@forgeax/engine-vfx 0.1.28 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -14
- package/dist/assets/particle-effect-decoder.d.ts.map +1 -1
- package/dist/authoring-descriptor.d.ts +4 -4
- package/dist/authoring-descriptor.d.ts.map +1 -1
- package/dist/code-source-v3.d.ts +101 -0
- package/dist/code-source-v3.d.ts.map +1 -0
- package/dist/code-source.d.ts +12 -11
- package/dist/code-source.d.ts.map +1 -1
- package/dist/data-interface.d.ts +17 -1
- package/dist/data-interface.d.ts.map +1 -1
- package/dist/effect-contract.d.ts +11 -1
- package/dist/effect-contract.d.ts.map +1 -1
- package/dist/gpu-loader.d.ts +5 -5
- package/dist/gpu-loader.d.ts.map +1 -1
- package/dist/gpu-program.d.ts +52 -27
- package/dist/gpu-program.d.ts.map +1 -1
- package/dist/gpu-runtime.d.ts +32 -7
- package/dist/gpu-runtime.d.ts.map +1 -1
- package/dist/index.d.ts +10 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +647 -117
- package/dist/index.mjs.map +1 -1
- package/dist/particle-layout.d.ts +62 -0
- package/dist/particle-layout.d.ts.map +1 -0
- package/package.json +5 -5
- package/src/__tests__/authoring-descriptor.unit.test.ts +18 -8
- package/src/__tests__/channel-input.unit.test.ts +4 -1
- package/src/__tests__/channel-overflow-policy-owner.test-d.ts +2 -2
- package/src/__tests__/code-source-v2.unit.test.ts +15 -14
- package/src/__tests__/data-interface-contract.unit.test.ts +41 -2
- package/src/__tests__/effect-contract.unit.test.ts +6 -3
- package/src/__tests__/gpu-program-simulation-culling-owner.test-d.ts +6 -6
- package/src/__tests__/gpu-reflection-vocabulary-owner.test-d.ts +2 -2
- package/src/__tests__/gpu-runtime.integration.test.ts +244 -11
- package/src/__tests__/instance-public-api.unit.test.ts +4 -1
- package/src/__tests__/instance.unit.test.ts +4 -1
- package/src/__tests__/loader-v1-rejection.unit.test.ts +14 -14
- package/src/__tests__/player-reflection.unit.test.ts +2 -2
- package/src/__tests__/player-type.test-d.ts +4 -1
- package/src/__tests__/renderer-source.unit.test.ts +6 -6
- package/src/__tests__/replay-canonical.unit.test.ts +4 -1
- package/src/__tests__/vfx-data-interface-vocabulary-owner.test-d.ts +3 -8
- package/src/assets/particle-effect-decoder.ts +4 -3
- package/src/authoring-descriptor.ts +26 -28
- package/src/code-source-v3.ts +460 -0
- package/src/code-source.ts +23 -27
- package/src/data-interface.ts +66 -1
- package/src/effect-contract.ts +73 -20
- package/src/gpu-loader.ts +61 -35
- package/src/gpu-program.ts +57 -35
- package/src/gpu-runtime.ts +115 -42
- package/src/index.ts +53 -9
- 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:
|
|
28
|
+
schemaVersion: 3,
|
|
19
29
|
programFingerprint: 'sha256:test',
|
|
20
30
|
emitters: [{ id: 'sparks', capacity: 100_000 }],
|
|
21
31
|
program: {
|
|
22
|
-
format: 'forgeax-vfx-program-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
40
|
-
it('
|
|
39
|
+
describe('vfxGpuEffectPackLoader version boundary', () => {
|
|
40
|
+
it('rejects older payloads before reading an artifact', async () => {
|
|
41
41
|
const result = await vfxGpuEffectPackLoader.load(
|
|
42
|
-
input(
|
|
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-
|
|
55
|
-
detail: { path: '
|
|
54
|
+
code: 'vfx-asset-version-unsupported',
|
|
55
|
+
detail: { path: 'schemaVersion' },
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
it('rejects a summary-only
|
|
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('
|
|
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(
|
|
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-
|
|
89
|
-
expect(result.error.detail.path).toBe('
|
|
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({ ...
|
|
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-
|
|
102
|
-
expect(result.error.detail.path).toBe('
|
|
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:
|
|
9
|
+
schemaVersion: 3,
|
|
10
10
|
programFingerprint: 'sha256:test',
|
|
11
11
|
emitters: [],
|
|
12
|
-
program: { format: 'forgeax-vfx-program-
|
|
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:
|
|
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 {
|
|
2
|
+
import { parseParticleEffectSourceV3 } from '../code-source-v3.js';
|
|
3
3
|
|
|
4
4
|
const rendererSource = (renderer: unknown) => ({
|
|
5
|
-
schemaVersion:
|
|
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: '
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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:
|
|
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'
|
|
13
|
+
'uniform' | 'sampled-depth' | 'sampled-float'
|
|
16
14
|
>();
|
|
17
15
|
expectTypeOf<VfxDataInterfaceToken>().toEqualTypeOf<
|
|
18
|
-
'vfx:camera' | 'vfx:scene-depth' | 'vfx:noise'
|
|
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 ===
|
|
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
|
|
29
|
-
hint: '
|
|
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
|
},
|