The Renderer That Draws Nothing

The engine I wrote for mobile has three render backends. One targets desktop OpenGL, one targets OpenGL ES, and one accepts every call and draws nothing at all.

That third one is 274 lines of code that produce no pixels, and it's the reason I trust the other two.

This is the last of eight posts taking one system at a time out of my custom engines.

Why Two Backends Forces an Interface

An engine with one graphics API doesn't have a renderer abstraction, it has graphics calls wherever drawing happens. My first engine was like that: game objects emitted vertices inline, so the drawing code and the game code were the same code.

Adding a second API is what makes the boundary real. Desktop OpenGL and OpenGL ES overlap heavily and differ in exactly the places that matter, so code written against one won't compile against the other. That incompatibility is useful pressure, because it forces every drawing call in the game to become a call to something the engine defines.

The result is that game code names operations rather than API functions. It asks for a quad, a texture bind, a framebuffer target. What that becomes on the way to the hardware belongs to the backend, and the backend is selected at compile time.

Two implementations is the minimum that proves an interface exists. One implementation can always be an accidental rename of the underlying API, and it usually is, because there's nothing pushing back.

The Third One Is the Interesting One

cmRenderNULL implements the same interface and does nothing. Every call is accepted, nothing is drawn, and no graphics context is required.

The immediate benefit is that the game can run with no GPU. Simulation timing without the renderer confusing the measurement. Logic running on a machine with no display. Automated runs that need the game to execute rather than to look correct.

The subtler benefit is that it proves the abstraction. An interface with two real implementations can still leak, because both of them are graphics APIs and both can quietly rely on the same assumption. A null implementation can't rely on anything. If the game runs correctly against a backend that does nothing, then nothing outside the renderer depends on rendering having happened, which is a property that's hard to establish any other way.

That's the null object pattern doing something more useful than avoiding a null check. It's a test of whether a boundary is where it claims to be, and it's cheap. Most of those 274 lines are empty function bodies.

The Boundary Has to Be an Operation

The design decision that makes a null backend possible is that the interface is expressed in operations rather than in resources the caller holds.

If game code holds a texture id, a buffer handle, or a shader location, those are graphics concepts and the null backend has to fabricate plausible ones. Every fabricated handle is a chance for game code to do something with it that only works because the real backend behaves a certain way.

If game code instead asks the renderer to draw a thing described in the engine's own types, the null backend has nothing to fake. It receives a description and discards it.

That's the test I'd apply to any abstraction, not only rendering. Can a do-nothing implementation satisfy it without inventing anything? If it can't, the interface is exposing the thing it was supposed to be hiding.

Platforms, Split the Same Way

The same split appears one level down, with separate Windows and iOS platform implementations behind a common interface, chosen at compile time.

The first engine did this with #ifdef blocks inline, so a single function might contain both the Windows and the Mac version of a step, separated by preprocessor directives. That works and it rots. Every function becomes a place where two platforms are interleaved, and reading either one means mentally deleting the other. Adding a third platform means touching every one of those functions.

Separate implementation files behind one interface means adding a platform is adding a file. It also means each file is readable on its own, which matters more than it sounds when debugging something that only reproduces on one of them.

What It Cost

An interface is the intersection of what its implementations can do, so anything one API offers that the others don't is either absent or leaks through as a special case. A desktop-only feature has nowhere clean to live in a design that also targets mobile, and the pressure is always toward the lowest common denominator.

And there's indirection at the call site. Every draw goes through a virtual call rather than straight to the API. For this engine, drawing a few thousand quads, that never showed up in a profile. For a renderer issuing hundreds of thousands of calls a frame it would, and the answer there is to batch at a higher level so the interface is crossed less often rather than to remove the interface.

The Return Was Longevity

The measurable outcome is that this engine outlived the game it was written for by more than a decade, and the renderer abstraction is the main reason.

A renderer behind an interface can gain a backend. When the target changed, the work was a new implementation of a known interface, which is bounded and reviewable. The first engine, where drawing was inline API calls in game code, could not have been ported. It would have been rewritten, and rewriting is what happened instead.

That's the general argument for interfaces at the boundary of anything a project doesn't control. Not because abstraction is a virtue, and not because a second implementation is planned, but because the thing on the other side will eventually change, and the interface converts a rewrite into an addition.

What Transfers

Two implementations to make an interface real, because one implementation is usually a rename.

A null implementation to prove it, because it can only be satisfied by an interface that genuinely hides what's behind it, and it's mostly empty function bodies.

Operations rather than handles, so that a do-nothing implementation has nothing to fabricate.

And separate files rather than preprocessor branches, so that each platform can be read without mentally deleting the others.