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) <noreply@anthropic.com>
This commit is contained in:
Henrik RydgårdandClaude Opus 5.5 committed 2026-10-24 01:52:34 +02:00
1 parent 42bb9b134b
commit 110fb7fd6a
5 files changed
+182 -2

No files matched your search

+78
View File
@@ -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;
+37
View File
@@ -4,6 +4,8 @@
#include <vector>
#include <cstdint>
#include <cmath>
#include <iterator>
#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;
+2
View File
@@ -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;
+28 -1
View File
@@ -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];
+37 -1
View File
@@ -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<ImageMeta> 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;
}