Skip to content

[M2-TEXT-03] SDF glyph atlas + SDF text (distance field + threshold shader) - #88

Merged
offdev merged 1 commit into
masterfrom
feat/m2-text-03-sdf-text
Oct 11, 2026
Merged

offdev merged 1 commit into
masterfrom
feat/m2-text-03-sdf-text

Conversation

@offdev

@offdev offdev commented Oct 10, 2026

Copy link
Copy Markdown
Owner

M2-TEXT-03 — SDF glyph atlas + SDF text (FR-2.8, P1)

The SDF half of FR-2.8 on top of M2-TEXT-01/02, per roadmap/M2-rendering-2.5d.md:

  • SdfGlyphAtlas (new laige/render/font_sdf.h + font_sdf.cpp, the pinned stb_truetype boundary reused — ADR 0009): converts the font bytes + glyph range ONCE at scene set-up into a fixed-size 8-bit signed distance field. Per glyph, the 1x ink 0.5-coverage pixels (alpha > 127) are the inside set of a two-pass 8-neighborhood distance transform (the Felice & Nienhuis 8SDD — CORE-014) over the glyph's padded cell (the ink grown by the radius r ∈ [1, 16] on all four sides), quantized to byte = clamp(128 + lround(127·d/r), 0, 255) with d positive inside: 128 = the contour, exact within r px, clamped 0/255 beyond (the documented threshold edge behavior). Metrics are the M2-TEXT-01 GlyphMetrics unchanged (the width match is exact, tolerance 0), atlasX/Y are the padded cell's origin, deterministic packing (the font-sdf-determinism FNV-1a line), the M2-TEXT-01 fallback, the untrusted-font guard, the padded-cell capacity pre-check.
  • TextItem gained FontRenderMode (Bitmap = 0 default, Sdf = 1 — the documented scene-config key) + non-owning sdfFont; the SDF quads cover the padded cell while the pen advances by the ink's advance (layout unchanged); the measureText SDF overload and expandSdfGlyphAtlasRgba8 (white-on-alpha upload).
  • SpriteRenderer gained bindSdfAtlas(id, w, h, rgba, threshold) (the per-atlas shader mode travels with the texture bind) and the fragment shader's SDF branch: aa = max(|grad(t.a)|, 1/255), smoothstep(thr ± aa, t.a) — the screen-space-derivative AA rule, a band exactly one screen pixel wide at any zoom (the P1 crispness motivation).
  • Tests: ctest -R font_sdf — 17 tests / 9 suites: validation matrix, untrusted-font boundary, capacity guard, metrics parity + exact width match, determinism (two creates byte-identical; radius 8 vs 4 diverges), threshold edge behavior (bitmap↔SDF boundary per ink texel, corner saturation, byte-exact brute-force min-distance oracle), fallback, upload bytes, 1000-lookup zero-alloc, and the offscreen SDF GL smoke (deep-inside exactly opaque, padded-cell corner exactly the clear color, the anti-alias band pixel on the edge).
  • Docs: new docs/api/font_sdf.md; text_items.md / sprite_renderer.md / font.md / coordinates.md / docs index / module README updated; roadmap Shipped block + board M2 24/33 (70/194) + changelog row.

Verification

  • Local (per the GPU local-execution policy, the GL smoke suites are excluded by explicit suite filter and are CI-verified only): all six canonical trees warning-free, 277/277 CPU tests each (build, build-clang, build-release, build-shared, build-asan, build-tsan); font_sdf CPU suites 16/16; include-lint + determinism-lint real-tree OK; laige-api.json regenerated last (1512 → 1548 symbols, 44 → 45 headers), api-real-tree green.
  • No standalone budgets.json entry (the atlas builds once at set-up; the per-frame cost is part of the composite 50k render-CPU budget, M2-PERF-01 — the pin count stays 17).

…hader)

- New laige::render SDF glyph atlas (public header
  laige/render/font_sdf.h, implementation font_sdf.cpp; reuses
  the pinned stb_truetype vendor boundary -- ADR 0009, no new
  dependency): the FR-2.8 SDF half of the text feature (P1).
- SdfGlyphAtlas::create(fontBytes, Options{atlasWidth,
  atlasHeight, fontSize, glyphFirst, glyphLast, sdfRadius}):
  converts the font + glyph range ONCE at scene set-up into a
  fixed-size 8-bit signed distance field. Per glyph, the 1x ink
  0.5-coverage pixels (alpha > 127 -- kSdfInsideCut) are the
  inside set of a two-pass 8-neighborhood distance transform
  (the Felice & Nienhuis 8SDD -- CORE-014: forward pass
  left/up-left/up/up-right, backward pass the reverse,
  orthogonal 1.0 / diagonal 1.414213562373095, float32) over the
  glyph's PADDED cell (the ink grown by the radius r in [1, 16]
  -- kSdfDefaultRadius 8, kSdfMaxRadius 16 -- on all four sides),
  quantized to byte = clamp(128 + lround(127*d/r), 0, 255) with d
  positive inside: 128 = the 0.5-coverage contour, the field
  exact within r px (byte 255/1 at exactly |d| = r), clamped to
  0/255 beyond (the documented threshold edge behavior).
- The metrics are the M2-TEXT-01 GlyphMetrics UNCHANGED (the
  layout is identical on both paths -- the width match is exact,
  tolerance 0); atlasX/Y are the PADDED cell's origin (the ink
  sits at +r/+r); shelf packing is deterministic (same font +
  options -> identical atlas bytes, the machine-greppable
  font-sdf-determinism FNV-1a line); glyph(code) is the
  M2-TEXT-01 fallback (total, never a crash). Options validate
  first (InvalidArgument, no log); the untrusted-font boundary
  is the M2-TEXT-01 guard (MalformedInput + one Error
  font/font_invalid); a padded cell not fitting the shelf is
  BudgetExhausted + one Error font/atlas_full (the capacity is
  pre-checked over ALL glyphs before any rasterization); success
  is one Info font/sdf_atlas_created. Move-only, stopped default
  state.
- TextItem gained FontRenderMode mode (Bitmap = 0 the default,
  Sdf = 1 -- the documented scene-config key) + non-owning
  const SdfGlyphAtlas* sdfFont. The shared declare walk runs per
  glyph path: the SDF quads cover the padded cell (size
  (w+2r)*scale x (h+2r)*scale, left lineX0 + pen +
  (bearingX - r)*scale, top baseline - (bearingY + r)*scale, the
  padded-cell uv rect) while the pen still advances by the ink's
  advance*scale (wrap + layout unchanged); the measureText SDF
  overload returns the same width (tolerance 0);
  expandSdfGlyphAtlasRgba8 is the white-on-alpha upload of the
  field (the bindSdfAtlas input).
- SpriteRenderer gained bindSdfAtlas(atlasId, w, h, rgba,
  threshold): the same upload contract as bindAtlas (GL_RGBA8,
  GL_NEAREST, GL_CLAMP_TO_EDGE, no mipmaps) + the per-atlas
  shader MODE + threshold in [0, 1] (the 0.5-coverage contour at
  0.5; outside the domain InvalidArgument, no GL work, no log).
  The mode travels with the texture: a re-bind on the id
  replaces the texture AND the mode (the lastTexture tracking),
  and the submit publishes uSdf/uSdfThreshold at each group's
  texture bind. The fragment shader's SDF branch (the
  M2-SPRITE-02 pattern, extended): aa = max(
  length(vec2(dFdx(t.a), dFdy(t.a))), 1/255) then
  smoothstep(uSdfThreshold - aa, uSdfThreshold + aa, t.a) -- the
  screen-space-derivative AA rule: a band exactly one screen
  pixel wide at ANY zoom (the P1 crispness motivation, FR-2.8).
- Tests: CTest entry font_sdf (17 tests / 9 suites;
  LAIGE_TEST_FONT_PATH on the entry + the unfiltered entry +
  both sanitizer blocks): FontSdfCreate (the validation matrix
  incl. the radius 0/17 bounds, the untrusted-font boundary -- 3
  Errors font_invalid, the padded-cell capacity guard on 8x8 /
  32x32 atlases -- 2 Errors atlas_full, the success
  introspection + 1 Info, the move-only stopped state),
  FontSdfMetrics (metrics parity with the bitmap atlas for every
  code + the padded-cell fit; the exact width match -- "Ma"@1,
  "Hello World"@1/@2, "W"@4, empty + whitespace-only --
  tolerance 0), FontSdfDeterminism (two creates byte-identical +
  the font-sdf-determinism line; radius 8 vs 4 diverges),
  FontSdfThreshold (per ink texel: bitmap alpha > 127 <=> SDF
  byte >= 128; both padded-cell corners byte 0 for every ink
  glyph; the engine bytes vs a brute-force double min-Euclidean
  oracle within one quantization byte), FontSdfFallback
  (out-of-range -> the U+0020 slot; a narrow range's missing
  space -> the empty metric; stopped -> nullptr), FontSdfExpand
  (white-on-alpha bytes; wrong-size + stopped -> InvalidArgument),
  FontSdfZeroAlloc (the 1000-lookup zero-alloc window), and
  FontSdfSmoke (the offscreen SDF frame: 64x2 headless,
  bindSdfAtlas + an SDF-mode TextItem "Ma" through the real
  SpriteRenderer::submit -- 1 draw call / 2 instances, the
  deep-inside texel (field >= 128 + 2*127/r) renders exactly
  (255,255,255,255), the padded-cell corner renders exactly the
  clear color, the edge scan finds the anti-aliased band pixel
  (0 < rgb < 255, r==g==b) -- the threshold edge behavior in a
  rendered frame; GTEST_SKIPs where no usable OpenGL 3.3
  environment).
- No standalone budgets.json entry (the atlas builds once at
  set-up; the per-frame cost is part of the composite 50k
  render-CPU budget, M2-PERF-01 -- the pin count stays 17).
- Docs: NEW docs/api/font_sdf.md (the field model, the layout,
  the 8SDD algorithm, the threshold edge behavior, the width
  match, the upload, ownership/perf/misuse, DOC-004);
  text_items.md (the FontRenderMode key, the padded-cell quads,
  the width match, the SDF upload + example); sprite_renderer.md
  (bindSdfAtlas + the SDF fragment branch); font.md (the shipped
  SDF ref); the docs index; the module README status;
  coordinates.md 4.12 + the conversion-row.
- Verified locally (per the GPU local-execution policy, the GL
  smoke suites are excluded by explicit suite filter and are
  CI-verified only): all six canonical trees warning-free with
  277/277 CPU tests each; include-lint + determinism-lint
  real-tree OK; laige-api.json regenerated LAST (1512 -> 1548
  symbols, 44 -> 45 headers), api-real-tree green.
- Roadmap: M2-TEXT-03 box checked + Shipped block; Progress
  Board M2 24/33 (total 70/194); changelog row.
@offdev
offdev merged commit 740aeb7 into master Oct 11, 2026
25 of 28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant