@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
@@ -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
 
@@ -577,7 +578,7 @@ function spawnValidatedPointShadow(
577
578
  expect(withShadow[15]).toBe(25);
578
579
  });
579
580
 
580
- it('AC-13: spot light packLightSlot leaves pad lanes at sentinel (-1, 0, 0)', () => {
581
+ it('AC-13: spot light packLightSlot writes its shadow defaults into the tail lanes', () => {
581
582
  const spotSnap = {
582
583
  kind: 'spot' as const,
583
584
  entity: 0,
@@ -595,8 +596,9 @@ function spawnValidatedPointShadow(
595
596
  farPlane: 50,
596
597
  shadowAtlasTile: -1,
597
598
  };
598
- // Even when shadow info is passed, spot lights ignore the lanes
599
- // (only point lights with shadow ride the pad lanes).
599
+ // SpotLight uses the former pad lanes for the shadow intensity and
600
+ // PCF-kernel defaults. The atlas tile remains the -1 sentinel while
601
+ // projector/shadow consumers read the appended payload fields.
600
602
  const packed = packLightSlot(spotSnap, {
601
603
  shadowAtlasLayer: 0,
602
604
  near: 0.1,
@@ -604,8 +606,64 @@ function spawnValidatedPointShadow(
604
606
  });
605
607
  const i32 = new Int32Array(packed.buffer);
606
608
  expect(i32[13]).toBe(-1); // sentinel; spot lights stay shadowless
607
- expect(packed[14]).toBe(0);
608
- expect(packed[15]).toBe(0);
609
+ expect(packed[14]).toBeCloseTo(1, 5);
610
+ expect(packed[15]).toBeCloseTo(3, 5);
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);
609
667
  });
610
668
 
611
669
  it('AC-05: LIGHTSLOT_LAYOUT byteSize unchanged at 64 (no-regression for standard)', () => {
@@ -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':
78
+ return code;
79
+ case 'render-target-state-invalid':
80
+ return code;
81
+ case 'render-target-operation-failed':
68
82
  return code;
69
- case 'hdrp-index-list-overflow':
83
+ case 'reflection-probe-budget-exceeded':
70
84
  return code;
71
- case 'hdrp-deferred-caps-insufficient':
85
+ case 'render-intent-invalid':
72
86
  return code;
73
87
  case 'point-shadow-atlas-uninitialized':
74
88
  return code;
@@ -114,6 +128,16 @@ function exhaustiveSwitchOnRenderCode(code: RenderErrorCode): string {
114
128
  return code;
115
129
  case 'points-lines-prepare-failed':
116
130
  return code;
131
+ case 'projector-binding-failed':
132
+ return code;
133
+ case 'volume-owner-conflict':
134
+ return code;
135
+ case 'volume-density-shape-mismatch':
136
+ return code;
137
+ case 'volume-invalid-bounds':
138
+ return code;
139
+ case 'volume-invalid-parameters':
140
+ return code;
117
141
  default: {
118
142
  const exhaustive: never = code;
119
143
  return exhaustive;
@@ -176,6 +200,10 @@ function narrowRenderError(err: RenderError): void {
176
200
  void err.detail.operation;
177
201
  void err.detail.causes;
178
202
  break;
203
+ case 'standard-profile-invalid':
204
+ void err.detail.field;
205
+ void err.detail.actual;
206
+ break;
179
207
  case 'frame-receipt-stale':
180
208
  void err.detail.frameId;
181
209
  void err.detail.receiptGeneration;
@@ -200,7 +228,9 @@ function narrowRenderError(err: RenderError): void {
200
228
  break;
201
229
  case 'shadow-invalid-config':
202
230
  void err.detail.field; // string
203
- void err.detail.value; // number
231
+ void err.detail.actual; // number
232
+ void err.detail.bound;
233
+ void err.detail.reason; // string
204
234
  break;
205
235
  case 'environment-source-conflict':
206
236
  void err.detail.owners;
@@ -227,17 +257,51 @@ function narrowRenderError(err: RenderError): void {
227
257
  case 'equirect-projection-failed':
228
258
  void err.detail.handle; // number
229
259
  break;
230
- case 'hdrp-light-budget-exceeded':
260
+ case 'standard-light-budget-exceeded':
231
261
  void err.detail.actual; // number
232
262
  void err.detail.budget; // number
233
263
  break;
234
- case 'hdrp-index-list-overflow':
264
+ case 'standard-cluster-index-overflow':
235
265
  void err.detail.actual; // number
236
266
  void err.detail.capacity; // number
237
267
  break;
238
- case 'hdrp-deferred-caps-insufficient':
239
- void err.detail.actual; // number
240
- 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;
241
305
  break;
242
306
  case 'point-shadow-atlas-uninitialized':
243
307
  // No detail on this class.
@@ -333,6 +397,25 @@ function narrowRenderError(err: RenderError): void {
333
397
  void err.detail.capability;
334
398
  void err.detail.stage;
335
399
  break;
400
+ case 'projector-binding-failed':
401
+ void err.detail.guid;
402
+ void err.detail.status;
403
+ break;
404
+ case 'volume-owner-conflict':
405
+ void err.detail.ownerCount;
406
+ break;
407
+ case 'volume-density-shape-mismatch':
408
+ void err.detail.guid;
409
+ void err.detail.viewDimension;
410
+ break;
411
+ case 'volume-invalid-bounds':
412
+ void err.detail.min;
413
+ void err.detail.max;
414
+ break;
415
+ case 'volume-invalid-parameters':
416
+ void err.detail.field;
417
+ void err.detail.value;
418
+ break;
336
419
  default: {
337
420
  const exhaustive: never = err;
338
421
  void exhaustive;
@@ -1193,12 +1193,11 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
1193
1193
  'TextureAsset',
1194
1194
  {
1195
1195
  kind: 'texture',
1196
- width: 1,
1197
- height: 1,
1196
+ shape: { viewDimension: '2d', extent: { width: 1, height: 1 } },
1198
1197
  format: 'rgba8unorm',
1199
1198
  data: new Uint8Array([255, 255, 255, 255]),
1200
1199
  colorSpace: 'linear',
1201
- mipmap: false,
1200
+ mips: { kind: 'none' },
1202
1201
  },
1203
1202
  );
1204
1203
  }
@@ -1211,12 +1210,11 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
1211
1210
  const textureGuid = AssetGuid.random();
1212
1211
  assets.catalog(textureGuid, {
1213
1212
  kind: 'texture',
1214
- width: 1,
1215
- height: 1,
1213
+ shape: { viewDimension: '2d', extent: { width: 1, height: 1 } },
1216
1214
  format: 'rgba8unorm',
1217
1215
  data: new Uint8Array([255, 255, 255, 255]),
1218
1216
  colorSpace: 'linear',
1219
- mipmap: false,
1217
+ mips: { kind: 'none' },
1220
1218
  } satisfies TextureAsset);
1221
1219
  const matHandle = world.allocSharedRef<'MaterialAsset', MaterialAsset>('MaterialAsset', {
1222
1220
  kind: 'material',