This page runs from a web server, not from a file: in the repository run python3 -m http.server 8000 and open http://localhost:8000/tutorial/.

Part IX · Build your own · Chapter 32

A ray tracer in one page

Every flat figure in this tutorial runs a small path tracer: a ray tracer, which follows rays from the eye to what they hit, whose rays also bounce at random and are averaged over many tries. This chapter is that path tracer, whole: one file, tutorial/kit/onepage.js, 88 lines of code and no imports, read part by part. It finds surfaces by their distances, makes a picture from a camera, bounces light off matte walls and aims at a lamp. Then it is extended by a mirror, in four lines, and set beside ShadyTracer, part by part, to show where each piece grew up. Writing it also found two mistakes in the tutorial's own figures, told in section 6.

1Finding the first surface

The world is the flat world of chapter 0: a slice, light spreading round circles. The room is a floor, a ceiling, two walls and a box, each a shape the program knows only by its distance: for any point, how far the nearest part of the shape is, negative inside (chapter 2). The lamp under the ceiling is a capsule: every point within 0.015 of a line (drawn as a line; its outline matters in section 3). A ray leaves the eye and marches: at each step it asks every shape for its distance, and the smallest is a circle around the ray's point with nothing inside it, so the ray can safely jump that far: in the figure each circle touches the nearest wall or the box. Here it reaches the box's side in 8 steps. Drag the handle on the ray to aim it.

Skim the box's top and the march slows down: along the top the box is always close, so every circle there is small (too small to see: the dots crowded along the top are its steps), and the ray takes 69 steps to get to where it hits. A real renderer pays for grazing rays the same way (chapter 2). The march stops when a distance is below 0.001 (a hit) or the ray is 50 away (a miss), and gives up after 200 steps. It steps 0.9 of each distance, not all of it: ShadyTracer's default step scale (), a margin for distances that are not quite exact and numbers that are slightly off (chapter 2).

Aimed up, the ray reaches the lamp in 9 steps. At a hit the program needs the surface's normal, the way it faces: the direction in which the hit shape's distance grows fastest, found by asking for the distance a hair to each side of the point across and up, four looks: central differences (chapter 4).

One ray from the eye, marched by onepage.js march: each step's point and the circle its distance draws (grey), the hit and its normal (red). Drag the handle to aim the ray.

One piece of arithmetic runs through the code: the dot product of two arrows, their x's multiplied plus their y's multiplied. For two arrows 1 long it is the cosine of the angle between them (chapter 1): 1 for the same way, 0 at a right angle, below 0 when they point apart. It is the segment's t (how far along it a point lies), every cosine in lampLight, the path's test of which side the ray came from, and the mirror's dn.

The shapes, one function each: a circle, a box, a capsule (a segment with a radius).


    

The scene's distance is the nearest shape's, and which shape that is:


    

The march, and the normal:


    

  

2From rays to a picture

The camera is a pinhole at the eye, its film 56 pixels across a field of view of 64° (chapter 1). Each pixel's ray goes through a random point across the pixel, so its samples cover all of it. One ray a pixel finds a surface; then the path bounces: a matte surface sends the light it receives evenly in every direction, so the program draws the next direction at random, more often near the normal, because of the cosine law (chapter 1): light arriving at a slant is spread over more surface, so the directions near the normal carry more of it (chapter 8), and multiplies the path's throughput by the surface's albedo, the share it keeps (0.7 here). A path that hits the lamp brings back its brightness, 1, times the throughput; most hit nothing glowing in five legs (the eye's and one after each of up to four bounces) and bring back 0. The picture is one row of 56 pixels, the first looking along the upper dashed line and the last along the lower; under the strip, what each stretch of it sees first. Every strip is shown on one scale, white at 0.4 and above. The noise number in the readout is the average jump between neighbouring pixels, divided by the picture's average. One sample a pixel: the picture's average 0.1118, and from pixel to pixel it jumps by 1.5 times the average, nearly all noise.

Each pixel is the average of its samples (chapter 12). 64 samples a pixel: the average 0.1184, the jumps down to 0.22 of it. The noise falls as one over the square root of the samples: 64 is 8 times quieter than 1, and here 7 times.

Now aim at the lamp: next event estimation, NEE (chapter 14). At every bounce but the last, a shadow ray to a random point of the lamp, and if nothing is in the way, the light that point sends, divided by the chance of picking it. A path that then happens to hit the lamp adds nothing: the shadow ray from that bounce already counted the light coming straight from the lamp, and the hit would count the same light again. 16 samples a pixel: the average 0.1190, the same picture as 64 without aiming within their noise, and its jumps 0.26 of it, nearly as quiet as 64 samples were, at a quarter of the paths (each also sends up to four shadow rays, so not a quarter of the work).

One path of pixel 15, drawn whole: its five legs numbered, each bounce's shadow ray dashed to a point of the lamp (green where it gets there, red where something is in the way), and at the end the last surface, which sends none. The figure draws it with the tutorial's flatland.js, which on the same random numbers makes this program's paths exactly (the test holds the two pictures to each other). Other paths draws another.

The extension: the right wall becomes a mirror keeping 95% of the light. The eye sees the room again in it, the lamp included, and the picture's average rises to 0.1602. Its noise rises too, to 0.39: the lamp seen in the mirror is found only by paths that happen to hit it, since nothing aims through a mirror. That is chapter 8's rule for its room: a mirror's reflection is followed, never aimed at. Section 4 is the code.

The room, the eye and its field of view (dashed), the mirror in the glass colour when it is on, and under them the picture onepage.js render makes, on one scale, with what each stretch of it sees. Choose the samples a pixel, whether paths aim at the lamp, the extension, and one path drawn whole; Other paths draws new random numbers.

The camera: pixel i's ray through the point u across it, 0 at its left edge and 1 at its right.


    

Aiming at the lamp. The light a point of the lamp sends to a surface is the lamp's brightness, times the two cosines (how squarely the surface faces it, and how squarely it faces the surface), over the distance: the flat world's 1/r where 3D has 1/r² (chapter 0). The surface sends back albedo/2 of it towards each direction (3D: albedo/π, chapter 8), and the pick is divided by its chance. The fold-out says why both.

The mathsDividing by the chance, and why a bounce does not seem to

A point stands for the whole lamp. Picking one point evenly along a length ℓ and multiplying what it sends by ℓ counts the lamp as if every point sent that much; over many picks the points that send more and less are each picked as often, so the average is the lamp's total. The chance of any one exact point is zero, but the chance per unit of length is 1/ℓ, the pdf: multiplying by ℓ is dividing by it. With two lamps each picked half the time, the one picked must count double: * lamps.length. Chapter 14 works it through.

Albedo/2. A matte surface sends the same brightness every way. Added up over the half circle of directions, each weighted by its cosine (light leaving at a slant covers less), that brightness counts twice, since the cosine over a half circle adds up to 2 (check it without calculus: cos of 1000 angles evenly from −90° to 90°, each times the step π/1000, adds up to 2.000). So for the total to be the albedo's share of what arrived, each direction gets albedo/2 (in 3D the cosine adds up to π). Chapter 8 works it out.

The bounce divides too, and it cancels. The bounce's direction is drawn with chance cos θ / 2 per radian at the angle θ from the normal: v is drawn evenly from −1 to 1 and taken as sin θ, and evenly spread sines bunch the angles near the normal exactly as their cosine: of 1000 draws, about 87 land between 0° and 10° from the normal (half of sin 10° − sin 0°, times 1000) but only 8 between 80° and 90°, eleven times fewer, as cos 5° is eleven times cos 85°. What the surface sends back that way, albedo/2 × cos θ, divided by that chance, is the albedo: so the code only multiplies by it.

Which point of the lamp, in steps (section 3 draws them): pick a lamp; find the long side facing the point lit; lay that side and the two round ends end to end, len long; take the point u of the way along.


    

And the light it sends:


    

The path, the heart of it:


    

And the picture: every pixel the average of its paths.


  

3Which points of the lamp, and which bounces

The lamp drawn large: a capsule, everything within r of its middle line, glowing all round its outline, which is what a bounce can hit. Seen from the point below it, only the outline's points facing that point can send it light. So lampPoint lays the facing long side and the two round ends end to end and picks evenly along them; the far side is never picked, because it sends this point nothing and leaving it out changes nothing on average. Of 28 points evenly spread, 17 face the point (orange, a shadow ray to each) and 11, on the far halves of the ends, face away and count 0 (red). Drag the point.

Beyond the lamp's end, level with it, neither long side faces the point: only the round end does, and 5 of 28 points count. The room's lamp is far thinner (0.015 round a line 1.5 long), so its ends are 6% of the outline it picks from, not the 47% drawn here.

Now which bounces aim. A path allowed four bounces is five legs, and aiming must count exactly the light bouncing can find, no more: each shadow ray stands for the leg after its surface hitting the lamp.

the legfound by bouncing if it hits the lampfound by aiming as
1, from the eyethe lamp seen directlyitself (no surface before it to aim)
2, after surface 1light after 1 bouncesurface 1's shadow ray
3, after surface 2after 2 bouncessurface 2's shadow ray
4, after surface 3after 3 bouncessurface 3's shadow ray
5, after surface 4after 4 bouncessurface 4's shadow ray
6, after surface 5: never tracednothingso surface 5 does not aim

A shadow ray from surface 5 would stand for a sixth leg that bouncing never traces: it would add light after five bounces, and the two ways would head for different answers.

A capsule lamp drawn large: the long side facing the point lit and the two round ends, which onepage.js lampPoint picks from (thick), the far side it never picks (faint), and 28 points picked evenly, each with its outline's normal: those facing the point get a shadow ray. Drag the point.

4Extend it

The mirror is four lines inside the path, before the matte bounce: the ray reflected about the normal, the throughput times the mirror's albedo, and no shadow ray, because a mirror sends a lamp's light along one direction only, which a random point of the lamp would never lie on (chapter 8). So aimed is false, and a lamp seen in the mirror counts in full.


    

Each of these is the next step, with the chapter that explains it and the place in the tutorial's own kit/flatland.js where it is done; that file is this one grown, and on the same random numbers the two make the same picture (the tutorial's test holds them to it).

  • Glass: at a glass face reflect or refract by Fresnel's law, carrying all the light either way (chapter 10; glassStep).
  • A sun: light from one direction, found by a shadow ray out of the scene in its small cone (chapter 18; tracePath's NEE TO THE SUN).
  • Russian roulette: after a few bounces end dim paths at random and give the survivors their light (chapter 13; tracePath's rr).
  • Dust: a path that meets a particle before the next surface scatters there (chapter 20; hazeCollide2).
  • Colour: three numbers where this has one, an albedo per channel (chapter 6).
  • A third dimension: distances of points in space, a film of rows as well as columns, and albedo/π for albedo/2; the tutorial's kit/space.js is this program in 3D.

5From one page to ShadyTracer

Every part of the one page is in the renderer, grown for a GPU and for 3D. Part by part, each row naming the chapters that explain its words:

One pageShadyTracerChapters
shapes and nearestthe shapes of sdf.js, joined into one scene function the builder writes and compiles2, 3, 24
marchthe march kernel in wavefront.js, at most 32 steps a ray each time it runs (), with culling boxes and a step cache to make steps cheaper22, 26
normalthe exact one, which most shapes give with their distance; for the rest, forward differences, three looks in 3D where this page's central ones take four in 2D4
cameragenrays.js, with a lens1, 17
lampLightlight sampling in materials_pbr.js, weighed against the bounces by MIS instead of choosing one14, 15, 16
pathmany rays at once, a thread each, the path's work split into kernels (march, shade, occlusion) run round after round13, 21, 22
renderthe frame buffer and the running average of the picture23

What the one page leaves out is most of the book: materials beyond matte and mirror (chapters 8 to 11), wavelengths, photon maps, units and tolerances, and the work of keeping it fast. But the questions a path asks are these seven.

6What went wrong before

The first draft of this page aimed at the lamp as the tutorial's figures then did, and its picture read brighter with aiming than without: 0.1210 ± 0.0005 against 0.1163 ± 0.0010, four bounces, 44,800 paths each (computed; ± is the error bar, chapter 12, so they were four error bars apart: not luck). Two mistakes, both in the figures' own path tracer, pulling opposite ways:

  • The wrong lamp. The shadow rays went to points of the lamp's middle line, a lamp of no thickness, while the bounces hit the capsule, everything within 0.015 of it, whose outline is longer by the two round ends. In chapter 14's room, given twelve bounces, bouncing read 0.1326 ± 0.0014 and aiming 0.1213 ± 0.0004, nine percent apart and eight error bars (at twelve bounces the second mistake adds almost nothing). Aiming now picks points of the outline the bounces can hit.
  • One bounce too many. The last surface of a path also aimed at the lamp, counting light one bounce further than a path without aiming can reach. Allowed one bounce, that room read 0.0870 aiming against 0.0556 without, with the lamp already fixed. The last surface no longer aims.

At four bounces in this chapter's room the extra bounce won, and aiming read brighter; in chapter 14's, at twelve, the thin lamp won and aiming read darker. Fixed, this room reads 0.1151 ± 0.0005 aiming and 0.1163 ± 0.0010 without. The tutorial's test had a room checked both ways all along, and it passed: its lamp was 0.001 thick and its 12,000 paths a pixel could not see a few percent. A check is only as good as the case it is given; chapter 29 is that lesson, and chapter 30's fattened lamp is the same mistake in the engine.

7Try it

  • In the first figure, find an aim that takes more than 100 steps. Where is it?
  • In the second, how many samples without aiming match the noise of 16 with it?
  • Copy onepage.js and make the box a circle of radius 0.55. What changes in the code, and what in the picture?
  • Change bounces from 4 to 1 and to 12. Which picture gets darker, and why does aiming not change the answer?

Words from this chapter