Skip to content

Commit af42911

Browse files
committed
[add] tutorial for how to use uniform buffer objects.
1 parent 93c85cc commit af42911

File tree

1 file changed

+365
-0
lines changed

1 file changed

+365
-0
lines changed

docs/tutorials/uniform-buffers.md

Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
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

Comments
 (0)