@forgeax/engine-vfx-render 0.1.27 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -10
- package/dist/__tests__/persistent-ticks.integration.test.d.ts +2 -0
- package/dist/__tests__/persistent-ticks.integration.test.d.ts.map +1 -0
- package/dist/feature/event-resources.d.ts +10 -3
- package/dist/feature/event-resources.d.ts.map +1 -1
- package/dist/feature/gpu-particle-feature.d.ts +18 -5
- package/dist/feature/gpu-particle-feature.d.ts.map +1 -1
- package/dist/feature/particle-resources.d.ts +43 -6
- package/dist/feature/particle-resources.d.ts.map +1 -1
- package/dist/host/data-interface-providers.d.ts +4 -1
- package/dist/host/data-interface-providers.d.ts.map +1 -1
- package/dist/host/vfx-runtime-host.d.ts +21 -2
- package/dist/host/vfx-runtime-host.d.ts.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +1027 -307
- package/dist/index.mjs.map +1 -1
- package/package.json +11 -9
- package/src/__tests__/billboard-advanced.integration.test.ts +1 -1
- package/src/__tests__/data-interface-providers.unit.test.ts +4 -0
- package/src/__tests__/data-interface-public-api.test-d.ts +1 -0
- package/src/__tests__/gpu-host.integration.test.ts +1471 -59
- package/src/__tests__/particle-resources.unit.test.ts +75 -0
- package/src/__tests__/persistent-ticks.integration.test.ts +162 -0
- package/src/__tests__/render-vocabulary-owner.test-d.ts +7 -7
- package/src/feature/event-resources.ts +18 -4
- package/src/feature/gpu-particle-feature.ts +1153 -376
- package/src/feature/particle-resources.ts +223 -11
- package/src/host/data-interface-providers.ts +19 -0
- package/src/host/vfx-runtime-host.ts +82 -2
- package/src/index.ts +8 -0
- package/src/shaders/beam-inputs.wgsl +40 -0
- package/src/shaders/beam.wgsl +4 -3
- package/src/shaders/billboard-inputs.wgsl +85 -0
- package/src/shaders/mesh-inputs.wgsl +95 -0
- package/src/shaders/mesh-shadow.wgsl +18 -0
- package/src/shaders/mesh.wgsl +64 -40
- package/src/shaders/ribbon-inputs.wgsl +39 -0
- package/src/shaders/trail-inputs.wgsl +45 -0
- package/src/shaders/trail.wgsl +9 -3
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { World } from '@forgeax/engine-ecs';
|
|
1
|
+
import { createWorldContext, World } from '@forgeax/engine-ecs';
|
|
2
2
|
import type { Asset, MaterialAsset } from '@forgeax/engine-types';
|
|
3
3
|
import {
|
|
4
4
|
ParticleEffectPlayer,
|
|
5
5
|
VFX_GPU_RUNTIME_RESOURCE_KEY,
|
|
6
|
+
VFX_PARTICLE_CORE_LAYOUT,
|
|
7
|
+
type VfxGpuEffectAsset,
|
|
6
8
|
type VfxGpuRuntime,
|
|
7
9
|
type VfxGpuTickIntent,
|
|
10
|
+
vfxGpuRuntimePlugin,
|
|
8
11
|
} from '@forgeax/engine-vfx';
|
|
9
12
|
import { describe, expect, it } from 'vitest';
|
|
10
13
|
import { gpuParticleRenderFeature } from '../feature/gpu-particle-feature.js';
|
|
@@ -12,9 +15,76 @@ import {
|
|
|
12
15
|
createCameraProvider,
|
|
13
16
|
createSceneDepthProvider,
|
|
14
17
|
createVfxRuntimeHost,
|
|
18
|
+
PARTICLE_INPUT_SHADER_IDENTIFIERS,
|
|
15
19
|
PARTICLE_SHADER_IDENTIFIERS,
|
|
16
20
|
} from '../index.js';
|
|
17
21
|
|
|
22
|
+
/** Keep legacy hand-authored fixtures executable on the one-cut Program v3 runtime. */
|
|
23
|
+
function asV3Effect(input: unknown): VfxGpuEffectAsset {
|
|
24
|
+
type FixtureRecord = Record<string, unknown>;
|
|
25
|
+
const value = input as FixtureRecord;
|
|
26
|
+
const program = value.program as FixtureRecord;
|
|
27
|
+
const emitters = (program.emitters as readonly FixtureRecord[]).map((emitter) => {
|
|
28
|
+
const renderers = (emitter.renderers ?? []) as readonly FixtureRecord[];
|
|
29
|
+
const reflection = emitter.reflection as FixtureRecord;
|
|
30
|
+
return {
|
|
31
|
+
...emitter,
|
|
32
|
+
reflection: {
|
|
33
|
+
hooks: ['vfx_spawn', 'vfx_update'],
|
|
34
|
+
imports: [],
|
|
35
|
+
resources: reflection.resources ?? [],
|
|
36
|
+
entryPoints: reflection.entryPoints ?? [],
|
|
37
|
+
bindings: reflection.bindings ?? [],
|
|
38
|
+
layout: reflection.layout ?? {
|
|
39
|
+
version: 3,
|
|
40
|
+
parameters: { name: 'VfxParameters', fields: [], size: 0, alignment: 1 },
|
|
41
|
+
custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
|
|
42
|
+
core: VFX_PARTICLE_CORE_LAYOUT,
|
|
43
|
+
customLayout: {
|
|
44
|
+
name: 'VfxCustom',
|
|
45
|
+
fields: [],
|
|
46
|
+
size: 0,
|
|
47
|
+
alignment: 1,
|
|
48
|
+
stride: 0,
|
|
49
|
+
lanes: 0,
|
|
50
|
+
},
|
|
51
|
+
fingerprint: 'sha256:legacy-fixture-layout',
|
|
52
|
+
},
|
|
53
|
+
dataInterfaces: reflection.dataInterfaces ?? [],
|
|
54
|
+
eventChannels: reflection.eventChannels ?? [],
|
|
55
|
+
events: reflection.events ?? [],
|
|
56
|
+
eventEntryPoint: 'forgeax_vfx_event_main',
|
|
57
|
+
stages: reflection.stages ?? [],
|
|
58
|
+
renderers: renderers.map((renderer) => ({
|
|
59
|
+
topology: renderer.kind,
|
|
60
|
+
resource: `${renderer.kind}Instances`,
|
|
61
|
+
capacity: renderer.capacity ?? emitter.capacity,
|
|
62
|
+
overflow: 'drop-newest',
|
|
63
|
+
enabled: renderer.enabled !== false,
|
|
64
|
+
shaderInputs: [],
|
|
65
|
+
attributes: {},
|
|
66
|
+
materialInputs: renderer.materialInputs ?? [],
|
|
67
|
+
...(renderer.materialInputDefinitions === undefined
|
|
68
|
+
? {}
|
|
69
|
+
: { materialInputDefinitions: renderer.materialInputDefinitions }),
|
|
70
|
+
...(renderer.historyLength === undefined
|
|
71
|
+
? {}
|
|
72
|
+
: { historyLength: renderer.historyLength }),
|
|
73
|
+
...(renderer.castShadows === undefined ? {} : { castShadows: renderer.castShadows }),
|
|
74
|
+
...(renderer.receiveShadows === undefined
|
|
75
|
+
? {}
|
|
76
|
+
: { receiveShadows: renderer.receiveShadows }),
|
|
77
|
+
})),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
...value,
|
|
83
|
+
schemaVersion: 3,
|
|
84
|
+
program: { ...program, format: 'forgeax-vfx-program-3', emitters },
|
|
85
|
+
} as unknown as VfxGpuEffectAsset;
|
|
86
|
+
}
|
|
87
|
+
|
|
18
88
|
describe('GPU VFX public host', () => {
|
|
19
89
|
it('owns loader and FixedUpdate attachment without exposing simulation internals', async () => {
|
|
20
90
|
const world = new World();
|
|
@@ -37,9 +107,10 @@ describe('GPU VFX public host', () => {
|
|
|
37
107
|
const attached = await host.attachWorld({ world, assets: assets as never });
|
|
38
108
|
expect(attached).toMatchObject({ ok: true, value: { state: 'attached' } });
|
|
39
109
|
expect(registered).toHaveLength(1);
|
|
40
|
-
expect(host.feature.requiredMaterialShaders).toEqual(
|
|
41
|
-
Object.values(PARTICLE_SHADER_IDENTIFIERS),
|
|
42
|
-
|
|
110
|
+
expect(host.feature.requiredMaterialShaders).toEqual([
|
|
111
|
+
...Object.values(PARTICLE_SHADER_IDENTIFIERS),
|
|
112
|
+
...Object.values(PARTICLE_INPUT_SHADER_IDENTIFIERS),
|
|
113
|
+
]);
|
|
43
114
|
expect(await host.detachWorld({ world })).toMatchObject({
|
|
44
115
|
ok: true,
|
|
45
116
|
value: { state: 'detached' },
|
|
@@ -220,9 +291,16 @@ describe('GPU VFX public host', () => {
|
|
|
220
291
|
it('declares every pending emitter program in one feature plan', () => {
|
|
221
292
|
const world = new World();
|
|
222
293
|
const player = world.spawn().unwrap();
|
|
223
|
-
const
|
|
294
|
+
const secondPlayer = world.spawn().unwrap();
|
|
295
|
+
let committed = 0;
|
|
296
|
+
let dispatchedEvents = 0;
|
|
297
|
+
const intent = (
|
|
298
|
+
id: string,
|
|
299
|
+
owner: typeof player = player,
|
|
300
|
+
renderers: VfxGpuTickIntent['emitter']['renderers'] = [],
|
|
301
|
+
): VfxGpuTickIntent => ({
|
|
224
302
|
sequence: 1,
|
|
225
|
-
player,
|
|
303
|
+
player: owner,
|
|
226
304
|
emitter: {
|
|
227
305
|
id,
|
|
228
306
|
module: `${id}.vfx.wgsl`,
|
|
@@ -237,10 +315,24 @@ describe('GPU VFX public host', () => {
|
|
|
237
315
|
hooks: ['vfx_spawn', 'vfx_update'],
|
|
238
316
|
imports: [],
|
|
239
317
|
resources: [],
|
|
240
|
-
entryPoints: [
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
318
|
+
entryPoints: [
|
|
319
|
+
'forgeax_vfx_spawn_main',
|
|
320
|
+
...(renderers.length > 0 ? ['forgeax_vfx_billboard_main'] : []),
|
|
321
|
+
],
|
|
322
|
+
bindings:
|
|
323
|
+
renderers.length === 0
|
|
324
|
+
? []
|
|
325
|
+
: [
|
|
326
|
+
{
|
|
327
|
+
entries: [0, 1, 2, 3, 4, 5, 6, 8, 9].map((binding) => ({
|
|
328
|
+
binding,
|
|
329
|
+
visibility: 4,
|
|
330
|
+
buffer: { type: binding === 1 ? 'uniform' : 'storage' },
|
|
331
|
+
})),
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
} as never,
|
|
335
|
+
renderers,
|
|
244
336
|
},
|
|
245
337
|
programFingerprint: id,
|
|
246
338
|
reset: true,
|
|
@@ -279,24 +371,34 @@ describe('GPU VFX public host', () => {
|
|
|
279
371
|
},
|
|
280
372
|
});
|
|
281
373
|
const feature = gpuParticleRenderFeature({ camera: { read: () => undefined } });
|
|
374
|
+
const runtime = {
|
|
375
|
+
isEmitterSessionEnabled: () => true,
|
|
376
|
+
setEmitterCameraVisibility: () => {},
|
|
377
|
+
markEventDispatched: () => {
|
|
378
|
+
dispatchedEvents += 1;
|
|
379
|
+
},
|
|
380
|
+
commit: () => {
|
|
381
|
+
committed += 1;
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
const camera = {
|
|
385
|
+
position: new Float32Array(3),
|
|
386
|
+
right: new Float32Array([1, 0, 0]),
|
|
387
|
+
up: new Float32Array([0, 1, 0]),
|
|
388
|
+
viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
|
389
|
+
};
|
|
282
390
|
const planned = feature.plan(
|
|
283
391
|
{
|
|
284
392
|
worlds: [
|
|
285
393
|
{
|
|
286
394
|
world,
|
|
287
|
-
runtime
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
position: new Float32Array(3),
|
|
295
|
-
right: new Float32Array([1, 0, 0]),
|
|
296
|
-
up: new Float32Array([0, 1, 0]),
|
|
297
|
-
viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
|
298
|
-
},
|
|
299
|
-
intents: [intent('first'), intent('second')],
|
|
395
|
+
runtime,
|
|
396
|
+
camera,
|
|
397
|
+
intents: [
|
|
398
|
+
intent('first'),
|
|
399
|
+
intent('second', secondPlayer),
|
|
400
|
+
{ ...intent('first'), reset: false, sequence: 3 },
|
|
401
|
+
],
|
|
300
402
|
},
|
|
301
403
|
],
|
|
302
404
|
frameNumber: 1,
|
|
@@ -309,7 +411,142 @@ describe('GPU VFX public host', () => {
|
|
|
309
411
|
planned.value.resources
|
|
310
412
|
.filter((resource) => resource.kind === 'compute-program')
|
|
311
413
|
.map((resource) => resource.name),
|
|
312
|
-
).toEqual([
|
|
414
|
+
).toEqual([
|
|
415
|
+
expect.stringContaining(`.p-${Number(player).toString(36)}.first.g-1.`),
|
|
416
|
+
expect.stringContaining(`.p-${Number(secondPlayer).toString(36)}.second.g-1.`),
|
|
417
|
+
]);
|
|
418
|
+
expect(
|
|
419
|
+
planned.value.passes.filter(
|
|
420
|
+
(pass) => pass.kind === 'compute' && pass.name.includes('.first.g-1.tick-'),
|
|
421
|
+
),
|
|
422
|
+
).toHaveLength(2);
|
|
423
|
+
expect(committed).toBe(0);
|
|
424
|
+
expect(dispatchedEvents).toBe(0);
|
|
425
|
+
feature.onFrameSubmitted?.({
|
|
426
|
+
worlds: [
|
|
427
|
+
{
|
|
428
|
+
world,
|
|
429
|
+
runtime,
|
|
430
|
+
camera,
|
|
431
|
+
intents: [],
|
|
432
|
+
},
|
|
433
|
+
],
|
|
434
|
+
frameNumber: 1,
|
|
435
|
+
} as never);
|
|
436
|
+
// Submission consumes the ledger captured by plan, not the declarative
|
|
437
|
+
// plan call itself. The runtime is committed once and all three queued
|
|
438
|
+
// ticks have their event counters acknowledged.
|
|
439
|
+
expect(committed).toBe(1);
|
|
440
|
+
expect(dispatchedEvents).toBe(3);
|
|
441
|
+
|
|
442
|
+
const warm = feature.plan(
|
|
443
|
+
{
|
|
444
|
+
worlds: [
|
|
445
|
+
{ world, runtime, camera, intents: [{ ...intent('first'), reset: false, sequence: 4 }] },
|
|
446
|
+
],
|
|
447
|
+
frameNumber: 2,
|
|
448
|
+
} as never,
|
|
449
|
+
{ targets: [], caps: {}, frame: { frameNumber: 2 }, generation: 1 } as never,
|
|
450
|
+
);
|
|
451
|
+
expect(warm.ok).toBe(true);
|
|
452
|
+
if (!warm.ok) return;
|
|
453
|
+
expect(
|
|
454
|
+
warm.value.resources.some(
|
|
455
|
+
(resource) => resource.kind === 'compute-program' && resource.name.includes('.first.g-1.'),
|
|
456
|
+
),
|
|
457
|
+
).toBe(true);
|
|
458
|
+
const warmParticles = warm.value.resources.find(
|
|
459
|
+
(resource) => resource.kind === 'buffer' && resource.name.endsWith('.particles'),
|
|
460
|
+
);
|
|
461
|
+
expect(warmParticles).toBeDefined();
|
|
462
|
+
expect(warmParticles).not.toHaveProperty('data');
|
|
463
|
+
feature.onFrameSubmitted?.({ worlds: [], frameNumber: 2 } as never);
|
|
464
|
+
|
|
465
|
+
const reset = feature.plan(
|
|
466
|
+
{
|
|
467
|
+
worlds: [
|
|
468
|
+
{ world, runtime, camera, intents: [{ ...intent('first'), reset: true, sequence: 5 }] },
|
|
469
|
+
],
|
|
470
|
+
frameNumber: 3,
|
|
471
|
+
} as never,
|
|
472
|
+
{ targets: [], caps: {}, frame: { frameNumber: 3 }, generation: 1 } as never,
|
|
473
|
+
);
|
|
474
|
+
expect(reset.ok).toBe(true);
|
|
475
|
+
if (reset.ok)
|
|
476
|
+
expect(
|
|
477
|
+
reset.value.resources.some(
|
|
478
|
+
(resource) =>
|
|
479
|
+
resource.kind === 'compute-program' && resource.name.includes('.first.g-2.'),
|
|
480
|
+
),
|
|
481
|
+
).toBe(true);
|
|
482
|
+
feature.onFrameSubmitted?.({ worlds: [], frameNumber: 3 } as never);
|
|
483
|
+
|
|
484
|
+
const projectedFirst = feature.plan(
|
|
485
|
+
{
|
|
486
|
+
worlds: [
|
|
487
|
+
{
|
|
488
|
+
world,
|
|
489
|
+
runtime,
|
|
490
|
+
camera,
|
|
491
|
+
intents: [
|
|
492
|
+
intent('projected', player, [{ kind: 'billboard', material: 'particle-material' }]),
|
|
493
|
+
],
|
|
494
|
+
},
|
|
495
|
+
],
|
|
496
|
+
frameNumber: 4,
|
|
497
|
+
} as never,
|
|
498
|
+
{ targets: [], caps: {}, frame: { frameNumber: 4 }, generation: 1 } as never,
|
|
499
|
+
);
|
|
500
|
+
feature.onFrameSubmitted?.({ worlds: [], frameNumber: 4 } as never);
|
|
501
|
+
const projectedSecond = feature.plan(
|
|
502
|
+
{
|
|
503
|
+
worlds: [
|
|
504
|
+
{
|
|
505
|
+
world,
|
|
506
|
+
runtime,
|
|
507
|
+
camera,
|
|
508
|
+
intents: [
|
|
509
|
+
{
|
|
510
|
+
...intent('projected', player, [
|
|
511
|
+
{ kind: 'billboard', material: 'particle-material' },
|
|
512
|
+
]),
|
|
513
|
+
reset: false,
|
|
514
|
+
sequence: 2,
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
...intent('projected', player, [
|
|
518
|
+
{ kind: 'billboard', material: 'particle-material' },
|
|
519
|
+
]),
|
|
520
|
+
reset: false,
|
|
521
|
+
sequence: 3,
|
|
522
|
+
},
|
|
523
|
+
],
|
|
524
|
+
},
|
|
525
|
+
],
|
|
526
|
+
frameNumber: 5,
|
|
527
|
+
} as never,
|
|
528
|
+
{ targets: [], caps: {}, frame: { frameNumber: 5 }, generation: 1 } as never,
|
|
529
|
+
);
|
|
530
|
+
expect(projectedFirst.ok).toBe(true);
|
|
531
|
+
expect(projectedSecond.ok).toBe(true);
|
|
532
|
+
if (projectedFirst.ok && projectedSecond.ok) {
|
|
533
|
+
const projectionBinding = (plan: typeof projectedFirst.value) =>
|
|
534
|
+
plan.resources.find(
|
|
535
|
+
(resource) =>
|
|
536
|
+
resource.kind === 'compute-bindings' &&
|
|
537
|
+
resource.name.endsWith('.renderer-0.compute-bindings'),
|
|
538
|
+
);
|
|
539
|
+
const firstBinding = projectionBinding(projectedFirst.value);
|
|
540
|
+
const secondBinding = projectionBinding(projectedSecond.value);
|
|
541
|
+
expect(firstBinding).toBeDefined();
|
|
542
|
+
expect(secondBinding).toBeDefined();
|
|
543
|
+
if (firstBinding?.kind === 'compute-bindings' && secondBinding?.kind === 'compute-bindings') {
|
|
544
|
+
expect(firstBinding.entries).toEqual(secondBinding.entries);
|
|
545
|
+
expect(firstBinding.entries.find((entry) => entry.binding === 8)?.resource).toContain(
|
|
546
|
+
'.projection-event-inputs',
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
313
550
|
});
|
|
314
551
|
|
|
315
552
|
it('keeps masked bindings warm and sends effect-relative time to WGSL', () => {
|
|
@@ -334,7 +571,7 @@ describe('GPU VFX public host', () => {
|
|
|
334
571
|
resources: [],
|
|
335
572
|
entryPoints: ['forgeax_vfx_spawn_main'],
|
|
336
573
|
bindings: [],
|
|
337
|
-
},
|
|
574
|
+
} as never,
|
|
338
575
|
renderers: [],
|
|
339
576
|
},
|
|
340
577
|
programFingerprint: 'masked-program',
|
|
@@ -414,48 +651,1223 @@ describe('GPU VFX public host', () => {
|
|
|
414
651
|
if (resumed.ok) expect(resumed.value.resources.length).toBeGreaterThan(0);
|
|
415
652
|
});
|
|
416
653
|
|
|
417
|
-
it('
|
|
654
|
+
it('acknowledges a skipped head and keeps a reset resource identity across retry', () => {
|
|
418
655
|
const world = new World();
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
656
|
+
const player = world.spawn().unwrap();
|
|
657
|
+
const commits: number[] = [];
|
|
658
|
+
let dispatched = 0;
|
|
659
|
+
const makeIntent = (id: string, sequence: number, reset: boolean): VfxGpuTickIntent => ({
|
|
660
|
+
sequence,
|
|
661
|
+
player,
|
|
662
|
+
emitter: {
|
|
663
|
+
id,
|
|
664
|
+
module: `${id}.vfx.wgsl`,
|
|
665
|
+
capacity: 4,
|
|
666
|
+
backend: { required: 'gpu' },
|
|
667
|
+
space: 'world',
|
|
668
|
+
simulationWhenCulled: 'continue',
|
|
669
|
+
schedule: { rate: 0, bursts: [] },
|
|
670
|
+
bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
|
|
671
|
+
wgsl: `// ${id}`,
|
|
672
|
+
reflection: {
|
|
673
|
+
hooks: ['vfx_spawn', 'vfx_update'],
|
|
674
|
+
imports: [],
|
|
675
|
+
resources: [],
|
|
676
|
+
entryPoints: ['forgeax_vfx_spawn_main'],
|
|
677
|
+
bindings: [],
|
|
678
|
+
} as never,
|
|
679
|
+
renderers: [],
|
|
680
|
+
},
|
|
681
|
+
programFingerprint: `${id}-program`,
|
|
682
|
+
reset,
|
|
683
|
+
fixedDelta: 1 / 60,
|
|
684
|
+
phaseTick: sequence,
|
|
685
|
+
tick: sequence,
|
|
686
|
+
seed: 1,
|
|
687
|
+
playCycle: 0,
|
|
688
|
+
spawnCount: 1,
|
|
689
|
+
firstParticleId: 0,
|
|
690
|
+
instanceGeneration: 0,
|
|
691
|
+
instancePatchCount: 0,
|
|
692
|
+
parameterBlock: new Uint8Array(),
|
|
693
|
+
canonicalPayload: new Uint8Array(),
|
|
694
|
+
replayInput: {
|
|
695
|
+
seed: 1,
|
|
696
|
+
tick: sequence,
|
|
697
|
+
generation: 0,
|
|
698
|
+
sequence,
|
|
699
|
+
fingerprint: `${id}-program`,
|
|
700
|
+
payload: new Uint8Array(),
|
|
701
|
+
values: {},
|
|
702
|
+
channelInputs: [],
|
|
703
|
+
droppedCount: 0,
|
|
704
|
+
},
|
|
705
|
+
channelInputs: [],
|
|
706
|
+
eventCounters: {
|
|
707
|
+
queued: 0,
|
|
708
|
+
produced: 0,
|
|
709
|
+
consumed: 0,
|
|
710
|
+
dropped: 0,
|
|
711
|
+
overflow: 0,
|
|
712
|
+
fanOut: 0,
|
|
713
|
+
recursionDepth: 0,
|
|
714
|
+
lastSequence: -1,
|
|
426
715
|
},
|
|
427
|
-
};
|
|
428
|
-
const first = createVfxRuntimeHost(options);
|
|
429
|
-
const second = createVfxRuntimeHost(options);
|
|
430
|
-
expect(await first.attachWorld({ world, assets: assets as never })).toMatchObject({ ok: true });
|
|
431
|
-
expect(await second.attachWorld({ world, assets: assets as never })).toMatchObject({
|
|
432
|
-
ok: false,
|
|
433
|
-
error: { code: 'vfx-host-world-attach-failed' },
|
|
434
716
|
});
|
|
435
|
-
|
|
717
|
+
const runtime = {
|
|
718
|
+
renderGeneration: 1,
|
|
719
|
+
isEmitterSessionEnabled: (_player: number, emitterId: string) => emitterId !== 'masked',
|
|
720
|
+
setEmitterCameraVisibility: () => {},
|
|
721
|
+
markEventDispatched: () => {
|
|
722
|
+
dispatched += 1;
|
|
723
|
+
},
|
|
724
|
+
commit: (sequence: number) => {
|
|
725
|
+
commits.push(sequence);
|
|
726
|
+
},
|
|
727
|
+
};
|
|
728
|
+
const camera = {
|
|
729
|
+
position: new Float32Array(3),
|
|
730
|
+
right: new Float32Array([1, 0, 0]),
|
|
731
|
+
up: new Float32Array([0, 1, 0]),
|
|
732
|
+
viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
|
733
|
+
};
|
|
734
|
+
const feature = gpuParticleRenderFeature({ camera: { read: () => camera } });
|
|
735
|
+
const frame = (frameNumber: number) =>
|
|
736
|
+
({
|
|
737
|
+
worlds: [
|
|
738
|
+
{
|
|
739
|
+
world,
|
|
740
|
+
runtime,
|
|
741
|
+
camera,
|
|
742
|
+
intents: [makeIntent('masked', 1, false), makeIntent('visible', 2, true)],
|
|
743
|
+
},
|
|
744
|
+
],
|
|
745
|
+
frameNumber,
|
|
746
|
+
}) as never;
|
|
747
|
+
const context = { targets: [], caps: {}, frame: { frameNumber: 1 }, generation: 1 } as never;
|
|
748
|
+
const first = feature.plan(frame(1), context);
|
|
749
|
+
expect(first.ok).toBe(true);
|
|
750
|
+
if (!first.ok) return;
|
|
751
|
+
const firstProgram = first.value.resources.find(
|
|
752
|
+
(resource) => resource.kind === 'compute-program',
|
|
753
|
+
);
|
|
754
|
+
expect(firstProgram?.name).toContain('.visible.g-1.');
|
|
755
|
+
feature.onFrameAborted?.(frame(1));
|
|
756
|
+
|
|
757
|
+
const retry = feature.plan(frame(1), context);
|
|
758
|
+
expect(retry.ok).toBe(true);
|
|
759
|
+
if (!retry.ok) return;
|
|
760
|
+
const retryProgram = retry.value.resources.find(
|
|
761
|
+
(resource) => resource.kind === 'compute-program',
|
|
762
|
+
);
|
|
763
|
+
expect(retryProgram?.name).toBe(firstProgram?.name);
|
|
764
|
+
feature.onFrameSubmitted?.(frame(1));
|
|
765
|
+
expect(commits).toEqual([[1, 2]]);
|
|
766
|
+
expect(dispatched).toBe(1);
|
|
436
767
|
});
|
|
437
768
|
|
|
438
|
-
it('keeps
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
769
|
+
it('keeps a reset after an earlier tick on retry and isolates reservations by World', () => {
|
|
770
|
+
const camera = {
|
|
771
|
+
position: new Float32Array(3),
|
|
772
|
+
right: new Float32Array([1, 0, 0]),
|
|
773
|
+
up: new Float32Array([0, 1, 0]),
|
|
774
|
+
viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
|
775
|
+
};
|
|
776
|
+
const makeIntent = (
|
|
777
|
+
player: VfxGpuTickIntent['player'],
|
|
778
|
+
id: string,
|
|
779
|
+
sequence: number,
|
|
780
|
+
reset: boolean,
|
|
781
|
+
requiresDataInterface = false,
|
|
782
|
+
): VfxGpuTickIntent => ({
|
|
783
|
+
sequence,
|
|
784
|
+
player,
|
|
785
|
+
emitter: {
|
|
786
|
+
id,
|
|
787
|
+
module: `${id}.vfx.wgsl`,
|
|
788
|
+
capacity: 4,
|
|
789
|
+
backend: { required: 'gpu' },
|
|
790
|
+
space: 'world',
|
|
791
|
+
simulationWhenCulled: 'continue',
|
|
792
|
+
schedule: { rate: 0, bursts: [] },
|
|
793
|
+
bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
|
|
794
|
+
wgsl: `// ${id}`,
|
|
795
|
+
reflection: {
|
|
796
|
+
hooks: ['vfx_spawn', 'vfx_update'],
|
|
797
|
+
imports: [],
|
|
798
|
+
resources: [],
|
|
799
|
+
entryPoints: ['forgeax_vfx_spawn_main'],
|
|
800
|
+
bindings: [],
|
|
801
|
+
...(requiresDataInterface
|
|
802
|
+
? {
|
|
803
|
+
dataInterfaces: [
|
|
804
|
+
{
|
|
805
|
+
token: 'vfx:camera',
|
|
806
|
+
kind: 'camera' as const,
|
|
807
|
+
binding: 0,
|
|
808
|
+
bindingType: 'uniform' as const,
|
|
809
|
+
lifetime: 'generation' as const,
|
|
810
|
+
},
|
|
811
|
+
],
|
|
812
|
+
}
|
|
813
|
+
: {}),
|
|
814
|
+
} as never,
|
|
815
|
+
renderers: [],
|
|
816
|
+
},
|
|
817
|
+
programFingerprint: `${id}-program`,
|
|
818
|
+
reset,
|
|
819
|
+
fixedDelta: 1 / 60,
|
|
820
|
+
phaseTick: sequence,
|
|
821
|
+
tick: sequence,
|
|
822
|
+
seed: 1,
|
|
823
|
+
playCycle: 0,
|
|
824
|
+
spawnCount: 1,
|
|
825
|
+
firstParticleId: 0,
|
|
826
|
+
instanceGeneration: 0,
|
|
827
|
+
instancePatchCount: 0,
|
|
828
|
+
parameterBlock: new Uint8Array(),
|
|
829
|
+
canonicalPayload: new Uint8Array(),
|
|
830
|
+
replayInput: {
|
|
831
|
+
seed: 1,
|
|
832
|
+
tick: sequence,
|
|
833
|
+
generation: 0,
|
|
834
|
+
sequence,
|
|
835
|
+
fingerprint: `${id}-program`,
|
|
836
|
+
payload: new Uint8Array(),
|
|
837
|
+
values: {},
|
|
838
|
+
channelInputs: [],
|
|
839
|
+
droppedCount: 0,
|
|
840
|
+
},
|
|
841
|
+
channelInputs: [],
|
|
842
|
+
eventCounters: {
|
|
843
|
+
queued: 0,
|
|
844
|
+
produced: 0,
|
|
845
|
+
consumed: 0,
|
|
846
|
+
dropped: 0,
|
|
847
|
+
overflow: 0,
|
|
848
|
+
fanOut: 0,
|
|
849
|
+
recursionDepth: 0,
|
|
850
|
+
lastSequence: -1,
|
|
851
|
+
},
|
|
445
852
|
});
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
bindingType: 'sampled-depth',
|
|
453
|
-
lifetime: 'generation',
|
|
853
|
+
const makeRuntime = () => {
|
|
854
|
+
let enabled = true;
|
|
855
|
+
const commits: number[] = [];
|
|
856
|
+
return {
|
|
857
|
+
get enabled() {
|
|
858
|
+
return enabled;
|
|
454
859
|
},
|
|
455
|
-
|
|
456
|
-
|
|
860
|
+
set enabled(value: boolean) {
|
|
861
|
+
enabled = value;
|
|
862
|
+
},
|
|
863
|
+
renderGeneration: 1,
|
|
864
|
+
isEmitterSessionEnabled: () => enabled,
|
|
865
|
+
setEmitterCameraVisibility: () => {},
|
|
866
|
+
markEventDispatched: () => {},
|
|
867
|
+
commit: (sequence: number) => commits.push(sequence),
|
|
868
|
+
commits,
|
|
869
|
+
};
|
|
870
|
+
};
|
|
871
|
+
const world = new World();
|
|
872
|
+
const otherWorld = new World();
|
|
873
|
+
const player = world.spawn().unwrap();
|
|
874
|
+
const otherPlayer = otherWorld.spawn().unwrap();
|
|
875
|
+
const runtime = makeRuntime();
|
|
876
|
+
const otherRuntime = makeRuntime();
|
|
877
|
+
let dataReady = false;
|
|
878
|
+
const feature = gpuParticleRenderFeature({
|
|
879
|
+
camera: { read: () => camera },
|
|
880
|
+
dataInterfaces: {
|
|
881
|
+
resolve: () => (dataReady ? ({ ok: true } as never) : ({ ok: false } as never)),
|
|
882
|
+
},
|
|
457
883
|
});
|
|
458
|
-
|
|
884
|
+
const context = { targets: [], caps: {}, frame: { frameNumber: 1 }, generation: 1 } as never;
|
|
885
|
+
const frame = (
|
|
886
|
+
frameNumber: number,
|
|
887
|
+
entries: readonly { world: World; runtime: typeof runtime; intents: VfxGpuTickIntent[] }[],
|
|
888
|
+
) =>
|
|
889
|
+
({
|
|
890
|
+
worlds: entries.map((entry) => ({ ...entry, camera })),
|
|
891
|
+
frameNumber,
|
|
892
|
+
}) as never;
|
|
893
|
+
|
|
894
|
+
const first = feature.plan(
|
|
895
|
+
frame(1, [
|
|
896
|
+
{
|
|
897
|
+
world,
|
|
898
|
+
runtime,
|
|
899
|
+
intents: [
|
|
900
|
+
makeIntent(player, 'ordered', 1, false),
|
|
901
|
+
makeIntent(player, 'ordered', 2, true),
|
|
902
|
+
],
|
|
903
|
+
},
|
|
904
|
+
]),
|
|
905
|
+
context,
|
|
906
|
+
);
|
|
907
|
+
expect(first.ok).toBe(true);
|
|
908
|
+
if (!first.ok) return;
|
|
909
|
+
const programNames = first.value.resources
|
|
910
|
+
.filter((resource) => resource.kind === 'compute-program')
|
|
911
|
+
.map((resource) => resource.name);
|
|
912
|
+
expect(programNames).toEqual([
|
|
913
|
+
expect.stringContaining('.ordered.g-0.compute-program'),
|
|
914
|
+
expect.stringContaining('.ordered.g-1.compute-program'),
|
|
915
|
+
]);
|
|
916
|
+
const resetParticles = first.value.resources.find(
|
|
917
|
+
(resource) => resource.kind === 'buffer' && resource.name.includes('.ordered.g-1.particles'),
|
|
918
|
+
);
|
|
919
|
+
expect(resetParticles).toHaveProperty('data');
|
|
920
|
+
feature.onFrameAborted?.(frame(1, [{ world, runtime, intents: [] }]));
|
|
921
|
+
|
|
922
|
+
const retry = feature.plan(
|
|
923
|
+
frame(1, [
|
|
924
|
+
{
|
|
925
|
+
world,
|
|
926
|
+
runtime,
|
|
927
|
+
intents: [
|
|
928
|
+
makeIntent(player, 'ordered', 1, false),
|
|
929
|
+
makeIntent(player, 'ordered', 2, true),
|
|
930
|
+
],
|
|
931
|
+
},
|
|
932
|
+
]),
|
|
933
|
+
context,
|
|
934
|
+
);
|
|
935
|
+
expect(retry.ok).toBe(true);
|
|
936
|
+
if (!retry.ok) return;
|
|
937
|
+
expect(
|
|
938
|
+
retry.value.resources
|
|
939
|
+
.filter((resource) => resource.kind === 'compute-program')
|
|
940
|
+
.map((resource) => resource.name),
|
|
941
|
+
).toEqual(programNames);
|
|
942
|
+
const retryResetParticles = retry.value.resources.find(
|
|
943
|
+
(resource) => resource.kind === 'buffer' && resource.name.includes('.ordered.g-1.particles'),
|
|
944
|
+
);
|
|
945
|
+
expect(retryResetParticles).toHaveProperty('data');
|
|
946
|
+
feature.onFrameSubmitted?.(frame(1, [{ world, runtime, intents: [] }]));
|
|
947
|
+
expect(runtime.commits).toEqual([[1, 2]]);
|
|
948
|
+
|
|
949
|
+
// A reset blocked by render consumption must stay deferred. It must not
|
|
950
|
+
// advance the committed epoch or leak a fake GPU state into a later tick.
|
|
951
|
+
runtime.enabled = false;
|
|
952
|
+
const skipped = feature.plan(
|
|
953
|
+
frame(2, [{ world, runtime, intents: [makeIntent(player, 'ordered', 3, true)] }]),
|
|
954
|
+
context,
|
|
955
|
+
);
|
|
956
|
+
expect(skipped).toMatchObject({ ok: true, value: { resources: [], passes: [] } });
|
|
957
|
+
feature.onFrameSubmitted?.(frame(2, [{ world, runtime, intents: [] }]));
|
|
958
|
+
expect(runtime.commits).toEqual([[1, 2]]);
|
|
959
|
+
runtime.enabled = true;
|
|
960
|
+
const afterSkipped = feature.plan(
|
|
961
|
+
frame(3, [{ world, runtime, intents: [makeIntent(player, 'ordered', 4, false)] }]),
|
|
962
|
+
context,
|
|
963
|
+
);
|
|
964
|
+
expect(afterSkipped.ok).toBe(true);
|
|
965
|
+
if (!afterSkipped.ok) return;
|
|
966
|
+
expect(
|
|
967
|
+
afterSkipped.value.resources.some(
|
|
968
|
+
(resource) =>
|
|
969
|
+
resource.kind === 'compute-program' && resource.name.includes('.ordered.g-1.'),
|
|
970
|
+
),
|
|
971
|
+
).toBe(true);
|
|
972
|
+
feature.onFrameSubmitted?.(frame(3, [{ world, runtime, intents: [] }]));
|
|
973
|
+
|
|
974
|
+
// Two deferred reset intents retain distinct ordered epochs and both
|
|
975
|
+
// initialize when the provider becomes available.
|
|
976
|
+
const deferredIntents = [
|
|
977
|
+
makeIntent(player, 'ordered', 5, true, true),
|
|
978
|
+
makeIntent(player, 'ordered', 6, true, true),
|
|
979
|
+
];
|
|
980
|
+
const deferred = feature.plan(
|
|
981
|
+
frame(4, [{ world, runtime, intents: deferredIntents }]),
|
|
982
|
+
context,
|
|
983
|
+
);
|
|
984
|
+
expect(deferred).toMatchObject({ ok: true, value: { resources: [], passes: [] } });
|
|
985
|
+
feature.onFrameSubmitted?.(frame(4, [{ world, runtime, intents: [] }]));
|
|
986
|
+
dataReady = true;
|
|
987
|
+
const resumedDeferred = feature.plan(
|
|
988
|
+
frame(5, [{ world, runtime, intents: deferredIntents }]),
|
|
989
|
+
context,
|
|
990
|
+
);
|
|
991
|
+
expect(resumedDeferred.ok).toBe(true);
|
|
992
|
+
if (!resumedDeferred.ok) return;
|
|
993
|
+
const deferredPrograms = resumedDeferred.value.resources
|
|
994
|
+
.filter((resource) => resource.kind === 'compute-program')
|
|
995
|
+
.map((resource) => resource.name);
|
|
996
|
+
expect(deferredPrograms).toEqual([
|
|
997
|
+
expect.stringContaining('.ordered.g-2.compute-program'),
|
|
998
|
+
expect.stringContaining('.ordered.g-3.compute-program'),
|
|
999
|
+
]);
|
|
1000
|
+
expect(
|
|
1001
|
+
resumedDeferred.value.resources.filter(
|
|
1002
|
+
(resource) =>
|
|
1003
|
+
resource.kind === 'buffer' &&
|
|
1004
|
+
resource.name.includes('.ordered.g-2.particles') &&
|
|
1005
|
+
resource.data !== undefined,
|
|
1006
|
+
),
|
|
1007
|
+
).toHaveLength(1);
|
|
1008
|
+
expect(
|
|
1009
|
+
resumedDeferred.value.resources.filter(
|
|
1010
|
+
(resource) =>
|
|
1011
|
+
resource.kind === 'buffer' &&
|
|
1012
|
+
resource.name.includes('.ordered.g-3.particles') &&
|
|
1013
|
+
resource.data !== undefined,
|
|
1014
|
+
),
|
|
1015
|
+
).toHaveLength(1);
|
|
1016
|
+
feature.onFrameSubmitted?.(frame(5, [{ world, runtime, intents: [] }]));
|
|
1017
|
+
|
|
1018
|
+
// The other World starts at the same local sequence but must get its own
|
|
1019
|
+
// first reset epoch rather than inheriting this World's reservation.
|
|
1020
|
+
const other = feature.plan(
|
|
1021
|
+
frame(4, [
|
|
1022
|
+
{
|
|
1023
|
+
world: otherWorld,
|
|
1024
|
+
runtime: otherRuntime,
|
|
1025
|
+
intents: [makeIntent(otherPlayer, 'ordered', 1, true)],
|
|
1026
|
+
},
|
|
1027
|
+
]),
|
|
1028
|
+
context,
|
|
1029
|
+
);
|
|
1030
|
+
expect(other.ok).toBe(true);
|
|
1031
|
+
if (!other.ok) return;
|
|
1032
|
+
expect(
|
|
1033
|
+
other.value.resources.some(
|
|
1034
|
+
(resource) =>
|
|
1035
|
+
resource.kind === 'compute-program' && resource.name.includes('.ordered.g-1.'),
|
|
1036
|
+
),
|
|
1037
|
+
).toBe(true);
|
|
1038
|
+
|
|
1039
|
+
// Detaching and reattaching the same World creates a new runtime
|
|
1040
|
+
// incarnation. Its first reset may reuse the local player/emitter IDs,
|
|
1041
|
+
// but its GPU resource identity must not collide with the old attachment.
|
|
1042
|
+
const replacementRuntime = makeRuntime();
|
|
1043
|
+
const replacement = feature.plan(
|
|
1044
|
+
frame(6, [
|
|
1045
|
+
{
|
|
1046
|
+
world,
|
|
1047
|
+
runtime: replacementRuntime,
|
|
1048
|
+
intents: [makeIntent(player, 'ordered', 1, true)],
|
|
1049
|
+
},
|
|
1050
|
+
]),
|
|
1051
|
+
context,
|
|
1052
|
+
);
|
|
1053
|
+
expect(replacement.ok).toBe(true);
|
|
1054
|
+
if (replacement.ok) {
|
|
1055
|
+
const replacementProgram = replacement.value.resources.find(
|
|
1056
|
+
(resource) => resource.kind === 'compute-program',
|
|
1057
|
+
);
|
|
1058
|
+
expect(replacementProgram?.name).toContain('.ordered.g-1.');
|
|
1059
|
+
expect(replacementProgram?.name).not.toBe(programNames[0]);
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
it('defers a reset while player render consumption is paused', () => {
|
|
1064
|
+
const world = new World();
|
|
1065
|
+
const player = world.spawn().unwrap();
|
|
1066
|
+
const camera = {
|
|
1067
|
+
position: new Float32Array(3),
|
|
1068
|
+
right: new Float32Array([1, 0, 0]),
|
|
1069
|
+
up: new Float32Array([0, 1, 0]),
|
|
1070
|
+
viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
|
1071
|
+
};
|
|
1072
|
+
const intent: VfxGpuTickIntent = {
|
|
1073
|
+
sequence: 1,
|
|
1074
|
+
player,
|
|
1075
|
+
emitter: {
|
|
1076
|
+
id: 'paused-reset',
|
|
1077
|
+
module: 'paused-reset.vfx.wgsl',
|
|
1078
|
+
capacity: 4,
|
|
1079
|
+
backend: { required: 'gpu' },
|
|
1080
|
+
space: 'world',
|
|
1081
|
+
simulationWhenCulled: 'continue',
|
|
1082
|
+
schedule: { rate: 1, bursts: [] },
|
|
1083
|
+
bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
|
|
1084
|
+
wgsl: '// paused reset',
|
|
1085
|
+
reflection: {
|
|
1086
|
+
hooks: ['vfx_spawn', 'vfx_update'],
|
|
1087
|
+
imports: [],
|
|
1088
|
+
resources: [],
|
|
1089
|
+
entryPoints: ['forgeax_vfx_spawn_main'],
|
|
1090
|
+
bindings: [],
|
|
1091
|
+
} as never,
|
|
1092
|
+
renderers: [],
|
|
1093
|
+
},
|
|
1094
|
+
programFingerprint: 'paused-reset-program',
|
|
1095
|
+
reset: true,
|
|
1096
|
+
fixedDelta: 1 / 60,
|
|
1097
|
+
phaseTick: 0,
|
|
1098
|
+
tick: 1,
|
|
1099
|
+
seed: 2,
|
|
1100
|
+
playCycle: 1,
|
|
1101
|
+
spawnCount: 1,
|
|
1102
|
+
firstParticleId: 0,
|
|
1103
|
+
instanceGeneration: 0,
|
|
1104
|
+
instancePatchCount: 0,
|
|
1105
|
+
parameterBlock: new Uint8Array(),
|
|
1106
|
+
canonicalPayload: new Uint8Array(),
|
|
1107
|
+
replayInput: {
|
|
1108
|
+
seed: 2,
|
|
1109
|
+
tick: 1,
|
|
1110
|
+
generation: 0,
|
|
1111
|
+
sequence: 1,
|
|
1112
|
+
fingerprint: 'paused-reset-program',
|
|
1113
|
+
payload: new Uint8Array(),
|
|
1114
|
+
values: {},
|
|
1115
|
+
channelInputs: [],
|
|
1116
|
+
droppedCount: 0,
|
|
1117
|
+
},
|
|
1118
|
+
channelInputs: [],
|
|
1119
|
+
eventCounters: {
|
|
1120
|
+
queued: 0,
|
|
1121
|
+
produced: 0,
|
|
1122
|
+
consumed: 0,
|
|
1123
|
+
dropped: 0,
|
|
1124
|
+
overflow: 0,
|
|
1125
|
+
fanOut: 0,
|
|
1126
|
+
recursionDepth: 0,
|
|
1127
|
+
lastSequence: -1,
|
|
1128
|
+
},
|
|
1129
|
+
};
|
|
1130
|
+
let consumptionEnabled = false;
|
|
1131
|
+
const commits: { readonly sequence: number; readonly published: readonly number[] }[] = [];
|
|
1132
|
+
const runtime = {
|
|
1133
|
+
renderGeneration: 1,
|
|
1134
|
+
isEmitterSessionEnabled: () => true,
|
|
1135
|
+
setEmitterCameraVisibility: () => {},
|
|
1136
|
+
markEventDispatched: () => {},
|
|
1137
|
+
commit: (sequence: number, published: readonly number[] = []) =>
|
|
1138
|
+
commits.push({ sequence, published }),
|
|
1139
|
+
};
|
|
1140
|
+
const feature = gpuParticleRenderFeature({
|
|
1141
|
+
camera: { read: () => camera },
|
|
1142
|
+
playerConsumption: { isEnabled: () => consumptionEnabled },
|
|
1143
|
+
});
|
|
1144
|
+
const frame = (frameNumber: number) =>
|
|
1145
|
+
({ worlds: [{ world, runtime, camera, intents: [intent] }], frameNumber }) as never;
|
|
1146
|
+
|
|
1147
|
+
const paused = feature.plan(frame(1), {
|
|
1148
|
+
targets: [],
|
|
1149
|
+
caps: {},
|
|
1150
|
+
frame: { frameNumber: 1 },
|
|
1151
|
+
generation: 1,
|
|
1152
|
+
} as never);
|
|
1153
|
+
expect(paused).toMatchObject({ ok: true, value: { passes: [] } });
|
|
1154
|
+
feature.onFrameSubmitted?.({ worlds: [], frameNumber: 1 } as never);
|
|
1155
|
+
expect(commits).toEqual([]);
|
|
1156
|
+
|
|
1157
|
+
consumptionEnabled = true;
|
|
1158
|
+
const resumed = feature.plan(frame(2), {
|
|
1159
|
+
targets: [],
|
|
1160
|
+
caps: {},
|
|
1161
|
+
frame: { frameNumber: 2 },
|
|
1162
|
+
generation: 1,
|
|
1163
|
+
} as never);
|
|
1164
|
+
expect(resumed.ok).toBe(true);
|
|
1165
|
+
if (resumed.ok) {
|
|
1166
|
+
expect(resumed.value.resources.some((resource) => resource.kind === 'compute-program')).toBe(
|
|
1167
|
+
true,
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
feature.onFrameSubmitted?.({ worlds: [], frameNumber: 2 } as never);
|
|
1171
|
+
expect(commits).toEqual([{ sequence: [1], published: [1] }]);
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
it('keeps a paused reset barrier local to its player stream', () => {
|
|
1175
|
+
const world = new World();
|
|
1176
|
+
const pausedPlayer = world.spawn().unwrap();
|
|
1177
|
+
const healthyPlayer = world.spawn().unwrap();
|
|
1178
|
+
const camera = {
|
|
1179
|
+
position: new Float32Array(3),
|
|
1180
|
+
right: new Float32Array([1, 0, 0]),
|
|
1181
|
+
up: new Float32Array([0, 1, 0]),
|
|
1182
|
+
viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
|
1183
|
+
};
|
|
1184
|
+
const intent = (
|
|
1185
|
+
player: VfxGpuTickIntent['player'],
|
|
1186
|
+
id: string,
|
|
1187
|
+
sequence: number,
|
|
1188
|
+
reset: boolean,
|
|
1189
|
+
): VfxGpuTickIntent => ({
|
|
1190
|
+
sequence,
|
|
1191
|
+
player,
|
|
1192
|
+
emitter: {
|
|
1193
|
+
id,
|
|
1194
|
+
module: `${id}.vfx.wgsl`,
|
|
1195
|
+
capacity: 4,
|
|
1196
|
+
backend: { required: 'gpu' },
|
|
1197
|
+
space: 'world',
|
|
1198
|
+
simulationWhenCulled: 'continue',
|
|
1199
|
+
schedule: { rate: 0, bursts: [] },
|
|
1200
|
+
bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
|
|
1201
|
+
wgsl: `// ${id}`,
|
|
1202
|
+
reflection: {
|
|
1203
|
+
hooks: ['vfx_spawn', 'vfx_update'],
|
|
1204
|
+
imports: [],
|
|
1205
|
+
resources: [],
|
|
1206
|
+
entryPoints: ['forgeax_vfx_spawn_main'],
|
|
1207
|
+
bindings: [],
|
|
1208
|
+
} as never,
|
|
1209
|
+
renderers: [],
|
|
1210
|
+
},
|
|
1211
|
+
programFingerprint: id,
|
|
1212
|
+
reset,
|
|
1213
|
+
fixedDelta: 1 / 60,
|
|
1214
|
+
phaseTick: sequence,
|
|
1215
|
+
tick: sequence,
|
|
1216
|
+
seed: 1,
|
|
1217
|
+
playCycle: 0,
|
|
1218
|
+
spawnCount: 1,
|
|
1219
|
+
firstParticleId: 0,
|
|
1220
|
+
instanceGeneration: 0,
|
|
1221
|
+
instancePatchCount: 0,
|
|
1222
|
+
parameterBlock: new Uint8Array(),
|
|
1223
|
+
canonicalPayload: new Uint8Array(),
|
|
1224
|
+
replayInput: {
|
|
1225
|
+
seed: 1,
|
|
1226
|
+
tick: sequence,
|
|
1227
|
+
generation: 0,
|
|
1228
|
+
sequence,
|
|
1229
|
+
fingerprint: id,
|
|
1230
|
+
payload: new Uint8Array(),
|
|
1231
|
+
values: {},
|
|
1232
|
+
channelInputs: [],
|
|
1233
|
+
droppedCount: 0,
|
|
1234
|
+
},
|
|
1235
|
+
channelInputs: [],
|
|
1236
|
+
eventCounters: {
|
|
1237
|
+
queued: 0,
|
|
1238
|
+
produced: 0,
|
|
1239
|
+
consumed: 0,
|
|
1240
|
+
dropped: 0,
|
|
1241
|
+
overflow: 0,
|
|
1242
|
+
fanOut: 0,
|
|
1243
|
+
recursionDepth: 0,
|
|
1244
|
+
lastSequence: -1,
|
|
1245
|
+
},
|
|
1246
|
+
});
|
|
1247
|
+
const runtime = {
|
|
1248
|
+
renderGeneration: 0,
|
|
1249
|
+
isEmitterSessionEnabled: () => true,
|
|
1250
|
+
setEmitterCameraVisibility: () => {},
|
|
1251
|
+
markEventDispatched: () => {},
|
|
1252
|
+
commit: () => {},
|
|
1253
|
+
};
|
|
1254
|
+
const feature = gpuParticleRenderFeature({
|
|
1255
|
+
camera: { read: () => camera },
|
|
1256
|
+
playerConsumption: {
|
|
1257
|
+
isEnabled: (_world, player) => player !== pausedPlayer,
|
|
1258
|
+
},
|
|
1259
|
+
});
|
|
1260
|
+
const planned = feature.plan(
|
|
1261
|
+
{
|
|
1262
|
+
worlds: [
|
|
1263
|
+
{
|
|
1264
|
+
world,
|
|
1265
|
+
runtime,
|
|
1266
|
+
camera,
|
|
1267
|
+
intents: [
|
|
1268
|
+
intent(pausedPlayer, 'paused', 1, true),
|
|
1269
|
+
intent(healthyPlayer, 'healthy', 2, true),
|
|
1270
|
+
],
|
|
1271
|
+
},
|
|
1272
|
+
],
|
|
1273
|
+
frameNumber: 1,
|
|
1274
|
+
} as never,
|
|
1275
|
+
{ targets: [], caps: {}, frame: { frameNumber: 1 }, generation: 1 } as never,
|
|
1276
|
+
);
|
|
1277
|
+
expect(planned.ok).toBe(true);
|
|
1278
|
+
if (!planned.ok) return;
|
|
1279
|
+
const computePrograms = planned.value.resources
|
|
1280
|
+
.filter((resource) => resource.kind === 'compute-program')
|
|
1281
|
+
.map((resource) => resource.name);
|
|
1282
|
+
expect(computePrograms).toEqual([expect.stringContaining('.healthy.g-1.')]);
|
|
1283
|
+
expect(planned.value.passes.some((pass) => pass.name.includes('.healthy.g-1.tick-0'))).toBe(
|
|
1284
|
+
true,
|
|
1285
|
+
);
|
|
1286
|
+
expect(planned.value.passes.some((pass) => pass.name.includes('.paused.g-1.tick-0'))).toBe(
|
|
1287
|
+
false,
|
|
1288
|
+
);
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
it('preserves a seed reset across a paused real runtime', async () => {
|
|
1292
|
+
const world = new World();
|
|
1293
|
+
const camera = {
|
|
1294
|
+
position: new Float32Array(3),
|
|
1295
|
+
right: new Float32Array([1, 0, 0]),
|
|
1296
|
+
up: new Float32Array([0, 1, 0]),
|
|
1297
|
+
viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
|
1298
|
+
};
|
|
1299
|
+
const effect = {
|
|
1300
|
+
guid: 'paused-seed-reset',
|
|
1301
|
+
kind: 'particle-effect',
|
|
1302
|
+
schemaVersion: 2,
|
|
1303
|
+
programFingerprint: 'sha256:paused-seed-reset',
|
|
1304
|
+
emitters: [{ id: 'reset', capacity: 4 }],
|
|
1305
|
+
program: {
|
|
1306
|
+
format: 'forgeax-vfx-program-2',
|
|
1307
|
+
fingerprint: 'sha256:paused-seed-reset',
|
|
1308
|
+
emitters: [
|
|
1309
|
+
{
|
|
1310
|
+
id: 'reset',
|
|
1311
|
+
module: 'paused-seed-reset.vfx.wgsl',
|
|
1312
|
+
capacity: 4,
|
|
1313
|
+
backend: { required: 'gpu' },
|
|
1314
|
+
space: 'world',
|
|
1315
|
+
simulationWhenCulled: 'continue',
|
|
1316
|
+
schedule: { rate: 1, bursts: [{ time: 0, count: 1 }] },
|
|
1317
|
+
bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
|
|
1318
|
+
wgsl: '// paused seed reset',
|
|
1319
|
+
reflection: {
|
|
1320
|
+
hooks: ['vfx_spawn', 'vfx_update'],
|
|
1321
|
+
imports: [],
|
|
1322
|
+
resources: [],
|
|
1323
|
+
entryPoints: ['forgeax_vfx_spawn_main'],
|
|
1324
|
+
bindings: [],
|
|
1325
|
+
},
|
|
1326
|
+
renderers: [],
|
|
1327
|
+
},
|
|
1328
|
+
],
|
|
1329
|
+
},
|
|
1330
|
+
} as unknown;
|
|
1331
|
+
const assets = {
|
|
1332
|
+
loaders: { registerPackLoader: () => {} },
|
|
1333
|
+
lookup: () => undefined,
|
|
1334
|
+
};
|
|
1335
|
+
const host = createVfxRuntimeHost({ camera: { read: () => camera } });
|
|
1336
|
+
expect(await host.attachWorld({ world, assets: assets as never })).toMatchObject({ ok: true });
|
|
1337
|
+
const handle = world.allocSharedRef('ParticleEffectAsset', asV3Effect(effect));
|
|
1338
|
+
const player = world
|
|
1339
|
+
.spawn({
|
|
1340
|
+
component: ParticleEffectPlayer,
|
|
1341
|
+
data: { effect: handle, playing: true, seed: 1, timeScale: 1 },
|
|
1342
|
+
})
|
|
1343
|
+
.unwrap();
|
|
1344
|
+
const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
|
|
1345
|
+
const extract = (frameNumber: number) => {
|
|
1346
|
+
const result = host.feature.extract?.({ worlds: [world], frameNumber } as never);
|
|
1347
|
+
expect(result?.ok).toBe(true);
|
|
1348
|
+
if (result === undefined || !result.ok) throw new Error('VFX extraction failed');
|
|
1349
|
+
return result.value;
|
|
1350
|
+
};
|
|
1351
|
+
const plan = (frameNumber: number) =>
|
|
1352
|
+
host.feature.plan?.(extract(frameNumber), {
|
|
1353
|
+
targets: [],
|
|
1354
|
+
caps: {},
|
|
1355
|
+
frame: { frameNumber },
|
|
1356
|
+
generation: 1,
|
|
1357
|
+
} as never);
|
|
1358
|
+
const submit = (frameNumber: number) =>
|
|
1359
|
+
host.feature.onFrameSubmitted?.({ worlds: [], frameNumber } as never);
|
|
1360
|
+
|
|
1361
|
+
world.update(1 / 60).unwrap();
|
|
1362
|
+
const first = plan(1);
|
|
1363
|
+
expect(first?.ok).toBe(true);
|
|
1364
|
+
submit(1);
|
|
1365
|
+
const firstCommitted = runtime.lastCommittedEmitter(player, 'reset');
|
|
1366
|
+
expect(firstCommitted).toMatchObject({ seed: 1, reset: true, phaseTick: 0 });
|
|
1367
|
+
|
|
1368
|
+
const control = host.acquireControl(world).unwrap();
|
|
1369
|
+
control.setPlayerRenderConsumption({ player, enabled: false }).unwrap();
|
|
1370
|
+
world.set(player, ParticleEffectPlayer, { seed: 2 }).unwrap();
|
|
1371
|
+
world.update(1 / 60).unwrap();
|
|
1372
|
+
const paused = plan(2);
|
|
1373
|
+
expect(paused).toMatchObject({ ok: true, value: { passes: [] } });
|
|
1374
|
+
submit(2);
|
|
1375
|
+
expect(runtime.lastCommittedEmitter(player, 'reset')).toBe(firstCommitted);
|
|
1376
|
+
expect(runtime.snapshot()[0]).toMatchObject({ seed: 2, reset: true, phaseTick: 0 });
|
|
1377
|
+
|
|
1378
|
+
control.setPlayerRenderConsumption({ player, enabled: true }).unwrap();
|
|
1379
|
+
const resumed = plan(3);
|
|
1380
|
+
expect(resumed?.ok).toBe(true);
|
|
1381
|
+
submit(3);
|
|
1382
|
+
expect(runtime.snapshot()).toHaveLength(0);
|
|
1383
|
+
expect(runtime.lastCommittedEmitter(player, 'reset')).toMatchObject({
|
|
1384
|
+
seed: 2,
|
|
1385
|
+
reset: true,
|
|
1386
|
+
phaseTick: 0,
|
|
1387
|
+
});
|
|
1388
|
+
await host.detachWorld({ world });
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
it('writes trail history once per fixed tick before the final projection', () => {
|
|
1392
|
+
const world = new World();
|
|
1393
|
+
const player = world.spawn().unwrap();
|
|
1394
|
+
const camera = {
|
|
1395
|
+
position: new Float32Array(3),
|
|
1396
|
+
right: new Float32Array([1, 0, 0]),
|
|
1397
|
+
up: new Float32Array([0, 1, 0]),
|
|
1398
|
+
viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
|
1399
|
+
};
|
|
1400
|
+
const renderer = {
|
|
1401
|
+
kind: 'trail' as const,
|
|
1402
|
+
material: 'trail-material',
|
|
1403
|
+
historyLength: 4,
|
|
1404
|
+
capacity: 2,
|
|
1405
|
+
width: 0.1,
|
|
1406
|
+
};
|
|
1407
|
+
const disabledRenderer = { ...renderer, enabled: false as const };
|
|
1408
|
+
const intent = (sequence: number, reset: boolean): VfxGpuTickIntent => ({
|
|
1409
|
+
sequence,
|
|
1410
|
+
player,
|
|
1411
|
+
emitter: {
|
|
1412
|
+
id: 'trail',
|
|
1413
|
+
module: 'trail.vfx.wgsl',
|
|
1414
|
+
capacity: 4,
|
|
1415
|
+
backend: { required: 'gpu' },
|
|
1416
|
+
space: 'world',
|
|
1417
|
+
simulationWhenCulled: 'continue',
|
|
1418
|
+
schedule: { rate: 0, bursts: [] },
|
|
1419
|
+
bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
|
|
1420
|
+
wgsl: '// trail',
|
|
1421
|
+
reflection: {
|
|
1422
|
+
hooks: ['vfx_spawn', 'vfx_update'],
|
|
1423
|
+
imports: [],
|
|
1424
|
+
resources: [],
|
|
1425
|
+
entryPoints: [
|
|
1426
|
+
'forgeax_vfx_spawn_main',
|
|
1427
|
+
'forgeax_vfx_trail_history_main',
|
|
1428
|
+
'forgeax_vfx_trail_main',
|
|
1429
|
+
],
|
|
1430
|
+
bindings: [
|
|
1431
|
+
{
|
|
1432
|
+
entries: [0, 1, 2, 3, 4, 5, 6, 8, 9].map((binding) => ({
|
|
1433
|
+
binding,
|
|
1434
|
+
visibility: 4,
|
|
1435
|
+
buffer: { type: binding === 1 ? 'uniform' : 'storage' },
|
|
1436
|
+
})),
|
|
1437
|
+
},
|
|
1438
|
+
],
|
|
1439
|
+
} as never,
|
|
1440
|
+
renderers: [renderer, disabledRenderer],
|
|
1441
|
+
},
|
|
1442
|
+
programFingerprint: 'trail-program',
|
|
1443
|
+
reset,
|
|
1444
|
+
fixedDelta: 1 / 60,
|
|
1445
|
+
phaseTick: sequence,
|
|
1446
|
+
tick: sequence,
|
|
1447
|
+
seed: 1,
|
|
1448
|
+
playCycle: 0,
|
|
1449
|
+
spawnCount: 1,
|
|
1450
|
+
firstParticleId: 0,
|
|
1451
|
+
instanceGeneration: 0,
|
|
1452
|
+
instancePatchCount: 0,
|
|
1453
|
+
parameterBlock: new Uint8Array(),
|
|
1454
|
+
canonicalPayload: new Uint8Array(),
|
|
1455
|
+
replayInput: {
|
|
1456
|
+
seed: 1,
|
|
1457
|
+
tick: sequence,
|
|
1458
|
+
generation: 0,
|
|
1459
|
+
sequence,
|
|
1460
|
+
fingerprint: 'trail-program',
|
|
1461
|
+
payload: new Uint8Array(),
|
|
1462
|
+
values: {},
|
|
1463
|
+
channelInputs: [],
|
|
1464
|
+
droppedCount: 0,
|
|
1465
|
+
},
|
|
1466
|
+
channelInputs: [],
|
|
1467
|
+
eventCounters: {
|
|
1468
|
+
queued: 0,
|
|
1469
|
+
produced: 0,
|
|
1470
|
+
consumed: 0,
|
|
1471
|
+
dropped: 0,
|
|
1472
|
+
overflow: 0,
|
|
1473
|
+
fanOut: 0,
|
|
1474
|
+
recursionDepth: 0,
|
|
1475
|
+
lastSequence: -1,
|
|
1476
|
+
},
|
|
1477
|
+
});
|
|
1478
|
+
const runtime = {
|
|
1479
|
+
renderGeneration: 1,
|
|
1480
|
+
isEmitterSessionEnabled: () => true,
|
|
1481
|
+
setEmitterCameraVisibility: () => {},
|
|
1482
|
+
markEventDispatched: () => {},
|
|
1483
|
+
commit: () => {},
|
|
1484
|
+
};
|
|
1485
|
+
const feature = gpuParticleRenderFeature({ camera: { read: () => camera } });
|
|
1486
|
+
const planned = feature.plan(
|
|
1487
|
+
{
|
|
1488
|
+
worlds: [{ world, runtime, camera, intents: [intent(1, true), intent(2, false)] }],
|
|
1489
|
+
frameNumber: 1,
|
|
1490
|
+
} as never,
|
|
1491
|
+
{ targets: [], caps: {}, frame: { frameNumber: 1 }, generation: 1 } as never,
|
|
1492
|
+
);
|
|
1493
|
+
expect(planned.ok).toBe(true);
|
|
1494
|
+
if (!planned.ok) return;
|
|
1495
|
+
const historyPasses = planned.value.passes.filter(
|
|
1496
|
+
(pass) => pass.kind === 'compute' && pass.name.endsWith('.history-bindings.write'),
|
|
1497
|
+
);
|
|
1498
|
+
expect(historyPasses).toHaveLength(2);
|
|
1499
|
+
expect(
|
|
1500
|
+
planned.value.resources.find(
|
|
1501
|
+
(resource) => resource.kind === 'buffer' && resource.name.endsWith('.renderer-0.history'),
|
|
1502
|
+
),
|
|
1503
|
+
).toMatchObject({ size: 4 * (4 + 1) * 16 });
|
|
1504
|
+
expect(
|
|
1505
|
+
planned.value.resources.some(
|
|
1506
|
+
(resource) => resource.kind === 'buffer' && resource.name.includes('.renderer-1.history'),
|
|
1507
|
+
),
|
|
1508
|
+
).toBe(false);
|
|
1509
|
+
expect(
|
|
1510
|
+
planned.value.passes.findIndex((pass) => pass.name.includes('.tick-0.simulate')),
|
|
1511
|
+
).toBeLessThan(
|
|
1512
|
+
planned.value.passes.findIndex((pass) =>
|
|
1513
|
+
pass.name.includes('.tick-0.renderer-0.history-bindings.write'),
|
|
1514
|
+
),
|
|
1515
|
+
);
|
|
1516
|
+
expect(
|
|
1517
|
+
planned.value.passes.findIndex((pass) =>
|
|
1518
|
+
pass.name.includes('.tick-0.renderer-0.history-bindings.write'),
|
|
1519
|
+
),
|
|
1520
|
+
).toBeLessThan(
|
|
1521
|
+
planned.value.passes.findIndex((pass) => pass.name.includes('.tick-1.simulate')),
|
|
1522
|
+
);
|
|
1523
|
+
expect(historyPasses.map((pass) => pass.name)).toEqual([
|
|
1524
|
+
expect.stringContaining('.tick-0.renderer-0.history-bindings.write'),
|
|
1525
|
+
expect.stringContaining('.tick-1.renderer-0.history-bindings.write'),
|
|
1526
|
+
]);
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
it('rejects a second host without replacing the first World runtime', async () => {
|
|
1530
|
+
const world = new World();
|
|
1531
|
+
const assets = {
|
|
1532
|
+
loaders: { registerPackLoader: () => {} },
|
|
1533
|
+
lookup: () => undefined,
|
|
1534
|
+
};
|
|
1535
|
+
const options = {
|
|
1536
|
+
camera: {
|
|
1537
|
+
read: () => undefined,
|
|
1538
|
+
},
|
|
1539
|
+
};
|
|
1540
|
+
const first = createVfxRuntimeHost(options);
|
|
1541
|
+
const second = createVfxRuntimeHost(options);
|
|
1542
|
+
expect(await first.attachWorld({ world, assets: assets as never })).toMatchObject({ ok: true });
|
|
1543
|
+
expect(await second.attachWorld({ world, assets: assets as never })).toMatchObject({
|
|
1544
|
+
ok: false,
|
|
1545
|
+
error: { code: 'vfx-host-world-attach-failed' },
|
|
1546
|
+
});
|
|
1547
|
+
expect(await first.detachWorld({ world })).toMatchObject({ ok: true });
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
it('keeps depth readiness on the host contract for a real renderer frame', () => {
|
|
1551
|
+
const host = createVfxRuntimeHost({
|
|
1552
|
+
camera: { read: () => undefined },
|
|
1553
|
+
providers: [
|
|
1554
|
+
createCameraProvider({ available: () => true }),
|
|
1555
|
+
createSceneDepthProvider({
|
|
1556
|
+
available: () => true,
|
|
1557
|
+
sampleCount: 1,
|
|
1558
|
+
resource: () => ({ kind: 'texture-view', value: {} }),
|
|
1559
|
+
}),
|
|
1560
|
+
],
|
|
1561
|
+
});
|
|
1562
|
+
const result = host.resolveDataInterfaces({
|
|
1563
|
+
requirements: [
|
|
1564
|
+
{
|
|
1565
|
+
token: 'vfx:scene-depth',
|
|
1566
|
+
kind: 'scene-depth',
|
|
1567
|
+
binding: 9,
|
|
1568
|
+
bindingType: 'sampled-depth',
|
|
1569
|
+
lifetime: 'generation',
|
|
1570
|
+
},
|
|
1571
|
+
],
|
|
1572
|
+
generation: 1,
|
|
1573
|
+
});
|
|
1574
|
+
expect(result).toMatchObject({ ok: true, value: { readiness: 'ready' } });
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
it('refreshes a paused emitter after the camera re-enters without a queued tick', async () => {
|
|
1578
|
+
const world = new World();
|
|
1579
|
+
const viewProjection = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
|
|
1580
|
+
const camera = {
|
|
1581
|
+
position: new Float32Array(3),
|
|
1582
|
+
right: new Float32Array([1, 0, 0]),
|
|
1583
|
+
up: new Float32Array([0, 1, 0]),
|
|
1584
|
+
viewProjection,
|
|
1585
|
+
};
|
|
1586
|
+
const effect = {
|
|
1587
|
+
guid: 'culling-effect',
|
|
1588
|
+
kind: 'particle-effect',
|
|
1589
|
+
schemaVersion: 2,
|
|
1590
|
+
programFingerprint: 'sha256:culling',
|
|
1591
|
+
emitters: [{ id: 'paused', capacity: 4 }],
|
|
1592
|
+
program: {
|
|
1593
|
+
format: 'forgeax-vfx-program-2',
|
|
1594
|
+
fingerprint: 'sha256:culling',
|
|
1595
|
+
emitters: [
|
|
1596
|
+
{
|
|
1597
|
+
id: 'paused',
|
|
1598
|
+
module: 'paused.vfx.wgsl',
|
|
1599
|
+
capacity: 4,
|
|
1600
|
+
backend: { required: 'gpu' },
|
|
1601
|
+
space: 'world',
|
|
1602
|
+
simulationWhenCulled: 'pause',
|
|
1603
|
+
schedule: { rate: 1, bursts: [] },
|
|
1604
|
+
bounds: { kind: 'sphere', center: [10, 0, 0], radius: 0.25 },
|
|
1605
|
+
wgsl: '// paused',
|
|
1606
|
+
reflection: {
|
|
1607
|
+
hooks: ['vfx_spawn', 'vfx_update'],
|
|
1608
|
+
imports: [],
|
|
1609
|
+
resources: [],
|
|
1610
|
+
entryPoints: ['forgeax_vfx_spawn_main'],
|
|
1611
|
+
bindings: [],
|
|
1612
|
+
},
|
|
1613
|
+
renderers: [],
|
|
1614
|
+
},
|
|
1615
|
+
],
|
|
1616
|
+
},
|
|
1617
|
+
} as unknown;
|
|
1618
|
+
const handle = world.allocSharedRef('ParticleEffectAsset', asV3Effect(effect));
|
|
1619
|
+
const player = world
|
|
1620
|
+
.spawn({
|
|
1621
|
+
component: ParticleEffectPlayer,
|
|
1622
|
+
data: { effect: handle, playing: true, seed: 3, timeScale: 1 },
|
|
1623
|
+
})
|
|
1624
|
+
.unwrap();
|
|
1625
|
+
await createWorldContext(world, [vfxGpuRuntimePlugin()]);
|
|
1626
|
+
const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
|
|
1627
|
+
const feature = gpuParticleRenderFeature({ camera: { read: () => camera } });
|
|
1628
|
+
const extract = (frameNumber: number) => {
|
|
1629
|
+
const result = feature.extract?.({ worlds: [world], frameNumber } as never);
|
|
1630
|
+
expect(result?.ok).toBe(true);
|
|
1631
|
+
if (result === undefined || !result.ok) throw new Error('VFX extraction failed');
|
|
1632
|
+
return result.value;
|
|
1633
|
+
};
|
|
1634
|
+
const plan = (frameNumber: number) =>
|
|
1635
|
+
feature.plan?.(extract(frameNumber), {
|
|
1636
|
+
targets: [],
|
|
1637
|
+
caps: {},
|
|
1638
|
+
frame: { frameNumber },
|
|
1639
|
+
generation: 1,
|
|
1640
|
+
} as never);
|
|
1641
|
+
|
|
1642
|
+
world.update(1 / 60).unwrap();
|
|
1643
|
+
const hidden = plan(1);
|
|
1644
|
+
expect(hidden).toMatchObject({ ok: true, value: { resources: [], passes: [] } });
|
|
1645
|
+
feature.onFrameSubmitted?.({ worlds: [], frameNumber: 1 } as never);
|
|
1646
|
+
// The reset was discovered after FixedUpdate while the emitter was
|
|
1647
|
+
// culled, so it remains queued rather than being acknowledged as a fake
|
|
1648
|
+
// GPU state.
|
|
1649
|
+
expect(runtime.snapshot().length).toBeGreaterThan(0);
|
|
1650
|
+
|
|
1651
|
+
viewProjection[12] = -10;
|
|
1652
|
+
const visibleWithoutTick = extract(2);
|
|
1653
|
+
expect(runtime.inspectPlayer(player)?.emitters[0]).toMatchObject({ cameraVisible: true });
|
|
1654
|
+
// The reset discovered while hidden remains queued until this re-entry;
|
|
1655
|
+
// visibility refresh must not consume it as a skipped state.
|
|
1656
|
+
expect(visibleWithoutTick.worlds[0]?.intents.length).toBeGreaterThan(0);
|
|
1657
|
+
world.update(1 / 60).unwrap();
|
|
1658
|
+
const resumed = plan(3);
|
|
1659
|
+
expect(resumed?.ok).toBe(true);
|
|
1660
|
+
if (resumed?.ok) expect(resumed.value.passes.length).toBeGreaterThan(0);
|
|
1661
|
+
});
|
|
1662
|
+
|
|
1663
|
+
it('retains committed particles for render frames without a fixed tick', async () => {
|
|
1664
|
+
const world = new World();
|
|
1665
|
+
const viewProjection = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
|
|
1666
|
+
const camera = {
|
|
1667
|
+
position: new Float32Array(3),
|
|
1668
|
+
right: new Float32Array([1, 0, 0]),
|
|
1669
|
+
up: new Float32Array([0, 1, 0]),
|
|
1670
|
+
viewProjection,
|
|
1671
|
+
};
|
|
1672
|
+
const effect = {
|
|
1673
|
+
guid: 'retained-effect',
|
|
1674
|
+
kind: 'particle-effect',
|
|
1675
|
+
schemaVersion: 2,
|
|
1676
|
+
programFingerprint: 'sha256:retained',
|
|
1677
|
+
emitters: [{ id: 'retained', capacity: 4 }],
|
|
1678
|
+
program: {
|
|
1679
|
+
format: 'forgeax-vfx-program-2',
|
|
1680
|
+
fingerprint: 'sha256:retained',
|
|
1681
|
+
emitters: [
|
|
1682
|
+
{
|
|
1683
|
+
id: 'retained',
|
|
1684
|
+
module: 'retained.vfx.wgsl',
|
|
1685
|
+
capacity: 4,
|
|
1686
|
+
backend: { required: 'gpu' },
|
|
1687
|
+
space: 'world',
|
|
1688
|
+
simulationWhenCulled: 'pause',
|
|
1689
|
+
schedule: { rate: 1, bursts: [] },
|
|
1690
|
+
bounds: { kind: 'sphere', center: [0, 0, 0], radius: 0.25 },
|
|
1691
|
+
wgsl: '// retained',
|
|
1692
|
+
reflection: {
|
|
1693
|
+
hooks: ['vfx_spawn', 'vfx_update'],
|
|
1694
|
+
imports: [],
|
|
1695
|
+
resources: [],
|
|
1696
|
+
entryPoints: ['forgeax_vfx_spawn_main', 'forgeax_vfx_billboard_main'],
|
|
1697
|
+
bindings: [],
|
|
1698
|
+
},
|
|
1699
|
+
renderers: [{ kind: 'billboard', material: 'retained-material' }],
|
|
1700
|
+
},
|
|
1701
|
+
],
|
|
1702
|
+
},
|
|
1703
|
+
} as unknown;
|
|
1704
|
+
const handle = world.allocSharedRef('ParticleEffectAsset', asV3Effect(effect));
|
|
1705
|
+
world
|
|
1706
|
+
.spawn({
|
|
1707
|
+
component: ParticleEffectPlayer,
|
|
1708
|
+
data: { effect: handle, playing: true, seed: 7, timeScale: 1 },
|
|
1709
|
+
})
|
|
1710
|
+
.unwrap();
|
|
1711
|
+
await createWorldContext(world, [vfxGpuRuntimePlugin()]);
|
|
1712
|
+
const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
|
|
1713
|
+
const feature = gpuParticleRenderFeature({ camera: { read: () => camera } });
|
|
1714
|
+
const extract = (frameNumber: number) => {
|
|
1715
|
+
const result = feature.extract?.({ worlds: [world], frameNumber } as never);
|
|
1716
|
+
expect(result?.ok).toBe(true);
|
|
1717
|
+
if (result === undefined || !result.ok) throw new Error('VFX extraction failed');
|
|
1718
|
+
return result.value;
|
|
1719
|
+
};
|
|
1720
|
+
const plan = (frameNumber: number) =>
|
|
1721
|
+
feature.plan?.(extract(frameNumber), {
|
|
1722
|
+
targets: [],
|
|
1723
|
+
caps: {},
|
|
1724
|
+
frame: { frameNumber },
|
|
1725
|
+
generation: 1,
|
|
1726
|
+
} as never);
|
|
1727
|
+
const submit = (frameNumber: number) =>
|
|
1728
|
+
feature.onFrameSubmitted?.({ worlds: [], frameNumber } as never);
|
|
1729
|
+
|
|
1730
|
+
world.update(1 / 60).unwrap();
|
|
1731
|
+
const first = plan(1);
|
|
1732
|
+
expect(first?.ok).toBe(true);
|
|
1733
|
+
if (first?.ok) {
|
|
1734
|
+
expect(first.value.passes.some((pass) => pass.name.endsWith('.raster'))).toBe(true);
|
|
1735
|
+
expect(first.value.passes.some((pass) => pass.name.endsWith('.simulate'))).toBe(true);
|
|
1736
|
+
}
|
|
1737
|
+
submit(1);
|
|
1738
|
+
expect(runtime.snapshot()).toHaveLength(0);
|
|
1739
|
+
|
|
1740
|
+
// A render-only frame has no new FixedUpdate intent, but the committed
|
|
1741
|
+
// particle buffer must still be projected and drawn.
|
|
1742
|
+
const retained = plan(2);
|
|
1743
|
+
expect(retained?.ok).toBe(true);
|
|
1744
|
+
if (retained?.ok) {
|
|
1745
|
+
expect(retained.value.resources.length).toBeGreaterThan(0);
|
|
1746
|
+
expect(retained.value.passes.some((pass) => pass.name.endsWith('.project'))).toBe(true);
|
|
1747
|
+
expect(retained.value.passes.some((pass) => pass.name.endsWith('.raster'))).toBe(true);
|
|
1748
|
+
expect(retained.value.passes.some((pass) => pass.name.endsWith('.simulate'))).toBe(false);
|
|
1749
|
+
}
|
|
1750
|
+
submit(2);
|
|
1751
|
+
|
|
1752
|
+
// Parking out of view for more than the old eight-frame owner window must
|
|
1753
|
+
// retain the buffers; re-entry can draw the same committed state.
|
|
1754
|
+
viewProjection[12] = -10;
|
|
1755
|
+
for (let frameNumber = 3; frameNumber <= 11; frameNumber += 1) {
|
|
1756
|
+
const hidden = plan(frameNumber);
|
|
1757
|
+
expect(hidden?.ok).toBe(true);
|
|
1758
|
+
if (hidden?.ok) {
|
|
1759
|
+
expect(hidden.value.resources.length).toBeGreaterThan(0);
|
|
1760
|
+
expect(hidden.value.passes.some((pass) => pass.name.endsWith('.raster'))).toBe(false);
|
|
1761
|
+
}
|
|
1762
|
+
submit(frameNumber);
|
|
1763
|
+
}
|
|
1764
|
+
viewProjection[12] = 0;
|
|
1765
|
+
const reentered = plan(12);
|
|
1766
|
+
expect(reentered?.ok).toBe(true);
|
|
1767
|
+
if (reentered?.ok)
|
|
1768
|
+
expect(reentered.value.passes.some((pass) => pass.name.endsWith('.raster'))).toBe(true);
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
it('acknowledges a healthy player while another player reset remains deferred', async () => {
|
|
1772
|
+
const world = new World();
|
|
1773
|
+
const camera = {
|
|
1774
|
+
position: new Float32Array(3),
|
|
1775
|
+
right: new Float32Array([1, 0, 0]),
|
|
1776
|
+
up: new Float32Array([0, 1, 0]),
|
|
1777
|
+
viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
|
1778
|
+
};
|
|
1779
|
+
const effect = {
|
|
1780
|
+
guid: 'cross-player-effect',
|
|
1781
|
+
kind: 'particle-effect',
|
|
1782
|
+
schemaVersion: 2,
|
|
1783
|
+
programFingerprint: 'sha256:cross-player',
|
|
1784
|
+
emitters: [{ id: 'stream', capacity: 8 }],
|
|
1785
|
+
program: {
|
|
1786
|
+
format: 'forgeax-vfx-program-2',
|
|
1787
|
+
fingerprint: 'sha256:cross-player',
|
|
1788
|
+
emitters: [
|
|
1789
|
+
{
|
|
1790
|
+
id: 'stream',
|
|
1791
|
+
module: 'cross-player.vfx.wgsl',
|
|
1792
|
+
capacity: 8,
|
|
1793
|
+
backend: { required: 'gpu' },
|
|
1794
|
+
space: 'world',
|
|
1795
|
+
simulationWhenCulled: 'continue',
|
|
1796
|
+
schedule: { rate: 60, bursts: [{ time: 0, count: 1 }] },
|
|
1797
|
+
bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
|
|
1798
|
+
wgsl: '// cross-player',
|
|
1799
|
+
reflection: {
|
|
1800
|
+
hooks: ['vfx_spawn', 'vfx_update'],
|
|
1801
|
+
imports: [],
|
|
1802
|
+
resources: [],
|
|
1803
|
+
entryPoints: ['forgeax_vfx_spawn_main'],
|
|
1804
|
+
bindings: [],
|
|
1805
|
+
},
|
|
1806
|
+
renderers: [],
|
|
1807
|
+
},
|
|
1808
|
+
],
|
|
1809
|
+
},
|
|
1810
|
+
} as unknown;
|
|
1811
|
+
const handle = world.allocSharedRef('ParticleEffectAsset', asV3Effect(effect));
|
|
1812
|
+
const pausedPlayer = world
|
|
1813
|
+
.spawn({
|
|
1814
|
+
component: ParticleEffectPlayer,
|
|
1815
|
+
data: { effect: handle, playing: true, seed: 1, timeScale: 1 },
|
|
1816
|
+
})
|
|
1817
|
+
.unwrap();
|
|
1818
|
+
const healthyPlayer = world
|
|
1819
|
+
.spawn({
|
|
1820
|
+
component: ParticleEffectPlayer,
|
|
1821
|
+
data: { effect: handle, playing: true, seed: 2, timeScale: 1 },
|
|
1822
|
+
})
|
|
1823
|
+
.unwrap();
|
|
1824
|
+
await createWorldContext(world, [vfxGpuRuntimePlugin()]);
|
|
1825
|
+
const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
|
|
1826
|
+
const feature = gpuParticleRenderFeature({
|
|
1827
|
+
camera: { read: () => camera },
|
|
1828
|
+
playerConsumption: { isEnabled: (_currentWorld, player) => player !== pausedPlayer },
|
|
1829
|
+
});
|
|
1830
|
+
const frame = (frameNumber: number) => {
|
|
1831
|
+
const extracted = feature.extract?.({ worlds: [world], frameNumber } as never);
|
|
1832
|
+
expect(extracted?.ok).toBe(true);
|
|
1833
|
+
if (extracted === undefined || !extracted.ok) throw new Error('VFX extraction failed');
|
|
1834
|
+
const planned = feature.plan?.(extracted.value, {
|
|
1835
|
+
targets: [],
|
|
1836
|
+
caps: {},
|
|
1837
|
+
frame: { frameNumber },
|
|
1838
|
+
generation: 1,
|
|
1839
|
+
} as never);
|
|
1840
|
+
expect(planned?.ok).toBe(true);
|
|
1841
|
+
feature.onFrameSubmitted?.({ worlds: [], frameNumber } as never);
|
|
1842
|
+
};
|
|
1843
|
+
|
|
1844
|
+
// The paused player's first reset is a real deferred intent. Keep
|
|
1845
|
+
// producing both streams beyond the queue bound while the healthy stream
|
|
1846
|
+
// must continue to submit and be selectively acknowledged.
|
|
1847
|
+
const healthyTicks: number[] = [];
|
|
1848
|
+
for (let frameNumber = 1; frameNumber <= 12; frameNumber += 1) {
|
|
1849
|
+
world.update(1 / 60).unwrap();
|
|
1850
|
+
frame(frameNumber);
|
|
1851
|
+
const healthySnapshot = runtime.inspectPlayer(healthyPlayer);
|
|
1852
|
+
expect(healthySnapshot?.queuedIntents).toBe(0);
|
|
1853
|
+
const committedTick = healthySnapshot?.lastCommitted?.tick;
|
|
1854
|
+
if (committedTick !== undefined) healthyTicks.push(committedTick);
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
const paused = runtime.inspectPlayer(pausedPlayer);
|
|
1858
|
+
const healthy = runtime.inspectPlayer(healthyPlayer);
|
|
1859
|
+
expect(paused?.queuedIntents).toBeGreaterThan(0);
|
|
1860
|
+
expect(healthy?.queuedIntents).toBe(0);
|
|
1861
|
+
expect(healthyTicks).toHaveLength(12);
|
|
1862
|
+
expect(
|
|
1863
|
+
healthyTicks.every((tick, index) => {
|
|
1864
|
+
const prior = healthyTicks[index - 1];
|
|
1865
|
+
return index === 0 || (prior !== undefined && tick > prior);
|
|
1866
|
+
}),
|
|
1867
|
+
).toBe(true);
|
|
1868
|
+
expect(
|
|
1869
|
+
healthy?.diagnostics.some((diagnostic) => diagnostic.code === 'vfx-intent-queue-overflow'),
|
|
1870
|
+
).toBe(false);
|
|
459
1871
|
});
|
|
460
1872
|
|
|
461
1873
|
it('returns an empty keyed inspection aggregate before a player is present', async () => {
|