@forgeax/engine-runtime 0.1.20 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +102 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts +2 -0
  4. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts.map +1 -0
  5. package/dist/__tests__/lights-preservation.fixup.test.d.ts +2 -0
  6. package/dist/__tests__/lights-preservation.fixup.test.d.ts.map +1 -0
  7. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  8. package/dist/createRenderer.d.ts.map +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.mjs +2 -1
  11. package/dist/index.mjs.map +1 -1
  12. package/package.json +30 -30
  13. package/src/__tests__/active-camera.unit.test.ts +6 -8
  14. package/src/__tests__/asset-registry.recursive.spec.ts +1 -1
  15. package/src/__tests__/clamp-to-last.e2e.browser.test.ts +2 -2
  16. package/src/__tests__/components.test-d.ts +10 -3
  17. package/src/__tests__/components.unit.test.ts +13 -7
  18. package/src/__tests__/create-renderer-assembly.integration.test.ts +24 -0
  19. package/src/__tests__/create-renderer-gpu-pass-timing.unit.test.ts +32 -0
  20. package/src/__tests__/createRenderer.browser-no-webgpu.test.ts +66 -0
  21. package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +9 -1
  22. package/src/__tests__/dawn/instances-shadow.dawn.test.ts +15 -2
  23. package/src/__tests__/errors.unit.test.ts +51 -20
  24. package/src/__tests__/extract-frames-merge.test.ts +48 -40
  25. package/src/__tests__/extract-skin-mismatch.unit.test.ts +6 -2
  26. package/src/__tests__/frame-targets-shadow-regression.unit.test.ts +13 -0
  27. package/src/__tests__/geometry.unit.test.ts +7 -10
  28. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +2 -1
  29. package/src/__tests__/light-snapshot.test-d.ts +8 -0
  30. package/src/__tests__/lights-preservation.fixup.test.ts +127 -0
  31. package/src/__tests__/lights.unit.test.ts +75 -2235
  32. package/src/__tests__/pbr-pipeline.unit.test.ts +6 -1
  33. package/src/__tests__/pipeline.unit.test.ts +48 -202
  34. package/src/__tests__/point-light-shadow.browser.test.ts +208 -83
  35. package/src/__tests__/point-light-shadow.unit.test.ts +61 -4
  36. package/src/__tests__/render-error-exhaustive.test-d.ts +63 -9
  37. package/src/__tests__/render-system-mega.test.ts +30 -41
  38. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +4 -1
  39. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +4 -1
  40. package/src/__tests__/render-system-skin-bg.unit.test.ts +14 -9
  41. package/src/__tests__/renderer-recover.unit.test.ts +8 -0
  42. package/src/__tests__/renderer-surface.unit.test.ts +229 -3
  43. package/src/__tests__/shadow-csm-atlas.test.ts +41 -0
  44. package/src/__tests__/shadow-csm-component.test.ts +31 -24
  45. package/src/__tests__/shadow-csm-extract.test.ts +156 -47
  46. package/src/__tests__/shadow-csm-runtime-vary.dawn.test.ts +72 -0
  47. package/src/__tests__/shadow-csm-shader.dawn.test.ts +19 -0
  48. package/src/__tests__/shadow-csm-ubo.test.ts +41 -45
  49. package/src/__tests__/shadow-csm-viewport.browser.test.ts +29 -0
  50. package/src/__tests__/shadow-fields-observable.dawn.test.ts +85 -20
  51. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +3 -1
  52. package/src/__tests__/skylight-lazy-projection.test.ts +8 -5
  53. package/src/__tests__/ssao-bgl.test.ts +10 -16
  54. package/src/__tests__/ssao-passes.test.ts +19 -2
  55. package/src/__tests__/systems.unit.test.ts +44 -31
  56. package/src/__tests__/view-ubo-layout.browser.test.ts +20 -10
  57. package/src/createRenderer.ts +1 -0
  58. package/src/index.ts +1 -2
@@ -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', () => {
@@ -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
 
@@ -57,13 +57,14 @@ const emptyLights: ExtractedLights = {
57
57
  directionalCount: 0,
58
58
  point: [],
59
59
  spot: [],
60
+ directionalShadowError: undefined,
60
61
  lightViewProj: undefined,
61
62
  splitPlanes: undefined,
62
63
  cascadeCount: undefined,
63
64
  cascadeBlend: undefined,
64
65
  depthBias: undefined,
65
66
  normalBias: undefined,
66
- pcfKernelSize: undefined,
67
+ directionalShadowQuality: undefined,
67
68
  } as unknown as ExtractedLights;
68
69
 
69
70
  /** Capture the single writeViewUbo payload as a Float32Array. */
@@ -21,6 +21,8 @@
21
21
  // covers any .test-d.ts file in any package).
22
22
 
23
23
  import { expectTypeOf, test } from 'vitest';
24
+ import type { DirectionalShadowQuality } from '../../../render/src/components/directional-shadow-filter';
25
+ import type { LightValidationError } from '../../../render/src/components/light-helpers';
24
26
  import type {
25
27
  DirectionalLightSnapshot,
26
28
  ExtractedLights,
@@ -81,4 +83,10 @@ test('ExtractedLights three-bucket shape', () => {
81
83
  expectTypeOf<ExtractedLights['directionalCount']>().toEqualTypeOf<number>();
82
84
  expectTypeOf<ExtractedLights['point']>().toEqualTypeOf<readonly PointLightSnapshot[]>();
83
85
  expectTypeOf<ExtractedLights['spot']>().toEqualTypeOf<readonly SpotLightSnapshot[]>();
86
+ expectTypeOf<ExtractedLights['directionalShadowQuality']>().toEqualTypeOf<
87
+ DirectionalShadowQuality | undefined
88
+ >();
89
+ expectTypeOf<ExtractedLights['directionalShadowError']>().toEqualTypeOf<
90
+ LightValidationError | undefined
91
+ >();
84
92
  });
@@ -0,0 +1,127 @@
1
+ import { World } from '@forgeax/engine-ecs';
2
+ import { vec3 } from '@forgeax/engine-math';
3
+ import { Camera, PointLight, SpotLight } from '@forgeax/engine-render';
4
+ import { Transform } from '@forgeax/engine-scene';
5
+ import { describe, expect, it } from 'vitest';
6
+ import {
7
+ computeInvRangeSquared,
8
+ degToCos,
9
+ validatePointLightShadowData,
10
+ validateSpotLightData,
11
+ } from '../../../render/src/components/light-helpers';
12
+ import {
13
+ packLightSlot,
14
+ packPointLight,
15
+ packSpotLight,
16
+ } from '../../../render/src/light-buffer-layout';
17
+ import { buildPbrViewBglEntries } from '../../../render/src/pbr-pipeline';
18
+ import type {
19
+ PointLightSnapshot,
20
+ SpotLightSnapshot,
21
+ } from '../../../render/src/render-system-extract';
22
+ import { extractFrame, prepareExtractContext } from '../../../render/src/render-system-extract';
23
+
24
+ function pointSnapshot(): PointLightSnapshot {
25
+ return {
26
+ kind: 'point',
27
+ position: vec3.create(1, 2, 3),
28
+ color: vec3.create(2, 1, 0.5),
29
+ intensity: 2,
30
+ invRangeSquared: 0.25,
31
+ };
32
+ }
33
+
34
+ function spotSnapshot(): SpotLightSnapshot {
35
+ return {
36
+ kind: 'spot',
37
+ position: vec3.create(1, 2, 3),
38
+ direction: vec3.create(0, -1, 0),
39
+ color: vec3.create(2, 1, 0.5),
40
+ intensity: 2,
41
+ invRangeSquared: 0.25,
42
+ cosInner: degToCos(15),
43
+ cosOuter: degToCos(30),
44
+ castShadow: false,
45
+ lightViewProj: undefined,
46
+ mapSize: 512,
47
+ nearPlane: 0.1,
48
+ farPlane: 25,
49
+ shadowAtlasTile: -1,
50
+ };
51
+ }
52
+
53
+ describe('Point/Spot and shared-light regression guard', () => {
54
+ it('keeps extract facts for point range and spot cone in the shared light path', () => {
55
+ const world = new World();
56
+ const pointSpawn = world.spawn(
57
+ { component: Transform, data: { pos: [1, 2, 3] } },
58
+ { component: PointLight, data: { range: 2 } },
59
+ );
60
+ const spotSpawn = world.spawn(
61
+ { component: Transform, data: { pos: [4, 5, 6] } },
62
+ {
63
+ component: SpotLight,
64
+ data: { direction: [0, -1, 0], range: 4, innerConeDeg: 15, outerConeDeg: 30 },
65
+ },
66
+ );
67
+ world.spawn(
68
+ { component: Transform, data: {} },
69
+ { component: Camera, data: { fov: Math.PI / 4, aspect: 1, near: 0.1, far: 100 } },
70
+ );
71
+ expect(pointSpawn.ok, JSON.stringify(pointSpawn)).toBe(true);
72
+ expect(spotSpawn.ok, JSON.stringify(spotSpawn)).toBe(true);
73
+
74
+ const lights = extractFrame(world, prepareExtractContext(world)).lights;
75
+ expect(lights.point).toHaveLength(1);
76
+ expect(lights.point[0]?.invRangeSquared).toBeCloseTo(0.25, 6);
77
+ expect(lights.spot).toHaveLength(1);
78
+ expect(lights.spot[0]?.cosInner).toBeCloseTo(degToCos(15), 6);
79
+ expect(lights.spot[0]?.cosOuter).toBeCloseTo(degToCos(30), 6);
80
+ });
81
+
82
+ it('keeps attenuation and cone helpers as finite, shared semantic owners', () => {
83
+ expect(computeInvRangeSquared(2)).toBeCloseTo(0.25, 8);
84
+ expect(computeInvRangeSquared(0)).toBe(1e8);
85
+ expect(computeInvRangeSquared(Number.POSITIVE_INFINITY)).toBe(0);
86
+ expect(degToCos(0)).toBeCloseTo(1, 8);
87
+ expect(degToCos(60)).toBeCloseTo(0.5, 8);
88
+ });
89
+
90
+ it('keeps Point/Spot packing and the shared LightSlot discriminant intact', () => {
91
+ const point = pointSnapshot();
92
+ const spot = spotSnapshot();
93
+ const pointPacked = packPointLight(point);
94
+ const spotPacked = packSpotLight(spot);
95
+ const pointSlot = packLightSlot(point, { shadowAtlasLayer: 2, near: 0.1, far: 20 });
96
+ const spotSlot = packLightSlot(spot);
97
+
98
+ expect(pointPacked.byteLength).toBe(32);
99
+ expect(spotPacked.byteLength).toBe(80);
100
+ expect(new Uint32Array(pointSlot.buffer)[12]).toBe(0);
101
+ expect(new Int32Array(pointSlot.buffer)[13]).toBe(2);
102
+ expect(new Uint32Array(spotSlot.buffer)[12]).toBe(1);
103
+ expect(new Int32Array(spotSlot.buffer)[13]).toBe(-1);
104
+ });
105
+
106
+ it('keeps shared PBR view bindings for both storage and uniform light routes', () => {
107
+ const storageBindings = buildPbrViewBglEntries({ storageBuffer: true });
108
+ const uniformBindings = buildPbrViewBglEntries({ storageBuffer: false });
109
+ expect(storageBindings.map((entry) => entry.binding)).toEqual(
110
+ uniformBindings.map((entry) => entry.binding),
111
+ );
112
+ expect(storageBindings[1]?.buffer?.type).toBe('read-only-storage');
113
+ expect(storageBindings[2]?.buffer?.type).toBe('read-only-storage');
114
+ expect(uniformBindings[1]?.buffer?.type).toBe('uniform');
115
+ expect(uniformBindings[2]?.buffer?.type).toBe('uniform');
116
+ });
117
+
118
+ it('keeps Point/Spot pcfKernelSize validation independent of Directional quality', () => {
119
+ expect(
120
+ validatePointLightShadowData({ mapSize: 512, nearPlane: 0.1, farPlane: 20, pcfKernelSize: 5 })
121
+ .ok,
122
+ ).toBe(true);
123
+ expect(validateSpotLightData({ direction: [0, -1, 0], pcfKernelSize: 3 }).ok).toBe(true);
124
+ expect(validatePointLightShadowData({ pcfKernelSize: 2 }).ok).toBe(false);
125
+ expect(validateSpotLightData({ direction: [0, -1, 0], pcfKernelSize: 2 }).ok).toBe(false);
126
+ });
127
+ });