@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,23 @@
1
+ import { describe, expectTypeOf, it } from 'vitest';
2
+ import type {
3
+ ParticleRendererOverflowPolicy,
4
+ ParticleRendererSorting,
5
+ ParticleStageDomain,
6
+ ParticleStageResourceAccess,
7
+ } from '../code-source.js';
8
+ import type { VfxGpuRendererReflection, VfxGpuStageReflection } from '../gpu-program.js';
9
+
10
+ describe('VFX GPU reflection vocabulary owner', () => {
11
+ it('derives reflection vocabularies from the authored source vocabulary', () => {
12
+ expectTypeOf<
13
+ VfxGpuRendererReflection['overflow']
14
+ >().toEqualTypeOf<ParticleRendererOverflowPolicy>();
15
+ expectTypeOf<
16
+ NonNullable<VfxGpuRendererReflection['sorting']>
17
+ >().toEqualTypeOf<ParticleRendererSorting>();
18
+ expectTypeOf<VfxGpuStageReflection['domain']>().toEqualTypeOf<ParticleStageDomain>();
19
+ expectTypeOf<
20
+ VfxGpuStageReflection['resources'][number]['access']
21
+ >().toEqualTypeOf<ParticleStageResourceAccess>();
22
+ });
23
+ });
@@ -0,0 +1,549 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createWorldContext, World } from '@forgeax/engine-ecs';
3
+ import type { ParticleEffectAsset } from '@forgeax/engine-types';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { createVfxEffectContract } from '../effect-contract.js';
6
+ import type { VfxGpuEffectAsset } from '../gpu-program.js';
7
+ import {
8
+ VFX_GPU_RUNTIME_RESOURCE_KEY,
9
+ type VfxGpuRuntime,
10
+ vfxGpuRuntimePlugin,
11
+ } from '../gpu-runtime.js';
12
+ import { ParticleEffectInstance } from '../instance.js';
13
+ import { ParticleEffectPlayer } from '../player.js';
14
+
15
+ const effect: VfxGpuEffectAsset = {
16
+ guid: 'effect-guid',
17
+ kind: 'particle-effect',
18
+ schemaVersion: 2,
19
+ programFingerprint: 'sha256:test',
20
+ emitters: [{ id: 'sparks', capacity: 100_000 }],
21
+ program: {
22
+ format: 'forgeax-vfx-program-2',
23
+ fingerprint: 'sha256:test',
24
+ emitters: [
25
+ {
26
+ id: 'sparks',
27
+ module: 'sparks.vfx.wgsl',
28
+ capacity: 100_000,
29
+ backend: { required: 'gpu' },
30
+ space: 'world',
31
+ schedule: { rate: 60, bursts: [{ time: 0, count: 7 }], loopDuration: 1 },
32
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 10 },
33
+ renderers: [{ kind: 'billboard', material: 'material-guid' }],
34
+ simulationWhenCulled: 'continue',
35
+ wgsl: 'cooked',
36
+ reflection: {
37
+ hooks: ['vfx_spawn', 'vfx_update'],
38
+ imports: [],
39
+ resources: [],
40
+ entryPoints: [],
41
+ bindings: [],
42
+ },
43
+ },
44
+ ],
45
+ },
46
+ };
47
+
48
+ describe('GPU VFX fixed-tick intents', () => {
49
+ it('uses the ordinary asset program as the playback source', () => {
50
+ const asset: ParticleEffectAsset = effect;
51
+ expect(asset.program.fingerprint).toBe(asset.programFingerprint);
52
+ expect(asset.program.emitters).toHaveLength(1);
53
+ });
54
+
55
+ it('keeps stage recovery scoped to the cooked generation', () => {
56
+ const source = readFileSync(new URL('../gpu-runtime.ts', import.meta.url), 'utf8');
57
+ expect(source).toContain('instanceGeneration');
58
+ expect(source).not.toContain('runtimeCompiler');
59
+ });
60
+
61
+ it('uploads channel inputs with the fixed tick and isolates per-player overflow', async () => {
62
+ const channelEffect = structuredClone(effect) as VfxGpuEffectAsset;
63
+ const channelEmitters = channelEffect.program.emitters as unknown as Array<
64
+ Record<string, unknown>
65
+ >;
66
+ channelEmitters[0] = {
67
+ ...channelEmitters[0],
68
+ channels: [{ id: 'impact', capacity: 1, overflow: 'drop-newest' }],
69
+ events: [
70
+ {
71
+ id: 'impact-event',
72
+ channel: 'impact',
73
+ subEmitter: 'sparks',
74
+ fanOut: 1,
75
+ recursionDepth: 1,
76
+ },
77
+ ],
78
+ };
79
+ const world = new World();
80
+ const handle = world.allocSharedRef('ParticleEffectAsset', channelEffect);
81
+ const player = world
82
+ .spawn({
83
+ component: ParticleEffectPlayer,
84
+ data: { effect: handle, playing: true, seed: 9, timeScale: 1 },
85
+ })
86
+ .unwrap();
87
+ await createWorldContext(world, [vfxGpuRuntimePlugin({ maxQueuedTicks: 2 })]);
88
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
89
+ world.update(1 / 60).unwrap();
90
+ const instance = runtime.getInstance(player);
91
+ expect(instance).toBeDefined();
92
+ if (instance === undefined) return;
93
+ const first = (instance as unknown as { submit(input: unknown): { ok: boolean } }).submit({
94
+ channel: 'impact',
95
+ payload: { position: [0, 1, 0], strength: 1 },
96
+ sequence: 1,
97
+ });
98
+ const second = (instance as unknown as { submit(input: unknown): { ok: boolean } }).submit({
99
+ channel: 'impact',
100
+ payload: { position: [0, 1, 0], strength: 1 },
101
+ sequence: 2,
102
+ });
103
+ expect(first.ok).toBe(true);
104
+ expect(second.ok).toBe(false);
105
+ world.update(1 / 60).unwrap();
106
+ expect(runtime.snapshot().find((intent) => intent.channelInputs.length > 0)).toMatchObject({
107
+ channelInputs: [{ channel: 'impact' }],
108
+ });
109
+ });
110
+
111
+ it('publishes multiple same-tick patches as one atomic instance generation', async () => {
112
+ const world = new World();
113
+ const handle = world.allocSharedRef('ParticleEffectAsset', effect);
114
+ const player = world
115
+ .spawn({
116
+ component: ParticleEffectPlayer,
117
+ data: { effect: handle, playing: true, seed: 9, timeScale: 1 },
118
+ })
119
+ .unwrap();
120
+ const contract = createVfxEffectContract({
121
+ version: 1,
122
+ parameters: { name: 'VfxParameters', fields: [], size: 0, alignment: 1 },
123
+ custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
124
+ fingerprint: 'sha256:atomic-patch',
125
+ });
126
+ const instance = new ParticleEffectInstance(contract);
127
+ await createWorldContext(world, [vfxGpuRuntimePlugin()]);
128
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
129
+ runtime.attachInstance(player, instance);
130
+ expect(instance.patch({}).ok).toBe(true);
131
+ expect(instance.patch({}).ok).toBe(true);
132
+
133
+ world.update(1 / 60).unwrap();
134
+
135
+ expect(runtime.snapshot()).toHaveLength(1);
136
+ expect(runtime.snapshot()[0]).toMatchObject({
137
+ tick: 1,
138
+ instanceGeneration: 1,
139
+ instancePatchCount: 2,
140
+ });
141
+ expect(runtime.snapshot()[0]?.canonicalPayload).toBeInstanceOf(Uint8Array);
142
+ });
143
+
144
+ it('drops stale instances at a new render generation and restarts from authored state', async () => {
145
+ const world = new World();
146
+ const handle = world.allocSharedRef('ParticleEffectAsset', effect);
147
+ const player = world
148
+ .spawn({
149
+ component: ParticleEffectPlayer,
150
+ data: { effect: handle, playing: true, seed: 9, timeScale: 1 },
151
+ })
152
+ .unwrap();
153
+ await createWorldContext(world, [vfxGpuRuntimePlugin()]);
154
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
155
+
156
+ world.update(1 / 60).unwrap();
157
+ const stale = runtime.getInstance(player);
158
+ expect(stale).toBeDefined();
159
+ expect(runtime.renderGeneration).toBe(0);
160
+
161
+ runtime.recover();
162
+
163
+ expect(runtime.renderGeneration).toBe(1);
164
+ expect(runtime.hasPlayer(player)).toBe(false);
165
+ expect(runtime.getInstance(player)).toBeUndefined();
166
+ expect(runtime.snapshot()).toHaveLength(0);
167
+ expect(runtime.lastCommitted(player)).toBeUndefined();
168
+
169
+ expect(stale?.patch({}).ok).toBe(true);
170
+ world.update(1 / 60).unwrap();
171
+ const restarted = runtime.getInstance(player);
172
+ expect(restarted).toBeDefined();
173
+ expect(restarted).not.toBe(stale);
174
+ expect(runtime.inspectPlayer(player)?.values.generation).toBe(0);
175
+
176
+ expect(restarted?.patch({}).ok).toBe(true);
177
+ world.update(1 / 60).unwrap();
178
+ expect(runtime.inspectPlayer(player)?.values.generation).toBe(1);
179
+ });
180
+
181
+ it('fires time-zero once and keeps tick commands bounded and ordered', async () => {
182
+ const world = new World();
183
+ const handle = world.allocSharedRef('ParticleEffectAsset', effect);
184
+ const spawned = world.spawn({
185
+ component: ParticleEffectPlayer,
186
+ data: { effect: handle, playing: true, seed: 9, timeScale: 1 },
187
+ });
188
+ expect(spawned.ok).toBe(true);
189
+ await createWorldContext(world, [vfxGpuRuntimePlugin({ maxQueuedTicks: 2 })]);
190
+
191
+ expect(world.update(1 / 60).ok).toBe(true);
192
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
193
+ expect(runtime.snapshot()).toHaveLength(1);
194
+ expect(runtime.snapshot()[0]).toMatchObject({ reset: true, spawnCount: 8, firstParticleId: 0 });
195
+ runtime.commit(runtime.snapshot().at(0)?.sequence ?? -1);
196
+
197
+ expect(world.update(1 / 60).ok).toBe(true);
198
+ expect(runtime.snapshot()).toHaveLength(1);
199
+ expect(runtime.snapshot()[0]).toMatchObject({
200
+ reset: false,
201
+ spawnCount: 1,
202
+ firstParticleId: 8,
203
+ });
204
+ });
205
+
206
+ it('replays an authored stopped player without mutating author intent', async () => {
207
+ const world = new World();
208
+ const handle = world.allocSharedRef('ParticleEffectAsset', effect);
209
+ const player = world
210
+ .spawn({
211
+ component: ParticleEffectPlayer,
212
+ data: { effect: handle, playing: false, seed: 9, timeScale: 1 },
213
+ })
214
+ .unwrap();
215
+ await createWorldContext(world, [vfxGpuRuntimePlugin()]);
216
+ world.update(1 / 60).unwrap();
217
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
218
+ expect(runtime.snapshot()).toHaveLength(0);
219
+
220
+ runtime.replay(player);
221
+ world.update(1 / 60).unwrap();
222
+
223
+ expect(runtime.snapshot()[0]).toMatchObject({
224
+ player,
225
+ reset: true,
226
+ phaseTick: 0,
227
+ spawnCount: 8,
228
+ firstParticleId: 0,
229
+ });
230
+ expect(world.get(player, ParticleEffectPlayer).unwrap().playing).toBe(false);
231
+
232
+ runtime.commit(runtime.snapshot().at(0)?.sequence ?? -1);
233
+ world.update(1 / 60).unwrap();
234
+
235
+ expect(runtime.snapshot()).toHaveLength(0);
236
+ expect(runtime.inspectPlayer(player)?.playing).toBe(false);
237
+ });
238
+
239
+ it('holds time during cold GPU preparation, then diagnoses post-start backpressure', async () => {
240
+ const world = new World();
241
+ const handle = world.allocSharedRef('ParticleEffectAsset', effect);
242
+ world
243
+ .spawn({
244
+ component: ParticleEffectPlayer,
245
+ data: { effect: handle, playing: true, seed: 1, timeScale: 1 },
246
+ })
247
+ .unwrap();
248
+ await createWorldContext(world, [vfxGpuRuntimePlugin({ maxQueuedTicks: 1 })]);
249
+ world.update(1 / 60).unwrap();
250
+ world.update(1 / 60).unwrap();
251
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
252
+ expect(runtime.snapshot()).toHaveLength(1);
253
+ expect(runtime.diagnostics()).toHaveLength(0);
254
+ runtime.commit(runtime.snapshot().at(0)?.sequence ?? -1);
255
+ world.update(1 / 60).unwrap();
256
+ world.update(1 / 60).unwrap();
257
+ expect(runtime.snapshot()).toHaveLength(1);
258
+ expect(runtime.diagnostics().at(-1)?.code).toBe('vfx-intent-queue-overflow');
259
+ runtime.commit(runtime.snapshot().at(-1)?.sequence ?? -1);
260
+ expect(runtime.diagnostics()).toEqual([]);
261
+ });
262
+
263
+ it('makes an explicit replay boundary discard an overflowing player queue', async () => {
264
+ const world = new World();
265
+ const handle = world.allocSharedRef('ParticleEffectAsset', effect);
266
+ const player = world
267
+ .spawn({
268
+ component: ParticleEffectPlayer,
269
+ data: { effect: handle, playing: true, seed: 1, timeScale: 1 },
270
+ })
271
+ .unwrap();
272
+ await createWorldContext(world, [vfxGpuRuntimePlugin({ maxQueuedTicks: 1 })]);
273
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
274
+
275
+ world.update(1 / 60).unwrap();
276
+ runtime.commit(runtime.snapshot()[0]?.sequence ?? -1);
277
+ world.update(1 / 60).unwrap();
278
+ world.update(1 / 60).unwrap();
279
+ expect(runtime.diagnostics()).toHaveLength(1);
280
+ expect(runtime.diagnostics()[0]).toMatchObject({
281
+ code: 'vfx-intent-queue-overflow',
282
+ detail: { player, maxQueuedTicks: 1 },
283
+ });
284
+ expect(runtime.inspectPlayer(player)).toMatchObject({
285
+ queuedIntents: 1,
286
+ queuedTicks: 1,
287
+ lastCommitted: {
288
+ tick: 1,
289
+ phaseTick: 0,
290
+ playCycle: 0,
291
+ firstParticleId: 0,
292
+ },
293
+ });
294
+
295
+ runtime.replay(player);
296
+
297
+ expect(runtime.snapshot()).toHaveLength(0);
298
+ expect(runtime.diagnostics()).toEqual([]);
299
+ world.update(1 / 60).unwrap();
300
+ expect(runtime.snapshot()[0]).toMatchObject({
301
+ player,
302
+ reset: true,
303
+ phaseTick: 0,
304
+ playCycle: 1,
305
+ firstParticleId: 0,
306
+ });
307
+ expect(runtime.inspectPlayer(player)).toMatchObject({
308
+ queuedIntents: 1,
309
+ queuedTicks: 1,
310
+ emitters: [{ phaseTick: 0, playCycle: 1, firstParticleId: 0, reset: true }],
311
+ lastCommitted: {
312
+ tick: 1,
313
+ phaseTick: 0,
314
+ playCycle: 0,
315
+ firstParticleId: 0,
316
+ },
317
+ });
318
+ });
319
+
320
+ it('deduplicates active overflow diagnostics after another diagnostic interleaves', async () => {
321
+ const world = new World();
322
+ const handle = world.allocSharedRef('ParticleEffectAsset', effect);
323
+ const player = world
324
+ .spawn({
325
+ component: ParticleEffectPlayer,
326
+ data: { effect: handle, playing: true, seed: 1, timeScale: 1 },
327
+ })
328
+ .unwrap();
329
+ await createWorldContext(world, [vfxGpuRuntimePlugin({ maxQueuedTicks: 1 })]);
330
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
331
+
332
+ world.update(1 / 60).unwrap();
333
+ runtime.commit(runtime.snapshot()[0]?.sequence ?? -1);
334
+ world.update(1 / 60).unwrap();
335
+ world.update(1 / 60).unwrap();
336
+ expect(runtime.diagnostics()).toHaveLength(1);
337
+
338
+ world.set(player, ParticleEffectPlayer, { timeScale: -1 }).unwrap();
339
+ world.update(1 / 60).unwrap();
340
+ expect(runtime.diagnostics()).toHaveLength(2);
341
+
342
+ world.set(player, ParticleEffectPlayer, { timeScale: 1 }).unwrap();
343
+ world.update(1 / 60).unwrap();
344
+
345
+ expect(runtime.diagnostics()).toHaveLength(1);
346
+ expect(runtime.diagnostics()[0]).toMatchObject({
347
+ code: 'vfx-intent-queue-overflow',
348
+ detail: { player, maxQueuedTicks: 1 },
349
+ });
350
+ });
351
+
352
+ it('bounds each player independently', async () => {
353
+ const world = new World();
354
+ const handle = world.allocSharedRef('ParticleEffectAsset', effect);
355
+ const first = world
356
+ .spawn({
357
+ component: ParticleEffectPlayer,
358
+ data: { effect: handle, playing: true, seed: 1, timeScale: 1 },
359
+ })
360
+ .unwrap();
361
+ world
362
+ .spawn({
363
+ component: ParticleEffectPlayer,
364
+ data: { effect: handle, playing: true, seed: 2, timeScale: 1 },
365
+ })
366
+ .unwrap();
367
+ await createWorldContext(world, [vfxGpuRuntimePlugin({ maxQueuedTicks: 1 })]);
368
+ world.update(1 / 60).unwrap();
369
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
370
+ expect(runtime.snapshot()).toHaveLength(2);
371
+ expect(runtime.snapshot().map((intent) => intent.player)).toContain(first);
372
+ });
373
+
374
+ it('inspects every player and emitter by stable runtime identity', async () => {
375
+ const multiEmitter = structuredClone(effect) as VfxGpuEffectAsset;
376
+ const firstEmitter = multiEmitter.program.emitters[0];
377
+ expect(firstEmitter).toBeDefined();
378
+ if (firstEmitter === undefined) return;
379
+ const secondEmitter = {
380
+ ...firstEmitter,
381
+ id: 'smoke',
382
+ module: 'smoke.vfx.wgsl',
383
+ capacity: 64,
384
+ renderers: [
385
+ { kind: 'trail' as const, material: 'smoke-material', historyLength: 4, capacity: 64 },
386
+ ],
387
+ };
388
+ (multiEmitter.program.emitters as VfxGpuEffectAsset['program']['emitters'][number][]).push(
389
+ secondEmitter,
390
+ );
391
+ (multiEmitter.emitters as { id: string; capacity: number }[]).push({
392
+ id: 'smoke',
393
+ capacity: 64,
394
+ });
395
+ const world = new World();
396
+ const handle = world.allocSharedRef('ParticleEffectAsset', multiEmitter);
397
+ const first = world
398
+ .spawn({
399
+ component: ParticleEffectPlayer,
400
+ data: { effect: handle, playing: true, seed: 1, timeScale: 1 },
401
+ })
402
+ .unwrap();
403
+ const second = world
404
+ .spawn({
405
+ component: ParticleEffectPlayer,
406
+ data: { effect: handle, playing: true, seed: 2, timeScale: 1 },
407
+ })
408
+ .unwrap();
409
+ await createWorldContext(world, [vfxGpuRuntimePlugin()]);
410
+ world.update(1 / 60).unwrap();
411
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
412
+
413
+ expect(runtime.inspectPlayers()).toEqual([
414
+ expect.objectContaining({
415
+ player: first,
416
+ assetGuid: 'effect-guid',
417
+ programFingerprint: 'sha256:test',
418
+ seed: 1,
419
+ fixedDelta: 1 / 60,
420
+ emitters: [
421
+ expect.objectContaining({
422
+ id: 'sparks',
423
+ module: 'sparks.vfx.wgsl',
424
+ cameraVisible: true,
425
+ sessionEnabled: true,
426
+ phaseTick: 0,
427
+ }),
428
+ expect.objectContaining({
429
+ id: 'smoke',
430
+ module: 'smoke.vfx.wgsl',
431
+ cameraVisible: true,
432
+ sessionEnabled: true,
433
+ phaseTick: 0,
434
+ }),
435
+ ],
436
+ }),
437
+ expect.objectContaining({ player: second }),
438
+ ]);
439
+
440
+ runtime.setEmitterCameraVisibility(first, 'smoke', false);
441
+ expect(runtime.inspectPlayer(first)?.emitters[1]).toMatchObject({
442
+ id: 'smoke',
443
+ cameraVisible: false,
444
+ sessionEnabled: true,
445
+ });
446
+ expect(runtime.inspectPlayer(999 as never)).toBeUndefined();
447
+ });
448
+
449
+ it('clears player and effect diagnostics after authored state recovers', async () => {
450
+ const world = new World();
451
+ const unavailable = world.allocSharedRef('ParticleEffectAsset', {
452
+ ...effect,
453
+ schemaVersion: 1,
454
+ } as never);
455
+ const available = world.allocSharedRef('ParticleEffectAsset', effect);
456
+ const player = world
457
+ .spawn({
458
+ component: ParticleEffectPlayer,
459
+ data: { effect: unavailable, playing: true, seed: 3, timeScale: -1 },
460
+ })
461
+ .unwrap();
462
+ await createWorldContext(world, [vfxGpuRuntimePlugin()]);
463
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
464
+
465
+ world.update(1 / 60).unwrap();
466
+ expect(runtime.diagnostics().map((diagnostic) => diagnostic.code)).toEqual([
467
+ 'vfx-player-invalid',
468
+ ]);
469
+
470
+ world.set(player, ParticleEffectPlayer, { timeScale: 1 }).unwrap();
471
+ world.update(1 / 60).unwrap();
472
+ expect(runtime.diagnostics().map((diagnostic) => diagnostic.code)).toEqual([
473
+ 'vfx-effect-unavailable',
474
+ ]);
475
+
476
+ world.set(player, ParticleEffectPlayer, { effect: available }).unwrap();
477
+ world.update(1 / 60).unwrap();
478
+ expect(runtime.diagnostics()).toEqual([]);
479
+ });
480
+
481
+ it.each([
482
+ ['pause', false, 8],
483
+ ['restart-on-visible', true, 0],
484
+ ] as const)('%s applies culling lifecycle at fixed-tick boundaries', async (policy, reset, firstParticleId) => {
485
+ const world = new World();
486
+ const policyEffect = structuredClone(effect) as VfxGpuEffectAsset;
487
+ (policyEffect.program.emitters[0] as { simulationWhenCulled: string }).simulationWhenCulled =
488
+ policy;
489
+ const handle = world.allocSharedRef('ParticleEffectAsset', policyEffect);
490
+ const player = world
491
+ .spawn({
492
+ component: ParticleEffectPlayer,
493
+ data: { effect: handle, playing: true, seed: 9, timeScale: 1 },
494
+ })
495
+ .unwrap();
496
+ await createWorldContext(world, [vfxGpuRuntimePlugin()]);
497
+ world.update(1 / 60).unwrap();
498
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
499
+ runtime.commit(runtime.snapshot().at(0)?.sequence ?? -1);
500
+
501
+ runtime.setEmitterCameraVisibility(player, 'sparks', false);
502
+ world.update(1 / 60).unwrap();
503
+ expect(runtime.snapshot()).toHaveLength(0);
504
+
505
+ runtime.setEmitterCameraVisibility(player, 'sparks', true);
506
+ world.update(1 / 60).unwrap();
507
+ expect(runtime.snapshot()[0]).toMatchObject({
508
+ reset,
509
+ firstParticleId,
510
+ phaseTick: reset ? 0 : 1,
511
+ });
512
+ });
513
+
514
+ it('keeps editor session masks independent from camera culling and authored enablement', async () => {
515
+ const world = new World();
516
+ const handle = world.allocSharedRef('ParticleEffectAsset', effect);
517
+ const player = world
518
+ .spawn({
519
+ component: ParticleEffectPlayer,
520
+ data: { effect: handle, playing: true, seed: 4, timeScale: 1 },
521
+ })
522
+ .unwrap();
523
+ await createWorldContext(world, [vfxGpuRuntimePlugin()]);
524
+ world.update(1 / 60).unwrap();
525
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
526
+ runtime.commit(runtime.snapshot().at(0)?.sequence ?? -1);
527
+
528
+ runtime.setEmitterSessionEnabled(player, 'sparks', false);
529
+ world.update(1 / 60).unwrap();
530
+ expect(runtime.snapshot()).toHaveLength(0);
531
+ expect(runtime.inspectPlayer(player)?.emitters[0]).toMatchObject({
532
+ cameraVisible: true,
533
+ sessionEnabled: false,
534
+ phaseTick: 0,
535
+ });
536
+
537
+ runtime.setEmitterCameraVisibility(player, 'sparks', false);
538
+ runtime.setEmitterSessionEnabled(player, 'sparks', true);
539
+ expect(runtime.isEmitterSessionEnabled(player, 'sparks')).toBe(true);
540
+ expect(runtime.inspectPlayer(player)?.emitters[0]).toMatchObject({
541
+ cameraVisible: false,
542
+ sessionEnabled: true,
543
+ });
544
+
545
+ runtime.setEmitterCameraVisibility(player, 'sparks', true);
546
+ world.update(1 / 60).unwrap();
547
+ expect(runtime.snapshot()[0]).toMatchObject({ reset: false, phaseTick: 1 });
548
+ });
549
+ });
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createVfxInspectSnapshot } from '../gpu-runtime.js';
3
+
4
+ describe('public VFX inspect snapshot', () => {
5
+ it('projects every Batch B observation surface without particle readback', () => {
6
+ const snapshot = createVfxInspectSnapshot({
7
+ layoutFingerprint: 'sha256:layout',
8
+ parameterGeneration: 4,
9
+ patchCount: 2,
10
+ dataInterfaces: { readiness: 'ready', generation: 4, requirements: ['vfx:camera'] },
11
+ channels: { impact: { capacity: 8, dropped: 1 } },
12
+ stages: { readiness: 'ready', dependencies: ['update'] },
13
+ renderers: { topology: 'ribbon', produced: 4, dropped: 0 },
14
+ hmr: { candidateGeneration: 5, lastKnownGoodGeneration: 4, state: 'last-known-good' },
15
+ gpuTiming: { frameMs: 1.25, samples: 4 },
16
+ });
17
+
18
+ expect(snapshot).toMatchObject({
19
+ layout: { fingerprint: 'sha256:layout' },
20
+ values: { generation: 4, patchCount: 2 },
21
+ dataInterfaces: { readiness: 'ready' },
22
+ channels: { impact: { capacity: 8, dropped: 1 } },
23
+ stages: { readiness: 'ready' },
24
+ renderers: { topology: 'ribbon' },
25
+ hmr: { lastKnownGoodGeneration: 4 },
26
+ gpuTiming: { frameMs: 1.25 },
27
+ });
28
+ expect(JSON.stringify(snapshot)).not.toContain('particles');
29
+ });
30
+
31
+ it('preserves owner-local structured recovery data', () => {
32
+ const snapshot = createVfxInspectSnapshot({
33
+ layoutFingerprint: 'sha256:layout',
34
+ parameterGeneration: 4,
35
+ patchCount: 2,
36
+ error: {
37
+ code: 'vfx-data-interface-missing',
38
+ expected: 'vfx:scene-depth',
39
+ hint: 'register the scene depth provider',
40
+ detail: { token: 'vfx:scene-depth' },
41
+ },
42
+ });
43
+
44
+ expect(snapshot.error).toMatchObject({
45
+ code: 'vfx-data-interface-missing',
46
+ expected: 'vfx:scene-depth',
47
+ hint: 'register the scene depth provider',
48
+ detail: { token: 'vfx:scene-depth' },
49
+ });
50
+ });
51
+ });
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createVfxEffectContract } from '../effect-contract.js';
3
+ import { ParticleEffectInstance } from '../instance.js';
4
+
5
+ const reflection = {
6
+ version: 1,
7
+ parameters: {
8
+ name: 'VfxParameters',
9
+ fields: [{ name: 'intensity', type: 'f32', offset: 0, size: 4, alignment: 4 }],
10
+ size: 16,
11
+ alignment: 16,
12
+ },
13
+ custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
14
+ fingerprint: 'sha256:public-runtime',
15
+ } as const;
16
+
17
+ describe('public VFX instance API', () => {
18
+ it('keeps legal public patches typed and reports illegal runtime payloads', () => {
19
+ const contract = createVfxEffectContract<{ readonly intensity: number }>(reflection);
20
+ const instance = new ParticleEffectInstance(contract, { initialValues: { intensity: 1 } });
21
+
22
+ expect(instance.patch({ intensity: 2 }).ok).toBe(true);
23
+ expect(instance.patch(JSON.parse('{"intensity":"bright"}')).ok).toBe(false);
24
+ expect(instance.values).toEqual({ intensity: 1 });
25
+ expect(instance.pendingPatchCount).toBe(1);
26
+ });
27
+ });