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