@forgeax/engine-vfx-render 0.1.3 → 0.1.6

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/engine-vfx-render",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -22,13 +22,13 @@
22
22
  "LICENSE"
23
23
  ],
24
24
  "dependencies": {
25
- "@forgeax/engine-assets-runtime": "0.1.3",
26
- "@forgeax/engine-ecs": "0.1.3",
27
- "@forgeax/engine-math": "0.1.3",
28
- "@forgeax/engine-render": "0.1.3",
29
- "@forgeax/engine-scene": "0.1.3",
30
- "@forgeax/engine-types": "0.1.3",
31
- "@forgeax/engine-vfx": "0.1.3"
25
+ "@forgeax/engine-assets-runtime": "0.1.6",
26
+ "@forgeax/engine-ecs": "0.1.6",
27
+ "@forgeax/engine-math": "0.1.6",
28
+ "@forgeax/engine-render": "0.1.6",
29
+ "@forgeax/engine-scene": "0.1.6",
30
+ "@forgeax/engine-types": "0.1.6",
31
+ "@forgeax/engine-vfx": "0.1.6"
32
32
  },
33
33
  "devDependencies": {},
34
34
  "forgeax": {
@@ -43,27 +43,4 @@ describe('billboard advanced executable inputs', () => {
43
43
  error: { code: 'vfx-renderer-depth-missing' },
44
44
  });
45
45
  });
46
-
47
- it('changes soft-particle alpha when scene depth changes before Fog mixing', () => {
48
- const nearDepth = resolveBillboardAdvancedState(renderer, {
49
- age: 0.1,
50
- lifetime: 1,
51
- particleDepth: 0.7,
52
- sceneDepth: 0.75,
53
- depthAvailable: true,
54
- });
55
- const farDepth = resolveBillboardAdvancedState(renderer, {
56
- age: 0.1,
57
- lifetime: 1,
58
- particleDepth: 0.7,
59
- sceneDepth: 1.0,
60
- depthAvailable: true,
61
- });
62
-
63
- expect(nearDepth.ok).toBe(true);
64
- expect(farDepth.ok).toBe(true);
65
- if (nearDepth.ok && farDepth.ok) {
66
- expect(farDepth.value.softParticleFade).toBeGreaterThan(nearDepth.value.softParticleFade);
67
- }
68
- });
69
46
  });
@@ -29,7 +29,7 @@ describe('VFX Fog producer contract', () => {
29
29
  }
30
30
  });
31
31
 
32
- it('keeps VFX coverage on the existing reactive graphics lane', () => {
32
+ it('keeps VFX coverage on the current graphics feature lane', () => {
33
33
  const feature = readFileSync(
34
34
  fileURLToPath(new URL('../feature/gpu-particle-feature.ts', import.meta.url)),
35
35
  'utf8',
@@ -38,9 +38,10 @@ describe('VFX Fog producer contract', () => {
38
38
  fileURLToPath(new URL('../feature/camera.ts', import.meta.url)),
39
39
  'utf8',
40
40
  );
41
- expect(feature).toContain("temporalCoverage: 'reactive'");
41
+ expect(feature).toContain('requiredCapabilities');
42
+ expect(feature).toContain('identity: IDENTITY');
42
43
  expect(feature).not.toMatch(/fog(?:History|Texture|Registry)/i);
43
- expect(feature).toContain('worldPosition');
44
+ expect(feature).toContain('worldCenter');
44
45
  expect(camera).toContain('position');
45
46
  expect(camera).toContain('viewProjection');
46
47
  });
@@ -1,15 +1,11 @@
1
- import { createAssetRegistry, createCatalogSource } from '@forgeax/engine-assets-runtime';
2
1
  import { World } from '@forgeax/engine-ecs';
3
- import { RenderFeaturePreparationFailedError } from '@forgeax/engine-render';
4
- import { err, ok } from '@forgeax/engine-types';
5
2
  import {
6
3
  ParticleEffectPlayer,
7
4
  VFX_GPU_RUNTIME_RESOURCE_KEY,
8
5
  type VfxGpuRuntime,
9
6
  type VfxGpuTickIntent,
10
- vfxGpuEffectContribution,
11
7
  } from '@forgeax/engine-vfx';
12
- import { describe, expect, it, vi } from 'vitest';
8
+ import { describe, expect, it } from 'vitest';
13
9
  import { gpuParticleRenderFeature } from '../feature/gpu-particle-feature.js';
14
10
  import {
15
11
  createCameraProvider,
@@ -21,12 +17,10 @@ import {
21
17
  describe('GPU VFX public host', () => {
22
18
  it('owns loader and FixedUpdate attachment without exposing simulation internals', async () => {
23
19
  const world = new World();
24
- const installed: unknown[] = [];
20
+ const registered: unknown[] = [];
25
21
  const assets = {
26
- installDecoder: (kind: unknown, decoder: unknown) => {
27
- installed.push({ kind, decoder });
28
- return { kind: 'particle-effect', dispose: () => {} };
29
- },
22
+ loaders: { registerPackLoader: (loader: unknown) => registered.push(loader) },
23
+ lookup: () => undefined,
30
24
  };
31
25
  const host = createVfxRuntimeHost({
32
26
  camera: {
@@ -41,45 +35,21 @@ describe('GPU VFX public host', () => {
41
35
 
42
36
  const attached = await host.attachWorld({ world, assets: assets as never });
43
37
  expect(attached).toMatchObject({ ok: true, value: { state: 'attached' } });
44
- expect(installed).toHaveLength(1);
38
+ expect(registered).toHaveLength(1);
45
39
  expect(host.feature.requiredMaterialShaders).toEqual(
46
40
  Object.values(PARTICLE_SHADER_IDENTIFIERS),
47
41
  );
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
42
  expect(await host.detachWorld({ world })).toMatchObject({
73
43
  ok: true,
74
44
  value: { state: 'detached' },
75
45
  });
76
- assets.dispose();
77
46
  });
78
47
 
79
48
  it('fences preview controls to one attached host generation', async () => {
80
49
  const world = new World();
81
50
  const assets = {
82
- installDecoder: () => ({ kind: 'particle-effect', dispose: () => {} }),
51
+ loaders: { registerPackLoader: () => {} },
52
+ lookup: () => undefined,
83
53
  };
84
54
  const host = createVfxRuntimeHost({ camera: { read: () => undefined } });
85
55
  expect(host.acquireControl(world)).toMatchObject({
@@ -145,7 +115,7 @@ describe('GPU VFX public host', () => {
145
115
  expect(host.acquireControl(world)).toMatchObject({ ok: true, value: { generation: 2 } });
146
116
  });
147
117
 
148
- it('starts every pending emitter program in one prepare frame', () => {
118
+ it('declares every pending emitter program in one feature plan', () => {
149
119
  const world = new World();
150
120
  const player = world.spawn().unwrap();
151
121
  const intent = (id: string): VfxGpuTickIntent => ({
@@ -206,45 +176,41 @@ describe('GPU VFX public host', () => {
206
176
  lastSequence: -1,
207
177
  },
208
178
  });
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
179
  const feature = gpuParticleRenderFeature({ camera: { read: () => undefined } });
223
-
224
- const prepared = feature.prepare(
180
+ const planned = feature.plan(
225
181
  {
226
182
  worlds: [
227
183
  {
228
184
  world,
229
- runtime: {},
230
- camera: {},
185
+ runtime: {
186
+ isEmitterSessionEnabled: () => true,
187
+ setEmitterCameraVisibility: () => {},
188
+ markEventDispatched: () => {},
189
+ commit: () => {},
190
+ },
191
+ camera: {
192
+ position: new Float32Array(3),
193
+ right: new Float32Array([1, 0, 0]),
194
+ up: new Float32Array([0, 1, 0]),
195
+ viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
196
+ },
231
197
  intents: [intent('first'), intent('second')],
232
198
  },
233
199
  ],
234
200
  frameNumber: 1,
235
201
  } as never,
236
- { gpu: { prepareProgram }, targets: [] } as never,
202
+ { targets: [], caps: {}, frame: { frameNumber: 1 }, generation: 1 } as never,
237
203
  );
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
- ]);
204
+ expect(planned.ok).toBe(true);
205
+ if (!planned.ok) return;
206
+ expect(
207
+ planned.value.resources
208
+ .filter((resource) => resource.kind === 'compute-program')
209
+ .map((resource) => resource.name),
210
+ ).toEqual([expect.stringContaining('.first.'), expect.stringContaining('.second.')]);
245
211
  });
246
212
 
247
- it('fences render consumption and keeps masked bindings warm', () => {
213
+ it('keeps masked bindings warm and sends effect-relative time to WGSL', () => {
248
214
  const world = new World();
249
215
  const player = world.spawn().unwrap();
250
216
  const intent = (tick: number): VfxGpuTickIntent => ({
@@ -306,38 +272,11 @@ describe('GPU VFX public host', () => {
306
272
  },
307
273
  });
308
274
  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
275
  const runtime = {
338
- hasPlayer: () => true,
339
276
  isEmitterSessionEnabled: () => sessionEnabled,
340
277
  setEmitterCameraVisibility: () => {},
278
+ markEventDispatched: () => {},
279
+ commit: () => {},
341
280
  };
342
281
  const camera = {
343
282
  position: new Float32Array(3),
@@ -345,45 +284,39 @@ describe('GPU VFX public host', () => {
345
284
  up: new Float32Array([0, 1, 0]),
346
285
  viewProjection: new Float32Array(16),
347
286
  };
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;
287
+ const feature = gpuParticleRenderFeature({ camera: { read: () => camera } });
288
+ const context = { targets: [], caps: {}, frame: { frameNumber: 1 }, generation: 1 } as never;
356
289
  const frame = (intents: readonly VfxGpuTickIntent[], frameNumber: number) =>
357
290
  ({
358
291
  worlds: [{ world, runtime, camera, intents }],
359
292
  frameNumber,
360
293
  }) as never;
361
294
 
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;
295
+ const active = feature.plan(frame([intent(0)], 1), context);
296
+ expect(active.ok).toBe(true);
297
+ if (!active.ok) return;
298
+ const runtimeWrite = active.value.resources.find(
299
+ (resource) => resource.kind === 'buffer' && resource.name.endsWith('.runtime'),
300
+ );
301
+ expect(runtimeWrite?.kind === 'buffer' ? runtimeWrite.data : undefined).toBeInstanceOf(
302
+ Uint8Array,
303
+ );
304
+ const runtimeBytes = runtimeWrite?.kind === 'buffer' ? runtimeWrite.data : undefined;
305
+ expect(new Uint32Array((runtimeBytes as Uint8Array).buffer)[1]).toBe(7);
372
306
  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);
307
+ const masked = feature.plan(frame([intent(1)], 2), context);
308
+ expect(masked).toMatchObject({ ok: true, value: { resources: [], passes: [] } });
378
309
  sessionEnabled = true;
379
- expect(feature.prepare(frame([], 4), context).ok).toBe(true);
380
- expect(retainBindings).toHaveBeenLastCalledWith([expect.objectContaining({ id: generation })]);
310
+ const resumed = feature.plan(frame([intent(2)], 3), context);
311
+ expect(resumed.ok).toBe(true);
312
+ if (resumed.ok) expect(resumed.value.resources.length).toBeGreaterThan(0);
381
313
  });
382
314
 
383
315
  it('rejects a second host without replacing the first World runtime', async () => {
384
316
  const world = new World();
385
317
  const assets = {
386
- installDecoder: () => ({ kind: 'particle-effect', dispose: () => {} }),
318
+ loaders: { registerPackLoader: () => {} },
319
+ lookup: () => undefined,
387
320
  };
388
321
  const options = {
389
322
  camera: {
@@ -426,7 +359,8 @@ describe('GPU VFX public host', () => {
426
359
  it('returns an empty keyed inspection aggregate before a player is present', async () => {
427
360
  const world = new World();
428
361
  const assets = {
429
- installDecoder: () => ({ kind: 'particle-effect', dispose: () => {} }),
362
+ loaders: { registerPackLoader: () => {} },
363
+ lookup: () => undefined,
430
364
  };
431
365
  const host = createVfxRuntimeHost({ camera: { read: () => undefined } });
432
366
  await host.attachWorld({ world, assets: assets as never });
@@ -4,7 +4,6 @@ import {
4
4
  canonicalMeshVertices,
5
5
  particleMaterialPass,
6
6
  particleMaterialUsesBindings,
7
- particleMaterialUsesSceneDepth,
8
7
  } from '../feature/particle-resources.js';
9
8
 
10
9
  describe('particle mesh resources', () => {
@@ -54,14 +53,6 @@ describe('particle material pass', () => {
54
53
  });
55
54
  });
56
55
 
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
56
  it('does not mistake an ordinary Forward pass for a particle shader', () => {
66
57
  const material: MaterialAsset = {
67
58
  kind: 'material',
@@ -1,6 +1,10 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
+ import type { RenderFeaturePlan } from '@forgeax/engine-render';
4
+ import type { VfxGpuTickIntent } from '@forgeax/engine-vfx';
3
5
  import { describe, expect, it } from 'vitest';
6
+ import { freezeRenderFeaturePlan } from '../../../render/src/features/plan';
7
+ import { gpuParticleRenderFeature } from '../feature/gpu-particle-feature.js';
4
8
 
5
9
  const stagePlanPath = resolve(import.meta.dirname, '../feature/stage-plan.ts');
6
10
 
@@ -13,4 +17,132 @@ describe('VFX managed stage execution seam', () => {
13
17
  expect(source).toContain('forgeax_vfx_stage_');
14
18
  expect(source).not.toContain('authorRawDispatch');
15
19
  });
20
+
21
+ it('projects a VFX stage into the closed render feature plan surface', () => {
22
+ const source = readFileSync(
23
+ resolve(import.meta.dirname, '../feature/gpu-particle-feature.ts'),
24
+ 'utf8',
25
+ );
26
+ const plan: RenderFeaturePlan = { resources: [], passes: [] };
27
+ expect(source).toContain('RenderFeaturePlan');
28
+ expect(source).not.toMatch(/GPUCommandEncoder|queue\.submit|encoder\.finish/);
29
+ expect(plan.passes).toEqual([]);
30
+ });
31
+
32
+ it('declares storage-produced indirect work and dependent raster descriptors', () => {
33
+ const intent = {
34
+ sequence: 1,
35
+ player: 1,
36
+ emitter: {
37
+ id: 'spark',
38
+ capacity: 4,
39
+ space: 'world',
40
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
41
+ wgsl: '// cooked VFX program',
42
+ reflection: {
43
+ entryPoints: ['forgeax_vfx_spawn_main', 'forgeax_vfx_billboard_main'],
44
+ bindings: [
45
+ {
46
+ entries: [
47
+ { binding: 0, visibility: 4, buffer: { type: 'storage' } },
48
+ { binding: 1, visibility: 4, buffer: { type: 'uniform' } },
49
+ { binding: 4, visibility: 4, buffer: { type: 'storage' } },
50
+ { binding: 6, visibility: 4, buffer: { type: 'storage' } },
51
+ ],
52
+ },
53
+ ],
54
+ stages: [],
55
+ dataInterfaces: [],
56
+ },
57
+ renderers: [
58
+ {
59
+ kind: 'billboard',
60
+ material: 'material-guid',
61
+ blend: 'additive',
62
+ sorting: 'none',
63
+ },
64
+ ],
65
+ },
66
+ programFingerprint: 'spark-program',
67
+ reset: true,
68
+ fixedDelta: 1 / 60,
69
+ phaseTick: 0,
70
+ tick: 0,
71
+ seed: 1,
72
+ playCycle: 0,
73
+ spawnCount: 1,
74
+ firstParticleId: 0,
75
+ instanceGeneration: 1,
76
+ channelInputs: [],
77
+ eventCounters: {},
78
+ } as unknown as VfxGpuTickIntent;
79
+ const runtime = {
80
+ isEmitterSessionEnabled: () => true,
81
+ setEmitterCameraVisibility: () => {},
82
+ markEventDispatched: () => {},
83
+ commit: () => {},
84
+ };
85
+ const feature = gpuParticleRenderFeature({ camera: { read: () => undefined } });
86
+ const planned = feature.plan(
87
+ {
88
+ worlds: [
89
+ {
90
+ world: {},
91
+ runtime,
92
+ camera: {
93
+ position: new Float32Array(3),
94
+ right: new Float32Array([1, 0, 0]),
95
+ up: new Float32Array([0, 1, 0]),
96
+ viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
97
+ },
98
+ intents: [intent],
99
+ },
100
+ ],
101
+ frameNumber: 1,
102
+ } as never,
103
+ {
104
+ caps: {} as never,
105
+ frame: { frameNumber: 1 },
106
+ generation: 1,
107
+ targets: [
108
+ { name: 'scene-color', kind: 'color', format: 'rgba16float', sampleCount: 1 },
109
+ { name: 'scene-depth', kind: 'depth', format: 'depth24plus', sampleCount: 1 },
110
+ ],
111
+ },
112
+ );
113
+
114
+ expect(planned.ok).toBe(true);
115
+ if (!planned.ok) return;
116
+ expect(
117
+ freezeRenderFeaturePlan(feature.identity, planned.value, [
118
+ { name: 'scene-color', kind: 'color', format: 'rgba16float', sampleCount: 1 },
119
+ { name: 'scene-depth', kind: 'depth', format: 'depth24plus', sampleCount: 1 },
120
+ ]).ok,
121
+ ).toBe(true);
122
+ expect(planned.value.resources).toEqual(
123
+ expect.arrayContaining([
124
+ expect.objectContaining({
125
+ kind: 'buffer',
126
+ name: expect.stringContaining('.indirect'),
127
+ usage: ['storage', 'indirect'],
128
+ }),
129
+ expect.objectContaining({ kind: 'graphics-program' }),
130
+ expect.objectContaining({ kind: 'graphics-bindings' }),
131
+ expect.objectContaining({ kind: 'vertex-data' }),
132
+ ]),
133
+ );
134
+ expect(planned.value.passes).toEqual(
135
+ expect.arrayContaining([
136
+ expect.objectContaining({ kind: 'compute' }),
137
+ expect.objectContaining({
138
+ kind: 'raster',
139
+ draws: [
140
+ expect.objectContaining({
141
+ draw: expect.objectContaining({ kind: 'draw-indirect' }),
142
+ }),
143
+ ],
144
+ }),
145
+ ]),
146
+ );
147
+ });
16
148
  });