three-vat 0.1.0 → 0.3.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 +49 -20
- package/dist/index.d.ts +34 -9
- package/dist/index.js +201 -42
- package/dist/tsl.d.ts +12 -2
- package/dist/tsl.js +9 -1
- package/dist/{types-DVtQvVjk.d.ts → types-wVmIj2tC.d.ts} +20 -2
- package/dist/webgl.d.ts +12 -3
- package/dist/webgl.js +6 -1
- package/package.json +12 -11
package/README.md
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# three-vat
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/three-vat)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
|
|
3
6
|
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.
|
|
4
7
|
|
|
5
|
-
VAT (Vertex Animation Texture) is battle-tested in Unity/Unreal but has been a gap on the three.js side: only scattered demos, no maintained package, nothing in drei. `three-vat` bakes the VAT **at runtime, directly from the glTF** — so any Mixamo/Sketchfab asset works with zero pipeline
|
|
8
|
+
VAT (Vertex Animation Texture) is battle-tested in Unity/Unreal but has been a gap on the three.js side: only scattered demos, no maintained package, nothing in drei. `three-vat` bakes the VAT **at runtime, directly from the glTF** — so any Mixamo/Sketchfab asset works with zero pipeline, and there is exactly one way to produce a VAT.
|
|
6
9
|
|
|
7
|
-
> **Status:
|
|
10
|
+
> **Status: early release — `0.3.0`, published on npm.** The baker core (skinning, morph targets **and** rigid node-animated subtrees) and WebGL decode are covered by tests. The TSL/WebGPU path ships but is verified visually, not yet by automated tests. See [`docs/DESIGN.md`](./docs/DESIGN.md) and [`docs/adr/`](./docs/adr) for the full rationale, and [`CHANGELOG.md`](./CHANGELOG.md) for release notes.
|
|
8
11
|
|
|
9
12
|
## Install
|
|
10
13
|
|
|
@@ -19,10 +22,28 @@ npm install three-vat three
|
|
|
19
22
|
```ts
|
|
20
23
|
import { bakeVAT } from 'three-vat'
|
|
21
24
|
|
|
22
|
-
// gltf loaded via GLTFLoader
|
|
25
|
+
// gltf loaded via GLTFLoader — pass the subtree root, not a mesh.
|
|
23
26
|
const clips = gltf.animations.filter((c) => c.name !== 'TPose')
|
|
24
|
-
const vat = bakeVAT(gltf.scene,
|
|
25
|
-
// vat: { positionTexture, normalTexture,
|
|
27
|
+
const vat = bakeVAT(gltf.scene, clips, { fps: 30 })
|
|
28
|
+
// vat: { positionTexture, normalTexture, geometry, materials, clips, bounds, ... }
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The bake unit is the **whole subtree**, merged into one vertex set and recorded in root space ([ADR-0008](./docs/adr/0008-a-vat-bakes-a-posed-subtree-not-a-skinnedmesh.md)). A VAT only records *where a vertex ended up*, never how it got there, so one call handles a single `SkinnedMesh`, a morph-target mesh, a hierarchy of rigid node-animated parts (three.js `RobotExpressive`), or any mix — with no classification by the caller.
|
|
32
|
+
|
|
33
|
+
Two consequences worth knowing up front:
|
|
34
|
+
|
|
35
|
+
- **Render `vat.geometry`, not your source mesh.** The merged vertex ordering is the baker's, and the textures are indexed by it.
|
|
36
|
+
- **Materials are never merged.** `vat.materials` lines up with `vat.geometry.groups`, giving one draw call per material. VAT collapses *instance* count, not *material* count — a 500-robot crowd with 3 materials is 3 draw calls, not 1 and not 500.
|
|
37
|
+
|
|
38
|
+
The VAT is a flat `vertexCount` × `totalFrames` texture pair, so **both** axes are bounded by the GPU's max texture dimension. The baker is renderer-agnostic and defaults to a conservative `16384`; pass the real limit whenever you have a renderer, or a bake that allocates on desktop can fail on mobile (commonly 4096–8192):
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { getMaxTextureSize } from 'three-vat/webgl' // or 'three-vat/tsl'
|
|
42
|
+
|
|
43
|
+
const vat = bakeVAT(gltf.scene, clips, {
|
|
44
|
+
fps: 30,
|
|
45
|
+
maxTextureSize: getMaxTextureSize(renderer),
|
|
46
|
+
})
|
|
26
47
|
```
|
|
27
48
|
|
|
28
49
|
## Render a crowd — WebGL (`WebGLRenderer`)
|
|
@@ -32,15 +53,19 @@ import { addInstancedVATAttributes, createVATUniforms, createVATDepthMaterial, p
|
|
|
32
53
|
|
|
33
54
|
const uniforms = createVATUniforms()
|
|
34
55
|
|
|
35
|
-
|
|
56
|
+
// vat.geometry already carries the all-frames bounding box/sphere, so instances
|
|
57
|
+
// never cull mid-animation.
|
|
58
|
+
const geometry = vat.geometry.clone()
|
|
36
59
|
addInstancedVATAttributes(geometry, instances) // instances: { clip, timeOffset, speed }[]
|
|
37
|
-
geometry.boundingBox = vat.bounds.clone() // union of all frames — avoids culling pops
|
|
38
|
-
geometry.boundingSphere = vat.bounds.getBoundingSphere(new THREE.Sphere())
|
|
39
60
|
|
|
40
|
-
|
|
41
|
-
|
|
61
|
+
// One patched material per source material, sharing one clock.
|
|
62
|
+
const materials = vat.materials.map((source) => {
|
|
63
|
+
const material = source.clone()
|
|
64
|
+
patchVATMaterial(material, vat, uniforms)
|
|
65
|
+
return material
|
|
66
|
+
})
|
|
42
67
|
|
|
43
|
-
const mesh = new THREE.InstancedMesh(geometry,
|
|
68
|
+
const mesh = new THREE.InstancedMesh(geometry, materials, instances.length)
|
|
44
69
|
mesh.customDepthMaterial = createVATDepthMaterial(vat, uniforms) // correct instanced shadows
|
|
45
70
|
mesh.castShadow = mesh.receiveShadow = true
|
|
46
71
|
|
|
@@ -65,17 +90,21 @@ time.value = clock.elapsedTime
|
|
|
65
90
|
|
|
66
91
|
Shadows just work on the TSL path (`positionNode` feeds the depth pass). v1 plays one clip per material with per-instance phase desync; use the WebGL path for mixed-clip crowds.
|
|
67
92
|
|
|
68
|
-
## Offline
|
|
93
|
+
## Offline format — deprecated, removed in 1.0
|
|
69
94
|
|
|
70
|
-
|
|
71
|
-
|
|
95
|
+
> **Do not use `serializeVAT` / `loadVAT`.** They still ship in `0.3.0` for
|
|
96
|
+
> compatibility and are removed in `1.0`
|
|
97
|
+
> ([ADR-0010](./docs/adr/0010-drop-the-offline-format-runtime-bake-is-the-library.md)).
|
|
72
98
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
99
|
+
The format stores the texel buffers and a manifest, but *not* the geometry. Since
|
|
100
|
+
`0.3.0` a bake merges the whole subtree into a new vertex set and the textures are
|
|
101
|
+
indexed by that ordering, so a serialized VAT can only be rendered by reloading the
|
|
102
|
+
source glTF and re-running the merge — the work the file existed to save. A VAT
|
|
103
|
+
restored by `loadVAT` has no `geometry` or `materials`, so it is **not**
|
|
104
|
+
interchangeable with a freshly-baked one, whatever earlier releases claimed.
|
|
77
105
|
|
|
78
|
-
|
|
106
|
+
Bake at runtime instead. The baker is pure CPU and touches no renderer, so if bake
|
|
107
|
+
time hurts on load, run `bakeVAT` in a Web Worker and transfer the texel buffers back.
|
|
79
108
|
|
|
80
109
|
## Trade-offs
|
|
81
110
|
|
|
@@ -90,7 +119,7 @@ pnpm install
|
|
|
90
119
|
pnpm test # baker core — pure CPU, no GPU needed
|
|
91
120
|
pnpm typecheck
|
|
92
121
|
pnpm build
|
|
93
|
-
pnpm example # runs the crowd demo in examples/ (
|
|
122
|
+
pnpm example # runs the robot-crowd demo in examples/ (model bundled)
|
|
94
123
|
```
|
|
95
124
|
|
|
96
125
|
## License
|
package/dist/index.d.ts
CHANGED
|
@@ -1,19 +1,42 @@
|
|
|
1
|
-
import { Object3D,
|
|
2
|
-
import { V as
|
|
1
|
+
import { Object3D, AnimationClip, TypedArray, TextureDataType, DataTexture } from 'three';
|
|
2
|
+
import { B as BakedVAT, V as VATClip, a as VAT } from './types-wVmIj2tC.js';
|
|
3
3
|
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Conservative fallback texture-dimension cap, used when the caller does not
|
|
6
|
+
* pass `maxTextureSize`. This is the WebGL2 *spec floor for high-end desktop*,
|
|
7
|
+
* not a guarantee — plenty of mobile GPUs report 4096 or 8192. The baker is
|
|
8
|
+
* renderer-agnostic by design (it runs in Node, and in a Web Worker), so it cannot
|
|
9
|
+
* query the real limit itself: pass `getMaxTextureSize(renderer)` from
|
|
10
|
+
* `three-vat/webgl` or `three-vat/tsl` whenever a renderer exists.
|
|
11
|
+
*/
|
|
5
12
|
declare const MAX_TEXTURE_SIZE = 16384;
|
|
6
13
|
interface BakeOptions {
|
|
7
14
|
/** Sample rate in frames per second. Default `30`. */
|
|
8
15
|
fps?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Largest texture dimension the target GPU accepts. Both VAT axes are checked
|
|
18
|
+
* against it: `vertexCount` (width) and `totalFrames` (height). Defaults to
|
|
19
|
+
* {@link MAX_TEXTURE_SIZE}; pass the renderer's real limit to avoid baking a
|
|
20
|
+
* VAT that allocates on your desktop and fails on a phone.
|
|
21
|
+
*/
|
|
22
|
+
maxTextureSize?: number;
|
|
9
23
|
}
|
|
10
24
|
/**
|
|
11
|
-
* Bake
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
25
|
+
* Bake `AnimationClip`s into a VAT by sampling the posed subtree frame by frame
|
|
26
|
+
* on the CPU.
|
|
27
|
+
*
|
|
28
|
+
* The unit of a bake is the whole subtree under `root`, merged into one vertex
|
|
29
|
+
* set and recorded in root space (ADR-0008) — so it handles a single
|
|
30
|
+
* `SkinnedMesh`, a morph-target mesh, a hierarchy of rigid node-animated parts
|
|
31
|
+
* (three.js `RobotExpressive`), or any mix of them, without the caller having
|
|
32
|
+
* to classify the asset. A VAT only records *where a vertex ended up*, never
|
|
33
|
+
* how it got there.
|
|
34
|
+
*
|
|
35
|
+
* Positions are stored as deltas from the merged rest pose; normals absolute.
|
|
36
|
+
* Renderer-agnostic — touches no WebGL/WebGPU context — so it runs identically
|
|
37
|
+
* at runtime, in a Web Worker, and in Node.
|
|
15
38
|
*/
|
|
16
|
-
declare function bakeVAT(root: Object3D,
|
|
39
|
+
declare function bakeVAT(root: Object3D, clips: AnimationClip[], { fps, maxTextureSize }?: BakeOptions): BakedVAT;
|
|
17
40
|
/**
|
|
18
41
|
* Build a VAT `DataTexture` with the fixed sampling flags every path relies on:
|
|
19
42
|
* RGBA, nearest filtering, no mipmaps. Frame interpolation is done manually in
|
|
@@ -55,12 +78,14 @@ interface SerializeOptions {
|
|
|
55
78
|
* the on-disk format; the runtime object is always reconstructed with
|
|
56
79
|
* {@link loadVAT}.
|
|
57
80
|
*/
|
|
81
|
+
/** @deprecated Removed in 1.0 — see ADR-0010. Bake at runtime instead. */
|
|
58
82
|
declare function serializeVAT(vat: VAT, { precision }?: SerializeOptions): SerializedVAT;
|
|
59
83
|
/**
|
|
60
84
|
* Reconstruct a runtime VAT from serialized buffers. `float16` uploads as
|
|
61
85
|
* `HalfFloatType`; `float32` as `FloatType`. The result is interchangeable with
|
|
62
86
|
* a {@link bakeVAT} result.
|
|
63
87
|
*/
|
|
88
|
+
/** @deprecated Removed in 1.0 — see ADR-0010. Bake at runtime instead. */
|
|
64
89
|
declare function loadVAT(serialized: SerializedVAT): VAT;
|
|
65
90
|
|
|
66
|
-
export { type BakeOptions, MAX_TEXTURE_SIZE, type SerializeOptions, type SerializedVAT, VAT, VATClip, type VATManifest, type VATPrecision, bakeVAT, loadVAT, makeVATTexture, serializeVAT };
|
|
91
|
+
export { type BakeOptions, BakedVAT, MAX_TEXTURE_SIZE, type SerializeOptions, type SerializedVAT, VAT, VATClip, type VATManifest, type VATPrecision, bakeVAT, loadVAT, makeVATTexture, serializeVAT };
|
package/dist/index.js
CHANGED
|
@@ -1,24 +1,151 @@
|
|
|
1
|
-
import { FloatType, AnimationMixer, Box3, Vector4,
|
|
1
|
+
import { FloatType, Matrix4, AnimationMixer, Box3, Vector4, Vector3, Sphere, DataTexture, RGBAFormat, NearestFilter, DataUtils, HalfFloatType, BufferGeometry, BufferAttribute } from 'three';
|
|
2
2
|
|
|
3
3
|
// src/bake.ts
|
|
4
4
|
var MAX_TEXTURE_SIZE = 16384;
|
|
5
|
-
function
|
|
6
|
-
|
|
7
|
-
const vertexCount = geometry.attributes.position.count;
|
|
8
|
-
if (vertexCount > MAX_TEXTURE_SIZE) {
|
|
5
|
+
function asAttribute(value, mesh, name) {
|
|
6
|
+
if (!(value instanceof BufferAttribute)) {
|
|
9
7
|
throw new Error(
|
|
10
|
-
`three-vat:
|
|
8
|
+
`three-vat: mesh "${mesh.name || "(unnamed)"}" has an interleaved or unsupported "${name}" attribute; VAT bakes plain BufferAttributes`
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
function collectParts(root) {
|
|
14
|
+
const found = [];
|
|
15
|
+
root.traverse((o) => {
|
|
16
|
+
const mesh = o;
|
|
17
|
+
if (mesh.isMesh && mesh.geometry) found.push(mesh);
|
|
18
|
+
});
|
|
19
|
+
if (found.length === 0) {
|
|
20
|
+
throw new Error("three-vat: no Mesh found under root; nothing to bake");
|
|
21
|
+
}
|
|
22
|
+
const materials = [];
|
|
23
|
+
const parts = [];
|
|
24
|
+
for (const mesh of found) {
|
|
25
|
+
if (Array.isArray(mesh.material)) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`three-vat: mesh "${mesh.name || "(unnamed)"}" uses a material array; split it into one mesh per material before baking`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const geometry = mesh.geometry;
|
|
31
|
+
if (!geometry.attributes.normal) geometry.computeVertexNormals();
|
|
32
|
+
const material = mesh.material;
|
|
33
|
+
let materialIndex = materials.indexOf(material);
|
|
34
|
+
if (materialIndex === -1) materialIndex = materials.push(material) - 1;
|
|
35
|
+
const skinned = mesh;
|
|
36
|
+
parts.push({
|
|
37
|
+
mesh,
|
|
38
|
+
material,
|
|
39
|
+
materialIndex,
|
|
40
|
+
vertexStart: 0,
|
|
41
|
+
// assigned below, after material sorting
|
|
42
|
+
vertexCount: geometry.attributes.position.count,
|
|
43
|
+
basePos: asAttribute(geometry.attributes.position, mesh, "position"),
|
|
44
|
+
baseNrm: asAttribute(geometry.attributes.normal, mesh, "normal"),
|
|
45
|
+
isSkinned: !!geometry.attributes.skinWeight && !!skinned.skeleton,
|
|
46
|
+
skeleton: skinned.skeleton,
|
|
47
|
+
skinIndex: geometry.attributes.skinIndex,
|
|
48
|
+
skinWeight: geometry.attributes.skinWeight,
|
|
49
|
+
morphPos: geometry.morphAttributes.position,
|
|
50
|
+
morphRelative: geometry.morphTargetsRelative
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
parts.sort((a, b) => a.materialIndex - b.materialIndex);
|
|
54
|
+
let offset = 0;
|
|
55
|
+
for (const part of parts) {
|
|
56
|
+
part.vertexStart = offset;
|
|
57
|
+
offset += part.vertexCount;
|
|
58
|
+
}
|
|
59
|
+
return parts;
|
|
60
|
+
}
|
|
61
|
+
function mergeGeometry(parts, restMatrices, total) {
|
|
62
|
+
const position = new Float32Array(total * 3);
|
|
63
|
+
const normal = new Float32Array(total * 3);
|
|
64
|
+
const wantUV = parts.every((p) => !!p.mesh.geometry.attributes.uv);
|
|
65
|
+
const wantColor = parts.every((p) => !!p.mesh.geometry.attributes.color);
|
|
66
|
+
const uv = wantUV ? new Float32Array(total * 2) : null;
|
|
67
|
+
const color = wantColor ? new Float32Array(total * 3) : null;
|
|
68
|
+
const indices = [];
|
|
69
|
+
const groups = [];
|
|
70
|
+
const _v = new Vector3();
|
|
71
|
+
const _n = new Vector3();
|
|
72
|
+
parts.forEach((part, pi) => {
|
|
73
|
+
const m = restMatrices[pi];
|
|
74
|
+
const geometry = part.mesh.geometry;
|
|
75
|
+
const start = part.vertexStart;
|
|
76
|
+
const srcUV = uv ? asAttribute(geometry.attributes.uv, part.mesh, "uv") : null;
|
|
77
|
+
const srcColor = color ? asAttribute(geometry.attributes.color, part.mesh, "color") : null;
|
|
78
|
+
for (let v = 0; v < part.vertexCount; v++) {
|
|
79
|
+
_v.fromBufferAttribute(part.basePos, v).applyMatrix4(m);
|
|
80
|
+
_n.fromBufferAttribute(part.baseNrm, v).transformDirection(m);
|
|
81
|
+
const o3 = (start + v) * 3;
|
|
82
|
+
position[o3] = _v.x;
|
|
83
|
+
position[o3 + 1] = _v.y;
|
|
84
|
+
position[o3 + 2] = _v.z;
|
|
85
|
+
normal[o3] = _n.x;
|
|
86
|
+
normal[o3 + 1] = _n.y;
|
|
87
|
+
normal[o3 + 2] = _n.z;
|
|
88
|
+
if (uv && srcUV) {
|
|
89
|
+
uv[(start + v) * 2] = srcUV.getX(v);
|
|
90
|
+
uv[(start + v) * 2 + 1] = srcUV.getY(v);
|
|
91
|
+
}
|
|
92
|
+
if (color && srcColor) {
|
|
93
|
+
color[o3] = srcColor.getX(v);
|
|
94
|
+
color[o3 + 1] = srcColor.getY(v);
|
|
95
|
+
color[o3 + 2] = srcColor.getZ(v);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const groupStart = indices.length;
|
|
99
|
+
const index = geometry.index;
|
|
100
|
+
if (index) {
|
|
101
|
+
for (let i = 0; i < index.count; i++) indices.push(start + index.getX(i));
|
|
102
|
+
} else {
|
|
103
|
+
for (let v = 0; v < part.vertexCount; v++) indices.push(start + v);
|
|
104
|
+
}
|
|
105
|
+
const last = groups[groups.length - 1];
|
|
106
|
+
if (last && last.materialIndex === part.materialIndex) {
|
|
107
|
+
last.count += indices.length - groupStart;
|
|
108
|
+
} else {
|
|
109
|
+
groups.push({
|
|
110
|
+
start: groupStart,
|
|
111
|
+
count: indices.length - groupStart,
|
|
112
|
+
materialIndex: part.materialIndex
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
const merged = new BufferGeometry();
|
|
117
|
+
merged.setAttribute("position", new BufferAttribute(position, 3));
|
|
118
|
+
merged.setAttribute("normal", new BufferAttribute(normal, 3));
|
|
119
|
+
if (uv) merged.setAttribute("uv", new BufferAttribute(uv, 2));
|
|
120
|
+
if (color) merged.setAttribute("color", new BufferAttribute(color, 3));
|
|
121
|
+
merged.setIndex(indices);
|
|
122
|
+
for (const g of groups) merged.addGroup(g.start, g.count, g.materialIndex);
|
|
123
|
+
return merged;
|
|
124
|
+
}
|
|
125
|
+
function bakeVAT(root, clips, { fps = 30, maxTextureSize = MAX_TEXTURE_SIZE } = {}) {
|
|
126
|
+
root.updateMatrixWorld(true);
|
|
127
|
+
const parts = collectParts(root);
|
|
128
|
+
const vertexCount = parts.reduce((n, p) => n + p.vertexCount, 0);
|
|
129
|
+
if (vertexCount > maxTextureSize) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`three-vat: vertexCount ${vertexCount} exceeds maxTextureSize ${maxTextureSize}; row wrapping is not implemented`
|
|
11
132
|
);
|
|
12
133
|
}
|
|
13
134
|
const frameCounts = clips.map((c) => Math.max(2, Math.round(c.duration * fps)));
|
|
14
135
|
const totalFrames = frameCounts.reduce((a, b) => a + b, 0);
|
|
136
|
+
if (totalFrames > maxTextureSize) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`three-vat: totalFrames ${totalFrames} exceeds maxTextureSize ${maxTextureSize}; lower fps or bake fewer clips`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const rootInverse = root.matrixWorld.clone().invert();
|
|
142
|
+
const restMatrices = parts.map(
|
|
143
|
+
(p) => new Matrix4().multiplyMatrices(rootInverse, p.mesh.matrixWorld)
|
|
144
|
+
);
|
|
145
|
+
const geometry = mergeGeometry(parts, restMatrices, vertexCount);
|
|
146
|
+
const mergedBase = geometry.attributes.position;
|
|
15
147
|
const posData = new Float32Array(vertexCount * totalFrames * 4);
|
|
16
148
|
const nrmData = new Float32Array(vertexCount * totalFrames * 4);
|
|
17
|
-
const basePos = geometry.attributes.position;
|
|
18
|
-
const baseNrm = geometry.attributes.normal;
|
|
19
|
-
const skinIndex = geometry.attributes.skinIndex;
|
|
20
|
-
const skinWeight = geometry.attributes.skinWeight;
|
|
21
|
-
const skeleton = skinnedMesh.skeleton;
|
|
22
149
|
const mixer = new AnimationMixer(root);
|
|
23
150
|
const bounds = new Box3();
|
|
24
151
|
const clipTable = [];
|
|
@@ -27,9 +154,12 @@ function bakeVAT(root, skinnedMesh, clips, { fps = 30 } = {}) {
|
|
|
27
154
|
const _bone = new Matrix4();
|
|
28
155
|
const _acc = new Matrix4();
|
|
29
156
|
const _skin = new Matrix4();
|
|
157
|
+
const _partMatrix = new Matrix4();
|
|
30
158
|
const _p = new Vector3();
|
|
31
159
|
const _bp = new Vector3();
|
|
32
160
|
const _n = new Vector3();
|
|
161
|
+
const _mt = new Vector3();
|
|
162
|
+
const _mb = new Vector3();
|
|
33
163
|
let rowOffset = 0;
|
|
34
164
|
clips.forEach((clip, ci) => {
|
|
35
165
|
const frames = frameCounts[ci];
|
|
@@ -40,37 +170,60 @@ function bakeVAT(root, skinnedMesh, clips, { fps = 30 } = {}) {
|
|
|
40
170
|
mixer.setTime(f / frames * clip.duration);
|
|
41
171
|
root.updateMatrixWorld(true);
|
|
42
172
|
const row = rowOffset + f;
|
|
43
|
-
for (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
for (let
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
173
|
+
for (const part of parts) {
|
|
174
|
+
_partMatrix.multiplyMatrices(rootInverse, part.mesh.matrixWorld);
|
|
175
|
+
const influences = part.mesh.morphTargetInfluences;
|
|
176
|
+
const { morphPos, morphRelative, isSkinned, skeleton, skinIndex, skinWeight } = part;
|
|
177
|
+
for (let v = 0; v < part.vertexCount; v++) {
|
|
178
|
+
const vi = part.vertexStart + v;
|
|
179
|
+
_p.fromBufferAttribute(part.basePos, v);
|
|
180
|
+
_n.fromBufferAttribute(part.baseNrm, v);
|
|
181
|
+
if (morphPos && influences) {
|
|
182
|
+
if (!morphRelative) _mb.fromBufferAttribute(part.basePos, v);
|
|
183
|
+
for (let t = 0; t < morphPos.length; t++) {
|
|
184
|
+
const w = influences[t];
|
|
185
|
+
if (w === 0) continue;
|
|
186
|
+
_mt.fromBufferAttribute(morphPos[t], v);
|
|
187
|
+
if (!morphRelative) _mt.sub(_mb);
|
|
188
|
+
_p.addScaledVector(_mt, w);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (isSkinned && skeleton) {
|
|
192
|
+
const skinned = part.mesh;
|
|
193
|
+
_si.fromBufferAttribute(skinIndex, v);
|
|
194
|
+
_sw.fromBufferAttribute(skinWeight, v);
|
|
195
|
+
_acc.elements.fill(0);
|
|
196
|
+
for (let i = 0; i < 4; i++) {
|
|
197
|
+
const w = _sw.getComponent(i);
|
|
198
|
+
if (w === 0) continue;
|
|
199
|
+
const bi = _si.getComponent(i);
|
|
200
|
+
_bone.multiplyMatrices(skeleton.bones[bi].matrixWorld, skeleton.boneInverses[bi]);
|
|
201
|
+
const ae = _acc.elements;
|
|
202
|
+
const be = _bone.elements;
|
|
203
|
+
for (let e = 0; e < 16; e++) ae[e] += be[e] * w;
|
|
204
|
+
}
|
|
205
|
+
_skin.multiplyMatrices(_acc, skinned.bindMatrix).premultiply(skinned.bindMatrixInverse);
|
|
206
|
+
_p.applyMatrix4(_skin);
|
|
207
|
+
_n.transformDirection(_skin);
|
|
208
|
+
}
|
|
209
|
+
_p.applyMatrix4(_partMatrix);
|
|
210
|
+
_n.transformDirection(_partMatrix);
|
|
211
|
+
bounds.expandByPoint(_p);
|
|
212
|
+
_bp.fromBufferAttribute(mergedBase, vi);
|
|
213
|
+
const o = (row * vertexCount + vi) * 4;
|
|
214
|
+
const dx = _p.x - _bp.x;
|
|
215
|
+
const dy = _p.y - _bp.y;
|
|
216
|
+
const dz = _p.z - _bp.z;
|
|
217
|
+
maxDeltaSq = Math.max(maxDeltaSq, dx * dx + dy * dy + dz * dz);
|
|
218
|
+
posData[o] = dx;
|
|
219
|
+
posData[o + 1] = dy;
|
|
220
|
+
posData[o + 2] = dz;
|
|
221
|
+
posData[o + 3] = 1;
|
|
222
|
+
nrmData[o] = _n.x;
|
|
223
|
+
nrmData[o + 1] = _n.y;
|
|
224
|
+
nrmData[o + 2] = _n.z;
|
|
225
|
+
nrmData[o + 3] = 1;
|
|
55
226
|
}
|
|
56
|
-
_skin.multiplyMatrices(_acc, skinnedMesh.bindMatrix).premultiply(skinnedMesh.bindMatrixInverse);
|
|
57
|
-
_bp.fromBufferAttribute(basePos, v);
|
|
58
|
-
_p.copy(_bp).applyMatrix4(_skin);
|
|
59
|
-
bounds.expandByPoint(_p);
|
|
60
|
-
_n.fromBufferAttribute(baseNrm, v).transformDirection(_skin);
|
|
61
|
-
const o = (row * vertexCount + v) * 4;
|
|
62
|
-
const dx = _p.x - _bp.x;
|
|
63
|
-
const dy = _p.y - _bp.y;
|
|
64
|
-
const dz = _p.z - _bp.z;
|
|
65
|
-
maxDeltaSq = Math.max(maxDeltaSq, dx * dx + dy * dy + dz * dz);
|
|
66
|
-
posData[o] = dx;
|
|
67
|
-
posData[o + 1] = dy;
|
|
68
|
-
posData[o + 2] = dz;
|
|
69
|
-
posData[o + 3] = 1;
|
|
70
|
-
nrmData[o] = _n.x;
|
|
71
|
-
nrmData[o + 1] = _n.y;
|
|
72
|
-
nrmData[o + 2] = _n.z;
|
|
73
|
-
nrmData[o + 3] = 1;
|
|
74
227
|
}
|
|
75
228
|
}
|
|
76
229
|
action.stop();
|
|
@@ -87,6 +240,10 @@ function bakeVAT(root, skinnedMesh, clips, { fps = 30 } = {}) {
|
|
|
87
240
|
mixer.stopAllAction();
|
|
88
241
|
mixer.setTime(0);
|
|
89
242
|
root.updateMatrixWorld(true);
|
|
243
|
+
geometry.boundingBox = bounds.clone();
|
|
244
|
+
geometry.boundingSphere = bounds.getBoundingSphere(new Sphere());
|
|
245
|
+
const materials = [];
|
|
246
|
+
for (const part of parts) materials[part.materialIndex] = part.material;
|
|
90
247
|
return {
|
|
91
248
|
positionTexture: makeVATTexture(posData, vertexCount, totalFrames),
|
|
92
249
|
normalTexture: makeVATTexture(nrmData, vertexCount, totalFrames),
|
|
@@ -94,7 +251,9 @@ function bakeVAT(root, skinnedMesh, clips, { fps = 30 } = {}) {
|
|
|
94
251
|
bounds,
|
|
95
252
|
vertexCount,
|
|
96
253
|
totalFrames,
|
|
97
|
-
encoding: "delta"
|
|
254
|
+
encoding: "delta",
|
|
255
|
+
geometry,
|
|
256
|
+
materials
|
|
98
257
|
};
|
|
99
258
|
}
|
|
100
259
|
function makeVATTexture(data, width, height, type = FloatType) {
|
package/dist/tsl.d.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { Node } from 'three/webgpu';
|
|
2
|
-
import {
|
|
2
|
+
import { a as VAT } from './types-wVmIj2tC.js';
|
|
3
3
|
import 'three';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* The real maximum texture dimension this renderer accepts, for
|
|
7
|
+
* `bakeVAT(..., { maxTextureSize })`. The baker is renderer-agnostic (it runs
|
|
8
|
+
* in Node, and in a Web Worker) so it cannot query this itself.
|
|
9
|
+
*
|
|
10
|
+
* Call this *after* `await renderer.init()` — the backend has no device before
|
|
11
|
+
* that. Handles both a WebGPU backend and the WebGL fallback backend a
|
|
12
|
+
* `WebGPURenderer` may silently switch to when WebGPU is unavailable.
|
|
13
|
+
*/
|
|
14
|
+
declare function getMaxTextureSize(renderer: object): number;
|
|
5
15
|
/** A fluent TSL float node (has `.add`, `.mul`, … via NodeExtensions). */
|
|
6
16
|
type FloatNode = Node<'float'>;
|
|
7
17
|
/** A fluent TSL vec3 node. */
|
|
@@ -41,4 +51,4 @@ interface VATNodes {
|
|
|
41
51
|
*/
|
|
42
52
|
declare function vatNodes(vat: VAT, options?: VATNodeOptions): VATNodes;
|
|
43
53
|
|
|
44
|
-
export { type VATNodeOptions, type VATNodes, vatNodes };
|
|
54
|
+
export { type VATNodeOptions, type VATNodes, getMaxTextureSize, vatNodes };
|
package/dist/tsl.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { uniform, float, int, hash, instanceIndex, vertexIndex, positionLocal, textureLoad, ivec2, mix } from 'three/tsl';
|
|
2
2
|
|
|
3
3
|
// src/tsl.ts
|
|
4
|
+
function getMaxTextureSize(renderer) {
|
|
5
|
+
const backend = renderer.backend;
|
|
6
|
+
const device = backend?.["device"];
|
|
7
|
+
if (device?.limits?.maxTextureDimension2D) return device.limits.maxTextureDimension2D;
|
|
8
|
+
const gl = backend?.["gl"];
|
|
9
|
+
if (gl) return gl.getParameter(gl.MAX_TEXTURE_SIZE);
|
|
10
|
+
throw new Error("three-vat: renderer has no initialized backend \u2014 call `await renderer.init()` first");
|
|
11
|
+
}
|
|
4
12
|
function vatNodes(vat, options = {}) {
|
|
5
13
|
const { time = uniform(0), clipIndex = 0, desync = 0 } = options;
|
|
6
14
|
const clip = vat.clips[clipIndex];
|
|
@@ -27,4 +35,4 @@ function vatNodes(vat, options = {}) {
|
|
|
27
35
|
};
|
|
28
36
|
}
|
|
29
37
|
|
|
30
|
-
export { vatNodes };
|
|
38
|
+
export { getMaxTextureSize, vatNodes };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DataTexture, Box3 } from 'three';
|
|
1
|
+
import { DataTexture, Box3, BufferGeometry, Material } from 'three';
|
|
2
2
|
|
|
3
3
|
/** One baked animation range within a VAT's stacked frame rows. */
|
|
4
4
|
interface VATClip {
|
|
@@ -41,5 +41,23 @@ interface VAT {
|
|
|
41
41
|
/** Position encoding. Only `'delta'` in v1. */
|
|
42
42
|
encoding: 'delta';
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* What {@link bakeVAT} returns: a VAT plus the geometry it was baked against.
|
|
46
|
+
*
|
|
47
|
+
* The merged vertex ordering is the baker's own invention and the textures are
|
|
48
|
+
* indexed by it (`x = gl_VertexID`), so the caller can no longer bring its own
|
|
49
|
+
* geometry — it must render the one baked here. `materials` is ordered to match
|
|
50
|
+
* `geometry.groups[].materialIndex`, giving one draw call per material.
|
|
51
|
+
*
|
|
52
|
+
* `loadVAT` returns a plain {@link VAT} without these, which is precisely why the
|
|
53
|
+
* offline format is deprecated and removed in 1.0 (ADR-0010): a serialized VAT
|
|
54
|
+
* cannot be rendered without re-running the merge that produced its ordering.
|
|
55
|
+
*/
|
|
56
|
+
interface BakedVAT extends VAT {
|
|
57
|
+
/** Merged, root-space rest-pose geometry. Its `position` is the delta reference. */
|
|
58
|
+
geometry: BufferGeometry;
|
|
59
|
+
/** Source materials, indexed by `geometry.groups[].materialIndex`. */
|
|
60
|
+
materials: Material[];
|
|
61
|
+
}
|
|
44
62
|
|
|
45
|
-
export type {
|
|
63
|
+
export type { BakedVAT as B, VATClip as V, VAT as a };
|
package/dist/webgl.d.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import { IUniform, BufferGeometry, MeshDepthMaterial, Material } from 'three';
|
|
2
|
-
import {
|
|
1
|
+
import { IUniform, BufferGeometry, MeshDepthMaterial, WebGLRenderer, Material } from 'three';
|
|
2
|
+
import { a as VAT } from './types-wVmIj2tC.js';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* The real maximum texture dimension this GPU accepts, for
|
|
6
|
+
* `bakeVAT(..., { maxTextureSize })`. The baker cannot query this itself — it
|
|
7
|
+
* is renderer-agnostic so it can run in Node or a Web Worker — so read it
|
|
8
|
+
* here and hand it over. Desktop typically reports 16384, but mobile GPUs
|
|
9
|
+
* commonly report 4096 or 8192, which is exactly the case a hardcoded default
|
|
10
|
+
* bakes straight past.
|
|
11
|
+
*/
|
|
12
|
+
declare function getMaxTextureSize(renderer: WebGLRenderer): number;
|
|
4
13
|
/** The shared uniform driving every VAT-patched material's playback clock. */
|
|
5
14
|
interface VATUniforms {
|
|
6
15
|
uVatTime: IUniform<number>;
|
|
@@ -35,4 +44,4 @@ declare function patchVATMaterial<T extends Material>(material: T, vat: VAT, uni
|
|
|
35
44
|
*/
|
|
36
45
|
declare function createVATDepthMaterial(vat: VAT, uniforms: VATUniforms): MeshDepthMaterial;
|
|
37
46
|
|
|
38
|
-
export { type VATInstance, type VATUniforms, addInstancedVATAttributes, createVATDepthMaterial, createVATUniforms, patchVATMaterial };
|
|
47
|
+
export { type VATInstance, type VATUniforms, addInstancedVATAttributes, createVATDepthMaterial, createVATUniforms, getMaxTextureSize, patchVATMaterial };
|
package/dist/webgl.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { InstancedBufferAttribute, MeshDepthMaterial, RGBADepthPacking } from 'three';
|
|
2
2
|
|
|
3
3
|
// src/webgl.ts
|
|
4
|
+
function getMaxTextureSize(renderer) {
|
|
5
|
+
return renderer.capabilities.maxTextureSize;
|
|
6
|
+
}
|
|
4
7
|
function createVATUniforms(time = 0) {
|
|
5
8
|
return { uVatTime: { value: time } };
|
|
6
9
|
}
|
|
7
10
|
function addInstancedVATAttributes(geometry, instances) {
|
|
11
|
+
geometry.morphAttributes = {};
|
|
12
|
+
geometry.morphTargetsRelative = false;
|
|
8
13
|
const n = instances.length;
|
|
9
14
|
const clipStart = new Float32Array(n);
|
|
10
15
|
const clipFrames = new Float32Array(n);
|
|
@@ -78,4 +83,4 @@ function createVATDepthMaterial(vat, uniforms) {
|
|
|
78
83
|
return depth;
|
|
79
84
|
}
|
|
80
85
|
|
|
81
|
-
export { addInstancedVATAttributes, createVATDepthMaterial, createVATUniforms, patchVATMaterial };
|
|
86
|
+
export { addInstancedVATAttributes, createVATDepthMaterial, createVATUniforms, getMaxTextureSize, patchVATMaterial };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "three-vat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Bake glTF animation clips into vertex animation textures for zero-CPU instanced crowds in three.js.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,15 +34,6 @@
|
|
|
34
34
|
"import": "./dist/tsl.js"
|
|
35
35
|
}
|
|
36
36
|
},
|
|
37
|
-
"scripts": {
|
|
38
|
-
"build": "tsup",
|
|
39
|
-
"dev": "tsup --watch",
|
|
40
|
-
"test": "vitest run",
|
|
41
|
-
"test:watch": "vitest",
|
|
42
|
-
"typecheck": "tsc --noEmit",
|
|
43
|
-
"prepublishOnly": "pnpm typecheck && pnpm test && pnpm build",
|
|
44
|
-
"example": "pnpm --dir examples dev"
|
|
45
|
-
},
|
|
46
37
|
"keywords": [
|
|
47
38
|
"three",
|
|
48
39
|
"threejs",
|
|
@@ -68,5 +59,15 @@
|
|
|
68
59
|
},
|
|
69
60
|
"engines": {
|
|
70
61
|
"node": ">=18"
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"build": "tsup",
|
|
65
|
+
"dev": "tsup --watch",
|
|
66
|
+
"test": "vitest run",
|
|
67
|
+
"test:watch": "vitest",
|
|
68
|
+
"typecheck": "tsc --noEmit",
|
|
69
|
+
"release": "pnpm publish --otp=\"${NPM_OTP:?set NPM_OTP=<code from your authenticator>}\" && pnpm verify:published",
|
|
70
|
+
"verify:published": "node scripts/verify-published.mjs",
|
|
71
|
+
"example": "pnpm --dir examples dev"
|
|
71
72
|
}
|
|
72
|
-
}
|
|
73
|
+
}
|