C++ Rendering Engine I – Abstracting the Render Device

Getting Started

Start With a Triangle

When building a render device, I like to start without the abstraction and get a single triangle onscreen as quickly as possible. This makes debugging the abstraction much easier. Every graphics programmer knows how hard it can be to debug their renderer when it produces only a black screen. See OpenGL: Why Is Your Code Producing a Black Window?.

To get this triangle onscreen, there are many tutorial websites where you can simply drop their “Hello, Triangle” code into your draw loop, before you start making an abstract render device. For example: Learn OpenGL : Hello Triangle or hello_triangle.cpp.

This commit in the sample code demonstrates the simple triangle with an empty RenderDeviceLib: a6fd9dd.

The goal for the rest of this page is to move all of the OpenGL code from the triangle sample into the render device. We will also move the platform management code so that we can quickly create example programs that only use our abstraction and not GLFW directly. (I know; I know. An abstraction wrapping an abstraction.)

We can summarize this effort into four main abstractions: platform, vertex data, shader pipeline, and draw commands.

Platform

This one is easy. Our rendering library will have InitPlatform(), CreatePlatformWindow(), PollPlatformWindow(), PresentPlatformWindow(), and TerminatePlatform() methods for the platform abstraction. We will not be making a class for these methods; we will leave them as global C functions.

InitPlatform() sets up the OpenGL context and other global platform services. TerminatePlatform() destroys the OpenGL context.

CreatePlatformWindow() sets up the window for OpenGL rendering.

PollPlatformWindow() ensures the window’s message queue is pumped. PresentPlatformWindow() ensures the window receives the visible results of any rendering performed with the OpenGL context.

This commit demonstrates the result of having GLFW wrapped up in our platform abstraction: 89d4943

Following is what our main loop likes like with the OpenGL code snipped out.

#include "render_device/platform.h"
 
int main()
{
	platform::InitPlatform();
 
	platform::PLATFORM_WINDOW_REF window =
		platform::CreatePlatformWindow(800, 600, "Triangle");
	if(!window)
	{
		platform::TerminatePlatform();
		return -1;
	}
 
	// ...
 
	while(platform::PollPlatformWindow(window))
	{
		// ...
 
		platform::PresentPlatformWindow(window);
	}
 
	// ...
 
	platform::TerminatePlatform();
 
	return 0;
}

Shader Pipeline

Next, we will create render device objects that encapsulate the shader pipeline. These are VertexShader, PixelShader, and Pipeline.

We will assume that all shader source code given to the render device is written in GLSL. When we port our render device to another platform, we will have to abstract away the specific
shader language syntax. This is the one gaping whole in our abstraction and is out of scope for this article. I plan to return to this issue in a future article.

This checkin has the shader pipeline fully abstracted in our triangle sample: 615e319

Following are the relevant types and methods of our render device interface.

namespace render {
 
// Encapsulates a vertex shader
class VertexShader
{
public:
 
	// virtual destructor to ensure subclasses have a virtual destructor
	virtual ~VertexShader() {}
 
protected:
 
	// protected default constructor to ensure these are never created
	// directly
	VertexShader() {}
};
 
// Encapsulates a pixel shader
class PixelShader
{
public:
 
	// virtual destructor to ensure subclasses have a virtual destructor
	virtual ~PixelShader() {}
 
protected:
 
	// protected default constructor to ensure these are never created
	// directly
	PixelShader() {}
};
 
// Encapsulates a shader pipeline
class Pipeline
{
public:
 
	// virtual destructor to ensure subclasses have a virtual destructor
	virtual ~Pipeline() {}
 
protected:
 
	// protected default constructor to ensure these are never created
	// directly
	Pipeline() {}
};
 
// Encapsulates the render device API.
class RenderDevice
{
public:
 
	// virtual destructor to ensure subclasses have a virtual destructor
	virtual ~RenderDevice() {}
 
	// Create a vertex shader from the supplied code
	// code is assumed to be GLSL for now
	virtual VertexShader *CreateVertexShader(const char *code) = 0;
 
	// Destroy a vertex shader
	virtual void DestroyVertexShader(VertexShader *vertexShader) = 0;
 
	// Create a pixel shader from the supplied code
	// code is assumed to be GLSL for now
	virtual PixelShader *CreatePixelShader(const char *code) = 0;
 
	// Destroy a pixel shader
	virtual void DestroyPixelShader(PixelShader *pixelShader) = 0;
 
	// Create a linked shader pipeline given a vertex and pixel shader
	virtual Pipeline *CreatePipeline(VertexShader *vertexShader,
		PixelShader *pixelShader) = 0;
 
	// Destroy a shader pipeline
	virtual void DestroyPipeline(Pipeline *pipeline) = 0;
 
	// Set a shader pipeline as active for subsequent draw commands
	virtual void SetPipeline(Pipeline *pipeline) = 0;
};
 
// Creates a RenderDevice
RenderDevice *CreateRenderDevice();
 
// Destroys a RenderDevice
void DestroyRenderDevice(RenderDevice *renderDevice);
 
} // end namespace render

And following, the state of our main function with the OpenGL code snipped out.

#include "render_device/platform.h"
 
#include "render_device/render_device.h"
 
const char *vertexShaderSource = "#version 410 core\n"
	"layout (location = 0) in vec3 aPos;\n"
	"void main()\n"
	"{\n"
	"   gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
	"}\0";
const char *pixelShaderSource = "#version 410 core\n"
	"out vec4 FragColor;\n"
	"void main()\n"
	"{\n"
	"   FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);\n"
	"}\n\0";
 
int main()
{
	platform::InitPlatform();
 
	platform::PLATFORM_WINDOW_REF window =
		platform::CreatePlatformWindow(800, 600, "Triangle");
	if(!window)
	{
		platform::TerminatePlatform();
		return -1;
	}
 
	render::RenderDevice *renderDevice = render::CreateRenderDevice();
 
	render::VertexShader *vertexShader =
		renderDevice->CreateVertexShader(vertexShaderSource);
 
	render::PixelShader *pixelShader =
		renderDevice->CreatePixelShader(pixelShaderSource);
 
	render::Pipeline *pipeline =
		renderDevice->CreatePipeline(vertexShader, pixelShader);
 
	renderDevice->DestroyVertexShader(vertexShader);
	renderDevice->DestroyPixelShader(pixelShader);
 
	// ...
 
	while(platform::PollPlatformWindow(window))
	{
		renderDevice->SetPipeline(pipeline);
 
		// ...
 
		platform::PresentPlatformWindow(window);
	}
 
	// ...
 
	renderDevice->DestroyPipeline(pipeline);
 
	platform::TerminatePlatform();
 
	return 0;
}

Vertex Data

Now, we will create a render device object that encapsulates a collection of vertex buffers and how the buffer data is to be interpreted by the shader pipeline. This involves the render device objects VertexBuffer, VertexDescription, and VertexArray. We also introduce the enum VertexElementType and the struct VertexElement that we use to tell the render device the layout and format of our vertex buffers.

Following are the additions to our render device interface.

// ...
 
// Encapsulates a vertex buffer
class VertexBuffer
{
public:
 
	// virtual destructor to ensure subclasses have a virtual destructor
	virtual ~VertexBuffer() {}
 
protected:
 
	// protected default constructor to ensure these are never created
	// directly
	VertexBuffer() {}
};
 
// Encapsulates a vertex buffer semantic description
class VertexDescription
{
public:
 
	// virtual destructor to ensure subclasses have a virtual destructor
	virtual ~VertexDescription() {}
 
protected:
 
	// protected default constructor to ensure these are never created
	// directly
	VertexDescription() {}
};
 
// Encapsulates a collection of vertex buffers and their semantic descriptions
class VertexArray
{
public:
 
	// virtual destructor to ensure subclasses have a virtual destructor
	virtual ~VertexArray() {}
 
protected:
 
	// protected default constructor to ensure these are never created
	// directly
	VertexArray() {}
};
 
// Describes a vertex element's type
enum VertexElementType
{
	VERTEXELEMENTTYPE_BYTE = 0,
	VERTEXELEMENTTYPE_SHORT,
	VERTEXELEMENTTYPE_INT,
 
	VERTEXELEMENTTYPE_UNSIGNED_BYTE,	
	VERTEXELEMENTTYPE_UNSIGNED_SHORT,
	VERTEXELEMENTTYPE_UNSIGNED_INT,
 
	VERTEXELEMENTTYPE_BYTE_NORMALIZE,
	VERTEXELEMENTTYPE_SHORT_NORMALIZE,
	VERTEXELEMENTTYPE_INT_NORMALIZE,
 
	VERTEXELEMENTTYPE_UNSIGNED_BYTE_NORMALIZE,	
	VERTEXELEMENTTYPE_UNSIGNED_SHORT_NORMALIZE,
	VERTEXELEMENTTYPE_UNSIGNED_INT_NORMALIZE,
 
	VERTEXELEMENTTYPE_HALF_FLOAT,
	VERTEXELEMENTTYPE_FLOAT,
	VERTEXELEMENTTYPE_DOUBLE
};
 
// Describes a vertex element within a vertex buffer
struct VertexElement
{
	// location binding for vertex element
	unsigned int index;
 
	// type of vertex element
	VertexElementType type;
 
	// number of components
	int size;
 
	// number of bytes between each successive element (leave zero for
	// this to be assumed to be size times size of type)
	int stride;
 
	// offset where first occurrence of this vertex element resides in
	// the buffer
	long long offset;
};
 
// Encapsulates the render device API.
class RenderDevice
{
public:
 
	// ...
 
	// Create a vertex buffer
	virtual VertexBuffer *CreateVertexBuffer(long long size,
		void *data = nullptr) = 0;
 
	// Destroy a vertex buffer
	virtual void DestroyVertexBuffer(VertexBuffer *vertexBuffer) = 0;
 
	// Create a vertex description given an array of VertexElement structures
	virtual VertexDescription *CreateVertexDescription(unsigned int numVertexElements,
		const VertexElement *vertexElements) = 0;
 
	// Destroy a vertex description
	virtual void DestroyVertexDescription(VertexDescription *vertexDescription) = 0;
 
	// Create a vertex array given an array of vertex buffers and associated vertex
	// descriptions; the arrays must be the same size.
	virtual VertexArray *CreateVertexArray(unsigned int numVertexBuffers,
		VertexBuffer **vertexBuffers, VertexDescription **vertexDescriptions) = 0;
 
	// Destroy a vertex array
	virtual void DestroyVertexArray(VertexArray *vertexArray) = 0;
 
	// Set a vertex array as active for subsequent draw commands
	virtual void SetVertexArray(VertexArray *vertexArray) = 0;
};

Following are the additions to our main loop.

// ...
 
int main()
{
	// ...
 
	float vertices[] = {
		-0.5f, -0.5f, 0.0f, // left  
		 0.5f, -0.5f, 0.0f, // right 
		 0.0f,  0.5f, 0.0f  // top   
	}; 
 
	render::VertexBuffer *vertexBuffer = renderDevice->CreateVertexBuffer(sizeof(vertices), vertices);
 
	render::VertexElement vertexElement = { 0, render::VERTEXELEMENTTYPE_FLOAT, 3, 0, 0, };
	render::VertexDescription *vertexDescription = renderDevice->CreateVertexDescription(1, &vertexElement);
 
	render::VertexArray *vertexArray = renderDevice->CreateVertexArray(1, &vertexBuffer, &vertexDescription);
 
	// ...
 
	while(platform::PollPlatformWindow(window))
	{
		// ...
 
		renderDevice->SetVertexArray(vertexArray);
 
		// ...
 
		platform::PresentPlatformWindow(window);
	}
 
	// ...
 
	renderDevice->DestroyVertexArray(vertexArray);
	renderDevice->DestroyVertexDescription(vertexDescription);
	renderDevice->DestroyVertexBuffer(vertexBuffer);
 
	// ...
 
	platform::TerminatePlatform();
 
	return 0;
}

Draw Commands

Finally, we need to add some draw commands; ClearColor for clearing the color buffer and DrawTriangles for drawing collections of triangles.

// ...
 
class RenderDevice
{
public:
 
	// ...
 
	// Clear the default render target's color buffer to the specified RGBA
	// values
	virtual void ClearColor(float red, float green, float blue, float alpha) = 0;
 
	// Draw a collection of triangles using the currently active shader pipeline
	// and vertex array data
	virtual void DrawTriangles(int offset, int count) = 0;
};

Our main loop calling these commands follows.

// ...
 
int main()
{
	// ...
 
	while(platform::PollPlatformWindow(window))
	{
		renderDevice->ClearColor(0.2f, 0.3f, 0.3f, 1.0f);
 
		// ...
 
		renderDevice->DrawTriangles(0, 3);
 
		// ...
	}
 
	// ...
}

The code so far is at this commit: b151434.

Next, we will add some additional features: index buffers, shader uniforms, textures, raster states, and depth/stencil states.

6 thoughts on “C++ Rendering Engine I – Abstracting the Render Device

        1. Andy Post author

          It certainly has been a while.

          I am currently working on a Vulkan engine (one at work and one at home). Soon, I will write about the choices I am making to support both OpenGL and Vulkan abstractions without sacrificing performance or expressiveness with Vulkan.

          Reply
  1. Mathew

    Thank you so much, words can explain how great it is to finally find something on this topic that is relevant and makes sense. I haven’t yet gotten to implementing the shader uniforms or texture part of the abstraction but with the knowledge I’ve gained so far, I now I understand that the possibilities are truly endless.

    I honestly think I’ve never truly grasped abstraction until today.

    I now understand that in order to create an API agnostic rendering engine, things need to be broken down, but not to the point where you end up defining methods that are specific to each API.

    When I first saw that you created a “VertexShader” and a “PixelShader” class I was so confused thinking to myself “But wait a minute… Those two things could just be grouped together as one ‘Shader’ class” and then as I soon as I started to implement the design myself… Something clicked inside of me! Those two classes are of course better off separate because we as programmers are horrible at seeing the future.

    Once again, I am thrilled to have found these articles.

    Thank you!

    Reply
    1. Andy Post author

      Thank you so much for the encouragement. A year later, it really is about time to do the next article in the series.

      My recent work is in task-based multithreading. Perhaps it is time for a particle system post.

      Reply

Leave a Reply to Mathew Cancel reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.