three-vat 1.0.1 → 2.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.
@@ -0,0 +1,211 @@
1
+ import { FloatType, DataTexture, RGBAFormat, NearestFilter } from 'three';
2
+
3
+ // src/vat-texture.ts
4
+ var MAX_TEXTURE_SIZE = 16384;
5
+ function makeVATTexture(data, width, height, type = FloatType) {
6
+ const tex = new DataTexture(data, width, height, RGBAFormat, type);
7
+ tex.minFilter = NearestFilter;
8
+ tex.magFilter = NearestFilter;
9
+ tex.generateMipmaps = false;
10
+ tex.needsUpdate = true;
11
+ return tex;
12
+ }
13
+
14
+ // src/instance-playback.ts
15
+ var PACK_TEXELS = {
16
+ clip: 0,
17
+ playback: 1,
18
+ fade: 2
19
+ };
20
+ var PACK_WIDTH = 3;
21
+ var PACK_STRIDE = PACK_WIDTH * 4;
22
+ var LoopMode = {
23
+ /** Play the clip end to end, forever (or `repetitions` times). */
24
+ Repeat: 0,
25
+ /** Play the clip through once. */
26
+ Once: 1,
27
+ /** Play forward, then backward, without baking the reversed frames. */
28
+ PingPong: 2
29
+ };
30
+ var EndMode = {
31
+ /** Hold the last frame. */
32
+ Clamp: 0,
33
+ /** Return to the first frame. */
34
+ Rewind: 1
35
+ };
36
+ var INFINITE_REPETITIONS = -1;
37
+ var MAX_FADE_DURATION = 0.25;
38
+ function defaultRepetitions(loopMode) {
39
+ return loopMode === LoopMode.Repeat ? INFINITE_REPETITIONS : 1;
40
+ }
41
+ var LIBRARY_PLAYBACK_DEFAULTS = {
42
+ loopMode: LoopMode.Repeat,
43
+ repetitions: defaultRepetitions(LoopMode.Repeat),
44
+ endMode: EndMode.Clamp,
45
+ speed: 1
46
+ };
47
+ function resolvedPlaybackOf(instance) {
48
+ const { clip } = instance;
49
+ const clipLoopMode = clip.loopMode ?? LIBRARY_PLAYBACK_DEFAULTS.loopMode;
50
+ const loopMode = instance.loopMode ?? clipLoopMode;
51
+ const clipRepetitions = loopMode === clipLoopMode ? clip.repetitions : void 0;
52
+ return {
53
+ loopMode,
54
+ repetitions: instance.repetitions ?? clipRepetitions ?? defaultRepetitions(loopMode),
55
+ endMode: instance.endMode ?? clip.endMode ?? LIBRARY_PLAYBACK_DEFAULTS.endMode,
56
+ speed: instance.speed ?? clip.speed ?? LIBRARY_PLAYBACK_DEFAULTS.speed
57
+ };
58
+ }
59
+ function fadeOf(instance) {
60
+ const duration = Math.min(instance.fadeDuration ?? 0, MAX_FADE_DURATION);
61
+ const from = instance.from;
62
+ if (!from || duration <= 0 || from.frames <= 0) return null;
63
+ return { from, duration };
64
+ }
65
+ function fadeRowOf(from) {
66
+ return from.startFrame + Math.max(Math.min(Math.floor(from.phase * from.frames), from.frames - 1), 0);
67
+ }
68
+ function resolveVATFrame(instance, time) {
69
+ const { clip } = instance;
70
+ const frames = clip.frames;
71
+ const last = frames - 1;
72
+ const duration = frames / clip.fps;
73
+ const { loopMode, repetitions, endMode, speed } = resolvedPlaybackOf(instance);
74
+ const local = (time - instance.startTime) * speed;
75
+ const loops = local / duration;
76
+ const started = local >= 0;
77
+ const finished = started && repetitions !== INFINITE_REPETITIONS && loops >= repetitions;
78
+ let phase;
79
+ let wraps;
80
+ if (!started) {
81
+ phase = 0;
82
+ wraps = false;
83
+ } else if (finished) {
84
+ phase = endMode === EndMode.Clamp ? 1 : 0;
85
+ wraps = false;
86
+ } else if (loopMode === LoopMode.PingPong) {
87
+ const m = loops % 2;
88
+ phase = m < 1 ? m : 2 - m;
89
+ wraps = false;
90
+ } else {
91
+ phase = loops % 1;
92
+ wraps = true;
93
+ }
94
+ const f = phase * (wraps ? frames : last);
95
+ const f0 = Math.min(Math.floor(f), last);
96
+ const f1 = wraps ? (f0 + 1) % frames : Math.min(f0 + 1, last);
97
+ const row = clip.startFrame + f0;
98
+ const fading = fadeOf(instance);
99
+ const elapsed = fading ? (time - instance.startTime) / fading.duration : 0;
100
+ return {
101
+ row,
102
+ rowNext: clip.startFrame + f1,
103
+ mix: f - f0,
104
+ wraps,
105
+ finished,
106
+ phase,
107
+ fadeRow: fading ? fadeRowOf(fading.from) : row,
108
+ fadeWeight: fading ? 1 - Math.min(Math.max(elapsed, 0), 1) : 0
109
+ };
110
+ }
111
+ function createVATPlaybackTexture(instances) {
112
+ const count = instances.length;
113
+ if (count < 1) {
114
+ throw new Error(
115
+ "three-vat: a crowd needs at least one instance \u2014 a playback texture is one row per instance, and there is no zero-row texture to carry none"
116
+ );
117
+ }
118
+ if (count > MAX_TEXTURE_SIZE) {
119
+ throw new Error(
120
+ `three-vat: a crowd of ${count} instances needs ${count} rows of playback texture, past the ${MAX_TEXTURE_SIZE}-row ceiling \u2014 the playback texture holds one row per instance, so MAX_TEXTURE_SIZE is the instance ceiling.`
121
+ );
122
+ }
123
+ const data = new Float32Array(count * PACK_STRIDE);
124
+ for (let i = 0; i < count; i++) writePack(data, i, instances[i]);
125
+ return { texture: makeVATTexture(data, PACK_WIDTH, count), count };
126
+ }
127
+ var FORWARD_ONLY_REASON = "a baked band plays forward from its own first row, so a negative speed would freeze it on that row rather than run it backwards \u2014 bake a reversed clip instead. A speed of 0 is a held first row, and is fine";
128
+ var rowStart = (index) => index * PACK_STRIDE;
129
+ var texelStart = (index, field) => rowStart(index) + field * 4;
130
+ function writePack(data, index, instance) {
131
+ const clip = texelStart(index, PACK_TEXELS.clip);
132
+ const playback = texelStart(index, PACK_TEXELS.playback);
133
+ const fade = texelStart(index, PACK_TEXELS.fade);
134
+ const policy = resolvedPlaybackOf(instance);
135
+ if (policy.speed < 0) {
136
+ throw new Error(`three-vat: instance ${index} has speed ${policy.speed}; ${FORWARD_ONLY_REASON}.`);
137
+ }
138
+ data[clip] = instance.clip.startFrame;
139
+ data[clip + 1] = instance.clip.frames;
140
+ data[clip + 2] = instance.clip.fps;
141
+ data[clip + 3] = policy.speed;
142
+ data[playback] = instance.startTime;
143
+ data[playback + 1] = policy.loopMode;
144
+ data[playback + 2] = policy.repetitions;
145
+ data[playback + 3] = policy.endMode;
146
+ const fading = fadeOf(instance);
147
+ data[fade] = fading ? fading.from.startFrame : 0;
148
+ data[fade + 1] = fading ? fading.from.frames : 0;
149
+ data[fade + 2] = fading ? fading.from.phase : 0;
150
+ data[fade + 3] = fading ? fading.duration : 0;
151
+ }
152
+ function readPack(data, index) {
153
+ const clip = texelStart(index, PACK_TEXELS.clip);
154
+ const playback = texelStart(index, PACK_TEXELS.playback);
155
+ return {
156
+ clip: { startFrame: data[clip], frames: data[clip + 1], fps: data[clip + 2] },
157
+ startTime: data[playback],
158
+ speed: data[clip + 3],
159
+ loopMode: data[playback + 1],
160
+ repetitions: data[playback + 2],
161
+ endMode: data[playback + 3]
162
+ };
163
+ }
164
+ function assertInstance(playback, index) {
165
+ if (!Number.isInteger(index) || index < 0 || index >= playback.count) {
166
+ throw new Error(`three-vat: instance ${index} is outside this crowd of ${playback.count}`);
167
+ }
168
+ }
169
+ function setVATInstance(playback, index, instance) {
170
+ assertInstance(playback, index);
171
+ const data = playback.texture.image.data;
172
+ const fading = instance.from === void 0 && (instance.fadeDuration ?? 0) > 0 ? { ...instance, from: freezeOf(data, index, instance.startTime) } : instance;
173
+ writePack(data, index, fading);
174
+ playback.texture.addUpdateRange(rowStart(index), PACK_STRIDE);
175
+ playback.texture.needsUpdate = true;
176
+ }
177
+ function freezeOf(data, index, time) {
178
+ const outgoing = readPack(data, index);
179
+ const { row } = resolveVATFrame(outgoing, time);
180
+ return {
181
+ startFrame: outgoing.clip.startFrame,
182
+ frames: outgoing.clip.frames,
183
+ // The row the instance is actually displaying at that moment — so a fade
184
+ // out of a finished one-shot freezes the end pose it was holding, not the
185
+ // first row of a clip it stopped playing seconds ago.
186
+ //
187
+ // Named as the phase at the *centre* of that row rather than the playback
188
+ // phase itself, because {@link fadeRowOf} is what reads it back and the two
189
+ // do not spread a phase the same way: a bouncing ping-pong is up to a row
190
+ // apart between them, and rounding in a shader could cost another. Half a
191
+ // row of slack costs nothing and lands all three decodes on this row.
192
+ phase: (row - outgoing.clip.startFrame + 0.5) / outgoing.clip.frames
193
+ };
194
+ }
195
+ function endsAt(instance) {
196
+ const { repetitions, speed } = resolvedPlaybackOf(instance);
197
+ if (repetitions === INFINITE_REPETITIONS || speed <= 0) return null;
198
+ const duration = instance.clip.frames / instance.clip.fps;
199
+ return instance.startTime + duration * repetitions / speed;
200
+ }
201
+
202
+ // src/rig-texture.ts
203
+ var RIG_TEXELS = {
204
+ /** `(qx, qy, qz, qw)` — the slot's rotation, on one hemisphere with its neighbouring rows. */
205
+ rotation: 0,
206
+ /** `(tx, ty, tz, s)` — where the slot puts the origin, and its uniform scale in the spare component. */
207
+ placement: 1
208
+ };
209
+ var RIG_TEXELS_PER_SLOT = 2;
210
+
211
+ export { EndMode, FORWARD_ONLY_REASON, INFINITE_REPETITIONS, LIBRARY_PLAYBACK_DEFAULTS, LoopMode, MAX_FADE_DURATION, MAX_TEXTURE_SIZE, PACK_TEXELS, RIG_TEXELS, RIG_TEXELS_PER_SLOT, createVATPlaybackTexture, endsAt, makeVATTexture, resolveVATFrame, setVATInstance };
@@ -0,0 +1,53 @@
1
+ // src/baked-normals.ts
2
+ var NORMAL_READING_FLAGS = [
3
+ "isMeshStandardMaterial",
4
+ "isMeshPhongMaterial",
5
+ "isMeshLambertMaterial",
6
+ "isMeshToonMaterial",
7
+ "isMeshNormalMaterial",
8
+ "isMeshMatcapMaterial"
9
+ ];
10
+ function needsBakedNormal(material) {
11
+ if (material.flatShading === true) return false;
12
+ const flags = material;
13
+ return NORMAL_READING_FLAGS.some((flag) => flags[flag] === true);
14
+ }
15
+ function assertBakedNormal(vat, material) {
16
+ if (vat.encoding !== "delta" || vat.normalTexture !== null || !needsBakedNormal(material)) return;
17
+ throw new Error(
18
+ `three-vat: material "${material.name || "(unnamed)"}" (${material.type}) shades from a normal, but this VAT was baked with \`bakeNormals: false\` and carries none \u2014 the crowd would be lit by its rest pose. Set \`flatShading: true\` on the material (three then derives the normal from the deformed position, per fragment, which is the right normal for a posed mesh), or use an unlit material such as MeshBasicMaterial, or bake with \`bakeNormals: true\`.`
19
+ );
20
+ }
21
+
22
+ // src/carrier.ts
23
+ function isBatchedCarrier(carrier) {
24
+ return carrier?.isBatchedMesh === true;
25
+ }
26
+ function rangeOf(batch, geometryId) {
27
+ try {
28
+ return batch.getGeometryRangeAt(geometryId);
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+ function assertVATCarrier(carrier, vat) {
34
+ if (!isBatchedCarrier(carrier)) return;
35
+ if (rangeOf(carrier, 1)) {
36
+ throw new Error(
37
+ "three-vat: a BatchedMesh carrier must hold exactly one geometry \u2014 the VAT\u2019s own. Both decode paths index the VAT by the vertex index, which on a batch is the geometry\u2019s vertexStart plus the vertex, so a second geometry\u2019s instances read another character\u2019s rows. One VAT, one geometry, N instances: mixing characters in one draw needs one VAT texture each, and a sampler is a uniform per draw call."
38
+ );
39
+ }
40
+ const range = rangeOf(carrier, 0);
41
+ if (!range) {
42
+ throw new Error(
43
+ "three-vat: this BatchedMesh holds no geometry \u2014 add `vat.geometry` with `addGeometry` before patching a material for it."
44
+ );
45
+ }
46
+ if (range.vertexStart !== 0 || range.vertexCount !== vat.vertexCount) {
47
+ throw new Error(
48
+ `three-vat: this BatchedMesh\u2019s geometry spans ${range.vertexCount} vertices from ${range.vertexStart}, and the VAT has ${vat.vertexCount} from 0. The decode reads the VAT at the batch\u2019s own vertex index, so the batch must hold \`vat.geometry\` and nothing before it.`
49
+ );
50
+ }
51
+ }
52
+
53
+ export { assertBakedNormal, assertVATCarrier, isBatchedCarrier };
package/dist/index.d.ts CHANGED
@@ -1,16 +1,16 @@
1
- import { Object3D, AnimationClip, TypedArray, TextureDataType, DataTexture } from 'three';
2
- import { V as VAT } from './instance-playback-BrGBIKLe.js';
3
- export { a as VATClip, b as VATClock, c as VATCrowd, d as VATInstance, e as addVATInstanceAttributes } from './instance-playback-BrGBIKLe.js';
1
+ import { AnimationClip, AnimationAction, Object3D, TypedArray, TextureDataType, DataTexture } from 'three';
2
+ import { D as DeltaVAT, R as RigVAT, V as VAT } from './carrier-BXaLAPRO.js';
3
+ export { E as EndMode, I as INFINITE_REPETITIONS, L as LoopMode, M as MAX_FADE_DURATION, a as VATBase, b as VATCarrier, c as VATClip, d as VATClipDefaults, e as VATClock, f as VATCrowd, g as VATFadeFrom, h as VATFrame, i as VATInstance, j as VATPlaybackTexture, k as createVATPlaybackTexture, l as endsAt, r as resolveVATFrame, s as setVATInstance } from './carrier-BXaLAPRO.js';
4
4
 
5
5
  /**
6
- * Conservative fallback texture-dimension cap, used when the caller does not
7
- * pass `maxTextureSize`. This is the WebGL2 *spec floor for high-end desktop*,
8
- * not a guarantee — plenty of mobile GPUs report 4096 or 8192. The baker is
9
- * renderer-agnostic by design (it runs in Node, and in a Web Worker), so it cannot
10
- * query the real limit itself: pass `getMaxTextureSize(renderer)` from
11
- * `three-vat/webgl` or `three-vat/tsl` whenever a renderer exists.
6
+ * What {@link bakeVAT} takes for each animation: the clip itself, or an
7
+ * `AnimationAction` already configured the way three taught you.
8
+ *
9
+ * An action costs the baker nothing — it builds an `AnimationMixer` to pose the
10
+ * mesh either way — and buys the caller per-clip defaults every instance of
11
+ * that clip inherits ({@link VATClipDefaults}).
12
12
  */
13
- declare const MAX_TEXTURE_SIZE = 16384;
13
+ type BakeInput = AnimationClip | AnimationAction;
14
14
  interface BakeOptions {
15
15
  /** Sample rate in frames per second. Default `30`. */
16
16
  fps?: number;
@@ -21,10 +21,76 @@ interface BakeOptions {
21
21
  * VAT that allocates on your desktop and fails on a phone.
22
22
  */
23
23
  maxTextureSize?: number;
24
+ /**
25
+ * Bake the normal texture. Default `true`.
26
+ *
27
+ * Turning it off halves the VAT — `verts x frames x 16 B x 2` becomes `x 1` —
28
+ * and is correct for exactly two material setups:
29
+ *
30
+ * - **Unlit** (`MeshBasicMaterial`, and its node twin), which never reads a
31
+ * normal, so the texture was pure waste.
32
+ * - **`flatShading: true`**, where three derives the normal from screen-space
33
+ * derivatives of the *deformed* position in the fragment stage. That is the
34
+ * correct normal for the posed mesh, computed for free — the baked one is
35
+ * not merely unnecessary there, it is redundant work.
36
+ *
37
+ * Anything else that shades — a smooth-shaded lit material — would light the
38
+ * crowd by its rest-pose normals, which is visibly wrong (ADR-0002). Both
39
+ * decode paths refuse that pairing loudly rather than render it.
40
+ *
41
+ * Accepted and ignored under the rig encoding, which has no normal texture to
42
+ * drop: normals come out of the skin matrix there. Refusing a no-op would
43
+ * punish the caller who switched encodings and left their options alone.
44
+ */
45
+ bakeNormals?: boolean;
46
+ /**
47
+ * What a frame row holds (ADR-0018). Default `'delta'`.
48
+ *
49
+ * - **`'delta'`** — the vertex encoding: where every vertex ended up, as a
50
+ * position delta and a normal. Source-agnostic — skinning, morph targets
51
+ * and node animation alike — and the default.
52
+ * - **`'rig'`** — the rig encoding: the posed rig, one **slot** per bone as a
53
+ * rotation, a translation and a uniform scale, skinned in the vertex shader
54
+ * from the rest-pose geometry. Two orders of magnitude smaller, no vertex
55
+ * ceiling, and unable to store what a rig cannot express — refused at the
56
+ * bake by name, before a frame is sampled: a morph target whose influence a
57
+ * baked clip animates, or a bone (or rigid part) scaled unevenly. A morph
58
+ * influence no clip animates is folded into the rest pose; a rigid,
59
+ * node-animated part is one slot of weight one; parts reading the same
60
+ * bones through the same bind matrix share slots.
61
+ */
62
+ encoding?: 'delta' | 'rig';
24
63
  }
25
64
  /**
26
- * Bake `AnimationClip`s into a VAT by sampling the posed subtree frame by frame
27
- * on the CPU.
65
+ * Bake animations into a VAT by sampling the posed subtree frame by frame on
66
+ * the CPU.
67
+ *
68
+ * Each entry of `animations` is an `AnimationClip`, or an `AnimationAction`
69
+ * already configured the way three taught you:
70
+ *
71
+ * ```ts
72
+ * const action = mixer.clipAction(deathClip)
73
+ * action.loop = THREE.LoopOnce
74
+ *
75
+ * const vat = bakeVAT(gltf.scene, [walkAction, action, idleClip])
76
+ * ```
77
+ *
78
+ * Every instance that plays `death` then inherits "once, clamped" without the
79
+ * caller saying so again, and may still override any of it. An action costs the
80
+ * bake nothing — it builds an `AnimationMixer` to pose the mesh either way — and
81
+ * a plain clip carries no configuration, so the simple case still needs no
82
+ * mixer at all and takes the library defaults ({@link VATClipDefaults}).
83
+ *
84
+ * `loop`, `repetitions` and `timeScale` are read. **`clampWhenFinished` is
85
+ * not**: it is `false` on every untouched action, so a crowd reads it as the
86
+ * silence it usually is and clamps either way — an instance names
87
+ * `endMode: EndMode.Rewind` to get three's behaviour back.
88
+ * **`time` and `paused` are ignored**: they say where a playhead is sitting,
89
+ * not how the animation is meant to play, and a VAT has no playhead of its own
90
+ * to seed — every instance's position is a function of the shared clock and its
91
+ * own `startTime`. A non-unit `weight` and an additive `blendMode` are refused
92
+ * outright; both describe several actions blended at once, which one baked band
93
+ * cannot be.
28
94
  *
29
95
  * The unit of a bake is the whole subtree under `root`, merged into one vertex
30
96
  * set and recorded in root space (ADR-0008) — so it handles a single
@@ -44,8 +110,41 @@ interface BakeOptions {
44
110
  * approximation becomes visible, so the bake warns once, naming the bone.
45
111
  * Renderer-agnostic — touches no WebGL/WebGPU context — so it runs identically
46
112
  * at runtime, in a Web Worker, and in Node.
113
+ *
114
+ * All of that is the **vertex encoding**, the default. `encoding: 'rig'`
115
+ * stores the posed rig instead — one slot per bone, two texels, skinned in the
116
+ * vertex shader from the rest-pose geometry (ADR-0018) — under the same clip
117
+ * table and the same playback contract, at a fraction of the memory; see
118
+ * {@link BakeOptions.encoding} for what it refuses. Overloaded on the option's
119
+ * literal, so a call that names no encoding, or the vertex encoding, still
120
+ * holds the narrow {@link DeltaVAT} it always did.
47
121
  */
48
- declare function bakeVAT(root: Object3D, clips: AnimationClip[], { fps, maxTextureSize }?: BakeOptions): VAT;
122
+ declare function bakeVAT(root: Object3D, animations: BakeInput[], options?: BakeOptions & {
123
+ encoding?: 'delta';
124
+ }): DeltaVAT;
125
+ declare function bakeVAT(root: Object3D, animations: BakeInput[], options: BakeOptions & {
126
+ encoding: 'rig';
127
+ }): RigVAT;
128
+ declare function bakeVAT(root: Object3D, animations: BakeInput[], options?: BakeOptions): VAT;
129
+
130
+ /**
131
+ * The WebGL2 *spec floor for high-end desktop*, which two things lean on.
132
+ *
133
+ * - **The bake's fallback cap**, used when the caller does not pass
134
+ * `maxTextureSize`. The baker is renderer-agnostic by design (it runs in
135
+ * Node, and in a Web Worker) so it cannot query the real limit itself: pass
136
+ * `getMaxTextureSize(renderer)` from `three-vat/webgl` or `three-vat/tsl`
137
+ * whenever a renderer exists.
138
+ * - **The instance ceiling**, because the playback texture is one row per
139
+ * instance (`createVATPlaybackTexture`, ADR-0016). That one takes no
140
+ * override, the crowd being built long after the bake was sized.
141
+ *
142
+ * Not a guarantee either way: plenty of mobile GPUs report 4096 or 8192, and
143
+ * a crowd between that and this number is refused by the driver at upload
144
+ * rather than here. The bake is where the real limit is worth passing, because
145
+ * it is where the numbers get large.
146
+ */
147
+ declare const MAX_TEXTURE_SIZE = 16384;
49
148
  /**
50
149
  * Build a VAT `DataTexture` with the fixed sampling flags every path relies on:
51
150
  * RGBA, nearest filtering, no mipmaps. Frame interpolation is done manually in
@@ -53,4 +152,4 @@ declare function bakeVAT(root: Object3D, clips: AnimationClip[], { fps, maxTextu
53
152
  */
54
153
  declare function makeVATTexture(data: TypedArray, width: number, height: number, type?: TextureDataType): DataTexture;
55
154
 
56
- export { type BakeOptions, MAX_TEXTURE_SIZE, VAT, bakeVAT, makeVATTexture };
155
+ export { type BakeInput, type BakeOptions, DeltaVAT, MAX_TEXTURE_SIZE, RigVAT, VAT, bakeVAT, makeVATTexture };