@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.
- package/LICENSE +202 -0
- package/NOTICE +12 -0
- package/package.json +62 -0
- package/src/adapter/body-marks.ts +69 -0
- package/src/adapter/constraint.ts +78 -0
- package/src/adapter/first-party-navigation-system.ts +48 -0
- package/src/adapter/hierarchy-marks.ts +156 -0
- package/src/adapter/ingest/scene-capture.ts +865 -0
- package/src/adapter/ingest/structural-ids.ts +139 -0
- package/src/adapter/ingest/visible-capture-window.ts +302 -0
- package/src/adapter/object3d-authoring-subject.ts +50 -0
- package/src/adapter/rapier-physics-adapter.ts +226 -0
- package/src/adapter/reflection-probe.ts +75 -0
- package/src/adapter/renderer-config.ts +116 -0
- package/src/adapter/trigger-volume.ts +29 -0
- package/src/ai/navigation.ts +627 -0
- package/src/animation/animation-clock.ts +479 -0
- package/src/animation/runtime-inspection.ts +45 -0
- package/src/animation/xstate-animation-binding.ts +655 -0
- package/src/animation/xstate-animation-meta.ts +360 -0
- package/src/asset-formats/camera.ts +18 -0
- package/src/asset-formats/collider.ts +66 -0
- package/src/asset-formats/index.ts +45 -0
- package/src/asset-formats/light.ts +50 -0
- package/src/asset-formats/material.ts +93 -0
- package/src/asset-formats/mesh.ts +78 -0
- package/src/asset-formats/particles.ts +426 -0
- package/src/asset-formats/render-env.ts +318 -0
- package/src/asset-formats/tuples.ts +21 -0
- package/src/asset-loaders.ts +242 -0
- package/src/asset-parse-error.ts +29 -0
- package/src/assets.ts +68 -0
- package/src/behavior/xstate-inspection.ts +51 -0
- package/src/defaults.ts +135 -0
- package/src/ecs/object-marks.ts +75 -0
- package/src/ecs/scene-index.ts +439 -0
- package/src/ecs/scene-query.ts +43 -0
- package/src/ecs/user-data.ts +272 -0
- package/src/loader.ts +134 -0
- package/src/physics/collider-dimensions.ts +121 -0
- package/src/physics/collision-system.ts +76 -0
- package/src/physics/physics-registry.ts +83 -0
- package/src/physics/transform-writer.ts +41 -0
- package/src/render/directional-shadow-fit.ts +158 -0
- package/src/render/environment-capture.ts +133 -0
- package/src/render/ibl-override-material.ts +202 -0
- package/src/render/light-camera-factory.ts +103 -0
- package/src/render/matcap-texture.ts +92 -0
- package/src/render/material-factory.ts +201 -0
- package/src/render/mesh-shadow.ts +18 -0
- package/src/render/particles-factory.ts +589 -0
- package/src/render/render-batch-system.ts +260 -0
- package/src/render/render-features.ts +128 -0
- package/src/render/render-settings.ts +71 -0
- package/src/render/soft-particle-depth.ts +360 -0
- package/src/render/spark-renderer-lifecycle.ts +64 -0
- package/src/render/structural-signature.ts +231 -0
- package/src/render/viewport-shading.ts +163 -0
- package/src/render/voxel-surface.ts +358 -0
- package/src/setup/setup-particles.ts +32 -0
- package/src/setup/setup-physics.ts +67 -0
- package/src/setup/setup-renderer.ts +524 -0
- package/src/types-n8ao.d.ts +37 -0
- package/src/types-realism-effects.d.ts +61 -0
- package/vendor/realism-effects/LICENSE.md +21 -0
- package/vendor/realism-effects/UPSTREAM.md +19 -0
- package/vendor/realism-effects/dist/index.cjs +3447 -0
- package/vendor/realism-effects/dist/index.d.ts +59 -0
- package/vendor/realism-effects/dist/index.js +3434 -0
- 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
|
+
}
|