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
+129
View File
@@ -0,0 +1,129 @@
#include "effects/bitcrush.hpp"
#include <algorithm>
#include <array>
#include <vector>
#include "core/parallel.hpp"
#include "effects/sampling.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& BitCrushEffect::params() const {
static const std::vector<ParamSpec> 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<std::uint8_t, 3> mask{};
std::array<int, 3> 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<Image>& 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<int>(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<int>(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<Band> plan(static_cast<std::size_t>(bandCount));
for (auto& band : plan) {
if (!chance(rng.next(), coverage)) continue;
band.op = fixed != Op::None ? fixed : static_cast<Op>(1 + rng.below(3));
switch (band.op) {
case Op::Xor:
for (auto& mask : band.mask) {
mask = static_cast<std::uint8_t>(rng.inRange(1, (1L << bits) - 1));
}
break;
case Op::Rotate:
band.rotate = static_cast<int>(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<std::size_t>(i)],
band.order[rng.below(static_cast<std::uint64_t>(i))]);
}
} while (band.order[0] == 0 && band.order[1] == 1);
break;
default:
break;
}
}
const std::uint8_t keep = static_cast<std::uint8_t>(0xFF << bits);
parallelFor(static_cast<std::size_t>(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<std::size_t>(bandHeight)];
if (band.op == Op::None) continue;
std::uint8_t* row = canvas.row(static_cast<int>(y));
for (int x = 0; x < width; ++x) {
std::uint8_t* px = row + static_cast<std::size_t>(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<std::size_t>(c)];
break;
case Op::Rotate:
for (int c = 0; c < 3; ++c) {
px[c] = static_cast<std::uint8_t>((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<std::size_t>(c)]];
break;
}
case Op::None:
break;
}
}
}
});
return canvas;
}
} // namespace effector
+26
View File
@@ -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<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+97
View File
@@ -0,0 +1,97 @@
#include "effects/channelsplit.hpp"
#include <algorithm>
#include <array>
#include <vector>
#include "core/parallel.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& ChannelSplitEffect::params() const {
static const std::vector<ParamSpec> 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<Offset, 3>;
ChannelOffsets drawOffsets(Rng& rng, int amount, const std::string& channels) {
ChannelOffsets offsets{};
auto draw = [&] {
return Offset{static_cast<int>(rng.inRange(-amount, amount)),
static_cast<int>(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<Image>& 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<int>(std::max(0L, paramInt(values, "split_amount", 12)));
const bool perBand = paramChoice(values, "split_mode", "global") == "per_band";
const int bandHeight = static_cast<int>(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<ChannelOffsets> plan;
plan.reserve(static_cast<std::size_t>(bandCount));
for (int i = 0; i < bandCount; ++i) plan.push_back(drawOffsets(rng, amount, channels));
Image canvas(width, height);
parallelFor(static_cast<std::size_t>(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<std::size_t>(band)];
std::uint8_t* out = canvas.row(static_cast<int>(y));
for (int c = 0; c < 3; ++c) {
const Offset& offset = offsets[static_cast<std::size_t>(c)];
const int sy = std::clamp(static_cast<int>(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<std::size_t>(x) * 3 + c] = in[static_cast<std::size_t>(sx) * 3 + c];
}
}
}
});
return canvas;
}
} // namespace effector
+26
View File
@@ -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<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+143
View File
@@ -0,0 +1,143 @@
#include "effects/corrupt.hpp"
#include <algorithm>
#include <cstring>
#include <vector>
#include "core/parallel.hpp"
#include "effects/sampling.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& CorruptEffect::params() const {
static const std::vector<ParamSpec> 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<Image>& 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<int>(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<Damage>(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<int>(rng.inRange(0, std::max(0, width - size)));
block.sy = static_cast<int>(rng.inRange(0, std::max(0, height - size)));
} else if (block.damage == Damage::Colour) {
for (int& shift : block.shift) shift = static_cast<int>(rng.inRange(-128, 128));
}
return block;
};
// The whole damage map is drawn up front, block by block in reading order.
std::vector<Block> plan(static_cast<std::size_t>(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<std::size_t>(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 = &block;
continue;
}
if (!chance(rng.next(), rate)) continue;
block = drawBlock(bx);
if (bleed) carried = &block;
}
}
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<std::size_t>(rows), [&](std::size_t begin, std::size_t end) {
for (std::size_t by = begin; by < end; ++by) {
const int y0 = static_cast<int>(by) * size;
const int h = std::min(size, height - y0);
for (int bx = 0; bx < cols; ++bx) {
const Block& block = plan[by * static_cast<std::size_t>(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<std::size_t>(w) * 3;
for (int line = 0; line < h; ++line) {
std::uint8_t* out = canvas.row(y0 + line) + static_cast<std::size_t>(x0) * 3;
switch (block.damage) {
case Damage::Repeat:
std::memmove(out, out - static_cast<std::size_t>(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<std::size_t>(x) * 3,
in + static_cast<std::size_t>(sx) * 3, 3);
}
break;
}
case Damage::Colour:
for (std::size_t i = 0; i < bytes; ++i) {
out[i] = static_cast<std::uint8_t>(
std::clamp(out[i] + block.shift[i % 3], 0, 255));
}
break;
case Damage::None:
break;
}
}
}
}
});
return canvas;
}
} // namespace effector
+27
View File
@@ -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<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+103
View File
@@ -0,0 +1,103 @@
#include "effects/feedback.hpp"
#include <algorithm>
#include <cmath>
#include <vector>
#include "core/parallel.hpp"
#include "effects/sampling.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& FeedbackEffect::params() const {
static const std::vector<ParamSpec> 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<Image>& 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<int>(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<Echo> echoes(static_cast<std::size_t>(count));
for (auto& echo : echoes) {
echo.dx = static_cast<double>(rng.inRange(-drift, drift));
echo.dy = static_cast<double>(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<int>(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<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
for (std::size_t row = begin; row < end; ++row) {
const std::uint8_t* base = input.row(static_cast<int>(row));
std::uint8_t* out = next.row(static_cast<int>(row));
const double ry = static_cast<double>(row) - cy - echo.dy;
for (int col = 0; col < width; ++col) {
const std::size_t at = static_cast<std::size_t>(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<std::size_t>(c)];
out[at + static_cast<std::size_t>(c)] =
static_cast<std::uint8_t>(under + (echoed[c] - under) * mix / 256);
}
}
}
});
std::swap(frame, next);
}
return frame;
}
} // namespace effector
+27
View File
@@ -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<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+151
View File
@@ -0,0 +1,151 @@
#include "effects/pixelsort.hpp"
#include <algorithm>
#include <array>
#include <cstring>
#include <vector>
#include "core/parallel.hpp"
#include "effects/sampling.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& PixelSortEffect::params() const {
static const std::vector<ParamSpec> 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<std::size_t>(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<int>(x));
for (int y = 0; y < src.height(); ++y) {
std::memcpy(row + static_cast<std::size_t>(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<Image>& 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<int>(std::clamp(paramDouble(values, "sort_low", 0.25), 0.0, 1.0) * 255.0 + 0.5);
const int high = static_cast<int>(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<int>(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<LinePlan> plan(static_cast<std::size_t>(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<std::size_t>(lines), [&](std::size_t begin, std::size_t end) {
std::vector<std::pair<int, std::array<std::uint8_t, 3>>> 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<int>(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<std::size_t>(start) + i) * 3, run[i].second.data(), 3);
}
run.clear();
};
int start = 0;
int limit = maxRun > 0 ? static_cast<int>(runRng.inRange(1, maxRun)) : span;
for (int x = 0; x < span; ++x) {
const std::uint8_t* px = row + static_cast<std::size_t>(x) * 3;
const int brightness = luma(px);
if (brightness < low || brightness > high) {
flush(start);
start = x + 1;
continue;
}
if (static_cast<int>(run.size()) >= limit) {
flush(start);
start = x;
if (maxRun > 0) limit = static_cast<int>(runRng.inRange(1, maxRun));
}
run.push_back({keyOf(px), {px[0], px[1], px[2]}});
}
flush(start);
}
});
return vertical ? transposed(canvas) : canvas;
}
} // namespace effector
+26
View File
@@ -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<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
#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<double>(maxX));
y = std::clamp(y, 0.0, static_cast<double>(maxY));
const int x0 = static_cast<int>(x);
const int y0 = static_cast<int>(y);
const int x1 = std::min(x0 + 1, maxX);
const int y1 = std::min(y0 + 1, maxY);
const int fx = static_cast<int>((x - x0) * 256.0);
const int fy = static_cast<int>((y - y0) * 256.0);
const std::uint8_t* a = image.row(y0) + static_cast<std::size_t>(x0) * 3;
const std::uint8_t* b = image.row(y0) + static_cast<std::size_t>(x1) * 3;
const std::uint8_t* c = image.row(y1) + static_cast<std::size_t>(x0) * 3;
const std::uint8_t* d = image.row(y1) + static_cast<std::size_t>(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<std::uint8_t>((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<int>(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<double>(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
+178
View File
@@ -0,0 +1,178 @@
#include "effects/shatter.hpp"
#include <algorithm>
#include <cmath>
#include <limits>
#include <vector>
#include "core/parallel.hpp"
#include "effects/sampling.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& ShatterEffect::params() const {
static const std::vector<ParamSpec> 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<Shard>& shards, int width, int height) : shards_(shards) {
cell_ = std::max(1.0, std::sqrt(static_cast<double>(width) * height /
static_cast<double>(shards.size())));
cols_ = static_cast<int>(width / cell_) + 1;
rows_ = static_cast<int>(height / cell_) + 1;
buckets_.resize(static_cast<std::size_t>(cols_) * rows_);
for (std::size_t i = 0; i < shards.size(); ++i) {
buckets_[bucketOf(shards[i].x, shards[i].y)].push_back(static_cast<int>(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<int>(x / cell_), 0, cols_ - 1);
const int by = std::clamp(static_cast<int>(y / cell_), 0, rows_ - 1);
double best = std::numeric_limits<double>::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<std::size_t>(gy) * cols_ + gx]) {
const Shard& shard = shards_[static_cast<std::size_t>(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<int>(x / cell_), 0, cols_ - 1);
const int by = std::clamp(static_cast<int>(y / cell_), 0, rows_ - 1);
return static_cast<std::size_t>(by) * cols_ + bx;
}
const std::vector<Shard>& shards_;
double cell_ = 1.0;
int cols_ = 1;
int rows_ = 1;
std::vector<std::vector<int>> buckets_;
};
} // namespace
Image ShatterEffect::render(const std::vector<Image>& 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<int>(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<double>(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<Shard> shards(static_cast<std::size_t>(count));
for (auto& shard : shards) {
shard.x = unitDouble(rng.next()) * width;
shard.y = unitDouble(rng.next()) * height;
shard.dx = static_cast<double>(rng.inRange(-displace, displace));
shard.dy = static_cast<double>(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<std::size_t>(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<int>(row));
const double y = static_cast<double>(row) + 0.5;
for (int col = 0; col < width; ++col) {
std::uint8_t* px = out + static_cast<std::size_t>(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<std::size_t>(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<std::size_t>(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
+27
View File
@@ -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<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+99
View File
@@ -0,0 +1,99 @@
#include "effects/slitshift.hpp"
#include <algorithm>
#include <cstring>
#include <vector>
#include "core/parallel.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& SlitShiftEffect::params() const {
static const std::vector<ParamSpec> 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<Image>& 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<int>(std::max(0L, paramInt(values, "shift_max", 120)));
const int bandHeight = static_cast<int>(std::max(1L, paramInt(values, "shift_band_height", 16)));
const int jitter = static_cast<int>(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<int> offsets(static_cast<std::size_t>(lines), 0);
for (long band = 0; band < bands; ++band) {
int thickness = bandHeight;
if (jitter > 0) thickness += static_cast<int>(rng.inRange(-jitter, jitter));
thickness = std::clamp(thickness, 1, lines);
const int start = static_cast<int>(rng.inRange(0, lines - thickness));
const int offset = static_cast<int>(rng.inRange(-maxShift, maxShift));
for (int line = start; line < start + thickness; ++line) {
offsets[static_cast<std::size_t>(line)] += offset;
}
}
Image canvas(width, height);
parallelFor(static_cast<std::size_t>(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<int>(line) : i;
const int y = vertical ? i : static_cast<int>(line);
std::uint8_t* out = canvas.row(y) + static_cast<std::size_t>(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<std::size_t>(fx) * 3, 3);
}
}
});
return canvas;
}
} // namespace effector
+26
View File
@@ -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<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+18
View File
@@ -2,9 +2,11 @@
#include <algorithm>
#include <cstring>
#include <vector>
#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<Image>& 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<int>(rng.inRange(0, std::max(0, src.width() - width)));
const int sy = static_cast<int>(rng.inRange(0, std::max(0, src.height() - height)));
fillFromSource(canvas, src, sx, sy);
}
return canvas;
}
} // namespace effector
+159
View File
@@ -0,0 +1,159 @@
#include "effects/warp.hpp"
#include <algorithm>
#include <cmath>
#include <vector>
#include "core/parallel.hpp"
#include "effects/sampling.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& WarpEffect::params() const {
static const std::vector<ParamSpec> 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<int>(width / spacing) + 2),
rows_(static_cast<int>(height / spacing) + 2),
lattice_(static_cast<std::size_t>(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<int>(gx), cols_ - 2);
const int iy = std::min(static_cast<int>(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<std::size_t>(y) * cols_ + x]; }
double spacing_;
int cols_;
int rows_;
std::vector<double> 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<NoiseLayer> layers_;
std::vector<double> weights_;
double total_ = 0.0;
};
} // namespace
Image WarpEffect::render(const std::vector<Image>& 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<double>(std::max(0L, paramInt(values, "warp_strength", 40)));
const double scale = static_cast<double>(std::max(4L, paramInt(values, "warp_scale", 160)));
const int octaves = static_cast<int>(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<NoiseField> extra;
if (!swirl) extra.emplace_back(rng, width, height, scale, octaves);
Image canvas(width, height);
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
for (std::size_t row = begin; row < end; ++row) {
std::uint8_t* out = canvas.row(static_cast<int>(row));
const double y = static_cast<double>(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<std::size_t>(col) * 3);
}
}
});
return canvas;
}
} // namespace effector
+27
View File
@@ -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<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector