Last modified on 27 September 2014, at 20:50

Cg Programming/Unity/Soft Shadows of Spheres

Shadows are not only important to understand the geometry of a scene (e.g. the distances between objects); they can also be quite beautiful.

This tutorial covers soft shadows of spheres.

It is one of several tutorials about lighting that go beyond the Phong reflection model, which is a local illumination model and therefore doesn't take shadows into account. The presented technique renders the soft shadow of a single sphere on any mesh and is somewhat related to a technique that was proposed by Orion Sky Lawlor (see the “Further Reading” section). The shader can be extended to render the shadows of a small number of spheres at the cost of rendering performance; however, it cannot easily be applied to any other kind of shadow caster. Potential applications are computer ball games (where the ball is often the only object that requires a soft shadow and the only object that should cast a dynamic shadow on all other objects), computer games with a spherical main character (e.g. “Marble Madness”), visualizations that consist only of spheres (e.g. planetary visualizations, ball models of small nuclei, atoms, or molecules, etc.), or test scenes that can be populated with spheres and benefit from soft shadows.

Umbra (black) and penumbra (gray) are the main parts of soft shadows.
“The crowning with thorns” by Caravaggio (ca. 1602). Note the shadow line in the upper left corner, which becomes softer with increasing distance from the shadow casting wall.

Soft ShadowsEdit

While directional light sources and point light sources produce hard shadows, any area light source generates a soft shadow. This is also true for all real light sources, in particular the sun and any light bulb or lamp. From some points behind the shadow caster, no part of the light source is visible and the shadow is uniformly dark: this is the umbra. From other points, more or less of the light source is visible and the shadow is therefore less or more complete: this is the penumbra. Finally, there are points from where the whole area of the light source is visible: these points are outside of the shadow.

In many cases, the softness of a shadow depends mainly on the distance between the shadow caster and the shadow receiver: the larger the distance, the softer the shadow. This is a well known effect in art; see for example the painting by Caravaggio to the right.

Vectors for the computation of soft shadows: vector L to the light source, vector S to the center of the sphere, tangent vector T, and distance d of the tangent from the center of the light source.

ComputationEdit

We are going to approximately compute the shadow of a point on a surface when a sphere of radius r_\text{sphere} at S (relative to the surface point) is occluding a spherical light source of radius r_\text{light} at L (again relative to the surface point); see the figure to the left.

To this end, we consider a tangent in direction T to the sphere and passing through the surface point. Furthermore, this tangent is chosen to be in the plane spanned by L and S, i.e. parallel to the view plane of the figure to the left. The crucial observation is that the minimum distance d of the center of the light source and this tangent line is directly related to the amount of shadowing of the surface point because it determines how large the area of the light source is that is visible from the surface point. More precisely spoken, we require a signed distance (positive if the tangent is on the same side of L as the sphere, negative otherwise) to determine whether the surface point is in the umbra (d < -r_\text{light}), in the penumbra (-r_\text{light} < d < r_\text{light}), or outside of the shadow (r_\text{light} < d).

For the computation of d, we consider the angles between L and S and between T and S. The difference between these two angles is the angle between L and T, which is related to d by:

\measuredangle(\mathbf{L},\mathbf{T}) \approx \sin\measuredangle(\mathbf{L},\mathbf{T}) = \frac{d}{\left\vert\mathbf{L}\right\vert} .

Thus, so far we have:

d \approx \left\vert\mathbf{L}\right\vert \measuredangle(\mathbf{L},\mathbf{T})   = \left\vert\mathbf{L}\right\vert \left(\measuredangle(\mathbf{L},\mathbf{S}) - \measuredangle(\mathbf{T},\mathbf{S})\right)

We can compute the angle between T and S using

\sin\measuredangle(\mathbf{T},\mathbf{S}) = \frac{r_\text{sphere}}{\left\vert\mathbf{S}\right\vert}.

Thus:

\measuredangle(\mathbf{T},\mathbf{S}) = \arcsin\frac{r_\text{sphere}}{\left\vert\mathbf{S}\right\vert}.

For the angle between L and S we use a feature of the cross product:

\left\vert\mathbf{a}\times\mathbf{b}\right\vert = \left\vert\mathbf{a}\right\vert\,\left\vert\mathbf{b}\right\vert\,\sin\measuredangle(\mathbf{a},\mathbf{b}).

Therefore:

\measuredangle(\mathbf{L},\mathbf{S}) = \arcsin\frac{\left\vert\mathbf{L}\times\mathbf{S}\right\vert}{\left\vert\mathbf{L}\right\vert\,\left\vert\mathbf{S}\right\vert}.

All in all we have:

d \approx \left\vert\mathbf{L}\right\vert \left(\arcsin\frac{\left\vert\mathbf{L}\times\mathbf{S}\right\vert}{\left\vert\mathbf{L}\right\vert\,\left\vert\mathbf{S}\right\vert} - \arcsin\frac{r_\text{sphere}}{\left\vert\mathbf{S}\right\vert}\right)

The approximation we did so far, doesn't matter much; more importantly it doesn't produce rendering artifacts. If performance is an issue one could go further and use arcsin(x) ≈ x; i.e., one could use:

d \approx \left\vert\mathbf{L}\right\vert \left(\frac{\left\vert\mathbf{L}\times\mathbf{S}\right\vert}{\left\vert\mathbf{L}\right\vert\,\left\vert\mathbf{S}\right\vert} - \frac{r_\text{sphere}}{\left\vert\mathbf{S}\right\vert}\right)

This avoids all trigonometric functions; however, it does introduce rendering artifacts (in particular if a specular highlight is in the penumbra that is facing the light source). Whether these rendering artifacts are worth the gains in performance has to be decided for each case.

Next we look at how to compute the level of shadowing w based on d. As d decreases from r_\text{light} to -r_\text{light}, w should increase from 0 to 1. In other words, we want a smooth step from 0 to 1 between values -1 and 1 of -d / r_\text{light}. Probably the most efficient way to achieve this is to use the Hermite interpolation offered by the built-in Cg function smoothstep(a,b,x) = t*t*(3-2*t) with t=clamp((x-a)/(b-a),0,1):

w = \mathrm{smoothstep}\left(-1, 1, \frac{-d}{r_\text{light}}\right)

While this isn't a particular good approximation of a physically-based relation between w and d, it still gets the essential features right.

Furthermore, w should be 0 if the light direction L is in the opposite direction of S; i.e., if their dot product is negative. This condition turns out to be a bit tricky since it leads to a noticeable discontinuity on the plane where L and S are orthogonal. To soften this discontinuity, we can again use smoothstep to compute an improved value w':

w' = w\,\mathrm{smoothstep}\left(0.0, 0.2, \frac{\mathbf{L}\cdot\mathbf{S}}{\left\vert\mathbf{L}\right\vert\,\left\vert\mathbf{S}\right\vert}\right)

Additionally, we have to set w' to 0 if a point light source is closer to the surface point than the occluding sphere. This is also somewhat tricky because the spherical light source can intersect the shadow-casting sphere. One solution that avoids too obvious artifacts (but fails to deal with the full intersection problem) is:

w'' = w'\,\mathrm{smoothstep}\left(0, r_\text{sphere}, \left\vert\mathbf{L}\right\vert-\left\vert\mathbf{S}\right\vert\right)

In the case of a directional light source we just set w'' = w'. Then the term (1 - w''), which specifies the level of unshadowed lighting, should be multiplied to any illumination by the light source. (Thus, ambient light shouldn't be multiplied with this factor.) If the shadows of multiple shadow casters are computed, the terms (1 - w'') for all shadow casters have to be combined for each light source. The common way is to multiply them although this can be inaccurate (in particular if the umbras overlap).

ImplementationEdit

The implementation computes the length of the lightDirection and sphereDirection vectors and then proceeds with the normalized vectors. This way, the lengths of these vectors have to be computed only once and we even avoid some divisions because we can use normalized vectors. Here is the crucial part of the fragment shader:

            // computation of level of shadowing w  
            float3 sphereDirection = 
               _SpherePosition.xyz - input.posWorld.xyz;
            float sphereDistance = length(sphereDirection);
            sphereDirection = sphereDirection / sphereDistance;
            float d = lightDistance 
               * (asin(min(1.0, 
               length(cross(lightDirection, sphereDirection)))) 
               - asin(min(1.0, _SphereRadius / sphereDistance)));
            float w = smoothstep(-1.0, 1.0, -d / _LightSourceRadius);
            w = w * smoothstep(0.0, 0.2, 
               dot(lightDirection, sphereDirection));
            if (0.0 != _WorldSpaceLightPos0.w) // point light source?
            {
               w = w * smoothstep(0.0, _SphereRadius, 
                  lightDistance - sphereDistance);
            }

The use of asin(min(1.0, ...)) makes sure that the argument of asin is in the allowed range.

Complete Shader CodeEdit

The complete source code defines properties for the shadow-casting sphere and the light source radius. All values are expected to be in world coordinates. For directional light sources, the light source radius should be given in radians (1 rad = 180° / π). The best way to set the position and radius of the shadow-casting sphere is a short script that should be attached to all shadow-receiving objects that use the shader, for example:

@script ExecuteInEditMode()
 
var occluder : GameObject;
 
function Update () {
   if (null != occluder) {
      renderer.sharedMaterial.SetVector("_SpherePosition", 
         occluder.transform.position);
      renderer.sharedMaterial.SetFloat("_SphereRadius", 
         occluder.transform.localScale.x / 2.0);
   }
}

This script has a public variable occluder that should be set to the shadow-casting sphere. Then it sets the properties _SpherePostion and _SphereRadius of the following shader (which should be attached to the same shadow-receiving object as the script).

The fragment shader is quite long and in fact we have to use the line #pragma target 3.0 to ignore some restrictions of older GPUs as documented in the Unity reference.

Shader "Cg shadow of sphere" {
   Properties {
      _Color ("Diffuse Material Color", Color) = (1,1,1,1) 
      _SpecColor ("Specular Material Color", Color) = (1,1,1,1) 
      _Shininess ("Shininess", Float) = 10
      _SpherePosition ("Sphere Position", Vector) = (0,0,0,1)
      _SphereRadius ("Sphere Radius", Float) = 1
      _LightSourceRadius ("Light Source Radius", Float) = 0.005
   }
   SubShader {
      Pass {      
         Tags { "LightMode" = "ForwardBase" } 
            // pass for ambient light and first light source
 
         CGPROGRAM
 
         #pragma vertex vert  
         #pragma fragment frag 
 
         #pragma target 3.0
 
         #include "UnityCG.cginc"
         uniform float4 _LightColor0; 
            // color of light source (from "Lighting.cginc")
 
         // User-specified properties
         uniform float4 _Color; 
         uniform float4 _SpecColor; 
         uniform float _Shininess;
         uniform float4 _SpherePosition; 
            // center of shadow-casting sphere in world coordinates
         uniform float _SphereRadius; 
            // radius of shadow-casting sphere
         uniform float _LightSourceRadius; 
            // in radians for directional light sources
 
         struct vertexInput {
            float4 vertex : POSITION;
            float3 normal : NORMAL;
         };
         struct vertexOutput {
            float4 pos : SV_POSITION;
            float4 posWorld : TEXCOORD0;
            float3 normalDir : TEXCOORD1;
         };
 
         vertexOutput vert(vertexInput input) 
         {
            vertexOutput output;
 
            float4x4 modelMatrix = _Object2World;
            float4x4 modelMatrixInverse = _World2Object; 
               // multiplication with unity_Scale.w is unnecessary 
               // because we normalize transformed vectors
 
            output.posWorld = mul(modelMatrix, input.vertex);
            output.normalDir = normalize(
               mul(float4(input.normal, 0.0), modelMatrixInverse).xyz);
            output.pos = mul(UNITY_MATRIX_MVP, input.vertex);
            return output;
         }
 
         float4 frag(vertexOutput input) : COLOR
         {
            float3 normalDirection = normalize(input.normalDir);
 
            float3 viewDirection = normalize(
               _WorldSpaceCameraPos - input.posWorld.xyz);
            float3 lightDirection;
            float lightDistance;
            float attenuation;
 
            if (0.0 == _WorldSpaceLightPos0.w) // directional light?
            {
               attenuation = 1.0; // no attenuation
               lightDirection = 
                  normalize(_WorldSpaceLightPos0.xyz);
               lightDistance = 1.0;
            } 
            else // point or spot light
            {
               lightDirection = 
                  _WorldSpaceLightPos0.xyz - input.posWorld.xyz;
               lightDistance = length(lightDirection);
               attenuation = 1.0 / lightDistance; // linear attenuation
               lightDirection = lightDirection / lightDistance;
            }
 
            // computation of level of shadowing w  
            float3 sphereDirection = 
               _SpherePosition.xyz - input.posWorld.xyz;
            float sphereDistance = length(sphereDirection);
            sphereDirection = sphereDirection / sphereDistance;
            float d = lightDistance 
               * (asin(min(1.0, 
               length(cross(lightDirection, sphereDirection)))) 
               - asin(min(1.0, _SphereRadius / sphereDistance)));
            float w = smoothstep(-1.0, 1.0, -d / _LightSourceRadius);
            w = w * smoothstep(0.0, 0.2, 
               dot(lightDirection, sphereDirection));
            if (0.0 != _WorldSpaceLightPos0.w) // point light source?
            {
               w = w * smoothstep(0.0, _SphereRadius, 
                  lightDistance - sphereDistance);
            }
 
            float3 ambientLighting = 
               UNITY_LIGHTMODEL_AMBIENT.rgb * _Color.rgb;
 
            float3 diffuseReflection = 
               attenuation * _LightColor0.rgb * _Color.rgb
               * max(0.0, dot(normalDirection, lightDirection));
 
            float3 specularReflection;
            if (dot(normalDirection, lightDirection) < 0.0) 
               // light source on the wrong side?
            {
               specularReflection = float3(0.0, 0.0, 0.0); 
                  // no specular reflection
            }
            else // light source on the right side
            {
               specularReflection = attenuation * _LightColor0.rgb 
                  * _SpecColor.rgb * pow(max(0.0, dot(
                  reflect(-lightDirection, normalDirection), 
                  viewDirection)), _Shininess);
            }
 
            return float4(ambientLighting 
               + (1.0 - w) * (diffuseReflection + specularReflection), 
               1.0);
         }
 
         ENDCG
      }
 
      Pass {      
         Tags { "LightMode" = "ForwardAdd" } 
            // pass for additional light sources
         Blend One One // additive blending 
 
         CGPROGRAM
 
         #pragma vertex vert  
         #pragma fragment frag 
 
         #pragma target 3.0
 
         #include "UnityCG.cginc"
         uniform float4 _LightColor0; 
            // color of light source (from "Lighting.cginc")
 
         // User-specified properties
         uniform float4 _Color; 
         uniform float4 _SpecColor; 
         uniform float _Shininess;
         uniform float4 _SpherePosition; 
            // center of shadow-casting sphere in world coordinates
         uniform float _SphereRadius; 
            // radius of shadow-casting sphere
         uniform float _LightSourceRadius; 
            // in radians for directional light sources
 
         struct vertexInput {
            float4 vertex : POSITION;
            float3 normal : NORMAL;
         };
         struct vertexOutput {
            float4 pos : SV_POSITION;
            float4 posWorld : TEXCOORD0;
            float3 normalDir : TEXCOORD1;
         };
 
         vertexOutput vert(vertexInput input) 
         {
            vertexOutput output;
 
            float4x4 modelMatrix = _Object2World;
            float4x4 modelMatrixInverse = _World2Object; 
               // multiplication with unity_Scale.w is unnecessary 
               // because we normalize transformed vectors
 
            output.posWorld = mul(modelMatrix, input.vertex);
            output.normalDir = normalize(
               mul(float4(input.normal, 0.0), modelMatrixInverse).xyz);
            output.pos = mul(UNITY_MATRIX_MVP, input.vertex);
            return output;
         }
 
         float4 frag(vertexOutput input) : COLOR
         {
            float3 normalDirection = normalize(input.normalDir);
 
            float3 viewDirection = normalize(
               _WorldSpaceCameraPos - input.posWorld.xyz);
            float3 lightDirection;
            float lightDistance;
            float attenuation;
 
            if (0.0 == _WorldSpaceLightPos0.w) // directional light?
            {
               attenuation = 1.0; // no attenuation
               lightDirection = normalize(_WorldSpaceLightPos0.xyz);
               lightDistance = 1.0;
            } 
            else // point or spot light
            {
               lightDirection =
                  _WorldSpaceLightPos0.xyz - input.posWorld.xyz;
               lightDistance = length(lightDirection);
               attenuation = 1.0 / lightDistance; // linear attenuation
               lightDirection = lightDirection / lightDistance;
            }
 
            // computation of level of shadowing w  
            float3 sphereDirection = 
               _SpherePosition.xyz - input.posWorld.xyz;
            float sphereDistance = length(sphereDirection);
            sphereDirection = sphereDirection / sphereDistance;
            float d = lightDistance 
               * (asin(min(1.0, 
               length(cross(lightDirection, sphereDirection)))) 
               - asin(min(1.0, _SphereRadius / sphereDistance)));
            float w = smoothstep(-1.0, 1.0, -d / _LightSourceRadius);
            w = w * smoothstep(0.0, 0.2, 
               dot(lightDirection, sphereDirection));
            if (0.0 != _WorldSpaceLightPos0.w) // point light source?
            {
               w = w * smoothstep(0.0, _SphereRadius, 
                  lightDistance - sphereDistance);
            }
 
            float3 diffuseReflection = 
               attenuation * _LightColor0.rgb * _Color.rgb
               * max(0.0, dot(normalDirection, lightDirection));
 
            float3 specularReflection;
            if (dot(normalDirection, lightDirection) < 0.0) 
               // light source on the wrong side?
            {
               specularReflection = float3(0.0, 0.0, 0.0); 
                  // no specular reflection
            }
            else // light source on the right side
            {
               specularReflection = attenuation * _LightColor0.rgb 
                  * _SpecColor.rgb * pow(max(0.0, dot(
                  reflect(-lightDirection, normalDirection), 
                  viewDirection)), _Shininess);
            }
 
            return float4((1.0 - w) * (diffuseReflection 
               + specularReflection), 1.0);
         }
 
         ENDCG
      }
   } 
   // The definition of a fallback shader should be commented out 
   // during development:
   // Fallback "Specular"
}

SummaryEdit

Congratulations! I hope you succeeded to render some nice soft shadows. We have looked at:

  • What soft shadows are and what the penumbra and umbra is.
  • How to compute soft shadows of spheres.
  • How to implement the computation, including a script in JavaScript that sets some properties based on another GameObject.

Further ReadingEdit

If you still want to know more

  • about the rest of the shader code, you should read Section “Smooth Specular Highlights”.
  • about computations of soft shadows, you should read a publication by Orion Sky Lawlor: “Interpolation-Friendly Soft Shadow Maps” in Proceedings of Computer Graphics and Virtual Reality ’06, pages 111–117. A preprint is available online.


< Cg Programming/Unity

Unless stated otherwise, all example source code on this page is granted to the public domain.