@forgeax/engine-scene 0.0.0-dev.8d955ade1c79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +55 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/asset-owner.integration.test.d.ts +2 -0
- package/dist/__tests__/asset-owner.integration.test.d.ts.map +1 -0
- package/dist/__tests__/components.unit.test.d.ts +2 -0
- package/dist/__tests__/components.unit.test.d.ts.map +1 -0
- package/dist/__tests__/diagnostic-type-surface.test-d.d.ts +3 -0
- package/dist/__tests__/diagnostic-type-surface.test-d.d.ts.map +1 -0
- package/dist/__tests__/direct-child-of-import.unit.test.d.ts +2 -0
- package/dist/__tests__/direct-child-of-import.unit.test.d.ts.map +1 -0
- package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts +8 -0
- package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts.map +1 -0
- package/dist/__tests__/hierarchy-projection-contract.unit.test.d.ts +2 -0
- package/dist/__tests__/hierarchy-projection-contract.unit.test.d.ts.map +1 -0
- package/dist/__tests__/hierarchy-projection.unit.test.d.ts +2 -0
- package/dist/__tests__/hierarchy-projection.unit.test.d.ts.map +1 -0
- package/dist/__tests__/plugin.integration.test.d.ts +2 -0
- package/dist/__tests__/plugin.integration.test.d.ts.map +1 -0
- package/dist/__tests__/propagation.unit.test.d.ts +2 -0
- package/dist/__tests__/propagation.unit.test.d.ts.map +1 -0
- package/dist/__tests__/transform-change-journal.unit.test.d.ts +2 -0
- package/dist/__tests__/transform-change-journal.unit.test.d.ts.map +1 -0
- package/dist/assets/scene-decoder.d.ts +8 -0
- package/dist/assets/scene-decoder.d.ts.map +1 -0
- package/dist/collect-subtree.d.ts +4 -0
- package/dist/collect-subtree.d.ts.map +1 -0
- package/dist/components/child-of.d.ts +31 -0
- package/dist/components/child-of.d.ts.map +1 -0
- package/dist/components/children.d.ts +48 -0
- package/dist/components/children.d.ts.map +1 -0
- package/dist/components/morph-weights.d.ts +5 -0
- package/dist/components/morph-weights.d.ts.map +1 -0
- package/dist/components/name.d.ts +4 -0
- package/dist/components/name.d.ts.map +1 -0
- package/dist/components/transform.d.ts +52 -0
- package/dist/components/transform.d.ts.map +1 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1731 -0
- package/dist/index.mjs.map +1 -0
- package/dist/instances/collect-profile.d.ts +7 -0
- package/dist/instances/collect-profile.d.ts.map +1 -0
- package/dist/instances/externalization.d.ts +14 -0
- package/dist/instances/externalization.d.ts.map +1 -0
- package/dist/instances/scene-instances.d.ts +350 -0
- package/dist/instances/scene-instances.d.ts.map +1 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/systems/hierarchy-projection.d.ts +16 -0
- package/dist/systems/hierarchy-projection.d.ts.map +1 -0
- package/dist/systems/index.d.ts +3 -0
- package/dist/systems/index.d.ts.map +1 -0
- package/dist/systems/propagate-transforms.d.ts +15 -0
- package/dist/systems/propagate-transforms.d.ts.map +1 -0
- package/package.json +58 -0
- package/src/__tests__/asset-owner.integration.test.ts +57 -0
- package/src/__tests__/components.unit.test.ts +26 -0
- package/src/__tests__/diagnostic-type-surface.test-d.ts +11 -0
- package/src/__tests__/direct-child-of-import.unit.test.ts +13 -0
- package/src/__tests__/fixtures/malformed-hierarchy-edge.ts +15 -0
- package/src/__tests__/hierarchy-projection-contract.unit.test.ts +45 -0
- package/src/__tests__/hierarchy-projection.unit.test.ts +92 -0
- package/src/__tests__/plugin.integration.test.ts +23 -0
- package/src/__tests__/propagation.unit.test.ts +163 -0
- package/src/__tests__/transform-change-journal.unit.test.ts +105 -0
- package/src/assets/scene-decoder.ts +153 -0
- package/src/collect-subtree.ts +30 -0
- package/src/components/child-of.ts +90 -0
- package/src/components/children.ts +113 -0
- package/src/components/morph-weights.ts +6 -0
- package/src/components/name.ts +22 -0
- package/src/components/transform.ts +89 -0
- package/src/errors.ts +34 -0
- package/src/index.ts +65 -0
- package/src/instances/collect-profile.ts +10 -0
- package/src/instances/externalization.ts +137 -0
- package/src/instances/scene-instances.ts +1564 -0
- package/src/plugin.ts +28 -0
- package/src/systems/hierarchy-projection.ts +134 -0
- package/src/systems/index.ts +11 -0
- package/src/systems/propagate-transforms.ts +376 -0
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Component, World } from '@forgeax/engine-ecs';
|
|
2
|
+
import type { Plugin } from '@forgeax/engine-plugin';
|
|
3
|
+
import { ChildOf } from './components/child-of';
|
|
4
|
+
import { Children } from './components/children';
|
|
5
|
+
import { MorphWeights } from './components/morph-weights';
|
|
6
|
+
import { Name } from './components/name';
|
|
7
|
+
import { Transform } from './components/transform';
|
|
8
|
+
import { registerPropagateTransforms } from './systems/propagate-transforms';
|
|
9
|
+
|
|
10
|
+
const SCENE_COMPONENTS: readonly Component[] = [ChildOf, Children, MorphWeights, Name, Transform];
|
|
11
|
+
|
|
12
|
+
function registerSceneComponents(world: World): () => void {
|
|
13
|
+
const leases = SCENE_COMPONENTS.map((component) => world.components.register(component).unwrap());
|
|
14
|
+
return () => {
|
|
15
|
+
for (let index = leases.length - 1; index >= 0; index -= 1) leases[index]?.dispose();
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function scenePlugin(): Plugin {
|
|
20
|
+
return {
|
|
21
|
+
name: 'scene',
|
|
22
|
+
inject: ['world'],
|
|
23
|
+
apply(ctx) {
|
|
24
|
+
ctx.effect(() => registerSceneComponents(ctx.world), 'scene/components');
|
|
25
|
+
ctx.effect(() => registerPropagateTransforms(ctx.world), 'scene/propagate-transforms');
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Entity, type EntityHandle, type World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { createWorldProjection } from '@forgeax/engine-ecs/projection';
|
|
3
|
+
import { ChildOf } from '../components/child-of';
|
|
4
|
+
import type { SceneErrorCode, SceneErrorDetail } from '../errors';
|
|
5
|
+
|
|
6
|
+
export interface SceneHierarchyDiagnostic {
|
|
7
|
+
readonly code: SceneErrorCode;
|
|
8
|
+
readonly expected: string;
|
|
9
|
+
readonly hint: string;
|
|
10
|
+
readonly detail: SceneErrorDetail;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SceneHierarchySnapshot {
|
|
14
|
+
readonly parentOf: ReadonlyMap<EntityHandle, EntityHandle>;
|
|
15
|
+
readonly diagnostics: readonly SceneHierarchyDiagnostic[];
|
|
16
|
+
getParent(entity: EntityHandle): EntityHandle | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface HierarchyProjectionCacheEntry {
|
|
20
|
+
readonly changes: ReturnType<typeof createWorldProjection>;
|
|
21
|
+
readonly snapshot: SceneHierarchySnapshot;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A World can be observed by the transform system, renderer visibility, and
|
|
25
|
+
// editor projections in the same frame. Keep one World-local projection so
|
|
26
|
+
// those consumers do not each rescan every archetype. Structure changes and
|
|
27
|
+
// the ChildOf component's own mutation token are the invalidation keys; the
|
|
28
|
+
// global mutation epoch is intentionally too broad because animation and
|
|
29
|
+
// runtime-only component writes may advance it every frame. Direct table
|
|
30
|
+
// writes are internal-only and must not mutate authored hierarchy state.
|
|
31
|
+
const HIERARCHY_PROJECTION_CACHE = new WeakMap<World, HierarchyProjectionCacheEntry>();
|
|
32
|
+
|
|
33
|
+
function diagnostic(
|
|
34
|
+
code: SceneErrorCode,
|
|
35
|
+
entity: EntityHandle,
|
|
36
|
+
parent: EntityHandle,
|
|
37
|
+
): SceneHierarchyDiagnostic {
|
|
38
|
+
if (code === 'hierarchy-cycle') {
|
|
39
|
+
return {
|
|
40
|
+
code,
|
|
41
|
+
expected: 'ChildOf parent edges form an acyclic live hierarchy',
|
|
42
|
+
hint: 'remove one ChildOf edge from the reported cycle, then re-run the extract',
|
|
43
|
+
detail: { entity, parent },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
code,
|
|
48
|
+
expected: 'ChildOf.parent references a live entity in the same World',
|
|
49
|
+
hint: 'remove the stale ChildOf component or restore the referenced parent in this World',
|
|
50
|
+
detail: { entity, parent },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build the only World-local projection of ChildOf parent facts. */
|
|
55
|
+
export function projectHierarchy(world: World): SceneHierarchySnapshot {
|
|
56
|
+
const cached = HIERARCHY_PROJECTION_CACHE.get(world);
|
|
57
|
+
const changes = cached?.changes ?? createWorldProjection(world, { components: [ChildOf] });
|
|
58
|
+
if (cached !== undefined) {
|
|
59
|
+
const evidence = changes.poll();
|
|
60
|
+
if (evidence.status === 'delta' && evidence.changes.length === 0) return cached.snapshot;
|
|
61
|
+
}
|
|
62
|
+
const liveEntities = new Set<EntityHandle>();
|
|
63
|
+
const authoredParents = new Map<EntityHandle, EntityHandle>();
|
|
64
|
+
|
|
65
|
+
const query = world.query({ read: [Entity], optional: [ChildOf] });
|
|
66
|
+
if (query.ok) {
|
|
67
|
+
for (const row of query.value) {
|
|
68
|
+
liveEntities.add(row.entity);
|
|
69
|
+
const parent = row.get(ChildOf)?.parent;
|
|
70
|
+
if (parent !== undefined && parent !== null) authoredParents.set(row.entity, parent);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const parentOf = new Map<EntityHandle, EntityHandle>();
|
|
75
|
+
const diagnostics: SceneHierarchyDiagnostic[] = [];
|
|
76
|
+
for (const [entity, parent] of authoredParents) {
|
|
77
|
+
if (liveEntities.has(parent)) {
|
|
78
|
+
parentOf.set(entity, parent);
|
|
79
|
+
} else {
|
|
80
|
+
diagnostics.push(diagnostic('hierarchy-broken', entity, parent));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const state = new Map<EntityHandle, 0 | 1 | 2>();
|
|
85
|
+
const stack: EntityHandle[] = [];
|
|
86
|
+
const cycleMembers = new Set<EntityHandle>();
|
|
87
|
+
const visit = (entity: EntityHandle): void => {
|
|
88
|
+
const currentState = state.get(entity) ?? 0;
|
|
89
|
+
if (currentState === 2) return;
|
|
90
|
+
if (currentState === 1) {
|
|
91
|
+
const cycleStart = stack.indexOf(entity);
|
|
92
|
+
for (let index = cycleStart; index >= 0 && index < stack.length; index++) {
|
|
93
|
+
const member = stack[index];
|
|
94
|
+
if (member !== undefined) cycleMembers.add(member);
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
state.set(entity, 1);
|
|
100
|
+
stack.push(entity);
|
|
101
|
+
const parent = parentOf.get(entity);
|
|
102
|
+
if (parent !== undefined) visit(parent);
|
|
103
|
+
stack.pop();
|
|
104
|
+
state.set(entity, 2);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
for (const entity of liveEntities) visit(entity);
|
|
108
|
+
for (const entity of cycleMembers) {
|
|
109
|
+
const parent = authoredParents.get(entity);
|
|
110
|
+
if (parent !== undefined) diagnostics.push(diagnostic('hierarchy-cycle', entity, parent));
|
|
111
|
+
parentOf.delete(entity);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
diagnostics.sort((left, right) => {
|
|
115
|
+
const entityDelta = (left.detail.entity as number) - (right.detail.entity as number);
|
|
116
|
+
if (entityDelta !== 0) return entityDelta;
|
|
117
|
+
return left.code.localeCompare(right.code);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const stableParentOf = new Map(parentOf);
|
|
121
|
+
const stableDiagnostics = Object.freeze(diagnostics.slice());
|
|
122
|
+
const snapshot: SceneHierarchySnapshot = {
|
|
123
|
+
parentOf: stableParentOf,
|
|
124
|
+
diagnostics: stableDiagnostics,
|
|
125
|
+
getParent(entity: EntityHandle): EntityHandle | undefined {
|
|
126
|
+
return stableParentOf.get(entity);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
HIERARCHY_PROJECTION_CACHE.set(world, {
|
|
130
|
+
changes,
|
|
131
|
+
snapshot,
|
|
132
|
+
});
|
|
133
|
+
return snapshot;
|
|
134
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export {
|
|
2
|
+
projectHierarchy,
|
|
3
|
+
type SceneHierarchyDiagnostic,
|
|
4
|
+
type SceneHierarchySnapshot,
|
|
5
|
+
} from './hierarchy-projection';
|
|
6
|
+
export {
|
|
7
|
+
PROPAGATE_TRANSFORMS_SYSTEM,
|
|
8
|
+
propagateTransforms,
|
|
9
|
+
registerPropagateTransforms,
|
|
10
|
+
TransformSet,
|
|
11
|
+
} from './propagate-transforms';
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineSystem,
|
|
3
|
+
defineSystemSet,
|
|
4
|
+
type EntityHandle,
|
|
5
|
+
FixedUpdate,
|
|
6
|
+
type SystemHandle,
|
|
7
|
+
Update,
|
|
8
|
+
type World,
|
|
9
|
+
} from '@forgeax/engine-ecs';
|
|
10
|
+
import {
|
|
11
|
+
createWorldProjection,
|
|
12
|
+
setDerivedComponent,
|
|
13
|
+
type WorldProjection,
|
|
14
|
+
} from '@forgeax/engine-ecs/projection';
|
|
15
|
+
import { type Mat4, mat4 } from '@forgeax/engine-math';
|
|
16
|
+
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
17
|
+
import { ChildOf } from '../components/child-of';
|
|
18
|
+
import { Transform } from '../components/transform';
|
|
19
|
+
import { SceneError } from '../errors';
|
|
20
|
+
import { projectHierarchy, type SceneHierarchySnapshot } from './hierarchy-projection';
|
|
21
|
+
|
|
22
|
+
export const PROPAGATE_TRANSFORMS_SYSTEM = 'propagateTransforms' as const;
|
|
23
|
+
export const PROPAGATE_TRANSFORMS_FIXED_SYSTEM = 'propagateTransformsFixed' as const;
|
|
24
|
+
export const TransformSet = defineSystemSet({ name: 'transform' });
|
|
25
|
+
export const TransformFixedSet = defineSystemSet({ name: 'transform-fixed' });
|
|
26
|
+
|
|
27
|
+
interface LocalState {
|
|
28
|
+
readonly pos: Float32Array;
|
|
29
|
+
readonly quat: Float32Array;
|
|
30
|
+
readonly scale: Float32Array;
|
|
31
|
+
}
|
|
32
|
+
|
|
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>();
|
|
45
|
+
|
|
46
|
+
interface TransformRegistrationLease {
|
|
47
|
+
refs: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
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),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
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;
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
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
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function compose(state: LocalState, out: Mat4): void {
|
|
87
|
+
mat4.compose(out, state.pos, state.quat, state.scale);
|
|
88
|
+
}
|
|
89
|
+
|
|
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
|
+
}
|
|
101
|
+
|
|
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;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function hierarchyError(hierarchy: SceneHierarchySnapshot): Result<void, SceneError> {
|
|
121
|
+
const first = hierarchy.diagnostics[0];
|
|
122
|
+
if (first === undefined) return ok(undefined);
|
|
123
|
+
return err(
|
|
124
|
+
new SceneError({
|
|
125
|
+
code: first.code,
|
|
126
|
+
expected: first.expected,
|
|
127
|
+
hint: first.hint,
|
|
128
|
+
detail: first.detail,
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
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
|
+
}
|
|
145
|
+
}
|
|
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),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
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);
|
|
173
|
+
}
|
|
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);
|
|
188
|
+
return err(
|
|
189
|
+
new SceneError({
|
|
190
|
+
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 },
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
cache.derived.set(entity, resolved);
|
|
198
|
+
published.add(entity);
|
|
199
|
+
visiting.delete(entity);
|
|
200
|
+
return ok(undefined);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function drainPublishedChanges(
|
|
204
|
+
cache: PropagationCache,
|
|
205
|
+
published: ReadonlySet<EntityHandle>,
|
|
206
|
+
): 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),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function propagateTransforms(
|
|
218
|
+
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;
|
|
230
|
+
}
|
|
231
|
+
if (!drainPublishedChanges(cache, published)) {
|
|
232
|
+
CACHE.delete(world);
|
|
233
|
+
return propagateTransforms(world);
|
|
234
|
+
}
|
|
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;
|
|
248
|
+
}
|
|
249
|
+
if (!drainPublishedChanges(cache, published)) {
|
|
250
|
+
CACHE.delete(world);
|
|
251
|
+
return propagateTransforms(world);
|
|
252
|
+
}
|
|
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;
|
|
268
|
+
}
|
|
269
|
+
if (change.kind !== 'component-changed') continue;
|
|
270
|
+
if (change.component === ChildOf) {
|
|
271
|
+
rebuild = true;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
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);
|
|
291
|
+
}
|
|
292
|
+
const next = copyState(current.value);
|
|
293
|
+
if (!sameState(cache.locals.get(entity), next)) changed.add(entity);
|
|
294
|
+
cache.locals.set(entity, next);
|
|
295
|
+
}
|
|
296
|
+
if (changed.size === 0) return cache.result;
|
|
297
|
+
|
|
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;
|
|
304
|
+
}
|
|
305
|
+
if (!drainPublishedChanges(cache, published)) {
|
|
306
|
+
CACHE.delete(world);
|
|
307
|
+
return propagateTransforms(world);
|
|
308
|
+
}
|
|
309
|
+
cache.result = hierarchyError(cache.hierarchy);
|
|
310
|
+
return cache.result;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export const PropagateTransforms: SystemHandle<readonly []> = defineSystem({
|
|
314
|
+
name: PROPAGATE_TRANSFORMS_SYSTEM,
|
|
315
|
+
queries: [],
|
|
316
|
+
fn: (world) => {
|
|
317
|
+
const result = propagateTransforms(world);
|
|
318
|
+
if (!result.ok) throw result.error;
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
export const PropagateTransformsFixed: SystemHandle<readonly []> = defineSystem({
|
|
323
|
+
name: PROPAGATE_TRANSFORMS_FIXED_SYSTEM,
|
|
324
|
+
queries: [],
|
|
325
|
+
fn: PropagateTransforms.fn,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
export function registerPropagateTransforms(
|
|
329
|
+
world: World,
|
|
330
|
+
options: { beforeSystemName?: string } = {},
|
|
331
|
+
): () => void {
|
|
332
|
+
const existing = REGISTRATION_LEASES.get(world);
|
|
333
|
+
if (existing !== undefined) {
|
|
334
|
+
existing.refs += 1;
|
|
335
|
+
let active = true;
|
|
336
|
+
return () => {
|
|
337
|
+
if (!active) return;
|
|
338
|
+
active = false;
|
|
339
|
+
existing.refs -= 1;
|
|
340
|
+
if (existing.refs === 0) {
|
|
341
|
+
world.removeSystem(FixedUpdate, PROPAGATE_TRANSFORMS_FIXED_SYSTEM);
|
|
342
|
+
world.removeSystem(Update, PROPAGATE_TRANSFORMS_SYSTEM);
|
|
343
|
+
REGISTRATION_LEASES.delete(world);
|
|
344
|
+
CACHE.delete(world);
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
if (options.beforeSystemName === undefined) {
|
|
349
|
+
world.addSystems(Update, TransformSet, [PropagateTransforms]).unwrap();
|
|
350
|
+
} else {
|
|
351
|
+
world
|
|
352
|
+
.addSystems(Update, TransformSet, [
|
|
353
|
+
{
|
|
354
|
+
name: PROPAGATE_TRANSFORMS_SYSTEM,
|
|
355
|
+
queries: [],
|
|
356
|
+
fn: PropagateTransforms.fn,
|
|
357
|
+
before: [options.beforeSystemName],
|
|
358
|
+
},
|
|
359
|
+
])
|
|
360
|
+
.unwrap();
|
|
361
|
+
}
|
|
362
|
+
world.addSystems(FixedUpdate, TransformFixedSet, [PropagateTransformsFixed]).unwrap();
|
|
363
|
+
const lease: TransformRegistrationLease = { refs: 1 };
|
|
364
|
+
REGISTRATION_LEASES.set(world, lease);
|
|
365
|
+
let active = true;
|
|
366
|
+
return () => {
|
|
367
|
+
if (!active) return;
|
|
368
|
+
active = false;
|
|
369
|
+
lease.refs -= 1;
|
|
370
|
+
if (lease.refs !== 0) return;
|
|
371
|
+
world.removeSystem(FixedUpdate, PROPAGATE_TRANSFORMS_FIXED_SYSTEM);
|
|
372
|
+
world.removeSystem(Update, PROPAGATE_TRANSFORMS_SYSTEM);
|
|
373
|
+
REGISTRATION_LEASES.delete(world);
|
|
374
|
+
CACHE.delete(world);
|
|
375
|
+
};
|
|
376
|
+
}
|