Rally-Sport is a DOS-based racing game featuring fixed-perspective pseudo-3D rendering. It was a relatively good-looking indie effort for the time, released initially in demo form at the end of 1996. The game was written in x86 assembly by Jukka Jäkälä, and in my opinion is an interesting bag of homegrown approaches by a capable game developer.
Like many Finns of this era, I played the game quite a bit in my childhood, and in later years developed the RallySportED modding toolset. Building asset editors invariably involved replicating parts of the game's renderer, which I ended up doing to varying degrees for a variety of platforms.
In this blog post, I'll go over the general things I know about the workings of the game's
renderer; starting with a brief description of the asset files used and then going through
in outlines the steps of rendering the game's style of graphics. My knowledge on this topic
isn't perfect though, so feel welcome to
I've made a relatively simple reference replica of the game's renderer in C. It's not a complete replication though and intended primarily for educational purposes. Throughout this post, I'll give links to this source code for concrete examples of implementation.
The browser version of RallySportED includes a reasonably faithful recreation of the game's renderer in JavaScript. You can run the app and see what you think. Although this version of the renderer is more accurate than the one in C, above, this is also a more complex application in general and so isn't necessarily good as a first point of reference.
The RallySportED toolset includes documentation about the game's data formats. This documentation isn't complete nor guaranteed to be correct, but it can save you time if you're looking to explore the game's internals in more detail.
I've also prepared a video that shows the game rendering a frame in slow motion. The video was recorded by modifying the game's executable to draw directly into video memory instead of a back buffer, then running the executable in DOSBox with a very low cycles count. Check it out:
In case you want a refresher on what the game is like to play, or if you've never played it at all, you can give it a spin below (or play in a larger window).
Instructions (which assume a Finnish keyboard layout):
Before anything can be rendered, the assets to be rendered need to be imported. So we'll get to deal with three types of content: textures, terrain metadata, and meshes.
The game's textures are stored in various binary files:
For brevity, we'll only discuss the terrain and 3D object textures.
PALAT files are texture atlases containing terrain textures. Each texture is 16 × 16 pixels and in each file there are about 200 of them. A given texture in a PALAT file consist of 8-bit unsigned values, a given value identifying the VGA palette index of the corresponding pixel. See texture.c:28 for a practical example of importing these data.
Textures for 3D objects are otherwise the same as for the terrain except that they're stored in the TEXT1.DTA texture atlas file and can be of any resolution. Sampling windows into the atlas – representing the individual textures within it – are found hard-coded in RALLYE.EXE. See texture.c:70 for a practical example of importing these data.
Terrain metadata comprises heightmaps and tilemaps, the former stored in MAASTO files and the latter in VARIMAA files. A given track's terrain is composed of one MAASTO file and one VARIMAA file.
Each binary MAASTO file contains an array of 16-bit little-endian values (words), each of which describes the height of the terrain at a particular location. If the terrain is 128 × 128 units, the height at coordinates 62, 72 would come from the corresponding MAASTO file at byte offset (2 * (61 + 71 * 128)). See ground.c:309 for a practical example of importing these data.
Each binary VARIMAA file contains an array of unsigned 8-bit values, each giving an index to a corresponding PALAT file indicating which texture should be applied to the terrain at that location. For a terrain of 128 × 128 units, the corresponding texture index for coordinates 62, 72 would be at byte offset (61 + 71 * 128) in the VARIMAA file. See ground.c:342 for a practical example of importing these data.
There are various 3D objects scattered about the game's tracks: trees, billboards, utility poles, etc. Their mesh data are hard-coded in RALLYE.EXE, stored as (1) a list of vertices and (2) vertex loops indexing into the vertex list and so defining the n-sided convex polygons that make up the meshes. See mesh.c:58 for a practical example of importing these data.
The above mesh viewer loads mesh data directly from a copy of RALLYE.EXE. The JavaScript source code for its data loader is available on GitHub.
A typical scene in the game consists of a terrain populated by various 2D and 3D decorations. Rendered frames may also include cars, particle effects, and UI elements, but we'll ignore those for brevity – they should be more or less figure-outable with information from the rest of the discussion.
Prior to rasterization, the game's renderer transforms polygons into a single-point perspective, in which the X and Y coordinates of a given vertex are transformed toward a vanishing point at the top middle of the screen:
// The vanishing point for a screen whose width is 320 and Y = 0 at the top. const vanish = {x: 160, y: 0}; // Transform our vertices toward the vanishing point. for (const vertex of vertices) { const z = Math.max(Number.MIN_VALUE, (vertex.z / 608)); vertex.x = (vanish.x + ((vertex.x - vanish.x) / z)); vertex.y = (vanish.y + ((vertex.y - vanish.y) / z)); }
An important consequence of this type of projection is that vertical lines located close to a screen edge aren't distorted like they are with a more typical three-point-like 3D projection:
The renderer generates a terrain mesh at run-time based on where in the scene the camera is looking. This mesh consists of rectangular polygons whose vertex heights are determined by values from a corresponding MAASTO heightmap file and each polygon being textured with a select 16 × 16 image from a PALAT file. You can find a concrete example of generating the terrain mesh in ground.c:116, and of rasterizing its polygons in polyfill.c:125
Terrain polygons don't have pre-defined UV texture coordinates; they're instead computed at render-time in screen space such that U is 0 at the start of each horizontal pixel span of the polygon and 1 at the end of the span, and V is 0 at the highest pixel and 1 at the lowest pixel.
A consequence of this style of texture-mapping is that the mapped image will skew toward corner vertices on non-square (as seen in screen space) polygons:
The game considers any terrain polygon textured with image #1 of the texture atlas to be water. At run-time, water polygons' vertices are adjusted on the Y (height) axis to match the height of the global water level (see section 2.2 of the data format reference):
The terrain mesh is additionally decorated with 2D impostors and lines as well as 3D objects.
impostors are rectangular planar polygons that sit perpendicular to the ground, textured with a select image from the corresponding PALAT texture atlas. Impostors are used to represent various things in-game: spectators, bushes, fence posts, etc.
A special type of impostor are bridges, which are like normal impostors but oriented parallel to the ground plane and positioned at a pre-set bridge height. The game will draw these bridge sprites over any terrain polygons textured with image #249 or #250 of the corresponding PALAT texture atlas, so long as the given terrain polygon is positioned lower than the pre-set bridge height.
Overhead telephone wires – and a few other similar things, like tape fences – are rendered as quadratically curved 2D lines.
In the image below, the straight cyan line represents a segment of telephone wire prior to curving. The multi-colored line is the curved version, in which the original line has been split into 10 points along the curve, each color representing one sub-line between the points and the curve tending toward a control point in the middle and down of the original line.
More involved terrain decorations are represented as 3D meshes; these include trees, arches, and fences, among other things. As discussed earlier, their data are hard-coded into the game executables, with their polygons being n-sided and convex. See polyfill.c:125 for an example of rasterizing these polygons.
As a consequence of the game using a one-point perspective (see Perspective projection), 3D objects can only be seen from four sides (left, right, front, top). The game has optimized for this by pre-removing the bottom and back polygons from the meshes, which means that any full 3D re-creation of the game – with unconstrained view rotation – would have to fill in the missing polygons or the meshes will look like empty shells from behind.
The game appears to pre-sort polygons by the Z component of their vertices and then render the sorted polygons back-to-front (see the slow-mo render video in the Reference materials section of this post). There doesn't appear to be a depth buffer involved.
Polygons are made to gradually fade to black at the periphery of the renderer's draw distance. The effect can be approximated with a linear fog starting at a suitable distance.