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:
-
Since we will perform lightning in world-space, we need to split the
cameramatrix into two pieces. We call one of themworld, and let it translate from model to world space. We call the other onecameraand let it transform vertices from world space to camera space.The reason for doing this is that it lets us retain points and normals in world space. It is more intuitive to perform lightning calculations in this coordinate system, as we don't have to counteract the effects of the projection that is at the end of the
cameramatrix. -
It turns out that we need a different matrix for transforming vertex normals (for example, we
don't want to apply translations to normals). We need to store that matrix as well (
forNormal). -
The pixel shader also needs to know the camera position in world-space (
cameraPos), as well as the position of the light in world space (lightPos). - Finally, we store information about global light properties in the pipeline.
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.
