acdream/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs
Erik 39e8408c7d feat(render): weather-driven foliage wind for procedural scenery, shadows follow (Campaign VM VM6b)
Procedural-scenery foliage (trees/bushes — entity ids in the
ProceduralSceneryIdAllocator's 0x8XXYYIII namespace) sways with weather in
mesh_atmospheric.vert and all four directional_shadow_world_* caster vertex
shaders, both calling the identical new foliage_wind.glsl include so the
shadow moves with the leaf by construction.

Classification (FoliageWindClassification, AcDream.App.Rendering.Wb): two
new BatchData.flags bits, computed once per (entity, subset) from four
inputs — entity id (bit 31 for procedural scenery), the pack's declared
FoliageExclusions membership, the subset's TranslucencyKind, and
ObjectRenderData.HasCutoutSubset (computed once per mesh at build time, not
per frame). Bit 1 marks an alpha-cutout leaf subset; bit 2 marks an opaque
trunk subset (only when its own mesh also owns a cutout subset, so rocks
stay still). WbDrawDispatcher.ClassifyBatches (world receiver) and
AddDirectionalShadowBatches (caster) call this with the same four inputs, so
casters and receivers classify identically without needing to share state.
Retail's mesh_modern/terrain_modern/mesh_detail pipelines never read these
bits, so pack-off output is unaffected.

Motion model (foliage_wind.glsl, mirrored bit-for-bit in the new
FoliageWindModel for hermetic CPU tests): height-squared-scaled slow lean
for every foliage subset, plus branch swing and per-vertex-hash-decorrelated
flutter for cutout subsets only. AtmosphericPostProcessGraph.ResolveFoliageWind
resolves the wind block once per frame.Serial — advanced by whichever of
RenderDirectionalShadows (which runs first) or RenderPostProcess is called
first that frame, with the second reading the already-advanced state, which
is what keeps the caster and receiver reading byte-identical clock/strength
values. The per-day-group mean/gust target (AtmospherePolicyDeclaration.
FoliageWindByDayGroup, keyed by the same day-group index convention
ActiveDayGroupMultipliers already established: Clear/Cloudy/Overcast/Rainy)
eases toward its target over WeatherSystem.TransitionSeconds (10s) so a
weather change never snaps; wind-enabled off or indoor instead gates the
OUTPUT to an exact zero (not an asymptotic approach) so a settings toggle or
cell transition is immediate. The wind clock is a Stopwatch started at graph
construction (monotonic, session-relative magnitude for GPU sin() accuracy),
overridable by the same ACDREAM_SKY_PHASE_SECONDS pin SkyRenderer already
uses, for deterministic offline gates.

New settings: wind-enabled, wind-strength, wind-direction-degrees (225°
default — no authored retail wind direction exists to read),
wind-lean-metres, wind-branch-metres, wind-flutter-metres (0 on Low),
wind-canopy-height-metres.

Register row IA-25 files this as an intentional, strictly opt-in divergence:
retail applies no per-vertex wind displacement to any geometry. Known,
accepted limitation: classification is per mesh-subset (one BatchData.flags
word per indirect-draw batch), not per entity instance, so the rare case of
one mesh subset being reachable from both a procedural-scenery and a
non-scenery placement would classify all of that subset's instances alike.

Tests: FoliageWindClassificationTests (the full classification matrix),
FoliageWindModelTests (identity on non-foliage/calm-wind/base-vertex,
canopy-top displacement bound, z-never-increases, trunk has no flutter
term), RenderPackAtmospherePolicyEvaluationTests (exact day-group lookup,
no interpolation across day-group ids, easing convergence without overshoot
or discontinuity), AtmosphericShaderAbiTests (each of the five shaders calls
acdreamFoliageDisplace exactly once; mesh_modern/terrain/mesh_detail call it
never), and four AtmosphericPostProcessGraphTests additions (indoor/disabled
exact-zero gating, settings-to-UBO wiring, same-frame-Serial idempotency —
the last proxies the caster/receiver agreement invariant without needing
this hermetic harness's WbDrawDispatcher/TerrainModernRenderer dependency
chain to exercise RenderDirectionalShadows directly).

App hermetic filter: 6015/6017 (the same 2 pre-existing failures as VM6a,
confirmed unrelated). Core.Tests hermetic: 4697/4697. RenderPackValidator.Tests:
30/30. Full solution Debug and Release builds green. Shader recompile
touched exactly the 5 edited files' .spv (plus manifest); the retail oracle
set and every other pack shader are byte-identical.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 00:56:34 +02:00

1042 lines
41 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
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);
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;
}
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// 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.
/// </summary>
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.IsValidFor(frame);
}
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);
long cpuStageStarted = input.MeasureCpuStages ? Stopwatch.GetTimestamp() : 0L;
DirectionalShadowEnvironmentState environment =
DirectionalShadowEnvironmentGate.Evaluate(
input.Environment,
_atmospherePolicy);
long environmentGateTicks = input.MeasureCpuStages
? Stopwatch.GetTimestamp() - cpuStageStarted
: 0L;
if (!environment.ShouldRender)
return Disabled(
in environment,
new DirectionalSunShadowCpuStageTicks(
environmentGateTicks, 0L, 0L, 0L, 0L));
if (input.ResidentMaximumReachMeters <= input.CameraNearMeters)
{
environment = environment with
{
Reason = DirectionalShadowGateReason.ResidentWindowUnavailable,
};
return Disabled(
in environment,
new DirectionalSunShadowCpuStageTicks(
environmentGateTicks, 0L, 0L, 0L, 0L));
}
cpuStageStarted = input.MeasureCpuStages ? Stopwatch.GetTimestamp() : 0L;
DirectionalShadowPreparedDraws worldDraws =
world.PrepareDirectionalShadowDraws(input.Casters);
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;
}
}
internal static DirectionalShadowCasterClassDiagnostics
CompleteCasterClassDiagnostics(
in DirectionalShadowCasterBuildStats casterStats,
int terrainCommandCount)
{
ArgumentOutOfRangeException.ThrowIfNegative(terrainCommandCount);
return casterStats.CasterClasses with
{
TerrainCommands = terrainCommandCount,
};
}
/// <summary>
/// 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.
/// </summary>
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)
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,
};
return Disabled(in unavailable, cpuStages);
}
ReadOnlySpan<DirectionalShadowCascade> 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 the world receiver binds (set 3/binding 5), so a
// displaced leaf's shadow moves with it by construction. 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);
(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<byte> batchBytes = MemoryMarshal.AsBytes(
_batchScratch.AsSpan(0, world.Batches.Length));
ReadOnlySpan<byte> 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<byte> 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<DirectionalShadowPreparedRun> 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;
}
}