vgpu-render 0.0.0-stage → 0.2.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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +605 -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-draw-list-2d.d.ts +177 -0
  16. package/dist/frame-draw-list-2d.js +378 -0
  17. package/dist/frame-draw-list.d.ts +413 -0
  18. package/dist/frame-draw-list.js +556 -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 +892 -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 +514 -0
  29. package/dist/index.d.ts +21 -0
  30. package/dist/index.js +20 -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/mesh-batch-3d.d.ts +10 -0
  36. package/dist/mesh-batch-3d.js +67 -0
  37. package/dist/shape2d-shader.d.ts +14 -0
  38. package/dist/shape2d-shader.js +288 -0
  39. package/dist/text-atlas.d.ts +176 -0
  40. package/dist/text-atlas.js +498 -0
  41. package/dist/text-font.d.ts +190 -0
  42. package/dist/text-font.js +261 -0
  43. package/dist/text-layout.d.ts +175 -0
  44. package/dist/text-layout.js +390 -0
  45. package/dist/text-msdf-baked.d.ts +41 -0
  46. package/dist/text-msdf-baked.js +117 -0
  47. package/dist/text-msdf-font.d.ts +85 -0
  48. package/dist/text-msdf-font.js +159 -0
  49. package/dist/text-msdf.d.ts +70 -0
  50. package/dist/text-msdf.js +242 -0
  51. package/dist/text-raster.d.ts +40 -0
  52. package/dist/text-raster.js +60 -0
  53. package/dist/text-sdf.d.ts +28 -0
  54. package/dist/text-sdf.js +119 -0
  55. package/dist/vector-math.d.ts +19 -0
  56. package/dist/vector-math.js +37 -0
  57. 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,606 @@
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 draw list:
34
+
35
+ ```ts
36
+ const drawList = debug.beginFrame();
37
+ drawList.point([0, 0, 0], { color: [1, 0, 0, 1], size: 8 });
38
+ drawList.line([0, 0, 0], [1, 0, 0], { color: [0, 1, 0, 1], width: 2 });
39
+ drawList.arrow([0, 0, 0], [1, 1, 0], { color: [0, 0.5, 1, 1] });
40
+ drawList.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
+ 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);
44
+ drawList.grid([0, 0, 0], [4, 4], [0.5, 0.5], { color: [0.4, 0.4, 0.5, 1] });
45
+ drawList.curve(cubicBezier, { color: [1, 1, 1, 1], tolerance: 0.08 });
46
+ drawList.mesh(sphere(0.5), { solid: true, color: [0.2, 0.6, 1, 0.8] });
47
+ drawList.text([0, 1, 0], "world label", { fontSize: 32, pixelSize: 0.01, color: [1, 0.8, 0.2, 1] });
48
+ drawList.text([24, 24, 0], "screen HUD", { space: "screen", anchor: "top-left", fontSize: 16 });
49
+ const commands = debug.finishFrame(drawList);
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
+ FrameDrawList -> 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. Both spaces are in **target pixels**, like the text `screen`
150
+ space, so a HiDPI caller scales `screen` content itself (`gallery/screen-scale.ts` does that for the
151
+ gallery); `world2d` needs no such step because its projection already reads the target size. `2d_shapes`' `X_EXTENT = 1000` is a pixel-scale world
152
+ coordinate in that sense, not a top-left one — the top-left, +y-down space is the reference UI space,
153
+ and here it is `screen`, the same one `TextSpace "screen"` already uses.
154
+
155
+ - Strokes: `line`, `ray`, `lineStrip`/`polyline`, `grid`, `point`; screen-pixel or world widths,
156
+ dashes with the same phase accumulation as the 3D paths, and round/bevel/miter joints.
157
+ - Shapes: `rect`, `roundedRect`, `circle`, `ellipse`, `arc`, `annulus`, `capsule`, `polygon`,
158
+ `triangle`, `rhombus`, `sector`, `segment`. Each is one instanced quad evaluated as a signed
159
+ distance field in the fragment stage, so nothing is triangulated on the CPU and an outline
160
+ width stays a pixel count at any zoom. `fill: false` plus `strokeWidth` turns any of them into
161
+ an outline; an `arc`'s outline is clipped to its `startAngle`/`sweep` wedge.
162
+ - The layer is an overlay: it draws after every 3D primitive, pins itself to the near plane and
163
+ does not write depth. Screen-space text remains the last thing drawn.
164
+ - `InfiniteGrid2D` renders the reference's 2D grid as a full-viewport shader quad: decimal LOD levels,
165
+ screen-space line width and the same three-layer alpha de-duplication as the 3D grid. It reads
166
+ `cellSize`, `lineWidthPixels`, `minPixelsBetweenCells`, the LOD range and both line colors from
167
+ the gallery's grid panel, and draws as the 2D layer's background.
168
+ - The 2D camera is independent of the 3D one (`mode: "3d" | "2d"` in the gallery config); the
169
+ `2d-*` pages fit their declared `view2d` extent, drag or hold WASD to pan, and scroll to zoom. The gallery
170
+ classifies the 2D layer like the 3D scenes: `2d-gizmos` and `2d-shapes` are overviews, while
171
+ `2d-lines`, `2d-curves`, `2d-markers`, `2d-outlines`, `2d-fill`, `2d-spaces` and `2d-grid` are
172
+ column-by-row matrices whose column titles and sample labels are `world2d` text, so they pan
173
+ and zoom with the geometry they annotate.
174
+
175
+ ## Text
176
+
177
+ `drawList.text(position, text, options)` draws one block per call. `text` is a string or a list
178
+ of spans, the equivalent of a text root entity plus its child span entities.
179
+
180
+ Three placement spaces share one pipeline and are selected per block:
181
+
182
+ | `space` | Position | Size |
183
+ | --- | --- | --- |
184
+ | `world-plane` (default) | world units, block on a fixed local XY plane | `fontSize × pixelSize` |
185
+ | `world-billboard` | world units, block rebuilt from the camera's axes | `fontSize × pixelSize` |
186
+ | `screen` | **top-left origin pixels** (`position.z` ignored) | `fontSize` pixels |
187
+
188
+ The `screen` space is in **target pixels** — the same units as the pass's viewport — because the
189
+ library has no DOM access and must not guess a display density. A caller on a HiDPI display owns the
190
+ conversion: the gallery authors its pages in CSS pixels and multiplies every screen-space command by
191
+ `devicePixelRatio` before handing the frame over (`gallery/screen-scale.ts`). A block placed at
192
+ `[24, 24]` with `fontSize: 18` in CSS pixels then draws at `[48, 48]` and 36 target pixels on a 2×
193
+ display, which is what keeps a label the same physical size on every screen.
194
+
195
+ `fontSize` is in **font pixels** and is the layout unit; `pixelSize` is world units per font pixel.
196
+ That pair is the reference `font_size` / `pixel_size`, and it lets a block keep a fixed world
197
+ size while its glyphs are rasterised at any resolution. Which resolution that is belongs to the glyph
198
+ source: one that can rasterise on demand is asked for the size the block covers on screen (see
199
+ [Raster size](#raster-size)), while one that declares `dynamicRaster: false` stays at
200
+ `fontSize × rasterScale`. Layout options follow the reference layout model: `justify`, `lineBreak`,
201
+ `bounds` (`TextBounds`, in font pixels), `anchor`, `lineHeight` and `letterSpacing`.
202
+
203
+ `rotation` turns a `world-plane` block away from the XY plane, with the reference `Label3D.rotation`
204
+ Euler order (Y, then X, then Z) in radians. `[-Math.PI / 2, 0, 0]` lays a block flat on the XZ plane
205
+ so it reads from above, which is what the reference example labels do; the two camera-relative
206
+ spaces ignore it. The basis travels in the instance world matrix, so the text pipeline stays inside
207
+ the device's vertex attribute budget.
208
+
209
+ `lineHeight` is the absolute pixel form when you pass one, and the default is a `1.2` multiple of the
210
+ font size otherwise — which means a line's box is that multiple of **the line's own
211
+ largest span**, so a line holding a bigger span is taller. `lineHeightScale` names that multiple
212
+ explicitly; the two are exclusive. Only `bounds.width` feeds layout:
213
+ `bounds.height` is not a layout input, so a block runs past it instead of being
214
+ truncated. `letterSpacing` lands after every character, the last one on a line included, because the
215
+ shaping pass adds it to each cluster's advance.
216
+
217
+ World text is depth-tested but never depth-written, so geometry in front of it
218
+ occludes it; `screen` text pins itself to the near plane and always wins.
219
+
220
+ ### Font face
221
+
222
+ `weight` (1–1000, default `400`), `style` (`normal` or `italic`) and `width` (the nine
223
+ `font-stretch` keywords) select the face a block is rasterised in, mirroring bevy's `TextFont`.
224
+ They are block defaults, and a span overrides one axis of them at a time rather than replacing the
225
+ whole face. The glyph atlas keys on all three, so two weights of one character cannot share texels.
226
+
227
+ How much of that a browser can honour depends on the loaded face, and the limits are real:
228
+
229
+ | option | what Canvas2D does |
230
+ | --- | --- |
231
+ | `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. |
232
+ | `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. |
233
+ | `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. |
234
+
235
+ The bundled Fira Mono subset is a static Medium face, so out of the box `weight` only ever produces
236
+ one synthesised step. `loadFontFaces(family, faces)` is the way out: it registers each entry as a face
237
+ of `family` with its own `weight` descriptor, so `weight: 200` through `900` select genuinely different
238
+ faces. The gallery does exactly that for the `text-font-query` page, which draws with eight instances
239
+ of a variable font built by `bun scripts/build-font-faces.ts` (see
240
+ `gallery/assets/`). That is gallery-only payload: the library's own entry point does not
241
+ carry it.
242
+
243
+ A custom glyph source receives the resolved face — `{ family, weight, style, width }` — instead of a
244
+ family string, because those are the fields the atlas keys on. A source that holds one fixed face (a
245
+ prebaked page, one parsed font buffer) sets `faceKey` to the part it can honour, so a weight it cannot
246
+ rasterise does not multiply identical atlas entries.
247
+
248
+ ### Outline
249
+
250
+ `outlineWidth` (in font pixels) rings every glyph in the block in `outlineColor`, which defaults to
251
+ the block's own colour. Font pixels rather than screen pixels keeps a ring proportional to the
252
+ glyphs: a block magnified by `pixelSize` carries its outline with it, and a `screen` block's ring is
253
+ in screen pixels, because nothing sits between those two units.
254
+
255
+ How the ring is built depends on the encoding. `sdf` and `msdf` put the true distance to the shape in
256
+ alpha — msdfgen's `mtsdf` layout — and the ring reads that, so it holds its width through a corner,
257
+ which is exactly where the RGB median disagrees with the true distance. `bitmap` has no distance to
258
+ read, so its ring is the furthest coverage eight directions report at the outline's radius: an
259
+ approximation, and one that samples outside the glyph, so a wide ring wants a wider page padding. A
260
+ width of `0` draws nothing and never reads alpha, which is what keeps a page baked before alpha
261
+ carried distances from flooding its quads.
262
+
263
+ An outline is the cheap way to keep a label readable over whatever it is drawn on — the alternative
264
+ is fighting the geometry behind it with `depthTest: false`.
265
+
266
+ ### Glyph encoding
267
+
268
+ The atlas stores glyphs one of three ways. The encoding belongs to the **glyph source**, not to a
269
+ render option, so the atlas and the shader always agree:
270
+
271
+ | Encoding | Stored | Scaling | Cost |
272
+ | --- | --- | --- | --- |
273
+ | `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 |
274
+ | `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 |
275
+ | `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 |
276
+
277
+ `CanvasGlyphRasterizer` produces `bitmap` and `sdf` from Canvas2D coverage, running an exact
278
+ Euclidean distance transform and letting partially covered texels place the edge sub-texel. Two
279
+ sources produce `msdf`, and both ship:
280
+
281
+ - **On demand** — `OutlineGlyphRasterizer` parses the inlined TrueType with `opentype.js` and encodes
282
+ the first time each glyph appears. Any character the face covers, and any face at all, at the cost
283
+ of a runtime dependency and an encoder pass; `rasterScale` works here too, encoding at a multiple of
284
+ the layout size while the quad stays put.
285
+ - **Prebaked** — `BakedMsdfRasterizer` reads the page `bun scripts/bake-msdf-atlas.ts` produced: 586
286
+ glyphs at a 24 px raster in a 512² page, gzipped to 313 KiB and carried as raw texels (not a PNG, so
287
+ no image decoder has to run and the payload still loads under Node). No `opentype.js`, and no
288
+ encoding ever runs — but the character set is fixed and the payload ships whether or not it is used. Its
289
+ glyphs are also stuck at the 24 px they were baked at: the further a block sits from that size, the
290
+ softer the reconstruction, which is why the default source rasterises on demand at the size a block
291
+ actually covers (see [Raster size](#raster-size)).
292
+
293
+ ### Raster size
294
+
295
+ A glyph is a bitmap, so it is only sharp when it is rasterised at the size it is drawn at. The
296
+ reference gets there by keying its atlas set by font size and rasterising every size at the physical
297
+ pixel size it will be shown at. That is affordable because its text sizes change rarely; a debug draw
298
+ block's size follows its camera, and a CJK block has thousands of candidate glyphs, so one atlas per
299
+ size is not something this library can spend.
300
+
301
+ The default source keeps the screen-facing half of that answer: glyphs are rasterised at **the size
302
+ the block covers on screen**, quantised onto a ladder of sizes (`text-raster.ts` — one step every
303
+ 1.25×, 6 px to 256 px). A block asks for the smallest rung that covers it, so a glyph is never
304
+ magnified when it is decoded, at most oversampled by one step, which the mip chain then covers.
305
+ Because the **rung**, not the font size, is the atlas key, a block whose size changes every frame
306
+ rasterises each character about once per rung it passes rather than once per frame.
307
+
308
+ - A `screen` block's font pixels are canvas pixels, so it asks for exactly what it draws.
309
+ - A world block multiplies `pixelSize` by the projection scale at its position
310
+ (`viewProjection[1][1] × viewportSize.y × 0.5 / clip.w`, the same estimate the width shaders use),
311
+ so a label that grows as the camera approaches keeps its glyphs sharp instead of only being sharp at
312
+ the one size its source was rasterised for.
313
+ - Coverage sampling puts the mip level on the floor: while the ladder oversamples by less than a step,
314
+ blending level 0 against a half-resolution level is exactly what softens a stem that is already the
315
+ right size. A block that really has shrunk into the chain still reads it.
316
+
317
+ A source's `rasterScale` becomes a lower bound rather than the whole story, and `dynamicRaster: false`
318
+ pins a source to that bound. The comparison scenes pin theirs, so one encoding still sits next to
319
+ another at a known raster size, and `text-reference.html` pins its `bitmap` source because it is
320
+ compared against the reference glyph by glyph.
321
+
322
+ The design, the reference correspondence it came from, and the measured sharpness numbers are in
323
+ [`docs/text-rasterization.md`](docs/text-rasterization.md).
324
+
325
+ ### CJK
326
+
327
+ Chinese, Japanese and Korean text has no spaces to break at and thousands of distinct glyphs, which
328
+ are the two assumptions the word breaking and per-size rasters above make. What the layout does about
329
+ the first:
330
+
331
+ - **Breaks between characters.** Ideographs, kana, hangul and CJK punctuation become their own tokens,
332
+ so `lineBreak: "word"` wraps between any two of them while latin text still wraps at spaces.
333
+ - **Kinsoku (禁则).** Punctuation that may not open a line is glued to the run before it, and opening
334
+ brackets to the character after it — expressed as token gluing because the breaker only moves whole
335
+ tokens.
336
+
337
+ Glyphs come from whatever face the family resolves to. The bundled subset has **no CJK coverage**, so
338
+ the canvas falls back to a system CJK face, which is the same arrangement the reference text stack
339
+ has with its system fallback; pass a `fontFamily` (bundled, or loaded into the document) to choose
340
+ one on purpose.
341
+ The side of the reference's raster handling that shows up here is [Raster size](#raster-size): each
342
+ ideograph is
343
+ rasterised at the rung it lands on, so a screenful of Chinese costs a few rungs' worth of glyphs
344
+ rather than one set per font size.
345
+
346
+ ### Anti-aliasing
347
+
348
+ A glyph edge is a texture alpha ramp inside a quad, so the scene target's `msaa: 4` cannot help it —
349
+ it anti-aliases triangle edges, not texel contents. What is actually in play:
350
+
351
+ - **Rasterisation coverage.** Canvas2D anti-aliases every glyph it draws. That is the whole of the
352
+ `bitmap` path's AA, and the input `sdf` is derived from.
353
+ - **Distance reconstruction.** `sdf` and `msdf` rebuild the edge with an `fwidth`-scaled ramp, so the
354
+ transition stays one pixel wide however far the camera pushes the glyph. The scale is the *average*
355
+ of both axes' derivatives — the reference shader's `0.5 * dot(px_range / msdf_size, dest_size)` —
356
+ because each derivative already spans one screen pixel: summing them counts it twice, and a ramp
357
+ half a pixel wide drops the rim pixels a correct one leaves at partial coverage, which reads as a
358
+ thinner, toothed glyph. `docs/visual-comparison.md` records the comparison that pinned this down.
359
+ - **Error correction.** The median of the three channels only equals the distance to the *nearest*
360
+ edge where two of them saturate, so inside a stroke it lands on the middle distance and the rebuilt
361
+ edge drifts. `msdfFromOutline` therefore corrects each texel's median against the exact shape
362
+ distance before packing, moving only the channel needed to keep that distance the middle value. At
363
+ 24 px, measured against a super-sampled coverage reference: mean error 0.073 → 0.043, and edge-band
364
+ texels whose median is off by more than half a texel 71% → 0%. Equalising flagged texels to their own
365
+ median — the form msdfgen's error correction takes — was measured too and makes this encoder *worse*
366
+ (0.078), because its channels share the texel's own inside/outside sign instead of carrying
367
+ per-edge signed distances. `bun scripts/measure-msdf-error.ts` prints the numbers for the bundled
368
+ face; `tests/text-msdf.test.ts` locks the property against analytic square and circle distances.
369
+ - **Supersampling.** `rasterScale` rasterises glyphs at a multiple of their layout size while quads
370
+ stay put — the lever that matters for `bitmap` and for high-DPR displays.
371
+ - **Outline.** A ring is a second coverage ramp around the first, so it is only as smooth as what it
372
+ reads: the true distance for `sdf`/`msdf`, eight samples of the mask for `bitmap`. It is not part of
373
+ the edge reconstruction and does not change it.
374
+ - **Mip chain.** WebGPU has no `generateMipmap` and vgpu exposes none, so levels are filtered on the
375
+ CPU and uploaded level by level. `mipLevels` defaults to 4, taking a 512 px page down to 64 px;
376
+ deeper chains start bleeding through the 2 px packing padding. A coverage mask averages; a distance
377
+ field is resampled **through coverage** (`downsampleDistancePage`) instead, and the shader scales its
378
+ decode range by the level it sampled, because a level's value describes one texel of that level.
379
+ Those two have to agree — averaging distances and then decoding with a level-scaled range, or the
380
+ reverse, each measured an order of magnitude worse. `bun scripts/measure-glyph-accuracy.ts` measures
381
+ the result as total ink against the geometric coverage of the same glyphs: every source lands within
382
+ about 3% of it, where sampling a level with the level-0 range lost about a tenth.
383
+ - **Atlas pages and uploads.** Pages are layers of one 2D array texture, so a glyph that lands
384
+ outside the first page costs no extra draw call. The page count defaults to a fixed texel budget —
385
+ four 512 px pages' worth — so a 1024 px page set stays one page deep instead of quadrupling the
386
+ memory each glyph source holds. A page whose glyphs were all
387
+ requested in an earlier frame is blanked and repacked as a whole, and a page that mixes current and
388
+ stale glyphs hands the stale rectangles back one at a time, so a bounded atlas recycles instead of
389
+ failing. When every page is full and nothing on them can be recycled, the renderer drops that glyph
390
+ (`onFull: "skip"`, the default) and counts it in `GpuText3D.skippedGlyphs`, because a frame that is
391
+ quietly missing a character has to be tellable apart from one the font never had; a probe or a test
392
+ that would rather fail loudly asks for `onFull: "throw"`. Only the rectangles written since the last
393
+ upload reach the GPU — a new glyph costs a few hundred bytes rather than a whole page, and each mip
394
+ level is re-sent for the region that maps to it.
395
+
396
+ Two limits are worth knowing about before blaming the renderer for an ugly block:
397
+
398
+ - **Magnification.** A distance field is only as fine as the raster it was built at, so a block pushed
399
+ far past the size its glyphs were rasterised at shows the texel grid they were reconstructed from.
400
+ The default source follows the screen and re-rasterises when the block climbs a rung, which spends
401
+ atlas area instead of sharpness. A source pinned to a fixed raster — `dynamicRaster: false`, which is
402
+ what the comparison scenes and the prebaked page use — keeps its own knob instead: `rasterScale`
403
+ rasterises at a multiple of the layout size while the quad stays put, and twice the scale costs four
404
+ times the atlas area, so raise `atlasSize` with it. The prebaked MSDF source has the equivalent knob
405
+ at bake time rather than at run time.
406
+ - **Depth.** Text is depth-tested, so anything that writes depth in front of it clips it. That
407
+ includes the infinite grid: it is a full-screen translucent plane that writes depth, matching the
408
+ reference's `depth_draw_always`, and when the camera looks along the ground its lines shatter
409
+ every letter behind them. It is the reference behaviour and not a bug, but debug labels that must
410
+ stay readable pass `depthTest: false`.
411
+
412
+ ### Multiple sources
413
+
414
+ A `GpuDebugDraw3D` registers **named glyph sources**, and a block picks one through
415
+ `TextOptions.source`. Encoding and rasterisation scale belong to the source, so one frame can mix
416
+ quality levels — or put two of them side by side. The renderer may override a source's raster size per
417
+ block; `dynamicRaster: false` opts a source out of that, which is what the comparison scenes do (see
418
+ [Raster size](#raster-size)).
419
+
420
+ ```ts
421
+ const renderer = new GpuDebugDraw3D(gpu, {
422
+ text: {
423
+ sources: {
424
+ sharp: new CanvasGlyphRasterizer({ encoding: "sdf", rasterScale: 4 }),
425
+ cheap: new CanvasGlyphRasterizer({ encoding: "bitmap" }),
426
+ baked: new BakedMsdfRasterizer(),
427
+ },
428
+ },
429
+ });
430
+
431
+ drawList.text([0, 0, 0], "read me", { source: "sharp" });
432
+ drawList.text([0, -1, 0], "and me", { source: "cheap" });
433
+ ```
434
+
435
+ Blocks that name nothing use the first source registered, and `defaultTextStyle` can set one for the
436
+ whole frame. Each source gets its own atlas and pipeline, and one is only built once something
437
+ actually names it, so registering sources you never draw costs nothing.
438
+
439
+ Seven scenes exercise text end to end, and none of them is a port — the reference addon has no text
440
+ API; the layout they run follows the reference layout model, and `docs/text-reference.md` records how
441
+ that was verified. The geometry examples also draw their own labels, because the reference scenes
442
+ own `Label3D` captions for every column and row; those go through the same pipeline, lying flat on
443
+ the ground with `rotation: [-Math.PI / 2, 0, 0]`. A `GpuDebugDraw3D` built without a `text` option
444
+ skips those blocks instead of failing, so geometry-only callers keep working.
445
+
446
+ `text-compare` is the parity matrix, in the shape of the line/circle examples: rows are glyph sources,
447
+ columns are font sizes, transposed relative to those because text grows sideways. The six
448
+ screen-space pages answer one question each, because a page that answers eight is comparable to
449
+ nothing:
450
+
451
+ | scene | the question it answers |
452
+ | --- | --- |
453
+ | `text-encodings` | the same text from four glyph sources, at two sizes, plus the outline pair |
454
+ | `text-scale` | the same text at six sizes, over three raster strategies at one size |
455
+ | `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 |
456
+ | `text-layout` | wrapping, justification, bounds, line height, letter spacing and spans |
457
+ | `text-anchor` | the nine anchors against the marker they land on and their own bounds |
458
+ | `text-cjk` | CJK wrapping per character, kinsoku and mixed scripts |
459
+
460
+ Two conventions run through the screen-space pages: every page repeats one text per condition with the
461
+ condition labelled above it, and each ends with a 1 px rule, because judging whether an edge is clean
462
+ needs something of a known width next to it.
463
+
464
+ Every world-space row in the matrix floats above the grid plane. The grid writes depth, and a block
465
+ crossing `y = 0` would be clipped along its lines.
466
+
467
+ ### Bundled font
468
+
469
+ The library inlines a **Fira Mono subset** (ASCII, Latin-1, Latin Extended-A, Greek, punctuation,
470
+ arrows, math and geometric symbols — 591 glyphs, 19 KB WOFF2 plus a 43 KB TrueType of the same
471
+ subset, SIL OFL 1.1). It is embedded the way the reference embeds its default font with
472
+ `include_bytes!`, so
473
+ nothing is fetched at runtime. WOFF2 feeds `FontFace` for the Canvas2D path; the TrueType is there
474
+ because `opentype.js` cannot decompress WOFF2. The subset has **no CJK coverage**: CJK labels fall back to a system face, and a `fontFamily` bundled
475
+ here or loaded into the document picks one on purpose. See [CJK](#cjk).
476
+
477
+ `?scene=<id>` opens any scene directly, including every text page (`text-compare`, `text-encodings`,
478
+ `text-scale`, `text-world-zoom`, `text-layout`, `text-anchor`, `text-cjk`). `?text=<name>` narrows the
479
+ gallery to a single glyph source (`auto`, `bitmap`, `bitmap4x`, `sdf`, `sdf4x`, `msdf`, `msdf2x`,
480
+ `baked`).
481
+
482
+ ## Capabilities
483
+
484
+ - Points (screen/world size), lines (screen/world width, dash, normal), triangles.
485
+ - 2D layer (`frame.d2`): `world2d` and `screen` spaces, strokes with dashes and joints, and
486
+ twelve SDF shape primitives with fill or pixel-width outlines, drawn as a depth-free overlay.
487
+ - `InfiniteGrid2D`: the addon's full-viewport 2D grid (decimal LOD, screen-space width, debug
488
+ palette), driven by the same grid panel as the 3D one.
489
+ - Text blocks: three placement spaces, wrapping (latin words and CJK characters), justification, span
490
+ styling, per-block anchor, line height, letter spacing and a per-block outline, drawn from an inlined
491
+ Fira Mono subset that falls back to the system for scripts it does not cover. The atlas can hold
492
+ `bitmap`, `sdf` or `msdf` glyphs (see [Text](#text)), rasterised at the size each block covers on
493
+ screen, with supersampling and a mip chain.
494
+ - Normal-aware strokes: `LineOptions.normal` for a single normal, `LineOptions.normals` for
495
+ per-vertex normals (one per segment on an open path, one per vertex on a closed one). Lines
496
+ and joints apply the reference back-face fade (`back_fade_start = 100°`,
497
+ `back_fade_transition = 20°`, `0.2` floor) from the `view` matrix `prepare()` receives.
498
+ - Procedural helpers: `ray`, `arrow`, `axes`, `grid`, `curve`, `lineStrip`,
499
+ `lineLoop`, `mesh`, `triangle`.
500
+ - Geometry generators: `rect`, `box`, `disc`, `arc`, `cylinder`, `cone`,
501
+ `sphere`, `capsule`, `gridLines`, and cubic `sampleCurve`.
502
+ - Reference wireframe generators: `sphereWire`, `capsuleWire`, `coneWire`,
503
+ `cylinderWire` and `circlePoints` return `draw_line_loop` rings plus
504
+ `draw_line_list` segments, so `shape.y`/`z`/`side` parameters select ring counts
505
+ exactly like `draw_sphere`/`draw_capsule`/`draw_cone`/`draw_cylinder` rather than
506
+ mesh subdivisions.
507
+ - `InfiniteGrid3D` shader-driven ground grid ported from the reference grid addon
508
+ (decimal LOD layers, screen-space width, edge/grazing/center/far fades).
509
+ - Orbit/pan/zoom camera controls in the gallery, tuned to the reference
510
+ `entities/free_camera.gd` and layered on a velocity model (`gallery/demo-camera.ts`): a drag is
511
+ an angular velocity (0.25°/px) or a linear velocity (`distance`/FOV in perspective,
512
+ `orthographicHeight` in orthographic), a wheel notch is a scalar velocity in log₂ space
513
+ (1.08× per notch), and a released drag keeps its velocity and glides — the exact integral of
514
+ an exponentially decaying velocity (halflife 0.14 s), capped at 60°, half a screen of pan and
515
+ 0.75 doublings of zoom. Releasing more than 90 ms after the last sample is treated as a stop.
516
+ The right button drops into a pointer-locked free-fly camera instead (WASD to move, Q/E for
517
+ up/down, shift to sprint at 3×), while the middle button pans and the left button orbits.
518
+ - Example switching frames the selected scene (`frameBounds`): the orbit pivot moves to the
519
+ scene's bounding-box centre and the distance/height fit its projected corners, so rotating
520
+ turns around the geometry instead of the origin. `?camera=reference` disables that framing so
521
+ the reference pose stays put for comparisons.
522
+
523
+ See `docs/reference-parity.md` for the scene-by-scene parity status and the
524
+ known approximations, `docs/visual-comparison.md` for the reference-vs-vgpu
525
+ screenshot comparisons and measured alignment, and `docs/text-reference.md` for the run-time
526
+ comparison of text layout against the reference.
527
+
528
+ ## Global configuration
529
+
530
+ Library defaults are separate from gallery state. Explicit primitive options
531
+ have priority over constructor defaults:
532
+
533
+ ```ts
534
+ const debug = new DebugDraw3D({
535
+ defaultVisibleLayers: 1,
536
+ defaultPointStyle: { color: [1, 1, 1, 1], size: 8 },
537
+ defaultLineStyle: { color: [0.2, 0.8, 1, 1], width: 2, widthMode: "screen" },
538
+ });
539
+ ```
540
+
541
+ `gallery/demo-config.ts` contains independently validated camera/view configuration
542
+ and injected storage helpers. Gallery controls support grid, opacity, animation,
543
+ width multiplier, projection, background, camera preset and reset; camera
544
+ interaction is persisted. The sidebar groups them into three collapsible
545
+ sections — display, Geometry 3D and Infinite Grid — and remembers which ones are
546
+ open next to the config. The display group (background, projection, animation,
547
+ grid visibility, grid opacity) is stored per example: switching examples restores
548
+ what that scene was set up with, and a scene that hides the grid opens with the
549
+ toggle off. Line width, joints and the infinite grid options stay shared. The camera preset offers three poses of the same gallery
550
+ distance, FOV and clipping, so switching is purely a change of angle: the gallery's own
551
+ orbit, a 45° oblique view (off the +X/+Z diagonal, so nothing sits edge-on) and a 45° front
552
+ view (square on to the origin from the +Z axis, so the X/Y plane reads straight off the
553
+ screen). `?camera=` also takes the
554
+ reference scene pose (`examples/*.tscn` `FreeCamera`, `/?scene=line&camera=reference`),
555
+ which stays URL-only because it exists for paired captures. Screenshot mode uses a
556
+ deterministic preset without localStorage: `/?compare=1&scene=circle`. Orthographic zoom
557
+ changes the orthographic extent.
558
+
559
+ The gallery UI is built with **Base UI + shadcn (base-lyra preset) + Tailwind v4**
560
+ in `gallery/components/`, and the imperative WebGPU side lives in `gallery/demo-engine.ts`.
561
+ React only renders chrome; the render loop, camera, and persistence stay outside
562
+ React and are read through `useSyncExternalStore`
563
+ (`gallery/demo-hooks.ts`). `gallery/demo.tsx` mounts the shell, `gallery/index.css` holds the base-lyra
564
+ theme tokens, and `gallery/demo.css` keeps the three-column layout, compare mode
565
+ and code highlighting.
566
+
567
+ Runtime comparison viewers (see `docs/visual-comparison.md` and `docs/text-reference.md`):
568
+ `http://127.0.0.1:5174/artifacts/{visual-baseline,text-reference}/index.html`. The whole
569
+ `artifacts/` tree is git-ignored — captures and comparison pages are regenerated locally, so run
570
+ the capture scripts before opening a page.
571
+
572
+ ## Development
573
+
574
+ ```bash
575
+ bun run typecheck
576
+ bun test
577
+ bun run build
578
+ bun run dev
579
+ ```
580
+
581
+ `bun run build` emits the static gallery that `.github/workflows/deploy-pages.yml` publishes to
582
+ GitHub Pages (<https://jwu.github.io/vgpu-render/>) on every push to `main`. Only the production
583
+ build is re-based to `/vgpu-render/` — override it with `VGPU_BASE` if the site path changes — while
584
+ the dev server and the capture scripts below keep addressing the app from `/`.
585
+
586
+ `bun run dev` serves the gallery; open the printed URL in a WebGPU-capable browser. On Linux, Chrome
587
+ only creates a hardware WebGPU adapter when its Vulkan feature is on — without it
588
+ `navigator.gpu.requestAdapter()` returns `null`. Either set `chrome://flags/#enable-vulkan` to
589
+ Enabled, or start the browser with `--enable-features=Vulkan`.
590
+
591
+ Visual baselines:
592
+
593
+ ```bash
594
+ # Reference capture (independent copy, real Vulkan). The engine binary and the script live in a
595
+ # local `/tmp/vgpu-parity-*` checkout; see `docs/visual-comparison.md` for the exact invocation.
596
+
597
+ # vgpu capture over CDP (needs a GPU display + dev server)
598
+ # CHROME_PATH selects the browser binary (default: first known install path, then PATH).
599
+ # VGPU_OZONE_PLATFORM pins the display server, e.g. `x11`; unset lets Chrome choose.
600
+ bun scripts/capture-vgpu.ts circle line
601
+ ```
602
+
603
+ Both sides write into `artifacts/`, which is entirely git-ignored: captures are recaptured locally
604
+ on demand and never committed. The reference capture is machine-specific (engine 4.7.2 / Vulkan /
605
+ RTX 4070 SUPER, see the capture conditions in `docs/visual-comparison.md`), so check that table
606
+ 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;