@forgeax/engine-scene 0.1.23 → 0.1.25

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.
@@ -2,6 +2,7 @@ import {
2
2
  defineSystem,
3
3
  defineSystemSet,
4
4
  type EcsError,
5
+ ENTITY_NULL_RAW,
5
6
  type EntityHandle,
6
7
  FixedUpdate,
7
8
  type Query,
@@ -9,9 +10,17 @@ import {
9
10
  Update,
10
11
  type World,
11
12
  } from '@forgeax/engine-ecs';
13
+ import {
14
+ type DerivedColumnBinding,
15
+ type DerivedRangeCursor,
16
+ type DerivedRangeWriter,
17
+ getDerivedWriter,
18
+ worldInternal,
19
+ } from '@forgeax/engine-ecs/internal';
12
20
  import { type Mat4, mat4 } from '@forgeax/engine-math';
13
21
  import { err, ok, type Result } from '@forgeax/engine-types';
14
22
  import { ChildOf } from '../components/child-of';
23
+ import { Children } from '../components/children';
15
24
  import { GlobalTransform, Transform } from '../components/transform';
16
25
  import { SceneError } from '../errors';
17
26
 
@@ -20,22 +29,129 @@ export const PROPAGATE_TRANSFORMS_FIXED_SYSTEM = 'propagateTransformsFixed' as c
20
29
  export const TransformSet = defineSystemSet({ name: 'transform' });
21
30
  export const TransformFixedSet = defineSystemSet({ name: 'transform-fixed' });
22
31
 
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;
50
+ }
51
+
52
+ let propagationTrace: TransformPropagationTrace | undefined;
53
+ let hierarchyRootCursorAllocationCount = 0;
54
+ let propagationTraceRootCursorAllocationStart = 0;
55
+
56
+ function createHierarchyRootCursor(): DerivedRangeCursor {
57
+ hierarchyRootCursorAllocationCount += 1;
58
+ return { bindingIndex: -1, row: -1 };
59
+ }
60
+
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,
74
+ };
75
+ }
76
+
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;
86
+ }
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
+ };
99
+ }
100
+
101
+ function countPropagation(name: keyof TransformPropagationTrace): void {
102
+ const trace = propagationTrace;
103
+ if (trace !== undefined) trace[name] += 1;
104
+ }
105
+
23
106
  interface Scratch {
24
107
  position: Float32Array;
25
108
  rotation: Float32Array;
26
109
  scale: Float32Array;
27
110
  local: Mat4;
28
- hierarchyPath: number[];
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;
29
130
  flatQuery?: FlatQuery;
30
- outputQuery?: OutputQuery;
31
131
  hierarchyQuery?: HierarchyQuery;
132
+ transformQuery?: TransformQuery;
133
+ hierarchyWriter?: HierarchyWriter;
134
+ transformWriter?: TransformWriter;
32
135
  missingGlobalQuery?: MissingGlobalQuery;
33
136
  missingTransformQuery?: MissingTransformQuery;
34
137
  }
35
138
 
36
- type OutputQuery = Query<readonly [], readonly [typeof GlobalTransform]>;
37
139
  type FlatQuery = Query<readonly [typeof Transform], readonly [typeof GlobalTransform]>;
38
- type HierarchyQuery = Query<readonly [typeof Transform, typeof ChildOf]>;
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>;
39
155
  type MissingGlobalQuery = Query<readonly [], readonly [], readonly []>;
40
156
  type MissingTransformQuery = Query<readonly [], readonly [], readonly []>;
41
157
 
@@ -60,8 +176,10 @@ function pairError(entity: EntityHandle, expected: string): Result<void, SceneEr
60
176
  function ensureQueries(world: World, scratch: Scratch): Result<void, SceneError> {
61
177
  if (
62
178
  scratch.flatQuery !== undefined &&
63
- scratch.outputQuery !== undefined &&
64
179
  scratch.hierarchyQuery !== undefined &&
180
+ scratch.transformQuery !== undefined &&
181
+ scratch.hierarchyWriter !== undefined &&
182
+ scratch.transformWriter !== undefined &&
65
183
  scratch.missingGlobalQuery !== undefined &&
66
184
  scratch.missingTransformQuery !== undefined
67
185
  ) {
@@ -73,16 +191,29 @@ function ensureQueries(world: World, scratch: Scratch): Result<void, SceneError>
73
191
  without: [ChildOf],
74
192
  changed: [Transform],
75
193
  });
76
- const output = world.query({ write: [GlobalTransform] });
77
- const hierarchy = world.query({ read: [Transform, ChildOf] });
194
+ const hierarchy = world.query({ read: [Transform, ChildOf], write: [GlobalTransform] });
195
+ const transform = world.query({ read: [Transform], write: [GlobalTransform] });
78
196
  const missingGlobal = world.query({ with: [Transform], without: [GlobalTransform] });
79
197
  const missingTransform = world.query({ with: [GlobalTransform], without: [Transform] });
80
- if (!flatOutput.ok || !output.ok || !hierarchy.ok || !missingGlobal.ok || !missingTransform.ok) {
198
+ if (
199
+ !flatOutput.ok ||
200
+ !hierarchy.ok ||
201
+ !transform.ok ||
202
+ !missingGlobal.ok ||
203
+ !missingTransform.ok
204
+ ) {
81
205
  return pairError(0 as EntityHandle, 'valid Transform and GlobalTransform pair queries');
82
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
+ }
83
212
  scratch.flatQuery = flatOutput.value as FlatQuery;
84
- scratch.outputQuery = output.value as OutputQuery;
85
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;
86
217
  scratch.missingGlobalQuery = missingGlobal.value as MissingGlobalQuery;
87
218
  scratch.missingTransformQuery = missingTransform.value as MissingTransformQuery;
88
219
  return ok(undefined);
@@ -113,7 +244,25 @@ function scratchFor(world: World): Scratch {
113
244
  rotation: new Float32Array(4),
114
245
  scale: new Float32Array(3),
115
246
  local: mat4.create(),
116
- hierarchyPath: [] as number[],
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,
117
266
  };
118
267
  SCRATCH.set(world, created);
119
268
  return created;
@@ -191,46 +340,7 @@ function composeFlatColumns(
191
340
  }
192
341
  }
193
342
 
194
- function writeWorld(
195
- world: World,
196
- output: ReturnType<World['query']> extends Result<infer Q, unknown> ? Q : never,
197
- entity: EntityHandle,
198
- value: Mat4,
199
- ): Result<void, SceneError> {
200
- const row = output.at(entity);
201
- if (row === undefined) {
202
- return err(
203
- new SceneError({
204
- code: 'hierarchy-broken',
205
- expected: 'each Transform entity to carry a GlobalTransform pair',
206
- hint: 'attach GlobalTransform at scene authoring or import time, then retry propagation',
207
- detail: { entity, parent: entity },
208
- }),
209
- );
210
- }
211
- const current = world.get(entity, GlobalTransform);
212
- if (!current.ok)
213
- return err(
214
- new SceneError({
215
- code: 'hierarchy-broken',
216
- expected: 'each Transform entity to carry a GlobalTransform pair',
217
- hint: 'attach GlobalTransform at scene authoring or import time, then retry propagation',
218
- detail: { entity, parent: entity },
219
- }),
220
- );
221
- let changed = false;
222
- for (let index = 0; index < value.length; index += 1) {
223
- if (current.value.world[index] !== value[index]) {
224
- changed = true;
225
- break;
226
- }
227
- }
228
- if (!changed) return ok(undefined);
229
- row.mut(GlobalTransform).world.set(value);
230
- return ok(undefined);
231
- }
232
-
233
- function propagateFlat(scratch: Scratch): Result<void, SceneError> {
343
+ function propagateFlat(world: World, scratch: Scratch): Result<void, SceneError> {
234
344
  const query = scratch.flatQuery;
235
345
  if (query === undefined)
236
346
  return err(
@@ -252,182 +362,843 @@ function propagateFlat(scratch: Scratch): Result<void, SceneError> {
252
362
  }
253
363
  } catch (cause) {
254
364
  const error = cause as EcsError;
255
- return err(
256
- new SceneError({
257
- code: 'hierarchy-broken',
258
- expected: 'derived GlobalTransform range publication to succeed',
259
- hint: error.hint,
260
- detail: {
261
- kind: 'derived-write',
262
- entity: 0 as EntityHandle,
263
- parent: 0 as EntityHandle,
264
- bindingIndex,
265
- base: 0,
266
- start: 0,
267
- count: 0,
268
- cause: error,
269
- },
270
- }),
271
- );
365
+ return err(derivedWriteError(error, bindingIndex));
366
+ }
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));
272
399
  }
400
+ scratch.flatStructureEpoch = structureEpoch;
273
401
  return ok(undefined);
274
402
  }
275
403
 
276
- interface HierarchyEntry {
277
- readonly entity: EntityHandle;
278
- parent: EntityHandle | undefined;
279
- readonly position: Float32Array;
280
- readonly rotation: Float32Array;
281
- readonly scale: Float32Array;
282
- readonly world: Mat4;
283
- state: 0 | 1 | 2;
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;
284
420
  }
285
421
 
286
- function findEntry(entries: readonly HierarchyEntry[], entity: EntityHandle): number {
287
- for (let index = 0; index < entries.length; index += 1) {
288
- if (entries[index]?.entity === entity) return index;
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
+ }
289
454
  }
290
- return -1;
291
455
  }
292
456
 
293
- function resolveEntry(
294
- index: number,
295
- entries: HierarchyEntry[],
296
- world: World,
297
- output: ReturnType<World['query']> extends Result<infer Q, unknown> ? Q : never,
457
+ function composeBindingRow(
458
+ binding: TransformBinding | HierarchyBinding,
459
+ row: number,
460
+ parentBinding: TransformBinding | undefined,
461
+ parentRow: number,
298
462
  scratch: Scratch,
299
- ): Result<void, SceneError> {
300
- const entry = entries[index];
301
- if (entry === undefined || entry.state === 2) return ok(undefined);
302
- if (entry.state === 1) {
303
- // A cycle is an input error, but leaving every member on its previous
304
- // GlobalTransform would make the renderer consume stale derived facts.
305
- // Cut the complete active cycle segment to roots for this failed frame;
306
- // the authored ChildOf values remain untouched and the next repaired
307
- // propagation recomputes the normal hierarchy.
308
- const cycleParent = entry.parent ?? entry.entity;
309
- const cycleStart = scratch.hierarchyPath.indexOf(index);
310
- const start = cycleStart < 0 ? 0 : cycleStart;
311
- for (let pathIndex = start; pathIndex < scratch.hierarchyPath.length; pathIndex += 1) {
312
- const cycleEntry = entries[scratch.hierarchyPath[pathIndex] ?? -1];
313
- if (cycleEntry !== undefined) cycleEntry.parent = undefined;
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;
314
478
  }
315
- return err(
316
- new SceneError({
317
- code: 'hierarchy-cycle',
318
- expected: 'a parent-before-child Transform hierarchy',
319
- hint: 'repair the ChildOf cycle and retry TransformPropagation',
320
- detail: { entity: entry.entity, parent: cycleParent },
321
- }),
322
- );
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);
323
482
  }
324
- entry.state = 1;
325
- scratch.hierarchyPath.push(index);
326
- let parentIndex = entry.parent === undefined ? -1 : findEntry(entries, entry.parent);
327
- let failure: SceneError | undefined;
328
- if (entry.parent !== undefined && parentIndex < 0) {
329
- const local = world.get(entry.parent, Transform);
330
- const global = world.get(entry.parent, GlobalTransform);
331
- if (!local.ok || !global.ok) {
332
- // The edge is invalid for this frame. Treat the child as a local root
333
- // so the output column reflects current authored input instead of the
334
- // previous frame's world matrix, while still returning the structured
335
- // error to the caller.
336
- failure = new SceneError({
337
- code: 'hierarchy-broken',
338
- expected: 'each ChildOf parent to carry Transform and GlobalTransform',
339
- hint: 'repair the missing parent pair before retrying propagation',
340
- detail: { entity: entry.entity, parent: entry.parent },
341
- });
342
- entry.parent = undefined;
343
- parentIndex = -1;
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
+ }
344
591
  } else {
345
- const relation = world.get(entry.parent, ChildOf);
346
- entries.push({
347
- entity: entry.parent,
348
- parent: relation.ok && relation.value.parent !== null ? relation.value.parent : undefined,
349
- position: new Float32Array(local.value.pos),
350
- rotation: new Float32Array(local.value.quat),
351
- scale: new Float32Array(local.value.scale),
352
- world: mat4.create(),
353
- state: 0,
354
- });
355
- parentIndex = entries.length - 1;
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[],
613
+ ): boolean {
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;
356
645
  }
357
646
  }
358
- if (parentIndex >= 0) {
359
- const parentResult = resolveEntry(parentIndex, entries, world, output, scratch);
360
- if (!parentResult.ok) failure = parentResult.error;
361
- // A cycle fallback can cut this entry's authored edge while resolving its
362
- // parent. Re-read the edge before composing so no cycle member consumes an
363
- // uninitialized parent matrix.
364
- parentIndex = entry.parent === undefined ? -1 : findEntry(entries, entry.parent);
365
- if (parentIndex >= 0 && entries[parentIndex]?.state !== 2) parentIndex = -1;
366
- }
367
- composeColumns(entry.position, entry.rotation, entry.scale, scratch.local, scratch);
368
- if (parentIndex >= 0) {
369
- const parent = entries[parentIndex];
370
- if (parent !== undefined) mat4.multiply(entry.world, parent.world, scratch.local);
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
+ ),
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
+ }
670
+ }
671
+
672
+ function walkChildren(
673
+ world: World,
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;
699
+ }
371
700
  } else {
372
- entry.world.set(scratch.local);
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;
710
+ }
373
711
  }
374
- const write = writeWorld(world, output, entry.entity, entry.world);
375
- if (!write.ok) {
376
- scratch.hierarchyPath.pop();
377
- return write;
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;
725
+ }
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;
839
+ }
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;
857
+ }
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;
905
+ }
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);
920
+ }
921
+ }
922
+ }
923
+
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);
941
+ }
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
+ );
954
+ }
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;
378
1106
  }
379
- entry.state = 2;
380
- scratch.hierarchyPath.pop();
381
- return failure === undefined ? ok(undefined) : err(failure);
382
1107
  }
383
1108
 
384
1109
  function propagateHierarchy(world: World, scratch: Scratch): Result<void, SceneError> {
385
- const query = scratch.hierarchyQuery;
386
- const output = scratch.outputQuery;
387
- if (query === undefined || output === undefined) {
1110
+ const hierarchyWriter = scratch.hierarchyWriter;
1111
+ const transformWriter = scratch.transformWriter;
1112
+ if (hierarchyWriter === undefined || transformWriter === undefined) {
388
1113
  return err(
389
1114
  new SceneError({
390
1115
  code: 'hierarchy-broken',
391
- expected: 'paired Transform and GlobalTransform query access',
1116
+ expected: 'paired Transform and GlobalTransform derived bindings',
392
1117
  hint: 'register the scene components before running TransformPropagation',
393
1118
  }),
394
1119
  );
395
1120
  }
396
- const entries: HierarchyEntry[] = [];
397
- for (const row of query) {
398
- if (findEntry(entries, row.entity) >= 0) continue;
399
- const local = row.get(Transform);
400
- const parentValue = row.get(ChildOf).parent;
401
- const parent = parentValue === null ? undefined : parentValue;
402
- entries.push({
403
- entity: row.entity,
404
- parent,
405
- position: new Float32Array(local.pos),
406
- rotation: new Float32Array(local.quat),
407
- scale: new Float32Array(local.scale),
408
- world: mat4.create(),
409
- state: 0,
410
- });
411
- }
412
- scratch.hierarchyPath.length = 0;
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);
413
1135
  let firstError: SceneError | undefined;
414
- for (let index = 0; index < entries.length; index += 1) {
415
- const result = resolveEntry(index, entries, world, output, scratch);
416
- if (!result.ok) {
417
- const currentEntity = Number(result.error.detail?.entity ?? Number.MAX_SAFE_INTEGER);
418
- if (firstError === undefined) {
419
- firstError = result.error;
420
- } else {
421
- const firstEntity = Number(firstError.detail?.entity ?? Number.MAX_SAFE_INTEGER);
422
- if (
423
- currentEntity < firstEntity ||
424
- (currentEntity === firstEntity && result.error.code.localeCompare(firstError.code) < 0)
425
- ) {
426
- firstError = result.error;
427
- }
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
+ );
428
1161
  }
429
1162
  }
430
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
+ }
431
1202
  return firstError === undefined ? ok(undefined) : err(firstError);
432
1203
  }
433
1204
 
@@ -435,7 +1206,7 @@ export function propagateTransforms(world: World): Result<void, SceneError> {
435
1206
  const scratch = scratchFor(world);
436
1207
  const pairs = validateTransformPairs(world, scratch);
437
1208
  if (!pairs.ok) return pairs;
438
- const flat = propagateFlat(scratch);
1209
+ const flat = propagateFlat(world, scratch);
439
1210
  if (!flat.ok) return flat;
440
1211
  return propagateHierarchy(world, scratch);
441
1212
  }