smallpt, a Crystal path tracer

This is a port of smallpt, Kevin Beason's famous ~100-line C++ global illumination renderer. The goal of this project is not just to have the algorithm in Crystal, but to compare how fast an idiomatic, readable Crystal implementation can be against the original optimized C++.

The renderer is unbiased: it shoots random rays from the camera, follows them as they bounce around the scene, and averages the results. With enough samples, the image converges to the physically correct one.

Usage: smallpt <samples> renders image.ppm at 1024x768 using <samples> subpixel passes (so 400 means 100 samples per pixel).

require "math"
require "wait_group"

Vectors

Everything in a path tracer starts with 3D vectors. In C++ smallpt these are a struct with overloaded operators; in Crystal a record gives us an immutable, stack-allocated value type — no garbage collection pressure in the hot loop, which is crucial for performance.

record(Vec, x : Float64 = 0.0, y : Float64 = 0.0, z : Float64 = 0.0) do
  def +(other : Vec)
    Vec.new(x + other.x, y + other.y, z + other.z)
  end

  def -(other : Vec)
    Vec.new(x - other.x, y - other.y, z - other.z)
  end

  def *(other : Float64)
    Vec.new(x * other, y * other, z * other)
  end

Component-wise multiplication, used to modulate light color by surface color as rays bounce.

  def mult(other : Vec)
    Vec.new(x * other.x, y * other.y, z * other.z)
  end

Scale to unit length.

  def norm
    inverse_length = 1.0 / Math.sqrt(x * x + y * y + z * z)
    self * inverse_length
  end

  def dot(other : Vec)
    x * other.x + y * other.y + z * other.z
  end

Cross product. The C++ original overloads % for this; we keep the same operator for fidelity.

  def %(other : Vec)
    Vec.new(y * other.z - z * other.y, z * other.x - x * other.z, x * other.y - y * other.x)
  end
end

A ray is just an origin and a direction.

record(Ray, o : Vec, d : Vec)

Materials

Surfaces can be perfectly diffuse (matte), perfect mirrors, or glass (dielectric refractors).

enum ReflT
  Diffuse
  Specular
  Refractive
end

The scene

Spheres are the only primitive. Intersection is solved analytically: for a ray o + t*d we solve the quadratic for t and return the smallest positive hit beyond a small epsilon (which avoids "shadow acne" from self-intersection).

struct Sphere
  getter radius : Float64, position : Vec, emission : Vec, color : Vec, reflection : ReflT

  def initialize(@radius, @position, @emission, @color, @reflection)
  end

  def intersect(ray : Ray)
    op = position - ray.o
    epsilon = 1e-4
    b = op.dot(ray.d)
    det = b * b - op.dot(op) + radius * radius
    return 0.0 if det < 0
    det = Math.sqrt(det)
    t = b - det
    return t if t > epsilon
    t = b + det
    t > epsilon ? t : 0.0
  end
end

The classic Cornell-box-like room: six huge spheres acting as walls (red and blue side walls, grey floor and ceiling), a mirror sphere, a glass sphere, and a big emissive sphere as the only light source.

SPHERES = [
  Sphere.new(1e5, Vec.new(1e5 + 1, 40.8, 81.6), Vec.new, Vec.new(0.75, 0.25, 0.25), ReflT::Diffuse),   # Left
  Sphere.new(1e5, Vec.new(-1e5 + 99, 40.8, 81.6), Vec.new, Vec.new(0.25, 0.25, 0.75), ReflT::Diffuse), # Right
  Sphere.new(1e5, Vec.new(50, 40.8, 1e5), Vec.new, Vec.new(0.75, 0.75, 0.75), ReflT::Diffuse),         # Back
  Sphere.new(1e5, Vec.new(50, 40.8, -1e5 + 170), Vec.new, Vec.new, ReflT::Diffuse),                    # Front
  Sphere.new(1e5, Vec.new(50, 1e5, 81.6), Vec.new, Vec.new(0.75, 0.75, 0.75), ReflT::Diffuse),         # Bottom
  Sphere.new(1e5, Vec.new(50, -1e5 + 81.6, 81.6), Vec.new, Vec.new(0.75, 0.75, 0.75), ReflT::Diffuse), # Top
  Sphere.new(16.5, Vec.new(27, 16.5, 47), Vec.new, Vec.new(1, 1, 1) * 0.999, ReflT::Specular),         # Mirror
  Sphere.new(16.5, Vec.new(73, 16.5, 78), Vec.new, Vec.new(1, 1, 1) * 0.999, ReflT::Refractive),       # Glass
  Sphere.new(600, Vec.new(50, 681.6 - 0.27, 81.6), Vec.new(12, 12, 12), Vec.new, ReflT::Diffuse),      # Light
]

Small utilities

Color channels are clamped to [0, 1], then gamma-corrected (2.2 gamma) for display.

def clamp(value : Float64)
  value < 0 ? 0.0 : value > 1 ? 1.0 : value
end

def to_int(value : Float64)
  (clamp(value) ** (1 / 2.2) * 255 + 0.5).to_i
end

Scene intersection: linear scan over all spheres, keeping the closest hit. We iterate backwards so ties resolve exactly like the C++ original does.

def intersect(ray : Ray)
  t = 1e20
  id = 0
  SPHERES.size.downto(1) do |index|
    distance = SPHERES[index - 1].intersect(ray)
    next unless distance != 0.0 && distance < t
    t = distance
    id = index - 1
  end
  {t < 1e20, t, id}
end

Random numbers: each scanline gets its own PCG32 stream seeded from the row index, so output is deterministic regardless of thread scheduling. next_u yields a UInt32, which we map uniformly onto [0, 1).

TWO_POW_32_INV = 1.0 / 4294967296.0

def next_f(rng : Random::PCG32) : Float64
  rng.next_u * TWO_POW_32_INV
end

The heart: radiance estimation

radiance traces a ray and returns the light it carries. At every bounce it picks up the surface's emission, then recurses along a randomly chosen direction depending on the material.

Russian roulette: after 5 bounces, paths are killed with probability 1 - p, where p is the brightest color channel. Surviving paths are scaled by 1/p so the estimate stays unbiased — expected contribution is unchanged, but deep paths terminate quickly.

def radiance(ray : Ray, depth : Int32, rng : Random::PCG32) : Vec
  hit, t, sphere_index = intersect(ray)
  return Vec.new unless hit

  object = SPHERES[sphere_index]
  x = ray.o + ray.d * t                # hit point
  n = (x - object.position).norm       # geometric normal
  nl = n.dot(ray.d) < 0 ? n : n * -1.0 # normal facing the incoming ray
  f = object.color
  p = f.x > f.y && f.x > f.z ? f.x : f.y > f.z ? f.y : f.z

  depth += 1
  if depth > 5
    if next_f(rng) < p
      f = f * (1 / p)
    else
      return object.emission
    end
  end

  case object.reflection
  in .diffuse?

Diffuse: sample the cosine-weighted hemisphere around the normal. The tangent-frame construction (w, u, v) is the standard branchless trick: pick any vector not parallel to the normal, build an orthonormal basis, and express the sampled direction in it.

    r1 = 2 * Math::PI * next_f(rng)
    r2 = next_f(rng)
    r2s = Math.sqrt(r2)
    w = nl
    u = ((w.x.abs > 0.1 ? Vec.new(0, 1, 0) : Vec.new(1, 0, 0)) % w).norm
    v = w % u
    d = (u * (Math.cos(r1) * r2s) + v * (Math.sin(r1) * r2s) + w * Math.sqrt(1 - r2)).norm
    object.emission + f.mult(radiance(Ray.new(x, d), depth, rng))
  in .specular?

Perfect mirror: reflect the ray about the normal and keep going.

    object.emission + f.mult(radiance(Ray.new(x, ray.d - n * (2 * n.dot(ray.d))), depth, rng))
  in .refractive?
    radiance_refractive(object, ray, x, n, nl, f, depth, rng)
  end
end

Glass is the subtle case. The ray may refract (bend) into or out of the sphere, or reflect — governed by Fresnel's equations:

  • If total internal reflection occurs (cos2t < 0), the ray must reflect.
  • Otherwise both reflection and refraction are possible. Below 2 bounces we add both contributions weighted by Fresnel reflectance Re; deeper, we pick one at random with probability proportional to its weight (Russian roulette again), which keeps the estimator unbiased while cutting cost.
def radiance_refractive(object : Sphere, ray : Ray, x : Vec, n : Vec, nl : Vec,
                        f : Vec, depth : Int32, rng : Random::PCG32) : Vec
  refl_ray = Ray.new(x, ray.d - n * (2 * n.dot(ray.d)))
  into = n.dot(nl) > 0 # entering or exiting the glass?
  nc = 1.0             # index of refraction of air
  nt = 1.5             # index of refraction of glass
  nnt = into ? nc / nt : nt / nc
  ddn = ray.d.dot(nl)
  cos2t = 1 - nnt * nnt * (1 - ddn * ddn)
  return object.emission + f.mult(radiance(refl_ray, depth, rng)) if cos2t < 0

  tdir = (ray.d * nnt - n * ((into ? 1.0 : -1.0) * (ddn * nnt + Math.sqrt(cos2t)))).norm
  a = nt - nc
  b = nt + nc
  r0 = a * a / (b * b) # Fresnel reflectance at normal incidence
  c = 1 - (into ? -ddn : tdir.dot(n))
  re = r0 + (1 - r0) * c * c * c * c * c # Schlick's approximation
  tr = 1 - re
  prob = 0.25 + 0.5 * re
  rp = re / prob
  tp = tr / (1 - prob)
  object.emission +
    f.mult(
      if depth > 2
        if next_f(rng) < prob
          radiance(refl_ray, depth, rng) * rp
        else
          radiance(Ray.new(x, tdir), depth, rng) * tp
        end
      else
        radiance(refl_ray, depth, rng) * re + radiance(Ray.new(x, tdir), depth, rng) * tr
      end
    )
end

Camera setup

A pinhole camera looking down -Z. cx and cy are the horizontal and vertical increments per pixel; the odd-looking -0.042612 tilt gives the scene its slightly elevated viewpoint.

width = 1024
height = 768
samples : Int32 = ARGV.size == 1 ? (ARGV[0].to_i // 4) : 1

camera = Ray.new(Vec.new(50, 52, 295.6), Vec.new(0, -0.042612, -1).norm)
cx = Vec.new(width * 0.5135 / height)
cy = (cx % camera.d).norm * 0.5135

canvas = Slice(Vec).new(width * height, Vec.new)

Parallel rendering

Since Crystal 1.21 programs start with parallelism set to 1, so we resize the default execution context to use every core.

Rows are claimed from a shared atomic counter (work stealing): no worker ever touches another's pixels, so there is no locking on the canvas, and scaling is near-perfect. Each row seeds its own RNG stream, so results are reproducible no matter which worker gets which row.

start_time = Time.instant

workers = ENV["SMALLPT_WORKERS"]?.try(&.to_i) || System.cpu_count
Fiber::ExecutionContext.default.resize(workers)
next_row = Atomic(Int32).new(0)
done_rows = Atomic(Int32).new(0)
wg = WaitGroup.new(workers)

workers.times do
  spawn do
    loop do
      y = next_row.add(1)
      break if y >= height
      render_row(y, width, height, samples, camera, cx, cy, canvas)
      done = done_rows.add(1) + 1
      STDERR.printf("\rRendering (%d spp) %5.2f%%", samples * 4, 100.0 * done / height) if done % 16 == 0
    end
    wg.done
  end
end

wg.wait

elapsed = Time.instant - start_time
STDERR.puts "\rRendering took #{elapsed.total_milliseconds / 1000.0}s   "

File.write("image.ppm", build_string(canvas, width, height))

Rendering one scanline: for each pixel we take a 2x2 grid of subpixels, and for each subpixel average samples camera rays. Rays are jittered inside the subpixel with a tent filter (the dx/dy computation concentrates samples toward the subpixel center), which reduces aliasing better than uniform jitter.

def render_row(y : Int32, width : Int32, height : Int32, samples : Int32,
               camera : Ray, cx : Vec, cy : Vec, canvas : Slice(Vec))
  rng = Random::PCG32.new(UInt64.new(y * y * y))
  width.times do |x|
    2.times do |subpixel_y|
      index = (height - y - 1) * width + x
      2.times do |subpixel_x|
        accumulated = Vec.new
        samples.times do
          r1 = 2 * next_f(rng)
          dx = r1 < 1 ? Math.sqrt(r1) - 1 : 1 - Math.sqrt(2 - r1)
          r2 = 2 * next_f(rng)
          dy = r2 < 1 ? Math.sqrt(r2) - 1 : 1 - Math.sqrt(2 - r2)
          direction = cx * (((subpixel_x + 0.5 + dx) / 2 + x) / width - 0.5) +
                      cy * (((subpixel_y + 0.5 + dy) / 2 + y) / height - 0.5) +
                      camera.d
          accumulated += radiance(Ray.new(camera.o + direction * 140, direction.norm), 0, rng) * (1.0 / samples)
        end
        canvas[index] += Vec.new(clamp(accumulated.x), clamp(accumulated.y), clamp(accumulated.z)) * 0.25
      end
    end
  end
end

Finally, write the image out as ASCII PPM (the format that requires zero dependencies to produce).

def build_string(canvas : Slice(Vec), width : Int32, height : Int32)
  String.build do |io|
    io << "P3\n#{width} #{height}\n255\n"
    canvas.each do |pixel|
      io << to_int(pixel.x) << ' ' << to_int(pixel.y) << ' ' << to_int(pixel.z) << ' '
    end
  end
end