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.

// ...
 
// Encapsulates an index buffer
class IndexBuffer
{
public:
 
	// virtual destructor to ensure subclasses have a virtual destructor
	virtual ~IndexBuffer() {}
 
protected:
 
	// protected default constructor to ensure these are never created
	// directly
	IndexBuffer() {}
};
 
class RenderDevice
{
public:
 
	// ...
 
	// Create an index buffer
	virtual IndexBuffer *CreateIndexBuffer(long long size,
		const void *data = nullptr) = 0;
 
	// Destroy an index buffer
	virtual void DestroyIndexBuffer(IndexBuffer *indexBuffer) = 0;
 
	// Set an index buffer as active for subsequent draw commands
	virtual void SetIndexBuffer(IndexBuffer *indexBuffer) = 0;
 
	// ...
 
	// Draw a collection of triangles using the currently active
	// shader pipeline, vertex array data, and index buffer
	virtual void DrawTrianglesIndexed32(long long offset,
		int count) = 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.

// ...
 
// Encapsulates a shader pipeline uniform parameter
class PipelineParam
{
public:
 
	// virtual destructor to ensure subclasses have a virtual destructor
	virtual ~PipelineParam() {}
 
	virtual void SetAsInt(int value) = 0;
 
	virtual void SetAsFloat(float value) = 0;
 
	virtual void SetAsMat4(const float *value) = 0;
 
	virtual void SetAsIntArray(int count, const int *values) = 0;
 
	virtual void SetAsFloatArray(int count, const float *values) = 0;
 
	virtual void SetAsMat4Array(int count, const float *values) = 0;
 
protected:
 
	// protected default constructor to ensure these are never created
	// directly
	PipelineParam() {}
};
 
class Pipeline
{
public:
 
	// ...
 
	virtual PipelineParam *GetParam(const char *name) = 0;
 
	// ...
};

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

// ...
 
// Encapsulates a 2D texture
class Texture2D
{
public:
 
	// virtual destructor to ensure subclasses have a virtual destructor
	virtual ~Texture2D() {}
 
protected:
 
	// protected default constructor to ensure these are never created
	// directly
	Texture2D() {}
};
 
class RenderDevice
{
public:
 
	// ...
 
	// Create a 2D texture
	//
	// data is assumed to consist of 32-bit pixel values where
	// 8 bits are used for each of the red, green, and blue
	// components, from lowest to highest byte order. The
	// most significant byte is ignored.
	virtual Texture2D *CreateTexture2D(int width, int height,
		const void *data = nullptr) = 0;
 
	// Destroy a 2D texture
	virtual void DestroyTexture2D(Texture2D *texture2D) = 0;
 
	// Set a 2D texture as active on a slot for subsequent draw commands
	virtual void SetTexture2D(unsigned int slot, Texture2D *texture2D) = 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 the rasterizer state
class RasterState
{
public:
 
	// virtual destructor to ensure subclasses have a virtual destructor
	virtual ~RasterState() {}
 
protected:
 
	// protected default constructor to ensure these are never created
	// directly
	RasterState() {}
};
 
enum Winding
{
	WINDING_CW = 0,
	WINDING_CCW,
	WINDING_MAX
};
 
enum Face
{
	FACE_FRONT = 0,
	FACE_BACK,
	FACE_FRONT_AND_BACK,
	FACE_MAX
};
 
enum RasterMode
{
	RASTERMODE_POINT = 0,
	RASTERMODE_LINE,
	RASTERMODE_FILL,
	RASTERMODE_MAX
};
 
class RenderDevice
{
public:
 
	// ...
 
	// Create a raster state.
	virtual RasterState *CreateRasterState(bool cullEnabled = true,
		Winding frontFace = WINDING_CCW, Face cullFace = FACE_BACK,
		RasterMode rasterMode = RASTERMODE_FILL) = 0;
 
	// Destroy a raster state.
	virtual void DestroyRasterState(RasterState *rasterState) = 0;
 
	 // Set a raster state for subsequent draw commands
	virtual void SetRasterState(RasterState *rasterState) = 0;
 
	// ...
}

Following are the additions to our main loop.

// ...
 
// Encapsulates the depth/stencil state
class DepthStencilState
{
public:
 
	// virtual destructor to ensure subclasses have a virtual destructor
	virtual ~DepthStencilState() {}
 
protected:
 
	// protected default constructor to ensure these are never created
	// directly
	DepthStencilState() {}
};
 
// An enum of values describing the test comparison function.
enum Compare
{
	// Test comparison never passes
	COMPARE_NEVER = 0,
 
	// Test comparison passes if the incoming value is less than the
	// stored value.
	COMPARE_LESS,
 
	// Test comparison passes if the incoming value is equal to the
	// stored value.
	COMPARE_EQUAL,
 
	// Test comparison passes if the incoming value is less than or
	// equal to the stored value.
	COMPARE_LEQUAL,
 
	// Test comparison passes if the incoming value is greater than
	// the stored value.
	COMPARE_GREATER,
 
	// Test comparison passes if the incoming value is not equal to
	// the stored value.
	COMPARE_NOTEQUAL,
 
	// Test comparison passes if the incoming value is greater than
	// or equal to the stored value.
	COMPARE_GEQUAL,
 
	// Test comparison always passes.
	COMPARE_ALWAYS,
 
	COMPARE_MAX
};
 
enum StencilAction
{
	// Keeps the current value.
	STENCIL_KEEP = 0,
 
	// Sets the stencil buffer to zero.
	STENCIL_ZERO,
 
	// Sets the stencil buffer to the reference value masked with
	// the write mask.
	STENCIL_REPLACE,
 
	// Increments the current stencil buffer value and clamps to
	// maximum unsigned value.
	STENCIL_INCR,
 
	// Increments the current stencil buffer value and wraps the
	// stencil buffer to zero when passing the maximum representable unsigned value.
	STENCIL_INCR_WRAP,
 
	// Decrements the current stencil buffer value and clamps to
	// zero.
	STENCIL_DECR,
 
	// Decrements the current stencil buffer value and wraps the
	// stencil buffer value to the maximum unsigned value.
	STENCIL_DECR_WRAP,
 
	// Bitwise invert of the current stencil buffer value.
	STENCIL_INVERT,
 
	STENCIL_MAX
};
 
class RenderDevice
{
public:
 
	// ...
 
	// Create a depth/stencil state.
	virtual DepthStencilState *CreateDepthStencilState(bool depthEnabled = true,
		bool depthWriteEnabled = true, float depthNear = 0, float depthFar = 1,
		Compare depthCompare = COMPARE_LESS, bool frontFaceStencilEnabled = false,
		Compare frontFaceStencilCompare = COMPARE_ALWAYS,
		StencilAction frontFaceStencilFail = STENCIL_KEEP,
		StencilAction frontFaceStencilPass = STENCIL_KEEP,
		StencilAction frontFaceDepthFail = STENCIL_KEEP,
		int frontFaceRef = 0, unsigned int frontFaceReadMask = 0xFFFFFFFF,
		unsigned int frontFaceWriteMask = 0xFFFFFFFF,
		bool backFaceStencilEnabled = false,
		Compare backFaceStencilCompare = COMPARE_ALWAYS,
		StencilAction backFaceStencilFail = STENCIL_KEEP,
		StencilAction backFaceStencilPass = STENCIL_KEEP,
		StencilAction backFaceDepthFail = STENCIL_KEEP,
		int backFaceRef = 0, unsigned int backFaceReadMask = 0xFFFFFFFF,
		unsigned int backFaceWriteMask = 0xFFFFFFFF) = 0;
 
	// Destroy a depth/stencil state.
	virtual void DestroyDepthStencilState(DepthStencilState *depthStencilState) = 0;
 
	// Set a depth/stencil state for subsequent draw commands
	virtual void SetDepthStencilState(DepthStencilState *depthStencilState) = 0;
 
	// ...
}

Draw Commands

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

// ...
 
const char *vertexShaderSource = "#version 410 core\n"
	"uniform mat4 uModel;\n"
	"uniform mat4 uView;\n"
	"uniform mat4 uProjection;\n"
	"layout (location = 0) in vec3 aPos;\n"
	"layout (location = 1) in vec2 aTexCoord;\n"
	"out vec2 FragTexCoord;\n"
	"void main()\n"
	"{\n"
	"   gl_Position = uProjection * uView * uModel * vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
	"   FragTexCoord = aTexCoord;\n"
	"}";
const char *pixelShaderSource = "#version 410 core\n"
	"uniform sampler2D uTextureSampler;\n"
	"in vec2 FragTexCoord;\n"
	"out vec4 FragColor;\n"
	"void main()\n"
	"{\n"
	"   FragColor = vec4(texture(uTextureSampler, FragTexCoord).rgb, 1);\n"
	"}\n";
 
struct Vertex
{
	float x, y, z;
	float u, v;
};
 
#define COUNT_OF(arr)	(sizeof(arr) / sizeof(*arr))
 
int main()
{
	// ...
 
	// For Sampler2D objects, we bind integers representing the texture
	// slot number to use
	render::PipelineParam *param = pipeline->GetParam("uTextureSampler");
	if(param)
		param->SetAsInt(0);
 
	// Get shader parameter for model matrix; we will set it every frame
	render::PipelineParam *uModelParam =
		pipeline->GetParam("uModel");
 
	// Get shader parameter for view matrix; we will set it every frame
	render::PipelineParam *uViewParam =
		pipeline->GetParam("uView");
 
	// Get shader parameter for projection matrix; we will set it every frame
	render::PipelineParam *uProjectionParam =
		pipeline->GetParam("uProjection");
 
	// ...
 
	// Our vertices now have 2D texture coordinates
	Vertex vertices[] = {
		// front
		-0.5f, -0.5f,  0.5f, 0, 1,
		 0.5f, -0.5f,  0.5f, 1, 1,
		 0.5f,  0.5f,  0.5f, 1, 0,
		-0.5f,  0.5f,  0.5f, 0, 0,
 
		// right
		 0.5f, -0.5f,  0.5f, 0, 1,
		 0.5f, -0.5f, -0.5f, 1, 1,
		 0.5f,  0.5f, -0.5f, 1, 0,
		 0.5f,  0.5f,  0.5f, 0, 0,
 
		// top
		-0.5f,  0.5f,  0.5f, 0, 1,
		 0.5f,  0.5f,  0.5f, 1, 1,
		 0.5f,  0.5f, -0.5f, 1, 0,
		-0.5f,  0.5f, -0.5f, 0, 0,
 
		// back
		 0.5f, -0.5f, -0.5f, 0, 1,
		-0.5f, -0.5f, -0.5f, 1, 1,
		-0.5f,  0.5f, -0.5f, 1, 0,
		 0.5f,  0.5f, -0.5f, 0, 0,
 
		// left
		-0.5f, -0.5f, -0.5f, 0, 1,
		-0.5f, -0.5f,  0.5f, 1, 1,
		-0.5f,  0.5f,  0.5f, 1, 0,
		-0.5f,  0.5f, -0.5f, 0, 0,
 
		// bottom
		-0.5f, -0.5f,  0.5f, 0, 1,
		-0.5f, -0.5f, -0.5f, 1, 1,
		 0.5f, -0.5f, -0.5f, 1, 0,
		 0.5f, -0.5f,  0.5f, 0, 0
	};
 
	render::VertexBuffer *vertexBuffer =
		renderDevice->CreateVertexBuffer(sizeof(vertices), vertices);
 
	render::VertexElement vertexElements[] = {
		{ 0, render::VERTEXELEMENTTYPE_FLOAT, 3, sizeof(Vertex), 0 },
		{ 1, render::VERTEXELEMENTTYPE_FLOAT, 2, sizeof(Vertex), 12 }
	};
	render::VertexDescription *vertexDescription =
		renderDevice->CreateVertexDescription(COUNT_OF(vertexElements), vertexElements);
 
	// ...
 
	// Setup indices and create index buffer
	uint32_t indices[] = {
		// front
		0, 1, 2, 0, 2, 3,
 
		// right
		4, 5, 6, 4, 6, 7,
 
		// top
		8, 9, 10, 8, 10, 11,
 
		// back
		12, 13, 14, 12, 14, 15,
 
		// left
		16, 17, 18, 16, 18, 19,
 
		// bottom
		20, 21, 22, 20, 22, 23
	};
 
	render::IndexBuffer *indexBuffer =
		renderDevice->CreateIndexBuffer(sizeof(indices), indices);
 
	// create texture
	render::Texture2D *texture2D =
		renderDevice->CreateTexture2D(BMPWIDTH, BMPHEIGHT, image32);
 
	while(platform::PollPlatformWindow(window))
	{
		glm::mat4 model(glm::uninitialize);
		glm::mat4 view(glm::uninitialize);
		glm::mat4 projection(glm::uninitialize);
		platform::GetPlatformViewport(model, view, projection);
 
		uModelParam->SetAsMat4(glm::value_ptr(model));
		uViewParam->SetAsMat4(glm::value_ptr(view));
		uProjectionParam->SetAsMat4(glm::value_ptr(projection));
 
		renderDevice->Clear(0.2f, 0.3f, 0.3f);
 
		// Set the texture for slot 0
		renderDevice->SetTexture2D(0, texture2D);
 
		// ...
 
		// Set the index buffer
		renderDevice->SetIndexBuffer(indexBuffer);
 
		// Draw assuming index buffer consists of 32-bit, unsigned integers
		renderDevice->DrawTrianglesIndexed32(0, COUNT_OF(indices));
 
		// ...
	}
 
	renderDevice->DestroyTexture2D(texture2D);
	renderDevice->DestroyIndexBuffer(indexBuffer);
 
	// ...
}

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

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.