vgpu-render
v0.6.0
Published
WebGPU debug-draw, gizmo, infinite grid and text layer built on vgpu
Maintainers
Readme
vgpu-render
A 3D debug-draw and gizmo layer built on top of vgpu, porting the reference
debug-draw addon's examples and its shader-driven infinite grid to TypeScript +
WGSL.
The current implementation is partial parity, not a complete migration. The
Circle/Line runtime screenshot comparison in
docs/visual-comparison.md records the remaining
layout, dash, rotation, normal-aware width, color, grid and label differences;
round/bevel/miter line joins are built in the vertex stage like the reference.
Passing GPU smoke tests does not establish visual equivalence.
Text rendering (see Text) is vgpu's own capability rather than a port: the reference addon has no text API at all, and the labels in its examples are plain billboard nodes.
Pages
The gallery serves two independent page sets from one entry (gallery/demo.tsx):
Basic examples (
/) — the API / parity gallery inexamples/basic/: the 3D matrix, the 2D shape matrices and the text pages.Battle VFX (
/?page=vfx) — the battle-effects page inexamples/vfx/:world2dscenes staged on a loop timeline with a scrub slider, an editableduration(0.8 s on every page), per-effect parameters, and hue/saturation sliders so each effect can be recoloured. The sidebar splits them in two. Game Feel is the hit-feedback set: one instant of contact — a swing, the mark it leaves, the flash on the frame it lands, the body it nudges — so the page is a matrix. Columns are the eleven anime.js families (linear/quad/cubic/quart/quint/sine/expo/circ/back/elastic/bounce), rows are the directions (in/out/inOut), 33 cells in total. Every cell shares the clock, the geometry and the parameters, so the curve difference is the only variable, and a square plot of each curve with a live playhead sits above its effect;linear(the only directionless family) is the baseline and the diagonal guide inside every plot. 真实案例 is the whole-clip set: one cast per page — a windup, the effect and its aftermath — with one curve row per element under the stage and a single playhead crossing all of them.slash-arc— the swing grows: a filled sector and an arc stroke sweep out from the start angle, so the swept angle at any instant is the curve.battle-slash— the swing the~/gamedev/battlesimulator draws: the first frame is already a full 240° band as wide as its own radius, which then retracts from its start edge while it fades. Here the curve is how fast that mark gets wiped away. Only the arc is drawn; the hit resolution is the next page.hit-mark— the stroke's other half: a blazing rhombus cut across a real unit, its long axis stretching outward while it fades. The unit is drawn because a cut across empty grid is just a diamond — this is where you can feel whether the timing reads as a hit.hit-flash— the other half of the hit feedback: a full-strength disc over the whole unit, lifting on1 - eased. Thecirc/incell is the simulator's own timing; the rest of the matrix shows what a slower or faster curve does to the same flash.hit-shake— the third channel: the body itself, nudged left and right and settling back. The displacement isamplitude × (1 - eased) × cos(progress × cycles × 2π), so the curve decides how fast the hit is spent rather than how far the body travels. The default opens well under the body radius on purpose — a shake that carries the unit more than its own size reads as a knockback.outrows snap home on the impact frame,inrows stay moving to the end.health-bar— four bar effects on one clock, because a unit is either being hit, healed, shielded or having its shield broken. 受击: the black slot and blood-red fill over a body, and the white ghost a hit leaves — it holds at the old length for 0.22 s, then walks down in 0.38 s onoutQuad. 治疗: the fill grows back on the same curve instead of jumping, and the stretch that just returned stays grass green before shrinking into the wound colour. 上盾: a blue barrier plate a size larger than the bar drops onto it, shrinks until it sits tight, and the shield snaps to full on that frame — no fill, no growth. The slot is1 + shieldMaxhealth bars wide, so the shield to the right of the fill reads as extra health. 破盾: one heavy blow takes the shield and spills the rest onto the fill, with the ghost starting on that frame — the only column where a shielded unit loses health, which is why the shield and the break are separate examples. All four start on the same frame; the shorter ones hold their last frame until the slowest is done and the loop restarts, anddurationcan only add a pause after that. A fifth bar effect is one more entry inEXAMPLES— the columns, strips and window re-space themselves.heal-beam— the first page that shows one effect instead of a matrix, and the first with an element timeline: the heal from~/gamedev/battle(a ground pool spreading from the feet, a stacked column rising off the body, sparks drifting up its length) on the left, and one curve row per element on the right — pool spread, column height, column brightness, spark brightness — with a single playhead crossing all four. The rows and the stage read the same phase functions, so the curve strip is the effect's timing rather than a second animation describing it.fireball— a projectile: the caster winds up an ember, the ball flies a straight line to the target dragging a tail, and the hit opens four layers at once — flash, a fast thin ring, a slow wide ring, sparks thrown out and embers left drifting. The stage is a strip across the top and the four curve rows sit underneath, sharing one playhead: ride it and the strip says the flight is still climbing while every blast row is flat, then the ring's arc peaks and turns over while the sparks are at their widest.chain-lightning— a strike, not a beam: one small charge, then the bolt lands and forks out of the first enemy to every other one on that same frame. The path is a hard zig-zag — a few long legs with big throws, rebuilt every frame from a deterministic hash — so it reads as lightning drawn between two bodies rather than a trembling wire or a smooth arc. The tree then kicks twice and is gone, and the rest of the loop is empty on purpose.targetschanges how many forks hang off the tree; the three rows plot the strike, each bolt's afterglow strobe, and the hit flash and ring. No engine reference — it is designed from the "first hit, then the rest on the same frame" beat.dash-slash— the delayed cut: the caster dashes past the enemy leaving afterimages, blinks out behind it, and then the stage holds. Only after that pause do six cuts open on the enemy one by one, each at its own angle and on its own short clock. The pause is the whole point — a dash that resolves on the frame it lands is a movement effect, not a hit.cuts,lengthandtrailschange the flurry, the cut size and the afterimages; the four rows plot the dash, each cut's brightness, one cut's stretch and the blink.light-pillar— a column of light through its whole life: it is planted, holds, and then bursts. The planting is a snap, not a growth — the height overshoots onoutExpoand settles back, and the floor is pushed open rather than lit: the pool's reach lags the shaft onoutQuadwhile a thinner ring is driven out of the foot ahead of it, so the floor reads as giving way. It holds for rather more than a third of the loop — breathing while it does: one sine cycle across the hold, both ends on zero, swinging the width, the height, the brightness and the hue together, so the column looks like it is doing work instead of waiting. That hold is what makes the burst land. The burst is all inside the first third of the scatter — the shaft is thrown open onoutExpo, the fills fold towards one width while the bright core leaves first, and the light drops to a fifth. What is left after that is a wide dim haze that fades on its own. The floor answers on the same frame with a ring thrown off the pool, outrunning it and gone by the time it is at its widest. Six nested fills of one colour plus two flat ellipses are the whole shape;ringat zero gives the old, ring-free scatter. The two halves used to be two matrix pages (one growing the column, one scattering it) — on one clock the rise ends exactly where the burst starts. Five rows plot the rise, the width, the brightness, and the ring's reach and light.ult-flash— the pop an ultimate leaves on its caster: a small cross of light gathers on the body, then shoots out into a four-armed star while a single ring is thrown off it and gone. The flash never tints the unit — it is its own object on top of the body, so it can be a cast cue without turning the character into a white disc. Four rows plot the star's brightness, the arms' length, and the ring's reach and brightness.
Both sets share the engine (gallery/demo-engine.ts): a switch swaps the registry,
dispatcher, storage record and camera, while the WebGPU device and render loop stay up.
Each set keeps its own localStorage records, so the two pages never overwrite each
other's panel state.
Install
bun add vgpu-render vgpu
# or
npm install vgpu-render vgpuvgpu is a peer dependency: the host application owns the WebGPU device and the frame loop, so both
packages must resolve the same vgpu copy. @vgpu/wgsl and opentype.js ship as regular
dependencies of this package.
Usage
The public API uses an explicit frame draw list:
const drawList = debug.beginFrame();
drawList.point([0, 0, 0], { color: [1, 0, 0, 1], size: 8 });
drawList.line([0, 0, 0], [1, 0, 0], { color: [0, 1, 0, 1], width: 2 });
drawList.arrow([0, 0, 0], [1, 1, 0], { color: [0, 0.5, 1, 1] });
drawList.lineStrip([[0, 0, 0], [1, 0, 1], [2, 0, 0]], { width: 4, joint: "round" }, false);
// Per-vertex normals select the reference `*_n` shading: each segment uses its start
// vertex's normal and the joints between them blend the two normals.
drawList.lineStrip([[0, 0, 0], [1, 0, 0], [1, 1, 0]], { width: 4, joint: "round", normals: [[0, 1, 0], [0, 1, 0], [0, 1, 0]] }, false);
drawList.grid([0, 0, 0], [4, 4], [0.5, 0.5], { color: [0.4, 0.4, 0.5, 1] });
drawList.curve(cubicBezier, { color: [1, 1, 1, 1], tolerance: 0.08 });
drawList.mesh(sphere(0.5), { solid: true, color: [0.2, 0.6, 1, 0.8] });
drawList.text([0, 1, 0], "world label", { fontSize: 32, pixelSize: 0.01, color: [1, 0.8, 0.2, 1] });
drawList.text([24, 24, 0], "screen HUD", { space: "screen", anchor: "top-left", fontSize: 16 });
const commands = debug.finishFrame(drawList);
// `view` is the world-to-view matrix; normal-aware strokes read it like the reference view matrix.
renderer.prepare(commands, { viewProjection, view });
grid.prepare({ viewProjection, cameraPosition, cameraForward, projection, farClip });
frame(gpu, (f) => f.pass({ target, clear: [0, 0, 0, 1], clearDepth: 1 }, (pass) => {
grid.draw(pass);
renderer.draw(pass);
}));grid.prepare() takes the camera state the reference addon reads from its
camera node: position, normalized forward, projection mode, and far clip. The LOD
distance is derived from that state (see below), so there is no separate
fadeDistance parameter.
Infinite grid
InfiniteGrid3D is a faithful port of the reference grid addon, including its parameter
names, decimal LOD scheme and fade stack:
| Option | Reference property | Default |
| --- | --- | --- |
| size | grid_size | 2000 |
| edgeFadeStartRatio / edgeFadeEndRatio | edge_fade_*_ratio | 0 / 1 |
| cellSize | cell_size | 1 |
| lineWidthPixels | line_width_pixels | 1 |
| lodFinerLevels / lodTotalLevels | lod_*_levels | 3 / 8 |
| enableGrazingOpacity, grazingFadeStart/End | enable_grazing_opacity, grazing_fade_* | true, 3°, 20° |
| enableLodCenterFade, lodCenterFadeLineCount | enable_lod_center_fade, lod_center_fade_line_count | true, 151 |
| enableStippleDiscard | enable_stipple_discard | false |
| thinLineColor / thickLineColor | thin_line_color / thick_line_color | [0.42,0.42,0.42,0.45] / [0.62,0.62,0.62,0.65] |
| debugLodColors | debug_lod_colors | false |
Behavior preserved from the reference:
- Three decimal LOD layers (finer / current / coarser) are combined with
de-duplicated alpha, and the current layer fades toward the next coarser cell
size. With
cellSize = 1,lodFinerLevels = 3andlodTotalLevels = 8the range is0.001 … 10000. - The camera LOD distance uses the reference rule: the view-to-ground distance
blended toward camera height near grazing angles, clamped to the finest LOD cell
size; orthographic projection uses
sizeinstead, and the focus point comes from the ray-plane intersection. - Edge, grazing, far-clip and per-LOD center fades, plus optional low-alpha stipple discard, run in the same order and with the same clamps.
- The plane follows the camera on XZ (
follow_viewport_camera) and writes depth, so it composites like the referencedepth_draw_always, two-sided material.
The direction-facing difference: the reference renders through a linear-space
pipeline and encodes to sRGB on output, while the WebGPU canvas here
is bgra8unorm (Chromium rejects bgra8unorm-srgb as a canvas format). Line
positions, LOD banding and coverage therefore match, but blended line pixels are
darker than the reference. See docs/visual-comparison.md.
Dot lattice (gallery extension, not reference parity)
displayMode and the dot* options ride on the existing infinite-grid shaders — the 3D finite
quad and the 2D full-viewport quad — so the endless dot lattice needs no extra geometry, draw call
or LOD scheme of its own. InfiniteGrid3D and InfiniteGrid2D share the option names, defaults
and shader maths; the 2D grid derives the pixel scale from zoom, the 3D one from dpdx/dpdy of
the world position:
| Option | Default | Meaning |
| --- | --- | --- |
| displayMode | "grid" | "grid" (reference lines) or "dots" (lattice) |
| dotRadiusPixels | 2 | dot radius, held in screen pixels |
| dotFadeFromPixels / dotFadeToPixels | 6 / 20 | on-screen spacing at which a layer fades out / reaches full strength |
| dotSpacingFactor | 4 | spacing factor between neighbouring dot layers |
| dotColor | [0.62, 0.62, 0.62, 0.55] | dot color; alpha participates like the line alphas |
The lattice is ported from the tldraw-style canvas dots in
~/dev/tools/ai-workflow/src/canvas/dots.tsx: a dot keeps a fixed screen radius, a
4x spacing ladder keeps its on-screen density roughly constant, and layers whose
spacing gets too narrow fade out instead of turning into a grey wash. Four layers
(-1..2 in the ladder) are combined with max so overlapping lattice points do not
brighten. On the 3D plane the edge, grazing, far-clip and center fades still apply
unchanged; the 2D quad has no such fades to begin with, so it only gates the lattice
by on-screen spacing. displayMode is an exclusive choice: the lattice replaces the
reference lines rather than blending with them, so the two sets of panel options never
apply at the same time.
Because the reference addon has no dot field, nothing here counts as parity — the
gallery exposes it purely as a configurable option.
setOpacity() is a gallery extension: it scales the two reference line-colour
alphas plus the dot alpha, which the addon does not expose.
## Pipeline
```text
FrameDrawList -> FrameCommand[] -> GpuDebugDraw3D
points -> instanced billboards
lines -> screen-space or world-space quads, dash + normal aware
triangles -> transient solid meshes
text -> per-glyph instanced quads sampling a generated glyph atlas
2D -> GpuDebugDraw2D: 2D lines/points/joints + SDF shape quads, drawn as an overlayPoints and lines are instanced; the CPU never expands a line into triangles. The infinite grid is a single finite plane whose fragment shader derives minor/major LOD grid lines.
2D
frame.d2 records 2D primitives into the same frame and layer mask as the 3D calls:
const frame = debug.beginFrame();
frame.d2.line([0, 0], [120, 40], { width: 2, color: [1, 0, 0, 1] });
frame.d2.lineStrip([[0, 0], [40, 80], [90, 10]], { width: 4, joint: "round" });
frame.d2.grid([0, 0], [8, 5], [40, 40], { color: [0.4, 0.4, 0.5, 1] });
frame.d2.rect([0, 0], [80, 40], { strokeWidth: 2 });
frame.d2.rect_s([160, 0], [80, 40], { color: [0.9, 0.9, 1, 1] });
frame.d2.rect_d([320, 0], [80, 40], [12, 6, 0], { strokeWidth: 2 });
frame.d2.rect_r_s([0, -60], [120, 40], 12, { color: [1, 0.7, 0.3, 1] });
frame.d2.circle_s([0, 120], 60, { color: [0, 0.5, 1, 1] });
frame.d2.arc([0, 120], 80, { strokeWidth: 3, startAngle: 0, sweep: Math.PI / 2 });
const commands = debug.finishFrame(frame);
// The third argument is the 2D camera; omit it and the last one stays in effect.
renderer.prepare(commands, { viewProjection, view }, { pan: [0, 0], zoom: 1 });Two coordinate spaces share one pixel-based pipeline:
| space | Origin | +y | Unit | Affected by the 2D camera |
| --- | --- | --- | --- | --- |
| world2d (default) | canvas centre | up | one canvas pixel at zoom = 1 | yes |
| screen | top-left corner | down | one canvas pixel | no |
world2d mirrors the reference 2D camera: the origin is the centre of the target, +y points up and
zoom is canvas pixels per world unit. Both spaces are in target pixels, like the text screen
space, so a HiDPI caller scales screen content itself (gallery/screen-scale.ts does that for the
gallery); world2d needs no such step because its projection already reads the target size. 2d_shapes' X_EXTENT = 1000 is a pixel-scale world
coordinate in that sense, not a top-left one — the top-left, +y-down space is the reference UI space,
and here it is screen, the same one TextSpace "screen" already uses.
- Strokes:
line,ray,lineStrip/polyline,lineLoop,grid,point; every stroke has a_dtwin (line_d,ray_d,lineStrip_d,polyline_d,lineLoop_d,grid_d) that takes the same[visible, gap, phase]triple and phase accumulation as the 3D dashed paths. Screen-pixel or world widths, and round/bevel/miter joints. - Arrows:
arrow/arrow_s/arrow_d/arrow_s_d— the open V head by default, a filled head for_s, and the same two forms dashed for_d, withheadLength/headAngleoptions. - Curves:
curve(curve, options)/curve_d(curve, dash, options)sample a cubic control polygon (Curve2D, the 2D twin ofCurve3D) into a strip, withtolerance,transform.center/rotation/scaleandclosedthe same way the 3D page uses them. The 2D Curve page mirrors the 3D one: a construction band below the matrix draws every control point (pink anchors, amber handles) for the seven reference curve shapes. - Shapes:
rect,rect_r,circle,ellipse,arc,annulus,capsule,polygon,triangle,rhombus,sector,segment. Each is one instanced quad evaluated as a signed distance field in the fragment stage, so nothing is triangulated on the CPU. An outline is a canvas-pixel count by default;strokeWidthMode: "world"readsstrokeWidthinworld2dunits instead, so the outline thickens with the camera zoom exactly like a stroke'swidthMode: "world". Every primitive follows the 3D naming:<shape>strokes the outline,<shape>_sfills it,<shape>_ddashes the outline along its perimeter with the same[visible, gap, phase]triple as the line layer, and<shape>_hfills it with stripes.archas no interior, so it has no_svariant; its_his a striped stroke instead, masking the bandstrokeWidthdefines.Shape2DOptionsno longer carriesfill— the method name decides. Dashes are cut from the shape's perimeter parameter, a closed form for every primitive except the ellipse (Simpson-integrated arc length), so they stay continuous across corners. Hatches are evaluated in the shape's own space, so the stripes turn withrotationinstead of staying fixed to the canvas;spacing,widthandphaseare shape-space units that scale with the zoom, and the stripe edge stays one pixel wide. Withouthatch.backgroundthe interior is transparent apart from the stripes (a hatch mask); with it, the shape reads as a solid base carrying a second colour. - The layer is an overlay: it draws after every 3D primitive and pins itself to the near plane without writing depth.
- Layering:
zIndex— an integer in-8..8, default0— is shared by the 2D primitives and the text blocks, so the two are directly comparable. Segments are drawn in ascending key order, so a higher key covers a lower one; equal keys keep the pre-zIndexorder, 2D first, then text. It is never depth: nothing in this layer writes the depth buffer, the key only orders draw calls. Seedocs/z-index.md. InfiniteGrid2Drenders the reference's 2D grid as a full-viewport shader quad: decimal LOD levels, screen-space line width and the same three-layer alpha de-duplication as the 3D grid. It readscellSize,lineWidthPixels,minPixelsBetweenCells, the LOD range and both line colors from the gallery's grid panel, and draws as the 2D layer's background. The samedisplayModeanddot*options give it the endless dot lattice too, with the identical 4x ladder, screen radius and spacing fade as the 3D grid (see Dot lattice above).- The 2D camera is independent of the 3D one (
mode: "3d" | "2d"in the gallery config); the2d-*pages fit their declaredview2dextent, drag or hold WASD to pan, hold shift to sprint, and scroll to zoom. The gallery classifies the 2D layer like the 3D scenes:2d-indexis the API index, the twelve shape pages (2d-rect,2d-rounded-rect,2d-circle,2d-ellipse,2d-arc,2d-annulus,2d-capsule,2d-polygon,2d-triangle,2d-rhombus,2d-sector,2d-segment) each own one primitive, and2d-lines,2d-curves,2d-markers,2d-arrows,2d-spaces,2d-gridcover the stroke API families,z-indexshows thezIndexlayering key against shapes and text, and2d-world-space-widthputs the same width number in both units so scrolling proves which one follows the zoom. Every matrix page's column titles and sample labels areworld2dtext, so they pan and zoom with the geometry they annotate.
Text
drawList.text(position, text, options) draws one block per call. text is a string or a list
of spans, the equivalent of a text root entity plus its child span entities.
Three placement spaces share one pipeline and are selected per block:
| space | Position | Size |
| --- | --- | --- |
| world-plane (default) | world units, block on a fixed local XY plane | fontSize × pixelSize |
| world-billboard | world units, block rebuilt from the camera's axes | fontSize × pixelSize |
| screen | top-left origin pixels (position.z ignored) | fontSize pixels |
The screen space is in target pixels — the same units as the pass's viewport — because the
library has no DOM access and must not guess a display density. A caller on a HiDPI display owns the
conversion: the gallery authors its pages in CSS pixels and multiplies every screen-space command by
devicePixelRatio before handing the frame over (gallery/screen-scale.ts). A block placed at
[24, 24] with fontSize: 18 in CSS pixels then draws at [48, 48] and 36 target pixels on a 2×
display, which is what keeps a label the same physical size on every screen.
fontSize is in font pixels and is the layout unit; pixelSize is world units per font pixel.
That pair is the reference font_size / pixel_size, and it lets a block keep a fixed world
size while its glyphs are rasterised at any resolution. Which resolution that is belongs to the glyph
source: one that can rasterise on demand is asked for the size the block covers on screen (see
Raster size), while one that declares dynamicRaster: false stays at
fontSize × rasterScale. Layout options follow the reference layout model: justify, lineBreak,
bounds (TextBounds, in font pixels), anchor, lineHeight and letterSpacing.
rotation turns a world-plane block away from the XY plane, with the reference Label3D.rotation
Euler order (Y, then X, then Z) in radians. [-Math.PI / 2, 0, 0] lays a block flat on the XZ plane
so it reads from above, which is what the reference example labels do; the two camera-relative
spaces ignore it. The basis travels in the instance world matrix, so the text pipeline stays inside
the device's vertex attribute budget.
lineHeight is the absolute pixel form when you pass one, and the default is a 1.2 multiple of the
font size otherwise — which means a line's box is that multiple of the line's own
largest span, so a line holding a bigger span is taller. lineHeightScale names that multiple
explicitly; the two are exclusive. Only bounds.width feeds layout:
bounds.height is not a layout input, so a block runs past it instead of being
truncated. letterSpacing lands after every character, the last one on a line included, because the
shaping pass adds it to each cluster's advance.
World text is depth-tested but never depth-written, so geometry in front of it
occludes it; screen text pins itself to the near plane and always wins.
Font face
weight (1–1000, default 400), style (normal or italic) and width (the nine
font-stretch keywords) select the face a block is rasterised in, mirroring bevy's TextFont.
They are block defaults, and a span overrides one axis of them at a time rather than replacing the
whole face. The glyph atlas keys on all three, so two weights of one character cannot share texels.
How much of that a browser can honour depends on the loaded face, and the limits are real:
| option | what Canvas2D does |
| --- | --- |
| weight | A weight the loaded family does not carry is synthesised in one step (the threshold sits between 500 and 600), so 700 and 900 then look identical and the advance never changes. A variable font's real wght axis is not reachable — Canvas2D has no font-variation-settings — but the browser does pick a face by the weight it was registered with, so registering the same family once per weight gives real weights. |
| style | italic is the only slant the font shorthand keeps; oblique <angle> reads back as plain italic, which is why bevy's FontStyle::Oblique(a) is not exposed. A face without an italic is slanted by the browser. |
| width | The keyword only reaches the font when the face has a wdth axis. The bundled static subset has none, so every stop draws at normal width there. A percentage is rejected by the shorthand, so only the nine named stops exist here. |
The bundled Fira Mono subset is a static Medium face, so out of the box weight only ever produces
one synthesised step. loadFontFaces(family, faces) is the way out: it registers each entry as a face
of family with its own weight descriptor, so weight: 200 through 900 select genuinely different
faces. The gallery does exactly that for the text-font-query page, which draws with eight instances
of a variable font built by bun scripts/build-font-faces.ts (see
gallery/assets/). That is gallery-only payload: the library's own entry point does not
carry it.
A custom glyph source receives the resolved face — { family, weight, style, width } — instead of a
family string, because those are the fields the atlas keys on. A source that holds one fixed face (a
prebaked page, one parsed font buffer) sets faceKey to the part it can honour, so a weight it cannot
rasterise does not multiply identical atlas entries.
Outline
outlineWidth (in font pixels) rings every glyph in the block in outlineColor, which defaults to
the block's own colour. Font pixels rather than screen pixels keeps a ring proportional to the
glyphs: a block magnified by pixelSize carries its outline with it, and a screen block's ring is
in screen pixels, because nothing sits between those two units.
How the ring is built depends on the encoding. sdf and msdf put the true distance to the shape in
alpha — msdfgen's mtsdf layout — and the ring reads that, so it holds its width through a corner,
which is exactly where the RGB median disagrees with the true distance. bitmap has no distance to
read, so its ring is the furthest coverage eight directions report at the outline's radius: an
approximation, and one that samples outside the glyph, so a wide ring wants a wider page padding. A
width of 0 draws nothing and never reads alpha, which is what keeps a page baked before alpha
carried distances from flooding its quads.
An outline is the cheap way to keep a label readable over whatever it is drawn on — the alternative
is fighting the geometry behind it with depthTest: false.
Glyph encoding
The atlas stores glyphs one of three ways. The encoding belongs to the glyph source, not to a render option, so the atlas and the shader always agree:
| Encoding | Stored | Scaling | Cost |
| --- | --- | --- | --- |
| bitmap | tintable alpha mask (RGB 255, coverage in A) — the reference atlas layout | the shader interpolates coverage, so magnifying softens it | cheapest; one byte of signal |
| sdf | signed distance in red, true distance in alpha | the shader rebuilds the edge from the distance, so it stays sharp at any size | one CPU distance transform per glyph |
| msdf | per-edge signed distance in RGB, true distance in alpha | as sdf, but the median keeps the corners a single channel rounds off | three channels, and outlines rather than a mask |
CanvasGlyphRasterizer produces bitmap and sdf from Canvas2D coverage, running an exact
Euclidean distance transform and letting partially covered texels place the edge sub-texel. Two
sources produce msdf, and both ship:
- On demand —
OutlineGlyphRasterizerparses the inlined TrueType withopentype.jsand encodes the first time each glyph appears. Any character the face covers, and any face at all, at the cost of a runtime dependency and an encoder pass;rasterScaleworks here too, encoding at a multiple of the layout size while the quad stays put. - Prebaked —
BakedMsdfRasterizerreads the pagebun scripts/bake-msdf-atlas.tsproduced: 586 glyphs at a 24 px raster in a 512² page, gzipped to 313 KiB and carried as raw texels (not a PNG, so no image decoder has to run and the payload still loads under Node). Noopentype.js, and no encoding ever runs — but the character set is fixed and the payload ships whether or not it is used. Its glyphs are also stuck at the 24 px they were baked at: the further a block sits from that size, the softer the reconstruction, which is why the default source rasterises on demand at the size a block actually covers (see Raster size).
Raster size
A glyph is a bitmap, so it is only sharp when it is rasterised at the size it is drawn at. The reference gets there by keying its atlas set by font size and rasterising every size at the physical pixel size it will be shown at. That is affordable because its text sizes change rarely; a debug draw block's size follows its camera, and a CJK block has thousands of candidate glyphs, so one atlas per size is not something this library can spend.
The default source keeps the screen-facing half of that answer: glyphs are rasterised at the size
the block covers on screen, quantised onto a ladder of sizes (text-raster.ts — one step every
1.25×, 6 px to 256 px). A block asks for the smallest rung that covers it, so a glyph is never
magnified when it is decoded, at most oversampled by one step, which the mip chain then covers.
Because the rung, not the font size, is the atlas key, a block whose size changes every frame
rasterises each character about once per rung it passes rather than once per frame.
- A
screenblock's font pixels are canvas pixels, so it asks for exactly what it draws. - A world block multiplies
pixelSizeby the projection scale at its position (viewProjection[1][1] × viewportSize.y × 0.5 / clip.w, the same estimate the width shaders use), so a label that grows as the camera approaches keeps its glyphs sharp instead of only being sharp at the one size its source was rasterised for. - Coverage sampling puts the mip level on the floor: while the ladder oversamples by less than a step, blending level 0 against a half-resolution level is exactly what softens a stem that is already the right size. A block that really has shrunk into the chain still reads it.
A source's rasterScale becomes a lower bound rather than the whole story, and dynamicRaster: false
pins a source to that bound. The comparison scenes pin theirs, so one encoding still sits next to
another at a known raster size, and text-reference.html pins its bitmap source because it is
compared against the reference glyph by glyph.
The design, the reference correspondence it came from, and the measured sharpness numbers are in
docs/text-rasterization.md.
CJK
Chinese, Japanese and Korean text has no spaces to break at and thousands of distinct glyphs, which are the two assumptions the word breaking and per-size rasters above make. What the layout does about the first:
- Breaks between characters. Ideographs, kana, hangul and CJK punctuation become their own tokens,
so
lineBreak: "word"wraps between any two of them while latin text still wraps at spaces. - Kinsoku (禁则). Punctuation that may not open a line is glued to the run before it, and opening brackets to the character after it — expressed as token gluing because the breaker only moves whole tokens.
Glyphs come from whatever face the family resolves to. The bundled subset has no CJK coverage, so
the canvas falls back to a system CJK face, which is the same arrangement the reference text stack
has with its system fallback; pass a fontFamily (bundled, or loaded into the document) to choose
one on purpose.
The side of the reference's raster handling that shows up here is Raster size: each
ideograph is
rasterised at the rung it lands on, so a screenful of Chinese costs a few rungs' worth of glyphs
rather than one set per font size.
Anti-aliasing
A glyph edge is a texture alpha ramp inside a quad, so the scene target's msaa: 4 cannot help it —
it anti-aliases triangle edges, not texel contents. What is actually in play:
- Rasterisation coverage. Canvas2D anti-aliases every glyph it draws. That is the whole of the
bitmappath's AA, and the inputsdfis derived from. - Distance reconstruction.
sdfandmsdfrebuild the edge with anfwidth-scaled ramp, so the transition stays one pixel wide however far the camera pushes the glyph. The scale is the average of both axes' derivatives — the reference shader's0.5 * dot(px_range / msdf_size, dest_size)— because each derivative already spans one screen pixel: summing them counts it twice, and a ramp half a pixel wide drops the rim pixels a correct one leaves at partial coverage, which reads as a thinner, toothed glyph.docs/visual-comparison.mdrecords the comparison that pinned this down. - Error correction. The median of the three channels only equals the distance to the nearest
edge where two of them saturate, so inside a stroke it lands on the middle distance and the rebuilt
edge drifts.
msdfFromOutlinetherefore corrects each texel's median against the exact shape distance before packing, moving only the channel needed to keep that distance the middle value. At 24 px, measured against a super-sampled coverage reference: mean error 0.073 → 0.043, and edge-band texels whose median is off by more than half a texel 71% → 0%. Equalising flagged texels to their own median — the form msdfgen's error correction takes — was measured too and makes this encoder worse (0.078), because its channels share the texel's own inside/outside sign instead of carrying per-edge signed distances.bun scripts/measure-msdf-error.tsprints the numbers for the bundled face;tests/text-msdf.test.tslocks the property against analytic square and circle distances. - Supersampling.
rasterScalerasterises glyphs at a multiple of their layout size while quads stay put — the lever that matters forbitmapand for high-DPR displays. - Outline. A ring is a second coverage ramp around the first, so it is only as smooth as what it
reads: the true distance for
sdf/msdf, eight samples of the mask forbitmap. It is not part of the edge reconstruction and does not change it. - Mip chain. WebGPU has no
generateMipmapand vgpu exposes none, so levels are filtered on the CPU and uploaded level by level.mipLevelsdefaults to 4, taking a 512 px page down to 64 px; deeper chains start bleeding through the 2 px packing padding. A coverage mask averages; a distance field is resampled through coverage (downsampleDistancePage) instead, and the shader scales its decode range by the level it sampled, because a level's value describes one texel of that level. Those two have to agree — averaging distances and then decoding with a level-scaled range, or the reverse, each measured an order of magnitude worse.bun scripts/measure-glyph-accuracy.tsmeasures the result as total ink against the geometric coverage of the same glyphs: every source lands within about 3% of it, where sampling a level with the level-0 range lost about a tenth. - Atlas pages and uploads. Pages are layers of one 2D array texture, so a glyph that lands
outside the first page costs no extra draw call. The page count defaults to a fixed texel budget —
four 512 px pages' worth — so a 1024 px page set stays one page deep instead of quadrupling the
memory each glyph source holds. A page whose glyphs were all
requested in an earlier frame is blanked and repacked as a whole, and a page that mixes current and
stale glyphs hands the stale rectangles back one at a time, so a bounded atlas recycles instead of
failing. When every page is full and nothing on them can be recycled, the renderer drops that glyph
(
onFull: "skip", the default) and counts it inGpuText3D.skippedGlyphs, because a frame that is quietly missing a character has to be tellable apart from one the font never had; a probe or a test that would rather fail loudly asks foronFull: "throw". Only the rectangles written since the last upload reach the GPU — a new glyph costs a few hundred bytes rather than a whole page, and each mip level is re-sent for the region that maps to it.
Two limits are worth knowing about before blaming the renderer for an ugly block:
- Magnification. A distance field is only as fine as the raster it was built at, so a block pushed
far past the size its glyphs were rasterised at shows the texel grid they were reconstructed from.
The default source follows the screen and re-rasterises when the block climbs a rung, which spends
atlas area instead of sharpness. A source pinned to a fixed raster —
dynamicRaster: false, which is what the comparison scenes and the prebaked page use — keeps its own knob instead:rasterScalerasterises at a multiple of the layout size while the quad stays put, and twice the scale costs four times the atlas area, so raiseatlasSizewith it. The prebaked MSDF source has the equivalent knob at bake time rather than at run time. - Depth. Text is depth-tested, so anything that writes depth in front of it clips it. That
includes the infinite grid: it is a full-screen translucent plane that writes depth, matching the
reference's
depth_draw_always, and when the camera looks along the ground its lines shatter every letter behind them. It is the reference behaviour and not a bug, but debug labels that must stay readable passdepthTest: false.
Multiple sources
A GpuDebugDraw3D registers named glyph sources, and a block picks one through
TextOptions.source. Encoding and rasterisation scale belong to the source, so one frame can mix
quality levels — or put two of them side by side. The renderer may override a source's raster size per
block; dynamicRaster: false opts a source out of that, which is what the comparison scenes do (see
Raster size).
const renderer = new GpuDebugDraw3D(gpu, {
text: {
sources: {
sharp: new CanvasGlyphRasterizer({ encoding: "sdf", rasterScale: 4 }),
cheap: new CanvasGlyphRasterizer({ encoding: "bitmap" }),
baked: new BakedMsdfRasterizer(),
},
},
});
drawList.text([0, 0, 0], "read me", { source: "sharp" });
drawList.text([0, -1, 0], "and me", { source: "cheap" });Blocks that name nothing use the first source registered, and defaultTextStyle can set one for the
whole frame. Each source gets its own atlas and pipeline, and one is only built once something
actually names it, so registering sources you never draw costs nothing.
Seven scenes exercise text end to end, and none of them is a port — the reference addon has no text
API; the layout they run follows the reference layout model, and docs/text-reference.md records how
that was verified. The geometry examples also draw their own labels, because the reference scenes
own Label3D captions for every column and row; those go through the same pipeline, lying flat on
the ground with rotation: [-Math.PI / 2, 0, 0]. A GpuDebugDraw3D built without a text option
skips those blocks instead of failing, so geometry-only callers keep working.
text-compare is the parity matrix, in the shape of the line/circle examples: rows are glyph sources,
columns are font sizes, transposed relative to those because text grows sideways. The six
screen-space pages answer one question each, because a page that answers eight is comparable to
nothing:
| scene | the question it answers |
| --- | --- |
| text-encodings | the same text from four glyph sources, at two sizes, plus the outline pair |
| text-scale | the same text at six sizes, over three raster strategies at one size |
| text-world-zoom | four sources in world space, magnified with the camera — the only honest test of what a source does past its own raster |
| text-layout | wrapping, justification, bounds, line height, letter spacing and spans |
| text-anchor | the nine anchors against the marker they land on and their own bounds |
| text-cjk | CJK wrapping per character, kinsoku and mixed scripts |
Two conventions run through the screen-space pages: every page repeats one text per condition with the condition labelled above it, and each ends with a 1 px rule, because judging whether an edge is clean needs something of a known width next to it.
Every world-space row in the matrix floats above the grid plane. The grid writes depth, and a block
crossing y = 0 would be clipped along its lines.
Bundled font
The library inlines a Fira Mono subset (ASCII, Latin-1, Latin Extended-A, Greek, punctuation,
arrows, math and geometric symbols — 591 glyphs, 19 KB WOFF2 plus a 43 KB TrueType of the same
subset, SIL OFL 1.1). It is embedded the way the reference embeds its default font with
include_bytes!, so
nothing is fetched at runtime. WOFF2 feeds FontFace for the Canvas2D path; the TrueType is there
because opentype.js cannot decompress WOFF2. The subset has no CJK coverage: CJK labels fall back to a system face, and a fontFamily bundled
here or loaded into the document picks one on purpose. See CJK.
?scene=<id> opens any scene directly, including every text page (text-compare, text-encodings,
text-scale, text-world-zoom, text-layout, text-anchor, text-cjk). ?text=<name> narrows the
gallery to a single glyph source (auto, bitmap, bitmap4x, sdf, sdf4x, msdf, msdf2x,
baked).
Capabilities
- Points (screen/world size), lines (screen/world width, dash, normal), triangles.
- 2D layer (
frame.d2):world2dandscreenspaces, strokes with dashes and joints, and twelve SDF shape primitives with fill or pixel-width outlines, drawn as a depth-free overlay. InfiniteGrid2D: the addon's full-viewport 2D grid (decimal LOD, screen-space width, debug palette), driven by the same grid panel as the 3D one.- Text blocks: three placement spaces, wrapping (latin words and CJK characters), justification, span
styling, per-block anchor, line height, letter spacing and a per-block outline, drawn from an inlined
Fira Mono subset that falls back to the system for scripts it does not cover. The atlas can hold
bitmap,sdformsdfglyphs (see Text), rasterised at the size each block covers on screen, with supersampling and a mip chain. - Normal-aware strokes:
LineOptions.normalfor a single normal,LineOptions.normalsfor per-vertex normals (one per segment on an open path, one per vertex on a closed one). Lines and joints apply the reference back-face fade (back_fade_start = 100°,back_fade_transition = 20°,0.2floor) from theviewmatrixprepare()receives. - Procedural helpers:
ray,arrow,axes,grid,curve,lineStrip,lineLoop,mesh,triangle. - Geometry generators:
rect,box,disc,arc,cylinder,cone,sphere,capsule,gridLines, and cubicsampleCurve. - Reference wireframe generators:
sphereWire,capsuleWire,coneWire,cylinderWireandcirclePointsreturndraw_line_looprings plusdraw_line_listsegments, soshape.y/z/sideparameters select ring counts exactly likedraw_sphere/draw_capsule/draw_cone/draw_cylinderrather than mesh subdivisions. InfiniteGrid3Dshader-driven ground grid ported from the reference grid addon (decimal LOD layers, screen-space width, edge/grazing/center/far fades).- Orbit/pan/zoom camera controls in the gallery, tuned to the reference
entities/free_camera.gdand layered on a velocity model (gallery/demo-camera.ts): a drag is an angular velocity (0.25°/px) or a linear velocity (distance/FOV in perspective,orthographicHeightin orthographic), a wheel notch is a scalar velocity in log₂ space (1.08× per notch), and a released drag keeps its velocity and glides — the exact integral of an exponentially decaying velocity (halflife 0.14 s), capped at 60°, half a screen of pan and 0.75 doublings of zoom. Releasing more than 90 ms after the last sample is treated as a stop. The right button drops into a pointer-locked free-fly camera instead (WASD to move, Q/E for up/down, shift to sprint at 3×), while the middle button pans and the left button orbits. - Example switching frames the selected scene (
frameBounds): the orbit pivot moves to the scene's bounding-box centre and the distance/height fit its projected corners, so rotating turns around the geometry instead of the origin.?camera=referencedisables that framing so the reference pose stays put for comparisons.
See docs/reference-parity.md for the scene-by-scene parity status and the
known approximations, docs/visual-comparison.md for the reference-vs-vgpu
screenshot comparisons and measured alignment, and docs/text-reference.md for the run-time
comparison of text layout against the reference.
Global configuration
Library defaults are separate from gallery state. Explicit primitive options have priority over constructor defaults:
const debug = new DebugDraw3D({
defaultVisibleLayers: 1,
defaultPointStyle: { color: [1, 1, 1, 1], size: 8 },
defaultLineStyle: { color: [0.2, 0.8, 1, 1], width: 2, widthMode: "screen" },
});gallery/demo-config.ts contains independently validated camera/view configuration
and injected storage helpers. Gallery controls support grid, opacity, animation,
width multiplier, projection, background, camera preset and reset; camera
interaction is persisted. The sidebar groups them into three collapsible
sections — display, Geometry 3D and Infinite Grid — and remembers which ones are
open next to the config. The infinite-grid section follows displayMode: grid shows only the
line fields and dots only the lattice fields, while cellSize and the 3D plane
size/edge/grazing/center fades stay visible in every mode because they gate both layers.
The display group (background, projection, animation,
grid visibility, grid opacity) is stored per example: switching examples restores
what that scene was set up with, and a scene that hides the grid opens with the
toggle off. Line width, joints and the infinite grid options stay shared. The camera preset offers three poses of the same gallery
distance, FOV and clipping, so switching is purely a change of angle: the gallery's own
orbit, a 45° oblique view (off the +X/+Z diagonal, so nothing sits edge-on) and a 45° front
view (square on to the origin from the +Z axis, so the X/Y plane reads straight off the
screen). ?camera= also takes the
reference scene pose (examples/*.tscn FreeCamera, /?scene=line&camera=reference),
which stays URL-only because it exists for paired captures. Screenshot mode uses a
deterministic preset without localStorage: /?compare=1&scene=circle. Orthographic zoom
changes the orthographic extent.
The gallery UI is built with Base UI + shadcn (base-lyra preset) + Tailwind v4
in gallery/components/, and the imperative WebGPU side lives in gallery/demo-engine.ts.
React only renders chrome; the render loop, camera, and persistence stay outside
React and are read through useSyncExternalStore
(gallery/demo-hooks.ts). gallery/demo.tsx mounts the shell, gallery/index.css holds the base-lyra
theme tokens, and gallery/demo.css keeps the three-column layout, compare mode
and code highlighting.
Runtime comparison viewers (see docs/visual-comparison.md and docs/text-reference.md):
http://127.0.0.1:5174/artifacts/{visual-baseline,text-reference}/index.html. The whole
artifacts/ tree is git-ignored — captures and comparison pages are regenerated locally, so run
the capture scripts before opening a page.
Development
bun run typecheck
bun test
bun run build
bun run devbun run build emits the static gallery that .github/workflows/deploy-pages.yml publishes to
GitHub Pages (https://jwu.github.io/vgpu-render/) on every push to main. Only the production
build is re-based to /vgpu-render/ — override it with VGPU_BASE if the site path changes — while
the dev server and the capture scripts below keep addressing the app from /.
bun run dev serves the gallery; open the printed URL in a WebGPU-capable browser. On Linux, Chrome
only creates a hardware WebGPU adapter when its Vulkan feature is on — without it
navigator.gpu.requestAdapter() returns null. Either set chrome://flags/#enable-vulkan to
Enabled, or start the browser with --enable-features=Vulkan.
Visual baselines:
# Reference capture (independent copy, real Vulkan). The engine binary and the script live in a
# local `/tmp/vgpu-parity-*` checkout; see `docs/visual-comparison.md` for the exact invocation.
# vgpu capture over CDP (needs a GPU display + dev server)
# CHROME_PATH selects the browser binary (default: first known install path, then PATH).
# VGPU_OZONE_PLATFORM pins the display server, e.g. `x11`; unset lets Chrome choose.
bun scripts/capture-vgpu.ts circle lineBoth sides write into artifacts/, which is entirely git-ignored: captures are recaptured locally
on demand and never committed. The reference capture is machine-specific (engine 4.7.2 / Vulkan /
RTX 4070 SUPER, see the capture conditions in docs/visual-comparison.md), so check that table
before recapturing it elsewhere.
