Doing Raytracer in Crystal

Latest code you can parametrize by setting env vars. If you want the best possible quality, this is like a 20k image that was sampled to 10k:

> CRYSTAL_WORKERS=16 AA_SAMPLES=4 SIZE=10000 ADAPTIVE_AA=0 ./bin/raytracer
Completed in 16849.322 ms

> file crystal-raytracer.png
crystal-raytracer.png: PNG image data, 10000 x 10000, 8-bit/color RGB, non-interlaced

This is a small part of that image on 1:1 scale

When I make time, I’ll get latest code and try those settings.

I tried this and it’s roughly the same speed, so I kept it cause it’s cleaner.

Doing some absurd microoptimizations I may have squeezed another 5% speedup (which is not noticeable at all)

Ok, I made some time and installed your new code and ran it.

First let me thank you for your excellent, easy to follow, instructions to do it.

Running each program on my older Lenovo, 5.1 GHz, 8C|16T laptop gives these rough results:

These are just eyeballed ballpark numbers from a few runs of each:

raytracer-500x500: ~9.1ms - 9.3ms
raytracer-1kx1k: ~34.4ms - 36.1ms
benchmark-500x500: ~9.2ms - 9.4ms
animated: ~84.5 FPS, ~10.ms - 12ms 

It’s curious that the raytracer 500x500 time is almost 3x slower than before. :thinking:

But I enjoyed the animations. So little code can produce such a nice effect.

This again was done with crystal-1.19.0-dev-1, with the final 1.19 supposed to be released today. It hasn’t been as I write this.

I’ll run them on my newer 5.4GHz, 16C|32T laptop later when I’m home.

Very nice work!

Oops, I missed you already tried benchmark.

You should not need to run it more than once, it uses benchmark.ips to run a while and average results.

Got 1.19 today; latest benchmark.cr code. Changed all Time.monotonic to Time.instant to get rid of deprecation warnings; compiled all three. Found cause of prior slower runs was spelling error. Did > CRYSTAL_WORKER… vs > CRYSTAL_WORKERS.. Hate when I do that.

Times now are what I expected, on Lenovo AMD 8845HS (8C|16T) @ 5.14GHz.

raytracer-500x500: ~3.4ms - 4.8ms
raytracer-1kx1k: ~13.4ms - 15.6.1ms
animated: ~168.5 FPS, ~5.5.ms - 6.8ms
=======================================================
➜ CRYSTAL_WORKERS=16 ./bin/benchmark                                               
Benchmarking 500x500 render...
Workers: 16

render 265.95  (  3.76ms) (± 5.80%)  4.31kB/op  fastest
========================================================

➜  raytracer_new CRYSTAL_WORKERS=16 ./bin/benchmark                                                        
Benchmarking 1000x1000 render...
Workers: 16

render  70.10  ( 14.26ms) (± 2.91%)  4.28kB/op  fastest
=========================================================

➜  raytracer_new CRYSTAL_WORKERS=16 ./bin/benchmark                                                           
Benchmarking 5000x5000 render...
Workers: 16

render   2.90  (344.89ms) (± 1.11%)  4.14kB/op  fastest

For completeness, here are times on my AMD 7945HX (16C|32T) @5.4GHz laptop.

animated: ~257.5 FPS, ~3.4ms - 4.2ms
=======================================================

➜  raytracer_new CRYSTAL_WORKERS=32 ./bin/benchmark
Benchmarking 500x500 render...
Workers: 32

render 435.44  (  2.30ms) (±10.96%)  7.99kB/op  fastest
=========================================================                                                                 

➜  raytracer_new CRYSTAL_WORKERS=32 ./bin/benchmark                                                        
Benchmarking 1000x1000 render...
Workers: 32

render 121.73  (  8.21ms) (± 6.55%)  7.81kB/op  fastest
==========================================================

➜  raytracer_new CRYSTAL_WORKERS=32 ./bin/benchmark
Benchmarking 5000x5000 render...
Workers: 32

render   5.26  (190.15ms) (± 0.76%)  10.5kB/op  fastest
➜  raytracer_new 

I updated benchmark code for Crystal 1.21 to use EC for parallelism. Here’s a typical run.

./benchmark-new                                                                     
Benchmarking 5000x5000 render...
Workers: 32

render   5.30  (188.54ms) (± 2.28%)  20.8kB/op  fastest

Standalone code runs for 500x500 takes ~3.9-4.1s. Compiled using -Dgc_none it’s ~2.9-3.1s. Here’s the updated benchmark code.

# Crystal >= 1.21
# $ crystal build --release --mcpu=native -benchmark-new.cr
# $ ./crystal-raybenchmark-new  or $ CRYSTAL_WORKERS=x ./crystal-raybenchmark-new

require "benchmark"
require "crimage"
require "wait_group"

struct Vector
  getter x : Float32, y : Float32, z : Float32

  def initialize(@x : Float32, @y : Float32, @z : Float32) end
  def scale(k : Float32) : Vector; Vector.new(@x * k, @y * k, @z * k) end
  def -(other : Vector) : Vector; Vector.new(@x - other.x, @y - other.y, @z - other.z) end
  def +(other : Vector) : Vector; Vector.new(@x + other.x, @y + other.y, @z + other.z) end
  def dot(other : Vector) : Float32; @x * other.x + @y * other.y + @z * other.z end
  def mag : Float32; Math.sqrt(@x * @x + @y * @y + @z * @z) end

  def norm : Vector
    mag_val = mag
    return Vector.new(Float32::INFINITY, Float32::INFINITY, Float32::INFINITY) if mag_val == 0
    scale(1.0_f32 / mag_val)
  end

  def cross(other : Vector) : Vector
    Vector.new(@y * other.z - @z * other.y, @z * other.x - @x * other.z, @x * other.y - @y * other.x)
  end
end

struct Color
  getter r : Float32, g : Float32, b : Float32

  def initialize(@r : Float32, @g : Float32, @b : Float32) end
  def scale(k : Float32) : Color; Color.new(@r * k, @g * k, @b * k) end
  def +(other : Color) : Color; Color.new(@r + other.r, @g + other.g, @b + other.b) end
  def *(other : Color) : Color; Color.new(@r * other.r, @g * other.g, @b * other.b) end
end

COLOR_WHITE         = Color.new(1.0_f32, 1.0_f32, 1.0_f32)
COLOR_GREY          = Color.new(0.5_f32, 0.5_f32, 0.5_f32)
COLOR_BLACK         = Color.new(0.0_f32, 0.0_f32, 0.0_f32)
COLOR_BACKGROUND    = COLOR_BLACK
COLOR_DEFAULT_COLOR = COLOR_BLACK

module Surface
  abstract def diffuse(pos : Vector) : Color
  abstract def specular(pos : Vector) : Color
  abstract def reflect(pos : Vector) : Float32
  abstract def roughness : Int32
end

class ShinySurface
  include Surface

  def diffuse(pos : Vector) : Color; COLOR_WHITE end
  def specular(pos : Vector) : Color; COLOR_GREY end
  def reflect(pos : Vector) : Float32; 0.7_f32   end
  def roughness : Int32; 250 end
end

class CheckerboardSurface
  include Surface

  def diffuse(pos : Vector) : Color; ((pos.z).floor.to_i + (pos.x).floor.to_i).odd? ? COLOR_WHITE : COLOR_BLACK end
  def reflect(pos : Vector) : Float32; ((pos.z).floor.to_i + (pos.x).floor.to_i).odd? ? 0.1_f32 : 0.7_f32 end
  def specular(pos : Vector) : Color; COLOR_WHITE end
  def roughness : Int32; 250 end
end

SURFACE_SHINY        = ShinySurface.new
SURFACE_CHECKERBOARD = CheckerboardSurface.new

class Camera
  getter pos : Vector, forward : Vector, right : Vector, up : Vector

  def initialize(pos : Vector, look_at : Vector)
    down     = Vector.new(0.0_f32, -1.0_f32, 0.0_f32)
    @pos     = pos
    @forward = (look_at - @pos).norm
    @right   = (@forward.cross(down)).norm.scale(1.5_f32)
    @up      = (@forward.cross(@right)).norm.scale(1.5_f32)
  end
end

record Ray, start : Vector, dir : Vector
record Intersection, thing : Thing, ray : Ray, dist : Float32

module Thing
  abstract def normal(pos : Vector) : Vector
  abstract def surface : Surface
  abstract def intersect(ray : Ray) : Intersection?
end

class Sphere
  include Thing
  getter radius2 : Float32, center : Vector

  def initialize(@center : Vector, radius : Float32, @surface : Surface) @radius2 = radius * radius end
  def normal(pos : Vector) : Vector; (pos - @center).norm end
  def surface : Surface; @surface end

  def intersect(ray : Ray) : Intersection?
    eo    = @center - ray.start
    v     = eo.dot(ray.dir)
    dist  = 0.0_f32
    (disc = @radius2 - (eo.dot(eo) - v * v); dist = v - Math.sqrt(disc) if disc >= 0) if v >= 0
    (dist == 0) ? nil : Intersection.new(self, ray, dist)
  end
end

class Plane
  include Thing
  getter norm : Vector, offset : Float32

  def initialize(@norm : Vector, @offset : Float32, @surface : Surface) end
  def normal(pos : Vector) : Vector; @norm end
  def surface : Surface; @surface end

  def intersect(ray : Ray) : Intersection?
    return nil if (denom = @norm.dot(ray.dir)) > 0
    dist = (@norm.dot(ray.start) + @offset) / (-denom)
    Intersection.new(self, ray, dist)
  end
end

record Light, pos : Vector, color : Color

class Scene
  getter things : Array(Thing), lights : Array(Light), camera : Camera
  def initialize(@things : Array(Thing), @lights : Array(Light), @camera : Camera) end
end

class RayTracer
  MAX_DEPTH = 5

  def intersections(ray : Ray, scene : Scene) : Intersection?
    closest, closest_inter, things = Float32::INFINITY, nil, scene.things

    things.each do |item|
      inter = item.intersect(ray)
      (closest_inter = inter; closest = inter.dist) if inter && inter.dist < closest
    end
    closest_inter
  end

  def test_ray(ray : Ray, scene : Scene) : Float32?
    isect = intersections(ray, scene)
    isect && isect.dist
  end

  def trace_ray(ray : Ray, scene : Scene, depth : Int32) : Color
    isect = intersections(ray, scene)
    isect.nil? ? COLOR_BACKGROUND : shade(isect, scene, depth)
  end

  def shade(isect : Intersection, scene : Scene, depth : Int32) : Color
    d       = isect.ray.dir
    pos     = isect.ray.start + (d.scale(isect.dist))
    normal  = isect.thing.normal(pos)
    dot_val = normal.dot(d)
    reflect_dir     = d - (normal.scale(2.0_f32 * dot_val))
    natural_color   = COLOR_BACKGROUND + get_natural_color(isect.thing, pos, normal, reflect_dir, scene)
    reflected_color = depth >= MAX_DEPTH ? COLOR_GREY : get_reflection_color(isect.thing, pos, normal, reflect_dir, scene, depth)
    natural_color + reflected_color
  end

  def get_reflection_color(thing : Thing, pos : Vector, normal : Vector, rd : Vector, scene : Scene, depth : Int32) : Color
    reflect_factor = thing.surface.reflect(pos)
    return COLOR_DEFAULT_COLOR if reflect_factor == 0
    (trace_ray(Ray.new(pos, rd), scene, depth + 1)).scale(reflect_factor)
  end

  def get_natural_color(thing : Thing, pos : Vector, norm : Vector, rd : Vector, scene : Scene) : Color

    color, lights, surface = COLOR_DEFAULT_COLOR, scene.lights, thing.surface

    lights.each do |light|
      ldis       = light.pos - pos
      livec      = ldis.norm
      neat_isect = test_ray(Ray.new(pos, livec), scene)

      next if (is_in_shadow = neat_isect && neat_isect <= ldis.mag)
      next if (illum = livec.dot(norm)) <= 0

      lcolor   = light.color.scale(illum)
      specular = livec.dot(rd)
      scolor   = specular > 0 ? light.color.scale(specular ** surface.roughness) : COLOR_DEFAULT_COLOR
      color    = color + (surface.diffuse(pos) * lcolor) + (surface.specular(pos) * scolor)
    end
    color
  end

  def render(scene : Scene, width : Int32, height : Int32) : CrImage::RGBA
    buffer = Bytes.new(width * height * 4)
    render_to_buffer(scene, width, height, buffer)
    CrImage::RGBA.from_buffer(buffer, width, height)
  end

  def render_to_buffer(scene : Scene, width : Int32, height : Int32, buffer : Bytes) : Nil
    num_threads = ENV["CRYSTAL_WORKERS"]?.try(&.to_i) || System.cpu_count

    next_row = Atomic(Int32).new(0)
    wg = WaitGroup.new(num_threads)

    things, lights                 = scene.things, scene.lights
    camera_pos, camera             = scene.camera.pos, scene.camera
    cam_forward, cam_right, cam_up = camera.forward, camera.right, camera.up
    local_scene = Scene.new(things, lights, camera)

    num_threads.times do
      Fiber::ExecutionContext::Isolated.new("workers") do
        while (y = next_row.add(1)) < height
          row_offset = y * width * 4
          recenter_y = -((y - (height >> 1)) / (height << 1)).to_f32
          up_scaled  = cam_up.scale(recenter_y)
          forward_plus_up = cam_forward + up_scaled # Pre-compute constant per row

          offset = row_offset
          width.times do |x|
            recenter_x = ((x - (width >> 1)) / (width << 1)).to_f32
            ray_dir    = (forward_plus_up + cam_right.scale(recenter_x)).norm
            color      = trace_ray(Ray.new(camera_pos, ray_dir), local_scene, 0)

            buffer[offset]     = (color.r.clamp(0.0_f32, 1.0_f32) * 255).to_u8
            buffer[offset + 1] = (color.g.clamp(0.0_f32, 1.0_f32) * 255).to_u8
            buffer[offset + 2] = (color.b.clamp(0.0_f32, 1.0_f32) * 255).to_u8
            buffer[offset + 3] = 255_u8
            offset += 4
        end end
        wg.done
    end end
    wg.wait

    # Create image from the filled buffer
    CrImage::RGBA.from_buffer(buffer, width, height)
  end
end

class DefaultScene
  getter things : Array(Thing), lights : Array(Light), camera : Camera

  def initialize
    @things = [
      Plane.new(Vector.new(0.0_f32, 1.0_f32, 0.0_f32), 0.0_f32, SURFACE_CHECKERBOARD),
      Sphere.new(Vector.new(0.0_f32, 1.0_f32, -0.25_f32), 1.0_f32, SURFACE_SHINY),
      Sphere.new(Vector.new(-1.0_f32, 0.5_f32, 1.5_f32), 0.5_f32, SURFACE_SHINY),
    ] of Thing
    @lights = [
      Light.new(Vector.new(-2.0_f32, 2.5_f32, 0.0_f32), Color.new(0.49_f32, 0.07_f32, 0.07_f32)),
      Light.new(Vector.new(1.5_f32, 2.5_f32, 1.5_f32), Color.new(0.07_f32, 0.07_f32, 0.49_f32)),
      Light.new(Vector.new(1.5_f32, 2.5_f32, -1.5_f32), Color.new(0.07_f32, 0.49_f32, 0.071_f32)),
      Light.new(Vector.new(0.0_f32, 3.5_f32, 0.0_f32), Color.new(0.21_f32, 0.21_f32, 0.35_f32)),
    ]
    @camera = Camera.new(Vector.new(3.0_f32, 2.0_f32, 4.0_f32), Vector.new(-1.0_f32, 0.5_f32, 0.0_f32))
  end

  def to_scene : Scene; Scene.new(@things, @lights, @camera) end
end

width, height = 5000, 5000

default_scene = DefaultScene.new
scene = default_scene.to_scene
ray_tracer = RayTracer.new

puts "Benchmarking #{width}x#{height} render..."
puts "Workers: #{(ENV["CRYSTAL_WORKERS"]? || System.cpu_count)}"
puts ""

# Pre-allocate buffer for benchmarking
benchmark_buffer = Bytes.new(width * height * 4)

Benchmark.ips(warmup: 4.seconds, calculation: 10.seconds) do |x|
  x.report("render") { ray_tracer.render_to_buffer(scene, width, height, benchmark_buffer) }
end

# Save one image for verification
img = ray_tracer.render(scene, width, height)
CrImage::PNG.write("crystal-raytracer5kx5kbenchmark-new.png", img)