diff --git a/CMakeLists.txt b/CMakeLists.txt index 57540ef..75540c1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,6 +28,7 @@ add_library(effector_core STATIC src/core/registry.cpp src/core/rng.cpp src/effects/blur.cpp + src/effects/grain.cpp src/effects/scanlines.cpp ) target_include_directories(effector_core PUBLIC src) diff --git a/src/core/registry.cpp b/src/core/registry.cpp index b540896..03633d7 100644 --- a/src/core/registry.cpp +++ b/src/core/registry.cpp @@ -1,6 +1,7 @@ #include "core/registry.hpp" #include "effects/blur.hpp" +#include "effects/grain.hpp" #include "effects/scanlines.hpp" namespace effector { @@ -10,6 +11,7 @@ const std::vector& effects() { static const std::vector registry = { std::make_shared(), std::make_shared(), + std::make_shared(), }; return registry; } diff --git a/src/effects/blur.cpp b/src/effects/blur.cpp index e69f0ef..99f2f28 100644 --- a/src/effects/blur.cpp +++ b/src/effects/blur.cpp @@ -5,6 +5,7 @@ #include #include "core/parallel.hpp" +#include "effects/source_fill.hpp" namespace effector { @@ -24,31 +25,6 @@ const std::vector& BlurEffect::params() const { namespace { -// Copies `src` into the canvas starting at (sx, sy), repeating it as needed so -// that every pixel is covered even when the source is smaller than the canvas. -void fillFromSource(Image& canvas, const Image& src, int sx, int sy) { - const int width = canvas.width(); - const std::size_t total = static_cast(width) * 3; - const int copyWidth = std::min(width, src.width() - sx); - const std::size_t copyBytes = static_cast(copyWidth) * 3; - - parallelFor(static_cast(canvas.height()), [&](std::size_t begin, std::size_t end) { - for (std::size_t y = begin; y < end; ++y) { - const int srcY = sy + (static_cast(y) % std::max(1, src.height() - sy)); - const std::uint8_t* in = src.row(srcY) + static_cast(sx) * 3; - std::uint8_t* out = canvas.row(static_cast(y)); - - std::memcpy(out, in, copyBytes); - std::size_t filled = copyBytes; - while (filled < total) { - const std::size_t chunk = std::min(filled, total - filled); - std::memcpy(out + filled, out, chunk); - filled += chunk; - } - } - }); -} - // One separable box blur pass. `stride` is the distance in bytes between two // neighbouring samples along the blurred axis, `count` how many of them there // are; the window is clamped at both ends. diff --git a/src/effects/grain.cpp b/src/effects/grain.cpp new file mode 100644 index 0000000..0166557 --- /dev/null +++ b/src/effects/grain.cpp @@ -0,0 +1,90 @@ +#include "effects/grain.hpp" + +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/source_fill.hpp" + +namespace effector { + +const std::vector& GrainEffect::params() const { + static const std::vector specs = { + ParamSpec::makeDouble("grain_amount", "Amount", 0.35, 0.0, 1.0, + "How far a grain may push a pixel, as a fraction of full scale."), + ParamSpec::makeInt("grain_size", "Grain size", 1, 1, 32, + "Width in pixels of one grain; larger sizes look coarser."), + ParamSpec::makeBool("grain_colour", "Coloured grain", false, + "Draw the channels separately instead of one grey grain per pixel."), + ParamSpec::makeBool("grain_midtones", "Weight towards midtones", true, + "Fade the grain out in the shadows and highlights, as film does."), + }; + return specs; +} + +namespace { + +// Response curve: 1 at mid grey, falling to 0 at black and at white, which is +// roughly how grain behaves on film. +inline int midtoneWeight(int value) { return 255 - std::abs(2 * value - 255); } + +} // namespace + +Image GrainEffect::render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const { + if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); + + const double amount = std::clamp(paramDouble(values, "grain_amount", 0.35), 0.0, 1.0); + const int size = static_cast(std::max(1L, paramInt(values, "grain_size", 1))); + const bool colour = paramBool(values, "grain_colour", false); + const bool midtones = paramBool(values, "grain_midtones", true); + + Image canvas(width, height); + if (!sources.empty()) { + // Drawn before any threading, so that a seed always gives the same image. + const std::size_t index = rng.below(sources.size() - 1); + const Image& src = sources[index]; + const int sx = static_cast(rng.inRange(0, std::max(0, src.width() - width))); + const int sy = static_cast(rng.inRange(0, std::max(0, src.height() - height))); + fillFromSource(canvas, src, sx, sy); + } + + if (amount <= 0.0) return canvas; + + // The whole grain field is drawn serially from the rng, exactly like the + // scanlines plan, and only the pixel arithmetic afterwards is parallel. + const int cellsX = (width + size - 1) / size; + const int cellsY = (height + size - 1) / size; + const int channels = colour ? 3 : 1; + std::vector field(static_cast(cellsX) * cellsY * channels); + for (auto& cell : field) cell = static_cast(rng.inRange(-127, 127)); + + // 127 * 255 is the largest product of a grain and a midtone weight; scaling + // by `amount` there keeps the whole thing in integers. + const int scale = static_cast(amount * 255.0 * 65536.0 / 127.0); + + parallelFor(static_cast(height), [&](std::size_t begin, std::size_t end) { + for (std::size_t y = begin; y < end; ++y) { + std::uint8_t* out = canvas.row(static_cast(y)); + const std::size_t rowCell = + static_cast(static_cast(y) / size) * cellsX; + + for (int x = 0; x < width; ++x) { + const std::size_t cell = (rowCell + static_cast(x / size)) * channels; + for (int c = 0; c < 3; ++c) { + const int value = out[static_cast(x) * 3 + c]; + const int grain = field[cell + (colour ? static_cast(c) : 0)]; + const int weight = midtones ? midtoneWeight(value) : 255; + const int delta = grain * weight / 255 * scale / 65536; + out[static_cast(x) * 3 + c] = + static_cast(std::clamp(value + delta, 0, 255)); + } + } + } + }); + + return canvas; +} + +} // namespace effector diff --git a/src/effects/grain.hpp b/src/effects/grain.hpp new file mode 100644 index 0000000..3cf1826 --- /dev/null +++ b/src/effects/grain.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include "core/effect.hpp" + +namespace effector { + +// Scatters film-like grain over its input. Like the blur it needs no source +// images of its own, so it can lead a chain: given sources it fills the canvas +// from one of them first, given none it grains an empty canvas — which with the +// midtone weighting on stays black, exactly as an unexposed film would. +class GrainEffect : public Effect { +public: + std::string name() const override { return "grain"; } + std::string title() const override { return "Film grain"; } + std::string description() const override { + return "Scatters film-like grain over its input, optionally coloured and weighted towards " + "the midtones; with sources loaded it fills the canvas from a randomly chosen one " + "first."; + } + const std::vector& params() const override; + bool needsSources() const override { return false; } + + Image render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const override; +}; + +} // namespace effector diff --git a/src/effects/source_fill.hpp b/src/effects/source_fill.hpp new file mode 100644 index 0000000..9a96059 --- /dev/null +++ b/src/effects/source_fill.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +#include "core/image.hpp" +#include "core/parallel.hpp" + +namespace effector { + +// Copies `src` into the canvas starting at (sx, sy), repeating it as needed so +// that every pixel is covered even when the source is smaller than the canvas. +// Shared by the effects that do not build an image of their own but still want +// something to work on when they lead a chain. +inline void fillFromSource(Image& canvas, const Image& src, int sx, int sy) { + const int width = canvas.width(); + const std::size_t total = static_cast(width) * 3; + const int copyWidth = std::min(width, src.width() - sx); + const std::size_t copyBytes = static_cast(copyWidth) * 3; + + parallelFor(static_cast(canvas.height()), [&](std::size_t begin, std::size_t end) { + for (std::size_t y = begin; y < end; ++y) { + const int srcY = sy + (static_cast(y) % std::max(1, src.height() - sy)); + const std::uint8_t* in = src.row(srcY) + static_cast(sx) * 3; + std::uint8_t* out = canvas.row(static_cast(y)); + + std::memcpy(out, in, copyBytes); + std::size_t filled = copyBytes; + while (filled < total) { + const std::size_t chunk = std::min(filled, total - filled); + std::memcpy(out + filled, out, chunk); + filled += chunk; + } + } + }); +} + +} // namespace effector diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index dd1ce27..24f1b03 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -15,6 +15,7 @@ #include "core/registry.hpp" #include "core/rng.hpp" #include "effects/blur.hpp" +#include "effects/grain.hpp" #include "effects/scanlines.hpp" namespace { @@ -71,6 +72,22 @@ effector::Image renderBlur(const std::vector& sources, int widt return effect.render(sources, width, height, rng, merged); } +effector::Image renderGrain(const std::vector& sources, int width, int height, + std::uint64_t seed, const effector::ParamValues& values) { + effector::GrainEffect effect; + effector::Rng rng(seed); + auto merged = effect.defaults(); + for (const auto& [key, value] : values) merged[key] = value; + return effect.render(sources, width, height, rng, merged); +} + +// A flat field of one shade, to measure what the grain does to it. +effector::Image flat(int width, int height, std::uint8_t shade) { + effector::Image image(width, height); + std::fill(image.data(), image.data() + image.byteSize(), shade); + return image; +} + effector::Image render(const std::vector& sources, int width, int height, std::uint64_t seed, const effector::ParamValues& values) { effector::ScanlinesEffect effect; @@ -202,6 +219,74 @@ int main() { check(allBlack, "without sources blur renders an empty canvas"); } + std::cout << "grain\n"; + { + std::vector sources = {gradient(1200, 800, 10), gradient(640, 480, 90)}; + + const auto first = renderGrain(sources, 400, 300, 4, {}); + const auto again = renderGrain(sources, 400, 300, 4, {}); + const auto other = renderGrain(sources, 400, 300, 11, {}); + check(first.width() == 400 && first.height() == 300, "canvas has the requested size"); + check(sameBytes(first, again), "same seed reproduces the image"); + check(!sameBytes(first, other), "a different seed changes the image"); + + // Mid grey is where the grain is strongest, so it must move, and stay + // grey while the channels are drawn together. + std::vector grey = {flat(200, 100, 128)}; + const auto grained = renderGrain(grey, 200, 100, 3, {{"grain_amount", 0.5}}); + bool moved = false; + bool neutral = true; + for (int x = 0; x < 200; ++x) { + const auto* px = grained.row(50) + x * 3; + moved = moved || px[0] != 128; + neutral = neutral && px[0] == px[1] && px[1] == px[2]; + } + check(moved, "grain moves a flat mid grey"); + check(neutral, "monochrome grain keeps the channels equal"); + + const auto coloured = renderGrain(grey, 200, 100, 3, {{"grain_colour", true}}); + bool split = false; + for (int x = 0; x < 200; ++x) { + const auto* px = coloured.row(50) + x * 3; + split = split || px[0] != px[1] || px[1] != px[2]; + } + check(split, "coloured grain pulls the channels apart"); + + // With the midtone weighting on, black and white are left alone; with + // it off the grain lands everywhere. + const auto blackWeighted = renderGrain({flat(64, 64, 0)}, 64, 64, 2, {}); + bool untouched = true; + for (std::size_t i = 0; i < blackWeighted.byteSize(); ++i) { + untouched = untouched && blackWeighted.data()[i] == 0; + } + check(untouched, "the midtone weighting leaves black alone"); + + const auto blackFlat = + renderGrain({flat(64, 64, 0)}, 64, 64, 2, {{"grain_midtones", false}}); + bool lifted = false; + for (std::size_t i = 0; i < blackFlat.byteSize(); ++i) { + lifted = lifted || blackFlat.data()[i] != 0; + } + check(lifted, "without the weighting grain reaches the shadows"); + + // Zero amount is a pass through, and a grain wider than the canvas must + // still only read the one cell it has. + check(sameBytes(renderGrain({flat(32, 32, 128)}, 32, 32, 1, {{"grain_amount", 0.0}}), + flat(32, 32, 128)), + "zero amount leaves the image untouched"); + const auto coarse = renderGrain(grey, 40, 30, 7, {{"grain_size", 64L}}); + check(coarse.width() == 40 && coarse.height() == 30, + "a grain wider than the canvas is clamped"); + + // Like blur, grain needs no sources: with none it grains the empty + // canvas it starts from instead of refusing to run. + const auto blank = renderGrain({}, 100, 100, 1, {{"grain_midtones", false}}); + bool speckled = blank.width() == 100 && blank.height() == 100; + bool any = false; + for (std::size_t i = 0; i < blank.byteSize(); ++i) any = any || blank.data()[i] != 0; + check(speckled && any, "without sources grain works on an empty canvas"); + } + std::cout << "chain\n"; { std::vector sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};