@forgeax/engine-vfx-render 0.0.0-dev.8d955ade1c79

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 (91) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +157 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/billboard-advanced.integration.test.d.ts +2 -0
  5. package/dist/__tests__/billboard-advanced.integration.test.d.ts.map +1 -0
  6. package/dist/__tests__/data-interface-providers.unit.test.d.ts +2 -0
  7. package/dist/__tests__/data-interface-providers.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/data-interface-public-api.test-d.d.ts +2 -0
  9. package/dist/__tests__/data-interface-public-api.test-d.d.ts.map +1 -0
  10. package/dist/__tests__/event-resources.unit.test.d.ts +2 -0
  11. package/dist/__tests__/event-resources.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/fog-producer.integration.test.d.ts +2 -0
  13. package/dist/__tests__/fog-producer.integration.test.d.ts.map +1 -0
  14. package/dist/__tests__/gpu-host.integration.test.d.ts +2 -0
  15. package/dist/__tests__/gpu-host.integration.test.d.ts.map +1 -0
  16. package/dist/__tests__/inspect-snapshot.integration.test.d.ts +2 -0
  17. package/dist/__tests__/inspect-snapshot.integration.test.d.ts.map +1 -0
  18. package/dist/__tests__/particle-resources.unit.test.d.ts +2 -0
  19. package/dist/__tests__/particle-resources.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/render-vocabulary-owner.test-d.d.ts +2 -0
  21. package/dist/__tests__/render-vocabulary-owner.test-d.d.ts.map +1 -0
  22. package/dist/__tests__/stage-plan.integration.test.d.ts +2 -0
  23. package/dist/__tests__/stage-plan.integration.test.d.ts.map +1 -0
  24. package/dist/__tests__/stage-recovery.integration.test.d.ts +2 -0
  25. package/dist/__tests__/stage-recovery.integration.test.d.ts.map +1 -0
  26. package/dist/__tests__/stage-resource-access-owner.test-d.d.ts +2 -0
  27. package/dist/__tests__/stage-resource-access-owner.test-d.d.ts.map +1 -0
  28. package/dist/__tests__/stage-resource-layout.unit.test.d.ts +2 -0
  29. package/dist/__tests__/stage-resource-layout.unit.test.d.ts.map +1 -0
  30. package/dist/__tests__/topology-capacity.unit.test.d.ts +2 -0
  31. package/dist/__tests__/topology-capacity.unit.test.d.ts.map +1 -0
  32. package/dist/__tests__/topology-resources.unit.test.d.ts +2 -0
  33. package/dist/__tests__/topology-resources.unit.test.d.ts.map +1 -0
  34. package/dist/feature/camera.d.ts +11 -0
  35. package/dist/feature/camera.d.ts.map +1 -0
  36. package/dist/feature/event-resources.d.ts +9 -0
  37. package/dist/feature/event-resources.d.ts.map +1 -0
  38. package/dist/feature/gpu-particle-feature.d.ts +90 -0
  39. package/dist/feature/gpu-particle-feature.d.ts.map +1 -0
  40. package/dist/feature/particle-resources.d.ts +63 -0
  41. package/dist/feature/particle-resources.d.ts.map +1 -0
  42. package/dist/feature/stage-plan.d.ts +54 -0
  43. package/dist/feature/stage-plan.d.ts.map +1 -0
  44. package/dist/feature/topologies/beam.d.ts +16 -0
  45. package/dist/feature/topologies/beam.d.ts.map +1 -0
  46. package/dist/feature/topologies/ribbon.d.ts +15 -0
  47. package/dist/feature/topologies/ribbon.d.ts.map +1 -0
  48. package/dist/feature/topologies/trail.d.ts +19 -0
  49. package/dist/feature/topologies/trail.d.ts.map +1 -0
  50. package/dist/host/data-interface-providers.d.ts +19 -0
  51. package/dist/host/data-interface-providers.d.ts.map +1 -0
  52. package/dist/host/vfx-runtime-host.d.ts +103 -0
  53. package/dist/host/vfx-runtime-host.d.ts.map +1 -0
  54. package/dist/index.d.ts +11 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.mjs +1308 -0
  57. package/dist/index.mjs.map +1 -0
  58. package/package.json +63 -0
  59. package/src/__tests__/billboard-advanced.integration.test.ts +46 -0
  60. package/src/__tests__/data-interface-providers.unit.test.ts +55 -0
  61. package/src/__tests__/data-interface-public-api.test-d.ts +58 -0
  62. package/src/__tests__/event-resources.unit.test.ts +71 -0
  63. package/src/__tests__/fog-producer.integration.test.ts +60 -0
  64. package/src/__tests__/gpu-host.integration.test.ts +459 -0
  65. package/src/__tests__/inspect-snapshot.integration.test.ts +26 -0
  66. package/src/__tests__/particle-resources.unit.test.ts +66 -0
  67. package/src/__tests__/render-vocabulary-owner.test-d.ts +81 -0
  68. package/src/__tests__/stage-plan.integration.test.ts +148 -0
  69. package/src/__tests__/stage-recovery.integration.test.ts +24 -0
  70. package/src/__tests__/stage-resource-access-owner.test-d.ts +26 -0
  71. package/src/__tests__/stage-resource-layout.unit.test.ts +40 -0
  72. package/src/__tests__/topology-capacity.unit.test.ts +59 -0
  73. package/src/__tests__/topology-resources.unit.test.ts +29 -0
  74. package/src/feature/camera.ts +12 -0
  75. package/src/feature/event-resources.ts +62 -0
  76. package/src/feature/gpu-particle-feature.ts +819 -0
  77. package/src/feature/particle-resources.ts +195 -0
  78. package/src/feature/stage-plan.ts +285 -0
  79. package/src/feature/topologies/beam.ts +41 -0
  80. package/src/feature/topologies/ribbon.ts +31 -0
  81. package/src/feature/topologies/trail.ts +43 -0
  82. package/src/host/data-interface-providers.ts +119 -0
  83. package/src/host/vfx-runtime-host.ts +459 -0
  84. package/src/index.ts +57 -0
  85. package/src/shaders/beam.wgsl +69 -0
  86. package/src/shaders/billboard.wgsl +132 -0
  87. package/src/shaders/event.wgsl +11 -0
  88. package/src/shaders/mesh.wgsl +98 -0
  89. package/src/shaders/ribbon.wgsl +69 -0
  90. package/src/shaders/stage.wgsl +5 -0
  91. package/src/shaders/trail.wgsl +77 -0
@@ -0,0 +1,819 @@
1
+ import type { EntityHandle, World } from '@forgeax/engine-ecs';
2
+ import { frustum } from '@forgeax/engine-math';
3
+ import type { RenderFeature, RenderFeaturePlan } from '@forgeax/engine-render';
4
+ import { Transform } from '@forgeax/engine-scene';
5
+ import { err, type MaterialAsset, type MeshAsset, ok } from '@forgeax/engine-types';
6
+ import type { ParticleRendererSource } from '@forgeax/engine-vfx';
7
+ import {
8
+ VFX_GPU_RUNTIME_RESOURCE_KEY,
9
+ type VfxGpuRuntime,
10
+ type VfxGpuTickIntent,
11
+ } from '@forgeax/engine-vfx';
12
+ import { RenderFeatureStageFailedError } from '../../../render/src/errors/render';
13
+ import { RENDER_FEATURE_VERTEX_LAYOUTS } from '../../../render/src/features/prepared-graphics';
14
+ import type { VfxDataInterfaceRegistry } from '../host/data-interface-providers.js';
15
+ import type { ParticleRenderCamera } from './camera.js';
16
+ import {
17
+ encodeEventInputs,
18
+ eventCapacity,
19
+ eventInputCapacity,
20
+ VFX_EVENT_BYTES,
21
+ VFX_EVENT_INPUT_BYTES,
22
+ } from './event-resources.js';
23
+ import {
24
+ canonicalMeshVertices,
25
+ createTopologyResourcePlan,
26
+ PARTICLE_SHADER_IDENTIFIERS,
27
+ particleMaterialPass,
28
+ particleMaterialUsesBindings,
29
+ } from './particle-resources.js';
30
+ import type { VfxStagePlanObservation, VfxValidatedStagePlan } from './stage-plan.js';
31
+ import { validatedStagePlan } from './stage-plan.js';
32
+
33
+ const IDENTITY = 'forgeax.vfx-render.gpu-particles';
34
+ const WORKGROUP_SIZE = 256;
35
+ const PARTICLE_BYTES = 80;
36
+ const BILLBOARD_INSTANCE_BYTES = 31 * 4;
37
+ const MESH_INSTANCE_BYTES = 28 * 4;
38
+ const COUNTERS_BYTES = 24;
39
+ const RUNTIME_BYTES = 72 * 4;
40
+ const IDENTITY_MATRIX = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
41
+
42
+ type ParticleRendererKind = ParticleRendererSource['kind'];
43
+ type ParticleTopologyRenderer = Extract<ParticleRendererSource, { readonly capacity: number }>;
44
+ type ParticleTopologyKind = ParticleTopologyRenderer['kind'];
45
+ type VfxStageOutput = VfxStagePlanObservation['stageOutput'];
46
+
47
+ interface VfxRenderStageState {
48
+ readonly stageOutput: VfxStageOutput;
49
+ }
50
+
51
+ interface VfxRenderInspectSnapshot extends VfxRenderStageState {
52
+ readonly topology: ParticleRendererKind;
53
+ readonly counters: {
54
+ readonly capacity: number;
55
+ readonly produced: number;
56
+ readonly dropped: number;
57
+ };
58
+ readonly stageReadiness: readonly unknown[];
59
+ readonly providerReadiness: unknown;
60
+ readonly gpuTiming: unknown;
61
+ }
62
+
63
+ export interface VfxRenderInspectInput {
64
+ readonly topology: ParticleRendererKind;
65
+ readonly capacity: number;
66
+ readonly produced: number;
67
+ readonly dropped: number;
68
+ readonly stageReadiness: readonly unknown[];
69
+ readonly stageOutput?: VfxStageOutput;
70
+ readonly providerReadiness: unknown;
71
+ readonly gpuTiming: unknown;
72
+ }
73
+
74
+ export function createVfxRenderInspectSnapshot(
75
+ input: VfxRenderInspectInput,
76
+ ): VfxRenderInspectSnapshot {
77
+ return {
78
+ topology: input.topology,
79
+ counters: { capacity: input.capacity, produced: input.produced, dropped: input.dropped },
80
+ stageReadiness: input.stageReadiness,
81
+ stageOutput: input.stageOutput ?? 'empty',
82
+ providerReadiness: input.providerReadiness,
83
+ gpuTiming: input.gpuTiming,
84
+ } as const;
85
+ }
86
+
87
+ export interface BillboardAdvancedSample {
88
+ readonly age: number;
89
+ readonly lifetime: number;
90
+ readonly particleDepth: number;
91
+ readonly sceneDepth: number;
92
+ readonly depthAvailable: boolean;
93
+ }
94
+
95
+ export function resolveBillboardAdvancedState(
96
+ renderer: Extract<ParticleRendererSource, { readonly kind: 'billboard' }>,
97
+ sample: BillboardAdvancedSample,
98
+ ) {
99
+ if (renderer.softParticle !== undefined && !sample.depthAvailable) {
100
+ return err({
101
+ code: 'vfx-renderer-depth-missing' as const,
102
+ expected: 'a scene-depth provider for soft particles',
103
+ hint: 'attach the scene-depth data interface or disable soft particles',
104
+ detail: { path: 'renderer.softParticle' },
105
+ });
106
+ }
107
+ const sheet = renderer.textureSheet;
108
+ const frameCount = sheet?.frameCount ?? (sheet === undefined ? 1 : sheet.columns * sheet.rows);
109
+ const frameIndex =
110
+ sheet === undefined || sheet.frameRate === 0
111
+ ? 0
112
+ : Math.min(
113
+ frameCount - 1,
114
+ Math.max(0, Math.floor(Math.max(0, sample.age) * sheet.frameRate)),
115
+ );
116
+ const softParticleFade =
117
+ renderer.softParticle === undefined
118
+ ? 1
119
+ : Math.max(
120
+ 0,
121
+ Math.min(
122
+ 1,
123
+ (sample.sceneDepth - sample.particleDepth) / renderer.softParticle.fadeDistance,
124
+ ),
125
+ );
126
+ return ok({
127
+ frameIndex,
128
+ pivot: renderer.pivot ?? ([0, 0] as const),
129
+ softParticleFade,
130
+ sortingKey: renderer.sorting === 'back-to-front' ? sample.particleDepth : 0,
131
+ });
132
+ }
133
+
134
+ export function topologyRecoveryHint(
135
+ topology: ParticleTopologyKind,
136
+ reason: 'capacity' | 'broken' | 'degenerate' | 'device',
137
+ ): string {
138
+ if (reason === 'capacity')
139
+ return `${topology} capacity overflow was bounded; increase its explicit capacity`;
140
+ if (reason === 'broken')
141
+ return `${topology} continuity broke; inspect its explicit source key and keep the last valid segment`;
142
+ if (reason === 'degenerate')
143
+ return `${topology} produced no drawable segment; preserve zero output and inspect source endpoints`;
144
+ return `${topology} device resources recovered from the last known good generation`;
145
+ }
146
+
147
+ interface GpuParticleFeatureOptions {
148
+ readonly camera: { read(world: World): ParticleRenderCamera | undefined };
149
+ readonly dataInterfaces?: Pick<VfxDataInterfaceRegistry, 'resolve'>;
150
+ readonly material?: { read(world: World, guid: string): MaterialAsset | undefined };
151
+ readonly mesh?: { read(world: World, guid: string): MeshAsset | undefined };
152
+ readonly playerConsumption?: {
153
+ readonly isEnabled: (world: World, player: EntityHandle) => boolean;
154
+ };
155
+ }
156
+
157
+ interface ExtractedWorld {
158
+ readonly world: World;
159
+ readonly runtime: VfxGpuRuntime;
160
+ readonly camera: ParticleRenderCamera;
161
+ readonly intents: readonly VfxGpuTickIntent[];
162
+ }
163
+
164
+ interface ExtractedFrame {
165
+ readonly worlds: readonly ExtractedWorld[];
166
+ readonly frameNumber: number;
167
+ }
168
+
169
+ function finite(value: unknown, fallback: number): number {
170
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
171
+ }
172
+
173
+ function vector(value: unknown, fallback: readonly number[], size: number): readonly number[] {
174
+ return Array.isArray(value)
175
+ ? Array.from({ length: size }, (_, index) => finite(value[index], fallback[index] ?? 0))
176
+ : fallback;
177
+ }
178
+
179
+ function runtimeData(
180
+ intent: VfxGpuTickIntent,
181
+ camera: ParticleRenderCamera,
182
+ material: MaterialAsset | undefined,
183
+ localToWorld: Float32Array,
184
+ renderer?: ParticleRendererSource,
185
+ rendererIndex = 0,
186
+ ): Uint8Array {
187
+ const storage = new ArrayBuffer(RUNTIME_BYTES);
188
+ const floats = new Float32Array(storage);
189
+ const words = new Uint32Array(storage);
190
+ floats[0] = intent.fixedDelta;
191
+ words[1] = intent.phaseTick;
192
+ words[2] = intent.seed;
193
+ words[3] = intent.playCycle;
194
+ words[4] = intent.emitter.capacity;
195
+ words[5] = intent.spawnCount;
196
+ words[6] = intent.firstParticleId;
197
+ words[7] = intent.emitter.renderers.length;
198
+ floats.set(camera.viewProjection, 8);
199
+ floats.set(camera.right, 24);
200
+ floats.set(camera.up, 28);
201
+ const values = material?.values ?? {};
202
+ floats.set(vector(values.baseColor, [1, 1, 1, 1], 4), 32);
203
+ const emissive = vector(values.emissive, [0, 0, 0], 3);
204
+ floats.set(emissive, 36);
205
+ floats[39] = finite(values.emissiveIntensity, 0);
206
+ floats[40] = finite(values.metallic, 0);
207
+ floats[41] = finite(values.roughness, 0.5);
208
+ floats[42] = finite(values.clearcoat, 0);
209
+ floats[43] = finite(values.clearcoatRoughness, 0.5);
210
+ floats.set(localToWorld, 44);
211
+ words[60] = rendererIndex;
212
+ words[61] = renderer?.kind === 'trail' ? renderer.historyLength : 0;
213
+ words[62] =
214
+ renderer?.kind === 'ribbon' || renderer?.kind === 'trail' || renderer?.kind === 'beam'
215
+ ? renderer.capacity
216
+ : intent.emitter.capacity;
217
+ words[63] =
218
+ renderer?.kind === 'billboard' && renderer.sorting === 'back-to-front'
219
+ ? 2
220
+ : renderer?.kind === 'billboard' && renderer.sorting === 'emitter'
221
+ ? 1
222
+ : 0;
223
+ floats[64] =
224
+ renderer?.kind === 'billboard'
225
+ ? (renderer.pivot?.[0] ?? 0)
226
+ : renderer?.kind === 'ribbon' || renderer?.kind === 'trail' || renderer?.kind === 'beam'
227
+ ? (renderer.width ?? 0.1)
228
+ : 0.1;
229
+ floats[65] = renderer?.kind === 'billboard' ? (renderer.pivot?.[1] ?? 0) : 0;
230
+ floats[66] = renderer?.kind === 'billboard' ? (renderer.softParticle?.fadeDistance ?? 0) : 0;
231
+ const sheet = renderer?.kind === 'billboard' ? renderer.textureSheet : undefined;
232
+ floats[68] = sheet?.columns ?? 1;
233
+ floats[69] = sheet?.rows ?? 1;
234
+ floats[70] = sheet?.frameRate ?? 0;
235
+ floats[71] = sheet?.frameCount ?? (sheet === undefined ? 1 : sheet.columns * sheet.rows);
236
+ return new Uint8Array(storage);
237
+ }
238
+
239
+ function emitterTransform(world: World, intent: VfxGpuTickIntent): Float32Array {
240
+ if (intent.emitter.space === 'world') return IDENTITY_MATRIX;
241
+ const transform = world.get(intent.player, Transform);
242
+ return transform.ok ? transform.value.world : IDENTITY_MATRIX;
243
+ }
244
+
245
+ function emitterVisible(
246
+ intent: VfxGpuTickIntent,
247
+ camera: ParticleRenderCamera,
248
+ localToWorld: Float32Array,
249
+ ): boolean {
250
+ const bounds = intent.emitter.bounds;
251
+ const center =
252
+ bounds.kind === 'sphere'
253
+ ? bounds.center
254
+ : ([
255
+ (bounds.min[0] + bounds.max[0]) * 0.5,
256
+ (bounds.min[1] + bounds.max[1]) * 0.5,
257
+ (bounds.min[2] + bounds.max[2]) * 0.5,
258
+ ] as const);
259
+ const radius =
260
+ bounds.kind === 'sphere'
261
+ ? bounds.radius
262
+ : Math.hypot(
263
+ (bounds.max[0] - bounds.min[0]) * 0.5,
264
+ (bounds.max[1] - bounds.min[1]) * 0.5,
265
+ (bounds.max[2] - bounds.min[2]) * 0.5,
266
+ );
267
+ const matrix = (index: number): number => localToWorld[index] ?? 0;
268
+ const worldCenter = new Float32Array([
269
+ matrix(0) * center[0] + matrix(4) * center[1] + matrix(8) * center[2] + matrix(12),
270
+ matrix(1) * center[0] + matrix(5) * center[1] + matrix(9) * center[2] + matrix(13),
271
+ matrix(2) * center[0] + matrix(6) * center[1] + matrix(10) * center[2] + matrix(14),
272
+ ]);
273
+ const scale = Math.max(
274
+ Math.hypot(matrix(0), matrix(1), matrix(2)),
275
+ Math.hypot(matrix(4), matrix(5), matrix(6)),
276
+ Math.hypot(matrix(8), matrix(9), matrix(10)),
277
+ );
278
+ const planes = frustum.fromViewProjection(frustum.create(), camera.viewProjection);
279
+ return frustum.intersectsSphere(planes, worldCenter, radius * scale);
280
+ }
281
+
282
+ function resetData(size: number): Uint8Array {
283
+ return new Uint8Array(size);
284
+ }
285
+
286
+ function requiresSceneDepth(intent: VfxGpuTickIntent): boolean {
287
+ return (intent.emitter.reflection.dataInterfaces ?? []).some(
288
+ (requirement) => requirement.kind === 'scene-depth',
289
+ );
290
+ }
291
+
292
+ function planFailure(): RenderFeatureStageFailedError {
293
+ return new RenderFeatureStageFailedError(IDENTITY, -1, 'plan', 'next-frame');
294
+ }
295
+
296
+ type PlanResource = RenderFeaturePlan['resources'][number];
297
+ type PlanPass = RenderFeaturePlan['passes'][number];
298
+
299
+ function planName(value: string, maxLength = 24): string {
300
+ const normalized = value.toLowerCase().replaceAll(/[^a-z0-9.-]/g, '-');
301
+ return (normalized.length === 0 ? 'unnamed' : normalized).slice(0, maxLength);
302
+ }
303
+
304
+ function computeBindingEntries(
305
+ intent: VfxGpuTickIntent,
306
+ resources: Readonly<Record<number, string>>,
307
+ ): readonly { readonly binding: number; readonly resource: string }[] {
308
+ const declared = new Set(
309
+ (intent.emitter.reflection.bindings[0]?.entries ?? [])
310
+ .filter((entry) => entry.buffer !== undefined)
311
+ .map((entry) => entry.binding),
312
+ );
313
+ return Object.entries(resources).flatMap(([binding, resource]) =>
314
+ declared.has(Number(binding)) ? [{ binding: Number(binding), resource }] : [],
315
+ );
316
+ }
317
+
318
+ function simulationDispatches(
319
+ intent: VfxGpuTickIntent,
320
+ stages: VfxValidatedStagePlan,
321
+ ): Extract<PlanPass, { readonly kind: 'compute' }>['dispatches'] {
322
+ const groups = Math.max(1, Math.ceil(intent.emitter.capacity / WORKGROUP_SIZE));
323
+ return [
324
+ { kind: 'direct', entryPoint: 'forgeax_vfx_spawn_main', workgroups: [groups] },
325
+ { kind: 'direct', entryPoint: 'forgeax_vfx_update_main', workgroups: [groups] },
326
+ ...stages.stages.map((stage) => ({
327
+ kind: 'direct' as const,
328
+ entryPoint: stage.entryPoint,
329
+ workgroups: [groups] as const,
330
+ })),
331
+ { kind: 'direct', entryPoint: 'forgeax_vfx_scan_blocks_main', workgroups: [groups] },
332
+ { kind: 'direct', entryPoint: 'forgeax_vfx_scan_block_offsets_main', workgroups: [1] },
333
+ { kind: 'direct', entryPoint: 'forgeax_vfx_add_offsets_main', workgroups: [groups] },
334
+ { kind: 'direct', entryPoint: 'forgeax_vfx_compact_main', workgroups: [groups] },
335
+ {
336
+ kind: 'direct',
337
+ entryPoint: 'forgeax_vfx_event_main',
338
+ workgroups: [Math.max(1, Math.ceil(eventInputCapacity(intent.emitter) / 64))],
339
+ },
340
+ ];
341
+ }
342
+
343
+ export function gpuParticleRenderFeature(
344
+ options: GpuParticleFeatureOptions,
345
+ ): RenderFeature<ExtractedFrame> {
346
+ return {
347
+ identity: IDENTITY,
348
+ requiredCapabilities: ['compute', 'indirectDrawing'],
349
+ requiredMaterialShaders: Object.values(PARTICLE_SHADER_IDENTIFIERS),
350
+ extract: (context) => {
351
+ const extracted: ExtractedWorld[] = [];
352
+ for (const world of context.worlds) {
353
+ if (!world.hasResource(VFX_GPU_RUNTIME_RESOURCE_KEY)) continue;
354
+ const camera = options.camera.read(world);
355
+ if (camera === undefined) continue;
356
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
357
+ const intents = runtime.snapshot().filter((intent) => {
358
+ if (options.playerConsumption?.isEnabled(world, intent.player) === false) return false;
359
+ const requirements = intent.emitter.reflection.dataInterfaces ?? [];
360
+ return (
361
+ requirements.length === 0 ||
362
+ options.dataInterfaces?.resolve(requirements, intent.instanceGeneration).ok === true
363
+ );
364
+ });
365
+ extracted.push({ world, runtime, camera, intents });
366
+ }
367
+ return ok({ worlds: extracted, frameNumber: context.frameNumber });
368
+ },
369
+ plan: (frame, context) => {
370
+ const resources: PlanResource[] = [];
371
+ const passes: PlanPass[] = [];
372
+ const dispatchedIntents = new Set<VfxGpuTickIntent>();
373
+ const colorTarget =
374
+ context.targets.find((candidate) => candidate.kind === 'color') ??
375
+ context.targets.find((candidate) => candidate.kind === 'swapchain');
376
+ const depthTarget = context.targets.find((candidate) => candidate.kind === 'depth');
377
+
378
+ for (const [worldIndex, entry] of frame.worlds.entries()) {
379
+ for (const [intentIndex, intent] of entry.intents.entries()) {
380
+ if (!entry.runtime.isEmitterSessionEnabled(intent.player, intent.emitter.id)) continue;
381
+ const localToWorld = emitterTransform(entry.world, intent);
382
+ const visible = emitterVisible(intent, entry.camera, localToWorld);
383
+ entry.runtime.setEmitterCameraVisibility(intent.player, intent.emitter.id, visible);
384
+ if (!visible) continue;
385
+ if (requiresSceneDepth(intent) && depthTarget === undefined) continue;
386
+
387
+ const stagePlan = validatedStagePlan(
388
+ intent.emitter.reflection.stages,
389
+ intent.instanceGeneration,
390
+ );
391
+ if (!stagePlan.ok) return err(planFailure());
392
+
393
+ const prefix = `vfx.w-${worldIndex}.i-${intentIndex}.${planName(intent.emitter.id)}`;
394
+ const program = `${prefix}.compute-program`;
395
+ const particles = `${prefix}.particles`;
396
+ const runtime = `${prefix}.runtime`;
397
+ const aliveIndices = `${prefix}.alive-indices`;
398
+ const counters = `${prefix}.counters`;
399
+ const indirect = `${prefix}.indirect`;
400
+ const scratch = `${prefix}.scratch`;
401
+ const sharedInstances = `${prefix}.shared-instances`;
402
+ const eventInputs = `${prefix}.event-inputs`;
403
+ const events = `${prefix}.events`;
404
+ const bindings = `${prefix}.simulation-bindings`;
405
+ const capacity = intent.emitter.capacity;
406
+ const renderers = intent.emitter.renderers;
407
+ const meshes = renderers.map((renderer) =>
408
+ renderer.kind === 'mesh' ? options.mesh?.read(entry.world, renderer.mesh) : undefined,
409
+ );
410
+ const indirectWords = new Uint32Array(Math.max(1, renderers.length) * 5);
411
+
412
+ for (const [rendererIndex, renderer] of renderers.entries()) {
413
+ const mesh = meshes[rendererIndex];
414
+ const submesh =
415
+ renderer.kind === 'mesh' ? mesh?.submeshes[renderer.submesh ?? 0] : undefined;
416
+ if (renderer.kind === 'mesh' && submesh === undefined) return err(planFailure());
417
+ if (
418
+ (renderer.kind === 'ribbon' ||
419
+ renderer.kind === 'trail' ||
420
+ renderer.kind === 'beam') &&
421
+ !createTopologyResourcePlan(renderer).ok
422
+ ) {
423
+ return err(planFailure());
424
+ }
425
+ indirectWords[rendererIndex * 5] =
426
+ renderer.kind === 'mesh'
427
+ ? mesh?.indices === undefined
428
+ ? (submesh?.vertexCount ?? 0)
429
+ : (submesh?.indexCount ?? 0)
430
+ : 6;
431
+ indirectWords[rendererIndex * 5 + 2] =
432
+ renderer.kind === 'mesh' && mesh?.indices !== undefined
433
+ ? (submesh?.indexOffset ?? 0)
434
+ : 0;
435
+ }
436
+
437
+ const scratchBytes = (capacity * 2 + Math.ceil(capacity / WORKGROUP_SIZE)) * 4;
438
+ const eventInputBytes = Math.max(
439
+ 4,
440
+ eventInputCapacity(intent.emitter) * VFX_EVENT_INPUT_BYTES,
441
+ );
442
+ const eventBytes = Math.max(4, eventCapacity(intent.emitter) * VFX_EVENT_BYTES);
443
+ resources.push(
444
+ {
445
+ kind: 'compute-program',
446
+ name: program,
447
+ program: {
448
+ wgsl: intent.emitter.wgsl,
449
+ entryPoints: intent.emitter.reflection.entryPoints,
450
+ bindings: intent.emitter.reflection.bindings,
451
+ },
452
+ },
453
+ {
454
+ kind: 'buffer',
455
+ name: particles,
456
+ size: capacity * PARTICLE_BYTES,
457
+ usage: ['storage'],
458
+ ...(intent.reset ? { data: resetData(capacity * PARTICLE_BYTES) } : {}),
459
+ },
460
+ { kind: 'buffer', name: aliveIndices, size: capacity * 4, usage: ['storage'] },
461
+ {
462
+ kind: 'buffer',
463
+ name: counters,
464
+ size: COUNTERS_BYTES,
465
+ usage: ['storage'],
466
+ ...(intent.reset ? { data: resetData(COUNTERS_BYTES) } : {}),
467
+ },
468
+ {
469
+ kind: 'buffer',
470
+ name: indirect,
471
+ size: indirectWords.byteLength,
472
+ usage: ['storage', 'indirect'],
473
+ data: indirectWords,
474
+ },
475
+ {
476
+ kind: 'buffer',
477
+ name: scratch,
478
+ size: scratchBytes,
479
+ usage: ['storage'],
480
+ ...(intent.reset ? { data: resetData(scratchBytes) } : {}),
481
+ },
482
+ {
483
+ kind: 'buffer',
484
+ name: sharedInstances,
485
+ size: capacity * Math.max(BILLBOARD_INSTANCE_BYTES, MESH_INSTANCE_BYTES),
486
+ usage: ['storage', 'vertex'],
487
+ },
488
+ {
489
+ kind: 'buffer',
490
+ name: eventInputs,
491
+ size: eventInputBytes,
492
+ usage: ['storage'],
493
+ data: encodeEventInputs(intent),
494
+ },
495
+ {
496
+ kind: 'buffer',
497
+ name: events,
498
+ size: eventBytes,
499
+ usage: ['storage'],
500
+ ...(intent.reset ? { data: resetData(eventBytes) } : {}),
501
+ },
502
+ {
503
+ kind: 'buffer',
504
+ name: runtime,
505
+ size: RUNTIME_BYTES,
506
+ usage: ['uniform'],
507
+ data: runtimeData(intent, entry.camera, undefined, localToWorld),
508
+ },
509
+ {
510
+ kind: 'compute-bindings',
511
+ name: bindings,
512
+ program,
513
+ entries: computeBindingEntries(intent, {
514
+ 0: particles,
515
+ 1: runtime,
516
+ 2: aliveIndices,
517
+ 3: counters,
518
+ 4: indirect,
519
+ 5: scratch,
520
+ 6: sharedInstances,
521
+ 8: eventInputs,
522
+ 9: events,
523
+ }),
524
+ },
525
+ );
526
+
527
+ const entryPoints = new Set(intent.emitter.reflection.entryPoints);
528
+ const dispatches = simulationDispatches(intent, stagePlan.value).filter((dispatch) =>
529
+ entryPoints.has(dispatch.entryPoint),
530
+ );
531
+ if (dispatches.length > 0) {
532
+ passes.push({
533
+ kind: 'compute',
534
+ name: `${prefix}.simulate`,
535
+ program,
536
+ bindings,
537
+ dispatches,
538
+ });
539
+ dispatchedIntents.add(intent);
540
+ }
541
+
542
+ for (const [rendererIndex, renderer] of renderers.entries()) {
543
+ const rendererPrefix = `${prefix}.renderer-${rendererIndex}`;
544
+ const isBillboard = renderer.kind === 'billboard';
545
+ const isTopology =
546
+ renderer.kind === 'ribbon' || renderer.kind === 'trail' || renderer.kind === 'beam';
547
+ const topologyPlan = isTopology ? createTopologyResourcePlan(renderer) : undefined;
548
+ if (topologyPlan !== undefined && !topologyPlan.ok) return err(planFailure());
549
+ const material = options.material?.read(entry.world, renderer.material);
550
+ const materialPass = particleMaterialPass(renderer.kind, material);
551
+ const mesh = meshes[rendererIndex];
552
+ const submesh =
553
+ renderer.kind === 'mesh' ? mesh?.submeshes[renderer.submesh ?? 0] : undefined;
554
+ const indexFormat = mesh?.indices instanceof Uint32Array ? 'uint32' : 'uint16';
555
+ const instances = `${rendererPrefix}.instances`;
556
+ const history = `${rendererPrefix}.history`;
557
+ const projectionRuntime = `${rendererPrefix}.runtime`;
558
+ const projectionBindings = `${rendererPrefix}.compute-bindings`;
559
+ const vertexLayout = isBillboard
560
+ ? RENDER_FEATURE_VERTEX_LAYOUTS.billboardMaterialInstance
561
+ : isTopology
562
+ ? RENDER_FEATURE_VERTEX_LAYOUTS.topologySegmentInstance
563
+ : RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInstance;
564
+ const instanceBytes = isTopology
565
+ ? (topologyPlan?.value.vertexBytes ?? 16)
566
+ : capacity * (isBillboard ? BILLBOARD_INSTANCE_BYTES : MESH_INSTANCE_BYTES);
567
+ const historyBytes =
568
+ renderer.kind === 'trail'
569
+ ? Math.max(16, renderer.capacity * renderer.historyLength * 16)
570
+ : 16;
571
+
572
+ resources.push(
573
+ {
574
+ kind: 'buffer',
575
+ name: instances,
576
+ size: instanceBytes,
577
+ usage: ['storage', 'vertex'],
578
+ },
579
+ {
580
+ kind: 'buffer',
581
+ name: history,
582
+ size: historyBytes,
583
+ usage: ['storage'],
584
+ ...(intent.reset ? { data: resetData(historyBytes) } : {}),
585
+ },
586
+ {
587
+ kind: 'buffer',
588
+ name: projectionRuntime,
589
+ size: RUNTIME_BYTES,
590
+ usage: ['uniform'],
591
+ data: runtimeData(
592
+ { ...intent, fixedDelta: 0, spawnCount: 0 },
593
+ entry.camera,
594
+ material,
595
+ localToWorld,
596
+ renderer,
597
+ rendererIndex,
598
+ ),
599
+ },
600
+ {
601
+ kind: 'compute-bindings',
602
+ name: projectionBindings,
603
+ program,
604
+ entries: computeBindingEntries(intent, {
605
+ 0: particles,
606
+ 1: projectionRuntime,
607
+ 2: aliveIndices,
608
+ 3: counters,
609
+ 4: indirect,
610
+ 5: history,
611
+ 6: instances,
612
+ 8: eventInputs,
613
+ 9: events,
614
+ }),
615
+ },
616
+ );
617
+
618
+ const projectionDispatches: Extract<
619
+ PlanPass,
620
+ { readonly kind: 'compute' }
621
+ >['dispatches'][number][] = [];
622
+ const pushProjection = (entryPoint: string, workgroups: number): void => {
623
+ if (!entryPoints.has(entryPoint)) return;
624
+ projectionDispatches.push({
625
+ kind: 'direct',
626
+ entryPoint,
627
+ workgroups: [Math.max(1, workgroups)],
628
+ });
629
+ };
630
+ if (isBillboard && renderer.sorting === 'back-to-front') {
631
+ pushProjection('forgeax_vfx_sort_main', 1);
632
+ }
633
+ if (renderer.kind === 'trail') {
634
+ pushProjection(
635
+ 'forgeax_vfx_trail_history_main',
636
+ Math.ceil(renderer.capacity / WORKGROUP_SIZE),
637
+ );
638
+ }
639
+ const projectionCount =
640
+ renderer.kind === 'trail'
641
+ ? renderer.capacity * Math.max(1, renderer.historyLength - 1)
642
+ : isTopology
643
+ ? renderer.capacity
644
+ : capacity;
645
+ pushProjection(
646
+ renderer.kind === 'billboard'
647
+ ? 'forgeax_vfx_billboard_main'
648
+ : renderer.kind === 'mesh'
649
+ ? 'forgeax_vfx_mesh_main'
650
+ : `forgeax_vfx_${renderer.kind}_main`,
651
+ Math.ceil(projectionCount / WORKGROUP_SIZE),
652
+ );
653
+ if (projectionDispatches.length > 0) {
654
+ passes.push({
655
+ kind: 'compute',
656
+ name: `${rendererPrefix}.project`,
657
+ program,
658
+ bindings: projectionBindings,
659
+ dispatches: projectionDispatches,
660
+ });
661
+ }
662
+
663
+ const graphicsProgram = `${rendererPrefix}.graphics-program`;
664
+ const graphicsBindings = `${rendererPrefix}.graphics-bindings`;
665
+ const vertexData = `${rendererPrefix}.vertex-data`;
666
+ const renderState =
667
+ isBillboard && depthTarget !== undefined
668
+ ? { ...(materialPass.renderState ?? {}), depthWriteEnabled: false }
669
+ : materialPass.renderState;
670
+ resources.push(
671
+ {
672
+ kind: 'graphics-program',
673
+ name: graphicsProgram,
674
+ program: {
675
+ shader: materialPass.shader,
676
+ vertexLayout,
677
+ colorFormats: [colorTarget?.format ?? 'rgba8unorm-srgb'],
678
+ ...(depthTarget === undefined ? {} : { depthFormat: depthTarget.format }),
679
+ sampleCount: colorTarget?.sampleCount ?? 1,
680
+ topology: submesh?.topology ?? 'triangle-list',
681
+ ...(mesh?.indices === undefined ? {} : { indexFormat }),
682
+ ...(renderState === undefined ? {} : { renderState }),
683
+ },
684
+ },
685
+ {
686
+ kind: 'graphics-bindings',
687
+ name: graphicsBindings,
688
+ program: graphicsProgram,
689
+ values: {
690
+ group: 0,
691
+ runtime: projectionRuntime,
692
+ instances,
693
+ ...(isBillboard ? { sceneDepthBinding: 1 } : {}),
694
+ },
695
+ ...(isBillboard && depthTarget !== undefined
696
+ ? { logicalTargets: { sceneDepth: depthTarget.name } }
697
+ : {}),
698
+ },
699
+ { kind: 'vertex-data', name: vertexData, layout: vertexLayout, buffer: instances },
700
+ );
701
+ const drawBindings = [graphicsBindings];
702
+ if (particleMaterialUsesBindings(material)) {
703
+ const materialBindings = `${rendererPrefix}.material-bindings`;
704
+ resources.push({
705
+ kind: 'graphics-bindings',
706
+ name: materialBindings,
707
+ program: graphicsProgram,
708
+ values: {
709
+ group: 1,
710
+ material: { world: worldIndex, guid: renderer.material },
711
+ },
712
+ });
713
+ drawBindings.push(materialBindings);
714
+ }
715
+
716
+ const vertexBindings: { readonly slot: number; readonly resource: string }[] = [];
717
+ let indexData:
718
+ | { readonly resource: string; readonly format: 'uint16' | 'uint32' }
719
+ | undefined;
720
+ if (renderer.kind === 'mesh') {
721
+ if (mesh === undefined) return err(planFailure());
722
+ const geometryBuffer = `${rendererPrefix}.geometry-buffer`;
723
+ const geometry = `${rendererPrefix}.geometry`;
724
+ const geometryData = canonicalMeshVertices(mesh);
725
+ resources.push(
726
+ {
727
+ kind: 'buffer',
728
+ name: geometryBuffer,
729
+ size: geometryData.byteLength,
730
+ usage: ['vertex'],
731
+ data: geometryData,
732
+ },
733
+ {
734
+ kind: 'vertex-data',
735
+ name: geometry,
736
+ layout: vertexLayout,
737
+ buffer: geometryBuffer,
738
+ },
739
+ );
740
+ vertexBindings.push(
741
+ { slot: 0, resource: geometry },
742
+ { slot: 1, resource: vertexData },
743
+ );
744
+ if (mesh.indices !== undefined) {
745
+ const indexBuffer = `${rendererPrefix}.index-buffer`;
746
+ const indices = `${rendererPrefix}.indices`;
747
+ resources.push(
748
+ {
749
+ kind: 'buffer',
750
+ name: indexBuffer,
751
+ size: mesh.indices.byteLength,
752
+ usage: ['index'],
753
+ data: mesh.indices,
754
+ },
755
+ {
756
+ kind: 'index-data',
757
+ name: indices,
758
+ format: indexFormat,
759
+ buffer: indexBuffer,
760
+ },
761
+ );
762
+ indexData = { resource: indices, format: indexFormat };
763
+ }
764
+ } else {
765
+ vertexBindings.push({ slot: 0, resource: vertexData });
766
+ }
767
+
768
+ passes.push({
769
+ kind: 'raster',
770
+ name: `${rendererPrefix}.raster`,
771
+ colorAttachments: [
772
+ {
773
+ target: colorTarget?.name ?? 'swapchain',
774
+ loadOp: 'load',
775
+ storeOp: 'store',
776
+ },
777
+ ],
778
+ ...(depthTarget === undefined
779
+ ? {}
780
+ : {
781
+ depthStencilAttachment: {
782
+ target: depthTarget.name,
783
+ depthLoadOp: 'load',
784
+ depthStoreOp: 'store',
785
+ },
786
+ }),
787
+ ...(isBillboard && depthTarget !== undefined
788
+ ? { sampledTargets: [depthTarget.name] }
789
+ : {}),
790
+ draws: [
791
+ {
792
+ program: graphicsProgram,
793
+ bindings: drawBindings,
794
+ vertexData: vertexBindings,
795
+ ...(indexData === undefined ? {} : { indexData }),
796
+ draw: {
797
+ kind: indexData === undefined ? 'draw-indirect' : 'draw-indexed-indirect',
798
+ resource: indirect,
799
+ offset: rendererIndex * 20,
800
+ },
801
+ },
802
+ ],
803
+ });
804
+ }
805
+ }
806
+ }
807
+ for (const entry of frame.worlds) {
808
+ for (const intent of entry.intents) {
809
+ if (dispatchedIntents.has(intent)) {
810
+ entry.runtime.markEventDispatched(intent.player, intent.eventCounters);
811
+ }
812
+ }
813
+ const lastIntent = entry.intents.at(-1);
814
+ if (lastIntent !== undefined) entry.runtime.commit(lastIntent.sequence);
815
+ }
816
+ return ok<RenderFeaturePlan>({ resources, passes });
817
+ },
818
+ };
819
+ }