@forgeax/engine-animation 0.1.2
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/README.md +141 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/animation-diagnostic.unit.test.d.ts +2 -0
- package/dist/__tests__/animation-diagnostic.unit.test.d.ts.map +1 -0
- package/dist/__tests__/animation-recovery.integration.test.d.ts +2 -0
- package/dist/__tests__/animation-recovery.integration.test.d.ts.map +1 -0
- package/dist/__tests__/animation-target-id.browser.test.d.ts +2 -0
- package/dist/__tests__/animation-target-id.browser.test.d.ts.map +1 -0
- package/dist/__tests__/animation-target-id.unit.test.d.ts +2 -0
- package/dist/__tests__/animation-target-id.unit.test.d.ts.map +1 -0
- package/dist/__tests__/animation-target-isolation.integration.test.d.ts +2 -0
- package/dist/__tests__/animation-target-isolation.integration.test.d.ts.map +1 -0
- package/dist/__tests__/animation-target-relationship.unit.test.d.ts +2 -0
- package/dist/__tests__/animation-target-relationship.unit.test.d.ts.map +1 -0
- package/dist/__tests__/animation-target-source-boundary.unit.test.d.ts +2 -0
- package/dist/__tests__/animation-target-source-boundary.unit.test.d.ts.map +1 -0
- package/dist/__tests__/bind-animation-targets.unit.test.d.ts +2 -0
- package/dist/__tests__/bind-animation-targets.unit.test.d.ts.map +1 -0
- package/dist/__tests__/documentation-contract.unit.test.d.ts +2 -0
- package/dist/__tests__/documentation-contract.unit.test.d.ts.map +1 -0
- package/dist/__tests__/ecs-consumer.integration.test.d.ts +2 -0
- package/dist/__tests__/ecs-consumer.integration.test.d.ts.map +1 -0
- package/dist/__tests__/errors.unit.test.d.ts +2 -0
- package/dist/__tests__/errors.unit.test.d.ts.map +1 -0
- package/dist/__tests__/graph-player.integration.test.d.ts +2 -0
- package/dist/__tests__/graph-player.integration.test.d.ts.map +1 -0
- package/dist/__tests__/graph-player.unit.test.d.ts +2 -0
- package/dist/__tests__/graph-player.unit.test.d.ts.map +1 -0
- package/dist/__tests__/handle-errors.unit.test.d.ts +2 -0
- package/dist/__tests__/handle-errors.unit.test.d.ts.map +1 -0
- package/dist/__tests__/loader-boundary.unit.test.d.ts +2 -0
- package/dist/__tests__/loader-boundary.unit.test.d.ts.map +1 -0
- package/dist/__tests__/morph-weights-playback.integration.test.d.ts +2 -0
- package/dist/__tests__/morph-weights-playback.integration.test.d.ts.map +1 -0
- package/dist/__tests__/morph-weights.unit.test.d.ts +2 -0
- package/dist/__tests__/morph-weights.unit.test.d.ts.map +1 -0
- package/dist/__tests__/public-animation-target-api.test-d.d.ts +2 -0
- package/dist/__tests__/public-animation-target-api.test-d.d.ts.map +1 -0
- package/dist/__tests__/resolver-ownership.integration.test.d.ts +2 -0
- package/dist/__tests__/resolver-ownership.integration.test.d.ts.map +1 -0
- package/dist/__tests__/root-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/root-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/scene-instance-target-collection.integration.test.d.ts +2 -0
- package/dist/__tests__/scene-instance-target-collection.integration.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts.map +1 -0
- package/dist/__tests__/time-delta-regression.integration.test.d.ts +2 -0
- package/dist/__tests__/time-delta-regression.integration.test.d.ts.map +1 -0
- package/dist/__tests__/transform-target-playback.integration.test.d.ts +2 -0
- package/dist/__tests__/transform-target-playback.integration.test.d.ts.map +1 -0
- package/dist/animation-diagnostic.d.ts +27 -0
- package/dist/animation-diagnostic.d.ts.map +1 -0
- package/dist/animation-player.d.ts +13 -0
- package/dist/animation-player.d.ts.map +1 -0
- package/dist/animation-target.d.ts +21 -0
- package/dist/animation-target.d.ts.map +1 -0
- package/dist/assets/animation-decoder.d.ts +4 -0
- package/dist/assets/animation-decoder.d.ts.map +1 -0
- package/dist/chunk-DMXAQUPI.mjs +52 -0
- package/dist/chunk-DMXAQUPI.mjs.map +1 -0
- package/dist/errors.d.ts +120 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/graph/define-animation-graph.d.ts +42 -0
- package/dist/graph/define-animation-graph.d.ts.map +1 -0
- package/dist/graph/describe-animation-graph.d.ts +37 -0
- package/dist/graph/describe-animation-graph.d.ts.map +1 -0
- package/dist/graph/serialize-animation-graph.d.ts +27 -0
- package/dist/graph/serialize-animation-graph.d.ts.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1457 -0
- package/dist/index.mjs.map +1 -0
- package/dist/player-errors.d.ts +43 -0
- package/dist/player-errors.d.ts.map +1 -0
- package/dist/plugin.d.ts +13 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/resolve-animation-asset.d.ts +42 -0
- package/dist/resolve-animation-asset.d.ts.map +1 -0
- package/dist/systems/advance-animation-player.d.ts +40 -0
- package/dist/systems/advance-animation-player.d.ts.map +1 -0
- package/dist/systems/evaluate-animation-graph.d.ts +32 -0
- package/dist/systems/evaluate-animation-graph.d.ts.map +1 -0
- package/dist/target-id.d.ts +4 -0
- package/dist/target-id.d.ts.map +1 -0
- package/dist/target-id.mjs +9 -0
- package/dist/target-id.mjs.map +1 -0
- package/package.json +65 -0
- package/src/__tests__/animation-diagnostic.unit.test.ts +237 -0
- package/src/__tests__/animation-recovery.integration.test.ts +125 -0
- package/src/__tests__/animation-target-id.browser.test.ts +22 -0
- package/src/__tests__/animation-target-id.unit.test.ts +41 -0
- package/src/__tests__/animation-target-isolation.integration.test.ts +179 -0
- package/src/__tests__/animation-target-relationship.unit.test.ts +65 -0
- package/src/__tests__/animation-target-source-boundary.unit.test.ts +49 -0
- package/src/__tests__/bind-animation-targets.unit.test.ts +167 -0
- package/src/__tests__/documentation-contract.unit.test.ts +94 -0
- package/src/__tests__/ecs-consumer.integration.test.ts +10 -0
- package/src/__tests__/errors.unit.test.ts +19 -0
- package/src/__tests__/graph-player.integration.test.ts +22 -0
- package/src/__tests__/graph-player.unit.test.ts +12 -0
- package/src/__tests__/handle-errors.unit.test.ts +17 -0
- package/src/__tests__/loader-boundary.unit.test.ts +66 -0
- package/src/__tests__/morph-weights-playback.integration.test.ts +84 -0
- package/src/__tests__/morph-weights.unit.test.ts +26 -0
- package/src/__tests__/public-animation-target-api.test-d.ts +47 -0
- package/src/__tests__/resolver-ownership.integration.test.ts +99 -0
- package/src/__tests__/root-surface.unit.test.ts +10 -0
- package/src/__tests__/scene-instance-target-collection.integration.test.ts +26 -0
- package/src/__tests__/scriptable-pack-consumer.test-d.ts +8 -0
- package/src/__tests__/time-delta-regression.integration.test.ts +79 -0
- package/src/__tests__/transform-target-playback.integration.test.ts +198 -0
- package/src/animation-diagnostic.ts +88 -0
- package/src/animation-player.ts +84 -0
- package/src/animation-target.ts +257 -0
- package/src/assets/animation-decoder.ts +107 -0
- package/src/errors.ts +201 -0
- package/src/graph/define-animation-graph.ts +193 -0
- package/src/graph/describe-animation-graph.ts +74 -0
- package/src/graph/serialize-animation-graph.ts +90 -0
- package/src/index.ts +57 -0
- package/src/player-errors.ts +72 -0
- package/src/plugin.ts +73 -0
- package/src/resolve-animation-asset.ts +128 -0
- package/src/systems/advance-animation-player.ts +971 -0
- package/src/systems/evaluate-animation-graph.ts +261 -0
- package/src/target-id.ts +51 -0
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
import { Time, Update } from '@forgeax/engine-ecs';
|
|
2
|
+
// @forgeax/engine-animation — advanceAnimationPlayer system (variable N-way blend).
|
|
3
|
+
//
|
|
4
|
+
// Per-tick: scans the variable-length SoA columns on each AnimationPlayer; for
|
|
5
|
+
// each active slot (clips[i] != 0), advances times[i] += dt * speeds[i] (paused
|
|
6
|
+
// gates the whole entity), samples the AnimationClip channels, and accumulates a
|
|
7
|
+
// weighted pose into per-joint TRS accumulators. Once all slots are
|
|
8
|
+
// folded in, each joint receives a single `world.set(joint, Transform,
|
|
9
|
+
// fullPose)` (research F-2 / F-7 / F-8 — single write per joint per tick).
|
|
10
|
+
//
|
|
11
|
+
// Variable N-slot (feat-20260713 M1 / w5): the fixed 4-slot cap is retired. The
|
|
12
|
+
// four parallel columns (clips / times / weights / speeds) are variable
|
|
13
|
+
// `array<T>` columns, so row queries expose them through each entity's resolved
|
|
14
|
+
// component value; the flat-SoA row window used by the retired fixed schema no
|
|
15
|
+
// longer applies. Each entity's columns are read back as
|
|
16
|
+
// resolved TypedArrays via `world.get(entity, AnimationPlayer)` and the advanced
|
|
17
|
+
// `times` column is written back via `world.set` (D-7: weights are never written
|
|
18
|
+
// back; clamping is read-time only). The blend math (translation linear / scale
|
|
19
|
+
// linear / rotation nlerp, per-channel sumW) is byte-for-byte unchanged from the
|
|
20
|
+
// fixed schema — only the loop bound moved from a hard 4 to the column length.
|
|
21
|
+
//
|
|
22
|
+
// Blend math (plan-strategy D-1):
|
|
23
|
+
// - translation / scale: linear average — accumulator += w_i * v_i, then
|
|
24
|
+
// accumulator /= Σw_i (per-channel sumW).
|
|
25
|
+
// - rotation: nlerp — first valid quat fixes the sign reference; later
|
|
26
|
+
// quats negated when dot < 0 to take the short arc; accumulator += w * q;
|
|
27
|
+
// finalize = normalize(accumulator). nlerp (not slerp) per D-1 (research
|
|
28
|
+
// F-7: Three.js Normal-mode mathematical form).
|
|
29
|
+
// - per-channel sumW: a joint receiving translation from 2 slots and rotation
|
|
30
|
+
// from 1 slot normalizes each channel by its own sum. Slot weights need not
|
|
31
|
+
// be partitioned-by-1 (research F-2).
|
|
32
|
+
//
|
|
33
|
+
// Best-effort failure modes (AC-05, plan-strategy D-7 / D-9):
|
|
34
|
+
// - clips[i] == 0 : skip slot, no resolver call (AC-04)
|
|
35
|
+
// - resolver miss : skip slot
|
|
36
|
+
// - weights[i] < 0 : clamped via max(0, w); not written back (D-7)
|
|
37
|
+
// - duration mismatch : per-slot modulo on its own duration
|
|
38
|
+
// - channel target missing : skip channel
|
|
39
|
+
// - channel missing on slot: per-channel normalize covers it
|
|
40
|
+
//
|
|
41
|
+
// w5 layers a dev-mode warn pass on top of these silent skips
|
|
42
|
+
// (channel-target-missing / channel-missing-on-some-slot, once per
|
|
43
|
+
// (entity, channelKey, reason)).
|
|
44
|
+
//
|
|
45
|
+
// Decision anchors:
|
|
46
|
+
// - requirements IS-2 / AC-03 / AC-04 / AC-05 (best-effort N-way blend)
|
|
47
|
+
// - plan-strategy D-1 (TRS accumulators + nlerp), D-3 (public Query, no
|
|
48
|
+
// query-backed lookup), D-7 (clamp without write-back), D-9 (negative speed
|
|
49
|
+
// natural reverse)
|
|
50
|
+
// - charter P4 (single Transform write per joint per tick)
|
|
51
|
+
|
|
52
|
+
import type { EntityHandle, SystemHandle, World } from '@forgeax/engine-ecs';
|
|
53
|
+
import { defineSystem, defineSystemSet, ENTITY_NULL_RAW } from '@forgeax/engine-ecs';
|
|
54
|
+
import { createWorldProjection, type WorldProjection } from '@forgeax/engine-ecs/projection';
|
|
55
|
+
import { MorphWeights, Transform } from '@forgeax/engine-scene';
|
|
56
|
+
import type { AnimationChannel, AnimationClip, AnimationSampler } from '@forgeax/engine-types';
|
|
57
|
+
import { toShared } from '@forgeax/engine-types';
|
|
58
|
+
import {
|
|
59
|
+
emitAnimationDiagnostic,
|
|
60
|
+
isAnimationDevMode,
|
|
61
|
+
_resetAnimationWarnsForTests as resetAnimationDiagnosticsForTests,
|
|
62
|
+
} from '../animation-diagnostic';
|
|
63
|
+
import { AnimationPlayer } from '../animation-player';
|
|
64
|
+
import { AnimationTargetId, AnimationTargets } from '../animation-target';
|
|
65
|
+
import { AnimationPlayerSlotLengthMismatchError } from '../player-errors';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* System name used when `registerAdvanceAnimationPlayer` installs the system
|
|
69
|
+
* into the ECS schedule. External consumers can reference this constant to
|
|
70
|
+
* declare `after: [ADVANCE_ANIMATION_PLAYER_SYSTEM]` on dependent systems.
|
|
71
|
+
*/
|
|
72
|
+
export const ADVANCE_ANIMATION_PLAYER_SYSTEM = 'advanceAnimationPlayer' as const;
|
|
73
|
+
export const AnimationSet = defineSystemSet({ name: 'animation' });
|
|
74
|
+
|
|
75
|
+
export function _resetAnimationWarnsForTests(world: World): void {
|
|
76
|
+
resetAnimationDiagnosticsForTests(world);
|
|
77
|
+
targetMapCacheByWorld.delete(world);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Advance all AnimationPlayer components by dt, blend the N active clips per
|
|
82
|
+
* entity, and write one Transform per joint per tick. Returns void.
|
|
83
|
+
*
|
|
84
|
+
* Iteration walks every matching row through the injected Query (D-3). Variable
|
|
85
|
+
* SoA columns are read through the row facade, so entity handles are collected first, then each
|
|
86
|
+
* entity's columns are resolved to TypedArrays via `world.get` and the advanced
|
|
87
|
+
* `times` written back via `world.set` (mutating the query bundle mid-walk would
|
|
88
|
+
* be a compile error against the reader shape).
|
|
89
|
+
*/
|
|
90
|
+
export function advanceAnimationPlayer(world: World, dt: number): void {
|
|
91
|
+
const query = world.query({ with: [AnimationPlayer] }).unwrap();
|
|
92
|
+
|
|
93
|
+
// Collect entity handles inside the walk (the Entity.self view is transient),
|
|
94
|
+
// then resolve + mutate each player outside it.
|
|
95
|
+
const entities: EntityHandle[] = [];
|
|
96
|
+
for (const row of query) entities.push(row.entity);
|
|
97
|
+
|
|
98
|
+
for (const entityRaw of entities) {
|
|
99
|
+
advanceOnePlayer(world, entityRaw, dt);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolved per-entity AnimationPlayer columns (a `world.get` snapshot). Variable
|
|
105
|
+
* columns alias the BufferPool slot bytes; the advanced `times` is written back
|
|
106
|
+
* via `world.set`, never in place, so the snapshot is treated read-only here.
|
|
107
|
+
*/
|
|
108
|
+
interface PlayerColumns {
|
|
109
|
+
readonly clips: Uint32Array;
|
|
110
|
+
readonly times: Float32Array;
|
|
111
|
+
readonly weights: Float32Array;
|
|
112
|
+
readonly speeds: Float32Array;
|
|
113
|
+
readonly paused: boolean;
|
|
114
|
+
readonly looping: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Advance one entity's AnimationPlayer: validate the four parallel column
|
|
119
|
+
* lengths at the evaluation entry (the single length chokepoint, D-5), walk the
|
|
120
|
+
* N slots to collect the active set + advanced times, write the advanced `times`
|
|
121
|
+
* column back, and fold the active slots into the joint pose.
|
|
122
|
+
*
|
|
123
|
+
* Length guard (AC-11 / D-5): variable columns are set field-by-field, so a
|
|
124
|
+
* consumer that desyncs their lengths is rejected here with the structured
|
|
125
|
+
* `animation-player-slot-length-mismatch` error rather than silently padded or
|
|
126
|
+
* truncated.
|
|
127
|
+
*/
|
|
128
|
+
function advanceOnePlayer(world: World, entityRaw: number, dt: number): void {
|
|
129
|
+
const entity = entityRaw as EntityHandle;
|
|
130
|
+
const apRes = world.get(entity, AnimationPlayer);
|
|
131
|
+
if (!apRes.ok) return;
|
|
132
|
+
const ap = apRes.value as unknown as PlayerColumns;
|
|
133
|
+
|
|
134
|
+
const count = ap.clips.length;
|
|
135
|
+
if (ap.times.length !== count || ap.weights.length !== count || ap.speeds.length !== count) {
|
|
136
|
+
throw new AnimationPlayerSlotLengthMismatchError({
|
|
137
|
+
entity: entityRaw,
|
|
138
|
+
clips: count,
|
|
139
|
+
times: ap.times.length,
|
|
140
|
+
weights: ap.weights.length,
|
|
141
|
+
speeds: ap.speeds.length,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (count === 0) return;
|
|
145
|
+
|
|
146
|
+
const newTimes = new Float32Array(ap.times);
|
|
147
|
+
const activeSlots = collectActiveSlotsAndAdvanceTimes(world, ap, newTimes, dt);
|
|
148
|
+
|
|
149
|
+
// Persist the advanced times column (D-7: weights are never written back —
|
|
150
|
+
// negative-weight clamping is read-time only). Only `times` is set, so the
|
|
151
|
+
// clips / weights / speeds slots are left untouched.
|
|
152
|
+
world.set(entity, AnimationPlayer, { times: newTimes });
|
|
153
|
+
|
|
154
|
+
if (activeSlots.length === 0) return;
|
|
155
|
+
tickEntityTargets(world, entity, entityRaw, activeSlots);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Walk the N SoA slots for one entity: skip `clips[i]==0` (AC-04, no resolver
|
|
160
|
+
* call), skip resolver miss, advance `times[i] += speeds[i]*dt` (paused gates
|
|
161
|
+
* the entity per AC-05) into `newTimes`, wrap / clamp by clip duration based on
|
|
162
|
+
* `looping`, and keep the slot if `max(0, weights[i]) > 0` (D-7 clamp without
|
|
163
|
+
* write-back).
|
|
164
|
+
*
|
|
165
|
+
* The negative-speed reverse case (D-9) falls out naturally — `newTime`
|
|
166
|
+
* goes negative, the looping branch's `+= duration` re-anchors it; the
|
|
167
|
+
* looping=false branch clamps to 0.
|
|
168
|
+
*/
|
|
169
|
+
function collectActiveSlotsAndAdvanceTimes(
|
|
170
|
+
world: World,
|
|
171
|
+
ap: PlayerColumns,
|
|
172
|
+
newTimes: Float32Array,
|
|
173
|
+
dt: number,
|
|
174
|
+
): ActiveSlot[] {
|
|
175
|
+
const paused = ap.paused;
|
|
176
|
+
const looping = ap.looping;
|
|
177
|
+
const count = ap.clips.length;
|
|
178
|
+
const activeSlots: ActiveSlot[] = [];
|
|
179
|
+
|
|
180
|
+
for (let i = 0; i < count; i++) {
|
|
181
|
+
const clipHandleRaw = ap.clips[i] ?? 0;
|
|
182
|
+
if (clipHandleRaw === 0) continue;
|
|
183
|
+
const clipLookup = world.sharedRefs.resolve<'AnimationClip', AnimationClip>(
|
|
184
|
+
toShared<'AnimationClip'>(clipHandleRaw),
|
|
185
|
+
);
|
|
186
|
+
if (!clipLookup.ok) throw clipLookup.error;
|
|
187
|
+
const clip = clipLookup.value;
|
|
188
|
+
|
|
189
|
+
const speed = ap.speeds[i] ?? 0;
|
|
190
|
+
let newTime = paused ? (ap.times[i] ?? 0) : (ap.times[i] ?? 0) + speed * dt;
|
|
191
|
+
const duration = clip.duration;
|
|
192
|
+
if (duration > 0) {
|
|
193
|
+
if (looping) {
|
|
194
|
+
newTime = newTime % duration;
|
|
195
|
+
if (newTime < 0) newTime += duration;
|
|
196
|
+
} else if (newTime > duration) {
|
|
197
|
+
newTime = duration;
|
|
198
|
+
} else if (newTime < 0) {
|
|
199
|
+
newTime = 0;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
newTimes[i] = newTime;
|
|
203
|
+
|
|
204
|
+
const wRaw = ap.weights[i] ?? 0;
|
|
205
|
+
const w = wRaw > 0 ? wRaw : 0;
|
|
206
|
+
if (w === 0) continue;
|
|
207
|
+
|
|
208
|
+
activeSlots.push({ clip, clipHandleRaw, weight: w, time: newTime, slotIdx: i });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return activeSlots;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Target-write pass for one player: resolves its explicit target mirror and
|
|
216
|
+
* folds every active-slot channel into a per-target TRS accumulator (linear
|
|
217
|
+
* for translation/scale, nlerp with sign-fixed reference for rotation),
|
|
218
|
+
* then writes one `world.set(joint, Transform, ...)` per touched joint.
|
|
219
|
+
*
|
|
220
|
+
* Dev-mode warns (D-2):
|
|
221
|
+
* - channel-target-missing: a clip channel ID does not resolve in this
|
|
222
|
+
* player's explicit target set. Once per (entityId, clip, chIdx).
|
|
223
|
+
* - channel-missing-on-some-slot: a (joint, kind) tuple is covered by
|
|
224
|
+
* some slot but missing on another. The warn is emitted once per
|
|
225
|
+
* (entityId, the-covering-slot's-clip, that-slot's-chIdx) so users
|
|
226
|
+
* find the authoring point that has the channel; per-channel sumW
|
|
227
|
+
* normalize covers the runtime gap regardless.
|
|
228
|
+
*/
|
|
229
|
+
interface TargetMap {
|
|
230
|
+
readonly entities: ReadonlyMap<string, EntityHandle>;
|
|
231
|
+
readonly missingTransforms: ReadonlyMap<string, EntityHandle>;
|
|
232
|
+
readonly duplicateIds: ReadonlySet<string>;
|
|
233
|
+
readonly hasStaleTarget: boolean;
|
|
234
|
+
readonly resolvedClips: WeakMap<AnimationClip, readonly (EntityHandle | undefined)[]>;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
interface WorldTargetMapCache {
|
|
238
|
+
readonly projection: WorldProjection;
|
|
239
|
+
readonly players: Map<number, TargetMap>;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const targetMapCacheByWorld = new WeakMap<World, WorldTargetMapCache>();
|
|
243
|
+
|
|
244
|
+
function targetMapForPlayer(world: World, player: EntityHandle): TargetMap {
|
|
245
|
+
let cache = targetMapCacheByWorld.get(world);
|
|
246
|
+
if (cache === undefined) {
|
|
247
|
+
cache = {
|
|
248
|
+
projection: createWorldProjection(world, {
|
|
249
|
+
components: [AnimationTargets, AnimationTargetId],
|
|
250
|
+
}),
|
|
251
|
+
players: new Map(),
|
|
252
|
+
};
|
|
253
|
+
targetMapCacheByWorld.set(world, cache);
|
|
254
|
+
} else {
|
|
255
|
+
const changes = cache.projection.poll();
|
|
256
|
+
if (changes.status === 'rebuild' || changes.changes.length > 0) {
|
|
257
|
+
cache = {
|
|
258
|
+
projection: createWorldProjection(world, {
|
|
259
|
+
components: [AnimationTargets, AnimationTargetId],
|
|
260
|
+
}),
|
|
261
|
+
players: new Map(),
|
|
262
|
+
};
|
|
263
|
+
targetMapCacheByWorld.set(world, cache);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const cached = cache.players.get(player as number);
|
|
268
|
+
if (cached !== undefined) return cached;
|
|
269
|
+
const built = buildTargetMap(world, player);
|
|
270
|
+
cache.players.set(player as number, built);
|
|
271
|
+
return built;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function buildTargetMap(world: World, player: EntityHandle): TargetMap {
|
|
275
|
+
const targets = world.get(player, AnimationTargets);
|
|
276
|
+
if (!targets.ok) {
|
|
277
|
+
return {
|
|
278
|
+
entities: new Map(),
|
|
279
|
+
missingTransforms: new Map(),
|
|
280
|
+
duplicateIds: new Set(),
|
|
281
|
+
hasStaleTarget: false,
|
|
282
|
+
resolvedClips: new WeakMap(),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
const result = new Map<string, EntityHandle>();
|
|
286
|
+
const missingTransforms = new Map<string, EntityHandle>();
|
|
287
|
+
const seen = new Set<string>();
|
|
288
|
+
const ambiguous = new Set<string>();
|
|
289
|
+
let hasStaleTarget = false;
|
|
290
|
+
for (const raw of targets.value.targets) {
|
|
291
|
+
if (raw === ENTITY_NULL_RAW) continue;
|
|
292
|
+
const target = raw as EntityHandle;
|
|
293
|
+
const id = world.get(target, AnimationTargetId);
|
|
294
|
+
if (!id.ok) {
|
|
295
|
+
hasStaleTarget = true;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const targetId = id.value.value;
|
|
299
|
+
if (ambiguous.has(targetId)) continue;
|
|
300
|
+
if (seen.has(targetId)) {
|
|
301
|
+
result.delete(targetId);
|
|
302
|
+
missingTransforms.delete(targetId);
|
|
303
|
+
ambiguous.add(targetId);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
seen.add(targetId);
|
|
307
|
+
if (world.get(target, Transform).ok) result.set(targetId, target);
|
|
308
|
+
else missingTransforms.set(targetId, target);
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
entities: result,
|
|
312
|
+
missingTransforms,
|
|
313
|
+
duplicateIds: ambiguous,
|
|
314
|
+
hasStaleTarget,
|
|
315
|
+
resolvedClips: new WeakMap(),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function tickEntityTargets(
|
|
320
|
+
world: World,
|
|
321
|
+
entity: EntityHandle,
|
|
322
|
+
entityRaw: number,
|
|
323
|
+
activeSlots: ActiveSlot[],
|
|
324
|
+
): void {
|
|
325
|
+
const targetMap = targetMapForPlayer(world, entity);
|
|
326
|
+
|
|
327
|
+
// Per-joint accumulator: lazily allocated when first channel writes.
|
|
328
|
+
// A Map keyed by jointIndex keeps the typical case (a few animated
|
|
329
|
+
// joints out of 20+) sparse rather than allocating for every joint.
|
|
330
|
+
const accumulators: Map<number, JointAccumulator> = new Map();
|
|
331
|
+
const morphAccumulators: Map<number, MorphWeightAccumulator> = new Map();
|
|
332
|
+
// Per-slot signature of (joint, channel-kind) coverage — used to detect
|
|
333
|
+
// channel-missing-on-some-slot once at the end of the channel walk. Lazy
|
|
334
|
+
// build only when there are 2+ active slots and dev-mode is on (warn pass
|
|
335
|
+
// is skipped in production by the shared diagnostics mode gate).
|
|
336
|
+
const slotCoverage: SlotCoverage[] = [];
|
|
337
|
+
const wantsCoverage = activeSlots.length >= 2 && isAnimationDevMode();
|
|
338
|
+
if (wantsCoverage) {
|
|
339
|
+
for (let i = 0; i < activeSlots.length; i++) slotCoverage.push(new Map());
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for (let slotIdx = 0; slotIdx < activeSlots.length; slotIdx++) {
|
|
343
|
+
// biome-ignore lint/style/noNonNullAssertion: bounded by activeSlots.length
|
|
344
|
+
const slot = activeSlots[slotIdx]!;
|
|
345
|
+
const resolvedTargets = resolveClipTargets(world, entityRaw, slot, targetMap);
|
|
346
|
+
for (let chIdx = 0; chIdx < slot.clip.channels.length; chIdx++) {
|
|
347
|
+
// biome-ignore lint/style/noNonNullAssertion: bounded by channels.length
|
|
348
|
+
const channel = slot.clip.channels[chIdx]!;
|
|
349
|
+
const sampled = sampleChannel(channel.sampler, slot.time, channel.property);
|
|
350
|
+
if (sampled === undefined) continue;
|
|
351
|
+
const target =
|
|
352
|
+
channel.property === 'weights'
|
|
353
|
+
? resolveChannelTarget(
|
|
354
|
+
world,
|
|
355
|
+
entityRaw,
|
|
356
|
+
slot.clipHandleRaw,
|
|
357
|
+
chIdx,
|
|
358
|
+
channel.targetId,
|
|
359
|
+
channel.property,
|
|
360
|
+
sampled.length,
|
|
361
|
+
targetMap,
|
|
362
|
+
)
|
|
363
|
+
: resolvedTargets[chIdx];
|
|
364
|
+
if (target === undefined) continue;
|
|
365
|
+
const targetRaw = target as number;
|
|
366
|
+
|
|
367
|
+
if (channel.property === 'weights') {
|
|
368
|
+
let weights = morphAccumulators.get(targetRaw);
|
|
369
|
+
if (weights === undefined) {
|
|
370
|
+
weights = { values: new Float32Array(sampled.length), sumW: 0 };
|
|
371
|
+
morphAccumulators.set(targetRaw, weights);
|
|
372
|
+
}
|
|
373
|
+
if (weights.values.length !== sampled.length) continue;
|
|
374
|
+
for (let i = 0; i < sampled.length; i++) {
|
|
375
|
+
weights.values[i] = (weights.values[i] ?? 0) + slot.weight * (sampled[i] ?? 0);
|
|
376
|
+
}
|
|
377
|
+
weights.sumW += slot.weight;
|
|
378
|
+
if (wantsCoverage) {
|
|
379
|
+
const coverage = slotCoverage[slotIdx];
|
|
380
|
+
if (coverage !== undefined) {
|
|
381
|
+
recordSlotCoverage(coverage, channel.targetId, channel.property, chIdx);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
let acc = accumulators.get(targetRaw);
|
|
388
|
+
if (acc === undefined) {
|
|
389
|
+
acc = createAccumulator();
|
|
390
|
+
accumulators.set(targetRaw, acc);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
foldChannelIntoAccumulator(acc, channel.property, sampled, slot.weight);
|
|
394
|
+
|
|
395
|
+
if (wantsCoverage) {
|
|
396
|
+
// biome-ignore lint/style/noNonNullAssertion: parallel to activeSlots
|
|
397
|
+
recordSlotCoverage(slotCoverage[slotIdx]!, channel.targetId, channel.property, chIdx);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (wantsCoverage) {
|
|
403
|
+
emitMissingOnSomeSlotWarns(world, entityRaw, activeSlots, slotCoverage);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
for (const [targetRaw, acc] of accumulators) {
|
|
407
|
+
const target = targetRaw as EntityHandle;
|
|
408
|
+
const partial = finalizeAccumulator(acc);
|
|
409
|
+
if (Object.keys(partial).length > 0) {
|
|
410
|
+
world.set(target, Transform as never, partial as never);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
for (const [targetRaw, acc] of morphAccumulators) {
|
|
414
|
+
if (acc.sumW <= 0) continue;
|
|
415
|
+
const target = targetRaw as EntityHandle;
|
|
416
|
+
const weights = world.get(target, MorphWeights);
|
|
417
|
+
if (weights.ok && weights.value.weights.length === acc.values.length) {
|
|
418
|
+
const next = new Float32Array(acc.values.length);
|
|
419
|
+
for (let i = 0; i < next.length; i++) next[i] = (acc.values[i] ?? 0) / acc.sumW;
|
|
420
|
+
world.set(target, MorphWeights, { weights: next });
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function resolveClipTargets(
|
|
426
|
+
world: World,
|
|
427
|
+
player: number,
|
|
428
|
+
slot: ActiveSlot,
|
|
429
|
+
targetMap: TargetMap,
|
|
430
|
+
): readonly (EntityHandle | undefined)[] {
|
|
431
|
+
const cached = targetMap.resolvedClips.get(slot.clip);
|
|
432
|
+
if (cached !== undefined) return cached;
|
|
433
|
+
|
|
434
|
+
const targets = slot.clip.channels.map((channel, channelIndex) =>
|
|
435
|
+
channel.property === 'weights'
|
|
436
|
+
? undefined
|
|
437
|
+
: resolveChannelTarget(
|
|
438
|
+
world,
|
|
439
|
+
player,
|
|
440
|
+
slot.clipHandleRaw,
|
|
441
|
+
channelIndex,
|
|
442
|
+
channel.targetId,
|
|
443
|
+
channel.property,
|
|
444
|
+
undefined,
|
|
445
|
+
targetMap,
|
|
446
|
+
),
|
|
447
|
+
);
|
|
448
|
+
targetMap.resolvedClips.set(slot.clip, targets);
|
|
449
|
+
return targets;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function resolveChannelTarget(
|
|
453
|
+
world: World,
|
|
454
|
+
player: number,
|
|
455
|
+
clip: number,
|
|
456
|
+
channel: number,
|
|
457
|
+
targetId: string,
|
|
458
|
+
property: ChannelKind,
|
|
459
|
+
expectedWeightCount: number | undefined,
|
|
460
|
+
targetMap: TargetMap,
|
|
461
|
+
): EntityHandle | undefined {
|
|
462
|
+
if (targetMap.duplicateIds.has(targetId)) {
|
|
463
|
+
emitTargetDiagnostic(
|
|
464
|
+
world,
|
|
465
|
+
player,
|
|
466
|
+
clip,
|
|
467
|
+
channel,
|
|
468
|
+
targetId,
|
|
469
|
+
'animation-target-id-duplicate',
|
|
470
|
+
'target-id-duplicate',
|
|
471
|
+
'assign a unique AnimationTargetId to each target owned by this player',
|
|
472
|
+
);
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
475
|
+
const target = targetMap.entities.get(targetId);
|
|
476
|
+
if (target === undefined) {
|
|
477
|
+
const transformMissingTarget = targetMap.missingTransforms.get(targetId);
|
|
478
|
+
if (transformMissingTarget !== undefined) {
|
|
479
|
+
emitTargetDiagnostic(
|
|
480
|
+
world,
|
|
481
|
+
player,
|
|
482
|
+
clip,
|
|
483
|
+
channel,
|
|
484
|
+
targetId,
|
|
485
|
+
'animation-target-transform-missing',
|
|
486
|
+
'transform-missing',
|
|
487
|
+
'attach Transform to the bound animation target',
|
|
488
|
+
transformMissingTarget as number,
|
|
489
|
+
);
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
492
|
+
emitTargetDiagnostic(
|
|
493
|
+
world,
|
|
494
|
+
player,
|
|
495
|
+
clip,
|
|
496
|
+
channel,
|
|
497
|
+
targetId,
|
|
498
|
+
targetMap.hasStaleTarget ? 'animation-target-owner-stale' : 'animation-target-missing',
|
|
499
|
+
targetMap.hasStaleTarget ? 'target-stale' : 'target-missing',
|
|
500
|
+
targetMap.hasStaleTarget
|
|
501
|
+
? 'remove the stale target relation or bind a live replacement'
|
|
502
|
+
: 'bind the matching AnimationTargetId to this player',
|
|
503
|
+
);
|
|
504
|
+
return undefined;
|
|
505
|
+
}
|
|
506
|
+
if (property === 'weights') {
|
|
507
|
+
const weights = world.get(target, MorphWeights);
|
|
508
|
+
if (!weights.ok) {
|
|
509
|
+
emitAnimationDiagnostic(world, {
|
|
510
|
+
code: 'animation-target-morph-weights-missing',
|
|
511
|
+
hint: 'attach MorphWeights to the morph target entity before playing a weights channel',
|
|
512
|
+
detail: {
|
|
513
|
+
player,
|
|
514
|
+
clip,
|
|
515
|
+
channel,
|
|
516
|
+
targetId,
|
|
517
|
+
reason: 'morph-weights-missing',
|
|
518
|
+
target: target as number,
|
|
519
|
+
property,
|
|
520
|
+
...(expectedWeightCount === undefined ? {} : { expectedWeightCount }),
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
return undefined;
|
|
524
|
+
}
|
|
525
|
+
if (expectedWeightCount !== undefined && weights.value.weights.length !== expectedWeightCount) {
|
|
526
|
+
emitAnimationDiagnostic(world, {
|
|
527
|
+
code: 'animation-morph-weight-count-mismatch',
|
|
528
|
+
hint: 'make MorphWeights.length equal the animation channel output width',
|
|
529
|
+
detail: {
|
|
530
|
+
player,
|
|
531
|
+
clip,
|
|
532
|
+
channel,
|
|
533
|
+
targetId,
|
|
534
|
+
reason: 'morph-weight-count-mismatch',
|
|
535
|
+
target: target as number,
|
|
536
|
+
property,
|
|
537
|
+
expectedWeightCount,
|
|
538
|
+
actualWeightCount: weights.value.weights.length,
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
return undefined;
|
|
542
|
+
}
|
|
543
|
+
return target;
|
|
544
|
+
}
|
|
545
|
+
if (!world.get(target, Transform).ok) {
|
|
546
|
+
emitTargetDiagnostic(
|
|
547
|
+
world,
|
|
548
|
+
player,
|
|
549
|
+
clip,
|
|
550
|
+
channel,
|
|
551
|
+
targetId,
|
|
552
|
+
'animation-target-transform-missing',
|
|
553
|
+
'transform-missing',
|
|
554
|
+
'attach Transform to the bound animation target',
|
|
555
|
+
target as number,
|
|
556
|
+
);
|
|
557
|
+
return undefined;
|
|
558
|
+
}
|
|
559
|
+
return target;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Per-slot (joint -> covered kinds) signature. The `chIdxByKind` field
|
|
564
|
+
* remembers which channel index of the slot's clip first covered the
|
|
565
|
+
* (joint, kind) pair — used as the channelKey when emitting a
|
|
566
|
+
* channel-missing-on-some-slot warn so the user can locate the
|
|
567
|
+
* authoring channel that exposed the asymmetry.
|
|
568
|
+
*/
|
|
569
|
+
type ChannelKind = AnimationChannel['property'];
|
|
570
|
+
type SlotCoverage = Map<string, Map<ChannelKind, number>>;
|
|
571
|
+
|
|
572
|
+
function recordSlotCoverage(
|
|
573
|
+
cov: SlotCoverage,
|
|
574
|
+
targetId: string,
|
|
575
|
+
kind: ChannelKind,
|
|
576
|
+
chIdx: number,
|
|
577
|
+
): void {
|
|
578
|
+
let perTarget = cov.get(targetId);
|
|
579
|
+
if (perTarget === undefined) {
|
|
580
|
+
perTarget = new Map();
|
|
581
|
+
cov.set(targetId, perTarget);
|
|
582
|
+
}
|
|
583
|
+
if (!perTarget.has(kind)) perTarget.set(kind, chIdx);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function emitTargetDiagnostic(
|
|
587
|
+
world: World,
|
|
588
|
+
entityRaw: number,
|
|
589
|
+
clipHandleRaw: number,
|
|
590
|
+
chIdx: number,
|
|
591
|
+
targetId: string,
|
|
592
|
+
code:
|
|
593
|
+
| 'animation-target-missing'
|
|
594
|
+
| 'animation-target-transform-missing'
|
|
595
|
+
| 'animation-target-id-duplicate'
|
|
596
|
+
| 'animation-target-owner-stale',
|
|
597
|
+
reason: 'target-missing' | 'transform-missing' | 'target-id-duplicate' | 'target-stale',
|
|
598
|
+
hint: string,
|
|
599
|
+
target?: number,
|
|
600
|
+
): void {
|
|
601
|
+
emitAnimationDiagnostic(world, {
|
|
602
|
+
code,
|
|
603
|
+
hint,
|
|
604
|
+
detail: {
|
|
605
|
+
player: entityRaw,
|
|
606
|
+
clip: clipHandleRaw,
|
|
607
|
+
channel: chIdx,
|
|
608
|
+
targetId,
|
|
609
|
+
reason,
|
|
610
|
+
...(target === undefined ? {} : { target }),
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Reconcile per-slot coverage against the union: for any (joint, kind)
|
|
617
|
+
* tuple covered by ≥ 1 slot but missing on another, emit the warn once per
|
|
618
|
+
* (entityId, the-covering-slot's-clip, that-slot's-chIdx, reason). Each
|
|
619
|
+
* covering slot may emit its own warn pointing at its own channel index —
|
|
620
|
+
* authoring tools can land on any of them.
|
|
621
|
+
*/
|
|
622
|
+
function emitMissingOnSomeSlotWarns(
|
|
623
|
+
world: World,
|
|
624
|
+
entityRaw: number,
|
|
625
|
+
activeSlots: ActiveSlot[],
|
|
626
|
+
slotCoverage: SlotCoverage[],
|
|
627
|
+
): void {
|
|
628
|
+
// Union over all slots: jointIndex -> Set<ChannelKind>.
|
|
629
|
+
const union: Map<string, Set<ChannelKind>> = new Map();
|
|
630
|
+
for (const cov of slotCoverage) {
|
|
631
|
+
for (const [targetId, kindMap] of cov) {
|
|
632
|
+
let set = union.get(targetId);
|
|
633
|
+
if (set === undefined) {
|
|
634
|
+
set = new Set();
|
|
635
|
+
union.set(targetId, set);
|
|
636
|
+
}
|
|
637
|
+
for (const kind of kindMap.keys()) set.add(kind);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
for (const [targetId, unionKinds] of union) {
|
|
642
|
+
for (let slotIdx = 0; slotIdx < activeSlots.length; slotIdx++) {
|
|
643
|
+
// biome-ignore lint/style/noNonNullAssertion: parallel arrays
|
|
644
|
+
const cov = slotCoverage[slotIdx]!;
|
|
645
|
+
const slotKinds = cov.get(targetId);
|
|
646
|
+
for (const kind of unionKinds) {
|
|
647
|
+
if (slotKinds?.has(kind)) continue;
|
|
648
|
+
// This slot is missing `kind` on jointIndex. Find the slot that
|
|
649
|
+
// does cover (joint, kind) and use ITS chIdx as the warn anchor.
|
|
650
|
+
for (let coveringIdx = 0; coveringIdx < activeSlots.length; coveringIdx++) {
|
|
651
|
+
if (coveringIdx === slotIdx) continue;
|
|
652
|
+
// biome-ignore lint/style/noNonNullAssertion: parallel arrays
|
|
653
|
+
const coveringCov = slotCoverage[coveringIdx]!;
|
|
654
|
+
const coveringKinds = coveringCov.get(targetId);
|
|
655
|
+
if (coveringKinds === undefined) continue;
|
|
656
|
+
const chIdx = coveringKinds.get(kind);
|
|
657
|
+
if (chIdx === undefined) continue;
|
|
658
|
+
// biome-ignore lint/style/noNonNullAssertion: parallel arrays
|
|
659
|
+
const coveringSlot = activeSlots[coveringIdx]!;
|
|
660
|
+
emitAnimationDiagnostic(world, {
|
|
661
|
+
code: 'animation-channel-missing',
|
|
662
|
+
hint: `author the missing ${kind} channel on the slot whose clip lacks it`,
|
|
663
|
+
detail: {
|
|
664
|
+
player: entityRaw,
|
|
665
|
+
clip: coveringSlot.clipHandleRaw,
|
|
666
|
+
channel: chIdx,
|
|
667
|
+
targetId,
|
|
668
|
+
reason: 'channel-missing',
|
|
669
|
+
property: kind,
|
|
670
|
+
},
|
|
671
|
+
});
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Add a sampled (translation / rotation / scale) channel to the per-joint
|
|
681
|
+
* accumulator, weighted by the slot's weight. Quat handling sign-fixes
|
|
682
|
+
* against the first quat seen so the nlerp picks the short arc (research
|
|
683
|
+
* F-7); per-channel sumW lets translation / rotation / scale normalize
|
|
684
|
+
* independently when slot coverage differs (research F-2 / AC-05(b)).
|
|
685
|
+
*/
|
|
686
|
+
function foldChannelIntoAccumulator(
|
|
687
|
+
acc: JointAccumulator,
|
|
688
|
+
property: ChannelKind,
|
|
689
|
+
sampled: number[],
|
|
690
|
+
weight: number,
|
|
691
|
+
): void {
|
|
692
|
+
if (property === 'weights') return;
|
|
693
|
+
if (property === 'translation' && sampled.length >= 3) {
|
|
694
|
+
acc.posX += weight * (sampled[0] ?? 0);
|
|
695
|
+
acc.posY += weight * (sampled[1] ?? 0);
|
|
696
|
+
acc.posZ += weight * (sampled[2] ?? 0);
|
|
697
|
+
acc.sumWPos += weight;
|
|
698
|
+
acc.hasPos = true;
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
if (property === 'rotation' && sampled.length >= 4) {
|
|
702
|
+
const qx = sampled[0] ?? 0;
|
|
703
|
+
const qy = sampled[1] ?? 0;
|
|
704
|
+
const qz = sampled[2] ?? 0;
|
|
705
|
+
const qw = sampled[3] ?? 1;
|
|
706
|
+
if (!acc.hasQuat) {
|
|
707
|
+
acc.refQX = qx;
|
|
708
|
+
acc.refQY = qy;
|
|
709
|
+
acc.refQZ = qz;
|
|
710
|
+
acc.refQW = qw;
|
|
711
|
+
acc.quatX = weight * qx;
|
|
712
|
+
acc.quatY = weight * qy;
|
|
713
|
+
acc.quatZ = weight * qz;
|
|
714
|
+
acc.quatW = weight * qw;
|
|
715
|
+
acc.hasQuat = true;
|
|
716
|
+
} else {
|
|
717
|
+
const dot = acc.refQX * qx + acc.refQY * qy + acc.refQZ * qz + acc.refQW * qw;
|
|
718
|
+
const sign = dot < 0 ? -1 : 1;
|
|
719
|
+
acc.quatX += weight * sign * qx;
|
|
720
|
+
acc.quatY += weight * sign * qy;
|
|
721
|
+
acc.quatZ += weight * sign * qz;
|
|
722
|
+
acc.quatW += weight * sign * qw;
|
|
723
|
+
}
|
|
724
|
+
acc.sumWQuat += weight;
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
if (property === 'scale' && sampled.length >= 3) {
|
|
728
|
+
acc.scaleX += weight * (sampled[0] ?? 1);
|
|
729
|
+
acc.scaleY += weight * (sampled[1] ?? 1);
|
|
730
|
+
acc.scaleZ += weight * (sampled[2] ?? 1);
|
|
731
|
+
acc.sumWScale += weight;
|
|
732
|
+
acc.hasScale = true;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Per-channel normalize: divide by per-channel sumW. Quat finalize
|
|
738
|
+
* normalizes the resulting vec4 (nlerp). A channel with sumW=0 is silently
|
|
739
|
+
* absent from the partial — `world.set` with a partial leaves untouched
|
|
740
|
+
* fields at their existing values (AC-05(b) per-channel fallback). Channel
|
|
741
|
+
* granularity maps 1:1 onto the Transform array columns (feat-20260709 M2):
|
|
742
|
+
* an animated channel always covers its whole pos/quat/scale vector, so the
|
|
743
|
+
* per-field partial write semantics are unchanged by the column migration.
|
|
744
|
+
*/
|
|
745
|
+
function finalizeAccumulator(acc: JointAccumulator): Record<string, number[]> {
|
|
746
|
+
const partial: Record<string, number[]> = {};
|
|
747
|
+
if (acc.hasPos && acc.sumWPos > 0) {
|
|
748
|
+
partial.pos = [acc.posX / acc.sumWPos, acc.posY / acc.sumWPos, acc.posZ / acc.sumWPos];
|
|
749
|
+
}
|
|
750
|
+
if (acc.hasQuat && acc.sumWQuat > 0) {
|
|
751
|
+
const qx = acc.quatX / acc.sumWQuat;
|
|
752
|
+
const qy = acc.quatY / acc.sumWQuat;
|
|
753
|
+
const qz = acc.quatZ / acc.sumWQuat;
|
|
754
|
+
const qw = acc.quatW / acc.sumWQuat;
|
|
755
|
+
const len = Math.sqrt(qx * qx + qy * qy + qz * qz + qw * qw);
|
|
756
|
+
if (len > 0) {
|
|
757
|
+
// Component order [x, y, z, w] (E6).
|
|
758
|
+
partial.quat = [qx / len, qy / len, qz / len, qw / len];
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (acc.hasScale && acc.sumWScale > 0) {
|
|
762
|
+
partial.scale = [
|
|
763
|
+
acc.scaleX / acc.sumWScale,
|
|
764
|
+
acc.scaleY / acc.sumWScale,
|
|
765
|
+
acc.scaleZ / acc.sumWScale,
|
|
766
|
+
];
|
|
767
|
+
}
|
|
768
|
+
return partial;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Per-active-slot snapshot folded into the per-entity accumulator pass:
|
|
773
|
+
* the resolved clip + the (advanced) sample time + the clamped weight, so
|
|
774
|
+
* the channel loop never has to re-read the SoA columns.
|
|
775
|
+
*/
|
|
776
|
+
interface ActiveSlot {
|
|
777
|
+
readonly clip: AnimationClip;
|
|
778
|
+
readonly clipHandleRaw: number;
|
|
779
|
+
readonly weight: number;
|
|
780
|
+
readonly time: number;
|
|
781
|
+
readonly slotIdx: number;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
interface JointAccumulator {
|
|
785
|
+
posX: number;
|
|
786
|
+
posY: number;
|
|
787
|
+
posZ: number;
|
|
788
|
+
sumWPos: number;
|
|
789
|
+
hasPos: boolean;
|
|
790
|
+
// Quat reference + accumulator. refQ* is the first sampled quat (sign-fixed)
|
|
791
|
+
// so subsequent quats with dot<0 are negated for short-arc nlerp.
|
|
792
|
+
refQX: number;
|
|
793
|
+
refQY: number;
|
|
794
|
+
refQZ: number;
|
|
795
|
+
refQW: number;
|
|
796
|
+
quatX: number;
|
|
797
|
+
quatY: number;
|
|
798
|
+
quatZ: number;
|
|
799
|
+
quatW: number;
|
|
800
|
+
sumWQuat: number;
|
|
801
|
+
hasQuat: boolean;
|
|
802
|
+
scaleX: number;
|
|
803
|
+
scaleY: number;
|
|
804
|
+
scaleZ: number;
|
|
805
|
+
sumWScale: number;
|
|
806
|
+
hasScale: boolean;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
interface MorphWeightAccumulator {
|
|
810
|
+
readonly values: Float32Array;
|
|
811
|
+
sumW: number;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function createAccumulator(): JointAccumulator {
|
|
815
|
+
return {
|
|
816
|
+
posX: 0,
|
|
817
|
+
posY: 0,
|
|
818
|
+
posZ: 0,
|
|
819
|
+
sumWPos: 0,
|
|
820
|
+
hasPos: false,
|
|
821
|
+
refQX: 0,
|
|
822
|
+
refQY: 0,
|
|
823
|
+
refQZ: 0,
|
|
824
|
+
refQW: 1,
|
|
825
|
+
quatX: 0,
|
|
826
|
+
quatY: 0,
|
|
827
|
+
quatZ: 0,
|
|
828
|
+
quatW: 0,
|
|
829
|
+
sumWQuat: 0,
|
|
830
|
+
hasQuat: false,
|
|
831
|
+
scaleX: 0,
|
|
832
|
+
scaleY: 0,
|
|
833
|
+
scaleZ: 0,
|
|
834
|
+
sumWScale: 0,
|
|
835
|
+
hasScale: false,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Sample an animation sampler at the given time.
|
|
841
|
+
*
|
|
842
|
+
* Returns an array of floats whose length matches the property element count:
|
|
843
|
+
* - translation / scale: 3 floats (vec3)
|
|
844
|
+
* - rotation: 4 floats (quat)
|
|
845
|
+
*/
|
|
846
|
+
function sampleChannel(
|
|
847
|
+
sampler: AnimationSampler,
|
|
848
|
+
time: number,
|
|
849
|
+
property: ChannelKind,
|
|
850
|
+
): number[] | undefined {
|
|
851
|
+
const { input, output, interpolation } = sampler;
|
|
852
|
+
if (input.length === 0) return undefined;
|
|
853
|
+
|
|
854
|
+
const elementCount = output.length / input.length;
|
|
855
|
+
|
|
856
|
+
// Clamp if before first key.
|
|
857
|
+
if (time <= (input[0] as number)) {
|
|
858
|
+
return sliceOutput(output, 0, elementCount);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Clamp if after last key.
|
|
862
|
+
const lastIdx = input.length - 1;
|
|
863
|
+
if (time >= (input[lastIdx] as number)) {
|
|
864
|
+
return sliceOutput(output, lastIdx, elementCount);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Binary search for the bracket.
|
|
868
|
+
let lo = 0;
|
|
869
|
+
let hi = input.length - 1;
|
|
870
|
+
while (hi - lo > 1) {
|
|
871
|
+
const mid = (lo + hi) >> 1;
|
|
872
|
+
if ((input[mid] as number) <= time) {
|
|
873
|
+
lo = mid;
|
|
874
|
+
} else {
|
|
875
|
+
hi = mid;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
const prev = lo;
|
|
879
|
+
const next = hi;
|
|
880
|
+
|
|
881
|
+
if (interpolation === 'STEP') {
|
|
882
|
+
return sliceOutput(output, prev, elementCount);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// LINEAR interpolation.
|
|
886
|
+
const t0 = input[prev] as number;
|
|
887
|
+
const t1 = input[next] as number;
|
|
888
|
+
const alpha = (time - t0) / (t1 - t0);
|
|
889
|
+
|
|
890
|
+
const prevValues = sliceOutput(output, prev, elementCount);
|
|
891
|
+
const nextValues = sliceOutput(output, next, elementCount);
|
|
892
|
+
|
|
893
|
+
if (property === 'rotation') {
|
|
894
|
+
// Per-sampler quat slerp at the bracket level — multi-slot blending is
|
|
895
|
+
// a separate stage (nlerp at the entity level, in advanceAnimationPlayer).
|
|
896
|
+
const px = prevValues[0] ?? 0;
|
|
897
|
+
const py = prevValues[1] ?? 0;
|
|
898
|
+
const pz = prevValues[2] ?? 0;
|
|
899
|
+
const pw = prevValues[3] ?? 1;
|
|
900
|
+
let nx = nextValues[0] ?? 0;
|
|
901
|
+
let ny = nextValues[1] ?? 0;
|
|
902
|
+
let nz = nextValues[2] ?? 0;
|
|
903
|
+
let nw = nextValues[3] ?? 1;
|
|
904
|
+
let dot = px * nx + py * ny + pz * nz + pw * nw;
|
|
905
|
+
if (dot < 0) {
|
|
906
|
+
nx = -nx;
|
|
907
|
+
ny = -ny;
|
|
908
|
+
nz = -nz;
|
|
909
|
+
nw = -nw;
|
|
910
|
+
dot = -dot;
|
|
911
|
+
}
|
|
912
|
+
if (dot > 0.9995) {
|
|
913
|
+
// Near-parallel — fall back to nlerp to avoid sin(theta) -> 0 blowup.
|
|
914
|
+
const lx = px + alpha * (nx - px);
|
|
915
|
+
const ly = py + alpha * (ny - py);
|
|
916
|
+
const lz = pz + alpha * (nz - pz);
|
|
917
|
+
const lw = pw + alpha * (nw - pw);
|
|
918
|
+
const len = Math.sqrt(lx * lx + ly * ly + lz * lz + lw * lw);
|
|
919
|
+
return len > 0 ? [lx / len, ly / len, lz / len, lw / len] : [0, 0, 0, 1];
|
|
920
|
+
}
|
|
921
|
+
const theta = Math.acos(dot);
|
|
922
|
+
const sinTheta = Math.sin(theta);
|
|
923
|
+
const sa = Math.sin((1 - alpha) * theta) / sinTheta;
|
|
924
|
+
const sb = Math.sin(alpha * theta) / sinTheta;
|
|
925
|
+
return [px * sa + nx * sb, py * sa + ny * sb, pz * sa + nz * sb, pw * sa + nw * sb];
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
return prevValues.map((value, index) => value + alpha * ((nextValues[index] ?? value) - value));
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function sliceOutput(output: Float32Array, index: number, elementCount: number): number[] {
|
|
932
|
+
const result: number[] = [];
|
|
933
|
+
const base = index * elementCount;
|
|
934
|
+
for (let i = 0; i < elementCount; i++) {
|
|
935
|
+
result.push(output[base + i] as number);
|
|
936
|
+
}
|
|
937
|
+
return result;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* The `advanceAnimationPlayer` system token.
|
|
942
|
+
*
|
|
943
|
+
* Module-level `defineSystem` with the real fn body — no closure and no
|
|
944
|
+
* feature-specific resolver resource. Handles resolve from the World passed
|
|
945
|
+
* to the system, so invalid clips become structured animation failures.
|
|
946
|
+
*/
|
|
947
|
+
export const AdvanceAnimationPlayer: SystemHandle<readonly []> = defineSystem({
|
|
948
|
+
name: ADVANCE_ANIMATION_PLAYER_SYSTEM,
|
|
949
|
+
queries: [],
|
|
950
|
+
before: ['propagateTransforms'],
|
|
951
|
+
fn: (world) => {
|
|
952
|
+
advanceAnimationPlayer(world, world.getResource(Time).delta);
|
|
953
|
+
},
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Register `advanceAnimationPlayer` into the ECS schedule before
|
|
958
|
+
* `propagateTransforms`. Animation clip handles are resolved from the running
|
|
959
|
+
* World; callers only need to register the system once per World.
|
|
960
|
+
*
|
|
961
|
+
* @example Driver registers once per World:
|
|
962
|
+
* const world = new World();
|
|
963
|
+
* registerAdvanceAnimationPlayer(world);
|
|
964
|
+
* // ...system will run each world.update() before propagateTransforms...
|
|
965
|
+
*/
|
|
966
|
+
export function registerAdvanceAnimationPlayer(world: World): () => void {
|
|
967
|
+
world.addSystems(Update, AnimationSet, [AdvanceAnimationPlayer]).unwrap();
|
|
968
|
+
return () => {
|
|
969
|
+
world.removeSystem(Update, ADVANCE_ANIMATION_PLAYER_SYSTEM);
|
|
970
|
+
};
|
|
971
|
+
}
|