Part 3 - Drawing Your First Triangle Image

#Part 3 - Drawing Your First Triangle

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

The triangle is the “hello world” of graphics programming. In this part we define vertex data, upload it to the GPU, write our first shaders in WGSL, and draw a colourful triangle to the screen.

Add bytemuck to Cargo.toml before you start:

Cargo.toml
[dependencies]
wgpu = "0.20"
winit = "0.29"
pollster = "0.3"
env_logger = "0.11"
log = "0.4"
bytemuck = { version = "1.14", features = ["derive"] }

What is a Vertex Buffer?

The GPU doesn’t know about Rust structs. To draw geometry, you copy raw bytes into a vertex buffer — a block of GPU memory that the vertex shader reads one vertex at a time. We define a Vertex struct, fill a slice of them, and upload it.

The #[repr(C)] attribute tells Rust to lay out the struct exactly as a C compiler would, with no padding surprises. Pod (plain old data) and Zeroable from bytemuck certify that the bytes can be safely cast to a &[u8] without undefined behaviour.

use bytemuck::{Pod, Zeroable};

#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
struct Vertex {
    position: [f32; 3],
    color: [f32; 3],
}

Defining Our Triangle

Positions are in NDC (normalised device coordinates): (0, 0) is the centre of the window, (1, 1) is the top-right corner, (-1, -1) is the bottom-left. We place one vertex at the top-centre and two at the bottom.

const VERTICES: &[Vertex] = &[
    Vertex { position: [0.0,  0.5, 0.0], color: [1.0, 0.0, 0.0] },
    Vertex { position: [-0.5, -0.5, 0.0], color: [0.0, 1.0, 0.0] },
    Vertex { position: [0.5, -0.5, 0.0], color: [0.0, 0.0, 1.0] },
];

Writing WGSL Shaders

Shaders are small programs that run on the GPU. A vertex shader runs once per vertex and outputs a clip-space position. A fragment shader runs once per pixel and outputs a colour. We write both in WGSL (WebGPU Shading Language).

const SHADER: &str = r#"
struct VertexInput {
    @location(0) position: vec3<f32>,
    @location(1) color: vec3<f32>,
}

struct VertexOutput {
    @builtin(position) clip_position: vec4<f32>,
    @location(0) color: vec3<f32>,
}

@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
    var out: VertexOutput;
    out.color = in.color;
    out.clip_position = vec4<f32>(in.position, 1.0);
    return out;
}

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

The @location(0) and @location(1) attributes match the slot indices we declare in Vertex::desc() below. The GPU uses these to wire up the buffer data to the shader inputs automatically.

The Vertex Buffer Layout

Vertex::desc() tells wgpu the byte layout of our vertex data — how many bytes per vertex, and where each attribute starts within that vertex.

impl Vertex {
    fn desc() -> wgpu::VertexBufferLayout<'static> {
        wgpu::VertexBufferLayout {
            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
            step_mode: wgpu::VertexStepMode::Vertex,
            attributes: &[
                wgpu::VertexAttribute {
                    offset: 0,
                    shader_location: 0,
                    format: wgpu::VertexFormat::Float32x3,
                },
                wgpu::VertexAttribute {
                    offset: std::mem::size_of::<[f32; 3]>() as wgpu::BufferAddress,
                    shader_location: 1,
                    format: wgpu::VertexFormat::Float32x3,
                },
            ],
        }
    }
}

Building the Render Pipeline

The render pipeline describes the complete GPU drawing process: which shaders to use, how vertices are assembled into triangles, and how the output blends with the existing framebuffer.

let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
    label: Some("Shader"),
    source: wgpu::ShaderSource::Wgsl(SHADER.into()),
});

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

let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
    label: Some("Render Pipeline"),
    layout: Some(&render_pipeline_layout),
    vertex: wgpu::VertexState {
        module: &shader,
        entry_point: "vs_main",
        buffers: &[Vertex::desc()],
    },
    fragment: Some(wgpu::FragmentState {
        module: &shader,
        entry_point: "fs_main",
        targets: &[Some(wgpu::ColorTargetState {
            format: config.format,
            blend: Some(wgpu::BlendState::REPLACE),
            write_mask: wgpu::ColorWrites::ALL,
        })],
    }),
    primitive: wgpu::PrimitiveState {
        topology: wgpu::PrimitiveTopology::TriangleList,
        strip_index_format: None,
        front_face: wgpu::FrontFace::Ccw,
        cull_mode: Some(wgpu::Face::Back),
        polygon_mode: wgpu::PolygonMode::Fill,
        unclipped_depth: false,
        conservative: false,
    },
    depth_stencil: None,
    multisample: wgpu::MultisampleState { count: 1, mask: !0, alpha_to_coverage_enabled: false },
    multiview: None,
});

TriangleList means every three vertices form one triangle. FrontFace::Ccw tells the GPU that counter-clockwise vertex order is the front face, and CullMode::Back skips back-facing triangles — a performance optimisation that also prevents drawing the inside of closed meshes.

Uploading Vertex Data and Drawing

Upload the vertex data and store the pipeline alongside it in State:

let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
    label: Some("Vertex Buffer"),
    contents: bytemuck::cast_slice(VERTICES),
    usage: wgpu::BufferUsages::VERTEX,
});

let num_vertices = VERTICES.len() as u32;

Then in render(), bind the pipeline and vertex buffer before drawing:

render_pass.set_pipeline(&self.render_pipeline);
render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
render_pass.draw(0..self.num_vertices, 0..1);

Running It

cargo run

You should see a red, green, and blue triangle on a dark background. The GPU interpolates the colours smoothly between vertices — that gradient happens automatically with no extra code.

What’s Next

In Part 4 we add a uniform buffer so we can pass data from the CPU to the shader every frame — and use it to animate the triangle’s colour.

Further reading:

FAQ

What is WGSL? WGSL (WebGPU Shading Language) is the shader language used by wgpu. It’s statically typed, designed for safety, and compiles to the native shader format (SPIR-V, MSL, HLSL) for whichever GPU backend is in use.

What is a vertex shader? A vertex shader is a small GPU program that runs once per vertex. It takes the raw vertex data (position, colour, UV, etc.) and outputs a clip-space position. The GPU uses the output positions to determine which pixels each triangle covers.

Why #[repr(C)]? By default Rust may reorder or pad struct fields for performance. #[repr(C)] forces the C layout, which is predictable. Since we’re casting the struct to raw bytes and sending it to the GPU, we need to know exactly where each field is.

What is a render pipeline? A render pipeline is a compiled description of the entire GPU drawing process: which shaders to use, how to interpret vertex data, how triangles are assembled and rasterised, and how the output is written to the framebuffer. Creating it upfront lets the GPU driver optimise it once.