This is a three part series:
Part 1 – Introduction to Abstract Renderers
Part 2 – DirectX 11 Interface & Gotchas
Part 3 – Shader Interface
Note: Unfortunately I can’t share my actual abstract renderer implementation. All code in this series should be considered pseudo-code for demonstrative purposes only.
Introduction
Deployment to multiple platforms is virtually a requirement for today’s games. It’s difficult to justify a large budget for a game that will only launch on one console or one type of phone, unless you’re lucky enough to have a publisher pay you a bonus for being exclusive.
As such, robust abstraction layers for rendering, sound, input, and network communication are common.
The major graphics APIs in use by games today – DirectX 9, DirectX 11, OpenGL, and OpenGL ES 2 are similar enough that a single interface is achievable. However, it’s not always easy to find common ground, particularly when interfacing with shaders.
Abstract at the System API Level
There are two major philosophies when it comes to writing an abstraction interface:
- Abstract at the object level – provide different implementations of your Mesh, Texture, or Sound Effect classes for each platform and have them directly call the system API
- Abstract at the system API level – provide a single abstract wrapper over the system API and write shared Mesh, Texture, or Sound Effect classes that only talk to the wrapper
In general, abstracting at the system API level has a higher upfront cost but makes porting to other platforms cheaper in the long run. When you abstract at the object level, you can immediately write your DX11 Mesh class and you pay no cost until you decide to port to iOS, at which point you have to write an OpenGL Mesh class. However, you will find yourself duplicating the porting effort each time you create a new graphics object(say, a TextRenderer class).
As such, I strongly recommend abstracting once at the system API level and only having to maintain a single Mesh, Texture, TextRenderer, etc.
Interface
There are many methods to write an abstraction interface – a singleton, a namespace, etc.
My preferred method is an abstract base class with derived implementations, i.e.
class Renderer
{
public:
virtual class VertexBufferResource * createVertexBuffer( unsigned int size, void * data, bool isDynamic ) = 0;
virtual void* lockVertexBuffer( class VertexBufferResource * vertexBuffer, unsigned int offset, unsigned int size ) = 0;
virtual void unlockVertexBuffer( class VertexBufferResource * vertexBuffer ) = 0;
virtual void releaseVertexBuffer( class VertexBufferResource * vertexBuffer ) = 0;
...
class OpenGLRenderer : public Renderer
...
This way, when writing a new renderer implementation, you’ll get compile errors for anything you miss. It also means that you could theoretically have similar renderers share code in a base class, for instance DX11Renderer and DX9Renderer could both derive from DXRenderer. Note: in practice, DX9 and DX11 are different enough that they cannot actually share any code, as we’ll see in Part 2.
Wrapping Resources
DX9, DX11, and OpenGL share a rough idea of graphics ‘objects’ or ‘resources’, like textures, vertex buffers, index buffers, compiled shader code, or render targets.
In OpenGL, these take the form of a GLuint handle that gets passed to functions like glGenBuffers, glBindBuffer, and glDeleteBuffers. In DirectX, they take the form of objects derived from a reference-counted resource interface that are constructed and manipulated by functions but destroy themselves when their reference count reaches 0.
One way to wrap these objects is just to follow the OpenGL C-style construction/manipulation/destruction interface:
class VertexBufferResource
{
};
...
class GLVertexBufferResource : public VertexBufferResource
{
public:
GLuint resource;
};
...
VertexBufferResource * OpenGLRenderer::createVertexBuffer( unsigned int size, void * data, bool isDynamic )
{
GLVertexBufferResource * resource = new GLVertexBufferResource();
glGenBuffers(1, &resource->resource);
glBindBuffer(GL_ARRAY_BUFFER, resource->resource);
glBufferData(GL_ARRAY_BUFFER, size, data, isDynamic ? GL_DYNAMIC_DRAW : GL_STATIC_DRAW);
assert(glGetError() == 0, "Could not create vertex buffer.");
return resource;
}
void* OpenGLRenderer::lockVertexBuffer( VertexBufferResource * vertexBuffer, unsigned int offset, unsigned int size )
{
// we assume it's actually a GLVertexBufferResource - you can provide more robust checks if you want
GLVertexBufferResource * resource = static_cast<GLVertexBufferResource>(vertexBuffer);
glBindBuffer(GL_ARRAY_BUFFER, resource->resource);
void * outData = glMapBufferOES(GL_ARRAY_BUFFER, GL_WRITE_ONLY_OES);
assert(glGetError() == 0, "Could not lock vertex buffer.");
return outData;
}
...
void OpenGLRenderer::releaseVertexBuffer( VertexBufferResource * vertexBuffer )
{
// we assume it's actually a GLVertexBufferResource - you can provide more robust checks if you want
GLVertexBufferResource * resource = static_cast<GLVertexBufferResource>(vertexBuffer);
glDeleteBuffers(1, &resource->resource);
delete vertexBuffer;
}
You could also make these VertexBufferResource objects follow the RAII pattern. Make sure that you are very careful about thread ownership and order of destruction issues if you go down that route!
NULL Implementation
A great place to start is writing a NULL(or Logging) implementation that doesn’t do any actual work but simply logs out the commands it receives. This can be a very useful tool:
- When writing a new Renderer, you don’t have to implement every command – you can temporarily derive from the NULL renderer. Whatever commands you don’t implement will show up in the log, and you can focus on implementing functionality one piece at a time.
- You can temporarily switch to the NULL renderer and generate a complete log of all graphics calls. You could also have the NULL renderer redirect calls to an actual renderer after logging.
Next time…
In Part 2, we’ll discuss why it’s best to design your interface around DirectX 11.
