Files
effector/src/effects/grain.cpp
T
SiandClaude Opus 5 82e730221e 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>
2026-09-18 16:42:04 +02:00

91 lines
4.0 KiB
C++

#include "effects/grain.hpp"
#include <algorithm>
#include <cstdint>
#include <vector>
#include "core/parallel.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& GrainEffect::params() const {
static const std::vector<ParamSpec> 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<Image>& 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<int>(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<int>(rng.inRange(0, std::max(0, src.width() - width)));
const int sy = static_cast<int>(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<std::int8_t> field(static_cast<std::size_t>(cellsX) * cellsY * channels);
for (auto& cell : field) cell = static_cast<std::int8_t>(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<int>(amount * 255.0 * 65536.0 / 127.0);
parallelFor(static_cast<std::size_t>(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<int>(y));
const std::size_t rowCell =
static_cast<std::size_t>(static_cast<int>(y) / size) * cellsX;
for (int x = 0; x < width; ++x) {
const std::size_t cell = (rowCell + static_cast<std::size_t>(x / size)) * channels;
for (int c = 0; c < 3; ++c) {
const int value = out[static_cast<std::size_t>(x) * 3 + c];
const int grain = field[cell + (colour ? static_cast<std::size_t>(c) : 0)];
const int weight = midtones ? midtoneWeight(value) : 255;
const int delta = grain * weight / 255 * scale / 65536;
out[static_cast<std::size_t>(x) * 3 + c] =
static_cast<std::uint8_t>(std::clamp(value + delta, 0, 255));
}
}
}
});
return canvas;
}
} // namespace effector