computeCascades method

List<ShadowCascade> computeCascades(
  1. Camera camera,
  2. double aspectRatio, [
  3. Vector3? worldDirection
])

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;
}