The 1990s saw a proliferation of 3D accelerator cards on the home computing market. Along with this rush of products came a medley of 3D rendering APIs, as manufacturers explored the new possibilities and looked to advantage their own hardware. At this time, modern standards like Direct3D were still some way away from attaining the favor of vendors and developers.
Below are (pseudo-ish) examples of rendering a solid-filled triangle using three different 3D APIs of the 1990s:
// Set up for a solid fill. IDirect3DDevice2_SetRenderState( D3DDEVICE_5, D3DRENDERSTATE_TEXTUREHANDLE, NULL ); // Rasterize the triangle. IDirect3DDevice2_BeginScene(D3DDEVICE_5); D3DTLVERTEX v[3]; IDirect3DDevice2_DrawPrimitive( D3DDEVICE_5, D3DPT_TRIANGLELIST, D3DVT_TLVERTEX, v, 3, NULL ); // Display the rendering. IDirect3DDevice2_EndScene(D3DDEVICE_5); IDirectDrawSurface3_Flip(FRONT_BUFFER, NULL, DDFLIP_WAIT);
// Set up for a solid fill. grColorCombine( GR_COMBINE_FUNCTION_LOCAL, GR_COMBINE_FACTOR_NONE, GR_COMBINE_LOCAL_ITERATED, GR_COMBINE_OTHER_NONE, FXFALSE ); // Rasterize the triangle. vertex_struct v[3]; grDrawTriangle(&v[0], &v[1], &v[2]); // Display the rendering. grBufferSwap(FXTRUE);
// Set up for a solid fill. T_msiParameters msiParameters; msiParameters.msiTexture.Enable = FALSE; msiSetParameters(&msiParameters); // Rasterize the triangle. T_msiVertex v[3]; msiRenderTriangle(&v[0], &v[1], &v[2], 100); // Display the rendering. msiEndFrame(0, 0, FALSE);
The first of the above APIs, Direct3D 5, made early strides in standardizing a cross-hardware home-market solution, gaining support from a large percentage of the available 3D cards – though still often optionally to a vendor's own API. The second API, Glide 3, was compatible only with 3dfx Voodoo hardware, but held a strong position in the market due to wide adoption of the hardware and competitive performance. The third API, Matrox Simple Interface, was specific to the Matrox Mystique line of cards.
Although these examples indicate some shared characteristics between the APIs, they're also different enough to require you to write the same code in three different ways if you want it to run natively on the various hardware platforms.
Out of curiosity, I decided to design and implement a unifying interface that would let a user write their retro 3D rendering code once and have it run natively across a variety of legacy 3D hardware and rendering APIs. This project became known as Kelpo, and is written in ANSI C89 for the Win32 platform.
In this blog post, I'll go over the basics of how Kelpo works – first from the end-user's perspective and then from under the hood.
Kelpo exposes a minimalist rendering API to the end-user application.
The code snippet below demonstrates the general life cycle of the interface:
const struct kelpo_interface_s *kelpo = NULL; kelpo_create_interface(&kelpo, "opengl_1_1"); /* Render something until the program exits. */ kelpo_release_interface(kelpo);
First, an instance of the interface is initialized with a call to kelpo_create_interface(). The instance is then used to control Kelpo's rendering during the program's run-time. Finally, at the end of the program's execution, kelpo_release_interface() is used to free up any resources that were reserved for the interface.
The code listing below represents a simplified but complete end-user Kelpo application:
The application first includes the Kelpo interface headers, then requests a Kelpo interface that routes to an OpenGL 1.1 renderer, opens a window to render into, and enters into a rendering loop that exits when the window is closed. Commands like kelpo->rasterizer.draw_triangles() are mapped transparently by Kelpo to OpenGL 1.1 calls.
The Kelpo codebase comes with a number of sample applications that you can browse through and test.
All of Kelpo's interface functions return an integer value to indicate error (0) or success (1). So individual API calls can be wrapped in conditionals to check whether the call succeeded:
if (!kelpo->window.open(0, 1920, 1080, 32)) { fprintf(stderr, "Failed to open a Kelpo renderer window.\n"); goto cleanup; }
Kelpo also maintains a global error stack that contains all error codes that have occurred during Kelpo's execution. So rather than wrapping individual API calls into error-checking conditionals, you can execute multiple API calls and then query the error stack to see if any errors occurred:
kelpo->window.open(0, 1920, 1080, 32); if (kelpo_error_peek() != KELPOERR_ALL_GOOD) { fprintf(stderr, "Kelpo has reported an error.\n"); goto cleanup; }
It's also possible to register a callback function for Kelpo to invoke whenever an error occurs:
void custom_error_callback(enum kelpo_error_code_e kelpoErrorCode) { fprintf(stderr, kelpo_error_string(kelpoErrorCode)); return; } kelpo_error_callback(custom_error_callback);
In this section, we'll take a brief look at Kelpo's behind-the-scenes – how the interface is architectured and structured.
To allow Kelpo to work across a variety of 3D hardware, it supports only a minimalist selection of rendering features, chosen so as to be as widely supported as possible by the target hardware.
As such, Kelpo supports – and is limited to – the following features:
With some exceptions – for example, early Matrox 3D cards not supporting linear texture interpolation – this set of features is quite well supported across the board of 1990s consumer 3D hardware and rendering APIs.
The code intended to be included in an end-user's application lives under src/kelpo_interface/.
The kelpo_interface_s structure, defined in interface.h, is the heart of the Kelpo end-user API. It provides the end-user application functions for controlling Kelpo's facilities (cf. Sample usage):
struct kelpo_interface_s { struct kelpo_interface_window_s { int (*process_messages)(void); int (*flip_surface)(void); /* ... */ } window; struct kelpo_interface_rasterizer_s { int (*clear_frame)(void); int (*upload_texture)(struct kelpo_polygon_texture_s *const texture); /* ... */ } rasterizer; struct kelpo_interface_metadata_s { unsigned rendererVersionMajor; unsigned rendererVersionMinor; /* ... */ } metadata; };
The interface functions are grouped semantically into three categories: window, rasterizer, and metadata. The window group contains functions for manipulating the window into which Kelpo renders – flipping the screen buffer, processing user input, etc. The functions of the rasterizer group control Kelpo's renderer – managing textures, submitting polygons to be rasterized, etc. Finally, metadata provides general information about the renderer, like it's name and version number.
As shown in the code listing above, the functions of the interface structure are pointers. Their actual implementations are provided by a renderer loaded from a DLL at run-time, as discussed in the next section.
Kelpo's implementation of each supported rendering API are located under src/kelpo_renderer/. For example, the following are the source files used by the Glide 3 renderer:
In the above listing, src/kelpo_renderer/renderer_glide_3.c is the renderer's point of entry. It provides an export_interface() function that populates a Kelpo interface structure (cf. End-user interface) with pointers to functions specific to the Glide 3 renderer:
The renderer's code is compiled into a DLL file for distribution with the end-user application. The DLL can be imported into the application at run-time with the kelpo_create_interface() function (cf. Sample usage):
int kelpo_create_interface( const struct kelpo_interface_s **dst, const char *const rendererName ) { HMODULE dllHandle = NULL; const char *dllFilename = NULL; dll_import_fn_t get_kelpo_interface = NULL; /* ... */ dllHandle = LoadLibraryA(dllFilename); /* ... */ get_kelpo_interface = (dll_import_fn_t)GetProcAddress(dllHandle, "export_interface"); /* ... */ get_kelpo_interface(*dst, KELPO_INTERFACE_VERSION_MAJOR); /* ... */ return 1; }
Each renderer's code is further divided into a rasterizer and a surface group. The following are the corresponding source files for the Glide 3 renderer:
The rasterizer code is responsible for rendering polygons and managing related aspects (e.g. texture memory). The surface code handles the initiation and run-time controlling of a suitable render context for the rasterizer – e.g. a DirectDraw surface for a Direct3D renderer.
Finally, Kelpo's generic Win32 windowing code interfaces with the active renderer's surface code to display the contents of the surface in Kelpo's window.
In addition to the interface and renderer code, Kelpo comes with a number of optional rendering-related implementations that one can use for bootstrapping their Kelpo application. These include triangle-clipping, vertex transformation, matrix operations, and a generic LIFO stack.
Outside of the Kelpo codebase, I've created a complementary mesh file format for Kelpo. The format is designed with the limitations of legacy rendering APIs in mind, and comes with an OBJ converter and a C89 importer for use in a Kelpo application. The OBJ converter will additionally convert the model's textures into Kelpo's 16-bit power-of-two format and generate mipmaps for them.