@forgeax/engine-runtime 0.1.19 → 0.1.21

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 (91) hide show
  1. package/README.md +102 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts +2 -0
  4. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts.map +1 -0
  5. package/dist/__tests__/helpers/tilemap-assets.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__/material-texture-dimension-mismatch.unit.test.d.ts +2 -0
  9. package/dist/__tests__/material-texture-dimension-mismatch.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  11. package/dist/__tests__/texture-dimensions.browser.test.d.ts +2 -0
  12. package/dist/__tests__/texture-dimensions.browser.test.d.ts.map +1 -0
  13. package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts +2 -0
  14. package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts.map +1 -0
  15. package/dist/createRenderer.d.ts.map +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.mjs +3 -1
  18. package/dist/index.mjs.map +1 -1
  19. package/package.json +30 -30
  20. package/src/__tests__/active-camera.unit.test.ts +6 -8
  21. package/src/__tests__/asset-registry.recursive.spec.ts +13 -19
  22. package/src/__tests__/asset.unit.test.ts +28 -25
  23. package/src/__tests__/basis-catalog-dispatch.integration.test.ts +10 -9
  24. package/src/__tests__/clamp-to-last.e2e.browser.test.ts +2 -2
  25. package/src/__tests__/components.test-d.ts +10 -3
  26. package/src/__tests__/components.unit.test.ts +13 -7
  27. package/src/__tests__/compressed-texture-physical-extent.integration.test.ts +12 -10
  28. package/src/__tests__/create-renderer-assembly.integration.test.ts +24 -0
  29. package/src/__tests__/create-renderer-gpu-pass-timing.unit.test.ts +32 -0
  30. package/src/__tests__/createRenderer.browser-no-webgpu.test.ts +66 -0
  31. package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +11 -4
  32. package/src/__tests__/dawn/instances-shadow.dawn.test.ts +15 -2
  33. package/src/__tests__/dawn/material-alpha.dawn.test.ts +2 -3
  34. package/src/__tests__/dawn/mipmap-srgb-linear.dawn.test.ts +2 -3
  35. package/src/__tests__/dawn/per-entity-material-texture-isolation.dawn.test.ts +2 -3
  36. package/src/__tests__/dawn/sprite-nineslice-mesh-bind.dawn.test.ts +2 -3
  37. package/src/__tests__/dawn/upload-texture.dawn.test.ts +4 -6
  38. package/src/__tests__/derive-bgl-integration.test.ts +11 -0
  39. package/src/__tests__/equirect-bc6h.integration.test.ts +6 -4
  40. package/src/__tests__/errors.unit.test.ts +71 -20
  41. package/src/__tests__/extract-frames-merge.test.ts +48 -40
  42. package/src/__tests__/extract-skin-mismatch.unit.test.ts +6 -2
  43. package/src/__tests__/four-path-compression.integration.test.ts +2 -2
  44. package/src/__tests__/frame-targets-shadow-regression.unit.test.ts +13 -0
  45. package/src/__tests__/geometry.unit.test.ts +7 -10
  46. package/src/__tests__/gpu-resource-cow-survivor.test.ts +2 -3
  47. package/src/__tests__/helpers/tilemap-assets.ts +3 -4
  48. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +6 -3
  49. package/src/__tests__/light-snapshot.test-d.ts +8 -0
  50. package/src/__tests__/lights-preservation.fixup.test.ts +127 -0
  51. package/src/__tests__/lights.unit.test.ts +75 -2214
  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 +10 -2
  56. package/src/__tests__/mip-gate.unit.test.ts +11 -8
  57. package/src/__tests__/msaa-sprite-pixel-diff.dawn.test.ts +2 -3
  58. package/src/__tests__/new-kind-refs-contract.test.ts +2 -3
  59. package/src/__tests__/pbr-pipeline.unit.test.ts +29 -5
  60. package/src/__tests__/pipeline.unit.test.ts +66 -221
  61. package/src/__tests__/point-light-shadow.browser.test.ts +208 -83
  62. package/src/__tests__/point-light-shadow.unit.test.ts +67 -9
  63. package/src/__tests__/render-error-exhaustive.test-d.ts +92 -9
  64. package/src/__tests__/render-system-extract.test.ts +4 -6
  65. package/src/__tests__/render-system-mega.test.ts +30 -41
  66. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +8 -7
  67. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +6 -4
  68. package/src/__tests__/render-system-skin-bg.unit.test.ts +14 -9
  69. package/src/__tests__/renderer-recover.unit.test.ts +8 -0
  70. package/src/__tests__/renderer-surface.unit.test.ts +229 -3
  71. package/src/__tests__/shadow-csm-atlas.test.ts +41 -0
  72. package/src/__tests__/shadow-csm-component.test.ts +31 -24
  73. package/src/__tests__/shadow-csm-extract.test.ts +156 -47
  74. package/src/__tests__/shadow-csm-runtime-vary.dawn.test.ts +72 -0
  75. package/src/__tests__/shadow-csm-shader.dawn.test.ts +19 -0
  76. package/src/__tests__/shadow-csm-ubo.test.ts +41 -45
  77. package/src/__tests__/shadow-csm-viewport.browser.test.ts +29 -0
  78. package/src/__tests__/shadow-fields-observable.dawn.test.ts +140 -58
  79. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +6 -2
  80. package/src/__tests__/skylight-lazy-projection.test.ts +8 -5
  81. package/src/__tests__/sprite-cullmode-flip.dawn.test.ts +4 -6
  82. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +4 -2
  83. package/src/__tests__/ssao-bgl.test.ts +10 -16
  84. package/src/__tests__/ssao-passes.test.ts +19 -2
  85. package/src/__tests__/systems.unit.test.ts +59 -33
  86. package/src/__tests__/texture-dimensions.browser.test.ts +127 -0
  87. package/src/__tests__/transcode-fallback.unit.test.ts +5 -1
  88. package/src/__tests__/view-ubo-layout.browser.test.ts +20 -10
  89. package/src/__tests__/volumetric-fog-record-recovery.integration.test.ts +180 -0
  90. package/src/createRenderer.ts +4 -0
  91. package/src/index.ts +1 -2
@@ -5,15 +5,17 @@
5
5
  // independent of runtime cascadeCount. Validates std140 byte offsets for
6
6
  // lightViewProj[4], splitPlanes[4], cascadeCount, cascadeBlend.
7
7
  //
8
- // feat-20260625-spot-light-shadow-mapping w25 (scope-amend webkit-fallback):
9
- // the View UBO grew 592 -> 784 B (148 -> 196 f32). The directional cascade
8
+ // feat-20260827-directional-csm-pcss-quality M1: the View UBO keeps its
9
+ // existing 960 B payload and 1024 B slot while the directional filter carrier
10
+ // reuses the tail pad. The directional cascade
10
11
  // offsets [0..128] are byte-for-byte UNCHANGED; the per-spot fragment-read
11
12
  // `spotLightViewProj` array<mat4x4<f32>,4> (256 B) folded into the tail at
12
13
  // byte 528 (float 132), replacing the former zero tail-pad and removing the
13
14
  // standalone @group(0) binding 9 uniform buffer that overflowed the WebGL2
14
15
  // fallback fragment uniform-buffer budget.
15
16
  //
16
- // Layout (784 B std140, 196 f32):
17
+ // Layout (960 B View payload, 1024 B slot; the directional prefix remains
18
+ // byte-compatible with the 784 B Spot-matrix boundary):
17
19
  // [ 0.. 16) worldViewProj mat4x4<f32> (align 16, size 64)
18
20
  // [ 16.. 19) lightDir vec3<f32> (align 16, 12 + 4 pad)
19
21
  // [ 20.. 22) lightColor vec3<f32> (align 16, 12 + 4 pad)
@@ -31,30 +33,27 @@
31
33
  // [125] cascadeBlend f32 (align 4, size 4)
32
34
  // [126] depthBias f32 (align 4, size 4)
33
35
  // [127] normalBias f32 (align 4, size 4)
34
- // [128] pcfKernelSize f32 (align 4, size 4)
35
- // [129..131] align padding f32[3] (mat4 array align 16)
36
+ // [128..131] directionalShadowFilter vec4<f32> (profile, radius, penumbra, reserved)
36
37
  // [132..195] spotLightViewProj array<mat4x4<f32>,4> (align 16, size 256)
37
38
 
38
39
  import { readFileSync } from 'node:fs';
39
40
  import { fileURLToPath } from 'node:url';
40
41
  import { describe, expect, it } from 'vitest';
41
42
 
42
- const VIEW_UBO_FLOAT_COUNT = 196;
43
- const VIEW_UBO_BYTES = 784;
43
+ const VIEW_UBO_FLOAT_COUNT = 240;
44
+ const VIEW_UBO_BYTES = 960;
44
45
  // feat-20260625 w25: spot lightViewProj array folded into the View UBO tail.
45
- const OFFSET_SPOT_LIGHT_VIEW_PROJ = 132; // byte 528 (16 B-aligned after pcfKernelSize)
46
+ const OFFSET_SPOT_LIGHT_VIEW_PROJ = 132; // byte 528 (16 B-aligned after filter carrier)
46
47
  const SPOT_LVP_LANE_COUNT = 4;
47
48
  const SPOT_LVP_FLOATS_PER_LANE = 16;
48
49
 
49
- // feat-20260621-merge-directionallightshadow-into-directionallight M3:
50
+ // feat-20260827-directional-csm-pcss-quality M1:
50
51
  // the merged DirectionalLight's shadow tail-pad slots — depthBias [126]/byte504,
51
- // normalBias [127]/byte508, pcfKernelSize [128]/byte512 (zero byte-layout growth;
52
- // three previously-zero tail-pad floats are consumed). The 5.3-production-shadow-
53
- // demos AC-14 pcfKernelSize lane moved from [126] to [128] when depthBias/normalBias
54
- // merged in ahead of it.
52
+ // normalBias [127]/byte508, and the four-float directional filter carrier
53
+ // [128..131]. The Spot matrix array remains at [132..195].
55
54
  const OFFSET_DEPTH_BIAS = 126; // byte 504
56
55
  const OFFSET_NORMAL_BIAS = 127; // byte 508
57
- const OFFSET_PCF_KERNEL_SIZE = 128; // byte 512
56
+ const OFFSET_DIRECTIONAL_FILTER = 128; // byte 512
58
57
 
59
58
  const F32_BYTES = 4;
60
59
 
@@ -79,30 +78,30 @@ const SPLIT_STRIDE_FLOATS = 4;
79
78
 
80
79
  describe('View UBO std140 layout (w14)', () => {
81
80
  describe('size invariants', () => {
82
- it('UBO total size is 784 B (196 f32)', () => {
83
- expect(VIEW_UBO_FLOAT_COUNT).toBe(196);
81
+ it('UBO total size is 960 B (240 f32)', () => {
82
+ expect(VIEW_UBO_FLOAT_COUNT).toBe(240);
84
83
  expect(VIEW_UBO_BYTES).toBe(VIEW_UBO_FLOAT_COUNT * F32_BYTES);
85
- expect(VIEW_UBO_BYTES).toBe(784);
84
+ expect(VIEW_UBO_BYTES).toBe(960);
86
85
  });
87
86
 
88
87
  it('UBO size is fixed — independent of cascadeCount', () => {
89
- // The host always allocates 784 B regardless of whether cascadeCount
88
+ // The host always allocates 960 B regardless of whether cascadeCount
90
89
  // is 1, 2, 3, or 4 at runtime. This validates AC-08.
91
90
  const sizeForN1 = VIEW_UBO_FLOAT_COUNT * F32_BYTES; // cascadeCount=1
92
91
  const sizeForN4 = VIEW_UBO_FLOAT_COUNT * F32_BYTES; // cascadeCount=4
93
92
  expect(sizeForN1).toBe(sizeForN4);
94
- expect(sizeForN1).toBe(784);
93
+ expect(sizeForN1).toBe(960);
95
94
  });
96
95
 
97
96
  it('spotLightViewProj array lands at byte 528 (float 132), 16 B-aligned, last field', () => {
98
97
  // feat-20260625 w25: the 4-lane spot perspective matrix array folds into
99
- // the View UBO tail after pcfKernelSize (float 128 / byte 512), 16 B-aligned
98
+ // the View UBO tail after the directional filter carrier, 16 B-aligned
100
99
  // so it starts at byte 528 (float 132). It is the last struct field.
101
100
  expect(OFFSET_SPOT_LIGHT_VIEW_PROJ * F32_BYTES).toBe(528);
102
101
  expect((OFFSET_SPOT_LIGHT_VIEW_PROJ * F32_BYTES) % 16).toBe(0);
103
102
  const spotArrayEndFloat =
104
103
  OFFSET_SPOT_LIGHT_VIEW_PROJ + SPOT_LVP_LANE_COUNT * SPOT_LVP_FLOATS_PER_LANE;
105
- expect(spotArrayEndFloat).toBe(VIEW_UBO_FLOAT_COUNT);
104
+ expect(spotArrayEndFloat).toBe(196);
106
105
  expect(spotArrayEndFloat * F32_BYTES).toBe(784);
107
106
  });
108
107
  });
@@ -220,15 +219,8 @@ describe('View UBO std140 layout (w14)', () => {
220
219
  });
221
220
  });
222
221
 
223
- // feat-20260621-learn-render-5-3-production-shadow-demos M0 / AC-14: the host
224
- // record stage must write pcfKernelSize into the tail-pad slot [126], and the
225
- // WGSL View struct must declare the matching `pcfKernelSize : f32` immediately
226
- // after cascadeBlend (single SSOT, append-at-tail, plan-strategy D-0). These
227
- // are read against the real source the compiler/host use (mirrors the
228
- // shadow-csm-tile-consistency antipattern guard: never re-declare and test a
229
- // formula against itself). RED until M0-T-IMPL-RECORD + M0-T-IMPL-WGSL-STRUCT
230
- // land; the slot was previously a zero tail-pad float (no record write existed).
231
- describe('pcfKernelSize tail-pad slot wiring (M0, AC-14)', () => {
222
+ // M1-T05: read the real host and WGSL sources so the frozen ABI cannot drift.
223
+ describe('directional filter carrier tail-pad wiring (M1-T05)', () => {
232
224
  const recordSrc = readFileSync(
233
225
  fileURLToPath(new URL('../../../render/src/record/view-ubo.ts', import.meta.url)),
234
226
  'utf8',
@@ -238,26 +230,25 @@ describe('pcfKernelSize tail-pad slot wiring (M0, AC-14)', () => {
238
230
  'utf8',
239
231
  );
240
232
 
241
- it('shadow tail floats [126..128] at fixed offsets, before the spot matrix array', () => {
233
+ it('shadow tail floats [126..131] at fixed offsets, before the spot matrix array', () => {
242
234
  expect(OFFSET_DEPTH_BIAS * F32_BYTES).toBe(504);
243
235
  expect(OFFSET_NORMAL_BIAS * F32_BYTES).toBe(508);
244
- expect(OFFSET_PCF_KERNEL_SIZE * F32_BYTES).toBe(512);
236
+ expect(OFFSET_DIRECTIONAL_FILTER * F32_BYTES).toBe(512);
245
237
  expect(OFFSET_DEPTH_BIAS).toBeGreaterThan(OFFSET_CASCADE_BLEND);
246
- expect(OFFSET_PCF_KERNEL_SIZE).toBeLessThan(VIEW_UBO_FLOAT_COUNT);
247
- // feat-20260625 w25: the shadow bias floats stay at [126..128]; the spot
248
- // matrix array follows them at float 132 (byte 528). The struct grew to
249
- // 196 f32 / 784 B to carry the folded-in spot matrices (was 148 / 592).
250
- expect(OFFSET_PCF_KERNEL_SIZE).toBeLessThan(OFFSET_SPOT_LIGHT_VIEW_PROJ);
251
- expect(VIEW_UBO_FLOAT_COUNT).toBe(196);
252
- expect(VIEW_UBO_BYTES).toBe(784);
238
+ expect(OFFSET_DIRECTIONAL_FILTER + 4).toBe(OFFSET_SPOT_LIGHT_VIEW_PROJ);
239
+ expect(OFFSET_DIRECTIONAL_FILTER).toBeLessThan(OFFSET_SPOT_LIGHT_VIEW_PROJ);
240
+ expect(VIEW_UBO_FLOAT_COUNT).toBe(240);
241
+ expect(VIEW_UBO_BYTES).toBe(960);
253
242
  });
254
243
 
255
- it('record writes the merged shadow tail-pad floats [126]/[127]/[128]', () => {
244
+ it('record writes the merged shadow tail-pad floats [126]/[127]/[128..131]', () => {
256
245
  expect(recordSrc).toMatch(/viewPayload\[126\]\s*=\s*lights\.depthBias/);
257
246
  expect(recordSrc).toMatch(/viewPayload\[127\]\s*=\s*lights\.normalBias/);
258
- expect(recordSrc).toMatch(
259
- /viewPayload\[128\]\s*=\s*clampPcfKernelSize\(lights\.pcfKernelSize\)/,
260
- );
247
+ expect(recordSrc).toMatch(/viewPayload\[128\]/);
248
+ expect(recordSrc).toMatch(/viewPayload\[129\]/);
249
+ expect(recordSrc).toMatch(/viewPayload\[130\]/);
250
+ expect(recordSrc).toMatch(/viewPayload\[131\]/);
251
+ expect(recordSrc).not.toMatch(/lights\.pcfKernelSize/);
261
252
  });
262
253
 
263
254
  it('record carries the folded spot matrices in the complete View UBO payload (w25)', () => {
@@ -269,10 +260,15 @@ describe('pcfKernelSize tail-pad slot wiring (M0, AC-14)', () => {
269
260
  expect(recordSrc).toMatch(/SPOT_LVP_BASE_FLOAT\s*=\s*132/);
270
261
  });
271
262
 
272
- it('common.wgsl View struct declares depthBias/normalBias/pcfKernelSize after cascadeBlend', () => {
263
+ it('common.wgsl View struct declares the directional filter vec4 after biases', () => {
273
264
  const m = wgslSrc.match(
274
- /cascadeBlend\s*:\s*f32\s*,\s*depthBias\s*:\s*f32\s*,\s*normalBias\s*:\s*f32\s*,\s*pcfKernelSize\s*:\s*f32\s*,/,
265
+ /cascadeBlend\s*:\s*f32\s*,\s*depthBias\s*:\s*f32\s*,\s*normalBias\s*:\s*f32\s*,\s*directionalShadowFilter\s*:\s*vec4<f32>\s*,/,
275
266
  );
276
267
  expect(m).not.toBeNull();
277
268
  });
269
+
270
+ it('keeps one View binding and removes the retired spot matrix binding', () => {
271
+ expect(wgslSrc.match(/@group\(0\) @binding\(0\) var<uniform> view/g)).toHaveLength(1);
272
+ expect(wgslSrc).not.toMatch(/@group\(0\)\s*@binding\(9\)/);
273
+ });
278
274
  });
@@ -11,6 +11,8 @@
11
11
  // D-6: pass names shadowCascade0..3.
12
12
 
13
13
  import { describe, expect, it } from 'vitest';
14
+ import { projectDirectionalShadowInspection } from '../../../render/src/assembly/directional-shadow-inspection';
15
+ import { resolveDirectionalShadowBackendAdmission } from '../../../render/src/render-pipeline';
14
16
 
15
17
  const browserReady = typeof navigator !== 'undefined' && navigator.gpu !== undefined;
16
18
 
@@ -83,3 +85,30 @@ describe('CSM per-cascade viewport (w30)', () => {
83
85
  });
84
86
  });
85
87
  });
88
+
89
+ describe('M3 Browser backend admission', () => {
90
+ it('declares WebGL2 PCSS fallback without changing the atlas viewport contract', () => {
91
+ const inspection = projectDirectionalShadowInspection({
92
+ admission: resolveDirectionalShadowBackendAdmission({
93
+ backendKind: 'wgpu-webgl2',
94
+ requested: 'pcssHigh',
95
+ candidate: 'accepted',
96
+ }),
97
+ cascadeCount: 4,
98
+ mapSize: 2048,
99
+ atlasBytes: 67_108_864,
100
+ writerPasses: 4,
101
+ blockerTaps: 0,
102
+ filterTapUpperBound: 25,
103
+ seamTapUpperBound: 25,
104
+ deviceGeneration: 1,
105
+ graphGeneration: 1,
106
+ });
107
+ expect(inspection).toMatchObject({
108
+ effective: 'pcf5',
109
+ fallbackReason: 'webgl2-unsupported',
110
+ cascadeCount: 4,
111
+ mapSize: 2048,
112
+ });
113
+ });
114
+ });
@@ -1,8 +1,8 @@
1
1
  // shadow-fields-observable.dawn.test.ts
2
2
  // feat-20260621-merge-directionallightshadow-into-directionallight M6
3
3
  //
4
- // Observability gate: proves depthBias, normalBias, pcfKernelSize --
5
- // three merged DirectionalLight shadow fields -- produce real pixel A/B diffs.
4
+ // Observability gate: proves depthBias, normalBias, and the directional filter
5
+ // carrier -- three merged DirectionalLight shadow fields -- produce real pixel A/B diffs.
6
6
  //
7
7
  // Root cause of prior "0 diff everywhere" failure: (a) materials without
8
8
  // ShadowCaster pass (nothing written into shadow depth atlas), (b) floor
@@ -33,12 +33,70 @@ const WIDTH = 256;
33
33
  const HEIGHT = 256;
34
34
  const BPR = Math.ceil((WIDTH * 4) / 256) * 256;
35
35
 
36
+ // Each falsifier performs four fresh 256x256 Dawn readbacks. Windows' Dawn
37
+ // prebuild can exceed Vitest's 90-second bound for that sequence while still
38
+ // completing well inside the nightly platform job's 45-minute budget.
39
+ const SHADOW_FIELDS_FALSIFIER_TIMEOUT_MS = 300_000;
40
+
36
41
  const ENGINE_MANIFEST = await (async () => {
37
42
  const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
38
43
  return buildEngineShaderManifest();
39
44
  })();
40
45
  const ENGINE_MANIFEST_URL = `data:application/json,${encodeURIComponent(JSON.stringify(ENGINE_MANIFEST))}`;
41
46
 
47
+ describe('M3 Dawn candidate admission', () => {
48
+ it('requires accepted production candidate to preserve the requested PCSS profile', async () => {
49
+ const module = (await import('../../../render/src/render-pipeline')) as Record<string, unknown>;
50
+ const resolver = module.resolveDirectionalShadowBackendAdmission;
51
+ expect(typeof resolver).toBe('function');
52
+ const result = (
53
+ resolver as (input: {
54
+ backendKind: 'webgpu';
55
+ requested: 'pcssMedium' | 'pcssHigh';
56
+ candidate: 'accepted' | 'failed';
57
+ }) => { effective: string; status: string; pixelEvidence: string }
58
+ )({ backendKind: 'webgpu', requested: 'pcssHigh', candidate: 'accepted' });
59
+ expect(result).toMatchObject({
60
+ effective: 'pcssHigh',
61
+ status: 'accepted',
62
+ pixelEvidence: 'available',
63
+ });
64
+ });
65
+
66
+ it('requires the same bounded Directional inspection projection used by Dawn', async () => {
67
+ const module = (await import(
68
+ '../../../render/src/assembly/directional-shadow-inspection'
69
+ )) as Record<string, unknown>;
70
+ const project = module.projectDirectionalShadowInspection;
71
+ expect(typeof project).toBe('function');
72
+ const result = (project as (input: Record<string, unknown>) => Record<string, unknown>)({
73
+ admission: {
74
+ requested: 'pcssMedium',
75
+ effective: 'pcssMedium',
76
+ status: 'accepted',
77
+ pixelEvidence: 'available',
78
+ },
79
+ cascadeCount: 4,
80
+ mapSize: 1024,
81
+ atlasBytes: 16_777_216,
82
+ writerPasses: 4,
83
+ blockerTaps: 8,
84
+ filterTapUpperBound: 16,
85
+ seamTapUpperBound: 48,
86
+ deviceGeneration: 1,
87
+ graphGeneration: 3,
88
+ });
89
+ expect(result).toMatchObject({
90
+ requested: 'pcssMedium',
91
+ effective: 'pcssMedium',
92
+ blockerTaps: 8,
93
+ filterTapUpperBound: 16,
94
+ seamTapUpperBound: 48,
95
+ });
96
+ expect(Object.keys(result)).not.toContain('perPixel');
97
+ });
98
+ });
99
+
42
100
  // Helper types
43
101
  interface WriteCapture {
44
102
  f32: Float32Array;
@@ -172,7 +230,9 @@ function spawnScene(
172
230
  depthBias,
173
231
  normalBias,
174
232
  shadowDistance: 50,
175
- pcfKernelSize: pcf,
233
+ shadowFilter: pcf === 1 ? 1 : pcf === 3 ? 2 : 3,
234
+ shadowAngularRadius: 0.00465,
235
+ maxPenumbraTexels: 32,
176
236
  },
177
237
  });
178
238
  world.spawn(
@@ -432,15 +492,15 @@ async function renderConfigWithSpy(
432
492
 
433
493
  describe('M6 shadow fields observability', () => {
434
494
  // --- Gate A: structural WGSL check ---
435
- it('composed default-standard-pbr WGSL reads view.depthBias, view.normalBias, view.pcfKernelSize', () => {
495
+ it('composed default-standard-pbr WGSL reads view.depthBias, view.normalBias, and directionalShadowFilter', () => {
436
496
  const wgsl = composedStandardPbr();
437
497
  expect(wgsl).toContain('depthBias');
438
498
  expect(wgsl).toContain('normalBias');
439
- expect(wgsl).toContain('pcfKernelSize');
499
+ expect(wgsl).toContain('directionalShadowFilter');
440
500
  });
441
501
 
442
- // --- Gate B: UBO spy — slots [126]=depthBias, [127]=normalBias, [128]=pcfKernelSize ---
443
- it('View UBO slots [126]=depthBias, [127]=normalBias, [128]=pcfKernelSize', async () => {
502
+ // --- Gate B: UBO spy — slots [126]=depthBias, [127]=normalBias, [128]=filter profile ---
503
+ it('View UBO slots [126]=depthBias, [127]=normalBias, [128]=filter profile', async () => {
444
504
  if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
445
505
  const { captured } = await renderConfigWithSpy(true, 0.123, 0.456, 3);
446
506
  const vw = captured.filter((c) => c.f32.length >= 148);
@@ -450,22 +510,22 @@ describe('M6 shadow fields observability', () => {
450
510
  const f32 = last.f32;
451
511
  expect(Math.abs((f32[126] ?? Number.NaN) - 0.123)).toBeLessThan(0.001);
452
512
  expect(Math.abs((f32[127] ?? Number.NaN) - 0.456)).toBeLessThan(0.001);
453
- expect(f32[128]).toBe(3);
513
+ expect(f32[128]).toBe(2);
454
514
  }, 60000);
455
515
 
456
- // --- Gate D: pcfKernelSize clamp (9 -> 5; cap matches WGSL MAX_PCF_HALF=2) ---
457
- it('pcfKernelSize=9 clamped to 5 in View UBO[128]', async () => {
516
+ // --- Gate D: pcf5 maps to the accepted profile carrier ---
517
+ it('pcf5 maps to profile 3 in View UBO[128]', async () => {
458
518
  if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
459
- const { captured } = await renderConfigWithSpy(true, 0.005, 0.05, 9);
519
+ const { captured } = await renderConfigWithSpy(true, 0.005, 0.05, 5);
460
520
  const vw9 = captured.filter((c) => c.f32.length >= 148);
461
521
  expect(vw9.length).toBeGreaterThan(0);
462
522
  const last9 = vw9[vw9.length - 1];
463
523
  if (last9 === undefined) throw new Error('no view UBO write captured');
464
- expect(last9.f32[128]).toBe(5);
524
+ expect(last9.f32[128]).toBe(3);
465
525
  }, 60000);
466
526
 
467
- // --- Gate D: validate() rejections ---
468
- it('validate() accepts pcfKernelSize=9 (odd), rejects 2 (even) and 0', () => {
527
+ // --- Gate D: Directional filter validation ---
528
+ it('validate() accepts pcf5 and rejects an unknown directional filter', () => {
469
529
  const valid = validateDirectionalLightData({
470
530
  direction: [0, -1, 0],
471
531
  castShadow: true,
@@ -474,7 +534,7 @@ describe('M6 shadow fields observability', () => {
474
534
  cascadeBlend: 0.2,
475
535
  mapSize: 2048,
476
536
  shadowDistance: 50,
477
- pcfKernelSize: 9,
537
+ shadowFilter: 3,
478
538
  });
479
539
  expect(valid.ok).toBe(true);
480
540
 
@@ -486,7 +546,7 @@ describe('M6 shadow fields observability', () => {
486
546
  cascadeBlend: 0.2,
487
547
  mapSize: 2048,
488
548
  shadowDistance: 50,
489
- pcfKernelSize: 2,
549
+ shadowFilter: 99,
490
550
  });
491
551
  expect(even.ok).toBe(false);
492
552
  if (!even.ok) expect(even.error.code).toBe('shadow-invalid-config');
@@ -499,7 +559,7 @@ describe('M6 shadow fields observability', () => {
499
559
  cascadeBlend: 0.2,
500
560
  mapSize: 2048,
501
561
  shadowDistance: 50,
502
- pcfKernelSize: 0,
562
+ shadowFilter: 0,
503
563
  });
504
564
  expect(zero.ok).toBe(false);
505
565
  if (!zero.ok) expect(zero.error.code).toBe('shadow-invalid-config');
@@ -514,6 +574,16 @@ describe('M6 shadow fields observability', () => {
514
574
  expect(d.diff).toBeGreaterThan(0);
515
575
  }, 60000);
516
576
 
577
+ it('castShadow=false emits a zero directional cascade count in the View UBO', async () => {
578
+ if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
579
+ const { captured } = await renderConfigWithSpy(false, 0.005, 0.05, 3);
580
+ const viewWrites = captured.filter((capture) => capture.f32.length >= 148);
581
+ expect(viewWrites.length).toBeGreaterThan(0);
582
+ const last = viewWrites[viewWrites.length - 1];
583
+ if (last === undefined) throw new Error('no view UBO write captured');
584
+ expect(last.f32[124]).toBe(0);
585
+ }, 60000);
586
+
517
587
  // --- Gate C: pixel A/B — depthBias 0.0 vs 0.5 ---
518
588
  it('pixel A/B: depthBias 0.0 vs 0.5', async () => {
519
589
  if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
@@ -532,8 +602,8 @@ describe('M6 shadow fields observability', () => {
532
602
  expect(d.diff).toBeGreaterThan(0);
533
603
  }, 60000);
534
604
 
535
- // --- Gate C: pixel A/B — pcfKernelSize 1 vs 5 (cap; MAX_PCF_HALF=2) ---
536
- it('pixel A/B: pcfKernelSize 1 vs 5', async () => {
605
+ // --- Gate C: pixel A/B — directional filter profile 1 vs 3 ---
606
+ it('pixel A/B: directional filter profile 1 vs 3', async () => {
537
607
  if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
538
608
  const pxA = await renderConfig(true, 0.005, 0.05, 1);
539
609
  const pxB = await renderConfig(true, 0.005, 0.05, 5);
@@ -582,45 +652,57 @@ describe('M6 shadow fields observability', () => {
582
652
  // (physically removing the UBO write), comment out the matching
583
653
  // `viewPayload[126|127|128] = ...` line in render-system-record.ts and re-run
584
654
  // the corresponding `varied` leg — its diff collapses to 0, matching `held`.
585
- it('falsification: depthBias varied differs, held does not (signal attributable to the field)', async () => {
586
- if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
587
- const varied = diff(
588
- await renderConfig(true, 0.0, 0.05, 3),
589
- await renderConfig(true, 0.5, 0.05, 3),
590
- );
591
- const held = diff(
592
- await renderConfig(true, 0.0, 0.05, 3),
593
- await renderConfig(true, 0.0, 0.05, 3),
594
- );
595
- expect(varied.diff).toBeGreaterThan(0);
596
- expect(held.diff).toBe(0);
597
- }, 90000);
655
+ it(
656
+ 'falsification: depthBias varied differs, held does not (signal attributable to the field)',
657
+ async () => {
658
+ if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
659
+ const varied = diff(
660
+ await renderConfig(true, 0.0, 0.05, 3),
661
+ await renderConfig(true, 0.5, 0.05, 3),
662
+ );
663
+ const held = diff(
664
+ await renderConfig(true, 0.0, 0.05, 3),
665
+ await renderConfig(true, 0.0, 0.05, 3),
666
+ );
667
+ expect(varied.diff).toBeGreaterThan(0);
668
+ expect(held.diff).toBe(0);
669
+ },
670
+ SHADOW_FIELDS_FALSIFIER_TIMEOUT_MS,
671
+ );
598
672
 
599
- it('falsification: normalBias varied differs, held does not', async () => {
600
- if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
601
- const varied = diff(
602
- await renderConfig(true, 0.005, 0.0, 3),
603
- await renderConfig(true, 0.005, 0.5, 3),
604
- );
605
- const held = diff(
606
- await renderConfig(true, 0.005, 0.0, 3),
607
- await renderConfig(true, 0.005, 0.0, 3),
608
- );
609
- expect(varied.diff).toBeGreaterThan(0);
610
- expect(held.diff).toBe(0);
611
- }, 90000);
673
+ it(
674
+ 'falsification: normalBias varied differs, held does not',
675
+ async () => {
676
+ if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
677
+ const varied = diff(
678
+ await renderConfig(true, 0.005, 0.0, 3),
679
+ await renderConfig(true, 0.005, 0.5, 3),
680
+ );
681
+ const held = diff(
682
+ await renderConfig(true, 0.005, 0.0, 3),
683
+ await renderConfig(true, 0.005, 0.0, 3),
684
+ );
685
+ expect(varied.diff).toBeGreaterThan(0);
686
+ expect(held.diff).toBe(0);
687
+ },
688
+ SHADOW_FIELDS_FALSIFIER_TIMEOUT_MS,
689
+ );
612
690
 
613
- it('falsification: pcfKernelSize varied differs, held does not', async () => {
614
- if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
615
- const varied = diff(
616
- await renderConfig(true, 0.005, 0.05, 1),
617
- await renderConfig(true, 0.005, 0.05, 5),
618
- );
619
- const held = diff(
620
- await renderConfig(true, 0.005, 0.05, 1),
621
- await renderConfig(true, 0.005, 0.05, 1),
622
- );
623
- expect(varied.diff).toBeGreaterThan(0);
624
- expect(held.diff).toBe(0);
625
- }, 90000);
691
+ it(
692
+ 'falsification: directional filter varied differs, held does not',
693
+ async () => {
694
+ if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
695
+ const varied = diff(
696
+ await renderConfig(true, 0.005, 0.05, 1),
697
+ await renderConfig(true, 0.005, 0.05, 5),
698
+ );
699
+ const held = diff(
700
+ await renderConfig(true, 0.005, 0.05, 1),
701
+ await renderConfig(true, 0.005, 0.05, 1),
702
+ );
703
+ expect(varied.diff).toBeGreaterThan(0);
704
+ expect(held.diff).toBe(0);
705
+ },
706
+ SHADOW_FIELDS_FALSIFIER_TIMEOUT_MS,
707
+ );
626
708
  });
@@ -203,7 +203,9 @@ function spawnMixedScene(world: World, castShadow: boolean): void {
203
203
  depthBias: 0.005,
204
204
  normalBias: 0.05,
205
205
  shadowDistance: 50,
206
- pcfKernelSize: 3,
206
+ shadowFilter: 2,
207
+ shadowAngularRadius: 0.00465,
208
+ maxPenumbraTexels: 32,
207
209
  },
208
210
  });
209
211
 
@@ -312,7 +314,9 @@ async function renderMixed(castShadow: boolean): Promise<MixedCapture> {
312
314
  if (!host.ok) throw host.error;
313
315
  renderer = host.value.renderer;
314
316
  unsubscribe = renderer.subscribe((event) => {
315
- if (event.kind === 'error') errors.push(event.error.code);
317
+ if (event.kind === 'error') {
318
+ errors.push(event.error.code);
319
+ }
316
320
  });
317
321
  const world = new World();
318
322
  spawnMixedScene(world, castShadow);
@@ -260,10 +260,10 @@ describe('driveLazyEquirectProjection — lazy projection state machine (M3 / w2
260
260
  const world = new World();
261
261
  const handle = catalogEquirect(world, equirectPod());
262
262
  const reg = new RhiErrorListenerRegistry();
263
- const fired: Array<{ code: string; handle: number | undefined }> = [];
263
+ const fired: Array<{ code: string; handle: number | undefined; hint?: string }> = [];
264
264
  reg.add((e) => {
265
265
  if (e.code === 'equirect-projection-failed') {
266
- fired.push({ code: e.code, handle: e.detail.handle });
266
+ fired.push({ code: e.code, handle: e.detail.handle, hint: e.hint });
267
267
  } else {
268
268
  fired.push({ code: e.code, handle: undefined });
269
269
  }
@@ -281,9 +281,12 @@ describe('driveLazyEquirectProjection — lazy projection state machine (M3 / w2
281
281
 
282
282
  // Frame 2: status:'failed' observed -> fire the structured error ONCE.
283
283
  driveLazyEquirectProjection(internals, world, frameState, handle as unknown as number);
284
- expect(fired).toEqual([
285
- { code: 'equirect-projection-failed', handle: handle as unknown as number },
286
- ]);
284
+ expect(fired).toHaveLength(1);
285
+ expect(fired[0]).toMatchObject({
286
+ code: 'equirect-projection-failed',
287
+ handle: handle as unknown as number,
288
+ });
289
+ expect(fired[0]?.hint).toContain('declare Skylight');
287
290
 
288
291
  // Frame 3+: the latch keeps the channel quiet -- no re-fire, no retry
289
292
  // (the store's status:'failed' short-circuit + the frameState latch).
@@ -229,12 +229,11 @@ describe('feat-20260608 M2 m2-t5: sprite pipeline cullMode "none" lets H/V flipp
229
229
  const synth = buildSyntheticRgba();
230
230
  const synthPod = {
231
231
  kind: 'texture' as const,
232
- width: synth.width,
233
- height: synth.height,
232
+ shape: { viewDimension: '2d' as const, extent: { width: synth.width, height: synth.height } },
234
233
  format: 'rgba8unorm-srgb' as const,
235
234
  data: synth.data,
236
235
  colorSpace: 'srgb' as const,
237
- mipmap: false,
236
+ mips: { kind: 'none' as const },
238
237
  };
239
238
  const textureHandle = worldFlip.allocSharedRef('TextureAsset', synthPod as never);
240
239
  const samplerHandle = worldFlip.allocSharedRef('SamplerAsset', {
@@ -382,12 +381,11 @@ describe('feat-20260608 M2 m2-t5: sprite pipeline cullMode "none" lets H/V flipp
382
381
  const synth = buildSyntheticRgba();
383
382
  const synthPod = {
384
383
  kind: 'texture' as const,
385
- width: synth.width,
386
- height: synth.height,
384
+ shape: { viewDimension: '2d' as const, extent: { width: synth.width, height: synth.height } },
387
385
  format: 'rgba8unorm-srgb' as const,
388
386
  data: synth.data,
389
387
  colorSpace: 'srgb' as const,
390
- mipmap: false,
388
+ mips: { kind: 'none' as const },
391
389
  };
392
390
  const textureHandle = worldFlipBoth.allocSharedRef('TextureAsset', synthPod as never);
393
391
  const samplerHandle = worldFlipBoth.allocSharedRef('SamplerAsset', {
@@ -47,7 +47,7 @@ function makeSpec(shaderId: string): PipelineSpec {
47
47
 
48
48
  describe('sprite-lit BGL byte-identical to sprite (AC-07, w4/w5 close)', () => {
49
49
  describe('pbr-view BGL (light buffers; binding 1+2)', () => {
50
- it('sprite-lit vs sprite share the same 10-entry pbr-view BGL under storage-buffer caps', () => {
50
+ it('sprite-lit vs sprite share the same 12-entry pbr-view BGL under storage-buffer caps', () => {
51
51
  const spriteSpec = makeSpec('forgeax::sprite');
52
52
  const spriteLitSpec = makeSpec('forgeax::sprite-lit');
53
53
  const sprite = buildBindGroupLayoutDescriptor(spriteSpec, {
@@ -59,7 +59,9 @@ describe('sprite-lit BGL byte-identical to sprite (AC-07, w4/w5 close)', () => {
59
59
  caps: { storageBuffer: true },
60
60
  });
61
61
  expect(JSON.stringify(spriteLit)).toBe(JSON.stringify(sprite));
62
- expect(spriteLit.entries.length).toBe(10); // binding 10 is the shared Points/Lines view UBO
62
+ // binding 10 is the shared Points/Lines view UBO; bindings 11/12 are the
63
+ // optional SpotLight projector texture and sampler on capable devices.
64
+ expect(spriteLit.entries.length).toBe(12);
63
65
  });
64
66
 
65
67
  it('sprite-lit vs sprite share the same 10-entry pbr-view BGL under uniform fallback caps (AC-10)', () => {