@forgeax/engine-vfx 0.1.27 → 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
@@ -10,16 +10,26 @@ import {
10
10
  vfxGpuRuntimePlugin,
11
11
  } from '../gpu-runtime.js';
12
12
  import { ParticleEffectInstance } from '../instance.js';
13
+ import { VFX_PARTICLE_CORE_LAYOUT } from '../particle-layout.js';
13
14
  import { ParticleEffectPlayer } from '../player.js';
14
15
 
16
+ function commitSnapshot(runtime: VfxGpuRuntime, count = Number.POSITIVE_INFINITY): void {
17
+ runtime.commit(
18
+ runtime
19
+ .snapshot()
20
+ .slice(0, count)
21
+ .map((intent) => intent.sequence),
22
+ );
23
+ }
24
+
15
25
  const effect: VfxGpuEffectAsset = {
16
26
  guid: 'effect-guid',
17
27
  kind: 'particle-effect',
18
- schemaVersion: 2,
28
+ schemaVersion: 3,
19
29
  programFingerprint: 'sha256:test',
20
30
  emitters: [{ id: 'sparks', capacity: 100_000 }],
21
31
  program: {
22
- format: 'forgeax-vfx-program-2',
32
+ format: 'forgeax-vfx-program-3',
23
33
  fingerprint: 'sha256:test',
24
34
  emitters: [
25
35
  {
@@ -39,6 +49,40 @@ const effect: VfxGpuEffectAsset = {
39
49
  resources: [],
40
50
  entryPoints: [],
41
51
  bindings: [],
52
+ layout: {
53
+ version: 3,
54
+ parameters: { name: 'VfxParameters', fields: [], size: 0, alignment: 1 },
55
+ custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
56
+ core: VFX_PARTICLE_CORE_LAYOUT,
57
+ customLayout: {
58
+ name: 'VfxCustom',
59
+ fields: [],
60
+ size: 0,
61
+ alignment: 1,
62
+ stride: 0,
63
+ lanes: 0,
64
+ },
65
+ fingerprint: 'sha256:test-layout',
66
+ },
67
+ dataInterfaces: [],
68
+ eventChannels: [],
69
+ events: [],
70
+ eventEntryPoint: 'forgeax_vfx_event_main',
71
+ stages: [],
72
+ renderers: [
73
+ {
74
+ topology: 'billboard',
75
+ resource: 'billboardInstances',
76
+ capacity: 100_000,
77
+ overflow: 'drop-newest',
78
+ enabled: true,
79
+ shaderInputs: [],
80
+ attributes: {},
81
+ materialInputs: [],
82
+ castShadows: false,
83
+ receiveShadows: false,
84
+ },
85
+ ],
42
86
  },
43
87
  },
44
88
  ],
@@ -52,6 +96,148 @@ describe('GPU VFX fixed-tick intents', () => {
52
96
  expect(asset.program.emitters).toHaveLength(1);
53
97
  });
54
98
 
99
+ it('keeps skipped queue acknowledgements out of the retained GPU state', async () => {
100
+ const world = new World();
101
+ const handle = world.allocSharedRef('ParticleEffectAsset', effect);
102
+ const player = world
103
+ .spawn({
104
+ component: ParticleEffectPlayer,
105
+ data: { effect: handle, playing: true, seed: 9, timeScale: 1 },
106
+ })
107
+ .unwrap();
108
+ await createWorldContext(world, [vfxGpuRuntimePlugin()]);
109
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
110
+
111
+ world.update(1 / 60).unwrap();
112
+ const first = runtime.snapshot()[0];
113
+ expect(first).toBeDefined();
114
+ if (first === undefined) return;
115
+ runtime.commit(first.sequence);
116
+ expect(runtime.lastCommittedEmitter(player, 'sparks')?.sequence).toBe(first.sequence);
117
+
118
+ world.update(1 / 60).unwrap();
119
+ const skipped = runtime.snapshot()[0];
120
+ expect(skipped).toBeDefined();
121
+ if (skipped === undefined) return;
122
+ // Queue acknowledgement alone must not publish a state whose GPU
123
+ // simulation/history was never submitted.
124
+ runtime.commit(skipped.sequence, []);
125
+ expect(runtime.lastCommittedEmitter(player, 'sparks')?.sequence).toBe(first.sequence);
126
+
127
+ world.update(1 / 60).unwrap();
128
+ const dispatched = runtime.snapshot()[0];
129
+ expect(dispatched).toBeDefined();
130
+ if (dispatched === undefined) return;
131
+ runtime.commit(dispatched.sequence, [dispatched.sequence]);
132
+ expect(runtime.lastCommittedEmitter(player, 'sparks')?.sequence).toBe(dispatched.sequence);
133
+ });
134
+
135
+ it('defers a seed reset until a session-disabled emitter can submit', async () => {
136
+ const world = new World();
137
+ const handle = world.allocSharedRef('ParticleEffectAsset', effect);
138
+ const player = world
139
+ .spawn({
140
+ component: ParticleEffectPlayer,
141
+ data: { effect: handle, playing: true, seed: 1, timeScale: 1 },
142
+ })
143
+ .unwrap();
144
+ await createWorldContext(world, [vfxGpuRuntimePlugin()]);
145
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
146
+
147
+ world.update(1 / 30).unwrap();
148
+ const firstIntents = [...runtime.snapshot()];
149
+ const first = firstIntents.find((intent) => intent.reset);
150
+ expect(first).toMatchObject({ reset: true, seed: 1, phaseTick: 0 });
151
+ if (first === undefined) return;
152
+ const firstTerminal = firstIntents.at(-1);
153
+ if (firstTerminal === undefined) return;
154
+ runtime.commit(
155
+ firstTerminal.sequence,
156
+ firstIntents.map((intent) => intent.sequence),
157
+ );
158
+ expect(runtime.lastCommittedEmitter(player, 'sparks')).toMatchObject({ seed: 1 });
159
+
160
+ runtime.setEmitterSessionEnabled(player, 'sparks', false);
161
+ world.set(player, ParticleEffectPlayer, { seed: 2 }).unwrap();
162
+ world.update(0).unwrap();
163
+ world.update(1 / 30).unwrap();
164
+ world.update(1 / 30).unwrap();
165
+ expect(runtime.snapshot()).toHaveLength(0);
166
+ expect(runtime.inspectPlayer(player)?.lastCommitted?.sequence).toBe(firstTerminal.sequence);
167
+ expect(runtime.lastCommittedEmitter(player, 'sparks')?.seed).toBe(1);
168
+
169
+ runtime.setEmitterSessionEnabled(player, 'sparks', true);
170
+ world.update(1 / 30).unwrap();
171
+ const resumed = runtime.snapshot().find((intent) => intent.reset);
172
+ expect(resumed).toMatchObject({ reset: true, seed: 2, phaseTick: 0 });
173
+ if (resumed === undefined) return;
174
+ const resumedIntents = [...runtime.snapshot()];
175
+ const resumedTerminal = resumedIntents.at(-1);
176
+ if (resumedTerminal === undefined) return;
177
+ runtime.commit(
178
+ resumedTerminal.sequence,
179
+ resumedIntents.map((intent) => intent.sequence),
180
+ );
181
+ expect(runtime.lastCommittedEmitter(player, 'sparks')).toMatchObject({ seed: 2 });
182
+ });
183
+
184
+ it('keeps a disabled emitter reset pending when a sibling emitter stays active', async () => {
185
+ const world = new World();
186
+ const multiEmitterEffect = structuredClone(effect) as VfxGpuEffectAsset;
187
+ const primary = multiEmitterEffect.program.emitters[0];
188
+ if (primary === undefined) return;
189
+ const emitters = multiEmitterEffect.program
190
+ .emitters as unknown as VfxGpuEffectAsset['program']['emitters'][number][];
191
+ emitters.splice(0, 1, primary, {
192
+ ...primary,
193
+ id: 'healthy',
194
+ module: 'healthy.vfx.wgsl',
195
+ });
196
+ const handle = world.allocSharedRef('ParticleEffectAsset', multiEmitterEffect);
197
+ const player = world
198
+ .spawn({
199
+ component: ParticleEffectPlayer,
200
+ data: { effect: handle, playing: true, seed: 1, timeScale: 1 },
201
+ })
202
+ .unwrap();
203
+ await createWorldContext(world, [vfxGpuRuntimePlugin()]);
204
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
205
+
206
+ world.update(1 / 30).unwrap();
207
+ const firstIntents = [...runtime.snapshot()];
208
+ const firstTerminal = firstIntents.at(-1);
209
+ if (firstTerminal === undefined) return;
210
+ runtime.commit(
211
+ firstTerminal.sequence,
212
+ firstIntents.map((intent) => intent.sequence),
213
+ );
214
+ expect(runtime.lastCommittedEmitter(player, 'sparks')?.seed).toBe(1);
215
+ expect(runtime.lastCommittedEmitter(player, 'healthy')?.seed).toBe(1);
216
+
217
+ runtime.setEmitterSessionEnabled(player, 'sparks', false);
218
+ world.set(player, ParticleEffectPlayer, { seed: 2 }).unwrap();
219
+ world.update(0).unwrap();
220
+ world.update(1 / 30).unwrap();
221
+ const healthyDuringPause = runtime
222
+ .snapshot()
223
+ .filter((intent) => intent.emitter.id === 'healthy');
224
+ expect(healthyDuringPause.some((intent) => intent.reset && intent.seed === 2)).toBe(true);
225
+ expect(runtime.snapshot().some((intent) => intent.emitter.id === 'sparks')).toBe(false);
226
+ const healthyTerminal = runtime.snapshot().at(-1);
227
+ if (healthyTerminal === undefined) return;
228
+ runtime.commit(
229
+ healthyTerminal.sequence,
230
+ runtime.snapshot().map((intent) => intent.sequence),
231
+ );
232
+
233
+ runtime.setEmitterSessionEnabled(player, 'sparks', true);
234
+ world.update(1 / 30).unwrap();
235
+ const resumed = runtime
236
+ .snapshot()
237
+ .find((intent) => intent.emitter.id === 'sparks' && intent.reset);
238
+ expect(resumed).toMatchObject({ seed: 2, reset: true, phaseTick: 0, spawnCount: 8 });
239
+ });
240
+
55
241
  it('keeps stage recovery scoped to the cooked generation', () => {
56
242
  const source = readFileSync(new URL('../gpu-runtime.ts', import.meta.url), 'utf8');
57
243
  expect(source).toContain('instanceGeneration');
@@ -118,9 +304,11 @@ describe('GPU VFX fixed-tick intents', () => {
118
304
  })
119
305
  .unwrap();
120
306
  const contract = createVfxEffectContract({
121
- version: 1,
307
+ version: 3,
122
308
  parameters: { name: 'VfxParameters', fields: [], size: 0, alignment: 1 },
123
309
  custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
310
+ core: VFX_PARTICLE_CORE_LAYOUT,
311
+ customLayout: { name: 'VfxCustom', fields: [], size: 0, alignment: 1, stride: 0, lanes: 0 },
124
312
  fingerprint: 'sha256:atomic-patch',
125
313
  });
126
314
  const instance = new ParticleEffectInstance(contract);
@@ -192,7 +380,7 @@ describe('GPU VFX fixed-tick intents', () => {
192
380
  const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
193
381
  expect(runtime.snapshot()).toHaveLength(1);
194
382
  expect(runtime.snapshot()[0]).toMatchObject({ reset: true, spawnCount: 8, firstParticleId: 0 });
195
- runtime.commit(runtime.snapshot().at(0)?.sequence ?? -1);
383
+ commitSnapshot(runtime, 1);
196
384
 
197
385
  expect(world.update(1 / 60).ok).toBe(true);
198
386
  expect(runtime.snapshot()).toHaveLength(1);
@@ -203,6 +391,51 @@ describe('GPU VFX fixed-tick intents', () => {
203
391
  });
204
392
  });
205
393
 
394
+ it('replays an emitter time-zero schedule when a session mask enables it later', async () => {
395
+ const delayedEffect = structuredClone(effect) as VfxGpuEffectAsset;
396
+ const firstEmitter = delayedEffect.program.emitters[0];
397
+ expect(firstEmitter).toBeDefined();
398
+ if (firstEmitter === undefined) return;
399
+ const delayedEmitter = {
400
+ ...firstEmitter,
401
+ id: 'delayed',
402
+ capacity: 16,
403
+ schedule: { rate: 0, bursts: [{ time: 0, count: 3 }] },
404
+ };
405
+ (delayedEffect.program.emitters as VfxGpuEffectAsset['program']['emitters'][number][]).push(
406
+ delayedEmitter,
407
+ );
408
+ (delayedEffect.emitters as { id: string; capacity: number }[]).push({
409
+ id: 'delayed',
410
+ capacity: delayedEmitter.capacity,
411
+ });
412
+
413
+ const world = new World();
414
+ const handle = world.allocSharedRef('ParticleEffectAsset', delayedEffect);
415
+ const player = world
416
+ .spawn({
417
+ component: ParticleEffectPlayer,
418
+ data: { effect: handle, playing: true, seed: 9, timeScale: 1 },
419
+ })
420
+ .unwrap();
421
+ await createWorldContext(world, [vfxGpuRuntimePlugin()]);
422
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
423
+ runtime.setEmitterSessionEnabled(player, 'delayed', false);
424
+
425
+ world.update(1 / 60).unwrap();
426
+ expect(runtime.snapshot().some((intent) => intent.emitter.id === 'delayed')).toBe(false);
427
+ commitSnapshot(runtime);
428
+
429
+ runtime.setEmitterSessionEnabled(player, 'delayed', true);
430
+ world.update(1 / 60).unwrap();
431
+ expect(runtime.snapshot().find((intent) => intent.emitter.id === 'delayed')).toMatchObject({
432
+ reset: true,
433
+ phaseTick: 0,
434
+ spawnCount: 3,
435
+ firstParticleId: 0,
436
+ });
437
+ });
438
+
206
439
  it('replays an authored stopped player without mutating author intent', async () => {
207
440
  const world = new World();
208
441
  const handle = world.allocSharedRef('ParticleEffectAsset', effect);
@@ -229,7 +462,7 @@ describe('GPU VFX fixed-tick intents', () => {
229
462
  });
230
463
  expect(world.get(player, ParticleEffectPlayer).unwrap().playing).toBe(false);
231
464
 
232
- runtime.commit(runtime.snapshot().at(0)?.sequence ?? -1);
465
+ commitSnapshot(runtime, 1);
233
466
  world.update(1 / 60).unwrap();
234
467
 
235
468
  expect(runtime.snapshot()).toHaveLength(0);
@@ -251,12 +484,12 @@ describe('GPU VFX fixed-tick intents', () => {
251
484
  const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
252
485
  expect(runtime.snapshot()).toHaveLength(1);
253
486
  expect(runtime.diagnostics()).toHaveLength(0);
254
- runtime.commit(runtime.snapshot().at(0)?.sequence ?? -1);
487
+ commitSnapshot(runtime, 1);
255
488
  world.update(1 / 60).unwrap();
256
489
  world.update(1 / 60).unwrap();
257
490
  expect(runtime.snapshot()).toHaveLength(1);
258
491
  expect(runtime.diagnostics().at(-1)?.code).toBe('vfx-intent-queue-overflow');
259
- runtime.commit(runtime.snapshot().at(-1)?.sequence ?? -1);
492
+ commitSnapshot(runtime);
260
493
  expect(runtime.diagnostics()).toEqual([]);
261
494
  });
262
495
 
@@ -273,7 +506,7 @@ describe('GPU VFX fixed-tick intents', () => {
273
506
  const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
274
507
 
275
508
  world.update(1 / 60).unwrap();
276
- runtime.commit(runtime.snapshot()[0]?.sequence ?? -1);
509
+ commitSnapshot(runtime, 1);
277
510
  world.update(1 / 60).unwrap();
278
511
  world.update(1 / 60).unwrap();
279
512
  expect(runtime.diagnostics()).toHaveLength(1);
@@ -330,7 +563,7 @@ describe('GPU VFX fixed-tick intents', () => {
330
563
  const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
331
564
 
332
565
  world.update(1 / 60).unwrap();
333
- runtime.commit(runtime.snapshot()[0]?.sequence ?? -1);
566
+ commitSnapshot(runtime, 1);
334
567
  world.update(1 / 60).unwrap();
335
568
  world.update(1 / 60).unwrap();
336
569
  expect(runtime.diagnostics()).toHaveLength(1);
@@ -496,7 +729,7 @@ describe('GPU VFX fixed-tick intents', () => {
496
729
  await createWorldContext(world, [vfxGpuRuntimePlugin()]);
497
730
  world.update(1 / 60).unwrap();
498
731
  const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
499
- runtime.commit(runtime.snapshot().at(0)?.sequence ?? -1);
732
+ commitSnapshot(runtime, 1);
500
733
 
501
734
  runtime.setEmitterCameraVisibility(player, 'sparks', false);
502
735
  world.update(1 / 60).unwrap();
@@ -523,7 +756,7 @@ describe('GPU VFX fixed-tick intents', () => {
523
756
  await createWorldContext(world, [vfxGpuRuntimePlugin()]);
524
757
  world.update(1 / 60).unwrap();
525
758
  const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
526
- runtime.commit(runtime.snapshot().at(0)?.sequence ?? -1);
759
+ commitSnapshot(runtime, 1);
527
760
 
528
761
  runtime.setEmitterSessionEnabled(player, 'sparks', false);
529
762
  world.update(1 / 60).unwrap();
@@ -1,9 +1,10 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { createVfxEffectContract } from '../effect-contract.js';
3
3
  import { ParticleEffectInstance } from '../instance.js';
4
+ import { VFX_PARTICLE_CORE_LAYOUT } from '../particle-layout.js';
4
5
 
5
6
  const reflection = {
6
- version: 1,
7
+ version: 3,
7
8
  parameters: {
8
9
  name: 'VfxParameters',
9
10
  fields: [{ name: 'intensity', type: 'f32', offset: 0, size: 4, alignment: 4 }],
@@ -11,6 +12,8 @@ const reflection = {
11
12
  alignment: 16,
12
13
  },
13
14
  custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
15
+ core: VFX_PARTICLE_CORE_LAYOUT,
16
+ customLayout: { name: 'VfxCustom', fields: [], size: 0, alignment: 1, stride: 0, lanes: 0 },
14
17
  fingerprint: 'sha256:public-runtime',
15
18
  } as const;
16
19
 
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { createVfxEffectContract } from '../effect-contract.js';
3
3
  import { ParticleEffectInstance, type VfxInstanceParent } from '../instance.js';
4
+ import { VFX_PARTICLE_CORE_LAYOUT } from '../particle-layout.js';
4
5
 
5
6
  type Values = {
6
7
  readonly direction: readonly [number, number, number];
@@ -8,7 +9,7 @@ type Values = {
8
9
  };
9
10
 
10
11
  const reflection = {
11
- version: 1,
12
+ version: 3,
12
13
  parameters: {
13
14
  name: 'VfxParameters',
14
15
  fields: [
@@ -19,6 +20,8 @@ const reflection = {
19
20
  alignment: 16,
20
21
  },
21
22
  custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
23
+ core: VFX_PARTICLE_CORE_LAYOUT,
24
+ customLayout: { name: 'VfxCustom', fields: [], size: 0, alignment: 1, stride: 0, lanes: 0 },
22
25
  fingerprint: 'sha256:instance-test',
23
26
  } as const;
24
27
 
@@ -30,16 +30,16 @@ function input(
30
30
  };
31
31
  }
32
32
 
33
- const validPayload = {
33
+ const invalidPayload = {
34
34
  kind: 'particle-effect',
35
35
  schemaVersion: 1,
36
36
  emitters: [{ id: 'spark', capacity: 32 }],
37
37
  };
38
38
 
39
- describe('vfxGpuEffectPackLoader v2 boundary', () => {
40
- it('keeps legacy artifact keys outside the v2 asset-local program contract', async () => {
39
+ describe('vfxGpuEffectPackLoader version boundary', () => {
40
+ it('rejects older payloads before reading an artifact', async () => {
41
41
  const result = await vfxGpuEffectPackLoader.load(
42
- input(validPayload, {
42
+ input(invalidPayload, {
43
43
  'effect/program.json': {
44
44
  descriptor: { path: 'program.json', mediaType: 'application/json' },
45
45
  bytes: new TextEncoder().encode('{}'),
@@ -51,13 +51,13 @@ describe('vfxGpuEffectPackLoader v2 boundary', () => {
51
51
  expect(result.ok).toBe(false);
52
52
  if (!result.ok) {
53
53
  expect(result.error).toMatchObject({
54
- code: 'vfx-asset-v2-invalid',
55
- detail: { path: 'payload' },
54
+ code: 'vfx-asset-version-unsupported',
55
+ detail: { path: 'schemaVersion' },
56
56
  });
57
57
  }
58
58
  });
59
59
 
60
- it('rejects a summary-only v2 payload before reading a program artifact', async () => {
60
+ it('rejects a summary-only older payload before reading a program artifact', async () => {
61
61
  const result = await vfxGpuEffectPackLoader.load(
62
62
  input({
63
63
  kind: 'particle-effect',
@@ -69,12 +69,12 @@ describe('vfxGpuEffectPackLoader v2 boundary', () => {
69
69
  );
70
70
 
71
71
  expect(result.ok).toBe(false);
72
- if (!result.ok) expect(result.error.detail.path).toBe('payload');
72
+ if (!result.ok) expect(result.error.detail.path).toBe('schemaVersion');
73
73
  });
74
74
 
75
75
  it('rejects a v1 package-global artifact shape', async () => {
76
76
  const result = await vfxGpuEffectPackLoader.load(
77
- input(validPayload, {
77
+ input(invalidPayload, {
78
78
  'effect/program.json': {
79
79
  descriptor: { path: 'program.json', mediaType: 'application/json' },
80
80
  bytes: new TextEncoder().encode('{}'),
@@ -85,21 +85,21 @@ describe('vfxGpuEffectPackLoader v2 boundary', () => {
85
85
 
86
86
  expect(result.ok).toBe(false);
87
87
  if (!result.ok) {
88
- expect(result.error.code).toBe('vfx-asset-v2-invalid');
89
- expect(result.error.detail.path).toBe('payload');
88
+ expect(result.error.code).toBe('vfx-asset-version-unsupported');
89
+ expect(result.error.detail.path).toBe('schemaVersion');
90
90
  }
91
91
  });
92
92
 
93
93
  it('rejects raw source fallback when the cooked program is absent', async () => {
94
94
  const result = await vfxGpuEffectPackLoader.load(
95
- input({ ...validPayload, source: { emitters: [] }, sourcePath: 'effect.vfx.json' }),
95
+ input({ ...invalidPayload, source: { emitters: [] }, sourcePath: 'effect.vfx.json' }),
96
96
  context(),
97
97
  );
98
98
 
99
99
  expect(result.ok).toBe(false);
100
100
  if (!result.ok) {
101
- expect(result.error.code).toBe('vfx-asset-v2-invalid');
102
- expect(result.error.detail.path).toBe('payload');
101
+ expect(result.error.code).toBe('vfx-asset-version-unsupported');
102
+ expect(result.error.detail.path).toBe('schemaVersion');
103
103
  }
104
104
  });
105
105
  });
@@ -6,10 +6,10 @@ import { ParticleEffectPlayer, type ParticleEffectPlayerData } from '../index.js
6
6
 
7
7
  const asset: ParticleEffectAsset = {
8
8
  kind: 'particle-effect',
9
- schemaVersion: 2,
9
+ schemaVersion: 3,
10
10
  programFingerprint: 'sha256:test',
11
11
  emitters: [],
12
- program: { format: 'forgeax-vfx-program-2', fingerprint: 'sha256:test', emitters: [] },
12
+ program: { format: 'forgeax-vfx-program-3', fingerprint: 'sha256:test', emitters: [] },
13
13
  };
14
14
 
15
15
  describe('ParticleEffectPlayer ECS contract', () => {
@@ -7,6 +7,7 @@ import {
7
7
  type ParticleEffectPlayer,
8
8
  type VfxEffectReflection,
9
9
  } from '../index.js';
10
+ import { VFX_PARTICLE_CORE_LAYOUT } from '../particle-layout.js';
10
11
 
11
12
  type Values = {
12
13
  readonly direction: readonly [number, number, number];
@@ -14,9 +15,11 @@ type Values = {
14
15
  };
15
16
 
16
17
  const reflection: VfxEffectReflection = {
17
- version: 1,
18
+ version: 3,
18
19
  parameters: { name: 'VfxParameters', fields: [], size: 0, alignment: 1 },
19
20
  custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
21
+ core: VFX_PARTICLE_CORE_LAYOUT,
22
+ customLayout: { name: 'VfxCustom', fields: [], size: 0, alignment: 1, stride: 0, lanes: 0 },
20
23
  fingerprint: 'sha256:player-type',
21
24
  };
22
25
 
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { parseParticleEffectSourceV2 } from '../code-source.js';
2
+ import { parseParticleEffectSourceV3 } from '../code-source-v3.js';
3
3
 
4
4
  const rendererSource = (renderer: unknown) => ({
5
- schemaVersion: 2,
5
+ schemaVersion: 3,
6
6
  emitters: [
7
7
  {
8
8
  id: 'showcase',
@@ -19,19 +19,19 @@ const rendererSource = (renderer: unknown) => ({
19
19
 
20
20
  describe('Batch B renderer source contract', () => {
21
21
  it.each([
22
- ['billboard', { kind: 'billboard', material: 'vfx', sorting: 'emitter' }],
22
+ ['billboard', { kind: 'billboard', material: 'vfx', sorting: 'view-depth' }],
23
23
  ['ribbon', { kind: 'ribbon', material: 'vfx', stripKey: 'alive-index', capacity: 32 }],
24
24
  ['trail', { kind: 'trail', material: 'vfx', historyLength: 8, capacity: 32 }],
25
25
  ['beam', { kind: 'beam', material: 'vfx', endpointField: 'velocity', capacity: 16 }],
26
26
  ])('parses the independent %s topology', (_kind, renderer) => {
27
- const parsed = parseParticleEffectSourceV2(rendererSource(renderer));
27
+ const parsed = parseParticleEffectSourceV3(rendererSource(renderer));
28
28
 
29
29
  expect(parsed.ok).toBe(true);
30
30
  if (parsed.ok) expect(parsed.value.emitters[0]?.renderers[0]).toMatchObject(renderer);
31
31
  });
32
32
 
33
33
  it('rejects zero topology capacity instead of silently disabling output', () => {
34
- const parsed = parseParticleEffectSourceV2(
34
+ const parsed = parseParticleEffectSourceV3(
35
35
  rendererSource({ kind: 'trail', material: 'vfx', historyLength: 8, capacity: 0 }),
36
36
  );
37
37
 
@@ -42,7 +42,7 @@ describe('Batch B renderer source contract', () => {
42
42
  });
43
43
 
44
44
  it('keeps an explicitly disabled renderer distinguishable from an empty list', () => {
45
- const parsed = parseParticleEffectSourceV2(
45
+ const parsed = parseParticleEffectSourceV3(
46
46
  rendererSource({
47
47
  kind: 'ribbon',
48
48
  material: 'vfx',
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { createVfxEffectContract } from '../effect-contract.js';
3
3
  import { ParticleEffectInstance } from '../instance.js';
4
+ import { VFX_PARTICLE_CORE_LAYOUT } from '../particle-layout.js';
4
5
 
5
6
  type Values = {
6
7
  readonly zeta: number;
@@ -8,7 +9,7 @@ type Values = {
8
9
  };
9
10
 
10
11
  const reflection = {
11
- version: 1,
12
+ version: 3,
12
13
  parameters: {
13
14
  name: 'VfxParameters',
14
15
  fields: [
@@ -19,6 +20,8 @@ const reflection = {
19
20
  alignment: 16,
20
21
  },
21
22
  custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
23
+ core: VFX_PARTICLE_CORE_LAYOUT,
24
+ customLayout: { name: 'VfxCustom', fields: [], size: 0, alignment: 1, stride: 0, lanes: 0 },
22
25
  fingerprint: 'sha256:canonical-test',
23
26
  } as const;
24
27
 
@@ -8,14 +8,12 @@ import type {
8
8
 
9
9
  describe('VFX Data Interface vocabulary owner', () => {
10
10
  it('preserves the exact kinds, binding types, and tokens', () => {
11
- expectTypeOf<VfxDataInterfaceKind>().toEqualTypeOf<
12
- 'camera' | 'scene-depth' | 'noise' | 'channel'
13
- >();
11
+ expectTypeOf<VfxDataInterfaceKind>().toEqualTypeOf<'camera' | 'scene-depth' | 'noise'>();
14
12
  expectTypeOf<VfxDataInterfaceBindingType>().toEqualTypeOf<
15
- 'uniform' | 'sampled-depth' | 'sampled-float' | 'storage-read'
13
+ 'uniform' | 'sampled-depth' | 'sampled-float'
16
14
  >();
17
15
  expectTypeOf<VfxDataInterfaceToken>().toEqualTypeOf<
18
- 'vfx:camera' | 'vfx:scene-depth' | 'vfx:noise' | 'vfx:channel'
16
+ 'vfx:camera' | 'vfx:scene-depth' | 'vfx:noise'
19
17
  >();
20
18
  });
21
19
 
@@ -27,8 +25,5 @@ describe('VFX Data Interface vocabulary owner', () => {
27
25
  expectTypeOf<
28
26
  VfxDataInterfaceProvider<'noise'>['bindingType']
29
27
  >().toEqualTypeOf<'sampled-float'>();
30
- expectTypeOf<
31
- VfxDataInterfaceProvider<'channel'>['bindingType']
32
- >().toEqualTypeOf<'storage-read'>();
33
28
  });
34
29
  });
@@ -17,7 +17,8 @@ export const particleEffectContribution: AssetDecoderContribution<
17
17
  const payload = envelope.payload;
18
18
  if (
19
19
  payload.kind === 'particle-effect' &&
20
- payload.schemaVersion === 2 &&
20
+ payload.schemaVersion === 3 &&
21
+ payload.program.format === 'forgeax-vfx-program-3' &&
21
22
  payload.emitters.length === payload.program.emitters.length &&
22
23
  payload.programFingerprint === payload.program.fingerprint
23
24
  ) {
@@ -25,8 +26,8 @@ export const particleEffectContribution: AssetDecoderContribution<
25
26
  }
26
27
  return err({
27
28
  code: 'asset-package-invalid',
28
- expected: 'a schema v2 particle payload matching its cooked program',
29
- hint: 'recook the particle effect atomically with its GPU program',
29
+ expected: 'a schemaVersion 3 particle payload matching its cooked Program v3',
30
+ hint: 'cold-cook the legacy payload with the Program v3 compiler and publish atomically',
30
31
  detail: { guid: envelope.guid, reason: 'particle owner validation failed' },
31
32
  });
32
33
  },