WebGPU notes / 02
WebGPU Vertex Attributes in Rust and wgpu
A vertex can carry a position, a normal, texture coordinates, and a tangent. This Rust and wgpu example renders the same mesh with interleaved and separate vertex buffers, showing how strides, offsets, and WGSL shader locations connect that data to the GPU.
- Buffer layouts
- 2
- Attributes / vertex
- 4
- Bytes / vertex
- 48
One mesh, two buffer layouts
After the WebGPU triangle example, the next question is how to feed more than positions and colors into a shader. Here I use the same geometry, textures, and lighting in both panels so you can follow what changes in the buffer setup.
In a wide viewport, the left panel uses an interleaved vertex buffer and the right panel uses separate attribute buffers. In a tall viewport, they stack in the same order. Both surfaces rotate automatically; there are no camera or input controls.
The wavy mesh is generated once from a grid with 72 cells per side: 5,329 vertices and 31,104 indices. A model matrix rotates it each frame. The two pipelines share the same index buffer, uniforms, textures, and WGSL shader. The application runs through sib::render, just like the triangle.
The interleaved vertex buffer
In the Rust source, one structure holds every attribute of a vertex:
#[repr(C)]
struct InterleavedVertex {
position: [f32; 3],
normal: [f32; 3],
uv: [f32; 2],
tangent: [f32; 4],
}
Those arrays occupy 12, 12, 8, and 16 bytes. Each vertex therefore uses 48 bytes, and the next vertex starts 48 bytes later. That distance is the array_stride in wgpu::VertexBufferLayout. The buffer uses VertexStepMode::Vertex, so its attributes advance for each vertex rather than each instance.
Each attribute also needs a format, a byte offset within the vertex, and a shader location. The table shows the exact layout in this example:
Scroll sideways to see all table columns.
| Attribute | Shader location | Format | Interleaved offset (bytes) | Separate stride (bytes) |
|---|---|---|---|---|
| Position | 0 | Float32x3 | 0 | 12 |
| Normal | 1 | Float32x3 | 12 | 12 |
| UV | 2 | Float32x2 | 24 | 8 |
| Tangent | 3 | Float32x4 | 32 | 16 |
All four interleaved attributes come from buffer slot 0. The vertex_attr_array! macro calculates their offsets from the formats. Its packed sequence matches this structure’s layout; if you change field types or introduce padding, check the offsets again.
Separate attribute buffers
The second representation stores all positions together, all normals together, and so on. Its four buffers have strides of 12, 12, 8, and 16 bytes. Each contains one attribute at offset 0, and all use the same vertex order as the shared index buffer.
The separate pipeline receives four vertex buffer layouts. Before its draw, the example binds the matching buffers:
render_pass.set_pipeline(&pipelines.separate);
render_pass.set_vertex_buffer(0, gpu_mesh.position_buffer.slice(..));
render_pass.set_vertex_buffer(1, gpu_mesh.normal_buffer.slice(..));
render_pass.set_vertex_buffer(2, gpu_mesh.uv_buffer.slice(..));
render_pass.set_vertex_buffer(3, gpu_mesh.tangent_buffer.slice(..));
render_pass.draw_indexed(0..gpu_mesh.index_count, 0, 0..1);
Buffer slots and shader locations are different concepts. A slot selects a bound buffer; the VertexAttribute descriptors say which values from that buffer feed each shader location. Their numbers happen to match in the separate layout here. In the interleaved layout, slot 0 supplies all four locations.
The renderer makes one mesh draw per panel, followed by a text overlay pass for the labels and FPS. Each panel uses its own viewport and scissor rectangle, keeping the two results in their assigned areas.
WGSL inputs and normal mapping
The GPU presents both layouts to the same vertex shader interface:
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) tangent: vec4<f32>,
};
The pipeline describes where to fetch the attributes, so the WGSL code does not need a separate version for each buffer arrangement. The vertex shader transforms positions into clip space and passes world position, UVs, normals, and tangents to the fragment shader.
These extra attributes have visible jobs. UVs repeat the procedural checkerboard color texture across the surface. The mesh normals describe its smooth slopes. A normal map adds finer lighting detail, with the tangent providing a direction along the surface.
The fragment shader reconstructs the bitangent with normalize(cross(normal, tangent) * input.tangent.w), then uses the tangent, bitangent, and normal as a basis for the sampled normal. In this mesh, tangent.w is 1.0. The resulting normal drives diffuse, specular, and rim lighting. Both procedural textures are 256 × 256 pixels and are shared by the two panels.
Choosing a vertex layout
Interleaving keeps a vertex’s attributes together and lets this example bind them with one buffer call. Separate buffers make it convenient to update or bind an individual attribute stream. For example, a pass that only needs positions can use a position stream without requiring the normal, UV, and tangent streams in its vertex layout.
Both representations here contain 48 bytes of attribute data per vertex, excluding indices and uniforms. The demo keeps both representations allocated so it can display them together. It does not demonstrate a reduction in total memory use.
The FPS display measures the combined frame, including both panels and the text overlay. It cannot tell you which layout is faster. That needs separate measurements of the actual passes and workloads you care about on your target GPUs.
Run and modify the example
From a local checkout with Rust installed, run the native example:
cargo run --example vertexattributes
For WebAssembly, install the wasm32-unknown-unknown target and the wasm-bindgen CLI version matching Cargo.lock, then build and serve:
scripts/build-wasm.sh --release vertexattributes
cargo run --bin serve
Open http://127.0.0.1:8080/vertexattributes/. The live rendering needs WebGPU support; the article and screenshot remain readable without it.
Three small changes are useful for exploring the source:
- Change
repeatinbuild_attribute_meshto see how UVs control the checkerboard scale. - Reduce the grid resolution from
72to see how vertex density changes the wave silhouette. - Use the interpolated mesh normal instead of
mapped_normalin the lighting calculation to isolate the normal map’s contribution.
If you change the attribute layout itself, update the Rust data, pipeline descriptors, and WGSL input types together. Keeping those three descriptions in agreement is the central lesson of this example.