@forgeax/engine-vfx-render 0.1.4 → 0.1.7
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 +6 -12
- package/dist/.tsbuildinfo +1 -1
- package/dist/feature/gpu-particle-feature.d.ts +20 -26
- package/dist/feature/gpu-particle-feature.d.ts.map +1 -1
- package/dist/feature/particle-resources.d.ts +0 -3
- package/dist/feature/particle-resources.d.ts.map +1 -1
- package/dist/feature/stage-plan.d.ts +1 -1
- package/dist/feature/stage-plan.d.ts.map +1 -1
- package/dist/host/vfx-runtime-host.d.ts +20 -4
- package/dist/host/vfx-runtime-host.d.ts.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +458 -722
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -8
- package/src/__tests__/billboard-advanced.integration.test.ts +0 -23
- package/src/__tests__/fog-producer.integration.test.ts +4 -3
- package/src/__tests__/gpu-host.integration.test.ts +133 -115
- package/src/__tests__/particle-resources.unit.test.ts +0 -9
- package/src/__tests__/stage-plan.integration.test.ts +132 -0
- package/src/feature/gpu-particle-feature.ts +482 -888
- package/src/feature/particle-resources.ts +0 -8
- package/src/feature/stage-plan.ts +4 -1
- package/src/host/vfx-runtime-host.ts +81 -24
- package/src/index.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgeax/engine-vfx-render",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -22,13 +22,13 @@
|
|
|
22
22
|
"LICENSE"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@forgeax/engine-assets-runtime": "0.1.
|
|
26
|
-
"@forgeax/engine-ecs": "0.1.
|
|
27
|
-
"@forgeax/engine-math": "0.1.
|
|
28
|
-
"@forgeax/engine-render": "0.1.
|
|
29
|
-
"@forgeax/engine-scene": "0.1.
|
|
30
|
-
"@forgeax/engine-types": "0.1.
|
|
31
|
-
"@forgeax/engine-vfx": "0.1.
|
|
25
|
+
"@forgeax/engine-assets-runtime": "0.1.7",
|
|
26
|
+
"@forgeax/engine-ecs": "0.1.7",
|
|
27
|
+
"@forgeax/engine-math": "0.1.7",
|
|
28
|
+
"@forgeax/engine-render": "0.1.7",
|
|
29
|
+
"@forgeax/engine-scene": "0.1.7",
|
|
30
|
+
"@forgeax/engine-types": "0.1.7",
|
|
31
|
+
"@forgeax/engine-vfx": "0.1.7"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {},
|
|
34
34
|
"forgeax": {
|
|
@@ -43,27 +43,4 @@ describe('billboard advanced executable inputs', () => {
|
|
|
43
43
|
error: { code: 'vfx-renderer-depth-missing' },
|
|
44
44
|
});
|
|
45
45
|
});
|
|
46
|
-
|
|
47
|
-
it('changes soft-particle alpha when scene depth changes before Fog mixing', () => {
|
|
48
|
-
const nearDepth = resolveBillboardAdvancedState(renderer, {
|
|
49
|
-
age: 0.1,
|
|
50
|
-
lifetime: 1,
|
|
51
|
-
particleDepth: 0.7,
|
|
52
|
-
sceneDepth: 0.75,
|
|
53
|
-
depthAvailable: true,
|
|
54
|
-
});
|
|
55
|
-
const farDepth = resolveBillboardAdvancedState(renderer, {
|
|
56
|
-
age: 0.1,
|
|
57
|
-
lifetime: 1,
|
|
58
|
-
particleDepth: 0.7,
|
|
59
|
-
sceneDepth: 1.0,
|
|
60
|
-
depthAvailable: true,
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
expect(nearDepth.ok).toBe(true);
|
|
64
|
-
expect(farDepth.ok).toBe(true);
|
|
65
|
-
if (nearDepth.ok && farDepth.ok) {
|
|
66
|
-
expect(farDepth.value.softParticleFade).toBeGreaterThan(nearDepth.value.softParticleFade);
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
46
|
});
|
|
@@ -29,7 +29,7 @@ describe('VFX Fog producer contract', () => {
|
|
|
29
29
|
}
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
it('keeps VFX coverage on the
|
|
32
|
+
it('keeps VFX coverage on the current graphics feature lane', () => {
|
|
33
33
|
const feature = readFileSync(
|
|
34
34
|
fileURLToPath(new URL('../feature/gpu-particle-feature.ts', import.meta.url)),
|
|
35
35
|
'utf8',
|
|
@@ -38,9 +38,10 @@ describe('VFX Fog producer contract', () => {
|
|
|
38
38
|
fileURLToPath(new URL('../feature/camera.ts', import.meta.url)),
|
|
39
39
|
'utf8',
|
|
40
40
|
);
|
|
41
|
-
expect(feature).toContain(
|
|
41
|
+
expect(feature).toContain('requiredCapabilities');
|
|
42
|
+
expect(feature).toContain('identity: IDENTITY');
|
|
42
43
|
expect(feature).not.toMatch(/fog(?:History|Texture|Registry)/i);
|
|
43
|
-
expect(feature).toContain('
|
|
44
|
+
expect(feature).toContain('worldCenter');
|
|
44
45
|
expect(camera).toContain('position');
|
|
45
46
|
expect(camera).toContain('viewProjection');
|
|
46
47
|
});
|
|
@@ -1,15 +1,12 @@
|
|
|
1
|
-
import { createAssetRegistry, createCatalogSource } from '@forgeax/engine-assets-runtime';
|
|
2
1
|
import { World } from '@forgeax/engine-ecs';
|
|
3
|
-
import {
|
|
4
|
-
import { err, ok } from '@forgeax/engine-types';
|
|
2
|
+
import type { Asset, MaterialAsset } from '@forgeax/engine-types';
|
|
5
3
|
import {
|
|
6
4
|
ParticleEffectPlayer,
|
|
7
5
|
VFX_GPU_RUNTIME_RESOURCE_KEY,
|
|
8
6
|
type VfxGpuRuntime,
|
|
9
7
|
type VfxGpuTickIntent,
|
|
10
|
-
vfxGpuEffectContribution,
|
|
11
8
|
} from '@forgeax/engine-vfx';
|
|
12
|
-
import { describe, expect, it
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
13
10
|
import { gpuParticleRenderFeature } from '../feature/gpu-particle-feature.js';
|
|
14
11
|
import {
|
|
15
12
|
createCameraProvider,
|
|
@@ -21,12 +18,10 @@ import {
|
|
|
21
18
|
describe('GPU VFX public host', () => {
|
|
22
19
|
it('owns loader and FixedUpdate attachment without exposing simulation internals', async () => {
|
|
23
20
|
const world = new World();
|
|
24
|
-
const
|
|
21
|
+
const registered: unknown[] = [];
|
|
25
22
|
const assets = {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return { kind: 'particle-effect', dispose: () => {} };
|
|
29
|
-
},
|
|
23
|
+
loaders: { registerPackLoader: (loader: unknown) => registered.push(loader) },
|
|
24
|
+
lookup: () => undefined,
|
|
30
25
|
};
|
|
31
26
|
const host = createVfxRuntimeHost({
|
|
32
27
|
camera: {
|
|
@@ -41,45 +36,104 @@ describe('GPU VFX public host', () => {
|
|
|
41
36
|
|
|
42
37
|
const attached = await host.attachWorld({ world, assets: assets as never });
|
|
43
38
|
expect(attached).toMatchObject({ ok: true, value: { state: 'attached' } });
|
|
44
|
-
expect(
|
|
39
|
+
expect(registered).toHaveLength(1);
|
|
45
40
|
expect(host.feature.requiredMaterialShaders).toEqual(
|
|
46
41
|
Object.values(PARTICLE_SHADER_IDENTIFIERS),
|
|
47
42
|
);
|
|
48
|
-
expect(host.feature.inspect()).toEqual({
|
|
49
|
-
frameNumber: -1,
|
|
50
|
-
dispatches: 0,
|
|
51
|
-
indirectDraws: 0,
|
|
52
|
-
subjectOutputs: 0,
|
|
53
|
-
});
|
|
54
43
|
expect(await host.detachWorld({ world })).toMatchObject({
|
|
55
44
|
ok: true,
|
|
56
45
|
value: { state: 'detached' },
|
|
57
46
|
});
|
|
58
47
|
});
|
|
59
48
|
|
|
60
|
-
it('
|
|
49
|
+
it('preserves the asset registry receiver while planning a material projection', async () => {
|
|
61
50
|
const world = new World();
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const host = createVfxRuntimeHost({ camera: { read: () => undefined } });
|
|
51
|
+
const lookups: string[] = [];
|
|
52
|
+
class StatefulAssets {
|
|
53
|
+
readonly material: MaterialAsset = { kind: 'material', values: {} };
|
|
54
|
+
readonly loaders = { registerPackLoader: () => {} };
|
|
67
55
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
56
|
+
lookup<T extends Asset = Asset>(guid: string): T | undefined {
|
|
57
|
+
if (this !== assets) throw new Error('asset registry receiver was lost');
|
|
58
|
+
lookups.push(guid);
|
|
59
|
+
return guid === 'material-guid' ? (this.material as T) : undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const assets = new StatefulAssets();
|
|
63
|
+
const host = createVfxRuntimeHost({
|
|
64
|
+
camera: {
|
|
65
|
+
read: () => ({
|
|
66
|
+
position: new Float32Array(3),
|
|
67
|
+
right: new Float32Array([1, 0, 0]),
|
|
68
|
+
up: new Float32Array([0, 1, 0]),
|
|
69
|
+
viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
|
70
|
+
}),
|
|
71
|
+
},
|
|
75
72
|
});
|
|
76
|
-
assets.
|
|
73
|
+
expect(await host.attachWorld({ world, assets })).toMatchObject({ ok: true });
|
|
74
|
+
|
|
75
|
+
const intent = {
|
|
76
|
+
player: world.spawn().unwrap(),
|
|
77
|
+
fixedDelta: 1 / 60,
|
|
78
|
+
phaseTick: 0,
|
|
79
|
+
seed: 1,
|
|
80
|
+
playCycle: 0,
|
|
81
|
+
spawnCount: 1,
|
|
82
|
+
firstParticleId: 0,
|
|
83
|
+
reset: true,
|
|
84
|
+
channelInputs: [],
|
|
85
|
+
emitter: {
|
|
86
|
+
id: 'receiver-check',
|
|
87
|
+
capacity: 4,
|
|
88
|
+
backend: { required: 'gpu' },
|
|
89
|
+
space: 'world',
|
|
90
|
+
simulationWhenCulled: 'continue',
|
|
91
|
+
schedule: { rate: 0, bursts: [] },
|
|
92
|
+
bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
|
|
93
|
+
wgsl: '// receiver check',
|
|
94
|
+
reflection: { entryPoints: [], bindings: [] },
|
|
95
|
+
renderers: [{ kind: 'billboard', material: 'material-guid' }],
|
|
96
|
+
},
|
|
97
|
+
} as unknown as VfxGpuTickIntent;
|
|
98
|
+
const planned = host.feature.plan(
|
|
99
|
+
{
|
|
100
|
+
worlds: [
|
|
101
|
+
{
|
|
102
|
+
world,
|
|
103
|
+
runtime: {
|
|
104
|
+
isEmitterSessionEnabled: () => true,
|
|
105
|
+
setEmitterCameraVisibility: () => {},
|
|
106
|
+
markEventDispatched: () => {},
|
|
107
|
+
commit: () => {},
|
|
108
|
+
},
|
|
109
|
+
camera: {
|
|
110
|
+
position: new Float32Array(3),
|
|
111
|
+
right: new Float32Array([1, 0, 0]),
|
|
112
|
+
up: new Float32Array([0, 1, 0]),
|
|
113
|
+
viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
|
114
|
+
},
|
|
115
|
+
intents: [intent],
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
frameNumber: 1,
|
|
119
|
+
} as never,
|
|
120
|
+
{
|
|
121
|
+
targets: [],
|
|
122
|
+
caps: {},
|
|
123
|
+
frame: { frameNumber: 1 },
|
|
124
|
+
generation: 1,
|
|
125
|
+
} as never,
|
|
126
|
+
);
|
|
127
|
+
expect(planned.ok).toBe(true);
|
|
128
|
+
expect(lookups).toEqual(['material-guid']);
|
|
129
|
+
await host.detachWorld({ world });
|
|
77
130
|
});
|
|
78
131
|
|
|
79
132
|
it('fences preview controls to one attached host generation', async () => {
|
|
80
133
|
const world = new World();
|
|
81
134
|
const assets = {
|
|
82
|
-
|
|
135
|
+
loaders: { registerPackLoader: () => {} },
|
|
136
|
+
lookup: () => undefined,
|
|
83
137
|
};
|
|
84
138
|
const host = createVfxRuntimeHost({ camera: { read: () => undefined } });
|
|
85
139
|
expect(host.acquireControl(world)).toMatchObject({
|
|
@@ -145,7 +199,7 @@ describe('GPU VFX public host', () => {
|
|
|
145
199
|
expect(host.acquireControl(world)).toMatchObject({ ok: true, value: { generation: 2 } });
|
|
146
200
|
});
|
|
147
201
|
|
|
148
|
-
it('
|
|
202
|
+
it('declares every pending emitter program in one feature plan', () => {
|
|
149
203
|
const world = new World();
|
|
150
204
|
const player = world.spawn().unwrap();
|
|
151
205
|
const intent = (id: string): VfxGpuTickIntent => ({
|
|
@@ -206,45 +260,41 @@ describe('GPU VFX public host', () => {
|
|
|
206
260
|
lastSequence: -1,
|
|
207
261
|
},
|
|
208
262
|
});
|
|
209
|
-
const prepareProgram = vi.fn((name: string) =>
|
|
210
|
-
err(
|
|
211
|
-
new RenderFeaturePreparationFailedError(
|
|
212
|
-
'forgeax.vfx-render.gpu-particles',
|
|
213
|
-
-1,
|
|
214
|
-
'prepare-gpu-program',
|
|
215
|
-
'pipeline',
|
|
216
|
-
name,
|
|
217
|
-
'rhi-not-available:compile pending',
|
|
218
|
-
'next-frame',
|
|
219
|
-
),
|
|
220
|
-
),
|
|
221
|
-
);
|
|
222
263
|
const feature = gpuParticleRenderFeature({ camera: { read: () => undefined } });
|
|
223
|
-
|
|
224
|
-
const prepared = feature.prepare(
|
|
264
|
+
const planned = feature.plan(
|
|
225
265
|
{
|
|
226
266
|
worlds: [
|
|
227
267
|
{
|
|
228
268
|
world,
|
|
229
|
-
runtime: {
|
|
230
|
-
|
|
269
|
+
runtime: {
|
|
270
|
+
isEmitterSessionEnabled: () => true,
|
|
271
|
+
setEmitterCameraVisibility: () => {},
|
|
272
|
+
markEventDispatched: () => {},
|
|
273
|
+
commit: () => {},
|
|
274
|
+
},
|
|
275
|
+
camera: {
|
|
276
|
+
position: new Float32Array(3),
|
|
277
|
+
right: new Float32Array([1, 0, 0]),
|
|
278
|
+
up: new Float32Array([0, 1, 0]),
|
|
279
|
+
viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
|
280
|
+
},
|
|
231
281
|
intents: [intent('first'), intent('second')],
|
|
232
282
|
},
|
|
233
283
|
],
|
|
234
284
|
frameNumber: 1,
|
|
235
285
|
} as never,
|
|
236
|
-
{
|
|
286
|
+
{ targets: [], caps: {}, frame: { frameNumber: 1 }, generation: 1 } as never,
|
|
237
287
|
);
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
expect(
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
]);
|
|
288
|
+
expect(planned.ok).toBe(true);
|
|
289
|
+
if (!planned.ok) return;
|
|
290
|
+
expect(
|
|
291
|
+
planned.value.resources
|
|
292
|
+
.filter((resource) => resource.kind === 'compute-program')
|
|
293
|
+
.map((resource) => resource.name),
|
|
294
|
+
).toEqual([expect.stringContaining('.first.'), expect.stringContaining('.second.')]);
|
|
245
295
|
});
|
|
246
296
|
|
|
247
|
-
it('
|
|
297
|
+
it('keeps masked bindings warm and sends effect-relative time to WGSL', () => {
|
|
248
298
|
const world = new World();
|
|
249
299
|
const player = world.spawn().unwrap();
|
|
250
300
|
const intent = (tick: number): VfxGpuTickIntent => ({
|
|
@@ -306,38 +356,11 @@ describe('GPU VFX public host', () => {
|
|
|
306
356
|
},
|
|
307
357
|
});
|
|
308
358
|
let sessionEnabled = true;
|
|
309
|
-
let renderConsumptionEnabled = true;
|
|
310
|
-
let generation = 0;
|
|
311
|
-
const liveBindings = new Set<object>();
|
|
312
|
-
const prepareProgram = vi.fn(() => ok({ generation: 1 } as never));
|
|
313
|
-
const prepareBuffer = vi.fn((_name: string, _descriptor: { readonly data?: Uint8Array }) =>
|
|
314
|
-
ok({ generation: 1 } as never),
|
|
315
|
-
);
|
|
316
|
-
const prepareBindings = vi.fn(() => {
|
|
317
|
-
generation += 1;
|
|
318
|
-
const reference = { generation: 1, id: generation };
|
|
319
|
-
liveBindings.add(reference);
|
|
320
|
-
return ok(reference as never);
|
|
321
|
-
});
|
|
322
|
-
const retainBindings = vi.fn((references: readonly object[]) =>
|
|
323
|
-
references.every((reference) => liveBindings.has(reference))
|
|
324
|
-
? ok(undefined)
|
|
325
|
-
: err(
|
|
326
|
-
new RenderFeaturePreparationFailedError(
|
|
327
|
-
'forgeax.vfx-render.gpu-particles',
|
|
328
|
-
-1,
|
|
329
|
-
'retain-gpu-bindings',
|
|
330
|
-
'bindings',
|
|
331
|
-
'persistent-bindings',
|
|
332
|
-
'bindings-unavailable',
|
|
333
|
-
'next-frame',
|
|
334
|
-
),
|
|
335
|
-
),
|
|
336
|
-
);
|
|
337
359
|
const runtime = {
|
|
338
|
-
hasPlayer: () => true,
|
|
339
360
|
isEmitterSessionEnabled: () => sessionEnabled,
|
|
340
361
|
setEmitterCameraVisibility: () => {},
|
|
362
|
+
markEventDispatched: () => {},
|
|
363
|
+
commit: () => {},
|
|
341
364
|
};
|
|
342
365
|
const camera = {
|
|
343
366
|
position: new Float32Array(3),
|
|
@@ -345,45 +368,39 @@ describe('GPU VFX public host', () => {
|
|
|
345
368
|
up: new Float32Array([0, 1, 0]),
|
|
346
369
|
viewProjection: new Float32Array(16),
|
|
347
370
|
};
|
|
348
|
-
const feature = gpuParticleRenderFeature({
|
|
349
|
-
|
|
350
|
-
playerConsumption: { isEnabled: () => renderConsumptionEnabled },
|
|
351
|
-
});
|
|
352
|
-
const context = {
|
|
353
|
-
gpu: { prepareProgram, prepareBuffer, prepareBindings, retainBindings },
|
|
354
|
-
targets: [],
|
|
355
|
-
} as never;
|
|
371
|
+
const feature = gpuParticleRenderFeature({ camera: { read: () => camera } });
|
|
372
|
+
const context = { targets: [], caps: {}, frame: { frameNumber: 1 }, generation: 1 } as never;
|
|
356
373
|
const frame = (intents: readonly VfxGpuTickIntent[], frameNumber: number) =>
|
|
357
374
|
({
|
|
358
375
|
worlds: [{ world, runtime, camera, intents }],
|
|
359
376
|
frameNumber,
|
|
360
377
|
}) as never;
|
|
361
378
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
379
|
+
const active = feature.plan(frame([intent(0)], 1), context);
|
|
380
|
+
expect(active.ok).toBe(true);
|
|
381
|
+
if (!active.ok) return;
|
|
382
|
+
const runtimeWrite = active.value.resources.find(
|
|
383
|
+
(resource) => resource.kind === 'buffer' && resource.name.endsWith('.runtime'),
|
|
384
|
+
);
|
|
385
|
+
expect(runtimeWrite?.kind === 'buffer' ? runtimeWrite.data : undefined).toBeInstanceOf(
|
|
386
|
+
Uint8Array,
|
|
387
|
+
);
|
|
388
|
+
const runtimeBytes = runtimeWrite?.kind === 'buffer' ? runtimeWrite.data : undefined;
|
|
389
|
+
expect(new Uint32Array((runtimeBytes as Uint8Array).buffer)[1]).toBe(7);
|
|
372
390
|
sessionEnabled = false;
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
// can be re-enabled without a fresh simulation intent, so the masked frame
|
|
376
|
-
// must keep its prepared bindings warm while contributing no work.
|
|
377
|
-
expect(retainBindings).toHaveBeenCalledTimes(2);
|
|
391
|
+
const masked = feature.plan(frame([intent(1)], 2), context);
|
|
392
|
+
expect(masked).toMatchObject({ ok: true, value: { resources: [], passes: [] } });
|
|
378
393
|
sessionEnabled = true;
|
|
379
|
-
|
|
380
|
-
expect(
|
|
394
|
+
const resumed = feature.plan(frame([intent(2)], 3), context);
|
|
395
|
+
expect(resumed.ok).toBe(true);
|
|
396
|
+
if (resumed.ok) expect(resumed.value.resources.length).toBeGreaterThan(0);
|
|
381
397
|
});
|
|
382
398
|
|
|
383
399
|
it('rejects a second host without replacing the first World runtime', async () => {
|
|
384
400
|
const world = new World();
|
|
385
401
|
const assets = {
|
|
386
|
-
|
|
402
|
+
loaders: { registerPackLoader: () => {} },
|
|
403
|
+
lookup: () => undefined,
|
|
387
404
|
};
|
|
388
405
|
const options = {
|
|
389
406
|
camera: {
|
|
@@ -426,7 +443,8 @@ describe('GPU VFX public host', () => {
|
|
|
426
443
|
it('returns an empty keyed inspection aggregate before a player is present', async () => {
|
|
427
444
|
const world = new World();
|
|
428
445
|
const assets = {
|
|
429
|
-
|
|
446
|
+
loaders: { registerPackLoader: () => {} },
|
|
447
|
+
lookup: () => undefined,
|
|
430
448
|
};
|
|
431
449
|
const host = createVfxRuntimeHost({ camera: { read: () => undefined } });
|
|
432
450
|
await host.attachWorld({ world, assets: assets as never });
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
canonicalMeshVertices,
|
|
5
5
|
particleMaterialPass,
|
|
6
6
|
particleMaterialUsesBindings,
|
|
7
|
-
particleMaterialUsesSceneDepth,
|
|
8
7
|
} from '../feature/particle-resources.js';
|
|
9
8
|
|
|
10
9
|
describe('particle mesh resources', () => {
|
|
@@ -54,14 +53,6 @@ describe('particle material pass', () => {
|
|
|
54
53
|
});
|
|
55
54
|
});
|
|
56
55
|
|
|
57
|
-
it('uses the renderer-owned shader contract for scene-depth selection', () => {
|
|
58
|
-
expect(particleMaterialUsesSceneDepth('group-0-resource')).toBe(true);
|
|
59
|
-
expect(particleMaterialUsesSceneDepth('view-with-resource')).toBe(true);
|
|
60
|
-
expect(particleMaterialUsesSceneDepth('group-0')).toBe(false);
|
|
61
|
-
expect(particleMaterialUsesSceneDepth('view-only')).toBe(false);
|
|
62
|
-
expect(particleMaterialUsesSceneDepth('render-material')).toBe(false);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
56
|
it('does not mistake an ordinary Forward pass for a particle shader', () => {
|
|
66
57
|
const material: MaterialAsset = {
|
|
67
58
|
kind: 'material',
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
|
+
import type { RenderFeaturePlan } from '@forgeax/engine-render';
|
|
4
|
+
import type { VfxGpuTickIntent } from '@forgeax/engine-vfx';
|
|
3
5
|
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import { freezeRenderFeaturePlan } from '../../../render/src/features/plan';
|
|
7
|
+
import { gpuParticleRenderFeature } from '../feature/gpu-particle-feature.js';
|
|
4
8
|
|
|
5
9
|
const stagePlanPath = resolve(import.meta.dirname, '../feature/stage-plan.ts');
|
|
6
10
|
|
|
@@ -13,4 +17,132 @@ describe('VFX managed stage execution seam', () => {
|
|
|
13
17
|
expect(source).toContain('forgeax_vfx_stage_');
|
|
14
18
|
expect(source).not.toContain('authorRawDispatch');
|
|
15
19
|
});
|
|
20
|
+
|
|
21
|
+
it('projects a VFX stage into the closed render feature plan surface', () => {
|
|
22
|
+
const source = readFileSync(
|
|
23
|
+
resolve(import.meta.dirname, '../feature/gpu-particle-feature.ts'),
|
|
24
|
+
'utf8',
|
|
25
|
+
);
|
|
26
|
+
const plan: RenderFeaturePlan = { resources: [], passes: [] };
|
|
27
|
+
expect(source).toContain('RenderFeaturePlan');
|
|
28
|
+
expect(source).not.toMatch(/GPUCommandEncoder|queue\.submit|encoder\.finish/);
|
|
29
|
+
expect(plan.passes).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('declares storage-produced indirect work and dependent raster descriptors', () => {
|
|
33
|
+
const intent = {
|
|
34
|
+
sequence: 1,
|
|
35
|
+
player: 1,
|
|
36
|
+
emitter: {
|
|
37
|
+
id: 'spark',
|
|
38
|
+
capacity: 4,
|
|
39
|
+
space: 'world',
|
|
40
|
+
bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
|
|
41
|
+
wgsl: '// cooked VFX program',
|
|
42
|
+
reflection: {
|
|
43
|
+
entryPoints: ['forgeax_vfx_spawn_main', 'forgeax_vfx_billboard_main'],
|
|
44
|
+
bindings: [
|
|
45
|
+
{
|
|
46
|
+
entries: [
|
|
47
|
+
{ binding: 0, visibility: 4, buffer: { type: 'storage' } },
|
|
48
|
+
{ binding: 1, visibility: 4, buffer: { type: 'uniform' } },
|
|
49
|
+
{ binding: 4, visibility: 4, buffer: { type: 'storage' } },
|
|
50
|
+
{ binding: 6, visibility: 4, buffer: { type: 'storage' } },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
stages: [],
|
|
55
|
+
dataInterfaces: [],
|
|
56
|
+
},
|
|
57
|
+
renderers: [
|
|
58
|
+
{
|
|
59
|
+
kind: 'billboard',
|
|
60
|
+
material: 'material-guid',
|
|
61
|
+
blend: 'additive',
|
|
62
|
+
sorting: 'none',
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
programFingerprint: 'spark-program',
|
|
67
|
+
reset: true,
|
|
68
|
+
fixedDelta: 1 / 60,
|
|
69
|
+
phaseTick: 0,
|
|
70
|
+
tick: 0,
|
|
71
|
+
seed: 1,
|
|
72
|
+
playCycle: 0,
|
|
73
|
+
spawnCount: 1,
|
|
74
|
+
firstParticleId: 0,
|
|
75
|
+
instanceGeneration: 1,
|
|
76
|
+
channelInputs: [],
|
|
77
|
+
eventCounters: {},
|
|
78
|
+
} as unknown as VfxGpuTickIntent;
|
|
79
|
+
const runtime = {
|
|
80
|
+
isEmitterSessionEnabled: () => true,
|
|
81
|
+
setEmitterCameraVisibility: () => {},
|
|
82
|
+
markEventDispatched: () => {},
|
|
83
|
+
commit: () => {},
|
|
84
|
+
};
|
|
85
|
+
const feature = gpuParticleRenderFeature({ camera: { read: () => undefined } });
|
|
86
|
+
const planned = feature.plan(
|
|
87
|
+
{
|
|
88
|
+
worlds: [
|
|
89
|
+
{
|
|
90
|
+
world: {},
|
|
91
|
+
runtime,
|
|
92
|
+
camera: {
|
|
93
|
+
position: new Float32Array(3),
|
|
94
|
+
right: new Float32Array([1, 0, 0]),
|
|
95
|
+
up: new Float32Array([0, 1, 0]),
|
|
96
|
+
viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
|
97
|
+
},
|
|
98
|
+
intents: [intent],
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
frameNumber: 1,
|
|
102
|
+
} as never,
|
|
103
|
+
{
|
|
104
|
+
caps: {} as never,
|
|
105
|
+
frame: { frameNumber: 1 },
|
|
106
|
+
generation: 1,
|
|
107
|
+
targets: [
|
|
108
|
+
{ name: 'scene-color', kind: 'color', format: 'rgba16float', sampleCount: 1 },
|
|
109
|
+
{ name: 'scene-depth', kind: 'depth', format: 'depth24plus', sampleCount: 1 },
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(planned.ok).toBe(true);
|
|
115
|
+
if (!planned.ok) return;
|
|
116
|
+
expect(
|
|
117
|
+
freezeRenderFeaturePlan(feature.identity, planned.value, [
|
|
118
|
+
{ name: 'scene-color', kind: 'color', format: 'rgba16float', sampleCount: 1 },
|
|
119
|
+
{ name: 'scene-depth', kind: 'depth', format: 'depth24plus', sampleCount: 1 },
|
|
120
|
+
]).ok,
|
|
121
|
+
).toBe(true);
|
|
122
|
+
expect(planned.value.resources).toEqual(
|
|
123
|
+
expect.arrayContaining([
|
|
124
|
+
expect.objectContaining({
|
|
125
|
+
kind: 'buffer',
|
|
126
|
+
name: expect.stringContaining('.indirect'),
|
|
127
|
+
usage: ['storage', 'indirect'],
|
|
128
|
+
}),
|
|
129
|
+
expect.objectContaining({ kind: 'graphics-program' }),
|
|
130
|
+
expect.objectContaining({ kind: 'graphics-bindings' }),
|
|
131
|
+
expect.objectContaining({ kind: 'vertex-data' }),
|
|
132
|
+
]),
|
|
133
|
+
);
|
|
134
|
+
expect(planned.value.passes).toEqual(
|
|
135
|
+
expect.arrayContaining([
|
|
136
|
+
expect.objectContaining({ kind: 'compute' }),
|
|
137
|
+
expect.objectContaining({
|
|
138
|
+
kind: 'raster',
|
|
139
|
+
draws: [
|
|
140
|
+
expect.objectContaining({
|
|
141
|
+
draw: expect.objectContaining({ kind: 'draw-indirect' }),
|
|
142
|
+
}),
|
|
143
|
+
],
|
|
144
|
+
}),
|
|
145
|
+
]),
|
|
146
|
+
);
|
|
147
|
+
});
|
|
16
148
|
});
|