C++ Rendering Engine I – Abstracting the Render Device

Additional Features

We can now clear the color buffer and render triangles in normalized device coordinates using a vertex and pixel shader.

Let’s round out the functionality a bit. On this page, we will implement support for index buffers, shader uniforms, textures, raster states, and depth/stencil states.

The results of everything on this page can be found at this commit: 9661cc0

Index Buffers

Index buffers are very straightforward and quite a bit simpler than vertex buffers. Index buffers only carry, well, indices of one of three types: 8-bit, 16-bit, and 32-bit. All of them unsigned.

Following is the added code to support index buffers.

// ...
 
// 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 Uniforms

In order to be able to render geometry in other than normalized device coordinates, we must have the ability to bind shader uniform variables.

We will query shader uniform variables by name in order to get an interface that lets us bind our variable using different types, such as int, float, float array, and 4×4 float matrix.

Following is the additional 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;
 
	// ...
};

Textures

For Textures, I am going to avoid dealing with image file formats in this article so we can stay focused on the render device abstraction. Therefore, our test image will be embedded as an array in C code.

Also, we are going to assume all textures coming in are three component RGB values using 8 bits per pixel. Red is the least significant byte and the highest significant byte is unused. Later, we will look at other texture formats.

To actually use a texture, we must do two things: 1) bind an integer to a sampler2D shader uniform parameter, and 2) bind the texture to a texture slot using the same integer.

Following is the additional interface elements supporting 2D textures.

// ...
 
// 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;
 
	// ...
}

Raster States

For raster states, we will group all related states into a single, encapsulating object. We do this because all of these states are likely to change at the same time. Also, changing any one of these states is likely to have the same impact on the graphics pipeline as changing all of them at once.

Take notice of the fact that the default arguments to CreateRasterState are the actual default values for a newly instantiated RenderDevice. This serves as the documentation of the default state values.

Following is the interface for raster states.

// ...
 
// 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;
 
	// ...
}

Depth/Stencil States

Again, we group all depth and stencil states since they are closely related and likely to change with the same frequency and performance impact.

Like with raster states, the default arguments for CreateDepthStencilState serve as documentation of the default state values.

Following is the interface for depth and stencil states.

// ...
 
// 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;
 
	// ...
}

Cube Sample

There exists a sample in the GitHub project, called Cube, that demonstrates a textured cube drawn using some of the functionality from this page. All of the code thus far is available at this commit: 9661cc0.

Using the left mouse button, you can rotate the cube. Using the mouse horizontal scroll wheel, you can zoom the camera in and out.

Following is the relevant code.

// ...
 
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);
 
	// ...
}

Next Steps

At this point, we have stood up a render device abstraction that we can build upon. Of course, we are missing blend states, uniform buffers, offscreen render targets, and other shaders (e.g. tessellation and geometry). I would implement those features in roughly that order. Also, our buffer data so far has always been static. We need to add support for dynamic updates.

If you want to be notified when these features get added to my GitHub project, please star it. These additions will be made even before I write more about it in this article. To be continued…

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.