Files
ppsspp/Common/Render/Text/draw_text.cpp
T
Henrik RydgårdandClaude Opus 5.5 352c0da492 TextDrawer: Add FLAG_DROP_SHADOW for a soft drop shadow under text
The backend rasterizes coverage (or color, for emoji) as usual, then a
shared CPU pass pads the bitmap, blurs an offset copy of the coverage
and composites the text over it as RGBA8888. Blur and offset scale with
the font size and with DPI. The flag is part of the string cache key,
and the text is still aligned by its own size, with the quad grown to
fit the shadow.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 11:51:18 +02:00

527 lines
17 KiB
C++

#include "ppsspp_config.h"
#if defined(__linux__) || defined(__FreeBSD__) && !defined(__ANDROID__)
# include <dlfcn.h>
#elif defined(_WIN32)
# include <windows.h>
#endif
#include <algorithm>
#include <cmath>
#include <cstring>
#include "ext/xxhash.h"
#include "Common/System/Display.h"
#include "Common/GPU/thin3d.h"
#include "Common/Data/Hash/Hash.h"
#include "Common/Data/Text/WrapText.h"
#include "Common/Data/Encoding/Utf8.h"
#include "Common/Render/Text/draw_text.h"
#include "Common/Render/Text/draw_text_win.h"
#include "Common/Render/Text/draw_text_cocoa.h"
#include "Common/Render/Text/draw_text_android.h"
#include "Common/Render/Text/draw_text_sdl.h"
#include "Common/Render/Text/draw_text_uwp.h"
#include "Common/StringUtils.h"
#include "Common/OSVersion.h"
// TODO: Move this to somewhere more appropriate.
static bool IsRenderDocLoaded() {
#if PPSSPP_PLATFORM(UWP) || PPSSPP_PLATFORM(IOS) || PPSSPP_PLATFORM(MAC) || PPSSPP_PLATFORM(ANDROID)
return false;
#elif defined(_WIN32)
return (GetModuleHandleA("renderdoc.dll") != nullptr);
#elif defined(__linux__) || defined(__FreeBSD__)
// Returns a pointer if loaded, otherwise null
void* handle = dlopen("librenderdoc.so", RTLD_NOW | RTLD_NOLOAD);
if (handle) {
dlclose(handle);
return true;
}
return false;
#else
return false;
#endif
}
// Drop shadow blur (sigma) 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_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;
size_t TextCacheKeyHash::operator()(const TextCacheKey &key) const {
uint32_t maxWidthBits, scaleBits;
memcpy(&maxWidthBits, &key.maxWidth, sizeof(maxWidthBits));
memcpy(&scaleBits, &key.scale, sizeof(scaleBits));
uint64_t seed = ((uint64_t)key.style.sizePts << 48) | ((uint64_t)key.style.family << 40) | ((uint64_t)key.style.flags << 32) | (uint32_t)key.flags;
seed ^= (((uint64_t)maxWidthBits << 32) | scaleBits) * 0x9E3779B97F4A7C15ULL;
return (size_t)XXH3_64bits_withSeed(key.text.data(), key.text.size(), seed);
}
TextDrawer::TextDrawer(Draw::DrawContext *draw) : draw_(draw) {
// These probably shouldn't be state.
dpiScale_ = CalculateDPIScale();
}
float TextDrawerWordWrapper::MeasureWidth(std::string_view str) {
float w, h;
drawer_->MeasureString(str, &w, &h);
return w;
}
const TextCacheKey &TextDrawer::LookupKey(std::string_view str, int flags, float maxWidth, float scale) {
lookupKey_.text.assign(str.data(), str.size());
lookupKey_.style = fontStyle_;
lookupKey_.flags = flags;
lookupKey_.maxWidth = maxWidth;
lookupKey_.scale = scale;
return lookupKey_;
}
const std::string &TextDrawer::WrapString(std::string_view str, float maxW, int flags) {
// The wrapper measures in scaled units, so the result depends on the scale too.
auto [iter, inserted] = wrapCache_.try_emplace(LookupKey(str, flags, maxW, fontScaleX_));
TextWrapEntry &entry = iter->second;
entry.lastUsedFrame = frameCount_;
if (inserted) {
TextDrawerWordWrapper wrapper(this, str, maxW, flags);
entry.wrapped = wrapper.Wrapped();
}
return entry.wrapped;
}
void TextDrawer::SetFontScale(float xscale, float yscale) {
fontScaleX_ = xscale;
fontScaleY_ = yscale;
}
float TextDrawer::CalculateDPIScale() const {
if (ignoreGlobalDpi_)
return dpiScale_;
return g_display.dpi_scale_y;
}
void TextDrawer::DrawString(DrawBuffer &target, std::string_view str, float x, float y, uint32_t color, int align) {
using namespace Draw;
if (str.empty()) {
return;
}
const bool dropShadow = (align & FLAG_DROP_SHADOW) != 0;
target.Flush(true);
TextStringEntry *entry;
auto [iter, inserted] = cache_.try_emplace(LookupKey(str, align & FLAG_DROP_SHADOW));
if (!inserted) {
entry = iter->second.get();
entry->lastUsedFrame = frameCount_;
if (!entry->texture) {
return;
}
} else {
DataFormat texFormat;
// Pick between the supported formats, of which at least one is supported on each platform. Prefer R8 (but only if swizzle is supported)
bool emoji = SupportsColorEmoji() && AnyEmojiInString(str.data(), str.length());
if (emoji) {
texFormat = Draw::DataFormat::R8G8B8A8_UNORM;
} else if ((draw_->GetDataFormatSupport(Draw::DataFormat::R8_UNORM) & Draw::FMT_TEXTURE) != 0 && draw_->GetDeviceCaps().textureSwizzleSupported) {
texFormat = Draw::DataFormat::R8_UNORM;
} else if (draw_->GetDataFormatSupport(Draw::DataFormat::R4G4B4A4_UNORM_PACK16) & FMT_TEXTURE) {
texFormat = Draw::DataFormat::R4G4B4A4_UNORM_PACK16;
} else if (draw_->GetDataFormatSupport(Draw::DataFormat::A4R4G4B4_UNORM_PACK16) & FMT_TEXTURE) {
texFormat = Draw::DataFormat::A4R4G4B4_UNORM_PACK16;
} else if (draw_->GetDataFormatSupport(Draw::DataFormat::B4G4R4A4_UNORM_PACK16) & FMT_TEXTURE) {
texFormat = Draw::DataFormat::B4G4R4A4_UNORM_PACK16;
} else {
texFormat = Draw::DataFormat::R8G8B8A8_UNORM;
}
iter->second = std::make_unique<TextStringEntry>(frameCount_);
entry = iter->second.get();
// The shadow is built on the CPU from the text's coverage (or its colors, for emoji), and needs a color format.
const DataFormat bitmapFormat = dropShadow ? (emoji ? DataFormat::R8G8B8A8_UNORM : DataFormat::R8_UNORM) : texFormat;
// Convert the bitmap to a Thin3D compatible array of 16-bit pixels. Can't use a single channel format
// because we need white. Well, we could using swizzle, but not all our backends support that.
TextureDesc desc{};
std::vector<uint8_t> bitmapData;
if (!DrawStringBitmap(bitmapData, *entry, bitmapFormat, str, align, emoji)) {
// Nothing drawn. The entry without a texture records that fact.
return;
}
if (dropShadow) {
AddDropShadow(bitmapData, *entry, bitmapFormat);
texFormat = DataFormat::R8G8B8A8_UNORM;
}
desc.initData.push_back(&bitmapData[0]);
desc.type = TextureType::LINEAR2D;
desc.format = texFormat;
desc.width = entry->bmWidth;
desc.height = entry->bmHeight;
desc.depth = 1;
desc.mipLevels = 1;
desc.tag = "TextDrawer";
desc.swizzle = texFormat == Draw::DataFormat::R8_UNORM ? Draw::TextureSwizzle::R8_AS_PREMUL_ALPHA : Draw::TextureSwizzle::DEFAULT,
entry->texture = draw_->CreateTexture(desc);
}
_dbg_assert_(entry->texture);
draw_->BindTexture(0, entry->texture);
// Okay, the texture is bound, let's draw.
float w = (float)entry->width * (fontScaleX_ * dpiScale_);
float h = (float)entry->height * (fontScaleY_ * dpiScale_);
float u = (float)(entry->width + entry->shadowPad * 2) / (float)entry->bmWidth;
float v = (float)(entry->height + entry->shadowPad * 2) / (float)entry->bmHeight;
DrawBuffer::DoAlign(align, &x, &y, &w, &h);
// Align by the text, then grow the quad to include the shadow padding.
const float padX = entry->shadowPad * (fontScaleX_ * dpiScale_);
const float padY = entry->shadowPad * (fontScaleY_ * dpiScale_);
target.DrawTexRect(x - padX, y - padY, x + w + padX, y + h + padY, 0.0f, 0.0f, u, v, color);
target.Flush(true);
}
void TextDrawer::MeasureString(std::string_view str, float *w, float *h) {
if (str.empty()) {
*w = 0.0f;
*h = 0.0f;
return;
}
// Clamp the size to something sane.
if (str.size() > MAX_TEXT_LENGTH) {
str = str.substr(0, MAX_TEXT_LENGTH);
}
auto [iter, inserted] = sizeCache_.try_emplace(LookupKey(str));
TextMeasureEntry &entry = iter->second;
if (inserted) {
float extW, extH;
MeasureStringInternal(str, &extW, &extH);
entry.width = extW;
entry.height = extH;
// Hm, use the old calculation?
// int h = i == lines.size() - 1 ? entry->height : metrics.tmHeight + metrics.tmExternalLeading;
}
entry.lastUsedFrame = frameCount_;
*w = entry.width * fontScaleX_ * dpiScale_;
*h = entry.height * fontScaleY_ * dpiScale_;
}
void TextDrawer::MeasureStringRect(std::string_view str, float maxWidth, float *w, float *h, int align) {
const int wrap = align & (FLAG_WRAP_TEXT | FLAG_ELLIPSIZE_TEXT);
// Clamp the size to something sane.
if (str.size() > MAX_TEXT_LENGTH) {
str = str.substr(0, MAX_TEXT_LENGTH);
}
float plainW, plainH;
MeasureString(str, &plainW, &plainH);
if (wrap && plainW > maxWidth) {
MeasureString(WrapString(str, maxWidth, wrap), w, h);
} else {
*w = plainW;
*h = plainH;
}
}
void TextDrawer::DrawStringRect(DrawBuffer &target, std::string_view str, const Bounds &bounds, uint32_t color, int align) {
if (bounds.w < 0.0f || bounds.h < 0.0f) {
return;
}
float x = bounds.x;
float y = bounds.y;
if (align & ALIGN_HCENTER) {
x = bounds.centerX();
} else if (align & ALIGN_RIGHT) {
x = bounds.x2();
}
if (align & ALIGN_VCENTER) {
y = bounds.centerY();
} else if (align & ALIGN_BOTTOM) {
y = bounds.y2();
}
// Clamp the size to something sane.
if (str.size() > MAX_TEXT_LENGTH) {
str = str.substr(0, MAX_TEXT_LENGTH);
}
const int wrap = align & (FLAG_WRAP_TEXT | FLAG_ELLIPSIZE_TEXT);
DrawString(target, wrap ? std::string_view(WrapString(str, bounds.w, wrap)) : str, x, y, color, align);
}
bool TextDrawer::DrawStringBitmapRect(std::vector<uint8_t> &bitmapData, TextStringEntry &entry, Draw::DataFormat texFormat, std::string_view str, const Bounds &bounds, int align, bool fullColor) {
// Clamp the size to something sane.
if (str.size() > MAX_TEXT_LENGTH) {
str = str.substr(0, MAX_TEXT_LENGTH);
}
const int wrap = align & (FLAG_WRAP_TEXT | FLAG_ELLIPSIZE_TEXT);
return DrawStringBitmap(bitmapData, entry, texFormat, wrap ? std::string_view(WrapString(str, bounds.w, wrap)) : str, align, fullColor);
}
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;
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;
}
}
}
// 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);
}
}
bitmapData = std::move(out);
entry.bmWidth = bmWidth;
entry.bmHeight = bmHeight;
entry.shadowPad = pad;
}
void TextDrawer::ClearCache() {
for (auto &iter : cache_) {
if (iter.second->texture)
iter.second->texture->Release();
}
cache_.clear();
sizeCache_.clear();
wrapCache_.clear();
fontStyle_ = {};
}
void TextDrawer::OncePerFrame() {
frameCount_++;
// If DPI changed (small-mode, future proper monitor DPI support), drop everything.
float newDpiScale = CalculateDPIScale();
if (newDpiScale != dpiScale_) {
INFO_LOG(Log::G3D, "DPI Scale changed (%f to %f) - wiping font cache (%d items)", dpiScale_, newDpiScale, (int)cache_.size());
dpiScale_ = newDpiScale;
ClearCache();
ClearFonts();
}
// Drop old strings. Use a prime number to reduce clashing with other rhythms
if (frameCount_ % 63 == 0) {
for (auto iter = cache_.begin(); iter != cache_.end();) {
if (frameCount_ - iter->second->lastUsedFrame > 100) {
if (iter->second->texture)
iter->second->texture->Release();
iter = cache_.erase(iter);
} else {
iter++;
}
}
for (auto iter = sizeCache_.begin(); iter != sizeCache_.end(); ) {
if (frameCount_ - iter->second.lastUsedFrame > 100) {
iter = sizeCache_.erase(iter);
} else {
iter++;
}
}
for (auto iter = wrapCache_.begin(); iter != wrapCache_.end(); ) {
if (frameCount_ - iter->second.lastUsedFrame > 100) {
iter = wrapCache_.erase(iter);
} else {
iter++;
}
}
}
}
size_t TextDrawer::GetCacheDataSize() const {
size_t sz = 0;
for (const auto &iter : cache_) {
if (iter.second->texture) {
sz += iter.second->texture->DataSize();
}
}
return sz;
}
TextDrawer *TextDrawer::Create(Draw::DrawContext *draw) {
TextDrawer *drawer = nullptr;
#if defined(__LIBRETRO__)
// No text drawer
#elif PPSSPP_PLATFORM(UWP)
drawer = new TextDrawerUWP(draw);
#elif defined(_WIN32)
// If RenderDoc is active, we can't use the UWP drawer since it uses D3D11 to initialize D2D,
// and RenderDoc doesn't permit multiple graphics contexts.
// Also, this seems to cause failures under Windows 7, so let's gate on Win 10 to be safe.
if (IsRenderDocLoaded() || !IsWin10OrHigher()) {
drawer = new TextDrawerWin32(draw);
} else {
drawer = new TextDrawerUWP(draw);
}
#elif PPSSPP_PLATFORM(MAC) || PPSSPP_PLATFORM(IOS)
drawer = new TextDrawerCocoa(draw);
#elif PPSSPP_PLATFORM(ANDROID)
drawer = new TextDrawerAndroid(draw);
#elif USE_SDL3_TTF
drawer = new TextDrawerSDL(draw);
#endif
if (drawer && !drawer->IsReady()) {
delete drawer;
drawer = nullptr;
}
return drawer;
}
struct FontDesc {
FontFamily family;
FontStyleFlags flags;
std::string_view fontName;
std::string_view filename;
};
// Append ".ttf" to get the actual filenames.
static const FontDesc g_fontDescs[] = {
{FontFamily::SansSerif, FontStyleFlags::Default, "Roboto Condensed", "Roboto_Condensed-Regular"},
{FontFamily::SansSerif, FontStyleFlags::Bold, "Roboto Condensed", "Roboto_Condensed-Bold"},
{FontFamily::SansSerif, FontStyleFlags::Italic, "Roboto Condensed", "Roboto_Condensed-Italic"},
{FontFamily::SansSerif, FontStyleFlags::Light, "Roboto Condensed", "Roboto_Condensed-Light"},
{FontFamily::Fixed, FontStyleFlags::Default, "Inconsolata", "Inconsolata-Regular"},
};
std::map<FontFamily, std::string> g_fontOverrides;
void SetFontNameOverride(FontFamily family, std::string_view overrideFont) {
g_fontOverrides[family] = std::string(overrideFont);
}
std::vector<std::string> GetAllFontFilenames() {
std::vector<std::string> vec;
for (const auto &desc : g_fontDescs) {
vec.emplace_back(desc.filename);
}
// uniquify idiom
std::sort(vec.begin(), vec.end());
vec.erase(std::unique(vec.begin(), vec.end()), vec.end());
return vec;
}
std::string GetFilenameForFontStyle(const FontStyle &font) {
for (const auto &desc : g_fontDescs) {
if (desc.flags == font.flags && desc.family == font.family) {
return std::string(desc.filename);
}
}
// If nothing matched, just return the first font of the requested family.
for (const auto &desc : g_fontDescs) {
if (desc.family == font.family) {
return std::string(desc.filename);
}
}
_dbg_assert_(false);
return "";
}
std::string GetFontNameForFontStyle(const FontStyle &font, FontStyleFlags *outFlags) {
auto override = g_fontOverrides.find(font.family);
if (override != g_fontOverrides.end()) {
*outFlags = font.flags;
return override->second;
}
for (const auto &desc : g_fontDescs) {
if (desc.flags == font.flags && desc.family == font.family) {
*outFlags = font.flags;
return std::string(desc.fontName);
}
}
// If nothing matched, just return the first font of the requested family.
for (const auto &desc : g_fontDescs) {
if (desc.family == font.family) {
// Return the flags of the font we found.
// TODO: Some platforms can actually synthesize traits (bold, italic) if missing.
*outFlags = desc.flags;
return std::string(desc.fontName);
}
}
return "";
}