Shadow Maps

Contents:

1. Intro

For reasoning behind these extensions, see also my paper Shadow maps and projective texturing in X3D (accepted for Web3D 2010 conference). PDF linked here has some absolutely minor corrections (for projection* fields and fixed URLs) compared to the conference version. The slides from the presentation are also available.

Specification below comes from this paper (section 4). Text below has some additional notes, mostly specific to our engine and implementation.

Note that the paper, and so portions of the text below, are Copyright 2010 by ACM, Inc. See the link for details, in general non-commercial use is fine, but commercial use usually requires asking ACM for permission. This is a necessary exception from my usual rules of publishing everything on GNU GPL.

Scenery with shadow maps
Just a screenshot with nice shadow maps
Close up shadows on the tree. Notice that leaves (modeled by alpha-test texture) also cast correct shadows.
Close up shadows on the tree, with Percentage Closer Filtering.

One of the shadows algorithms implemented in our engine is shadow maps.

Shadow maps work completely orthogonal to shadow volumes (see shadow volumes docs), which means that you can freely mix both shadow approaches within a single scene. Shadow maps, described here, are usually more adviced: they are simpler to use (in the simplest case, just add "shadows TRUE" to your light source, and it just works with an abritrary 3D scene), and have a better implementation (shadow maps from multiple light sources cooperate perfectly thanks to the shaders pipeline).

Most important TODO about shadow maps: PointLight sources do not cast shadow maps yet. (Easy to do, please report if you need it.)

2. Examples

Our VRML/X3D demo models contain many demos using shadow maps. Download them and open with view3dscene files insde shadow_maps subdirectory. See in particular the nice model inside shadow_maps/sunny_street/, that was used for some screenshots visible on this page.

3. Define lights casting shadows on everything

In the very simplest case, to make the light source just cast shadows on everything, set the shadows field of the light source to TRUE.

*Light {
  ... all normal *Light fields ...
  SFBool     []            shadows     FALSE     
}

This is equivalent to adding this light source to every shape's receiveShadows field. Read on to know more details.

This is the simplest extension to enable shadows.

TODO: In the future, this field (shadows on light) and receiveShadows field (see below) should be suitable for other shadows implementations too We plan to use it for shadow volumes in the future too (removing old shadowVolumesMain extensions and such), and maybe ray-tracer too. shadowCaster (see below) already works for all our shadows implementations.

If you use X3D shader nodes, like ComposedShader and related nodes, be aware that your custom shaders may be ignored. Browsers have to use internal shaders to produce nice shading for shadow receivers. Use instead our compositing shaders extensions for X3D, like Effect and related nodes to write shader code that can cooperate with other effects (like shadow maps, and much more). Or (less adviced) use the lower-level nodes described below to activate shadow maps more manuallly.

4. Define shadow receivers

To enable the shadows on specific receivers, use this field:
Appearance {
  ... all normal Appearance fields ...
  MFNode     []            receiveShadows  []          # [X3DLightNode] list
}

Each light present in the receiveShadows list will cast shadows on the given shape. That is, contribution of the light source will be scaled down if the light is occluded at a given fragment. The whole light contribution is affected, including the ambient term. We do not make any additional changes to the X3D lighting model. The resulting fragment color is the sum of all the visible lights (visible because they are not occluded, or because they don't cast shadows on this shape), modified by the material emissive color and fog, following the X3D specification.

5. The lower-level extensions

5.1. Overview of the lower-level extensions

The following extensions make it possible to precisely setup and control shadow maps. Their use requires a basic knowledge of the shadow map approach, and they are necessarily closely tied to the shadow map workflow. On the other hand, they allow the author to define custom shaders for the scene and control every important detail of the shadow mapping process.

These lower-level extensions give a complete and flexible system to control the shadow maps, making the Appearance.receiveShadows and X3DLightNode.shadows features only a shortcuts for the usual setup.

We make a shadow map texture by the GeneratedShadowMap node, and project it on the shadow receiver by ProjectedTextureCoordinate. An example X3D code (in classic encoding) for a shadow map setup:

  DEF MySpot SpotLight {
    location 0 0 10
    direction 0 0 -1
    projectionNear 1
    projectionFar 20
  }

  Shape {
    appearance Appearance {
      material Material { }
      texture GeneratedShadowMap { light USE MySpot update "ALWAYS" }
    }
    geometry IndexedFaceSet {
      texCoord ProjectedTextureCoordinate {
        projector USE MySpot
      }
      # ... other IndexedFaceSet fields
    }
  }

Note that the shadow texture will be applied in a very trivial way, just to generate intensity values (0 - in shadow, 1 - not in shadow). If you want to have some nice shading, you should use GLSL shader to sample the depth texture (like shadow2DProj(shadowMap, gl_TexCoord[0]).r) and do there any shading you want. Using shaders is generally the way to make shadow mapping both beautiful and in one pass (read: fast), and it's the way of the future anyway. You can start from a trivial fragment shader in our examples: shadow_map.fs.

Note that view3dscene's menu items View -> Shadow Maps -> ... do not affect the lower-level shadow maps. Essentially, when using the lower-level nodes, you directly control the shaders (and everything else) yourself.

Remember: If you don't want to write your own GLSL shader, and you need nice shadows, then these lower-level extensions are not for you. Instead, you could use easy receiveShadows:

  DEF MySpot SpotLight {
    location 0 0 10
    direction 0 0 -1
  }

  Shape {
    appearance Appearance {
      material Material { }
      receiveShadows MySpot
    }
    geometry IndexedFaceSet {  ....  }
  }

Using the receiveShadows approach is simpler, also the browser will use nice internal GLSL shaders automatically.

5.2. Light sources parameters

The motivation behind the extensions in this section is that we want to use light sources as cameras. This means that lights need additional parameters to specify projection details.

To every X3D light node (DirectionalLight, SpotLight, PointLight) we add new fields:

*Light {
  ... all normal *Light fields ...
  SFFloat    [in,out]      projectionNear        0           # must be >= 0
  SFFloat    [in,out]      projectionFar         0           # must be > projectionNear, or = 0
  SFVec3f    [in,out]      up                    0 0 0     
  SFNode     []            defaultShadowMap      NULL        # [GeneratedShadowMap]
}

The fields projectionNear and projectionFar specify the near and far values for the projection used when rendering to the shadow map texture. These are distances from the light position, along the light direction. You should always try to make projectionNear as large as possible and projectionFar as small as possible, this will make depth precision better (keeping projectionNear large is more important for this). At the same time, your projection range must include all your shadow casters.

The field up is the "up" vector of the light camera when capturing the shadow map. This is used only with non-point lights (DirectionalLight and SpotLight). Although we know the direction of the light source, but for shadow mapping we also need to know the "up" vector to have camera parameters fully determined.

You usually don't need to provide the "up" vector value in the file. We intelligently guess (or fix your provided value) to be always Ok. The "up" value is processed like this:

  1. If up = zero (default), assume up := +Y axis (0,1,0).
  2. If up is parallel to the direction vector, set up := arbitrary vector orthogonal to the direction.
  3. Finally, make sure up vector is exactly orthogonal to the direction (eventually rotating it slightly).

These properties are specified at the light node, because both shadow map generation and texture coordinate calculation must know them, and use the same values (otherwise results would not be of much use).

The field defaultShadowMap is meaningful only when some shape uses the receiveShadows feature. This will be described in the later section.

DirectionalLight gets additional fields to specify orthogonal projection rectangle (projection XY sizes) and location for the light camera. Although directional light is conceptually at infinity and doesn't have a location, but for making a texture projection we actually need to define the light's location.

DirectionalLight {
  ... all normal *Light fields ...
  SFVec4f    [in,out]      projectionRectangle   0 0 0 0     # 
      # left, bottom, right, top (order like for OrthoViewpoint.fieldOfView).
      # Must be left < right and bottom < top, or all zero
  SFVec3f    [in,out]      projectionLocation    0 0 0       # affected by node's transformation
}

When projectionNear, projectionFar, up, projectionRectangle have (default) zero values, then some sensible values are automatically calculated for them by the browser. projectionLocation will also be automaticaly adjusted, if and only if projectionRectangle is zero. This will work perfectly for shadow receivers marked by the receiveShadows field. This feature was not "invented" at the time of submitting the PDF paper to the Web3D 2010 conference, so it's not documented there.

SpotLight projecting texture
SpotLight projecting texture 2

SpotLight gets additional field to explicitly specify a perspective projection angle.

SpotLight {
  ... all normal *Light fields ...
  SFFloat    [in,out]      projectionAngle       0         
}

Leaving projectionAngle at the default zero value is equivalent to setting projectionAngle to 2 * cutOffAngle. This is usually exactly what is needed. Note that the projectionAngle is the vertical and horizontal field of view for the square texture, while cutOffAngle is the angle of the half of the cone (that's the reasoning for *2 multiplier). Using 2 * cutOffAngle as projectionAngle makes the perceived light cone fit nicely inside the projected texture rectangle. It also means that some texture space is essentially wasted — we cannot perfectly fit a rectangular texture into a circle shape.

Images on the right show how a light cone fits within the projected texture.

5.3. Automatically generated shadow maps

Now that we can treat lights as cameras, we want to render shadow maps from the light sources. The rendered image is stored as a texture, represented by a new node:

GeneratedShadowMap : X3DTextureNode {
  SFNode     [in,out]      metadata         NULL                  # [X3DMetadataObject]
  SFString   [in,out]      update           "NONE"                # ["NONE"|"NEXT_FRAME_ONLY"|"ALWAYS"]
  SFInt32    []            size             128                 
  SFNode     []            light            NULL                  # any light node
  SFFloat    [in,out]      scale            4.0                 
  SFFloat    [in,out]      bias             4.0                 
  SFString   []            compareMode      "COMPARE_R_LEQUAL"    # ["COMPARE_R_LEQUAL" | "COMPARE_R_GEQUAL" | "NONE"]
}
Shadow map, as seen from the light
Shadow map mapped over the scene

The update field determines how often the shadow map should be regenerated. It is analogous to the update field in the standard GeneratedCubeMapTexture node.

  • "NONE" means that the texture is not generated. It is the default value (because it's the most conservative, so it's the safest value).

  • "ALWAYS" means that the shadow map must be always accurate. Generally, it needs to be generated every time shadow caster's geometry noticeably changes. The simplest implementation may just render the shadow map at every frame.

  • "NEXT_FRAME_ONLY" says to update the shadow map at the next frame, and afterwards change the value back to "NONE". This gives the author an explicit control over when the texture is regenerated, for example by sending "NEXT_FRAME_ONLY" values by a Script node.

The field size gives the size of the (square) shadow map texture in pixels.

The field light specifies the light node from which to generate the map. Ideally, implementation should support all three X3D light source types. NULL will prevent the texture from generating. It's usually comfortable to "USE" here some existing light node, instead of defining a new one. TODO: for now, we do not handle shadow maps from PointLight nodes.

Note that the light node instanced inside the GeneratedShadowMap.light or ProjectedTextureCoordinate.projector fields isn't considered a normal light, that is it doesn't shine anywhere. It should be defined elsewhere in the scene to actually act like a normal light. Moreover, it should not be instanced many times (outside of GeneratedShadowMap.light and ProjectedTextureCoordinate.projector), as then it's unspecified from which view we will generate the shadow map.

Correct bias/scale
Too large bias/scale
Too small bias/scale
Lights editor with bias and scale

Fields scale and bias are used to offset the scene rendered to the shadow map. This avoids the precision problems inherent in the shadow maps comparison. In short, increase them if you see a strange noise appearing on the shadow casters (but don't increase them too much, or the shadows will move back). You may increase the bias a little more carelessly (it is multiplied by a constant implementation-dependent offset, that is usually something very small). Increasing the scale has to be done a little more carefully (it's effect depends on the polygon slope).

Images on the right show the effects of various scale and bias values.

You can adjust the bias, scale and size interactively in view3dscene. Using the Edit->Lights Editor feature, you can configure the defaultShadowMap parameters for a given light, and immediately see the results.

For an OpenGL implementation that offsets the geometry rendered into the shadow map, scale and bias are an obvious parameters (in this order) for the glPolygonOffset call. Other implementations are free to ignore these parameters, or derive from them values for their offset methods.

Field compareMode allows to additionally do depth comparison on the texture. For texture coordinate (s, t, r, q), compare mode allows to compare r/q with texture(s/q, t/q). Typically combined with the projective texture mapping, this is the moment when we actually decide which screen pixel is in the shadow and which is not. Default value COMPARE_R_LEQUAL is the most useful value for standard shadow mapping, it generates 1 (true) when r/q <= texture(s/q, t/q), and 0 (false) otherwise. Recall from the shadow maps algorithm that, theoretically, assuming infinite shadow map resolution and such, r/q should never be smaller than the texture value (it can only be equal or larger).

When the compareMode is set to NONE, the comparison is not done, and depth texture values are returned directly. This is very useful to visualize shadow maps, for debug and demonstration purposes — you can view the texture as a normal grayscale (luminance) texture. In particular, problems with tweaking the projectionNear and projectionFar values become easily solvable when you can actually see how the texture contents look.

For OpenGL implementations, the most natural format for a shadow map texture is the GL_DEPTH_COMPONENT (see ARB_depth_texture). This makes it ideal for typical shadow map operations. For GLSL shader, this is best used with sampler2DShadow (for spot and directional lights) and samplerCubeShadow (for point lights). Unless the compareMode is NONE, in which case you should treat them like a normal grayscale textures and use the sampler2D or the samplerCube types.

Variance Shadow Maps notes: If you turn on Variance Shadow Maps (e.g. by view3dscene menu View -> Shadow Maps -> Variance Shadow Maps), then the generated textures are a little different. If you used the simple "receiveShadows" field, everything is taken care of for you. But if you use lower-level nodes and write your own shaders, you must understand the differences: for VSM, shadow maps are treated always as sampler2D, with the first two components being E(depth) and E(depth^2). See the paper about Variance Shadow Maps, and see example GLSL shader code to handle them.

5.4. Projective texture mapping

We propose a new ProjectedTextureCoordinate node:

ProjectedTextureCoordinate : X3DTextureCoordinateNode {
  SFNode     [in,out]      projector   NULL        # [SpotLight, DirectionalLight, X3DViewpointNode]
}

This node generates texture coordinates, much like the standard TextureCoordinateGenerator node. More precisely, a texture coordinate (s, t, r, q) will be generated for a fragment that corresponds to the shadow map pixel on the position (s/q, t/q), with r/q being the depth (distance from the light source or the viewpoint, expressed in the same way as depth buffer values are stored in the shadow map). In other words, the generated texture coordinates will contain the actual 3D geometry positions, but expressed in the projector's frustum coordinate system. This cooperates closely with the GeneratedShadowMap.compareMode = COMPARE_R_LEQUAL behavior, see the previous subsection.

This can be used in all situations when the light or the viewpoint act like a projector for a 2D texture. For shadow maps, projector should be a light source.

When a perspective Viewpoint is used as the projector, we need an additional rule. That's because the viewpoint doesn't explicitly determine the horizontal and vertical angles of view, so it doesn't precisely define a projection. We resolve it as follows: when the viewpoint that is not currently bound is used as a projector, we use Viewpoint.fieldOfView for both the horizontal and vertical view angles. When the currently bound viewpoint is used, we follow the standard Viewpoint specification for calculating view angles based on the Viewpoint.fieldOfView and the window sizes. (TODO: our current implementation doesn't treat currently bound viewpoint this way.) We feel that this is the most useful behavior for scene authors.

When the geometry uses a user-specified vertex shader, the implementation should calculate correct texture coordinates on the CPU. This way shader authors still benefit from the projective texturing extension. If the shader author wants to implement projective texturing inside the shader, he is of course free to do so, there's no point in using ProjectedTextureCoordinate at all then.

Note that this is not suitable for point lights. Point lights do not have a direction, and their shadow maps can no longer be single 2D textures. Instead, they must use six 2D maps. For point lights, it's expected that the shader code will have to do the appropriate texture coordinate calculation: a direction to the point light (to sample the shadow map cube) and a distance to it (to compare with the depth read from the texture).

Deprecated: In older engine versions, instead of this node you had to use TextureCoordinateGenerator.mode = "PROJECTION" and TextureCoordinateGenerator.projectedLight. This is still handled (for compatibility), but should not be used in new models.

5.5. How the receiveShadows field maps to the lower-level extensions

Placing a light on the receiveShadows list is equivalent to adding the appropriate GeneratedShadowMap to the shape's textures, and adding the appropriate ProjectedTextureCoordinate to the geometry texCoord field. Also, receiveShadows makes the right shading (for example by shaders) automatically used.

In fact, the receiveShadows feature may be implemented by a simple transformation of the X3D node graph. Since the receiveShadows and defaultShadowMap fields are not exposed (they do not have accompanying input and output events) it's enough to perform such transformation once after loading the scene. Note that the texture nodes of the shadow receivers may have to be internally changed to multi-texture nodes during this operation.

An author may also optionally specify a GeneratedShadowMap node inside the light's defaultShadowMap field. See the lights extensions section for defaultShadowMap declaration. Note that when GeneratedShadowMap is placed in a X3DLightNode.defaultShadowMap field, then the GeneratedShadowMap.light value is ignored (we always use the light containing defaultShadowMap declaration then).

Leaving the defaultShadowMap as NULL means that an implicit shadow map with default browser settings should be generated for this light. This must behave like update was set to ALWAYS.

In effect, to enable the shadows the author must merely specify which shapes receive the shadows (and from which lights) by the Appearance.receiveShadows field. This way the author doesn't have to deal with lower-level tasks:

  1. Using GeneratedShadowMap nodes.
  2. Using ProjectedTextureCoordinate nodes.
  3. Writing own shaders.

6. Optionally specify shadow casters (Appearance.shadowCaster)

By default, every Shape in the scene casts a shadow. This is the most common setup for shadows. However it's sometimes useful to explicitly disable shadow casting (blocking of the light) for some tricky shapes. For example, this is usually desired for shapes that visualize the light source position. For this purpose we extend the Appearance node:

Appearance {
  ... all Appearance fields ...
  SFBool     [in,out]      shadowCaster     TRUE      
}

Note that if you disable shadow casting on your shadow receivers (that is, you make all the objects only casting or only receiving the shadows, but not both) then you avoid some offset problems with shadow maps. The bias and scale parameters of the GeneratedShadowMap become less crucial then.

This is honoured by all our shadow implementations: shadow volumes, shadow maps (that is, both methods for dynamic shadows in OpenGL) and also by our ray-tracers.

Note that no shadow algorithm can deal with transparency by alpha-blending. So these shapes are not treated as shadow casters, by any shadow algorithm right now.