A literate walkthrough · rt_weekend.mojo

Ray Tracing in One Weekend, in Mojo

This companion celebrates Pete Shirley's brilliant Ray Tracing in One Weekend and the Mojo 1.0 release. It explores how one Mojo path tracer can use a GPU or a SIMD CPU.

Three spheres, glass, diffuse red and polished metal, on a grey
       ground under a pale blue sky gradient

Mojo rendered this 640×360 image with 500 samples per pixel and 24 bounces. Glass, diffuse, and metal use the same width-generic tracing code on the GPU and CPU.

Before you start

How to use this companion

I have had a long affair with computer-generated imagery (CGI). From 1996 to 1998, I used Polyray. Since then, I have used many renderers and written some small rendering toys of my own.

I remember poring over the radiosity renderer in the book PC Graphics Unleashed. I wanted to understand how code could turn geometry, materials, and light into an image.

Pete Shirley's Ray Tracing in One Weekend, version 4.0.2 is brilliant. It makes a difficult subject clear, practical, and inviting.

This companion celebrates that book and the Mojo 1.0 release. I wanted to learn what GPU-accelerated ray tracing in Mojo looks like. The best way to learn was to implement the book.

This page follows the same chapter order as the original. Open the book beside this page. Read the explanation in the book, and then use this page to examine the Mojo implementation.

Each chapter links to the matching section in the book. Use these links to move between the original explanation and the Mojo code.

This is not a line-for-line translation. Recursive calls, virtual objects, and heap-owned object graphs are not a good fit for this device kernel.

The mathematics and results stay the same. The data representation and control flow change. These changes let the same tracing core run on a GPU or in SIMD packets on a CPU.

The project has two source files. rt_weekend.mojo is the finished renderer. steps/tutorial_steps.mojo contains the incomplete teaching stages.

This separation keeps known incomplete modes out of the final application. Run pixi run tutorial-gallery to rebuild the images. Then run pixi run docs to rebuild this page.

The Mojo you need for this page

You do not need Mojo experience to read this companion. If you know Python, much of the syntax will look familiar. Mojo types, ownership rules, compile-time parameters, SIMD values, and GPU execution are important here.

If Mojo is new to you, start with the Mojo website and Mojo Quickstart.

Use the Mojo Manual for explanations. Use the language reference for exact syntax.

ConstructHow to read it here
def, var, structFunctions, local variables, and statically laid-out value types. These are the familiar foundation.
[w: SIMDLength]A compile-time parameter. Mojo generates a width-one version for a GPU thread and a native-width packet version for the CPU.
SIMD[dtype, w]One typed value with w lanes. Arithmetic and comparisons operate on every lane.
SIMD[DType.bool, w]A lane mask. Each Boolean says whether the corresponding ray should take part in an update.
mask.select(a, b)A lane-wise choice: take a where the mask is true and b where it is false.
mut, out, immutable argumentsArgument conventions make mutation and initialization explicit. Most inputs are borrowed immutably by default.
comptimeA value or branch resolved during compilation. We use it for data types, lane widths, constants, and accelerator availability.
DeviceContext and enqueue_functionThe host owns buffers and queues a plain Mojo function as a GPU kernel. The kernel itself has no CUDA-style declaration.

These constructs occur throughout the page. The main idea is simple: w changes what a value represents. Masks let rays in one value have different results without scalar control flow.

parse the command line--scene, --samples, --devicebuild scene + cameraflat float32 arrays, on the host--deviceupload to device buffersspheres + camera paramsallocate host framebufferList[Float32]render_kernelone thread per pixel · w = 1render_cpupackets x cores · w = PACKETframebuffer3 x float32 per pixel, lineargamma 2, quantise, writerender.ppm (binary P6)
Figure 1One width-generic tracer, instantiated for a scalar GPU thread or a native-width CPU packet.

Chapter 1

Overview

Original: Chapter 1 — Overview.

The finished path tracer is in one Mojo source file. It has two execution modes. On the GPU, one scalar thread owns one pixel. On the CPU, one invocation owns a SIMD packet of adjacent pixels.

The teaching program also provides each intermediate chapter state. These states do not add incomplete modes to the finished renderer.

The lane count w makes this possible. The tracing types and functions use it as a parameter. GPU code uses w == 1 because GPU threads provide the parallel work.

CPU code uses simd_width_of[DType.float32]() so that one instruction can advance several rays. The --simd off option selects scalar CPU code.

The algorithm is shared. Only the work mapping changes.

rt_weekend.mojolines 99-102
 99struct Vec3[w: SIMDLength](ImplicitlyCopyable, Movable):100    var x: SIMD[dtype, Self.w]101    var y: SIMD[dtype, Self.w]102    var z: SIMD[dtype, Self.w]

Coverage through 14.1

Chapter Status Mojo implementation
1. Overview Complete One source targets the CPU and GPU.
2. Output an Image Complete Binary P6 output with back-end-aware progress.
3. The vec3 Class Complete Vec3[w] stores one or many SIMD lanes.
4. Rays, Camera, and Background Complete Rays and sky are generic over lane width.
5. Adding a Sphere Complete Lane masks control the quadratic solver.
6. Normals and Multiple Objects Adapted Flat records replace polymorphic hittables.
7. Camera Class Adapted Camera work is split across host setup and device ray generation.
8. Antialiasing Complete Each pixel owns an independent PCG stream.
9. Diffuse Materials Complete A masked bounce loop replaces recursion.
10. Metal Complete Integer tags replace virtual material calls.
11. Dielectrics Complete Refraction, total internal reflection, Schlick, and hollow glass.
12. Positionable Camera Complete Uniform camera geometry is computed once on the host.
13. Defocus Blur Complete Disk sampling works in CPU packets and GPU threads.
14.1. Final Render Complete --scene book builds and renders the full scene.

Chapter 2

Output an Image

Original: Chapter 2 — Output an Image.

2.1 The PPM Image Format

The first milestone is to write an image. PPM keeps file output independent of a graphics API.

The original book starts with text-based P3 output. This implementation uses binary P6 output. P6 produces the same pixels without the conversion of millions of channel values to strings.

rt_weekend.mojolines 942-958
942def write_ppm(path: String, fb: Ptr, width: Int, height: Int) raises:943    var header = (944        String("P6\n") + String(width) + " " + String(height) + "\n255\n"945    )946    var bytes = List[UInt8](capacity=width * height * 3 + 32)947    for b in header.bytes():948        bytes.append(b)949    # Row 0 of the framebuffer is the bottom of the image; PPM wants top first.950    for j in range(height):951        var py = height - 1 - j952        for i in range(width):953            var idx = (py * width + i) * 3954            bytes.append(to_byte(fb[unsafe_offset=idx + 0]))955            bytes.append(to_byte(fb[unsafe_offset=idx + 1]))956            bytes.append(to_byte(fb[unsafe_offset=idx + 2]))957    with open(path, "w") as f:958        f.write_bytes(Span(bytes))

The first checkpoint does not use rays. It fills the framebuffer with red from x, green from y, and zero blue. It then calls the same write_ppm function as later renders.

This test verifies allocation, colour layout, row order, and file output before geometry makes the program more complex.

steps/tutorial_steps.mojolines 691-695
691    if stage == Float32(STAGE_PPM):692        # The first checkpoint exercises the real PPM writer without rays.693        # The framebuffer starts at the bottom, so green is inverted here to694        # make the displayed file run black-to-green from top to bottom.695        return Vec3[w](px * inv_w, 1.0 - py * inv_h, SIMD[dtype, w](0.0))
A sixteen by nine red, green and yellow PPM gradient
Figure 2The 16×9 P6 PPM exercise, written by the tutorial Mojo program.

2.2 Creating an Image File

Use --out PATH to select the output file. The CPU writes its framebuffer directly.

The GPU path has one more boundary. Wait for the kernel, map the completed framebuffer to the host, and then write the file. Both paths produce the same image.

A red, green and yellow gradient produced by Mojo
Figure 3The same Mojo PPM writer at 480×270: red across x, green down y.

2.3 Adding a Progress Indicator

Progress output shows four phases: prepare, upload or allocate, trace, and write. It then shows the scene, dimensions, back end, warm-up, elapsed time, and throughput. Use --progress off for redirected output.

The output does not count scanlines. A GPU launch is asynchronous, so the host does not know when a specific row is complete.

The output reports the actual life cycle at synchronisation points. This information helps you find a stalled render or an expensive transfer.

rt_weekend.mojolines 961-964
961def progress_step(enabled: Bool, step: Int, total: Int, label: String):962    if enabled:963        var percent = (step * 100) // total964        print(

Chapter 3

The vec3 Class

Original: Chapter 3 — The vec3 Class.

Vec3[w] represents points, directions, and colours, as the book's vec3 does. Each component is a SIMD[float32, w] value.

At width one, it describes one GPU ray. At a larger width, it describes one CPU packet.

The register layout is a structure of arrays. One register contains all x lanes, one contains all y lanes, and one contains all z lanes.

Dot products and vector operations can advance the full packet. They do not have to rearrange an array of three-float objects. Both back ends use this type.

rt_weekend.mojolines 123-136
123    def __mul__(self, s: SIMD[dtype, Self.w]) -> Self:124        return Self(self.x * s, self.y * s, self.z * s)125 126    def __mul__(self, o: Self) -> Self:127        return Self(self.x * o.x, self.y * o.y, self.z * o.z)128 129    def __truediv__(self, s: SIMD[dtype, Self.w]) -> Self:130        return self * (1.0 / s)131 132    def length_squared(self) -> SIMD[dtype, Self.w]:133        return self.x * self.x + self.y * self.y + self.z * self.z134 135    def length(self) -> SIMD[dtype, Self.w]:136        return sqrt(self.length_squared())

3.1 Color Utility Functions

Colour stays in linear Float32 format while the renderer accumulates paths. When the framebuffer becomes bytes, the writer applies gamma conversion, clamps the result, and quantises it.

rt_weekend.mojolines 938-939
938def to_byte(v: Float32) -> UInt8:939    return UInt8(Int(256.0 * min(max(v, 0.0), 0.999)))

Vec3[w] also stores colour. Sample addition and material attenuation use the same lane operations as geometry.

Conversion occurs only at the output boundary. Thus, all colour values in the tracer are linear.

Chapter 4

Rays, a Simple Camera, and Background

Original: Chapter 4 — Rays, a Simple Camera, and Background.

4.1 The ray Class

A ray has an origin, a direction, and an at(t) operation. It is width-generic. Therefore, sphere intersections and material bounces use the same code on both back ends.

rt_weekend.mojolines 277-286
277struct Ray[w: SIMDLength](ImplicitlyCopyable, Movable):278    var origin: Vec3[Self.w]279    var dir: Vec3[Self.w]280 281    def __init__(out self, origin: Vec3[Self.w], dir: Vec3[Self.w]):282        self.origin = origin283        self.dir = dir284 285    def at(self, t: SIMD[dtype, Self.w]) -> Vec3[Self.w]:286        return self.origin + self.dir * t

4.2 Sending Rays Into the Scene

camera_ray maps a sample position to the viewport. A GPU thread calls it for one pixel. The CPU calls it with adjacent coordinates in SIMD lanes.

The camera centre is the ray origin. horizontal spans the viewport from left to right. vertical spans it from bottom to top. lower_left sets the position of the rectangle in the scene.

A normalised sample (s,t) selects a point on the viewport. Subtract the camera centre from that point to get the ray direction.

Geometry uses a lower-left origin. The PPM file starts with the top row. The writer reverses the row order so that storage requirements do not change the camera mathematics.

The scene does not contain objects yet, so all rays miss. The y direction controls the same white-to-blue interpolation as the original. This gradient is the first view through the camera.

rt_weekend.mojolines 564-569
564            var ud = unit(cur.dir)565            var a = 0.5 * (ud.y + 1.0)566            var sky = (567                splat3[w](1.0, 1.0, 1.0) * (1.0 - a)568                + splat3[w](0.5, 0.7, 1.0) * a569            )
A blue-to-white vertical sky gradient rendered with rays
Figure 4One camera ray per pixel; a miss maps its unit y direction to the sky.

Chapter 5

Adding a Sphere

Original: Chapter 5 — Adding a Sphere.

5.1 Ray-Sphere Intersection

For the first object, substitute the ray into the sphere equation. Then solve the same quadratic as the original. The geometry does not change.

The control flow changes. A CPU packet can contain hits and misses at the same time. Thus, each intersection test uses a per-lane mask, not one Boolean branch.

The GPU uses the same operations at width one. The CPU uses them to keep several independent results in progress.

rt_weekend.mojolines 364-378
364    var real = active & disc.ge(0.0)365    if not real.reduce_or():366        return mask[w](False)367 368    # `max(disc, 0)` keeps the sqrt finite in lanes we are about to discard.369    var sqrtd = sqrt(max(disc, SIMD[dtype, w](0.0)))370    var root_near = (-half_b - sqrtd) / a371    var root_far = (-half_b + sqrtd) / a372    var use_near = real & root_near.ge(t_min) & root_near.le(closest)373    var use_far = real & ~use_near & root_far.ge(t_min) & root_far.le(closest)374    var accept = use_near | use_far375    if not accept.reduce_or():376        return mask[w](False)377 378    var root = use_near.select(root_near, root_far)

5.2 Creating Our First Raytraced Image

For the first ray-traced image, the teaching checkpoint stops at the first accepted surface and returns red. This test verifies that the camera rays and the quadratic give the same sphere position.

The next diagnostic converts a normal direction to RGB. Orientation errors are easy to see in this image.

The finished renderer passes the same normal and hit point to the material scatter operation. It does not display them directly.

steps/tutorial_steps.mojolines 621-635
621        if stage == Float32(STAGE_RED_SPHERE):622            radiance = select_vec3(623                active,624                radiance + throughput * splat3[w](1.0, 0.0, 0.0),625                radiance,626            )627            break628        if stage == Float32(STAGE_NORMALS_ONE) or stage == Float32(629            STAGE_NORMALS_WORLD630        ):631            var normal_colour = (rec.normal + splat3[w](1.0, 1.0, 1.0)) * 0.5632            radiance = select_vec3(633                active, radiance + throughput * normal_colour, radiance634            )635            break
A red sphere against a pale blue sky
Figure 5The first ray-traced surface: a red sphere against the ray-derived sky.

Chapter 6

Surface Normals and Multiple Objects

Original: Chapter 6 — Surface Normals and Multiple Objects.

6.1 Shading with Surface Normals

A Boolean hit is not sufficient when the surface affects the ray. The renderer also needs the intersection point, nearest t, oriented normal, material data, and face direction.

HitRecord[w] keeps these values together for one ray or one packet of rays.

Before you read HitRecord.merge, examine select. SIMD.select is an operation on a Boolean SIMD value. The mask selects its first argument in true lanes and its second argument in false lanes.

select_vec3 is not a Mojo built-in. It applies the same mask to x, y, and z. It then assembles the selected components into a new Vec3[w] value.

rt_weekend.mojolines 170-174
170def select_vec3[171    w: SIMDLength, //172](m: SIMD[DType.bool, w], a: Vec3[w], b: Vec3[w]) -> Vec3[w]:173    """Lane-wise choice between two vectors: `m ? a : b`."""174    return Vec3[w](m.select(a.x, b.x), m.select(a.y, b.y), m.select(a.z, b.z))

The dot product is negative when the ray direction and outward normal oppose each other. Thus, .lt(0.0) produces the front mask.

select_vec3 keeps the outward normal in front lanes and reverses it in the other lanes.

The accept mask identifies candidates that are nearer than the stored hit. Each accept.select(candidate, previous) updates true lanes and keeps false lanes.

At w == 1, the same operation is a one-lane choice for one GPU thread.

rt_weekend.mojolines 328-334
328        var front = dot(r.dir, outward).lt(0.0)329        var n = select_vec3(front, outward, -outward)330        self.t = accept.select(t, self.t)331        self.p = select_vec3(accept, p, self.p)332        self.normal = select_vec3(accept, n, self.normal)333        self.front_face = accept.select(front, self.front_face)334        self.kind = accept.select(kind, self.kind)
A sphere shaded by its surface normals
Figure 6Outward normals remapped from [−1, 1] to displayable RGB.

6.2 Simplifying the Ray-Sphere Intersection Code

The simplified quadratic in the original removes an unnecessary factor of two. hit_sphere makes the same change. Both roots are SIMD values.

select keeps the nearest acceptable root in each lane. The Mojo code first simplifies the mathematics. It then uses masks for different lane results.

6.3 An Abstraction for Hittable Objects

The original now introduces a hittable abstraction. This implementation needs the same boundary but not the same class hierarchy.

A primitive receives a ray, an interval, an active mask, and a hit record. It can update the record when it finds a nearer hit.

Mojo has traits, but a tree of heap objects and per-object virtual calls is not suitable for this GPU upload. The device receives compact tagged records. Ordinary code controls dispatch.

This design uses contiguous storage and makes transfer cost and branch divergence visible.

6.4 Front Faces Versus Back Faces

The geometric normal points outwards. The normal for scattering must oppose the incoming ray.

Compare the two directions and reverse the normal when necessary. Store the result in front_face. Glass uses this value to select the refraction ratio for entry or exit.

6.5 A List of Hittable Objects

The world is a contiguous array of spheres. world_hit walks the array and keeps the nearest accepted distance. A later object replaces the current record only when its hit is nearer.

This is the same linear search as the original. It does not use heap allocation or pointer traversal. Both the CPU and GPU can read this representation directly.

rt_weekend.mojolines 435-457
435def world_hit[436    w: SIMDLength, //437](438    spheres: Ptr,439    n_spheres: Int,440    params: Ptr,441    r: Ray[w],442    active: SIMD[DType.bool, w],443    mut rec: HitRecord[w],444) -> SIMD[DType.bool, w]:445    var t_min = bcast[w](T_MIN)446    var closest = bcast[w](INF)447    var hit_any = mask[w](False)448 449    if params[unsafe_offset=P_DISK] > 0.0:450        hit_any = hit_any | hit_ground_disk(451            params, r, t_min, closest, active, rec452        )453    for i in range(n_spheres):454        hit_any = hit_any | hit_sphere(455            spheres, i, r, t_min, closest, active, rec456        )457    return hit_any
A normal-shaded sphere and ground
Figure 7The normal diagnostic after the world gains a second hittable object.

6.6 Some New C++ Features

The original uses several modern C++ ownership features here. The Mojo version puts the ownership boundary around the hardware.

A List owns mutable data while the host builds the scene. A mapped device buffer gives mutable access only while the mapping exists.

When the kernel starts, the scene and camera arguments are immutable. Only the framebuffer and each path's random state can change.

Traits describe copy and move behaviour. Compile-time parameters specify the lane width.

The host uploads one flat, immutable scene. All threads can read it. The program does not share a graph of reference-counted heap objects between two address spaces.

6.7 Common Constants and Utility Functions

Infinity, epsilon, and the minimum accepted ray parameter are compile-time Float32 values. Both generated back ends use them. Thus, the CPU and GPU use the same conditions for a valid hit.

rt_weekend.mojolines 40-42
40comptime INF: Float32 = 1.0e3041comptime EPS: Float32 = 1.0e-842comptime T_MIN: Float32 = 0.001

6.8 An Interval Class

The book puts valid ray parameters in an interval class. This implementation carries t_min and a mutable closest bound for each lane.

The meaning is the same. The compiler can keep the flat representation in registers while a packet walks the scene.

Chapter 7

Moving Camera Code Into Its Own Class

Original: Chapter 7 — Moving Camera Code Into Its Own Class.

The book now puts camera behaviour in a class. This implementation keeps the same responsibility but divides the work at the hardware boundary.

build_params computes the geometry once on the host. camera_ray uses these parameters in a GPU thread or CPU packet to create each sampled ray.

The camera basis is the same for the full frame. Do not compute it for each pixel. Keep host trigonometry out of the hot kernel.

The host produces plain floats for the camera centre, viewport origin, viewport edges, and lens basis. The device does only the multiply-add operations that change for each sample.

rt_weekend.mojolines 866-878
866def build_params(867    mut params: List[Float32],868    aspect: Float32,869    look_from_x: Float32,870    look_from_y: Float32,871    look_from_z: Float32,872    look_at_x: Float32,873    look_at_y: Float32,874    look_at_z: Float32,875    vfov_deg: Float32,876    defocus_deg: Float32,877    focus_dist: Float32,878    disk: Bool,
rt_weekend.mojolines 583-592
583def camera_ray[584    w: SIMDLength, //585](586    params: Ptr,587    s: SIMD[dtype, w],588    t: SIMD[dtype, w],589    mut seed: SIMD[DType.uint32, w],590) -> Ray[w]:591    var origin = load3[w](params, P_ORIGIN)592    var lower_left = load3[w](params, P_LOWER_LEFT)

Chapter 8

Antialiasing

Original: Chapter 8 — Antialiasing.

8.1 Some Random Number Utilities

Antialiasing uses random values. Each pixel gets an independent PCG-derived stream.

On the CPU, each SIMD lane carries the state for its pixel. When a lane retires, its state stops. Thus, packet width and adjacent materials do not change a pixel's samples.

rt_weekend.mojolines 218-225
218def rand_f32[219    w: SIMDLength, //220](mut seed: SIMD[DType.uint32, w], active: SIMD[DType.bool, w]) -> SIMD[221    dtype, w222]:223    """Uniform [0,1) per lane; lanes outside `active` do not advance."""224    seed = active.select(pcg_hash(seed), seed)225    return seed.cast[dtype]() * Float32(1.0 / 4294967296.0)

8.2 Generating Pixels with Multiple Samples

For each output pixel, offset several sample positions inside its square. Trace each path and calculate their average contribution.

One centre sample produces a staircase at the sphere edge. Multiple samples estimate fractional coverage. More samples reduce Monte Carlo noise. They do not increase the image resolution.

1 sample per pixel64 samples per pixel
Figure 8One and 64 jittered samples per pixel. The silhouette smooths; the geometry is unchanged.

Chapter 9

Diffuse Materials

Original: Chapter 9 — Diffuse Materials.

9.1 A Simple Diffuse Material

At a diffuse hit, select a child direction in the outwards hemisphere. Multiply the path throughput by the surface albedo. Then continue the ray instead of returning a final colour.

The first teaching stage uses a simpler hemisphere direction and fixed half-energy attenuation. Its image is not the final Lambertian result.

This intermediate result shows what each later refinement changes.

steps/tutorial_steps.mojolines 540-547
540        if stage >= Float32(STAGE_DIFFUSE) and stage <= Float32(STAGE_ACNE):541            # The tutorial's first diffuse model chooses a random direction in542            # the hemisphere and loses half its energy at every bounce.543            d = select_vec3(dot(jitter, rec.normal).gt(0.0), jitter, -jitter)544            attenuation = select_vec3(545                is_lambertian, splat3[w](0.5, 0.5, 0.5), attenuation546            )547        d = select_vec3(d.near_zero(), rec.normal, d)  # degenerate direction guard
Diffuse sphereLimited to four bouncesShadow acne
Figure 9First diffuse scattering; then four bounces; then the same limit with a zero lower hit bound to reveal self-intersection.

9.2 Limiting the Number of Child Rays

In the original, each bounce makes a recursive call. This GPU kernel does not use an unbounded call stack.

The Mojo tracer carries the same state through a bounded loop: current ray, accumulated throughput, output radiance, and active mask.

Lanes retire when they escape, are absorbed, or reach the depth limit. The calculation is recursive, but the storage is iterative.

rt_weekend.mojolines 557-578
557    for _bounce in range(max_depth):558        var rec = HitRecord[w]()559        var hit = world_hit(spheres, n_spheres, params, cur, active, rec)560 561        # Lanes that escaped the scene pick up the sky gradient and retire.562        var escaped = active & ~hit563        if escaped.reduce_or():564            var ud = unit(cur.dir)565            var a = 0.5 * (ud.y + 1.0)566            var sky = (567                splat3[w](1.0, 1.0, 1.0) * (1.0 - a)568                + splat3[w](0.5, 0.7, 1.0) * a569            )570            radiance = select_vec3(escaped, radiance + throughput * sky, radiance)571            active = active & ~escaped572        if not active.reduce_or():573            break574 575        var sc = scatter(rec, cur, seed, active)576        active = active & sc.ok  # absorbed lanes retire carrying nothing577        throughput = select_vec3(active, throughput * sc.attenuation, throughput)578        cur = rsel(active, sc.scattered, cur)

9.3 Fixing Shadow Acne

The first diffuse image also shows shadow acne. A secondary ray starts numerically close to its source surface. Floating-point error can make it hit that surface again.

The condition t > T_MIN gives the ray a small interval in which to leave the surface. The side-by-side render makes the result clear.

9.4 True Lambertian Reflection

For the Lambertian step, add a random unit vector to the oriented normal. Rejection sampling supplies the random direction.

This method uses arithmetic and sqrt. It does not use host-only trigonometry, so the shared device-compatible path can run it.

9.5 Using Gamma Correction for Accurate Color Intensity

This change affects display output, not light transport. Calculate the sample average in linear space. Then apply the original gamma-two conversion with sqrt at the output boundary.

This order prevents lighting operations on display-encoded colour values.

Linear Lambertian outputGamma-corrected output
Figure 10True Lambertian sampling in linear output, then gamma-2 output.

Chapter 10

Metal

Original: Chapter 10 — Metal.

10.1 An Abstract Class for Materials

Metal adds a second material. scatter is the common interface. Material selection uses an integer tag instead of a virtual method call.

This keeps the device representation flat. It also makes each possible kernel branch visible in the code.

10.2 A Data Structure to Describe Ray-Object Intersections

HitRecord[w] passes data from intersection to shading. It contains the geometry and material values that scatter needs. A lane does not have to follow a pointer to an object graph.

10.3 Modeling Light Scatter and Reflectance

Scatter[w] contains an active mask, an attenuation, and an outgoing ray. In the recursive version, the caller multiplies the returned child colour.

In the loop, each iteration multiplies attenuation into the current throughput. Both forms do the same path calculation.

10.4 Mirrored Light Reflection

The reflection equation does not change. It operates on each lane. Thus, a SIMD packet can reflect several directions while other lanes follow a different material branch.

rt_weekend.mojolines 498-517
498    var is_lambertian = active & rec.kind.eq(MAT_LAMBERTIAN)499    var is_metal = active & rec.kind.eq(MAT_METAL)500    var is_dielectric = active & rec.kind.eq(MAT_DIELECTRIC)501 502    var dir = splat3[w](0.0, 0.0, 0.0)503    var attenuation = splat3[w](1.0, 1.0, 1.0)504    var ok = active505 506    if is_lambertian.reduce_or():507        var d = rec.normal + jitter508        d = select_vec3(d.near_zero(), rec.normal, d)  # degenerate direction guard509        dir = select_vec3(is_lambertian, d, dir)510        attenuation = select_vec3(is_lambertian, rec.albedo, attenuation)511 512    if is_metal.reduce_or():513        var reflected = reflect(unit(r_in.dir), rec.normal) + jitter * rec.fuzz514        dir = select_vec3(is_metal, reflected, dir)515        attenuation = select_vec3(is_metal, rec.albedo, attenuation)516        # A fuzzed ray that ends up below the surface is absorbed.517        ok = ok & ~(is_metal & dot(reflected, rec.normal).le(0.0))

10.5 A Scene with Metal Spheres

The three-sphere scene has diffuse material in the centre and metal on the right. A later stage adds glass on the left.

The final scene uses the same material records for hundreds of spheres. Thus, this checkpoint tests the final code path.

10.6 Fuzzy Reflection

Fuzz adds a scaled random unit direction to the reflected vector. Larger fuzz values spread the outgoing rays more.

If a changed ray points below the surface, its lane is absorbed. The two renders show the change from polished to rough reflection.

Polished metal spheresFuzzy reflection
Figure 11Polished reflection and the same scene with a fuzzy metal lobe.

Chapter 11

Dielectrics

Original: Chapter 11 — Dielectrics.

11.1 Refraction

Glass changes a path direction but does not tint it. Therefore, attenuation stays at one.

refract splits the outgoing direction into components that are perpendicular and parallel to the normal. The Vec3[w] calculation works for one GPU path or several CPU paths.

11.2 Snell's Law

The front_face value selects the refraction ratio. It uses air-to-glass on entry and glass-to-air on exit.

The normal already opposes the ray. Thus, the rest of the calculation does not need more orientation branches.

11.3 Total Internal Reflection

Some exit angles cannot refract. If Snell's law requires a sine greater than one, that lane reflects.

An adjacent SIMD ray can still refract. The mask keeps its result instead of forcing all lanes through one scalar branch.

11.4 Schlick Approximation

When both results are possible, Schlick's fifth-power approximation gives the reflection probability. A random value selects reflection or refraction for each active lane.

Independent random state and masked control flow work together here.

rt_weekend.mojolines 521-532
521        var ratio = rec.front_face.select(1.0 / rec.ir, rec.ir)522        var cos_theta = min(dot(-ud, rec.normal), SIMD[dtype, w](1.0))523        var sin_theta = sqrt(524            max(1.0 - cos_theta * cos_theta, SIMD[dtype, w](0.0))525        )526        var must_reflect = (ratio * sin_theta).gt(1.0) | reflectance(527            cos_theta, ratio528        ).gt(coin)529        var d = select_vec3(530            must_reflect,531            reflect(ud, rec.normal),532            refract(ud, rec.normal, ratio),

The checkpoint renderer shows three stages. First, it forces refraction. Next, it adds total internal reflection. Finally, it adds Schlick's approximation.

These are teaching controls. The finished renderer always uses the complete rule.

steps/tutorial_steps.mojolines 566-570
566        var must_reflect = (ratio * sin_theta).gt(1.0)567        if stage == Float32(STAGE_GLASS_REFRACT):568            must_reflect = mask[w](False)569        elif stage != Float32(STAGE_GLASS_TIR):570            must_reflect = must_reflect | reflectance(cos_theta, ratio).gt(coin)
Always refractTotal internal reflectionSchlick reflectance
Figure 12Forced refraction, total internal reflection, and probabilistic Schlick reflectance, all rendered by the checkpoint kernel.

11.5 Modeling a Hollow Glass Sphere

A hollow glass sphere tests the complete glass code. Two concentric dielectric surfaces create four possible transitions.

As in version 4.0.2 of the original, the inner sphere uses the reciprocal index 1.0 / 1.5. The shared scatter code uses front_face to identify entry and exit. It does not need a special hollow-sphere branch.

steps/tutorial_steps.mojolines 949-965
949        if stage == STAGE_HOLLOW:950            # The inner material is air relative to glass: 1.0 / 1.5.951            push_sphere(952                spheres,953                -1.0,954                0.0,955                -1.0,956                0.4,957                MAT_DIELECTRIC,958                1,959                1,960                1,961                0,962                Float32(1.0 / 1.5),963            )964 965    var fuzz = Float32(0.0)
A hollow glass sphere beside diffuse and metal spheres
Figure 13A concentric reversed inner boundary turns the left glass sphere hollow.

Chapter 12

Positionable Camera

Original: Chapter 12 — Positionable Camera.

12.1 Camera Viewing Geometry

To move the camera, separate its target from its field of view. Vertical field of view and focus distance set the viewport size. lookfrom, lookat, and vup set its orientation.

The wide and narrow renders keep the subjects fixed. Thus, the field-of-view change is easy to see.

Wide-angle cameraNarrow-angle camera
Figure 14The same red and blue subjects at wide and narrow vertical fields of view.

12.2 Positioning and Orienting the Camera

The camera geometry is the same for the full frame. The host computes the basis and viewport once. This calculation includes the tan operation for field of view.

The host uploads plain floats. Each GPU thread or CPU lane reuses these values.

rt_weekend.mojolines 883-894
883    # `tan` is a host-only libm call, which is precisely why the camera basis is884    # built here and shipped to the device as plain floats.885    var h = Float32(tan(Float64(vfov_deg) * Float64(pi) / 360.0))886    var viewport_h = 2.0 * h * focus_dist887    var viewport_w = aspect * viewport_h888 889    var lf = Vec3[1](look_from_x, look_from_y, look_from_z)890    var la = Vec3[1](look_at_x, look_at_y, look_at_z)891    var vup = Vec3[1](0.0, 1.0, 0.0)892    var w_axis = unit(lf - la)893    var u_axis = unit(cross(vup, w_axis))894    var v_axis = cross(w_axis, u_axis)

The orthonormal basis is w = unit(lookfrom - lookat), u = unit(cross(vup,w)), and v = cross(w,u).

The vup value is a preferred direction, not the final up axis. The cross products make the right and up axes perpendicular to the view direction.

With this basis, a different view changes only camera parameters. It does not change tracing code.

Distant viewClose view
Figure 15A distant oblique camera and a close camera, each rebuilt on the host.

Chapter 13

Defocus Blur

Original: Chapter 13 — Defocus Blur.

13.1 A Thin Lens Approximation

The final camera feature is depth of field. Put the focus plane at focus_dist. The host converts the defocus angle into two scaled vectors that span a small lens disk.

This geometry is uniform, so calculate it once instead of once for each sample.

13.2 Generating Sample Rays

Each sample selects a point in the unit disk and offsets the ray origin. The ray points at its sample position on the focus plane.

Rays on the focus plane converge. Rays for other points spread into a blur. Set the defocus angle to zero to use the pinhole camera.

rt_weekend.mojolines 597-605
597    if params[unsafe_offset=P_DEFOCUS] > 0.0:598        var all_lanes = mask[w](True)599        var lens = random_in_unit_disk(seed, all_lanes)600        var offset = (601            load3[w](params, P_LENS_U) * lens.x602            + load3[w](params, P_LENS_V) * lens.y603        )604        var eye = origin + offset605        return Ray[w](eye, target - eye)

The parameters have different functions. defocus_deg controls aperture size. focus_dist selects the plane where samples stay aligned as the origin moves on the lens disk.

The comparison shows the same scene with and without defocus blur.

Three spheres rendered with depth of field
Figure 16Mojo thin-lens sampling: the centre subject is sharp while nearer and farther surfaces spread across the aperture.

Chapter 14

Where Next?

Original: Chapter 14 — Where Next?.

14.1 A Final Render

The --scene book option creates the large ground sphere, a random field of small spheres, three feature spheres, a positioned camera, and defocus blur.

It produces the same visual destination as section 14.1 of the original with the Mojo structures from the preceding chapters.

Hundreds of diffuse, metal, and glass spheres under a blue sky
Figure 17The final random-sphere scene rendered by this Mojo program.

Run pixi run book. Alternatively, use --scene book with the required resolution and sample count on either back end. This renderer produced the image below. It is not copied from the book.

This companion stops after section 14.1. Acceleration structures and later effects are work for another project.

The result is intentionally familiar. The exploration shows how to shape the same work for GPUs, SIMD CPUs, and Mojo 1.0.

Appendix A

What changes for a GPU

The rendered result does not show the changes for the GPU. The path-tracing mathematics stays the same. Data representation, control flow, memory movement, and work division change.

Do not start with the instruction to make one CPU procedure faster. First, identify many work items that can progress independently. This renderer uses six decisions:

  1. Expose path parallelism. One GPU thread owns one pixel in this renderer. The launch grid covers the image, and excess edge threads return before touching memory.
  2. Keep the hot function device-compatible. The kernel uses arithmetic, masks, select, bounded loops, and device-supported maths.
  3. Remove recursion and virtual dispatch. A bounce loop and integer material tags avoid a device stack, vtables, and heap allocation.
  4. Flatten scene data. Contiguous sphere and camera records are uploaded once and read by every thread.
  5. Compute uniform work on the host. Camera trigonometry and scene construction should not be repeated by every pixel.
  6. Respect asynchronous execution. Allocation, copies, and launches are queued. Measurements end after synchronize(), not after enqueueing.
rt_weekend.mojolines 644-665
644def render_kernel(645    fb: Ptr,646    spheres: Ptr,647    n_spheres_arg: Int32,648    params: Ptr,649    width_arg: Int32,650    height_arg: Int32,651    samples_arg: Int32,652    max_depth_arg: Int32,653):654    var n_spheres = Int(n_spheres_arg)655    var width = Int(width_arg)656    var height = Int(height_arg)657    var samples = Int(samples_arg)658    var max_depth = Int(max_depth_arg)659    var px = Int(block_dim.x * block_idx.x + thread_idx.x)660    var py = Int(block_dim.y * block_idx.y + thread_idx.y)661    if px >= width or py >= height:662        return663 664    var seed = pcg_hash(SIMD[DType.uint32, 1](UInt32(px + width * py + 1)))665    var col = trace_pixels(
HOSTDEVICEparse options, build the sceneplain float32 arrays, no device yet1011DeviceContext()picks whatever accelerator exists1147context + streamone queue for everything belowopenenqueue_create_bufferreturns straight away1148global memoryframebuffer, spheres, cameraallocatemap_to_host, write, unmapcopy the scene across1152scene residentread-only for every threaduploadenqueue_functiongrid_dim, block_dim; still no wait1190render_kernelone thread per pixel, w = 1thousands of threads in flightlaunchsynchronize()the one blocking call1206waitkernel retiresframebuffer completemap_to_host, gamma, write PPMback on the CPU1210read back
Figure 18The host/device lifecycle. Enqueued operations are asynchronous; synchronization is the point at which the host waits.

Is one pixel per thread best practice?

One pixel per thread is suitable for this renderer. It is not a universal rule. Apple uses this mapping in a standard 2D image-processing example.

NVIDIA gives more general guidance. Expose sufficient parallel work. Coalesce adjacent memory access. Limit divergence. Measure block size and register pressure on the target device.

This mapping works because each path has clear ownership. A path owns its random stream and accumulator. It reads an immutable scene and writes to one separate framebuffer location.

A path does not need a partial result from another path. Thus, the kernel does not need locks, atomics, or communication between threads.

The algorithm provides this independence. Pixels do not provide it automatically.

Adaptive sampling, sample splatting, shared reservoirs, denoising neighbourhoods, and global schedulers make paths communicate. For these designs, one pixel per thread can be unsuitable.

Each thread owns all samples and bounces for one pixel. This megakernel keeps path state close to the thread. It does not write intermediate rays to global memory.

The cost is divergence. Adjacent paths can hit different materials and finish at different depths, but the hardware executes them as a group.

Mapping Good fit Main cost
One pixel per thread Small scenes, coherent materials, enough pixels Long paths and mixed materials diverge.
One sample per thread High samples-per-pixel or a small image Samples need a reduction or atomic accumulation.
One ray stage per thread Complex production paths Queues and kernel boundaries add memory traffic.
One tile per threadgroup Reused neighbourhood data Cooperation and shared memory complicate the kernel.

The current renderer is a megakernel. One launch traces each path to completion. It is compact and prevents queue and launch overhead. It is suitable for this tutorial scene.

A 16×16 block gives 256 threads. This is a portable starting point, not a fixed optimum. Measure it on the target device.

A production renderer can use wavefront path tracing. Path state moves through queues. Specialised kernels process intersections, materials, shadows, and escaped paths.

Groups of similar work can reduce divergence. They also cause more global-memory traffic and more kernel boundaries.

For a complete example, read PBRT v4's wavefront rendering chapter. Its renderer combines selected stages when a separate queue costs too much.

There is no best mapping for all workloads. Balance coherence, occupancy, register pressure, launch overhead, and memory traffic for the applicable workload.

Hardware guidance gives consistent principles. Keep data contiguous. Expose sufficient work. Prevent unnecessary transfers and synchronisation. Limit divergent regions. Measure on the target device.

These principles make one pixel per thread a good starting point for this renderer.

The GPU uses w == 1. A multi-pixel Mojo packet in each GPU thread would add a second form of parallelism and increase thread state.

The hardware already executes adjacent scalar threads in SIMD-like groups. An explicit Mojo lane width is useful on the CPU, where the program forms the packet.

The same principle applies outside graphics. A thread can process a row, token, particle, hash candidate, audio frame, simulation cell, or database item.

Ask two questions. What state can one work item own? What data can all work items read without coordination? The answers help you shape independent GPU work.

Appendix B

How the CPU uses SIMD

The CPU uses independence at two levels. Multiple workers divide rows between cores. In each worker, the tracer uses the native Float32 SIMD width. Each register lane owns an adjacent pixel.

rt_weekend.mojolines 694-710
694def render_cpu[695    w: SIMDLength696](697    fb: Ptr,698    spheres: Ptr,699    n_spheres: Int,700    params: Ptr,701    width: Int,702    height: Int,703    samples: Int,704    max_depth: Int,705    workers: Int,706):707    # Rows cost wildly different amounts. A row of empty sky retires after one708    # bounce; a row through the glass sphere runs the full depth. So handing709    # each worker a contiguous band of scanlines leaves half of them idle at the710    # end. `chunk` work items are dealt out round-robin instead, which gives
rt_weekend.mojolines 733-747
733            var px = SIMD[dtype, w](0)734            var seed = SIMD[DType.uint32, w](0)735            for lane in range(w):736                # Lanes past the right edge duplicate the last pixel; their737                # results are simply not stored.738                var col = min(x + lane, width - 1)739                px[lane] = Float32(col)740                seed[lane] = UInt32(col + width * py + 1)741            var colour = trace_pixels(742                spheres,743                n_spheres,744                params,745                px,746                bcast[w](Float32(py)),747                pcg_hash(seed),

Rays in one packet can have different results. One packet can contain a miss, a metal hit, and a glass hit. Therefore, masks control branch work.

The packet evaluates the necessary paths. select puts the correct result in each lane.

Random state follows the same ownership rule. When a lane retires, its generator stops. Thus, a longer adjacent path cannot change its future samples.

SIMD and multicore scheduling work together. Workers distribute packets between cores. SIMD advances several pixels per instruction in each worker. Each method supplies a different layer of parallelism.

This model is similar to GPU programming, but the group is smaller and explicit. Shape values so that one instruction advances several independent items. Use masks when the results are different.

Mitchell Hashimoto's “Every programmer should know SIMD” connects this example to other software.

Look for data-parallel structures in parsing, search, validation, compression, and other workloads.

The standalone Thinking in lanes: SIMD in Mojo 1.0 companion uses a smaller program. It shows real lane values, a Boolean mask, select, a reduction, and a scalar tail.

Appendix C

What happens without CPU SIMD

Measure the scalar version. Do not assume that the speed increase equals the SIMD lane count.

--simd off uses render_cpu[1]. It keeps the algorithm, scene, random streams, worker count, and scheduling unchanged. This is the control configuration.

Without SIMD, each vector instruction advances one pixel instead of one native packet. This removes data-level parallelism in each core.

The loss does not have to equal the full lane width. Packets diverge. Finished lanes can wait for the longest ray. Packets with mixed materials can evaluate more than one branch.

Ten-run mean measurements on the 20-core M1 Ultra show the difference. Native four-wide NEON reduced the 16-worker frame time from 0.365 seconds to 0.274 seconds. This is a 1.33x speed increase.

Disabling SIMD made this workload approximately 33% slower. With one worker, it was approximately 37% slower. These results show that SIMD and multicore scheduling apply to different layers of the problem.

Configuration Time Relative to scalar
1 worker, scalar 4.531 s 1.00x
1 worker, 4-wide SIMD 3.320 s 1.37x
16 workers, scalar 0.365 s 12.42x
16 workers, 4-wide SIMD 0.274 s 16.52x

This result applies to this workload. It is not a hardware constant. Scene coherence, SIMD width, worker count, and architecture change the result. Appendix D gives the full method and the C89 and GPU controls.

Use bench.sh. Alternatively, compare --simd on and --simd off renders on the target CPU.

SIMD is not always 1.33x faster. For this workload, the scalar configuration had measurably lower throughput.

Appendix D

The C89 reference case

The repository includes rt_weekend.c. It is a single-threaded, scalar C89 implementation of the same scene, camera, materials, and output format. It is the reference case for this work.

This reference does not support a general comparison of C and Mojo. It is a plain control with no GPU scheduling, multicore worker pool, or explicit SIMD.

Use it to see the effect of each additional execution strategy.

I wrote it with Eskil Steenberg Hald's Dependable C approach. It uses a conservative subset of C89, conventional interfaces, few assumptions, and no newer language features.

Dependable C focuses on compatibility, not coding style. Many compilers must accept the program. A reader must be able to understand it without a modern toolchain.

Run pixi run render-c to build and run the reference case. Resolution and quality are compile-time settings. This keeps the program small and explicit.

Benchmarking the reference against Mojo

Start the comparison with the controls. C89 scalar and Mojo scalar each use one CPU worker without explicit SIMD.

Then add four-wide SIMD, CPU workers, and the GPU. Do not change the scene.

Each run traced the three-sphere scene at 800×450, with 200 samples per pixel and 24 bounces. This is 72 million primary rays before secondary bounces.

The reported times do not include file encoding or writing.

For each C and CPU configuration, I discarded two warm-up runs and calculated the mean of ten measured runs.

The GPU had more run-time variation. For the GPU, I discarded five warm-up runs and calculated the mean of 30 measured runs.

The test machine was a Mac Studio with an Apple M1 Ultra. It had 20 CPU cores, with 16 performance cores and four efficiency cores. It also had a 48-core integrated GPU and 64 GB of memory.

The operating system was macOS 26.5.2.

The toolchain was Mojo 1.0.0 and Apple Clang 21.0.0. I compiled the C reference as strict C89 with -O2.

I built the Mojo executable before measurement. The times do not include compilation.

ConfigurationRunsMeanObserved rangeAgainst C89
C89 scalar104.481 s4.477-4.489 s1.00x
Mojo scalar, 1 worker104.531 s4.526-4.536 s0.99x
Mojo SIMD4, 1 worker103.320 s3.315-3.322 s1.35x
Mojo scalar, 16 workers100.365 s0.353-0.372 s12.29x
Mojo SIMD4, 16 workers100.274 s0.256-0.292 s16.34x
Mojo GPU300.0718 s0.038-0.102 s62.38x
Figure 19Mean path-tracing time. The thin line is the observed range; the marker is the arithmetic mean. File output is outside every timer.

Mojo without SIMD or GPU acceleration was 1.1% slower than the C89 reference. At this scale, the two scalar results are effectively equal.

SIMD reduced the one-worker Mojo mean from 4.531 seconds to 3.320 seconds. This is 1.37x faster than Mojo scalar and 1.35x faster than the C reference.

It is less than the theoretical fourfold increase suggested by the lane count.

Sixteen scalar workers reached 12.29x the C result. Sixteen workers with SIMD reached 16.34x.

The mechanisms work together, but divergence, scheduling, and memory operations prevent their gains from multiplying directly.

The GPU mean was 71.8 ms, or 62.38x the C reference. The median was 70.2 ms.

The wider range shows normal contention from the active desktop, which shared the integrated GPU.

These measurements apply to this implementation and machine. They show the source of the speed increase. They do not establish a general ranking of C, Mojo, CPUs, or GPUs.

Appendix E

References

This companion follows the chapter order and terminology of the original book. It does not reproduce the book's prose.

Use the chapter links for the authors' explanations and derivations. Use this page as the accompanying Mojo implementation notes.

Appendix F

Glossary

Rendering

ray tracing
A method that follows a line from the camera into the scene to calculate the colour of a pixel.
path tracing
A ray-tracing method in which a ray continues after a hit. The ray collects colour at each surface. It stops at a light or at a set limit. This program is a path tracer.
ray
An origin and a direction. Points along it are written P(t) = A + tb, so one number t names any point on the line.
bounce
One movement of a ray from one surface to the next. The renderer sets a limit because a ray between two mirrors does not stop.
scatter
The operation that absorbs a ray or sends it in a new direction after a hit. Different scatter behaviour makes a surface look like metal, glass, or paint.
albedo
The fraction of light a surface reflects, per colour channel. A red wall has a high albedo in red and a low one in green and blue.
attenuation
The fraction of ray brightness that remains after one bounce. Apply it at each hit to make reflected light progressively darker.
throughput
The product of all attenuation values on one path. It starts at 1. When the path escapes, it multiplies the sky colour.
Lambertian
A perfectly matte surface: chalk, emulsion paint, paper. It scatters light equally in all directions, so it looks the same brightness from every angle.
dielectric
A transparent material that bends light as it passes through, like glass or water.
normal
The direction a surface faces at a given point, at right angles to the surface itself. Almost every lighting calculation needs it.
Monte Carlo
A method that calculates an estimate from the average of random samples. More samples usually make the estimate more stable.
antialiasing
A method that reduces jagged edges. This renderer traces rays at random positions in each pixel and averages the results.
samples per pixel
How many rays are averaged for each pixel. More samples means less noise, but the noise only falls as the square root, so four times the samples halves it.
gamma
The non-linear encoding applied before an image is saved. Brightness is added up linearly while rendering, then compressed so it looks right on a display.
shadow acne
Black dots caused when rounding makes a bounced ray hit its source surface again. Start the new ray slightly above the surface to prevent this artefact.
field of view
How wide an angle the camera sees. Small is a telephoto lens, large is wide-angle.
viewport
The rectangle in space that the camera's pixels are projected onto.
defocus blur
The softness of objects outside the focus distance. A lens with a non-zero width produces this depth-of-field effect.
framebuffer
The block of memory holding the colour of every pixel while the image is being built.
acceleration structure
A spatial index that lets a ray skip most of the scene instead of testing every object. This renderer deliberately has none; it is the subject of the next book.

Processors and parallelism

host
The CPU, and the memory it can address directly. The host runs the program that sets everything up.
device
An accelerator, usually a GPU, and its separate memory. The host must request a copy to read device memory.
kernel
A function compiled for the device and launched across many threads. This is not an operating-system kernel.
thread
One independent execution of a kernel. Here each thread owns exactly one pixel.
block
A group of threads scheduled together on the device and able to share fast local memory. This program launches blocks of 16x16 threads.
grid
The whole set of blocks in one launch, sized so there is a thread for every pixel.
warp
A group of threads that a GPU executes together. It has 32 threads on NVIDIA and 64 on some AMD devices. Different branches can cause unnecessary work.
SIMD
Single Instruction, Multiple Data. One instruction operates on several values in one wide register.
lane
One of the slots in a SIMD register. Four-wide means four lanes, each carrying its own pixel through the same instructions.
packet
A group of rays travelling through the scene together, one per lane, so the same instruction advances all of them.
mask
One Boolean value for each lane. It specifies the lanes to which an operation applies. Use it to keep the correct result in each lane when results are different.
select
An operation that uses a mask to merge two values, one lane at a time. It replaces an if-statement in a packet.
scalar
One value at a time, as opposed to a whole SIMD register. A GPU thread is scalar: it owns one pixel and needs no lanes.
vector register
The wide CPU register a SIMD instruction works on: 128 bits on NEON and SSE, 256 on AVX2, 512 on AVX-512.
libm
The C standard maths library. It contains functions such as sin, cos, and tan. It is compiled for the CPU, so a device kernel cannot call it.
ISA
Instruction set architecture: the actual machine language of a chip. Metal AIR on Apple GPUs, PTX on NVIDIA, GCN on AMD.
occupancy
How many threads a GPU can keep in flight at once. A kernel that needs many registers per thread lowers it, which leaves part of the GPU idle.

Mojo and C++

comptime
Mojo's keyword for a value the compiler must know before the program runs. It replaced alias.
parameter
In Mojo, a compile-time input, written in square brackets. Distinct from an argument, which is a runtime input in round brackets.
trait
A set of capabilities a type promises to have, listed in parentheses after a struct name. Similar in spirit to an interface, and not inheritance.
origin
Mojo's way of tracking which value a reference borrows from, so the compiler can tell whether it is still valid.
virtual dispatch
The C++ mechanism for deciding at runtime which version of a method to call, through a hidden table of function pointers on the object.
vtable
The table of function pointers that makes virtual dispatch work. It needs the object on a heap, which a GPU kernel does not have.

Edit literate.md and run pixi run docs. The finished code is in rt_weekend.mojo. The teaching stages are in steps/tutorial_steps.mojo. Mojo rendered every figure.