@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.
Files changed (86) hide show
  1. package/README.md +105 -3
  2. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts +2 -0
  3. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts.map +1 -0
  4. package/dist/__tests__/helpers/register-runtime-components.d.ts.map +1 -1
  5. package/dist/__tests__/helpers/register-scene-components.d.ts.map +1 -1
  6. package/dist/__tests__/lights-preservation.fixup.test.d.ts +2 -0
  7. package/dist/__tests__/lights-preservation.fixup.test.d.ts.map +1 -0
  8. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  9. package/dist/createRenderer.d.ts.map +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.mjs +4 -2
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/sprite-param-values.d.ts +1 -1
  14. package/package.json +30 -30
  15. package/src/__tests__/active-camera.unit.test.ts +6 -8
  16. package/src/__tests__/animation-graph-asset-union.test-d.ts +2 -0
  17. package/src/__tests__/asset-brand-and-name-retirement.test.ts +4 -3
  18. package/src/__tests__/asset-registry-scene.test-d.ts +1 -0
  19. package/src/__tests__/asset-registry.recursive.spec.ts +1 -1
  20. package/src/__tests__/asset.unit.test.ts +5 -2
  21. package/src/__tests__/clamp-to-last.e2e.browser.test.ts +2 -2
  22. package/src/__tests__/collect-transient-roundtrip.test.ts +14 -3
  23. package/src/__tests__/components.test-d.ts +19 -9
  24. package/src/__tests__/components.unit.test.ts +36 -26
  25. package/src/__tests__/create-renderer-assembly.integration.test.ts +24 -0
  26. package/src/__tests__/create-renderer-gpu-pass-timing.unit.test.ts +32 -0
  27. package/src/__tests__/createRenderer.browser-no-webgpu.test.ts +66 -0
  28. package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +9 -1
  29. package/src/__tests__/dawn/instances-shadow.dawn.test.ts +15 -2
  30. package/src/__tests__/errors.unit.test.ts +54 -20
  31. package/src/__tests__/extract-frames-merge.test.ts +51 -40
  32. package/src/__tests__/extract-skin-mismatch.unit.test.ts +6 -2
  33. package/src/__tests__/frame-targets-shadow-regression.unit.test.ts +13 -0
  34. package/src/__tests__/geometry.unit.test.ts +7 -10
  35. package/src/__tests__/helpers/register-runtime-components.ts +9 -1
  36. package/src/__tests__/helpers/register-scene-components.ts +9 -2
  37. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +10 -11
  38. package/src/__tests__/light-snapshot.test-d.ts +35 -1
  39. package/src/__tests__/lights-preservation.fixup.test.ts +125 -0
  40. package/src/__tests__/lights.unit.test.ts +223 -308
  41. package/src/__tests__/loader-name-survival.test.ts +8 -9
  42. package/src/__tests__/package-promotion.test.ts +7 -11
  43. package/src/__tests__/pbr-pipeline.unit.test.ts +22 -17
  44. package/src/__tests__/pipeline.unit.test.ts +68 -226
  45. package/src/__tests__/point-light-shadow.browser.test.ts +209 -83
  46. package/src/__tests__/point-light-shadow.unit.test.ts +24 -47
  47. package/src/__tests__/propagate-transforms.dawn.test.ts +15 -16
  48. package/src/__tests__/record-worldid-isolation.test.ts +16 -32
  49. package/src/__tests__/render-error-exhaustive.test-d.ts +73 -9
  50. package/src/__tests__/render-system-extract.test.ts +5 -5
  51. package/src/__tests__/render-system-mega.test.ts +30 -41
  52. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +5 -1
  53. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +5 -1
  54. package/src/__tests__/render-system-skin-bg.unit.test.ts +14 -9
  55. package/src/__tests__/renderer-recover.unit.test.ts +8 -0
  56. package/src/__tests__/renderer-surface.unit.test.ts +229 -5
  57. package/src/__tests__/resolveName-unified-entry.test.ts +6 -6
  58. package/src/__tests__/resolveName.test.ts +14 -2
  59. package/src/__tests__/shadow-csm-atlas.test.ts +41 -0
  60. package/src/__tests__/shadow-csm-cascade-loadop.test.ts +1 -0
  61. package/src/__tests__/shadow-csm-component.test.ts +31 -24
  62. package/src/__tests__/shadow-csm-extract.test.ts +156 -47
  63. package/src/__tests__/shadow-csm-runtime-vary.dawn.test.ts +72 -0
  64. package/src/__tests__/shadow-csm-shader.dawn.test.ts +19 -0
  65. package/src/__tests__/shadow-csm-tile-consistency.test.ts +1 -0
  66. package/src/__tests__/shadow-csm-ubo.test.ts +43 -45
  67. package/src/__tests__/shadow-csm-viewport.browser.test.ts +29 -0
  68. package/src/__tests__/shadow-fields-observable.dawn.test.ts +100 -24
  69. package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +1 -1
  70. package/src/__tests__/skin-parented-double-transform.test.ts +5 -5
  71. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +4 -7
  72. package/src/__tests__/skylight-lazy-projection.test.ts +8 -5
  73. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -13
  74. package/src/__tests__/ssao-bgl.test.ts +12 -17
  75. package/src/__tests__/ssao-passes.test.ts +19 -2
  76. package/src/__tests__/systems.unit.test.ts +69 -205
  77. package/src/__tests__/template-game-default-api-contract.test.ts +45 -23
  78. package/src/__tests__/tilemap-chunk-extract.propagate-order.test.ts +10 -4
  79. package/src/__tests__/transform-hierarchy-pixel-diff.dawn.test.ts +2 -2
  80. package/src/__tests__/view-ubo-layout.browser.test.ts +20 -10
  81. package/src/collect-scene-asset.ts +1 -1
  82. package/src/components/__tests__/transform-vec-schema.test.ts +2 -2
  83. package/src/createRenderer.ts +2 -0
  84. package/src/index.ts +1 -2
  85. package/src/sprite-param-values.ts +1 -1
  86. 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 returns the
17
- // basename derived from its package path. Without the entry ->
18
- // Package wiring the texture would land in a null package and
19
- // resolveName would be '' -- so basename here still proves the
20
- // async load path registered the package post-roundtrip.
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
- // Single-asset texture package -> basename(path) (AC-01). The wiring is what
191
- // makes this non-empty: without entry -> Package the texture lands in a null
192
- // package and resolveName would be ''.
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 is observable through `package.xor-invariant-violated`: when the
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('abnormal single-asset-with-name state bumps the xor counter and is not overwritten', () => {
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 fires: the pre-existing name is preserved (not overwritten by
82
- // basename) and the soft-violation counter records the abnormal state.
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(1);
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: 12 entries with read-only-storage on bindings 1+2 and projector bindings 11+12', () => {
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
- // feat-20260625-spot-light-shadow-mapping: binding 8 (spot shadow atlas,
59
- // w14) raised the view BGL to 9 entries (always-on, D-5). The
60
- // Points/Lines viewport UBO occupies binding 10, while w25 (scope-amend
61
- // webkit-fallback) folded the former binding 9 spotLightViewProj matrices
62
- // UBO into the View UBO (`view.spotLightViewProj`), so the BGL stays at 9
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: bindings 1+2 fall back to uniform', () => {
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({ storageBuffer: true, projectorAvailable: false }),
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: 2 entries, both dynamic offset', () => {
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__/hdrp-grid-invalid.test.ts
35
- // - packages/runtime/src/__tests__/hdrp-index-list-overflow-once.test.ts
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 yields uniform for bindings 1 and 2', () => {
907
+ it('buildPbrViewBglEntries with UNIFORM_CAPS has no local-light bindings', () => {
911
908
  const entries = buildPbrViewBglEntries(UNIFORM_CAPS);
912
- expect(entries[1]?.buffer?.type).toBe('uniform');
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 yields read-only-storage (regression guard)', () => {
912
+ it('buildPbrViewBglEntries with STORAGE_CAPS has no local-light bindings', () => {
917
913
  const entries = buildPbrViewBglEntries(STORAGE_CAPS);
918
- expect(entries[1]?.buffer?.type).toBe('read-only-storage');
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 bindings 1+2 are uniform', () => {
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[1]?.buffer?.type).toBe('uniform');
1003
- expect(viewBgl?.entries[2]?.buffer?.type).toBe('uniform');
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 isHdrpActive: boolean },
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.isHdrpActive;
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
- isHdrpActive: false,
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
- isHdrpActive: false,
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
- isHdrpActive: false,
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 hdrp-grid-invalid.test.ts ---
4938
- describe('hdrp-grid-invalid', () => {
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 hdrp-grid-invalid and hint contains "positive integer"', () => {
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('hdrp-grid-invalid');
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({ x: 0, y: 9, z: 24 });
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 hdrp-grid-invalid', () => {
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('hdrp-grid-invalid');
4956
+ expect(result.error.code).toBe('standard-profile-invalid');
4958
4957
  expect(result.error.hint).toContain('positive integer');
4959
- expect(result.error.detail).toEqual({ x: 1.5, y: 9, z: 24 });
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 hdrp-grid-invalid', () => {
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('hdrp-grid-invalid');
4970
+ expect(result.error.code).toBe('standard-profile-invalid');
4969
4971
  expect(result.error.hint).toContain('positive integer');
4970
- expect(result.error.detail).toEqual({ x: 16, y: -1, z: 24 });
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('hdrp-grid-invalid');
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({ x: 16, y: 9, z: 65 });
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('HdrpInstallError shape', () => {
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 HdrpInstallError(0, 9, 24);
5008
- expect(err.code).toBe('hdrp-grid-invalid');
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 hdrp-index-list-overflow-once.test.ts ---
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
- // ── hdrp-index-list-overflow RuntimeError class shape ────────────────────────
5090
+ // ── Standard cluster index-overflow RuntimeError class shape ─────────────────
5080
5091
 
5081
- // ── AC-03 guardrail: URP LIGHT_ARRAY_MAX_SLOTS=4 + 5 PointLights ─────────────
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('URP light budget warn-once guardrail (AC-03)', () => {
5088
- it('LIGHT_ARRAY_MAX_SLOTS is still 4 (URP first-slice cap)', async () => {
5089
- const { LIGHT_ARRAY_MAX_SLOTS } = await import('../../../render/src/light-buffer-layout');
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(POINT_LIGHT_STD430_BYTES).toBe(32);
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('set with entry suppresses subsequent fires on the same code', () => {
5250
- const fired = new Set<string>(['hdrp-light-budget-exceeded']);
5251
- expect(fired.has('hdrp-light-budget-exceeded')).toBe(true);
5252
- });
5253
-
5254
- it('hdrp-light-budget-exceeded is independent from hdrp-index-list-overflow', () => {
5255
- const fired = new Set<string>(['hdrp-index-list-overflow']);
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::hdrp',
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::hdrp');
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::urp',
5136
+ pipelineId: 'forgeax::standard',
5295
5137
  };
5296
5138
 
5297
- expect(asset.pipelineId).toBe('forgeax::urp');
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::hdrp',
5146
+ pipelineId: 'forgeax::standard',
5305
5147
  config: {
5306
5148
  passCount: 3,
5307
5149
  clusterGrid: { x: 16, y: 9, z: 24 },