Before you start
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.
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.
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.
Chapter 1
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.
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]Chapter 2
Original: Chapter 2 — Output an Image.
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.
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.
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))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.
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.
961def progress_step(enabled: Bool, step: Int, total: Int, label: String):962 if enabled:963 var percent = (step * 100) // total964 print(Chapter 3
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.
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())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.
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
Original: Chapter 4 — Rays, a Simple Camera, and Background.
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.
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 * tcamera_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.
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 )Chapter 5
Original: Chapter 5 — Adding a Sphere.
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.
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)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.
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 breakChapter 6
Original: Chapter 6 — Surface Normals and Multiple Objects.
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.
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.
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)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.
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.
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.
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.
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_anyThe 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.
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.
40comptime INF: Float32 = 1.0e3041comptime EPS: Float32 = 1.0e-842comptime T_MIN: Float32 = 0.001The 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
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.
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,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
Original: Chapter 8 — Antialiasing.
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.
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)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.
Chapter 9
Original: Chapter 9 — Diffuse Materials.
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.
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 guardIn 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.
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)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.
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.
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.
Chapter 10
Original: Chapter 10 — Metal.
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.
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.
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.
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.
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))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.
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.
Chapter 11
Original: Chapter 11 — Dielectrics.
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.
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.
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.
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.
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.
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)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.
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)Chapter 12
Original: Chapter 12 — Positionable Camera.
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.
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.
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.
Chapter 13
Original: Chapter 13 — Defocus Blur.
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.
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.
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.
Chapter 14
Original: Chapter 14 — Where Next?.
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.
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
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:
select, bounded loops, and device-supported maths.synchronize(), not after enqueueing.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(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
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.
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 gives733 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
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 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.
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.
| Configuration | Runs | Mean | Observed range | Against C89 |
|---|---|---|---|---|
| C89 scalar | 10 | 4.481 s | 4.477-4.489 s | 1.00x |
| Mojo scalar, 1 worker | 10 | 4.531 s | 4.526-4.536 s | 0.99x |
| Mojo SIMD4, 1 worker | 10 | 3.320 s | 3.315-3.322 s | 1.35x |
| Mojo scalar, 16 workers | 10 | 0.365 s | 0.353-0.372 s | 12.29x |
| Mojo SIMD4, 16 workers | 10 | 0.274 s | 0.256-0.292 s | 16.34x |
| Mojo GPU | 30 | 0.0718 s | 0.038-0.102 s | 62.38x |
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
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
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.