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 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
@@ -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 };
@@ -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
+ }