#Part 5 - Textures and Image Loading
A triangle is a great proof-of-concept, but most real graphics involve images. In this part we load a PNG, upload it to the GPU as a texture, and sample it onto a textured quad.
Add the image crate to 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"] }
image = "0.25"
Place any PNG image at assets/happy-tree.png in your project root. Any image will work — we’ll use it as the texture.
From Triangle to Quad
A quad is just two triangles forming a rectangle. We could define six vertices (two sets of three), but that wastes memory — the two triangles share two vertices. An index buffer solves this: we define four unique vertices and an index list that tells the GPU which vertices to connect for each triangle.
const VERTICES: &[Vertex] = &[
Vertex { position: [-0.5, 0.5, 0.0], tex_coords: [0.0, 0.0] },
Vertex { position: [-0.5, -0.5, 0.0], tex_coords: [0.0, 1.0] },
Vertex { position: [ 0.5, -0.5, 0.0], tex_coords: [1.0, 1.0] },
Vertex { position: [ 0.5, 0.5, 0.0], tex_coords: [1.0, 0.0] },
];
const INDICES: &[u16] = &[0, 1, 2, 0, 2, 3];
Two triangles: [0, 1, 2] and [0, 2, 3]. The index buffer lets us reuse vertices 0 and 2.
UV Coordinates
We replaced the color field with tex_coords — a pair of floats in the 0–1 range that map each vertex to a point on the texture. (0, 0) is the top-left of the image, (1, 1) is the bottom-right.
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
struct Vertex {
position: [f32; 3],
tex_coords: [f32; 2],
}
Loading the Image
The image crate handles decoding. to_rgba8() converts whatever format the PNG uses into a consistent 4-channel 8-bit format the GPU understands:
let img = image::open("assets/happy-tree.png")
.unwrap()
.to_rgba8();
let (img_width, img_height) = img.dimensions();
Creating the Texture
let texture_size = wgpu::Extent3d {
width: img_width,
height: img_height,
depth_or_array_layers: 1,
};
let diffuse_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Diffuse Texture"),
size: texture_size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
Rgba8UnormSrgb matches the output of to_rgba8() and tells the GPU to treat the values as sRGB colour, which gives correct linear-light rendering.
Uploading Pixel Data
queue.write_texture(
wgpu::ImageCopyTexture {
texture: &diffuse_texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&img,
wgpu::ImageDataLayout {
offset: 0,
bytes_per_row: Some(4 * img_width),
rows_per_image: Some(img_height),
},
texture_size,
);
bytes_per_row is 4 * width because each pixel is four bytes (R, G, B, A). This layout tells wgpu how to interpret the flat byte slice as a 2D image.
The Sampler
A sampler defines how the GPU reads the texture when a UV coordinate falls between pixels or outside the 0–1 range:
let diffuse_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Nearest,
mipmap_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
ClampToEdge repeats the edge pixel when UVs go outside 0–1. Linear magnification blends neighbouring pixels when the texture is scaled up, producing a smoother result than Nearest.
Updating the Shader
@group(0) @binding(0)
var t_diffuse: texture_2d<f32>;
@group(0) @binding(1)
var s_diffuse: sampler;
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return textureSample(t_diffuse, s_diffuse, in.tex_coords);
}
textureSample takes a texture, a sampler, and UV coordinates, and returns the interpolated colour at that position.
Drawing with an Index Buffer
The draw call changes slightly — we bind the index buffer and use draw_indexed instead of draw:
render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
render_pass.draw_indexed(0..self.num_indices, 0, 0..1);
Running It
cargo run
You should see the image displayed on a quad in the centre of the window. Try replacing the PNG with a different image to see it update.
What’s Next
In Part 6 we add a transformation matrix so we can rotate and scale the quad every frame.
Further reading:
FAQ
What is a UV coordinate? A UV coordinate is a 2D position in texture space, ranging from (0, 0) at the top-left to (1, 1) at the bottom-right. Each vertex gets a UV, and the GPU interpolates between them to sample the correct part of the texture for each pixel.
What is a texture sampler? A sampler defines the rules for reading a texture — what happens at the edges (clamping, wrapping, mirroring) and how neighbouring texels are blended when the texture is scaled (nearest-neighbour vs linear filtering).
What is an index buffer? An index buffer is a list of integers that tells the GPU which vertices to use for each triangle. It avoids duplicating shared vertices, reducing memory usage and the amount of data the vertex shader processes.
What does to_rgba8 do?
It converts a decoded image into a consistent 8-bit-per-channel RGBA format, regardless of the source format (RGB, greyscale, PNG palette, etc.). This gives a predictable byte layout to upload to the GPU.
Continue Learning
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 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 3 - Drawing Your First Triangle
Draw your first triangle with wgpu in Rust — define vertex data, upload it to the GPU with a vertex buffer, and write your first WGSL shaders.
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.