@vulfram/engine 0.14.8-alpha → 0.19.2-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 (63) hide show
  1. package/README.md +106 -0
  2. package/package.json +60 -4
  3. package/src/core.ts +14 -0
  4. package/src/ecs.ts +1 -0
  5. package/src/engine/api.ts +222 -24
  6. package/src/engine/bridge/dispatch.ts +260 -40
  7. package/src/engine/bridge/guards.ts +4 -1
  8. package/src/engine/bridge/protocol.ts +69 -52
  9. package/src/engine/ecs/components.ts +340 -0
  10. package/src/engine/ecs/index.ts +3 -518
  11. package/src/engine/ecs/intents.ts +184 -0
  12. package/src/engine/ecs/systems.ts +26 -0
  13. package/src/engine/intents/store.ts +72 -0
  14. package/src/engine/state.ts +136 -5
  15. package/src/engine/systems/command-intent.ts +159 -14
  16. package/src/engine/systems/constraint-solve.ts +167 -0
  17. package/src/engine/systems/core-command-builder.ts +9 -268
  18. package/src/engine/systems/diagnostics.ts +20 -29
  19. package/src/engine/systems/index.ts +3 -1
  20. package/src/engine/systems/input-mirror.ts +257 -21
  21. package/src/engine/systems/resource-upload.ts +108 -58
  22. package/src/engine/systems/response-decode.ts +86 -15
  23. package/src/engine/systems/scene-sync.ts +305 -0
  24. package/src/engine/systems/ui-bridge.ts +381 -0
  25. package/src/engine/systems/utils.ts +86 -1
  26. package/src/engine/systems/world-lifecycle.ts +43 -114
  27. package/src/engine/window/manager.ts +168 -0
  28. package/src/engine/world/entities.ts +998 -91
  29. package/src/engine/world/mount.ts +195 -0
  30. package/src/engine/world/types.ts +71 -0
  31. package/src/engine/world/world-ui.ts +313 -0
  32. package/src/engine/world/world3d.ts +529 -0
  33. package/src/helpers/collision.ts +487 -0
  34. package/src/helpers/index.ts +2 -0
  35. package/src/helpers/raycast.ts +442 -0
  36. package/src/index.ts +30 -1
  37. package/src/mount.ts +2 -0
  38. package/src/types/cmds/audio.ts +73 -48
  39. package/src/types/cmds/camera.ts +12 -8
  40. package/src/types/cmds/environment.ts +9 -3
  41. package/src/types/cmds/geometry.ts +15 -16
  42. package/src/types/cmds/index.ts +234 -162
  43. package/src/types/cmds/input.ts +39 -0
  44. package/src/types/cmds/light.ts +12 -11
  45. package/src/types/cmds/material.ts +19 -21
  46. package/src/types/cmds/model.ts +17 -15
  47. package/src/types/cmds/realm.ts +23 -0
  48. package/src/types/cmds/system.ts +29 -0
  49. package/src/types/cmds/target.ts +96 -0
  50. package/src/types/cmds/texture.ts +13 -3
  51. package/src/types/cmds/ui.ts +220 -0
  52. package/src/types/cmds/window.ts +41 -204
  53. package/src/types/events/index.ts +4 -1
  54. package/src/types/events/keyboard.ts +2 -2
  55. package/src/types/events/pointer.ts +85 -13
  56. package/src/types/events/system.ts +188 -30
  57. package/src/types/events/ui.ts +21 -0
  58. package/src/types/index.ts +1 -0
  59. package/src/types/json.ts +15 -0
  60. package/src/window.ts +8 -0
  61. package/src/world-ui.ts +2 -0
  62. package/src/world3d.ts +10 -0
  63. package/tsconfig.json +0 -29
@@ -0,0 +1,26 @@
1
+ import type { WorldState } from '../state';
2
+ import type { ComponentSchema } from './components';
3
+
4
+ /** Pipeline stage at which a system runs. */
5
+ export type SystemStep = 'input' | 'update' | 'preRender' | 'postRender';
6
+
7
+ /** System execution context provided each frame. */
8
+ export interface SystemContext {
9
+ dt: number;
10
+ time: number;
11
+ worldId: number;
12
+ }
13
+
14
+ /** System function signature. */
15
+ export type System = (state: WorldState, context: SystemContext) => void;
16
+
17
+ /** Registry of components and systems. */
18
+ export interface EngineRegistry {
19
+ components: Map<string, ComponentSchema>;
20
+ systems: {
21
+ input: System[];
22
+ update: System[];
23
+ preRender: System[];
24
+ postRender: System[];
25
+ };
26
+ }
@@ -0,0 +1,72 @@
1
+ import type { Intent } from '../ecs';
2
+
3
+ export type IntentType = Intent['type'];
4
+
5
+ type IntentEntry<K extends IntentType> = {
6
+ seq: number;
7
+ intent: Extract<Intent, { type: K }>;
8
+ };
9
+
10
+ export class IntentStore {
11
+ private byType = new Map<IntentType, IntentEntry<IntentType>[]>();
12
+ private sequence = 0;
13
+ private total = 0;
14
+
15
+ enqueue(intent: Intent): void {
16
+ const key = intent.type as IntentType;
17
+ const list = this.byType.get(key);
18
+ const entry: IntentEntry<IntentType> = {
19
+ seq: this.sequence++,
20
+ intent: intent as Extract<Intent, { type: IntentType }>,
21
+ };
22
+ if (list) {
23
+ list.push(entry);
24
+ } else {
25
+ this.byType.set(key, [entry]);
26
+ }
27
+ this.total++;
28
+ }
29
+
30
+ take<K extends IntentType>(type: K): Extract<Intent, { type: K }>[] {
31
+ const list = this.byType.get(type);
32
+ if (!list || list.length === 0) {
33
+ return [];
34
+ }
35
+ this.byType.delete(type);
36
+ this.total -= list.length;
37
+ return list.map((entry) => entry.intent as Extract<Intent, { type: K }>);
38
+ }
39
+
40
+ takeMany<K extends IntentType>(
41
+ types: readonly K[],
42
+ ): Extract<Intent, { type: K }>[] {
43
+ const merged: IntentEntry<K>[] = [];
44
+ for (let i = 0; i < types.length; i++) {
45
+ const type = types[i];
46
+ if (type === undefined) continue;
47
+ const list = this.byType.get(type);
48
+ if (!list || list.length === 0) continue;
49
+ this.byType.delete(type);
50
+ this.total -= list.length;
51
+ for (let j = 0; j < list.length; j++) {
52
+ const entry = list[j];
53
+ if (entry) merged.push(entry as unknown as IntentEntry<K>);
54
+ }
55
+ }
56
+ merged.sort((a, b) => a.seq - b.seq);
57
+ return merged.map((entry) => entry.intent as Extract<Intent, { type: K }>);
58
+ }
59
+
60
+ size(): number {
61
+ return this.total;
62
+ }
63
+
64
+ clear(): void {
65
+ this.byType.clear();
66
+ this.total = 0;
67
+ }
68
+ }
69
+
70
+ export function createIntentStore(): IntentStore {
71
+ return new IntentStore();
72
+ }
@@ -1,13 +1,14 @@
1
1
  import type { EngineTransport } from '@vulfram/transport-types';
2
2
  import type { CommandResponseEnvelope, EngineCmdEnvelope } from '../types/cmds';
3
3
  import type { EngineEvent } from '../types/events';
4
+ import type { CmdRealmCreateArgs, RealmKind } from '../types/cmds/realm';
4
5
  import type {
5
6
  Component,
6
7
  ComponentType,
7
8
  EngineRegistry,
8
- Intent,
9
9
  WorldEvent,
10
10
  } from './ecs';
11
+ import type { IntentStore } from './intents/store';
11
12
 
12
13
  type EngineStatus = 'uninitialized' | 'initialized' | 'disposed';
13
14
 
@@ -28,8 +29,78 @@ type ResourceIdCounters = {
28
29
  texture: number;
29
30
  };
30
31
 
32
+ export type RoutingIndex = {
33
+ dirty: boolean;
34
+ byWindowId: Map<number, number[]>;
35
+ byRealmId: Map<number, number[]>;
36
+ byTargetId: Map<number, number[]>;
37
+ };
38
+
31
39
  export type WorldState = {
32
- windowId: number;
40
+ worldId: number;
41
+ /**
42
+ * Realm kind for this world.
43
+ * `three-d` realms are used by World3D APIs and `two-d` realms by WorldUI APIs.
44
+ */
45
+ realmKind: RealmKind;
46
+ primaryWindowId?: number;
47
+ boundWindowIds: Set<number>;
48
+ /**
49
+ * Latest target-layer binding args keyed by targetId.
50
+ * Used to keep realm->target binds coherent when cameras are created later.
51
+ */
52
+ targetLayerBindings: Map<
53
+ number,
54
+ {
55
+ targetId: number;
56
+ layout: import('../types/cmds/target').TargetLayerLayout;
57
+ cameraId?: number;
58
+ environmentId?: number;
59
+ }
60
+ >;
61
+ /**
62
+ * Window target ownership keyed by targetId.
63
+ * Tracks which window a target is currently presenting into for routing purposes.
64
+ */
65
+ targetWindowBindings: Map<number, number>;
66
+ coreRealmId?: number;
67
+ realmCreateRetryCount: number;
68
+ nextRealmCreateRetryAtMs: number;
69
+ realmCreateArgs: CmdRealmCreateArgs;
70
+ coreSurfaceId?: number;
71
+ corePresentId?: number;
72
+ /**
73
+ * Constraint-solved world matrices keyed by entity id.
74
+ */
75
+ resolvedEntityTransforms: Map<number, Float32Array>;
76
+ /**
77
+ * Entities explicitly marked as dirty for constraint recomputation.
78
+ */
79
+ constraintDirtyEntities: Set<number>;
80
+ /**
81
+ * Scratch cache for per-tick constraint solve graph resolution.
82
+ */
83
+ constraintScratchResolved: Map<number, Float32Array>;
84
+ /**
85
+ * Scratch set used while resolving hierarchy recursion (cycle detection).
86
+ */
87
+ constraintScratchVisiting: Set<number>;
88
+ /**
89
+ * Entities with resolved matrix changes in the current tick.
90
+ */
91
+ constraintChangedEntities: Set<number>;
92
+ /**
93
+ * Parent -> children relationship index for hierarchy propagation.
94
+ */
95
+ constraintChildrenByParent: Map<number, Set<number>>;
96
+ /**
97
+ * Child -> parent relationship index for fast reparent operations.
98
+ */
99
+ constraintParentByChild: Map<number, number>;
100
+ /**
101
+ * Reusable scratch arrays used when syncing matrices to core.
102
+ */
103
+ sceneSyncMatrixScratch: Map<number, number[]>;
33
104
  entities: Set<number>;
34
105
  /**
35
106
  * Components stored by entityId -> componentType -> ComponentData
@@ -38,9 +109,9 @@ export type WorldState = {
38
109
  nextCoreId: number;
39
110
  systems: string[];
40
111
  /**
41
- * Pending intents to be processed by systems in the next tick.
112
+ * Indexed intent store to query/consume by type with stable insertion order.
42
113
  */
43
- pendingIntents: Intent[];
114
+ intentStore: IntentStore;
44
115
  /**
45
116
  * Internal world events for system-to-system communication.
46
117
  */
@@ -50,6 +121,7 @@ export type WorldState = {
50
121
  * Will be moved to the engine's global batch during the tick.
51
122
  */
52
123
  pendingCommands: EngineCmdEnvelope[];
124
+ pendingCommandsHead: number;
53
125
  /**
54
126
  * Events routed to this world in the current frame.
55
127
  */
@@ -64,17 +136,57 @@ export type EngineState = {
64
136
  status: EngineStatus;
65
137
  transport: EngineTransport | null;
66
138
  worlds: Map<number, WorldState>;
139
+ nextWorldId: number;
140
+ nextWindowId: number;
67
141
  nextEntityId: number;
68
142
  nextCommandId: number;
143
+ /**
144
+ * Global ID allocator for core-global resources/objects.
145
+ * Use for IDs that are not realm-scoped (material/geometry/texture/target/...).
146
+ */
147
+ nextGlobalId: number;
69
148
  /**
70
149
  * Global command batch for the current frame.
71
150
  */
72
151
  commandBatch: EngineCmdEnvelope[];
152
+ /**
153
+ * Tracks reserved window IDs to avoid logical ID collisions for host windows.
154
+ */
155
+ usedWindowIds: Set<number>;
156
+ /**
157
+ * Pending global commands not scoped to any specific world.
158
+ */
159
+ globalPendingCommands: EngineCmdEnvelope[];
160
+ globalPendingCommandsHead: number;
161
+ /**
162
+ * Window IDs confirmed by core `window-create` responses.
163
+ */
164
+ confirmedWindowIds: Set<number>;
165
+ /**
166
+ * Maps pending window-create command ids to requested window ids.
167
+ */
168
+ pendingWindowCreateByCommandId: Map<number, number>;
169
+ /**
170
+ * Maps pending window-close command ids to target window ids.
171
+ */
172
+ pendingWindowCloseByCommandId: Map<number, number>;
73
173
  /**
74
174
  * Maps command IDs to the world ID that created them.
75
175
  * This is used to route responses back to the correct world.
76
176
  */
77
177
  commandTracker: Map<number, number>;
178
+ /**
179
+ * Tracks global commands (not world-scoped) so their responses are not lost.
180
+ */
181
+ globalCommandTracker: Set<number>;
182
+ /**
183
+ * Buffered responses for global commands.
184
+ */
185
+ globalInboundResponses: CommandResponseEnvelope[];
186
+ /**
187
+ * Cached world routing index for event fanout.
188
+ */
189
+ routingIndex: RoutingIndex;
78
190
  /**
79
191
  * Registry for custom components and systems.
80
192
  */
@@ -99,8 +211,10 @@ export type EngineState = {
99
211
  export const REQUIRED_SYSTEMS = [
100
212
  'InputMirrorSystem',
101
213
  'CommandIntentSystem',
214
+ 'UiBridgeSystem',
102
215
  'ResourceUploadSystem',
103
- 'CoreCommandBuilderSystem',
216
+ 'ConstraintSolveSystem',
217
+ 'SceneSyncSystem',
104
218
  'ResponseDecodeSystem',
105
219
  'WorldLifecycleSystem',
106
220
  'DiagnosticsSystem',
@@ -110,10 +224,27 @@ export const engineState: EngineState = {
110
224
  status: 'uninitialized',
111
225
  transport: null,
112
226
  worlds: new Map(),
227
+ nextWorldId: 1,
228
+ nextWindowId: 1,
113
229
  nextEntityId: 1,
114
230
  nextCommandId: 1,
231
+ nextGlobalId: 100,
115
232
  commandBatch: [],
233
+ usedWindowIds: new Set(),
234
+ globalPendingCommands: [],
235
+ globalPendingCommandsHead: 0,
236
+ confirmedWindowIds: new Set(),
237
+ pendingWindowCreateByCommandId: new Map(),
238
+ pendingWindowCloseByCommandId: new Map(),
116
239
  commandTracker: new Map(),
240
+ globalCommandTracker: new Set(),
241
+ globalInboundResponses: [],
242
+ routingIndex: {
243
+ dirty: true,
244
+ byWindowId: new Map(),
245
+ byRealmId: new Map(),
246
+ byTargetId: new Map(),
247
+ },
117
248
  registry: {
118
249
  components: new Map(),
119
250
  systems: {
@@ -5,15 +5,54 @@ import type {
5
5
  LightComponent,
6
6
  ModelComponent,
7
7
  System,
8
+ TransformComponent,
8
9
  } from '../ecs';
10
+ import { toQuat, toVec3 } from './utils';
9
11
 
12
+ const COMMAND_INTENT_TYPES = [
13
+ 'create-entity',
14
+ 'attach-tag',
15
+ 'set-parent',
16
+ 'update-transform',
17
+ 'remove-entity',
18
+ ] as const;
19
+
20
+ function wouldCreateParentCycle(
21
+ world: Parameters<System>[0],
22
+ childEntityId: number,
23
+ parentEntityId: number,
24
+ ): boolean {
25
+ let cursor: number | null = parentEntityId;
26
+ const visited = new Set<number>();
27
+
28
+ while (cursor !== null) {
29
+ if (cursor === childEntityId) {
30
+ return true;
31
+ }
32
+ if (visited.has(cursor)) {
33
+ return true;
34
+ }
35
+ visited.add(cursor);
36
+
37
+ const store = world.components.get(cursor);
38
+ const parent = store?.get('Parent') as { parentId: number } | undefined;
39
+ cursor = parent?.parentId ?? null;
40
+ }
41
+
42
+ return false;
43
+ }
44
+
45
+ /**
46
+ * Applies structural ECS intents that must mutate local world state before
47
+ * render-time command synthesis (entity lifecycle, tags, parent links, transforms).
48
+ */
10
49
  export const CommandIntentSystem: System = (world, context) => {
11
- const intentsToRemove: number[] = [];
50
+ const realmId = world.coreRealmId;
51
+ const intents = world.intentStore.takeMany(COMMAND_INTENT_TYPES);
12
52
 
13
- for (let i = 0; i < world.pendingIntents.length; i++) {
14
- const intent = world.pendingIntents[i];
53
+ for (let i = 0; i < intents.length; i++) {
54
+ const intent = intents[i];
15
55
  if (!intent) continue;
16
-
17
56
  if (intent.type === 'create-entity') {
18
57
  world.entities.add(intent.entityId);
19
58
  // Initialize default transform
@@ -32,8 +71,117 @@ export const CommandIntentSystem: System = (world, context) => {
32
71
  visible: true,
33
72
  });
34
73
  }
35
- intentsToRemove.push(i);
74
+ world.constraintDirtyEntities.add(intent.entityId);
75
+ } else if (intent.type === 'attach-tag') {
76
+ let store = world.components.get(intent.entityId);
77
+ if (!store) {
78
+ store = new Map();
79
+ world.components.set(intent.entityId, store);
80
+ }
81
+ store.set('Tag', {
82
+ type: 'Tag',
83
+ name: intent.props.name ?? '',
84
+ labels: new Set(intent.props.labels ?? []),
85
+ });
86
+ } else if (intent.type === 'set-parent') {
87
+ let store = world.components.get(intent.entityId);
88
+ if (!store) {
89
+ store = new Map();
90
+ world.components.set(intent.entityId, store);
91
+ }
92
+ if (intent.parentId === null) {
93
+ const oldParentId = world.constraintParentByChild.get(intent.entityId);
94
+ if (oldParentId !== undefined) {
95
+ const siblings = world.constraintChildrenByParent.get(oldParentId);
96
+ siblings?.delete(intent.entityId);
97
+ if (siblings && siblings.size === 0) {
98
+ world.constraintChildrenByParent.delete(oldParentId);
99
+ }
100
+ world.constraintParentByChild.delete(intent.entityId);
101
+ }
102
+ store.delete('Parent');
103
+ } else {
104
+ if (intent.parentId === intent.entityId) {
105
+ console.error(
106
+ `[World ${context.worldId}] Invalid parent constraint: entity ${intent.entityId} cannot parent itself.`,
107
+ );
108
+ continue;
109
+ }
110
+ if (wouldCreateParentCycle(world, intent.entityId, intent.parentId)) {
111
+ console.error(
112
+ `[World ${context.worldId}] Invalid parent constraint: cycle detected for child ${intent.entityId} and parent ${intent.parentId}.`,
113
+ );
114
+ continue;
115
+ }
116
+ store.set('Parent', {
117
+ type: 'Parent',
118
+ parentId: intent.parentId,
119
+ });
120
+
121
+ const oldParentId = world.constraintParentByChild.get(intent.entityId);
122
+ if (oldParentId !== undefined && oldParentId !== intent.parentId) {
123
+ const siblings = world.constraintChildrenByParent.get(oldParentId);
124
+ siblings?.delete(intent.entityId);
125
+ if (siblings && siblings.size === 0) {
126
+ world.constraintChildrenByParent.delete(oldParentId);
127
+ }
128
+ }
129
+ world.constraintParentByChild.set(intent.entityId, intent.parentId);
130
+ let children = world.constraintChildrenByParent.get(intent.parentId);
131
+ if (!children) {
132
+ children = new Set();
133
+ world.constraintChildrenByParent.set(intent.parentId, children);
134
+ }
135
+ children.add(intent.entityId);
136
+ }
137
+ world.constraintDirtyEntities.add(intent.entityId);
138
+ } else if (intent.type === 'update-transform') {
139
+ const store = world.components.get(intent.entityId);
140
+ if (!store) {
141
+ continue;
142
+ }
143
+ const transform = store.get('Transform') as TransformComponent | undefined;
144
+ if (!transform) {
145
+ continue;
146
+ }
147
+
148
+ const nextProps = { ...intent.props };
149
+ if (nextProps.position) {
150
+ nextProps.position = toVec3(nextProps.position);
151
+ }
152
+ if (nextProps.rotation) {
153
+ nextProps.rotation = toQuat(nextProps.rotation);
154
+ }
155
+ if (nextProps.scale) {
156
+ nextProps.scale = toVec3(nextProps.scale);
157
+ }
158
+ Object.assign(transform, nextProps);
159
+ world.constraintDirtyEntities.add(intent.entityId);
36
160
  } else if (intent.type === 'remove-entity') {
161
+ if (realmId === undefined) continue;
162
+
163
+ // Detach all children linked to this entity to keep hierarchy state coherent.
164
+ const children = world.constraintChildrenByParent.get(intent.entityId);
165
+ if (children) {
166
+ for (const childId of children) {
167
+ const childStore = world.components.get(childId);
168
+ childStore?.delete('Parent');
169
+ world.constraintParentByChild.delete(childId);
170
+ world.constraintDirtyEntities.add(childId);
171
+ }
172
+ world.constraintChildrenByParent.delete(intent.entityId);
173
+ }
174
+
175
+ const parentId = world.constraintParentByChild.get(intent.entityId);
176
+ if (parentId !== undefined) {
177
+ const siblings = world.constraintChildrenByParent.get(parentId);
178
+ siblings?.delete(intent.entityId);
179
+ if (siblings && siblings.size === 0) {
180
+ world.constraintChildrenByParent.delete(parentId);
181
+ }
182
+ world.constraintParentByChild.delete(intent.entityId);
183
+ }
184
+
37
185
  const store = world.components.get(intent.entityId);
38
186
  if (store) {
39
187
  // Emit disposal commands for all components with IDs
@@ -42,19 +190,19 @@ export const CommandIntentSystem: System = (world, context) => {
42
190
  if (type === 'Model') {
43
191
  const modelComp = comp as ModelComponent;
44
192
  enqueueCommand(context.worldId, 'cmd-model-dispose', {
45
- windowId: context.worldId,
193
+ realmId,
46
194
  modelId: modelComp.id,
47
195
  });
48
196
  } else if (type === 'Camera') {
49
197
  const cameraComp = comp as CameraComponent;
50
198
  enqueueCommand(context.worldId, 'cmd-camera-dispose', {
51
- windowId: context.worldId,
199
+ realmId,
52
200
  cameraId: cameraComp.id,
53
201
  });
54
202
  } else if (type === 'Light') {
55
203
  const lightComp = comp as LightComponent;
56
204
  enqueueCommand(context.worldId, 'cmd-light-dispose', {
57
- windowId: context.worldId,
205
+ realmId,
58
206
  lightId: lightComp.id,
59
207
  });
60
208
  }
@@ -63,12 +211,9 @@ export const CommandIntentSystem: System = (world, context) => {
63
211
  world.components.delete(intent.entityId);
64
212
  }
65
213
  world.entities.delete(intent.entityId);
66
- intentsToRemove.push(i);
214
+ world.resolvedEntityTransforms.delete(intent.entityId);
215
+ world.sceneSyncMatrixScratch.delete(intent.entityId);
216
+ world.constraintDirtyEntities.add(intent.entityId);
67
217
  }
68
218
  }
69
-
70
- for (let i = intentsToRemove.length - 1; i >= 0; i--) {
71
- const idx = intentsToRemove[i];
72
- if (idx !== undefined) world.pendingIntents.splice(idx, 1);
73
- }
74
219
  };
@@ -0,0 +1,167 @@
1
+ import { mat4 } from 'gl-matrix';
2
+ import type { ParentComponent, System, TransformComponent } from '../ecs';
3
+ import { mat4EqualsApprox, getEntityLocalTransformMatrix } from './utils';
4
+
5
+ type ConstraintStrategyContext = {
6
+ worldId: number;
7
+ entityId: number;
8
+ };
9
+
10
+ interface ConstraintStrategy {
11
+ name: string;
12
+ apply(a: mat4, b: mat4, context: ConstraintStrategyContext): mat4;
13
+ }
14
+
15
+ const ParentConstraintStrategy: ConstraintStrategy = {
16
+ name: 'parent',
17
+ apply(a, b) {
18
+ const out = mat4.create();
19
+ mat4.multiply(out, a, b);
20
+ return out;
21
+ },
22
+ };
23
+
24
+ type ResolveState = {
25
+ worldId: number;
26
+ world: Parameters<System>[0];
27
+ affected: Set<number>;
28
+ resolved: Map<number, mat4>;
29
+ visiting: Set<number>;
30
+ cycleLogged: boolean;
31
+ };
32
+
33
+ function resolveEntityMatrix(state: ResolveState, entityId: number): mat4 {
34
+ if (!state.affected.has(entityId)) {
35
+ const existing = state.world.resolvedEntityTransforms.get(entityId);
36
+ if (existing) {
37
+ return existing as unknown as mat4;
38
+ }
39
+ }
40
+
41
+ const cached = state.resolved.get(entityId);
42
+ if (cached) {
43
+ return cached;
44
+ }
45
+
46
+ if (state.visiting.has(entityId)) {
47
+ if (!state.cycleLogged) {
48
+ console.error(
49
+ `[World ${state.worldId}] Constraint cycle detected in parent hierarchy. Falling back to local transforms for cyclic nodes.`,
50
+ );
51
+ state.cycleLogged = true;
52
+ }
53
+ return getEntityLocalTransformMatrix(state.world, entityId);
54
+ }
55
+
56
+ state.visiting.add(entityId);
57
+ const local = getEntityLocalTransformMatrix(state.world, entityId);
58
+ const store = state.world.components.get(entityId);
59
+ const parent = store?.get('Parent') as ParentComponent | undefined;
60
+
61
+ let resolved = local;
62
+ if (parent) {
63
+ const parentStore = state.world.components.get(parent.parentId);
64
+ const parentTransform = parentStore?.get('Transform') as
65
+ | TransformComponent
66
+ | undefined;
67
+ if (parentTransform) {
68
+ const parentMatrix = resolveEntityMatrix(state, parent.parentId);
69
+ resolved = ParentConstraintStrategy.apply(parentMatrix, local, {
70
+ worldId: state.worldId,
71
+ entityId,
72
+ });
73
+ }
74
+ }
75
+
76
+ state.visiting.delete(entityId);
77
+ state.resolved.set(entityId, resolved);
78
+ return resolved;
79
+ }
80
+
81
+ function collectAffectedEntities(world: Parameters<System>[0]): Set<number> {
82
+ const affected = new Set<number>();
83
+ const queue: number[] = [];
84
+
85
+ for (const entityId of world.constraintDirtyEntities) {
86
+ affected.add(entityId);
87
+ queue.push(entityId);
88
+ }
89
+
90
+ for (let i = 0; i < queue.length; i++) {
91
+ const parentId = queue[i]!;
92
+ const children = world.constraintChildrenByParent.get(parentId);
93
+ if (!children) continue;
94
+ for (const childId of children) {
95
+ if (affected.has(childId)) continue;
96
+ affected.add(childId);
97
+ queue.push(childId);
98
+ }
99
+ }
100
+
101
+ return affected;
102
+ }
103
+
104
+ /**
105
+ * Resolves transform constraints into world matrices.
106
+ *
107
+ * Current built-in strategy:
108
+ * - `parent`: composes parent world matrix with child local matrix (`A * B`).
109
+ *
110
+ * The solver walks dependencies from higher nodes to leaves and caches each
111
+ * resolved entity matrix so render sync can emit final transforms to core.
112
+ */
113
+ export const ConstraintSolveSystem: System = (world, context) => {
114
+ if (world.constraintDirtyEntities.size === 0) {
115
+ world.constraintChangedEntities.clear();
116
+ return;
117
+ }
118
+
119
+ const matrices = world.resolvedEntityTransforms;
120
+ const changed = world.constraintChangedEntities;
121
+ changed.clear();
122
+ const affected = collectAffectedEntities(world);
123
+
124
+ const resolveState: ResolveState = {
125
+ worldId: context.worldId,
126
+ world,
127
+ affected,
128
+ resolved: world.constraintScratchResolved,
129
+ visiting: world.constraintScratchVisiting,
130
+ cycleLogged: false,
131
+ };
132
+ resolveState.resolved.clear();
133
+ resolveState.visiting.clear();
134
+
135
+ for (const entityId of affected) {
136
+ if (!world.entities.has(entityId)) {
137
+ if (matrices.delete(entityId)) {
138
+ changed.add(entityId);
139
+ }
140
+ continue;
141
+ }
142
+
143
+ const store = world.components.get(entityId);
144
+ const transform = store?.get('Transform') as TransformComponent | undefined;
145
+ if (!transform) {
146
+ if (matrices.delete(entityId)) {
147
+ changed.add(entityId);
148
+ }
149
+ continue;
150
+ }
151
+
152
+ const resolved = resolveEntityMatrix(resolveState, entityId);
153
+ const previousSnapshot = matrices.get(entityId);
154
+ if (!previousSnapshot) {
155
+ matrices.set(entityId, new Float32Array(resolved));
156
+ changed.add(entityId);
157
+ continue;
158
+ }
159
+
160
+ if (!mat4EqualsApprox(previousSnapshot, resolved)) {
161
+ previousSnapshot.set(resolved);
162
+ changed.add(entityId);
163
+ }
164
+ }
165
+
166
+ world.constraintDirtyEntities.clear();
167
+ };