RT Graphics Programming
Lesson 01 of 28

Part 1 · Ray tracing by hand

1. A Pixel Is a Question

Begin with the image itself and ask what one pixel is expected to know.

Graphics glossary 24 terms mentioned in this lesson

Select any term for a clear definition. The return button brings you back to the exact term link you used.

A ray traced image is often introduced by showing a polished scene containing glass, mirrors and soft shadows. That picture is attractive, but it is a poor place to begin. It encourages us to treat the renderer as a machine into which a scene goes and from which an image appears. If the result looks plausible, the machine is assumed to work. A plausible image is useful evidence, but it is not an explanation.

We shall begin with one pixel. The important question is not how to colour it, because setting three numbers is easy. The important question is what those numbers are meant to report. A ray tracer treats the pixel as the destination of a visibility calculation. It asks which route through the camera belongs to a sample of that pixel, which surface is first on that route and what light could arrive from that surface. We do not yet know how to answer any of those questions. We do, however, know precisely what must eventually be answered.

What you should be able to account for

  • Distinguish a raster, a pixel and a sample rather than using the three words for the same thing.
  • Create an image buffer and write an RGB value into one known pixel.
  • Explain why Canvas is being used as a display surface and not as the ray tracer.

The raster is storage

A raster is a finite rectangular grid. If its width is W and its height is H, it contains W × H pixels. We shall use the conventional browser image coordinates: x increases from left to right, y increases from top to bottom, and the top-left pixel is (0, 0). This is a storage convention. It is not yet a camera and it says nothing about three-dimensional space.

Each pixel in our buffer has four eight-bit channels: red, green, blue and alpha. The alpha value will remain 255, meaning fully opaque. Four consecutive array entries therefore belong to each pixel. The row-major index is:

index = 4 × (y × width + x)

The multiplication by four is not a decorative piece of syntax. Remove it and neighbouring pixels will share channels. Reverse x and y and the image will be transposed or corrupted. A rendered image can conceal this sort of error when its colours vary gently, so we first use a deliberately obvious selected pixel.

const width = 16;
const height = 10;
const image = context.createImageData(width, height);

function writePixel(x, y, red, green, blue) {
    const index = 4 * (y * width + x);
    image.data[index + 0] = red;
    image.data[index + 1] = green;
    image.data[index + 2] = blue;
    image.data[index + 3] = 255;
}

This function does not draw a circle, a line or a sphere. It changes four entries in an array. After all required entries have been written, putImageData transfers that array to the visible canvas. The distinction matters because later lessons will calculate the channels themselves. If we called context.arc to obtain a sphere, Canvas would already have decided which pixels belong to the circle. That is exactly the decision we are trying to understand.

A pixel is a region, not a mathematical point

It is tempting to say that a pixel is one point on the image. The raster gives each pixel an integer address, which makes that account look sensible. However, a displayed pixel covers a small rectangular region. A ray tracer normally evaluates one or more sample positions associated with that region, then reconstructs a stored pixel value from the samples. Using the centre, (x + 0.5, y + 0.5), is a convenient first estimate. It is not the complete pixel.

This distinction does not change our first program, but it prevents a later confusion. When a diagonal edge forms steps, the pixel has not failed to be small enough in some absolute sense. We have estimated a continuous signal using a finite grid and perhaps only one sample per region. Lesson 13 will return to the pixel and spend more work on that estimate. For now, one centre sample gives us a traceable starting rule.

Account for pixel (9, 4)

Suppose the image width is 16. The pixel is in row 4, so four complete rows contain 4 × 16 = 64 pixels. Moving nine columns into the row gives pixel number 73. Each pixel occupies four array entries, so its red channel is at entry 4 × 73 = 292. Green, blue and alpha are at 293, 294 and 295. These numbers are addresses. If we store gold there, the four values are 247, 184, 75 and 255. Move the same gold colour to another pixel and the addresses change, but those four colour values do not.

Pixel storage laboratory

Move One Colour to a New Address

Choose a cell. Its position changes where the gold colour is stored, but the RGBA values used for gold do not change.

Change this, then watch this: Move the gold pixel. Its four array addresses change, while its colour values remain 247, 184, 75, 255.

16 × 10 address gridChoose a cell
A uniform dark green raster with one gold selected pixel.

Ordinary24, 42, 36, 255Selected247, 184, 75, 255

Array storageAddress is not value
The four changing array addresses and four fixed RGBA values belonging to the selected gold pixel.
Selected coordinate
(9, 4)
Pixel number
73
Array addresses
292–295
Values stored
247, 184, 75, 255

4 × (4 × 16 + 9) = 292

What this establishes:

Construct a diagnostic image

A diagnostic image should make the state we care about easy to inspect. Every ordinary pixel therefore stores the same dark green value. The selected pixel stores one fixed gold value. The thin lines between cells are an interface guide placed over the enlarged image; they are not extra colours in the image buffer. If the gold cell appears in the requested row and column, the address calculation is supported. If it appears elsewhere, we have a specific disagreement to investigate.

for (let y = 0; y < height; ++y) {
    for (let x = 0; x < width; ++x) {
        const selected = x === selectedX && y === selectedY;
        const red   = selected ? 247 : 24;
        const green = selected ? 184 : 42;
        const blue  = selected ? 75  : 36;
        writePixel(x, y, red, green, blue);
    }
}

context.putImageData(image, 0, 0);

Move the selected pixel and watch the two kinds of number separately. The array addresses change because a different piece of storage has been selected. The gold values remain 247, 184, 75 and 255 because we are moving the same colour. An address answers where?; an RGBA value answers what is stored there? The successful display supports the indexing rule, channel order and transfer to Canvas. It does not prove that a camera exists, that the colours are physically meaningful or that an object has been intersected.

Check the address before running it

An image is 20 pixels wide. Which four array entries belong to pixel (3, 2)? State the calculation, not only the final number.

Reveal the trace

Two complete rows contain 2 × 20 = 40 pixels. Moving three pixels into the next row gives pixel number 43. Its first channel is 4 × 43 = 172, so the four entries are 172, 173, 174 and 175. Entry 172 stores red; 175 stores alpha.

Attach a question to the sample

The laboratory deliberately stops at the four array addresses. It does not draw a camera or a ray because neither has been constructed yet. Our next responsibility is to turn a sample associated with this integer pixel address into an origin and direction in a three-dimensional coordinate system. Once we have those values, we shall have a ray with positions that can be calculated. Once we have an intersection equation, that ray can ask whether a surface occupies one of those positions.

Keeping these responsibilities separate is valuable. The image buffer stores answers. The camera constructs questions. Geometry tests the questions. Materials and lights interpret successful results. A finished renderer combines them, but combining them early does not make them easier to understand. It merely makes an error harder to locate.

Make the storage rule yours

Change the diagnostic program to a 32 by 18 raster. Select the last valid pixel and predict its red-channel index before running the page. Then deliberately use x × height + y in place of y × width + x. Explain the visible result in terms of addresses. Restore the correct rule only after you can account for the failure.

Keep this model for Vulkan

The Vulkan half of the course will use images, formats and GPU memory rather than a JavaScript Uint8ClampedArray. Those mechanisms are more explicit and considerably less forgiving, but the underlying obligation remains: identify which stored element belongs to the invocation, decide what value it should contain and make the write visible at the right time. Vulkan will not remove the pixel question. It will make ownership of the answer more explicit.

We now have a raster that can receive a result and a precise address for every pixel. Lesson 2 supplies the mathematical object that will carry a sample away from the camera. Until that ray exists, the highlighted pixel is only a highlighted piece of storage.