@viggle/splat-engine-three 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +290 -2
  3. package/dist/chunks/chunk-MM6DJU6S.js +7801 -0
  4. package/dist/chunks/chunk-MM6DJU6S.js.map +7 -0
  5. package/dist/index.js +52 -0
  6. package/dist/index.js.map +7 -0
  7. package/dist/types/anim/animation-conversion.d.ts +25 -0
  8. package/dist/types/anim/bind-pose.d.ts +15 -0
  9. package/dist/types/anim/keyframe-splines.d.ts +59 -0
  10. package/dist/types/anim/rest-relative.d.ts +32 -0
  11. package/dist/types/anim/spline.d.ts +14 -0
  12. package/dist/types/animation.d.ts +216 -0
  13. package/dist/types/armature.d.ts +524 -0
  14. package/dist/types/character.d.ts +540 -0
  15. package/dist/types/engine.d.ts +166 -0
  16. package/dist/types/environment.d.ts +53 -0
  17. package/dist/types/index.d.ts +37 -0
  18. package/dist/types/lighting/color.d.ts +13 -0
  19. package/dist/types/lighting/point-light.d.ts +33 -0
  20. package/dist/types/lighting/sky-light.d.ts +22 -0
  21. package/dist/types/lighting/sun-light.d.ts +34 -0
  22. package/dist/types/loader/animation/animation-glb.d.ts +117 -0
  23. package/dist/types/loader/character/index.d.ts +33 -0
  24. package/dist/types/loader/character/legacy/index.d.ts +34 -0
  25. package/dist/types/loader/character/legacy/interleaved-splat.d.ts +101 -0
  26. package/dist/types/loader/character/legacy/planar-splat.d.ts +139 -0
  27. package/dist/types/loader/character/load.d.ts +33 -0
  28. package/dist/types/loader/character/types.d.ts +129 -0
  29. package/dist/types/loader/character/v1/decode.d.ts +32 -0
  30. package/dist/types/loader/environment/ply.d.ts +16 -0
  31. package/dist/types/loader/shared/bin-cache.d.ts +19 -0
  32. package/dist/types/loader/skeleton/index.d.ts +19 -0
  33. package/dist/types/loader/skeleton/skeleton-data-441.d.ts +5 -0
  34. package/dist/types/loader/skeleton/skeleton-data.d.ts +6 -0
  35. package/dist/types/math.d.ts +82 -0
  36. package/dist/types/skeletal/animation-player.d.ts +185 -0
  37. package/dist/types/skeletal/bone-mask.d.ts +27 -0
  38. package/dist/types/skeletal/bone-stretch.d.ts +65 -0
  39. package/dist/types/skeletal/pose.d.ts +27 -0
  40. package/dist/types/skeletal/skeleton.d.ts +57 -0
  41. package/dist/types/skeletal/splat-skinning.d.ts +69 -0
  42. package/dist/types/splat/backend.d.ts +57 -0
  43. package/dist/types/splat/bone-binding.d.ts +38 -0
  44. package/dist/types/splat/depth-sort.d.ts +58 -0
  45. package/dist/types/splat/packed-splats.d.ts +36 -0
  46. package/dist/types/splat/splat-mesh.d.ts +71 -0
  47. package/dist/types/splat/splat.d.ts +186 -0
  48. package/dist/types/splat/transform-palette.d.ts +42 -0
  49. package/dist/types/splat/webgl/backend.d.ts +17 -0
  50. package/dist/types/splat/webgl/shaders.d.ts +2 -0
  51. package/dist/types/splat/webgl/splat-mesh.d.ts +52 -0
  52. package/dist/types/splat/webgpu/backend.d.ts +25 -0
  53. package/dist/types/splat/webgpu/lighting.d.ts +34 -0
  54. package/dist/types/splat/webgpu/radix-sort.d.ts +46 -0
  55. package/dist/types/splat/webgpu/splat-mesh.d.ts +71 -0
  56. package/dist/types/three/loaders.d.ts +77 -0
  57. package/dist/types/three/splat-character.d.ts +192 -0
  58. package/dist/types/types.d.ts +30 -0
  59. package/dist/types/webgpu.d.ts +5 -0
  60. package/dist/webgpu.js +813 -0
  61. package/dist/webgpu.js.map +7 -0
  62. package/package.json +46 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Viggle / WarpEngine
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,291 @@
1
- # Temporary Holding Version
1
+ # @viggle/splat-engine-three
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
+ Gaussian-splat characters for three.js. Load `.vsplat` characters as `Object3D`s, play MetaHuman GLB skeletal animations with crossfade blending, load `.ply` splat scenes, and render them in your own three.js scene under `WebGLRenderer` or `WebGPURenderer`.
4
+
5
+ A port of [`@viggle/splat-engine`](https://www.npmjs.com/package/@viggle/splat-engine) with no PlayCanvas dependency. The rig, animation and bundle-decoding code is shared with that package; the renderer is new.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @viggle/splat-engine-three three
11
+ ```
12
+
13
+ Built and tested against three.js r186.
14
+
15
+ ## Quick start
16
+
17
+ Characters and splat scenes are ordinary three.js objects: load them with a loader, add them to your scene, move them with `position` / `rotation` / `scale`. Call `character.update(delta)` once per frame - the job `AnimationMixer.update` does for a skinned mesh.
18
+
19
+ ```typescript
20
+ import * as THREE from 'three';
21
+ import { VSplatLoader, SplatClipLoader, SplatPLYLoader } from '@viggle/splat-engine-three';
22
+
23
+ const renderer = new THREE.WebGLRenderer({ canvas });
24
+ const scene = new THREE.Scene();
25
+ const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
26
+
27
+ // A splat scene (optional). Many captures are authored Y-down.
28
+ const room = await new SplatPLYLoader().loadAsync('/assets/room.ply');
29
+ room.rotation.x = Math.PI;
30
+ scene.add(room);
31
+
32
+ // A character
33
+ const character = await new VSplatLoader().loadAsync('/assets/character.vsplat');
34
+ character.position.set(0, 0, 1);
35
+ scene.add(character);
36
+
37
+ // Clips
38
+ const clips = new SplatClipLoader();
39
+ const [idle, run] = await Promise.all([
40
+ clips.loadAsync('/assets/idle.glb'),
41
+ clips.loadAsync('/assets/run.glb'),
42
+ ]);
43
+ character.play(idle);
44
+ character.crossFadeTo(run, 0.3);
45
+
46
+ const clock = new THREE.Clock();
47
+ renderer.setAnimationLoop(() => {
48
+ character.update(clock.getDelta());
49
+ renderer.render(scene, camera);
50
+ });
51
+ ```
52
+
53
+ Get character `.vsplat` files and animation `.glb` files from https://viggle.ai/pinoc/app/.
54
+
55
+ ## API
56
+
57
+ ### Loaders
58
+
59
+ All three extend `THREE.Loader`, so `load` / `loadAsync`, `setPath`, `setRequestHeader`, `setWithCredentials` and a `LoadingManager` work as usual. Each also has `parseAsync(arrayBuffer)` for bytes you already hold.
60
+
61
+ | Loader | Reads | Gives you |
62
+ |---|---|---|
63
+ | `VSplatLoader` | `.vsplat` character bundles | `SplatCharacter` |
64
+ | `SplatClipLoader` | MetaHuman-rig `.glb` animations | `SplatClip` |
65
+ | `SplatPLYLoader` | uncompressed 3D Gaussian Splatting `.ply` | `SplatMesh` |
66
+
67
+ ### `SplatCharacter`
68
+
69
+ An `Object3D`. Its `position` is the model origin: a character in its rest pose stands on it.
70
+
71
+ ```typescript
72
+ character.play(clip, { loop: false }); // cut to a clip
73
+ character.crossFadeTo(clip, 0.3); // blend to a clip over 0.3 s
74
+ character.stop(0.2); // back to the rest pose
75
+
76
+ character.timeScale = 1.5; // playback speed
77
+ character.paused = true;
78
+ character.time; // seconds into the clip (settable)
79
+ character.duration; // length of the clip in seconds
80
+ character.clip; // what is playing, or null
81
+
82
+ character.addEventListener('finished', (e) => {}); // a non-looping clip ended
83
+ character.addEventListener('loop', (e) => {}); // a looping clip wrapped
84
+
85
+ // A second clip on part of the body, over the base clip
86
+ character.playLayer('wave', waveClip, { region: 'upperBody', weight: 0.8 });
87
+ character.stopLayer('wave', 0.2);
88
+
89
+ // Root motion: 'off' plays in place, 'apply' moves the body with the clip,
90
+ // 'extract' plays in place and hands you the travel
91
+ character.rootMotion = 'extract';
92
+ const step = character.consumeRootMotion(); // Vector3, local space, per frame
93
+
94
+ character.setTint('#ff4040', 0.6); // blend toward a colour; 0 clears it
95
+ character.getBoneWorldMatrix('hand_r'); // Matrix4, to attach a prop
96
+
97
+ const twin = character.clone(); // shares the decoded data, not the pose
98
+ character.dispose(); // free GPU resources
99
+ ```
100
+
101
+ Animation goes through this class, not `AnimationMixer`: a splat character has no `Bone` hierarchy for a mixer to drive. A `SplatClip` is not tied to a character - play one clip on any number of them, and call `clip.dispose()` when you are done with it.
102
+
103
+ For several copies of one character, load it once and `clone()` it; loading the same file twice decodes it twice.
104
+
105
+ ### `SplatMesh`
106
+
107
+ A `Mesh`, so `visible`, `renderOrder`, `layers` and parent transforms behave as they do for any mesh. `SplatPLYLoader` gives it `renderOrder = -1` so it draws before characters; see [Depth and draw order](#depth-and-draw-order). `mesh.splat` holds its render options (`antiAlias`, `minPixelSize`, `setTint`, `setGrade`); `mesh.dispose()` frees it.
108
+
109
+ Splats are not pickable: `Raycaster` reports no hits on them.
110
+
111
+ ### Lights
112
+
113
+ Splats carry baked colour and ignore the lights in your scene. To relight them, use the engine's own lights, which multiply that colour: ambient plus sun plus range-attenuated point lights, with no shading by surface direction.
114
+
115
+ ```typescript
116
+ import { SplatEngine, SunLight, SkyLight, PointLight } from '@viggle/splat-engine-three';
117
+
118
+ SkyLight.create(SplatEngine.shared, { color: '#8ea8ff', intensity: 0.4 });
119
+ SunLight.create(SplatEngine.shared, { color: '#ffd9a0', intensity: 0.9 });
120
+ PointLight.create(SplatEngine.shared, { position: { x: 1, y: 1.5, z: 1 }, color: '#ff4020', intensity: 2, range: 2.5 });
121
+ ```
122
+
123
+ `SplatEngine.shared` is the state loaders use by default - the skeleton library and these lights. With no lights set, splats render as authored.
124
+
125
+ ## WebGL or WebGPU
126
+
127
+ The package draws under both `THREE.WebGLRenderer` and `WebGPURenderer`, and picks its implementation from whichever renderer draws a splat. The only thing you choose is the import: applications on `WebGPURenderer` import from `@viggle/splat-engine-three/webgpu`, the same way they import three.js itself from `three/webgpu`.
128
+
129
+ ```typescript
130
+ import { WebGPURenderer } from 'three/webgpu';
131
+ import { VSplatLoader, SplatClipLoader, SplatPLYLoader } from '@viggle/splat-engine-three/webgpu';
132
+
133
+ const renderer = new WebGPURenderer({ canvas });
134
+ await renderer.init();
135
+ // ...everything else is identical
136
+ ```
137
+
138
+ | | `THREE.WebGLRenderer` | `WebGPURenderer` |
139
+ |---|---|---|
140
+ | Import from | `@viggle/splat-engine-three` | `@viggle/splat-engine-three/webgpu` |
141
+ | Runs on | WebGL2 | WebGPU |
142
+ | Skinning | GPU, vertex stage | GPU, compute |
143
+ | Depth sort | CPU - main thread for characters, a worker for static meshes | GPU, compute |
144
+ | Per-character CPU cost | re-skins every splat centre each frame to feed the sort | a pose evaluation and a palette upload |
145
+
146
+ **Use WebGPU when you can.** With animated characters on screen a frame costs about 40% of what it does under WebGL, because nothing per-splat happens on the CPU. See [Performance](#performance).
147
+
148
+ `WebGPURenderer` must actually be running on WebGPU. Its WebGL2 fallback (`forceWebGL`, or a browser without WebGPU) has no compute shaders and cannot draw splats; the package logs an error saying so. Check before you build the scene, and use `THREE.WebGLRenderer` on those devices:
149
+
150
+ ```typescript
151
+ if (renderer.backend.isWebGPUBackend !== true) { /* use THREE.WebGLRenderer instead */ }
152
+ ```
153
+
154
+ ### Colour and `WebGPURenderer`
155
+
156
+ Splat colours are display-referred (sRGB), and splats are trained to be alpha-blended in that space. `THREE.WebGLRenderer` draws straight to an sRGB canvas, so that is what happens.
157
+
158
+ `WebGPURenderer` with its default `outputColorSpace` renders into a linear buffer first and converts at the end, so splats blend in linear space instead. Soft edges - hair, fabric edges, anything semi-transparent - come out slightly different from the reference look. To get the reference look, have the renderer write its working space straight to the canvas:
159
+
160
+ ```typescript
161
+ renderer.outputColorSpace = THREE.LinearSRGBColorSpace;
162
+ ```
163
+
164
+ That setting applies to the whole renderer: other materials in the scene are then no longer converted to sRGB for you. If your scene depends on three.js colour management, leave the default and accept the difference on soft edges. Measured against the PlayCanvas build on a face close-up, the default leaves about 10% of pixels off by more than 8/255 (PSNR 33 dB); writing the working space directly leaves 0.02% (PSNR 48 dB).
165
+
166
+ ## What is and is not ported
167
+
168
+ Ported, and checked against the PlayCanvas build by the A/B suite (see [Verification](#verification)):
169
+
170
+ - Characters - loading `.vsplat` bundles, position / rotation / scale, tint, colour grade
171
+ - Animation - GLB clips, crossfade, layers with bone masks, root motion
172
+ - Rig - pose evaluation, bone-length scaling (`setBoneStretch`)
173
+ - Splat scenes - uncompressed `.ply`, with position / rotation / scale
174
+ - `SunLight`, `SkyLight`, `PointLight`
175
+ - Render options - anti-aliasing, sub-pixel culling, per-group visibility (`setSplatGroups` / `setVariantMask`)
176
+
177
+ Ported from the same source, but not exercised by the suite for want of test assets:
178
+
179
+ - Legacy `.bin` character bundles, and clips embedded in them
180
+ - `.anim` clips and animation events
181
+ - Multi-level character LOD (`Character.load([...])`, `addLodLevel`, `setRenderLod`) and variant splats (`addVariantSplat`)
182
+ - Animation LOD and sort LOD
183
+
184
+ Not ported:
185
+
186
+ - Shadows (`ShadowSystem`)
187
+ - Post effects (`PostFX`, pixel-art, CRT, outline, stained glass) and the VFX system
188
+ - Ragdoll and the physics joints
189
+ - `.spz`, compressed `.ply`, SOG and streamed LOD bundles for environments; environment clip bounds and camera cutaway
190
+ - Presets: `DamageVariant`, `BodyShapeVariant`, `CharacterMotor`, `WasdController`, camera controls, `TileGrid`, grid / sky dome / gizmos. The camera controls and helpers have direct three.js equivalents (`OrbitControls`, `GridHelper`, …).
191
+
192
+ ## The engine-level API
193
+
194
+ Underneath the three.js-style API sits a second one, shaped like the PlayCanvas build's: `SplatEngine`, `Character`, `Animation`, `Environment`. It is what the A/B suite compares against that build, and it is how you reach the features the three.js-style API does not surface yet - multi-level character LOD, variant splats, animation and sort LOD, bone-length scaling.
195
+
196
+ `splatCharacter.character` is the engine-level `Character` behind a `SplatCharacter`, and `.armature` its rig, so the two can be mixed:
197
+
198
+ ```typescript
199
+ character.armature.setBoneStretch({ thigh_l: { along: 1.3 }, calf_l: { along: 1.3 } });
200
+ await character.character.addLodLevel('/assets/character.l1.vsplat');
201
+ ```
202
+
203
+ Used on its own it looks like this. Clips are registered and played by name, time is counted in frames, rotations are in degrees, and `engine.update(dt)` advances every character at once:
204
+
205
+ ```typescript
206
+ import { SplatEngine, Character, Animation, Environment } from '@viggle/splat-engine-three';
207
+
208
+ const engine = new SplatEngine({ scene, camera }); // everything it loads is added under `scene`
209
+ await Environment.load(engine, '/assets/room.ply', { rotation: { x: 180, y: 0, z: 0 } });
210
+ const character = await Character.load(engine, '/assets/character.vsplat');
211
+ await Animation.loadGlb(engine, '/assets/run.glb', 'run');
212
+ character.playAnimation('run', { loop: true });
213
+
214
+ renderer.setAnimationLoop(() => {
215
+ engine.update(clock.getDelta());
216
+ renderer.render(scene, camera);
217
+ });
218
+ ```
219
+
220
+ One difference to know when mixing the two: `Character.setPosition` lowers the model by a per-character ground offset carried over from the PlayCanvas build (about 8 cm on the test characters), to match how characters in that build's older bundle format sit. `SplatCharacter.position` applies no offset.
221
+
222
+ ## Differences from the PlayCanvas build
223
+
224
+ - **No `Scene`.** `Scene.create(canvas)` + `scene.start()` become your own renderer, scene and render loop.
225
+ - **Environments always respond to lights.** In the PlayCanvas build an environment only follows `SunLight` / `SkyLight` once a `ShadowSystem` is bound; without one it stays unlit whatever lights exist. Here it follows them from the start.
226
+ - **Character bundles decode on the main thread.** There is no worker file to keep track of, so the bundler notes in that build's README do not apply. A bundle decodes in a few milliseconds.
227
+ - **Under WebGPU, at most 16 point lights and 64 variant groups** are evaluated.
228
+
229
+ ### Depth and draw order
230
+
231
+ Splats test against the depth buffer and do not write it. Opaque meshes therefore occlude them correctly with no extra setup, but two splat objects are layered by draw order alone, not by depth. Static meshes from `SplatPLYLoader` draw first (`renderOrder = -1`), so characters always composite on top of the scene they stand in - the same layering the PlayCanvas build uses. A character that walks behind a wall of the splat scene is still drawn over it.
232
+
233
+ ## Performance
234
+
235
+ Mean time per frame, 1920×1080, NVIDIA RTX 3090, Chromium (headless, vsync off), median of five runs. Lower is better. The first two columns are the original PlayCanvas engine on the same scenes, for reference.
236
+
237
+ <!-- BENCH_TABLE -->
238
+ | Scenario | Splats | PlayCanvas, WebGL | PlayCanvas, WebGPU | this package, WebGL | this package, WebGPU |
239
+ |---|--:|--:|--:|--:|--:|
240
+ | 1 character | 46,983 | 1.22 ms | 11.13 ms | 1.17 ms | **0.76 ms** |
241
+ | 8 characters | 356,960 | 14.13 ms | 11.08 ms | 10.29 ms | **3.91 ms** |
242
+ | 32 characters | 1,427,840 | 40.94 ms | 31.48 ms | 37.60 ms | **14.84 ms** |
243
+ | room, still camera | 1,048,576 | 2.77 ms | 14.47 ms | **1.71 ms** | 1.71 ms |
244
+ | room, orbiting camera | 1,048,576 | 3.40 ms | 14.67 ms | **1.85 ms** | 5.10 ms |
245
+ | room + 4 characters, orbiting | 1,227,056 | **5.44 ms** | 20.71 ms | 6.43 ms | 7.09 ms |
246
+ <!-- /BENCH_TABLE -->
247
+
248
+ Reading the table:
249
+
250
+ - The two WebGL columns do the same work per character - both re-skin every splat centre on the CPU to feed the depth sort - and are equal to within run-to-run noise. The machine was shared while these were measured; the character rows, which are CPU-bound, moved by about ±20% between runs.
251
+ - Under WebGPU that work moves to the GPU, which is where the 2.5x on the character rows comes from (3x in a run where the CPU was busier). What remains on the CPU is pose evaluation.
252
+ - With a turning camera and nothing animated, WebGL is ahead: it re-sorts a static mesh in a worker and keeps drawing the previous order meanwhile, while WebGPU re-sorts on the GPU inside the frame. The WebGPU order is never a frame behind; the WebGL one can be.
253
+ - The PlayCanvas WebGPU column sat on an ~11 ms floor in this harness, which drives that engine one `app.tick()` at a time. That was not investigated and may not reflect how it behaves under its own frame loop.
254
+
255
+ Regenerate with `npm run bench -- --backend <webgl|webgpu|webgpu-managed|pc-webgl|pc-webgpu>`, then `node scripts/readme-tables.mjs`.
256
+
257
+ ## Verification
258
+
259
+ `ab/` holds an A/B suite that drives the PlayCanvas build and this package through the same cases - both characters, every supplied clip, crossfades, layers, root motion, transforms, tint, grade, lights, bone stretch, variant masks, and the splat scene - and compares:
260
+
261
+ - **Bone matrices**, numerically. They are bit-identical to the PlayCanvas build in every case.
262
+ - **Rendered frames**, per pixel. See the table below; the PlayCanvas build's own WebGL and WebGPU renderers differ from each other by about as much as this package differs from either.
263
+
264
+ The suite runs this package through the three.js-style API by default, and can run it through the engine-level API instead (`--api engine`); both pass. What has no counterpart in the PlayCanvas build - loaders and the `LoadingManager`, events, time in seconds, `clone`, `dispose` - is covered by `ab/check-api.mjs`.
265
+
266
+ <!-- AB_TABLE -->
267
+ | Compared with the PlayCanvas build (WebGL) | Cases | Bone matrices, max difference | PSNR, median | PSNR, worst | Pixels off by more than 8/255, worst case |
268
+ |---|--:|--:|--:|--:|--:|
269
+ | WebGL backend, characters | 66 | 0 | 55.5 dB | 45.7 dB | 0.050% |
270
+ | WebGL backend, environment | 10 | 0 | 44.1 dB | 42.6 dB | 0.040% |
271
+ | WebGPU backend, characters | 66 | 0 | 55.6 dB | 45.7 dB | 0.048% |
272
+ | WebGPU backend, environment | 10 | 0 | 44.1 dB | 42.6 dB | 0.041% |
273
+ <!-- /AB_TABLE -->
274
+
275
+ ```bash
276
+ npm run build
277
+ npm run ab # WebGL vs PlayCanvas (WebGL), three.js-style API
278
+ npm run ab -- --port webgpu # WebGPU
279
+ npm run ab -- --api engine # the engine-level API instead
280
+ node ab/check-api.mjs # the three.js-style API's own contract
281
+ node ab/check-stability.mjs # still frames must be bit-stable
282
+ node ab/check-consumer.mjs <dir> # pack, install into a Vite + TypeScript project, build, run
283
+ ```
284
+
285
+ `check-consumer.mjs` takes a scratch project that depends on `three`, `vite` and `typescript` and has the README's quick start as its source; it verifies the published `exports`, the type declarations under `strict`, and that a minified production build still runs under both renderers.
286
+
287
+ The suite needs a GPU-backed headless Chromium (`npx playwright install chromium`).
288
+
289
+ ## Licence
290
+
291
+ MIT. See [LICENSE](./LICENSE).