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:
@@ -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)};
|
||||
|
||||
Reference in New Issue
Block a user