@forgeax/engine-vfx 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +214 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/authoring-descriptor.unit.test.d.ts +2 -0
  5. package/dist/__tests__/authoring-descriptor.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/channel-input.unit.test.d.ts +2 -0
  7. package/dist/__tests__/channel-input.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/channel-inspect.unit.test.d.ts +2 -0
  9. package/dist/__tests__/channel-inspect.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/code-source-v2.unit.test.d.ts +2 -0
  11. package/dist/__tests__/code-source-v2.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/data-interface-contract.unit.test.d.ts +2 -0
  13. package/dist/__tests__/data-interface-contract.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/effect-contract.unit.test.d.ts +2 -0
  15. package/dist/__tests__/effect-contract.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/gpu-program-simulation-culling-owner.test-d.d.ts +2 -0
  17. package/dist/__tests__/gpu-program-simulation-culling-owner.test-d.d.ts.map +1 -0
  18. package/dist/__tests__/gpu-reflection-vocabulary-owner.test-d.d.ts +2 -0
  19. package/dist/__tests__/gpu-reflection-vocabulary-owner.test-d.d.ts.map +1 -0
  20. package/dist/__tests__/gpu-runtime.integration.test.d.ts +2 -0
  21. package/dist/__tests__/gpu-runtime.integration.test.d.ts.map +1 -0
  22. package/dist/__tests__/inspect-snapshot.unit.test.d.ts +2 -0
  23. package/dist/__tests__/inspect-snapshot.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/instance-public-api.unit.test.d.ts +2 -0
  25. package/dist/__tests__/instance-public-api.unit.test.d.ts.map +1 -0
  26. package/dist/__tests__/instance.unit.test.d.ts +2 -0
  27. package/dist/__tests__/instance.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/loader-v1-rejection.unit.test.d.ts +2 -0
  29. package/dist/__tests__/loader-v1-rejection.unit.test.d.ts.map +1 -0
  30. package/dist/__tests__/player-reflection.unit.test.d.ts +2 -0
  31. package/dist/__tests__/player-reflection.unit.test.d.ts.map +1 -0
  32. package/dist/__tests__/player-type.test-d.d.ts +2 -0
  33. package/dist/__tests__/player-type.test-d.d.ts.map +1 -0
  34. package/dist/__tests__/renderer-source.unit.test.d.ts +2 -0
  35. package/dist/__tests__/renderer-source.unit.test.d.ts.map +1 -0
  36. package/dist/__tests__/replay-canonical.unit.test.d.ts +2 -0
  37. package/dist/__tests__/replay-canonical.unit.test.d.ts.map +1 -0
  38. package/dist/__tests__/runtime-dist-isolation.test.d.ts +2 -0
  39. package/dist/__tests__/runtime-dist-isolation.test.d.ts.map +1 -0
  40. package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts +2 -0
  41. package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts.map +1 -0
  42. package/dist/__tests__/stage-source.unit.test.d.ts +2 -0
  43. package/dist/__tests__/stage-source.unit.test.d.ts.map +1 -0
  44. package/dist/__tests__/vfx-data-interface-vocabulary-owner.test-d.d.ts +2 -0
  45. package/dist/__tests__/vfx-data-interface-vocabulary-owner.test-d.d.ts.map +1 -0
  46. package/dist/__tests__/vfx-performance-contract.unit.test.d.ts +2 -0
  47. package/dist/__tests__/vfx-performance-contract.unit.test.d.ts.map +1 -0
  48. package/dist/__tests__/vfx-value-type-owner.test-d.d.ts +2 -0
  49. package/dist/__tests__/vfx-value-type-owner.test-d.d.ts.map +1 -0
  50. package/dist/assets/particle-effect-decoder.d.ts +3 -0
  51. package/dist/assets/particle-effect-decoder.d.ts.map +1 -0
  52. package/dist/authoring-descriptor.d.ts +60 -0
  53. package/dist/authoring-descriptor.d.ts.map +1 -0
  54. package/dist/code-source.d.ts +140 -0
  55. package/dist/code-source.d.ts.map +1 -0
  56. package/dist/data-interface.d.ts +53 -0
  57. package/dist/data-interface.d.ts.map +1 -0
  58. package/dist/effect-contract.d.ts +57 -0
  59. package/dist/effect-contract.d.ts.map +1 -0
  60. package/dist/gpu-loader.d.ts +33 -0
  61. package/dist/gpu-loader.d.ts.map +1 -0
  62. package/dist/gpu-program.d.ts +80 -0
  63. package/dist/gpu-program.d.ts.map +1 -0
  64. package/dist/gpu-runtime.d.ts +174 -0
  65. package/dist/gpu-runtime.d.ts.map +1 -0
  66. package/dist/index.d.ts +21 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.mjs +1986 -0
  69. package/dist/index.mjs.map +1 -0
  70. package/dist/instance.d.ts +86 -0
  71. package/dist/instance.d.ts.map +1 -0
  72. package/dist/player.d.ts +17 -0
  73. package/dist/player.d.ts.map +1 -0
  74. package/package.json +66 -0
  75. package/src/__tests__/authoring-descriptor.unit.test.ts +138 -0
  76. package/src/__tests__/channel-input.unit.test.ts +65 -0
  77. package/src/__tests__/channel-inspect.unit.test.ts +65 -0
  78. package/src/__tests__/code-source-v2.unit.test.ts +121 -0
  79. package/src/__tests__/data-interface-contract.unit.test.ts +92 -0
  80. package/src/__tests__/effect-contract.unit.test.ts +62 -0
  81. package/src/__tests__/gpu-program-simulation-culling-owner.test-d.ts +58 -0
  82. package/src/__tests__/gpu-reflection-vocabulary-owner.test-d.ts +23 -0
  83. package/src/__tests__/gpu-runtime.integration.test.ts +549 -0
  84. package/src/__tests__/inspect-snapshot.unit.test.ts +51 -0
  85. package/src/__tests__/instance-public-api.unit.test.ts +27 -0
  86. package/src/__tests__/instance.unit.test.ts +107 -0
  87. package/src/__tests__/loader-v1-rejection.unit.test.ts +125 -0
  88. package/src/__tests__/player-reflection.unit.test.ts +91 -0
  89. package/src/__tests__/player-type.test-d.ts +54 -0
  90. package/src/__tests__/renderer-source.unit.test.ts +58 -0
  91. package/src/__tests__/replay-canonical.unit.test.ts +68 -0
  92. package/src/__tests__/runtime-dist-isolation.test.ts +100 -0
  93. package/src/__tests__/scriptable-pack-consumer.test-d.ts +9 -0
  94. package/src/__tests__/stage-source.unit.test.ts +64 -0
  95. package/src/__tests__/vfx-data-interface-vocabulary-owner.test-d.ts +34 -0
  96. package/src/__tests__/vfx-performance-contract.unit.test.ts +19 -0
  97. package/src/__tests__/vfx-value-type-owner.test-d.ts +32 -0
  98. package/src/assets/particle-effect-decoder.ts +34 -0
  99. package/src/authoring-descriptor.ts +355 -0
  100. package/src/code-source.ts +827 -0
  101. package/src/data-interface.ts +181 -0
  102. package/src/effect-contract.ts +220 -0
  103. package/src/gpu-loader.ts +279 -0
  104. package/src/gpu-program.ts +101 -0
  105. package/src/gpu-runtime.ts +807 -0
  106. package/src/index.ts +110 -0
  107. package/src/instance.ts +379 -0
  108. package/src/player.ts +21 -0
@@ -0,0 +1,807 @@
1
+ import { Entity, type EntityHandle, FixedTime, FixedUpdate, type World } from '@forgeax/engine-ecs';
2
+ import type { Plugin } from '@forgeax/engine-plugin';
3
+ import { type Handle, toShared } from '@forgeax/engine-types';
4
+ import type { ParticleEventSource } from './code-source.js';
5
+ import type { VfxValueMap } from './effect-contract.js';
6
+ import { createVfxEffectContract, type VfxEffectReflection } from './effect-contract.js';
7
+ import type { VfxGpuEffectAsset, VfxGpuEmitterProgram } from './gpu-program.js';
8
+ import {
9
+ ParticleEffectInstance,
10
+ type VfxChannelCounters,
11
+ type VfxReplayInput,
12
+ } from './instance.js';
13
+ import { ParticleEffectPlayer } from './player.js';
14
+
15
+ export const VFX_GPU_RUNTIME_RESOURCE_KEY = 'VfxGpuRuntime';
16
+
17
+ export interface VfxInspectSnapshotInput {
18
+ readonly layoutFingerprint: string;
19
+ readonly parameterGeneration: number;
20
+ readonly patchCount: number;
21
+ readonly dataInterfaces?: unknown;
22
+ readonly channels?: unknown;
23
+ readonly stages?: unknown;
24
+ readonly renderers?: unknown;
25
+ readonly hmr?: unknown;
26
+ readonly gpuTiming?: unknown;
27
+ readonly error?: {
28
+ readonly code: string;
29
+ readonly expected: string;
30
+ readonly hint: string;
31
+ readonly detail: unknown;
32
+ };
33
+ }
34
+
35
+ export function createVfxInspectSnapshot(input: VfxInspectSnapshotInput) {
36
+ return {
37
+ layout: { fingerprint: input.layoutFingerprint },
38
+ values: { generation: input.parameterGeneration, patchCount: input.patchCount },
39
+ ...(input.dataInterfaces === undefined ? {} : { dataInterfaces: input.dataInterfaces }),
40
+ ...(input.channels === undefined ? {} : { channels: input.channels }),
41
+ ...(input.stages === undefined ? {} : { stages: input.stages }),
42
+ ...(input.renderers === undefined ? {} : { renderers: input.renderers }),
43
+ ...(input.hmr === undefined ? {} : { hmr: input.hmr }),
44
+ ...(input.gpuTiming === undefined ? {} : { gpuTiming: input.gpuTiming }),
45
+ ...(input.error === undefined ? {} : { error: input.error }),
46
+ } as const;
47
+ }
48
+
49
+ export interface VfxGpuTickIntent {
50
+ readonly sequence: number;
51
+ readonly player: EntityHandle;
52
+ readonly emitter: VfxGpuEmitterProgram;
53
+ readonly programFingerprint: string;
54
+ readonly reset: boolean;
55
+ readonly fixedDelta: number;
56
+ /** Effect-relative fixed tick. Resets to zero for replay and restart-on-visible. */
57
+ readonly phaseTick: number;
58
+ /** World-global FixedTime tick retained for renderer ring selection and correlation. */
59
+ readonly tick: number;
60
+ readonly seed: number;
61
+ readonly playCycle: number;
62
+ readonly spawnCount: number;
63
+ readonly firstParticleId: number;
64
+ readonly instanceGeneration: number;
65
+ readonly instancePatchCount: number;
66
+ readonly parameterBlock: Uint8Array;
67
+ readonly canonicalPayload: Uint8Array;
68
+ readonly replayInput: VfxReplayInput<VfxValueMap>;
69
+ readonly channelInputs: VfxReplayInput<VfxValueMap>['channelInputs'];
70
+ readonly eventCounters: VfxChannelCounters;
71
+ }
72
+
73
+ export interface VfxGpuEmitterInspectSnapshot {
74
+ readonly id: string;
75
+ readonly module: string;
76
+ readonly capacity: number;
77
+ /** Renderer-owned camera-frustum result. This is not an editor mute/isolate control. */
78
+ readonly cameraVisible: boolean;
79
+ /** Session-only preview mask. False suppresses both simulation and retained rendering. */
80
+ readonly sessionEnabled: boolean;
81
+ readonly phaseTick: number | null;
82
+ readonly tick: number | null;
83
+ readonly playCycle: number | null;
84
+ readonly spawnCount: number;
85
+ readonly firstParticleId: number;
86
+ readonly reset: boolean;
87
+ readonly schedule: VfxGpuEmitterProgram['schedule'];
88
+ readonly bounds: VfxGpuEmitterProgram['bounds'];
89
+ readonly simulationWhenCulled: VfxGpuEmitterProgram['simulationWhenCulled'];
90
+ readonly renderers: readonly {
91
+ readonly index: number;
92
+ readonly kind: VfxGpuEmitterProgram['renderers'][number]['kind'];
93
+ readonly enabled: boolean;
94
+ }[];
95
+ readonly stages: readonly string[];
96
+ readonly dataInterfaces: readonly string[];
97
+ }
98
+
99
+ export interface VfxGpuCommittedInspectSnapshot {
100
+ readonly sequence: number;
101
+ readonly tick: number;
102
+ readonly phaseTick: number;
103
+ readonly playCycle: number;
104
+ readonly spawnCount: number;
105
+ readonly firstParticleId: number;
106
+ readonly reset: boolean;
107
+ readonly instanceGeneration: number;
108
+ readonly instancePatchCount: number;
109
+ }
110
+
111
+ export interface VfxGpuPlayerInspectSnapshot {
112
+ readonly player: EntityHandle;
113
+ readonly assetGuid: string;
114
+ readonly programFingerprint: string;
115
+ readonly seed: number;
116
+ readonly fixedDelta: number;
117
+ readonly playing: boolean;
118
+ readonly values: {
119
+ readonly layoutFingerprint: string;
120
+ readonly generation: number;
121
+ readonly pendingPatchCount: number;
122
+ };
123
+ readonly queuedIntents: number;
124
+ readonly queuedTicks: number;
125
+ readonly lastCommitted: VfxGpuCommittedInspectSnapshot | null;
126
+ readonly channels: VfxChannelCounters;
127
+ readonly emitters: readonly VfxGpuEmitterInspectSnapshot[];
128
+ readonly diagnostics: readonly VfxGpuRuntimeDiagnostic[];
129
+ }
130
+
131
+ export interface VfxGpuRuntimeDiagnostic {
132
+ readonly code:
133
+ | 'vfx-intent-queue-overflow'
134
+ | 'vfx-effect-unavailable'
135
+ | 'vfx-player-invalid'
136
+ | 'vfx-instance-commit-failed';
137
+ readonly expected: string;
138
+ readonly hint: string;
139
+ readonly detail: { readonly player: EntityHandle; readonly maxQueuedTicks?: number };
140
+ }
141
+
142
+ interface PlayerState {
143
+ effect: Handle<'ParticleEffectAsset', 'shared'>;
144
+ assetGuid: string;
145
+ programFingerprint: string;
146
+ emitters: readonly VfxGpuEmitterProgram[];
147
+ seed: number;
148
+ playing: boolean;
149
+ playCycle: number;
150
+ elapsed: number[];
151
+ rateRemainders: number[];
152
+ nextParticleIds: number[];
153
+ playCycles: number[];
154
+ cameraVisible: boolean[];
155
+ phaseTicks: number[];
156
+ hasCommitted: boolean;
157
+ }
158
+
159
+ function eventCounters(
160
+ intent: Pick<VfxGpuTickIntent, 'channelInputs' | 'emitter'>,
161
+ dropped: number,
162
+ eventSources: readonly ParticleEventSource[],
163
+ ): VfxChannelCounters {
164
+ const inputs = intent.channelInputs;
165
+ const events = [...(intent.emitter.events ?? []), ...eventSources];
166
+ return Object.freeze({
167
+ queued: inputs.length,
168
+ produced: (intent.emitter.events?.length ?? 0) > 0 ? inputs.length : 0,
169
+ consumed:
170
+ eventSources.length > 0
171
+ ? inputs.length * eventSources.reduce((total, event) => total + event.fanOut, 0)
172
+ : 0,
173
+ dropped,
174
+ overflow: dropped > 0 ? 1 : 0,
175
+ fanOut: events.reduce((total, event) => total + event.fanOut, 0),
176
+ recursionDepth: events.reduce((depth, event) => Math.max(depth, event.recursionDepth), 0),
177
+ lastSequence: inputs.at(-1)?.sequence ?? -1,
178
+ });
179
+ }
180
+
181
+ export interface VfxGpuRuntimeOptions {
182
+ readonly maxQueuedTicks?: number;
183
+ }
184
+
185
+ export class VfxGpuRuntime {
186
+ readonly #maxQueuedTicks: number;
187
+ readonly #players = new Map<EntityHandle, PlayerState>();
188
+ readonly #instances = new Map<EntityHandle, ParticleEffectInstance>();
189
+ readonly #seen = new Set<EntityHandle>();
190
+ readonly #intents: VfxGpuTickIntent[] = [];
191
+ readonly #diagnostics: VfxGpuRuntimeDiagnostic[] = [];
192
+ readonly #lastCommitted = new Map<EntityHandle, VfxGpuTickIntent>();
193
+ readonly #lastCommittedByEmitter = new Map<EntityHandle, Map<string, VfxGpuTickIntent>>();
194
+ readonly #eventCounters = new Map<EntityHandle, VfxChannelCounters>();
195
+ readonly #cameraVisibility = new Map<string, boolean>();
196
+ readonly #sessionEnabled = new Map<string, boolean>();
197
+ readonly #replayRequests = new Set<EntityHandle>();
198
+ readonly #replayInputs = new Map<EntityHandle, VfxReplayInput<VfxValueMap>>();
199
+ #sequence = 0;
200
+ #renderGeneration = 0;
201
+
202
+ constructor(options: VfxGpuRuntimeOptions = {}) {
203
+ this.#maxQueuedTicks = options.maxQueuedTicks ?? 8;
204
+ }
205
+
206
+ /** Resource generation owned by the current VFX render attachment. */
207
+ get renderGeneration(): number {
208
+ return this.#renderGeneration;
209
+ }
210
+
211
+ snapshot(): readonly VfxGpuTickIntent[] {
212
+ return this.#intents;
213
+ }
214
+
215
+ diagnostics(): readonly VfxGpuRuntimeDiagnostic[] {
216
+ return this.#diagnostics;
217
+ }
218
+
219
+ lastCommitted(player: EntityHandle): VfxGpuTickIntent | undefined {
220
+ return this.#lastCommitted.get(player);
221
+ }
222
+
223
+ inspectPlayers(): readonly VfxGpuPlayerInspectSnapshot[] {
224
+ return Object.freeze(
225
+ [...this.#players.keys()]
226
+ .sort((left, right) => Number(left) - Number(right))
227
+ .flatMap((player) => {
228
+ const snapshot = this.inspectPlayer(player);
229
+ return snapshot === undefined ? [] : [snapshot];
230
+ }),
231
+ );
232
+ }
233
+
234
+ inspectPlayer(player: EntityHandle): VfxGpuPlayerInspectSnapshot | undefined {
235
+ const state = this.#players.get(player);
236
+ if (state === undefined) return undefined;
237
+ const instance = this.#instances.get(player);
238
+ const latest = state.emitters.map((emitter) => this.#latestIntent(player, emitter.id));
239
+ const lastIntent = latest.reduce<VfxGpuTickIntent | undefined>(
240
+ (current, intent) =>
241
+ intent !== undefined && (current === undefined || intent.sequence > current.sequence)
242
+ ? intent
243
+ : current,
244
+ undefined,
245
+ );
246
+ const layoutFingerprint =
247
+ state.emitters.find((emitter) => emitter.reflection.layout !== undefined)?.reflection.layout
248
+ ?.fingerprint ?? state.programFingerprint;
249
+ const lastCommitted = this.#lastCommitted.get(player);
250
+ const queuedTicks = new Set<number>();
251
+ let queuedIntents = 0;
252
+ for (const intent of this.#intents) {
253
+ if (intent.player !== player) continue;
254
+ queuedIntents += 1;
255
+ queuedTicks.add(intent.tick);
256
+ }
257
+ return Object.freeze({
258
+ player,
259
+ assetGuid: state.assetGuid,
260
+ programFingerprint: state.programFingerprint,
261
+ seed: lastIntent?.seed ?? state.seed,
262
+ fixedDelta: lastIntent?.fixedDelta ?? 0,
263
+ playing: state.playing,
264
+ values: Object.freeze({
265
+ layoutFingerprint,
266
+ generation: instance?.generation ?? lastIntent?.instanceGeneration ?? 0,
267
+ pendingPatchCount: instance?.pendingPatchCount ?? 0,
268
+ }),
269
+ queuedIntents,
270
+ queuedTicks: queuedTicks.size,
271
+ lastCommitted:
272
+ lastCommitted === undefined
273
+ ? null
274
+ : Object.freeze({
275
+ sequence: lastCommitted.sequence,
276
+ tick: lastCommitted.tick,
277
+ phaseTick: lastCommitted.phaseTick,
278
+ playCycle: lastCommitted.playCycle,
279
+ spawnCount: lastCommitted.spawnCount,
280
+ firstParticleId: lastCommitted.firstParticleId,
281
+ reset: lastCommitted.reset,
282
+ instanceGeneration: lastCommitted.instanceGeneration,
283
+ instancePatchCount: lastCommitted.instancePatchCount,
284
+ }),
285
+ channels: this.eventCounters(player),
286
+ emitters: Object.freeze(
287
+ state.emitters.map((emitter, index) => {
288
+ const intent = latest[index];
289
+ return Object.freeze({
290
+ id: emitter.id,
291
+ module: emitter.module,
292
+ capacity: emitter.capacity,
293
+ cameraVisible: this.#cameraVisibility.get(`${player}:${emitter.id}`) ?? true,
294
+ sessionEnabled: this.isEmitterSessionEnabled(player, emitter.id),
295
+ phaseTick: intent?.phaseTick ?? null,
296
+ tick: intent?.tick ?? null,
297
+ playCycle: intent?.playCycle ?? null,
298
+ spawnCount: intent?.spawnCount ?? 0,
299
+ firstParticleId: intent?.firstParticleId ?? 0,
300
+ reset: intent?.reset ?? false,
301
+ schedule: emitter.schedule,
302
+ bounds: emitter.bounds,
303
+ simulationWhenCulled: emitter.simulationWhenCulled,
304
+ renderers: Object.freeze(
305
+ emitter.renderers.map((renderer, rendererIndex) =>
306
+ Object.freeze({
307
+ index: rendererIndex,
308
+ kind: renderer.kind,
309
+ enabled: renderer.enabled ?? true,
310
+ }),
311
+ ),
312
+ ),
313
+ stages: Object.freeze((emitter.reflection.stages ?? []).map((stage) => stage.id)),
314
+ dataInterfaces: Object.freeze(
315
+ (emitter.reflection.dataInterfaces ?? []).map((requirement) => requirement.token),
316
+ ),
317
+ });
318
+ }),
319
+ ),
320
+ diagnostics: Object.freeze(
321
+ this.#diagnostics.filter((diagnostic) => diagnostic.detail.player === player),
322
+ ),
323
+ });
324
+ }
325
+
326
+ eventCounters(player: EntityHandle): VfxChannelCounters {
327
+ return (
328
+ this.#eventCounters.get(player) ??
329
+ this.#lastCommitted.get(player)?.eventCounters ?? {
330
+ queued: 0,
331
+ produced: 0,
332
+ consumed: 0,
333
+ dropped: 0,
334
+ overflow: 0,
335
+ fanOut: 0,
336
+ recursionDepth: 0,
337
+ lastSequence: -1,
338
+ }
339
+ );
340
+ }
341
+
342
+ markEventDispatched(player: EntityHandle, counters: VfxChannelCounters): void {
343
+ const prior = this.#eventCounters.get(player);
344
+ if (counters.produced === 0 && prior !== undefined) {
345
+ this.#eventCounters.set(
346
+ player,
347
+ Object.freeze({
348
+ ...prior,
349
+ queued: 0,
350
+ consumed: Math.max(prior.consumed, counters.consumed),
351
+ }),
352
+ );
353
+ return;
354
+ }
355
+ this.#eventCounters.set(
356
+ player,
357
+ Object.freeze({ ...counters, queued: 0, consumed: counters.produced }),
358
+ );
359
+ }
360
+
361
+ hasPlayer(player: EntityHandle): boolean {
362
+ return this.#players.has(player);
363
+ }
364
+
365
+ attachInstance(player: EntityHandle, instance: ParticleEffectInstance): void {
366
+ this.#instances.set(player, instance);
367
+ }
368
+
369
+ detachInstance(player: EntityHandle): void {
370
+ this.#instances.delete(player);
371
+ }
372
+
373
+ getInstance(player: EntityHandle): ParticleEffectInstance | undefined {
374
+ return this.#instances.get(player);
375
+ }
376
+
377
+ setEmitterCameraVisibility(player: EntityHandle, emitterId: string, visible: boolean): void {
378
+ this.#cameraVisibility.set(`${player}:${emitterId}`, visible);
379
+ }
380
+
381
+ setEmitterSessionEnabled(player: EntityHandle, emitterId: string, enabled: boolean): void {
382
+ this.#sessionEnabled.set(`${player}:${emitterId}`, enabled);
383
+ }
384
+
385
+ isEmitterSessionEnabled(player: EntityHandle, emitterId: string): boolean {
386
+ return this.#sessionEnabled.get(`${player}:${emitterId}`) ?? true;
387
+ }
388
+
389
+ /** Restart from tick zero without changing authored `ParticleEffectPlayer.playing`. */
390
+ replay(player: EntityHandle, input?: VfxReplayInput<VfxValueMap>): void {
391
+ this.#discardQueuedIntents(player);
392
+ this.#clearDiagnostics(player, 'vfx-intent-queue-overflow');
393
+ this.#replayInputs.delete(player);
394
+ if (input === undefined) {
395
+ this.#instances.delete(player);
396
+ } else {
397
+ this.#replayInputs.set(player, input);
398
+ }
399
+ this.#replayRequests.add(player);
400
+ }
401
+
402
+ commit(sequence: number): void {
403
+ const committedPlayers = new Set<EntityHandle>();
404
+ 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);
413
+ }
414
+ emitters.set(intent.emitter.id, intent);
415
+ committedPlayers.add(intent.player);
416
+ }
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);
420
+ for (const player of committedPlayers) {
421
+ this.#clearDiagnostics(player, 'vfx-intent-queue-overflow');
422
+ }
423
+ }
424
+
425
+ reset(player: EntityHandle): void {
426
+ this.#discardQueuedIntents(player);
427
+ this.#clearDiagnostics(player);
428
+ this.#players.delete(player);
429
+ this.#instances.delete(player);
430
+ this.#replayRequests.delete(player);
431
+ this.#lastCommitted.delete(player);
432
+ this.#lastCommittedByEmitter.delete(player);
433
+ this.#eventCounters.delete(player);
434
+ this.#replayInputs.delete(player);
435
+ const prefix = `${player}:`;
436
+ for (const key of this.#cameraVisibility.keys()) {
437
+ if (key.startsWith(prefix)) this.#cameraVisibility.delete(key);
438
+ }
439
+ for (const key of this.#sessionEnabled.keys()) {
440
+ if (key.startsWith(prefix)) this.#sessionEnabled.delete(key);
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Drop every generation-owned VFX runtime value after device recovery.
446
+ * Authored ParticleEffectPlayer state remains the restart source on the next
447
+ * FixedUpdate; no queued intent or stale instance crosses the boundary.
448
+ */
449
+ recover(): void {
450
+ this.#renderGeneration += 1;
451
+ this.#players.clear();
452
+ this.#instances.clear();
453
+ this.#seen.clear();
454
+ this.#intents.length = 0;
455
+ this.#diagnostics.length = 0;
456
+ this.#lastCommitted.clear();
457
+ this.#lastCommittedByEmitter.clear();
458
+ this.#eventCounters.clear();
459
+ this.#cameraVisibility.clear();
460
+ this.#sessionEnabled.clear();
461
+ this.#replayRequests.clear();
462
+ this.#replayInputs.clear();
463
+ }
464
+
465
+ #report(diagnostic: VfxGpuRuntimeDiagnostic): void {
466
+ const alreadyActive = this.#diagnostics.some(
467
+ (prior) => prior.code === diagnostic.code && prior.detail.player === diagnostic.detail.player,
468
+ );
469
+ if (alreadyActive) return;
470
+ if (this.#diagnostics.length === 64) this.#diagnostics.shift();
471
+ this.#diagnostics.push(diagnostic);
472
+ }
473
+
474
+ #clearDiagnostics(player: EntityHandle, code?: VfxGpuRuntimeDiagnostic['code']): void {
475
+ for (let index = this.#diagnostics.length - 1; index >= 0; index -= 1) {
476
+ const diagnostic = this.#diagnostics[index];
477
+ if (
478
+ diagnostic?.detail.player === player &&
479
+ (code === undefined || diagnostic.code === code)
480
+ ) {
481
+ this.#diagnostics.splice(index, 1);
482
+ }
483
+ }
484
+ }
485
+
486
+ #discardQueuedIntents(player: EntityHandle): void {
487
+ let retained = 0;
488
+ for (const intent of this.#intents) {
489
+ if (intent.player !== player) {
490
+ this.#intents[retained] = intent;
491
+ retained += 1;
492
+ }
493
+ }
494
+ this.#intents.length = retained;
495
+ }
496
+
497
+ advance(
498
+ world: World,
499
+ tick: number,
500
+ fixedDelta: number,
501
+ players: readonly {
502
+ readonly player: EntityHandle;
503
+ readonly effect: Handle<'ParticleEffectAsset', 'shared'>;
504
+ readonly playing: boolean;
505
+ readonly seed: number;
506
+ readonly timeScale: number;
507
+ }[],
508
+ ): void {
509
+ this.#seen.clear();
510
+ for (const input of players) {
511
+ this.#seen.add(input.player);
512
+ if (!Number.isFinite(input.timeScale) || input.timeScale < 0) {
513
+ this.#report({
514
+ code: 'vfx-player-invalid',
515
+ expected: 'a finite non-negative particle timeScale',
516
+ hint: 'repair ParticleEffectPlayer.timeScale and restart the player',
517
+ detail: { player: input.player },
518
+ });
519
+ continue;
520
+ }
521
+ this.#clearDiagnostics(input.player, 'vfx-player-invalid');
522
+ const resolved = world.sharedRefs.resolve<'ParticleEffectAsset', VfxGpuEffectAsset>(
523
+ input.effect,
524
+ );
525
+ if (!resolved.ok || resolved.value.schemaVersion !== 2) {
526
+ this.#report({
527
+ code: 'vfx-effect-unavailable',
528
+ expected: 'a loaded schemaVersion 2 GPU particle effect',
529
+ hint: 'load and recook the effect before the first FixedUpdate',
530
+ detail: { player: input.player },
531
+ });
532
+ continue;
533
+ }
534
+ this.#clearDiagnostics(input.player, 'vfx-effect-unavailable');
535
+ const previous = this.#players.get(input.player);
536
+ const replayRequested = this.#replayRequests.delete(input.player);
537
+ const restart =
538
+ previous === undefined ||
539
+ previous.effect !== input.effect ||
540
+ previous.seed !== input.seed ||
541
+ replayRequested ||
542
+ (!previous.playing && input.playing);
543
+ const state = restart
544
+ ? {
545
+ effect: input.effect,
546
+ assetGuid: resolved.value.guid,
547
+ programFingerprint: resolved.value.program.fingerprint,
548
+ emitters: resolved.value.program.emitters,
549
+ seed: input.seed,
550
+ playing: input.playing,
551
+ playCycle: (previous?.playCycle ?? -1) + 1,
552
+ elapsed: resolved.value.program.emitters.map(() => 0),
553
+ rateRemainders: resolved.value.program.emitters.map(() => 0),
554
+ nextParticleIds: resolved.value.program.emitters.map(() => 0),
555
+ playCycles: resolved.value.program.emitters.map(() => (previous?.playCycle ?? -1) + 1),
556
+ cameraVisible: resolved.value.program.emitters.map(() => true),
557
+ phaseTicks: resolved.value.program.emitters.map(() => 0),
558
+ hasCommitted: false,
559
+ }
560
+ : previous;
561
+ this.#players.set(input.player, state);
562
+ // Replay is a one-FixedUpdate request, not a second persistent play-state
563
+ // authority. Inspection always returns the authored player state after
564
+ // the requested reset intent has been emitted.
565
+ state.playing = input.playing;
566
+ if (!input.playing && !replayRequested) continue;
567
+ const queuedForPlayer = this.#intents.reduce(
568
+ (count, intent) => count + (intent.player === input.player ? 1 : 0),
569
+ 0,
570
+ );
571
+ if (
572
+ queuedForPlayer >=
573
+ this.#maxQueuedTicks * Math.max(1, resolved.value.program.emitters.length)
574
+ ) {
575
+ if (state.hasCommitted) {
576
+ this.#report({
577
+ code: 'vfx-intent-queue-overflow',
578
+ expected: `at most ${this.#maxQueuedTicks} unconsumed fixed ticks`,
579
+ hint: 'recover or restart the renderer; VFX does not silently discard simulation ticks',
580
+ detail: { player: input.player, maxQueuedTicks: this.#maxQueuedTicks },
581
+ });
582
+ }
583
+ continue;
584
+ }
585
+ const instance =
586
+ this.#instances.get(input.player) ?? this.#createInstance(input.player, resolved.value);
587
+ if (instance === undefined) continue;
588
+ const hasActiveEmitter = resolved.value.program.emitters.some((emitter) => {
589
+ if (!this.isEmitterSessionEnabled(input.player, emitter.id)) return false;
590
+ const cameraVisible = this.#cameraVisibility.get(`${input.player}:${emitter.id}`) ?? true;
591
+ return cameraVisible || emitter.simulationWhenCulled === 'continue';
592
+ });
593
+ if (!hasActiveEmitter) {
594
+ for (const [index, emitter] of resolved.value.program.emitters.entries()) {
595
+ state.cameraVisible[index] =
596
+ this.#cameraVisibility.get(`${input.player}:${emitter.id}`) ?? true;
597
+ }
598
+ continue;
599
+ }
600
+ const replayInput = this.#replayInputs.get(input.player);
601
+ this.#replayInputs.delete(input.player);
602
+ const committed =
603
+ replayInput === undefined
604
+ ? instance.commit({ seed: input.seed, tick })
605
+ : instance.replay(replayInput);
606
+ if (!committed.ok) {
607
+ this.#report({
608
+ code: 'vfx-instance-commit-failed',
609
+ expected: 'the current typed instance values to pack into the reflected GPU block',
610
+ hint: 'repair the instance patch and retry at the next FixedUpdate',
611
+ detail: { player: input.player },
612
+ });
613
+ continue;
614
+ }
615
+ const delta = fixedDelta * input.timeScale;
616
+ const committedChannels = committed.value.channelInputs;
617
+ for (const [index, emitter] of resolved.value.program.emitters.entries()) {
618
+ const cameraVisible = this.#cameraVisibility.get(`${input.player}:${emitter.id}`) ?? true;
619
+ const becameVisible = cameraVisible && state.cameraVisible[index] === false;
620
+ state.cameraVisible[index] = cameraVisible;
621
+ if (!this.isEmitterSessionEnabled(input.player, emitter.id)) continue;
622
+ if (!cameraVisible && emitter.simulationWhenCulled !== 'continue') continue;
623
+ const visibilityRestart =
624
+ becameVisible && emitter.simulationWhenCulled === 'restart-on-visible';
625
+ if (visibilityRestart) {
626
+ state.elapsed[index] = 0;
627
+ state.rateRemainders[index] = 0;
628
+ state.nextParticleIds[index] = 0;
629
+ state.playCycles[index] = (state.playCycles[index] ?? state.playCycle) + 1;
630
+ state.phaseTicks[index] = 0;
631
+ }
632
+ const previousElapsed = state.elapsed[index] ?? 0;
633
+ const scheduled = spawnCount(
634
+ emitter,
635
+ previousElapsed,
636
+ previousElapsed + delta,
637
+ restart || visibilityRestart,
638
+ state.rateRemainders[index] ?? 0,
639
+ );
640
+ state.rateRemainders[index] = scheduled.remainder;
641
+ const firstParticleId = state.nextParticleIds[index] ?? 0;
642
+ state.nextParticleIds[index] = firstParticleId + scheduled.count;
643
+ const consumesEvent = resolved.value.program.emitters.some((source) =>
644
+ (source.events ?? []).some((event) => event.subEmitter === emitter.id),
645
+ );
646
+ const eventSources = resolved.value.program.emitters.flatMap((source) =>
647
+ (source.events ?? []).filter((event) => event.subEmitter === emitter.id),
648
+ );
649
+ const channelInputs =
650
+ (emitter.events?.length ?? 0) > 0 || consumesEvent ? committedChannels : [];
651
+ const phaseTick = state.phaseTicks[index] ?? 0;
652
+ this.#intents.push(
653
+ Object.freeze({
654
+ sequence: this.#sequence++,
655
+ player: input.player,
656
+ emitter,
657
+ programFingerprint: resolved.value.program.fingerprint,
658
+ reset: restart || visibilityRestart,
659
+ fixedDelta: delta,
660
+ phaseTick,
661
+ tick,
662
+ seed: input.seed,
663
+ playCycle: state.playCycles[index] ?? state.playCycle,
664
+ spawnCount: scheduled.count,
665
+ firstParticleId,
666
+ instanceGeneration: committed.value.generation,
667
+ instancePatchCount: committed.value.patchCount,
668
+ parameterBlock: committed.value.parameterBlock,
669
+ canonicalPayload: committed.value.canonicalPayload,
670
+ replayInput: committed.value.replayInput,
671
+ channelInputs,
672
+ eventCounters: eventCounters(
673
+ { channelInputs, emitter },
674
+ channelInputs.length === 0 ? 0 : committed.value.droppedCount,
675
+ eventSources,
676
+ ),
677
+ }),
678
+ );
679
+ state.elapsed[index] = previousElapsed + delta;
680
+ state.phaseTicks[index] = phaseTick + 1;
681
+ }
682
+ }
683
+ for (const player of this.#players.keys()) {
684
+ if (!this.#seen.has(player)) this.reset(player);
685
+ }
686
+ }
687
+
688
+ #createInstance(
689
+ player: EntityHandle,
690
+ effect: VfxGpuEffectAsset,
691
+ ): ParticleEffectInstance | undefined {
692
+ const layout = effect.program.emitters.find(
693
+ (emitter) => emitter.reflection.layout !== undefined,
694
+ )?.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
+ };
703
+ try {
704
+ const instance = new ParticleEffectInstance(createVfxEffectContract(reflection), {
705
+ channels: effect.program.emitters.flatMap((emitter) => emitter.channels ?? []),
706
+ });
707
+ this.#instances.set(player, instance);
708
+ return instance;
709
+ } catch {
710
+ this.#report({
711
+ code: 'vfx-instance-commit-failed',
712
+ expected: 'a valid reflected VFX instance contract',
713
+ hint: 'recook the effect with a valid reflection layout before starting the player',
714
+ detail: { player },
715
+ });
716
+ return undefined;
717
+ }
718
+ }
719
+
720
+ #latestIntent(player: EntityHandle, emitterId: string): VfxGpuTickIntent | undefined {
721
+ let latest = this.#lastCommittedByEmitter.get(player)?.get(emitterId);
722
+ for (let index = this.#intents.length - 1; index >= 0; index -= 1) {
723
+ const intent = this.#intents[index];
724
+ if (intent?.player !== player || intent.emitter.id !== emitterId) continue;
725
+ if (latest === undefined || intent.sequence > latest.sequence) latest = intent;
726
+ break;
727
+ }
728
+ return latest;
729
+ }
730
+ }
731
+
732
+ function spawnCount(
733
+ emitter: VfxGpuEmitterProgram,
734
+ previous: number,
735
+ next: number,
736
+ firstTick: boolean,
737
+ priorRemainder: number,
738
+ ): { readonly count: number; readonly remainder: number } {
739
+ const exactRate = emitter.schedule.rate * Math.max(0, next - previous) + priorRemainder;
740
+ let count = Math.floor(exactRate);
741
+ const loop = emitter.schedule.loopDuration;
742
+ for (const burst of emitter.schedule.bursts ?? []) {
743
+ if (loop === undefined) {
744
+ if ((firstTick && burst.time === 0) || (burst.time > previous && burst.time <= next)) {
745
+ count += burst.count;
746
+ }
747
+ continue;
748
+ }
749
+ const firstOccurrence = Math.max(0, Math.floor((previous - burst.time) / loop) + 1);
750
+ const lastOccurrence = Math.floor((next - burst.time) / loop);
751
+ const occurrences = Math.max(0, lastOccurrence - firstOccurrence + 1);
752
+ count += occurrences * burst.count;
753
+ if (firstTick && burst.time === 0) count += burst.count;
754
+ }
755
+ return { count, remainder: exactRate - Math.floor(exactRate) };
756
+ }
757
+
758
+ export function vfxGpuRuntimePlugin(options: VfxGpuRuntimeOptions = {}): Plugin {
759
+ const rows: {
760
+ player: EntityHandle;
761
+ effect: Handle<'ParticleEffectAsset', 'shared'>;
762
+ playing: boolean;
763
+ seed: number;
764
+ timeScale: number;
765
+ }[] = [];
766
+ return {
767
+ name: 'vfx-gpu-runtime',
768
+ inject: ['world'],
769
+ apply(ctx) {
770
+ const world = ctx.world;
771
+ if (world.hasResource(VFX_GPU_RUNTIME_RESOURCE_KEY)) {
772
+ throw new TypeError(`${VFX_GPU_RUNTIME_RESOURCE_KEY} already exists`);
773
+ }
774
+ const runtime = new VfxGpuRuntime(options);
775
+ ctx.effect(() => {
776
+ world.insertResource(VFX_GPU_RUNTIME_RESOURCE_KEY, runtime);
777
+ return () => {
778
+ world.removeResource(VFX_GPU_RUNTIME_RESOURCE_KEY);
779
+ };
780
+ }, 'vfx/runtime-resource');
781
+ ctx.effect(() => {
782
+ world
783
+ .addSystem(FixedUpdate, {
784
+ name: 'vfx-gpu-runtime',
785
+ queries: [{ with: [Entity, ParticleEffectPlayer] }],
786
+ fn: (world, queryResults) => {
787
+ rows.length = 0;
788
+ for (const row of queryResults[0]) {
789
+ const player = row.get(ParticleEffectPlayer);
790
+ rows.push({
791
+ player: row.entity,
792
+ effect: toShared<'ParticleEffectAsset'>(player.effect),
793
+ playing: player.playing,
794
+ seed: player.seed,
795
+ timeScale: player.timeScale,
796
+ });
797
+ }
798
+ const fixed = world.getResource(FixedTime);
799
+ runtime.advance(world, fixed.tick, fixed.delta, rows);
800
+ },
801
+ })
802
+ .unwrap();
803
+ return () => world.removeSystem(FixedUpdate, 'vfx-gpu-runtime');
804
+ }, 'vfx/tick-system');
805
+ },
806
+ };
807
+ }