Module: Text
Package path:
packages/babylon-lite/src/text/Slug-style GPU font rendering for Lite. Glyphs are stored as quadratic Bézier outlines with a spatial-band index packed into two
rgba32floattextures, and drawn as instanced unit quads where the fragment shader resolves analytic coverage from the curves intersecting each pixel's bands. The module is layered so the lowest level (GlyphStorage) holds only outlines and atlases, the middle level (TextData) layers per-instance slot allocation on top, and the top levels (TextRenderable,TextRenderer) attachTextDatato either a 3D scene or a standalone 2D pass.
Purpose
The text feature exists to draw resolution-independent glyphs anywhere — inside a
3D scene as a world-space TextRenderable, or in pure 2D as a standalone
TextRenderer (no scene, no camera). Both paths share one CPU layout
(TextData per-instance buffer + draw groups) and one GPU pipeline (Slug
fragment shader against curve+band textures). They differ only in how the MVP
matrix is composed and which render pass owns the draw.
The module is organized as four lifetime tiers, longest-lived first:
GlyphStorage— outlines + GPU atlases. One storage can hold many curve-sets (one per font face) and back manyTextDatas. Caller-owned.TextData— per-text-block instance buffer + slot allocator + draw groups. Borrows aGlyphStorage. OneTextDataper logical text block.TextRenderable(3D) /TextRenderer+TextLayer(2D) — the thing that gets registered with an engine/scene and actually draws.DefaultTextData+ helpers — convenience layer that does default LTR layout viatext-shaperand ships its own privateGlyphStorage.
Each tier depends only on the tier below it, so a caller that hand-rolls its own
layout (e.g. an external rich-text engine) imports only GlyphStorage +
TextData + a renderer and pays zero bytes for the default layout / text-shaper
glue.
Public API Surface
Tier 1 — GlyphStorage (longest-lived)
export interface GlyphStorage { /* opaque */}export type CurveSetId = string;
export function createGlyphStorage(initial?: Map<CurveSetId, Map<number, GlyphCurves>>): GlyphStorage;export function updateGlyphStorage(storage: GlyphStorage, curveSetId: CurveSetId, curves: ReadonlyMap<number, GlyphCurves>): void;export function disposeGlyphStorage(storage: GlyphStorage): void;A GlyphStorage is an opaque bundle of (curveSetId → glyph outlines + packed atlas). Each CurveSetId (a string, typically the font family name) maps to
exactly one atlas; one GlyphStorage holds an arbitrary number of curve-sets.
Glyph ids inside a curve-set are dense small integers (font glyph indices), and
once packed into the atlas a glyph's slot is never moved.
updateGlyphStorage is idempotent per glyph id — already-present ids are
skipped, so callers can pass the union of every glyph they might draw without
re-rasterizing. Lifetime is caller-owned (matches Texture2D semantics): the
caller must outlive any TextData that borrows the storage, then call
disposeGlyphStorage exactly once to release every atlas's GPU textures.
The same GlyphStorage can be shared by reference across any number of
TextDatas — that is the whole reason it is a separate tier. Two text blocks
in the same font pay one atlas upload.
Tier 2 — TextData
export interface TextData { readonly runs: readonly GlyphRun[]; // ... opaque internals}
export type GlyphRun = { readonly curveSet: CurveSetId; readonly glyphs: readonly PlacedGlyph[]; readonly pixelsPerFontUnit: number; readonly defaultColor?: readonly [number, number, number, number];};export type PlacedGlyph = { readonly glyphId: number; readonly x: number; // pixels, glyph origin readonly y: number; // pixels, baseline up readonly color?: readonly [number, number, number, number]; // overrides run defaultColor};
export function createTextData(storage: GlyphStorage, runs?: readonly GlyphRun[]): TextData;export function updateTextData(data: TextData, update: TextDataUpdate): void;export function disposeTextData(data: TextData): void;
export type TextDataUpdate = | { update: "reset"; runs?: GlyphRun[]; storage?: GlyphStorage } | { update: "addRun"; run: GlyphRun; insertBefore?: number } | { update: "removeRun"; run: GlyphRun | number } | { update: "replaceRun"; previous: GlyphRun | number; run: GlyphRun };A TextData represents one logical text block as an ordered list of
GlyphRuns. Each run carries the glyphs in a single font (one curveSet) at a
single pixels-per-font-unit scale; mixed-font / mixed-size content is just
multiple runs in the same TextData. The update API is a small discriminated
union driving the slot allocator (see §Implementation).
disposeTextData releases only the per-block GPU resources (instance buffer +
bind groups). It does not touch the borrowed GlyphStorage — the caller
owns that lifetime.
Tier 3a — TextRenderable (3D, attached to a scene)
export interface TextRenderableOptions { readonly position?: Vec3; readonly rotationQuaternion?: { x: number; y: number; z: number; w: number }; readonly scaling?: Vec3; readonly opacity?: number; // whole-block fade. default 1 readonly ignoreDepth?: boolean; // skip depth test/write. default false readonly order?: number; // sort order. default 200}
export interface TextRenderable extends Renderable { readonly position: ObservableVec3; readonly rotation: EulerProxy; readonly rotationQuaternion: ObservableQuat; readonly scaling: ObservableVec3; opacity: number; ignoreDepth: boolean; order: number;}
export function createTextRenderable(data: TextData, options?: TextRenderableOptions): TextRenderable;export function addTextRenderable(scene: SceneContext, renderable: TextRenderable): void;export function disposeTextRenderable(renderable: TextRenderable): void;A TextRenderable mirrors Mesh's TRS surface (position, rotation,
rotationQuaternion, scaling) and implements the standard Renderable
interface, so it sorts and binds like any other scene entity. It is isTransparent
by default (text always uses src-over blending) and consumes its TextData
read-only — the data and its underlying GlyphStorage may be shared across
many TextRenderables.
Scene-attached text can opt into alpha-to-coverage with
setAlphaToCoverage(renderable, true) before it is added/registered. The option
is effective only when ignoreDepth === false and the scene target is
multisampled. That variant uses straight RGB, analytic glyph coverage as alpha,
replacement color (no blend), and per-sample depth writes. Standalone
TextRenderer layers draw to a 1x swapchain and retain ordinary straight-alpha
blending.
Tier 3b — TextRenderer + TextLayer (standalone 2D)
export interface TextLayerOptions { readonly positionPx?: { x: number; y: number }; // canvas pixel origin readonly rotationRad?: number; // z-axis rotation readonly scale?: number; // uniform readonly order?: number; // within renderer readonly opacity?: number; readonly visible?: boolean;}export interface TextLayer { readonly data: TextData; positionPx: { x: number; y: number }; rotationRad: number; scale: number; order: number; opacity: number; visible: boolean;}export function createTextLayer(data: TextData, options?: TextLayerOptions): TextLayer;export function setTextLayerPosition(layer: TextLayer, x: number, y: number): void;
export interface TextRendererOptions { layers: readonly TextLayer[]; clear?: boolean; // default true clearValue?: GPUColorDict;}export function createTextRenderer(engine: EngineContext, opts: TextRendererOptions): TextRenderer;export function addTextRendererLayer(tr: TextRenderer, layer: TextLayer): void;export function removeTextRendererLayer(tr: TextRenderer, layer: TextLayer): boolean;export function registerTextRenderer(tr: TextRenderer): void;export function unregisterTextRenderer(tr: TextRenderer): void;export function disposeTextRenderer(tr: TextRenderer): void;TextRenderer is a standalone RenderingContext (sibling of SpriteRenderer):
no scene, no camera. It opens its own swapchain render pass and draws each
visible TextLayer in order order. The MVP is a pure CPU-built 2D affine
(layer position/rotation/scale + ortho projection) — there is no view matrix.
Layers can be added/removed at any time; their pixel position/rotation/scale/
opacity may be mutated directly between frames.
A scene that wants 2D HUD text on top of 3D uses registerScene first, then
createTextRenderer + registerTextRenderer so the text pass runs after the
scene's frame graph.
Tier 4 — Default helpers (depend on text-shaper)
export interface Font { /* opaque, wraps text-shaper.Font */}export function loadFont(url: string): Promise<Font>;export function createFontFromBuffer(data: ArrayBuffer): Font;
export function extractGlyphCurves(font: Font, glyphIds: ReadonlySet<number>, target: Map<number, GlyphCurves>): void;export function cubicToQuadratics(/* control points */): [QuadCurve, QuadCurve];
export interface DefaultTextData extends TextData { readonly width: number; // pixel-space laid-out width readonly height: number; // pixel-space laid-out height}export function createDefaultTextData(font: Font, fontSizePx: number, text: string, textColor?: [number, number, number, number], options?: TextLayoutOptions): DefaultTextData;export function updateDefaultTextData(data: DefaultTextData, text: string, textColor?: [number, number, number, number]): void;export function disposeDefaultTextData(data: DefaultTextData): void;createDefaultTextData runs the default LTR + word-wrap + align layout
(layout.ts, built on text-shaper's HarfBuzz-style shaping), extracts the
required glyph outlines from the font (glyph-extraction.ts, using text-shaper's glyph
path provider), packs them into a fresh single-curve-set GlyphStorage, and
wraps the result in a TextData with one GlyphRun. The branded
DefaultTextData carries pixel-space width / height so callers can size
their TextRenderable.scaling or place a TextLayer precisely.
updateDefaultTextData re-shapes the text, appends any newly-needed glyph
outlines to the existing GlyphStorage (existing ids no-op via
updateGlyphStorage), and applies the new run via updateTextData(replaceRun)
— which hits the in-place rewrite fast path whenever the glyph count is
unchanged.
disposeDefaultTextData releases both the per-block resources and the
owned GlyphStorage (because this helper allocated both).
Callers driving their own text layout import only Tiers 1–3 and pay zero bytes
for layout.ts, glyph-extraction.ts, default-text-data.ts, or text-shaper's shaping
codepath.
Minimal example — createDefaultTextData + TextRenderer
The shortest path from font URL to rendered text on a canvas:
import { createEngine, startEngine, loadFont, createDefaultTextData, createTextLayer, createTextRenderer, registerTextRenderer } from "@babylonjs/lite";
const canvas = document.getElementById("canvas") as HTMLCanvasElement;const engine = await createEngine(canvas);const font = await loadFont("/fonts/Inter.ttf");
// Shape + extract curves + pack atlas in one call (a fresh GlyphStorage is// allocated under the hood and released by `disposeDefaultTextData`).const data = createDefaultTextData(font, 48, "Hello, world!");
// Place the laid-out block at pixel (32, 64) on the canvas.const layer = createTextLayer(data, { positionPx: { x: 32, y: 64 } });
// Standalone 2D renderer — no scene, no camera.const renderer = createTextRenderer(engine, { layers: [layer] });registerTextRenderer(renderer);
await startEngine(engine);For a 3D scene, swap the last three lines for createTextRenderable(data) +
addTextRenderable(scene, …); everything else is identical.
Implementation
CPU data layout
Each GlyphStorage curve-set owns a SharedAtlas:
type SharedAtlas = { curveTexData: Float32Array; // rgba32float, width 4096, grows by row doubling curveTexelsUsed: number; bandTexData: Float32Array; // rgba32float, width 4096, grows by row doubling bandTexelsUsed: number; glyphSlots: Map<number, AtlasSlot>; // glyphId → curve start + band header location + band counts version: number; // monotonic, bumped per packAppendGlyph gpu: SharedAtlasGpu | null; // lazy};The two textures are fixed-width 4096 and grow in row-doubling steps. Curve
texels store quadratic control points (p0, p1, p2 packed as two
vec4s per curve); band texels store per-band headers (count + offset) followed
by curve-index lists. Per-glyph metadata lives in AtlasSlot (curve start
texel, band header location, (vBandCount, hBandCount) for the fragment
shader's transform).
Each TextData owns a contiguous packed instance buffer (Float32Array, 20
floats = 5 vec4s = 80 bytes per instance), an instanceCount, and a list of
draw groups:
type TextDataDrawGroup = { groupKey: TextGroupKey; // draw-group identity; === curveSetId unless a styling feature interned another curveSetId: CurveSetId; curveSet: GlyphStorageCurveSet; // cached pointer into _storage._curveSets slotStart: number; slotCount: number; // live + dead liveCount: number; freeSlots: number[]; // LIFO stack of dead slot indices bindGroup: GPUBindGroup | null; bindGroupVersion: number; // last-seen atlas.uploadedVersion};One draw group per unique groupKey used by the live runs — which is one per unique
curveSetId unless an opt-in styling feature splits a curve set further (see
"Draw-group keys"). Each group owns a
contiguous [slotStart, slotStart + slotCount) slot range in the shared
instance buffer; live and dead slots intermix within that range. The vertex
shader detects dead slots and emits a degenerate off-screen quad, so they cost
only a vertex-shader invocation. A per-run RunRecord tracks which absolute
slot indices each GlyphRun currently occupies, so add/remove/replace are O(touched glyphs).
Per-instance layout (5 × vec4, 80 bytes)
| Field | Floats | Contents |
|---|---|---|
slugBounds | 4 | (xMin, yMin, xMax, yMax) in font units — the quad's extent |
slugAnchor | 4 | (xPx, yPx, 1/pixelsPerFontUnit, deadSentinel) — pixel origin + scale; .w = 1 marks the slot dead |
slugAtlas | 4 | (glyphLocX, glyphLocY, bandMaxX, bandMaxY) — band texture lookup base + max band indices |
slugBand | 4 | (bandScaleX, bandScaleY, bandOffsetX, bandOffsetY) — derived from glyph bounds + band counts |
slugColor | 4 | linear RGBA per glyph (falls back to run defaultColor, then white) |
The vertex shader reads slugAnchor.w first; when non-zero it emits a
clip-space point at (-2, -2, -2, 1) so all six quad vertices collapse to one
off-screen point and rasterization culls the zero-area triangles.
Slot allocator (fast incremental updates)
The allocator is the heart of the module — its purpose is to make
updateTextData cheap enough that a typing user can drive it on every
keystroke without rebuilding GPU buffers.
addRunallocates one slot per glyph, first by popping from the owning group'sfreeSlotsLIFO (a dead slot vacated by a priorremoveRun), then by extending the group's range. Extending requirescopyWithin-shifting every later group right byextraSlots; same-group runs are never shifted because slots within a group are unordered.removeRunwrites the dead-slot sentinel into every slot the run occupied and pushes those slot indices into the group'sfreeSlots. If the group becomes empty (no live runs) it is dropped and later groups shift left to close the gap. Removed slots dirty the buffer range[minSlot, maxSlot+1].replaceRunhas a fast path: same curveSet + same glyph count → rewrite the existing slots in place (no allocator work, no buffer shift). This is the pathupdateDefaultTextDatahits when the user types a single character into a line that didn't word-wrap. Different size or different curveSet falls back toremoveRun+addRunat the samerunsindex.reset(full or compaction) rebuilds groups + slot ranges from scratch, re-using existing group records when the curveSet matches (preservesbindGroupidentity when the atlas pointer is unchanged) and packing every live slot contiguously without gaps. CallingupdateTextData(data, { update: "reset" })with bothrunsandstorageomitted is therefore a pure compaction pass.
Automatic vs manual compaction. The allocator does compact automatically
in two cases: (a) dead slots within a group are always reclaimed by the next
addRun / replaceRun via the freeSlots LIFO, so freed slots are not lost,
they just sit dormant in-place; and (b) a group whose liveCount reaches zero
is dropped wholesale by dropEmptyGroup, shifting later groups left to close
the gap. What is not automatic is intra-group hole closing while the group
is still live: if a 200-glyph run shrinks to 5 glyphs the other 195 slots
remain dead until a future run reuses them or the caller invokes reset. The
vertex shader collapses dead slots to a single off-screen point (a cheap
vertex invocation, no fragment work), so the steady-state cost is bounded —
an explicit reset is only needed if a workload spends a long time with a
large dead-slot fraction and the caller wants the GPU instance buffer to
shrink. Adding an internal heuristic (e.g. auto-reset when dead / total > 0.5)
is a future option but not currently implemented.
Dirty range + version-based GPU upload
TextData carries a _version (bumped per mutation) and a [_dirtyStart, _dirtyEnd)
half-open dirty range. Every allocator path that writes the instance buffer
calls markDirty(data, minSlot, maxSlot + 1). The GPU side
(TextRenderable / TextRenderer) caches uploadedDataVersion and at frame
upload time:
- If
data._version === uploadedDataVersion→ skip entirely. - Else if a partial upload is safe (
uploadedDataVersion !== -1and the dirty range is non-empty) →writeBufferonly the dirty subrange. - Else (post-reset or post-resize) → upload the whole prefix
[0, _instanceCount).
The instance GPU buffer doubles capacity when needed; on resize the next upload falls into branch 3.
SharedAtlas.version plays the same role for the curve+band textures.
ensureSharedAtlasGpu(device, atlas) lazily allocates the textures, grows them
(power-of-two rows) when capacity needs change, and re-uploads only when
uploadedVersion !== atlas.version. It returns { rebuilt, gpu }; rebuilt = true (texture object identity changed) is the signal for the renderer to drop
every draw group's bindGroup so it gets re-created against the new texture
views. Same-version polls during steady-state are a single integer compare.
Spatial-band index (internal to glyph-storage.ts)
For each glyph, buildGlyphBands(glyph) partitions the curves into up to 8
horizontal and 8 vertical bands by bounding-box overlap. Curves within an
h-band are sorted by descending max(p0x, p1x, p2x) (and v-bands by
descending max(p0y, p1y, p2y)) so the fragment shader can early-exit a band
as soon as a curve is entirely to the left/below the pixel. The result is
memoized on the GlyphCurves object via a @internal _bands? field so a
glyph re-used by a second GlyphStorage (e.g. another text block re-extracting
from the same Font) pays the band-build cost only once.
Pipeline (_gpu/text-pipeline.ts)
One bind group layout + one pair of base WGSL modules cached per GPUDevice (a
WeakMap<GPUDevice, TextPipelineDeviceCache>). The render pipeline itself is
cached per fixed-arity
(colorFormat, sampleCount, depthStencilFormat, depthWrite, alphaToCoverage, variantId)
key — so a TextRenderable with ignoreDepth=true and a TextRenderable
with ignoreDepth=false share modules + bind group layout but get separate
pipelines. Optional shader variants are supplied as already-compiled opaque module
pairs by an opt-in feature; see "Variant module lifecycle" below.
Blend is fixed src-over:
color: (src.a * src.rgb) + (1 - src.a) * dst.rgbalpha: src.a + (1 - src.a) * dst.aTopology is triangle-list (two triangles per glyph from a shared 6-vertex
unit-quad buffer). The bind group has three entries: the TextU UBO (mvp +
viewport + color) on binding 0, and the curve / band textures on bindings 1
and 2 (both texture_2d<f32> as unfilterable-float).
WGSL outline
Both shader stages are direct WGSL ports of Eric Lengyel's Slug algorithm (github.com/EricLengyel/Slug) — the curve+band atlas layout, the per-pixel band lookup, the quadratic root-code table, and the screen-space dilation math all come from that reference implementation. The Babylon Lite shaders are the same algorithm reshaped to fit Lite's instance-buffer + bind-group plumbing.
The vertex stage (the vertex half of shaders/slug-shader.ts):
- Dead-slot detection.
if (in.pk == 0xffffffffu) → emit (-2,-2,-2,1) point. Dead slots use an all-ones packed glyph/style index. - Quad corner expansion.
tex = mix(slugBounds.xy, slugBounds.zw, isMax)maps the unit corner sign to the glyph's font-unit bounds;pos = slugAnchor.xy + tex * scaleputs the corner in object-space pixels. - Slug dilation (Eric Lengyel's analytic AA expansion) — extracts MVP
rows, computes a screen-space dilation vector
dproportional to(1 / viewport, 1 / viewport)so glyph edges always cover one fragment. Also dilates the texcoord by the inverse glyph Jacobian. - Outputs the dilated clip position + dilated
vTexcoord+ flatvBanding/vGlyph/vColor.
The fragment stage (the fragment half of shaders/slug-shader.ts):
- From
vTexcoord, derive(hBandIndex, vBandIndex)viabandScale * tex + bandOffsetclamped to[0, bandMax]. - Read the band's header (count + curve-list offset) from
bandTexatglyphLoc + bandIndex. - Walk the curve-index list; for each curve, fetch its two
vec4s fromcurveTexand solve the horizontal (and vertical for v-bands) polynomial to count signed crossings using the standard Loop-Blinn 3-bit root-code table (0x2E74). Early-exit when sorted-curve x/y is past the pixel. - Accumulate signed coverage from h-bands and v-bands and average; clamp
to
[0, 1]; multiply byvColor. Output the result.
The shader is independent of the layout / curve-extraction path — it consumes only the packed atlas + instance buffer.
TextRenderable (3D) per-frame
TextRenderable is a Renderable with isTransparent = true. Its bind is
called by the scene's frame graph; update does:
- Late-installed variant refresh.
bindresolves both pipelines, but scene bindings are built once — not per frame — so a styling feature enabled after a binding exists would otherwise leave its weighted groups drawing with the base pipeline until an unrelated scene mutation rebuilt the binding. The binding'supdatetherefore re-resolves the variant pipeline exactly once, whengpu._variantPipeline === gpu._pipeline && _textVariantResolver— an identity comparison that a base consumer fails on its first term and that stops matching after the single refresh, so no cache key is ever built per frame. The re-resolution reuses the bind-time target arguments captured in the binding closure (colorFormat,sampleCount,depthStencilFormat,depthWrite, and the renderable itself as the alpha-to-coverage owner), so the refreshed variant is the exact sibling of the base pipeline the binding already declares. - For each draw group:
ensureSharedAtlasGpu(uploads / regrows curve+band textures); rebuildbindGroupwhen atlas was rebuilt orbindGroupVersionis stale. - Resize / re-upload the instance buffer per the version + dirty-range protocol above.
- Compose MVP into a 16-float scratch from the active camera's view-projection
× the renderable's world matrix;
writeBufferto theTextUUBO offset 0. Skip the recompute + upload when the world matrix is clean and the camera'sworldMatrixVersionand effective aspect are both unchanged. - Write viewport size at UBO offset 64 (16 bytes) when target size changed.
Write
(1, 1, 1, opacity)at UBO offset 80 when opacity changed.
draw then iterates the draw groups: bind the group's pipeline (base, or the
pre-resolved variant when g.groupKey !== g.curveSetId, deduped against the
currently bound one); setBindGroup(0, g.bindGroup);
pass.draw(6, g.slotCount, 0, g.slotStart). There is one draw call per
non-empty group, and slotCount includes dead slots (which collapse in the
vertex shader). If any variant group was bound, the base pipeline is re-bound
before returning so the draw list's setPipeline dedupe stays valid for the next
renderable. Crucially, TextRenderable does not bind the scene's
shared scene-UBO at group 0 — it composes its own MVP so the same pipeline can
run from a TextRenderer with no scene at all.
TextRenderer (2D) per-frame
TextRenderer is a RenderingContext registered via registerTextRenderer.
startEngine calls its _update (per-layer GPU sync) and _record (opens a
swapchain render pass and emits per-layer draws) once per frame.
The MVP for a 2D layer is built directly:
[ cos·(2s/W) sin·(2s/W) 0 (2·px/W - 1) ][ -sin·(2s/H) cos·(2s/H) 0 (1 - 2·py/H) ][ 0 0 1 0 ][ 0 0 0 1 ]A 6-float lastMvpInputs cache (px, py, rot, scale, W, H) gates the MVP
writeBuffer so a static layer pays zero per frame after the first.
The renderer's pipeline is cached at (swapchain format, sampleCount=1, no depth, depthWrite=false, flipY=false); depth-less means depth-hosted text-on-3D
must use TextRenderable instead. Layers are sorted by order once per frame
when there are >1; per-layer GPU records (LayerGpu) hold one bind group per
draw group plus an instanceBuf and a textU UBO.
Default layout (layout.ts)
layoutText(font, text, fontSizePx, options) runs LTR shaping per paragraph
(text-shaper.shape returns positioned glyph clusters), greedy word wrap at
maxWidth, optional alignment, then bakes a flat PlacedGlyph[] with
pixel-space positions:
- Y is up: line 0 sits at
y=0, subsequent lines aty = -lineIdx * lineHeightPx. This pairs naturally with the font's em-space Y-up bounds so a 3D scene with a Y-up camera renders text upright with no extra transform. pixelsPerFontUnit = font.scaleForSize(fontSizePx)is returned alongside; theGlyphRunconsumes it and the per-instanceslugAnchor.z = 1 / scaledrives the vertex shader's em→pixel transform.
Default curve extraction (glyph-extraction.ts)
extractGlyphCurves(font, glyphIds, target) walks text-shaper.getGlyphPath
for each requested id, converts every command (M, L, Q, C, Z) into
quadratic Bézier segments, and stores the result in target. L is split into
a degenerate quadratic (control point at midpoint); C is split into two
quadratics via the "3/4 rule" (exposed publicly as cubicToQuadratics for
callers that ingest their own cubic outlines from DirectWrite / FreeType / etc).
Font._curvesCache memoizes per-glyph extraction across calls, so a second
createDefaultTextData with the same font re-uses the already-rasterized
outlines.
Dependencies
text-shaper(npm) — used byfont.ts(font loading),layout.ts(shaping), andglyph-extraction.ts(glyph path extraction). Only the default-layout / default-curves modules import it; callers using hand-rolled layout pay zero bytes for it.engine/engine.ts—EngineContext,RenderingContext(TextRenderer registration),getRenderTargetSize.engine/render-target.ts—RenderTargetSignature(TextRenderable pipeline key).render/renderable.ts—Renderable,DrawBinding,DrawUpdateContext(TextRenderable contract).scene/scene-core.ts—addDeferredSceneRenderables(addTextRenderableattachment).camera/camera.ts—getViewProjectionMatrix,getEffectiveAspectRatio(TextRenderable per-frame MVP composition).math/observable-vec3.ts,math/observable-quat.ts,math/compose-mat4.ts,math/multiply-mat4-into-buffer.ts,scene/world-matrix-state.ts,scene/scene-node.ts— TRS + world matrix plumbing reused from Mesh.resource/gpu-buffers.ts—createEmptyUniformBufferfor theTextUUBO.
Reference scenes
- Scene 180 —
scene180-text-renderer: standalone 2DTextRenderer+TextLayerdriven by a<textarea>+ sliders. Demonstrates pure-2D path (no scene, no camera), liveupdateDefaultTextData, color slider routed through areplaceRunop, drag-to-move, wheel-to-scale. - Scene 181 —
scene181-text-editor: 3DTextRenderableattached to an arc-rotate scene, driven by a<textarea>callingupdateDefaultTextDataon every keystroke. Demonstrates in-place atlas growth and instance-buffer reuse — typing a new character extends the atlas with that glyph's outlines and rewrites the run viareplaceRun's in-place fast path when the glyph count is unchanged.
Both scenes are tagged skipParity and skipPerf (interactive demos, no
golden-image oracle).
Test specification
Unit tests live in tests/lite/unit/:
text-glyph-storage.test.ts— coversGlyphStorageownership semantics:disposeTextDatadoes not touch the borrowed storage; oneGlyphStoragebacks multipleTextDatas;disposeGlyphStorageis idempotent and tears down every curve-set's atlas;updateGlyphStorageextends an existing curve-set and creates new ones on demand;resetwith no args performs compaction (re-lays-out slots and frees dead-slot gaps).text-color.test.ts— covers per-glyphPlacedGlyph.coloroverriding the run'sdefaultColor, anddefaultColorpropagating to glyphs that omit their own color.
File inventory
| File | Responsibility |
|---|---|
src/text/font.ts | Font (branded type) + loadFont + createFontFromBuffer; text-shaper boundary. |
src/text/glyph-extraction.ts | extractGlyphCurves + cubicToQuadratics; default text-shaper-backed glyph path extraction. Outline value types live in glyph-storage.ts. |
src/text/glyph-storage.ts | GlyphStorage (branded) + CurveSetId + outline value types (QuadCurve, GlyphBounds, GlyphCurves) + the supporting SharedAtlas / AtlasSlot / GlyphBands types and their packers (packAppendGlyph, buildGlyphBands). Public API: createGlyphStorage / updateGlyphStorage / disposeGlyphStorage. |
src/text/layout.ts | TextLayoutOptions + layoutText — default LTR + word-wrap + align layout via text-shaper.shape. |
src/text/text-data.ts | TextData (branded) + GlyphRun + PlacedGlyph + TextDataUpdate + slot-allocator types. Public API: createTextData / updateTextData / disposeTextData and the per-instance allocator (addRun / removeRun / replaceRun / reset+compaction). |
src/text/default-text-data.ts | DefaultTextData (branded) + createDefaultTextData / updateDefaultTextData / disposeDefaultTextData; convenience layer composing layout + curve extraction + a private GlyphStorage. |
src/text/text-renderable.ts | TextRenderable + createTextRenderable / addTextRenderable / disposeTextRenderable; 3D Renderable implementation. |
src/text/text-renderer.ts | TextLayer (2D pixel-space placement record) + TextRenderer (standalone RenderingContext) + their factories and the swapchain draw pass. |
src/text/_gpu/text-textures.ts | ensureSharedAtlasGpu; lazy rgba32float texture create + version-gated upload + capacity grow. (Atlas teardown is inlined in disposeGlyphStorage to avoid a circular import.) |
src/text/_gpu/text-pipeline.ts | getOrCreateTextPipeline / getTextPipelineCache / clearTextPipelineCache + the TextPipelineVariant seam; per-device bind group layout + base WGSL modules + per-target-key pipeline cache. Knows nothing about shader fragments and composes no WGSL. |
src/text/shaders/slug-shader.ts | The single authoritative copy of the Slug WGSL. Inline TypeScript template + composeSlugShader(fragment) — a deterministic builder that interpolates an optional TextShaderFragment into the base vertex/fragment source at named slots. Called with null for the base variant. |
src/text/shaders/text-shader-fragment.ts | Type-only module: TextShaderFragment, TextVertexSlot, TextFragmentSlot. Feature-agnostic — no font-weight (or any other feature's) semantics. Erased at build time. |
src/text/set-font-weight-offset.ts | Opt-in feature entry point: setFontWeightOffset(data, run, offset). Owns the per-run offset map, the interned draw-group keys, and the per-GPUDevice composed+compiled variant module pair; validates and resolves the run against its TextData, installs the text-data styling seam and the text-pipeline variant resolver on first effective call, then repacks the data through updateTextData({ update: "reset" }). |
src/text/load-font-weight-offset.ts | Root-exported lazy loader for the exact set-font-weight-offset.ts implementation module. Application code can keep the feature behind a dynamic boundary without importing the package barrel as a namespace. |
src/text/shaders/weight-shader-fragment.ts | The weight-only TextShaderFragment: distance-to-quadratic helper, bounded nearest-contour band scan, weight varying, quad inflation, and unsigned-distance coverage expansion. Contains no copy of any base Slug logic. Reachable only from set-font-weight-offset.ts. |
Slug shader composition
One template, many variants
There is exactly one copy of the base Slug vertex and fragment logic, and it lives
in src/text/shaders/slug-shader.ts as an inline TypeScript template. There are no
slug.vert.wgsl / slug.frag.wgsl ?raw files, and there is no second "weighted"
copy of the shader: optional features contribute incremental WGSL through a typed
fragment that the template interpolates at named slots.
export function composeSlugShader(fragment: TextShaderFragment | null): ComposedSlugShader;
export interface ComposedSlugShader { /** @internal Composed vertex WGSL. */ readonly _vert: string; /** @internal Composed fragment WGSL. */ readonly _frag: string; /** @internal Fragment id ("" for the base variant) — folded into the pipeline cache key. */ readonly _key: string;}This mirrors the material stack's ShaderTemplate + ShaderFragment + composeShader
model (src/shader/fragment-types.ts, src/shader/shader-composer.ts), but is a
compact text-specific builder rather than a second instance of the generic material
composer: text owns a fixed custom @group(0) layout (uniform + curve texture + band
texture + glyph-metadata storage + style storage) with no mesh/material UBO, no shadow
group, no vertex-attribute negotiation and no UBO layout computation, so none of the
generic composer's machinery applies.
Interpolation is direct (${...} into the template literal), not marker
substitution and not a regex pass over emitted WGSL. Production bundles minify inline
WGSL template literals (scripts/wgsl-minify-plugin.ts → renderChunk), which strips
// comments and collapses whitespace; anything that depended on comment markers or on
parsing the emitted string would break there (see GUIDANCE "Never parse emitted WGSL
strings"). A builder that concatenates known strings is immune.
Injection slots
TextShaderFragment is a pure-type description (src/text/shaders/text-shader-fragment.ts):
export type TextVertexSlot = "VO" | "VD" | "VB" | "VA";export type TextFragmentSlot = "FI" | "FH" | "CO";
export interface TextShaderFragment { /** @internal Stable id — part of the pipeline cache key and the shader module label. */ readonly _id: string; /** @internal */ readonly _vertexSlots?: Partial<Record<TextVertexSlot, string>>; /** @internal */ readonly _fragmentSlots?: Partial<Record<TextFragmentSlot, string>>;}| Slot | Stage | Location | Contract |
|---|---|---|---|
VO | vertex | End of the VOut struct body | Comma-terminated member declarations. Locations 0..3 are taken by the base varyings, so fragments start at @location(4). |
VD | vertex | Dead-slot early-return block | Statements assigning a defined value to every member the fragment added, on the local d: VOut. |
VB | vertex | After md (glyph metadata) and sy (style) are in scope, before quad setup | Statements that must declare let sb: vec4<f32> — the shaped em-space glyph bounds. When the slot is present the template uses sb in place of md.b; when absent the template names md.b directly, so the base variant pays nothing. Values declared here stay in scope for VA. |
VA | vertex | Just before return out; | Statements assigning the fragment's added varyings on out. |
FI | fragment | End of the FIn struct body (before @builtin(front_facing)) | Comma-terminated member declarations mirroring VO. |
FH | fragment | Module scope, after the base helpers, before @fragment fn main | Helper function / constant declarations. The base helpers (rcode, solveH, solveV, ccov, cwgt, bloc) and the ct / bt textures are already in scope, so a fragment that needs its own scan of the glyph's band lists writes it here as a self-contained function. |
CO | fragment | After base cov is computed and clamped, before the coverage-gamma pow | Statements that may read and reassign var cov: f32 (coverage in [0,1]). In scope: rc (em-space render coord), pe (pixels-per-em), gp (glyph texel origin), bm (max band indices), in. The gamma, the a2c premultiply select, and the final color write stay in the template. |
There are deliberately no per-curve slots inside the base band loops. Those loops
break as soon as a curve is behind the pixel along the band's sort axis, and they only
ever visit the single band that contains the pixel — both correct for coverage, both
wrong for anything that needs nearest-contour information (see "Why the weight fragment
owns its own scan"). A feature that needs a different traversal writes its own bounded
scan in FH rather than piggy-backing on a loop whose exit condition it does not control.
Slot ids are short because they are WGSL-adjacent identifiers in a size-sensitive path; they are enumerated as a union type, so a typo is a compile error rather than a silently ignored injection.
Base variant is free
composeSlugShader(null) emits exactly the base shader: no extra varying, no helper, no
coverage override — the slot expressions collapse to "" and the bounds expression names
md.b directly. Unweighted (base) draw groups therefore execute behaviourally identical
shader code to the pre-feature engine, with zero extra fragment-shader work.
Variant module lifecycle
src/text/_gpu/text-pipeline.ts knows nothing about shader fragments and never composes
WGSL. It accepts an already-compiled, opaque module pair:
/** @internal Opaque compiled shader-module pair for one composed text shader variant. */export interface TextPipelineVariant { /** @internal Stable id — the variant field of the pipeline cache key. */ readonly _id: string; /** @internal */ readonly _vertModule: GPUShaderModule; /** @internal */ readonly _fragModule: GPUShaderModule;}
/** @internal Installed by an opt-in text styling feature; resolves that feature's compiled * module pair for a device. Null until a feature installs one. */export let _textVariantResolver: ((device: GPUDevice) => TextPipelineVariant) | null;/** @internal */ export function _installTextVariantResolver(resolve: (device: GPUDevice) => TextPipelineVariant): void;- The feature owns composition and compilation.
set-font-weight-offset.tscallscomposeSlugShader(WEIGHT_SHADER_FRAGMENT)anddevice.createShaderModuleitself, and memoizes the resultingTextPipelineVariantin a lazily createdWeakMap<GPUDevice, TextPipelineVariant>(no module-level allocation, auto-invalidating per device — GUIDANCE §4). The core cache holds no variant module fields at all, so a second feature can never overwrite or inherit another's stale modules. - One styling feature at a time. The resolver is a single nullable slot; installing a second feature replaces the first. The group-key seam (below) and the variant resolver are installed together by the same feature, so they cannot disagree.
- The pipeline cache key is fixed arity, six
:-separated fields, every field always present:format : sampleCount : depthStencilFormat|"-" : "w"|"r" : "a"|"-" : variantId|"-". There is no optional field and no delimiter alias, so a base A2C pipeline (…:a:-) can never collide with a variant whose id happens to be"a"(…:-:a). - Alpha-to-coverage stays a pipeline-overridable constant (
@id(0) override a2c) on whichever module the pipeline uses — one module still serves both the blended and the A2C pipeline for each variant.
Pipeline selection per draw group
getOrCreateTextPipeline returns both pipelines for a target signature:
export interface TextPipelineSet { /** @internal Base Slug pipeline. */ _pipeline: GPURenderPipeline; /** @internal Composed-variant pipeline; **aliases `_pipeline`** when no styling feature * is installed, so draw paths can bind it unconditionally. */ _variantPipeline: GPURenderPipeline; /** @internal */ _cache: TextPipelineDeviceCache;}Resolution happens once per bind (TextRenderable) / once per frame update
(TextRenderer) and is stored on the per-renderable / per-layer GPU record — never inside
the draw loop, which does no map lookups and builds no key strings. A TextRenderable
binding additionally re-resolves its variant pipeline once if a styling feature was
installed after the binding was built (see "TextRenderable (3D) per-frame" step 0);
the TextRenderer path needs no equivalent because its _update already resolves both
pipelines every frame. Draw paths that only need the shared quad buffer or bind-group
layout call getTextPipelineCache(engine) instead, which is a single WeakMap get.
A draw group selects between the two with one identity comparison,
g._groupKey === g._curveSetId (see "Draw-group keys"), tracks the currently bound
pipeline locally, and — in the TextRenderable path, which shares an encoder with the
scene's draw list — restores the base pipeline before returning so the caller's
setPipeline dedupe stays valid for the next renderable. The TextRenderer path records
into a render bundle whose state does not outlive executeBundles, so it needs no restore.
Tree-shaking
text-shader-fragment.tsis types only → zero runtime bytes.slug-shader.ts(base template + builder) is reached fromtext-pipeline.ts, so every text consumer carries it — exactly one copy, as before.weight-shader-fragment.tsis imported only byset-font-weight-offset.ts. A consumer that does not importsetFontWeightOffsetnever reaches it, so the distance-to-quadratic WGSL, the band scan, the weight varying and the coverage override are absent from the bundle.tests/lite/build/text-shader-fragment-treeshake.test.tsasserts both directions against the real shippedbuild/liboutput.text-data.tscarries no font-weight semantics. It knows only an optional per-run style parameter and an opaque draw-group key whose default is the run's ownCurveSetId.
Opt-in feature: synthetic font-weight offset
Purpose
A synthetic contour offset (emboldening) per GlyphRun, evaluated
analytically in the Slug fragment shader. This is not CSS font-weight face selection
(100–900); it is a non-negative contour offset in font design units that thickens the
rendered outlines by expanding them outward. Round
contour joins fall out of the distance field automatically — no miter/bevel logic.
The feature owns all of its cost at its enabler: importing setFontWeightOffset pulls in
the weight shader fragment; calling it with an offset that actually changes a run installs
the two core seams and composes the variant shader. Consumers that never import the setter
pay zero shader bytes and zero shader work.
Public API
export function setFontWeightOffset(data: TextData, run: GlyphRun | number, offset: number): void;export function loadFontWeightOffset(): Promise<typeof setFontWeightOffset>;loadFontWeightOffset() is the application-owned lazy-boundary form. Its implementation
dynamically imports only text/set-font-weight-offset, never the package root namespace,
so bundlers do not conservatively retain unrelated root exports in the lazy chunk.
| Parameter | Type | Description |
|---|---|---|
data | TextData | The TextData that owns the run. The setter mutates live data: it repacks and regroups data immediately, exactly like a setter on a live material. |
run | GlyphRun | number | The run to weight, as either its GlyphRun reference or its current index in data.runs. Must be a live run of data; anything else throws with the same message shape updateTextData uses. |
offset | number | Contour offset in font design units (the space of GlyphCurves.bounds). Positive = bolder. Must be finite; clamped to [0, 100] with a console.warn. Zero clears a previously set offset. |
import { setFontWeightOffset, createDefaultTextData, loadFont } from "@babylonjs/lite";
const font = await loadFont("/fonts/Inter.ttf");const data = createDefaultTextData(font, 48, "Bold text");setFontWeightOffset(data, data.runs[0]!, 10); // +10 font unitssetFontWeightOffset(data, 0, 10); // identical: runs may be addressed by indexsetFontWeightOffset(data, 0, 0); // back to the base pipelineIt takes the owning TextData — not a bare GlyphRun — because runs are packed
synchronously. createTextData / createDefaultTextData pack their runs before they
return, and updateTextData packs each edit as it is applied, so a setter that only
recorded state on a detached run descriptor could never affect data that already existed;
on a DefaultTextData (whose single run is created and packed inside the factory) there is
no moment at which such a call could be made at all. Owning the TextData lets the setter
repack and regroup on the spot, so the call works at any point in the data's life,
including after it is already bound and rendering.
Setter lifecycle
- Validate the offset. Non-finite →
console.error, return. Nothing is installed and nothing is repacked. - Resolve the run against
data._resolveRunRef(data, run, "setFontWeightOffset")(text-data.ts) accepts aGlyphRunreference that is live indataor an in-range index intodata.runs, and throws otherwise — same wording as theupdateTextDataerrors (… : run index N out of range (0..M)./… : GlyphRun reference is not in this TextData.).updateTextData's own index path shares this helper, so the two can never drift. - Clamp to
[0, 100]with aconsole.warnwhen out of range. - No-op detection. If the resolved run's current effective offset already equals the
clamped value, return before touching anything: no seam install, no shader composition,
no
_version/_layoutVersionchurn. A zero call on a run that never had an offset is therefore a true no-op — it does not install the feature or compile its shader — while the first nonzero call always installs. - Capture the
WeakMap's exact prior entry for this run — whether it had one at all, and its value — then store the clamped offset (zero deletes the entry), then install the seams (_installTextStyleSeam+_installTextVariantResolver), idempotently. - Repack via
updateTextData(data, { update: "reset" })— the narrowest existing TextData operation that both re-reads every run's draw-group key and rewrites every style entry'sparams.y.resetwith norunskeeps the current run objects (it defensively copiesdata._runs), compacts the slot allocator, and reuses the previous draw-group objects per key, so a run moving between the base and weighted groups is a regrouping — not a rebuild of the caller's descriptors. The feature deliberately re-implements none of the allocator, grouping or style-packing logic. - Roll back on failure. If step 6 throws (e.g. the run's curve set is no longer in
data's storage), theWeakMapentry written in step 5 is restored to its exact prior state — deleted if the run had no entry before this call, set back to the previous value if it did — before the error is rethrown unchanged. Without this, a failed repack would leave the map at the new offset while the data itself was never actually repacked; a caller that fixes the failure and retries with the same offset would then hit the no-op guard in step 4 and get silent non-repair instead of a retry. The seams installed in step 5 are not rolled back — they are idempotent, side-effect-free until a run actually uses them, and re-runningensureInstalledon a retry is a no-op, so leaving them installed after a failed call is harmless.
Zero therefore both clears the offset and returns the run's group to the base key (and the base pipeline) in the same call.
DefaultTextData and run replacement
createDefaultTextData packs its single run before returning, so the setter is usable
immediately: setFontWeightOffset(data, data.runs[0]!, 10) (or setFontWeightOffset(data, 0, 10)).
updateDefaultTextData re-shapes the text into a new GlyphRun descriptor and applies
it with replaceRun. Offsets are keyed by run identity, so they do not transfer to the
replacement run: the block returns to the base pipeline until the caller re-applies
setFontWeightOffset(data, 0, offset) on the new run (index form is the convenient one
here, since the reference changed). This is deliberate — a generic "carry per-run feature
state across replaceRun" seam would put feature semantics back into text-data.ts and cost
every text consumer bytes for a transfer no base consumer can use. The same holds for any
caller-driven updateTextData({ update: "replaceRun" | "reset", runs }) that swaps run
descriptors.
The core styling seam
text-data.ts exposes exactly one nullable seam object and one opaque type:
/** @internal Opaque draw-group identity, compared by `===` only. The default is the run's * own `CurveSetId`; a styling feature may substitute an interned non-string token, which * by construction can never equal a curve-set id. */export type TextGroupKey = CurveSetId | object;
/** @internal */export interface TextStyleSeam { /** @internal Draw-group key for a run — `run.curveSet` unless the run needs a different * pipeline variant. Must never return a value that equals another curve set's key. */ _key(run: GlyphRun): TextGroupKey; /** @internal Extra style float, packed verbatim into `TextStyle.params.y`. */ _param(run: GlyphRun): number;}
/** @internal */ export let _textStyleSeam: TextStyleSeam | null;/** @internal */ export function _installTextStyleSeam(seam: TextStyleSeam): void;text-data.ts attaches no meaning to either result: _param is a float it copies into
params.y, _key is a token it compares with ===. Both call sites are single
optional-chained calls with a neutral default (_textStyleSeam?._key(run) ?? run.curveSet,
_textStyleSeam?._param(run) ?? 0), and only scalars / opaque values cross the boundary.
All font-weight semantics live in set-font-weight-offset.ts.
text-data.ts additionally exports one feature-agnostic helper so per-run feature setters
resolve and validate their run argument exactly as updateTextData does:
/** @internal Resolve a `GlyphRun | number` reference against `data`'s live runs. `op` * prefixes the thrown message. */export function _resolveRunRef(data: TextData, ref: GlyphRun | number, op: string): GlyphRun;It is used by updateTextData's own index path, so the two cannot drift, and it is
tree-shaken away for consumers that import no feature setter.
Style packing
writeRunToSlots calls _param once per run and writes the result into
TextStyle.params.y (previously reserved/zero; params.x still carries invScale). No
struct or stride change. The value is compared through Math.fround like every other style
float, so an unchanged offset does not bump _styleVersion.
Draw-group keys
TextDataDrawGroup._groupKey: TextGroupKey replaces the pre-feature implicit "one group per
curve set" identity. Grouping, previous-group reuse across a reset, addRun lookup and
replaceRun's in-place fast path all compare that one field by identity — the flat
Map<TextGroupKey, GlyphRun[]> / Map<TextGroupKey, TextDataDrawGroup> shape, allocation
profile and iteration order of the pre-feature code are preserved exactly (one array per
group, no nested maps, no per-curve-set array pairs). _curveSetId remains on the group for
atlas lookup, bind-group labelling and error messages.
- With no styling feature installed,
_groupKey === _curveSetIdfor every group and the grouping algorithm is byte-for-byte the pre-feature one. set-font-weight-offset.tsreturnsrun.curveSetfor an unweighted run and an interned object token (one perCurveSetId, from a lazily createdMap<CurveSetId, object>) for a weighted one. An object can never===a string, so a curve set whose id happens to look like another group's variant key cannot collide — the pathological"X"/"X:w"pair that a delimited composite string key would conflate stays in distinct groups (regression-tested).- Base and weighted runs on the same curve set land in separate draw groups, because
they need different pipelines. Weighted runs with different nonzero offsets batch into
one group — the offset travels per style entry in
params.y. g._groupKey !== g._curveSetIdis exactly "this group needs the variant pipeline", so the draw paths need no extra per-group state.
Weight shader fragment
src/text/shaders/weight-shader-fragment.ts exports one TextShaderFragment
(_id: "w"), containing only incremental code:
| Slot | Contribution |
|---|---|
VO | @location(4) @interpolate(flat) wo:f32, — the per-instance weight offset varying. |
VD | d.wo=0.0; — dead-slot default. |
VB | let wo=sy.p.y; let sb=vec4<f32>(md.b.xy-vec2<f32>(wo),md.b.zw+vec2<f32>(wo)); — inflates the font-space bounds symmetrically so the quad covers the expanded contour. |
VA | out.wo=wo; |
FI | @location(4) @interpolate(flat) wo:f32, |
FH | dot2 + dq (exact distance to a quadratic Bézier) + wdst (the bounded nearest-contour band scan). |
CO | Zero-offset guard, unsigned distance, weighted coverage, and monotone max finalization. |
Why the weight fragment owns its own scan
The base band walk is a winding query, not a nearest-contour query, and its two optimizations are only sound for winding:
- It reads exactly one h-band (the band containing the pixel's
y) and one v-band. That is complete for a+x/+yray cast, because a ray from the pixel stays inside its own band. It is not complete for distance: the nearest contour to a pixel near a band boundary is frequently in the adjacent band. - Inside a band it
breaks as soon as a curve'smax xis more than half a pixel behind the pixel (curves are sorted by descendingmax x). Every remaining curve is behind the ray, so it cannot cross it — but curves to the left of the pixel are exactly the ones that can be nearest to it.
Accumulating a running minimum distance inside those loops therefore silently skips curves
left of and below the pixel, which clips positive (emboldening) offsets on the right and
top edges of a glyph and on all four corners. The weight fragment consequently declares its
own complete, bounded scan in FH and reads nothing from the base loops:
fn wdst(rc:vec2<f32>,gp:vec2<i32>,bm:vec2<i32>,bn:vec4<f32>,rad:f32)->f32- Search radius.
rad = wo + 1/aaScalefont units, whereaaScale = max(pe.x, pe.y)is pixels-per-font-unit. Beyond that radius the weighted coverage saturates to 0 (outside) or is dominated by the base coverage (inside), so a curve farther thanradcannot change the result. - Complete band range. Every h-band spans the full glyph width in
xand partitionsy(glyph-storage.ts→buildBandsInternal), and a band holds every curve whosey-extent intersects it.wdsttherefore iterates all h-bands intersecting[rc.y - rad, rc.y + rad]:y0 = clamp(i32((rc.y - rad) * bn.y + bn.w), 0, bm.y),y1 = clamp(i32((rc.y + rad) * bn.y + bn.w), 0, bm.y). Any curve with a point withinradof the pixel has that point inside the glyph'sy-bounds, so its band index is inside[y0, y1]and the curve is in that band's list. Validated band-transform invariant:bandScaleY = hBandCount / heightFuwhenheightFu > 0and0otherwise, andbandOffsetY = -yMin * bandScaleY, so the transform is monotone non-decreasing and never negative. A zero scale collapses the range to band 0, which is the only band a zero-height glyph has. No sign guard is required; a unit test pins the invariant in the builder. - Radius-aware break only. Within a band the scan still exploits the descending-
max xsort, but with the radius-aware boundcurveMaxX < rc.x - rad. A curve with a point withinradhasmaxX >= rc.x - rad, and every curve before it in sort order has amaxXat least as large, so no in-range curve is ever skipped. dqis the exact point-to-quadratic distance: a degenerate straight-segment fast path whendot(b,b) < 1e-7, otherwise the closest-point depressed-cubic solve withtclamped to[0,1](Cardano for one real root, trigonometric form for three).
Shader math (the CO slot)
if(in.wo!=0.0){ let aas=max(max(pe.x,pe.y),1.0e-8); let d=wdst(rc,gp,bm,in.bn,in.wo+1.0/aas); let wc=clamp((in.wo-d)*aas+0.5,0.0,1.0); cov=max(cov,wc);}- Zero-offset guard. A run whose offset is zero — including a stale style entry left in a variant group — takes no distance scan at all and keeps the base coverage bit-exactly.
- Base Slug coverage runs unchanged and remains authoritative inside the original fill.
- Unsigned contour distance. Positive-only emboldening needs no inside/outside
classification:
wdstis the distance to the nearest outline in either direction. - Offset threshold. Coverage decreases with distance, and the offset pushes
the boundary outward:
wc = saturate((wo - d) * aaScale + 0.5), a ~1px screen-space transition. - Monotone finalization. Emboldening may only add coverage:
cov = max(cov, wc). This keeps the analytic base coverage authoritative inside the original fill, while the distance field contributes only its outward expansion. An overestimatedwdstcan never punch holes into the original glyph. - The template then applies the coverage gamma and the
a2cpremultiply select exactly as for the base variant.
aaScale is floored at 1e-8 so a degenerate derivative (fwidth == inf) cannot produce a
non-finite radius; the coverage transfer function is unaffected for any real glyph.
Limits
Offsets are clamped to [0, 100] font design units; out-of-range values are clamped with a
console.warn, and non-finite values are rejected with a console.error. The proportional
effect depends on the font's unitsPerEm: for a 2048-unit font, 100 is about 0.049 em.
The bound exists because large offsets expand the glyph quad into its neighbours and
lengthen the per-pixel band scan (its cost is proportional to the number of bands the
radius spans).
Test specification
tests/lite/unit/text-font-weight-offset.test.ts:
- Setter semantics — attach / overwrite / clear-with-zero on a live
TextData;NaNandInfinityrejected withconsole.errorand no repack; values clamped to[0, 100]withconsole.warn; setting the value a run already has is a no-op (_versionand_layoutVersionunchanged), and a zero call on a never-weighted run installs nothing. - Run resolution — the index form (
setFontWeightOffset(data, 0, …)) is equivalent to the reference form; an out-of-range index and aGlyphRunthat belongs to anotherTextDataboth throw with theupdateTextData-shaped message. - Post-create mutation — a setter call made after
createTextData(i.e. after the runs are already packed) changes the run's draw-group key and itsparams.yimmediately; a following zero restores both to the base key and0. - Style packing — a weighted run's
params.yholdsMath.fround(offset); a base run's is0. DefaultTextDataflow — the setter works ondata.runs[0]immediately aftercreateDefaultTextData, and afterupdateDefaultTextDatareplaces the run the offset does not transfer but re-applying it by index does.- Variant grouping — one base + one weighted run on the same curve set produce two groups
with the same
_curveSetId, one keyed by the curve set and one by an interned token; two weighted runs with different offsets share one group;addRun/replaceRun/removeRunrespect the key. - Group-key collision regression — the
"X:w"/"X"pair stays in distinct groups with the correct curve set, on create and across areset(group object identity is reused per key). - Band-scan completeness (algorithmic) — a JS replica of
wdst's candidate selection, driven by the realbuildGlyphBandsoutput for a synthetic square glyph, must consider the true nearest curve for sample points on all four exterior sides and all four corners. The pre-fix "accumulate inside the base loop" candidate set is asserted to fail the same check, so the regression cannot silently return. - Band-transform invariant —
buildGlyphBands+ the packed metadata always produce a non-negative, monotone band transform (whatwdst's range clamp relies on). - Composition —
composeSlugShader(null)contains no weight varying,dq/wdsthelper or coverage override, and its band loops contain no injected statements; the composed variant contains all of them; the base source is a subsequence of the variant at every significant line, proving composition rather than duplication._keyis""for the base and the fragment id for the variant. - Coverage direction — a JS replica of the
COtransfer function proves positive offsets expand and thatmaxfinalization can never remove analytic base coverage. - Pipeline cache key — the exported key builder is fixed arity and a base A2C pipeline
cannot collide with a variant whose id is
"a".
tests/lite/unit/text-renderer-variant-pipeline.test.ts: a mocked device + render-bundle
encoder records a layer holding base, weighted and base draw groups and asserts the exact
setPipeline / setBindGroup / draw command sequence (base → variant → base), that a
base-only layer records exactly the pre-feature command sequence, and that the variant
pipeline is resolved during _update, not per draw. The same file covers the
TextRenderable late-install path in module isolation (vi.resetModules() + dynamic
import, so no earlier test has installed the resolver): a renderable is bound first,
its binding update runs with no styling feature installed (variant pipeline aliases the
base), the setter is then called on its live TextData, and the next binding update
must refresh gpu._variantPipeline to a distinct pipeline built from the same target
signature — after which the recorded draw sequence is base → variant → base.
tests/lite/build/text-shader-fragment-treeshake.test.ts (Rollup over the shipped
build/lib): a consumer importing text rendering but not setFontWeightOffset retains the
base Slug source and not the weight fragment's WGSL; a text consumer that also imports
setFontWeightOffset retains both, with the base Slug logic still declared exactly once.