From 370d3e550e93ec61009fe6f2470b309ecf749dc3 Mon Sep 17 00:00:00 2001 From: Si Date: Sat, 26 Sep 2026 20:32:49 +0200 Subject: [PATCH] Add Echo tunnel, a video effect that flies through Feedback's nested echoes Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ CMakeLists.txt | 1 + src/core/registry.cpp | 2 + src/effects/tunnel.cpp | 136 +++++++++++++++++++++++++++++++++++++++++ src/effects/tunnel.hpp | 30 +++++++++ tests/core_tests.cpp | 75 +++++++++++++++++++++++ 6 files changed, 249 insertions(+) create mode 100644 src/effects/tunnel.cpp create mode 100644 src/effects/tunnel.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 64f2af8..5ed1c01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 command line it is `-e tileswap` with `--tileswap-rate`, `--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 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 04398e3..c262070 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,6 +58,7 @@ add_library(effector_core STATIC src/effects/tiles.cpp src/effects/tileswap.cpp src/effects/tracking.cpp + src/effects/tunnel.cpp src/effects/video_effect.cpp src/effects/warp.cpp ) diff --git a/src/core/registry.cpp b/src/core/registry.cpp index 223b34d..f4147ac 100644 --- a/src/core/registry.cpp +++ b/src/core/registry.cpp @@ -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& effects() { // Video effects, offered only in a video project. std::make_shared(), std::make_shared(), + std::make_shared(), }; return registry; } diff --git a/src/effects/tunnel.cpp b/src/effects/tunnel.cpp new file mode 100644 index 0000000..2837634 --- /dev/null +++ b/src/effects/tunnel.cpp @@ -0,0 +1,136 @@ +#include "effects/tunnel.hpp" + +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/sampling.hpp" + +namespace effector { + +const std::vector& TunnelEffect::params() const { + static const std::vector 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(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(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 echoes; + echoes.reserve(static_cast(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(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(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(row)); + std::uint8_t* out = frame.row(static_cast(row)); + for (int col = 0; col < width; ++col) { + const std::size_t px = static_cast(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(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(c)] = static_cast(colour[c]); + } + } + } + }); + return frame; +} + +} // namespace effector diff --git a/src/effects/tunnel.hpp b/src/effects/tunnel.hpp new file mode 100644 index 0000000..106431b --- /dev/null +++ b/src/effects/tunnel.hpp @@ -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& params() const override; + +protected: + Image animate(Image input, const ParamValues& values, const Moment& at) const override; +}; + +} // namespace effector diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index f600848..a15d307 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -2095,6 +2095,81 @@ int main() { 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 = { + {"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 movingChain = {stage("tiles"), wildStage, stage("grain")}; + std::vector 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(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. effector::Project project; project.stages = {stage("tracking", wild)};