fix(ui): decode the font atlas's SDF in atlas pixels, so text edges ramp over one screen pixel
Shaders - ui-text and ui-fused turned a sample of the R8_UNORM atlas into a distance with (128/255 - sdf) * 32. stb_truetype writes kOnEdgeValue + kPixelDistScale * distance into each byte, so an atlas pixel is kPixelDistScale / 255 of a sample; 32 made every distance 1024/255 (about 4) times too large, and the coverage ramp, meant to span one screen pixel, spanned a quarter of one: nearly every edge pixel came out fully on or fully off, and text looked pixelated at every size. On an RTX 4090, UIFallbackParity's 22 px label had 98 partly covered pixels where stb_truetype's own rasterizer has 415. Wrong since the v3 UI (1f56973), and copied into the fused kernel (574242d). - The decode now lives once, in ui-shared.glsl: UI_SDF_ON_EDGE and UI_SDF_PIXEL_DIST_SCALE are FontAtlas's two constants in the encoder's own units, uiSdfDistance decodes a sample, uiGlyphRamp works out a glyph's ramp when it is loaded and uiGlyphCoverage the coverage per pixel. Both kernels call them, and a copy of either should; the UI_ prefix keeps them clear of a copied kernel's own names. - stb_truetype truncates, so a byte b stands for [b, b + 1) of the encoded value and the decode takes its middle: decoding b pulled every outline 1/64 atlas pixel inwards. - The ramp is capped at what a byte holds, about 4 atlas pixels each way of the outline. With the corrected decode a wider one left every glyph quad's padding translucent below 4 px. - The UV span is taken with abs(): a glyph turned by swapping its UVs got a negative scale, a ramp of 0.0001 and hard edges. - A glyph scaled differently along x and y ramps along the distance's gradient, from two one-texel differences. With the corrected decode, one width for both axes, the more minified one's, would leave a row stretched three times with stems three pixels soft. Glyphs scaled alike, as ShapeText's are, keep one multiply-add per pixel, the ramp's reciprocal worked out with the glyph. - The atlas size is queried rather than FontAtlas::kAtlasSize hard-coded as 1024. WebGPU - The WGSL text kernel decoded the distance right but ramped over one atlas pixel whatever the size: half a screen pixel at 16 px, two at 64 px. It now repeats ui-shared.glsl's decode, cap, abs and gradient path, and renders TextAntialiasing's scene as Vulkan does (run through Deno's WebGPU: 1.02, 1.07 and 1.09 times the reference's edge pixels, where the old kernel gave 0.55, 2.19 and 0.89). FontAtlas - kOnEdgeValue and kPixelDistScale say the kernels carry copies of them. Tests - TextAntialiasing draws one string at 14 px, 64 px and 14 px stretched three times, a 'g' turned by its UVs and empty atlas texels shrunk to 12.8 atlas pixels per screen pixel, through DispatchText and again through DispatchFused, and holds them to stb_truetype's antialiased rasterizer, to the mirror image and to nothing. On master the two plain rows have 0.31 and 0.21 times the reference's edge pixels; with the decode fixed alone the stretched row had 1.62 times, the turned 'g' was 127 codes off its mirror and the empty texels drew 48. Now: 1.02, 1.07 and 1.09 times, 0 and 0, on the 4090, on llvmpipe and on descriptor sets. It skips a swapchain that isn't 8-bit, which the capture can't read, and captures the first frame. - FontAtlasDecode reads ui-shared.glsl and dom-webgpu.js and holds their copies of the two constants to FontAtlas's. No GPU. Docs - AGENTS.consumer.md has a custom text shader decode through the helpers and gives the encoding; README says so in a line; AGENTS.md lists the tests and where the decode lives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a4be6f1a9c
commit
781d95ae75
11 changed files with 767 additions and 63 deletions
|
|
@ -46,11 +46,6 @@ layout(push_constant) uniform PC {
|
|||
|
||||
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
|
||||
|
||||
// SDF tuning for the text phase — must match Crafter::FontAtlas constants
|
||||
// (same values as ui-text.comp.glsl).
|
||||
const float ON_EDGE = 128.0 / 255.0;
|
||||
const float DIST_SCALE = 32.0;
|
||||
|
||||
// Per-category clip-active bits packed into pc.misc.w (the fused analogue of
|
||||
// ui-shared.glsl's UI_FLAG_CLIP). A bit is set only when that category's clip
|
||||
// rect is narrower than the surface; when clear, the category skips its four
|
||||
|
|
@ -79,9 +74,10 @@ shared uvec4 s_v4[UI_CHUNK];
|
|||
// phases like the s_v* scratch (the per-pixel inner loops read them instead of
|
||||
// recomputing per pixel × item):
|
||||
// images: s_inv = 1.0/rect.zw
|
||||
// text: s_inv = 1.0/rect.zw, s_band = SDF AA scale (a divide + two maxes)
|
||||
// text: s_inv = 1.0/rect.zw, s_ramp = the SDF edge ramp (ui-shared.glsl's
|
||||
// uiGlyphRamp, as in ui-text.comp.glsl)
|
||||
shared vec2 s_inv[UI_CHUNK];
|
||||
shared float s_band[UI_CHUNK];
|
||||
shared UIGlyphRamp s_ramp[UI_CHUNK];
|
||||
shared uint s_keep[UI_CHUNK];
|
||||
shared uint s_order[UI_CHUNK];
|
||||
shared uint s_count;
|
||||
|
|
@ -315,6 +311,10 @@ void main() {
|
|||
uint count = pc.itemCounts.w;
|
||||
bool inClip = inSurface &&
|
||||
((pc.misc.w & UI_FUSED_CLIP_TEXT) == 0u || uiPixelInClipRect(pxu, pc.clipText));
|
||||
// Queried only with text to draw: without a FontAtlas the font slots
|
||||
// name nothing.
|
||||
vec2 atlasSize = vec2(1.0);
|
||||
if (count != 0u) atlasSize = uiGlyphAtlasSize(pc.misc.y, pc.misc.z);
|
||||
for (uint base = 0u; base < count; base += UI_CHUNK) {
|
||||
uint idx = base + lid;
|
||||
bool keep = false;
|
||||
|
|
@ -323,12 +323,7 @@ void main() {
|
|||
s_v1[lid] = uiGlyphHeap[heap].items[idx].uv;
|
||||
s_v2[lid] = uiGlyphHeap[heap].items[idx].color;
|
||||
s_inv[lid] = 1.0 / s_v0[lid].zw;
|
||||
// SDF AA band — atlas-px per screen-px, constant per glyph
|
||||
// (uvSpan * kAtlasSize(1024) / screenSpan), max'd to 1px floor.
|
||||
vec2 uvSpan = s_v1[lid].zw - s_v1[lid].xy;
|
||||
vec2 atlasPerScreen = (uvSpan * 1024.0) * s_inv[lid];
|
||||
float scalePx = max(atlasPerScreen.x, atlasPerScreen.y);
|
||||
s_band[lid] = max(scalePx, 0.0001);
|
||||
s_ramp[lid] = uiGlyphRamp(s_v1[lid], s_inv[lid], atlasSize);
|
||||
keep = uiAabbOverlapsTile(s_v0[lid].xy, s_v0[lid].xy + s_v0[lid].zw,
|
||||
tileMin, tileMax);
|
||||
}
|
||||
|
|
@ -349,18 +344,9 @@ void main() {
|
|||
vec2 t = (sp - s_v0[c].xy) * s_inv[c];
|
||||
vec2 uv = mix(s_v1[c].xy, s_v1[c].zw, t);
|
||||
|
||||
// Font slots are push constants — provably dynamically uniform,
|
||||
// so no nonuniformEXT (same as ui-text.comp.glsl).
|
||||
float sdf = texture(
|
||||
sampler2D(uiTextures[pc.misc.y],
|
||||
uiSamplers[pc.misc.z]),
|
||||
uv
|
||||
).r;
|
||||
|
||||
float dAtlas = (ON_EDGE - sdf) * DIST_SCALE;
|
||||
|
||||
// band (the AA scale) was precomputed once per glyph at load.
|
||||
float a = clamp(0.5 - dAtlas / s_band[c], 0.0, 1.0);
|
||||
// Font slots are push constants, dynamically uniform as
|
||||
// uiGlyphCoverage needs (same as ui-text.comp.glsl).
|
||||
float a = uiGlyphCoverage(pc.misc.y, pc.misc.z, uv, s_ramp[c], atlasSize);
|
||||
if (a <= 0.0) continue;
|
||||
|
||||
vec4 col = s_v2[c];
|
||||
|
|
|
|||
|
|
@ -157,6 +157,93 @@ float uiSdRoundRect(vec2 p, vec2 halfSize, vec4 r) {
|
|||
return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r.x;
|
||||
}
|
||||
|
||||
// ─── SDF text (FontAtlas) ───────────────────────────────────────────────
|
||||
// FontAtlas keeps each glyph as a signed distance field: stb_truetype writes
|
||||
// kOnEdgeValue + kPixelDistScale * d into each texel, d being the distance
|
||||
// from the outline in atlas pixels, positive inside, truncated to a byte and
|
||||
// clamped to 0..255. These are FontAtlas's two constants in the encoder's own
|
||||
// units; the FontAtlasDecode test holds them, and the WGSL copy in
|
||||
// dom-webgpu.js, to FontAtlas's. ui-text and the fused kernel's text phase
|
||||
// decode through the helpers below, and so should a copy of either.
|
||||
const float UI_SDF_ON_EDGE = 128.0; // FontAtlas::kOnEdgeValue
|
||||
const float UI_SDF_PIXEL_DIST_SCALE = 32.0; // FontAtlas::kPixelDistScale
|
||||
|
||||
// The widest edge ramp, in atlas pixels, the stored distances can carry. A
|
||||
// byte holds from (UI_SDF_ON_EDGE - 0.5) / UI_SDF_PIXEL_DIST_SCALE outside
|
||||
// the outline to (255.5 - UI_SDF_ON_EDGE) / UI_SDF_PIXEL_DIST_SCALE inside,
|
||||
// about 4 atlas pixels each way, and a ramp wider than twice the nearer end
|
||||
// never reaches 0 there: below about 4 px every glyph quad's padding, all 0,
|
||||
// would show as a translucent box. For text that small a ramp narrower than
|
||||
// one screen pixel is the lesser evil.
|
||||
const float UI_SDF_MAX_BAND = 2.0 * min(UI_SDF_ON_EDGE - 0.5, 255.5 - UI_SDF_ON_EDGE)
|
||||
/ UI_SDF_PIXEL_DIST_SCALE;
|
||||
|
||||
// Distance from the outline in atlas pixels, negative inside, for an atlas
|
||||
// sample (0..1). stb_truetype truncates, so byte b stands for [b, b + 1) of
|
||||
// the encoded value and b + 0.5 is its unbiased middle: decoding b itself
|
||||
// pulls every outline 1/64 atlas pixel inwards.
|
||||
float uiSdfDistance(float sdf) {
|
||||
return (UI_SDF_ON_EDGE - (sdf * 255.0 + 0.5)) / UI_SDF_PIXEL_DIST_SCALE;
|
||||
}
|
||||
|
||||
// What a glyph's per-pixel coverage needs, worked out once per glyph:
|
||||
// atlasPerScreen the atlas pixels one screen pixel spans along x and y;
|
||||
// coverage (k, b) of coverage = clamp(k * sdf + b, 0, 1) for a glyph
|
||||
// scaled alike along x and y (within 1%), as ShapeText's
|
||||
// always are: a ramp one screen pixel wide folded into one
|
||||
// multiply-add. (0, 0) for one that isn't, whose ramp is
|
||||
// then worked out per pixel.
|
||||
struct UIGlyphRamp {
|
||||
vec2 atlasPerScreen;
|
||||
vec2 coverage;
|
||||
};
|
||||
|
||||
// The atlas's size in texels: queried rather than taken from
|
||||
// FontAtlas::kAtlasSize, so the two can't drift apart.
|
||||
vec2 uiGlyphAtlasSize(uint texSlot, uint samplerSlot) {
|
||||
return vec2(textureSize(sampler2D(uiTextures[texSlot], uiSamplers[samplerSlot]), 0));
|
||||
}
|
||||
|
||||
// `uv` is the GlyphItem's (u0, v0, u1, v1), `invRectSize` 1 / its (w, h).
|
||||
UIGlyphRamp uiGlyphRamp(vec4 uv, vec2 invRectSize, vec2 atlasSize) {
|
||||
UIGlyphRamp ramp;
|
||||
// abs: a glyph turned by swapping its UVs keeps its scale.
|
||||
ramp.atlasPerScreen = abs(uv.zw - uv.xy) * atlasSize * invRectSize;
|
||||
ramp.coverage = vec2(0.0);
|
||||
float lo = min(ramp.atlasPerScreen.x, ramp.atlasPerScreen.y);
|
||||
float hi = max(ramp.atlasPerScreen.x, ramp.atlasPerScreen.y);
|
||||
if (hi <= lo * 1.01) {
|
||||
// 0.5 - uiSdfDistance(sdf) / band, the band one screen pixel.
|
||||
float inv = 1.0 / (UI_SDF_PIXEL_DIST_SCALE * clamp(hi, 0.0001, UI_SDF_MAX_BAND));
|
||||
ramp.coverage = vec2(255.0 * inv, 0.5 - (UI_SDF_ON_EDGE - 0.5) * inv);
|
||||
}
|
||||
return ramp;
|
||||
}
|
||||
|
||||
// The glyph's coverage at `uv` in the atlas. The slots must be dynamically
|
||||
// uniform, as push constants are: there is no nonuniformEXT here, which lets
|
||||
// the compiler hoist the descriptor load out of the per-pixel glyph loop.
|
||||
float uiGlyphCoverage(uint texSlot, uint samplerSlot, vec2 uv, UIGlyphRamp ramp, vec2 atlasSize) {
|
||||
float sdf = texture(sampler2D(uiTextures[texSlot], uiSamplers[samplerSlot]), uv).r;
|
||||
if (ramp.coverage.x != 0.0) return clamp(ramp.coverage.x * sdf + ramp.coverage.y, 0.0, 1.0);
|
||||
|
||||
// Scaled differently along x and y: one screen pixel spans
|
||||
// atlasPerScreen.x atlas pixels across a vertical edge and
|
||||
// atlasPerScreen.y across a horizontal one, so the ramp's width follows
|
||||
// the direction the distance changes in, from one-texel differences. One
|
||||
// width for both would be several pixels wide across one of them.
|
||||
vec2 texel = 1.0 / atlasSize;
|
||||
vec2 gradient = vec2(
|
||||
texture(sampler2D(uiTextures[texSlot], uiSamplers[samplerSlot]), uv + vec2(texel.x, 0.0)).r,
|
||||
texture(sampler2D(uiTextures[texSlot], uiSamplers[samplerSlot]), uv + vec2(0.0, texel.y)).r) - sdf;
|
||||
float len = length(gradient);
|
||||
// No gradient where the field is clamped, 4 atlas pixels from any
|
||||
// outline, where the coverage is 0 or 1 at any width up to the cap.
|
||||
float band = len > 0.0 ? length(gradient / len * ramp.atlasPerScreen)
|
||||
: max(ramp.atlasPerScreen.x, ramp.atlasPerScreen.y);
|
||||
return clamp(0.5 - uiSdfDistance(sdf) / clamp(band, 0.0001, UI_SDF_MAX_BAND), 0.0, 1.0);
|
||||
}
|
||||
|
||||
// ─── shared-memory cooperative tile culling ─────────────────────────────
|
||||
// Each shader still dispatches one 8×8 workgroup per screen tile, but rather
|
||||
// than have all 64 threads walk the FULL item list — re-reading every item
|
||||
|
|
|
|||
|
|
@ -21,20 +21,16 @@ layout(push_constant) uniform PC {
|
|||
|
||||
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
|
||||
|
||||
// SDF tuning — must match Crafter::FontAtlas::kOnEdgeValue / kPixelDistScale.
|
||||
const float ON_EDGE = 128.0 / 255.0;
|
||||
const float DIST_SCALE = 32.0;
|
||||
|
||||
shared vec4 s_rect[UI_CHUNK];
|
||||
shared vec4 s_uv[UI_CHUNK];
|
||||
shared vec4 s_color[UI_CHUNK];
|
||||
// Per-glyph constants precomputed once at cooperative-load time and read by the
|
||||
// per-pixel inner loop, instead of being recomputed for every pixel × glyph:
|
||||
// s_invRectSize = 1.0/rect.zw (turns the per-pixel divide into a multiply)
|
||||
// s_band = the SDF AA scale (a divide + two maxes that depend only on
|
||||
// the glyph's uv span and rect size — fully loop-invariant)
|
||||
// s_ramp = the SDF edge ramp (ui-shared.glsl's uiGlyphRamp), which
|
||||
// depends only on the glyph's uv span and rect size
|
||||
shared vec2 s_invRectSize[UI_CHUNK];
|
||||
shared float s_band[UI_CHUNK];
|
||||
shared UIGlyphRamp s_ramp[UI_CHUNK];
|
||||
shared uint s_keep[UI_CHUNK];
|
||||
shared uint s_order[UI_CHUNK];
|
||||
shared uint s_count;
|
||||
|
|
@ -85,6 +81,10 @@ void main() {
|
|||
vec2 tileMin, tileMax;
|
||||
uiTileBounds(tileMin, tileMax);
|
||||
|
||||
// DispatchText draws only with a FontAtlas registered, so the font slots
|
||||
// always name it here.
|
||||
vec2 atlasSize = uiGlyphAtlasSize(pc.fontTextureSlot, pc.fontSamplerSlot);
|
||||
|
||||
uint lid = gl_LocalInvocationIndex;
|
||||
for (uint base = 0u; base < pc.hdr.itemCount; base += UI_CHUNK) {
|
||||
uint idx = base + lid;
|
||||
|
|
@ -94,14 +94,7 @@ void main() {
|
|||
s_uv[lid] = uiGlyphHeap[pc.hdr.itemBuffer].items[idx].uv;
|
||||
s_color[lid] = uiGlyphHeap[pc.hdr.itemBuffer].items[idx].color;
|
||||
s_invRectSize[lid] = 1.0 / s_rect[lid].zw;
|
||||
// Atlas-px per screen-px along this glyph's transform — keeps AA
|
||||
// crisp at any rendering size. uvSpan * atlasSize / screenSpan,
|
||||
// with FontAtlas::kAtlasSize = 1024. Constant for the whole glyph.
|
||||
vec2 uvSpan = s_uv[lid].zw - s_uv[lid].xy;
|
||||
vec2 atlasPerScreen = (uvSpan * 1024.0) * s_invRectSize[lid];
|
||||
float scalePx = max(atlasPerScreen.x, atlasPerScreen.y);
|
||||
// 1-screen-px AA band, expressed in atlas-pixel units of dAtlas.
|
||||
s_band[lid] = max(scalePx, 0.0001);
|
||||
s_ramp[lid] = uiGlyphRamp(s_uv[lid], s_invRectSize[lid], atlasSize);
|
||||
keep = uiAabbOverlapsTile(s_rect[lid].xy, s_rect[lid].xy + s_rect[lid].zw,
|
||||
tileMin, tileMax);
|
||||
}
|
||||
|
|
@ -123,20 +116,9 @@ void main() {
|
|||
vec2 t = (sp - s_rect[c].xy) * s_invRectSize[c];
|
||||
vec2 uv = mix(s_uv[c].xy, s_uv[c].zw, t);
|
||||
|
||||
// Font slots are push constants — provably dynamically uniform, so no
|
||||
// nonuniformEXT: lets the compiler hoist the descriptor load out of the
|
||||
// per-pixel glyph loop and avoid the divergent-descriptor path.
|
||||
float sdf = texture(
|
||||
sampler2D(uiTextures[pc.fontTextureSlot],
|
||||
uiSamplers[pc.fontSamplerSlot]),
|
||||
uv
|
||||
).r;
|
||||
|
||||
// Distance in atlas-pixels (negative inside the glyph).
|
||||
float dAtlas = (ON_EDGE - sdf) * DIST_SCALE;
|
||||
|
||||
// band (the AA scale) was precomputed once per glyph at load.
|
||||
float a = clamp(0.5 - dAtlas / s_band[c], 0.0, 1.0);
|
||||
// Font slots are push constants, dynamically uniform as
|
||||
// uiGlyphCoverage needs.
|
||||
float a = uiGlyphCoverage(pc.fontTextureSlot, pc.fontSamplerSlot, uv, s_ramp[c], atlasSize);
|
||||
if (a <= 0.0) continue;
|
||||
|
||||
vec4 col = s_color[c];
|
||||
|
|
|
|||
Loading…
Reference in a new issue