@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
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forgeax/engine-vfx",
|
|
3
|
+
"version": "0.0.0-dev.8d955ade1c79",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"description": "Runtime-safe code-first GPU VFX source, asset loading, player intent, and fixed-tick GPU work contracts.",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"vfx",
|
|
11
|
+
"gpu-particles",
|
|
12
|
+
"particle-effect-player",
|
|
13
|
+
"wgsl",
|
|
14
|
+
"fixed-update"
|
|
15
|
+
],
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.mjs"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"main": "./dist/index.mjs",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@forgeax/engine-assets-runtime": "0.0.0-dev.8d955ade1c79",
|
|
33
|
+
"@forgeax/engine-ecs": "0.0.0-dev.8d955ade1c79",
|
|
34
|
+
"@forgeax/engine-plugin": "0.0.0-dev.8d955ade1c79",
|
|
35
|
+
"@forgeax/engine-types": "0.0.0-dev.8d955ade1c79"
|
|
36
|
+
},
|
|
37
|
+
"forgeax": {
|
|
38
|
+
"metrics": {
|
|
39
|
+
"bundle-size": {
|
|
40
|
+
"enabled": true,
|
|
41
|
+
"path": "dist/index.mjs",
|
|
42
|
+
"compression": "gzip"
|
|
43
|
+
},
|
|
44
|
+
"fps": {
|
|
45
|
+
"enabled": false,
|
|
46
|
+
"reason": "runtime contract package; no frame loop or canvas"
|
|
47
|
+
},
|
|
48
|
+
"bench": {
|
|
49
|
+
"enabled": false,
|
|
50
|
+
"reason": "GPU performance requires an adapter-qualified renderer benchmark, not the runtime contract package"
|
|
51
|
+
},
|
|
52
|
+
"gate": {
|
|
53
|
+
"enabled": false,
|
|
54
|
+
"reason": "public contract covered by package tests and workspace gates"
|
|
55
|
+
},
|
|
56
|
+
"spike-report": {
|
|
57
|
+
"enabled": false,
|
|
58
|
+
"reason": "not a spike package"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build": "tsup",
|
|
64
|
+
"test": "vitest run"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { describeVfxGpuEffect, isVfxGpuEffectAsset } from '../authoring-descriptor.js';
|
|
3
|
+
import type { VfxGpuEffectAsset } from '../gpu-program.js';
|
|
4
|
+
import { VFX_GPU_PROGRAM_ARTIFACT_KEY, VFX_GPU_PROGRAM_FORMAT } from '../gpu-program.js';
|
|
5
|
+
|
|
6
|
+
const effect: VfxGpuEffectAsset = {
|
|
7
|
+
guid: 'effect-guid',
|
|
8
|
+
kind: 'particle-effect',
|
|
9
|
+
schemaVersion: 2,
|
|
10
|
+
programFingerprint: 'sha256:effect',
|
|
11
|
+
emitters: [{ id: 'sparks', capacity: 128 }],
|
|
12
|
+
program: {
|
|
13
|
+
format: 'forgeax-vfx-program-2',
|
|
14
|
+
fingerprint: 'sha256:effect',
|
|
15
|
+
emitters: [
|
|
16
|
+
{
|
|
17
|
+
id: 'sparks',
|
|
18
|
+
module: 'sparks.vfx.wgsl',
|
|
19
|
+
capacity: 128,
|
|
20
|
+
backend: { required: 'gpu' },
|
|
21
|
+
space: 'world',
|
|
22
|
+
schedule: { rate: 8, bursts: [{ time: 0, count: 4 }], loopDuration: 2 },
|
|
23
|
+
bounds: { kind: 'sphere', center: [0, 1, 0], radius: 4 },
|
|
24
|
+
renderers: [
|
|
25
|
+
{ kind: 'billboard', material: 'material-guid', sorting: 'back-to-front' },
|
|
26
|
+
{ kind: 'beam', material: 'beam-material-guid', endpointField: 'velocity', capacity: 32 },
|
|
27
|
+
],
|
|
28
|
+
channels: [{ id: 'impact', payload: 'impact', capacity: 8, overflow: 'drop-newest' }],
|
|
29
|
+
events: [
|
|
30
|
+
{
|
|
31
|
+
id: 'impact-event',
|
|
32
|
+
channel: 'impact',
|
|
33
|
+
subEmitter: 'sparks',
|
|
34
|
+
fanOut: 1,
|
|
35
|
+
recursionDepth: 1,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
simulationWhenCulled: 'continue',
|
|
39
|
+
wgsl: 'cooked',
|
|
40
|
+
reflection: {
|
|
41
|
+
hooks: ['vfx_spawn', 'vfx_update'],
|
|
42
|
+
imports: ['game::noise'],
|
|
43
|
+
resources: ['particles'],
|
|
44
|
+
entryPoints: ['forgeax_vfx_spawn_main'],
|
|
45
|
+
bindings: [],
|
|
46
|
+
layout: {
|
|
47
|
+
version: 1,
|
|
48
|
+
parameters: {
|
|
49
|
+
name: 'VfxParameters',
|
|
50
|
+
fields: [
|
|
51
|
+
{
|
|
52
|
+
name: 'strength',
|
|
53
|
+
type: 'f32',
|
|
54
|
+
offset: 0,
|
|
55
|
+
size: 4,
|
|
56
|
+
alignment: 4,
|
|
57
|
+
defaultValue: 1,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
size: 16,
|
|
61
|
+
alignment: 16,
|
|
62
|
+
},
|
|
63
|
+
custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
|
|
64
|
+
fingerprint: 'sha256:layout',
|
|
65
|
+
},
|
|
66
|
+
stages: [
|
|
67
|
+
{
|
|
68
|
+
id: 'turbulence',
|
|
69
|
+
entry: 'vfx_turbulence',
|
|
70
|
+
entryPoint: 'forgeax_vfx_stage_turbulence',
|
|
71
|
+
domain: 'particle',
|
|
72
|
+
resources: [{ name: 'particles', access: 'read-write' }],
|
|
73
|
+
dependsOn: [],
|
|
74
|
+
iterationBudget: 2,
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
describe('VFX authoring descriptor', () => {
|
|
84
|
+
it('keeps the complete program format and asset-local artifact key canonical', () => {
|
|
85
|
+
expect(VFX_GPU_PROGRAM_FORMAT).toBe('forgeax-vfx-program-2');
|
|
86
|
+
expect(VFX_GPU_PROGRAM_ARTIFACT_KEY).toBe('particle-effect/program.json');
|
|
87
|
+
expect(effect.program.format).toBe(VFX_GPU_PROGRAM_FORMAT);
|
|
88
|
+
expect(effect.programFingerprint).toMatch(/^sha256:/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('owns the cooked asset type guard at the producer boundary', () => {
|
|
92
|
+
expect(isVfxGpuEffectAsset(effect)).toBe(true);
|
|
93
|
+
expect(
|
|
94
|
+
isVfxGpuEffectAsset({ ...effect, program: { ...effect.program, format: 'legacy' } }),
|
|
95
|
+
).toBe(false);
|
|
96
|
+
expect(isVfxGpuEffectAsset({ kind: 'particle-effect', schemaVersion: 2 })).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
it('projects one producer-owned system tree, timeline, dependencies, and capability truth', () => {
|
|
99
|
+
const descriptor = describeVfxGpuEffect(effect);
|
|
100
|
+
|
|
101
|
+
expect(descriptor).toMatchObject({
|
|
102
|
+
version: 1,
|
|
103
|
+
assetGuid: 'effect-guid',
|
|
104
|
+
artifactFingerprint: 'sha256:effect',
|
|
105
|
+
timeline: [{ emitterId: 'sparks', rate: 8, loopDuration: 2 }],
|
|
106
|
+
});
|
|
107
|
+
expect(descriptor.emitters[0]).toMatchObject({
|
|
108
|
+
id: 'emitter:sparks',
|
|
109
|
+
role: 'emitter',
|
|
110
|
+
label: 'sparks',
|
|
111
|
+
module: 'sparks.vfx.wgsl',
|
|
112
|
+
});
|
|
113
|
+
expect(descriptor.emitters[0]?.children.map((node) => node.role)).toEqual([
|
|
114
|
+
'program',
|
|
115
|
+
'parameters',
|
|
116
|
+
'stage',
|
|
117
|
+
'channel',
|
|
118
|
+
'event',
|
|
119
|
+
'renderer',
|
|
120
|
+
'renderer',
|
|
121
|
+
]);
|
|
122
|
+
expect(descriptor.dependencies).toEqual(
|
|
123
|
+
expect.arrayContaining([
|
|
124
|
+
expect.objectContaining({ kind: 'module', identity: 'sparks.vfx.wgsl' }),
|
|
125
|
+
expect.objectContaining({ kind: 'asset', identity: 'material-guid' }),
|
|
126
|
+
expect.objectContaining({ kind: 'asset', identity: 'beam-material-guid' }),
|
|
127
|
+
expect.objectContaining({ kind: 'module', identity: 'game::noise' }),
|
|
128
|
+
]),
|
|
129
|
+
);
|
|
130
|
+
expect(descriptor.capabilities).toEqual(
|
|
131
|
+
expect.arrayContaining([
|
|
132
|
+
expect.objectContaining({ id: 'runtime-parameters', state: 'partial' }),
|
|
133
|
+
expect.objectContaining({ id: 'deterministic-replay', state: 'executable' }),
|
|
134
|
+
]),
|
|
135
|
+
);
|
|
136
|
+
expect(JSON.stringify(descriptor)).not.toContain('wgsl":"cooked');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createVfxEffectContract } from '../effect-contract.js';
|
|
3
|
+
import { ParticleEffectInstance, type VfxChannelInput } from '../instance.js';
|
|
4
|
+
|
|
5
|
+
const reflection = {
|
|
6
|
+
version: 1,
|
|
7
|
+
parameters: { name: 'VfxParameters', fields: [], size: 0, alignment: 1 },
|
|
8
|
+
custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
|
|
9
|
+
fingerprint: 'sha256:channel-input',
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
function instance() {
|
|
13
|
+
return new ParticleEffectInstance(createVfxEffectContract(reflection));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function input(sequence: number, strength: number): VfxChannelInput {
|
|
17
|
+
return {
|
|
18
|
+
channel: 'impact',
|
|
19
|
+
payload: { position: [sequence, 1, 0], strength },
|
|
20
|
+
sequence,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('VFX channel input', () => {
|
|
25
|
+
it('commits typed inputs once at the fixed-tick boundary in sequence order', () => {
|
|
26
|
+
const effect = instance();
|
|
27
|
+
|
|
28
|
+
expect(effect.submit(input(2, 0.4)).ok).toBe(true);
|
|
29
|
+
expect(effect.submit(input(1, 0.9)).ok).toBe(true);
|
|
30
|
+
const committed = effect.commit({ seed: 3, tick: 8 });
|
|
31
|
+
|
|
32
|
+
expect(committed.ok).toBe(true);
|
|
33
|
+
if (!committed.ok) return;
|
|
34
|
+
expect(committed.value.channelInputs).toEqual([input(1, 0.9), input(2, 0.4)]);
|
|
35
|
+
expect(committed.value.channelInputs[0]?.tick).toBe(8);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('drops new input at capacity without changing another instance', () => {
|
|
39
|
+
const first = instance();
|
|
40
|
+
const second = instance();
|
|
41
|
+
first.setChannelCapacity('impact', 1);
|
|
42
|
+
|
|
43
|
+
expect(first.submit(input(1, 0.5)).ok).toBe(true);
|
|
44
|
+
const overflow = first.submit(input(2, 0.6));
|
|
45
|
+
expect(overflow.ok).toBe(false);
|
|
46
|
+
if (!overflow.ok) expect(overflow.error.code).toBe('vfx-channel-overflow');
|
|
47
|
+
expect(second.submit(input(1, 0.7)).ok).toBe(true);
|
|
48
|
+
const firstCommit = first.commit({ seed: 1, tick: 1 });
|
|
49
|
+
const secondCommit = second.commit({ seed: 2, tick: 1 });
|
|
50
|
+
expect(firstCommit.ok && firstCommit.value).toMatchObject({ droppedCount: 1 });
|
|
51
|
+
expect(secondCommit.ok && secondCommit.value).toMatchObject({ droppedCount: 0 });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('clears pending inputs on despawn/reset and preserves replay ordering', () => {
|
|
55
|
+
const effect = instance();
|
|
56
|
+
effect.submit(input(4, 1));
|
|
57
|
+
const committed = effect.commit({ seed: 4, tick: 3 });
|
|
58
|
+
expect(committed.ok).toBe(true);
|
|
59
|
+
if (!committed.ok) return;
|
|
60
|
+
const replay = instance().replay(committed.value.replayInput);
|
|
61
|
+
expect(replay.ok).toBe(true);
|
|
62
|
+
if (!replay.ok) return;
|
|
63
|
+
expect(replay.value.channelInputs).toEqual(committed.value.channelInputs);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { EntityHandle } from '@forgeax/engine-ecs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { VfxGpuRuntime } from '../gpu-runtime.js';
|
|
4
|
+
|
|
5
|
+
const player = 7 as EntityHandle;
|
|
6
|
+
|
|
7
|
+
describe('VFX channel inspect counters', () => {
|
|
8
|
+
it('keeps bounded event counters observable without GPU readback', () => {
|
|
9
|
+
const runtime = new VfxGpuRuntime();
|
|
10
|
+
runtime.markEventDispatched(player, {
|
|
11
|
+
queued: 4,
|
|
12
|
+
produced: 4,
|
|
13
|
+
consumed: 0,
|
|
14
|
+
dropped: 2,
|
|
15
|
+
overflow: 1,
|
|
16
|
+
fanOut: 2,
|
|
17
|
+
recursionDepth: 1,
|
|
18
|
+
lastSequence: 9,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(runtime.eventCounters(player)).toEqual({
|
|
22
|
+
queued: 0,
|
|
23
|
+
produced: 4,
|
|
24
|
+
consumed: 4,
|
|
25
|
+
dropped: 2,
|
|
26
|
+
overflow: 1,
|
|
27
|
+
fanOut: 2,
|
|
28
|
+
recursionDepth: 1,
|
|
29
|
+
lastSequence: 9,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('merges a consumer dispatch into the producer snapshot', () => {
|
|
34
|
+
const runtime = new VfxGpuRuntime();
|
|
35
|
+
runtime.markEventDispatched(player, {
|
|
36
|
+
queued: 1,
|
|
37
|
+
produced: 1,
|
|
38
|
+
consumed: 0,
|
|
39
|
+
dropped: 0,
|
|
40
|
+
overflow: 0,
|
|
41
|
+
fanOut: 2,
|
|
42
|
+
recursionDepth: 1,
|
|
43
|
+
lastSequence: 3,
|
|
44
|
+
});
|
|
45
|
+
runtime.markEventDispatched(player, {
|
|
46
|
+
queued: 1,
|
|
47
|
+
produced: 0,
|
|
48
|
+
consumed: 2,
|
|
49
|
+
dropped: 0,
|
|
50
|
+
overflow: 0,
|
|
51
|
+
fanOut: 2,
|
|
52
|
+
recursionDepth: 1,
|
|
53
|
+
lastSequence: 3,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(runtime.eventCounters(player)).toMatchObject({
|
|
57
|
+
queued: 0,
|
|
58
|
+
produced: 1,
|
|
59
|
+
consumed: 2,
|
|
60
|
+
fanOut: 2,
|
|
61
|
+
recursionDepth: 1,
|
|
62
|
+
lastSequence: 3,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
3
|
+
import type {
|
|
4
|
+
ParticleChannelOverflowPolicy,
|
|
5
|
+
ParticleChannelSource,
|
|
6
|
+
ParticleRendererOverflowPolicy,
|
|
7
|
+
ParticleRendererSource,
|
|
8
|
+
} from '../code-source.js';
|
|
9
|
+
import type {
|
|
10
|
+
ParticleChannelOverflowPolicy as PublicParticleChannelOverflowPolicy,
|
|
11
|
+
ParticleRendererOverflowPolicy as PublicParticleRendererOverflowPolicy,
|
|
12
|
+
} from '../index.js';
|
|
13
|
+
|
|
14
|
+
type BillboardRenderer = Extract<ParticleRendererSource, { readonly kind: 'billboard' }>;
|
|
15
|
+
type RendererOverflow = BillboardRenderer['overflow'];
|
|
16
|
+
type ChannelOverflow = ParticleChannelSource['overflow'];
|
|
17
|
+
type RendererOverflowSlot = Pick<BillboardRenderer, 'overflow'>;
|
|
18
|
+
type ChannelOverflowSlot = Pick<ParticleChannelSource, 'overflow'>;
|
|
19
|
+
|
|
20
|
+
const codeSource = readFileSync(new URL('../code-source.ts', import.meta.url), 'utf8');
|
|
21
|
+
|
|
22
|
+
describe('VFX channel overflow policy owner', () => {
|
|
23
|
+
it('derives the channel policy from the renderer policy without changing field presence', () => {
|
|
24
|
+
expectTypeOf<ParticleChannelOverflowPolicy>().toEqualTypeOf<ParticleRendererOverflowPolicy>();
|
|
25
|
+
expectTypeOf<ParticleRendererOverflowPolicy>().toEqualTypeOf<ParticleChannelOverflowPolicy>();
|
|
26
|
+
expectTypeOf<PublicParticleChannelOverflowPolicy>().toEqualTypeOf<ParticleChannelOverflowPolicy>();
|
|
27
|
+
expectTypeOf<PublicParticleRendererOverflowPolicy>().toEqualTypeOf<ParticleRendererOverflowPolicy>();
|
|
28
|
+
expectTypeOf<RendererOverflow>().toEqualTypeOf<ParticleRendererOverflowPolicy | undefined>();
|
|
29
|
+
expectTypeOf<ParticleRendererOverflowPolicy | undefined>().toEqualTypeOf<RendererOverflow>();
|
|
30
|
+
expectTypeOf<ChannelOverflow>().toEqualTypeOf<ParticleChannelOverflowPolicy>();
|
|
31
|
+
expectTypeOf<ParticleChannelOverflowPolicy>().toEqualTypeOf<ChannelOverflow>();
|
|
32
|
+
expectTypeOf<RendererOverflowSlot>().toEqualTypeOf<{
|
|
33
|
+
readonly overflow?: ParticleRendererOverflowPolicy;
|
|
34
|
+
}>();
|
|
35
|
+
expectTypeOf<ChannelOverflowSlot>().toEqualTypeOf<{
|
|
36
|
+
readonly overflow: ParticleChannelOverflowPolicy;
|
|
37
|
+
}>();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('keeps exactly the drop-newest and drop-oldest members', () => {
|
|
41
|
+
expectTypeOf<ParticleChannelOverflowPolicy>().toEqualTypeOf<'drop-newest' | 'drop-oldest'>();
|
|
42
|
+
expectTypeOf<'drop-newest'>().toExtend<ParticleChannelOverflowPolicy>();
|
|
43
|
+
expectTypeOf<'drop-oldest'>().toExtend<ParticleChannelOverflowPolicy>();
|
|
44
|
+
expectTypeOf<'drop-other'>().not.toExtend<ParticleChannelOverflowPolicy>();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('keeps the authored renderer policy as the only production owner', () => {
|
|
48
|
+
expect(codeSource).toContain(
|
|
49
|
+
'export type ParticleChannelOverflowPolicy = ParticleRendererOverflowPolicy;',
|
|
50
|
+
);
|
|
51
|
+
expect(codeSource).not.toContain(
|
|
52
|
+
"export type ParticleChannelOverflowPolicy = 'drop-newest' | 'drop-oldest';",
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseParticleEffectSourceV2, parseVfxStageDeclarations } from '../code-source.js';
|
|
3
|
+
|
|
4
|
+
const source = {
|
|
5
|
+
schemaVersion: 2,
|
|
6
|
+
emitters: [
|
|
7
|
+
{
|
|
8
|
+
id: 'sparks',
|
|
9
|
+
capacity: 1024,
|
|
10
|
+
backend: { required: 'gpu' as const },
|
|
11
|
+
space: 'world',
|
|
12
|
+
schedule: { rate: 32, bursts: [{ time: 0, count: 16 }], loopDuration: 2 },
|
|
13
|
+
bounds: { kind: 'aabb', min: [-10, -10, -10], max: [10, 10, 10] },
|
|
14
|
+
program: { module: 'sparks.vfx.wgsl' },
|
|
15
|
+
renderers: [{ kind: 'billboard', material: 'material-guid' }],
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe('ParticleCodeEffectSource v2', () => {
|
|
21
|
+
it('accepts typed bounded channels and same-effect visual events', () => {
|
|
22
|
+
const result = parseParticleEffectSourceV2({
|
|
23
|
+
...source,
|
|
24
|
+
emitters: [
|
|
25
|
+
{
|
|
26
|
+
...source.emitters[0],
|
|
27
|
+
channels: [{ id: 'impact', capacity: 2, overflow: 'drop-newest' }],
|
|
28
|
+
events: [
|
|
29
|
+
{
|
|
30
|
+
id: 'impact-event',
|
|
31
|
+
channel: 'impact',
|
|
32
|
+
subEmitter: 'sparks',
|
|
33
|
+
fanOut: 1,
|
|
34
|
+
recursionDepth: 1,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
expect(result.ok).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('accepts the code-first source without an operator stack', () => {
|
|
44
|
+
const result = parseParticleEffectSourceV2(source);
|
|
45
|
+
expect(result.ok).toBe(true);
|
|
46
|
+
if (result.ok) {
|
|
47
|
+
expect(result.value.emitters[0]?.program.module).toBe('sparks.vfx.wgsl');
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('keeps stage declarations in authored WGSL rather than the source manifest', () => {
|
|
52
|
+
const stage = parseVfxStageDeclarations(
|
|
53
|
+
'// #vfx stage turbulence entry=vfx_turbulence domain=particle resources=particles:read-write dependsOn=update iterationBudget=4',
|
|
54
|
+
);
|
|
55
|
+
expect(stage.ok).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('rejects v1 with an executable recook hint', () => {
|
|
59
|
+
const result = parseParticleEffectSourceV2({ schemaVersion: 1, emitters: [] });
|
|
60
|
+
expect(result.ok).toBe(false);
|
|
61
|
+
if (!result.ok) {
|
|
62
|
+
expect(result.error.code).toBe('vfx-source-version-unsupported');
|
|
63
|
+
expect(result.error.hint).toContain('WGSL');
|
|
64
|
+
expect(result.error.hint).toContain('recook');
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('requires one explicit GPU backend policy and rejects derived fields', () => {
|
|
69
|
+
const backend = structuredClone(source) as unknown as {
|
|
70
|
+
emitters: Array<Record<string, unknown>>;
|
|
71
|
+
};
|
|
72
|
+
const emitter = backend.emitters[0];
|
|
73
|
+
expect(emitter).toBeDefined();
|
|
74
|
+
if (emitter === undefined) return;
|
|
75
|
+
emitter.backend = { required: 'cpu' };
|
|
76
|
+
expect(parseParticleEffectSourceV2(backend).ok).toBe(false);
|
|
77
|
+
const derived = structuredClone(source) as typeof source & { backendPlan: string };
|
|
78
|
+
derived.backendPlan = 'gpu';
|
|
79
|
+
expect(parseParticleEffectSourceV2(derived).ok).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it.each([
|
|
83
|
+
['bounds', { bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1, dynamic: true } }],
|
|
84
|
+
['schedule', { schedule: { rate: 1, legacyRate: 2 } }],
|
|
85
|
+
['burst', { schedule: { rate: 1, bursts: [{ time: 0, count: 1, once: true }] } }],
|
|
86
|
+
['program', { program: { module: 'sparks.vfx.wgsl', fallback: 'cpu' } }],
|
|
87
|
+
['parameters', { parameters: { speed: 1 } }],
|
|
88
|
+
['custom data', { customData: { value: 1 } }],
|
|
89
|
+
])('rejects unknown nested %s fields', (_name, override) => {
|
|
90
|
+
const emitter = { ...source.emitters[0], ...override };
|
|
91
|
+
expect(parseParticleEffectSourceV2({ ...source, emitters: [emitter] }).ok).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('keeps Data Interface requirements out of the source-side roster', () => {
|
|
95
|
+
const emitter = {
|
|
96
|
+
...source.emitters[0],
|
|
97
|
+
dataInterfaces: [{ token: 'vfx:camera' }],
|
|
98
|
+
};
|
|
99
|
+
expect(parseParticleEffectSourceV2({ ...source, emitters: [emitter] }).ok).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('accepts an explicit mesh submesh and rejects ambiguous indices', () => {
|
|
103
|
+
const mesh = structuredClone(source) as unknown as {
|
|
104
|
+
schemaVersion: number;
|
|
105
|
+
emitters: Array<Record<string, unknown>>;
|
|
106
|
+
};
|
|
107
|
+
const emitter = mesh.emitters[0];
|
|
108
|
+
expect(emitter).toBeDefined();
|
|
109
|
+
if (emitter === undefined) return;
|
|
110
|
+
const renderers: Array<Record<string, unknown>> = [
|
|
111
|
+
{ kind: 'mesh', material: 'material-guid', mesh: 'mesh-guid', submesh: 2 },
|
|
112
|
+
];
|
|
113
|
+
emitter.renderers = renderers;
|
|
114
|
+
expect(parseParticleEffectSourceV2(mesh).ok).toBe(true);
|
|
115
|
+
const renderer = renderers[0];
|
|
116
|
+
expect(renderer).toBeDefined();
|
|
117
|
+
if (renderer === undefined) return;
|
|
118
|
+
renderer.submesh = -1;
|
|
119
|
+
expect(parseParticleEffectSourceV2(mesh).ok).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { ok, type Result } from '@forgeax/engine-types';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
resolveVfxDataInterfaces,
|
|
5
|
+
type VfxDataInterfaceError,
|
|
6
|
+
type VfxDataInterfaceProvider,
|
|
7
|
+
type VfxDataInterfaceRequirement,
|
|
8
|
+
type VfxDataInterfaceResource,
|
|
9
|
+
} from '../index.js';
|
|
10
|
+
|
|
11
|
+
const camera: VfxDataInterfaceRequirement = {
|
|
12
|
+
token: 'vfx:camera',
|
|
13
|
+
kind: 'camera',
|
|
14
|
+
binding: 8,
|
|
15
|
+
bindingType: 'uniform',
|
|
16
|
+
lifetime: 'generation',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function provider(
|
|
20
|
+
resource: Omit<VfxDataInterfaceResource, 'generation'> & { readonly generation?: number },
|
|
21
|
+
): VfxDataInterfaceProvider {
|
|
22
|
+
return {
|
|
23
|
+
id: `${resource.token}-provider`,
|
|
24
|
+
token: resource.token,
|
|
25
|
+
kind: resource.kind,
|
|
26
|
+
bindingType: resource.bindingType,
|
|
27
|
+
provide: (generation) =>
|
|
28
|
+
ok({
|
|
29
|
+
...resource,
|
|
30
|
+
generation: resource.generation ?? generation,
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('VFX Data Interface contract', () => {
|
|
36
|
+
it.each([
|
|
37
|
+
['missing', [], 'vfx-data-interface-missing'],
|
|
38
|
+
[
|
|
39
|
+
'wrong type',
|
|
40
|
+
[provider({ token: 'vfx:camera', kind: 'camera', bindingType: 'sampled-depth' })],
|
|
41
|
+
'vfx-data-interface-wrong-type',
|
|
42
|
+
],
|
|
43
|
+
[
|
|
44
|
+
'stale generation',
|
|
45
|
+
[provider({ token: 'vfx:camera', kind: 'camera', bindingType: 'uniform', generation: 2 })],
|
|
46
|
+
'vfx-data-interface-stale',
|
|
47
|
+
],
|
|
48
|
+
])('%s is a structured failure', (_name, providers, code) => {
|
|
49
|
+
const result = resolveVfxDataInterfaces([camera], providers as VfxDataInterfaceProvider[], 3);
|
|
50
|
+
expect(result.ok).toBe(false);
|
|
51
|
+
if (!result.ok) {
|
|
52
|
+
expect(result.error.code).toBe(code);
|
|
53
|
+
if (code === 'vfx-data-interface-stale') {
|
|
54
|
+
expect(result.error.expected).toContain('generation 3');
|
|
55
|
+
} else {
|
|
56
|
+
expect(result.error.expected).toContain('camera');
|
|
57
|
+
}
|
|
58
|
+
expect(result.error.hint.length).toBeGreaterThan(0);
|
|
59
|
+
expect(result.error.detail).toMatchObject({ token: 'vfx:camera' });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns generation-scoped readiness for a valid provider', () => {
|
|
64
|
+
const result = resolveVfxDataInterfaces(
|
|
65
|
+
[camera],
|
|
66
|
+
[provider({ token: 'vfx:camera', kind: 'camera', bindingType: 'uniform' })],
|
|
67
|
+
7,
|
|
68
|
+
);
|
|
69
|
+
expect(result).toMatchObject({ ok: true, value: { generation: 7, readiness: 'ready' } });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('keeps provider failures exhaustive without parsing messages', () => {
|
|
73
|
+
const result: Result<unknown, VfxDataInterfaceError> = resolveVfxDataInterfaces(
|
|
74
|
+
[camera],
|
|
75
|
+
[],
|
|
76
|
+
1,
|
|
77
|
+
);
|
|
78
|
+
if (!result.ok) {
|
|
79
|
+
switch (result.error.code) {
|
|
80
|
+
case 'vfx-data-interface-missing':
|
|
81
|
+
case 'vfx-data-interface-wrong-type':
|
|
82
|
+
case 'vfx-data-interface-stale':
|
|
83
|
+
case 'vfx-data-interface-duplicate':
|
|
84
|
+
break;
|
|
85
|
+
default: {
|
|
86
|
+
const exhaustive: never = result.error.code;
|
|
87
|
+
expect(exhaustive).toBeUndefined();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ParticleEffectAsset } from '@forgeax/engine-types';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { createVfxEffectContract } from '../effect-contract.js';
|
|
4
|
+
|
|
5
|
+
const reflection = {
|
|
6
|
+
version: 1,
|
|
7
|
+
parameters: {
|
|
8
|
+
name: 'VfxParameters',
|
|
9
|
+
fields: [
|
|
10
|
+
{ name: 'direction', type: 'vec3<f32>', offset: 0, size: 12, alignment: 16 },
|
|
11
|
+
{ name: 'speed', type: 'f32', offset: 16, size: 4, alignment: 4 },
|
|
12
|
+
],
|
|
13
|
+
size: 32,
|
|
14
|
+
alignment: 16,
|
|
15
|
+
},
|
|
16
|
+
custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
|
|
17
|
+
fingerprint: 'sha256:0000000000000000000000000000000000000000000000000000000000000000',
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
describe('VfxEffectContract', () => {
|
|
21
|
+
it('models the executable program on the ordinary particle asset', () => {
|
|
22
|
+
const asset: ParticleEffectAsset = {
|
|
23
|
+
kind: 'particle-effect',
|
|
24
|
+
schemaVersion: 2,
|
|
25
|
+
programFingerprint: 'sha256:program',
|
|
26
|
+
emitters: [{ id: 'sparks', capacity: 8 }],
|
|
27
|
+
program: {
|
|
28
|
+
format: 'forgeax-vfx-program-2',
|
|
29
|
+
fingerprint: 'sha256:program',
|
|
30
|
+
emitters: [],
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
expect(asset.program.fingerprint).toBe(asset.programFingerprint);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('derives defaults and packed values from the same reflection result', () => {
|
|
37
|
+
const contract = createVfxEffectContract(reflection);
|
|
38
|
+
const values = contract.createValues({ speed: 2.5, direction: [0, 1, 0] });
|
|
39
|
+
expect(values.ok).toBe(true);
|
|
40
|
+
if (!values.ok) return;
|
|
41
|
+
expect(values.value.speed).toBe(2.5);
|
|
42
|
+
expect(values.value.direction).toEqual([0, 1, 0]);
|
|
43
|
+
expect(contract.pack(values.value).ok).toBe(true);
|
|
44
|
+
expect(contract.fingerprint).toBe(reflection.fingerprint);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects unknown names, wrong dimensions, and wrong scalar kinds without assertions', () => {
|
|
48
|
+
const contract = createVfxEffectContract(reflection);
|
|
49
|
+
|
|
50
|
+
const unknown = contract.createValues({ missing: 1 });
|
|
51
|
+
expect(unknown.ok).toBe(false);
|
|
52
|
+
if (!unknown.ok) expect(unknown.error.code).toBe('vfx-value-unknown-field');
|
|
53
|
+
|
|
54
|
+
const wrongDimension = contract.createValues({ direction: [0, 1] });
|
|
55
|
+
expect(wrongDimension.ok).toBe(false);
|
|
56
|
+
if (!wrongDimension.ok) expect(wrongDimension.error.code).toBe('vfx-value-type-mismatch');
|
|
57
|
+
|
|
58
|
+
const wrongScalar = contract.validateValues(JSON.parse('{"speed":"fast"}'));
|
|
59
|
+
expect(wrongScalar.ok).toBe(false);
|
|
60
|
+
if (!wrongScalar.ok) expect(wrongScalar.error.code).toBe('vfx-value-type-mismatch');
|
|
61
|
+
});
|
|
62
|
+
});
|