@forgeax/engine-runtime 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 +105 -3
- package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts +2 -0
- package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts.map +1 -0
- package/dist/__tests__/helpers/register-runtime-components.d.ts.map +1 -1
- package/dist/__tests__/helpers/register-scene-components.d.ts.map +1 -1
- package/dist/__tests__/lights-preservation.fixup.test.d.ts +2 -0
- package/dist/__tests__/lights-preservation.fixup.test.d.ts.map +1 -0
- package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
- package/dist/createRenderer.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +4 -2
- package/dist/index.mjs.map +1 -1
- package/dist/sprite-param-values.d.ts +1 -1
- package/package.json +30 -30
- package/src/__tests__/active-camera.unit.test.ts +6 -8
- package/src/__tests__/animation-graph-asset-union.test-d.ts +2 -0
- package/src/__tests__/asset-brand-and-name-retirement.test.ts +4 -3
- package/src/__tests__/asset-registry-scene.test-d.ts +1 -0
- package/src/__tests__/asset-registry.recursive.spec.ts +1 -1
- package/src/__tests__/asset.unit.test.ts +5 -2
- package/src/__tests__/clamp-to-last.e2e.browser.test.ts +2 -2
- package/src/__tests__/collect-transient-roundtrip.test.ts +14 -3
- package/src/__tests__/components.test-d.ts +19 -9
- package/src/__tests__/components.unit.test.ts +36 -26
- package/src/__tests__/create-renderer-assembly.integration.test.ts +24 -0
- package/src/__tests__/create-renderer-gpu-pass-timing.unit.test.ts +32 -0
- package/src/__tests__/createRenderer.browser-no-webgpu.test.ts +66 -0
- package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +9 -1
- package/src/__tests__/dawn/instances-shadow.dawn.test.ts +15 -2
- package/src/__tests__/errors.unit.test.ts +54 -20
- package/src/__tests__/extract-frames-merge.test.ts +51 -40
- package/src/__tests__/extract-skin-mismatch.unit.test.ts +6 -2
- package/src/__tests__/frame-targets-shadow-regression.unit.test.ts +13 -0
- package/src/__tests__/geometry.unit.test.ts +7 -10
- package/src/__tests__/helpers/register-runtime-components.ts +9 -1
- package/src/__tests__/helpers/register-scene-components.ts +9 -2
- package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +10 -11
- package/src/__tests__/light-snapshot.test-d.ts +35 -1
- package/src/__tests__/lights-preservation.fixup.test.ts +125 -0
- package/src/__tests__/lights.unit.test.ts +223 -308
- package/src/__tests__/loader-name-survival.test.ts +8 -9
- package/src/__tests__/package-promotion.test.ts +7 -11
- package/src/__tests__/pbr-pipeline.unit.test.ts +22 -17
- package/src/__tests__/pipeline.unit.test.ts +68 -226
- package/src/__tests__/point-light-shadow.browser.test.ts +209 -83
- package/src/__tests__/point-light-shadow.unit.test.ts +24 -47
- package/src/__tests__/propagate-transforms.dawn.test.ts +15 -16
- package/src/__tests__/record-worldid-isolation.test.ts +16 -32
- package/src/__tests__/render-error-exhaustive.test-d.ts +73 -9
- package/src/__tests__/render-system-extract.test.ts +5 -5
- package/src/__tests__/render-system-mega.test.ts +30 -41
- package/src/__tests__/render-system-record-multi-material-textureview.test.ts +5 -1
- package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +5 -1
- package/src/__tests__/render-system-skin-bg.unit.test.ts +14 -9
- package/src/__tests__/renderer-recover.unit.test.ts +8 -0
- package/src/__tests__/renderer-surface.unit.test.ts +229 -5
- package/src/__tests__/resolveName-unified-entry.test.ts +6 -6
- package/src/__tests__/resolveName.test.ts +14 -2
- package/src/__tests__/shadow-csm-atlas.test.ts +41 -0
- package/src/__tests__/shadow-csm-cascade-loadop.test.ts +1 -0
- package/src/__tests__/shadow-csm-component.test.ts +31 -24
- package/src/__tests__/shadow-csm-extract.test.ts +156 -47
- package/src/__tests__/shadow-csm-runtime-vary.dawn.test.ts +72 -0
- package/src/__tests__/shadow-csm-shader.dawn.test.ts +19 -0
- package/src/__tests__/shadow-csm-tile-consistency.test.ts +1 -0
- package/src/__tests__/shadow-csm-ubo.test.ts +43 -45
- package/src/__tests__/shadow-csm-viewport.browser.test.ts +29 -0
- package/src/__tests__/shadow-fields-observable.dawn.test.ts +100 -24
- package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +1 -1
- package/src/__tests__/skin-parented-double-transform.test.ts +5 -5
- package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +4 -7
- package/src/__tests__/skylight-lazy-projection.test.ts +8 -5
- package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -13
- package/src/__tests__/ssao-bgl.test.ts +12 -17
- package/src/__tests__/ssao-passes.test.ts +19 -2
- package/src/__tests__/systems.unit.test.ts +69 -205
- package/src/__tests__/template-game-default-api-contract.test.ts +45 -23
- package/src/__tests__/tilemap-chunk-extract.propagate-order.test.ts +10 -4
- package/src/__tests__/transform-hierarchy-pixel-diff.dawn.test.ts +2 -2
- package/src/__tests__/view-ubo-layout.browser.test.ts +20 -10
- package/src/collect-scene-asset.ts +1 -1
- package/src/components/__tests__/transform-vec-schema.test.ts +2 -2
- package/src/createRenderer.ts +2 -0
- package/src/index.ts +1 -2
- package/src/sprite-param-values.ts +1 -1
- package/dist/.tsbuildinfo +0 -1
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
// after loadByGuid (parseAssetPayload sync branch) resolveName
|
|
14
14
|
// returns each stored name.
|
|
15
15
|
// AC-08 async — a texture in its own (single-asset) package; after loadByGuid
|
|
16
|
-
// (texture upstream async branch) resolveName
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
16
|
+
// (texture upstream async branch) resolveName preserves the
|
|
17
|
+
// explicit entry name. Without the entry -> Package wiring the
|
|
18
|
+
// texture would land in a null package and resolveName would be
|
|
19
|
+
// '' -- so the named result proves the async load path registered
|
|
20
|
+
// the package post-roundtrip.
|
|
21
21
|
// FALSIFY — the same pack with the entry name stripped degrades to
|
|
22
22
|
// basename(path), proving the assertion is name-sensitive (not
|
|
23
23
|
// a tautology). The FALSIFY block does not gate CI; it documents
|
|
@@ -187,10 +187,9 @@ describe('loader name survival (AC-08)', () => {
|
|
|
187
187
|
|
|
188
188
|
const r = await reg.loadByGuid(parseGuid(TEX));
|
|
189
189
|
expect(r.ok).toBe(true);
|
|
190
|
-
//
|
|
191
|
-
// makes this
|
|
192
|
-
|
|
193
|
-
expect(reg.resolveName(TEX)).toBe('diffuse.pack.json');
|
|
190
|
+
// An explicit entry name wins even for a single-asset package. The wiring
|
|
191
|
+
// makes this name survive the async JSON-roundtrip path.
|
|
192
|
+
expect(reg.resolveName(TEX)).toBe('Diffuse');
|
|
194
193
|
});
|
|
195
194
|
|
|
196
195
|
it('FALSIFY: stripped entry.name degrades to basename, proving name-sensitivity', async () => {
|
|
@@ -7,13 +7,10 @@
|
|
|
7
7
|
// asset's derived basename is frozen as its stored name so it keeps
|
|
8
8
|
// the same resolveName (now via the multi-asset branch), with no error
|
|
9
9
|
// and no caller awareness. Promotion is idempotent: re-registering the
|
|
10
|
-
// same path does not overwrite an already-frozen name.
|
|
10
|
+
// same path does not overwrite an already-frozen name. An explicitly
|
|
11
|
+
// authored name is already stable and is preserved through promotion.
|
|
11
12
|
//
|
|
12
|
-
// The promotion
|
|
13
|
-
// original asset already carries a stored name at promotion time (the abnormal
|
|
14
|
-
// single-asset-with-name state, D-4), the freeze must not overwrite it and the
|
|
15
|
-
// soft-violation counter increments. Without w11 there is no promotion branch,
|
|
16
|
-
// so the counter never fires (the falsifying assertion).
|
|
13
|
+
// The promotion must not emit a violation for an explicitly named first asset.
|
|
17
14
|
|
|
18
15
|
import { AssetRegistry } from '@forgeax/engine-assets-runtime';
|
|
19
16
|
import { AssetGuid } from '@forgeax/engine-pack/guid';
|
|
@@ -70,19 +67,18 @@ describe('1->N package promotion (AC-05)', () => {
|
|
|
70
67
|
expect(reg.resolveName(G2)).toBe('SecondAsset');
|
|
71
68
|
});
|
|
72
69
|
|
|
73
|
-
it('
|
|
70
|
+
it('preserves an explicitly authored first-asset name without a false violation', () => {
|
|
74
71
|
const reg = makeRegistry();
|
|
75
72
|
const metrics = createEngineMetrics();
|
|
76
73
|
reg.setMetrics(metrics);
|
|
77
|
-
// Abnormal: a single-asset package whose lone asset already carries a name.
|
|
78
74
|
reg.catalog(parseGuid(G1), sampler);
|
|
79
75
|
reg._registerPackage(PATH, [G1], new Map([[G1, 'PreNamed']]));
|
|
80
76
|
reg.catalog(parseGuid(G2), sampler);
|
|
81
|
-
// Promotion
|
|
82
|
-
// basename
|
|
77
|
+
// Promotion preserves the authored name rather than replacing it with the
|
|
78
|
+
// package basename.
|
|
83
79
|
reg._registerPackage(PATH, [G2], new Map([[G2, 'SecondAsset']]));
|
|
84
80
|
|
|
85
81
|
expect(reg.resolveName(G1)).toBe('PreNamed');
|
|
86
|
-
expect(metrics.snapshot()['package.xor-invariant-violated']).toBe(
|
|
82
|
+
expect(metrics.snapshot()['package.xor-invariant-violated'] ?? 0).toBe(0);
|
|
87
83
|
});
|
|
88
84
|
});
|
|
@@ -44,34 +44,29 @@ function makeSpec(): PipelineSpec {
|
|
|
44
44
|
|
|
45
45
|
describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', () => {
|
|
46
46
|
describe('pbr-view', () => {
|
|
47
|
-
it('storageBuffer=true:
|
|
47
|
+
it('storageBuffer=true: 10 entries with projector bindings 11+12', () => {
|
|
48
48
|
const spec = makeSpec();
|
|
49
49
|
const out = buildBindGroupLayoutDescriptor(spec, {
|
|
50
50
|
kind: 'pbr-view',
|
|
51
|
-
caps: { storageBuffer: true },
|
|
51
|
+
caps: { storageBuffer: true, extendedLighting: false },
|
|
52
52
|
});
|
|
53
53
|
const expected = {
|
|
54
54
|
label: 'pbr-view-bgl',
|
|
55
|
-
entries: buildPbrViewBglEntries({ storageBuffer: true }),
|
|
55
|
+
entries: buildPbrViewBglEntries({ storageBuffer: true, extendedLighting: false }),
|
|
56
56
|
};
|
|
57
57
|
expect(out).toEqual(expected);
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
// entries (bindings 0..8 plus optional projector 11+12 and binding 10)
|
|
64
|
-
// — keeping the WebGL2 fallback fragment uniform-buffer count <= 11
|
|
65
|
-
// (GLES 3.0), while the projector remains available on the default
|
|
66
|
-
// WebGPU path.
|
|
67
|
-
expect(out.entries.length).toBe(12);
|
|
58
|
+
// Binding 8 is the always-on spot shadow atlas, binding 10 is the
|
|
59
|
+
// Points/Lines viewport UBO, and bindings 11/12 are the optional
|
|
60
|
+
// projector resources. Local lights are exclusively carried by the
|
|
61
|
+
// Standard Cluster group(2), so the view group has no point/spot slots.
|
|
62
|
+
expect(out.entries.length).toBe(10);
|
|
68
63
|
expect(out.entries.find((entry) => entry.binding === 9)).toBeUndefined();
|
|
69
64
|
expect(out.entries.find((entry) => entry.binding === 10)?.buffer?.type).toBe('uniform');
|
|
70
65
|
expect(out.entries.find((entry) => entry.binding === 11)?.texture?.viewDimension).toBe('2d');
|
|
71
66
|
expect(out.entries.find((entry) => entry.binding === 12)?.sampler?.type).toBe('filtering');
|
|
72
67
|
});
|
|
73
68
|
|
|
74
|
-
it('storageBuffer=false:
|
|
69
|
+
it('storageBuffer=false: view group still has no local-light bindings', () => {
|
|
75
70
|
const spec = makeSpec();
|
|
76
71
|
const out = buildBindGroupLayoutDescriptor(spec, {
|
|
77
72
|
kind: 'pbr-view',
|
|
@@ -82,17 +77,22 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
|
|
|
82
77
|
entries: buildPbrViewBglEntries({ storageBuffer: false }),
|
|
83
78
|
};
|
|
84
79
|
expect(out).toEqual(expected);
|
|
80
|
+
expect(out.entries.some((entry) => entry.binding === 1 || entry.binding === 2)).toBe(false);
|
|
85
81
|
});
|
|
86
82
|
|
|
87
83
|
it('projectorAvailable=false: omits optional projector bindings for the 16-texture limit', () => {
|
|
88
84
|
const spec = makeSpec();
|
|
89
85
|
const out = buildBindGroupLayoutDescriptor(spec, {
|
|
90
86
|
kind: 'pbr-view',
|
|
91
|
-
caps: { storageBuffer: true, projectorAvailable: false },
|
|
87
|
+
caps: { storageBuffer: true, projectorAvailable: false, extendedLighting: false },
|
|
92
88
|
});
|
|
93
89
|
expect(out).toEqual({
|
|
94
90
|
label: 'pbr-view-bgl',
|
|
95
|
-
entries: buildPbrViewBglEntries({
|
|
91
|
+
entries: buildPbrViewBglEntries({
|
|
92
|
+
storageBuffer: true,
|
|
93
|
+
projectorAvailable: false,
|
|
94
|
+
extendedLighting: false,
|
|
95
|
+
}),
|
|
96
96
|
});
|
|
97
97
|
expect(out.entries.find((entry) => entry.binding === 11)).toBeUndefined();
|
|
98
98
|
expect(out.entries.find((entry) => entry.binding === 12)).toBeUndefined();
|
|
@@ -179,7 +179,7 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
|
|
|
179
179
|
});
|
|
180
180
|
|
|
181
181
|
describe('pbr-skin-mesh-array', () => {
|
|
182
|
-
it('storageBuffer=true:
|
|
182
|
+
it('storageBuffer=true: 3 entries, all dynamic offset', () => {
|
|
183
183
|
const spec = makeSpec();
|
|
184
184
|
const out = buildBindGroupLayoutDescriptor(spec, {
|
|
185
185
|
kind: 'pbr-skin-mesh-array',
|
|
@@ -198,6 +198,11 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
|
|
|
198
198
|
visibility: 0x1,
|
|
199
199
|
buffer: { type: 'read-only-storage', hasDynamicOffset: true },
|
|
200
200
|
},
|
|
201
|
+
{
|
|
202
|
+
binding: 2,
|
|
203
|
+
visibility: 0x1,
|
|
204
|
+
buffer: { type: 'read-only-storage', hasDynamicOffset: true },
|
|
205
|
+
},
|
|
201
206
|
],
|
|
202
207
|
});
|
|
203
208
|
});
|
|
@@ -31,9 +31,8 @@
|
|
|
31
31
|
// - packages/runtime/src/__tests__/render-data.test.ts
|
|
32
32
|
// - packages/runtime/src/__tests__/hdrp-bgl-slots.test.ts
|
|
33
33
|
// - packages/runtime/src/__tests__/hdrp-caps-gate.test.ts
|
|
34
|
-
// - packages/runtime/src/__tests__/
|
|
35
|
-
// - packages/runtime/src/__tests__/
|
|
36
|
-
// - packages/runtime/src/__tests__/hdrp-light-budget.test.ts
|
|
34
|
+
// - packages/runtime/src/__tests__/standard-profile-invalid.test.ts
|
|
35
|
+
// - packages/runtime/src/__tests__/standard-cluster-index-overflow-once.test.ts
|
|
37
36
|
// - packages/runtime/src/__tests__/hdrp-pipeline-asset-config.test.ts
|
|
38
37
|
// - packages/runtime/src/__tests__/m7-hdrp-demo-shape.test.ts
|
|
39
38
|
//
|
|
@@ -110,16 +109,14 @@ import {
|
|
|
110
109
|
} from '../../../render/src/cluster-binner';
|
|
111
110
|
import { GpuResidencyCache } from '../../../render/src/device/gpu-residency';
|
|
112
111
|
import { createEngineMetrics } from '../../../render/src/engine-metrics';
|
|
112
|
+
import { StandardProfileInvalidError } from '../../../render/src/errors/render';
|
|
113
113
|
import { assertStorageBufferCap } from '../../../render/src/light-buffer-layout';
|
|
114
114
|
import {
|
|
115
115
|
buildPbrPipelineLayouts,
|
|
116
116
|
buildPbrViewBglEntries,
|
|
117
117
|
type PbrCaps,
|
|
118
118
|
} from '../../../render/src/pbr-pipeline';
|
|
119
|
-
import {
|
|
120
|
-
HdrpInstallError,
|
|
121
|
-
validateClusterGrid,
|
|
122
|
-
} from '../../../render/src/pipeline/standard-pipeline';
|
|
119
|
+
import { validateClusterGrid } from '../../../render/src/pipeline/standard-pipeline';
|
|
123
120
|
import {
|
|
124
121
|
buildPipelineForMaterialShader,
|
|
125
122
|
type PipelineBuilderContext,
|
|
@@ -907,16 +904,14 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
|
|
|
907
904
|
expect(result.value).toBe(false);
|
|
908
905
|
});
|
|
909
906
|
|
|
910
|
-
it('buildPbrViewBglEntries with UNIFORM_CAPS
|
|
907
|
+
it('buildPbrViewBglEntries with UNIFORM_CAPS has no local-light bindings', () => {
|
|
911
908
|
const entries = buildPbrViewBglEntries(UNIFORM_CAPS);
|
|
912
|
-
expect(entries
|
|
913
|
-
expect(entries[2]?.buffer?.type).toBe('uniform');
|
|
909
|
+
expect(entries.some((entry) => entry.binding === 1 || entry.binding === 2)).toBe(false);
|
|
914
910
|
});
|
|
915
911
|
|
|
916
|
-
it('buildPbrViewBglEntries with STORAGE_CAPS
|
|
912
|
+
it('buildPbrViewBglEntries with STORAGE_CAPS has no local-light bindings', () => {
|
|
917
913
|
const entries = buildPbrViewBglEntries(STORAGE_CAPS);
|
|
918
|
-
expect(entries
|
|
919
|
-
expect(entries[2]?.buffer?.type).toBe('read-only-storage');
|
|
914
|
+
expect(entries.some((entry) => entry.binding === 1 || entry.binding === 2)).toBe(false);
|
|
920
915
|
});
|
|
921
916
|
});
|
|
922
917
|
|
|
@@ -993,14 +988,15 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
|
|
|
993
988
|
expect(instBgl?.entries[0]?.buffer?.type).toBe('uniform');
|
|
994
989
|
});
|
|
995
990
|
|
|
996
|
-
it('uniform fallback: view BGL
|
|
991
|
+
it('uniform fallback: view BGL has no local-light bindings', () => {
|
|
997
992
|
const device = makeMockDevice();
|
|
998
993
|
// biome-ignore lint/suspicious/noExplicitAny: structural mock
|
|
999
994
|
buildPbrPipelineLayouts(device as any, UNIFORM_CAPS);
|
|
1000
995
|
const viewBgl = device.capturedBgls.find((b) => b.label === 'pbr-view-bgl');
|
|
1001
996
|
expect(viewBgl).toBeDefined();
|
|
1002
|
-
expect(viewBgl?.entries
|
|
1003
|
-
|
|
997
|
+
expect(viewBgl?.entries.some((entry) => entry.binding === 1 || entry.binding === 2)).toBe(
|
|
998
|
+
false,
|
|
999
|
+
);
|
|
1004
1000
|
});
|
|
1005
1001
|
|
|
1006
1002
|
it('storage path: mesh-array BGL entry type is read-only-storage (regression guard)', () => {
|
|
@@ -2122,13 +2118,13 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
|
|
|
2122
2118
|
// sprite; the test locks the outcome so an inline-logic drift is caught.
|
|
2123
2119
|
function resolveBootDefinesKey(
|
|
2124
2120
|
msEntry: MaterialShaderManifestEntry,
|
|
2125
|
-
opts: { readonly storageBufferCapable: boolean; readonly
|
|
2121
|
+
opts: { readonly storageBufferCapable: boolean; readonly clusteredLighting: boolean },
|
|
2126
2122
|
): string {
|
|
2127
2123
|
const variantDefines: Record<string, boolean> = {
|
|
2128
2124
|
STORAGE_BUFFER_AVAILABLE: opts.storageBufferCapable,
|
|
2129
2125
|
};
|
|
2130
2126
|
if (msEntry.variants.some((v) => 'CLUSTER_FORWARD_AVAILABLE' in v.defines)) {
|
|
2131
|
-
variantDefines.CLUSTER_FORWARD_AVAILABLE = opts.
|
|
2127
|
+
variantDefines.CLUSTER_FORWARD_AVAILABLE = opts.clusteredLighting;
|
|
2132
2128
|
}
|
|
2133
2129
|
if (msEntry.variants.some((v) => 'PER_INSTANCE_REGION' in v.defines)) {
|
|
2134
2130
|
variantDefines.PER_INSTANCE_REGION = false;
|
|
@@ -2145,7 +2141,7 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
|
|
|
2145
2141
|
const spriteEntry = makeSpriteEntry();
|
|
2146
2142
|
const definesKey = resolveBootDefinesKey(spriteEntry, {
|
|
2147
2143
|
storageBufferCapable: true,
|
|
2148
|
-
|
|
2144
|
+
clusteredLighting: false,
|
|
2149
2145
|
});
|
|
2150
2146
|
expect(definesKey).toBe('PER_INSTANCE_REGION=false+STORAGE_BUFFER_AVAILABLE=true');
|
|
2151
2147
|
const variant = findVariantByKey(spriteEntry, definesKey);
|
|
@@ -2162,7 +2158,7 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
|
|
|
2162
2158
|
const spriteEntry = makeSpriteEntry();
|
|
2163
2159
|
const definesKey = resolveBootDefinesKey(spriteEntry, {
|
|
2164
2160
|
storageBufferCapable: false,
|
|
2165
|
-
|
|
2161
|
+
clusteredLighting: false,
|
|
2166
2162
|
});
|
|
2167
2163
|
expect(definesKey).toBe('PER_INSTANCE_REGION=false+STORAGE_BUFFER_AVAILABLE=false');
|
|
2168
2164
|
const variant = findVariantByKey(spriteEntry, definesKey);
|
|
@@ -2197,7 +2193,7 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
|
|
|
2197
2193
|
const spriteEntry = makeSpriteEntry();
|
|
2198
2194
|
const bootDefinesKey = resolveBootDefinesKey(spriteEntry, {
|
|
2199
2195
|
storageBufferCapable: true,
|
|
2200
|
-
|
|
2196
|
+
clusteredLighting: false,
|
|
2201
2197
|
});
|
|
2202
2198
|
const bootVariant = findVariantByKey(spriteEntry, bootDefinesKey);
|
|
2203
2199
|
const spriteInstancesVariant = findVariantByKey(spriteEntry, '');
|
|
@@ -4934,40 +4930,49 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
|
|
|
4934
4930
|
}
|
|
4935
4931
|
|
|
4936
4932
|
{
|
|
4937
|
-
// --- from
|
|
4938
|
-
describe('
|
|
4933
|
+
// --- from standard-profile-invalid.test.ts ---
|
|
4934
|
+
describe('standard-profile-invalid', () => {
|
|
4939
4935
|
describe('validateClusterGrid', () => {
|
|
4940
4936
|
// AC-23 scenario (a): x === 0
|
|
4941
|
-
it('rejects x=0 with code
|
|
4937
|
+
it('rejects x=0 with code standard-profile-invalid and hint contains "positive integer"', () => {
|
|
4942
4938
|
const result = validateClusterGrid({ x: 0, y: 9, z: 24 });
|
|
4943
4939
|
expect(result.ok).toBe(false);
|
|
4944
4940
|
if (!result.ok) {
|
|
4945
|
-
expect(result.error.code).toBe('
|
|
4941
|
+
expect(result.error.code).toBe('standard-profile-invalid');
|
|
4946
4942
|
expect(result.error.hint).toContain('positive integer');
|
|
4947
4943
|
expect(result.error.expected).toContain('[1, 64]');
|
|
4948
|
-
expect(result.error.detail).toEqual({
|
|
4944
|
+
expect(result.error.detail).toEqual({
|
|
4945
|
+
field: 'clusterGrid',
|
|
4946
|
+
actual: { x: 0, y: 9, z: 24 },
|
|
4947
|
+
});
|
|
4949
4948
|
}
|
|
4950
4949
|
});
|
|
4951
4950
|
|
|
4952
4951
|
// AC-23 scenario (b): non-integer x=1.5
|
|
4953
|
-
it('rejects x=1.5 (non-integer) with code
|
|
4952
|
+
it('rejects x=1.5 (non-integer) with code standard-profile-invalid', () => {
|
|
4954
4953
|
const result = validateClusterGrid({ x: 1.5, y: 9, z: 24 });
|
|
4955
4954
|
expect(result.ok).toBe(false);
|
|
4956
4955
|
if (!result.ok) {
|
|
4957
|
-
expect(result.error.code).toBe('
|
|
4956
|
+
expect(result.error.code).toBe('standard-profile-invalid');
|
|
4958
4957
|
expect(result.error.hint).toContain('positive integer');
|
|
4959
|
-
expect(result.error.detail).toEqual({
|
|
4958
|
+
expect(result.error.detail).toEqual({
|
|
4959
|
+
field: 'clusterGrid',
|
|
4960
|
+
actual: { x: 1.5, y: 9, z: 24 },
|
|
4961
|
+
});
|
|
4960
4962
|
}
|
|
4961
4963
|
});
|
|
4962
4964
|
|
|
4963
4965
|
// AC-23 scenario (c): y === -1
|
|
4964
|
-
it('rejects y=-1 with code
|
|
4966
|
+
it('rejects y=-1 with code standard-profile-invalid', () => {
|
|
4965
4967
|
const result = validateClusterGrid({ x: 16, y: -1, z: 24 });
|
|
4966
4968
|
expect(result.ok).toBe(false);
|
|
4967
4969
|
if (!result.ok) {
|
|
4968
|
-
expect(result.error.code).toBe('
|
|
4970
|
+
expect(result.error.code).toBe('standard-profile-invalid');
|
|
4969
4971
|
expect(result.error.hint).toContain('positive integer');
|
|
4970
|
-
expect(result.error.detail).toEqual({
|
|
4972
|
+
expect(result.error.detail).toEqual({
|
|
4973
|
+
field: 'clusterGrid',
|
|
4974
|
+
actual: { x: 16, y: -1, z: 24 },
|
|
4975
|
+
});
|
|
4971
4976
|
}
|
|
4972
4977
|
});
|
|
4973
4978
|
|
|
@@ -4976,10 +4981,13 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
|
|
|
4976
4981
|
const result = validateClusterGrid({ x: 16, y: 9, z: 65 });
|
|
4977
4982
|
expect(result.ok).toBe(false);
|
|
4978
4983
|
if (!result.ok) {
|
|
4979
|
-
expect(result.error.code).toBe('
|
|
4984
|
+
expect(result.error.code).toBe('standard-profile-invalid');
|
|
4980
4985
|
expect(result.error.hint).toContain('[1, 64]');
|
|
4981
4986
|
expect(result.error.expected).toContain('[1, 64]');
|
|
4982
|
-
expect(result.error.detail).toEqual({
|
|
4987
|
+
expect(result.error.detail).toEqual({
|
|
4988
|
+
field: 'clusterGrid',
|
|
4989
|
+
actual: { x: 16, y: 9, z: 65 },
|
|
4990
|
+
});
|
|
4983
4991
|
}
|
|
4984
4992
|
});
|
|
4985
4993
|
|
|
@@ -5001,21 +5009,24 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
|
|
|
5001
5009
|
});
|
|
5002
5010
|
});
|
|
5003
5011
|
|
|
5004
|
-
describe('
|
|
5012
|
+
describe('StandardProfileInvalidError shape', () => {
|
|
5005
5013
|
// AC-23: charter P3 triple-set .code / .hint / .expected + .detail.{x,y,z}
|
|
5006
5014
|
it('carries charter P3 triple-set with detail', () => {
|
|
5007
|
-
const err = new
|
|
5008
|
-
|
|
5015
|
+
const err = new StandardProfileInvalidError('clusterGrid invalid', {
|
|
5016
|
+
field: 'clusterGrid',
|
|
5017
|
+
actual: { x: 0, y: 9, z: 24 },
|
|
5018
|
+
});
|
|
5019
|
+
expect(err.code).toBe('standard-profile-invalid');
|
|
5009
5020
|
expect(err.hint).toContain('positive integer');
|
|
5010
5021
|
expect(err.expected).toContain('[1, 64]');
|
|
5011
|
-
expect(err.detail).toEqual({ x: 0, y: 9, z: 24 });
|
|
5022
|
+
expect(err.detail).toEqual({ field: 'clusterGrid', actual: { x: 0, y: 9, z: 24 } });
|
|
5012
5023
|
});
|
|
5013
5024
|
});
|
|
5014
5025
|
});
|
|
5015
5026
|
}
|
|
5016
5027
|
|
|
5017
5028
|
{
|
|
5018
|
-
// --- from
|
|
5029
|
+
// --- from standard-cluster-index-overflow-once.test.ts ---
|
|
5019
5030
|
function makeIdentityMat4(): Mat4 {
|
|
5020
5031
|
return mat4.identity(mat4.create());
|
|
5021
5032
|
}
|
|
@@ -5076,194 +5087,25 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
|
|
|
5076
5087
|
});
|
|
5077
5088
|
});
|
|
5078
5089
|
|
|
5079
|
-
// ──
|
|
5090
|
+
// ── Standard cluster index-overflow RuntimeError class shape ─────────────────
|
|
5080
5091
|
|
|
5081
|
-
// ──
|
|
5082
|
-
//
|
|
5083
|
-
// This test verifies that the existing URP `render-system-multi-light` warn-once
|
|
5084
|
-
// still fires when PointLights exceed the first-slice cap (AC-03).
|
|
5085
|
-
// The budget constant LIGHT_ARRAY_MAX_SLOTS must remain at 4.
|
|
5092
|
+
// ── Unified Cluster light ABI guardrails ─────────────────────────────────
|
|
5086
5093
|
|
|
5087
|
-
describe('
|
|
5088
|
-
it('
|
|
5089
|
-
const {
|
|
5090
|
-
expect(LIGHT_ARRAY_MAX_SLOTS).toBe(4);
|
|
5091
|
-
});
|
|
5092
|
-
|
|
5093
|
-
it('POINT_LIGHT_STD430_BYTES and SPOT_LIGHT_STD430_BYTES match the current URP payloads', async () => {
|
|
5094
|
-
const { POINT_LIGHT_STD430_BYTES, SPOT_LIGHT_STD430_BYTES } = await import(
|
|
5094
|
+
describe('Standard Cluster light ABI guardrail', () => {
|
|
5095
|
+
it('BYTES_PER_DIRECT_LIGHT_SLOT matches the unified URP and HDRP ABI', async () => {
|
|
5096
|
+
const { BYTES_PER_DIRECT_LIGHT_SLOT } = await import(
|
|
5095
5097
|
'../../../render/src/light-buffer-layout'
|
|
5096
5098
|
);
|
|
5097
|
-
expect(
|
|
5098
|
-
// SpotLight's projector receiver controls and shadow intensity occupy
|
|
5099
|
-
// the appended fifth vec4 column; point-light layout remains unchanged.
|
|
5100
|
-
expect(SPOT_LIGHT_STD430_BYTES).toBe(80);
|
|
5101
|
-
});
|
|
5102
|
-
});
|
|
5103
|
-
}
|
|
5104
|
-
|
|
5105
|
-
{
|
|
5106
|
-
// --- from hdrp-light-budget.test.ts ---
|
|
5107
|
-
|
|
5108
|
-
// ── Budget gate unit logic (the pure gate w23 will embed) ─────────────────────
|
|
5109
|
-
|
|
5110
|
-
const HDRP_BUDGET = 256;
|
|
5111
|
-
|
|
5112
|
-
function enforceLightBudget(
|
|
5113
|
-
totalLightCount: number,
|
|
5114
|
-
oncePerFrameFired: Set<string>,
|
|
5115
|
-
fire: (code: string, errors: unknown[]) => void,
|
|
5116
|
-
): number {
|
|
5117
|
-
if (totalLightCount <= HDRP_BUDGET) {
|
|
5118
|
-
return totalLightCount;
|
|
5119
|
-
}
|
|
5120
|
-
// Truncate to budget.
|
|
5121
|
-
const truncated = HDRP_BUDGET;
|
|
5122
|
-
if (!oncePerFrameFired.has('hdrp-light-budget-exceeded')) {
|
|
5123
|
-
oncePerFrameFired.add('hdrp-light-budget-exceeded');
|
|
5124
|
-
fire('hdrp-light-budget-exceeded', [totalLightCount, HDRP_BUDGET]);
|
|
5125
|
-
}
|
|
5126
|
-
return truncated;
|
|
5127
|
-
}
|
|
5128
|
-
|
|
5129
|
-
describe('HDRP light budget enforcement gate (AC-06/AC-07)', () => {
|
|
5130
|
-
it('256 lights: no fire, returns 256 (AC-06 boundary)', () => {
|
|
5131
|
-
const fired = new Set<string>();
|
|
5132
|
-
const errors: Array<{ code: string; actual: number; budget: number }> = [];
|
|
5133
|
-
const fireFn = (_code: string, args: unknown[]) => {
|
|
5134
|
-
errors.push({
|
|
5135
|
-
code: 'hdrp-light-budget-exceeded',
|
|
5136
|
-
actual: args[0] as number,
|
|
5137
|
-
budget: args[1] as number,
|
|
5138
|
-
});
|
|
5139
|
-
};
|
|
5140
|
-
|
|
5141
|
-
const result = enforceLightBudget(256, fired, fireFn);
|
|
5142
|
-
expect(result).toBe(256);
|
|
5143
|
-
expect(fired.size).toBe(0);
|
|
5144
|
-
expect(errors.length).toBe(0);
|
|
5145
|
-
});
|
|
5146
|
-
|
|
5147
|
-
it('257 lights: fires once, returns 256 (AC-07 truncation)', () => {
|
|
5148
|
-
const fired = new Set<string>();
|
|
5149
|
-
const errors: Array<{ code: string; actual: number; budget: number }> = [];
|
|
5150
|
-
const fireFn = (_code: string, args: unknown[]) => {
|
|
5151
|
-
errors.push({
|
|
5152
|
-
code: 'hdrp-light-budget-exceeded',
|
|
5153
|
-
actual: args[0] as number,
|
|
5154
|
-
budget: args[1] as number,
|
|
5155
|
-
});
|
|
5156
|
-
};
|
|
5157
|
-
|
|
5158
|
-
const result = enforceLightBudget(257, fired, fireFn);
|
|
5159
|
-
expect(result).toBe(256);
|
|
5160
|
-
expect(fired.size).toBe(1);
|
|
5161
|
-
expect(fired.has('hdrp-light-budget-exceeded')).toBe(true);
|
|
5162
|
-
expect(errors.length).toBe(1);
|
|
5163
|
-
expect(errors[0]?.actual).toBe(257);
|
|
5164
|
-
expect(errors[0]?.budget).toBe(256);
|
|
5165
|
-
});
|
|
5166
|
-
|
|
5167
|
-
it('same-frame subsequent overflow suppressed (warn-once-per-frame-bucket)', () => {
|
|
5168
|
-
// Simulate frame already having fired the budget error.
|
|
5169
|
-
const fired = new Set<string>(['hdrp-light-budget-exceeded']);
|
|
5170
|
-
const errors: Array<{ code: string; actual: number; budget: number }> = [];
|
|
5171
|
-
const fireFn = (_code: string, args: unknown[]) => {
|
|
5172
|
-
errors.push({
|
|
5173
|
-
code: 'hdrp-light-budget-exceeded',
|
|
5174
|
-
actual: args[0] as number,
|
|
5175
|
-
budget: args[1] as number,
|
|
5176
|
-
});
|
|
5177
|
-
};
|
|
5178
|
-
|
|
5179
|
-
// Even with 300 lights on the same frame, it should not fire again.
|
|
5180
|
-
const result = enforceLightBudget(300, fired, fireFn);
|
|
5181
|
-
expect(result).toBe(256);
|
|
5182
|
-
expect(errors.length).toBe(0);
|
|
5183
|
-
});
|
|
5184
|
-
|
|
5185
|
-
it('second frame fires again (fresh bucket)', () => {
|
|
5186
|
-
// New frame: empty Set.
|
|
5187
|
-
const fired = new Set<string>();
|
|
5188
|
-
const errors: Array<{ code: string; actual: number; budget: number }> = [];
|
|
5189
|
-
const fireFn = (_code: string, args: unknown[]) => {
|
|
5190
|
-
errors.push({
|
|
5191
|
-
code: 'hdrp-light-budget-exceeded',
|
|
5192
|
-
actual: args[0] as number,
|
|
5193
|
-
budget: args[1] as number,
|
|
5194
|
-
});
|
|
5195
|
-
};
|
|
5196
|
-
|
|
5197
|
-
const result = enforceLightBudget(258, fired, fireFn);
|
|
5198
|
-
expect(result).toBe(256);
|
|
5199
|
-
expect(errors.length).toBe(1);
|
|
5200
|
-
expect(errors[0]?.actual).toBe(258);
|
|
5201
|
-
expect(errors[0]?.budget).toBe(256);
|
|
5202
|
-
});
|
|
5203
|
-
|
|
5204
|
-
it('second frame with exactly 256 does not fire (fresh bucket, under budget)', () => {
|
|
5205
|
-
const fired = new Set<string>();
|
|
5206
|
-
const errors: Array<{ code: string; actual: number; budget: number }> = [];
|
|
5207
|
-
const fireFn = (_code: string, args: unknown[]) => {
|
|
5208
|
-
errors.push({
|
|
5209
|
-
code: 'hdrp-light-budget-exceeded',
|
|
5210
|
-
actual: args[0] as number,
|
|
5211
|
-
budget: args[1] as number,
|
|
5212
|
-
});
|
|
5213
|
-
};
|
|
5214
|
-
|
|
5215
|
-
const result = enforceLightBudget(256, fired, fireFn);
|
|
5216
|
-
expect(result).toBe(256);
|
|
5217
|
-
expect(errors.length).toBe(0);
|
|
5218
|
-
});
|
|
5219
|
-
|
|
5220
|
-
it('0 lights returns 0 (empty scene, no fire)', () => {
|
|
5221
|
-
const fired = new Set<string>();
|
|
5222
|
-
const errors: Array<{ code: string; actual: number; budget: number }> = [];
|
|
5223
|
-
const fireFn = (_code: string, args: unknown[]) => {
|
|
5224
|
-
errors.push({
|
|
5225
|
-
code: 'hdrp-light-budget-exceeded',
|
|
5226
|
-
actual: args[0] as number,
|
|
5227
|
-
budget: args[1] as number,
|
|
5228
|
-
});
|
|
5229
|
-
};
|
|
5230
|
-
|
|
5231
|
-
const result = enforceLightBudget(0, fired, fireFn);
|
|
5232
|
-
expect(result).toBe(0);
|
|
5233
|
-
expect(errors.length).toBe(0);
|
|
5234
|
-
});
|
|
5235
|
-
|
|
5236
|
-
it('HDRP_BUDGET constant must be 256 (D-scope anchor)', () => {
|
|
5237
|
-
expect(HDRP_BUDGET).toBe(256);
|
|
5238
|
-
});
|
|
5239
|
-
});
|
|
5240
|
-
|
|
5241
|
-
// ── Integration pattern: once-per-frame Set complements existing hdrpOncePerFrameFired ──
|
|
5242
|
-
|
|
5243
|
-
describe('hdrpOncePerFrameFired Set dedup semantics', () => {
|
|
5244
|
-
it('empty set allows first fire', () => {
|
|
5245
|
-
const fired = new Set<string>();
|
|
5246
|
-
expect(fired.has('hdrp-light-budget-exceeded')).toBe(false);
|
|
5099
|
+
expect(BYTES_PER_DIRECT_LIGHT_SLOT).toBe(80);
|
|
5247
5100
|
});
|
|
5248
5101
|
|
|
5249
|
-
it('
|
|
5250
|
-
const
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
// Budget-exceeded should still fire even if index-list-overflow already fired this frame.
|
|
5257
|
-
expect(fired.has('hdrp-light-budget-exceeded')).toBe(false);
|
|
5258
|
-
expect(fired.has('hdrp-index-list-overflow')).toBe(true);
|
|
5259
|
-
});
|
|
5260
|
-
|
|
5261
|
-
it('clearing the set (new frame) allows re-fire', () => {
|
|
5262
|
-
const fired = new Set<string>(['hdrp-light-budget-exceeded']);
|
|
5263
|
-
fired.clear();
|
|
5264
|
-
expect(fired.has('hdrp-light-budget-exceeded')).toBe(false);
|
|
5265
|
-
// The recordFrame entry clears hdrpOncePerFrameFired when frameNumber increments.
|
|
5266
|
-
// This test verifies the clear() semantics match the expected new-frame behaviour.
|
|
5102
|
+
it('direct-light payload byte size matches the Cluster volume transport', async () => {
|
|
5103
|
+
const { BYTES_PER_DIRECT_LIGHT_SLOT } = await import(
|
|
5104
|
+
'../../../render/src/light-buffer-layout'
|
|
5105
|
+
);
|
|
5106
|
+
// Point, Spot, and Rect volume receivers share the same five-row
|
|
5107
|
+
// DirectLightSlot transport as the Standard surface Cluster path.
|
|
5108
|
+
expect(BYTES_PER_DIRECT_LIGHT_SLOT).toBe(80);
|
|
5267
5109
|
});
|
|
5268
5110
|
});
|
|
5269
5111
|
}
|
|
@@ -5274,13 +5116,13 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
|
|
|
5274
5116
|
it('creates a RenderPipelineAsset with HDRP pipelineId and clusterGrid config', () => {
|
|
5275
5117
|
const asset: RenderPipelineAsset = {
|
|
5276
5118
|
kind: 'render-pipeline',
|
|
5277
|
-
pipelineId: 'forgeax::
|
|
5119
|
+
pipelineId: 'forgeax::standard',
|
|
5278
5120
|
config: {
|
|
5279
5121
|
clusterGrid: { x: 16, y: 9, z: 24 },
|
|
5280
5122
|
},
|
|
5281
5123
|
};
|
|
5282
5124
|
|
|
5283
|
-
expect(asset.pipelineId).toBe('forgeax::
|
|
5125
|
+
expect(asset.pipelineId).toBe('forgeax::standard');
|
|
5284
5126
|
expect(asset.kind).toBe('render-pipeline');
|
|
5285
5127
|
expect(asset.config?.clusterGrid).toBeDefined();
|
|
5286
5128
|
expect(asset.config?.clusterGrid?.x).toBe(16);
|
|
@@ -5291,17 +5133,17 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
|
|
|
5291
5133
|
it('creates a RenderPipelineAsset with URP pipelineId and no clusterGrid', () => {
|
|
5292
5134
|
const asset: RenderPipelineAsset = {
|
|
5293
5135
|
kind: 'render-pipeline',
|
|
5294
|
-
pipelineId: 'forgeax::
|
|
5136
|
+
pipelineId: 'forgeax::standard',
|
|
5295
5137
|
};
|
|
5296
5138
|
|
|
5297
|
-
expect(asset.pipelineId).toBe('forgeax::
|
|
5139
|
+
expect(asset.pipelineId).toBe('forgeax::standard');
|
|
5298
5140
|
expect(asset.config?.clusterGrid).toBeUndefined();
|
|
5299
5141
|
});
|
|
5300
5142
|
|
|
5301
5143
|
it('passCount field is still supported alongside clusterGrid', () => {
|
|
5302
5144
|
const asset: RenderPipelineAsset = {
|
|
5303
5145
|
kind: 'render-pipeline',
|
|
5304
|
-
pipelineId: 'forgeax::
|
|
5146
|
+
pipelineId: 'forgeax::standard',
|
|
5305
5147
|
config: {
|
|
5306
5148
|
passCount: 3,
|
|
5307
5149
|
clusterGrid: { x: 16, y: 9, z: 24 },
|