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
+2
View File
@@ -16,6 +16,7 @@
#include "effects/tiles.hpp"
#include "effects/tileswap.hpp"
#include "effects/tracking.hpp"
#include "effects/tunnel.hpp"
#include "effects/warp.hpp"
namespace effector {
@@ -41,6 +42,7 @@ const std::vector<EffectPtr>& effects() {
// Video effects, offered only in a video project.
std::make_shared<TrackingEffect>(),
std::make_shared<TileSwapEffect>(),
std::make_shared<TunnelEffect>(),
};
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