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
|
|
@ -83,7 +83,7 @@ Per frame, in an `EventListener<UIBuildArgs>` on `ui.onBuild` (fired from the pa
|
|||
|
||||
- The item structs are `std430` PODs mirrored byte for byte in `shaders/ui-shared.glsl`, and frozen: `QuadItem` (64 bytes: `x, y, w, h`, `r, g, b, a`, corner radii `cTL, cTR, cBR, cBL`, `outline, oR, oG, oB`), `CircleItem` (48: `cx, cy, radius, _p0`, colour, outline), `ImageItem` (64: rect, `u0, v0, u1, v1`, tint, `texSlot, sampSlot, _p1, _p2`), `GlyphItem` (48: rect, atlas uv, colour). Colours are straight alpha, blended source-over; coordinates are window pixels.
|
||||
- Cost: every kernel dispatches one workgroup per 8x8 screen tile and each pixel walks every item (behind a per-tile cull), so a dispatch is `O(W*H*N)`, fine up to a few hundred items at 1080p. Splitting one buffer into several dispatches does not help. Thousands of items want a different shader (tile binning), which is Tier 1.
|
||||
- Text: `Font font("font.ttf")` (TrueType through stb_truetype); `font.GetLineWidth(text, px)`, `LineHeight(px)`, `AscentPx(px)`, `NearestCursorByte(text, px, x)`. The atlas is a 1024 by 1024 SDF atlas at a 32 px base size, filled lazily per codepoint and re-uploaded by dirty rectangle; glyphs scale from it, so very large text goes soft. `ui.ShapeText(font, px, x, baselineY, utf8, color, out, capacity, &advance, TextAlign::Left)` writes `GlyphItem`s: single line, no kerning, no wrapping, a shaped-run cache behind it. Once the atlas is full, a glyph that finds no free room draws blank for one frame but keeps its advance, and gets a cell when the next frame begins, taken from the glyphs drawn least recently, never one drawn in the last `Device::framesInFlight + 1` frames. So a `GlyphItem` holds for the frame it was shaped for: shape text every frame (the Tier 3 components do, and a cached run costs a copy) rather than drawing a glyph buffer filled in an earlier frame. `atlas.EnsureAndGet(font, codepoint)` returns `const Glyph&` and never fails; its UVs hold only when `Resident()`, and a `Lookup` pointer only until the atlas's next `EnsureAndGet`, `Touch` or `Update`. A Tier 1 kernel that samples `FontAtlasImageSlot()` through `ui.Dispatch` sees the frame's new glyphs: every dispatch uploads the atlas first.
|
||||
- Text: `Font font("font.ttf")` (TrueType through stb_truetype); `font.GetLineWidth(text, px)`, `LineHeight(px)`, `AscentPx(px)`, `NearestCursorByte(text, px, x)`. The atlas is a 1024 by 1024 SDF atlas at a 32 px base size, filled lazily per codepoint and re-uploaded by dirty rectangle. Glyphs scale from it with the edge ramped over one screen pixel at any size and aspect, on both backends; far above 32 px corners round off and fine detail goes, and below about 4 px the ramp narrows and thin strokes bolden. `ui.ShapeText(font, px, x, baselineY, utf8, color, out, capacity, &advance, TextAlign::Left)` writes `GlyphItem`s: single line, no kerning, no wrapping, a shaped-run cache behind it. Once the atlas is full, a glyph that finds no free room draws blank for one frame but keeps its advance, and gets a cell when the next frame begins, taken from the glyphs drawn least recently, never one drawn in the last `Device::framesInFlight + 1` frames. So a `GlyphItem` holds for the frame it was shaped for: shape text every frame (the Tier 3 components do, and a cached run costs a copy) rather than drawing a glyph buffer filled in an earlier frame. `atlas.EnsureAndGet(font, codepoint)` returns `const Glyph&` and never fails; its UVs hold only when `Resident()`, and a `Lookup` pointer only until the atlas's next `EnsureAndGet`, `Touch` or `Update`. A Tier 1 kernel that samples `FontAtlasImageSlot()` through `ui.Dispatch` sees the frame's new glyphs: every dispatch uploads the atlas first.
|
||||
- Tier 3: fill a `UIBuffer` (`quads`, `quadCount`, `quadCap`; `glyphs`, `glyphCount`, `glyphCap`; `images`, `imageCount`, `imageCap`; `atlas`; `renderer`; a null pointer makes the components that need it no-ops) and call `DrawButton(buf, rect, label, hovered, pressed, font, px, ButtonColors)`, `DrawCheckbox(buf, rect, checked, hovered, CheckboxColors)`, `DrawSlider(buf, rect, t01, dragging, SliderColors)`, `DrawProgressBar(buf, rect, t01, ProgressColors)`, `DrawText(buf, text, x, baselineY, font, px, color, align)` (returns the advance) and `DrawImage(buf, rect, textureSlot, samplerSlot, tint, uv)`. Colours are small `*Colors` aggregates you hold; there is no `Theme`.
|
||||
- `InputField` is the model for a stateful component: a POD (`value`, `type`, `focused`, `cursorPos`), free functions you call from your own listeners (`InputField_OnText(field, utf8)` from `onTextInput`, `InputField_OnKey(field, code)` from `onRawKeyDown`, `InputField_HitTestCursor(field, rect, clickX, font, px, colors)`), `InputField_TryParse<T>(field)`, and `DrawInputField(buf, field, rect, font, px, colors, caretVisible)`; the blink is yours.
|
||||
- Images in the UI: `auto tex = ui.RegisterImage(image, VK_FORMAT_R8G8B8A8_UNORM);` (an `ImageSlot`; the layout defaults to `SHADER_READ_ONLY_OPTIMAL`), `auto samp = ui.RegisterLinearClampSampler();`, then both go into `ImageItem::texSlot` and `sampSlot`. `ui.OutImageSlot()`, `FontAtlasImageSlot()` and `FontAtlasSamplerSlot()` expose the renderer's own slots.
|
||||
|
|
@ -110,6 +110,7 @@ void main() {
|
|||
- Declare bindless resources with `UI_HEAP_IMAGES`, `UI_HEAP_TEXTURES`, `UI_HEAP_SAMPLERS` and `UI_HEAP_BUFFERS`, never a literal `descriptor_heap` or a set and binding, and register the source twice in `project.cpp`: once plain, once with `sets.defines = {"CRAFTER_GRAPHICS_DESCRIPTOR_SETS"}; sets.variant = "sets";`, which yields `<name>.spv` and `<name>.sets.spv`. `ComputeShader::Load("<name>.spv", heap)` picks the build the device needs and throws naming a missing file. `examples/CustomShader/project.cpp` has the lines.
|
||||
- `UIDispatchHeader` (48 bytes; in C++ `outImage, itemBuffer, surfaceWidth, surfaceHeight, clipX, clipY, clipW, clipH, itemCount, frameIdx, flags, _pad`, and in GLSL the same bytes as `uint outImage, itemBuffer; uvec2 surfaceSize; vec4 clipRectPx; uint itemCount, frameIdx, flags, _pad`) must be the first member of the push block; `ui.FillHeader(slot, count, clipRectPx, flags)` fills it. The high bit of `flags` (`kUIFlagClip`) is the renderer's, the rest are yours. Keep the push block a multiple of 4 bytes and within 256; the descriptor-set path throws past the device's `maxPushConstantsSize`.
|
||||
- `ui.Dispatch(cmd, shader, &pc, sizeof(pc), ceil(W / 8), ceil(H / 8))` binds, pushes and dispatches, with a `SHADER_WRITE -> SHADER_READ | SHADER_WRITE` barrier on the swapchain image before every dispatch but the frame's first, so a custom shader sees what the dispatch before it drew. `ComputeShader::Dispatch(cmd, push, bytes, gx, gy, gz, tlasAddress = 0)` is the same without the barrier; a compute shader that ray-queries the TLAS passes `RenderingElement3D::tlases[frameIdx].address`, for the NVIDIA workaround below.
|
||||
- A shader that draws text from the font atlas (`ui.FontAtlasImageSlot()` and `FontAtlasSamplerSlot()`), a copy of `ui-text.comp.glsl` among them, decodes it with `ui-shared.glsl`'s helpers rather than arithmetic of its own: `uiGlyphAtlasSize(texSlot, samplerSlot)`, then `uiGlyphRamp(item.uv, 1.0 / item.rect.zw, atlasSize)` once per glyph and `uiGlyphCoverage(texSlot, samplerSlot, uv, ramp, atlasSize)` per pixel, with dynamically uniform slots. A texel holds stb_truetype's `UI_SDF_ON_EDGE + UI_SDF_PIXEL_DIST_SCALE * d` (128 + 32 d), `d` the distance from the outline in atlas pixels, positive inside, truncated to a byte; `uiSdfDistance(sample)` gives `d` back. Multiplying a sample by 32 instead of dividing by 32/255 gives a ramp a quarter of a pixel wide, and pixelated text.
|
||||
- Keep the one-workgroup-per-screen-tile shape for anything that composites. A workgroup per item races on overlapping pixels: Vulkan orders nothing between the workgroups of one dispatch.
|
||||
- A `ComputeShader` can also run outside the UI pass, dispatched from `onUpdate` into the frame's command buffer with barriers of your own.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue