@forgeax/engine-runtime 0.1.20 → 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 (58) 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__/lights-preservation.fixup.test.d.ts +2 -0
  6. package/dist/__tests__/lights-preservation.fixup.test.d.ts.map +1 -0
  7. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  8. package/dist/createRenderer.d.ts.map +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.mjs +2 -1
  11. package/dist/index.mjs.map +1 -1
  12. package/package.json +30 -30
  13. package/src/__tests__/active-camera.unit.test.ts +6 -8
  14. package/src/__tests__/asset-registry.recursive.spec.ts +1 -1
  15. package/src/__tests__/clamp-to-last.e2e.browser.test.ts +2 -2
  16. package/src/__tests__/components.test-d.ts +10 -3
  17. package/src/__tests__/components.unit.test.ts +13 -7
  18. package/src/__tests__/create-renderer-assembly.integration.test.ts +24 -0
  19. package/src/__tests__/create-renderer-gpu-pass-timing.unit.test.ts +32 -0
  20. package/src/__tests__/createRenderer.browser-no-webgpu.test.ts +66 -0
  21. package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +9 -1
  22. package/src/__tests__/dawn/instances-shadow.dawn.test.ts +15 -2
  23. package/src/__tests__/errors.unit.test.ts +51 -20
  24. package/src/__tests__/extract-frames-merge.test.ts +48 -40
  25. package/src/__tests__/extract-skin-mismatch.unit.test.ts +6 -2
  26. package/src/__tests__/frame-targets-shadow-regression.unit.test.ts +13 -0
  27. package/src/__tests__/geometry.unit.test.ts +7 -10
  28. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +2 -1
  29. package/src/__tests__/light-snapshot.test-d.ts +8 -0
  30. package/src/__tests__/lights-preservation.fixup.test.ts +127 -0
  31. package/src/__tests__/lights.unit.test.ts +75 -2235
  32. package/src/__tests__/pbr-pipeline.unit.test.ts +6 -1
  33. package/src/__tests__/pipeline.unit.test.ts +48 -202
  34. package/src/__tests__/point-light-shadow.browser.test.ts +208 -83
  35. package/src/__tests__/point-light-shadow.unit.test.ts +61 -4
  36. package/src/__tests__/render-error-exhaustive.test-d.ts +63 -9
  37. package/src/__tests__/render-system-mega.test.ts +30 -41
  38. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +4 -1
  39. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +4 -1
  40. package/src/__tests__/render-system-skin-bg.unit.test.ts +14 -9
  41. package/src/__tests__/renderer-recover.unit.test.ts +8 -0
  42. package/src/__tests__/renderer-surface.unit.test.ts +229 -3
  43. package/src/__tests__/shadow-csm-atlas.test.ts +41 -0
  44. package/src/__tests__/shadow-csm-component.test.ts +31 -24
  45. package/src/__tests__/shadow-csm-extract.test.ts +156 -47
  46. package/src/__tests__/shadow-csm-runtime-vary.dawn.test.ts +72 -0
  47. package/src/__tests__/shadow-csm-shader.dawn.test.ts +19 -0
  48. package/src/__tests__/shadow-csm-ubo.test.ts +41 -45
  49. package/src/__tests__/shadow-csm-viewport.browser.test.ts +29 -0
  50. package/src/__tests__/shadow-fields-observable.dawn.test.ts +85 -20
  51. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +3 -1
  52. package/src/__tests__/skylight-lazy-projection.test.ts +8 -5
  53. package/src/__tests__/ssao-bgl.test.ts +10 -16
  54. package/src/__tests__/ssao-passes.test.ts +19 -2
  55. package/src/__tests__/systems.unit.test.ts +44 -31
  56. package/src/__tests__/view-ubo-layout.browser.test.ts +20 -10
  57. package/src/createRenderer.ts +1 -0
  58. package/src/index.ts +1 -2
@@ -6,16 +6,22 @@
6
6
  // This test asserts the extract stage:
7
7
  // (a) castShadow:true → CSM path runs (lightViewProj + cascades populated)
8
8
  // (b) castShadow:false → CSM path skipped (no cascade output)
9
- // (c) ExtractedLights carries depthBias/normalBias/pcfKernelSize
10
- // (d) bias/PCF fields are undefined when castShadow:false
9
+ // (c) ExtractedLights carries depthBias/normalBias and accepted filter quality
10
+ // (d) bias/filter fields are undefined when castShadow:false
11
11
  // RED before m2-t2/m2-t3: extract still queries the deleted
12
12
  // castShadow: true; lightViewProj won't populate.
13
13
 
14
14
  import { World } from '@forgeax/engine-ecs';
15
+ import { vec3 } from '@forgeax/engine-math';
15
16
  import { Camera, DirectionalLight } from '@forgeax/engine-render';
16
17
  import { Transform } from '@forgeax/engine-scene';
17
18
  import { describe, expect, it } from 'vitest';
18
- import { extractFrame, prepareExtractContext } from '../../../render/src/render-system-extract';
19
+ import {
20
+ type CsmCameraData,
21
+ computeDirectionalCsm,
22
+ extractFrame,
23
+ prepareExtractContext,
24
+ } from '../../../render/src/render-system-extract';
19
25
 
20
26
  function makeWorld(castShadow?: boolean): World {
21
27
  const world = new World();
@@ -26,7 +32,9 @@ function makeWorld(castShadow?: boolean): World {
26
32
  castShadow: castShadow ?? true,
27
33
  depthBias: 0.01,
28
34
  normalBias: 0.08,
29
- pcfKernelSize: 5,
35
+ shadowFilter: 3,
36
+ shadowAngularRadius: 0.01,
37
+ maxPenumbraTexels: 48,
30
38
  },
31
39
  });
32
40
  world.spawn(
@@ -56,16 +64,16 @@ describe('ExtractedLights interface (M2 castShadow gate)', () => {
56
64
  }
57
65
  });
58
66
 
59
- it('splitPlanes is a Float32Array of length 4', () => {
67
+ it('splitPlanes is a Float32Array of four vec4 metric lanes', () => {
60
68
  const world = makeWorld(true);
61
69
  const frame = extractFrame(world, prepareExtractContext(world));
62
70
  const { splitPlanes } = frame.lights;
63
71
  expect(splitPlanes).toBeDefined();
64
72
  if (splitPlanes === undefined) return;
65
73
  expect(splitPlanes).toBeInstanceOf(Float32Array);
66
- expect(splitPlanes.length).toBe(4);
74
+ expect(splitPlanes.length).toBe(16);
67
75
  for (let i = 0; i < 4; i++) {
68
- const v = splitPlanes[i];
76
+ const v = splitPlanes[i * 4];
69
77
  expect(typeof v).toBe('number');
70
78
  expect(Number.isFinite(v)).toBe(true);
71
79
  }
@@ -95,7 +103,7 @@ describe('ExtractedLights interface (M2 castShadow gate)', () => {
95
103
  });
96
104
  });
97
105
 
98
- describe('ExtractedLights carries bias/PCF from merged DirectionalLight', () => {
106
+ describe('ExtractedLights carries bias and accepted Directional quality', () => {
99
107
  it('depthBias populated when castShadow=true', () => {
100
108
  const world = makeWorld(true);
101
109
  const frame = extractFrame(world, prepareExtractContext(world));
@@ -108,18 +116,46 @@ describe('ExtractedLights interface (M2 castShadow gate)', () => {
108
116
  expect(frame.lights.normalBias).toBeCloseTo(0.08, 5);
109
117
  });
110
118
 
111
- it('pcfKernelSize populated when castShadow=true', () => {
119
+ it('accepted Directional quality is populated when castShadow=true', () => {
112
120
  const world = makeWorld(true);
113
121
  const frame = extractFrame(world, prepareExtractContext(world));
114
- expect(frame.lights.pcfKernelSize).toBe(5);
122
+ expect(frame.lights.directionalShadowQuality).toEqual({ kind: 'pcf', kernel: 5 });
115
123
  });
116
124
 
117
- it('depthBias, normalBias, pcfKernelSize are undefined when castShadow=false', () => {
125
+ it('quality is undefined when castShadow=false', () => {
118
126
  const world = makeWorld(false);
119
127
  const frame = extractFrame(world, prepareExtractContext(world));
120
128
  expect(frame.lights.depthBias).toBeUndefined();
121
129
  expect(frame.lights.normalBias).toBeUndefined();
122
- expect(frame.lights.pcfKernelSize).toBeUndefined();
130
+ expect(frame.lights.directionalShadowQuality).toBeUndefined();
131
+ });
132
+
133
+ it('withholds an invalid PCSS candidate and exposes a structured error', () => {
134
+ const world = new World();
135
+ const spawned = world.spawn({
136
+ component: DirectionalLight,
137
+ data: {
138
+ direction: [0, -1, 0],
139
+ shadowFilter: 4,
140
+ shadowAngularRadius: 999,
141
+ maxPenumbraTexels: 32,
142
+ },
143
+ });
144
+ expect(spawned.ok).toBe(true);
145
+ world.spawn(
146
+ { component: Transform, data: {} },
147
+ { component: Camera, data: { fov: Math.PI / 4, aspect: 1, near: 0.1, far: 100 } },
148
+ );
149
+
150
+ const frame = extractFrame(world, prepareExtractContext(world));
151
+ expect(frame.lights.directionalShadowError?.code).toBe('shadow-invalid-config');
152
+ expect(frame.lights.directionalShadowError?.detail).toMatchObject({
153
+ field: 'shadowAngularRadius',
154
+ actual: 999,
155
+ });
156
+ expect(frame.lights.directionalShadowQuality).toBeUndefined();
157
+ expect(frame.lights.directionalCsmConfig).toBeUndefined();
158
+ expect(frame.lights.lightViewProj).toBeUndefined();
123
159
  });
124
160
  });
125
161
 
@@ -174,24 +210,18 @@ describe('ExtractedLights interface (M2 castShadow gate)', () => {
174
210
  });
175
211
  });
176
212
 
177
- // feat-20260621-learn-render-5-3-production-shadow-demos M0 / AC-14:
178
- // extract reads DirectionalLightShadow.pcfKernelSize into the frame lights
179
- // struct (symmetric with cascadeCount/cascadeBlend). Before M0 this field is
180
- // dead (research F7): extract never read it, so frame.lights.pcfKernelSize
181
- // is undefined regardless of the component value -- these cases are RED
182
- // until M0-T-IMPL-EXTRACT lands.
183
- describe('pcfKernelSize wiring (M0, AC-14; merged DirectionalLight)', () => {
184
- function setupWorldWithPcf(pcfKernelSize: number): World {
213
+ describe('CSM fit derives world-scale metrics and accepts one quality projection', () => {
214
+ function setupWorldWithQuality(shadowFilter: number): World {
185
215
  const world = new World();
186
- // feat-20260621: shadow fields merged onto DirectionalLight (castShadow
187
- // defaults true); pcfKernelSize is read from the same component.
188
216
  world.spawn({
189
217
  component: DirectionalLight,
190
218
  data: {
191
219
  direction: [0, -1, 0],
192
220
  cascadeCount: 1,
193
221
  shadowDistance: 100,
194
- pcfKernelSize,
222
+ shadowFilter,
223
+ shadowAngularRadius: 0.01,
224
+ maxPenumbraTexels: 32,
195
225
  },
196
226
  });
197
227
  world.spawn(
@@ -201,36 +231,115 @@ describe('ExtractedLights interface (M2 castShadow gate)', () => {
201
231
  return world;
202
232
  }
203
233
 
204
- it('reads pcfKernelSize=1 from the merged component', () => {
205
- const world = setupWorldWithPcf(1);
206
- const frame = extractFrame(world, prepareExtractContext(world));
207
- expect(frame.lights.pcfKernelSize).toBe(1);
208
- });
209
-
210
- it('reads pcfKernelSize=3 from the merged component', () => {
211
- const world = setupWorldWithPcf(3);
234
+ it('projects PCSS quality from the same Directional query', () => {
235
+ const world = setupWorldWithQuality(4);
212
236
  const frame = extractFrame(world, prepareExtractContext(world));
213
- expect(frame.lights.pcfKernelSize).toBe(3);
237
+ expect(frame.lights.directionalShadowQuality).toEqual({
238
+ kind: 'pcss',
239
+ preset: 'medium',
240
+ angularRadiusRadians: expect.closeTo(0.01, 0.000001),
241
+ maxPenumbraTexels: 32,
242
+ });
214
243
  });
215
244
 
216
- it('reads pcfKernelSize=5 from the merged component', () => {
217
- const world = setupWorldWithPcf(5);
218
- const frame = extractFrame(world, prepareExtractContext(world));
219
- expect(frame.lights.pcfKernelSize).toBe(5);
245
+ it('exposes four finite vec4 cascade metric lanes from the CSM fit', () => {
246
+ const camera: CsmCameraData = {
247
+ world: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
248
+ fov: Math.PI / 4,
249
+ aspect: 1,
250
+ near: 0.1,
251
+ far: 100,
252
+ projection: 'perspective',
253
+ orthoLeft: -1,
254
+ orthoRight: 1,
255
+ orthoBottom: -1,
256
+ orthoTop: 1,
257
+ };
258
+ for (const cascadeCount of [1, 2, 3, 4]) {
259
+ const result = computeDirectionalCsm(
260
+ vec3.create(0, -1, 0),
261
+ {
262
+ cascadeCount,
263
+ splitLambda: 0.75,
264
+ cascadeBlend: 0.2,
265
+ mapSize: 1024,
266
+ shadowDistance: 100,
267
+ shadowFilter: 3,
268
+ shadowAngularRadius: 0.01,
269
+ maxPenumbraTexels: 32,
270
+ },
271
+ camera,
272
+ );
273
+ expect(result).not.toBeNull();
274
+ const lanes = result?.splitPlanes ?? new Float32Array();
275
+ expect(lanes).toHaveLength(16);
276
+ for (let index = 0; index < cascadeCount; index += 1) {
277
+ expect(Number.isFinite(lanes[index * 4 + 1])).toBe(true);
278
+ expect(lanes[index * 4 + 1]).toBeGreaterThan(0);
279
+ expect(Number.isFinite(lanes[index * 4 + 2])).toBe(true);
280
+ expect(lanes[index * 4 + 2]).toBeGreaterThan(0);
281
+ }
282
+ }
220
283
  });
221
284
 
222
- it('defaults pcfKernelSize to 3 when the component omits it', () => {
223
- const world = new World();
224
- world.spawn({
225
- component: DirectionalLight,
226
- data: { direction: [0, -1, 0], cascadeCount: 1 },
227
- });
228
- world.spawn(
229
- { component: Transform, data: {} },
230
- { component: Camera, data: { fov: Math.PI / 4, aspect: 1, near: 0.1, far: 100 } },
285
+ it('recomputes worldUnitsPerTexel when mapSize changes and rejects non-finite config', () => {
286
+ const camera: CsmCameraData = {
287
+ world: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
288
+ fov: Math.PI / 4,
289
+ aspect: 1,
290
+ near: 0.1,
291
+ far: 100,
292
+ projection: 'perspective',
293
+ orthoLeft: -1,
294
+ orthoRight: 1,
295
+ orthoBottom: -1,
296
+ orthoTop: 1,
297
+ };
298
+ const coarse = computeDirectionalCsm(
299
+ vec3.create(0, -1, 0),
300
+ {
301
+ cascadeCount: 1,
302
+ splitLambda: 0.75,
303
+ cascadeBlend: 0.2,
304
+ mapSize: 512,
305
+ shadowDistance: 100,
306
+ shadowFilter: 3,
307
+ shadowAngularRadius: 0.01,
308
+ maxPenumbraTexels: 32,
309
+ },
310
+ camera,
231
311
  );
232
- const frame = extractFrame(world, prepareExtractContext(world));
233
- expect(frame.lights.pcfKernelSize).toBe(3);
312
+ const fine = computeDirectionalCsm(
313
+ vec3.create(0, -1, 0),
314
+ {
315
+ cascadeCount: 1,
316
+ splitLambda: 0.75,
317
+ cascadeBlend: 0.2,
318
+ mapSize: 2048,
319
+ shadowDistance: 100,
320
+ shadowFilter: 3,
321
+ shadowAngularRadius: 0.01,
322
+ maxPenumbraTexels: 32,
323
+ },
324
+ camera,
325
+ );
326
+ expect(fine?.splitPlanes[1]).toBeCloseTo((coarse?.splitPlanes[1] ?? 0) / 4, 4);
327
+ expect(
328
+ computeDirectionalCsm(
329
+ vec3.create(0, -1, 0),
330
+ {
331
+ cascadeCount: 1,
332
+ splitLambda: 0.75,
333
+ cascadeBlend: 0.2,
334
+ mapSize: Number.NaN,
335
+ shadowDistance: 100,
336
+ shadowFilter: 3,
337
+ shadowAngularRadius: 0.01,
338
+ maxPenumbraTexels: 32,
339
+ },
340
+ camera,
341
+ ),
342
+ ).toBeNull();
234
343
  });
235
344
  });
236
345
  });
@@ -20,6 +20,8 @@ import { World } from '@forgeax/engine-ecs';
20
20
  import { Camera, DirectionalLight } from '@forgeax/engine-render';
21
21
  import { Transform } from '@forgeax/engine-scene';
22
22
  import { describe, expect, it } from 'vitest';
23
+ import { projectDirectionalShadowInspection } from '../../../render/src/assembly/directional-shadow-inspection';
24
+ import { resolveDirectionalShadowBackendAdmission } from '../../../render/src/render-pipeline';
23
25
  import { constructRuntimeRendererHost } from '../renderer-host';
24
26
  import { drawPublished } from './draw-published';
25
27
 
@@ -34,6 +36,51 @@ const ENGINE_MANIFEST_URL = `data:application/json,${encodeURIComponent(
34
36
  // biome-ignore lint/suspicious/noExplicitAny: dawn-node detection guard
35
37
  const dawnReady = typeof navigator !== 'undefined' && (navigator as any).gpu !== undefined;
36
38
 
39
+ describe('M3 RhiNull structural admission', () => {
40
+ it('requires the structural-only RhiNull projection and explicit no-pixel claim', async () => {
41
+ const module = (await import('../../../render/src/render-pipeline')) as Record<string, unknown>;
42
+ const resolver = module.resolveDirectionalShadowBackendAdmission;
43
+ expect(typeof resolver).toBe('function');
44
+ const result = (
45
+ resolver as (input: {
46
+ backendKind: 'null';
47
+ requested: 'pcssMedium' | 'pcssHigh';
48
+ candidate: 'accepted' | 'failed';
49
+ }) => { effective: string; fallbackReason?: string; pixelEvidence: string }
50
+ )({ backendKind: 'null', requested: 'pcssMedium', candidate: 'accepted' });
51
+ expect(result).toMatchObject({
52
+ effective: 'rhi-null-structural',
53
+ fallbackReason: 'rhi-null-structural',
54
+ pixelEvidence: 'not-available',
55
+ });
56
+ });
57
+
58
+ it('keeps RhiNull recovery structural and generation-scoped', () => {
59
+ const inspection = projectDirectionalShadowInspection({
60
+ admission: resolveDirectionalShadowBackendAdmission({
61
+ backendKind: 'null',
62
+ requested: 'pcssMedium',
63
+ candidate: 'accepted',
64
+ }),
65
+ cascadeCount: 4,
66
+ mapSize: 1024,
67
+ atlasBytes: 16_777_216,
68
+ writerPasses: 4,
69
+ blockerTaps: 0,
70
+ filterTapUpperBound: 0,
71
+ seamTapUpperBound: 0,
72
+ deviceGeneration: 4,
73
+ graphGeneration: 9,
74
+ });
75
+ expect(inspection).toMatchObject({
76
+ effective: 'rhi-null-structural',
77
+ pixelEvidence: 'not-available',
78
+ deviceGeneration: 4,
79
+ graphGeneration: 9,
80
+ });
81
+ });
82
+ });
83
+
37
84
  const WIDTH = 320;
38
85
  const HEIGHT = 240;
39
86
 
@@ -200,4 +247,29 @@ describe('CSM runtime cascade + mapSize variation (M5/w26)', () => {
200
247
  const { world } = buildScene(1, 1024);
201
248
  expect(drawPublished(renderer, world).ok).toBe(true);
202
249
  });
250
+
251
+ it('castShadow true -> false -> true toggles the directional graph without device error', async () => {
252
+ if (!dawnReady) return;
253
+ const canvas = createMockCanvas();
254
+ const host = await constructRuntimeRendererHost(
255
+ canvas as unknown as HTMLCanvasElement,
256
+ {},
257
+ { shaderManifestUrl: ENGINE_MANIFEST_URL },
258
+ );
259
+ expect(host.ok).toBe(true);
260
+ if (!host.ok) throw host.error;
261
+ const { renderer } = host.value;
262
+ const { world, shadowEntity } = buildScene(4, 1024);
263
+
264
+ expect(drawPublished(renderer, world).ok).toBe(true);
265
+ world.set(shadowEntity, DirectionalLight, { castShadow: false });
266
+ expect(drawPublished(renderer, world).ok).toBe(true);
267
+ world.set(shadowEntity, DirectionalLight, {
268
+ castShadow: true,
269
+ mapSize: 1024,
270
+ shadowDistance: 50,
271
+ cascadeCount: 4,
272
+ });
273
+ expect(drawPublished(renderer, world).ok).toBe(true);
274
+ });
203
275
  });
@@ -20,8 +20,15 @@
20
20
  // regression in the kernel logic itself. The kernel is the executable AC-06
21
21
  // spec; w18 implementation must derive identical behavior.
22
22
 
23
+ import { readFileSync } from 'node:fs';
24
+ import { fileURLToPath } from 'node:url';
23
25
  import { describe, expect, it } from 'vitest';
24
26
 
27
+ const directionalShader = readFileSync(
28
+ fileURLToPath(new URL('../../../shader/src/lighting-directional.wgsl', import.meta.url)),
29
+ 'utf8',
30
+ );
31
+
25
32
  // biome-ignore lint/suspicious/noExplicitAny: dawn-node detection guard
26
33
  const dawnReady = typeof navigator !== 'undefined' && (navigator as any).gpu !== undefined;
27
34
 
@@ -228,6 +235,18 @@ describe('CSM shader cascade selection + atlas UV + blend (M5/w17)', () => {
228
235
  expect(dawnReady).toBe(true);
229
236
  });
230
237
 
238
+ it('production Directional PCSS uses raw depth and comparison without new bindings', () => {
239
+ expect(directionalShader).toContain('fn _samplePcssForCascade');
240
+ expect(directionalShader).toContain('shadow_load_raw_depth');
241
+ expect(directionalShader).toContain('shadow_sample_compare');
242
+ expect(directionalShader).toContain('PCSS_MEDIUM_RAW_TAPS : u32 = 8u');
243
+ expect(directionalShader).toContain('PCSS_MEDIUM_COMPARE_TAPS : u32 = 16u');
244
+ expect(directionalShader).toContain('PCSS_HIGH_RAW_TAPS : u32 = 16u');
245
+ expect(directionalShader).toContain('PCSS_HIGH_COMPARE_TAPS : u32 = 32u');
246
+ expect(directionalShader).not.toMatch(/@binding\(/);
247
+ expect(directionalShader).not.toMatch(/frameIndex|frame_index|taaJitter|taa_jitter|jitter/);
248
+ });
249
+
231
250
  describe('cascade selection by viewZ (AC-03 / AC-06)', () => {
232
251
  it('viewZ < splits[0] -> layer 0', async () => {
233
252
  const r = await runKernel({
@@ -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
+ });