@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
@@ -13,8 +13,74 @@
13
13
 
14
14
  import { createRenderer, EngineEnvironmentError } from '@forgeax/engine-runtime';
15
15
  import { describe, expect, it } from 'vitest';
16
+ import { projectDirectionalShadowInspection } from '../../../render/src/assembly/directional-shadow-inspection';
17
+ import { resolveDirectionalShadowBackendAdmission } from '../../../render/src/render-pipeline';
16
18
 
17
19
  describe('createRenderer without navigator.gpu (browser-no-webgpu)', () => {
20
+ it('requires the shared backend admission truth table for WebGL2 fallback and capable failure', async () => {
21
+ const module = (await import('../../../render/src/render-pipeline')) as Record<string, unknown>;
22
+ const resolver = module.resolveDirectionalShadowBackendAdmission;
23
+ expect(typeof resolver).toBe('function');
24
+ const resolve = resolver as (input: {
25
+ backendKind: 'webgpu' | 'wgpu-webgl2';
26
+ requested: 'pcssMedium' | 'pcssHigh';
27
+ candidate: 'accepted' | 'failed';
28
+ lastKnownGood?: 'pcf3' | 'pcf5';
29
+ }) => {
30
+ effective: string;
31
+ status: string;
32
+ fallbackReason?: string;
33
+ };
34
+ expect(
35
+ resolve({ backendKind: 'wgpu-webgl2', requested: 'pcssMedium', candidate: 'accepted' }),
36
+ ).toMatchObject({
37
+ effective: 'pcf3',
38
+ status: 'fallback',
39
+ fallbackReason: 'webgl2-unsupported',
40
+ });
41
+ expect(
42
+ resolve({
43
+ backendKind: 'webgpu',
44
+ requested: 'pcssHigh',
45
+ candidate: 'failed',
46
+ lastKnownGood: 'pcf3',
47
+ }),
48
+ ).toMatchObject({ effective: 'pcf3', status: 'rejected', fallbackReason: 'candidate-failed' });
49
+ });
50
+
51
+ it('keeps capable candidate failure explicit while retaining the LKG inspection facts', () => {
52
+ const inspection = projectDirectionalShadowInspection({
53
+ admission: resolveDirectionalShadowBackendAdmission({
54
+ backendKind: 'webgpu',
55
+ requested: 'pcssHigh',
56
+ candidate: 'failed',
57
+ lastKnownGood: 'pcf3',
58
+ }),
59
+ cascadeCount: 4,
60
+ mapSize: 2048,
61
+ atlasBytes: 67_108_864,
62
+ writerPasses: 4,
63
+ blockerTaps: 0,
64
+ filterTapUpperBound: 9,
65
+ seamTapUpperBound: 9,
66
+ deviceGeneration: 2,
67
+ graphGeneration: 6,
68
+ error: {
69
+ code: 'shadow-candidate-failed',
70
+ expected: 'the capable backend candidate compiles and validates',
71
+ hint: 'fix the authoring input, then retry or recover',
72
+ },
73
+ });
74
+ expect(inspection).toMatchObject({
75
+ effective: 'pcf3',
76
+ status: 'rejected',
77
+ fallbackReason: 'candidate-failed',
78
+ lastKnownGood: true,
79
+ graphGeneration: 6,
80
+ error: { hint: expect.stringContaining('retry') },
81
+ });
82
+ });
83
+
18
84
  it('navigator.gpu is absent or non-functional in this browser environment', async () => {
19
85
  const nav = globalThis.navigator as { gpu?: GPU };
20
86
  if (nav.gpu === undefined) return;
@@ -62,6 +62,12 @@ const BUFFER_USAGE_MAP_READ = 0x0001;
62
62
  const BUFFER_USAGE_COPY_DST = 0x0008;
63
63
  const MAP_MODE_READ = 0x0001;
64
64
 
65
+ // Windows Dawn first-touch construction for the two renderer/readback probes
66
+ // can exceed Vitest's project-wide 30-second timeout while still completing
67
+ // well inside the nightly platform budget. Keep this owner-specific gate
68
+ // bounded so a valid cold-start probe is not retried as a false failure.
69
+ const SPRITE_NINESLICE_DAWN_TEST_TIMEOUT_MS = 120_000;
70
+
65
71
  // Distinctive 4x4 texture: corners RED (cells (0,0)/(3,0)/(0,3)/(3,3)),
66
72
  // remaining cells BLUE. With slices=[0.25,0.25,0.25,0.25] in atlas-uv space
67
73
  // the 4 corners line up with the 4 corner texels -- making the central band
@@ -89,11 +95,10 @@ function makeCornerCheckerTexture(): TextureAsset {
89
95
  }
90
96
  return {
91
97
  kind: 'texture',
92
- width: side,
93
- height: side,
98
+ shape: { viewDimension: '2d', extent: { width: side, height: side } },
94
99
  format: 'rgba8unorm-srgb',
95
100
  colorSpace: 'srgb',
96
- mipmap: false,
101
+ mips: { kind: 'none' },
97
102
  data: bytes,
98
103
  };
99
104
  }
@@ -326,7 +331,9 @@ function midBandDistance(a: CapturedFrame, b: CapturedFrame): number {
326
331
  }
327
332
 
328
333
  describe('feat-20260527-sprite-nineslice w14 dawn smoke (stretch vs tile divergence)', () => {
329
- it('stretch + tile sprite frames diverge in the mid-band after w15 (red until w15 lands)', async () => {
334
+ it('stretch + tile sprite frames diverge in the mid-band after w15 (red until w15 lands)', {
335
+ timeout: SPRITE_NINESLICE_DAWN_TEST_TIMEOUT_MS,
336
+ }, async () => {
330
337
  // Asymmetric scale: x=4 vs y=1. With slices=[0.25,0.25,0.25,0.25] and the
331
338
  // 4x4 corner-checker texture, the post-w15 9-slice path keeps corner
332
339
  // zones at fixed world dimensions while the legacy pre-w15 path stretches
@@ -55,6 +55,17 @@ const MAP_MODE_READ = 0x0001;
55
55
  const INSTANCE_COUNT = 5;
56
56
  const SPACING = 2.5;
57
57
 
58
+ const SHADOW_FILTER = (() => {
59
+ const value = (
60
+ globalThis as unknown as { process?: { env?: Record<string, string | undefined> } }
61
+ ).process?.env?.FORGEAX_SHADOW_FILTER;
62
+ if (value === 'pcssMedium') return 4;
63
+ if (value === 'pcssHigh') return 5;
64
+ if (value === 'pcf1') return 1;
65
+ if (value === 'pcf5') return 3;
66
+ return 2;
67
+ })();
68
+
58
69
  function buildTranslationGrid(): Float32Array {
59
70
  const out = new Float32Array(INSTANCE_COUNT * 16);
60
71
  const half = ((INSTANCE_COUNT - 1) * SPACING) / 2;
@@ -263,7 +274,9 @@ describe('w10 -- shadow instanced dawn smoke (AC-05 behavioral)', () => {
263
274
  depthBias: 0.005,
264
275
  normalBias: 0.05,
265
276
  shadowDistance: 50,
266
- pcfKernelSize: 3,
277
+ shadowFilter: SHADOW_FILTER,
278
+ shadowAngularRadius: 0.00465,
279
+ maxPenumbraTexels: 32,
267
280
  },
268
281
  });
269
282
 
@@ -285,7 +298,7 @@ describe('w10 -- shadow instanced dawn smoke (AC-05 behavioral)', () => {
285
298
  { component: Instances, data: { transforms: instanceTransforms } },
286
299
  );
287
300
 
288
- // Render 300 frames for temporal stability (PCF shadow, light accumulation)
301
+ // Render 300 frames for temporal stability (PCF or PCSS shadow, light accumulation)
289
302
  const attachment = renderer.attach(world);
290
303
  expect(attachment.ok).toBe(true);
291
304
  if (!attachment.ok) throw attachment.error;
@@ -99,12 +99,11 @@ function textureBytes(color: readonly [number, number, number, number]): Uint8Ar
99
99
  function makeTexture(color: readonly [number, number, number, number]): TextureAsset {
100
100
  return {
101
101
  kind: 'texture',
102
- width: 1,
103
- height: 1,
102
+ shape: { viewDimension: '2d', extent: { width: 1, height: 1 } },
104
103
  format: 'rgba8unorm-srgb',
105
104
  data: textureBytes(color),
106
105
  colorSpace: 'srgb',
107
- mipmap: false,
106
+ mips: { kind: 'none' },
108
107
  };
109
108
  }
110
109
 
@@ -42,12 +42,11 @@ function makeTexture(
42
42
  ): TextureAsset {
43
43
  return {
44
44
  kind: 'texture',
45
- width,
46
- height,
45
+ shape: { viewDimension: '2d', extent: { width, height } },
47
46
  format,
48
47
  data: new Uint8Array(width * height * 4),
49
48
  colorSpace,
50
- mipmap,
49
+ mips: mipmap ? { kind: 'generate' } : { kind: 'none' },
51
50
  };
52
51
  }
53
52
 
@@ -60,12 +60,11 @@ function makeChequerTexture(primaryR: number, primaryG: number, primaryB: number
60
60
  }
61
61
  return {
62
62
  kind: 'texture',
63
- width: side,
64
- height: side,
63
+ shape: { viewDimension: '2d', extent: { width: side, height: side } },
65
64
  format: 'rgba8unorm',
66
65
  data: bytes,
67
66
  colorSpace: 'srgb',
68
- mipmap: false,
67
+ mips: { kind: 'none' },
69
68
  };
70
69
  }
71
70
 
@@ -126,11 +126,10 @@ describe('feat-20260527-sprite-nineslice w13 dawn smoke (HANDLE_NINESLICE_QUAD b
126
126
  }
127
127
  const texAsset: TextureAsset = {
128
128
  kind: 'texture',
129
- width: 4,
130
- height: 4,
129
+ shape: { viewDimension: '2d', extent: { width: 4, height: 4 } },
131
130
  format: 'rgba8unorm-srgb',
132
131
  colorSpace: 'srgb',
133
- mipmap: false,
132
+ mips: { kind: 'none' },
134
133
  data: pixels,
135
134
  };
136
135
  const texHandle = world.allocSharedRef<'TextureAsset', TextureAsset>('TextureAsset', texAsset);
@@ -72,12 +72,11 @@ function makeTexture(
72
72
  ): TextureAsset {
73
73
  return {
74
74
  kind: 'texture',
75
- width,
76
- height,
75
+ shape: { viewDimension: '2d', extent: { width, height } },
77
76
  format,
78
77
  data: new Uint8Array(width * height * 4),
79
78
  colorSpace,
80
- mipmap,
79
+ mips: mipmap ? { kind: 'generate' } : { kind: 'none' },
81
80
  };
82
81
  }
83
82
 
@@ -182,12 +181,11 @@ describe('AC-04: uploadTexture non-256-aligned width (real GPU path)', () => {
182
181
 
183
182
  const tex: TextureAsset = {
184
183
  kind: 'texture',
185
- width,
186
- height,
184
+ shape: { viewDimension: '2d', extent: { width, height } },
187
185
  format: 'rgba8unorm',
188
186
  data: buf,
189
187
  colorSpace: 'linear',
190
- mipmap: false,
188
+ mips: { kind: 'none' },
191
189
  };
192
190
 
193
191
  const handle = world.allocSharedRef('TextureAsset', tex);
@@ -87,6 +87,17 @@ const builtinSchemas: ReadonlyArray<{ id: string; schema: readonly ParamSchemaEn
87
87
  ];
88
88
 
89
89
  describe('derive(schema) integration over 5 built-in shader families (M3 w11)', () => {
90
+ it('derives array and volume texture schemas without a second binding path', () => {
91
+ for (const schema of [
92
+ [{ name: 'layers', type: 'texture2d_array' as const }],
93
+ [{ name: 'volume', type: 'texture3d' as const }],
94
+ ]) {
95
+ const out = derive(schema);
96
+ expect(out.bglEntries.map((entry) => entry.binding)).toEqual([0, 1, 2]);
97
+ expect(out.resourceBindings[1]?.name).toBe(schema[0]?.name);
98
+ expect(out.textureFieldNames).toContain(schema[0]?.name);
99
+ }
100
+ });
90
101
  it('(a) every built-in shader derives a well-formed BGL with consistent userRegionBindingEnd', () => {
91
102
  for (const { id, schema } of builtinSchemas) {
92
103
  const out = derive(schema);
@@ -145,12 +145,14 @@ describe.skipIf(!hasGpu)('equirect renderable-source integration (feat-20260707
145
145
  // POD mirrors that 2D surface.
146
146
  const source = {
147
147
  kind: 'texture' as const,
148
- width: asset.width,
149
- height: asset.height,
148
+ shape: { viewDimension: '2d' as const, extent: { width: asset.width, height: asset.height } },
150
149
  format: asset.format,
151
- data: asset.data,
150
+ data:
151
+ asset.data instanceof Uint8ClampedArray
152
+ ? new Uint8Array(asset.data.buffer, asset.data.byteOffset, asset.data.byteLength)
153
+ : asset.data,
152
154
  colorSpace: asset.colorSpace,
153
- mipmap: false,
155
+ mips: { kind: 'none' as const },
154
156
  };
155
157
  const res = deriveRenderDataCubemap(source);
156
158
  expect(res.ok).toBe(true);
@@ -349,7 +349,7 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
349
349
  });
350
350
  });
351
351
 
352
- describe('t2 - AC-15 ImageErrorCode exhaustive switch (new member)', () => {
352
+ describe('t2 - AC-15 ImageErrorCode exhaustive switch (new members)', () => {
353
353
  it('ImageErrorCode includes image-hdr-decode-failed', () => {
354
354
  const code: ImageErrorCode = 'image-hdr-decode-failed';
355
355
  expect(code).toBe('image-hdr-decode-failed');
@@ -378,10 +378,17 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
378
378
  return 'atlas size exceeded';
379
379
  case 'atlas-region-mismatch':
380
380
  return 'atlas region mismatch';
381
+ // feat-20260829-deep-agent-game-feedback-resolution M5 —
382
+ // PixelSurface authoring errors remain part of the closed image
383
+ // error vocabulary and must stay covered by every exhaustive
384
+ // consumer switch.
385
+ case 'image-surface-invalid':
386
+ return 'surface invalid';
381
387
  }
382
388
  }
383
389
  expect(exhaustive('image-decode-failed')).toBe('decode failed');
384
390
  expect(exhaustive('image-hdr-decode-failed')).toBe('hdr decode failed');
391
+ expect(exhaustive('image-surface-invalid')).toBe('surface invalid');
385
392
  });
386
393
  });
387
394
  });
@@ -420,6 +427,8 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
420
427
  return 'cleanup failed';
421
428
  case 'scene-data-unavailable':
422
429
  return 'scene data unavailable';
430
+ case 'standard-profile-invalid':
431
+ return 'standard profile invalid';
423
432
  case 'frame-receipt-stale':
424
433
  return 'frame receipt stale';
425
434
  case 'renderer-contract-failed':
@@ -464,14 +473,24 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
464
473
  return 'mesh ssbo capacity exceeded';
465
474
  case 'mesh-ssbo-ceiling-reached':
466
475
  return 'mesh ssbo ceiling reached';
467
- case 'hdrp-light-budget-exceeded':
468
- return 'hdrp light budget exceeded';
469
- case 'hdrp-index-list-overflow':
470
- return 'hdrp index list overflow';
471
- // feat-20260612-hdrp-deferred-shading-learn-render-5-8 M1 / w6:
472
- // 3 new deferred-path error codes.
473
- case 'hdrp-deferred-caps-insufficient':
474
- return 'hdrp deferred caps insufficient';
476
+ case 'render-target-descriptor-invalid':
477
+ return 'render target descriptor invalid';
478
+ case 'render-target-capability-missing':
479
+ return 'render target capability missing';
480
+ case 'render-target-state-invalid':
481
+ return 'render target state invalid';
482
+ case 'render-target-operation-failed':
483
+ return 'render target operation failed';
484
+ case 'reflection-probe-budget-exceeded':
485
+ return 'reflection probe budget exceeded';
486
+ case 'render-intent-invalid':
487
+ return 'render intent invalid';
488
+ case 'standard-light-budget-exceeded':
489
+ return 'standard light budget exceeded';
490
+ case 'standard-cluster-index-overflow':
491
+ return 'standard cluster index overflow';
492
+ case 'standard-cluster-transport-unavailable':
493
+ return 'standard cluster transport unavailable';
475
494
  // feat-20260611-fox-skinning-vertex-attribute-chain M4 / w17 (D-5):
476
495
  // bidirectional Skin <-> pbr-skin material mismatch detected at extract.
477
496
  case 'skin-material-mismatch':
@@ -530,6 +549,16 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
530
549
  return 'points lines prepare failed';
531
550
  case 'transmission-capability-missing':
532
551
  return 'transmission capability missing';
552
+ case 'projector-binding-failed':
553
+ return 'projector binding failed';
554
+ case 'volume-owner-conflict':
555
+ return 'volume owner conflict';
556
+ case 'volume-density-shape-mismatch':
557
+ return 'volume density shape mismatch';
558
+ case 'volume-invalid-bounds':
559
+ return 'volume invalid bounds';
560
+ case 'volume-invalid-parameters':
561
+ return 'volume invalid parameters';
533
562
  }
534
563
  }
535
564
  expect(exhaustive('equirect-projection-failed')).toBe('equirect projection failed');
@@ -541,16 +570,15 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
541
570
  });
542
571
 
543
572
  /*
544
- * feat-20260612-hdrp-deferred-shading-learn-render-5-8 M1 / w3:
545
- * RuntimeErrorCode +3 members — exhaustive narrow test.
573
+ * Standard lighting closed error codes — exhaustive narrow test.
546
574
  *
547
- * AC-07: three new error codes must be members of RuntimeErrorCode closed union.
575
+ * AC-07: three Standard lighting codes are members of the closed render union.
548
576
  * Each error code narrows without a default branch in switch (err.code).
549
577
  */
550
- describe('RuntimeErrorCode +3 new members (w3)', () => {
551
- it('hdrp-deferred-caps-insufficient is a valid RuntimeErrorCode', () => {
552
- const code: RuntimeLayerErrorCode = 'hdrp-deferred-caps-insufficient';
553
- expect(code).toBe('hdrp-deferred-caps-insufficient');
578
+ describe('Standard lighting error members', () => {
579
+ it('standard-cluster-transport-unavailable is a valid RenderErrorCode', () => {
580
+ const code: RuntimeLayerErrorCode = 'standard-cluster-transport-unavailable';
581
+ expect(code).toBe('standard-cluster-transport-unavailable');
554
582
  });
555
583
 
556
584
  it('exhaustive switch covers all 3 new members alongside existing members', () => {
@@ -581,6 +609,7 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
581
609
  case 'cleanup-failed':
582
610
  return 'ok';
583
611
  case 'scene-data-unavailable':
612
+ case 'standard-profile-invalid':
584
613
  return 'ok';
585
614
  case 'frame-receipt-stale':
586
615
  return 'ok';
@@ -626,9 +655,9 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
626
655
  return 'ok';
627
656
  case 'mesh-ssbo-ceiling-reached':
628
657
  return 'ok';
629
- case 'hdrp-light-budget-exceeded':
658
+ case 'standard-light-budget-exceeded':
630
659
  return 'ok';
631
- case 'hdrp-index-list-overflow':
660
+ case 'standard-cluster-index-overflow':
632
661
  return 'ok';
633
662
  case 'skin-material-mismatch':
634
663
  return 'ok';
@@ -640,7 +669,19 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
640
669
  return 'ok';
641
670
  case 'joint-entity-dangling':
642
671
  return 'ok';
643
- case 'hdrp-deferred-caps-insufficient':
672
+ case 'standard-cluster-transport-unavailable':
673
+ return 'ok';
674
+ case 'render-target-descriptor-invalid':
675
+ return 'ok';
676
+ case 'render-target-capability-missing':
677
+ return 'ok';
678
+ case 'render-target-state-invalid':
679
+ return 'ok';
680
+ case 'render-target-operation-failed':
681
+ return 'ok';
682
+ case 'reflection-probe-budget-exceeded':
683
+ return 'ok';
684
+ case 'render-intent-invalid':
644
685
  return 'ok';
645
686
  case 'point-shadow-atlas-uninitialized':
646
687
  return 'ok';
@@ -680,9 +721,19 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
680
721
  return 'ok';
681
722
  case 'transmission-capability-missing':
682
723
  return 'ok';
724
+ case 'projector-binding-failed':
725
+ return 'ok';
726
+ case 'volume-owner-conflict':
727
+ return 'ok';
728
+ case 'volume-density-shape-mismatch':
729
+ return 'ok';
730
+ case 'volume-invalid-bounds':
731
+ return 'ok';
732
+ case 'volume-invalid-parameters':
733
+ return 'ok';
683
734
  }
684
735
  }
685
- expect(exhaustive('hdrp-deferred-caps-insufficient')).toBe('ok');
736
+ expect(exhaustive('standard-cluster-transport-unavailable')).toBe('ok');
686
737
  expect(exhaustive('point-shadow-atlas-uninitialized')).toBe('ok');
687
738
  expect(exhaustive('point-shadow-atlas-bounds-violation')).toBe('ok');
688
739
  });
@@ -171,52 +171,26 @@ function simulateMerge(
171
171
  }
172
172
  directionalCount += f.lights.directionalCount;
173
173
  }
174
+ const firstDirectionalLights = frames.find(
175
+ (frame) => frame.lights.directional !== undefined,
176
+ )?.lights;
174
177
  const lights: ExtractedLights = {
175
178
  directional,
176
179
  directionalCount,
177
180
  point,
178
181
  spot,
179
- lightViewProj:
180
- directional !== undefined
181
- ? frames.find((f) => f.lights.directional !== undefined)?.lights.lightViewProj
182
- : undefined,
183
- splitPlanes:
184
- directional !== undefined
185
- ? frames.find((f) => f.lights.directional !== undefined)?.lights.splitPlanes
186
- : undefined,
187
- cascadeCount:
188
- directional !== undefined
189
- ? frames.find((f) => f.lights.directional !== undefined)?.lights.cascadeCount
190
- : undefined,
191
- cascadeBlend:
192
- directional !== undefined
193
- ? frames.find((f) => f.lights.directional !== undefined)?.lights.cascadeBlend
194
- : undefined,
195
- shadowMapSize:
196
- directional !== undefined
197
- ? frames.find((f) => f.lights.directional !== undefined)?.lights.shadowMapSize
198
- : undefined,
199
- depthBias:
200
- directional !== undefined
201
- ? frames.find((f) => f.lights.directional !== undefined)?.lights.depthBias
202
- : undefined,
203
- normalBias:
204
- directional !== undefined
205
- ? frames.find((f) => f.lights.directional !== undefined)?.lights.normalBias
206
- : undefined,
207
- pcfKernelSize:
208
- directional !== undefined
209
- ? frames.find((f) => f.lights.directional !== undefined)?.lights.pcfKernelSize
210
- : undefined,
182
+ lightViewProj: firstDirectionalLights?.lightViewProj,
183
+ splitPlanes: firstDirectionalLights?.splitPlanes,
184
+ cascadeCount: firstDirectionalLights?.cascadeCount,
185
+ cascadeBlend: firstDirectionalLights?.cascadeBlend,
186
+ shadowMapSize: firstDirectionalLights?.shadowMapSize,
187
+ depthBias: firstDirectionalLights?.depthBias,
188
+ normalBias: firstDirectionalLights?.normalBias,
189
+ directionalShadowQuality: firstDirectionalLights?.directionalShadowQuality,
190
+ directionalShadowError: firstDirectionalLights?.directionalShadowError,
211
191
  pointShadow: frames.flatMap((f) => f.lights.pointShadow),
212
- directionalCsmConfig:
213
- directional !== undefined
214
- ? frames.find((f) => f.lights.directional !== undefined)?.lights.directionalCsmConfig
215
- : undefined,
216
- directionalCsmDirection:
217
- directional !== undefined
218
- ? frames.find((f) => f.lights.directional !== undefined)?.lights.directionalCsmDirection
219
- : undefined,
192
+ directionalCsmConfig: firstDirectionalLights?.directionalCsmConfig,
193
+ directionalCsmDirection: firstDirectionalLights?.directionalCsmDirection,
220
194
  };
221
195
 
222
196
  // AC-05: singletons from owner world only
@@ -433,6 +407,36 @@ describe('extractFrames merge semantics (m2-t1, AC-04/05/06)', () => {
433
407
  expect(frameB.lights.directionalCount).toBe(1);
434
408
  });
435
409
 
410
+ it('AC-04: first directional owner error is preserved across the merge', () => {
411
+ const worldA = new World();
412
+ worldA
413
+ .spawn(
414
+ { component: Transform, data: identityTransform() },
415
+ {
416
+ component: DirectionalLight,
417
+ data: {
418
+ direction: [0, -1, 0],
419
+ shadowFilter: 4,
420
+ shadowAngularRadius: 999,
421
+ maxPenumbraTexels: 32,
422
+ },
423
+ },
424
+ )
425
+ .unwrap();
426
+
427
+ const worldB = makeWorldWithDirectionalLight();
428
+ const frameA = extractFrame(worldA, prepareExtractContext(worldA));
429
+ const frameB = extractFrame(worldB, prepareExtractContext(worldB));
430
+
431
+ expect(frameA.lights.directionalShadowError?.code).toBe('shadow-invalid-config');
432
+ expect(frameB.lights.directionalShadowError).toBeUndefined();
433
+ const merged = simulateMerge([frameA, frameB], 0);
434
+ expect(merged.lights.directionalShadowError?.detail).toMatchObject({
435
+ field: 'shadowAngularRadius',
436
+ actual: 999,
437
+ });
438
+ });
439
+
436
440
  // ── AC-05: singleton resources from owner only ─────────────────────────────
437
441
 
438
442
  it('AC-05: skylight is taken from owner world only, non-owner ignored', () => {
@@ -624,6 +628,9 @@ describe('extractFrames cross-world directional CSM (bug-20260710)', () => {
624
628
  }
625
629
  // splitPlanes reach the component shadowDistance on the last cascade.
626
630
  expect(frame.lights.splitPlanes).toBeDefined();
631
+ expect(frame.lights.splitPlanes).toHaveLength(16);
632
+ expect(frame.lights.splitPlanes?.[1]).toBeGreaterThan(0);
633
+ expect(frame.lights.splitPlanes?.[2]).toBeGreaterThan(0);
627
634
  });
628
635
 
629
636
  it('single world (camera + light together) still yields non-zero lightViewProj', () => {
@@ -652,5 +659,6 @@ describe('extractFrames cross-world directional CSM (bug-20260710)', () => {
652
659
  for (let i = 0; i < (frame.lights.cascadeCount ?? 0); i++) {
653
660
  expect(isNonZeroMat(lvp?.[i])).toBe(true);
654
661
  }
662
+ expect(frame.lights.splitPlanes).toHaveLength(16);
655
663
  });
656
664
  });
@@ -276,7 +276,7 @@ describe('render-system-extract Skin / pbr-skin mismatch (AC-07 / w19)', () => {
276
276
  expect(frame.renderables).toHaveLength(0);
277
277
  });
278
278
 
279
- it('(c) Skin + pbr-skin material + skinned mesh -> no error routed', () => {
279
+ it('(c) Skin + pbr-skin material without a palette allocator -> renderable and dispatch skipped', () => {
280
280
  const world = new World();
281
281
  const assets = makeAssetRegistry();
282
282
  const meshHandle = registerSkinnedMesh(world);
@@ -288,9 +288,13 @@ describe('render-system-extract Skin / pbr-skin mismatch (AC-07 / w19)', () => {
288
288
 
289
289
  const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
290
290
 
291
- extractFrame(world, prepareExtractContext(world, { assets }));
291
+ const frame = extractFrame(world, prepareExtractContext(world, { assets }));
292
292
 
293
293
  expect(errorSpy).not.toHaveBeenCalled();
294
+ expect(frame.renderables).toHaveLength(0);
295
+ expect(frame.dispatch.some((entry) => entry.materialShaderId === 'forgeax::pbr-skin')).toBe(
296
+ false,
297
+ );
294
298
  });
295
299
 
296
300
  it('(d) D-5 continue semantics: bad-skin entity skipped does NOT abort sibling extract', () => {
@@ -216,10 +216,10 @@ describe('w16: 4-path compressed-fixture loading', () => {
216
216
  guid: GUID_TEX,
217
217
  kind: 'texture',
218
218
  payload: {
219
- width: 4,
220
- height: 4,
219
+ shape: { viewDimension: '2d', extent: { width: 4, height: 4 } },
221
220
  format: 'rgba8unorm',
222
221
  colorSpace: 'srgb',
222
+ mips: { kind: 'none' },
223
223
  },
224
224
  refs: [],
225
225
  artifacts: {
@@ -25,4 +25,17 @@ describe('frame targets shadow removal', () => {
25
25
 
26
26
  expect(resolveShadowMapSize(internals as never, lights as never)).toBe(1024);
27
27
  });
28
+
29
+ it('does not resolve a Directional atlas for the disabled topology sentinel', () => {
30
+ const internals = {
31
+ device: { limits: { maxTextureDimension2D: 2048 } },
32
+ };
33
+ const lights = {
34
+ directional: 'disabled',
35
+ shadowMapSize: 2048,
36
+ cascadeCount: undefined,
37
+ };
38
+
39
+ expect(resolveShadowMapSize(internals as never, lights as never)).toBeUndefined();
40
+ });
28
41
  });
@@ -2390,20 +2390,17 @@ function drawPublished(renderer: RendererType, world: WorldType) {
2390
2390
  spies.drawIndexed.mockClear();
2391
2391
  drawPublished(renderer, world as WorldType);
2392
2392
 
2393
- // All submesh draws share the same instanceCount (D-8).
2394
- // Verify drawIndexed calls carry instanceCount parameter at index 1.
2393
+ // Mesh indexed draws carry the Instances cardinality. Fullscreen graph
2394
+ // draws are a separate topology concern and intentionally use one
2395
+ // instance; do not mix those two contracts in one equality assertion.
2395
2396
  const indexedCalls = spies.drawIndexed.mock.calls as Array<
2396
2397
  [number, number, number, number, number]
2397
2398
  >;
2398
2399
  const drawCalls = spies.draw.mock.calls as Array<[number, number, number, number]>;
2399
- const allInstanceCounts = [...indexedCalls.map((c) => c[1]), ...drawCalls.map((c) => c[1])];
2400
- // All instanceCounts should be identical (shared across submeshes, D-8).
2401
- if (allInstanceCounts.length >= 2) {
2402
- const first = allInstanceCounts[0];
2403
- for (const ic of allInstanceCounts) {
2404
- expect(ic).toBe(first);
2405
- }
2406
- }
2400
+ expect(indexedCalls.length).toBeGreaterThan(0);
2401
+ expect(indexedCalls.map((call) => call[1])).toEqual(expect.arrayContaining([4]));
2402
+ expect(indexedCalls.every((call) => call[1] === 4)).toBe(true);
2403
+ expect(drawCalls.every((call) => call[1] === 1)).toBe(true);
2407
2404
  expect(errors).toEqual([]);
2408
2405
  });
2409
2406