@forgeax/engine-vfx-render 0.1.3 → 0.1.6

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.
@@ -1,17 +1,6 @@
1
1
  import type { EntityHandle, World } from '@forgeax/engine-ecs';
2
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';
3
+ import type { RenderFeature, RenderFeaturePlan } from '@forgeax/engine-render';
15
4
  import { Transform } from '@forgeax/engine-scene';
16
5
  import { err, type MaterialAsset, type MeshAsset, ok } from '@forgeax/engine-types';
17
6
  import type { ParticleRendererSource } from '@forgeax/engine-vfx';
@@ -20,6 +9,8 @@ import {
20
9
  type VfxGpuRuntime,
21
10
  type VfxGpuTickIntent,
22
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';
23
14
  import type { VfxDataInterfaceRegistry } from '../host/data-interface-providers.js';
24
15
  import type { ParticleRenderCamera } from './camera.js';
25
16
  import {
@@ -35,16 +26,9 @@ import {
35
26
  PARTICLE_SHADER_IDENTIFIERS,
36
27
  particleMaterialPass,
37
28
  particleMaterialUsesBindings,
38
- particleMaterialUsesSceneDepth,
39
29
  } 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';
30
+ import type { VfxStagePlanObservation, VfxValidatedStagePlan } from './stage-plan.js';
31
+ import { validatedStagePlan } from './stage-plan.js';
48
32
 
49
33
  const IDENTITY = 'forgeax.vfx-render.gpu-particles';
50
34
  const WORKGROUP_SIZE = 256;
@@ -53,7 +37,6 @@ const BILLBOARD_INSTANCE_BYTES = 31 * 4;
53
37
  const MESH_INSTANCE_BYTES = 28 * 4;
54
38
  const COUNTERS_BYTES = 24;
55
39
  const RUNTIME_BYTES = 72 * 4;
56
- const MAX_TICK_RINGS = 8;
57
40
  const IDENTITY_MATRIX = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
58
41
 
59
42
  type ParticleRendererKind = ParticleRendererSource['kind'];
@@ -61,21 +44,41 @@ type ParticleTopologyRenderer = Extract<ParticleRendererSource, { readonly capac
61
44
  type ParticleTopologyKind = ParticleTopologyRenderer['kind'];
62
45
  type VfxStageOutput = VfxStagePlanObservation['stageOutput'];
63
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
+
64
63
  export interface VfxRenderInspectInput {
65
64
  readonly topology: ParticleRendererKind;
66
65
  readonly capacity: number;
67
66
  readonly produced: number;
68
67
  readonly dropped: number;
69
68
  readonly stageReadiness: readonly unknown[];
69
+ readonly stageOutput?: VfxStageOutput;
70
70
  readonly providerReadiness: unknown;
71
71
  readonly gpuTiming: unknown;
72
72
  }
73
73
 
74
- export function createVfxRenderInspectSnapshot(input: VfxRenderInspectInput) {
74
+ export function createVfxRenderInspectSnapshot(
75
+ input: VfxRenderInspectInput,
76
+ ): VfxRenderInspectSnapshot {
75
77
  return {
76
78
  topology: input.topology,
77
79
  counters: { capacity: input.capacity, produced: input.produced, dropped: input.dropped },
78
80
  stageReadiness: input.stageReadiness,
81
+ stageOutput: input.stageOutput ?? 'empty',
79
82
  providerReadiness: input.providerReadiness,
80
83
  gpuTiming: input.gpuTiming,
81
84
  } as const;
@@ -151,18 +154,6 @@ interface GpuParticleFeatureOptions {
151
154
  };
152
155
  }
153
156
 
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
157
  interface ExtractedWorld {
167
158
  readonly world: World;
168
159
  readonly runtime: VfxGpuRuntime;
@@ -175,55 +166,6 @@ interface ExtractedFrame {
175
166
  readonly frameNumber: number;
176
167
  }
177
168
 
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
169
  function finite(value: unknown, fallback: number): number {
228
170
  return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
229
171
  }
@@ -323,7 +265,7 @@ function emitterVisible(
323
265
  (bounds.max[2] - bounds.min[2]) * 0.5,
324
266
  );
325
267
  const matrix = (index: number): number => localToWorld[index] ?? 0;
326
- const worldPosition = new Float32Array([
268
+ const worldCenter = new Float32Array([
327
269
  matrix(0) * center[0] + matrix(4) * center[1] + matrix(8) * center[2] + matrix(12),
328
270
  matrix(1) * center[0] + matrix(5) * center[1] + matrix(9) * center[2] + matrix(13),
329
271
  matrix(2) * center[0] + matrix(6) * center[1] + matrix(10) * center[2] + matrix(14),
@@ -334,93 +276,73 @@ function emitterVisible(
334
276
  Math.hypot(matrix(8), matrix(9), matrix(10)),
335
277
  );
336
278
  const planes = frustum.fromViewProjection(frustum.create(), camera.viewProjection);
337
- return frustum.intersectsSphere(planes, worldPosition, radius * scale);
279
+ return frustum.intersectsSphere(planes, worldCenter, radius * scale);
338
280
  }
339
281
 
340
282
  function resetData(size: number): Uint8Array {
341
283
  return new Uint8Array(size);
342
284
  }
343
285
 
344
- function target(
345
- targets: readonly RenderFeatureTargetHandle[],
346
- kind: 'scene-color' | 'scene-depth',
347
- ): RenderFeatureTargetHandle | undefined {
348
- return targets.find((entry) => entry.kind === kind);
286
+ function requiresSceneDepth(intent: VfxGpuTickIntent): boolean {
287
+ return (intent.emitter.reflection.dataInterfaces ?? []).some(
288
+ (requirement) => requirement.kind === 'scene-depth',
289
+ );
349
290
  }
350
291
 
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
- );
292
+ function planFailure(): RenderFeatureStageFailedError {
293
+ return new RenderFeatureStageFailedError(IDENTITY, -1, 'plan', 'next-frame');
361
294
  }
362
295
 
363
- function materialResourceKey(shader: string): string {
364
- return shader.replaceAll(/[^a-zA-Z0-9_.:-]/g, '_');
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);
365
302
  }
366
303
 
367
- function requiresSceneDepth(intent: VfxGpuTickIntent): boolean {
368
- return (intent.emitter.reflection.dataInterfaces ?? []).some(
369
- (requirement) => requirement.kind === 'scene-depth',
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 }] : [],
370
315
  );
371
316
  }
372
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
+
373
343
  export function gpuParticleRenderFeature(
374
344
  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
-
345
+ ): RenderFeature<ExtractedFrame> {
424
346
  return {
425
347
  identity: IDENTITY,
426
348
  requiredCapabilities: ['compute', 'indirectDrawing'],
@@ -435,8 +357,8 @@ export function gpuParticleRenderFeature(
435
357
  const intents = runtime.snapshot().filter((intent) => {
436
358
  if (options.playerConsumption?.isEnabled(world, intent.player) === false) return false;
437
359
  const requirements = intent.emitter.reflection.dataInterfaces ?? [];
438
- if (requirements.length === 0) return true;
439
360
  return (
361
+ requirements.length === 0 ||
440
362
  options.dataInterfaces?.resolve(requirements, intent.instanceGeneration).ok === true
441
363
  );
442
364
  });
@@ -444,782 +366,454 @@ export function gpuParticleRenderFeature(
444
366
  }
445
367
  return ok({ worlds: extracted, frameNumber: context.frameNumber });
446
368
  },
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(
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(
467
388
  intent.emitter.reflection.stages,
468
389
  intent.instanceGeneration,
469
390
  );
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,
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,
493
409
  );
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) {
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());
504
417
  if (
505
- program.error.code !== 'render-feature-preparation-failed' ||
506
- program.error.detail.recovery !== 'next-frame'
418
+ (renderer.kind === 'ribbon' ||
419
+ renderer.kind === 'trail' ||
420
+ renderer.kind === 'beam') &&
421
+ !createTopologyResourcePlan(renderer).ok
507
422
  ) {
508
- return program;
423
+ return err(planFailure());
509
424
  }
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
425
+ indirectWords[rendererIndex * 5] =
426
+ renderer.kind === 'mesh'
427
+ ? mesh?.indices === undefined
731
428
  ? (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;
429
+ : (submesh?.indexCount ?? 0)
430
+ : 6;
431
+ indirectWords[rendererIndex * 5 + 2] =
432
+ renderer.kind === 'mesh' && mesh?.indices !== undefined
433
+ ? (submesh?.indexOffset ?? 0)
434
+ : 0;
768
435
  }
769
- const materialPass = particleMaterialPass(renderer.kind, material);
770
- const materialKey = materialResourceKey(materialPass.shader);
771
- const samplesSceneDepth = particleMaterialUsesSceneDepth(
772
- context.graphics.getMaterialShaderBindingContract(materialPass.shader),
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,
773
441
  );
774
- const particleBlend = renderer.kind === 'billboard' ? renderer.blend : 'alpha';
775
- const projectionInstances = gpu.prepareBuffer(
776
- `${state.names}.renderer.${rendererIndex}.instances`,
442
+ const eventBytes = Math.max(4, eventCapacity(intent.emitter) * VFX_EVENT_BYTES);
443
+ resources.push(
777
444
  {
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'],
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
+ },
784
452
  },
785
- );
786
- if (!projectionInstances.ok) return projectionInstances;
787
- const projectionHistory = gpu.prepareBuffer(
788
- `${state.names}.renderer.${rendererIndex}.history`,
789
453
  {
790
- size:
791
- renderer.kind === 'trail'
792
- ? Math.max(16, renderer.capacity * renderer.historyLength * 16)
793
- : 16,
454
+ kind: 'buffer',
455
+ name: particles,
456
+ size: capacity * PARTICLE_BYTES,
794
457
  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
- : {}),
458
+ ...(intent.reset ? { data: resetData(capacity * PARTICLE_BYTES) } : {}),
804
459
  },
805
- );
806
- if (!projectionHistory.ok) return projectionHistory;
807
- const projectionRuntime = gpu.prepareBuffer(
808
- `${state.names}.renderer.${rendererIndex}.runtime`,
460
+ { kind: 'buffer', name: aliveIndices, size: capacity * 4, usage: ['storage'] },
809
461
  {
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
- ),
462
+ kind: 'buffer',
463
+ name: counters,
464
+ size: COUNTERS_BYTES,
465
+ usage: ['storage'],
466
+ ...(intent.reset ? { data: resetData(COUNTERS_BYTES) } : {}),
820
467
  },
821
- );
822
- if (!projectionRuntime.ok) return projectionRuntime;
823
- const projectionBindings = gpu.prepareBindings(
824
- `${state.names}.renderer.${rendererIndex}.bindings`,
825
468
  {
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
- ],
469
+ kind: 'buffer',
470
+ name: indirect,
471
+ size: indirectWords.byteLength,
472
+ usage: ['storage', 'indirect'],
473
+ data: indirectWords,
838
474
  },
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,
475
+ {
476
+ kind: 'buffer',
477
+ name: scratch,
478
+ size: scratchBytes,
479
+ usage: ['storage'],
480
+ ...(intent.reset ? { data: resetData(scratchBytes) } : {}),
847
481
  },
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
482
  {
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
- : {}),
483
+ kind: 'buffer',
484
+ name: sharedInstances,
485
+ size: capacity * Math.max(BILLBOARD_INSTANCE_BYTES, MESH_INSTANCE_BYTES),
486
+ usage: ['storage', 'vertex'],
903
487
  },
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
488
  {
918
- pipeline: pipeline.value,
919
- values: {
920
- group: 0,
921
- shader: materialPass.shader,
922
- ...(samplesSceneDepth && depthTarget !== undefined
923
- ? { sceneDepth: depthTarget }
924
- : {}),
925
- },
489
+ kind: 'buffer',
490
+ name: eventInputs,
491
+ size: eventInputBytes,
492
+ usage: ['storage'],
493
+ data: encodeEventInputs(intent),
926
494
  },
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
495
  {
977
- size: geometryData.byteLength,
978
- usage: ['vertex'],
979
- data: geometryData,
496
+ kind: 'buffer',
497
+ name: events,
498
+ size: eventBytes,
499
+ usage: ['storage'],
500
+ ...(intent.reset ? { data: resetData(eventBytes) } : {}),
980
501
  },
981
- );
982
- if (!geometryBuffer.ok) return geometryBuffer;
983
- const geometry = context.graphics.prepareVertexData(
984
- `${state.names}.renderer.${rendererIndex}.mesh.geometry`,
985
502
  {
986
- layout: RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInstance,
987
- buffer: geometryBuffer.value,
503
+ kind: 'buffer',
504
+ name: runtime,
505
+ size: RUNTIME_BYTES,
506
+ usage: ['uniform'],
507
+ data: runtimeData(intent, entry.camera, undefined, localToWorld),
988
508
  },
989
- );
990
- if (!geometry.ok) return geometry;
991
- const instances = context.graphics.prepareVertexData(
992
- `${state.names}.renderer.${rendererIndex}.mesh.instances`,
993
509
  {
994
- layout: RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInstance,
995
- buffer: projectionInstances.value,
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
+ }),
996
524
  },
997
525
  );
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
- },
526
+
527
+ const entryPoints = new Set(intent.emitter.reflection.entryPoints);
528
+ const dispatches = simulationDispatches(intent, stagePlan.value).filter((dispatch) =>
529
+ entryPoints.has(dispatch.entryPoint),
1040
530
  );
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,
531
+ if (dispatches.length > 0) {
532
+ passes.push({
533
+ kind: 'compute',
534
+ name: `${prefix}.simulate`,
535
+ program,
1100
536
  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,
537
+ dispatches,
1117
538
  });
539
+ dispatchedIntents.add(intent);
1118
540
  }
1119
- dispatches.push({
1120
- entryPoint:
1121
- projection.kind === 'billboard'
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'
1122
647
  ? 'forgeax_vfx_billboard_main'
1123
- : projection.kind === 'mesh'
648
+ : renderer.kind === 'mesh'
1124
649
  ? '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: [
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(
1158
748
  {
1159
- resource: state.colorTarget ?? 'swapchain',
1160
- format: state.colorTarget?.format ?? 'rgba8unorm-srgb',
1161
- loadOp: 'load',
1162
- storeOp: 'store',
749
+ kind: 'buffer',
750
+ name: indexBuffer,
751
+ size: mesh.indices.byteLength,
752
+ usage: ['index'],
753
+ data: mesh.indices,
1163
754
  },
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
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
1177
779
  ? {}
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;
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
+ }
1189
805
  }
1190
806
  }
1191
807
  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));
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);
1208
815
  }
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);
816
+ return ok<RenderFeaturePlan>({ resources, passes });
1222
817
  },
1223
- inspect: () => lastObservation,
1224
818
  };
1225
819
  }