@forgeax/engine-vfx 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 (53) hide show
  1. package/README.md +62 -14
  2. package/dist/assets/particle-effect-decoder.d.ts.map +1 -1
  3. package/dist/authoring-descriptor.d.ts +4 -4
  4. package/dist/authoring-descriptor.d.ts.map +1 -1
  5. package/dist/code-source-v3.d.ts +101 -0
  6. package/dist/code-source-v3.d.ts.map +1 -0
  7. package/dist/code-source.d.ts +12 -11
  8. package/dist/code-source.d.ts.map +1 -1
  9. package/dist/data-interface.d.ts +17 -1
  10. package/dist/data-interface.d.ts.map +1 -1
  11. package/dist/effect-contract.d.ts +11 -1
  12. package/dist/effect-contract.d.ts.map +1 -1
  13. package/dist/gpu-loader.d.ts +5 -5
  14. package/dist/gpu-loader.d.ts.map +1 -1
  15. package/dist/gpu-program.d.ts +52 -27
  16. package/dist/gpu-program.d.ts.map +1 -1
  17. package/dist/gpu-runtime.d.ts +32 -7
  18. package/dist/gpu-runtime.d.ts.map +1 -1
  19. package/dist/index.d.ts +10 -6
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.mjs +647 -117
  22. package/dist/index.mjs.map +1 -1
  23. package/dist/particle-layout.d.ts +62 -0
  24. package/dist/particle-layout.d.ts.map +1 -0
  25. package/package.json +5 -5
  26. package/src/__tests__/authoring-descriptor.unit.test.ts +18 -8
  27. package/src/__tests__/channel-input.unit.test.ts +4 -1
  28. package/src/__tests__/channel-overflow-policy-owner.test-d.ts +2 -2
  29. package/src/__tests__/code-source-v2.unit.test.ts +15 -14
  30. package/src/__tests__/data-interface-contract.unit.test.ts +41 -2
  31. package/src/__tests__/effect-contract.unit.test.ts +6 -3
  32. package/src/__tests__/gpu-program-simulation-culling-owner.test-d.ts +6 -6
  33. package/src/__tests__/gpu-reflection-vocabulary-owner.test-d.ts +2 -2
  34. package/src/__tests__/gpu-runtime.integration.test.ts +244 -11
  35. package/src/__tests__/instance-public-api.unit.test.ts +4 -1
  36. package/src/__tests__/instance.unit.test.ts +4 -1
  37. package/src/__tests__/loader-v1-rejection.unit.test.ts +14 -14
  38. package/src/__tests__/player-reflection.unit.test.ts +2 -2
  39. package/src/__tests__/player-type.test-d.ts +4 -1
  40. package/src/__tests__/renderer-source.unit.test.ts +6 -6
  41. package/src/__tests__/replay-canonical.unit.test.ts +4 -1
  42. package/src/__tests__/vfx-data-interface-vocabulary-owner.test-d.ts +3 -8
  43. package/src/assets/particle-effect-decoder.ts +4 -3
  44. package/src/authoring-descriptor.ts +26 -28
  45. package/src/code-source-v3.ts +460 -0
  46. package/src/code-source.ts +23 -27
  47. package/src/data-interface.ts +66 -1
  48. package/src/effect-contract.ts +73 -20
  49. package/src/gpu-loader.ts +61 -35
  50. package/src/gpu-program.ts +57 -35
  51. package/src/gpu-runtime.ts +115 -42
  52. package/src/index.ts +53 -9
  53. package/src/particle-layout.ts +214 -0
@@ -1,23 +1,28 @@
1
1
  import type {
2
2
  BindGroupLayoutDescriptor,
3
- ParticleEffectAsset,
4
- ParticleEffectProgram,
3
+ MaterialParticleInput,
4
+ ParticleEffectAssetV3,
5
5
  ParticleEffectProgramEmitter,
6
+ ParticleEffectProgramV3,
6
7
  } from '@forgeax/engine-types';
7
8
  import type {
8
9
  ParticleChannelSource,
9
- ParticleEmitterSourceV2,
10
10
  ParticleEventSource,
11
- ParticleRendererOverflowPolicy,
12
- ParticleRendererSorting,
13
- ParticleRendererSource,
14
11
  ParticleStageDomain,
15
12
  ParticleStageResourceAccess,
16
13
  } from './code-source.js';
14
+ import type {
15
+ ParticleEmitterSourceV3,
16
+ ParticleRendererSemanticMap,
17
+ ParticleRendererSortingV3,
18
+ ParticleRendererSourceV3,
19
+ } from './code-source-v3.js';
17
20
  import type { VfxDataInterfaceRequirement } from './data-interface.js';
18
21
  import type { VfxEffectReflection } from './effect-contract.js';
19
22
 
20
- export const VFX_GPU_PROGRAM_FORMAT = 'forgeax-vfx-program-2' as const;
23
+ /** The only cooked GPU program ABI exposed by the runtime. */
24
+ export const VFX_GPU_PROGRAM_FORMAT = 'forgeax-vfx-program-3' as const;
25
+ export const VFX_GPU_PROGRAM_FORMAT_V3 = VFX_GPU_PROGRAM_FORMAT;
21
26
  export const VFX_GPU_PROGRAM_ARTIFACT_KEY = 'particle-effect/program.json' as const;
22
27
 
23
28
  export interface VfxGpuStageReflection {
@@ -33,13 +38,17 @@ export interface VfxGpuStageReflection {
33
38
  readonly iterationBudget: number;
34
39
  }
35
40
 
36
- export interface VfxGpuRendererReflection {
37
- readonly topology: ParticleRendererSource['kind'];
41
+ /** Reflection of one executable renderer projection. */
42
+ export interface VfxGpuRendererReflectionV3 {
43
+ readonly topology: ParticleRendererSourceV3['kind'];
38
44
  readonly resource: string;
39
45
  readonly capacity: number;
40
- readonly overflow: ParticleRendererOverflowPolicy;
46
+ readonly overflow: 'drop-newest' | 'drop-oldest';
41
47
  readonly enabled: boolean;
42
48
  readonly shaderInputs: readonly string[];
49
+ readonly attributes: ParticleRendererSemanticMap;
50
+ readonly materialInputs: readonly string[];
51
+ readonly materialInputDefinitions?: readonly MaterialParticleInput[];
43
52
  readonly textureSheet?: {
44
53
  readonly columns: number;
45
54
  readonly rows: number;
@@ -48,54 +57,67 @@ export interface VfxGpuRendererReflection {
48
57
  };
49
58
  readonly pivot?: readonly [number, number];
50
59
  readonly softParticle?: { readonly fadeDistance: number; readonly requiresDepth: true };
51
- readonly sorting?: ParticleRendererSorting;
60
+ readonly sorting?: ParticleRendererSortingV3;
52
61
  readonly stripKey?: 'alive-index';
53
62
  readonly historyLength?: number;
54
63
  readonly endpointField?: 'velocity';
64
+ readonly lighting?: 'unlit' | 'standard';
65
+ readonly castShadows: boolean;
66
+ readonly receiveShadows: boolean;
55
67
  }
56
68
 
57
- export interface VfxGpuProgramReflection {
69
+ export interface VfxGpuProgramReflectionV3 {
58
70
  readonly hooks: readonly ['vfx_spawn', 'vfx_update'];
59
71
  readonly imports: readonly string[];
60
72
  readonly resources: readonly string[];
61
73
  readonly entryPoints: readonly string[];
62
74
  readonly bindings: readonly BindGroupLayoutDescriptor[];
63
- readonly layout?: VfxEffectReflection;
64
- readonly dataInterfaces?: readonly VfxDataInterfaceRequirement[];
65
- readonly eventChannels?: readonly ParticleChannelSource[];
66
- readonly events?: readonly ParticleEventSource[];
67
- readonly eventEntryPoint?: 'forgeax_vfx_event_main';
68
- readonly stages?: readonly VfxGpuStageReflection[];
69
- readonly renderers?: readonly VfxGpuRendererReflection[];
75
+ readonly layout: VfxEffectReflection;
76
+ readonly dataInterfaces: readonly VfxDataInterfaceRequirement[];
77
+ readonly eventChannels: readonly ParticleChannelSource[];
78
+ readonly events: readonly ParticleEventSource[];
79
+ readonly eventEntryPoint: 'forgeax_vfx_event_main';
80
+ readonly stages: readonly VfxGpuStageReflection[];
81
+ readonly renderers: readonly VfxGpuRendererReflectionV3[];
70
82
  }
71
83
 
72
- export interface VfxGpuEmitterProgram
73
- extends Omit<
74
- ParticleEffectProgramEmitter,
75
- 'schedule' | 'bounds' | 'renderers' | 'reflection' | 'channels' | 'events'
76
- > {
84
+ export interface VfxGpuEmitterProgramV3
85
+ extends Omit<ParticleEffectProgramEmitter, 'renderers' | 'reflection'> {
77
86
  readonly id: string;
78
87
  readonly module: string;
79
88
  readonly capacity: number;
80
- readonly backend: ParticleEmitterSourceV2['backend'];
81
- readonly space: ParticleEmitterSourceV2['space'];
82
- readonly schedule: ParticleEmitterSourceV2['schedule'];
83
- readonly bounds: ParticleEmitterSourceV2['bounds'];
84
- readonly renderers: ParticleEmitterSourceV2['renderers'];
89
+ readonly backend: ParticleEmitterSourceV3['backend'];
90
+ readonly space: ParticleEmitterSourceV3['space'];
91
+ readonly schedule: ParticleEmitterSourceV3['schedule'];
92
+ readonly bounds: ParticleEmitterSourceV3['bounds'];
93
+ readonly renderers: readonly ParticleRendererSourceV3[];
85
94
  readonly channels?: readonly ParticleChannelSource[];
86
95
  readonly events?: readonly ParticleEventSource[];
87
- readonly simulationWhenCulled: NonNullable<ParticleEmitterSourceV2['simulationWhenCulled']>;
96
+ readonly simulationWhenCulled: NonNullable<ParticleEmitterSourceV3['simulationWhenCulled']>;
88
97
  readonly wgsl: string;
89
- readonly reflection: VfxGpuProgramReflection;
98
+ readonly reflection: VfxGpuProgramReflectionV3;
90
99
  }
91
100
 
92
- export interface VfxGpuProgram extends Omit<ParticleEffectProgram, 'emitters'> {
101
+ export interface VfxGpuProgramV3 extends Omit<ParticleEffectProgramV3, 'emitters'> {
93
102
  readonly format: typeof VFX_GPU_PROGRAM_FORMAT;
94
103
  readonly fingerprint: string;
95
- readonly emitters: readonly VfxGpuEmitterProgram[];
104
+ readonly emitters: readonly VfxGpuEmitterProgramV3[];
96
105
  }
97
106
 
98
- export interface VfxGpuEffectAsset extends ParticleEffectAsset {
107
+ export interface VfxGpuEffectAssetV3 extends Omit<ParticleEffectAssetV3, 'program'> {
99
108
  readonly guid: string;
100
- readonly program: VfxGpuProgram;
109
+ readonly kind: 'particle-effect';
110
+ readonly schemaVersion: 3;
111
+ readonly programFingerprint: string;
112
+ readonly emitters: readonly { readonly id: string; readonly capacity: number }[];
113
+ readonly program: VfxGpuProgramV3;
101
114
  }
115
+
116
+ /** Public names point at the same one-cut Program v3 shapes. */
117
+ export type VfxGpuRendererReflection = VfxGpuRendererReflectionV3;
118
+ export type VfxGpuProgramReflection = VfxGpuProgramReflectionV3;
119
+ export type VfxGpuEmitterProgram = VfxGpuEmitterProgramV3;
120
+ export type VfxGpuProgram = VfxGpuProgramV3;
121
+ export type VfxGpuEffectAsset = VfxGpuEffectAssetV3;
122
+ export type VfxGpuEmitterProgramAny = VfxGpuEmitterProgramV3;
123
+ export type VfxGpuEffectAssetAny = VfxGpuEffectAssetV3;
@@ -4,7 +4,7 @@ import { type Handle, toShared } from '@forgeax/engine-types';
4
4
  import type { ParticleEventSource } from './code-source.js';
5
5
  import type { VfxValueMap } from './effect-contract.js';
6
6
  import { createVfxEffectContract, type VfxEffectReflection } from './effect-contract.js';
7
- import type { VfxGpuEffectAsset, VfxGpuEmitterProgram } from './gpu-program.js';
7
+ import type { VfxGpuEffectAssetAny, VfxGpuEmitterProgramAny } from './gpu-program.js';
8
8
  import {
9
9
  ParticleEffectInstance,
10
10
  type VfxChannelCounters,
@@ -49,7 +49,7 @@ export function createVfxInspectSnapshot(input: VfxInspectSnapshotInput) {
49
49
  export interface VfxGpuTickIntent {
50
50
  readonly sequence: number;
51
51
  readonly player: EntityHandle;
52
- readonly emitter: VfxGpuEmitterProgram;
52
+ readonly emitter: VfxGpuEmitterProgramAny;
53
53
  readonly programFingerprint: string;
54
54
  readonly reset: boolean;
55
55
  readonly fixedDelta: number;
@@ -84,18 +84,24 @@ export interface VfxGpuEmitterInspectSnapshot {
84
84
  readonly spawnCount: number;
85
85
  readonly firstParticleId: number;
86
86
  readonly reset: boolean;
87
- readonly schedule: VfxGpuEmitterProgram['schedule'];
88
- readonly bounds: VfxGpuEmitterProgram['bounds'];
89
- readonly simulationWhenCulled: VfxGpuEmitterProgram['simulationWhenCulled'];
87
+ readonly schedule: VfxGpuEmitterProgramAny['schedule'];
88
+ readonly bounds: VfxGpuEmitterProgramAny['bounds'];
89
+ readonly simulationWhenCulled: VfxGpuEmitterProgramAny['simulationWhenCulled'];
90
90
  readonly renderers: readonly {
91
91
  readonly index: number;
92
- readonly kind: VfxGpuEmitterProgram['renderers'][number]['kind'];
92
+ readonly kind: VfxGpuEmitterProgramAny['renderers'][number]['kind'];
93
93
  readonly enabled: boolean;
94
94
  }[];
95
95
  readonly stages: readonly string[];
96
96
  readonly dataInterfaces: readonly string[];
97
97
  }
98
98
 
99
+ /** Renderer-facing source view used to refresh culling without a queued tick. */
100
+ export interface VfxGpuEmitterSource {
101
+ readonly player: EntityHandle;
102
+ readonly emitter: VfxGpuEmitterProgramAny;
103
+ }
104
+
99
105
  export interface VfxGpuCommittedInspectSnapshot {
100
106
  readonly sequence: number;
101
107
  readonly tick: number;
@@ -143,7 +149,7 @@ interface PlayerState {
143
149
  effect: Handle<'ParticleEffectAsset', 'shared'>;
144
150
  assetGuid: string;
145
151
  programFingerprint: string;
146
- emitters: readonly VfxGpuEmitterProgram[];
152
+ emitters: readonly VfxGpuEmitterProgramAny[];
147
153
  seed: number;
148
154
  playing: boolean;
149
155
  playCycle: number;
@@ -154,6 +160,7 @@ interface PlayerState {
154
160
  cameraVisible: boolean[];
155
161
  phaseTicks: number[];
156
162
  hasCommitted: boolean;
163
+ pendingResets: boolean[];
157
164
  }
158
165
 
159
166
  function eventCounters(
@@ -216,10 +223,30 @@ export class VfxGpuRuntime {
216
223
  return this.#diagnostics;
217
224
  }
218
225
 
226
+ /**
227
+ * Visit every live emitter source. The renderer uses this on render frames
228
+ * where pause/restart culling has no pending simulation intent, so a camera
229
+ * move back into the bounds can refresh visibility before the next tick.
230
+ */
231
+ forEachEmitterSource(visitor: (source: VfxGpuEmitterSource) => void): void {
232
+ for (const [player, state] of this.#players) {
233
+ for (const emitter of state.emitters) visitor({ player, emitter });
234
+ }
235
+ }
236
+
219
237
  lastCommitted(player: EntityHandle): VfxGpuTickIntent | undefined {
220
238
  return this.#lastCommitted.get(player);
221
239
  }
222
240
 
241
+ /**
242
+ * Return the last submitted fixed-tick state for one emitter. Renderers
243
+ * retain this projection between fixed ticks; simulation intents are a
244
+ * queue, while a drawable emitter remains live until the player is reset.
245
+ */
246
+ lastCommittedEmitter(player: EntityHandle, emitterId: string): VfxGpuTickIntent | undefined {
247
+ return this.#lastCommittedByEmitter.get(player)?.get(emitterId);
248
+ }
249
+
223
250
  inspectPlayers(): readonly VfxGpuPlayerInspectSnapshot[] {
224
251
  return Object.freeze(
225
252
  [...this.#players.keys()]
@@ -399,24 +426,49 @@ export class VfxGpuRuntime {
399
426
  this.#replayRequests.add(player);
400
427
  }
401
428
 
402
- commit(sequence: number): void {
429
+ /**
430
+ * Acknowledge queued intents. A scalar acknowledges the contiguous prefix;
431
+ * an array acknowledges only those terminal sequences, which lets unrelated
432
+ * player/emitter streams progress while another stream is deferred. When the
433
+ * renderer supplies `publishedSequences`, only intents that reached GPU
434
+ * submission update the retained last-committed projection; skipped intents
435
+ * are queue acknowledgements, not new GPU state.
436
+ */
437
+ commit(sequence: number | readonly number[], publishedSequences?: readonly number[]): void {
438
+ const prefix = typeof sequence === 'number' ? sequence : undefined;
439
+ const acknowledged = typeof sequence === 'number' ? undefined : new Set(sequence);
440
+ const published = publishedSequences === undefined ? undefined : new Set(publishedSequences);
403
441
  const committedPlayers = new Set<EntityHandle>();
442
+ const retained: VfxGpuTickIntent[] = [];
404
443
  for (const intent of this.#intents) {
405
- if (intent.sequence > sequence) break;
406
- const state = this.#players.get(intent.player);
407
- if (state !== undefined) state.hasCommitted = true;
408
- this.#lastCommitted.set(intent.player, intent);
409
- let emitters = this.#lastCommittedByEmitter.get(intent.player);
410
- if (emitters === undefined) {
411
- emitters = new Map();
412
- this.#lastCommittedByEmitter.set(intent.player, emitters);
444
+ const shouldAcknowledge =
445
+ prefix === undefined
446
+ ? acknowledged?.has(intent.sequence) === true
447
+ : intent.sequence <= prefix;
448
+ if (!shouldAcknowledge) {
449
+ retained.push(intent);
450
+ continue;
413
451
  }
414
- emitters.set(intent.emitter.id, intent);
452
+ const state = this.#players.get(intent.player);
415
453
  committedPlayers.add(intent.player);
454
+ if (published === undefined || published.has(intent.sequence)) {
455
+ if (state !== undefined) state.hasCommitted = true;
456
+ const priorCommitted = this.#lastCommitted.get(intent.player);
457
+ if (priorCommitted === undefined || priorCommitted.sequence < intent.sequence) {
458
+ this.#lastCommitted.set(intent.player, intent);
459
+ }
460
+ let emitters = this.#lastCommittedByEmitter.get(intent.player);
461
+ if (emitters === undefined) {
462
+ emitters = new Map();
463
+ this.#lastCommittedByEmitter.set(intent.player, emitters);
464
+ }
465
+ const priorEmitter = emitters.get(intent.emitter.id);
466
+ if (priorEmitter === undefined || priorEmitter.sequence < intent.sequence) {
467
+ emitters.set(intent.emitter.id, intent);
468
+ }
469
+ }
416
470
  }
417
- const retained = this.#intents.findIndex((intent) => intent.sequence > sequence);
418
- if (retained < 0) this.#intents.length = 0;
419
- else if (retained > 0) this.#intents.splice(0, retained);
471
+ this.#intents.splice(0, this.#intents.length, ...retained);
420
472
  for (const player of committedPlayers) {
421
473
  this.#clearDiagnostics(player, 'vfx-intent-queue-overflow');
422
474
  }
@@ -519,14 +571,14 @@ export class VfxGpuRuntime {
519
571
  continue;
520
572
  }
521
573
  this.#clearDiagnostics(input.player, 'vfx-player-invalid');
522
- const resolved = world.sharedRefs.resolve<'ParticleEffectAsset', VfxGpuEffectAsset>(
574
+ const resolved = world.sharedRefs.resolve<'ParticleEffectAsset', VfxGpuEffectAssetAny>(
523
575
  input.effect,
524
576
  );
525
- if (!resolved.ok || resolved.value.schemaVersion !== 2) {
577
+ if (!resolved.ok || resolved.value.schemaVersion !== 3) {
526
578
  this.#report({
527
579
  code: 'vfx-effect-unavailable',
528
- expected: 'a loaded schemaVersion 2 GPU particle effect',
529
- hint: 'load and recook the effect before the first FixedUpdate',
580
+ expected: 'a loaded schemaVersion 3 GPU particle effect',
581
+ hint: 'cold-cook the legacy effect as Program v3 before the first FixedUpdate',
530
582
  detail: { player: input.player },
531
583
  });
532
584
  continue;
@@ -534,13 +586,14 @@ export class VfxGpuRuntime {
534
586
  this.#clearDiagnostics(input.player, 'vfx-effect-unavailable');
535
587
  const previous = this.#players.get(input.player);
536
588
  const replayRequested = this.#replayRequests.delete(input.player);
537
- const restart =
589
+ const restartRequested =
538
590
  previous === undefined ||
539
591
  previous.effect !== input.effect ||
540
592
  previous.seed !== input.seed ||
541
593
  replayRequested ||
542
594
  (!previous.playing && input.playing);
543
- const state = restart
595
+ const restart = restartRequested;
596
+ const state = restartRequested
544
597
  ? {
545
598
  effect: input.effect,
546
599
  assetGuid: resolved.value.guid,
@@ -556,6 +609,7 @@ export class VfxGpuRuntime {
556
609
  cameraVisible: resolved.value.program.emitters.map(() => true),
557
610
  phaseTicks: resolved.value.program.emitters.map(() => 0),
558
611
  hasCommitted: false,
612
+ pendingResets: resolved.value.program.emitters.map(() => false),
559
613
  }
560
614
  : previous;
561
615
  this.#players.set(input.player, state);
@@ -563,7 +617,10 @@ export class VfxGpuRuntime {
563
617
  // authority. Inspection always returns the authored player state after
564
618
  // the requested reset intent has been emitted.
565
619
  state.playing = input.playing;
566
- if (!input.playing && !replayRequested) continue;
620
+ if (!input.playing && !replayRequested) {
621
+ if (restart) state.pendingResets.fill(true);
622
+ continue;
623
+ }
567
624
  const queuedForPlayer = this.#intents.reduce(
568
625
  (count, intent) => count + (intent.player === input.player ? 1 : 0),
569
626
  0,
@@ -572,6 +629,7 @@ export class VfxGpuRuntime {
572
629
  queuedForPlayer >=
573
630
  this.#maxQueuedTicks * Math.max(1, resolved.value.program.emitters.length)
574
631
  ) {
632
+ if (restart) state.pendingResets.fill(true);
575
633
  if (state.hasCommitted) {
576
634
  this.#report({
577
635
  code: 'vfx-intent-queue-overflow',
@@ -584,13 +642,17 @@ export class VfxGpuRuntime {
584
642
  }
585
643
  const instance =
586
644
  this.#instances.get(input.player) ?? this.#createInstance(input.player, resolved.value);
587
- if (instance === undefined) continue;
645
+ if (instance === undefined) {
646
+ if (restart) state.pendingResets.fill(true);
647
+ continue;
648
+ }
588
649
  const hasActiveEmitter = resolved.value.program.emitters.some((emitter) => {
589
650
  if (!this.isEmitterSessionEnabled(input.player, emitter.id)) return false;
590
651
  const cameraVisible = this.#cameraVisibility.get(`${input.player}:${emitter.id}`) ?? true;
591
652
  return cameraVisible || emitter.simulationWhenCulled === 'continue';
592
653
  });
593
654
  if (!hasActiveEmitter) {
655
+ if (restart) state.pendingResets.fill(true);
594
656
  for (const [index, emitter] of resolved.value.program.emitters.entries()) {
595
657
  state.cameraVisible[index] =
596
658
  this.#cameraVisibility.get(`${input.player}:${emitter.id}`) ?? true;
@@ -604,6 +666,7 @@ export class VfxGpuRuntime {
604
666
  ? instance.commit({ seed: input.seed, tick })
605
667
  : instance.replay(replayInput);
606
668
  if (!committed.ok) {
669
+ if (restart) state.pendingResets.fill(true);
607
670
  this.#report({
608
671
  code: 'vfx-instance-commit-failed',
609
672
  expected: 'the current typed instance values to pack into the reflected GPU block',
@@ -618,8 +681,15 @@ export class VfxGpuRuntime {
618
681
  const cameraVisible = this.#cameraVisibility.get(`${input.player}:${emitter.id}`) ?? true;
619
682
  const becameVisible = cameraVisible && state.cameraVisible[index] === false;
620
683
  state.cameraVisible[index] = cameraVisible;
621
- if (!this.isEmitterSessionEnabled(input.player, emitter.id)) continue;
622
- if (!cameraVisible && emitter.simulationWhenCulled !== 'continue') continue;
684
+ const emitterRestart = restart || state.pendingResets[index] === true;
685
+ if (!this.isEmitterSessionEnabled(input.player, emitter.id)) {
686
+ if (emitterRestart) state.pendingResets[index] = true;
687
+ continue;
688
+ }
689
+ if (!cameraVisible && emitter.simulationWhenCulled !== 'continue') {
690
+ if (emitterRestart) state.pendingResets[index] = true;
691
+ continue;
692
+ }
623
693
  const visibilityRestart =
624
694
  becameVisible && emitter.simulationWhenCulled === 'restart-on-visible';
625
695
  if (visibilityRestart) {
@@ -634,7 +704,7 @@ export class VfxGpuRuntime {
634
704
  emitter,
635
705
  previousElapsed,
636
706
  previousElapsed + delta,
637
- restart || visibilityRestart,
707
+ emitterRestart || visibilityRestart,
638
708
  state.rateRemainders[index] ?? 0,
639
709
  );
640
710
  state.rateRemainders[index] = scheduled.remainder;
@@ -655,7 +725,7 @@ export class VfxGpuRuntime {
655
725
  player: input.player,
656
726
  emitter,
657
727
  programFingerprint: resolved.value.program.fingerprint,
658
- reset: restart || visibilityRestart,
728
+ reset: emitterRestart || visibilityRestart,
659
729
  fixedDelta: delta,
660
730
  phaseTick,
661
731
  tick,
@@ -678,6 +748,7 @@ export class VfxGpuRuntime {
678
748
  );
679
749
  state.elapsed[index] = previousElapsed + delta;
680
750
  state.phaseTicks[index] = phaseTick + 1;
751
+ state.pendingResets[index] = false;
681
752
  }
682
753
  }
683
754
  for (const player of this.#players.keys()) {
@@ -687,19 +758,21 @@ export class VfxGpuRuntime {
687
758
 
688
759
  #createInstance(
689
760
  player: EntityHandle,
690
- effect: VfxGpuEffectAsset,
761
+ effect: VfxGpuEffectAssetAny,
691
762
  ): ParticleEffectInstance | undefined {
692
763
  const layout = effect.program.emitters.find(
693
764
  (emitter) => emitter.reflection.layout !== undefined,
694
765
  )?.reflection.layout;
695
- const reflection: VfxEffectReflection = layout ?? {
696
- version: 1,
697
- parameters: { name: 'VfxParameters', fields: [], size: 0, alignment: 1 },
698
- custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
699
- fingerprint: effect.program.fingerprint.startsWith('sha256:')
700
- ? effect.program.fingerprint
701
- : `sha256:${effect.program.fingerprint}`,
702
- };
766
+ if (layout === undefined) {
767
+ this.#report({
768
+ code: 'vfx-effect-unavailable',
769
+ expected: 'a Program v3 emitter reflection layout',
770
+ hint: 'recook the effect with Program v3 reflection before starting the player',
771
+ detail: { player },
772
+ });
773
+ return undefined;
774
+ }
775
+ const reflection: VfxEffectReflection = layout;
703
776
  try {
704
777
  const instance = new ParticleEffectInstance(createVfxEffectContract(reflection), {
705
778
  channels: effect.program.emitters.flatMap((emitter) => emitter.channels ?? []),
@@ -730,7 +803,7 @@ export class VfxGpuRuntime {
730
803
  }
731
804
 
732
805
  function spawnCount(
733
- emitter: VfxGpuEmitterProgram,
806
+ emitter: VfxGpuEmitterProgramAny,
734
807
  previous: number,
735
808
  next: number,
736
809
  firstTick: boolean,
package/src/index.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  // @forgeax/engine-vfx - runtime-safe code-first GPU VFX contract.
2
2
 
3
- export type { ParticleEffectAsset, ParticleEmitterDefinition } from '@forgeax/engine-types';
3
+ export type {
4
+ ParticleEffectAsset,
5
+ ParticleEffectAssetV3,
6
+ ParticleEffectProgramV3,
7
+ ParticleEmitterDefinition,
8
+ } from '@forgeax/engine-types';
4
9
  export { particleEffectContribution } from './assets/particle-effect-decoder';
5
10
  export type {
6
11
  VfxAuthoringCapabilityDescriptor,
@@ -20,25 +25,34 @@ export type {
20
25
  ParticleChannelSource,
21
26
  ParticleCodeSourceError,
22
27
  ParticleCodeSourceInvalidDetail,
23
- ParticleEffectRootSourceV2,
24
- ParticleEffectSourceV2,
25
- ParticleEmitterSourceV2,
26
28
  ParticleEventSource,
27
29
  ParticleRendererOverflowPolicy,
28
- ParticleRendererSorting,
29
- ParticleRendererSource,
30
30
  ParticleStageDomain,
31
31
  ParticleStageResourceAccess,
32
32
  ParticleStageResourceSource,
33
33
  ParticleStageSource,
34
34
  } from './code-source.js';
35
35
  export {
36
- defineParticleEffectSourceV2,
37
36
  PARTICLE_CODE_DEFAULT_MODULE_ID,
38
37
  PARTICLE_STAGE_RESOURCE_NAMES,
39
- parseParticleEffectSourceV2,
40
38
  parseVfxStageDeclarations,
41
39
  } from './code-source.js';
40
+ export type {
41
+ ParticleAttributeRef,
42
+ ParticleEffectRootSourceV3,
43
+ ParticleEffectSourceV3,
44
+ ParticleEmitterSourceV3,
45
+ ParticleRendererSemantic,
46
+ ParticleRendererSemanticMap,
47
+ ParticleRendererSortingV3,
48
+ ParticleRendererSourceV3,
49
+ } from './code-source-v3.js';
50
+ export {
51
+ defaultParticleRendererAttributes,
52
+ defineParticleEffectSourceV3,
53
+ PARTICLE_RENDERER_SEMANTICS,
54
+ parseParticleEffectSourceV3,
55
+ } from './code-source-v3.js';
42
56
  export type {
43
57
  VfxDataInterfaceBindingType,
44
58
  VfxDataInterfaceError,
@@ -72,14 +86,26 @@ export {
72
86
  } from './gpu-loader.js';
73
87
  export type {
74
88
  VfxGpuEffectAsset,
89
+ VfxGpuEffectAssetAny,
90
+ VfxGpuEffectAssetV3,
75
91
  VfxGpuEmitterProgram,
92
+ VfxGpuEmitterProgramAny,
93
+ VfxGpuEmitterProgramV3,
76
94
  VfxGpuProgram,
77
95
  VfxGpuProgramReflection,
96
+ VfxGpuProgramReflectionV3,
97
+ VfxGpuProgramV3,
98
+ VfxGpuRendererReflectionV3,
78
99
  VfxGpuStageReflection,
79
100
  } from './gpu-program.js';
80
- export { VFX_GPU_PROGRAM_ARTIFACT_KEY, VFX_GPU_PROGRAM_FORMAT } from './gpu-program.js';
101
+ export {
102
+ VFX_GPU_PROGRAM_ARTIFACT_KEY,
103
+ VFX_GPU_PROGRAM_FORMAT,
104
+ VFX_GPU_PROGRAM_FORMAT_V3,
105
+ } from './gpu-program.js';
81
106
  export type {
82
107
  VfxGpuEmitterInspectSnapshot,
108
+ VfxGpuEmitterSource,
83
109
  VfxGpuPlayerInspectSnapshot,
84
110
  VfxGpuRuntimeDiagnostic,
85
111
  VfxGpuRuntimeOptions,
@@ -106,5 +132,23 @@ export {
106
132
  createParticleEffectInstance,
107
133
  ParticleEffectInstance,
108
134
  } from './instance.js';
135
+ export type {
136
+ VfxCustomLayout,
137
+ VfxParametersLayout,
138
+ VfxParticleCoreAttribute,
139
+ VfxParticleCoreField,
140
+ VfxParticleCoreLayout,
141
+ VfxParticleCoreValue,
142
+ VfxParticleCoreValues,
143
+ } from './particle-layout.js';
144
+ export {
145
+ deriveVfxCustomLayout,
146
+ encodeVfxParticleCore,
147
+ normalizedVfxParticleAge,
148
+ VFX_PARTICLE_CORE_LAYOUT,
149
+ VFX_PARTICLE_CORE_STRIDE,
150
+ vfxParticleCoreField,
151
+ vfxParticleCoreWgsl,
152
+ } from './particle-layout.js';
109
153
  export type { ParticleEffectPlayerData } from './player.js';
110
154
  export { ParticleEffectPlayer } from './player.js';