three-vat 1.0.0 → 2.0.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/README.md +164 -308
- package/dist/carrier-BFCPmcQK.d.ts +472 -0
- package/dist/chunk-2PGZ44TP.js +202 -0
- package/dist/chunk-W2ZAFMPB.js +53 -0
- package/dist/index.d.ts +78 -14
- package/dist/index.js +137 -47
- package/dist/tsl.d.ts +43 -31
- package/dist/tsl.js +94 -38
- package/dist/webgl.d.ts +31 -26
- package/dist/webgl.js +135 -33
- package/package.json +24 -21
- package/dist/chunk-SXTVASKG.js +0 -40
- package/dist/instance-playback-BrGBIKLe.d.ts +0 -109
package/README.md
CHANGED
|
@@ -1,23 +1,14 @@
|
|
|
1
1
|
# three-vat
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[](https://www.npmjs.com/package/three-vat)
|
|
5
|
-
[](./LICENSE)
|
|
6
|
-
|
|
7
|
-
Bake a glTF `AnimationClip` into GPU textures and animate **hundreds or thousands of instanced characters with zero per-frame CPU** — one draw call, no `SkinnedMesh` per character.
|
|
3
|
+

|
|
8
4
|
|
|
9
|
-
|
|
5
|
+
**[Open the live demo →](https://mikefernandez-pro.github.io/three-vat/)** and drag the count from 1 robot to 340. The draw calls do not move.
|
|
10
6
|
|
|
11
|
-
|
|
7
|
+
Bake a glTF `AnimationClip` into GPU textures and animate **hundreds or thousands of instanced characters with zero per-frame CPU** — one draw call per material, no `SkinnedMesh` per character. Works on `WebGLRenderer` and `WebGPURenderer`, from the same baked VAT.
|
|
12
8
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
Both pages carry a live draw-call counter and a view of the baked textures, with
|
|
17
|
-
cursors on the frame rows each instance is sampling. Source in
|
|
18
|
-
[`examples/`](./examples); they deploy from `main` on every push.
|
|
19
|
-
|
|
20
|
-
> **Status: `1.0.0`** — the npm badge above reads the registry, so it is the one to trust for what is actually published. The library is **three surfaces**, and all three work: the core baker (`three-vat`), the WebGL/GLSL decode (`three-vat/webgl`), and the WebGPU/TSL decode (`three-vat/tsl`). The two decode paths read one shared instance-playback contract and export the same `createVATMesh`, so nothing documented here is true on one renderer and false on the other. Where the renderers genuinely differ — shadow materials, the `time` clock's type, and what the TSL node builder needs to re-apply instancing — it is called out where it arises. The baker and the WebGL decode are covered by tests; the TSL path is tested structurally, because CI has no GPU, and that the two paths decode *pixel-identically* is a manual release gate ([`pnpm parity`](./docs/releasing.md)). See [`docs/DESIGN.md`](./docs/DESIGN.md) and [`docs/adr/`](./docs/adr) for the full rationale, [What 1.0 does not do](#what-10-does-not-do) for the deferred work, and [`CHANGELOG.md`](./CHANGELOG.md) for release notes.
|
|
9
|
+
[](https://github.com/MikeFernandez-Pro/three-vat/actions/workflows/ci.yml)
|
|
10
|
+
[](https://www.npmjs.com/package/three-vat)
|
|
11
|
+
[](./LICENSE)
|
|
21
12
|
|
|
22
13
|
## Install
|
|
23
14
|
|
|
@@ -25,346 +16,211 @@ cursors on the frame rows each instance is sampling. Source in
|
|
|
25
16
|
npm install three-vat three
|
|
26
17
|
```
|
|
27
18
|
|
|
28
|
-
`three` (>= 0.
|
|
19
|
+
`three` (>= 0.186) is a peer dependency.
|
|
29
20
|
|
|
30
|
-
##
|
|
21
|
+
## One crowd, start to finish
|
|
31
22
|
|
|
32
23
|
```ts
|
|
24
|
+
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
|
33
25
|
import { bakeVAT } from 'three-vat'
|
|
26
|
+
import { createVATMesh, getMaxTextureSize } from 'three-vat/webgl'
|
|
27
|
+
// WebGPURenderer? `from 'three-vat/tsl'` — that import is the only line that changes.
|
|
28
|
+
|
|
29
|
+
// The parts you already know — renderer, scene, camera, lights, clock — made
|
|
30
|
+
// however you usually make them. Nothing on this line is VAT-specific.
|
|
31
|
+
const { renderer, scene, camera, clock } = setUpYourScene()
|
|
32
|
+
|
|
33
|
+
const gltf = await new GLTFLoader().loadAsync('/robot.glb')
|
|
34
|
+
|
|
35
|
+
// Bake once, at load. Pass the subtree root — `gltf.scene` — and not a mesh
|
|
36
|
+
// inside it: the bake unit is the whole subtree, merged and recorded in root
|
|
37
|
+
// space. This same call takes
|
|
38
|
+
// - a skinned character (Mixamo, Sketchfab, `Soldier.glb`)
|
|
39
|
+
// - a morph-target mesh
|
|
40
|
+
// - a hierarchy of rigid, node-animated parts (three's `RobotExpressive`)
|
|
41
|
+
// - any mix of those in one subtree
|
|
42
|
+
// because a VAT records where a vertex ended up and never how it got there.
|
|
43
|
+
// Nothing to classify, and no variant of this call to go looking for.
|
|
44
|
+
const vat = bakeVAT(gltf.scene, gltf.animations, {
|
|
45
|
+
fps: 30,
|
|
46
|
+
maxTextureSize: getMaxTextureSize(renderer), // this GPU's real ceiling
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// One entry per character: which clip it plays, when it started, its rate.
|
|
50
|
+
// Everything but `startTime` is optional — a clip baked from a configured
|
|
51
|
+
// `AnimationAction` carries its own loop, repetition count, end behaviour and
|
|
52
|
+
// speed, and an instance overrides only what it wants to differ.
|
|
53
|
+
const instances = Array.from({ length: 500 }, (_, i) => ({
|
|
54
|
+
clip: vat.clips[i % vat.clips.length],
|
|
55
|
+
startTime: -Math.random() * 2, // began a moment ago, so the crowd is not in lockstep
|
|
56
|
+
speed: 0.9 + Math.random() * 0.2,
|
|
57
|
+
}))
|
|
58
|
+
|
|
59
|
+
// The crowd: one InstancedMesh, its geometry and materials already decoding the
|
|
60
|
+
// VAT, plus the clock that drives every instance.
|
|
61
|
+
const { mesh, time } = createVATMesh(vat, instances)
|
|
62
|
+
mesh.castShadow = mesh.receiveShadow = true
|
|
63
|
+
scene.add(mesh)
|
|
64
|
+
|
|
65
|
+
// Where each character stands is yours — the library never guesses a layout.
|
|
66
|
+
for (let i = 0; i < instances.length; i++) mesh.setMatrixAt(i, matrixFor(i))
|
|
67
|
+
mesh.instanceMatrix.needsUpdate = true
|
|
68
|
+
mesh.computeBoundingSphere() // or frustumCulled = false, if matrices move every frame
|
|
34
69
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
70
|
+
renderer.setAnimationLoop(() => {
|
|
71
|
+
time.value = clock.getElapsedTime() // the whole per-frame cost of the animation
|
|
72
|
+
renderer.render(scene, camera)
|
|
73
|
+
})
|
|
39
74
|
```
|
|
40
75
|
|
|
41
|
-
|
|
76
|
+
Two things worth knowing the first time:
|
|
42
77
|
|
|
43
|
-
|
|
78
|
+
- **Render `vat.geometry`, not your source mesh.** The merged vertex ordering is
|
|
79
|
+
the baker's, and the textures are indexed by it. `createVATMesh` does this for
|
|
80
|
+
you; by hand, clone that geometry and no other.
|
|
81
|
+
- **Materials are never merged.** A 500-robot crowd with 3 materials is 3 draw
|
|
82
|
+
calls — not 1, and not 500. VAT collapses instance count, not material count.
|
|
44
83
|
|
|
45
|
-
|
|
46
|
-
|
|
84
|
+
<details>
|
|
85
|
+
<summary><b>Does it work with my model?</b></summary>
|
|
47
86
|
|
|
48
|
-
|
|
87
|
+
If `GLTFLoader` loads it and it has an `AnimationClip`, yes — the four shapes
|
|
88
|
+
listed in the snippet above, and any mix of them, through that one call.
|
|
49
89
|
|
|
50
|
-
|
|
51
|
-
|
|
90
|
+
The bake unit is the **subtree**, not the mesh
|
|
91
|
+
([ADR-0008](./docs/adr/0008-a-vat-bakes-a-posed-subtree-not-a-skinnedmesh.md)),
|
|
92
|
+
which is where the one real mistake lives: pass `gltf.scene`, or the node you
|
|
93
|
+
want animated, never a `SkinnedMesh` you fished out of it.
|
|
52
94
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
95
|
+
Positions bake exactly under any rig; normals match what three's own skinning
|
|
96
|
+
shader draws, which is approximate under non-uniform bone scale — `bakeVAT`
|
|
97
|
+
warns once and names the bone. A rest-pose track such as Mixamo's `TPose` bakes
|
|
98
|
+
to a frozen band and reports it as a near-zero `clip.maxDelta`: filter those out
|
|
99
|
+
of `gltf.animations` rather than spending texture rows on them.
|
|
58
100
|
|
|
59
|
-
|
|
101
|
+
</details>
|
|
60
102
|
|
|
61
|
-
|
|
103
|
+
<details>
|
|
104
|
+
<summary><b>Per-clip playback defaults</b></summary>
|
|
62
105
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
```
|
|
106
|
+
`bakeVAT` takes an `AnimationClip` **or** an `AnimationAction`, in the same
|
|
107
|
+
array. Configure the action the way three already taught you, and every instance
|
|
108
|
+
of that clip inherits it — and overrides any field it names.
|
|
67
109
|
|
|
68
110
|
```ts
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
mesh.castShadow = mesh.receiveShadow = true
|
|
72
|
-
scene.add(mesh)
|
|
111
|
+
const death = mixer.clipAction(deathClip)
|
|
112
|
+
death.loop = THREE.LoopOnce
|
|
73
113
|
|
|
74
|
-
|
|
75
|
-
for (let i = 0; i < instances.length; i++) mesh.setMatrixAt(i, matrix)
|
|
76
|
-
mesh.instanceMatrix.needsUpdate = true
|
|
77
|
-
mesh.computeBoundingSphere() // or frustumCulled = false, if matrices change every frame
|
|
78
|
-
|
|
79
|
-
// per frame:
|
|
80
|
-
time.value = clock.elapsedTime
|
|
114
|
+
const vat = bakeVAT(gltf.scene, [walkClip, death, idleClip])
|
|
81
115
|
```
|
|
82
116
|
|
|
83
|
-
|
|
117
|
+
`loop`, `repetitions` and `timeScale` are read; `time` and `paused` are ignored,
|
|
118
|
+
because a VAT has no playhead of its own to seed; a non-unit `weight` or an
|
|
119
|
+
additive `blendMode` throws, because one baked band cannot be several actions
|
|
120
|
+
blended at once.
|
|
84
121
|
|
|
85
|
-
|
|
122
|
+
**Both inputs clamp, where three rewinds.** `clampWhenFinished` defaults to
|
|
123
|
+
`false` in three, which means an untouched action says nothing about the end
|
|
124
|
+
either — and a crowd's answer to nothing is to hold the last frame, because a
|
|
125
|
+
corpse standing back up is the worse default. So the end mode is not read off
|
|
126
|
+
the action; an instance asks for three's rewind with `endMode: EndMode.Rewind`,
|
|
127
|
+
which is the finer grain anyway.
|
|
86
128
|
|
|
87
|
-
|
|
129
|
+
[Declaring the defaults at the bake](./docs/usage.md#declaring-the-defaults-at-the-bake).
|
|
88
130
|
|
|
89
|
-
|
|
131
|
+
</details>
|
|
90
132
|
|
|
91
133
|
<details>
|
|
92
|
-
<summary>
|
|
93
|
-
|
|
94
|
-
```ts
|
|
95
|
-
import { addVATInstanceAttributes } from 'three-vat'
|
|
96
|
-
import { createVATUniforms, createVATDepthMaterial, patchVATMaterial } from 'three-vat/webgl'
|
|
97
|
-
|
|
98
|
-
const uniforms = createVATUniforms()
|
|
99
|
-
|
|
100
|
-
// vat.geometry already carries the all-frames bounding box/sphere, so instances
|
|
101
|
-
// never cull mid-animation.
|
|
102
|
-
const geometry = vat.geometry.clone()
|
|
103
|
-
// Instance playback — `{ clip, timeOffset, speed }` per instance — is a core
|
|
104
|
-
// contract both decode paths read, not a WebGL-only concept.
|
|
105
|
-
addVATInstanceAttributes(geometry, instances)
|
|
106
|
-
|
|
107
|
-
// One patched material per source material, sharing one clock.
|
|
108
|
-
const materials = vat.materials.map((source) => {
|
|
109
|
-
const material = source.clone()
|
|
110
|
-
patchVATMaterial(material, vat, uniforms)
|
|
111
|
-
return material
|
|
112
|
-
})
|
|
134
|
+
<summary><b>Upgrading from 1.x</b></summary>
|
|
113
135
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
mesh.customDistanceMaterial = patchVATMaterial(new THREE.MeshDistanceMaterial(), vat, uniforms)
|
|
136
|
+
The breaks that reach a 1.x caller, no shims — the package had no users on the
|
|
137
|
+
1.x playback contract, so 2.0 spells it one way rather than two. The full list
|
|
138
|
+
is [the CHANGELOG's 2.0.0 entry](./CHANGELOG.md).
|
|
118
139
|
|
|
119
|
-
|
|
120
|
-
|
|
140
|
+
```ts
|
|
141
|
+
{ clip, timeOffset: 1.4, speed: 1 } // 1.x — every field required
|
|
142
|
+
{ clip, startTime: -1.4 } // 2.0 — desync is a start time in the past
|
|
121
143
|
```
|
|
122
144
|
|
|
145
|
+
`timeOffset` is gone (`startTime: -timeOffset / speed`), and with it
|
|
146
|
+
`aTimeOffset`. The pack is three `vec4`s — clip, playback, fade — in a
|
|
147
|
+
**playback texture** keyed by the instance's logical index, not instanced
|
|
148
|
+
attributes, which are indexed by the *drawn* slot:
|
|
149
|
+
`addInstancedVATAttributes` is removed, `createVATPlaybackTexture(instances)`
|
|
150
|
+
replaces `addVATInstanceAttributes`, and `setVATInstance(playback, id, instance)`
|
|
151
|
+
takes that texture — `createVATMesh` returns it as `playback` — rather than a
|
|
152
|
+
geometry. A VAT texture's `image.data` is now opaque. Node 20, where
|
|
153
|
+
1.x said 18, and `three >= 0.186`, where `batchIndirectIndex` is exported;
|
|
154
|
+
browsers are unaffected.
|
|
155
|
+
|
|
156
|
+
New, and none of it breaking: per-instance loop modes, one-shots,
|
|
157
|
+
`setVATInstance` after the crowd is built, and a crowd on a `BatchedMesh`.
|
|
158
|
+
[By hand, on either path](./docs/usage.md#by-hand-on-either-path).
|
|
159
|
+
|
|
123
160
|
</details>
|
|
124
161
|
|
|
125
162
|
<details>
|
|
126
|
-
<summary>
|
|
163
|
+
<summary><b>Going bigger: texture limits, bake cost, Web Workers</b></summary>
|
|
127
164
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
import { vatNodes } from 'three-vat/tsl'
|
|
132
|
-
|
|
133
|
-
const geometry = vat.geometry.clone()
|
|
134
|
-
addVATInstanceAttributes(geometry, instances)
|
|
135
|
-
|
|
136
|
-
// The mesh is built first, because the decode is built from it.
|
|
137
|
-
const material = new MeshStandardNodeMaterial()
|
|
138
|
-
const mesh = new THREE.InstancedMesh(geometry, material, instances.length)
|
|
139
|
-
|
|
140
|
-
// `geometry`: each instance plays its own clip, phase and rate.
|
|
141
|
-
// `instancedMesh`: the decode re-applies this mesh's instancing itself, because
|
|
142
|
-
// three applies the instance matrix to `positionLocal` *before* it reads
|
|
143
|
-
// `positionNode` — so the delta has to be added in the geometry's own space and
|
|
144
|
-
// instanced afterwards. Omit it only for a single, non-instanced mesh.
|
|
145
|
-
const { positionNode, time } = vatNodes(vat, { geometry, instancedMesh: mesh })
|
|
146
|
-
material.positionNode = positionNode
|
|
147
|
-
|
|
148
|
-
// per frame:
|
|
149
|
-
time.value = clock.elapsedTime
|
|
150
|
-
```
|
|
165
|
+
The VAT is one `vertexCount` × `totalFrames` texture pair, so both axes hit the
|
|
166
|
+
GPU's texture ceiling. Always pass `maxTextureSize: getMaxTextureSize(renderer)`
|
|
167
|
+
as above: the default is a desktop-shaped guess, and mobile is often 4096.
|
|
151
168
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
built in the *fragment* stage and expected in view space, which is neither where
|
|
156
|
-
nor what a per-vertex, object-space VAT normal is.
|
|
169
|
+
The bake is CPU work done once at load — about 100 ms for the demo's robot, and
|
|
170
|
+
seconds for a 20k-vertex skinned character with many clips. It never touches the
|
|
171
|
+
renderer, so it moves into a Web Worker as-is.
|
|
157
172
|
|
|
158
|
-
|
|
173
|
+
**[docs/usage.md](./docs/usage.md)** has the measured bake-cost table, the
|
|
174
|
+
worker recipe, the draw-call arithmetic, and the primitives underneath
|
|
175
|
+
`createVATMesh` for when you are not rendering onto a plain `InstancedMesh`.
|
|
159
176
|
|
|
160
177
|
</details>
|
|
161
178
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
A VAT is produced exactly one way — `bakeVAT` at runtime
|
|
165
|
-
([ADR-0010](./docs/adr/0010-drop-the-offline-format-runtime-bake-is-the-library.md)).
|
|
166
|
-
There is no file format to write or load, so the one cost to budget is the bake
|
|
167
|
-
itself, once at load. Measured on `three@0.185.1` (Node, Apple Silicon, mean of
|
|
168
|
-
3 runs after warm-up):
|
|
169
|
-
|
|
170
|
-
| Asset | Clips | fps | Rows | Bake |
|
|
171
|
-
|---|---|---|---|---|
|
|
172
|
-
| `RobotExpressive` (rigid, 7 214 v) | 3 (the demo) | 30 | 158 | 97 ms |
|
|
173
|
-
| `RobotExpressive` | 5 | 30 | 313 | 178 ms |
|
|
174
|
-
| `RobotExpressive` | 14 (all) | 30 | 585 | 330 ms |
|
|
175
|
-
| `RobotExpressive` | 14 (all) | 60 | 1 168 | 659 ms |
|
|
176
|
-
| `Soldier` (skinned, 7 434 v) | 4 (all) | 30 | 113 | 250 ms |
|
|
177
|
-
| `Soldier` (skinned) | 4 (all) | 60 | 224 | 492 ms |
|
|
178
|
-
|
|
179
|
-
Cost is linear in `vertices × frames`, and **a skinned vertex costs ~4× per
|
|
180
|
-
frame row what a rigid one does** (2.2 ms/row here vs 0.56) — the four-weight
|
|
181
|
-
bone blend is the hot loop. So budget by rows, and halve `fps` before you cut
|
|
182
|
-
clips. A 20k-vertex skinned character with 6 clips at 30 fps extrapolates to
|
|
183
|
-
~1.8 s on this machine and several seconds on a mid-range phone — enough to
|
|
184
|
-
matter, and the point at which the bake belongs off the main thread.
|
|
185
|
-
|
|
186
|
-
It can go there as-is: **the baker is pure CPU and never touches the renderer**,
|
|
187
|
-
so it runs in a Web Worker, with the texel buffers transferred back at no copy
|
|
188
|
-
cost. The worker loads the glTF and bakes; the main thread rebuilds the
|
|
189
|
-
textures and the geometry from plain buffers.
|
|
179
|
+
<details>
|
|
180
|
+
<summary><b>What it does not do</b></summary>
|
|
190
181
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
182
|
+
No clip crossfade (an instance blends out of a frozen pose, not between two
|
|
183
|
+
clips that are both playing), no LOD, no baking CLI or file
|
|
184
|
+
format, no React/drei binding, glTF input only. Each is a decision rather than a
|
|
185
|
+
gap, and each is written up with its reasoning in
|
|
186
|
+
**[docs/usage.md](./docs/usage.md#what-10-does-not-do)**, alongside the
|
|
187
|
+
trade-offs against `SkinnedMesh` and bone-texture instancing.
|
|
195
188
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const normal = vat.normalTexture.image.data as Float32Array
|
|
202
|
-
const index = vat.geometry.getIndex()
|
|
203
|
-
|
|
204
|
-
self.postMessage(
|
|
205
|
-
{
|
|
206
|
-
position,
|
|
207
|
-
normal,
|
|
208
|
-
// Every attribute the merge produced — position, normal, and uv/color
|
|
209
|
-
// when the source had them. Carry each itemSize rather than guessing it.
|
|
210
|
-
attributes: Object.fromEntries(
|
|
211
|
-
Object.entries(vat.geometry.attributes).map(([name, a]) => [
|
|
212
|
-
name,
|
|
213
|
-
{ array: a.array, itemSize: a.itemSize },
|
|
214
|
-
]),
|
|
215
|
-
),
|
|
216
|
-
index: index?.array,
|
|
217
|
-
groups: vat.geometry.groups,
|
|
218
|
-
clips: vat.clips,
|
|
219
|
-
bounds: { min: vat.bounds.min.toArray(), max: vat.bounds.max.toArray() },
|
|
220
|
-
vertexCount: vat.vertexCount,
|
|
221
|
-
totalFrames: vat.totalFrames,
|
|
222
|
-
},
|
|
223
|
-
// Transferred, not copied — the texel buffers are the large part.
|
|
224
|
-
[position.buffer, normal.buffer],
|
|
225
|
-
)
|
|
226
|
-
}
|
|
227
|
-
```
|
|
189
|
+
Both decode paths — GLSL on `WebGLRenderer`, TSL on `WebGPURenderer` — read one
|
|
190
|
+
shared instance-playback contract and export the same `createVATMesh`, so
|
|
191
|
+
nothing documented here is true on one renderer and false on the other. That the
|
|
192
|
+
two decode *pixel-identically* is a manual gate before every release
|
|
193
|
+
([docs/releasing.md](./docs/releasing.md)).
|
|
228
194
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
// the main thread can ask the GPU for its limits.
|
|
238
|
-
const worker = new Worker(new URL('./bake.worker.ts', import.meta.url), { type: 'module' })
|
|
239
|
-
|
|
240
|
-
function bakeInWorker(url: string, fps = 30): Promise<VAT> {
|
|
241
|
-
return new Promise((resolve) => {
|
|
242
|
-
worker.onmessage = ({ data: d }) => {
|
|
243
|
-
const geometry = new THREE.BufferGeometry()
|
|
244
|
-
for (const [name, { array, itemSize }] of Object.entries(d.attributes)) {
|
|
245
|
-
geometry.setAttribute(name, new THREE.BufferAttribute(array, itemSize))
|
|
246
|
-
}
|
|
247
|
-
if (d.index) geometry.setIndex(new THREE.BufferAttribute(d.index, 1))
|
|
248
|
-
for (const g of d.groups) geometry.addGroup(g.start, g.count, g.materialIndex)
|
|
249
|
-
|
|
250
|
-
const bounds = new THREE.Box3(
|
|
251
|
-
new THREE.Vector3(...d.bounds.min),
|
|
252
|
-
new THREE.Vector3(...d.bounds.max),
|
|
253
|
-
)
|
|
254
|
-
// The union-of-all-frames volume, or a deformed crowd culls mid-animation.
|
|
255
|
-
geometry.boundingBox = bounds.clone()
|
|
256
|
-
geometry.boundingSphere = bounds.getBoundingSphere(new THREE.Sphere())
|
|
257
|
-
|
|
258
|
-
resolve({
|
|
259
|
-
positionTexture: makeVATTexture(d.position, d.vertexCount, d.totalFrames),
|
|
260
|
-
normalTexture: makeVATTexture(d.normal, d.vertexCount, d.totalFrames),
|
|
261
|
-
geometry,
|
|
262
|
-
// One per group, in `materialIndex` order — see the note below.
|
|
263
|
-
materials: d.groups.map(() => new THREE.MeshStandardMaterial()),
|
|
264
|
-
clips: d.clips,
|
|
265
|
-
bounds,
|
|
266
|
-
vertexCount: d.vertexCount,
|
|
267
|
-
totalFrames: d.totalFrames,
|
|
268
|
-
encoding: 'delta',
|
|
269
|
-
})
|
|
270
|
-
}
|
|
271
|
-
worker.postMessage({ url, fps, maxTextureSize: getMaxTextureSize(renderer) })
|
|
272
|
-
})
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
const { mesh, time } = createVATMesh(await bakeInWorker('/robot.glb'), instances)
|
|
195
|
+
</details>
|
|
196
|
+
|
|
197
|
+
<details>
|
|
198
|
+
<summary><b>Development</b></summary>
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
pnpm i
|
|
202
|
+
pnpm run dev # opens the demo — this and the line above are the whole setup
|
|
276
203
|
```
|
|
277
204
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
- **Materials.** A `Material` holds textures and GPU state, so it has to be
|
|
281
|
-
built on the main thread. The placeholder above is a stand-in: `materials`
|
|
282
|
-
must have one entry per `geometry.groups[].materialIndex`, or every group past
|
|
283
|
-
the first renders undefined. For a glTF's real materials, load it a second
|
|
284
|
-
time on the main thread (the browser serves it from cache) and take
|
|
285
|
-
`gltf.scene`'s materials in the same order the merge recorded them.
|
|
286
|
-
- **The renderer's `maxTextureSize`**, which only the main thread can ask for —
|
|
287
|
-
read it there and pass it in, as the snippet does.
|
|
288
|
-
|
|
289
|
-
A `bakeVATInWorker` helper is deferred, for the reason in
|
|
290
|
-
[What 1.0 does not do](#what-10-does-not-do).
|
|
291
|
-
|
|
292
|
-
## Trade-offs
|
|
293
|
-
|
|
294
|
-
- **vs N × `SkinnedMesh`:** N draw calls + per-frame CPU skeletons → VAT is 1 draw call, zero per-frame CPU, 2 texel fetches per vertex. The headline.
|
|
295
|
-
- **vs bone-texture instancing:** smaller textures and supports blending, but more fetches per vertex. VAT also captures morph/non-skeletal deformation for free.
|
|
296
|
-
- **VAT limits:** no runtime IK/blending, discrete frames, memory cost (`verts × frames × 16 B × 2` textures). No clip crossfade — see [What 1.0 does not do](#what-10-does-not-do).
|
|
297
|
-
- **Skinned normals:** positions bake exactly under any rig. Normals reproduce what three's own skinning shader renders — linear-blend skinning transforms a normal by the skin matrix rather than its inverse-transpose, exact for rigid and uniformly-scaled bones, an approximation otherwise. Non-uniform bone scale is where that shows, so `bakeVAT` warns once, naming the bone.
|
|
298
|
-
|
|
299
|
-
## What 1.0 does not do
|
|
300
|
-
|
|
301
|
-
Named rather than left to be discovered. None of these is a known defect; each
|
|
302
|
-
is a decision, with the reasoning recorded where it was made.
|
|
303
|
-
|
|
304
|
-
- **No clip crossfade.** An instance cuts between clips, it does not blend.
|
|
305
|
-
Crossfade doubles the per-vertex texel fetches (2 → 4) and adds per-instance
|
|
306
|
-
transition state, which is not worth spending before the single-clip decode is
|
|
307
|
-
proven on both paths ([ADR-0007](./docs/adr/0007-v1-scope-library-only.md)).
|
|
308
|
-
The instance-attribute layout reserves room for a second clip index, so it
|
|
309
|
-
stays a non-breaking addition.
|
|
310
|
-
- **No LOD.** Every instance samples the VAT at full vertex count, whatever its
|
|
311
|
-
distance.
|
|
312
|
-
- **No `npx vat-bake` CLI, and no file format for it to write.** The offline
|
|
313
|
-
format was removed in 1.0 and the runtime bake is the only way to produce a
|
|
314
|
-
VAT ([ADR-0010](./docs/adr/0010-drop-the-offline-format-runtime-bake-is-the-library.md));
|
|
315
|
-
the design a CLI would build on is kept on record in the superseded
|
|
316
|
-
[ADR-0003](./docs/adr/0003-offline-format-float16-bin-plus-manifest.md), so
|
|
317
|
-
reviving it is a decision rather than a fresh design problem.
|
|
318
|
-
- **No React/drei hook or component.** A downstream contribution rather than a
|
|
319
|
-
library surface, and `createVATMesh` is what makes it thin enough to be one.
|
|
320
|
-
- **No `bakeVATInWorker` helper.** 1.0 ships the [recipe](#bake-cost-and-baking-in-a-web-worker)
|
|
321
|
-
instead: adding a second, async way to bake while the API stabilizes is the
|
|
322
|
-
two-entry-point split [ADR-0008](./docs/adr/0008-a-vat-bakes-a-posed-subtree-not-a-skinnedmesh.md)
|
|
323
|
-
refused, and demand should decide it.
|
|
324
|
-
- **glTF/GLB input only**, and multi-material meshes are rejected rather than
|
|
325
|
-
split by geometry group — `GLTFLoader` emits one mesh per primitive, so the
|
|
326
|
-
case is unreachable through the only input surface there is.
|
|
327
|
-
|
|
328
|
-
## Development
|
|
205
|
+
Three more verbs, and that is the table:
|
|
329
206
|
|
|
330
207
|
```bash
|
|
331
|
-
pnpm
|
|
332
|
-
pnpm
|
|
333
|
-
pnpm
|
|
334
|
-
pnpm test:examples # the demo's own suite (crowd layout, page/bundle shape)
|
|
335
|
-
pnpm typecheck
|
|
336
|
-
pnpm typecheck:examples
|
|
337
|
-
pnpm build
|
|
338
|
-
pnpm example # serves the demo pages in examples/ (model bundled)
|
|
339
|
-
pnpm parity # cross-path pixel-diff gate — needs a GPU and a WebGPU browser
|
|
208
|
+
pnpm test # every suite: the baker core, the release suite, the demo's own
|
|
209
|
+
pnpm typecheck # all three tsconfigs: library, release suite, demo
|
|
210
|
+
pnpm build # the published library (`pnpm run build:watch` to watch)
|
|
340
211
|
```
|
|
341
212
|
|
|
342
|
-
`examples/` is
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
([
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
at the WebGL demo when there is none — `WebGPURenderer` would otherwise fall
|
|
354
|
-
back to its WebGL backend and quietly draw the WebGPU demo through GLSL.
|
|
355
|
-
|
|
356
|
-
`pnpm parity` is the one check that is not in CI and not optional. It renders
|
|
357
|
-
one bake through both decode paths at the same camera, lights and time and
|
|
358
|
-
compares the frames pixel by pixel — the only thing that can catch a decode
|
|
359
|
-
subtly wrong on one path only, and the only thing that needs a real GPU on both
|
|
360
|
-
backends. It is a **required gate before publishing**, not a test; see
|
|
361
|
-
[docs/releasing.md](./docs/releasing.md) for what it checks and how to read a
|
|
362
|
-
failure.
|
|
363
|
-
|
|
364
|
-
The suite is green on a fresh clone with no network: the real-asset tests skip
|
|
365
|
-
when their asset is missing. Run `pnpm fetch:test-assets` before touching the
|
|
366
|
-
baker, so a real skinned character is actually being baked — see
|
|
367
|
-
[docs/test-assets.md](./docs/test-assets.md).
|
|
213
|
+
`examples/` is the demo — one page per renderer, self-contained on purpose
|
|
214
|
+
([ADR-0011](./docs/adr/0011-one-example-per-renderer-duplicated-on-purpose.md)),
|
|
215
|
+
deployed from `main` on every push. Release steps are `node` invocations rather
|
|
216
|
+
than table entries, the parity gate among them and required
|
|
217
|
+
([docs/releasing.md](./docs/releasing.md)). The suite is green on a fresh clone
|
|
218
|
+
with no network: real-asset tests skip when their asset is missing, so fetch it
|
|
219
|
+
before touching the baker ([docs/test-assets.md](./docs/test-assets.md)).
|
|
220
|
+
|
|
221
|
+
</details>
|
|
222
|
+
|
|
223
|
+
Deeper: [docs/](./docs) · [CHANGELOG.md](./CHANGELOG.md) · [live WebGPU demo](https://mikefernandez-pro.github.io/three-vat/webgpu_crowd.html)
|
|
368
224
|
|
|
369
225
|
## License
|
|
370
226
|
|