1.5 Drawing a Colored Triangle with a Single Buffer

In our previous example, we used two separate buffers and two locations to provide positions and colors for vertices. However, using multiple buffers isn't always necessary or optimal. In this modified example, we'll demonstrate how to use a single buffer to provide all vertex attributes.

Launch Playground - 1_05_colored_triangle_with_a_single_buffer

Let's focus on the key differences between this example and the previous one. Note that the shader code remains unchanged.

const positionAttribDesc = {
    shaderLocation: 0, // @location(0)
    offset: 0,
    format: 'float32x3'
};

const colorAttribDesc = {
    shaderLocation: 1, // @location(1)
    offset: 4 * 3,
    format: 'float32x3'
};

First, we modify the color attribute descriptor. We introduce an offset because we're interleaving position and color data. The offset signifies the beginning of the first color data within the buffer. Since we've placed color after vertex positions, the offset is set to 12 bytes (4 bytes * 3), which is the size of a vertex position vector.

const positionColorBufferLayoutDesc = {
    attributes: [positionAttribDesc, colorAttribDesc],
    arrayStride: 4 * 6, // sizeof(float) * 3
    stepMode: 'vertex'
};

Secondly, instead of creating separate buffers for positions and colors, we now use a single buffer called positionColorBuffer. When creating the descriptor for this buffer, we include both attributes in the attribute list. The arrayStride is set to 24 bytes (4 * 6) instead of 12, because each vertex now has 6 float numbers associated with it (3 for position, 3 for color).

const positionColors = new Float32Array([
    1.0, -1.0, 0.0, // position
    1.0, 0.0, 0.0, // 🔴
    -1.0, -1.0, 0.0, 
    0.0, 1.0, 0.0, // 🟢
    0.0, 1.0, 0.0,
    0.0, 0.0, 1.0 // 🔵
]);

let positionColorBuffer = createGPUBuffer(device, positionColors, GPUBufferUsage.VERTEX);

When creating data for this buffer, we supply 18 floating-point numbers (3 vertices * 6 floats per vertex), with positions and colors interleaved:

const pipelineDesc = {
    layout,
    vertex: {
        module: shaderModule,
        entryPoint: 'vs_main',
        buffers: [positionColorBufferLayoutDesc]
    },
    fragment: {
        module: shaderModule,
        entryPoint: 'fs_main',
        targets: [colorState]
    },
    primitive: {
        topology: 'triangle-list',
        frontFace: 'cw',
        cullMode: 'back'
    }
};
• • •
passEncoder = commandEncoder.beginRenderPass(renderPassDesc);
passEncoder.setViewport(0, 0, canvas.width, canvas.height, 0, 1);
passEncoder.setPipeline(pipeline);
passEncoder.setVertexBuffer(0, positionColorBuffer);
passEncoder.draw(3, 1);
passEncoder.end();

In the pipeline descriptor, we now only have one buffer descriptor in the buffers field. When encoding the render command, we set only one buffer at slot zero:

This program is functionally equivalent to the previous one, but it uses a single buffer instead of two. Using a single buffer in this case is more efficient because it avoids creating extra resources and eliminates the need to copy data twice from the CPU to the GPU. Transferring data from CPU to GPU incurs latency, so minimizing these transfers is beneficial for performance.

You might wonder when to use multiple buffers versus a single buffer. It depends on how frequently the attributes change. In this example, both the positions and colors remain constant throughout execution, making a single buffer suitable. However, if we need to update vertex colors frequently, perhaps in an animation, separating the attributes into different buffers might be preferable. This way, we can update color data without transferring the unchanged position data to the GPU.

Leave a Comment on Github