Shadow Maps

Contents:

1. Intro

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/castle_with_trees/, 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, be aware that your custom shaders are then responsible for performing shadow mapps tests (as your shaders override engine shaders). Use instead our compositing shaders extensions for X3D, like Effect, to write shader code that can cooperate with our shadow maps (and other engine effects).

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. Additional features

The following extensions make it possible to precisely control the shadow maps (and/or projective texturing) behavior. An example usage:

DEF MySpot SpotLight {
  location 0 0 10
  direction 0 0 -1
  projectionNear 1
  projectionFar 20
  defaultShadowMap GeneratedShadowMap {
    update "ALWAYS"
    size 1024
  }
}

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

The shadow map will be used by the engine to determine whether the associated light is obscured or not at each screen pixel. Our default shaders will make it look nice out-of-the-box.

5.1. Optionally specify light projection

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 allows to adjust shadow map parameters. It is used only when the light actually casts shadows using shadow maps (so the light is listed among some shape receiveShadows, or the light has shadows field set TRUE). 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.

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.

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.2. Optionally specify shadow map parameters (GeneratedShadowMap node)

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; deprecated
  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 deprecated 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.

Note that this field is ignored when the GeneratedShadowMap is placed in a X3DLightNode.defaultShadowMap field. And placing GeneratedShadowMap on X3DLightNode.defaultShadowMap is the only non-deprecated usage for GeneratedShadowMap node now.

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.

Usage notes: You should place GeneratedShadowMap node inside light's defaultShadowMap field.

Alternatively, only for backward compatibility, you can also treat GeneratedShadowMap as any other X3DTextureNode and place it inside Appearance.texture.

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.

5.3. Use projective texturing explicitly to map textures (ProjectedTextureCoordinate node)

We add 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.4. 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 shadow maps cannot deal with transparency by alpha-blending. The objects using blending are never shadow casters, for shadow maps and ray-tracers.

5.5. Deprecated: use GeneratedShadowMap and ProjectedTextureCoordinate at each shadow-receiving shape

For original reasoning behind these extensions, see also my paper Shadow maps and projective texturing in X3D (presented at Web3D 2010 conference). The slides from the presentation are also available.

Note that the adviced usage of shadow maps (section 4 of the paper) shifted a bit since the paper was written. The PDF paper talks about "low-level nodes", which are deprecated now, for reasons explained below.

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.

For backward compatibility (avoid it in new applications!), you can place GeneratedShadowMap node in the Appearance.texture (possibly inside MultiTexture node). In this case you also need to specify texture coordinates using an explicit ProjectedTextureCoordinate node. An example is below:

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"
      size 1024
    }
  }
  geometry IndexedFaceSet {
    texCoord ProjectedTextureCoordinate {
      projector USE MySpot
    }
    # ... other IndexedFaceSet fields
  }
}

Note that view3dscene's menu items View -> Shadow Maps -> ... does not affect the shadow map in this case.

This approach is deprecated now. Reasons:

  • Placing the shadow map in Appearance.texture is not really consistent with normal Appearance.texture treatment, since the shadow map affects the rendering in a special way (it "masks" the particular light source contribution).

    Shadow map does not mix the fragment color like Appearance.texture should (that scales the Material.diffuseColor, PhysicalMaterial.baseColor or UnlitMaterial.emissiveColor).

  • Placing the shadow map here doesn't work with CommonSurfaceShader, as it has it's own textures. CommonSurfaceShader.diffuseTexture hides the Appearance.texture.