Add Echo tunnel, a video effect that flies through Feedback's nested echoes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-26 20:32:49 +02:00
co-authored by Claude Opus 5.5
parent 8537a3cf0a
commit 370d3e550e
6 changed files with 249 additions and 0 deletions
+5
View File
@@ -12,6 +12,11 @@ commit that made it. The interface shows this file under Changelog in its menu.
moment of its own, so the mosaic keeps changing piece by piece. On the moment of its own, so the mosaic keeps changing piece by piece. On the
command line it is `-e tileswap` with `--tileswap-rate`, command line it is `-e tileswap` with `--tileswap-rate`,
`--tileswap-layout` and the rest. `--tileswap-layout` and the rest.
- Echo tunnel, a video effect: the picture nested inside itself as Feedback
nests it, flying into the echoes (or backing out of them) without end, a
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.
- 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
@@ -58,6 +58,7 @@ add_library(effector_core STATIC
src/effects/tiles.cpp src/effects/tiles.cpp
src/effects/tileswap.cpp src/effects/tileswap.cpp
src/effects/tracking.cpp src/effects/tracking.cpp
src/effects/tunnel.cpp
src/effects/video_effect.cpp src/effects/video_effect.cpp
src/effects/warp.cpp src/effects/warp.cpp
) )
+2
View File
@@ -16,6 +16,7 @@
#include "effects/tiles.hpp" #include "effects/tiles.hpp"
#include "effects/tileswap.hpp" #include "effects/tileswap.hpp"
#include "effects/tracking.hpp" #include "effects/tracking.hpp"
#include "effects/tunnel.hpp"
#include "effects/warp.hpp" #include "effects/warp.hpp"
namespace effector { namespace effector {
@@ -41,6 +42,7 @@ const std::vector<EffectPtr>& effects() {
// Video effects, offered only in a video project. // Video effects, offered only in a video project.
std::make_shared<TrackingEffect>(), std::make_shared<TrackingEffect>(),
std::make_shared<TileSwapEffect>(), std::make_shared<TileSwapEffect>(),
std::make_shared<TunnelEffect>(),
}; };
return registry; return registry;
} }
+136
View File
@@ -0,0 +1,136 @@
#include "effects/tunnel.hpp"
#include <algorithm>
#include <cmath>
#include <vector>
#include "core/parallel.hpp"
#include "effects/sampling.hpp"
namespace effector {
const std::vector<ParamSpec>& TunnelEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeInt("tunnel_count", "Echoes", 8, 2, 32,
"How many echoes are nested inside the picture at once."),
ParamSpec::makeDouble("tunnel_scale", "Scale", 0.8, 0.3, 0.95,
"Size of each echo against the one around it.", 0.01),
ParamSpec::makeDouble("tunnel_rotate", "Rotation", 6.0, -180.0, 180.0,
"Turn in degrees of each echo against the one around it.", 0.5, 1),
ParamSpec::makeInt("tunnel_drift", "Drift", 0, 0, 4096,
"How far in pixels each echo sits off the centre of the one around "
"it, all in one direction of the seed's own."),
ParamSpec::makeDouble("tunnel_fade", "Opacity", 0.8, 0.0, 1.0,
"How strongly each echo covers what lies beneath it."),
ParamSpec::makeDouble("tunnel_speed", "Speed", 0.5, 0.0, 10.0,
"Echoes passed per second, rounded so that the clip loops without "
"a jump; 0 holds still.",
0.1, 1),
ParamSpec::makeChoice("tunnel_direction", "Direction", "in", {"in", "out"},
"in flies into the tunnel, the echoes growing towards you; out "
"backs away from it, the echoes shrinking into the distance."),
};
return specs;
}
namespace {
struct Settings {
int count;
double scale;
double rotate;
int drift;
double fade;
double speed;
bool out;
explicit Settings(const ParamValues& values)
: count(static_cast<int>(std::clamp(paramInt(values, "tunnel_count", 8), 2L, 32L))),
scale(std::clamp(paramDouble(values, "tunnel_scale", 0.8), 0.3, 0.95)),
rotate(std::clamp(paramDouble(values, "tunnel_rotate", 6.0), -180.0, 180.0)),
drift(static_cast<int>(std::max(0L, paramInt(values, "tunnel_drift", 0)))),
fade(std::clamp(paramDouble(values, "tunnel_fade", 0.8), 0.0, 1.0)),
speed(std::max(0.0, paramDouble(values, "tunnel_speed", 0.5))),
out(paramChoice(values, "tunnel_direction", "in") == "out") {}
};
// One echo as it lies in this frame: how a pixel of the canvas finds the
// pixel of the input it shows, and how strongly it covers what is beneath.
struct Echo {
double sine;
double cosine;
double shrink; // 1 / size
double dx;
double dy;
int mix; // out of 256
};
} // namespace
Image TunnelEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
const Settings settings(values);
const int width = input.width();
const int height = input.height();
// What stays put for the whole clip: the direction the echoes drift in.
Rng steady = at.steady();
double driftSine = 0.0;
double driftCosine = 1.0;
sinCosDegrees(unitDouble(steady.next()) * 360.0, driftSine, driftCosine);
// Where this frame is in time: how far each echo has moved on from its
// place at the start of the step. Echo k sits at depth k + 1 - t flying
// in, k + t backing out; at depth 0 it is the picture itself, so it can
// melt into it, and a new one fades in at the far end as the old leaves.
const double t = settings.speed > 0.0 ? at.cycle(settings.speed) : 0.0;
std::vector<Echo> echoes;
echoes.reserve(static_cast<std::size_t>(settings.count));
for (int k = 0; k < settings.count; ++k) {
const double depth = settings.out ? k + t : k + 1.0 - t;
const double weight = settings.fade * std::clamp(depth, 0.0, 1.0) *
std::clamp(settings.count - depth, 0.0, 1.0);
const int mix = static_cast<int>(weight * 256.0 + 0.5);
if (mix <= 0) continue;
Echo echo{};
sinCosDegrees(settings.rotate * depth, echo.sine, echo.cosine);
echo.shrink = 1.0 / std::pow(settings.scale, depth);
echo.dx = settings.drift * depth * driftCosine;
echo.dy = settings.drift * depth * driftSine;
echo.mix = mix;
echoes.push_back(echo);
}
if (echoes.empty()) return input;
// The echoes are laid largest first, so the deeper ones nest inside.
const double cx = (width - 1) * 0.5;
const double cy = (height - 1) * 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) {
const std::uint8_t* base = input.row(static_cast<int>(row));
std::uint8_t* out = frame.row(static_cast<int>(row));
for (int col = 0; col < width; ++col) {
const std::size_t px = static_cast<std::size_t>(col) * 3;
int colour[3] = {base[px], base[px + 1], base[px + 2]};
for (const Echo& echo : echoes) {
const double rx = col - cx - echo.dx;
const double ry = static_cast<double>(row) - cy - echo.dy;
const double sx = cx + (rx * echo.cosine + ry * echo.sine) * echo.shrink;
const double sy = cy + (-rx * echo.sine + ry * echo.cosine) * echo.shrink;
if (sx < 0.0 || sy < 0.0 || sx > width - 1 || sy > height - 1) continue;
std::uint8_t echoed[3];
sampleBilinear(input, sx, sy, echoed);
for (int c = 0; c < 3; ++c) {
colour[c] += (echoed[c] - colour[c]) * echo.mix / 256;
}
}
for (int c = 0; c < 3; ++c) {
out[px + static_cast<std::size_t>(c)] = static_cast<std::uint8_t>(colour[c]);
}
}
}
});
return frame;
}
} // namespace effector
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include "effects/video_effect.hpp"
namespace effector {
// The feedback effect in motion: the picture nested inside itself, each copy
// smaller, turned and drifted further than the one around it, and the whole
// stack flying inwards (or outwards) without end.
//
// A copy sits at a depth, and its size, turn and drift follow from that depth
// alone. Every frame moves each copy on by part of one step, so after a whole
// cycle each copy lies exactly where its neighbour started: a new one fades
// in at the far end while the nearest fades out into the picture itself, and
// the clip loops without a jump.
class TunnelEffect : public VideoEffect {
public:
std::string name() const override { return "tunnel"; }
std::string title() const override { return "Echo tunnel"; }
std::string description() const override {
return "Nests the picture inside itself, as Feedback does, and flies through the echoes "
"without end.";
}
const std::vector<ParamSpec>& params() const override;
protected:
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
};
} // namespace effector
+75
View File
@@ -2095,6 +2095,81 @@ int main() {
check(alone, "a tile swap clip's frame is exactly that frame rendered on its own"); check(alone, "a tile swap clip's frame is exactly that frame rendered on its own");
} }
// The video transforms made from image effects. Each gets a setting
// that moves plenty, one without its per-frame noise (which, drawn
// anew on every frame, is the one thing that does not loop), and one
// that switches it off.
struct Moving {
std::string name;
effector::ParamValues lively;
effector::ParamValues smooth;
effector::ParamValues off;
};
const std::vector<Moving> moving = {
{"tunnel", {{"tunnel_drift", 6L}, {"tunnel_fade", 1.0}}, {}, {{"tunnel_fade", 0.0}}},
};
for (const auto& [name, lively, smooth, off] : moving) {
const auto effect = effectByName(name);
auto with = [&](const effector::ParamValues& changes) {
ChainStage built = stage(name);
for (const auto& [key, value] : changes) built.values[key] = value;
return built;
};
auto frameOf = [&](const ChainStage& at, int index) {
effector::Rng rng(6);
return renderChain({at}, input, 96, 64, rng, {}, FrameTime{index, 8, 8});
};
const ChainStage wildStage = with(lively);
check(effect->video() && !effect->needsSources(),
name + " is a video effect that needs no sources");
ChainStage scanStage = stage("scanlines");
effector::Rng withIt(3);
effector::Rng withoutIt(3);
check(sameBytes(renderChain({scanStage, wildStage}, sources, 80, 48, withIt),
renderChain({scanStage}, sources, 80, 48, withoutIt)),
"a still skips " + name + " as though it were not there");
effector::Rng stateA(9);
effector::Rng stateB(9);
effect->renderFrame(sources, 80, 48, stateA, wildStage.values, FrameTime{3, 8, 8});
effect->renderFrame(sources, 80, 48, stateB, wildStage.values, FrameTime{5, 8, 8});
check(stateA.next() == stateB.next(), name + " leaves the shared rng in one state");
bool changes = false;
for (int index = 1; index < 8; ++index) {
changes = changes || !sameBytes(frameOf(wildStage, 0), frameOf(wildStage, index));
}
check(changes, name + " moves over the clip");
const ChainStage smoothStage = with(smooth);
check(sameBytes(frameOf(smoothStage, 0), frameOf(smoothStage, 8)),
name + " loops: the frame after the last is the first again");
check(sameBytes(frameOf(with(off), 5), input[0]),
name + " switched off leaves its input alone");
effector::Rng sliverA(2);
effector::Rng sliverB(2);
check(renderChain({wildStage}, input, 1, 57, sliverA, {}, FrameTime{3, 8, 8}).height() == 57 &&
renderChain({wildStage}, input, 57, 1, sliverB, {}, FrameTime{3, 8, 8}).width() == 57,
name + " copes with a sliver of a canvas");
const std::vector<ChainStage> movingChain = {stage("tiles"), wildStage, stage("grain")};
std::vector<effector::Image> clip;
effector::renderFrames(movingChain, sources, 80, 48, 21, {}, video,
[&](int, effector::Image&& frame) {
clip.push_back(std::move(frame));
return true;
});
bool exactFrames = clip.size() == 8;
for (const int index : {0, 5, 7}) {
effector::Rng rng(21);
exactFrames = exactFrames &&
sameBytes(effector::renderChain(movingChain, sources, 80, 48, rng, {},
FrameTime{index, 8, 8}),
clip[static_cast<std::size_t>(index)]);
}
check(exactFrames, "a " + name + " clip's frame is exactly that frame rendered on its own");
}
// The project file: a video says so, a still says nothing about time. // The project file: a video says so, a still says nothing about time.
effector::Project project; effector::Project project;
project.stages = {stage("tracking", wild)}; project.stages = {stage("tracking", wild)};