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 <noreply@anthropic.com>
This commit is contained in:
+151
-2
@@ -32,6 +32,28 @@ const std::vector<ParamSpec>& OverlayEffect::params() const {
|
|||||||
ParamSpec::makeDouble("overlay_opacity", "Opacity", 1.0, 0.0, 1.0,
|
ParamSpec::makeDouble("overlay_opacity", "Opacity", 1.0, 0.0, 1.0,
|
||||||
"How strongly the image is mixed over the input; its own "
|
"How strongly the image is mixed over the input; its own "
|
||||||
"transparency is taken into account as well."),
|
"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;
|
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<std::size_t>(i) * stride];
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
out[static_cast<std::size_t>(i) * stride] = (sum + window / 2) / window;
|
||||||
|
if (i + radius + 1 < count) sum += in[static_cast<std::size_t>(i + radius + 1) * stride];
|
||||||
|
if (i - radius >= 0) sum -= in[static_cast<std::size_t>(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<std::int32_t>& mask, int width, int height, int size) {
|
||||||
|
if (size <= 0) return;
|
||||||
|
const int radius = std::max(1, (size + 2) / 3);
|
||||||
|
std::vector<std::int32_t> scratch(mask.size());
|
||||||
|
for (int pass = 0; pass < 3; ++pass) {
|
||||||
|
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 std::size_t offset = y * static_cast<std::size_t>(width);
|
||||||
|
maskPass(mask.data() + offset, scratch.data() + offset, width, 1, radius);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
parallelFor(static_cast<std::size_t>(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<std::size_t>(width), radius);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void OverlayEffect::loadOverlay(const std::string& path, Image& image,
|
void OverlayEffect::loadOverlay(const std::string& path, Image& image,
|
||||||
@@ -187,11 +247,100 @@ Image OverlayEffect::render(const std::vector<Image>& sources, int width, int he
|
|||||||
const int lastRow = tiling ? height : std::min(height, place.y + place.height);
|
const int lastRow = tiling ? height : std::min(height, place.y + place.height);
|
||||||
const int firstCol = tiling ? 0 : std::max(0, place.x);
|
const int firstCol = tiling ? 0 : std::max(0, place.x);
|
||||||
const int lastCol = tiling ? width : std::min(width, place.x + place.width);
|
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<int>(opacity * 255.0 + 0.5);
|
const int mix = static_cast<int>(opacity * 255.0 + 0.5);
|
||||||
const std::uint8_t* alphaData = alpha.data();
|
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<int>(std::clamp<long>(paramInt(values, "overlay_halo_size", 12), 0, 500));
|
||||||
|
const int reach = 3 * std::max(1, (size + 2) / 3);
|
||||||
|
const int shiftX = shadow ? static_cast<int>(paramInt(values, "overlay_shadow_x", 8)) : 0;
|
||||||
|
const int shiftY = shadow ? static_cast<int>(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<std::int32_t> mask(static_cast<std::size_t>(maskWidth) * maskHeight, 0);
|
||||||
|
parallelFor(static_cast<std::size_t>(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<int>(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<std::size_t>(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<int>(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<std::int32_t>(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<std::size_t>(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<int>(i);
|
||||||
|
const std::int32_t* in =
|
||||||
|
mask.data() + static_cast<std::size_t>(y - shiftY - top) * maskWidth;
|
||||||
|
std::uint8_t* out = canvas.row(y);
|
||||||
|
for (int x = fromCol; x < toCol; ++x) {
|
||||||
|
const int weight =
|
||||||
|
std::clamp<int>(in[x - shiftX - left] * gain / 256, 0, 255) *
|
||||||
|
haloMix / 255;
|
||||||
|
if (weight == 0) continue;
|
||||||
|
std::uint8_t* px = out + static_cast<std::size_t>(x) * 3;
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
px[c] = static_cast<std::uint8_t>(
|
||||||
|
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<std::size_t>(lastRow - firstRow), [&](std::size_t begin,
|
parallelFor(static_cast<std::size_t>(lastRow - firstRow), [&](std::size_t begin,
|
||||||
std::size_t end) {
|
std::size_t end) {
|
||||||
int rgb[3];
|
int rgb[3];
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ namespace effector {
|
|||||||
|
|
||||||
// Lays one picture from disk over its input: a logo, a texture, a frame. The
|
// 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
|
// 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
|
// 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.
|
// sources it fills the canvas from a randomly chosen one first.
|
||||||
class OverlayEffect : public Effect {
|
class OverlayEffect : public Effect {
|
||||||
@@ -20,7 +21,8 @@ public:
|
|||||||
std::string title() const override { return "Image overlay"; }
|
std::string title() const override { return "Image overlay"; }
|
||||||
std::string description() const override {
|
std::string description() const override {
|
||||||
return "Lays a single image from disk over its input, fitted to the canvas, centred or "
|
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<ParamSpec>& params() const override;
|
const std::vector<ParamSpec>& params() const override;
|
||||||
bool needsSources() const override { return false; }
|
bool needsSources() const override { return false; }
|
||||||
|
|||||||
@@ -638,6 +638,49 @@ int main() {
|
|||||||
solid(100, 100, 0, 0, 255)),
|
solid(100, 100, 0, 0, 255)),
|
||||||
"zero opacity leaves the input untouched");
|
"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.
|
// 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)),
|
check(sameBytes(renderOverlay(blue, 100, 100, {}), solid(100, 100, 0, 0, 255)),
|
||||||
"with no image chosen the stage passes its input through");
|
"with no image chosen the stage passes its input through");
|
||||||
|
|||||||
Reference in New Issue
Block a user