#Part 3 - Drawing Your First Triangle
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:
[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.
Continue Learning
Part 4 - Colors and Uniforms
Learn how to pass data from your Rust program to the GPU using uniform buffers and bind groups in wgpu — and animate a colour tint in real time.
Part 6 - 2D Transformations
Move, rotate, and scale objects in wgpu using transformation matrices with glam — pass a 4×4 matrix as a uniform to the vertex shader and animate it each frame.
Part 5 - Textures and Image Loading
Load an image file and render it as a texture on a quad with wgpu in Rust — covers texture creation, samplers, UV coordinates, and index buffers.
Part 2 - The Render Pipeline
Initialise the wgpu render pipeline in Rust — create a GPU device, configure a surface, and clear the screen to a colour using a render pass.