@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
@@ -147,16 +147,14 @@ describe('w7 — getActiveCamera / setActiveCamera', () => {
147
147
  // ═══════════════════════════════════════════════════════════════════════════════
148
148
 
149
149
  describe('w7 — extractFrame ActiveCamera selection', () => {
150
- it('(2) no ActiveCamera + multiple cameras -> all cameras surface (first-hit fallback preserved)', () => {
150
+ it('(2) no ActiveCamera + multiple display cameras -> first display camera surfaces', () => {
151
151
  const world = new World();
152
152
  const assets = makeAssets();
153
153
  spawnCamera(world, 1.0);
154
154
  spawnCamera(world, 2.0);
155
155
  const frame = extractFrame(world, prepareExtractContext(world, { assets }));
156
- // No selection -> existing behavior: both cameras flow through; record
157
- // stage fires multi-camera diagnostic + uses first hit. We assert extract
158
- // does NOT prune when there is no ActiveCamera.
159
- expect(frame.cameras.length).toBe(2);
156
+ // No selection -> the first display camera remains the display authority.
157
+ expect(frame.cameras.length).toBe(1);
160
158
  });
161
159
 
162
160
  it('(1) ActiveCamera pointing at 2nd camera -> only that camera surfaces', () => {
@@ -171,15 +169,15 @@ describe('w7 — extractFrame ActiveCamera selection', () => {
171
169
  expect(frame.cameras[0]?.fov).toBeCloseTo(2.0, 5);
172
170
  });
173
171
 
174
- it('(3) ActiveCamera pointing at non-existent entity -> fallback to first-hit (all cameras)', () => {
172
+ it('(3) ActiveCamera pointing at non-existent entity -> fallback to first display camera', () => {
175
173
  const world = new World();
176
174
  const assets = makeAssets();
177
175
  spawnCamera(world, 1.0);
178
176
  spawnCamera(world, 2.0);
179
177
  setActiveCamera(world, 999999);
180
178
  const frame = extractFrame(world, prepareExtractContext(world, { assets }));
181
- // Unresolvable -> behave as if no ActiveCamera (do not prune).
182
- expect(frame.cameras.length).toBe(2);
179
+ // Unresolvable -> behave as if no ActiveCamera.
180
+ expect(frame.cameras.length).toBe(1);
183
181
  });
184
182
 
185
183
  it('(4) single camera + ActiveCamera pointing at it -> behavior unchanged (one camera)', () => {
@@ -68,6 +68,8 @@ describe('AnimationGraph POD in the Asset union (M2 / w12)', () => {
68
68
  return 'video';
69
69
  case 'particle-effect':
70
70
  return 'particle-effect';
71
+ case 'ies-profile':
72
+ return 'ies-profile';
71
73
  default: {
72
74
  const _exhaustive: never = a;
73
75
  return _exhaustive;
@@ -2,8 +2,9 @@
2
2
  //
3
3
  // AC-05: storedNameOf retired; per-GUID stored name lives on the envelope and
4
4
  // feeds resolveName's `storedName` argument. resolveName's three-arg
5
- // XOR derivation (single-asset basename / multi-asset stored name /
6
- // no-package '' fallback / 1->N promotion freeze) must be unchanged.
5
+ // display-name derivation (stored-name precedence / package basename
6
+ // fallback / no-package '' fallback / promotion freeze) must remain
7
+ // coherent.
7
8
  // AC-06: the 14-arm assetBrand switch retired for the ASSET_BRAND Record
8
9
  // table. The table maps every Asset.kind to its brand, and the two
9
10
  // consumption points (instantiate -> allocSharedRef brand arg;
@@ -19,7 +20,7 @@ function makeReg(): AssetRegistry {
19
20
 
20
21
  const sampler = { kind: 'sampler' as const };
21
22
 
22
- // ── AC-05: resolveName XOR derivation unchanged after storedNameOf retirement ──
23
+ // ── AC-05: resolveName remains coherent after storedNameOf retirement ──
23
24
 
24
25
  describe('AC-05 resolveName regression (storedNameOf retired, name on envelope)', () => {
25
26
  it('single-asset package -> basename(path), no stored name needed', () => {
@@ -44,6 +44,7 @@ describe('AssetUnion exhaustive switch covers all 15 members (feat-20260608 M0 b
44
44
  | 'tileset'
45
45
  | 'video'
46
46
  | 'particle-effect'
47
+ | 'ies-profile'
47
48
  >();
48
49
  });
49
50
  });
@@ -1166,7 +1166,7 @@ const LEAF_FIXTURES: readonly LeafFixture[] = [
1166
1166
  kind: 'render-pipeline',
1167
1167
  makeAsset: () => ({
1168
1168
  kind: 'render-pipeline' as const,
1169
- pipelineId: 'forgeax::urp' as const,
1169
+ pipelineId: 'forgeax::standard' as const,
1170
1170
  }),
1171
1171
  },
1172
1172
  ];
@@ -784,6 +784,7 @@ function _makeStubGPU(): unknown {
784
784
  'ui',
785
785
  'audio',
786
786
  'particle-effect',
787
+ 'ies-profile',
787
788
  ] as const;
788
789
 
789
790
  const UNREGISTERED_STUBS = ['shader'] as const;
@@ -795,13 +796,15 @@ function _makeStubGPU(): unknown {
795
796
  for (const kind of REGISTERED_KINDS) {
796
797
  expect(reg.get(kind), `expected loader for kind '${kind}'`).toBeDefined();
797
798
  }
798
- expect(reg.registeredKinds()).toHaveLength(17);
799
+ expect(reg.registeredKinds()).toHaveLength(18);
800
+ expect(reg.get('ies-profile'), 'ies-profile is wired internally').toBeDefined();
799
801
  });
800
802
 
801
803
  it('default set includes audio, video, and UI', () => {
802
804
  const reg = new LoaderRegistry();
803
805
  wireDefaultLoaders(reg);
804
- expect(reg.registeredKinds()).toHaveLength(17);
806
+ expect(reg.registeredKinds()).toHaveLength(18);
807
+ expect(reg.get('ies-profile'), 'ies-profile is wired internally').toBeDefined();
805
808
  expect(reg.get('video'), 'video is wired internally (graphics-extras)').toBeDefined();
806
809
  expect(reg.get('ui'), 'ui is wired internally (engine-ui)').toBeDefined();
807
810
  expect(reg.get('audio'), 'audio is an ordinary default asset kind').toBeDefined();
@@ -61,8 +61,8 @@ const SUT_ATTRIBUTABLE_RENDER_CODES: ReadonlySet<string> = new Set([
61
61
  'vertex-storage-buffer-unavailable',
62
62
  'mesh-ssbo-capacity-exceeded',
63
63
  'mesh-ssbo-ceiling-reached',
64
- 'hdrp-light-budget-exceeded',
65
- 'hdrp-index-list-overflow',
64
+ 'standard-light-budget-exceeded',
65
+ 'standard-cluster-index-overflow',
66
66
  ]);
67
67
 
68
68
  // Suppress WebGPU teardown race: chromium fires unhandled OperationError
@@ -14,9 +14,15 @@ import * as SceneOwner from '@forgeax/engine-scene';
14
14
 
15
15
  import { AssetRegistry } from '@forgeax/engine-assets-runtime';
16
16
  import type { EntityHandle } from '@forgeax/engine-ecs';
17
- import { World } from '@forgeax/engine-ecs';
17
+ import { componentDefinition, World } from '@forgeax/engine-ecs';
18
18
  import { AssetGuid } from '@forgeax/engine-pack/guid';
19
- import { ChildOf, Children, propagateTransforms, Transform } from '@forgeax/engine-scene';
19
+ import {
20
+ ChildOf,
21
+ Children,
22
+ GlobalTransform,
23
+ propagateTransforms,
24
+ Transform,
25
+ } from '@forgeax/engine-scene';
20
26
  import type { LocalEntityId, SceneAsset } from '@forgeax/engine-types';
21
27
  import { describe, expect, it } from 'vitest';
22
28
  import { rootsToSceneAsset } from '../collect-scene-asset';
@@ -26,7 +32,7 @@ import { registerSceneComponents } from './helpers/register-scene-components';
26
32
  // Read the resolved world mat4 (column-major 16 floats) from the Transform
27
33
  // world column array view.
28
34
  function worldOf(world: World, entity: EntityHandle): Float32Array {
29
- return world.get(entity, Transform).unwrap().world;
35
+ return world.get(entity, GlobalTransform).unwrap().world;
30
36
  }
31
37
 
32
38
  function makeRegistry(): AssetRegistry {
@@ -210,6 +216,7 @@ describe('w1 — AC-04 transient world roundtrip numeric equivalence', () => {
210
216
  for (const ent of collected.value.entities) {
211
217
  const t = (ent.components as Record<string, Record<string, unknown>>).Transform;
212
218
  if (t !== undefined) expect('world' in t).toBe(false);
219
+ expect((ent.components as Record<string, unknown>).GlobalTransform).toBeUndefined();
213
220
  }
214
221
 
215
222
  // Round-trip into a fresh world.
@@ -266,4 +273,8 @@ describe('w1 — AC-04 transient world roundtrip numeric equivalence', () => {
266
273
  expect(wDst[13] as number).toBeCloseTo(4, 5);
267
274
  expect(wDst[14] as number).toBeCloseTo(5, 5);
268
275
  });
276
+
277
+ it('declares GlobalTransform transient and reconstructs it from the authored pair', () => {
278
+ expect(componentDefinition(GlobalTransform).policy.transient).toBe(true);
279
+ });
269
280
  });
@@ -16,18 +16,20 @@
16
16
 
17
17
  import type { InputShapeOf, SchemaOf, ShapeOf } from '@forgeax/engine-ecs';
18
18
  import type { Camera, DirectionalLight, MeshFilter, MeshRenderer } from '@forgeax/engine-render';
19
- import type { ChildOf, Transform } from '@forgeax/engine-scene';
19
+ import type { ChildOf, GlobalTransform, Transform } from '@forgeax/engine-scene';
20
20
  import type { Handle } from '@forgeax/engine-types';
21
21
  import { describe, expectTypeOf, it } from 'vitest';
22
22
 
23
23
  describe('w7 type-level - 5 component schemas yield exact data shapes via ShapeOf', () => {
24
- it('Transform data shape has 3 local inline-array fields + world array<f32,16>', () => {
24
+ it('Transform data shape has 3 local inline-array fields', () => {
25
25
  type Data = ShapeOf<SchemaOf<typeof Transform>>;
26
- expectTypeOf<keyof Data>().toEqualTypeOf<'pos' | 'quat' | 'scale' | 'world'>();
26
+ expectTypeOf<keyof Data>().toEqualTypeOf<'pos' | 'quat' | 'scale'>();
27
27
  expectTypeOf<Data['pos']>().toEqualTypeOf<Float32Array>();
28
28
  expectTypeOf<Data['quat']>().toEqualTypeOf<Float32Array>();
29
29
  expectTypeOf<Data['scale']>().toEqualTypeOf<Float32Array>();
30
- expectTypeOf<Data['world']>().toEqualTypeOf<Float32Array>();
30
+ type GlobalData = ShapeOf<SchemaOf<typeof GlobalTransform>>;
31
+ expectTypeOf<keyof GlobalData>().toEqualTypeOf<'world'>();
32
+ expectTypeOf<GlobalData['world']>().toEqualTypeOf<Float32Array>();
31
33
  // Write side widens `array<f32, N>` to also accept plain number[] literals
32
34
  // (InputShapeOf asymmetry): `pos: [1, 2, 3]` spawns without a
33
35
  // Float32Array wrapper at the call site.
@@ -67,7 +69,7 @@ describe('w7 type-level - 5 component schemas yield exact data shapes via ShapeO
67
69
  expectTypeOf<{ materials: readonly [0] }>().not.toMatchTypeOf<SpawnData>();
68
70
  });
69
71
 
70
- it('Camera data shape has 20 fields (18 number + clearColor array + autoAspect boolean)', () => {
72
+ it('Camera data shape has 21 fields (18 number + clearColor array + autoAspect boolean + target)', () => {
71
73
  type Data = ShapeOf<SchemaOf<typeof Camera>>;
72
74
  expectTypeOf<keyof Data>().toEqualTypeOf<
73
75
  | 'fov'
@@ -90,6 +92,7 @@ describe('w7 type-level - 5 component schemas yield exact data shapes via ShapeO
90
92
  | 'bloomBlurRadius'
91
93
  | 'clearColor'
92
94
  | 'autoAspect'
95
+ | 'target'
93
96
  >();
94
97
  expectTypeOf<Data['fov']>().toEqualTypeOf<number>();
95
98
  expectTypeOf<Data['far']>().toEqualTypeOf<number>();
@@ -107,9 +110,10 @@ describe('w7 type-level - 5 component schemas yield exact data shapes via ShapeO
107
110
  expectTypeOf<Data['clearColor']>().toEqualTypeOf<Float32Array>();
108
111
  // feat-20260617 / M3: AC-09 -- bool column narrows to boolean, not number.
109
112
  expectTypeOf<Data['autoAspect']>().toEqualTypeOf<boolean>();
113
+ expectTypeOf<Data['target']>().toEqualTypeOf<Handle<'RenderTarget', 'shared'>>();
110
114
  });
111
115
 
112
- it('DirectionalLight data shape: 3 light fields + castShadow bool + 8 merged shadow fields', () => {
116
+ it('DirectionalLight data shape includes closed shadow quality fields', () => {
113
117
  // feat-20260621: DirectionalLightShadow merged into DirectionalLight via castShadow toggle.
114
118
  // shadowDistance replaced the nearPlane/farPlane pair (near derives from camera).
115
119
  // feat-20260709 M2: direction/color collapsed to array<f32,3> columns.
@@ -126,13 +130,18 @@ describe('w7 type-level - 5 component schemas yield exact data shapes via ShapeO
126
130
  | 'depthBias'
127
131
  | 'normalBias'
128
132
  | 'shadowDistance'
129
- | 'pcfKernelSize'
133
+ | 'shadowFilter'
134
+ | 'shadowAngularRadius'
135
+ | 'maxPenumbraTexels'
130
136
  >();
131
137
  expectTypeOf<Data['direction']>().toEqualTypeOf<Float32Array>();
132
138
  expectTypeOf<Data['color']>().toEqualTypeOf<Float32Array>();
133
139
  expectTypeOf<Data['intensity']>().toEqualTypeOf<number>();
134
140
  // bool column narrows to boolean, not number.
135
141
  expectTypeOf<Data['castShadow']>().toEqualTypeOf<boolean>();
142
+ expectTypeOf<Data['shadowFilter']>().toEqualTypeOf<number>();
143
+ expectTypeOf<Data['shadowAngularRadius']>().toEqualTypeOf<number>();
144
+ expectTypeOf<Data['maxPenumbraTexels']>().toEqualTypeOf<number>();
136
145
  });
137
146
  });
138
147
 
@@ -183,12 +192,13 @@ describe('w7 type-level - component name literal types are preserved', () => {
183
192
  // ────────────────────────────────────────────────────────────────────────────
184
193
 
185
194
  describe('w15 AC-04 type constraint — ShapeOf derivation from field-payload', () => {
186
- it("ShapeOf<SchemaOf<typeof Transform>> yields Float32Array views (all 'array<f32, N>' -> Float32Array)", () => {
195
+ it('ShapeOf<SchemaOf<typeof Transform>> yields local Float32Array views', () => {
187
196
  type T = ShapeOf<SchemaOf<typeof Transform>>;
188
197
  expectTypeOf<T['pos']>().toEqualTypeOf<Float32Array>();
189
198
  expectTypeOf<T['quat']>().toEqualTypeOf<Float32Array>();
190
199
  expectTypeOf<T['scale']>().toEqualTypeOf<Float32Array>();
191
- expectTypeOf<T['world']>().toEqualTypeOf<Float32Array>();
200
+ type GlobalData = ShapeOf<SchemaOf<typeof GlobalTransform>>;
201
+ expectTypeOf<GlobalData['world']>().toEqualTypeOf<Float32Array>();
192
202
  });
193
203
 
194
204
  it("ShapeOf<SchemaOf<typeof Camera>> yields 21 number fields ('f32' -> number) + autoAspect boolean ('bool' -> boolean)", () => {
@@ -65,7 +65,13 @@ import {
65
65
  Tilemap,
66
66
  } from '@forgeax/engine-render/authoring';
67
67
  import * as SceneOwner from '@forgeax/engine-scene';
68
- import { ChildOf, Children, propagateTransforms, Transform } from '@forgeax/engine-scene';
68
+ import {
69
+ ChildOf,
70
+ Children,
71
+ GlobalTransform,
72
+ propagateTransforms,
73
+ Transform,
74
+ } from '@forgeax/engine-scene';
69
75
  import type { Handle, LocalEntityId, SceneAsset, SceneEntity } from '@forgeax/engine-types';
70
76
  import { toShared } from '@forgeax/engine-types';
71
77
  import { describe, expect, expectTypeOf, it } from 'vitest';
@@ -150,17 +156,17 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
150
156
  // abstraction: components are flat-scalar SoA columns, not nested objects).
151
157
 
152
158
  describe('w7 - 5 component schemas register through defineComponent', () => {
153
- it('Transform has pos/quat/scale inline array fields + world array<f32,16>', () => {
159
+ it('Transform has the three authored local array fields', () => {
154
160
  expect(Transform.name).toBe('Transform');
155
- expect(Object.keys(componentSchema(Transform)).length).toBe(4);
161
+ expect(Object.keys(componentSchema(Transform)).length).toBe(3);
156
162
  // pos: inline [x, y, z]
157
163
  expect(componentSchema(Transform).pos).toBe('array<f32, 3>');
158
164
  // quat: inline [x, y, z, w] quaternion
159
165
  expect(componentSchema(Transform).quat).toBe('array<f32, 4>');
160
166
  // scale: inline [x, y, z]
161
167
  expect(componentSchema(Transform).scale).toBe('array<f32, 3>');
162
- // world: resolved mat4 (column-major 16 floats)
163
- expect(componentSchema(Transform).world).toBe('array<f32, 16>');
168
+ expect(GlobalTransform.name).toBe('GlobalTransform');
169
+ expect(componentSchema(GlobalTransform).world).toBe('array<f32, 16>');
164
170
  });
165
171
 
166
172
  it('MeshFilter has 1 shared<MeshAsset> field (assetHandle; M5 / w14)', () => {
@@ -176,9 +182,9 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
176
182
  expect(schemaRecord.materials).toBe('array<shared<MaterialAsset>>');
177
183
  });
178
184
 
179
- it('Camera has 20 fields (17 f32 + historyVersion u32 + clearColor array<f32,4> + autoAspect bool)', () => {
185
+ it('Camera has 21 fields (17 f32 + historyVersion u32 + clearColor array<f32,4> + autoAspect bool + target)', () => {
180
186
  expect(Camera.name).toBe('Camera');
181
- expect(Object.keys(componentSchema(Camera)).length).toBe(20);
187
+ expect(Object.keys(componentSchema(Camera)).length).toBe(21);
182
188
  expect(componentSchema(Camera).fov).toBe('f32');
183
189
  expect(componentSchema(Camera).aspect).toBe('f32');
184
190
  expect(componentSchema(Camera).near).toBe('f32');
@@ -211,13 +217,13 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
211
217
  expect(componentSchema(Camera).autoAspect).toBe('bool');
212
218
  });
213
219
 
214
- it('DirectionalLight has 12 fields: 3 light + castShadow bool + 8 merged shadow f32', () => {
220
+ it('DirectionalLight has 14 fields: 3 light + castShadow bool + closed shadow quality fields', () => {
215
221
  // feat-20260621: DirectionalLightShadow merged into DirectionalLight via castShadow toggle.
216
222
  // shadowDistance replaced the nearPlane/farPlane pair (near derives from camera).
217
223
  // feat-20260709 M2: direction/color collapsed from 6 per-axis scalars to
218
224
  // two array<f32,3> columns (3 light fields: direction + color + intensity).
219
225
  expect(DirectionalLight.name).toBe('DirectionalLight');
220
- expect(Object.keys(componentSchema(DirectionalLight)).length).toBe(12);
226
+ expect(Object.keys(componentSchema(DirectionalLight)).length).toBe(14);
221
227
  // 3 light fields
222
228
  expect(componentSchema(DirectionalLight).direction).toBe('array<f32, 3>');
223
229
  expect(componentSchema(DirectionalLight).color).toBe('array<f32, 3>');
@@ -231,7 +237,10 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
231
237
  expect(componentSchema(DirectionalLight).depthBias).toBe('f32');
232
238
  expect(componentSchema(DirectionalLight).normalBias).toBe('f32');
233
239
  expect(componentSchema(DirectionalLight).shadowDistance).toBe('f32');
234
- expect(componentSchema(DirectionalLight).pcfKernelSize).toBe('f32');
240
+ expect(componentSchema(DirectionalLight).shadowFilter).toBe('enum');
241
+ expect(componentSchema(DirectionalLight).shadowAngularRadius).toBe('f32');
242
+ expect(componentSchema(DirectionalLight).maxPenumbraTexels).toBe('f32');
243
+ expect('pcfKernelSize' in componentSchema(DirectionalLight)).toBe(false);
235
244
  });
236
245
 
237
246
  it('all 5 components are frozen tokens with owner-assigned identities', () => {
@@ -411,9 +420,10 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
411
420
  // file keeps an integration-shape spawn smoke for the
412
421
  // archetype-with-Children path.
413
422
  //
414
- // feat-20260601 M4: GlobalTransform is retired. The resolved world transform
415
- // is the `Transform.world` mat4 column; propagate + hierarchy compose coverage
416
- // lives in render-system-extract.test.ts (Transform.world parent x child).
423
+ // The resolved world transform is the `GlobalTransform.world` mat4 column;
424
+ // Transform's generic ECS requirement materializes that carrier on normal
425
+ // spawn/add paths. Propagation + hierarchy compose coverage lives in
426
+ // render-system-extract.test.ts (GlobalTransform.world parent x child).
417
427
  //
418
428
  // charter mapping: proposition 2 (Bevy ChildOf/Children industry analog) +
419
429
  // proposition 3 (machine-readable schema > prose: grep componentSchema(ChildOf) /
@@ -432,14 +442,10 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
432
442
  expect((componentSchema(Children) as Record<string, unknown>).entities).toBe('array<entity>');
433
443
  });
434
444
 
435
- it('Transform carries the resolved world mat4 column (array<f32, 16>)', () => {
436
- // feat-20260601 M4: the world transform lives on Transform.world, not a
437
- // separate GlobalTransform component. The 3 local-TRS inline array
438
- // columns (pos/quat/scale) plus the `world: array<f32, 16>` resolved
439
- // mat4 are the full shape.
440
- const tKeys = Object.keys(componentSchema(Transform));
445
+ it('GlobalTransform carries the resolved world mat4 column', () => {
446
+ const tKeys = Object.keys(componentSchema(GlobalTransform));
441
447
  expect(tKeys).toContain('world');
442
- expect(componentSchema(Transform).world).toBe('array<f32, 16>');
448
+ expect(componentSchema(GlobalTransform).world).toBe('array<f32, 16>');
443
449
  expect(componentSchema(Transform).pos).toBe('array<f32, 3>');
444
450
  expect(componentSchema(Transform).quat).toBe('array<f32, 4>');
445
451
  expect(componentSchema(Transform).scale).toBe('array<f32, 3>');
@@ -602,7 +608,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
602
608
  //
603
609
  // Two orthogonal guarantees after ChildOf gained its `relationship` block:
604
610
  // AC-25 reader path unchanged -- propagateTransforms still walks ChildOf.parent
605
- // upward and composes child.Transform.world = parent.world x
611
+ // upward and composes child.GlobalTransform.world = parent.world x
606
612
  // compose(child local). The migration must NOT alter this output.
607
613
  // AC-25 mirror auto-maintained -- spawning / removing / reparenting ChildOf
608
614
  // now auto-updates the parent's Children.entities list (the OOS-10 manual-
@@ -634,9 +640,9 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
634
640
  const r = propagateTransforms(world);
635
641
  expect(r.ok).toBe(true);
636
642
 
637
- // feat-20260601: the resolved world transform lives on Transform.world
643
+ // feat-20260601: the resolved world transform lives on GlobalTransform.world
638
644
  // (a 16-float column-major mat4); the translation column is m[12,13,14].
639
- const t = world.get(child, Transform);
645
+ const t = world.get(child, GlobalTransform);
640
646
  expect(t.ok).toBe(true);
641
647
  if (!t.ok) return;
642
648
  const w = t.value.world;
@@ -1408,7 +1414,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
1408
1414
  // ────────────────────────────────────────────────────────────────────────────
1409
1415
 
1410
1416
  describe('Camera.fields reflection (w14 AC-07 SSOT)', () => {
1411
- it('Camera.fields has exactly 20 keys matching the Camera column set', () => {
1417
+ it('Camera.fields has exactly 21 keys matching the Camera column set', () => {
1412
1418
  const keys = Object.keys(Camera.fields).sort();
1413
1419
  expect(keys).toEqual([
1414
1420
  'antialias',
@@ -1428,6 +1434,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
1428
1434
  'near',
1429
1435
  'projection',
1430
1436
  'right',
1437
+ 'target',
1431
1438
  'tonemap',
1432
1439
  'top',
1433
1440
  'whitePoint',
@@ -1443,7 +1450,9 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
1443
1450
  ? 'array<f32, 4>'
1444
1451
  : key === 'historyVersion'
1445
1452
  ? 'u32'
1446
- : 'f32';
1453
+ : key === 'target'
1454
+ ? 'shared<RenderTarget>'
1455
+ : 'f32';
1447
1456
  expect(Camera.fields[key].type).toBe(expected);
1448
1457
  }
1449
1458
  });
@@ -2409,6 +2418,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
2409
2418
  const world = new World();
2410
2419
  const e = world.spawn({ component: Transform, data: {} }).unwrap();
2411
2420
  const row = world.get(e, Transform).unwrap();
2421
+ const global = world.get(e, GlobalTransform).unwrap();
2412
2422
 
2413
2423
  // local pos/quat/scale inline array columns at identity.
2414
2424
  expect(Array.from(row.pos)).toEqual([0, 0, 0]);
@@ -2417,7 +2427,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
2417
2427
 
2418
2428
  // world array<f32,16> resolves to a Float32Array view of 16 contiguous
2419
2429
  // floats. Default fill is the identity mat4 (column-major).
2420
- const w = row.world as Float32Array;
2430
+ const w = global.world as Float32Array;
2421
2431
  expect(w).toBeInstanceOf(Float32Array);
2422
2432
  expect(w.length).toBe(16);
2423
2433
  const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
@@ -36,6 +36,30 @@ describe('runtime renderer host assembly', () => {
36
36
  host.value.renderer.dispose();
37
37
  });
38
38
 
39
+ it('repeats null-device assembly and frame observation without device loss', async () => {
40
+ for (let attempt = 0; attempt < 2; attempt += 1) {
41
+ const host = await constructRendererHost(canvas(), { rhi }, { shaderManifestUrl: manifest });
42
+ expect(host.ok).toBe(true);
43
+ if (!host.ok) continue;
44
+ const world = new World();
45
+ const attached = host.value.renderer.attach(world);
46
+ expect(attached.ok).toBe(true);
47
+ if (!attached.ok) {
48
+ host.value.renderer.dispose();
49
+ continue;
50
+ }
51
+ expect(world.update().ok).toBe(true);
52
+ const frame = host.value.renderer.draw(frameRequest(attached.value));
53
+ expect(frame.ok).toBe(true);
54
+ if (frame.ok) {
55
+ expect((await host.value.renderer.observe(frame.value, { include: ['timings'] })).ok).toBe(
56
+ true,
57
+ );
58
+ }
59
+ host.value.renderer.dispose();
60
+ }
61
+ });
62
+
39
63
  it('keeps runtime construction errors structured', async () => {
40
64
  const result = await createRenderer(canvas(), { rhi: undefined });
41
65
  expect(result.ok).toBe(false);
@@ -0,0 +1,32 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import type { GpuPassTimingOptions, RendererOptions } from '@forgeax/engine-render';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ describe('runtime GPU pass timing forwarding', () => {
7
+ it('keeps one option object and projects the exact field to Render', () => {
8
+ const gpuPassTiming = { maxPassesPerFrame: 8 } satisfies GpuPassTimingOptions;
9
+ const options = { gpuPassTiming } satisfies RendererOptions;
10
+ expect(options.gpuPassTiming).toBe(gpuPassTiming);
11
+ });
12
+
13
+ it('forwards without owning admission, sessions, handles, or CPU capture', () => {
14
+ const source = readFileSync(
15
+ fileURLToPath(new URL('../createRenderer.ts', import.meta.url)),
16
+ 'utf8',
17
+ );
18
+ expect(source).toContain('gpuPassTiming: options.gpuPassTiming');
19
+ expect(source).not.toContain('createGpuPassTimingSession');
20
+ expect(source).not.toContain('ProfileCapture');
21
+ expect(source).not.toContain('passCatalog');
22
+ });
23
+
24
+ it('keeps the no-option path free of a timing field', () => {
25
+ const source = readFileSync(
26
+ fileURLToPath(new URL('../createRenderer.ts', import.meta.url)),
27
+ 'utf8',
28
+ );
29
+ expect(source).toContain('options === undefined');
30
+ expect(source).toContain('gpuPassTiming: options.gpuPassTiming');
31
+ });
32
+ });
@@ -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
@@ -325,7 +331,9 @@ function midBandDistance(a: CapturedFrame, b: CapturedFrame): number {
325
331
  }
326
332
 
327
333
  describe('feat-20260527-sprite-nineslice w14 dawn smoke (stretch vs tile divergence)', () => {
328
- 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 () => {
329
337
  // Asymmetric scale: x=4 vs y=1. With slices=[0.25,0.25,0.25,0.25] and the
330
338
  // 4x4 corner-checker texture, the post-w15 9-slice path keeps corner
331
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;