@forgeax/engine-vfx-render 0.1.2

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 +163 -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 +96 -0
  39. package/dist/feature/gpu-particle-feature.d.ts.map +1 -0
  40. package/dist/feature/particle-resources.d.ts +66 -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 +87 -0
  53. package/dist/host/vfx-runtime-host.d.ts.map +1 -0
  54. package/dist/index.d.ts +12 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.mjs +1572 -0
  57. package/dist/index.mjs.map +1 -0
  58. package/package.json +63 -0
  59. package/src/__tests__/billboard-advanced.integration.test.ts +69 -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 +59 -0
  64. package/src/__tests__/gpu-host.integration.test.ts +441 -0
  65. package/src/__tests__/inspect-snapshot.integration.test.ts +26 -0
  66. package/src/__tests__/particle-resources.unit.test.ts +75 -0
  67. package/src/__tests__/render-vocabulary-owner.test-d.ts +81 -0
  68. package/src/__tests__/stage-plan.integration.test.ts +16 -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 +1225 -0
  77. package/src/feature/particle-resources.ts +203 -0
  78. package/src/feature/stage-plan.ts +282 -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 +402 -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,1225 @@
1
+ import type { EntityHandle, World } from '@forgeax/engine-ecs';
2
+ import { frustum } from '@forgeax/engine-math';
3
+ import {
4
+ RENDER_FEATURE_VERTEX_LAYOUTS,
5
+ type RenderError,
6
+ type RenderFeature,
7
+ type RenderFeatureDrawRecord,
8
+ type RenderFeatureGpuBindingsRef,
9
+ type RenderFeatureGpuBufferRef,
10
+ type RenderFeatureGpuProgramRef,
11
+ RenderFeaturePreparationFailedError,
12
+ RenderFeatureStageFailedError,
13
+ type RenderFeatureTargetHandle,
14
+ } from '@forgeax/engine-render';
15
+ import { Transform } from '@forgeax/engine-scene';
16
+ import { err, type MaterialAsset, type MeshAsset, ok } from '@forgeax/engine-types';
17
+ import type { ParticleRendererSource } from '@forgeax/engine-vfx';
18
+ import {
19
+ VFX_GPU_RUNTIME_RESOURCE_KEY,
20
+ type VfxGpuRuntime,
21
+ type VfxGpuTickIntent,
22
+ } from '@forgeax/engine-vfx';
23
+ import type { VfxDataInterfaceRegistry } from '../host/data-interface-providers.js';
24
+ import type { ParticleRenderCamera } from './camera.js';
25
+ import {
26
+ encodeEventInputs,
27
+ eventCapacity,
28
+ eventInputCapacity,
29
+ VFX_EVENT_BYTES,
30
+ VFX_EVENT_INPUT_BYTES,
31
+ } from './event-resources.js';
32
+ import {
33
+ canonicalMeshVertices,
34
+ createTopologyResourcePlan,
35
+ PARTICLE_SHADER_IDENTIFIERS,
36
+ particleMaterialPass,
37
+ particleMaterialUsesBindings,
38
+ particleMaterialUsesSceneDepth,
39
+ } from './particle-resources.js';
40
+ import {
41
+ observeStagePlan,
42
+ stageDispatches,
43
+ type VfxStagePlanObservation,
44
+ type VfxStageReadiness,
45
+ type VfxValidatedStagePlan,
46
+ validatedStagePlan,
47
+ } from './stage-plan.js';
48
+
49
+ const IDENTITY = 'forgeax.vfx-render.gpu-particles';
50
+ const WORKGROUP_SIZE = 256;
51
+ const PARTICLE_BYTES = 80;
52
+ const BILLBOARD_INSTANCE_BYTES = 31 * 4;
53
+ const MESH_INSTANCE_BYTES = 28 * 4;
54
+ const COUNTERS_BYTES = 24;
55
+ const RUNTIME_BYTES = 72 * 4;
56
+ const MAX_TICK_RINGS = 8;
57
+ const IDENTITY_MATRIX = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
58
+
59
+ type ParticleRendererKind = ParticleRendererSource['kind'];
60
+ type ParticleTopologyRenderer = Extract<ParticleRendererSource, { readonly capacity: number }>;
61
+ type ParticleTopologyKind = ParticleTopologyRenderer['kind'];
62
+ type VfxStageOutput = VfxStagePlanObservation['stageOutput'];
63
+
64
+ export interface VfxRenderInspectInput {
65
+ readonly topology: ParticleRendererKind;
66
+ readonly capacity: number;
67
+ readonly produced: number;
68
+ readonly dropped: number;
69
+ readonly stageReadiness: readonly unknown[];
70
+ readonly providerReadiness: unknown;
71
+ readonly gpuTiming: unknown;
72
+ }
73
+
74
+ export function createVfxRenderInspectSnapshot(input: VfxRenderInspectInput) {
75
+ return {
76
+ topology: input.topology,
77
+ counters: { capacity: input.capacity, produced: input.produced, dropped: input.dropped },
78
+ stageReadiness: input.stageReadiness,
79
+ providerReadiness: input.providerReadiness,
80
+ gpuTiming: input.gpuTiming,
81
+ } as const;
82
+ }
83
+
84
+ export interface BillboardAdvancedSample {
85
+ readonly age: number;
86
+ readonly lifetime: number;
87
+ readonly particleDepth: number;
88
+ readonly sceneDepth: number;
89
+ readonly depthAvailable: boolean;
90
+ }
91
+
92
+ export function resolveBillboardAdvancedState(
93
+ renderer: Extract<ParticleRendererSource, { readonly kind: 'billboard' }>,
94
+ sample: BillboardAdvancedSample,
95
+ ) {
96
+ if (renderer.softParticle !== undefined && !sample.depthAvailable) {
97
+ return err({
98
+ code: 'vfx-renderer-depth-missing' as const,
99
+ expected: 'a scene-depth provider for soft particles',
100
+ hint: 'attach the scene-depth data interface or disable soft particles',
101
+ detail: { path: 'renderer.softParticle' },
102
+ });
103
+ }
104
+ const sheet = renderer.textureSheet;
105
+ const frameCount = sheet?.frameCount ?? (sheet === undefined ? 1 : sheet.columns * sheet.rows);
106
+ const frameIndex =
107
+ sheet === undefined || sheet.frameRate === 0
108
+ ? 0
109
+ : Math.min(
110
+ frameCount - 1,
111
+ Math.max(0, Math.floor(Math.max(0, sample.age) * sheet.frameRate)),
112
+ );
113
+ const softParticleFade =
114
+ renderer.softParticle === undefined
115
+ ? 1
116
+ : Math.max(
117
+ 0,
118
+ Math.min(
119
+ 1,
120
+ (sample.sceneDepth - sample.particleDepth) / renderer.softParticle.fadeDistance,
121
+ ),
122
+ );
123
+ return ok({
124
+ frameIndex,
125
+ pivot: renderer.pivot ?? ([0, 0] as const),
126
+ softParticleFade,
127
+ sortingKey: renderer.sorting === 'back-to-front' ? sample.particleDepth : 0,
128
+ });
129
+ }
130
+
131
+ export function topologyRecoveryHint(
132
+ topology: ParticleTopologyKind,
133
+ reason: 'capacity' | 'broken' | 'degenerate' | 'device',
134
+ ): string {
135
+ if (reason === 'capacity')
136
+ return `${topology} capacity overflow was bounded; increase its explicit capacity`;
137
+ if (reason === 'broken')
138
+ return `${topology} continuity broke; inspect its explicit source key and keep the last valid segment`;
139
+ if (reason === 'degenerate')
140
+ return `${topology} produced no drawable segment; preserve zero output and inspect source endpoints`;
141
+ return `${topology} device resources recovered from the last known good generation`;
142
+ }
143
+
144
+ interface GpuParticleFeatureOptions {
145
+ readonly camera: { read(world: World): ParticleRenderCamera | undefined };
146
+ readonly dataInterfaces?: Pick<VfxDataInterfaceRegistry, 'resolve'>;
147
+ readonly material?: { read(world: World, guid: string): MaterialAsset | undefined };
148
+ readonly mesh?: { read(world: World, guid: string): MeshAsset | undefined };
149
+ readonly playerConsumption?: {
150
+ readonly isEnabled: (world: World, player: EntityHandle) => boolean;
151
+ };
152
+ }
153
+
154
+ /**
155
+ * GPU work observed from the producer-owned VFX feature for the last frame.
156
+ * Counts are published only after the feature stages its real compute and
157
+ * indirect graphics passes; preview consumers must not manufacture them.
158
+ */
159
+ export interface VfxGpuRenderObservation {
160
+ readonly frameNumber: number;
161
+ readonly dispatches: number;
162
+ readonly indirectDraws: number;
163
+ readonly subjectOutputs: number;
164
+ }
165
+
166
+ interface ExtractedWorld {
167
+ readonly world: World;
168
+ readonly runtime: VfxGpuRuntime;
169
+ readonly camera: ParticleRenderCamera;
170
+ readonly intents: readonly VfxGpuTickIntent[];
171
+ }
172
+
173
+ interface ExtractedFrame {
174
+ readonly worlds: readonly ExtractedWorld[];
175
+ readonly frameNumber: number;
176
+ }
177
+
178
+ interface GpuRefs {
179
+ readonly program: RenderFeatureGpuProgramRef;
180
+ readonly particles: RenderFeatureGpuBufferRef;
181
+ readonly aliveIndices: RenderFeatureGpuBufferRef;
182
+ readonly counters: RenderFeatureGpuBufferRef;
183
+ readonly indirect: RenderFeatureGpuBufferRef;
184
+ readonly scratch: RenderFeatureGpuBufferRef;
185
+ readonly billboardInstances: RenderFeatureGpuBufferRef;
186
+ readonly eventInputs: RenderFeatureGpuBufferRef;
187
+ readonly events: RenderFeatureGpuBufferRef;
188
+ }
189
+
190
+ interface TickRing {
191
+ readonly runtime: RenderFeatureGpuBufferRef;
192
+ readonly bindings: RenderFeatureGpuBindingsRef;
193
+ }
194
+
195
+ interface RendererProjection {
196
+ readonly kind: ParticleRendererSource['kind'];
197
+ readonly ring: TickRing;
198
+ readonly instances: RenderFeatureGpuBufferRef;
199
+ readonly workgroups: number;
200
+ readonly historyWorkgroups?: number;
201
+ readonly sorting?: 'none' | 'emitter' | 'back-to-front';
202
+ }
203
+
204
+ interface EmitterState {
205
+ readonly world: World;
206
+ readonly player: EntityHandle;
207
+ readonly emitterId: string;
208
+ readonly fingerprint: string;
209
+ readonly capacity: number;
210
+ readonly names: string;
211
+ refs?: GpuRefs;
212
+ rings: TickRing[];
213
+ projections: RendererProjection[];
214
+ colorTarget: RenderFeatureTargetHandle | undefined;
215
+ depthTarget: RenderFeatureTargetHandle | undefined;
216
+ indirectInitialized: boolean;
217
+ culled: boolean;
218
+ lastIntent?: VfxGpuTickIntent;
219
+ draws: RenderFeatureDrawRecord[];
220
+ depthSampledDraws: RenderFeatureDrawRecord[];
221
+ stagePlan?: VfxValidatedStagePlan;
222
+ lastKnownGoodStage?: VfxValidatedStagePlan;
223
+ stageReadiness: readonly VfxStageReadiness[];
224
+ stageOutput: VfxStageOutput;
225
+ }
226
+
227
+ function finite(value: unknown, fallback: number): number {
228
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
229
+ }
230
+
231
+ function vector(value: unknown, fallback: readonly number[], size: number): readonly number[] {
232
+ return Array.isArray(value)
233
+ ? Array.from({ length: size }, (_, index) => finite(value[index], fallback[index] ?? 0))
234
+ : fallback;
235
+ }
236
+
237
+ function runtimeData(
238
+ intent: VfxGpuTickIntent,
239
+ camera: ParticleRenderCamera,
240
+ material: MaterialAsset | undefined,
241
+ localToWorld: Float32Array,
242
+ renderer?: ParticleRendererSource,
243
+ rendererIndex = 0,
244
+ ): Uint8Array {
245
+ const storage = new ArrayBuffer(RUNTIME_BYTES);
246
+ const floats = new Float32Array(storage);
247
+ const words = new Uint32Array(storage);
248
+ floats[0] = intent.fixedDelta;
249
+ words[1] = intent.phaseTick;
250
+ words[2] = intent.seed;
251
+ words[3] = intent.playCycle;
252
+ words[4] = intent.emitter.capacity;
253
+ words[5] = intent.spawnCount;
254
+ words[6] = intent.firstParticleId;
255
+ words[7] = intent.emitter.renderers.length;
256
+ floats.set(camera.viewProjection, 8);
257
+ floats.set(camera.right, 24);
258
+ floats.set(camera.up, 28);
259
+ const values = material?.values ?? {};
260
+ floats.set(vector(values.baseColor, [1, 1, 1, 1], 4), 32);
261
+ const emissive = vector(values.emissive, [0, 0, 0], 3);
262
+ floats.set(emissive, 36);
263
+ floats[39] = finite(values.emissiveIntensity, 0);
264
+ floats[40] = finite(values.metallic, 0);
265
+ floats[41] = finite(values.roughness, 0.5);
266
+ floats[42] = finite(values.clearcoat, 0);
267
+ floats[43] = finite(values.clearcoatRoughness, 0.5);
268
+ floats.set(localToWorld, 44);
269
+ words[60] = rendererIndex;
270
+ words[61] = renderer?.kind === 'trail' ? renderer.historyLength : 0;
271
+ words[62] =
272
+ renderer?.kind === 'ribbon' || renderer?.kind === 'trail' || renderer?.kind === 'beam'
273
+ ? renderer.capacity
274
+ : intent.emitter.capacity;
275
+ words[63] =
276
+ renderer?.kind === 'billboard' && renderer.sorting === 'back-to-front'
277
+ ? 2
278
+ : renderer?.kind === 'billboard' && renderer.sorting === 'emitter'
279
+ ? 1
280
+ : 0;
281
+ floats[64] =
282
+ renderer?.kind === 'billboard'
283
+ ? (renderer.pivot?.[0] ?? 0)
284
+ : renderer?.kind === 'ribbon' || renderer?.kind === 'trail' || renderer?.kind === 'beam'
285
+ ? (renderer.width ?? 0.1)
286
+ : 0.1;
287
+ floats[65] = renderer?.kind === 'billboard' ? (renderer.pivot?.[1] ?? 0) : 0;
288
+ floats[66] = renderer?.kind === 'billboard' ? (renderer.softParticle?.fadeDistance ?? 0) : 0;
289
+ const sheet = renderer?.kind === 'billboard' ? renderer.textureSheet : undefined;
290
+ floats[68] = sheet?.columns ?? 1;
291
+ floats[69] = sheet?.rows ?? 1;
292
+ floats[70] = sheet?.frameRate ?? 0;
293
+ floats[71] = sheet?.frameCount ?? (sheet === undefined ? 1 : sheet.columns * sheet.rows);
294
+ return new Uint8Array(storage);
295
+ }
296
+
297
+ function emitterTransform(world: World, intent: VfxGpuTickIntent): Float32Array {
298
+ if (intent.emitter.space === 'world') return IDENTITY_MATRIX;
299
+ const transform = world.get(intent.player, Transform);
300
+ return transform.ok ? transform.value.world : IDENTITY_MATRIX;
301
+ }
302
+
303
+ function emitterVisible(
304
+ intent: VfxGpuTickIntent,
305
+ camera: ParticleRenderCamera,
306
+ localToWorld: Float32Array,
307
+ ): boolean {
308
+ const bounds = intent.emitter.bounds;
309
+ const center =
310
+ bounds.kind === 'sphere'
311
+ ? bounds.center
312
+ : ([
313
+ (bounds.min[0] + bounds.max[0]) * 0.5,
314
+ (bounds.min[1] + bounds.max[1]) * 0.5,
315
+ (bounds.min[2] + bounds.max[2]) * 0.5,
316
+ ] as const);
317
+ const radius =
318
+ bounds.kind === 'sphere'
319
+ ? bounds.radius
320
+ : Math.hypot(
321
+ (bounds.max[0] - bounds.min[0]) * 0.5,
322
+ (bounds.max[1] - bounds.min[1]) * 0.5,
323
+ (bounds.max[2] - bounds.min[2]) * 0.5,
324
+ );
325
+ const matrix = (index: number): number => localToWorld[index] ?? 0;
326
+ const worldPosition = new Float32Array([
327
+ matrix(0) * center[0] + matrix(4) * center[1] + matrix(8) * center[2] + matrix(12),
328
+ matrix(1) * center[0] + matrix(5) * center[1] + matrix(9) * center[2] + matrix(13),
329
+ matrix(2) * center[0] + matrix(6) * center[1] + matrix(10) * center[2] + matrix(14),
330
+ ]);
331
+ const scale = Math.max(
332
+ Math.hypot(matrix(0), matrix(1), matrix(2)),
333
+ Math.hypot(matrix(4), matrix(5), matrix(6)),
334
+ Math.hypot(matrix(8), matrix(9), matrix(10)),
335
+ );
336
+ const planes = frustum.fromViewProjection(frustum.create(), camera.viewProjection);
337
+ return frustum.intersectsSphere(planes, worldPosition, radius * scale);
338
+ }
339
+
340
+ function resetData(size: number): Uint8Array {
341
+ return new Uint8Array(size);
342
+ }
343
+
344
+ function target(
345
+ targets: readonly RenderFeatureTargetHandle[],
346
+ kind: 'scene-color' | 'scene-depth',
347
+ ): RenderFeatureTargetHandle | undefined {
348
+ return targets.find((entry) => entry.kind === kind);
349
+ }
350
+
351
+ function missingMeshPreparation(guid: string): RenderFeaturePreparationFailedError {
352
+ return new RenderFeaturePreparationFailedError(
353
+ IDENTITY,
354
+ -1,
355
+ 'asset-load',
356
+ 'vertex-data',
357
+ `mesh:${guid}`,
358
+ 'asset-not-ready',
359
+ 'next-frame',
360
+ );
361
+ }
362
+
363
+ function materialResourceKey(shader: string): string {
364
+ return shader.replaceAll(/[^a-zA-Z0-9_.:-]/g, '_');
365
+ }
366
+
367
+ function requiresSceneDepth(intent: VfxGpuTickIntent): boolean {
368
+ return (intent.emitter.reflection.dataInterfaces ?? []).some(
369
+ (requirement) => requirement.kind === 'scene-depth',
370
+ );
371
+ }
372
+
373
+ export function gpuParticleRenderFeature(
374
+ options: GpuParticleFeatureOptions,
375
+ ): RenderFeature<ExtractedFrame> & { readonly inspect: () => VfxGpuRenderObservation } {
376
+ const worldIds = new WeakMap<World, number>();
377
+ let nextWorldId = 0;
378
+ const states = new Map<string, EmitterState>();
379
+ let lastObservation: VfxGpuRenderObservation = Object.freeze({
380
+ frameNumber: -1,
381
+ dispatches: 0,
382
+ indirectDraws: 0,
383
+ subjectOutputs: 0,
384
+ });
385
+ const keyOf = (world: World, intent: VfxGpuTickIntent): string => {
386
+ let worldId = worldIds.get(world);
387
+ if (worldId === undefined) {
388
+ worldId = nextWorldId++;
389
+ worldIds.set(world, worldId);
390
+ }
391
+ return `${worldId}:${intent.player}:${intent.emitter.id}`;
392
+ };
393
+ const stateFor = (world: World, intent: VfxGpuTickIntent): EmitterState => {
394
+ const key = keyOf(world, intent);
395
+ let state = states.get(key);
396
+ if (state !== undefined && state.fingerprint !== intent.programFingerprint) {
397
+ states.delete(key);
398
+ state = undefined;
399
+ }
400
+ if (state !== undefined) return state;
401
+ const fingerprint = intent.programFingerprint.slice(0, 12).replaceAll(':', '_');
402
+ state = {
403
+ world,
404
+ player: intent.player,
405
+ emitterId: intent.emitter.id,
406
+ fingerprint: intent.programFingerprint,
407
+ capacity: intent.emitter.capacity,
408
+ names: `gpu.${key.replaceAll(':', '.')}.${fingerprint}`,
409
+ rings: [],
410
+ projections: [],
411
+ draws: [],
412
+ depthSampledDraws: [],
413
+ colorTarget: undefined,
414
+ depthTarget: undefined,
415
+ indirectInitialized: false,
416
+ culled: false,
417
+ stageReadiness: [],
418
+ stageOutput: 'empty',
419
+ };
420
+ states.set(key, state);
421
+ return state;
422
+ };
423
+
424
+ return {
425
+ identity: IDENTITY,
426
+ requiredCapabilities: ['compute', 'indirectDrawing'],
427
+ requiredMaterialShaders: Object.values(PARTICLE_SHADER_IDENTIFIERS),
428
+ extract: (context) => {
429
+ const extracted: ExtractedWorld[] = [];
430
+ for (const world of context.worlds) {
431
+ if (!world.hasResource(VFX_GPU_RUNTIME_RESOURCE_KEY)) continue;
432
+ const camera = options.camera.read(world);
433
+ if (camera === undefined) continue;
434
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
435
+ const intents = runtime.snapshot().filter((intent) => {
436
+ if (options.playerConsumption?.isEnabled(world, intent.player) === false) return false;
437
+ const requirements = intent.emitter.reflection.dataInterfaces ?? [];
438
+ if (requirements.length === 0) return true;
439
+ return (
440
+ options.dataInterfaces?.resolve(requirements, intent.instanceGeneration).ok === true
441
+ );
442
+ });
443
+ extracted.push({ world, runtime, camera, intents });
444
+ }
445
+ return ok({ worlds: extracted, frameNumber: context.frameNumber });
446
+ },
447
+ prepare: (frame, context) => {
448
+ const gpu = context.gpu;
449
+ if (gpu === undefined) {
450
+ return err(new RenderFeatureStageFailedError(IDENTITY, -1, 'prepare', 'renderer-recover'));
451
+ }
452
+ // Kick every newly observed WGSL module before awaiting the next frame.
453
+ // Shader-module creation is asynchronous in the browser RHI; returning on
454
+ // the first pending module serialized effect startup across emitters and
455
+ // could outlive a short authored burst on a slow runner.
456
+ let pendingProgramError: RenderFeaturePreparationFailedError | undefined;
457
+ const preparedIntents: Array<{
458
+ readonly entry: ExtractedWorld;
459
+ readonly intent: VfxGpuTickIntent;
460
+ readonly state: EmitterState;
461
+ }> = [];
462
+ for (const entry of frame.worlds) {
463
+ for (const intent of entry.intents) {
464
+ const key = keyOf(entry.world, intent);
465
+ let state = states.get(key);
466
+ const candidate = validatedStagePlan(
467
+ intent.emitter.reflection.stages,
468
+ intent.instanceGeneration,
469
+ );
470
+ if (!candidate.ok) {
471
+ if (state === undefined) {
472
+ return err(new RenderFeatureStageFailedError(IDENTITY, -1, 'prepare', 'next-frame'));
473
+ }
474
+ const observation = observeStagePlan(
475
+ candidate,
476
+ intent.instanceGeneration,
477
+ state.lastKnownGoodStage,
478
+ );
479
+ state.stagePlan = observation.validatedStagePlan;
480
+ state.stageReadiness = observation.stageReadiness;
481
+ state.stageOutput = observation.stageOutput;
482
+ continue;
483
+ }
484
+ if (state !== undefined && state.fingerprint !== intent.programFingerprint) {
485
+ states.delete(key);
486
+ state = undefined;
487
+ }
488
+ state ??= stateFor(entry.world, intent);
489
+ const observation = observeStagePlan(
490
+ candidate,
491
+ intent.instanceGeneration,
492
+ state.lastKnownGoodStage,
493
+ );
494
+ state.stagePlan = observation.validatedStagePlan;
495
+ state.lastKnownGoodStage = candidate.value;
496
+ state.stageReadiness = observation.stageReadiness;
497
+ state.stageOutput = observation.stageOutput;
498
+ const program = gpu.prepareProgram(`${state.names}.program`, {
499
+ wgsl: intent.emitter.wgsl,
500
+ entryPoints: intent.emitter.reflection.entryPoints,
501
+ bindings: intent.emitter.reflection.bindings,
502
+ });
503
+ if (!program.ok) {
504
+ if (
505
+ program.error.code !== 'render-feature-preparation-failed' ||
506
+ program.error.detail.recovery !== 'next-frame'
507
+ ) {
508
+ return program;
509
+ }
510
+ pendingProgramError ??= program.error;
511
+ }
512
+ preparedIntents.push({ entry, intent, state });
513
+ }
514
+ }
515
+ if (pendingProgramError !== undefined) return err(pendingProgramError);
516
+ let pendingGraphicsError: RenderError | undefined;
517
+ for (const { entry, intent, state } of preparedIntents) {
518
+ if (intent.reset) state.indirectInitialized = false;
519
+ state.lastIntent = intent;
520
+ const base = state.names;
521
+ const program = gpu.prepareProgram(`${base}.program`, {
522
+ wgsl: intent.emitter.wgsl,
523
+ entryPoints: intent.emitter.reflection.entryPoints,
524
+ bindings: intent.emitter.reflection.bindings,
525
+ });
526
+ if (!program.ok) return program;
527
+ const prepare = (
528
+ name: string,
529
+ size: number,
530
+ usage: readonly ('storage' | 'uniform' | 'indirect' | 'vertex')[],
531
+ data?: ArrayBufferView,
532
+ ) =>
533
+ gpu.prepareBuffer(`${base}.${name}`, {
534
+ size,
535
+ usage,
536
+ ...(data === undefined ? {} : { data }),
537
+ });
538
+ const particles = prepare(
539
+ 'particles',
540
+ state.capacity * PARTICLE_BYTES,
541
+ ['storage'],
542
+ intent.reset ? resetData(state.capacity * PARTICLE_BYTES) : undefined,
543
+ );
544
+ if (!particles.ok) return particles;
545
+ const aliveIndices = prepare('alive-indices', state.capacity * 4, ['storage']);
546
+ if (!aliveIndices.ok) return aliveIndices;
547
+ const counters = prepare(
548
+ 'counters',
549
+ COUNTERS_BYTES,
550
+ ['storage'],
551
+ intent.reset ? resetData(COUNTERS_BYTES) : undefined,
552
+ );
553
+ if (!counters.ok) return counters;
554
+ const indirect = prepare('indirect', Math.max(1, intent.emitter.renderers.length) * 20, [
555
+ 'storage',
556
+ 'indirect',
557
+ ]);
558
+ if (!indirect.ok) return indirect;
559
+ const scratchBytes = (state.capacity * 2 + Math.ceil(state.capacity / WORKGROUP_SIZE)) * 4;
560
+ const scratch = prepare(
561
+ 'scratch',
562
+ scratchBytes,
563
+ ['storage'],
564
+ intent.reset ? resetData(scratchBytes) : undefined,
565
+ );
566
+ if (!scratch.ok) return scratch;
567
+ const billboardInstances = prepare(
568
+ 'billboard-instances',
569
+ state.capacity * Math.max(BILLBOARD_INSTANCE_BYTES, MESH_INSTANCE_BYTES),
570
+ ['storage', 'vertex'],
571
+ );
572
+ if (!billboardInstances.ok) return billboardInstances;
573
+ const events = prepare(
574
+ 'events',
575
+ eventCapacity(intent.emitter) * VFX_EVENT_BYTES,
576
+ ['storage'],
577
+ intent.reset ? resetData(eventCapacity(intent.emitter) * VFX_EVENT_BYTES) : undefined,
578
+ );
579
+ if (!events.ok) return events;
580
+ const initialEventInputs = prepare(
581
+ 'event-inputs',
582
+ eventInputCapacity(intent.emitter) * VFX_EVENT_INPUT_BYTES,
583
+ ['storage'],
584
+ encodeEventInputs(intent),
585
+ );
586
+ if (!initialEventInputs.ok) return initialEventInputs;
587
+ state.refs = {
588
+ program: program.value,
589
+ particles: particles.value,
590
+ aliveIndices: aliveIndices.value,
591
+ counters: counters.value,
592
+ indirect: indirect.value,
593
+ scratch: scratch.value,
594
+ billboardInstances: billboardInstances.value,
595
+ eventInputs: initialEventInputs.value,
596
+ events: events.value,
597
+ };
598
+ const ringIndex = intent.tick % MAX_TICK_RINGS;
599
+ const runtime = gpu.prepareBuffer(`${base}.runtime.${ringIndex}`, {
600
+ size: RUNTIME_BYTES,
601
+ usage: ['uniform'],
602
+ data: runtimeData(intent, entry.camera, undefined, emitterTransform(entry.world, intent)),
603
+ });
604
+ if (!runtime.ok) return runtime;
605
+ const refs = state.refs;
606
+ const updatedEventInputs = gpu.prepareBuffer(`${base}.event-inputs`, {
607
+ size: eventInputCapacity(intent.emitter) * VFX_EVENT_INPUT_BYTES,
608
+ usage: ['storage'],
609
+ data: encodeEventInputs(intent),
610
+ });
611
+ if (!updatedEventInputs.ok) return updatedEventInputs;
612
+ const bindings = gpu.prepareBindings(`${base}.bindings.${ringIndex}`, {
613
+ program: refs.program,
614
+ entries: [
615
+ { binding: 0, buffer: refs.particles },
616
+ { binding: 1, buffer: runtime.value },
617
+ { binding: 2, buffer: refs.aliveIndices },
618
+ { binding: 3, buffer: refs.counters },
619
+ { binding: 4, buffer: refs.indirect },
620
+ { binding: 5, buffer: refs.scratch },
621
+ { binding: 6, buffer: refs.billboardInstances },
622
+ { binding: 8, buffer: updatedEventInputs.value },
623
+ { binding: 9, buffer: refs.events },
624
+ ],
625
+ });
626
+ if (!bindings.ok) return bindings;
627
+ state.rings[ringIndex] = {
628
+ runtime: runtime.value,
629
+ bindings: bindings.value,
630
+ };
631
+ }
632
+
633
+ for (const [key, state] of states) {
634
+ const extracted = frame.worlds.find((entry) => entry.world === state.world);
635
+ if (extracted === undefined || !extracted.runtime.hasPlayer(state.player)) {
636
+ states.delete(key);
637
+ continue;
638
+ }
639
+ if (options.playerConsumption?.isEnabled(state.world, state.player) === false) {
640
+ // A render-consumption fence must also suppress the retained
641
+ // lastIntent fallback below. The authored player may be despawned
642
+ // after FixedUpdate, so reading its Transform here would otherwise
643
+ // produce an identity matrix and one final frame at world origin.
644
+ state.projections = [];
645
+ state.draws = [];
646
+ state.depthSampledDraws = [];
647
+ state.colorTarget = undefined;
648
+ state.depthTarget = undefined;
649
+ continue;
650
+ }
651
+ const intent = state.lastIntent;
652
+ const refs = state.refs;
653
+ if (intent === undefined || refs === undefined) continue;
654
+ const retained = gpu.retainBindings([
655
+ ...state.rings.flatMap((ring) => (ring === undefined ? [] : [ring.bindings])),
656
+ ...state.projections.map((projection) => projection.ring.bindings),
657
+ ]);
658
+ if (!retained.ok) return retained;
659
+ if (!extracted.runtime.isEmitterSessionEnabled(state.player, state.emitterId)) {
660
+ // Session isolation is transient preview state, not a resource
661
+ // lifetime boundary. Keep prepared bindings warm while suppressing
662
+ // every contribution: a paused player may be re-enabled without a
663
+ // fresh simulation intent that could rebuild those bindings.
664
+ state.projections = [];
665
+ state.draws = [];
666
+ state.depthSampledDraws = [];
667
+ continue;
668
+ }
669
+ const renderers = intent.emitter.renderers;
670
+ if (renderers.length === 0) continue;
671
+ const localToWorld = emitterTransform(state.world, intent);
672
+ const visible = emitterVisible(intent, extracted.camera, localToWorld);
673
+ extracted.runtime.setEmitterCameraVisibility(state.player, state.emitterId, visible);
674
+ const currentIntents = extracted.intents.filter(
675
+ (candidate) =>
676
+ candidate.player === state.player && candidate.emitter.id === state.emitterId,
677
+ );
678
+ if (!visible) {
679
+ state.culled = true;
680
+ state.projections = [];
681
+ state.draws = [];
682
+ state.depthSampledDraws = [];
683
+ continue;
684
+ }
685
+ if (
686
+ state.culled &&
687
+ intent.emitter.simulationWhenCulled === 'restart-on-visible' &&
688
+ !currentIntents.some((candidate) => candidate.reset)
689
+ ) {
690
+ state.projections = [];
691
+ state.draws = [];
692
+ state.depthSampledDraws = [];
693
+ continue;
694
+ }
695
+ state.culled = false;
696
+ const colorTarget = target(context.targets, 'scene-color');
697
+ const depthTarget = target(context.targets, 'scene-depth');
698
+ const softParticle = requiresSceneDepth(intent);
699
+ if (softParticle && depthTarget === undefined) continue;
700
+ const eventRing = state.rings[intent.tick % MAX_TICK_RINGS];
701
+ if (eventRing === undefined) continue;
702
+ const meshes = renderers.map((renderer) =>
703
+ renderer.kind === 'mesh' ? options.mesh?.read(state.world, renderer.mesh) : undefined,
704
+ );
705
+ const missingMesh = renderers.find(
706
+ (renderer, index) =>
707
+ renderer.kind === 'mesh' &&
708
+ meshes[index]?.submeshes[renderer.submesh ?? 0] === undefined,
709
+ );
710
+ if (missingMesh?.kind === 'mesh') {
711
+ pendingGraphicsError ??= missingMeshPreparation(missingMesh.mesh);
712
+ continue;
713
+ }
714
+ const indirectWords = new Uint32Array(renderers.length * 5);
715
+ for (const [index, renderer] of renderers.entries()) {
716
+ const mesh = meshes[index];
717
+ const submesh =
718
+ renderer.kind === 'mesh' ? mesh?.submeshes[renderer.submesh ?? 0] : undefined;
719
+ const topologyPlan =
720
+ renderer.kind === 'ribbon' || renderer.kind === 'trail' || renderer.kind === 'beam'
721
+ ? createTopologyResourcePlan(renderer)
722
+ : undefined;
723
+ if (topologyPlan !== undefined && !topologyPlan.ok)
724
+ return err(new RenderFeatureStageFailedError(IDENTITY, -1, 'prepare', 'next-frame'));
725
+ indirectWords[index * 5] =
726
+ renderer.kind === 'billboard'
727
+ ? 6
728
+ : renderer.kind === 'ribbon' || renderer.kind === 'trail' || renderer.kind === 'beam'
729
+ ? 6
730
+ : mesh?.indices === undefined
731
+ ? (submesh?.vertexCount ?? 0)
732
+ : (submesh?.indexCount ?? 0);
733
+ indirectWords[index * 5 + 2] =
734
+ renderer.kind === 'mesh' && mesh?.indices !== undefined
735
+ ? (submesh?.indexOffset ?? 0)
736
+ : 0;
737
+ }
738
+ const indirectInit = gpu.prepareBuffer(`${state.names}.indirect`, {
739
+ size: Math.max(1, renderers.length) * 20,
740
+ usage: ['storage', 'indirect'],
741
+ ...(state.indirectInitialized ? {} : { data: indirectWords }),
742
+ });
743
+ if (!indirectInit.ok) return indirectInit;
744
+ state.indirectInitialized = true;
745
+ const draws: RenderFeatureDrawRecord[] = [];
746
+ const depthSampledDraws: RenderFeatureDrawRecord[] = [];
747
+ const projections: RendererProjection[] = [];
748
+ for (const [rendererIndex, renderer] of renderers.entries()) {
749
+ const isBillboard = renderer.kind === 'billboard';
750
+ const isTopology =
751
+ renderer.kind === 'ribbon' || renderer.kind === 'trail' || renderer.kind === 'beam';
752
+ const topologyPlan = isTopology ? createTopologyResourcePlan(renderer) : undefined;
753
+ if (topologyPlan !== undefined && !topologyPlan.ok)
754
+ return err(new RenderFeatureStageFailedError(IDENTITY, -1, 'prepare', 'next-frame'));
755
+ const mesh = meshes[rendererIndex];
756
+ const submesh =
757
+ renderer.kind === 'mesh' ? mesh?.submeshes[renderer.submesh ?? 0] : undefined;
758
+ const indexFormat =
759
+ mesh?.indices instanceof Uint32Array ? ('uint32' as const) : ('uint16' as const);
760
+ let material = options.material?.read(state.world, renderer.material);
761
+ if (material !== undefined) {
762
+ const resolvedMaterial = context.graphics.resolveMaterialAsset(material);
763
+ if (!resolvedMaterial.ok) {
764
+ pendingGraphicsError ??= resolvedMaterial.error;
765
+ continue;
766
+ }
767
+ material = resolvedMaterial.value;
768
+ }
769
+ const materialPass = particleMaterialPass(renderer.kind, material);
770
+ const materialKey = materialResourceKey(materialPass.shader);
771
+ const samplesSceneDepth = particleMaterialUsesSceneDepth(
772
+ context.graphics.getMaterialShaderBindingContract(materialPass.shader),
773
+ );
774
+ const particleBlend = renderer.kind === 'billboard' ? renderer.blend : 'alpha';
775
+ const projectionInstances = gpu.prepareBuffer(
776
+ `${state.names}.renderer.${rendererIndex}.instances`,
777
+ {
778
+ size: isTopology
779
+ ? topologyPlan?.ok
780
+ ? topologyPlan.value.vertexBytes
781
+ : 0
782
+ : state.capacity * (isBillboard ? BILLBOARD_INSTANCE_BYTES : MESH_INSTANCE_BYTES),
783
+ usage: ['storage', 'vertex'],
784
+ },
785
+ );
786
+ if (!projectionInstances.ok) return projectionInstances;
787
+ const projectionHistory = gpu.prepareBuffer(
788
+ `${state.names}.renderer.${rendererIndex}.history`,
789
+ {
790
+ size:
791
+ renderer.kind === 'trail'
792
+ ? Math.max(16, renderer.capacity * renderer.historyLength * 16)
793
+ : 16,
794
+ usage: ['storage'],
795
+ ...(intent.reset
796
+ ? {
797
+ data: resetData(
798
+ renderer.kind === 'trail'
799
+ ? Math.max(16, renderer.capacity * renderer.historyLength * 16)
800
+ : 16,
801
+ ),
802
+ }
803
+ : {}),
804
+ },
805
+ );
806
+ if (!projectionHistory.ok) return projectionHistory;
807
+ const projectionRuntime = gpu.prepareBuffer(
808
+ `${state.names}.renderer.${rendererIndex}.runtime`,
809
+ {
810
+ size: RUNTIME_BYTES,
811
+ usage: ['uniform'],
812
+ data: runtimeData(
813
+ { ...intent, fixedDelta: 0, spawnCount: 0 },
814
+ extracted.camera,
815
+ material,
816
+ localToWorld,
817
+ renderer,
818
+ rendererIndex,
819
+ ),
820
+ },
821
+ );
822
+ if (!projectionRuntime.ok) return projectionRuntime;
823
+ const projectionBindings = gpu.prepareBindings(
824
+ `${state.names}.renderer.${rendererIndex}.bindings`,
825
+ {
826
+ program: refs.program,
827
+ entries: [
828
+ { binding: 0, buffer: refs.particles },
829
+ { binding: 1, buffer: projectionRuntime.value },
830
+ { binding: 2, buffer: refs.aliveIndices },
831
+ { binding: 3, buffer: refs.counters },
832
+ { binding: 4, buffer: refs.indirect },
833
+ { binding: 5, buffer: projectionHistory.value },
834
+ { binding: 6, buffer: projectionInstances.value },
835
+ { binding: 8, buffer: refs.eventInputs },
836
+ { binding: 9, buffer: refs.events },
837
+ ],
838
+ },
839
+ );
840
+ if (!projectionBindings.ok) return projectionBindings;
841
+ projections.push({
842
+ kind: renderer.kind,
843
+ instances: projectionInstances.value,
844
+ ring: {
845
+ runtime: projectionRuntime.value,
846
+ bindings: projectionBindings.value,
847
+ },
848
+ workgroups: Math.ceil(
849
+ (renderer.kind === 'trail'
850
+ ? renderer.capacity * Math.max(1, renderer.historyLength - 1)
851
+ : renderer.kind === 'ribbon' || renderer.kind === 'beam'
852
+ ? renderer.capacity
853
+ : state.capacity) / WORKGROUP_SIZE,
854
+ ),
855
+ ...(renderer.kind === 'trail'
856
+ ? { historyWorkgroups: Math.ceil(renderer.capacity / WORKGROUP_SIZE) }
857
+ : {}),
858
+ ...(renderer.kind === 'billboard' ? { sorting: renderer.sorting ?? 'none' } : {}),
859
+ });
860
+ const pipeline = context.graphics.preparePipeline(
861
+ `${state.names}.renderer.${rendererIndex}.${renderer.kind}.pipeline.${materialKey}`,
862
+ {
863
+ shader: materialPass.shader,
864
+ vertexLayout: isBillboard
865
+ ? RENDER_FEATURE_VERTEX_LAYOUTS.billboardMaterialInstance
866
+ : isTopology
867
+ ? RENDER_FEATURE_VERTEX_LAYOUTS.topologySegmentInstance
868
+ : RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInstance,
869
+ colorFormats: [colorTarget?.format ?? 'rgba8unorm-srgb'],
870
+ ...(depthTarget === undefined ? {} : { depthFormat: depthTarget.format }),
871
+ sampleCount: colorTarget?.sampleCount ?? 1,
872
+ topology: submesh?.topology ?? 'triangle-list',
873
+ ...(mesh?.indices === undefined ? {} : { indexFormat }),
874
+ ...(materialPass.renderState !== undefined
875
+ ? { renderState: materialPass.renderState }
876
+ : isBillboard || isTopology
877
+ ? {
878
+ renderState: {
879
+ cullMode: 'none',
880
+ depthCompare: 'less-equal',
881
+ depthWriteEnabled:
882
+ softParticle || isTopology ? false : particleBlend === 'opaque-cutout',
883
+ ...(particleBlend === 'opaque-cutout'
884
+ ? {}
885
+ : {
886
+ blend: {
887
+ color: {
888
+ srcFactor: 'one',
889
+ dstFactor:
890
+ particleBlend === 'additive' ? 'one' : 'one-minus-src-alpha',
891
+ operation: 'add',
892
+ },
893
+ alpha: {
894
+ srcFactor: 'one',
895
+ dstFactor: 'one-minus-src-alpha',
896
+ operation: 'add',
897
+ },
898
+ },
899
+ }),
900
+ },
901
+ }
902
+ : {}),
903
+ },
904
+ );
905
+ if (!pipeline.ok) {
906
+ if (
907
+ pipeline.error.code !== 'render-feature-preparation-failed' ||
908
+ pipeline.error.detail.recovery !== 'next-frame'
909
+ ) {
910
+ return pipeline;
911
+ }
912
+ pendingGraphicsError ??= pipeline.error;
913
+ continue;
914
+ }
915
+ const graphicsBindings = context.graphics.prepareBindings(
916
+ `${state.names}.renderer.${rendererIndex}.${renderer.kind}.binding.${materialKey}`,
917
+ {
918
+ pipeline: pipeline.value,
919
+ values: {
920
+ group: 0,
921
+ shader: materialPass.shader,
922
+ ...(samplesSceneDepth && depthTarget !== undefined
923
+ ? { sceneDepth: depthTarget }
924
+ : {}),
925
+ },
926
+ },
927
+ );
928
+ if (!graphicsBindings.ok) return graphicsBindings;
929
+ const materialBindings = !particleMaterialUsesBindings(material)
930
+ ? undefined
931
+ : context.graphics.prepareBindings(
932
+ `${state.names}.renderer.${rendererIndex}.${renderer.kind}.material-binding.${materialKey}`,
933
+ {
934
+ pipeline: pipeline.value,
935
+ values: {
936
+ group: 1,
937
+ material: {
938
+ world: frame.worlds.findIndex((entry) => entry.world === state.world),
939
+ guid: renderer.material,
940
+ },
941
+ },
942
+ },
943
+ );
944
+ if (materialBindings !== undefined && !materialBindings.ok) return materialBindings;
945
+ const drawBindings = [
946
+ graphicsBindings.value,
947
+ ...(materialBindings === undefined ? [] : [materialBindings.value]),
948
+ ];
949
+ if (isBillboard || isTopology) {
950
+ const vertexData = context.graphics.prepareVertexData(
951
+ `${state.names}.${isTopology ? renderer.kind : 'billboard'}.vertices`,
952
+ {
953
+ layout: isTopology
954
+ ? RENDER_FEATURE_VERTEX_LAYOUTS.topologySegmentInstance
955
+ : RENDER_FEATURE_VERTEX_LAYOUTS.billboardMaterialInstance,
956
+ buffer: projectionInstances.value,
957
+ },
958
+ );
959
+ if (!vertexData.ok) return vertexData;
960
+ const draw: RenderFeatureDrawRecord = {
961
+ kind: 'draw-indirect',
962
+ pipeline: pipeline.value,
963
+ bindings: drawBindings,
964
+ vertexData: [{ slot: 0, resource: vertexData.value }],
965
+ command: { buffer: refs.indirect, offset: rendererIndex * 20 },
966
+ };
967
+ (samplesSceneDepth ? depthSampledDraws : draws).push(draw);
968
+ continue;
969
+ }
970
+ if (mesh === undefined) {
971
+ return err(new RenderFeatureStageFailedError(IDENTITY, -1, 'prepare', 'next-frame'));
972
+ }
973
+ const geometryData = canonicalMeshVertices(mesh);
974
+ const geometryBuffer = gpu.prepareBuffer(
975
+ `${state.names}.renderer.${rendererIndex}.mesh.geometry-buffer`,
976
+ {
977
+ size: geometryData.byteLength,
978
+ usage: ['vertex'],
979
+ data: geometryData,
980
+ },
981
+ );
982
+ if (!geometryBuffer.ok) return geometryBuffer;
983
+ const geometry = context.graphics.prepareVertexData(
984
+ `${state.names}.renderer.${rendererIndex}.mesh.geometry`,
985
+ {
986
+ layout: RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInstance,
987
+ buffer: geometryBuffer.value,
988
+ },
989
+ );
990
+ if (!geometry.ok) return geometry;
991
+ const instances = context.graphics.prepareVertexData(
992
+ `${state.names}.renderer.${rendererIndex}.mesh.instances`,
993
+ {
994
+ layout: RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInstance,
995
+ buffer: projectionInstances.value,
996
+ },
997
+ );
998
+ if (!instances.ok) return instances;
999
+ const indexBuffer =
1000
+ mesh.indices === undefined
1001
+ ? undefined
1002
+ : gpu.prepareBuffer(`${state.names}.renderer.${rendererIndex}.mesh.index-buffer`, {
1003
+ size: mesh.indices.byteLength,
1004
+ usage: ['index'],
1005
+ data: mesh.indices,
1006
+ });
1007
+ if (indexBuffer !== undefined && !indexBuffer.ok) return indexBuffer;
1008
+ const indices =
1009
+ indexBuffer === undefined
1010
+ ? undefined
1011
+ : context.graphics.prepareIndexData(
1012
+ `${state.names}.renderer.${rendererIndex}.mesh.indices`,
1013
+ {
1014
+ format: indexFormat,
1015
+ buffer: indexBuffer.value,
1016
+ },
1017
+ );
1018
+ if (indices !== undefined && !indices.ok) return indices;
1019
+ const vertexData = [
1020
+ { slot: 0, resource: geometry.value },
1021
+ { slot: 1, resource: instances.value },
1022
+ ];
1023
+ draws.push(
1024
+ indices === undefined
1025
+ ? {
1026
+ kind: 'draw-indirect',
1027
+ pipeline: pipeline.value,
1028
+ bindings: drawBindings,
1029
+ vertexData,
1030
+ command: { buffer: refs.indirect, offset: rendererIndex * 20 },
1031
+ }
1032
+ : {
1033
+ kind: 'draw-indexed-indirect',
1034
+ pipeline: pipeline.value,
1035
+ bindings: drawBindings,
1036
+ vertexData,
1037
+ indexData: { resource: indices.value, format: indexFormat },
1038
+ command: { buffer: refs.indirect, offset: rendererIndex * 20 },
1039
+ },
1040
+ );
1041
+ }
1042
+ state.projections = projections;
1043
+ state.draws = draws;
1044
+ state.depthSampledDraws = depthSampledDraws;
1045
+ state.colorTarget = colorTarget;
1046
+ state.depthTarget = depthTarget;
1047
+ }
1048
+ if (pendingGraphicsError !== undefined) return err(pendingGraphicsError);
1049
+ return ok(undefined);
1050
+ },
1051
+ contribute: (frame, context) => {
1052
+ let dispatchCount = 0;
1053
+ let indirectDrawCount = 0;
1054
+ let subjectOutputCount = 0;
1055
+ for (const state of states.values()) {
1056
+ const refs = state.refs;
1057
+ const firstProjection = state.projections[0];
1058
+ if (refs === undefined) continue;
1059
+ const extracted = frame.worlds.find((entry) => entry.world === state.world);
1060
+ if (extracted === undefined) continue;
1061
+ if (options.playerConsumption?.isEnabled(state.world, state.player) === false) continue;
1062
+ if (!extracted.runtime.isEmitterSessionEnabled(state.player, state.emitterId)) continue;
1063
+ const currentIntents = extracted.intents.filter(
1064
+ (intent) => intent.player === state.player && intent.emitter.id === state.emitterId,
1065
+ );
1066
+ const intents = currentIntents.some(
1067
+ (intent) => intent.programFingerprint === state.fingerprint,
1068
+ )
1069
+ ? currentIntents.filter((intent) => intent.programFingerprint === state.fingerprint)
1070
+ : state.lastIntent === undefined
1071
+ ? []
1072
+ : [state.lastIntent];
1073
+ const groups = Math.ceil(state.capacity / WORKGROUP_SIZE);
1074
+ const dispatches = intents.flatMap((intent) => {
1075
+ const bindings = state.rings[intent.tick % MAX_TICK_RINGS]?.bindings;
1076
+ if (bindings === undefined) return [];
1077
+ return [
1078
+ { entryPoint: 'forgeax_vfx_spawn_main', workgroups: [groups] as const, bindings },
1079
+ { entryPoint: 'forgeax_vfx_update_main', workgroups: [groups] as const, bindings },
1080
+ ...stageDispatches(
1081
+ state.stagePlan ?? {
1082
+ stages: [],
1083
+ fingerprint: '',
1084
+ generation: intent.instanceGeneration,
1085
+ },
1086
+ groups,
1087
+ bindings,
1088
+ ),
1089
+ { entryPoint: 'forgeax_vfx_scan_blocks_main', workgroups: [groups] as const, bindings },
1090
+ {
1091
+ entryPoint: 'forgeax_vfx_scan_block_offsets_main',
1092
+ workgroups: [1] as const,
1093
+ bindings,
1094
+ },
1095
+ { entryPoint: 'forgeax_vfx_add_offsets_main', workgroups: [groups] as const, bindings },
1096
+ { entryPoint: 'forgeax_vfx_compact_main', workgroups: [groups] as const, bindings },
1097
+ {
1098
+ entryPoint: 'forgeax_vfx_event_main',
1099
+ workgroups: [Math.ceil(eventInputCapacity(intent.emitter) / 64)] as const,
1100
+ bindings,
1101
+ },
1102
+ ];
1103
+ });
1104
+ for (const projection of state.projections) {
1105
+ if (projection.kind === 'billboard' && projection.sorting === 'back-to-front') {
1106
+ dispatches.push({
1107
+ entryPoint: 'forgeax_vfx_sort_main',
1108
+ workgroups: [1],
1109
+ bindings: projection.ring.bindings,
1110
+ });
1111
+ }
1112
+ if (projection.kind === 'trail') {
1113
+ dispatches.push({
1114
+ entryPoint: 'forgeax_vfx_trail_history_main',
1115
+ workgroups: [projection.historyWorkgroups ?? 1],
1116
+ bindings: projection.ring.bindings,
1117
+ });
1118
+ }
1119
+ dispatches.push({
1120
+ entryPoint:
1121
+ projection.kind === 'billboard'
1122
+ ? 'forgeax_vfx_billboard_main'
1123
+ : projection.kind === 'mesh'
1124
+ ? 'forgeax_vfx_mesh_main'
1125
+ : `forgeax_vfx_${projection.kind}_main`,
1126
+ workgroups: [projection.workgroups],
1127
+ bindings: projection.ring.bindings,
1128
+ });
1129
+ }
1130
+ const passBindings =
1131
+ firstProjection?.ring.bindings ??
1132
+ intents
1133
+ .map((intent) => state.rings[intent.tick % MAX_TICK_RINGS]?.bindings)
1134
+ .find((bindings) => bindings !== undefined);
1135
+ if (passBindings === undefined || dispatches.length === 0) continue;
1136
+ const computePassIdentity = `${state.names}.simulate-and-project`;
1137
+ const compute = context.staging.addComputePass(computePassIdentity, {
1138
+ program: refs.program,
1139
+ bindings: passBindings,
1140
+ dispatches,
1141
+ });
1142
+ if (!compute.ok) return compute;
1143
+ dispatchCount += dispatches.length;
1144
+ for (const intent of intents) {
1145
+ extracted.runtime.markEventDispatched(state.player, intent.eventCounters);
1146
+ }
1147
+ for (const [drawKind, passDraws] of [
1148
+ ['regular', state.draws],
1149
+ ['depth-sampled', state.depthSampledDraws],
1150
+ ] as const) {
1151
+ if (passDraws.length === 0) continue;
1152
+ const samplesDepth = drawKind === 'depth-sampled';
1153
+ const draw = context.staging.addGraphicsPass(
1154
+ `${state.names}.draw.${drawKind}`,
1155
+ {
1156
+ attachments: {
1157
+ colors: [
1158
+ {
1159
+ resource: state.colorTarget ?? 'swapchain',
1160
+ format: state.colorTarget?.format ?? 'rgba8unorm-srgb',
1161
+ loadOp: 'load',
1162
+ storeOp: 'store',
1163
+ },
1164
+ ],
1165
+ ...(state.depthTarget === undefined
1166
+ ? {}
1167
+ : {
1168
+ depthStencil: {
1169
+ resource: state.depthTarget,
1170
+ format: state.depthTarget.format,
1171
+ depthLoadOp: 'load' as const,
1172
+ depthStoreOp: 'store' as const,
1173
+ },
1174
+ }),
1175
+ },
1176
+ ...(state.depthTarget === undefined || !samplesDepth
1177
+ ? {}
1178
+ : { sampledTargets: [state.depthTarget] }),
1179
+ temporalCoverage: 'reactive',
1180
+ draws: passDraws,
1181
+ },
1182
+ { dependsOn: [{ featureIdentity: IDENTITY, passIdentity: computePassIdentity }] },
1183
+ );
1184
+ if (!draw.ok) return draw;
1185
+ indirectDrawCount += passDraws.filter(
1186
+ (record) => record.kind === 'draw-indirect' || record.kind === 'draw-indexed-indirect',
1187
+ ).length;
1188
+ subjectOutputCount += passDraws.length;
1189
+ }
1190
+ }
1191
+ for (const entry of frame.worlds) {
1192
+ const last = entry.intents.at(-1);
1193
+ if (last !== undefined) entry.runtime.commit(last.sequence);
1194
+ }
1195
+ lastObservation = Object.freeze({
1196
+ frameNumber: frame.frameNumber,
1197
+ dispatches: dispatchCount,
1198
+ indirectDraws: indirectDrawCount,
1199
+ subjectOutputs: subjectOutputCount,
1200
+ });
1201
+ return ok(undefined);
1202
+ },
1203
+ recover: () => {
1204
+ const runtimes = new Set<VfxGpuRuntime>();
1205
+ for (const state of states.values()) {
1206
+ if (!state.world.hasResource(VFX_GPU_RUNTIME_RESOURCE_KEY)) continue;
1207
+ runtimes.add(state.world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY));
1208
+ }
1209
+ for (const runtime of runtimes) runtime.recover();
1210
+ states.clear();
1211
+ return ok(undefined);
1212
+ },
1213
+ dispose: () => {
1214
+ states.clear();
1215
+ lastObservation = Object.freeze({
1216
+ frameNumber: -1,
1217
+ dispatches: 0,
1218
+ indirectDraws: 0,
1219
+ subjectOutputs: 0,
1220
+ });
1221
+ return ok(undefined);
1222
+ },
1223
+ inspect: () => lastObservation,
1224
+ };
1225
+ }