@forgeax/engine-vfx-render 0.0.0-dev.8d955ade1c79

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 (91) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +157 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/billboard-advanced.integration.test.d.ts +2 -0
  5. package/dist/__tests__/billboard-advanced.integration.test.d.ts.map +1 -0
  6. package/dist/__tests__/data-interface-providers.unit.test.d.ts +2 -0
  7. package/dist/__tests__/data-interface-providers.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/data-interface-public-api.test-d.d.ts +2 -0
  9. package/dist/__tests__/data-interface-public-api.test-d.d.ts.map +1 -0
  10. package/dist/__tests__/event-resources.unit.test.d.ts +2 -0
  11. package/dist/__tests__/event-resources.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/fog-producer.integration.test.d.ts +2 -0
  13. package/dist/__tests__/fog-producer.integration.test.d.ts.map +1 -0
  14. package/dist/__tests__/gpu-host.integration.test.d.ts +2 -0
  15. package/dist/__tests__/gpu-host.integration.test.d.ts.map +1 -0
  16. package/dist/__tests__/inspect-snapshot.integration.test.d.ts +2 -0
  17. package/dist/__tests__/inspect-snapshot.integration.test.d.ts.map +1 -0
  18. package/dist/__tests__/particle-resources.unit.test.d.ts +2 -0
  19. package/dist/__tests__/particle-resources.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/render-vocabulary-owner.test-d.d.ts +2 -0
  21. package/dist/__tests__/render-vocabulary-owner.test-d.d.ts.map +1 -0
  22. package/dist/__tests__/stage-plan.integration.test.d.ts +2 -0
  23. package/dist/__tests__/stage-plan.integration.test.d.ts.map +1 -0
  24. package/dist/__tests__/stage-recovery.integration.test.d.ts +2 -0
  25. package/dist/__tests__/stage-recovery.integration.test.d.ts.map +1 -0
  26. package/dist/__tests__/stage-resource-access-owner.test-d.d.ts +2 -0
  27. package/dist/__tests__/stage-resource-access-owner.test-d.d.ts.map +1 -0
  28. package/dist/__tests__/stage-resource-layout.unit.test.d.ts +2 -0
  29. package/dist/__tests__/stage-resource-layout.unit.test.d.ts.map +1 -0
  30. package/dist/__tests__/topology-capacity.unit.test.d.ts +2 -0
  31. package/dist/__tests__/topology-capacity.unit.test.d.ts.map +1 -0
  32. package/dist/__tests__/topology-resources.unit.test.d.ts +2 -0
  33. package/dist/__tests__/topology-resources.unit.test.d.ts.map +1 -0
  34. package/dist/feature/camera.d.ts +11 -0
  35. package/dist/feature/camera.d.ts.map +1 -0
  36. package/dist/feature/event-resources.d.ts +9 -0
  37. package/dist/feature/event-resources.d.ts.map +1 -0
  38. package/dist/feature/gpu-particle-feature.d.ts +90 -0
  39. package/dist/feature/gpu-particle-feature.d.ts.map +1 -0
  40. package/dist/feature/particle-resources.d.ts +63 -0
  41. package/dist/feature/particle-resources.d.ts.map +1 -0
  42. package/dist/feature/stage-plan.d.ts +54 -0
  43. package/dist/feature/stage-plan.d.ts.map +1 -0
  44. package/dist/feature/topologies/beam.d.ts +16 -0
  45. package/dist/feature/topologies/beam.d.ts.map +1 -0
  46. package/dist/feature/topologies/ribbon.d.ts +15 -0
  47. package/dist/feature/topologies/ribbon.d.ts.map +1 -0
  48. package/dist/feature/topologies/trail.d.ts +19 -0
  49. package/dist/feature/topologies/trail.d.ts.map +1 -0
  50. package/dist/host/data-interface-providers.d.ts +19 -0
  51. package/dist/host/data-interface-providers.d.ts.map +1 -0
  52. package/dist/host/vfx-runtime-host.d.ts +103 -0
  53. package/dist/host/vfx-runtime-host.d.ts.map +1 -0
  54. package/dist/index.d.ts +11 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.mjs +1308 -0
  57. package/dist/index.mjs.map +1 -0
  58. package/package.json +63 -0
  59. package/src/__tests__/billboard-advanced.integration.test.ts +46 -0
  60. package/src/__tests__/data-interface-providers.unit.test.ts +55 -0
  61. package/src/__tests__/data-interface-public-api.test-d.ts +58 -0
  62. package/src/__tests__/event-resources.unit.test.ts +71 -0
  63. package/src/__tests__/fog-producer.integration.test.ts +60 -0
  64. package/src/__tests__/gpu-host.integration.test.ts +459 -0
  65. package/src/__tests__/inspect-snapshot.integration.test.ts +26 -0
  66. package/src/__tests__/particle-resources.unit.test.ts +66 -0
  67. package/src/__tests__/render-vocabulary-owner.test-d.ts +81 -0
  68. package/src/__tests__/stage-plan.integration.test.ts +148 -0
  69. package/src/__tests__/stage-recovery.integration.test.ts +24 -0
  70. package/src/__tests__/stage-resource-access-owner.test-d.ts +26 -0
  71. package/src/__tests__/stage-resource-layout.unit.test.ts +40 -0
  72. package/src/__tests__/topology-capacity.unit.test.ts +59 -0
  73. package/src/__tests__/topology-resources.unit.test.ts +29 -0
  74. package/src/feature/camera.ts +12 -0
  75. package/src/feature/event-resources.ts +62 -0
  76. package/src/feature/gpu-particle-feature.ts +819 -0
  77. package/src/feature/particle-resources.ts +195 -0
  78. package/src/feature/stage-plan.ts +285 -0
  79. package/src/feature/topologies/beam.ts +41 -0
  80. package/src/feature/topologies/ribbon.ts +31 -0
  81. package/src/feature/topologies/trail.ts +43 -0
  82. package/src/host/data-interface-providers.ts +119 -0
  83. package/src/host/vfx-runtime-host.ts +459 -0
  84. package/src/index.ts +57 -0
  85. package/src/shaders/beam.wgsl +69 -0
  86. package/src/shaders/billboard.wgsl +132 -0
  87. package/src/shaders/event.wgsl +11 -0
  88. package/src/shaders/mesh.wgsl +98 -0
  89. package/src/shaders/ribbon.wgsl +69 -0
  90. package/src/shaders/stage.wgsl +5 -0
  91. package/src/shaders/trail.wgsl +77 -0
@@ -0,0 +1,55 @@
1
+ import { ok } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import {
4
+ createVfxDataInterfaceRegistry,
5
+ type VfxDataInterfaceProvider,
6
+ } from '../host/data-interface-providers.js';
7
+
8
+ function provider(token: VfxDataInterfaceProvider['token']): VfxDataInterfaceProvider {
9
+ return {
10
+ id: `${token}-provider`,
11
+ token,
12
+ kind: token.slice(4) as VfxDataInterfaceProvider['kind'],
13
+ bindingType: token === 'vfx:scene-depth' ? 'sampled-depth' : 'uniform',
14
+ provide: (generation) =>
15
+ ok({
16
+ token,
17
+ kind: token.slice(4) as VfxDataInterfaceProvider['kind'],
18
+ bindingType: token === 'vfx:scene-depth' ? 'sampled-depth' : 'uniform',
19
+ generation,
20
+ }),
21
+ };
22
+ }
23
+
24
+ describe('VFX host Data Interface providers', () => {
25
+ it('rejects duplicate tokens before a second provider can replace the first', () => {
26
+ const registry = createVfxDataInterfaceRegistry();
27
+ expect(registry.register(provider('vfx:camera'))).toMatchObject({ ok: true });
28
+ const duplicate = registry.register(provider('vfx:camera'));
29
+ expect(duplicate).toMatchObject({
30
+ ok: false,
31
+ error: {
32
+ code: 'vfx-data-interface-duplicate',
33
+ detail: { token: 'vfx:camera' },
34
+ },
35
+ });
36
+ });
37
+
38
+ it('projects readiness and errors by reflected token', () => {
39
+ const registry = createVfxDataInterfaceRegistry();
40
+ registry.register(provider('vfx:camera'));
41
+ const result = registry.resolve(
42
+ [
43
+ {
44
+ token: 'vfx:camera',
45
+ kind: 'camera',
46
+ binding: 8,
47
+ bindingType: 'uniform',
48
+ lifetime: 'generation',
49
+ },
50
+ ],
51
+ 11,
52
+ );
53
+ expect(result).toMatchObject({ ok: true, value: { readiness: 'ready', generation: 11 } });
54
+ });
55
+ });
@@ -0,0 +1,58 @@
1
+ import { ok, type Result } from '@forgeax/engine-types';
2
+ import type {
3
+ VfxDataInterfaceError,
4
+ VfxDataInterfaceRequirement,
5
+ VfxDataInterfaceResolution,
6
+ } from '@forgeax/engine-vfx';
7
+ import { describe, expectTypeOf, it } from 'vitest';
8
+ import {
9
+ createCameraProvider,
10
+ createSceneDepthProvider,
11
+ createVfxDataInterfaceRegistry,
12
+ type VfxDataInterfaceProvider,
13
+ type VfxDataInterfaceRegistry,
14
+ } from '../index.js';
15
+
16
+ describe('VFX Data Interface public types', () => {
17
+ it('keeps factories and readiness on the public provider protocol', () => {
18
+ const camera = createCameraProvider({ available: () => true });
19
+ const depth = createSceneDepthProvider({ available: () => true });
20
+ const registry = createVfxDataInterfaceRegistry([camera, depth]);
21
+ const requirements: VfxDataInterfaceRequirement[] = [
22
+ {
23
+ token: 'vfx:scene-depth',
24
+ kind: 'scene-depth',
25
+ binding: 9,
26
+ bindingType: 'sampled-depth',
27
+ lifetime: 'generation',
28
+ },
29
+ ];
30
+ const result = registry.resolve(requirements, 4);
31
+
32
+ expectTypeOf(camera).toMatchTypeOf<VfxDataInterfaceProvider>();
33
+ expectTypeOf(depth).toMatchTypeOf<VfxDataInterfaceProvider>();
34
+ expectTypeOf(registry).toMatchTypeOf<VfxDataInterfaceRegistry>();
35
+ expectTypeOf(result).toEqualTypeOf<Result<VfxDataInterfaceResolution, VfxDataInterfaceError>>();
36
+ expectTypeOf(registry.snapshot?.result).toEqualTypeOf<
37
+ Result<VfxDataInterfaceResolution, VfxDataInterfaceError> | undefined
38
+ >();
39
+ });
40
+
41
+ it('rejects a provider with binding metadata outside its reflected kind', () => {
42
+ const invalid: VfxDataInterfaceProvider<'scene-depth'> = {
43
+ id: 'invalid',
44
+ token: 'vfx:scene-depth',
45
+ kind: 'scene-depth',
46
+ // @ts-expect-error Scene depth is sampled-depth, not uniform.
47
+ bindingType: 'uniform',
48
+ provide: () =>
49
+ ok({
50
+ token: 'vfx:scene-depth',
51
+ kind: 'scene-depth',
52
+ bindingType: 'sampled-depth',
53
+ generation: 1,
54
+ }),
55
+ };
56
+ expectTypeOf(invalid).toMatchTypeOf<VfxDataInterfaceProvider>();
57
+ });
58
+ });
@@ -0,0 +1,71 @@
1
+ import type { VfxGpuEmitterProgram, VfxGpuTickIntent } from '@forgeax/engine-vfx';
2
+ import { describe, expect, it } from 'vitest';
3
+ import {
4
+ encodeEventInputs,
5
+ eventCapacity,
6
+ eventInputCapacity,
7
+ VFX_EVENT_INPUT_BYTES,
8
+ } from '../feature/event-resources.js';
9
+
10
+ const emitter = {
11
+ id: 'mouth-charge',
12
+ capacity: 5,
13
+ backend: { required: 'gpu' },
14
+ space: 'world',
15
+ schedule: { rate: 1 },
16
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
17
+ renderers: [{ kind: 'billboard', material: 'material-guid' }],
18
+ channels: [
19
+ { id: 'impact', capacity: 3, overflow: 'drop-oldest' },
20
+ { id: 'spark', capacity: 4, overflow: 'drop-newest' },
21
+ ],
22
+ events: [
23
+ {
24
+ id: 'impact-event',
25
+ channel: 'impact',
26
+ subEmitter: 'impact-mesh',
27
+ fanOut: 2,
28
+ recursionDepth: 1,
29
+ },
30
+ ],
31
+ simulationWhenCulled: 'continue',
32
+ wgsl: 'cooked',
33
+ reflection: {
34
+ hooks: ['vfx_spawn', 'vfx_update'],
35
+ imports: [],
36
+ resources: [],
37
+ entryPoints: [],
38
+ bindings: [],
39
+ },
40
+ } as unknown as VfxGpuEmitterProgram;
41
+
42
+ describe('GPU event resources', () => {
43
+ it('derives bounded input and output capacities from reflected channels', () => {
44
+ expect(eventInputCapacity(emitter)).toBe(7);
45
+ expect(eventCapacity(emitter)).toBe(5);
46
+ });
47
+
48
+ it('encodes inputs in stable order and fills unused slots with a sentinel', () => {
49
+ const intent = {
50
+ emitter,
51
+ channelInputs: [
52
+ {
53
+ channel: 'impact',
54
+ payload: { position: [1, 2, 3], strength: 0.5 },
55
+ sequence: 11,
56
+ },
57
+ ],
58
+ } as unknown as VfxGpuTickIntent;
59
+ const encoded = encodeEventInputs(intent);
60
+ const view = new DataView(encoded.buffer, encoded.byteOffset, encoded.byteLength);
61
+
62
+ expect(encoded.byteLength).toBe(7 * VFX_EVENT_INPUT_BYTES);
63
+ expect(view.getFloat32(0, true)).toBe(1);
64
+ expect(view.getFloat32(4, true)).toBe(2);
65
+ expect(view.getFloat32(8, true)).toBe(3);
66
+ expect(view.getFloat32(16, true)).toBe(0.5);
67
+ expect(view.getUint32(20, true)).toBe(11);
68
+ expect(view.getUint32(28, true)).toBe(2);
69
+ expect(encoded[32]).toBe(0xff);
70
+ });
71
+ });
@@ -0,0 +1,60 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const shaderNames = ['billboard', 'mesh', 'ribbon', 'trail', 'beam'] as const;
6
+
7
+ function shaderSource(name: (typeof shaderNames)[number]): string {
8
+ return readFileSync(fileURLToPath(new URL(`../shaders/${name}.wgsl`, import.meta.url)), 'utf8');
9
+ }
10
+
11
+ function count(source: string, pattern: RegExp): number {
12
+ return source.match(pattern)?.length ?? 0;
13
+ }
14
+
15
+ describe('VFX Fog producer contract', () => {
16
+ it('requires one shared Fog call with a world-space ray for every topology', () => {
17
+ for (const name of shaderNames) {
18
+ const source = shaderSource(name);
19
+ expect(source, name).toMatch(
20
+ /#import\s+forgeax_view::common::\{[^}]*\bFogViewParams\b[^}]*\bFogRay\b[^}]*\}/,
21
+ );
22
+ expect(source, name).toMatch(/#import\s+forgeax_view::fog::\{[^}]*\bapply_fog\b[^}]*\}/);
23
+ expect(count(source, /\bapply_fog\s*\(/g), name).toBe(1);
24
+ expect(count(source, /\bFogRay\s*\(/g), name).toBe(1);
25
+ expect(source, name).toMatch(/world[_A-Za-z]*position|worldPosition/);
26
+ expect(source, name).toMatch(/ray[_A-Za-z]*distance|distance/);
27
+ expect(source, name).toMatch(/\.a\b|alpha/);
28
+ expect(source, name).not.toMatch(/texture_3d|raymarch/i);
29
+ }
30
+ });
31
+
32
+ it('keeps VFX coverage on the current graphics feature lane', () => {
33
+ const feature = readFileSync(
34
+ fileURLToPath(new URL('../feature/gpu-particle-feature.ts', import.meta.url)),
35
+ 'utf8',
36
+ );
37
+ const camera = readFileSync(
38
+ fileURLToPath(new URL('../feature/camera.ts', import.meta.url)),
39
+ 'utf8',
40
+ );
41
+ expect(feature).toContain('requiredCapabilities');
42
+ expect(feature).toContain('identity: IDENTITY');
43
+ expect(feature).not.toMatch(/fog(?:History|Texture|Registry)/i);
44
+ expect(feature).toContain('worldCenter');
45
+ expect(camera).toContain('position');
46
+ expect(camera).toContain('viewProjection');
47
+ });
48
+
49
+ it('keeps billboard scene-depth soft-particle alpha before Fog mixing', () => {
50
+ const billboard = shaderSource('billboard');
51
+ const depthLoad = billboard.indexOf('textureLoad(scene_depth');
52
+ const softParticleCall = billboard.indexOf('softParticle(input.position');
53
+ const fogCall = billboard.indexOf('apply_fog(');
54
+
55
+ expect(depthLoad).toBeGreaterThanOrEqual(0);
56
+ expect(softParticleCall).toBeGreaterThan(depthLoad);
57
+ expect(fogCall).toBeGreaterThan(softParticleCall);
58
+ expect(billboard).toContain('input.color.a * edge, input.fade_distance');
59
+ });
60
+ });
@@ -0,0 +1,459 @@
1
+ import { World } from '@forgeax/engine-ecs';
2
+ import type { Asset, MaterialAsset } from '@forgeax/engine-types';
3
+ import {
4
+ ParticleEffectPlayer,
5
+ VFX_GPU_RUNTIME_RESOURCE_KEY,
6
+ type VfxGpuRuntime,
7
+ type VfxGpuTickIntent,
8
+ } from '@forgeax/engine-vfx';
9
+ import { describe, expect, it } from 'vitest';
10
+ import { gpuParticleRenderFeature } from '../feature/gpu-particle-feature.js';
11
+ import {
12
+ createCameraProvider,
13
+ createSceneDepthProvider,
14
+ createVfxRuntimeHost,
15
+ PARTICLE_SHADER_IDENTIFIERS,
16
+ } from '../index.js';
17
+
18
+ describe('GPU VFX public host', () => {
19
+ it('owns loader and FixedUpdate attachment without exposing simulation internals', async () => {
20
+ const world = new World();
21
+ const registered: unknown[] = [];
22
+ const assets = {
23
+ loaders: { registerPackLoader: (loader: unknown) => registered.push(loader) },
24
+ lookup: () => undefined,
25
+ };
26
+ const host = createVfxRuntimeHost({
27
+ camera: {
28
+ read: () => ({
29
+ position: new Float32Array(3),
30
+ right: new Float32Array([1, 0, 0]),
31
+ up: new Float32Array([0, 1, 0]),
32
+ viewProjection: new Float32Array(16),
33
+ }),
34
+ },
35
+ });
36
+
37
+ const attached = await host.attachWorld({ world, assets: assets as never });
38
+ expect(attached).toMatchObject({ ok: true, value: { state: 'attached' } });
39
+ expect(registered).toHaveLength(1);
40
+ expect(host.feature.requiredMaterialShaders).toEqual(
41
+ Object.values(PARTICLE_SHADER_IDENTIFIERS),
42
+ );
43
+ expect(await host.detachWorld({ world })).toMatchObject({
44
+ ok: true,
45
+ value: { state: 'detached' },
46
+ });
47
+ });
48
+
49
+ it('preserves the asset registry receiver while planning a material projection', async () => {
50
+ const world = new World();
51
+ const lookups: string[] = [];
52
+ class StatefulAssets {
53
+ readonly material: MaterialAsset = { kind: 'material', values: {} };
54
+ readonly loaders = { registerPackLoader: () => {} };
55
+
56
+ lookup<T extends Asset = Asset>(guid: string): T | undefined {
57
+ if (this !== assets) throw new Error('asset registry receiver was lost');
58
+ lookups.push(guid);
59
+ return guid === 'material-guid' ? (this.material as T) : undefined;
60
+ }
61
+ }
62
+ const assets = new StatefulAssets();
63
+ const host = createVfxRuntimeHost({
64
+ camera: {
65
+ read: () => ({
66
+ position: new Float32Array(3),
67
+ right: new Float32Array([1, 0, 0]),
68
+ up: new Float32Array([0, 1, 0]),
69
+ viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
70
+ }),
71
+ },
72
+ });
73
+ expect(await host.attachWorld({ world, assets })).toMatchObject({ ok: true });
74
+
75
+ const intent = {
76
+ player: world.spawn().unwrap(),
77
+ fixedDelta: 1 / 60,
78
+ phaseTick: 0,
79
+ seed: 1,
80
+ playCycle: 0,
81
+ spawnCount: 1,
82
+ firstParticleId: 0,
83
+ reset: true,
84
+ channelInputs: [],
85
+ emitter: {
86
+ id: 'receiver-check',
87
+ capacity: 4,
88
+ backend: { required: 'gpu' },
89
+ space: 'world',
90
+ simulationWhenCulled: 'continue',
91
+ schedule: { rate: 0, bursts: [] },
92
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
93
+ wgsl: '// receiver check',
94
+ reflection: { entryPoints: [], bindings: [] },
95
+ renderers: [{ kind: 'billboard', material: 'material-guid' }],
96
+ },
97
+ } as unknown as VfxGpuTickIntent;
98
+ const planned = host.feature.plan(
99
+ {
100
+ worlds: [
101
+ {
102
+ world,
103
+ runtime: {
104
+ isEmitterSessionEnabled: () => true,
105
+ setEmitterCameraVisibility: () => {},
106
+ markEventDispatched: () => {},
107
+ commit: () => {},
108
+ },
109
+ camera: {
110
+ position: new Float32Array(3),
111
+ right: new Float32Array([1, 0, 0]),
112
+ up: new Float32Array([0, 1, 0]),
113
+ viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
114
+ },
115
+ intents: [intent],
116
+ },
117
+ ],
118
+ frameNumber: 1,
119
+ } as never,
120
+ {
121
+ targets: [],
122
+ caps: {},
123
+ frame: { frameNumber: 1 },
124
+ generation: 1,
125
+ } as never,
126
+ );
127
+ expect(planned.ok).toBe(true);
128
+ expect(lookups).toEqual(['material-guid']);
129
+ await host.detachWorld({ world });
130
+ });
131
+
132
+ it('fences preview controls to one attached host generation', async () => {
133
+ const world = new World();
134
+ const assets = {
135
+ loaders: { registerPackLoader: () => {} },
136
+ lookup: () => undefined,
137
+ };
138
+ const host = createVfxRuntimeHost({ camera: { read: () => undefined } });
139
+ expect(host.acquireControl(world)).toMatchObject({
140
+ ok: false,
141
+ error: { code: 'vfx-host-control-world-detached' },
142
+ });
143
+
144
+ expect(await host.attachWorld({ world, assets: assets as never })).toMatchObject({ ok: true });
145
+ const acquired = host.acquireControl(world);
146
+ expect(acquired).toMatchObject({ ok: true, value: { generation: 1 } });
147
+ if (!acquired.ok) throw new Error(acquired.error.code);
148
+ const effect = world.allocSharedRef('ParticleEffectAsset', {} as never);
149
+ const player = world
150
+ .spawn({
151
+ component: ParticleEffectPlayer,
152
+ data: { effect, playing: true, seed: 1, timeScale: 1 },
153
+ })
154
+ .unwrap();
155
+
156
+ expect(
157
+ acquired.value.setEmitterSessionEnabled({
158
+ player,
159
+ emitterId: 'sparks',
160
+ enabled: false,
161
+ }),
162
+ ).toMatchObject({ ok: true, value: { state: 'disabled', generation: 1 } });
163
+ expect(
164
+ world
165
+ .getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY)
166
+ .isEmitterSessionEnabled(player, 'sparks'),
167
+ ).toBe(false);
168
+ expect(acquired.value.replay({ player })).toMatchObject({
169
+ ok: true,
170
+ value: { state: 'queued', generation: 1 },
171
+ });
172
+ expect(acquired.value.setPlayerRenderConsumption({ player, enabled: false })).toMatchObject({
173
+ ok: true,
174
+ value: { state: 'paused', generation: 1 },
175
+ });
176
+ expect(acquired.value.setPlayerRenderConsumption({ player, enabled: true })).toMatchObject({
177
+ ok: true,
178
+ value: { state: 'enabled', generation: 1 },
179
+ });
180
+
181
+ world.despawn(player).unwrap();
182
+ expect(acquired.value.replay({ player })).toMatchObject({
183
+ ok: false,
184
+ error: {
185
+ code: 'vfx-host-control-player-unavailable',
186
+ detail: { player, requestedGeneration: 1, currentGeneration: 1 },
187
+ },
188
+ });
189
+
190
+ expect(await host.detachWorld({ world })).toMatchObject({ ok: true });
191
+ expect(await host.attachWorld({ world, assets: assets as never })).toMatchObject({ ok: true });
192
+ expect(acquired.value.replay({ player })).toMatchObject({
193
+ ok: false,
194
+ error: {
195
+ code: 'vfx-host-control-stale-generation',
196
+ detail: { requestedGeneration: 1, currentGeneration: 2 },
197
+ },
198
+ });
199
+ expect(host.acquireControl(world)).toMatchObject({ ok: true, value: { generation: 2 } });
200
+ });
201
+
202
+ it('declares every pending emitter program in one feature plan', () => {
203
+ const world = new World();
204
+ const player = world.spawn().unwrap();
205
+ const intent = (id: string): VfxGpuTickIntent => ({
206
+ sequence: 1,
207
+ player,
208
+ emitter: {
209
+ id,
210
+ module: `${id}.vfx.wgsl`,
211
+ capacity: 4,
212
+ backend: { required: 'gpu' },
213
+ space: 'local',
214
+ simulationWhenCulled: 'continue',
215
+ schedule: { rate: 0, bursts: [] },
216
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
217
+ wgsl: `// ${id}`,
218
+ reflection: {
219
+ hooks: ['vfx_spawn', 'vfx_update'],
220
+ imports: [],
221
+ resources: [],
222
+ entryPoints: ['forgeax_vfx_spawn_main'],
223
+ bindings: [],
224
+ },
225
+ renderers: [],
226
+ },
227
+ programFingerprint: id,
228
+ reset: true,
229
+ fixedDelta: 1 / 60,
230
+ phaseTick: 0,
231
+ tick: 0,
232
+ seed: 1,
233
+ playCycle: 0,
234
+ spawnCount: 1,
235
+ firstParticleId: 0,
236
+ instanceGeneration: 0,
237
+ instancePatchCount: 0,
238
+ parameterBlock: new Uint8Array(),
239
+ canonicalPayload: new Uint8Array(),
240
+ replayInput: {
241
+ seed: 1,
242
+ tick: 0,
243
+ generation: 0,
244
+ sequence: 1,
245
+ fingerprint: id,
246
+ payload: new Uint8Array(),
247
+ values: {},
248
+ channelInputs: [],
249
+ droppedCount: 0,
250
+ },
251
+ channelInputs: [],
252
+ eventCounters: {
253
+ queued: 0,
254
+ produced: 0,
255
+ consumed: 0,
256
+ dropped: 0,
257
+ overflow: 0,
258
+ fanOut: 0,
259
+ recursionDepth: 0,
260
+ lastSequence: -1,
261
+ },
262
+ });
263
+ const feature = gpuParticleRenderFeature({ camera: { read: () => undefined } });
264
+ const planned = feature.plan(
265
+ {
266
+ worlds: [
267
+ {
268
+ world,
269
+ runtime: {
270
+ isEmitterSessionEnabled: () => true,
271
+ setEmitterCameraVisibility: () => {},
272
+ markEventDispatched: () => {},
273
+ commit: () => {},
274
+ },
275
+ camera: {
276
+ position: new Float32Array(3),
277
+ right: new Float32Array([1, 0, 0]),
278
+ up: new Float32Array([0, 1, 0]),
279
+ viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
280
+ },
281
+ intents: [intent('first'), intent('second')],
282
+ },
283
+ ],
284
+ frameNumber: 1,
285
+ } as never,
286
+ { targets: [], caps: {}, frame: { frameNumber: 1 }, generation: 1 } as never,
287
+ );
288
+ expect(planned.ok).toBe(true);
289
+ if (!planned.ok) return;
290
+ expect(
291
+ planned.value.resources
292
+ .filter((resource) => resource.kind === 'compute-program')
293
+ .map((resource) => resource.name),
294
+ ).toEqual([expect.stringContaining('.first.'), expect.stringContaining('.second.')]);
295
+ });
296
+
297
+ it('keeps masked bindings warm and sends effect-relative time to WGSL', () => {
298
+ const world = new World();
299
+ const player = world.spawn().unwrap();
300
+ const intent = (tick: number): VfxGpuTickIntent => ({
301
+ sequence: tick + 1,
302
+ player,
303
+ emitter: {
304
+ id: 'masked',
305
+ module: 'masked.vfx.wgsl',
306
+ capacity: 4,
307
+ backend: { required: 'gpu' },
308
+ space: 'local',
309
+ simulationWhenCulled: 'continue',
310
+ schedule: { rate: 0, bursts: [] },
311
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
312
+ wgsl: '// masked',
313
+ reflection: {
314
+ hooks: ['vfx_spawn', 'vfx_update'],
315
+ imports: [],
316
+ resources: [],
317
+ entryPoints: ['forgeax_vfx_spawn_main'],
318
+ bindings: [],
319
+ },
320
+ renderers: [],
321
+ },
322
+ programFingerprint: 'masked-program',
323
+ reset: tick === 0,
324
+ fixedDelta: 1 / 60,
325
+ phaseTick: tick + 7,
326
+ tick,
327
+ seed: 1,
328
+ playCycle: 0,
329
+ spawnCount: tick === 0 ? 1 : 0,
330
+ firstParticleId: 0,
331
+ instanceGeneration: 0,
332
+ instancePatchCount: 0,
333
+ parameterBlock: new Uint8Array(),
334
+ canonicalPayload: new Uint8Array(),
335
+ replayInput: {
336
+ seed: 1,
337
+ tick,
338
+ generation: 0,
339
+ sequence: tick + 1,
340
+ fingerprint: 'masked-program',
341
+ payload: new Uint8Array(),
342
+ values: {},
343
+ channelInputs: [],
344
+ droppedCount: 0,
345
+ },
346
+ channelInputs: [],
347
+ eventCounters: {
348
+ queued: 0,
349
+ produced: 0,
350
+ consumed: 0,
351
+ dropped: 0,
352
+ overflow: 0,
353
+ fanOut: 0,
354
+ recursionDepth: 0,
355
+ lastSequence: -1,
356
+ },
357
+ });
358
+ let sessionEnabled = true;
359
+ const runtime = {
360
+ isEmitterSessionEnabled: () => sessionEnabled,
361
+ setEmitterCameraVisibility: () => {},
362
+ markEventDispatched: () => {},
363
+ commit: () => {},
364
+ };
365
+ const camera = {
366
+ position: new Float32Array(3),
367
+ right: new Float32Array([1, 0, 0]),
368
+ up: new Float32Array([0, 1, 0]),
369
+ viewProjection: new Float32Array(16),
370
+ };
371
+ const feature = gpuParticleRenderFeature({ camera: { read: () => camera } });
372
+ const context = { targets: [], caps: {}, frame: { frameNumber: 1 }, generation: 1 } as never;
373
+ const frame = (intents: readonly VfxGpuTickIntent[], frameNumber: number) =>
374
+ ({
375
+ worlds: [{ world, runtime, camera, intents }],
376
+ frameNumber,
377
+ }) as never;
378
+
379
+ const active = feature.plan(frame([intent(0)], 1), context);
380
+ expect(active.ok).toBe(true);
381
+ if (!active.ok) return;
382
+ const runtimeWrite = active.value.resources.find(
383
+ (resource) => resource.kind === 'buffer' && resource.name.endsWith('.runtime'),
384
+ );
385
+ expect(runtimeWrite?.kind === 'buffer' ? runtimeWrite.data : undefined).toBeInstanceOf(
386
+ Uint8Array,
387
+ );
388
+ const runtimeBytes = runtimeWrite?.kind === 'buffer' ? runtimeWrite.data : undefined;
389
+ expect(new Uint32Array((runtimeBytes as Uint8Array).buffer)[1]).toBe(7);
390
+ sessionEnabled = false;
391
+ const masked = feature.plan(frame([intent(1)], 2), context);
392
+ expect(masked).toMatchObject({ ok: true, value: { resources: [], passes: [] } });
393
+ sessionEnabled = true;
394
+ const resumed = feature.plan(frame([intent(2)], 3), context);
395
+ expect(resumed.ok).toBe(true);
396
+ if (resumed.ok) expect(resumed.value.resources.length).toBeGreaterThan(0);
397
+ });
398
+
399
+ it('rejects a second host without replacing the first World runtime', async () => {
400
+ const world = new World();
401
+ const assets = {
402
+ loaders: { registerPackLoader: () => {} },
403
+ lookup: () => undefined,
404
+ };
405
+ const options = {
406
+ camera: {
407
+ read: () => undefined,
408
+ },
409
+ };
410
+ const first = createVfxRuntimeHost(options);
411
+ const second = createVfxRuntimeHost(options);
412
+ expect(await first.attachWorld({ world, assets: assets as never })).toMatchObject({ ok: true });
413
+ expect(await second.attachWorld({ world, assets: assets as never })).toMatchObject({
414
+ ok: false,
415
+ error: { code: 'vfx-host-world-attach-failed' },
416
+ });
417
+ expect(await first.detachWorld({ world })).toMatchObject({ ok: true });
418
+ });
419
+
420
+ it('keeps depth readiness on the host contract for a real renderer frame', () => {
421
+ const host = createVfxRuntimeHost({
422
+ camera: { read: () => undefined },
423
+ providers: [
424
+ createCameraProvider({ available: () => true }),
425
+ createSceneDepthProvider({ available: () => true }),
426
+ ],
427
+ });
428
+ const result = host.resolveDataInterfaces({
429
+ requirements: [
430
+ {
431
+ token: 'vfx:scene-depth',
432
+ kind: 'scene-depth',
433
+ binding: 9,
434
+ bindingType: 'sampled-depth',
435
+ lifetime: 'generation',
436
+ },
437
+ ],
438
+ generation: 1,
439
+ });
440
+ expect(result).toMatchObject({ ok: true, value: { readiness: 'ready' } });
441
+ });
442
+
443
+ it('returns an empty keyed inspection aggregate before a player is present', async () => {
444
+ const world = new World();
445
+ const assets = {
446
+ loaders: { registerPackLoader: () => {} },
447
+ lookup: () => undefined,
448
+ };
449
+ const host = createVfxRuntimeHost({ camera: { read: () => undefined } });
450
+ await host.attachWorld({ world, assets: assets as never });
451
+
452
+ expect(host.inspect(world)).toEqual({
453
+ generation: 1,
454
+ renderGeneration: 0,
455
+ players: [],
456
+ diagnostics: [],
457
+ });
458
+ });
459
+ });