@forgeax/engine-runtime 0.1.7 → 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 (88) hide show
  1. package/README.md +45 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/helpers/standard-material-manifest.d.ts +12 -0
  4. package/dist/__tests__/helpers/standard-material-manifest.d.ts.map +1 -0
  5. package/dist/__tests__/helpers/tilemap-assets.d.ts.map +1 -1
  6. package/dist/__tests__/material-texture-dimension-mismatch.unit.test.d.ts +2 -0
  7. package/dist/__tests__/material-texture-dimension-mismatch.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/render-environment-consumer.integration.test.d.ts +2 -0
  9. package/dist/__tests__/render-environment-consumer.integration.test.d.ts.map +1 -0
  10. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  11. package/dist/__tests__/render-feature-prepared-graphics.fixture.d.ts.map +1 -1
  12. package/dist/__tests__/renderer-host-fallback.unit.test.d.ts +2 -0
  13. package/dist/__tests__/renderer-host-fallback.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/skinned-shadow-mixed.dawn.test.d.ts +2 -0
  15. package/dist/__tests__/skinned-shadow-mixed.dawn.test.d.ts.map +1 -0
  16. package/dist/__tests__/texture-dimensions.browser.test.d.ts +2 -0
  17. package/dist/__tests__/texture-dimensions.browser.test.d.ts.map +1 -0
  18. package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts +2 -0
  19. package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts.map +1 -0
  20. package/dist/backend-selection.d.ts.map +1 -1
  21. package/dist/createRenderer.d.ts.map +1 -1
  22. package/dist/index.mjs +21 -1
  23. package/dist/index.mjs.map +1 -1
  24. package/dist/renderer-host.d.ts +2 -1
  25. package/dist/renderer-host.d.ts.map +1 -1
  26. package/dist/renderer-host.mjs +21 -2
  27. package/dist/renderer-host.mjs.map +1 -1
  28. package/package.json +30 -30
  29. package/src/__tests__/asset-registry.recursive.spec.ts +12 -18
  30. package/src/__tests__/asset.unit.test.ts +28 -25
  31. package/src/__tests__/basis-catalog-dispatch.integration.test.ts +10 -9
  32. package/src/__tests__/compressed-texture-physical-extent.integration.test.ts +12 -10
  33. package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +2 -3
  34. package/src/__tests__/dawn/instances-per-instance-pbr.dawn.test.ts +12 -11
  35. package/src/__tests__/dawn/material-alpha.dawn.test.ts +2 -3
  36. package/src/__tests__/dawn/mipmap-srgb-linear.dawn.test.ts +2 -3
  37. package/src/__tests__/dawn/per-entity-material-texture-isolation.dawn.test.ts +2 -3
  38. package/src/__tests__/dawn/sprite-nineslice-mesh-bind.dawn.test.ts +2 -3
  39. package/src/__tests__/dawn/upload-texture.dawn.test.ts +4 -6
  40. package/src/__tests__/derive-bgl-integration.test.ts +11 -0
  41. package/src/__tests__/equirect-bc6h.integration.test.ts +6 -4
  42. package/src/__tests__/errors.unit.test.ts +56 -0
  43. package/src/__tests__/extract-record-no-hardcoded-texture-fields.test.ts +4 -5
  44. package/src/__tests__/four-path-compression.integration.test.ts +2 -2
  45. package/src/__tests__/fullscreen-post-process-pass.dawn.test.ts +461 -18
  46. package/src/__tests__/geometry.unit.test.ts +3 -1
  47. package/src/__tests__/gpu-resource-cow-survivor.test.ts +2 -3
  48. package/src/__tests__/helpers/standard-material-manifest.ts +45 -0
  49. package/src/__tests__/helpers/tilemap-assets.ts +3 -4
  50. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +4 -2
  51. package/src/__tests__/lights.unit.test.ts +38 -17
  52. package/src/__tests__/loadbyguid-texture-intern.unit.test.ts +3 -4
  53. package/src/__tests__/loader-name-survival.test.ts +2 -3
  54. package/src/__tests__/material-texture-dimension-mismatch.unit.test.ts +18 -0
  55. package/src/__tests__/material-texture-uv-scale.unit.test.ts +18 -16
  56. package/src/__tests__/materials.unit.test.ts +2 -0
  57. package/src/__tests__/mip-gate.unit.test.ts +11 -8
  58. package/src/__tests__/msaa-sprite-pixel-diff.dawn.test.ts +2 -3
  59. package/src/__tests__/new-kind-refs-contract.test.ts +2 -3
  60. package/src/__tests__/pbr-pipeline.unit.test.ts +31 -12
  61. package/src/__tests__/pipeline-cache-keying.unit.test.ts +6 -4
  62. package/src/__tests__/pipeline.unit.test.ts +23 -21
  63. package/src/__tests__/point-light-shadow.unit.test.ts +6 -5
  64. package/src/__tests__/render-environment-consumer.integration.test.ts +33 -0
  65. package/src/__tests__/render-error-exhaustive.test-d.ts +86 -0
  66. package/src/__tests__/render-feature-prepared-graphics.fixture.ts +10 -0
  67. package/src/__tests__/render-system-extract.test.ts +4 -6
  68. package/src/__tests__/render-system-mega.test.ts +3 -1
  69. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +7 -7
  70. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +5 -4
  71. package/src/__tests__/render-system-record.test.ts +8 -6
  72. package/src/__tests__/renderer-host-fallback.unit.test.ts +112 -0
  73. package/src/__tests__/renderer-lifecycle.integration.test.ts +10 -0
  74. package/src/__tests__/renderer-surface.unit.test.ts +66 -0
  75. package/src/__tests__/shadow-csm-cascade-loadop.test.ts +5 -1
  76. package/src/__tests__/shadow-csm-tile-consistency.test.ts +5 -1
  77. package/src/__tests__/shadow-fields-observable.dawn.test.ts +56 -39
  78. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +385 -0
  79. package/src/__tests__/sprite-cullmode-flip.dawn.test.ts +4 -6
  80. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -9
  81. package/src/__tests__/ssao-passes.test.ts +28 -12
  82. package/src/__tests__/systems.unit.test.ts +63 -33
  83. package/src/__tests__/texture-dimensions.browser.test.ts +127 -0
  84. package/src/__tests__/transcode-fallback.unit.test.ts +5 -1
  85. package/src/__tests__/volumetric-fog-record-recovery.integration.test.ts +180 -0
  86. package/src/backend-selection.ts +4 -0
  87. package/src/createRenderer.ts +3 -0
  88. package/src/renderer-host.ts +30 -3
@@ -117,7 +117,7 @@ describe('w7 -- light GPU pack byte-neutral (AC-11)', () => {
117
117
  expect(out[6]).toBeCloseTo(0.6, 6);
118
118
  });
119
119
 
120
- it('packSpotLight writes color slots 4..6 + direction slots 8..10 byte-for-byte (64 B)', () => {
120
+ it('packSpotLight writes color slots 4..6 + direction slots 8..10 byte-for-byte (80 B)', () => {
121
121
  const snap: SpotLightSnapshot = {
122
122
  kind: 'spot',
123
123
  position: vec3.create(0, 5, 0),
@@ -135,7 +135,9 @@ describe('w7 -- light GPU pack byte-neutral (AC-11)', () => {
135
135
  shadowAtlasTile: -1,
136
136
  };
137
137
  const out = packSpotLight(snap);
138
- expect(out.byteLength).toBe(64);
138
+ // SpotLight carries projector receiver controls and the shadow intensity
139
+ // tail in the current std430 payload.
140
+ expect(out.byteLength).toBe(80);
139
141
  expect(out[4]).toBeCloseTo(0.3, 6);
140
142
  expect(out[5]).toBeCloseTo(0.4, 6);
141
143
  expect(out[6]).toBeCloseTo(0.5, 6);
@@ -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
+ });
@@ -3,7 +3,6 @@ import { vec3 } from '@forgeax/engine-math';
3
3
  import { describe, expect, it } from 'vitest';
4
4
  import {
5
5
  applyMaterialTextureUvScales,
6
- BUILTIN_USER_REGION_TEXTURE_FIELDS,
7
6
  buildPbrMaterialUboPayload,
8
7
  materialTextureUvScale,
9
8
  userRegionTextureFieldOrder,
@@ -11,7 +10,10 @@ import {
11
10
 
12
11
  describe('material texture UV scale [w37]', () => {
13
12
  it('maps a non-aligned BC7 logical edge below padded physical storage', () => {
14
- 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
+ });
15
17
  expect(scale).toEqual([2085 / 2088, 1573 / 1576]);
16
18
  expect(scale[0]).toBeLessThan(1);
17
19
  expect(scale[1]).toBeLessThan(1);
@@ -19,14 +21,17 @@ describe('material texture UV scale [w37]', () => {
19
21
 
20
22
  it('uses identity scale for uncompressed and fallback texture bindings', () => {
21
23
  expect(materialTextureUvScale(undefined)).toEqual([1, 1]);
22
- 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]);
23
30
  });
24
31
 
25
- it('keeps builtin texture slots when compatibility param schemas are empty or numeric-only', () => {
26
- expect(userRegionTextureFieldOrder([])).toEqual(BUILTIN_USER_REGION_TEXTURE_FIELDS);
27
- expect(userRegionTextureFieldOrder([{ name: 'baseColor', type: 'color' }])).toEqual(
28
- BUILTIN_USER_REGION_TEXTURE_FIELDS,
29
- );
32
+ it('keeps compatibility param schemas compact when they have no textures', () => {
33
+ expect(userRegionTextureFieldOrder([])).toEqual([]);
34
+ expect(userRegionTextureFieldOrder([{ name: 'baseColor', type: 'color' }])).toEqual([]);
30
35
  });
31
36
 
32
37
  it('writes each slot set and transform into the builtin PBR payload', () => {
@@ -61,8 +66,8 @@ describe('material texture UV scale [w37]', () => {
61
66
  new World(),
62
67
  );
63
68
  const f32 = new Float32Array(payload.buffer, payload.byteOffset, payload.byteLength / 4);
64
- expect(Array.from(f32.slice(24, 32))).toEqual([0.125, 0.25, 2, 3, 1, 0.5, 1, 1]);
65
- expect(Array.from(f32.slice(40, 48))).toEqual([0.75, 0.25, 4, 5, 0, 0, 1, 1]);
69
+ expect(Array.from(f32.slice(32, 40))).toEqual([0.125, 0.25, 2, 3, 1, 0.5, 1, 1]);
70
+ expect(Array.from(f32.slice(48, 56))).toEqual([0.75, 0.25, 4, 5, 0, 0, 1, 1]);
66
71
  });
67
72
 
68
73
  it('keeps identity UV records for metadata-free standard and builtin schema materials', () => {
@@ -85,14 +90,11 @@ describe('material texture UV scale [w37]', () => {
85
90
  expect(
86
91
  Array.from(
87
92
  new Float32Array(standard.buffer, standard.byteOffset, standard.byteLength / 4).slice(
88
- 24,
89
- 72,
93
+ 32,
94
+ 96,
90
95
  ),
91
96
  ),
92
- ).toEqual([
93
- 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1,
94
- 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1,
95
- ]);
97
+ ).toEqual(Array.from({ length: 8 }, () => [0, 0, 1, 1, 0, 0, 1, 1]).flat());
96
98
 
97
99
  const unlit = buildPbrMaterialUboPayload({
98
100
  baseColor: vec3.create(1, 1, 1),
@@ -197,6 +197,8 @@ describe('Materials.standard multi-pass (w14)', () => {
197
197
  { name: 'specularTintTexture', type: 'texture', optional: true },
198
198
  { name: 'emissiveTexture', type: 'texture', optional: true },
199
199
  { name: 'occlusionTexture', type: 'texture', optional: true },
200
+ { name: 'transmissionTexture', type: 'texture', optional: true },
201
+ { name: 'thicknessTexture', type: 'texture', optional: true },
200
202
  ]);
201
203
  });
202
204
 
@@ -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,25 +83,40 @@ 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', () => {
85
- it('24 entries: user-region 13 (derived) + ibl 7 + lightmap 4', () => {
104
+ it('26 entries: user-region 17 (derived) + ibl 7 + transmission 2', () => {
86
105
  const spec = makeSpec();
87
106
  const out = buildBindGroupLayoutDescriptor(spec, {
88
107
  kind: 'pbr-material-merged',
89
108
  });
90
109
  // Post-M2 (D-1): user-region comes from derive(paramSchema).bglEntries
91
- // (built-in standard-PBR 4-texture fallback), then IBL + lightmap are
92
- // appended at start = userRegion.length.
110
+ // (built-in standard-PBR 8-texture contract), then IBL + transmission
111
+ // are appended at start = userRegion.length.
93
112
  const userRegion = buildPbrMaterialUserRegionEntries();
94
113
  const afterIbl = [...userRegion, ...appendInjection(userRegion, 'ibl')];
95
114
  const expected = {
96
115
  label: 'pbr-material-skylight-bgl',
97
- entries: [...afterIbl, ...appendInjection(afterIbl, 'lightmap')],
116
+ entries: [...afterIbl, ...appendInjection(afterIbl, 'transmission')],
98
117
  };
99
118
  expect(out).toEqual(expected);
100
- expect(out.entries.length).toBe(24);
119
+ expect(out.entries.length).toBe(26);
101
120
  });
102
121
  });
103
122
 
@@ -142,7 +161,7 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
142
161
  entries: [
143
162
  {
144
163
  binding: 0,
145
- visibility: 0x1,
164
+ visibility: 0x3,
146
165
  buffer: { type: 'read-only-storage', hasDynamicOffset: false },
147
166
  },
148
167
  ],
@@ -185,7 +204,7 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
185
204
  });
186
205
 
187
206
  describe('unlit-material', () => {
188
- it('13 entries: base PBR material only (no skylight injection)', () => {
207
+ it('17 entries: base PBR material only (no skylight injection)', () => {
189
208
  const spec = makeSpec();
190
209
  const out = buildBindGroupLayoutDescriptor(spec, {
191
210
  kind: 'unlit-material',
@@ -194,7 +213,7 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
194
213
  label: 'unlit-material-bgl',
195
214
  entries: buildPbrMaterialUserRegionEntries(),
196
215
  });
197
- expect(out.entries.length).toBe(13);
216
+ expect(out.entries.length).toBe(17);
198
217
  });
199
218
  });
200
219
  });
@@ -127,14 +127,16 @@ describe('cacheKeyOf passKind dimension', () => {
127
127
  describe('PassKind open string + KNOWN_PASS_KINDS (feat-20260615 D-10)', () => {
128
128
  // M2-T4 expanded KNOWN_PASS_KINDS from 4 to 6 entries by adding 'post-process'
129
129
  // and 'skybox'; point-shadow-caster is the engine's depth-only point-light
130
- // pass and is part of the discoverable catalogue as well.
131
- it('KNOWN_PASS_KINDS has exactly 7 entries', () => {
132
- expect(KNOWN_PASS_KINDS).toHaveLength(7);
130
+ // pass and is part of the discoverable catalogue as well. M3-T4 adds the
131
+ // Standard temporal producer to the shipped pass catalogue.
132
+ it('KNOWN_PASS_KINDS has exactly 8 entries', () => {
133
+ expect(KNOWN_PASS_KINDS).toHaveLength(8);
133
134
  });
134
135
 
135
- it('KNOWN_PASS_KINDS contains the 7 engine-shipped pass kinds', () => {
136
+ it('KNOWN_PASS_KINDS contains the 8 engine-shipped pass kinds', () => {
136
137
  expect(KNOWN_PASS_KINDS).toContain('forward');
137
138
  expect(KNOWN_PASS_KINDS).toContain('deferred');
139
+ expect(KNOWN_PASS_KINDS).toContain('temporal');
138
140
  expect(KNOWN_PASS_KINDS).toContain('lighting');
139
141
  expect(KNOWN_PASS_KINDS).toContain('shadow-caster');
140
142
  expect(KNOWN_PASS_KINDS).toContain('point-shadow-caster');
@@ -138,6 +138,7 @@ import {
138
138
  import { extractFrame, prepareExtractContext } from '../../../render/src/render-system-extract';
139
139
  import { matchPass } from '../../../render/src/systems/pass-selector';
140
140
  import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
141
+ import { standardMaterialShaderVariants } from './helpers/standard-material-manifest';
141
142
 
142
143
  function makeExplicitNullRhi(spies: {
143
144
  setIndexBuffer: ReturnType<typeof vi.fn>;
@@ -2407,7 +2408,8 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
2407
2408
  sourcePath: `${identifier}.wgsl`,
2408
2409
  composedWgsl: '/* stub */',
2409
2410
  paramSchema: '[]',
2410
- variants: [],
2411
+ variants:
2412
+ identifier === 'forgeax::default-standard-pbr' ? standardMaterialShaderVariants() : [],
2411
2413
  });
2412
2414
  const manifest = {
2413
2415
  schemaVersion: '1.0.0',
@@ -2697,7 +2699,8 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
2697
2699
  sourcePath: `${identifier}.wgsl`,
2698
2700
  composedWgsl: '/* stub */',
2699
2701
  paramSchema: '[]',
2700
- variants: [],
2702
+ variants:
2703
+ identifier === 'forgeax::default-standard-pbr' ? standardMaterialShaderVariants() : [],
2701
2704
  });
2702
2705
  const manifest = {
2703
2706
  schemaVersion: '1.0.0',
@@ -4463,12 +4466,11 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
4463
4466
  function texturePod(mipmap: boolean, format: GPUTextureFormat = 'rgba8unorm-srgb'): TextureAsset {
4464
4467
  return {
4465
4468
  kind: 'texture',
4466
- width: 2,
4467
- height: 2,
4469
+ shape: { viewDimension: '2d', extent: { width: 2, height: 2 } },
4468
4470
  format,
4469
4471
  data: new Uint8Array(2 * 2 * 4).fill(188),
4470
4472
  colorSpace: format.endsWith('-srgb') ? 'srgb' : 'linear',
4471
- mipmap,
4473
+ mips: { kind: mipmap ? 'generate' : 'none' },
4472
4474
  };
4473
4475
  }
4474
4476
 
@@ -4546,12 +4548,11 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
4546
4548
  ): TextureAsset {
4547
4549
  return {
4548
4550
  kind: 'texture',
4549
- width,
4550
- height,
4551
+ shape: { viewDimension: '2d', extent: { width, height } },
4551
4552
  format,
4552
4553
  data: new Uint8Array(width * height * 4).fill(170),
4553
4554
  colorSpace: format.endsWith('-srgb') ? 'srgb' : 'linear',
4554
- mipmap,
4555
+ mips: { kind: mipmap ? 'generate' : 'none' },
4555
4556
  };
4556
4557
  }
4557
4558
 
@@ -4563,12 +4564,11 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
4563
4564
  ): TextureAsset {
4564
4565
  return {
4565
4566
  kind: 'texture',
4566
- width,
4567
- height,
4567
+ shape: { viewDimension: '2d', extent: { width, height } },
4568
4568
  format,
4569
4569
  data: new Uint8Array(width * height * 8),
4570
4570
  colorSpace,
4571
- mipmap: false,
4571
+ mips: { kind: 'none' },
4572
4572
  };
4573
4573
  }
4574
4574
 
@@ -4655,33 +4655,35 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
4655
4655
  const source = texturePod(false, 'rgba16float');
4656
4656
  const res = deriveRenderDataTexture({
4657
4657
  ...source,
4658
- data: new Uint8Array(source.width * source.height * 8),
4658
+ data: new Uint8Array(source.shape.extent.width * source.shape.extent.height * 8),
4659
4659
  });
4660
4660
  expect(res.ok).toBe(true);
4661
4661
  if (!res.ok) return;
4662
- expect(res.value.bytesPerRow).toBe(source.width * 8);
4662
+ expect(res.value.bytesPerRow).toBe(source.shape.extent.width * 8);
4663
4663
  });
4664
4664
 
4665
4665
  it('derives row pitch from format when an rgba8 decoder retains trailing bytes', () => {
4666
4666
  const source = texturePod(false, 'rgba8unorm');
4667
4667
  const res = deriveRenderDataTexture({
4668
4668
  ...source,
4669
- 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
+ ),
4670
4672
  });
4671
4673
  expect(res.ok).toBe(true);
4672
4674
  if (!res.ok) return;
4673
- expect(res.value.bytesPerRow).toBe(source.width * 4);
4675
+ expect(res.value.bytesPerRow).toBe(source.shape.extent.width * 4);
4674
4676
  });
4675
4677
 
4676
4678
  it('derives the row pitch from an rgba32float source payload', () => {
4677
4679
  const source = texturePod(false, 'rgba32float');
4678
4680
  const res = deriveRenderDataTexture({
4679
4681
  ...source,
4680
- data: new Uint8Array(source.width * source.height * 16),
4682
+ data: new Uint8Array(source.shape.extent.width * source.shape.extent.height * 16),
4681
4683
  });
4682
4684
  expect(res.ok).toBe(true);
4683
4685
  if (!res.ok) return;
4684
- expect(res.value.bytesPerRow).toBe(source.width * 16);
4686
+ expect(res.value.bytesPerRow).toBe(source.shape.extent.width * 16);
4685
4687
  });
4686
4688
 
4687
4689
  it('rejects an uncompressed payload shorter than its base mip', () => {
@@ -5088,14 +5090,14 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
5088
5090
  expect(LIGHT_ARRAY_MAX_SLOTS).toBe(4);
5089
5091
  });
5090
5092
 
5091
- 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 () => {
5092
5094
  const { POINT_LIGHT_STD430_BYTES, SPOT_LIGHT_STD430_BYTES } = await import(
5093
5095
  '../../../render/src/light-buffer-layout'
5094
5096
  );
5095
5097
  expect(POINT_LIGHT_STD430_BYTES).toBe(32);
5096
- // feat-20260625-spot-light-shadow-mapping M2 / w8 (D-4): SpotLight grew
5097
- // 48 -> 64 for the shadowAtlasTile i32 clip-signal lane (4th vec4 column).
5098
- 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);
5099
5101
  });
5100
5102
  });
5101
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)', () => {