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):
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.
Triangles.
Wireframe render.
Flat shading render.
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?
-
Because now I’d have to go implement Targa loading before continuing. And I’m unexcited at this point. ↩
-
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. ↩ -
Absence of any sort of error handling certainly helped in that regard, too. ↩
-
Ever went grocery shopping with a Ferrari? ↩