Shading: normals, light, and texture#

The rasteriser decided which pixels a triangle covers; shading decides their colour. It rests entirely on the dot product: a surface is bright in proportion to how much it faces the light, and that is one dot product. The rest is refinement.

Normals#

A normal is a unit vector pointing out of a surface. For a triangle it is the cross product of two edges:

my $n = v_norm(v_cross(v_sub($v1, $v0), v_sub($v2, $v0)));
# [0, 0, 1] for a triangle lying in the xy plane

Direction depends on winding, as with back-face culling: reversing the vertex order gives [0, 0, -1]. A mesh with inconsistent winding has some normals pointing inward, and those triangles render black.

One normal per triangle lights a flat surface. For curved objects, store a normal per vertex instead, averaging the normals of the faces meeting there. The geometry stays faceted, the lighting does not, which is why a sphere of 200 triangles can look round.

Lambert#

Brightness is the cosine of the angle between the normal and the direction to the light, which for unit vectors is their dot product.

# Both must be unit vectors, and $light_dir points FROM the surface
# TOWARD the light, not the direction the light travels.
my $i = v_dot($normal, $light_dir);
$i = 0 if $i < 0;

The clamp matters: a negative value means the light is behind the surface, and without it light shines through solid objects.

light direction              intensity
straight on  [0,0,1]         1.000
30 deg off   [0.5,0,0.87]    0.866
60 deg off   [0.87,0,0.5]    0.500
edge on      [1,0,0]         0.000
from behind  [0,0,-1]        0.000

This models a matte surface: chalk, paper, unfinished wood. It looks identical from every viewing angle, because the formula never mentions the viewer.

Alone it leaves anything facing away from the light pure black, which no real scene is, because light bounces. The cheap fix is an ambient term:

my $i = 0.1 + 0.9 * $lambert;      # never fully black

Not physically justified: a stand-in for the bounced light the model does not simulate, which keeps shadowed areas readable.

Specular#

Shiny surfaces have a highlight that moves with the viewer, which Lambert cannot produce. Blinn-Phong adds one using the halfway vector, the direction midway between light and viewer:

# $view_dir points FROM the surface TOWARD the viewer, matching
# $light_dir's convention. Clamp the dot product before the power:
# a negative base with a fractional exponent is a domain error.
my $H = v_norm(v_add($light_dir, $view_dir));
my $nh = v_dot($normal, $H);
$nh = 0 if $nh < 0;
my $spec = $nh ** $shininess;

When the normal aligns with the halfway vector the surface bounces light straight at the viewer and the highlight peaks. The exponent controls how tight it is:

shininess    specular
1            0.9659
8            0.7578
32           0.3298
128          0.0118

Same geometry throughout, only the exponent changes. Low values give a broad sheen, high values a small bright spot: roughly 8 for polished wood, 32 for plastic, 128 and up for glass or wet metal.

The three terms combined:

my $colour = $ambient + $diffuse * $lambert + $specular * $spec;

How often you evaluate it#

The formulas are the same wherever they run. How often they run changes the result enormously.

Flat shading: one intensity per triangle, applied to every pixel. Cheap, and the faceting shows:

 +++++++
  +++++++++++++++++++
   ++++++++++++++++++++++++
   +++++++++++++++++++++
    ++++++++++++++++++
     ++++++++++++++
      +++++++++++
      ++++++++
       +++++
        +

Gouraud shading: an intensity per vertex, interpolated with the same barycentric weights the rasteriser already computes for depth:

my $i = $b0 * $inten[0] + $b1 * $inten[1] + $b2 * $inten[2];
 @%%%%##
  %%%####****++++===-
   %%###****++++====---::::
   %####***++++====---::
    ###***++++====----
     #****+++====--
      ***++++===-
      **++++==
       ++++=
        +

Three lighting calculations rather than one per pixel, and the flat region becomes a gradient. The approximation is interpolating the result, which shows on specular highlights: a highlight landing mid triangle with all three vertices dim vanishes, because there is nothing at the vertices to interpolate.

Per-pixel shading, confusingly called Phong shading, interpolates the normal instead and runs the lighting at every pixel. More expensive, and correct. This is what a fragment shader does.

The rule that carries forward: interpolate the inputs, not the answer.

Texture mapping#

Same interpolation again. Each vertex carries a UV coordinate, a position in an image, both axes 0 to 1 regardless of pixel size. Interpolate UVs across the triangle and look up the image at the result:

# $b0..$b2 must be PERSPECTIVE-CORRECT barycentric weights. Screen-linear
# weights slide the texture across the surface; see the rasteriser chapter.
my $u = $b0 * $uv[0][0] + $b1 * $uv[1][0] + $b2 * $uv[2][0];
my $v = $b0 * $uv[0][1] + $b1 * $uv[1][1] + $b2 * $uv[2][1];
my $texel = sample($image, $u, $v);

A checker pattern sampled that way, over the same triangle:

 ####...
  ......####...###...
   ...###...####......###..
   ####...###......####.
    ...####......###..
     ###......####.
      ......###..
      ...####.
       ###..
        .

Assigning UVs is unwrapping, a modelling task rather than a rendering one: deciding how the surface is cut and flattened, like the net of a paper model.

Two practical details. UVs are the value that slides visibly without perspective-correct interpolation. And a UV outside 0 to 1 needs a rule, repeat or clamp, which is a per-texture setting in every graphics API.

The same lookup carries more than colour: per-pixel normals (a normal map, bumps without geometry), shininess, or how much light a point blocks. Only the meaning of the number changes.

Physically based rendering#

The model above uses numbers chosen by eye. PBR replaces them with quantities that conserve energy, so a surface never reflects more light than it receives, and describes materials by measurable properties: roughness, and whether the surface is metal.

The practical consequence: PBR materials hold up under any lighting, where Blinn-Phong parameters are tuned per scene and break in the next one. The pipeline shape is unchanged, with a dot product still at the centre.

Blinn-Phong is still worth implementing first; every PBR treatment assumes it.

Next#

You can light a surface and texture it, but have nothing to shade yet. The next arc covers meshes, the file formats carrying them, the operations that construct new shapes, and the scene graph arranging them in a world.