@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.
Files changed (40) hide show
  1. package/README.md +55 -10
  2. package/dist/__tests__/persistent-ticks.integration.test.d.ts +2 -0
  3. package/dist/__tests__/persistent-ticks.integration.test.d.ts.map +1 -0
  4. package/dist/feature/event-resources.d.ts +10 -3
  5. package/dist/feature/event-resources.d.ts.map +1 -1
  6. package/dist/feature/gpu-particle-feature.d.ts +18 -5
  7. package/dist/feature/gpu-particle-feature.d.ts.map +1 -1
  8. package/dist/feature/particle-resources.d.ts +43 -6
  9. package/dist/feature/particle-resources.d.ts.map +1 -1
  10. package/dist/host/data-interface-providers.d.ts +4 -1
  11. package/dist/host/data-interface-providers.d.ts.map +1 -1
  12. package/dist/host/vfx-runtime-host.d.ts +21 -2
  13. package/dist/host/vfx-runtime-host.d.ts.map +1 -1
  14. package/dist/index.d.ts +4 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.mjs +1027 -307
  17. package/dist/index.mjs.map +1 -1
  18. package/package.json +11 -9
  19. package/src/__tests__/billboard-advanced.integration.test.ts +1 -1
  20. package/src/__tests__/data-interface-providers.unit.test.ts +4 -0
  21. package/src/__tests__/data-interface-public-api.test-d.ts +1 -0
  22. package/src/__tests__/gpu-host.integration.test.ts +1471 -59
  23. package/src/__tests__/particle-resources.unit.test.ts +75 -0
  24. package/src/__tests__/persistent-ticks.integration.test.ts +162 -0
  25. package/src/__tests__/render-vocabulary-owner.test-d.ts +7 -7
  26. package/src/feature/event-resources.ts +18 -4
  27. package/src/feature/gpu-particle-feature.ts +1153 -376
  28. package/src/feature/particle-resources.ts +223 -11
  29. package/src/host/data-interface-providers.ts +19 -0
  30. package/src/host/vfx-runtime-host.ts +82 -2
  31. package/src/index.ts +8 -0
  32. package/src/shaders/beam-inputs.wgsl +40 -0
  33. package/src/shaders/beam.wgsl +4 -3
  34. package/src/shaders/billboard-inputs.wgsl +85 -0
  35. package/src/shaders/mesh-inputs.wgsl +95 -0
  36. package/src/shaders/mesh-shadow.wgsl +18 -0
  37. package/src/shaders/mesh.wgsl +64 -40
  38. package/src/shaders/ribbon-inputs.wgsl +39 -0
  39. package/src/shaders/trail-inputs.wgsl +45 -0
  40. 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 { ParticleRendererSource } from '@forgeax/engine-vfx';
11
+ import type { ParticleRendererSourceV3 } from '@forgeax/engine-vfx';
11
12
 
12
- type ParticleRendererKind = ParticleRendererSource['kind'];
13
- type ParticleTopologyRenderer = Extract<ParticleRendererSource, { readonly capacity: number }>;
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<ParticleRendererSource> & Record<string, unknown>;
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<ParticleRendererSource, { readonly kind: 'billboard' }>['blend'];
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: pass?.program.module ?? PARTICLE_SHADER_IDENTIFIERS[kind],
166
- ...(pass?.renderState === undefined
167
- ? {}
168
- : { renderState: pass.renderState as MaterialRenderState }),
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') return 1;
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 v2 VFX loader to be registered once',
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
+ }
@@ -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 base = vec4<f32>(0.7, 0.2, 1.0, 1.0);
37
- let alpha = base.a * input.color.a;
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
+ }