diff --git a/CMakeLists.txt b/CMakeLists.txt index 0139194..c14b919 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,6 +25,7 @@ add_library(effector_core STATIC src/core/params.cpp src/core/registry.cpp src/core/rng.cpp + src/effects/blur.cpp src/effects/scanlines.cpp ) target_include_directories(effector_core PUBLIC src) diff --git a/src/core/registry.cpp b/src/core/registry.cpp index de5c25d..b540896 100644 --- a/src/core/registry.cpp +++ b/src/core/registry.cpp @@ -1,5 +1,6 @@ #include "core/registry.hpp" +#include "effects/blur.hpp" #include "effects/scanlines.hpp" namespace effector { @@ -8,6 +9,7 @@ namespace effector { const std::vector& effects() { static const std::vector registry = { std::make_shared(), + std::make_shared(), }; return registry; } diff --git a/src/effects/blur.cpp b/src/effects/blur.cpp new file mode 100644 index 0000000..81b2e0b --- /dev/null +++ b/src/effects/blur.cpp @@ -0,0 +1,136 @@ +#include "effects/blur.hpp" + +#include +#include +#include + +#include "core/parallel.hpp" + +namespace effector { + +const std::vector& BlurEffect::params() const { + static const std::vector specs = { + ParamSpec::makeInt("radius", "Radius", 8, 1, 256, + "Half width in pixels of the blur window."), + ParamSpec::makeInt("passes", "Passes", 2, 1, 8, + "How often the box blur is repeated; more passes look more gaussian."), + ParamSpec::makeChoice("direction", "Direction", "both", {"both", "horizontal", "vertical"}, + "Axis the blur is applied along."), + ParamSpec::makeBool("first_source", "Use the first image", false, + "Always blur the first source instead of picking one at random."), + }; + return specs; +} + +namespace { + +// Copies `src` into the canvas starting at (sx, sy), repeating it as needed so +// that every pixel is covered even when the source is smaller than the canvas. +void fillFromSource(Image& canvas, const Image& src, int sx, int sy) { + const int width = canvas.width(); + const std::size_t total = static_cast(width) * 3; + const int copyWidth = std::min(width, src.width() - sx); + const std::size_t copyBytes = static_cast(copyWidth) * 3; + + parallelFor(static_cast(canvas.height()), [&](std::size_t begin, std::size_t end) { + for (std::size_t y = begin; y < end; ++y) { + const int srcY = sy + (static_cast(y) % std::max(1, src.height() - sy)); + const std::uint8_t* in = src.row(srcY) + static_cast(sx) * 3; + std::uint8_t* out = canvas.row(static_cast(y)); + + std::memcpy(out, in, copyBytes); + 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; + } + } + }); +} + +// One separable box blur pass. `stride` is the distance in bytes between two +// neighbouring samples along the blurred axis, `count` how many of them there +// are; the window is clamped at both ends. +void boxPass(const std::uint8_t* in, std::uint8_t* out, int count, std::size_t stride, int radius) { + const int window = 2 * radius + 1; + int sum[3] = {0, 0, 0}; + + for (int c = 0; c < 3; ++c) { + sum[c] = in[c] * (radius + 1); + for (int i = 1; i <= radius; ++i) { + sum[c] += in[static_cast(std::min(i, count - 1)) * stride + c]; + } + } + + for (int i = 0; i < count; ++i) { + for (int c = 0; c < 3; ++c) { + out[static_cast(i) * stride + c] = + static_cast(sum[c] / window); + } + const int add = std::min(i + radius + 1, count - 1); + const int drop = std::max(i - radius, 0); + for (int c = 0; c < 3; ++c) { + sum[c] += in[static_cast(add) * stride + c] - + in[static_cast(drop) * stride + c]; + } + } +} + +void blurHorizontal(const Image& src, Image& dst, int radius) { + parallelFor(static_cast(src.height()), [&](std::size_t begin, std::size_t end) { + for (std::size_t y = begin; y < end; ++y) { + boxPass(src.row(static_cast(y)), dst.row(static_cast(y)), src.width(), 3, + radius); + } + }); +} + +void blurVertical(const Image& src, Image& dst, int radius) { + const std::size_t stride = src.stride(); + parallelFor(static_cast(src.width()), [&](std::size_t begin, std::size_t end) { + for (std::size_t x = begin; x < end; ++x) { + const std::size_t offset = x * 3; + boxPass(src.data() + offset, dst.data() + offset, src.height(), stride, radius); + } + }); +} + +} // namespace + +Image BlurEffect::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 int radius = static_cast(std::max(1L, paramInt(values, "radius", 8))); + const int passes = static_cast(std::max(1L, paramInt(values, "passes", 2))); + const std::string direction = paramChoice(values, "direction", "both"); + const bool firstSource = paramBool(values, "first_source", false); + + // Every random value is drawn here, before any threading, so that a seed + // always gives the same image. + const std::size_t index = firstSource ? 0 : rng.below(sources.size() - 1); + const Image& src = sources[index]; + 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))); + + Image canvas(width, height); + fillFromSource(canvas, src, sx, sy); + + Image scratch(width, height); + for (int pass = 0; pass < passes; ++pass) { + if (direction != "vertical") { + blurHorizontal(canvas, scratch, std::min(radius, std::max(1, width - 1))); + std::swap(canvas, scratch); + } + if (direction != "horizontal") { + blurVertical(canvas, scratch, std::min(radius, std::max(1, height - 1))); + std::swap(canvas, scratch); + } + } + + return canvas; +} + +} // namespace effector diff --git a/src/effects/blur.hpp b/src/effects/blur.hpp new file mode 100644 index 0000000..57cf3d2 --- /dev/null +++ b/src/effects/blur.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include "core/effect.hpp" + +namespace effector { + +// Fills the canvas with one of the sources and softens it with a separable box +// blur, repeated a few times to approximate a gaussian. +class BlurEffect : public Effect { +public: + std::string name() const override { return "blur"; } + std::string title() const override { return "Blur"; } + std::string description() const override { + return "Fills the canvas from a randomly chosen source and softens it with a repeated " + "box blur, horizontally, vertically or both."; + } + 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 d913488..3b0e38e 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -11,6 +11,7 @@ #include "core/registry.hpp" #include "core/rng.hpp" +#include "effects/blur.hpp" #include "effects/scanlines.hpp" namespace { @@ -44,6 +45,29 @@ bool sameBytes(const effector::Image& a, const effector::Image& b) { return std::equal(a.data(), a.data() + a.byteSize(), b.data()); } +// Alternating black and white columns: any blur along x has to pull these +// towards mid grey. +effector::Image stripes(int width, int height) { + effector::Image image(width, height); + for (int y = 0; y < height; ++y) { + auto* row = image.row(y); + for (int x = 0; x < width; ++x) { + const std::uint8_t value = (x % 2 == 0) ? 0 : 255; + row[x * 3 + 0] = row[x * 3 + 1] = row[x * 3 + 2] = value; + } + } + return image; +} + +effector::Image renderBlur(const std::vector& sources, int width, int height, + std::uint64_t seed, const effector::ParamValues& values) { + effector::BlurEffect 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); +} + effector::Image render(const std::vector& sources, int width, int height, std::uint64_t seed, const effector::ParamValues& values) { effector::ScanlinesEffect effect; @@ -128,6 +152,54 @@ int main() { check(threw, "no sources throws"); } + std::cout << "blur\n"; + { + std::vector sources = {gradient(1200, 800, 10), gradient(640, 480, 90)}; + + const auto first = renderBlur(sources, 400, 300, 4, {{"radius", 10L}}); + const auto again = renderBlur(sources, 400, 300, 4, {{"radius", 10L}}); + const auto other = renderBlur(sources, 400, 300, 11, {{"radius", 10L}}); + check(first.width() == 400 && first.height() == 300, "canvas has the requested size"); + check(sameBytes(first, again), "same seed reproduces the image"); + check(!sameBytes(first, other), "a different seed changes the image"); + + // A wide blur over one-pixel stripes has to land near mid grey, and only + // along the axis it is applied to. + std::vector striped = {stripes(200, 100)}; + const auto flattened = renderBlur(striped, 200, 100, 1, + {{"radius", 8L}, {"direction", std::string("horizontal")}}); + // The clamped edges ramp in over radius * passes pixels on each side. + bool grey = true; + for (int x = 32; x < 168; ++x) grey = grey && std::abs(flattened.row(50)[x * 3] - 127) <= 2; + check(grey, "a horizontal blur evens out vertical stripes"); + + const auto untouched = renderBlur(striped, 200, 100, 1, + {{"radius", 8L}, {"direction", std::string("vertical")}}); + check(untouched.row(50)[0] == 0 && untouched.row(50)[3] == 255, + "a vertical blur leaves columns alone"); + + // Every column is uniform, so a vertical blur must not change anything. + const auto vertical = renderBlur(striped, 200, 100, 1, + {{"radius", 4L}, {"passes", 3L}, {"direction", std::string("vertical")}}); + check(sameBytes(untouched, vertical), "repeated vertical passes stay stable"); + + // Radius larger than the canvas must clamp rather than read out of bounds. + const auto tiny = renderBlur(striped, 3, 3, 2, {{"radius", 200L}}); + check(tiny.width() == 3 && tiny.height() == 3, "radius wider than the canvas is clamped"); + + const auto pinned = renderBlur(sources, 300, 200, 6, {{"first_source", true}}); + const auto pinnedAgain = renderBlur(sources, 300, 200, 6, {{"first_source", true}}); + check(sameBytes(pinned, pinnedAgain), "first_source is reproducible"); + + bool threw = false; + try { + renderBlur({}, 100, 100, 1, {}); + } catch (const EffectError&) { + threw = true; + } + check(threw, "no sources throws"); + } + std::cout << "image io\n"; { const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png";