##### Cornea, an Isometric 3D Graphing Module for Haskell
###### 2017-08-30

While working on the rendering my solution for the Irreversible Pocket Cube, I ended up writing a faux isometric rendering engine which could turn my specific Rubix Cube datastructure into something like an isometric projection of that cube. This my attempt to do it the right way.

The full code lives at ambuc/Cornea.

# Mathematics

To quote Wikipedia,

where $\alpha$ and $\beta$ are the pitch and yaw of the camera angle; that is, how far from the equator and meridian in spherical coordinates our viewpoint resides, if the center of the scene is at the origin. This projection matrix works for any 3d point $\begin{bmatrix} a_x & a_y & a_z \end{bmatrix}^\intercal$ and results in 2d coordinates $\begin{bmatrix} b_x & b_y & 0 \end{bmatrix}^\intercal$.

That’s really all the projection math we need. The name metric is sort of a play on isometric, except the function is extensible and can return a transformation matrix for any viewing angle, not just the default $(35.264,45)$ isometric viewing angle.

# Describing the World

Let’s define an Obj object as either

• a single point, a [Float] list of three floating point numbers describing its $(x,y,z)$ coordinates, or
• a list of points describing a line or polyline, or
• a list of points describing a face

## Styling the World

We want to be able to style each of these objects. Because I like Graphics.Rasterific, we’ll use their types and define style as a mapping between a [Primitive] and a final Drawing px (). In practice we would use the builtin withTexture (uniformTexture <color>) . fill for a solid fill, or withTexture (uniformTexture <color>) . stroke <width> JoinRound (CapRound, CapRound) for a stroke (used for a line, polyline, or point). In practice it would be nice to provide synonyms in the form of solid k and mark k n.

# Projecting an Object onto a Drawing

Let’s define a convenient type synonym View for our pitch/yaw tuple:

and write a proj v <obj> function which can take a coordinate and project it into the plane.

Great. Now we can plot a coordinate by simply projecting it into the plane. We should be able to plot a line or polyline by projecting each of its coordinates, and a face (or polygon) the same way.

Very cool. Let’s construct a sample world and try writing it to a .png.

# Writing to .png

Here’s our calibration world:

Now we need a function which can turn a list of these (Obj,Style) tuples into a single Drawing px () item. We really want to map drawFrom over each of them, and then sequence_ [] the list; we can write that as mapM_ instead, which maps a monad over a list of inputs, discarding the intermediate results along the way.

The very final piece of our puzzle is something which takes a Drawing px () and actually turns it into an Image px, which can be written to .png with Rasterific’s writePng.

We’ll need to supply render with a width/height/scale

Great. Let’s write this Drawing px () out to an Image px () and eventually to a file IO ().

That’s super! Let’s try something more complicated: define myWorld to be a bunch of intersecting squares along the x-y and y-z planes:

And render it as before:

Uh-oh. We’re running into the visibility problem, wherein we don’t know which order to render the faces in so that the things nearer the camera would be visible over the things farther from the camera.

# The Visibility Problem

There are a bunch of ways to solve this problem, but I ended up computing a vector from the origin to the centroid of each object, defining a vector from the origin to the position of the camera, and ranking the objects by the scalar projection of each object’s centroid-vector onto the camera-vector.

Now, instead of just mapM_ (drawFrom v) world we can write:

Which uses a Data.List builtin sortBy (comparing <comparison_fn>) list to reorder a list according a custom comparison_fn. You could write one like (\a b -> if a > b then a else b), or we could use comparing to rank by an implicit function of each item.

In this case, that implicit function is closeness v . centroid . fst, which takes each (Obj, Style) tuple, extracts the Obj, finds its centroid, and finds the closeness by calculating the scalar product of that centroid-origin vector with the viewpoint-origin vector.

This gets us something a little more reasonable:

# Animation

This is pretty cool, but what if we want to animate our world?

Let’s define a series of pitches and yaws to make a cool wobbling sweep around our world. Again, mapM_ lets us map our writePng function over a list of 3-tuples containing the pitch, yaw, and frame number. We can write them out to /tmp/canvas*.png

…and compose them together into a .gif later with convert -loop 0 /tmp/canvas* \<filename\>.

Here’s that contrasted with an animation from before we solved the visibility problem:

# Animating with Codec.Picture.Gif

Codec.Picture supports assembling Images into gifs in Haskell, without having to write the frames out to a file and assembling them with convert. It’s a lot slower, though. I’ll present it here just out of interest, but in practice I found it anywhere from 8x-10x slower.

This took ~3min and I see a bit of flickering – probably because the command-line convert utility understands color and framerate better than I do.

# Conclusions

This is a neat little library, just powerful enough to handle the matrix multiplication necessary to take a scene depicted in raw coordinates and render it in a useful way. I have no idea what its performance will be like for a larger scene, but I’ll continue to use it in the future and we’ll see what sorts of optimizations can be made.

I’ve put it on Github here: ambuc/cornea in the hopes that someone else might get a kick out of it.

• View the source code on Github.