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)};