@forgeax/engine-render 0.1.19 → 0.1.20

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 (174) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/assembly/factory.d.ts +16 -5
  3. package/dist/assembly/factory.d.ts.map +1 -1
  4. package/dist/{chunk-RJRUS55L.mjs → chunk-4XINDLAY.mjs} +267 -186
  5. package/dist/chunk-4XINDLAY.mjs.map +1 -0
  6. package/dist/{chunk-YTQ5C7YQ.mjs → chunk-CDTWTHZR.mjs} +176 -8
  7. package/dist/chunk-CDTWTHZR.mjs.map +1 -0
  8. package/dist/{chunk-IPXCABEP.mjs → chunk-M2MP4ZOU.mjs} +2 -2
  9. package/dist/chunk-M2MP4ZOU.mjs.map +1 -0
  10. package/dist/{chunk-LOSO4CTV.mjs → chunk-TBQJOK2L.mjs} +67 -3
  11. package/dist/chunk-TBQJOK2L.mjs.map +1 -0
  12. package/dist/components/light-helpers.d.ts.map +1 -1
  13. package/dist/components/spot-light.d.ts +12 -0
  14. package/dist/components/spot-light.d.ts.map +1 -1
  15. package/dist/construct-renderer.mjs +2281 -164
  16. package/dist/construct-renderer.mjs.map +1 -1
  17. package/dist/device/gpu-residency.d.ts +19 -2
  18. package/dist/device/gpu-residency.d.ts.map +1 -1
  19. package/dist/errors/render.d.ts +69 -1
  20. package/dist/errors/render.d.ts.map +1 -1
  21. package/dist/gpu-usage.d.ts +2 -0
  22. package/dist/gpu-usage.d.ts.map +1 -1
  23. package/dist/index.d.ts +7 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.mjs +3 -3
  26. package/dist/internal.mjs +1 -1
  27. package/dist/light-buffer-layout.d.ts +20 -12
  28. package/dist/light-buffer-layout.d.ts.map +1 -1
  29. package/dist/pbr-pipeline.d.ts +6 -3
  30. package/dist/pbr-pipeline.d.ts.map +1 -1
  31. package/dist/pipeline/standard-forward-lane.d.ts.map +1 -1
  32. package/dist/pipeline/standard-pipeline.d.ts.map +1 -1
  33. package/dist/pipeline/standard-profile.d.ts +13 -0
  34. package/dist/pipeline/standard-profile.d.ts.map +1 -1
  35. package/dist/record/frame-lighting.d.ts +2 -0
  36. package/dist/record/frame-lighting.d.ts.map +1 -1
  37. package/dist/record/frame-snapshot.d.ts +37 -1
  38. package/dist/record/frame-snapshot.d.ts.map +1 -1
  39. package/dist/record/frame-targets.d.ts.map +1 -1
  40. package/dist/record/frame.d.ts +3 -2
  41. package/dist/record/frame.d.ts.map +1 -1
  42. package/dist/record/gpu-timing.d.ts +32 -0
  43. package/dist/record/gpu-timing.d.ts.map +1 -0
  44. package/dist/record/main-pass-material.d.ts +1 -1
  45. package/dist/record/main-pass-material.d.ts.map +1 -1
  46. package/dist/record/render-context.d.ts +5 -1
  47. package/dist/record/render-context.d.ts.map +1 -1
  48. package/dist/record/shadow-pass.d.ts.map +1 -1
  49. package/dist/record/typed-frame-graph.d.ts +11 -3
  50. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  51. package/dist/render-contract.d.ts +66 -1
  52. package/dist/render-contract.d.ts.map +1 -1
  53. package/dist/render-data.d.ts +10 -6
  54. package/dist/render-data.d.ts.map +1 -1
  55. package/dist/render-pipeline.d.ts +36 -0
  56. package/dist/render-pipeline.d.ts.map +1 -1
  57. package/dist/render-system-extract.d.ts +55 -3
  58. package/dist/render-system-extract.d.ts.map +1 -1
  59. package/dist/render-system.d.ts +7 -1
  60. package/dist/render-system.d.ts.map +1 -1
  61. package/dist/renderer-inspect.d.ts +29 -0
  62. package/dist/renderer-inspect.d.ts.map +1 -0
  63. package/dist/scene/render-scene.d.ts.map +1 -1
  64. package/dist/temporal/frame.d.ts +44 -0
  65. package/dist/temporal/frame.d.ts.map +1 -0
  66. package/dist/temporal/index.mjs +3 -3
  67. package/dist/typed-render-graph-primitives.d.ts +6 -1
  68. package/dist/typed-render-graph-primitives.d.ts.map +1 -1
  69. package/dist/typed-shadow-atlas.d.ts +10 -0
  70. package/dist/typed-shadow-atlas.d.ts.map +1 -0
  71. package/dist/typed-shadow-passes.d.ts.map +1 -1
  72. package/dist/volume/capability.d.ts +57 -0
  73. package/dist/volume/capability.d.ts.map +1 -0
  74. package/dist/volume/component.d.ts +61 -0
  75. package/dist/volume/component.d.ts.map +1 -0
  76. package/dist/volume/extract.d.ts +11 -0
  77. package/dist/volume/extract.d.ts.map +1 -0
  78. package/dist/volume/inspection.d.ts +38 -0
  79. package/dist/volume/inspection.d.ts.map +1 -0
  80. package/dist/volume/passes.d.ts +38 -0
  81. package/dist/volume/passes.d.ts.map +1 -0
  82. package/dist/volume/recovery.d.ts +38 -0
  83. package/dist/volume/recovery.d.ts.map +1 -0
  84. package/dist/volume/resources.d.ts +57 -0
  85. package/dist/volume/resources.d.ts.map +1 -0
  86. package/dist/volume/temporal.d.ts +35 -0
  87. package/dist/volume/temporal.d.ts.map +1 -0
  88. package/package.json +20 -20
  89. package/src/__tests__/ktx2-basis-gpu-consumer.dawn.test.ts +14 -14
  90. package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +1 -1
  91. package/src/__tests__/render-error-code-owner.test-d.ts +5 -0
  92. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +28 -0
  93. package/src/__tests__/renderer-frame-transaction.integration.test.ts +1 -1
  94. package/src/__tests__/skinned-shadow-caster.test.ts +21 -0
  95. package/src/__tests__/standard-profile-oracle.unit.test.ts +7 -5
  96. package/src/__tests__/volume-optics-oracle.unit.test.ts +85 -0
  97. package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +655 -0
  98. package/src/__tests__/volumetric-fog-component.unit.test.ts +71 -0
  99. package/src/__tests__/volumetric-fog-composite-stage-readback.dawn.test.ts +376 -0
  100. package/src/__tests__/volumetric-fog-errors.unit.test.ts +51 -0
  101. package/src/__tests__/volumetric-fog-inspection.unit.test.ts +77 -0
  102. package/src/__tests__/volumetric-fog-light-capability.unit.test.ts +19 -0
  103. package/src/__tests__/volumetric-fog-light-consumers.integration.test.ts +18 -0
  104. package/src/__tests__/volumetric-fog-light-errors.unit.test.ts +17 -0
  105. package/src/__tests__/volumetric-fog-light-topology.unit.test.ts +21 -0
  106. package/src/__tests__/volumetric-fog-light-transaction.integration.test.ts +53 -0
  107. package/src/__tests__/volumetric-fog-m9-watcher.integration.test.ts +27 -0
  108. package/src/__tests__/volumetric-fog-point-light-owner.integration.test.ts +44 -0
  109. package/src/__tests__/volumetric-fog-point-spot-accumulation.integration.test.ts +49 -0
  110. package/src/__tests__/volumetric-fog-point-spot-contract.unit.test.ts +28 -0
  111. package/src/__tests__/volumetric-fog-point-spot-lkg.integration.test.ts +65 -0
  112. package/src/__tests__/volumetric-fog-point-spot-selection.integration.test.ts +49 -0
  113. package/src/__tests__/volumetric-fog-point-spot-temporal.unit.test.ts +55 -0
  114. package/src/__tests__/volumetric-fog-projector-contract.unit.test.ts +22 -0
  115. package/src/__tests__/volumetric-fog-projector-inspection.unit.test.ts +12 -0
  116. package/src/__tests__/volumetric-fog-projector-surface-volume.integration.test.ts +11 -0
  117. package/src/__tests__/volumetric-fog-projector-transaction.integration.test.ts +26 -0
  118. package/src/__tests__/volumetric-fog-recovery.integration.test.ts +47 -0
  119. package/src/__tests__/volumetric-fog-selected-light-resolution.integration.test.ts +63 -0
  120. package/src/__tests__/volumetric-fog-spot-closure.integration.test.ts +26 -0
  121. package/src/__tests__/volumetric-fog-spot-contract.test-d.ts +21 -0
  122. package/src/__tests__/volumetric-fog-spot-contract.unit.test.ts +27 -0
  123. package/src/__tests__/volumetric-fog-spot-frame-optics.integration.test.ts +14 -0
  124. package/src/__tests__/volumetric-fog-spot-optics.integration.test.ts +29 -0
  125. package/src/__tests__/volumetric-fog-stage-readback.dawn.test.ts +550 -0
  126. package/src/__tests__/volumetric-fog-temporal-selected-light.unit.test.ts +33 -0
  127. package/src/__tests__/volumetric-fog-temporal.unit.test.ts +45 -0
  128. package/src/__tests__/volumetric-fog-topology.unit.test.ts +237 -0
  129. package/src/__tests__/volumetric-fog-transparent.integration.test.ts +27 -0
  130. package/src/assembly/factory.ts +271 -15
  131. package/src/components/light-helpers.ts +4 -0
  132. package/src/components/spot-light.ts +22 -3
  133. package/src/device/gpu-residency.ts +168 -58
  134. package/src/errors/render.ts +119 -1
  135. package/src/gpu-usage.ts +2 -0
  136. package/src/index.ts +38 -1
  137. package/src/light-buffer-layout.ts +59 -26
  138. package/src/pbr-pipeline.ts +33 -10
  139. package/src/pipeline/standard-forward-lane.ts +20 -0
  140. package/src/pipeline/standard-pipeline.ts +20 -0
  141. package/src/pipeline/standard-profile.ts +23 -0
  142. package/src/record/frame-lighting.ts +103 -89
  143. package/src/record/frame-snapshot.ts +41 -1
  144. package/src/record/frame-targets.ts +6 -2
  145. package/src/record/frame.ts +908 -5
  146. package/src/record/gpu-timing.ts +224 -0
  147. package/src/record/main-pass-material.ts +6 -2
  148. package/src/record/render-context.ts +5 -0
  149. package/src/record/shadow-pass.ts +87 -38
  150. package/src/record/typed-frame-graph.ts +143 -8
  151. package/src/render-contract.ts +72 -0
  152. package/src/render-data.ts +50 -22
  153. package/src/render-pipeline.ts +44 -0
  154. package/src/render-system-extract.ts +329 -3
  155. package/src/render-system.ts +79 -0
  156. package/src/renderer-inspect.ts +66 -0
  157. package/src/scene/render-scene.ts +2 -0
  158. package/src/temporal/frame.ts +128 -0
  159. package/src/transmission/__tests__/standard-transmission.dawn.test.ts +2 -3
  160. package/src/typed-render-graph-primitives.ts +32 -2
  161. package/src/typed-shadow-atlas.ts +17 -0
  162. package/src/typed-shadow-passes.ts +6 -5
  163. package/src/volume/capability.ts +173 -0
  164. package/src/volume/component.ts +110 -0
  165. package/src/volume/extract.ts +21 -0
  166. package/src/volume/inspection.ts +78 -0
  167. package/src/volume/passes.ts +680 -0
  168. package/src/volume/recovery.ts +86 -0
  169. package/src/volume/resources.ts +236 -0
  170. package/src/volume/temporal.ts +89 -0
  171. package/dist/chunk-IPXCABEP.mjs.map +0 -1
  172. package/dist/chunk-LOSO4CTV.mjs.map +0 -1
  173. package/dist/chunk-RJRUS55L.mjs.map +0 -1
  174. package/dist/chunk-YTQ5C7YQ.mjs.map +0 -1
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ recoverVolumetricFog,
4
+ stageVolumetricFog,
5
+ type VolumeRecoveryState,
6
+ } from '../volume/recovery';
7
+
8
+ const selected = { entity: 19, kind: 'spot' as const, revision: 8, shadowTile: 2 };
9
+ const state = {
10
+ guid: 'density-guid',
11
+ generation: 4,
12
+ deviceEpoch: 2,
13
+ status: 'accepted' as const,
14
+ selectedLight: selected,
15
+ };
16
+
17
+ describe('selected volumetric light transaction', () => {
18
+ it('keeps selected light LKG when candidate submit fails', () => {
19
+ const staged = stageVolumetricFog(
20
+ state as VolumeRecoveryState,
21
+ {
22
+ generation: 5,
23
+ digest: 'candidate-digest',
24
+ selectedLight: { ...selected, revision: 9 },
25
+ } as never,
26
+ );
27
+ const failed = recoverVolumetricFog(staged, { kind: 'submit-failed' });
28
+ expect(failed).toMatchObject({
29
+ status: 'degraded',
30
+ generation: 4,
31
+ selectedLight: selected,
32
+ candidateFailure: 'submit-failed',
33
+ });
34
+ });
35
+
36
+ it('does not promote a candidate from a stale device generation', () => {
37
+ const staged = stageVolumetricFog(
38
+ state as VolumeRecoveryState,
39
+ {
40
+ generation: 5,
41
+ digest: 'candidate-digest',
42
+ selectedLight: selected,
43
+ } as never,
44
+ );
45
+ const stale = recoverVolumetricFog(staged, { kind: 'accepted', generation: 4 });
46
+ expect(stale).toMatchObject({
47
+ status: 'degraded',
48
+ generation: 4,
49
+ selectedLight: selected,
50
+ candidateFailure: 'stale-generation',
51
+ });
52
+ });
53
+ });
@@ -0,0 +1,27 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const watcherSource = readFileSync(
6
+ fileURLToPath(
7
+ new URL('../../../vite-plugin-pack/src/dev/plugin-server-configure.ts', import.meta.url),
8
+ ),
9
+ 'utf8',
10
+ );
11
+
12
+ describe('volumetric fog M9 watcher contract', () => {
13
+ it('uses the watcher transaction as the refresh suppression owner', () => {
14
+ expect(watcherSource).toContain('classifyWatchedPath');
15
+ expect(watcherSource).toContain('rebuildState');
16
+ expect(watcherSource).toContain("rebuildStatus === 'serving'");
17
+ expect(watcherSource).not.toContain('catalogChanged');
18
+ expect(watcherSource).not.toContain('catalogSourceChanged');
19
+ });
20
+
21
+ it('keeps degraded rebuilds on the LKG diagnostic path', () => {
22
+ expect(watcherSource).toContain("authority: 'degraded'");
23
+ expect(watcherSource).toContain('Refreshing the page here would discard that recovery path');
24
+ expect(watcherSource).toContain("rebuildStatus === 'degraded'");
25
+ expect(watcherSource).toContain("rebuildStatus === 'failed'");
26
+ });
27
+ });
@@ -0,0 +1,44 @@
1
+ import { World } from '@forgeax/engine-ecs';
2
+ import { Transform } from '@forgeax/engine-scene';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { PointLight, SpotLight } from '../index';
5
+ import { resolveVolumetricFogLightPair } from '../volume/capability';
6
+
7
+ describe('volumetric fog punctual owner selection', () => {
8
+ it('keeps Point and Spot identities distinct in one accepted pair', () => {
9
+ const world = new World();
10
+ const point = world
11
+ .spawn(
12
+ { component: Transform, data: { pos: [0, 1.4, 0] } },
13
+ { component: PointLight, data: {} },
14
+ )
15
+ .unwrap();
16
+ const spot = world
17
+ .spawn(
18
+ { component: Transform, data: { pos: [2.5, 5, 2.5] } },
19
+ { component: SpotLight, data: { direction: [-2.5, -5, -2.5] } },
20
+ )
21
+ .unwrap();
22
+ expect(resolveVolumetricFogLightPair(world, point, spot)).toEqual({
23
+ status: 'available',
24
+ mode: 'point-spot',
25
+ point,
26
+ spot,
27
+ });
28
+ });
29
+
30
+ it('rejects same entity and foreign-world handles without synthesizing lights', () => {
31
+ const world = new World();
32
+ const other = new World();
33
+ const point = other
34
+ .spawn(
35
+ { component: Transform, data: { pos: [0, 1, 0] } },
36
+ { component: PointLight, data: {} },
37
+ )
38
+ .unwrap();
39
+ expect(resolveVolumetricFogLightPair(world, point, point)).toMatchObject({
40
+ status: 'unresolved',
41
+ reason: 'missing-entity',
42
+ });
43
+ });
44
+ });
@@ -0,0 +1,49 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ const inject = readFileSync(
5
+ new URL('../../../shader/src/volume/volume-inject.wgsl', import.meta.url),
6
+ 'utf8',
7
+ );
8
+ const integrate = readFileSync(
9
+ new URL('../../../shader/src/volume/volume-integrate.wgsl', import.meta.url),
10
+ 'utf8',
11
+ );
12
+ const passes = readFileSync(new URL('../volume/passes.ts', import.meta.url), 'utf8');
13
+
14
+ describe('volumetric fog Point and Spot accumulation', () => {
15
+ it('accumulates Point and Spot in one fixed inject stage', () => {
16
+ expect(inject).toContain('pointLightsBuffer');
17
+ expect(inject).toContain('spotLightsBuffer');
18
+ expect(inject).toContain('evalVolumePoint');
19
+ expect(inject).toContain('evalVolumeSpot');
20
+ expect(inject).toContain('shadow_visibility');
21
+ expect(integrate).toContain('evalVolumePoint');
22
+ expect(integrate).toContain('evalVolumeSpot');
23
+ expect(integrate).toContain('scattering = scattering +');
24
+ });
25
+
26
+ it('keeps the four-pass topology and has no synthetic beam pass', () => {
27
+ expect(passes).toContain("'volume-inject'");
28
+ expect(passes).toContain("'volume-integrate'");
29
+ expect(passes).toContain("'volume-temporal'");
30
+ expect(passes).toContain("'volume-composite'");
31
+ expect(passes).not.toContain('volume-beam');
32
+ expect(passes).not.toContain('godray');
33
+ const declaredOrder = passes.match(
34
+ /export const VOLUMETRIC_FOG_PASS_ORDER = \[([\s\S]*?)\] as const/,
35
+ )?.[1];
36
+ expect(declaredOrder?.match(/'volume-[a-z-]+'/g)).toHaveLength(4);
37
+ });
38
+
39
+ it('retains falsifier hooks for off lights, occluders, and shadow visibility', () => {
40
+ expect(inject).toContain('count');
41
+ expect(inject).toContain('shadowAtlasTile');
42
+ expect(inject).toContain('return 0.0');
43
+ expect(integrate).toContain('evalSpotAttenuation');
44
+ expect(integrate).toContain('let shadow_visibility =');
45
+ expect(integrate).toContain('mix(1.0, shadow_visibility, volume_spot_shadow_intensity())');
46
+ expect(integrate).not.toContain('shadow_visibility * attenuation');
47
+ expect(integrate).toContain('transmittance');
48
+ });
49
+ });
@@ -0,0 +1,28 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ const componentSource = readFileSync(new URL('../volume/component.ts', import.meta.url), 'utf8');
5
+ const capabilitySource = readFileSync(new URL('../volume/capability.ts', import.meta.url), 'utf8');
6
+
7
+ describe('VolumetricFog exact Point+Spot authoring contract', () => {
8
+ it('exposes a closed pair instead of a single selected light', () => {
9
+ expect(componentSource).toMatch(/readonly light:\s*EntityHandle/);
10
+ expect(componentSource).toMatch(/readonly spotLight\??:\s*EntityHandle/);
11
+ expect(capabilitySource).toContain("'point-spot'");
12
+ expect(capabilitySource).toContain('PointLight');
13
+ });
14
+
15
+ it('keeps pair selection same-World and rejects arbitrary light counts', () => {
16
+ expect(capabilitySource).toContain('same World');
17
+ expect(capabilitySource).toMatch(/exactly (one|two)|ambiguous/);
18
+ expect(capabilitySource).not.toContain('forEachLight');
19
+ expect(capabilitySource).not.toContain('lights: EntityHandle[]');
20
+ });
21
+
22
+ it('retains structured failure fields for invalid pair authoring', () => {
23
+ expect(componentSource).toMatch(/code/);
24
+ expect(componentSource).toMatch(/expected/);
25
+ expect(componentSource).toMatch(/hint/);
26
+ expect(componentSource).toMatch(/detail/);
27
+ });
28
+ });
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ recoverVolumetricFog,
4
+ stageVolumetricFog,
5
+ type VolumeRecoveryState,
6
+ } from '../volume/recovery';
7
+
8
+ const selectedLight = {
9
+ entity: 19,
10
+ kind: 'point' as const,
11
+ revision: 8,
12
+ shadowTile: 2,
13
+ projector: {
14
+ guid: 'projector-guid',
15
+ generation: 3,
16
+ view: 'spot-view-7',
17
+ sampler: 'linear-clamp',
18
+ projection: 'spot-projection-7',
19
+ revision: 11,
20
+ },
21
+ };
22
+
23
+ const state: VolumeRecoveryState = {
24
+ guid: 'density-guid',
25
+ generation: 4,
26
+ deviceEpoch: 2,
27
+ status: 'accepted',
28
+ selectedLight,
29
+ };
30
+
31
+ describe('Point+Spot volumetric LKG transaction', () => {
32
+ it('keeps the accepted pair and projector tuple after submit failure', () => {
33
+ const staged = stageVolumetricFog(state, {
34
+ generation: 5,
35
+ digest: 'candidate-digest',
36
+ selectedLight: {
37
+ ...selectedLight,
38
+ revision: 9,
39
+ projector: { ...selectedLight.projector, revision: 12 },
40
+ },
41
+ });
42
+ const failed = recoverVolumetricFog(staged, { kind: 'submit-failed' });
43
+ expect(failed).toMatchObject({
44
+ status: 'degraded',
45
+ generation: 4,
46
+ selectedLight,
47
+ candidateFailure: 'submit-failed',
48
+ });
49
+ });
50
+
51
+ it('does not promote a projector candidate from a stale generation', () => {
52
+ const staged = stageVolumetricFog(state, {
53
+ generation: 5,
54
+ digest: 'candidate-digest',
55
+ selectedLight,
56
+ });
57
+ const stale = recoverVolumetricFog(staged, { kind: 'accepted', generation: 4 });
58
+ expect(stale).toMatchObject({
59
+ status: 'degraded',
60
+ generation: 4,
61
+ selectedLight,
62
+ candidateFailure: 'stale-generation',
63
+ });
64
+ });
65
+ });
@@ -0,0 +1,49 @@
1
+ import { World } from '@forgeax/engine-ecs';
2
+ import { Transform } from '@forgeax/engine-scene';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { PointLight, SpotLight } from '../index';
5
+ import { resolveSelectedVolumetricLight } from '../volume/capability';
6
+
7
+ describe('VolumetricFog Point+Spot selection', () => {
8
+ it('resolves one PointLight and one SpotLight as one accepted pair', () => {
9
+ const world = new World();
10
+ const point = world
11
+ .spawn(
12
+ { component: Transform, data: { pos: [0, 1.4, 0] } },
13
+ { component: PointLight, data: {} },
14
+ )
15
+ .unwrap();
16
+ const spot = world
17
+ .spawn(
18
+ { component: Transform, data: { pos: [2.5, 5, 2.5] } },
19
+ { component: SpotLight, data: { direction: [-2.5, -5, -2.5] } },
20
+ )
21
+ .unwrap();
22
+
23
+ expect(resolveSelectedVolumetricLight(world, point)).toMatchObject({
24
+ status: 'available',
25
+ kind: 'point',
26
+ entity: point,
27
+ });
28
+ expect(resolveSelectedVolumetricLight(world, spot)).toMatchObject({
29
+ status: 'available',
30
+ kind: 'spot',
31
+ entity: spot,
32
+ });
33
+ });
34
+
35
+ it('does not accept a foreign-world or three-light combination', () => {
36
+ const world = new World();
37
+ const foreignWorld = new World();
38
+ const foreign = foreignWorld
39
+ .spawn(
40
+ { component: Transform, data: { pos: [0, 1, 0] } },
41
+ { component: PointLight, data: {} },
42
+ )
43
+ .unwrap();
44
+ expect(resolveSelectedVolumetricLight(world, foreign)).toMatchObject({
45
+ status: 'unresolved',
46
+ reason: 'missing-entity',
47
+ });
48
+ });
49
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolveVolumeTemporalReset, type VolumeTemporalSignature } from '../volume/temporal';
3
+
4
+ const projector = {
5
+ guid: 'projector-guid',
6
+ generation: 3,
7
+ view: 'spot-view-7',
8
+ sampler: 'linear-clamp',
9
+ projection: 'spot-projection-7',
10
+ revision: 11,
11
+ } as const;
12
+
13
+ const signature: VolumeTemporalSignature = {
14
+ cameraRevision: 4,
15
+ fogRevision: 2,
16
+ lightRevision: 7,
17
+ densityGeneration: 3,
18
+ width: 1280,
19
+ height: 720,
20
+ lightKind: 'point',
21
+ lightEntity: 19,
22
+ pointLightEntity: 19,
23
+ spotLightEntity: 23,
24
+ projector,
25
+ };
26
+
27
+ describe('Point+Spot volumetric temporal identity', () => {
28
+ it('keeps history for the same accepted pair and projector tuple', () => {
29
+ expect(resolveVolumeTemporalReset(signature, signature)).toEqual({ reset: false });
30
+ });
31
+
32
+ it.each([
33
+ ['projector GUID', { ...projector, guid: 'new-projector-guid' }],
34
+ ['projector generation', { ...projector, generation: 4 }],
35
+ ['projector view', { ...projector, view: 'spot-view-8' }],
36
+ ['projector sampler', { ...projector, sampler: 'nearest-clamp' }],
37
+ ['projector projection', { ...projector, projection: 'spot-projection-8' }],
38
+ ['projector revision', { ...projector, revision: 12 }],
39
+ ] as const)('resets history when %s changes', (_name, nextProjector) => {
40
+ expect(
41
+ resolveVolumeTemporalReset(signature, { ...signature, projector: nextProjector }),
42
+ ).toEqual({ reset: true, reason: 'light-revision' });
43
+ });
44
+
45
+ it('resets history when either selected pair entity changes', () => {
46
+ expect(resolveVolumeTemporalReset(signature, { ...signature, spotLightEntity: 24 })).toEqual({
47
+ reset: true,
48
+ reason: 'light-revision',
49
+ });
50
+ expect(resolveVolumeTemporalReset(signature, { ...signature, pointLightEntity: 20 })).toEqual({
51
+ reset: true,
52
+ reason: 'light-revision',
53
+ });
54
+ });
55
+ });
@@ -0,0 +1,22 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ const spotSource = readFileSync(new URL('../components/spot-light.ts', import.meta.url), 'utf8');
5
+ const errorSource = readFileSync(new URL('../errors/render.ts', import.meta.url), 'utf8');
6
+
7
+ describe('SpotLight projector contract', () => {
8
+ it('exposes an optional GUID-backed TextureAsset projector field', () => {
9
+ expect(spotSource).toMatch(/projector\??:/);
10
+ expect(spotSource).toMatch(/TextureAsset/);
11
+ expect(spotSource).not.toContain('whiteTexture');
12
+ expect(spotSource).not.toContain('cookieRegistry');
13
+ });
14
+
15
+ it('distinguishes absent projector from authored failure', () => {
16
+ expect(spotSource).toContain('absent');
17
+ expect(errorSource).toMatch(/projector/);
18
+ expect(errorSource).toMatch(/expected/);
19
+ expect(errorSource).toMatch(/hint/);
20
+ expect(errorSource).toMatch(/detail/);
21
+ });
22
+ });
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ProjectorBindingError } from '../errors/render';
3
+
4
+ describe('projector inspection', () => {
5
+ it('distinguishes absent projector from an authored failure', () => {
6
+ const absent = { status: 'absent' as const };
7
+ const failure = new ProjectorBindingError('density-projector', 'invalid');
8
+ expect(absent.status).toBe('absent');
9
+ expect(failure.code).toBe('projector-binding-failed');
10
+ expect(failure.detail.guid).toBe('density-projector');
11
+ });
12
+ });
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ describe('surface and volume projector tuple', () => {
4
+ it('rejects mismatched revision and accepts the same tuple', () => {
5
+ const surface = { guid: 'g', generation: 2, revision: 7 };
6
+ const volume = { guid: 'g', generation: 2, revision: 7 };
7
+ const drifted = { guid: 'g', generation: 2, revision: 8 };
8
+ expect(volume).toEqual(surface);
9
+ expect(drifted).not.toEqual(surface);
10
+ });
11
+ });
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ProjectorBindingError } from '../errors/render';
3
+
4
+ describe('projector publication transaction', () => {
5
+ it('uses one accepted identity for both surface and volume', () => {
6
+ const identity = { guid: 'projector-guid', generation: 4, revision: 9 };
7
+ const surface = identity;
8
+ const volume = identity;
9
+ expect(surface).toBe(volume);
10
+ expect(surface).toEqual({ guid: 'projector-guid', generation: 4, revision: 9 });
11
+ });
12
+
13
+ it('keeps pending and bind failure structured', () => {
14
+ const pending = new ProjectorBindingError('projector-guid', 'pending');
15
+ const failed = new ProjectorBindingError('projector-guid', 'bind-failed');
16
+ expect(pending).toMatchObject({
17
+ code: 'projector-binding-failed',
18
+ detail: { status: 'pending' },
19
+ });
20
+ expect(failed).toMatchObject({
21
+ expected: expect.any(String),
22
+ hint: expect.any(String),
23
+ detail: { status: 'bind-failed' },
24
+ });
25
+ });
26
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ recoverVolumetricFog,
4
+ stageVolumetricFog,
5
+ type VolumeRecoveryState,
6
+ } from '../volume/recovery';
7
+
8
+ const state: VolumeRecoveryState = {
9
+ guid: 'density-guid',
10
+ generation: 4,
11
+ deviceEpoch: 2,
12
+ status: 'accepted',
13
+ };
14
+
15
+ describe('volumetric fog recovery transaction', () => {
16
+ it('keeps accepted LKG when candidate upload or submit fails', () => {
17
+ const staged = stageVolumetricFog(state, { generation: 5, digest: 'candidate-digest' });
18
+ expect(staged).toMatchObject({ status: 'candidate', candidateGeneration: 5 });
19
+ const failed = recoverVolumetricFog(staged, { kind: 'submit-failed' });
20
+ expect(failed).toMatchObject({
21
+ status: 'degraded',
22
+ generation: 4,
23
+ lkgGeneration: 4,
24
+ candidateFailure: 'submit-failed',
25
+ });
26
+ });
27
+
28
+ it('clears device-bound resources and rebuilds the same GUID after device loss', () => {
29
+ const lost = recoverVolumetricFog(state, { kind: 'device-lost', deviceEpoch: 3 });
30
+ expect(lost).toMatchObject({
31
+ status: 'recovering',
32
+ guid: 'density-guid',
33
+ deviceEpoch: 3,
34
+ generation: 4,
35
+ });
36
+ expect(lost).not.toHaveProperty('acceptedGeneration', 5);
37
+ });
38
+
39
+ it('rejects stale generations instead of silently publishing a new LKG', () => {
40
+ const stale = recoverVolumetricFog(state, { kind: 'accepted', generation: 3 });
41
+ expect(stale).toMatchObject({
42
+ status: 'degraded',
43
+ generation: 4,
44
+ candidateFailure: 'stale-generation',
45
+ });
46
+ });
47
+ });
@@ -0,0 +1,63 @@
1
+ import { World } from '@forgeax/engine-ecs';
2
+ import { Transform } from '@forgeax/engine-scene';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { DirectionalLight, SpotLight } from '../index';
5
+ import { resolveSelectedVolumetricLight } from '../volume/capability';
6
+
7
+ describe('selected volumetric light resolution', () => {
8
+ it('selects a real DirectionalLight or SpotLight entity', () => {
9
+ const world = new World();
10
+ const directional = world
11
+ .spawn({ component: DirectionalLight, data: { direction: [0, -1, 0] } })
12
+ .unwrap();
13
+ const spot = world
14
+ .spawn(
15
+ { component: Transform, data: { pos: [0, 2, 0] } },
16
+ { component: SpotLight, data: { direction: [0, -1, 0] } },
17
+ )
18
+ .unwrap();
19
+
20
+ expect(resolveSelectedVolumetricLight(world, directional)).toMatchObject({
21
+ status: 'available',
22
+ entity: directional,
23
+ kind: 'directional',
24
+ });
25
+ expect(resolveSelectedVolumetricLight(world, spot)).toMatchObject({
26
+ status: 'available',
27
+ entity: spot,
28
+ kind: 'spot',
29
+ });
30
+ });
31
+
32
+ it('returns closed reasons for missing, wrong-component, and ambiguous entities', () => {
33
+ const world = new World();
34
+ const ordinary = world.spawn().unwrap();
35
+ const both = world
36
+ .spawn(
37
+ { component: Transform, data: { pos: [0, 2, 0] } },
38
+ { component: DirectionalLight, data: { direction: [0, -1, 0] } },
39
+ { component: SpotLight, data: { direction: [0, -1, 0] } },
40
+ )
41
+ .unwrap();
42
+ const otherWorld = new World();
43
+ otherWorld.spawn();
44
+ otherWorld.spawn();
45
+ otherWorld.spawn();
46
+ const foreign = otherWorld
47
+ .spawn({ component: DirectionalLight, data: { direction: [0, -1, 0] } })
48
+ .unwrap();
49
+
50
+ expect(resolveSelectedVolumetricLight(world, ordinary)).toMatchObject({
51
+ status: 'unresolved',
52
+ reason: 'wrong-component',
53
+ });
54
+ expect(resolveSelectedVolumetricLight(world, both)).toMatchObject({
55
+ status: 'unresolved',
56
+ reason: 'ambiguous',
57
+ });
58
+ expect(resolveSelectedVolumetricLight(world, foreign)).toMatchObject({
59
+ status: 'unresolved',
60
+ reason: 'missing-entity',
61
+ });
62
+ });
63
+ });
@@ -0,0 +1,26 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ const main = readFileSync(
5
+ new URL('../../../../apps/hello/volumetric-fog/src/main.ts', import.meta.url),
6
+ 'utf8',
7
+ );
8
+ const smoke = readFileSync(
9
+ new URL('../../../../apps/hello/volumetric-fog/scripts/smoke-browser.mjs', import.meta.url),
10
+ 'utf8',
11
+ );
12
+
13
+ describe('volumetric fog Spot closure', () => {
14
+ it('uses exactly one real SpotLight as the VolumetricFog source', () => {
15
+ expect(main).toContain('spawnVolumetricSpot');
16
+ expect(main).toContain('light: selectedLight');
17
+ expect(main).not.toContain('component: DirectionalLight');
18
+ });
19
+
20
+ it('keeps browser cases on the real renderer and includes shadow falsifiers', () => {
21
+ expect(smoke).toContain('diagnostic-ceiling-only');
22
+ expect(smoke).toContain('diagnostic-ceiling-occluder');
23
+ expect(smoke).toContain('diagnostic-no-shadow');
24
+ expect(smoke).toContain('perFramePassNames');
25
+ });
26
+ });
@@ -0,0 +1,21 @@
1
+ import type { EntityHandle } from '@forgeax/engine-ecs';
2
+ import { expectTypeOf } from 'vitest';
3
+ import type { VolumetricFogAuthoring } from '../volume/component';
4
+
5
+ // M6 requires the selected light to be an entity from the same World. This
6
+ // intentionally fails against the pre-M6 optional synthetic-light shape.
7
+ expectTypeOf<VolumetricFogAuthoring>().toHaveProperty('light');
8
+ expectTypeOf<VolumetricFogAuthoring['light']>().toEqualTypeOf<EntityHandle>();
9
+
10
+ type CandidateKind = 'directional' | 'spot';
11
+ const candidate: CandidateKind = 'spot';
12
+ void candidate;
13
+
14
+ // The old synthetic light API must not be part of the public volume surface.
15
+ type Capability = typeof import('../volume/capability');
16
+ type SyntheticNames = Extract<
17
+ keyof Capability,
18
+ 'VolumetricFogDirectionalLight' | 'VolumetricFogCsmSnapshot' | 'resolveVolumetricFogLight'
19
+ >;
20
+ const noSyntheticNames: never = null as unknown as SyntheticNames;
21
+ void noSyntheticNames;
@@ -0,0 +1,27 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ const componentSource = readFileSync(new URL('../volume/component.ts', import.meta.url), 'utf8');
5
+ const capabilitySource = readFileSync(new URL('../volume/capability.ts', import.meta.url), 'utf8');
6
+
7
+ describe('VolumetricFog selected-light contract', () => {
8
+ it('requires a same-World Entity light and keeps only real light candidates', () => {
9
+ expect(componentSource).toMatch(/readonly light:\s*EntityHandle/);
10
+ expect(componentSource).toContain('type EntityHandle');
11
+ expect(componentSource).toContain('DirectionalLight');
12
+ expect(componentSource).toContain('SpotLight');
13
+ });
14
+
15
+ it('does not retain the synthetic directional resolver or snapshot exports', () => {
16
+ expect(capabilitySource).not.toContain('VolumetricFogDirectionalLight');
17
+ expect(capabilitySource).not.toContain('VolumetricFogCsmSnapshot');
18
+ expect(capabilitySource).toContain('resolveSelectedVolumetricLight');
19
+ expect(capabilitySource).toContain('resolveVolumetricFogLightPair');
20
+ expect(capabilitySource).not.toContain('resolveVolumetricFogLight(');
21
+ });
22
+
23
+ it('documents closed reasons for unresolved light identity', () => {
24
+ expect(componentSource).toMatch(/same-World|wrong-World|wrong component|light/i);
25
+ expect(componentSource).toMatch(/same-World|wrong-World|wrong component|light/i);
26
+ });
27
+ });
@@ -0,0 +1,14 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ const frame = readFileSync(new URL('../record/frame.ts', import.meta.url), 'utf8');
5
+
6
+ describe('volumetric Spot frame optics', () => {
7
+ it('passes extracted colorTimesIntensity through once', () => {
8
+ expect(frame).toMatch(
9
+ /const lightColor = selectedSpot\?\.color \?\? lights\.directional\?\.color/,
10
+ );
11
+ expect(frame).toContain('params.set(lightColor, 24);');
12
+ expect(frame).not.toContain('channel * selectedSpot.intensity');
13
+ });
14
+ });