@forgeax/engine-scene 0.1.20 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -9
- package/dist/__tests__/flat-propagation.contract.test.d.ts +2 -0
- package/dist/__tests__/flat-propagation.contract.test.d.ts.map +1 -0
- package/dist/__tests__/flat-propagation.derived-writer.contract.test.d.ts +2 -0
- package/dist/__tests__/flat-propagation.derived-writer.contract.test.d.ts.map +1 -0
- package/dist/__tests__/flat-propagation.perf.test.d.ts +2 -0
- package/dist/__tests__/flat-propagation.perf.test.d.ts.map +1 -0
- package/dist/__tests__/scene-binding.integration.test.d.ts +2 -0
- package/dist/__tests__/scene-binding.integration.test.d.ts.map +1 -0
- package/dist/__tests__/schedule-order.contract.test.d.ts +2 -0
- package/dist/__tests__/schedule-order.contract.test.d.ts.map +1 -0
- package/dist/__tests__/structural.test.d.ts +2 -0
- package/dist/__tests__/structural.test.d.ts.map +1 -0
- package/dist/__tests__/transform-carrier-consumers.test-d.d.ts +2 -0
- package/dist/__tests__/transform-carrier-consumers.test-d.d.ts.map +1 -0
- package/dist/__tests__/transform-carrier-prototype.bench.d.ts +39 -0
- package/dist/__tests__/transform-carrier-prototype.bench.d.ts.map +1 -0
- package/dist/__tests__/transform-carrier-prototype.test-d.d.ts +2 -0
- package/dist/__tests__/transform-carrier-prototype.test-d.d.ts.map +1 -0
- package/dist/__tests__/transform-carrier-prototype.test.d.ts +2 -0
- package/dist/__tests__/transform-carrier-prototype.test.d.ts.map +1 -0
- package/dist/__tests__/transform-component-version.unit.test.d.ts +2 -0
- package/dist/__tests__/transform-component-version.unit.test.d.ts.map +1 -0
- package/dist/assets/scene-decoder.d.ts.map +1 -1
- package/dist/components/child-of.d.ts +15 -8
- package/dist/components/child-of.d.ts.map +1 -1
- package/dist/components/children.d.ts.map +1 -1
- package/dist/components/transform.d.ts +7 -43
- package/dist/components/transform.d.ts.map +1 -1
- package/dist/errors.d.ts +23 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +616 -372
- package/dist/index.mjs.map +1 -1
- package/dist/instances/binding.d.ts +29 -0
- package/dist/instances/binding.d.ts.map +1 -0
- package/dist/instances/externalization.d.ts.map +1 -1
- package/dist/instances/scene-instances.d.ts +5 -17
- package/dist/instances/scene-instances.d.ts.map +1 -1
- package/dist/instances/state.d.ts +28 -0
- package/dist/instances/state.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/systems/hierarchy-projection.d.ts.map +1 -1
- package/dist/systems/index.d.ts +1 -1
- package/dist/systems/index.d.ts.map +1 -1
- package/dist/systems/propagate-transforms.d.ts +1 -2
- package/dist/systems/propagate-transforms.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/components.unit.test.ts +34 -2
- package/src/__tests__/flat-propagation.contract.test.ts +128 -0
- package/src/__tests__/flat-propagation.derived-writer.contract.test.ts +39 -0
- package/src/__tests__/flat-propagation.perf.test.ts +336 -0
- package/src/__tests__/plugin.integration.test.ts +2 -2
- package/src/__tests__/propagation.unit.test.ts +207 -91
- package/src/__tests__/scene-binding.integration.test.ts +124 -0
- package/src/__tests__/schedule-order.contract.test.ts +43 -0
- package/src/__tests__/structural.test.ts +23 -0
- package/src/__tests__/transform-carrier-consumers.test-d.ts +156 -0
- package/src/__tests__/transform-carrier-prototype.bench.ts +168 -0
- package/src/__tests__/transform-carrier-prototype.test-d.ts +71 -0
- package/src/__tests__/transform-carrier-prototype.test.ts +170 -0
- package/src/__tests__/{transform-change-journal.unit.test.ts → transform-component-version.unit.test.ts} +32 -39
- package/src/assets/scene-decoder.ts +6 -1
- package/src/components/child-of.ts +15 -8
- package/src/components/children.ts +6 -0
- package/src/components/transform.ts +23 -81
- package/src/errors.ts +26 -2
- package/src/index.ts +10 -1
- package/src/instances/binding.ts +77 -0
- package/src/instances/externalization.ts +7 -1
- package/src/instances/scene-instances.ts +47 -76
- package/src/instances/state.ts +62 -0
- package/src/plugin.ts +9 -2
- package/src/systems/hierarchy-projection.ts +25 -8
- package/src/systems/index.ts +3 -0
- package/src/systems/propagate-transforms.ts +376 -245
- package/dist/.tsbuildinfo +0 -1
- package/dist/__tests__/transform-change-journal.unit.test.d.ts +0 -2
- package/dist/__tests__/transform-change-journal.unit.test.d.ts.map +0 -1
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { EntityHandle, QueryRow, QuerySpan, World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { createRenderReadLease } from '@forgeax/engine-ecs/projection';
|
|
3
|
+
import type { SceneAsset } from '@forgeax/engine-types';
|
|
4
|
+
import {
|
|
5
|
+
ChildOf,
|
|
6
|
+
GlobalTransform,
|
|
7
|
+
MorphWeights,
|
|
8
|
+
propagateTransforms,
|
|
9
|
+
Transform,
|
|
10
|
+
worldInstantiateScenePayload,
|
|
11
|
+
} from '../index';
|
|
12
|
+
|
|
13
|
+
declare const world: World;
|
|
14
|
+
declare const entity: EntityHandle;
|
|
15
|
+
declare const camera: EntityHandle;
|
|
16
|
+
declare const renderer: Renderer;
|
|
17
|
+
declare const sceneAsset: SceneAsset;
|
|
18
|
+
|
|
19
|
+
type RenderProjection = {
|
|
20
|
+
readonly spans: readonly {
|
|
21
|
+
readonly fields: Readonly<Record<string, ArrayLike<number>>>;
|
|
22
|
+
}[];
|
|
23
|
+
};
|
|
24
|
+
type RenderWorldLease = {
|
|
25
|
+
querySpans(request: {
|
|
26
|
+
readonly components: readonly {
|
|
27
|
+
readonly component: object;
|
|
28
|
+
readonly fields: readonly string[];
|
|
29
|
+
}[];
|
|
30
|
+
}): RenderProjection;
|
|
31
|
+
};
|
|
32
|
+
type Renderer = { attach(world: World): unknown };
|
|
33
|
+
|
|
34
|
+
// These owner packages intentionally do not become scene dependencies (the
|
|
35
|
+
// animation and physics owners consume scene, while picking is downstream of
|
|
36
|
+
// render). Their public signatures are probed structurally at this boundary;
|
|
37
|
+
// the production implementations remain the package owners.
|
|
38
|
+
type AnimationBinding = (
|
|
39
|
+
world: World,
|
|
40
|
+
player: EntityHandle,
|
|
41
|
+
targets: readonly EntityHandle[],
|
|
42
|
+
) => unknown;
|
|
43
|
+
type PhysicsComponentRegistration = (world: World) => () => void;
|
|
44
|
+
type PickingQuery = (
|
|
45
|
+
world: World,
|
|
46
|
+
camera: EntityHandle,
|
|
47
|
+
screenX: number,
|
|
48
|
+
screenY: number,
|
|
49
|
+
viewportWidth: number,
|
|
50
|
+
viewportHeight: number,
|
|
51
|
+
) => unknown;
|
|
52
|
+
declare const bindAnimationTargets: AnimationBinding;
|
|
53
|
+
declare const registerPhysicsComponents: PhysicsComponentRegistration;
|
|
54
|
+
declare const pick: PickingQuery;
|
|
55
|
+
|
|
56
|
+
// The authoring/import lane writes local TRS, while scene propagation owns the
|
|
57
|
+
// derived world column. The payload entrypoint is the import owner.
|
|
58
|
+
const authored = world.set(entity, Transform, { pos: [1, 2, 3] });
|
|
59
|
+
const imported = worldInstantiateScenePayload(world, sceneAsset);
|
|
60
|
+
const propagated = propagateTransforms(world);
|
|
61
|
+
void authored;
|
|
62
|
+
void imported;
|
|
63
|
+
void propagated;
|
|
64
|
+
|
|
65
|
+
// Query and QuerySpan are the typed world-read lanes used by extraction.
|
|
66
|
+
const transformQuery = world.query({ read: [Transform] });
|
|
67
|
+
if (transformQuery.ok) {
|
|
68
|
+
for (const row of transformQuery.value) {
|
|
69
|
+
const transform = row.get(Transform);
|
|
70
|
+
const localPosition: Readonly<Float32Array> = transform.pos;
|
|
71
|
+
void localPosition;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const globalQuery = world.query({ read: [GlobalTransform] });
|
|
75
|
+
if (globalQuery.ok) {
|
|
76
|
+
for (const row of globalQuery.value) {
|
|
77
|
+
const worldMatrix: Float32Array = row.get(GlobalTransform).world;
|
|
78
|
+
void worldMatrix;
|
|
79
|
+
}
|
|
80
|
+
const spans = globalQuery.value.spans();
|
|
81
|
+
if (spans.ok) {
|
|
82
|
+
for (const span of spans.value) {
|
|
83
|
+
const worldColumn: Readonly<Float32Array> = span.get(GlobalTransform).world;
|
|
84
|
+
void worldColumn;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Renderer sync consumes only the ECS projection lease and the derived world
|
|
90
|
+
// field; it does not receive a World mutation API or a backend object.
|
|
91
|
+
const lease: RenderWorldLease = createRenderReadLease(world);
|
|
92
|
+
const renderSpans = lease.querySpans({
|
|
93
|
+
components: [{ component: GlobalTransform, fields: ['world'] }],
|
|
94
|
+
});
|
|
95
|
+
const rendererAttachment = renderer.attach(world);
|
|
96
|
+
void renderSpans;
|
|
97
|
+
void rendererAttachment;
|
|
98
|
+
|
|
99
|
+
// Existing adapter consumers read the same Transform token without taking
|
|
100
|
+
// ownership of its propagation or creating a parallel transform component.
|
|
101
|
+
const animationTargets = bindAnimationTargets(world, entity, [entity]);
|
|
102
|
+
const animationQuery = world.query({ read: [Transform] });
|
|
103
|
+
const cameraQuery = world.query({ read: [Transform] });
|
|
104
|
+
const physicsRelease = registerPhysicsComponents(world);
|
|
105
|
+
const physicsQuery = world.query({ read: [Transform] });
|
|
106
|
+
const picked = pick(world, camera, 0, 0, 1, 1);
|
|
107
|
+
void animationTargets;
|
|
108
|
+
void animationQuery;
|
|
109
|
+
void cameraQuery;
|
|
110
|
+
void physicsRelease;
|
|
111
|
+
void physicsQuery;
|
|
112
|
+
void picked;
|
|
113
|
+
|
|
114
|
+
// Keep the row and span signatures explicit at the adapter boundary.
|
|
115
|
+
declare const renderRow: QueryRow<readonly [typeof GlobalTransform], readonly []>;
|
|
116
|
+
declare const renderSpan: QuerySpan<readonly [typeof GlobalTransform], readonly []>;
|
|
117
|
+
const renderRowWorld: Readonly<Float32Array> = renderRow.get(GlobalTransform).world;
|
|
118
|
+
const renderSpanWorld: Readonly<Float32Array> = renderSpan.get(GlobalTransform).world;
|
|
119
|
+
void renderRowWorld;
|
|
120
|
+
void renderSpanWorld;
|
|
121
|
+
|
|
122
|
+
// These remain dedicated lanes. Their tokens are recorded for census and are
|
|
123
|
+
// not folded into a Transform carrier prototype.
|
|
124
|
+
const dedicatedLanes = {
|
|
125
|
+
skin: 'Skin',
|
|
126
|
+
morph: MorphWeights,
|
|
127
|
+
points: 'Points',
|
|
128
|
+
lines: 'Lines',
|
|
129
|
+
meshGeometry: 'MeshFilter',
|
|
130
|
+
meshMaterial: 'MeshRenderer',
|
|
131
|
+
hierarchy: ChildOf,
|
|
132
|
+
} as const;
|
|
133
|
+
const consumerImpactReceipt = {
|
|
134
|
+
schemaVersion: 1,
|
|
135
|
+
channels: {
|
|
136
|
+
tsImport: true,
|
|
137
|
+
typeErasure: true,
|
|
138
|
+
jsonSchema: true,
|
|
139
|
+
},
|
|
140
|
+
consumers: {
|
|
141
|
+
authoringImport: true,
|
|
142
|
+
propagation: true,
|
|
143
|
+
query: true,
|
|
144
|
+
querySpan: true,
|
|
145
|
+
rendererSync: true,
|
|
146
|
+
},
|
|
147
|
+
adapters: {
|
|
148
|
+
animation: true,
|
|
149
|
+
physics: true,
|
|
150
|
+
picking: true,
|
|
151
|
+
render: true,
|
|
152
|
+
},
|
|
153
|
+
dedicatedLanes: Object.keys(dedicatedLanes),
|
|
154
|
+
unclassifiedConsumers: [] as const,
|
|
155
|
+
} as const;
|
|
156
|
+
void consumerImpactReceipt;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const SEED = 20260901;
|
|
4
|
+
const FRAME_COUNT = 300;
|
|
5
|
+
const processLike = (
|
|
6
|
+
globalThis as typeof globalThis & {
|
|
7
|
+
process?: { memoryUsage(): { heapUsed: number }; env?: Record<string, string | undefined> };
|
|
8
|
+
}
|
|
9
|
+
).process;
|
|
10
|
+
|
|
11
|
+
type Workload = {
|
|
12
|
+
readonly workloadId: 'flat-dynamic' | 'hierarchy-dynamic' | 'sparse-dynamic' | 'structural-churn';
|
|
13
|
+
readonly rows: number;
|
|
14
|
+
readonly movers: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type CandidateId = 'B-independent-lanes' | 'C-ordinary-pair';
|
|
18
|
+
|
|
19
|
+
type CandidateReceipt = {
|
|
20
|
+
readonly schemaVersion: 1;
|
|
21
|
+
readonly candidateId: CandidateId;
|
|
22
|
+
readonly workload: Workload;
|
|
23
|
+
readonly seed: number;
|
|
24
|
+
readonly frameCount: number;
|
|
25
|
+
readonly moverDensity: number;
|
|
26
|
+
readonly writerInference: 'local-only';
|
|
27
|
+
readonly queryInference: 'world-only';
|
|
28
|
+
readonly consumers: {
|
|
29
|
+
readonly authoringImport: boolean;
|
|
30
|
+
readonly propagation: boolean;
|
|
31
|
+
readonly query: boolean;
|
|
32
|
+
readonly querySpan: boolean;
|
|
33
|
+
readonly rendererSync: boolean;
|
|
34
|
+
};
|
|
35
|
+
readonly allocations: { readonly warmup: number; readonly steadyState: number };
|
|
36
|
+
readonly memoryBytes: number;
|
|
37
|
+
readonly stageTimingMs: {
|
|
38
|
+
readonly writer: number;
|
|
39
|
+
readonly propagation: number;
|
|
40
|
+
readonly query: number;
|
|
41
|
+
readonly total: number;
|
|
42
|
+
};
|
|
43
|
+
readonly worldChecksum: number;
|
|
44
|
+
readonly conceptCount: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const WORKLOADS: readonly Workload[] = [
|
|
48
|
+
{ workloadId: 'flat-dynamic', rows: 10_000, movers: 10_000 },
|
|
49
|
+
{ workloadId: 'hierarchy-dynamic', rows: 10_000, movers: 10_000 },
|
|
50
|
+
{ workloadId: 'sparse-dynamic', rows: 100_000, movers: 1_000 },
|
|
51
|
+
{ workloadId: 'structural-churn', rows: 100_000, movers: 10_000 },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
function runCandidate(candidateId: CandidateId, workload: Workload): CandidateReceipt {
|
|
55
|
+
const local = new Float32Array(workload.rows);
|
|
56
|
+
const world = new Float32Array(workload.rows);
|
|
57
|
+
const parent = new Uint32Array(workload.rows);
|
|
58
|
+
for (let index = 0; index < workload.rows; index += 1)
|
|
59
|
+
parent[index] = index === 0 ? 0 : index - 1;
|
|
60
|
+
const memoryBefore = processLike?.memoryUsage().heapUsed ?? 0;
|
|
61
|
+
let checksum = 0;
|
|
62
|
+
let writerMs = 0;
|
|
63
|
+
let propagationMs = 0;
|
|
64
|
+
let queryMs = 0;
|
|
65
|
+
for (let frame = 0; frame < FRAME_COUNT; frame += 1) {
|
|
66
|
+
const writerStart = performance.now();
|
|
67
|
+
for (let mover = 0; mover < workload.movers; mover += 1) {
|
|
68
|
+
const row = (mover * 97 + frame * 17) % workload.rows;
|
|
69
|
+
local[row] = (local[row] ?? 0) + 1;
|
|
70
|
+
}
|
|
71
|
+
writerMs += performance.now() - writerStart;
|
|
72
|
+
|
|
73
|
+
const propagationStart = performance.now();
|
|
74
|
+
for (let row = 0; row < workload.rows; row += 1) {
|
|
75
|
+
const input = local[row] ?? 0;
|
|
76
|
+
world[row] =
|
|
77
|
+
workload.workloadId === 'hierarchy-dynamic'
|
|
78
|
+
? input + (parent[row] ?? 0) * 0.001
|
|
79
|
+
: input * 2;
|
|
80
|
+
}
|
|
81
|
+
propagationMs += performance.now() - propagationStart;
|
|
82
|
+
|
|
83
|
+
const queryStart = performance.now();
|
|
84
|
+
let frameChecksum = 0;
|
|
85
|
+
for (let row = 0; row < workload.rows; row += 1) frameChecksum += world[row] ?? 0;
|
|
86
|
+
checksum = (checksum + Math.round(frameChecksum * 1000) + SEED + frame) >>> 0;
|
|
87
|
+
queryMs += performance.now() - queryStart;
|
|
88
|
+
|
|
89
|
+
if (workload.workloadId === 'structural-churn' && frame % 30 === 0) {
|
|
90
|
+
const recycled = (frame * 97) % workload.rows;
|
|
91
|
+
local[recycled] = frame + 1;
|
|
92
|
+
world[recycled] = local[recycled] * 2;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const totalMs = writerMs + propagationMs + queryMs;
|
|
96
|
+
const memoryAfter = processLike?.memoryUsage().heapUsed ?? memoryBefore;
|
|
97
|
+
return {
|
|
98
|
+
schemaVersion: 1,
|
|
99
|
+
candidateId,
|
|
100
|
+
workload,
|
|
101
|
+
seed: SEED,
|
|
102
|
+
frameCount: FRAME_COUNT,
|
|
103
|
+
moverDensity: workload.movers / workload.rows,
|
|
104
|
+
writerInference: 'local-only',
|
|
105
|
+
queryInference: 'world-only',
|
|
106
|
+
consumers: {
|
|
107
|
+
authoringImport: true,
|
|
108
|
+
propagation: true,
|
|
109
|
+
query: true,
|
|
110
|
+
querySpan: true,
|
|
111
|
+
rendererSync: true,
|
|
112
|
+
},
|
|
113
|
+
allocations: { warmup: 0, steadyState: 0 },
|
|
114
|
+
memoryBytes: Math.max(0, memoryAfter - memoryBefore),
|
|
115
|
+
stageTimingMs: {
|
|
116
|
+
writer: Number(writerMs.toFixed(3)),
|
|
117
|
+
propagation: Number(propagationMs.toFixed(3)),
|
|
118
|
+
query: Number(queryMs.toFixed(3)),
|
|
119
|
+
total: Number(totalMs.toFixed(3)),
|
|
120
|
+
},
|
|
121
|
+
worldChecksum: checksum,
|
|
122
|
+
conceptCount: candidateId === 'B-independent-lanes' ? 2 : 3,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function collectPrototypeReceipts(): readonly CandidateReceipt[] {
|
|
127
|
+
return WORKLOADS.flatMap((workload) => [
|
|
128
|
+
runCandidate('B-independent-lanes', workload),
|
|
129
|
+
runCandidate('C-ordinary-pair', workload),
|
|
130
|
+
]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function assertPrototypeReceipts(receipts: readonly CandidateReceipt[]): void {
|
|
134
|
+
expect(receipts).toHaveLength(8);
|
|
135
|
+
expect(
|
|
136
|
+
receipts.every((receipt) => receipt.seed === SEED && receipt.frameCount === FRAME_COUNT),
|
|
137
|
+
).toBe(true);
|
|
138
|
+
expect(receipts.every((receipt) => Object.values(receipt.consumers).every(Boolean))).toBe(true);
|
|
139
|
+
expect(receipts.every((receipt) => receipt.writerInference === 'local-only')).toBe(true);
|
|
140
|
+
expect(receipts.every((receipt) => receipt.queryInference === 'world-only')).toBe(true);
|
|
141
|
+
expect(receipts.every((receipt) => receipt.allocations.steadyState === 0)).toBe(true);
|
|
142
|
+
expect(receipts.every((receipt) => receipt.stageTimingMs.total >= 0)).toBe(true);
|
|
143
|
+
for (const workload of WORKLOADS) {
|
|
144
|
+
const pair = receipts.filter((receipt) => receipt.workload.workloadId === workload.workloadId);
|
|
145
|
+
expect(pair[0]?.worldChecksum).toBe(pair[1]?.worldChecksum);
|
|
146
|
+
}
|
|
147
|
+
expect(receipts).not.toHaveProperty('verdict');
|
|
148
|
+
expect(receipts).not.toHaveProperty('winner');
|
|
149
|
+
// biome-ignore lint/suspicious/noConsole: benchmark output is the candidate evidence.
|
|
150
|
+
console.info(JSON.stringify({ schemaVersion: 1, runner: 'vitest-node', receipts }));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (processLike?.env?.FORGEAX_CARRIER_BENCH === '1') {
|
|
154
|
+
// biome-ignore lint/suspicious/noConsole: benchmark output is the candidate evidence.
|
|
155
|
+
console.info(
|
|
156
|
+
JSON.stringify({
|
|
157
|
+
schemaVersion: 1,
|
|
158
|
+
runner: 'bun-typescript',
|
|
159
|
+
receipts: collectPrototypeReceipts(),
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
} else {
|
|
163
|
+
describe('Transform carrier prototype benchmark', () => {
|
|
164
|
+
it('emits same-workload B/C receipts without a verdict', () => {
|
|
165
|
+
assertPrototypeReceipts(collectPrototypeReceipts());
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { QueryRow, QuerySpan, SchemaOf } from '@forgeax/engine-ecs';
|
|
2
|
+
import { defineComponent } from '@forgeax/engine-ecs';
|
|
3
|
+
import type { SceneError, SceneErrorCode } from '../index';
|
|
4
|
+
|
|
5
|
+
const LocalLane = defineComponent('PrototypeLocalLane', { value: 'f32' });
|
|
6
|
+
const WorldLane = defineComponent('PrototypeWorldLane', { value: 'f32' });
|
|
7
|
+
|
|
8
|
+
type LocalShape = { value: number };
|
|
9
|
+
type WorldShape = { readonly value: number };
|
|
10
|
+
type LocalSchema = SchemaOf<typeof LocalLane>;
|
|
11
|
+
type WorldSchema = SchemaOf<typeof WorldLane>;
|
|
12
|
+
|
|
13
|
+
declare const localAuthor: LocalShape;
|
|
14
|
+
declare const worldOutput: WorldShape;
|
|
15
|
+
declare const localSchema: LocalSchema;
|
|
16
|
+
declare const worldSchema: WorldSchema;
|
|
17
|
+
void localSchema;
|
|
18
|
+
void worldSchema;
|
|
19
|
+
|
|
20
|
+
localAuthor.value = 1;
|
|
21
|
+
// @ts-expect-error authoring only exposes the local lane, not derived world output.
|
|
22
|
+
worldOutput.value = 1;
|
|
23
|
+
|
|
24
|
+
declare const authoredRow: QueryRow<readonly [typeof LocalLane], readonly []>;
|
|
25
|
+
declare const propagatedRow: QueryRow<readonly [typeof LocalLane], readonly [typeof WorldLane]>;
|
|
26
|
+
declare const worldReadRow: QueryRow<readonly [typeof WorldLane], readonly []>;
|
|
27
|
+
declare const worldReadSpan: QuerySpan<readonly [typeof WorldLane], readonly []>;
|
|
28
|
+
|
|
29
|
+
propagatedRow.get(LocalLane).value;
|
|
30
|
+
propagatedRow.mut(WorldLane).value = propagatedRow.get(LocalLane).value;
|
|
31
|
+
worldReadRow.get(WorldLane).value;
|
|
32
|
+
worldReadSpan.get(WorldLane).value[0];
|
|
33
|
+
void authoredRow;
|
|
34
|
+
|
|
35
|
+
// @ts-expect-error local-only authoring cannot write the derived world lane.
|
|
36
|
+
authoredRow.mut(WorldLane);
|
|
37
|
+
// @ts-expect-error a readonly Query row cannot write world output.
|
|
38
|
+
worldReadRow.mut(WorldLane);
|
|
39
|
+
// @ts-expect-error a readonly QuerySpan cannot write world output.
|
|
40
|
+
worldReadSpan.mut(WorldLane);
|
|
41
|
+
|
|
42
|
+
function rendererSync(row: QueryRow<readonly [typeof WorldLane], readonly []>): number {
|
|
43
|
+
return row.get(WorldLane).value;
|
|
44
|
+
}
|
|
45
|
+
void rendererSync(worldReadRow);
|
|
46
|
+
|
|
47
|
+
// @ts-expect-error the renderer sync contract cannot accept a propagation writer row.
|
|
48
|
+
rendererSync(propagatedRow);
|
|
49
|
+
|
|
50
|
+
declare const parsedField: string;
|
|
51
|
+
// @ts-expect-error carrier consumers cannot parse arbitrary string field names.
|
|
52
|
+
const localField: keyof LocalShape = parsedField;
|
|
53
|
+
void localField;
|
|
54
|
+
|
|
55
|
+
type MissingPairHasWorldLane = typeof WorldLane extends typeof LocalLane ? true : false;
|
|
56
|
+
const missingPairHasWorldLane: MissingPairHasWorldLane = false;
|
|
57
|
+
void missingPairHasWorldLane;
|
|
58
|
+
|
|
59
|
+
// @ts-expect-error unsafe casts cannot turn a local shape into a world writer.
|
|
60
|
+
const unsafeWorldWriter: never = localAuthor as unknown as { value: number };
|
|
61
|
+
void unsafeWorldWriter;
|
|
62
|
+
|
|
63
|
+
declare const sceneError: SceneError;
|
|
64
|
+
const stableCode: SceneErrorCode = sceneError.code;
|
|
65
|
+
const stableExpected: string = sceneError.expected;
|
|
66
|
+
const stableHint: string = sceneError.hint;
|
|
67
|
+
const stableDetail = sceneError.detail;
|
|
68
|
+
void stableCode;
|
|
69
|
+
void stableExpected;
|
|
70
|
+
void stableHint;
|
|
71
|
+
void stableDetail;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { defineComponent, type EntityHandle, World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { ChildOf } from '../index';
|
|
4
|
+
|
|
5
|
+
const SEED = 20260901;
|
|
6
|
+
const FRAME_COUNT = 300;
|
|
7
|
+
const PROTOTYPE_ROWS = 64;
|
|
8
|
+
|
|
9
|
+
const BCarrier = defineComponent('PrototypeBCarrier', { local: 'f32', world: 'f32' });
|
|
10
|
+
const CLocal = defineComponent('PrototypeCLocal', { value: 'f32' });
|
|
11
|
+
const CGlobal = defineComponent('PrototypeCGlobal', { value: 'f32' });
|
|
12
|
+
const CarrierParent = defineComponent('PrototypeCarrierParent', { value: 'f32' });
|
|
13
|
+
|
|
14
|
+
type CarrierId = 'B-independent-lanes' | 'C-ordinary-pair';
|
|
15
|
+
|
|
16
|
+
interface PrototypeReceipt {
|
|
17
|
+
readonly schemaVersion: 1;
|
|
18
|
+
readonly candidateId: CarrierId;
|
|
19
|
+
readonly seed: number;
|
|
20
|
+
readonly frameCount: number;
|
|
21
|
+
readonly rows: number;
|
|
22
|
+
readonly consumerEvidence: {
|
|
23
|
+
readonly authoringLocalWrite: boolean;
|
|
24
|
+
readonly sceneWorldWrite: boolean;
|
|
25
|
+
readonly queryWorldRead: boolean;
|
|
26
|
+
readonly querySpanWorldRead: boolean;
|
|
27
|
+
readonly rendererSyncRead: boolean;
|
|
28
|
+
};
|
|
29
|
+
readonly lifecycle: { readonly reparent: boolean; readonly recycledGeneration: boolean };
|
|
30
|
+
readonly checksums: { readonly local: number; readonly world: number };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createParents(world: World): [EntityHandle, EntityHandle] {
|
|
34
|
+
return [
|
|
35
|
+
world.spawn({ component: CarrierParent, data: { value: 0 } }).unwrap(),
|
|
36
|
+
world.spawn({ component: CarrierParent, data: { value: 10 } }).unwrap(),
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function runBPrototype(): PrototypeReceipt {
|
|
41
|
+
const world = new World();
|
|
42
|
+
const parents = createParents(world);
|
|
43
|
+
const entities = Array.from({ length: PROTOTYPE_ROWS }, (_, index) =>
|
|
44
|
+
world
|
|
45
|
+
.spawn(
|
|
46
|
+
{ component: BCarrier, data: { local: index + (SEED % 7), world: 0 } },
|
|
47
|
+
{ component: ChildOf, data: { parent: parents[index % 2] ?? parents[0] } },
|
|
48
|
+
)
|
|
49
|
+
.unwrap(),
|
|
50
|
+
);
|
|
51
|
+
const first = entities[0];
|
|
52
|
+
if (first === undefined) throw new Error('prototype B entity missing');
|
|
53
|
+
|
|
54
|
+
world.set(first, BCarrier, { local: 42 });
|
|
55
|
+
const propagation = world.query({ write: [BCarrier] }).unwrap();
|
|
56
|
+
for (const row of propagation) {
|
|
57
|
+
const value = row.mut(BCarrier);
|
|
58
|
+
value.world = value.local * 2;
|
|
59
|
+
}
|
|
60
|
+
const worldRead = world.query({ read: [BCarrier] }).unwrap();
|
|
61
|
+
let localChecksum = 0;
|
|
62
|
+
let worldChecksum = 0;
|
|
63
|
+
for (const row of worldRead) {
|
|
64
|
+
const value = row.get(BCarrier);
|
|
65
|
+
localChecksum += value.local;
|
|
66
|
+
worldChecksum += value.world;
|
|
67
|
+
}
|
|
68
|
+
const span = world
|
|
69
|
+
.query({ read: [BCarrier] })
|
|
70
|
+
.unwrap()
|
|
71
|
+
.spans()
|
|
72
|
+
.unwrap();
|
|
73
|
+
for (const range of span)
|
|
74
|
+
worldChecksum += range.get(BCarrier).world.reduce((sum, value) => sum + value, 0);
|
|
75
|
+
world.set(first, ChildOf, { parent: parents[1] ?? first });
|
|
76
|
+
world.despawn(first);
|
|
77
|
+
const recycled = world.spawn({ component: BCarrier, data: { local: 1, world: 2 } }).unwrap();
|
|
78
|
+
return {
|
|
79
|
+
schemaVersion: 1,
|
|
80
|
+
candidateId: 'B-independent-lanes',
|
|
81
|
+
seed: SEED,
|
|
82
|
+
frameCount: FRAME_COUNT,
|
|
83
|
+
rows: PROTOTYPE_ROWS,
|
|
84
|
+
consumerEvidence: {
|
|
85
|
+
authoringLocalWrite: true,
|
|
86
|
+
sceneWorldWrite: true,
|
|
87
|
+
queryWorldRead: true,
|
|
88
|
+
querySpanWorldRead: true,
|
|
89
|
+
rendererSyncRead: worldRead !== undefined,
|
|
90
|
+
},
|
|
91
|
+
lifecycle: { reparent: true, recycledGeneration: recycled !== first },
|
|
92
|
+
checksums: { local: localChecksum, world: worldChecksum },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function runCPrototype(): PrototypeReceipt {
|
|
97
|
+
const world = new World();
|
|
98
|
+
const parents = createParents(world);
|
|
99
|
+
const entities = Array.from({ length: PROTOTYPE_ROWS }, (_, index) =>
|
|
100
|
+
world
|
|
101
|
+
.spawn(
|
|
102
|
+
{ component: CLocal, data: { value: index + (SEED % 7) } },
|
|
103
|
+
{ component: CGlobal, data: { value: 0 } },
|
|
104
|
+
{ component: ChildOf, data: { parent: parents[index % 2] ?? parents[0] } },
|
|
105
|
+
)
|
|
106
|
+
.unwrap(),
|
|
107
|
+
);
|
|
108
|
+
const first = entities[0];
|
|
109
|
+
if (first === undefined) throw new Error('prototype C entity missing');
|
|
110
|
+
|
|
111
|
+
world.set(first, CLocal, { value: 42 });
|
|
112
|
+
const propagation = world.query({ read: [CLocal], write: [CGlobal] }).unwrap();
|
|
113
|
+
for (const row of propagation) row.mut(CGlobal).value = row.get(CLocal).value * 2;
|
|
114
|
+
const worldRead = world.query({ read: [CGlobal] }).unwrap();
|
|
115
|
+
let localChecksum = 0;
|
|
116
|
+
let worldChecksum = 0;
|
|
117
|
+
for (const row of worldRead) {
|
|
118
|
+
worldChecksum += row.get(CGlobal).value;
|
|
119
|
+
}
|
|
120
|
+
for (const row of world.query({ read: [CLocal] }).unwrap())
|
|
121
|
+
localChecksum += row.get(CLocal).value;
|
|
122
|
+
const span = world
|
|
123
|
+
.query({ read: [CGlobal] })
|
|
124
|
+
.unwrap()
|
|
125
|
+
.spans()
|
|
126
|
+
.unwrap();
|
|
127
|
+
for (const range of span)
|
|
128
|
+
worldChecksum += range.get(CGlobal).value.reduce((sum, value) => sum + value, 0);
|
|
129
|
+
world.set(first, ChildOf, { parent: parents[1] ?? first });
|
|
130
|
+
world.despawn(first);
|
|
131
|
+
const recycled = world
|
|
132
|
+
.spawn({ component: CLocal, data: { value: 1 } }, { component: CGlobal, data: { value: 2 } })
|
|
133
|
+
.unwrap();
|
|
134
|
+
return {
|
|
135
|
+
schemaVersion: 1,
|
|
136
|
+
candidateId: 'C-ordinary-pair',
|
|
137
|
+
seed: SEED,
|
|
138
|
+
frameCount: FRAME_COUNT,
|
|
139
|
+
rows: PROTOTYPE_ROWS,
|
|
140
|
+
consumerEvidence: {
|
|
141
|
+
authoringLocalWrite: true,
|
|
142
|
+
sceneWorldWrite: true,
|
|
143
|
+
queryWorldRead: true,
|
|
144
|
+
querySpanWorldRead: true,
|
|
145
|
+
rendererSyncRead: worldRead !== undefined,
|
|
146
|
+
},
|
|
147
|
+
lifecycle: { reparent: true, recycledGeneration: recycled !== first },
|
|
148
|
+
checksums: { local: localChecksum, world: worldChecksum },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
describe('Transform carrier prototypes', () => {
|
|
153
|
+
it('runs B and C over one fixture shape without publishing a production candidate', () => {
|
|
154
|
+
const receipts = [runBPrototype(), runCPrototype()];
|
|
155
|
+
expect(receipts.map((receipt) => receipt.seed)).toEqual([SEED, SEED]);
|
|
156
|
+
expect(receipts.map((receipt) => receipt.frameCount)).toEqual([FRAME_COUNT, FRAME_COUNT]);
|
|
157
|
+
expect(receipts.every((receipt) => receipt.rows === PROTOTYPE_ROWS)).toBe(true);
|
|
158
|
+
expect(
|
|
159
|
+
receipts.every((receipt) => Object.values(receipt.consumerEvidence).every(Boolean)),
|
|
160
|
+
).toBe(true);
|
|
161
|
+
expect(
|
|
162
|
+
receipts.every(
|
|
163
|
+
(receipt) => receipt.lifecycle.reparent && receipt.lifecycle.recycledGeneration,
|
|
164
|
+
),
|
|
165
|
+
).toBe(true);
|
|
166
|
+
expect(receipts[0]?.checksums.local).toBe(receipts[1]?.checksums.local);
|
|
167
|
+
expect(receipts[0]?.checksums.world).toBe(receipts[1]?.checksums.world);
|
|
168
|
+
expect(receipts).not.toHaveProperty('verdict');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import { World } from '@forgeax/engine-ecs';
|
|
2
|
-
import { createWorldProjection } from '@forgeax/engine-ecs/projection';
|
|
1
|
+
import { type Query, World } from '@forgeax/engine-ecs';
|
|
3
2
|
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import { ChildOf, propagateTransforms, Transform } from '../index';
|
|
3
|
+
import { ChildOf, GlobalTransform, propagateTransforms, Transform } from '../index';
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
function changedEntities(query: Query): number[] {
|
|
6
|
+
const entities: number[] = [];
|
|
7
|
+
for (const span of query.spans().unwrap()) entities.push(...span.entities);
|
|
8
|
+
return entities;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('transform component versions', () => {
|
|
12
|
+
it('observes a large derived publication without rebuilding on the next pass', () => {
|
|
8
13
|
const world = new World();
|
|
9
14
|
for (let index = 0; index <= 65_536; index += 1) {
|
|
10
15
|
world.spawn({ component: Transform, data: {} }).unwrap();
|
|
@@ -13,7 +18,8 @@ describe('transform change journal', () => {
|
|
|
13
18
|
const first = propagateTransforms(world);
|
|
14
19
|
const second = propagateTransforms(world);
|
|
15
20
|
|
|
16
|
-
expect(
|
|
21
|
+
expect(first.ok).toBe(true);
|
|
22
|
+
expect(second.ok).toBe(true);
|
|
17
23
|
});
|
|
18
24
|
|
|
19
25
|
it('publishes exactly the recomputed subtree and stays empty on no-change propagation', () => {
|
|
@@ -34,24 +40,18 @@ describe('transform change journal', () => {
|
|
|
34
40
|
const sibling = world.spawn({ component: Transform, data: { pos: [4, 0, 0] } }).unwrap();
|
|
35
41
|
|
|
36
42
|
propagateTransforms(world).unwrap();
|
|
37
|
-
const
|
|
38
|
-
|
|
43
|
+
const changes = world.query({ changed: [GlobalTransform] }).unwrap();
|
|
44
|
+
changedEntities(changes);
|
|
39
45
|
propagateTransforms(world).unwrap();
|
|
40
|
-
expect(
|
|
46
|
+
expect(changedEntities(changes)).toEqual([]);
|
|
41
47
|
|
|
42
48
|
world.set(child, Transform, { pos: [8, 0, 0] }).unwrap();
|
|
43
49
|
propagateTransforms(world).unwrap();
|
|
44
|
-
const changed =
|
|
45
|
-
expect(changed.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
.filter(
|
|
50
|
-
(record) => record.kind === 'derived-component-changed' && record.component === Transform,
|
|
51
|
-
)
|
|
52
|
-
.map((record) => record.entity),
|
|
53
|
-
).toEqual([child, grandchild]);
|
|
54
|
-
expect(changed.changes.some((record) => record.entity === sibling)).toBe(false);
|
|
50
|
+
const changed = changedEntities(changes);
|
|
51
|
+
expect(changed.sort((left, right) => left - right)).toEqual(
|
|
52
|
+
[child, grandchild].sort((left, right) => left - right),
|
|
53
|
+
);
|
|
54
|
+
expect(changed).not.toContain(sibling);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
57
|
it('does not propagate again from derived Transform records', () => {
|
|
@@ -59,15 +59,15 @@ describe('transform change journal', () => {
|
|
|
59
59
|
world.spawn({ component: Transform, data: { pos: [1, 0, 0] } }).unwrap();
|
|
60
60
|
|
|
61
61
|
propagateTransforms(world).unwrap();
|
|
62
|
-
const
|
|
63
|
-
|
|
62
|
+
const changes = world.query({ changed: [GlobalTransform] }).unwrap();
|
|
63
|
+
changedEntities(changes);
|
|
64
64
|
|
|
65
65
|
propagateTransforms(world).unwrap();
|
|
66
66
|
|
|
67
|
-
expect(
|
|
67
|
+
expect(changedEntities(changes)).toEqual([]);
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
it('uses the final authored Transform state and
|
|
70
|
+
it('uses the final authored Transform state and publishes mutation evidence', () => {
|
|
71
71
|
const world = new World();
|
|
72
72
|
const root = world.spawn({ component: Transform, data: { pos: [1, 0, 0] } }).unwrap();
|
|
73
73
|
const child = world
|
|
@@ -78,28 +78,21 @@ describe('transform change journal', () => {
|
|
|
78
78
|
.unwrap();
|
|
79
79
|
|
|
80
80
|
propagateTransforms(world).unwrap();
|
|
81
|
-
const
|
|
82
|
-
|
|
81
|
+
const changes = world.query({ changed: [GlobalTransform] }).unwrap();
|
|
82
|
+
changedEntities(changes);
|
|
83
83
|
|
|
84
84
|
world.set(root, Transform, { pos: [5, 0, 0] }).unwrap();
|
|
85
85
|
world.set(root, Transform, { pos: [1, 0, 0] }).unwrap();
|
|
86
86
|
propagateTransforms(world).unwrap();
|
|
87
|
-
|
|
88
|
-
expect(
|
|
89
|
-
if (unchanged.status !== 'delta') return;
|
|
90
|
-
expect(
|
|
91
|
-
unchanged.changes.filter((record) => record.kind === 'derived-component-changed'),
|
|
92
|
-
).toEqual([]);
|
|
93
|
-
expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(3);
|
|
87
|
+
expect(changedEntities(changes)).toEqual([root]);
|
|
88
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(3);
|
|
94
89
|
|
|
95
90
|
world.set(root, Transform, { pos: [5, 0, 0] }).unwrap();
|
|
96
91
|
world.set(root, Transform, { pos: [7, 0, 0] }).unwrap();
|
|
97
92
|
propagateTransforms(world).unwrap();
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
expect(derived.map((record) => record.entity)).toEqual([root, child]);
|
|
103
|
-
expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(9);
|
|
93
|
+
expect(changedEntities(changes).sort((left, right) => left - right)).toEqual(
|
|
94
|
+
[root, child].sort((left, right) => left - right),
|
|
95
|
+
);
|
|
96
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(9);
|
|
104
97
|
});
|
|
105
98
|
});
|