@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
@@ -10,7 +10,21 @@
10
10
  // This file is scoped to the 'browser' vitest project (file-naming convention
11
11
  // *.browser.test.ts); the 'dawn' and 'unit' projects exclude it.
12
12
 
13
+ import { HANDLE_CUBE } from '@forgeax/engine-assets-runtime';
14
+ import { World } from '@forgeax/engine-ecs';
15
+ import {
16
+ Camera,
17
+ DirectionalLight,
18
+ Materials,
19
+ MeshFilter,
20
+ MeshRenderer,
21
+ PointLight,
22
+ PointLightShadow,
23
+ } from '@forgeax/engine-render';
24
+ import { propagateTransforms, Transform } from '@forgeax/engine-scene';
13
25
  import { describe, expect, it } from 'vitest';
26
+ import { page } from 'vitest/browser';
27
+ import { constructRuntimeRendererHost } from '../renderer-host';
14
28
 
15
29
  // WebGPU bitmask constants per spec (avoids needing @webgpu/types globals
16
30
  // in the runtime tsconfig). Values are stable across implementations.
@@ -192,87 +206,198 @@ describe('M0 cube_array comparison sampler (browser)', () => {
192
206
  });
193
207
  });
194
208
 
195
- // feat-20260612-point-light-shadows-urp-hdrp M3 / T-M3-6 (AC-14 URP path).
196
- //
197
- // URP-end-to-end pixel-readback assertion: spawn PointLight + PointLightShadow
198
- // + occluder + ground plane, run one frame, read back swap-chain pixels, and
199
- // assert the occluded fragment is darker than a non-occluded reference
200
- // fragment.
201
- //
202
- // Round-2 status (feat-20260612 Round-2 commit-B + commit-C): the BGL
203
- // hookup landed -- POINT_SHADOW_AVAILABLE is registered at all 3
204
- // vite-plugin-shader define sites, the runtime PBR view BGL emits
205
- // always-on bindings 5 (cube_array depth atlas) + 6 (shadowParams UBO 64 B),
206
- // and recordPointShadowPass walks validated entries with real drawIndexed
207
- // calls + per-face VP write to viewUniformBuffer.lightSpaceMatrix. The
208
- // shadowParams UBO is written per frame in record-stage with
209
- // (near, far, 1/(far-near), 0) per shadow-casting snapshot.
210
- //
211
- // What remains for this `it.skip` to flip to `it`: the full
212
- // createRenderer + canvas + readPixels + scene scaffolding (mirror of
213
- // `light-casters-9-light.browser.test.ts` shape: spawnCamera + spawn
214
- // occluder cube + spawn ground plane + spawnPointAt + spawnPointShadow
215
- // + driving FRAMES_PER_SCENE rAF ticks + sampleBlockAverage at occluded
216
- // vs free sample sites). The wiring is unblocked at the engine layer; the
217
- // scope of writing the test fixture itself + tuning the lighting setup so
218
- // occluded < non-occluded reliably under chromium headless presentation
219
- // timing exceeds Round-2 fix-up budget. Tracked as a sub-followup
220
- // (feat-followup-point-shadow-browser-readback) per implement-decisions.md.
221
- describe('URP point shadow pixel readback (browser, T-M3-6 / AC-14)', () => {
222
- it.skip('occluded fragment darker than non-occluded -- BGL hookup landed in Round-2; full createRenderer + readPixels scaffolding deferred', () => {
223
- // Pseudocode for the assertion the runnable test will make once the
224
- // hookup lands:
225
- //
226
- // const renderer = createRenderer(canvas, { ... });
227
- // const world = new World();
228
- // world.spawn({ component: Transform, data: {...} },
229
- // { component: PointLight, data: {...} },
230
- // { component: PointLightShadow, data: {} });
231
- // // ... ground plane + occluder ...
232
- // renderer.draw(world);
233
- // const px = await readback(canvas, occludedX, occludedY);
234
- // const py = await readback(canvas, freeX, freeY);
235
- // expect(px.r + px.g + px.b).toBeLessThan(py.r + py.g + py.b);
236
- // expect(py.r + py.g + py.b).toBeGreaterThan(0); // non-black frame
237
- });
238
- });
209
+ // feat-20260612-point-light-shadows-urp-hdrp M5 / AC-24 focused consumer.
210
+ // This is a real Standard createRenderer path rather than a cube-array-only
211
+ // primitive: the same caster/receiver scene is rendered with and without the
212
+ // PointLightShadow component, then the canvas pixels and renderer pass facts
213
+ // are checked together.
214
+ const browserReady = typeof navigator !== 'undefined' && navigator.gpu !== undefined;
215
+ const REAL_CANVAS_SIZE = 96;
239
216
 
240
- // feat-20260612-point-light-shadows-urp-hdrp M4 / T-M4-6 (AC-14 HDRP path).
241
- //
242
- // HDRP-end-to-end pixel-readback assertion: createRenderer with HDRP pipeline
243
- // installed -> spawn PointLight + PointLightShadow + occluder + ground ->
244
- // renderFrame -> readback pixels. Assert occluded < non-occluded brightness
245
- // AND non-black frame.
246
- //
247
- // Round-2 status (feat-20260612 Round-2 commit-B + commit-C): the BGL
248
- // hookup landed for both URP and HDRP -- the shared `pbr-view-bgl` declares
249
- // always-on bindings 5 + 6 (HDRP rides binding 5 only; HDRP gets the
250
- // (near, far) pair via LightSlot.kind_and_pad.zw per plan-strategy D-8),
251
- // and hdrp-cluster-forward.wgsl now imports shadowAtlas + shadowSampler
252
- // from common.wgsl under the POINT_SHADOW_AVAILABLE ifdef so naga_oil
253
- // resolves the free identifiers. recordPointShadowPass writes real depth
254
- // values to the cube atlas faces.
255
- //
256
- // What remains for this `it.skip` to flip to `it`: same as the URP test --
257
- // the full createRenderer + canvas + readPixels + scene scaffolding,
258
- // additionally with `installPipeline(hdrpAsset)` to drive the HDRP path.
259
- // Tracked under the same sub-followup (feat-followup-point-shadow-browser-readback).
260
- describe('HDRP point shadow pixel readback (browser, T-M4-6 / AC-14)', () => {
261
- it.skip('occluded fragment darker than non-occluded -- BGL hookup landed in Round-2; full createRenderer + readPixels + HDRP install scaffolding deferred', () => {
262
- // Pseudocode for the assertion the runnable test will make once the
263
- // hookup lands:
264
- //
265
- // const renderer = createRenderer(canvas, { ... });
266
- // renderer.installPipeline(hdrpAsset); // forgeax::hdrp
267
- // const world = new World();
268
- // world.spawn({ component: Transform, data: {...} },
269
- // { component: PointLight, data: {...} },
270
- // { component: PointLightShadow, data: {} });
271
- // // ... ground plane + occluder ...
272
- // renderer.draw(world);
273
- // const px = await readback(canvas, occludedX, occludedY);
274
- // const py = await readback(canvas, freeX, freeY);
275
- // expect(px.r + px.g + px.b).toBeLessThan(py.r + py.g + py.b);
276
- // expect(py.r + py.g + py.b).toBeGreaterThan(0); // non-black frame
277
- });
278
- });
217
+ function spawnPointShadowScene(world: World, withShadow: boolean): void {
218
+ const material = world.allocSharedRef(
219
+ 'MaterialAsset',
220
+ Materials.standard({
221
+ baseColor: [0.78, 0.26, 0.08, 1],
222
+ metallic: 0,
223
+ roughness: 0.45,
224
+ }),
225
+ );
226
+ world
227
+ .spawn(
228
+ { component: Transform, data: { pos: [0, 0, 0], scale: [1.3, 1.3, 1.3] } },
229
+ { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
230
+ { component: MeshRenderer, data: { materials: [material] } },
231
+ )
232
+ .unwrap();
233
+ world
234
+ .spawn(
235
+ { component: Transform, data: { pos: [0, -1.65, 0], scale: [3.5, 0.15, 3.5] } },
236
+ { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
237
+ { component: MeshRenderer, data: { materials: [material] } },
238
+ )
239
+ .unwrap();
240
+ world
241
+ .spawn(
242
+ { component: Transform, data: { pos: [0, 0, 6] } },
243
+ { component: Camera, data: { fov: Math.PI / 4, aspect: 1, near: 0.1, far: 50 } },
244
+ )
245
+ .unwrap();
246
+ world
247
+ .spawn({
248
+ component: DirectionalLight,
249
+ data: { direction: [-0.4, -0.8, -1], intensity: 0.35, castShadow: false },
250
+ })
251
+ .unwrap();
252
+ const point = [1.8, 2.6, 2.2] as const;
253
+ world
254
+ .spawn(
255
+ { component: Transform, data: { pos: point } },
256
+ { component: PointLight, data: { color: [1, 0.72, 0.4], intensity: 22, range: 12 } },
257
+ ...(withShadow
258
+ ? [{ component: PointLightShadow, data: { mapSize: 256, nearPlane: 0.1, farPlane: 25 } }]
259
+ : []),
260
+ )
261
+ .unwrap();
262
+
263
+ // The standalone renderer host does not install scenePlugin for this
264
+ // fixture. Publish world-space matrices before extract so the pixel proof
265
+ // exercises the actual caster/receiver scene instead of the clear colour.
266
+ propagateTransforms(world).unwrap();
267
+ }
268
+
269
+ async function waitForPresentedPixels(canvas: HTMLCanvasElement): Promise<Uint8Array> {
270
+ let latest = new Uint8Array(REAL_CANVAS_SIZE * REAL_CANVAS_SIZE * 4);
271
+ for (let attempt = 0; attempt < 6; attempt += 1) {
272
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
273
+ await new Promise<void>((resolve) => setTimeout(resolve, 25));
274
+ // CDP-backed element screenshots read the compositor surface directly;
275
+ // createImageBitmap(canvas) can race swap-chain presentation in headless
276
+ // Chromium and return an all-zero opaque surface.
277
+ const shot = await page.elementLocator(canvas).screenshot({ base64: true, save: false });
278
+ const b64 = typeof shot === 'string' ? shot : shot.base64;
279
+ const binary = atob(b64);
280
+ const encoded = new Uint8Array(binary.length);
281
+ for (let index = 0; index < binary.length; index += 1) {
282
+ encoded[index] = binary.charCodeAt(index);
283
+ }
284
+ const bitmap = await createImageBitmap(new Blob([encoded], { type: 'image/png' }));
285
+ const surface = new OffscreenCanvas(bitmap.width, bitmap.height);
286
+ const context = surface.getContext('2d', { willReadFrequently: true });
287
+ if (context === null) {
288
+ bitmap.close();
289
+ throw new Error('point-shadow browser test: canvas readback context unavailable');
290
+ }
291
+ context.drawImage(bitmap, 0, 0);
292
+ const image = context.getImageData(0, 0, bitmap.width, bitmap.height);
293
+ bitmap.close();
294
+ latest = new Uint8Array(image.data);
295
+ let nonBlack = false;
296
+ for (let index = 0; index < latest.length; index += 4) {
297
+ if ((latest[index] ?? 0) + (latest[index + 1] ?? 0) + (latest[index + 2] ?? 0) > 0) {
298
+ nonBlack = true;
299
+ break;
300
+ }
301
+ }
302
+ if (nonBlack) return latest;
303
+ }
304
+ return latest;
305
+ }
306
+
307
+ function pixelLuma(pixels: Uint8Array): number {
308
+ let sum = 0;
309
+ for (let index = 0; index < pixels.length; index += 4) {
310
+ sum += (pixels[index] ?? 0) + (pixels[index + 1] ?? 0) + (pixels[index + 2] ?? 0);
311
+ }
312
+ return sum / (pixels.length / 4);
313
+ }
314
+
315
+ function pixelDifference(a: Uint8Array, b: Uint8Array): number {
316
+ let difference = 0;
317
+ for (let index = 0; index < Math.min(a.length, b.length); index += 4) {
318
+ difference += Math.abs((a[index] ?? 0) - (b[index] ?? 0));
319
+ difference += Math.abs((a[index + 1] ?? 0) - (b[index + 1] ?? 0));
320
+ difference += Math.abs((a[index + 2] ?? 0) - (b[index + 2] ?? 0));
321
+ }
322
+ return difference;
323
+ }
324
+
325
+ it.skipIf(!browserReady)(
326
+ 'Standard point-shadow scene differs from its no-shadow baseline',
327
+ async () => {
328
+ const canvas = document.createElement('canvas');
329
+ canvas.width = REAL_CANVAS_SIZE;
330
+ canvas.height = REAL_CANVAS_SIZE;
331
+ canvas.style.width = `${REAL_CANVAS_SIZE}px`;
332
+ canvas.style.height = `${REAL_CANVAS_SIZE}px`;
333
+ document.body.appendChild(canvas);
334
+ let renderer: import('@forgeax/engine-render').Renderer | undefined;
335
+ try {
336
+ const host = await constructRuntimeRendererHost(
337
+ canvas,
338
+ {},
339
+ { shaderManifestUrl: '/shaders/manifest.json' },
340
+ );
341
+ if (!host.ok) throw host.error;
342
+ renderer = host.value.renderer;
343
+ const errors: string[] = [];
344
+ const unsubscribe = renderer.subscribe((event) => {
345
+ if (event.kind === 'error') errors.push(event.error.code);
346
+ });
347
+
348
+ const shadowWorld = new World();
349
+ spawnPointShadowScene(shadowWorld, true);
350
+ const shadowAttachment = renderer.attach(shadowWorld);
351
+ expect(shadowAttachment.ok).toBe(true);
352
+ if (!shadowAttachment.ok) throw shadowAttachment.error;
353
+ for (let frame = 0; frame < 6; frame += 1) {
354
+ shadowWorld.update(1 / 60).unwrap();
355
+ const drawn = renderer.draw({
356
+ leases: [shadowAttachment.value],
357
+ camera: { lease: shadowAttachment.value },
358
+ environment: { lease: shadowAttachment.value },
359
+ });
360
+ expect(drawn.ok).toBe(true);
361
+ if (!drawn.ok) throw drawn.error;
362
+ }
363
+ const shadowPixels = await waitForPresentedPixels(canvas);
364
+ const shadowInspection = renderer.inspect();
365
+ expect(shadowInspection.pointShadow?.status).toBe('ready');
366
+ expect(shadowInspection.pointShadow?.requested).toBe(1);
367
+ expect(shadowInspection.pointShadow?.admitted).toBe(1);
368
+ expect(
369
+ shadowInspection.perFramePassNames.filter((name) => name.startsWith('point-shadow-')),
370
+ ).toHaveLength(6);
371
+
372
+ const noShadowWorld = new World();
373
+ spawnPointShadowScene(noShadowWorld, false);
374
+ const noShadowAttachment = renderer.attach(noShadowWorld);
375
+ expect(noShadowAttachment.ok).toBe(true);
376
+ if (!noShadowAttachment.ok) throw noShadowAttachment.error;
377
+ for (let frame = 0; frame < 6; frame += 1) {
378
+ noShadowWorld.update(1 / 60).unwrap();
379
+ const drawn = renderer.draw({
380
+ leases: [noShadowAttachment.value],
381
+ camera: { lease: noShadowAttachment.value },
382
+ environment: { lease: noShadowAttachment.value },
383
+ });
384
+ expect(drawn.ok).toBe(true);
385
+ if (!drawn.ok) throw drawn.error;
386
+ }
387
+ const noShadowPixels = await waitForPresentedPixels(canvas);
388
+ const noShadowInspection = renderer.inspect();
389
+ expect(noShadowInspection.pointShadow?.status).toBe('inactive');
390
+ expect(
391
+ noShadowInspection.perFramePassNames.filter((name) => name.startsWith('point-shadow-')),
392
+ ).toHaveLength(0);
393
+ expect(pixelLuma(shadowPixels)).toBeGreaterThan(0);
394
+ expect(pixelLuma(noShadowPixels)).toBeGreaterThan(0);
395
+ expect(pixelDifference(shadowPixels, noShadowPixels)).toBeGreaterThan(0);
396
+ expect(errors).toEqual([]);
397
+ unsubscribe();
398
+ } finally {
399
+ if (renderer !== undefined) await renderer.dispose();
400
+ canvas.remove();
401
+ }
402
+ },
403
+ );
@@ -22,6 +22,7 @@ import {
22
22
  BYTES_PER_LIGHT_SLOT,
23
23
  LIGHTSLOT_LAYOUT,
24
24
  packLightSlot,
25
+ SPOT_LIGHT_PROJECTOR_FLAG,
25
26
  } from '../../../render/src/light-buffer-layout';
26
27
  import { buildPointShadowMatrices } from '../../../render/src/render-system-extract';
27
28
  import {
@@ -164,8 +165,8 @@ function spawnValidatedPointShadow(
164
165
  expect(r.error).toBeInstanceOf(ShadowInvalidConfigError);
165
166
  const detail = (r.error as unknown as ShadowInvalidConfigError).detail;
166
167
  expect(detail.field).toBe('mapSize');
167
- expect(detail.value).toBe(0);
168
- expect(detail.min).toBe(1);
168
+ expect(detail.actual).toBe(0);
169
+ expect(detail.bound).toEqual({ kind: 'lower-bound', operator: '>=', value: 1 });
169
170
  // .expected and .hint carry actionable strings
170
171
  expect((r.error as unknown as ShadowInvalidConfigError).expected).toContain('mapSize');
171
172
  expect((r.error as unknown as ShadowInvalidConfigError).hint).toContain('mapSize');
@@ -190,8 +191,8 @@ function spawnValidatedPointShadow(
190
191
  expect(r.error).toBeInstanceOf(ShadowInvalidConfigError);
191
192
  const detail = (r.error as unknown as ShadowInvalidConfigError).detail;
192
193
  expect(detail.field).toBe('farPlane');
193
- expect(detail.value).toBe(0.5);
194
- expect(detail.min).toBe(1.0);
194
+ expect(detail.actual).toBe(0.5);
195
+ expect(detail.bound).toEqual({ kind: 'lower-bound', operator: '>=', value: 1.0 });
195
196
  }
196
197
  });
197
198
 
@@ -609,6 +610,62 @@ function spawnValidatedPointShadow(
609
610
  expect(packed[15]).toBeCloseTo(3, 5);
610
611
  });
611
612
 
613
+ it('keeps authored spot shadow intensity and PCF width in the cluster lanes', () => {
614
+ const spotSnap = {
615
+ kind: 'spot' as const,
616
+ entity: 0,
617
+ position: vec3.create(0, 0, 0),
618
+ direction: vec3.create(0, -1, 0),
619
+ color: vec3.create(1, 1, 1),
620
+ intensity: 1,
621
+ invRangeSquared: 0,
622
+ cosInner: 0.95,
623
+ cosOuter: 0.85,
624
+ castShadow: true,
625
+ lightViewProj: undefined,
626
+ mapSize: 2048,
627
+ nearPlane: 0.1,
628
+ farPlane: 50,
629
+ shadowAtlasTile: 2,
630
+ shadowIntensity: 0.35,
631
+ pcfKernelSize: 5,
632
+ };
633
+ const packed = packLightSlot(spotSnap);
634
+ const i32 = new Int32Array(packed.buffer);
635
+ expect(i32[13]).toBe(2);
636
+ expect(packed[14]).toBeCloseTo(0.35, 5);
637
+ expect(packed[15]).toBeCloseTo(5, 5);
638
+ });
639
+
640
+ it('marks the accepted projector without losing a shadow tile', () => {
641
+ const spotSnap = {
642
+ kind: 'spot' as const,
643
+ entity: 7,
644
+ position: vec3.create(0, 2, 0),
645
+ direction: vec3.create(0, -1, 0),
646
+ color: vec3.create(1, 1, 1),
647
+ intensity: 1,
648
+ invRangeSquared: 0.04,
649
+ cosInner: 0.95,
650
+ cosOuter: 0.85,
651
+ castShadow: true,
652
+ lightViewProj: new Float32Array(16),
653
+ mapSize: 512,
654
+ nearPlane: 0.1,
655
+ farPlane: 25,
656
+ shadowAtlasTile: 2,
657
+ projectorAsset: { kind: 'texture' } as never,
658
+ };
659
+ const packed = packLightSlot(spotSnap, undefined, true);
660
+ const encoded = new Int32Array(packed.buffer)[13] ?? 0;
661
+ expect(encoded & SPOT_LIGHT_PROJECTOR_FLAG).toBe(SPOT_LIGHT_PROJECTOR_FLAG);
662
+ expect(encoded & (SPOT_LIGHT_PROJECTOR_FLAG - 1)).toBe(2);
663
+
664
+ const projectorOnly = packLightSlot({ ...spotSnap, shadowAtlasTile: -1 }, undefined, true);
665
+ const projectorOnlyEncoded = new Int32Array(projectorOnly.buffer)[13] ?? 0;
666
+ expect(projectorOnlyEncoded).toBe(SPOT_LIGHT_PROJECTOR_FLAG);
667
+ });
668
+
612
669
  it('AC-05: LIGHTSLOT_LAYOUT byteSize unchanged at 64 (no-regression for standard)', () => {
613
670
  // The pad-lane re-purposing must not change the total LightSlot size:
614
671
  // the std430 vec4 stride is preserved; only the field semantics within
@@ -40,6 +40,8 @@ function exhaustiveSwitchOnRenderCode(code: RenderErrorCode): string {
40
40
  return code;
41
41
  case 'taa-unavailable':
42
42
  return code;
43
+ case 'standard-profile-invalid':
44
+ return code;
43
45
  case 'frame-receipt-stale':
44
46
  return code;
45
47
  case 'renderer-contract-failed':
@@ -64,11 +66,23 @@ function exhaustiveSwitchOnRenderCode(code: RenderErrorCode): string {
64
66
  return code;
65
67
  case 'equirect-projection-failed':
66
68
  return code;
67
- case 'hdrp-light-budget-exceeded':
69
+ case 'standard-light-budget-exceeded':
70
+ return code;
71
+ case 'standard-cluster-index-overflow':
72
+ return code;
73
+ case 'standard-cluster-transport-unavailable':
74
+ return code;
75
+ case 'render-target-descriptor-invalid':
76
+ return code;
77
+ case 'render-target-capability-missing':
68
78
  return code;
69
- case 'hdrp-index-list-overflow':
79
+ case 'render-target-state-invalid':
70
80
  return code;
71
- case 'hdrp-deferred-caps-insufficient':
81
+ case 'render-target-operation-failed':
82
+ return code;
83
+ case 'reflection-probe-budget-exceeded':
84
+ return code;
85
+ case 'render-intent-invalid':
72
86
  return code;
73
87
  case 'point-shadow-atlas-uninitialized':
74
88
  return code;
@@ -186,6 +200,10 @@ function narrowRenderError(err: RenderError): void {
186
200
  void err.detail.operation;
187
201
  void err.detail.causes;
188
202
  break;
203
+ case 'standard-profile-invalid':
204
+ void err.detail.field;
205
+ void err.detail.actual;
206
+ break;
189
207
  case 'frame-receipt-stale':
190
208
  void err.detail.frameId;
191
209
  void err.detail.receiptGeneration;
@@ -210,7 +228,9 @@ function narrowRenderError(err: RenderError): void {
210
228
  break;
211
229
  case 'shadow-invalid-config':
212
230
  void err.detail.field; // string
213
- void err.detail.value; // number
231
+ void err.detail.actual; // number
232
+ void err.detail.bound;
233
+ void err.detail.reason; // string
214
234
  break;
215
235
  case 'environment-source-conflict':
216
236
  void err.detail.owners;
@@ -237,17 +257,51 @@ function narrowRenderError(err: RenderError): void {
237
257
  case 'equirect-projection-failed':
238
258
  void err.detail.handle; // number
239
259
  break;
240
- case 'hdrp-light-budget-exceeded':
260
+ case 'standard-light-budget-exceeded':
241
261
  void err.detail.actual; // number
242
262
  void err.detail.budget; // number
243
263
  break;
244
- case 'hdrp-index-list-overflow':
264
+ case 'standard-cluster-index-overflow':
245
265
  void err.detail.actual; // number
246
266
  void err.detail.capacity; // number
247
267
  break;
248
- case 'hdrp-deferred-caps-insufficient':
249
- void err.detail.actual; // number
250
- void err.detail.expected; // number
268
+ case 'standard-cluster-transport-unavailable':
269
+ void err.detail.requested; // number
270
+ void err.detail.admitted; // 0
271
+ break;
272
+ case 'render-target-descriptor-invalid':
273
+ void err.detail.field;
274
+ void err.detail.value;
275
+ void err.detail.expected;
276
+ break;
277
+ case 'render-target-capability-missing':
278
+ void err.detail.operation;
279
+ void err.detail.requested;
280
+ void err.detail.capability;
281
+ void err.detail.actual;
282
+ break;
283
+ case 'render-target-state-invalid':
284
+ void err.detail.operation;
285
+ void err.detail.reason;
286
+ void err.detail.state;
287
+ void err.detail.generation;
288
+ break;
289
+ case 'render-target-operation-failed':
290
+ void err.detail.operation;
291
+ void err.detail.stage;
292
+ void err.detail.generation;
293
+ void err.detail.cause;
294
+ void err.detail.recovery;
295
+ break;
296
+ case 'reflection-probe-budget-exceeded':
297
+ void err.detail.actual;
298
+ void err.detail.budget;
299
+ break;
300
+ case 'render-intent-invalid':
301
+ void err.detail.component;
302
+ void err.detail.field;
303
+ void err.detail.value;
304
+ void err.detail.allowed;
251
305
  break;
252
306
  case 'point-shadow-atlas-uninitialized':
253
307
  // No detail on this class.
@@ -436,10 +436,11 @@ function drawPublished(renderer: RendererType, world: WorldType) {
436
436
 
437
437
  // DirectionalLight with merged shadow fields.
438
438
  expect(errors).toHaveLength(0);
439
- // The current typed graph owns one shadow fallback, six cube fallback
440
- // faces, and the frame attachment pass.
441
- expect(log.beginRenderPassCount).toBe(8);
442
- expect(log.setPipelineCount).toBe(1);
439
+ // Standard Cluster owns the shadow, G-buffer, lighting, forward, and
440
+ // output topology stages. Keep this structural rather than freezing a
441
+ // pass-count literal that changes when a named stage is split.
442
+ expect(log.beginRenderPassCount).toBeGreaterThan(log.queueSubmitCount);
443
+ expect(log.setPipelineCount).toBeGreaterThan(0);
443
444
  expect(log.drawIndexedCount).toBe(1);
444
445
  expect(log.encoderFinishCount).toBe(1); // one typed graph command encoder
445
446
  expect(log.queueSubmitCount).toBe(1); // one submission for the frame graph
@@ -592,9 +593,13 @@ function drawPublished(renderer: RendererType, world: WorldType) {
592
593
 
593
594
  // Soft path: no error fired (D-Q7 mirroring; LO §1.1 minimum semantic).
594
595
  expect(errors).toHaveLength(0);
595
- // Empty geometry does not remove graph-owned shadow and output passes.
596
+ // Empty geometry does not remove graph-owned attachment and output passes.
597
+ // With no DirectionalLight, exact-zero topology omits the directional
598
+ // shadow target and pass while retaining the spot/point attachments.
599
+ // The graph-owned final output transform is recorded after the frame
600
+ // attachment pass even when the scene has no geometry.
596
601
  expect(log.beginRenderPassCount).toBe(12);
597
- expect(log.queueSubmitCount).toBe(8); // shadow fallback + 6 cube faces + frame
602
+ expect(log.queueSubmitCount).toBe(8); // spot + 6 cube faces + frame
598
603
  // No geometry submitted.
599
604
  expect(log.drawIndexedCount).toBe(0);
600
605
  });
@@ -703,7 +708,7 @@ function drawPublished(renderer: RendererType, world: WorldType) {
703
708
  Object.assign(log, makeLog());
704
709
  drawPublished(renderer, world as WorldType);
705
710
 
706
- expect(errors.some((e) => e.code === 'render-system-multi-camera')).toBe(true);
711
+ expect(errors.some((e) => e.code === 'render-system-multi-camera')).toBe(false);
707
712
  // First archetype hit still rendered.
708
713
  expect(log.drawIndexedCount).toBeGreaterThanOrEqual(1);
709
714
  });
@@ -822,45 +827,28 @@ function drawPublished(renderer: RendererType, world: WorldType) {
822
827
  // registration, the clear pass still runs (so visual debugging shows
823
828
  // the cleared canvas rather than a stale frame).
824
829
  // The invalid entity is skipped, while graph-owned attachment passes are
825
- // still recorded after the seven boot fallback clears.
830
+ // still recorded after the spot and six cube-face attachment passes.
831
+ // The graph-owned final output transform still runs after an invalid
832
+ // renderable is skipped, preserving a visible cleared frame.
826
833
  expect(log.beginRenderPassCount).toBe(12);
827
- expect(log.queueSubmitCount).toBe(8); // shadow fallback + 6 cube faces + frame
834
+ expect(log.queueSubmitCount).toBe(8); // spot + 6 cube faces + frame
828
835
  });
829
836
 
830
837
  it('internal exception fires webgpu-runtime-error with .detail = { error: string } and skips frame', async () => {
831
- // Inject a failure path: queue.writeBuffer throws during the Prepare
832
- // stage so RenderSystem's try/catch surfaces a webgpu-runtime-error.
838
+ // Inject a raw command-encoder failure only after renderer construction
839
+ // has completed. RHI device creation intentionally leaves this raw
840
+ // boundary synchronous, so RenderSystem's outer try/catch must surface
841
+ // the failure as webgpu-runtime-error instead of a ready-time failure.
833
842
  const log = makeLog();
834
843
  const { device } = makeMockGPUDevice(log);
835
- const baseQueue = (device as { queue: { writeBuffer: (...args: unknown[]) => void } }).queue;
836
- const originalWrite = baseQueue.writeBuffer;
837
- let writeCallCount = 0;
838
- baseQueue.writeBuffer = (...args: unknown[]) => {
839
- writeCallCount++;
840
- // Step 3 (ready) hits this path 13 times: 10 for builtin meshes
841
- // (HANDLE_CUBE + HANDLE_TRIANGLE + HANDLE_QUAD + HANDLE_SPHERE +
842
- // HANDLE_NINESLICE_QUAD = 5 vbo + 5 ibo) + 1 for the identity-
843
- // instance fallback storage buffer seed (feat-20260513-instanced-
844
- // mesh M3 T-M3-2) + 1 for the skin-palette-identity seed
845
- // (feat-20260611 R2 / M8 / w28 IS-14, 255 identity mat4 = 16320 B)
846
- // + 1 for the built-in tonemap params UBO defaultValue seed at register
847
- // (feat-20260621 M-A3 / D-5: postProcess.register('forgeax::tonemap')
848
- // eager-writes its 16 B defaultValue during ready).
849
- // feat-20260625-spot-light-shadow-mapping w25 (scope-amend webkit-
850
- // fallback): the boot-time spot lightViewProj UBO 256 B zero-init seed
851
- // is GONE — the spot matrices folded into the View UBO tail (no standalone
852
- // buffer to zero-init), so the boot writeBuffer count dropped 14 -> 13.
853
- // HANDLE_QUAD joined the builtin upload loop in
854
- // feat-20260520-2d-sprite-layer-mvp post-merge fix; HANDLE_SPHERE
855
- // (id=4) joined in feat-20260529-fxaa-runtime-toggle;
856
- // HANDLE_NINESLICE_QUAD (id=5) joined in feat-20260527-sprite-
857
- // nineslice M2 / w12.
858
- // Only fail after ready completes (call 14+) so the failure
859
- // surfaces in RenderSystem rather than the ready Promise.
860
- if (writeCallCount > 13) {
861
- throw new Error('mock: writeBuffer NaN payload');
862
- }
863
- return originalWrite.call(baseQueue, ...args);
844
+ const raw = device as {
845
+ createCommandEncoder: (...args: unknown[]) => unknown;
846
+ };
847
+ const originalCreateCommandEncoder = raw.createCommandEncoder;
848
+ let failRecord = false;
849
+ raw.createCommandEncoder = (...args: unknown[]) => {
850
+ if (failRecord) throw new Error('mock: createCommandEncoder failure');
851
+ return originalCreateCommandEncoder(...args);
864
852
  };
865
853
  vi.stubGlobal('navigator', { ...baseNavigator, gpu: makeMockGPU(device) });
866
854
  const engine = await importEngine();
@@ -868,6 +856,7 @@ function drawPublished(renderer: RendererType, world: WorldType) {
868
856
  const renderer = (
869
857
  await engine.createRenderer(canvas, {}, { shaderManifestUrl: buildManifestDataUrl() })
870
858
  ).unwrap();
859
+ failRecord = true;
871
860
 
872
861
  const { World } = await importEcs();
873
862
  const C = await importComponents();
@@ -907,7 +896,7 @@ function drawPublished(renderer: RendererType, world: WorldType) {
907
896
  | undefined;
908
897
  expect(runtimeDetail?.error).toBeDefined();
909
898
  expect(typeof runtimeDetail?.error?.message).toBe('string');
910
- expect(runtimeDetail?.error?.message).toContain('writeBuffer');
899
+ expect(runtimeDetail?.error?.message).toContain('createCommandEncoder');
911
900
  });
912
901
  });
913
902
 
@@ -116,6 +116,7 @@ function makeMockGPUDevice(spies: DeviceSpies, maxTextureDimension2D = 8192): {
116
116
  limits: { maxTextureDimension2D },
117
117
  queue: {
118
118
  submit: () => undefined,
119
+ onSubmittedWorkDone: async () => undefined,
119
120
  writeBuffer: () => undefined,
120
121
  writeTexture: () => undefined,
121
122
  },
@@ -486,7 +487,9 @@ describe('record: per-submesh PBR material BG textureView (bug-20260610 D2 regre
486
487
  const { renderer } = await setupRenderer(spies);
487
488
  const errors: string[] = [];
488
489
  renderer.subscribe((event) => {
489
- if (event.kind === 'error') errors.push(event.error.code);
490
+ if (event.kind === 'error') {
491
+ errors.push(event.error.code);
492
+ }
490
493
  });
491
494
 
492
495
  const world = await spawnPbrMultiMaterialScene();