using System.Diagnostics;
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Packs;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb;
using DatReaderWriter.Enums;
namespace AcDream.App.Rendering;
///
/// Campaign VM VM6: the caster's own set 3/binding 5 AtmosphericFrame slice
/// (192 bytes, ABI v2 — see RenderPackShaderAbi.AtmosphericFrameSizeBytes),
/// owned and allocated by the graph exactly like the world receiver's frame
/// block, so the caster can read the identical foliage-wind clock/amplitude
/// inputs through the shared foliage_wind.glsl include. Unbound (the
/// default) when the caller has no atmospheric-frame data to offer — the
/// declared-pack graph does not build one, and the renderer simply skips
/// binding 5 for that caller rather than manufacturing a buffer nobody asked
/// for.
///
internal readonly record struct AtmosphericFrameBufferBinding(
IGpuBuffer? Buffer,
uint OffsetBytes,
uint SizeBytes)
{
internal bool IsBound => Buffer is not null;
}
internal readonly record struct DirectionalSunShadowRenderInput(
DirectionalShadowEnvironmentInput Environment,
Matrix4x4 CameraView,
Matrix4x4 CameraProjection,
DirectionalShadowCasterFrame Casters,
float CameraNearMeters = 0.1f,
float CasterDepthPaddingMeters = 48f,
float ResidentMaximumReachMeters = float.PositiveInfinity,
bool MeasureGpuTimers = true,
bool MeasureCpuStages = false,
AtmosphericFrameBufferBinding AtmosphericFrame = default,
// #429 owner-approved pipelining: false keeps the retained caster/draw
// topology this frame (transform refresh only) so the rebuild lands on a
// quieter frame. The prepare seams below re-validate and rebuild anyway
// whenever deferral would be unsafe.
bool AllowTopologyRebuild = true);
internal readonly record struct DirectionalSunShadowCpuStageTicks(
long EnvironmentGateTicks,
long PreparedDrawsAndTransformsTicks,
long FitAndUniformTicks,
long LayeredPassRecordingTicks,
long BookkeepingTicks);
internal readonly record struct DirectionalShadowTransformChurnDiagnostics(
int CopiedSceneChanges,
int UpdateTransformChanges,
int UpdateAppearanceChanges,
int DynamicSynchronizationChanges,
int ActiveAnimatedStaticChanges,
int LiveDynamicRootChanges,
int EquippedChildChanges,
int DedupedCasterSlots,
bool SceneJournalFullRefresh,
bool DensityBulkRefresh,
int BatchedProjectionCopyCalls,
int ChangedMatrixSlots,
int FlightCurrentChangedMatrices,
int FlightPendingReplayMatrices,
int FlightUploadedMatrices,
int FlightUploadRanges,
long FlightBytesWritten,
bool FlightFullDynamicFallback,
bool DenseDirectUpload,
bool DenseFlightReplay,
DirectionalShadowCasterClassDiagnostics CasterClasses = default);
internal readonly record struct DirectionalSunShadowDiagnostics(
DirectionalShadowGateReason GateReason,
float Strength,
int CascadeCount,
int DrawCalls,
int WorldOpaqueCommands,
int WorldAlphaCutoutCommands,
int TerrainCommands,
ulong WorldPreparationSequence,
ulong TerrainPreparationSequence,
double CpuMilliseconds,
double LastResolvedGpuMilliseconds,
bool HasResolvedGpuMeasurement,
long ResidentDepthBytes,
DirectionalSunShadowCpuStageTicks CpuStages = default,
DirectionalShadowTransformChurnDiagnostics TransformChurn = default,
AuthoredCelestialShadowSourceKind SourceKind =
AuthoredCelestialShadowSourceKind.None,
int SourceObjectIndex = -1,
uint SourceGfxObjId = 0u,
Vector3 SurfaceToLightDirection = default,
float LightElevationSin = 0f);
internal static class DirectionalShadowBatchFlags
{
internal const uint AlphaCutout = 1u << 0;
internal static uint Encode(DirectionalShadowCasterMaterial material) =>
material is DirectionalShadowCasterMaterial.AlphaCutout
? AlphaCutout
: 0u;
}
///
/// Tier-2 producer only: fits selected celestial-light cascades and records
/// their depth maps.
/// It does not alter the retail world pass or sample shadows in receivers.
///
internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverSource, IDisposable
{
internal const string TimerPrefix = "directional-shadow-cascade-";
internal const string MultiviewTimerName = "directional-shadow-multiview";
internal const uint LowMultiviewMask = 0b11;
private const int DrawCommandStride = 20;
private readonly IGpuDevice _device;
private readonly DirectionalShadowQuality _quality;
private readonly DirectionalShadowAtmospherePolicy _atmospherePolicy;
private readonly DirectionalShadowPipelineShaders _pipelineShaders;
private readonly bool _multiviewCascades;
private readonly IGpuDirectionalDepthTarget _target;
private readonly IGpuSampler _sampler;
private readonly GpuTextureSlot _textureSlot;
private readonly IGpuPipeline _terrainPipeline;
private readonly IGpuPipeline _worldOpaquePipeline;
private readonly IGpuPipeline _worldCutoutPipeline;
private readonly IGpuPipeline? _terrainMultiviewPipeline;
private readonly IGpuPipeline? _worldOpaqueMultiviewPipeline;
private readonly IGpuPipeline? _worldCutoutMultiviewPipeline;
private readonly DirectionalShadowTransformBufferSet _transformBuffers;
private readonly DirectionalShadowCascade[] _cascades = new DirectionalShadowCascade[4];
private DirectionalShadowBatchGpuData[] _batchScratch = [];
private IGpuBuffer? _worldBatchBuffer;
private IGpuBuffer? _worldCommandBuffer;
private IGpuBuffer? _terrainCommandBuffer;
private ulong _worldGpuBuildSequence;
private ulong _terrainGpuBuildSequence;
private DirectionalShadowFrameBinding _currentFrameBinding;
private bool _disposed;
internal DirectionalSunShadowRenderer(
IGpuDevice device,
DirectionalShadowPreset preset,
DirectionalShadowAtmospherePolicy? atmospherePolicy = null,
DirectionalShadowPipelineShaders? pipelineShaders = null,
bool multiviewCascades = false)
: this(
device,
DirectionalShadowQuality.For(preset),
atmospherePolicy,
pipelineShaders,
multiviewCascades)
{
}
internal DirectionalSunShadowRenderer(
IGpuDevice device,
DirectionalShadowQuality quality,
DirectionalShadowAtmospherePolicy? atmospherePolicy = null,
DirectionalShadowPipelineShaders? pipelineShaders = null,
bool multiviewCascades = false)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
if (quality.CascadeCount is < 1 or > 4
|| quality.MapResolution <= 0
|| !float.IsFinite(quality.MaximumReachMeters)
|| quality.MaximumReachMeters <= 0f
|| quality.PcfRadiusTexels is < 0 or > 2)
{
throw new ArgumentOutOfRangeException(
nameof(quality),
"Directional-shadow quality must declare 1..4 cascades, a positive "
+ "resolution/reach, and a 0..2 PCF radius.");
}
_quality = quality;
_atmospherePolicy = atmospherePolicy ?? DirectionalShadowAtmospherePolicy.BuiltIn;
_pipelineShaders = pipelineShaders ?? DirectionalShadowPipelineShaders.Local;
_multiviewCascades = multiviewCascades;
if (multiviewCascades && quality.CascadeCount != 2)
throw new NotSupportedException("The multiview shadow hint requires exactly two Low cascades.");
if (multiviewCascades && !device.Capabilities.SupportsMultiview)
throw new NotSupportedException("The selected device does not support multiview shadow cascades.");
if (multiviewCascades && _pipelineShaders.MultiviewCasters is null)
throw new NotSupportedException("The pack did not declare all multiview shadow caster variants.");
IGpuDirectionalDepthTarget? target = null;
IGpuSampler? sampler = null;
GpuTextureSlot textureSlot = GpuTextureSlot.Unassigned;
IGpuPipeline? terrain = null;
IGpuPipeline? opaque = null;
IGpuPipeline? cutout = null;
IGpuPipeline? terrainMultiview = null;
IGpuPipeline? opaqueMultiview = null;
IGpuPipeline? cutoutMultiview = null;
DirectionalShadowTransformBufferSet? transformBuffers = null;
try
{
target = device.CreateDirectionalDepthTarget(
new GpuDirectionalDepthTargetDescription(
$"directional-shadow-{quality.Preset.ToString().ToLowerInvariant()}",
_quality.MapResolution,
_quality.CascadeCount));
sampler = device.CreateSampler(GpuSamplerDescription.ShadowNearestClamp);
textureSlot = device.RegisterTexture(target.DepthTexture, sampler);
terrain = CreatePipeline(
device,
"directional-shadow-terrain",
_pipelineShaders.TerrainCaster,
TerrainModernRenderer.TerrainVertexLayout,
GpuFrontFace.CounterClockwise);
opaque = CreatePipeline(
device,
"directional-shadow-world-opaque",
_pipelineShaders.WorldOpaqueCaster,
GpuVertexLayout.WorldMesh,
GpuFrontFace.Clockwise);
cutout = CreatePipeline(
device,
"directional-shadow-world-cutout",
_pipelineShaders.WorldAlphaCutoutCaster,
GpuVertexLayout.WorldMesh,
GpuFrontFace.Clockwise);
if (multiviewCascades)
{
DirectionalShadowMultiviewPipelineShaders shaders =
_pipelineShaders.MultiviewCasters!.Value;
terrainMultiview = CreatePipeline(device, "directional-shadow-terrain-multiview",
shaders.TerrainCaster, TerrainModernRenderer.TerrainVertexLayout,
GpuFrontFace.CounterClockwise, LowMultiviewMask);
opaqueMultiview = CreatePipeline(device, "directional-shadow-world-opaque-multiview",
shaders.WorldOpaqueCaster, GpuVertexLayout.WorldMesh,
GpuFrontFace.Clockwise, LowMultiviewMask);
cutoutMultiview = CreatePipeline(device, "directional-shadow-world-cutout-multiview",
shaders.WorldAlphaCutoutCaster, GpuVertexLayout.WorldMesh,
GpuFrontFace.Clockwise, LowMultiviewMask);
}
transformBuffers = new DirectionalShadowTransformBufferSet(device);
}
catch
{
transformBuffers?.Dispose();
cutoutMultiview?.Dispose();
opaqueMultiview?.Dispose();
terrainMultiview?.Dispose();
cutout?.Dispose();
opaque?.Dispose();
terrain?.Dispose();
if (textureSlot.IsAssigned)
device.ReleaseTextureSlot(textureSlot);
sampler?.Dispose();
target?.Dispose();
throw;
}
_target = target;
_sampler = sampler;
_textureSlot = textureSlot;
_terrainPipeline = terrain;
_worldOpaquePipeline = opaque;
_worldCutoutPipeline = cutout;
_terrainMultiviewPipeline = terrainMultiview;
_worldOpaqueMultiviewPipeline = opaqueMultiview;
_worldCutoutMultiviewPipeline = cutoutMultiview;
_transformBuffers = transformBuffers;
}
internal DirectionalShadowQuality Quality => _quality;
internal bool MultiviewCascadesEnabled => _multiviewCascades;
internal static string TimerName(int cascadeIndex) => cascadeIndex switch
{
0 => "directional-shadow-cascade-0",
1 => "directional-shadow-cascade-1",
2 => "directional-shadow-cascade-2",
3 => "directional-shadow-cascade-3",
_ => throw new ArgumentOutOfRangeException(nameof(cascadeIndex)),
};
internal IGpuTexture DepthTexture => _target.DepthTexture;
internal GpuTextureSlot TextureSlot => _textureSlot;
public DirectionalShadowPipelineShaders PipelineShaders => _pipelineShaders;
internal DirectionalShadowFrameBinding CurrentFrameBinding => _currentFrameBinding;
///
/// Topology-only command metadata lives in pack-owned device-local buffers.
/// It is rebuilt transactionally when the retained CPU product changes and
/// is never copied through a per-frame ring on a stable scene.
///
internal long RetainedCommandBufferBytes => checked(
(_worldBatchBuffer?.SizeBytes ?? 0L)
+ (_worldCommandBuffer?.SizeBytes ?? 0L)
+ (_terrainCommandBuffer?.SizeBytes ?? 0L));
internal int RetainedCommandBufferCount =>
(_worldBatchBuffer is null ? 0 : 1)
+ (_worldCommandBuffer is null ? 0 : 1)
+ (_terrainCommandBuffer is null ? 0 : 1);
internal long RetainedGpuBufferBytes => checked(
RetainedCommandBufferBytes + _transformBuffers.RetainedGpuBytes);
internal int RetainedGpuBufferCount => checked(
RetainedCommandBufferCount + _transformBuffers.BufferCount);
public bool TryGetCurrentFrameBinding(
IGpuFrame frame,
out DirectionalShadowFrameBinding binding)
{
ArgumentNullException.ThrowIfNull(frame);
binding = _currentFrameBinding;
return !_disposed && binding.IsBindableFor(frame);
}
///
/// Campaign VM VM6 review fix round 5 (T1): the gate prologue Render
/// runs before it touches world/terrain, extracted into its own
/// internal method so a hermetic test can drive it directly —
/// constructing a real WbDrawDispatcher/TerrainModernRenderer pair
/// just to reach these two early-out paths is not a cheap test (see
/// PublishDisabledReceiverBinding's own doc comment). Returns the
/// disabled diagnostics Render should return immediately, or null when
/// the gate passed and Render should continue (in which case
/// and
/// are the validated values
/// Render's remaining stages use). Behaviour-preserving: identical
/// order, identical PublishDisabledReceiverBinding calls, identical
/// Disabled(...) construction as before the extraction. Round 6 (N4):
/// self-contained — resets itself
/// on entry instead of relying on the caller to have done it first,
/// so a future direct caller of this method (not just Render) cannot
/// observe a stale binding from a previous frame. Idempotent with
/// Render's own reset immediately before calling this.
///
internal DirectionalSunShadowDiagnostics? EvaluateGateAndPublishDisabledBinding(
IGpuFrame frame,
in DirectionalSunShadowRenderInput input,
out DirectionalShadowEnvironmentState environment,
out long environmentGateTicks)
{
_currentFrameBinding = DirectionalShadowFrameBinding.Disabled;
long cpuStageStarted = input.MeasureCpuStages ? Stopwatch.GetTimestamp() : 0L;
environment = DirectionalShadowEnvironmentGate.Evaluate(
input.Environment,
_atmospherePolicy);
environmentGateTicks = input.MeasureCpuStages
? Stopwatch.GetTimestamp() - cpuStageStarted
: 0L;
if (!environment.ShouldRender)
{
PublishDisabledReceiverBinding(frame, input.AtmosphericFrame);
return Disabled(
in environment,
new DirectionalSunShadowCpuStageTicks(
environmentGateTicks, 0L, 0L, 0L, 0L));
}
if (input.ResidentMaximumReachMeters <= input.CameraNearMeters)
{
environment = environment with
{
Reason = DirectionalShadowGateReason.ResidentWindowUnavailable,
};
PublishDisabledReceiverBinding(frame, input.AtmosphericFrame);
return Disabled(
in environment,
new DirectionalSunShadowCpuStageTicks(
environmentGateTicks, 0L, 0L, 0L, 0L));
}
return null;
}
internal DirectionalSunShadowDiagnostics Render(
IGpuFrame frame,
in DirectionalSunShadowRenderInput input,
WbDrawDispatcher world,
TerrainModernRenderer terrain)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(frame);
_currentFrameBinding = DirectionalShadowFrameBinding.Disabled;
ArgumentNullException.ThrowIfNull(world);
ArgumentNullException.ThrowIfNull(terrain);
DirectionalSunShadowDiagnostics? gated = EvaluateGateAndPublishDisabledBinding(
frame,
in input,
out DirectionalShadowEnvironmentState environment,
out long environmentGateTicks);
if (gated is not null)
return gated.Value;
long cpuStageStarted = input.MeasureCpuStages ? Stopwatch.GetTimestamp() : 0L;
DirectionalShadowPreparedDraws worldDraws =
world.PrepareDirectionalShadowDraws(
input.Casters,
input.AllowTopologyRebuild);
DirectionalShadowTerrainPreparedDraws terrainDraws =
terrain.PrepareDirectionalShadowDraws();
DirectionalShadowMeshGeometry? worldGeometry =
worldDraws.Commands.IsEmpty ? null : world.GetDirectionalShadowGeometry();
DirectionalShadowTerrainGeometry? terrainGeometry =
terrainDraws.Commands.IsEmpty ? null : terrain.GetDirectionalShadowGeometry();
uint transformBindingSizeBytes =
world.ResolveDirectionalShadowTransformBindingSize(
worldDraws.Transforms.Length,
// Stats counts every current WB source render batch before
// transparent/cutout shadow rejection. Ordinary WB submission
// publishes at most one matrix per source batch, making this a
// complete-frame upper bound available before shadow commands
// bind the one authoritative pose buffer.
worldDraws.Stats.SourceBatches);
WorldTransformFrameSlice retainedTransforms = _transformBuffers.Publish(
frame,
worldDraws.BuildSequence,
worldDraws.Transforms,
worldDraws.DynamicTransformSlots,
worldDraws.AllDynamicTransformSlots,
worldDraws.LastDynamicTransformRefreshWasDense,
transformBindingSizeBytes);
WorldTransformFrameSlice transforms =
world.BeginDirectionalShadowTransformFrame(
frame,
in retainedTransforms);
DirectionalShadowCasterBuildStats casterStats = input.Casters.Stats;
DirectionalShadowCasterClassDiagnostics casterClasses =
CompleteCasterClassDiagnostics(
in casterStats,
terrainDraws.Commands.Length);
DirectionalShadowTransformPublishStats publishStats =
_transformBuffers.LastStats;
var transformChurn = new DirectionalShadowTransformChurnDiagnostics(
casterStats.CopiedTransformChanges,
casterStats.UpdateTransformChanges,
casterStats.UpdateAppearanceChanges,
casterStats.DynamicSynchronizationChanges,
casterStats.ActiveAnimatedStaticChanges,
casterStats.LiveDynamicRootChanges,
casterStats.EquippedChildChanges,
casterStats.DedupedChangedCasterSlots,
casterStats.TransformJournalFullRefresh,
casterStats.DensityBulkRefresh,
casterStats.BatchedProjectionCopyCalls,
worldDraws.LastDynamicTransformRefreshCount,
publishStats.CurrentChangedMatrices,
publishStats.PendingReplayMatrices,
publishStats.DynamicMatricesUpdated,
publishStats.DynamicRangesUpdated,
publishStats.BytesWritten,
publishStats.UsedFullDynamicFallback,
publishStats.DenseDirectUpload,
publishStats.DenseFlightReplay,
casterClasses);
long preparedDrawsAndTransformsTicks = input.MeasureCpuStages
? Stopwatch.GetTimestamp() - cpuStageStarted
: 0L;
try
{
return RenderPrepared(
frame,
environment,
input.CameraView,
input.CameraProjection,
input.CameraNearMeters,
input.CasterDepthPaddingMeters,
worldDraws,
terrainDraws,
worldGeometry,
terrainGeometry,
transforms,
input.ResidentMaximumReachMeters,
input.MeasureGpuTimers,
input.MeasureCpuStages,
new DirectionalSunShadowCpuStageTicks(
environmentGateTicks,
preparedDrawsAndTransformsTicks,
0L,
0L,
0L),
transformChurn,
input.AtmosphericFrame);
}
catch
{
world.CancelDirectionalShadowTransformFrame(frame);
throw;
}
}
///
/// Campaign VM VM6 review fix round 4 (item 3): the offline pixel gate
/// found wind welded to "directional shadows rendered this frame" —
/// 's two early-disabled paths left
/// at its pure
/// default (no
/// buffer at all), so returned
/// false, the world receiver pipeline selection
/// (DirectionalShadowReceiverPolicy.ShouldSelectReceiverPipeline)
/// fell back to the plain mesh_modern/mesh_atmospheric-less
/// pipeline, and foliage_wind.glsl's include never ran — even though
/// the built-in pack's CPU wind state (ResolveFoliageWind) kept
/// ticking correctly the whole time. The shadow gate fires far more
/// often than "shadows visibly missing" suggests: every night
/// (elevation response goes to 0), at authored sun-shadow-strength 0,
/// indoors, and under the portal/login cover.
///
/// Fix: when the built-in pack supplies an AtmosphericFrame
/// binding (declared packs never do — their receiver shaders don't
/// declare set 3 binding 5, so they correctly keep today's plain-
/// pipeline behaviour when shadows are gated off), publish a REAL ring
/// allocation carrying a DISABLED shadow block — every matrix Identity,
/// every control/bias term zero, TextureAndFlags all zero (bit 0
/// clear is exactly what directional_shadow_receiver.glsl's
/// acdreamDirectionalShadowVisibility already reads as "no
/// shadow, full visibility" — see its early return 1.0), and a
/// UNIT light direction (never the zero vector a fragment shader's
/// normalize() could turn into NaN). This binding passes
/// (there IS
/// a current-frame allocation) but fails
/// (Enabled is
/// false, TextureSlot is Unassigned, CascadeCount is 0) — so the world
/// receiver pipeline runs (and reads correct wind data from the
/// AtmosphericFrame half of the same binding), while anything that
/// actually needs shadow content (VolumetricShaftRenderer's own gate)
/// still correctly reports no current directional shadow.
/// internal, not private: a hermetic test drives
/// this directly (constructing the full environment gate plus a real
/// WbDrawDispatcher/TerrainModernRenderer pair just to reach Render's
/// early-out paths is not a cheap test — no test in this suite
/// constructs either) instead of standing up that dependency chain.
///
internal void PublishDisabledReceiverBinding(
IGpuFrame frame,
AtmosphericFrameBufferBinding atmosphericFrame)
{
if (!atmosphericFrame.IsBound)
return;
var disabledUniforms = new DirectionalShadowUniforms(
Matrix4x4.Identity,
Matrix4x4.Identity,
Matrix4x4.Identity,
Matrix4x4.Identity,
Vector4.Zero,
Vector4.Zero,
Vector4.Zero,
new UInt4(0u, 0u, 0u, 0u),
// Campaign VM VM6 review fix round 5 (F1): (0,0,1) is a
// normalize()-cannot-NaN guard ONLY — round 5 fixed
// mesh_atmospheric.vert/terrain_atmospheric.vert to fall back
// to the plain pipeline's uLights-derived direction whenever
// this block's flags bit 0 is clear, so this vector is never
// actually read as a light direction any more. It stays a
// unit vector regardless, in case a future reader forgets that
// rule and reads it directly.
new Vector4(0f, 0f, 1f, 0f));
GpuRingAllocation allocation = frame.AllocateRing(
DirectionalShadowUniforms.SizeInBytes,
GpuRingUsage.Uniform);
MemoryMarshal.Write(allocation.Data, in disabledUniforms);
_currentFrameBinding = new DirectionalShadowFrameBinding(
frame.Serial,
Enabled: false,
allocation.Buffer,
allocation.OffsetBytes,
DirectionalShadowUniforms.SizeInBytes,
GpuTextureSlot.Unassigned,
CascadeCount: 0,
AtmosphericFrame: atmosphericFrame);
}
internal static DirectionalShadowCasterClassDiagnostics
CompleteCasterClassDiagnostics(
in DirectionalShadowCasterBuildStats casterStats,
int terrainCommandCount)
{
ArgumentOutOfRangeException.ThrowIfNegative(terrainCommandCount);
return casterStats.CasterClasses with
{
TerrainCommands = terrainCommandCount,
};
}
///
/// The fitted frustum slice bounds receivers, not the casters that project
/// onto them. At low celestial elevations a modest tree can be many metres
/// farther along the light ray than its visible ground shadow. Padding by
/// less than the effective receiver reach therefore clips a caster as the
/// camera-relative slice turns, producing partial or disappearing shadows.
/// This expands depth only; cascade XY density, draw count, and caster
/// membership are unchanged.
///
internal static float ResolveCasterDepthPaddingMeters(
float configuredPaddingMeters,
float qualityReachMeters,
float residentMaximumReachMeters)
{
if (!float.IsFinite(configuredPaddingMeters)
|| configuredPaddingMeters <= 0f)
{
throw new ArgumentOutOfRangeException(
nameof(configuredPaddingMeters));
}
if (!float.IsFinite(qualityReachMeters) || qualityReachMeters <= 0f)
throw new ArgumentOutOfRangeException(nameof(qualityReachMeters));
if (float.IsNaN(residentMaximumReachMeters)
|| residentMaximumReachMeters <= 0f)
{
throw new ArgumentOutOfRangeException(
nameof(residentMaximumReachMeters));
}
float effectiveReceiverReach = MathF.Min(
qualityReachMeters,
residentMaximumReachMeters);
return MathF.Max(configuredPaddingMeters, effectiveReceiverReach);
}
internal DirectionalSunShadowDiagnostics RenderPrepared(
IGpuFrame frame,
in DirectionalShadowEnvironmentState environment,
Matrix4x4 cameraView,
Matrix4x4 cameraProjection,
float cameraNearMeters,
float casterDepthPaddingMeters,
DirectionalShadowPreparedDraws worldDraws,
DirectionalShadowTerrainPreparedDraws terrainDraws,
DirectionalShadowMeshGeometry? worldGeometry,
DirectionalShadowTerrainGeometry? terrainGeometry,
WorldTransformFrameSlice worldTransforms,
float residentMaximumReachMeters = float.PositiveInfinity,
bool measureGpuTimers = true,
bool measureCpuStages = false,
DirectionalSunShadowCpuStageTicks cpuStages = default,
DirectionalShadowTransformChurnDiagnostics transformChurn = default,
AtmosphericFrameBufferBinding atmosphericFrame = default)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(frame);
_currentFrameBinding = DirectionalShadowFrameBinding.Disabled;
ArgumentNullException.ThrowIfNull(worldDraws);
ArgumentNullException.ThrowIfNull(terrainDraws);
if (!environment.ShouldRender)
{
// Campaign VM VM6 review fix round 6 (N4): a fourth bufferless-
// disabled exit, same shape as Render's own two and the
// cascadeCount == 0 exit below (F2). Unreachable via Render
// itself (its own gate already validated ShouldRender before
// calling here), but RenderPrepared is also called directly
// (tests, and any future caller) — one rule covers every exit
// on a frame that draws the world: publish when the pack's
// AtmosphericFrame is bound.
PublishDisabledReceiverBinding(frame, atmosphericFrame);
return Disabled(in environment, cpuStages);
}
if (!worldDraws.Commands.IsEmpty && worldGeometry is null)
throw new ArgumentNullException(nameof(worldGeometry));
if (!terrainDraws.Commands.IsEmpty && terrainGeometry is null)
throw new ArgumentNullException(nameof(terrainGeometry));
if (!worldTransforms.IsValidFor(frame))
throw new ArgumentException(
"Shadow transforms must use this frame's shared N.5 allocation.",
nameof(worldTransforms));
long started = Stopwatch.GetTimestamp();
float effectiveCasterDepthPaddingMeters =
ResolveCasterDepthPaddingMeters(
casterDepthPaddingMeters,
_quality.MaximumReachMeters,
residentMaximumReachMeters);
var fit = new DirectionalShadowCascadeFitInput(
cameraView,
cameraProjection,
environment.SurfaceToLightDirection,
_quality,
cameraNearMeters,
PracticalSplitLambda: 0.65f,
effectiveCasterDepthPaddingMeters,
residentMaximumReachMeters);
int cascadeCount = DirectionalShadowCascadeFitter.Fit(
fit,
_cascades);
if (cascadeCount == 0)
{
DirectionalShadowEnvironmentState unavailable = environment with
{
Reason = DirectionalShadowGateReason.ResidentWindowUnavailable,
};
// Campaign VM VM6 review fix round 5 (F2): a third bufferless-
// disabled exit reachable from a frame that already passed
// Render's own two gates (ShouldRender, the constructor-level
// ResidentMaximumReachMeters check) — the cascade fitter can
// still find zero usable cascades. Publish the same disabled
// receiver binding as Render's two early-outs so this path
// doesn't reintroduce the F1/round-4 bug for the rarer case
// where a frame draws the world but the shadow fitter itself
// bails out.
PublishDisabledReceiverBinding(frame, atmosphericFrame);
return Disabled(in unavailable, cpuStages);
}
ReadOnlySpan cascades =
_cascades.AsSpan(0, cascadeCount);
DirectionalShadowUniforms uniforms = DirectionalShadowUniforms.Create(
cascades,
environment,
_quality,
_textureSlot);
GpuRingAllocation uniformAllocation = frame.AllocateRing(
DirectionalShadowUniforms.SizeInBytes,
GpuRingUsage.Uniform);
MemoryMarshal.Write(uniformAllocation.Data, in uniforms);
long fitAndUniformFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
PreparedGpuUploads uploads = PrepareGpuData(
worldTransforms,
worldDraws,
terrainDraws);
if (MultiviewCascadesEnabled)
{
using IGpuPassEncoder encoder = frame.BeginPass(
GpuPassDescription.DirectionalDepthMultiview(
"directional-shadow-multiview",
_target,
LowMultiviewMask));
using IDisposable? timer = measureGpuTimers
? encoder.BeginTimerScope(MultiviewTimerName)
: null;
encoder.BindUniformBuffer(
GpuBindingModel.UniformDirectionalShadow,
uniformAllocation.Buffer,
uniformAllocation.OffsetBytes,
DirectionalShadowUniforms.SizeInBytes);
// Campaign VM VM6: the caster pass reads foliage-wind clock/
// amplitude inputs through the same shared AtmosphericFrame
// block (set 3/binding 5) the world receiver pass ALSO
// explicitly binds — this exact buffer/offset is carried
// forward on DirectionalShadowFrameBinding.AtmosphericFrame
// (review fix round A3) so WbDrawDispatcher.BindDirectionalShadowReceiver
// binds it itself rather than depending on this caster bind
// surviving un-reset until the receiver pass runs later in the
// frame. Bound only when the caller supplied one — declared
// (non-built-in) packs leave this unbound and their caster
// shaders, which never declare binding 5, are unaffected.
if (atmosphericFrame.IsBound)
{
encoder.BindUniformBuffer(
GpuBindingModel.UniformAtmosphericFrame,
atmosphericFrame.Buffer!,
atmosphericFrame.OffsetBytes,
atmosphericFrame.SizeBytes);
}
DrawTerrain(encoder, uploads, terrainDraws, terrainGeometry, 0,
_terrainMultiviewPipeline);
DrawWorld(encoder, uploads, worldDraws, worldGeometry, 0,
_worldOpaqueMultiviewPipeline, _worldCutoutMultiviewPipeline);
}
else for (int cascadeIndex = 0; cascadeIndex < cascadeCount; cascadeIndex++)
{
using IGpuPassEncoder encoder = frame.BeginPass(
GpuPassDescription.DirectionalDepth(
$"directional-shadow-{cascadeIndex}",
_target,
cascadeIndex));
using IDisposable? timer = measureGpuTimers
? encoder.BeginTimerScope(TimerName(cascadeIndex))
: null;
encoder.BindUniformBuffer(
GpuBindingModel.UniformDirectionalShadow,
uniformAllocation.Buffer,
uniformAllocation.OffsetBytes,
DirectionalShadowUniforms.SizeInBytes);
// Campaign VM VM6 — see the matching comment in the multiview
// branch above.
if (atmosphericFrame.IsBound)
{
encoder.BindUniformBuffer(
GpuBindingModel.UniformAtmosphericFrame,
atmosphericFrame.Buffer!,
atmosphericFrame.OffsetBytes,
atmosphericFrame.SizeBytes);
}
DrawTerrain(encoder, uploads, terrainDraws, terrainGeometry, cascadeIndex);
DrawWorld(encoder, uploads, worldDraws, worldGeometry, cascadeIndex);
}
long passRecordingFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
_currentFrameBinding = new DirectionalShadowFrameBinding(
frame.Serial,
Enabled: true,
uniformAllocation.Buffer,
uniformAllocation.OffsetBytes,
DirectionalShadowUniforms.SizeInBytes,
_textureSlot,
cascadeCount,
// Campaign VM VM6 review fix round (A3): the exact same
// buffer/offset the caster pass just bound above, carried
// forward so the receiver pass can bind it itself.
AtmosphericFrame: atmosphericFrame);
(bool hasGpu, double gpuMilliseconds) = ResolveGpu(cascadeCount);
int drawsPerCascade = terrainDraws.Commands.IsEmpty ? 0 : 1;
drawsPerCascade = checked(
drawsPerCascade
+ (worldDraws.Commands.IsEmpty
? 0
: worldDraws.OpaqueRuns.Length + worldDraws.AlphaCutoutRuns.Length));
long finished = Stopwatch.GetTimestamp();
cpuStages = cpuStages with
{
FitAndUniformTicks = measureCpuStages
? fitAndUniformFinished - started
: 0L,
LayeredPassRecordingTicks = measureCpuStages
? passRecordingFinished - fitAndUniformFinished
: 0L,
BookkeepingTicks = measureCpuStages
? finished - passRecordingFinished
: 0L,
};
return new DirectionalSunShadowDiagnostics(
DirectionalShadowGateReason.Enabled,
environment.Strength,
cascadeCount,
checked((MultiviewCascadesEnabled ? 1 : cascadeCount) * drawsPerCascade),
worldDraws.OpaqueCommandCount,
worldDraws.AlphaCutoutCommandCount,
terrainDraws.Commands.Length,
worldDraws.BuildSequence,
terrainDraws.BuildSequence,
(finished - started) * 1000d / Stopwatch.Frequency,
gpuMilliseconds,
hasGpu,
_quality.ApproximateDepthMapBytes,
cpuStages,
transformChurn,
environment.SourceKind,
environment.SourceObjectIndex,
environment.SourceGfxObjId,
environment.SurfaceToLightDirection,
environment.LightElevationSin);
}
private PreparedGpuUploads PrepareGpuData(
in WorldTransformFrameSlice transforms,
DirectionalShadowPreparedDraws world,
DirectionalShadowTerrainPreparedDraws terrain)
{
if (_worldGpuBuildSequence != world.BuildSequence)
RebuildWorldGpuData(world);
if (_terrainGpuBuildSequence != terrain.BuildSequence)
RebuildTerrainGpuData(terrain);
return new PreparedGpuUploads(
transforms,
Slice(_worldBatchBuffer),
Slice(_worldCommandBuffer),
Slice(_terrainCommandBuffer));
}
private void RebuildWorldGpuData(DirectionalShadowPreparedDraws world)
{
IGpuBuffer? batches = null;
IGpuBuffer? commands = null;
try
{
if (!world.Commands.IsEmpty)
{
EnsureBatchCapacity(world.Batches.Length);
for (int i = 0; i < world.Batches.Length; i++)
{
DirectionalShadowPreparedBatch batch = world.Batches[i];
_batchScratch[i] = new DirectionalShadowBatchGpuData(
batch.TextureSlot.Index,
0u,
batch.TextureLayer,
// Campaign VM VM6: batch.FoliageFlags rides alongside
// the existing alpha-cutout bit in the same word the
// caster shaders read as BatchData.flags.
DirectionalShadowBatchFlags.Encode(batch.Material)
| batch.FoliageFlags);
}
ReadOnlySpan batchBytes = MemoryMarshal.AsBytes(
_batchScratch.AsSpan(0, world.Batches.Length));
ReadOnlySpan commandBytes = MemoryMarshal.AsBytes(
world.Commands);
batches = CreateRetainedBuffer(
$"directional-shadow-world-batches-{world.BuildSequence}",
batchBytes,
GpuBufferUsage.Storage);
commands = CreateRetainedBuffer(
$"directional-shadow-world-commands-{world.BuildSequence}",
commandBytes,
GpuBufferUsage.Indirect);
}
}
catch
{
commands?.Dispose();
batches?.Dispose();
throw;
}
IGpuBuffer? previousBatches = _worldBatchBuffer;
IGpuBuffer? previousCommands = _worldCommandBuffer;
_worldBatchBuffer = batches;
_worldCommandBuffer = commands;
_worldGpuBuildSequence = world.BuildSequence;
previousCommands?.Dispose();
previousBatches?.Dispose();
}
private void RebuildTerrainGpuData(DirectionalShadowTerrainPreparedDraws terrain)
{
IGpuBuffer? commands = null;
if (!terrain.Commands.IsEmpty)
{
commands = CreateRetainedBuffer(
$"directional-shadow-terrain-commands-{terrain.BuildSequence}",
MemoryMarshal.AsBytes(terrain.Commands),
GpuBufferUsage.Indirect);
}
IGpuBuffer? previous = _terrainCommandBuffer;
_terrainCommandBuffer = commands;
_terrainGpuBuildSequence = terrain.BuildSequence;
previous?.Dispose();
}
private IGpuBuffer CreateRetainedBuffer(
string name,
ReadOnlySpan contents,
GpuBufferUsage usage)
{
if (contents.IsEmpty)
throw new ArgumentException("Retained shadow buffers cannot be empty.", nameof(contents));
IGpuBuffer buffer = _device.CreateBuffer(new GpuBufferDescription(
name,
contents.Length,
usage | GpuBufferUsage.TransferDestination,
GpuMemoryResidency.DeviceLocal));
try
{
buffer.Upload(0, contents);
return buffer;
}
catch
{
buffer.Dispose();
throw;
}
}
private static RetainedGpuBufferSlice Slice(IGpuBuffer? buffer) =>
new(buffer, 0u, checked((uint)(buffer?.SizeBytes ?? 0L)));
private void DrawTerrain(
IGpuPassEncoder encoder,
in PreparedGpuUploads uploads,
DirectionalShadowTerrainPreparedDraws draws,
DirectionalShadowTerrainGeometry? geometry,
int cascadeIndex,
IGpuPipeline? pipeline = null)
{
if (draws.Commands.IsEmpty)
return;
DirectionalShadowTerrainGeometry actual = geometry!.Value;
encoder.BindPipeline(pipeline ?? _terrainPipeline);
encoder.BindVertexBuffer(0, actual.VertexBuffer, 0);
encoder.BindIndexBuffer(actual.IndexBuffer, 0, GpuIndexType.UInt32);
GpuPushConstants push = PushForCascade(cascadeIndex, 0);
encoder.SetPushConstants(in push);
encoder.MultiDrawIndexedIndirect(
uploads.TerrainCommands.RequireBuffer(),
uploads.TerrainCommands.OffsetBytes,
checked((uint)draws.Commands.Length),
DrawCommandStride);
}
private void DrawWorld(
IGpuPassEncoder encoder,
in PreparedGpuUploads uploads,
DirectionalShadowPreparedDraws draws,
DirectionalShadowMeshGeometry? geometry,
int cascadeIndex,
IGpuPipeline? opaquePipeline = null,
IGpuPipeline? cutoutPipeline = null)
{
if (draws.Commands.IsEmpty)
return;
DirectionalShadowMeshGeometry actual = geometry!.Value;
encoder.BindStorageBuffer(
GpuBindingModel.StorageInstances,
uploads.Transforms.Buffer,
uploads.Transforms.BaseOffsetBytes,
uploads.Transforms.BindingSizeBytes);
encoder.BindStorageBuffer(
GpuBindingModel.StorageBatches,
uploads.Batches.RequireBuffer(),
uploads.Batches.OffsetBytes,
uploads.Batches.SizeBytes);
DrawWorldRange(
encoder,
uploads.WorldCommands,
draws.OpaqueRuns,
cascadeIndex,
opaquePipeline ?? _worldOpaquePipeline,
actual);
DrawWorldRange(
encoder,
uploads.WorldCommands,
draws.AlphaCutoutRuns,
cascadeIndex,
cutoutPipeline ?? _worldCutoutPipeline,
actual);
}
private static void DrawWorldRange(
IGpuPassEncoder encoder,
in RetainedGpuBufferSlice commands,
ReadOnlySpan runs,
int cascadeIndex,
IGpuPipeline pipeline,
in DirectionalShadowMeshGeometry geometry)
{
if (runs.IsEmpty)
return;
encoder.BindPipeline(pipeline);
encoder.BindVertexBuffer(0, geometry.VertexBuffer, 0);
encoder.BindIndexBuffer(geometry.IndexBuffer, 0, GpuIndexType.UInt16);
for (int runIndex = 0; runIndex < runs.Length; runIndex++)
{
DirectionalShadowPreparedRun run = runs[runIndex];
ApplyCull(encoder, run.CullMode);
GpuPushConstants push = PushForCascade(cascadeIndex, run.StartCommand);
encoder.SetPushConstants(in push);
encoder.MultiDrawIndexedIndirect(
commands.RequireBuffer(),
commands.OffsetBytes + checked((uint)(run.StartCommand * DrawCommandStride)),
checked((uint)run.CommandCount),
DrawCommandStride);
}
}
private static GpuPushConstants PushForCascade(int cascadeIndex, int drawIdOffset)
{
GpuPushConstants push = GpuPushConstants.Default;
push.RenderPass = cascadeIndex;
push.DrawIdOffset = drawIdOffset;
return push;
}
private static void ApplyCull(IGpuPassEncoder encoder, CullMode mode)
{
encoder.SetFrontFace(GpuFrontFace.Clockwise);
encoder.SetCullMode(mode switch
{
CullMode.None => GpuCullMode.None,
CullMode.Clockwise => GpuCullMode.Front,
_ => GpuCullMode.Back,
});
}
private static IGpuPipeline CreatePipeline(
IGpuDevice device,
string name,
GpuShaderSet shaders,
GpuVertexLayout layout,
GpuFrontFace frontFace,
uint viewMask = 0) =>
device.CreatePipeline(new GpuPipelineDescription
{
Name = name,
Shaders = shaders,
VertexLayout = layout,
Topology = GpuPrimitiveTopology.TriangleList,
Blend = GpuBlendMode.None,
Depth = new GpuDepthState(true, true, GpuCompareOp.Less),
Cull = GpuCullMode.Back,
FrontFace = frontFace,
AlphaToCoverage = false,
ColorWrite = false,
HasColorAttachment = false,
AllowColorFormatVariants = false,
SampleCount = 1,
UsesRenderPackShaderAbi = true,
ViewMask = viewMask,
});
private (bool HasMeasurement, double Milliseconds) ResolveGpu(int cascadeCount)
{
if (MultiviewCascadesEnabled)
return _device.Timers.TryResolve(MultiviewTimerName, out double measured)
? (true, measured)
: (false, 0d);
double total = 0d;
for (int i = 0; i < cascadeCount; i++)
{
if (!_device.Timers.TryResolve(TimerName(i), out double milliseconds))
return (false, 0d);
total += milliseconds;
}
return (true, total);
}
private DirectionalSunShadowDiagnostics Disabled(
in DirectionalShadowEnvironmentState environment,
DirectionalSunShadowCpuStageTicks cpuStages = default) =>
new(
environment.Reason,
0f,
0,
0,
0,
0,
0,
0,
0,
0d,
0d,
false,
_quality.ApproximateDepthMapBytes,
cpuStages,
SourceKind: environment.SourceKind,
SourceObjectIndex: environment.SourceObjectIndex,
SourceGfxObjId: environment.SourceGfxObjId,
SurfaceToLightDirection: environment.SurfaceToLightDirection,
LightElevationSin: environment.LightElevationSin);
private void EnsureBatchCapacity(int required)
{
if (_batchScratch.Length >= required)
return;
int capacity = _batchScratch.Length == 0 ? 16 : _batchScratch.Length;
while (capacity < required)
capacity = checked(capacity * 2);
Array.Resize(ref _batchScratch, capacity);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_currentFrameBinding = DirectionalShadowFrameBinding.Disabled;
_worldCutoutPipeline.Dispose();
_worldCutoutMultiviewPipeline?.Dispose();
_worldOpaqueMultiviewPipeline?.Dispose();
_terrainMultiviewPipeline?.Dispose();
_worldOpaquePipeline.Dispose();
_terrainPipeline.Dispose();
_terrainCommandBuffer?.Dispose();
_worldCommandBuffer?.Dispose();
_worldBatchBuffer?.Dispose();
_transformBuffers.Dispose();
_device.ReleaseTextureSlot(_textureSlot);
_sampler.Dispose();
_target.Dispose();
}
[StructLayout(LayoutKind.Sequential, Pack = 4)]
private readonly record struct DirectionalShadowBatchGpuData(
uint TextureIndex,
uint Reserved,
uint TextureLayer,
uint Flags);
private readonly record struct RetainedGpuBufferSlice(
IGpuBuffer? Buffer,
uint OffsetBytes,
uint SizeBytes)
{
internal IGpuBuffer RequireBuffer() => Buffer
?? throw new InvalidOperationException(
"A non-empty directional-shadow draw has no retained GPU buffer.");
}
private readonly record struct PreparedGpuUploads(
WorldTransformFrameSlice transforms,
RetainedGpuBufferSlice batches,
RetainedGpuBufferSlice worldCommands,
RetainedGpuBufferSlice terrainCommands)
{
internal WorldTransformFrameSlice Transforms { get; } = transforms;
internal RetainedGpuBufferSlice Batches { get; } = batches;
internal RetainedGpuBufferSlice WorldCommands { get; } = worldCommands;
internal RetainedGpuBufferSlice TerrainCommands { get; } = terrainCommands;
}
}