computeCascades method
Builds the shadowCascadeCount shadow cascades that cover
camera's view out to shadowMaxDistance, for a render target of
the given aspectRatio. Returned near-to-far.
Each cascade fits a bounding sphere to its slice of the camera frustum, so the cascade's projection size stays constant as the camera rotates; the projection is then texel-snapped so shadow edges do not shimmer.
An orthographic camera's slices are boxes of constant cross-section, so its splits are uniform from the near plane (the logarithmic blend exists to follow perspective's shrinking texel footprint).
worldDirection is the light's world-space travel direction. When
omitted it falls back to direction (the light's own field), which
is correct for a light placed without a node transform.
Implementation
List<ShadowCascade> computeCascades(
Camera camera,
double aspectRatio, [
Vector3? worldDirection,
]) {
// The unit-height view carries the aspect ratio; a projection the renderer
// bound to its view ignores it.
final projection = ProjectionParams.of(
camera.projection,
ui.Size(aspectRatio, 1.0),
);
final orthographic = projection.orthographic;
final count = shadowCascadeCount.clamp(1, 4);
final near = projection.near;
final far = orthographic
? math.min(shadowMaxDistance, projection.far)
: shadowMaxDistance;
final lambda = orthographic ? 0.0 : shadowCascadeSplitLambda;
// Practical split scheme: a blend of logarithmic and uniform
// spacing, so the near cascades get proportionally more resolution. A
// pinned first bound takes the first split and the same scheme spreads the
// rest from there; a single cascade has no rest, so it keeps far.
// A pin at or past shadowMaxDistance leaves no range for the remaining
// cascades (every later split collapses onto far), so it falls back to
// the automatic scheme; the lower clamp stays strictly above the near
// plane so cascade 0 keeps thickness.
final bound = firstCascadeFarBound;
final pinned = bound != null && count > 1 && bound < far && far > near
? math.max(bound, near + (far - near) * 1e-3)
: null;
final splits = <double>[near];
if (pinned != null) splits.add(pinned);
final splitNear = pinned ?? near;
final splitCount = pinned != null ? count - 1 : count;
for (var i = 1; i <= splitCount; i++) {
final ratio = i / splitCount;
final uniformSplit = splitNear + (far - splitNear) * ratio;
if (lambda == 0.0) {
splits.add(uniformSplit);
continue;
}
final logSplit = splitNear * math.pow(far / splitNear, ratio);
splits.add(lambda * logSplit + (1.0 - lambda) * uniformSplit);
}
// Camera basis and the projection's lateral extents (half-fov tangents,
// or half sizes when orthographic).
final forward = camera.forward;
final right = camera.up.cross(forward)..normalize();
final up = forward.cross(right)..normalize();
final tanRadius2 =
projection.scaleX * projection.scaleX +
projection.scaleY * projection.scaleY;
// Where the orthographic volume's axis sits off the eye (an offset
// projection shifts it across the view plane).
final lateralCenter =
right * (-projection.offsetX * projection.scaleX) +
up * (-projection.offsetY * projection.scaleY);
final effectiveDirection = worldDirection ?? direction;
final lightLength = effectiveDirection.length;
final lightDir = lightLength == 0.0
? Vector3(0.0, -1.0, 0.0)
: effectiveDirection * (1.0 / lightLength);
final overlap = cascadeOverlap.clamp(0.0, 1.0);
final cascades = <ShadowCascade>[];
for (var c = 0; c < count; c++) {
// The smallest stable sphere enclosing both rectangular end planes has
// its center on the view axis. Equalize the near/far corner distances,
// unless that point lies beyond the far plane, where the far rectangle's
// own circumcircle is the minimum. This keeps the rotation-invariant
// cascade fit while wasting less shadow-map area than a midpoint sphere.
final sliceNear = splits[c];
// Overlap fits a cascade past its split so it and its successor both
// cover the band the shader cross-fades over. The last cascade has no
// successor, so it keeps its bound.
final sliceFar = overlap > 0.0 && c < count - 1
? splits[c + 1] + (splits[c + 1] - sliceNear) * overlap
: splits[c + 1];
final position = camera.position;
final Vector3 center;
final double radius;
if (orthographic) {
// A box slice: its bounding sphere sits at the box center.
final centerDepth = (sliceNear + sliceFar) * 0.5;
final halfLength = (sliceFar - sliceNear) * 0.5;
center = position + forward * centerDepth + lateralCenter;
radius = math.sqrt(tanRadius2 + halfLength * halfLength);
} else {
final centerDepth = math.min(
sliceFar,
(sliceNear + sliceFar) * (1.0 + tanRadius2) * 0.5,
);
center = Vector3(
position.x + forward.x * centerDepth,
position.y + forward.y * centerDepth,
position.z + forward.z * centerDepth,
);
final nearRadius2 =
(centerDepth - sliceNear) * (centerDepth - sliceNear) +
sliceNear * sliceNear * tanRadius2;
final farRadius2 =
(sliceFar - centerDepth) * (sliceFar - centerDepth) +
sliceFar * sliceFar * tanRadius2;
radius = math.sqrt(math.max(nearRadius2, farRadius2));
}
cascades.add(
ShadowCascade(
lightSpaceMatrix: _cascadeLightSpaceMatrix(lightDir, center, radius),
splitDistance: splits[c + 1],
boxSize: radius * 2.0,
center: center,
radius: radius,
),
);
}
return cascades;
}