How Half-Life’s BSP Renderer Was Reworked for the Dreamcast
At a glance, the Dreamcast port of Half-Life still renders the world the same way as the PC build: determine the current BSP leaf, decompress its PVS, walk the visible BSP nodes, reject geometry outside the frustum, collect visible surfaces, and draw them grouped by texture.
Underneath that familiar structure, however, the Dreamcast renderer is substantially different. The port trades the PC renderer’s generous memory layouts and OpenGL immediate-mode drawing for compact structures, shared data, explicit batching, native texture formats, and carefully managed rendering passes.
The result is not a new renderer so much as a Dreamcast-specific interpretation of GoldSrc’s renderer.
The BSP algorithm survived; its data did not
The high-level world-rendering sequence remains recognizable:
- Mod_PointInLeaf finds the leaf containing the camera.
- R_MarkLeaves obtains that leaf’s potentially visible set.
- Visible leaves mark their parent nodes.
- R_RecursiveWorldNode walks only the marked part of the BSP.
- Node bounds are tested against the view frustum.
- Visible surfaces are linked into texture chains.
- The texture chains are submitted in batches.
- Decals and lightmaps are rendered as later passes.
The PC build follows almost the same logical sequence. The major difference is that the Dreamcast port aggressively changes the runtime representation of the BSP.
The original PC structures use full-width integers for values such as contents and visframe. On Dreamcast, these become signed bytes. Surface frame markers, dynamic-light frame markers, lightmap coordinates, surface edge counts, style indices, and several flags are packed into byte or short fields.
The Dreamcast node and leaf layouts are also deliberately arranged so their common header occupies the same offsets. A leaf can therefore be traversed as an mnode_t while walking back through its parents without conversion or an extra abstraction layer.
This matters on a machine with 16 MB of main RAM. BSP data is touched constantly by visibility, collision, lighting, entity linking, and rendering. Reducing its working set improves more than the amount of free memory: it also reduces the pressure placed on the SH-4 cache and memory bus.
The plane-normal table
One of the most distinctive Dreamcast changes is the global plane-normal table.
In the PC build, every runtime BSP plane is an mplane_t containing:
- A three-float normal vector.
- A float distance.
- Plane type and sign bits.
- Padding.
That is 20 bytes per plane. The PC loader even reserves space for twice the BSP plane count.
The Dreamcast instead uses an eight-byte mclipplane_t:
typedef struct mclipplane_s {
float dist; byte type; byte signbits; unsigned short normalindex;
} mclipplane_t;
The three-float normal is replaced by a 16-bit index into g_planeNormalTable.
This works because BSP maps contain far fewer unique normal vectors than planes. Large portions of a level are axis-aligned, so hundreds or thousands of planes may reuse one of only six normals:
+X -X +Y -Y +Z -Z
At initialization, the port allocates a 4,096-entry normal table and seeds it with those six axial normals. Each table entry is 16 bytes: three floats plus an unused float that also serves as the empty-slot marker.
When a BSP is loaded, Mod_AddNormalToTable hashes the twelve raw bytes of the normal vector. Collisions use linear probing. If the exact three-float vector already exists, the loader reuses its index; otherwise it installs a new entry.
The trade is deliberate:
- The renderer pays for one 64 KB lookup table.
- Every runtime BSP plane becomes 12 bytes smaller.
- The same representation is reused by rendering, dynamic lighting, server collision, player movement, surface tracing, and PVS-related calculations.
This is more than a renderer optimization. The normal table became an engine-wide data contract.
The common case is therefore both compact and fast. Only arbitrarily oriented planes require the indexed normal lookup and full dot product.
Visibility with byte-sized frame stamps
The PC renderer stores visibility frame numbers as integers. The Dreamcast version uses bytes and allows them to wrap every 256 visibility updates.
R_MarkLeaves increments r_visframecount, decompresses the current leaf’s PVS, and walks from every visible leaf toward the root. Each visited node receives the current byte-sized visibility stamp.
R_RecursiveWorldNode can reject a branch immediately when:
- It is solid.
- Its visibility stamp does not match.
- Its packed short bounding box lies outside the frustum.
Only a leaf that survives all three tests marks its surfaces and contributes its entity fragments.
Using a byte here is safe because the renderer is comparing equality, not ordering. After wrapping, old values remain harmless as long as the current traversal rewrites the active path. It saves space in every node, leaf, and surface touched by the traversal.
The PC and Dreamcast builds therefore perform the same PVS algorithm, but the Dreamcast version turns its per-frame bookkeeping into a compact set of byte stamps.
From BSP order to texture order
A BSP naturally produces surfaces in spatial order. That is useful for visibility, but poor for rendering hardware because adjacent surfaces may use different textures and render states.
Both builds solve this by placing visible surfaces onto texture chains. The difference is what happens after the chain is built.
The PC OpenGL renderer still draws most surfaces through an immediate-mode pattern
Even with texture sorting, each surface generally becomes its own OpenGL polygon submission.
The Dreamcast port converts texture chains into indexed triangle batches. R_DrawSequentialPoly examines an entire chain and selects an appropriate path:
- Sky surfaces are deferred to the sky renderer.
- Turbulent chains use the water renderer.
- Scrolling surfaces use a specialized accumulator.
- Ordinary opaque surfaces use the solid-poly accumulator.
- Alpha-tested surfaces use a colored-poly accumulator.
- Mixed or unusual chains fall back to R_RenderBrushPoly.
Animated texture chains are subdivided into runs that resolve to the same animation frame. One texture is bound, every compatible polygon is appended to the accumulator, and the resulting geometry is flushed with one indexed draw.
This is important because the Dreamcast is much less tolerant of thousands of tiny API submissions than a contemporary PC OpenGL driver.
The vertex accumulator
The center of the Dreamcast submission path is dc_accum.c.
It allocates:
- 64 KB for transformed/lit vertices.
- 16 KB for 16-bit indices.
- Additional matrix storage for the studio renderer.
Polygons are converted into D3DLVERTEX data and appended to these buffers. A flush sends the current batch through a single DrawIndexedPrimitive call.
The accumulator also contains precomputed index patterns for common polygon and strip shapes. Rather than rebuilding the same triangle fan or quad topology repeatedly, the renderer can copy or reference a prepared pattern and adjust it for the batch’s base vertex.
A conservative threshold of 224 accumulated vertices triggers an early flush. The actual buffer can hold considerably more, but the smaller threshold leaves room for the next primitive and helps prevent expensive overflow handling in the middle of a surface.
Render-state helpers are coupled to the accumulator. A texture, blend, fog, clipping, or depth-state transition flushes pending geometry before changing the device state. This preserves ordering while allowing large runs of identical state to remain batched.
On PC, OpenGL and its driver absorb much of this responsibility. On Dreamcast, the engine has to make the batching policy explicit.
Lightmaps remain a second pass
Like the PC OpenGL renderer, the Dreamcast renderer normally draws the world in two major passes:
- Draw the base textures.
- Modulate them with lightmap textures.
Visible base polygons are simultaneously linked into lightmap_polys[] chains. Once the base-texture pass, decals, and other required work are complete, R_BlendLightmaps walks those chains and accumulates geometry grouped by lightmap atlas.
The Dreamcast lightmaps are stored and uploaded as RGB565, using two bytes per texel. The blend state multiplies the existing framebuffer color by the lightmap:
result = base texture × lightmap
Dynamic lighting does not require rebuilding an entire atlas. When a surface’s light style changes or a dynamic light touches it, the engine rebuilds that surface’s lightmap block and uploads only the corresponding atlas rectangle with DCV_UpdateTextureSubRect.
The PC renderer also tracks modified rectangles, but the Dreamcast path is more tightly coupled to the console texture manager and its RGB565 representation.
Compressed lightmap sources
The Dreamcast port supports several non-PC lightmap encodings selected through model->lightmap_mode.
The normal PC-style path reads full RGB samples. The console port additionally understands compact formats intended to reduce level storage and load-time memory traffic.
Mode 1: delta-coded RGB555
A texel with bit 15 clear contains an absolute RGB555 color. A texel with bit 15 set contains signed channel deltas relative to the previously decoded texel.
Large smooth lightmaps change slowly, so many samples can be represented by small deltas rather than complete colors.
Mode 2: row runs
Each lightmap row stores a reduced run of RGB control samples. At runtime the renderer expands the run horizontally using linear interpolation to obtain the surface’s full lightmap width.
Mode 3: compact two-dimensional grids
A complete lightmap style is represented by a small grid. The runtime uses bilinear interpolation to reconstruct the full surface block.
Horizontal interpolation indices and weights are precomputed for a column and reused across rows, avoiding some repeated work during expansion.
Compared with the PC build, these formats exchange some level-load or dynamic-lightmap CPU work for smaller assets and reduced memory use—usually the correct trade on Dreamcast.
Compact textures and console-native formats
The same philosophy appears in world textures.
The PC build expects conventional BSP/WAD texture data and lets OpenGL convert it into an appropriate hardware representation. The Dreamcast port can instead load an external compact texture table and console-oriented texture data, including PVR-backed resources and preformatted RGB565 surfaces.
The compact table intentionally contains texture metadata rather than embedded mip pixels. Texture content can live in the console’s resource pipeline instead of being duplicated inside the BSP.
This separates three things that the PC engine tends to treat as one object:
- BSP texture identity and animation metadata.
- Source image or WAD data.
- Hardware-ready Dreamcast texture storage.
It reduces duplication and gives the port control over texture placement, palettes, formats, and upload behavior.
Brush models and depth management
Moving doors, platforms, and other brush entities reuse world surfaces, but they require their own model transform and local visibility calculations.
The PC build pushes an OpenGL matrix, rotates for the entity, draws the surfaces, and pops the matrix.
The Dreamcast performs the equivalent operation through its D3D-style matrix stack, but it also applies a slightly adjusted projection depth for brush models. A rotating sequence of depth slots gives different brush entities tiny near-plane offsets, reducing depth fighting where a moving brush shares a plane with world geometry.
Decals receive a similar projection nudge during their pass.
These are small console-specific accommodations around the same GoldSrc geometry. They acknowledge the finite precision and behavior of the Dreamcast depth pipeline instead of assuming that PC OpenGL defaults will produce the same result.