Commit graph Crafter.Graphics/shaders/ui-text.comp.glsl
Author SHA1 Message Date
781d95ae75
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>
2026-10-31 20:23:40 +01:00
42a479572d SPDX license update 2026-07-22 18:09:06 +02:00
9046562a90 Merge pull request 'perf(ui): subgroup prefix-sum stream compaction in the UI compute shaders (#124)' (#145) from claude/issue-124 into master 2026-06-18 15:27:30 +02:00
e1222c0ab7 Merge pull request 'perf(ui): skip the destination read-modify-write on empty tiles (#127)' (#144) from claude/issue-127 into master 2026-06-18 15:27:08 +02:00
catbot
da9f2a89e8 perf(ui): subgroup prefix-sum stream compaction, not lane-0 serial scan (#124)
uiCompactChunk ran the survivor scan entirely on gl_LocalInvocationIndex
== 0: ~64 serial iterations with 63/64 lanes idle, between two barriers,
4x per chunk in the fused kernel. It must emit a stable in-order
(buffer-order) exclusive prefix of the survivors so the per-pixel inner
loop still sees items in draw order.

Replace it with a per-subgroup subgroupExclusiveAdd of the keep bits plus
a carry across subgroups: each subgroup publishes its survivor total to
shared memory, then every lane sums the totals of all lower-id subgroups
for its base. A survivor's slot in s_order[] therefore equals the number
of survivors with a smaller local index — buffer (draw) order preserved
exactly, unlike an atomicAdd which would scramble it.

The carry makes the result correct for any subgroup width (2 subgroups on
the 32-wide descriptor_heap target, up to 8/16 on narrower parts), relying
only on the gl_LocalInvocationIndex <-> (gl_SubgroupID,
gl_SubgroupInvocationID) linear mapping every Vulkan compute
implementation provides. The feature is free at the device baseline
(apiVersion 1.4 + VK_EXT_descriptor_heap implies Vulkan 1.1 subgroup
arithmetic), so no correctness fallback is needed; WebGPU is unaffected
(separate embedded WGSL).

Applied to ui-fused.comp.glsl and the same pattern inlined in
ui-quads/circles/images/text.

Verified: all 23 tests pass (UIFusedShader recompiles + spirv-val +
pins push-constant offsets); a 140k-trial CPU simulation of the algorithm
matches the serial reference for subgroup sizes 1..64; HelloUI renders
correctly on an RTX 4090 (DispatchFused quads+circles+text) with draw
order intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:26:37 +00:00
catbot
225556532a perf(ui): skip the destination read-modify-write on empty tiles (#127)
The standalone per-category UI shaders (quads/circles/images/text)
unconditionally imageLoad the destination pixel up front and imageStore
it at the end, paying a full read-modify-write even on tiles no item
touches — the common case for a sparse UI. The fused uber-kernel already
amortizes a single load/store across all four categories; the standalone
Dispatch* path has no such umbrella.

Defer the imageLoad to just before the first surviving blend (`loaded`
flag) and skip the imageStore unless something was actually blended.
Output is bit-identical: before the first blend `dst` is unused, and a
skipped store leaves the pixel exactly as a no-op load+store would have
rewritten it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:26:32 +00:00
catbot
ac9180076c perf(ui): hoist loop-invariant per-item math out of the per-pixel loop (#125)
The cooperative-load section already streams each item into shared memory
once per workgroup, but the per-pixel inner loop then recomputed values
that are constant for the whole item/glyph. The compiler can't hoist them
itself — they read shared memory at a varying index.

Precompute them once per item at load time into new shared slots:

  - ui-images / ui-text / ui-fused (images+text phases): invRectSize =
    1.0/rect.zw, turning the per-pixel `(sp-rect.xy)/rect.zw` vec2 divide
    into a multiply.
  - ui-text / ui-fused (text phase): the SDF AA `band` (a vec2 divide +
    two maxes depending only on the glyph's uv span and rect size).

In ui-fused the new slots (s_inv, s_band) are reused across phases like
the existing s_v* scratch, so the LDS bump is per-category, not summed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:22:07 +00:00
catbot
256ce56f1d Merge remote-tracking branch 'origin/master' into claude/issue-46
# Conflicts:
#	shaders/ui-text.comp.glsl
2026-06-16 16:02:00 +00:00
catbot
6eb11fbf71 perf(ui): cooperative shared-memory tile culling in UI compute shaders
Each 8×8-tile workgroup previously had all 64 threads walk the entire
item list, re-reading every item from the SSBO 64× and running the
per-pixel reject for items that never touch the tile — O(tiles × N)
item loads dominated by SSBO traffic.

The workgroup now streams the item list in chunks of 64: each thread
loads one item, tests its AABB against the whole tile, and the survivors
are compacted — in original buffer order — into shared memory. Every
thread then runs the unchanged per-pixel accumulate over only those
survivors. This drops SSBO traffic ~64× (each item read once per
workgroup instead of once per pixel) and shrinks the inner loop to the
items that actually overlap the tile.

Draw order is preserved exactly: chunks run in array order and the
in-chunk compaction is a stable in-order scan, so later items still
overdraw earlier ones (no atomic-append nondeterminism). The inner loop
body is byte-for-byte the original per-pixel logic, so output is
pixel-identical — the cull only decides which items reach it. Threads
no longer early-return so every thread reaches the workgroup barriers.

Applied to ui-quads, ui-circles, ui-images and ui-text; shared helpers
(uiTileBounds / uiAabbOverlapsTile) live in ui-shared.glsl.

Resolves #46

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:45:12 +00:00
catbot
a8ed369c71 perf(shaders): drop nonuniformEXT on text font slots
fontTextureSlot/fontSamplerSlot are push constants, hence provably
dynamically uniform. Wrapping them in nonuniformEXT forced the
per-invocation divergent-descriptor path and blocked hoisting the
uniform descriptor load out of the per-pixel glyph loop.

Removing the decoration is zero correctness risk — it is purely a
read-index hint. Not applied to ui-images.comp.glsl, whose slots come
from an SSBO load the compiler cannot prove uniform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:29:29 +00:00
1f5697326c UI rewrite 3rd attempt 2026-05-02 21:08:20 +02:00