(Stylized 3D artwork depicting futuristic vehicles under fire among blockhouses, rendered with huge pixels.)

Welcome to InRelief, a graphics engine for volumetric pixel art. It's a minimal system that takes grayscale sprites, assigns each pixel a depth based on its value and a color from a colormap, then renders the whole thing in perspective projection. Multiple instances of the same sprite can be rendered at different coordinates; display lists can be used to keep them sorted. This way an otherwise flat scene is given depth without resorting to expensive 3D modeling work, as long as the camera doesn't need to rotate or tilt.

InRelief was originally created for a game called Attack Vector: Sunset Flight.

Why not call it a voxel engine? Two reasons:

  1. As of 2018, the word "voxel" is largely synonymous with "blocky sandbox game", as opposed to a graphics rendering technique. Language drifts. One must adapt.
  2. InRelief still works with flat sprites that can be created with an ordinary pixel art editor; drawn objects always face the camera, and have a single layer.

In fact, the latter point was my original reason for creating this engine. Voxel editors are fiddly, often expensive, and usually require hardware acceleration to render all the little cubes, with real-time lighting and whatnot. This very much misses the point. InRelief is pure math, and doesn't even care what you do with its output. It just calls a user-supplied function repeatedly, yielding little colored squares. You can easily render scenes with div elements on a web page, or for that matter as SVG files! Just not in real time.

Download

Second public release (8.5K)

The InRelief graphics engine is open source under the MIT License; see the included documentation for contact information and exact terms.

How to use

As of 19 July 2018, InRelief comes in Python and Lua versions. See the included example files for details, but the basics are as follows.

First, you need some data to work with (zeroed pixels are transparent):

pixels = (
    (0, 0, 0, 0, 0, 0, 0, 0),
    (0, 0, 0, 1, 1, 0, 0, 0),
    (0, 0, 1, 2, 2, 1, 0, 0),
    (0, 1, 2, 3, 3, 2, 1, 0),
    (0, 1, 2, 3, 3, 2, 1, 0),
    (0, 0, 1, 2, 2, 1, 0, 0),
    (0, 0, 0, 1, 1, 0, 0, 0),
    (0, 0, 0, 0, 0, 0, 0, 0))

Then you need a colormap:

RED = (208, 70, 72)
ORANGE = (210, 125, 44)
YELLOW = (218, 212, 94)

FIERY = (None, RED, ORANGE, YELLOW)

Note the bogus entry at index zero; you don't need it in Lua, as tables are 1-based. Either way, now you need to make a sprite out of it:

import inrelief

fireball = inrelief.Sprite(pixels, FIERY)
fireball.set_hotspot(4, 4, -2)

The hotspot is placed dead center; the upper-left corner would be at (0, 0). The third coordinate is inverted because we're working with a left-handed camera, where the Z axis goes into the screen. But it doesn't care which way Y grows, and we can take advantage of it:

camera = inrelief.Camera(0, 0, -34, 600)

The fourth argument can be thought of as the focal length. As a rule of thumb, set it equal to the height of your viewport, give or take. Last but not least, define a callback function for rendering, and use it:

def renderer(x, y, size, color):
    screen.fill(color, (x + 400, y + 300, size, size))

fireball.render_at(0, 0, 0, camera, renderer)

The above code centers the image in the viewport in the simplest way possible. Replace with a more appropriate solution as needed.

Performance

As of 19 July 2018, the Python implementation can push 2000 voxels per frame at 20 FPS while running on an Intel Atom 1.6GHz CPU. The Lua implementation does 30 FPS under similar conditions. Some of the difference can be attributed to the respective rendering back-ends (Pygame versus Love2D). In particular, Pygame running in software mode has a noticeable overhead.

Implementation notes

Perhaps the biggest issue is creating the sprites. There is a suitable image format known as PGM, but no editor I'm aware of that saves them in quite the right way:

P2
8 8
3
0 0 0 0 0 0 0 0
0 0 0 1 1 0 0 0
0 0 1 2 2 1 0 0
0 1 2 3 3 2 1 0
0 1 2 3 3 2 1 0
0 0 1 2 2 1 0 0
0 0 0 1 1 0 0 0
0 0 0 0 0 0 0 0

You can, of course, settle for having 256 levels of gray and scaling them as needed, but it's clumsy. For that matter, you can just use the shades of gray directly, or apply them to select color channels to colorize sprites on the fly. Colormaps just allow for more flexibility than a straight progression of shades.

Keep in mind that voxels are only sorted properly within a sprite, then the sprites among themselves within a display list. This is both for simplicity and performance, but it can create issues if two sprites would overlap in 3D space. InRelief is best at rendering sparse scenes.

For that matter, you want to limit the number of depth levels in use. About half the width and height of each sprite is plenty enough. Try just three levels at first, like in the included example; together with the zero value, it makes for exactly two bits per pixel.

As for the "camera", it's mostly just a convenience function for textbook perspective projection. It doesn't even invert the image! It can, however, pan and zoom as needed.

Either way, make sure to look at the source code and adapt it for your own needs: the Python implementation is literally 100 lines of code.