April 20, 2020

Raytraclj, a raytracer in Clojure

I was preparing an article about Clojure and buddy (it is coming) and then my friend David told me:

You already know your backend, APIs and stuff. Why don’t you get out of your comfort zone and do some 3D.

He was right 🤠

I thought about writing a raytracer because it’s cool and I know nothing, so I searched on the web and found this gem named Ray Tracing in One Weekend.

Then I told my friend David, I’m gonna write this raytracer but in Clojure and he told me:

Dude I already sent you this book two days ago.

Fair enough, so I just launched IntelliJ and started to code.

As the book suggests, it took me the weekend. But hey, it was a lot of fun, learned a lot (and forgot pretty quickly 😅, I wouldn’t be able to come up with all these formulas by heart). But it opens my perspective on other fields in IT.

OMG this is way harder to get right than backends! I had some problem of reflections, how do you debug that? ahah so much fun. I’ve thought multiple times boy you’re so dumb, and I think I am 🤪.

The code is on agrison/raytraclj, I’ll just post the latest version I’ve committed below.

The final scene from the first book takes around 35 minutes to render on my computer (Ryzen 9 3900X) with multi-threading enabled.

So this is fairly slow, the C++ implementation in the book took around 14 minutes. I did not try to improve that much the performance. Just tinkered with Aparapi to move some computations on the GPU but was not able to. I should look at neanderthal, maybe it can help, I don’t know.

All in all, just do it if you have some time, it was a cool experiment, and the book is a gem ❤.

Edit:

Someone pointed me to the clojure2d project on Github (via Reddit) and digging in it I fount that it contains an implementation of the same ray tracer. The implementation in there is way faster than mine (based on fastmaths) and it has real-time rendering on an image canvas.

You can take a look at it over there: https://github.com/Clojure2D/clojure2d-examples/tree/master/src/rt_in_weekend

Just be sure to change map to pmap at line 80 of file ch12_random_scene.clj, it’s like 5 to 6 times faster and it renders on a canvas view in real time, really great work!

I definitely need to change my code to use fastmaths to see what improvements it gives!

Until next time!

camera.clj

(ns me.grison.raytraclj.camera
  (:require [me.grison.raytraclj.ray :as ray]
            [me.grison.raytraclj.vec :as vec]))

(defn random-in-unit-disk []
  (loop [p (vec/- (vec/* [(rand) (rand) 0] 2) [1 1 0])]
    (if (< (vec/ p p) 1)
      p
      (recur (vec/- (vec/* [(rand) (rand) 0] 2) [1 1 0])))))

(defn make [look-from look-at vup vfov aspect aperture focus-dist]
  (let [lens-radius (/ aperture 2)
        theta (/ (* vfov Math/PI) 180)
        half-height (Math/tan (/ theta 2))
        half-width (* aspect half-height)
        origin look-from
        w (vec/unit-vector (vec/- look-from look-at))
        u (vec/unit-vector (vec/ vup w))
        v (vec/ w u)
        lower-left-corner (vec/- (vec/- (vec/- origin
                                               (vec/* u (* half-width focus-dist)))
                                        (vec/* v (* half-height focus-dist)))
                                 (vec/* w focus-dist))
        horizontal (vec/* u (* 2 half-width focus-dist))
        vertical (vec/* v (* 2 half-height focus-dist))]
    {:lower-left-corner lower-left-corner
     :horizontal        horizontal
     :vertical          vertical
     :origin            origin
     :lens-radius       lens-radius
     :u                 u
     :v                 v
     :w                 w}))

(defn get-ray [{:keys [lower-left-corner horizontal vertical
                       origin lens-radius u v]} s t]
  (let [rd (vec/* (random-in-unit-disk) lens-radius)
        offset (vec/+ (vec/* u (vec/x rd)) (vec/* v (vec/y rd)))]
    (ray/make (vec/+ origin offset)
              (vec/+ lower-left-corner
                     (vec/- (vec/- (vec/+ (vec/* horizontal s) (vec/* vertical t))
                                   origin)
                            offset)))))

hitable.clj

(ns me.grison.raytraclj.hitable
  (:require [me.grison.raytraclj.vec :as vec]
            [me.grison.raytraclj.ray :as ray]))

(defprotocol Hitable
  (hit [this r t-min t-max]))

(defn hit-record [r t center radius material]
  (let [p (ray/point-at-parameter r t)]
    {:t t :p p :normal (vec// (vec/- p center) radius) :material material}))

(defrecord Sphere [center radius material]
  Hitable
  (hit [this r t-min t-max]
    (let [oc (vec/- (ray/origin r) (:center this))
          a (vec/squared-length (ray/direction r))
          half-b (vec/ oc (ray/direction r))
          c (- (vec/squared-length oc) (* (:radius this) (:radius this)))
          discriminant (- (* half-b half-b) (* a c))]
      (when (pos? discriminant)
        (let [root (Math/sqrt discriminant)
              temp (/ (- (- half-b) root) a)]
          (if (and (< temp t-max) (> temp t-min))
            (hit-record r temp (:center this) (:radius this) (:material this))
            (let [temp (/ (+ (- half-b) root) a)]
              (when (and (< temp t-max) (> temp t-min))
                (hit-record r temp (:center this) (:radius this) (:material this))))))))))

(defn hits [world r t-min t-max]
  (let [closest-so-far (atom t-max)
        record (atom nil)]
    (doseq [i (range 0 (count world))]
      (do
        (if-let [rec (hit (get world i) r t-min @closest-so-far)]
          (do
            (reset! closest-so-far (:t rec))
            (reset! record rec)))))
    @record))

material.clj

(ns me.grison.raytraclj.material
  (:require [me.grison.raytraclj.vec :as vec]
            [me.grison.raytraclj.ray :as ray]))

(defn random-in-unit-sphere []
  (loop [p (vec/- (vec/* [(rand) (rand) (rand)] 2.0) [1.0 1.0 1.0])]
    (if (> (vec/squared-length p) 1.0)
      p
      (recur (vec/- (vec/* [(rand) (rand) (rand)] 2.0) [1.0 1.0 1.0])))))

(defprotocol Material
  (scatter [this r-in rec]))

(defrecord Lambertian [a]
  Material
  (scatter [this r-in rec]
    (let [target (vec/+ (vec/+ (:p rec) (:normal rec)) (random-in-unit-sphere))
          scattered (ray/make (:p rec) (vec/- target (:p rec)))]
      {:ok true :attenuation (:a this) :scattered scattered})))

(defrecord Metal [a f]
  Material
  (scatter [this r-in rec]
    (let [fuzz (if (< f 1) f 1)
          reflected (vec/reflect (vec/unit-vector (:direction r-in)) (:normal rec))
          scattered (ray/make (:p rec) (vec/+ reflected (vec/* (random-in-unit-sphere) fuzz)))
          final (vec/ (:direction scattered) (:normal rec))]
      {:ok (pos? final) :attenuation (:a this) :scattered scattered})))

(defn schlick [cosine ref-idx]
  (let [r0 (/ (- 1 ref-idx)
              (+ 1 ref-idx))
        r0 (* r0 r0)]
    (+ r0 (* (- 1 r0)
             (Math/pow (- 1 cosine) 5)))))

(defrecord Dielectric [ref-idx]
  Material
  (scatter [this r-in rec]
    (let [reflected (vec/reflect (:direction r-in) (:normal rec))
          attenuation [1.0 1.0 1.0]
          dot-dir-normal (vec/ (:direction r-in) (:normal rec))
          ni-over-nt (if (pos? dot-dir-normal)
                       (:ref-idx this)
                       (/ 1.0 (:ref-idx this)))
          outward-normal (if (pos? dot-dir-normal)
                           (vec/- [0 0 0] (:normal rec))
                           (:normal rec))
          cosine (if (pos? dot-dir-normal)
                   (/ (* (:ref-idx this) (vec/ (:direction r-in) (:normal rec)))
                      (vec/length (:direction r-in)))
                   (/ (- (vec/ (:direction r-in) (:normal rec)))
                      (vec/length (:direction r-in))))
          refracted (vec/refract (:direction r-in) outward-normal ni-over-nt)
          reflect-prob (if (not (nil? refracted))
                         (schlick cosine (:ref-idx this))
                         1.0)]
      (if (< (rand) reflect-prob)
        {:ok true :attenuation attenuation :scattered (ray/make (:p rec) reflected)}
        {:ok true :attenuation attenuation :scattered (ray/make (:p rec) refracted)}))))

ray.clj

(ns me.grison.raytraclj.ray
  (:require [me.grison.raytraclj.vec :as vec]))

(defn make
  [origin direction]
  {:origin origin :direction direction})

(defn origin [ray]
  (:origin ray))

(defn direction [ray]
  (:direction ray))

(defn point-at-parameter
  [ray t]
  (vec/+ (origin ray)
         (vec/* (direction ray) t)))

(defn hit-sphere
  [center radius {:keys [origin direction]}]
  (let [oc (vec/- origin center)
        a (vec/ direction direction)
        b (* 2.0 (vec/ oc direction))
        c (- (vec/ oc oc) (* radius radius))
        discriminant (- (* b b) (* 4 a c))]
    (if (neg? discriminant)
      -1.0
      (/ (- (- b)
            (Math/sqrt discriminant))
         (* 2.0 a)))))

vec.clj

(ns me.grison.raytraclj.vec
  (:require [clojure.core :as clj]))

(defn mute [op
            [^float x1 ^float y1 ^float z1]
            [^float x2 ^float y2 ^float z2]]
  [(op x1 x2) (op y1 y2) (op z1 z2)])

(defn + [v1 v2]
  (mute clj/+ v1 v2))

(defn - [v1 v2]
  (if (number? v2)
    (map #(clj/- v2 %) v1)
    (mute clj/- v1 v2)))

(defn * [v1 v2]
  (if (number? v2)
    (map #(clj/* v2 %) v1)
    (mute clj/* v1 v2)))

(defn / [v1 v2]
  (if (number? v2)
    (if (zero? v2)
      1
      (* v1 (clj// 1 v2)))
    (mute clj// v1 v2)))

(defn  [v1 v2]
  (reduce clj/+ (* v1 v2)))

(defn  [[x1 y1 z1] [x2 y2 z3]]
  [(clj/- (clj/* y1 z3) (clj/* z1 y2))
   (clj/- (clj/* z1 x2) (clj/* x1 z3))
   (clj/- (clj/* x1 y2) (clj/* y1 x2))])

(defn squared-length [[x y z]]
  (clj/+ (clj/* x x) (clj/* y y) (clj/* z z)))

(defn length [v]
  (Math/sqrt (squared-length v)))

(defn unit-vector [v]
  (let [l (length v)]
    (map #(clj// % l) v)))

(defn x [v]
  (first v))

(defn y [v]
  (second v))

(defn z [v]
  (last v))

(defn reflect [v n]
  (let [m (* n (clj/* ( v n) 2))]
    (- v m)))

(defn refract [v n ni-over-nt]
  (let [uv (unit-vector v)
        dt ( uv n)
        discriminant (clj/- 1.0
                        (clj/* ni-over-nt ni-over-nt (clj/- 1 (clj/* dt dt))))]
    (when (pos? discriminant)
      (let [uv-ndt (- uv (* n dt))
            n-discrim (* n (Math/sqrt discriminant))]
        (- (* uv-ndt ni-over-nt) n-discrim)))))

Alexandre Grison - //grison.me - @algrison