@forgeax/engine-vfx-render 0.1.28 → 0.1.29

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 (40) hide show
  1. package/README.md +55 -10
  2. package/dist/__tests__/persistent-ticks.integration.test.d.ts +2 -0
  3. package/dist/__tests__/persistent-ticks.integration.test.d.ts.map +1 -0
  4. package/dist/feature/event-resources.d.ts +10 -3
  5. package/dist/feature/event-resources.d.ts.map +1 -1
  6. package/dist/feature/gpu-particle-feature.d.ts +18 -5
  7. package/dist/feature/gpu-particle-feature.d.ts.map +1 -1
  8. package/dist/feature/particle-resources.d.ts +43 -6
  9. package/dist/feature/particle-resources.d.ts.map +1 -1
  10. package/dist/host/data-interface-providers.d.ts +4 -1
  11. package/dist/host/data-interface-providers.d.ts.map +1 -1
  12. package/dist/host/vfx-runtime-host.d.ts +21 -2
  13. package/dist/host/vfx-runtime-host.d.ts.map +1 -1
  14. package/dist/index.d.ts +4 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.mjs +1027 -307
  17. package/dist/index.mjs.map +1 -1
  18. package/package.json +11 -9
  19. package/src/__tests__/billboard-advanced.integration.test.ts +1 -1
  20. package/src/__tests__/data-interface-providers.unit.test.ts +4 -0
  21. package/src/__tests__/data-interface-public-api.test-d.ts +1 -0
  22. package/src/__tests__/gpu-host.integration.test.ts +1471 -59
  23. package/src/__tests__/particle-resources.unit.test.ts +75 -0
  24. package/src/__tests__/persistent-ticks.integration.test.ts +162 -0
  25. package/src/__tests__/render-vocabulary-owner.test-d.ts +7 -7
  26. package/src/feature/event-resources.ts +18 -4
  27. package/src/feature/gpu-particle-feature.ts +1153 -376
  28. package/src/feature/particle-resources.ts +223 -11
  29. package/src/host/data-interface-providers.ts +19 -0
  30. package/src/host/vfx-runtime-host.ts +82 -2
  31. package/src/index.ts +8 -0
  32. package/src/shaders/beam-inputs.wgsl +40 -0
  33. package/src/shaders/beam.wgsl +4 -3
  34. package/src/shaders/billboard-inputs.wgsl +85 -0
  35. package/src/shaders/mesh-inputs.wgsl +95 -0
  36. package/src/shaders/mesh-shadow.wgsl +18 -0
  37. package/src/shaders/mesh.wgsl +64 -40
  38. package/src/shaders/ribbon-inputs.wgsl +39 -0
  39. package/src/shaders/trail-inputs.wgsl +45 -0
  40. package/src/shaders/trail.wgsl +9 -3
@@ -1,10 +1,13 @@
1
- import { World } from '@forgeax/engine-ecs';
1
+ import { createWorldContext, World } from '@forgeax/engine-ecs';
2
2
  import type { Asset, MaterialAsset } from '@forgeax/engine-types';
3
3
  import {
4
4
  ParticleEffectPlayer,
5
5
  VFX_GPU_RUNTIME_RESOURCE_KEY,
6
+ VFX_PARTICLE_CORE_LAYOUT,
7
+ type VfxGpuEffectAsset,
6
8
  type VfxGpuRuntime,
7
9
  type VfxGpuTickIntent,
10
+ vfxGpuRuntimePlugin,
8
11
  } from '@forgeax/engine-vfx';
9
12
  import { describe, expect, it } from 'vitest';
10
13
  import { gpuParticleRenderFeature } from '../feature/gpu-particle-feature.js';
@@ -12,9 +15,76 @@ import {
12
15
  createCameraProvider,
13
16
  createSceneDepthProvider,
14
17
  createVfxRuntimeHost,
18
+ PARTICLE_INPUT_SHADER_IDENTIFIERS,
15
19
  PARTICLE_SHADER_IDENTIFIERS,
16
20
  } from '../index.js';
17
21
 
22
+ /** Keep legacy hand-authored fixtures executable on the one-cut Program v3 runtime. */
23
+ function asV3Effect(input: unknown): VfxGpuEffectAsset {
24
+ type FixtureRecord = Record<string, unknown>;
25
+ const value = input as FixtureRecord;
26
+ const program = value.program as FixtureRecord;
27
+ const emitters = (program.emitters as readonly FixtureRecord[]).map((emitter) => {
28
+ const renderers = (emitter.renderers ?? []) as readonly FixtureRecord[];
29
+ const reflection = emitter.reflection as FixtureRecord;
30
+ return {
31
+ ...emitter,
32
+ reflection: {
33
+ hooks: ['vfx_spawn', 'vfx_update'],
34
+ imports: [],
35
+ resources: reflection.resources ?? [],
36
+ entryPoints: reflection.entryPoints ?? [],
37
+ bindings: reflection.bindings ?? [],
38
+ layout: reflection.layout ?? {
39
+ version: 3,
40
+ parameters: { name: 'VfxParameters', fields: [], size: 0, alignment: 1 },
41
+ custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
42
+ core: VFX_PARTICLE_CORE_LAYOUT,
43
+ customLayout: {
44
+ name: 'VfxCustom',
45
+ fields: [],
46
+ size: 0,
47
+ alignment: 1,
48
+ stride: 0,
49
+ lanes: 0,
50
+ },
51
+ fingerprint: 'sha256:legacy-fixture-layout',
52
+ },
53
+ dataInterfaces: reflection.dataInterfaces ?? [],
54
+ eventChannels: reflection.eventChannels ?? [],
55
+ events: reflection.events ?? [],
56
+ eventEntryPoint: 'forgeax_vfx_event_main',
57
+ stages: reflection.stages ?? [],
58
+ renderers: renderers.map((renderer) => ({
59
+ topology: renderer.kind,
60
+ resource: `${renderer.kind}Instances`,
61
+ capacity: renderer.capacity ?? emitter.capacity,
62
+ overflow: 'drop-newest',
63
+ enabled: renderer.enabled !== false,
64
+ shaderInputs: [],
65
+ attributes: {},
66
+ materialInputs: renderer.materialInputs ?? [],
67
+ ...(renderer.materialInputDefinitions === undefined
68
+ ? {}
69
+ : { materialInputDefinitions: renderer.materialInputDefinitions }),
70
+ ...(renderer.historyLength === undefined
71
+ ? {}
72
+ : { historyLength: renderer.historyLength }),
73
+ ...(renderer.castShadows === undefined ? {} : { castShadows: renderer.castShadows }),
74
+ ...(renderer.receiveShadows === undefined
75
+ ? {}
76
+ : { receiveShadows: renderer.receiveShadows }),
77
+ })),
78
+ },
79
+ };
80
+ });
81
+ return {
82
+ ...value,
83
+ schemaVersion: 3,
84
+ program: { ...program, format: 'forgeax-vfx-program-3', emitters },
85
+ } as unknown as VfxGpuEffectAsset;
86
+ }
87
+
18
88
  describe('GPU VFX public host', () => {
19
89
  it('owns loader and FixedUpdate attachment without exposing simulation internals', async () => {
20
90
  const world = new World();
@@ -37,9 +107,10 @@ describe('GPU VFX public host', () => {
37
107
  const attached = await host.attachWorld({ world, assets: assets as never });
38
108
  expect(attached).toMatchObject({ ok: true, value: { state: 'attached' } });
39
109
  expect(registered).toHaveLength(1);
40
- expect(host.feature.requiredMaterialShaders).toEqual(
41
- Object.values(PARTICLE_SHADER_IDENTIFIERS),
42
- );
110
+ expect(host.feature.requiredMaterialShaders).toEqual([
111
+ ...Object.values(PARTICLE_SHADER_IDENTIFIERS),
112
+ ...Object.values(PARTICLE_INPUT_SHADER_IDENTIFIERS),
113
+ ]);
43
114
  expect(await host.detachWorld({ world })).toMatchObject({
44
115
  ok: true,
45
116
  value: { state: 'detached' },
@@ -220,9 +291,16 @@ describe('GPU VFX public host', () => {
220
291
  it('declares every pending emitter program in one feature plan', () => {
221
292
  const world = new World();
222
293
  const player = world.spawn().unwrap();
223
- const intent = (id: string): VfxGpuTickIntent => ({
294
+ const secondPlayer = world.spawn().unwrap();
295
+ let committed = 0;
296
+ let dispatchedEvents = 0;
297
+ const intent = (
298
+ id: string,
299
+ owner: typeof player = player,
300
+ renderers: VfxGpuTickIntent['emitter']['renderers'] = [],
301
+ ): VfxGpuTickIntent => ({
224
302
  sequence: 1,
225
- player,
303
+ player: owner,
226
304
  emitter: {
227
305
  id,
228
306
  module: `${id}.vfx.wgsl`,
@@ -237,10 +315,24 @@ describe('GPU VFX public host', () => {
237
315
  hooks: ['vfx_spawn', 'vfx_update'],
238
316
  imports: [],
239
317
  resources: [],
240
- entryPoints: ['forgeax_vfx_spawn_main'],
241
- bindings: [],
242
- },
243
- renderers: [],
318
+ entryPoints: [
319
+ 'forgeax_vfx_spawn_main',
320
+ ...(renderers.length > 0 ? ['forgeax_vfx_billboard_main'] : []),
321
+ ],
322
+ bindings:
323
+ renderers.length === 0
324
+ ? []
325
+ : [
326
+ {
327
+ entries: [0, 1, 2, 3, 4, 5, 6, 8, 9].map((binding) => ({
328
+ binding,
329
+ visibility: 4,
330
+ buffer: { type: binding === 1 ? 'uniform' : 'storage' },
331
+ })),
332
+ },
333
+ ],
334
+ } as never,
335
+ renderers,
244
336
  },
245
337
  programFingerprint: id,
246
338
  reset: true,
@@ -279,24 +371,34 @@ describe('GPU VFX public host', () => {
279
371
  },
280
372
  });
281
373
  const feature = gpuParticleRenderFeature({ camera: { read: () => undefined } });
374
+ const runtime = {
375
+ isEmitterSessionEnabled: () => true,
376
+ setEmitterCameraVisibility: () => {},
377
+ markEventDispatched: () => {
378
+ dispatchedEvents += 1;
379
+ },
380
+ commit: () => {
381
+ committed += 1;
382
+ },
383
+ };
384
+ const camera = {
385
+ position: new Float32Array(3),
386
+ right: new Float32Array([1, 0, 0]),
387
+ up: new Float32Array([0, 1, 0]),
388
+ viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
389
+ };
282
390
  const planned = feature.plan(
283
391
  {
284
392
  worlds: [
285
393
  {
286
394
  world,
287
- runtime: {
288
- isEmitterSessionEnabled: () => true,
289
- setEmitterCameraVisibility: () => {},
290
- markEventDispatched: () => {},
291
- commit: () => {},
292
- },
293
- camera: {
294
- position: new Float32Array(3),
295
- right: new Float32Array([1, 0, 0]),
296
- up: new Float32Array([0, 1, 0]),
297
- viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
298
- },
299
- intents: [intent('first'), intent('second')],
395
+ runtime,
396
+ camera,
397
+ intents: [
398
+ intent('first'),
399
+ intent('second', secondPlayer),
400
+ { ...intent('first'), reset: false, sequence: 3 },
401
+ ],
300
402
  },
301
403
  ],
302
404
  frameNumber: 1,
@@ -309,7 +411,142 @@ describe('GPU VFX public host', () => {
309
411
  planned.value.resources
310
412
  .filter((resource) => resource.kind === 'compute-program')
311
413
  .map((resource) => resource.name),
312
- ).toEqual([expect.stringContaining('.first.'), expect.stringContaining('.second.')]);
414
+ ).toEqual([
415
+ expect.stringContaining(`.p-${Number(player).toString(36)}.first.g-1.`),
416
+ expect.stringContaining(`.p-${Number(secondPlayer).toString(36)}.second.g-1.`),
417
+ ]);
418
+ expect(
419
+ planned.value.passes.filter(
420
+ (pass) => pass.kind === 'compute' && pass.name.includes('.first.g-1.tick-'),
421
+ ),
422
+ ).toHaveLength(2);
423
+ expect(committed).toBe(0);
424
+ expect(dispatchedEvents).toBe(0);
425
+ feature.onFrameSubmitted?.({
426
+ worlds: [
427
+ {
428
+ world,
429
+ runtime,
430
+ camera,
431
+ intents: [],
432
+ },
433
+ ],
434
+ frameNumber: 1,
435
+ } as never);
436
+ // Submission consumes the ledger captured by plan, not the declarative
437
+ // plan call itself. The runtime is committed once and all three queued
438
+ // ticks have their event counters acknowledged.
439
+ expect(committed).toBe(1);
440
+ expect(dispatchedEvents).toBe(3);
441
+
442
+ const warm = feature.plan(
443
+ {
444
+ worlds: [
445
+ { world, runtime, camera, intents: [{ ...intent('first'), reset: false, sequence: 4 }] },
446
+ ],
447
+ frameNumber: 2,
448
+ } as never,
449
+ { targets: [], caps: {}, frame: { frameNumber: 2 }, generation: 1 } as never,
450
+ );
451
+ expect(warm.ok).toBe(true);
452
+ if (!warm.ok) return;
453
+ expect(
454
+ warm.value.resources.some(
455
+ (resource) => resource.kind === 'compute-program' && resource.name.includes('.first.g-1.'),
456
+ ),
457
+ ).toBe(true);
458
+ const warmParticles = warm.value.resources.find(
459
+ (resource) => resource.kind === 'buffer' && resource.name.endsWith('.particles'),
460
+ );
461
+ expect(warmParticles).toBeDefined();
462
+ expect(warmParticles).not.toHaveProperty('data');
463
+ feature.onFrameSubmitted?.({ worlds: [], frameNumber: 2 } as never);
464
+
465
+ const reset = feature.plan(
466
+ {
467
+ worlds: [
468
+ { world, runtime, camera, intents: [{ ...intent('first'), reset: true, sequence: 5 }] },
469
+ ],
470
+ frameNumber: 3,
471
+ } as never,
472
+ { targets: [], caps: {}, frame: { frameNumber: 3 }, generation: 1 } as never,
473
+ );
474
+ expect(reset.ok).toBe(true);
475
+ if (reset.ok)
476
+ expect(
477
+ reset.value.resources.some(
478
+ (resource) =>
479
+ resource.kind === 'compute-program' && resource.name.includes('.first.g-2.'),
480
+ ),
481
+ ).toBe(true);
482
+ feature.onFrameSubmitted?.({ worlds: [], frameNumber: 3 } as never);
483
+
484
+ const projectedFirst = feature.plan(
485
+ {
486
+ worlds: [
487
+ {
488
+ world,
489
+ runtime,
490
+ camera,
491
+ intents: [
492
+ intent('projected', player, [{ kind: 'billboard', material: 'particle-material' }]),
493
+ ],
494
+ },
495
+ ],
496
+ frameNumber: 4,
497
+ } as never,
498
+ { targets: [], caps: {}, frame: { frameNumber: 4 }, generation: 1 } as never,
499
+ );
500
+ feature.onFrameSubmitted?.({ worlds: [], frameNumber: 4 } as never);
501
+ const projectedSecond = feature.plan(
502
+ {
503
+ worlds: [
504
+ {
505
+ world,
506
+ runtime,
507
+ camera,
508
+ intents: [
509
+ {
510
+ ...intent('projected', player, [
511
+ { kind: 'billboard', material: 'particle-material' },
512
+ ]),
513
+ reset: false,
514
+ sequence: 2,
515
+ },
516
+ {
517
+ ...intent('projected', player, [
518
+ { kind: 'billboard', material: 'particle-material' },
519
+ ]),
520
+ reset: false,
521
+ sequence: 3,
522
+ },
523
+ ],
524
+ },
525
+ ],
526
+ frameNumber: 5,
527
+ } as never,
528
+ { targets: [], caps: {}, frame: { frameNumber: 5 }, generation: 1 } as never,
529
+ );
530
+ expect(projectedFirst.ok).toBe(true);
531
+ expect(projectedSecond.ok).toBe(true);
532
+ if (projectedFirst.ok && projectedSecond.ok) {
533
+ const projectionBinding = (plan: typeof projectedFirst.value) =>
534
+ plan.resources.find(
535
+ (resource) =>
536
+ resource.kind === 'compute-bindings' &&
537
+ resource.name.endsWith('.renderer-0.compute-bindings'),
538
+ );
539
+ const firstBinding = projectionBinding(projectedFirst.value);
540
+ const secondBinding = projectionBinding(projectedSecond.value);
541
+ expect(firstBinding).toBeDefined();
542
+ expect(secondBinding).toBeDefined();
543
+ if (firstBinding?.kind === 'compute-bindings' && secondBinding?.kind === 'compute-bindings') {
544
+ expect(firstBinding.entries).toEqual(secondBinding.entries);
545
+ expect(firstBinding.entries.find((entry) => entry.binding === 8)?.resource).toContain(
546
+ '.projection-event-inputs',
547
+ );
548
+ }
549
+ }
313
550
  });
314
551
 
315
552
  it('keeps masked bindings warm and sends effect-relative time to WGSL', () => {
@@ -334,7 +571,7 @@ describe('GPU VFX public host', () => {
334
571
  resources: [],
335
572
  entryPoints: ['forgeax_vfx_spawn_main'],
336
573
  bindings: [],
337
- },
574
+ } as never,
338
575
  renderers: [],
339
576
  },
340
577
  programFingerprint: 'masked-program',
@@ -414,48 +651,1223 @@ describe('GPU VFX public host', () => {
414
651
  if (resumed.ok) expect(resumed.value.resources.length).toBeGreaterThan(0);
415
652
  });
416
653
 
417
- it('rejects a second host without replacing the first World runtime', async () => {
654
+ it('acknowledges a skipped head and keeps a reset resource identity across retry', () => {
418
655
  const world = new World();
419
- const assets = {
420
- loaders: { registerPackLoader: () => {} },
421
- lookup: () => undefined,
422
- };
423
- const options = {
424
- camera: {
425
- read: () => undefined,
656
+ const player = world.spawn().unwrap();
657
+ const commits: number[] = [];
658
+ let dispatched = 0;
659
+ const makeIntent = (id: string, sequence: number, reset: boolean): VfxGpuTickIntent => ({
660
+ sequence,
661
+ player,
662
+ emitter: {
663
+ id,
664
+ module: `${id}.vfx.wgsl`,
665
+ capacity: 4,
666
+ backend: { required: 'gpu' },
667
+ space: 'world',
668
+ simulationWhenCulled: 'continue',
669
+ schedule: { rate: 0, bursts: [] },
670
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
671
+ wgsl: `// ${id}`,
672
+ reflection: {
673
+ hooks: ['vfx_spawn', 'vfx_update'],
674
+ imports: [],
675
+ resources: [],
676
+ entryPoints: ['forgeax_vfx_spawn_main'],
677
+ bindings: [],
678
+ } as never,
679
+ renderers: [],
680
+ },
681
+ programFingerprint: `${id}-program`,
682
+ reset,
683
+ fixedDelta: 1 / 60,
684
+ phaseTick: sequence,
685
+ tick: sequence,
686
+ seed: 1,
687
+ playCycle: 0,
688
+ spawnCount: 1,
689
+ firstParticleId: 0,
690
+ instanceGeneration: 0,
691
+ instancePatchCount: 0,
692
+ parameterBlock: new Uint8Array(),
693
+ canonicalPayload: new Uint8Array(),
694
+ replayInput: {
695
+ seed: 1,
696
+ tick: sequence,
697
+ generation: 0,
698
+ sequence,
699
+ fingerprint: `${id}-program`,
700
+ payload: new Uint8Array(),
701
+ values: {},
702
+ channelInputs: [],
703
+ droppedCount: 0,
704
+ },
705
+ channelInputs: [],
706
+ eventCounters: {
707
+ queued: 0,
708
+ produced: 0,
709
+ consumed: 0,
710
+ dropped: 0,
711
+ overflow: 0,
712
+ fanOut: 0,
713
+ recursionDepth: 0,
714
+ lastSequence: -1,
426
715
  },
427
- };
428
- const first = createVfxRuntimeHost(options);
429
- const second = createVfxRuntimeHost(options);
430
- expect(await first.attachWorld({ world, assets: assets as never })).toMatchObject({ ok: true });
431
- expect(await second.attachWorld({ world, assets: assets as never })).toMatchObject({
432
- ok: false,
433
- error: { code: 'vfx-host-world-attach-failed' },
434
716
  });
435
- expect(await first.detachWorld({ world })).toMatchObject({ ok: true });
717
+ const runtime = {
718
+ renderGeneration: 1,
719
+ isEmitterSessionEnabled: (_player: number, emitterId: string) => emitterId !== 'masked',
720
+ setEmitterCameraVisibility: () => {},
721
+ markEventDispatched: () => {
722
+ dispatched += 1;
723
+ },
724
+ commit: (sequence: number) => {
725
+ commits.push(sequence);
726
+ },
727
+ };
728
+ const camera = {
729
+ position: new Float32Array(3),
730
+ right: new Float32Array([1, 0, 0]),
731
+ up: new Float32Array([0, 1, 0]),
732
+ viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
733
+ };
734
+ const feature = gpuParticleRenderFeature({ camera: { read: () => camera } });
735
+ const frame = (frameNumber: number) =>
736
+ ({
737
+ worlds: [
738
+ {
739
+ world,
740
+ runtime,
741
+ camera,
742
+ intents: [makeIntent('masked', 1, false), makeIntent('visible', 2, true)],
743
+ },
744
+ ],
745
+ frameNumber,
746
+ }) as never;
747
+ const context = { targets: [], caps: {}, frame: { frameNumber: 1 }, generation: 1 } as never;
748
+ const first = feature.plan(frame(1), context);
749
+ expect(first.ok).toBe(true);
750
+ if (!first.ok) return;
751
+ const firstProgram = first.value.resources.find(
752
+ (resource) => resource.kind === 'compute-program',
753
+ );
754
+ expect(firstProgram?.name).toContain('.visible.g-1.');
755
+ feature.onFrameAborted?.(frame(1));
756
+
757
+ const retry = feature.plan(frame(1), context);
758
+ expect(retry.ok).toBe(true);
759
+ if (!retry.ok) return;
760
+ const retryProgram = retry.value.resources.find(
761
+ (resource) => resource.kind === 'compute-program',
762
+ );
763
+ expect(retryProgram?.name).toBe(firstProgram?.name);
764
+ feature.onFrameSubmitted?.(frame(1));
765
+ expect(commits).toEqual([[1, 2]]);
766
+ expect(dispatched).toBe(1);
436
767
  });
437
768
 
438
- it('keeps depth readiness on the host contract for a real renderer frame', () => {
439
- const host = createVfxRuntimeHost({
440
- camera: { read: () => undefined },
441
- providers: [
442
- createCameraProvider({ available: () => true }),
443
- createSceneDepthProvider({ available: () => true }),
444
- ],
769
+ it('keeps a reset after an earlier tick on retry and isolates reservations by World', () => {
770
+ const camera = {
771
+ position: new Float32Array(3),
772
+ right: new Float32Array([1, 0, 0]),
773
+ up: new Float32Array([0, 1, 0]),
774
+ viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
775
+ };
776
+ const makeIntent = (
777
+ player: VfxGpuTickIntent['player'],
778
+ id: string,
779
+ sequence: number,
780
+ reset: boolean,
781
+ requiresDataInterface = false,
782
+ ): VfxGpuTickIntent => ({
783
+ sequence,
784
+ player,
785
+ emitter: {
786
+ id,
787
+ module: `${id}.vfx.wgsl`,
788
+ capacity: 4,
789
+ backend: { required: 'gpu' },
790
+ space: 'world',
791
+ simulationWhenCulled: 'continue',
792
+ schedule: { rate: 0, bursts: [] },
793
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
794
+ wgsl: `// ${id}`,
795
+ reflection: {
796
+ hooks: ['vfx_spawn', 'vfx_update'],
797
+ imports: [],
798
+ resources: [],
799
+ entryPoints: ['forgeax_vfx_spawn_main'],
800
+ bindings: [],
801
+ ...(requiresDataInterface
802
+ ? {
803
+ dataInterfaces: [
804
+ {
805
+ token: 'vfx:camera',
806
+ kind: 'camera' as const,
807
+ binding: 0,
808
+ bindingType: 'uniform' as const,
809
+ lifetime: 'generation' as const,
810
+ },
811
+ ],
812
+ }
813
+ : {}),
814
+ } as never,
815
+ renderers: [],
816
+ },
817
+ programFingerprint: `${id}-program`,
818
+ reset,
819
+ fixedDelta: 1 / 60,
820
+ phaseTick: sequence,
821
+ tick: sequence,
822
+ seed: 1,
823
+ playCycle: 0,
824
+ spawnCount: 1,
825
+ firstParticleId: 0,
826
+ instanceGeneration: 0,
827
+ instancePatchCount: 0,
828
+ parameterBlock: new Uint8Array(),
829
+ canonicalPayload: new Uint8Array(),
830
+ replayInput: {
831
+ seed: 1,
832
+ tick: sequence,
833
+ generation: 0,
834
+ sequence,
835
+ fingerprint: `${id}-program`,
836
+ payload: new Uint8Array(),
837
+ values: {},
838
+ channelInputs: [],
839
+ droppedCount: 0,
840
+ },
841
+ channelInputs: [],
842
+ eventCounters: {
843
+ queued: 0,
844
+ produced: 0,
845
+ consumed: 0,
846
+ dropped: 0,
847
+ overflow: 0,
848
+ fanOut: 0,
849
+ recursionDepth: 0,
850
+ lastSequence: -1,
851
+ },
445
852
  });
446
- const result = host.resolveDataInterfaces({
447
- requirements: [
448
- {
449
- token: 'vfx:scene-depth',
450
- kind: 'scene-depth',
451
- binding: 9,
452
- bindingType: 'sampled-depth',
453
- lifetime: 'generation',
853
+ const makeRuntime = () => {
854
+ let enabled = true;
855
+ const commits: number[] = [];
856
+ return {
857
+ get enabled() {
858
+ return enabled;
454
859
  },
455
- ],
456
- generation: 1,
860
+ set enabled(value: boolean) {
861
+ enabled = value;
862
+ },
863
+ renderGeneration: 1,
864
+ isEmitterSessionEnabled: () => enabled,
865
+ setEmitterCameraVisibility: () => {},
866
+ markEventDispatched: () => {},
867
+ commit: (sequence: number) => commits.push(sequence),
868
+ commits,
869
+ };
870
+ };
871
+ const world = new World();
872
+ const otherWorld = new World();
873
+ const player = world.spawn().unwrap();
874
+ const otherPlayer = otherWorld.spawn().unwrap();
875
+ const runtime = makeRuntime();
876
+ const otherRuntime = makeRuntime();
877
+ let dataReady = false;
878
+ const feature = gpuParticleRenderFeature({
879
+ camera: { read: () => camera },
880
+ dataInterfaces: {
881
+ resolve: () => (dataReady ? ({ ok: true } as never) : ({ ok: false } as never)),
882
+ },
457
883
  });
458
- expect(result).toMatchObject({ ok: true, value: { readiness: 'ready' } });
884
+ const context = { targets: [], caps: {}, frame: { frameNumber: 1 }, generation: 1 } as never;
885
+ const frame = (
886
+ frameNumber: number,
887
+ entries: readonly { world: World; runtime: typeof runtime; intents: VfxGpuTickIntent[] }[],
888
+ ) =>
889
+ ({
890
+ worlds: entries.map((entry) => ({ ...entry, camera })),
891
+ frameNumber,
892
+ }) as never;
893
+
894
+ const first = feature.plan(
895
+ frame(1, [
896
+ {
897
+ world,
898
+ runtime,
899
+ intents: [
900
+ makeIntent(player, 'ordered', 1, false),
901
+ makeIntent(player, 'ordered', 2, true),
902
+ ],
903
+ },
904
+ ]),
905
+ context,
906
+ );
907
+ expect(first.ok).toBe(true);
908
+ if (!first.ok) return;
909
+ const programNames = first.value.resources
910
+ .filter((resource) => resource.kind === 'compute-program')
911
+ .map((resource) => resource.name);
912
+ expect(programNames).toEqual([
913
+ expect.stringContaining('.ordered.g-0.compute-program'),
914
+ expect.stringContaining('.ordered.g-1.compute-program'),
915
+ ]);
916
+ const resetParticles = first.value.resources.find(
917
+ (resource) => resource.kind === 'buffer' && resource.name.includes('.ordered.g-1.particles'),
918
+ );
919
+ expect(resetParticles).toHaveProperty('data');
920
+ feature.onFrameAborted?.(frame(1, [{ world, runtime, intents: [] }]));
921
+
922
+ const retry = feature.plan(
923
+ frame(1, [
924
+ {
925
+ world,
926
+ runtime,
927
+ intents: [
928
+ makeIntent(player, 'ordered', 1, false),
929
+ makeIntent(player, 'ordered', 2, true),
930
+ ],
931
+ },
932
+ ]),
933
+ context,
934
+ );
935
+ expect(retry.ok).toBe(true);
936
+ if (!retry.ok) return;
937
+ expect(
938
+ retry.value.resources
939
+ .filter((resource) => resource.kind === 'compute-program')
940
+ .map((resource) => resource.name),
941
+ ).toEqual(programNames);
942
+ const retryResetParticles = retry.value.resources.find(
943
+ (resource) => resource.kind === 'buffer' && resource.name.includes('.ordered.g-1.particles'),
944
+ );
945
+ expect(retryResetParticles).toHaveProperty('data');
946
+ feature.onFrameSubmitted?.(frame(1, [{ world, runtime, intents: [] }]));
947
+ expect(runtime.commits).toEqual([[1, 2]]);
948
+
949
+ // A reset blocked by render consumption must stay deferred. It must not
950
+ // advance the committed epoch or leak a fake GPU state into a later tick.
951
+ runtime.enabled = false;
952
+ const skipped = feature.plan(
953
+ frame(2, [{ world, runtime, intents: [makeIntent(player, 'ordered', 3, true)] }]),
954
+ context,
955
+ );
956
+ expect(skipped).toMatchObject({ ok: true, value: { resources: [], passes: [] } });
957
+ feature.onFrameSubmitted?.(frame(2, [{ world, runtime, intents: [] }]));
958
+ expect(runtime.commits).toEqual([[1, 2]]);
959
+ runtime.enabled = true;
960
+ const afterSkipped = feature.plan(
961
+ frame(3, [{ world, runtime, intents: [makeIntent(player, 'ordered', 4, false)] }]),
962
+ context,
963
+ );
964
+ expect(afterSkipped.ok).toBe(true);
965
+ if (!afterSkipped.ok) return;
966
+ expect(
967
+ afterSkipped.value.resources.some(
968
+ (resource) =>
969
+ resource.kind === 'compute-program' && resource.name.includes('.ordered.g-1.'),
970
+ ),
971
+ ).toBe(true);
972
+ feature.onFrameSubmitted?.(frame(3, [{ world, runtime, intents: [] }]));
973
+
974
+ // Two deferred reset intents retain distinct ordered epochs and both
975
+ // initialize when the provider becomes available.
976
+ const deferredIntents = [
977
+ makeIntent(player, 'ordered', 5, true, true),
978
+ makeIntent(player, 'ordered', 6, true, true),
979
+ ];
980
+ const deferred = feature.plan(
981
+ frame(4, [{ world, runtime, intents: deferredIntents }]),
982
+ context,
983
+ );
984
+ expect(deferred).toMatchObject({ ok: true, value: { resources: [], passes: [] } });
985
+ feature.onFrameSubmitted?.(frame(4, [{ world, runtime, intents: [] }]));
986
+ dataReady = true;
987
+ const resumedDeferred = feature.plan(
988
+ frame(5, [{ world, runtime, intents: deferredIntents }]),
989
+ context,
990
+ );
991
+ expect(resumedDeferred.ok).toBe(true);
992
+ if (!resumedDeferred.ok) return;
993
+ const deferredPrograms = resumedDeferred.value.resources
994
+ .filter((resource) => resource.kind === 'compute-program')
995
+ .map((resource) => resource.name);
996
+ expect(deferredPrograms).toEqual([
997
+ expect.stringContaining('.ordered.g-2.compute-program'),
998
+ expect.stringContaining('.ordered.g-3.compute-program'),
999
+ ]);
1000
+ expect(
1001
+ resumedDeferred.value.resources.filter(
1002
+ (resource) =>
1003
+ resource.kind === 'buffer' &&
1004
+ resource.name.includes('.ordered.g-2.particles') &&
1005
+ resource.data !== undefined,
1006
+ ),
1007
+ ).toHaveLength(1);
1008
+ expect(
1009
+ resumedDeferred.value.resources.filter(
1010
+ (resource) =>
1011
+ resource.kind === 'buffer' &&
1012
+ resource.name.includes('.ordered.g-3.particles') &&
1013
+ resource.data !== undefined,
1014
+ ),
1015
+ ).toHaveLength(1);
1016
+ feature.onFrameSubmitted?.(frame(5, [{ world, runtime, intents: [] }]));
1017
+
1018
+ // The other World starts at the same local sequence but must get its own
1019
+ // first reset epoch rather than inheriting this World's reservation.
1020
+ const other = feature.plan(
1021
+ frame(4, [
1022
+ {
1023
+ world: otherWorld,
1024
+ runtime: otherRuntime,
1025
+ intents: [makeIntent(otherPlayer, 'ordered', 1, true)],
1026
+ },
1027
+ ]),
1028
+ context,
1029
+ );
1030
+ expect(other.ok).toBe(true);
1031
+ if (!other.ok) return;
1032
+ expect(
1033
+ other.value.resources.some(
1034
+ (resource) =>
1035
+ resource.kind === 'compute-program' && resource.name.includes('.ordered.g-1.'),
1036
+ ),
1037
+ ).toBe(true);
1038
+
1039
+ // Detaching and reattaching the same World creates a new runtime
1040
+ // incarnation. Its first reset may reuse the local player/emitter IDs,
1041
+ // but its GPU resource identity must not collide with the old attachment.
1042
+ const replacementRuntime = makeRuntime();
1043
+ const replacement = feature.plan(
1044
+ frame(6, [
1045
+ {
1046
+ world,
1047
+ runtime: replacementRuntime,
1048
+ intents: [makeIntent(player, 'ordered', 1, true)],
1049
+ },
1050
+ ]),
1051
+ context,
1052
+ );
1053
+ expect(replacement.ok).toBe(true);
1054
+ if (replacement.ok) {
1055
+ const replacementProgram = replacement.value.resources.find(
1056
+ (resource) => resource.kind === 'compute-program',
1057
+ );
1058
+ expect(replacementProgram?.name).toContain('.ordered.g-1.');
1059
+ expect(replacementProgram?.name).not.toBe(programNames[0]);
1060
+ }
1061
+ });
1062
+
1063
+ it('defers a reset while player render consumption is paused', () => {
1064
+ const world = new World();
1065
+ const player = world.spawn().unwrap();
1066
+ const camera = {
1067
+ position: new Float32Array(3),
1068
+ right: new Float32Array([1, 0, 0]),
1069
+ up: new Float32Array([0, 1, 0]),
1070
+ viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
1071
+ };
1072
+ const intent: VfxGpuTickIntent = {
1073
+ sequence: 1,
1074
+ player,
1075
+ emitter: {
1076
+ id: 'paused-reset',
1077
+ module: 'paused-reset.vfx.wgsl',
1078
+ capacity: 4,
1079
+ backend: { required: 'gpu' },
1080
+ space: 'world',
1081
+ simulationWhenCulled: 'continue',
1082
+ schedule: { rate: 1, bursts: [] },
1083
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
1084
+ wgsl: '// paused reset',
1085
+ reflection: {
1086
+ hooks: ['vfx_spawn', 'vfx_update'],
1087
+ imports: [],
1088
+ resources: [],
1089
+ entryPoints: ['forgeax_vfx_spawn_main'],
1090
+ bindings: [],
1091
+ } as never,
1092
+ renderers: [],
1093
+ },
1094
+ programFingerprint: 'paused-reset-program',
1095
+ reset: true,
1096
+ fixedDelta: 1 / 60,
1097
+ phaseTick: 0,
1098
+ tick: 1,
1099
+ seed: 2,
1100
+ playCycle: 1,
1101
+ spawnCount: 1,
1102
+ firstParticleId: 0,
1103
+ instanceGeneration: 0,
1104
+ instancePatchCount: 0,
1105
+ parameterBlock: new Uint8Array(),
1106
+ canonicalPayload: new Uint8Array(),
1107
+ replayInput: {
1108
+ seed: 2,
1109
+ tick: 1,
1110
+ generation: 0,
1111
+ sequence: 1,
1112
+ fingerprint: 'paused-reset-program',
1113
+ payload: new Uint8Array(),
1114
+ values: {},
1115
+ channelInputs: [],
1116
+ droppedCount: 0,
1117
+ },
1118
+ channelInputs: [],
1119
+ eventCounters: {
1120
+ queued: 0,
1121
+ produced: 0,
1122
+ consumed: 0,
1123
+ dropped: 0,
1124
+ overflow: 0,
1125
+ fanOut: 0,
1126
+ recursionDepth: 0,
1127
+ lastSequence: -1,
1128
+ },
1129
+ };
1130
+ let consumptionEnabled = false;
1131
+ const commits: { readonly sequence: number; readonly published: readonly number[] }[] = [];
1132
+ const runtime = {
1133
+ renderGeneration: 1,
1134
+ isEmitterSessionEnabled: () => true,
1135
+ setEmitterCameraVisibility: () => {},
1136
+ markEventDispatched: () => {},
1137
+ commit: (sequence: number, published: readonly number[] = []) =>
1138
+ commits.push({ sequence, published }),
1139
+ };
1140
+ const feature = gpuParticleRenderFeature({
1141
+ camera: { read: () => camera },
1142
+ playerConsumption: { isEnabled: () => consumptionEnabled },
1143
+ });
1144
+ const frame = (frameNumber: number) =>
1145
+ ({ worlds: [{ world, runtime, camera, intents: [intent] }], frameNumber }) as never;
1146
+
1147
+ const paused = feature.plan(frame(1), {
1148
+ targets: [],
1149
+ caps: {},
1150
+ frame: { frameNumber: 1 },
1151
+ generation: 1,
1152
+ } as never);
1153
+ expect(paused).toMatchObject({ ok: true, value: { passes: [] } });
1154
+ feature.onFrameSubmitted?.({ worlds: [], frameNumber: 1 } as never);
1155
+ expect(commits).toEqual([]);
1156
+
1157
+ consumptionEnabled = true;
1158
+ const resumed = feature.plan(frame(2), {
1159
+ targets: [],
1160
+ caps: {},
1161
+ frame: { frameNumber: 2 },
1162
+ generation: 1,
1163
+ } as never);
1164
+ expect(resumed.ok).toBe(true);
1165
+ if (resumed.ok) {
1166
+ expect(resumed.value.resources.some((resource) => resource.kind === 'compute-program')).toBe(
1167
+ true,
1168
+ );
1169
+ }
1170
+ feature.onFrameSubmitted?.({ worlds: [], frameNumber: 2 } as never);
1171
+ expect(commits).toEqual([{ sequence: [1], published: [1] }]);
1172
+ });
1173
+
1174
+ it('keeps a paused reset barrier local to its player stream', () => {
1175
+ const world = new World();
1176
+ const pausedPlayer = world.spawn().unwrap();
1177
+ const healthyPlayer = world.spawn().unwrap();
1178
+ const camera = {
1179
+ position: new Float32Array(3),
1180
+ right: new Float32Array([1, 0, 0]),
1181
+ up: new Float32Array([0, 1, 0]),
1182
+ viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
1183
+ };
1184
+ const intent = (
1185
+ player: VfxGpuTickIntent['player'],
1186
+ id: string,
1187
+ sequence: number,
1188
+ reset: boolean,
1189
+ ): VfxGpuTickIntent => ({
1190
+ sequence,
1191
+ player,
1192
+ emitter: {
1193
+ id,
1194
+ module: `${id}.vfx.wgsl`,
1195
+ capacity: 4,
1196
+ backend: { required: 'gpu' },
1197
+ space: 'world',
1198
+ simulationWhenCulled: 'continue',
1199
+ schedule: { rate: 0, bursts: [] },
1200
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
1201
+ wgsl: `// ${id}`,
1202
+ reflection: {
1203
+ hooks: ['vfx_spawn', 'vfx_update'],
1204
+ imports: [],
1205
+ resources: [],
1206
+ entryPoints: ['forgeax_vfx_spawn_main'],
1207
+ bindings: [],
1208
+ } as never,
1209
+ renderers: [],
1210
+ },
1211
+ programFingerprint: id,
1212
+ reset,
1213
+ fixedDelta: 1 / 60,
1214
+ phaseTick: sequence,
1215
+ tick: sequence,
1216
+ seed: 1,
1217
+ playCycle: 0,
1218
+ spawnCount: 1,
1219
+ firstParticleId: 0,
1220
+ instanceGeneration: 0,
1221
+ instancePatchCount: 0,
1222
+ parameterBlock: new Uint8Array(),
1223
+ canonicalPayload: new Uint8Array(),
1224
+ replayInput: {
1225
+ seed: 1,
1226
+ tick: sequence,
1227
+ generation: 0,
1228
+ sequence,
1229
+ fingerprint: id,
1230
+ payload: new Uint8Array(),
1231
+ values: {},
1232
+ channelInputs: [],
1233
+ droppedCount: 0,
1234
+ },
1235
+ channelInputs: [],
1236
+ eventCounters: {
1237
+ queued: 0,
1238
+ produced: 0,
1239
+ consumed: 0,
1240
+ dropped: 0,
1241
+ overflow: 0,
1242
+ fanOut: 0,
1243
+ recursionDepth: 0,
1244
+ lastSequence: -1,
1245
+ },
1246
+ });
1247
+ const runtime = {
1248
+ renderGeneration: 0,
1249
+ isEmitterSessionEnabled: () => true,
1250
+ setEmitterCameraVisibility: () => {},
1251
+ markEventDispatched: () => {},
1252
+ commit: () => {},
1253
+ };
1254
+ const feature = gpuParticleRenderFeature({
1255
+ camera: { read: () => camera },
1256
+ playerConsumption: {
1257
+ isEnabled: (_world, player) => player !== pausedPlayer,
1258
+ },
1259
+ });
1260
+ const planned = feature.plan(
1261
+ {
1262
+ worlds: [
1263
+ {
1264
+ world,
1265
+ runtime,
1266
+ camera,
1267
+ intents: [
1268
+ intent(pausedPlayer, 'paused', 1, true),
1269
+ intent(healthyPlayer, 'healthy', 2, true),
1270
+ ],
1271
+ },
1272
+ ],
1273
+ frameNumber: 1,
1274
+ } as never,
1275
+ { targets: [], caps: {}, frame: { frameNumber: 1 }, generation: 1 } as never,
1276
+ );
1277
+ expect(planned.ok).toBe(true);
1278
+ if (!planned.ok) return;
1279
+ const computePrograms = planned.value.resources
1280
+ .filter((resource) => resource.kind === 'compute-program')
1281
+ .map((resource) => resource.name);
1282
+ expect(computePrograms).toEqual([expect.stringContaining('.healthy.g-1.')]);
1283
+ expect(planned.value.passes.some((pass) => pass.name.includes('.healthy.g-1.tick-0'))).toBe(
1284
+ true,
1285
+ );
1286
+ expect(planned.value.passes.some((pass) => pass.name.includes('.paused.g-1.tick-0'))).toBe(
1287
+ false,
1288
+ );
1289
+ });
1290
+
1291
+ it('preserves a seed reset across a paused real runtime', async () => {
1292
+ const world = new World();
1293
+ const camera = {
1294
+ position: new Float32Array(3),
1295
+ right: new Float32Array([1, 0, 0]),
1296
+ up: new Float32Array([0, 1, 0]),
1297
+ viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
1298
+ };
1299
+ const effect = {
1300
+ guid: 'paused-seed-reset',
1301
+ kind: 'particle-effect',
1302
+ schemaVersion: 2,
1303
+ programFingerprint: 'sha256:paused-seed-reset',
1304
+ emitters: [{ id: 'reset', capacity: 4 }],
1305
+ program: {
1306
+ format: 'forgeax-vfx-program-2',
1307
+ fingerprint: 'sha256:paused-seed-reset',
1308
+ emitters: [
1309
+ {
1310
+ id: 'reset',
1311
+ module: 'paused-seed-reset.vfx.wgsl',
1312
+ capacity: 4,
1313
+ backend: { required: 'gpu' },
1314
+ space: 'world',
1315
+ simulationWhenCulled: 'continue',
1316
+ schedule: { rate: 1, bursts: [{ time: 0, count: 1 }] },
1317
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
1318
+ wgsl: '// paused seed reset',
1319
+ reflection: {
1320
+ hooks: ['vfx_spawn', 'vfx_update'],
1321
+ imports: [],
1322
+ resources: [],
1323
+ entryPoints: ['forgeax_vfx_spawn_main'],
1324
+ bindings: [],
1325
+ },
1326
+ renderers: [],
1327
+ },
1328
+ ],
1329
+ },
1330
+ } as unknown;
1331
+ const assets = {
1332
+ loaders: { registerPackLoader: () => {} },
1333
+ lookup: () => undefined,
1334
+ };
1335
+ const host = createVfxRuntimeHost({ camera: { read: () => camera } });
1336
+ expect(await host.attachWorld({ world, assets: assets as never })).toMatchObject({ ok: true });
1337
+ const handle = world.allocSharedRef('ParticleEffectAsset', asV3Effect(effect));
1338
+ const player = world
1339
+ .spawn({
1340
+ component: ParticleEffectPlayer,
1341
+ data: { effect: handle, playing: true, seed: 1, timeScale: 1 },
1342
+ })
1343
+ .unwrap();
1344
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
1345
+ const extract = (frameNumber: number) => {
1346
+ const result = host.feature.extract?.({ worlds: [world], frameNumber } as never);
1347
+ expect(result?.ok).toBe(true);
1348
+ if (result === undefined || !result.ok) throw new Error('VFX extraction failed');
1349
+ return result.value;
1350
+ };
1351
+ const plan = (frameNumber: number) =>
1352
+ host.feature.plan?.(extract(frameNumber), {
1353
+ targets: [],
1354
+ caps: {},
1355
+ frame: { frameNumber },
1356
+ generation: 1,
1357
+ } as never);
1358
+ const submit = (frameNumber: number) =>
1359
+ host.feature.onFrameSubmitted?.({ worlds: [], frameNumber } as never);
1360
+
1361
+ world.update(1 / 60).unwrap();
1362
+ const first = plan(1);
1363
+ expect(first?.ok).toBe(true);
1364
+ submit(1);
1365
+ const firstCommitted = runtime.lastCommittedEmitter(player, 'reset');
1366
+ expect(firstCommitted).toMatchObject({ seed: 1, reset: true, phaseTick: 0 });
1367
+
1368
+ const control = host.acquireControl(world).unwrap();
1369
+ control.setPlayerRenderConsumption({ player, enabled: false }).unwrap();
1370
+ world.set(player, ParticleEffectPlayer, { seed: 2 }).unwrap();
1371
+ world.update(1 / 60).unwrap();
1372
+ const paused = plan(2);
1373
+ expect(paused).toMatchObject({ ok: true, value: { passes: [] } });
1374
+ submit(2);
1375
+ expect(runtime.lastCommittedEmitter(player, 'reset')).toBe(firstCommitted);
1376
+ expect(runtime.snapshot()[0]).toMatchObject({ seed: 2, reset: true, phaseTick: 0 });
1377
+
1378
+ control.setPlayerRenderConsumption({ player, enabled: true }).unwrap();
1379
+ const resumed = plan(3);
1380
+ expect(resumed?.ok).toBe(true);
1381
+ submit(3);
1382
+ expect(runtime.snapshot()).toHaveLength(0);
1383
+ expect(runtime.lastCommittedEmitter(player, 'reset')).toMatchObject({
1384
+ seed: 2,
1385
+ reset: true,
1386
+ phaseTick: 0,
1387
+ });
1388
+ await host.detachWorld({ world });
1389
+ });
1390
+
1391
+ it('writes trail history once per fixed tick before the final projection', () => {
1392
+ const world = new World();
1393
+ const player = world.spawn().unwrap();
1394
+ const camera = {
1395
+ position: new Float32Array(3),
1396
+ right: new Float32Array([1, 0, 0]),
1397
+ up: new Float32Array([0, 1, 0]),
1398
+ viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
1399
+ };
1400
+ const renderer = {
1401
+ kind: 'trail' as const,
1402
+ material: 'trail-material',
1403
+ historyLength: 4,
1404
+ capacity: 2,
1405
+ width: 0.1,
1406
+ };
1407
+ const disabledRenderer = { ...renderer, enabled: false as const };
1408
+ const intent = (sequence: number, reset: boolean): VfxGpuTickIntent => ({
1409
+ sequence,
1410
+ player,
1411
+ emitter: {
1412
+ id: 'trail',
1413
+ module: 'trail.vfx.wgsl',
1414
+ capacity: 4,
1415
+ backend: { required: 'gpu' },
1416
+ space: 'world',
1417
+ simulationWhenCulled: 'continue',
1418
+ schedule: { rate: 0, bursts: [] },
1419
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
1420
+ wgsl: '// trail',
1421
+ reflection: {
1422
+ hooks: ['vfx_spawn', 'vfx_update'],
1423
+ imports: [],
1424
+ resources: [],
1425
+ entryPoints: [
1426
+ 'forgeax_vfx_spawn_main',
1427
+ 'forgeax_vfx_trail_history_main',
1428
+ 'forgeax_vfx_trail_main',
1429
+ ],
1430
+ bindings: [
1431
+ {
1432
+ entries: [0, 1, 2, 3, 4, 5, 6, 8, 9].map((binding) => ({
1433
+ binding,
1434
+ visibility: 4,
1435
+ buffer: { type: binding === 1 ? 'uniform' : 'storage' },
1436
+ })),
1437
+ },
1438
+ ],
1439
+ } as never,
1440
+ renderers: [renderer, disabledRenderer],
1441
+ },
1442
+ programFingerprint: 'trail-program',
1443
+ reset,
1444
+ fixedDelta: 1 / 60,
1445
+ phaseTick: sequence,
1446
+ tick: sequence,
1447
+ seed: 1,
1448
+ playCycle: 0,
1449
+ spawnCount: 1,
1450
+ firstParticleId: 0,
1451
+ instanceGeneration: 0,
1452
+ instancePatchCount: 0,
1453
+ parameterBlock: new Uint8Array(),
1454
+ canonicalPayload: new Uint8Array(),
1455
+ replayInput: {
1456
+ seed: 1,
1457
+ tick: sequence,
1458
+ generation: 0,
1459
+ sequence,
1460
+ fingerprint: 'trail-program',
1461
+ payload: new Uint8Array(),
1462
+ values: {},
1463
+ channelInputs: [],
1464
+ droppedCount: 0,
1465
+ },
1466
+ channelInputs: [],
1467
+ eventCounters: {
1468
+ queued: 0,
1469
+ produced: 0,
1470
+ consumed: 0,
1471
+ dropped: 0,
1472
+ overflow: 0,
1473
+ fanOut: 0,
1474
+ recursionDepth: 0,
1475
+ lastSequence: -1,
1476
+ },
1477
+ });
1478
+ const runtime = {
1479
+ renderGeneration: 1,
1480
+ isEmitterSessionEnabled: () => true,
1481
+ setEmitterCameraVisibility: () => {},
1482
+ markEventDispatched: () => {},
1483
+ commit: () => {},
1484
+ };
1485
+ const feature = gpuParticleRenderFeature({ camera: { read: () => camera } });
1486
+ const planned = feature.plan(
1487
+ {
1488
+ worlds: [{ world, runtime, camera, intents: [intent(1, true), intent(2, false)] }],
1489
+ frameNumber: 1,
1490
+ } as never,
1491
+ { targets: [], caps: {}, frame: { frameNumber: 1 }, generation: 1 } as never,
1492
+ );
1493
+ expect(planned.ok).toBe(true);
1494
+ if (!planned.ok) return;
1495
+ const historyPasses = planned.value.passes.filter(
1496
+ (pass) => pass.kind === 'compute' && pass.name.endsWith('.history-bindings.write'),
1497
+ );
1498
+ expect(historyPasses).toHaveLength(2);
1499
+ expect(
1500
+ planned.value.resources.find(
1501
+ (resource) => resource.kind === 'buffer' && resource.name.endsWith('.renderer-0.history'),
1502
+ ),
1503
+ ).toMatchObject({ size: 4 * (4 + 1) * 16 });
1504
+ expect(
1505
+ planned.value.resources.some(
1506
+ (resource) => resource.kind === 'buffer' && resource.name.includes('.renderer-1.history'),
1507
+ ),
1508
+ ).toBe(false);
1509
+ expect(
1510
+ planned.value.passes.findIndex((pass) => pass.name.includes('.tick-0.simulate')),
1511
+ ).toBeLessThan(
1512
+ planned.value.passes.findIndex((pass) =>
1513
+ pass.name.includes('.tick-0.renderer-0.history-bindings.write'),
1514
+ ),
1515
+ );
1516
+ expect(
1517
+ planned.value.passes.findIndex((pass) =>
1518
+ pass.name.includes('.tick-0.renderer-0.history-bindings.write'),
1519
+ ),
1520
+ ).toBeLessThan(
1521
+ planned.value.passes.findIndex((pass) => pass.name.includes('.tick-1.simulate')),
1522
+ );
1523
+ expect(historyPasses.map((pass) => pass.name)).toEqual([
1524
+ expect.stringContaining('.tick-0.renderer-0.history-bindings.write'),
1525
+ expect.stringContaining('.tick-1.renderer-0.history-bindings.write'),
1526
+ ]);
1527
+ });
1528
+
1529
+ it('rejects a second host without replacing the first World runtime', async () => {
1530
+ const world = new World();
1531
+ const assets = {
1532
+ loaders: { registerPackLoader: () => {} },
1533
+ lookup: () => undefined,
1534
+ };
1535
+ const options = {
1536
+ camera: {
1537
+ read: () => undefined,
1538
+ },
1539
+ };
1540
+ const first = createVfxRuntimeHost(options);
1541
+ const second = createVfxRuntimeHost(options);
1542
+ expect(await first.attachWorld({ world, assets: assets as never })).toMatchObject({ ok: true });
1543
+ expect(await second.attachWorld({ world, assets: assets as never })).toMatchObject({
1544
+ ok: false,
1545
+ error: { code: 'vfx-host-world-attach-failed' },
1546
+ });
1547
+ expect(await first.detachWorld({ world })).toMatchObject({ ok: true });
1548
+ });
1549
+
1550
+ it('keeps depth readiness on the host contract for a real renderer frame', () => {
1551
+ const host = createVfxRuntimeHost({
1552
+ camera: { read: () => undefined },
1553
+ providers: [
1554
+ createCameraProvider({ available: () => true }),
1555
+ createSceneDepthProvider({
1556
+ available: () => true,
1557
+ sampleCount: 1,
1558
+ resource: () => ({ kind: 'texture-view', value: {} }),
1559
+ }),
1560
+ ],
1561
+ });
1562
+ const result = host.resolveDataInterfaces({
1563
+ requirements: [
1564
+ {
1565
+ token: 'vfx:scene-depth',
1566
+ kind: 'scene-depth',
1567
+ binding: 9,
1568
+ bindingType: 'sampled-depth',
1569
+ lifetime: 'generation',
1570
+ },
1571
+ ],
1572
+ generation: 1,
1573
+ });
1574
+ expect(result).toMatchObject({ ok: true, value: { readiness: 'ready' } });
1575
+ });
1576
+
1577
+ it('refreshes a paused emitter after the camera re-enters without a queued tick', async () => {
1578
+ const world = new World();
1579
+ const viewProjection = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
1580
+ const camera = {
1581
+ position: new Float32Array(3),
1582
+ right: new Float32Array([1, 0, 0]),
1583
+ up: new Float32Array([0, 1, 0]),
1584
+ viewProjection,
1585
+ };
1586
+ const effect = {
1587
+ guid: 'culling-effect',
1588
+ kind: 'particle-effect',
1589
+ schemaVersion: 2,
1590
+ programFingerprint: 'sha256:culling',
1591
+ emitters: [{ id: 'paused', capacity: 4 }],
1592
+ program: {
1593
+ format: 'forgeax-vfx-program-2',
1594
+ fingerprint: 'sha256:culling',
1595
+ emitters: [
1596
+ {
1597
+ id: 'paused',
1598
+ module: 'paused.vfx.wgsl',
1599
+ capacity: 4,
1600
+ backend: { required: 'gpu' },
1601
+ space: 'world',
1602
+ simulationWhenCulled: 'pause',
1603
+ schedule: { rate: 1, bursts: [] },
1604
+ bounds: { kind: 'sphere', center: [10, 0, 0], radius: 0.25 },
1605
+ wgsl: '// paused',
1606
+ reflection: {
1607
+ hooks: ['vfx_spawn', 'vfx_update'],
1608
+ imports: [],
1609
+ resources: [],
1610
+ entryPoints: ['forgeax_vfx_spawn_main'],
1611
+ bindings: [],
1612
+ },
1613
+ renderers: [],
1614
+ },
1615
+ ],
1616
+ },
1617
+ } as unknown;
1618
+ const handle = world.allocSharedRef('ParticleEffectAsset', asV3Effect(effect));
1619
+ const player = world
1620
+ .spawn({
1621
+ component: ParticleEffectPlayer,
1622
+ data: { effect: handle, playing: true, seed: 3, timeScale: 1 },
1623
+ })
1624
+ .unwrap();
1625
+ await createWorldContext(world, [vfxGpuRuntimePlugin()]);
1626
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
1627
+ const feature = gpuParticleRenderFeature({ camera: { read: () => camera } });
1628
+ const extract = (frameNumber: number) => {
1629
+ const result = feature.extract?.({ worlds: [world], frameNumber } as never);
1630
+ expect(result?.ok).toBe(true);
1631
+ if (result === undefined || !result.ok) throw new Error('VFX extraction failed');
1632
+ return result.value;
1633
+ };
1634
+ const plan = (frameNumber: number) =>
1635
+ feature.plan?.(extract(frameNumber), {
1636
+ targets: [],
1637
+ caps: {},
1638
+ frame: { frameNumber },
1639
+ generation: 1,
1640
+ } as never);
1641
+
1642
+ world.update(1 / 60).unwrap();
1643
+ const hidden = plan(1);
1644
+ expect(hidden).toMatchObject({ ok: true, value: { resources: [], passes: [] } });
1645
+ feature.onFrameSubmitted?.({ worlds: [], frameNumber: 1 } as never);
1646
+ // The reset was discovered after FixedUpdate while the emitter was
1647
+ // culled, so it remains queued rather than being acknowledged as a fake
1648
+ // GPU state.
1649
+ expect(runtime.snapshot().length).toBeGreaterThan(0);
1650
+
1651
+ viewProjection[12] = -10;
1652
+ const visibleWithoutTick = extract(2);
1653
+ expect(runtime.inspectPlayer(player)?.emitters[0]).toMatchObject({ cameraVisible: true });
1654
+ // The reset discovered while hidden remains queued until this re-entry;
1655
+ // visibility refresh must not consume it as a skipped state.
1656
+ expect(visibleWithoutTick.worlds[0]?.intents.length).toBeGreaterThan(0);
1657
+ world.update(1 / 60).unwrap();
1658
+ const resumed = plan(3);
1659
+ expect(resumed?.ok).toBe(true);
1660
+ if (resumed?.ok) expect(resumed.value.passes.length).toBeGreaterThan(0);
1661
+ });
1662
+
1663
+ it('retains committed particles for render frames without a fixed tick', async () => {
1664
+ const world = new World();
1665
+ const viewProjection = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
1666
+ const camera = {
1667
+ position: new Float32Array(3),
1668
+ right: new Float32Array([1, 0, 0]),
1669
+ up: new Float32Array([0, 1, 0]),
1670
+ viewProjection,
1671
+ };
1672
+ const effect = {
1673
+ guid: 'retained-effect',
1674
+ kind: 'particle-effect',
1675
+ schemaVersion: 2,
1676
+ programFingerprint: 'sha256:retained',
1677
+ emitters: [{ id: 'retained', capacity: 4 }],
1678
+ program: {
1679
+ format: 'forgeax-vfx-program-2',
1680
+ fingerprint: 'sha256:retained',
1681
+ emitters: [
1682
+ {
1683
+ id: 'retained',
1684
+ module: 'retained.vfx.wgsl',
1685
+ capacity: 4,
1686
+ backend: { required: 'gpu' },
1687
+ space: 'world',
1688
+ simulationWhenCulled: 'pause',
1689
+ schedule: { rate: 1, bursts: [] },
1690
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 0.25 },
1691
+ wgsl: '// retained',
1692
+ reflection: {
1693
+ hooks: ['vfx_spawn', 'vfx_update'],
1694
+ imports: [],
1695
+ resources: [],
1696
+ entryPoints: ['forgeax_vfx_spawn_main', 'forgeax_vfx_billboard_main'],
1697
+ bindings: [],
1698
+ },
1699
+ renderers: [{ kind: 'billboard', material: 'retained-material' }],
1700
+ },
1701
+ ],
1702
+ },
1703
+ } as unknown;
1704
+ const handle = world.allocSharedRef('ParticleEffectAsset', asV3Effect(effect));
1705
+ world
1706
+ .spawn({
1707
+ component: ParticleEffectPlayer,
1708
+ data: { effect: handle, playing: true, seed: 7, timeScale: 1 },
1709
+ })
1710
+ .unwrap();
1711
+ await createWorldContext(world, [vfxGpuRuntimePlugin()]);
1712
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
1713
+ const feature = gpuParticleRenderFeature({ camera: { read: () => camera } });
1714
+ const extract = (frameNumber: number) => {
1715
+ const result = feature.extract?.({ worlds: [world], frameNumber } as never);
1716
+ expect(result?.ok).toBe(true);
1717
+ if (result === undefined || !result.ok) throw new Error('VFX extraction failed');
1718
+ return result.value;
1719
+ };
1720
+ const plan = (frameNumber: number) =>
1721
+ feature.plan?.(extract(frameNumber), {
1722
+ targets: [],
1723
+ caps: {},
1724
+ frame: { frameNumber },
1725
+ generation: 1,
1726
+ } as never);
1727
+ const submit = (frameNumber: number) =>
1728
+ feature.onFrameSubmitted?.({ worlds: [], frameNumber } as never);
1729
+
1730
+ world.update(1 / 60).unwrap();
1731
+ const first = plan(1);
1732
+ expect(first?.ok).toBe(true);
1733
+ if (first?.ok) {
1734
+ expect(first.value.passes.some((pass) => pass.name.endsWith('.raster'))).toBe(true);
1735
+ expect(first.value.passes.some((pass) => pass.name.endsWith('.simulate'))).toBe(true);
1736
+ }
1737
+ submit(1);
1738
+ expect(runtime.snapshot()).toHaveLength(0);
1739
+
1740
+ // A render-only frame has no new FixedUpdate intent, but the committed
1741
+ // particle buffer must still be projected and drawn.
1742
+ const retained = plan(2);
1743
+ expect(retained?.ok).toBe(true);
1744
+ if (retained?.ok) {
1745
+ expect(retained.value.resources.length).toBeGreaterThan(0);
1746
+ expect(retained.value.passes.some((pass) => pass.name.endsWith('.project'))).toBe(true);
1747
+ expect(retained.value.passes.some((pass) => pass.name.endsWith('.raster'))).toBe(true);
1748
+ expect(retained.value.passes.some((pass) => pass.name.endsWith('.simulate'))).toBe(false);
1749
+ }
1750
+ submit(2);
1751
+
1752
+ // Parking out of view for more than the old eight-frame owner window must
1753
+ // retain the buffers; re-entry can draw the same committed state.
1754
+ viewProjection[12] = -10;
1755
+ for (let frameNumber = 3; frameNumber <= 11; frameNumber += 1) {
1756
+ const hidden = plan(frameNumber);
1757
+ expect(hidden?.ok).toBe(true);
1758
+ if (hidden?.ok) {
1759
+ expect(hidden.value.resources.length).toBeGreaterThan(0);
1760
+ expect(hidden.value.passes.some((pass) => pass.name.endsWith('.raster'))).toBe(false);
1761
+ }
1762
+ submit(frameNumber);
1763
+ }
1764
+ viewProjection[12] = 0;
1765
+ const reentered = plan(12);
1766
+ expect(reentered?.ok).toBe(true);
1767
+ if (reentered?.ok)
1768
+ expect(reentered.value.passes.some((pass) => pass.name.endsWith('.raster'))).toBe(true);
1769
+ });
1770
+
1771
+ it('acknowledges a healthy player while another player reset remains deferred', async () => {
1772
+ const world = new World();
1773
+ const camera = {
1774
+ position: new Float32Array(3),
1775
+ right: new Float32Array([1, 0, 0]),
1776
+ up: new Float32Array([0, 1, 0]),
1777
+ viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
1778
+ };
1779
+ const effect = {
1780
+ guid: 'cross-player-effect',
1781
+ kind: 'particle-effect',
1782
+ schemaVersion: 2,
1783
+ programFingerprint: 'sha256:cross-player',
1784
+ emitters: [{ id: 'stream', capacity: 8 }],
1785
+ program: {
1786
+ format: 'forgeax-vfx-program-2',
1787
+ fingerprint: 'sha256:cross-player',
1788
+ emitters: [
1789
+ {
1790
+ id: 'stream',
1791
+ module: 'cross-player.vfx.wgsl',
1792
+ capacity: 8,
1793
+ backend: { required: 'gpu' },
1794
+ space: 'world',
1795
+ simulationWhenCulled: 'continue',
1796
+ schedule: { rate: 60, bursts: [{ time: 0, count: 1 }] },
1797
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
1798
+ wgsl: '// cross-player',
1799
+ reflection: {
1800
+ hooks: ['vfx_spawn', 'vfx_update'],
1801
+ imports: [],
1802
+ resources: [],
1803
+ entryPoints: ['forgeax_vfx_spawn_main'],
1804
+ bindings: [],
1805
+ },
1806
+ renderers: [],
1807
+ },
1808
+ ],
1809
+ },
1810
+ } as unknown;
1811
+ const handle = world.allocSharedRef('ParticleEffectAsset', asV3Effect(effect));
1812
+ const pausedPlayer = world
1813
+ .spawn({
1814
+ component: ParticleEffectPlayer,
1815
+ data: { effect: handle, playing: true, seed: 1, timeScale: 1 },
1816
+ })
1817
+ .unwrap();
1818
+ const healthyPlayer = world
1819
+ .spawn({
1820
+ component: ParticleEffectPlayer,
1821
+ data: { effect: handle, playing: true, seed: 2, timeScale: 1 },
1822
+ })
1823
+ .unwrap();
1824
+ await createWorldContext(world, [vfxGpuRuntimePlugin()]);
1825
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
1826
+ const feature = gpuParticleRenderFeature({
1827
+ camera: { read: () => camera },
1828
+ playerConsumption: { isEnabled: (_currentWorld, player) => player !== pausedPlayer },
1829
+ });
1830
+ const frame = (frameNumber: number) => {
1831
+ const extracted = feature.extract?.({ worlds: [world], frameNumber } as never);
1832
+ expect(extracted?.ok).toBe(true);
1833
+ if (extracted === undefined || !extracted.ok) throw new Error('VFX extraction failed');
1834
+ const planned = feature.plan?.(extracted.value, {
1835
+ targets: [],
1836
+ caps: {},
1837
+ frame: { frameNumber },
1838
+ generation: 1,
1839
+ } as never);
1840
+ expect(planned?.ok).toBe(true);
1841
+ feature.onFrameSubmitted?.({ worlds: [], frameNumber } as never);
1842
+ };
1843
+
1844
+ // The paused player's first reset is a real deferred intent. Keep
1845
+ // producing both streams beyond the queue bound while the healthy stream
1846
+ // must continue to submit and be selectively acknowledged.
1847
+ const healthyTicks: number[] = [];
1848
+ for (let frameNumber = 1; frameNumber <= 12; frameNumber += 1) {
1849
+ world.update(1 / 60).unwrap();
1850
+ frame(frameNumber);
1851
+ const healthySnapshot = runtime.inspectPlayer(healthyPlayer);
1852
+ expect(healthySnapshot?.queuedIntents).toBe(0);
1853
+ const committedTick = healthySnapshot?.lastCommitted?.tick;
1854
+ if (committedTick !== undefined) healthyTicks.push(committedTick);
1855
+ }
1856
+
1857
+ const paused = runtime.inspectPlayer(pausedPlayer);
1858
+ const healthy = runtime.inspectPlayer(healthyPlayer);
1859
+ expect(paused?.queuedIntents).toBeGreaterThan(0);
1860
+ expect(healthy?.queuedIntents).toBe(0);
1861
+ expect(healthyTicks).toHaveLength(12);
1862
+ expect(
1863
+ healthyTicks.every((tick, index) => {
1864
+ const prior = healthyTicks[index - 1];
1865
+ return index === 0 || (prior !== undefined && tick > prior);
1866
+ }),
1867
+ ).toBe(true);
1868
+ expect(
1869
+ healthy?.diagnostics.some((diagnostic) => diagnostic.code === 'vfx-intent-queue-overflow'),
1870
+ ).toBe(false);
459
1871
  });
460
1872
 
461
1873
  it('returns an empty keyed inspection aggregate before a player is present', async () => {