From 9fb4428a3bdb82812ac3bd0e72679bbc1b6c283b Mon Sep 17 00:00:00 2001 From: Si Date: Thu, 24 Sep 2026 00:56:27 +0200 Subject: [PATCH] Give the image overlay an optional shadow or glow A blurred copy of the overlay's own alpha is drawn in a colour beneath it: moved by an offset for a shadow, centred for a glow, with a size, a spread and an opacity. The mask is integer-only so a seed still reproduces it, and reaches past the canvas so an image lying off it still casts onto it. Co-Authored-By: Claude Opus 5.5 --- src/effects/overlay.cpp | 153 +++++++++++++++++++++++++++++++++++++++- src/effects/overlay.hpp | 6 +- tests/core_tests.cpp | 43 +++++++++++ 3 files changed, 198 insertions(+), 4 deletions(-) diff --git a/src/effects/overlay.cpp b/src/effects/overlay.cpp index 4900fb4..3d69016 100644 --- a/src/effects/overlay.cpp +++ b/src/effects/overlay.cpp @@ -32,6 +32,28 @@ const std::vector& OverlayEffect::params() const { ParamSpec::makeDouble("overlay_opacity", "Opacity", 1.0, 0.0, 1.0, "How strongly the image is mixed over the input; its own " "transparency is taken into account as well."), + ParamSpec::makeChoice("overlay_halo", "Halo", "none", {"none", "shadow", "glow"}, + "Draws a soft copy of the image's outline beneath it: a shadow, " + "moved by the shadow offset, or a glow all around it."), + ParamSpec::makeColor("overlay_shadow_color", "Shadow colour", "#000000", + "The colour of the shadow."), + ParamSpec::makeColor("overlay_glow_color", "Glow colour", "#ffffff", + "The colour of the glow."), + ParamSpec::makeInt("overlay_halo_size", "Halo size", 12, 0, 500, + "How far the shadow or glow fades out, in pixels; 0 gives a hard " + "edge."), + ParamSpec::makeDouble("overlay_halo_spread", "Halo spread", 0.0, 0.0, 0.9, + "Pushes the full strength of the shadow or glow out from the " + "image's edge before it starts to fade; a glow usually wants some."), + ParamSpec::makeDouble("overlay_halo_opacity", "Halo opacity", 0.75, 0.0, 1.0, + "How strongly the shadow or glow is drawn, on top of the image's " + "own opacity."), + ParamSpec::makeInt("overlay_shadow_x", "Shadow X", 8, -2000, 2000, + "Moves the shadow right by this many pixels (left when negative); " + "a glow stays centred."), + ParamSpec::makeInt("overlay_shadow_y", "Shadow Y", 8, -2000, 2000, + "Moves the shadow down by this many pixels (up when negative); a " + "glow stays centred."), }; return specs; } @@ -123,6 +145,44 @@ void sampleOverlay(const Image& src, const std::uint8_t* alpha, double u, double } } +// One box blur pass over a line of the halo mask. Unlike the blur effect's the +// window counts zero beyond both ends, so the halo fades out at the edge of +// the mask instead of smearing its last value outwards. Integers throughout, +// so a seed reproduces the halo byte for byte on any machine. +void maskPass(const std::int32_t* in, std::int32_t* out, int count, std::size_t stride, + int radius) { + const int window = 2 * radius + 1; + std::int32_t sum = 0; + for (int i = 0; i <= radius && i < count; ++i) sum += in[static_cast(i) * stride]; + for (int i = 0; i < count; ++i) { + out[static_cast(i) * stride] = (sum + window / 2) / window; + if (i + radius + 1 < count) sum += in[static_cast(i + radius + 1) * stride]; + if (i - radius >= 0) sum -= in[static_cast(i - radius) * stride]; + } +} + +// Three box passes each way, which is close enough to a gaussian: `size` is +// roughly how far the result reaches past the edge of what was covered. +void blurMask(std::vector& mask, int width, int height, int size) { + if (size <= 0) return; + const int radius = std::max(1, (size + 2) / 3); + std::vector scratch(mask.size()); + for (int pass = 0; pass < 3; ++pass) { + parallelFor(static_cast(height), [&](std::size_t begin, std::size_t end) { + for (std::size_t y = begin; y < end; ++y) { + const std::size_t offset = y * static_cast(width); + maskPass(mask.data() + offset, scratch.data() + offset, width, 1, radius); + } + }); + parallelFor(static_cast(width), [&](std::size_t begin, std::size_t end) { + for (std::size_t x = begin; x < end; ++x) { + maskPass(scratch.data() + x, mask.data() + x, height, + static_cast(width), radius); + } + }); + } +} + } // namespace void OverlayEffect::loadOverlay(const std::string& path, Image& image, @@ -187,11 +247,100 @@ Image OverlayEffect::render(const std::vector& sources, int width, int he const int lastRow = tiling ? height : std::min(height, place.y + place.height); const int firstCol = tiling ? 0 : std::max(0, place.x); const int lastCol = tiling ? width : std::min(width, place.x + place.width); - if (firstRow >= lastRow || firstCol >= lastCol) return canvas; // entirely off the canvas - const int mix = static_cast(opacity * 255.0 + 0.5); const std::uint8_t* alphaData = alpha.data(); + // The halo goes down first, so that the image covers it wherever it is + // opaque and shows it through wherever it is not. + const std::string halo = paramChoice(values, "overlay_halo", "none"); + const double haloOpacity = + std::clamp(paramDouble(values, "overlay_halo_opacity", 0.75), 0.0, 1.0); + if ((halo == "shadow" || halo == "glow") && haloOpacity > 0.0) { + const bool shadow = halo == "shadow"; + const int size = + static_cast(std::clamp(paramInt(values, "overlay_halo_size", 12), 0, 500)); + const int reach = 3 * std::max(1, (size + 2) / 3); + const int shiftX = shadow ? static_cast(paramInt(values, "overlay_shadow_x", 8)) : 0; + const int shiftY = shadow ? static_cast(paramInt(values, "overlay_shadow_y", 8)) : 0; + std::uint8_t colour[3]; + paramColor(values, shadow ? "overlay_shadow_color" : "overlay_glow_color", + shadow ? "#000000" : "#ffffff", colour); + + // The mask covers the canvas as the shadow sees it (moved back by the + // offset) plus the blur's reach, so that parts of the image lying off + // the canvas still cast onto it. Outside the image nothing is covered, + // so the mask is cut down to the image plus that reach as well. + int left = -shiftX - reach; + int top = -shiftY - reach; + int right = width - shiftX + reach; + int bottom = height - shiftY + reach; + if (!tiling) { + left = std::max(left, place.x - reach); + top = std::max(top, place.y - reach); + right = std::min(right, place.x + place.width + reach); + bottom = std::min(bottom, place.y + place.height + reach); + } + if (left < right && top < bottom) { + const int maskWidth = right - left; + const int maskHeight = bottom - top; + std::vector mask(static_cast(maskWidth) * maskHeight, 0); + parallelFor(static_cast(maskHeight), [&](std::size_t begin, + std::size_t end) { + int rgb[3]; + int pixelAlpha = 255; + for (std::size_t i = begin; i < end; ++i) { + const int y = top + static_cast(i); + if (!tiling && (y < place.y || y >= place.y + place.height)) continue; + const double v = (y - place.y + 0.5) * stepY - 0.5; + std::int32_t* out = mask.data() + i * static_cast(maskWidth); + for (int x = left; x < right; ++x) { + if (!tiling && (x < place.x || x >= place.x + place.width)) continue; + const double u = (x - place.x + 0.5) * stepX - 0.5; + sampleOverlay(overlay, alphaData, u, v, tiling, rgb, pixelAlpha); + out[x - left] = pixelAlpha; + } + } + }); + blurMask(mask, maskWidth, maskHeight, size); + + const int haloMix = static_cast(haloOpacity * mix + 0.5); + // Spread amplifies the blurred mask, so the part near the edge + // saturates and only the outer rim still fades. + const double spread = + std::clamp(paramDouble(values, "overlay_halo_spread", 0.0), 0.0, 0.9); + const std::int32_t gain = static_cast(256.0 / (1.0 - spread) + 0.5); + const int fromRow = std::max(0, top + shiftY); + const int toRow = std::min(height, bottom + shiftY); + const int fromCol = std::max(0, left + shiftX); + const int toCol = std::min(width, right + shiftX); + if (fromRow < toRow && fromCol < toCol) { + parallelFor(static_cast(toRow - fromRow), [&](std::size_t begin, + std::size_t end) { + for (std::size_t i = begin; i < end; ++i) { + const int y = fromRow + static_cast(i); + const std::int32_t* in = + mask.data() + static_cast(y - shiftY - top) * maskWidth; + std::uint8_t* out = canvas.row(y); + for (int x = fromCol; x < toCol; ++x) { + const int weight = + std::clamp(in[x - shiftX - left] * gain / 256, 0, 255) * + haloMix / 255; + if (weight == 0) continue; + std::uint8_t* px = out + static_cast(x) * 3; + for (int c = 0; c < 3; ++c) { + px[c] = static_cast( + px[c] + (colour[c] - px[c]) * weight / 255); + } + } + } + }); + } + } + } + + // A shadow can reach the canvas from an image lying wholly beside it. + if (firstRow >= lastRow || firstCol >= lastCol) return canvas; + parallelFor(static_cast(lastRow - firstRow), [&](std::size_t begin, std::size_t end) { int rgb[3]; diff --git a/src/effects/overlay.hpp b/src/effects/overlay.hpp index 42caad6..1a093d6 100644 --- a/src/effects/overlay.hpp +++ b/src/effects/overlay.hpp @@ -11,7 +11,8 @@ namespace effector { // Lays one picture from disk over its input: a logo, a texture, a frame. The // overlay is fitted to the canvas in the chosen way, placed from the top left -// or from the centre, nudged by an offset and mixed in by its opacity. Like the +// or from the centre, nudged by an offset and mixed in by its opacity, with a +// soft shadow or glow cut from its own outline beneath it if asked. Like the // blur it needs no source images of its own, so it can lead a chain; given // sources it fills the canvas from a randomly chosen one first. class OverlayEffect : public Effect { @@ -20,7 +21,8 @@ public: std::string title() const override { return "Image overlay"; } std::string description() const override { return "Lays a single image from disk over its input, fitted to the canvas, centred or " - "not, nudged by an offset and mixed in by its opacity."; + "not, nudged by an offset and mixed in by its opacity, optionally with a shadow or " + "a glow around it."; } const std::vector& params() const override; bool needsSources() const override { return false; } diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index 747c935..c659bb8 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -638,6 +638,49 @@ int main() { solid(100, 100, 0, 0, 255)), "zero opacity leaves the input untouched"); + // Halo: none by default, a shadow moved by its offset, a glow all round. + check(sameBytes(renderOverlay(blue, 100, 100, at), + renderOverlay(blue, 100, 100, [&] { + ParamValues none = at; + none["overlay_halo"] = std::string("none"); + return none; + }())), + "no halo is the default"); + ParamValues shadowed = at; + shadowed["overlay_halo"] = std::string("shadow"); + shadowed["overlay_halo_size"] = 0L; + shadowed["overlay_halo_opacity"] = 1.0; + shadowed["overlay_shadow_x"] = 5L; + shadowed["overlay_shadow_y"] = 5L; + const auto shadow = renderOverlay(blue, 100, 100, shadowed); + const auto dark = pixel(shadow, 72, 62); // past the logo's bottom right corner (70, 60) + check(dark[0] == 0 && dark[1] == 0 && dark[2] == 0, + "a hard shadow is cast down and right by its offset"); + check(isBlue(pixel(shadow, 28, 50)) && isRed(pixel(shadow, 50, 50)), + "it leaves the other side alone and stays beneath the image"); + shadowed["overlay_halo_size"] = 12L; + const auto soft = pixel(renderOverlay(blue, 100, 100, shadowed), 78, 50); + check(soft[2] > 0 && soft[2] < 255, "a soft shadow fades out past the edge"); + + ParamValues glowing = at; + glowing["overlay_halo"] = std::string("glow"); + const auto glow = renderOverlay(blue, 100, 100, glowing); + const auto left = pixel(glow, 27, 50); + const auto right = pixel(glow, 72, 50); + check(left[0] > 0 && left[0] == right[0] && left[1] == right[1], + "a glow lightens both sides alike"); + check(isBlue(pixel(glow, 2, 2)), "and fades out before the far corners"); + glowing["overlay_halo_spread"] = 0.5; + check(pixel(renderOverlay(blue, 100, 100, glowing), 27, 50)[0] > left[0], + "spread strengthens the halo near the edge"); + + ParamValues offCanvas = shadowed; + offCanvas["overlay_halo_size"] = 0L; + offCanvas["overlay_offset_x"] = -70L; // the logo ends just left of the canvas + offCanvas["overlay_shadow_x"] = 20L; + const auto cast = pixel(renderOverlay(blue, 100, 100, offCanvas), 5, 50); + check(cast[2] == 0, "an image off the canvas still casts its shadow onto it"); + // No image chosen is the state the interface starts in: a pass through. check(sameBytes(renderOverlay(blue, 100, 100, {}), solid(100, 100, 0, 0, 255)), "with no image chosen the stage passes its input through");