@viggle/splat-engine-three 0.1.0 → 0.1.1
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/README.md +174 -14
- package/dist/chunks/{chunk-MM6DJU6S.js → chunk-WN5WBKM4.js} +848 -43
- package/dist/chunks/chunk-WN5WBKM4.js.map +7 -0
- package/dist/index.js +7 -3
- package/dist/types/character.d.ts +43 -1
- package/dist/types/index.d.ts +3 -0
- package/dist/types/splat/bone-binding.d.ts +1 -1
- package/dist/types/splat/depth-sort.d.ts +23 -5
- package/dist/types/splat/importance.d.ts +60 -0
- package/dist/types/splat/prune.d.ts +60 -0
- package/dist/types/splat/quality.d.ts +175 -0
- package/dist/types/splat/splat.d.ts +66 -0
- package/dist/types/splat/webgl/shaders.d.ts +1 -1
- package/dist/types/splat/webgl/splat-mesh.d.ts +2 -0
- package/dist/types/splat/webgpu/splat-mesh.d.ts +2 -0
- package/dist/types/three/splat-character.d.ts +28 -0
- package/dist/webgpu.js +36 -6
- package/dist/webgpu.js.map +2 -2
- package/package.json +1 -1
- package/dist/chunks/chunk-MM6DJU6S.js.map +0 -7
package/README.md
CHANGED
|
@@ -82,6 +82,10 @@ character.clip; // what is playing, or null
|
|
|
82
82
|
character.addEventListener('finished', (e) => {}); // a non-looping clip ended
|
|
83
83
|
character.addEventListener('loop', (e) => {}); // a looping clip wrapped
|
|
84
84
|
|
|
85
|
+
// Optional for grounded clips such as a dance; pins the lowest splats to y=0.
|
|
86
|
+
// Leave unset for jumps or other intentional airborne motion.
|
|
87
|
+
character.groundLockY = 0;
|
|
88
|
+
|
|
85
89
|
// A second clip on part of the body, over the base clip
|
|
86
90
|
character.playLayer('wave', waveClip, { region: 'upperBody', weight: 0.8 });
|
|
87
91
|
character.stopLayer('wave', 0.2);
|
|
@@ -98,6 +102,12 @@ const twin = character.clone(); // shares the decoded data, not th
|
|
|
98
102
|
character.dispose(); // free GPU resources
|
|
99
103
|
```
|
|
100
104
|
|
|
105
|
+
Expression `.vsplat` bundles with 119 bones use their 86 body bones and
|
|
106
|
+
per-character body rest lengths. Their remaining 33 facial influences follow
|
|
107
|
+
the head as a rigid group, keeping the face connected during animation. This
|
|
108
|
+
does not reproduce independent facial expressions; use a 441-bone character
|
|
109
|
+
for those.
|
|
110
|
+
|
|
101
111
|
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
112
|
|
|
103
113
|
For several copies of one character, load it once and `clone()` it; loading the same file twice decodes it twice.
|
|
@@ -143,7 +153,7 @@ await renderer.init();
|
|
|
143
153
|
| Depth sort | CPU - main thread for characters, a worker for static meshes | GPU, compute |
|
|
144
154
|
| Per-character CPU cost | re-skins every splat centre each frame to feed the sort | a pose evaluation and a palette upload |
|
|
145
155
|
|
|
146
|
-
**Use WebGPU when you can.**
|
|
156
|
+
**Use WebGPU when you can.** Its cost per animated character is about a third of WebGL's, because nothing per-splat happens on the CPU. See [Performance](#performance).
|
|
147
157
|
|
|
148
158
|
`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
159
|
|
|
@@ -173,6 +183,7 @@ Ported, and checked against the PlayCanvas build by the A/B suite (see [Verifica
|
|
|
173
183
|
- Splat scenes - uncompressed `.ply`, with position / rotation / scale
|
|
174
184
|
- `SunLight`, `SkyLight`, `PointLight`
|
|
175
185
|
- Render options - anti-aliasing, sub-pixel culling, per-group visibility (`setSplatGroups` / `setVariantMask`)
|
|
186
|
+
- Quality settings - `SplatQuality`, ported from the Unity build of the same engine rather than from the PlayCanvas one. Everything that applies to a renderer with no offscreen splat target; see [Tuning](#tuning) for which of its knobs did not come across and why.
|
|
176
187
|
|
|
177
188
|
Ported from the same source, but not exercised by the suite for want of test assets:
|
|
178
189
|
|
|
@@ -232,28 +243,178 @@ Splats test against the depth buffer and do not write it. Opaque meshes therefor
|
|
|
232
243
|
|
|
233
244
|
## Performance
|
|
234
245
|
|
|
246
|
+
For what to change when these numbers are not good enough on a given device, see [Tuning](#tuning).
|
|
247
|
+
|
|
235
248
|
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
249
|
|
|
237
250
|
<!-- BENCH_TABLE -->
|
|
238
251
|
| Scenario | Splats | PlayCanvas, WebGL | PlayCanvas, WebGPU | this package, WebGL | this package, WebGPU |
|
|
239
252
|
|---|--:|--:|--:|--:|--:|
|
|
240
|
-
| 1 character | 46,983 | 1.
|
|
241
|
-
| 8 characters | 356,960 |
|
|
242
|
-
| 32 characters | 1,427,840 | 40.
|
|
243
|
-
| room, still camera | 1,048,576 | 2.
|
|
244
|
-
| room, orbiting camera | 1,048,576 | 3.
|
|
245
|
-
| room + 4 characters, orbiting | 1,227,056 | **
|
|
253
|
+
| 1 character | 46,983 | 1.48 ms | 13.66 ms | 1.42 ms | **0.96 ms** |
|
|
254
|
+
| 8 characters | 356,960 | 11.37 ms | 12.76 ms | 13.44 ms | **4.18 ms** |
|
|
255
|
+
| 32 characters | 1,427,840 | 40.78 ms | 39.58 ms | 45.78 ms | **15.13 ms** |
|
|
256
|
+
| room, still camera | 1,048,576 | 2.73 ms | 15.54 ms | **1.70 ms** | 2.07 ms |
|
|
257
|
+
| room, orbiting camera | 1,048,576 | 3.39 ms | 15.54 ms | **1.86 ms** | 5.28 ms |
|
|
258
|
+
| room + 4 characters, orbiting | 1,227,056 | **6.64 ms** | 22.31 ms | 9.76 ms | 7.23 ms |
|
|
246
259
|
<!-- /BENCH_TABLE -->
|
|
247
260
|
|
|
248
261
|
Reading the table:
|
|
249
262
|
|
|
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
|
|
251
|
-
-
|
|
252
|
-
- With a
|
|
253
|
-
- The PlayCanvas WebGPU column sat on
|
|
263
|
+
- 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 the run-to-run noise of the shared machine these were measured on (roughly ±20% on the character rows).
|
|
264
|
+
- The WebGPU backend moves that work to the GPU, which is where the 3x on the character rows comes from. What remains on the CPU is pose evaluation.
|
|
265
|
+
- With a moving camera and nothing animated, the WebGL backend is ahead: it re-sorts the environment in a worker and keeps drawing the previous order meanwhile, while the WebGPU backend re-sorts on the GPU inside the frame. The WebGPU result is never a frame behind; the WebGL one can be.
|
|
266
|
+
- The PlayCanvas WebGPU column sat on a ~13 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
267
|
|
|
255
268
|
Regenerate with `npm run bench -- --backend <webgl|webgpu|webgpu-managed|pc-webgl|pc-webgpu>`, then `node scripts/readme-tables.mjs`.
|
|
256
269
|
|
|
270
|
+
## Tuning
|
|
271
|
+
|
|
272
|
+
A splat frame is bound by one of two things, and that decides which knobs do anything:
|
|
273
|
+
|
|
274
|
+
| Bound by | When | What moves it |
|
|
275
|
+
|---|---|---|
|
|
276
|
+
| **Fragments** (GPU) | WebGPU; anything static; a character filling the screen | `quadAlphaCutoff` |
|
|
277
|
+
| **Splats** (main thread) | WebGL with animated characters - every centre is re-skinned each frame to feed the CPU sort | `minOpacity`, `pruneRatio`, the distance throttles |
|
|
278
|
+
|
|
279
|
+
Measured on 32 animated characters under WebGL: 26.5 ms of main thread per frame, of which 23.7 ms is skinning and 2.8 ms the sort. `node ab/profile-stages.mjs` prints this split for any scene.
|
|
280
|
+
|
|
281
|
+
Settings are module-level, on `SplatQuality`, read per frame (or per decode, where stated). Two ship non-zero.
|
|
282
|
+
|
|
283
|
+
### `quadAlphaCutoff` — default `8 / 255`
|
|
284
|
+
|
|
285
|
+
Sizes each splat's quad to the radius its own opacity reaches, instead of the worst case. Fewer blended fragments; harder splat edges, visible first on hair, fabric and faces. **Takes effect next frame**, reversible, safe to drive from a frame-rate governor. Clamped to `[0, 1]`.
|
|
286
|
+
|
|
287
|
+
| Value | |
|
|
288
|
+
|---|---|
|
|
289
|
+
| `1 / 255` | Identical to the behaviour before this knob existed. A reference, not a setting. |
|
|
290
|
+
| `8 / 255` | **Default.** Also what the Unity build ships. |
|
|
291
|
+
| `12 / 255` | One step down. |
|
|
292
|
+
| `16 / 255` | **Low-end, and the floor.** |
|
|
293
|
+
| above `16 / 255` | Don't. More performance is there, but every silhouette hardens; spend it on `pruneRatio`, pixel ratio or a distance LOD instead. |
|
|
294
|
+
|
|
295
|
+
Cost on a face filling a 760px frame, against `1 / 255`: `8/255` 40.7 dB / 3.1% of pixels off by >8; `12/255` 36.8 dB / 7.8%; `16/255` 34.3 dB / 11.2%. Frame time where fragments bind, against `1 / 255`: `8/255` -29%, `16/255` -42%.
|
|
296
|
+
|
|
297
|
+
### `minOpacity` — default `0.05`, hard cap `0.1`
|
|
298
|
+
|
|
299
|
+
Drops splats whose peak opacity is below the threshold. Saves memory, skinning, sorting and rasterisation together, where `quadAlphaCutoff` saves rasterisation alone.
|
|
300
|
+
|
|
301
|
+
- **Applied during the decode of a character bundle.** Set it before `load()`; changing it later needs a reload.
|
|
302
|
+
- **Does not touch `.ply` scenes** - same scope as the Unity build. `0.05` removes 8.7% of the test character but 41.8% of the 1M-splat test room. `pruneSplats` is exported for scenes.
|
|
303
|
+
- **Changes `Splat.numSplats`.** A `setSplatGroups` array must be built from the loaded splats, not from the file. An array authored offline against the file cannot be truncated to fit - its indices no longer line up.
|
|
304
|
+
- **Values above `0.1` are clamped**, with one console warning. Enforced because every increment buys frame time while the cost arrives gradually and everywhere, so nothing tells a tuning pass to stop.
|
|
305
|
+
|
|
306
|
+
| Value | Cost on a face filling the frame | Splats kept |
|
|
307
|
+
|---|---|---|
|
|
308
|
+
| `0` | none | 100% |
|
|
309
|
+
| `1 / 255` | pixel-identical | 98% |
|
|
310
|
+
| `0.05` | max 20/255, 0.94% of pixels off by >8 | 91% |
|
|
311
|
+
| `0.1` (cap) | max 45/255, 9.3% of pixels off by >8 | 83% |
|
|
312
|
+
|
|
313
|
+
### The rest
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
SplatQuality.pruneRatio = 0.3; // 0..1, decode-time, characters only: drop the weakest
|
|
317
|
+
// share of every pruneVoxelSize cell by opacity x footprint
|
|
318
|
+
SplatQuality.pruneVoxelSize = 0.04; // metres; the cell that quota is per
|
|
319
|
+
SplatQuality.screenSizeCull = 0; // pixels; drop splats smaller than this. For distant crowds
|
|
320
|
+
SplatQuality.depthOcclusion = true; // scene geometry hides splats. Correctness, not performance:
|
|
321
|
+
// this renderer gets the hardware depth test for free
|
|
322
|
+
SplatQuality.passTimings = false; // then read SplatQuality.timings after each render
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
`pruneRatio` ranks inside each cell, not globally: a face is many faint splats where a jacket is few strong ones, so a global rank deletes the face first.
|
|
326
|
+
|
|
327
|
+
### Distance throttles, per character
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
SplatEngine.shared.camera = camera; // all three measure distance from it; without it they no-op
|
|
331
|
+
|
|
332
|
+
character.setSplatCountLod({ nearDist: 4, farDist: 16, farFraction: 0.25 });
|
|
333
|
+
character.armature.setSortLod({ nearDist: 5, farDist: 15, nearStride: 2, farStride: 4 });
|
|
334
|
+
character.armature.setAnimationLod({ nearDist: 12, farDist: 24, nearStride: 2, farStride: 4 });
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
All off until called. `nearStride` applies between `nearDist` and `farDist`, `farStride` beyond `farDist` - **set both**, or the band most of a crowd is standing in keeps its previous value.
|
|
338
|
+
|
|
339
|
+
`setSplatCountLod` is `pruneRatio` made dynamic: nothing is discarded, and the fraction skinned, sorted and drawn follows camera distance, by the same per-cell importance. WebGL backend only - that is the one paying per splat on the CPU.
|
|
340
|
+
|
|
341
|
+
### Per device
|
|
342
|
+
|
|
343
|
+
Pixel ratio first; it is not in this package and it is larger than everything below it. A phone reports `devicePixelRatio` 3, which is nine times the fragments.
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
| Tier | `quadAlphaCutoff` | `minOpacity` | `pruneRatio` | Pixel ratio | Also |
|
|
350
|
+
|---|---|---|---|---|---|
|
|
351
|
+
| Desktop / flagship | `8 / 255` | `0.05` | `0` | ≤ 2 | — |
|
|
352
|
+
| Mid-range phone | `12 / 255` | `0.05` | `0` | ≤ 1.5 | — |
|
|
353
|
+
| Low-end phone | `16 / 255` | `0.05` | `0.3` | ≤ 1.5 | `setSplatCountLod({ nearDist: 3, farDist: 12, farFraction: 0.25 })` |
|
|
354
|
+
| Very low-end, or a crowd | `16 / 255` | `0.05` | `0.5` | 1 | plus `setSortLod` / `setAnimationLod` |
|
|
355
|
+
|
|
356
|
+
On WebGL with several animated characters the cutoff column does nothing; spend the budget to its right.
|
|
357
|
+
|
|
358
|
+
### Measured
|
|
359
|
+
|
|
360
|
+
1920×1080, Apple M5 Pro, headless Chromium, vsync off. Baseline is **every knob off**, including the two that ship on, so the `DEFAULT` rows are what the defaults themselves cost and buy. `~` is inside that column's noise floor.
|
|
361
|
+
|
|
362
|
+
<!-- QUALITY_TABLE -->
|
|
363
|
+
| Setting | Splats | PSNR | Worst pixel | WebGL, 2 close | WebGL, 2 close, GPU only |
|
|
364
|
+
|---|--:|--:|--:|--:|--:|
|
|
365
|
+
| `DEFAULT cutoff 8/255` | - | 53.4 dB | 31/255 | ~ | -22% |
|
|
366
|
+
| `DEFAULT 8/255 + minOp 0.05` | 78,004 | 53.0 dB | 31/255 | -10% | -22% |
|
|
367
|
+
| `cutoff 12/255` | - | 48.5 dB | 47/255 | ~ | -24% |
|
|
368
|
+
| `cutoff 16/255` | - | 45.3 dB | 63/255 | ~ | -29% |
|
|
369
|
+
| `cutoff 16 + minOp 0.05` | 78,004 | 45.3 dB | 63/255 | -12% | -30% |
|
|
370
|
+
| `cutoff 24/255` | - | 40.8 dB | 98/255 | ~ | -34% |
|
|
371
|
+
| `cutoff 32/255` | - | 37.5 dB | 144/255 | ~ | -36% |
|
|
372
|
+
| `minOpacity 1/255` | 87,262 | ∞ | 0/255 | ~ | ~ |
|
|
373
|
+
| `minOpacity 0.05` | 78,004 | 61.7 dB | 14/255 | -11% | ~ |
|
|
374
|
+
| `prune 0.3` | 64,755 | 43.9 dB | 174/255 | -23% | -8% |
|
|
375
|
+
| `prune 0.5` | 45,952 | 37.8 dB | 187/255 | -42% | -20% |
|
|
376
|
+
| `screenSizeCull 1px` | - | ∞ | 0/255 | ~ | +10% |
|
|
377
|
+
| `screenSizeCull 2px` | - | 100.8 dB | 4/255 | ~ | ~ |
|
|
378
|
+
| `cutoff 16 + prune 0.3` | 64,755 | 40.3 dB | 179/255 | -22% | -36% |
|
|
379
|
+
| `cutoff 16 + prune 0.3 + minOp 0.05` | 56,824 | 40.2 dB | 179/255 | -31% | -36% |
|
|
380
|
+
| `countLod 0.5` | - | 35.2 dB | 190/255 | -43% | -17% |
|
|
381
|
+
| `countLod 0.3` | - | 31.1 dB | 196/255 | -41% | -35% |
|
|
382
|
+
| `countLod 0.3 + cutoff 16` | - | 30.4 dB | 201/255 | -57% | -47% |
|
|
383
|
+
| `sortLod stride 2` | - | 48.2 dB | 56/255 | -24% | ~ |
|
|
384
|
+
| `sortLod stride 4` | - | 48.2 dB | 56/255 | -24% | ~ |
|
|
385
|
+
| `animLod stride 2` | - | ∞ | 0/255 | -38% | ~ |
|
|
386
|
+
|
|
387
|
+
Noise floor per column: WebGL, 2 close ±9%, WebGL, 2 close, GPU only ±6%. `~` is a result inside it. Splats are per scene where a setting changes the count, `-` where it does not.
|
|
388
|
+
<!-- /QUALITY_TABLE -->
|
|
389
|
+
|
|
390
|
+
`screenSizeCull` culls nothing in these scenes (frames are bit-identical) yet reads +10% in one column: a single run can still mislead. The throttles read the same because two characters cannot separate skipping half the frames from skipping three quarters; on 32 characters, per-frame main-thread ms:
|
|
391
|
+
|
|
392
|
+
| | frame | update | skin | sort |
|
|
393
|
+
|---|--:|--:|--:|--:|
|
|
394
|
+
| baseline | 29.9 | 26.5 | 23.7 | 2.8 |
|
|
395
|
+
| `sortLod` stride 2 | 16.5 | 14.6 | 11.9 | 1.4 |
|
|
396
|
+
| `sortLod` stride 4 | 9.9 | 8.8 | 6.1 | 0.7 |
|
|
397
|
+
| `countLod 0.3` | 12.2 | 10.1 | 7.3 | 1.7 |
|
|
398
|
+
| `prune 0.5` | 16.7 | 14.7 | 11.9 | 1.4 |
|
|
399
|
+
|
|
400
|
+
With skinning throttled away the floor is pose evaluation, a couple of ms per 32 characters.
|
|
401
|
+
|
|
402
|
+
```bash
|
|
403
|
+
npm run build
|
|
404
|
+
node ab/run-quality-ab.mjs --backend webgl --scenario fill-2 # [--animate 0] for GPU alone
|
|
405
|
+
node ab/profile-stages.mjs --scenario crowd-32 # which stage a setting cut
|
|
406
|
+
node ab/quality-shots.mjs # contact sheets to judge by eye
|
|
407
|
+
node scripts/readme-tables.mjs # rewrite the table above
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Scenarios: `fill-2`, `crowd-16`, `crowd-32`, `room`, `room-4`.
|
|
411
|
+
|
|
412
|
+
### Not ported
|
|
413
|
+
|
|
414
|
+
`RenderScale`, `LdrRenderTarget`, `FitTargetToCharacters`, `FitCompositeOnly` act on an offscreen splat target. This package draws straight into the camera's target - the mode the Unity build measured as its fastest - so they have nothing to act on. For a lower splat resolution, render the whole frame smaller.
|
|
415
|
+
|
|
416
|
+
`SplitComputeAndDraw` (batching every splat's compute ahead of all draws, worth 11.6% on a Mali-G57) was ported and removed: `WebGPURenderer` already submits each compute call in its own command buffer ahead of the render pass, so there is no interleaving to undo, and it measured 6-18% slower.
|
|
417
|
+
|
|
257
418
|
## Verification
|
|
258
419
|
|
|
259
420
|
`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:
|
|
@@ -279,11 +440,10 @@ npm run ab -- --port webgpu # WebGPU
|
|
|
279
440
|
npm run ab -- --api engine # the engine-level API instead
|
|
280
441
|
node ab/check-api.mjs # the three.js-style API's own contract
|
|
281
442
|
node ab/check-stability.mjs # still frames must be bit-stable
|
|
282
|
-
node ab/check-
|
|
443
|
+
node ab/check-prune.mjs # the load-time prune keeps every column in lockstep
|
|
444
|
+
node ab/check-animation.mjs # a playing clip must actually reach the screen
|
|
283
445
|
```
|
|
284
446
|
|
|
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
447
|
The suite needs a GPU-backed headless Chromium (`npx playwright install chromium`).
|
|
288
448
|
|
|
289
449
|
## Licence
|