Simple Shadows

The full source code for this part of the tutorial is available in the file root/tutorials/s3d/shadow.bs. You can run it using storm -f tutorials.s3d.shadow. As in the previous parts, the code is fairly well-documented, and it is likely possible to read it on its own.

This tutorial builds upon the code from the lighting part (or the cel-shading) part of the tutorial. As you will see, this part only modifies the painter and adds another shader. To avoid name clashes, the painter is renamed from LightPainter to ShadowPainter. Apart from that, this sample will reuse the vertex types and shaders from the previous parts.

More Geometry

To make sure that we actually see the shadow, we start by adding a floor below the teapot and a sphere that shows where the light source is located. We do this by adding a few models to the painter (here called ShadowPainter):

class ShadowPainter extends SamplePainter3D {
    // Normal pipeline.
    LightPipeline pipeline;

    // Model to render (teapot).
    Model model;

    // Cube used as a floor.
    Model floor;

    // Sphere used to indicate the position of the light.
    Model cameraPos;

    // Create.
    init() {
        init {
            // Create the pipeline and attach shaders.
            pipeline = LightPipeline:create(lightVertexShader, lightPixelShader);

            // Create the default camera transform.
            cameraPos = rotateX(-35 deg) * translate(Vector(0, -3, 6));

            // Load a teapot to show.
            model = resUrl("teapot.obj").loadOBJ;
            // Create a cube and scale it as the floor. Also move it to
            // make the top edge slightly below 0.
            floor = Model:unitCube()
                .transform(scale(Vector(40, 0.1, 40)) * translate(Vector(0, -0.15, 0)));
            // Create a sphere that we place where the light is.
            light = Model:sphere(0.2, 20, 20);
        }

        // Set colors.
        model.color = red;
        floor.color = green;
        light.color = yellow;

        // Set ambient light strength.
        pipeline.ambientStrength = 0.1;

        // Set specular light properties.
        pipeline.specularStrength = 0.8;
        pipeline.specularExponent = 32.0;
        pipeline.specularColor = white;
    }
}

Note that we place the floor slightly below the coordinate y = 0. This is convenient, since it will be natural to render the shadow at y = 0 later.

We also update the render function to actually draw the new geometry. To make the shadows a bit more interesting, we also make the teapot move up and down a bit.

Bool render(Size size, Graphics3D g) : override {
    Float time = animationTime(20 s);

    // Clear the buffer.
    g.clearDepth();
    g.fill(black);

    // Set the camera matrix.
    pipeline.camera = cameraPos * perspectiveProject(100 deg, size.aspect, 0.1, 100);

    // Let the light rotate around the model in a big circle.
    Vector lightPos = Vector(0, 7, -7) * rotateY(360 deg * -time);
    pipeline.lightPos = lightPos;

    // Compute the camera position, for specular highlights.
    pipeline.cameraPos = Vector(0, 0, 0) * cameraPos.inverted;

    // Draw the light.
    // Note: We cheat a bit here. We use scale(-1) to flip the normals of the sphere
    // inwards. We do this since the light source is *inside* the sphere. As such,
    // this means that the light will be completely lit up. For this reason we bypass
    // the 'world' setter we created earlier.
    pipeline.worldPosition = translate(lightPos);
    pipeline.worldNormal = scale(-1);
    pipeline.render(g, light);

    // Draw the teapot. Make it move up and down slightly as well as rotate.
    Vector modelPos(0, 2 + sin(time * 3 * 360 deg), 0);
    pipeline.world = rotateY(360 deg * time * 4) * scale(0.5) * translate(modelPos);
    pipeline.render(g, model);

    // Draw the floor.
    pipeline.world = Transform();
    pipeline.render(g, floor);
}

This code also shows how we can render multiple sets of geometry (with different positions, for example) using the same pipeline. At this point, we can run the program and we should see a rotating teapot that moves up and down above a floor. From time to time the light source will also come into view.

As a final step, let's render a shadow of a teapot. For this, we will simply project the geometry onto the y=0 plane, rather than doing proper shadow mapping. As you will see, this gives a fairly good-looking result, but the teapot will only cast a shadow onto the floor. For example, the teapot's handle will not cast a shadow onto the teapot itself.

To render the shadow, we first need a different pixel shader that discards the color of the geometry and replaces it with the color black:

pixel shader for LightPipeline: Output shadowPixelShader(LightIntermediate point) {
    Output(black);
}

Then, we need a new pipeline instance so that we can use the two sets of shaders for rendering. As such, we add the following to the shader class:

class ShadowPainter extends SamplePainter3D {
    // Normal pipeline.
    LightPipeline pipeline;

    // Pipeline for rendering shadows.
    LightPipeline shadowPipeline;

    // ...

    // Create.
    init() {
        init {
            // Create the pipelines.
            pipeline = LightPipeline:create(lightVertexShader, lightPixelShader);
            shadowPipeline = LightPipeline:create(lightVertexShader, shadowPixelShader);

            // ...
        }

        // ...
    }

    // ...
}

Note that we don't have to set up all light parameters on shadowPipeline, since it will not do any light computations - shadows are always rendered in black regardless.

Finally, we can use the shadow pipeline to render the teapot's shadow in the render function. It is not too important exactly when we do this (unless we use transparency, which has other problems), but it is convenient to do it right after rendering the teapot itself.

A key insight here is that we use the core.geometry.Transform shadowProjectXZ(core.geometry.Vector light) function to create a matrix that projects the entire teapot onto the XZ plane (i.e. where y = 0). This gives us a flat version of the teapot that we render in black to simulate a shadow. This looks as follows:

Bool render(Size size, Graphics3D g) : override {
    // ...

    // Draw the teapot.
    // ...

    // Draw the shadow. Note that we need to bypass the 'world' setter here
    // since the projection matrix is not invertible. Calling 'world' would
    // trigger an assertion as 'forNormal' involves an inverse.
    shadowPipeline.world = pipeline.worldPosition * shadowProjectXZ(lightPos);
    shadowPipeline.render(g, model);

    // Draw the floor.
    // ...
}

Now, if we run the program, we will see that the teapot seems to cast a shadow onto the green floor.