@volter/threejs-runtime 0.5.65

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.
Files changed (70) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +12 -0
  3. package/package.json +62 -0
  4. package/src/adapter/body-marks.ts +69 -0
  5. package/src/adapter/constraint.ts +78 -0
  6. package/src/adapter/first-party-navigation-system.ts +48 -0
  7. package/src/adapter/hierarchy-marks.ts +156 -0
  8. package/src/adapter/ingest/scene-capture.ts +865 -0
  9. package/src/adapter/ingest/structural-ids.ts +139 -0
  10. package/src/adapter/ingest/visible-capture-window.ts +302 -0
  11. package/src/adapter/object3d-authoring-subject.ts +50 -0
  12. package/src/adapter/rapier-physics-adapter.ts +226 -0
  13. package/src/adapter/reflection-probe.ts +75 -0
  14. package/src/adapter/renderer-config.ts +116 -0
  15. package/src/adapter/trigger-volume.ts +29 -0
  16. package/src/ai/navigation.ts +627 -0
  17. package/src/animation/animation-clock.ts +479 -0
  18. package/src/animation/runtime-inspection.ts +45 -0
  19. package/src/animation/xstate-animation-binding.ts +655 -0
  20. package/src/animation/xstate-animation-meta.ts +360 -0
  21. package/src/asset-formats/camera.ts +18 -0
  22. package/src/asset-formats/collider.ts +66 -0
  23. package/src/asset-formats/index.ts +45 -0
  24. package/src/asset-formats/light.ts +50 -0
  25. package/src/asset-formats/material.ts +93 -0
  26. package/src/asset-formats/mesh.ts +78 -0
  27. package/src/asset-formats/particles.ts +426 -0
  28. package/src/asset-formats/render-env.ts +318 -0
  29. package/src/asset-formats/tuples.ts +21 -0
  30. package/src/asset-loaders.ts +242 -0
  31. package/src/asset-parse-error.ts +29 -0
  32. package/src/assets.ts +68 -0
  33. package/src/behavior/xstate-inspection.ts +51 -0
  34. package/src/defaults.ts +135 -0
  35. package/src/ecs/object-marks.ts +75 -0
  36. package/src/ecs/scene-index.ts +439 -0
  37. package/src/ecs/scene-query.ts +43 -0
  38. package/src/ecs/user-data.ts +272 -0
  39. package/src/loader.ts +134 -0
  40. package/src/physics/collider-dimensions.ts +121 -0
  41. package/src/physics/collision-system.ts +76 -0
  42. package/src/physics/physics-registry.ts +83 -0
  43. package/src/physics/transform-writer.ts +41 -0
  44. package/src/render/directional-shadow-fit.ts +158 -0
  45. package/src/render/environment-capture.ts +133 -0
  46. package/src/render/ibl-override-material.ts +202 -0
  47. package/src/render/light-camera-factory.ts +103 -0
  48. package/src/render/matcap-texture.ts +92 -0
  49. package/src/render/material-factory.ts +201 -0
  50. package/src/render/mesh-shadow.ts +18 -0
  51. package/src/render/particles-factory.ts +589 -0
  52. package/src/render/render-batch-system.ts +260 -0
  53. package/src/render/render-features.ts +128 -0
  54. package/src/render/render-settings.ts +71 -0
  55. package/src/render/soft-particle-depth.ts +360 -0
  56. package/src/render/spark-renderer-lifecycle.ts +64 -0
  57. package/src/render/structural-signature.ts +231 -0
  58. package/src/render/viewport-shading.ts +163 -0
  59. package/src/render/voxel-surface.ts +358 -0
  60. package/src/setup/setup-particles.ts +32 -0
  61. package/src/setup/setup-physics.ts +67 -0
  62. package/src/setup/setup-renderer.ts +524 -0
  63. package/src/types-n8ao.d.ts +37 -0
  64. package/src/types-realism-effects.d.ts +61 -0
  65. package/vendor/realism-effects/LICENSE.md +21 -0
  66. package/vendor/realism-effects/UPSTREAM.md +19 -0
  67. package/vendor/realism-effects/dist/index.cjs +3447 -0
  68. package/vendor/realism-effects/dist/index.d.ts +59 -0
  69. package/vendor/realism-effects/dist/index.js +3434 -0
  70. package/vendor/realism-effects/package.json +23 -0
@@ -0,0 +1,655 @@
1
+ import type { SystemFn, SystemOptions, SystemPhaseName } from '@volter/editor-project/core/system-phase';
2
+ import * as THREE from 'three';
3
+ import type { AnyStateMachine } from 'xstate';
4
+ import { deleteUserData, getUserData, setUserData } from '../ecs/user-data';
5
+ import { attachAnimationRuntimeInspection } from './runtime-inspection';
6
+ import {
7
+ type AnimationBoneMask,
8
+ type AnimationMetaStateNodeLike,
9
+ asBlendTreeDef,
10
+ type BlendTreeDef,
11
+ clipNamesOf,
12
+ collectMachineAnimationMeta,
13
+ isBlendTreeAnimationMeta,
14
+ type MachineAnimationMetaEntry,
15
+ type StateAnimationMeta,
16
+ } from './xstate-animation-meta';
17
+
18
+ /**
19
+ * The slice of a host's system runner this binding drives — declared
20
+ * STRUCTURALLY rather than imported, because the runner is
21
+ * `@volter/game-runtime`'s and a twin may not depend on another twin (the
22
+ * project contract owns the phase vocabulary both speak). `createSystemRunner`
23
+ * satisfies it.
24
+ */
25
+ export interface SystemRunnerSlice {
26
+ add(phase: SystemPhaseName, fn: SystemFn, options?: SystemOptions): void;
27
+ remove(phase: SystemPhaseName, fn: SystemFn): void;
28
+ }
29
+
30
+ /**
31
+ * The snapshot half of {@link XStateAnimationActor} — the three members this
32
+ * binder and the editor's machine inspectors actually read off a snapshot.
33
+ * Named here rather than imported so the seam stays XState-free; see the
34
+ * actor's own note for why that matters.
35
+ */
36
+ export interface XStateAnimationSnapshot {
37
+ /** Active state value (`'idle'`, `{ locomotion: 'run' }`). XState's
38
+ * `StateValue`, kept as `unknown` here — only the inspector interprets it. */
39
+ readonly value: unknown;
40
+ /** Live machine context, read for blend-tree parameters. */
41
+ readonly context: unknown;
42
+ /** Meta of every active state, keyed by state id — how the binder learns
43
+ * which states are active and what `meta.animation` they carry. */
44
+ readonly getMeta: () => Record<string, unknown>;
45
+ }
46
+
47
+ /**
48
+ * What this binder needs from a running actor — a MINIMAL STRUCTURAL shape
49
+ * that names no XState type at all. It is satisfied by `Actor<TMachine>`, by
50
+ * `ActorRefFrom<typeof machine>`, and by anything else that can report a
51
+ * snapshot and be subscribed to.
52
+ *
53
+ * TWO REASONS IT IS SPELLED OUT INSTEAD OF IMPORTED, both measured:
54
+ *
55
+ * - A nominal `Actor<AnyStateMachine>` parameter rejects the type XState's own
56
+ * React docs reach for — `ActorRefFrom<typeof machine>`, e.g. in a
57
+ * `useRef<ActorRefFrom<typeof machine> | null>` — which carries no `logic`,
58
+ * `clock` or `options`. The call then fails with a wall of "missing the
59
+ * following properties" and the only obvious escape is
60
+ * `bindXStateAnimation(actor as any, …)`, a cast that also disables checking
61
+ * of the clip map and options.
62
+ * - XState's own `AnyActorRef` fixes that but is DEEP, and a game does not
63
+ * compile against the same physical copy of `xstate` the engine does: a
64
+ * scaffolded project installs its own, and the moment those two copies
65
+ * differ in version TypeScript stops deduplicating them by package id (the
66
+ * id is name + subpath + VERSION) and has to
67
+ * compare `Actor<StateMachine<…>>` from one copy against `AnyActorRef` from
68
+ * the other — structurally, through `system`, `src`, `_parent` and a generic
69
+ * `select`, until it gives up with `TS2321: Excessive stack depth`. Measured
70
+ * on a fresh scaffold + `vgai add humanoid`, where every in-repo gate
71
+ * stayed green because the monorepo resolves exactly one copy. A shape this
72
+ * shallow cannot blow that budget however many copies are in play.
73
+ *
74
+ * Both actor shapes still carry the machine — `Actor.logic`, and `machine` on
75
+ * every `MachineSnapshot` — so {@link machineOfXStateActor} reads it and no
76
+ * caller casts.
77
+ */
78
+ export interface XStateAnimationActor {
79
+ readonly getSnapshot: () => XStateAnimationSnapshot;
80
+ readonly subscribe: (next: (snapshot: XStateAnimationSnapshot) => void) => {
81
+ unsubscribe: () => void;
82
+ };
83
+ }
84
+
85
+ /** A binding-time problem such as a missing clip, bone, or ambiguous layer. */
86
+ export class AnimationBindingError extends Error {
87
+ constructor(
88
+ message: string,
89
+ readonly stateId?: string,
90
+ ) {
91
+ super(message);
92
+ this.name = 'AnimationBindingError';
93
+ }
94
+ }
95
+
96
+ /** The state machine behind a bound actor, from whichever half of the actor
97
+ * shape carries it. Throws rather than silently binding nothing when the
98
+ * actor is not a state-machine actor (a promise/callback actor has no
99
+ * `meta.animation` to collect). */
100
+ export function machineOfXStateActor(actor: XStateAnimationActor): AnyStateMachine {
101
+ const logic = (actor as { logic?: unknown }).logic as AnyStateMachine | undefined;
102
+ if (logic?.root) return logic;
103
+ const snapshot = actor.getSnapshot() as XStateAnimationSnapshot & { machine?: AnyStateMachine };
104
+ if (snapshot?.machine?.root) return snapshot.machine;
105
+ throw new AnimationBindingError(
106
+ '[bindXStateAnimation] the actor exposes no state machine — only a state-machine ' +
107
+ 'actor carries the `meta.animation` this binder reads',
108
+ );
109
+ }
110
+
111
+ export interface XStateAnimationBindingOptions {
112
+ /** Select live numeric/boolean blend-tree parameters from actor context. */
113
+ selectParameters?: (context: unknown) => Record<string, number | boolean>;
114
+ /** Root whose named Bone/Object3D hierarchy is used by `boneMask`. Defaults to mixer root. */
115
+ root?: THREE.Object3D;
116
+ /** Entity that exposes this binding to editor/runtime inspection. Defaults to `root`. */
117
+ owner?: THREE.Object3D;
118
+ /**
119
+ * Engine runner used to register `tick` in the canonical animation phase.
120
+ * Pass `ctx.systems`; disposal removes the callback automatically. When
121
+ * omitted, the caller owns ticking (useful for standalone Three tests).
122
+ */
123
+ systems?: SystemRunnerSlice;
124
+ }
125
+
126
+ export interface XStateAnimationLayerState {
127
+ readonly layer: string;
128
+ readonly stateId: string;
129
+ readonly clips: readonly string[];
130
+ /** Live native AnimationAction weights, including an in-progress crossfade. */
131
+ readonly clipWeights: readonly { readonly clip: string; readonly weight: number }[];
132
+ readonly weight: number;
133
+ readonly blendMode: 'override' | 'additive';
134
+ readonly boneMask?: AnimationBoneMask;
135
+ }
136
+
137
+ export interface XStateAnimationBinding {
138
+ /** Advance native actions/mixer. Register this in the engine animation phase. */
139
+ tick: (dt: number) => void;
140
+ readonly actor: XStateAnimationActor;
141
+ /** Live numeric/boolean values after the binding's optional parameter selector.
142
+ * This is the exact parameter surface used by blend evaluation, exposed so
143
+ * editor instrumentation never guesses by reading actor context directly. */
144
+ getParameters: () => ReadonlyMap<string, number | boolean>;
145
+ /** Current native composition, useful to the editor and game diagnostics. */
146
+ getActiveLayers: () => readonly XStateAnimationLayerState[];
147
+ /** Unsubscribe, stop/uncache actions, and remove owner inspection data. Idempotent. */
148
+ dispose: () => void;
149
+ }
150
+
151
+ let inspectionVersion = 0;
152
+ const inspectionListeners = new Set<() => void>();
153
+
154
+ /** React/useSyncExternalStore-compatible lifecycle signal for live editor inspection. */
155
+ export function subscribeXStateAnimationBindings(listener: () => void): () => void {
156
+ inspectionListeners.add(listener);
157
+ return () => inspectionListeners.delete(listener);
158
+ }
159
+
160
+ /** Monotonic snapshot changed whenever a binding is attached or disposed. */
161
+ export function getXStateAnimationBindingsVersion(): number {
162
+ return inspectionVersion;
163
+ }
164
+
165
+ function notifyInspectionLifecycle(): void {
166
+ inspectionVersion++;
167
+ for (const listener of inspectionListeners) listener();
168
+ }
169
+
170
+ interface WeightedClip {
171
+ clip: string;
172
+ actionKey: string;
173
+ weight: number;
174
+ }
175
+
176
+ interface ActiveState {
177
+ layer: string;
178
+ stateId: string;
179
+ meta: StateAnimationMeta;
180
+ natural: WeightedClip[];
181
+ dominantActionKey: string;
182
+ crossfadeRemaining: number;
183
+ }
184
+
185
+ interface CachedAction {
186
+ action: THREE.AnimationAction;
187
+ clip: THREE.AnimationClip;
188
+ }
189
+
190
+ const layerOf = (meta: StateAnimationMeta): string => meta.layer ?? 'base';
191
+ const layerWeightOf = (meta: StateAnimationMeta): number => meta.weight ?? 1;
192
+ const blendModeOf = (meta: StateAnimationMeta): 'override' | 'additive' =>
193
+ meta.blendMode ?? 'override';
194
+
195
+ function targetNameOf(track: THREE.KeyframeTrack): string {
196
+ try {
197
+ return THREE.PropertyBinding.parseTrackName(track.name).nodeName ?? '';
198
+ } catch {
199
+ const dot = track.name.indexOf('.');
200
+ return dot < 0 ? track.name : track.name.slice(0, dot);
201
+ }
202
+ }
203
+
204
+ function subtreeNames(root: THREE.Object3D, requested: readonly string[], trackNames: Set<string>) {
205
+ const names = new Set<string>();
206
+ const missing: string[] = [];
207
+ for (const requestedName of requested) {
208
+ let found = false;
209
+ root.traverse((object) => {
210
+ if (object.name !== requestedName) return;
211
+ found = true;
212
+ object.traverse((descendant) => {
213
+ if (descendant.name) names.add(descendant.name);
214
+ });
215
+ });
216
+ // Some exporters target a named node not retained as a discoverable Bone.
217
+ // Exact track-target matches remain useful, but cannot imply descendants.
218
+ if (trackNames.has(requestedName)) {
219
+ found = true;
220
+ names.add(requestedName);
221
+ }
222
+ if (!found) missing.push(requestedName);
223
+ }
224
+ return { names, missing };
225
+ }
226
+
227
+ function maskedClip(
228
+ source: THREE.AnimationClip,
229
+ mask: AnimationBoneMask,
230
+ root: THREE.Object3D,
231
+ stateId: string,
232
+ ): THREE.AnimationClip {
233
+ const trackNames = new Set(source.tracks.map(targetNameOf));
234
+ const included = mask.include ? subtreeNames(root, mask.include, trackNames) : undefined;
235
+ const excluded = mask.exclude ? subtreeNames(root, mask.exclude, trackNames) : undefined;
236
+ const missing = [...(included?.missing ?? []), ...(excluded?.missing ?? [])];
237
+ if (missing.length) {
238
+ throw new AnimationBindingError(
239
+ `[bindXStateAnimation] state "${stateId}" boneMask names unknown bones/objects: ${missing.join(', ')}`,
240
+ stateId,
241
+ );
242
+ }
243
+ const tracks = source.tracks.filter((track) => {
244
+ const name = targetNameOf(track);
245
+ return (!included || included.names.has(name)) && !excluded?.names.has(name);
246
+ });
247
+ if (tracks.length === 0) {
248
+ throw new AnimationBindingError(
249
+ `[bindXStateAnimation] state "${stateId}" boneMask removes every track from clip "${source.name}"`,
250
+ stateId,
251
+ );
252
+ }
253
+ return new THREE.AnimationClip(
254
+ source.name,
255
+ source.duration,
256
+ tracks.map((track) => track.clone()),
257
+ );
258
+ }
259
+
260
+ function dominantOf(weights: WeightedClip[]): WeightedClip {
261
+ return weights.reduce((a, b) => (b.weight > a.weight ? b : a), weights[0]!);
262
+ }
263
+
264
+ /**
265
+ * Optional integration that maps a native XState actor to native Three
266
+ * actions. XState remains a behavior primitive; this bridge is useful when a
267
+ * project deliberately chooses to let one behavior statechart emit animation
268
+ * policy, but it is not the engine's animation model. Animation inspection is
269
+ * published separately through `AnimationRuntimeInspection`.
270
+ *
271
+ * One active animated state is allowed per named layer. Parallel XState
272
+ * regions therefore compose lower-body locomotion, upper-body weapon states,
273
+ * facial animation, and other independent layers without introducing a
274
+ * second transition language. Bone masks clone/filter native clip tracks;
275
+ * additive layers use `AnimationUtils.makeClipAdditive` and Three's additive
276
+ * blend mode. Single unlayered machines retain the original clip/action path.
277
+ */
278
+ export function bindXStateAnimation(
279
+ actor: XStateAnimationActor,
280
+ mixer: THREE.AnimationMixer,
281
+ clips: Map<string, THREE.AnimationClip>,
282
+ options: XStateAnimationBindingOptions = {},
283
+ ): XStateAnimationBinding {
284
+ const mixerRoot = mixer.getRoot();
285
+ const root = options.root ?? (mixerRoot instanceof THREE.Object3D ? mixerRoot : undefined);
286
+ if (!root) {
287
+ throw new AnimationBindingError(
288
+ '[bindXStateAnimation] a THREE.Object3D `root` option is required when the mixer uses AnimationObjectGroup',
289
+ );
290
+ }
291
+ const owner = options.owner ?? root;
292
+ const machineRoot = machineOfXStateActor(actor).root as unknown as AnimationMetaStateNodeLike;
293
+ const entries: MachineAnimationMetaEntry[] = collectMachineAnimationMeta(machineRoot);
294
+ const metaByStateId = new Map(entries.map((entry) => [entry.stateId, entry.meta]));
295
+
296
+ for (const entry of entries) {
297
+ for (const clipName of clipNamesOf(entry.meta)) {
298
+ if (!clips.has(clipName)) {
299
+ throw new AnimationBindingError(
300
+ `[bindXStateAnimation] state "${entry.stateId}" references clip "${clipName}", which is ` +
301
+ `not present in the supplied clips map. Known clips: ${[...clips.keys()].join(', ') || '(none)'}`,
302
+ entry.stateId,
303
+ );
304
+ }
305
+ }
306
+ }
307
+
308
+ // Different layers need distinct AnimationActions even when they use the
309
+ // same source clip. A plain base state keeps the original clip identity for
310
+ // backwards-compatible mixer.existingAction(sourceClip) behavior.
311
+ const preparedClips = new Map<string, THREE.AnimationClip>();
312
+ function actionKey(stateId: string, meta: StateAnimationMeta, clipName: string): string {
313
+ const transformed =
314
+ layerOf(meta) !== 'base' || Boolean(meta.boneMask) || blendModeOf(meta) === 'additive';
315
+ return transformed ? `${stateId}\u0000${clipName}` : clipName;
316
+ }
317
+ for (const { stateId, meta } of entries) {
318
+ for (const clipName of clipNamesOf(meta)) {
319
+ const key = actionKey(stateId, meta, clipName);
320
+ if (preparedClips.has(key)) continue;
321
+ const source = clips.get(clipName)!;
322
+ let prepared = meta.boneMask ? maskedClip(source, meta.boneMask, root, stateId) : source;
323
+ if (prepared !== source || key !== clipName) {
324
+ if (prepared === source) prepared = source.clone();
325
+ prepared.name = `${source.name}@${layerOf(meta)}:${stateId}`;
326
+ }
327
+ if (blendModeOf(meta) === 'additive') {
328
+ THREE.AnimationUtils.makeClipAdditive(prepared);
329
+ prepared.blendMode = THREE.AdditiveAnimationBlendMode;
330
+ } else {
331
+ prepared.blendMode = THREE.NormalAnimationBlendMode;
332
+ }
333
+ preparedClips.set(key, prepared);
334
+ }
335
+ }
336
+
337
+ const actionCache = new Map<string, CachedAction>();
338
+ function ensureAction(
339
+ key: string,
340
+ loop: boolean,
341
+ speed: number,
342
+ weight: number,
343
+ ): THREE.AnimationAction {
344
+ let cached = actionCache.get(key);
345
+ if (!cached) {
346
+ const clip = preparedClips.get(key);
347
+ if (!clip)
348
+ throw new AnimationBindingError(`[bindXStateAnimation] prepared clip "${key}" not found`);
349
+ const action = mixer.clipAction(clip);
350
+ action.setEffectiveWeight(0);
351
+ action.play();
352
+ cached = { action, clip };
353
+ actionCache.set(key, cached);
354
+ }
355
+ cached.action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, Infinity);
356
+ cached.action.clampWhenFinished = !loop;
357
+ cached.action.timeScale = speed;
358
+ cached.action.weight = weight;
359
+ return cached.action;
360
+ }
361
+
362
+ function readParameters(context: unknown): Map<string, number | boolean> {
363
+ const raw = options.selectParameters
364
+ ? options.selectParameters(context)
365
+ : (context as Record<string, number | boolean> | null | undefined);
366
+ return new Map(Object.entries(raw ?? {}));
367
+ }
368
+
369
+ function weightsOf(stateId: string, meta: StateAnimationMeta, context: unknown): WeightedClip[] {
370
+ const weights = isBlendTreeAnimationMeta(meta)
371
+ ? evaluateBlendTree(asBlendTreeDef(meta.blendTree), readParameters(context))
372
+ : [{ clip: meta.clip, weight: 1 }];
373
+ return weights.map(({ clip, weight }) => ({
374
+ clip,
375
+ weight,
376
+ actionKey: actionKey(stateId, meta, clip),
377
+ }));
378
+ }
379
+
380
+ const activeLayers = new Map<string, ActiveState>();
381
+ let disposed = false;
382
+ let detachRuntimeInspection = () => {};
383
+ function silence(weights: WeightedClip[], except: string | undefined, duration: number): void {
384
+ for (const weighted of weights) {
385
+ if (weighted.actionKey === except) continue;
386
+ const action = actionCache.get(weighted.actionKey)?.action;
387
+ if (!action) continue;
388
+ if (duration > 0) action.fadeOut(duration);
389
+ else action.setEffectiveWeight(0);
390
+ }
391
+ }
392
+
393
+ function applyLiveBlendWeights(state: ActiveState, context: unknown): void {
394
+ if (!isBlendTreeAnimationMeta(state.meta)) return;
395
+ state.natural = weightsOf(state.stateId, state.meta, context);
396
+ const layerWeight = layerWeightOf(state.meta);
397
+ for (const weighted of state.natural) {
398
+ actionCache.get(weighted.actionKey)?.action.setEffectiveWeight(weighted.weight * layerWeight);
399
+ }
400
+ }
401
+
402
+ function enterState(stateId: string, meta: StateAnimationMeta, context: unknown): void {
403
+ const layer = layerOf(meta);
404
+ const outgoing = activeLayers.get(layer) ?? null;
405
+ const weights = weightsOf(stateId, meta, context);
406
+ const dominant = dominantOf(weights);
407
+ const loop = isBlendTreeAnimationMeta(meta) ? true : meta.loop;
408
+ const speed = isBlendTreeAnimationMeta(meta) ? 1 : meta.speed;
409
+ const layerWeight = layerWeightOf(meta);
410
+
411
+ for (const weighted of weights) ensureAction(weighted.actionKey, loop, speed, layerWeight);
412
+ const dominantAction = actionCache.get(dominant.actionKey)!.action;
413
+ dominantAction.reset().setEffectiveWeight(layerWeight);
414
+ for (const weighted of weights) {
415
+ if (weighted.actionKey === dominant.actionKey) continue;
416
+ actionCache.get(weighted.actionKey)!.action.reset().setEffectiveWeight(0);
417
+ }
418
+
419
+ const duration = meta.crossfade?.duration ?? 0;
420
+ const warp = meta.crossfade?.warp ?? false;
421
+ if (!outgoing) {
422
+ if (duration > 0) dominantAction.fadeIn(duration);
423
+ } else {
424
+ silence(outgoing.natural, outgoing.dominantActionKey, duration);
425
+ const previous = actionCache.get(outgoing.dominantActionKey)?.action;
426
+ if (previous && duration > 0) previous.crossFadeTo(dominantAction, duration, warp);
427
+ else {
428
+ previous?.setEffectiveWeight(0);
429
+ dominantAction.setEffectiveWeight(layerWeight);
430
+ }
431
+ }
432
+
433
+ const active: ActiveState = {
434
+ layer,
435
+ stateId,
436
+ meta,
437
+ natural: weights,
438
+ dominantActionKey: dominant.actionKey,
439
+ crossfadeRemaining: duration,
440
+ };
441
+ activeLayers.set(layer, active);
442
+ if (isBlendTreeAnimationMeta(meta) && duration <= 0) applyLiveBlendWeights(active, context);
443
+ }
444
+
445
+ function activeStateIds(snapshot: { getMeta: () => Record<string, unknown> }): string[] {
446
+ return Object.keys(snapshot.getMeta()).filter((id) => metaByStateId.has(id));
447
+ }
448
+
449
+ function reconcile(snapshot: { getMeta: () => Record<string, unknown>; context: unknown }): void {
450
+ const nextByLayer = new Map<string, string>();
451
+ for (const stateId of activeStateIds(snapshot)) {
452
+ const meta = metaByStateId.get(stateId)!;
453
+ const layer = layerOf(meta);
454
+ const previous = nextByLayer.get(layer);
455
+ if (previous) {
456
+ throw new AnimationBindingError(
457
+ `[bindXStateAnimation] animated states "${previous}" and "${stateId}" are both active ` +
458
+ `on layer "${layer}". Give parallel regions distinct meta.animation.layer names.`,
459
+ stateId,
460
+ );
461
+ }
462
+ nextByLayer.set(layer, stateId);
463
+ }
464
+
465
+ for (const [layer, active] of activeLayers) {
466
+ if (nextByLayer.has(layer)) continue;
467
+ silence(active.natural, undefined, 0);
468
+ activeLayers.delete(layer);
469
+ }
470
+ for (const [layer, stateId] of nextByLayer) {
471
+ if (activeLayers.get(layer)?.stateId === stateId) continue;
472
+ enterState(stateId, metaByStateId.get(stateId)!, snapshot.context);
473
+ }
474
+ }
475
+
476
+ const initialSnapshot = actor.getSnapshot();
477
+ reconcile(initialSnapshot);
478
+ const subscription = actor.subscribe((snapshot) => {
479
+ if (!disposed) reconcile(snapshot);
480
+ });
481
+
482
+ function tick(dt: number): void {
483
+ if (disposed) return;
484
+ const elapsed = dt;
485
+ const context = actor.getSnapshot().context;
486
+ for (const active of activeLayers.values()) {
487
+ if (active.crossfadeRemaining > 0) {
488
+ active.crossfadeRemaining = Math.max(0, active.crossfadeRemaining - elapsed);
489
+ if (active.crossfadeRemaining <= 0) applyLiveBlendWeights(active, context);
490
+ } else {
491
+ applyLiveBlendWeights(active, context);
492
+ }
493
+ }
494
+ mixer.update(elapsed);
495
+ }
496
+
497
+ const binding: XStateAnimationBinding = {
498
+ tick,
499
+ actor,
500
+ getParameters: () => readParameters(actor.getSnapshot().context),
501
+ getActiveLayers: () =>
502
+ [...activeLayers.values()].map((active) => ({
503
+ layer: active.layer,
504
+ stateId: active.stateId,
505
+ clips: active.natural.map((weighted) => weighted.clip),
506
+ clipWeights: active.natural.map((weighted) => ({
507
+ clip: weighted.clip,
508
+ weight: actionCache.get(weighted.actionKey)?.action.getEffectiveWeight() ?? 0,
509
+ })),
510
+ weight: layerWeightOf(active.meta),
511
+ blendMode: blendModeOf(active.meta),
512
+ ...(active.meta.boneMask ? { boneMask: active.meta.boneMask } : {}),
513
+ })),
514
+ dispose(): void {
515
+ if (disposed) return;
516
+ disposed = true;
517
+ detachRuntimeInspection();
518
+ options.systems?.remove('animation', tick);
519
+ subscription.unsubscribe();
520
+ for (const { action, clip } of actionCache.values()) {
521
+ action.stop();
522
+ mixer.uncacheAction(clip, action.getRoot());
523
+ }
524
+ actionCache.clear();
525
+ activeLayers.clear();
526
+ if (getUserData(owner, '_xstateAnimation') === binding) {
527
+ deleteUserData(owner, '_xstateAnimation');
528
+ notifyInspectionLifecycle();
529
+ }
530
+ },
531
+ };
532
+ detachRuntimeInspection = attachAnimationRuntimeInspection(owner, { mixer, clips });
533
+ setUserData(owner, '_xstateAnimation', binding);
534
+ notifyInspectionLifecycle();
535
+ options.systems?.add('animation', tick);
536
+ return binding;
537
+ }
538
+
539
+ /**
540
+ * Evaluates a blend tree and returns weights for each child clip.
541
+ *
542
+ * 1D blend: interpolates between sorted blend points based on a single parameter.
543
+ * Example: speed=3 with children at [walk:1, run:5] → weights [0.5, 0.5]
544
+ *
545
+ * 2D blend: uses two parameters (e.g., strafe locomotion). Weights are computed by
546
+ * inverse-distance weighting (IDW): each child's weight is proportional to
547
+ * 1/distance from the (parameter, parameterY) sample point to that child's
548
+ * (threshold, thresholdY) position, then normalized so all weights sum to 1.
549
+ * A sample landing exactly on a child gives that child ~full weight. This is a
550
+ * smooth approximation, not a Delaunay/barycentric blend (so non-adjacent
551
+ * children still receive a small share); it is intentionally kept simple.
552
+ *
553
+ * Direct: each child has an explicit weight.
554
+ */
555
+ export function evaluateBlendTree(
556
+ def: BlendTreeDef,
557
+ parameters: Map<string, number | boolean>,
558
+ ): { clip: string; weight: number }[] {
559
+ switch (def.type) {
560
+ case '1D':
561
+ return evaluate1D(def, parameters);
562
+ case '2D':
563
+ return evaluate2D(def, parameters);
564
+ case 'direct':
565
+ return evaluateDirect(def);
566
+ default:
567
+ return def.children.map((c) => ({ clip: c.clip, weight: 1 / def.children.length }));
568
+ }
569
+ }
570
+
571
+ function evaluate1D(
572
+ def: BlendTreeDef,
573
+ parameters: Map<string, number | boolean>,
574
+ ): { clip: string; weight: number }[] {
575
+ const paramValue = Number(parameters.get(def.parameter) ?? 0);
576
+ const children = [...def.children].sort((a, b) => a.threshold - b.threshold);
577
+ const results = children.map((c) => ({ clip: c.clip, weight: 0 }));
578
+
579
+ if (children.length === 0) return results;
580
+ if (children.length === 1) {
581
+ results[0]!.weight = 1;
582
+ return results;
583
+ }
584
+
585
+ // Below first threshold
586
+ if (paramValue <= children[0]!.threshold) {
587
+ results[0]!.weight = 1;
588
+ return results;
589
+ }
590
+
591
+ // Above last threshold
592
+ if (paramValue >= children[children.length - 1]!.threshold) {
593
+ results[results.length - 1]!.weight = 1;
594
+ return results;
595
+ }
596
+
597
+ // Between two thresholds — linear interpolation
598
+ for (let i = 0; i < children.length - 1; i++) {
599
+ const lo = children[i]!;
600
+ const hi = children[i + 1]!;
601
+ if (paramValue >= lo.threshold && paramValue <= hi.threshold) {
602
+ const range = hi.threshold - lo.threshold;
603
+ const t = range > 0 ? (paramValue - lo.threshold) / range : 0;
604
+ results[i]!.weight = 1 - t;
605
+ results[i + 1]!.weight = t;
606
+ return results;
607
+ }
608
+ }
609
+
610
+ return results;
611
+ }
612
+
613
+ function evaluate2D(
614
+ def: BlendTreeDef,
615
+ parameters: Map<string, number | boolean>,
616
+ ): { clip: string; weight: number }[] {
617
+ const px = Number(parameters.get(def.parameter) ?? 0);
618
+ const py = Number(parameters.get(def.parameterY ?? '') ?? 0);
619
+
620
+ // Inverse-distance weighting: weight each child by 1/distance to its 2D point,
621
+ // then normalize below so the returned weights sum to 1.
622
+ const children = def.children;
623
+ const results = children.map((c) => ({ clip: c.clip, weight: 0 }));
624
+
625
+ let totalInvDist = 0;
626
+ const invDists: number[] = [];
627
+
628
+ for (const child of children) {
629
+ const dx = px - child.threshold;
630
+ const dy = py - (child.thresholdY ?? 0);
631
+ const dist = Math.sqrt(dx * dx + dy * dy);
632
+ const invDist = dist < 0.001 ? 1000 : 1 / dist;
633
+ invDists.push(invDist);
634
+ totalInvDist += invDist;
635
+ }
636
+
637
+ if (totalInvDist > 0) {
638
+ for (let i = 0; i < results.length; i++) {
639
+ results[i]!.weight = invDists[i]! / totalInvDist;
640
+ }
641
+ }
642
+
643
+ return results;
644
+ }
645
+
646
+ function evaluateDirect(def: BlendTreeDef): { clip: string; weight: number }[] {
647
+ const results = def.children.map((c) => ({ clip: c.clip, weight: c.weight ?? 0 }));
648
+ // Normalize so weights sum to exactly 1.0 — otherwise the leftover weight
649
+ // bleeds in the bind pose / T-pose (the actions add up to <1 or >1).
650
+ const total = results.reduce((sum, r) => sum + r.weight, 0);
651
+ if (total > 0) {
652
+ for (const r of results) r.weight /= total;
653
+ }
654
+ return results;
655
+ }