@forgeax/engine-vfx-render 0.1.27 → 0.1.29
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/README.md +55 -10
- package/dist/__tests__/persistent-ticks.integration.test.d.ts +2 -0
- package/dist/__tests__/persistent-ticks.integration.test.d.ts.map +1 -0
- package/dist/feature/event-resources.d.ts +10 -3
- package/dist/feature/event-resources.d.ts.map +1 -1
- package/dist/feature/gpu-particle-feature.d.ts +18 -5
- package/dist/feature/gpu-particle-feature.d.ts.map +1 -1
- package/dist/feature/particle-resources.d.ts +43 -6
- package/dist/feature/particle-resources.d.ts.map +1 -1
- package/dist/host/data-interface-providers.d.ts +4 -1
- package/dist/host/data-interface-providers.d.ts.map +1 -1
- package/dist/host/vfx-runtime-host.d.ts +21 -2
- package/dist/host/vfx-runtime-host.d.ts.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +1027 -307
- package/dist/index.mjs.map +1 -1
- package/package.json +11 -9
- package/src/__tests__/billboard-advanced.integration.test.ts +1 -1
- package/src/__tests__/data-interface-providers.unit.test.ts +4 -0
- package/src/__tests__/data-interface-public-api.test-d.ts +1 -0
- package/src/__tests__/gpu-host.integration.test.ts +1471 -59
- package/src/__tests__/particle-resources.unit.test.ts +75 -0
- package/src/__tests__/persistent-ticks.integration.test.ts +162 -0
- package/src/__tests__/render-vocabulary-owner.test-d.ts +7 -7
- package/src/feature/event-resources.ts +18 -4
- package/src/feature/gpu-particle-feature.ts +1153 -376
- package/src/feature/particle-resources.ts +223 -11
- package/src/host/data-interface-providers.ts +19 -0
- package/src/host/vfx-runtime-host.ts +82 -2
- package/src/index.ts +8 -0
- package/src/shaders/beam-inputs.wgsl +40 -0
- package/src/shaders/beam.wgsl +4 -3
- package/src/shaders/billboard-inputs.wgsl +85 -0
- package/src/shaders/mesh-inputs.wgsl +95 -0
- package/src/shaders/mesh-shadow.wgsl +18 -0
- package/src/shaders/mesh.wgsl +64 -40
- package/src/shaders/ribbon-inputs.wgsl +39 -0
- package/src/shaders/trail-inputs.wgsl +45 -0
- package/src/shaders/trail.wgsl +9 -3
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import type { EntityHandle, World } from '@forgeax/engine-ecs';
|
|
2
2
|
import { frustum } from '@forgeax/engine-math';
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
RenderFeature,
|
|
5
|
+
RenderFeaturePlan,
|
|
6
|
+
RenderFeaturePlanContext,
|
|
7
|
+
} from '@forgeax/engine-render';
|
|
4
8
|
import { GlobalTransform } from '@forgeax/engine-scene';
|
|
5
9
|
import { err, type MaterialAsset, type MeshAsset, ok } from '@forgeax/engine-types';
|
|
6
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
ParticleRendererSourceV3,
|
|
12
|
+
VfxDataInterfaceRequirement,
|
|
13
|
+
VfxDataInterfaceResource,
|
|
14
|
+
} from '@forgeax/engine-vfx';
|
|
7
15
|
import {
|
|
8
16
|
VFX_GPU_RUNTIME_RESOURCE_KEY,
|
|
17
|
+
VFX_PARTICLE_CORE_STRIDE,
|
|
18
|
+
type VfxGpuEmitterSource,
|
|
9
19
|
type VfxGpuRuntime,
|
|
10
20
|
type VfxGpuTickIntent,
|
|
11
21
|
} from '@forgeax/engine-vfx';
|
|
@@ -14,7 +24,7 @@ import { RENDER_FEATURE_VERTEX_LAYOUTS } from '../../../render/src/features/prep
|
|
|
14
24
|
import type { VfxDataInterfaceRegistry } from '../host/data-interface-providers.js';
|
|
15
25
|
import type { ParticleRenderCamera } from './camera.js';
|
|
16
26
|
import {
|
|
17
|
-
|
|
27
|
+
encodeEventBuffer,
|
|
18
28
|
eventCapacity,
|
|
19
29
|
eventInputCapacity,
|
|
20
30
|
VFX_EVENT_BYTES,
|
|
@@ -23,26 +33,28 @@ import {
|
|
|
23
33
|
import {
|
|
24
34
|
canonicalMeshVertices,
|
|
25
35
|
createTopologyResourcePlan,
|
|
36
|
+
PARTICLE_INPUT_SHADER_IDENTIFIERS,
|
|
26
37
|
PARTICLE_SHADER_IDENTIFIERS,
|
|
27
38
|
particleMaterialPass,
|
|
28
39
|
particleMaterialSceneDepthBinding,
|
|
29
40
|
particleMaterialUsesBindings,
|
|
30
41
|
particleRendererRenderState,
|
|
42
|
+
prepareParticleMaterialInputs,
|
|
31
43
|
} from './particle-resources.js';
|
|
32
44
|
import type { VfxStagePlanObservation, VfxValidatedStagePlan } from './stage-plan.js';
|
|
33
45
|
import { validatedStagePlan } from './stage-plan.js';
|
|
34
46
|
|
|
35
47
|
const IDENTITY = 'forgeax.vfx-render.gpu-particles';
|
|
36
48
|
const WORKGROUP_SIZE = 256;
|
|
37
|
-
const PARTICLE_BYTES = 80;
|
|
38
49
|
const BILLBOARD_INSTANCE_BYTES = 31 * 4;
|
|
39
|
-
const MESH_INSTANCE_BYTES =
|
|
50
|
+
const MESH_INSTANCE_BYTES = 18 * 4;
|
|
40
51
|
const COUNTERS_BYTES = 24;
|
|
41
|
-
const RUNTIME_BYTES =
|
|
52
|
+
const RUNTIME_BYTES = 76 * 4;
|
|
42
53
|
const IDENTITY_MATRIX = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
|
|
43
54
|
|
|
44
|
-
type ParticleRendererKind =
|
|
45
|
-
type
|
|
55
|
+
type ParticleRendererKind = ParticleRendererSourceV3['kind'];
|
|
56
|
+
type ParticleRenderer = ParticleRendererSourceV3;
|
|
57
|
+
type ParticleTopologyRenderer = Extract<ParticleRendererSourceV3, { readonly capacity: number }>;
|
|
46
58
|
type ParticleTopologyKind = ParticleTopologyRenderer['kind'];
|
|
47
59
|
type VfxStageOutput = VfxStagePlanObservation['stageOutput'];
|
|
48
60
|
|
|
@@ -95,7 +107,7 @@ export interface BillboardAdvancedSample {
|
|
|
95
107
|
}
|
|
96
108
|
|
|
97
109
|
export function resolveBillboardAdvancedState(
|
|
98
|
-
renderer: Extract<
|
|
110
|
+
renderer: Extract<ParticleRendererSourceV3, { readonly kind: 'billboard' }>,
|
|
99
111
|
sample: BillboardAdvancedSample,
|
|
100
112
|
) {
|
|
101
113
|
if (renderer.softParticle !== undefined && !sample.depthAvailable) {
|
|
@@ -129,7 +141,10 @@ export function resolveBillboardAdvancedState(
|
|
|
129
141
|
frameIndex,
|
|
130
142
|
pivot: renderer.pivot ?? ([0, 0] as const),
|
|
131
143
|
softParticleFade,
|
|
132
|
-
sortingKey:
|
|
144
|
+
sortingKey:
|
|
145
|
+
renderer.sorting === 'view-depth' || renderer.sorting === 'view-distance'
|
|
146
|
+
? sample.particleDepth
|
|
147
|
+
: 0,
|
|
133
148
|
});
|
|
134
149
|
}
|
|
135
150
|
|
|
@@ -161,6 +176,20 @@ interface ExtractedWorld {
|
|
|
161
176
|
readonly runtime: VfxGpuRuntime;
|
|
162
177
|
readonly camera: ParticleRenderCamera;
|
|
163
178
|
readonly intents: readonly VfxGpuTickIntent[];
|
|
179
|
+
/**
|
|
180
|
+
* Last submitted emitter state kept drawable between fixed ticks. The
|
|
181
|
+
* queue above is simulation work only; renderHz is allowed to exceed
|
|
182
|
+
* fixedHz, and a live emitter must keep its projection/raster lease warm.
|
|
183
|
+
*/
|
|
184
|
+
readonly retained: readonly RetainedEmitter[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface RetainedEmitter {
|
|
188
|
+
readonly player: VfxGpuEmitterSource['player'];
|
|
189
|
+
readonly emitter: VfxGpuEmitterSource['emitter'];
|
|
190
|
+
readonly intent: VfxGpuTickIntent;
|
|
191
|
+
readonly localToWorld: Float32Array;
|
|
192
|
+
readonly visible: boolean;
|
|
164
193
|
}
|
|
165
194
|
|
|
166
195
|
interface ExtractedFrame {
|
|
@@ -172,6 +201,22 @@ function finite(value: unknown, fallback: number): number {
|
|
|
172
201
|
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
173
202
|
}
|
|
174
203
|
|
|
204
|
+
function billboardSortingMode(renderer: ParticleRenderer | undefined): 0 | 1 | 2 | 3 | 4 | 5 {
|
|
205
|
+
if (renderer?.kind !== 'billboard') return 0;
|
|
206
|
+
switch (renderer.sorting) {
|
|
207
|
+
case 'view-depth':
|
|
208
|
+
return 2;
|
|
209
|
+
case 'view-distance':
|
|
210
|
+
return 5;
|
|
211
|
+
case 'custom-ascending':
|
|
212
|
+
return 3;
|
|
213
|
+
case 'custom-descending':
|
|
214
|
+
return 4;
|
|
215
|
+
default:
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
175
220
|
function vector(value: unknown, fallback: readonly number[], size: number): readonly number[] {
|
|
176
221
|
return Array.isArray(value)
|
|
177
222
|
? Array.from({ length: size }, (_, index) => finite(value[index], fallback[index] ?? 0))
|
|
@@ -183,8 +228,10 @@ function runtimeData(
|
|
|
183
228
|
camera: ParticleRenderCamera,
|
|
184
229
|
material: MaterialAsset | undefined,
|
|
185
230
|
localToWorld: Float32Array,
|
|
186
|
-
renderer?:
|
|
231
|
+
renderer?: ParticleRenderer,
|
|
187
232
|
rendererIndex = 0,
|
|
233
|
+
particleInputLanes = renderer?.materialInputs?.length ?? 0,
|
|
234
|
+
meshDraw?: { readonly count: number; readonly firstIndex: number },
|
|
188
235
|
): Uint8Array {
|
|
189
236
|
const storage = new ArrayBuffer(RUNTIME_BYTES);
|
|
190
237
|
const floats = new Float32Array(storage);
|
|
@@ -200,6 +247,7 @@ function runtimeData(
|
|
|
200
247
|
floats.set(camera.viewProjection, 8);
|
|
201
248
|
floats.set(camera.right, 24);
|
|
202
249
|
floats.set(camera.up, 28);
|
|
250
|
+
floats.set(camera.position, 72);
|
|
203
251
|
const values = material?.values ?? {};
|
|
204
252
|
floats.set(vector(values.baseColor, [1, 1, 1, 1], 4), 32);
|
|
205
253
|
const emissive = vector(values.emissive, [0, 0, 0], 3);
|
|
@@ -211,17 +259,19 @@ function runtimeData(
|
|
|
211
259
|
floats[43] = finite(values.clearcoatRoughness, 0.5);
|
|
212
260
|
floats.set(localToWorld, 44);
|
|
213
261
|
words[60] = rendererIndex;
|
|
214
|
-
words[61] =
|
|
262
|
+
words[61] =
|
|
263
|
+
renderer === undefined &&
|
|
264
|
+
((intent.emitter.reflection.resources ?? []).includes('eventBuffer') ||
|
|
265
|
+
(intent.emitter.reflection.entryPoints ?? []).includes('forgeax_vfx_event_main'))
|
|
266
|
+
? eventInputCapacity(intent.emitter)
|
|
267
|
+
: renderer?.kind === 'trail'
|
|
268
|
+
? renderer.historyLength
|
|
269
|
+
: (meshDraw?.count ?? 0);
|
|
215
270
|
words[62] =
|
|
216
271
|
renderer?.kind === 'ribbon' || renderer?.kind === 'trail' || renderer?.kind === 'beam'
|
|
217
272
|
? renderer.capacity
|
|
218
273
|
: intent.emitter.capacity;
|
|
219
|
-
words[63] =
|
|
220
|
-
renderer?.kind === 'billboard' && renderer.sorting === 'back-to-front'
|
|
221
|
-
? 2
|
|
222
|
-
: renderer?.kind === 'billboard' && renderer.sorting === 'emitter'
|
|
223
|
-
? 1
|
|
224
|
-
: 0;
|
|
274
|
+
words[63] = meshDraw?.firstIndex ?? billboardSortingMode(renderer);
|
|
225
275
|
floats[64] =
|
|
226
276
|
renderer?.kind === 'billboard'
|
|
227
277
|
? (renderer.pivot?.[0] ?? 0)
|
|
@@ -230,6 +280,9 @@ function runtimeData(
|
|
|
230
280
|
: 0.1;
|
|
231
281
|
floats[65] = renderer?.kind === 'billboard' ? (renderer.pivot?.[1] ?? 0) : 0;
|
|
232
282
|
floats[66] = renderer?.kind === 'billboard' ? (renderer.softParticle?.fadeDistance ?? 0) : 0;
|
|
283
|
+
// The projection shader derives its instance stride from this reflected
|
|
284
|
+
// material-input lane count; undeclared inputs therefore allocate nothing.
|
|
285
|
+
floats[67] = particleInputLanes;
|
|
233
286
|
const sheet = renderer?.kind === 'billboard' ? renderer.textureSheet : undefined;
|
|
234
287
|
floats[68] = sheet?.columns ?? 1;
|
|
235
288
|
floats[69] = sheet?.rows ?? 1;
|
|
@@ -245,11 +298,11 @@ function emitterTransform(world: World, intent: VfxGpuTickIntent): Float32Array
|
|
|
245
298
|
}
|
|
246
299
|
|
|
247
300
|
function emitterVisible(
|
|
248
|
-
|
|
301
|
+
emitter: VfxGpuTickIntent['emitter'],
|
|
249
302
|
camera: ParticleRenderCamera,
|
|
250
303
|
localToWorld: Float32Array,
|
|
251
304
|
): boolean {
|
|
252
|
-
const bounds =
|
|
305
|
+
const bounds = emitter.bounds;
|
|
253
306
|
const center =
|
|
254
307
|
bounds.kind === 'sphere'
|
|
255
308
|
? bounds.center
|
|
@@ -291,6 +344,53 @@ function requiresSceneDepth(intent: VfxGpuTickIntent): boolean {
|
|
|
291
344
|
);
|
|
292
345
|
}
|
|
293
346
|
|
|
347
|
+
function dataInterfacesExecutable(
|
|
348
|
+
intent: VfxGpuTickIntent,
|
|
349
|
+
registry: Pick<VfxDataInterfaceRegistry, 'resolve'> | undefined,
|
|
350
|
+
): boolean {
|
|
351
|
+
const requirements = intent.emitter.reflection.dataInterfaces ?? [];
|
|
352
|
+
if (requirements.length === 0) return true;
|
|
353
|
+
const resolved = registry?.resolve(requirements, intent.instanceGeneration);
|
|
354
|
+
// Render can provide its prepared fallback resources. An unavailable
|
|
355
|
+
// provider is therefore deferred in plan, while an explicit hard failure
|
|
356
|
+
// remains fail-closed and observable.
|
|
357
|
+
if (resolved === undefined) return true;
|
|
358
|
+
if (!resolved.ok) {
|
|
359
|
+
const error = resolved.error;
|
|
360
|
+
if (error === undefined) return false;
|
|
361
|
+
return (
|
|
362
|
+
error.code === 'vfx-data-interface-missing' &&
|
|
363
|
+
(error.detail.providerId === undefined || error.expected.includes('resident'))
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
// Synthetic availability-only providers used by host probes intentionally
|
|
367
|
+
// omit the payload; concrete plan resources are still checked below.
|
|
368
|
+
if (resolved.value === undefined) return true;
|
|
369
|
+
return resolved.value.resources.every((resource) => resource.resource !== undefined);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function preparedDataInterfaceResources(
|
|
373
|
+
requirements: readonly VfxDataInterfaceRequirement[],
|
|
374
|
+
generation: number,
|
|
375
|
+
prepared: RenderFeaturePlanContext['preparedDataInterfaces'],
|
|
376
|
+
): readonly VfxDataInterfaceResource[] | undefined {
|
|
377
|
+
if (prepared === undefined) return undefined;
|
|
378
|
+
const resources: VfxDataInterfaceResource[] = [];
|
|
379
|
+
for (const requirement of requirements) {
|
|
380
|
+
const value = prepared[requirement.kind];
|
|
381
|
+
if (value === undefined) return undefined;
|
|
382
|
+
resources.push({
|
|
383
|
+
token: requirement.token,
|
|
384
|
+
kind: requirement.kind,
|
|
385
|
+
bindingType: requirement.bindingType,
|
|
386
|
+
generation,
|
|
387
|
+
...(requirement.sampleCount === undefined ? {} : { sampleCount: requirement.sampleCount }),
|
|
388
|
+
resource: value,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
return resources;
|
|
392
|
+
}
|
|
393
|
+
|
|
294
394
|
function planFailure(): RenderFeatureStageFailedError {
|
|
295
395
|
return new RenderFeatureStageFailedError(IDENTITY, -1, 'plan', 'next-frame');
|
|
296
396
|
}
|
|
@@ -309,7 +409,13 @@ function computeBindingEntries(
|
|
|
309
409
|
): readonly { readonly binding: number; readonly resource: string }[] {
|
|
310
410
|
const declared = new Set(
|
|
311
411
|
(intent.emitter.reflection.bindings[0]?.entries ?? [])
|
|
312
|
-
.filter(
|
|
412
|
+
.filter(
|
|
413
|
+
(entry) =>
|
|
414
|
+
entry.buffer !== undefined ||
|
|
415
|
+
entry.texture !== undefined ||
|
|
416
|
+
entry.sampler !== undefined ||
|
|
417
|
+
entry.storageTexture !== undefined,
|
|
418
|
+
)
|
|
313
419
|
.map((entry) => entry.binding),
|
|
314
420
|
);
|
|
315
421
|
return Object.entries(resources).flatMap(([binding, resource]) =>
|
|
@@ -322,6 +428,12 @@ function simulationDispatches(
|
|
|
322
428
|
stages: VfxValidatedStagePlan,
|
|
323
429
|
): Extract<PlanPass, { readonly kind: 'compute' }>['dispatches'] {
|
|
324
430
|
const groups = Math.max(1, Math.ceil(intent.emitter.capacity / WORKGROUP_SIZE));
|
|
431
|
+
const compact: Extract<PlanPass, { readonly kind: 'compute' }>['dispatches'] = [
|
|
432
|
+
{ kind: 'direct', entryPoint: 'forgeax_vfx_scan_blocks_main', workgroups: [groups] },
|
|
433
|
+
{ kind: 'direct', entryPoint: 'forgeax_vfx_scan_block_offsets_main', workgroups: [1] },
|
|
434
|
+
{ kind: 'direct', entryPoint: 'forgeax_vfx_add_offsets_main', workgroups: [groups] },
|
|
435
|
+
{ kind: 'direct', entryPoint: 'forgeax_vfx_compact_main', workgroups: [groups] },
|
|
436
|
+
];
|
|
325
437
|
return [
|
|
326
438
|
{ kind: 'direct', entryPoint: 'forgeax_vfx_spawn_main', workgroups: [groups] },
|
|
327
439
|
{ kind: 'direct', entryPoint: 'forgeax_vfx_update_main', workgroups: [groups] },
|
|
@@ -330,22 +442,83 @@ function simulationDispatches(
|
|
|
330
442
|
entryPoint: stage.entryPoint,
|
|
331
443
|
workgroups: [groups] as const,
|
|
332
444
|
})),
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
445
|
+
...compact,
|
|
446
|
+
...(intent.emitter.reflection.entryPoints.includes('forgeax_vfx_event_main')
|
|
447
|
+
? [
|
|
448
|
+
{
|
|
449
|
+
kind: 'direct' as const,
|
|
450
|
+
entryPoint: 'forgeax_vfx_event_main',
|
|
451
|
+
workgroups: [1] as const,
|
|
452
|
+
},
|
|
453
|
+
...compact,
|
|
454
|
+
]
|
|
455
|
+
: []),
|
|
342
456
|
];
|
|
343
457
|
}
|
|
344
458
|
|
|
345
459
|
export function gpuParticleRenderFeature(
|
|
346
460
|
options: GpuParticleFeatureOptions,
|
|
347
461
|
): RenderFeature<ExtractedFrame> {
|
|
348
|
-
|
|
462
|
+
type IntentOutcomeState = 'dispatched' | 'skipped' | 'deferred';
|
|
463
|
+
interface PlannedIntent {
|
|
464
|
+
readonly intent: VfxGpuTickIntent;
|
|
465
|
+
readonly localToWorld: Float32Array;
|
|
466
|
+
readonly stagePlan: VfxValidatedStagePlan;
|
|
467
|
+
/** Camera projection is optional for `simulationWhenCulled: continue`. */
|
|
468
|
+
readonly visible: boolean;
|
|
469
|
+
/** Retained state has no fixed-tick simulation/history work this frame. */
|
|
470
|
+
readonly retained: boolean;
|
|
471
|
+
}
|
|
472
|
+
interface EmitterGroup {
|
|
473
|
+
readonly key: string;
|
|
474
|
+
readonly worldIndex: number;
|
|
475
|
+
readonly entry: ExtractedWorld;
|
|
476
|
+
readonly intents: PlannedIntent[];
|
|
477
|
+
readonly initialReset: boolean;
|
|
478
|
+
}
|
|
479
|
+
interface IntentOutcome {
|
|
480
|
+
readonly intent: VfxGpuTickIntent;
|
|
481
|
+
readonly state: IntentOutcomeState;
|
|
482
|
+
/** Stable reset epoch assigned once per intent, including retry plans. */
|
|
483
|
+
readonly resetEpoch?: number;
|
|
484
|
+
}
|
|
485
|
+
interface PendingEntry {
|
|
486
|
+
readonly world: World;
|
|
487
|
+
readonly runtime: VfxGpuRuntime;
|
|
488
|
+
readonly outcomes: readonly IntentOutcome[];
|
|
489
|
+
}
|
|
490
|
+
const pendingFrames = new Map<number, readonly PendingEntry[]>();
|
|
491
|
+
const worldIds = new WeakMap<World, number>();
|
|
492
|
+
const runtimeIds = new WeakMap<VfxGpuRuntime, number>();
|
|
493
|
+
const resetEpochByEmitter = new WeakMap<VfxGpuRuntime, Map<string, number>>();
|
|
494
|
+
// Sequences are local to a VfxGpuRuntime. Scope reset reservations by the
|
|
495
|
+
// runtime instance so detach/reattach on the same World cannot borrow old
|
|
496
|
+
// epochs. Reservations survive an aborted frame, but are only promoted to
|
|
497
|
+
// the emitter's committed epoch on submission.
|
|
498
|
+
const resetEpochByIntent = new WeakMap<
|
|
499
|
+
VfxGpuRuntime,
|
|
500
|
+
Map<
|
|
501
|
+
number,
|
|
502
|
+
{ readonly generation: number; readonly emitterKey: string; readonly epoch: number }
|
|
503
|
+
>
|
|
504
|
+
>();
|
|
505
|
+
let nextWorldId = 0;
|
|
506
|
+
let nextRuntimeId = 0;
|
|
507
|
+
const worldId = (world: World): number => {
|
|
508
|
+
const prior = worldIds.get(world);
|
|
509
|
+
if (prior !== undefined) return prior;
|
|
510
|
+
const assigned = nextWorldId++;
|
|
511
|
+
worldIds.set(world, assigned);
|
|
512
|
+
return assigned;
|
|
513
|
+
};
|
|
514
|
+
const runtimeId = (runtime: VfxGpuRuntime): number => {
|
|
515
|
+
const prior = runtimeIds.get(runtime);
|
|
516
|
+
if (prior !== undefined) return prior;
|
|
517
|
+
const assigned = nextRuntimeId++;
|
|
518
|
+
runtimeIds.set(runtime, assigned);
|
|
519
|
+
return assigned;
|
|
520
|
+
};
|
|
521
|
+
const feature: RenderFeature<ExtractedFrame> = {
|
|
349
522
|
identity: IDENTITY,
|
|
350
523
|
requiredCapabilities: ['compute', 'indirectDrawing'],
|
|
351
524
|
// Generated emitter programs are first-use assets. They must hand a
|
|
@@ -353,438 +526,1000 @@ export function gpuParticleRenderFeature(
|
|
|
353
526
|
// diagnostic-only getCompilationInfo() round trip; pipeline creation still
|
|
354
527
|
// validates the module before the pass is submitted.
|
|
355
528
|
shaderModuleMode: 'immediate',
|
|
356
|
-
requiredMaterialShaders: Object.
|
|
529
|
+
requiredMaterialShaders: Object.freeze([
|
|
530
|
+
...Object.values(PARTICLE_SHADER_IDENTIFIERS),
|
|
531
|
+
...Object.values(PARTICLE_INPUT_SHADER_IDENTIFIERS),
|
|
532
|
+
]),
|
|
357
533
|
extract: (context) => {
|
|
534
|
+
// A rejected recovery candidate can outlive the frame that produced it.
|
|
535
|
+
// Keep the producer-owned submission ledger bounded even when a host
|
|
536
|
+
// disappears before it can call onFrameAborted.
|
|
537
|
+
for (const frameNumber of pendingFrames.keys()) {
|
|
538
|
+
if (frameNumber + 4 < context.frameNumber) pendingFrames.delete(frameNumber);
|
|
539
|
+
}
|
|
358
540
|
const extracted: ExtractedWorld[] = [];
|
|
359
541
|
for (const world of context.worlds) {
|
|
360
542
|
if (!world.hasResource(VFX_GPU_RUNTIME_RESOURCE_KEY)) continue;
|
|
361
543
|
const camera = options.camera.read(world);
|
|
362
544
|
if (camera === undefined) continue;
|
|
363
545
|
const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
546
|
+
const retained: RetainedEmitter[] = [];
|
|
547
|
+
// Refresh frustum state from live emitter sources even when a paused
|
|
548
|
+
// or restart-on-visible emitter has no queued tick. Otherwise a
|
|
549
|
+
// camera move back into its bounds can never produce the next intent.
|
|
550
|
+
runtime.forEachEmitterSource(({ player, emitter }) => {
|
|
551
|
+
const sourceIntent = runtime.lastCommittedEmitter(player, emitter.id);
|
|
552
|
+
const localToWorld =
|
|
553
|
+
sourceIntent === undefined
|
|
554
|
+
? emitter.space === 'world'
|
|
555
|
+
? IDENTITY_MATRIX
|
|
556
|
+
: (() => {
|
|
557
|
+
const transform = world.get(player, GlobalTransform);
|
|
558
|
+
return transform.ok ? transform.value.world : IDENTITY_MATRIX;
|
|
559
|
+
})()
|
|
560
|
+
: emitterTransform(world, sourceIntent);
|
|
561
|
+
const visible = emitterVisible(emitter, camera, localToWorld);
|
|
562
|
+
runtime.setEmitterCameraVisibility(player, emitter.id, visible);
|
|
563
|
+
const intent = runtime.lastCommittedEmitter(player, emitter.id);
|
|
564
|
+
if (intent !== undefined) {
|
|
565
|
+
retained.push({ player, emitter, intent, localToWorld, visible });
|
|
566
|
+
}
|
|
371
567
|
});
|
|
372
|
-
|
|
568
|
+
// Keep every queued intent in the extracted frame. Plan assigns each
|
|
569
|
+
// intent an explicit terminal state (dispatched/skipped/deferred), so a
|
|
570
|
+
// filtered head can never block an acknowledged tail or be silently
|
|
571
|
+
// removed by a later global commit.
|
|
572
|
+
const intents = runtime.snapshot();
|
|
573
|
+
extracted.push({ world, runtime, camera, intents, retained });
|
|
373
574
|
}
|
|
374
575
|
return ok({ worlds: extracted, frameNumber: context.frameNumber });
|
|
375
576
|
},
|
|
376
577
|
plan: (frame, context) => {
|
|
377
578
|
const resources: PlanResource[] = [];
|
|
378
579
|
const passes: PlanPass[] = [];
|
|
379
|
-
const dispatchedIntents = new Set<VfxGpuTickIntent>();
|
|
380
580
|
const colorTarget =
|
|
381
581
|
context.targets.find((candidate) => candidate.kind === 'color') ??
|
|
382
582
|
context.targets.find((candidate) => candidate.kind === 'swapchain');
|
|
383
583
|
const depthTarget = context.targets.find((candidate) => candidate.kind === 'depth');
|
|
584
|
+
const groups = new Map<string, EmitterGroup>();
|
|
585
|
+
const outcomesByEntry = new Map<ExtractedWorld, IntentOutcome[]>();
|
|
384
586
|
|
|
587
|
+
// First classify every queued intent. A deferred intent is an ordered
|
|
588
|
+
// barrier: later intents in the same runtime cannot be dispatched until
|
|
589
|
+
// the provider/target is ready. Session masks and paused consumption
|
|
590
|
+
// skip ordinary ticks, but a reset remains deferred so a new authored
|
|
591
|
+
// timeline cannot be mixed into the previous GPU state.
|
|
592
|
+
// The key is the stable player/emitter identity plus a reset epoch; queued
|
|
593
|
+
// ticks in one epoch deliberately share their particle/counter/history
|
|
594
|
+
// storage.
|
|
385
595
|
for (const [worldIndex, entry] of frame.worlds.entries()) {
|
|
386
|
-
|
|
387
|
-
|
|
596
|
+
const outcomes: IntentOutcome[] = [];
|
|
597
|
+
outcomesByEntry.set(entry, outcomes);
|
|
598
|
+
const effectiveEpochByEmitter = new Map<string, number>();
|
|
599
|
+
const renderGeneration = entry.runtime.renderGeneration ?? 0;
|
|
600
|
+
const attachmentId = runtimeId(entry.runtime);
|
|
601
|
+
let emitterEpochs = resetEpochByEmitter.get(entry.runtime);
|
|
602
|
+
if (emitterEpochs === undefined) {
|
|
603
|
+
emitterEpochs = new Map();
|
|
604
|
+
resetEpochByEmitter.set(entry.runtime, emitterEpochs);
|
|
605
|
+
}
|
|
606
|
+
let reservations = resetEpochByIntent.get(entry.runtime);
|
|
607
|
+
if (reservations === undefined) {
|
|
608
|
+
reservations = new Map();
|
|
609
|
+
resetEpochByIntent.set(entry.runtime, reservations);
|
|
610
|
+
}
|
|
611
|
+
const liveResetSequences = new Set(
|
|
612
|
+
(entry.runtime.snapshot?.() ?? entry.intents)
|
|
613
|
+
.filter((intent) => intent.reset)
|
|
614
|
+
.map((intent) => intent.sequence),
|
|
615
|
+
);
|
|
616
|
+
for (const [sequence, reservation] of reservations) {
|
|
617
|
+
if (reservation.generation !== renderGeneration || !liveResetSequences.has(sequence)) {
|
|
618
|
+
reservations.delete(sequence);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
// Ordering barriers belong to one player/emitter stream. A paused
|
|
622
|
+
// reset or unavailable provider must not head-of-line block unrelated
|
|
623
|
+
// players that share the same VfxGpuRuntime/World.
|
|
624
|
+
const blockedEmitters = new Set<string>();
|
|
625
|
+
for (const intent of entry.intents) {
|
|
626
|
+
const baseKey =
|
|
627
|
+
`${worldId(entry.world)}:${renderGeneration}:` +
|
|
628
|
+
`${Number(intent.player)}:${intent.emitter.id}`;
|
|
629
|
+
let priorReservedEpoch: number | undefined;
|
|
630
|
+
let priorReservedSequence = -1;
|
|
631
|
+
let exactReservation:
|
|
632
|
+
| { readonly generation: number; readonly emitterKey: string; readonly epoch: number }
|
|
633
|
+
| undefined;
|
|
634
|
+
for (const [sequence, reservation] of reservations) {
|
|
635
|
+
if (reservation.generation === renderGeneration && reservation.emitterKey === baseKey) {
|
|
636
|
+
if (sequence === intent.sequence) {
|
|
637
|
+
exactReservation = reservation;
|
|
638
|
+
} else if (sequence < intent.sequence && sequence > priorReservedSequence) {
|
|
639
|
+
priorReservedEpoch = reservation.epoch;
|
|
640
|
+
priorReservedSequence = sequence;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
const committedEpoch =
|
|
645
|
+
effectiveEpochByEmitter.get(baseKey) ??
|
|
646
|
+
Math.max(emitterEpochs.get(baseKey) ?? 0, priorReservedEpoch ?? 0);
|
|
647
|
+
let resetEpoch = committedEpoch;
|
|
648
|
+
if (intent.reset) {
|
|
649
|
+
resetEpoch =
|
|
650
|
+
exactReservation?.generation === renderGeneration &&
|
|
651
|
+
exactReservation.emitterKey === baseKey
|
|
652
|
+
? exactReservation.epoch
|
|
653
|
+
: committedEpoch + 1;
|
|
654
|
+
reservations.set(intent.sequence, {
|
|
655
|
+
generation: renderGeneration,
|
|
656
|
+
emitterKey: baseKey,
|
|
657
|
+
epoch: resetEpoch,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
if (blockedEmitters.has(baseKey)) {
|
|
661
|
+
outcomes.push({
|
|
662
|
+
intent,
|
|
663
|
+
state: 'deferred',
|
|
664
|
+
...(intent.reset ? { resetEpoch } : {}),
|
|
665
|
+
});
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (
|
|
669
|
+
options.playerConsumption?.isEnabled(entry.world, intent.player) === false ||
|
|
670
|
+
!entry.runtime.isEmitterSessionEnabled(intent.player, intent.emitter.id)
|
|
671
|
+
) {
|
|
672
|
+
const state = intent.reset ? ('deferred' as const) : ('skipped' as const);
|
|
673
|
+
outcomes.push({
|
|
674
|
+
intent,
|
|
675
|
+
state,
|
|
676
|
+
...(intent.reset ? { resetEpoch } : {}),
|
|
677
|
+
});
|
|
678
|
+
if (intent.reset) blockedEmitters.add(baseKey);
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
388
681
|
const localToWorld = emitterTransform(entry.world, intent);
|
|
389
|
-
const visible = emitterVisible(intent, entry.camera, localToWorld);
|
|
682
|
+
const visible = emitterVisible(intent.emitter, entry.camera, localToWorld);
|
|
390
683
|
entry.runtime.setEmitterCameraVisibility(intent.player, intent.emitter.id, visible);
|
|
391
|
-
if (!visible)
|
|
392
|
-
|
|
393
|
-
|
|
684
|
+
if (!visible && intent.emitter.simulationWhenCulled !== 'continue') {
|
|
685
|
+
const state = intent.reset ? ('deferred' as const) : ('skipped' as const);
|
|
686
|
+
outcomes.push({
|
|
687
|
+
intent,
|
|
688
|
+
state,
|
|
689
|
+
...(intent.reset ? { resetEpoch } : {}),
|
|
690
|
+
});
|
|
691
|
+
if (intent.reset) blockedEmitters.add(baseKey);
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
if (
|
|
695
|
+
visible &&
|
|
696
|
+
requiresSceneDepth(intent) &&
|
|
697
|
+
(depthTarget === undefined || depthTarget.sampleCount !== 1)
|
|
698
|
+
) {
|
|
699
|
+
outcomes.push({
|
|
700
|
+
intent,
|
|
701
|
+
state: 'deferred',
|
|
702
|
+
...(intent.reset ? { resetEpoch } : {}),
|
|
703
|
+
});
|
|
704
|
+
blockedEmitters.add(baseKey);
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
const requirements = intent.emitter.reflection.dataInterfaces ?? [];
|
|
708
|
+
if (
|
|
709
|
+
requirements.length > 0 &&
|
|
710
|
+
!dataInterfacesExecutable(intent, options.dataInterfaces)
|
|
711
|
+
) {
|
|
712
|
+
outcomes.push({
|
|
713
|
+
intent,
|
|
714
|
+
state: 'deferred',
|
|
715
|
+
...(intent.reset ? { resetEpoch } : {}),
|
|
716
|
+
});
|
|
717
|
+
blockedEmitters.add(baseKey);
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
394
720
|
const stagePlan = validatedStagePlan(
|
|
395
721
|
intent.emitter.reflection.stages,
|
|
396
722
|
intent.instanceGeneration,
|
|
397
723
|
);
|
|
398
724
|
if (!stagePlan.ok) return err(planFailure());
|
|
725
|
+
const entryPoints = new Set(intent.emitter.reflection.entryPoints);
|
|
726
|
+
const dispatches = simulationDispatches(intent, stagePlan.value).filter((dispatch) =>
|
|
727
|
+
entryPoints.has(dispatch.entryPoint),
|
|
728
|
+
);
|
|
729
|
+
if (dispatches.length === 0 && intent.emitter.renderers.length === 0) {
|
|
730
|
+
outcomes.push({
|
|
731
|
+
intent,
|
|
732
|
+
state: 'skipped',
|
|
733
|
+
...(intent.reset ? { resetEpoch } : {}),
|
|
734
|
+
});
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
effectiveEpochByEmitter.set(baseKey, resetEpoch);
|
|
738
|
+
const key =
|
|
739
|
+
`vfx.w-${planName(worldId(entry.world).toString(36), 8)}.` +
|
|
740
|
+
`a-${planName(attachmentId.toString(36), 8)}.` +
|
|
741
|
+
`r-${entry.runtime.renderGeneration}.p-${planName(Number(intent.player).toString(36), 16)}.` +
|
|
742
|
+
`${planName(intent.emitter.id)}.g-${resetEpoch}`;
|
|
743
|
+
let group = groups.get(key);
|
|
744
|
+
if (group === undefined) {
|
|
745
|
+
group = {
|
|
746
|
+
key,
|
|
747
|
+
worldIndex,
|
|
748
|
+
entry,
|
|
749
|
+
intents: [],
|
|
750
|
+
// Reset data is a per-frame boundary, not a property of the
|
|
751
|
+
// epoch. Once a reset-created group is warm, later ticks in
|
|
752
|
+
// the same epoch must retain its particle state.
|
|
753
|
+
initialReset: intent.reset,
|
|
754
|
+
};
|
|
755
|
+
groups.set(key, group);
|
|
756
|
+
} else if (
|
|
757
|
+
group.entry !== entry ||
|
|
758
|
+
group.intents[0]?.intent.emitter.wgsl !== intent.emitter.wgsl ||
|
|
759
|
+
group.intents[0]?.intent.emitter.capacity !== intent.emitter.capacity
|
|
760
|
+
) {
|
|
761
|
+
return err(planFailure());
|
|
762
|
+
}
|
|
763
|
+
group.intents.push({
|
|
764
|
+
intent,
|
|
765
|
+
localToWorld,
|
|
766
|
+
stagePlan: stagePlan.value,
|
|
767
|
+
visible,
|
|
768
|
+
retained: false,
|
|
769
|
+
});
|
|
770
|
+
outcomes.push({
|
|
771
|
+
intent,
|
|
772
|
+
state: dispatches.length > 0 ? 'dispatched' : 'skipped',
|
|
773
|
+
...(intent.reset ? { resetEpoch } : {}),
|
|
774
|
+
});
|
|
775
|
+
if (dispatches.length === 0) effectiveEpochByEmitter.delete(baseKey);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
399
778
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
const
|
|
415
|
-
|
|
779
|
+
// The fixed-tick queue is intentionally empty on most render frames.
|
|
780
|
+
// Keep the last submitted emitter state as a render-only group so its
|
|
781
|
+
// persistent particle/history buffers, projection and raster passes stay
|
|
782
|
+
// alive until the player is reset. A queued intent admitted above owns
|
|
783
|
+
// the frame; deferred/skipped work falls back to this retained lease.
|
|
784
|
+
for (const [worldIndex, entry] of frame.worlds.entries()) {
|
|
785
|
+
const renderGeneration = entry.runtime.renderGeneration ?? 0;
|
|
786
|
+
const attachmentId = runtimeId(entry.runtime);
|
|
787
|
+
let emitterEpochs = resetEpochByEmitter.get(entry.runtime);
|
|
788
|
+
if (emitterEpochs === undefined) {
|
|
789
|
+
emitterEpochs = new Map();
|
|
790
|
+
resetEpochByEmitter.set(entry.runtime, emitterEpochs);
|
|
791
|
+
}
|
|
792
|
+
for (const retained of entry.retained ?? []) {
|
|
793
|
+
const hasAdmittedGroup = [...groups.values()].some(
|
|
794
|
+
(group) =>
|
|
795
|
+
group.worldIndex === worldIndex &&
|
|
796
|
+
group.entry === entry &&
|
|
797
|
+
group.intents.some(
|
|
798
|
+
(planned) =>
|
|
799
|
+
planned.intent.player === retained.player &&
|
|
800
|
+
planned.intent.emitter.id === retained.emitter.id,
|
|
801
|
+
),
|
|
802
|
+
);
|
|
803
|
+
if (hasAdmittedGroup) continue;
|
|
804
|
+
const stagePlan = validatedStagePlan(
|
|
805
|
+
retained.intent.emitter.reflection.stages,
|
|
806
|
+
retained.intent.instanceGeneration,
|
|
416
807
|
);
|
|
417
|
-
|
|
808
|
+
if (!stagePlan.ok) return err(planFailure());
|
|
809
|
+
const baseKey =
|
|
810
|
+
`${worldId(entry.world)}:${renderGeneration}:` +
|
|
811
|
+
`${Number(retained.player)}:${retained.emitter.id}`;
|
|
812
|
+
const resetEpoch = emitterEpochs.get(baseKey) ?? 0;
|
|
813
|
+
const key =
|
|
814
|
+
`vfx.w-${planName(worldId(entry.world).toString(36), 8)}.` +
|
|
815
|
+
`a-${planName(attachmentId.toString(36), 8)}.` +
|
|
816
|
+
`r-${renderGeneration}.p-${planName(Number(retained.player).toString(36), 16)}.` +
|
|
817
|
+
`${planName(retained.emitter.id)}.g-${resetEpoch}`;
|
|
818
|
+
const visible =
|
|
819
|
+
retained.visible &&
|
|
820
|
+
(options.playerConsumption?.isEnabled(entry.world, retained.player) ?? true) &&
|
|
821
|
+
entry.runtime.isEmitterSessionEnabled(retained.player, retained.emitter.id);
|
|
822
|
+
groups.set(key, {
|
|
823
|
+
key,
|
|
824
|
+
worldIndex,
|
|
825
|
+
entry,
|
|
826
|
+
intents: [
|
|
827
|
+
{
|
|
828
|
+
intent: retained.intent,
|
|
829
|
+
localToWorld: retained.localToWorld,
|
|
830
|
+
stagePlan: stagePlan.value,
|
|
831
|
+
visible,
|
|
832
|
+
retained: true,
|
|
833
|
+
},
|
|
834
|
+
],
|
|
835
|
+
initialReset: false,
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
}
|
|
418
839
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
840
|
+
for (const group of groups.values()) {
|
|
841
|
+
const first = group.intents[0];
|
|
842
|
+
if (first === undefined) continue;
|
|
843
|
+
const firstIntent = first.intent;
|
|
844
|
+
const prefix = group.key;
|
|
845
|
+
const program = `${prefix}.compute-program`;
|
|
846
|
+
const particles = `${prefix}.particles`;
|
|
847
|
+
const aliveIndices = `${prefix}.alive-indices`;
|
|
848
|
+
const counters = `${prefix}.counters`;
|
|
849
|
+
const indirect = `${prefix}.indirect`;
|
|
850
|
+
const scratch = `${prefix}.scratch`;
|
|
851
|
+
const sharedInstances = `${prefix}.shared-instances`;
|
|
852
|
+
// Binding 8 is the cooked event ABI: input and output records share one
|
|
853
|
+
// storage buffer. Keep the stable projection name for bind-group reuse.
|
|
854
|
+
const projectionEventInputs = `${prefix}.projection-event-inputs`;
|
|
855
|
+
const capacity = firstIntent.emitter.capacity;
|
|
856
|
+
const renderers = firstIntent.emitter.renderers;
|
|
857
|
+
const layout = firstIntent.emitter.reflection.layout;
|
|
858
|
+
const parameterBytes = layout?.parameters.size ?? 0;
|
|
859
|
+
const customStride = layout?.customLayout?.stride ?? 0;
|
|
860
|
+
const hasEvents =
|
|
861
|
+
(firstIntent.emitter.reflection.resources ?? []).includes('eventBuffer') ||
|
|
862
|
+
(firstIntent.emitter.reflection.entryPoints ?? []).includes('forgeax_vfx_event_main') ||
|
|
863
|
+
(firstIntent.emitter.reflection.bindings[0]?.entries ?? []).some(
|
|
864
|
+
(entry) => entry.binding === 8,
|
|
865
|
+
);
|
|
866
|
+
const diBindings: Record<number, string> = {};
|
|
867
|
+
const diResources: PlanResource[] = [];
|
|
868
|
+
const reflectedBindings = new Set(
|
|
869
|
+
firstIntent.emitter.reflection.bindings.flatMap((group) =>
|
|
870
|
+
group.entries.map((entry) => entry.binding),
|
|
871
|
+
),
|
|
872
|
+
);
|
|
873
|
+
const requirements = (firstIntent.emitter.reflection.dataInterfaces ?? []).filter(
|
|
874
|
+
(requirement) => reflectedBindings.has(requirement.binding),
|
|
875
|
+
);
|
|
876
|
+
if (requirements.length > 0) {
|
|
877
|
+
let resolved = options.dataInterfaces?.resolve(
|
|
878
|
+
requirements,
|
|
879
|
+
firstIntent.instanceGeneration,
|
|
880
|
+
);
|
|
881
|
+
if (
|
|
882
|
+
(resolved === undefined ||
|
|
883
|
+
(!resolved.ok &&
|
|
884
|
+
resolved.error.code === 'vfx-data-interface-missing' &&
|
|
885
|
+
(resolved.error.detail.providerId === undefined ||
|
|
886
|
+
resolved.error.expected.includes('resident')))) &&
|
|
887
|
+
context.preparedDataInterfaces !== undefined
|
|
888
|
+
) {
|
|
889
|
+
const fallback = preparedDataInterfaceResources(
|
|
890
|
+
requirements,
|
|
891
|
+
firstIntent.instanceGeneration,
|
|
892
|
+
context.preparedDataInterfaces,
|
|
893
|
+
);
|
|
894
|
+
if (fallback !== undefined) {
|
|
895
|
+
resolved = ok({
|
|
896
|
+
generation: firstIntent.instanceGeneration,
|
|
897
|
+
readiness: 'ready' as const,
|
|
898
|
+
resources: fallback,
|
|
899
|
+
});
|
|
431
900
|
}
|
|
432
|
-
indirectWords[rendererIndex * 5] =
|
|
433
|
-
renderer.kind === 'mesh'
|
|
434
|
-
? mesh?.indices === undefined
|
|
435
|
-
? (submesh?.vertexCount ?? 0)
|
|
436
|
-
: (submesh?.indexCount ?? 0)
|
|
437
|
-
: 6;
|
|
438
|
-
indirectWords[rendererIndex * 5 + 2] =
|
|
439
|
-
renderer.kind === 'mesh' && mesh?.indices !== undefined
|
|
440
|
-
? (submesh?.indexOffset ?? 0)
|
|
441
|
-
: 0;
|
|
442
901
|
}
|
|
902
|
+
if (resolved === undefined || !resolved.ok) return err(planFailure());
|
|
903
|
+
for (const requirement of requirements) {
|
|
904
|
+
const resource = resolved.value.resources.find(
|
|
905
|
+
(candidate) => candidate.token === requirement.token,
|
|
906
|
+
);
|
|
907
|
+
const prepared = resource?.resource;
|
|
908
|
+
if (resource === undefined || prepared === undefined) return err(planFailure());
|
|
909
|
+
const name = `${prefix}.di-${planName(requirement.kind)}`;
|
|
910
|
+
if (prepared.kind === 'buffer') {
|
|
911
|
+
if (prepared.size === undefined || prepared.size <= 0) return err(planFailure());
|
|
912
|
+
diResources.push({
|
|
913
|
+
kind: 'prepared-gpu-resource',
|
|
914
|
+
name,
|
|
915
|
+
resource: {
|
|
916
|
+
kind: 'buffer',
|
|
917
|
+
value: prepared.value,
|
|
918
|
+
size: prepared.size,
|
|
919
|
+
usage: prepared.usage === 'storage' ? ['storage'] : ['uniform'],
|
|
920
|
+
},
|
|
921
|
+
});
|
|
922
|
+
} else {
|
|
923
|
+
const external =
|
|
924
|
+
prepared.kind === 'texture-view'
|
|
925
|
+
? { kind: 'texture-view' as const, value: prepared.value }
|
|
926
|
+
: { kind: 'sampler' as const, value: prepared.value };
|
|
927
|
+
diResources.push({
|
|
928
|
+
kind: 'prepared-gpu-resource',
|
|
929
|
+
name,
|
|
930
|
+
resource: external,
|
|
931
|
+
...(requirement.kind === 'scene-depth' && depthTarget !== undefined
|
|
932
|
+
? { logicalTarget: depthTarget.name }
|
|
933
|
+
: {}),
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
diBindings[requirement.binding] = name;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
const meshes = renderers.map((renderer) =>
|
|
940
|
+
renderer.kind === 'mesh'
|
|
941
|
+
? options.mesh?.read(group.entry.world, renderer.mesh)
|
|
942
|
+
: undefined,
|
|
943
|
+
);
|
|
944
|
+
const indirectWords = new Uint32Array(Math.max(1, renderers.length) * 5);
|
|
945
|
+
for (const [rendererIndex, renderer] of renderers.entries()) {
|
|
946
|
+
const mesh = meshes[rendererIndex];
|
|
947
|
+
const submesh =
|
|
948
|
+
renderer.kind === 'mesh' ? mesh?.submeshes[renderer.submesh ?? 0] : undefined;
|
|
949
|
+
if (renderer.kind === 'mesh' && submesh === undefined) return err(planFailure());
|
|
950
|
+
if (
|
|
951
|
+
(renderer.kind === 'ribbon' || renderer.kind === 'trail' || renderer.kind === 'beam') &&
|
|
952
|
+
!createTopologyResourcePlan(renderer).ok
|
|
953
|
+
) {
|
|
954
|
+
return err(planFailure());
|
|
955
|
+
}
|
|
956
|
+
indirectWords[rendererIndex * 5] =
|
|
957
|
+
renderer.kind === 'mesh'
|
|
958
|
+
? mesh?.indices === undefined
|
|
959
|
+
? (submesh?.vertexCount ?? 0)
|
|
960
|
+
: (submesh?.indexCount ?? 0)
|
|
961
|
+
: 6;
|
|
962
|
+
indirectWords[rendererIndex * 5 + 2] =
|
|
963
|
+
renderer.kind === 'mesh' && mesh?.indices !== undefined
|
|
964
|
+
? (submesh?.indexOffset ?? 0)
|
|
965
|
+
: 0;
|
|
966
|
+
}
|
|
967
|
+
const scratchBytes = (capacity * 2 + Math.ceil(capacity / WORKGROUP_SIZE)) * 4;
|
|
968
|
+
const eventBufferBytes = Math.max(
|
|
969
|
+
4,
|
|
970
|
+
eventInputCapacity(firstIntent.emitter) * VFX_EVENT_INPUT_BYTES +
|
|
971
|
+
eventCapacity(firstIntent.emitter) * VFX_EVENT_BYTES,
|
|
972
|
+
);
|
|
973
|
+
const particleBytes = VFX_PARTICLE_CORE_STRIDE;
|
|
974
|
+
const maxParticleInputBytes = Math.max(
|
|
975
|
+
0,
|
|
976
|
+
...renderers.map((renderer) => (renderer.materialInputs?.length ?? 0) * 16),
|
|
977
|
+
);
|
|
978
|
+
resources.push(
|
|
979
|
+
...diResources,
|
|
980
|
+
{
|
|
981
|
+
kind: 'compute-program',
|
|
982
|
+
name: program,
|
|
983
|
+
program: {
|
|
984
|
+
wgsl: firstIntent.emitter.wgsl,
|
|
985
|
+
entryPoints: firstIntent.emitter.reflection.entryPoints,
|
|
986
|
+
bindings: firstIntent.emitter.reflection.bindings,
|
|
987
|
+
},
|
|
988
|
+
},
|
|
989
|
+
{
|
|
990
|
+
kind: 'buffer',
|
|
991
|
+
name: particles,
|
|
992
|
+
size: capacity * particleBytes,
|
|
993
|
+
usage: ['storage'],
|
|
994
|
+
...(group.initialReset ? { data: resetData(capacity * particleBytes) } : {}),
|
|
995
|
+
},
|
|
996
|
+
...(parameterBytes === 0
|
|
997
|
+
? []
|
|
998
|
+
: [
|
|
999
|
+
{
|
|
1000
|
+
kind: 'buffer' as const,
|
|
1001
|
+
name: `${prefix}.parameters`,
|
|
1002
|
+
size: parameterBytes,
|
|
1003
|
+
usage: ['uniform'] as const,
|
|
1004
|
+
data: firstIntent.parameterBlock,
|
|
1005
|
+
},
|
|
1006
|
+
]),
|
|
1007
|
+
...(customStride === 0
|
|
1008
|
+
? []
|
|
1009
|
+
: [
|
|
1010
|
+
{
|
|
1011
|
+
kind: 'buffer' as const,
|
|
1012
|
+
name: `${prefix}.custom`,
|
|
1013
|
+
size: capacity * customStride,
|
|
1014
|
+
usage: ['storage'] as const,
|
|
1015
|
+
...(group.initialReset ? { data: resetData(capacity * customStride) } : {}),
|
|
1016
|
+
},
|
|
1017
|
+
]),
|
|
1018
|
+
{ kind: 'buffer', name: aliveIndices, size: capacity * 4, usage: ['storage'] },
|
|
1019
|
+
{
|
|
1020
|
+
kind: 'buffer',
|
|
1021
|
+
name: counters,
|
|
1022
|
+
size: COUNTERS_BYTES,
|
|
1023
|
+
usage: ['storage'],
|
|
1024
|
+
...(group.initialReset ? { data: resetData(COUNTERS_BYTES) } : {}),
|
|
1025
|
+
},
|
|
1026
|
+
{
|
|
1027
|
+
kind: 'buffer',
|
|
1028
|
+
name: indirect,
|
|
1029
|
+
size: indirectWords.byteLength,
|
|
1030
|
+
usage: ['storage', 'indirect'],
|
|
1031
|
+
...(group.initialReset ? { data: indirectWords } : {}),
|
|
1032
|
+
},
|
|
1033
|
+
{
|
|
1034
|
+
kind: 'buffer',
|
|
1035
|
+
name: scratch,
|
|
1036
|
+
size: scratchBytes,
|
|
1037
|
+
usage: ['storage'],
|
|
1038
|
+
...(group.initialReset ? { data: resetData(scratchBytes) } : {}),
|
|
1039
|
+
},
|
|
1040
|
+
{
|
|
1041
|
+
kind: 'buffer',
|
|
1042
|
+
name: sharedInstances,
|
|
1043
|
+
size:
|
|
1044
|
+
capacity *
|
|
1045
|
+
(Math.max(BILLBOARD_INSTANCE_BYTES, MESH_INSTANCE_BYTES) + maxParticleInputBytes),
|
|
1046
|
+
usage: ['storage', 'vertex'],
|
|
1047
|
+
},
|
|
1048
|
+
...(hasEvents
|
|
1049
|
+
? [
|
|
1050
|
+
{
|
|
1051
|
+
kind: 'buffer' as const,
|
|
1052
|
+
name: projectionEventInputs,
|
|
1053
|
+
size: eventBufferBytes,
|
|
1054
|
+
usage: ['storage'] as const,
|
|
1055
|
+
data: encodeEventBuffer(firstIntent),
|
|
1056
|
+
},
|
|
1057
|
+
]
|
|
1058
|
+
: []),
|
|
1059
|
+
);
|
|
1060
|
+
const entryPoints = new Set(firstIntent.emitter.reflection.entryPoints);
|
|
443
1061
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
1062
|
+
// Each fixed tick gets only its own immutable runtime/event input and
|
|
1063
|
+
// bindings. The particle/counter/scratch/event/history resources are
|
|
1064
|
+
// shared, so dispatch order advances one emitter state rather than
|
|
1065
|
+
// simulating independent q-0/q-1 copies.
|
|
1066
|
+
for (const [tickIndex, planned] of group.intents.entries()) {
|
|
1067
|
+
if (planned.retained) continue;
|
|
1068
|
+
const tickPrefix = `${prefix}.tick-${tickIndex}`;
|
|
1069
|
+
const tickRuntime = `${tickPrefix}.runtime`;
|
|
1070
|
+
const tickEventInputs = `${tickPrefix}.event-inputs`;
|
|
1071
|
+
const tickBindings = `${tickPrefix}.simulation-bindings`;
|
|
450
1072
|
resources.push(
|
|
451
|
-
{
|
|
452
|
-
kind: 'compute-program',
|
|
453
|
-
name: program,
|
|
454
|
-
program: {
|
|
455
|
-
wgsl: intent.emitter.wgsl,
|
|
456
|
-
entryPoints: intent.emitter.reflection.entryPoints,
|
|
457
|
-
bindings: intent.emitter.reflection.bindings,
|
|
458
|
-
},
|
|
459
|
-
},
|
|
460
1073
|
{
|
|
461
1074
|
kind: 'buffer',
|
|
462
|
-
name:
|
|
463
|
-
size: capacity * PARTICLE_BYTES,
|
|
464
|
-
usage: ['storage'],
|
|
465
|
-
...(intent.reset ? { data: resetData(capacity * PARTICLE_BYTES) } : {}),
|
|
466
|
-
},
|
|
467
|
-
{ kind: 'buffer', name: aliveIndices, size: capacity * 4, usage: ['storage'] },
|
|
468
|
-
{
|
|
469
|
-
kind: 'buffer',
|
|
470
|
-
name: counters,
|
|
471
|
-
size: COUNTERS_BYTES,
|
|
472
|
-
usage: ['storage'],
|
|
473
|
-
...(intent.reset ? { data: resetData(COUNTERS_BYTES) } : {}),
|
|
474
|
-
},
|
|
475
|
-
{
|
|
476
|
-
kind: 'buffer',
|
|
477
|
-
name: indirect,
|
|
478
|
-
size: indirectWords.byteLength,
|
|
479
|
-
usage: ['storage', 'indirect'],
|
|
480
|
-
data: indirectWords,
|
|
481
|
-
},
|
|
482
|
-
{
|
|
483
|
-
kind: 'buffer',
|
|
484
|
-
name: scratch,
|
|
485
|
-
size: scratchBytes,
|
|
486
|
-
usage: ['storage'],
|
|
487
|
-
...(intent.reset ? { data: resetData(scratchBytes) } : {}),
|
|
488
|
-
},
|
|
489
|
-
{
|
|
490
|
-
kind: 'buffer',
|
|
491
|
-
name: sharedInstances,
|
|
492
|
-
size: capacity * Math.max(BILLBOARD_INSTANCE_BYTES, MESH_INSTANCE_BYTES),
|
|
493
|
-
usage: ['storage', 'vertex'],
|
|
494
|
-
},
|
|
495
|
-
{
|
|
496
|
-
kind: 'buffer',
|
|
497
|
-
name: eventInputs,
|
|
498
|
-
size: eventInputBytes,
|
|
499
|
-
usage: ['storage'],
|
|
500
|
-
data: encodeEventInputs(intent),
|
|
501
|
-
},
|
|
502
|
-
{
|
|
503
|
-
kind: 'buffer',
|
|
504
|
-
name: events,
|
|
505
|
-
size: eventBytes,
|
|
506
|
-
usage: ['storage'],
|
|
507
|
-
...(intent.reset ? { data: resetData(eventBytes) } : {}),
|
|
508
|
-
},
|
|
509
|
-
{
|
|
510
|
-
kind: 'buffer',
|
|
511
|
-
name: runtime,
|
|
1075
|
+
name: tickRuntime,
|
|
512
1076
|
size: RUNTIME_BYTES,
|
|
513
1077
|
usage: ['uniform'],
|
|
514
|
-
data: runtimeData(
|
|
1078
|
+
data: runtimeData(
|
|
1079
|
+
planned.intent,
|
|
1080
|
+
group.entry.camera,
|
|
1081
|
+
undefined,
|
|
1082
|
+
planned.localToWorld,
|
|
1083
|
+
),
|
|
515
1084
|
},
|
|
1085
|
+
...(hasEvents
|
|
1086
|
+
? [
|
|
1087
|
+
{
|
|
1088
|
+
kind: 'buffer' as const,
|
|
1089
|
+
name: tickEventInputs,
|
|
1090
|
+
size: eventBufferBytes,
|
|
1091
|
+
usage: ['storage'] as const,
|
|
1092
|
+
data: encodeEventBuffer(planned.intent),
|
|
1093
|
+
},
|
|
1094
|
+
]
|
|
1095
|
+
: []),
|
|
1096
|
+
...(parameterBytes === 0
|
|
1097
|
+
? []
|
|
1098
|
+
: [
|
|
1099
|
+
{
|
|
1100
|
+
kind: 'buffer' as const,
|
|
1101
|
+
name: `${tickPrefix}.parameters`,
|
|
1102
|
+
size: parameterBytes,
|
|
1103
|
+
usage: ['uniform'] as const,
|
|
1104
|
+
data: planned.intent.parameterBlock,
|
|
1105
|
+
},
|
|
1106
|
+
]),
|
|
516
1107
|
{
|
|
517
1108
|
kind: 'compute-bindings',
|
|
518
|
-
name:
|
|
1109
|
+
name: tickBindings,
|
|
519
1110
|
program,
|
|
520
|
-
entries: computeBindingEntries(intent, {
|
|
1111
|
+
entries: computeBindingEntries(planned.intent, {
|
|
521
1112
|
0: particles,
|
|
522
|
-
1:
|
|
1113
|
+
1: tickRuntime,
|
|
523
1114
|
2: aliveIndices,
|
|
524
1115
|
3: counters,
|
|
525
1116
|
4: indirect,
|
|
526
1117
|
5: scratch,
|
|
527
1118
|
6: sharedInstances,
|
|
528
|
-
8:
|
|
529
|
-
|
|
1119
|
+
...(hasEvents ? { 8: tickEventInputs } : {}),
|
|
1120
|
+
...(parameterBytes === 0 ? {} : { 10: `${tickPrefix}.parameters` }),
|
|
1121
|
+
...(customStride === 0 ? {} : { 11: `${prefix}.custom` }),
|
|
1122
|
+
...diBindings,
|
|
530
1123
|
}),
|
|
531
1124
|
},
|
|
532
1125
|
);
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
const dispatches = simulationDispatches(intent, stagePlan.value).filter((dispatch) =>
|
|
536
|
-
entryPoints.has(dispatch.entryPoint),
|
|
1126
|
+
const dispatches = simulationDispatches(planned.intent, planned.stagePlan).filter(
|
|
1127
|
+
(dispatch) => entryPoints.has(dispatch.entryPoint),
|
|
537
1128
|
);
|
|
538
|
-
if (dispatches.length
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
dispatchedIntents.add(intent);
|
|
547
|
-
}
|
|
1129
|
+
if (dispatches.length === 0) continue;
|
|
1130
|
+
passes.push({
|
|
1131
|
+
kind: 'compute',
|
|
1132
|
+
name: `${tickPrefix}.simulate`,
|
|
1133
|
+
program,
|
|
1134
|
+
bindings: tickBindings,
|
|
1135
|
+
dispatches,
|
|
1136
|
+
});
|
|
548
1137
|
|
|
1138
|
+
// Trail history is part of the fixed-step simulation state. It must
|
|
1139
|
+
// be written immediately after each tick, before the next tick can
|
|
1140
|
+
// mutate particles; projecting history once after the whole batch
|
|
1141
|
+
// would overwrite only the latest ring slot.
|
|
549
1142
|
for (const [rendererIndex, renderer] of renderers.entries()) {
|
|
1143
|
+
if (renderer.enabled === false) continue;
|
|
1144
|
+
if (renderer.kind !== 'trail') continue;
|
|
1145
|
+
if (!entryPoints.has('forgeax_vfx_trail_history_main')) continue;
|
|
550
1146
|
const rendererPrefix = `${prefix}.renderer-${rendererIndex}`;
|
|
551
|
-
const isBillboard = renderer.kind === 'billboard';
|
|
552
|
-
const isTopology =
|
|
553
|
-
renderer.kind === 'ribbon' || renderer.kind === 'trail' || renderer.kind === 'beam';
|
|
554
|
-
const topologyPlan = isTopology ? createTopologyResourcePlan(renderer) : undefined;
|
|
555
|
-
if (topologyPlan !== undefined && !topologyPlan.ok) return err(planFailure());
|
|
556
|
-
const material = options.material?.read(entry.world, renderer.material);
|
|
557
|
-
const materialPass = particleMaterialPass(renderer.kind, material);
|
|
558
|
-
const mesh = meshes[rendererIndex];
|
|
559
|
-
const submesh =
|
|
560
|
-
renderer.kind === 'mesh' ? mesh?.submeshes[renderer.submesh ?? 0] : undefined;
|
|
561
|
-
const indexFormat = mesh?.indices instanceof Uint32Array ? 'uint32' : 'uint16';
|
|
562
|
-
const sceneDepthBinding = isBillboard
|
|
563
|
-
? particleMaterialSceneDepthBinding(
|
|
564
|
-
context.materialShaderBindingContract?.(materialPass.shader) ??
|
|
565
|
-
(materialPass.shader === PARTICLE_SHADER_IDENTIFIERS.billboard
|
|
566
|
-
? 'view-and-scene-depth'
|
|
567
|
-
: undefined),
|
|
568
|
-
)
|
|
569
|
-
: undefined;
|
|
570
|
-
const instances = `${rendererPrefix}.instances`;
|
|
571
1147
|
const history = `${rendererPrefix}.history`;
|
|
572
|
-
const
|
|
573
|
-
const
|
|
574
|
-
const vertexLayout = isBillboard
|
|
575
|
-
? RENDER_FEATURE_VERTEX_LAYOUTS.billboardMaterialInstance
|
|
576
|
-
: isTopology
|
|
577
|
-
? RENDER_FEATURE_VERTEX_LAYOUTS.topologySegmentInstance
|
|
578
|
-
: RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInstance;
|
|
579
|
-
const instanceBytes = isTopology
|
|
580
|
-
? (topologyPlan?.value.vertexBytes ?? 16)
|
|
581
|
-
: capacity * (isBillboard ? BILLBOARD_INSTANCE_BYTES : MESH_INSTANCE_BYTES);
|
|
582
|
-
const historyBytes =
|
|
583
|
-
renderer.kind === 'trail'
|
|
584
|
-
? Math.max(16, renderer.capacity * renderer.historyLength * 16)
|
|
585
|
-
: 16;
|
|
586
|
-
|
|
1148
|
+
const historyRuntime = `${tickPrefix}.renderer-${rendererIndex}.history-runtime`;
|
|
1149
|
+
const historyBindings = `${tickPrefix}.renderer-${rendererIndex}.history-bindings`;
|
|
587
1150
|
resources.push(
|
|
588
1151
|
{
|
|
589
1152
|
kind: 'buffer',
|
|
590
|
-
name:
|
|
591
|
-
size: instanceBytes,
|
|
592
|
-
usage: ['storage', 'vertex'],
|
|
593
|
-
},
|
|
594
|
-
{
|
|
595
|
-
kind: 'buffer',
|
|
596
|
-
name: history,
|
|
597
|
-
size: historyBytes,
|
|
598
|
-
usage: ['storage'],
|
|
599
|
-
...(intent.reset ? { data: resetData(historyBytes) } : {}),
|
|
600
|
-
},
|
|
601
|
-
{
|
|
602
|
-
kind: 'buffer',
|
|
603
|
-
name: projectionRuntime,
|
|
1153
|
+
name: historyRuntime,
|
|
604
1154
|
size: RUNTIME_BYTES,
|
|
605
1155
|
usage: ['uniform'],
|
|
606
1156
|
data: runtimeData(
|
|
607
|
-
|
|
608
|
-
entry.camera,
|
|
609
|
-
|
|
610
|
-
localToWorld,
|
|
1157
|
+
planned.intent,
|
|
1158
|
+
group.entry.camera,
|
|
1159
|
+
undefined,
|
|
1160
|
+
planned.localToWorld,
|
|
611
1161
|
renderer,
|
|
612
1162
|
rendererIndex,
|
|
613
1163
|
),
|
|
614
1164
|
},
|
|
615
1165
|
{
|
|
616
1166
|
kind: 'compute-bindings',
|
|
617
|
-
name:
|
|
1167
|
+
name: historyBindings,
|
|
618
1168
|
program,
|
|
619
|
-
entries: computeBindingEntries(intent, {
|
|
1169
|
+
entries: computeBindingEntries(planned.intent, {
|
|
620
1170
|
0: particles,
|
|
621
|
-
1:
|
|
1171
|
+
1: historyRuntime,
|
|
622
1172
|
2: aliveIndices,
|
|
623
1173
|
3: counters,
|
|
624
1174
|
4: indirect,
|
|
625
1175
|
5: history,
|
|
626
|
-
6:
|
|
627
|
-
8:
|
|
628
|
-
|
|
1176
|
+
6: sharedInstances,
|
|
1177
|
+
...(hasEvents ? { 8: tickEventInputs } : {}),
|
|
1178
|
+
...(parameterBytes === 0 ? {} : { 10: `${tickPrefix}.parameters` }),
|
|
1179
|
+
...(customStride === 0 ? {} : { 11: `${prefix}.custom` }),
|
|
1180
|
+
...diBindings,
|
|
629
1181
|
}),
|
|
630
1182
|
},
|
|
631
1183
|
);
|
|
1184
|
+
passes.push({
|
|
1185
|
+
kind: 'compute',
|
|
1186
|
+
name: `${historyBindings}.write`,
|
|
1187
|
+
program,
|
|
1188
|
+
bindings: historyBindings,
|
|
1189
|
+
dispatches: [
|
|
1190
|
+
{
|
|
1191
|
+
kind: 'direct',
|
|
1192
|
+
entryPoint: 'forgeax_vfx_trail_history_main',
|
|
1193
|
+
workgroups: [Math.max(1, Math.ceil(renderer.capacity / WORKGROUP_SIZE))],
|
|
1194
|
+
},
|
|
1195
|
+
],
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
632
1199
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
1200
|
+
const latest = group.intents.at(-1) ?? first;
|
|
1201
|
+
for (const [rendererIndex, renderer] of renderers.entries()) {
|
|
1202
|
+
// Disabled entries remain in the cooked renderer index space but do
|
|
1203
|
+
// not acquire projection, graphics, or shadow resources.
|
|
1204
|
+
if (renderer.enabled === false) continue;
|
|
1205
|
+
const rendererPrefix = `${prefix}.renderer-${rendererIndex}`;
|
|
1206
|
+
const isBillboard = renderer.kind === 'billboard';
|
|
1207
|
+
const isTopology =
|
|
1208
|
+
renderer.kind === 'ribbon' || renderer.kind === 'trail' || renderer.kind === 'beam';
|
|
1209
|
+
const topologyPlan = isTopology ? createTopologyResourcePlan(renderer) : undefined;
|
|
1210
|
+
if (topologyPlan !== undefined && !topologyPlan.ok) return err(planFailure());
|
|
1211
|
+
const material = options.material?.read(group.entry.world, renderer.material);
|
|
1212
|
+
const reflectedRenderer = firstIntent.emitter.reflection.renderers?.[rendererIndex];
|
|
1213
|
+
const preparedInputs = prepareParticleMaterialInputs(
|
|
1214
|
+
renderer,
|
|
1215
|
+
material,
|
|
1216
|
+
reflectedRenderer?.materialInputDefinitions,
|
|
1217
|
+
);
|
|
1218
|
+
if (!preparedInputs.ok) return err(planFailure());
|
|
1219
|
+
const hasParticleInputs = preparedInputs.value.lanes > 0;
|
|
1220
|
+
const materialPass = particleMaterialPass(renderer.kind, material, hasParticleInputs);
|
|
1221
|
+
const mesh = meshes[rendererIndex];
|
|
1222
|
+
const submesh =
|
|
1223
|
+
renderer.kind === 'mesh' ? mesh?.submeshes[renderer.submesh ?? 0] : undefined;
|
|
1224
|
+
const indexFormat = mesh?.indices instanceof Uint32Array ? 'uint32' : 'uint16';
|
|
1225
|
+
const sceneDepthBinding = particleMaterialSceneDepthBinding(
|
|
1226
|
+
context.materialShaderBindingContract?.(materialPass.shader) ??
|
|
1227
|
+
(materialPass.shader === PARTICLE_SHADER_IDENTIFIERS.billboard
|
|
1228
|
+
? 'view-and-scene-depth'
|
|
1229
|
+
: undefined),
|
|
1230
|
+
);
|
|
1231
|
+
const instances = `${rendererPrefix}.instances`;
|
|
1232
|
+
const history = `${rendererPrefix}.history`;
|
|
1233
|
+
const projectionRuntime = `${rendererPrefix}.runtime`;
|
|
1234
|
+
const projectionBindings = `${rendererPrefix}.compute-bindings`;
|
|
1235
|
+
const vertexLayout = isBillboard
|
|
1236
|
+
? hasParticleInputs
|
|
1237
|
+
? RENDER_FEATURE_VERTEX_LAYOUTS.billboardMaterialInputInstance
|
|
1238
|
+
: RENDER_FEATURE_VERTEX_LAYOUTS.billboardMaterialInstance
|
|
1239
|
+
: isTopology
|
|
1240
|
+
? hasParticleInputs
|
|
1241
|
+
? RENDER_FEATURE_VERTEX_LAYOUTS.topologySegmentMaterialInputInstance
|
|
1242
|
+
: RENDER_FEATURE_VERTEX_LAYOUTS.topologySegmentInstance
|
|
1243
|
+
: hasParticleInputs
|
|
1244
|
+
? RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInputInstance
|
|
1245
|
+
: RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInstance;
|
|
1246
|
+
const inputBytes = preparedInputs.value.stride;
|
|
1247
|
+
const instanceBytes = isTopology
|
|
1248
|
+
? (() => {
|
|
1249
|
+
const vertexCount = Math.max(
|
|
1250
|
+
1,
|
|
1251
|
+
Math.floor((topologyPlan?.value.vertexBytes ?? 16) / 48),
|
|
1252
|
+
);
|
|
1253
|
+
return vertexCount * (48 + inputBytes);
|
|
1254
|
+
})()
|
|
1255
|
+
: capacity *
|
|
1256
|
+
((isBillboard ? BILLBOARD_INSTANCE_BYTES : MESH_INSTANCE_BYTES) + inputBytes);
|
|
1257
|
+
const historyBytes =
|
|
1258
|
+
renderer.kind === 'trail'
|
|
1259
|
+
? capacity * (Math.max(2, renderer.historyLength) + 1) * 16
|
|
1260
|
+
: 16;
|
|
1261
|
+
resources.push(
|
|
1262
|
+
{
|
|
1263
|
+
kind: 'buffer',
|
|
1264
|
+
name: instances,
|
|
1265
|
+
size: instanceBytes,
|
|
1266
|
+
usage: ['storage', 'vertex'],
|
|
1267
|
+
},
|
|
1268
|
+
{
|
|
1269
|
+
kind: 'buffer',
|
|
1270
|
+
name: history,
|
|
1271
|
+
size: historyBytes,
|
|
1272
|
+
usage: ['storage'],
|
|
1273
|
+
...(group.initialReset ? { data: resetData(historyBytes) } : {}),
|
|
1274
|
+
},
|
|
1275
|
+
);
|
|
1276
|
+
// `simulationWhenCulled: continue` still owns persistent simulation
|
|
1277
|
+
// and (for trails) history writes, but it must not project or draw
|
|
1278
|
+
// hidden state. Keep the two persistent storage declarations above;
|
|
1279
|
+
// the remaining projection/raster resources are draw-only.
|
|
1280
|
+
const castsShadow = renderer.kind === 'mesh' && renderer.castShadows;
|
|
1281
|
+
if (!latest.visible && !castsShadow) continue;
|
|
1282
|
+
resources.push(
|
|
1283
|
+
{
|
|
1284
|
+
kind: 'buffer',
|
|
1285
|
+
name: projectionRuntime,
|
|
1286
|
+
size: RUNTIME_BYTES,
|
|
1287
|
+
usage: ['uniform'],
|
|
1288
|
+
data: runtimeData(
|
|
1289
|
+
{ ...latest.intent, fixedDelta: 0, spawnCount: 0 },
|
|
1290
|
+
group.entry.camera,
|
|
1291
|
+
material,
|
|
1292
|
+
latest.localToWorld,
|
|
1293
|
+
renderer,
|
|
1294
|
+
rendererIndex,
|
|
1295
|
+
preparedInputs.value.lanes,
|
|
1296
|
+
renderer.kind === 'mesh' && submesh !== undefined
|
|
1297
|
+
? {
|
|
1298
|
+
count: mesh?.indices === undefined ? submesh.vertexCount : submesh.indexCount,
|
|
1299
|
+
firstIndex: mesh?.indices === undefined ? 0 : submesh.indexOffset,
|
|
1300
|
+
}
|
|
1301
|
+
: undefined,
|
|
1302
|
+
),
|
|
1303
|
+
},
|
|
1304
|
+
{
|
|
1305
|
+
kind: 'compute-bindings',
|
|
1306
|
+
name: projectionBindings,
|
|
1307
|
+
program,
|
|
1308
|
+
entries: computeBindingEntries(latest.intent, {
|
|
1309
|
+
0: particles,
|
|
1310
|
+
1: projectionRuntime,
|
|
1311
|
+
2: aliveIndices,
|
|
1312
|
+
3: counters,
|
|
1313
|
+
4: indirect,
|
|
1314
|
+
5: history,
|
|
1315
|
+
6: instances,
|
|
1316
|
+
...(hasEvents ? { 8: projectionEventInputs } : {}),
|
|
1317
|
+
...(parameterBytes === 0 ? {} : { 10: `${prefix}.parameters` }),
|
|
1318
|
+
...(customStride === 0 ? {} : { 11: `${prefix}.custom` }),
|
|
1319
|
+
...diBindings,
|
|
1320
|
+
}),
|
|
1321
|
+
},
|
|
1322
|
+
);
|
|
1323
|
+
const projectionDispatches: Extract<
|
|
1324
|
+
PlanPass,
|
|
1325
|
+
{ readonly kind: 'compute' }
|
|
1326
|
+
>['dispatches'][number][] = [];
|
|
1327
|
+
const pushProjection = (entryPoint: string, workgroups: number): void => {
|
|
1328
|
+
if (!entryPoints.has(entryPoint)) return;
|
|
1329
|
+
projectionDispatches.push({
|
|
1330
|
+
kind: 'direct',
|
|
1331
|
+
entryPoint,
|
|
1332
|
+
workgroups: [Math.max(1, workgroups)],
|
|
1333
|
+
});
|
|
1334
|
+
};
|
|
1335
|
+
if (
|
|
1336
|
+
isBillboard &&
|
|
1337
|
+
(renderer.sorting === 'view-depth' ||
|
|
1338
|
+
renderer.sorting === 'view-distance' ||
|
|
1339
|
+
renderer.sorting === 'custom-ascending' ||
|
|
1340
|
+
renderer.sorting === 'custom-descending')
|
|
1341
|
+
) {
|
|
1342
|
+
pushProjection('forgeax_vfx_sort_main', 1);
|
|
1343
|
+
}
|
|
1344
|
+
if (renderer.kind === 'trail') pushProjection('forgeax_vfx_trail_offsets_main', 1);
|
|
1345
|
+
const projectionCount =
|
|
1346
|
+
renderer.kind === 'trail'
|
|
1347
|
+
? renderer.capacity * Math.max(1, renderer.historyLength - 1)
|
|
1348
|
+
: isTopology
|
|
1349
|
+
? renderer.capacity
|
|
1350
|
+
: capacity;
|
|
1351
|
+
pushProjection(
|
|
1352
|
+
renderer.kind === 'billboard'
|
|
1353
|
+
? 'forgeax_vfx_billboard_main'
|
|
1354
|
+
: renderer.kind === 'mesh'
|
|
1355
|
+
? 'forgeax_vfx_mesh_main'
|
|
1356
|
+
: `forgeax_vfx_${renderer.kind}_main`,
|
|
1357
|
+
Math.ceil(projectionCount / WORKGROUP_SIZE),
|
|
1358
|
+
);
|
|
1359
|
+
if (projectionDispatches.length > 0) {
|
|
1360
|
+
passes.push({
|
|
1361
|
+
kind: 'compute',
|
|
1362
|
+
name: `${rendererPrefix}.project`,
|
|
1363
|
+
program,
|
|
1364
|
+
bindings: projectionBindings,
|
|
1365
|
+
dispatches: projectionDispatches,
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
677
1368
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
1369
|
+
const graphicsProgram = `${rendererPrefix}.graphics-program`;
|
|
1370
|
+
const graphicsBindings = `${rendererPrefix}.graphics-bindings`;
|
|
1371
|
+
const vertexData = `${rendererPrefix}.vertex-data`;
|
|
1372
|
+
const defaultRenderState = particleRendererRenderState(
|
|
1373
|
+
renderer.kind,
|
|
1374
|
+
renderer.kind === 'billboard' ? renderer.blend : undefined,
|
|
1375
|
+
materialPass.renderState,
|
|
1376
|
+
);
|
|
1377
|
+
const renderState =
|
|
1378
|
+
isBillboard && depthTarget !== undefined
|
|
1379
|
+
? { ...(defaultRenderState ?? {}), depthWriteEnabled: false }
|
|
1380
|
+
: defaultRenderState;
|
|
1381
|
+
resources.push(
|
|
1382
|
+
{
|
|
1383
|
+
kind: 'graphics-program',
|
|
1384
|
+
name: graphicsProgram,
|
|
1385
|
+
program: {
|
|
1386
|
+
shader: materialPass.shader,
|
|
1387
|
+
vertexLayout,
|
|
1388
|
+
...(hasParticleInputs ? { particleInputLanes: preparedInputs.value.lanes } : {}),
|
|
1389
|
+
colorFormats: [colorTarget?.format ?? 'rgba8unorm-srgb'],
|
|
1390
|
+
...(depthTarget === undefined ? {} : { depthFormat: depthTarget.format }),
|
|
1391
|
+
sampleCount: colorTarget?.sampleCount ?? 1,
|
|
1392
|
+
topology: submesh?.topology ?? 'triangle-list',
|
|
1393
|
+
...(mesh?.indices === undefined ? {} : { indexFormat }),
|
|
1394
|
+
...(renderState === undefined ? {} : { renderState }),
|
|
1395
|
+
},
|
|
1396
|
+
},
|
|
1397
|
+
{
|
|
1398
|
+
kind: 'graphics-bindings',
|
|
1399
|
+
name: graphicsBindings,
|
|
1400
|
+
program: graphicsProgram,
|
|
1401
|
+
values: {
|
|
1402
|
+
group: 0,
|
|
1403
|
+
runtime: projectionRuntime,
|
|
1404
|
+
instances,
|
|
1405
|
+
...(sceneDepthBinding === undefined ? {} : { sceneDepthBinding }),
|
|
1406
|
+
},
|
|
1407
|
+
...(sceneDepthBinding !== undefined && depthTarget !== undefined
|
|
1408
|
+
? { logicalTargets: { sceneDepth: depthTarget.name } }
|
|
1409
|
+
: {}),
|
|
1410
|
+
},
|
|
1411
|
+
{ kind: 'vertex-data', name: vertexData, layout: vertexLayout, buffer: instances },
|
|
1412
|
+
);
|
|
1413
|
+
const drawBindings = [graphicsBindings];
|
|
1414
|
+
if (
|
|
1415
|
+
particleMaterialUsesBindings(material) ||
|
|
1416
|
+
materialPass.shader === PARTICLE_SHADER_IDENTIFIERS.mesh ||
|
|
1417
|
+
materialPass.shader === PARTICLE_INPUT_SHADER_IDENTIFIERS.mesh
|
|
1418
|
+
) {
|
|
1419
|
+
const materialBindings = `${rendererPrefix}.material-bindings.w-${group.worldIndex}`;
|
|
1420
|
+
resources.push({
|
|
1421
|
+
kind: 'graphics-bindings',
|
|
1422
|
+
name: materialBindings,
|
|
1423
|
+
program: graphicsProgram,
|
|
1424
|
+
values: {
|
|
1425
|
+
group: 1,
|
|
1426
|
+
material: { world: group.worldIndex, guid: renderer.material },
|
|
1427
|
+
},
|
|
1428
|
+
});
|
|
1429
|
+
drawBindings.push(materialBindings);
|
|
1430
|
+
}
|
|
1431
|
+
const vertexBindings: { readonly slot: number; readonly resource: string }[] = [];
|
|
1432
|
+
let indexData:
|
|
1433
|
+
| { readonly resource: string; readonly format: 'uint16' | 'uint32' }
|
|
1434
|
+
| undefined;
|
|
1435
|
+
if (renderer.kind === 'mesh') {
|
|
1436
|
+
if (mesh === undefined) return err(planFailure());
|
|
1437
|
+
const geometryBuffer = `${rendererPrefix}.geometry-buffer`;
|
|
1438
|
+
const geometry = `${rendererPrefix}.geometry`;
|
|
1439
|
+
const geometryData = canonicalMeshVertices(mesh);
|
|
1440
|
+
resources.push(
|
|
1441
|
+
{
|
|
1442
|
+
kind: 'buffer',
|
|
1443
|
+
name: geometryBuffer,
|
|
1444
|
+
size: geometryData.byteLength,
|
|
1445
|
+
usage: ['vertex'],
|
|
1446
|
+
data: geometryData,
|
|
1447
|
+
},
|
|
1448
|
+
{
|
|
1449
|
+
kind: 'vertex-data',
|
|
1450
|
+
name: geometry,
|
|
1451
|
+
layout: vertexLayout,
|
|
1452
|
+
buffer: geometryBuffer,
|
|
1453
|
+
},
|
|
685
1454
|
);
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
1455
|
+
vertexBindings.push({ slot: 0, resource: geometry }, { slot: 1, resource: vertexData });
|
|
1456
|
+
if (mesh.indices !== undefined) {
|
|
1457
|
+
const indexBuffer = `${rendererPrefix}.index-buffer`;
|
|
1458
|
+
const indices = `${rendererPrefix}.indices`;
|
|
1459
|
+
resources.push(
|
|
1460
|
+
{
|
|
1461
|
+
kind: 'buffer',
|
|
1462
|
+
name: indexBuffer,
|
|
1463
|
+
size: mesh.indices.byteLength,
|
|
1464
|
+
usage: ['index'],
|
|
1465
|
+
data: mesh.indices,
|
|
1466
|
+
},
|
|
1467
|
+
{
|
|
1468
|
+
kind: 'index-data',
|
|
1469
|
+
name: indices,
|
|
1470
|
+
format: indexFormat,
|
|
1471
|
+
buffer: indexBuffer,
|
|
1472
|
+
},
|
|
1473
|
+
);
|
|
1474
|
+
indexData = { resource: indices, format: indexFormat };
|
|
1475
|
+
}
|
|
1476
|
+
} else {
|
|
1477
|
+
vertexBindings.push({ slot: 0, resource: vertexData });
|
|
1478
|
+
}
|
|
1479
|
+
if (castsShadow) {
|
|
1480
|
+
const shadowProgram = `${rendererPrefix}.shadow-program`;
|
|
1481
|
+
const shadowBindings = `${rendererPrefix}.shadow-bindings`;
|
|
690
1482
|
resources.push(
|
|
691
1483
|
{
|
|
692
1484
|
kind: 'graphics-program',
|
|
693
|
-
name:
|
|
1485
|
+
name: shadowProgram,
|
|
694
1486
|
program: {
|
|
695
|
-
shader:
|
|
1487
|
+
shader: 'forgeax::vfx-render.particles.mesh-shadow',
|
|
696
1488
|
vertexLayout,
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
1489
|
+
...(hasParticleInputs ? { particleInputLanes: preparedInputs.value.lanes } : {}),
|
|
1490
|
+
colorFormats: [],
|
|
1491
|
+
depthFormat: 'depth32float',
|
|
700
1492
|
topology: submesh?.topology ?? 'triangle-list',
|
|
701
1493
|
...(mesh?.indices === undefined ? {} : { indexFormat }),
|
|
702
|
-
|
|
1494
|
+
renderState: { depthWriteEnabled: true, cullMode: 'none' },
|
|
703
1495
|
},
|
|
704
1496
|
},
|
|
705
1497
|
{
|
|
706
1498
|
kind: 'graphics-bindings',
|
|
707
|
-
name:
|
|
708
|
-
program:
|
|
709
|
-
values: {
|
|
710
|
-
group: 0,
|
|
711
|
-
runtime: projectionRuntime,
|
|
712
|
-
instances,
|
|
713
|
-
...(sceneDepthBinding === undefined ? {} : { sceneDepthBinding }),
|
|
714
|
-
},
|
|
715
|
-
...(sceneDepthBinding !== undefined && depthTarget !== undefined
|
|
716
|
-
? { logicalTargets: { sceneDepth: depthTarget.name } }
|
|
717
|
-
: {}),
|
|
1499
|
+
name: shadowBindings,
|
|
1500
|
+
program: shadowProgram,
|
|
1501
|
+
values: { group: 0, runtime: projectionRuntime, instances },
|
|
718
1502
|
},
|
|
719
|
-
{ kind: 'vertex-data', name: vertexData, layout: vertexLayout, buffer: instances },
|
|
720
1503
|
);
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
kind: 'graphics-bindings',
|
|
726
|
-
name: materialBindings,
|
|
727
|
-
program: graphicsProgram,
|
|
728
|
-
values: {
|
|
729
|
-
group: 1,
|
|
730
|
-
material: { world: worldIndex, guid: renderer.material },
|
|
731
|
-
},
|
|
732
|
-
});
|
|
733
|
-
drawBindings.push(materialBindings);
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
const vertexBindings: { readonly slot: number; readonly resource: string }[] = [];
|
|
737
|
-
let indexData:
|
|
738
|
-
| { readonly resource: string; readonly format: 'uint16' | 'uint32' }
|
|
739
|
-
| undefined;
|
|
740
|
-
if (renderer.kind === 'mesh') {
|
|
741
|
-
if (mesh === undefined) return err(planFailure());
|
|
742
|
-
const geometryBuffer = `${rendererPrefix}.geometry-buffer`;
|
|
743
|
-
const geometry = `${rendererPrefix}.geometry`;
|
|
744
|
-
const geometryData = canonicalMeshVertices(mesh);
|
|
745
|
-
resources.push(
|
|
746
|
-
{
|
|
747
|
-
kind: 'buffer',
|
|
748
|
-
name: geometryBuffer,
|
|
749
|
-
size: geometryData.byteLength,
|
|
750
|
-
usage: ['vertex'],
|
|
751
|
-
data: geometryData,
|
|
752
|
-
},
|
|
1504
|
+
passes.push({
|
|
1505
|
+
kind: 'shadow-caster',
|
|
1506
|
+
name: `${rendererPrefix}.shadow`,
|
|
1507
|
+
draws: [
|
|
753
1508
|
{
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
{ slot: 1, resource: vertexData },
|
|
763
|
-
);
|
|
764
|
-
if (mesh.indices !== undefined) {
|
|
765
|
-
const indexBuffer = `${rendererPrefix}.index-buffer`;
|
|
766
|
-
const indices = `${rendererPrefix}.indices`;
|
|
767
|
-
resources.push(
|
|
768
|
-
{
|
|
769
|
-
kind: 'buffer',
|
|
770
|
-
name: indexBuffer,
|
|
771
|
-
size: mesh.indices.byteLength,
|
|
772
|
-
usage: ['index'],
|
|
773
|
-
data: mesh.indices,
|
|
774
|
-
},
|
|
775
|
-
{
|
|
776
|
-
kind: 'index-data',
|
|
777
|
-
name: indices,
|
|
778
|
-
format: indexFormat,
|
|
779
|
-
buffer: indexBuffer,
|
|
1509
|
+
program: shadowProgram,
|
|
1510
|
+
bindings: [shadowBindings],
|
|
1511
|
+
vertexData: vertexBindings,
|
|
1512
|
+
...(indexData === undefined ? {} : { indexData }),
|
|
1513
|
+
draw: {
|
|
1514
|
+
kind: indexData === undefined ? 'draw-indirect' : 'draw-indexed-indirect',
|
|
1515
|
+
resource: indirect,
|
|
1516
|
+
offset: rendererIndex * 20,
|
|
780
1517
|
},
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
}
|
|
787
|
-
|
|
1518
|
+
},
|
|
1519
|
+
],
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
if (latest.visible)
|
|
788
1523
|
passes.push({
|
|
789
1524
|
kind: 'raster',
|
|
790
1525
|
name: `${rendererPrefix}.raster`,
|
|
@@ -821,19 +1556,61 @@ export function gpuParticleRenderFeature(
|
|
|
821
1556
|
},
|
|
822
1557
|
],
|
|
823
1558
|
});
|
|
824
|
-
}
|
|
825
1559
|
}
|
|
826
1560
|
}
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1561
|
+
|
|
1562
|
+
pendingFrames.set(
|
|
1563
|
+
frame.frameNumber,
|
|
1564
|
+
frame.worlds.map((entry) => ({
|
|
1565
|
+
world: entry.world,
|
|
1566
|
+
runtime: entry.runtime,
|
|
1567
|
+
outcomes:
|
|
1568
|
+
outcomesByEntry.get(entry) ??
|
|
1569
|
+
entry.intents.map((intent) => ({ intent, state: 'deferred' as const })),
|
|
1570
|
+
})),
|
|
1571
|
+
);
|
|
1572
|
+
return ok<RenderFeaturePlan>({ resources, passes });
|
|
1573
|
+
},
|
|
1574
|
+
onFrameSubmitted: (frame) => {
|
|
1575
|
+
const pending = pendingFrames.get(frame.frameNumber);
|
|
1576
|
+
if (pending === undefined) return;
|
|
1577
|
+
pendingFrames.delete(frame.frameNumber);
|
|
1578
|
+
for (const entry of pending) {
|
|
1579
|
+
// A deferred provider or target is an ordered barrier for its own
|
|
1580
|
+
// player/emitter stream. Other streams may still have terminal work in
|
|
1581
|
+
// this frame, so acknowledge those exact sequences without dropping
|
|
1582
|
+
// the deferred intents that must retry later.
|
|
1583
|
+
const acknowledgedSequences: number[] = [];
|
|
1584
|
+
const publishedSequences: number[] = [];
|
|
1585
|
+
for (const outcome of entry.outcomes) {
|
|
1586
|
+
if (outcome.state === 'deferred') continue;
|
|
1587
|
+
acknowledgedSequences.push(outcome.intent.sequence);
|
|
1588
|
+
if (outcome.state === 'dispatched') {
|
|
1589
|
+
publishedSequences.push(outcome.intent.sequence);
|
|
1590
|
+
entry.runtime.markEventDispatched(outcome.intent.player, outcome.intent.eventCounters);
|
|
1591
|
+
if (outcome.resetEpoch !== undefined) {
|
|
1592
|
+
const baseKey =
|
|
1593
|
+
`${worldId(entry.world)}:` +
|
|
1594
|
+
`${entry.runtime.renderGeneration ?? 0}:` +
|
|
1595
|
+
`${Number(outcome.intent.player)}:${outcome.intent.emitter.id}`;
|
|
1596
|
+
resetEpochByEmitter.get(entry.runtime)?.set(baseKey, outcome.resetEpoch);
|
|
1597
|
+
resetEpochByIntent.get(entry.runtime)?.delete(outcome.intent.sequence);
|
|
1598
|
+
}
|
|
1599
|
+
} else if (outcome.state === 'skipped' && outcome.resetEpoch !== undefined) {
|
|
1600
|
+
// A skipped reset never touched GPU state. Drop only its retry
|
|
1601
|
+
// reservation; a future dispatched reset must allocate a new
|
|
1602
|
+
// epoch from the last committed emitter state.
|
|
1603
|
+
resetEpochByIntent.get(entry.runtime)?.delete(outcome.intent.sequence);
|
|
831
1604
|
}
|
|
832
1605
|
}
|
|
833
|
-
|
|
834
|
-
|
|
1606
|
+
if (acknowledgedSequences.length > 0) {
|
|
1607
|
+
entry.runtime.commit(acknowledgedSequences, publishedSequences);
|
|
1608
|
+
}
|
|
835
1609
|
}
|
|
836
|
-
|
|
1610
|
+
},
|
|
1611
|
+
onFrameAborted: (frame) => {
|
|
1612
|
+
pendingFrames.delete(frame.frameNumber);
|
|
837
1613
|
},
|
|
838
1614
|
};
|
|
1615
|
+
return feature;
|
|
839
1616
|
}
|