|
| 1 | +--- |
| 2 | +title: "Uniform Buffers: Build a Spinning Triangle" |
| 3 | +document_id: "uniform-buffers-tutorial-2025-10-17" |
| 4 | +status: "draft" |
| 5 | +created: "2025-10-17T00:00:00Z" |
| 6 | +last_updated: "2025-10-17T00:15:00Z" |
| 7 | +version: "0.2.0" |
| 8 | +engine_workspace_version: "2023.1.30" |
| 9 | +wgpu_version: "26.0.1" |
| 10 | +shader_backend_default: "naga" |
| 11 | +winit_version: "0.29.10" |
| 12 | +repo_commit: "00aababeb76370ebdeb67fc12ab4393aac5e4193" |
| 13 | +owners: ["lambda-sh"] |
| 14 | +reviewers: ["engine", "rendering"] |
| 15 | +tags: ["tutorial", "graphics", "uniform-buffers", "rust", "wgpu"] |
| 16 | +--- |
| 17 | + |
| 18 | +Uniform buffer objects (UBOs) are a standard mechanism to pass per‑frame or per‑draw constants to shaders. This document demonstrates a minimal 3D spinning triangle that uses a UBO to provide a model‑view‑projection matrix to the vertex shader. |
| 19 | + |
| 20 | +Reference implementation: `crates/lambda-rs/examples/uniform_buffer_triangle.rs`. |
| 21 | + |
| 22 | +Goals |
| 23 | +- Build a spinning triangle that reads a model‑view‑projection matrix from a uniform buffer. |
| 24 | +- Learn how to define a uniform block in shaders and mirror it in Rust. |
| 25 | +- Learn how to create a bind group layout, allocate a uniform buffer, and write per‑frame data. |
| 26 | +- Learn how to construct a render pipeline and issue draw commands using Lambda’s builders. |
| 27 | + |
| 28 | +Prerequisites |
| 29 | +- Rust toolchain installed and the workspace builds: `cargo build --workspace`. |
| 30 | +- Familiarity with basic Rust and the repository’s example layout. |
| 31 | +- Ability to run examples: `cargo run --example minimal` verifies setup. |
| 32 | + |
| 33 | +Requirements and constraints |
| 34 | +- The uniform block layout in the shader and the Rust structure MUST match in size, alignment, and field order. |
| 35 | +- The bind group layout in Rust MUST match the shader `set` and `binding` indices. |
| 36 | +- Matrices MUST be provided in the order expected by the shader (column‑major in this example). Rationale: prevents implicit driver conversions and avoids incorrect transforms. |
| 37 | +- Acronyms MUST be defined on first use (e.g., uniform buffer object (UBO)). |
| 38 | + |
| 39 | +Data flow |
| 40 | +- CPU writes → UBO → bind group (set 0) → pipeline layout → vertex shader. |
| 41 | +- A single UBO MAY be reused across multiple draws and pipelines. |
| 42 | + |
| 43 | +Implementation Steps |
| 44 | + |
| 45 | +1) Runtime and component skeleton |
| 46 | +Before rendering, create a minimal application entry point and a `Component` that receives lifecycle callbacks. The engine routes initialization, input, updates, and rendering through the component interface, which provides the context needed to create GPU resources and submit commands. |
| 47 | + |
| 48 | +```rust |
| 49 | +use lambda::{ |
| 50 | + component::Component, |
| 51 | + runtime::start_runtime, |
| 52 | + runtimes::{ |
| 53 | + application::ComponentResult, |
| 54 | + ApplicationRuntimeBuilder, |
| 55 | + }, |
| 56 | +}; |
| 57 | + |
| 58 | +pub struct UniformBufferExample { |
| 59 | + elapsed_seconds: f32, |
| 60 | + width: u32, |
| 61 | + height: u32, |
| 62 | + // we will add resources here as we build |
| 63 | +} |
| 64 | + |
| 65 | +impl Default for UniformBufferExample { |
| 66 | + fn default() -> Self { |
| 67 | + return Self { |
| 68 | + elapsed_seconds: 0.0, |
| 69 | + width: 800, |
| 70 | + height: 600, |
| 71 | + }; |
| 72 | + } |
| 73 | +} |
| 74 | + |
| 75 | +fn main() { |
| 76 | + let runtime = ApplicationRuntimeBuilder::new("3D Uniform Buffer Example") |
| 77 | + .with_window_configured_as(|w| w.with_dimensions(800, 600).with_name("3D Uniform Buffer Example")) |
| 78 | + .with_renderer_configured_as(|r| r.with_render_timeout(1_000_000_000)) |
| 79 | + .with_component(|runtime, example: UniformBufferExample| { return (runtime, example); }) |
| 80 | + .build(); |
| 81 | + |
| 82 | + start_runtime(runtime); |
| 83 | +} |
| 84 | +``` |
| 85 | + |
| 86 | +2) Vertex and fragment shaders |
| 87 | +Define shader stages next. The vertex shader declares three vertex attributes and a uniform block at set 0, binding 0. It multiplies the incoming position by the matrix stored in the UBO. The fragment shader returns the interpolated color. Declaring the uniform block now establishes the contract that the Rust side will satisfy via a matching bind group layout and buffer. |
| 88 | + |
| 89 | +```glsl |
| 90 | +// Vertex (GLSL 450) |
| 91 | +#version 450 |
| 92 | +layout (location = 0) in vec3 vertex_position; |
| 93 | +layout (location = 1) in vec3 vertex_normal; |
| 94 | +layout (location = 2) in vec3 vertex_color; |
| 95 | +
|
| 96 | +layout (location = 0) out vec3 frag_color; |
| 97 | +
|
| 98 | +layout (set = 0, binding = 0) uniform Globals { |
| 99 | + mat4 render_matrix; |
| 100 | +} globals; |
| 101 | +
|
| 102 | +void main() { |
| 103 | + gl_Position = globals.render_matrix * vec4(vertex_position, 1.0); |
| 104 | + frag_color = vertex_color; |
| 105 | +} |
| 106 | +``` |
| 107 | + |
| 108 | +```glsl |
| 109 | +// Fragment (GLSL 450) |
| 110 | +#version 450 |
| 111 | +layout (location = 0) in vec3 frag_color; |
| 112 | +layout (location = 0) out vec4 fragment_color; |
| 113 | +
|
| 114 | +void main() { |
| 115 | + fragment_color = vec4(frag_color, 1.0); |
| 116 | +} |
| 117 | +``` |
| 118 | + |
| 119 | +Load these as `VirtualShader::Source` via `ShaderBuilder`: |
| 120 | + |
| 121 | +```rust |
| 122 | +use lambda::render::shader::{Shader, ShaderBuilder, ShaderKind, VirtualShader}; |
| 123 | + |
| 124 | +let vertex_virtual = VirtualShader::Source { |
| 125 | + source: VERTEX_SHADER_SOURCE.to_string(), |
| 126 | + kind: ShaderKind::Vertex, |
| 127 | + entry_point: "main".to_string(), |
| 128 | + name: "uniform_buffer_triangle".to_string(), |
| 129 | +}; |
| 130 | +let fragment_virtual = VirtualShader::Source { |
| 131 | + source: FRAGMENT_SHADER_SOURCE.to_string(), |
| 132 | + kind: ShaderKind::Fragment, |
| 133 | + entry_point: "main".to_string(), |
| 134 | + name: "uniform_buffer_triangle".to_string(), |
| 135 | +}; |
| 136 | +let mut shader_builder = ShaderBuilder::new(); |
| 137 | +let vertex_shader: Shader = shader_builder.build(vertex_virtual); |
| 138 | +let fragment_shader: Shader = shader_builder.build(fragment_virtual); |
| 139 | +``` |
| 140 | + |
| 141 | +3) Mesh data and vertex layout |
| 142 | +Provide vertex data for a single triangle and describe how the pipeline reads it. Each vertex stores position, normal, and color as three `f32` values. The attribute descriptors specify locations and byte offsets so the pipeline can interpret the packed buffer consistently across platforms. |
| 143 | + |
| 144 | +```rust |
| 145 | +use lambda::render::{ |
| 146 | + mesh::{Mesh, MeshBuilder}, |
| 147 | + vertex::{VertexAttribute, VertexBuilder, VertexElement}, |
| 148 | + ColorFormat, |
| 149 | +}; |
| 150 | + |
| 151 | +let vertices = [ |
| 152 | + VertexBuilder::new().with_position([ 1.0, 1.0, 0.0]).with_normal([0.0,0.0,0.0]).with_color([1.0,0.0,0.0]).build(), |
| 153 | + VertexBuilder::new().with_position([-1.0, 1.0, 0.0]).with_normal([0.0,0.0,0.0]).with_color([0.0,1.0,0.0]).build(), |
| 154 | + VertexBuilder::new().with_position([ 0.0, -1.0, 0.0]).with_normal([0.0,0.0,0.0]).with_color([0.0,0.0,1.0]).build(), |
| 155 | +]; |
| 156 | + |
| 157 | +let mut mesh_builder = MeshBuilder::new(); |
| 158 | +vertices.iter().for_each(|v| { mesh_builder.with_vertex(v.clone()); }); |
| 159 | + |
| 160 | +let mesh: Mesh = mesh_builder |
| 161 | + .with_attributes(vec![ |
| 162 | + VertexAttribute { // position @ location 0 |
| 163 | + location: 0, offset: 0, |
| 164 | + element: VertexElement { format: ColorFormat::Rgb32Sfloat, offset: 0 }, |
| 165 | + }, |
| 166 | + VertexAttribute { // normal @ location 1 |
| 167 | + location: 1, offset: 0, |
| 168 | + element: VertexElement { format: ColorFormat::Rgb32Sfloat, offset: 12 }, |
| 169 | + }, |
| 170 | + VertexAttribute { // color @ location 2 |
| 171 | + location: 2, offset: 0, |
| 172 | + element: VertexElement { format: ColorFormat::Rgb32Sfloat, offset: 24 }, |
| 173 | + }, |
| 174 | + ]) |
| 175 | + .build(); |
| 176 | +``` |
| 177 | + |
| 178 | +4) Uniform data layout in Rust |
| 179 | +Mirror the shader’s uniform block with a Rust structure. Use `#[repr(C)]` so the memory layout is predictable. A `mat4` in the shader corresponds to a 4×4 `f32` array here. Many GPU interfaces expect column‑major matrices; transpose before upload if the local math library is row‑major. This avoids implicit driver conversions and prevents incorrect transforms. |
| 180 | + |
| 181 | +```rust |
| 182 | +#[repr(C)] |
| 183 | +#[derive(Debug, Clone, Copy)] |
| 184 | +pub struct GlobalsUniform { |
| 185 | + pub render_matrix: [[f32; 4]; 4], |
| 186 | +} |
| 187 | +``` |
| 188 | + |
| 189 | +5) Bind group layout at set 0 |
| 190 | +Create a bind group layout that matches the shader declaration. This layout says: at set 0, binding 0 there is a uniform buffer visible to the vertex stage. The pipeline layout will incorporate this, ensuring the shader and the bound resources agree at draw time. |
| 191 | + |
| 192 | +```rust |
| 193 | +use lambda::render::bind::{BindGroupLayoutBuilder, BindingVisibility}; |
| 194 | + |
| 195 | +let layout = BindGroupLayoutBuilder::new() |
| 196 | + .with_uniform(0, BindingVisibility::Vertex) // binding 0 |
| 197 | + .build(render_context); |
| 198 | +``` |
| 199 | + |
| 200 | +6) Create the uniform buffer and bind group |
| 201 | +Allocate the uniform buffer, seed it with an initial matrix, and create a bind group using the layout. Mark the buffer usage as `UNIFORM` and properties as `CPU_VISIBLE` to permit direct per‑frame writes from the CPU. This is the simplest path for frequently updated data. |
| 202 | + |
| 203 | +```rust |
| 204 | +use lambda::render::buffer::{BufferBuilder, Usage, Properties}; |
| 205 | + |
| 206 | +let initial_uniform = GlobalsUniform { render_matrix: initial_matrix.transpose() }; |
| 207 | + |
| 208 | +let uniform_buffer = BufferBuilder::new() |
| 209 | + .with_length(std::mem::size_of::<GlobalsUniform>()) |
| 210 | + .with_usage(Usage::UNIFORM) |
| 211 | + .with_properties(Properties::CPU_VISIBLE) |
| 212 | + .with_label("globals-uniform") |
| 213 | + .build(render_context, vec![initial_uniform]) |
| 214 | + .expect("Failed to create uniform buffer"); |
| 215 | + |
| 216 | +use lambda::render::bind::BindGroupBuilder; |
| 217 | + |
| 218 | +let bind_group = BindGroupBuilder::new() |
| 219 | + .with_layout(&layout) |
| 220 | + .with_uniform(0, &uniform_buffer, 0, None) // binding 0 |
| 221 | + .build(render_context); |
| 222 | +``` |
| 223 | + |
| 224 | +7) Build the render pipeline |
| 225 | +Construct the render pipeline, supplying the bind group layouts, vertex buffer, and the shader pair. Disable face culling for simplicity so both sides of the triangle remain visible regardless of winding during early experimentation. |
| 226 | + |
| 227 | +```rust |
| 228 | +use lambda::render::{ |
| 229 | + pipeline::RenderPipelineBuilder, |
| 230 | + render_pass::RenderPassBuilder, |
| 231 | +}; |
| 232 | + |
| 233 | +let render_pass = RenderPassBuilder::new().build(render_context); |
| 234 | + |
| 235 | +let pipeline = RenderPipelineBuilder::new() |
| 236 | + .with_culling(lambda::render::pipeline::CullingMode::None) |
| 237 | + .with_layouts(&[&layout]) |
| 238 | + .with_buffer( |
| 239 | + BufferBuilder::build_from_mesh(&mesh, render_context).expect("Failed to create buffer"), |
| 240 | + mesh.attributes().to_vec(), |
| 241 | + ) |
| 242 | + .build(render_context, &render_pass, &vertex_shader, Some(&fragment_shader)); |
| 243 | +``` |
| 244 | + |
| 245 | +8) Per‑frame update and write |
| 246 | +Animate by recomputing the model‑view‑projection matrix each frame and writing it into the uniform buffer. The helper `compute_model_view_projection_matrix_about_pivot` maintains a correct aspect ratio using the current window dimensions and rotates the model around a chosen pivot. |
| 247 | + |
| 248 | +```rust |
| 249 | +use lambda::render::scene_math::{compute_model_view_projection_matrix_about_pivot, SimpleCamera}; |
| 250 | + |
| 251 | +const ROTATION_TURNS_PER_SECOND: f32 = 0.12; |
| 252 | + |
| 253 | +fn update_uniform_each_frame( |
| 254 | + elapsed_seconds: f32, |
| 255 | + width: u32, |
| 256 | + height: u32, |
| 257 | + render_context: &mut lambda::render::RenderContext, |
| 258 | + uniform_buffer: &lambda::render::buffer::Buffer, |
| 259 | +) { |
| 260 | + let camera = SimpleCamera { |
| 261 | + position: [0.0, 0.0, 3.0], |
| 262 | + field_of_view_in_turns: 0.25, |
| 263 | + near_clipping_plane: 0.1, |
| 264 | + far_clipping_plane: 100.0, |
| 265 | + }; |
| 266 | + |
| 267 | + let angle_in_turns = ROTATION_TURNS_PER_SECOND * elapsed_seconds; |
| 268 | + let model_view_projection_matrix = compute_model_view_projection_matrix_about_pivot( |
| 269 | + &camera, |
| 270 | + width.max(1), |
| 271 | + height.max(1), |
| 272 | + [0.0, -1.0 / 3.0, 0.0], // pivot |
| 273 | + [0.0, 1.0, 0.0], // axis |
| 274 | + angle_in_turns, |
| 275 | + 0.5, // scale |
| 276 | + [0.0, 1.0 / 3.0, 0.0], // translation |
| 277 | + ); |
| 278 | + |
| 279 | + let value = GlobalsUniform { render_matrix: model_view_projection_matrix.transpose() }; |
| 280 | + uniform_buffer.write_value(render_context, 0, &value); |
| 281 | +} |
| 282 | +``` |
| 283 | + |
| 284 | +9) Issue draw commands |
| 285 | +Record commands in the order the GPU expects: begin the render pass, set the pipeline, configure viewport and scissors, bind the vertex buffer and the uniform bind group, draw the vertices, then end the pass. This sequence describes the full state required for a single draw. |
| 286 | + |
| 287 | +```rust |
| 288 | +use lambda::render::{ |
| 289 | + command::RenderCommand, |
| 290 | + viewport::ViewportBuilder, |
| 291 | +}; |
| 292 | + |
| 293 | +let viewport = ViewportBuilder::new().build(width, height); |
| 294 | + |
| 295 | +let commands = vec![ |
| 296 | + RenderCommand::BeginRenderPass { render_pass: render_pass_id, viewport: viewport.clone() }, |
| 297 | + RenderCommand::SetPipeline { pipeline: pipeline_id }, |
| 298 | + RenderCommand::SetViewports { start_at: 0, viewports: vec![viewport.clone()] }, |
| 299 | + RenderCommand::SetScissors { start_at: 0, viewports: vec![viewport.clone()] }, |
| 300 | + RenderCommand::BindVertexBuffer { pipeline: pipeline_id, buffer: 0 }, |
| 301 | + RenderCommand::SetBindGroup { set: 0, group: bind_group_id, dynamic_offsets: vec![] }, |
| 302 | + RenderCommand::Draw { vertices: 0..mesh.vertices().len() as u32 }, |
| 303 | + RenderCommand::EndRenderPass, |
| 304 | +]; |
| 305 | +``` |
| 306 | + |
| 307 | +10) Handle window resize |
| 308 | +Track window dimensions and update the per‑frame matrix using the new aspect ratio. Forwarding resize events into stored `width` and `height` maintains consistent camera projection across resizes. |
| 309 | + |
| 310 | +```rust |
| 311 | +use lambda::events::{Events, WindowEvent}; |
| 312 | + |
| 313 | +fn on_event(&mut self, event: Events) -> Result<ComponentResult, String> { |
| 314 | + if let Events::Window { event, .. } = event { |
| 315 | + if let WindowEvent::Resize { width, height } = event { |
| 316 | + self.width = width; |
| 317 | + self.height = height; |
| 318 | + } |
| 319 | + } |
| 320 | + return Ok(ComponentResult::Success); |
| 321 | +} |
| 322 | +``` |
| 323 | + |
| 324 | +Validation |
| 325 | +- Build the workspace: `cargo build --workspace` |
| 326 | +- Run the example: `cargo run --example uniform_buffer_triangle` |
| 327 | + |
| 328 | +Notes |
| 329 | +- Layout matching: The Rust `GlobalsUniform` MUST match the shader block layout. Keep `#[repr(C)]` and follow alignment rules. |
| 330 | +- Matrix order: The shader expects column‑major matrices, so the uploaded matrix MUST be transposed if the local math library uses row‑major. |
| 331 | +- Binding indices: The Rust bind group layout and `.with_uniform(0, ...)`, plus the shader `set = 0, binding = 0`, MUST be consistent. |
| 332 | +- Update strategy: `CPU_VISIBLE` buffers SHOULD be used for per‑frame updates; device‑local memory MAY be preferred for static data. |
| 333 | +- Pipeline layout: All bind group layouts used by the pipeline MUST be included via `.with_layouts(...)`. |
| 334 | + |
| 335 | +Exercises |
| 336 | + |
| 337 | +- Exercise 1: Time‑based fragment color |
| 338 | + - Implement a second UBO at set 0, binding 1 with a `float time_seconds`. |
| 339 | + - Modify the fragment shader to modulate color with a sine of time. |
| 340 | + - Hint: add `.with_uniform(1, BindingVisibility::Fragment)` and a second binding. |
| 341 | + |
| 342 | +- Exercise 2: Camera orbit control |
| 343 | + - Implement an orbiting camera around the origin and update the uniform each frame. |
| 344 | + - Add input to adjust orbit speed. |
| 345 | + |
| 346 | +- Exercise 3: Two objects with dynamic offsets |
| 347 | + - Pack two `GlobalsUniform` matrices into one UBO and issue two draws with different dynamic offsets. |
| 348 | + - Use `dynamic_offsets` in `RenderCommand::SetBindGroup`. |
| 349 | + |
| 350 | +- Exercise 4: Basic Lambert lighting |
| 351 | + - Extend shaders to compute diffuse lighting. |
| 352 | + - Provide a lighting UBO at binding 2 with light position and color. |
| 353 | + |
| 354 | +- Exercise 5: Push constants comparison |
| 355 | + - Port to push constants (see `crates/lambda-rs/examples/push_constants.rs`) and compare trade‑offs. |
| 356 | + |
| 357 | +- Exercise 6: Per‑material uniforms |
| 358 | + - Split per‑frame and per‑material data; use a shared frame UBO and a per‑material UBO (e.g., tint color). |
| 359 | + |
| 360 | +- Exercise 7: Shader hot‑reload (stretch) |
| 361 | + - Rebuild shaders on file changes and re‑create the pipeline while preserving UBOs and bind groups. |
| 362 | + |
| 363 | +Changelog |
| 364 | +- 0.2.0 (2025‑10‑17): Added goals and book‑style step explanations; expanded rationale before code blocks; refined validation and notes. |
| 365 | +- 0.1.0 (2025‑10‑17): Initial draft aligned with `crates/lambda-rs/examples/uniform_buffer_triangle.rs`. |
0 commit comments