GLSL Ring Shadows on Oblate Spherical Planets
This article explains how to compute the shadows cast by planetary rings onto oblate spheroids using OpenGL Shading Language (GLSL). Calculating this effect requires finding the world-space surface coordinate of the flattened planet, projecting a ray from that surface point along the light vector toward the ring plane, and sampling the ring’s radial opacity profile to attenuate the incoming light.
Defining the Oblate Spheroid and Ring Plane
An oblate spherical planet—such as Saturn or Jupiter—is geometrically defined as an ellipsoid with an equatorial radius \(a\) and a polar radius \(b\), where \(b < a\). In the planet’s local coordinate space (with the Y-axis aligned with the polar axis), its surface satisfies:
\[\frac{x^2 + z^2}{a^2} + \frac{y^2}{b^2} = 1\]
Planetary rings lie strictly within the planet's equatorial plane (\(y = 0\)) and extend from an inner radius \(R_{inner}\) to an outer radius \(R_{outer}\). Because the rings share the equatorial plane, their normal vector \(\mathbf{N}_{ring}\) is identical to the planet’s local polar axis \((0, 1, 0)\).
The Shadow Projection Mathematics
To calculate whether a point on the planet is shadowed by the rings, the computation is performed in reverse: from the planet surface toward the light source.
- Obtain the Surface Position: For a rasterized mesh or a raymarched ellipsoid, obtain the fragment's position \(\mathbf{P}\) in the local space of the planet.
- Define the Light Vector: Let \(\mathbf{L}\) be the normalized vector pointing from the planet toward the light source (e.g., the Sun) in local space.
- Ray-Plane Intersection: Project a ray from surface point \(\mathbf{P}\) in the direction of \(\mathbf{L}\) to intersect the equatorial ring plane \(y = 0\):
\[P_y + t \cdot L_y = 0 \implies t = -\frac{P_y}{L_y}\]
The scalar \(t\) represents the distance along the light vector to the ring plane. For a shadow to be physically valid:
- \(t > 0\): The intersection must lie in the direction toward the light source. If \(t \le 0\), the ring plane lies behind the fragment relative to the incoming sunlight.
- \(L_y \neq 0\): If the light direction is perfectly parallel to the ring plane, the ray never intersects the ring.
- Radial Distance Check: Calculate the intersection coordinate on the ring plane \(\mathbf{I}\):
\[\mathbf{I} = \mathbf{P} + t \mathbf{L}\]
Since the intersection occurs at \(y = 0\), its distance \(r\) from the planetary center is:
\[r = \sqrt{I_x^2 + I_z^2}\]
If \(R_{inner} \le r \le R_{outer}\), the ray passes through the rings, meaning the ring casts a shadow on fragment \(\mathbf{P}\).
GLSL Implementation
The following GLSL function calculates the shadow factor, returning
0.0 for full shadow and 1.0 for fully lit.
// Uniforms or constants defining the ring system
uniform float u_ringInnerRadius;
uniform float u_ringOuterRadius;
uniform sampler2D u_ringTexture; // 1D/2D texture containing opacity along the radius
float calculateRingShadow(vec3 localPos, vec3 localLightDir) {
// Avoid division by zero when the light is parallel to the ring plane
if (abs(localLightDir.y) < 1e-5) {
return 1.0;
}
// Calculate distance t along the light ray to the y = 0 plane
float t = -localPos.y / localLightDir.y;
// The ring must be between the surface and the light source
if (t <= 0.0) {
return 1.0;
}
// Compute intersection point on the ring plane
vec3 hitPoint = localPos + t * localLightDir;
// Calculate radial distance on the equatorial plane
float dist = length(hitPoint.xz);
// Check if the intersection point lies within the ring boundaries
if (dist >= u_ringInnerRadius && dist <= u_ringOuterRadius) {
// Normalize radius to a [0.0, 1.0] texture coordinate
float u = (dist - u_ringInnerRadius) / (u_ringOuterRadius - u_ringInnerRadius);
// Sample the optical depth/opacity from the ring profile
float ringAlpha = texture(u_ringTexture, vec2(u, 0.5)).a;
// Return the attenuated light factor
return 1.0 - ringAlpha;
}
return 1.0;
}Self-Shadowing and Oblateness Considerations
Because the planet is oblate, fragments near the poles are closer to the center than those near the equator. The surface normal \(\mathbf{N}\) at local point \(\mathbf{P}(x, y, z)\) on an ellipsoid is given by:
\[\mathbf{N} = \text{normalize}\left(\frac{x}{a^2}, \frac{y}{b^2}, \frac{z}{a^2}\right)\]
Ensure that standard diffuse lighting (\(\max(\mathbf{N} \cdot \mathbf{L}, 0.0)\)) is computed using this ellipsoidal normal rather than a normalized sphere vector. This guarantees that the terminator line matches the oblate shape, preventing ring shadows from appearing on faces naturally turned away from the light. Combine the diffuse lighting with the shadow factor by simple multiplication:
vec3 normal = normalize(vec3(localPos.x / (a * a), localPos.y / (b * b), localPos.z / (a * a)));
float NdotL = max(dot(normal, localLightDir), 0.0);
float shadow = calculateRingShadow(localPos, localLightDir);
vec3 finalIllumination = lightColor * NdotL * shadow;