@forgeax/engine-vfx-render 0.1.2

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 +163 -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 +96 -0
  39. package/dist/feature/gpu-particle-feature.d.ts.map +1 -0
  40. package/dist/feature/particle-resources.d.ts +66 -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 +87 -0
  53. package/dist/host/vfx-runtime-host.d.ts.map +1 -0
  54. package/dist/index.d.ts +12 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.mjs +1572 -0
  57. package/dist/index.mjs.map +1 -0
  58. package/package.json +63 -0
  59. package/src/__tests__/billboard-advanced.integration.test.ts +69 -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 +59 -0
  64. package/src/__tests__/gpu-host.integration.test.ts +441 -0
  65. package/src/__tests__/inspect-snapshot.integration.test.ts +26 -0
  66. package/src/__tests__/particle-resources.unit.test.ts +75 -0
  67. package/src/__tests__/render-vocabulary-owner.test-d.ts +81 -0
  68. package/src/__tests__/stage-plan.integration.test.ts +16 -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 +1225 -0
  77. package/src/feature/particle-resources.ts +203 -0
  78. package/src/feature/stage-plan.ts +282 -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 +402 -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,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,59 @@
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 existing reactive graphics 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("temporalCoverage: 'reactive'");
42
+ expect(feature).not.toMatch(/fog(?:History|Texture|Registry)/i);
43
+ expect(feature).toContain('worldPosition');
44
+ expect(camera).toContain('position');
45
+ expect(camera).toContain('viewProjection');
46
+ });
47
+
48
+ it('keeps billboard scene-depth soft-particle alpha before Fog mixing', () => {
49
+ const billboard = shaderSource('billboard');
50
+ const depthLoad = billboard.indexOf('textureLoad(scene_depth');
51
+ const softParticleCall = billboard.indexOf('softParticle(input.position');
52
+ const fogCall = billboard.indexOf('apply_fog(');
53
+
54
+ expect(depthLoad).toBeGreaterThanOrEqual(0);
55
+ expect(softParticleCall).toBeGreaterThan(depthLoad);
56
+ expect(fogCall).toBeGreaterThan(softParticleCall);
57
+ expect(billboard).toContain('input.color.a * edge, input.fade_distance');
58
+ });
59
+ });
@@ -0,0 +1,441 @@
1
+ import { createAssetRegistry, createCatalogSource } from '@forgeax/engine-assets-runtime';
2
+ import { World } from '@forgeax/engine-ecs';
3
+ import { RenderFeaturePreparationFailedError } from '@forgeax/engine-render';
4
+ import { err, ok } from '@forgeax/engine-types';
5
+ import {
6
+ ParticleEffectPlayer,
7
+ VFX_GPU_RUNTIME_RESOURCE_KEY,
8
+ type VfxGpuRuntime,
9
+ type VfxGpuTickIntent,
10
+ vfxGpuEffectContribution,
11
+ } from '@forgeax/engine-vfx';
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import { gpuParticleRenderFeature } from '../feature/gpu-particle-feature.js';
14
+ import {
15
+ createCameraProvider,
16
+ createSceneDepthProvider,
17
+ createVfxRuntimeHost,
18
+ PARTICLE_SHADER_IDENTIFIERS,
19
+ } from '../index.js';
20
+
21
+ describe('GPU VFX public host', () => {
22
+ it('owns loader and FixedUpdate attachment without exposing simulation internals', async () => {
23
+ const world = new World();
24
+ const installed: unknown[] = [];
25
+ const assets = {
26
+ installDecoder: (kind: unknown, decoder: unknown) => {
27
+ installed.push({ kind, decoder });
28
+ return { kind: 'particle-effect', dispose: () => {} };
29
+ },
30
+ };
31
+ const host = createVfxRuntimeHost({
32
+ camera: {
33
+ read: () => ({
34
+ position: new Float32Array(3),
35
+ right: new Float32Array([1, 0, 0]),
36
+ up: new Float32Array([0, 1, 0]),
37
+ viewProjection: new Float32Array(16),
38
+ }),
39
+ },
40
+ });
41
+
42
+ const attached = await host.attachWorld({ world, assets: assets as never });
43
+ expect(attached).toMatchObject({ ok: true, value: { state: 'attached' } });
44
+ expect(installed).toHaveLength(1);
45
+ expect(host.feature.requiredMaterialShaders).toEqual(
46
+ Object.values(PARTICLE_SHADER_IDENTIFIERS),
47
+ );
48
+ expect(host.feature.inspect()).toEqual({
49
+ frameNumber: -1,
50
+ dispatches: 0,
51
+ indirectDraws: 0,
52
+ subjectOutputs: 0,
53
+ });
54
+ expect(await host.detachWorld({ world })).toMatchObject({
55
+ ok: true,
56
+ value: { state: 'detached' },
57
+ });
58
+ });
59
+
60
+ it('shares an App-selected GPU decoder when the host attaches the same registry', async () => {
61
+ const world = new World();
62
+ const assets = createAssetRegistry({
63
+ catalog: createCatalogSource({ entries: [] }),
64
+ });
65
+ assets.installDecoder(vfxGpuEffectContribution.kind, vfxGpuEffectContribution.decoder);
66
+ const host = createVfxRuntimeHost({ camera: { read: () => undefined } });
67
+
68
+ expect(await host.attachWorld({ world, assets })).toMatchObject({
69
+ ok: true,
70
+ value: { state: 'attached' },
71
+ });
72
+ expect(await host.detachWorld({ world })).toMatchObject({
73
+ ok: true,
74
+ value: { state: 'detached' },
75
+ });
76
+ assets.dispose();
77
+ });
78
+
79
+ it('fences preview controls to one attached host generation', async () => {
80
+ const world = new World();
81
+ const assets = {
82
+ installDecoder: () => ({ kind: 'particle-effect', dispose: () => {} }),
83
+ };
84
+ const host = createVfxRuntimeHost({ camera: { read: () => undefined } });
85
+ expect(host.acquireControl(world)).toMatchObject({
86
+ ok: false,
87
+ error: { code: 'vfx-host-control-world-detached' },
88
+ });
89
+
90
+ expect(await host.attachWorld({ world, assets: assets as never })).toMatchObject({ ok: true });
91
+ const acquired = host.acquireControl(world);
92
+ expect(acquired).toMatchObject({ ok: true, value: { generation: 1 } });
93
+ if (!acquired.ok) throw new Error(acquired.error.code);
94
+ const effect = world.allocSharedRef('ParticleEffectAsset', {} as never);
95
+ const player = world
96
+ .spawn({
97
+ component: ParticleEffectPlayer,
98
+ data: { effect, playing: true, seed: 1, timeScale: 1 },
99
+ })
100
+ .unwrap();
101
+
102
+ expect(
103
+ acquired.value.setEmitterSessionEnabled({
104
+ player,
105
+ emitterId: 'sparks',
106
+ enabled: false,
107
+ }),
108
+ ).toMatchObject({ ok: true, value: { state: 'disabled', generation: 1 } });
109
+ expect(
110
+ world
111
+ .getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY)
112
+ .isEmitterSessionEnabled(player, 'sparks'),
113
+ ).toBe(false);
114
+ expect(acquired.value.replay({ player })).toMatchObject({
115
+ ok: true,
116
+ value: { state: 'queued', generation: 1 },
117
+ });
118
+ expect(acquired.value.setPlayerRenderConsumption({ player, enabled: false })).toMatchObject({
119
+ ok: true,
120
+ value: { state: 'paused', generation: 1 },
121
+ });
122
+ expect(acquired.value.setPlayerRenderConsumption({ player, enabled: true })).toMatchObject({
123
+ ok: true,
124
+ value: { state: 'enabled', generation: 1 },
125
+ });
126
+
127
+ world.despawn(player).unwrap();
128
+ expect(acquired.value.replay({ player })).toMatchObject({
129
+ ok: false,
130
+ error: {
131
+ code: 'vfx-host-control-player-unavailable',
132
+ detail: { player, requestedGeneration: 1, currentGeneration: 1 },
133
+ },
134
+ });
135
+
136
+ expect(await host.detachWorld({ world })).toMatchObject({ ok: true });
137
+ expect(await host.attachWorld({ world, assets: assets as never })).toMatchObject({ ok: true });
138
+ expect(acquired.value.replay({ player })).toMatchObject({
139
+ ok: false,
140
+ error: {
141
+ code: 'vfx-host-control-stale-generation',
142
+ detail: { requestedGeneration: 1, currentGeneration: 2 },
143
+ },
144
+ });
145
+ expect(host.acquireControl(world)).toMatchObject({ ok: true, value: { generation: 2 } });
146
+ });
147
+
148
+ it('starts every pending emitter program in one prepare frame', () => {
149
+ const world = new World();
150
+ const player = world.spawn().unwrap();
151
+ const intent = (id: string): VfxGpuTickIntent => ({
152
+ sequence: 1,
153
+ player,
154
+ emitter: {
155
+ id,
156
+ module: `${id}.vfx.wgsl`,
157
+ capacity: 4,
158
+ backend: { required: 'gpu' },
159
+ space: 'local',
160
+ simulationWhenCulled: 'continue',
161
+ schedule: { rate: 0, bursts: [] },
162
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
163
+ wgsl: `// ${id}`,
164
+ reflection: {
165
+ hooks: ['vfx_spawn', 'vfx_update'],
166
+ imports: [],
167
+ resources: [],
168
+ entryPoints: ['forgeax_vfx_spawn_main'],
169
+ bindings: [],
170
+ },
171
+ renderers: [],
172
+ },
173
+ programFingerprint: id,
174
+ reset: true,
175
+ fixedDelta: 1 / 60,
176
+ phaseTick: 0,
177
+ tick: 0,
178
+ seed: 1,
179
+ playCycle: 0,
180
+ spawnCount: 1,
181
+ firstParticleId: 0,
182
+ instanceGeneration: 0,
183
+ instancePatchCount: 0,
184
+ parameterBlock: new Uint8Array(),
185
+ canonicalPayload: new Uint8Array(),
186
+ replayInput: {
187
+ seed: 1,
188
+ tick: 0,
189
+ generation: 0,
190
+ sequence: 1,
191
+ fingerprint: id,
192
+ payload: new Uint8Array(),
193
+ values: {},
194
+ channelInputs: [],
195
+ droppedCount: 0,
196
+ },
197
+ channelInputs: [],
198
+ eventCounters: {
199
+ queued: 0,
200
+ produced: 0,
201
+ consumed: 0,
202
+ dropped: 0,
203
+ overflow: 0,
204
+ fanOut: 0,
205
+ recursionDepth: 0,
206
+ lastSequence: -1,
207
+ },
208
+ });
209
+ const prepareProgram = vi.fn((name: string) =>
210
+ err(
211
+ new RenderFeaturePreparationFailedError(
212
+ 'forgeax.vfx-render.gpu-particles',
213
+ -1,
214
+ 'prepare-gpu-program',
215
+ 'pipeline',
216
+ name,
217
+ 'rhi-not-available:compile pending',
218
+ 'next-frame',
219
+ ),
220
+ ),
221
+ );
222
+ const feature = gpuParticleRenderFeature({ camera: { read: () => undefined } });
223
+
224
+ const prepared = feature.prepare(
225
+ {
226
+ worlds: [
227
+ {
228
+ world,
229
+ runtime: {},
230
+ camera: {},
231
+ intents: [intent('first'), intent('second')],
232
+ },
233
+ ],
234
+ frameNumber: 1,
235
+ } as never,
236
+ { gpu: { prepareProgram }, targets: [] } as never,
237
+ );
238
+
239
+ expect(prepared).toMatchObject({ ok: false, error: { detail: { recovery: 'next-frame' } } });
240
+ expect(prepareProgram).toHaveBeenCalledTimes(2);
241
+ expect(prepareProgram.mock.calls.map(([name]) => name)).toEqual([
242
+ expect.stringContaining('.first.'),
243
+ expect.stringContaining('.second.'),
244
+ ]);
245
+ });
246
+
247
+ it('fences render consumption and keeps masked bindings warm', () => {
248
+ const world = new World();
249
+ const player = world.spawn().unwrap();
250
+ const intent = (tick: number): VfxGpuTickIntent => ({
251
+ sequence: tick + 1,
252
+ player,
253
+ emitter: {
254
+ id: 'masked',
255
+ module: 'masked.vfx.wgsl',
256
+ capacity: 4,
257
+ backend: { required: 'gpu' },
258
+ space: 'local',
259
+ simulationWhenCulled: 'continue',
260
+ schedule: { rate: 0, bursts: [] },
261
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
262
+ wgsl: '// masked',
263
+ reflection: {
264
+ hooks: ['vfx_spawn', 'vfx_update'],
265
+ imports: [],
266
+ resources: [],
267
+ entryPoints: ['forgeax_vfx_spawn_main'],
268
+ bindings: [],
269
+ },
270
+ renderers: [],
271
+ },
272
+ programFingerprint: 'masked-program',
273
+ reset: tick === 0,
274
+ fixedDelta: 1 / 60,
275
+ phaseTick: tick + 7,
276
+ tick,
277
+ seed: 1,
278
+ playCycle: 0,
279
+ spawnCount: tick === 0 ? 1 : 0,
280
+ firstParticleId: 0,
281
+ instanceGeneration: 0,
282
+ instancePatchCount: 0,
283
+ parameterBlock: new Uint8Array(),
284
+ canonicalPayload: new Uint8Array(),
285
+ replayInput: {
286
+ seed: 1,
287
+ tick,
288
+ generation: 0,
289
+ sequence: tick + 1,
290
+ fingerprint: 'masked-program',
291
+ payload: new Uint8Array(),
292
+ values: {},
293
+ channelInputs: [],
294
+ droppedCount: 0,
295
+ },
296
+ channelInputs: [],
297
+ eventCounters: {
298
+ queued: 0,
299
+ produced: 0,
300
+ consumed: 0,
301
+ dropped: 0,
302
+ overflow: 0,
303
+ fanOut: 0,
304
+ recursionDepth: 0,
305
+ lastSequence: -1,
306
+ },
307
+ });
308
+ let sessionEnabled = true;
309
+ let renderConsumptionEnabled = true;
310
+ let generation = 0;
311
+ const liveBindings = new Set<object>();
312
+ const prepareProgram = vi.fn(() => ok({ generation: 1 } as never));
313
+ const prepareBuffer = vi.fn((_name: string, _descriptor: { readonly data?: Uint8Array }) =>
314
+ ok({ generation: 1 } as never),
315
+ );
316
+ const prepareBindings = vi.fn(() => {
317
+ generation += 1;
318
+ const reference = { generation: 1, id: generation };
319
+ liveBindings.add(reference);
320
+ return ok(reference as never);
321
+ });
322
+ const retainBindings = vi.fn((references: readonly object[]) =>
323
+ references.every((reference) => liveBindings.has(reference))
324
+ ? ok(undefined)
325
+ : err(
326
+ new RenderFeaturePreparationFailedError(
327
+ 'forgeax.vfx-render.gpu-particles',
328
+ -1,
329
+ 'retain-gpu-bindings',
330
+ 'bindings',
331
+ 'persistent-bindings',
332
+ 'bindings-unavailable',
333
+ 'next-frame',
334
+ ),
335
+ ),
336
+ );
337
+ const runtime = {
338
+ hasPlayer: () => true,
339
+ isEmitterSessionEnabled: () => sessionEnabled,
340
+ setEmitterCameraVisibility: () => {},
341
+ };
342
+ const camera = {
343
+ position: new Float32Array(3),
344
+ right: new Float32Array([1, 0, 0]),
345
+ up: new Float32Array([0, 1, 0]),
346
+ viewProjection: new Float32Array(16),
347
+ };
348
+ const feature = gpuParticleRenderFeature({
349
+ camera: { read: () => camera },
350
+ playerConsumption: { isEnabled: () => renderConsumptionEnabled },
351
+ });
352
+ const context = {
353
+ gpu: { prepareProgram, prepareBuffer, prepareBindings, retainBindings },
354
+ targets: [],
355
+ } as never;
356
+ const frame = (intents: readonly VfxGpuTickIntent[], frameNumber: number) =>
357
+ ({
358
+ worlds: [{ world, runtime, camera, intents }],
359
+ frameNumber,
360
+ }) as never;
361
+
362
+ expect(feature.prepare(frame([intent(0)], 1), context).ok).toBe(true);
363
+ const runtimeWrite = prepareBuffer.mock.calls.find(([name]) => name.includes('.runtime.'))?.[1];
364
+ expect(runtimeWrite?.data).toBeInstanceOf(Uint8Array);
365
+ expect(new Uint32Array((runtimeWrite?.data as Uint8Array).buffer)[1]).toBe(7);
366
+ renderConsumptionEnabled = false;
367
+ expect(feature.prepare(frame([], 2), context).ok).toBe(true);
368
+ // Despawned players must not fall back to the retained lastIntent,
369
+ // otherwise the missing Transform is rendered once at world origin.
370
+ expect(retainBindings).toHaveBeenCalledTimes(1);
371
+ renderConsumptionEnabled = true;
372
+ sessionEnabled = false;
373
+ expect(feature.prepare(frame([], 3), context).ok).toBe(true);
374
+ // Session isolation is not a resource lifetime boundary. A paused player
375
+ // can be re-enabled without a fresh simulation intent, so the masked frame
376
+ // must keep its prepared bindings warm while contributing no work.
377
+ expect(retainBindings).toHaveBeenCalledTimes(2);
378
+ sessionEnabled = true;
379
+ expect(feature.prepare(frame([], 4), context).ok).toBe(true);
380
+ expect(retainBindings).toHaveBeenLastCalledWith([expect.objectContaining({ id: generation })]);
381
+ });
382
+
383
+ it('rejects a second host without replacing the first World runtime', async () => {
384
+ const world = new World();
385
+ const assets = {
386
+ installDecoder: () => ({ kind: 'particle-effect', dispose: () => {} }),
387
+ };
388
+ const options = {
389
+ camera: {
390
+ read: () => undefined,
391
+ },
392
+ };
393
+ const first = createVfxRuntimeHost(options);
394
+ const second = createVfxRuntimeHost(options);
395
+ expect(await first.attachWorld({ world, assets: assets as never })).toMatchObject({ ok: true });
396
+ expect(await second.attachWorld({ world, assets: assets as never })).toMatchObject({
397
+ ok: false,
398
+ error: { code: 'vfx-host-world-attach-failed' },
399
+ });
400
+ expect(await first.detachWorld({ world })).toMatchObject({ ok: true });
401
+ });
402
+
403
+ it('keeps depth readiness on the host contract for a real renderer frame', () => {
404
+ const host = createVfxRuntimeHost({
405
+ camera: { read: () => undefined },
406
+ providers: [
407
+ createCameraProvider({ available: () => true }),
408
+ createSceneDepthProvider({ available: () => true }),
409
+ ],
410
+ });
411
+ const result = host.resolveDataInterfaces({
412
+ requirements: [
413
+ {
414
+ token: 'vfx:scene-depth',
415
+ kind: 'scene-depth',
416
+ binding: 9,
417
+ bindingType: 'sampled-depth',
418
+ lifetime: 'generation',
419
+ },
420
+ ],
421
+ generation: 1,
422
+ });
423
+ expect(result).toMatchObject({ ok: true, value: { readiness: 'ready' } });
424
+ });
425
+
426
+ it('returns an empty keyed inspection aggregate before a player is present', async () => {
427
+ const world = new World();
428
+ const assets = {
429
+ installDecoder: () => ({ kind: 'particle-effect', dispose: () => {} }),
430
+ };
431
+ const host = createVfxRuntimeHost({ camera: { read: () => undefined } });
432
+ await host.attachWorld({ world, assets: assets as never });
433
+
434
+ expect(host.inspect(world)).toEqual({
435
+ generation: 1,
436
+ renderGeneration: 0,
437
+ players: [],
438
+ diagnostics: [],
439
+ });
440
+ });
441
+ });
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createVfxRenderInspectSnapshot } from '../feature/gpu-particle-feature.js';
3
+
4
+ describe('VFX render inspect projection', () => {
5
+ it('reports topology counters, stage readiness, provider state, and timing', () => {
6
+ const snapshot = createVfxRenderInspectSnapshot({
7
+ topology: 'beam',
8
+ capacity: 12,
9
+ produced: 6,
10
+ dropped: 1,
11
+ stageReadiness: [{ id: 'turbulence', state: 'ready', generation: 3 }],
12
+ providerReadiness: { readiness: 'ready', generation: 3 },
13
+ gpuTiming: { frameMs: 0.8, samples: 8 },
14
+ });
15
+
16
+ expect(snapshot).toEqual(
17
+ expect.objectContaining({
18
+ topology: 'beam',
19
+ counters: { capacity: 12, produced: 6, dropped: 1 },
20
+ stageReadiness: expect.arrayContaining([expect.objectContaining({ id: 'turbulence' })]),
21
+ providerReadiness: { readiness: 'ready', generation: 3 },
22
+ gpuTiming: { frameMs: 0.8, samples: 8 },
23
+ }),
24
+ );
25
+ });
26
+ });
@@ -0,0 +1,75 @@
1
+ import type { MaterialAsset, MeshAsset } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import {
4
+ canonicalMeshVertices,
5
+ particleMaterialPass,
6
+ particleMaterialUsesBindings,
7
+ particleMaterialUsesSceneDepth,
8
+ } from '../feature/particle-resources.js';
9
+
10
+ describe('particle mesh resources', () => {
11
+ it('preserves authored canonical vertices when the attribute map is empty', () => {
12
+ const vertices = new Float32Array([
13
+ 0, 1, 0, 0, 1, 0, 0.5, 1, 1, 0, 0, 1, 0, -1, 0, 0, -1, 0, 0.5, 0, 1, 0, 0, 1,
14
+ ]);
15
+ const mesh = {
16
+ vertices,
17
+ indices: new Uint16Array([0, 1, 0]),
18
+ attributes: {},
19
+ submeshes: [],
20
+ } as unknown as MeshAsset;
21
+
22
+ expect(canonicalMeshVertices(mesh)).toBe(vertices);
23
+ });
24
+ });
25
+
26
+ describe('particle material pass', () => {
27
+ it('only requests the shared material bind group for a declared parameter contract', () => {
28
+ expect(particleMaterialUsesBindings(undefined)).toBe(false);
29
+ expect(particleMaterialUsesBindings({ kind: 'material', values: {} })).toBe(false);
30
+ expect(
31
+ particleMaterialUsesBindings({
32
+ kind: 'material',
33
+ parameters: [{ name: 'maskTexture', type: 'texture' }],
34
+ values: {},
35
+ }),
36
+ ).toBe(true);
37
+ });
38
+ it('uses the renderer-specific authored shader and render state', () => {
39
+ const material: MaterialAsset = {
40
+ kind: 'material',
41
+ passes: [
42
+ { name: 'Forward', program: { module: 'game::standard' } },
43
+ {
44
+ name: 'particle-billboard',
45
+ program: { module: 'game::hex-sigil' },
46
+ renderState: { depthWriteEnabled: false, cullMode: 'none' },
47
+ },
48
+ ],
49
+ };
50
+
51
+ expect(particleMaterialPass('billboard', material)).toEqual({
52
+ shader: 'game::hex-sigil',
53
+ renderState: { depthWriteEnabled: false, cullMode: 'none' },
54
+ });
55
+ });
56
+
57
+ it('uses the renderer-owned shader contract for scene-depth selection', () => {
58
+ expect(particleMaterialUsesSceneDepth('group-0-resource')).toBe(true);
59
+ expect(particleMaterialUsesSceneDepth('view-with-resource')).toBe(true);
60
+ expect(particleMaterialUsesSceneDepth('group-0')).toBe(false);
61
+ expect(particleMaterialUsesSceneDepth('view-only')).toBe(false);
62
+ expect(particleMaterialUsesSceneDepth('render-material')).toBe(false);
63
+ });
64
+
65
+ it('does not mistake an ordinary Forward pass for a particle shader', () => {
66
+ const material: MaterialAsset = {
67
+ kind: 'material',
68
+ passes: [{ name: 'Forward', program: { module: 'game::standard' } }],
69
+ };
70
+
71
+ expect(particleMaterialPass('mesh', material)).toEqual({
72
+ shader: 'forgeax::vfx-render.particles.mesh',
73
+ });
74
+ });
75
+ });