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:
@@ -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)
|
||||
|
||||
@@ -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<EffectPtr>& effects() {
|
||||
static const std::vector<EffectPtr> registry = {
|
||||
std::make_shared<ScanlinesEffect>(),
|
||||
std::make_shared<BlurEffect>(),
|
||||
std::make_shared<GrainEffect>(),
|
||||
};
|
||||
return registry;
|
||||
}
|
||||
|
||||
+1
-25
@@ -5,6 +5,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
@@ -24,31 +25,6 @@ const std::vector<ParamSpec>& 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<std::size_t>(width) * 3;
|
||||
const int copyWidth = std::min(width, src.width() - sx);
|
||||
const std::size_t copyBytes = static_cast<std::size_t>(copyWidth) * 3;
|
||||
|
||||
parallelFor(static_cast<std::size_t>(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<int>(y) % std::max(1, src.height() - sy));
|
||||
const std::uint8_t* in = src.row(srcY) + static_cast<std::size_t>(sx) * 3;
|
||||
std::uint8_t* out = canvas.row(static_cast<int>(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.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
#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
|
||||
@@ -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<ParamSpec>& params() const override;
|
||||
bool needsSources() const override { return false; }
|
||||
|
||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#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<std::size_t>(width) * 3;
|
||||
const int copyWidth = std::min(width, src.width() - sx);
|
||||
const std::size_t copyBytes = static_cast<std::size_t>(copyWidth) * 3;
|
||||
|
||||
parallelFor(static_cast<std::size_t>(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<int>(y) % std::max(1, src.height() - sy));
|
||||
const std::uint8_t* in = src.row(srcY) + static_cast<std::size_t>(sx) * 3;
|
||||
std::uint8_t* out = canvas.row(static_cast<int>(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
|
||||
@@ -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)};
|
||||
|
||||
Reference in New Issue
Block a user