From 749ea239bb872081517f1e31d38821a38014de42 Mon Sep 17 00:00:00 2001 From: Si Date: Fri, 18 Sep 2026 22:10:54 +0200 Subject: [PATCH] Add a tiles effect The two-dimensional counterpart of scanlines: rectangular tiles cut at random positions from the source images, in three arrangements. A grid draws its column widths and row heights once so the cells stay aligned even when jittered; brick repacks every row on its own so the vertical seams do not meet; scatter fills the canvas from one of the images and drops loose tiles over it. The plan is drawn serially from the rng as scanlines does, but scattered tiles overlap, so they are painted in plan order on one thread: letting threads race for the same pixels would leave the output depending on their scheduling. A source smaller than a tile is repeated to fill the cell, sideways and then downwards, which keeps grid and brick gapless. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 4 +- CMakeLists.txt | 1 + src/core/registry.cpp | 2 + src/effects/tiles.cpp | 180 ++++++++++++++++++++++++++++++++++++++++++ src/effects/tiles.hpp | 23 ++++++ tests/core_tests.cpp | 85 ++++++++++++++++++++ 6 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 src/effects/tiles.cpp create mode 100644 src/effects/tiles.hpp diff --git a/CLAUDE.md b/CLAUDE.md index 04c7789..2e812cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,8 +14,8 @@ Everything is warning-clean under `-Wall -Wextra -Wpedantic`; keep it that way. The test suite is a single hand-rolled binary (no framework), so there is no per-test filter: run `./build/tests/effector_tests` directly to see the section -headings (`rng`, `registry`, `scanlines`, `blur`, `chain`, `project`, `image -io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment +headings (`rng`, `registry`, `scanlines`, `tiles`, `blur`, `chain`, `project`, +`image io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment out the other blocks in `tests/core_tests.cpp` or add a new `std::cout << "section\n"` block — checks are plain `check(condition, "description")` calls and `main()` returns the failure count. diff --git a/CMakeLists.txt b/CMakeLists.txt index 19a1b12..72fc0bd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,6 +36,7 @@ add_library(effector_core STATIC src/effects/overlay.cpp src/effects/scanlines.cpp src/effects/text.cpp + src/effects/tiles.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 31fe57d..e107fcf 100644 --- a/src/core/registry.cpp +++ b/src/core/registry.cpp @@ -6,6 +6,7 @@ #include "effects/overlay.hpp" #include "effects/scanlines.hpp" #include "effects/text.hpp" +#include "effects/tiles.hpp" namespace effector { @@ -18,6 +19,7 @@ const std::vector& effects() { std::make_shared(), std::make_shared(), std::make_shared(), + std::make_shared(), }; return registry; } diff --git a/src/effects/tiles.cpp b/src/effects/tiles.cpp new file mode 100644 index 0000000..d315bb1 --- /dev/null +++ b/src/effects/tiles.cpp @@ -0,0 +1,180 @@ +#include "effects/tiles.hpp" + +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/source_fill.hpp" + +namespace effector { + +const std::vector& TilesEffect::params() const { + static const std::vector specs = { + ParamSpec::makeChoice("tile_layout", "Layout", "grid", {"grid", "brick", "scatter"}, + "grid lines the tiles up in rows and columns; brick packs every row " + "on its own so the seams do not meet; scatter drops loose tiles over " + "a filled background, overlapping."), + ParamSpec::makeInt("tile_width", "Tile width", 96, 1, 2048, + "Width in pixels of each cut-out tile."), + ParamSpec::makeInt("tile_height", "Tile height", 96, 1, 2048, + "Height in pixels of each cut-out tile."), + ParamSpec::makeInt("tile_jitter", "Size jitter", 0, 0, 512, + "Random +/- variation added to both tile sizes (0 = uniform)."), + ParamSpec::makeInt("tile_count", "Tile count", 64, 1, 4096, + "How many tiles are dropped on the canvas; scattered layout only."), + ParamSpec::makeBool("tile_keep_order", "Cycle images in order", false, + "Take tiles from the images in order instead of picking randomly."), + }; + return specs; +} + +namespace { + +// One cut-out: `w` by `h` pixels taken from source `sourceIndex` at (sx, sy) +// and laid down at (dstX, dstY). +struct Tile { + int dstX; + int dstY; + int w; + int h; + int sourceIndex; + int sx; + int sy; +}; + +// Splits `span` into consecutive pieces of `base` pixels give or take `jitter`, +// the last one clipped so that the pieces cover the span exactly. +std::vector planSpans(Rng& rng, int base, int jitter, int span) { + std::vector spans; + spans.reserve(static_cast(span / base) + 8); + int used = 0; + while (used < span) { + int size = base; + if (jitter > 0) size += static_cast(rng.inRange(-jitter, jitter)); + size = std::max(1, std::min(size, span - used)); + spans.push_back(size); + used += size; + } + return spans; +} + +void paintTile(Image& canvas, const Image& src, const Tile& tile) { + const int copyWidth = std::min(tile.w, src.width() - tile.sx); + const int copyHeight = std::min(tile.h, src.height() - tile.sy); + const std::size_t copyBytes = static_cast(copyWidth) * 3; + const std::size_t total = static_cast(tile.w) * 3; + const std::size_t offset = static_cast(tile.dstX) * 3; + + for (int line = 0; line < copyHeight; ++line) { + const std::uint8_t* in = src.row(tile.sy + line) + static_cast(tile.sx) * 3; + std::uint8_t* out = canvas.row(tile.dstY + line) + offset; + + std::memcpy(out, in, copyBytes); + // Repeat the cut sideways when the source is narrower than the tile, + // doubling the filled span on each pass. + std::size_t filled = copyBytes; + while (filled < total) { + const std::size_t chunk = std::min(filled, total - filled); + std::memcpy(out + filled, out, chunk); + filled += chunk; + } + } + // Same again downwards, out of the lines already written. + for (int line = copyHeight; line < tile.h; ++line) { + std::memcpy(canvas.row(tile.dstY + line) + offset, + canvas.row(tile.dstY + (line % copyHeight)) + offset, total); + } +} + +} // namespace + +Image TilesEffect::render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const { + if (sources.empty()) throw EffectError("No source images."); + if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); + + const std::string layout = paramChoice(values, "tile_layout", "grid"); + const int tileWidth = static_cast(std::max(1L, paramInt(values, "tile_width", 96))); + const int tileHeight = static_cast(std::max(1L, paramInt(values, "tile_height", 96))); + const int jitter = static_cast(std::max(0L, paramInt(values, "tile_jitter", 0))); + const long count = std::max(1L, paramInt(values, "tile_count", 64)); + const bool keepOrder = paramBool(values, "tile_keep_order", false); + + Image canvas(width, height); + + // The whole plan is drawn sequentially so that a seed always gives the same + // layout; only the pixel copying afterwards is parallelised. + std::size_t index = 0; + auto cut = [&](int dstX, int dstY, int w, int h) { + const int sourceIndex = keepOrder ? static_cast(index++ % sources.size()) + : static_cast(rng.below(sources.size() - 1)); + const Image& src = sources[static_cast(sourceIndex)]; + const int sx = static_cast(rng.inRange(0, std::max(0, src.width() - w))); + const int sy = static_cast(rng.inRange(0, std::max(0, src.height() - h))); + return Tile{dstX, dstY, w, h, sourceIndex, sx, sy}; + }; + + std::vector plan; + if (layout == "scatter") { + // Loose tiles leave gaps, so the canvas starts as one of the images + // rather than as black. + const std::size_t backdrop = rng.below(sources.size() - 1); + const Image& src = sources[backdrop]; + 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); + + plan.reserve(static_cast(count)); + for (long i = 0; i < count; ++i) { + int w = tileWidth; + int h = tileHeight; + if (jitter > 0) { + w += static_cast(rng.inRange(-jitter, jitter)); + h += static_cast(rng.inRange(-jitter, jitter)); + } + const int dstX = static_cast(rng.inRange(0, width - 1)); + const int dstY = static_cast(rng.inRange(0, height - 1)); + w = std::max(1, std::min(w, width - dstX)); + h = std::max(1, std::min(h, height - dstY)); + plan.push_back(cut(dstX, dstY, w, h)); + } + } else { + const std::vector rows = planSpans(rng, tileHeight, jitter, height); + // In a grid every row is cut at the same columns; in the brick layout + // each row is packed on its own, so the vertical seams do not line up. + std::vector columns; + if (layout != "brick") columns = planSpans(rng, tileWidth, jitter, width); + + plan.reserve(rows.size() * (columns.empty() ? 8 : columns.size())); + int y = 0; + for (const int rowHeight : rows) { + if (layout == "brick") columns = planSpans(rng, tileWidth, jitter, width); + int x = 0; + for (const int cellWidth : columns) { + plan.push_back(cut(x, y, cellWidth, rowHeight)); + x += cellWidth; + } + y += rowHeight; + } + } + + if (layout == "scatter") { + // Scattered tiles overlap, so they are painted in plan order on one + // thread: letting threads race for the same pixels would make the + // output depend on their scheduling. + for (const Tile& tile : plan) { + paintTile(canvas, sources[static_cast(tile.sourceIndex)], tile); + } + } else { + parallelFor(plan.size(), [&](std::size_t begin, std::size_t end) { + for (std::size_t i = begin; i < end; ++i) { + paintTile(canvas, sources[static_cast(plan[i].sourceIndex)], plan[i]); + } + }); + } + + return canvas; +} + +} // namespace effector diff --git a/src/effects/tiles.hpp b/src/effects/tiles.hpp new file mode 100644 index 0000000..e0579ae --- /dev/null +++ b/src/effects/tiles.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include "core/effect.hpp" + +namespace effector { + +// Covers the canvas with rectangular tiles cut at random positions from the +// source images: the two-dimensional counterpart of the scanlines effect. +class TilesEffect : public Effect { +public: + std::string name() const override { return "tiles"; } + std::string title() const override { return "Tiles"; } + std::string description() const override { + return "Cuts rectangular tiles at random positions from the source images and lays " + "them over the canvas, in a grid, in offset rows or scattered about."; + } + const std::vector& params() const override; + + 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 013a6cf..da83af1 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -26,6 +26,7 @@ #include "effects/overlay.hpp" #include "effects/scanlines.hpp" #include "effects/text.hpp" +#include "effects/tiles.hpp" namespace { @@ -139,6 +140,15 @@ effector::Image renderText(const std::vector& sources, int widt return effect.render(sources, width, height, rng, merged); } +effector::Image renderTiles(const std::vector& sources, int width, int height, + std::uint64_t seed, const effector::ParamValues& values) { + effector::TilesEffect effect; + 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); +} + // The three channels of one pixel, to read a placement off directly. std::array pixel(const effector::Image& image, int x, int y) { const auto* px = image.row(y) + static_cast(x) * 3; @@ -253,6 +263,81 @@ int main() { check(threw, "no sources throws"); } + std::cout << "tiles\n"; + { + std::vector sources = {gradient(1200, 800, 10), gradient(640, 480, 90), + gradient(300, 200, 200)}; + + for (const std::string layout : {"grid", "brick", "scatter"}) { + const ParamValues values = {{"tile_layout", layout}, + {"tile_width", 70L}, + {"tile_height", 50L}, + {"tile_jitter", 12L}}; + const auto first = renderTiles(sources, 400, 300, 1, values); + const auto again = renderTiles(sources, 400, 300, 1, values); + const auto other = renderTiles(sources, 400, 300, 2, values); + + check(first.width() == 400 && first.height() == 300, layout + " has the requested size"); + check(sameBytes(first, again), layout + ": same seed reproduces the image"); + check(!sameBytes(first, other), layout + ": a different seed changes the image"); + } + + // With no jitter the grid cells line up: the gradient's red channel + // counts up with x inside a tile and only breaks at a cell boundary. + const auto aligned = renderTiles(sources, 400, 300, 5, + {{"tile_layout", std::string("grid")}, {"tile_width", 100L}, + {"tile_height", 100L}}); + bool onlyAtBoundaries = true; + for (int y = 0; y < aligned.height(); ++y) { + const auto* row = aligned.row(y); + for (int x = 1; x < aligned.width(); ++x) { + const bool breaks = row[x * 3] != ((row[(x - 1) * 3] + 1) & 0xFF); + if (breaks && x % 100 != 0) onlyAtBoundaries = false; + } + } + check(onlyAtBoundaries, "grid cells break only on the cell boundaries"); + + // One source smaller than a single tile: the cut is repeated to fill + // the cell, so the canvas is covered rather than left black. + std::vector small = {gradient(60, 40, 200)}; + const auto covered = renderTiles(small, 500, 400, 9, + {{"tile_layout", std::string("brick")}, + {"tile_width", 200L}, + {"tile_height", 150L}}); + bool filled = true; + for (int y = 0; y < covered.height(); ++y) { + const auto* row = covered.row(y); + for (int x = 0; x < covered.width(); ++x) filled = filled && row[x * 3 + 2] == 200; + } + check(filled, "a source smaller than a tile still covers the canvas"); + + const auto tiny = renderTiles(sources, 120, 90, 3, + {{"tile_width", 1L}, {"tile_height", 1L}}); + check(tiny.width() == 120 && tiny.height() == 90, "tiles one pixel across"); + + const auto ordered = renderTiles(sources, 300, 200, 4, + {{"tile_width", 40L}, {"tile_keep_order", true}}); + check(ordered.height() == 200, "keep_order fills the canvas"); + + // A single scattered tile leaves the rest of the canvas to the backdrop. + const auto sparse = renderTiles(small, 400, 300, 6, + {{"tile_layout", std::string("scatter")}, {"tile_count", 1L}}); + bool backdrop = true; + for (int y = 0; y < sparse.height(); ++y) { + const auto* row = sparse.row(y); + for (int x = 0; x < sparse.width(); ++x) backdrop = backdrop && row[x * 3 + 2] == 200; + } + check(backdrop, "scattered tiles land on a filled backdrop"); + + bool threw = false; + try { + renderTiles({}, 100, 100, 1, {}); + } catch (const EffectError&) { + threw = true; + } + check(threw, "no sources throws"); + } + std::cout << "blur\n"; { std::vector sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};