Commit graph Crafter.Graphics/interfaces/Crafter.Graphics-FontAtlas.cppm
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
a4be6f1a9c
fix(font-atlas): give glyphs that find no room a cell when the next frame begins, evicting the least recently drawn
The atlas packed glyphs onto shelves and never gave a place back (#55).
Once its 1024x1024 image was full, EnsureAndGet returned nullptr for each
new glyph before caching anything, so every new string holding one
rasterised it again, and ShapeText dropped it together with its advance:
the rest of the line closed up over the gap, and stayed that way.

FontAtlas
- A glyph is measured, not rasterised, until it gets a cell. One that
  finds no free room keeps its advance and box and draws blank for a
  frame. EnsureAndGet always returns the glyph, now a reference;
  Glyph::Resident() says whether it has a cell, Glyph::handle which one
  (slot and generation).
- Evictions wait for the next frame: its first EnsureAndGet, Touch or
  Update places the glyphs that waited, once every glyph the frame before
  drew has been marked. Evicting while a frame is still asking for glyphs
  took ones it drew further down, which then took others in turn: a page
  switched back to could rasterise most of itself again rather than what
  it was missing, and on the DOM backend an over-full view could do so
  every frame.
- A glyph keeps its cell for framesInFlight + 1 frames after it was drawn.
  The frame loop waits frame u's fence at the start of frame
  u + framesInFlight, but Device::frameCounter reads that from the end of
  the frame before, and input callbacks and onBeforeUpdate run in between.
- FontAtlas::Packer places glyphs. Until the rows run out it fills shelves
  exactly as before. Then a glyph takes the same columns of one shelf at
  least as tall, or of several in a row as tall together, the free rows
  below the last shelf counting as one more: free texels, and, when the
  frame begins, glyphs nothing in flight draws, the least recently drawn
  first. A glyph spanning shelves lets in one taller than every shelf, and
  a few glyphs drawn every frame pin only their columns. Merging whole
  shelves instead, tried first, left a random workload without room for a
  glyph while a median 43% of the atlas was in flight; spanning, at 65%.
- A box that found no room in a frame is remembered, with every box at
  least as big, as a small set per mode, until the frame ends or (without
  eviction) something is evicted: a saturated atlas no longer searches
  every shelf for each of its missing glyphs.
- An evicted glyph is forgotten, and its texels are zeroed and uploaded
  with the glyph that takes its place: a neighbour's filtering at its edge
  must read padding or zero, not a stale glyph.
- Touch(handles) marks every glyph whose handle still holds its cell, and
  says whether all did. InvalidateFont(font) forgets the font's glyphs and
  those waiting for a cell, whose Font* the next frame would read. The
  glyph index is looked up at rasterisation, so a Font replaced in place
  never gets one past its glyphs. GlyphsRasterized() counts, for tests.

UIRenderer
- A shaped run keeps a handle per glyph it draws. A hit touches them all,
  so the atlas keeps what is drawn every frame, and shapes the run again
  when one was evicted or one had no room. A glyph without room draws
  blank but advances the pen.
- InvalidateFont clears the atlas's glyphs of the font too, as its comment
  and AGENTS.md always said it did.
- Record calls the atlas's Update before onBuild, every frame, so the
  waiting glyphs are placed and uploaded first. Dispatch uploads the atlas
  before any kernel, a Tier 1 one sampling FontAtlasImageSlot() included:
  a glyph placed earlier in onBuild can sit in a cell another glyph had.
  DispatchText and DispatchFused no longer do it themselves.

Device, Window (DOM)
- Device::frameCounter and Device::framesInFlight exist on the DOM backend
  too, the counter bumped at the end of Window::Render, with one frame in
  flight: a frame's writeTexture lands before that frame's submit but after
  every earlier one.

HelloUI
- One set of item buffers and slots per frame in flight, by frameIdx. A
  frame still on the GPU read the items the next frame's onBuild was
  rewriting; with cells changing hands, those can name cells it drew other
  glyphs in.

Tests
- FontAtlasPacker: the packer with made-up boxes and no device: the fill
  order, least recently drawn first, framesInFlight + 1 frames held,
  nothing without evict, wide glyphs, tall glyphs spanning shelves and the
  free rows, Touch past a stale handle, stale handles, dirty spans, and the
  invariants over a random workload placed the way the atlas places.
- FontAtlasEviction: fills the atlas through ShapeText frame by frame. The
  line that no longer fits keeps its advance and its glyphs' places; its
  missing glyphs aren't rasterised while nothing can go; when only line 0
  is cold, only its glyphs give way; a frame's own misses evict nothing in
  it; a frame later the line and twenty more are whole, pixel for pixel,
  line 0 losing a larger share than the glyphs drawn after it; a stale
  cached run is shaped again; no stale texel is left; InvalidateFont
  reaches the atlas. Against master, with its two calls to the new API
  stubbed out, an earlier version failed ten checks.
- UIDispatchAtlasFlush: a custom ui.Dispatch after ShapeText leaves the
  atlas uploaded. Needs a compositor.
- ShapeTextCache: a hit rasterises nothing; after InvalidateFont the
  re-shaped run draws the same, from new cells.

Docs
- README and AGENTS.consumer.md: the atlas's eviction, the frame a glyph
  waits, shaping text every frame into per-frame item buffers,
  InvalidateFont, EnsureAndGet's signature and Lookup's lifetime, and that
  the atlas, like the deletion queue, assumes one rendering window and
  evicts nothing without one.
- AGENTS.md: FontAtlas::Packer among the pure-logic helpers, eviction
  without a Window, and InvalidateFont.

Breaking: FontAtlas::EnsureAndGet returns const Glyph& and never fails;
check Glyph::Resident() before drawing from its UVs. FontAtlas::Touch is
no longer noexcept. A GlyphItem is good for the frame it was shaped for,
once the atlas has filled up, and item buffers must be per frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:43:58 +01:00
1c30b1bbf7
fix(font-atlas): wait for the GPU before the image goes; DOM stubs for SetAppId and SetTextInput
~FontAtlas destroyed the image while frames that sample it could still
run (HelloUI declares the atlas after the window, so it goes first); it
waits on the queue now, as the descriptor heap does. The DOM build got
the two new Window methods as no-ops, like the other backend-specific
ones.
2026-10-18 03:28:11 +02:00
b16f1e99d5
fix(font-atlas): the atlas destroys its image
FontAtlas::Initialize creates an ImageVulkan and nothing ever called its
Destroy(): every atlas left its 1024x1024 image, memory and view behind.
The atlas now owns the image for its lifetime and is no longer copyable.
ImageVulkan's handles start as VK_NULL_HANDLE and Destroy() resets them,
so an atlas that was never initialised, or an image destroyed by hand
before the atlas goes, is a no-op in the destructor.

The owner waits for the GPU before the atlas goes and, in sets mode,
destroys the descriptor heap that holds the image's view before the atlas.
2026-10-18 01:12:21 +02:00
42a479572d SPDX license update 2026-07-22 18:09:06 +02:00
catbot
16d291b0ad perf(ui): per-shelf dirty spans for tight atlas uploads (#129)
FontAtlas::Update used a single union bounding box for the dirty rect.
Scattered new glyphs landing on different shelves produced a tall union
box that re-uploaded mostly-unchanged texels between the shelves.

Track a dirty span per shelf instead and issue one tight UpdateRegion /
wgpuWriteAtlasRegion copy per armed span. A shelf packs glyphs
left-to-right at a fixed top, so each span stays a contiguous X run
capped by the shelf height. The whole-atlas zero-clear in Initialize
keeps using the existing dirtyRect span. `dirty` is now the OR of every
span and is cleared together with them in Update.

Verified: full test suite green (23 passed); HelloUI text renders
crisply on the WebGPU backend through the new multi-span upload path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:35:54 +00:00
0b00d9d680 Merge pull request 'perf(text): fold Ensure+Lookup into FontAtlas::EnsureAndGet (#53)' (#84) from claude/issue-53 into master 2026-06-16 17:44:20 +02:00
catbot
77a6501ced perf(text): fold Ensure+Lookup into FontAtlas::EnsureAndGet (#53)
Per glyph, ShapeText did cache_.contains(key) (in Ensure) then
cache_.find(key) (in Lookup) — two independent hashes + node-chases of
the same key. Add EnsureAndGet returning const Glyph* with a single
find, insert-on-miss, returning &it->second. It returns nullptr only on
ShelfPlace failure, preserving the existing `if (g==nullptr) continue;`
behavior. Ensure now delegates to it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:43:58 +00:00
catbot
97c79c23ee perf(font): upload only the dirty sub-rect of the glyph atlas (#51)
FontAtlas::Update re-uploaded the entire 1024x1024 R8 atlas (1 MiB)
whenever a single glyph was rasterized, so warm-up / language-switch /
size-churn cost roughly one full-atlas copy per frame that added a
codepoint.

Accumulate a dirty bounding box (FontAtlas::DirtyRect) in MarkDirty as
glyphs are placed, and copy only that sub-rect. On Vulkan this is a new
ImageVulkan::UpdateRegion: the staging buffer keeps the full atlas row
stride, so bufferRowLength stays the atlas width and bufferOffset points
at the rect origin while imageOffset/imageExtent carve out the rect.
Layout transitions stay whole-image (cheap); only the copy extent
shrinks. WebGPU passes the rect straight to wgpuWriteAtlasRegion, which
already took x/y/w/h.

The freshly-zeroed atlas marks its whole extent dirty in Initialize so
the first Update clears the image once; thereafter every untouched texel
stays the zero it was uploaded as, keeping the image byte-identical to
the staging copy.

Tested: new FontAtlasDirtyRect unit test covers the accumulation /
clamp / lockstep-with-`dirty` math (no GPU needed); HelloUI renders text
correctly on both Vulkan (NVIDIA, validation-clean) and WebGPU.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:40:37 +00:00
dedf6b0467 webgpu support 2026-05-18 04:58:52 +02:00
5352ef69a2 browser DOM support 2026-05-18 02:07:48 +02:00
1f5697326c UI rewrite 3rd attempt 2026-05-02 21:08:20 +02:00
Renamed from interfaces/Crafter.Graphics-UIAtlas.cppm (Browse further)