@forgeax/engine-render 0.1.19 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/assembly/factory.d.ts +16 -5
- package/dist/assembly/factory.d.ts.map +1 -1
- package/dist/{chunk-RJRUS55L.mjs → chunk-4XINDLAY.mjs} +267 -186
- package/dist/chunk-4XINDLAY.mjs.map +1 -0
- package/dist/{chunk-YTQ5C7YQ.mjs → chunk-CDTWTHZR.mjs} +176 -8
- package/dist/chunk-CDTWTHZR.mjs.map +1 -0
- package/dist/{chunk-IPXCABEP.mjs → chunk-M2MP4ZOU.mjs} +2 -2
- package/dist/chunk-M2MP4ZOU.mjs.map +1 -0
- package/dist/{chunk-LOSO4CTV.mjs → chunk-TBQJOK2L.mjs} +67 -3
- package/dist/chunk-TBQJOK2L.mjs.map +1 -0
- package/dist/components/light-helpers.d.ts.map +1 -1
- package/dist/components/spot-light.d.ts +12 -0
- package/dist/components/spot-light.d.ts.map +1 -1
- package/dist/construct-renderer.mjs +2281 -164
- package/dist/construct-renderer.mjs.map +1 -1
- package/dist/device/gpu-residency.d.ts +19 -2
- package/dist/device/gpu-residency.d.ts.map +1 -1
- package/dist/errors/render.d.ts +69 -1
- package/dist/errors/render.d.ts.map +1 -1
- package/dist/gpu-usage.d.ts +2 -0
- package/dist/gpu-usage.d.ts.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +3 -3
- package/dist/internal.mjs +1 -1
- package/dist/light-buffer-layout.d.ts +20 -12
- package/dist/light-buffer-layout.d.ts.map +1 -1
- package/dist/pbr-pipeline.d.ts +6 -3
- package/dist/pbr-pipeline.d.ts.map +1 -1
- package/dist/pipeline/standard-forward-lane.d.ts.map +1 -1
- package/dist/pipeline/standard-pipeline.d.ts.map +1 -1
- package/dist/pipeline/standard-profile.d.ts +13 -0
- package/dist/pipeline/standard-profile.d.ts.map +1 -1
- package/dist/record/frame-lighting.d.ts +2 -0
- package/dist/record/frame-lighting.d.ts.map +1 -1
- package/dist/record/frame-snapshot.d.ts +37 -1
- package/dist/record/frame-snapshot.d.ts.map +1 -1
- package/dist/record/frame-targets.d.ts.map +1 -1
- package/dist/record/frame.d.ts +3 -2
- package/dist/record/frame.d.ts.map +1 -1
- package/dist/record/gpu-timing.d.ts +32 -0
- package/dist/record/gpu-timing.d.ts.map +1 -0
- package/dist/record/main-pass-material.d.ts +1 -1
- package/dist/record/main-pass-material.d.ts.map +1 -1
- package/dist/record/render-context.d.ts +5 -1
- package/dist/record/render-context.d.ts.map +1 -1
- package/dist/record/shadow-pass.d.ts.map +1 -1
- package/dist/record/typed-frame-graph.d.ts +11 -3
- package/dist/record/typed-frame-graph.d.ts.map +1 -1
- package/dist/render-contract.d.ts +66 -1
- package/dist/render-contract.d.ts.map +1 -1
- package/dist/render-data.d.ts +10 -6
- package/dist/render-data.d.ts.map +1 -1
- package/dist/render-pipeline.d.ts +36 -0
- package/dist/render-pipeline.d.ts.map +1 -1
- package/dist/render-system-extract.d.ts +55 -3
- package/dist/render-system-extract.d.ts.map +1 -1
- package/dist/render-system.d.ts +7 -1
- package/dist/render-system.d.ts.map +1 -1
- package/dist/renderer-inspect.d.ts +29 -0
- package/dist/renderer-inspect.d.ts.map +1 -0
- package/dist/scene/render-scene.d.ts.map +1 -1
- package/dist/temporal/frame.d.ts +44 -0
- package/dist/temporal/frame.d.ts.map +1 -0
- package/dist/temporal/index.mjs +3 -3
- package/dist/typed-render-graph-primitives.d.ts +6 -1
- package/dist/typed-render-graph-primitives.d.ts.map +1 -1
- package/dist/typed-shadow-atlas.d.ts +10 -0
- package/dist/typed-shadow-atlas.d.ts.map +1 -0
- package/dist/typed-shadow-passes.d.ts.map +1 -1
- package/dist/volume/capability.d.ts +57 -0
- package/dist/volume/capability.d.ts.map +1 -0
- package/dist/volume/component.d.ts +61 -0
- package/dist/volume/component.d.ts.map +1 -0
- package/dist/volume/extract.d.ts +11 -0
- package/dist/volume/extract.d.ts.map +1 -0
- package/dist/volume/inspection.d.ts +38 -0
- package/dist/volume/inspection.d.ts.map +1 -0
- package/dist/volume/passes.d.ts +38 -0
- package/dist/volume/passes.d.ts.map +1 -0
- package/dist/volume/recovery.d.ts +38 -0
- package/dist/volume/recovery.d.ts.map +1 -0
- package/dist/volume/resources.d.ts +57 -0
- package/dist/volume/resources.d.ts.map +1 -0
- package/dist/volume/temporal.d.ts +35 -0
- package/dist/volume/temporal.d.ts.map +1 -0
- package/package.json +20 -20
- package/src/__tests__/ktx2-basis-gpu-consumer.dawn.test.ts +14 -14
- package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +1 -1
- package/src/__tests__/render-error-code-owner.test-d.ts +5 -0
- package/src/__tests__/renderer-factory-material-contract.unit.test.ts +28 -0
- package/src/__tests__/renderer-frame-transaction.integration.test.ts +1 -1
- package/src/__tests__/skinned-shadow-caster.test.ts +21 -0
- package/src/__tests__/standard-profile-oracle.unit.test.ts +7 -5
- package/src/__tests__/volume-optics-oracle.unit.test.ts +85 -0
- package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +655 -0
- package/src/__tests__/volumetric-fog-component.unit.test.ts +71 -0
- package/src/__tests__/volumetric-fog-composite-stage-readback.dawn.test.ts +376 -0
- package/src/__tests__/volumetric-fog-errors.unit.test.ts +51 -0
- package/src/__tests__/volumetric-fog-inspection.unit.test.ts +77 -0
- package/src/__tests__/volumetric-fog-light-capability.unit.test.ts +19 -0
- package/src/__tests__/volumetric-fog-light-consumers.integration.test.ts +18 -0
- package/src/__tests__/volumetric-fog-light-errors.unit.test.ts +17 -0
- package/src/__tests__/volumetric-fog-light-topology.unit.test.ts +21 -0
- package/src/__tests__/volumetric-fog-light-transaction.integration.test.ts +53 -0
- package/src/__tests__/volumetric-fog-m9-watcher.integration.test.ts +27 -0
- package/src/__tests__/volumetric-fog-point-light-owner.integration.test.ts +44 -0
- package/src/__tests__/volumetric-fog-point-spot-accumulation.integration.test.ts +49 -0
- package/src/__tests__/volumetric-fog-point-spot-contract.unit.test.ts +28 -0
- package/src/__tests__/volumetric-fog-point-spot-lkg.integration.test.ts +65 -0
- package/src/__tests__/volumetric-fog-point-spot-selection.integration.test.ts +49 -0
- package/src/__tests__/volumetric-fog-point-spot-temporal.unit.test.ts +55 -0
- package/src/__tests__/volumetric-fog-projector-contract.unit.test.ts +22 -0
- package/src/__tests__/volumetric-fog-projector-inspection.unit.test.ts +12 -0
- package/src/__tests__/volumetric-fog-projector-surface-volume.integration.test.ts +11 -0
- package/src/__tests__/volumetric-fog-projector-transaction.integration.test.ts +26 -0
- package/src/__tests__/volumetric-fog-recovery.integration.test.ts +47 -0
- package/src/__tests__/volumetric-fog-selected-light-resolution.integration.test.ts +63 -0
- package/src/__tests__/volumetric-fog-spot-closure.integration.test.ts +26 -0
- package/src/__tests__/volumetric-fog-spot-contract.test-d.ts +21 -0
- package/src/__tests__/volumetric-fog-spot-contract.unit.test.ts +27 -0
- package/src/__tests__/volumetric-fog-spot-frame-optics.integration.test.ts +14 -0
- package/src/__tests__/volumetric-fog-spot-optics.integration.test.ts +29 -0
- package/src/__tests__/volumetric-fog-stage-readback.dawn.test.ts +550 -0
- package/src/__tests__/volumetric-fog-temporal-selected-light.unit.test.ts +33 -0
- package/src/__tests__/volumetric-fog-temporal.unit.test.ts +45 -0
- package/src/__tests__/volumetric-fog-topology.unit.test.ts +237 -0
- package/src/__tests__/volumetric-fog-transparent.integration.test.ts +27 -0
- package/src/assembly/factory.ts +271 -15
- package/src/components/light-helpers.ts +4 -0
- package/src/components/spot-light.ts +22 -3
- package/src/device/gpu-residency.ts +168 -58
- package/src/errors/render.ts +119 -1
- package/src/gpu-usage.ts +2 -0
- package/src/index.ts +38 -1
- package/src/light-buffer-layout.ts +59 -26
- package/src/pbr-pipeline.ts +33 -10
- package/src/pipeline/standard-forward-lane.ts +20 -0
- package/src/pipeline/standard-pipeline.ts +20 -0
- package/src/pipeline/standard-profile.ts +23 -0
- package/src/record/frame-lighting.ts +103 -89
- package/src/record/frame-snapshot.ts +41 -1
- package/src/record/frame-targets.ts +6 -2
- package/src/record/frame.ts +908 -5
- package/src/record/gpu-timing.ts +224 -0
- package/src/record/main-pass-material.ts +6 -2
- package/src/record/render-context.ts +5 -0
- package/src/record/shadow-pass.ts +87 -38
- package/src/record/typed-frame-graph.ts +143 -8
- package/src/render-contract.ts +72 -0
- package/src/render-data.ts +50 -22
- package/src/render-pipeline.ts +44 -0
- package/src/render-system-extract.ts +329 -3
- package/src/render-system.ts +79 -0
- package/src/renderer-inspect.ts +66 -0
- package/src/scene/render-scene.ts +2 -0
- package/src/temporal/frame.ts +128 -0
- package/src/transmission/__tests__/standard-transmission.dawn.test.ts +2 -3
- package/src/typed-render-graph-primitives.ts +32 -2
- package/src/typed-shadow-atlas.ts +17 -0
- package/src/typed-shadow-passes.ts +6 -5
- package/src/volume/capability.ts +173 -0
- package/src/volume/component.ts +110 -0
- package/src/volume/extract.ts +21 -0
- package/src/volume/inspection.ts +78 -0
- package/src/volume/passes.ts +680 -0
- package/src/volume/recovery.ts +86 -0
- package/src/volume/resources.ts +236 -0
- package/src/volume/temporal.ts +89 -0
- package/dist/chunk-IPXCABEP.mjs.map +0 -1
- package/dist/chunk-LOSO4CTV.mjs.map +0 -1
- package/dist/chunk-RJRUS55L.mjs.map +0 -1
- package/dist/chunk-YTQ5C7YQ.mjs.map +0 -1
package/src/record/frame.ts
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
import { HANDLE_NINESLICE_QUAD, resolveAssetHandle } from '@forgeax/engine-assets-runtime';
|
|
5
5
|
import type { World } from '@forgeax/engine-ecs';
|
|
6
6
|
import type { RenderReadLease } from '@forgeax/engine-ecs/projection';
|
|
7
|
-
import { mat4 } from '@forgeax/engine-math';
|
|
7
|
+
import { mat4, vec3 } from '@forgeax/engine-math';
|
|
8
8
|
import {
|
|
9
9
|
type CurrentFrameObservationLease,
|
|
10
10
|
createCurrentFrameObservationLease,
|
|
11
11
|
} from '@forgeax/engine-render-graph';
|
|
12
12
|
import {
|
|
13
|
+
type Buffer,
|
|
13
14
|
err,
|
|
14
15
|
ok,
|
|
15
16
|
type Result,
|
|
@@ -25,12 +26,22 @@ import { ObservationUnavailableError, type ObservationUnavailableReason } from '
|
|
|
25
26
|
import type { FogFrame } from '../extract/environment';
|
|
26
27
|
import type { GpuDrivenProduction, PreparedGpuDrivenFrame } from '../gpu-driven/production-raster';
|
|
27
28
|
import { GpuBuffer } from '../gpu-resource';
|
|
28
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
GPU_BUFFER_USAGE_COPY_DST,
|
|
31
|
+
GPU_BUFFER_USAGE_UNIFORM,
|
|
32
|
+
GPU_BUFFER_USAGE_VERTEX,
|
|
33
|
+
} from '../gpu-usage';
|
|
29
34
|
import { disposeTransientInstanceBuffers } from '../instance-buffer-cache';
|
|
30
|
-
import type {
|
|
35
|
+
import type {
|
|
36
|
+
CameraSnapshot,
|
|
37
|
+
RenderRecordPhase,
|
|
38
|
+
SpotLightProjectorFrameContext,
|
|
39
|
+
VolumetricFogFrameContext,
|
|
40
|
+
} from '../render-contract';
|
|
31
41
|
import type {
|
|
32
42
|
DispatchEntry,
|
|
33
43
|
ExtractedLights,
|
|
44
|
+
ExtractedVolumetricFog,
|
|
34
45
|
MaterialSnapshot,
|
|
35
46
|
RenderableSnapshot,
|
|
36
47
|
SkyboxSnapshot,
|
|
@@ -43,6 +54,9 @@ import {
|
|
|
43
54
|
TRANSPARENT_SORT_MODE_LAYER_Z,
|
|
44
55
|
} from '../systems/transparent-sort-config';
|
|
45
56
|
import type { TransmissionDemand } from '../transmission/projection';
|
|
57
|
+
import { hasVolumetricFogCapability } from '../volume/capability';
|
|
58
|
+
import { inspectVolumetricFogResources, VOLUMETRIC_FOG_PARAMS_BYTES } from '../volume/resources';
|
|
59
|
+
import { resolveVolumeTemporalReset, type VolumeTemporalSignature } from '../volume/temporal';
|
|
46
60
|
import {
|
|
47
61
|
buildPerFrameBindGroups,
|
|
48
62
|
prepareFrameLighting,
|
|
@@ -188,12 +202,14 @@ export async function observeCurrentFrame(
|
|
|
188
202
|
}
|
|
189
203
|
|
|
190
204
|
import { createTemporalView, resolveTemporalReset } from '../temporal/view';
|
|
205
|
+
import { inspectVolumetricFog } from '../volume/inspection';
|
|
191
206
|
import {
|
|
192
207
|
acquireSwapChainTarget,
|
|
193
208
|
graphExecutionPhase,
|
|
194
209
|
resolveShadowMapSize,
|
|
195
210
|
resolveSpotShadowMapSize,
|
|
196
211
|
} from './frame-targets';
|
|
212
|
+
import type { GpuTimingCapture } from './gpu-timing';
|
|
197
213
|
import {
|
|
198
214
|
computeProjectionMatrix,
|
|
199
215
|
computeViewMatrix,
|
|
@@ -210,6 +226,7 @@ import {
|
|
|
210
226
|
ensureCompiledFrameGraph,
|
|
211
227
|
executeCompiledFrameGraph,
|
|
212
228
|
type RenderFeatureGraphCandidate,
|
|
229
|
+
settleVolumetricFogGraphCandidate,
|
|
213
230
|
} from './typed-frame-graph';
|
|
214
231
|
import { writePointsLinesViewUbo, writeViewUbo } from './view-ubo';
|
|
215
232
|
|
|
@@ -294,6 +311,703 @@ function runRecordProfilePhase<T>(
|
|
|
294
311
|
return runner === undefined ? action() : runner(phase, action);
|
|
295
312
|
}
|
|
296
313
|
|
|
314
|
+
const VOLUMETRIC_FOG_PASS_COUNT = 4;
|
|
315
|
+
|
|
316
|
+
type VolumetricFogParamsSlot = 0 | 1;
|
|
317
|
+
|
|
318
|
+
function nextVolumetricFogParamsSlot(frameState: RenderFrameState): VolumetricFogParamsSlot {
|
|
319
|
+
const accepted = frameState.volumetricFogParamsAcceptedSlot;
|
|
320
|
+
const pending = frameState.volumetricFogParamsPendingSlot;
|
|
321
|
+
if (pending !== null && pending !== accepted) return pending;
|
|
322
|
+
return accepted === null ? 0 : accepted === 0 ? 1 : 0;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function stageVolumetricFogParams(
|
|
326
|
+
internals: RenderSystemInternals,
|
|
327
|
+
frameState: RenderFrameState,
|
|
328
|
+
params: Float32Array,
|
|
329
|
+
): Buffer | undefined {
|
|
330
|
+
const slot = nextVolumetricFogParamsSlot(frameState);
|
|
331
|
+
let buffer = frameState.volumetricFogParamsBuffers[slot];
|
|
332
|
+
if (buffer === null) {
|
|
333
|
+
const created = internals.device.createBuffer({
|
|
334
|
+
label: `volumetric-fog-params-${slot}`,
|
|
335
|
+
size: VOLUMETRIC_FOG_PARAMS_BYTES,
|
|
336
|
+
usage: GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST,
|
|
337
|
+
mappedAtCreation: false,
|
|
338
|
+
});
|
|
339
|
+
if (!created.ok) {
|
|
340
|
+
internals.errorRegistry.fire(created.error);
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
buffer = created.value;
|
|
344
|
+
frameState.volumetricFogParamsBuffers[slot] = buffer;
|
|
345
|
+
}
|
|
346
|
+
const written = internals.device.queue.writeBuffer(buffer, 0, params);
|
|
347
|
+
if (!written.ok) {
|
|
348
|
+
internals.errorRegistry.fire(written.error);
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
frameState.volumetricFogParamsPendingSlot = slot;
|
|
352
|
+
frameState.volumetricFogPendingParams = new Float32Array(params);
|
|
353
|
+
return buffer;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Retire parameter buffers only after the submit that stopped using them. */
|
|
357
|
+
function retireVolumetricFogParams(
|
|
358
|
+
internals: RenderSystemInternals,
|
|
359
|
+
frameState: RenderFrameState,
|
|
360
|
+
): void {
|
|
361
|
+
const buffers = frameState.volumetricFogParamsBuffers;
|
|
362
|
+
frameState.volumetricFogParamsBuffers = [null, null];
|
|
363
|
+
frameState.volumetricFogParamsPendingSlot = null;
|
|
364
|
+
frameState.volumetricFogParamsAcceptedSlot = null;
|
|
365
|
+
frameState.volumetricFogAcceptedParams = undefined;
|
|
366
|
+
frameState.volumetricFogPendingParams = undefined;
|
|
367
|
+
const live = buffers.filter((buffer): buffer is Buffer => buffer !== null);
|
|
368
|
+
if (live.length === 0) return;
|
|
369
|
+
const device = internals.device;
|
|
370
|
+
const release = (): void => {
|
|
371
|
+
for (const buffer of live) {
|
|
372
|
+
const destroyed = device.destroyBuffer(buffer);
|
|
373
|
+
if (!destroyed.ok) internals.errorRegistry.fire(destroyed.error);
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
try {
|
|
377
|
+
void device.queue.onSubmittedWorkDone().then(release, release);
|
|
378
|
+
} catch {
|
|
379
|
+
release();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function volumeRevision(values: readonly number[]): number {
|
|
384
|
+
let hash = 2166136261;
|
|
385
|
+
for (const value of values) {
|
|
386
|
+
const quantized = Number.isFinite(value) ? Math.round(value * 1_000_000) : 0;
|
|
387
|
+
hash = Math.imul(hash ^ quantized, 16777619) >>> 0;
|
|
388
|
+
}
|
|
389
|
+
return hash;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function volumeTemporalSignature(
|
|
393
|
+
fog: ExtractedVolumetricFog,
|
|
394
|
+
prepared: VolumetricFogFrameContext,
|
|
395
|
+
camera: CameraSnapshot,
|
|
396
|
+
lights: ExtractedLights,
|
|
397
|
+
width: number,
|
|
398
|
+
height: number,
|
|
399
|
+
): VolumeTemporalSignature {
|
|
400
|
+
const authored = fog.fog;
|
|
401
|
+
const directional = lights.directional;
|
|
402
|
+
const cameraRevision = volumeRevision([
|
|
403
|
+
...camera.world,
|
|
404
|
+
camera.fov,
|
|
405
|
+
camera.aspect,
|
|
406
|
+
camera.near,
|
|
407
|
+
camera.far,
|
|
408
|
+
camera.orthoLeft,
|
|
409
|
+
camera.orthoRight,
|
|
410
|
+
camera.orthoBottom,
|
|
411
|
+
camera.orthoTop,
|
|
412
|
+
]);
|
|
413
|
+
const fogRevision =
|
|
414
|
+
authored === undefined
|
|
415
|
+
? 0
|
|
416
|
+
: volumeRevision([
|
|
417
|
+
...authored.bounds.min,
|
|
418
|
+
...authored.bounds.max,
|
|
419
|
+
...authored.extinction,
|
|
420
|
+
...authored.albedo,
|
|
421
|
+
...authored.emission,
|
|
422
|
+
authored.anisotropy,
|
|
423
|
+
authored.maxDistance,
|
|
424
|
+
]);
|
|
425
|
+
const selectedSpot =
|
|
426
|
+
fog.spotLightEntity === undefined
|
|
427
|
+
? fog.lightKind === 'spot'
|
|
428
|
+
? lights.spot.find((spot) => spot.entity === fog.lightEntity)
|
|
429
|
+
: undefined
|
|
430
|
+
: lights.spot.find((spot) => spot.entity === fog.spotLightEntity);
|
|
431
|
+
const selectedPoint =
|
|
432
|
+
fog.pointLightEntity === undefined
|
|
433
|
+
? fog.lightKind === 'point'
|
|
434
|
+
? lights.point.find((point) => point.entity === fog.lightEntity)
|
|
435
|
+
: undefined
|
|
436
|
+
: lights.point.find((point) => point.entity === fog.pointLightEntity);
|
|
437
|
+
const selectedDirectional =
|
|
438
|
+
fog.lightKind === 'directional' && directional?.entity === fog.lightEntity
|
|
439
|
+
? directional
|
|
440
|
+
: undefined;
|
|
441
|
+
const lightRevision =
|
|
442
|
+
selectedSpot === undefined && selectedPoint === undefined && selectedDirectional === undefined
|
|
443
|
+
? 0
|
|
444
|
+
: volumeRevision(
|
|
445
|
+
selectedSpot === undefined && selectedPoint === undefined
|
|
446
|
+
? selectedDirectional === undefined
|
|
447
|
+
? []
|
|
448
|
+
: [
|
|
449
|
+
...selectedDirectional.direction,
|
|
450
|
+
...selectedDirectional.color,
|
|
451
|
+
selectedDirectional.intensity,
|
|
452
|
+
...(lights.lightViewProj?.flatMap((matrix) => [...matrix]) ?? []),
|
|
453
|
+
]
|
|
454
|
+
: [
|
|
455
|
+
...(selectedPoint === undefined
|
|
456
|
+
? []
|
|
457
|
+
: [
|
|
458
|
+
...selectedPoint.position,
|
|
459
|
+
...selectedPoint.color,
|
|
460
|
+
selectedPoint.intensity,
|
|
461
|
+
selectedPoint.invRangeSquared,
|
|
462
|
+
selectedPoint.shadowAtlasLayer ?? -1,
|
|
463
|
+
selectedPoint.shadowNear ?? 0,
|
|
464
|
+
selectedPoint.shadowFar ?? 0,
|
|
465
|
+
]),
|
|
466
|
+
...(selectedSpot === undefined
|
|
467
|
+
? []
|
|
468
|
+
: [
|
|
469
|
+
...selectedSpot.position,
|
|
470
|
+
...selectedSpot.direction,
|
|
471
|
+
...selectedSpot.color,
|
|
472
|
+
selectedSpot.intensity,
|
|
473
|
+
selectedSpot.invRangeSquared,
|
|
474
|
+
selectedSpot.cosInner,
|
|
475
|
+
selectedSpot.cosOuter,
|
|
476
|
+
selectedSpot.depthBias ?? 0.005,
|
|
477
|
+
selectedSpot.normalBias ?? 0.05,
|
|
478
|
+
selectedSpot.pcfKernelSize ?? 3,
|
|
479
|
+
selectedSpot.shadowAtlasTile,
|
|
480
|
+
...(selectedSpot.lightViewProj === undefined
|
|
481
|
+
? []
|
|
482
|
+
: [...selectedSpot.lightViewProj]),
|
|
483
|
+
]),
|
|
484
|
+
],
|
|
485
|
+
);
|
|
486
|
+
return {
|
|
487
|
+
cameraRevision,
|
|
488
|
+
fogRevision,
|
|
489
|
+
lightRevision,
|
|
490
|
+
densityGeneration: prepared.densityGeneration,
|
|
491
|
+
width,
|
|
492
|
+
height,
|
|
493
|
+
...(fog.lightKind === undefined ? {} : { lightKind: fog.lightKind }),
|
|
494
|
+
...(fog.lightEntity === undefined ? {} : { lightEntity: fog.lightEntity }),
|
|
495
|
+
...(fog.pointLightEntity === undefined ? {} : { pointLightEntity: fog.pointLightEntity }),
|
|
496
|
+
...(fog.spotLightEntity === undefined ? {} : { spotLightEntity: fog.spotLightEntity }),
|
|
497
|
+
...(fog.projector === undefined ? {} : { projector: fog.projector }),
|
|
498
|
+
lightShadowRevision: lightRevision,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function resolveVolumetricFogHistoryContext(
|
|
503
|
+
frameState: RenderFrameState,
|
|
504
|
+
graph: import('@forgeax/engine-render-graph').CompiledRenderGraph<
|
|
505
|
+
import('../render-pipeline').RenderPipelineFrame
|
|
506
|
+
>,
|
|
507
|
+
fog: ExtractedVolumetricFog,
|
|
508
|
+
prepared: VolumetricFogFrameContext,
|
|
509
|
+
camera: CameraSnapshot,
|
|
510
|
+
lights: ExtractedLights,
|
|
511
|
+
width: number,
|
|
512
|
+
height: number,
|
|
513
|
+
): { readonly context: VolumetricFogFrameContext; readonly signature: VolumeTemporalSignature } {
|
|
514
|
+
const signature = volumeTemporalSignature(fog, prepared, camera, lights, width, height);
|
|
515
|
+
const acceptedGraph = frameState.volumetricFogHistoryGraph;
|
|
516
|
+
const acceptedSlot = frameState.volumetricFogHistorySlot;
|
|
517
|
+
const previousSignature = frameState.volumetricFogHistorySignature;
|
|
518
|
+
let reset = acceptedGraph !== graph || acceptedSlot === null || previousSignature === null;
|
|
519
|
+
if (!reset && previousSignature !== null) {
|
|
520
|
+
const temporal = frameState.temporalFrame;
|
|
521
|
+
const previousResetReason = temporal?.resetReason;
|
|
522
|
+
const reprojection =
|
|
523
|
+
previousResetReason === 'out-of-screen'
|
|
524
|
+
? 'out-of-screen'
|
|
525
|
+
: previousResetReason === 'depth-discontinuity'
|
|
526
|
+
? 'depth-discontinuity'
|
|
527
|
+
: undefined;
|
|
528
|
+
reset = resolveVolumeTemporalReset(previousSignature, signature, {
|
|
529
|
+
taaEnabled: camera.antialias === 'taa',
|
|
530
|
+
...(reprojection === undefined ? {} : { reprojection }),
|
|
531
|
+
}).reset;
|
|
532
|
+
}
|
|
533
|
+
const historyReadSlot = reset ? null : acceptedSlot;
|
|
534
|
+
const historyWriteSlot = reset || acceptedSlot === null ? 0 : acceptedSlot === 0 ? 1 : 0;
|
|
535
|
+
return {
|
|
536
|
+
context: {
|
|
537
|
+
...prepared,
|
|
538
|
+
historyReadSlot,
|
|
539
|
+
historyWriteSlot,
|
|
540
|
+
historyValid: historyReadSlot !== null,
|
|
541
|
+
},
|
|
542
|
+
signature,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Resolve the authored density POD into the renderer-owned GPU resources used
|
|
548
|
+
* by the graph. This is deliberately a pull operation: a missing/stale
|
|
549
|
+
* handle or failed upload produces a structured error and leaves the graph
|
|
550
|
+
* candidate disabled for this frame rather than binding a synthetic density.
|
|
551
|
+
*/
|
|
552
|
+
function prepareVolumetricFogFrame(
|
|
553
|
+
internals: RenderSystemInternals,
|
|
554
|
+
frameState: RenderFrameState,
|
|
555
|
+
world: World,
|
|
556
|
+
worlds: readonly World[],
|
|
557
|
+
fog: ExtractedVolumetricFog | undefined,
|
|
558
|
+
lights: ExtractedLights,
|
|
559
|
+
volumeCapability: boolean,
|
|
560
|
+
projector: SpotLightProjectorFrameContext | undefined,
|
|
561
|
+
): VolumetricFogFrameContext | undefined {
|
|
562
|
+
if (
|
|
563
|
+
!volumeCapability ||
|
|
564
|
+
fog?.status !== 'available' ||
|
|
565
|
+
fog.fog === undefined ||
|
|
566
|
+
fog.densityHandle === undefined ||
|
|
567
|
+
fog.densityAsset === undefined ||
|
|
568
|
+
(fog.lightKind === 'spot'
|
|
569
|
+
? lights.spot.find((light) => light.entity === fog.lightEntity) === undefined
|
|
570
|
+
: fog.lightKind === 'point'
|
|
571
|
+
? lights.point.find((light) => light.entity === fog.lightEntity) === undefined
|
|
572
|
+
: lights.directional === undefined)
|
|
573
|
+
) {
|
|
574
|
+
return undefined;
|
|
575
|
+
}
|
|
576
|
+
const ownerWorld = worlds[fog.worldId ?? 0] ?? world;
|
|
577
|
+
const resident = internals.gpuStore.ensureResident(
|
|
578
|
+
fog.densityHandle,
|
|
579
|
+
fog.densityAsset,
|
|
580
|
+
ownerWorld,
|
|
581
|
+
);
|
|
582
|
+
if (!resident.ok || !('texture' in resident.value)) {
|
|
583
|
+
if (!resident.ok) {
|
|
584
|
+
internals.errorRegistry.fire(
|
|
585
|
+
resident.error instanceof RhiError
|
|
586
|
+
? resident.error
|
|
587
|
+
: new RhiError({
|
|
588
|
+
code: 'webgpu-runtime-error',
|
|
589
|
+
expected: 'volumetric density residency to resolve a GPU texture',
|
|
590
|
+
hint: 'repair the authored TextureAsset payload and retry the next frame',
|
|
591
|
+
detail: { error: resident.error },
|
|
592
|
+
}),
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
return undefined;
|
|
596
|
+
}
|
|
597
|
+
const params = new Float32Array(32);
|
|
598
|
+
params.set(fog.fog.bounds.min, 0);
|
|
599
|
+
params.set(fog.fog.bounds.max, 4);
|
|
600
|
+
params.set(fog.fog.extinction, 8);
|
|
601
|
+
params.set(fog.fog.albedo, 12);
|
|
602
|
+
params.set(fog.fog.emission, 16);
|
|
603
|
+
const selectedPoint =
|
|
604
|
+
fog.pointLightEntity === undefined
|
|
605
|
+
? undefined
|
|
606
|
+
: lights.point.find((light) => light.entity === fog.pointLightEntity);
|
|
607
|
+
const selectedSpot =
|
|
608
|
+
fog.spotLightEntity === undefined
|
|
609
|
+
? fog.lightKind === 'spot'
|
|
610
|
+
? lights.spot.find((light) => light.entity === fog.lightEntity)
|
|
611
|
+
: undefined
|
|
612
|
+
: lights.spot.find((light) => light.entity === fog.spotLightEntity);
|
|
613
|
+
const lightDirection =
|
|
614
|
+
selectedSpot?.direction ?? lights.directional?.direction ?? vec3.create(0, -1, 0);
|
|
615
|
+
const lightColor = selectedSpot?.color ?? lights.directional?.color ?? vec3.create(0, 0, 0);
|
|
616
|
+
if (lightDirection === undefined || lightColor === undefined) return undefined;
|
|
617
|
+
params.set(lightDirection, 20);
|
|
618
|
+
// Reuse the vec4 padding lane as the renderer-owned frame phase for
|
|
619
|
+
// sub-froxel jitter. The temporal resolver consumes it without growing the
|
|
620
|
+
// public fog component or allocating another uniform buffer.
|
|
621
|
+
params[23] = frameState.frameNumber % 1024;
|
|
622
|
+
const selectedSpotIndex =
|
|
623
|
+
selectedSpot === undefined
|
|
624
|
+
? -1
|
|
625
|
+
: lights.spot.findIndex((light) => light.entity === selectedSpot.entity);
|
|
626
|
+
params.set(lightColor, 24);
|
|
627
|
+
params[19] =
|
|
628
|
+
selectedPoint === undefined
|
|
629
|
+
? -1
|
|
630
|
+
: lights.point.findIndex((light) => light.entity === selectedPoint.entity);
|
|
631
|
+
params[27] = selectedSpotIndex;
|
|
632
|
+
params[28] = fog.fog.maxDistance;
|
|
633
|
+
params[29] = fog.fog.anisotropy;
|
|
634
|
+
params[30] =
|
|
635
|
+
selectedPoint !== undefined && selectedSpot !== undefined
|
|
636
|
+
? 3
|
|
637
|
+
: selectedPoint !== undefined
|
|
638
|
+
? 1
|
|
639
|
+
: selectedSpot !== undefined
|
|
640
|
+
? 2
|
|
641
|
+
: 0;
|
|
642
|
+
// The temporal shader reads this lane after the record stage resolves the
|
|
643
|
+
// accepted graph/slot. It is overwritten on the next queue-visible frame;
|
|
644
|
+
// the first candidate deliberately starts with history disabled.
|
|
645
|
+
params[31] =
|
|
646
|
+
frameState.temporalFrame !== undefined && frameState.temporalFrame.resetReason === 'none'
|
|
647
|
+
? 1
|
|
648
|
+
: 0;
|
|
649
|
+
|
|
650
|
+
const paramsBuffer = stageVolumetricFogParams(internals, frameState, params);
|
|
651
|
+
if (paramsBuffer === undefined) return undefined;
|
|
652
|
+
return {
|
|
653
|
+
densityTexture: resident.value.texture.handle,
|
|
654
|
+
densityView: resident.value.view,
|
|
655
|
+
paramsBuffer,
|
|
656
|
+
densityGeneration: resident.value.receipt.generation,
|
|
657
|
+
historyReadSlot: null,
|
|
658
|
+
historyWriteSlot: 0,
|
|
659
|
+
historyValid: false,
|
|
660
|
+
...(projector === undefined
|
|
661
|
+
? {}
|
|
662
|
+
: {
|
|
663
|
+
projectorTexture: projector.texture,
|
|
664
|
+
projectorView: projector.view,
|
|
665
|
+
projectorSampler: projector.sampler,
|
|
666
|
+
}),
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Resolve the SpotLight-owned cookie independently of volumetric fog. Surface
|
|
672
|
+
* lighting must keep the authored projector when the fog component is absent;
|
|
673
|
+
* otherwise the view bind group silently falls back to white and changes the
|
|
674
|
+
* direct-light energy on the floor. A fog-selected projector is preferred so
|
|
675
|
+
* the surface and volume lanes consume one identity, with the first authored
|
|
676
|
+
* projector as the deterministic scene fallback.
|
|
677
|
+
*/
|
|
678
|
+
function prepareSpotLightProjectorFrame(
|
|
679
|
+
internals: RenderSystemInternals,
|
|
680
|
+
world: World,
|
|
681
|
+
worlds: readonly World[],
|
|
682
|
+
lights: ExtractedLights,
|
|
683
|
+
fog: ExtractedVolumetricFog | undefined,
|
|
684
|
+
): SpotLightProjectorFrameContext | undefined {
|
|
685
|
+
const preferred =
|
|
686
|
+
fog?.projectorHandle === undefined
|
|
687
|
+
? undefined
|
|
688
|
+
: lights.spot.find((light) => light.projectorHandle === fog.projectorHandle);
|
|
689
|
+
const source =
|
|
690
|
+
preferred ??
|
|
691
|
+
lights.spot.find(
|
|
692
|
+
(light) =>
|
|
693
|
+
light.projectorHandle !== undefined &&
|
|
694
|
+
light.projectorAsset !== undefined &&
|
|
695
|
+
light.lightViewProj !== undefined,
|
|
696
|
+
);
|
|
697
|
+
if (
|
|
698
|
+
source === undefined ||
|
|
699
|
+
source.projectorHandle === undefined ||
|
|
700
|
+
source.projectorAsset === undefined
|
|
701
|
+
) {
|
|
702
|
+
return undefined;
|
|
703
|
+
}
|
|
704
|
+
const ownerWorld = worlds[fog?.worldId ?? 0] ?? world;
|
|
705
|
+
const resident = internals.gpuStore.ensureResident(
|
|
706
|
+
source.projectorHandle,
|
|
707
|
+
source.projectorAsset,
|
|
708
|
+
ownerWorld,
|
|
709
|
+
);
|
|
710
|
+
if (!resident.ok || !('texture' in resident.value)) {
|
|
711
|
+
if (!resident.ok) {
|
|
712
|
+
internals.errorRegistry.fire(
|
|
713
|
+
resident.error instanceof RhiError
|
|
714
|
+
? resident.error
|
|
715
|
+
: new RhiError({
|
|
716
|
+
code: 'webgpu-runtime-error',
|
|
717
|
+
expected: 'SpotLight projector residency to resolve a GPU texture',
|
|
718
|
+
hint: 'repair the authored projector TextureAsset payload and retry the next frame',
|
|
719
|
+
detail: { error: resident.error },
|
|
720
|
+
}),
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
return undefined;
|
|
724
|
+
}
|
|
725
|
+
const sampler = internals.device.createSampler({
|
|
726
|
+
label: 'spot-projector-linear-clamp',
|
|
727
|
+
minFilter: 'linear',
|
|
728
|
+
magFilter: 'linear',
|
|
729
|
+
mipmapFilter: 'linear',
|
|
730
|
+
addressModeU: 'clamp-to-edge',
|
|
731
|
+
addressModeV: 'clamp-to-edge',
|
|
732
|
+
addressModeW: 'clamp-to-edge',
|
|
733
|
+
});
|
|
734
|
+
if (!sampler.ok) {
|
|
735
|
+
internals.errorRegistry.fire(sampler.error);
|
|
736
|
+
return undefined;
|
|
737
|
+
}
|
|
738
|
+
return {
|
|
739
|
+
texture: resident.value.texture.handle,
|
|
740
|
+
view: resident.value.view,
|
|
741
|
+
sampler: sampler.value,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Stage the accepted parameter payload into the writable slot for a degraded
|
|
746
|
+
* source frame. This keeps the accepted GPU buffer immutable until submit
|
|
747
|
+
* succeeds while still allowing the LKG graph to update its history flag. */
|
|
748
|
+
function stageAcceptedVolumetricFogFrame(
|
|
749
|
+
internals: RenderSystemInternals,
|
|
750
|
+
frameState: RenderFrameState,
|
|
751
|
+
accepted: VolumetricFogFrameContext | undefined,
|
|
752
|
+
): VolumetricFogFrameContext | undefined {
|
|
753
|
+
if (accepted === undefined) return undefined;
|
|
754
|
+
const params = frameState.volumetricFogAcceptedParams;
|
|
755
|
+
if (params === undefined) return accepted;
|
|
756
|
+
const paramsBuffer = stageVolumetricFogParams(internals, frameState, new Float32Array(params));
|
|
757
|
+
if (paramsBuffer === undefined) return accepted;
|
|
758
|
+
return { ...accepted, paramsBuffer };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function updateVolumetricFogInspection(
|
|
762
|
+
frameState: RenderFrameState,
|
|
763
|
+
fog: ExtractedVolumetricFog | undefined,
|
|
764
|
+
lights: ExtractedLights,
|
|
765
|
+
prepared: VolumetricFogFrameContext | undefined,
|
|
766
|
+
submitted: boolean,
|
|
767
|
+
volumeCapability: boolean,
|
|
768
|
+
volumeSubmissionAttempted: boolean,
|
|
769
|
+
): void {
|
|
770
|
+
const resourceFacts =
|
|
771
|
+
frameState.compiledFrameGraph === undefined || frameState.compiledFrameGraph === null
|
|
772
|
+
? undefined
|
|
773
|
+
: inspectVolumetricFogResources(frameState.compiledFrameGraph.inspect(), {
|
|
774
|
+
parameterBufferCount: frameState.volumetricFogParamsBuffers.filter(
|
|
775
|
+
(buffer) => buffer !== null,
|
|
776
|
+
).length,
|
|
777
|
+
});
|
|
778
|
+
const graphSampleCount = resourceFacts?.sampleCount ?? 0;
|
|
779
|
+
const graphMemoryBytes = resourceFacts?.totalBytes ?? 0;
|
|
780
|
+
const previous = frameState.volumetricFogInspection;
|
|
781
|
+
const accepted = frameState.volumetricFogAccepted;
|
|
782
|
+
const acceptedLkg =
|
|
783
|
+
accepted === undefined
|
|
784
|
+
? undefined
|
|
785
|
+
: {
|
|
786
|
+
guid: accepted.guid ?? previous.guid,
|
|
787
|
+
generation: accepted.generation ?? previous.generation,
|
|
788
|
+
digest: accepted.digest ?? previous.digest,
|
|
789
|
+
deviceEpoch: previous.deviceEpoch,
|
|
790
|
+
format: previous.format ?? accepted.densityAsset?.format ?? fog?.densityAsset?.format,
|
|
791
|
+
passCount: previous.passCount > 0 ? previous.passCount : VOLUMETRIC_FOG_PASS_COUNT,
|
|
792
|
+
sampleCount: previous.sampleCount > 0 ? previous.sampleCount : graphSampleCount,
|
|
793
|
+
memoryBytes: previous.memoryBytes > 0 ? previous.memoryBytes : graphMemoryBytes,
|
|
794
|
+
resourceFacts: previous.resourceFacts ?? resourceFacts,
|
|
795
|
+
lightEntity: accepted.lightEntity,
|
|
796
|
+
lightKind: accepted.lightKind,
|
|
797
|
+
pointLightEntity: accepted.pointLightEntity,
|
|
798
|
+
spotLightEntity: accepted.spotLightEntity,
|
|
799
|
+
projector: accepted.projector,
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
const candidateIdentity =
|
|
803
|
+
fog?.guid === undefined || fog.generation === undefined
|
|
804
|
+
? undefined
|
|
805
|
+
: {
|
|
806
|
+
guid: fog.guid,
|
|
807
|
+
generation: fog.generation,
|
|
808
|
+
digest: fog.digest,
|
|
809
|
+
};
|
|
810
|
+
const selectedLightAvailable =
|
|
811
|
+
fog?.lightKind === 'spot'
|
|
812
|
+
? lights.spot.some((light) => light.entity === fog.lightEntity)
|
|
813
|
+
: fog?.lightKind === 'point'
|
|
814
|
+
? lights.point.some(
|
|
815
|
+
(light) => light.entity === (fog.pointLightEntity ?? fog.lightEntity),
|
|
816
|
+
) &&
|
|
817
|
+
(fog.spotLightEntity === undefined ||
|
|
818
|
+
lights.spot.some((light) => light.entity === fog.spotLightEntity))
|
|
819
|
+
: fog?.lightKind === 'directional'
|
|
820
|
+
? lights.directional?.entity === fog.lightEntity
|
|
821
|
+
: lights.directional !== undefined;
|
|
822
|
+
const capability = volumeCapability && selectedLightAvailable ? 'available' : 'unavailable';
|
|
823
|
+
|
|
824
|
+
const inspectAccepted = (status: 'accepted' | 'degraded', candidateFailure?: 'submit-failed') => {
|
|
825
|
+
if (
|
|
826
|
+
acceptedLkg === undefined ||
|
|
827
|
+
acceptedLkg.guid === undefined ||
|
|
828
|
+
acceptedLkg.generation === undefined
|
|
829
|
+
) {
|
|
830
|
+
return false;
|
|
831
|
+
}
|
|
832
|
+
const recovery = {
|
|
833
|
+
guid: acceptedLkg.guid,
|
|
834
|
+
generation: acceptedLkg.generation,
|
|
835
|
+
deviceEpoch: acceptedLkg.deviceEpoch,
|
|
836
|
+
status,
|
|
837
|
+
...(status === 'degraded' ? { lkgGeneration: acceptedLkg.generation } : {}),
|
|
838
|
+
...(candidateIdentity === undefined
|
|
839
|
+
? {}
|
|
840
|
+
: {
|
|
841
|
+
candidateGeneration: candidateIdentity.generation,
|
|
842
|
+
...(candidateIdentity.digest === undefined
|
|
843
|
+
? {}
|
|
844
|
+
: { candidateDigest: candidateIdentity.digest }),
|
|
845
|
+
}),
|
|
846
|
+
...(acceptedLkg.lightEntity === undefined || acceptedLkg.lightKind === undefined
|
|
847
|
+
? {}
|
|
848
|
+
: {
|
|
849
|
+
selectedLight: {
|
|
850
|
+
entity: acceptedLkg.lightEntity,
|
|
851
|
+
kind: acceptedLkg.lightKind,
|
|
852
|
+
revision: 0,
|
|
853
|
+
shadowTile: -1,
|
|
854
|
+
...(acceptedLkg.pointLightEntity === undefined
|
|
855
|
+
? {}
|
|
856
|
+
: { pointLightEntity: acceptedLkg.pointLightEntity }),
|
|
857
|
+
...(acceptedLkg.spotLightEntity === undefined
|
|
858
|
+
? {}
|
|
859
|
+
: { spotLightEntity: acceptedLkg.spotLightEntity }),
|
|
860
|
+
...(acceptedLkg.projector === undefined ? {} : { projector: acceptedLkg.projector }),
|
|
861
|
+
},
|
|
862
|
+
}),
|
|
863
|
+
...(candidateFailure === undefined ? {} : { candidateFailure }),
|
|
864
|
+
} satisfies import('../volume/recovery').VolumeRecoveryState;
|
|
865
|
+
frameState.volumetricFogInspection = inspectVolumetricFog({
|
|
866
|
+
authored: true,
|
|
867
|
+
capability: 'available',
|
|
868
|
+
degraded: status === 'degraded',
|
|
869
|
+
recovery,
|
|
870
|
+
...(acceptedLkg.digest === undefined ? {} : { acceptedDigest: acceptedLkg.digest }),
|
|
871
|
+
...(acceptedLkg.format === undefined ? {} : { format: acceptedLkg.format }),
|
|
872
|
+
passCount: acceptedLkg.passCount,
|
|
873
|
+
sampleCount: acceptedLkg.sampleCount,
|
|
874
|
+
memoryBytes: acceptedLkg.memoryBytes,
|
|
875
|
+
...(acceptedLkg.resourceFacts === undefined
|
|
876
|
+
? {}
|
|
877
|
+
: { resourceFacts: acceptedLkg.resourceFacts }),
|
|
878
|
+
});
|
|
879
|
+
return true;
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
if (fog === undefined || fog.status === 'off') {
|
|
883
|
+
// A failed fog-off candidate must not erase the inspection/LKG that is
|
|
884
|
+
// still being rendered. The accepted projection is retired only after a
|
|
885
|
+
// successful no-volume submission below.
|
|
886
|
+
if (!submitted && volumeSubmissionAttempted && inspectAccepted('degraded', 'submit-failed')) {
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
if (!submitted && volumeSubmissionAttempted) return;
|
|
890
|
+
if (!submitted) return;
|
|
891
|
+
frameState.volumetricFogInspection = inspectVolumetricFog({
|
|
892
|
+
authored: false,
|
|
893
|
+
capability: 'available',
|
|
894
|
+
});
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const authored = fog.status === 'available' || fog.status === 'degraded';
|
|
899
|
+
|
|
900
|
+
// A successfully submitted available candidate is the only path that may
|
|
901
|
+
// become the new accepted projection. This call runs after graph settlement
|
|
902
|
+
// so the inspection and the graph/context observe the same transaction.
|
|
903
|
+
if (
|
|
904
|
+
submitted &&
|
|
905
|
+
fog.status === 'available' &&
|
|
906
|
+
prepared !== undefined &&
|
|
907
|
+
capability === 'available'
|
|
908
|
+
) {
|
|
909
|
+
const recovery =
|
|
910
|
+
candidateIdentity === undefined
|
|
911
|
+
? undefined
|
|
912
|
+
: {
|
|
913
|
+
guid: candidateIdentity.guid,
|
|
914
|
+
generation: candidateIdentity.generation,
|
|
915
|
+
deviceEpoch: previous.deviceEpoch,
|
|
916
|
+
status: 'accepted' as const,
|
|
917
|
+
...(fog.lightEntity === undefined || fog.lightKind === undefined
|
|
918
|
+
? {}
|
|
919
|
+
: {
|
|
920
|
+
selectedLight: {
|
|
921
|
+
entity: fog.lightEntity,
|
|
922
|
+
kind: fog.lightKind,
|
|
923
|
+
revision: 0,
|
|
924
|
+
shadowTile:
|
|
925
|
+
lights.spot.find((spot) => spot.entity === fog.spotLightEntity)
|
|
926
|
+
?.shadowAtlasTile ??
|
|
927
|
+
lights.spot.find((spot) => spot.entity === fog.lightEntity)
|
|
928
|
+
?.shadowAtlasTile ??
|
|
929
|
+
-1,
|
|
930
|
+
...(fog.pointLightEntity === undefined
|
|
931
|
+
? {}
|
|
932
|
+
: { pointLightEntity: fog.pointLightEntity }),
|
|
933
|
+
...(fog.spotLightEntity === undefined
|
|
934
|
+
? {}
|
|
935
|
+
: { spotLightEntity: fog.spotLightEntity }),
|
|
936
|
+
...(fog.projector === undefined ? {} : { projector: fog.projector }),
|
|
937
|
+
},
|
|
938
|
+
}),
|
|
939
|
+
};
|
|
940
|
+
frameState.volumetricFogInspection = inspectVolumetricFog({
|
|
941
|
+
authored: true,
|
|
942
|
+
capability,
|
|
943
|
+
...(recovery === undefined ? {} : { recovery }),
|
|
944
|
+
...(candidateIdentity?.digest === undefined
|
|
945
|
+
? {}
|
|
946
|
+
: { acceptedDigest: candidateIdentity.digest }),
|
|
947
|
+
...(fog.densityAsset?.format === undefined ? {} : { format: fog.densityAsset.format }),
|
|
948
|
+
passCount: VOLUMETRIC_FOG_PASS_COUNT,
|
|
949
|
+
sampleCount: graphSampleCount,
|
|
950
|
+
memoryBytes: graphMemoryBytes,
|
|
951
|
+
...(resourceFacts === undefined ? {} : { resourceFacts }),
|
|
952
|
+
});
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// A submitted degraded source (or an available source whose residency
|
|
957
|
+
// preparation failed) is rendered through the previously accepted graph.
|
|
958
|
+
// Keep that LKG resource fact visible instead of projecting a zero-resource
|
|
959
|
+
// candidate merely because the current source could not be prepared.
|
|
960
|
+
if (
|
|
961
|
+
submitted &&
|
|
962
|
+
acceptedLkg !== undefined &&
|
|
963
|
+
(fog.status === 'degraded' || prepared === undefined)
|
|
964
|
+
) {
|
|
965
|
+
if (inspectAccepted('degraded')) return;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Any candidate that reached encode/submit but failed must project the LKG,
|
|
969
|
+
// never the unsubmitted candidate. The candidate identity remains visible so
|
|
970
|
+
// recovery tooling can distinguish the rejected bytes from the frame being
|
|
971
|
+
// rendered.
|
|
972
|
+
if (!submitted && volumeSubmissionAttempted && inspectAccepted('degraded', 'submit-failed')) {
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// A degraded source that has no LKG is still a diagnostic, but it must not
|
|
977
|
+
// claim accepted resources. Keep its identity in the candidate stage.
|
|
978
|
+
const candidateRecovery =
|
|
979
|
+
candidateIdentity === undefined
|
|
980
|
+
? undefined
|
|
981
|
+
: {
|
|
982
|
+
guid: candidateIdentity.guid,
|
|
983
|
+
generation: candidateIdentity.generation,
|
|
984
|
+
deviceEpoch: previous.deviceEpoch,
|
|
985
|
+
status: 'candidate' as const,
|
|
986
|
+
candidateGeneration: candidateIdentity.generation,
|
|
987
|
+
...(candidateIdentity.digest === undefined
|
|
988
|
+
? {}
|
|
989
|
+
: { candidateDigest: candidateIdentity.digest }),
|
|
990
|
+
...(!submitted && volumeSubmissionAttempted
|
|
991
|
+
? { candidateFailure: 'submit-failed' as const }
|
|
992
|
+
: {}),
|
|
993
|
+
};
|
|
994
|
+
frameState.volumetricFogInspection = inspectVolumetricFog({
|
|
995
|
+
authored,
|
|
996
|
+
capability,
|
|
997
|
+
degraded: fog.status === 'degraded' || capability === 'unavailable',
|
|
998
|
+
...(candidateRecovery === undefined ? {} : { recovery: candidateRecovery }),
|
|
999
|
+
...(acceptedLkg?.digest === undefined ? {} : { acceptedDigest: acceptedLkg.digest }),
|
|
1000
|
+
...(fog.densityAsset?.format === undefined
|
|
1001
|
+
? acceptedLkg?.format === undefined
|
|
1002
|
+
? {}
|
|
1003
|
+
: { format: acceptedLkg.format }
|
|
1004
|
+
: { format: fog.densityAsset.format }),
|
|
1005
|
+
passCount: 0,
|
|
1006
|
+
sampleCount: 0,
|
|
1007
|
+
memoryBytes: 0,
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
|
|
297
1011
|
export function recordFrame(
|
|
298
1012
|
internals: RenderSystemInternals,
|
|
299
1013
|
world: World,
|
|
@@ -331,6 +1045,8 @@ export function recordFrame(
|
|
|
331
1045
|
fogSignature = '',
|
|
332
1046
|
environmentReady = true,
|
|
333
1047
|
transmissionDemand?: TransmissionDemand,
|
|
1048
|
+
volumetricFog?: ExtractedVolumetricFog,
|
|
1049
|
+
timingCapture?: GpuTimingCapture,
|
|
334
1050
|
): boolean {
|
|
335
1051
|
// The `try / finally` wrapper advances `frameState.frameNumber` exactly
|
|
336
1052
|
// once per `recordFrame` invocation regardless of which early-return
|
|
@@ -361,6 +1077,11 @@ export function recordFrame(
|
|
|
361
1077
|
);
|
|
362
1078
|
activeCameras = [makeZeroCameraFallbackSnapshot()];
|
|
363
1079
|
}
|
|
1080
|
+
let submitted = false;
|
|
1081
|
+
let volumeInspectionReady = false;
|
|
1082
|
+
let volumeSubmissionAttempted = false;
|
|
1083
|
+
let preparedVolumetricFogForInspection: VolumetricFogFrameContext | undefined;
|
|
1084
|
+
let volumeCapabilityForInspection = false;
|
|
364
1085
|
try {
|
|
365
1086
|
// Case D: N>1 Camera => fire onError, use first hit (D-S7).
|
|
366
1087
|
if (activeCameras.length > 1) {
|
|
@@ -494,6 +1215,64 @@ export function recordFrame(
|
|
|
494
1215
|
const effectiveShadowMapSize = resolveShadowMapSize(internals, lights);
|
|
495
1216
|
const effectiveSpotShadowMapSize = resolveSpotShadowMapSize(internals, lights);
|
|
496
1217
|
const graphShadowMapSize = effectiveShadowMapSize ?? effectiveSpotShadowMapSize;
|
|
1218
|
+
const volumeCapability = hasVolumetricFogCapability(
|
|
1219
|
+
internals.device.caps,
|
|
1220
|
+
internals.volumetricFogShaders,
|
|
1221
|
+
);
|
|
1222
|
+
volumeInspectionReady = true;
|
|
1223
|
+
volumeCapabilityForInspection = volumeCapability;
|
|
1224
|
+
|
|
1225
|
+
const preparedSpotLightProjector = prepareSpotLightProjectorFrame(
|
|
1226
|
+
internals,
|
|
1227
|
+
world,
|
|
1228
|
+
worlds,
|
|
1229
|
+
lights,
|
|
1230
|
+
volumetricFog,
|
|
1231
|
+
);
|
|
1232
|
+
|
|
1233
|
+
const preparedVolumetricFog = prepareVolumetricFogFrame(
|
|
1234
|
+
internals,
|
|
1235
|
+
frameState,
|
|
1236
|
+
world,
|
|
1237
|
+
worlds,
|
|
1238
|
+
volumetricFog,
|
|
1239
|
+
lights,
|
|
1240
|
+
volumeCapability,
|
|
1241
|
+
preparedSpotLightProjector,
|
|
1242
|
+
);
|
|
1243
|
+
preparedVolumetricFogForInspection = preparedVolumetricFog;
|
|
1244
|
+
// A degraded authored snapshot must keep consuming the last accepted GPU
|
|
1245
|
+
// projection. The current source is still surfaced as a diagnostic, but
|
|
1246
|
+
// it must not replace the graph/context that produced the last good frame.
|
|
1247
|
+
// An absent component is different: a successful no-volume frame retires
|
|
1248
|
+
// the accepted projection and compiles the normal graph.
|
|
1249
|
+
const acceptedFallback =
|
|
1250
|
+
volumeCapability && volumetricFog !== undefined && preparedVolumetricFog === undefined
|
|
1251
|
+
? frameState.volumetricFogAccepted
|
|
1252
|
+
: undefined;
|
|
1253
|
+
const acceptedFallbackContext =
|
|
1254
|
+
acceptedFallback === undefined
|
|
1255
|
+
? undefined
|
|
1256
|
+
: stageAcceptedVolumetricFogFrame(
|
|
1257
|
+
internals,
|
|
1258
|
+
frameState,
|
|
1259
|
+
frameState.volumetricFogAcceptedContext,
|
|
1260
|
+
);
|
|
1261
|
+
const graphVolumetricFog = volumeCapability
|
|
1262
|
+
? preparedVolumetricFog === undefined
|
|
1263
|
+
? acceptedFallback
|
|
1264
|
+
: volumetricFog
|
|
1265
|
+
: undefined;
|
|
1266
|
+
const baseVolumetricFogContext = preparedVolumetricFog ?? acceptedFallbackContext;
|
|
1267
|
+
// A volume graph replacement is provisional whenever it can affect the
|
|
1268
|
+
// accepted volume projection. This includes authoring degradation (which
|
|
1269
|
+
// consumes the LKG graph), an authored replacement, and an explicit
|
|
1270
|
+
// fog-off transition. Non-volume frames retain the legacy immediate graph
|
|
1271
|
+
// retirement path.
|
|
1272
|
+
const volumeTopologyCandidate =
|
|
1273
|
+
(graphVolumetricFog?.status === 'available' && baseVolumetricFogContext !== undefined) ||
|
|
1274
|
+
(graphVolumetricFog === undefined && frameState.volumetricFogAccepted !== undefined) ||
|
|
1275
|
+
(volumetricFog?.status === 'degraded' && acceptedFallback !== undefined);
|
|
497
1276
|
|
|
498
1277
|
let preparedGpuDriven: PreparedGpuDrivenFrame | undefined;
|
|
499
1278
|
if (gpuDriven !== undefined) {
|
|
@@ -533,6 +1312,8 @@ export function recordFrame(
|
|
|
533
1312
|
featureGraphCandidate,
|
|
534
1313
|
cameras.length === 0,
|
|
535
1314
|
transmissionDemand,
|
|
1315
|
+
graphVolumetricFog,
|
|
1316
|
+
volumeTopologyCandidate,
|
|
536
1317
|
),
|
|
537
1318
|
);
|
|
538
1319
|
if (preflightGraph === null) return false;
|
|
@@ -623,6 +1404,35 @@ export function recordFrame(
|
|
|
623
1404
|
: { kind: 'taa', view: temporalView };
|
|
624
1405
|
const recordCamera: CameraSnapshot =
|
|
625
1406
|
temporalView === undefined ? camera : { ...camera, temporal: temporalView };
|
|
1407
|
+
let activeVolumetricFogContext: VolumetricFogFrameContext | undefined;
|
|
1408
|
+
let activeVolumetricFogSignature: VolumeTemporalSignature | undefined;
|
|
1409
|
+
if (graphVolumetricFog?.status === 'available' && baseVolumetricFogContext !== undefined) {
|
|
1410
|
+
const resolvedHistory = resolveVolumetricFogHistoryContext(
|
|
1411
|
+
frameState,
|
|
1412
|
+
preflightGraph,
|
|
1413
|
+
graphVolumetricFog,
|
|
1414
|
+
baseVolumetricFogContext,
|
|
1415
|
+
camera,
|
|
1416
|
+
lights,
|
|
1417
|
+
targetW,
|
|
1418
|
+
targetH,
|
|
1419
|
+
);
|
|
1420
|
+
activeVolumetricFogContext = resolvedHistory.context;
|
|
1421
|
+
activeVolumetricFogSignature = resolvedHistory.signature;
|
|
1422
|
+
const historyFlag = new Float32Array([activeVolumetricFogContext.historyValid ? 1 : 0]);
|
|
1423
|
+
if (frameState.volumetricFogPendingParams !== undefined) {
|
|
1424
|
+
frameState.volumetricFogPendingParams[31] = historyFlag[0] ?? 0;
|
|
1425
|
+
}
|
|
1426
|
+
const historyFlagWritten = internals.device.queue.writeBuffer(
|
|
1427
|
+
activeVolumetricFogContext.paramsBuffer,
|
|
1428
|
+
31 * Float32Array.BYTES_PER_ELEMENT,
|
|
1429
|
+
historyFlag,
|
|
1430
|
+
);
|
|
1431
|
+
if (!historyFlagWritten.ok) {
|
|
1432
|
+
internals.errorRegistry.fire(historyFlagWritten.error);
|
|
1433
|
+
return false;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
626
1436
|
|
|
627
1437
|
const depthView: TextureView | null = null;
|
|
628
1438
|
// feat-20260709 M3 / D-3: clear-color read from the active CameraSnapshot's
|
|
@@ -772,7 +1582,18 @@ export function recordFrame(
|
|
|
772
1582
|
// feat-20260531-skybox-env-background M2 / w6: gate relaxed from
|
|
773
1583
|
// `validatedOrdered.length > 0` to include skybox-only frames
|
|
774
1584
|
// (plan-strategy D-3, R-3).
|
|
775
|
-
|
|
1585
|
+
const currentViewProjection = mat4.create();
|
|
1586
|
+
mat4.multiply(
|
|
1587
|
+
currentViewProjection,
|
|
1588
|
+
computeProjectionMatrix(camera),
|
|
1589
|
+
computeViewMatrix(camera),
|
|
1590
|
+
);
|
|
1591
|
+
if (
|
|
1592
|
+
validatedOrdered.length > 0 ||
|
|
1593
|
+
skyboxActive ||
|
|
1594
|
+
activeVolumetricFogContext !== undefined ||
|
|
1595
|
+
preparedVolumetricFog !== undefined
|
|
1596
|
+
) {
|
|
776
1597
|
runRecordProfilePhase(profilePhase, 'record/uploads', () => {
|
|
777
1598
|
// View UBO + CSM/spot-shadow matrix pack: assembled + uploaded in one
|
|
778
1599
|
// queue.writeBuffer round-trip. Extracted to view-ubo.ts (M3/w18) so
|
|
@@ -922,6 +1743,15 @@ export function recordFrame(
|
|
|
922
1743
|
geometryColorResolveView,
|
|
923
1744
|
ldrSpriteColorView,
|
|
924
1745
|
postProcessParams: framePostProcessParams,
|
|
1746
|
+
...(internals.volumetricFogShaders === undefined
|
|
1747
|
+
? {}
|
|
1748
|
+
: { volumetricFogShaders: internals.volumetricFogShaders }),
|
|
1749
|
+
...(activeVolumetricFogContext === undefined
|
|
1750
|
+
? {}
|
|
1751
|
+
: { volumetricFog: activeVolumetricFogContext }),
|
|
1752
|
+
...(preparedSpotLightProjector === undefined
|
|
1753
|
+
? {}
|
|
1754
|
+
: { spotLightProjector: preparedSpotLightProjector }),
|
|
925
1755
|
dispatch: transparentDispatch,
|
|
926
1756
|
hdrpClusterBindGroup,
|
|
927
1757
|
hdrpClusterMembershipBindGroup,
|
|
@@ -935,7 +1765,21 @@ export function recordFrame(
|
|
|
935
1765
|
directionalShadowCacheReuse: directionalShadowCache.reuse,
|
|
936
1766
|
...(profilePhase !== undefined ? { profilePhase } : {}),
|
|
937
1767
|
};
|
|
938
|
-
const
|
|
1768
|
+
const stagedTemporalFrame = {
|
|
1769
|
+
frameId: frameState.frameNumber,
|
|
1770
|
+
currentViewProjection: new Float32Array(currentViewProjection),
|
|
1771
|
+
jitter: [0, 0] as [number, number],
|
|
1772
|
+
viewport: { width: targetW, height: targetH },
|
|
1773
|
+
cameraPosition: [
|
|
1774
|
+
camera.position[0] ?? 0,
|
|
1775
|
+
camera.position[1] ?? 0,
|
|
1776
|
+
camera.position[2] ?? 0,
|
|
1777
|
+
] as [number, number, number],
|
|
1778
|
+
};
|
|
1779
|
+
frameState.temporalFrameInput = stagedTemporalFrame;
|
|
1780
|
+
frameState.temporalFrameTransaction.stage(stagedTemporalFrame);
|
|
1781
|
+
volumeSubmissionAttempted = volumeTopologyCandidate || activeVolumetricFogContext !== undefined;
|
|
1782
|
+
submitted = runRecordProfilePhase(profilePhase, 'record/graph-execute', () =>
|
|
939
1783
|
executeCompiledFrameGraph(
|
|
940
1784
|
internals,
|
|
941
1785
|
frameState,
|
|
@@ -944,6 +1788,7 @@ export function recordFrame(
|
|
|
944
1788
|
profilePhase === undefined
|
|
945
1789
|
? undefined
|
|
946
1790
|
: (pass, encode) => profilePhase(graphExecutionPhase(pass.name), encode),
|
|
1791
|
+
timingCapture,
|
|
947
1792
|
),
|
|
948
1793
|
);
|
|
949
1794
|
if (submitted && !directionalShadowCache.reuse) {
|
|
@@ -951,8 +1796,66 @@ export function recordFrame(
|
|
|
951
1796
|
? directionalShadowCache.next
|
|
952
1797
|
: null;
|
|
953
1798
|
}
|
|
1799
|
+
if (submitted) {
|
|
1800
|
+
if (graphVolumetricFog === undefined) {
|
|
1801
|
+
frameState.volumetricFogAccepted = undefined;
|
|
1802
|
+
frameState.volumetricFogAcceptedContext = undefined;
|
|
1803
|
+
frameState.volumetricFogHistoryGraph = null;
|
|
1804
|
+
frameState.volumetricFogHistorySlot = null;
|
|
1805
|
+
frameState.volumetricFogHistorySignature = null;
|
|
1806
|
+
retireVolumetricFogParams(internals, frameState);
|
|
1807
|
+
} else if (
|
|
1808
|
+
activeVolumetricFogContext !== undefined &&
|
|
1809
|
+
activeVolumetricFogSignature !== undefined
|
|
1810
|
+
) {
|
|
1811
|
+
// Temporal history is graph-owned state. Promote the write slot only
|
|
1812
|
+
// after queue submission succeeds; a failed candidate keeps the
|
|
1813
|
+
// accepted graph/context/slot and retries into the same pending slot.
|
|
1814
|
+
frameState.volumetricFogHistoryGraph = preflightGraph;
|
|
1815
|
+
frameState.volumetricFogHistorySlot = activeVolumetricFogContext.historyWriteSlot;
|
|
1816
|
+
frameState.volumetricFogHistorySignature = activeVolumetricFogSignature;
|
|
1817
|
+
if (frameState.volumetricFogParamsPendingSlot !== null) {
|
|
1818
|
+
frameState.volumetricFogParamsAcceptedSlot = frameState.volumetricFogParamsPendingSlot;
|
|
1819
|
+
frameState.volumetricFogAcceptedParams =
|
|
1820
|
+
frameState.volumetricFogPendingParams === undefined
|
|
1821
|
+
? undefined
|
|
1822
|
+
: new Float32Array(frameState.volumetricFogPendingParams);
|
|
1823
|
+
frameState.volumetricFogParamsPendingSlot = null;
|
|
1824
|
+
frameState.volumetricFogPendingParams = undefined;
|
|
1825
|
+
}
|
|
1826
|
+
if (volumetricFog?.status === 'available' && preparedVolumetricFog !== undefined) {
|
|
1827
|
+
frameState.volumetricFogAccepted = volumetricFog;
|
|
1828
|
+
frameState.volumetricFogAcceptedContext = activeVolumetricFogContext;
|
|
1829
|
+
} else if (acceptedFallback !== undefined) {
|
|
1830
|
+
// The current authored source is degraded, so keep the old accepted
|
|
1831
|
+
// POD while advancing the LKG graph's ping-pong slot.
|
|
1832
|
+
frameState.volumetricFogAcceptedContext = activeVolumetricFogContext;
|
|
1833
|
+
}
|
|
1834
|
+
} else if (volumetricFog?.status === 'available' && preparedVolumetricFog !== undefined) {
|
|
1835
|
+
// Defensive fallback for a volume graph that was accepted before the
|
|
1836
|
+
// temporal context could be resolved; do not manufacture history.
|
|
1837
|
+
frameState.volumetricFogAccepted = volumetricFog;
|
|
1838
|
+
frameState.volumetricFogAcceptedContext = preparedVolumetricFog;
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
954
1841
|
return submitted;
|
|
955
1842
|
} finally {
|
|
1843
|
+
// Volume topology replacements are provisional until the queue submission
|
|
1844
|
+
// succeeds. This runs for every early-return path as well, so a swapchain,
|
|
1845
|
+
// encoder, or graph failure cannot leave a non-submitted candidate as the
|
|
1846
|
+
// next frame's accepted graph.
|
|
1847
|
+
settleVolumetricFogGraphCandidate(frameState, submitted);
|
|
1848
|
+
if (volumeInspectionReady && (submitted || volumeSubmissionAttempted)) {
|
|
1849
|
+
updateVolumetricFogInspection(
|
|
1850
|
+
frameState,
|
|
1851
|
+
volumetricFog,
|
|
1852
|
+
lights,
|
|
1853
|
+
preparedVolumetricFogForInspection,
|
|
1854
|
+
submitted,
|
|
1855
|
+
volumeCapabilityForInspection,
|
|
1856
|
+
volumeSubmissionAttempted,
|
|
1857
|
+
);
|
|
1858
|
+
}
|
|
956
1859
|
frameState.frameNumber += 1;
|
|
957
1860
|
// feat-20260608-cluster-lighting M5 / w22: clear HDRP once-per-frame fired
|
|
958
1861
|
// set so the next frame re-fires if the condition persists.
|