A three-flatland experiment
Below are two demos running the same Ladder and Grid workloads. One is upstream uikit with MSDF text and SVG-mesh icons; the other is a fork using Slug for both, which also carries a few uikit CPU optimizations we intend to contribute upstream. This page reports what the benchmarks show, and where each approach fits, without claiming more than the measurements support.
Both demos are the benchmark-suite apps themselves, so DPR does not follow your display automatically. Set it manually in the navbar, along with scene (Ladder / Grid), view angle, backend, and language.
Both demos render the same two scenes:
Each demo renders that text and those icons a different way:
Slug sends each glyph's quadratic Bézier outline to the GPU and estimates fractional coverage per fragment from the curves and their winding. Nothing is baked, so there is no atlas resolution: the outline is evaluated at the requested size.
Ahead of time, each glyph is baked into a small multi-channel distance-field texture. At draw time the shader samples the atlas, takes the median of three channels, scales the stored distance into screen pixels, and computes coverage. That is light work, but the atlas is baked at a fixed resolution.
A finite-resolution MSDF can lose small glyph features when it is minified (drawn much smaller than it
was baked). Its shader scales the baked distance range into screen pixels, and msdfgen's author warns
that anti-aliasing is likely to fail once that screen-space range becomes too narrow. Standard reusable
atlases also lack the size-specific hinting that conventional rasterizers use to snap thin stems onto
the pixel grid. How visible this is depends on atlas resolution, the baked distance range
(pxrange), filtering, and the glyph itself. In this demo you can see it directly: at 1×
the small MSDF rows soften while Slug stays sharp.
At 2× DPR the glyph covers more screen pixels, and the softness seen here is reduced. Slug has no atlas resolution to fall below, so its outline does not need a higher-resolution re-bake as scale changes, and in the tested rows it stays sharp at 1×. (Very small Slug text has its own anti-aliasing considerations, which Slug mitigates with dilation; resolution-independence is not a guarantee of perfect rasterization at every size.) MSDF was designed to preserve sharp corners better than single-channel SDF; Chlumský's msdfgen is the reference for the multi-channel technique.
Slug's analytic coverage does more fragment work than an atlas sample, so it costs more. Measured on the Ladder scene, p50 GPU-milliseconds per frame from real timestamp queries (lower is better):
Single device; p50 GPU timestamp over a settled ~12s window. See Method for environment.
| Text · Ladder | GPU @ 1× | GPU @ 2× | draws |
|---|---|---|---|
| MSDF · WebGL | 0.19 ms | 0.53 ms | 29 |
| Slug · WebGPU | 1.11 ms | 2.69 ms | 5 |
| Slug · WebGL2 | 1.82 ms | 3.63 ms | 5 |
On this Ladder scene, MSDF took about one-fifth to one-tenth of Slug's GPU time (roughly 5–10×), depending on backend and DPR. Note this compares complete implementations across different renderers, not the fragment algorithms in isolation. Both cost more at 2× DPR: here Slug rose 2.42× on WebGPU and 1.99× on WebGL2, while MSDF rose 2.79× but stayed much cheaper in absolute time.
Reducing that 2× cost is unfinished work; see Open questions below.
In this uikit build, each SVG icon path is turned into an unbatched mesh, so a grid of a few thousand labels produces a few thousand draw calls. Slug includes icon curves in the same shared batches as its glyphs. On the Grid scene:
Single device; Slug = WebGPU, uikit = WebGL. p50 GPU timestamp; draws read from the live renderer.
| Icons · Grid (thousands of cells) | backend | draw calls | GPU |
|---|---|---|---|
| Slug | WebGPU | 6 | 2.49 ms |
| uikit (mesh per SVG path) | WebGL | 3,907 | 2.21 ms |
6 draw calls versus 3,907. The total GPU timestamps are similar (2.49 vs 2.21 ms) despite that gap, so fragment time is not the main measured difference. The draw count points to more CPU and driver submission overhead for uikit, but this page does not include a CPU profile that isolates it.
Scope of the icon result. The draw-call gap comes from this uikit build converting SVG paths to unbatched meshes, not from MSDF itself. A batched or atlas-based MSDF icon path would narrow it. Icon representation and text representation are separable decisions: an engine can use MSDF for both, MSDF text with instanced icon geometry, or Slug for both. Slug's advantage here is that text and icons share one batched pipeline; the six measured draws include the icons, but the demo does not measure the marginal cost of adding Slug text on top.
Frame-rate confound. The Slug demo (the fork) also carries CPU-side layout and frame-loop changes that upstream uikit lacks, so frame rate cannot be used to compare Slug with MSDF; the tables above report GPU time and draw calls instead. Those changes are the subject of the next section.
Both render Simplified Chinese, and both scale to any display size. What grows with the character set is storage. Slug's curve data fits one GPU page well past the common sets:
| Character set | glyphs | Slug curves |
|---|---|---|
| Common | 3,500 | 12 MB · 1 page |
| GB2312 | 6,763 | 24 MB · 1 page |
| GBK | 21,000 | 74 MB · 1 page |
| Full (Noto SC) | 65,535 | 229 MB · paged |
An MSDF atlas grows on its own model (glyph count and cell size, not outline complexity). We have not measured the two head-to-head; the shared point is that the full repertoire exceeds a single GPU resource and needs paging either way.
Building the fork surfaced a number of issues in upstream uikit, and in its React-Three-Fiber v10 binding, that we intend to contribute back. Two are the per-frame CPU costs that make frame rate unusable for comparing Slug with MSDF here, which is why the tables above report GPU time and draw calls instead. The rest are correctness fixes.
1/pointScaleFactor grid, but the default factor of 100 makes 1/100, which
is not exactly representable in binary float, so values re-truncate every time they cross the
JS-to-Yoga (WASM) boundary. Combined with an off-grid center derivation, a static element's position
accumulates sub-pixel error across relayouts, and the text visibly swims. Switching to 128 (a power
of two, so 1/128 is exact) plus snapping the center math makes layout byte-identical
across relayouts, verified stable over 150 of them. This one is already extracted onto a clean fork
and is the most PR-ready of the set.
for...in loop over an array, in two places. Both
getStarProperties and ClassList's iterator walk an array with
for...in, yielding indices instead of entries. The star-property one silently drops
*-nested conditional styles (hover, active, focus, dark), so a component styled only
that way cannot even be hovered. One-word fixes.
return), so it renders in the enabled
colour.
Text can paint over a later sibling's
Panel regardless of document order. Confirmed shared with upstream, with a
deterministic repro; the fix is still being designed.
addEventListener listeners, which breaks uikit's Slider and
Textarea drag. Routing events through @pmndrs/pointer-events restores it; worth landing
as the sanctioned v10 wiring.
All of these are already fixed in the fork, apart from the paint-order tie, which has a deterministic repro but a fix still being designed. The upstream PRs are not yet filed; details and repros live in the fork's upstream-bug ledger and PR #179.
This is a work in progress, and several things here are genuinely unsettled. We would rather name them than pretend they are solved.