Add a film grain effect

Grain is the third effect and the second that declares needsSources()
false, so it works as a filter stage in a cascade and can also lead one,
filling the canvas from a source the way blur does. Its knobs are the
amount a grain may push a pixel, the grain size in pixels, whether the
channels are drawn apart into coloured grain, and whether the response is
weighted towards the midtones — which it is by default, since film grains
least where it is least exposed. That last one means grain over an empty
canvas stays black unless the weighting is turned off.

Determinism comes from the same shape the scanlines effect uses: the
whole grain field is drawn serially from the Rng into one cell per grain
before any threading, and only the integer pixel arithmetic afterwards
runs under parallelFor.

The canvas fill blur used to keep to itself moves to
effects/source_fill.hpp unchanged, now that two effects want it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-18 16:42:04 +02:00
co-authored by Claude Opus 5
parent b6b5f94f08
commit 82e730221e
7 changed files with 244 additions and 25 deletions
+85
View File
@@ -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<effector::Image>& sources, int widt
return effect.render(sources, width, height, rng, merged);
}
effector::Image renderGrain(const std::vector<effector::Image>& 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<effector::Image>& 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<Image> 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<Image> 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<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};