vgpu-render 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +555 -2
  3. package/dist/assets/fira-mono-medium-subset.d.ts +4 -0
  4. package/dist/assets/fira-mono-medium-subset.js +889 -0
  5. package/dist/assets/fira-mono-msdf-atlas.d.ts +13 -0
  6. package/dist/assets/fira-mono-msdf-atlas.js +6222 -0
  7. package/dist/camera-2d.d.ts +43 -0
  8. package/dist/camera-2d.js +75 -0
  9. package/dist/color-space.d.ts +32 -0
  10. package/dist/color-space.js +45 -0
  11. package/dist/curve.d.ts +16 -0
  12. package/dist/curve.js +46 -0
  13. package/dist/debug-draw-3d.d.ts +14 -0
  14. package/dist/debug-draw-3d.js +36 -0
  15. package/dist/frame-recorder-2d.d.ts +177 -0
  16. package/dist/frame-recorder-2d.js +378 -0
  17. package/dist/frame-recorder.d.ts +361 -0
  18. package/dist/frame-recorder.js +515 -0
  19. package/dist/geometry-generators.d.ts +101 -0
  20. package/dist/geometry-generators.js +481 -0
  21. package/dist/gpu-debug-draw-2d.d.ts +48 -0
  22. package/dist/gpu-debug-draw-2d.js +450 -0
  23. package/dist/gpu-debug-draw-3d.d.ts +52 -0
  24. package/dist/gpu-debug-draw-3d.js +890 -0
  25. package/dist/gpu-instancing.d.ts +34 -0
  26. package/dist/gpu-instancing.js +93 -0
  27. package/dist/gpu-text-3d.d.ts +98 -0
  28. package/dist/gpu-text-3d.js +467 -0
  29. package/dist/index.d.ts +21 -0
  30. package/dist/index.js +21 -0
  31. package/dist/infinite-grid-2d.d.ts +73 -0
  32. package/dist/infinite-grid-2d.js +253 -0
  33. package/dist/infinite-grid-3d.d.ts +134 -0
  34. package/dist/infinite-grid-3d.js +407 -0
  35. package/dist/layout-3d.d.ts +227 -0
  36. package/dist/layout-3d.js +226 -0
  37. package/dist/mesh-batch-3d.d.ts +10 -0
  38. package/dist/mesh-batch-3d.js +67 -0
  39. package/dist/scene-examples.d.ts +39 -0
  40. package/dist/scene-examples.js +200 -0
  41. package/dist/scenes-2d.d.ts +18 -0
  42. package/dist/scenes-2d.js +160 -0
  43. package/dist/scenes-3d.d.ts +46 -0
  44. package/dist/scenes-3d.js +1994 -0
  45. package/dist/shape2d-shader.d.ts +14 -0
  46. package/dist/shape2d-shader.js +287 -0
  47. package/dist/text-atlas.d.ts +174 -0
  48. package/dist/text-atlas.js +495 -0
  49. package/dist/text-font.d.ts +149 -0
  50. package/dist/text-font.js +209 -0
  51. package/dist/text-layout.d.ts +116 -0
  52. package/dist/text-layout.js +340 -0
  53. package/dist/text-msdf-baked.d.ts +36 -0
  54. package/dist/text-msdf-baked.js +112 -0
  55. package/dist/text-msdf-font.d.ts +79 -0
  56. package/dist/text-msdf-font.js +153 -0
  57. package/dist/text-msdf.d.ts +70 -0
  58. package/dist/text-msdf.js +242 -0
  59. package/dist/text-raster.d.ts +40 -0
  60. package/dist/text-raster.js +60 -0
  61. package/dist/text-scenes.d.ts +85 -0
  62. package/dist/text-scenes.js +338 -0
  63. package/dist/text-sdf.d.ts +28 -0
  64. package/dist/text-sdf.js +119 -0
  65. package/package.json +80 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Johnny Wu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,556 @@
1
- # Temporary Holding Version
1
+ # vgpu-render
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **[Examples gallery →](https://jwu.github.io/vgpu-render/)**
4
+
5
+ A 3D debug-draw and gizmo layer built on top of `vgpu`, porting the reference
6
+ debug-draw addon's examples and its shader-driven infinite grid to TypeScript +
7
+ WGSL.
8
+
9
+ The current implementation is **partial parity**, not a complete migration. The
10
+ Circle/Line runtime screenshot comparison in
11
+ [`docs/visual-comparison.md`](docs/visual-comparison.md) records the remaining
12
+ layout, dash, rotation, normal-aware width, color, grid and label differences;
13
+ round/bevel/miter line joins are built in the vertex stage like the reference.
14
+ Passing GPU smoke tests does not establish visual equivalence.
15
+
16
+ Text rendering (see [Text](#text)) is vgpu's own capability rather than a port: the reference addon
17
+ has no text API at all, and the labels in its examples are plain billboard nodes.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ bun add vgpu-render vgpu
23
+ # or
24
+ npm install vgpu-render vgpu
25
+ ```
26
+
27
+ `vgpu` is a peer dependency: the host application owns the WebGPU device and the frame loop, so both
28
+ packages must resolve the same `vgpu` copy. `@vgpu/wgsl` and `opentype.js` ship as regular
29
+ dependencies of this package.
30
+
31
+ ## Usage
32
+
33
+ The public API uses an explicit frame recorder:
34
+
35
+ ```ts
36
+ const recorder = debug.beginFrame();
37
+ recorder.point([0, 0, 0], { color: [1, 0, 0, 1], size: 8 });
38
+ recorder.line([0, 0, 0], [1, 0, 0], { color: [0, 1, 0, 1], width: 2 });
39
+ recorder.arrow([0, 0, 0], [1, 1, 0], { color: [0, 0.5, 1, 1] });
40
+ recorder.lineStrip([[0, 0, 0], [1, 0, 1], [2, 0, 0]], { width: 4, joint: "round" }, false);
41
+ // Per-vertex normals select the reference `*_n` shading: each segment uses its start
42
+ // vertex's normal and the joints between them blend the two normals.
43
+ recorder.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);
44
+ recorder.grid([0, 0, 0], [4, 4], [0.5, 0.5], { color: [0.4, 0.4, 0.5, 1] });
45
+ recorder.curve(cubicBezier, { color: [1, 1, 1, 1], tolerance: 0.08 });
46
+ recorder.mesh(sphere(0.5), { solid: true, color: [0.2, 0.6, 1, 0.8] });
47
+ recorder.text([0, 1, 0], "world label", { fontSize: 32, pixelSize: 0.01, color: [1, 0.8, 0.2, 1] });
48
+ recorder.text([24, 24, 0], "screen HUD", { space: "screen", anchor: "top-left", fontSize: 16 });
49
+ const commands = debug.finishFrame(recorder);
50
+
51
+ // `view` is the world-to-view matrix; normal-aware strokes read it like the reference view matrix.
52
+ renderer.prepare(commands, { viewProjection, view });
53
+ grid.prepare({ viewProjection, cameraPosition, cameraForward, projection, farClip });
54
+ frame(gpu, (f) => f.pass({ target, clear: [0, 0, 0, 1], clearDepth: 1 }, (pass) => {
55
+ grid.draw(pass);
56
+ renderer.draw(pass);
57
+ }));
58
+ ```
59
+
60
+ `grid.prepare()` takes the camera state the reference addon reads from its
61
+ camera node: position, normalized forward, projection mode, and far clip. The LOD
62
+ distance is derived from that state (see below), so there is no separate
63
+ `fadeDistance` parameter.
64
+
65
+ ## Infinite grid
66
+
67
+ `InfiniteGrid3D` is a faithful port of the reference grid addon, including its parameter
68
+ names, decimal LOD scheme and fade stack:
69
+
70
+ | Option | Reference property | Default |
71
+ | --- | --- | --- |
72
+ | `size` | `grid_size` | `2000` |
73
+ | `edgeFadeStartRatio` / `edgeFadeEndRatio` | `edge_fade_*_ratio` | `0` / `1` |
74
+ | `cellSize` | `cell_size` | `1` |
75
+ | `lineWidthPixels` | `line_width_pixels` | `1` |
76
+ | `lodFinerLevels` / `lodTotalLevels` | `lod_*_levels` | `3` / `8` |
77
+ | `enableGrazingOpacity`, `grazingFadeStart/End` | `enable_grazing_opacity`, `grazing_fade_*` | `true`, `3°`, `20°` |
78
+ | `enableLodCenterFade`, `lodCenterFadeLineCount` | `enable_lod_center_fade`, `lod_center_fade_line_count` | `true`, `151` |
79
+ | `enableStippleDiscard` | `enable_stipple_discard` | `false` |
80
+ | `thinLineColor` / `thickLineColor` | `thin_line_color` / `thick_line_color` | `[0.42,0.42,0.42,0.45]` / `[0.62,0.62,0.62,0.65]` |
81
+ | `debugLodColors` | `debug_lod_colors` | `false` |
82
+
83
+ Behavior preserved from the reference:
84
+
85
+ - Three decimal LOD layers (finer / current / coarser) are combined with
86
+ de-duplicated alpha, and the current layer fades toward the next coarser cell
87
+ size. With `cellSize = 1`, `lodFinerLevels = 3` and `lodTotalLevels = 8` the
88
+ range is `0.001 … 10000`.
89
+ - The camera LOD distance uses the reference rule: the view-to-ground distance
90
+ blended toward camera height near grazing angles, clamped to the finest LOD cell
91
+ size; orthographic projection uses `size` instead, and the focus point comes
92
+ from the ray-plane intersection.
93
+ - Edge, grazing, far-clip and per-LOD center fades, plus optional low-alpha
94
+ stipple discard, run in the same order and with the same clamps.
95
+ - The plane follows the camera on XZ (`follow_viewport_camera`) and writes depth,
96
+ so it composites like the reference `depth_draw_always`, two-sided material.
97
+
98
+ The `direction-facing` difference: the reference renders through a linear-space
99
+ pipeline and encodes to sRGB on output, while the WebGPU canvas here
100
+ is `bgra8unorm` (Chromium rejects `bgra8unorm-srgb` as a canvas format). Line
101
+ positions, LOD banding and coverage therefore match, but blended line pixels are
102
+ darker than the reference. See `docs/visual-comparison.md`.
103
+
104
+ `setOpacity()` is a gallery extension: it scales the two reference line-colour
105
+ alphas, which the addon does not expose.
106
+ ```
107
+
108
+ ## Pipeline
109
+
110
+ ```text
111
+ FrameRecorder -> FrameCommand[] -> GpuDebugDraw3D
112
+ points -> instanced billboards
113
+ lines -> screen-space or world-space quads, dash + normal aware
114
+ triangles -> transient solid meshes
115
+ text -> per-glyph instanced quads sampling a generated glyph atlas
116
+ 2D -> GpuDebugDraw2D: 2D lines/points/joints + SDF shape quads, drawn as an overlay
117
+ ```
118
+
119
+ Points and lines are instanced; the CPU never expands a line into triangles. The
120
+ infinite grid is a single finite plane whose fragment shader derives minor/major
121
+ LOD grid lines.
122
+
123
+ ## 2D
124
+
125
+ `frame.d2` records 2D primitives into the same frame and layer mask as the 3D calls:
126
+
127
+ ```ts
128
+ const frame = debug.beginFrame();
129
+ frame.d2.line([0, 0], [120, 40], { width: 2, color: [1, 0, 0, 1] });
130
+ frame.d2.lineStrip([[0, 0], [40, 80], [90, 10]], { width: 4, joint: "round" });
131
+ frame.d2.grid([0, 0], [8, 5], [40, 40], { color: [0.4, 0.4, 0.5, 1] });
132
+ frame.d2.rect([0, 0], [80, 40], { fill: false, strokeWidth: 2 });
133
+ frame.d2.roundedRect([0, -60], [120, 40], 12, { color: [1, 0.7, 0.3, 1] });
134
+ frame.d2.circle([0, 120], 60, { color: [0, 0.5, 1, 1] });
135
+ frame.d2.arc([0, 120], 80, { fill: false, strokeWidth: 3, startAngle: 0, sweep: Math.PI / 2 });
136
+ const commands = debug.finishFrame(frame);
137
+ // The third argument is the 2D camera; omit it and the last one stays in effect.
138
+ renderer.prepare(commands, { viewProjection, view }, { pan: [0, 0], zoom: 1 });
139
+ ```
140
+
141
+ Two coordinate spaces share one pixel-based pipeline:
142
+
143
+ | `space` | Origin | +y | Unit | Affected by the 2D camera |
144
+ | --- | --- | --- | --- | --- |
145
+ | `world2d` (default) | canvas centre | up | one canvas pixel at `zoom = 1` | yes |
146
+ | `screen` | top-left corner | down | one canvas pixel | no |
147
+
148
+ `world2d` mirrors the reference 2D camera: the origin is the centre of the target, `+y` points up and
149
+ `zoom` is canvas pixels per world unit. `2d_shapes`' `X_EXTENT = 1000` is a pixel-scale world
150
+ coordinate in that sense, not a top-left one — the top-left, +y-down space is the reference UI space,
151
+ and here it is `screen`, the same one `TextSpace "screen"` already uses.
152
+
153
+ - Strokes: `line`, `ray`, `lineStrip`/`polyline`, `grid`, `point`; screen-pixel or world widths,
154
+ dashes with the same phase accumulation as the 3D paths, and round/bevel/miter joints.
155
+ - Shapes: `rect`, `roundedRect`, `circle`, `ellipse`, `arc`, `annulus`, `capsule`, `polygon`,
156
+ `triangle`, `rhombus`, `sector`, `segment`. Each is one instanced quad evaluated as a signed
157
+ distance field in the fragment stage, so nothing is triangulated on the CPU and an outline
158
+ width stays a pixel count at any zoom. `fill: false` plus `strokeWidth` turns any of them into
159
+ an outline.
160
+ - The layer is an overlay: it draws after every 3D primitive, pins itself to the near plane and
161
+ does not write depth. Screen-space text remains the last thing drawn.
162
+ - `InfiniteGrid2D` renders the reference's 2D grid as a full-viewport shader quad: decimal LOD levels,
163
+ screen-space line width and the same three-layer alpha de-duplication as the 3D grid. It reads
164
+ `cellSize`, `lineWidthPixels`, `minPixelsBetweenCells`, the LOD range and both line colors from
165
+ the gallery's grid panel, and draws as the 2D layer's background.
166
+ - The 2D camera is independent of the 3D one (`mode: "3d" | "2d"` in the gallery config); the
167
+ `2d-*` pages fit their declared `view2d` extent, drag to pan and scroll to zoom.
168
+
169
+ ## Text
170
+
171
+ `recorder.text(position, text, options)` draws one block per call. `text` is a string or a list
172
+ of spans, the equivalent of a text root entity plus its child span entities.
173
+
174
+ Three placement spaces share one pipeline and are selected per block:
175
+
176
+ | `space` | Position | Size |
177
+ | --- | --- | --- |
178
+ | `world-plane` (default) | world units, block on a fixed local XY plane | `fontSize × pixelSize` |
179
+ | `world-billboard` | world units, block rebuilt from the camera's axes | `fontSize × pixelSize` |
180
+ | `screen` | **top-left origin pixels** (`position.z` ignored) | `fontSize` pixels |
181
+
182
+ `fontSize` is in **font pixels** and is the layout unit; `pixelSize` is world units per font pixel.
183
+ That pair is the reference `font_size` / `pixel_size`, and it lets a block keep a fixed world
184
+ size while its glyphs are rasterised at any resolution. Which resolution that is belongs to the glyph
185
+ source: one that can rasterise on demand is asked for the size the block covers on screen (see
186
+ [Raster size](#raster-size)), while one that declares `dynamicRaster: false` stays at
187
+ `fontSize × rasterScale`. Layout options follow the reference layout model: `justify`, `lineBreak`,
188
+ `bounds` (`TextBounds`, in font pixels), `anchor`, `lineHeight` and `letterSpacing`.
189
+
190
+ `lineHeight` is the absolute pixel form when you pass one, and the default is a `1.2` multiple of the
191
+ font size otherwise — which means a line's box is that multiple of **the line's own
192
+ largest span**, so a line holding a bigger span is taller. `lineHeightScale` names that multiple
193
+ explicitly; the two are exclusive. Only `bounds.width` feeds layout:
194
+ `bounds.height` is not a layout input, so a block runs past it instead of being
195
+ truncated. `letterSpacing` lands after every character, the last one on a line included, because the
196
+ shaping pass adds it to each cluster's advance.
197
+
198
+ World text is depth-tested but never depth-written, so geometry in front of it
199
+ occludes it; `screen` text pins itself to the near plane and always wins.
200
+
201
+ ### Outline
202
+
203
+ `outlineWidth` (in font pixels) rings every glyph in the block in `outlineColor`, which defaults to
204
+ the block's own colour. Font pixels rather than screen pixels keeps a ring proportional to the
205
+ glyphs: a block magnified by `pixelSize` carries its outline with it, and a `screen` block's ring is
206
+ in screen pixels, because nothing sits between those two units.
207
+
208
+ How the ring is built depends on the encoding. `sdf` and `msdf` put the true distance to the shape in
209
+ alpha — msdfgen's `mtsdf` layout — and the ring reads that, so it holds its width through a corner,
210
+ which is exactly where the RGB median disagrees with the true distance. `bitmap` has no distance to
211
+ read, so its ring is the furthest coverage eight directions report at the outline's radius: an
212
+ approximation, and one that samples outside the glyph, so a wide ring wants a wider page padding. A
213
+ width of `0` draws nothing and never reads alpha, which is what keeps a page baked before alpha
214
+ carried distances from flooding its quads.
215
+
216
+ An outline is the cheap way to keep a label readable over whatever it is drawn on — the alternative
217
+ is fighting the geometry behind it with `depthTest: false`.
218
+
219
+ ### Glyph encoding
220
+
221
+ The atlas stores glyphs one of three ways. The encoding belongs to the **glyph source**, not to a
222
+ render option, so the atlas and the shader always agree:
223
+
224
+ | Encoding | Stored | Scaling | Cost |
225
+ | --- | --- | --- | --- |
226
+ | `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 |
227
+ | `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 |
228
+ | `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 |
229
+
230
+ `CanvasGlyphRasterizer` produces `bitmap` and `sdf` from Canvas2D coverage, running an exact
231
+ Euclidean distance transform and letting partially covered texels place the edge sub-texel. Two
232
+ sources produce `msdf`, and both ship:
233
+
234
+ - **On demand** — `OutlineGlyphRasterizer` parses the inlined TrueType with `opentype.js` and encodes
235
+ the first time each glyph appears. Any character the face covers, and any face at all, at the cost
236
+ of a runtime dependency and an encoder pass; `rasterScale` works here too, encoding at a multiple of
237
+ the layout size while the quad stays put.
238
+ - **Prebaked** — `BakedMsdfRasterizer` reads the page `bun scripts/bake-msdf-atlas.ts` produced: 586
239
+ glyphs at a 24 px raster in a 512² page, gzipped to 313 KiB and carried as raw texels (not a PNG, so
240
+ no image decoder has to run and the payload still loads under Node). No `opentype.js`, and no
241
+ encoding ever runs — but the character set is fixed and the payload ships whether or not it is used. Its
242
+ glyphs are also stuck at the 24 px they were baked at: the further a block sits from that size, the
243
+ softer the reconstruction, which is why the default source rasterises on demand at the size a block
244
+ actually covers (see [Raster size](#raster-size)).
245
+
246
+ ### Raster size
247
+
248
+ A glyph is a bitmap, so it is only sharp when it is rasterised at the size it is drawn at. The
249
+ reference gets there by keying its atlas set by font size and rasterising every size at the physical
250
+ pixel size it will be shown at. That is affordable because its text sizes change rarely; a debug draw
251
+ block's size follows its camera, and a CJK block has thousands of candidate glyphs, so one atlas per
252
+ size is not something this library can spend.
253
+
254
+ The default source keeps the screen-facing half of that answer: glyphs are rasterised at **the size
255
+ the block covers on screen**, quantised onto a ladder of sizes (`text-raster.ts` — one step every
256
+ 1.25×, 6 px to 256 px). A block asks for the smallest rung that covers it, so a glyph is never
257
+ magnified when it is decoded, at most oversampled by one step, which the mip chain then covers.
258
+ Because the **rung**, not the font size, is the atlas key, a block whose size changes every frame
259
+ rasterises each character about once per rung it passes rather than once per frame.
260
+
261
+ - A `screen` block's font pixels are canvas pixels, so it asks for exactly what it draws.
262
+ - A world block multiplies `pixelSize` by the projection scale at its position
263
+ (`viewProjection[1][1] × viewportSize.y × 0.5 / clip.w`, the same estimate the width shaders use),
264
+ so a label that grows as the camera approaches keeps its glyphs sharp instead of only being sharp at
265
+ the one size its source was rasterised for.
266
+ - Coverage sampling puts the mip level on the floor: while the ladder oversamples by less than a step,
267
+ blending level 0 against a half-resolution level is exactly what softens a stem that is already the
268
+ right size. A block that really has shrunk into the chain still reads it.
269
+
270
+ A source's `rasterScale` becomes a lower bound rather than the whole story, and `dynamicRaster: false`
271
+ pins a source to that bound. The comparison scenes pin theirs, so one encoding still sits next to
272
+ another at a known raster size, and `text-reference.html` pins its `bitmap` source because it is
273
+ compared against the reference glyph by glyph.
274
+
275
+ The design, the reference correspondence it came from, and the measured sharpness numbers are in
276
+ [`docs/text-rasterization.md`](docs/text-rasterization.md).
277
+
278
+ ### CJK
279
+
280
+ Chinese, Japanese and Korean text has no spaces to break at and thousands of distinct glyphs, which
281
+ are the two assumptions the word breaking and per-size rasters above make. What the layout does about
282
+ the first:
283
+
284
+ - **Breaks between characters.** Ideographs, kana, hangul and CJK punctuation become their own tokens,
285
+ so `lineBreak: "word"` wraps between any two of them while latin text still wraps at spaces.
286
+ - **Kinsoku (禁则).** Punctuation that may not open a line is glued to the run before it, and opening
287
+ brackets to the character after it — expressed as token gluing because the breaker only moves whole
288
+ tokens.
289
+
290
+ Glyphs come from whatever face the family resolves to. The bundled subset has **no CJK coverage**, so
291
+ the canvas falls back to a system CJK face, which is the same arrangement the reference text stack
292
+ has with its system fallback; pass a `fontFamily` (bundled, or loaded into the document) to choose
293
+ one on purpose.
294
+ The side of the reference's raster handling that shows up here is [Raster size](#raster-size): each
295
+ ideograph is
296
+ rasterised at the rung it lands on, so a screenful of Chinese costs a few rungs' worth of glyphs
297
+ rather than one set per font size.
298
+
299
+ ### Anti-aliasing
300
+
301
+ A glyph edge is a texture alpha ramp inside a quad, so the scene target's `msaa: 4` cannot help it —
302
+ it anti-aliases triangle edges, not texel contents. What is actually in play:
303
+
304
+ - **Rasterisation coverage.** Canvas2D anti-aliases every glyph it draws. That is the whole of the
305
+ `bitmap` path's AA, and the input `sdf` is derived from.
306
+ - **Distance reconstruction.** `sdf` and `msdf` rebuild the edge with an `fwidth`-scaled ramp, so the
307
+ transition stays one pixel wide however far the camera pushes the glyph. The scale is the *average*
308
+ of both axes' derivatives — the reference shader's `0.5 * dot(px_range / msdf_size, dest_size)` —
309
+ because each derivative already spans one screen pixel: summing them counts it twice, and a ramp
310
+ half a pixel wide drops the rim pixels a correct one leaves at partial coverage, which reads as a
311
+ thinner, toothed glyph. `docs/visual-comparison.md` records the comparison that pinned this down.
312
+ - **Error correction.** The median of the three channels only equals the distance to the *nearest*
313
+ edge where two of them saturate, so inside a stroke it lands on the middle distance and the rebuilt
314
+ edge drifts. `msdfFromOutline` therefore corrects each texel's median against the exact shape
315
+ distance before packing, moving only the channel needed to keep that distance the middle value. At
316
+ 24 px, measured against a super-sampled coverage reference: mean error 0.073 → 0.043, and edge-band
317
+ texels whose median is off by more than half a texel 71% → 0%. Equalising flagged texels to their own
318
+ median — the form msdfgen's error correction takes — was measured too and makes this encoder *worse*
319
+ (0.078), because its channels share the texel's own inside/outside sign instead of carrying
320
+ per-edge signed distances. `bun scripts/measure-msdf-error.ts` prints the numbers for the bundled
321
+ face; `tests/text-msdf.test.ts` locks the property against analytic square and circle distances.
322
+ - **Supersampling.** `rasterScale` rasterises glyphs at a multiple of their layout size while quads
323
+ stay put — the lever that matters for `bitmap` and for high-DPR displays.
324
+ - **Outline.** A ring is a second coverage ramp around the first, so it is only as smooth as what it
325
+ reads: the true distance for `sdf`/`msdf`, eight samples of the mask for `bitmap`. It is not part of
326
+ the edge reconstruction and does not change it.
327
+ - **Mip chain.** WebGPU has no `generateMipmap` and vgpu exposes none, so levels are filtered on the
328
+ CPU and uploaded level by level. `mipLevels` defaults to 4, taking a 512 px page down to 64 px;
329
+ deeper chains start bleeding through the 2 px packing padding. A coverage mask averages; a distance
330
+ field is resampled **through coverage** (`downsampleDistancePage`) instead, and the shader scales its
331
+ decode range by the level it sampled, because a level's value describes one texel of that level.
332
+ Those two have to agree — averaging distances and then decoding with a level-scaled range, or the
333
+ reverse, each measured an order of magnitude worse. `bun scripts/measure-glyph-accuracy.ts` measures
334
+ the result as total ink against the geometric coverage of the same glyphs: every source lands within
335
+ about 3% of it, where sampling a level with the level-0 range lost about a tenth.
336
+ - **Atlas pages and uploads.** Pages are layers of one 2D array texture, so a glyph that lands
337
+ outside the first page costs no extra draw call. The page count defaults to a fixed texel budget —
338
+ four 512 px pages' worth — so a 1024 px page set stays one page deep instead of quadrupling the
339
+ memory each glyph source holds. A page whose glyphs were all
340
+ requested in an earlier frame is blanked and repacked as a whole, and a page that mixes current and
341
+ stale glyphs hands the stale rectangles back one at a time, so a bounded atlas recycles instead of
342
+ failing. When every page is full and nothing on them can be recycled, the renderer drops that glyph
343
+ (`onFull: "skip"`, the default) and counts it in `GpuText3D.skippedGlyphs`, because a frame that is
344
+ quietly missing a character has to be tellable apart from one the font never had; a probe or a test
345
+ that would rather fail loudly asks for `onFull: "throw"`. Only the rectangles written since the last
346
+ upload reach the GPU — a new glyph costs a few hundred bytes rather than a whole page, and each mip
347
+ level is re-sent for the region that maps to it.
348
+
349
+ Two limits are worth knowing about before blaming the renderer for an ugly block:
350
+
351
+ - **Magnification.** A distance field is only as fine as the raster it was built at, so a block pushed
352
+ far past the size its glyphs were rasterised at shows the texel grid they were reconstructed from.
353
+ The default source follows the screen and re-rasterises when the block climbs a rung, which spends
354
+ atlas area instead of sharpness. A source pinned to a fixed raster — `dynamicRaster: false`, which is
355
+ what the comparison scenes and the prebaked page use — keeps its own knob instead: `rasterScale`
356
+ rasterises at a multiple of the layout size while the quad stays put, and twice the scale costs four
357
+ times the atlas area, so raise `atlasSize` with it. The prebaked MSDF source has the equivalent knob
358
+ at bake time rather than at run time.
359
+ - **Depth.** Text is depth-tested, so anything that writes depth in front of it clips it. That
360
+ includes the infinite grid: it is a full-screen translucent plane that writes depth, matching the
361
+ reference's `depth_draw_always`, and when the camera looks along the ground its lines shatter
362
+ every letter behind them. It is the reference behaviour and not a bug, but debug labels that must
363
+ stay readable pass `depthTest: false`.
364
+
365
+ ### Multiple sources
366
+
367
+ A `GpuDebugDraw3D` registers **named glyph sources**, and a block picks one through
368
+ `TextOptions.source`. Encoding and rasterisation scale belong to the source, so one frame can mix
369
+ quality levels — or put two of them side by side. The renderer may override a source's raster size per
370
+ block; `dynamicRaster: false` opts a source out of that, which is what the comparison scenes do (see
371
+ [Raster size](#raster-size)).
372
+
373
+ ```ts
374
+ const renderer = new GpuDebugDraw3D(gpu, {
375
+ text: {
376
+ sources: {
377
+ sharp: new CanvasGlyphRasterizer({ encoding: "sdf", rasterScale: 4 }),
378
+ cheap: new CanvasGlyphRasterizer({ encoding: "bitmap" }),
379
+ baked: new BakedMsdfRasterizer(),
380
+ },
381
+ },
382
+ });
383
+
384
+ recorder.text([0, 0, 0], "read me", { source: "sharp" });
385
+ recorder.text([0, -1, 0], "and me", { source: "cheap" });
386
+ ```
387
+
388
+ Blocks that name nothing use the first source registered, and `defaultTextStyle` can set one for the
389
+ whole frame. Each source gets its own atlas and pipeline, and one is only built once something
390
+ actually names it, so registering sources you never draw costs nothing.
391
+
392
+ Seven scenes exercise text end to end, and none of them is a port — the reference addon has no text
393
+ API; the layout they run follows the reference layout model, and `docs/text-reference.md` records how
394
+ that was verified.
395
+
396
+ `text-compare` is the parity matrix, in the shape of the line/circle examples: rows are glyph sources,
397
+ columns are font sizes, transposed relative to those because text grows sideways. The six
398
+ screen-space pages answer one question each, because a page that answers eight is comparable to
399
+ nothing:
400
+
401
+ | scene | the question it answers |
402
+ | --- | --- |
403
+ | `text-encodings` | the same text from four glyph sources, at two sizes, plus the outline pair |
404
+ | `text-scale` | the same text at six sizes, over three raster strategies at one size |
405
+ | `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 |
406
+ | `text-layout` | wrapping, justification, bounds, line height, letter spacing and spans |
407
+ | `text-anchor` | the nine anchors against the marker they land on and their own bounds |
408
+ | `text-cjk` | CJK wrapping per character, kinsoku and mixed scripts |
409
+
410
+ Two conventions run through the screen-space pages: every page repeats one text per condition with the
411
+ condition labelled above it, and each ends with a 1 px rule, because judging whether an edge is clean
412
+ needs something of a known width next to it.
413
+
414
+ Every world-space row in the matrix floats above the grid plane. The grid writes depth, and a block
415
+ crossing `y = 0` would be clipped along its lines.
416
+
417
+ ### Bundled font
418
+
419
+ The library inlines a **Fira Mono subset** (ASCII, Latin-1, Latin Extended-A, Greek, punctuation,
420
+ arrows, math and geometric symbols — 591 glyphs, 19 KB WOFF2 plus a 43 KB TrueType of the same
421
+ subset, SIL OFL 1.1). It is embedded the way the reference embeds its default font with
422
+ `include_bytes!`, so
423
+ nothing is fetched at runtime. WOFF2 feeds `FontFace` for the Canvas2D path; the TrueType is there
424
+ because `opentype.js` cannot decompress WOFF2. The subset has **no CJK coverage**: CJK labels fall back to a system face, and a `fontFamily` bundled
425
+ here or loaded into the document picks one on purpose. See [CJK](#cjk).
426
+
427
+ `?scene=<id>` opens any scene directly, including every text page (`text-compare`, `text-encodings`,
428
+ `text-scale`, `text-world-zoom`, `text-layout`, `text-anchor`, `text-cjk`). `?text=<name>` narrows the
429
+ gallery to a single glyph source (`auto`, `bitmap`, `bitmap4x`, `sdf`, `sdf4x`, `msdf`, `msdf2x`,
430
+ `baked`).
431
+
432
+ ## Capabilities
433
+
434
+ - Points (screen/world size), lines (screen/world width, dash, normal), triangles.
435
+ - 2D layer (`frame.d2`): `world2d` and `screen` spaces, strokes with dashes and joints, and
436
+ twelve SDF shape primitives with fill or pixel-width outlines, drawn as a depth-free overlay.
437
+ - `InfiniteGrid2D`: the addon's full-viewport 2D grid (decimal LOD, screen-space width, debug
438
+ palette), driven by the same grid panel as the 3D one.
439
+ - Text blocks: three placement spaces, wrapping (latin words and CJK characters), justification, span
440
+ styling, per-block anchor, line height, letter spacing and a per-block outline, drawn from an inlined
441
+ Fira Mono subset that falls back to the system for scripts it does not cover. The atlas can hold
442
+ `bitmap`, `sdf` or `msdf` glyphs (see [Text](#text)), rasterised at the size each block covers on
443
+ screen, with supersampling and a mip chain.
444
+ - Normal-aware strokes: `LineOptions.normal` for a single normal, `LineOptions.normals` for
445
+ per-vertex normals (one per segment on an open path, one per vertex on a closed one). Lines
446
+ and joints apply the reference back-face fade (`back_fade_start = 100°`,
447
+ `back_fade_transition = 20°`, `0.2` floor) from the `view` matrix `prepare()` receives.
448
+ - Procedural helpers: `ray`, `arrow`, `axes`, `grid`, `curve`, `lineStrip`,
449
+ `lineLoop`, `mesh`, `triangle`.
450
+ - Geometry generators: `rect`, `box`, `disc`, `arc`, `cylinder`, `cone`,
451
+ `sphere`, `capsule`, `gridLines`, and cubic `sampleCurve`.
452
+ - Reference wireframe generators: `sphereWire`, `capsuleWire`, `coneWire`,
453
+ `cylinderWire` and `circlePoints` return `draw_line_loop` rings plus
454
+ `draw_line_list` segments, so `shape.y`/`z`/`side` parameters select ring counts
455
+ exactly like `draw_sphere`/`draw_capsule`/`draw_cone`/`draw_cylinder` rather than
456
+ mesh subdivisions.
457
+ - `InfiniteGrid3D` shader-driven ground grid ported from the reference grid addon
458
+ (decimal LOD layers, screen-space width, edge/grazing/center/far fades).
459
+ - Orbit/pan/zoom camera controls in the gallery, tuned to the reference
460
+ `entities/free_camera.gd` and layered on a velocity model (`src/demo-camera.ts`): a drag is
461
+ an angular velocity (0.25°/px) or a linear velocity (`distance`/FOV in perspective,
462
+ `orthographicHeight` in orthographic), a wheel notch is a scalar velocity in log₂ space
463
+ (1.08× per notch), and a released drag keeps its velocity and glides — the exact integral of
464
+ an exponentially decaying velocity (halflife 0.14 s), capped at 60°, half a screen of pan and
465
+ 0.75 doublings of zoom. Releasing more than 90 ms after the last sample is treated as a stop.
466
+ The right button drops into a pointer-locked free-fly camera instead (WASD to move, Q/E for
467
+ up/down, shift to sprint at 3×), while the middle button pans and the left button orbits.
468
+ - Example switching frames the selected scene (`frameBounds`): the orbit pivot moves to the
469
+ scene's bounding-box centre and the distance/height fit its projected corners, so rotating
470
+ turns around the geometry instead of the origin. `?camera=reference` disables that framing so
471
+ the reference pose stays put for comparisons.
472
+
473
+ See `docs/reference-parity.md` for the scene-by-scene parity status and the
474
+ known approximations, `docs/visual-comparison.md` for the reference-vs-vgpu
475
+ screenshot comparisons and measured alignment, and `docs/text-reference.md` for the run-time
476
+ comparison of text layout against the reference.
477
+
478
+ ## Global configuration
479
+
480
+ Library defaults are separate from gallery state. Explicit primitive options
481
+ have priority over constructor defaults:
482
+
483
+ ```ts
484
+ const debug = new DebugDraw3D({
485
+ defaultVisibleLayers: 1,
486
+ defaultPointStyle: { color: [1, 1, 1, 1], size: 8 },
487
+ defaultLineStyle: { color: [0.2, 0.8, 1, 1], width: 2, widthMode: "screen" },
488
+ });
489
+ ```
490
+
491
+ `src/demo-config.ts` contains independently validated camera/view configuration
492
+ and injected storage helpers. Gallery controls support grid, opacity, animation,
493
+ width multiplier, projection, background, camera preset and reset; camera
494
+ interaction is persisted. The sidebar groups them into three collapsible
495
+ sections — display, Geometry 3D and Infinite Grid — and remembers which ones are
496
+ open next to the config. The display group (background, projection, animation,
497
+ grid visibility, grid opacity) is stored per example: switching examples restores
498
+ what that scene was set up with, and a scene that hides the grid opens with the
499
+ toggle off. Line width, joints and the infinite grid options stay shared. The camera preset offers three poses of the same gallery
500
+ distance, FOV and clipping, so switching is purely a change of angle: the gallery's own
501
+ orbit, a 45° oblique view (off the +X/+Z diagonal, so nothing sits edge-on) and a 45° front
502
+ view (square on to the origin from the +Z axis, so the X/Y plane reads straight off the
503
+ screen). `?camera=` also takes the
504
+ reference scene pose (`examples/*.tscn` `FreeCamera`, `/?scene=line&camera=reference`),
505
+ which stays URL-only because it exists for paired captures. Screenshot mode uses a
506
+ deterministic preset without localStorage: `/?compare=1&scene=circle`. Orthographic zoom
507
+ changes the orthographic extent.
508
+
509
+ The gallery UI is built with **Base UI + shadcn (base-lyra preset) + Tailwind v4**
510
+ in `src/components/`, and the imperative WebGPU side lives in `src/demo-engine.ts`.
511
+ React only renders chrome; the render loop, camera, and persistence stay outside
512
+ React and are read through `useSyncExternalStore`
513
+ (`src/demo-hooks.ts`). `src/demo.tsx` mounts the shell, `src/index.css` holds the base-lyra
514
+ theme tokens, and `src/demo.css` keeps the three-column layout, compare mode
515
+ and code highlighting.
516
+
517
+ Runtime comparison viewers (see `docs/visual-comparison.md` and `docs/text-reference.md`):
518
+ `http://127.0.0.1:5174/artifacts/{visual-baseline,text-reference}/index.html`. The whole
519
+ `artifacts/` tree is git-ignored — captures and comparison pages are regenerated locally, so run
520
+ the capture scripts before opening a page.
521
+
522
+ ## Development
523
+
524
+ ```bash
525
+ bun run typecheck
526
+ bun test
527
+ bun run build
528
+ bun run dev
529
+ ```
530
+
531
+ `bun run build` emits the static gallery that `.github/workflows/deploy-pages.yml` publishes to
532
+ GitHub Pages (<https://jwu.github.io/vgpu-render/>) on every push to `main`. Only the production
533
+ build is re-based to `/vgpu-render/` — override it with `VGPU_BASE` if the site path changes — while
534
+ the dev server and the capture scripts below keep addressing the app from `/`.
535
+
536
+ `bun run dev` serves the gallery; open the printed URL in a WebGPU-capable browser. On Linux, Chrome
537
+ only creates a hardware WebGPU adapter when its Vulkan feature is on — without it
538
+ `navigator.gpu.requestAdapter()` returns `null`. Either set `chrome://flags/#enable-vulkan` to
539
+ Enabled, or start the browser with `--enable-features=Vulkan`.
540
+
541
+ Visual baselines:
542
+
543
+ ```bash
544
+ # Reference capture (independent copy, real Vulkan). The engine binary and the script live in a
545
+ # local `/tmp/vgpu-parity-*` checkout; see `docs/visual-comparison.md` for the exact invocation.
546
+
547
+ # vgpu capture over CDP (needs a GPU display + dev server)
548
+ # CHROME_PATH selects the browser binary (default: first known install path, then PATH).
549
+ # VGPU_OZONE_PLATFORM pins the display server, e.g. `x11`; unset lets Chrome choose.
550
+ bun scripts/capture-vgpu.ts circle line
551
+ ```
552
+
553
+ Both sides write into `artifacts/`, which is entirely git-ignored: captures are recaptured locally
554
+ on demand and never committed. The reference capture is machine-specific (engine 4.7.2 / Vulkan /
555
+ RTX 4070 SUPER, see the capture conditions in `docs/visual-comparison.md`), so check that table
556
+ before recapturing it elsewhere.
@@ -0,0 +1,4 @@
1
+ /** WOFF2 bytes of the bundled default font, base64 encoded. Feed it to `FontFace`. */
2
+ export declare const FIRA_MONO_MEDIUM_SUBSET_BASE64: string;
3
+ /** TrueType bytes of the same subset, base64 encoded. `opentype.js` reads outlines from this one. */
4
+ export declare const FIRA_MONO_MEDIUM_SUBSET_TTF_BASE64: string;