@forgeax/engine-vfx-render 0.1.28 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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
|
@@ -2,10 +2,12 @@ import type { MaterialAsset, MeshAsset } from '@forgeax/engine-types';
|
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
3
|
import {
|
|
4
4
|
canonicalMeshVertices,
|
|
5
|
+
canonicalMeshVerticesCached,
|
|
5
6
|
particleMaterialPass,
|
|
6
7
|
particleMaterialSceneDepthBinding,
|
|
7
8
|
particleMaterialUsesBindings,
|
|
8
9
|
particleRendererRenderState,
|
|
10
|
+
prepareParticleMaterialInputs,
|
|
9
11
|
} from '../feature/particle-resources.js';
|
|
10
12
|
|
|
11
13
|
describe('particle mesh resources', () => {
|
|
@@ -22,6 +24,37 @@ describe('particle mesh resources', () => {
|
|
|
22
24
|
|
|
23
25
|
expect(canonicalMeshVertices(mesh)).toBe(vertices);
|
|
24
26
|
});
|
|
27
|
+
|
|
28
|
+
it('reuses derived vertices only for frozen asset publications', () => {
|
|
29
|
+
const mesh = Object.freeze({
|
|
30
|
+
kind: 'mesh' as const,
|
|
31
|
+
vertices: new Float32Array(),
|
|
32
|
+
attributes: { position: new Float32Array([0, 1, 2]) },
|
|
33
|
+
indices: new Uint16Array([0]),
|
|
34
|
+
submeshes: [],
|
|
35
|
+
materialSlots: [],
|
|
36
|
+
}) as unknown as MeshAsset;
|
|
37
|
+
const first = canonicalMeshVerticesCached(mesh);
|
|
38
|
+
const second = canonicalMeshVerticesCached(mesh);
|
|
39
|
+
expect(second).toBe(first);
|
|
40
|
+
expect(Array.from(second)).toEqual([0, 1, 2, 0, 0, 1, 0, 0, 1, 0, 0, 1]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('does not hide mutations on unfrozen authoring meshes', () => {
|
|
44
|
+
const mesh = {
|
|
45
|
+
kind: 'mesh' as const,
|
|
46
|
+
vertices: new Float32Array(),
|
|
47
|
+
attributes: { position: new Float32Array([0, 1, 2]) },
|
|
48
|
+
indices: new Uint16Array([0]),
|
|
49
|
+
submeshes: [],
|
|
50
|
+
materialSlots: [],
|
|
51
|
+
} as unknown as MeshAsset;
|
|
52
|
+
const first = canonicalMeshVerticesCached(mesh);
|
|
53
|
+
(mesh.attributes.position as Float32Array)[0] = 7;
|
|
54
|
+
const second = canonicalMeshVerticesCached(mesh);
|
|
55
|
+
expect(second).not.toBe(first);
|
|
56
|
+
expect(second[0]).toBe(7);
|
|
57
|
+
});
|
|
25
58
|
});
|
|
26
59
|
|
|
27
60
|
describe('particle material pass', () => {
|
|
@@ -36,6 +69,7 @@ describe('particle material pass', () => {
|
|
|
36
69
|
}),
|
|
37
70
|
).toBe(true);
|
|
38
71
|
});
|
|
72
|
+
|
|
39
73
|
it('uses the renderer-specific authored shader and render state', () => {
|
|
40
74
|
const material: MaterialAsset = {
|
|
41
75
|
kind: 'material',
|
|
@@ -73,6 +107,47 @@ describe('particle material pass', () => {
|
|
|
73
107
|
shader: 'forgeax::vfx-render.particles.mesh',
|
|
74
108
|
});
|
|
75
109
|
});
|
|
110
|
+
|
|
111
|
+
it('returns a structured missing-input error instead of dereferencing an absent declaration', () => {
|
|
112
|
+
expect(
|
|
113
|
+
prepareParticleMaterialInputs(
|
|
114
|
+
{
|
|
115
|
+
kind: 'billboard',
|
|
116
|
+
material: '019e9c00-0000-7000-8000-000000000003',
|
|
117
|
+
materialInputs: ['heat'],
|
|
118
|
+
},
|
|
119
|
+
{ kind: 'material', particleInputs: [] },
|
|
120
|
+
[],
|
|
121
|
+
),
|
|
122
|
+
).toMatchObject({
|
|
123
|
+
ok: false,
|
|
124
|
+
error: { code: 'vfx-material-input-missing', detail: { name: 'heat' } },
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('VFX Mesh world-space projection', () => {
|
|
130
|
+
it('adapts Standard geometry without discarding the ordinary material render state', () => {
|
|
131
|
+
const renderState = {
|
|
132
|
+
depthCompare: 'less-equal',
|
|
133
|
+
depthWriteEnabled: true,
|
|
134
|
+
queue: 2000,
|
|
135
|
+
} as const;
|
|
136
|
+
const material = {
|
|
137
|
+
kind: 'material',
|
|
138
|
+
passes: [
|
|
139
|
+
{
|
|
140
|
+
name: 'forward',
|
|
141
|
+
program: { module: 'forgeax::default-standard-pbr' },
|
|
142
|
+
renderState,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
} as MaterialAsset;
|
|
146
|
+
expect(particleMaterialPass('mesh', material)).toEqual({
|
|
147
|
+
shader: 'forgeax::vfx-render.particles.mesh',
|
|
148
|
+
renderState,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
76
151
|
});
|
|
77
152
|
|
|
78
153
|
describe('particle renderer blend defaults', () => {
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { createWorldContext, World } from '@forgeax/engine-ecs';
|
|
2
|
+
import type { ParticleEffectAsset } from '@forgeax/engine-types';
|
|
3
|
+
import {
|
|
4
|
+
ParticleEffectPlayer,
|
|
5
|
+
VFX_GPU_RUNTIME_RESOURCE_KEY,
|
|
6
|
+
type VfxGpuRuntime,
|
|
7
|
+
vfxGpuRuntimePlugin,
|
|
8
|
+
} from '@forgeax/engine-vfx';
|
|
9
|
+
import { cookParticleCodeProgram } from '@forgeax/engine-vfx-compiler';
|
|
10
|
+
import { expect, it } from 'vitest';
|
|
11
|
+
import { freezeRenderFeaturePlan } from '../../../render/src/features/plan.js';
|
|
12
|
+
import { gpuParticleRenderFeature } from '../feature/gpu-particle-feature.js';
|
|
13
|
+
|
|
14
|
+
it.each([
|
|
15
|
+
false,
|
|
16
|
+
true,
|
|
17
|
+
])('retains particles and acknowledges ordered work only after submission (events=%s)', async (events) => {
|
|
18
|
+
const result = await cookParticleCodeProgram(
|
|
19
|
+
{
|
|
20
|
+
schemaVersion: 3,
|
|
21
|
+
emitters: [
|
|
22
|
+
{
|
|
23
|
+
id: 'persistent',
|
|
24
|
+
capacity: 4,
|
|
25
|
+
backend: { required: 'gpu' },
|
|
26
|
+
space: 'world',
|
|
27
|
+
bounds: { kind: 'sphere', center: [0, 0, 0], radius: 10 },
|
|
28
|
+
schedule: { rate: 0, bursts: [{ time: 0, count: 1 }] },
|
|
29
|
+
program: { module: 'persistent.wgsl' },
|
|
30
|
+
renderers: [{ kind: 'billboard', material: 'material' }],
|
|
31
|
+
...(events
|
|
32
|
+
? {
|
|
33
|
+
channels: [{ id: 'hit', capacity: 2, overflow: 'drop-newest' }],
|
|
34
|
+
events: [
|
|
35
|
+
{
|
|
36
|
+
id: 'hit',
|
|
37
|
+
channel: 'hit',
|
|
38
|
+
subEmitter: 'persistent',
|
|
39
|
+
fanOut: 2,
|
|
40
|
+
recursionDepth: 1,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
}
|
|
44
|
+
: {}),
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
'persistent.wgsl': {
|
|
50
|
+
entry: `#import forgeax_vfx::prelude::{VfxParticle, VfxSpawnContext, VfxUpdateContext}
|
|
51
|
+
fn vfx_spawn(ctx: VfxSpawnContext, particle: ptr<function, VfxParticle>) { (*particle).lifetime = 86400.0; }
|
|
52
|
+
fn vfx_update(ctx: VfxUpdateContext, particle: ptr<function, VfxParticle>) { (*particle).position.x += ctx.delta; }`,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
if (!result.ok) throw result.error;
|
|
57
|
+
const asset: ParticleEffectAsset = {
|
|
58
|
+
kind: 'particle-effect',
|
|
59
|
+
schemaVersion: 3,
|
|
60
|
+
programFingerprint: result.value.fingerprint,
|
|
61
|
+
emitters: [{ id: 'persistent', capacity: 4 }],
|
|
62
|
+
program: { ...result.value.program, fingerprint: result.value.fingerprint },
|
|
63
|
+
};
|
|
64
|
+
const world = new World();
|
|
65
|
+
const handle = world.allocSharedRef('ParticleEffectAsset', { ...asset, guid: 'effect' });
|
|
66
|
+
const player = world
|
|
67
|
+
.spawn({
|
|
68
|
+
component: ParticleEffectPlayer,
|
|
69
|
+
data: { effect: handle, playing: true, seed: 1, timeScale: 1 },
|
|
70
|
+
})
|
|
71
|
+
.unwrap();
|
|
72
|
+
const context = await createWorldContext(world, [vfxGpuRuntimePlugin()]);
|
|
73
|
+
const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
|
|
74
|
+
const camera = {
|
|
75
|
+
position: new Float32Array(3),
|
|
76
|
+
right: new Float32Array([1, 0, 0]),
|
|
77
|
+
up: new Float32Array([0, 1, 0]),
|
|
78
|
+
viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
|
79
|
+
};
|
|
80
|
+
const feature = gpuParticleRenderFeature({ camera: { read: () => camera } });
|
|
81
|
+
const targets = [
|
|
82
|
+
{
|
|
83
|
+
name: 'color',
|
|
84
|
+
kind: 'color' as const,
|
|
85
|
+
format: 'rgba8unorm' as const,
|
|
86
|
+
sampleCount: 1 as const,
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
let frameNumber = 0;
|
|
90
|
+
const frame = (worlds = [world]) => {
|
|
91
|
+
const extracted = feature.extract({ worlds, owner: 0, frameNumber: ++frameNumber }).unwrap();
|
|
92
|
+
const plan = feature
|
|
93
|
+
.plan(extracted, {
|
|
94
|
+
targets,
|
|
95
|
+
caps: {} as never,
|
|
96
|
+
frame: { frameNumber },
|
|
97
|
+
generation: 0,
|
|
98
|
+
sceneData: {} as never,
|
|
99
|
+
})
|
|
100
|
+
.unwrap();
|
|
101
|
+
expect(freezeRenderFeaturePlan(feature.identity, plan, targets).ok).toBe(true);
|
|
102
|
+
return { extracted, plan };
|
|
103
|
+
};
|
|
104
|
+
const particleName = (plan: ReturnType<typeof frame>['plan']) =>
|
|
105
|
+
plan.resources.find((resource) => resource.name.endsWith('.particles'))?.name;
|
|
106
|
+
try {
|
|
107
|
+
world.update(1 / 60).unwrap();
|
|
108
|
+
const first = frame();
|
|
109
|
+
const entries = first.plan.passes.flatMap((pass) =>
|
|
110
|
+
pass.kind === 'compute' ? pass.dispatches.map((dispatch) => dispatch.entryPoint) : [],
|
|
111
|
+
);
|
|
112
|
+
const eventIndex = entries.indexOf('forgeax_vfx_event_main');
|
|
113
|
+
if (events) {
|
|
114
|
+
expect(eventIndex).toBeGreaterThan(0);
|
|
115
|
+
expect(entries.slice(eventIndex + 1, eventIndex + 5)).toEqual([
|
|
116
|
+
'forgeax_vfx_scan_blocks_main',
|
|
117
|
+
'forgeax_vfx_scan_block_offsets_main',
|
|
118
|
+
'forgeax_vfx_add_offsets_main',
|
|
119
|
+
'forgeax_vfx_compact_main',
|
|
120
|
+
]);
|
|
121
|
+
} else expect(eventIndex).toBe(-1);
|
|
122
|
+
expect(runtime.snapshot()).toHaveLength(1);
|
|
123
|
+
const retry = frame();
|
|
124
|
+
expect(particleName(retry.plan)).toBe(particleName(first.plan));
|
|
125
|
+
feature.onFrameSubmitted?.(retry.extracted);
|
|
126
|
+
expect(runtime.snapshot()).toHaveLength(0);
|
|
127
|
+
const presentation = frame([new World(), world]);
|
|
128
|
+
expect(particleName(presentation.plan)).toBe(particleName(first.plan));
|
|
129
|
+
expect(presentation.plan.passes.filter((pass) => pass.kind === 'raster')).toHaveLength(1);
|
|
130
|
+
expect(
|
|
131
|
+
presentation.plan.passes
|
|
132
|
+
.flatMap((pass) => (pass.kind === 'compute' ? pass.dispatches : []))
|
|
133
|
+
.some((dispatch) => /spawn|update|history/.test(dispatch.entryPoint)),
|
|
134
|
+
).toBe(false);
|
|
135
|
+
expect(
|
|
136
|
+
presentation.plan.resources.find(
|
|
137
|
+
(resource) => resource.kind === 'buffer' && resource.name.endsWith('.indirect'),
|
|
138
|
+
),
|
|
139
|
+
).not.toHaveProperty('data');
|
|
140
|
+
world.update(1 / 60).unwrap();
|
|
141
|
+
world.update(1 / 60).unwrap();
|
|
142
|
+
const catchup = frame();
|
|
143
|
+
expect(runtime.snapshot()).toHaveLength(2);
|
|
144
|
+
expect(catchup.plan.passes.filter((pass) => pass.name.endsWith('.simulate'))).toHaveLength(2);
|
|
145
|
+
expect(catchup.plan.passes.filter((pass) => pass.kind === 'raster')).toHaveLength(1);
|
|
146
|
+
expect(particleName(catchup.plan)).toBe(particleName(first.plan));
|
|
147
|
+
expect(
|
|
148
|
+
catchup.plan.resources.find((resource) => resource.name.endsWith('.indirect')),
|
|
149
|
+
).not.toHaveProperty('data');
|
|
150
|
+
feature.onFrameSubmitted?.(catchup.extracted);
|
|
151
|
+
expect(runtime.snapshot()).toHaveLength(0);
|
|
152
|
+
runtime.replay(player);
|
|
153
|
+
world.update(1 / 60).unwrap();
|
|
154
|
+
const replay = frame();
|
|
155
|
+
expect(particleName(replay.plan)).not.toBe(particleName(first.plan));
|
|
156
|
+
feature.onFrameSubmitted?.(replay.extracted);
|
|
157
|
+
runtime.reset(player);
|
|
158
|
+
expect(frame().plan.resources).toEqual([]);
|
|
159
|
+
} finally {
|
|
160
|
+
await context.fiber.dispose();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
-
import type {
|
|
2
|
+
import type { ParticleRendererSourceV3 } from '@forgeax/engine-vfx';
|
|
3
3
|
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
4
4
|
import type {
|
|
5
5
|
createVfxRenderInspectSnapshot,
|
|
@@ -13,8 +13,8 @@ import type {
|
|
|
13
13
|
} from '../feature/particle-resources.js';
|
|
14
14
|
import type { VfxStagePlanObservation } from '../feature/stage-plan.js';
|
|
15
15
|
|
|
16
|
-
type RendererKind =
|
|
17
|
-
type TopologyRenderer = Extract<
|
|
16
|
+
type RendererKind = ParticleRendererSourceV3['kind'];
|
|
17
|
+
type TopologyRenderer = Extract<ParticleRendererSourceV3, { readonly capacity: number }>;
|
|
18
18
|
type TopologyKind = TopologyRenderer['kind'];
|
|
19
19
|
type StageOutput = VfxStagePlanObservation['stageOutput'];
|
|
20
20
|
|
|
@@ -62,16 +62,16 @@ describe('VFX render vocabulary owners', () => {
|
|
|
62
62
|
|
|
63
63
|
it('keeps both production projections derived from the source owner', () => {
|
|
64
64
|
expect(normalizedGpuFeatureSource).toContain(
|
|
65
|
-
"type ParticleRendererKind =
|
|
65
|
+
"type ParticleRendererKind = ParticleRendererSourceV3['kind'];",
|
|
66
66
|
);
|
|
67
67
|
expect(normalizedParticleResourcesSource).toContain(
|
|
68
|
-
"type ParticleRendererKind =
|
|
68
|
+
"type ParticleRendererKind = ParticleRendererSourceV3['kind'];",
|
|
69
69
|
);
|
|
70
70
|
expect(normalizedGpuFeatureSource).toContain(
|
|
71
|
-
'type ParticleTopologyRenderer = Extract<
|
|
71
|
+
'type ParticleTopologyRenderer = Extract<ParticleRendererSourceV3, { readonly capacity: number }>;',
|
|
72
72
|
);
|
|
73
73
|
expect(normalizedParticleResourcesSource).toContain(
|
|
74
|
-
'type ParticleTopologyRenderer = Extract<
|
|
74
|
+
'type ParticleTopologyRenderer = Extract<ParticleRendererSourceV3, { readonly capacity: number }>;',
|
|
75
75
|
);
|
|
76
76
|
expect(gpuFeatureSource).toContain('readonly topology: ParticleRendererKind;');
|
|
77
77
|
expect(gpuFeatureSource).toContain('topology: ParticleTopologyKind,');
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { VfxGpuEmitterProgramAny, VfxGpuTickIntent } from '@forgeax/engine-vfx';
|
|
2
2
|
|
|
3
3
|
export const VFX_EVENT_INPUT_BYTES = 32;
|
|
4
4
|
export const VFX_EVENT_BYTES = 32;
|
|
5
5
|
export const VFX_EVENT_COUNTER_BYTES = 16;
|
|
6
6
|
|
|
7
|
-
function channelFanOut(emitter:
|
|
7
|
+
function channelFanOut(emitter: VfxGpuEmitterProgramAny, channel: string): number {
|
|
8
8
|
return Math.max(
|
|
9
9
|
1,
|
|
10
10
|
...(emitter.events ?? [])
|
|
@@ -13,14 +13,14 @@ function channelFanOut(emitter: VfxGpuEmitterProgram, channel: string): number {
|
|
|
13
13
|
);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export function eventInputCapacity(emitter:
|
|
16
|
+
export function eventInputCapacity(emitter: VfxGpuEmitterProgramAny): number {
|
|
17
17
|
return Math.max(
|
|
18
18
|
1,
|
|
19
19
|
(emitter.channels ?? []).reduce((total, channel) => total + channel.capacity, 0),
|
|
20
20
|
);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export function eventCapacity(emitter:
|
|
23
|
+
export function eventCapacity(emitter: VfxGpuEmitterProgramAny): number {
|
|
24
24
|
const capacity = (emitter.channels ?? []).reduce(
|
|
25
25
|
(total, channel) => total + channel.capacity * channelFanOut(emitter, channel.id),
|
|
26
26
|
0,
|
|
@@ -57,6 +57,20 @@ export function encodeEventInputs(intent: VfxGpuTickIntent): Uint8Array {
|
|
|
57
57
|
return bytes;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Pack the fixed-tick inputs and GPU-produced events into one typed storage
|
|
62
|
+
* resource. The managed event kernel uses the reflected input capacity as the
|
|
63
|
+
* split point; this keeps channel/sub-emitter semantics while leaving one
|
|
64
|
+
* storage binding available for Custom-enabled emitters.
|
|
65
|
+
*/
|
|
66
|
+
export function encodeEventBuffer(intent: VfxGpuTickIntent): Uint8Array {
|
|
67
|
+
const inputBytes = encodeEventInputs(intent);
|
|
68
|
+
const outputBytes = eventCapacity(intent.emitter) * VFX_EVENT_BYTES;
|
|
69
|
+
const data = new Uint8Array(inputBytes.byteLength + outputBytes);
|
|
70
|
+
data.set(inputBytes);
|
|
71
|
+
return data;
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
export function eventCounterData(): Uint8Array {
|
|
61
75
|
return new Uint8Array(VFX_EVENT_COUNTER_BYTES);
|
|
62
76
|
}
|