three-vat 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.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +154 -0
- package/dist/tsl.d.ts +44 -0
- package/dist/tsl.js +30 -0
- package/dist/types-DVtQvVjk.d.ts +45 -0
- package/dist/webgl.d.ts +38 -0
- package/dist/webgl.js +81 -0
- package/package.json +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mike Fernandez
|
|
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
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# three-vat
|
|
2
|
+
|
|
3
|
+
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
|
+
|
|
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 — with an optional offline path that produces the identical texture.
|
|
6
|
+
|
|
7
|
+
> **Status: v1 / pre-release.** The baker core and WebGL decode are ported from a browser-validated prototype. The TSL path is present but verified visually, not yet by automated tests. See [`docs/DESIGN.md`](./docs/DESIGN.md) and [`docs/adr/`](./docs/adr) for the full rationale.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install three-vat three
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`three` (>= 0.185) is a peer dependency.
|
|
16
|
+
|
|
17
|
+
## Bake
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { bakeVAT } from 'three-vat'
|
|
21
|
+
|
|
22
|
+
// gltf loaded via GLTFLoader; sourceMesh is the SkinnedMesh in it.
|
|
23
|
+
const clips = gltf.animations.filter((c) => c.name !== 'TPose')
|
|
24
|
+
const vat = bakeVAT(gltf.scene, sourceMesh, clips, { fps: 30 })
|
|
25
|
+
// vat: { positionTexture, normalTexture, clips, bounds, vertexCount, totalFrames, encoding }
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Render a crowd — WebGL (`WebGLRenderer`)
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { addInstancedVATAttributes, createVATUniforms, createVATDepthMaterial, patchVATMaterial } from 'three-vat/webgl'
|
|
32
|
+
|
|
33
|
+
const uniforms = createVATUniforms()
|
|
34
|
+
|
|
35
|
+
const geometry = sourceMesh.geometry.clone()
|
|
36
|
+
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
|
+
|
|
40
|
+
const material = sourceMesh.material.clone()
|
|
41
|
+
patchVATMaterial(material, vat, uniforms)
|
|
42
|
+
|
|
43
|
+
const mesh = new THREE.InstancedMesh(geometry, material, instances.length)
|
|
44
|
+
mesh.customDepthMaterial = createVATDepthMaterial(vat, uniforms) // correct instanced shadows
|
|
45
|
+
mesh.castShadow = mesh.receiveShadow = true
|
|
46
|
+
|
|
47
|
+
// per frame:
|
|
48
|
+
uniforms.uVatTime.value = clock.elapsedTime
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Render a crowd — TSL (`WebGPURenderer`)
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { MeshStandardNodeMaterial } from 'three/webgpu'
|
|
55
|
+
import { vatNodes } from 'three-vat/tsl'
|
|
56
|
+
|
|
57
|
+
const { positionNode, normalNode, time } = vatNodes(vat, { clipIndex: 0, desync: 10 })
|
|
58
|
+
const material = new MeshStandardNodeMaterial()
|
|
59
|
+
material.positionNode = positionNode
|
|
60
|
+
material.normalNode = normalNode
|
|
61
|
+
|
|
62
|
+
// per frame:
|
|
63
|
+
time.value = clock.elapsedTime
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
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
|
+
|
|
68
|
+
## Offline (bake once, ship the texture)
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { serializeVAT, loadVAT } from 'three-vat'
|
|
72
|
+
|
|
73
|
+
const { manifest, position, normal } = serializeVAT(vat, { precision: 'float16' })
|
|
74
|
+
// write manifest as JSON, position/normal as .bin — then later:
|
|
75
|
+
const vat = loadVAT({ manifest, position, normal })
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The canonical format is a raw Float16 `.bin` + versioned JSON manifest (KTX2 rejected as default — its GPU compression doesn't apply to float data). The loaded VAT is interchangeable with a freshly-baked one.
|
|
79
|
+
|
|
80
|
+
## Trade-offs
|
|
81
|
+
|
|
82
|
+
- **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.
|
|
83
|
+
- **vs bone-texture instancing:** smaller textures and supports blending, but more fetches per vertex. VAT also captures morph/non-skeletal deformation for free.
|
|
84
|
+
- **VAT limits:** no runtime IK/blending, discrete frames, memory cost (`verts × frames × 16 B × 2` textures). No clip crossfade in v1.
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pnpm install
|
|
90
|
+
pnpm test # baker core — pure CPU, no GPU needed
|
|
91
|
+
pnpm typecheck
|
|
92
|
+
pnpm build
|
|
93
|
+
pnpm example # runs the crowd demo in examples/ (needs a Soldier.glb)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Object3D, SkinnedMesh, AnimationClip, TypedArray, TextureDataType, DataTexture } from 'three';
|
|
2
|
+
import { V as VAT, a as VATClip } from './types-DVtQvVjk.js';
|
|
3
|
+
|
|
4
|
+
/** Maximum texture dimension we support; `vertexCount` must not exceed it. */
|
|
5
|
+
declare const MAX_TEXTURE_SIZE = 16384;
|
|
6
|
+
interface BakeOptions {
|
|
7
|
+
/** Sample rate in frames per second. Default `30`. */
|
|
8
|
+
fps?: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Bake skinned `AnimationClip`s into a VAT by sampling the posed mesh frame by
|
|
12
|
+
* frame on the CPU. Positions are stored as deltas from the bind pose; normals
|
|
13
|
+
* are stored absolute. Renderer-agnostic — touches no WebGL/WebGPU context — so
|
|
14
|
+
* it runs identically at runtime and offline in Node.
|
|
15
|
+
*/
|
|
16
|
+
declare function bakeVAT(root: Object3D, skinnedMesh: SkinnedMesh, clips: AnimationClip[], { fps }?: BakeOptions): VAT;
|
|
17
|
+
/**
|
|
18
|
+
* Build a VAT `DataTexture` with the fixed sampling flags every path relies on:
|
|
19
|
+
* RGBA, nearest filtering, no mipmaps. Frame interpolation is done manually in
|
|
20
|
+
* the shader, so linear filtering must stay off.
|
|
21
|
+
*/
|
|
22
|
+
declare function makeVATTexture(data: TypedArray, width: number, height: number, type?: TextureDataType): DataTexture;
|
|
23
|
+
|
|
24
|
+
type VATPrecision = 'float16' | 'float32';
|
|
25
|
+
/**
|
|
26
|
+
* The versioned descriptor of an offline-baked VAT. The manifest *is* the
|
|
27
|
+
* format — bump `version` on any breaking layout change.
|
|
28
|
+
*/
|
|
29
|
+
interface VATManifest {
|
|
30
|
+
version: 1;
|
|
31
|
+
vertexCount: number;
|
|
32
|
+
totalFrames: number;
|
|
33
|
+
encoding: 'delta';
|
|
34
|
+
precision: VATPrecision;
|
|
35
|
+
clips: VATClip[];
|
|
36
|
+
bounds: {
|
|
37
|
+
min: [number, number, number];
|
|
38
|
+
max: [number, number, number];
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** A serialized VAT: manifest plus the two raw texel buffers. */
|
|
42
|
+
interface SerializedVAT {
|
|
43
|
+
manifest: VATManifest;
|
|
44
|
+
/** Interleaved RGBA position deltas, `float16` or `float32` per `manifest.precision`. */
|
|
45
|
+
position: ArrayBuffer;
|
|
46
|
+
/** Interleaved RGBA absolute normals, same precision. */
|
|
47
|
+
normal: ArrayBuffer;
|
|
48
|
+
}
|
|
49
|
+
interface SerializeOptions {
|
|
50
|
+
/** On-disk texel precision. Default `'float16'` (half the bytes, uploads directly). */
|
|
51
|
+
precision?: VATPrecision;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Serialize a baked VAT to raw texel buffers + a versioned manifest. This is
|
|
55
|
+
* the on-disk format; the runtime object is always reconstructed with
|
|
56
|
+
* {@link loadVAT}.
|
|
57
|
+
*/
|
|
58
|
+
declare function serializeVAT(vat: VAT, { precision }?: SerializeOptions): SerializedVAT;
|
|
59
|
+
/**
|
|
60
|
+
* Reconstruct a runtime VAT from serialized buffers. `float16` uploads as
|
|
61
|
+
* `HalfFloatType`; `float32` as `FloatType`. The result is interchangeable with
|
|
62
|
+
* a {@link bakeVAT} result.
|
|
63
|
+
*/
|
|
64
|
+
declare function loadVAT(serialized: SerializedVAT): VAT;
|
|
65
|
+
|
|
66
|
+
export { type BakeOptions, MAX_TEXTURE_SIZE, type SerializeOptions, type SerializedVAT, VAT, VATClip, type VATManifest, type VATPrecision, bakeVAT, loadVAT, makeVATTexture, serializeVAT };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { FloatType, AnimationMixer, Box3, Vector4, Matrix4, Vector3, DataTexture, RGBAFormat, NearestFilter, DataUtils, HalfFloatType } from 'three';
|
|
2
|
+
|
|
3
|
+
// src/bake.ts
|
|
4
|
+
var MAX_TEXTURE_SIZE = 16384;
|
|
5
|
+
function bakeVAT(root, skinnedMesh, clips, { fps = 30 } = {}) {
|
|
6
|
+
const geometry = skinnedMesh.geometry;
|
|
7
|
+
const vertexCount = geometry.attributes.position.count;
|
|
8
|
+
if (vertexCount > MAX_TEXTURE_SIZE) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
`three-vat: vertexCount ${vertexCount} exceeds MAX_TEXTURE_SIZE ${MAX_TEXTURE_SIZE}; row wrapping is not implemented`
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
const frameCounts = clips.map((c) => Math.max(2, Math.round(c.duration * fps)));
|
|
14
|
+
const totalFrames = frameCounts.reduce((a, b) => a + b, 0);
|
|
15
|
+
const posData = new Float32Array(vertexCount * totalFrames * 4);
|
|
16
|
+
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
|
+
const mixer = new AnimationMixer(root);
|
|
23
|
+
const bounds = new Box3();
|
|
24
|
+
const clipTable = [];
|
|
25
|
+
const _si = new Vector4();
|
|
26
|
+
const _sw = new Vector4();
|
|
27
|
+
const _bone = new Matrix4();
|
|
28
|
+
const _acc = new Matrix4();
|
|
29
|
+
const _skin = new Matrix4();
|
|
30
|
+
const _p = new Vector3();
|
|
31
|
+
const _bp = new Vector3();
|
|
32
|
+
const _n = new Vector3();
|
|
33
|
+
let rowOffset = 0;
|
|
34
|
+
clips.forEach((clip, ci) => {
|
|
35
|
+
const frames = frameCounts[ci];
|
|
36
|
+
const action = mixer.clipAction(clip);
|
|
37
|
+
action.play();
|
|
38
|
+
let maxDeltaSq = 0;
|
|
39
|
+
for (let f = 0; f < frames; f++) {
|
|
40
|
+
mixer.setTime(f / frames * clip.duration);
|
|
41
|
+
root.updateMatrixWorld(true);
|
|
42
|
+
const row = rowOffset + f;
|
|
43
|
+
for (let v = 0; v < vertexCount; v++) {
|
|
44
|
+
_si.fromBufferAttribute(skinIndex, v);
|
|
45
|
+
_sw.fromBufferAttribute(skinWeight, v);
|
|
46
|
+
_acc.elements.fill(0);
|
|
47
|
+
for (let i = 0; i < 4; i++) {
|
|
48
|
+
const w = _sw.getComponent(i);
|
|
49
|
+
if (w === 0) continue;
|
|
50
|
+
const bi = _si.getComponent(i);
|
|
51
|
+
_bone.multiplyMatrices(skeleton.bones[bi].matrixWorld, skeleton.boneInverses[bi]);
|
|
52
|
+
const ae = _acc.elements;
|
|
53
|
+
const be = _bone.elements;
|
|
54
|
+
for (let e = 0; e < 16; e++) ae[e] += be[e] * w;
|
|
55
|
+
}
|
|
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
|
+
}
|
|
75
|
+
}
|
|
76
|
+
action.stop();
|
|
77
|
+
clipTable.push({
|
|
78
|
+
name: clip.name,
|
|
79
|
+
startFrame: rowOffset,
|
|
80
|
+
frames,
|
|
81
|
+
fps: frames / clip.duration,
|
|
82
|
+
duration: clip.duration,
|
|
83
|
+
maxDelta: Math.sqrt(maxDeltaSq)
|
|
84
|
+
});
|
|
85
|
+
rowOffset += frames;
|
|
86
|
+
});
|
|
87
|
+
mixer.stopAllAction();
|
|
88
|
+
mixer.setTime(0);
|
|
89
|
+
root.updateMatrixWorld(true);
|
|
90
|
+
return {
|
|
91
|
+
positionTexture: makeVATTexture(posData, vertexCount, totalFrames),
|
|
92
|
+
normalTexture: makeVATTexture(nrmData, vertexCount, totalFrames),
|
|
93
|
+
clips: clipTable,
|
|
94
|
+
bounds,
|
|
95
|
+
vertexCount,
|
|
96
|
+
totalFrames,
|
|
97
|
+
encoding: "delta"
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function makeVATTexture(data, width, height, type = FloatType) {
|
|
101
|
+
const tex = new DataTexture(data, width, height, RGBAFormat, type);
|
|
102
|
+
tex.minFilter = NearestFilter;
|
|
103
|
+
tex.magFilter = NearestFilter;
|
|
104
|
+
tex.generateMipmaps = false;
|
|
105
|
+
tex.needsUpdate = true;
|
|
106
|
+
return tex;
|
|
107
|
+
}
|
|
108
|
+
function serializeVAT(vat, { precision = "float16" } = {}) {
|
|
109
|
+
const pos = vat.positionTexture.image.data;
|
|
110
|
+
const nrm = vat.normalTexture.image.data;
|
|
111
|
+
const manifest = {
|
|
112
|
+
version: 1,
|
|
113
|
+
vertexCount: vat.vertexCount,
|
|
114
|
+
totalFrames: vat.totalFrames,
|
|
115
|
+
encoding: vat.encoding,
|
|
116
|
+
precision,
|
|
117
|
+
clips: vat.clips,
|
|
118
|
+
bounds: {
|
|
119
|
+
min: [vat.bounds.min.x, vat.bounds.min.y, vat.bounds.min.z],
|
|
120
|
+
max: [vat.bounds.max.x, vat.bounds.max.y, vat.bounds.max.z]
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
return { manifest, position: encode(pos, precision), normal: encode(nrm, precision) };
|
|
124
|
+
}
|
|
125
|
+
function encode(src, precision) {
|
|
126
|
+
if (precision === "float32") {
|
|
127
|
+
return src.slice().buffer;
|
|
128
|
+
}
|
|
129
|
+
const half = new Uint16Array(src.length);
|
|
130
|
+
for (let i = 0; i < src.length; i++) half[i] = DataUtils.toHalfFloat(src[i]);
|
|
131
|
+
return half.buffer;
|
|
132
|
+
}
|
|
133
|
+
function loadVAT(serialized) {
|
|
134
|
+
const { manifest, position, normal } = serialized;
|
|
135
|
+
const { vertexCount, totalFrames, precision } = manifest;
|
|
136
|
+
const type = precision === "float32" ? FloatType : HalfFloatType;
|
|
137
|
+
const posData = precision === "float32" ? new Float32Array(position) : new Uint16Array(position);
|
|
138
|
+
const nrmData = precision === "float32" ? new Float32Array(normal) : new Uint16Array(normal);
|
|
139
|
+
const bounds = new Box3(
|
|
140
|
+
new Vector3().fromArray(manifest.bounds.min),
|
|
141
|
+
new Vector3().fromArray(manifest.bounds.max)
|
|
142
|
+
);
|
|
143
|
+
return {
|
|
144
|
+
positionTexture: makeVATTexture(posData, vertexCount, totalFrames, type),
|
|
145
|
+
normalTexture: makeVATTexture(nrmData, vertexCount, totalFrames, type),
|
|
146
|
+
clips: manifest.clips,
|
|
147
|
+
bounds,
|
|
148
|
+
vertexCount,
|
|
149
|
+
totalFrames,
|
|
150
|
+
encoding: manifest.encoding
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export { MAX_TEXTURE_SIZE, bakeVAT, loadVAT, makeVATTexture, serializeVAT };
|
package/dist/tsl.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Node } from 'three/webgpu';
|
|
2
|
+
import { V as VAT } from './types-DVtQvVjk.js';
|
|
3
|
+
import 'three';
|
|
4
|
+
|
|
5
|
+
/** A fluent TSL float node (has `.add`, `.mul`, … via NodeExtensions). */
|
|
6
|
+
type FloatNode = Node<'float'>;
|
|
7
|
+
/** A fluent TSL vec3 node. */
|
|
8
|
+
type Vec3Node = Node<'vec3'>;
|
|
9
|
+
interface VATNodeOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Elapsed-time uniform node (seconds). Create once with `uniform(0)` and set
|
|
12
|
+
* `.value` per frame. Defaults to a fresh `uniform(0)` you can read back.
|
|
13
|
+
*/
|
|
14
|
+
time?: FloatNode;
|
|
15
|
+
/** Which clip to play (index into `vat.clips`). Default `0`. */
|
|
16
|
+
clipIndex?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Max random per-instance time offset in seconds, hashed from `instanceIndex`.
|
|
19
|
+
* `0` (default) plays every instance in lockstep.
|
|
20
|
+
*/
|
|
21
|
+
desync?: number;
|
|
22
|
+
}
|
|
23
|
+
/** Position/normal nodes to assign onto a `MeshStandardNodeMaterial` (or similar). */
|
|
24
|
+
interface VATNodes {
|
|
25
|
+
positionNode: Vec3Node;
|
|
26
|
+
normalNode: Vec3Node;
|
|
27
|
+
/** The time uniform in use — set `.value` each frame. */
|
|
28
|
+
time: FloatNode;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Build TSL decode nodes for a baked VAT, for the WebGPU/TSL renderer path.
|
|
32
|
+
* Per-instance desync comes from `hash(instanceIndex)` — no instanced
|
|
33
|
+
* attributes needed. Shadows work automatically because `positionNode` also
|
|
34
|
+
* feeds the depth pass.
|
|
35
|
+
*
|
|
36
|
+
* v1 limitation: a single clip per material (all instances share `clipIndex`,
|
|
37
|
+
* only their phase is desynced). Per-instance clip variety is future work; use
|
|
38
|
+
* the `three-vat/webgl` path for mixed-clip crowds today.
|
|
39
|
+
*
|
|
40
|
+
* NOTE: the TSL path is verified visually/manually in v1 (no automated GPU test).
|
|
41
|
+
*/
|
|
42
|
+
declare function vatNodes(vat: VAT, options?: VATNodeOptions): VATNodes;
|
|
43
|
+
|
|
44
|
+
export { type VATNodeOptions, type VATNodes, vatNodes };
|
package/dist/tsl.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { uniform, float, int, hash, instanceIndex, vertexIndex, positionLocal, textureLoad, ivec2, mix } from 'three/tsl';
|
|
2
|
+
|
|
3
|
+
// src/tsl.ts
|
|
4
|
+
function vatNodes(vat, options = {}) {
|
|
5
|
+
const { time = uniform(0), clipIndex = 0, desync = 0 } = options;
|
|
6
|
+
const clip = vat.clips[clipIndex];
|
|
7
|
+
if (!clip) {
|
|
8
|
+
throw new Error(`three-vat: clipIndex ${clipIndex} out of range (${vat.clips.length} clips)`);
|
|
9
|
+
}
|
|
10
|
+
const frames = float(clip.frames);
|
|
11
|
+
const startFrame = int(clip.startFrame);
|
|
12
|
+
const duration = float(clip.frames / clip.fps);
|
|
13
|
+
const offset = hash(instanceIndex).mul(desync);
|
|
14
|
+
const vertexRow = int(vertexIndex);
|
|
15
|
+
const sample = (tex) => {
|
|
16
|
+
const t = time.add(offset).div(duration).fract().mul(frames);
|
|
17
|
+
const f0 = int(t);
|
|
18
|
+
const f1 = int(f0.add(1).toFloat().mod(frames));
|
|
19
|
+
const s0 = textureLoad(tex, ivec2(vertexRow, f0.add(startFrame))).xyz;
|
|
20
|
+
const s1 = textureLoad(tex, ivec2(vertexRow, f1.add(startFrame))).xyz;
|
|
21
|
+
return mix(s0, s1, t.fract());
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
positionNode: positionLocal.add(sample(vat.positionTexture)),
|
|
25
|
+
normalNode: sample(vat.normalTexture).normalize(),
|
|
26
|
+
time
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export { vatNodes };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { DataTexture, Box3 } from 'three';
|
|
2
|
+
|
|
3
|
+
/** One baked animation range within a VAT's stacked frame rows. */
|
|
4
|
+
interface VATClip {
|
|
5
|
+
/** Clip name, taken from the source `AnimationClip`. */
|
|
6
|
+
name: string;
|
|
7
|
+
/** First frame row (y) of this clip in the texture. */
|
|
8
|
+
startFrame: number;
|
|
9
|
+
/** Number of frame rows baked for this clip. */
|
|
10
|
+
frames: number;
|
|
11
|
+
/** Effective frames-per-second of the bake (`frames / duration`). */
|
|
12
|
+
fps: number;
|
|
13
|
+
/** Source clip duration in seconds. */
|
|
14
|
+
duration: number;
|
|
15
|
+
/**
|
|
16
|
+
* Largest per-vertex position-delta magnitude (metres) across the clip.
|
|
17
|
+
* Near-zero means the clip baked as a frozen pose — the diagnostic for a
|
|
18
|
+
* mis-targeted or genuinely static clip.
|
|
19
|
+
*/
|
|
20
|
+
maxDelta: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A baked Vertex Animation Texture: the position/normal `DataTexture`s plus the
|
|
24
|
+
* clip table and bounds needed to decode and render them. Produced by
|
|
25
|
+
* {@link bakeVAT} (runtime) or `loadVAT` (offline); the two paths yield
|
|
26
|
+
* identical objects.
|
|
27
|
+
*/
|
|
28
|
+
interface VAT {
|
|
29
|
+
/** RGBA float texture of per-vertex position deltas (`x = vertex`, `y = frame`). */
|
|
30
|
+
positionTexture: DataTexture;
|
|
31
|
+
/** RGBA float texture of per-vertex absolute normals (`x = vertex`, `y = frame`). */
|
|
32
|
+
normalTexture: DataTexture;
|
|
33
|
+
/** Clip table: name → `{ startFrame, frames, fps, ... }`. */
|
|
34
|
+
clips: VATClip[];
|
|
35
|
+
/** Union of every baked frame's bounds; use as the geometry bounding box. */
|
|
36
|
+
bounds: Box3;
|
|
37
|
+
/** Vertex count (texture width). */
|
|
38
|
+
vertexCount: number;
|
|
39
|
+
/** Total frame rows across all clips (texture height). */
|
|
40
|
+
totalFrames: number;
|
|
41
|
+
/** Position encoding. Only `'delta'` in v1. */
|
|
42
|
+
encoding: 'delta';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type { VAT as V, VATClip as a };
|
package/dist/webgl.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { IUniform, BufferGeometry, MeshDepthMaterial, Material } from 'three';
|
|
2
|
+
import { V as VAT } from './types-DVtQvVjk.js';
|
|
3
|
+
|
|
4
|
+
/** The shared uniform driving every VAT-patched material's playback clock. */
|
|
5
|
+
interface VATUniforms {
|
|
6
|
+
uVatTime: IUniform<number>;
|
|
7
|
+
}
|
|
8
|
+
/** Create the shared time uniform. Update `uVatTime.value` once per frame. */
|
|
9
|
+
declare function createVATUniforms(time?: number): VATUniforms;
|
|
10
|
+
/** Per-instance playback state consumed by the patched shader. */
|
|
11
|
+
interface VATInstance {
|
|
12
|
+
clip: Pick<VAT['clips'][number], 'startFrame' | 'frames' | 'fps'>;
|
|
13
|
+
/** Phase offset in seconds — desyncs the crowd. */
|
|
14
|
+
timeOffset: number;
|
|
15
|
+
/** Playback rate multiplier. */
|
|
16
|
+
speed: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Attach the per-instance attributes the WebGL decode reads: clip band, fps,
|
|
20
|
+
* time offset, and speed. Call on the instanced geometry before rendering.
|
|
21
|
+
*/
|
|
22
|
+
declare function addInstancedVATAttributes(geometry: BufferGeometry, instances: VATInstance[]): void;
|
|
23
|
+
/**
|
|
24
|
+
* Patch any built-in material so its vertex stage samples the VAT instead of
|
|
25
|
+
* skinning. Works on the render material and on `MeshDepthMaterial` (needed for
|
|
26
|
+
* instanced shadows — see {@link createVATDepthMaterial}). Mutates and returns
|
|
27
|
+
* the material.
|
|
28
|
+
*/
|
|
29
|
+
declare function patchVATMaterial<T extends Material>(material: T, vat: VAT, uniforms: VATUniforms): T;
|
|
30
|
+
/**
|
|
31
|
+
* Build the `customDepthMaterial` an `InstancedMesh` needs so a VAT crowd casts
|
|
32
|
+
* correctly-deformed shadows instead of bind-pose shadows. Assign the result to
|
|
33
|
+
* `mesh.customDepthMaterial` (and, for point lights, mirror with a patched
|
|
34
|
+
* `MeshDistanceMaterial`).
|
|
35
|
+
*/
|
|
36
|
+
declare function createVATDepthMaterial(vat: VAT, uniforms: VATUniforms): MeshDepthMaterial;
|
|
37
|
+
|
|
38
|
+
export { type VATInstance, type VATUniforms, addInstancedVATAttributes, createVATDepthMaterial, createVATUniforms, patchVATMaterial };
|
package/dist/webgl.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { InstancedBufferAttribute, MeshDepthMaterial, RGBADepthPacking } from 'three';
|
|
2
|
+
|
|
3
|
+
// src/webgl.ts
|
|
4
|
+
function createVATUniforms(time = 0) {
|
|
5
|
+
return { uVatTime: { value: time } };
|
|
6
|
+
}
|
|
7
|
+
function addInstancedVATAttributes(geometry, instances) {
|
|
8
|
+
const n = instances.length;
|
|
9
|
+
const clipStart = new Float32Array(n);
|
|
10
|
+
const clipFrames = new Float32Array(n);
|
|
11
|
+
const clipFps = new Float32Array(n);
|
|
12
|
+
const timeOffset = new Float32Array(n);
|
|
13
|
+
const speed = new Float32Array(n);
|
|
14
|
+
for (let i = 0; i < n; i++) {
|
|
15
|
+
const inst = instances[i];
|
|
16
|
+
clipStart[i] = inst.clip.startFrame;
|
|
17
|
+
clipFrames[i] = inst.clip.frames;
|
|
18
|
+
clipFps[i] = inst.clip.fps;
|
|
19
|
+
timeOffset[i] = inst.timeOffset;
|
|
20
|
+
speed[i] = inst.speed;
|
|
21
|
+
}
|
|
22
|
+
geometry.setAttribute("aClipStart", new InstancedBufferAttribute(clipStart, 1));
|
|
23
|
+
geometry.setAttribute("aClipFrames", new InstancedBufferAttribute(clipFrames, 1));
|
|
24
|
+
geometry.setAttribute("aClipFps", new InstancedBufferAttribute(clipFps, 1));
|
|
25
|
+
geometry.setAttribute("aTimeOffset", new InstancedBufferAttribute(timeOffset, 1));
|
|
26
|
+
geometry.setAttribute("aSpeed", new InstancedBufferAttribute(speed, 1));
|
|
27
|
+
}
|
|
28
|
+
var DECODE_PRELUDE = (
|
|
29
|
+
/* glsl */
|
|
30
|
+
`
|
|
31
|
+
uniform highp sampler2D uVatPosTex;
|
|
32
|
+
uniform highp sampler2D uVatNrmTex;
|
|
33
|
+
uniform float uVatTime;
|
|
34
|
+
attribute float aClipStart;
|
|
35
|
+
attribute float aClipFrames;
|
|
36
|
+
attribute float aClipFps;
|
|
37
|
+
attribute float aTimeOffset;
|
|
38
|
+
attribute float aSpeed;
|
|
39
|
+
vec3 vatSample( const in sampler2D tex ) {
|
|
40
|
+
float duration = aClipFrames / aClipFps;
|
|
41
|
+
float t = fract( ( uVatTime * aSpeed + aTimeOffset ) / duration ) * aClipFrames;
|
|
42
|
+
int f0 = int( t );
|
|
43
|
+
int f1 = int( mod( float( f0 + 1 ), aClipFrames ) );
|
|
44
|
+
vec3 s0 = texelFetch( tex, ivec2( gl_VertexID, f0 + int( aClipStart ) ), 0 ).xyz;
|
|
45
|
+
vec3 s1 = texelFetch( tex, ivec2( gl_VertexID, f1 + int( aClipStart ) ), 0 ).xyz;
|
|
46
|
+
return mix( s0, s1, fract( t ) );
|
|
47
|
+
}
|
|
48
|
+
`
|
|
49
|
+
);
|
|
50
|
+
var DECODE_POSITION = (
|
|
51
|
+
/* glsl */
|
|
52
|
+
`
|
|
53
|
+
vec3 transformed = position + vatSample( uVatPosTex );
|
|
54
|
+
`
|
|
55
|
+
);
|
|
56
|
+
var DECODE_NORMAL = (
|
|
57
|
+
/* glsl */
|
|
58
|
+
`
|
|
59
|
+
vec3 objectNormal = normalize( vatSample( uVatNrmTex ) );
|
|
60
|
+
#ifdef USE_TANGENT
|
|
61
|
+
vec3 objectTangent = vec3( tangent.xyz );
|
|
62
|
+
#endif
|
|
63
|
+
`
|
|
64
|
+
);
|
|
65
|
+
function patchVATMaterial(material, vat, uniforms) {
|
|
66
|
+
material.onBeforeCompile = (shader) => {
|
|
67
|
+
shader.uniforms.uVatPosTex = { value: vat.positionTexture };
|
|
68
|
+
shader.uniforms.uVatNrmTex = { value: vat.normalTexture };
|
|
69
|
+
shader.uniforms.uVatTime = uniforms.uVatTime;
|
|
70
|
+
shader.vertexShader = DECODE_PRELUDE + shader.vertexShader.replace("#include <begin_vertex>", DECODE_POSITION).replace("#include <beginnormal_vertex>", DECODE_NORMAL);
|
|
71
|
+
};
|
|
72
|
+
material.customProgramCacheKey = () => "three-vat";
|
|
73
|
+
return material;
|
|
74
|
+
}
|
|
75
|
+
function createVATDepthMaterial(vat, uniforms) {
|
|
76
|
+
const depth = new MeshDepthMaterial({ depthPacking: RGBADepthPacking });
|
|
77
|
+
patchVATMaterial(depth, vat, uniforms);
|
|
78
|
+
return depth;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export { addInstancedVATAttributes, createVATDepthMaterial, createVATUniforms, patchVATMaterial };
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "three-vat",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Bake glTF animation clips into vertex animation textures for zero-CPU instanced crowds in three.js.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Mike Fernandez <mike.fernandez.sec@gmail.com>",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/MikeFernandez-Pro/three-vat.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/MikeFernandez-Pro/three-vat/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/MikeFernandez-Pro/three-vat#readme",
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"files": [
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"import": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./webgl": {
|
|
29
|
+
"types": "./dist/webgl.d.ts",
|
|
30
|
+
"import": "./dist/webgl.js"
|
|
31
|
+
},
|
|
32
|
+
"./tsl": {
|
|
33
|
+
"types": "./dist/tsl.d.ts",
|
|
34
|
+
"import": "./dist/tsl.js"
|
|
35
|
+
}
|
|
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
|
+
"keywords": [
|
|
47
|
+
"three",
|
|
48
|
+
"threejs",
|
|
49
|
+
"vat",
|
|
50
|
+
"vertex-animation-texture",
|
|
51
|
+
"instancing",
|
|
52
|
+
"instancedmesh",
|
|
53
|
+
"crowd",
|
|
54
|
+
"animation",
|
|
55
|
+
"skinning",
|
|
56
|
+
"webgpu",
|
|
57
|
+
"tsl"
|
|
58
|
+
],
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"three": ">=0.185.0"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@types/three": "^0.185.0",
|
|
64
|
+
"three": "^0.185.0",
|
|
65
|
+
"tsup": "^8.3.0",
|
|
66
|
+
"typescript": "^5.6.0",
|
|
67
|
+
"vitest": "^2.1.0"
|
|
68
|
+
},
|
|
69
|
+
"engines": {
|
|
70
|
+
"node": ">=18"
|
|
71
|
+
}
|
|
72
|
+
}
|