@forgeax/engine-runtime 0.1.21 → 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 +3 -3
- 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__/render-error-exhaustive.test-d.d.ts.map +1 -1
- package/dist/createRenderer.d.ts.map +1 -1
- package/dist/index.mjs +3 -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__/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.unit.test.ts +5 -2
- package/src/__tests__/collect-transient-roundtrip.test.ts +14 -3
- package/src/__tests__/components.test-d.ts +9 -6
- package/src/__tests__/components.unit.test.ts +23 -19
- package/src/__tests__/errors.unit.test.ts +3 -0
- package/src/__tests__/extract-frames-merge.test.ts +3 -0
- 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 +8 -10
- package/src/__tests__/light-snapshot.test-d.ts +27 -1
- package/src/__tests__/lights-preservation.fixup.test.ts +19 -21
- package/src/__tests__/lights.unit.test.ts +2076 -1
- 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 +16 -16
- package/src/__tests__/pipeline.unit.test.ts +20 -24
- package/src/__tests__/point-light-shadow.browser.test.ts +1 -0
- package/src/__tests__/point-light-shadow.unit.test.ts +18 -98
- 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 +10 -0
- package/src/__tests__/render-system-extract.test.ts +5 -5
- package/src/__tests__/render-system-record-multi-material-textureview.test.ts +1 -0
- package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +1 -0
- package/src/__tests__/renderer-surface.unit.test.ts +0 -2
- package/src/__tests__/resolveName-unified-entry.test.ts +6 -6
- package/src/__tests__/resolveName.test.ts +14 -2
- package/src/__tests__/shadow-csm-cascade-loadop.test.ts +1 -0
- package/src/__tests__/shadow-csm-tile-consistency.test.ts +1 -0
- package/src/__tests__/shadow-csm-ubo.test.ts +3 -1
- package/src/__tests__/shadow-fields-observable.dawn.test.ts +15 -4
- 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 +1 -6
- package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -13
- package/src/__tests__/ssao-bgl.test.ts +2 -1
- package/src/__tests__/systems.unit.test.ts +25 -174
- 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/collect-scene-asset.ts +1 -1
- package/src/components/__tests__/transform-vec-schema.test.ts +2 -2
- package/src/createRenderer.ts +1 -0
- 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();
|
|
@@ -904,16 +904,14 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
|
|
|
904
904
|
expect(result.value).toBe(false);
|
|
905
905
|
});
|
|
906
906
|
|
|
907
|
-
it('buildPbrViewBglEntries with UNIFORM_CAPS
|
|
907
|
+
it('buildPbrViewBglEntries with UNIFORM_CAPS has no local-light bindings', () => {
|
|
908
908
|
const entries = buildPbrViewBglEntries(UNIFORM_CAPS);
|
|
909
|
-
expect(entries
|
|
910
|
-
expect(entries[2]?.buffer?.type).toBe('uniform');
|
|
909
|
+
expect(entries.some((entry) => entry.binding === 1 || entry.binding === 2)).toBe(false);
|
|
911
910
|
});
|
|
912
911
|
|
|
913
|
-
it('buildPbrViewBglEntries with STORAGE_CAPS
|
|
912
|
+
it('buildPbrViewBglEntries with STORAGE_CAPS has no local-light bindings', () => {
|
|
914
913
|
const entries = buildPbrViewBglEntries(STORAGE_CAPS);
|
|
915
|
-
expect(entries
|
|
916
|
-
expect(entries[2]?.buffer?.type).toBe('read-only-storage');
|
|
914
|
+
expect(entries.some((entry) => entry.binding === 1 || entry.binding === 2)).toBe(false);
|
|
917
915
|
});
|
|
918
916
|
});
|
|
919
917
|
|
|
@@ -990,14 +988,15 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
|
|
|
990
988
|
expect(instBgl?.entries[0]?.buffer?.type).toBe('uniform');
|
|
991
989
|
});
|
|
992
990
|
|
|
993
|
-
it('uniform fallback: view BGL
|
|
991
|
+
it('uniform fallback: view BGL has no local-light bindings', () => {
|
|
994
992
|
const device = makeMockDevice();
|
|
995
993
|
// biome-ignore lint/suspicious/noExplicitAny: structural mock
|
|
996
994
|
buildPbrPipelineLayouts(device as any, UNIFORM_CAPS);
|
|
997
995
|
const viewBgl = device.capturedBgls.find((b) => b.label === 'pbr-view-bgl');
|
|
998
996
|
expect(viewBgl).toBeDefined();
|
|
999
|
-
expect(viewBgl?.entries
|
|
1000
|
-
|
|
997
|
+
expect(viewBgl?.entries.some((entry) => entry.binding === 1 || entry.binding === 2)).toBe(
|
|
998
|
+
false,
|
|
999
|
+
);
|
|
1001
1000
|
});
|
|
1002
1001
|
|
|
1003
1002
|
it('storage path: mesh-array BGL entry type is read-only-storage (regression guard)', () => {
|
|
@@ -5090,26 +5089,23 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
|
|
|
5090
5089
|
|
|
5091
5090
|
// ── Standard cluster index-overflow RuntimeError class shape ─────────────────
|
|
5092
5091
|
|
|
5093
|
-
// ──
|
|
5094
|
-
//
|
|
5095
|
-
// This test verifies that the existing URP `render-system-multi-light` warn-once
|
|
5096
|
-
// still fires when PointLights exceed the first-slice cap (AC-03).
|
|
5097
|
-
// The budget constant LIGHT_ARRAY_MAX_SLOTS must remain at 4.
|
|
5092
|
+
// ── Unified Cluster light ABI guardrails ─────────────────────────────────
|
|
5098
5093
|
|
|
5099
|
-
describe('
|
|
5100
|
-
it('
|
|
5101
|
-
const {
|
|
5102
|
-
|
|
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(
|
|
5097
|
+
'../../../render/src/light-buffer-layout'
|
|
5098
|
+
);
|
|
5099
|
+
expect(BYTES_PER_DIRECT_LIGHT_SLOT).toBe(80);
|
|
5103
5100
|
});
|
|
5104
5101
|
|
|
5105
|
-
it('
|
|
5106
|
-
const {
|
|
5102
|
+
it('direct-light payload byte size matches the Cluster volume transport', async () => {
|
|
5103
|
+
const { BYTES_PER_DIRECT_LIGHT_SLOT } = await import(
|
|
5107
5104
|
'../../../render/src/light-buffer-layout'
|
|
5108
5105
|
);
|
|
5109
|
-
|
|
5110
|
-
//
|
|
5111
|
-
|
|
5112
|
-
expect(SPOT_LIGHT_STD430_BYTES).toBe(80);
|
|
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);
|
|
5113
5109
|
});
|
|
5114
5110
|
});
|
|
5115
5111
|
}
|
|
@@ -19,10 +19,9 @@ import { describe, expect, it } from 'vitest';
|
|
|
19
19
|
import { validatePointLightShadowData } from '../../../render/src/components/light-helpers';
|
|
20
20
|
import { ShadowInvalidConfigError } from '../../../render/src/errors/render';
|
|
21
21
|
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
SPOT_LIGHT_PROJECTOR_FLAG,
|
|
22
|
+
BYTES_PER_DIRECT_LIGHT_SLOT,
|
|
23
|
+
DIRECT_LIGHT_SLOT_LAYOUT,
|
|
24
|
+
packDirectLightSlot,
|
|
26
25
|
} from '../../../render/src/light-buffer-layout';
|
|
27
26
|
import { buildPointShadowMatrices } from '../../../render/src/render-system-extract';
|
|
28
27
|
import {
|
|
@@ -551,7 +550,7 @@ function spawnValidatedPointShadow(
|
|
|
551
550
|
});
|
|
552
551
|
|
|
553
552
|
describe('standard point shadow light-slot packing (AC-05)', () => {
|
|
554
|
-
it('AC-13:
|
|
553
|
+
it('AC-13: packDirectLightSlot writes point shadow identity metadata', () => {
|
|
555
554
|
const snap = {
|
|
556
555
|
kind: 'point' as const,
|
|
557
556
|
position: vec3.create(1, 2, 3),
|
|
@@ -559,26 +558,18 @@ function spawnValidatedPointShadow(
|
|
|
559
558
|
intensity: 1,
|
|
560
559
|
invRangeSquared: 0.04,
|
|
561
560
|
};
|
|
562
|
-
// Without shadow info: layer = -1 sentinel
|
|
563
|
-
const noShadow =
|
|
561
|
+
// Without shadow info: layer = -1 sentinel in metadata word 17.
|
|
562
|
+
const noShadow = packDirectLightSlot(snap);
|
|
564
563
|
const i32NoShadow = new Int32Array(noShadow.buffer);
|
|
565
|
-
expect(i32NoShadow[
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
// With shadow info: layer 2, near 0.1, far 25 — matches PointLightShadow defaults.
|
|
570
|
-
const withShadow = packLightSlot(snap, {
|
|
571
|
-
shadowAtlasLayer: 2,
|
|
572
|
-
near: 0.1,
|
|
573
|
-
far: 25,
|
|
574
|
-
});
|
|
564
|
+
expect(i32NoShadow[17]).toBe(-1); // sentinel
|
|
565
|
+
|
|
566
|
+
// Extract joins the PointLightShadow layer before packing.
|
|
567
|
+
const withShadow = packDirectLightSlot({ ...snap, shadowAtlasLayer: 2 });
|
|
575
568
|
const i32Shadow = new Int32Array(withShadow.buffer);
|
|
576
|
-
expect(i32Shadow[
|
|
577
|
-
expect(withShadow[14]).toBeCloseTo(0.1, 5);
|
|
578
|
-
expect(withShadow[15]).toBe(25);
|
|
569
|
+
expect(i32Shadow[17]).toBe(2);
|
|
579
570
|
});
|
|
580
571
|
|
|
581
|
-
it('AC-13:
|
|
572
|
+
it('AC-13: unified Spot packing writes shadow metadata', () => {
|
|
582
573
|
const spotSnap = {
|
|
583
574
|
kind: 'spot' as const,
|
|
584
575
|
entity: 0,
|
|
@@ -596,86 +587,15 @@ function spawnValidatedPointShadow(
|
|
|
596
587
|
farPlane: 50,
|
|
597
588
|
shadowAtlasTile: -1,
|
|
598
589
|
};
|
|
599
|
-
|
|
600
|
-
// PCF-kernel defaults. The atlas tile remains the -1 sentinel while
|
|
601
|
-
// projector/shadow consumers read the appended payload fields.
|
|
602
|
-
const packed = packLightSlot(spotSnap, {
|
|
603
|
-
shadowAtlasLayer: 0,
|
|
604
|
-
near: 0.1,
|
|
605
|
-
far: 25,
|
|
606
|
-
});
|
|
590
|
+
const packed = packDirectLightSlot(spotSnap);
|
|
607
591
|
const i32 = new Int32Array(packed.buffer);
|
|
608
|
-
expect(i32[
|
|
609
|
-
expect(packed[14]).toBeCloseTo(1, 5);
|
|
610
|
-
expect(packed[15]).toBeCloseTo(3, 5);
|
|
592
|
+
expect(i32[17]).toBe(-1);
|
|
611
593
|
});
|
|
612
594
|
|
|
613
|
-
it('
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
position: vec3.create(0, 0, 0),
|
|
618
|
-
direction: vec3.create(0, -1, 0),
|
|
619
|
-
color: vec3.create(1, 1, 1),
|
|
620
|
-
intensity: 1,
|
|
621
|
-
invRangeSquared: 0,
|
|
622
|
-
cosInner: 0.95,
|
|
623
|
-
cosOuter: 0.85,
|
|
624
|
-
castShadow: true,
|
|
625
|
-
lightViewProj: undefined,
|
|
626
|
-
mapSize: 2048,
|
|
627
|
-
nearPlane: 0.1,
|
|
628
|
-
farPlane: 50,
|
|
629
|
-
shadowAtlasTile: 2,
|
|
630
|
-
shadowIntensity: 0.35,
|
|
631
|
-
pcfKernelSize: 5,
|
|
632
|
-
};
|
|
633
|
-
const packed = packLightSlot(spotSnap);
|
|
634
|
-
const i32 = new Int32Array(packed.buffer);
|
|
635
|
-
expect(i32[13]).toBe(2);
|
|
636
|
-
expect(packed[14]).toBeCloseTo(0.35, 5);
|
|
637
|
-
expect(packed[15]).toBeCloseTo(5, 5);
|
|
638
|
-
});
|
|
639
|
-
|
|
640
|
-
it('marks the accepted projector without losing a shadow tile', () => {
|
|
641
|
-
const spotSnap = {
|
|
642
|
-
kind: 'spot' as const,
|
|
643
|
-
entity: 7,
|
|
644
|
-
position: vec3.create(0, 2, 0),
|
|
645
|
-
direction: vec3.create(0, -1, 0),
|
|
646
|
-
color: vec3.create(1, 1, 1),
|
|
647
|
-
intensity: 1,
|
|
648
|
-
invRangeSquared: 0.04,
|
|
649
|
-
cosInner: 0.95,
|
|
650
|
-
cosOuter: 0.85,
|
|
651
|
-
castShadow: true,
|
|
652
|
-
lightViewProj: new Float32Array(16),
|
|
653
|
-
mapSize: 512,
|
|
654
|
-
nearPlane: 0.1,
|
|
655
|
-
farPlane: 25,
|
|
656
|
-
shadowAtlasTile: 2,
|
|
657
|
-
projectorAsset: { kind: 'texture' } as never,
|
|
658
|
-
};
|
|
659
|
-
const packed = packLightSlot(spotSnap, undefined, true);
|
|
660
|
-
const encoded = new Int32Array(packed.buffer)[13] ?? 0;
|
|
661
|
-
expect(encoded & SPOT_LIGHT_PROJECTOR_FLAG).toBe(SPOT_LIGHT_PROJECTOR_FLAG);
|
|
662
|
-
expect(encoded & (SPOT_LIGHT_PROJECTOR_FLAG - 1)).toBe(2);
|
|
663
|
-
|
|
664
|
-
const projectorOnly = packLightSlot({ ...spotSnap, shadowAtlasTile: -1 }, undefined, true);
|
|
665
|
-
const projectorOnlyEncoded = new Int32Array(projectorOnly.buffer)[13] ?? 0;
|
|
666
|
-
expect(projectorOnlyEncoded).toBe(SPOT_LIGHT_PROJECTOR_FLAG);
|
|
667
|
-
});
|
|
668
|
-
|
|
669
|
-
it('AC-05: LIGHTSLOT_LAYOUT byteSize unchanged at 64 (no-regression for standard)', () => {
|
|
670
|
-
// The pad-lane re-purposing must not change the total LightSlot size:
|
|
671
|
-
// the std430 vec4 stride is preserved; only the field semantics within
|
|
672
|
-
// bytes 52..64 changed (u32 pad -> i32 layer + f32 near + f32 far).
|
|
673
|
-
expect(BYTES_PER_LIGHT_SLOT).toBe(64);
|
|
674
|
-
expect(LIGHTSLOT_LAYOUT.byteSize).toBe(64);
|
|
675
|
-
expect(LIGHTSLOT_LAYOUT.shadowAtlasLayerOffset).toBe(52);
|
|
676
|
-
expect(LIGHTSLOT_LAYOUT.shadowPayloadOffset).toBe(52);
|
|
677
|
-
expect(LIGHTSLOT_LAYOUT.shadowNearOffset).toBe(56);
|
|
678
|
-
expect(LIGHTSLOT_LAYOUT.shadowFarOffset).toBe(60);
|
|
595
|
+
it('AC-05: DIRECT_LIGHT_SLOT_LAYOUT keeps the five-row ABI', () => {
|
|
596
|
+
expect(BYTES_PER_DIRECT_LIGHT_SLOT).toBe(80);
|
|
597
|
+
expect(DIRECT_LIGHT_SLOT_LAYOUT.byteSize).toBe(80);
|
|
598
|
+
expect(DIRECT_LIGHT_SLOT_LAYOUT.shadowByteOffset).toBe(68);
|
|
679
599
|
});
|
|
680
600
|
});
|
|
681
601
|
|
|
@@ -5,13 +5,12 @@
|
|
|
5
5
|
// Environment: node + setupFiles ./vitest.setup-webgpu.ts injects
|
|
6
6
|
// globalThis.navigator.gpu (provided by dawn.node native binding).
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// translation column (m[12,13,14]).
|
|
8
|
+
// The derived world mat4 lives in the paired `GlobalTransform.world` column
|
|
9
|
+
// (column-major 16 floats). Tests read that column via the ECS array view and
|
|
10
|
+
// assert the translation column (m[12,13,14]).
|
|
12
11
|
//
|
|
13
12
|
// Scope (w6 acceptanceCheck):
|
|
14
|
-
// (a) root-only entity:
|
|
13
|
+
// (a) root-only entity: GlobalTransform.world translation = local translation
|
|
15
14
|
// (b) 2-level hierarchy: child.world = parent.world x child local
|
|
16
15
|
// (c) stale ChildOf ref: Result.err(RhiError({ code: 'hierarchy-broken' }))
|
|
17
16
|
// (d) N=3 deep chain: composition stacks correctly
|
|
@@ -24,7 +23,7 @@
|
|
|
24
23
|
import type { EntityHandle } from '@forgeax/engine-ecs';
|
|
25
24
|
import { World } from '@forgeax/engine-ecs';
|
|
26
25
|
import { readRenderArrayView } from '@forgeax/engine-ecs/projection';
|
|
27
|
-
import { ChildOf, propagateTransforms, Transform } from '@forgeax/engine-scene';
|
|
26
|
+
import { ChildOf, GlobalTransform, propagateTransforms, Transform } from '@forgeax/engine-scene';
|
|
28
27
|
import { describe, expect, it } from 'vitest';
|
|
29
28
|
import { TileLayer, Tilemap } from '../../../render/src/components';
|
|
30
29
|
|
|
@@ -36,16 +35,17 @@ function identityTransformData() {
|
|
|
36
35
|
};
|
|
37
36
|
}
|
|
38
37
|
|
|
39
|
-
// Read the resolved world mat4 (column-major 16 floats) from the
|
|
40
|
-
// world column array view. Translation lives in column 3
|
|
38
|
+
// Read the resolved world mat4 (column-major 16 floats) from the
|
|
39
|
+
// GlobalTransform world column array view. Translation lives in column 3
|
|
40
|
+
// (m[12,13,14]).
|
|
41
41
|
function worldOf(world: World, entity: EntityHandle): Float32Array {
|
|
42
|
-
const view = readRenderArrayView(world, entity,
|
|
43
|
-
if (view === undefined) throw new Error('
|
|
42
|
+
const view = readRenderArrayView(world, entity, GlobalTransform, 'world');
|
|
43
|
+
if (view === undefined) throw new Error('GlobalTransform.world view missing');
|
|
44
44
|
return view as Float32Array;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
describe('propagate-transforms.dawn - root-down DFS + stale ChildOf fail-fast (AC-04)', () => {
|
|
48
|
-
it('root entity:
|
|
48
|
+
it('root entity: GlobalTransform.world translation = local translation (identity chain)', () => {
|
|
49
49
|
const world = new World();
|
|
50
50
|
const root = world
|
|
51
51
|
.spawn({
|
|
@@ -153,7 +153,7 @@ describe('propagate-transforms.dawn - root-down DFS + stale ChildOf fail-fast (A
|
|
|
153
153
|
|
|
154
154
|
// AC-07 (tweak-20260714-tilemap-layer-childed-render-entities M5): TileLayer
|
|
155
155
|
// as an identity middle node must be transparent to propagateTransforms -- the
|
|
156
|
-
// layer's
|
|
156
|
+
// layer's GlobalTransform.world (16 f32) must equal its Tilemap parent's world mat4
|
|
157
157
|
// byte-for-byte. This proves M1's coAttach injection produces a genuinely
|
|
158
158
|
// identity TRS (pos=[0,0,0], quat=[0,0,0,1], scale=[1,1,1]) and the compose
|
|
159
159
|
// (parent.world x identity) is a no-op in mat4 form.
|
|
@@ -165,7 +165,7 @@ describe('propagate-transforms.dawn - root-down DFS + stale ChildOf fail-fast (A
|
|
|
165
165
|
// asserted here because the M1 contract is precisely "identity default,
|
|
166
166
|
// callers may override" -- the interesting invariant is transparency in the
|
|
167
167
|
// default case.
|
|
168
|
-
it('AC-07: identity TileLayer middle node ->
|
|
168
|
+
it('AC-07: identity TileLayer middle node -> GlobalTransform.world byte-identical to Tilemap parent', () => {
|
|
169
169
|
const world = new World();
|
|
170
170
|
for (const component of [ChildOf, TileLayer, Tilemap, Transform]) {
|
|
171
171
|
world.components.register(component).unwrap();
|
|
@@ -189,9 +189,8 @@ describe('propagate-transforms.dawn - root-down DFS + stale ChildOf fail-fast (A
|
|
|
189
189
|
},
|
|
190
190
|
)
|
|
191
191
|
.unwrap();
|
|
192
|
-
// TileLayer carries an
|
|
193
|
-
//
|
|
194
|
-
// apps/hello/tilemap/** and keeps the hierarchy contract explicit.
|
|
192
|
+
// TileLayer carries an identity Transform; the generic Transform requirement
|
|
193
|
+
// materializes its GlobalTransform world column at the structural boundary.
|
|
195
194
|
const layer = world
|
|
196
195
|
.spawn(
|
|
197
196
|
{
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Anchors:
|
|
8
8
|
// plan-tasks.json m2 (patch assignment)
|
|
9
|
-
// plan-strategy D-1a #1 (instanceBuffers positive half)
|
|
9
|
+
// plan-strategy D-1a #1 (instanceBuffers positive half); bind groups are now
|
|
10
|
+
// shared by opaque buffer handles after the probe/identity layout split.
|
|
10
11
|
// requirements AC-07
|
|
11
12
|
//
|
|
12
|
-
// The
|
|
13
|
+
// The production instance-buffer sites and the shared bind-group resolver are
|
|
13
14
|
// located by their stable cache operation below; source line numbers are not
|
|
14
15
|
// part of the contract and must not make this gate stale after extraction.
|
|
15
16
|
//
|
|
@@ -29,7 +30,7 @@ import { describe, expect, it } from 'vitest';
|
|
|
29
30
|
* Verify that a cache site uses a worldEntityKey composite key.
|
|
30
31
|
* Scans the source file around the given line (within a window) for both
|
|
31
32
|
* `worldEntityKey` and the cache operation (`targetPattern` such as
|
|
32
|
-
* `instanceBuffers.get`,
|
|
33
|
+
* `instanceBuffers.get`, etc.).
|
|
33
34
|
*/
|
|
34
35
|
function expectWorldEntityKeyAt(
|
|
35
36
|
filePath: string,
|
|
@@ -95,19 +96,16 @@ describe('production-path geometry instanceBuffers worldEntityKey', () => {
|
|
|
95
96
|
});
|
|
96
97
|
});
|
|
97
98
|
|
|
98
|
-
// ── Sprite identity BG instancesBgPerEntity (D-1a #3) ──────────────────────
|
|
99
|
-
|
|
100
99
|
const SPRITE_FILE = fileURLToPath(
|
|
101
100
|
new URL('../../../render/src/record/main-pass-sprite-draws.ts', import.meta.url),
|
|
102
101
|
);
|
|
103
102
|
|
|
104
|
-
describe('production-path
|
|
105
|
-
it('
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
);
|
|
103
|
+
describe('production-path shared instance bind-group resolver', () => {
|
|
104
|
+
it('sprite identity and sprite-instance paths use the shared resolver', () => {
|
|
105
|
+
const src = readFileSync(SPRITE_FILE, 'utf8');
|
|
106
|
+
expect(src).toContain('const identityInstBg = resolveGeometryInstancesBindGroup(');
|
|
107
|
+
expect(src).toContain('const spriteInstBg = resolveGeometryInstancesBindGroup(');
|
|
108
|
+
expect(src).toContain('const spriteInstancesBg = resolveGeometryInstancesBindGroup(');
|
|
111
109
|
});
|
|
112
110
|
});
|
|
113
111
|
|
|
@@ -131,18 +129,6 @@ describe('production-path sprite Instances instanceBuffers worldEntityKey', () =
|
|
|
131
129
|
});
|
|
132
130
|
});
|
|
133
131
|
|
|
134
|
-
// ── Sprite pass instancesBgPerEntity (D-1a #3) ─────────────────────────────
|
|
135
|
-
|
|
136
|
-
describe('production-path sprite pass instancesBgPerEntity worldEntityKey', () => {
|
|
137
|
-
it('spriteInstancesBg entityKey write uses worldEntityKey', () => {
|
|
138
|
-
expectWorldEntityKeyAt(
|
|
139
|
-
SPRITE_FILE,
|
|
140
|
-
findLine(SPRITE_FILE, 'frameState.instancesBgPerEntity', 1),
|
|
141
|
-
'main-pass-sprite-draws sprite pass instancesBgPerEntity write',
|
|
142
|
-
);
|
|
143
|
-
});
|
|
144
|
-
});
|
|
145
|
-
|
|
146
132
|
// ── SpriteInstances instanceBuffers (D-1a #1) ──────────────────────────────
|
|
147
133
|
|
|
148
134
|
describe('production-path SpriteInstances instanceBuffers worldEntityKey', () => {
|
|
@@ -197,15 +183,13 @@ describe('production-path shadow instanceBuffers worldEntityKey', () => {
|
|
|
197
183
|
});
|
|
198
184
|
});
|
|
199
185
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
SHADOW_FILE,
|
|
206
|
-
findLine(SHADOW_FILE, 'c.frameState.instancesBgPerEntity', 0),
|
|
207
|
-
'shadow-pass instancesBgPerEntity write',
|
|
186
|
+
describe('production-path shadow shared instance bind-group resolver', () => {
|
|
187
|
+
it('shadow pass resolves the bind group from the world-aware buffer cache', () => {
|
|
188
|
+
const src = readFileSync(SHADOW_FILE, 'utf8');
|
|
189
|
+
expect(src).toContain(
|
|
190
|
+
'const shadowInstancesBg = resolveGeometryInstancesBindGroup(c, shadowInstanceBuffer);',
|
|
208
191
|
);
|
|
192
|
+
expect(src).toContain('worldEntityKey(entry.source.worldId, shadowInst.cacheKey)');
|
|
209
193
|
});
|
|
210
194
|
});
|
|
211
195
|
|
|
@@ -128,6 +128,7 @@ function exhaustiveSwitchOnRenderCode(code: RenderErrorCode): string {
|
|
|
128
128
|
return code;
|
|
129
129
|
case 'points-lines-prepare-failed':
|
|
130
130
|
return code;
|
|
131
|
+
case 'light-resource-unavailable':
|
|
131
132
|
case 'projector-binding-failed':
|
|
132
133
|
return code;
|
|
133
134
|
case 'volume-owner-conflict':
|
|
@@ -392,6 +393,15 @@ function narrowRenderError(err: RenderError): void {
|
|
|
392
393
|
void err.detail.owner;
|
|
393
394
|
void err.detail.generation;
|
|
394
395
|
break;
|
|
396
|
+
case 'light-resource-unavailable':
|
|
397
|
+
void err.detail.entity;
|
|
398
|
+
void err.detail.feature;
|
|
399
|
+
void err.detail.generation;
|
|
400
|
+
void err.detail.sourceKey;
|
|
401
|
+
void err.detail.reason;
|
|
402
|
+
void err.expected;
|
|
403
|
+
void err.hint;
|
|
404
|
+
break;
|
|
395
405
|
case 'transmission-capability-missing':
|
|
396
406
|
void err.detail.material;
|
|
397
407
|
void err.detail.capability;
|
|
@@ -40,22 +40,22 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
|
|
|
40
40
|
{
|
|
41
41
|
// w9 -- render-system-extract consumer migration unit tests (M3, AC-07 / AC-15).
|
|
42
42
|
//
|
|
43
|
-
// Post-migration extract reads the single resolved `
|
|
43
|
+
// Post-migration extract reads the single resolved `GlobalTransform.world` mat4
|
|
44
44
|
// (written by propagateTransforms) for every world-space consumer; the legacy
|
|
45
45
|
// GlobalTransform-column-switch + per-snapshot `mat4.compose` are gone.
|
|
46
46
|
//
|
|
47
47
|
// Five consumer classes covered:
|
|
48
48
|
// 1. mesh-walk -> RenderableSnapshot.transform.world is the 16-float
|
|
49
49
|
// column-major world mat4 (parent x child), copied straight from the
|
|
50
|
-
//
|
|
50
|
+
// GlobalTransform.world view; no per-snapshot compose.
|
|
51
51
|
// 2. frustum cull -> the cull AABB follows the world mat4 (a child placed
|
|
52
52
|
// off-screen by its parent is culled; in-screen is kept) -- same-source
|
|
53
53
|
// with the rendered world (AC-05 carried forward).
|
|
54
54
|
// 3. camera view -> CameraSnapshot.position is the world-space translation
|
|
55
|
-
// (mat4.getTranslation of
|
|
55
|
+
// (mat4.getTranslation of GlobalTransform.world); a child camera reflects
|
|
56
56
|
// parent x child.
|
|
57
57
|
// 4. light position -> point / spot light position is the world-space
|
|
58
|
-
// translation extracted from
|
|
58
|
+
// translation extracted from GlobalTransform.world (mat4.getTranslation).
|
|
59
59
|
// 5. AC-15 zero-materialization -> the mesh-walk reads world through the
|
|
60
60
|
// column-level array view; it does NOT call `world.get` per renderable
|
|
61
61
|
// to materialize a `{}` whole-component object.
|
|
@@ -209,7 +209,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
|
|
|
209
209
|
expect(w?.[12]).toBeCloseTo(12, 5);
|
|
210
210
|
expect(w?.[13]).toBeCloseTo(3, 5);
|
|
211
211
|
expect(w?.[14]).toBeCloseTo(0, 5);
|
|
212
|
-
// The snapshot world must equal the entity's
|
|
212
|
+
// The snapshot world must equal the entity's GlobalTransform.world column byte-for-byte.
|
|
213
213
|
});
|
|
214
214
|
|
|
215
215
|
it('mesh-walk: flat root world mat4 equals compose(local)', () => {
|
|
@@ -180,8 +180,6 @@ function makeFrameState(): MockObj {
|
|
|
180
180
|
transientInstanceBuffers: [],
|
|
181
181
|
warnedZeroLightStandard: false,
|
|
182
182
|
warnedMultiLightDirectional: false,
|
|
183
|
-
warnedMultiLightPoint: false,
|
|
184
|
-
warnedMultiLightSpot: false,
|
|
185
183
|
warnedSkyboxTonemapNone: false,
|
|
186
184
|
warnedMissingBaseColorTextureHandles: new Set<number>(),
|
|
187
185
|
warnedNineSliceScaleEntities: new Set<number>(),
|