@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
|
@@ -2,15 +2,16 @@ import type { RenderFeatureMaterialShaderBindingContract } from '@forgeax/engine
|
|
|
2
2
|
import {
|
|
3
3
|
err,
|
|
4
4
|
type MaterialAsset,
|
|
5
|
+
type MaterialParticleInput,
|
|
5
6
|
type MaterialRenderState,
|
|
6
7
|
type MeshAsset,
|
|
7
8
|
ok,
|
|
8
9
|
type Result,
|
|
9
10
|
} from '@forgeax/engine-types';
|
|
10
|
-
import type {
|
|
11
|
+
import type { ParticleRendererSourceV3 } from '@forgeax/engine-vfx';
|
|
11
12
|
|
|
12
|
-
type ParticleRendererKind =
|
|
13
|
-
type ParticleTopologyRenderer = Extract<
|
|
13
|
+
type ParticleRendererKind = ParticleRendererSourceV3['kind'];
|
|
14
|
+
type ParticleTopologyRenderer = Extract<ParticleRendererSourceV3, { readonly capacity: number }>;
|
|
14
15
|
type ParticleTopologyKind = ParticleTopologyRenderer['kind'];
|
|
15
16
|
|
|
16
17
|
export const PARTICLE_SHADER_IDENTIFIERS = Object.freeze({
|
|
@@ -21,6 +22,15 @@ export const PARTICLE_SHADER_IDENTIFIERS = Object.freeze({
|
|
|
21
22
|
beam: 'forgeax::vfx-render.particles.beam',
|
|
22
23
|
});
|
|
23
24
|
|
|
25
|
+
/** Built-in shader variants with one explicit vec4 material-input lane block. */
|
|
26
|
+
export const PARTICLE_INPUT_SHADER_IDENTIFIERS = Object.freeze({
|
|
27
|
+
billboard: 'forgeax::vfx-render.particles.billboard-inputs',
|
|
28
|
+
mesh: 'forgeax::vfx-render.particles.mesh-inputs',
|
|
29
|
+
ribbon: 'forgeax::vfx-render.particles.ribbon-inputs',
|
|
30
|
+
trail: 'forgeax::vfx-render.particles.trail-inputs',
|
|
31
|
+
beam: 'forgeax::vfx-render.particles.beam-inputs',
|
|
32
|
+
});
|
|
33
|
+
|
|
24
34
|
export interface TopologyResourcePlan {
|
|
25
35
|
readonly topology: ParticleTopologyKind;
|
|
26
36
|
readonly capacity: number;
|
|
@@ -50,7 +60,7 @@ export function createTopologyResourcePlan(
|
|
|
50
60
|
hint: 'declare a ribbon, trail, or beam renderer',
|
|
51
61
|
detail: { path: 'renderer' },
|
|
52
62
|
});
|
|
53
|
-
const value = renderer as Partial<
|
|
63
|
+
const value = renderer as Partial<ParticleRendererSourceV3> & Record<string, unknown>;
|
|
54
64
|
if (value.kind !== 'ribbon' && value.kind !== 'trail' && value.kind !== 'beam')
|
|
55
65
|
return err({
|
|
56
66
|
code: 'vfx-topology-resource-invalid',
|
|
@@ -138,7 +148,7 @@ export interface ParticleMaterialPass {
|
|
|
138
148
|
readonly renderState?: MaterialRenderState;
|
|
139
149
|
}
|
|
140
150
|
|
|
141
|
-
type ParticleBlendMode = Extract<
|
|
151
|
+
type ParticleBlendMode = Extract<ParticleRendererSourceV3, { readonly kind: 'billboard' }>['blend'];
|
|
142
152
|
|
|
143
153
|
const PARTICLE_PREMULTIPLIED_ALPHA_BLEND: NonNullable<MaterialRenderState['blend']> = {
|
|
144
154
|
color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
|
|
@@ -159,16 +169,200 @@ const PARTICLE_ADDITIVE_BLEND: NonNullable<MaterialRenderState['blend']> = {
|
|
|
159
169
|
export function particleMaterialPass(
|
|
160
170
|
kind: ParticleRendererKind,
|
|
161
171
|
material: MaterialAsset | undefined,
|
|
172
|
+
hasParticleInputs = false,
|
|
162
173
|
): ParticleMaterialPass {
|
|
163
174
|
const pass = material?.passes?.find((candidate) => candidate.name === `particle-${kind}`);
|
|
175
|
+
const renderState =
|
|
176
|
+
pass?.renderState ??
|
|
177
|
+
(kind === 'mesh'
|
|
178
|
+
? material?.passes?.find((candidate) => {
|
|
179
|
+
const tags = candidate.renderState?.tags;
|
|
180
|
+
return (
|
|
181
|
+
candidate.name === 'forward' ||
|
|
182
|
+
(typeof tags === 'object' &&
|
|
183
|
+
tags !== null &&
|
|
184
|
+
'LightMode' in tags &&
|
|
185
|
+
tags.LightMode === 'Forward')
|
|
186
|
+
);
|
|
187
|
+
})?.renderState
|
|
188
|
+
: undefined);
|
|
164
189
|
return {
|
|
165
|
-
shader:
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
190
|
+
shader:
|
|
191
|
+
pass?.program.module ??
|
|
192
|
+
(hasParticleInputs
|
|
193
|
+
? PARTICLE_INPUT_SHADER_IDENTIFIERS[kind]
|
|
194
|
+
: PARTICLE_SHADER_IDENTIFIERS[kind]),
|
|
195
|
+
...(renderState === undefined ? {} : { renderState: renderState as MaterialRenderState }),
|
|
169
196
|
};
|
|
170
197
|
}
|
|
171
198
|
|
|
199
|
+
export interface PreparedParticleMaterialInputs {
|
|
200
|
+
/** The exact declarations requested by this renderer, in authored order. */
|
|
201
|
+
readonly definitions: readonly MaterialParticleInput[];
|
|
202
|
+
/** Number of vec4 lanes reserved in the per-particle instance stream. */
|
|
203
|
+
readonly lanes: number;
|
|
204
|
+
/** Byte width of the per-particle input projection. */
|
|
205
|
+
readonly stride: number;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface ParticleMaterialInputPreparationError {
|
|
209
|
+
readonly code:
|
|
210
|
+
| 'vfx-material-input-missing'
|
|
211
|
+
| 'vfx-material-input-wrong-type'
|
|
212
|
+
| 'vfx-material-input-stale'
|
|
213
|
+
| 'vfx-material-input-duplicate';
|
|
214
|
+
readonly expected: string;
|
|
215
|
+
readonly hint: string;
|
|
216
|
+
readonly detail: {
|
|
217
|
+
readonly material?: string;
|
|
218
|
+
readonly name?: string;
|
|
219
|
+
readonly lane?: number;
|
|
220
|
+
readonly path?: string;
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const EMPTY_PARTICLE_MATERIAL_INPUTS: PreparedParticleMaterialInputs = Object.freeze({
|
|
225
|
+
definitions: Object.freeze([]),
|
|
226
|
+
lanes: 0,
|
|
227
|
+
stride: 0,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
function particleInputFailure(
|
|
231
|
+
code: ParticleMaterialInputPreparationError['code'],
|
|
232
|
+
expected: string,
|
|
233
|
+
hint: string,
|
|
234
|
+
detail: ParticleMaterialInputPreparationError['detail'],
|
|
235
|
+
): Result<never, ParticleMaterialInputPreparationError> {
|
|
236
|
+
return err({ code, expected, hint, detail });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isParticleInputType(value: unknown): value is MaterialParticleInput['type'] {
|
|
240
|
+
return value === 'f32' || value === 'vec2<f32>' || value === 'vec3<f32>' || value === 'vec4<f32>';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isParticleInputVisibility(value: unknown): value is MaterialParticleInput['visibility'] {
|
|
244
|
+
return value === 'vertex' || value === 'fragment' || value === 'vertex-fragment';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function validParticleInput(value: unknown): value is MaterialParticleInput {
|
|
248
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
249
|
+
const input = value as Record<string, unknown>;
|
|
250
|
+
return (
|
|
251
|
+
typeof input.name === 'string' &&
|
|
252
|
+
/^[A-Za-z_][A-Za-z0-9_]*$/.test(input.name) &&
|
|
253
|
+
isParticleInputType(input.type) &&
|
|
254
|
+
isParticleInputVisibility(input.visibility) &&
|
|
255
|
+
typeof input.lane === 'number' &&
|
|
256
|
+
Number.isInteger(input.lane) &&
|
|
257
|
+
input.lane >= 0 &&
|
|
258
|
+
input.lane < 4
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function sameParticleInput(left: MaterialParticleInput, right: MaterialParticleInput): boolean {
|
|
263
|
+
return (
|
|
264
|
+
left.name === right.name &&
|
|
265
|
+
left.type === right.type &&
|
|
266
|
+
left.visibility === right.visibility &&
|
|
267
|
+
left.lane === right.lane
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Validate and prepare the material-side particle-input bridge.
|
|
273
|
+
*
|
|
274
|
+
* The returned stride is added to the renderer-owned GPU instance stream; no
|
|
275
|
+
* CPU mirror or per-frame upload is created. Reflection is required when the
|
|
276
|
+
* renderer came from a cooked effect so an old material/program pair cannot
|
|
277
|
+
* silently bind a different input layout.
|
|
278
|
+
*/
|
|
279
|
+
export function prepareParticleMaterialInputs(
|
|
280
|
+
renderer: ParticleRendererSourceV3,
|
|
281
|
+
material: MaterialAsset | undefined,
|
|
282
|
+
reflected?: readonly MaterialParticleInput[],
|
|
283
|
+
): Result<PreparedParticleMaterialInputs, ParticleMaterialInputPreparationError> {
|
|
284
|
+
const requested = renderer.materialInputs ?? [];
|
|
285
|
+
if (requested.length === 0) return ok(EMPTY_PARTICLE_MATERIAL_INPUTS);
|
|
286
|
+
if (new Set(requested).size !== requested.length) {
|
|
287
|
+
return particleInputFailure(
|
|
288
|
+
'vfx-material-input-duplicate',
|
|
289
|
+
'unique particle input names per renderer',
|
|
290
|
+
'remove the duplicate renderer material input and recook the effect',
|
|
291
|
+
{ material: renderer.material, path: 'renderer.materialInputs' },
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
const definitions = material?.particleInputs;
|
|
295
|
+
if (definitions === undefined) {
|
|
296
|
+
return particleInputFailure(
|
|
297
|
+
'vfx-material-input-missing',
|
|
298
|
+
`material ${renderer.material} to declare particleInputs`,
|
|
299
|
+
'add the requested typed input to MaterialAsset and recook the material before the VFX effect',
|
|
300
|
+
{ material: renderer.material, path: 'material.particleInputs' },
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
const names = new Set<string>();
|
|
304
|
+
const lanes = new Set<number>();
|
|
305
|
+
for (const [index, candidate] of definitions.entries()) {
|
|
306
|
+
if (!validParticleInput(candidate)) {
|
|
307
|
+
return particleInputFailure(
|
|
308
|
+
'vfx-material-input-wrong-type',
|
|
309
|
+
'particleInputs entries with a supported type, visibility, and lane',
|
|
310
|
+
'repair the material particleInputs declaration and recook it',
|
|
311
|
+
{ material: renderer.material, path: `material.particleInputs[${index}]` },
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
if (names.has(candidate.name) || lanes.has(candidate.lane)) {
|
|
315
|
+
return particleInputFailure(
|
|
316
|
+
'vfx-material-input-duplicate',
|
|
317
|
+
'unique particle input names and lanes',
|
|
318
|
+
'assign one lane to one input name and recook the material',
|
|
319
|
+
{ material: renderer.material, name: candidate.name, lane: candidate.lane },
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
names.add(candidate.name);
|
|
323
|
+
lanes.add(candidate.lane);
|
|
324
|
+
}
|
|
325
|
+
const selectedDefinitions: MaterialParticleInput[] = [];
|
|
326
|
+
for (const name of requested) {
|
|
327
|
+
const input = definitions.find((candidate) => candidate.name === name);
|
|
328
|
+
if (input === undefined) {
|
|
329
|
+
return particleInputFailure(
|
|
330
|
+
'vfx-material-input-missing',
|
|
331
|
+
`material ${renderer.material} to declare particle input ${name}`,
|
|
332
|
+
'add the requested input to MaterialAsset.particleInputs and recook both assets',
|
|
333
|
+
{ material: renderer.material, name, path: 'renderer.materialInputs' },
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
selectedDefinitions.push(input);
|
|
337
|
+
}
|
|
338
|
+
if (reflected === undefined) {
|
|
339
|
+
return particleInputFailure(
|
|
340
|
+
'vfx-material-input-stale',
|
|
341
|
+
'cooked renderer reflection to carry the material input declarations',
|
|
342
|
+
'recook the VFX effect with the current material artifact catalog',
|
|
343
|
+
{ material: renderer.material, path: 'effect.reflection.renderers.materialInputDefinitions' },
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
for (const input of selectedDefinitions) {
|
|
347
|
+
const cooked = reflected.find((candidate) => candidate.name === input.name);
|
|
348
|
+
if (cooked === undefined || !sameParticleInput(input, cooked)) {
|
|
349
|
+
return particleInputFailure(
|
|
350
|
+
'vfx-material-input-stale',
|
|
351
|
+
`the cooked declaration for material input ${input.name} to match MaterialAsset`,
|
|
352
|
+
'recook the VFX effect and material together so names, types, visibility, and lanes agree',
|
|
353
|
+
{ material: renderer.material, name: input.name, lane: input.lane },
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const lanesUsed = selectedDefinitions.map((input) => input.lane);
|
|
358
|
+
const lanesCount = Math.max(...lanesUsed, -1) + 1;
|
|
359
|
+
return ok({
|
|
360
|
+
definitions: Object.freeze([...selectedDefinitions]),
|
|
361
|
+
lanes: lanesCount,
|
|
362
|
+
stride: lanesCount * 16,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
172
366
|
/**
|
|
173
367
|
* Resolve the default pipeline state for a particle renderer.
|
|
174
368
|
*
|
|
@@ -220,8 +414,9 @@ export function particleMaterialUsesBindings(material: MaterialAsset | undefined
|
|
|
220
414
|
export function particleMaterialSceneDepthBinding(
|
|
221
415
|
contract: RenderFeatureMaterialShaderBindingContract | undefined,
|
|
222
416
|
): 0 | 1 | undefined {
|
|
223
|
-
if (contract === 'group-0-resource') return 0;
|
|
224
|
-
if (contract === 'view-and-scene-depth'
|
|
417
|
+
if (contract === 'group-0-resource' || contract === 'render-material-with-scene-depth') return 0;
|
|
418
|
+
if (contract === 'view-and-scene-depth' || contract === 'render-material-and-scene-depth')
|
|
419
|
+
return 1;
|
|
225
420
|
return undefined;
|
|
226
421
|
}
|
|
227
422
|
|
|
@@ -257,3 +452,20 @@ export function canonicalMeshVertices(mesh: MeshAsset): Float32Array {
|
|
|
257
452
|
}
|
|
258
453
|
return result;
|
|
259
454
|
}
|
|
455
|
+
|
|
456
|
+
// Runtime asset publications are ordinary-object frozen snapshots. Keep the
|
|
457
|
+
// derived 12-float particle vertex stream by that publication identity so a
|
|
458
|
+
// VFX feature with several emitters does not rebuild the same mesh projection
|
|
459
|
+
// on every frame. Mutable hand-authored meshes deliberately bypass this cache;
|
|
460
|
+
// their attribute values remain observable on the next call instead of being
|
|
461
|
+
// hidden behind an object-identity assumption.
|
|
462
|
+
const canonicalMeshVertexCache = new WeakMap<MeshAsset, Float32Array>();
|
|
463
|
+
|
|
464
|
+
export function canonicalMeshVerticesCached(mesh: MeshAsset): Float32Array {
|
|
465
|
+
if (!Object.isFrozen(mesh)) return canonicalMeshVertices(mesh);
|
|
466
|
+
const cached = canonicalMeshVertexCache.get(mesh);
|
|
467
|
+
if (cached !== undefined) return cached;
|
|
468
|
+
const derived = canonicalMeshVertices(mesh);
|
|
469
|
+
canonicalMeshVertexCache.set(mesh, derived);
|
|
470
|
+
return derived;
|
|
471
|
+
}
|
|
@@ -14,6 +14,8 @@ export type { VfxDataInterfaceProvider } from '@forgeax/engine-vfx';
|
|
|
14
14
|
|
|
15
15
|
export interface VfxDataInterfaceAvailabilitySource {
|
|
16
16
|
readonly available: (generation: number) => boolean;
|
|
17
|
+
readonly sampleCount?: 1 | 4;
|
|
18
|
+
readonly resource?: (generation: number) => VfxDataInterfaceResource['resource'] | undefined;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export interface VfxDataInterfaceRegistry {
|
|
@@ -60,11 +62,22 @@ function availableProvider<K extends VfxDataInterfaceKind>(
|
|
|
60
62
|
detail: { token, providerId: `${token}-provider` },
|
|
61
63
|
});
|
|
62
64
|
}
|
|
65
|
+
const prepared = source.resource?.(generation);
|
|
66
|
+
if (prepared === undefined) {
|
|
67
|
+
return err({
|
|
68
|
+
code: 'vfx-data-interface-missing',
|
|
69
|
+
expected: `a resident ${kind} resource for ${token}`,
|
|
70
|
+
hint: `provide the generation-owned ${kind} resource before rendering`,
|
|
71
|
+
detail: { token, providerId: `${token}-provider` },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
63
74
|
const resource: VfxDataInterfaceResource = {
|
|
64
75
|
token,
|
|
65
76
|
kind,
|
|
66
77
|
bindingType,
|
|
67
78
|
generation,
|
|
79
|
+
...(source.sampleCount === undefined ? {} : { sampleCount: source.sampleCount }),
|
|
80
|
+
resource: prepared,
|
|
68
81
|
};
|
|
69
82
|
return ok(resource);
|
|
70
83
|
},
|
|
@@ -83,6 +96,12 @@ export function createSceneDepthProvider(
|
|
|
83
96
|
return availableProvider('vfx:scene-depth', 'scene-depth', 'sampled-depth', source);
|
|
84
97
|
}
|
|
85
98
|
|
|
99
|
+
export function createNoiseProvider(
|
|
100
|
+
source: VfxDataInterfaceAvailabilitySource,
|
|
101
|
+
): VfxDataInterfaceProvider<'noise'> {
|
|
102
|
+
return availableProvider('vfx:noise', 'noise', 'sampled-float', source);
|
|
103
|
+
}
|
|
104
|
+
|
|
86
105
|
export function createVfxDataInterfaceRegistry(
|
|
87
106
|
initialProviders: readonly VfxDataInterfaceProvider[] = [],
|
|
88
107
|
): VfxDataInterfaceRegistry {
|
|
@@ -14,6 +14,8 @@ import type {
|
|
|
14
14
|
} from '@forgeax/engine-types';
|
|
15
15
|
import { err, ok } from '@forgeax/engine-types';
|
|
16
16
|
import type {
|
|
17
|
+
ParticleEffectInstance,
|
|
18
|
+
VfxChannelInput,
|
|
17
19
|
VfxDataInterfaceError,
|
|
18
20
|
VfxDataInterfaceProvider,
|
|
19
21
|
VfxDataInterfaceRequirement,
|
|
@@ -76,13 +78,15 @@ export interface VfxRuntimeHostControlError {
|
|
|
76
78
|
| 'vfx-host-control-world-detached'
|
|
77
79
|
| 'vfx-host-control-stale-generation'
|
|
78
80
|
| 'vfx-host-control-runtime-unavailable'
|
|
79
|
-
| 'vfx-host-control-player-unavailable'
|
|
81
|
+
| 'vfx-host-control-player-unavailable'
|
|
82
|
+
| 'vfx-host-control-instance-rejected';
|
|
80
83
|
readonly expected: string;
|
|
81
84
|
readonly hint: string;
|
|
82
85
|
readonly detail: {
|
|
83
86
|
readonly requestedGeneration?: number;
|
|
84
87
|
readonly currentGeneration?: number;
|
|
85
88
|
readonly player?: EntityHandle;
|
|
89
|
+
readonly causeCode?: string;
|
|
86
90
|
};
|
|
87
91
|
}
|
|
88
92
|
|
|
@@ -113,6 +117,30 @@ export interface VfxRuntimeHostControl {
|
|
|
113
117
|
},
|
|
114
118
|
VfxRuntimeHostControlError
|
|
115
119
|
>;
|
|
120
|
+
patchPlayerParameters(input: {
|
|
121
|
+
readonly player: EntityHandle;
|
|
122
|
+
readonly values: Partial<VfxValueMap>;
|
|
123
|
+
}): Result<
|
|
124
|
+
{
|
|
125
|
+
readonly state: 'queued';
|
|
126
|
+
readonly generation: number;
|
|
127
|
+
readonly parameterGeneration: number;
|
|
128
|
+
readonly pendingPatchCount: number;
|
|
129
|
+
},
|
|
130
|
+
VfxRuntimeHostControlError
|
|
131
|
+
>;
|
|
132
|
+
submitChannel(input: {
|
|
133
|
+
readonly player: EntityHandle;
|
|
134
|
+
readonly channel: string;
|
|
135
|
+
readonly payload: VfxChannelInput['payload'];
|
|
136
|
+
readonly sequence: number;
|
|
137
|
+
}): Result<
|
|
138
|
+
{
|
|
139
|
+
readonly state: 'queued';
|
|
140
|
+
readonly generation: number;
|
|
141
|
+
},
|
|
142
|
+
VfxRuntimeHostControlError
|
|
143
|
+
>;
|
|
116
144
|
}
|
|
117
145
|
|
|
118
146
|
export interface VfxRuntimeHost {
|
|
@@ -320,6 +348,41 @@ export function createVfxRuntimeHost(options: VfxRuntimeHostOptions): VfxRuntime
|
|
|
320
348
|
}
|
|
321
349
|
return ok(action(world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY)));
|
|
322
350
|
};
|
|
351
|
+
type InstanceAction<T> =
|
|
352
|
+
| { readonly kind: 'value'; readonly value: T }
|
|
353
|
+
| { readonly kind: 'error'; readonly causeCode: string };
|
|
354
|
+
const withInstance = <T>(
|
|
355
|
+
player: EntityHandle,
|
|
356
|
+
action: (instance: ParticleEffectInstance) => Result<T, { readonly code: string }>,
|
|
357
|
+
): Result<T, VfxRuntimeHostControlError> => {
|
|
358
|
+
const result = withRuntime(player, (runtime): InstanceAction<T> => {
|
|
359
|
+
const instance = runtime.getInstance(player);
|
|
360
|
+
if (instance === undefined) {
|
|
361
|
+
return { kind: 'error', causeCode: 'vfx-instance-unavailable' };
|
|
362
|
+
}
|
|
363
|
+
const outcome = action(instance);
|
|
364
|
+
return outcome.ok
|
|
365
|
+
? { kind: 'value', value: outcome.value }
|
|
366
|
+
: { kind: 'error', causeCode: outcome.error.code };
|
|
367
|
+
});
|
|
368
|
+
if (!result.ok) return result;
|
|
369
|
+
if (result.value.kind === 'error') {
|
|
370
|
+
return err(
|
|
371
|
+
controlFailure(
|
|
372
|
+
'vfx-host-control-instance-rejected',
|
|
373
|
+
'the live typed VFX instance to accept this control input',
|
|
374
|
+
'run one fixed tick or repair the reflected instance contract before retrying',
|
|
375
|
+
{
|
|
376
|
+
requestedGeneration,
|
|
377
|
+
currentGeneration: requestedGeneration,
|
|
378
|
+
player,
|
|
379
|
+
causeCode: result.value.causeCode,
|
|
380
|
+
},
|
|
381
|
+
),
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
return ok(result.value.value);
|
|
385
|
+
};
|
|
323
386
|
const control: VfxRuntimeHostControl = {
|
|
324
387
|
generation: requestedGeneration,
|
|
325
388
|
replay: ({ player, replayInput }) =>
|
|
@@ -351,6 +414,23 @@ export function createVfxRuntimeHost(options: VfxRuntimeHostOptions): VfxRuntime
|
|
|
351
414
|
generation: requestedGeneration,
|
|
352
415
|
});
|
|
353
416
|
}),
|
|
417
|
+
patchPlayerParameters: ({ player, values }) =>
|
|
418
|
+
withInstance(player, (instance) => {
|
|
419
|
+
const patched = instance.patch(values);
|
|
420
|
+
if (!patched.ok) return patched;
|
|
421
|
+
return ok({
|
|
422
|
+
state: 'queued' as const,
|
|
423
|
+
generation: requestedGeneration,
|
|
424
|
+
parameterGeneration: instance.generation,
|
|
425
|
+
pendingPatchCount: instance.pendingPatchCount,
|
|
426
|
+
});
|
|
427
|
+
}),
|
|
428
|
+
submitChannel: ({ player, channel, payload, sequence }) =>
|
|
429
|
+
withInstance(player, (instance) => {
|
|
430
|
+
const submitted = instance.submit({ channel, payload, sequence });
|
|
431
|
+
if (!submitted.ok) return submitted;
|
|
432
|
+
return ok({ state: 'queued' as const, generation: requestedGeneration });
|
|
433
|
+
}),
|
|
354
434
|
};
|
|
355
435
|
return ok(Object.freeze(control));
|
|
356
436
|
},
|
|
@@ -377,7 +457,7 @@ export function createVfxRuntimeHost(options: VfxRuntimeHostOptions): VfxRuntime
|
|
|
377
457
|
return err(
|
|
378
458
|
failure(
|
|
379
459
|
'vfx-host-loader-install-failed',
|
|
380
|
-
'the
|
|
460
|
+
'the Program v3 VFX loader to be registered once',
|
|
381
461
|
'remove a conflicting particle-effect loader and retry attachWorld',
|
|
382
462
|
cause,
|
|
383
463
|
),
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
export type { ParticleRenderCamera, ParticleRenderCameraSource } from './feature/camera.js';
|
|
4
4
|
export {
|
|
5
|
+
encodeEventBuffer,
|
|
5
6
|
encodeEventInputs,
|
|
6
7
|
eventCapacity,
|
|
7
8
|
eventCounterData,
|
|
@@ -16,9 +17,15 @@ export {
|
|
|
16
17
|
resolveBillboardAdvancedState,
|
|
17
18
|
topologyRecoveryHint,
|
|
18
19
|
} from './feature/gpu-particle-feature.js';
|
|
20
|
+
export type {
|
|
21
|
+
ParticleMaterialInputPreparationError,
|
|
22
|
+
PreparedParticleMaterialInputs,
|
|
23
|
+
} from './feature/particle-resources.js';
|
|
19
24
|
export {
|
|
20
25
|
createTopologyResourcePlan,
|
|
26
|
+
PARTICLE_INPUT_SHADER_IDENTIFIERS,
|
|
21
27
|
PARTICLE_SHADER_IDENTIFIERS,
|
|
28
|
+
prepareParticleMaterialInputs,
|
|
22
29
|
topologyCapacitySnapshot,
|
|
23
30
|
} from './feature/particle-resources.js';
|
|
24
31
|
export type {
|
|
@@ -42,6 +49,7 @@ export type {
|
|
|
42
49
|
} from './host/data-interface-providers.js';
|
|
43
50
|
export {
|
|
44
51
|
createCameraProvider,
|
|
52
|
+
createNoiseProvider,
|
|
45
53
|
createSceneDepthProvider,
|
|
46
54
|
createVfxDataInterfaceRegistry,
|
|
47
55
|
} from './host/data-interface-providers.js';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#define_import_path forgeax::vfx-render.particles.beam-inputs
|
|
2
|
+
|
|
3
|
+
struct BeamInput {
|
|
4
|
+
@location(0) start: vec3<f32>,
|
|
5
|
+
@location(1) endpoint: vec3<f32>,
|
|
6
|
+
@location(2) color: vec4<f32>,
|
|
7
|
+
@location(3) properties: vec2<f32>,
|
|
8
|
+
@location(4) particle_inputs: vec4<f32>,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
struct VertexOutput {
|
|
12
|
+
@builtin(position) position: vec4<f32>,
|
|
13
|
+
@location(0) color: vec4<f32>,
|
|
14
|
+
@location(2) across: f32,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@vertex
|
|
18
|
+
fn vs_main(input: BeamInput, @builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
|
|
19
|
+
let corners = array<vec2<f32>, 6>(
|
|
20
|
+
vec2<f32>(0.0, -1.0), vec2<f32>(1.0, -1.0), vec2<f32>(1.0, 1.0),
|
|
21
|
+
vec2<f32>(0.0, -1.0), vec2<f32>(1.0, 1.0), vec2<f32>(0.0, 1.0)
|
|
22
|
+
);
|
|
23
|
+
let corner = corners[vertexIndex];
|
|
24
|
+
let delta = input.endpoint.xy - input.start.xy;
|
|
25
|
+
let normal = normalize(vec2<f32>(-delta.y, delta.x) + vec2<f32>(0.000001, 0.0));
|
|
26
|
+
let point = mix(input.start, input.endpoint, corner.x);
|
|
27
|
+
let clipPosition = vec3<f32>(point.xy + normal * corner.y * input.properties.x, point.z);
|
|
28
|
+
var output: VertexOutput;
|
|
29
|
+
output.position = vec4<f32>(clipPosition, 1.0);
|
|
30
|
+
output.across = corner.y;
|
|
31
|
+
let heat = clamp(input.particle_inputs.x, 0.0, 1.0);
|
|
32
|
+
output.color = input.color * (0.35 + 0.65 * heat);
|
|
33
|
+
return output;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@fragment
|
|
37
|
+
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
|
|
38
|
+
let alpha = input.color.a * (1.0 - smoothstep(0.15, 1.0, abs(input.across)));
|
|
39
|
+
return vec4<f32>(input.color.rgb * alpha, alpha);
|
|
40
|
+
}
|
package/src/shaders/beam.wgsl
CHANGED
|
@@ -10,6 +10,7 @@ struct BeamInput {
|
|
|
10
10
|
struct VertexOutput {
|
|
11
11
|
@builtin(position) position: vec4<f32>,
|
|
12
12
|
@location(0) color: vec4<f32>,
|
|
13
|
+
@location(2) across: f32,
|
|
13
14
|
@location(1) clip_position: vec3<f32>,
|
|
14
15
|
}
|
|
15
16
|
|
|
@@ -26,6 +27,7 @@ fn vs_main(input: BeamInput, @builtin(vertex_index) vertexIndex: u32) -> VertexO
|
|
|
26
27
|
let clipPosition = vec3<f32>(point.xy + normal * corner.y * input.properties.x, point.z);
|
|
27
28
|
var output: VertexOutput;
|
|
28
29
|
output.position = vec4<f32>(clipPosition, 1.0);
|
|
30
|
+
output.across = corner.y;
|
|
29
31
|
output.color = input.color;
|
|
30
32
|
output.clip_position = clipPosition;
|
|
31
33
|
return output;
|
|
@@ -33,7 +35,6 @@ fn vs_main(input: BeamInput, @builtin(vertex_index) vertexIndex: u32) -> VertexO
|
|
|
33
35
|
|
|
34
36
|
@fragment
|
|
35
37
|
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
|
|
36
|
-
let
|
|
37
|
-
|
|
38
|
-
return vec4<f32>(base.rgb * input.color.rgb * alpha, alpha);
|
|
38
|
+
let alpha = input.color.a * (1.0 - smoothstep(0.15, 1.0, abs(input.across)));
|
|
39
|
+
return vec4<f32>(input.color.rgb * alpha, alpha);
|
|
39
40
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#define_import_path forgeax::vfx-render.particles.billboard-inputs
|
|
2
|
+
@group(0) @binding(0) var scene_depth: texture_depth_2d;
|
|
3
|
+
|
|
4
|
+
struct VertexOutput {
|
|
5
|
+
@builtin(position) position: vec4<f32>,
|
|
6
|
+
@location(0) color: vec4<f32>,
|
|
7
|
+
@location(1) local: vec2<f32>,
|
|
8
|
+
@location(2) emissive_intensity: vec4<f32>,
|
|
9
|
+
@location(3) surface: vec4<f32>,
|
|
10
|
+
@location(4) sheet_uv: vec2<f32>,
|
|
11
|
+
@location(5) sheet_frame: f32,
|
|
12
|
+
@location(6) fade_distance: f32,
|
|
13
|
+
@location(7) clip_position: vec3<f32>,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
fn textureSheetUv(local: vec2<f32>, frame: u32, columns: u32, rows: u32) -> vec2<f32> {
|
|
17
|
+
let safeColumns = max(columns, 1u);
|
|
18
|
+
let safeRows = max(rows, 1u);
|
|
19
|
+
let cell = vec2<u32>(frame % safeColumns, frame / safeColumns);
|
|
20
|
+
return (local + vec2<f32>(1.0)) * 0.5 / vec2<f32>(f32(safeColumns), f32(safeRows)) +
|
|
21
|
+
vec2<f32>(f32(cell.x) / f32(safeColumns), f32(cell.y) / f32(safeRows));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
fn softParticle(position: vec4<f32>, alpha: f32, fadeDistance: f32) -> f32 {
|
|
25
|
+
let pixel = vec2<i32>(position.xy);
|
|
26
|
+
let sceneDepth = textureLoad(scene_depth, pixel, 0);
|
|
27
|
+
if (fadeDistance <= 0.0) { return select(alpha, 0.0, position.z > sceneDepth); }
|
|
28
|
+
return alpha * clamp((sceneDepth - position.z) / fadeDistance, 0.0, 1.0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
struct VertexInput {
|
|
32
|
+
@location(0) position: vec3<f32>,
|
|
33
|
+
@location(1) right: vec2<f32>,
|
|
34
|
+
@location(2) up: vec2<f32>,
|
|
35
|
+
@location(3) particle_color: vec4<f32>,
|
|
36
|
+
@location(4) base_color: vec4<f32>,
|
|
37
|
+
@location(5) emissive_intensity: vec4<f32>,
|
|
38
|
+
@location(6) surface: vec4<f32>,
|
|
39
|
+
@location(7) advanced: vec4<f32>,
|
|
40
|
+
@location(8) texture_sheet: vec4<f32>,
|
|
41
|
+
@location(9) particle_inputs: vec4<f32>,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
@vertex
|
|
45
|
+
fn vs_main(input: VertexInput, @builtin(vertex_index) vertex_index: u32) -> VertexOutput {
|
|
46
|
+
let corners = array<vec2<f32>, 6>(
|
|
47
|
+
vec2<f32>(-1.0, -1.0), vec2<f32>(1.0, -1.0), vec2<f32>(1.0, 1.0),
|
|
48
|
+
vec2<f32>(-1.0, -1.0), vec2<f32>(1.0, 1.0), vec2<f32>(-1.0, 1.0)
|
|
49
|
+
);
|
|
50
|
+
let corner = corners[vertex_index] + input.advanced.xy * 2.0;
|
|
51
|
+
var output: VertexOutput;
|
|
52
|
+
let clipPosition = vec3<f32>(
|
|
53
|
+
input.position.xy + input.right * corner.x + input.up * corner.y,
|
|
54
|
+
input.position.z,
|
|
55
|
+
);
|
|
56
|
+
output.position = vec4<f32>(clipPosition, 1.0);
|
|
57
|
+
output.clip_position = clipPosition;
|
|
58
|
+
let heat = clamp(input.particle_inputs.x, 0.0, 1.0);
|
|
59
|
+
output.color = input.particle_color * input.base_color * (0.35 + 0.65 * heat);
|
|
60
|
+
output.local = corner;
|
|
61
|
+
output.emissive_intensity = input.emissive_intensity;
|
|
62
|
+
output.surface = input.surface;
|
|
63
|
+
output.sheet_uv = textureSheetUv(
|
|
64
|
+
corners[vertex_index], u32(input.advanced.z),
|
|
65
|
+
u32(input.texture_sheet.x), u32(input.texture_sheet.y),
|
|
66
|
+
);
|
|
67
|
+
output.sheet_frame = input.advanced.z;
|
|
68
|
+
output.fade_distance = input.texture_sheet.z;
|
|
69
|
+
return output;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@fragment
|
|
73
|
+
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
|
|
74
|
+
let radius = length(input.local);
|
|
75
|
+
let edge = 1.0 - smoothstep(0.45, 1.0, radius);
|
|
76
|
+
let core = 1.0 - smoothstep(0.0, 0.42, radius);
|
|
77
|
+
let roughness = clamp(input.surface.y, 0.04, 1.0);
|
|
78
|
+
let clearcoat = clamp(input.surface.z, 0.0, 1.0);
|
|
79
|
+
let highlight = core * clearcoat * (1.0 - roughness * 0.65);
|
|
80
|
+
let emissive = input.emissive_intensity.rgb * input.emissive_intensity.a;
|
|
81
|
+
let alpha = softParticle(input.position, input.color.a * edge, input.fade_distance);
|
|
82
|
+
let sheetPulse = 0.82 + 0.18 * fract(input.sheet_frame * 0.618 + input.sheet_uv.x + input.sheet_uv.y);
|
|
83
|
+
let rgb = (input.color.rgb + emissive * (0.35 + core * 0.65) + vec3<f32>(highlight)) * sheetPulse;
|
|
84
|
+
return vec4<f32>(rgb * alpha, alpha);
|
|
85
|
+
}
|