@galfus/engine 0.0.0-stage → 0.31.0-alpha

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 (80) hide show
  1. package/README.md +190 -2
  2. package/package.json +59 -4
  3. package/src/anchors/cache.ts +101 -0
  4. package/src/anchors/component.ts +123 -0
  5. package/src/anchors/hierarchy.ts +192 -0
  6. package/src/anchors/index.ts +8 -0
  7. package/src/anchors/solver.ts +247 -0
  8. package/src/anchors/strategy.ts +142 -0
  9. package/src/animation/blending.ts +163 -0
  10. package/src/animation/clip.ts +132 -0
  11. package/src/animation/coordinator.ts +568 -0
  12. package/src/animation/index.ts +7 -0
  13. package/src/animation/native-targets.ts +37 -0
  14. package/src/animation/player.ts +287 -0
  15. package/src/audio/index.ts +4 -0
  16. package/src/audio/manager.ts +371 -0
  17. package/src/collision/components.ts +211 -0
  18. package/src/collision/index.ts +16 -0
  19. package/src/collision/query2d.ts +167 -0
  20. package/src/collision/query3d.ts +170 -0
  21. package/src/ecs/component.ts +130 -0
  22. package/src/ecs/entity.ts +75 -0
  23. package/src/ecs/index.ts +9 -0
  24. package/src/ecs/intent.ts +162 -0
  25. package/src/ecs/metadata.ts +44 -0
  26. package/src/ecs/system.ts +224 -0
  27. package/src/ecs/world.ts +291 -0
  28. package/src/index.ts +19 -0
  29. package/src/interaction/focus.ts +327 -0
  30. package/src/interaction/index.ts +9 -0
  31. package/src/interaction/listener.ts +58 -0
  32. package/src/interaction/snapshot2d.ts +210 -0
  33. package/src/interaction/snapshot3d.ts +214 -0
  34. package/src/interaction/system.ts +544 -0
  35. package/src/interaction/types.ts +27 -0
  36. package/src/math/common.ts +67 -0
  37. package/src/math/index.ts +17 -0
  38. package/src/math/mat2d.ts +320 -0
  39. package/src/math/mat4.ts +576 -0
  40. package/src/math/quat.ts +438 -0
  41. package/src/math/vec2.ts +161 -0
  42. package/src/math/vec3.ts +242 -0
  43. package/src/math/vec4.ts +171 -0
  44. package/src/multiverse/coordinator.ts +540 -0
  45. package/src/multiverse/index.ts +13 -0
  46. package/src/multiverse/presentation-graph.ts +306 -0
  47. package/src/multiverse/presentation-links.ts +134 -0
  48. package/src/multiverse/realm.ts +49 -0
  49. package/src/platform/index.ts +2 -0
  50. package/src/platform/platform.ts +149 -0
  51. package/src/platform/window.ts +181 -0
  52. package/src/realm2d/components.ts +254 -0
  53. package/src/realm2d/index.ts +12 -0
  54. package/src/realm2d/presets/index.ts +36 -0
  55. package/src/realm2d/realm.ts +522 -0
  56. package/src/realm2d/sync.ts +284 -0
  57. package/src/realm2d/utils/index.ts +32 -0
  58. package/src/realm3d/components.ts +341 -0
  59. package/src/realm3d/geometry.ts +224 -0
  60. package/src/realm3d/index.ts +12 -0
  61. package/src/realm3d/presets/index.ts +67 -0
  62. package/src/realm3d/realm.ts +588 -0
  63. package/src/realm3d/resource-ids.ts +62 -0
  64. package/src/realm3d/skinning.ts +128 -0
  65. package/src/realm3d/sync.ts +442 -0
  66. package/src/realm3d/utils/index.ts +26 -0
  67. package/src/resources/index.ts +4 -0
  68. package/src/resources/manager.ts +92 -0
  69. package/src/runtime.ts +588 -0
  70. package/src/spatial/dirty_registry.ts +217 -0
  71. package/src/spatial/flags.ts +14 -0
  72. package/src/spatial/index.ts +6 -0
  73. package/src/spatial/metrics.ts +124 -0
  74. package/src/text/editor.ts +224 -0
  75. package/src/text/encoding.ts +38 -0
  76. package/src/text/index.ts +13 -0
  77. package/src/text/input.ts +160 -0
  78. package/src/text/keyboard.ts +50 -0
  79. package/src/text/svg-decorations.ts +147 -0
  80. package/src/types.ts +85 -0
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Anchor and spatial hierarchy subsystem.
3
+ */
4
+ export * from './cache';
5
+ export * from './component';
6
+ export * from './hierarchy';
7
+ export * from './solver';
8
+ export * from './strategy';
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Anchor and hierarchical transform solver system.
3
+ *
4
+ * Runs during the `preRender` stage to calculate local and world matrices
5
+ * directly into Model3D/Model2D components and the persistent AnchorMatrixCache,
6
+ * resolving dependencies topologically with cycle detection, incremental dirty propagation,
7
+ * and SpatialDirtyFlags transitions.
8
+ */
9
+
10
+ import type { EntityId } from '../ecs/entity';
11
+ import type { System, SystemContext } from '../ecs/system';
12
+ import type { EcsWorld } from '../ecs/world';
13
+ import { mat2d, type Mat2d, mat4, type Mat4 } from '../math/index';
14
+ import type { Model2DComponent } from '../realm2d/components';
15
+ import type { Model3DComponent } from '../realm3d/components';
16
+ import { SpatialDirtyFlags } from '../spatial/flags';
17
+ import { AnchorMatrixCache } from './cache';
18
+ import type { AnchorComponent } from './component';
19
+ import { AnchorStrategyRegistry } from './strategy';
20
+
21
+ export class AnchorSolveSystem {
22
+ readonly cache = new AnchorMatrixCache();
23
+ readonly strategies = new AnchorStrategyRegistry();
24
+
25
+ /**
26
+ * Resolves all 2D and 3D transforms for entities in the given world.
27
+ */
28
+ solve(world: EcsWorld, context: SystemContext): void {
29
+ this.solve2D(world, context);
30
+ this.solve3D(world, context);
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // 2D Solving
35
+ // ---------------------------------------------------------------------------
36
+ private solve2D(world: EcsWorld, context: SystemContext): void {
37
+ const all2DEntities = world.query('Model2D');
38
+
39
+ const visiting = new Set<EntityId>();
40
+ const resolved = new Set<EntityId>();
41
+ const dirtyTargets = new Set<EntityId>();
42
+
43
+ for (const id of all2DEntities) {
44
+ this.resolveEntity2D(id, world, context, visiting, resolved, dirtyTargets);
45
+ }
46
+ }
47
+
48
+ private resolveEntity2D(
49
+ id: EntityId,
50
+ world: EcsWorld,
51
+ context: SystemContext,
52
+ visiting: Set<EntityId>,
53
+ resolved: Set<EntityId>,
54
+ dirtyTargets: Set<EntityId>
55
+ ): Mat2d {
56
+ const worldMatrix = this.cache.getOrCreateWorld2D(id);
57
+ if (resolved.has(id)) {
58
+ return worldMatrix;
59
+ }
60
+
61
+ const model = world.getComponent<Model2DComponent>(id, 'Model2D');
62
+ if (!model) {
63
+ return worldMatrix;
64
+ }
65
+
66
+ const localMatrix = this.cache.getOrCreateLocal2D(id);
67
+ mat2d.copy(localMatrix, model.localMatrix);
68
+
69
+ const anchor = world.getComponent<AnchorComponent>(id, 'Anchor');
70
+ if (!anchor || !anchor.active) {
71
+ const isLocalDirty = (model.flags & SpatialDirtyFlags.LocalDirty) !== 0;
72
+ if (isLocalDirty) {
73
+ mat2d.copy(worldMatrix, localMatrix);
74
+ mat2d.copy(model.worldMatrix, worldMatrix);
75
+ model.flags = (model.flags & ~SpatialDirtyFlags.LocalDirty) | SpatialDirtyFlags.WorldDirty;
76
+ dirtyTargets.add(id);
77
+ } else {
78
+ mat2d.copy(worldMatrix, model.worldMatrix);
79
+ }
80
+ resolved.add(id);
81
+ return worldMatrix;
82
+ }
83
+
84
+ // Cycle detection fallback
85
+ if (visiting.has(id)) {
86
+ console.warn(
87
+ `[AnchorSolveSystem] Cycle detected at entity ${id} in 2D anchor hierarchy. Falling back to local transform.`
88
+ );
89
+ mat2d.copy(worldMatrix, localMatrix);
90
+ mat2d.copy(model.worldMatrix, worldMatrix);
91
+ model.flags = (model.flags & ~SpatialDirtyFlags.LocalDirty) | SpatialDirtyFlags.WorldDirty;
92
+ dirtyTargets.add(id);
93
+ resolved.add(id);
94
+ return worldMatrix;
95
+ }
96
+
97
+ visiting.add(id);
98
+ const targetWorld = this.resolveEntity2D(
99
+ anchor.targetEntityId,
100
+ world,
101
+ context,
102
+ visiting,
103
+ resolved,
104
+ dirtyTargets
105
+ );
106
+ visiting.delete(id);
107
+
108
+ const isTargetDirty = dirtyTargets.has(anchor.targetEntityId);
109
+ const isLocalDirty = model ? (model.flags & SpatialDirtyFlags.LocalDirty) !== 0 : true;
110
+
111
+ // Incremental optimization: skip re-multiplication if neither target nor local changed
112
+ if (!isTargetDirty && !isLocalDirty) {
113
+ if (model) {
114
+ mat2d.copy(worldMatrix, model.worldMatrix);
115
+ }
116
+ resolved.add(id);
117
+ return worldMatrix;
118
+ }
119
+
120
+ if (anchor.callback2d) {
121
+ anchor.callback2d(targetWorld, localMatrix, worldMatrix, context);
122
+ } else {
123
+ const strategy = this.strategies.get2D(anchor.strategy);
124
+ strategy.apply(targetWorld, localMatrix, worldMatrix, anchor.offset2d, context);
125
+ }
126
+
127
+ if (model) {
128
+ mat2d.copy(model.worldMatrix, worldMatrix);
129
+ model.flags = (model.flags & ~SpatialDirtyFlags.LocalDirty) | SpatialDirtyFlags.WorldDirty;
130
+ dirtyTargets.add(id);
131
+ }
132
+
133
+ resolved.add(id);
134
+ return worldMatrix;
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // 3D Solving
139
+ // ---------------------------------------------------------------------------
140
+ private solve3D(world: EcsWorld, context: SystemContext): void {
141
+ const all3DEntities = world.query('Model3D');
142
+
143
+ const visiting = new Set<EntityId>();
144
+ const resolved = new Set<EntityId>();
145
+ const dirtyTargets = new Set<EntityId>();
146
+
147
+ for (const id of all3DEntities) {
148
+ this.resolveEntity3D(id, world, context, visiting, resolved, dirtyTargets);
149
+ }
150
+ }
151
+
152
+ private resolveEntity3D(
153
+ id: EntityId,
154
+ world: EcsWorld,
155
+ context: SystemContext,
156
+ visiting: Set<EntityId>,
157
+ resolved: Set<EntityId>,
158
+ dirtyTargets: Set<EntityId>
159
+ ): Mat4 {
160
+ const worldMatrix = this.cache.getOrCreateWorld3D(id);
161
+ if (resolved.has(id)) {
162
+ return worldMatrix;
163
+ }
164
+
165
+ const model = world.getComponent<Model3DComponent>(id, 'Model3D');
166
+ if (!model) {
167
+ return worldMatrix;
168
+ }
169
+
170
+ const localMatrix = this.cache.getOrCreateLocal3D(id);
171
+ mat4.copy(localMatrix, model.localMatrix);
172
+
173
+ const anchor = world.getComponent<AnchorComponent>(id, 'Anchor');
174
+ if (!anchor || !anchor.active) {
175
+ const isLocalDirty = (model.flags & SpatialDirtyFlags.LocalDirty) !== 0;
176
+ if (isLocalDirty) {
177
+ mat4.copy(worldMatrix, localMatrix);
178
+ mat4.copy(model.worldMatrix, worldMatrix);
179
+ model.flags = (model.flags & ~SpatialDirtyFlags.LocalDirty) | SpatialDirtyFlags.WorldDirty;
180
+ dirtyTargets.add(id);
181
+ } else {
182
+ mat4.copy(worldMatrix, model.worldMatrix);
183
+ }
184
+ resolved.add(id);
185
+ return worldMatrix;
186
+ }
187
+
188
+ // Cycle detection fallback
189
+ if (visiting.has(id)) {
190
+ console.warn(
191
+ `[AnchorSolveSystem] Cycle detected at entity ${id} in 3D anchor hierarchy. Falling back to local transform.`
192
+ );
193
+ mat4.copy(worldMatrix, localMatrix);
194
+ mat4.copy(model.worldMatrix, worldMatrix);
195
+ model.flags = (model.flags & ~SpatialDirtyFlags.LocalDirty) | SpatialDirtyFlags.WorldDirty;
196
+ dirtyTargets.add(id);
197
+ resolved.add(id);
198
+ return worldMatrix;
199
+ }
200
+
201
+ visiting.add(id);
202
+ const targetWorld = this.resolveEntity3D(
203
+ anchor.targetEntityId,
204
+ world,
205
+ context,
206
+ visiting,
207
+ resolved,
208
+ dirtyTargets
209
+ );
210
+ visiting.delete(id);
211
+
212
+ const isTargetDirty = dirtyTargets.has(anchor.targetEntityId);
213
+ const isLocalDirty = model ? (model.flags & SpatialDirtyFlags.LocalDirty) !== 0 : true;
214
+
215
+ // Incremental optimization: skip re-multiplication if neither target nor local changed
216
+ if (!isTargetDirty && !isLocalDirty) {
217
+ if (model) {
218
+ mat4.copy(worldMatrix, model.worldMatrix);
219
+ }
220
+ resolved.add(id);
221
+ return worldMatrix;
222
+ }
223
+
224
+ if (anchor.callback3d) {
225
+ anchor.callback3d(targetWorld, localMatrix, worldMatrix, context);
226
+ } else {
227
+ const strategy = this.strategies.get3D(anchor.strategy);
228
+ strategy.apply(targetWorld, localMatrix, worldMatrix, anchor.offset3d, context);
229
+ }
230
+
231
+ if (model) {
232
+ mat4.copy(model.worldMatrix, worldMatrix);
233
+ model.flags = (model.flags & ~SpatialDirtyFlags.LocalDirty) | SpatialDirtyFlags.WorldDirty;
234
+ dirtyTargets.add(id);
235
+ }
236
+
237
+ resolved.add(id);
238
+ return worldMatrix;
239
+ }
240
+
241
+ /**
242
+ * Helper function creating an ECS System instance running this solver in `preRender`.
243
+ */
244
+ createSystem(): System<EcsWorld> {
245
+ return (world, context) => this.solve(world, context);
246
+ }
247
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Extensible Anchor strategies for 2D and 3D hierarchical solving.
3
+ */
4
+
5
+ import { mat2d, type Mat2d, mat4, type Mat4 } from '../math/index';
6
+
7
+ export interface AnchorContext {
8
+ /** Delta time of the frame in seconds. */
9
+ dt: number;
10
+ /** Total elapsed time in seconds. */
11
+ time: number;
12
+ }
13
+
14
+ export type AnchorConstraintCallback2D = (
15
+ targetWorld: Mat2d,
16
+ local: Mat2d,
17
+ outWorld: Mat2d,
18
+ context: AnchorContext
19
+ ) => void;
20
+
21
+ export type AnchorConstraintCallback3D = (
22
+ targetWorld: Mat4,
23
+ local: Mat4,
24
+ outWorld: Mat4,
25
+ context: AnchorContext
26
+ ) => void;
27
+
28
+ /**
29
+ * 2D Anchor resolution strategy contract.
30
+ */
31
+ export interface AnchorStrategy2D {
32
+ readonly name: string;
33
+ apply(
34
+ targetWorld: Mat2d,
35
+ local: Mat2d,
36
+ outWorld: Mat2d,
37
+ offset: Mat2d | undefined,
38
+ context: AnchorContext
39
+ ): void;
40
+ }
41
+
42
+ /**
43
+ * 3D Anchor resolution strategy contract.
44
+ */
45
+ export interface AnchorStrategy3D {
46
+ readonly name: string;
47
+ apply(
48
+ targetWorld: Mat4,
49
+ local: Mat4,
50
+ outWorld: Mat4,
51
+ offset: Mat4 | undefined,
52
+ context: AnchorContext
53
+ ): void;
54
+ }
55
+
56
+ /**
57
+ * Default 'static' 2D strategy: multiplies targetWorld * (offset ? offset * local : local).
58
+ */
59
+ export const StaticAnchorStrategy2D: AnchorStrategy2D = {
60
+ name: 'static',
61
+ apply(targetWorld, local, outWorld, offset) {
62
+ if (offset) {
63
+ const temp = mat2d.create();
64
+ mat2d.multiply(temp, offset, local);
65
+ mat2d.multiply(outWorld, targetWorld, temp);
66
+ } else {
67
+ mat2d.multiply(outWorld, targetWorld, local);
68
+ }
69
+ }
70
+ };
71
+
72
+ /**
73
+ * Default 'static' 3D strategy: multiplies targetWorld * (offset ? offset * local : local).
74
+ */
75
+ export const StaticAnchorStrategy3D: AnchorStrategy3D = {
76
+ name: 'static',
77
+ apply(targetWorld, local, outWorld, offset) {
78
+ if (offset) {
79
+ const temp = mat4.create();
80
+ mat4.multiply(temp, offset, local);
81
+ mat4.multiply(outWorld, targetWorld, temp);
82
+ } else {
83
+ mat4.multiply(outWorld, targetWorld, local);
84
+ }
85
+ }
86
+ };
87
+
88
+ /**
89
+ * Pure 'translation' 2D strategy: follows targetWorld translation only without inheriting orientation or scale.
90
+ */
91
+ export const TranslationAnchorStrategy2D: AnchorStrategy2D = {
92
+ name: 'translation',
93
+ apply(targetWorld, local, outWorld, offset) {
94
+ mat2d.copy(outWorld, local);
95
+ outWorld[4] += targetWorld[4] + (offset ? offset[4] : 0);
96
+ outWorld[5] += targetWorld[5] + (offset ? offset[5] : 0);
97
+ }
98
+ };
99
+
100
+ /**
101
+ * Pure 'translation' 3D strategy: follows targetWorld translation only without inheriting orientation or scale.
102
+ */
103
+ export const TranslationAnchorStrategy3D: AnchorStrategy3D = {
104
+ name: 'translation',
105
+ apply(targetWorld, local, outWorld, offset) {
106
+ mat4.copy(outWorld, local);
107
+ outWorld[12] += targetWorld[12] + (offset ? offset[12] : 0);
108
+ outWorld[13] += targetWorld[13] + (offset ? offset[13] : 0);
109
+ outWorld[14] += targetWorld[14] + (offset ? offset[14] : 0);
110
+ }
111
+ };
112
+
113
+ /**
114
+ * Registry holding custom Anchor strategies.
115
+ */
116
+ export class AnchorStrategyRegistry {
117
+ private readonly _strategies2D = new Map<string, AnchorStrategy2D>();
118
+ private readonly _strategies3D = new Map<string, AnchorStrategy3D>();
119
+
120
+ constructor() {
121
+ this.register2D(StaticAnchorStrategy2D);
122
+ this.register2D(TranslationAnchorStrategy2D);
123
+ this.register3D(StaticAnchorStrategy3D);
124
+ this.register3D(TranslationAnchorStrategy3D);
125
+ }
126
+
127
+ register2D(strategy: AnchorStrategy2D): void {
128
+ this._strategies2D.set(strategy.name, strategy);
129
+ }
130
+
131
+ register3D(strategy: AnchorStrategy3D): void {
132
+ this._strategies3D.set(strategy.name, strategy);
133
+ }
134
+
135
+ get2D(name: string): AnchorStrategy2D {
136
+ return this._strategies2D.get(name) ?? StaticAnchorStrategy2D;
137
+ }
138
+
139
+ get3D(name: string): AnchorStrategy3D {
140
+ return this._strategies3D.get(name) ?? StaticAnchorStrategy3D;
141
+ }
142
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Pose extraction, blending, and crossfading routines.
3
+ */
4
+
5
+ import type { EntityId } from '../ecs/entity';
6
+ import { common, quat, vec3 } from '../math/index';
7
+ import { type AnimationClip, sampleTrack } from './clip';
8
+
9
+ export interface AnimationPose {
10
+ translations: Map<EntityId, [number, number, number]>;
11
+ rotations: Map<EntityId, [number, number, number, number]>;
12
+ scales: Map<EntityId, [number, number, number]>;
13
+ weights: Map<EntityId, Float32Array>;
14
+ }
15
+
16
+ export function createAnimationPose(): AnimationPose {
17
+ return {
18
+ translations: new Map(),
19
+ rotations: new Map(),
20
+ scales: new Map(),
21
+ weights: new Map()
22
+ };
23
+ }
24
+
25
+ export function clearAnimationPose(pose: AnimationPose): void {
26
+ pose.translations.clear();
27
+ pose.rotations.clear();
28
+ pose.scales.clear();
29
+ pose.weights.clear();
30
+ }
31
+
32
+ export function copyAnimationPose(from: AnimationPose, to: AnimationPose): void {
33
+ clearAnimationPose(to);
34
+ for (const [id, val] of from.translations) to.translations.set(id, [val[0], val[1], val[2]]);
35
+ for (const [id, val] of from.rotations) to.rotations.set(id, [val[0], val[1], val[2], val[3]]);
36
+ for (const [id, val] of from.scales) to.scales.set(id, [val[0], val[1], val[2]]);
37
+ for (const [id, val] of from.weights) to.weights.set(id, new Float32Array(val));
38
+ }
39
+
40
+ // Reusable sampling and interpolation buffers; pose entries own their values.
41
+ const SCRATCH_VEC3 = new Float32Array(3);
42
+ const SCRATCH_VEC4 = new Float32Array(4);
43
+ const SCRATCH_BLEND_VEC3 = vec3.create();
44
+ const SCRATCH_BLEND_QUAT = quat.create();
45
+
46
+ function blendVectors(
47
+ out: Map<EntityId, [number, number, number]>,
48
+ poseA: Map<EntityId, [number, number, number]>,
49
+ poseB: Map<EntityId, [number, number, number]>,
50
+ t: number
51
+ ): void {
52
+ for (const [id, a] of poseA) {
53
+ const b = poseB.get(id);
54
+ if (b) {
55
+ vec3.lerp(SCRATCH_BLEND_VEC3, a, b, t);
56
+ out.set(id, [SCRATCH_BLEND_VEC3[0], SCRATCH_BLEND_VEC3[1], SCRATCH_BLEND_VEC3[2]]);
57
+ } else out.set(id, [a[0], a[1], a[2]]);
58
+ }
59
+ for (const [id, b] of poseB) if (!poseA.has(id)) out.set(id, [b[0], b[1], b[2]]);
60
+ }
61
+
62
+ /**
63
+ * Evaluates an AnimationClip at a specific timestamp and writes the resulting pose into `outPose`.
64
+ */
65
+ export function evaluateClip(clip: AnimationClip, time: number, outPose: AnimationPose): void {
66
+ for (const track of clip.tracks) {
67
+ const id = track.targetEntity;
68
+
69
+ switch (track.path) {
70
+ case 'translation':
71
+ sampleTrack(track, time, SCRATCH_VEC3);
72
+ outPose.translations.set(id, [SCRATCH_VEC3[0]!, SCRATCH_VEC3[1]!, SCRATCH_VEC3[2]!]);
73
+ break;
74
+
75
+ case 'rotation':
76
+ sampleTrack(track, time, SCRATCH_VEC4);
77
+ outPose.rotations.set(id, [
78
+ SCRATCH_VEC4[0]!,
79
+ SCRATCH_VEC4[1]!,
80
+ SCRATCH_VEC4[2]!,
81
+ SCRATCH_VEC4[3]!
82
+ ]);
83
+ break;
84
+
85
+ case 'scale':
86
+ sampleTrack(track, time, SCRATCH_VEC3);
87
+ outPose.scales.set(id, [SCRATCH_VEC3[0]!, SCRATCH_VEC3[1]!, SCRATCH_VEC3[2]!]);
88
+ break;
89
+
90
+ case 'weights': {
91
+ const records = track.timestamps.length * (track.interpolation === 'cubicspline' ? 3 : 1);
92
+ const weightCount = Math.floor(track.values.length / Math.max(1, records));
93
+ let weightsBuf = outPose.weights.get(id);
94
+ if (!weightsBuf || weightsBuf.length !== weightCount) {
95
+ weightsBuf = new Float32Array(weightCount);
96
+ outPose.weights.set(id, weightsBuf);
97
+ }
98
+ sampleTrack(track, time, weightsBuf);
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Linearly and spherically blends two poses according to weight alpha (0.0 = poseA, 1.0 = poseB).
107
+ */
108
+ export function blendPoses(
109
+ out: AnimationPose,
110
+ poseA: AnimationPose,
111
+ poseB: AnimationPose,
112
+ alpha: number
113
+ ): void {
114
+ const t = common.clamp(alpha, 0, 1);
115
+
116
+ if (t === 0) {
117
+ copyAnimationPose(poseA, out);
118
+ return;
119
+ }
120
+ if (t === 1) {
121
+ copyAnimationPose(poseB, out);
122
+ return;
123
+ }
124
+
125
+ clearAnimationPose(out);
126
+
127
+ blendVectors(out.translations, poseA.translations, poseB.translations, t);
128
+
129
+ // Blend rotations with quaternion SLERP.
130
+ for (const [id, a] of poseA.rotations) {
131
+ const b = poseB.rotations.get(id);
132
+ if (b) {
133
+ quat.slerp(SCRATCH_BLEND_QUAT, a, b, t);
134
+ out.rotations.set(id, [
135
+ SCRATCH_BLEND_QUAT[0],
136
+ SCRATCH_BLEND_QUAT[1],
137
+ SCRATCH_BLEND_QUAT[2],
138
+ SCRATCH_BLEND_QUAT[3]
139
+ ]);
140
+ } else out.rotations.set(id, [a[0], a[1], a[2], a[3]]);
141
+ }
142
+ for (const [id, b] of poseB.rotations)
143
+ if (!poseA.rotations.has(id)) out.rotations.set(id, [b[0], b[1], b[2], b[3]]);
144
+
145
+ // Blend scales.
146
+ blendVectors(out.scales, poseA.scales, poseB.scales, t);
147
+
148
+ // Blend weights.
149
+ for (const [id, a] of poseA.weights) {
150
+ const b = poseB.weights.get(id);
151
+ if (b && a.length === b.length) {
152
+ const blended = new Float32Array(a.length);
153
+ for (let i = 0; i < a.length; i++) {
154
+ blended[i] = common.lerp(a[i]!, b[i]!, t);
155
+ }
156
+ out.weights.set(id, blended);
157
+ } else {
158
+ out.weights.set(id, new Float32Array(a));
159
+ }
160
+ }
161
+ for (const [id, b] of poseB.weights)
162
+ if (!poseA.weights.has(id)) out.weights.set(id, new Float32Array(b));
163
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Animation track and clip structures supporting keyframe sampling for glTF animations.
3
+ */
4
+
5
+ import type { EntityId } from '../ecs/entity';
6
+ import { common, quat, type Quat } from '../math/index';
7
+
8
+ export type InterpolationMode = 'step' | 'linear' | 'cubicspline';
9
+ export type AnimationPath = 'translation' | 'rotation' | 'scale' | 'weights';
10
+
11
+ export interface KeyframeTrack {
12
+ targetEntity: EntityId;
13
+ path: AnimationPath;
14
+ /** Monotonically increasing keyframe timestamps in seconds */
15
+ timestamps: Float32Array;
16
+ /** Flattened keyframe values: 3 floats per key for pos/scale, 4 for quat, N for weights */
17
+ values: Float32Array;
18
+ interpolation: InterpolationMode;
19
+ }
20
+
21
+ export interface AnimationClip {
22
+ name: string;
23
+ duration: number;
24
+ tracks: KeyframeTrack[];
25
+ }
26
+
27
+ /**
28
+ * Samples an individual keyframe track at a given time and outputs interpolated values into `out`.
29
+ */
30
+ export function sampleTrack(track: KeyframeTrack, time: number, out: Float32Array): void {
31
+ const times = track.timestamps;
32
+ const values = track.values;
33
+ const count = times.length;
34
+ if (count === 0) return;
35
+
36
+ const isCubic = track.interpolation === 'cubicspline';
37
+ const stride = track.path === 'rotation' ? 4 : track.path === 'weights' ? out.length : 3;
38
+
39
+ // In glTF cubic spline, each keyframe stores 3 values: [in-tangent, value, out-tangent]
40
+ const recordSize = isCubic ? stride * 3 : stride;
41
+
42
+ // Edge case 1: Before or at first keyframe
43
+ if (time <= times[0]!) {
44
+ const valOffset = isCubic ? stride : 0;
45
+ for (let i = 0; i < stride; i++) {
46
+ out[i] = values[valOffset + i] ?? 0;
47
+ }
48
+ return;
49
+ }
50
+
51
+ // Edge case 2: After or at last keyframe
52
+ if (time >= times[count - 1]!) {
53
+ const valOffset = (count - 1) * recordSize + (isCubic ? stride : 0);
54
+ for (let i = 0; i < stride; i++) {
55
+ out[i] = values[valOffset + i] ?? 0;
56
+ }
57
+ return;
58
+ }
59
+
60
+ // Binary search for keyframe interval [k, k + 1] such that times[k] <= time < times[k + 1]
61
+ let low = 0;
62
+ let high = count - 1;
63
+ while (low <= high) {
64
+ const mid = (low + high) >> 1;
65
+ if (times[mid]! <= time) {
66
+ low = mid + 1;
67
+ } else {
68
+ high = mid - 1;
69
+ }
70
+ }
71
+ const k = Math.max(0, low - 1);
72
+ const t0 = times[k]!;
73
+ const t1 = times[k + 1]!;
74
+ const deltaT = t1 - t0;
75
+ const factor = deltaT > 0 ? common.clamp((time - t0) / deltaT, 0, 1) : 0;
76
+
77
+ // 1. STEP interpolation
78
+ if (track.interpolation === 'step') {
79
+ const valOffset = k * recordSize + (isCubic ? stride : 0);
80
+ for (let i = 0; i < stride; i++) {
81
+ out[i] = values[valOffset + i] ?? 0;
82
+ }
83
+ return;
84
+ }
85
+
86
+ // 2. LINEAR interpolation
87
+ if (track.interpolation === 'linear') {
88
+ const offset0 = k * recordSize;
89
+ const offset1 = (k + 1) * recordSize;
90
+
91
+ if (track.path === 'rotation') {
92
+ const q0 = values.subarray(offset0, offset0 + 4) as unknown as Quat;
93
+ const q1 = values.subarray(offset1, offset1 + 4) as unknown as Quat;
94
+ quat.slerp(out as unknown as Quat, q0, q1, factor);
95
+ } else {
96
+ for (let i = 0; i < stride; i++) {
97
+ const v0 = values[offset0 + i] ?? 0;
98
+ const v1 = values[offset1 + i] ?? 0;
99
+ out[i] = common.lerp(v0, v1, factor);
100
+ }
101
+ }
102
+ return;
103
+ }
104
+
105
+ // 3. CUBIC SPLINE interpolation
106
+ // Hermite polynomial coefficients:
107
+ // p(tau) = (2*tau^3 - 3*tau^2 + 1)*p0 + (tau^3 - 2*tau^2 + tau)*dt*m0 + (-2*tau^3 + 3*tau^2)*p1 + (tau^3 - tau^2)*dt*m1
108
+ const tau = factor;
109
+ const tau2 = tau * tau;
110
+ const tau3 = tau2 * tau;
111
+
112
+ const h00 = 2 * tau3 - 3 * tau2 + 1;
113
+ const h10 = (tau3 - 2 * tau2 + tau) * deltaT;
114
+ const h01 = -2 * tau3 + 3 * tau2;
115
+ const h11 = (tau3 - tau2) * deltaT;
116
+
117
+ const base0 = k * recordSize;
118
+ const base1 = (k + 1) * recordSize;
119
+
120
+ for (let i = 0; i < stride; i++) {
121
+ const p0 = values[base0 + stride + i] ?? 0;
122
+ const m0 = values[base0 + 2 * stride + i] ?? 0; // out-tangent of k
123
+ const p1 = values[base1 + stride + i] ?? 0;
124
+ const m1 = values[base1 + i] ?? 0; // in-tangent of k + 1
125
+
126
+ out[i] = h00 * p0 + h10 * m0 + h01 * p1 + h11 * m1;
127
+ }
128
+
129
+ if (track.path === 'rotation') {
130
+ quat.normalize(out as unknown as Quat, out as unknown as Quat);
131
+ }
132
+ }