diff --git a/CHANGELOG.md b/CHANGELOG.md index b7055e6..03f1bfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,11 @@ commit that made it. The interface shows this file under Changelog in its menu. and back, so the streaks spread over one part of the picture after another. On the command line it is `-e sweep` with `--sweep-from`, `--sweep-to`, `--sweep-width`, `--sweep-speed` and the rest. +- Film gate, a video effect: film running through a projector, with grain + drawn anew on every frame as Film grain draws it, the picture swaying in + the gate, flicker, dust and scratches. On the command line it is + `-e filmgate` with `--gate-grain`, `--gate-weave`, `--gate-flicker`, + `--gate-dust` and `--gate-scratches`. - A Quality choice under Video on Setup: Small file, Standard or High, for how the saved video is encoded. It is kept in the project, and on the command line it is `--video-quality small|standard|high`. Standard writes diff --git a/CMakeLists.txt b/CMakeLists.txt index 66d8394..f59b935 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,6 +47,7 @@ add_library(effector_core STATIC src/effects/channelsplit.cpp src/effects/corrupt.cpp src/effects/feedback.cpp + src/effects/filmgate.cpp src/effects/flow.cpp src/effects/grain.cpp src/effects/monochrome.cpp diff --git a/src/core/registry.cpp b/src/core/registry.cpp index beb285d..b5ab07c 100644 --- a/src/core/registry.cpp +++ b/src/core/registry.cpp @@ -5,6 +5,7 @@ #include "effects/channelsplit.hpp" #include "effects/corrupt.hpp" #include "effects/feedback.hpp" +#include "effects/filmgate.hpp" #include "effects/flow.hpp" #include "effects/grain.hpp" #include "effects/monochrome.hpp" @@ -47,6 +48,7 @@ const std::vector& effects() { std::make_shared(), std::make_shared(), std::make_shared(), + std::make_shared(), }; return registry; } diff --git a/src/effects/filmgate.cpp b/src/effects/filmgate.cpp new file mode 100644 index 0000000..91c33b9 --- /dev/null +++ b/src/effects/filmgate.cpp @@ -0,0 +1,168 @@ +#include "effects/filmgate.hpp" + +#include +#include +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/grain_field.hpp" +#include "effects/sampling.hpp" + +namespace effector { + +const std::vector& FilmGateEffect::params() const { + static const std::vector specs = { + ParamSpec::makeDouble("gate_grain", "Grain", 0.3, 0.0, 1.0, + "How far a grain may push a pixel, as a fraction of full scale; " + "the grain is new on every frame."), + ParamSpec::makeInt("gate_grain_size", "Grain size", 2, 1, 32, + "Width in pixels of one grain; larger sizes look coarser."), + ParamSpec::makeInt("gate_weave", "Weave", 2, 0, 64, + "Furthest in pixels the picture sways sideways in the gate, and half " + "that up and down."), + ParamSpec::makeDouble("gate_flicker", "Flicker", 0.06, 0.0, 1.0, + "How far the brightness may jump from one frame to the next, as a " + "fraction of itself."), + ParamSpec::makeInt("gate_dust", "Dust", 3, 0, 500, + "Specks of dust on each frame, every one there for that frame alone."), + ParamSpec::makeInt("gate_scratches", "Scratches", 1, 0, 16, + "Thin bright lines running down the film, wandering a little and " + "now and then missing a frame."), + }; + return specs; +} + +namespace { + +struct Settings { + double grain; + int grainSize; + int weave; + double flicker; + int dust; + int scratches; + + explicit Settings(const ParamValues& values) + : grain(std::clamp(paramDouble(values, "gate_grain", 0.3), 0.0, 1.0)), + grainSize(static_cast(std::max(1L, paramInt(values, "gate_grain_size", 2)))), + weave(static_cast(std::max(0L, paramInt(values, "gate_weave", 2)))), + flicker(std::clamp(paramDouble(values, "gate_flicker", 0.06), 0.0, 1.0)), + dust(static_cast(std::max(0L, paramInt(values, "gate_dust", 3)))), + scratches(static_cast(std::max(0L, paramInt(values, "gate_scratches", 1)))) {} +}; + +double unit(Rng& rng) { return unitDouble(rng.next()); } + +// The swings the sway is made of: roughly how often each comes round a +// second, and how much of the sway it carries. +constexpr std::array kSwingRate = {0.7, 1.9, 4.3}; +constexpr std::array kSwingShare = {0.5, 0.3, 0.2}; + +// Blends a pixel towards `shade` by `mix` out of 256. +void towards(std::uint8_t* px, int shade, int mix) { + for (int c = 0; c < 3; ++c) { + px[c] = static_cast(px[c] + (shade - px[c]) * mix / 256); + } +} + +} // namespace + +Image FilmGateEffect::animate(Image input, const ParamValues& values, const Moment& at) const { + const Settings settings(values); + const int width = input.width(); + const int height = input.height(); + constexpr double tau = 2.0 * std::numbers::pi; + + // What stays put for the whole clip: where each swing of the sway + // starts, and where each scratch runs down the film. + Rng steady = at.steady(); + std::array phaseX{}; + std::array phaseY{}; + for (std::size_t i = 0; i < kSwingRate.size(); ++i) { + phaseX[i] = unit(steady) * tau; + phaseY[i] = unit(steady) * tau; + } + std::vector scratchAt(static_cast(settings.scratches)); + for (double& x : scratchAt) x = unit(steady); + + // Where this frame is in time: how far the picture has swayed. + double swayX = 0.0; + double swayY = 0.0; + for (std::size_t i = 0; i < kSwingRate.size(); ++i) { + const double turn = at.cycle(kSwingRate[i]) * tau; + swayX += kSwingShare[i] * std::sin(turn + phaseX[i]); + swayY += kSwingShare[i] * std::sin(turn + phaseY[i]); + } + const int dx = static_cast(std::lround(swayX * settings.weave)); + const int dy = static_cast(std::lround(swayY * settings.weave * 0.5)); + + // What changes on every frame, all drawn before a pixel is touched: the + // flicker, the grain, the dust, and which scratches show and where. + Rng fresh = at.fresh(); + const double flicker = settings.flicker * (unit(fresh) * 2.0 - 1.0); + const int gain = static_cast(std::lround((1.0 + flicker) * 256.0)); + GrainField grain; + if (settings.grain > 0.0) grain = drawGrain(fresh, width, height, settings.grainSize, false); + struct Speck { + int x; + int y; + int radius; + }; + std::vector specks(static_cast(settings.dust)); + for (Speck& speck : specks) { + speck.x = static_cast(fresh.inRange(0, width - 1)); + speck.y = static_cast(fresh.inRange(0, height - 1)); + speck.radius = static_cast(fresh.inRange(0, 2)); + } + std::vector scratchX; + for (const double x : scratchAt) { + const int wander = static_cast(fresh.inRange(-2, 2)); + if (unit(fresh) < 0.85) scratchX.push_back(static_cast(x * (width - 1)) + wander); + } + + // The picture moved in the gate, the edges it leaves held from the last + // row and column, and lit up or down by the flicker. + Image frame(width, height); + parallelFor(static_cast(height), [&](std::size_t begin, std::size_t end) { + for (std::size_t y = begin; y < end; ++y) { + const int sy = std::clamp(static_cast(y) - dy, 0, height - 1); + const std::uint8_t* in = input.row(sy); + std::uint8_t* out = frame.row(static_cast(y)); + for (int x = 0; x < width; ++x) { + const int sx = std::clamp(x - dx, 0, width - 1); + const std::uint8_t* from = in + static_cast(sx) * 3; + std::uint8_t* to = out + static_cast(x) * 3; + for (int c = 0; c < 3; ++c) { + to[c] = static_cast(std::min(255, from[c] * gain >> 8)); + } + } + } + }); + if (settings.grain > 0.0) applyGrain(frame, grain, settings.grain, true); + + // Dust lies dark on the picture; a scratch through the emulsion lets the + // light through. + for (const Speck& speck : specks) { + for (int y = speck.y - speck.radius; y <= speck.y + speck.radius; ++y) { + if (y < 0 || y >= height) continue; + for (int x = speck.x - speck.radius; x <= speck.x + speck.radius; ++x) { + if (x < 0 || x >= width) continue; + const int ox = x - speck.x; + const int oy = y - speck.y; + if (ox * ox + oy * oy > speck.radius * speck.radius) continue; + towards(frame.row(y) + static_cast(x) * 3, 16, 220); + } + } + } + for (const int x : scratchX) { + if (x < 0 || x >= width) continue; + for (int y = 0; y < height; ++y) { + towards(frame.row(y) + static_cast(x) * 3, 255, 150); + } + } + return frame; +} + +} // namespace effector diff --git a/src/effects/filmgate.hpp b/src/effects/filmgate.hpp new file mode 100644 index 0000000..f2a0e3c --- /dev/null +++ b/src/effects/filmgate.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "effects/video_effect.hpp" + +namespace effector { + +// Film running through a projector: the grain is new on every frame, the +// picture sways a little in the gate and its brightness flickers, and dust +// and scratches pass over it. +// +// The sway is a sum of slow swings, each a whole number of times in the +// clip, so it loops; grain, flicker and dust are drawn from at.fresh() and +// are different on every frame, as they are on film. +class FilmGateEffect : public VideoEffect { +public: + std::string name() const override { return "filmgate"; } + std::string title() const override { return "Film gate"; } + std::string description() const override { + return "Film running through a projector: grain new on every frame, as Film grain draws " + "it, a picture swaying in the gate, flicker, dust and scratches."; + } + const std::vector& params() const override; + +protected: + Image animate(Image input, const ParamValues& values, const Moment& at) const override; +}; + +} // namespace effector diff --git a/src/effects/grain.cpp b/src/effects/grain.cpp index 0166557..43015cc 100644 --- a/src/effects/grain.cpp +++ b/src/effects/grain.cpp @@ -4,7 +4,7 @@ #include #include -#include "core/parallel.hpp" +#include "effects/grain_field.hpp" #include "effects/source_fill.hpp" namespace effector { @@ -23,14 +23,6 @@ const std::vector& GrainEffect::params() const { 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."); @@ -54,35 +46,8 @@ Image GrainEffect::render(const std::vector& sources, int width, int heig // 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)); - } - } - } - }); + const GrainField field = drawGrain(rng, width, height, size, colour); + applyGrain(canvas, field, amount, midtones); return canvas; } diff --git a/src/effects/grain_field.hpp b/src/effects/grain_field.hpp new file mode 100644 index 0000000..bfba513 --- /dev/null +++ b/src/effects/grain_field.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include +#include + +#include "core/image.hpp" +#include "core/parallel.hpp" +#include "core/rng.hpp" + +namespace effector { + +// A field of grain over a canvas: one value in [-127, 127] per cell of +// `size` pixels square, per channel when the grain is coloured. Shared by the +// film grain and the film gate effects. +struct GrainField { + int size = 1; + int cellsX = 0; + bool colour = false; + std::vector cells; +}; + +// Draws the whole field serially from the rng, so that only the pixel +// arithmetic that applies it runs in parallel. +inline GrainField drawGrain(Rng& rng, int width, int height, int size, bool colour) { + GrainField field; + field.size = size; + field.colour = colour; + field.cellsX = (width + size - 1) / size; + const int cellsY = (height + size - 1) / size; + field.cells.resize(static_cast(field.cellsX) * cellsY * (colour ? 3 : 1)); + for (auto& cell : field.cells) cell = static_cast(rng.inRange(-127, 127)); + return field; +} + +// 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); } + +// Pushes every pixel by its grain, at most `amount` of full scale, and less in +// the shadows and highlights when `midtones` is set. +inline void applyGrain(Image& canvas, const GrainField& field, double amount, bool midtones) { + const int width = canvas.width(); + const int channels = field.colour ? 3 : 1; + // 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(canvas.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) / field.size) * field.cellsX; + + for (int x = 0; x < width; ++x) { + const std::size_t cell = + (rowCell + static_cast(x / field.size)) * channels; + for (int c = 0; c < 3; ++c) { + const int value = out[static_cast(x) * 3 + c]; + const std::size_t plane = field.colour ? static_cast(c) : 0; + const int grain = field.cells[cell + plane]; + 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)); + } + } + } + }); +} + +} // namespace effector diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index 9382b23..ac58fe5 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -2110,6 +2110,11 @@ int main() { {"flow", {{"flow_mode", std::string("swirl")}}, {}, {{"flow_strength", 0L}}}, {"sweep", {{"sweep_reverse", true}, {"sweep_max_run", 20L}}, {}, {{"sweep_line_chance", 0.0}}}, + {"filmgate", {{"gate_weave", 6L}, {"gate_dust", 20L}, {"gate_scratches", 3L}}, + {{"gate_weave", 6L}, {"gate_grain", 0.0}, {"gate_flicker", 0.0}, {"gate_dust", 0L}, + {"gate_scratches", 0L}}, + {{"gate_weave", 0L}, {"gate_grain", 0.0}, {"gate_flicker", 0.0}, {"gate_dust", 0L}, + {"gate_scratches", 0L}}}, }; for (const auto& [name, lively, smooth, off] : moving) { const auto effect = effectByName(name);