Tinyrenderer in Elixir (part of it, anyway)


Problem statement

Not so long ago I found an intriguing post titled OpenGL in 500 lines (sort of…) on Hackaday.

It links a edu project by Dmitry Sokolov that builds up a renderer from scratch, with a rather impressive resulting render of a man’s head. With texture, shading, perspective and all that.

For a fun few hours I took a stab at implementing the first three lessons in Elixir, ending just before the texture homework1.

With the following result (converted to png from tga):

my result

What follows are a few notes. So I can eventually resume when the urge comes.

Notes

In general: I paid very little attention to performance optimization. So I probably have the big-O down, but the constants suck. Still, it turned out acceptable in terms of execution delay.

Image module

I lean heavily on Erlang’s array library for the image and z-buffer.

I chose a two-dimensional (nested) array for the image backing (array of pixel rows) because it allows for an easy horizontal/vertical flipping2.

In retrospect, it might have been just as good to use simple arithmetics to address the pixel in flat buffer (the way I do it for the zbuffer):

idx = x + y * width

but then the serialization (to tga) would be a bit trickier. With the nested array it’s pretty much:

defp data_to_list(%Image{data: d}), do: :array.to_list(d) |> Enum.map(&:array.to_list/1)

def to_list(%Image{v_flip: false, h_flip: false} = img), do: data_to_list(img)

def to_list(%Image{v_flip: false, h_flip: true} = img),
  do: data_to_list(img) |> Enum.reverse()

def to_list(%Image{v_flip: true, h_flip: false} = img),
  do: data_to_list(img) |> Enum.map(&Enum.reverse/1)

def to_list(%Image{v_flip: true, h_flip: true} = img),
  do: data_to_list(img) |> Enum.reverse() |> Enum.map(&Enum.reverse/1)

Wavefront module

I was pleasantly surprised how well Elixir lends itself to parsing the Wavefront Obj.

Pattern matching made bulk of the parsing look like child’s play3:

defmodule Wavefront do
  defstruct v: [], vt: [], vn: [], f: []

  # [...]

  def from_bytes(bytes) do
    # first run through line_parser, then reverse and put into arrays

    wf = bytes |> String.split("\n") |> Enum.reduce(%Wavefront{}, &line_parser/2)

    %Wavefront{
      v: :array.from_list(Enum.reverse(wf.v)),
      vt: :array.from_list(Enum.reverse(wf.vt)),
      vn: :array.from_list(Enum.reverse(wf.vn)),
      f: :array.from_list(Enum.reverse(wf.f))
    }
  end

  defp line_parser("v " <> rest, %Wavefront{v: vert} = obj) do
    # vertex
    %Wavefront{obj | v: [parse_vec3(rest) | vert]}
  end

  # [...]

  defp parse_vec3(str) do
    [x, y, z] =
      str
      |> String.trim()
      |> String.split(" ", parts: 3)
      |> Enum.map(&Float.parse/1)
      |> Enum.map(fn {x, ""} -> x end)

    Vec3.new(x, y, z)
  end

Not that in Ruby it wouldn’t be similarly easy. But I was expecting a struggle that wasn’t there.

Shapes module (triangle drawing)

For the triangle drawing I went with the Baricentric coords method. Which in the end uses iteration through all pixels in the bounding box. Essentially:

# Ruby
for x in (xmin..xmax)
  for y in (ymin..ymax)
    # ... do stuff
  end
end

In the end, the comprehensions syntactic sugar was helpful:

defp pixel_pairs_from_bb(bbmin, bbmax) do
  for x <- Range.new(bbmin.x, bbmax.x), y <- Range.new(bbmin.y, bbmax.y), do: {x, y}
end

def triangle(...) do
  bbmin = Vec2.new(...)
  bbmax = Vec2.new(...)

  pixel_pairs_from_bb(bbmin, bbmax)
  |> Enum.reduce(img, fn {x, y}, acc ->
    # ...
    acc
  end)
end

Top level module

Even for the top level functionality, the Enum module is convenient and the resulting code readable.

The Enum.reduce is a universal workhorse that can be abused for pretty much anything. Here, drawing triangle-at-a-time and dragging the image and resulting zbuffer along:

def zbuffered_shading_render(light \\ Vec3.new(0, 0, -1)) do
  width = height = 800
  img = Image.create(width, height)
  obj = Wavefront.from_bytes(File.read!('african_head.obj'))

  light = Vec3.normalize(light)

  zbuffer = :array.new(size: width * height, fixed: true, default: -1)

  {img, _} =
    Range.new(0, Wavefront.nfaces(obj) - 1)
    |> Enum.map(fn x -> Wavefront.face(obj, x) end)
    |> Enum.reduce({img, zbuffer}, fn [[v1 | _], [v2 | _], [v3 | _]], {acc, zb} ->

      # translate 0..1 -> 0..width (or 0..height)
      p1 = Vec3.new(trunc((v1.x + 1) * width / 2), trunc((v1.y + 1) * height / 2), v1.z)
      p2 = Vec3.new(trunc((v2.x + 1) * width / 2), trunc((v2.y + 1) * height / 2), v2.z)
      p3 = Vec3.new(trunc((v3.x + 1) * width / 2), trunc((v3.y + 1) * height / 2), v3.z)

      n = Vec3.normalize(Vec3.cross(Vec3.sub(v3, v1), Vec3.sub(v2, v1)))

      intensity = trunc(Vec3.mult(n, light) * 255)

      if intensity > 0 do
        c = Color.rgb(intensity, intensity, intensity)
        Shapes.triangle_zbuffer(acc, p1, p2, p3, zb, c)
      else
        {acc, zb}
      end
    end)

  img = Image.flip_horizontally(img)
  b = Image.to_tga_bytestream(img)
  File.write!('a.tga', b)
end

Showcase

During the development I’ve gone through many stages of grief demos. These are the intermediaries:

three lines Three lines.

triangles Triangles.

wireframe Wireframe render.

flat shading Flat shading render.

lighted shading Lighted shading render (notice the missing zbuffer).

Code

All of the code is in tirex repo.

It’s scant in comments, but the notes above and the tinyrenderer wiki should help.

Closing words

While far from complete, the result isn’t bad for a few hours worth of poking about.

I thoroughly enjoyed doing it in a language I wouldn’t normally consider for a task like this4. What I’m left wondering is – would it easily lend itself to some sort of parallelization?

  1. Because now I’d have to go implement Targa loading before continuing. And I’m unexcited at this point.

  2. Why the flipping? Digital images are usually with (0, 0) in top left corner, whereas mathy things tend to have that origin in the bottom left corner. Think X-Y graph. So you draw in mathy coords, then flip along the horizontal axis.

  3. Absence of any sort of error handling certainly helped in that regard, too.

  4. Ever went grocery shopping with a Ferrari?