acdream/src/AcDream.App/Rendering/DirectionalShadowCascadeFitter.cs

286 lines
11 KiB
C#

using System.Numerics;
namespace AcDream.App.Rendering;
internal readonly record struct DirectionalShadowCascadeFitInput(
Matrix4x4 CameraView,
Matrix4x4 CameraProjection,
Vector3 SurfaceToLightDirection,
DirectionalShadowQuality Quality,
float CameraNearMeters = 0.1f,
float PracticalSplitLambda = 0.65f,
float CasterDepthPaddingMeters = 48f,
float ResidentMaximumReachMeters = float.PositiveInfinity);
internal readonly record struct DirectionalShadowCascade(
int Index,
float SplitNearMeters,
float SplitFarMeters,
Matrix4x4 LightView,
Matrix4x4 LightProjection,
Matrix4x4 WorldToShadowClip,
Vector2 StabilizedLightSpaceCenter,
float HalfExtentMeters,
float TexelWorldSize,
float CasterDepthPaddingMeters,
DirectionalShadowWorldBias Bias);
/// <summary>
/// Pure camera-relative cascade fitting. It receives no scene/PView callback,
/// so fitting N cascades cannot trigger N CPU visibility traversals.
/// </summary>
internal static class DirectionalShadowCascadeFitter
{
private const float RadiusQuantizationMeters = 1f / 16f;
public static int Fit(
in DirectionalShadowCascadeFitInput input,
Span<DirectionalShadowCascade> destination)
{
Validate(in input, destination.Length);
if (!Matrix4x4.Invert(input.CameraView, out Matrix4x4 inverseView))
throw new ArgumentException("Camera view matrix is not invertible.", nameof(input));
if (!Matrix4x4.Invert(input.CameraProjection, out Matrix4x4 inverseProjection))
throw new ArgumentException("Camera projection matrix is not invertible.", nameof(input));
Vector3 lightDirection = Vector3.Normalize(input.SurfaceToLightDirection);
float maximumReach = MathF.Min(
input.Quality.MaximumReachMeters,
input.ResidentMaximumReachMeters);
if (maximumReach <= input.CameraNearMeters)
return 0;
float splitNear = input.CameraNearMeters;
Span<Vector3> corners = stackalloc Vector3[8];
for (int cascadeIndex = 0;
cascadeIndex < input.Quality.CascadeCount;
cascadeIndex++)
{
float splitFar = PracticalSplit(
input.CameraNearMeters,
maximumReach,
cascadeIndex + 1,
input.Quality.CascadeCount,
input.PracticalSplitLambda);
BuildFrustumSliceCorners(
inverseView,
inverseProjection,
splitNear,
splitFar,
corners);
destination[cascadeIndex] = FitCascade(
cascadeIndex,
splitNear,
splitFar,
corners,
lightDirection,
input.Quality.MapResolution,
input.CasterDepthPaddingMeters,
input.Quality.BiasPolicy);
splitNear = splitFar;
}
return input.Quality.CascadeCount;
}
internal static float PracticalSplit(
float nearMeters,
float farMeters,
int splitIndex,
int splitCount,
float lambda)
{
if (!float.IsFinite(nearMeters)
|| !float.IsFinite(farMeters)
|| nearMeters <= 0f
|| farMeters <= nearMeters)
{
throw new ArgumentOutOfRangeException(nameof(farMeters));
}
if (splitCount <= 0 || splitIndex <= 0 || splitIndex > splitCount)
throw new ArgumentOutOfRangeException(nameof(splitIndex));
if (!float.IsFinite(lambda) || lambda < 0f || lambda > 1f)
throw new ArgumentOutOfRangeException(nameof(lambda));
float fraction = (float)splitIndex / splitCount;
float logarithmic = nearMeters * MathF.Pow(farMeters / nearMeters, fraction);
float uniform = nearMeters + (farMeters - nearMeters) * fraction;
return lambda * logarithmic + (1f - lambda) * uniform;
}
private static DirectionalShadowCascade FitCascade(
int index,
float splitNear,
float splitFar,
ReadOnlySpan<Vector3> corners,
Vector3 surfaceToLight,
int mapResolution,
float depthPadding,
in DirectionalShadowBiasPolicy biasPolicy)
{
Vector3 center = Vector3.Zero;
for (int i = 0; i < corners.Length; i++)
center += corners[i];
center /= corners.Length;
float radius = 0f;
for (int i = 0; i < corners.Length; i++)
radius = MathF.Max(radius, Vector3.Distance(center, corners[i]));
radius = MathF.Ceiling(radius / RadiusQuantizationMeters)
* RadiusQuantizationMeters;
radius = MathF.Max(radius, RadiusQuantizationMeters);
Vector3 up = StableLightUp(surfaceToLight);
Matrix4x4 lightRotation = Matrix4x4.CreateLookAt(
Vector3.Zero,
-surfaceToLight,
up);
Vector3 lightCenter = Vector3.Transform(center, lightRotation);
float texelWorldSize = (2f * radius) / mapResolution;
float snappedX = SnapToTexel(lightCenter.X, texelWorldSize);
float snappedY = SnapToTexel(lightCenter.Y, texelWorldSize);
float minZ = float.PositiveInfinity;
float maxZ = float.NegativeInfinity;
for (int i = 0; i < corners.Length; i++)
{
float z = Vector3.Transform(corners[i], lightRotation).Z;
minZ = MathF.Min(minZ, z);
maxZ = MathF.Max(maxZ, z);
}
// Move the light eye toward the selected celestial source. The
// receiver slice then lies
// between depthPadding and span+depthPadding metres in front of it,
// while the far extension admits casters behind the slice as well.
float eyeAxis = maxZ + depthPadding;
Vector3 eye = surfaceToLight * eyeAxis;
Matrix4x4 lightView = Matrix4x4.CreateLookAt(
eye,
eye - surfaceToLight,
up);
float nearPlane = 0.1f;
float farPlane = MathF.Max(
nearPlane + 0.1f,
(maxZ - minZ) + 2f * depthPadding);
Matrix4x4 lightProjection = Matrix4x4.CreateOrthographicOffCenter(
snappedX - radius,
snappedX + radius,
snappedY - radius,
snappedY + radius,
nearPlane,
farPlane);
return new DirectionalShadowCascade(
index,
splitNear,
splitFar,
lightView,
lightProjection,
lightView * lightProjection,
new Vector2(snappedX, snappedY),
radius,
texelWorldSize,
depthPadding,
biasPolicy.Resolve(texelWorldSize));
}
private static void BuildFrustumSliceCorners(
Matrix4x4 inverseView,
Matrix4x4 inverseProjection,
float nearMeters,
float farMeters,
Span<Vector3> destination)
{
int cursor = 0;
for (int depthIndex = 0; depthIndex < 2; depthIndex++)
{
float distance = depthIndex == 0 ? nearMeters : farMeters;
for (int yIndex = 0; yIndex < 2; yIndex++)
{
float y = yIndex == 0 ? -1f : 1f;
for (int xIndex = 0; xIndex < 2; xIndex++)
{
float x = xIndex == 0 ? -1f : 1f;
Vector4 viewCorner = Vector4.Transform(
new Vector4(x, y, 1f, 1f),
inverseProjection);
if (MathF.Abs(viewCorner.W) <= 1e-6f)
throw new ArgumentException("Camera projection produced a corner at infinity.");
Vector3 view = new(
viewCorner.X / viewCorner.W,
viewCorner.Y / viewCorner.W,
viewCorner.Z / viewCorner.W);
float viewDepth = MathF.Abs(view.Z);
if (viewDepth <= 1e-6f)
throw new ArgumentException("Camera projection produced zero view depth.");
view *= distance / viewDepth;
destination[cursor++] = Vector3.Transform(view, inverseView);
}
}
}
}
private static float SnapToTexel(float value, float texelWorldSize) =>
MathF.Round(value / texelWorldSize, MidpointRounding.AwayFromZero)
* texelWorldSize;
/// <summary>
/// Uses Duff's numerically stable revision of Frisvad's orthonormal basis.
/// The selected celestial source occupies the accepted upper hemisphere,
/// where this basis varies continuously through the exact zenith. The old
/// 0.95 dot-product branch rotated the cascade basis abruptly, while
/// projected world-up merely moved that discontinuity to exact zenith.
/// </summary>
internal static Vector3 StableLightUp(Vector3 surfaceToLight)
{
surfaceToLight = Vector3.Normalize(surfaceToLight);
float sign = MathF.CopySign(1f, surfaceToLight.Z);
float a = -1f / (sign + surfaceToLight.Z);
float b = surfaceToLight.X * surfaceToLight.Y * a;
return Vector3.Normalize(new Vector3(
b,
sign + surfaceToLight.Y * surfaceToLight.Y * a,
-surfaceToLight.Y));
}
private static void Validate(
in DirectionalShadowCascadeFitInput input,
int destinationLength)
{
DirectionalShadowQuality quality = input.Quality;
if (quality.CascadeCount <= 0 || quality.CascadeCount > 4)
throw new ArgumentOutOfRangeException(nameof(input), "Cascade count must be in [1,4].");
if (destinationLength < quality.CascadeCount)
throw new ArgumentException("Destination cannot hold every configured cascade.");
if (quality.MapResolution <= 0
|| !float.IsFinite(quality.MaximumReachMeters)
|| quality.MaximumReachMeters <= input.CameraNearMeters)
{
throw new ArgumentOutOfRangeException(nameof(input), "Shadow quality dimensions are invalid.");
}
if (!float.IsFinite(input.CameraNearMeters) || input.CameraNearMeters <= 0f)
throw new ArgumentOutOfRangeException(nameof(input), "Camera near distance must be positive.");
if (float.IsNaN(input.ResidentMaximumReachMeters)
|| input.ResidentMaximumReachMeters < 0f)
{
throw new ArgumentOutOfRangeException(
nameof(input),
"Resident shadow reach must be nonnegative or positive infinity.");
}
if (!float.IsFinite(input.PracticalSplitLambda)
|| input.PracticalSplitLambda < 0f
|| input.PracticalSplitLambda > 1f)
{
throw new ArgumentOutOfRangeException(nameof(input), "Split lambda must be in [0,1].");
}
if (!float.IsFinite(input.CasterDepthPaddingMeters)
|| input.CasterDepthPaddingMeters <= 0f)
{
throw new ArgumentOutOfRangeException(nameof(input), "Caster depth padding must be positive.");
}
float lightLength = input.SurfaceToLightDirection.Length();
if (!float.IsFinite(lightLength) || lightLength <= 1e-6f)
throw new ArgumentOutOfRangeException(nameof(input), "Light direction must be finite and nonzero.");
}
}