Shadows add realism to the scene, provide depth cues and reveal contact points. In cases when realism is less important for stylistic or performance reasons a viable alternative to shadow mapping could well be planar shadows. Planar shadows are created by projecting the object’s mesh to a shadow receiver plane based on light direction. While being fast and simple they also come with some drawbacks.

Projection shadows
Projection shadows Low-poly and high-poly mesh shadows

Shadow mapping

Unity uses shadow mapping algorithm to produce shadows which are convincing, objects can be self-shadowed. To calculate them all objects that cast and receive shadows must be processed from a light source perspective to generate light map information. The final shadow quality is determined by shadow map size and depth. Increasing size and depth of course comes at a cost and aliasing needs to be applied in order to smooth out shadow edges.

Projected planar shadows

Planar shadows are created by projecting the object’s mesh to a shadow receiver plane based on light direction. But in this case rather rendering the shadows onto a texture, object’s copy is projected on a shadow receiving plane.

This is a simpler method and can be less expensive to execute. In place of a high-poly object, a lower quality object or a primitive can be used to increase performance furthermore.

This method works really well when receiver plane is flat or at least relatively flat. But since shadows are flat, they can not be cast onto another object or itself. Z-fighting can occur between shadow receiver mesh and shadow mesh.

Implementation

Projection shadows
Projection shadows Projecting object's mesh onto a plane

Projecting a mesh onto a plane is implemented using a shader. A controller script is attached to each shadow mesh object in order to send the shadow receiving plane information to the shader.

Shader

Mesh vertices are projected onto a plane using plane normal vector and normalized world space light direction. The plane normal is populated via the script, light direction is already provided by Unity. The light direction is available when shader’s light mode is set to forward base via tags. The light vector has to be normalized and reversed, since it points into the opposite direction to where the shadow should be.

The vertex has to be pushed towards the ground plane using light direction. To calculate the distance we scale the normalized light direction vector.

Similar triangles
Similar triangles Pushing the vertex to the ground plane using light direction

Where:

  • L is dot product of plane normal and normalized inversed light direction
  • Ly is dot product of plane normal and world vertex position

The distance between original vertex and vertex projected on the ground plane is Ly * (1 / -L).

The vertex code of the shader now looks like this:

v2f vert(appdata v)
{
  v2f o;

  // In ForwardBase light mode _WorldSpaceLightPos0 is always direction light
  float4 worldLightDirection = -normalize(_WorldSpaceLightPos0);

  // Calculate vertex offset
  float planeNormalDotWorldVertex = dot(_PlaneNormal, mul(unity_ObjectToWorld, v.vertex));
  float planeNormalDotLightDir = dot(_PlaneNormal, worldLightDirection);
  float3 worldVertexToPlaneVector = worldLightDirection * (planeNormalDotWorldVertex / (-planeNormalDotLightDir));

  // Add vertex offset in local coordinates before applying final transformation
  o.vertex = UnityObjectToClipPos(v.vertex + mul(unity_WorldToObject, worldVertexToPlaneVector));

  // Apply fog
  UNITY_TRANSFER_FOG(o,o.vertex);
  return o;
}

The fragment shader just uses a solid color that is set as a material parameter.

fixed4 frag(v2f i) : SV_Target
{
  fixed4 col = _Color;

  // Apply fog
  UNITY_APPLY_FOG(i.fogCoord, col);
  return col;
}

Controller script

Controller script should be attached to all objects which meshes are rendered as projected shadows. On each update it updates the shader’s plane normal parameter. Please note that plane normal parameter contains plane normal and distance between plane origin and world origin, measured along the plane normal direction. Having plane normal as a parameter of the shader allows shadow receiving plane to move and rotate while keeping mesh projected at the right position.

private void Update()
{
    if (shadowReceiverPlane != null)
    {
        // Update shader's plane normal vector with plane's distance from origin
        meshRenderer.sharedMaterial.SetVector("_PlaneNormal",
           new Vector4(
                shadowReceiverPlane.transform.up.x,
                shadowReceiverPlane.transform.up.y,
                shadowReceiverPlane.transform.up.z,
                -Vector3.Dot(shadowReceiverPlane.transform.up, shadowReceiverPlane.transform.position)
           ));
    }
}

Avoiding Z-fighting

When shadow mesh is projected onto a plane, Z-fighting might occur. The simplest way to fix this is to raise the shadow for a tiny amount above the ground plane. In the finished demo project, the shadow receiver plane is an empty game object that is raised one centimeter above the ground plane. Feel free to check it out.

Putting it all together

The project’s demo scene features two teapots. Each of them has a child object that has a shadow material assigned to it. The shadow material is using the projection shadow shader. This allows for a bit more flexibility since shadow mesh can be a lower poly object or a primitive - a box for example. But it also requires manual setup steps that have the potential to be automated.

The alternative approach could be to simply render the original mesh again in a second shader pass. Although convenient, this would reduce the flexibility and we would have to couple the main, first pass shader with shadow shader. Which path to take usually depends on project requirements.

Star

Conclusion

The projected planar shadows are simple and fast. They produce hard shadows on relatively flat planes only. With no ability to cast shadows on itself or other non-planar objects its usage really is limited.

The demo project itself is just a proof of concept. It needs a bit more work before the shadow system is suitable for a non-trivial project as shadow casters should be set up automatically etc.

Resources

comments powered by Disqus