@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
@@ -83,7 +83,11 @@ describe('CSM component schema (w1)', () => {
83
83
  error: {
84
84
  code?: string;
85
85
  hint?: string;
86
- detail?: { field?: string; value?: number; min?: number; max?: number };
86
+ detail?: {
87
+ field?: string;
88
+ actual?: number;
89
+ bound?: { kind?: string; min?: number; max?: number };
90
+ };
87
91
  };
88
92
  };
89
93
 
@@ -97,7 +101,7 @@ describe('CSM component schema (w1)', () => {
97
101
  expect(r.error.code).toBe('shadow-invalid-config');
98
102
  expect(r.error.hint).toContain('cascadeCount');
99
103
  expect(r.error.detail?.field).toBe('cascadeCount');
100
- expect(r.error.detail?.value).toBe(0);
104
+ expect(r.error.detail?.actual).toBe(0);
101
105
  });
102
106
 
103
107
  it('cascadeCount=5 -> ShadowInvalidConfigError (max=4)', () => {
@@ -110,8 +114,8 @@ describe('CSM component schema (w1)', () => {
110
114
  expect(r.error.code).toBe('shadow-invalid-config');
111
115
  expect(r.error.hint).toContain('cascadeCount');
112
116
  expect(r.error.detail?.field).toBe('cascadeCount');
113
- expect(r.error.detail?.value).toBe(5);
114
- expect(r.error.detail?.max).toBe(4);
117
+ expect(r.error.detail?.actual).toBe(5);
118
+ expect(r.error.detail?.bound).toEqual({ kind: 'range', min: 1, max: 4 });
115
119
  });
116
120
 
117
121
  it('splitLambda=-0.1 -> ShadowInvalidConfigError (min=0)', () => {
@@ -124,7 +128,8 @@ describe('CSM component schema (w1)', () => {
124
128
  expect(r.error.code).toBe('shadow-invalid-config');
125
129
  expect(r.error.hint).toContain('splitLambda');
126
130
  expect(r.error.detail?.field).toBe('splitLambda');
127
- expect(r.error.detail?.value).toBeCloseTo(-0.1, 5);
131
+ expect(r.error.detail?.actual).toBeCloseTo(-0.1, 5);
132
+ expect(r.error.detail?.bound).toEqual({ kind: 'range', min: 0, max: 1 });
128
133
  });
129
134
 
130
135
  it('splitLambda=1.1 -> ShadowInvalidConfigError (max=1)', () => {
@@ -137,8 +142,8 @@ describe('CSM component schema (w1)', () => {
137
142
  expect(r.error.code).toBe('shadow-invalid-config');
138
143
  expect(r.error.hint).toContain('splitLambda');
139
144
  expect(r.error.detail?.field).toBe('splitLambda');
140
- expect(r.error.detail?.value).toBeCloseTo(1.1, 5);
141
- expect(r.error.detail?.max).toBe(1);
145
+ expect(r.error.detail?.actual).toBeCloseTo(1.1, 5);
146
+ expect(r.error.detail?.bound).toEqual({ kind: 'range', min: 0, max: 1 });
142
147
  });
143
148
 
144
149
  it('cascadeBlend=-0.1 -> ShadowInvalidConfigError (min=0)', () => {
@@ -151,7 +156,8 @@ describe('CSM component schema (w1)', () => {
151
156
  expect(r.error.code).toBe('shadow-invalid-config');
152
157
  expect(r.error.hint).toContain('cascadeBlend');
153
158
  expect(r.error.detail?.field).toBe('cascadeBlend');
154
- expect(r.error.detail?.value).toBeCloseTo(-0.1, 5);
159
+ expect(r.error.detail?.actual).toBeCloseTo(-0.1, 5);
160
+ expect(r.error.detail?.bound).toEqual({ kind: 'range', min: 0, max: 0.5 });
155
161
  });
156
162
 
157
163
  it('cascadeBlend=0.6 -> ShadowInvalidConfigError (max=0.5)', () => {
@@ -164,8 +170,8 @@ describe('CSM component schema (w1)', () => {
164
170
  expect(r.error.code).toBe('shadow-invalid-config');
165
171
  expect(r.error.hint).toContain('cascadeBlend');
166
172
  expect(r.error.detail?.field).toBe('cascadeBlend');
167
- expect(r.error.detail?.value).toBeCloseTo(0.6, 5);
168
- expect(r.error.detail?.max).toBeCloseTo(0.5, 5);
173
+ expect(r.error.detail?.actual).toBeCloseTo(0.6, 5);
174
+ expect(r.error.detail?.bound).toEqual({ kind: 'range', min: 0, max: 0.5 });
169
175
  });
170
176
 
171
177
  it('all three new fields within valid range spawn succeeds', () => {
@@ -180,29 +186,28 @@ describe('CSM component schema (w1)', () => {
180
186
 
181
187
  // ── w2: ShadowInvalidConfigDetail.max field constructor tests ──────────
182
188
 
183
- describe('w2: ShadowInvalidConfigDetail.max constructor', () => {
184
- it('cascadeCount=5 detail.max=4 via constructor', () => {
189
+ describe('w2: ShadowInvalidConfigDetail.bound constructor', () => {
190
+ it('cascadeCount=5 bound.max=4 via constructor', () => {
185
191
  // TDD red: max param not yet on constructor.
186
192
  const err = new (ShadowInvalidConfigError as any)('cascadeCount', 5, 1, 4);
187
193
  expect(err.code).toBe('shadow-invalid-config');
188
194
  expect(err.detail.field).toBe('cascadeCount');
189
- expect(err.detail.value).toBe(5);
190
- expect(err.detail.min).toBe(1);
191
- expect(err.detail.max).toBe(4);
195
+ expect(err.detail.actual).toBe(5);
196
+ expect(err.detail.bound).toEqual({ kind: 'range', min: 1, max: 4 });
192
197
  expect(err.hint).toContain('[1, 4]');
193
198
  });
194
199
 
195
- it('cascadeBlend=0.6 detail.max=0.5 via constructor', () => {
200
+ it('cascadeBlend=0.6 bound.max=0.5 via constructor', () => {
196
201
  const err = new (ShadowInvalidConfigError as any)('cascadeBlend', 0.6, 0, 0.5);
197
202
  expect(err.detail.field).toBe('cascadeBlend');
198
- expect(err.detail.max).toBeCloseTo(0.5, 5);
203
+ expect(err.detail.bound).toEqual({ kind: 'range', min: 0, max: 0.5 });
199
204
  expect(err.hint).toContain('[0, 0.5]');
200
205
  });
201
206
 
202
- it('splitLambda=1.1 detail.max=1 via constructor', () => {
207
+ it('splitLambda=1.1 bound.max=1 via constructor', () => {
203
208
  const err = new (ShadowInvalidConfigError as any)('splitLambda', 1.1, 0, 1);
204
209
  expect(err.detail.field).toBe('splitLambda');
205
- expect(err.detail.max).toBe(1);
210
+ expect(err.detail.bound).toEqual({ kind: 'range', min: 0, max: 1 });
206
211
  expect(err.hint).toContain('[0, 1]');
207
212
  });
208
213
  });
@@ -260,15 +265,17 @@ describe('CSM component schema (w1)', () => {
260
265
  const err = r.error as {
261
266
  code?: string;
262
267
  hint?: string;
263
- detail?: { field?: string; value?: number; min?: number; max?: number };
268
+ detail?: {
269
+ field?: string;
270
+ actual?: number;
271
+ bound?: { kind?: string; min?: number; max?: number };
272
+ };
264
273
  };
265
274
  expect(err.code).toBe('shadow-invalid-config');
266
275
  expect(err.hint).toContain('mapSize');
267
276
  expect(err.detail?.field).toBe('mapSize');
268
- expect(err.detail?.value).toBe(0);
269
- expect(err.detail?.min).toBe(1);
270
- // mapSize < 1 has no max (lower-bound only), compatible with old callers.
271
- expect(err.detail?.max).toBeUndefined();
277
+ expect(err.detail?.actual).toBe(0);
278
+ expect(err.detail?.bound).toEqual({ kind: 'lower-bound', operator: '>=', value: 1 });
272
279
  }
273
280
  });
274
281
 
@@ -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({