Ruby Raytracer in Crystal

This website contains comparison of different language implementations of a raytracer.

I present here the optimized Crystal implementation of the Ruby version.
The source code is over +90% the same|equivalent.
However, because Crystal is a compiled static typed language, it’s orders of
magnitude faster by taking specific advantages of static typing of values.

For Rubyist who need segments of their code perform optimally, using Crystal
(which went 1.0.0 on March 22, 2021) is probably a better alternative than
using C, Rust, etc, for those instances, because you can essentially write
the code in Ruby, and make the minor changes in it to use Crystal.

Here is the original Ruby code from the website.

ruby-raytracer.rb

# Following libs are required:
#   gem install imageruby
#   gem install imageruby-bmp

require "rubygems"
require "imageruby"

class Vector
    attr_accessor :x , :y , :z
    def initialize(x,y,z)
        @x = x
        @y = y
        @z = z
    end

    def self.times(k, v)
        return Vector.new(k * v.x, k * v.y, k * v.z)
    end

    def self.minus(v1, v2)
        return Vector.new(v1.x - v2.x, v1.y - v2.y, v1.z - v2.z)
    end

    def self.plus(v1, v2)
        return Vector.new(v1.x + v2.x, v1.y + v2.y, v1.z + v2.z)
    end

    def self.dot(v1, v2)
        return v1.x * v2.x + v1.y * v2.y + v1.z * v2.z
    end

    def self.mag(v)
        return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z)
    end

    def self.norm(v)
        mag = Vector.mag(v)
        if (mag == 0)
            div = Float::INFINITY
        else
            div = 1.0 / mag
        end
        return Vector.times(div, v)
    end

    def self.cross(v1, v2)
        return Vector.new(v1.y * v2.z - v1.z * v2.y, v1.z * v2.x - v1.x * v2.z, v1.x * v2.y - v1.y * v2.x)
    end
end

class Color
    attr_accessor :r, :g, :b

    def initialize(r,g,b)
        @r, @g, @b = r, g, b
    end

    def self.scale(k, v)
        return Color.new(k * v.r, k * v.g, k * v.b)
    end

    def self.plus(v1, v2)
        return Color.new(v1.r + v2.r, v1.g + v2.g, v1.b + v2.b)
    end

    def self.times(v1, v2)
        return Color.new(v1.r * v2.r, v1.g * v2.g, v1.b * v2.b)
    end

    def self.toDrawingColor(c)
        clamp = lambda do |d|
            return 1 if d > 1
            return 0 if d < 0
            return d
        end
        r = (clamp.(c.r)*255).floor
        g = (clamp.(c.g)*255).floor
        b = (clamp.(c.b)*255).floor
        return r, g ,b
    end
end

Color_white        = Color.new(1.0, 1.0, 1.0)
Color_grey         = Color.new(0.5, 0.5, 0.5)
Color_black        = Color.new(0.0, 0.0, 0.0)
Color_background   = Color_black
Color_defaultColor = Color_black

class Camera
    attr_accessor :pos, :forward, :right, :up
    def initialize(pos, lookAt)
        down     = Vector.new(0.0, -1.0, 0.0)
        @pos     = pos
        @forward = Vector.norm(Vector.minus(lookAt, @pos))
        @right   = Vector.times(1.5, Vector.norm(Vector.cross(@forward, down)))
        @up      = Vector.times(1.5, Vector.norm(Vector.cross(@forward, @right)))
    end
end

class Ray
    attr_accessor :start, :dir
    def initialize(start, dir)
        @start = start
        @dir   = dir
    end
end

class Intersection
    attr_accessor :thing, :ray, :dist
    def initialize(thing, ray, dist)
        @thing = thing
        @ray   = ray
        @dist  = dist
    end
end

class Light
    attr_accessor :pos, :color
    def initialize(pos, color)
        @pos   = pos
        @color = color
    end
end

class Sphere
    def initialize(center, radius, surface)
        @radius2 = radius*radius
        @_surface = surface
        @center  = center
    end

    def normal(pos)
        return Vector.norm(Vector.minus(pos, @center))
    end

    def surface()
        return @_surface
    end

    def intersect(ray)
        eo = Vector.minus(@center, ray.start)
        v  = Vector.dot(eo, ray.dir)
        dist = 0
        if (v >= 0)
            disc = @radius2 - (Vector.dot(eo, eo) - v * v)
            if (disc >= 0)
                dist = v - Math.sqrt(disc)
            end
        end
        if (dist == 0)
            return nil
        end
        return Intersection.new(self, ray, dist)
    end
end

class Plane
    def initialize(norm, offset, surface)
        @_norm    = norm
        @_surface = surface
        @offset   = offset
    end

    def normal(pos)
        return @_norm
    end

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

    def surface()
        return @_surface
    end
end

class ShinySurface
    def diffuse(pos)
        return Color_white
    end
    def specular(pos)
        return Color_grey
    end
    def reflect(pos)
        return 0.7
    end
    def roughness()
        return 250
    end
end

class CheckerboardSurface
    def diffuse(pos)
        return Color_white if ((pos.z).floor + (pos.x).floor) % 2 != 0
        return Color_black
    end

    def specular(pos)
        return Color_white
    end

    def reflect(pos)
        return 0.1 if ((pos.z).floor  + (pos.x).floor) % 2 != 0
        return 0.7
    end

    def roughness()
        return 250
    end
end

Surface_shiny        = ShinySurface.new
Surface_checkerboard = CheckerboardSurface.new

class RayTracer
    MaxDepth = 5

    def intersections(ray, scene)
        closest = Float::INFINITY
        closestInter = nil
        for item in scene.things()
            inter = item.intersect(ray)
            if inter != nil and inter.dist < closest
                closestInter = inter
                closest = inter.dist
            end
        end
        return closestInter
    end

    def testRay(ray, scene)
        isect = self.intersections(ray, scene)
        return isect.dist if isect != nil
        return nil
    end

    def traceRay(ray, scene, depth)
        isect = self.intersections(ray, scene)
        return Color_background if (isect == nil)
        return self.shade(isect, scene, depth)
    end

    def shade(isect, scene, depth)
        d = isect.ray.dir
        pos = Vector.plus(Vector.times(isect.dist, d), isect.ray.start)
        normal = isect.thing.normal(pos)
        reflectDir   = Vector.minus(d, Vector.times(2, Vector.times(Vector.dot(normal, d), normal)))
        naturalColor = Color.plus(Color_background,self.getNaturalColor(isect.thing, pos, normal, reflectDir, scene))

        if (depth >= MaxDepth)
            reflectedColor = Color_grey
        else
            reflectedColor = self.getReflectionColor(isect.thing, pos, normal, reflectDir, scene, depth)
        end
        return Color.plus(naturalColor, reflectedColor)
    end

    def getReflectionColor(thing, pos, normal, rd, scene, depth)
        return Color.scale(thing.surface().reflect(pos), self.traceRay(Ray.new(pos, rd), scene, depth + 1))
    end

    def getNaturalColor(thing, pos, norm, rd, scene)
        color = Color_defaultColor
        for light in scene.lights()
            color = self.addLight(color, light,pos, norm,scene,thing,rd)
        end
        return color
    end

    def addLight(col, light, pos, norm, scene, thing, rd)
        ldis  = Vector.minus(light.pos, pos)
        livec = Vector.norm(ldis)
        neatIsect = self.testRay(Ray.new(pos, livec), scene)

        isInShadow = false
        isInShadow = neatIsect <= Vector.mag(ldis) if neatIsect != nil

        return col if isInShadow

        illum = Vector.dot(livec, norm)

        lcolor = Color_defaultColor
        lcolor = Color.scale(illum, light.color) if illum > 0

        specular = Vector.dot(livec, Vector.norm(rd))
        scolor   = Color_defaultColor
        scolor   = Color.scale(specular ** thing.surface().roughness(), light.color) if (specular > 0)

        return Color.plus(col, Color.plus(Color.times(thing.surface().diffuse(pos), lcolor), Color.times(thing.surface().specular(pos), scolor)))
    end

    def getPoint(x, y, camera,screenWidth,screenHeight)
        recenterX = lambda  do |x|
            (x - (screenWidth  / 2.0)) / 2.0 / screenWidth
        end
        recenterY = lambda  do |y|
            -(y - (screenHeight / 2.0)) / 2.0 / screenHeight
        end
        return Vector.norm(Vector.plus(camera.forward, Vector.plus(Vector.times(recenterX.(x), camera.right), Vector.times(recenterY.(y), camera.up))))
    end

    def render(scene, image, screenWidth, screenHeight)
        for y in (0..screenHeight-1)
            for x in (0..screenWidth-1)
                color = self.traceRay(Ray.new(scene.camera().pos, self.getPoint(x, y, scene.camera(),screenWidth, screenHeight )) , scene, 0)
                r,g,b = Color.toDrawingColor(color)
                image.set_pixel(x,y, ImageRuby::Color.from_rgba(r,g,b, 255));
            end
        end
    end
end

class DefaultScene
    attr_accessor :things, :lights, :camera
    def initialize
        @things = [
            Plane.new(Vector.new(0.0, 1.0, 0.0)  ,0.0, Surface_checkerboard),
            Sphere.new(Vector.new(0.0, 1.0, -0.25),1.0, Surface_shiny),
            Sphere.new(Vector.new(-1.0, 0.5, 1.5) ,0.5, Surface_shiny)
        ]
        @lights = [
             Light.new(Vector.new(-2.0, 2.5, 0.0), Color.new(0.49, 0.07, 0.07)),
             Light.new(Vector.new(1.5, 2.5, 1.5),  Color.new(0.07, 0.07, 0.49)),
             Light.new(Vector.new(1.5, 2.5, -1.5), Color.new(0.07, 0.49, 0.071)),
             Light.new(Vector.new(0.0, 3.5, 0.0),  Color.new(0.21, 0.21, 0.35))
        ]
        @camera = Camera.new(Vector.new(3.0, 2.0, 4.0), Vector.new(-1.0, 0.5, 0.0))
    end
end

width  = 500
height = 500
image = ImageRuby::Image.new(width, height, ImageRuby::Color.black)

t1 = Time.now
n.times do
rayTracer = RayTracer.new()
scene     = DefaultScene.new()
rayTracer.render(scene, image, width, height)
t2 = Time.now

t = (t2 - t1)*1000

puts "Completed in #{t} ms"
image.save("ruby-raytracer.bmp", :bmp)

Here is the optimized Crystal version, with compiling instructions.
1: To use stumpy_png shard, create file (shown below): shard.yml
2: Place it in the same dir|folder as the source filename.cr file.
3: Run (need internet connection): $ shards init then $ shards install
4: Compile *.cr source file: $ crystal build filename.cr --release
5: Run compiled code (no *.cr): $ ./filename
6: A filename.png file will be created in dir, which you can view.

shard.yml

name: shards
version: 1.2.3
crystal: '>= 0.34.0'

dependencies:
  stumpy_png:
    github: stumpycr/stumpy_png
    version: "~> 5.0"

crystal-raytracer-opt.cr

require "stumpy_png"

struct Vector
  getter x, y, z

  def initialize(@x : Float64, @y : Float64, @z : Float64) end

  def self.minus(v1, v2) Vector.new(v1.x - v2.x, v1.y - v2.y, v1.z - v2.z) end

  def self.plus(v1, v2)  Vector.new(v1.x + v2.x, v1.y + v2.y, v1.z + v2.z) end
      
  def self.scale(k, v)   Vector.new(k * v.x, k * v.y, k * v.z) end

  def self.dot(v1, v2) v1.x * v2.x + v1.y * v2.y + v1.z * v2.z end
      
  def self.mag(v) Math.sqrt(Vector.dot(v, v)) end

  def self.norm(v)
    mag = Vector.mag(v)
    div = (mag == 0) ? Float64::INFINITY : 1.0 / mag
    Vector.scale(div, v)
  end

  def self.cross(v1, v2)
    Vector.new(v1.y * v2.z - v1.z * v2.y, v1.z * v2.x - v1.x * v2.z, v1.x * v2.y - v1.y * v2.x)
  end
end

struct Color
  getter r, g, b

  def initialize(@r : Float64, @g : Float64, @b : Float64)   end

  def self.scale(k, v)  Color.new(k * v.r, k * v.g, k * v.b) end

  def self.plus(v1, v2) Color.new(v1.r + v2.r, v1.g + v2.g, v1.b + v2.b) end

  def self.mult(v1, v2) Color.new(v1.r * v2.r, v1.g * v2.g, v1.b * v2.b) end
      
  def self.toDrawingColor(c)
    r = (c.r.clamp(0.0, 1.0)*255).floor
    g = (c.g.clamp(0.0, 1.0)*255).floor
    b = (c.b.clamp(0.0, 1.0)*255).floor
    {r, g, b}
  end
end

Color_white        = Color.new(1.0, 1.0, 1.0)
Color_grey         = Color.new(0.5, 0.5, 0.5)
Color_black        = Color.new(0.0, 0.0, 0.0)
Color_background   = Color_black
Color_defaultColor = Color_black

class Camera
  getter pos, forward, right, up

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

record Ray, start : Vector, dir : Vector
record Light, pos : Vector, color : Color
record Intersection, thing : Thing, ray : Ray, dist : Float64

abstract class Thing end

class Sphere < Thing
  @radius2 : Float64

  def initialize(@center : Vector, radius : Float64, @_surface : Surface)
    @radius2 = radius*radius
  end

  def normal(pos) Vector.norm(Vector.minus(pos, @center)) end

  def surface; @_surface end

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

class Plane < Thing
  def initialize(@_norm : Vector, @offset : Float64, @_surface : Surface) end

  def normal(pos) @_norm end

  def surface; @_surface end

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

abstract class Surface end

class ShinySurface < Surface
  def diffuse(pos) Color_white end

  def specular(pos) Color_grey end

  def reflect(pos) 0.7 end

  def roughness; 250 end
end

class CheckerboardSurface < Surface
  def diffuse(pos) ((pos.z).floor + (pos.x).floor).to_i.odd? ? Color_white : Color_black end
  
  def reflect(pos) ((pos.z).floor + (pos.x).floor).to_i.odd? ? 0.1 : 0.7 end

  def specular(pos) Color_white end

  def roughness; 250 end
end

Surface_shiny        = ShinySurface.new
Surface_checkerboard = CheckerboardSurface.new

class RayTracer
  MaxDepth = 5

  def intersections(ray, scene)
    closest = Float64::INFINITY
    closestInter = nil
    scene.things.each do |item|
      inter = item.intersect(ray)
      if inter && inter.dist < closest
        closestInter = inter
        closest = inter.dist
      end 
    end
    closestInter
  end

  def testRay(ray, scene)
    isect = self.intersections(ray, scene)
    isect && isect.dist
  end

  def traceRay(ray, scene, depth)
    isect = self.intersections(ray, scene)
    isect.nil? ? Color_background : self.shade(isect, scene, depth)
  end

  def shade(isect : Intersection, scene, depth)
    d = isect.ray.dir
    pos = Vector.plus(Vector.scale(isect.dist, d), isect.ray.start)
    normal = isect.thing.normal(pos)
    reflectDir = Vector.minus(d, Vector.scale(2, Vector.scale(Vector.dot(normal, d), normal)))
    naturalColor = Color.plus(Color_background, self.getNaturalColor(isect.thing, pos, normal, reflectDir, scene))
    reflectedColor = (depth >= MaxDepth) ? Color_grey : self.getReflectionColor(isect.thing, pos, normal, reflectDir, scene, depth)
    Color.plus(naturalColor, reflectedColor)
  end

  def getReflectionColor(thing, pos, normal, rd, scene, depth)
    Color.scale(thing.surface.reflect(pos), self.traceRay(Ray.new(pos, rd), scene, depth + 1))
  end

  def getNaturalColor(thing, pos, norm, rd, scene)
    color = Color_defaultColor
    scene.lights.each { |light| color = self.addLight(color, light, pos, norm, scene, thing, rd) }
    color
  end

  def addLight(col, light, pos, norm, scene, thing, rd)
    ldis  = Vector.minus(light.pos, pos)
    livec = Vector.norm(ldis)
    neatIsect = self.testRay(Ray.new(pos, livec), scene)

    isInShadow = neatIsect && neatIsect <= Vector.mag(ldis)
    return col if isInShadow

    illum  = Vector.dot(livec, norm)
    lcolor = (illum > 0) ? Color.scale(illum, light.color) : Color_defaultColor

    specular = Vector.dot(livec, Vector.norm(rd))
    scolor = (specular > 0) ? Color.scale(specular ** thing.surface.roughness, light.color) : Color_defaultColor

    Color.plus(col, Color.plus(Color.mult(thing.surface.diffuse(pos), lcolor), Color.mult(thing.surface.specular(pos), scolor)))
  end

  def getPoint(x : Int32, y : Int32, screenWidth : Int32, screenHeight : Int32, camera)
    recenterX =  (x - (screenWidth  >> 1)) / (screenWidth  << 1)
    recenterY = -(y - (screenHeight >> 1)) / (screenHeight << 1)
    Vector.norm(Vector.plus(camera.forward, Vector.plus(Vector.scale(recenterX, camera.right), Vector.scale(recenterY, camera.up))))
  end

  def render(scene, image, screenWidth, screenHeight)
    screenHeight.times do |y|
      screenWidth.times do |x|
        color = self.traceRay(Ray.new(scene.camera.pos, self.getPoint(x, y, screenWidth, screenHeight, scene.camera)), scene, 0)
        r, g, b = Color.toDrawingColor(color)
        image.set(x, y, StumpyCore::RGBA.from_rgb(r, g, b))
  end end end
end

class DefaultScene
  getter :things, :lights, :camera

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

width, height = 500, 500
image = StumpyCore::Canvas.new(width, height)

n = 1000
t1 = Time.monotonic
n.times do
rayTracer = RayTracer.new
scene = DefaultScene.new
rayTracer.render(scene, image, width, height)
end
t2 = (Time.monotonic - t1).total_milliseconds

puts "total time for #{n} iterations = #{t2} ms, avg time = #{t2/n} ms"

StumpyPNG.write(image, "crystal-raytracer-opt.png") 

Here is an optimized Ruby version which mimics the Crystal implementation.
You can see how identical their code implementations are.

I ran it on these current VMs: Ruby 2.6.7 and 2.7.3, and JRuby 9.2.17.0.
JRuby is fastest, followed by 2.6.7, then 2.7.3
The rvm gem installs of Ruby 3.0.1 and Truffleruby 21.0.0.2 are broke on my
system, so they can’t install the necessary gems to run it. If you are able
to run it with them, I’d be interested in the timing comparisons between all the
different versions.

ruby-raytracer-opt.rb

# Following libs are required:
# $ gem install imageruby imageruby-bmp

#require "rubygems"
require "imageruby"

class Vector
  attr_accessor :x , :y , :z
  def initialize(x, y, z)
    @x, @y, @z = x, y, z
  end

  def self.scale(k, v)
    Vector.new(k * v.x, k * v.y, k * v.z)
  end

  def self.minus(v1, v2)
    Vector.new(v1.x - v2.x, v1.y - v2.y, v1.z - v2.z)
  end

  def self.plus(v1, v2)
    Vector.new(v1.x + v2.x, v1.y + v2.y, v1.z + v2.z)
  end

  def self.dot(v1, v2)
    v1.x * v2.x + v1.y * v2.y + v1.z * v2.z
  end

  def self.mag(v)
    Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z)
  end

  def self.norm(v)
    mag = Vector.mag(v)
    div = (mag == 0) ? Float::INFINITY : 1.0 / mag
    Vector.scale(div, v)
  end

  def self.cross(v1, v2)
    Vector.new(v1.y * v2.z - v1.z * v2.y, v1.z * v2.x - v1.x * v2.z, v1.x * v2.y - v1.y * v2.x)
  end
end

class Color
  attr_accessor :r, :g, :b

  def initialize(r, g, b)
    @r, @g, @b = r, g, b
  end

  def self.scale(k, v)
    Color.new(k * v.r, k * v.g, k * v.b)
  end

  def self.plus(v1, v2)
    Color.new(v1.r + v2.r, v1.g + v2.g, v1.b + v2.b)
  end

  def self.mult(v1, v2)
    Color.new(v1.r * v2.r, v1.g * v2.g, v1.b * v2.b)
  end

  def self.toDrawingColor(c)
    r = (c.r.clamp(0.0, 1.0)*255).floor
    g = (c.g.clamp(0.0, 1.0)*255).floor
    b = (c.b.clamp(0.0, 1.0)*255).floor
    return r, g ,b
  end
end

Color_white        = Color.new(1.0, 1.0, 1.0)
Color_grey         = Color.new(0.5, 0.5, 0.5)
Color_black        = Color.new(0.0, 0.0, 0.0)
Color_background   = Color_black
Color_defaultColor = Color_black

class Camera
  attr_accessor :pos, :forward, :right, :up
  def initialize(pos, lookAt)
    down     = Vector.new(0.0, -1.0, 0.0)
    @pos     = pos
    @forward = Vector.norm(Vector.minus(lookAt, @pos))
    @right   = Vector.scale(1.5, Vector.norm(Vector.cross(@forward, down)))
    @up      = Vector.scale(1.5, Vector.norm(Vector.cross(@forward, @right)))
  end
end

class Ray
  attr_accessor :start, :dir
  def initialize(start, dir)
    @start, @dir = start, dir
  end
end

class Intersection
  attr_accessor :thing, :ray, :dist
  def initialize(thing, ray, dist)
    @thing, @ray, @dist = thing, ray, dist
  end
end

class Light
  attr_accessor :pos, :color
  def initialize(pos, color)
    @pos, @color = pos, color
  end
end

class Sphere
  def initialize(center, radius, surface)
    @radius2, @_surface, @center = radius*radius, surface, center
  end

  def normal(pos)
    Vector.norm(Vector.minus(pos, @center))
  end

  def surface()
    @_surface
  end

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

class Plane
  def initialize(norm, offset, surface)
    @_norm, @_surface, @offset = norm, surface, offset
  end

  def normal(pos)
    @_norm
  end

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

  def surface()
    @_surface
  end
end

class ShinySurface
  def diffuse(pos)
    Color_white
  end

  def specular(pos)
    Color_grey
  end

  def reflect(pos)
    0.7
  end

  def roughness()
    250
  end
end

class CheckerboardSurface
  def diffuse(pos)
    ((pos.z).floor + (pos.x).floor).to_i.odd? ? Color_white : Color_black
  end

  def reflect(pos)
    ((pos.z).floor + (pos.x).floor).to_i.odd? ? 0.1 : 0.7
  end

  def specular(pos)
    Color_white
  end

  def roughness()
    250
  end
end

Surface_shiny        = ShinySurface.new
Surface_checkerboard = CheckerboardSurface.new

class RayTracer
  MaxDepth = 5

  def intersections(ray, scene)
    closest = Float::INFINITY
    closestInter = nil
    scene.things.each do |item|
      inter = item.intersect(ray)
      if inter && inter.dist < closest
        closestInter = inter
        closest = inter.dist
      end
    end
    closestInter
  end

  def testRay(ray, scene)
    isect = self.intersections(ray, scene)
    isect && isect.dist
  end

  def traceRay(ray, scene, depth)
    isect = self.intersections(ray, scene)
    isect == nil ? Color_background : self.shade(isect, scene, depth)
  end

  def shade(isect, scene, depth)
    d = isect.ray.dir
    pos = Vector.plus(Vector.scale(isect.dist, d), isect.ray.start)
    normal = isect.thing.normal(pos)
    reflectDir   = Vector.minus(d, Vector.scale(2, Vector.scale(Vector.dot(normal, d), normal)))
    naturalColor = Color.plus(Color_background,self.getNaturalColor(isect.thing, pos, normal, reflectDir, scene))
    reflectedColor = (depth >= MaxDepth) ? Color_grey : self.getReflectionColor(isect.thing, pos, normal, reflectDir, scene, depth)
    Color.plus(naturalColor, reflectedColor)
  end

  def getReflectionColor(thing, pos, normal, rd, scene, depth)
    Color.scale(thing.surface().reflect(pos), self.traceRay(Ray.new(pos, rd), scene, depth + 1))
  end

  def getNaturalColor(thing, pos, norm, rd, scene)
    color = Color_defaultColor
    scene.lights.each { |light| color = self.addLight(color, light, pos, norm, scene, thing, rd) }
    color
  end

  def addLight(col, light, pos, norm, scene, thing, rd)
    ldis  = Vector.minus(light.pos, pos)
    livec = Vector.norm(ldis)
    neatIsect = self.testRay(Ray.new(pos, livec), scene)

    isInShadow = neatIsect && neatIsect <= Vector.mag(ldis)
    return col if isInShadow

    illum  = Vector.dot(livec, norm)
    lcolor = (illum > 0) ? Color.scale(illum, light.color) : Color_defaultColor

    specular = Vector.dot(livec, Vector.norm(rd))
    scolor = (specular > 0) ? Color.scale(specular ** thing.surface().roughness(), light.color) : Color_defaultColor

    Color.plus(col, Color.plus(Color.mult(thing.surface().diffuse(pos), lcolor), Color.mult(thing.surface().specular(pos), scolor)))
  end

  def getPoint(x, y, screenWidth, screenHeight, camera)
    recenterX =  (x - (screenWidth  * 0.5)) / (screenWidth  * 2.0)
    recenterY = -(y - (screenHeight * 0.5)) / (screenHeight * 2.0)
    Vector.norm(Vector.plus(camera.forward, Vector.plus(Vector.scale(recenterX, camera.right), Vector.scale(recenterY, camera.up))))
  end

  def render(scene, image, screenWidth, screenHeight)
    screenHeight.times do |y|
      screenWidth.times do |x|
        color = self.traceRay(Ray.new(scene.camera().pos, self.getPoint(x, y, screenWidth, screenHeight, scene.camera())), scene, 0)
        r,g,b = Color.toDrawingColor(color)
        image.set_pixel(x,y, ImageRuby::Color.from_rgba(r,g,b, 255))
  end end end
end

class DefaultScene
  attr_accessor :things, :lights, :camera
  def initialize
    @things = [
      Plane.new(Vector.new(0.0, 1.0, 0.0), 0.0, Surface_checkerboard),
      Sphere.new(Vector.new(0.0, 1.0, -0.25), 1.0, Surface_shiny),
      Sphere.new(Vector.new(-1.0, 0.5, 1.5), 0.5, Surface_shiny)
    ]
    @lights = [
      Light.new(Vector.new(-2.0, 2.5, 0.0), Color.new(0.49, 0.07, 0.07)),
      Light.new(Vector.new(1.5, 2.5, 1.5),  Color.new(0.07, 0.07, 0.49)),
      Light.new(Vector.new(1.5, 2.5, -1.5), Color.new(0.07, 0.49, 0.071)),
      Light.new(Vector.new(0.0, 3.5, 0.0),  Color.new(0.21, 0.21, 0.35))
    ]
    @camera = Camera.new(Vector.new(3.0, 2.0, 4.0), Vector.new(-1.0, 0.5, 0.0))
  end
end

width, height  = 500, 500
image = ImageRuby::Image.new(width, height, ImageRuby::Color.black)

t1 = Time.now
rayTracer = RayTracer.new()
scene     = DefaultScene.new()
rayTracer.render(scene, image, width, height)
t2 = (Time.now - t1) * 1000

puts "Completed in #{t2} ms"

image.save("ruby-raytracer-opt.bmp", :bmp)
2 Likes

TLDR Crystal version is about 200 times faster on this benchmark.

UPDATE: For reference the best performing C++ version from linked repo excluding a cheating Nim version (Uses Quake square root algorithm) is only about 15% faster than Crystal (117 ms for GCC compiled version % g++ RayTracer.cpp -O2 -std=c++17 -s -o gcc-raytracer). Looks like Crystal implementation should be in top 5 best performing there.

I’ve only checked MRI 3.0.1, 2.7.1, 2.6.6 vs Crystal 1.0.

Best performing MRI was 2.6.6 without --jit option. Average time per iteration was 26562.8425 ms with benchmark using 10 iterations.

Crystal 1.0 version average time per iteration was 135.21166815 ms with benchmark using 1000 iterations.

So Crystal is 26562.8425 / 135.21166815 = 196.4537740229 times faster than best performing MRI version for me.

To make comparison more fair I’ve added loop in Ruby version, but limited it to just 10 iterations. Otherwise it’s just too long to wait for me. Crystal version has 1000 iterations.

The imageruby gem is not working with Truffleruby, so no results for Truffleruby. I didn’t wanted to install Java runtime on my system, hence no results for JRuby as well.

Here are the detailed results on my 2012 MacBook running MacOS Catalina for different versions of MRI I have and Crystal 1.0:

% sysctl -n machdep.cpu.brand_string
Intel(R) Core(TM) i7-3615QM CPU @ 2.30GHz

Crystal 1.0

% crystal --version
Crystal 1.0.0 (2021-03-22)

LLVM: 9.0.1
Default target: x86_64-apple-macosx

% crystal build --release crystal-raytracer-opt.cr

% ./crystal-raytracer-opt
total time for 1000 iterations = 135211.66815 ms, avg time = 135.21166815 ms

Ruby 3.0.1

% ruby --version
ruby 3.0.1p64 (2021-04-05 revision 0fb782ee38) [x86_64-darwin19]

% ruby --jit ruby-raytracer-opt.rb
total time for 10 iterations = 270198.65499999997 ms, avg time = 27019.865499999996 ms

% ruby ruby-raytracer-opt.rb
total time for 10 iterations = 279196.921 ms, avg time = 27919.692099999997 ms

Ruby 2.7.1

% ruby --version
ruby 2.7.1p83 (2020-03-31 revision a0c7c23c9c) [x86_64-darwin19]

% ruby --jit ruby-raytracer-opt.rb
total time for 10 iterations = 287726.73099999997 ms, avg time = 28772.673099999996 ms

% ruby ruby-raytracer-opt.rb
total time for 10 iterations = 290020.92 ms, avg time = 29002.091999999997 ms

Ruby 2.6.6

% ruby --version
ruby 2.6.6p146 (2020-03-31 revision 67876) [x86_64-darwin19]

% ruby --jit ruby-raytracer-opt.rb
total time for 10 iterations = 279292.154 ms, avg time = 27929.215399999997 ms

% ruby ruby-raytracer-opt.rb
total time for 10 iterations = 265628.425 ms, avg time = 26562.8425 ms
3 Likes

I submitted the optimized Ruby version to the site today (Wed April 21, 2021) to replace the old version, and have been informed it’s been merged. So now you can get the optimized Ruby version from the website.

New version of TruffleRuby was released and imageruby-bmp is working now. So I’ve ran benchmark with it, but the results are much worse than MRI (26.5 - 28s)

% ruby --version
truffleruby 21.1.0, like ruby 2.7.2, GraalVM CE Native [x86_64-darwin]

I’ve only ran 1 iteration since it was so slow that I couldn’t wait until it finished 10 iterations.

% ruby ruby-raytracer-opt.rb
Completed in 978826.119 ms

So it’s more than 30x times slower than MRI.