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 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-26 20:34:55 +02:00
co-authored by Claude Opus 5.5
parent 370d3e550e
commit 0377cd4abb
8 changed files with 299 additions and 101 deletions
+4
View File
@@ -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. 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`, On the command line it is `-e tunnel` with `--tunnel-speed`,
`--tunnel-scale`, `--tunnel-direction` and the rest. `--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 - 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 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 command line it is `--video-quality small|standard|high`. Standard writes
+1
View File
@@ -47,6 +47,7 @@ add_library(effector_core STATIC
src/effects/channelsplit.cpp src/effects/channelsplit.cpp
src/effects/corrupt.cpp src/effects/corrupt.cpp
src/effects/feedback.cpp src/effects/feedback.cpp
src/effects/flow.cpp
src/effects/grain.cpp src/effects/grain.cpp
src/effects/monochrome.cpp src/effects/monochrome.cpp
src/effects/overlay.cpp src/effects/overlay.cpp
+2
View File
@@ -5,6 +5,7 @@
#include "effects/channelsplit.hpp" #include "effects/channelsplit.hpp"
#include "effects/corrupt.hpp" #include "effects/corrupt.hpp"
#include "effects/feedback.hpp" #include "effects/feedback.hpp"
#include "effects/flow.hpp"
#include "effects/grain.hpp" #include "effects/grain.hpp"
#include "effects/monochrome.hpp" #include "effects/monochrome.hpp"
#include "effects/overlay.hpp" #include "effects/overlay.hpp"
@@ -43,6 +44,7 @@ const std::vector<EffectPtr>& effects() {
std::make_shared<TrackingEffect>(), std::make_shared<TrackingEffect>(),
std::make_shared<TileSwapEffect>(), std::make_shared<TileSwapEffect>(),
std::make_shared<TunnelEffect>(), std::make_shared<TunnelEffect>(),
std::make_shared<FlowEffect>(),
}; };
return registry; return registry;
} }
+118
View File
@@ -0,0 +1,118 @@
#include "effects/flow.hpp"
#include <algorithm>
#include <vector>
#include "core/parallel.hpp"
#include "effects/noise_field.hpp"
#include "effects/sampling.hpp"
namespace effector {
const std::vector<ParamSpec>& FlowEffect::params() const {
static const std::vector<ParamSpec> 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<double>(std::max(0L, paramInt(values, "flow_strength", 30)))),
scale(static_cast<double>(std::max(4L, paramInt(values, "flow_scale", 160)))),
octaves(static_cast<int>(std::clamp(paramInt(values, "flow_octaves", 3), 1L, 6L))),
swirl(paramChoice(values, "flow_mode", "displace") == "swirl"),
travel(static_cast<int>(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<int>(reach) + 2;
Rng steady = at.steady();
const WarpField field(steady, width + 2 * margin, height + 2 * margin, settings.scale,
settings.octaves, settings.swirl);
std::vector<Orbit> orbits(static_cast<std::size_t>(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<NoiseShift> firstShifts(static_cast<std::size_t>(settings.octaves));
std::vector<NoiseShift> secondShifts(static_cast<std::size_t>(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<std::size_t>(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<std::size_t>(i)];
shift.x = margin + radius * cosine;
shift.y = margin + radius * sine;
}
radius *= 0.5;
}
Image frame(width, height);
parallelFor(static_cast<std::size_t>(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<int>(row));
const double y = static_cast<double>(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<std::size_t>(col) * 3);
}
}
});
return frame;
}
} // namespace effector
+28
View File
@@ -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<ParamSpec>& params() const override;
protected:
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
};
} // namespace effector
+141
View File
@@ -0,0 +1,141 @@
#pragma once
#include <algorithm>
#include <vector>
#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<int>(width / spacing) + 2),
rows_(static_cast<int>(height / spacing) + 2),
lattice_(static_cast<std::size_t>(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<int>(gx), cols_ - 2);
const int iy = std::min(static_cast<int>(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<std::size_t>(y) * cols_ + x]; }
double spacing_;
int cols_;
int rows_;
std::vector<double> 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<NoiseShift>* 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<NoiseLayer> layers_;
std::vector<double> 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<NoiseShift>* firstShifts = nullptr,
const std::vector<NoiseShift>* 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<NoiseField> second_;
double scale_;
bool swirl_;
};
} // namespace effector
+4 -101
View File
@@ -5,6 +5,7 @@
#include <vector> #include <vector>
#include "core/parallel.hpp" #include "core/parallel.hpp"
#include "effects/noise_field.hpp"
#include "effects/sampling.hpp" #include "effects/sampling.hpp"
#include "effects/source_fill.hpp" #include "effects/source_fill.hpp"
@@ -26,88 +27,6 @@ const std::vector<ParamSpec>& WarpEffect::params() const {
return specs; 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<int>(width / spacing) + 2),
rows_(static_cast<int>(height / spacing) + 2),
lattice_(static_cast<std::size_t>(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<int>(gx), cols_ - 2);
const int iy = std::min(static_cast<int>(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<std::size_t>(y) * cols_ + x]; }
double spacing_;
int cols_;
int rows_;
std::vector<double> 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<NoiseLayer> layers_;
std::vector<double> weights_;
double total_ = 0.0;
};
} // namespace
Image WarpEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng, Image WarpEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const { const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); 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<Image>& sources, int width, int heigh
const Image input = canvasFromSources(sources, width, height, rng); const Image input = canvasFromSources(sources, width, height, rng);
// Lattices are drawn in full before any pixel moves: one field for the // The lattices are drawn in full before any pixel moves.
// swirl, which flows along its contours, two for a push along x and y. const WarpField field(rng, width, height, scale, octaves, swirl);
const NoiseField first(rng, width, height, scale, octaves);
std::vector<NoiseField> extra;
if (!swirl) extra.emplace_back(rng, width, height, scale, octaves);
Image canvas(width, height); Image canvas(width, height);
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) { parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
@@ -132,22 +48,9 @@ Image WarpEffect::render(const std::vector<Image>& sources, int width, int heigh
const double y = static_cast<double>(row); const double y = static_cast<double>(row);
for (int col = 0; col < width; ++col) { for (int col = 0; col < width; ++col) {
const double x = 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 dx = 0.0;
double dy = 0.0; double dy = 0.0;
if (swirl) { field.push(x, y, strength, dx, dy);
// 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;
}
sampleBilinear(input, x + dx, y + dy, out + static_cast<std::size_t>(col) * 3); sampleBilinear(input, x + dx, y + dy, out + static_cast<std::size_t>(col) * 3);
} }
} }
+1
View File
@@ -2107,6 +2107,7 @@ int main() {
}; };
const std::vector<Moving> moving = { const std::vector<Moving> moving = {
{"tunnel", {{"tunnel_drift", 6L}, {"tunnel_fade", 1.0}}, {}, {{"tunnel_fade", 0.0}}}, {"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) { for (const auto& [name, lively, smooth, off] : moving) {
const auto effect = effectByName(name); const auto effect = effectByName(name);