Skip to content

perf: sort main-pass SDF text writes for fewer draw calls - #865

Open
wouterlucas wants to merge 3 commits into
lightning-js:mainfrom
wouterlucas:feat/sdf-text-sorted-writes
Open

perf: sort main-pass SDF text writes for fewer draw calls#865
wouterlucas wants to merge 3 commits into
lightning-js:mainfrom
wouterlucas:feat/sdf-text-sorted-writes

Conversation

@wouterlucas

Copy link
Copy Markdown
Contributor

What

Defer main-pass SDF text rendering into a scene-wide sorted list instead of writing interleaved glyph runs inline during the traversal.

SDF text already batches into a shared GPU buffer, but only consecutive writes sharing a vertex layout (plain 6f vs rich 7f), glyph atlas (per font family) and scissor clip rect merge into one drawElements. Apps that interleave plain/rich text (cards, rails) forced a draw call per region. This sorts the visible SDF text nodes by batch key at render-list build time so each (layout, atlas, clip) group becomes one contiguous range and merges into a single draw call, with zero app-side grouping.

Changes

  • lib/utils.ts — new SdfBatchKey type and compareSdfBatchKeys total ordering (matches the merge key in WebGlRenderer.finalizeSdfBatch: layout, atlas, clip rect incl. radius).
  • CoreNode.getSdfBatchKey() — virtual returning null; CoreTextNode overrides it to return the key for main-pass SDF text, null for canvas text, un-laid-out text, and text inside render-to-texture subtrees.
  • StagebuildRenderList collects main-pass SDF text nodes (scene order) instead of writing inline; sortRenderListSdfTextNodes sorts them with a stable sort (equal keys keep scene order / relative z), cached across frames; writeRenderListSdfText emits them after the main list on every frame. RTT traversal and all non-SDF nodes keep the inline path.
  • Tests — 6 getSdfBatchKey tests in CoreTextNode.test.ts; 12 Stage tests covering comparator order, deferral, sorted emission, inline paths (images, rounded clips, RTT), visibility skips, stable equal-keys, and cached replay.

Correctness

The sort is best-effort: finalizeSdfBatch still keys off actual write-time values, so a stale cached order (e.g. a fontFamily/clip change without a structural rebuild) only costs extra draw calls, never wrong output.

Verification

  • pnpm test 272/272, tsc --build, prettier, eslint clean.
  • SDF visual regression suite 21/21 PASS (chromium-local).
  • Stress A/B example: page 1 (interleaved) vs page 2 (grouped) differ by 20/921,600 px (0.002%) — sorted rendering merges interleaved text into the same grouped output.

Render SDF glyphs into renderer-owned shared vertex buffers (one plain
6f + one rich 7f per-vertex layout) backed by per-node CPU vertex caches.
Consecutive text nodes that share a layout, font atlas, and clip merge
into a single draw call, and unchanged nodes skip GPU uploads entirely
via a cache-hit mem-copy path.

- add SdfBuffer (cursor/growth/change tracking) and SdfRenderOp
  (contiguous-range drawElements with scissor-only clipping)
- rewrite SdfShader to feed pre-transformed position, per-vertex color,
  distance range, and style as vertex attributes; remove the per-node
  u_transform/u_size/u_color/u_distanceRange uniforms
- extend WebGlShaderProgram.bindRenderOp to bind SdfRenderOps through the
  normal uniform path; drop the legacy onSdfBind hook and isSdfRenderOp flag
- move CoreTextNode onto a shared SdfVertexCache; track lastStartQuad and
  lastWriteDirty so a render-list reorder marks the shared buffer dirty and
  re-uploads instead of leaving stale GPU bytes at shifted offsets
- add the text-stress-sdf example (interleaved vs grouped render order)
  and unit tests for the buffer, bind path, and offset-tracking fix
Add certified visual baselines for the SDF text stress example.
@wouterlucas
wouterlucas force-pushed the feat/sdf-text-sorted-writes branch from b726054 to d5cbd76 Compare August 7, 2026 15:44
@wouterlucas

Copy link
Copy Markdown
Contributor Author

Rebased onto latest main (3aef7251), which includes the merged #860 (static nodeCoords VBO / reduced vertex stride) and #861 (surgical dirty-quad uploads). Conflicts in WebGlRenderer.ts and Stage.ts resolved by keeping both: upstream quad uploads + the batched SDF pipeline. Added a stub for the new SDF buffer fields in the upstream dirty-quad test harness (the Object.create-based mock skips class-field initializers). All 296 unit tests pass; tsc --build clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant