Shading makes a disc look like a ball
Shading is working out how bright each point of a surface should look. A sphere that is one flat colour looks like a disc, and a sphere shaded by light looks round.
In this lesson you light a surface the standard way, using diffuse shading, which depends on the angle between the surface and the light.
By the end of this lesson
Note
I can work out the diffuse brightness of a surface from its normal and the direction to the light.
Key words
| Word | What it means |
|---|---|
| Normal | a vector that points straight out of a surface |
| Unit vector | a vector whose length is exactly 1 |
| Normalise | to scale a vector to length 1 |
| Diffuse light | light that scatters evenly from a rough surface |
| Clamp | to cut a number off at a limit |
The cells students fill in are left blank in this preview.
A normal points straight out of a surface
A flat table has a normal that points straight up. A ball has a different normal at every point: it points away from the centre.
On a sphere the normal at a point is the vector from the centre to that point. Shading uses the normal to know which way the surface faces.
Direction vectors are scaled to length 1
Only the direction of a normal matters, not its length. So we scale it to length 1, which is called normalising. The result is a unit vector.
The length of a vector is the square root of its dot product with itself. To normalise, divide each number by that length. The vector (3, 0, 4) has length 5, so its unit vector is (0.6, 0, 0.8).
Normalise a vector with NumPy
docker exec jupyter python3 -c "import numpy as np; v = np.array([3.0, 0.0, 4.0]); print((v / np.linalg.norm(v)).tolist())"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
A surface is brightest facing the light
A rough surface scatters light in all directions, so its brightness depends on one thing: how directly it faces the light. This is diffuse light.
Take the unit normal and the unit vector pointing to the light, and form their dot product. It is 1 when they match, 0 when they are at a right angle, and negative when the surface faces away.
Worked example: a normal of (0, 0, 1)
| Direction to the light | Unit direction | Dot product | Brightness |
|---|---|---|---|
| (0, 0, 5) | (0, 0, 1) | 1.0 | 1.0, fully lit |
| (3, 0, 4) | (0.6, 0, 0.8) | 0.8 | 0.8 |
| (1, 0, 0) | (1, 0, 0) | 0.0 | 0.0, light skims past |
| (0, 0, -2) | (0, 0, -1) | -1.0 | 0.0, facing away |
The cells students fill in are left blank in this preview.
Diffuse brightness of four lights
docker exec jupyter python3 -c "import numpy as np; n = np.array([0, 0, 1]); print([round(float(max(0, n @ (l / np.linalg.norm(l)))), 4) for l in (np.array([0, 0, 5]), np.array([3, 0, 4]), np.array([1, 0, 0]), np.array([0, 0, -2]))])"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
A common mistake
Warning
Many people think a surface facing away from the light gets a negative brightness. There is no negative light. The dot product goes below 0 when the surface faces away, so we clamp it: brightness is the larger of 0 and the dot product. Without the clamp, shadows come out as strange bright patches.
Light a sphere at the hit point
Put the lesson together. A ray hits the sphere at a point p. The normal there is p minus the centre, made a unit vector. The direction to the light is the light position minus p, also made a unit vector.
The brightness is the clamped dot product of those two. Each pixel gets its own, so the sphere shades smoothly from bright to dark.
From an angle to a grey level
docker exec jupyter python3 -c "import math; print([round(255 * max(0, math.cos(math.radians(a)))) for a in (0, 30, 60, 90, 120)])"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Brightness becomes a grey level
The dot product of two unit vectors is the cosine of the angle between them. The Run prints [255, 221, 128, 0, 0] for the angles 0, 30, 60, 90 and 120 degrees.
A light straight on gives 255, white. At 90 degrees the light only skims, so 0. Past 90 degrees the clamp keeps it at 0.
Shade a sphere in text
def dot(a, b):
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def unit(v):
length = dot(v, v) ** 0.5
return [v[0] / length, v[1] / length, v[2] / length]
centre = [0, 0, 40]
radius = 14
light = [-30, -30, 0]
shades = " .:-=+*#%@"
oc = [0, 0, -40]
for y in range(13):
line = ""
for x in range(27):
d = [x - 13, (y - 6) * 2, 20]
a = dot(d, d)
b = 2 * dot(d, oc)
disc = b * b - 4 * a * (dot(oc, oc) - radius * radius)
if disc < 0:
line = line + " "
else:
t = (-b - disc ** 0.5) / (2 * a)
p = [t * d[0], t * d[1], t * d[2]]
normal = unit([p[0] - centre[0], p[1] - centre[1], p[2] - centre[2]])
to_light = unit([light[0] - p[0], light[1] - p[1], light[2] - p[2]])
line = line + shades[int(max(0, dot(normal, to_light)) * 9)]
print(line)
# Change light to [30, 30, 0] and press Run. The bright side swaps.
Save a head-on lit sphere
docker exec jupyter python3 -c "from PIL import Image; r = 18; p = Image.new('L', (41, 41), 0); [p.putpixel((x, y), round(255 * (1 - ((x - 20) ** 2 + (y - 20) ** 2) / r ** 2) ** 0.5)) for x in range(41) for y in range(41) if (x - 20) ** 2 + (y - 20) ** 2 <= r * r]; p.save('ball.png'); print(p.getpixel((20, 20)), p.getpixel((20, 32)), p.getpixel((0, 0)))"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Open ball.png in JupyterLab
The Run prints 255 190 0 and saves ball.png. Open it from the file list.
You see a round ball on a black background. It is brightest white in the middle, where the surface faces you. It fades smoothly to black at the rim, as if a lamp sat right behind your head.