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 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-23 20:36:09 +02:00
co-authored by Claude Opus 5.5
parent 8113a06da7
commit ce3a422f6a
21 changed files with 1548 additions and 0 deletions
+166
View File
@@ -12,6 +12,7 @@
#include <locale>
#include <mutex>
#include <optional>
#include <set>
#include <string>
#include <utility>
#include <vector>
@@ -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<effector::Image>& 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<effector::Image>& 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<std::string> 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<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
const std::vector<Image> input = {gradient(400, 300, 40)};
// What every one of them must do, and what switches each one off.
const std::vector<std::pair<std::string, ParamValues>> 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<std::uint8_t> before(input[0].row(y), input[0].row(y) + 1200);
std::vector<std::uint8_t> 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<int, 3>{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<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};