Lightning

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

This tutorial builds upon the code from the model tutorial. If you started writing your own code previously, you can continue working on it. Note, however, that the names of types and shaders have been modified from Model... to Light... to avoid name clashes in the tutorial package.

Updating the Pipeline

As before, we start by updating the pipeline. This time the changes are driven by the fact that the pixel shader needs to have more information than before. In particular, we need to make the following changes:

All in all, we update the pipeline to look like below:

pipeline LightPipeline {
    data (Vertex vertex) -> LightIntermediate -> Output;

    // Model space to world space. Used to transform positions.
    Transform worldPosition;

    // Model space to world space. Used to transform normals.
    Transform worldNormal;

    // World space to camera space.
    Transform camera;

    // Camera position in world space.
    Vector cameraPos;

    // Position of the light in world space.
    Vector lightPos;

    // Ambient light strength.
    Float ambientStrength;

    // Specular light strength and exponent.
    Float specularStrength;
    Float specularExponent;

    // Specular color.
    Color specularColor;
}

We also need to update the intermediate vertex type. In particular, we need to include the world-space position, and normal coordinates. This gives us the following:

vertex LightIntermediate {
    // Screen space position (as before).
    Vector4 pos : position, perspective;

    // World position. Used for lightning.
    Vector world : perspective;

    // Normal direction transformed to world space. For lightning.
    Vector normal : perspective;

    // Vertex color. Used for ambient and diffuse lightning.
    Color color : perspective;

    // Texture coordinates, if we want to apply textures.
    Point tex : perspective;
}

Finally, we need to update the shaders. As a starting point, we modify the vertex shader to provide all relevant information to the pixel shader, but we wait to implement light calculations until a bit later.

vertex shader for LightPipeline: LightIntermediate lightVertexShader(Vertex point) {
    Vector4 worldPos = Vector4(point.pos) * worldPosition;
    Vector4 screenPos = worldPos * camera;

    // Transform the normal. Note that we need to normalize it at some point. We do
    // that later, since the interpolation between the vertex and pixel shaders will
    // undo any normalization we perform here anyway.
    Vector normal = point.normal * worldNormal;

    LightIntermediate(screenPos, Vector(worldPos), normal, point.color, point.tex);
}

As you can see from above, the vertex shader resembles the ones we have seen so far. The main difference this time is that we transform the model's coordinates in two steps. First, we apply the world transform to compute its world space coordinates. We then apply camera to compute its screen space coordinates. This allows us to save the world coordinates and give them to the pixel shader to perform lighting.

For now, we use a simple pixel shader. That way we can get something on the screen to see that we are supplying (approximately) the correct data to the rendering pipeline at least.

pixel shader for LightPipeline: Output lightPixelShader(LightIntermediate point) {
    Output(point.color);
}

Updating the Painter

Before we can perform any lightning, we need to supply the new values to the pipeline. To do that, we need some slight modifications to our painter.

We start by setting the global light properties in the constructor of the painter:

init() {
    init {
        pipeline = LightPipeline:create(lightVertexShader, lightPixelShader);
        cameraPos = rotateX(-35 deg) * translate(Vector(0, 0, 3));
        model = resUrl("teapot.obj").loadOBJ.withColor(red);
    }

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

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

Then, perhaps the more interesting change. In the render function we need to compute and update the transforms that the shaders use. We will also add code to make the light source rotate a bit above the model so that we can observe the light from different angles more easily.

Bool render(Size size, Graphics3D g) : override {
    Float time = animationTime(20 s);  // So that we can have different speeds!

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

    // Set the world transforms.
    Transform world = rotateY(360 deg * time * 4) * scale(0.5);
    pipeline.worldPosition = world;
    pipeline.worldNormal = world.forNormal();

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

    // Set the light position. We let it rotate above the model in a big circle, but slower.
    pipeline.lightPos = Vector(0, 10, -10) * rotateY(360 deg * -time);

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

    // Now, we can render the model as before.
    pipeline.render(g, model);

    true;
}

It is worth noting that forNormal involves inverting the matrix. This is why we make sure to do compute the matrix once on the CPU, rather than letting the shaders do the computation for each pixel.

At this point you can run the program. Perhaps dissapointingly, you will just see the same rotating teapot as before, since we have not yet used the new information available to the shaders.

Convenient Updates of Matrices

As you can see above, we need to remember to set multiple shader uniforms at the same time. We can simplify this a bit by utilizing the fact that a pipeline is essentially a class. In particular, this allows us to create assign functions that update multiple uniforms in one go. For example, to update the world position conveniently, we can add an assign function as follows:

pipeline LightPipeline {
    data (Vertex vertex) -> LightIntermediate -> Output;

    // Model space to world space. Used to transform positions.
    Transform worldPosition;

    // Model space to world space. Used to transform normals.
    Transform worldNormal;

    // Set both 'worldPosition' and 'worldNormal' in one go.
    assign world(Transform tfm) {
        worldPosition = tfm;
        worldNormal = tfm.forNormal();
    }

    // ...
}

This lets us replace the two assignments to worldPosition and worldNormal in our render function with:

// Set the world transforms.
pipeline.world = rotateY(360 deg * time * 4) * scale(0.5);

Ambient Light

Now it is time to actually apply some lightning. In this tutorial we will implement what is sometimes referred to as the Phong lightning model. We will start with what is perhaps the most boring part, ambient light. This portion of light models the "stray" light that has bounced around in the scene enough to have no direction in particular. As such, it is often modeled using only a factor (or a dark color) to represent the fact that ambient light is typically rather faint. For example, we just set the ambient light to 0.1 in the painter.

In the pixel shader, we can implement it as follows:

pixel shader for LightPipeline: Output lightPixelShader(LightIntermediate point) {
    // Ambient light:
    Color light = point.color * ambientStrength;

    Output(light);
}

As you can see, ambient light only scales the ordinary color of the vertex. As such, if we run the program now, the only thing we will see is a much darker version of the teapot.

Diffuse Light

The second part of the light is diffuse light. This part of light models the fact that surfaces that are perpendicular to a light source receives a light of higher intensity than surfaces that are angled to the light. Or equivalently, the surface receives the most light if the surface normal points directly at the light source. Note that this simplification assumes that once the light hits the surface, the roughness of the surface reflects the light evenly in all directions. Because of this, the camera position does not matter for diffuse light.

We can model it in the pixel shader as follows:

pixel shader for LightPipeline: Output lightPixelShader(LightIntermediate point) {
    // Compute directions to light sources and normalize the normal vector
    // (interpolation and translation may have altered its length).
    Vector lightDir = (lightPos - point.world).normalized;
    Vector normal = point.normal.normalized;

    // Ambient light:
    Color light = point.color * ambientStrength;

    // Diffuse light:
    Float diffuse = max(normal * lightDir, 0.0);
    light += point.color * diffuse;

    Output(light);
}

Note that we can simply compute the light strength as the dot product between the light direction and the surface normal. Since both vectors are normalized, this gives us the cosine of the angle between the two vectors, which corresponds to how light intensity falls off with angles. We then use max to avoid negative light intensities which will mess with the other parts of lightning (e.g. entirely eliminate the ambient light).

If we run the code at this point, we will get a more exciting result. The addition of ambient light highlight the fact that the teapot is indeed not flat, but rather has some volume to it!

Specular Light

The final part of the Phon lightning model is specular light, or sometimes called specular highlights. This part models the fact that most surfaces do not reflect light evenly in all directions. Rather, they reflect more light in the direction directly perpendicular to the incoming light. This makes objects appear shiny.

In our pixel shader, we can implement it as follows:

pixel shader for LightPipeline: Output lightPixelShader(LightIntermediate point) {
    // Compute directions to light sources, the camera, and normalize the normal vector
    // (interpolation and translation may have altered its length).
    Vector cameraDir = (cameraPos - point.world).normalized;
    Vector lightDir = (lightPos - point.world).normalized;
    Vector normal = point.normal.normalized;

    // Ambient light:
    Color light = point.color * ambientStrength;

    // Diffuse light:
    Float diffuse = max(normal * lightDir, 0.0);
    light += point.color * diffuse;

    // Specular light:
    Vector reflectDir = lightDir.reflect(normal);
    Float specular = pow(max(cameraDir * reflectDir, 0.0), specularExponent);
    light += specularColor * specularStrength * specular;

    Output(light);
}

Note that we use the reflect member of Vector to compute the reflection direction of the light as it is reflected in a plane with a normal of normal. The computation for the strength of the light is then quite similar to the diffuse light: we compute the intensity using the dot product and make sure it is positive using max. However, to make the reflection more narrow, we raise the result to 32. We then scale the output with the specular intensity and add the specular color to our final light mix.

At this point we are finished. Running the example now will show some highlights which makes the teapot look shiny.