From ce3a422f6a8a30068573c98ab78b39722be2a88e Mon Sep 17 00:00:00 2001 From: Si Date: Wed, 23 Sep 2026 20:36:09 +0200 Subject: [PATCH] Add eight effects that throw something chaotic into the chain Slit shift, channel split, pixel sort, corruption, shatter, warp, bit crush and feedback: tearing bands sideways, knocking the colour planes out of register, sorting runs of pixels, damaging blocks the way a broken stream does, breaking the picture into shards, pushing it along a noise field, mangling its bytes and laying it over itself again and again. All eight are pure transforms. None needs sources, so any of them can lead a chain, and like blur they start from a crop of a random source when they do and photographs are loaded. canvasFromSources holds that start once for them. Each draws its whole plan from the rng before touching a pixel, and pixel sort hands every line a seed of its own, so the parallel work never decides anything. The rotations in shatter and feedback take their sine and cosine from a short series of plain multiplications rather than libm, so a seed lands on the same pixels whatever the machine's maths library makes of std::sin. The tests hold every one of them to the same things -- a seed reproduces it, it runs without sources and as a later stage, a sliver of a canvas does not trip it, turned right down it leaves its input alone -- plus a check of what each actually does, and the registry now checks that no two effects declare the same param key. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 8 ++ src/core/registry.cpp | 16 ++++ src/effects/bitcrush.cpp | 129 +++++++++++++++++++++++++ src/effects/bitcrush.hpp | 26 +++++ src/effects/channelsplit.cpp | 97 +++++++++++++++++++ src/effects/channelsplit.hpp | 26 +++++ src/effects/corrupt.cpp | 143 ++++++++++++++++++++++++++++ src/effects/corrupt.hpp | 27 ++++++ src/effects/feedback.cpp | 103 ++++++++++++++++++++ src/effects/feedback.hpp | 27 ++++++ src/effects/pixelsort.cpp | 151 +++++++++++++++++++++++++++++ src/effects/pixelsort.hpp | 26 +++++ src/effects/sampling.hpp | 69 ++++++++++++++ src/effects/shatter.cpp | 178 +++++++++++++++++++++++++++++++++++ src/effects/shatter.hpp | 27 ++++++ src/effects/slitshift.cpp | 99 +++++++++++++++++++ src/effects/slitshift.hpp | 26 +++++ src/effects/source_fill.hpp | 18 ++++ src/effects/warp.cpp | 159 +++++++++++++++++++++++++++++++ src/effects/warp.hpp | 27 ++++++ tests/core_tests.cpp | 166 ++++++++++++++++++++++++++++++++ 21 files changed, 1548 insertions(+) create mode 100644 src/effects/bitcrush.cpp create mode 100644 src/effects/bitcrush.hpp create mode 100644 src/effects/channelsplit.cpp create mode 100644 src/effects/channelsplit.hpp create mode 100644 src/effects/corrupt.cpp create mode 100644 src/effects/corrupt.hpp create mode 100644 src/effects/feedback.cpp create mode 100644 src/effects/feedback.hpp create mode 100644 src/effects/pixelsort.cpp create mode 100644 src/effects/pixelsort.hpp create mode 100644 src/effects/sampling.hpp create mode 100644 src/effects/shatter.cpp create mode 100644 src/effects/shatter.hpp create mode 100644 src/effects/slitshift.cpp create mode 100644 src/effects/slitshift.hpp create mode 100644 src/effects/warp.cpp create mode 100644 src/effects/warp.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a6f3eec..52b821a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,13 +32,21 @@ add_library(effector_core STATIC src/core/registry.cpp src/core/rng.cpp src/core/style.cpp + src/effects/bitcrush.cpp src/effects/blur.cpp + src/effects/channelsplit.cpp + src/effects/corrupt.cpp + src/effects/feedback.cpp src/effects/grain.cpp src/effects/monochrome.cpp src/effects/overlay.cpp + src/effects/pixelsort.cpp src/effects/scanlines.cpp + src/effects/shatter.cpp + src/effects/slitshift.cpp src/effects/text.cpp src/effects/tiles.cpp + src/effects/warp.cpp ) target_include_directories(effector_core PUBLIC src) # The core is GUI-free: it only needs gdk-pixbuf for decoding and encoding, diff --git a/src/core/registry.cpp b/src/core/registry.cpp index e107fcf..c473306 100644 --- a/src/core/registry.cpp +++ b/src/core/registry.cpp @@ -1,12 +1,20 @@ #include "core/registry.hpp" +#include "effects/bitcrush.hpp" #include "effects/blur.hpp" +#include "effects/channelsplit.hpp" +#include "effects/corrupt.hpp" +#include "effects/feedback.hpp" #include "effects/grain.hpp" #include "effects/monochrome.hpp" #include "effects/overlay.hpp" +#include "effects/pixelsort.hpp" #include "effects/scanlines.hpp" +#include "effects/shatter.hpp" +#include "effects/slitshift.hpp" #include "effects/text.hpp" #include "effects/tiles.hpp" +#include "effects/warp.hpp" namespace effector { @@ -20,6 +28,14 @@ const std::vector& effects() { std::make_shared(), std::make_shared(), std::make_shared(), + std::make_shared(), + std::make_shared(), + std::make_shared(), + std::make_shared(), + std::make_shared(), + std::make_shared(), + std::make_shared(), + std::make_shared(), }; return registry; } diff --git a/src/effects/bitcrush.cpp b/src/effects/bitcrush.cpp new file mode 100644 index 0000000..7236b7c --- /dev/null +++ b/src/effects/bitcrush.cpp @@ -0,0 +1,129 @@ +#include "effects/bitcrush.hpp" + +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/sampling.hpp" +#include "effects/source_fill.hpp" + +namespace effector { + +const std::vector& BitCrushEffect::params() const { + static const std::vector specs = { + ParamSpec::makeChoice("crush_op", "Operation", "mixed", + {"mixed", "posterize", "xor", "bitrotate", "channel_swap"}, + "posterize drops the low bits, xor flips random low bits, " + "bitrotate turns each byte's bits round, channel_swap shuffles " + "red, green and blue; mixed picks one for every band."), + ParamSpec::makeInt("crush_bits", "Bits", 3, 1, 7, + "How many bits the operation reaches: dropped by posterize, flipped " + "at most by xor, turned at most by bitrotate."), + ParamSpec::makeDouble("crush_coverage", "Coverage", 0.5, 0.0, 1.0, + "Chance of each band being crushed."), + ParamSpec::makeInt("crush_band_height", "Band height", 32, 1, 4096, + "Thickness in pixels of each band."), + }; + return specs; +} + +namespace { + +enum class Op : std::uint8_t { None, Posterize, Xor, Rotate, Swap }; + +struct Band { + Op op = Op::None; + int rotate = 0; + std::array mask{}; + std::array order{0, 1, 2}; +}; + +Op opNamed(const std::string& name) { + if (name == "posterize") return Op::Posterize; + if (name == "xor") return Op::Xor; + if (name == "bitrotate") return Op::Rotate; + if (name == "channel_swap") return Op::Swap; + return Op::None; // mixed: drawn per band +} + +} // namespace + +Image BitCrushEffect::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."); + + const Op fixed = opNamed(paramChoice(values, "crush_op", "mixed")); + const int bits = static_cast(std::clamp(paramInt(values, "crush_bits", 3), 1L, 7L)); + const double coverage = std::clamp(paramDouble(values, "crush_coverage", 0.5), 0.0, 1.0); + const int bandHeight = static_cast(std::max(1L, paramInt(values, "crush_band_height", 32))); + + Image canvas = canvasFromSources(sources, width, height, rng); + + const int bandCount = (height + bandHeight - 1) / bandHeight; + std::vector plan(static_cast(bandCount)); + for (auto& band : plan) { + if (!chance(rng.next(), coverage)) continue; + band.op = fixed != Op::None ? fixed : static_cast(1 + rng.below(3)); + switch (band.op) { + case Op::Xor: + for (auto& mask : band.mask) { + mask = static_cast(rng.inRange(1, (1L << bits) - 1)); + } + break; + case Op::Rotate: + band.rotate = static_cast(rng.inRange(1, bits)); + break; + case Op::Swap: + // Fisher-Yates, never leaving the order as it was. + do { + band.order = {0, 1, 2}; + for (int i = 2; i > 0; --i) { + std::swap(band.order[static_cast(i)], + band.order[rng.below(static_cast(i))]); + } + } while (band.order[0] == 0 && band.order[1] == 1); + break; + default: + break; + } + } + + const std::uint8_t keep = static_cast(0xFF << bits); + + parallelFor(static_cast(height), [&](std::size_t begin, std::size_t end) { + for (std::size_t y = begin; y < end; ++y) { + const Band& band = plan[y / static_cast(bandHeight)]; + if (band.op == Op::None) continue; + std::uint8_t* row = canvas.row(static_cast(y)); + for (int x = 0; x < width; ++x) { + std::uint8_t* px = row + static_cast(x) * 3; + switch (band.op) { + case Op::Posterize: + for (int c = 0; c < 3; ++c) px[c] &= keep; + break; + case Op::Xor: + for (int c = 0; c < 3; ++c) px[c] ^= band.mask[static_cast(c)]; + break; + case Op::Rotate: + for (int c = 0; c < 3; ++c) { + px[c] = static_cast((px[c] << band.rotate) | + (px[c] >> (8 - band.rotate))); + } + break; + case Op::Swap: { + const std::uint8_t in[3] = {px[0], px[1], px[2]}; + for (int c = 0; c < 3; ++c) px[c] = in[band.order[static_cast(c)]]; + break; + } + case Op::None: + break; + } + } + } + }); + + return canvas; +} + +} // namespace effector diff --git a/src/effects/bitcrush.hpp b/src/effects/bitcrush.hpp new file mode 100644 index 0000000..327d8e3 --- /dev/null +++ b/src/effects/bitcrush.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "core/effect.hpp" + +namespace effector { + +// Mangles the bytes of its input band by band: posterising, xoring with a +// random mask, rotating the bits or shuffling the channels. +// A pure transform: it needs no source images, and leading a chain with +// sources loaded it starts from a crop of a randomly chosen one. +class BitCrushEffect : public Effect { +public: + std::string name() const override { return "bitcrush"; } + std::string title() const override { return "Bit crush"; } + std::string description() const override { + return "Mangles the bytes of random bands: posterises them, xors them with a random " + "mask, rotates their bits or shuffles their channels."; + } + const std::vector& params() const override; + bool needsSources() const override { return false; } + + Image render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const override; +}; + +} // namespace effector diff --git a/src/effects/channelsplit.cpp b/src/effects/channelsplit.cpp new file mode 100644 index 0000000..802018e --- /dev/null +++ b/src/effects/channelsplit.cpp @@ -0,0 +1,97 @@ +#include "effects/channelsplit.hpp" + +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/source_fill.hpp" + +namespace effector { + +const std::vector& ChannelSplitEffect::params() const { + static const std::vector specs = { + ParamSpec::makeInt("split_amount", "Amount", 12, 0, 1024, + "Furthest in pixels a channel may be moved, along either axis."), + ParamSpec::makeChoice("split_mode", "Mode", "global", {"global", "per_band"}, + "global moves each channel once over the whole image; per_band " + "draws new offsets for every band."), + ParamSpec::makeInt("split_band_height", "Band height", 24, 1, 2048, + "Thickness in pixels of each band; per_band mode only."), + ParamSpec::makeChoice("split_channels", "Channels", "rgb", {"rgb", "red_cyan", "random"}, + "rgb moves all three channels apart; red_cyan moves red one way and " + "green and blue the other, as 3D glasses would; random moves one " + "channel picked at random."), + }; + return specs; +} + +namespace { + +struct Offset { + int dx = 0; + int dy = 0; +}; + +using ChannelOffsets = std::array; + +ChannelOffsets drawOffsets(Rng& rng, int amount, const std::string& channels) { + ChannelOffsets offsets{}; + auto draw = [&] { + return Offset{static_cast(rng.inRange(-amount, amount)), + static_cast(rng.inRange(-amount, amount))}; + }; + if (channels == "red_cyan") { + const Offset red = draw(); + offsets[0] = red; + offsets[1] = offsets[2] = Offset{-red.dx, -red.dy}; + } else if (channels == "random") { + offsets[rng.below(2)] = draw(); + } else { + for (auto& offset : offsets) offset = draw(); + } + return offsets; +} + +} // namespace + +Image ChannelSplitEffect::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."); + + const int amount = static_cast(std::max(0L, paramInt(values, "split_amount", 12))); + const bool perBand = paramChoice(values, "split_mode", "global") == "per_band"; + const int bandHeight = static_cast(std::max(1L, paramInt(values, "split_band_height", 24))); + const std::string channels = paramChoice(values, "split_channels", "rgb"); + + const Image input = canvasFromSources(sources, width, height, rng); + + // One set of offsets per band, a single band covering everything in global + // mode; all of them drawn before the pixels are touched. + const int band = perBand ? bandHeight : height; + const int bandCount = (height + band - 1) / band; + std::vector plan; + plan.reserve(static_cast(bandCount)); + for (int i = 0; i < bandCount; ++i) plan.push_back(drawOffsets(rng, amount, channels)); + + Image canvas(width, height); + parallelFor(static_cast(height), [&](std::size_t begin, std::size_t end) { + for (std::size_t y = begin; y < end; ++y) { + const ChannelOffsets& offsets = plan[y / static_cast(band)]; + std::uint8_t* out = canvas.row(static_cast(y)); + for (int c = 0; c < 3; ++c) { + const Offset& offset = offsets[static_cast(c)]; + const int sy = std::clamp(static_cast(y) - offset.dy, 0, height - 1); + const std::uint8_t* in = input.row(sy); + for (int x = 0; x < width; ++x) { + const int sx = std::clamp(x - offset.dx, 0, width - 1); + out[static_cast(x) * 3 + c] = in[static_cast(sx) * 3 + c]; + } + } + } + }); + + return canvas; +} + +} // namespace effector diff --git a/src/effects/channelsplit.hpp b/src/effects/channelsplit.hpp new file mode 100644 index 0000000..12537e8 --- /dev/null +++ b/src/effects/channelsplit.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "core/effect.hpp" + +namespace effector { + +// Knocks the red, green and blue planes out of register, either the whole image +// at once or band by band, each band with offsets of its own. +// A pure transform: it needs no source images, and leading a chain with +// sources loaded it starts from a crop of a randomly chosen one. +class ChannelSplitEffect : public Effect { +public: + std::string name() const override { return "channelsplit"; } + std::string title() const override { return "Channel split"; } + std::string description() const override { + return "Knocks the red, green and blue planes out of register, over the whole image or " + "band by band."; + } + const std::vector& params() const override; + bool needsSources() const override { return false; } + + Image render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const override; +}; + +} // namespace effector diff --git a/src/effects/corrupt.cpp b/src/effects/corrupt.cpp new file mode 100644 index 0000000..c240815 --- /dev/null +++ b/src/effects/corrupt.cpp @@ -0,0 +1,143 @@ +#include "effects/corrupt.hpp" + +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/sampling.hpp" +#include "effects/source_fill.hpp" + +namespace effector { + +const std::vector& CorruptEffect::params() const { + static const std::vector specs = { + ParamSpec::makeInt("corrupt_block", "Block size", 16, 2, 256, + "Width and height in pixels of one block, as a codec would cut them."), + ParamSpec::makeDouble("corrupt_rate", "Rate", 0.08, 0.0, 1.0, + "Chance of each block being damaged."), + ParamSpec::makeChoice("corrupt_kinds", "Damage", "mixed", + {"mixed", "repeat", "displace", "colour"}, + "repeat freezes a block into a copy of the one to its left, " + "displace pulls one in from elsewhere in the image, colour knocks " + "it off colour; mixed picks one of them for every block."), + ParamSpec::makeBool("corrupt_bleed", "Bleed", false, + "Carry each damaged block's fault on to the end of its row, the way " + "one broken bit spoils the rest of a stream."), + }; + return specs; +} + +namespace { + +enum class Damage : std::uint8_t { None, Repeat, Displace, Colour }; + +struct Block { + Damage damage = Damage::None; + int sx = 0; // displace: where the block is read from + int sy = 0; + int shift[3] = {0, 0, 0}; // colour: added to each channel +}; + +} // namespace + +Image CorruptEffect::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."); + + const int size = static_cast(std::max(2L, paramInt(values, "corrupt_block", 16))); + const double rate = std::clamp(paramDouble(values, "corrupt_rate", 0.08), 0.0, 1.0); + const std::string kinds = paramChoice(values, "corrupt_kinds", "mixed"); + const bool bleed = paramBool(values, "corrupt_bleed", false); + + const Image input = canvasFromSources(sources, width, height, rng); + + const int cols = (width + size - 1) / size; + const int rows = (height + size - 1) / size; + + auto drawBlock = [&](int bx) { + Block block; + if (kinds == "repeat") block.damage = Damage::Repeat; + else if (kinds == "displace") block.damage = Damage::Displace; + else if (kinds == "colour") block.damage = Damage::Colour; + else block.damage = static_cast(1 + rng.below(2)); + // The first block of a row has nothing to its left to repeat. + if (block.damage == Damage::Repeat && bx == 0) block.damage = Damage::Displace; + if (block.damage == Damage::Displace) { + block.sx = static_cast(rng.inRange(0, std::max(0, width - size))); + block.sy = static_cast(rng.inRange(0, std::max(0, height - size))); + } else if (block.damage == Damage::Colour) { + for (int& shift : block.shift) shift = static_cast(rng.inRange(-128, 128)); + } + return block; + }; + + // The whole damage map is drawn up front, block by block in reading order. + std::vector plan(static_cast(cols) * rows); + for (int by = 0; by < rows; ++by) { + const Block* carried = nullptr; + for (int bx = 0; bx < cols; ++bx) { + Block& block = plan[static_cast(by) * cols + bx]; + if (carried != nullptr) { + block = *carried; + // A displaced run keeps moving along with the row, so the + // stretch reads as one shifted strip rather than one block + // over and over. + if (block.damage == Damage::Displace) block.sx += size; + carried = █ + continue; + } + if (!chance(rng.next(), rate)) continue; + block = drawBlock(bx); + if (bleed) carried = █ + } + } + + Image canvas = input; + // Block rows are independent (a repeat only ever reads to its left), so + // they run in parallel and each walks its row in order. + parallelFor(static_cast(rows), [&](std::size_t begin, std::size_t end) { + for (std::size_t by = begin; by < end; ++by) { + const int y0 = static_cast(by) * size; + const int h = std::min(size, height - y0); + for (int bx = 0; bx < cols; ++bx) { + const Block& block = plan[by * static_cast(cols) + bx]; + if (block.damage == Damage::None) continue; + const int x0 = bx * size; + const int w = std::min(size, width - x0); + const std::size_t bytes = static_cast(w) * 3; + + for (int line = 0; line < h; ++line) { + std::uint8_t* out = canvas.row(y0 + line) + static_cast(x0) * 3; + switch (block.damage) { + case Damage::Repeat: + std::memmove(out, out - static_cast(size) * 3, bytes); + break; + case Damage::Displace: { + const int sy = std::min(block.sy + line, height - 1); + const std::uint8_t* in = input.row(sy); + for (int x = 0; x < w; ++x) { + const int sx = (block.sx + x) % width; + std::memcpy(out + static_cast(x) * 3, + in + static_cast(sx) * 3, 3); + } + break; + } + case Damage::Colour: + for (std::size_t i = 0; i < bytes; ++i) { + out[i] = static_cast( + std::clamp(out[i] + block.shift[i % 3], 0, 255)); + } + break; + case Damage::None: + break; + } + } + } + } + }); + + return canvas; +} + +} // namespace effector diff --git a/src/effects/corrupt.hpp b/src/effects/corrupt.hpp new file mode 100644 index 0000000..ef360a0 --- /dev/null +++ b/src/effects/corrupt.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include "core/effect.hpp" + +namespace effector { + +// Imitates a damaged video stream: blocks of the image are frozen into a copy of +// their neighbour, pulled in from elsewhere or knocked off colour, and with +// bleeding switched on the damage runs on to the end of the row. +// A pure transform: it needs no source images, and leading a chain with +// sources loaded it starts from a crop of a randomly chosen one. +class CorruptEffect : public Effect { +public: + std::string name() const override { return "corrupt"; } + std::string title() const override { return "Corruption"; } + std::string description() const override { + return "Imitates a damaged video stream: random blocks are smeared, displaced or knocked " + "off colour, optionally bleeding on to the end of their row."; + } + const std::vector& params() const override; + bool needsSources() const override { return false; } + + Image render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const override; +}; + +} // namespace effector diff --git a/src/effects/feedback.cpp b/src/effects/feedback.cpp new file mode 100644 index 0000000..1aba251 --- /dev/null +++ b/src/effects/feedback.cpp @@ -0,0 +1,103 @@ +#include "effects/feedback.hpp" + +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/sampling.hpp" +#include "effects/source_fill.hpp" + +namespace effector { + +const std::vector& FeedbackEffect::params() const { + static const std::vector specs = { + ParamSpec::makeInt("echo_count", "Echoes", 6, 1, 64, + "How many times the image is laid back over itself."), + ParamSpec::makeDouble("echo_scale", "Scale", 0.85, 0.1, 2.0, + "Size of each echo against the one before; below 1 the echoes " + "tunnel inwards, above 1 they burst outwards.", + 0.01), + ParamSpec::makeDouble("echo_rotate", "Rotation", 4.0, -180.0, 180.0, + "Turn in degrees of each echo against the one before.", 0.5, 1), + ParamSpec::makeInt("echo_drift", "Drift", 20, 0, 4096, + "Furthest in pixels each echo may wander off centre, either way."), + ParamSpec::makeDouble("echo_fade", "Opacity", 0.8, 0.0, 1.0, + "How strongly each echo covers what lies beneath it."), + }; + return specs; +} + +namespace { + +struct Echo { + double dx = 0.0; + double dy = 0.0; +}; + +} // namespace + +Image FeedbackEffect::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."); + + const long count = std::clamp(paramInt(values, "echo_count", 6), 1L, 64L); + const double scale = std::clamp(paramDouble(values, "echo_scale", 0.85), 0.1, 2.0); + const double rotate = std::clamp(paramDouble(values, "echo_rotate", 4.0), -180.0, 180.0); + const int drift = static_cast(std::max(0L, paramInt(values, "echo_drift", 20))); + const double fade = std::clamp(paramDouble(values, "echo_fade", 0.8), 0.0, 1.0); + + const Image input = canvasFromSources(sources, width, height, rng); + + std::vector echoes(static_cast(count)); + for (auto& echo : echoes) { + echo.dx = static_cast(rng.inRange(-drift, drift)); + echo.dy = static_cast(rng.inRange(-drift, drift)); + } + + double sine = 0.0; + double cosine = 1.0; + sinCosDegrees(rotate, sine, cosine); + const double cx = (width - 1) * 0.5; + const double cy = (height - 1) * 0.5; + const int mix = static_cast(fade * 256.0 + 0.5); + + // A camera filming its own monitor: every pass lays the whole picture so + // far, shrunk (or grown), turned and drifted, over the untouched input, so + // the older echoes nest inside the newer ones and fade as they go. + Image frame = input; + Image next(width, height); + for (const Echo& echo : echoes) { + 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 = next.row(static_cast(row)); + const double ry = static_cast(row) - cy - echo.dy; + for (int col = 0; col < width; ++col) { + const std::size_t at = static_cast(col) * 3; + const double rx = col - cx - echo.dx; + const double sx = cx + (rx * cosine + ry * sine) / scale; + const double sy = cy + (-rx * sine + ry * cosine) / scale; + if (sx < 0.0 || sy < 0.0 || sx > width - 1 || sy > height - 1) { + out[at] = base[at]; + out[at + 1] = base[at + 1]; + out[at + 2] = base[at + 2]; + continue; + } + std::uint8_t echoed[3]; + sampleBilinear(frame, sx, sy, echoed); + for (int c = 0; c < 3; ++c) { + const int under = base[at + static_cast(c)]; + out[at + static_cast(c)] = + static_cast(under + (echoed[c] - under) * mix / 256); + } + } + } + }); + std::swap(frame, next); + } + + return frame; +} + +} // namespace effector diff --git a/src/effects/feedback.hpp b/src/effects/feedback.hpp new file mode 100644 index 0000000..df48d06 --- /dev/null +++ b/src/effects/feedback.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include "core/effect.hpp" + +namespace effector { + +// Lays the image over itself again and again, each copy zoomed, turned and +// drifted a little further and fading as it goes, like a camera filming its +// own monitor. +// A pure transform: it needs no source images, and leading a chain with +// sources loaded it starts from a crop of a randomly chosen one. +class FeedbackEffect : public Effect { +public: + std::string name() const override { return "feedback"; } + std::string title() const override { return "Feedback"; } + std::string description() const override { + return "Lays the image over itself again and again, each copy zoomed, turned and drifted " + "further and fading, like a camera filming its own monitor."; + } + const std::vector& params() const override; + bool needsSources() const override { return false; } + + Image render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const override; +}; + +} // namespace effector diff --git a/src/effects/pixelsort.cpp b/src/effects/pixelsort.cpp new file mode 100644 index 0000000..9881a1b --- /dev/null +++ b/src/effects/pixelsort.cpp @@ -0,0 +1,151 @@ +#include "effects/pixelsort.hpp" + +#include +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/sampling.hpp" +#include "effects/source_fill.hpp" + +namespace effector { + +const std::vector& PixelSortEffect::params() const { + static const std::vector specs = { + ParamSpec::makeDouble("sort_low", "Lower threshold", 0.25, 0.0, 1.0, + "Pixels darker than this break a run and stay where they are."), + ParamSpec::makeDouble("sort_high", "Upper threshold", 0.8, 0.0, 1.0, + "Pixels brighter than this break a run and stay where they are."), + ParamSpec::makeChoice("sort_key", "Sort by", "luma", {"luma", "hue", "saturation"}, + "What orders the pixels inside a run."), + ParamSpec::makeChoice("sort_direction", "Direction", "horizontal", + {"horizontal", "vertical"}, "Whether rows or columns are sorted."), + ParamSpec::makeBool("sort_reverse", "Reverse", false, + "Sort from high to low instead of from low to high."), + ParamSpec::makeDouble("sort_line_chance", "Line chance", 1.0, 0.0, 1.0, + "Chance of each row (or column) being sorted at all."), + ParamSpec::makeInt("sort_max_run", "Longest run", 0, 0, 8192, + "Each run is cut after a random length up to this many pixels, which " + "frays the edges of the sorted streaks (0 = no limit)."), + }; + return specs; +} + +namespace { + +int luma(const std::uint8_t* px) { return (px[0] * 54 + px[1] * 183 + px[2] * 19) >> 8; } + +// Hue on a 0..1535 scale (six sectors of 256), in integers. +int hue(const std::uint8_t* px) { + const int r = px[0], g = px[1], b = px[2]; + const int hi = std::max({r, g, b}); + const int lo = std::min({r, g, b}); + const int range = hi - lo; + if (range == 0) return 0; + if (hi == r) return g >= b ? (g - b) * 255 / range : 1535 - (b - g) * 255 / range; + if (hi == g) return 512 + (b - r) * 255 / range; + return 1024 + (r - g) * 255 / range; +} + +int saturation(const std::uint8_t* px) { + return std::max({px[0], px[1], px[2]}) - std::min({px[0], px[1], px[2]}); +} + +// The image with rows and columns swapped, so vertical sorting is horizontal +// sorting of the transpose. +Image transposed(const Image& src) { + Image out(src.height(), src.width()); + parallelFor(static_cast(src.width()), [&](std::size_t begin, std::size_t end) { + for (std::size_t x = begin; x < end; ++x) { + std::uint8_t* row = out.row(static_cast(x)); + for (int y = 0; y < src.height(); ++y) { + std::memcpy(row + static_cast(y) * 3, src.row(y) + x * 3, 3); + } + } + }); + return out; +} + +struct LinePlan { + bool sorted = false; + std::uint64_t seed = 0; // for the run lengths, when they are limited +}; + +} // namespace + +Image PixelSortEffect::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."); + + const int low = static_cast(std::clamp(paramDouble(values, "sort_low", 0.25), 0.0, 1.0) * 255.0 + 0.5); + const int high = static_cast(std::clamp(paramDouble(values, "sort_high", 0.8), 0.0, 1.0) * 255.0 + 0.5); + const std::string keyName = paramChoice(values, "sort_key", "luma"); + const bool vertical = paramChoice(values, "sort_direction", "horizontal") == "vertical"; + const bool reverse = paramBool(values, "sort_reverse", false); + const double lineChance = std::clamp(paramDouble(values, "sort_line_chance", 1.0), 0.0, 1.0); + const int maxRun = static_cast(std::max(0L, paramInt(values, "sort_max_run", 0))); + + Image canvas = canvasFromSources(sources, width, height, rng); + if (vertical) canvas = transposed(canvas); + const int lines = canvas.height(); + const int span = canvas.width(); + + // Which lines are sorted, and a seed for each line's run lengths, are drawn + // serially; the lines then sort in parallel, each from its own generator. + std::vector plan(static_cast(lines)); + for (auto& line : plan) { + line.sorted = chance(rng.next(), lineChance); + if (maxRun > 0) line.seed = rng.next(); + } + + auto keyOf = [&](const std::uint8_t* px) { + if (keyName == "hue") return hue(px); + if (keyName == "saturation") return saturation(px); + return luma(px); + }; + + parallelFor(static_cast(lines), [&](std::size_t begin, std::size_t end) { + std::vector>> run; + for (std::size_t line = begin; line < end; ++line) { + if (!plan[line].sorted) continue; + Rng runRng(plan[line].seed); + std::uint8_t* row = canvas.row(static_cast(line)); + + auto flush = [&](int start) { + // A stable sort, so equal keys keep their order on every + // standard library. + std::stable_sort(run.begin(), run.end(), [&](const auto& a, const auto& b) { + return reverse ? a.first > b.first : a.first < b.first; + }); + for (std::size_t i = 0; i < run.size(); ++i) { + std::memcpy(row + (static_cast(start) + i) * 3, run[i].second.data(), 3); + } + run.clear(); + }; + + int start = 0; + int limit = maxRun > 0 ? static_cast(runRng.inRange(1, maxRun)) : span; + for (int x = 0; x < span; ++x) { + const std::uint8_t* px = row + static_cast(x) * 3; + const int brightness = luma(px); + if (brightness < low || brightness > high) { + flush(start); + start = x + 1; + continue; + } + if (static_cast(run.size()) >= limit) { + flush(start); + start = x; + if (maxRun > 0) limit = static_cast(runRng.inRange(1, maxRun)); + } + run.push_back({keyOf(px), {px[0], px[1], px[2]}}); + } + flush(start); + } + }); + + return vertical ? transposed(canvas) : canvas; +} + +} // namespace effector diff --git a/src/effects/pixelsort.hpp b/src/effects/pixelsort.hpp new file mode 100644 index 0000000..a293db8 --- /dev/null +++ b/src/effects/pixelsort.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "core/effect.hpp" + +namespace effector { + +// Finds runs of pixels whose brightness lies between two thresholds and sorts +// each run by brightness, hue or saturation, which makes the image drip. +// A pure transform: it needs no source images, and leading a chain with +// sources loaded it starts from a crop of a randomly chosen one. +class PixelSortEffect : public Effect { +public: + std::string name() const override { return "pixelsort"; } + std::string title() const override { return "Pixel sort"; } + std::string description() const override { + return "Sorts the runs of pixels whose brightness lies between two thresholds, along " + "rows or columns, so the image melts and drips."; + } + const std::vector& params() const override; + bool needsSources() const override { return false; } + + Image render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const override; +}; + +} // namespace effector diff --git a/src/effects/sampling.hpp b/src/effects/sampling.hpp new file mode 100644 index 0000000..1c1f6f9 --- /dev/null +++ b/src/effects/sampling.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include + +#include "core/image.hpp" + +namespace effector { + +// Reads the colour at a fractional position, mixing the four neighbouring +// pixels; positions off the image take the nearest edge pixel. The weights are +// cut to 1/256 steps so the mix itself is integer arithmetic. +inline void sampleBilinear(const Image& image, double x, double y, std::uint8_t out[3]) { + const int maxX = image.width() - 1; + const int maxY = image.height() - 1; + x = std::clamp(x, 0.0, static_cast(maxX)); + y = std::clamp(y, 0.0, static_cast(maxY)); + + const int x0 = static_cast(x); + const int y0 = static_cast(y); + const int x1 = std::min(x0 + 1, maxX); + const int y1 = std::min(y0 + 1, maxY); + const int fx = static_cast((x - x0) * 256.0); + const int fy = static_cast((y - y0) * 256.0); + + const std::uint8_t* a = image.row(y0) + static_cast(x0) * 3; + const std::uint8_t* b = image.row(y0) + static_cast(x1) * 3; + const std::uint8_t* c = image.row(y1) + static_cast(x0) * 3; + const std::uint8_t* d = image.row(y1) + static_cast(x1) * 3; + for (int ch = 0; ch < 3; ++ch) { + const int top = a[ch] * (256 - fx) + b[ch] * fx; + const int bottom = c[ch] * (256 - fx) + d[ch] * fx; + out[ch] = static_cast((top * (256 - fy) + bottom * fy + 32768) >> 16); + } +} + +// Sine and cosine of an angle in degrees from nothing but additions and +// multiplications, so a rotation lands on the same pixels whatever the +// machine's libm makes of std::sin. The angle is folded into one octant and a +// short Taylor series does the rest, far below a pixel's worth of error. +inline void sinCosDegrees(double degrees, double& sine, double& cosine) { + const double turns = degrees / 90.0; + const double quadrant = std::floor(turns + 0.5); + const double x = (turns - quadrant) * 1.5707963267948966; // [-pi/4, pi/4] + const double x2 = x * x; + const double s = + x * (1.0 - x2 / 6.0 * (1.0 - x2 / 20.0 * (1.0 - x2 / 42.0 * (1.0 - x2 / 72.0)))); + const double c = + 1.0 - x2 / 2.0 * (1.0 - x2 / 12.0 * (1.0 - x2 / 30.0 * (1.0 - x2 / 56.0 * (1.0 - x2 / 90.0)))); + switch (static_cast(quadrant - 4.0 * std::floor(quadrant / 4.0))) { + case 0: sine = s; cosine = c; break; + case 1: sine = c; cosine = -s; break; + case 2: sine = -s; cosine = -c; break; + default: sine = -c; cosine = s; break; + } +} + +// A uniform double in [0, 1) from the top 53 bits of one rng draw. +inline double unitDouble(std::uint64_t bits) { + return static_cast(bits >> 11) * (1.0 / 9007199254740992.0); +} + +// Whether an event with the given probability happens, from one rng draw. +inline bool chance(std::uint64_t bits, double probability) { + return unitDouble(bits) < probability; +} + +} // namespace effector diff --git a/src/effects/shatter.cpp b/src/effects/shatter.cpp new file mode 100644 index 0000000..029e2f9 --- /dev/null +++ b/src/effects/shatter.cpp @@ -0,0 +1,178 @@ +#include "effects/shatter.hpp" + +#include +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/sampling.hpp" +#include "effects/source_fill.hpp" + +namespace effector { + +const std::vector& ShatterEffect::params() const { + static const std::vector specs = { + ParamSpec::makeInt("shatter_cells", "Shards", 40, 2, 5000, + "How many shards the image breaks into."), + ParamSpec::makeInt("shatter_displace", "Displacement", 40, 0, 4096, + "Furthest in pixels a shard's picture may slide, along either axis."), + ParamSpec::makeDouble("shatter_rotate", "Rotation", 8.0, 0.0, 180.0, + "Largest turn in degrees of a shard's picture, either way.", 0.5, 1), + ParamSpec::makeDouble("shatter_zoom", "Zoom", 0.05, 0.0, 0.9, + "Largest zoom in or out of a shard's picture, as a fraction.", 0.01), + ParamSpec::makeInt("shatter_crack_width", "Crack width", 2, 0, 64, + "Width in pixels of the cracks between the shards (0 = none)."), + ParamSpec::makeColor("shatter_crack_colour", "Crack colour", "#101010", + "Colour the cracks are drawn in."), + }; + return specs; +} + +namespace { + +struct Shard { + double x = 0.0; // the point the shard grows around + double y = 0.0; + double dx = 0.0; + double dy = 0.0; + double sine = 0.0; + double cosine = 1.0; + double zoom = 1.0; +}; + +// The shard points bucketed on a coarse grid, so finding the two nearest to a +// pixel looks at a few buckets instead of every point. +class ShardGrid { +public: + ShardGrid(const std::vector& shards, int width, int height) : shards_(shards) { + cell_ = std::max(1.0, std::sqrt(static_cast(width) * height / + static_cast(shards.size()))); + cols_ = static_cast(width / cell_) + 1; + rows_ = static_cast(height / cell_) + 1; + buckets_.resize(static_cast(cols_) * rows_); + for (std::size_t i = 0; i < shards.size(); ++i) { + buckets_[bucketOf(shards[i].x, shards[i].y)].push_back(static_cast(i)); + } + } + + // The nearest and second nearest shard to (x, y). + void nearest(double x, double y, int& first, int& second) const { + const int bx = std::clamp(static_cast(x / cell_), 0, cols_ - 1); + const int by = std::clamp(static_cast(y / cell_), 0, rows_ - 1); + double best = std::numeric_limits::max(); + double next = best; + first = second = -1; + const int maxRing = std::max(cols_, rows_); + for (int ring = 0; ring <= maxRing; ++ring) { + // Anything in this ring is at least (ring - 1) cells away, so once + // both answers are closer than that the search is over. + const double reach = std::max(0, ring - 1) * cell_; + if (second >= 0 && next < reach * reach) break; + for (int gy = by - ring; gy <= by + ring; ++gy) { + if (gy < 0 || gy >= rows_) continue; + const bool edgeRow = gy == by - ring || gy == by + ring; + for (int gx = bx - ring; gx <= bx + ring; gx += edgeRow ? 1 : 2 * std::max(ring, 1)) { + if (gx < 0 || gx >= cols_) continue; + for (int index : buckets_[static_cast(gy) * cols_ + gx]) { + const Shard& shard = shards_[static_cast(index)]; + const double d = (shard.x - x) * (shard.x - x) + (shard.y - y) * (shard.y - y); + if (d < best || (d == best && index < first)) { + next = best; + second = first; + best = d; + first = index; + } else if (d < next || (d == next && index < second)) { + next = d; + second = index; + } + } + if (ring == 0) break; + } + } + } + } + +private: + std::size_t bucketOf(double x, double y) const { + const int bx = std::clamp(static_cast(x / cell_), 0, cols_ - 1); + const int by = std::clamp(static_cast(y / cell_), 0, rows_ - 1); + return static_cast(by) * cols_ + bx; + } + + const std::vector& shards_; + double cell_ = 1.0; + int cols_ = 1; + int rows_ = 1; + std::vector> buckets_; +}; + +} // namespace + +Image ShatterEffect::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."); + + const long count = std::max(2L, paramInt(values, "shatter_cells", 40)); + const int displace = static_cast(std::max(0L, paramInt(values, "shatter_displace", 40))); + const double rotate = std::clamp(paramDouble(values, "shatter_rotate", 8.0), 0.0, 180.0); + const double zoom = std::clamp(paramDouble(values, "shatter_zoom", 0.05), 0.0, 0.9); + const double crackWidth = static_cast(std::max(0L, paramInt(values, "shatter_crack_width", 2))); + std::uint8_t crack[3]; + paramColor(values, "shatter_crack_colour", "#101010", crack); + + const Image input = canvasFromSources(sources, width, height, rng); + + std::vector shards(static_cast(count)); + for (auto& shard : shards) { + shard.x = unitDouble(rng.next()) * width; + shard.y = unitDouble(rng.next()) * height; + shard.dx = static_cast(rng.inRange(-displace, displace)); + shard.dy = static_cast(rng.inRange(-displace, displace)); + sinCosDegrees((unitDouble(rng.next()) * 2.0 - 1.0) * rotate, shard.sine, shard.cosine); + shard.zoom = 1.0 + (unitDouble(rng.next()) * 2.0 - 1.0) * zoom; + } + const ShardGrid grid(shards, width, height); + + Image canvas(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 = canvas.row(static_cast(row)); + const double y = static_cast(row) + 0.5; + for (int col = 0; col < width; ++col) { + std::uint8_t* px = out + static_cast(col) * 3; + const double x = col + 0.5; + int first = 0; + int second = 0; + grid.nearest(x, y, first, second); + const Shard& a = shards[static_cast(first)]; + + if (crackWidth > 0.0 && second >= 0) { + // Distance to the edge between the two nearest shards, + // which is the perpendicular bisector of their points. + const Shard& b = shards[static_cast(second)]; + const double da = (x - a.x) * (x - a.x) + (y - a.y) * (y - a.y); + const double db = (x - b.x) * (x - b.x) + (y - b.y) * (y - b.y); + const double gap = std::sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y)); + if (gap > 0.0 && (db - da) / (2.0 * gap) < crackWidth * 0.5) { + px[0] = crack[0]; + px[1] = crack[1]; + px[2] = crack[2]; + continue; + } + } + + // Undo the shard's slide, then its turn and zoom about its point. + const double rx = x - a.dx - a.x; + const double ry = y - a.dy - a.y; + const double sx = a.x + (rx * a.cosine + ry * a.sine) / a.zoom; + const double sy = a.y + (-rx * a.sine + ry * a.cosine) / a.zoom; + sampleBilinear(input, sx - 0.5, sy - 0.5, px); + } + } + }); + + return canvas; +} + +} // namespace effector diff --git a/src/effects/shatter.hpp b/src/effects/shatter.hpp new file mode 100644 index 0000000..733e11b --- /dev/null +++ b/src/effects/shatter.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include "core/effect.hpp" + +namespace effector { + +// Breaks its input into random glass shards (the cells around scattered points), +// each showing the image shifted, turned and zoomed a little on its own, with +// optional cracks drawn along the edges. +// A pure transform: it needs no source images, and leading a chain with +// sources loaded it starts from a crop of a randomly chosen one. +class ShatterEffect : public Effect { +public: + std::string name() const override { return "shatter"; } + std::string title() const override { return "Shatter"; } + std::string description() const override { + return "Breaks the image into random shards, each shifted, turned and zoomed on its own, " + "with optional cracks between them."; + } + const std::vector& params() const override; + bool needsSources() const override { return false; } + + Image render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const override; +}; + +} // namespace effector diff --git a/src/effects/slitshift.cpp b/src/effects/slitshift.cpp new file mode 100644 index 0000000..ed97105 --- /dev/null +++ b/src/effects/slitshift.cpp @@ -0,0 +1,99 @@ +#include "effects/slitshift.hpp" + +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/source_fill.hpp" + +namespace effector { + +const std::vector& SlitShiftEffect::params() const { + static const std::vector specs = { + ParamSpec::makeInt("shift_bands", "Bands", 24, 1, 4096, + "How many bands are torn out of place."), + ParamSpec::makeInt("shift_max", "Largest shift", 120, 0, 8192, + "Furthest in pixels a band may be pushed, either way."), + ParamSpec::makeInt("shift_band_height", "Band height", 16, 1, 2048, + "Thickness in pixels of each band."), + ParamSpec::makeInt("shift_height_jitter", "Height jitter", 12, 0, 2048, + "Random +/- variation added to the band thickness (0 = uniform)."), + ParamSpec::makeChoice("shift_edge", "Edges", "wrap", {"wrap", "smear", "black"}, + "What fills the gap a band leaves: wrap brings in what was pushed " + "off the other side, smear stretches the last pixel, black leaves " + "it empty."), + ParamSpec::makeChoice("shift_axis", "Axis", "horizontal", {"horizontal", "vertical"}, + "horizontal tears bands of rows sideways, vertical tears bands of " + "columns up and down."), + }; + return specs; +} + +namespace { + +// Where the pixel at `index` of a line of `count` is read from once the line is +// pushed by `offset`, or -1 for an empty (black) pixel. +int shiftedIndex(int index, int offset, int count, char edge) { + const int from = index - offset; + if (from >= 0 && from < count) return from; + if (edge == 'w') return ((from % count) + count) % count; + if (edge == 's') return std::clamp(from, 0, count - 1); + return -1; +} + +} // namespace + +Image SlitShiftEffect::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."); + + const long bands = std::max(1L, paramInt(values, "shift_bands", 24)); + const int maxShift = static_cast(std::max(0L, paramInt(values, "shift_max", 120))); + const int bandHeight = static_cast(std::max(1L, paramInt(values, "shift_band_height", 16))); + const int jitter = static_cast(std::max(0L, paramInt(values, "shift_height_jitter", 12))); + const char edge = paramChoice(values, "shift_edge", "wrap").front(); + const bool vertical = paramChoice(values, "shift_axis", "horizontal") == "vertical"; + + const Image input = canvasFromSources(sources, width, height, rng); + + // Every band is drawn up front, and overlapping bands simply add their + // pushes, so what the pixels need is one offset per line. + const int lines = vertical ? width : height; + const int span = vertical ? height : width; + std::vector offsets(static_cast(lines), 0); + for (long band = 0; band < bands; ++band) { + int thickness = bandHeight; + if (jitter > 0) thickness += static_cast(rng.inRange(-jitter, jitter)); + thickness = std::clamp(thickness, 1, lines); + const int start = static_cast(rng.inRange(0, lines - thickness)); + const int offset = static_cast(rng.inRange(-maxShift, maxShift)); + for (int line = start; line < start + thickness; ++line) { + offsets[static_cast(line)] += offset; + } + } + + Image canvas(width, height); + parallelFor(static_cast(lines), [&](std::size_t begin, std::size_t end) { + for (std::size_t line = begin; line < end; ++line) { + const int offset = offsets[line]; + for (int i = 0; i < span; ++i) { + const int from = shiftedIndex(i, offset, span, edge); + const int x = vertical ? static_cast(line) : i; + const int y = vertical ? i : static_cast(line); + std::uint8_t* out = canvas.row(y) + static_cast(x) * 3; + if (from < 0) { + out[0] = out[1] = out[2] = 0; + continue; + } + const int fx = vertical ? x : from; + const int fy = vertical ? from : y; + std::memcpy(out, input.row(fy) + static_cast(fx) * 3, 3); + } + } + }); + + return canvas; +} + +} // namespace effector diff --git a/src/effects/slitshift.hpp b/src/effects/slitshift.hpp new file mode 100644 index 0000000..802003a --- /dev/null +++ b/src/effects/slitshift.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "core/effect.hpp" + +namespace effector { + +// Tears its input apart: random bands of rows (or columns) are pushed sideways by a +// random distance each, the way a video signal loses its sync. +// A pure transform: it needs no source images, and leading a chain with +// sources loaded it starts from a crop of a randomly chosen one. +class SlitShiftEffect : public Effect { +public: + std::string name() const override { return "slitshift"; } + std::string title() const override { return "Slit shift"; } + std::string description() const override { + return "Tears random bands of its input sideways, like a video signal losing sync; " + "overlapping bands add their shifts up."; + } + const std::vector& params() const override; + bool needsSources() const override { return false; } + + Image render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const override; +}; + +} // namespace effector diff --git a/src/effects/source_fill.hpp b/src/effects/source_fill.hpp index 9a96059..bad2af0 100644 --- a/src/effects/source_fill.hpp +++ b/src/effects/source_fill.hpp @@ -2,9 +2,11 @@ #include #include +#include #include "core/image.hpp" #include "core/parallel.hpp" +#include "core/rng.hpp" namespace effector { @@ -35,4 +37,20 @@ inline void fillFromSource(Image& canvas, const Image& src, int sx, int sy) { }); } +// The canvas a pure transform starts from: a crop of a randomly chosen source, +// or black when there is none. Inside a chain the only source is the stage +// before, which has the canvas size, so no random value is drawn at all; only a +// transform leading a chain with photographs loaded spends any of the rng here. +inline Image canvasFromSources(const std::vector& sources, int width, int height, Rng& rng) { + Image canvas(width, height); + if (!sources.empty()) { + const std::size_t index = rng.below(sources.size() - 1); + const Image& src = sources[index]; + const int sx = static_cast(rng.inRange(0, std::max(0, src.width() - width))); + const int sy = static_cast(rng.inRange(0, std::max(0, src.height() - height))); + fillFromSource(canvas, src, sx, sy); + } + return canvas; +} + } // namespace effector diff --git a/src/effects/warp.cpp b/src/effects/warp.cpp new file mode 100644 index 0000000..fb3a606 --- /dev/null +++ b/src/effects/warp.cpp @@ -0,0 +1,159 @@ +#include "effects/warp.hpp" + +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/sampling.hpp" +#include "effects/source_fill.hpp" + +namespace effector { + +const std::vector& WarpEffect::params() const { + static const std::vector specs = { + ParamSpec::makeInt("warp_strength", "Strength", 40, 0, 4096, + "How far in pixels the field pushes the image at its strongest."), + ParamSpec::makeInt("warp_scale", "Scale", 160, 4, 8192, + "Size in pixels of the largest features in the field."), + ParamSpec::makeInt("warp_octaves", "Octaves", 3, 1, 6, + "Finer layers of noise laid over the first; more give a turbulent " + "edge to the flow."), + ParamSpec::makeChoice("warp_mode", "Mode", "displace", {"displace", "swirl"}, + "displace pushes along the field; swirl flows along its contour " + "lines, which curls the image into eddies."), + }; + 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."); + + const double strength = static_cast(std::max(0L, paramInt(values, "warp_strength", 40))); + const double scale = static_cast(std::max(4L, paramInt(values, "warp_scale", 160))); + const int octaves = static_cast(std::clamp(paramInt(values, "warp_octaves", 3), 1L, 6L)); + const bool swirl = paramChoice(values, "warp_mode", "displace") == "swirl"; + + 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); + + Image canvas(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 = canvas.row(static_cast(row)); + 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; + } + sampleBilinear(input, x + dx, y + dy, out + static_cast(col) * 3); + } + } + }); + + return canvas; +} + +} // namespace effector diff --git a/src/effects/warp.hpp b/src/effects/warp.hpp new file mode 100644 index 0000000..0260401 --- /dev/null +++ b/src/effects/warp.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include "core/effect.hpp" + +namespace effector { + +// Pushes every pixel along a smooth random noise field: a haze at low strength, +// melted paint at high. The swirl mode follows the field's contour lines, +// which turns the image into eddies instead of ripples. +// A pure transform: it needs no source images, and leading a chain with +// sources loaded it starts from a crop of a randomly chosen one. +class WarpEffect : public Effect { +public: + std::string name() const override { return "warp"; } + std::string title() const override { return "Warp"; } + std::string description() const override { + return "Pushes every pixel along a smooth random noise field, from a gentle haze to " + "molten eddies."; + } + const std::vector& params() const override; + bool needsSources() const override { return false; } + + Image render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const override; +}; + +} // namespace effector diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index d1fbd8a..2dc172b 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -201,6 +202,18 @@ effector::Image flat(int width, int height, std::uint8_t shade) { return image; } +// Renders any registered effect by name with its defaults, overridden by +// `values`, from a generator seeded with `seed`. +effector::Image renderNamed(const std::string& name, const std::vector& sources, + int width, int height, std::uint64_t seed, + const effector::ParamValues& values) { + const auto effect = effector::effectByName(name); + effector::Rng rng(seed); + auto merged = effect->defaults(); + for (const auto& [key, value] : values) merged[key] = value; + return effect->render(sources, width, height, rng, merged); +} + effector::Image render(const std::vector& sources, int width, int height, std::uint64_t seed, const effector::ParamValues& values) { effector::ScanlinesEffect effect; @@ -243,6 +256,15 @@ int main() { threw = true; } check(threw, "unknown effect throws"); + + // Flags share one namespace, so a key two effects declared would be + // handed to whichever came first. + std::set keys; + bool unique = true; + for (const auto& effect : effects()) { + for (const auto& spec : effect->params()) unique = keys.insert(spec.key).second && unique; + } + check(unique, "no two effects declare the same param key"); } std::cout << "scanlines\n"; @@ -933,6 +955,150 @@ int main() { "and so does a chain handed no styles at all"); } + std::cout << "chaos transforms\n"; + { + const std::vector sources = {gradient(1200, 800, 10), gradient(640, 480, 90)}; + const std::vector input = {gradient(400, 300, 40)}; + + // What every one of them must do, and what switches each one off. + const std::vector> transforms = { + {"slitshift", {{"shift_max", 0L}}}, + {"channelsplit", {{"split_amount", 0L}}}, + {"pixelsort", {{"sort_line_chance", 0.0}}}, + {"corrupt", {{"corrupt_rate", 0.0}}}, + {"shatter", + {{"shatter_displace", 0L}, {"shatter_rotate", 0.0}, {"shatter_zoom", 0.0}, + {"shatter_crack_width", 0L}}}, + {"warp", {{"warp_strength", 0L}}}, + {"bitcrush", {{"crush_coverage", 0.0}}}, + {"feedback", {{"echo_fade", 0.0}}}, + }; + for (const auto& [name, off] : transforms) { + check(!effectByName(name)->needsSources(), name + " needs no source images"); + + const auto first = renderNamed(name, sources, 400, 300, 4, {}); + const auto again = renderNamed(name, sources, 400, 300, 4, {}); + const auto other = renderNamed(name, sources, 400, 300, 11, {}); + check(first.width() == 400 && first.height() == 300, + name + " renders the requested size"); + check(sameBytes(first, again), name + " reproduces itself from a seed"); + check(!sameBytes(first, other), name + " changes with the seed"); + check(!sameBytes(renderNamed(name, input, 400, 300, 4, {}), input[0]), + name + " changes its input"); + check(sameBytes(renderNamed(name, input, 400, 300, 4, off), input[0]), + name + " switched right down leaves its input alone"); + + const auto blank = renderNamed(name, {}, 120, 80, 1, {}); + check(blank.width() == 120 && blank.height() == 80, + name + " renders without sources"); + + ChainStage scanlines{effectByName("scanlines"), {{"strip_height", 6L}}}; + ChainStage stage{effectByName(name), {}}; + Rng a(9); + Rng b(9); + check(sameBytes(renderChain({scanlines, stage}, sources, 300, 200, a), + renderChain({scanlines, stage}, sources, 300, 200, b)), + name + " cascades reproducibly"); + check(!chainNeedsSources({stage, scanlines}), "a chain led by " + name + " needs no sources"); + + // A canvas one pixel wide or high must not trip any of them up. + check(renderNamed(name, {}, 1, 1, 2, {}).width() == 1 && + renderNamed(name, input, 1, 57, 2, {}).height() == 57 && + renderNamed(name, input, 57, 1, 2, {}).width() == 57, + name + " copes with a sliver of a canvas"); + } + + // Wrapped slit shifts only rotate each row, so every row keeps its + // pixels, just not their places. + const auto wrapped = renderNamed("slitshift", input, 400, 300, 5, {{"shift_bands", 200L}}); + bool rotated = true; + for (int y = 0; y < 300; ++y) { + std::vector before(input[0].row(y), input[0].row(y) + 1200); + std::vector after(wrapped.row(y), wrapped.row(y) + 1200); + std::sort(before.begin(), before.end()); + std::sort(after.begin(), after.end()); + rotated = rotated && before == after; + } + check(rotated, "a wrapped slit shift keeps every row's pixels"); + const auto upright = renderNamed("slitshift", input, 400, 300, 5, + {{"shift_axis", std::string("vertical")}}); + check(!sameBytes(upright, wrapped), "the vertical axis tears columns instead"); + + // Moving a plane of one colour moves nothing that can be seen. + const auto split = renderNamed("channelsplit", {solid(80, 60, 200, 90, 30)}, 80, 60, 3, + {{"split_mode", std::string("per_band")}, + {"split_band_height", 7L}}); + check(sameBytes(split, solid(80, 60, 200, 90, 30)), "a channel split keeps a flat colour"); + const auto rc = renderNamed("channelsplit", input, 400, 300, 3, + {{"split_channels", std::string("red_cyan")}}); + check(pixel(rc, 200, 150)[2] == pixel(input[0], 200, 150)[2], + "red_cyan leaves blue where a flat plane has it"); + + // With the thresholds wide open every row comes out sorted. + const auto sorted = renderNamed("pixelsort", {stripes(64, 16)}, 64, 16, 1, + {{"sort_low", 0.0}, {"sort_high", 1.0}}); + bool ordered = true; + for (int y = 0; y < 16; ++y) { + for (int x = 1; x < 64; ++x) ordered = ordered && pixel(sorted, x - 1, y)[0] <= pixel(sorted, x, y)[0]; + } + check(ordered, "an open pixel sort orders every row"); + const auto downward = renderNamed("pixelsort", {stripes(64, 16)}, 64, 16, 1, + {{"sort_low", 0.0}, {"sort_high", 1.0}, + {"sort_direction", std::string("vertical")}}); + check(sameBytes(downward, stripes(64, 16)), "sorting uniform columns changes nothing"); + + // Every block repeated is the first block smeared across the row. + const auto smeared = renderNamed("corrupt", input, 64, 32, 2, + {{"corrupt_rate", 1.0}, + {"corrupt_block", 16L}, + {"corrupt_kinds", std::string("repeat")}}); + bool periodic = true; + for (int y = 0; y < 32; ++y) { + for (int x = 16; x < 64; ++x) periodic = periodic && pixel(smeared, x, y) == pixel(smeared, x - 16, y); + } + check(periodic, "repeated blocks copy the block to their left"); + const auto bled = renderNamed("corrupt", input, 400, 300, 2, + {{"corrupt_rate", 0.02}, {"corrupt_bleed", true}}); + check(!sameBytes(bled, renderNamed("corrupt", input, 400, 300, 2, {{"corrupt_rate", 0.02}})), + "bleeding carries the damage further"); + + const auto cracked = renderNamed("shatter", {flat(200, 150, 128)}, 200, 150, 6, + {{"shatter_crack_colour", std::string("#ff0000")}}); + bool red = false; + for (int y = 0; y < 150; ++y) { + for (int x = 0; x < 200; ++x) red = red || isRed(pixel(cracked, x, y)); + } + check(red, "shatter draws its cracks in the crack colour"); + + check(!sameBytes(renderNamed("warp", input, 400, 300, 4, {{"warp_mode", std::string("swirl")}}), + renderNamed("warp", input, 400, 300, 4, {})), + "swirl flows differently from a plain push"); + + const auto poster = renderNamed("bitcrush", input, 400, 300, 3, + {{"crush_op", std::string("posterize")}, + {"crush_coverage", 1.0}}); + bool coarse = true; + for (std::size_t i = 0; i < poster.byteSize(); ++i) coarse = coarse && poster.data()[i] % 8 == 0; + check(coarse, "posterising three bits leaves multiples of eight"); + const auto swapped = renderNamed("bitcrush", {solid(40, 40, 250, 120, 5)}, 40, 40, 3, + {{"crush_op", std::string("channel_swap")}, + {"crush_coverage", 1.0}}); + check(pixel(swapped, 20, 20) != std::array{250, 120, 5}, + "a channel swap never leaves the order as it was"); + + // A shrunk, fully opaque echo lands the image's corner inside the + // canvas, while the border outside every echo stays the input's. + const auto tunnel = renderNamed("feedback", input, 400, 300, 1, + {{"echo_scale", 0.5}, {"echo_rotate", 0.0}, + {"echo_drift", 0L}, {"echo_fade", 1.0}}); + check(pixel(tunnel, 0, 0) == pixel(input[0], 0, 0), "the border outside the echoes is the input"); + // Halfway to the corner reads the corner of the image at half size. + const auto near = pixel(tunnel, 100, 75); + check(std::abs(near[0] - pixel(input[0], 0, 0)[0]) < 4 && + std::abs(near[1] - pixel(input[0], 0, 0)[1]) < 4, + "the echoes nest inside it"); + } + std::cout << "chain\n"; { std::vector sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};