From 110fb7fd6a15df54bc47452bdac43eba010d2467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Fri, 23 Oct 2026 09:58:11 +0200 Subject: [PATCH] UI: Rounded rectangles from precomputed atlas corners UIAtlas generates an antialiased white corner for each radius in ROUND_RECT_RADII (8, 16, 24, 32 UI units) and each style (filled, and thin/medium/thick outlines from ROUND_RECT_THICKNESS) at the atlas DPI. DrawBuffer::DrawRoundRect mirrors the corner into all four corners and stretches the corner's last texel row/column along the edges, so a round rect is 9 quads (8 for outlines). Other radii use the nearest corner, scaled. The LogoScreen temporarily draws some for testing. Co-Authored-By: Claude Opus 5.5 (1M context) --- Common/Render/DrawBuffer.cpp | 78 ++++++++++++++++++++++++++++++++++++ Common/Render/DrawBuffer.h | 37 +++++++++++++++++ Common/Render/TextureAtlas.h | 2 + UI/MiscScreens.cpp | 29 +++++++++++++- UI/UIAtlas.cpp | 38 +++++++++++++++++- 5 files changed, 182 insertions(+), 2 deletions(-) diff --git a/Common/Render/DrawBuffer.cpp b/Common/Render/DrawBuffer.cpp index c173b7580d..ba49e8a4ff 100644 --- a/Common/Render/DrawBuffer.cpp +++ b/Common/Render/DrawBuffer.cpp @@ -429,6 +429,84 @@ void DrawBuffer::DrawImage4Grid(ImageID atlas_image, float x1, float y1, float x DrawTexRect(xb, yb, x2, y2, um, vm, u2, v2, color); } +const char *RoundRectCornerImageName(int radiusIndex, RoundRectStyle style) { + // Generated from the indices, so the tables in the header are all there is to edit. + struct Names { + char names[ROUND_RECT_RADIUS_COUNT][(int)RoundRectStyle::COUNT][16]; + Names() { + for (int r = 0; r < ROUND_RECT_RADIUS_COUNT; r++) { + for (int s = 0; s < (int)RoundRectStyle::COUNT; s++) { + snprintf(names[r][s], sizeof(names[r][s]), "I_RRECT_%d_%d", r, s); + } + } + } + }; + static const Names names; + return names.names[radiusIndex][(int)style]; +} + +void DrawBuffer::DrawRoundRect(float x1, float y1, float x2, float y2, float radius, Color color, RoundRectStyle style) { + if (!atlas || x2 <= x1 || y2 <= y1) { + return; + } + if (roundRectAtlasImages_ != atlas->images) { + for (int r = 0; r < ROUND_RECT_RADIUS_COUNT; r++) { + for (int s = 0; s < (int)RoundRectStyle::COUNT; s++) { + roundRectCorners_[r][s] = atlas->getImage(ImageID(RoundRectCornerImageName(r, (RoundRectStyle)s))); + } + } + roundRectAtlasImages_ = atlas->images; + } + + int radiusIndex = 0; + for (int i = 1; i < ROUND_RECT_RADIUS_COUNT; i++) { + if (fabsf(ROUND_RECT_RADII[i] - radius) < fabsf(ROUND_RECT_RADII[radiusIndex] - radius)) { + radiusIndex = i; + } + } + const AtlasImage *image = roundRectCorners_[radiusIndex][(int)style]; + if (!image) { + return; + } + + const bool filled = style == RoundRectStyle::Filled; + const float dpiScale = atlas->dpiScale; + const float r = ROUND_RECT_RADII[radiusIndex] * dpiScale; + const int center = RoundRectCornerCenter(r); + + // The corner quad runs from the center of texel 0 to the center of the last texel, which holds + // the straight edge profile that gets stretched along the sides. Shrink it to fit small rects. + float scale = std::max(radius, 0.0f) / ROUND_RECT_RADII[radiusIndex]; + const float maxScale = std::min(x2 - x1, y2 - y1) * dpiScale / (2.0f * (r + 0.5f)); + scale = std::min(scale, maxScale); + if (scale <= 0.0f) { + if (filled) { + DrawImageCenterTexel(ImageID("I_SOLIDWHITE"), x1, y1, x2, y2, color); + } + return; + } + const float texel = scale / dpiScale; + const float outside = (center - r - 0.5f) * texel; // From the rect edge out to texel 0's center. + const float inside = (r + 0.5f) * texel; // From the rect edge in to the last texel's center. + + const float xs[4] = { x1 - outside, x1 + inside, x2 - inside, x2 + outside }; + const float ys[4] = { y1 - outside, y1 + inside, y2 - inside, y2 + outside }; + const float us[4] = { image->u1, image->u2, image->u2, image->u1 }; + const float vs[4] = { image->v1, image->v2, image->v2, image->v1 }; + + for (int y = 0; y < 3; y++) { + if (ys[y + 1] <= ys[y]) { + continue; + } + for (int x = 0; x < 3; x++) { + if (xs[x + 1] <= xs[x] || (x == 1 && y == 1 && !filled)) { + continue; + } + DrawTexRect(xs[x], ys[y], xs[x + 1], ys[y + 1], us[x], vs[y], us[x + 1], vs[y + 1], color); + } + } +} + void DrawBuffer::DrawImage2GridH(ImageID atlas_image, float x1, float y1, float x2, Color color, float corner_scale) { const AtlasImage *image = atlas->getImage(atlas_image); float um = (image->u1 + image->u2) * 0.5f; diff --git a/Common/Render/DrawBuffer.h b/Common/Render/DrawBuffer.h index de13bfcec6..b11622eca5 100644 --- a/Common/Render/DrawBuffer.h +++ b/Common/Render/DrawBuffer.h @@ -4,6 +4,8 @@ #include #include +#include +#include #include "Common/Data/Color/RGBAUtil.h" #include "Common/Render/TextureAtlas.h" @@ -50,6 +52,30 @@ struct GradientStop { class TextDrawer; +// Rounded rectangles are drawn from antialiased corner images that UI/UIAtlas.cpp generates +// into the UI atlas, one per radius and style. +enum class RoundRectStyle : uint8_t { + Filled, + OutlineThin, + OutlineMedium, + OutlineThick, + COUNT, +}; + +// Corner radii in UI units, each of which gets an image per style. Other radii scale the nearest one. +// Keep them larger than the thickest outline. +constexpr float ROUND_RECT_RADII[] = { 8.0f, 16.0f, 24.0f, 32.0f }; +constexpr int ROUND_RECT_RADIUS_COUNT = (int)std::size(ROUND_RECT_RADII); +// Outline thickness in UI units, per RoundRectStyle. +constexpr float ROUND_RECT_THICKNESS[(int)RoundRectStyle::COUNT] = { 0.0f, 1.0f, 2.0f, 3.0f }; + +const char *RoundRectCornerImageName(int radiusIndex, RoundRectStyle style); +// The corner image is (center + 1) texels square. The arc is centered on texel corner (center, center), +// and the last row and column are the straight edge profile. +inline int RoundRectCornerCenter(float radiusPx) { + return (int)ceilf(radiusPx) + 1; +} + class DrawBuffer { public: DrawBuffer(const DrawBuffer &) = delete; @@ -149,6 +175,13 @@ public: // Results in 18 triangles. Kind of expensive for a button. void DrawImage4Grid(ImageID atlas_image, float x1, float y1, float x2, float y2, Color color = COLOR(0xFFFFFF), float corner_scale = 1.0); + // Uses the nearest precomputed corner radius, scaled to fit, so outlines get thinner or thicker + // when radius isn't one of ROUND_RECT_RADII. 18 triangles filled, 16 outlined. + void DrawRoundRect(float x1, float y1, float x2, float y2, float radius, Color color, RoundRectStyle style = RoundRectStyle::Filled); + void DrawRoundRect(const Bounds &bounds, float radius, Color color, RoundRectStyle style = RoundRectStyle::Filled) { + DrawRoundRect(bounds.x, bounds.y, bounds.x2(), bounds.y2(), radius, color, style); + } + // This is only 6 triangles, much cheaper. void DrawImage2GridH(ImageID atlas_image, float x1, float y1, float x2, Color color = COLOR(0xFFFFFF), float scale = 1.0); @@ -225,6 +258,10 @@ private: const Atlas *atlas = nullptr; const Atlas *fontAtlas_ = nullptr; + // Atlas lookups are linear searches by name, so the corner images are looked up once per atlas. + const AtlasImage *roundRectCorners_[ROUND_RECT_RADIUS_COUNT][(int)RoundRectStyle::COUNT]{}; + const AtlasImage *roundRectAtlasImages_ = nullptr; + bool inited_ = false; float fontscalex = 1.0f; float fontscaley = 1.0f; diff --git a/Common/Render/TextureAtlas.h b/Common/Render/TextureAtlas.h index 15ae9e0fb3..bbc71f5a22 100644 --- a/Common/Render/TextureAtlas.h +++ b/Common/Render/TextureAtlas.h @@ -143,6 +143,8 @@ struct Atlas { int num_fonts = 0; AtlasImage *images = nullptr; int num_images = 0; + // Texels per UI unit that generated images were rasterized at. Not stored in metadata. + float dpiScale = 1.0f; // These are inefficient linear searches, try not to call every frame. const AtlasFont *getFont(FontID id) const; diff --git a/UI/MiscScreens.cpp b/UI/MiscScreens.cpp index ee58537ad6..031c2e9234 100644 --- a/UI/MiscScreens.cpp +++ b/UI/MiscScreens.cpp @@ -468,7 +468,34 @@ void LogoScreen::DrawForeground(UIContext &dc) { alphaText = 3.0f - t; uint32_t textColor = colorAlpha(dc.GetTheme().infoStyle.fgColor, alphaText); - auto cr = GetI18NCategory(I18NCat::PSPCREDITS); + // TEMPORARY: Round rect test. Fixed seed so they stay put between frames. + { + uint32_t seed = 12345; + auto rnd = [&seed]() { + seed = seed * 1664525 + 1013904223; + return (float)(seed >> 8) * (1.0f / 16777216.0f); + }; + for (int i = 0; i < 24; i++) { + const float w = 20.0f + rnd() * 200.0f; + const float h = 20.0f + rnd() * 120.0f; + const float x = bounds.x + rnd() * (bounds.w - w); + const float y = bounds.y + rnd() * (bounds.h - h); + const float radius = 2.0f + rnd() * 20.0f; + const RoundRectStyle style = (RoundRectStyle)(int)(rnd() * (float)RoundRectStyle::COUNT); + const uint32_t color = 0x80000000 | ((uint32_t)(rnd() * 16777215.0f) & 0xFFFFFF); + dc.Draw()->DrawRoundRect(x, y, x + w, y + h, radius, color, style); + } + // Every precomputed corner at its native size. + for (int r = 0; r < ROUND_RECT_RADIUS_COUNT; r++) { + for (int s = 0; s < (int)RoundRectStyle::COUNT; s++) { + const float x = bounds.x + 10.0f + s * 130.0f; + const float y = bounds.y + 10.0f + r * 90.0f; + dc.Draw()->DrawRoundRect(x, y, x + 120.0f, y + 80.0f, ROUND_RECT_RADII[r], 0xFFFFFFFF, (RoundRectStyle)s); + } + } + } + + auto cr =GetI18NCategory(I18NCat::PSPCREDITS); auto gr = GetI18NCategory(I18NCat::GRAPHICS); char temp[256]; diff --git a/UI/UIAtlas.cpp b/UI/UIAtlas.cpp index d7bdb119af..dcd34e98cd 100644 --- a/UI/UIAtlas.cpp +++ b/UI/UIAtlas.cpp @@ -9,6 +9,7 @@ #include "Common/UI/Context.h" #include "Common/Data/Format/PNGLoad.h" #include "Common/Render/AtlasGen.h" +#include "Common/Render/DrawBuffer.h" #include "Common/Render/ManagedTexture.h" #include "Common/Common.h" #include "Common/Thread/ParallelLoop.h" @@ -40,6 +41,9 @@ Atlas *GetUIAtlas() { struct ImageMeta { std::string_view id; bool addShadow = false; + // If set, the image is a generated round rect corner (see DrawBuffer::DrawRoundRect). In UI units. + float cornerRadius = 0.0f; + float cornerThickness = 0.0f; // 0 = filled }; // We add shadows to all line-art images that are used for buttons, to improve visibility. @@ -375,6 +379,28 @@ static bool RasterizeSVG(std::string_view filename, float dpiScale, int maxTextu return true; } +// White, premultiplied, antialiased by distance to the edge. +static void GenerateRoundRectCorner(Image &img, float radius, float thickness, float dpiScale) { + const float r = radius * dpiScale; + const float t = thickness * dpiScale; + const int center = RoundRectCornerCenter(r); + img.resize(center + 1, center + 1); + img.scale = dpiScale; + for (int y = 0; y <= center; y++) { + const float dy = std::max((float)center - (y + 0.5f), 0.0f); + for (int x = 0; x <= center; x++) { + const float dx = std::max((float)center - (x + 0.5f), 0.0f); + const float d = sqrtf(dx * dx + dy * dy); + float coverage = std::clamp(r - d + 0.5f, 0.0f, 1.0f); + if (t > 0.0f) { + coverage -= std::clamp(r - t - d + 0.5f, 0.0f, 1.0f); + } + const u32 a = (u32)(coverage * 255.0f + 0.5f); + img.set1(x, y, a * 0x01010101); + } + } +} + static bool GenerateUIAtlasImage(Atlas *atlas, float dpiScale, Image *dest, int maxTextureSize, const ImageMeta *imageIDs, size_t imageCount) { Bucket bucket; @@ -445,6 +471,8 @@ static bool GenerateUIAtlasImage(Atlas *atlas, float dpiScale, Image *dest, int } else if (equals(imageIDs[i].id, "I_EMPTY")) { img.resize(16, 16); img.fill(0); + } else if (imageIDs[i].cornerRadius > 0.0f) { + GenerateRoundRectCorner(img, imageIDs[i].cornerRadius, imageIDs[i].cornerThickness, dpiScale); } else { std::string name = "ui_images/"; std::string pngName = PNGNameFromID(imageIDs[i].id); @@ -491,6 +519,7 @@ static bool GenerateUIAtlasImage(Atlas *atlas, float dpiScale, Image *dest, int atlas->images = new AtlasImage[genAtlasImages.size()]; std::copy(genAtlasImages.begin(), genAtlasImages.end(), atlas->images); atlas->num_images = (int)genAtlasImages.size(); + atlas->dpiScale = dpiScale; // For debug, write out the atlas. if (SAVE_DEBUG_ATLAS) { @@ -510,8 +539,15 @@ Draw::Texture *GenerateUIAtlas(Draw::DrawContext *draw, Atlas *atlas, float dpiS INFO_LOG(Log::G3D, "Regenerating atlas (empty: %s). Dpi scale (changed: %s): %0.2f (invalidate=%d)", g_cachedUIAtlasImage.IsEmpty() ? "true" : "false", dpiScale != g_cachedDpiScale ? "true" : "false", dpiScale, invalidate); + std::vector imageIDs(std::begin(g_uiImageIDs), std::end(g_uiImageIDs)); + for (int r = 0; r < ROUND_RECT_RADIUS_COUNT; r++) { + for (int style = 0; style < (int)RoundRectStyle::COUNT; style++) { + imageIDs.push_back({ RoundRectCornerImageName(r, (RoundRectStyle)style), false, ROUND_RECT_RADII[r], ROUND_RECT_THICKNESS[style] }); + } + } + g_cachedUIAtlasImage.clear(); - if (!GenerateUIAtlasImage(atlas, dpiScale, &g_cachedUIAtlasImage, draw->GetDeviceCaps().maxTextureSize, g_uiImageIDs, ARRAY_SIZE(g_uiImageIDs))) { + if (!GenerateUIAtlasImage(atlas, dpiScale, &g_cachedUIAtlasImage, draw->GetDeviceCaps().maxTextureSize, imageIDs.data(), imageIDs.size())) { ERROR_LOG(Log::G3D, "Failed to generate UI atlas!"); return nullptr; }