@forgeax/engine-runtime 0.1.20 → 0.1.23

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 (86) hide show
  1. package/README.md +105 -3
  2. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts +2 -0
  3. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts.map +1 -0
  4. package/dist/__tests__/helpers/register-runtime-components.d.ts.map +1 -1
  5. package/dist/__tests__/helpers/register-scene-components.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__/render-error-exhaustive.test-d.d.ts.map +1 -1
  9. package/dist/createRenderer.d.ts.map +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.mjs +4 -2
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/sprite-param-values.d.ts +1 -1
  14. package/package.json +30 -30
  15. package/src/__tests__/active-camera.unit.test.ts +6 -8
  16. package/src/__tests__/animation-graph-asset-union.test-d.ts +2 -0
  17. package/src/__tests__/asset-brand-and-name-retirement.test.ts +4 -3
  18. package/src/__tests__/asset-registry-scene.test-d.ts +1 -0
  19. package/src/__tests__/asset-registry.recursive.spec.ts +1 -1
  20. package/src/__tests__/asset.unit.test.ts +5 -2
  21. package/src/__tests__/clamp-to-last.e2e.browser.test.ts +2 -2
  22. package/src/__tests__/collect-transient-roundtrip.test.ts +14 -3
  23. package/src/__tests__/components.test-d.ts +19 -9
  24. package/src/__tests__/components.unit.test.ts +36 -26
  25. package/src/__tests__/create-renderer-assembly.integration.test.ts +24 -0
  26. package/src/__tests__/create-renderer-gpu-pass-timing.unit.test.ts +32 -0
  27. package/src/__tests__/createRenderer.browser-no-webgpu.test.ts +66 -0
  28. package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +9 -1
  29. package/src/__tests__/dawn/instances-shadow.dawn.test.ts +15 -2
  30. package/src/__tests__/errors.unit.test.ts +54 -20
  31. package/src/__tests__/extract-frames-merge.test.ts +51 -40
  32. package/src/__tests__/extract-skin-mismatch.unit.test.ts +6 -2
  33. package/src/__tests__/frame-targets-shadow-regression.unit.test.ts +13 -0
  34. package/src/__tests__/geometry.unit.test.ts +7 -10
  35. package/src/__tests__/helpers/register-runtime-components.ts +9 -1
  36. package/src/__tests__/helpers/register-scene-components.ts +9 -2
  37. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +10 -11
  38. package/src/__tests__/light-snapshot.test-d.ts +35 -1
  39. package/src/__tests__/lights-preservation.fixup.test.ts +125 -0
  40. package/src/__tests__/lights.unit.test.ts +223 -308
  41. package/src/__tests__/loader-name-survival.test.ts +8 -9
  42. package/src/__tests__/package-promotion.test.ts +7 -11
  43. package/src/__tests__/pbr-pipeline.unit.test.ts +22 -17
  44. package/src/__tests__/pipeline.unit.test.ts +68 -226
  45. package/src/__tests__/point-light-shadow.browser.test.ts +209 -83
  46. package/src/__tests__/point-light-shadow.unit.test.ts +24 -47
  47. package/src/__tests__/propagate-transforms.dawn.test.ts +15 -16
  48. package/src/__tests__/record-worldid-isolation.test.ts +16 -32
  49. package/src/__tests__/render-error-exhaustive.test-d.ts +73 -9
  50. package/src/__tests__/render-system-extract.test.ts +5 -5
  51. package/src/__tests__/render-system-mega.test.ts +30 -41
  52. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +5 -1
  53. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +5 -1
  54. package/src/__tests__/render-system-skin-bg.unit.test.ts +14 -9
  55. package/src/__tests__/renderer-recover.unit.test.ts +8 -0
  56. package/src/__tests__/renderer-surface.unit.test.ts +229 -5
  57. package/src/__tests__/resolveName-unified-entry.test.ts +6 -6
  58. package/src/__tests__/resolveName.test.ts +14 -2
  59. package/src/__tests__/shadow-csm-atlas.test.ts +41 -0
  60. package/src/__tests__/shadow-csm-cascade-loadop.test.ts +1 -0
  61. package/src/__tests__/shadow-csm-component.test.ts +31 -24
  62. package/src/__tests__/shadow-csm-extract.test.ts +156 -47
  63. package/src/__tests__/shadow-csm-runtime-vary.dawn.test.ts +72 -0
  64. package/src/__tests__/shadow-csm-shader.dawn.test.ts +19 -0
  65. package/src/__tests__/shadow-csm-tile-consistency.test.ts +1 -0
  66. package/src/__tests__/shadow-csm-ubo.test.ts +43 -45
  67. package/src/__tests__/shadow-csm-viewport.browser.test.ts +29 -0
  68. package/src/__tests__/shadow-fields-observable.dawn.test.ts +100 -24
  69. package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +1 -1
  70. package/src/__tests__/skin-parented-double-transform.test.ts +5 -5
  71. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +4 -7
  72. package/src/__tests__/skylight-lazy-projection.test.ts +8 -5
  73. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -13
  74. package/src/__tests__/ssao-bgl.test.ts +12 -17
  75. package/src/__tests__/ssao-passes.test.ts +19 -2
  76. package/src/__tests__/systems.unit.test.ts +69 -205
  77. package/src/__tests__/template-game-default-api-contract.test.ts +45 -23
  78. package/src/__tests__/tilemap-chunk-extract.propagate-order.test.ts +10 -4
  79. package/src/__tests__/transform-hierarchy-pixel-diff.dawn.test.ts +2 -2
  80. package/src/__tests__/view-ubo-layout.browser.test.ts +20 -10
  81. package/src/collect-scene-asset.ts +1 -1
  82. package/src/components/__tests__/transform-vec-schema.test.ts +2 -2
  83. package/src/createRenderer.ts +2 -0
  84. package/src/index.ts +1 -2
  85. package/src/sprite-param-values.ts +1 -1
  86. package/dist/.tsbuildinfo +0 -1
@@ -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,199 @@ 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
+ 60_000,
404
+ );
@@ -19,9 +19,9 @@ import { describe, expect, it } from 'vitest';
19
19
  import { validatePointLightShadowData } from '../../../render/src/components/light-helpers';
20
20
  import { ShadowInvalidConfigError } from '../../../render/src/errors/render';
21
21
  import {
22
- BYTES_PER_LIGHT_SLOT,
23
- LIGHTSLOT_LAYOUT,
24
- packLightSlot,
22
+ BYTES_PER_DIRECT_LIGHT_SLOT,
23
+ DIRECT_LIGHT_SLOT_LAYOUT,
24
+ packDirectLightSlot,
25
25
  } from '../../../render/src/light-buffer-layout';
26
26
  import { buildPointShadowMatrices } from '../../../render/src/render-system-extract';
27
27
  import {
@@ -164,8 +164,8 @@ function spawnValidatedPointShadow(
164
164
  expect(r.error).toBeInstanceOf(ShadowInvalidConfigError);
165
165
  const detail = (r.error as unknown as ShadowInvalidConfigError).detail;
166
166
  expect(detail.field).toBe('mapSize');
167
- expect(detail.value).toBe(0);
168
- expect(detail.min).toBe(1);
167
+ expect(detail.actual).toBe(0);
168
+ expect(detail.bound).toEqual({ kind: 'lower-bound', operator: '>=', value: 1 });
169
169
  // .expected and .hint carry actionable strings
170
170
  expect((r.error as unknown as ShadowInvalidConfigError).expected).toContain('mapSize');
171
171
  expect((r.error as unknown as ShadowInvalidConfigError).hint).toContain('mapSize');
@@ -190,8 +190,8 @@ function spawnValidatedPointShadow(
190
190
  expect(r.error).toBeInstanceOf(ShadowInvalidConfigError);
191
191
  const detail = (r.error as unknown as ShadowInvalidConfigError).detail;
192
192
  expect(detail.field).toBe('farPlane');
193
- expect(detail.value).toBe(0.5);
194
- expect(detail.min).toBe(1.0);
193
+ expect(detail.actual).toBe(0.5);
194
+ expect(detail.bound).toEqual({ kind: 'lower-bound', operator: '>=', value: 1.0 });
195
195
  }
196
196
  });
197
197
 
@@ -550,7 +550,7 @@ function spawnValidatedPointShadow(
550
550
  });
551
551
 
552
552
  describe('standard point shadow light-slot packing (AC-05)', () => {
553
- it('AC-13: packLightSlot writes shadow info onto LightSlot pad lanes (byte 52..64)', () => {
553
+ it('AC-13: packDirectLightSlot writes point shadow identity metadata', () => {
554
554
  const snap = {
555
555
  kind: 'point' as const,
556
556
  position: vec3.create(1, 2, 3),
@@ -558,26 +558,18 @@ function spawnValidatedPointShadow(
558
558
  intensity: 1,
559
559
  invRangeSquared: 0.04,
560
560
  };
561
- // Without shadow info: layer = -1 sentinel; near/far = 0.
562
- const noShadow = packLightSlot(snap);
561
+ // Without shadow info: layer = -1 sentinel in metadata word 17.
562
+ const noShadow = packDirectLightSlot(snap);
563
563
  const i32NoShadow = new Int32Array(noShadow.buffer);
564
- expect(i32NoShadow[13]).toBe(-1); // sentinel
565
- expect(noShadow[14]).toBe(0); // near
566
- expect(noShadow[15]).toBe(0); // far
567
-
568
- // With shadow info: layer 2, near 0.1, far 25 — matches PointLightShadow defaults.
569
- const withShadow = packLightSlot(snap, {
570
- shadowAtlasLayer: 2,
571
- near: 0.1,
572
- far: 25,
573
- });
564
+ expect(i32NoShadow[17]).toBe(-1); // sentinel
565
+
566
+ // Extract joins the PointLightShadow layer before packing.
567
+ const withShadow = packDirectLightSlot({ ...snap, shadowAtlasLayer: 2 });
574
568
  const i32Shadow = new Int32Array(withShadow.buffer);
575
- expect(i32Shadow[13]).toBe(2);
576
- expect(withShadow[14]).toBeCloseTo(0.1, 5);
577
- expect(withShadow[15]).toBe(25);
569
+ expect(i32Shadow[17]).toBe(2);
578
570
  });
579
571
 
580
- it('AC-13: spot light packLightSlot writes its shadow defaults into the tail lanes', () => {
572
+ it('AC-13: unified Spot packing writes shadow metadata', () => {
581
573
  const spotSnap = {
582
574
  kind: 'spot' as const,
583
575
  entity: 0,
@@ -595,30 +587,15 @@ function spawnValidatedPointShadow(
595
587
  farPlane: 50,
596
588
  shadowAtlasTile: -1,
597
589
  };
598
- // SpotLight uses the former pad lanes for the shadow intensity and
599
- // PCF-kernel defaults. The atlas tile remains the -1 sentinel while
600
- // projector/shadow consumers read the appended payload fields.
601
- const packed = packLightSlot(spotSnap, {
602
- shadowAtlasLayer: 0,
603
- near: 0.1,
604
- far: 25,
605
- });
590
+ const packed = packDirectLightSlot(spotSnap);
606
591
  const i32 = new Int32Array(packed.buffer);
607
- expect(i32[13]).toBe(-1); // sentinel; spot lights stay shadowless
608
- expect(packed[14]).toBeCloseTo(1, 5);
609
- expect(packed[15]).toBeCloseTo(3, 5);
610
- });
611
-
612
- it('AC-05: LIGHTSLOT_LAYOUT byteSize unchanged at 64 (no-regression for standard)', () => {
613
- // The pad-lane re-purposing must not change the total LightSlot size:
614
- // the std430 vec4 stride is preserved; only the field semantics within
615
- // bytes 52..64 changed (u32 pad -> i32 layer + f32 near + f32 far).
616
- expect(BYTES_PER_LIGHT_SLOT).toBe(64);
617
- expect(LIGHTSLOT_LAYOUT.byteSize).toBe(64);
618
- expect(LIGHTSLOT_LAYOUT.shadowAtlasLayerOffset).toBe(52);
619
- expect(LIGHTSLOT_LAYOUT.shadowPayloadOffset).toBe(52);
620
- expect(LIGHTSLOT_LAYOUT.shadowNearOffset).toBe(56);
621
- expect(LIGHTSLOT_LAYOUT.shadowFarOffset).toBe(60);
592
+ expect(i32[17]).toBe(-1);
593
+ });
594
+
595
+ it('AC-05: DIRECT_LIGHT_SLOT_LAYOUT keeps the five-row ABI', () => {
596
+ expect(BYTES_PER_DIRECT_LIGHT_SLOT).toBe(80);
597
+ expect(DIRECT_LIGHT_SLOT_LAYOUT.byteSize).toBe(80);
598
+ expect(DIRECT_LIGHT_SLOT_LAYOUT.shadowByteOffset).toBe(68);
622
599
  });
623
600
  });
624
601
 
@@ -5,13 +5,12 @@
5
5
  // Environment: node + setupFiles ./vitest.setup-webgpu.ts injects
6
6
  // globalThis.navigator.gpu (provided by dawn.node native binding).
7
7
  //
8
- // feat-20260601 M4: GlobalTransform is retired. propagateTransforms now writes
9
- // the resolved world mat4 into the `Transform.world` column (column-major 16
10
- // floats). Tests read that column via the ECS array view and assert the
11
- // translation column (m[12,13,14]).
8
+ // The derived world mat4 lives in the paired `GlobalTransform.world` column
9
+ // (column-major 16 floats). Tests read that column via the ECS array view and
10
+ // assert the translation column (m[12,13,14]).
12
11
  //
13
12
  // Scope (w6 acceptanceCheck):
14
- // (a) root-only entity: Transform.world translation = local translation
13
+ // (a) root-only entity: GlobalTransform.world translation = local translation
15
14
  // (b) 2-level hierarchy: child.world = parent.world x child local
16
15
  // (c) stale ChildOf ref: Result.err(RhiError({ code: 'hierarchy-broken' }))
17
16
  // (d) N=3 deep chain: composition stacks correctly
@@ -24,7 +23,7 @@
24
23
  import type { EntityHandle } from '@forgeax/engine-ecs';
25
24
  import { World } from '@forgeax/engine-ecs';
26
25
  import { readRenderArrayView } from '@forgeax/engine-ecs/projection';
27
- import { ChildOf, propagateTransforms, Transform } from '@forgeax/engine-scene';
26
+ import { ChildOf, GlobalTransform, propagateTransforms, Transform } from '@forgeax/engine-scene';
28
27
  import { describe, expect, it } from 'vitest';
29
28
  import { TileLayer, Tilemap } from '../../../render/src/components';
30
29
 
@@ -36,16 +35,17 @@ function identityTransformData() {
36
35
  };
37
36
  }
38
37
 
39
- // Read the resolved world mat4 (column-major 16 floats) from the Transform
40
- // world column array view. Translation lives in column 3 (m[12,13,14]).
38
+ // Read the resolved world mat4 (column-major 16 floats) from the
39
+ // GlobalTransform world column array view. Translation lives in column 3
40
+ // (m[12,13,14]).
41
41
  function worldOf(world: World, entity: EntityHandle): Float32Array {
42
- const view = readRenderArrayView(world, entity, Transform, 'world');
43
- if (view === undefined) throw new Error('Transform.world view missing');
42
+ const view = readRenderArrayView(world, entity, GlobalTransform, 'world');
43
+ if (view === undefined) throw new Error('GlobalTransform.world view missing');
44
44
  return view as Float32Array;
45
45
  }
46
46
 
47
47
  describe('propagate-transforms.dawn - root-down DFS + stale ChildOf fail-fast (AC-04)', () => {
48
- it('root entity: Transform.world translation = local translation (identity chain)', () => {
48
+ it('root entity: GlobalTransform.world translation = local translation (identity chain)', () => {
49
49
  const world = new World();
50
50
  const root = world
51
51
  .spawn({
@@ -153,7 +153,7 @@ describe('propagate-transforms.dawn - root-down DFS + stale ChildOf fail-fast (A
153
153
 
154
154
  // AC-07 (tweak-20260714-tilemap-layer-childed-render-entities M5): TileLayer
155
155
  // as an identity middle node must be transparent to propagateTransforms -- the
156
- // layer's Transform.world (16 f32) must equal its Tilemap parent's world mat4
156
+ // layer's GlobalTransform.world (16 f32) must equal its Tilemap parent's world mat4
157
157
  // byte-for-byte. This proves M1's coAttach injection produces a genuinely
158
158
  // identity TRS (pos=[0,0,0], quat=[0,0,0,1], scale=[1,1,1]) and the compose
159
159
  // (parent.world x identity) is a no-op in mat4 form.
@@ -165,7 +165,7 @@ describe('propagate-transforms.dawn - root-down DFS + stale ChildOf fail-fast (A
165
165
  // asserted here because the M1 contract is precisely "identity default,
166
166
  // callers may override" -- the interesting invariant is transparency in the
167
167
  // default case.
168
- it('AC-07: identity TileLayer middle node -> Transform.world byte-identical to Tilemap parent', () => {
168
+ it('AC-07: identity TileLayer middle node -> GlobalTransform.world byte-identical to Tilemap parent', () => {
169
169
  const world = new World();
170
170
  for (const component of [ChildOf, TileLayer, Tilemap, Transform]) {
171
171
  world.components.register(component).unwrap();
@@ -189,9 +189,8 @@ describe('propagate-transforms.dawn - root-down DFS + stale ChildOf fail-fast (A
189
189
  },
190
190
  )
191
191
  .unwrap();
192
- // TileLayer carries an explicit identity Transform in the current ECS
193
- // component vocabulary. This mirrors the demo spawn pattern in
194
- // apps/hello/tilemap/** and keeps the hierarchy contract explicit.
192
+ // TileLayer carries an identity Transform; the generic Transform requirement
193
+ // materializes its GlobalTransform world column at the structural boundary.
195
194
  const layer = world
196
195
  .spawn(
197
196
  {
@@ -6,10 +6,11 @@
6
6
  //
7
7
  // Anchors:
8
8
  // plan-tasks.json m2 (patch assignment)
9
- // plan-strategy D-1a #1 (instanceBuffers positive half) and #3 (instancesBgPerEntity)
9
+ // plan-strategy D-1a #1 (instanceBuffers positive half); bind groups are now
10
+ // shared by opaque buffer handles after the probe/identity layout split.
10
11
  // requirements AC-07
11
12
  //
12
- // The 13 sites under test (plus 2 read-side instancesBgPerEntity reads) are
13
+ // The production instance-buffer sites and the shared bind-group resolver are
13
14
  // located by their stable cache operation below; source line numbers are not
14
15
  // part of the contract and must not make this gate stale after extraction.
15
16
  //
@@ -29,7 +30,7 @@ import { describe, expect, it } from 'vitest';
29
30
  * Verify that a cache site uses a worldEntityKey composite key.
30
31
  * Scans the source file around the given line (within a window) for both
31
32
  * `worldEntityKey` and the cache operation (`targetPattern` such as
32
- * `instanceBuffers.get`, `instancesBgPerEntity` as second arg, etc.).
33
+ * `instanceBuffers.get`, etc.).
33
34
  */
34
35
  function expectWorldEntityKeyAt(
35
36
  filePath: string,
@@ -95,19 +96,16 @@ describe('production-path geometry instanceBuffers worldEntityKey', () => {
95
96
  });
96
97
  });
97
98
 
98
- // ── Sprite identity BG instancesBgPerEntity (D-1a #3) ──────────────────────
99
-
100
99
  const SPRITE_FILE = fileURLToPath(
101
100
  new URL('../../../render/src/record/main-pass-sprite-draws.ts', import.meta.url),
102
101
  );
103
102
 
104
- describe('production-path sprite identity instancesBgPerEntity worldEntityKey', () => {
105
- it('identityInstBg entityKey uses worldEntityKey', () => {
106
- expectWorldEntityKeyAt(
107
- SPRITE_FILE,
108
- findLine(SPRITE_FILE, 'frameState.instancesBgPerEntity', 0),
109
- 'main-pass-sprite-draws instancesBgPerEntity write',
110
- );
103
+ describe('production-path shared instance bind-group resolver', () => {
104
+ it('sprite identity and sprite-instance paths use the shared resolver', () => {
105
+ const src = readFileSync(SPRITE_FILE, 'utf8');
106
+ expect(src).toContain('const identityInstBg = resolveGeometryInstancesBindGroup(');
107
+ expect(src).toContain('const spriteInstBg = resolveGeometryInstancesBindGroup(');
108
+ expect(src).toContain('const spriteInstancesBg = resolveGeometryInstancesBindGroup(');
111
109
  });
112
110
  });
113
111
 
@@ -131,18 +129,6 @@ describe('production-path sprite Instances instanceBuffers worldEntityKey', () =
131
129
  });
132
130
  });
133
131
 
134
- // ── Sprite pass instancesBgPerEntity (D-1a #3) ─────────────────────────────
135
-
136
- describe('production-path sprite pass instancesBgPerEntity worldEntityKey', () => {
137
- it('spriteInstancesBg entityKey write uses worldEntityKey', () => {
138
- expectWorldEntityKeyAt(
139
- SPRITE_FILE,
140
- findLine(SPRITE_FILE, 'frameState.instancesBgPerEntity', 1),
141
- 'main-pass-sprite-draws sprite pass instancesBgPerEntity write',
142
- );
143
- });
144
- });
145
-
146
132
  // ── SpriteInstances instanceBuffers (D-1a #1) ──────────────────────────────
147
133
 
148
134
  describe('production-path SpriteInstances instanceBuffers worldEntityKey', () => {
@@ -197,15 +183,13 @@ describe('production-path shadow instanceBuffers worldEntityKey', () => {
197
183
  });
198
184
  });
199
185
 
200
- // ── Shadow pass instancesBgPerEntity (D-1a #3) ─────────────────────────────
201
-
202
- describe('production-path shadow instancesBgPerEntity worldEntityKey', () => {
203
- it('shadowInstancesBg entityKey write uses worldEntityKey', () => {
204
- expectWorldEntityKeyAt(
205
- SHADOW_FILE,
206
- findLine(SHADOW_FILE, 'c.frameState.instancesBgPerEntity', 0),
207
- 'shadow-pass instancesBgPerEntity write',
186
+ describe('production-path shadow shared instance bind-group resolver', () => {
187
+ it('shadow pass resolves the bind group from the world-aware buffer cache', () => {
188
+ const src = readFileSync(SHADOW_FILE, 'utf8');
189
+ expect(src).toContain(
190
+ 'const shadowInstancesBg = resolveGeometryInstancesBindGroup(c, shadowInstanceBuffer);',
208
191
  );
192
+ expect(src).toContain('worldEntityKey(entry.source.worldId, shadowInst.cacheKey)');
209
193
  });
210
194
  });
211
195