Repository navigation
[M2-TEXT-03] SDF glyph atlas + SDF text (distance field + threshold shader) - #88
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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(newlaige/render/font_sdf.h+font_sdf.cpp, the pinnedstb_truetypeboundary 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 tobyte = 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-01GlyphMetricsunchanged (the width match is exact, tolerance 0),atlasX/Yare the padded cell's origin, deterministic packing (thefont-sdf-determinismFNV-1a line), the M2-TEXT-01 fallback, the untrusted-font guard, the padded-cell capacity pre-check.TextItemgainedFontRenderMode(Bitmap = 0 default, Sdf = 1 — the documented scene-config key) + non-owningsdfFont; the SDF quads cover the padded cell while the pen advances by the ink's advance (layout unchanged); themeasureTextSDF overload andexpandSdfGlyphAtlasRgba8(white-on-alpha upload).SpriteRenderergainedbindSdfAtlas(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).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/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
font_sdfCPU suites 16/16; include-lint + determinism-lint real-tree OK;laige-api.jsonregenerated last (1512 → 1548 symbols, 44 → 45 headers),api-real-treegreen.budgets.jsonentry (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).