@forgeax/engine-runtime 0.1.21 → 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 (57) hide show
  1. package/README.md +3 -3
  2. package/dist/__tests__/helpers/register-runtime-components.d.ts.map +1 -1
  3. package/dist/__tests__/helpers/register-scene-components.d.ts.map +1 -1
  4. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  5. package/dist/createRenderer.d.ts.map +1 -1
  6. package/dist/index.mjs +3 -2
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/sprite-param-values.d.ts +1 -1
  9. package/package.json +30 -30
  10. package/src/__tests__/animation-graph-asset-union.test-d.ts +2 -0
  11. package/src/__tests__/asset-brand-and-name-retirement.test.ts +4 -3
  12. package/src/__tests__/asset-registry-scene.test-d.ts +1 -0
  13. package/src/__tests__/asset.unit.test.ts +5 -2
  14. package/src/__tests__/collect-transient-roundtrip.test.ts +14 -3
  15. package/src/__tests__/components.test-d.ts +9 -6
  16. package/src/__tests__/components.unit.test.ts +23 -19
  17. package/src/__tests__/errors.unit.test.ts +3 -0
  18. package/src/__tests__/extract-frames-merge.test.ts +3 -0
  19. package/src/__tests__/helpers/register-runtime-components.ts +9 -1
  20. package/src/__tests__/helpers/register-scene-components.ts +9 -2
  21. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +8 -10
  22. package/src/__tests__/light-snapshot.test-d.ts +27 -1
  23. package/src/__tests__/lights-preservation.fixup.test.ts +19 -21
  24. package/src/__tests__/lights.unit.test.ts +2076 -1
  25. package/src/__tests__/loader-name-survival.test.ts +8 -9
  26. package/src/__tests__/package-promotion.test.ts +7 -11
  27. package/src/__tests__/pbr-pipeline.unit.test.ts +16 -16
  28. package/src/__tests__/pipeline.unit.test.ts +20 -24
  29. package/src/__tests__/point-light-shadow.browser.test.ts +1 -0
  30. package/src/__tests__/point-light-shadow.unit.test.ts +18 -98
  31. package/src/__tests__/propagate-transforms.dawn.test.ts +15 -16
  32. package/src/__tests__/record-worldid-isolation.test.ts +16 -32
  33. package/src/__tests__/render-error-exhaustive.test-d.ts +10 -0
  34. package/src/__tests__/render-system-extract.test.ts +5 -5
  35. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +1 -0
  36. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +1 -0
  37. package/src/__tests__/renderer-surface.unit.test.ts +0 -2
  38. package/src/__tests__/resolveName-unified-entry.test.ts +6 -6
  39. package/src/__tests__/resolveName.test.ts +14 -2
  40. package/src/__tests__/shadow-csm-cascade-loadop.test.ts +1 -0
  41. package/src/__tests__/shadow-csm-tile-consistency.test.ts +1 -0
  42. package/src/__tests__/shadow-csm-ubo.test.ts +3 -1
  43. package/src/__tests__/shadow-fields-observable.dawn.test.ts +15 -4
  44. package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +1 -1
  45. package/src/__tests__/skin-parented-double-transform.test.ts +5 -5
  46. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +1 -6
  47. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -13
  48. package/src/__tests__/ssao-bgl.test.ts +2 -1
  49. package/src/__tests__/systems.unit.test.ts +25 -174
  50. package/src/__tests__/template-game-default-api-contract.test.ts +45 -23
  51. package/src/__tests__/tilemap-chunk-extract.propagate-order.test.ts +10 -4
  52. package/src/__tests__/transform-hierarchy-pixel-diff.dawn.test.ts +2 -2
  53. package/src/collect-scene-asset.ts +1 -1
  54. package/src/components/__tests__/transform-vec-schema.test.ts +2 -2
  55. package/src/createRenderer.ts +1 -0
  56. package/src/sprite-param-values.ts +1 -1
  57. package/dist/.tsbuildinfo +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/engine-runtime",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -26,37 +26,37 @@
26
26
  "LICENSE"
27
27
  ],
28
28
  "dependencies": {
29
- "@forgeax/engine-animation": "0.1.21",
30
- "@forgeax/engine-assets-runtime": "0.1.21",
31
- "@forgeax/engine-audio-webaudio": "0.1.21",
32
- "@forgeax/engine-codec": "0.1.21",
33
- "@forgeax/engine-debug-draw": "0.1.21",
34
- "@forgeax/engine-ecs": "0.1.21",
35
- "@forgeax/engine-font": "0.1.21",
36
- "@forgeax/engine-geometry": "0.1.21",
37
- "@forgeax/engine-graphics-extras": "0.1.21",
38
- "@forgeax/engine-image": "0.1.21",
39
- "@forgeax/engine-input": "0.1.21",
40
- "@forgeax/engine-math": "0.1.21",
41
- "@forgeax/engine-pack": "0.1.21",
42
- "@forgeax/engine-profiler": "0.1.21",
43
- "@forgeax/engine-render": "0.1.21",
44
- "@forgeax/engine-render-graph": "0.1.21",
45
- "@forgeax/engine-rhi": "0.1.21",
46
- "@forgeax/engine-rhi-null": "0.1.21",
47
- "@forgeax/engine-rhi-webgpu": "0.1.21",
48
- "@forgeax/engine-rhi-wgpu": "0.1.21",
49
- "@forgeax/engine-scene": "0.1.21",
50
- "@forgeax/engine-shader": "0.1.21",
51
- "@forgeax/engine-skinning": "0.1.21",
52
- "@forgeax/engine-types": "0.1.21",
53
- "@forgeax/engine-vfx": "0.1.21"
29
+ "@forgeax/engine-animation": "0.1.23",
30
+ "@forgeax/engine-assets-runtime": "0.1.23",
31
+ "@forgeax/engine-audio-webaudio": "0.1.23",
32
+ "@forgeax/engine-codec": "0.1.23",
33
+ "@forgeax/engine-debug-draw": "0.1.23",
34
+ "@forgeax/engine-ecs": "0.1.23",
35
+ "@forgeax/engine-font": "0.1.23",
36
+ "@forgeax/engine-geometry": "0.1.23",
37
+ "@forgeax/engine-graphics-extras": "0.1.23",
38
+ "@forgeax/engine-image": "0.1.23",
39
+ "@forgeax/engine-input": "0.1.23",
40
+ "@forgeax/engine-math": "0.1.23",
41
+ "@forgeax/engine-pack": "0.1.23",
42
+ "@forgeax/engine-profiler": "0.1.23",
43
+ "@forgeax/engine-render": "0.1.23",
44
+ "@forgeax/engine-render-graph": "0.1.23",
45
+ "@forgeax/engine-rhi": "0.1.23",
46
+ "@forgeax/engine-rhi-null": "0.1.23",
47
+ "@forgeax/engine-rhi-webgpu": "0.1.23",
48
+ "@forgeax/engine-rhi-wgpu": "0.1.23",
49
+ "@forgeax/engine-scene": "0.1.23",
50
+ "@forgeax/engine-shader": "0.1.23",
51
+ "@forgeax/engine-skinning": "0.1.23",
52
+ "@forgeax/engine-types": "0.1.23",
53
+ "@forgeax/engine-vfx": "0.1.23"
54
54
  },
55
55
  "devDependencies": {
56
- "@forgeax/engine-import": "0.1.21",
57
- "@forgeax/engine-naga": "0.1.21",
58
- "@forgeax/engine-state": "0.1.21",
59
- "@forgeax/engine-vite-plugin-shader": "0.1.21",
56
+ "@forgeax/engine-import": "0.1.23",
57
+ "@forgeax/engine-naga": "0.1.23",
58
+ "@forgeax/engine-state": "0.1.23",
59
+ "@forgeax/engine-vite-plugin-shader": "0.1.23",
60
60
  "@types/node": "^20.14.0"
61
61
  },
62
62
  "forgeax": {
@@ -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
  });
@@ -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();
@@ -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.
@@ -190,12 +192,13 @@ describe('w7 type-level - component name literal types are preserved', () => {
190
192
  // ────────────────────────────────────────────────────────────────────────────
191
193
 
192
194
  describe('w15 AC-04 type constraint — ShapeOf derivation from field-payload', () => {
193
- it("ShapeOf<SchemaOf<typeof Transform>> yields Float32Array views (all 'array<f32, N>' -> Float32Array)", () => {
195
+ it('ShapeOf<SchemaOf<typeof Transform>> yields local Float32Array views', () => {
194
196
  type T = ShapeOf<SchemaOf<typeof Transform>>;
195
197
  expectTypeOf<T['pos']>().toEqualTypeOf<Float32Array>();
196
198
  expectTypeOf<T['quat']>().toEqualTypeOf<Float32Array>();
197
199
  expectTypeOf<T['scale']>().toEqualTypeOf<Float32Array>();
198
- expectTypeOf<T['world']>().toEqualTypeOf<Float32Array>();
200
+ type GlobalData = ShapeOf<SchemaOf<typeof GlobalTransform>>;
201
+ expectTypeOf<GlobalData['world']>().toEqualTypeOf<Float32Array>();
199
202
  });
200
203
 
201
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)', () => {
@@ -414,9 +420,10 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
414
420
  // file keeps an integration-shape spawn smoke for the
415
421
  // archetype-with-Children path.
416
422
  //
417
- // feat-20260601 M4: GlobalTransform is retired. The resolved world transform
418
- // is the `Transform.world` mat4 column; propagate + hierarchy compose coverage
419
- // 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).
420
427
  //
421
428
  // charter mapping: proposition 2 (Bevy ChildOf/Children industry analog) +
422
429
  // proposition 3 (machine-readable schema > prose: grep componentSchema(ChildOf) /
@@ -435,14 +442,10 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
435
442
  expect((componentSchema(Children) as Record<string, unknown>).entities).toBe('array<entity>');
436
443
  });
437
444
 
438
- it('Transform carries the resolved world mat4 column (array<f32, 16>)', () => {
439
- // feat-20260601 M4: the world transform lives on Transform.world, not a
440
- // separate GlobalTransform component. The 3 local-TRS inline array
441
- // columns (pos/quat/scale) plus the `world: array<f32, 16>` resolved
442
- // mat4 are the full shape.
443
- const tKeys = Object.keys(componentSchema(Transform));
445
+ it('GlobalTransform carries the resolved world mat4 column', () => {
446
+ const tKeys = Object.keys(componentSchema(GlobalTransform));
444
447
  expect(tKeys).toContain('world');
445
- expect(componentSchema(Transform).world).toBe('array<f32, 16>');
448
+ expect(componentSchema(GlobalTransform).world).toBe('array<f32, 16>');
446
449
  expect(componentSchema(Transform).pos).toBe('array<f32, 3>');
447
450
  expect(componentSchema(Transform).quat).toBe('array<f32, 4>');
448
451
  expect(componentSchema(Transform).scale).toBe('array<f32, 3>');
@@ -605,7 +608,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
605
608
  //
606
609
  // Two orthogonal guarantees after ChildOf gained its `relationship` block:
607
610
  // AC-25 reader path unchanged -- propagateTransforms still walks ChildOf.parent
608
- // upward and composes child.Transform.world = parent.world x
611
+ // upward and composes child.GlobalTransform.world = parent.world x
609
612
  // compose(child local). The migration must NOT alter this output.
610
613
  // AC-25 mirror auto-maintained -- spawning / removing / reparenting ChildOf
611
614
  // now auto-updates the parent's Children.entities list (the OOS-10 manual-
@@ -637,9 +640,9 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
637
640
  const r = propagateTransforms(world);
638
641
  expect(r.ok).toBe(true);
639
642
 
640
- // feat-20260601: the resolved world transform lives on Transform.world
643
+ // feat-20260601: the resolved world transform lives on GlobalTransform.world
641
644
  // (a 16-float column-major mat4); the translation column is m[12,13,14].
642
- const t = world.get(child, Transform);
645
+ const t = world.get(child, GlobalTransform);
643
646
  expect(t.ok).toBe(true);
644
647
  if (!t.ok) return;
645
648
  const w = t.value.world;
@@ -2415,6 +2418,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
2415
2418
  const world = new World();
2416
2419
  const e = world.spawn({ component: Transform, data: {} }).unwrap();
2417
2420
  const row = world.get(e, Transform).unwrap();
2421
+ const global = world.get(e, GlobalTransform).unwrap();
2418
2422
 
2419
2423
  // local pos/quat/scale inline array columns at identity.
2420
2424
  expect(Array.from(row.pos)).toEqual([0, 0, 0]);
@@ -2423,7 +2427,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
2423
2427
 
2424
2428
  // world array<f32,16> resolves to a Float32Array view of 16 contiguous
2425
2429
  // floats. Default fill is the identity mat4 (column-major).
2426
- const w = row.world as Float32Array;
2430
+ const w = global.world as Float32Array;
2427
2431
  expect(w).toBeInstanceOf(Float32Array);
2428
2432
  expect(w.length).toBe(16);
2429
2433
  const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
@@ -547,6 +547,8 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
547
547
  return 'points lines budget exceeded';
548
548
  case 'points-lines-prepare-failed':
549
549
  return 'points lines prepare failed';
550
+ case 'light-resource-unavailable':
551
+ return 'light resource unavailable';
550
552
  case 'transmission-capability-missing':
551
553
  return 'transmission capability missing';
552
554
  case 'projector-binding-failed':
@@ -719,6 +721,7 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
719
721
  return 'ok';
720
722
  case 'points-lines-prepare-failed':
721
723
  return 'ok';
724
+ case 'light-resource-unavailable':
722
725
  case 'transmission-capability-missing':
723
726
  return 'ok';
724
727
  case 'projector-binding-failed':
@@ -161,11 +161,13 @@ function simulateMerge(
161
161
  // AC-04: lights — point[]/spot[] concat; directional first-hit in worlds[] order; directionalCount sum
162
162
  const point: PointLightSnapshot[] = [];
163
163
  const spot: SpotLightSnapshot[] = [];
164
+ const rect: ExtractedLights['rect'][number][] = [];
164
165
  let directional: DirectionalLightSnapshot | undefined;
165
166
  let directionalCount = 0;
166
167
  for (const f of frames) {
167
168
  for (const p of f.lights.point) point.push(p);
168
169
  for (const s of f.lights.spot) spot.push(s);
170
+ for (const r of f.lights.rect) rect.push(r);
169
171
  if (directional === undefined && f.lights.directional !== undefined) {
170
172
  directional = f.lights.directional;
171
173
  }
@@ -179,6 +181,7 @@ function simulateMerge(
179
181
  directionalCount,
180
182
  point,
181
183
  spot,
184
+ rect,
182
185
  lightViewProj: firstDirectionalLights?.lightViewProj,
183
186
  splitPlanes: firstDirectionalLights?.splitPlanes,
184
187
  cascadeCount: firstDirectionalLights?.cascadeCount,
@@ -24,7 +24,14 @@ import {
24
24
  TileLayer,
25
25
  Tilemap,
26
26
  } from '@forgeax/engine-render/authoring';
27
- import { ChildOf, Children, MorphWeights, Name, Transform } from '@forgeax/engine-scene';
27
+ import {
28
+ ChildOf,
29
+ Children,
30
+ GlobalTransform,
31
+ MorphWeights,
32
+ Name,
33
+ Transform,
34
+ } from '@forgeax/engine-scene';
28
35
 
29
36
  const RUNTIME_COMPONENTS: readonly Component[] = [
30
37
  Camera,
@@ -51,6 +58,7 @@ const RUNTIME_COMPONENTS: readonly Component[] = [
51
58
  TileLayer,
52
59
  Tilemap,
53
60
  Transform,
61
+ GlobalTransform,
54
62
  VideoPlayer,
55
63
  Visibility,
56
64
  ];
@@ -1,10 +1,17 @@
1
1
  import type { Component, World } from '@forgeax/engine-ecs';
2
2
  import { SceneInstance } from '@forgeax/engine-render';
3
- import { ChildOf, Children, Transform } from '@forgeax/engine-scene';
3
+ import { ChildOf, Children, GlobalTransform, Transform } from '@forgeax/engine-scene';
4
4
 
5
5
  /** Register the scene vocabulary explicitly for an isolated test World. */
6
6
  export function registerSceneComponents(world: World, extras: readonly Component[] = []): void {
7
- for (const component of [SceneInstance, ChildOf, Children, Transform, ...extras]) {
7
+ for (const component of [
8
+ SceneInstance,
9
+ ChildOf,
10
+ Children,
11
+ Transform,
12
+ GlobalTransform,
13
+ ...extras,
14
+ ]) {
8
15
  world.components.register(component).unwrap();
9
16
  }
10
17
  }
@@ -7,8 +7,8 @@
7
7
  // lock the four light GPU pack paths' byte output so the collapse is proven
8
8
  // byte-neutral end to end:
9
9
  // 1. View UBO (DirectionalLight direction/color lanes) -> writeViewUbo
10
- // 2. PointLightBuffer (color lanes) -> packPointLight
11
- // 3. SpotLightBuffer (direction + color lanes) -> packSpotLight
10
+ // 2. PointLightBuffer (color lanes) -> packDirectLightSlot
11
+ // 3. SpotLightBuffer (direction + color lanes) -> packDirectLightSlot
12
12
  // 4. Skylight uniform/ambient (color destructure) -> SkylightSnapshot
13
13
  // (Camera clear pass is M3, not here.)
14
14
 
@@ -16,7 +16,7 @@ import { vec3 } from '@forgeax/engine-math';
16
16
  import type { Buffer, RhiQueue } from '@forgeax/engine-rhi';
17
17
  import { ok } from '@forgeax/engine-rhi';
18
18
  import { describe, expect, it } from 'vitest';
19
- import { packPointLight, packSpotLight } from '../../../render/src/light-buffer-layout';
19
+ import { packDirectLightSlot } from '../../../render/src/light-buffer-layout';
20
20
  import { writeViewUbo } from '../../../render/src/record/view-ubo';
21
21
  import type { CameraSnapshot } from '../../../render/src/render-contract';
22
22
  import type {
@@ -103,7 +103,7 @@ describe('w7 -- light GPU pack byte-neutral (AC-11)', () => {
103
103
  expect(payload[22]).toBeCloseTo(0.7, 5);
104
104
  });
105
105
 
106
- it('packPointLight writes color into slots 4..6 byte-for-byte (32 B)', () => {
106
+ it('packDirectLightSlot writes Point color into slots 4..6 byte-for-byte (80 B)', () => {
107
107
  const snap: PointLightSnapshot = {
108
108
  kind: 'point',
109
109
  position: vec3.create(1.5, -2.25, 0.125),
@@ -111,14 +111,14 @@ describe('w7 -- light GPU pack byte-neutral (AC-11)', () => {
111
111
  intensity: 2,
112
112
  invRangeSquared: 0.04,
113
113
  };
114
- const out = packPointLight(snap);
115
- expect(out.byteLength).toBe(32);
114
+ const out = packDirectLightSlot(snap);
115
+ expect(out.byteLength).toBe(80);
116
116
  expect(out[4]).toBeCloseTo(0.4, 6);
117
117
  expect(out[5]).toBeCloseTo(0.5, 6);
118
118
  expect(out[6]).toBeCloseTo(0.6, 6);
119
119
  });
120
120
 
121
- it('packSpotLight writes color slots 4..6 + direction slots 8..10 byte-for-byte (80 B)', () => {
121
+ it('packDirectLightSlot writes Spot color slots 4..6 + direction slots 8..10 byte-for-byte (80 B)', () => {
122
122
  const snap: SpotLightSnapshot = {
123
123
  kind: 'spot',
124
124
  position: vec3.create(0, 5, 0),
@@ -135,9 +135,7 @@ describe('w7 -- light GPU pack byte-neutral (AC-11)', () => {
135
135
  farPlane: 50,
136
136
  shadowAtlasTile: -1,
137
137
  };
138
- const out = packSpotLight(snap);
139
- // SpotLight carries projector receiver controls and the shadow intensity
140
- // tail in the current std430 payload.
138
+ const out = packDirectLightSlot(snap);
141
139
  expect(out.byteLength).toBe(80);
142
140
  expect(out[4]).toBeCloseTo(0.3, 6);
143
141
  expect(out[5]).toBeCloseTo(0.4, 6);
@@ -23,11 +23,16 @@
23
23
  import { expectTypeOf, test } from 'vitest';
24
24
  import type { DirectionalShadowQuality } from '../../../render/src/components/directional-shadow-filter';
25
25
  import type { LightValidationError } from '../../../render/src/components/light-helpers';
26
+ import type {
27
+ DirectLightSlotKind,
28
+ DirectLightSnapshot,
29
+ } from '../../../render/src/light-buffer-layout';
26
30
  import type {
27
31
  DirectionalLightSnapshot,
28
32
  ExtractedLights,
29
33
  LightSnapshot,
30
34
  PointLightSnapshot,
35
+ RectAreaDirectLightSnapshot,
31
36
  SpotLightSnapshot,
32
37
  } from '../../../render/src/render-system-extract';
33
38
 
@@ -46,6 +51,8 @@ function consumeSnapshot(s: LightSnapshot): number {
46
51
  return s.invRangeSquared;
47
52
  case 'spot':
48
53
  return s.cosInner + s.cosOuter;
54
+ case 'rect-area':
55
+ return s.halfWidth + s.halfHeight;
49
56
  default:
50
57
  return assertNever(s);
51
58
  }
@@ -53,7 +60,7 @@ function consumeSnapshot(s: LightSnapshot): number {
53
60
 
54
61
  test('LightSnapshot is a discriminated union on kind', () => {
55
62
  expectTypeOf<LightSnapshot>().toEqualTypeOf<
56
- DirectionalLightSnapshot | PointLightSnapshot | SpotLightSnapshot
63
+ DirectionalLightSnapshot | PointLightSnapshot | SpotLightSnapshot | RectAreaDirectLightSnapshot
57
64
  >();
58
65
  // Smoke that the consumer compiles (run-time noop; compile-time exhaustive).
59
66
  void consumeSnapshot;
@@ -83,6 +90,25 @@ test('ExtractedLights three-bucket shape', () => {
83
90
  expectTypeOf<ExtractedLights['directionalCount']>().toEqualTypeOf<number>();
84
91
  expectTypeOf<ExtractedLights['point']>().toEqualTypeOf<readonly PointLightSnapshot[]>();
85
92
  expectTypeOf<ExtractedLights['spot']>().toEqualTypeOf<readonly SpotLightSnapshot[]>();
93
+ expectTypeOf<ExtractedLights['rect']>().toEqualTypeOf<readonly RectAreaDirectLightSnapshot[]>();
94
+ });
95
+
96
+ function consumeDirectLight(snapshot: DirectLightSnapshot): number {
97
+ switch (snapshot.kind) {
98
+ case 'point':
99
+ return snapshot.invRangeSquared;
100
+ case 'spot':
101
+ return snapshot.cosInner + snapshot.cosOuter;
102
+ case 'rect-area':
103
+ return snapshot.halfWidth + snapshot.halfHeight;
104
+ default:
105
+ return assertNever(snapshot);
106
+ }
107
+ }
108
+
109
+ test('DirectLightSlot kind is closed and includes only finite direct lights', () => {
110
+ expectTypeOf<DirectLightSlotKind>().toEqualTypeOf<'point' | 'spot' | 'rect-area'>();
111
+ void consumeDirectLight;
86
112
  expectTypeOf<ExtractedLights['directionalShadowQuality']>().toEqualTypeOf<
87
113
  DirectionalShadowQuality | undefined
88
114
  >();
@@ -9,11 +9,7 @@ import {
9
9
  validatePointLightShadowData,
10
10
  validateSpotLightData,
11
11
  } from '../../../render/src/components/light-helpers';
12
- import {
13
- packLightSlot,
14
- packPointLight,
15
- packSpotLight,
16
- } from '../../../render/src/light-buffer-layout';
12
+ import { packDirectLightSlot } from '../../../render/src/light-buffer-layout';
17
13
  import { buildPbrViewBglEntries } from '../../../render/src/pbr-pipeline';
18
14
  import type {
19
15
  PointLightSnapshot,
@@ -87,32 +83,34 @@ describe('Point/Spot and shared-light regression guard', () => {
87
83
  expect(degToCos(60)).toBeCloseTo(0.5, 8);
88
84
  });
89
85
 
90
- it('keeps Point/Spot packing and the shared LightSlot discriminant intact', () => {
86
+ it('keeps Point/Spot packing in the shared DirectLightSlot owner', () => {
91
87
  const point = pointSnapshot();
92
88
  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);
89
+ const pointSlot = packDirectLightSlot({ ...point, shadowAtlasLayer: 2 });
90
+ const spotSlot = packDirectLightSlot(spot);
97
91
 
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);
92
+ expect(pointSlot.byteLength).toBe(80);
93
+ expect(spotSlot.byteLength).toBe(80);
94
+ expect(new Uint32Array(pointSlot.buffer)[16]).toBe(0);
95
+ expect(new Uint32Array(pointSlot.buffer)[17]).toBe(2);
96
+ expect(new Uint32Array(spotSlot.buffer)[16]).toBe(1);
97
+ expect(new Uint32Array(spotSlot.buffer)[17]).toBe(0xffffffff);
104
98
  });
105
99
 
106
- it('keeps shared PBR view bindings for both storage and uniform light routes', () => {
100
+ it('keeps the PBR view layout independent from cluster light storage capability', () => {
107
101
  const storageBindings = buildPbrViewBglEntries({ storageBuffer: true });
108
102
  const uniformBindings = buildPbrViewBglEntries({ storageBuffer: false });
109
103
  expect(storageBindings.map((entry) => entry.binding)).toEqual(
110
104
  uniformBindings.map((entry) => entry.binding),
111
105
  );
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');
106
+ // Local lights are consumed through the cluster payload in group(2). The
107
+ // shared view group therefore has no legacy direct-light storage/uniform
108
+ // entries at bindings 1/2, and its shape cannot drift with the capability
109
+ // route selected for the cluster buffers.
110
+ expect(storageBindings.find((entry) => entry.binding === 1)).toBeUndefined();
111
+ expect(storageBindings.find((entry) => entry.binding === 2)).toBeUndefined();
112
+ expect(uniformBindings.find((entry) => entry.binding === 1)).toBeUndefined();
113
+ expect(uniformBindings.find((entry) => entry.binding === 2)).toBeUndefined();
116
114
  });
117
115
 
118
116
  it('keeps Point/Spot pcfKernelSize validation independent of Directional quality', () => {