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.
- package/LICENSE +21 -0
- package/README.md +605 -2
- package/dist/assets/fira-mono-medium-subset.d.ts +4 -0
- package/dist/assets/fira-mono-medium-subset.js +889 -0
- package/dist/assets/fira-mono-msdf-atlas.d.ts +13 -0
- package/dist/assets/fira-mono-msdf-atlas.js +6222 -0
- package/dist/camera-2d.d.ts +43 -0
- package/dist/camera-2d.js +75 -0
- package/dist/color-space.d.ts +32 -0
- package/dist/color-space.js +45 -0
- package/dist/curve.d.ts +16 -0
- package/dist/curve.js +46 -0
- package/dist/debug-draw-3d.d.ts +14 -0
- package/dist/debug-draw-3d.js +36 -0
- package/dist/frame-draw-list-2d.d.ts +177 -0
- package/dist/frame-draw-list-2d.js +378 -0
- package/dist/frame-draw-list.d.ts +413 -0
- package/dist/frame-draw-list.js +556 -0
- package/dist/geometry-generators.d.ts +101 -0
- package/dist/geometry-generators.js +481 -0
- package/dist/gpu-debug-draw-2d.d.ts +48 -0
- package/dist/gpu-debug-draw-2d.js +450 -0
- package/dist/gpu-debug-draw-3d.d.ts +52 -0
- package/dist/gpu-debug-draw-3d.js +892 -0
- package/dist/gpu-instancing.d.ts +34 -0
- package/dist/gpu-instancing.js +93 -0
- package/dist/gpu-text-3d.d.ts +98 -0
- package/dist/gpu-text-3d.js +514 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +20 -0
- package/dist/infinite-grid-2d.d.ts +73 -0
- package/dist/infinite-grid-2d.js +253 -0
- package/dist/infinite-grid-3d.d.ts +134 -0
- package/dist/infinite-grid-3d.js +407 -0
- package/dist/mesh-batch-3d.d.ts +10 -0
- package/dist/mesh-batch-3d.js +67 -0
- package/dist/shape2d-shader.d.ts +14 -0
- package/dist/shape2d-shader.js +288 -0
- package/dist/text-atlas.d.ts +176 -0
- package/dist/text-atlas.js +498 -0
- package/dist/text-font.d.ts +190 -0
- package/dist/text-font.js +261 -0
- package/dist/text-layout.d.ts +175 -0
- package/dist/text-layout.js +390 -0
- package/dist/text-msdf-baked.d.ts +41 -0
- package/dist/text-msdf-baked.js +117 -0
- package/dist/text-msdf-font.d.ts +85 -0
- package/dist/text-msdf-font.js +159 -0
- package/dist/text-msdf.d.ts +70 -0
- package/dist/text-msdf.js +242 -0
- package/dist/text-raster.d.ts +40 -0
- package/dist/text-raster.js +60 -0
- package/dist/text-sdf.d.ts +28 -0
- package/dist/text-sdf.js +119 -0
- package/dist/vector-math.d.ts +19 -0
- package/dist/vector-math.js +37 -0
- 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
|
-
#
|
|
1
|
+
# vgpu-render
|
|
2
2
|
|
|
3
|
-
|
|
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;
|