Share the drop shadow code between the UI atlas and the text drawer

CompositeDropShadow in Common/Render/DropShadow.cpp blurs a premultiplied
RGBA image's alpha (with an optional offset) and composites the image
over it. The atlas's AddDropShadow and TextDrawer::AddDropShadow are now
thin wrappers that pick the parameters.

The blur scatters only the non-transparent texels, which makes atlas
shadows 3-7x faster than the old gather-with-clamping version. Atlas
output is unchanged apart from rounding (alpha differs by at most 1).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Henrik RydgårdandClaude Opus 5.5 committed 2026-10-23 11:56:24 +02:00
1 parent 352c0da492
commit fcfac13643
12 files changed
+200 -191

No files matched your search

+2
View File
@@ -272,6 +272,8 @@ add_library(Common STATIC
Profiler/Profiler.h
Render/AtlasGen.cpp
Render/AtlasGen.h
Render/DropShadow.cpp
Render/DropShadow.h
Render/TextureAtlas.cpp
Render/TextureAtlas.h
Render/DrawBuffer.cpp
+2
View File
@@ -495,6 +495,7 @@
<ClInclude Include="Net\WebsocketServer.h" />
<ClInclude Include="Profiler\Profiler.h" />
<ClInclude Include="Render\AtlasGen.h" />
<ClInclude Include="Render\DropShadow.h" />
<ClInclude Include="Render\DrawBuffer.h" />
<ClInclude Include="Render\ManagedTexture.h" />
<ClInclude Include="Render\TextureAtlas.h" />
@@ -1019,6 +1020,7 @@
<ClCompile Include="Net\WebsocketServer.cpp" />
<ClCompile Include="Profiler\Profiler.cpp" />
<ClCompile Include="Render\AtlasGen.cpp" />
<ClCompile Include="Render\DropShadow.cpp" />
<ClCompile Include="Render\DrawBuffer.cpp" />
<ClCompile Include="Render\ManagedTexture.cpp" />
<ClCompile Include="Render\TextureAtlas.cpp" />
+6
View File
@@ -698,6 +698,9 @@
<ClInclude Include="Render\AtlasGen.h">
<Filter>Render</Filter>
</ClInclude>
<ClInclude Include="Render\DropShadow.h">
<Filter>Render</Filter>
</ClInclude>
<ClInclude Include="..\ext\nanosvg\src\nanosvg.h">
<Filter>ext\nanosvg</Filter>
</ClInclude>
@@ -1417,6 +1420,9 @@
<ClCompile Include="Render\AtlasGen.cpp">
<Filter>Render</Filter>
</ClCompile>
<ClCompile Include="Render\DropShadow.cpp">
<Filter>Render</Filter>
</ClCompile>
<ClCompile Include="UI\TabHolder.cpp">
<Filter>UI</Filter>
</ClCompile>
+9 -118
View File
@@ -23,6 +23,7 @@
#include "Common/File/FileUtil.h"
#include "Common/File/VFS/VFS.h"
#include "Common/Render/AtlasGen.h"
#include "Common/Render/DropShadow.h"
typedef unsigned short u16;
@@ -227,78 +228,6 @@ AtlasImage ToAtlasImage(int id, std::string_view name, float tw, float th, const
return img;
}
// The below is ChatGPT-generated drop shadow code. Needs optimization!
static std::vector<float> makeGaussianKernel(int radius) {
const float sigma = radius / 2.0f;
std::vector<float> kernel(2 * radius + 1);
float sum = 0.0f;
for (int i = -radius; i <= radius; i++) {
float val = std::exp(-(i * i) / (2 * sigma * sigma));
kernel[i + radius] = val;
sum += val;
}
sum = 1.0f / sum;
for (float &v : kernel)
v *= sum;
return kernel;
}
static void blurAlpha(const std::vector<float> &src, std::vector<float> &dst, int w, int h, int radius) {
auto kernel = makeGaussianKernel(radius);
int ksize = (int)kernel.size();
int kr = radius;
std::vector<float> tmp(w * h, 0.0f);
// horizontal
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
float sum = 0.0f;
for (int k = -kr; k <= kr; k++) {
int xx = std::clamp(x + k, 0, w - 1);
sum += src[y * w + xx] * kernel[k + kr];
}
tmp[y * w + x] = sum;
}
}
// vertical
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
float sum = 0.0f;
for (int k = -kr; k <= kr; k++) {
int yy = std::clamp(y + k, 0, h - 1);
sum += tmp[yy * w + x] * kernel[k + kr];
}
dst[y * w + x] = sum;
}
}
}
static inline uint32_t Over_ABGR(uint32_t front, uint32_t back) {
const uint32_t fr = front & 0xFFu;
const uint32_t fg = (front >> 8) & 0xFFu; // green
const uint32_t fb = (front >> 16) & 0xFFu;
const uint32_t fa = (front >> 24) & 0xFFu;
const uint32_t br = back & 0xFFu;
const uint32_t bg = (back >> 8) & 0xFFu; // green
const uint32_t bb = (back >> 16) & 0xFFu;
const uint32_t ba = (back >> 24) & 0xFFu;
const uint32_t invA = 255u - fa;
// multiply then divide by 255 with rounding equivalent to (x*invA + 127)/255
uint32_t outr = fr + (uint32_t)((br * invA + 127u) / 255u);
uint32_t outg = fg + (uint32_t)((bg * invA + 127u) / 255u);
uint32_t outb = fb + (uint32_t)((bb * invA + 127u) / 255u);
uint32_t outa = fa + (uint32_t)((ba * invA + 127u) / 255u);
// pack back to ABGR
return (outa << 24) | (outb << 16) | (outg << 8) | outr;
}
void Add1PxTransparentBorder(Image &img) {
std::vector<u32> newData((img.w + 2) * (img.h + 2), 0);
for (int y = 0; y < img.h; y++) {
@@ -313,51 +242,13 @@ void Add1PxTransparentBorder(Image &img) {
}
void AddDropShadow(Image &img, int shadowSize, float intensity) {
int radius = std::max(1, (int)(shadowSize * img.scale));
// Expand canvas so blur has space on all sides
int newW = img.w + radius * 2;
int newH = img.h + radius * 2;
// Expanded alpha buffer
std::vector<float> alpha(newW * newH, 0.0f);
for (int y = 0; y < img.h; y++) {
for (int x = 0; x < img.w; x++) {
float a = ((img.dat[y * img.w + x] >> 24) & 0xFF) * (1.0f / 255.0f);
alpha[(y + radius) * newW + (x + radius)] = a;
}
}
// Blur the expanded alpha
std::vector<float> blurred(newW * newH, 0.0f);
blurAlpha(alpha, blurred, newW, newH, radius);
// Target buffer with transparent background
std::vector<u32> newData(newW * newH, 0);
// Draw the computed shadow first (black, blurred alpha - automatically premultiplied).
for (int y = 0; y < newH; y++) {
for (int x = 0; x < newW; x++) {
float a = blurred[y * newW + x];
if (a > 0.001f) {
newData[y * newW + x] = ((u32)(a * 255 * intensity) << 24);
}
}
}
// Composite original image on top (centered in expanded buffer)
for (int y = 0; y < img.h; y++) {
for (int x = 0; x < img.w; x++) {
u32 c = img.dat[y * img.w + x];
if ((c >> 24) & 0xFF) {
int nx = x + radius;
int ny = y + radius;
newData[ny * newW + nx] = Over_ABGR(c, newData[ny * newW + nx]);
}
}
}
img.w = newW;
img.h = newH;
DropShadowParams params;
params.radius = std::max(1, (int)(shadowSize * img.scale));
params.intensity = intensity;
const int pad = params.Padding();
std::vector<u32> newData((img.w + pad * 2) * (img.h + pad * 2));
CompositeDropShadow(img.dat.data(), img.w, img.h, newData.data(), img.w + pad * 2, params);
img.w += pad * 2;
img.h += pad * 2;
img.dat = std::move(newData);
}
+79
View File
@@ -0,0 +1,79 @@
// Copyright (c) 2012- PPSSPP Project.
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 2.0 or later versions.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License 2.0 for more details.
// A copy of the GPL 2.0 should have been included with the program.
// If not, see http://www.gnu.org/licenses/
// Official git repository and contact information can be found at
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
#include <cmath>
#include <vector>
#include "Common/Render/DropShadow.h"
void CompositeDropShadow(const u32 *src, int w, int h, u32 *dst, int dstStride, const DropShadowParams &params) {
const int radius = std::max(1, params.radius);
const int pad = params.Padding();
const int outW = w + pad * 2;
const int outH = h + pad * 2;
const float sigma = radius * 0.5f;
std::vector<float> kernel(radius * 2 + 1);
float kernelSum = 0.0f;
for (int i = -radius; i <= radius; i++) {
kernel[i + radius] = expf(-(float)(i * i) / (2.0f * sigma * sigma));
kernelSum += kernel[i + radius];
}
for (float &k : kernel) {
k /= kernelSum;
}
// Blur the offset alpha, scattering each texel horizontally into rows, then the rows vertically.
// Scattering lets us skip the transparent texels, which are most of them.
std::vector<float> rows(outW * h);
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
const u32 a = src[y * w + x] >> 24;
if (!a) {
continue;
}
const float fa = a * (1.0f / 255.0f);
float *row = &rows[y * outW + x + pad + params.offsetX - radius];
for (int i = 0; i <= radius * 2; i++) {
row[i] += fa * kernel[i];
}
}
}
std::vector<float> shadow(outW * outH);
for (int y = 0; y < h; y++) {
const float *row = &rows[y * outW];
for (int i = 0; i <= radius * 2; i++) {
float *out = &shadow[(y + pad + params.offsetY - radius + i) * outW];
const float k = kernel[i];
for (int x = 0; x < outW; x++) {
out[x] += row[x] * k;
}
}
}
for (int y = 0; y < outH; y++) {
for (int x = 0; x < outW; x++) {
const int sx = x - pad;
const int sy = y - pad;
const u32 color = (sx >= 0 && sx < w && sy >= 0 && sy < h) ? src[sy * w + sx] : 0;
const u32 alpha = color >> 24;
const float shadowAlpha = std::min(1.0f, shadow[y * outW + x] * params.intensity);
const u32 outAlpha = alpha + (u32)((255 - alpha) * shadowAlpha + 0.5f);
dst[y * dstStride + x] = (outAlpha << 24) | (color & 0x00FFFFFF);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
// Copyright (c) 2012- PPSSPP Project.
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 2.0 or later versions.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License 2.0 for more details.
// A copy of the GPL 2.0 should have been included with the program.
// If not, see http://www.gnu.org/licenses/
// Official git repository and contact information can be found at
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
#pragma once
#include <algorithm>
#include <cstdlib>
#include "Common/CommonTypes.h"
// A soft black shadow, shared by the UI atlas images and the text drawer.
struct DropShadowParams {
// Blur kernel half-width in pixels. The gaussian's sigma is half of it.
int radius = 1;
// Shadow offset in pixels, positive is right/down.
int offsetX = 0;
int offsetY = 0;
// Multiplies the shadow's alpha, which is then clamped to 1.
float intensity = 1.0f;
// Room the shadow needs on each side of the image.
int Padding() const {
return radius + std::max(abs(offsetX), abs(offsetY));
}
};
// src is premultiplied RGBA with alpha in the top byte. Writes it composited over its shadow to dst,
// (w + 2 * padding) x (h + 2 * padding) pixels with the image centered. Since the shadow is black,
// only alpha changes under the image.
void CompositeDropShadow(const u32 *src, int w, int h, u32 *dst, int dstStride, const DropShadowParams &params);
+20 -73
View File
@@ -17,6 +17,7 @@
#include "Common/Data/Text/WrapText.h"
#include "Common/Data/Encoding/Utf8.h"
#include "Common/Render/DropShadow.h"
#include "Common/Render/Text/draw_text.h"
#include "Common/Render/Text/draw_text_win.h"
#include "Common/Render/Text/draw_text_cocoa.h"
@@ -45,9 +46,9 @@ static bool IsRenderDocLoaded() {
#endif
}
// Drop shadow blur (sigma) and offset as fractions of the font size, which is in UI units. The bitmap
// Drop shadow blur radius and offset as fractions of the font size, which is in UI units. The bitmap
// has 1/dpiScale_ pixels per UI unit, so at higher DPI the blur spans more pixels.
static constexpr float SHADOW_BLUR = 1.0f / 14.0f;
static constexpr float SHADOW_BLUR = 1.0f / 7.0f;
static constexpr float SHADOW_OFFSET = 1.0f / 16.0f;
// Blurring thin strokes spreads them thin, so boost the shadow's alpha a bit (clamped).
static constexpr float SHADOW_STRENGTH = 1.5f;
@@ -273,85 +274,31 @@ bool TextDrawer::DrawStringBitmapRect(std::vector<uint8_t> &bitmapData, TextStri
}
void TextDrawer::AddDropShadow(std::vector<uint8_t> &bitmapData, TextStringEntry &entry, Draw::DataFormat srcFormat) const {
const bool srcColor = srcFormat == Draw::DataFormat::R8G8B8A8_UNORM;
const float pixelsPerUnit = 1.0f / dpiScale_;
const float sigma = std::max(0.5f, fontStyle_.sizePts * SHADOW_BLUR * pixelsPerUnit);
const int radius = (int)ceilf(sigma * 2.5f);
const int offset = std::max(1, (int)roundf(fontStyle_.sizePts * SHADOW_OFFSET * pixelsPerUnit));
// Same padding on all sides, so the text stays centered in the bitmap.
const int pad = radius + offset;
DropShadowParams params;
params.radius = std::max(1, (int)roundf(fontStyle_.sizePts * SHADOW_BLUR * pixelsPerUnit));
params.offsetX = params.offsetY = std::max(1, (int)roundf(fontStyle_.sizePts * SHADOW_OFFSET * pixelsPerUnit));
params.intensity = SHADOW_STRENGTH;
const int pad = params.Padding();
const int srcW = entry.bmWidth;
const int srcH = entry.bmHeight;
const int w = srcW + pad * 2;
const int h = srcH + pad * 2;
std::vector<float> kernel(radius * 2 + 1);
float kernelSum = 0.0f;
for (int i = -radius; i <= radius; i++) {
kernel[i + radius] = expf(-(float)(i * i) / (2.0f * sigma * sigma));
kernelSum += kernel[i + radius];
}
for (float &k : kernel) {
k /= kernelSum;
}
auto srcAlpha = [&](int x, int y) -> uint8_t {
return srcColor ? bitmapData[(y * srcW + x) * 4 + 3] : bitmapData[y * srcW + x];
};
// Blur the offset coverage, scattering each texel horizontally into rows, then the rows vertically.
std::vector<float> rows(w * srcH);
for (int y = 0; y < srcH; y++) {
for (int x = 0; x < srcW; x++) {
const uint8_t a = srcAlpha(x, y);
if (!a) {
continue;
}
const float fa = a * (1.0f / 255.0f);
float *dst = &rows[y * w + x + pad + offset - radius];
for (int i = 0; i <= radius * 2; i++) {
dst[i] += fa * kernel[i];
}
}
}
std::vector<float> shadow(w * h);
for (int y = 0; y < srcH; y++) {
const float *src = &rows[y * w];
for (int i = 0; i <= radius * 2; i++) {
float *dst = &shadow[(y + pad + offset - radius + i) * w];
const float k = kernel[i];
for (int x = 0; x < w; x++) {
dst[x] += src[x] * k;
}
std::vector<u32> src;
if (srcFormat == Draw::DataFormat::R8G8B8A8_UNORM) {
src.resize(srcW * srcH);
memcpy(src.data(), bitmapData.data(), src.size() * sizeof(u32));
} else {
src.reserve(srcW * srcH);
for (uint8_t a : bitmapData) {
src.push_back(AlphaToPremul8888(a));
}
}
// Composite the (premultiplied) text over the black shadow. Only alpha changes, since the shadow adds no color.
const int bmWidth = (w + 3) & ~3;
const int bmHeight = (h + 3) & ~3;
std::vector<uint8_t> out(bmWidth * bmHeight * sizeof(uint32_t));
uint32_t *out32 = (uint32_t *)out.data();
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
const int sx = x - pad;
const int sy = y - pad;
uint32_t textColor = 0;
if (sx >= 0 && sx < srcW && sy >= 0 && sy < srcH) {
if (srcColor) {
memcpy(&textColor, &bitmapData[(sy * srcW + sx) * 4], sizeof(textColor));
} else {
textColor = AlphaToPremul8888(bitmapData[sy * srcW + sx]);
}
}
const uint32_t textAlpha = textColor >> 24;
const float shadowAlpha = std::min(1.0f, shadow[y * w + x] * SHADOW_STRENGTH);
const uint32_t alpha = textAlpha + (uint32_t)((255 - textAlpha) * shadowAlpha + 0.5f);
out32[y * bmWidth + x] = (alpha << 24) | (textColor & 0x00FFFFFF);
}
}
const int bmWidth = (srcW + pad * 2 + 3) & ~3;
const int bmHeight = (srcH + pad * 2 + 3) & ~3;
bitmapData.assign(bmWidth * bmHeight * sizeof(u32), 0);
CompositeDropShadow(src.data(), srcW, srcH, (u32 *)bitmapData.data(), bmWidth, params);
bitmapData = std::move(out);
entry.bmWidth = bmWidth;
entry.bmHeight = bmHeight;
entry.shadowPad = pad;
+2
View File
@@ -163,6 +163,7 @@
<ClInclude Include="..\..\Common\Net\WebsocketServer.h" />
<ClInclude Include="..\..\Common\Profiler\Profiler.h" />
<ClInclude Include="..\..\Common\Render\AtlasGen.h" />
<ClInclude Include="..\..\Common\Render\DropShadow.h" />
<ClInclude Include="..\..\Common\Render\DrawBuffer.h" />
<ClInclude Include="..\..\Common\Render\ManagedTexture.h" />
<ClInclude Include="..\..\Common\Render\TextureAtlas.h" />
@@ -365,6 +366,7 @@
<ClCompile Include="..\..\Common\Net\WebsocketServer.cpp" />
<ClCompile Include="..\..\Common\Profiler\Profiler.cpp" />
<ClCompile Include="..\..\Common\Render\AtlasGen.cpp" />
<ClCompile Include="..\..\Common\Render\DropShadow.cpp" />
<ClCompile Include="..\..\Common\Render\DrawBuffer.cpp" />
<ClCompile Include="..\..\Common\Render\ManagedTexture.cpp" />
<ClCompile Include="..\..\Common\Render\TextureAtlas.cpp" />
+6
View File
@@ -524,6 +524,9 @@
<ClCompile Include="..\..\Common\Render\AtlasGen.cpp">
<Filter>Render</Filter>
</ClCompile>
<ClCompile Include="..\..\Common\Render\DropShadow.cpp">
<Filter>Render</Filter>
</ClCompile>
<ClCompile Include="..\..\Common\UI\TabHolder.cpp">
<Filter>UI</Filter>
</ClCompile>
@@ -1077,6 +1080,9 @@
<ClInclude Include="..\..\Common\Render\AtlasGen.h">
<Filter>Render</Filter>
</ClInclude>
<ClInclude Include="..\..\Common\Render\DropShadow.h">
<Filter>Render</Filter>
</ClInclude>
<ClInclude Include="..\..\Common\UI\TabHolder.h">
<Filter>UI</Filter>
</ClInclude>
+1
View File
@@ -422,6 +422,7 @@ EXEC_AND_LIB_FILES := \
$(SRC)/Common/Render/ManagedTexture.cpp \
$(SRC)/Common/Render/DrawBuffer.cpp \
$(SRC)/Common/Render/AtlasGen.cpp \
$(SRC)/Common/Render/DropShadow.cpp \
$(SRC)/Common/Render/TextureAtlas.cpp \
$(SRC)/Common/Render/Text/draw_text.cpp \
$(SRC)/Common/Render/Text/draw_text_android.cpp \
+1
View File
@@ -511,6 +511,7 @@ SOURCES_CXX += \
$(COMMONDIR)/Net/WebsocketServer.cpp \
$(COMMONDIR)/Render/ManagedTexture.cpp \
$(COMMONDIR)/Render/DrawBuffer.cpp \
$(COMMONDIR)/Render/DropShadow.cpp \
$(COMMONDIR)/Render/TextureAtlas.cpp \
$(COMMONDIR)/Serialize/Serializer.cpp \
$(COMMONDIR)/Thread/ThreadUtil.cpp \
+28
View File
@@ -128,6 +128,7 @@
#include "Common/File/AndroidContentURI.h"
#include "Common/System/Request.h"
#include "Common/Render/AtlasGen.h"
#include "Common/Render/Text/draw_text.h"
#include "Common/Net/Resolve.h"
#include "Common/Net/SocketCompat.h"
@@ -3707,6 +3708,32 @@ bool TestTextDrawerCache() {
return true;
}
// The atlas shadow has no offset, so it pads the image evenly and stays symmetric around it.
bool TestAtlasDropShadow() {
Image img;
img.scale = 2.0f;
img.resize(8, 8);
for (int y = 2; y < 6; y++) {
for (int x = 2; x < 6; x++) {
img.set1(x, y, 0xFFFFFFFF);
}
}
AddDropShadow(img, 3, 0.66f);
EXPECT_EQ_INT(img.w, 8 + 12);
EXPECT_EQ_INT(img.h, 8 + 12);
EXPECT_EQ_HEX(img.get1(6 + 3, 6 + 3), 0xFFFFFFFF);
EXPECT_EQ_HEX(img.get1(0, 0), 0);
for (int y = 0; y < img.h; y++) {
for (int x = 0; x < img.w; x++) {
EXPECT_EQ_HEX(img.get1(x, y), img.get1(img.w - 1 - x, img.h - 1 - y));
}
}
const u32 edge = img.get1(5, 10);
EXPECT_EQ_INT(edge & 0xFFFFFF, 0);
EXPECT_TRUE((edge >> 24) > 0 && (edge >> 24) < 0xA8);
return true;
}
// Tab/Shift+Tab focus navigation walks the view hierarchy in declaration order rather than by
// geometry, so what it does is entirely determined by CollectTabOrder - which is worth pinning
// down, since the interesting cases (nesting, hidden tabs, disabled items) are all structural.
@@ -4020,6 +4047,7 @@ TestItem availableTests[] = {
TEST_ITEM(UITabOrder),
TEST_ITEM(UIFocusNavigation),
TEST_ITEM(TextDrawerCache),
TEST_ITEM(AtlasDropShadow),
TEST_ITEM(FatShortNames),
TEST_ITEM(GEDumpGameID),
TEST_ITEM(QueueBuf),