@forgeax/engine-vfx 0.0.0-dev.8d955ade1c79
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/LICENSE +202 -0
- package/README.md +214 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/authoring-descriptor.unit.test.d.ts +2 -0
- package/dist/__tests__/authoring-descriptor.unit.test.d.ts.map +1 -0
- package/dist/__tests__/channel-input.unit.test.d.ts +2 -0
- package/dist/__tests__/channel-input.unit.test.d.ts.map +1 -0
- package/dist/__tests__/channel-inspect.unit.test.d.ts +2 -0
- package/dist/__tests__/channel-inspect.unit.test.d.ts.map +1 -0
- package/dist/__tests__/channel-overflow-policy-owner.test-d.d.ts +2 -0
- package/dist/__tests__/channel-overflow-policy-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/code-source-v2.unit.test.d.ts +2 -0
- package/dist/__tests__/code-source-v2.unit.test.d.ts.map +1 -0
- package/dist/__tests__/data-interface-contract.unit.test.d.ts +2 -0
- package/dist/__tests__/data-interface-contract.unit.test.d.ts.map +1 -0
- package/dist/__tests__/effect-contract.unit.test.d.ts +2 -0
- package/dist/__tests__/effect-contract.unit.test.d.ts.map +1 -0
- package/dist/__tests__/gpu-program-simulation-culling-owner.test-d.d.ts +2 -0
- package/dist/__tests__/gpu-program-simulation-culling-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/gpu-reflection-vocabulary-owner.test-d.d.ts +2 -0
- package/dist/__tests__/gpu-reflection-vocabulary-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/gpu-runtime.integration.test.d.ts +2 -0
- package/dist/__tests__/gpu-runtime.integration.test.d.ts.map +1 -0
- package/dist/__tests__/inspect-snapshot.unit.test.d.ts +2 -0
- package/dist/__tests__/inspect-snapshot.unit.test.d.ts.map +1 -0
- package/dist/__tests__/instance-public-api.unit.test.d.ts +2 -0
- package/dist/__tests__/instance-public-api.unit.test.d.ts.map +1 -0
- package/dist/__tests__/instance.unit.test.d.ts +2 -0
- package/dist/__tests__/instance.unit.test.d.ts.map +1 -0
- package/dist/__tests__/loader-v1-rejection.unit.test.d.ts +2 -0
- package/dist/__tests__/loader-v1-rejection.unit.test.d.ts.map +1 -0
- package/dist/__tests__/player-reflection.unit.test.d.ts +2 -0
- package/dist/__tests__/player-reflection.unit.test.d.ts.map +1 -0
- package/dist/__tests__/player-type.test-d.d.ts +2 -0
- package/dist/__tests__/player-type.test-d.d.ts.map +1 -0
- package/dist/__tests__/renderer-source.unit.test.d.ts +2 -0
- package/dist/__tests__/renderer-source.unit.test.d.ts.map +1 -0
- package/dist/__tests__/replay-canonical.unit.test.d.ts +2 -0
- package/dist/__tests__/replay-canonical.unit.test.d.ts.map +1 -0
- package/dist/__tests__/runtime-dist-isolation.test.d.ts +2 -0
- package/dist/__tests__/runtime-dist-isolation.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts.map +1 -0
- package/dist/__tests__/stage-source.unit.test.d.ts +2 -0
- package/dist/__tests__/stage-source.unit.test.d.ts.map +1 -0
- package/dist/__tests__/vfx-data-interface-vocabulary-owner.test-d.d.ts +2 -0
- package/dist/__tests__/vfx-data-interface-vocabulary-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/vfx-performance-contract.unit.test.d.ts +2 -0
- package/dist/__tests__/vfx-performance-contract.unit.test.d.ts.map +1 -0
- package/dist/__tests__/vfx-value-type-owner.test-d.d.ts +2 -0
- package/dist/__tests__/vfx-value-type-owner.test-d.d.ts.map +1 -0
- package/dist/assets/particle-effect-decoder.d.ts +3 -0
- package/dist/assets/particle-effect-decoder.d.ts.map +1 -0
- package/dist/authoring-descriptor.d.ts +60 -0
- package/dist/authoring-descriptor.d.ts.map +1 -0
- package/dist/code-source.d.ts +140 -0
- package/dist/code-source.d.ts.map +1 -0
- package/dist/data-interface.d.ts +53 -0
- package/dist/data-interface.d.ts.map +1 -0
- package/dist/effect-contract.d.ts +57 -0
- package/dist/effect-contract.d.ts.map +1 -0
- package/dist/gpu-loader.d.ts +33 -0
- package/dist/gpu-loader.d.ts.map +1 -0
- package/dist/gpu-program.d.ts +80 -0
- package/dist/gpu-program.d.ts.map +1 -0
- package/dist/gpu-runtime.d.ts +174 -0
- package/dist/gpu-runtime.d.ts.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1995 -0
- package/dist/index.mjs.map +1 -0
- package/dist/instance.d.ts +86 -0
- package/dist/instance.d.ts.map +1 -0
- package/dist/player.d.ts +17 -0
- package/dist/player.d.ts.map +1 -0
- package/package.json +66 -0
- package/src/__tests__/authoring-descriptor.unit.test.ts +138 -0
- package/src/__tests__/channel-input.unit.test.ts +65 -0
- package/src/__tests__/channel-inspect.unit.test.ts +65 -0
- package/src/__tests__/channel-overflow-policy-owner.test-d.ts +55 -0
- package/src/__tests__/code-source-v2.unit.test.ts +121 -0
- package/src/__tests__/data-interface-contract.unit.test.ts +92 -0
- package/src/__tests__/effect-contract.unit.test.ts +62 -0
- package/src/__tests__/gpu-program-simulation-culling-owner.test-d.ts +58 -0
- package/src/__tests__/gpu-reflection-vocabulary-owner.test-d.ts +23 -0
- package/src/__tests__/gpu-runtime.integration.test.ts +549 -0
- package/src/__tests__/inspect-snapshot.unit.test.ts +51 -0
- package/src/__tests__/instance-public-api.unit.test.ts +27 -0
- package/src/__tests__/instance.unit.test.ts +107 -0
- package/src/__tests__/loader-v1-rejection.unit.test.ts +105 -0
- package/src/__tests__/player-reflection.unit.test.ts +91 -0
- package/src/__tests__/player-type.test-d.ts +54 -0
- package/src/__tests__/renderer-source.unit.test.ts +58 -0
- package/src/__tests__/replay-canonical.unit.test.ts +68 -0
- package/src/__tests__/runtime-dist-isolation.test.ts +100 -0
- package/src/__tests__/scriptable-pack-consumer.test-d.ts +9 -0
- package/src/__tests__/stage-source.unit.test.ts +64 -0
- package/src/__tests__/vfx-data-interface-vocabulary-owner.test-d.ts +34 -0
- package/src/__tests__/vfx-performance-contract.unit.test.ts +19 -0
- package/src/__tests__/vfx-value-type-owner.test-d.ts +32 -0
- package/src/assets/particle-effect-decoder.ts +34 -0
- package/src/authoring-descriptor.ts +355 -0
- package/src/code-source.ts +827 -0
- package/src/data-interface.ts +181 -0
- package/src/effect-contract.ts +220 -0
- package/src/gpu-loader.ts +291 -0
- package/src/gpu-program.ts +101 -0
- package/src/gpu-runtime.ts +807 -0
- package/src/index.ts +110 -0
- package/src/instance.ts +379 -0
- package/src/player.ts +21 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
3
|
+
import type { ParticleEmitterSourceV2 } from '../code-source.js';
|
|
4
|
+
import type { VfxGpuEmitterProgram } from '../gpu-program.js';
|
|
5
|
+
import type {
|
|
6
|
+
VfxGpuEmitterProgram as PublicVfxGpuEmitterProgram,
|
|
7
|
+
VfxGpuEffectAsset,
|
|
8
|
+
VfxGpuProgram,
|
|
9
|
+
} from '../index.js';
|
|
10
|
+
|
|
11
|
+
type SourceSimulationWhenCulled = ParticleEmitterSourceV2['simulationWhenCulled'];
|
|
12
|
+
type CookedSimulationWhenCulled = VfxGpuEmitterProgram['simulationWhenCulled'];
|
|
13
|
+
type RequiredSimulationWhenCulled = Pick<VfxGpuEmitterProgram, 'simulationWhenCulled'>;
|
|
14
|
+
type PublicProgramEmitter = VfxGpuProgram['emitters'][number];
|
|
15
|
+
type PublicAssetEmitter = VfxGpuEffectAsset['program']['emitters'][number];
|
|
16
|
+
|
|
17
|
+
const gpuProgramSource = readFileSync(new URL('../gpu-program.ts', import.meta.url), 'utf8');
|
|
18
|
+
|
|
19
|
+
describe('VFX GPU program simulation culling owner', () => {
|
|
20
|
+
it('derives the cooked policy from the authored source while keeping it required', () => {
|
|
21
|
+
expectTypeOf<CookedSimulationWhenCulled>().toEqualTypeOf<
|
|
22
|
+
NonNullable<ParticleEmitterSourceV2['simulationWhenCulled']>
|
|
23
|
+
>();
|
|
24
|
+
expectTypeOf<SourceSimulationWhenCulled>().toEqualTypeOf<
|
|
25
|
+
NonNullable<ParticleEmitterSourceV2['simulationWhenCulled']> | undefined
|
|
26
|
+
>();
|
|
27
|
+
expectTypeOf<RequiredSimulationWhenCulled>().toEqualTypeOf<{
|
|
28
|
+
readonly simulationWhenCulled: NonNullable<ParticleEmitterSourceV2['simulationWhenCulled']>;
|
|
29
|
+
}>();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('keeps exact policy membership across public projections', () => {
|
|
33
|
+
expectTypeOf<CookedSimulationWhenCulled>().toEqualTypeOf<
|
|
34
|
+
'continue' | 'pause' | 'restart-on-visible'
|
|
35
|
+
>();
|
|
36
|
+
expectTypeOf<PublicProgramEmitter>().toEqualTypeOf<VfxGpuEmitterProgram>();
|
|
37
|
+
expectTypeOf<PublicAssetEmitter>().toEqualTypeOf<VfxGpuEmitterProgram>();
|
|
38
|
+
expectTypeOf<PublicVfxGpuEmitterProgram>().toEqualTypeOf<VfxGpuEmitterProgram>();
|
|
39
|
+
|
|
40
|
+
const acceptsSimulationWhenCulled = (
|
|
41
|
+
value: CookedSimulationWhenCulled,
|
|
42
|
+
): CookedSimulationWhenCulled => value;
|
|
43
|
+
expect(acceptsSimulationWhenCulled('continue')).toBe('continue');
|
|
44
|
+
expect(acceptsSimulationWhenCulled('pause')).toBe('pause');
|
|
45
|
+
expect(acceptsSimulationWhenCulled('restart-on-visible')).toBe('restart-on-visible');
|
|
46
|
+
// @ts-expect-error VfxGpuEmitterProgram excludes additional culling policies.
|
|
47
|
+
acceptsSimulationWhenCulled('reset');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('keeps the source-derived owner explicit in the declaration', () => {
|
|
51
|
+
expect(gpuProgramSource).toContain(
|
|
52
|
+
"readonly simulationWhenCulled: NonNullable<ParticleEmitterSourceV2['simulationWhenCulled']>;",
|
|
53
|
+
);
|
|
54
|
+
expect(gpuProgramSource).not.toContain(
|
|
55
|
+
"readonly simulationWhenCulled: 'continue' | 'pause' | 'restart-on-visible';",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -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
|
+
});
|