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 that era, I played the game quite a bit; and in later years developed the RallySportED modding toolset. Building asset editors for the toolset invariably involved replicating parts of the game's renderer.
In this blog post, I'll go over some of the things I've come to know about the workings of the game's
renderer, starting with a brief description of the asset files it uses and then going through in
outlines the steps the game takes to render its scenes. My knowledge on this topic isn't complete
though, so feel free to
I've made a relatively simple reference replica of the game's renderer in C. It's not a complete implementation and is generally based on empirical observation, but it should help you get started. Throughout this post, I'll provide links to this source code for samples 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 complete than the one in C mentioned 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 get you started 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 the back buffer, then running the executable in DOSBox with a very low cycles count. Check it out:
For a refresher on what the game is like to play, you can give it a spin below (or play in a larger window). If you don't hear sound, click inside the game screen.
On a Finnish keyboard, the controls are like so:
Space | Start the game |
A | Accelerate |
Z | Brake |
, | Left |
. | Right |
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:
Filename | Role |
---|---|
PALAT.00x | Terrain textures |
TEXT1.DTA | 3D object textures |
CARxx.DTA | Car textures |
ANIMS.DTA | Animation frames |
FONTTI.BMP | Font glyphs |
For brevity, we'll only discuss the terrain and 3D object textures.
PALAT.00x files are texture atlases containing terrain textures.
Each terrain texture is 16 × 16 pixels in resolution, its data consisting of 8-bit unsigned values identifying the palette index of the corresponding pixel.
See load_from_pala() in texture.c for an example of reading terrain texture data.
The TEXT1.DTA file is a texture atlas for 3D objects (e.g. trees and fences).
The textures in this atlas are stored in the same format as terrain textures but can be of any resolution. Sampling rectangles for the individual textures are hard-coded in RALLYE.EXE.
See load_from_text() in texture.c for a practical example of reading these data.
Terrain metadata comprises heightmaps and tilemaps, the former stored in MAASTO.00x files and the latter in VARIMAA.00x 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, each of which describes the height of the terrain at a particular location.
For example, if the terrain is 128 × 128 tiles in size, the height at coordinates 62, 72 would come from the corresponding MAASTO file at byte offset (2 × (61 + 71 × 128)).
See kground_initialize_ground() in ground.c for an example of importing heightmap data.
Each binary VARIMAA file contains an array of unsigned 8-bit values, each giving an index to a corresponding PALAT terrain texture file indicating which texture should be applied to the terrain at that location.
For example, for a terrain of 128 × 128 tiles, the corresponding texture index for coordinates 62, 72 would be at byte offset (61 + 71 × 128) in the VARIMAA file.
See kground_initialize_ground() in ground.c for an example of importing tilemap data.
There are various 3D objects scattered about the game's tracks: trees, billboards, utility poles, etc.
Their mesh (vertex) 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 load_prop_mesh() in mesh.c for an example of importing mesh data.
The above mesh viewer loads mesh data directly from RALLYE.EXE. The JavaScript source code for its data loader is available here.
A typical scene in the game consists of a terrain populated by various 2D and 3D decorations.
Although the scene will invariably include cars, particle effects, and UI elements, we'll ignore them for brevity and focus on rendering the terrain and certain 2D and 3D objects. The rest should be more or less figure-outable with that information in hand.
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 transformation goes something like this:
// 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 visual consequence of this type of projection is that vertical lines close to the screen's edge aren't distorted as they are with a more typical three-point-like 3D projection:
The terrain mesh consists of 4-sided polygons whose vertex heights are determined by values from a MAASTO heightmap file, each polygon textured with a select 16 × 16 image from a PALAT texture atlas as dictated by the corresponding VARIMAA tilemap file.
See kground_update_ground_mesh() in ground.c for an example of generating the terrain mesh, and fill_poly() in polyfill.c for an example of rasterizing it.
Terrain polygons' UV texture coordinates are 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.
An important visual consequence of this type of mapping is that textures on the terrain warp toward corner vertices on non-square (as seen in screen space) polygons:
The game considers any terrain polygon textured with image #1 of the PALA texture atlas to be water.
Water polygons' vertices will be adjusted at run-time on the Y (height) axis to match the height of the track's water level (see section 2.2 of the data format reference).
The terrain mesh is decorated with three types of objects: lines, 2D impostors, and 3D objects.
Utility wires and a few other similar decorations are rendered by the game as quadratically curved 2D lines.
In the image below, the straight cyan line represents a segment of utility wire between two utility poles prior to curving. The multi-colored line below it is the curved version, in which the original line has been split into nine color-coded sub-segments along the curve that sag toward a control point in the middle.
These decorations are rectangular planar polygons that sit perpendicular to the ground plane and are textured with a select image from the PALAT texture atlas.
Impostors are used in the game to represent various objects: spectators, bushes, fence posts, etc.
Bridges are a special type of impostor, being like normal impostors but oriented parallel to the ground plane and positioned at a pre-set bridge height.
The game will draw bridge impostors over any terrain polygon textured with image #249 or #250 of the PALAT texture atlas, so long as the polygon is positioned lower than the bridge height.
More involved terrain decorations are represented as 3D meshes; these include trees, fences, etc. As noted earlier, their vertex data are hard-coded into RALLYE.EXE.
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). 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.