@galfus/engine 0.0.0-stage → 0.31.1-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.
- package/README.md +190 -2
- package/package.json +59 -4
- package/src/anchors/cache.ts +101 -0
- package/src/anchors/component.ts +123 -0
- package/src/anchors/hierarchy.ts +192 -0
- package/src/anchors/index.ts +8 -0
- package/src/anchors/solver.ts +247 -0
- package/src/anchors/strategy.ts +142 -0
- package/src/animation/blending.ts +163 -0
- package/src/animation/clip.ts +132 -0
- package/src/animation/coordinator.ts +568 -0
- package/src/animation/index.ts +7 -0
- package/src/animation/native-targets.ts +37 -0
- package/src/animation/player.ts +287 -0
- package/src/audio/index.ts +4 -0
- package/src/audio/manager.ts +371 -0
- package/src/collision/components.ts +211 -0
- package/src/collision/index.ts +16 -0
- package/src/collision/query2d.ts +167 -0
- package/src/collision/query3d.ts +170 -0
- package/src/ecs/component.ts +130 -0
- package/src/ecs/entity.ts +75 -0
- package/src/ecs/index.ts +9 -0
- package/src/ecs/intent.ts +162 -0
- package/src/ecs/metadata.ts +44 -0
- package/src/ecs/system.ts +224 -0
- package/src/ecs/world.ts +291 -0
- package/src/index.ts +19 -0
- package/src/interaction/focus.ts +327 -0
- package/src/interaction/index.ts +9 -0
- package/src/interaction/listener.ts +58 -0
- package/src/interaction/snapshot2d.ts +210 -0
- package/src/interaction/snapshot3d.ts +214 -0
- package/src/interaction/system.ts +544 -0
- package/src/interaction/types.ts +27 -0
- package/src/math/common.ts +67 -0
- package/src/math/index.ts +17 -0
- package/src/math/mat2d.ts +320 -0
- package/src/math/mat4.ts +576 -0
- package/src/math/quat.ts +438 -0
- package/src/math/vec2.ts +161 -0
- package/src/math/vec3.ts +242 -0
- package/src/math/vec4.ts +171 -0
- package/src/multiverse/coordinator.ts +540 -0
- package/src/multiverse/index.ts +13 -0
- package/src/multiverse/presentation-graph.ts +306 -0
- package/src/multiverse/presentation-links.ts +134 -0
- package/src/multiverse/realm.ts +49 -0
- package/src/platform/index.ts +2 -0
- package/src/platform/platform.ts +149 -0
- package/src/platform/window.ts +181 -0
- package/src/realm2d/components.ts +254 -0
- package/src/realm2d/index.ts +12 -0
- package/src/realm2d/presets/index.ts +36 -0
- package/src/realm2d/realm.ts +522 -0
- package/src/realm2d/sync.ts +284 -0
- package/src/realm2d/utils/index.ts +32 -0
- package/src/realm3d/components.ts +341 -0
- package/src/realm3d/geometry.ts +224 -0
- package/src/realm3d/index.ts +12 -0
- package/src/realm3d/presets/index.ts +67 -0
- package/src/realm3d/realm.ts +588 -0
- package/src/realm3d/resource-ids.ts +62 -0
- package/src/realm3d/skinning.ts +128 -0
- package/src/realm3d/sync.ts +442 -0
- package/src/realm3d/utils/index.ts +26 -0
- package/src/resources/index.ts +4 -0
- package/src/resources/manager.ts +92 -0
- package/src/runtime.ts +588 -0
- package/src/spatial/dirty_registry.ts +217 -0
- package/src/spatial/flags.ts +14 -0
- package/src/spatial/index.ts +6 -0
- package/src/spatial/metrics.ts +124 -0
- package/src/text/editor.ts +224 -0
- package/src/text/encoding.ts +38 -0
- package/src/text/index.ts +13 -0
- package/src/text/input.ts +160 -0
- package/src/text/keyboard.ts +50 -0
- package/src/text/svg-decorations.ts +147 -0
- package/src/types.ts +85 -0
|
@@ -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
|
+
}
|