@forgeax/engine-scene 0.1.21 → 0.1.24

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 (87) hide show
  1. package/README.md +82 -11
  2. package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts +5 -3
  3. package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts.map +1 -1
  4. package/dist/__tests__/flat-propagation.contract.test.d.ts +2 -0
  5. package/dist/__tests__/flat-propagation.contract.test.d.ts.map +1 -0
  6. package/dist/__tests__/flat-propagation.derived-writer.contract.test.d.ts +2 -0
  7. package/dist/__tests__/flat-propagation.derived-writer.contract.test.d.ts.map +1 -0
  8. package/dist/__tests__/flat-propagation.perf.test.d.ts +2 -0
  9. package/dist/__tests__/flat-propagation.perf.test.d.ts.map +1 -0
  10. package/dist/__tests__/hierarchy-propagation.perf.test.d.ts +2 -0
  11. package/dist/__tests__/hierarchy-propagation.perf.test.d.ts.map +1 -0
  12. package/dist/__tests__/scene-binding.integration.test.d.ts +2 -0
  13. package/dist/__tests__/scene-binding.integration.test.d.ts.map +1 -0
  14. package/dist/__tests__/schedule-order.contract.test.d.ts +2 -0
  15. package/dist/__tests__/schedule-order.contract.test.d.ts.map +1 -0
  16. package/dist/__tests__/structural.test.d.ts +2 -0
  17. package/dist/__tests__/structural.test.d.ts.map +1 -0
  18. package/dist/__tests__/transform-carrier-consumers.test-d.d.ts +2 -0
  19. package/dist/__tests__/transform-carrier-consumers.test-d.d.ts.map +1 -0
  20. package/dist/__tests__/transform-carrier-prototype.bench.d.ts +39 -0
  21. package/dist/__tests__/transform-carrier-prototype.bench.d.ts.map +1 -0
  22. package/dist/__tests__/transform-carrier-prototype.test-d.d.ts +2 -0
  23. package/dist/__tests__/transform-carrier-prototype.test-d.d.ts.map +1 -0
  24. package/dist/__tests__/transform-carrier-prototype.test.d.ts +2 -0
  25. package/dist/__tests__/transform-carrier-prototype.test.d.ts.map +1 -0
  26. package/dist/__tests__/transform-component-version.unit.test.d.ts +2 -0
  27. package/dist/__tests__/transform-component-version.unit.test.d.ts.map +1 -0
  28. package/dist/assets/scene-decoder.d.ts.map +1 -1
  29. package/dist/components/child-of.d.ts +23 -14
  30. package/dist/components/child-of.d.ts.map +1 -1
  31. package/dist/components/children.d.ts +14 -13
  32. package/dist/components/children.d.ts.map +1 -1
  33. package/dist/components/transform.d.ts +7 -43
  34. package/dist/components/transform.d.ts.map +1 -1
  35. package/dist/errors.d.ts +23 -2
  36. package/dist/errors.d.ts.map +1 -1
  37. package/dist/index.d.ts +3 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.mjs +1122 -358
  40. package/dist/index.mjs.map +1 -1
  41. package/dist/instances/binding.d.ts +29 -0
  42. package/dist/instances/binding.d.ts.map +1 -0
  43. package/dist/instances/externalization.d.ts.map +1 -1
  44. package/dist/instances/scene-instances.d.ts +6 -18
  45. package/dist/instances/scene-instances.d.ts.map +1 -1
  46. package/dist/instances/state.d.ts +28 -0
  47. package/dist/instances/state.d.ts.map +1 -0
  48. package/dist/plugin.d.ts.map +1 -1
  49. package/dist/systems/hierarchy-projection.d.ts.map +1 -1
  50. package/dist/systems/index.d.ts +1 -1
  51. package/dist/systems/index.d.ts.map +1 -1
  52. package/dist/systems/propagate-transforms.d.ts +22 -2
  53. package/dist/systems/propagate-transforms.d.ts.map +1 -1
  54. package/package.json +5 -5
  55. package/src/__tests__/components.unit.test.ts +34 -2
  56. package/src/__tests__/fixtures/malformed-hierarchy-edge.ts +26 -4
  57. package/src/__tests__/flat-propagation.contract.test.ts +128 -0
  58. package/src/__tests__/flat-propagation.derived-writer.contract.test.ts +39 -0
  59. package/src/__tests__/flat-propagation.perf.test.ts +336 -0
  60. package/src/__tests__/hierarchy-propagation.perf.test.ts +149 -0
  61. package/src/__tests__/plugin.integration.test.ts +2 -2
  62. package/src/__tests__/propagation.unit.test.ts +271 -91
  63. package/src/__tests__/scene-binding.integration.test.ts +124 -0
  64. package/src/__tests__/schedule-order.contract.test.ts +43 -0
  65. package/src/__tests__/structural.test.ts +23 -0
  66. package/src/__tests__/transform-carrier-consumers.test-d.ts +156 -0
  67. package/src/__tests__/transform-carrier-prototype.bench.ts +168 -0
  68. package/src/__tests__/transform-carrier-prototype.test-d.ts +71 -0
  69. package/src/__tests__/transform-carrier-prototype.test.ts +170 -0
  70. package/src/__tests__/{transform-change-journal.unit.test.ts → transform-component-version.unit.test.ts} +32 -39
  71. package/src/assets/scene-decoder.ts +6 -1
  72. package/src/components/child-of.ts +38 -27
  73. package/src/components/children.ts +36 -37
  74. package/src/components/transform.ts +23 -81
  75. package/src/errors.ts +26 -2
  76. package/src/index.ts +10 -1
  77. package/src/instances/binding.ts +77 -0
  78. package/src/instances/externalization.ts +7 -1
  79. package/src/instances/scene-instances.ts +51 -80
  80. package/src/instances/state.ts +62 -0
  81. package/src/plugin.ts +9 -2
  82. package/src/systems/hierarchy-projection.ts +25 -8
  83. package/src/systems/index.ts +3 -0
  84. package/src/systems/propagate-transforms.ts +1142 -240
  85. package/dist/.tsbuildinfo +0 -1
  86. package/dist/__tests__/transform-change-journal.unit.test.d.ts +0 -2
  87. package/dist/__tests__/transform-change-journal.unit.test.d.ts.map +0 -1
@@ -1,313 +1,1214 @@
1
1
  import {
2
2
  defineSystem,
3
3
  defineSystemSet,
4
+ type EcsError,
5
+ ENTITY_NULL_RAW,
4
6
  type EntityHandle,
5
7
  FixedUpdate,
8
+ type Query,
6
9
  type SystemHandle,
7
10
  Update,
8
11
  type World,
9
12
  } from '@forgeax/engine-ecs';
10
13
  import {
11
- createWorldProjection,
12
- setDerivedComponent,
13
- type WorldProjection,
14
- } from '@forgeax/engine-ecs/projection';
14
+ type DerivedColumnBinding,
15
+ type DerivedRangeCursor,
16
+ type DerivedRangeWriter,
17
+ getDerivedWriter,
18
+ worldInternal,
19
+ } from '@forgeax/engine-ecs/internal';
15
20
  import { type Mat4, mat4 } from '@forgeax/engine-math';
16
21
  import { err, ok, type Result } from '@forgeax/engine-types';
17
22
  import { ChildOf } from '../components/child-of';
18
- import { Transform } from '../components/transform';
23
+ import { Children } from '../components/children';
24
+ import { GlobalTransform, Transform } from '../components/transform';
19
25
  import { SceneError } from '../errors';
20
- import { projectHierarchy, type SceneHierarchySnapshot } from './hierarchy-projection';
21
26
 
22
27
  export const PROPAGATE_TRANSFORMS_SYSTEM = 'propagateTransforms' as const;
23
28
  export const PROPAGATE_TRANSFORMS_FIXED_SYSTEM = 'propagateTransformsFixed' as const;
24
29
  export const TransformSet = defineSystemSet({ name: 'transform' });
25
30
  export const TransformFixedSet = defineSystemSet({ name: 'transform-fixed' });
26
31
 
27
- interface LocalState {
28
- readonly pos: Float32Array;
29
- readonly quat: Float32Array;
30
- readonly scale: Float32Array;
32
+ /**
33
+ * Optional, test-owned counters for the parent-first executor. The counters
34
+ * are disabled unless a caller brackets a run with begin/end; production
35
+ * propagation therefore pays only one predictable branch per instrumented
36
+ * event. They are deliberately not a second execution state or a public
37
+ * dirty/cache contract.
38
+ */
39
+ export interface TransformPropagationTrace {
40
+ hierarchyRootInvocations: number;
41
+ hierarchyRootCursorReuses: number;
42
+ hierarchyRootCursorAllocations: number;
43
+ hierarchyEntityLookups: number;
44
+ hierarchyRowsEvaluated: number;
45
+ hierarchyEdgesVisited: number;
46
+ hierarchyPublishedRows: number;
47
+ hierarchyPublishedRuns: number;
48
+ hierarchyResidualParentProbes: number;
49
+ flatStructuralRootRows: number;
31
50
  }
32
51
 
33
- interface PropagationCache {
34
- readonly projection: WorldProjection;
35
- readonly hierarchy: SceneHierarchySnapshot;
36
- readonly parentOf: ReadonlyMap<EntityHandle, EntityHandle>;
37
- readonly childrenOf: ReadonlyMap<EntityHandle, readonly EntityHandle[]>;
38
- readonly entities: ReadonlySet<EntityHandle>;
39
- readonly locals: Map<EntityHandle, LocalState>;
40
- readonly derived: Map<EntityHandle, Float32Array>;
41
- result: Result<void, SceneError>;
42
- }
43
-
44
- const CACHE = new WeakMap<World, PropagationCache>();
52
+ let propagationTrace: TransformPropagationTrace | undefined;
53
+ let hierarchyRootCursorAllocationCount = 0;
54
+ let propagationTraceRootCursorAllocationStart = 0;
45
55
 
46
- interface TransformRegistrationLease {
47
- refs: number;
56
+ function createHierarchyRootCursor(): DerivedRangeCursor {
57
+ hierarchyRootCursorAllocationCount += 1;
58
+ return { bindingIndex: -1, row: -1 };
48
59
  }
49
60
 
50
- // A World can be consumed by more than one renderer/view. Keep the schedule
51
- // registration leased per World so one owner disposing cannot remove the
52
- // shared transform system from another owner. This is lifecycle bookkeeping,
53
- // not a second component/system authority; the World schedule remains the
54
- // source of truth.
55
- const REGISTRATION_LEASES = new WeakMap<World, TransformRegistrationLease>();
56
-
57
- function copyState(value: {
58
- readonly pos: ArrayLike<number>;
59
- readonly quat: ArrayLike<number>;
60
- readonly scale: ArrayLike<number>;
61
- }): LocalState {
62
- return {
63
- pos: new Float32Array(value.pos),
64
- quat: new Float32Array(value.quat),
65
- scale: new Float32Array(value.scale),
61
+ export function beginTransformPropagationTrace(): void {
62
+ propagationTraceRootCursorAllocationStart = hierarchyRootCursorAllocationCount;
63
+ propagationTrace = {
64
+ hierarchyRootInvocations: 0,
65
+ hierarchyRootCursorReuses: 0,
66
+ hierarchyRootCursorAllocations: 0,
67
+ hierarchyEntityLookups: 0,
68
+ hierarchyRowsEvaluated: 0,
69
+ hierarchyEdgesVisited: 0,
70
+ hierarchyPublishedRows: 0,
71
+ hierarchyPublishedRuns: 0,
72
+ hierarchyResidualParentProbes: 0,
73
+ flatStructuralRootRows: 0,
66
74
  };
67
75
  }
68
76
 
69
- function sameArray(left: ArrayLike<number>, right: ArrayLike<number>): boolean {
70
- if (left.length !== right.length) return false;
71
- for (let index = 0; index < left.length; index += 1) {
72
- if (left[index] !== right[index]) return false;
77
+ export function endTransformPropagationTrace(): TransformPropagationTrace {
78
+ const trace = propagationTrace;
79
+ propagationTrace = undefined;
80
+ const rootCursorAllocations =
81
+ hierarchyRootCursorAllocationCount - propagationTraceRootCursorAllocationStart;
82
+ propagationTraceRootCursorAllocationStart = hierarchyRootCursorAllocationCount;
83
+ if (trace !== undefined) {
84
+ trace.hierarchyRootCursorAllocations = rootCursorAllocations;
85
+ return trace;
73
86
  }
74
- return true;
87
+ return {
88
+ hierarchyRootInvocations: 0,
89
+ hierarchyRootCursorReuses: 0,
90
+ hierarchyRootCursorAllocations: 0,
91
+ hierarchyEntityLookups: 0,
92
+ hierarchyRowsEvaluated: 0,
93
+ hierarchyEdgesVisited: 0,
94
+ hierarchyPublishedRows: 0,
95
+ hierarchyPublishedRuns: 0,
96
+ hierarchyResidualParentProbes: 0,
97
+ flatStructuralRootRows: 0,
98
+ };
75
99
  }
76
100
 
77
- function sameState(left: LocalState | undefined, right: LocalState): boolean {
78
- return (
79
- left !== undefined &&
80
- sameArray(left.pos, right.pos) &&
81
- sameArray(left.quat, right.quat) &&
82
- sameArray(left.scale, right.scale)
83
- );
101
+ function countPropagation(name: keyof TransformPropagationTrace): void {
102
+ const trace = propagationTrace;
103
+ if (trace !== undefined) trace[name] += 1;
84
104
  }
85
105
 
86
- function compose(state: LocalState, out: Mat4): void {
87
- mat4.compose(out, state.pos, state.quat, state.scale);
106
+ interface Scratch {
107
+ position: Float32Array;
108
+ rotation: Float32Array;
109
+ scale: Float32Array;
110
+ local: Mat4;
111
+ parent: Mat4;
112
+ candidate: Mat4;
113
+ hierarchyStackEntities: EntityHandle[];
114
+ hierarchyStackChildren: number[];
115
+ hierarchyProbeEntities: EntityHandle[];
116
+ hierarchyCurrentCursor: DerivedRangeCursor;
117
+ hierarchyParentCursor: DerivedRangeCursor;
118
+ hierarchyChildCursor: DerivedRangeCursor;
119
+ hierarchyResidualCursor: DerivedRangeCursor;
120
+ hierarchyResidualParentCursor: DerivedRangeCursor;
121
+ hierarchyRootCursor: DerivedRangeCursor;
122
+ hierarchyStates: Uint8Array[];
123
+ hierarchyChanged: Uint8Array[];
124
+ hierarchyBindingTables: number[];
125
+ hierarchyBindingRows: number[];
126
+ flatChanged: Uint8Array[];
127
+ flatBindingTables: number[];
128
+ flatBindingRows: number[];
129
+ flatStructureEpoch: number;
130
+ flatQuery?: FlatQuery;
131
+ hierarchyQuery?: HierarchyQuery;
132
+ transformQuery?: TransformQuery;
133
+ hierarchyWriter?: HierarchyWriter;
134
+ transformWriter?: TransformWriter;
135
+ missingGlobalQuery?: MissingGlobalQuery;
136
+ missingTransformQuery?: MissingTransformQuery;
88
137
  }
89
138
 
90
- function indexChildren(
91
- hierarchy: SceneHierarchySnapshot,
92
- ): ReadonlyMap<EntityHandle, readonly EntityHandle[]> {
93
- const childrenOf = new Map<EntityHandle, EntityHandle[]>();
94
- for (const [child, parent] of hierarchy.parentOf) {
95
- const children = childrenOf.get(parent);
96
- if (children === undefined) childrenOf.set(parent, [child]);
97
- else children.push(child);
98
- }
99
- return childrenOf;
100
- }
139
+ type FlatQuery = Query<readonly [typeof Transform], readonly [typeof GlobalTransform]>;
140
+ type HierarchyQuery = Query<
141
+ readonly [typeof Transform, typeof ChildOf],
142
+ readonly [typeof GlobalTransform]
143
+ >;
144
+ type TransformQuery = Query<readonly [typeof Transform], readonly [typeof GlobalTransform]>;
145
+ type HierarchyWriter = DerivedRangeWriter<
146
+ typeof Transform | typeof ChildOf,
147
+ typeof GlobalTransform
148
+ >;
149
+ type TransformWriter = DerivedRangeWriter<typeof Transform, typeof GlobalTransform>;
150
+ type HierarchyBinding = DerivedColumnBinding<
151
+ typeof Transform | typeof ChildOf,
152
+ typeof GlobalTransform
153
+ >;
154
+ type TransformBinding = DerivedColumnBinding<typeof Transform, typeof GlobalTransform>;
155
+ type MissingGlobalQuery = Query<readonly [], readonly [], readonly []>;
156
+ type MissingTransformQuery = Query<readonly [], readonly [], readonly []>;
101
157
 
102
- function descendants(
103
- childrenOf: ReadonlyMap<EntityHandle, readonly EntityHandle[]>,
104
- roots: ReadonlySet<EntityHandle>,
105
- ): Set<EntityHandle> {
106
- const affected = new Set(roots);
107
- const pending = [...roots];
108
- for (let index = 0; index < pending.length; index += 1) {
109
- const parent = pending[index];
110
- if (parent === undefined) continue;
111
- for (const child of childrenOf.get(parent) ?? []) {
112
- if (affected.has(child)) continue;
113
- affected.add(child);
114
- pending.push(child);
115
- }
116
- }
117
- return affected;
158
+ interface RegistrationLease {
159
+ refs: number;
118
160
  }
119
161
 
120
- function hierarchyError(hierarchy: SceneHierarchySnapshot): Result<void, SceneError> {
121
- const first = hierarchy.diagnostics[0];
122
- if (first === undefined) return ok(undefined);
162
+ const SCRATCH = new WeakMap<World, Scratch>();
163
+ const REGISTRATION_LEASES = new WeakMap<World, RegistrationLease>();
164
+
165
+ function pairError(entity: EntityHandle, expected: string): Result<void, SceneError> {
123
166
  return err(
124
167
  new SceneError({
125
- code: first.code,
126
- expected: first.expected,
127
- hint: first.hint,
128
- detail: first.detail,
168
+ code: 'hierarchy-broken',
169
+ expected,
170
+ hint: 'attach both Transform and GlobalTransform at scene authoring or import time, then retry propagation',
171
+ detail: { entity, parent: entity },
129
172
  }),
130
173
  );
131
174
  }
132
175
 
133
- function buildCache(world: World, projection: WorldProjection): PropagationCache {
134
- const hierarchy = projectHierarchy(world);
135
- const locals = new Map<EntityHandle, LocalState>();
136
- const entities = new Set<EntityHandle>();
137
- const query = world.query({ read: [Transform], optional: [ChildOf] });
138
- if (query.ok) {
139
- for (const row of query.value) {
140
- const transform = row.get(Transform);
141
- if (transform === undefined) continue;
142
- entities.add(row.entity);
143
- locals.set(row.entity, copyState(transform));
144
- }
176
+ function ensureQueries(world: World, scratch: Scratch): Result<void, SceneError> {
177
+ if (
178
+ scratch.flatQuery !== undefined &&
179
+ scratch.hierarchyQuery !== undefined &&
180
+ scratch.transformQuery !== undefined &&
181
+ scratch.hierarchyWriter !== undefined &&
182
+ scratch.transformWriter !== undefined &&
183
+ scratch.missingGlobalQuery !== undefined &&
184
+ scratch.missingTransformQuery !== undefined
185
+ ) {
186
+ return ok(undefined);
145
187
  }
146
- return {
147
- projection,
148
- hierarchy,
149
- parentOf: hierarchy.parentOf,
150
- childrenOf: indexChildren(hierarchy),
151
- entities,
152
- locals,
153
- derived: new Map(),
154
- result: ok(undefined),
188
+ const flatOutput = world.query({
189
+ read: [Transform],
190
+ write: [GlobalTransform],
191
+ without: [ChildOf],
192
+ changed: [Transform],
193
+ });
194
+ const hierarchy = world.query({ read: [Transform, ChildOf], write: [GlobalTransform] });
195
+ const transform = world.query({ read: [Transform], write: [GlobalTransform] });
196
+ const missingGlobal = world.query({ with: [Transform], without: [GlobalTransform] });
197
+ const missingTransform = world.query({ with: [GlobalTransform], without: [Transform] });
198
+ if (
199
+ !flatOutput.ok ||
200
+ !hierarchy.ok ||
201
+ !transform.ok ||
202
+ !missingGlobal.ok ||
203
+ !missingTransform.ok
204
+ ) {
205
+ return pairError(0 as EntityHandle, 'valid Transform and GlobalTransform pair queries');
206
+ }
207
+ const hierarchyWriter = getDerivedWriter(hierarchy.value, GlobalTransform);
208
+ const transformWriter = getDerivedWriter(transform.value, GlobalTransform);
209
+ if (!hierarchyWriter.ok || !transformWriter.ok) {
210
+ return pairError(0 as EntityHandle, 'dense Transform and ChildOf derived bindings');
211
+ }
212
+ scratch.flatQuery = flatOutput.value as FlatQuery;
213
+ scratch.hierarchyQuery = hierarchy.value as HierarchyQuery;
214
+ scratch.transformQuery = transform.value as TransformQuery;
215
+ scratch.hierarchyWriter = hierarchyWriter.value as HierarchyWriter;
216
+ scratch.transformWriter = transformWriter.value as TransformWriter;
217
+ scratch.missingGlobalQuery = missingGlobal.value as MissingGlobalQuery;
218
+ scratch.missingTransformQuery = missingTransform.value as MissingTransformQuery;
219
+ return ok(undefined);
220
+ }
221
+
222
+ function validateTransformPairs(world: World, scratch: Scratch): Result<void, SceneError> {
223
+ const queryResult = ensureQueries(world, scratch);
224
+ if (!queryResult.ok) return queryResult;
225
+ const missingGlobal = scratch.missingGlobalQuery;
226
+ const missingTransform = scratch.missingTransformQuery;
227
+ if (missingGlobal === undefined || missingTransform === undefined) {
228
+ return pairError(0 as EntityHandle, 'valid Transform and GlobalTransform pair queries');
229
+ }
230
+ for (const row of missingGlobal) {
231
+ return pairError(row.entity, 'each Transform entity to carry a GlobalTransform pair');
232
+ }
233
+ for (const row of missingTransform) {
234
+ return pairError(row.entity, 'each GlobalTransform entity to carry a Transform pair');
235
+ }
236
+ return ok(undefined);
237
+ }
238
+
239
+ function scratchFor(world: World): Scratch {
240
+ const existing = SCRATCH.get(world);
241
+ if (existing !== undefined) return existing;
242
+ const created = {
243
+ position: new Float32Array(3),
244
+ rotation: new Float32Array(4),
245
+ scale: new Float32Array(3),
246
+ local: mat4.create(),
247
+ parent: mat4.create(),
248
+ candidate: mat4.create(),
249
+ hierarchyStackEntities: [] as EntityHandle[],
250
+ hierarchyStackChildren: [] as number[],
251
+ hierarchyProbeEntities: [] as EntityHandle[],
252
+ hierarchyCurrentCursor: { bindingIndex: -1, row: -1 },
253
+ hierarchyParentCursor: { bindingIndex: -1, row: -1 },
254
+ hierarchyChildCursor: { bindingIndex: -1, row: -1 },
255
+ hierarchyResidualCursor: { bindingIndex: -1, row: -1 },
256
+ hierarchyResidualParentCursor: { bindingIndex: -1, row: -1 },
257
+ hierarchyRootCursor: createHierarchyRootCursor(),
258
+ hierarchyStates: [],
259
+ hierarchyChanged: [],
260
+ hierarchyBindingTables: [],
261
+ hierarchyBindingRows: [],
262
+ flatChanged: [],
263
+ flatBindingTables: [],
264
+ flatBindingRows: [],
265
+ flatStructureEpoch: -1,
155
266
  };
267
+ SCRATCH.set(world, created);
268
+ return created;
156
269
  }
157
270
 
158
- function deriveEntity(
159
- world: World,
160
- entity: EntityHandle,
161
- cache: PropagationCache,
162
- affected: ReadonlySet<EntityHandle>,
163
- visiting: Set<EntityHandle>,
164
- published: Set<EntityHandle>,
165
- ): Result<void, SceneError> {
166
- if (!affected.has(entity) || cache.derived.has(entity)) return ok(undefined);
167
- if (visiting.has(entity)) return ok(undefined);
168
- visiting.add(entity);
169
- const local = cache.locals.get(entity);
170
- if (local === undefined) {
171
- visiting.delete(entity);
172
- return ok(undefined);
271
+ function composeColumns(
272
+ position: ArrayLike<number>,
273
+ rotation: ArrayLike<number>,
274
+ scale: ArrayLike<number>,
275
+ out: Mat4,
276
+ scratch: Scratch,
277
+ positionStart = 0,
278
+ rotationStart = 0,
279
+ ): void {
280
+ scratch.position[0] = position[positionStart] ?? 0;
281
+ scratch.position[1] = position[positionStart + 1] ?? 0;
282
+ scratch.position[2] = position[positionStart + 2] ?? 0;
283
+ scratch.rotation[0] = rotation[rotationStart] ?? 0;
284
+ scratch.rotation[1] = rotation[rotationStart + 1] ?? 0;
285
+ scratch.rotation[2] = rotation[rotationStart + 2] ?? 0;
286
+ scratch.rotation[3] = rotation[rotationStart + 3] ?? 1;
287
+ scratch.scale[0] = scale[positionStart] ?? 1;
288
+ scratch.scale[1] = scale[positionStart + 1] ?? 1;
289
+ scratch.scale[2] = scale[positionStart + 2] ?? 1;
290
+ mat4.compose(out, scratch.position, scratch.rotation, scratch.scale);
291
+ }
292
+
293
+ function composeFlatColumns(
294
+ positions: ArrayLike<number>,
295
+ rotations: ArrayLike<number>,
296
+ scales: ArrayLike<number>,
297
+ worlds: Float32Array,
298
+ count: number,
299
+ ): void {
300
+ for (let row = 0; row < count; row += 1) {
301
+ const position = row * 3;
302
+ const rotation = row * 4;
303
+ const world = row * 16;
304
+ const x = rotations[rotation] ?? 0;
305
+ const y = rotations[rotation + 1] ?? 0;
306
+ const z = rotations[rotation + 2] ?? 0;
307
+ const w = rotations[rotation + 3] ?? 1;
308
+ const x2 = x + x;
309
+ const y2 = y + y;
310
+ const z2 = z + z;
311
+ const xx = x * x2;
312
+ const xy = x * y2;
313
+ const xz = x * z2;
314
+ const yy = y * y2;
315
+ const yz = y * z2;
316
+ const zz = z * z2;
317
+ const wx = w * x2;
318
+ const wy = w * y2;
319
+ const wz = w * z2;
320
+ const sx = scales[position] ?? 1;
321
+ const sy = scales[position + 1] ?? 1;
322
+ const sz = scales[position + 2] ?? 1;
323
+
324
+ worlds[world] = (1 - (yy + zz)) * sx;
325
+ worlds[world + 1] = (xy + wz) * sx;
326
+ worlds[world + 2] = (xz - wy) * sx;
327
+ worlds[world + 3] = 0;
328
+ worlds[world + 4] = (xy - wz) * sy;
329
+ worlds[world + 5] = (1 - (xx + zz)) * sy;
330
+ worlds[world + 6] = (yz + wx) * sy;
331
+ worlds[world + 7] = 0;
332
+ worlds[world + 8] = (xz + wy) * sz;
333
+ worlds[world + 9] = (yz - wx) * sz;
334
+ worlds[world + 10] = (1 - (xx + yy)) * sz;
335
+ worlds[world + 11] = 0;
336
+ worlds[world + 12] = positions[position] ?? 0;
337
+ worlds[world + 13] = positions[position + 1] ?? 0;
338
+ worlds[world + 14] = positions[position + 2] ?? 0;
339
+ worlds[world + 15] = 1;
173
340
  }
174
- const parent = cache.parentOf.get(entity);
175
- if (parent !== undefined && cache.entities.has(parent)) {
176
- const parentResult = deriveEntity(world, parent, cache, affected, visiting, published);
177
- if (!parentResult.ok) return parentResult;
178
- }
179
- const localWorld = mat4.create();
180
- compose(local, localWorld);
181
- const parentWorld = parent === undefined ? undefined : cache.derived.get(parent);
182
- const resolved = mat4.create();
183
- if (parentWorld === undefined) resolved.set(localWorld);
184
- else mat4.multiply(resolved, parentWorld, localWorld);
185
- const write = setDerivedComponent(world, entity, Transform, { world: resolved });
186
- if (!write.ok) {
187
- visiting.delete(entity);
341
+ }
342
+
343
+ function propagateFlat(world: World, scratch: Scratch): Result<void, SceneError> {
344
+ const query = scratch.flatQuery;
345
+ if (query === undefined)
188
346
  return err(
189
347
  new SceneError({
190
348
  code: 'hierarchy-broken',
191
- expected: 'the derived Transform.world write to succeed',
192
- hint: 'inspect the ECS mutation error before retrying transform propagation',
193
- detail: { entity, parent: entity },
349
+ expected: 'a valid changed Transform write query',
350
+ hint: 'register the scene components before running TransformPropagation',
194
351
  }),
195
352
  );
353
+ const spans = query.spans();
354
+ if (!spans.ok) return pairError(0 as EntityHandle, 'dense numeric Transform spans');
355
+ let bindingIndex = 0;
356
+ try {
357
+ for (const span of spans.value) {
358
+ const local = span.get(Transform);
359
+ const world = span.mut(GlobalTransform).world;
360
+ composeFlatColumns(local.pos, local.quat, local.scale, world, span.length);
361
+ bindingIndex += 1;
362
+ }
363
+ } catch (cause) {
364
+ const error = cause as EcsError;
365
+ return err(derivedWriteError(error, bindingIndex));
196
366
  }
197
- cache.derived.set(entity, resolved);
198
- published.add(entity);
199
- visiting.delete(entity);
367
+
368
+ // A structural relation change can turn an unchanged Transform row into a
369
+ // flat root (most importantly ChildOf removal). The changed Transform query
370
+ // above cannot observe that transition, so once per structure epoch scan the
371
+ // already-bound numeric Transform rows and publish only differing roots.
372
+ const transformWriter = scratch.transformWriter;
373
+ if (transformWriter === undefined) {
374
+ return pairError(0 as EntityHandle, 'dense Transform and GlobalTransform derived bindings');
375
+ }
376
+ const transformBindings = transformWriter.bindings as readonly TransformBinding[];
377
+ const structureEpoch = world[worldInternal].getStructureEpoch();
378
+ if (scratch.flatStructureEpoch === structureEpoch) return ok(undefined);
379
+
380
+ ensureFlatBuffers(scratch, transformBindings);
381
+ resetFlatBuffers(scratch);
382
+ for (let bindingIndex = 0; bindingIndex < transformBindings.length; bindingIndex += 1) {
383
+ const binding = transformBindings[bindingIndex];
384
+ const changed = scratch.flatChanged[bindingIndex];
385
+ if (binding === undefined || changed === undefined) continue;
386
+ for (let row = 0; row < binding.rowCapacity; row += 1) {
387
+ const entity = (binding.entities[row] ?? 0) as EntityHandle;
388
+ const parentRaw = world[worldInternal].getFieldValue(entity, ChildOf, 'parent');
389
+ if (parentRaw !== undefined && parentRaw !== ENTITY_NULL_RAW) continue;
390
+ countPropagation('flatStructuralRootRows');
391
+ composeBindingRow(binding, row, undefined, 0, scratch, changed);
392
+ }
393
+ }
394
+ for (let bindingIndex = 0; bindingIndex < transformBindings.length; bindingIndex += 1) {
395
+ const changed = scratch.flatChanged[bindingIndex];
396
+ if (changed === undefined) continue;
397
+ const published = transformWriter.publishChangedRows(bindingIndex, changed);
398
+ if (!published.ok) return err(derivedWriteError(published.error, bindingIndex));
399
+ }
400
+ scratch.flatStructureEpoch = structureEpoch;
200
401
  return ok(undefined);
201
402
  }
202
403
 
203
- function drainPublishedChanges(
204
- cache: PropagationCache,
205
- published: ReadonlySet<EntityHandle>,
404
+ interface TransformColumnShape {
405
+ readonly pos: ArrayLike<number>;
406
+ readonly quat: ArrayLike<number>;
407
+ readonly scale: ArrayLike<number>;
408
+ }
409
+
410
+ interface OutputColumnShape {
411
+ readonly world: Float32Array;
412
+ }
413
+
414
+ interface HierarchyColumnShape extends TransformColumnShape {
415
+ readonly parent: ArrayLike<number>;
416
+ }
417
+
418
+ function transformColumns(binding: TransformBinding | HierarchyBinding): TransformColumnShape {
419
+ return binding.read as unknown as TransformColumnShape;
420
+ }
421
+
422
+ function hierarchyColumns(binding: HierarchyBinding): HierarchyColumnShape {
423
+ return binding.read as unknown as HierarchyColumnShape;
424
+ }
425
+
426
+ function worldColumn(binding: TransformBinding | HierarchyBinding): Float32Array {
427
+ return (binding.write as unknown as OutputColumnShape).world;
428
+ }
429
+
430
+ function hierarchyError(
431
+ code: 'hierarchy-broken' | 'hierarchy-cycle',
432
+ entity: EntityHandle,
433
+ parent: EntityHandle,
434
+ expected: string,
435
+ hint: string,
436
+ ): SceneError {
437
+ return new SceneError({ code, expected, hint, detail: { entity, parent } });
438
+ }
439
+
440
+ function writeCandidate(
441
+ binding: TransformBinding | HierarchyBinding,
442
+ row: number,
443
+ candidate: Mat4,
444
+ changed: Uint8Array,
445
+ ): void {
446
+ const worlds = worldColumn(binding);
447
+ const base = row * 16;
448
+ for (let index = 0; index < 16; index += 1) {
449
+ if (worlds[base + index] !== candidate[index]) {
450
+ worlds.set(candidate, base);
451
+ changed[row] = 1;
452
+ return;
453
+ }
454
+ }
455
+ }
456
+
457
+ function composeBindingRow(
458
+ binding: TransformBinding | HierarchyBinding,
459
+ row: number,
460
+ parentBinding: TransformBinding | undefined,
461
+ parentRow: number,
462
+ scratch: Scratch,
463
+ changed: Uint8Array,
464
+ ): void {
465
+ // Hierarchy bindings carry the parent column; flat structural-root repair
466
+ // uses the same numeric kernel but is intentionally counted separately.
467
+ if ('parent' in (binding.read as object)) countPropagation('hierarchyRowsEvaluated');
468
+ const local = transformColumns(binding);
469
+ const offset = row * 3;
470
+ composeColumns(local.pos, local.quat, local.scale, scratch.local, scratch, offset, row * 4);
471
+ if (parentBinding === undefined) {
472
+ scratch.candidate.set(scratch.local);
473
+ } else {
474
+ const parentWorld = worldColumn(parentBinding);
475
+ const parentOffset = parentRow * 16;
476
+ for (let index = 0; index < 16; index += 1) {
477
+ scratch.parent[index] = parentWorld[parentOffset + index] ?? 0;
478
+ }
479
+ // Column-major composition is intentionally identical to the previous
480
+ // matrix path: Global = Parent * Local, with no TRS decomposition.
481
+ mat4.multiply(scratch.candidate, scratch.parent, scratch.local);
482
+ }
483
+ writeCandidate(binding, row, scratch.candidate, changed);
484
+ }
485
+
486
+ function ensureHierarchyBuffers(scratch: Scratch, bindings: readonly HierarchyBinding[]): void {
487
+ let same = scratch.hierarchyBindingTables.length === bindings.length;
488
+ if (same) {
489
+ for (let index = 0; index < bindings.length; index += 1) {
490
+ const binding = bindings[index];
491
+ if (
492
+ binding === undefined ||
493
+ scratch.hierarchyBindingTables[index] !== binding.tableId ||
494
+ scratch.hierarchyBindingRows[index] !== binding.rowCapacity
495
+ ) {
496
+ same = false;
497
+ break;
498
+ }
499
+ }
500
+ }
501
+ if (same) return;
502
+ scratch.hierarchyBindingTables = bindings.map((binding) => binding.tableId);
503
+ scratch.hierarchyBindingRows = bindings.map((binding) => binding.rowCapacity);
504
+ scratch.hierarchyStates = bindings.map((binding) => new Uint8Array(binding.rowCapacity));
505
+ scratch.hierarchyChanged = bindings.map((binding) => new Uint8Array(binding.rowCapacity));
506
+ }
507
+
508
+ function resetHierarchyBuffers(scratch: Scratch): void {
509
+ for (let index = 0; index < scratch.hierarchyStates.length; index += 1) {
510
+ scratch.hierarchyStates[index]?.fill(0);
511
+ scratch.hierarchyChanged[index]?.fill(0);
512
+ }
513
+ }
514
+
515
+ function ensureFlatBuffers(scratch: Scratch, bindings: readonly TransformBinding[]): void {
516
+ let same = scratch.flatBindingTables.length === bindings.length;
517
+ if (same) {
518
+ for (let index = 0; index < bindings.length; index += 1) {
519
+ const binding = bindings[index];
520
+ if (
521
+ binding === undefined ||
522
+ scratch.flatBindingTables[index] !== binding.tableId ||
523
+ scratch.flatBindingRows[index] !== binding.rowCapacity
524
+ ) {
525
+ same = false;
526
+ break;
527
+ }
528
+ }
529
+ }
530
+ if (same) return;
531
+ scratch.flatBindingTables = bindings.map((binding) => binding.tableId);
532
+ scratch.flatBindingRows = bindings.map((binding) => binding.rowCapacity);
533
+ scratch.flatChanged = bindings.map((binding) => new Uint8Array(binding.rowCapacity));
534
+ }
535
+
536
+ function resetFlatBuffers(scratch: Scratch): void {
537
+ for (const changed of scratch.flatChanged) changed.fill(0);
538
+ }
539
+
540
+ function derivedWriteError(cause: EcsError, bindingIndex: number): SceneError {
541
+ return new SceneError({
542
+ code: 'hierarchy-broken',
543
+ expected: 'derived GlobalTransform range publication to succeed',
544
+ hint: cause.hint ?? 'retry propagation on a healthy World',
545
+ detail: {
546
+ kind: 'derived-write',
547
+ entity: 0 as EntityHandle,
548
+ parent: 0 as EntityHandle,
549
+ bindingIndex,
550
+ base: 0,
551
+ start: 0,
552
+ count: 0,
553
+ cause,
554
+ },
555
+ });
556
+ }
557
+
558
+ function findHierarchyLocation(
559
+ writer: HierarchyWriter,
560
+ bindings: readonly HierarchyBinding[],
561
+ entity: EntityHandle,
562
+ cursor: DerivedRangeCursor,
563
+ ): HierarchyBinding | undefined {
564
+ countPropagation('hierarchyEntityLookups');
565
+ if (!writer.locateEntity(entity, cursor)) return undefined;
566
+ return bindings[cursor.bindingIndex];
567
+ }
568
+
569
+ function findTransformLocation(
570
+ writer: TransformWriter,
571
+ bindings: readonly TransformBinding[],
572
+ entity: EntityHandle,
573
+ cursor: DerivedRangeCursor,
574
+ ): TransformBinding | undefined {
575
+ countPropagation('hierarchyEntityLookups');
576
+ if (!writer.locateEntity(entity, cursor)) return undefined;
577
+ return bindings[cursor.bindingIndex];
578
+ }
579
+
580
+ function countPublishedRows(changed: Uint8Array): void {
581
+ const trace = propagationTrace;
582
+ if (trace === undefined) return;
583
+ let runOpen = false;
584
+ for (let row = 0; row < changed.length; row += 1) {
585
+ if ((changed[row] ?? 0) !== 0) {
586
+ trace.hierarchyPublishedRows += 1;
587
+ if (!runOpen) {
588
+ trace.hierarchyPublishedRuns += 1;
589
+ runOpen = true;
590
+ }
591
+ } else {
592
+ runOpen = false;
593
+ }
594
+ }
595
+ }
596
+
597
+ function noteHierarchyError(current: SceneError | undefined, next: SceneError): SceneError {
598
+ if (current === undefined) return next;
599
+ const currentEntity = Number(current.detail?.entity ?? Number.MAX_SAFE_INTEGER);
600
+ const nextEntity = Number(next.detail?.entity ?? Number.MAX_SAFE_INTEGER);
601
+ return nextEntity < currentEntity ||
602
+ (nextEntity === currentEntity && next.code.localeCompare(current.code) < 0)
603
+ ? next
604
+ : current;
605
+ }
606
+
607
+ function composeLocalHierarchyEntity(
608
+ writer: HierarchyWriter,
609
+ bindings: readonly HierarchyBinding[],
610
+ entity: EntityHandle,
611
+ scratch: Scratch,
612
+ changed: Uint8Array[],
206
613
  ): boolean {
207
- const drained = cache.projection.poll();
208
- if (drained.status === 'rebuild') return true;
209
- return drained.changes.every(
210
- (change) =>
211
- change.kind === 'derived-component-changed' &&
212
- change.component === Transform &&
213
- published.has(change.entity),
614
+ const cursor = scratch.hierarchyCurrentCursor;
615
+ const binding = findHierarchyLocation(writer, bindings, entity, cursor);
616
+ if (binding === undefined) return false;
617
+ composeBindingRow(
618
+ binding,
619
+ cursor.row,
620
+ undefined,
621
+ 0,
622
+ scratch,
623
+ changed[cursor.bindingIndex] as Uint8Array,
624
+ );
625
+ const states = scratch.hierarchyStates[cursor.bindingIndex];
626
+ if (states !== undefined) states[cursor.row] = 3;
627
+ return true;
628
+ }
629
+
630
+ function handleActiveCycle(
631
+ repeated: EntityHandle,
632
+ stackEntities: EntityHandle[],
633
+ stackChildren: number[],
634
+ writer: HierarchyWriter,
635
+ bindings: readonly HierarchyBinding[],
636
+ scratch: Scratch,
637
+ changed: Uint8Array[],
638
+ report: (error: SceneError) => void,
639
+ ): void {
640
+ let cycleStart = -1;
641
+ for (let index = 0; index < stackEntities.length; index += 1) {
642
+ if (stackEntities[index] === repeated) {
643
+ cycleStart = index;
644
+ break;
645
+ }
646
+ }
647
+ if (cycleStart < 0) return;
648
+ const repeatedCursor: DerivedRangeCursor = { bindingIndex: -1, row: -1 };
649
+ const repeatedBinding = findHierarchyLocation(writer, bindings, repeated, repeatedCursor);
650
+ const repeatedParent =
651
+ repeatedBinding === undefined
652
+ ? repeated
653
+ : ((hierarchyColumns(repeatedBinding).parent[repeatedCursor.row] ??
654
+ ENTITY_NULL_RAW) as number as EntityHandle);
655
+ report(
656
+ hierarchyError(
657
+ 'hierarchy-cycle',
658
+ repeated,
659
+ repeatedParent,
660
+ 'a parent-before-child Transform hierarchy',
661
+ 'repair the ChildOf cycle and retry TransformPropagation',
662
+ ),
214
663
  );
664
+ for (let index = cycleStart; index < stackEntities.length; index += 1) {
665
+ const cycleEntity = stackEntities[index];
666
+ if (cycleEntity === undefined) continue;
667
+ composeLocalHierarchyEntity(writer, bindings, cycleEntity, scratch, changed);
668
+ stackChildren[index] = 0;
669
+ }
215
670
  }
216
671
 
217
- export function propagateTransforms(
672
+ function walkChildren(
218
673
  world: World,
219
- _hierarchy?: SceneHierarchySnapshot,
220
- ): Result<void, SceneError> {
221
- let cache = CACHE.get(world);
222
- if (cache === undefined) {
223
- cache = buildCache(world, createWorldProjection(world, { components: [Transform, ChildOf] }));
224
- CACHE.set(world, cache);
225
- const initial = new Set(cache.entities);
226
- const published = new Set<EntityHandle>();
227
- for (const entity of initial) {
228
- const result = deriveEntity(world, entity, cache, initial, new Set(), published);
229
- if (!result.ok) return result;
674
+ root: EntityHandle,
675
+ writer: HierarchyWriter,
676
+ bindings: readonly HierarchyBinding[],
677
+ transformWriter: TransformWriter,
678
+ transformBindings: readonly TransformBinding[],
679
+ scratch: Scratch,
680
+ report: (error: SceneError) => void,
681
+ allowCompletedRoot: boolean,
682
+ ): void {
683
+ countPropagation('hierarchyRootInvocations');
684
+ const stackEntities = scratch.hierarchyStackEntities;
685
+ const stackChildren = scratch.hierarchyStackChildren;
686
+ stackEntities.length = 0;
687
+ stackChildren.length = 0;
688
+
689
+ if (allowCompletedRoot) {
690
+ // Residual recovery may be entered once for each member of a malformed
691
+ // parent path. State 4 means a previous fallback walk already expanded
692
+ // this root and all reachable Children edges, so do not repeat that
693
+ // subtree for every cycle member.
694
+ const rootCursor = scratch.hierarchyRootCursor;
695
+ const rootBinding = findHierarchyLocation(writer, bindings, root, rootCursor);
696
+ if (rootBinding !== undefined) {
697
+ const state = scratch.hierarchyStates[rootCursor.bindingIndex]?.[rootCursor.row] ?? 0;
698
+ if (state === 4) return;
230
699
  }
231
- if (!drainPublishedChanges(cache, published)) {
232
- CACHE.delete(world);
233
- return propagateTransforms(world);
700
+ } else {
701
+ // This cursor is scratch-owned and reused for every root. A root walk is
702
+ // a hot invocation; allocating a cursor here would scale object churn with
703
+ // the number of roots even after all bindings have warmed.
704
+ const rootCursor = scratch.hierarchyRootCursor;
705
+ countPropagation('hierarchyRootCursorReuses');
706
+ const rootBinding = findHierarchyLocation(writer, bindings, root, rootCursor);
707
+ if (rootBinding !== undefined) {
708
+ const state = scratch.hierarchyStates[rootCursor.bindingIndex]?.[rootCursor.row] ?? 0;
709
+ if (state !== 0) return;
234
710
  }
235
- cache.result = hierarchyError(cache.hierarchy);
236
- return cache.result;
237
- }
238
-
239
- const evidence = cache.projection.poll();
240
- if (evidence.status === 'rebuild') {
241
- cache = buildCache(world, cache.projection);
242
- CACHE.set(world, cache);
243
- const all = new Set(cache.entities);
244
- const published = new Set<EntityHandle>();
245
- for (const entity of all) {
246
- const result = deriveEntity(world, entity, cache, all, new Set(), published);
247
- if (!result.ok) return result;
711
+ }
712
+ stackEntities.push(root);
713
+ stackChildren.push(-1);
714
+
715
+ const currentCursor = scratch.hierarchyCurrentCursor;
716
+ const parentCursor = scratch.hierarchyParentCursor;
717
+ const childCursor = scratch.hierarchyChildCursor;
718
+ while (stackEntities.length > 0) {
719
+ const top = stackEntities.length - 1;
720
+ const current = stackEntities[top];
721
+ if (current === undefined) {
722
+ stackEntities.pop();
723
+ stackChildren.pop();
724
+ continue;
248
725
  }
249
- if (!drainPublishedChanges(cache, published)) {
250
- CACHE.delete(world);
251
- return propagateTransforms(world);
726
+ const hierarchyBinding = findHierarchyLocation(writer, bindings, current, currentCursor);
727
+ const nextChild = stackChildren[top] ?? -1;
728
+ if (nextChild < 0) {
729
+ if (hierarchyBinding !== undefined) {
730
+ const state = scratch.hierarchyStates[currentCursor.bindingIndex]?.[currentCursor.row] ?? 0;
731
+ if (state === 0) {
732
+ const states = scratch.hierarchyStates[currentCursor.bindingIndex];
733
+ if (states !== undefined) states[currentCursor.row] = 1;
734
+ const parentRaw = (hierarchyColumns(hierarchyBinding).parent[currentCursor.row] ??
735
+ ENTITY_NULL_RAW) as number;
736
+ let completed = false;
737
+ if (parentRaw === ENTITY_NULL_RAW) {
738
+ composeBindingRow(
739
+ hierarchyBinding,
740
+ currentCursor.row,
741
+ undefined,
742
+ 0,
743
+ scratch,
744
+ scratch.hierarchyChanged[currentCursor.bindingIndex] as Uint8Array,
745
+ );
746
+ completed = true;
747
+ } else {
748
+ const parent = parentRaw as EntityHandle;
749
+ const parentBinding = findTransformLocation(
750
+ transformWriter,
751
+ transformBindings,
752
+ parent,
753
+ parentCursor,
754
+ );
755
+ const parentHierarchy = findHierarchyLocation(writer, bindings, parent, childCursor);
756
+ if (parentBinding === undefined) {
757
+ report(
758
+ hierarchyError(
759
+ 'hierarchy-broken',
760
+ current,
761
+ parent,
762
+ 'each ChildOf parent to carry Transform and GlobalTransform',
763
+ 'repair the missing parent pair before retrying propagation',
764
+ ),
765
+ );
766
+ composeBindingRow(
767
+ hierarchyBinding,
768
+ currentCursor.row,
769
+ undefined,
770
+ 0,
771
+ scratch,
772
+ scratch.hierarchyChanged[currentCursor.bindingIndex] as Uint8Array,
773
+ );
774
+ completed = true;
775
+ } else if (parentHierarchy !== undefined) {
776
+ const parentState =
777
+ scratch.hierarchyStates[childCursor.bindingIndex]?.[childCursor.row] ?? 0;
778
+ if (parentState === 1) {
779
+ handleActiveCycle(
780
+ parent,
781
+ stackEntities,
782
+ stackChildren,
783
+ writer,
784
+ bindings,
785
+ scratch,
786
+ scratch.hierarchyChanged,
787
+ report,
788
+ );
789
+ completed = true;
790
+ } else if (parentState === 0) {
791
+ report(
792
+ hierarchyError(
793
+ 'hierarchy-broken',
794
+ current,
795
+ parent,
796
+ 'Children to enumerate every parent-before-child edge',
797
+ 'repair the Children mirror and retry TransformPropagation',
798
+ ),
799
+ );
800
+ composeBindingRow(
801
+ hierarchyBinding,
802
+ currentCursor.row,
803
+ undefined,
804
+ 0,
805
+ scratch,
806
+ scratch.hierarchyChanged[currentCursor.bindingIndex] as Uint8Array,
807
+ );
808
+ completed = true;
809
+ } else {
810
+ composeBindingRow(
811
+ hierarchyBinding,
812
+ currentCursor.row,
813
+ parentBinding,
814
+ parentCursor.row,
815
+ scratch,
816
+ scratch.hierarchyChanged[currentCursor.bindingIndex] as Uint8Array,
817
+ );
818
+ completed = true;
819
+ }
820
+ } else {
821
+ composeBindingRow(
822
+ hierarchyBinding,
823
+ currentCursor.row,
824
+ parentBinding,
825
+ parentCursor.row,
826
+ scratch,
827
+ scratch.hierarchyChanged[currentCursor.bindingIndex] as Uint8Array,
828
+ );
829
+ completed = true;
830
+ }
831
+ }
832
+ if (completed && states !== undefined && states[currentCursor.row] === 1) {
833
+ states[currentCursor.row] = 2;
834
+ }
835
+ }
836
+ }
837
+ stackChildren[top] = 0;
838
+ continue;
252
839
  }
253
- cache.result = hierarchyError(cache.hierarchy);
254
- return cache.result;
255
- }
256
- if (evidence.changes.length === 0) return cache.result;
257
-
258
- const transformSeeds = new Set<EntityHandle>();
259
- let rebuild = false;
260
- for (const change of evidence.changes) {
261
- if (
262
- change.kind === 'entity-removed' ||
263
- change.kind === 'component-added' ||
264
- change.kind === 'component-removed'
265
- ) {
266
- rebuild = true;
267
- break;
840
+
841
+ const childrenLength = world[worldInternal].getArrayLength(current, Children, 'entities') ?? 0;
842
+ if (nextChild >= childrenLength) {
843
+ stackEntities.pop();
844
+ stackChildren.pop();
845
+ if (allowCompletedRoot) {
846
+ const completedCursor = scratch.hierarchyResidualCursor;
847
+ const completedBinding = findHierarchyLocation(writer, bindings, current, completedCursor);
848
+ const completedStates =
849
+ completedBinding === undefined
850
+ ? undefined
851
+ : scratch.hierarchyStates[completedCursor.bindingIndex];
852
+ if (completedStates !== undefined && completedStates[completedCursor.row] === 3) {
853
+ completedStates[completedCursor.row] = 4;
854
+ }
855
+ }
856
+ continue;
268
857
  }
269
- if (change.kind !== 'component-changed') continue;
270
- if (change.component === ChildOf) {
271
- rebuild = true;
272
- break;
858
+ stackChildren[top] = nextChild + 1;
859
+ countPropagation('hierarchyEdgesVisited');
860
+ const childRaw = world[worldInternal].getArrayElement(current, Children, 'entities', nextChild);
861
+ if (childRaw === undefined || childRaw === ENTITY_NULL_RAW) {
862
+ report(
863
+ hierarchyError(
864
+ 'hierarchy-broken',
865
+ current,
866
+ current,
867
+ 'Children.entities to contain live child handles',
868
+ 'repair the Children mirror and retry TransformPropagation',
869
+ ),
870
+ );
871
+ continue;
872
+ }
873
+ const child = childRaw as EntityHandle;
874
+ const childBinding = findHierarchyLocation(writer, bindings, child, childCursor);
875
+ if (childBinding === undefined) {
876
+ report(
877
+ hierarchyError(
878
+ 'hierarchy-broken',
879
+ child,
880
+ current,
881
+ 'each Children entry to carry Transform, GlobalTransform, and ChildOf',
882
+ 'repair the child component pair before retrying propagation',
883
+ ),
884
+ );
885
+ continue;
886
+ }
887
+ const childParent = (hierarchyColumns(childBinding).parent[childCursor.row] ??
888
+ ENTITY_NULL_RAW) as number;
889
+ const childState = scratch.hierarchyStates[childCursor.bindingIndex]?.[childCursor.row] ?? 0;
890
+ if (childParent !== (current as number)) {
891
+ // Leave an unvisited child for the residual row-state pass. That pass
892
+ // can classify the complete path (including a rootless cycle) without
893
+ // emitting a premature mirror error that would hide the cycle cause.
894
+ if (childState === 0) continue;
895
+ report(
896
+ hierarchyError(
897
+ 'hierarchy-broken',
898
+ child,
899
+ current,
900
+ 'Children and ChildOf to describe the same parent',
901
+ 'repair the relationship mirror and retry TransformPropagation',
902
+ ),
903
+ );
904
+ continue;
273
905
  }
274
- // Derived Transform publications are deliberately not dirty seeds. Only
275
- // authored component-changed records can invalidate local TRS state.
276
- if (change.component === Transform) transformSeeds.add(change.entity);
277
- }
278
- if (rebuild) {
279
- CACHE.delete(world);
280
- return propagateTransforms(world);
281
- }
282
-
283
- // A World can record several authored writes before this pass. Read each
284
- // seed once so the final local state decides whether propagation is needed.
285
- const changed = new Set<EntityHandle>();
286
- for (const entity of transformSeeds) {
287
- const current = world.get(entity, Transform);
288
- if (!current.ok) {
289
- CACHE.delete(world);
290
- return propagateTransforms(world);
906
+ if (childState === 1) {
907
+ handleActiveCycle(
908
+ child,
909
+ stackEntities,
910
+ stackChildren,
911
+ writer,
912
+ bindings,
913
+ scratch,
914
+ scratch.hierarchyChanged,
915
+ report,
916
+ );
917
+ } else if (childState === 0) {
918
+ stackEntities.push(child);
919
+ stackChildren.push(-1);
291
920
  }
292
- const next = copyState(current.value);
293
- if (!sameState(cache.locals.get(entity), next)) changed.add(entity);
294
- cache.locals.set(entity, next);
295
921
  }
296
- if (changed.size === 0) return cache.result;
922
+ }
297
923
 
298
- const affected = descendants(cache.childrenOf, changed);
299
- const published = new Set<EntityHandle>();
300
- for (const entity of affected) cache.derived.delete(entity);
301
- for (const entity of affected) {
302
- const result = deriveEntity(world, entity, cache, affected, new Set(), published);
303
- if (!result.ok) return result;
924
+ function fallbackResidualPath(
925
+ world: World,
926
+ path: readonly EntityHandle[],
927
+ writer: HierarchyWriter,
928
+ bindings: readonly HierarchyBinding[],
929
+ transformWriter: TransformWriter,
930
+ transformBindings: readonly TransformBinding[],
931
+ scratch: Scratch,
932
+ report: (error: SceneError) => void,
933
+ ): void {
934
+ // Root traversal owns the normal path. A residual path is necessarily a
935
+ // malformed Children projection (or a rootless cycle); cut the complete
936
+ // path to local roots in one pass so no stale GlobalTransform survives the
937
+ // diagnostic. Keeping the path as an explicit work list also makes this
938
+ // recovery O(path length), rather than repeatedly searching parent chains.
939
+ for (const entity of path) {
940
+ composeLocalHierarchyEntity(writer, bindings, entity, scratch, scratch.hierarchyChanged);
304
941
  }
305
- if (!drainPublishedChanges(cache, published)) {
306
- CACHE.delete(world);
307
- return propagateTransforms(world);
942
+ for (const entity of path) {
943
+ walkChildren(
944
+ world,
945
+ entity,
946
+ writer,
947
+ bindings,
948
+ transformWriter,
949
+ transformBindings,
950
+ scratch,
951
+ report,
952
+ true,
953
+ );
308
954
  }
309
- cache.result = hierarchyError(cache.hierarchy);
310
- return cache.result;
955
+ }
956
+
957
+ function resolveResidualPath(
958
+ world: World,
959
+ entity: EntityHandle,
960
+ writer: HierarchyWriter,
961
+ bindings: readonly HierarchyBinding[],
962
+ transformWriter: TransformWriter,
963
+ transformBindings: readonly TransformBinding[],
964
+ scratch: Scratch,
965
+ report: (error: SceneError) => void,
966
+ ): void {
967
+ const path = scratch.hierarchyProbeEntities;
968
+ path.length = 0;
969
+ const cursor = scratch.hierarchyResidualCursor;
970
+ const parentTransformCursor = scratch.hierarchyParentCursor;
971
+ const parentHierarchyCursor = scratch.hierarchyResidualParentCursor;
972
+ let current = entity;
973
+ while (true) {
974
+ countPropagation('hierarchyResidualParentProbes');
975
+ const binding = findHierarchyLocation(writer, bindings, current, cursor);
976
+ if (binding === undefined) break;
977
+ const states = scratch.hierarchyStates[cursor.bindingIndex];
978
+ const state = states?.[cursor.row] ?? 0;
979
+ if (state !== 0) {
980
+ const columns = hierarchyColumns(binding);
981
+ const parentRaw = (columns.parent[cursor.row] ?? ENTITY_NULL_RAW) as number;
982
+ report(
983
+ hierarchyError(
984
+ 'hierarchy-cycle',
985
+ current,
986
+ parentRaw === ENTITY_NULL_RAW ? current : (parentRaw as EntityHandle),
987
+ 'a parent-before-child Transform hierarchy',
988
+ 'repair the ChildOf cycle and retry TransformPropagation',
989
+ ),
990
+ );
991
+ fallbackResidualPath(
992
+ world,
993
+ path,
994
+ writer,
995
+ bindings,
996
+ transformWriter,
997
+ transformBindings,
998
+ scratch,
999
+ report,
1000
+ );
1001
+ return;
1002
+ }
1003
+
1004
+ if (states !== undefined) states[cursor.row] = 1;
1005
+ path.push(current);
1006
+ const parentRaw = (hierarchyColumns(binding).parent[cursor.row] ?? ENTITY_NULL_RAW) as number;
1007
+ if (parentRaw === ENTITY_NULL_RAW) {
1008
+ // Every null-parent row is enumerated as a root above. Reaching one
1009
+ // here means the materialized Children graph failed to expose the
1010
+ // parent-first edge; preserve the explicit malformed-graph error.
1011
+ report(
1012
+ hierarchyError(
1013
+ 'hierarchy-broken',
1014
+ current,
1015
+ current,
1016
+ 'Children to enumerate every parent-before-child edge',
1017
+ 'repair the Children mirror and retry TransformPropagation',
1018
+ ),
1019
+ );
1020
+ fallbackResidualPath(
1021
+ world,
1022
+ path,
1023
+ writer,
1024
+ bindings,
1025
+ transformWriter,
1026
+ transformBindings,
1027
+ scratch,
1028
+ report,
1029
+ );
1030
+ return;
1031
+ }
1032
+
1033
+ const parent = parentRaw as EntityHandle;
1034
+ const parentTransform = findTransformLocation(
1035
+ transformWriter,
1036
+ transformBindings,
1037
+ parent,
1038
+ parentTransformCursor,
1039
+ );
1040
+ const parentHierarchy = findHierarchyLocation(writer, bindings, parent, parentHierarchyCursor);
1041
+ if (parentHierarchy === undefined) {
1042
+ report(
1043
+ hierarchyError(
1044
+ 'hierarchy-broken',
1045
+ current,
1046
+ parent,
1047
+ parentTransform === undefined
1048
+ ? 'each ChildOf parent to carry Transform and GlobalTransform'
1049
+ : 'Children to enumerate every parent-before-child edge',
1050
+ parentTransform === undefined
1051
+ ? 'repair the missing parent pair before retrying propagation'
1052
+ : 'repair the Children mirror and retry TransformPropagation',
1053
+ ),
1054
+ );
1055
+ fallbackResidualPath(
1056
+ world,
1057
+ path,
1058
+ writer,
1059
+ bindings,
1060
+ transformWriter,
1061
+ transformBindings,
1062
+ scratch,
1063
+ report,
1064
+ );
1065
+ return;
1066
+ }
1067
+
1068
+ const parentState =
1069
+ scratch.hierarchyStates[parentHierarchyCursor.bindingIndex]?.[parentHierarchyCursor.row] ?? 0;
1070
+ if (parentState === 0) {
1071
+ current = parent;
1072
+ continue;
1073
+ }
1074
+ if (parentState === 1) {
1075
+ report(
1076
+ hierarchyError(
1077
+ 'hierarchy-cycle',
1078
+ parent,
1079
+ current,
1080
+ 'a parent-before-child Transform hierarchy',
1081
+ 'repair the ChildOf cycle and retry TransformPropagation',
1082
+ ),
1083
+ );
1084
+ } else {
1085
+ report(
1086
+ hierarchyError(
1087
+ 'hierarchy-broken',
1088
+ current,
1089
+ parent,
1090
+ 'Children to enumerate every parent-before-child edge',
1091
+ 'repair the Children mirror and retry TransformPropagation',
1092
+ ),
1093
+ );
1094
+ }
1095
+ fallbackResidualPath(
1096
+ world,
1097
+ path,
1098
+ writer,
1099
+ bindings,
1100
+ transformWriter,
1101
+ transformBindings,
1102
+ scratch,
1103
+ report,
1104
+ );
1105
+ return;
1106
+ }
1107
+ }
1108
+
1109
+ function propagateHierarchy(world: World, scratch: Scratch): Result<void, SceneError> {
1110
+ const hierarchyWriter = scratch.hierarchyWriter;
1111
+ const transformWriter = scratch.transformWriter;
1112
+ if (hierarchyWriter === undefined || transformWriter === undefined) {
1113
+ return err(
1114
+ new SceneError({
1115
+ code: 'hierarchy-broken',
1116
+ expected: 'paired Transform and GlobalTransform derived bindings',
1117
+ hint: 'register the scene components before running TransformPropagation',
1118
+ }),
1119
+ );
1120
+ }
1121
+ const hierarchyBindings = hierarchyWriter.bindings as readonly HierarchyBinding[];
1122
+ const transformBindings = transformWriter.bindings as readonly TransformBinding[];
1123
+ if (hierarchyBindings.length === 0) {
1124
+ // Keep the flat-only lane independent: it must not allocate hierarchy
1125
+ // markers or scan every Transform row merely to prove that no ChildOf
1126
+ // archetype exists.
1127
+ scratch.hierarchyBindingTables.length = 0;
1128
+ scratch.hierarchyBindingRows.length = 0;
1129
+ scratch.hierarchyStates.length = 0;
1130
+ scratch.hierarchyChanged.length = 0;
1131
+ return ok(undefined);
1132
+ }
1133
+ ensureHierarchyBuffers(scratch, hierarchyBindings);
1134
+ resetHierarchyBuffers(scratch);
1135
+ let firstError: SceneError | undefined;
1136
+ const report = (error: SceneError): void => {
1137
+ firstError = noteHierarchyError(firstError, error);
1138
+ };
1139
+
1140
+ // Start only at actual roots (flat Transform rows and null-parent
1141
+ // ChildOf rows). Descendants are discovered exclusively through the ECS
1142
+ // materialized Children lists, so every normal frame is parent-first and
1143
+ // linear in rows plus relationship edges.
1144
+ for (const binding of transformBindings) {
1145
+ const entities = binding.entities;
1146
+ for (let row = 0; row < binding.rowCapacity; row += 1) {
1147
+ const entity = (entities[row] ?? 0) as EntityHandle;
1148
+ const parentRaw = world[worldInternal].getFieldValue(entity, ChildOf, 'parent');
1149
+ if (parentRaw === undefined || parentRaw === ENTITY_NULL_RAW) {
1150
+ walkChildren(
1151
+ world,
1152
+ entity,
1153
+ hierarchyWriter,
1154
+ hierarchyBindings,
1155
+ transformWriter,
1156
+ transformBindings,
1157
+ scratch,
1158
+ report,
1159
+ false,
1160
+ );
1161
+ }
1162
+ }
1163
+ }
1164
+
1165
+ // Any remaining hierarchy row is either behind a malformed/missing mirror
1166
+ // edge or belongs to a rootless cycle. Follow each residual parent chain
1167
+ // once with the same row-state machine (not a per-node parent search), then
1168
+ // cut that complete residual path to local space. This keeps malformed
1169
+ // coverage linear in residual rows and edges while normal frames remain
1170
+ // exclusively Children-driven.
1171
+ for (let bindingIndex = 0; bindingIndex < hierarchyBindings.length; bindingIndex += 1) {
1172
+ const binding = hierarchyBindings[bindingIndex];
1173
+ if (binding === undefined) continue;
1174
+ const entities = binding.entities;
1175
+ const states = scratch.hierarchyStates[bindingIndex];
1176
+ for (let row = 0; row < binding.rowCapacity; row += 1) {
1177
+ if ((states?.[row] ?? 0) !== 0) continue;
1178
+ const entity = (entities[row] ?? 0) as EntityHandle;
1179
+ resolveResidualPath(
1180
+ world,
1181
+ entity,
1182
+ hierarchyWriter,
1183
+ hierarchyBindings,
1184
+ transformWriter,
1185
+ transformBindings,
1186
+ scratch,
1187
+ report,
1188
+ );
1189
+ }
1190
+ }
1191
+
1192
+ for (let bindingIndex = 0; bindingIndex < hierarchyBindings.length; bindingIndex += 1) {
1193
+ const changed = scratch.hierarchyChanged[bindingIndex];
1194
+ if (changed === undefined) continue;
1195
+ countPublishedRows(changed);
1196
+ const published = hierarchyWriter.publishChangedRows(bindingIndex, changed);
1197
+ if (!published.ok) {
1198
+ const cause = published.error as EcsError;
1199
+ report(derivedWriteError(cause, bindingIndex));
1200
+ }
1201
+ }
1202
+ return firstError === undefined ? ok(undefined) : err(firstError);
1203
+ }
1204
+
1205
+ export function propagateTransforms(world: World): Result<void, SceneError> {
1206
+ const scratch = scratchFor(world);
1207
+ const pairs = validateTransformPairs(world, scratch);
1208
+ if (!pairs.ok) return pairs;
1209
+ const flat = propagateFlat(world, scratch);
1210
+ if (!flat.ok) return flat;
1211
+ return propagateHierarchy(world, scratch);
311
1212
  }
312
1213
 
313
1214
  export const PropagateTransforms: SystemHandle<readonly []> = defineSystem({
@@ -341,7 +1242,7 @@ export function registerPropagateTransforms(
341
1242
  world.removeSystem(FixedUpdate, PROPAGATE_TRANSFORMS_FIXED_SYSTEM);
342
1243
  world.removeSystem(Update, PROPAGATE_TRANSFORMS_SYSTEM);
343
1244
  REGISTRATION_LEASES.delete(world);
344
- CACHE.delete(world);
1245
+ SCRATCH.delete(world);
345
1246
  }
346
1247
  };
347
1248
  }
@@ -360,17 +1261,18 @@ export function registerPropagateTransforms(
360
1261
  .unwrap();
361
1262
  }
362
1263
  world.addSystems(FixedUpdate, TransformFixedSet, [PropagateTransformsFixed]).unwrap();
363
- const lease: TransformRegistrationLease = { refs: 1 };
364
- REGISTRATION_LEASES.set(world, lease);
1264
+ REGISTRATION_LEASES.set(world, { refs: 1 });
365
1265
  let active = true;
366
1266
  return () => {
367
1267
  if (!active) return;
368
1268
  active = false;
1269
+ const lease = REGISTRATION_LEASES.get(world);
1270
+ if (lease === undefined) return;
369
1271
  lease.refs -= 1;
370
1272
  if (lease.refs !== 0) return;
371
1273
  world.removeSystem(FixedUpdate, PROPAGATE_TRANSFORMS_FIXED_SYSTEM);
372
1274
  world.removeSystem(Update, PROPAGATE_TRANSFORMS_SYSTEM);
373
1275
  REGISTRATION_LEASES.delete(world);
374
- CACHE.delete(world);
1276
+ SCRATCH.delete(world);
375
1277
  };
376
1278
  }