From 0377cd4abb2dda5146e33d73fd13b7284ff1f96d Mon Sep 17 00:00:00 2001 From: Si Date: Sat, 26 Sep 2026 20:34:55 +0200 Subject: [PATCH] Add Warp flow, a video effect that sets Warp's noise field drifting Warp's noise field moves to effects/noise_field.hpp so both share it; Warp renders exactly as before. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + CMakeLists.txt | 1 + src/core/registry.cpp | 2 + src/effects/flow.cpp | 118 ++++++++++++++++++++++++++++++ src/effects/flow.hpp | 28 +++++++ src/effects/noise_field.hpp | 141 ++++++++++++++++++++++++++++++++++++ src/effects/warp.cpp | 105 +-------------------------- tests/core_tests.cpp | 1 + 8 files changed, 299 insertions(+), 101 deletions(-) create mode 100644 src/effects/flow.cpp create mode 100644 src/effects/flow.hpp create mode 100644 src/effects/noise_field.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ed1c01..8c09c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ commit that made it. The interface shows this file under Changelog in its menu. new echo fading in at the far end as the nearest melts into the picture. On the command line it is `-e tunnel` with `--tunnel-speed`, `--tunnel-scale`, `--tunnel-direction` and the rest. +- Warp flow, a video effect: the picture pushed or swirled through a field + of noise as Warp does, with the field drifting so the picture ripples like + heat haze or water. On the command line it is `-e flow` with + `--flow-strength`, `--flow-travel`, `--flow-speed` and the rest. - 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 c262070..3c73f47 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/flow.cpp src/effects/grain.cpp src/effects/monochrome.cpp src/effects/overlay.cpp diff --git a/src/core/registry.cpp b/src/core/registry.cpp index f4147ac..afcdc50 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/flow.hpp" #include "effects/grain.hpp" #include "effects/monochrome.hpp" #include "effects/overlay.hpp" @@ -43,6 +44,7 @@ const std::vector& effects() { std::make_shared(), std::make_shared(), std::make_shared(), + std::make_shared(), }; return registry; } diff --git a/src/effects/flow.cpp b/src/effects/flow.cpp new file mode 100644 index 0000000..c97aeb5 --- /dev/null +++ b/src/effects/flow.cpp @@ -0,0 +1,118 @@ +#include "effects/flow.hpp" + +#include +#include + +#include "core/parallel.hpp" +#include "effects/noise_field.hpp" +#include "effects/sampling.hpp" + +namespace effector { + +const std::vector& FlowEffect::params() const { + static const std::vector specs = { + ParamSpec::makeInt("flow_strength", "Strength", 30, 0, 4096, + "How far in pixels the field pushes the image at its strongest."), + ParamSpec::makeInt("flow_scale", "Scale", 160, 4, 8192, + "Size in pixels of the largest features in the field."), + ParamSpec::makeInt("flow_octaves", "Octaves", 3, 1, 6, + "Finer layers of noise laid over the first; more give a turbulent " + "edge to the flow."), + ParamSpec::makeChoice("flow_mode", "Mode", "displace", {"displace", "swirl"}, + "displace pushes along the field; swirl flows along its contour " + "lines, which curls the image into eddies."), + ParamSpec::makeInt("flow_travel", "Travel", 80, 0, 4096, + "Across how many pixels the field drifts in each cycle; the finer " + "layers drift half as far and turn faster."), + ParamSpec::makeDouble("flow_speed", "Speed", 0.25, 0.0, 10.0, + "Cycles per second, rounded so that the clip loops without a jump; " + "0 holds still.", + 0.05, 2), + }; + return specs; +} + +namespace { + +struct Settings { + double strength; + double scale; + int octaves; + bool swirl; + int travel; + double speed; + + explicit Settings(const ParamValues& values) + : strength(static_cast(std::max(0L, paramInt(values, "flow_strength", 30)))), + scale(static_cast(std::max(4L, paramInt(values, "flow_scale", 160)))), + octaves(static_cast(std::clamp(paramInt(values, "flow_octaves", 3), 1L, 6L))), + swirl(paramChoice(values, "flow_mode", "displace") == "swirl"), + travel(static_cast(std::max(0L, paramInt(values, "flow_travel", 80)))), + speed(std::max(0.0, paramDouble(values, "flow_speed", 0.25))) {} +}; + +// Which way round, and from where, one layer of a field drifts. +struct Orbit { + double start; // degrees + double turn; // +1 or -1 +}; + +} // namespace + +Image FlowEffect::animate(Image input, const ParamValues& values, const Moment& at) const { + const Settings settings(values); + const int width = input.width(); + const int height = input.height(); + if (settings.strength <= 0.0) return input; + + // What stays put for the whole clip: the field, drawn a circle's radius + // larger on every side than the picture, so it can drift without running + // out, and where each of its layers starts its circle, and which way. + const double reach = settings.travel * 0.5; + const int margin = static_cast(reach) + 2; + Rng steady = at.steady(); + const WarpField field(steady, width + 2 * margin, height + 2 * margin, settings.scale, + settings.octaves, settings.swirl); + std::vector orbits(static_cast(settings.octaves) * 2); + for (Orbit& orbit : orbits) { + orbit.start = unitDouble(steady.next()) * 360.0; + orbit.turn = (steady.next() >> 63) != 0 ? 1.0 : -1.0; + } + + // Where this frame is in time: layer i has gone i + 1 times round a + // circle half as wide as the one before in a whole cycle. + const double t = settings.speed > 0.0 ? at.cycle(settings.speed) : 0.0; + std::vector firstShifts(static_cast(settings.octaves)); + std::vector secondShifts(static_cast(settings.octaves)); + double radius = reach; + for (int i = 0; i < settings.octaves; ++i) { + for (int f = 0; f < 2; ++f) { + const Orbit& orbit = orbits[static_cast(i * 2 + f)]; + double sine = 0.0; + double cosine = 1.0; + sinCosDegrees(orbit.start + orbit.turn * (i + 1) * 360.0 * t, sine, cosine); + NoiseShift& shift = (f == 0 ? firstShifts : secondShifts)[static_cast(i)]; + shift.x = margin + radius * cosine; + shift.y = margin + radius * sine; + } + radius *= 0.5; + } + + Image frame(width, height); + parallelFor(static_cast(height), [&](std::size_t begin, std::size_t end) { + for (std::size_t row = begin; row < end; ++row) { + std::uint8_t* out = frame.row(static_cast(row)); + const double y = static_cast(row); + for (int col = 0; col < width; ++col) { + const double x = col; + double dx = 0.0; + double dy = 0.0; + field.push(x, y, settings.strength, dx, dy, &firstShifts, &secondShifts); + sampleBilinear(input, x + dx, y + dy, out + static_cast(col) * 3); + } + } + }); + return frame; +} + +} // namespace effector diff --git a/src/effects/flow.hpp b/src/effects/flow.hpp new file mode 100644 index 0000000..5a95b82 --- /dev/null +++ b/src/effects/flow.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "effects/video_effect.hpp" + +namespace effector { + +// The warp effect in motion: the same field of noise pushes or swirls the +// picture, and its layers drift, each round a circle of its own, so the +// picture ripples like heat haze or water under wind. +// +// Every layer of the field goes round its circle a whole number of times in a +// cycle -- the finer layers smaller and faster -- so the field, and with it +// the clip, comes back to where it started without a jump. +class FlowEffect : public VideoEffect { +public: + std::string name() const override { return "flow"; } + std::string title() const override { return "Warp flow"; } + std::string description() const override { + return "Pushes the picture through a field of noise, as Warp does, and sets the field " + "drifting so the picture ripples."; + } + 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/noise_field.hpp b/src/effects/noise_field.hpp new file mode 100644 index 0000000..c090094 --- /dev/null +++ b/src/effects/noise_field.hpp @@ -0,0 +1,141 @@ +#pragma once + +#include +#include + +#include "core/rng.hpp" +#include "effects/sampling.hpp" + +namespace effector { + +// One layer of value noise: random heights on a lattice `spacing` pixels +// apart, blended smoothly between the lattice points. Positions are read from +// (0, 0) to the width and height it was built for. +class NoiseLayer { +public: + NoiseLayer(Rng& rng, int width, int height, double spacing) + : spacing_(spacing), + cols_(static_cast(width / spacing) + 2), + rows_(static_cast(height / spacing) + 2), + lattice_(static_cast(cols_) * rows_) { + for (auto& value : lattice_) value = unitDouble(rng.next()) * 2.0 - 1.0; + } + + // The height at (x, y) in [-1, 1], and its slope per pixel along x and y. + void sample(double x, double y, double& value, double& slopeX, double& slopeY) const { + const double gx = x / spacing_; + const double gy = y / spacing_; + const int ix = std::min(static_cast(gx), cols_ - 2); + const int iy = std::min(static_cast(gy), rows_ - 2); + const double fx = gx - ix; + const double fy = gy - iy; + const double sx = fx * fx * (3.0 - 2.0 * fx); + const double sy = fy * fy * (3.0 - 2.0 * fy); + + const double a = at(ix, iy); + const double b = at(ix + 1, iy); + const double c = at(ix, iy + 1); + const double d = at(ix + 1, iy + 1); + const double cross = a - b - c + d; + value = a + (b - a) * sx + (c - a) * sy + cross * sx * sy; + slopeX = 6.0 * fx * (1.0 - fx) * ((b - a) + cross * sy) / spacing_; + slopeY = 6.0 * fy * (1.0 - fy) * ((c - a) + cross * sx) / spacing_; + } + +private: + double at(int x, int y) const { return lattice_[static_cast(y) * cols_ + x]; } + + double spacing_; + int cols_; + int rows_; + std::vector lattice_; +}; + +// Where one layer of a field is read from, against where it is asked for. +struct NoiseShift { + double x = 0.0; + double y = 0.0; +}; + +// Octaves of noise summed, each twice as fine and half as strong as the one +// before, and scaled back so the total stays in [-1, 1]. +class NoiseField { +public: + NoiseField(Rng& rng, int width, int height, double scale, int octaves) { + double spacing = scale; + double weight = 1.0; + for (int i = 0; i < octaves; ++i) { + layers_.emplace_back(rng, width, height, std::max(1.0, spacing)); + weights_.push_back(weight); + total_ += weight; + spacing *= 0.5; + weight *= 0.5; + } + } + + // As above, with each layer read that far off from (x, y), one shift per + // octave -- which is how a field is set moving: its layers slide apart. + void sample(double x, double y, double& value, double& slopeX, double& slopeY, + const std::vector* shifts = nullptr) const { + value = slopeX = slopeY = 0.0; + for (std::size_t i = 0; i < layers_.size(); ++i) { + double v = 0.0, sx = 0.0, sy = 0.0; + if (shifts != nullptr) { + layers_[i].sample(x + (*shifts)[i].x, y + (*shifts)[i].y, v, sx, sy); + } else { + layers_[i].sample(x, y, v, sx, sy); + } + value += v * weights_[i]; + slopeX += sx * weights_[i]; + slopeY += sy * weights_[i]; + } + value /= total_; + slopeX /= total_; + slopeY /= total_; + } + +private: + std::vector layers_; + std::vector weights_; + double total_ = 0.0; +}; + +// The push the warp effects give a pixel: one field for a swirl, which flows +// along its contours, two for a push along x and y. The fields are drawn in +// full, in that order, when this is built. +class WarpField { +public: + WarpField(Rng& rng, int width, int height, double scale, int octaves, bool swirl) + : first_(rng, width, height, scale, octaves), scale_(scale), swirl_(swirl) { + if (!swirl) second_.emplace_back(rng, width, height, scale, octaves); + } + + // How far the pixel at (x, y) is pushed, at `strength` pixels at most; + // `shifts` (one list per field) slides the fields' layers as NoiseField does. + void push(double x, double y, double strength, double& dx, double& dy, + const std::vector* firstShifts = nullptr, + const std::vector* secondShifts = nullptr) const { + double value = 0.0, slopeX = 0.0, slopeY = 0.0; + first_.sample(x, y, value, slopeX, slopeY, firstShifts); + if (swirl_) { + // Along the contour: the slope turned a quarter, which never piles + // pixels up or tears them apart. The slope of a field `scale` wide + // is about 1/scale, hence the factor. + dx = slopeY * scale_ * strength * 0.5; + dy = -slopeX * scale_ * strength * 0.5; + } else { + double other = 0.0; + second_.front().sample(x, y, other, slopeX, slopeY, secondShifts); + dx = value * strength; + dy = other * strength; + } + } + +private: + NoiseField first_; + std::vector second_; + double scale_; + bool swirl_; +}; + +} // namespace effector diff --git a/src/effects/warp.cpp b/src/effects/warp.cpp index fb3a606..3214b9f 100644 --- a/src/effects/warp.cpp +++ b/src/effects/warp.cpp @@ -5,6 +5,7 @@ #include #include "core/parallel.hpp" +#include "effects/noise_field.hpp" #include "effects/sampling.hpp" #include "effects/source_fill.hpp" @@ -26,88 +27,6 @@ const std::vector& WarpEffect::params() const { return specs; } -namespace { - -// One layer of value noise: random heights on a lattice `spacing` pixels -// apart, blended smoothly between the lattice points. -class NoiseLayer { -public: - NoiseLayer(Rng& rng, int width, int height, double spacing) - : spacing_(spacing), - cols_(static_cast(width / spacing) + 2), - rows_(static_cast(height / spacing) + 2), - lattice_(static_cast(cols_) * rows_) { - for (auto& value : lattice_) value = unitDouble(rng.next()) * 2.0 - 1.0; - } - - // The height at (x, y) in [-1, 1], and its slope per pixel along x and y. - void sample(double x, double y, double& value, double& slopeX, double& slopeY) const { - const double gx = x / spacing_; - const double gy = y / spacing_; - const int ix = std::min(static_cast(gx), cols_ - 2); - const int iy = std::min(static_cast(gy), rows_ - 2); - const double fx = gx - ix; - const double fy = gy - iy; - const double sx = fx * fx * (3.0 - 2.0 * fx); - const double sy = fy * fy * (3.0 - 2.0 * fy); - - const double a = at(ix, iy); - const double b = at(ix + 1, iy); - const double c = at(ix, iy + 1); - const double d = at(ix + 1, iy + 1); - const double cross = a - b - c + d; - value = a + (b - a) * sx + (c - a) * sy + cross * sx * sy; - slopeX = 6.0 * fx * (1.0 - fx) * ((b - a) + cross * sy) / spacing_; - slopeY = 6.0 * fy * (1.0 - fy) * ((c - a) + cross * sx) / spacing_; - } - -private: - double at(int x, int y) const { return lattice_[static_cast(y) * cols_ + x]; } - - double spacing_; - int cols_; - int rows_; - std::vector lattice_; -}; - -// Octaves of noise summed, each twice as fine and half as strong as the one -// before, and scaled back so the total stays in [-1, 1]. -class NoiseField { -public: - NoiseField(Rng& rng, int width, int height, double scale, int octaves) { - double spacing = scale; - double weight = 1.0; - for (int i = 0; i < octaves; ++i) { - layers_.emplace_back(rng, width, height, std::max(1.0, spacing)); - weights_.push_back(weight); - total_ += weight; - spacing *= 0.5; - weight *= 0.5; - } - } - - void sample(double x, double y, double& value, double& slopeX, double& slopeY) const { - value = slopeX = slopeY = 0.0; - for (std::size_t i = 0; i < layers_.size(); ++i) { - double v = 0.0, sx = 0.0, sy = 0.0; - layers_[i].sample(x, y, v, sx, sy); - value += v * weights_[i]; - slopeX += sx * weights_[i]; - slopeY += sy * weights_[i]; - } - value /= total_; - slopeX /= total_; - slopeY /= total_; - } - -private: - std::vector layers_; - std::vector weights_; - double total_ = 0.0; -}; - -} // namespace - Image WarpEffect::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."); @@ -119,11 +38,8 @@ Image WarpEffect::render(const std::vector& sources, int width, int heigh const Image input = canvasFromSources(sources, width, height, rng); - // Lattices are drawn in full before any pixel moves: one field for the - // swirl, which flows along its contours, two for a push along x and y. - const NoiseField first(rng, width, height, scale, octaves); - std::vector extra; - if (!swirl) extra.emplace_back(rng, width, height, scale, octaves); + // The lattices are drawn in full before any pixel moves. + const WarpField field(rng, width, height, scale, octaves, swirl); Image canvas(width, height); parallelFor(static_cast(height), [&](std::size_t begin, std::size_t end) { @@ -132,22 +48,9 @@ Image WarpEffect::render(const std::vector& sources, int width, int heigh const double y = static_cast(row); for (int col = 0; col < width; ++col) { const double x = col; - double value = 0.0, slopeX = 0.0, slopeY = 0.0; - first.sample(x, y, value, slopeX, slopeY); double dx = 0.0; double dy = 0.0; - if (swirl) { - // Along the contour: the slope turned a quarter, which - // never piles pixels up or tears them apart. The slope of - // a field `scale` wide is about 1/scale, hence the factor. - dx = slopeY * scale * strength * 0.5; - dy = -slopeX * scale * strength * 0.5; - } else { - double other = 0.0; - extra.front().sample(x, y, other, slopeX, slopeY); - dx = value * strength; - dy = other * strength; - } + field.push(x, y, strength, dx, dy); sampleBilinear(input, x + dx, y + dy, out + static_cast(col) * 3); } } diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index a15d307..9955f6d 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -2107,6 +2107,7 @@ int main() { }; const std::vector moving = { {"tunnel", {{"tunnel_drift", 6L}, {"tunnel_fade", 1.0}}, {}, {{"tunnel_fade", 0.0}}}, + {"flow", {{"flow_mode", std::string("swirl")}}, {}, {{"flow_strength", 0L}}}, }; for (const auto& [name, lively, smooth, off] : moving) { const auto effect = effectByName(name);