@forgeax/engine-runtime 0.1.19 → 0.1.20

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 (54) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/__tests__/helpers/tilemap-assets.d.ts.map +1 -1
  3. package/dist/__tests__/material-texture-dimension-mismatch.unit.test.d.ts +2 -0
  4. package/dist/__tests__/material-texture-dimension-mismatch.unit.test.d.ts.map +1 -0
  5. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  6. package/dist/__tests__/texture-dimensions.browser.test.d.ts +2 -0
  7. package/dist/__tests__/texture-dimensions.browser.test.d.ts.map +1 -0
  8. package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts +2 -0
  9. package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts.map +1 -0
  10. package/dist/createRenderer.d.ts.map +1 -1
  11. package/dist/index.mjs +1 -0
  12. package/dist/index.mjs.map +1 -1
  13. package/package.json +30 -30
  14. package/src/__tests__/asset-registry.recursive.spec.ts +12 -18
  15. package/src/__tests__/asset.unit.test.ts +28 -25
  16. package/src/__tests__/basis-catalog-dispatch.integration.test.ts +10 -9
  17. package/src/__tests__/compressed-texture-physical-extent.integration.test.ts +12 -10
  18. package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +2 -3
  19. package/src/__tests__/dawn/material-alpha.dawn.test.ts +2 -3
  20. package/src/__tests__/dawn/mipmap-srgb-linear.dawn.test.ts +2 -3
  21. package/src/__tests__/dawn/per-entity-material-texture-isolation.dawn.test.ts +2 -3
  22. package/src/__tests__/dawn/sprite-nineslice-mesh-bind.dawn.test.ts +2 -3
  23. package/src/__tests__/dawn/upload-texture.dawn.test.ts +4 -6
  24. package/src/__tests__/derive-bgl-integration.test.ts +11 -0
  25. package/src/__tests__/equirect-bc6h.integration.test.ts +6 -4
  26. package/src/__tests__/errors.unit.test.ts +20 -0
  27. package/src/__tests__/four-path-compression.integration.test.ts +2 -2
  28. package/src/__tests__/gpu-resource-cow-survivor.test.ts +2 -3
  29. package/src/__tests__/helpers/tilemap-assets.ts +3 -4
  30. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +4 -2
  31. package/src/__tests__/lights.unit.test.ts +38 -17
  32. package/src/__tests__/loadbyguid-texture-intern.unit.test.ts +3 -4
  33. package/src/__tests__/loader-name-survival.test.ts +2 -3
  34. package/src/__tests__/material-texture-dimension-mismatch.unit.test.ts +18 -0
  35. package/src/__tests__/material-texture-uv-scale.unit.test.ts +10 -2
  36. package/src/__tests__/mip-gate.unit.test.ts +11 -8
  37. package/src/__tests__/msaa-sprite-pixel-diff.dawn.test.ts +2 -3
  38. package/src/__tests__/new-kind-refs-contract.test.ts +2 -3
  39. package/src/__tests__/pbr-pipeline.unit.test.ts +23 -4
  40. package/src/__tests__/pipeline.unit.test.ts +18 -19
  41. package/src/__tests__/point-light-shadow.unit.test.ts +6 -5
  42. package/src/__tests__/render-error-exhaustive.test-d.ts +29 -0
  43. package/src/__tests__/render-system-extract.test.ts +4 -6
  44. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +4 -6
  45. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +2 -3
  46. package/src/__tests__/shadow-fields-observable.dawn.test.ts +56 -39
  47. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +3 -1
  48. package/src/__tests__/sprite-cullmode-flip.dawn.test.ts +4 -6
  49. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +4 -2
  50. package/src/__tests__/systems.unit.test.ts +15 -2
  51. package/src/__tests__/texture-dimensions.browser.test.ts +127 -0
  52. package/src/__tests__/transcode-fallback.unit.test.ts +5 -1
  53. package/src/__tests__/volumetric-fog-record-recovery.integration.test.ts +180 -0
  54. package/src/createRenderer.ts +3 -0
@@ -1295,11 +1295,12 @@ function spawnValidatedLight(
1295
1295
  });
1296
1296
  });
1297
1297
 
1298
- describe('packSpotLight - std430 64B layout (feat-20260625 M2 w6)', () => {
1298
+ describe('packSpotLight - std430 80B layout (feat-20260625 M2 w6)', () => {
1299
1299
  // feat-20260625-spot-light-shadow-mapping M2 w6 (D-4): SpotLight std430
1300
- // stride 48 -> 64 (12 -> 16 floats). Slots 0..11 keep the prior layout;
1301
- // slots 12..14 are vec4-alignment padding; slot 15 is shadowAtlasTile i32
1302
- // (sentinel -1 = unassigned/clipped) written via an Int32Array view.
1300
+ // stride 64 -> 80 (16 -> 20 floats). Slots 0..11 keep the prior layout;
1301
+ // slots 12..14 carry receiver controls, slot 15 is shadowAtlasTile i32
1302
+ // (sentinel -1 = unassigned/clipped), slot 16 carries shadowIntensity,
1303
+ // and slots 17..19 are the std430 tail pad.
1303
1304
  function makeSnap(shadowAtlasTile: number): SpotLightSnapshot {
1304
1305
  return {
1305
1306
  kind: 'spot',
@@ -1319,11 +1320,11 @@ function spawnValidatedLight(
1319
1320
  };
1320
1321
  }
1321
1322
 
1322
- it('emits 16 floats / 64 bytes; slots 0..11 unchanged from the 48B layout', () => {
1323
+ it('emits 20 floats / 80 bytes; slots 0..11 unchanged from the 48B layout', () => {
1323
1324
  const out = packSpotLight(makeSnap(0));
1324
1325
  expect(out).toBeInstanceOf(Float32Array);
1325
- expect(out.length).toBe(16);
1326
- expect(out.byteLength).toBe(64);
1326
+ expect(out.length).toBe(20);
1327
+ expect(out.byteLength).toBe(80);
1327
1328
  // Slot 0..2: position vec3.
1328
1329
  expect(out[0]).toBeCloseTo(3.0, 6);
1329
1330
  expect(out[1]).toBeCloseTo(4.0, 6);
@@ -1342,10 +1343,16 @@ function spawnValidatedLight(
1342
1343
  expect(out[10]).toBeCloseTo(0.0, 6);
1343
1344
  // Slot 11: cosOuter (packed into direction.w lane).
1344
1345
  expect(out[11]).toBeCloseTo(0.866, 6);
1345
- // Slot 12..14: vec4-alignment padding, zero-initialised.
1346
- expect(out[12]).toBe(0);
1347
- expect(out[13]).toBe(0);
1348
- expect(out[14]).toBe(0);
1346
+ // Slot 12..14: shared receiver controls.
1347
+ expect(out[12]).toBeCloseTo(0.005, 6);
1348
+ expect(out[13]).toBeCloseTo(0.05, 6);
1349
+ expect(out[14]).toBe(3);
1350
+ // Slot 16 carries the authored shadow visibility factor; the tail is
1351
+ // explicit zero padding for the 80-byte std430 array stride.
1352
+ expect(out[16]).toBe(1);
1353
+ expect(out[17]).toBe(0);
1354
+ expect(out[18]).toBe(0);
1355
+ expect(out[19]).toBe(0);
1349
1356
  });
1350
1357
 
1351
1358
  it('slot 15 carries shadowAtlasTile as i32 (tile=0)', () => {
@@ -2308,13 +2315,20 @@ function spawnValidatedLight(
2308
2315
  });
2309
2316
  });
2310
2317
 
2311
- describe('sampler reuse (binding 4) spot adds no new sampler (D-5)', () => {
2312
- it('keeps binding 4 as the single comparison sampler (shared by directional/point/spot)', () => {
2318
+ describe('sampler reuse (binding 4) plus optional projector filtering sampler', () => {
2319
+ it('keeps binding 4 as the comparison sampler and binding 12 as the projector filtering sampler', () => {
2313
2320
  const entries = buildPbrViewBglEntries({ storageBuffer: true });
2314
2321
  const samplers = entries.filter((e) => e.sampler !== undefined);
2315
- expect(samplers).toHaveLength(1);
2316
- expect(samplers[0]?.binding).toBe(4);
2317
- expect(samplers[0]?.sampler?.type).toBe('comparison');
2322
+ expect(samplers).toHaveLength(2);
2323
+ expect(samplers.find((entry) => entry.binding === 4)?.sampler?.type).toBe('comparison');
2324
+ expect(samplers.find((entry) => entry.binding === 12)?.sampler?.type).toBe('filtering');
2325
+ });
2326
+
2327
+ it('omits the projector filtering sampler on the 16-texture capability path', () => {
2328
+ const entries = buildPbrViewBglEntries({ storageBuffer: true, projectorAvailable: false });
2329
+ expect(entries.find((entry) => entry.binding === 11)).toBeUndefined();
2330
+ expect(entries.find((entry) => entry.binding === 12)).toBeUndefined();
2331
+ expect(entries.find((entry) => entry.binding === 4)?.sampler?.type).toBe('comparison');
2318
2332
  });
2319
2333
  });
2320
2334
 
@@ -2353,10 +2367,17 @@ function spawnValidatedLight(
2353
2367
  });
2354
2368
 
2355
2369
  describe('full binding roster reserves 9 for Points/Lines at 10', () => {
2356
- it('exposes bindings 0..8 plus binding 10, with binding 9 reserved', () => {
2370
+ it('exposes bindings 0..8 plus optional projector 11+12 and binding 10', () => {
2357
2371
  const bindings = buildPbrViewBglEntries({ storageBuffer: true })
2358
2372
  .map((e) => e.binding)
2359
2373
  .sort((a, b) => a - b);
2374
+ expect(bindings).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12]);
2375
+ });
2376
+
2377
+ it('drops only optional projector bindings on the low sampled-texture path', () => {
2378
+ const bindings = buildPbrViewBglEntries({ storageBuffer: true, projectorAvailable: false })
2379
+ .map((e) => e.binding)
2380
+ .sort((a, b) => a - b);
2360
2381
  expect(bindings).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 10]);
2361
2382
  });
2362
2383
  });
@@ -64,13 +64,12 @@ function registerQuadMesh(world: World): Handle<'MeshAsset', 'shared'> {
64
64
  function textureAsset(red = 255): TextureAsset {
65
65
  return {
66
66
  kind: 'texture',
67
- width: 1,
68
- height: 1,
67
+ shape: { viewDimension: '2d', extent: { width: 1, height: 1 } },
69
68
  format: 'rgba8unorm',
70
69
  data: new Uint8Array([red, 255, 255, 255]),
71
70
  colorSpace: 'linear',
72
- mipmap: false,
73
- } as unknown as TextureAsset;
71
+ mips: { kind: 'none' },
72
+ };
74
73
  }
75
74
 
76
75
  // Build a loadByGuid-shaped scene: the material's baseColorTexture paramValue is
@@ -77,11 +77,10 @@ function makeSharedPack(): unknown {
77
77
 
78
78
  const TEX_METADATA = {
79
79
  kind: 'texture',
80
- width: 1,
81
- height: 1,
80
+ shape: { viewDimension: '2d', extent: { width: 1, height: 1 } },
82
81
  format: 'rgba8unorm',
83
82
  colorSpace: 'srgb',
84
- mipmap: false,
83
+ mips: { kind: 'none' },
85
84
  };
86
85
 
87
86
  // `includeNames=false` is the FALSIFY variant (entry.name stripped).
@@ -0,0 +1,18 @@
1
+ import { derive } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ describe('material texture dimension mismatch contract', () => {
5
+ it('carries the authored array view dimension into the material binding interface', () => {
6
+ const derived = derive([{ name: 'layers', type: 'texture2d_array' }]);
7
+
8
+ expect(derived.coordinateRecords[0]?.parameter).toBe('layers');
9
+ expect(derived.bglEntries[2]?.texture?.viewDimension).toBe('2d-array');
10
+ });
11
+
12
+ it('carries the authored volume view dimension without changing the submit boundary', () => {
13
+ const derived = derive([{ name: 'volume', type: 'texture3d' }]);
14
+
15
+ expect(derived.bglEntries[2]?.texture?.viewDimension).toBe('3d');
16
+ expect(derived.resourceBindings).toHaveLength(2);
17
+ });
18
+ });
@@ -10,7 +10,10 @@ import {
10
10
 
11
11
  describe('material texture UV scale [w37]', () => {
12
12
  it('maps a non-aligned BC7 logical edge below padded physical storage', () => {
13
- const scale = materialTextureUvScale({ width: 2085, height: 1573, format: 'bc7-rgba-unorm' });
13
+ const scale = materialTextureUvScale({
14
+ shape: { viewDimension: '2d', extent: { width: 2085, height: 1573 } },
15
+ format: 'bc7-rgba-unorm',
16
+ });
14
17
  expect(scale).toEqual([2085 / 2088, 1573 / 1576]);
15
18
  expect(scale[0]).toBeLessThan(1);
16
19
  expect(scale[1]).toBeLessThan(1);
@@ -18,7 +21,12 @@ describe('material texture UV scale [w37]', () => {
18
21
 
19
22
  it('uses identity scale for uncompressed and fallback texture bindings', () => {
20
23
  expect(materialTextureUvScale(undefined)).toEqual([1, 1]);
21
- expect(materialTextureUvScale({ width: 17, height: 9, format: 'rgba8unorm' })).toEqual([1, 1]);
24
+ expect(
25
+ materialTextureUvScale({
26
+ shape: { viewDimension: '2d', extent: { width: 17, height: 9 } },
27
+ format: 'rgba8unorm',
28
+ }),
29
+ ).toEqual([1, 1]);
22
30
  });
23
31
 
24
32
  it('keeps compatibility param schemas compact when they have no textures', () => {
@@ -27,19 +27,20 @@ import { deriveRenderDataTexture } from '../../../render/src/render-data';
27
27
  function tex(overrides: Partial<TextureAsset>): TextureAsset {
28
28
  return {
29
29
  kind: 'texture',
30
- width: 8,
31
- height: 8,
30
+ shape: { viewDimension: '2d', extent: { width: 8, height: 8 } },
32
31
  format: 'rgba8unorm',
33
32
  data: new Uint8Array(8 * 8 * 4),
34
33
  colorSpace: 'linear',
35
- mipmap: false,
34
+ mips: { kind: 'none' },
36
35
  ...overrides,
37
36
  };
38
37
  }
39
38
 
40
39
  describe('deriveRenderDataTexture -- mip gate on compressed formats (w30, AC-09)', () => {
41
40
  it('compressed format + mipmap:true (runtime mip-gen requested) -> error', () => {
42
- const res = deriveRenderDataTexture(tex({ format: 'bc7-rgba-unorm', mipmap: true }));
41
+ const res = deriveRenderDataTexture(
42
+ tex({ format: 'bc7-rgba-unorm', mips: { kind: 'generate' } }),
43
+ );
43
44
  expect(res.ok).toBe(false);
44
45
  if (!res.ok) {
45
46
  expect(res.error.code).toBe('mipgen-unsupported-compressed-format');
@@ -50,7 +51,9 @@ describe('deriveRenderDataTexture -- mip gate on compressed formats (w30, AC-09)
50
51
  });
51
52
 
52
53
  it('mip-gate error fires for an ETC2 compressed format too', () => {
53
- const res = deriveRenderDataTexture(tex({ format: 'etc2-rgba8unorm', mipmap: true }));
54
+ const res = deriveRenderDataTexture(
55
+ tex({ format: 'etc2-rgba8unorm', mips: { kind: 'generate' } }),
56
+ );
54
57
  expect(res.ok).toBe(false);
55
58
  if (!res.ok) expect(res.error.code).toBe('mipgen-unsupported-compressed-format');
56
59
  });
@@ -59,20 +62,20 @@ describe('deriveRenderDataTexture -- mip gate on compressed formats (w30, AC-09)
59
62
  // The KTX2 loader baked the mip chain offline; this is the normal
60
63
  // compressed-upload path and must NOT trip the runtime-mip-gen gate.
61
64
  const res = deriveRenderDataTexture(
62
- tex({ format: 'bc7-rgba-unorm', mipmap: true, mipLevelCount: 4 }),
65
+ tex({ format: 'bc7-rgba-unorm', mips: { kind: 'packed', levelCount: 4 } }),
63
66
  );
64
67
  expect(res.ok).toBe(true);
65
68
  if (res.ok) expect(res.value.mipLevelCount).toBe(4);
66
69
  });
67
70
 
68
71
  it('compressed format + mipmap:false -> ok (single level)', () => {
69
- const res = deriveRenderDataTexture(tex({ format: 'bc7-rgba-unorm', mipmap: false }));
72
+ const res = deriveRenderDataTexture(tex({ format: 'bc7-rgba-unorm', mips: { kind: 'none' } }));
70
73
  expect(res.ok).toBe(true);
71
74
  if (res.ok) expect(res.value.mipLevelCount).toBe(1);
72
75
  });
73
76
 
74
77
  it('uncompressed format + mipmap:true -> ok (existing runtime mip-gen path unchanged)', () => {
75
- const res = deriveRenderDataTexture(tex({ format: 'rgba8unorm', mipmap: true }));
78
+ const res = deriveRenderDataTexture(tex({ format: 'rgba8unorm', mips: { kind: 'generate' } }));
76
79
  expect(res.ok).toBe(true);
77
80
  if (res.ok) expect(res.value.mipLevelCount).toBeGreaterThan(1);
78
81
  });
@@ -260,12 +260,11 @@ describe('feat-20260604-msaa M2 w9 [F-1]: LDR sprite + MSAA split sub-pass cover
260
260
  const synth = buildSyntheticRgba();
261
261
  const synthPod = {
262
262
  kind: 'texture' as const,
263
- width: synth.width,
264
- height: synth.height,
263
+ shape: { viewDimension: '2d' as const, extent: { width: synth.width, height: synth.height } },
265
264
  format: 'rgba8unorm-srgb' as const,
266
265
  data: synth.data,
267
266
  colorSpace: 'srgb' as const,
268
- mipmap: false,
267
+ mips: { kind: 'none' as const },
269
268
  };
270
269
  const TEX_GUID = '00000000-0000-7000-8000-0000000005a1';
271
270
  const SAMPLER_GUID = '00000000-0000-7000-8000-0000000005a2';
@@ -62,12 +62,11 @@ describe('AC-07 — a new reference appears in the load graph via envelope.refs'
62
62
  // new reference field whose GUID it dropped into refs[]".
63
63
  reg.catalog(parseGuid(EXTRA_REF_GUID), {
64
64
  kind: 'texture',
65
- width: 1,
66
- height: 1,
65
+ shape: { viewDimension: '2d', extent: { width: 1, height: 1 } },
67
66
  format: 'rgba8unorm',
68
67
  data: new Uint8Array(4),
69
68
  colorSpace: 'srgb',
70
- mipmap: false,
69
+ mips: { kind: 'none' },
71
70
  });
72
71
 
73
72
  const packIndex = [
@@ -44,7 +44,7 @@ function makeSpec(): PipelineSpec {
44
44
 
45
45
  describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', () => {
46
46
  describe('pbr-view', () => {
47
- it('storageBuffer=true: 10 entries with read-only-storage on bindings 1+2', () => {
47
+ it('storageBuffer=true: 12 entries with read-only-storage on bindings 1+2 and projector bindings 11+12', () => {
48
48
  const spec = makeSpec();
49
49
  const out = buildBindGroupLayoutDescriptor(spec, {
50
50
  kind: 'pbr-view',
@@ -60,11 +60,15 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
60
60
  // Points/Lines viewport UBO occupies binding 10, while w25 (scope-amend
61
61
  // webkit-fallback) folded the former binding 9 spotLightViewProj matrices
62
62
  // UBO into the View UBO (`view.spotLightViewProj`), so the BGL stays at 9
63
- // entries (bindings 0..8 plus binding 10) keeping the WebGL2 fallback fragment uniform-
64
- // buffer count <= 11 (GLES 3.0).
65
- expect(out.entries.length).toBe(10);
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);
66
68
  expect(out.entries.find((entry) => entry.binding === 9)).toBeUndefined();
67
69
  expect(out.entries.find((entry) => entry.binding === 10)?.buffer?.type).toBe('uniform');
70
+ expect(out.entries.find((entry) => entry.binding === 11)?.texture?.viewDimension).toBe('2d');
71
+ expect(out.entries.find((entry) => entry.binding === 12)?.sampler?.type).toBe('filtering');
68
72
  });
69
73
 
70
74
  it('storageBuffer=false: bindings 1+2 fall back to uniform', () => {
@@ -79,6 +83,21 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
79
83
  };
80
84
  expect(out).toEqual(expected);
81
85
  });
86
+
87
+ it('projectorAvailable=false: omits optional projector bindings for the 16-texture limit', () => {
88
+ const spec = makeSpec();
89
+ const out = buildBindGroupLayoutDescriptor(spec, {
90
+ kind: 'pbr-view',
91
+ caps: { storageBuffer: true, projectorAvailable: false },
92
+ });
93
+ expect(out).toEqual({
94
+ label: 'pbr-view-bgl',
95
+ entries: buildPbrViewBglEntries({ storageBuffer: true, projectorAvailable: false }),
96
+ });
97
+ expect(out.entries.find((entry) => entry.binding === 11)).toBeUndefined();
98
+ expect(out.entries.find((entry) => entry.binding === 12)).toBeUndefined();
99
+ expect(out.entries.find((entry) => entry.binding === 10)?.buffer?.type).toBe('uniform');
100
+ });
82
101
  });
83
102
 
84
103
  describe('pbr-material-merged', () => {
@@ -4466,12 +4466,11 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
4466
4466
  function texturePod(mipmap: boolean, format: GPUTextureFormat = 'rgba8unorm-srgb'): TextureAsset {
4467
4467
  return {
4468
4468
  kind: 'texture',
4469
- width: 2,
4470
- height: 2,
4469
+ shape: { viewDimension: '2d', extent: { width: 2, height: 2 } },
4471
4470
  format,
4472
4471
  data: new Uint8Array(2 * 2 * 4).fill(188),
4473
4472
  colorSpace: format.endsWith('-srgb') ? 'srgb' : 'linear',
4474
- mipmap,
4473
+ mips: { kind: mipmap ? 'generate' : 'none' },
4475
4474
  };
4476
4475
  }
4477
4476
 
@@ -4549,12 +4548,11 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
4549
4548
  ): TextureAsset {
4550
4549
  return {
4551
4550
  kind: 'texture',
4552
- width,
4553
- height,
4551
+ shape: { viewDimension: '2d', extent: { width, height } },
4554
4552
  format,
4555
4553
  data: new Uint8Array(width * height * 4).fill(170),
4556
4554
  colorSpace: format.endsWith('-srgb') ? 'srgb' : 'linear',
4557
- mipmap,
4555
+ mips: { kind: mipmap ? 'generate' : 'none' },
4558
4556
  };
4559
4557
  }
4560
4558
 
@@ -4566,12 +4564,11 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
4566
4564
  ): TextureAsset {
4567
4565
  return {
4568
4566
  kind: 'texture',
4569
- width,
4570
- height,
4567
+ shape: { viewDimension: '2d', extent: { width, height } },
4571
4568
  format,
4572
4569
  data: new Uint8Array(width * height * 8),
4573
4570
  colorSpace,
4574
- mipmap: false,
4571
+ mips: { kind: 'none' },
4575
4572
  };
4576
4573
  }
4577
4574
 
@@ -4658,33 +4655,35 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
4658
4655
  const source = texturePod(false, 'rgba16float');
4659
4656
  const res = deriveRenderDataTexture({
4660
4657
  ...source,
4661
- data: new Uint8Array(source.width * source.height * 8),
4658
+ data: new Uint8Array(source.shape.extent.width * source.shape.extent.height * 8),
4662
4659
  });
4663
4660
  expect(res.ok).toBe(true);
4664
4661
  if (!res.ok) return;
4665
- expect(res.value.bytesPerRow).toBe(source.width * 8);
4662
+ expect(res.value.bytesPerRow).toBe(source.shape.extent.width * 8);
4666
4663
  });
4667
4664
 
4668
4665
  it('derives row pitch from format when an rgba8 decoder retains trailing bytes', () => {
4669
4666
  const source = texturePod(false, 'rgba8unorm');
4670
4667
  const res = deriveRenderDataTexture({
4671
4668
  ...source,
4672
- data: new Uint8Array(source.width * source.height * 4 + source.width),
4669
+ data: new Uint8Array(
4670
+ source.shape.extent.width * source.shape.extent.height * 4 + source.shape.extent.width,
4671
+ ),
4673
4672
  });
4674
4673
  expect(res.ok).toBe(true);
4675
4674
  if (!res.ok) return;
4676
- expect(res.value.bytesPerRow).toBe(source.width * 4);
4675
+ expect(res.value.bytesPerRow).toBe(source.shape.extent.width * 4);
4677
4676
  });
4678
4677
 
4679
4678
  it('derives the row pitch from an rgba32float source payload', () => {
4680
4679
  const source = texturePod(false, 'rgba32float');
4681
4680
  const res = deriveRenderDataTexture({
4682
4681
  ...source,
4683
- data: new Uint8Array(source.width * source.height * 16),
4682
+ data: new Uint8Array(source.shape.extent.width * source.shape.extent.height * 16),
4684
4683
  });
4685
4684
  expect(res.ok).toBe(true);
4686
4685
  if (!res.ok) return;
4687
- expect(res.value.bytesPerRow).toBe(source.width * 16);
4686
+ expect(res.value.bytesPerRow).toBe(source.shape.extent.width * 16);
4688
4687
  });
4689
4688
 
4690
4689
  it('rejects an uncompressed payload shorter than its base mip', () => {
@@ -5091,14 +5090,14 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
5091
5090
  expect(LIGHT_ARRAY_MAX_SLOTS).toBe(4);
5092
5091
  });
5093
5092
 
5094
- it('POINT_LIGHT_STD430_BYTES and SPOT_LIGHT_STD430_BYTES still match URP specs', async () => {
5093
+ it('POINT_LIGHT_STD430_BYTES and SPOT_LIGHT_STD430_BYTES match the current URP payloads', async () => {
5095
5094
  const { POINT_LIGHT_STD430_BYTES, SPOT_LIGHT_STD430_BYTES } = await import(
5096
5095
  '../../../render/src/light-buffer-layout'
5097
5096
  );
5098
5097
  expect(POINT_LIGHT_STD430_BYTES).toBe(32);
5099
- // feat-20260625-spot-light-shadow-mapping M2 / w8 (D-4): SpotLight grew
5100
- // 48 -> 64 for the shadowAtlasTile i32 clip-signal lane (4th vec4 column).
5101
- expect(SPOT_LIGHT_STD430_BYTES).toBe(64);
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);
5102
5101
  });
5103
5102
  });
5104
5103
  }
@@ -577,7 +577,7 @@ function spawnValidatedPointShadow(
577
577
  expect(withShadow[15]).toBe(25);
578
578
  });
579
579
 
580
- it('AC-13: spot light packLightSlot leaves pad lanes at sentinel (-1, 0, 0)', () => {
580
+ it('AC-13: spot light packLightSlot writes its shadow defaults into the tail lanes', () => {
581
581
  const spotSnap = {
582
582
  kind: 'spot' as const,
583
583
  entity: 0,
@@ -595,8 +595,9 @@ function spawnValidatedPointShadow(
595
595
  farPlane: 50,
596
596
  shadowAtlasTile: -1,
597
597
  };
598
- // Even when shadow info is passed, spot lights ignore the lanes
599
- // (only point lights with shadow ride the pad lanes).
598
+ // SpotLight uses the former pad lanes for the shadow intensity and
599
+ // PCF-kernel defaults. The atlas tile remains the -1 sentinel while
600
+ // projector/shadow consumers read the appended payload fields.
600
601
  const packed = packLightSlot(spotSnap, {
601
602
  shadowAtlasLayer: 0,
602
603
  near: 0.1,
@@ -604,8 +605,8 @@ function spawnValidatedPointShadow(
604
605
  });
605
606
  const i32 = new Int32Array(packed.buffer);
606
607
  expect(i32[13]).toBe(-1); // sentinel; spot lights stay shadowless
607
- expect(packed[14]).toBe(0);
608
- expect(packed[15]).toBe(0);
608
+ expect(packed[14]).toBeCloseTo(1, 5);
609
+ expect(packed[15]).toBeCloseTo(3, 5);
609
610
  });
610
611
 
611
612
  it('AC-05: LIGHTSLOT_LAYOUT byteSize unchanged at 64 (no-regression for standard)', () => {
@@ -114,6 +114,16 @@ function exhaustiveSwitchOnRenderCode(code: RenderErrorCode): string {
114
114
  return code;
115
115
  case 'points-lines-prepare-failed':
116
116
  return code;
117
+ case 'projector-binding-failed':
118
+ return code;
119
+ case 'volume-owner-conflict':
120
+ return code;
121
+ case 'volume-density-shape-mismatch':
122
+ return code;
123
+ case 'volume-invalid-bounds':
124
+ return code;
125
+ case 'volume-invalid-parameters':
126
+ return code;
117
127
  default: {
118
128
  const exhaustive: never = code;
119
129
  return exhaustive;
@@ -333,6 +343,25 @@ function narrowRenderError(err: RenderError): void {
333
343
  void err.detail.capability;
334
344
  void err.detail.stage;
335
345
  break;
346
+ case 'projector-binding-failed':
347
+ void err.detail.guid;
348
+ void err.detail.status;
349
+ break;
350
+ case 'volume-owner-conflict':
351
+ void err.detail.ownerCount;
352
+ break;
353
+ case 'volume-density-shape-mismatch':
354
+ void err.detail.guid;
355
+ void err.detail.viewDimension;
356
+ break;
357
+ case 'volume-invalid-bounds':
358
+ void err.detail.min;
359
+ void err.detail.max;
360
+ break;
361
+ case 'volume-invalid-parameters':
362
+ void err.detail.field;
363
+ void err.detail.value;
364
+ break;
336
365
  default: {
337
366
  const exhaustive: never = err;
338
367
  void exhaustive;
@@ -1193,12 +1193,11 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
1193
1193
  'TextureAsset',
1194
1194
  {
1195
1195
  kind: 'texture',
1196
- width: 1,
1197
- height: 1,
1196
+ shape: { viewDimension: '2d', extent: { width: 1, height: 1 } },
1198
1197
  format: 'rgba8unorm',
1199
1198
  data: new Uint8Array([255, 255, 255, 255]),
1200
1199
  colorSpace: 'linear',
1201
- mipmap: false,
1200
+ mips: { kind: 'none' },
1202
1201
  },
1203
1202
  );
1204
1203
  }
@@ -1211,12 +1210,11 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
1211
1210
  const textureGuid = AssetGuid.random();
1212
1211
  assets.catalog(textureGuid, {
1213
1212
  kind: 'texture',
1214
- width: 1,
1215
- height: 1,
1213
+ shape: { viewDimension: '2d', extent: { width: 1, height: 1 } },
1216
1214
  format: 'rgba8unorm',
1217
1215
  data: new Uint8Array([255, 255, 255, 255]),
1218
1216
  colorSpace: 'linear',
1219
- mipmap: false,
1217
+ mips: { kind: 'none' },
1220
1218
  } satisfies TextureAsset);
1221
1219
  const matHandle = world.allocSharedRef<'MaterialAsset', MaterialAsset>('MaterialAsset', {
1222
1220
  kind: 'material',
@@ -323,8 +323,7 @@ function makeChequerTexture(): TextureAsset {
323
323
  // textureView sentinel.
324
324
  return {
325
325
  kind: 'texture',
326
- width: 2,
327
- height: 2,
326
+ shape: { viewDimension: '2d', extent: { width: 2, height: 2 } },
328
327
  // colorSpace=linear+rgba8unorm to satisfy the runtime's
329
328
  // srgb-format consistency validator (layer 7c-1: srgb requires
330
329
  // rgba8unorm-srgb). The test only cares that each texture's
@@ -332,7 +331,7 @@ function makeChequerTexture(): TextureAsset {
332
331
  format: 'rgba8unorm',
333
332
  data: new Uint8Array(2 * 2 * 4),
334
333
  colorSpace: 'linear',
335
- mipmap: false,
334
+ mips: { kind: 'none' },
336
335
  } as unknown as TextureAsset;
337
336
  }
338
337
 
@@ -586,12 +585,11 @@ async function spawnPbrMissingTextureScene(): Promise<unknown> {
586
585
  // existing debug-pink fallback.
587
586
  const unresidentTexture = {
588
587
  kind: 'texture',
589
- width: 2,
590
- height: 2,
588
+ shape: { viewDimension: '2d', extent: { width: 2, height: 2 } },
591
589
  format: 'rgba8unorm-srgb',
592
590
  data: new Uint8Array(2 * 2 * 4),
593
591
  colorSpace: 'srgb',
594
- mipmap: false,
592
+ mips: { kind: 'none' },
595
593
  } as unknown as TextureAsset;
596
594
  const badTexHandle = world.allocSharedRef('TextureAsset', unresidentTexture) as unknown as Handle<
597
595
  'TextureAsset',
@@ -256,12 +256,11 @@ function cameraTransform() {
256
256
  function makeTex(): TextureAsset {
257
257
  return {
258
258
  kind: 'texture',
259
- width: 2,
260
- height: 2,
259
+ shape: { viewDimension: '2d', extent: { width: 2, height: 2 } },
261
260
  format: 'rgba8unorm',
262
261
  data: new Uint8Array(2 * 2 * 4),
263
262
  colorSpace: 'linear',
264
- mipmap: false,
263
+ mips: { kind: 'none' },
265
264
  } as unknown as TextureAsset;
266
265
  }
267
266