Shader Programming: Massively parallel art [Episode 9: Specular Lighting]

in #learnwithsteem2 months ago (edited)

Welcome back to the much-delayed 9th episode of the Shader series! I'm happy to be back on Steem after some IRL issues.

Today we're going to improve our lighting model by introducing a specular lighting component.

PART 9: Specular Lighting

Specular lighting models the glints and highlights that we see when light reflects off a shiny object into our eye, and lets us give a realistic "metallic" look to objects in our scene.

Unlike diffuse lighting, in which a surface looks the same when viewed from any angle, specular lighting depends not on just on the light direction and surface normal, but also on the viewer's position.

If you look at a shiny object in real life, like a coin or a ring, you'll notice that the highlights change when you move your eye. This is the specular component we'll model.

What we need to calculate

specular lighting.png

I already said that when our eye moves, a scene's specular highlights change. It's therefore not surprising that we'll need to calculate an eye direction vector from our hit point - shown in blue on the diagram above.

The other vector we need to find is the reflected light direction; note on the diagram that the solid yellow light direction vector is reflected through the surface normal to give the dashed yellow reflection vector.

Once we have those, we're interested in the angle between the eye direction and the reflected light direction, marked in green on the diagram.

The smaller that angle, the closer the reflected light direction is to colliding directly with the eye, and the brighter the specular highlight!

Calculating the vectors

Let's deal with the eye direction vector first. If you remember from last time, this is an easy one; to get a direction vector from one point in 3D space to another, we subtract the starting point from the destination point, and normalize the result to give us a unit vector with magnitude of 1.

        vec3 eyeDirection = normalize(camPos - currentPos);

So, now we need to find that reflected light direction vector. GLSL to the rescue again, with the built-in reflect() function, which does exactly what we need; it accepts a vector to reflect and a surface normal vector to reflect it against, and returns the reflected vector.

Currently, our lightDirection vector points from the hit point to the light. In actual fact, to reflect it off the surface, we need a vector from the light source to the surface. For this, we simply use the negative of our existing lightDirection.

Note that because lightDirection is already normalized before we reflect it, we don't need to normalize the result as it will still have a magnitude of 1.

        vec3 reflectedLight = reflect(-lightDirection, normal);

Finding the specular term

Now that we have these two vectors, the rest of the task is easy. Recall from when we were working on diffuse lighting that the dot product of 2 vectors gives the cosine of the angle between them.

Just as before, we can use this cosine directly as a brightness value, as the higher its value the smaller the angle, and the brighter the specular highlight should be.

We do need to make sure that we discard any negative values however, using max().

        float spec = max(0., dot(reflectedLight, eyeDirection));

Do notice how similar that line looks to the line where we calculate the diffuse lighting term.

If we leave it like that, it does actually work, but our highlights are rather large and spread out. This can be effective for modelling certain surface materials, but generally we use pow() to make the specular highlights smaller.

        spec = pow(spec, 12.);

The higher the power we raise spec to, the smaller the highlight. The image below illustrates various values.

specular powers.png

The final shader

Putting it together, these are all the new lines to add to our shader since last time.

        // Calculate specular factor
        vec3 eyeDirection = normalize(camPos - currentPos);
        vec3 reflectedLight = reflect(-lightDirection, normal);

        float spec = max(0., dot(reflectedLight, eyeDirection));
        spec = pow(spec, 12.);
        // Apply lighting
        vec3 surfaceColour = vec3(.5,.7,.3);    // Greenish
        col = surfaceColour * (diffuse + spec);

Note that we simply add the diffuse and specular lighting components together to give a final brightness value, which we then multiply with the surface colour to get our final, shaded colour.

Below is the complete code for our raymarcher at this point.

You can see it working on ShaderToy here.

// *** Simple Raymarcher V3 ***

// Scene distance function
float GetSceneDistance(vec3 p) {
    // Define sphere position and radius
    vec3 ballPos = vec3(0,0,1);
    float ballRadius = 1.;

    return distance(p, ballPos) - ballRadius;

// Get surface normal at given point
vec3 CalcNormal(vec3 p) {
    vec2 h = vec2(.001, 0); // Epsilon vector for swizzling
    vec3 normal = vec3(
       GetSceneDistance(p+h.xyy) - GetSceneDistance(p-h.xyy),   // x gradient
       GetSceneDistance(p+h.yxy) - GetSceneDistance(p-h.yxy),   // y gradient
       GetSceneDistance(p+h.yyx) - GetSceneDistance(p-h.yyx)    // z gradient
    return normalize(normal);

// Entrypoint
void mainImage(out vec4 fragColor, in vec2 fragCoord)
    // Calculate uv
    vec2 uv = (fragCoord / iResolution.xy - .5) * 2.;
    uv.x *= iResolution.x / iResolution.y;
    // Cam pos and ray direction for current pixel
    vec3 camPos = vec3(0,0,-1);
    vec3 rayDir = vec3(uv, 1.0);
    rayDir = normalize(rayDir);

    // Raymarch for max 128 steps
    vec3 currentPos = camPos;
    float distance;
    for (int i = 0; i < 128; i++) {
        distance = GetSceneDistance(currentPos); // Check scene distance
        if (distance < 0.01) // Break if we hit something...

        currentPos += distance * rayDir; // ...otherwise, step along ray

    // Calculate lighting
    vec3 col = vec3(.5);
    if (distance < 0.01) {
        // Get light direction
        vec3 lightPosition = vec3(-3, 2, -3);
        vec3 lightDirection = lightPosition - currentPos;
        lightDirection = normalize(lightDirection);
        // Get surface normal
        vec3 normal = CalcNormal(currentPos);
        // Calculate diffuse factor
        float diffuse = max(0., dot(normal, lightDirection));
        // Calculate specular factor
        vec3 eyeDirection = normalize(camPos - currentPos);
        vec3 reflectedLight = reflect(-lightDirection, normal);

        float spec = max(0., dot(reflectedLight, eyeDirection));
        spec = pow(spec, 12.);
        // Apply lighting
        vec3 surfaceColour = vec3(.5,.7,.3);    // Greenish
        col = surfaceColour * (diffuse + spec);
    // Output colour
    fragColor = vec4(col,1);

Next time, as we approach the end of the raymarching series, we're going to look at adding some more interesting geometry to our scene.

After this series, I have really big plans for a fresh new series on a subject that's much more Steem focussed, so stay tuned :)

Shader gallery

The cool things we've made so far in the series.

Episode 9: Specular lighting
Episode 8: Diffuse lighting
Episode 7: First raymarcher
Episode 5: Rotozoomer
Episode 4: Plasma

Coin Marketplace

STEEM 0.22
TRX 0.06
JST 0.025
BTC 19325.03
ETH 1313.02
USDT 1.00
SBD 2.45