@forgeax/engine-runtime 0.1.28 → 0.1.29
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 +98 -0
- package/dist/collect-scene-asset.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +480 -15
- package/dist/index.mjs.map +1 -1
- package/dist/scene-utils/mount-override-fold.d.ts +3 -1
- package/dist/scene-utils/mount-override-fold.d.ts.map +1 -1
- package/package.json +37 -32
- package/src/__tests__/animation-graph-collect-roundtrip.test.ts +4 -2
- package/src/__tests__/asset-registry-aabb.cases.unit.test.ts +5 -4
- package/src/__tests__/asset-registry-guid-reverse.test.ts +22 -27
- package/src/__tests__/asset-registry-mounts.test.ts +174 -734
- package/src/__tests__/asset-registry-scene.cases.unit.test.ts +9 -18
- package/src/__tests__/asset-registry.recursive.spec.ts +23 -29
- package/src/__tests__/camera-ortho.cases.unit.test.ts +12 -3
- package/src/__tests__/collect-scene-asset.test.ts +96 -113
- package/src/__tests__/collect-scene-mount-collapse.test.ts +64 -66
- package/src/__tests__/collect-transient-children-absent.test.ts +11 -17
- package/src/__tests__/collect-transient-roundtrip.test.ts +12 -13
- package/src/__tests__/collect-transient-skip.test.ts +20 -27
- package/src/__tests__/components.test-d.ts +17 -1
- package/src/__tests__/components.unit.test.ts +75 -27
- package/src/__tests__/composite-skybox-cross-world.browser.test.ts +4 -2
- package/src/__tests__/dawn/instances-per-instance-pbr.dawn.test.ts +8 -4
- package/src/__tests__/dawn/instances-shadow.dawn.test.ts +4 -1
- package/src/__tests__/dawn/instances-uniform-fallback.dawn.test.ts +66 -92
- package/src/__tests__/dawn/material-cooked-fixture.dawn.test.ts +200 -4
- package/src/__tests__/errors.unit.test.ts +13 -0
- package/src/__tests__/feature-depth-material.dawn.test.ts +23 -0
- package/src/__tests__/feature-depth-material.fixture.ts +473 -0
- package/src/__tests__/fullscreen-post-process-pass.dawn.test.ts +14 -14
- package/src/__tests__/geometry.unit.test.ts +42 -43
- package/src/__tests__/gpu-resource-release.cases.unit.test.ts +16 -9
- package/src/__tests__/handle-migration.test.ts +0 -1
- package/src/__tests__/instances-renderer-lifecycle.integration.test.ts +20 -14
- package/src/__tests__/instances.test-d.ts +13 -52
- package/src/__tests__/instantiate-mount-deferred-wiring.test.ts +42 -80
- package/src/__tests__/lazy-catalog.cases.unit.test.ts +17 -3
- package/src/__tests__/loader-registry.cases.unit.test.ts +4 -4
- package/src/__tests__/material-publication.browser.test.ts +29 -0
- package/src/__tests__/material-publication.commands.ts +23 -0
- package/src/__tests__/material-publication.dawn.test.ts +24 -0
- package/src/__tests__/material-publication.fixture.ts +349 -0
- package/src/__tests__/material-publication.server.ts +109 -0
- package/src/__tests__/parse-scene-payload-refs.cases.unit.test.ts +73 -96
- package/src/__tests__/pbr-pipeline.unit.test.ts +25 -11
- package/src/__tests__/point-light-shadow.browser.test.ts +8 -2
- package/src/__tests__/point-light-shadow.unit.test.ts +37 -8
- package/src/__tests__/record-all-topology.cases.unit.test.ts +52 -0
- package/src/__tests__/record-draw-branch.cases.unit.test.ts +1 -0
- package/src/__tests__/record-worldid-isolation.test.ts +47 -28
- package/src/__tests__/render-error-exhaustive.test-d.ts +24 -0
- package/src/__tests__/render-system-extract-dispatch-ordering.test.ts +15 -4
- package/src/__tests__/render-system-extract.test.ts +61 -0
- package/src/__tests__/render-system-extract.unit.test.ts +21 -7
- package/src/__tests__/render-system-fold-bucket.unit.test.ts +33 -1
- package/src/__tests__/render-system-fold-material.unit.test.ts +32 -6
- package/src/__tests__/render-system-fold-modegate.unit.test.ts +26 -4
- package/src/__tests__/render-system-fold.dawn.test.ts +4 -1
- package/src/__tests__/render-system-mega.test.ts +50 -62
- package/src/__tests__/renderer-device-loss-provider-feasibility.dawn.test.ts +0 -1
- package/src/__tests__/renderer-device-loss-recovery.dawn.test.ts +0 -3
- package/src/__tests__/renderer-draw-world.cases.unit.test.ts +16 -513
- package/src/__tests__/resolve-scene-guids.cases.unit.test.ts +48 -43
- package/src/__tests__/rhi-null-command-flow.unit.test.ts +9 -7
- package/src/__tests__/roots-to-scene-asset.test.ts +27 -28
- package/src/__tests__/roundtrip-primitive.test.ts +24 -23
- package/src/__tests__/scene-equirect-roundtrip.test.ts +5 -16
- package/src/__tests__/scene-instantiate-cycle-runtime.test.ts +13 -29
- package/src/__tests__/scene-mount-fixed-point.test.ts +104 -227
- package/src/__tests__/scene-mount-roundtrip.test.ts +95 -961
- package/src/__tests__/scene-mount-wrapper-roundtrip.test.ts +36 -182
- package/src/__tests__/scene-sugar-instantiate.test.ts +20 -15
- package/src/__tests__/serialize-mounts-roundtrip.test.ts +108 -492
- package/src/__tests__/serialize-scene-asset.test.ts +36 -50
- package/src/__tests__/shadow-caster-empty-schema.test.ts +3 -1
- package/src/__tests__/shadow-fields-observable.dawn.test.ts +34 -19
- package/src/__tests__/shadow-mesh-ssbo-order.test.ts +5 -12
- package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +6 -4
- package/src/__tests__/skin-m2-extract-happy.cases.unit.test.ts +50 -2
- package/src/__tests__/skin-m2-extract-joint-error.cases.unit.test.ts +50 -2
- package/src/__tests__/skin-m2-extract-null-assets.cases.unit.test.ts +50 -2
- package/src/__tests__/skin-m2-extract-skeleton-error.cases.unit.test.ts +50 -2
- package/src/__tests__/skin-palette-extract.cases.unit.test.ts +2 -0
- package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +93 -48
- package/src/__tests__/skylight-pipeline-layout.cases.unit.test.ts +6 -2
- package/src/__tests__/sparse-tag-scene-roundtrip.test.ts +1 -1
- package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -3
- package/src/__tests__/template-game-default-api-contract.test.ts +21 -21
- package/src/__tests__/tonemap-hdr-target.cases.unit.test.ts +8 -5
- package/src/__tests__/tonemap-pipeline-split.cases.unit.test.ts +3 -0
- package/src/__tests__/transient-fields-collect.test.ts +4 -4
- package/src/__tests__/transient-runtime-behavior.test.ts +9 -9
- package/src/__tests__/vfx-depth-material.dawn.test.ts +42 -0
- package/src/__tests__/vfx-mesh-lighting.browser.test.ts +16 -0
- package/src/__tests__/vfx-mesh-lighting.commands.ts +14 -0
- package/src/__tests__/vfx-mesh-lighting.dawn.test.ts +16 -0
- package/src/__tests__/vfx-mesh-lighting.fixture.ts +883 -0
- package/src/__tests__/wave1-rendering-materials.browser.test.ts +617 -0
- package/src/__tests__/wave1-rendering-p0.browser.test.ts +751 -0
- package/src/__tests__/wave1-rendering-recovery.browser.test.ts +666 -0
- package/src/__tests__/wave1-shadow-diagnostic.browser.test.ts +582 -0
- package/src/__tests__/world-scene-despawn.test.ts +8 -8
- package/src/__tests__/world-scene-instantiate.test.ts +23 -29
- package/src/__tests__/world-scene-nested.test.ts +90 -163
- package/src/__tests__/world-scene-override.test.ts +17 -16
- package/src/__tests__/zero-compression-zero-load.integration.test.ts +1 -0
- package/src/collect-scene-asset.ts +637 -19
- package/src/components/__tests__/sprite-animation.test-d.ts +2 -2
- package/src/index.ts +1 -0
- package/src/scene-utils/mount-override-fold.ts +5 -14
- package/src/__tests__/node-shims.d.ts +0 -31
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Verifies that every cache write/read site in the record stage that was
|
|
4
4
|
// identified in plan-strategy D-1a as needing worldEntityKey compositing
|
|
5
|
-
// actually uses
|
|
5
|
+
// actually uses a world-aware key (worldEntityKey or
|
|
6
|
+
// instanceCollectionCacheKey), not a bare entityKey / cacheKey.
|
|
6
7
|
//
|
|
7
8
|
// Anchors:
|
|
8
9
|
// plan-tasks.json m2 (patch assignment)
|
|
@@ -15,7 +16,7 @@
|
|
|
15
16
|
// part of the contract and must not make this gate stale after extraction.
|
|
16
17
|
//
|
|
17
18
|
// Each site check uses a line-range window: we read the source file and verify
|
|
18
|
-
// that within the line range, a
|
|
19
|
+
// that within the line range, a world-aware key call appears on a line
|
|
19
20
|
// that contains the target key expression. This directly falsifies the bug
|
|
20
21
|
// "site uses bare key" — when a site uses a bare key, the worldEntityKey
|
|
21
22
|
// check will fail.
|
|
@@ -27,12 +28,12 @@ import { describe, expect, it } from 'vitest';
|
|
|
27
28
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
|
-
* Verify that a cache site uses a
|
|
31
|
+
* Verify that a cache site uses a world-aware composite key.
|
|
31
32
|
* Scans the source file around the given line (within a window) for both
|
|
32
33
|
* `worldEntityKey` and the cache operation (`targetPattern` such as
|
|
33
34
|
* `instanceBuffers.get`, etc.).
|
|
34
35
|
*/
|
|
35
|
-
function
|
|
36
|
+
function expectWorldAwareCacheKeyAt(
|
|
36
37
|
filePath: string,
|
|
37
38
|
line: number,
|
|
38
39
|
description: string,
|
|
@@ -56,7 +57,7 @@ function expectWorldEntityKeyAt(
|
|
|
56
57
|
nearby.push(` ${i + 1}: ${lines[i]}`);
|
|
57
58
|
}
|
|
58
59
|
expect.fail(
|
|
59
|
-
`${description}:
|
|
60
|
+
`${description}: world-aware cache key NOT found near line ${line} in ${filePath}\nNearby lines:\n${nearby.join('\n')}`,
|
|
60
61
|
);
|
|
61
62
|
}
|
|
62
63
|
|
|
@@ -74,21 +75,21 @@ function findLine(filePath: string, needle: string, occurrence = 0): number {
|
|
|
74
75
|
|
|
75
76
|
// ── Geometry pass instanceBuffers (D-1a #1) ────────────────────────────────
|
|
76
77
|
|
|
77
|
-
describe('production-path geometry instanceBuffers
|
|
78
|
+
describe('production-path geometry instanceBuffers world-aware key', () => {
|
|
78
79
|
const FILE = fileURLToPath(
|
|
79
80
|
new URL('../../../render/src/record/main-pass-geometry.ts', import.meta.url),
|
|
80
81
|
);
|
|
81
82
|
|
|
82
|
-
it('inst.cacheKey get uses
|
|
83
|
-
|
|
83
|
+
it('inst.cacheKey get uses a world-aware key', () => {
|
|
84
|
+
expectWorldAwareCacheKeyAt(
|
|
84
85
|
FILE,
|
|
85
86
|
findLine(FILE, 'frameState.instanceBuffers.get('),
|
|
86
87
|
'main-pass-geometry instanceBuffers.get',
|
|
87
88
|
);
|
|
88
89
|
});
|
|
89
90
|
|
|
90
|
-
it('inst.cacheKey set uses
|
|
91
|
-
|
|
91
|
+
it('inst.cacheKey set uses a world-aware key', () => {
|
|
92
|
+
expectWorldAwareCacheKeyAt(
|
|
92
93
|
FILE,
|
|
93
94
|
findLine(FILE, 'frameState.instanceBuffers.set('),
|
|
94
95
|
'main-pass-geometry instanceBuffers.set',
|
|
@@ -110,17 +111,17 @@ describe('production-path shared instance bind-group resolver', () => {
|
|
|
110
111
|
|
|
111
112
|
// ── Sprite Instances instanceBuffers (D-1a #1) ─────────────────────────────
|
|
112
113
|
|
|
113
|
-
describe('production-path sprite Instances instanceBuffers
|
|
114
|
-
it('spriteInst.cacheKey get uses
|
|
115
|
-
|
|
114
|
+
describe('production-path sprite Instances instanceBuffers world-aware key', () => {
|
|
115
|
+
it('spriteInst.cacheKey get uses a world-aware key', () => {
|
|
116
|
+
expectWorldAwareCacheKeyAt(
|
|
116
117
|
SPRITE_FILE,
|
|
117
118
|
findLine(SPRITE_FILE, 'frameState.instanceBuffers.get(', 1),
|
|
118
119
|
'main-pass-sprite-draws sprite instanceBuffers.get',
|
|
119
120
|
);
|
|
120
121
|
});
|
|
121
122
|
|
|
122
|
-
it('spriteInst.cacheKey set uses
|
|
123
|
-
|
|
123
|
+
it('spriteInst.cacheKey set uses a world-aware key', () => {
|
|
124
|
+
expectWorldAwareCacheKeyAt(
|
|
124
125
|
SPRITE_FILE,
|
|
125
126
|
findLine(SPRITE_FILE, 'frameState.instanceBuffers.set(', 1),
|
|
126
127
|
'main-pass-sprite-draws sprite instanceBuffers.set',
|
|
@@ -130,17 +131,17 @@ describe('production-path sprite Instances instanceBuffers worldEntityKey', () =
|
|
|
130
131
|
|
|
131
132
|
// ── SpriteInstances instanceBuffers (D-1a #1) ──────────────────────────────
|
|
132
133
|
|
|
133
|
-
describe('production-path SpriteInstances instanceBuffers
|
|
134
|
-
it('spriteInstancesSnap.cacheKey get uses
|
|
135
|
-
|
|
134
|
+
describe('production-path SpriteInstances instanceBuffers world-aware key', () => {
|
|
135
|
+
it('spriteInstancesSnap.cacheKey get uses a world-aware key', () => {
|
|
136
|
+
expectWorldAwareCacheKeyAt(
|
|
136
137
|
SPRITE_FILE,
|
|
137
138
|
findLine(SPRITE_FILE, 'frameState.instanceBuffers.get(', 2),
|
|
138
139
|
'main-pass-sprite-draws spriteInstances instanceBuffers.get',
|
|
139
140
|
);
|
|
140
141
|
});
|
|
141
142
|
|
|
142
|
-
it('spriteInstancesSnap.cacheKey set uses
|
|
143
|
-
|
|
143
|
+
it('spriteInstancesSnap.cacheKey set uses a world-aware key', () => {
|
|
144
|
+
expectWorldAwareCacheKeyAt(
|
|
144
145
|
SPRITE_FILE,
|
|
145
146
|
findLine(SPRITE_FILE, 'frameState.instanceBuffers.set(', 2),
|
|
146
147
|
'main-pass-sprite-draws spriteInstances instanceBuffers.set',
|
|
@@ -154,12 +155,21 @@ const SHADOW_FILE = fileURLToPath(
|
|
|
154
155
|
new URL('../../../render/src/record/shadow-pass.ts', import.meta.url),
|
|
155
156
|
);
|
|
156
157
|
|
|
157
|
-
describe('production-path shadow instanceBuffers
|
|
158
|
-
it('
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
158
|
+
describe('production-path shadow instanceBuffers world-aware key', () => {
|
|
159
|
+
it('shadowInst.cacheKey get uses a world-aware key', () => {
|
|
160
|
+
expectWorldAwareCacheKeyAt(
|
|
161
|
+
SHADOW_FILE,
|
|
162
|
+
findLine(SHADOW_FILE, 'c.frameState.instanceBuffers.get(', 0),
|
|
163
|
+
'shadow-pass instanceBuffers.get',
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('shadowInst.cacheKey set uses a world-aware key', () => {
|
|
168
|
+
expectWorldAwareCacheKeyAt(
|
|
169
|
+
SHADOW_FILE,
|
|
170
|
+
findLine(SHADOW_FILE, 'c.frameState.instanceBuffers.set('),
|
|
171
|
+
'shadow-pass instanceBuffers.set',
|
|
172
|
+
);
|
|
163
173
|
});
|
|
164
174
|
|
|
165
175
|
it('spot shadow uses the shared world-aware caster recorder', () => {
|
|
@@ -167,9 +177,18 @@ describe('production-path shadow instanceBuffers worldEntityKey', () => {
|
|
|
167
177
|
const start = src.indexOf('export function encodeSpotShadowPass(');
|
|
168
178
|
expect(start).toBeGreaterThanOrEqual(0);
|
|
169
179
|
expect(src.slice(start)).toContain('recordShadowCasterDraws(');
|
|
170
|
-
expect(src.slice(start)).toContain(
|
|
171
|
-
|
|
180
|
+
expect(src.slice(start)).toContain('buildMatchedRenderableIndices(');
|
|
181
|
+
expect(src.slice(start)).toContain("{ LightMode: ['ShadowCaster'] }");
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('production-path shadow shared instance bind-group resolver', () => {
|
|
186
|
+
it('shadow pass resolves the bind group from the world-aware buffer cache', () => {
|
|
187
|
+
const src = readFileSync(SHADOW_FILE, 'utf8');
|
|
188
|
+
expect(src).toContain(
|
|
189
|
+
'const shadowInstancesBg = resolveGeometryInstancesBindGroup(c, instanceDraw.buffer);',
|
|
172
190
|
);
|
|
191
|
+
expect(src).toContain('instanceCollectionCacheKey(entry.source.worldId, shadowInst)');
|
|
173
192
|
});
|
|
174
193
|
});
|
|
175
194
|
|
|
@@ -14,6 +14,14 @@ import type { RenderError, RenderErrorCode } from '@forgeax/engine-render';
|
|
|
14
14
|
|
|
15
15
|
function exhaustiveSwitchOnRenderCode(code: RenderErrorCode): string {
|
|
16
16
|
switch (code) {
|
|
17
|
+
case 'auto-exposure-invalid-parameter':
|
|
18
|
+
return code;
|
|
19
|
+
case 'auto-exposure-capability-unavailable':
|
|
20
|
+
return code;
|
|
21
|
+
case 'auto-exposure-stale-generation':
|
|
22
|
+
return code;
|
|
23
|
+
case 'auto-exposure-stage-failed':
|
|
24
|
+
return code;
|
|
17
25
|
case 'lifecycle-construction-failed':
|
|
18
26
|
return code;
|
|
19
27
|
case 'world-lease-invalid':
|
|
@@ -148,6 +156,22 @@ function exhaustiveSwitchOnRenderCode(code: RenderErrorCode): string {
|
|
|
148
156
|
|
|
149
157
|
function narrowRenderError(err: RenderError): void {
|
|
150
158
|
switch (err.code) {
|
|
159
|
+
case 'auto-exposure-invalid-parameter':
|
|
160
|
+
void err.detail.field;
|
|
161
|
+
void err.detail.value;
|
|
162
|
+
break;
|
|
163
|
+
case 'auto-exposure-capability-unavailable':
|
|
164
|
+
void err.detail.capability;
|
|
165
|
+
void err.detail.generation;
|
|
166
|
+
break;
|
|
167
|
+
case 'auto-exposure-stale-generation':
|
|
168
|
+
void err.detail.expectedGeneration;
|
|
169
|
+
void err.detail.actualGeneration;
|
|
170
|
+
break;
|
|
171
|
+
case 'auto-exposure-stage-failed':
|
|
172
|
+
void err.detail.stage;
|
|
173
|
+
void err.detail.operation;
|
|
174
|
+
break;
|
|
151
175
|
case 'lifecycle-construction-failed':
|
|
152
176
|
void err.detail.owner;
|
|
153
177
|
void err.detail.generation;
|
|
@@ -51,7 +51,13 @@
|
|
|
51
51
|
|
|
52
52
|
import { AssetRegistry, HANDLE_QUAD } from '@forgeax/engine-assets-runtime';
|
|
53
53
|
import { World } from '@forgeax/engine-ecs';
|
|
54
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
Camera,
|
|
56
|
+
InstanceCollectionStore,
|
|
57
|
+
Instances,
|
|
58
|
+
MeshFilter,
|
|
59
|
+
MeshRenderer,
|
|
60
|
+
} from '@forgeax/engine-render';
|
|
55
61
|
import { propagateTransforms, Transform } from '@forgeax/engine-scene';
|
|
56
62
|
import type { MaterialAsset } from '@forgeax/engine-types';
|
|
57
63
|
import { describe, expect, it } from 'vitest';
|
|
@@ -245,13 +251,15 @@ describe('render-system-extract M2.5 dispatch/renderable slot pairing (AC-06)',
|
|
|
245
251
|
instanceTransforms[n * 16 + 10] = 1;
|
|
246
252
|
instanceTransforms[n * 16 + 15] = 1;
|
|
247
253
|
}
|
|
254
|
+
const instanceCollections = new InstanceCollectionStore();
|
|
255
|
+
const collection = instanceCollections.create({ transforms: instanceTransforms }).unwrap();
|
|
248
256
|
|
|
249
257
|
world
|
|
250
258
|
.spawn(
|
|
251
259
|
{ component: Transform, data: transformAtZ(100) },
|
|
252
260
|
{ component: MeshFilter, data: { assetHandle: HANDLE_QUAD } },
|
|
253
261
|
{ component: MeshRenderer, data: { materials: [matBlue] } },
|
|
254
|
-
{ component: Instances, data: {
|
|
262
|
+
{ component: Instances, data: { collectionId: collection.collectionId } },
|
|
255
263
|
)
|
|
256
264
|
.unwrap();
|
|
257
265
|
world
|
|
@@ -259,12 +267,15 @@ describe('render-system-extract M2.5 dispatch/renderable slot pairing (AC-06)',
|
|
|
259
267
|
{ component: Transform, data: identityTransform() },
|
|
260
268
|
{ component: MeshFilter, data: { assetHandle: HANDLE_QUAD } },
|
|
261
269
|
{ component: MeshRenderer, data: { materials: [matYellow] } },
|
|
262
|
-
{ component: Instances, data: {
|
|
270
|
+
{ component: Instances, data: { collectionId: collection.collectionId } },
|
|
263
271
|
)
|
|
264
272
|
.unwrap();
|
|
265
273
|
|
|
266
274
|
expect(propagateTransforms(world).ok).toBe(true);
|
|
267
|
-
const frame = extractFrame(
|
|
275
|
+
const frame = extractFrame(
|
|
276
|
+
world,
|
|
277
|
+
prepareExtractContext(world, { assets, instanceCollections }),
|
|
278
|
+
);
|
|
268
279
|
|
|
269
280
|
expect(frame.renderables).toHaveLength(1);
|
|
270
281
|
expect(frame.renderables[0]?.instances).toBeDefined();
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
MeshFilter,
|
|
18
18
|
MeshRenderer,
|
|
19
19
|
PointLight,
|
|
20
|
+
PointLightShadow,
|
|
20
21
|
SpotLight,
|
|
21
22
|
} from '@forgeax/engine-render';
|
|
22
23
|
import { ChildOf, propagateTransforms, Transform } from '@forgeax/engine-scene';
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
extractFrames,
|
|
36
37
|
prepareExtractContext,
|
|
37
38
|
} from '../../../render/src/render-system-extract';
|
|
39
|
+
import { PersistentRenderScene } from '../../../render/src/scene/render-scene';
|
|
38
40
|
import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
|
|
39
41
|
|
|
40
42
|
// ─── from render-system-extract.test.ts ───
|
|
@@ -539,6 +541,65 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
|
|
|
539
541
|
expect(frame.frustumStats).toEqual({ culled: 1, total: 1 });
|
|
540
542
|
});
|
|
541
543
|
|
|
544
|
+
it('retains off-camera casters only for shadow dispatch and only inside a light frustum', () => {
|
|
545
|
+
const world = new World();
|
|
546
|
+
spawnCamera(world);
|
|
547
|
+
world
|
|
548
|
+
.spawn(
|
|
549
|
+
{ component: Transform, data: { ...identity(), pos: [0, 0, 2] } },
|
|
550
|
+
{ component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
|
|
551
|
+
{ component: MeshRenderer, data: {} },
|
|
552
|
+
)
|
|
553
|
+
.unwrap();
|
|
554
|
+
const light = world
|
|
555
|
+
.spawn(
|
|
556
|
+
{ component: Transform, data: { ...identity(), pos: [0, 0, 5] } },
|
|
557
|
+
{ component: PointLight, data: { intensity: 10, range: 10 } },
|
|
558
|
+
{ component: PointLightShadow, data: { nearPlane: 0.1, farPlane: 10 } },
|
|
559
|
+
)
|
|
560
|
+
.unwrap();
|
|
561
|
+
propagateTransforms(world).unwrap();
|
|
562
|
+
const frame = extractFrame(world, prepareExtractContext(world));
|
|
563
|
+
expect(frame.frustumStats).toEqual({ culled: 1, total: 1 });
|
|
564
|
+
expect(frame.renderables).toHaveLength(1);
|
|
565
|
+
expect(frame.dispatch.map((entry) => entry.tags.LightMode)).toEqual(['ShadowCaster']);
|
|
566
|
+
expect(frame.dispatch.every((entry) => entry.renderableIndex === 0)).toBe(true);
|
|
567
|
+
world.set(light, PointLightShadow, { farPlane: 1 }).unwrap();
|
|
568
|
+
expect(extractFrame(world, prepareExtractContext(world)).renderables).toHaveLength(0);
|
|
569
|
+
world.despawn(light).unwrap();
|
|
570
|
+
expect(extractFrame(world, prepareExtractContext(world)).renderables).toHaveLength(0);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('persistent composition retains shadow-only casters lit from another World', () => {
|
|
574
|
+
const sceneWorld = new World();
|
|
575
|
+
spawnCamera(sceneWorld);
|
|
576
|
+
sceneWorld
|
|
577
|
+
.spawn(
|
|
578
|
+
{ component: Transform, data: { ...identity(), pos: [0, 0, 2] } },
|
|
579
|
+
{ component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
|
|
580
|
+
{ component: MeshRenderer, data: {} },
|
|
581
|
+
)
|
|
582
|
+
.unwrap();
|
|
583
|
+
const lightWorld = new World();
|
|
584
|
+
lightWorld
|
|
585
|
+
.spawn(
|
|
586
|
+
{ component: Transform, data: { ...identity(), pos: [0, 0, 5] } },
|
|
587
|
+
{ component: PointLight, data: { intensity: 10, range: 10 } },
|
|
588
|
+
{ component: PointLightShadow, data: { nearPlane: 0.1, farPlane: 10 } },
|
|
589
|
+
)
|
|
590
|
+
.unwrap();
|
|
591
|
+
for (const world of [sceneWorld, lightWorld]) propagateTransforms(world).unwrap();
|
|
592
|
+
const worlds = [sceneWorld, lightWorld];
|
|
593
|
+
const owner = { cameraOwner: 0, resourceOwner: 0 };
|
|
594
|
+
const scene = new PersistentRenderScene();
|
|
595
|
+
const frame = scene.extractComposition(worlds, owner, 0, () =>
|
|
596
|
+
extractFrames(worlds, owner, undefined, undefined, undefined, { cull: 'none' }),
|
|
597
|
+
);
|
|
598
|
+
expect(frame.frustumStats).toEqual({ culled: 1, total: 1 });
|
|
599
|
+
expect(frame.renderables).toHaveLength(1);
|
|
600
|
+
expect(frame.dispatch.map((entry) => entry.tags.LightMode)).toEqual(['ShadowCaster']);
|
|
601
|
+
});
|
|
602
|
+
|
|
542
603
|
it('cross-world extract keeps frustum culling against the surfaced camera-owner camera', () => {
|
|
543
604
|
const cameraWorld = new World();
|
|
544
605
|
cameraWorld
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
// The 4 sub-assertions below RED before w10 lands the 3 _routeError fires
|
|
25
25
|
// + SpriteInstancesSnapshot population in `render-system-extract.ts`. They
|
|
26
26
|
// GO GREEN once w10 wires the validation into the QueryRow loop at the
|
|
27
|
-
// same
|
|
28
|
-
// (see packages/runtime/src/__tests__/render-system-mega.test.ts
|
|
29
|
-
//
|
|
27
|
+
// same extract boundary as renderer-owned InstanceCollectionError validation
|
|
28
|
+
// (see packages/runtime/src/__tests__/render-system-mega.test.ts for the
|
|
29
|
+
// collection association coverage).
|
|
30
30
|
//
|
|
31
31
|
// Anchors:
|
|
32
32
|
// - requirements AC-03 (3 sprite-instances error codes fire at extract)
|
|
@@ -41,6 +41,7 @@ import { World } from '@forgeax/engine-ecs';
|
|
|
41
41
|
import {
|
|
42
42
|
Camera,
|
|
43
43
|
DirectionalLight,
|
|
44
|
+
InstanceCollectionStore,
|
|
44
45
|
Instances,
|
|
45
46
|
MeshFilter,
|
|
46
47
|
MeshRenderer,
|
|
@@ -162,6 +163,7 @@ function makeScene(): {
|
|
|
162
163
|
mesh: Handle<'MeshAsset', 'shared'>;
|
|
163
164
|
spriteMat: Handle<'MaterialAsset', 'shared'>;
|
|
164
165
|
unlitMat: Handle<'MaterialAsset', 'shared'>;
|
|
166
|
+
instanceCollections: InstanceCollectionStore;
|
|
165
167
|
} {
|
|
166
168
|
const collected: CollectedError[] = [];
|
|
167
169
|
const world = new World();
|
|
@@ -182,24 +184,36 @@ function makeScene(): {
|
|
|
182
184
|
passes: [UNLIT_PASS],
|
|
183
185
|
values: {},
|
|
184
186
|
} as MaterialAsset);
|
|
185
|
-
return {
|
|
187
|
+
return {
|
|
188
|
+
world,
|
|
189
|
+
assets,
|
|
190
|
+
collected,
|
|
191
|
+
mesh,
|
|
192
|
+
spriteMat,
|
|
193
|
+
unlitMat,
|
|
194
|
+
instanceCollections: new InstanceCollectionStore(),
|
|
195
|
+
};
|
|
186
196
|
}
|
|
187
197
|
|
|
188
198
|
describe('render-system-extract SpriteInstances validation (w8, AC-03 + Edge Cases)', () => {
|
|
189
199
|
it('(a) entity carries both Instances + SpriteInstances -> fires sprite-instances-mutually-exclusive-with-instances + entity skipped', () => {
|
|
190
|
-
const { world, assets, collected, mesh, spriteMat } = makeScene();
|
|
200
|
+
const { world, assets, collected, mesh, spriteMat, instanceCollections } = makeScene();
|
|
191
201
|
const transforms = new Float32Array(16);
|
|
192
202
|
const regions = new Float32Array(4);
|
|
203
|
+
const collection = instanceCollections.create({ transforms }).unwrap();
|
|
193
204
|
world
|
|
194
205
|
.spawn(
|
|
195
206
|
{ component: Transform, data: identityTransform() },
|
|
196
207
|
{ component: MeshFilter, data: { assetHandle: mesh } },
|
|
197
208
|
{ component: MeshRenderer, data: { materials: [spriteMat] } },
|
|
198
|
-
{ component: Instances, data: {
|
|
209
|
+
{ component: Instances, data: { collectionId: collection.collectionId } },
|
|
199
210
|
{ component: SpriteInstances, data: { transforms, regions } },
|
|
200
211
|
)
|
|
201
212
|
.unwrap();
|
|
202
|
-
const frame = extractFrame(
|
|
213
|
+
const frame = extractFrame(
|
|
214
|
+
world,
|
|
215
|
+
prepareExtractContext(world, { assets, instanceCollections }),
|
|
216
|
+
);
|
|
203
217
|
const fired = collected.filter(
|
|
204
218
|
(c) => c.code === 'sprite-instances-mutually-exclusive-with-instances',
|
|
205
219
|
);
|
|
@@ -28,6 +28,7 @@ function mockEntry(opts: {
|
|
|
28
28
|
renderableIndex: number;
|
|
29
29
|
materialHandle: number;
|
|
30
30
|
layer: number;
|
|
31
|
+
materialShaderId?: string;
|
|
31
32
|
}): DispatchEntry {
|
|
32
33
|
return {
|
|
33
34
|
entityIndex: opts.renderableIndex,
|
|
@@ -41,7 +42,7 @@ function mockEntry(opts: {
|
|
|
41
42
|
defines: undefined,
|
|
42
43
|
vertexEntry: undefined,
|
|
43
44
|
fragmentEntry: undefined,
|
|
44
|
-
materialShaderId:
|
|
45
|
+
materialShaderId: opts.materialShaderId ?? 'forgeax::sprite',
|
|
45
46
|
paramSnapshot: undefined,
|
|
46
47
|
};
|
|
47
48
|
}
|
|
@@ -185,6 +186,37 @@ describe('foldDispatchBuckets — mode 0 basic paths (w1)', () => {
|
|
|
185
186
|
// 'sprite'` to `transparent === true` (the M3 ablation collapsed the
|
|
186
187
|
// sprite discriminator into the generic transparent flag).
|
|
187
188
|
describe('foldDispatchBuckets — transparent-only gate (PR #502 fix + feat-20260625 R2 fix-up)', () => {
|
|
189
|
+
it('transparent Standard PBR entries remain singleton while sprite shaders fold', () => {
|
|
190
|
+
const standardEntries = [0, 1].map((renderableIndex) =>
|
|
191
|
+
mockEntry({
|
|
192
|
+
renderableIndex,
|
|
193
|
+
materialHandle: 13,
|
|
194
|
+
layer: 0,
|
|
195
|
+
materialShaderId: 'forgeax::default-standard-pbr',
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
const standardRenderables = standardEntries.map(() => mockRenderable(1.0, true));
|
|
199
|
+
const standardBuckets = foldDispatchBuckets(
|
|
200
|
+
standardEntries,
|
|
201
|
+
TRANSPARENT_SORT_MODE_LAYER_Z,
|
|
202
|
+
standardRenderables,
|
|
203
|
+
);
|
|
204
|
+
expect(standardBuckets.map((bucket) => bucket.bucketSize)).toEqual([1, 1]);
|
|
205
|
+
|
|
206
|
+
for (const materialShaderId of ['forgeax::sprite', 'forgeax::sprite-lit']) {
|
|
207
|
+
const spriteEntries = [0, 1].map((renderableIndex) =>
|
|
208
|
+
mockEntry({ renderableIndex, materialHandle: 13, layer: 0, materialShaderId }),
|
|
209
|
+
);
|
|
210
|
+
const spriteBuckets = foldDispatchBuckets(
|
|
211
|
+
spriteEntries,
|
|
212
|
+
TRANSPARENT_SORT_MODE_LAYER_Z,
|
|
213
|
+
standardRenderables,
|
|
214
|
+
);
|
|
215
|
+
expect(spriteBuckets).toHaveLength(1);
|
|
216
|
+
expect(spriteBuckets[0]?.bucketSize).toBe(2);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
188
220
|
it('non-transparent (unlit-shading) entries never form multi-entry buckets', () => {
|
|
189
221
|
// 4 entries sharing (layer, posZ, materialHandle) — pre-fix would have
|
|
190
222
|
// folded into a single bucketSize=4 bucket. With the transparent-only
|
|
@@ -19,6 +19,7 @@ function mockEntry(opts: {
|
|
|
19
19
|
renderableIndex: number;
|
|
20
20
|
materialHandle: number;
|
|
21
21
|
layer: number;
|
|
22
|
+
materialShaderId?: string;
|
|
22
23
|
}): DispatchEntry {
|
|
23
24
|
return {
|
|
24
25
|
entityIndex: opts.renderableIndex,
|
|
@@ -32,7 +33,7 @@ function mockEntry(opts: {
|
|
|
32
33
|
defines: undefined,
|
|
33
34
|
vertexEntry: undefined,
|
|
34
35
|
fragmentEntry: undefined,
|
|
35
|
-
materialShaderId:
|
|
36
|
+
materialShaderId: opts.materialShaderId,
|
|
36
37
|
paramSnapshot: undefined,
|
|
37
38
|
};
|
|
38
39
|
}
|
|
@@ -60,11 +61,36 @@ describe('foldDispatchBuckets — AC-10 multi-material boundary (w2)', () => {
|
|
|
60
61
|
// [M1, M1, M1, M2, M2] — 3 entries with materialHandle=11 followed by
|
|
61
62
|
// 2 with materialHandle=22, all same layer=5 and same posZ=3.0.
|
|
62
63
|
const entries: DispatchEntry[] = [
|
|
63
|
-
mockEntry({
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
mockEntry({
|
|
65
|
+
renderableIndex: 0,
|
|
66
|
+
materialHandle: 11,
|
|
67
|
+
layer: 5,
|
|
68
|
+
materialShaderId: 'forgeax::sprite',
|
|
69
|
+
}),
|
|
70
|
+
mockEntry({
|
|
71
|
+
renderableIndex: 1,
|
|
72
|
+
materialHandle: 11,
|
|
73
|
+
layer: 5,
|
|
74
|
+
materialShaderId: 'forgeax::sprite',
|
|
75
|
+
}),
|
|
76
|
+
mockEntry({
|
|
77
|
+
renderableIndex: 2,
|
|
78
|
+
materialHandle: 11,
|
|
79
|
+
layer: 5,
|
|
80
|
+
materialShaderId: 'forgeax::sprite',
|
|
81
|
+
}),
|
|
82
|
+
mockEntry({
|
|
83
|
+
renderableIndex: 3,
|
|
84
|
+
materialHandle: 22,
|
|
85
|
+
layer: 5,
|
|
86
|
+
materialShaderId: 'forgeax::sprite',
|
|
87
|
+
}),
|
|
88
|
+
mockEntry({
|
|
89
|
+
renderableIndex: 4,
|
|
90
|
+
materialHandle: 22,
|
|
91
|
+
layer: 5,
|
|
92
|
+
materialShaderId: 'forgeax::sprite',
|
|
93
|
+
}),
|
|
68
94
|
];
|
|
69
95
|
const renderables: ReturnType<typeof mockRenderable>[] = [
|
|
70
96
|
mockRenderable(3.0),
|
|
@@ -30,6 +30,7 @@ function mockEntry(opts: {
|
|
|
30
30
|
renderableIndex: number;
|
|
31
31
|
materialHandle: number;
|
|
32
32
|
layer: number;
|
|
33
|
+
materialShaderId?: string;
|
|
33
34
|
}): DispatchEntry {
|
|
34
35
|
return {
|
|
35
36
|
entityIndex: opts.renderableIndex,
|
|
@@ -43,7 +44,7 @@ function mockEntry(opts: {
|
|
|
43
44
|
defines: undefined,
|
|
44
45
|
vertexEntry: undefined,
|
|
45
46
|
fragmentEntry: undefined,
|
|
46
|
-
materialShaderId:
|
|
47
|
+
materialShaderId: opts.materialShaderId,
|
|
47
48
|
paramSnapshot: undefined,
|
|
48
49
|
};
|
|
49
50
|
}
|
|
@@ -80,7 +81,14 @@ describe('foldDispatchBuckets — mode-gate (D-5, w3)', () => {
|
|
|
80
81
|
const entries: DispatchEntry[] = [];
|
|
81
82
|
const renderables: ReturnType<typeof mockRenderable>[] = [];
|
|
82
83
|
for (let i = 0; i < N; i++) {
|
|
83
|
-
entries.push(
|
|
84
|
+
entries.push(
|
|
85
|
+
mockEntry({
|
|
86
|
+
renderableIndex: i,
|
|
87
|
+
materialHandle: 1,
|
|
88
|
+
layer: 0,
|
|
89
|
+
materialShaderId: 'forgeax::sprite',
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
84
92
|
renderables.push(mockRenderable(0, 0));
|
|
85
93
|
}
|
|
86
94
|
return { entries, renderables };
|
|
@@ -113,7 +121,14 @@ describe('foldDispatchBuckets — mode-gate (D-5, w3)', () => {
|
|
|
113
121
|
const entries: DispatchEntry[] = [];
|
|
114
122
|
const renderables: ReturnType<typeof mockRenderable>[] = [];
|
|
115
123
|
for (let i = 0; i < N; i++) {
|
|
116
|
-
entries.push(
|
|
124
|
+
entries.push(
|
|
125
|
+
mockEntry({
|
|
126
|
+
renderableIndex: i,
|
|
127
|
+
materialHandle: 1,
|
|
128
|
+
layer: 0,
|
|
129
|
+
materialShaderId: 'forgeax::sprite',
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
117
132
|
renderables.push(mockRenderable(0, i * 16)); // posY = 0, 16, 32, 48
|
|
118
133
|
}
|
|
119
134
|
const buckets = foldDispatchBuckets(entries, TRANSPARENT_SORT_MODE_LAYER_Y, renderables);
|
|
@@ -127,7 +142,14 @@ describe('foldDispatchBuckets — mode-gate (D-5, w3)', () => {
|
|
|
127
142
|
const entries: DispatchEntry[] = [];
|
|
128
143
|
const renderables: ReturnType<typeof mockRenderable>[] = [];
|
|
129
144
|
for (let i = 0; i < N; i++) {
|
|
130
|
-
entries.push(
|
|
145
|
+
entries.push(
|
|
146
|
+
mockEntry({
|
|
147
|
+
renderableIndex: i,
|
|
148
|
+
materialHandle: 1,
|
|
149
|
+
layer: 0,
|
|
150
|
+
materialShaderId: 'forgeax::sprite',
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
131
153
|
renderables.push(mockRenderable(0, 3.0));
|
|
132
154
|
}
|
|
133
155
|
const buckets = foldDispatchBuckets(entries, TRANSPARENT_SORT_MODE_LAYER_Y, renderables);
|
|
@@ -71,7 +71,10 @@ function mockDispatchEntry(opts: {
|
|
|
71
71
|
defines: undefined,
|
|
72
72
|
vertexEntry: undefined,
|
|
73
73
|
fragmentEntry: undefined,
|
|
74
|
-
|
|
74
|
+
// Fold admission is intentionally limited to the transparent sprite
|
|
75
|
+
// owners; these fixtures model sprite/tilemap draws rather than generic
|
|
76
|
+
// transparent Standard PBR geometry.
|
|
77
|
+
materialShaderId: 'forgeax::sprite',
|
|
75
78
|
paramSnapshot: undefined,
|
|
76
79
|
};
|
|
77
80
|
}
|