Skip to content

Pixel-exact software rendering ​

Until 3D cards were common, a game drew every pixel itself: lines, filled polygons, sprites, textured walls. Two renderers that draw "the same" triangle can differ in a pixel along every edge. The differences come from how each one rounds a coordinate, steps along an edge, or decides which pixels on a boundary to fill. A port that must match the original frame for frame has to copy those decisions. This page lists the decisions that change pixels, and how to measure each one in the original. The video modes themselves are on 2D graphics. Michael Abrash's Graphics Programming Black Book (1997, free from the author) explains many of these techniques as the programmers of the time used them.

Where pixels differ ​

Two triangles that share an edge, and which pixels each draws

A 16.16 fixed-point number, bit by bit

DecisionWhat varies between renderersHow to find the original's choice
Fill ruleWhich pixels on a polygon's edge are drawn. A common rule draws a pixel whose centre lies on a left or top edge and leaves it for a right or bottom edge, so two polygons that share an edge never both draw it. Many games used their own ruleDraw two polygons that share an edge in the original, and look for gaps or double-drawn pixels
Edge steppingWhere each scanline's span starts and ends. Most renderers step the x position by a fixed-point slope each line. Adding the slope again and again rounds differently from computing each line's x afreshRead the edge setup in the code. Note the fixed-point format, the starting value (often rounded up), and whether the step accumulates
Pixel centresWhether a vertex at (10, 10) means the corner of a pixel or its centre. That moves every edge by half a pixelDraw a one-pixel-high polygon at known coordinates
Line drawingWhich pixels a line covers when it passes exactly between two. Bresenham's method, run-length slices and fixed-point DDA lines break ties differently. Lines also differ in whether they draw their last pointDraw lines at each octant's angles and at 45 degrees
Texture mappingAffine mapping (straight interpolation across the span) or perspective-correct mapping, and how often the perspective is recomputed. Quake recomputes it every 16 pixels and interpolates in betweenDraw a textured wall at an angle and compare the texel at each pixel
Fixed-point formats16.16, 24.8, 8.8 and others. Multiplications that keep a 64-bit product (imul then shrd) give different results from ones that overflow 32 bitsThe instructions around each multiply (Reading compiled code)
Lighting and colourLighting by lookup tables: a colour map with one row of 256 entries per light level, chosen by distance. The table is the rule, so the port must use the same tableFind the table (tools/triage/filetypes.py, the code that indexes it) and copy it
DitheringAn ordered dither matrix or error diffusion, and where the pattern is anchored (the screen or the polygon)Capture a flat shaded area and read the pattern
Drawing orderThe painter's algorithm draws far to near. Objects at equal depth come out in the order the sort leaves them, and an unstable sort such as qsort can swap themThe sort call and its compare function. Equal keys need the original's exact order
ClippingClipping against the screen edges in fixed point, or with a guard band, creates new vertices with their own roundingDraw polygons that cross each screen edge
SpritesTransparent colour index, the order of overlapping sprites, scaling by stepping in fixed point (which source pixels repeat or drop)Capture a scaled sprite at several sizes

How to measure ​

  1. Run the drawing routine alone. Call the original's polygon, line or span function under emulation (re-emulate) with coordinates you choose, into an empty buffer. Save the buffer. Then do the same with the port's function, and compare the two buffers byte for byte (tools/verify/bitcompare.py).
  2. Choose hard cases. Use edges that fall exactly on pixel centres, slopes of 1 and of 1/2, polygons one pixel high, lines in every octant, and textures at steep angles. These are where the rules differ.
  3. Look at the result. tools/formats/rawimage.py turns a buffer or a dump of video memory into a picture, in the original's video mode and palette. Two pictures side by side show where the edges differ. The pixel explorer does the same in the browser.
  4. Write each rule down with the test that proved it, as measured evidence. The port's own test suite then runs the same cases.

Porting a software renderer ​

  • Keep the original's integer and fixed-point arithmetic. Do not replace a fixed-point edge walker with floating point, even where the result looks the same. It will differ in a few pixels.
  • Keep the lookup tables as they are, including any rounding errors. Sine tables, colour maps and reciprocal tables are part of the rule (tools/triage/tables.py finds sine tables).
  • Draw into a buffer of the original's size and palette. Then let the port scale and present the buffer with modern hardware. Comparing frames is only possible when the buffer is the original's.
  • Mark any change you make on purpose, such as a higher resolution or smoothing, as a Deviation, and keep the exact path available for tests.

What to record ​

  • For each drawing routine: its address, its fixed-point format, its fill rule or line rule, and the test cases that confirmed them.
  • Every lookup table it uses, with its address and size.
  • The drawing order and the sort that sets it.

Code: Apache 2.0. Content: CC0 1.0. No game material: bring your own copy.