@forgeax/engine-vfx-render 0.1.4 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,9 +1,8 @@
1
1
  import { frustum } from '@forgeax/engine-math';
2
- import { RenderFeatureStageFailedError, RENDER_FEATURE_VERTEX_LAYOUTS, RenderFeaturePreparationFailedError } from '@forgeax/engine-render';
3
2
  import { Transform } from '@forgeax/engine-scene';
4
3
  import { err, ok } from '@forgeax/engine-types';
5
- import { VFX_GPU_RUNTIME_RESOURCE_KEY, vfxGpuEffectContribution, vfxGpuRuntimePlugin, resolveVfxDataInterfaces, ParticleEffectPlayer } from '@forgeax/engine-vfx';
6
- import { getAssetRegistryResolver } from '@forgeax/engine-assets-runtime/internal';
4
+ import { VFX_GPU_RUNTIME_RESOURCE_KEY, vfxGpuEffectContribution, vfxGpuEffectPackLoader, vfxGpuRuntimePlugin, resolveVfxDataInterfaces, ParticleEffectPlayer } from '@forgeax/engine-vfx';
5
+ import { getAssetRegistryResolver } from '@forgeax/engine-assets-runtime';
7
6
  import { createWorldContext } from '@forgeax/engine-ecs';
8
7
 
9
8
  // src/feature/event-resources.ts
@@ -59,6 +58,34 @@ function encodeEventInputs(intent) {
59
58
  function eventCounterData() {
60
59
  return new Uint8Array(VFX_EVENT_COUNTER_BYTES);
61
60
  }
61
+
62
+ // ../render/src/errors/render.ts
63
+ var renderFeatureRecoveryHintByRecovery = {
64
+ "next-frame": (featureIdentity, stage) => `correct '${featureIdentity}' ${stage} data and retry on the next frame`,
65
+ "renderer-recover": (featureIdentity, _stage) => `wait for renderer recovery before retrying '${featureIdentity}'`,
66
+ registration: (featureIdentity, _stage) => `correct '${featureIdentity}' registration before retrying`
67
+ };
68
+ var RenderFeatureStageFailedError = class extends Error {
69
+ code = "render-feature-stage-failed";
70
+ expected;
71
+ hint;
72
+ detail;
73
+ constructor(featureIdentity, order, stage, recovery) {
74
+ const expected = `feature '${featureIdentity}' completes its ${stage} stage without an error`;
75
+ const hint = renderFeatureRecoveryHintByRecovery[recovery](featureIdentity, stage);
76
+ super(`render feature '${featureIdentity}' failed during ${stage}`);
77
+ this.name = "RenderFeatureStageFailedError";
78
+ this.expected = expected;
79
+ this.hint = hint;
80
+ this.detail = { featureIdentity, order, stage, recovery };
81
+ }
82
+ };
83
+ var RENDER_FEATURE_VERTEX_LAYOUTS = Object.freeze({
84
+ positionSizeColorInstance: "position-size-color-instance",
85
+ billboardMaterialInstance: "billboard-material-instance",
86
+ topologySegmentInstance: "topology-segment-instance",
87
+ meshGeometryMaterialInstance: "mesh-geometry-material-instance"
88
+ });
62
89
  var PARTICLE_SHADER_IDENTIFIERS = Object.freeze({
63
90
  billboard: "forgeax::vfx-render.particles.billboard",
64
91
  mesh: "forgeax::vfx-render.particles.mesh",
@@ -144,9 +171,6 @@ function particleMaterialPass(kind, material) {
144
171
  ...pass?.renderState === void 0 ? {} : { renderState: pass.renderState }
145
172
  };
146
173
  }
147
- function particleMaterialUsesSceneDepth(contract) {
148
- return contract === "group-0-resource" || contract === "view-with-resource";
149
- }
150
174
  function particleMaterialUsesBindings(material) {
151
175
  return (material?.parameters?.length ?? 0) > 0;
152
176
  }
@@ -164,19 +188,19 @@ function canonicalMeshVertices(mesh) {
164
188
  const tangents = floatAttribute(mesh.attributes.tangent);
165
189
  const result = new Float32Array(vertexCount * 12);
166
190
  for (let index = 0; index < vertexCount; index += 1) {
167
- const target2 = index * 12;
168
- result.set(positions.subarray(index * 3, index * 3 + 3), target2);
191
+ const target = index * 12;
192
+ result.set(positions.subarray(index * 3, index * 3 + 3), target);
169
193
  result.set(
170
194
  normals.length >= index * 3 + 3 ? normals.subarray(index * 3, index * 3 + 3) : [0, 0, 1],
171
- target2 + 3
195
+ target + 3
172
196
  );
173
197
  result.set(
174
198
  uvs.length >= index * 2 + 2 ? uvs.subarray(index * 2, index * 2 + 2) : [0, 0],
175
- target2 + 6
199
+ target + 6
176
200
  );
177
201
  result.set(
178
202
  tangents.length >= index * 4 + 4 ? tangents.subarray(index * 4, index * 4 + 4) : [1, 0, 0, 1],
179
- target2 + 8
203
+ target + 8
180
204
  );
181
205
  }
182
206
  return result;
@@ -374,13 +398,13 @@ var BILLBOARD_INSTANCE_BYTES = 31 * 4;
374
398
  var MESH_INSTANCE_BYTES = 28 * 4;
375
399
  var COUNTERS_BYTES = 24;
376
400
  var RUNTIME_BYTES = 72 * 4;
377
- var MAX_TICK_RINGS = 8;
378
401
  var IDENTITY_MATRIX = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
379
402
  function createVfxRenderInspectSnapshot(input) {
380
403
  return {
381
404
  topology: input.topology,
382
405
  counters: { capacity: input.capacity, produced: input.produced, dropped: input.dropped },
383
406
  stageReadiness: input.stageReadiness,
407
+ stageOutput: input.stageOutput ?? "empty",
384
408
  providerReadiness: input.providerReadiness,
385
409
  gpuTiming: input.gpuTiming
386
410
  };
@@ -486,7 +510,7 @@ function emitterVisible(intent, camera, localToWorld) {
486
510
  (bounds.max[2] - bounds.min[2]) * 0.5
487
511
  );
488
512
  const matrix = (index) => localToWorld[index] ?? 0;
489
- const worldPosition = new Float32Array([
513
+ const worldCenter = new Float32Array([
490
514
  matrix(0) * center[0] + matrix(4) * center[1] + matrix(8) * center[2] + matrix(12),
491
515
  matrix(1) * center[0] + matrix(5) * center[1] + matrix(9) * center[2] + matrix(13),
492
516
  matrix(2) * center[0] + matrix(6) * center[1] + matrix(10) * center[2] + matrix(14)
@@ -497,81 +521,53 @@ function emitterVisible(intent, camera, localToWorld) {
497
521
  Math.hypot(matrix(8), matrix(9), matrix(10))
498
522
  );
499
523
  const planes = frustum.fromViewProjection(frustum.create(), camera.viewProjection);
500
- return frustum.intersectsSphere(planes, worldPosition, radius * scale);
524
+ return frustum.intersectsSphere(planes, worldCenter, radius * scale);
501
525
  }
502
526
  function resetData(size) {
503
527
  return new Uint8Array(size);
504
528
  }
505
- function target(targets, kind) {
506
- return targets.find((entry) => entry.kind === kind);
507
- }
508
- function missingMeshPreparation(guid) {
509
- return new RenderFeaturePreparationFailedError(
510
- IDENTITY,
511
- -1,
512
- "asset-load",
513
- "vertex-data",
514
- `mesh:${guid}`,
515
- "asset-not-ready",
516
- "next-frame"
517
- );
518
- }
519
- function materialResourceKey(shader) {
520
- return shader.replaceAll(/[^a-zA-Z0-9_.:-]/g, "_");
521
- }
522
529
  function requiresSceneDepth(intent) {
523
530
  return (intent.emitter.reflection.dataInterfaces ?? []).some(
524
531
  (requirement) => requirement.kind === "scene-depth"
525
532
  );
526
533
  }
527
- function gpuParticleRenderFeature(options) {
528
- const worldIds = /* @__PURE__ */ new WeakMap();
529
- let nextWorldId = 0;
530
- const states = /* @__PURE__ */ new Map();
531
- let lastObservation = Object.freeze({
532
- frameNumber: -1,
533
- dispatches: 0,
534
- indirectDraws: 0,
535
- subjectOutputs: 0
536
- });
537
- const keyOf = (world, intent) => {
538
- let worldId = worldIds.get(world);
539
- if (worldId === void 0) {
540
- worldId = nextWorldId++;
541
- worldIds.set(world, worldId);
542
- }
543
- return `${worldId}:${intent.player}:${intent.emitter.id}`;
544
- };
545
- const stateFor = (world, intent) => {
546
- const key = keyOf(world, intent);
547
- let state = states.get(key);
548
- if (state !== void 0 && state.fingerprint !== intent.programFingerprint) {
549
- states.delete(key);
550
- state = void 0;
534
+ function planFailure() {
535
+ return new RenderFeatureStageFailedError(IDENTITY, -1, "plan", "next-frame");
536
+ }
537
+ function planName(value, maxLength = 24) {
538
+ const normalized = value.toLowerCase().replaceAll(/[^a-z0-9.-]/g, "-");
539
+ return (normalized.length === 0 ? "unnamed" : normalized).slice(0, maxLength);
540
+ }
541
+ function computeBindingEntries(intent, resources) {
542
+ const declared = new Set(
543
+ (intent.emitter.reflection.bindings[0]?.entries ?? []).filter((entry) => entry.buffer !== void 0).map((entry) => entry.binding)
544
+ );
545
+ return Object.entries(resources).flatMap(
546
+ ([binding, resource]) => declared.has(Number(binding)) ? [{ binding: Number(binding), resource }] : []
547
+ );
548
+ }
549
+ function simulationDispatches(intent, stages) {
550
+ const groups = Math.max(1, Math.ceil(intent.emitter.capacity / WORKGROUP_SIZE));
551
+ return [
552
+ { kind: "direct", entryPoint: "forgeax_vfx_spawn_main", workgroups: [groups] },
553
+ { kind: "direct", entryPoint: "forgeax_vfx_update_main", workgroups: [groups] },
554
+ ...stages.stages.map((stage) => ({
555
+ kind: "direct",
556
+ entryPoint: stage.entryPoint,
557
+ workgroups: [groups]
558
+ })),
559
+ { kind: "direct", entryPoint: "forgeax_vfx_scan_blocks_main", workgroups: [groups] },
560
+ { kind: "direct", entryPoint: "forgeax_vfx_scan_block_offsets_main", workgroups: [1] },
561
+ { kind: "direct", entryPoint: "forgeax_vfx_add_offsets_main", workgroups: [groups] },
562
+ { kind: "direct", entryPoint: "forgeax_vfx_compact_main", workgroups: [groups] },
563
+ {
564
+ kind: "direct",
565
+ entryPoint: "forgeax_vfx_event_main",
566
+ workgroups: [Math.max(1, Math.ceil(eventInputCapacity(intent.emitter) / 64))]
551
567
  }
552
- if (state !== void 0) return state;
553
- const fingerprint = intent.programFingerprint.slice(0, 12).replaceAll(":", "_");
554
- state = {
555
- world,
556
- player: intent.player,
557
- emitterId: intent.emitter.id,
558
- fingerprint: intent.programFingerprint,
559
- capacity: intent.emitter.capacity,
560
- names: `gpu.${key.replaceAll(":", ".")}.${fingerprint}`,
561
- rings: [],
562
- projections: [],
563
- draws: [],
564
- depthSampledDraws: [],
565
- colorTarget: void 0,
566
- depthTarget: void 0,
567
- indirectInitialized: false,
568
- culled: false,
569
- stageReadiness: [],
570
- stageOutput: "empty"
571
- };
572
- states.set(key, state);
573
- return state;
574
- };
568
+ ];
569
+ }
570
+ function gpuParticleRenderFeature(options) {
575
571
  return {
576
572
  identity: IDENTITY,
577
573
  requiredCapabilities: ["compute", "indirectDrawing"],
@@ -586,676 +582,399 @@ function gpuParticleRenderFeature(options) {
586
582
  const intents = runtime.snapshot().filter((intent) => {
587
583
  if (options.playerConsumption?.isEnabled(world, intent.player) === false) return false;
588
584
  const requirements = intent.emitter.reflection.dataInterfaces ?? [];
589
- if (requirements.length === 0) return true;
590
- return options.dataInterfaces?.resolve(requirements, intent.instanceGeneration).ok === true;
585
+ return requirements.length === 0 || options.dataInterfaces?.resolve(requirements, intent.instanceGeneration).ok === true;
591
586
  });
592
587
  extracted.push({ world, runtime, camera, intents });
593
588
  }
594
589
  return ok({ worlds: extracted, frameNumber: context.frameNumber });
595
590
  },
596
- prepare: (frame, context) => {
597
- const gpu = context.gpu;
598
- if (gpu === void 0) {
599
- return err(new RenderFeatureStageFailedError(IDENTITY, -1, "prepare", "renderer-recover"));
600
- }
601
- let pendingProgramError;
602
- const preparedIntents = [];
603
- for (const entry of frame.worlds) {
604
- for (const intent of entry.intents) {
605
- const key = keyOf(entry.world, intent);
606
- let state = states.get(key);
607
- const candidate = validatedStagePlan(
591
+ plan: (frame, context) => {
592
+ const resources = [];
593
+ const passes = [];
594
+ const dispatchedIntents = /* @__PURE__ */ new Set();
595
+ const colorTarget = context.targets.find((candidate) => candidate.kind === "color") ?? context.targets.find((candidate) => candidate.kind === "swapchain");
596
+ const depthTarget = context.targets.find((candidate) => candidate.kind === "depth");
597
+ for (const [worldIndex, entry] of frame.worlds.entries()) {
598
+ for (const [intentIndex, intent] of entry.intents.entries()) {
599
+ if (!entry.runtime.isEmitterSessionEnabled(intent.player, intent.emitter.id)) continue;
600
+ const localToWorld = emitterTransform(entry.world, intent);
601
+ const visible = emitterVisible(intent, entry.camera, localToWorld);
602
+ entry.runtime.setEmitterCameraVisibility(intent.player, intent.emitter.id, visible);
603
+ if (!visible) continue;
604
+ if (requiresSceneDepth(intent) && depthTarget === void 0) continue;
605
+ const stagePlan = validatedStagePlan(
608
606
  intent.emitter.reflection.stages,
609
607
  intent.instanceGeneration
610
608
  );
611
- if (!candidate.ok) {
612
- if (state === void 0) {
613
- return err(new RenderFeatureStageFailedError(IDENTITY, -1, "prepare", "next-frame"));
614
- }
615
- const observation2 = observeStagePlan(
616
- candidate,
617
- intent.instanceGeneration,
618
- state.lastKnownGoodStage
619
- );
620
- state.stagePlan = observation2.validatedStagePlan;
621
- state.stageReadiness = observation2.stageReadiness;
622
- state.stageOutput = observation2.stageOutput;
623
- continue;
624
- }
625
- if (state !== void 0 && state.fingerprint !== intent.programFingerprint) {
626
- states.delete(key);
627
- state = void 0;
628
- }
629
- state ??= stateFor(entry.world, intent);
630
- const observation = observeStagePlan(
631
- candidate,
632
- intent.instanceGeneration,
633
- state.lastKnownGoodStage
609
+ if (!stagePlan.ok) return err(planFailure());
610
+ const prefix = `vfx.w-${worldIndex}.i-${intentIndex}.${planName(intent.emitter.id)}`;
611
+ const program = `${prefix}.compute-program`;
612
+ const particles = `${prefix}.particles`;
613
+ const runtime = `${prefix}.runtime`;
614
+ const aliveIndices = `${prefix}.alive-indices`;
615
+ const counters = `${prefix}.counters`;
616
+ const indirect = `${prefix}.indirect`;
617
+ const scratch = `${prefix}.scratch`;
618
+ const sharedInstances = `${prefix}.shared-instances`;
619
+ const eventInputs = `${prefix}.event-inputs`;
620
+ const events = `${prefix}.events`;
621
+ const bindings = `${prefix}.simulation-bindings`;
622
+ const capacity = intent.emitter.capacity;
623
+ const renderers = intent.emitter.renderers;
624
+ const meshes = renderers.map(
625
+ (renderer) => renderer.kind === "mesh" ? options.mesh?.read(entry.world, renderer.mesh) : void 0
634
626
  );
635
- state.stagePlan = observation.validatedStagePlan;
636
- state.lastKnownGoodStage = candidate.value;
637
- state.stageReadiness = observation.stageReadiness;
638
- state.stageOutput = observation.stageOutput;
639
- const program = gpu.prepareProgram(`${state.names}.program`, {
640
- wgsl: intent.emitter.wgsl,
641
- entryPoints: intent.emitter.reflection.entryPoints,
642
- bindings: intent.emitter.reflection.bindings
643
- });
644
- if (!program.ok) {
645
- if (program.error.code !== "render-feature-preparation-failed" || program.error.detail.recovery !== "next-frame") {
646
- return program;
627
+ const indirectWords = new Uint32Array(Math.max(1, renderers.length) * 5);
628
+ for (const [rendererIndex, renderer] of renderers.entries()) {
629
+ const mesh = meshes[rendererIndex];
630
+ const submesh = renderer.kind === "mesh" ? mesh?.submeshes[renderer.submesh ?? 0] : void 0;
631
+ if (renderer.kind === "mesh" && submesh === void 0) return err(planFailure());
632
+ if ((renderer.kind === "ribbon" || renderer.kind === "trail" || renderer.kind === "beam") && !createTopologyResourcePlan(renderer).ok) {
633
+ return err(planFailure());
647
634
  }
648
- pendingProgramError ??= program.error;
635
+ indirectWords[rendererIndex * 5] = renderer.kind === "mesh" ? mesh?.indices === void 0 ? submesh?.vertexCount ?? 0 : submesh?.indexCount ?? 0 : 6;
636
+ indirectWords[rendererIndex * 5 + 2] = renderer.kind === "mesh" && mesh?.indices !== void 0 ? submesh?.indexOffset ?? 0 : 0;
649
637
  }
650
- preparedIntents.push({ entry, intent, state });
651
- }
652
- }
653
- if (pendingProgramError !== void 0) return err(pendingProgramError);
654
- let pendingGraphicsError;
655
- for (const { entry, intent, state } of preparedIntents) {
656
- if (intent.reset) state.indirectInitialized = false;
657
- state.lastIntent = intent;
658
- const base = state.names;
659
- const program = gpu.prepareProgram(`${base}.program`, {
660
- wgsl: intent.emitter.wgsl,
661
- entryPoints: intent.emitter.reflection.entryPoints,
662
- bindings: intent.emitter.reflection.bindings
663
- });
664
- if (!program.ok) return program;
665
- const prepare = (name, size, usage, data) => gpu.prepareBuffer(`${base}.${name}`, {
666
- size,
667
- usage,
668
- ...data === void 0 ? {} : { data }
669
- });
670
- const particles = prepare(
671
- "particles",
672
- state.capacity * PARTICLE_BYTES,
673
- ["storage"],
674
- intent.reset ? resetData(state.capacity * PARTICLE_BYTES) : void 0
675
- );
676
- if (!particles.ok) return particles;
677
- const aliveIndices = prepare("alive-indices", state.capacity * 4, ["storage"]);
678
- if (!aliveIndices.ok) return aliveIndices;
679
- const counters = prepare(
680
- "counters",
681
- COUNTERS_BYTES,
682
- ["storage"],
683
- intent.reset ? resetData(COUNTERS_BYTES) : void 0
684
- );
685
- if (!counters.ok) return counters;
686
- const indirect = prepare("indirect", Math.max(1, intent.emitter.renderers.length) * 20, [
687
- "storage",
688
- "indirect"
689
- ]);
690
- if (!indirect.ok) return indirect;
691
- const scratchBytes = (state.capacity * 2 + Math.ceil(state.capacity / WORKGROUP_SIZE)) * 4;
692
- const scratch = prepare(
693
- "scratch",
694
- scratchBytes,
695
- ["storage"],
696
- intent.reset ? resetData(scratchBytes) : void 0
697
- );
698
- if (!scratch.ok) return scratch;
699
- const billboardInstances = prepare(
700
- "billboard-instances",
701
- state.capacity * Math.max(BILLBOARD_INSTANCE_BYTES, MESH_INSTANCE_BYTES),
702
- ["storage", "vertex"]
703
- );
704
- if (!billboardInstances.ok) return billboardInstances;
705
- const events = prepare(
706
- "events",
707
- eventCapacity(intent.emitter) * VFX_EVENT_BYTES,
708
- ["storage"],
709
- intent.reset ? resetData(eventCapacity(intent.emitter) * VFX_EVENT_BYTES) : void 0
710
- );
711
- if (!events.ok) return events;
712
- const initialEventInputs = prepare(
713
- "event-inputs",
714
- eventInputCapacity(intent.emitter) * VFX_EVENT_INPUT_BYTES,
715
- ["storage"],
716
- encodeEventInputs(intent)
717
- );
718
- if (!initialEventInputs.ok) return initialEventInputs;
719
- state.refs = {
720
- program: program.value,
721
- particles: particles.value,
722
- aliveIndices: aliveIndices.value,
723
- counters: counters.value,
724
- indirect: indirect.value,
725
- scratch: scratch.value,
726
- billboardInstances: billboardInstances.value,
727
- eventInputs: initialEventInputs.value,
728
- events: events.value
729
- };
730
- const ringIndex = intent.tick % MAX_TICK_RINGS;
731
- const runtime = gpu.prepareBuffer(`${base}.runtime.${ringIndex}`, {
732
- size: RUNTIME_BYTES,
733
- usage: ["uniform"],
734
- data: runtimeData(intent, entry.camera, void 0, emitterTransform(entry.world, intent))
735
- });
736
- if (!runtime.ok) return runtime;
737
- const refs = state.refs;
738
- const updatedEventInputs = gpu.prepareBuffer(`${base}.event-inputs`, {
739
- size: eventInputCapacity(intent.emitter) * VFX_EVENT_INPUT_BYTES,
740
- usage: ["storage"],
741
- data: encodeEventInputs(intent)
742
- });
743
- if (!updatedEventInputs.ok) return updatedEventInputs;
744
- const bindings = gpu.prepareBindings(`${base}.bindings.${ringIndex}`, {
745
- program: refs.program,
746
- entries: [
747
- { binding: 0, buffer: refs.particles },
748
- { binding: 1, buffer: runtime.value },
749
- { binding: 2, buffer: refs.aliveIndices },
750
- { binding: 3, buffer: refs.counters },
751
- { binding: 4, buffer: refs.indirect },
752
- { binding: 5, buffer: refs.scratch },
753
- { binding: 6, buffer: refs.billboardInstances },
754
- { binding: 8, buffer: updatedEventInputs.value },
755
- { binding: 9, buffer: refs.events }
756
- ]
757
- });
758
- if (!bindings.ok) return bindings;
759
- state.rings[ringIndex] = {
760
- runtime: runtime.value,
761
- bindings: bindings.value
762
- };
763
- }
764
- for (const [key, state] of states) {
765
- const extracted = frame.worlds.find((entry) => entry.world === state.world);
766
- if (extracted === void 0 || !extracted.runtime.hasPlayer(state.player)) {
767
- states.delete(key);
768
- continue;
769
- }
770
- if (options.playerConsumption?.isEnabled(state.world, state.player) === false) {
771
- state.projections = [];
772
- state.draws = [];
773
- state.depthSampledDraws = [];
774
- state.colorTarget = void 0;
775
- state.depthTarget = void 0;
776
- continue;
777
- }
778
- const intent = state.lastIntent;
779
- const refs = state.refs;
780
- if (intent === void 0 || refs === void 0) continue;
781
- const retained = gpu.retainBindings([
782
- ...state.rings.flatMap((ring) => ring === void 0 ? [] : [ring.bindings]),
783
- ...state.projections.map((projection) => projection.ring.bindings)
784
- ]);
785
- if (!retained.ok) return retained;
786
- if (!extracted.runtime.isEmitterSessionEnabled(state.player, state.emitterId)) {
787
- state.projections = [];
788
- state.draws = [];
789
- state.depthSampledDraws = [];
790
- continue;
791
- }
792
- const renderers = intent.emitter.renderers;
793
- if (renderers.length === 0) continue;
794
- const localToWorld = emitterTransform(state.world, intent);
795
- const visible = emitterVisible(intent, extracted.camera, localToWorld);
796
- extracted.runtime.setEmitterCameraVisibility(state.player, state.emitterId, visible);
797
- const currentIntents = extracted.intents.filter(
798
- (candidate) => candidate.player === state.player && candidate.emitter.id === state.emitterId
799
- );
800
- if (!visible) {
801
- state.culled = true;
802
- state.projections = [];
803
- state.draws = [];
804
- state.depthSampledDraws = [];
805
- continue;
806
- }
807
- if (state.culled && intent.emitter.simulationWhenCulled === "restart-on-visible" && !currentIntents.some((candidate) => candidate.reset)) {
808
- state.projections = [];
809
- state.draws = [];
810
- state.depthSampledDraws = [];
811
- continue;
812
- }
813
- state.culled = false;
814
- const colorTarget = target(context.targets, "scene-color");
815
- const depthTarget = target(context.targets, "scene-depth");
816
- const softParticle = requiresSceneDepth(intent);
817
- if (softParticle && depthTarget === void 0) continue;
818
- const eventRing = state.rings[intent.tick % MAX_TICK_RINGS];
819
- if (eventRing === void 0) continue;
820
- const meshes = renderers.map(
821
- (renderer) => renderer.kind === "mesh" ? options.mesh?.read(state.world, renderer.mesh) : void 0
822
- );
823
- const missingMesh = renderers.find(
824
- (renderer, index) => renderer.kind === "mesh" && meshes[index]?.submeshes[renderer.submesh ?? 0] === void 0
825
- );
826
- if (missingMesh?.kind === "mesh") {
827
- pendingGraphicsError ??= missingMeshPreparation(missingMesh.mesh);
828
- continue;
829
- }
830
- const indirectWords = new Uint32Array(renderers.length * 5);
831
- for (const [index, renderer] of renderers.entries()) {
832
- const mesh = meshes[index];
833
- const submesh = renderer.kind === "mesh" ? mesh?.submeshes[renderer.submesh ?? 0] : void 0;
834
- const topologyPlan = renderer.kind === "ribbon" || renderer.kind === "trail" || renderer.kind === "beam" ? createTopologyResourcePlan(renderer) : void 0;
835
- if (topologyPlan !== void 0 && !topologyPlan.ok)
836
- return err(new RenderFeatureStageFailedError(IDENTITY, -1, "prepare", "next-frame"));
837
- indirectWords[index * 5] = renderer.kind === "billboard" ? 6 : renderer.kind === "ribbon" || renderer.kind === "trail" || renderer.kind === "beam" ? 6 : mesh?.indices === void 0 ? submesh?.vertexCount ?? 0 : submesh?.indexCount ?? 0;
838
- indirectWords[index * 5 + 2] = renderer.kind === "mesh" && mesh?.indices !== void 0 ? submesh?.indexOffset ?? 0 : 0;
839
- }
840
- const indirectInit = gpu.prepareBuffer(`${state.names}.indirect`, {
841
- size: Math.max(1, renderers.length) * 20,
842
- usage: ["storage", "indirect"],
843
- ...state.indirectInitialized ? {} : { data: indirectWords }
844
- });
845
- if (!indirectInit.ok) return indirectInit;
846
- state.indirectInitialized = true;
847
- const draws = [];
848
- const depthSampledDraws = [];
849
- const projections = [];
850
- for (const [rendererIndex, renderer] of renderers.entries()) {
851
- const isBillboard = renderer.kind === "billboard";
852
- const isTopology = renderer.kind === "ribbon" || renderer.kind === "trail" || renderer.kind === "beam";
853
- const topologyPlan = isTopology ? createTopologyResourcePlan(renderer) : void 0;
854
- if (topologyPlan !== void 0 && !topologyPlan.ok)
855
- return err(new RenderFeatureStageFailedError(IDENTITY, -1, "prepare", "next-frame"));
856
- const mesh = meshes[rendererIndex];
857
- const submesh = renderer.kind === "mesh" ? mesh?.submeshes[renderer.submesh ?? 0] : void 0;
858
- const indexFormat = mesh?.indices instanceof Uint32Array ? "uint32" : "uint16";
859
- let material = options.material?.read(state.world, renderer.material);
860
- if (material !== void 0) {
861
- const resolvedMaterial = context.graphics.resolveMaterialAsset(material);
862
- if (!resolvedMaterial.ok) {
863
- pendingGraphicsError ??= resolvedMaterial.error;
864
- continue;
865
- }
866
- material = resolvedMaterial.value;
867
- }
868
- const materialPass = particleMaterialPass(renderer.kind, material);
869
- const materialKey = materialResourceKey(materialPass.shader);
870
- const samplesSceneDepth = particleMaterialUsesSceneDepth(
871
- context.graphics.getMaterialShaderBindingContract(materialPass.shader)
638
+ const scratchBytes = (capacity * 2 + Math.ceil(capacity / WORKGROUP_SIZE)) * 4;
639
+ const eventInputBytes = Math.max(
640
+ 4,
641
+ eventInputCapacity(intent.emitter) * VFX_EVENT_INPUT_BYTES
872
642
  );
873
- const particleBlend = renderer.kind === "billboard" ? renderer.blend : "alpha";
874
- const projectionInstances = gpu.prepareBuffer(
875
- `${state.names}.renderer.${rendererIndex}.instances`,
643
+ const eventBytes = Math.max(4, eventCapacity(intent.emitter) * VFX_EVENT_BYTES);
644
+ resources.push(
645
+ {
646
+ kind: "compute-program",
647
+ name: program,
648
+ program: {
649
+ wgsl: intent.emitter.wgsl,
650
+ entryPoints: intent.emitter.reflection.entryPoints,
651
+ bindings: intent.emitter.reflection.bindings
652
+ }
653
+ },
654
+ {
655
+ kind: "buffer",
656
+ name: particles,
657
+ size: capacity * PARTICLE_BYTES,
658
+ usage: ["storage"],
659
+ ...intent.reset ? { data: resetData(capacity * PARTICLE_BYTES) } : {}
660
+ },
661
+ { kind: "buffer", name: aliveIndices, size: capacity * 4, usage: ["storage"] },
662
+ {
663
+ kind: "buffer",
664
+ name: counters,
665
+ size: COUNTERS_BYTES,
666
+ usage: ["storage"],
667
+ ...intent.reset ? { data: resetData(COUNTERS_BYTES) } : {}
668
+ },
876
669
  {
877
- size: isTopology ? topologyPlan?.ok ? topologyPlan.value.vertexBytes : 0 : state.capacity * (isBillboard ? BILLBOARD_INSTANCE_BYTES : MESH_INSTANCE_BYTES),
670
+ kind: "buffer",
671
+ name: indirect,
672
+ size: indirectWords.byteLength,
673
+ usage: ["storage", "indirect"],
674
+ data: indirectWords
675
+ },
676
+ {
677
+ kind: "buffer",
678
+ name: scratch,
679
+ size: scratchBytes,
680
+ usage: ["storage"],
681
+ ...intent.reset ? { data: resetData(scratchBytes) } : {}
682
+ },
683
+ {
684
+ kind: "buffer",
685
+ name: sharedInstances,
686
+ size: capacity * Math.max(BILLBOARD_INSTANCE_BYTES, MESH_INSTANCE_BYTES),
878
687
  usage: ["storage", "vertex"]
879
- }
880
- );
881
- if (!projectionInstances.ok) return projectionInstances;
882
- const projectionHistory = gpu.prepareBuffer(
883
- `${state.names}.renderer.${rendererIndex}.history`,
688
+ },
884
689
  {
885
- size: renderer.kind === "trail" ? Math.max(16, renderer.capacity * renderer.historyLength * 16) : 16,
690
+ kind: "buffer",
691
+ name: eventInputs,
692
+ size: eventInputBytes,
886
693
  usage: ["storage"],
887
- ...intent.reset ? {
888
- data: resetData(
889
- renderer.kind === "trail" ? Math.max(16, renderer.capacity * renderer.historyLength * 16) : 16
890
- )
891
- } : {}
892
- }
893
- );
894
- if (!projectionHistory.ok) return projectionHistory;
895
- const projectionRuntime = gpu.prepareBuffer(
896
- `${state.names}.renderer.${rendererIndex}.runtime`,
694
+ data: encodeEventInputs(intent)
695
+ },
897
696
  {
697
+ kind: "buffer",
698
+ name: events,
699
+ size: eventBytes,
700
+ usage: ["storage"],
701
+ ...intent.reset ? { data: resetData(eventBytes) } : {}
702
+ },
703
+ {
704
+ kind: "buffer",
705
+ name: runtime,
898
706
  size: RUNTIME_BYTES,
899
707
  usage: ["uniform"],
900
- data: runtimeData(
901
- { ...intent, fixedDelta: 0, spawnCount: 0 },
902
- extracted.camera,
903
- material,
904
- localToWorld,
905
- renderer,
906
- rendererIndex
907
- )
908
- }
909
- );
910
- if (!projectionRuntime.ok) return projectionRuntime;
911
- const projectionBindings = gpu.prepareBindings(
912
- `${state.names}.renderer.${rendererIndex}.bindings`,
913
- {
914
- program: refs.program,
915
- entries: [
916
- { binding: 0, buffer: refs.particles },
917
- { binding: 1, buffer: projectionRuntime.value },
918
- { binding: 2, buffer: refs.aliveIndices },
919
- { binding: 3, buffer: refs.counters },
920
- { binding: 4, buffer: refs.indirect },
921
- { binding: 5, buffer: projectionHistory.value },
922
- { binding: 6, buffer: projectionInstances.value },
923
- { binding: 8, buffer: refs.eventInputs },
924
- { binding: 9, buffer: refs.events }
925
- ]
926
- }
927
- );
928
- if (!projectionBindings.ok) return projectionBindings;
929
- projections.push({
930
- kind: renderer.kind,
931
- instances: projectionInstances.value,
932
- ring: {
933
- runtime: projectionRuntime.value,
934
- bindings: projectionBindings.value
708
+ data: runtimeData(intent, entry.camera, void 0, localToWorld)
935
709
  },
936
- workgroups: Math.ceil(
937
- (renderer.kind === "trail" ? renderer.capacity * Math.max(1, renderer.historyLength - 1) : renderer.kind === "ribbon" || renderer.kind === "beam" ? renderer.capacity : state.capacity) / WORKGROUP_SIZE
938
- ),
939
- ...renderer.kind === "trail" ? { historyWorkgroups: Math.ceil(renderer.capacity / WORKGROUP_SIZE) } : {},
940
- ...renderer.kind === "billboard" ? { sorting: renderer.sorting ?? "none" } : {}
941
- });
942
- const pipeline = context.graphics.preparePipeline(
943
- `${state.names}.renderer.${rendererIndex}.${renderer.kind}.pipeline.${materialKey}`,
944
710
  {
945
- shader: materialPass.shader,
946
- vertexLayout: isBillboard ? RENDER_FEATURE_VERTEX_LAYOUTS.billboardMaterialInstance : isTopology ? RENDER_FEATURE_VERTEX_LAYOUTS.topologySegmentInstance : RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInstance,
947
- colorFormats: [colorTarget?.format ?? "rgba8unorm-srgb"],
948
- ...depthTarget === void 0 ? {} : { depthFormat: depthTarget.format },
949
- sampleCount: colorTarget?.sampleCount ?? 1,
950
- topology: submesh?.topology ?? "triangle-list",
951
- ...mesh?.indices === void 0 ? {} : { indexFormat },
952
- ...materialPass.renderState !== void 0 ? { renderState: materialPass.renderState } : isBillboard || isTopology ? {
953
- renderState: {
954
- cullMode: "none",
955
- depthCompare: "less-equal",
956
- depthWriteEnabled: softParticle || isTopology ? false : particleBlend === "opaque-cutout",
957
- ...particleBlend === "opaque-cutout" ? {} : {
958
- blend: {
959
- color: {
960
- srcFactor: "one",
961
- dstFactor: particleBlend === "additive" ? "one" : "one-minus-src-alpha",
962
- operation: "add"
963
- },
964
- alpha: {
965
- srcFactor: "one",
966
- dstFactor: "one-minus-src-alpha",
967
- operation: "add"
968
- }
969
- }
970
- }
971
- }
972
- } : {}
973
- }
974
- );
975
- if (!pipeline.ok) {
976
- if (pipeline.error.code !== "render-feature-preparation-failed" || pipeline.error.detail.recovery !== "next-frame") {
977
- return pipeline;
978
- }
979
- pendingGraphicsError ??= pipeline.error;
980
- continue;
981
- }
982
- const graphicsBindings = context.graphics.prepareBindings(
983
- `${state.names}.renderer.${rendererIndex}.${renderer.kind}.binding.${materialKey}`,
984
- {
985
- pipeline: pipeline.value,
986
- values: {
987
- group: 0,
988
- shader: materialPass.shader,
989
- ...samplesSceneDepth && depthTarget !== void 0 ? { sceneDepth: depthTarget } : {}
990
- }
711
+ kind: "compute-bindings",
712
+ name: bindings,
713
+ program,
714
+ entries: computeBindingEntries(intent, {
715
+ 0: particles,
716
+ 1: runtime,
717
+ 2: aliveIndices,
718
+ 3: counters,
719
+ 4: indirect,
720
+ 5: scratch,
721
+ 6: sharedInstances,
722
+ 8: eventInputs,
723
+ 9: events
724
+ })
991
725
  }
992
726
  );
993
- if (!graphicsBindings.ok) return graphicsBindings;
994
- const materialBindings = !particleMaterialUsesBindings(material) ? void 0 : context.graphics.prepareBindings(
995
- `${state.names}.renderer.${rendererIndex}.${renderer.kind}.material-binding.${materialKey}`,
996
- {
997
- pipeline: pipeline.value,
998
- values: {
999
- group: 1,
1000
- material: {
1001
- world: frame.worlds.findIndex((entry) => entry.world === state.world),
1002
- guid: renderer.material
1003
- }
1004
- }
1005
- }
727
+ const entryPoints = new Set(intent.emitter.reflection.entryPoints);
728
+ const dispatches = simulationDispatches(intent, stagePlan.value).filter(
729
+ (dispatch) => entryPoints.has(dispatch.entryPoint)
1006
730
  );
1007
- if (materialBindings !== void 0 && !materialBindings.ok) return materialBindings;
1008
- const drawBindings = [
1009
- graphicsBindings.value,
1010
- ...materialBindings === void 0 ? [] : [materialBindings.value]
1011
- ];
1012
- if (isBillboard || isTopology) {
1013
- const vertexData2 = context.graphics.prepareVertexData(
1014
- `${state.names}.${isTopology ? renderer.kind : "billboard"}.vertices`,
731
+ if (dispatches.length > 0) {
732
+ passes.push({
733
+ kind: "compute",
734
+ name: `${prefix}.simulate`,
735
+ program,
736
+ bindings,
737
+ dispatches
738
+ });
739
+ dispatchedIntents.add(intent);
740
+ }
741
+ for (const [rendererIndex, renderer] of renderers.entries()) {
742
+ const rendererPrefix = `${prefix}.renderer-${rendererIndex}`;
743
+ const isBillboard = renderer.kind === "billboard";
744
+ const isTopology = renderer.kind === "ribbon" || renderer.kind === "trail" || renderer.kind === "beam";
745
+ const topologyPlan = isTopology ? createTopologyResourcePlan(renderer) : void 0;
746
+ if (topologyPlan !== void 0 && !topologyPlan.ok) return err(planFailure());
747
+ const material = options.material?.read(entry.world, renderer.material);
748
+ const materialPass = particleMaterialPass(renderer.kind, material);
749
+ const mesh = meshes[rendererIndex];
750
+ const submesh = renderer.kind === "mesh" ? mesh?.submeshes[renderer.submesh ?? 0] : void 0;
751
+ const indexFormat = mesh?.indices instanceof Uint32Array ? "uint32" : "uint16";
752
+ const instances = `${rendererPrefix}.instances`;
753
+ const history = `${rendererPrefix}.history`;
754
+ const projectionRuntime = `${rendererPrefix}.runtime`;
755
+ const projectionBindings = `${rendererPrefix}.compute-bindings`;
756
+ const vertexLayout = isBillboard ? RENDER_FEATURE_VERTEX_LAYOUTS.billboardMaterialInstance : isTopology ? RENDER_FEATURE_VERTEX_LAYOUTS.topologySegmentInstance : RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInstance;
757
+ const instanceBytes = isTopology ? topologyPlan?.value.vertexBytes ?? 16 : capacity * (isBillboard ? BILLBOARD_INSTANCE_BYTES : MESH_INSTANCE_BYTES);
758
+ const historyBytes = renderer.kind === "trail" ? Math.max(16, renderer.capacity * renderer.historyLength * 16) : 16;
759
+ resources.push(
760
+ {
761
+ kind: "buffer",
762
+ name: instances,
763
+ size: instanceBytes,
764
+ usage: ["storage", "vertex"]
765
+ },
1015
766
  {
1016
- layout: isTopology ? RENDER_FEATURE_VERTEX_LAYOUTS.topologySegmentInstance : RENDER_FEATURE_VERTEX_LAYOUTS.billboardMaterialInstance,
1017
- buffer: projectionInstances.value
767
+ kind: "buffer",
768
+ name: history,
769
+ size: historyBytes,
770
+ usage: ["storage"],
771
+ ...intent.reset ? { data: resetData(historyBytes) } : {}
772
+ },
773
+ {
774
+ kind: "buffer",
775
+ name: projectionRuntime,
776
+ size: RUNTIME_BYTES,
777
+ usage: ["uniform"],
778
+ data: runtimeData(
779
+ { ...intent, fixedDelta: 0, spawnCount: 0 },
780
+ entry.camera,
781
+ material,
782
+ localToWorld,
783
+ renderer,
784
+ rendererIndex
785
+ )
786
+ },
787
+ {
788
+ kind: "compute-bindings",
789
+ name: projectionBindings,
790
+ program,
791
+ entries: computeBindingEntries(intent, {
792
+ 0: particles,
793
+ 1: projectionRuntime,
794
+ 2: aliveIndices,
795
+ 3: counters,
796
+ 4: indirect,
797
+ 5: history,
798
+ 6: instances,
799
+ 8: eventInputs,
800
+ 9: events
801
+ })
1018
802
  }
1019
803
  );
1020
- if (!vertexData2.ok) return vertexData2;
1021
- const draw = {
1022
- kind: "draw-indirect",
1023
- pipeline: pipeline.value,
1024
- bindings: drawBindings,
1025
- vertexData: [{ slot: 0, resource: vertexData2.value }],
1026
- command: { buffer: refs.indirect, offset: rendererIndex * 20 }
804
+ const projectionDispatches = [];
805
+ const pushProjection = (entryPoint, workgroups) => {
806
+ if (!entryPoints.has(entryPoint)) return;
807
+ projectionDispatches.push({
808
+ kind: "direct",
809
+ entryPoint,
810
+ workgroups: [Math.max(1, workgroups)]
811
+ });
1027
812
  };
1028
- (samplesSceneDepth ? depthSampledDraws : draws).push(draw);
1029
- continue;
1030
- }
1031
- if (mesh === void 0) {
1032
- return err(new RenderFeatureStageFailedError(IDENTITY, -1, "prepare", "next-frame"));
1033
- }
1034
- const geometryData = canonicalMeshVertices(mesh);
1035
- const geometryBuffer = gpu.prepareBuffer(
1036
- `${state.names}.renderer.${rendererIndex}.mesh.geometry-buffer`,
1037
- {
1038
- size: geometryData.byteLength,
1039
- usage: ["vertex"],
1040
- data: geometryData
813
+ if (isBillboard && renderer.sorting === "back-to-front") {
814
+ pushProjection("forgeax_vfx_sort_main", 1);
1041
815
  }
1042
- );
1043
- if (!geometryBuffer.ok) return geometryBuffer;
1044
- const geometry = context.graphics.prepareVertexData(
1045
- `${state.names}.renderer.${rendererIndex}.mesh.geometry`,
1046
- {
1047
- layout: RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInstance,
1048
- buffer: geometryBuffer.value
1049
- }
1050
- );
1051
- if (!geometry.ok) return geometry;
1052
- const instances = context.graphics.prepareVertexData(
1053
- `${state.names}.renderer.${rendererIndex}.mesh.instances`,
1054
- {
1055
- layout: RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInstance,
1056
- buffer: projectionInstances.value
1057
- }
1058
- );
1059
- if (!instances.ok) return instances;
1060
- const indexBuffer = mesh.indices === void 0 ? void 0 : gpu.prepareBuffer(`${state.names}.renderer.${rendererIndex}.mesh.index-buffer`, {
1061
- size: mesh.indices.byteLength,
1062
- usage: ["index"],
1063
- data: mesh.indices
1064
- });
1065
- if (indexBuffer !== void 0 && !indexBuffer.ok) return indexBuffer;
1066
- const indices = indexBuffer === void 0 ? void 0 : context.graphics.prepareIndexData(
1067
- `${state.names}.renderer.${rendererIndex}.mesh.indices`,
1068
- {
1069
- format: indexFormat,
1070
- buffer: indexBuffer.value
816
+ if (renderer.kind === "trail") {
817
+ pushProjection(
818
+ "forgeax_vfx_trail_history_main",
819
+ Math.ceil(renderer.capacity / WORKGROUP_SIZE)
820
+ );
1071
821
  }
1072
- );
1073
- if (indices !== void 0 && !indices.ok) return indices;
1074
- const vertexData = [
1075
- { slot: 0, resource: geometry.value },
1076
- { slot: 1, resource: instances.value }
1077
- ];
1078
- draws.push(
1079
- indices === void 0 ? {
1080
- kind: "draw-indirect",
1081
- pipeline: pipeline.value,
1082
- bindings: drawBindings,
1083
- vertexData,
1084
- command: { buffer: refs.indirect, offset: rendererIndex * 20 }
1085
- } : {
1086
- kind: "draw-indexed-indirect",
1087
- pipeline: pipeline.value,
1088
- bindings: drawBindings,
1089
- vertexData,
1090
- indexData: { resource: indices.value, format: indexFormat },
1091
- command: { buffer: refs.indirect, offset: rendererIndex * 20 }
822
+ const projectionCount = renderer.kind === "trail" ? renderer.capacity * Math.max(1, renderer.historyLength - 1) : isTopology ? renderer.capacity : capacity;
823
+ pushProjection(
824
+ renderer.kind === "billboard" ? "forgeax_vfx_billboard_main" : renderer.kind === "mesh" ? "forgeax_vfx_mesh_main" : `forgeax_vfx_${renderer.kind}_main`,
825
+ Math.ceil(projectionCount / WORKGROUP_SIZE)
826
+ );
827
+ if (projectionDispatches.length > 0) {
828
+ passes.push({
829
+ kind: "compute",
830
+ name: `${rendererPrefix}.project`,
831
+ program,
832
+ bindings: projectionBindings,
833
+ dispatches: projectionDispatches
834
+ });
1092
835
  }
1093
- );
1094
- }
1095
- state.projections = projections;
1096
- state.draws = draws;
1097
- state.depthSampledDraws = depthSampledDraws;
1098
- state.colorTarget = colorTarget;
1099
- state.depthTarget = depthTarget;
1100
- }
1101
- if (pendingGraphicsError !== void 0) return err(pendingGraphicsError);
1102
- return ok(void 0);
1103
- },
1104
- contribute: (frame, context) => {
1105
- let dispatchCount = 0;
1106
- let indirectDrawCount = 0;
1107
- let subjectOutputCount = 0;
1108
- for (const state of states.values()) {
1109
- const refs = state.refs;
1110
- const firstProjection = state.projections[0];
1111
- if (refs === void 0) continue;
1112
- const extracted = frame.worlds.find((entry) => entry.world === state.world);
1113
- if (extracted === void 0) continue;
1114
- if (options.playerConsumption?.isEnabled(state.world, state.player) === false) continue;
1115
- if (!extracted.runtime.isEmitterSessionEnabled(state.player, state.emitterId)) continue;
1116
- const currentIntents = extracted.intents.filter(
1117
- (intent) => intent.player === state.player && intent.emitter.id === state.emitterId
1118
- );
1119
- const intents = currentIntents.some(
1120
- (intent) => intent.programFingerprint === state.fingerprint
1121
- ) ? currentIntents.filter((intent) => intent.programFingerprint === state.fingerprint) : state.lastIntent === void 0 ? [] : [state.lastIntent];
1122
- const groups = Math.ceil(state.capacity / WORKGROUP_SIZE);
1123
- const dispatches = intents.flatMap((intent) => {
1124
- const bindings = state.rings[intent.tick % MAX_TICK_RINGS]?.bindings;
1125
- if (bindings === void 0) return [];
1126
- return [
1127
- { entryPoint: "forgeax_vfx_spawn_main", workgroups: [groups], bindings },
1128
- { entryPoint: "forgeax_vfx_update_main", workgroups: [groups], bindings },
1129
- ...stageDispatches(
1130
- state.stagePlan ?? {
1131
- stages: [],
1132
- generation: intent.instanceGeneration
836
+ const graphicsProgram = `${rendererPrefix}.graphics-program`;
837
+ const graphicsBindings = `${rendererPrefix}.graphics-bindings`;
838
+ const vertexData = `${rendererPrefix}.vertex-data`;
839
+ const renderState = isBillboard && depthTarget !== void 0 ? { ...materialPass.renderState ?? {}, depthWriteEnabled: false } : materialPass.renderState;
840
+ resources.push(
841
+ {
842
+ kind: "graphics-program",
843
+ name: graphicsProgram,
844
+ program: {
845
+ shader: materialPass.shader,
846
+ vertexLayout,
847
+ colorFormats: [colorTarget?.format ?? "rgba8unorm-srgb"],
848
+ ...depthTarget === void 0 ? {} : { depthFormat: depthTarget.format },
849
+ sampleCount: colorTarget?.sampleCount ?? 1,
850
+ topology: submesh?.topology ?? "triangle-list",
851
+ ...mesh?.indices === void 0 ? {} : { indexFormat },
852
+ ...renderState === void 0 ? {} : { renderState }
853
+ }
1133
854
  },
1134
- groups,
1135
- bindings
1136
- ),
1137
- { entryPoint: "forgeax_vfx_scan_blocks_main", workgroups: [groups], bindings },
1138
- {
1139
- entryPoint: "forgeax_vfx_scan_block_offsets_main",
1140
- workgroups: [1],
1141
- bindings
1142
- },
1143
- { entryPoint: "forgeax_vfx_add_offsets_main", workgroups: [groups], bindings },
1144
- { entryPoint: "forgeax_vfx_compact_main", workgroups: [groups], bindings },
1145
- {
1146
- entryPoint: "forgeax_vfx_event_main",
1147
- workgroups: [Math.ceil(eventInputCapacity(intent.emitter) / 64)],
1148
- bindings
855
+ {
856
+ kind: "graphics-bindings",
857
+ name: graphicsBindings,
858
+ program: graphicsProgram,
859
+ values: {
860
+ group: 0,
861
+ runtime: projectionRuntime,
862
+ instances,
863
+ ...isBillboard ? { sceneDepthBinding: 1 } : {}
864
+ },
865
+ ...isBillboard && depthTarget !== void 0 ? { logicalTargets: { sceneDepth: depthTarget.name } } : {}
866
+ },
867
+ { kind: "vertex-data", name: vertexData, layout: vertexLayout, buffer: instances }
868
+ );
869
+ const drawBindings = [graphicsBindings];
870
+ if (particleMaterialUsesBindings(material)) {
871
+ const materialBindings = `${rendererPrefix}.material-bindings`;
872
+ resources.push({
873
+ kind: "graphics-bindings",
874
+ name: materialBindings,
875
+ program: graphicsProgram,
876
+ values: {
877
+ group: 1,
878
+ material: { world: worldIndex, guid: renderer.material }
879
+ }
880
+ });
881
+ drawBindings.push(materialBindings);
1149
882
  }
1150
- ];
1151
- });
1152
- for (const projection of state.projections) {
1153
- if (projection.kind === "billboard" && projection.sorting === "back-to-front") {
1154
- dispatches.push({
1155
- entryPoint: "forgeax_vfx_sort_main",
1156
- workgroups: [1],
1157
- bindings: projection.ring.bindings
1158
- });
1159
- }
1160
- if (projection.kind === "trail") {
1161
- dispatches.push({
1162
- entryPoint: "forgeax_vfx_trail_history_main",
1163
- workgroups: [projection.historyWorkgroups ?? 1],
1164
- bindings: projection.ring.bindings
1165
- });
1166
- }
1167
- dispatches.push({
1168
- entryPoint: projection.kind === "billboard" ? "forgeax_vfx_billboard_main" : projection.kind === "mesh" ? "forgeax_vfx_mesh_main" : `forgeax_vfx_${projection.kind}_main`,
1169
- workgroups: [projection.workgroups],
1170
- bindings: projection.ring.bindings
1171
- });
1172
- }
1173
- const passBindings = firstProjection?.ring.bindings ?? intents.map((intent) => state.rings[intent.tick % MAX_TICK_RINGS]?.bindings).find((bindings) => bindings !== void 0);
1174
- if (passBindings === void 0 || dispatches.length === 0) continue;
1175
- const computePassIdentity = `${state.names}.simulate-and-project`;
1176
- const compute = context.staging.addComputePass(computePassIdentity, {
1177
- program: refs.program,
1178
- bindings: passBindings,
1179
- dispatches
1180
- });
1181
- if (!compute.ok) return compute;
1182
- dispatchCount += dispatches.length;
1183
- for (const intent of intents) {
1184
- extracted.runtime.markEventDispatched(state.player, intent.eventCounters);
1185
- }
1186
- for (const [drawKind, passDraws] of [
1187
- ["regular", state.draws],
1188
- ["depth-sampled", state.depthSampledDraws]
1189
- ]) {
1190
- if (passDraws.length === 0) continue;
1191
- const samplesDepth = drawKind === "depth-sampled";
1192
- const draw = context.staging.addGraphicsPass(
1193
- `${state.names}.draw.${drawKind}`,
1194
- {
1195
- attachments: {
1196
- colors: [
883
+ const vertexBindings = [];
884
+ let indexData;
885
+ if (renderer.kind === "mesh") {
886
+ if (mesh === void 0) return err(planFailure());
887
+ const geometryBuffer = `${rendererPrefix}.geometry-buffer`;
888
+ const geometry = `${rendererPrefix}.geometry`;
889
+ const geometryData = canonicalMeshVertices(mesh);
890
+ resources.push(
891
+ {
892
+ kind: "buffer",
893
+ name: geometryBuffer,
894
+ size: geometryData.byteLength,
895
+ usage: ["vertex"],
896
+ data: geometryData
897
+ },
898
+ {
899
+ kind: "vertex-data",
900
+ name: geometry,
901
+ layout: vertexLayout,
902
+ buffer: geometryBuffer
903
+ }
904
+ );
905
+ vertexBindings.push(
906
+ { slot: 0, resource: geometry },
907
+ { slot: 1, resource: vertexData }
908
+ );
909
+ if (mesh.indices !== void 0) {
910
+ const indexBuffer = `${rendererPrefix}.index-buffer`;
911
+ const indices = `${rendererPrefix}.indices`;
912
+ resources.push(
1197
913
  {
1198
- resource: state.colorTarget ?? "swapchain",
1199
- format: state.colorTarget?.format ?? "rgba8unorm-srgb",
1200
- loadOp: "load",
1201
- storeOp: "store"
1202
- }
1203
- ],
1204
- ...state.depthTarget === void 0 ? {} : {
1205
- depthStencil: {
1206
- resource: state.depthTarget,
1207
- format: state.depthTarget.format,
1208
- depthLoadOp: "load",
1209
- depthStoreOp: "store"
914
+ kind: "buffer",
915
+ name: indexBuffer,
916
+ size: mesh.indices.byteLength,
917
+ usage: ["index"],
918
+ data: mesh.indices
919
+ },
920
+ {
921
+ kind: "index-data",
922
+ name: indices,
923
+ format: indexFormat,
924
+ buffer: indexBuffer
1210
925
  }
926
+ );
927
+ indexData = { resource: indices, format: indexFormat };
928
+ }
929
+ } else {
930
+ vertexBindings.push({ slot: 0, resource: vertexData });
931
+ }
932
+ passes.push({
933
+ kind: "raster",
934
+ name: `${rendererPrefix}.raster`,
935
+ colorAttachments: [
936
+ {
937
+ target: colorTarget?.name ?? "swapchain",
938
+ loadOp: "load",
939
+ storeOp: "store"
940
+ }
941
+ ],
942
+ ...depthTarget === void 0 ? {} : {
943
+ depthStencilAttachment: {
944
+ target: depthTarget.name,
945
+ depthLoadOp: "load",
946
+ depthStoreOp: "store"
1211
947
  }
1212
948
  },
1213
- ...state.depthTarget === void 0 || !samplesDepth ? {} : { sampledTargets: [state.depthTarget] },
1214
- temporalCoverage: "reactive",
1215
- draws: passDraws
1216
- },
1217
- { dependsOn: [{ featureIdentity: IDENTITY, passIdentity: computePassIdentity }] }
1218
- );
1219
- if (!draw.ok) return draw;
1220
- indirectDrawCount += passDraws.filter(
1221
- (record) => record.kind === "draw-indirect" || record.kind === "draw-indexed-indirect"
1222
- ).length;
1223
- subjectOutputCount += passDraws.length;
949
+ ...isBillboard && depthTarget !== void 0 ? { sampledTargets: [depthTarget.name] } : {},
950
+ draws: [
951
+ {
952
+ program: graphicsProgram,
953
+ bindings: drawBindings,
954
+ vertexData: vertexBindings,
955
+ ...indexData === void 0 ? {} : { indexData },
956
+ draw: {
957
+ kind: indexData === void 0 ? "draw-indirect" : "draw-indexed-indirect",
958
+ resource: indirect,
959
+ offset: rendererIndex * 20
960
+ }
961
+ }
962
+ ]
963
+ });
964
+ }
1224
965
  }
1225
966
  }
1226
967
  for (const entry of frame.worlds) {
1227
- const last = entry.intents.at(-1);
1228
- if (last !== void 0) entry.runtime.commit(last.sequence);
1229
- }
1230
- lastObservation = Object.freeze({
1231
- frameNumber: frame.frameNumber,
1232
- dispatches: dispatchCount,
1233
- indirectDraws: indirectDrawCount,
1234
- subjectOutputs: subjectOutputCount
1235
- });
1236
- return ok(void 0);
1237
- },
1238
- recover: () => {
1239
- const runtimes = /* @__PURE__ */ new Set();
1240
- for (const state of states.values()) {
1241
- if (!state.world.hasResource(VFX_GPU_RUNTIME_RESOURCE_KEY)) continue;
1242
- runtimes.add(state.world.getResource(VFX_GPU_RUNTIME_RESOURCE_KEY));
968
+ for (const intent of entry.intents) {
969
+ if (dispatchedIntents.has(intent)) {
970
+ entry.runtime.markEventDispatched(intent.player, intent.eventCounters);
971
+ }
972
+ }
973
+ const lastIntent = entry.intents.at(-1);
974
+ if (lastIntent !== void 0) entry.runtime.commit(lastIntent.sequence);
1243
975
  }
1244
- for (const runtime of runtimes) runtime.recover();
1245
- states.clear();
1246
- return ok(void 0);
1247
- },
1248
- dispose: () => {
1249
- states.clear();
1250
- lastObservation = Object.freeze({
1251
- frameNumber: -1,
1252
- dispatches: 0,
1253
- indirectDraws: 0,
1254
- subjectOutputs: 0
1255
- });
1256
- return ok(void 0);
1257
- },
1258
- inspect: () => lastObservation
976
+ return ok({ resources, passes });
977
+ }
1259
978
  };
1260
979
  }
1261
980
  function duplicate(token, providerId) {
@@ -1336,7 +1055,7 @@ function controlFailure(code, expected, hint, detail = {}) {
1336
1055
  return { code, expected, hint, detail };
1337
1056
  }
1338
1057
  function createVfxRuntimeHost(options) {
1339
- const decoderLeases = /* @__PURE__ */ new WeakMap();
1058
+ const registries = /* @__PURE__ */ new WeakSet();
1340
1059
  const worlds = /* @__PURE__ */ new WeakMap();
1341
1060
  const pausedPlayers = /* @__PURE__ */ new WeakMap();
1342
1061
  let nextGeneration = 1;
@@ -1347,14 +1066,23 @@ function createVfxRuntimeHost(options) {
1347
1066
  const key = `${kind}:${guid.toLowerCase()}`;
1348
1067
  const cached = attached.renderAssets.get(key);
1349
1068
  if (cached?.kind === kind) return cached;
1069
+ if ("lookup" in attached.assets) {
1070
+ const lookup = attached.assets.lookup;
1071
+ const legacy = lookup.call(attached.assets, guid);
1072
+ if (legacy?.kind === kind) {
1073
+ attached.renderAssets.set(key, legacy);
1074
+ return legacy;
1075
+ }
1076
+ }
1350
1077
  const resolved = attached.resolver?.lookup(guid);
1351
1078
  if (resolved?.kind === kind) {
1352
1079
  attached.renderAssets.set(key, resolved);
1353
1080
  return resolved;
1354
1081
  }
1355
- if (!attached.pendingRenderAssets.has(key)) {
1082
+ if ("load" in attached.assets && typeof attached.assets.load === "function" && !attached.pendingRenderAssets.has(key)) {
1356
1083
  const epoch = attached.catalogEpoch;
1357
- const request = attached.assets.load(guid, kind).then((result) => {
1084
+ const load = attached.assets.load;
1085
+ const request = load(guid, kind).then((result) => {
1358
1086
  if (!result.ok || worlds.get(world) !== attached || attached.catalogEpoch !== epoch)
1359
1087
  return;
1360
1088
  attached.renderAssets.set(key, result.value);
@@ -1482,15 +1210,22 @@ function createVfxRuntimeHost(options) {
1482
1210
  resolveDataInterfaces: ({ requirements, generation }) => dataInterfaces.resolve(requirements, generation),
1483
1211
  attachWorld: async ({ world, assets }) => {
1484
1212
  if (worlds.has(world)) return ok({ state: "already-attached" });
1485
- if (!decoderLeases.has(assets)) {
1213
+ if (!registries.has(assets)) {
1486
1214
  try {
1487
- decoderLeases.set(assets, installVfxRuntimeDecoder(assets));
1215
+ if ("loaders" in assets) {
1216
+ assets.loaders.registerPackLoader(vfxGpuEffectPackLoader);
1217
+ } else if ("installDecoder" in assets) {
1218
+ assets.installDecoder(vfxGpuEffectContribution.kind, vfxGpuEffectContribution.decoder);
1219
+ } else {
1220
+ throw new TypeError("VFX assets registry exposes neither loaders nor installDecoder");
1221
+ }
1222
+ registries.add(assets);
1488
1223
  } catch (cause) {
1489
1224
  return err(
1490
1225
  failure2(
1491
1226
  "vfx-host-loader-install-failed",
1492
- "the VFX owner decoder to be installed once in the runtime core",
1493
- "remove a conflicting particle-effect decoder and retry attachWorld",
1227
+ "the v2 VFX loader to be registered once",
1228
+ "remove a conflicting particle-effect loader and retry attachWorld",
1494
1229
  cause
1495
1230
  )
1496
1231
  );
@@ -1514,10 +1249,12 @@ function createVfxRuntimeHost(options) {
1514
1249
  );
1515
1250
  }
1516
1251
  let resolver;
1517
- try {
1518
- resolver = getAssetRegistryResolver(assets);
1519
- } catch {
1520
- resolver = void 0;
1252
+ if (!("loaders" in assets) && "installDecoder" in assets) {
1253
+ try {
1254
+ resolver = getAssetRegistryResolver(assets);
1255
+ } catch {
1256
+ resolver = void 0;
1257
+ }
1521
1258
  }
1522
1259
  const attached = {
1523
1260
  assets,
@@ -1526,13 +1263,12 @@ function createVfxRuntimeHost(options) {
1526
1263
  generation: nextGeneration++,
1527
1264
  renderAssets: /* @__PURE__ */ new Map(),
1528
1265
  pendingRenderAssets: /* @__PURE__ */ new Map(),
1529
- catalogEpoch: typeof assets.snapshot === "function" ? assets.snapshot().epoch : 0,
1266
+ catalogEpoch: "snapshot" in assets && typeof assets.snapshot === "function" ? assets.snapshot().epoch : 0,
1530
1267
  unsubscribeAssets: () => {
1531
1268
  }
1532
1269
  };
1533
- const subscribe = assets.subscribe;
1534
- if (subscribe !== void 0) {
1535
- attached.unsubscribeAssets = subscribe((snapshot) => {
1270
+ if ("subscribe" in assets && typeof assets.subscribe === "function") {
1271
+ attached.unsubscribeAssets = assets.subscribe((snapshot) => {
1536
1272
  if (snapshot.epoch === attached.catalogEpoch) return;
1537
1273
  attached.catalogEpoch = snapshot.epoch;
1538
1274
  attached.renderAssets.clear();