C++ Conditional Compiling Using constexpr and Templates
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 template
s 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!