This post will explore some real-world usage of the more exotic template metaprogramming features of C++11, 14, and 17. I think the resulting interface is quite nice and would not have been as convenient to provide without modern language features. The specific application in this example is an abstraction around the Vulkan vk::SpecializationInfo struct but the techniques should transfer well to other domains outside graphics as well. All the code described here is free to use as a simple single header here.

Problem Statement

In Vulkan, we often want to provide what are known as specialization constants to a shader. You can think of these as preprocessor definitions that will modify the layout and execution of the shader bytecode on the GPU. This data must be fed to the Vulkan shader module (an object that encapsulates the shader state) from the CPU.

All of this data should be held in a contiguous block of typeless memory. To specify the shape of the data, we create an array of specialization map entries (vk::SpecializationMapEntry) that contain three pieces of information a piece:

  1. The constant identifier for corresponding the map entry to the data in the shader
  2. The size of the data this entry refers to
  3. The offset of the entry in the block

For example, one might have a simplified shader below which contains a single specialization constant for specifying the number of instances rendered in a draw call:

#version 450

// Number of instances specified at runtime. Defaults to 1
layout (constant_id = 0) const uint k_instance_count = 1

layout (binding = 0, set = 1) uniform Camera
{
    mat4 projection;
    mat4 view;
};

layout (binding = 1, set = 0) uniform Model
{
    // We use the specialization constant here to determine how many model transform matrices should be
    // provided at draw time
    mat4 model[k_instance_count];
};

// Rest of the vertex shader code here

In C++, we’d provide the value of k_instance_count at pipeline creation time. Note that I’m using the C++ bindings to Vulkan (and you should too!) but C users will follow along just fine.

// Set the number of instances to 100
unsigned int instance_count = 100;

// We're just using one entry for now, but we present it as an array since
// we may have many entries in the general case (all of which are typed differently)
std::array<vk::SpecializationMapEntry, 1> entries = {
  vk::SpecializationMapEntry{
    0,                    // Constant ID
    0,                    // Offset
    sizeof(unsigned int), // Size
  }
};

vk::SpecializationInfo info{
  entries.size(),       // Entry count
  entries.data(),       // Entry data
  sizeof(unsigned int), // Total size of ALL the entry data
  &instance_count       // Pointer to the start of the data block
};

If we had more data to provide than just the instance count, we would need to ensure that all the data is packed appropriately. Obviously, there are a lot of pitfalls in this interface. We have to manage all the offsets and size bookkeeping ourselves, and with this much boilerplate, it’s easy to introduce bugs. This isn’t a fault of Vulkan itself, but just a natural consequence of dealing with type erasure boundaries.

Imagining a Solution

People that work with me know that I like to think about ideal interfaces upfront. Even if they aren’t always ultimately practical (either due to language constraints, or possibly time constraints), undergoing the mental exercise of imagining an ideal solution gives you a target definition of good you can pursue.

What I initially came up with was something like the following:

// Will construct map entries for an int and a float
// The int and float will be packed contiguously in memory
ShaderSpecialization<unsigned int, float> sp;

sp.set<0>(4);
sp.set<1>(2.5f);

// Returns the value of the unsigned int at index 0
unsigned int x = sp.get<0>();

// Returns a vk::SpecializationInfo object map
// Offsets are computed at compile time
vk::SpecializationInfo info = sp.info();

// Use the info above to construct your pipeline

With an interface like this, we can very easily create type-safe specializations!

The Code

template <typename... Ts>
class ShaderSpecialization
{
  // Write me please
};

Let’s start first with the data this will need to hold. The first order of business is to compute the total size of the data. That is, we wish to compute the sum of all the sizes of the types in the parameter pack Ts.

To do this we write a couple helper functions:

  template <typename T>
  static constexpr size_t size_helper()
  {
    static_assert(std::is_scalar<T>::value,
                  "Data put into specialization maps must be scalars");
    return sizeof(T);
  }

  template <typename T1, typename T2, typename... More>
  static constexpr size_t size_helper()
  {
    static_assert(std::is_scalar<T1>::value,
                  "Data put into specialization maps must be scalars");
    return sizeof(T1) + size_helper<T2, More...>();
  }

The static asserts ensure that we don’t try to place anything funky in the specialization map as these are not supported by the GL_KHR_vulkan_glsl extension. Next, we use these functions to initialize a static variable and our data:

  static constexpr size_t size = size_helper<Ts...>();
  std::array<uint8_t, size> m_data;

It should be relatively clear that calling size_helper will recursively fold over the types and reduce them down to the total byte size. Because this is a constexpr expression, we can use it to determine the size of m_data at compile time.

The next problem we need to solve is how to retrieve data from this array given an index. Because the array is untyped, we need to compute the byte location of a given entry. This is obviously the sum of all the byte sizes of the types that precede it. Since all the types are known at compile time, it’s clear that we should be able to compute the offsets at compile time as well.

Let’s start first with something that will give us the type at the Nth index. We can do this easily with:

  template <size_t N>
  using type = typename std::tuple_element<N, std::tuple<Ts...>>::type;

If you haven’t seen this before, it might be a little shocking as the type alias itself is templatized. This is totally legal :). With this, given some index, we can access the type in the parameter pack. Let’s use this to build our offset_of variable.

  static constexpr size_t count = sizeof...(Ts);
  
  template <size_t N>
  static constexpr size_t offset_of = offset_of_helper<0, N, 0>();

  template <size_t Index, size_t Max, size_t Offset>
  static constexpr size_t offset_of_helper()
  {
    if constexpr (Index == Max)
    {
      return Offset;
    }
    else
    {
      return offset_of_helper<Index + 1, Offset + sizeof(type<Index>)>();
    }
  }

Here, offset_of is another template variable (available since C++14), templatized on the index in the parameter pack. The offset_of_helper function is yet another recursive function that increments the index and accumulates type sizes to return the final offset. To termination condition uses an if constexpr to stop accumulating into the return value once we reach the desired index.

We can use offset_of to write a bunch of getters and setters:

  template <size_t N>
  auto get() const
  {
    type<N> value;
    std::memcpy(&value, m_data.data() + offset_of<N>, sizeof(type<N>));
    return value;
  }

  template <size_t N>
  void set(type<N> value)
  {
    std::memcpy(m_data.data() + offset_of<N>, &value, sizeof(type<N>));
  }

The purpose of the std::memcpy here is to prevent undefined behavior when aliasing memory.

Because of our type magic, these casts are nice and typesafe. The last thing we need to do now is provide a constructor. This constructor needs to initialize the vk::SpecializationInfo and vk::SpecializationMapEntry objects directly. Remember that because our offset_of quantity is a template variable, we cannot templatize it on a runtime constant. Thus, our constructor also needs to rely on compile-time code.

  // Data members
  vk::SpecializationInfo m_info;
  std::array<vk::SpecializationMapEntry, sizeof...(Ts)> m_entries;

  ShaderSpecialization()
  {
    m_info = vk::SpecializationInfo{
      count,                             // Map entry count
      m_entries.data(),                  // Map entries
      static_cast<vk::DeviceSize>(size), // Data size
      reinterpret_cast<void*>(m_data)    // Data
    };
    construct_helper<0>();
  }

  template <size_t Index>
  size_t construct_helper()
  {
    if constexpr (Index == count)
    {
      return;
    }
    else
    {
      m_entries[Index] = vk::SpecializationMapEntry{
        Index,              // Constant ID
        offset_of<Index>,   // Offset
        sizeof(type<Index>) // Size
      };
      construct_helper<Index + 1>();
    }
  }

It is very important to fix the copy and move constructors for this class. Because the specialization info takes a pointer to the start of the data block, this must be corrected when the class is moved and copied. These constructors are shown below:

  ShaderSpecialization(ShaderSpecialization&& other)
      : m_entries{std::move(other.m_entries)}
      , m_data{std::move(other.m_data)}
  {
    m_info = vk::SpecializationInfo{
      count,                                 // Map entry count
      m_entries.data(),                      // Map entries
      static_cast<vk::DeviceSize>(size),     // Data size
      reinterpret_cast<void*>(m_data.data()) // Data
    };
  }

  ShaderSpecialization(const ShaderSpecialization& other)
      : m_entries{other.m_entries}
      , m_data{other.m_data}
  {
    m_info = vk::SpecializationInfo{
      count,                                 // Map entry count
      m_entries.data(),                      // Map entries
      static_cast<vk::DeviceSize>(size),     // Data size
      reinterpret_cast<void*>(m_data.data()) // Data
    };
  }

  ShaderSpecialization& operator=(const ShaderSpecialization& other)
  {
    if (this != &other)
    {
      // We are careful not to copy the info and entry data
      m_data = other.m_data;
    }
    return *this;
  }

  ShaderSpecialization& operator=(ShaderSpecialization&& other)
  {
    if (this != &other)
    {
      // We are careful not to move the info and entry data
      m_data = std::move(other.m_data);
    }
    return *this;
  }

In the case of the assignment operators, we can be sure that one of the other constructors ran so m_info and m_entries will contain the correct data and will not need modification.

Just like when we wrote offset_of, using if constexpr here lets us make a nice and simple compile time loop. We use recursion as before on construct_helper to initialize all the map entries to have the correct offsets and sizes using all the facilities we’ve written before. Finally, we slap an accessor for m_info and we’re done!

The code in its entirety, reformatted and with correct scoping is reproduced below:

#pragma once

#include <array>
#include <tuple>
#include <vulkan/vulkan.hpp>

template <typename... Ts>
class ShaderSpecialization
{
private:
    template <typename T>
    static constexpr size_t size_helper()
    {
        static_assert(std::is_scalar<T>::value,
                      "Data put into specialization maps must be scalars");
        return sizeof(T);
    }

    template <typename T1, typename T2, typename... More>
    static constexpr size_t size_helper()
    {
        static_assert(std::is_scalar<T1>::value,
                      "Data put into specialization maps must be scalars");
        return sizeof(T1) + size_helper<T2, More...>();
    }

    template <size_t Index, size_t Max, size_t Offset>
    static constexpr size_t offset_of_helper()
    {
        if constexpr (Index == Max)
        {
            return Offset;
        }
        else
        {
            return offset_of_helper<Index + 1, Offset + sizeof(type<Index>)>();
        }
    }

public:
    static constexpr size_t count = sizeof...(Ts);
    static constexpr size_t size = size_helper<Ts...>();

    template <size_t N>
    using type = typename std::tuple_element<N, std::tuple<Ts...>>::type;

    template <size_t N>
    static constexpr size_t offset_of = offset_of_helper<0, N, 0>();

    vk::SpecializationInfo& info()
    {
        return m_info;
    }

    template <size_t N>
    auto get() const
    {
        type<N> value;
        std::memcpy(&value, m_data.data() + offset_of<N>, sizeof(type<N>));
        return value;
    }

    template <size_t N>
    void set(type<N> value)
    {
        std::memcpy(m_data.data() + offset_of<N>, &value, sizeof(type<N>));
    }

    ShaderSpecialization()
    {
        construct_helper<0>();
        m_info = vk::SpecializationInfo{
            count,                                 // Map entry count
            m_entries.data(),                      // Map entries
            static_cast<vk::DeviceSize>(size),     // Data size
            reinterpret_cast<void*>(m_data.data()) // Data
        };
    }

    ShaderSpecialization(ShaderSpecialization&& other)
        : m_entries{std::move(other.m_entries)}
        , m_data{std::move(other.m_data)}
    {
        m_info = vk::SpecializationInfo{
            count,                                 // Map entry count
            m_entries.data(),                      // Map entries
            static_cast<vk::DeviceSize>(size),     // Data size
            reinterpret_cast<void*>(m_data.data()) // Data
        };
    }

    ShaderSpecialization(const ShaderSpecialization& other)
        : m_entries{other.m_entries}
        , m_data{other.m_data}
    {
        m_info = vk::SpecializationInfo{
            count,                                 // Map entry count
            m_entries.data(),                      // Map entries
            static_cast<vk::DeviceSize>(size),     // Data size
            reinterpret_cast<void*>(m_data.data()) // Data
        };
    }

    ShaderSpecialization& operator=(const ShaderSpecialization& other)
    {
        if (this != &other)
        {
            // We are careful not to copy the info and entry data
            m_data = other.m_data;
        }
        return *this;
    }

    ShaderSpecialization& operator=(ShaderSpecialization&& other)
    {
        if (this != &other)
        {
            // We are careful not to move the info and entry data
            m_data = std::move(other.m_data);
        }
        return *this;
    }

private:
    template <size_t Index>
    void construct_helper()
    {
        if constexpr (Index == count)
        {
            return;
        }
        else
        {
            m_entries[Index] = vk::SpecializationMapEntry{
                Index,              // Constant ID
                offset_of<Index>,   // Offset
                sizeof(type<Index>) // Size
            };
            construct_helper<Index + 1>();
        }
    }

    vk::SpecializationInfo m_info;
    std::array<vk::SpecializationMapEntry, count> m_entries;
    std::array<uint8_t, size> m_data;
};

To use the class, do something like the following:

// Define a specialization map containing 2 integers and a float
ShaderSpecialization<int, int, float> sp;

// Assign some values
sp.set<0>(4);
sp.set<1>(1);
sp.set<2>(93.2f);

// Access them if you want
std::cout << sp.get<0>() << std::endl;

// Use this to create your graphics or compute pipeline
// The data will be mapped to constant ids 0, 1, and 2 respectively
vk::SpecializationInfo& info = sp.info();