C++ Conditional Compiling Using constexpr and Templates

Written on July 9, 2020

Estimated Read Time: ~5 minutes

After another year of game industry C++ experience coupled with watching the CppCon 2017: constexpr ALL the Things! talk of Jason Turner & Ben Deane, I decided it is time to have some meta programming fun using the C++17 constexpr features while revisiting some old code and making improvements on it.

Problem

To give some context:

  • I’m generating some data that represents a triangle in 3D space
    • Consists of a collection of Vertices & Indices
  • In addition to the 3D position, the Vertices can have some additional attributes such as
    • texture coordinates (uv)
    • normal/tangent vectors
    • color etc.
struct FVertex
{
    float position[3];
    float normal[3];
    float tangent[3];
    float uv[2];
    float color[3];
};

//  Bitangent
// 
// ^  
// |                  V1 (uv1)
// |                  ^
// |                 / \ 
// |                /   \
// |               /     \
// |              /       \ 
// |             /         \
// |            /           \
// |           /             \
// |          /               \
// |         /                 \
// |    V0   ___________________ V2 
// |   (uv0)                    (uv2)
// ----------------------------------------->  Tangent
Mesh GeometryGenerator::Triangle(float scale)
{
    const float& size = scale;

    vector<FVertex> vertices(3);
    vector<unsigned> indices = { 0, 1, 2 };

    // vertices - Clockwise
    vertices[0].position    = vec3(-size, -size, 0.0f);
    vertices[0].normal      = vec3(0, 0, -1);
    vertices[0].uv          = vec2(0.0f, 1.0f);
    vertices[0].color       = vec3(1, 0, 0);

    vertices[1].position    = vec3(0, size, 0.0f);
    vertices[1].normal      = vec3(0, 0, -1);
    vertices[1].uv          = vec2(0.5f, 0.0f);
    vertices[1].color       = vec3(0, 1, 0);

    vertices[2].position    = vec3(size, -size, 0.0f);
    vertices[2].normal      = vec3(0, 0, -1);
    vertices[2].uv          = vec2(1.0f, 1.0f);
    vertices[2].color       = vec3(0, 0, 1);
    
    // some vector math that writes into vector[i].tangent
    CalculateTangentsAndBitangents(vertices, indices); 

    return Mesh(vertices, indices, "BuiltinTriangle");
}

Let’s ignore the details of conversions from vec3 to float[3] and the Mesh struct for simplicity

At the time this function was written, it was good enough to have ALL the vertices of the same data layout for the scope of the project. However, not all vertices actually needed all the attributes. This can be bad! Having redundant/unused data in the vertex buffer will lead to less optimized cache & bandwidth usage on the GPU when rendering geometry, which can result in a performance penalty depending on the application’s graphics workload.

In practice, (Positions + Texture Coordinates) would suffice while rendering a fullscreen triangle for Ambient Occlusion, Post Processing & Deferred Lighting passes. On the other hand, the Normal Vectors would be needed in addition to the (Positions + Texture Coordinates) for the lighting calculations if the triangle was being used in the 3D scene and was lit.

Template metaprogramming to the rescue!

Solution

If we slice up the vertex data, we end up using a combination of the vertex attributes:

struct FVertexDefault
{
    float position[3];
    float uv[2];
};
struct FVertexWithColor
{
    float position[3];
    float color[3];
    float uv[2];
};
struct FVertexWithColorAndAlpha
{
    float position[3];
    float color[4];
    float uv[2];
};
struct FVertexWithNormal
{
    float position[3];
    float normal[3];
    float uv[2];
};
struct FVertexWithNormalAndTangent
{
    float position[3];
    float normal[3];
    float tangent[3];
    float uv[2];
};

Now that we have different types of Vertices, we will need to use templates when representing the geometry data. While we’re at templating the vertices, why not do the same for indices too? After all, they can be either 16-bits or 32-bits wide.

Let’s define a generic data type representing the geometry:

template<class TVertex, class TIndex = unsigned> // 32-bit indices by default
struct GeometryData
{
	// std library provides std::is_same<T1, T2> to check if the two types are the same
	// static_assert allows us to ensure type safety on compile time
	static_assert( // ensure UINT32 or UINT16 indices
		   std::is_same<TIndex, unsigned>() 
		|| std::is_same<TIndex, unsigned short>()
	); 

	std::vector<TVertex>  Vertices;
	std::vector<TIndex>   Indices;
};

Next, refactoring the original geometry generator function to

  • be generic for all Vertex and Index types by taking in template arguments
  • change the return type to the generic geometry data type we just defined
template<class TVertex, class TIndex>
GeometryData<TVertex, TIndex> Triangle(float size)
{
    GeometryData<TVertex, TIndex> data;
    // ...
    return data;
}

We can adjust the triangle generation algorithm for the generic vertex data type as follows:

  • assign positions
  • assign uv texture coordinates
  • assign normals IF vertex has normals
  • assign tangents IF vertex has tangents
  • assign color IF vertex has color

C++17 expands the context of constexpr and makes it usable with if/else blocks to allow for conditional compilation. We can use constexpr if in conjunction with the std::is_same<T1, T2> shown earlier to make the compiler generate code based on the Vertex type provided by the template argument TVertex.

We start with defining some compile-time bool variables, then calculate the indices, position and uv as they’re shared by all the vertex types:

template<class TVertex, class TIndex>
constexpr GeometryData<TVertex, TIndex> Triangle(float size)
{
    constexpr bool bHasTangents = std::is_same<TVertex, FVertexWithNormalAndTangent>();
    constexpr bool bHasNormals  = std::is_same<TVertex, FVertexWithNormal>() || std::is_same<TVertex, FVertexWithNormalAndTangent>();
    constexpr bool bHasColor    = std::is_same<TVertex, FVertexWithColor>()  || std::is_same<TVertex, FVertexWithColorAndAlpha>();
    constexpr bool bHasAlpha    = std::is_same<TVertex, FVertexWithColorAndAlpha>();

    GeometryData<TVertex, TIndex> data;
    
    // indices
    data.Indices = { 0u, 1u, 2u };

    // position
    data.Vertices[0].position = vec3(-size, -size, 0.0f);
    data.Vertices[1].position = vec3( 0.0f,  size, 0.0f);
    data.Vertices[2].position = vec3( size, -size, 0.0f);
	
    // uv
    data.Vertices[0].uv = vec2(0.0f, 1.0f);
    data.Vertices[1].uv = vec2(0.5f, 0.0f);
    data.Vertices[2].uv = vec2(1.0f, 1.0f);

    // ...

    return data;
}

Finally, we can use the constexpr if for the conditional compilation.

The complete function would look like this:

//  Bitangent
// 
// ^  
// |                  V1 (uv1)
// |                  ^
// |                 / \ 
// |                /   \
// |               /     \
// |              /       \ 
// |             /         \
// |            /           \
// |           /             \
// |          /               \
// |         /                 \
// |    V0   ___________________ V2 
// |   (uv0)                    (uv2)
// ----------------------------------------->  Tangent
template<class TVertex, class TIndex>
constexpr GeometryData<TVertex, TIndex> Triangle(float size)
{
    constexpr bool bHasTangents = std::is_same<TVertex, FVertexWithNormalAndTangent>();
    constexpr bool bHasNormals  = std::is_same<TVertex, FVertexWithNormal>() || std::is_same<TVertex, FVertexWithNormalAndTangent>();
    constexpr bool bHasColor    = std::is_same<TVertex, FVertexWithColor>()  || std::is_same<TVertex, FVertexWithColorAndAlpha>();
    constexpr bool bHasAlpha    = std::is_same<TVertex, FVertexWithColorAndAlpha>();

    GeometryData<TVertex, TIndex> data;
    
    // indices
    data.Indices = { 0u, 1u, 2u };

    // position
    data.Vertices[0].position = vec3(-size, -size, 0.0f);
    data.Vertices[1].position = vec3( 0.0f,  size, 0.0f);
    data.Vertices[2].position = vec3( size, -size, 0.0f);
	
    // uv
    data.Vertices[0].uv = vec2(0.0f, 1.0f);
    data.Vertices[1].uv = vec2(0.5f, 0.0f);
    data.Vertices[2].uv = vec2(1.0f, 1.0f);

    // normals
    if constexpr (bHasNormals)
    {
    	constexpr vec3 NormalVec = vec3{ 0, 0, -1 };
        data.Vertices[0].normal = NormalVec;
        data.Vertices[1].normal = NormalVec;
        data.Vertices[2].normal = NormalVec;
    }
    
    // color
    if constexpr (bHasColor)
    {
    	data.Vertices[0].color = vec3(1.0f, 0.0f, 0.0f); // Red
    	data.Vertices[1].color = vec3(0.0f, 1.0f, 0.0f); // Green
    	data.Vertices[2].color = vec3(0.0f, 0.0f, 1.0f); // Blue
    	if constexpr (bHasAlpha)
    	{
            v[0].color[3] = 1.0f; // Alpha
            v[1].color[3] = 1.0f; // Alpha
            v[2].color[3] = 1.0f; // Alpha
    	}
    }
    
    // tangent
    if constexpr (bHasTangents)
    {
        CalculateTangents(data.Vertices, data.Indices); // constexpr function
    }
    
    return data;
}

Here’s an example how the caller would invoke the function with different vertex types:

void VQEngine::InitializeBuiltinMeshes()
{
    GeometryGenerator::GeometryData<FVertexWithColorAndAlpha>    dataTriangle = GeometryGenerator::Triangle<FVertexWithColorAndAlpha>(1.0f);
    GeometryGenerator::GeometryData<FVertexWithNormalAndTangent> dataCube     = GeometryGenerator::Cube<FVertexWithNormalAndTangent>();
    // ...
}

This setup will work as long as the vertex buffer definitions keep the naming consistent among different types.
In other words, there cannot be a vertex type with float position[3] and another with float pos[3]

With this small constraint, the compiler will generate code based on the vertex type, resulting in a more flexible geometry generator and I find this pretty neat!