Part 4 - Colors and Uniforms Image

#Part 4 - Colors and Uniforms

Elliot Forbes Elliot Forbes · May 29, 2026 · 4 min read

So far our triangle is static. In this part we add a CPU-to-GPU data channel called a uniform buffer, and use it to animate the triangle’s colour tint every frame.

What is a Uniform Buffer?

A uniform is a value that stays constant across all vertices and fragments in a single draw call, but can be updated between frames. It’s how you pass per-frame data — time, camera position, colour — from your Rust code into the shader.

This is different from vertex attributes, which vary per vertex. A uniform is the same value for every vertex and every pixel in one draw call.

The ColorUniform Struct

#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
struct ColorUniform {
    tint: [f32; 4],
}

We use four floats (vec4) rather than three because the GPU has alignment rules that can cause vec3 fields to behave unexpectedly when packed into a uniform buffer. Four floats always align correctly.

Updating the WGSL Shader

Declare the uniform in the shader with @group(0) @binding(0):

struct ColorUniform {
    tint: vec4<f32>,
}

@group(0) @binding(0)
var<uniform> color: ColorUniform;

Then use it in the fragment shader:

@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
    return vec4<f32>(in.color, 1.0) * color.tint;
}

Multiplying the vertex colour by the tint lets us shift the colour from Rust without touching the vertex data.

The Bind Group

A bind group is how you wire a buffer (or texture) to a shader binding slot. Creating one takes three steps:

1. Bind group layout — describes what bindings exist and which shader stages can see them:

let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
    label: Some("Color Bind Group Layout"),
    entries: &[wgpu::BindGroupLayoutEntry {
        binding: 0,
        visibility: wgpu::ShaderStages::FRAGMENT,
        ty: wgpu::BindingType::Buffer {
            ty: wgpu::BufferBindingType::Uniform,
            has_dynamic_offset: false,
            min_binding_size: None,
        },
        count: None,
    }],
});

2. Bind group — the actual pairing of a concrete buffer with the layout:

let color_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
    label: Some("Color Bind Group"),
    layout: &bind_group_layout,
    entries: &[wgpu::BindGroupEntry {
        binding: 0,
        resource: color_buffer.as_entire_binding(),
    }],
});

3. Wire it into the pipeline layout so the shader can access it:

let render_pipeline_layout =
    device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
        label: Some("Render Pipeline Layout"),
        bind_group_layouts: &[&bind_group_layout],
        push_constant_ranges: &[],
    });

The BindGroupLayout must outlive the BindGroup that references it. Rust’s lifetime rules make this a compile-time guarantee — if you accidentally drop the layout first, the compiler refuses to compile.

The update() Method

Each frame, we compute a new tint value and write it to the GPU with queue.write_buffer:

fn update(&mut self) {
    let t = self.start_time.elapsed().as_secs_f32();
    let tint = ColorUniform {
        tint: [(t.sin() * 0.5 + 0.5), (t.cos() * 0.5 + 0.5), 1.0, 1.0],
    };
    self.queue
        .write_buffer(&self.color_buffer, 0, bytemuck::cast_slice(&[tint]));
}

write_buffer copies bytes from CPU memory to the GPU buffer. The sin and cos functions oscillate between 0 and 1, producing a smooth colour shift over time.

Running It

cargo run

The triangle should now pulse with shifting red and green tints, cycling smoothly as time progresses. Try changing the tint formula to get different animation patterns.

What’s Next

In Part 5 we load an image file and display it on a quad, introducing textures, samplers, and index buffers.

Further reading:

FAQ

What is a uniform buffer? A uniform buffer is a block of GPU memory that holds values constant across an entire draw call. You update it from the CPU between frames using queue.write_buffer, and the shader reads from it on the GPU.

What is a bind group? A bind group is the mechanism that connects CPU-side resources (buffers, textures, samplers) to shader binding slots. You declare the slot layout with a BindGroupLayout, then create a BindGroup that maps concrete resources to those slots.

What does queue.write_buffer do? It copies bytes from a Rust slice into a GPU buffer. The data isn’t immediately visible on the GPU — it’s queued and applied before the next draw call that reads the buffer.

Why use vec4 instead of vec3 for the colour tint? WGSL and GLSL have alignment rules that require vec3 uniform fields to be padded to 16 bytes anyway. Using vec4 makes the layout explicit and avoids subtle bugs where your Rust struct and the shader disagree on where data starts.