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>
137 lines
5.2 KiB
GLSL
137 lines
5.2 KiB
GLSL
//SPDX-License-Identifier: LGPL-3.0-only
|
||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||
|
||
#version 460
|
||
#extension GL_GOOGLE_include_directive : enable
|
||
#extension GL_KHR_shader_subgroup_basic : enable
|
||
#extension GL_KHR_shader_subgroup_arithmetic : enable
|
||
#include "ui-shared.glsl"
|
||
|
||
// One workgroup per 8×8 screen tile. The workgroup cooperatively streams the
|
||
// GlyphItem list in chunks of 64 (see ui-shared.glsl), culling each chunk
|
||
// against the tile and compacting survivors — in buffer order — into shared
|
||
// memory; every thread then accumulates over only those survivors.
|
||
layout(push_constant) uniform PC {
|
||
UIDispatchHeader hdr;
|
||
uint fontTextureSlot;
|
||
uint fontSamplerSlot;
|
||
uint _p0;
|
||
uint _p1;
|
||
} pc;
|
||
|
||
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
|
||
|
||
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_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 UIGlyphRamp s_ramp[UI_CHUNK];
|
||
shared uint s_keep[UI_CHUNK];
|
||
shared uint s_order[UI_CHUNK];
|
||
shared uint s_count;
|
||
shared uint s_subTotals[UI_CHUNK]; // per-subgroup survivor totals (carry step)
|
||
|
||
// Stable in-order compaction of one chunk's survivors via a per-subgroup
|
||
// exclusive prefix-sum plus a carry across subgroups, so a survivor's slot in
|
||
// s_order[] equals the number of survivors with a smaller local index — buffer
|
||
// (draw) order preserved. Replaces the old lane-0 serial scan. See
|
||
// ui-fused.comp.glsl for the full rationale; the body is identical.
|
||
void uiCompactChunk() {
|
||
uint lid = gl_LocalInvocationIndex;
|
||
uint keep = s_keep[lid];
|
||
uint subPrefix = subgroupExclusiveAdd(keep);
|
||
uint subTotal = subgroupAdd(keep);
|
||
if (subgroupElect())
|
||
s_subTotals[gl_SubgroupID] = subTotal;
|
||
barrier();
|
||
|
||
uint base = 0u;
|
||
uint total = 0u;
|
||
for (uint s = 0u; s < gl_NumSubgroups; ++s) {
|
||
uint t = s_subTotals[s];
|
||
if (s < gl_SubgroupID) base += t;
|
||
total += t;
|
||
}
|
||
if (lid == 0u) s_count = total;
|
||
|
||
if (keep != 0u)
|
||
s_order[base + subPrefix] = lid;
|
||
}
|
||
|
||
void main() {
|
||
ivec2 screenPx;
|
||
bool valid = uiResolveScreenPixel(pc.hdr, screenPx);
|
||
|
||
// Defer the destination read-modify-write: a sparse UI leaves most tiles
|
||
// untouched, so only load the pixel when the first surviving glyph blends
|
||
// over it (`loaded`), and only store when something actually touched it.
|
||
// The fused kernel amortizes a single load/store across all categories; the
|
||
// standalone Dispatch* path has no such umbrella and otherwise pays a full
|
||
// read-modify-write per empty tile.
|
||
vec4 dst = vec4(0.0);
|
||
vec2 sp = vec2(0.0);
|
||
bool loaded = false;
|
||
if (valid) sp = vec2(screenPx) + 0.5;
|
||
|
||
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;
|
||
bool keep = false;
|
||
if (idx < pc.hdr.itemCount) {
|
||
s_rect[lid] = uiGlyphHeap[pc.hdr.itemBuffer].items[idx].rect;
|
||
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;
|
||
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);
|
||
}
|
||
s_keep[lid] = keep ? 1u : 0u;
|
||
barrier();
|
||
|
||
uiCompactChunk();
|
||
barrier();
|
||
|
||
if (valid) {
|
||
for (uint j = 0u; j < s_count; ++j) {
|
||
uint c = s_order[j];
|
||
|
||
vec2 lo = s_rect[c].xy;
|
||
vec2 hi = s_rect[c].xy + s_rect[c].zw;
|
||
if (sp.x < lo.x || sp.y < lo.y) continue;
|
||
if (sp.x >= hi.x || sp.y >= hi.y) continue;
|
||
|
||
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, 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];
|
||
vec4 src = vec4(col.rgb, col.a * a);
|
||
if (!loaded) {
|
||
dst = imageLoad(uiImages[pc.hdr.outImage], screenPx);
|
||
loaded = true;
|
||
}
|
||
dst = uiBlendOver(dst, src);
|
||
}
|
||
}
|
||
barrier();
|
||
}
|
||
|
||
if (loaded) imageStore(uiImages[pc.hdr.outImage], screenPx, dst); // loaded ⇒ valid
|
||
}
|