Index Buffer#

Resulting code: step034

Resulting code: step034-vanilla

The index buffer is used to separate the list of vertex attributes from the actual order in which they are connected. To illustrate its interest, let us draw a square, which is made of 2 triangles.

../../_images/quad-light.plain.svg../../_images/quad-dark.plain.svg

Index data#

A straightforward way of doing this is as follows:

std::vector<float> vertexData = {
    // Triangle #0
    -0.5, -0.5, // A
    +0.5, -0.5,
    +0.5, +0.5, // C

    // Triangle #1
    -0.5, -0.5, // A
    +0.5, +0.5, // C
    -0.5, +0.5,
};

But as you can see some data is duplicated. And this duplication could be much worst on larger shapes with connected triangles.

A more compact way of expressing the square’s geometry is to separate the position from the connectivity:

// The de-duplicated list of point positions
std::vector<float> pointData = {
    -0.5, -0.5, // A
    +0.5, -0.5,
    +0.5, +0.5, // C
    -0.5, +0.5,
};

// This is a list of indices referencing positions in the pointData
std::vector<uint16_t> indexData = {
    0, 1, 2, // Triangle #0
    0, 2, 3  // Triangle #1
};

int indexCount = static_cast<int>(indexData.size());

The index data must have type uint16_t or uint32_t. The former is more compact but limited to \(2^{16} = 65 536\) vertices.

Note

I also keep the interleaved color attribute in this example, my vertex data is:

std::vector<float> vertexData = {
    // x,   y,     r,   g,   b
    -0.5, -0.5,   1.0, 0.0, 0.0,
    +0.5, -0.5,   0.0, 1.0, 0.0,
    +0.5, +0.5,   0.0, 0.0, 1.0,
    -0.5, +0.5,   1.0, 1.0, 0.0
};

Using the index buffer adds an overhead of 6 * sizeof(uint16_t) = 12 bytes but also saves 2 * 5 * sizeof(float) = 40 bytes, so even on this very simple example it is worth using.

Buffer creation#

Of course the index data must be stored in a GPU-side buffer. This buffer needs a usage of BufferUsage::Index.

// Create index buffer
// (we reuse the bufferDesc initialized for the vertexBuffer)
bufferDesc.size = indexData.size() * sizeof(uint16_t);
bufferDesc.usage = BufferUsage::CopyDst | BufferUsage::Index;
Buffer indexBuffer = device.createBuffer(bufferDesc);

queue.writeBuffer(indexBuffer, 0, indexData.data(), bufferDesc.size);
// Create index buffer
// (we reuse the bufferDesc initialized for the vertexBuffer)
bufferDesc.size = indexData.size() * sizeof(uint16_t);
bufferDesc.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_Index;;
WGPUBuffer indexBuffer = wgpuDeviceCreateBuffer(device, &bufferDesc);

wgpuQueueWriteBuffer(queue, indexBuffer, 0, indexData.data(), bufferDesc.size);

Important

A writeBuffer operation must copy a number of bytes that is a multiple of 4. To ensure this, we must ceil the buffer size up to the next multiple of 4 before creating it:

bufferDesc.size = (bufferDesc.size + 3) & ~3; // round up to the next multiple of 4

Render pass#

To draw with an index buffer, there are two changes in the render pass encoding:

  1. Set the active index buffer with renderPass.setIndexBuffer.

  2. Replace draw() with drawIndexed().

// Set both vertex and index buffers
renderPass.setVertexBuffer(0, vertexBuffer, 0, pointData.size() * sizeof(float));
// The second argument must correspond to the choice of uint16_t or uint32_t
// we've done when creating the index buffer.
renderPass.setIndexBuffer(indexBuffer, IndexFormat::Uint16, 0, indexData.size() * sizeof(uint16_t));

// Replace `draw()` with `drawIndexed()` and `vertexCount` with `indexCount`
// The extra argument is an offset within the index buffer.
renderPass.drawIndexed(indexCount, 1, 0, 0, 0);
// Set both vertex and index buffers
wgpuRenderPassEncoderSetVertexBuffer(renderPass, 0, vertexBuffer, 0, pointData.size() * sizeof(float));
// The second argument must correspond to the choice of uint16_t or uint32_t
// we've done when creating the index buffer.
wgpuRenderPassEncoderSetIndexBuffer(renderPass, indexBuffer, WGPUIndexFormat_Uint16, 0, indexData.size() * sizeof(uint16_t));

// Replace `draw()` with `drawIndexed()` and `vertexCount` with `indexCount`
// The extra argument is an offset within the index buffer.
wgpuRenderPassEncoderDrawIndexed(renderPass, indexCount, 1, 0, 0, 0);
../../_images/deformed-quad.png

The square is deformed.#

Ratio correction#

The square we obtained is deformed because its coordinates are expressed relative to the window’s dimensions. This can be fixed by multiplying one of the coordinates by the ratio of the window \(640/480\).

We could do this either in the initial vertex data vector, but this will require is to update these values whenever the window dimension changes. A more interesting option is to use the power of the vertex shader:

// In vs_main():
let ratio = 640.0 / 480.0; // The width and height of the target surface
out.position = vec4f(in.position.x, in.position.y * ratio, 0.0, 1.0);

Although basic, this is a first step towards what will be the key use of the vertex shader when introducing 3D transforms.

Note

It might feel a little unsatisfying to hard-code the window resolution in the shader like this, but we will quickly see how to make this more flexible thanks to uniforms.

../../_images/quad.png

The expected square#

Conclusion#

Using an index buffer is a rather simple concept in the end, and can save a lot of VRAM. Additionally, it corresponds to the way traditional formats usually encode 3D meshes.

Resulting code: step034

Resulting code: step034-vanilla