Goon – Voxel object

Edit: Since this post got wider distribution than expected it’s probably worth clarifying that the intro runs on an Amiga computer from the 90s (with a Motorola 68060 cpu at 50MHz or more) and that this of course drove the implementation decisions and trade-offs. 🙂

This part of Goon Royale, where voxels seemingly protrude from a flatshaded 3D object, is based on the same effect as You are Lucy and one of the parts in Norwegian Kindness, which I’ve mentioned very briefly before.

Before we get into how it’s used in the intro, let’s take a look at how this effect works.


It’s actually a quite crude screen space parallax effect working with a 2D heightmap. In other words: there’s no 3D voxels or intersection with a flat-shaded object, it just looks that way. 🙂

Essentially, for each on-screen pixel we’re repeatedly sampling a heightmap in 2D space while comparing the height value from the map against a reference which decreases for each loop iteration. The sampling direction determines the parallax viewing angle. When the value retrieved from the height map is higher than (or equal to) the reference we plot the corresponding texel to screen.

If you’ve done a bit of pixel shader coding then this concept should be easy. That said, when I implemented this back in 2010 I had no clue this was what I’d end up with. I was just experimenting with a vague idea of “doing multiple height comparisons based on pixel position and an on-screen focus point”. Kinda like reinventing an almost square wheel.

The visual effect is quite neat, although it’s extremely content dependent and falls apart badly with the “wrong” viewing angle or height map.

In pseudocode it would look something like this:

for(y=0; y<height; y++)
  ydelta = (y-focuspoint.y) * scalefactor.y         // ray step in y-direction
  for(x=0; x=0)
      samplepos = x+xdelta*steps + (y+ydelta*steps)*width
      heightref = steps*2                           // heightmap has values [0,63] (see notes)
      if(heightmap[samplepos] >= heightref) break   // hit, exit from step loop
    framebuffer[x+y*width] = texture[samplepos]     // texture and heightmap are often the same (see notes)

In Lucy there’s a couple of scenes where we use separate height maps and textures, but in most cases (and also in Goon) we just use the same buffer. This obviously limits the visuals a bit (i.e. all pixels with the same height also have the same color) but it means stuff runs faster since we can update just one source buffer instead of two. Anyway, this is why the heightmap in the example above has higher precision (64 values) than the depth test (32 values) – in order to have a bit more range in the texture colors.

Now, the pseudocode is nice and dandy but the actual implementation needed a bit of work to be fast enough.
I ended up with an approach where the x- and y-deltas for the sample direction are precalculated in 2 tables. This is quite heavy on memory reads per pixel but cache coherence for the tables isn’t too bad. It might be faster to re-order and simplify the delta calculations and do them in realtime instead, but I stuck with what I’d used back in 2011.

Either way, the most important speed-up was to just perform the stepping and comparisons at a quarter of the screen resolution and plot 2×2 pixels at a time. It looks way worse than doing it per-pixel, but it helped a lot with performance.

Putting it all together

Combining the parallax “voxels” with the flat-shaded 3D rendering was done in a few easy steps:

First, a slight modification to the parallax effect so that the lowest 32 values of the source data are all treated as height = 0. In other words: pixels with values 0-31 in the combined texture & height buffer would not get any parallax distortion.
We also set up a 64 colors palette using the first 32 entries for the flatshading and the last 32 for the voxel. (Actually, we used a copper gradient to freshen things up and add more color.)

The “growing” animation of the voxels is precalculated as 32 textures based on the same heightmap with different depth offsets. (We also do a bit of additional processing on the texel values to simplify the compositing that is done at runtime.)

Then, while the effect is running:

Render the 3D object twice, into two separate buffers. First flat-shaded, then textured with one of the precalculated heightmaps.

Flatshaded (The pixels have values 0-31,  copper gradient added for more color)
Textured with one of the 32 precalculated heightmaps. (The blue-tinted pixels will be used to darken the flatshaded object in the compositing pass).

After that it’s time to composite the two buffers together. Just doing a sharp cutoff between the textured and flatshaded pixels looked quite bad so I wanted some subtractive blending to darken the flatshaded object around the base of the protruding voxels.

What we’re doing is effectively:

a = heightmap_pixel
b = flatshade_pixel
if(a>31) return a                           // heightmap texture
else if(a>24) return MAX( b-((a-24)<<2), 0) // darken flatshaded pixels
else return b                               // flatshading
Composited result with subtractive blending. (The lines on the top left and right edges of the object are due to mismatches in the code for textured and flat triangles)

In order to speed this up some of the work was baked into the precalculation of the 32 height textures. This let us process 4 pixels in parallell without any conditionals during the actual compositing, with the help of some masking, subtraction and shifting.

And then finally we apply the parallax effect to the composited image and voila! All pixels with values 32-63 appear to be poking out from the faces of the object.



Leave a Reply

Fill in your details below or click an icon to log in: Logo

You are commenting using your account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s