From a9ecb6df8188fb189e3221598b36f6cf757697bb Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sun, 12 Oct 2025 18:53:33 -0700 Subject: [PATCH 01/17] [add] uniform buffer objects, bind groups, and scene math helpers for simplifying camera code. --- crates/lambda-rs-platform/src/wgpu/bind.rs | 207 +++++++++ crates/lambda-rs-platform/src/wgpu/mod.rs | 2 + crates/lambda-rs/examples/push_constants.rs | 54 +-- .../examples/uniform_buffer_triangle.rs | 411 ++++++++++++++++++ crates/lambda-rs/src/render/bind.rs | 184 ++++++++ crates/lambda-rs/src/render/buffer.rs | 20 + crates/lambda-rs/src/render/command.rs | 13 + crates/lambda-rs/src/render/mod.rs | 33 ++ crates/lambda-rs/src/render/pipeline.rs | 55 ++- crates/lambda-rs/src/render/scene_math.rs | 200 +++++++++ docs/specs/uniform-buffers-and-bind-groups.md | 311 +++++++++++++ 11 files changed, 1455 insertions(+), 35 deletions(-) create mode 100644 crates/lambda-rs-platform/src/wgpu/bind.rs create mode 100644 crates/lambda-rs/examples/uniform_buffer_triangle.rs create mode 100644 crates/lambda-rs/src/render/bind.rs create mode 100644 crates/lambda-rs/src/render/scene_math.rs create mode 100644 docs/specs/uniform-buffers-and-bind-groups.md diff --git a/crates/lambda-rs-platform/src/wgpu/bind.rs b/crates/lambda-rs-platform/src/wgpu/bind.rs new file mode 100644 index 00000000..ffda3012 --- /dev/null +++ b/crates/lambda-rs-platform/src/wgpu/bind.rs @@ -0,0 +1,207 @@ +//! Bind group and bind group layout builders for the platform layer. +//! +//! These types provide a thin, explicit wrapper around `wgpu` bind resources +//! so higher layers can compose layouts and groups without pulling in raw +//! `wgpu` descriptors throughout the codebase. + +use std::num::NonZeroU64; + +use crate::wgpu::types as wgpu; + +#[derive(Debug)] +/// Wrapper around `wgpu::BindGroupLayout` that preserves a label. +pub struct BindGroupLayout { + pub(crate) raw: wgpu::BindGroupLayout, + pub(crate) label: Option, +} + +impl BindGroupLayout { + /// Borrow the underlying `wgpu::BindGroupLayout`. + pub fn raw(&self) -> &wgpu::BindGroupLayout { + &self.raw + } + + /// Optional debug label used during creation. + pub fn label(&self) -> Option<&str> { + self.label.as_deref() + } +} + +#[derive(Debug)] +/// Wrapper around `wgpu::BindGroup` that preserves a label. +pub struct BindGroup { + pub(crate) raw: wgpu::BindGroup, + pub(crate) label: Option, +} + +impl BindGroup { + /// Borrow the underlying `wgpu::BindGroup`. + pub fn raw(&self) -> &wgpu::BindGroup { + &self.raw + } + + /// Optional debug label used during creation. + pub fn label(&self) -> Option<&str> { + self.label.as_deref() + } +} + +#[derive(Clone, Copy, Debug)] +/// Visibility of a binding across shader stages. +pub enum Visibility { + Vertex, + Fragment, + Compute, + VertexAndFragment, + All, +} + +impl Visibility { + fn to_wgpu(self) -> wgpu::ShaderStages { + match self { + Visibility::Vertex => wgpu::ShaderStages::VERTEX, + Visibility::Fragment => wgpu::ShaderStages::FRAGMENT, + Visibility::Compute => wgpu::ShaderStages::COMPUTE, + Visibility::VertexAndFragment => { + wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT + } + Visibility::All => wgpu::ShaderStages::all(), + } + } +} + +#[derive(Default)] +/// Builder for creating a `wgpu::BindGroupLayout`. +pub struct BindGroupLayoutBuilder { + label: Option, + entries: Vec, +} + +impl BindGroupLayoutBuilder { + /// Create a builder with no entries. + pub fn new() -> Self { + Self { + label: None, + entries: Vec::new(), + } + } + + /// Attach a human‑readable label. + pub fn with_label(mut self, label: &str) -> Self { + self.label = Some(label.to_string()); + self + } + + /// Declare a uniform buffer binding at the provided index. + pub fn with_uniform(mut self, binding: u32, visibility: Visibility) -> Self { + self.entries.push(wgpu::BindGroupLayoutEntry { + binding, + visibility: visibility.to_wgpu(), + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }); + self + } + + /// Declare a uniform buffer binding with dynamic offsets at the provided index. + pub fn with_uniform_dynamic( + mut self, + binding: u32, + visibility: Visibility, + ) -> Self { + self.entries.push(wgpu::BindGroupLayoutEntry { + binding, + visibility: visibility.to_wgpu(), + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: true, + min_binding_size: None, + }, + count: None, + }); + self + } + + /// Build the layout using the provided device. + pub fn build(self, device: &wgpu::Device) -> BindGroupLayout { + let raw = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: self.label.as_deref(), + entries: &self.entries, + }); + BindGroupLayout { + raw, + label: self.label, + } + } +} + +#[derive(Default)] +/// Builder for creating a `wgpu::BindGroup`. +pub struct BindGroupBuilder<'a> { + label: Option, + layout: Option<&'a wgpu::BindGroupLayout>, + entries: Vec>, +} + +impl<'a> BindGroupBuilder<'a> { + /// Create a new builder with no layout or entries. + pub fn new() -> Self { + Self { + label: None, + layout: None, + entries: Vec::new(), + } + } + + /// Attach a human‑readable label. + pub fn with_label(mut self, label: &str) -> Self { + self.label = Some(label.to_string()); + self + } + + /// Specify the layout to use for this bind group. + pub fn with_layout(mut self, layout: &'a BindGroupLayout) -> Self { + self.layout = Some(layout.raw()); + self + } + + /// Bind a uniform buffer at a binding index with optional size slice. + pub fn with_uniform( + mut self, + binding: u32, + buffer: &'a wgpu::Buffer, + offset: u64, + size: Option, + ) -> Self { + self.entries.push(wgpu::BindGroupEntry { + binding, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer, + offset, + size, + }), + }); + self + } + + /// Build the bind group with the accumulated entries. + pub fn build(self, device: &wgpu::Device) -> BindGroup { + let layout = self + .layout + .expect("BindGroupBuilder requires a layout before build"); + let raw = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: self.label.as_deref(), + layout, + entries: &self.entries, + }); + BindGroup { + raw, + label: self.label, + } + } +} diff --git a/crates/lambda-rs-platform/src/wgpu/mod.rs b/crates/lambda-rs-platform/src/wgpu/mod.rs index 058f0e80..b15807dc 100644 --- a/crates/lambda-rs-platform/src/wgpu/mod.rs +++ b/crates/lambda-rs-platform/src/wgpu/mod.rs @@ -15,6 +15,8 @@ use wgpu::rwh::{ use crate::winit::WindowHandle; +pub mod bind; + #[derive(Debug, Clone)] /// Builder for creating a `wgpu::Instance` with consistent defaults. /// diff --git a/crates/lambda-rs/examples/push_constants.rs b/crates/lambda-rs/examples/push_constants.rs index 4d5014be..083329c7 100644 --- a/crates/lambda-rs/examples/push_constants.rs +++ b/crates/lambda-rs/examples/push_constants.rs @@ -21,6 +21,10 @@ use lambda::{ RenderPipelineBuilder, }, render_pass::RenderPassBuilder, + scene_math::{ + compute_model_view_projection_matrix_about_pivot, + SimpleCamera, + }, shader::{ Shader, ShaderBuilder, @@ -100,23 +104,7 @@ pub fn push_constants_to_bytes(push_constants: &PushConstant) -> &[u32] { return bytes; } -fn make_transform( - translate: [f32; 3], - angle: f32, - scale: f32, -) -> [[f32; 4]; 4] { - let c = angle.cos() * scale; - let s = angle.sin() * scale; - - let [x, y, z] = translate; - - return [ - [c, 0.0, s, 0.0], - [0.0, scale, 0.0, 0.0], - [-s, 0.0, c, 0.0], - [x, y, z, 1.0], - ]; -} +// Model, view, and projection matrix computations are handled by `scene_math`. // --------------------------------- COMPONENT --------------------------------- @@ -196,6 +184,7 @@ impl Component for PushConstantsExample { logging::trace!("mesh: {:?}", mesh); let pipeline = RenderPipelineBuilder::new() + .with_culling(lambda::render::pipeline::CullingMode::None) .with_push_constant(PipelineStage::VERTEX, push_constant_size) .with_buffer( BufferBuilder::build_from_mesh(&mesh, render_context) @@ -253,25 +242,23 @@ impl Component for PushConstantsExample { render_context: &mut lambda::render::RenderContext, ) -> Vec { self.frame_number += 1; - let camera = [0.0, 0.0, -2.0]; - let view: [[f32; 4]; 4] = matrix::translation_matrix(camera); - - // Create a projection matrix. - let projection: [[f32; 4]; 4] = - matrix::perspective_matrix(0.25, (4 / 3) as f32, 0.1, 100.0); - - // Rotate model. - let model: [[f32; 4]; 4] = matrix::rotate_matrix( - matrix::identity_matrix(4, 4), + let camera = SimpleCamera { + position: [0.0, 0.0, 3.0], + field_of_view_in_turns: 0.25, + near_clipping_plane: 0.1, + far_clipping_plane: 100.0, + }; + let mesh_matrix = compute_model_view_projection_matrix_about_pivot( + &camera, + self.width.max(1), + self.height.max(1), + [0.0, -1.0 / 3.0, 0.0], [0.0, 1.0, 0.0], 0.001 * self.frame_number as f32, + 0.5, + [0.0, 1.0 / 3.0, 0.0], ); - // Create render matrix. - let mesh_matrix = projection.multiply(&view).multiply(&model); - let mesh_matrix = - make_transform([0.0, 0.0, 0.5], self.frame_number as f32 * 0.01, 0.5); - // Create viewport. let viewport = viewport::ViewportBuilder::new().build(self.width, self.height); @@ -309,7 +296,8 @@ impl Component for PushConstantsExample { offset: 0, bytes: Vec::from(push_constants_to_bytes(&PushConstant { data: [0.0, 0.0, 0.0, 0.0], - render_matrix: mesh_matrix, + // Transpose to match GPU's column‑major expectation. + render_matrix: mesh_matrix.transpose(), })), }, RenderCommand::Draw { diff --git a/crates/lambda-rs/examples/uniform_buffer_triangle.rs b/crates/lambda-rs/examples/uniform_buffer_triangle.rs new file mode 100644 index 00000000..6615941e --- /dev/null +++ b/crates/lambda-rs/examples/uniform_buffer_triangle.rs @@ -0,0 +1,411 @@ +#![allow(clippy::needless_return)] + +//! Example: Spinning triangle in 3D using a uniform buffer and a bind group. +//! +//! This example mirrors the push constants demo but uses a uniform buffer +//! bound at group(0) binding(0) and a bind group layout declared in Rust. +//! The model, view, and projection matrices are computed via the shared +//! `scene_math` helpers so the example does not hand-roll the math. + +use lambda::{ + component::Component, + events::WindowEvent, + logging, + math::matrix::Matrix, + render::{ + bind::{ + BindGroupBuilder, + BindGroupLayoutBuilder, + BindingVisibility, + }, + buffer::{ + BufferBuilder, + Properties, + Usage, + }, + command::RenderCommand, + mesh::{ + Mesh, + MeshBuilder, + }, + pipeline::RenderPipelineBuilder, + render_pass::RenderPassBuilder, + scene_math::{ + compute_model_view_projection_matrix_about_pivot, + SimpleCamera, + }, + shader::{ + Shader, + ShaderBuilder, + ShaderKind, + VirtualShader, + }, + vertex::{ + VertexAttribute, + VertexBuilder, + VertexElement, + }, + viewport, + ColorFormat, + ResourceId, + }, + runtime::start_runtime, + runtimes::{ + application::ComponentResult, + ApplicationRuntime, + ApplicationRuntimeBuilder, + }, +}; + +// ------------------------------ SHADER SOURCE -------------------------------- + +const VERTEX_SHADER_SOURCE: &str = r#" +#version 450 + +layout (location = 0) in vec3 vertex_position; +layout (location = 1) in vec3 vertex_normal; +layout (location = 2) in vec3 vertex_color; + +layout (location = 0) out vec3 frag_color; + +layout (set = 0, binding = 0) uniform Globals { + mat4 render_matrix; +} globals; + +void main() { + gl_Position = globals.render_matrix * vec4(vertex_position, 1.0); + frag_color = vertex_color; +} + +"#; + +const FRAGMENT_SHADER_SOURCE: &str = r#" +#version 450 + +layout (location = 0) in vec3 frag_color; +layout (location = 0) out vec4 fragment_color; + +void main() { + fragment_color = vec4(frag_color, 1.0); +} + +"#; + +// ---------------------------- UNIFORM STRUCTURE ------------------------------ + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct GlobalsUniform { + pub render_matrix: [[f32; 4]; 4], +} + +// --------------------------------- COMPONENT --------------------------------- + +pub struct UniformBufferExample { + frame_number: u64, + shader: Shader, + fragment_shader: Shader, + mesh: Option, + render_pipeline: Option, + render_pass: Option, + uniform_buffer: Option, + bind_group: Option, + width: u32, + height: u32, +} + +impl Component for UniformBufferExample { + fn on_attach( + &mut self, + render_context: &mut lambda::render::RenderContext, + ) -> Result { + let render_pass = RenderPassBuilder::new().build(render_context); + + // Create triangle mesh. + let vertices = [ + 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(), + 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(), + 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(), + ]; + + let mut mesh_builder = MeshBuilder::new(); + vertices.iter().for_each(|vertex| { + mesh_builder.with_vertex(vertex.clone()); + }); + + let mesh = mesh_builder + .with_attributes(vec![ + VertexAttribute { + location: 0, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 0, + }, + }, + VertexAttribute { + location: 1, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 12, + }, + }, + VertexAttribute { + location: 2, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 24, + }, + }, + ]) + .build(); + + logging::trace!("mesh: {:?}", mesh); + + // Create a bind group layout with a single uniform buffer at binding 0. + let layout = BindGroupLayoutBuilder::new() + .with_uniform(0, BindingVisibility::Vertex) + .build(&render_context); + + // Create the uniform buffer with an initial matrix. + let camera = SimpleCamera { + position: [0.0, 0.0, 3.0], + field_of_view_in_turns: 0.25, + near_clipping_plane: 0.1, + far_clipping_plane: 100.0, + }; + let initial_matrix = compute_model_view_projection_matrix_about_pivot( + &camera, + self.width.max(1), + self.height.max(1), + [0.0, -1.0 / 3.0, 0.0], + [0.0, 1.0, 0.0], + 0.0, + 0.5, + [0.0, 1.0 / 3.0, 0.0], + ); + + let initial_uniform = GlobalsUniform { + // Transpose to match GPU column‑major layout. + render_matrix: initial_matrix.transpose(), + }; + + let uniform_buffer = BufferBuilder::new() + .with_length(std::mem::size_of::()) + .with_usage(Usage::UNIFORM) + .with_properties(Properties::CPU_VISIBLE) + .with_label("globals-uniform") + .build(render_context, vec![initial_uniform]) + .expect("Failed to create uniform buffer"); + + // Create the bind group using the layout and uniform buffer. + let bind_group = BindGroupBuilder::new() + .with_layout(&layout) + .with_uniform(0, &uniform_buffer, 0, None) + .build(render_context); + + let pipeline = RenderPipelineBuilder::new() + .with_culling(lambda::render::pipeline::CullingMode::None) + .with_layouts(&[&layout]) + .with_buffer( + BufferBuilder::build_from_mesh(&mesh, render_context) + .expect("Failed to create buffer"), + mesh.attributes().to_vec(), + ) + .build( + render_context, + &render_pass, + &self.shader, + Some(&self.fragment_shader), + ); + + self.render_pass = Some(render_context.attach_render_pass(render_pass)); + self.render_pipeline = Some(render_context.attach_pipeline(pipeline)); + self.bind_group = Some(render_context.attach_bind_group(bind_group)); + self.uniform_buffer = Some(uniform_buffer); + self.mesh = Some(mesh); + + return Ok(ComponentResult::Success); + } + + fn on_detach( + &mut self, + _render_context: &mut lambda::render::RenderContext, + ) -> Result { + logging::info!("Detaching component"); + return Ok(ComponentResult::Success); + } + + fn on_event( + &mut self, + event: lambda::events::Events, + ) -> Result { + match event { + lambda::events::Events::Window { + event, + issued_at: _, + } => match event { + WindowEvent::Resize { width, height } => { + self.width = width; + self.height = height; + logging::info!("Window resized to {}x{}", width, height); + } + _ => {} + }, + _ => {} + }; + return Ok(ComponentResult::Success); + } + + fn on_update( + &mut self, + _last_frame: &std::time::Duration, + ) -> Result { + self.frame_number += 1; + return Ok(ComponentResult::Success); + } + + fn on_render( + &mut self, + render_context: &mut lambda::render::RenderContext, + ) -> Vec { + self.frame_number += 1; + + // Compute the model, view, projection matrix for this frame. + let camera = SimpleCamera { + position: [0.0, 0.0, 3.0], + field_of_view_in_turns: 0.25, + near_clipping_plane: 0.1, + far_clipping_plane: 100.0, + }; + let render_matrix = compute_model_view_projection_matrix_about_pivot( + &camera, + self.width.max(1), + self.height.max(1), + [0.0, -1.0 / 3.0, 0.0], + [0.0, 1.0, 0.0], + 0.001 * self.frame_number as f32, + 0.5, + [0.0, 1.0 / 3.0, 0.0], + ); + + // Update the uniform buffer with the new matrix. + if let Some(ref uniform_buffer) = self.uniform_buffer { + // Transpose to match GPU column‑major layout. + let value = GlobalsUniform { + render_matrix: render_matrix.transpose(), + }; + uniform_buffer.write_value(render_context, 0, &value); + } + + // Create viewport. + let viewport = + viewport::ViewportBuilder::new().build(self.width, self.height); + + let render_pipeline = self + .render_pipeline + .expect("No render pipeline actively set for rendering."); + let group_id = self.bind_group.expect("Bind group must exist"); + + return vec![ + RenderCommand::BeginRenderPass { + render_pass: self + .render_pass + .expect("Cannot begin the render pass when it does not exist.") + .clone(), + viewport: viewport.clone(), + }, + RenderCommand::SetPipeline { + pipeline: render_pipeline.clone(), + }, + RenderCommand::SetViewports { + start_at: 0, + viewports: vec![viewport.clone()], + }, + RenderCommand::SetScissors { + start_at: 0, + viewports: vec![viewport.clone()], + }, + RenderCommand::BindVertexBuffer { + pipeline: render_pipeline.clone(), + buffer: 0, + }, + RenderCommand::SetBindGroup { + set: 0, + group: group_id, + dynamic_offsets: vec![], + }, + RenderCommand::Draw { + vertices: 0..self.mesh.as_ref().unwrap().vertices().len() as u32, + }, + RenderCommand::EndRenderPass, + ]; + } +} + +impl Default for UniformBufferExample { + fn default() -> Self { + let vertex_virtual_shader = VirtualShader::Source { + source: VERTEX_SHADER_SOURCE.to_string(), + kind: ShaderKind::Vertex, + entry_point: "main".to_string(), + name: "uniform_buffer_triangle".to_string(), + }; + + let fragment_virtual_shader = VirtualShader::Source { + source: FRAGMENT_SHADER_SOURCE.to_string(), + kind: ShaderKind::Fragment, + entry_point: "main".to_string(), + name: "uniform_buffer_triangle".to_string(), + }; + + let mut builder = ShaderBuilder::new(); + let shader = builder.build(vertex_virtual_shader); + let fragment_shader = builder.build(fragment_virtual_shader); + + return Self { + frame_number: 0, + shader, + fragment_shader, + mesh: None, + render_pipeline: None, + render_pass: None, + uniform_buffer: None, + bind_group: None, + width: 800, + height: 600, + }; + } +} + +fn main() { + let runtime = ApplicationRuntimeBuilder::new("3D Uniform Buffer Example") + .with_window_configured_as(move |window_builder| { + return window_builder + .with_dimensions(800, 600) + .with_name("3D Uniform Buffer Example"); + }) + .with_renderer_configured_as(|renderer_builder| { + return renderer_builder.with_render_timeout(1_000_000_000); + }) + .with_component(move |runtime, example: UniformBufferExample| { + return (runtime, example); + }) + .build(); + + start_runtime(runtime); +} diff --git a/crates/lambda-rs/src/render/bind.rs b/crates/lambda-rs/src/render/bind.rs new file mode 100644 index 00000000..e2b3d014 --- /dev/null +++ b/crates/lambda-rs/src/render/bind.rs @@ -0,0 +1,184 @@ +//! High-level bind group and bind group layout wrappers and builders. +//! +//! This module exposes ergonomic builders for declaring uniform buffer +//! bindings and constructing bind groups, following the same style used by the +//! buffer, pipeline, and render pass builders. + +use std::rc::Rc; + +use lambda_platform::wgpu::types as wgpu; + +use super::{ + buffer::Buffer, + RenderContext, +}; + +#[derive(Debug)] +/// Visibility of a binding across shader stages. +pub enum BindingVisibility { + Vertex, + Fragment, + Compute, + VertexAndFragment, + All, +} + +impl BindingVisibility { + fn to_platform(self) -> lambda_platform::wgpu::bind::Visibility { + use lambda_platform::wgpu::bind::Visibility as V; + match self { + BindingVisibility::Vertex => V::Vertex, + BindingVisibility::Fragment => V::Fragment, + BindingVisibility::Compute => V::Compute, + BindingVisibility::VertexAndFragment => V::VertexAndFragment, + BindingVisibility::All => V::All, + } + } +} + +#[derive(Debug, Clone)] +/// Bind group layout used when creating pipelines and bind groups. +pub struct BindGroupLayout { + layout: Rc, +} + +impl BindGroupLayout { + pub(crate) fn raw(&self) -> &wgpu::BindGroupLayout { + self.layout.raw() + } +} + +#[derive(Debug, Clone)] +/// Bind group that binds one or more resources to a pipeline set index. +pub struct BindGroup { + group: Rc, +} + +impl BindGroup { + pub(crate) fn raw(&self) -> &wgpu::BindGroup { + self.group.raw() + } +} + +/// Builder for creating a bind group layout with uniform buffer bindings. +pub struct BindGroupLayoutBuilder { + label: Option, + entries: Vec<(u32, BindingVisibility, bool)>, +} + +impl BindGroupLayoutBuilder { + /// Create a new builder with no bindings. + pub fn new() -> Self { + Self { + label: None, + entries: Vec::new(), + } + } + + /// Attach a label for debugging and profiling. + pub fn with_label(mut self, label: &str) -> Self { + self.label = Some(label.to_string()); + self + } + + /// Add a uniform buffer binding visible to the specified stages. + pub fn with_uniform( + mut self, + binding: u32, + visibility: BindingVisibility, + ) -> Self { + self.entries.push((binding, visibility, false)); + self + } + + /// Add a uniform buffer binding with dynamic offset support. + pub fn with_uniform_dynamic( + mut self, + binding: u32, + visibility: BindingVisibility, + ) -> Self { + self.entries.push((binding, visibility, true)); + self + } + + /// Build the layout using the `RenderContext` device. + pub fn build(self, render_context: &RenderContext) -> BindGroupLayout { + let mut platform = + lambda_platform::wgpu::bind::BindGroupLayoutBuilder::new(); + if let Some(label) = &self.label { + platform = platform.with_label(label); + } + for (binding, vis, dynamic) in self.entries.into_iter() { + platform = if dynamic { + platform.with_uniform_dynamic(binding, vis.to_platform()) + } else { + platform.with_uniform(binding, vis.to_platform()) + }; + } + let layout = platform.build(render_context.device()); + BindGroupLayout { + layout: Rc::new(layout), + } + } +} + +/// Builder for creating a bind group for a previously built layout. +pub struct BindGroupBuilder<'a> { + label: Option, + layout: Option<&'a BindGroupLayout>, + entries: Vec<(u32, &'a Buffer, u64, Option)>, +} + +impl<'a> BindGroupBuilder<'a> { + /// Create a new builder with no layout. + pub fn new() -> Self { + Self { + label: None, + layout: None, + entries: Vec::new(), + } + } + + /// Attach a label for debugging and profiling. + pub fn with_label(mut self, label: &str) -> Self { + self.label = Some(label.to_string()); + self + } + + /// Use a previously created layout for this bind group. + pub fn with_layout(mut self, layout: &'a BindGroupLayout) -> Self { + self.layout = Some(layout); + self + } + + /// Bind a uniform buffer to the specified binding index. + pub fn with_uniform( + mut self, + binding: u32, + buffer: &'a Buffer, + offset: u64, + size: Option, + ) -> Self { + self.entries.push((binding, buffer, offset, size)); + self + } + + /// Build the bind group on the current device. + pub fn build(self, render_context: &RenderContext) -> BindGroup { + let layout = self + .layout + .expect("BindGroupBuilder requires a layout before build"); + let mut platform = lambda_platform::wgpu::bind::BindGroupBuilder::new() + .with_layout(&layout.layout); + if let Some(label) = &self.label { + platform = platform.with_label(label); + } + for (binding, buffer, offset, size) in self.entries.into_iter() { + platform = platform.with_uniform(binding, buffer.raw(), offset, size); + } + let group = platform.build(render_context.device()); + BindGroup { + group: Rc::new(group), + } + } +} diff --git a/crates/lambda-rs/src/render/buffer.rs b/crates/lambda-rs/src/render/buffer.rs index fc337866..d6dfd5e7 100644 --- a/crates/lambda-rs/src/render/buffer.rs +++ b/crates/lambda-rs/src/render/buffer.rs @@ -116,6 +116,26 @@ impl Buffer { pub fn buffer_type(&self) -> BufferType { self.buffer_type } + + /// Write a single plain-old-data value into this buffer at the specified + /// byte offset. This is intended for updating uniform buffer contents from + /// the CPU. The `data` type must be trivially copyable. + pub fn write_value( + &self, + render_context: &RenderContext, + offset: u64, + data: &T, + ) { + let bytes = unsafe { + std::slice::from_raw_parts( + (data as *const T) as *const u8, + std::mem::size_of::(), + ) + }; + render_context + .queue() + .write_buffer(self.raw(), offset, bytes); + } } /// Builder for creating `Buffer` objects with explicit usage and properties. diff --git a/crates/lambda-rs/src/render/command.rs b/crates/lambda-rs/src/render/command.rs index 823762e4..80579eb4 100644 --- a/crates/lambda-rs/src/render/command.rs +++ b/crates/lambda-rs/src/render/command.rs @@ -44,4 +44,17 @@ pub enum RenderCommand { }, /// Issue a non‑indexed draw for the provided vertex range. Draw { vertices: Range }, + + /// Bind a previously created bind group to a set index with optional + /// dynamic offsets. Dynamic offsets are counted in bytes and must obey the + /// device's minimum uniform buffer offset alignment when using dynamic + /// uniform bindings. + SetBindGroup { + /// The pipeline layout set index to bind this group to. + set: u32, + /// Resource identifier returned by `RenderContext::attach_bind_group`. + group: super::ResourceId, + /// Dynamic offsets in bytes to apply to bindings marked as dynamic. + dynamic_offsets: Vec, + }, } diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index 735db8e7..2c827c17 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -2,11 +2,13 @@ //! windowing. // Module Exports +pub mod bind; pub mod buffer; pub mod command; pub mod mesh; pub mod pipeline; pub mod render_pass; +pub mod scene_math; pub mod shader; pub mod vertex; pub mod viewport; @@ -98,6 +100,8 @@ impl RenderContextBuilder { size, render_passes: vec![], render_pipelines: vec![], + bind_group_layouts: vec![], + bind_groups: vec![], } } } @@ -118,6 +122,8 @@ pub struct RenderContext { size: (u32, u32), render_passes: Vec, render_pipelines: Vec, + bind_group_layouts: Vec, + bind_groups: Vec, } /// Opaque handle used to refer to resources attached to a `RenderContext`. @@ -138,6 +144,23 @@ impl RenderContext { id } + /// Attach a bind group layout and return a handle for use in pipeline layout composition. + pub fn attach_bind_group_layout( + &mut self, + layout: bind::BindGroupLayout, + ) -> ResourceId { + let id = self.bind_group_layouts.len(); + self.bind_group_layouts.push(layout); + id + } + + /// Attach a bind group and return a handle for use in render commands. + pub fn attach_bind_group(&mut self, group: bind::BindGroup) -> ResourceId { + let id = self.bind_groups.len(); + self.bind_groups.push(group); + id + } + /// Explicitly destroy the context. Dropping also releases resources. pub fn destroy(self) { drop(self); @@ -297,6 +320,16 @@ impl RenderContext { pass.set_scissor_rect(x, y, width, height); } } + RenderCommand::SetBindGroup { + set, + group, + dynamic_offsets, + } => { + let group_ref = self.bind_groups.get(group).ok_or_else(|| { + RenderError::Configuration(format!("Unknown bind group {group}")) + })?; + pass.set_bind_group(set, group_ref.raw(), &dynamic_offsets); + } RenderCommand::BindVertexBuffer { pipeline, buffer } => { let pipeline_ref = self.render_pipelines.get(pipeline).ok_or_else(|| { diff --git a/crates/lambda-rs/src/render/pipeline.rs b/crates/lambda-rs/src/render/pipeline.rs index 464c1298..eba2b70d 100644 --- a/crates/lambda-rs/src/render/pipeline.rs +++ b/crates/lambda-rs/src/render/pipeline.rs @@ -10,6 +10,7 @@ use std::{ use lambda_platform::wgpu::types as wgpu; use super::{ + bind, buffer::Buffer, render_pass::RenderPass, shader::Shader, @@ -77,10 +78,33 @@ struct BufferBinding { attributes: Vec, } +#[derive(Clone, Copy, Debug)] +/// Controls triangle face culling for the graphics pipeline. +pub enum CullingMode { + /// Disable face culling; render both triangle faces. + None, + /// Cull triangles whose winding is counterclockwise after projection. + Front, + /// Cull triangles whose winding is clockwise after projection. + Back, +} + +impl CullingMode { + fn to_wgpu(self) -> Option { + match self { + CullingMode::None => None, + CullingMode::Front => Some(wgpu::Face::Front), + CullingMode::Back => Some(wgpu::Face::Back), + } + } +} + /// Builder for creating a graphics `RenderPipeline`. pub struct RenderPipelineBuilder { push_constants: Vec, bindings: Vec, + culling: CullingMode, + bind_group_layouts: Vec>, label: Option, } @@ -90,6 +114,8 @@ impl RenderPipelineBuilder { Self { push_constants: Vec::new(), bindings: Vec::new(), + culling: CullingMode::Back, + bind_group_layouts: Vec::new(), label: None, } } @@ -123,6 +149,21 @@ impl RenderPipelineBuilder { self } + /// Configure triangle face culling. Defaults to culling back faces. + pub fn with_culling(mut self, mode: CullingMode) -> Self { + self.culling = mode; + self + } + + /// Provide one or more bind group layouts used to create the pipeline layout. + pub fn with_layouts(mut self, layouts: &[&bind::BindGroupLayout]) -> Self { + self.bind_group_layouts = layouts + .iter() + .map(|l| std::rc::Rc::new(l.raw().clone())) + .collect(); + self + } + /// Build a graphics pipeline using the provided shader modules and /// previously registered vertex inputs and push constants. pub fn build( @@ -159,10 +200,15 @@ impl RenderPipelineBuilder { }) .collect(); + let bind_group_layout_refs: Vec<&wgpu::BindGroupLayout> = self + .bind_group_layouts + .iter() + .map(|rc| rc.as_ref()) + .collect(); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("lambda-pipeline-layout"), - bind_group_layouts: &[], + bind_group_layouts: &bind_group_layout_refs, push_constant_ranges: &push_constant_ranges, }); @@ -224,11 +270,16 @@ impl RenderPipelineBuilder { buffers: vertex_buffer_layouts.as_slice(), }; + let primitive_state = wgpu::PrimitiveState { + cull_mode: self.culling.to_wgpu(), + ..wgpu::PrimitiveState::default() + }; + let pipeline_descriptor = wgpu::RenderPipelineDescriptor { label: self.label.as_deref(), layout: Some(&pipeline_layout), vertex: vertex_state, - primitive: wgpu::PrimitiveState::default(), + primitive: primitive_state, depth_stencil: None, multisample: wgpu::MultisampleState::default(), fragment, diff --git a/crates/lambda-rs/src/render/scene_math.rs b/crates/lambda-rs/src/render/scene_math.rs new file mode 100644 index 00000000..93ae341a --- /dev/null +++ b/crates/lambda-rs/src/render/scene_math.rs @@ -0,0 +1,200 @@ +//! Scene mathematics helpers for common camera and model transforms. +//! +//! These functions centralize the construction of model, view, and projection +//! matrices so that examples and applications do not need to implement the +//! mathematics by hand. All functions use right-handed coordinates and the +//! engine's existing matrix utilities. + +use crate::math::{ + matrix, + matrix::Matrix, +}; + +/// Convert OpenGL-style normalized device coordinates (Z in [-1, 1]) to +/// wgpu/Vulkan/Direct3D normalized device coordinates (Z in [0, 1]). +/// +/// This matrix leaves X and Y unchanged and remaps Z as `z = 0.5 * z + 0.5`. +fn opengl_to_wgpu_ndc() -> [[f32; 4]; 4] { + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 0.5, 0.0], + [0.0, 0.0, 0.5, 1.0], + ] +} + +/// Simple camera parameters used to produce a perspective projection +/// together with a view translation. +#[derive(Clone, Copy, Debug)] +pub struct SimpleCamera { + /// World-space camera position used to build a translation view matrix. + pub position: [f32; 3], + /// Field of view in turns (where one turn equals 2π radians). + pub field_of_view_in_turns: f32, + /// Near clipping plane distance. + pub near_clipping_plane: f32, + /// Far clipping plane distance. + pub far_clipping_plane: f32, +} + +/// Compute a model matrix using a translation vector, a rotation axis and +/// an angle in turns, and a uniform scale factor applied around the origin. +pub fn compute_model_matrix( + translation: [f32; 3], + rotation_axis: [f32; 3], + angle_in_turns: f32, + uniform_scale: f32, +) -> [[f32; 4]; 4] { + let mut model: [[f32; 4]; 4] = matrix::identity_matrix(4, 4); + // Apply rotation first, then scaling via a diagonal matrix, and finally translation. + model = matrix::rotate_matrix(model, rotation_axis, angle_in_turns); + + let mut scaled: [[f32; 4]; 4] = [[0.0; 4]; 4]; + for i in 0..4 { + for j in 0..4 { + if i == j { + scaled[i][j] = if i == 3 { 1.0 } else { uniform_scale }; + } else { + scaled[i][j] = 0.0; + } + } + } + model = model.multiply(&scaled); + + let translation_matrix: [[f32; 4]; 4] = + matrix::translation_matrix(translation); + translation_matrix.multiply(&model) +} + +/// Compute a model matrix that applies a rotation and uniform scale about a +/// specific local-space pivot point before applying a world-space translation. +/// +/// This is useful when your mesh vertices are not centered around the origin +/// and you want to rotate the object "in place" (around its own center) rather +/// than orbit around the world origin. +pub fn compute_model_matrix_about_pivot( + translation: [f32; 3], + rotation_axis: [f32; 3], + angle_in_turns: f32, + uniform_scale: f32, + pivot_local: [f32; 3], +) -> [[f32; 4]; 4] { + // Base rotation and scale around origin. + let base = compute_model_matrix( + [0.0, 0.0, 0.0], + rotation_axis, + angle_in_turns, + uniform_scale, + ); + // Translate to pivot, apply base, then translate back from pivot. + let to_pivot: [[f32; 4]; 4] = matrix::translation_matrix(pivot_local); + let from_pivot: [[f32; 4]; 4] = matrix::translation_matrix([ + -pivot_local[0], + -pivot_local[1], + -pivot_local[2], + ]); + // World translation after rotating around the pivot. + let world_translation: [[f32; 4]; 4] = + matrix::translation_matrix(translation); + + // For column-vector convention: T_world * ( T_pivot * (R*S) * T_-pivot ) + world_translation + .multiply(&to_pivot) + .multiply(&base) + .multiply(&from_pivot) +} + +/// Compute a simple view matrix from a camera position. +/// +/// The view matrix is the inverse of the camera transform. For a camera whose +/// world-space transform is a pure translation by `camera_position`, the +/// inverse is a translation by the negated vector. This function applies that +/// inverse so the world moves opposite to the camera when rendering. +pub fn compute_view_matrix(camera_position: [f32; 3]) -> [[f32; 4]; 4] { + let inverse = [ + -camera_position[0], + -camera_position[1], + -camera_position[2], + ]; + matrix::translation_matrix(inverse) +} + +/// Compute a perspective projection matrix from camera parameters and the +/// current viewport width and height. +pub fn compute_perspective_projection( + field_of_view_in_turns: f32, + viewport_width: u32, + viewport_height: u32, + near_clipping_plane: f32, + far_clipping_plane: f32, +) -> [[f32; 4]; 4] { + let aspect_ratio = viewport_width as f32 / viewport_height as f32; + // Build an OpenGL-style projection (Z in [-1, 1]) using the existing math + // helper, then convert to wgpu/Vulkan/D3D NDC (Z in [0, 1]). + let projection_gl: [[f32; 4]; 4] = matrix::perspective_matrix( + field_of_view_in_turns, + aspect_ratio, + near_clipping_plane, + far_clipping_plane, + ); + let conversion = opengl_to_wgpu_ndc(); + conversion.multiply(&projection_gl) +} + +/// Compute a full model-view-projection matrix given a simple camera, a +/// viewport, and the model transform parameters. +pub fn compute_model_view_projection_matrix( + camera: &SimpleCamera, + viewport_width: u32, + viewport_height: u32, + model_translation: [f32; 3], + rotation_axis: [f32; 3], + angle_in_turns: f32, + uniform_scale: f32, +) -> [[f32; 4]; 4] { + let model = compute_model_matrix( + model_translation, + rotation_axis, + angle_in_turns, + uniform_scale, + ); + let view = compute_view_matrix(camera.position); + let projection = compute_perspective_projection( + camera.field_of_view_in_turns, + viewport_width, + viewport_height, + camera.near_clipping_plane, + camera.far_clipping_plane, + ); + projection.multiply(&view).multiply(&model) +} + +/// Compute a full model-view-projection matrix for a rotation around a specific +/// local-space pivot point. +pub fn compute_model_view_projection_matrix_about_pivot( + camera: &SimpleCamera, + viewport_width: u32, + viewport_height: u32, + model_translation: [f32; 3], + rotation_axis: [f32; 3], + angle_in_turns: f32, + uniform_scale: f32, + pivot_local: [f32; 3], +) -> [[f32; 4]; 4] { + let model = compute_model_matrix_about_pivot( + model_translation, + rotation_axis, + angle_in_turns, + uniform_scale, + pivot_local, + ); + let view = compute_view_matrix(camera.position); + let projection = compute_perspective_projection( + camera.field_of_view_in_turns, + viewport_width, + viewport_height, + camera.near_clipping_plane, + camera.far_clipping_plane, + ); + projection.multiply(&view).multiply(&model) +} diff --git a/docs/specs/uniform-buffers-and-bind-groups.md b/docs/specs/uniform-buffers-and-bind-groups.md new file mode 100644 index 00000000..d6993f46 --- /dev/null +++ b/docs/specs/uniform-buffers-and-bind-groups.md @@ -0,0 +1,311 @@ +--- +title: "Uniform Buffers and Bind Groups" +document_id: "ubo-spec-2025-10-11" +status: "draft" +created: "2025-10-11T00:00:00Z" +last_updated: "2025-10-11T00:00:00Z" +version: "0.1.0" +engine_workspace_version: "2023.1.30" +wgpu_version: "26.0.1" +shader_backend_default: "naga" +winit_version: "0.29.10" +repo_commit: "0fdc489f5560acf809ca9cd8440f086baab7bad5" +owners: ["lambda-sh"] +reviewers: ["engine", "rendering"] +tags: ["spec", "rendering", "uniforms", "bind-groups", "wgpu"] +--- + +# Uniform Buffers and Bind Groups + +This spec defines uniform buffer objects (UBO) and bind groups for Lambda’s +wgpu-backed renderer. It follows the existing builder/command patterns and +splits responsibilities between the platform layer (`lambda-rs-platform`) and +the high-level API (`lambda-rs`). + +The design enables larger, structured GPU constants (cameras, materials, +per-frame data) beyond push constants, with an ergonomic path to dynamic +offsets for batching many small uniforms in a single buffer. + +## Goals + +- Add first-class uniform buffers and bind groups. +- Maintain builder ergonomics consistent with buffers, pipelines, and passes. +- Integrate with the existing render command stream (inside a pass). +- Provide a portable, WGSL/GLSL-friendly layout model and validation. +- Expose dynamic uniform offsets (opt-in) with correct alignment handling. + +## Non-Goals + +- Storage buffers, textures/samplers, and compute are referenced but not + implemented here; separate specs cover them. +- Descriptor set caching beyond wgpu’s internal caches. + +## Background + +Roadmap docs propose UBOs and bind groups to complement push constants and +unlock cameras/materials. This spec refines those sketches into concrete API +types, builders, commands, validation, and an implementation plan for both +layers of the workspace. + +## Architecture Overview + +- Platform (`lambda-rs-platform`) + - Thin wrappers around `wgpu::BindGroupLayout` and `wgpu::BindGroup` with + builder structs that produce concrete `wgpu` descriptors and perform + validation against device limits. + - Expose the raw `wgpu` handles for use by higher layers. + +- High level (`lambda-rs`) + - Public builders/types for bind group layouts and bind groups aligned with + `RenderPipelineBuilder` and `BufferBuilder` patterns. + - Extend `RenderPipelineBuilder` to accept bind group layouts, building a + `wgpu::PipelineLayout` under the hood. + - Extend `RenderCommand` with `SetBindGroup` to bind resources during a pass. + +Data flow (one-time setup → per-frame): +``` +BindGroupLayoutBuilder --> BindGroupLayout --+--> RenderPipelineBuilder (layouts) + | +BufferBuilder (Usage::UNIFORM) --------------+--> BindGroupBuilder (uniform binding) + +Per-frame commands: BeginRenderPass -> SetPipeline -> SetBindGroup -> Draw -> End +``` + +## Platform API Design (lambda-rs-platform) + +- Module: `lambda_platform::wgpu::bind` + - `struct BindGroupLayout { raw: wgpu::BindGroupLayout, label: Option }` + - `struct BindGroup { raw: wgpu::BindGroup, label: Option }` + - `enum Visibility { Vertex, Fragment, Compute, BothVF, All }` + - Maps to `wgpu::ShaderStages`. + - `enum BindingKind { Uniform { dynamic: bool }, /* future: SampledTexture, Sampler, Storage*/ }` + - `struct BindGroupLayoutBuilder { entries: Vec, label: Option }` + - `fn new() -> Self` + - `fn with_uniform(mut self, binding: u32, visibility: Visibility) -> Self` + - `fn with_uniform_dynamic(mut self, binding: u32, visibility: Visibility) -> Self` + - `fn with_label(mut self, label: &str) -> Self` + - `fn build(self, device: &wgpu::Device) -> BindGroupLayout` + - `struct BindGroupBuilder { layout: wgpu::BindGroupLayout, entries: Vec, label: Option }` + - `fn new() -> Self` + - `fn with_layout(mut self, layout: &BindGroupLayout) -> Self` + - `fn with_uniform(mut self, binding: u32, buffer: &wgpu::Buffer, offset: u64, size: Option) -> Self` + - `fn with_label(mut self, label: &str) -> Self` + - `fn build(self, device: &wgpu::Device) -> BindGroup` + +Validation and limits +- On `build`, validate against `wgpu::Limits`: + - `max_uniform_buffer_binding_size` for explicit sizes. + - `min_uniform_buffer_offset_alignment` for dynamic offsets (caller provides + aligned `offset`; builder re-checks and errors if misaligned). + - `max_bind_groups` when composing pipeline layouts (exposed via helper). +- Return detailed error strings mapped into high-level errors in `lambda-rs`. + +Helpers +- `fn shader_stages(vis: Visibility) -> wgpu::ShaderStages` +- `fn align_up(value: u64, align: u64) -> u64` + +## High-Level API Design (lambda-rs) + +New module: `lambda::render::bind` +- `pub struct BindGroupLayout { /* holds Rc */ }` +- `pub struct BindGroup { /* holds Rc */ }` +- `pub enum BindingVisibility { Vertex, Fragment, Compute, BothVF, All }` +- `pub struct BindGroupLayoutBuilder { /* mirrors platform builder */ }` + - `pub fn new() -> Self` + - `pub fn with_uniform(self, binding: u32, visibility: BindingVisibility) -> Self` + - `pub fn with_uniform_dynamic(self, binding: u32, visibility: BindingVisibility) -> Self` + - `pub fn with_label(self, label: &str) -> Self` + - `pub fn build(self, rc: &RenderContext) -> BindGroupLayout` +- `pub struct BindGroupBuilder { /* mirrors platform builder */ }` + - `pub fn new() -> Self` + - `pub fn with_layout(self, layout: &BindGroupLayout) -> Self` + - `pub fn with_uniform(self, binding: u32, buffer: &buffer::Buffer, offset: u64, size: Option) -> Self` + - `pub fn with_label(self, label: &str) -> Self` + - `pub fn build(self, rc: &RenderContext) -> BindGroup` + +Pipeline integration +- `RenderPipelineBuilder::with_layouts(&[&BindGroupLayout])` stores layouts and + constructs a `wgpu::PipelineLayout` during `build(...)`. + +Render commands +- Extend `RenderCommand` with: + - `SetBindGroup { set: u32, group: super::ResourceId, dynamic_offsets: Vec }` +- `RenderContext::encode_pass` maps to `wgpu::RenderPass::set_bind_group`. + +Buffers +- Continue using `buffer::BufferBuilder` with `Usage::UNIFORM` and CPU-visible + properties for frequently updated UBOs. No new types are strictly required, + but an optional typed helper improves ergonomics: + +```rust +#[repr(C)] +#[derive(Copy, Clone)] +pub struct UniformBuffer { inner: buffer::Buffer, _phantom: core::marker::PhantomData } + +impl UniformBuffer { + pub fn raw(&self) -> &buffer::Buffer { &self.inner } + pub fn write(&self, rc: &mut RenderContext, value: &T) where T: Copy { + let bytes = unsafe { + std::slice::from_raw_parts( + (value as *const T) as *const u8, + core::mem::size_of::(), + ) + }; + rc.queue().write_buffer(self.inner.raw(), 0, bytes); + } +} +``` + +## Layout and Alignment Rules + +- WGSL/std140-like layout for uniform buffers (via naga/wgpu): + - Scalars 4 B; `vec2` 8 B; `vec3/vec4` 16 B; matrices 16 B column alignment. + - Struct members rounded up to their alignment; struct size rounded up to the + max alignment of its fields. +- Rust-side structs used as UBOs must be `#[repr(C)]` and plain-old-data. + Recommend `bytemuck::{Pod, Zeroable}` in examples for safety. +- Dynamic offsets must be multiples of + `limits.min_uniform_buffer_offset_alignment`. +- Respect `limits.max_uniform_buffer_binding_size` when slicing UBOs. + +## Example Usage + +Rust (high level) +```rust +use lambda::render::{ + bind::{BindGroupLayoutBuilder, BindGroupBuilder, BindingVisibility}, + buffer::{BufferBuilder, Usage, Properties}, + pipeline::RenderPipelineBuilder, + command::RenderCommand as RC, +}; + +#[repr(C)] +#[derive(Copy, Clone)] +struct Globals { view_proj: [[f32; 4]; 4] } + +// Layout: set(0)@binding(0) uniform visible to vertex stage +let layout = BindGroupLayoutBuilder::new() + .with_uniform(0, BindingVisibility::Vertex) + .build(&mut rc); + +// Create UBO +let ubo = BufferBuilder::new() + .with_length(core::mem::size_of::()) + .with_usage(Usage::UNIFORM) + .with_properties(Properties::CPU_VISIBLE) + .with_label("globals-ubo") + .build(&mut rc, vec![Globals { view_proj }])?; + +// Bind group that points binding(0) at our UBO +let group0 = BindGroupBuilder::new() + .with_layout(&layout) + .with_uniform(0, &ubo, 0, None) + .build(&mut rc); + +// Pipeline includes the layout +let pipe = RenderPipelineBuilder::new() + .with_layouts(&[&layout]) + .with_buffer(vbo, attributes) + .build(&mut rc, &pass, &vs, Some(&fs)); + +// Encode commands +let cmds = vec![ + RC::BeginRenderPass { render_pass: pass_id, viewport }, + RC::SetPipeline { pipeline: pipe_id }, + RC::SetBindGroup { set: 0, group: group0_id, dynamic_offsets: vec![] }, + RC::Draw { vertices: 0..3 }, + RC::EndRenderPass, +]; +rc.render(cmds); +``` + +WGSL snippet +```wgsl +struct Globals { view_proj: mat4x4; }; +@group(0) @binding(0) var globals: Globals; + +@vertex +fn vs_main(in_pos: vec3) -> @builtin(position) vec4 { + return globals.view_proj * vec4(in_pos, 1.0); +} +``` + +GLSL snippet (via naga -> SPIR-V) +```glsl +layout(set = 0, binding = 0) uniform Globals { mat4 view_proj; } globals; +void main() { gl_Position = globals.view_proj * vec4(in_pos, 1.0); } +``` + +Dynamic offsets (optional) +```rust +let dyn_layout = BindGroupLayoutBuilder::new() + .with_uniform_dynamic(0, BindingVisibility::Vertex) + .build(&mut rc); + +let stride = align_up(core::mem::size_of::() as u64, + rc.device().limits().min_uniform_buffer_offset_alignment); +let offsets = vec![0u32, stride as u32, (2*stride) as u32]; +RC::SetBindGroup { set: 0, group: dyn_group_id, dynamic_offsets: offsets }; +``` + +## Error Handling + +- `BufferBuilder` already errors on zero length; keep behavior. +- New bind errors returned during `build` with clear messages: + - "uniform binding size exceeds max_uniform_buffer_binding_size" + - "dynamic offset not aligned to min_uniform_buffer_offset_alignment" + - "invalid binding index (duplicate or out of range)" + - "pipeline layouts exceed device.max_bind_groups" + +## Performance Notes + +- Prefer `Properties::DEVICE_LOCAL` for long-lived UBOs updated infrequently; + otherwise CPU-visible + `Queue::write_buffer` for per-frame updates. +- Dynamic offsets reduce bind group churn; align and pack many objects per UBO. +- Group stable data (camera) separate from frequently changing data (object). + +## Implementation Plan + +Phase 0 (minimal, static UBO) +- Platform: add bind module, layout/bind builders, validation helpers. +- High level: expose `bind` module; add pipeline `.with_layouts`; extend + `RenderCommand` and encoder with `SetBindGroup`. +- Update examples to use one UBO for a transform/camera. + +Phase 1 (dynamic offsets) +- Add `.with_uniform_dynamic` to layout builder and support offsets in + `SetBindGroup`. Validate alignment vs device limits. +- Add small helper to compute aligned strides. + +Phase 2 (ergonomics/testing) +- Optional `UniformBuffer` wrapper with `.write(&T)` for convenience. +- Unit tests for builders and validation; integration test that animates a + triangle with a camera UBO. + +File layout +- Platform: `crates/lambda-rs-platform/src/wgpu/bind.rs` (+ `mod.rs` re-export). +- High level: `crates/lambda-rs/src/render/bind.rs`, plus edits to + `render/pipeline.rs`, `render/command.rs`, and `render/mod.rs` to wire in + pipeline layouts and `SetBindGroup` encoding. + +## Testing Plan + +- Unit tests + - Layout builder produces expected `wgpu::BindGroupLayoutEntry` values. + - Bind group builder rejects misaligned dynamic offsets. + - Pipeline builder errors if too many layouts are provided. +- Integration tests (`crates/lambda-rs/tests/runnables.rs`) + - Simple pipeline using a UBO to transform vertices; compare golden pixels or + log successful draw on supported adapters. + +## Open Questions + +- Should we introduce a typed `Uniform` handle now, or wait until there is a + second typed resource (e.g., storage) to avoid a one-off abstraction? +- Do we want a tiny cache for bind groups keyed by buffer+offset for frequent + reuse, or rely entirely on wgpu’s internal caches? + +## Changelog + +- 2025-10-11 (v0.1.0) — Initial draft aligned to roadmap; specifies platform + and high-level APIs, commands, validation, examples, and phased delivery. From be6737b2e2831d5c4e2678b291a1a931c54b0c4c Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sun, 12 Oct 2025 20:37:37 -0700 Subject: [PATCH 02/17] [update] animations to not be frame dependent. --- crates/lambda-rs/examples/push_constants.rs | 17 ++++++++--------- .../examples/uniform_buffer_triangle.rs | 13 +++++++------ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/crates/lambda-rs/examples/push_constants.rs b/crates/lambda-rs/examples/push_constants.rs index 083329c7..e58bdce0 100644 --- a/crates/lambda-rs/examples/push_constants.rs +++ b/crates/lambda-rs/examples/push_constants.rs @@ -108,14 +108,15 @@ pub fn push_constants_to_bytes(push_constants: &PushConstant) -> &[u32] { // --------------------------------- COMPONENT --------------------------------- +const ROTATION_TURNS_PER_SECOND: f32 = 0.12; + pub struct PushConstantsExample { - frame_number: u64, + elapsed_seconds: f32, shader: Shader, fs: Shader, mesh: Option, render_pipeline: Option, render_pass: Option, - last_frame: std::time::Duration, width: u32, height: u32, } @@ -227,13 +228,12 @@ impl Component for PushConstantsExample { return Ok(ComponentResult::Success); } - /// Update the frame number every frame. + /// Update elapsed time every frame. fn on_update( &mut self, last_frame: &std::time::Duration, ) -> Result { - self.last_frame = *last_frame; - self.frame_number += 1; + self.elapsed_seconds += last_frame.as_secs_f32(); return Ok(ComponentResult::Success); } @@ -241,20 +241,20 @@ impl Component for PushConstantsExample { &mut self, render_context: &mut lambda::render::RenderContext, ) -> Vec { - self.frame_number += 1; let camera = SimpleCamera { position: [0.0, 0.0, 3.0], field_of_view_in_turns: 0.25, near_clipping_plane: 0.1, far_clipping_plane: 100.0, }; + let angle_in_turns = ROTATION_TURNS_PER_SECOND * self.elapsed_seconds; let mesh_matrix = compute_model_view_projection_matrix_about_pivot( &camera, self.width.max(1), self.height.max(1), [0.0, -1.0 / 3.0, 0.0], [0.0, 1.0, 0.0], - 0.001 * self.frame_number as f32, + angle_in_turns, 0.5, [0.0, 1.0 / 3.0, 0.0], ); @@ -329,10 +329,9 @@ impl Default for PushConstantsExample { let fs = builder.build(triangle_fragment_shader); return Self { - frame_number: 0, + elapsed_seconds: 0.0, shader, fs, - last_frame: std::time::Duration::from_secs(0), mesh: None, render_pipeline: None, render_pass: None, diff --git a/crates/lambda-rs/examples/uniform_buffer_triangle.rs b/crates/lambda-rs/examples/uniform_buffer_triangle.rs index 6615941e..ab8bb491 100644 --- a/crates/lambda-rs/examples/uniform_buffer_triangle.rs +++ b/crates/lambda-rs/examples/uniform_buffer_triangle.rs @@ -102,7 +102,7 @@ pub struct GlobalsUniform { // --------------------------------- COMPONENT --------------------------------- pub struct UniformBufferExample { - frame_number: u64, + elapsed_seconds: f32, shader: Shader, fragment_shader: Shader, mesh: Option, @@ -273,9 +273,9 @@ impl Component for UniformBufferExample { fn on_update( &mut self, - _last_frame: &std::time::Duration, + last_frame: &std::time::Duration, ) -> Result { - self.frame_number += 1; + self.elapsed_seconds += last_frame.as_secs_f32(); return Ok(ComponentResult::Success); } @@ -283,7 +283,7 @@ impl Component for UniformBufferExample { &mut self, render_context: &mut lambda::render::RenderContext, ) -> Vec { - self.frame_number += 1; + const ROTATION_TURNS_PER_SECOND: f32 = 0.12; // Compute the model, view, projection matrix for this frame. let camera = SimpleCamera { @@ -292,13 +292,14 @@ impl Component for UniformBufferExample { near_clipping_plane: 0.1, far_clipping_plane: 100.0, }; + let angle_in_turns = ROTATION_TURNS_PER_SECOND * self.elapsed_seconds; let render_matrix = compute_model_view_projection_matrix_about_pivot( &camera, self.width.max(1), self.height.max(1), [0.0, -1.0 / 3.0, 0.0], [0.0, 1.0, 0.0], - 0.001 * self.frame_number as f32, + angle_in_turns, 0.5, [0.0, 1.0 / 3.0, 0.0], ); @@ -378,7 +379,7 @@ impl Default for UniformBufferExample { let fragment_shader = builder.build(fragment_virtual_shader); return Self { - frame_number: 0, + elapsed_seconds: 0.0, shader, fragment_shader, mesh: None, From 60d30f13a2afa88460c012147f5d77f02f3a82d5 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sun, 12 Oct 2025 21:00:20 -0700 Subject: [PATCH 03/17] [update] specification. --- docs/specs/uniform-buffers-and-bind-groups.md | 52 ++++++++++--------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/docs/specs/uniform-buffers-and-bind-groups.md b/docs/specs/uniform-buffers-and-bind-groups.md index d6993f46..25c76074 100644 --- a/docs/specs/uniform-buffers-and-bind-groups.md +++ b/docs/specs/uniform-buffers-and-bind-groups.md @@ -3,13 +3,13 @@ title: "Uniform Buffers and Bind Groups" document_id: "ubo-spec-2025-10-11" status: "draft" created: "2025-10-11T00:00:00Z" -last_updated: "2025-10-11T00:00:00Z" -version: "0.1.0" +last_updated: "2025-10-13T00:00:00Z" +version: "0.1.1" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "0fdc489f5560acf809ca9cd8440f086baab7bad5" +repo_commit: "a9ecb6df8188fb189e3221598b36f6cf757697bb" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "rendering", "uniforms", "bind-groups", "wgpu"] @@ -76,9 +76,8 @@ Per-frame commands: BeginRenderPass -> SetPipeline -> SetBindGroup -> Draw -> En - Module: `lambda_platform::wgpu::bind` - `struct BindGroupLayout { raw: wgpu::BindGroupLayout, label: Option }` - `struct BindGroup { raw: wgpu::BindGroup, label: Option }` - - `enum Visibility { Vertex, Fragment, Compute, BothVF, All }` + - `enum Visibility { Vertex, Fragment, Compute, VertexAndFragment, All }` - Maps to `wgpu::ShaderStages`. - - `enum BindingKind { Uniform { dynamic: bool }, /* future: SampledTexture, Sampler, Storage*/ }` - `struct BindGroupLayoutBuilder { entries: Vec, label: Option }` - `fn new() -> Self` - `fn with_uniform(mut self, binding: u32, visibility: Visibility) -> Self` @@ -93,23 +92,23 @@ Per-frame commands: BeginRenderPass -> SetPipeline -> SetBindGroup -> Draw -> En - `fn build(self, device: &wgpu::Device) -> BindGroup` Validation and limits -- On `build`, validate against `wgpu::Limits`: - - `max_uniform_buffer_binding_size` for explicit sizes. - - `min_uniform_buffer_offset_alignment` for dynamic offsets (caller provides - aligned `offset`; builder re-checks and errors if misaligned). - - `max_bind_groups` when composing pipeline layouts (exposed via helper). -- Return detailed error strings mapped into high-level errors in `lambda-rs`. +- Current implementation defers validation to `wgpu` and backend validators. + Builders do not perform explicit limit checks yet; invalid inputs will surface + as `wgpu` validation errors at creation/bind time. + - No explicit checks for `max_uniform_buffer_binding_size` in builders. + - Dynamic offset alignment is not re-validated; callers must ensure offsets are + multiples of `min_uniform_buffer_offset_alignment`. + - Pipeline layout size (`max_bind_groups`) is not pre-validated by the builder. Helpers -- `fn shader_stages(vis: Visibility) -> wgpu::ShaderStages` -- `fn align_up(value: u64, align: u64) -> u64` +- No dedicated helpers are exposed in the platform layer yet. ## High-Level API Design (lambda-rs) New module: `lambda::render::bind` - `pub struct BindGroupLayout { /* holds Rc */ }` - `pub struct BindGroup { /* holds Rc */ }` -- `pub enum BindingVisibility { Vertex, Fragment, Compute, BothVF, All }` +- `pub enum BindingVisibility { Vertex, Fragment, Compute, VertexAndFragment, All }` - `pub struct BindGroupLayoutBuilder { /* mirrors platform builder */ }` - `pub fn new() -> Self` - `pub fn with_uniform(self, binding: u32, visibility: BindingVisibility) -> Self` @@ -119,7 +118,7 @@ New module: `lambda::render::bind` - `pub struct BindGroupBuilder { /* mirrors platform builder */ }` - `pub fn new() -> Self` - `pub fn with_layout(self, layout: &BindGroupLayout) -> Self` - - `pub fn with_uniform(self, binding: u32, buffer: &buffer::Buffer, offset: u64, size: Option) -> Self` + - `pub fn with_uniform(self, binding: u32, buffer: &buffer::Buffer, offset: u64, size: Option) -> Self` - `pub fn with_label(self, label: &str) -> Self` - `pub fn build(self, rc: &RenderContext) -> BindGroup` @@ -167,6 +166,9 @@ impl UniformBuffer { - Dynamic offsets must be multiples of `limits.min_uniform_buffer_offset_alignment`. - Respect `limits.max_uniform_buffer_binding_size` when slicing UBOs. + - Matrices are column‑major in GLSL/WGSL. If your CPU math builds row‑major + matrices, either transpose before uploading to the GPU or mark GLSL uniform + blocks with `layout(row_major)` to avoid unexpected transforms. ## Example Usage @@ -242,8 +244,9 @@ let dyn_layout = BindGroupLayoutBuilder::new() .with_uniform_dynamic(0, BindingVisibility::Vertex) .build(&mut rc); -let stride = align_up(core::mem::size_of::() as u64, - rc.device().limits().min_uniform_buffer_offset_alignment); +let align = rc.device().limits().min_uniform_buffer_offset_alignment; +let size = core::mem::size_of::() as u64; +let stride = ((size + align - 1) / align) * align; // manual align-up let offsets = vec![0u32, stride as u32, (2*stride) as u32]; RC::SetBindGroup { set: 0, group: dyn_group_id, dynamic_offsets: offsets }; ``` @@ -251,11 +254,10 @@ RC::SetBindGroup { set: 0, group: dyn_group_id, dynamic_offsets: offsets }; ## Error Handling - `BufferBuilder` already errors on zero length; keep behavior. -- New bind errors returned during `build` with clear messages: - - "uniform binding size exceeds max_uniform_buffer_binding_size" - - "dynamic offset not aligned to min_uniform_buffer_offset_alignment" - - "invalid binding index (duplicate or out of range)" - - "pipeline layouts exceed device.max_bind_groups" +- Bind group and layout builders currently do not pre‑validate against device limits. + Invalid sizes/offsets typically surface as `wgpu` validation errors during creation + or when calling `set_bind_group`. Ensure dynamic offsets are aligned to device limits + and uniform ranges respect `max_uniform_buffer_binding_size`. ## Performance Notes @@ -278,7 +280,7 @@ Phase 1 (dynamic offsets) - Add small helper to compute aligned strides. Phase 2 (ergonomics/testing) -- Optional `UniformBuffer` wrapper with `.write(&T)` for convenience. +- Optional `UniformBuffer` wrapper with `.write(&T)` for convenience (not implemented yet). - Unit tests for builders and validation; integration test that animates a triangle with a camera UBO. @@ -307,5 +309,5 @@ File layout ## Changelog -- 2025-10-11 (v0.1.0) — Initial draft aligned to roadmap; specifies platform - and high-level APIs, commands, validation, examples, and phased delivery. +- 2025-10-13 (v0.1.1) — Synced spec to implementation: renamed visibility enum variant to `VertexAndFragment`; clarified that builders defer validation to `wgpu`; updated `with_uniform` size type to `Option`; added note on GPU column‑major matrices and CPU transpose guidance; adjusted dynamic offset example. +- 2025-10-11 (v0.1.0) — Initial draft aligned to roadmap; specifies platform and high-level APIs, commands, validation, examples, and phased delivery. From 3e63f82b0a364bc52a40ae297a5300f998800518 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sun, 12 Oct 2025 21:59:31 -0700 Subject: [PATCH 04/17] [add] unit tests --- crates/lambda-rs-platform/src/wgpu/bind.rs | 24 ++ crates/lambda-rs/src/render/bind.rs | 30 +++ crates/lambda-rs/src/render/scene_math.rs | 255 +++++++++++++++++++++ 3 files changed, 309 insertions(+) diff --git a/crates/lambda-rs-platform/src/wgpu/bind.rs b/crates/lambda-rs-platform/src/wgpu/bind.rs index ffda3012..9165c1e5 100644 --- a/crates/lambda-rs-platform/src/wgpu/bind.rs +++ b/crates/lambda-rs-platform/src/wgpu/bind.rs @@ -70,6 +70,30 @@ impl Visibility { } } +#[cfg(test)] +mod tests { + use super::*; + + /// This test verifies that each public binding visibility option is + /// converted into the correct set of shader stage flags expected by the + /// underlying graphics layer. It checks single stage selections, a + /// combination of vertex and fragment stages, and the catch‑all option that + /// enables all stages. The goal is to demonstrate that the mapping logic is + /// precise and predictable so higher level code can rely on it when building + /// layouts and groups. + #[test] + fn visibility_maps_to_expected_shader_stages() { + assert_eq!(Visibility::Vertex.to_wgpu(), wgpu::ShaderStages::VERTEX); + assert_eq!(Visibility::Fragment.to_wgpu(), wgpu::ShaderStages::FRAGMENT); + assert_eq!(Visibility::Compute.to_wgpu(), wgpu::ShaderStages::COMPUTE); + assert_eq!( + Visibility::VertexAndFragment.to_wgpu(), + wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT + ); + assert_eq!(Visibility::All.to_wgpu(), wgpu::ShaderStages::all()); + } +} + #[derive(Default)] /// Builder for creating a `wgpu::BindGroupLayout`. pub struct BindGroupLayoutBuilder { diff --git a/crates/lambda-rs/src/render/bind.rs b/crates/lambda-rs/src/render/bind.rs index e2b3d014..9316b6b0 100644 --- a/crates/lambda-rs/src/render/bind.rs +++ b/crates/lambda-rs/src/render/bind.rs @@ -36,6 +36,36 @@ impl BindingVisibility { } } +#[cfg(test)] +mod tests { + use super::*; + + /// This test confirms that every high‑level binding visibility option maps + /// directly to the corresponding visibility option in the platform layer. + /// Matching these values ensures that builder code in this module forwards + /// intent without alteration, which is important for readability and for + /// maintenance when constructing layouts and groups. + #[test] + fn binding_visibility_maps_to_platform_enum() { + use lambda_platform::wgpu::bind::Visibility as P; + + assert!(matches!(BindingVisibility::Vertex.to_platform(), P::Vertex)); + assert!(matches!( + BindingVisibility::Fragment.to_platform(), + P::Fragment + )); + assert!(matches!( + BindingVisibility::Compute.to_platform(), + P::Compute + )); + assert!(matches!( + BindingVisibility::VertexAndFragment.to_platform(), + P::VertexAndFragment + )); + assert!(matches!(BindingVisibility::All.to_platform(), P::All)); + } +} + #[derive(Debug, Clone)] /// Bind group layout used when creating pipelines and bind groups. pub struct BindGroupLayout { diff --git a/crates/lambda-rs/src/render/scene_math.rs b/crates/lambda-rs/src/render/scene_math.rs index 93ae341a..1202b152 100644 --- a/crates/lambda-rs/src/render/scene_math.rs +++ b/crates/lambda-rs/src/render/scene_math.rs @@ -198,3 +198,258 @@ pub fn compute_model_view_projection_matrix_about_pivot( ); projection.multiply(&view).multiply(&model) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::matrix as m; + + /// This test demonstrates the complete order of operations used to produce a + /// model matrix. It rotates a base identity matrix by a chosen axis and + /// angle, applies a uniform scale using a diagonal scaling matrix, and then + /// applies a world translation. The expected matrix is built step by step in + /// the same manner so that individual differences are easy to reason about + /// when reading a failure. Every element is compared using a small tolerance + /// to account for floating point rounding. + #[test] + fn model_matrix_composes_rotation_scale_and_translation() { + let translation = [3.0, -2.0, 5.0]; + let axis = [0.0, 1.0, 0.0]; + let angle_in_turns = 0.25; // quarter turn + let scale = 2.0; + + // Compute via the public function under test. + let actual = compute_model_matrix(translation, axis, angle_in_turns, scale); + + // Build the expected matrix explicitly: R, then S, then T. + let mut expected: [[f32; 4]; 4] = m::identity_matrix(4, 4); + expected = m::rotate_matrix(expected, axis, angle_in_turns); + + let mut s: [[f32; 4]; 4] = [[0.0; 4]; 4]; + for i in 0..4 { + for j in 0..4 { + s[i][j] = if i == j { + if i == 3 { + 1.0 + } else { + scale + } + } else { + 0.0 + }; + } + } + expected = expected.multiply(&s); + + let t: [[f32; 4]; 4] = m::translation_matrix(translation); + let expected = t.multiply(&expected); + + for i in 0..4 { + for j in 0..4 { + crate::assert_approximately_equal!( + actual.at(i, j), + expected.at(i, j), + 1e-5 + ); + } + } + } + + /// This test verifies that rotating and scaling around a local pivot point + /// produces the same result as translating into the pivot, applying the base + /// transform, and translating back out, followed by the world translation. + /// It constructs both forms and checks that all elements match within a + /// small tolerance. + #[test] + fn model_matrix_respects_local_pivot() { + let translation = [1.0, 2.0, 3.0]; + let axis = [1.0, 0.0, 0.0]; + let angle_in_turns = 0.125; // one eighth of a full turn + let scale = 0.5; + let pivot = [10.0, -4.0, 2.0]; + + let actual = compute_model_matrix_about_pivot( + translation, + axis, + angle_in_turns, + scale, + pivot, + ); + + let base = + compute_model_matrix([0.0, 0.0, 0.0], axis, angle_in_turns, scale); + let to_pivot: [[f32; 4]; 4] = m::translation_matrix(pivot); + let from_pivot: [[f32; 4]; 4] = + m::translation_matrix([-pivot[0], -pivot[1], -pivot[2]]); + let world: [[f32; 4]; 4] = m::translation_matrix(translation); + let expected = world + .multiply(&to_pivot) + .multiply(&base) + .multiply(&from_pivot); + + for i in 0..4 { + for j in 0..4 { + crate::assert_approximately_equal!( + actual.at(i, j), + expected.at(i, j), + 1e-5 + ); + } + } + } + + /// This test confirms that the view computation is the inverse of a camera + /// translation. For a camera expressed only as a world space translation, the + /// inverse is a translation by the negated vector. The test constructs that + /// expected matrix directly and compares it to the function result. + #[test] + fn view_matrix_is_inverse_translation() { + let camera_position = [7.0, -3.0, 2.5]; + let expected: [[f32; 4]; 4] = m::translation_matrix([ + -camera_position[0], + -camera_position[1], + -camera_position[2], + ]); + let actual = compute_view_matrix(camera_position); + assert_eq!(actual, expected); + } + + /// This test validates that the perspective projection matches an + /// OpenGL‑style projection that is converted into the normalized device + /// coordinate range used by the target platforms. The expected conversion is + /// performed by multiplying a fixed conversion matrix with the projection + /// produced by the existing matrix helper. The result is compared element by + /// element within a small tolerance. + #[test] + fn perspective_projection_matches_converted_reference() { + let fov_turns = 0.25; + let width = 1280; + let height = 720; + let near = 0.1; + let far = 100.0; + + let actual = + compute_perspective_projection(fov_turns, width, height, near, far); + + let aspect = width as f32 / height as f32; + let projection_gl: [[f32; 4]; 4] = + m::perspective_matrix(fov_turns, aspect, near, far); + let conversion = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 0.5, 0.0], + [0.0, 0.0, 0.5, 1.0], + ]; + let expected = conversion.multiply(&projection_gl); + + for i in 0..4 { + for j in 0..4 { + crate::assert_approximately_equal!( + actual.at(i, j), + expected.at(i, j), + 1e-5 + ); + } + } + } + + /// This test builds a full model, view, and projection composition using both + /// the public helper and a reference expression that multiplies the same + /// parts in the same order. It uses a simple camera and a non‑trivial model + /// transform to provide coverage for the code paths. Results are compared + /// with a small tolerance to account for floating point rounding. + #[test] + fn model_view_projection_composition_matches_reference() { + let camera = SimpleCamera { + position: [0.5, -1.0, 2.0], + field_of_view_in_turns: 0.3, + near_clipping_plane: 0.01, + far_clipping_plane: 500.0, + }; + let (w, h) = (1024, 600); + let model_t = [2.0, 0.5, -3.0]; + let axis = [0.0, 0.0, 1.0]; + let angle = 0.2; + let scale = 1.25; + + let actual = compute_model_view_projection_matrix( + &camera, w, h, model_t, axis, angle, scale, + ); + + let model = compute_model_matrix(model_t, axis, angle, scale); + let view = compute_view_matrix(camera.position); + let proj = compute_perspective_projection( + camera.field_of_view_in_turns, + w, + h, + camera.near_clipping_plane, + camera.far_clipping_plane, + ); + let expected = proj.multiply(&view).multiply(&model); + + for i in 0..4 { + for j in 0..4 { + crate::assert_approximately_equal!( + actual.at(i, j), + expected.at(i, j), + 1e-5 + ); + } + } + } + + /// This test builds a full model, view, and projection composition for a + /// model that rotates and scales around a local pivot point. It compares the + /// public helper result to a reference expression that expands the pivot + /// operations into individual translations and the base transform. Elements + /// are compared with a small tolerance to make the test robust to floating + /// point differences. + #[test] + fn model_view_projection_about_pivot_matches_reference() { + let camera = SimpleCamera { + position: [-3.0, 0.0, 1.0], + field_of_view_in_turns: 0.15, + near_clipping_plane: 0.1, + far_clipping_plane: 50.0, + }; + let (w, h) = (800, 480); + let model_t = [0.0, -1.0, 2.0]; + let axis = [0.0, 1.0, 0.0]; + let angle = 0.4; + let scale = 0.75; + let pivot = [5.0, 0.0, -2.0]; + + let actual = compute_model_view_projection_matrix_about_pivot( + &camera, w, h, model_t, axis, angle, scale, pivot, + ); + + let base = compute_model_matrix([0.0, 0.0, 0.0], axis, angle, scale); + let to_pivot: [[f32; 4]; 4] = m::translation_matrix(pivot); + let from_pivot: [[f32; 4]; 4] = + m::translation_matrix([-pivot[0], -pivot[1], -pivot[2]]); + let world: [[f32; 4]; 4] = m::translation_matrix(model_t); + let model = world + .multiply(&to_pivot) + .multiply(&base) + .multiply(&from_pivot); + let view = compute_view_matrix(camera.position); + let proj = compute_perspective_projection( + camera.field_of_view_in_turns, + w, + h, + camera.near_clipping_plane, + camera.far_clipping_plane, + ); + let expected = proj.multiply(&view).multiply(&model); + + for i in 0..4 { + for j in 0..4 { + crate::assert_approximately_equal!( + actual.at(i, j), + expected.at(i, j), + 1e-5 + ); + } + } + } +} From 00aababeb76370ebdeb67fc12ab4393aac5e4193 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 13 Oct 2025 00:05:11 -0700 Subject: [PATCH 05/17] [add] ubo validation, add tests, and update render context impl. --- crates/lambda-rs/src/render/bind.rs | 28 +++++++ crates/lambda-rs/src/render/buffer.rs | 42 ++++++++++ crates/lambda-rs/src/render/mod.rs | 24 ++++++ crates/lambda-rs/src/render/pipeline.rs | 8 ++ crates/lambda-rs/src/render/validation.rs | 76 ++++++++++++++++++ docs/specs/uniform-buffers-and-bind-groups.md | 79 ++++++++----------- 6 files changed, 210 insertions(+), 47 deletions(-) create mode 100644 crates/lambda-rs/src/render/validation.rs diff --git a/crates/lambda-rs/src/render/bind.rs b/crates/lambda-rs/src/render/bind.rs index 9316b6b0..3b6f3c38 100644 --- a/crates/lambda-rs/src/render/bind.rs +++ b/crates/lambda-rs/src/render/bind.rs @@ -70,24 +70,38 @@ mod tests { /// Bind group layout used when creating pipelines and bind groups. pub struct BindGroupLayout { layout: Rc, + /// Total number of dynamic bindings declared in this layout. + dynamic_binding_count: u32, } impl BindGroupLayout { pub(crate) fn raw(&self) -> &wgpu::BindGroupLayout { self.layout.raw() } + + /// Number of dynamic bindings declared in this layout. + pub fn dynamic_binding_count(&self) -> u32 { + self.dynamic_binding_count + } } #[derive(Debug, Clone)] /// Bind group that binds one or more resources to a pipeline set index. pub struct BindGroup { group: Rc, + /// Cached number of dynamic bindings expected when binding this group. + dynamic_binding_count: u32, } impl BindGroup { pub(crate) fn raw(&self) -> &wgpu::BindGroup { self.group.raw() } + + /// Number of dynamic bindings expected when calling set_bind_group. + pub fn dynamic_binding_count(&self) -> u32 { + self.dynamic_binding_count + } } /// Builder for creating a bind group layout with uniform buffer bindings. @@ -135,6 +149,8 @@ impl BindGroupLayoutBuilder { pub fn build(self, render_context: &RenderContext) -> BindGroupLayout { let mut platform = lambda_platform::wgpu::bind::BindGroupLayoutBuilder::new(); + let dynamic_binding_count = + self.entries.iter().filter(|(_, _, d)| *d).count() as u32; if let Some(label) = &self.label { platform = platform.with_label(label); } @@ -148,6 +164,7 @@ impl BindGroupLayoutBuilder { let layout = platform.build(render_context.device()); BindGroupLayout { layout: Rc::new(layout), + dynamic_binding_count, } } } @@ -203,12 +220,23 @@ impl<'a> BindGroupBuilder<'a> { if let Some(label) = &self.label { platform = platform.with_label(label); } + let max_binding = render_context.limit_max_uniform_buffer_binding_size(); for (binding, buffer, offset, size) in self.entries.into_iter() { + if let Some(sz) = size { + assert!( + sz.get() <= max_binding, + "Uniform binding at binding={} requests size={} > device limit {}", + binding, + sz.get(), + max_binding + ); + } platform = platform.with_uniform(binding, buffer.raw(), offset, size); } let group = platform.build(render_context.device()); BindGroup { group: Rc::new(group), + dynamic_binding_count: layout.dynamic_binding_count(), } } } diff --git a/crates/lambda-rs/src/render/buffer.rs b/crates/lambda-rs/src/render/buffer.rs index d6dfd5e7..8c2e73e2 100644 --- a/crates/lambda-rs/src/render/buffer.rs +++ b/crates/lambda-rs/src/render/buffer.rs @@ -138,6 +138,48 @@ impl Buffer { } } +/// Strongly‑typed uniform buffer wrapper for ergonomics and safety. +/// +/// Stores a single value of type `T` and provides a convenience method to +/// upload updates to the GPU. The underlying buffer has `UNIFORM` usage and +/// is CPU‑visible by default for easy updates via `Queue::write_buffer`. +pub struct UniformBuffer { + inner: Buffer, + _phantom: core::marker::PhantomData, +} + +impl UniformBuffer { + /// Create a new uniform buffer initialized with `initial`. + pub fn new( + render_context: &mut RenderContext, + initial: &T, + label: Option<&str>, + ) -> Result { + let mut builder = BufferBuilder::new(); + builder.with_length(core::mem::size_of::()); + builder.with_usage(Usage::UNIFORM); + builder.with_properties(Properties::CPU_VISIBLE); + if let Some(l) = label { + builder.with_label(l); + } + let inner = builder.build(render_context, vec![*initial])?; + Ok(Self { + inner, + _phantom: core::marker::PhantomData, + }) + } + + /// Borrow the underlying generic `Buffer` for binding. + pub fn raw(&self) -> &Buffer { + &self.inner + } + + /// Write a new value to the GPU buffer at offset 0. + pub fn write(&self, render_context: &RenderContext, value: &T) { + self.inner.write_value(render_context, 0, value); + } +} + /// Builder for creating `Buffer` objects with explicit usage and properties. /// /// A buffer is a block of memory the GPU can access. You supply a total byte diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index 2c827c17..d8ab72e6 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -10,6 +10,7 @@ pub mod pipeline; pub mod render_pass; pub mod scene_math; pub mod shader; +pub mod validation; pub mod vertex; pub mod viewport; pub mod window; @@ -213,6 +214,21 @@ impl RenderContext { self.config.format } + /// Device limit: maximum bytes that can be bound for a single uniform buffer binding. + pub fn limit_max_uniform_buffer_binding_size(&self) -> u64 { + self.gpu.limits().max_uniform_buffer_binding_size.into() + } + + /// Device limit: number of bind groups that can be used by a pipeline layout. + pub fn limit_max_bind_groups(&self) -> u32 { + self.gpu.limits().max_bind_groups + } + + /// Device limit: required alignment in bytes for dynamic uniform buffer offsets. + pub fn limit_min_uniform_buffer_offset_alignment(&self) -> u32 { + self.gpu.limits().min_uniform_buffer_offset_alignment + } + /// Encode and submit GPU work for a single frame. fn render_internal( &mut self, @@ -328,6 +344,14 @@ impl RenderContext { let group_ref = self.bind_groups.get(group).ok_or_else(|| { RenderError::Configuration(format!("Unknown bind group {group}")) })?; + // Validate dynamic offsets count and alignment before binding. + validation::validate_dynamic_offsets( + group_ref.dynamic_binding_count(), + &dynamic_offsets, + self.limit_min_uniform_buffer_offset_alignment(), + set, + ) + .map_err(RenderError::Configuration)?; pass.set_bind_group(set, group_ref.raw(), &dynamic_offsets); } RenderCommand::BindVertexBuffer { pipeline, buffer } => { diff --git a/crates/lambda-rs/src/render/pipeline.rs b/crates/lambda-rs/src/render/pipeline.rs index eba2b70d..4029d9e9 100644 --- a/crates/lambda-rs/src/render/pipeline.rs +++ b/crates/lambda-rs/src/render/pipeline.rs @@ -200,6 +200,14 @@ impl RenderPipelineBuilder { }) .collect(); + let max_bind_groups = render_context.limit_max_bind_groups() as usize; + assert!( + self.bind_group_layouts.len() <= max_bind_groups, + "Pipeline declares {} bind group layouts, exceeds device max {}", + self.bind_group_layouts.len(), + max_bind_groups + ); + let bind_group_layout_refs: Vec<&wgpu::BindGroupLayout> = self .bind_group_layouts .iter() diff --git a/crates/lambda-rs/src/render/validation.rs b/crates/lambda-rs/src/render/validation.rs new file mode 100644 index 00000000..1a6f9932 --- /dev/null +++ b/crates/lambda-rs/src/render/validation.rs @@ -0,0 +1,76 @@ +//! Small helpers for limits and alignment validation used by the renderer. + +/// Align `value` up to the nearest multiple of `align`. +/// If `align` is zero, returns `value` unchanged. +pub fn align_up(value: u64, align: u64) -> u64 { + if align == 0 { + return value; + } + let mask = align - 1; + (value + mask) & !mask +} + +/// Validate a set of dynamic offsets against the required count and alignment. +/// Returns `Ok(())` when valid; otherwise a human‑readable error message. +pub fn validate_dynamic_offsets( + required_count: u32, + offsets: &[u32], + alignment: u32, + set_index: u32, +) -> Result<(), String> { + if offsets.len() as u32 != required_count { + return Err(format!( + "Bind group at set {} expects {} dynamic offsets, got {}", + set_index, + required_count, + offsets.len() + )); + } + let align = alignment.max(1); + for (i, off) in offsets.iter().enumerate() { + if (*off as u32) % align != 0 { + return Err(format!( + "Dynamic offset[{}]={} is not {}-byte aligned", + i, off, align + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn align_up_noop_on_zero_align() { + assert_eq!(align_up(13, 0), 13); + } + + #[test] + fn align_up_rounds_to_multiple() { + assert_eq!(align_up(0, 256), 0); + assert_eq!(align_up(1, 256), 256); + assert_eq!(align_up(255, 256), 256); + assert_eq!(align_up(256, 256), 256); + assert_eq!(align_up(257, 256), 512); + } + + #[test] + fn validate_dynamic_offsets_count_and_alignment() { + // Correct count and alignment + assert!(validate_dynamic_offsets(2, &[0, 256], 256, 0).is_ok()); + + // Wrong count + let err = validate_dynamic_offsets(3, &[0, 256], 256, 1) + .err() + .unwrap(); + assert!(err.contains("expects 3 dynamic offsets")); + + // Misaligned + let err = validate_dynamic_offsets(2, &[0, 128], 256, 0) + .err() + .unwrap(); + assert!(err.contains("not 256-byte aligned")); + } +} diff --git a/docs/specs/uniform-buffers-and-bind-groups.md b/docs/specs/uniform-buffers-and-bind-groups.md index 25c76074..b5af146e 100644 --- a/docs/specs/uniform-buffers-and-bind-groups.md +++ b/docs/specs/uniform-buffers-and-bind-groups.md @@ -1,15 +1,15 @@ --- title: "Uniform Buffers and Bind Groups" document_id: "ubo-spec-2025-10-11" -status: "draft" +status: "living" created: "2025-10-11T00:00:00Z" last_updated: "2025-10-13T00:00:00Z" -version: "0.1.1" +version: "0.2.0" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "a9ecb6df8188fb189e3221598b36f6cf757697bb" +repo_commit: "3e63f82b0a364bc52a40ae297a5300f998800518" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "rendering", "uniforms", "bind-groups", "wgpu"] @@ -61,6 +61,8 @@ layers of the workspace. - Extend `RenderPipelineBuilder` to accept bind group layouts, building a `wgpu::PipelineLayout` under the hood. - Extend `RenderCommand` with `SetBindGroup` to bind resources during a pass. + - Avoid exposing `wgpu` types in the public API; surface numeric limits and + high-level wrappers only, delegating raw handles to the platform layer. Data flow (one-time setup → per-frame): ``` @@ -92,16 +94,16 @@ Per-frame commands: BeginRenderPass -> SetPipeline -> SetBindGroup -> Draw -> En - `fn build(self, device: &wgpu::Device) -> BindGroup` Validation and limits -- Current implementation defers validation to `wgpu` and backend validators. - Builders do not perform explicit limit checks yet; invalid inputs will surface - as `wgpu` validation errors at creation/bind time. - - No explicit checks for `max_uniform_buffer_binding_size` in builders. - - Dynamic offset alignment is not re-validated; callers must ensure offsets are - multiples of `min_uniform_buffer_offset_alignment`. - - Pipeline layout size (`max_bind_groups`) is not pre-validated by the builder. +- High-level validation now checks common cases early: + - Bind group uniform binding sizes are asserted to be ≤ `max_uniform_buffer_binding_size`. + - Dynamic offset count and alignment are validated before encoding `SetBindGroup`. + - Pipeline builder asserts the number of bind group layouts ≤ `max_bind_groups`. + - Helpers are provided to compute aligned strides and to validate dynamic offsets. Helpers -- No dedicated helpers are exposed in the platform layer yet. +- High-level exposes small helpers: + - `align_up(value, align)` to compute aligned uniform strides (for offsets). + - `validate_dynamic_offsets(required, offsets, alignment, set)` used internally and testable. ## High-Level API Design (lambda-rs) @@ -133,27 +135,9 @@ Render commands Buffers - Continue using `buffer::BufferBuilder` with `Usage::UNIFORM` and CPU-visible - properties for frequently updated UBOs. No new types are strictly required, - but an optional typed helper improves ergonomics: - -```rust -#[repr(C)] -#[derive(Copy, Clone)] -pub struct UniformBuffer { inner: buffer::Buffer, _phantom: core::marker::PhantomData } - -impl UniformBuffer { - pub fn raw(&self) -> &buffer::Buffer { &self.inner } - pub fn write(&self, rc: &mut RenderContext, value: &T) where T: Copy { - let bytes = unsafe { - std::slice::from_raw_parts( - (value as *const T) as *const u8, - core::mem::size_of::(), - ) - }; - rc.queue().write_buffer(self.inner.raw(), 0, bytes); - } -} -``` + properties for frequently updated UBOs. +- A typed `UniformBuffer` wrapper is available with `new(&mut rc, &T, label)` + and `write(&rc, &T)`, and exposes `raw()` to bind. ## Layout and Alignment Rules @@ -238,15 +222,15 @@ layout(set = 0, binding = 0) uniform Globals { mat4 view_proj; } globals; void main() { gl_Position = globals.view_proj * vec4(in_pos, 1.0); } ``` -Dynamic offsets (optional) +Dynamic offsets ```rust let dyn_layout = BindGroupLayoutBuilder::new() .with_uniform_dynamic(0, BindingVisibility::Vertex) .build(&mut rc); -let align = rc.device().limits().min_uniform_buffer_offset_alignment; +let align = rc.limit_min_uniform_buffer_offset_alignment() as u64; let size = core::mem::size_of::() as u64; -let stride = ((size + align - 1) / align) * align; // manual align-up +let stride = lambda::render::validation::align_up(size, align); let offsets = vec![0u32, stride as u32, (2*stride) as u32]; RC::SetBindGroup { set: 0, group: dyn_group_id, dynamic_offsets: offsets }; ``` @@ -275,14 +259,13 @@ Phase 0 (minimal, static UBO) - Update examples to use one UBO for a transform/camera. Phase 1 (dynamic offsets) -- Add `.with_uniform_dynamic` to layout builder and support offsets in - `SetBindGroup`. Validate alignment vs device limits. -- Add small helper to compute aligned strides. +- Done: `.with_uniform_dynamic` in layout builder, support for dynamic offsets, and + validation of count/alignment before binding. Alignment helper implemented. Phase 2 (ergonomics/testing) -- Optional `UniformBuffer` wrapper with `.write(&T)` for convenience (not implemented yet). -- Unit tests for builders and validation; integration test that animates a - triangle with a camera UBO. +- Done: `UniformBuffer` wrapper with `.write(&T)` convenience. +- Added unit tests for alignment and dynamic offset validation; example animates a + triangle with a UBO (integration test remains minimal). File layout - Platform: `crates/lambda-rs-platform/src/wgpu/bind.rs` (+ `mod.rs` re-export). @@ -293,12 +276,11 @@ File layout ## Testing Plan - Unit tests - - Layout builder produces expected `wgpu::BindGroupLayoutEntry` values. - - Bind group builder rejects misaligned dynamic offsets. - - Pipeline builder errors if too many layouts are provided. -- Integration tests (`crates/lambda-rs/tests/runnables.rs`) - - Simple pipeline using a UBO to transform vertices; compare golden pixels or - log successful draw on supported adapters. + - Alignment helper (`align_up`) and dynamic offset validation logic. + - Visibility enum mapping test (in-place). +- Integration + - Example `uniform_buffer_triangle` exercises the full path; a fuller + runnable test remains a future improvement. ## Open Questions @@ -311,3 +293,6 @@ File layout - 2025-10-13 (v0.1.1) — Synced spec to implementation: renamed visibility enum variant to `VertexAndFragment`; clarified that builders defer validation to `wgpu`; updated `with_uniform` size type to `Option`; added note on GPU column‑major matrices and CPU transpose guidance; adjusted dynamic offset example. - 2025-10-11 (v0.1.0) — Initial draft aligned to roadmap; specifies platform and high-level APIs, commands, validation, examples, and phased delivery. +- 2025-10-13 (v0.2.0) — Add validation for dynamic offsets (count/alignment), + assert uniform binding sizes against device limits, assert max bind groups in + pipeline builder; add `UniformBuffer` wrapper; expose `align_up` helper; update examples. From 93c85ccbc3863ecffc73e68fb340c5d45df89377 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 17 Oct 2025 15:08:49 -0700 Subject: [PATCH 06/17] [update] the specification. --- docs/specs/uniform-buffers-and-bind-groups.md | 310 +++++++++--------- 1 file changed, 159 insertions(+), 151 deletions(-) diff --git a/docs/specs/uniform-buffers-and-bind-groups.md b/docs/specs/uniform-buffers-and-bind-groups.md index b5af146e..04d18599 100644 --- a/docs/specs/uniform-buffers-and-bind-groups.md +++ b/docs/specs/uniform-buffers-and-bind-groups.md @@ -3,13 +3,13 @@ title: "Uniform Buffers and Bind Groups" document_id: "ubo-spec-2025-10-11" status: "living" created: "2025-10-11T00:00:00Z" -last_updated: "2025-10-13T00:00:00Z" -version: "0.2.0" +last_updated: "2025-10-17T00:00:00Z" +version: "0.4.0" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "3e63f82b0a364bc52a40ae297a5300f998800518" +repo_commit: "00aababeb76370ebdeb67fc12ab4393aac5e4193" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "rendering", "uniforms", "bind-groups", "wgpu"] @@ -17,16 +17,17 @@ tags: ["spec", "rendering", "uniforms", "bind-groups", "wgpu"] # Uniform Buffers and Bind Groups -This spec defines uniform buffer objects (UBO) and bind groups for Lambda’s -wgpu-backed renderer. It follows the existing builder/command patterns and -splits responsibilities between the platform layer (`lambda-rs-platform`) and -the high-level API (`lambda-rs`). +Summary +- Specifies uniform buffer objects (UBOs) and bind groups for the + wgpu‑backed renderer, preserving builder/command patterns and the separation + between platform and high‑level layers. +- Rationale: Enables structured constants (for example, cameras, materials, + per‑frame data) beyond push constants and supports dynamic offsets for + batching many small records efficiently. -The design enables larger, structured GPU constants (cameras, materials, -per-frame data) beyond push constants, with an ergonomic path to dynamic -offsets for batching many small uniforms in a single buffer. +## Scope -## Goals +### Goals - Add first-class uniform buffers and bind groups. - Maintain builder ergonomics consistent with buffers, pipelines, and passes. @@ -34,35 +35,41 @@ offsets for batching many small uniforms in a single buffer. - Provide a portable, WGSL/GLSL-friendly layout model and validation. - Expose dynamic uniform offsets (opt-in) with correct alignment handling. -## Non-Goals +### Non-Goals - Storage buffers, textures/samplers, and compute are referenced but not implemented here; separate specs cover them. - Descriptor set caching beyond wgpu’s internal caches. -## Background +## Terminology -Roadmap docs propose UBOs and bind groups to complement push constants and -unlock cameras/materials. This spec refines those sketches into concrete API -types, builders, commands, validation, and an implementation plan for both -layers of the workspace. +- Uniform buffer object (UBO): Read‑only constant buffer accessed by shaders as + `var`. +- Bind group: A collection of bound resources used together by a pipeline. +- Bind group layout: The declared interface (bindings, types, visibility) for a + bind group. +- Dynamic offset: A per‑draw offset applied to a uniform binding to select a + different slice within a larger buffer. +- Visibility: Shader stage visibility for a binding (vertex, fragment, compute). ## Architecture Overview - Platform (`lambda-rs-platform`) - - Thin wrappers around `wgpu::BindGroupLayout` and `wgpu::BindGroup` with - builder structs that produce concrete `wgpu` descriptors and perform - validation against device limits. - - Expose the raw `wgpu` handles for use by higher layers. + - Wrappers around `wgpu::BindGroupLayout` and `wgpu::BindGroup` with builder + types that produce `wgpu` descriptors and perform validation against device + limits. + - The platform layer owns the raw `wgpu` handles and exposes them to the + high-level layer as needed. - High level (`lambda-rs`) - - Public builders/types for bind group layouts and bind groups aligned with - `RenderPipelineBuilder` and `BufferBuilder` patterns. - - Extend `RenderPipelineBuilder` to accept bind group layouts, building a - `wgpu::PipelineLayout` under the hood. - - Extend `RenderCommand` with `SetBindGroup` to bind resources during a pass. - - Avoid exposing `wgpu` types in the public API; surface numeric limits and - high-level wrappers only, delegating raw handles to the platform layer. + - Public builders and types for bind group layouts and bind groups, aligned + with existing `RenderPipelineBuilder` and `BufferBuilder` patterns. + - `RenderPipelineBuilder` accepts bind group layouts and constructs a + `wgpu::PipelineLayout` during build. + - `RenderCommand` includes `SetBindGroup` to bind resources during a pass. + - The public application programming interface avoids exposing `wgpu` types. + Numeric limits and high-level wrappers are surfaced; raw handles live in the + platform layer. Data flow (one-time setup → per-frame): ``` @@ -73,86 +80,64 @@ BufferBuilder (Usage::UNIFORM) --------------+--> BindGroupBuilder (uniform bind Per-frame commands: BeginRenderPass -> SetPipeline -> SetBindGroup -> Draw -> End ``` -## Platform API Design (lambda-rs-platform) - -- Module: `lambda_platform::wgpu::bind` - - `struct BindGroupLayout { raw: wgpu::BindGroupLayout, label: Option }` - - `struct BindGroup { raw: wgpu::BindGroup, label: Option }` - - `enum Visibility { Vertex, Fragment, Compute, VertexAndFragment, All }` - - Maps to `wgpu::ShaderStages`. - - `struct BindGroupLayoutBuilder { entries: Vec, label: Option }` - - `fn new() -> Self` - - `fn with_uniform(mut self, binding: u32, visibility: Visibility) -> Self` - - `fn with_uniform_dynamic(mut self, binding: u32, visibility: Visibility) -> Self` - - `fn with_label(mut self, label: &str) -> Self` - - `fn build(self, device: &wgpu::Device) -> BindGroupLayout` - - `struct BindGroupBuilder { layout: wgpu::BindGroupLayout, entries: Vec, label: Option }` - - `fn new() -> Self` - - `fn with_layout(mut self, layout: &BindGroupLayout) -> Self` - - `fn with_uniform(mut self, binding: u32, buffer: &wgpu::Buffer, offset: u64, size: Option) -> Self` - - `fn with_label(mut self, label: &str) -> Self` - - `fn build(self, device: &wgpu::Device) -> BindGroup` - -Validation and limits -- High-level validation now checks common cases early: - - Bind group uniform binding sizes are asserted to be ≤ `max_uniform_buffer_binding_size`. - - Dynamic offset count and alignment are validated before encoding `SetBindGroup`. - - Pipeline builder asserts the number of bind group layouts ≤ `max_bind_groups`. - - Helpers are provided to compute aligned strides and to validate dynamic offsets. - -Helpers -- High-level exposes small helpers: - - `align_up(value, align)` to compute aligned uniform strides (for offsets). - - `validate_dynamic_offsets(required, offsets, alignment, set)` used internally and testable. - -## High-Level API Design (lambda-rs) - -New module: `lambda::render::bind` -- `pub struct BindGroupLayout { /* holds Rc */ }` -- `pub struct BindGroup { /* holds Rc */ }` -- `pub enum BindingVisibility { Vertex, Fragment, Compute, VertexAndFragment, All }` -- `pub struct BindGroupLayoutBuilder { /* mirrors platform builder */ }` - - `pub fn new() -> Self` - - `pub fn with_uniform(self, binding: u32, visibility: BindingVisibility) -> Self` - - `pub fn with_uniform_dynamic(self, binding: u32, visibility: BindingVisibility) -> Self` - - `pub fn with_label(self, label: &str) -> Self` - - `pub fn build(self, rc: &RenderContext) -> BindGroupLayout` -- `pub struct BindGroupBuilder { /* mirrors platform builder */ }` - - `pub fn new() -> Self` - - `pub fn with_layout(self, layout: &BindGroupLayout) -> Self` - - `pub fn with_uniform(self, binding: u32, buffer: &buffer::Buffer, offset: u64, size: Option) -> Self` - - `pub fn with_label(self, label: &str) -> Self` - - `pub fn build(self, rc: &RenderContext) -> BindGroup` - -Pipeline integration -- `RenderPipelineBuilder::with_layouts(&[&BindGroupLayout])` stores layouts and - constructs a `wgpu::PipelineLayout` during `build(...)`. - -Render commands -- Extend `RenderCommand` with: - - `SetBindGroup { set: u32, group: super::ResourceId, dynamic_offsets: Vec }` -- `RenderContext::encode_pass` maps to `wgpu::RenderPass::set_bind_group`. - -Buffers -- Continue using `buffer::BufferBuilder` with `Usage::UNIFORM` and CPU-visible - properties for frequently updated UBOs. -- A typed `UniformBuffer` wrapper is available with `new(&mut rc, &T, label)` - and `write(&rc, &T)`, and exposes `raw()` to bind. - -## Layout and Alignment Rules - -- WGSL/std140-like layout for uniform buffers (via naga/wgpu): +## Design + +### API Surface + +- Platform layer (`lambda-rs-platform`, module `lambda_platform::wgpu::bind`) + - Types: `BindGroupLayout`, `BindGroup`, and `Visibility` (maps to + `wgpu::ShaderStages`). + - Builders: `BindGroupLayoutBuilder` and `BindGroupBuilder` for declaring + uniform bindings (static and dynamic), setting labels, and creating + resources. +- High-level layer (`lambda-rs`, module `lambda::render::bind`) + - Types: high-level `BindGroupLayout` and `BindGroup` wrappers, and + `BindingVisibility` enumeration. + - Builders: mirror the platform builders; integrate with `RenderContext`. +- Pipeline integration: `RenderPipelineBuilder::with_layouts(&[&BindGroupLayout])` + stores layouts and constructs a `wgpu::PipelineLayout` during `build`. +- Render commands: `RenderCommand::SetBindGroup { set, group, dynamic_offsets }` + encodes `wgpu::RenderPass::set_bind_group` via `RenderContext`. +- Buffers: Uniform buffers MUST be created with `Usage::UNIFORM`. For frequently + updated data, pair with CPU-visible properties. A typed `UniformBuffer` + provides `new(&mut rc, &T, label)`, `write(&rc, &T)`, and exposes `raw()`. + +### Behavior + +- Bind group layouts declare uniform bindings and their stage visibility. Layout + indices correspond to set numbers; binding indices map one-to-one to shader + `@binding(N)` declarations. +- Bind groups bind a buffer (with optional size slice) to a binding declared in + the layout. When a binding is dynamic, the actual offset is supplied at draw + time using `dynamic_offsets`. +- Pipelines reference one or more bind group layouts; all render passes that use + that pipeline MUST supply compatible bind groups at the expected sets. + +### Validation and Errors + +- Uniform binding ranges MUST NOT exceed + `limits.max_uniform_buffer_binding_size`. +- Dynamic uniform offsets MUST be aligned to + `limits.min_uniform_buffer_offset_alignment` and the count MUST match the + number of dynamic bindings set. +- The number of bind group layouts in a pipeline MUST be ≤ `limits.max_bind_groups`. +- Violations surface as wgpu validation errors during resource creation or when + encoding `set_bind_group`. Helper functions validate alignment and counts. + +## Constraints and Rules + +- WGSL/std140-like layout for uniform buffers (as enforced by wgpu): - Scalars 4 B; `vec2` 8 B; `vec3/vec4` 16 B; matrices 16 B column alignment. - Struct members rounded up to their alignment; struct size rounded up to the max alignment of its fields. -- Rust-side structs used as UBOs must be `#[repr(C)]` and plain-old-data. - Recommend `bytemuck::{Pod, Zeroable}` in examples for safety. +- Rust-side structs used as UBOs MUST be `#[repr(C)]` and plain old data. Using + `bytemuck::{Pod, Zeroable}` in examples is recommended for safety. - Dynamic offsets must be multiples of `limits.min_uniform_buffer_offset_alignment`. - Respect `limits.max_uniform_buffer_binding_size` when slicing UBOs. - - Matrices are column‑major in GLSL/WGSL. If your CPU math builds row‑major - matrices, either transpose before uploading to the GPU or mark GLSL uniform - blocks with `layout(row_major)` to avoid unexpected transforms. +- Matrices are column‑major in GLSL/WGSL. If CPU math constructs row‑major + matrices, transpose before uploading or mark GLSL uniform + blocks with `layout(row_major)` to avoid unexpected transforms. ## Example Usage @@ -234,63 +219,86 @@ let stride = lambda::render::validation::align_up(size, align); let offsets = vec![0u32, stride as u32, (2*stride) as u32]; RC::SetBindGroup { set: 0, group: dyn_group_id, dynamic_offsets: offsets }; ``` +## Performance Considerations + +- Prefer `Properties::DEVICE_LOCAL` for long‑lived uniform buffers that are + updated infrequently; otherwise use CPU‑visible memory with + `Queue::write_buffer` for per‑frame updates. + - Rationale: Device‑local memory provides higher bandwidth and lower latency + for repeated reads. When updates are rare, the staging copy cost is + amortized and the GPU benefits every frame. For small + per‑frame updates, writing directly to CPU‑visible memory avoids additional + copies and reduces driver synchronization. On integrated graphics, the hint + still guides an efficient path and helps avoid stalls. +- Use dynamic offsets to reduce bind group churn; align and pack many objects in + a single uniform buffer. + - Rationale: Reusing one bind group and changing only a 32‑bit offset turns + descriptor updates into a cheap command. This lowers CPU + overhead, reduces driver validation and allocation, improves cache locality + by keeping per‑object blocks contiguous, and reduces the number of bind + groups created and cached. Align slices to + `min_uniform_buffer_offset_alignment` to satisfy hardware requirements and + avoid implicit padding or copies. +- Separate stable data (for example, camera) from frequently changing data (for + example, per‑object). + - Rationale: Bind stable data once per pass and vary only the hot set per + draw. This reduces state changes, keeps descriptor caches warm, avoids + rebinding large constant blocks when only small data changes, and lowers + bandwidth while improving cache effectiveness. + +## Requirements Checklist + +- Functionality + - [x] Core behavior implemented — crates/lambda-rs/src/render/bind.rs + - [x] Dynamic offsets supported — crates/lambda-rs/src/render/command.rs + - [x] Edge cases validated (alignment/size) — crates/lambda-rs/src/render/validation.rs +- API Surface + - [x] Platform types and builders — crates/lambda-rs-platform/src/wgpu/bind.rs + - [x] High-level wrappers and builders — crates/lambda-rs/src/render/bind.rs + - [x] Pipeline layout integration — crates/lambda-rs/src/render/pipeline.rs +- Validation and Errors + - [x] Uniform binding size checks — crates/lambda-rs/src/render/mod.rs + - [x] Dynamic offset alignment/count checks — crates/lambda-rs/src/render/validation.rs +- Performance + - [x] Recommendations documented (this section) + - [x] Dynamic offsets example provided — docs/specs/uniform-buffers-and-bind-groups.md +- Documentation and Examples + - [x] Spec updated (this document) + - [x] Example added — crates/lambda-rs/examples/uniform_buffer_triangle.rs + +## Verification and Testing -## Error Handling - -- `BufferBuilder` already errors on zero length; keep behavior. -- Bind group and layout builders currently do not pre‑validate against device limits. - Invalid sizes/offsets typically surface as `wgpu` validation errors during creation - or when calling `set_bind_group`. Ensure dynamic offsets are aligned to device limits - and uniform ranges respect `max_uniform_buffer_binding_size`. - -## Performance Notes - -- Prefer `Properties::DEVICE_LOCAL` for long-lived UBOs updated infrequently; - otherwise CPU-visible + `Queue::write_buffer` for per-frame updates. -- Dynamic offsets reduce bind group churn; align and pack many objects per UBO. -- Group stable data (camera) separate from frequently changing data (object). - -## Implementation Plan - -Phase 0 (minimal, static UBO) -- Platform: add bind module, layout/bind builders, validation helpers. -- High level: expose `bind` module; add pipeline `.with_layouts`; extend - `RenderCommand` and encoder with `SetBindGroup`. -- Update examples to use one UBO for a transform/camera. - -Phase 1 (dynamic offsets) -- Done: `.with_uniform_dynamic` in layout builder, support for dynamic offsets, and - validation of count/alignment before binding. Alignment helper implemented. - -Phase 2 (ergonomics/testing) -- Done: `UniformBuffer` wrapper with `.write(&T)` convenience. -- Added unit tests for alignment and dynamic offset validation; example animates a - triangle with a UBO (integration test remains minimal). - -File layout -- Platform: `crates/lambda-rs-platform/src/wgpu/bind.rs` (+ `mod.rs` re-export). -- High level: `crates/lambda-rs/src/render/bind.rs`, plus edits to - `render/pipeline.rs`, `render/command.rs`, and `render/mod.rs` to wire in - pipeline layouts and `SetBindGroup` encoding. +- Unit tests + - Alignment helper and dynamic offset validation — crates/lambda-rs/src/render/validation.rs + - Visibility mapping — crates/lambda-rs-platform/src/wgpu/bind.rs + - Command encoding satisfies device limits — crates/lambda-rs/src/render/command.rs + - Command: `cargo test --workspace` +- Integration tests and examples + - `uniform_buffer_triangle` exercises the full path — crates/lambda-rs/examples/uniform_buffer_triangle.rs + - Command: `cargo run -p lambda-rs --example uniform_buffer_triangle` +- Manual checks (optional) + - Validate dynamic offsets across multiple objects render correctly (no + misaligned reads) by varying object counts and strides. -## Testing Plan +## Compatibility and Migration -- Unit tests - - Alignment helper (`align_up`) and dynamic offset validation logic. - - Visibility enum mapping test (in-place). -- Integration - - Example `uniform_buffer_triangle` exercises the full path; a fuller - runnable test remains a future improvement. +- No breaking changes. The feature is additive. Existing pipelines without bind + groups continue to function. New pipelines MAY specify layouts via + `with_layouts` without impacting prior behavior. -## Open Questions -- Should we introduce a typed `Uniform` handle now, or wait until there is a - second typed resource (e.g., storage) to avoid a one-off abstraction? -- Do we want a tiny cache for bind groups keyed by buffer+offset for frequent - reuse, or rely entirely on wgpu’s internal caches? ## Changelog +- 2025-10-17 (v0.4.0) — Restructure to match spec template: add Summary, Scope, + Terminology, Design (API/Behavior/Validation), Constraints and Rules, + Requirements Checklist, Verification and Testing, and Compatibility. Remove + Implementation Plan and Open Questions. No functional changes. +- 2025-10-17 (v0.3.0) — Edit for professional tone; adopt clearer normative + phrasing; convert Performance Notes to concise rationale; no functional + changes to the specification; update metadata. +- 2025-10-17 (v0.2.1) — Expand Performance Notes with rationale; update + metadata (`last_updated`, `version`, `repo_commit`). - 2025-10-13 (v0.1.1) — Synced spec to implementation: renamed visibility enum variant to `VertexAndFragment`; clarified that builders defer validation to `wgpu`; updated `with_uniform` size type to `Option`; added note on GPU column‑major matrices and CPU transpose guidance; adjusted dynamic offset example. - 2025-10-11 (v0.1.0) — Initial draft aligned to roadmap; specifies platform and high-level APIs, commands, validation, examples, and phased delivery. - 2025-10-13 (v0.2.0) — Add validation for dynamic offsets (count/alignment), From af4291109e40c3791b5baa86f6e1967cd730601d Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 17 Oct 2025 15:39:44 -0700 Subject: [PATCH 07/17] [add] tutorial for how to use uniform buffer objects. --- docs/tutorials/uniform-buffers.md | 365 ++++++++++++++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 docs/tutorials/uniform-buffers.md diff --git a/docs/tutorials/uniform-buffers.md b/docs/tutorials/uniform-buffers.md new file mode 100644 index 00000000..a76b8ac9 --- /dev/null +++ b/docs/tutorials/uniform-buffers.md @@ -0,0 +1,365 @@ +--- +title: "Uniform Buffers: Build a Spinning Triangle" +document_id: "uniform-buffers-tutorial-2025-10-17" +status: "draft" +created: "2025-10-17T00:00:00Z" +last_updated: "2025-10-17T00:15:00Z" +version: "0.2.0" +engine_workspace_version: "2023.1.30" +wgpu_version: "26.0.1" +shader_backend_default: "naga" +winit_version: "0.29.10" +repo_commit: "00aababeb76370ebdeb67fc12ab4393aac5e4193" +owners: ["lambda-sh"] +reviewers: ["engine", "rendering"] +tags: ["tutorial", "graphics", "uniform-buffers", "rust", "wgpu"] +--- + +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. + +Reference implementation: `crates/lambda-rs/examples/uniform_buffer_triangle.rs`. + +Goals +- Build a spinning triangle that reads a model‑view‑projection matrix from a uniform buffer. +- Learn how to define a uniform block in shaders and mirror it in Rust. +- Learn how to create a bind group layout, allocate a uniform buffer, and write per‑frame data. +- Learn how to construct a render pipeline and issue draw commands using Lambda’s builders. + +Prerequisites +- Rust toolchain installed and the workspace builds: `cargo build --workspace`. +- Familiarity with basic Rust and the repository’s example layout. +- Ability to run examples: `cargo run --example minimal` verifies setup. + +Requirements and constraints +- The uniform block layout in the shader and the Rust structure MUST match in size, alignment, and field order. +- The bind group layout in Rust MUST match the shader `set` and `binding` indices. +- 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. +- Acronyms MUST be defined on first use (e.g., uniform buffer object (UBO)). + +Data flow +- CPU writes → UBO → bind group (set 0) → pipeline layout → vertex shader. +- A single UBO MAY be reused across multiple draws and pipelines. + +Implementation Steps + +1) Runtime and component skeleton +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. + +```rust +use lambda::{ + component::Component, + runtime::start_runtime, + runtimes::{ + application::ComponentResult, + ApplicationRuntimeBuilder, + }, +}; + +pub struct UniformBufferExample { + elapsed_seconds: f32, + width: u32, + height: u32, + // we will add resources here as we build +} + +impl Default for UniformBufferExample { + fn default() -> Self { + return Self { + elapsed_seconds: 0.0, + width: 800, + height: 600, + }; + } +} + +fn main() { + let runtime = ApplicationRuntimeBuilder::new("3D Uniform Buffer Example") + .with_window_configured_as(|w| w.with_dimensions(800, 600).with_name("3D Uniform Buffer Example")) + .with_renderer_configured_as(|r| r.with_render_timeout(1_000_000_000)) + .with_component(|runtime, example: UniformBufferExample| { return (runtime, example); }) + .build(); + + start_runtime(runtime); +} +``` + +2) Vertex and fragment shaders +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. + +```glsl +// Vertex (GLSL 450) +#version 450 +layout (location = 0) in vec3 vertex_position; +layout (location = 1) in vec3 vertex_normal; +layout (location = 2) in vec3 vertex_color; + +layout (location = 0) out vec3 frag_color; + +layout (set = 0, binding = 0) uniform Globals { + mat4 render_matrix; +} globals; + +void main() { + gl_Position = globals.render_matrix * vec4(vertex_position, 1.0); + frag_color = vertex_color; +} +``` + +```glsl +// Fragment (GLSL 450) +#version 450 +layout (location = 0) in vec3 frag_color; +layout (location = 0) out vec4 fragment_color; + +void main() { + fragment_color = vec4(frag_color, 1.0); +} +``` + +Load these as `VirtualShader::Source` via `ShaderBuilder`: + +```rust +use lambda::render::shader::{Shader, ShaderBuilder, ShaderKind, VirtualShader}; + +let vertex_virtual = VirtualShader::Source { + source: VERTEX_SHADER_SOURCE.to_string(), + kind: ShaderKind::Vertex, + entry_point: "main".to_string(), + name: "uniform_buffer_triangle".to_string(), +}; +let fragment_virtual = VirtualShader::Source { + source: FRAGMENT_SHADER_SOURCE.to_string(), + kind: ShaderKind::Fragment, + entry_point: "main".to_string(), + name: "uniform_buffer_triangle".to_string(), +}; +let mut shader_builder = ShaderBuilder::new(); +let vertex_shader: Shader = shader_builder.build(vertex_virtual); +let fragment_shader: Shader = shader_builder.build(fragment_virtual); +``` + +3) Mesh data and vertex layout +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. + +```rust +use lambda::render::{ + mesh::{Mesh, MeshBuilder}, + vertex::{VertexAttribute, VertexBuilder, VertexElement}, + ColorFormat, +}; + +let vertices = [ + 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(), + 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(), + 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(), +]; + +let mut mesh_builder = MeshBuilder::new(); +vertices.iter().for_each(|v| { mesh_builder.with_vertex(v.clone()); }); + +let mesh: Mesh = mesh_builder + .with_attributes(vec![ + VertexAttribute { // position @ location 0 + location: 0, offset: 0, + element: VertexElement { format: ColorFormat::Rgb32Sfloat, offset: 0 }, + }, + VertexAttribute { // normal @ location 1 + location: 1, offset: 0, + element: VertexElement { format: ColorFormat::Rgb32Sfloat, offset: 12 }, + }, + VertexAttribute { // color @ location 2 + location: 2, offset: 0, + element: VertexElement { format: ColorFormat::Rgb32Sfloat, offset: 24 }, + }, + ]) + .build(); +``` + +4) Uniform data layout in Rust +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. + +```rust +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct GlobalsUniform { + pub render_matrix: [[f32; 4]; 4], +} +``` + +5) Bind group layout at set 0 +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. + +```rust +use lambda::render::bind::{BindGroupLayoutBuilder, BindingVisibility}; + +let layout = BindGroupLayoutBuilder::new() + .with_uniform(0, BindingVisibility::Vertex) // binding 0 + .build(render_context); +``` + +6) Create the uniform buffer and bind group +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. + +```rust +use lambda::render::buffer::{BufferBuilder, Usage, Properties}; + +let initial_uniform = GlobalsUniform { render_matrix: initial_matrix.transpose() }; + +let uniform_buffer = BufferBuilder::new() + .with_length(std::mem::size_of::()) + .with_usage(Usage::UNIFORM) + .with_properties(Properties::CPU_VISIBLE) + .with_label("globals-uniform") + .build(render_context, vec![initial_uniform]) + .expect("Failed to create uniform buffer"); + +use lambda::render::bind::BindGroupBuilder; + +let bind_group = BindGroupBuilder::new() + .with_layout(&layout) + .with_uniform(0, &uniform_buffer, 0, None) // binding 0 + .build(render_context); +``` + +7) Build the render pipeline +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. + +```rust +use lambda::render::{ + pipeline::RenderPipelineBuilder, + render_pass::RenderPassBuilder, +}; + +let render_pass = RenderPassBuilder::new().build(render_context); + +let pipeline = RenderPipelineBuilder::new() + .with_culling(lambda::render::pipeline::CullingMode::None) + .with_layouts(&[&layout]) + .with_buffer( + BufferBuilder::build_from_mesh(&mesh, render_context).expect("Failed to create buffer"), + mesh.attributes().to_vec(), + ) + .build(render_context, &render_pass, &vertex_shader, Some(&fragment_shader)); +``` + +8) Per‑frame update and write +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. + +```rust +use lambda::render::scene_math::{compute_model_view_projection_matrix_about_pivot, SimpleCamera}; + +const ROTATION_TURNS_PER_SECOND: f32 = 0.12; + +fn update_uniform_each_frame( + elapsed_seconds: f32, + width: u32, + height: u32, + render_context: &mut lambda::render::RenderContext, + uniform_buffer: &lambda::render::buffer::Buffer, +) { + let camera = SimpleCamera { + position: [0.0, 0.0, 3.0], + field_of_view_in_turns: 0.25, + near_clipping_plane: 0.1, + far_clipping_plane: 100.0, + }; + + let angle_in_turns = ROTATION_TURNS_PER_SECOND * elapsed_seconds; + let model_view_projection_matrix = compute_model_view_projection_matrix_about_pivot( + &camera, + width.max(1), + height.max(1), + [0.0, -1.0 / 3.0, 0.0], // pivot + [0.0, 1.0, 0.0], // axis + angle_in_turns, + 0.5, // scale + [0.0, 1.0 / 3.0, 0.0], // translation + ); + + let value = GlobalsUniform { render_matrix: model_view_projection_matrix.transpose() }; + uniform_buffer.write_value(render_context, 0, &value); +} +``` + +9) Issue draw commands +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. + +```rust +use lambda::render::{ + command::RenderCommand, + viewport::ViewportBuilder, +}; + +let viewport = ViewportBuilder::new().build(width, height); + +let commands = vec![ + RenderCommand::BeginRenderPass { render_pass: render_pass_id, viewport: viewport.clone() }, + RenderCommand::SetPipeline { pipeline: pipeline_id }, + RenderCommand::SetViewports { start_at: 0, viewports: vec![viewport.clone()] }, + RenderCommand::SetScissors { start_at: 0, viewports: vec![viewport.clone()] }, + RenderCommand::BindVertexBuffer { pipeline: pipeline_id, buffer: 0 }, + RenderCommand::SetBindGroup { set: 0, group: bind_group_id, dynamic_offsets: vec![] }, + RenderCommand::Draw { vertices: 0..mesh.vertices().len() as u32 }, + RenderCommand::EndRenderPass, +]; +``` + +10) Handle window resize +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. + +```rust +use lambda::events::{Events, WindowEvent}; + +fn on_event(&mut self, event: Events) -> Result { + if let Events::Window { event, .. } = event { + if let WindowEvent::Resize { width, height } = event { + self.width = width; + self.height = height; + } + } + return Ok(ComponentResult::Success); +} +``` + +Validation +- Build the workspace: `cargo build --workspace` +- Run the example: `cargo run --example uniform_buffer_triangle` + +Notes +- Layout matching: The Rust `GlobalsUniform` MUST match the shader block layout. Keep `#[repr(C)]` and follow alignment rules. +- Matrix order: The shader expects column‑major matrices, so the uploaded matrix MUST be transposed if the local math library uses row‑major. +- Binding indices: The Rust bind group layout and `.with_uniform(0, ...)`, plus the shader `set = 0, binding = 0`, MUST be consistent. +- Update strategy: `CPU_VISIBLE` buffers SHOULD be used for per‑frame updates; device‑local memory MAY be preferred for static data. +- Pipeline layout: All bind group layouts used by the pipeline MUST be included via `.with_layouts(...)`. + +Exercises + +- Exercise 1: Time‑based fragment color + - Implement a second UBO at set 0, binding 1 with a `float time_seconds`. + - Modify the fragment shader to modulate color with a sine of time. + - Hint: add `.with_uniform(1, BindingVisibility::Fragment)` and a second binding. + +- Exercise 2: Camera orbit control + - Implement an orbiting camera around the origin and update the uniform each frame. + - Add input to adjust orbit speed. + +- Exercise 3: Two objects with dynamic offsets + - Pack two `GlobalsUniform` matrices into one UBO and issue two draws with different dynamic offsets. + - Use `dynamic_offsets` in `RenderCommand::SetBindGroup`. + +- Exercise 4: Basic Lambert lighting + - Extend shaders to compute diffuse lighting. + - Provide a lighting UBO at binding 2 with light position and color. + +- Exercise 5: Push constants comparison + - Port to push constants (see `crates/lambda-rs/examples/push_constants.rs`) and compare trade‑offs. + +- Exercise 6: Per‑material uniforms + - Split per‑frame and per‑material data; use a shared frame UBO and a per‑material UBO (e.g., tint color). + +- Exercise 7: Shader hot‑reload (stretch) + - Rebuild shaders on file changes and re‑create the pipeline while preserving UBOs and bind groups. + +Changelog +- 0.2.0 (2025‑10‑17): Added goals and book‑style step explanations; expanded rationale before code blocks; refined validation and notes. +- 0.1.0 (2025‑10‑17): Initial draft aligned with `crates/lambda-rs/examples/uniform_buffer_triangle.rs`. From a40f82cd8b0b7689a0398482b96b5b1e861790ee Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 17 Oct 2025 15:45:37 -0700 Subject: [PATCH 08/17] [update] readme, tutorial spec, and add a tutorial catalogue. --- README.md | 13 +++++ docs/specs/_spec-template.md | 108 +++++++++++++++++++++++++++++++++++ docs/tutorials/README.md | 25 ++++++++ 3 files changed, 146 insertions(+) create mode 100644 docs/specs/_spec-template.md create mode 100644 docs/tutorials/README.md diff --git a/README.md b/README.md index feb72f56..bdf7b824 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ 1. [Optional dependencies](#opt_deps) 1. [Linux, Macos, Windows](#bash) 1. [Getting started](#get_started) +1. [Tutorials](#tutorials) 1. [Examples](#examples) 1. [Planned additions](#plans) 1. [Releases & Publishing](#publishing) @@ -110,7 +111,19 @@ If this works successfully, then lambda is ready to work on your system! ## Getting started Coming soon. +## Tutorials +Start with the tutorials to build features step by step: + +- Tutorials index: [docs/tutorials/](./docs/tutorials/) +- Uniform Buffers: Build a Spinning Triangle: [docs/tutorials/uniform-buffers.md](./docs/tutorials/uniform-buffers.md) + ## Examples +Browse example sources: + +- Core API examples: [crates/lambda-rs/examples/](./crates/lambda-rs/examples/) +- Logging examples: [crates/lambda-rs-logging/examples/](./crates/lambda-rs-logging/examples/) +- Argument parsing examples: [crates/lambda-rs-args/examples/](./crates/lambda-rs-args/examples/) + ### Minimal A minimal example of an application with a working window using lambda. ```rust diff --git a/docs/specs/_spec-template.md b/docs/specs/_spec-template.md new file mode 100644 index 00000000..a0dd6594 --- /dev/null +++ b/docs/specs/_spec-template.md @@ -0,0 +1,108 @@ +--- +title: "" +document_id: "" +status: "draft" # draft | living | frozen | deprecated +created: "" # e.g., 2025-10-17T00:00:00Z +last_updated: "" +version: "0.1.0" +engine_workspace_version: "2023.1.30" +wgpu_version: "26.0.1" +shader_backend_default: "naga" +winit_version: "0.29.10" +repo_commit: "" +owners: ["lambda-sh"] +reviewers: ["engine", "rendering"] +tags: ["spec", ""] +--- + +# + +Summary +- State the problem and the desired outcome in one paragraph. +- Include a concise rationale for introducing or changing behavior. + +## Scope + +- Goals + - List concrete, testable goals. +- Non-Goals + - List items explicitly out of scope. + +## Terminology + +- Define domain terms and acronyms on first use (for example, uniform buffer + object (UBO)). Use the acronym thereafter. + +## Architecture Overview + +- Describe components, data flow, and boundaries with existing modules. +- Include a small ASCII diagram if it materially aids understanding. + +## Design + +- API Surface + - Enumerate public types, builders, functions, and commands. + - Use code identifiers with backticks (for example, `RenderCommand`). +- Behavior + - Specify observable behavior, constraints, and edge cases. + - Use normative language (MUST/SHOULD/MAY) where appropriate. +- Validation and Errors + - Document validation rules and the layer that enforces them. + - Describe error conditions and error types. + +## Constraints and Rules + +- Platform and device limits that apply (for example, alignments, size caps). +- Data layout or serialization rules, if any. + +## Performance Considerations + +- Recommendations + - State performance guidance succinctly. + - Rationale: Provide a short reason for each recommendation. + +## Requirements Checklist + +- Functionality + - [ ] Feature flags defined (if applicable) + - [ ] Core behavior implemented + - [ ] Edge cases handled (list) +- API Surface + - [ ] Public types and builders implemented + - [ ] Commands/entry points exposed + - [ ] Backwards compatibility assessed +- Validation and Errors + - [ ] Input validation implemented + - [ ] Device/limit checks implemented + - [ ] Error reporting specified and implemented +- Performance + - [ ] Critical paths profiled or reasoned + - [ ] Memory usage characterized + - [ ] Recommendations documented +- Documentation and Examples + - [ ] User-facing docs updated + - [ ] Minimal example(s) added/updated + - [ ] Migration notes (if applicable) + +For each checked item, include a reference to a commit, pull request, or file +path that demonstrates the implementation. + +## Verification and Testing + +- Unit Tests + - Describe coverage targets and representative cases. + - Commands: `cargo test -p -- --nocapture` +- Integration Tests + - Describe scenarios and expected outputs. + - Commands: `cargo test --workspace` +- Manual Checks (if necessary) + - Short, deterministic steps to validate behavior. + +## Compatibility and Migration + +- Enumerate breaking changes and migration steps, or state “None”. +- Note interactions with feature flags or environment variables. + +## Changelog + +- (v0.1.0) — Initial draft. diff --git a/docs/tutorials/README.md b/docs/tutorials/README.md new file mode 100644 index 00000000..30c05674 --- /dev/null +++ b/docs/tutorials/README.md @@ -0,0 +1,25 @@ +--- +title: "Tutorials Index" +document_id: "tutorials-index-2025-10-17" +status: "living" +created: "2025-10-17T00:20:00Z" +last_updated: "2025-10-17T00:20:00Z" +version: "0.1.0" +engine_workspace_version: "2023.1.30" +wgpu_version: "26.0.1" +shader_backend_default: "naga" +winit_version: "0.29.10" +repo_commit: "93c85ccbc3863ecffc73e68fb340c5d45df89377" +owners: ["lambda-sh"] +reviewers: ["engine", "rendering"] +tags: ["index", "tutorials", "docs"] +--- + +This index lists tutorials that teach specific engine tasks through complete, incremental builds. + +- Uniform Buffers: Build a Spinning Triangle — `docs/tutorials/uniform-buffers.md` + +Browse all tutorials in this directory. + +Changelog +- 0.1.0 (2025-10-17): Initial index with uniform buffers tutorial. From c4e35cfae681a1298d1d0c9ac1faf79694c4a2de Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sun, 26 Oct 2025 18:51:41 -0700 Subject: [PATCH 09/17] [fix] return statements to be explicit. --- crates/lambda-rs/src/render/bind.rs | 46 ++++++++++++++++---------- crates/lambda-rs/src/render/mod.rs | 51 +++++++++++++++++------------ rustfmt.toml | 1 - 3 files changed, 58 insertions(+), 40 deletions(-) diff --git a/crates/lambda-rs/src/render/bind.rs b/crates/lambda-rs/src/render/bind.rs index 3b6f3c38..03935d96 100644 --- a/crates/lambda-rs/src/render/bind.rs +++ b/crates/lambda-rs/src/render/bind.rs @@ -26,13 +26,13 @@ pub enum BindingVisibility { impl BindingVisibility { fn to_platform(self) -> lambda_platform::wgpu::bind::Visibility { use lambda_platform::wgpu::bind::Visibility as V; - match self { + return match self { BindingVisibility::Vertex => V::Vertex, BindingVisibility::Fragment => V::Fragment, BindingVisibility::Compute => V::Compute, BindingVisibility::VertexAndFragment => V::VertexAndFragment, BindingVisibility::All => V::All, - } + }; } } @@ -76,12 +76,12 @@ pub struct BindGroupLayout { impl BindGroupLayout { pub(crate) fn raw(&self) -> &wgpu::BindGroupLayout { - self.layout.raw() + return self.layout.raw(); } /// Number of dynamic bindings declared in this layout. pub fn dynamic_binding_count(&self) -> u32 { - self.dynamic_binding_count + return self.dynamic_binding_count; } } @@ -95,12 +95,12 @@ pub struct BindGroup { impl BindGroup { pub(crate) fn raw(&self) -> &wgpu::BindGroup { - self.group.raw() + return self.group.raw(); } /// Number of dynamic bindings expected when calling set_bind_group. pub fn dynamic_binding_count(&self) -> u32 { - self.dynamic_binding_count + return self.dynamic_binding_count; } } @@ -122,7 +122,7 @@ impl BindGroupLayoutBuilder { /// Attach a label for debugging and profiling. pub fn with_label(mut self, label: &str) -> Self { self.label = Some(label.to_string()); - self + return self; } /// Add a uniform buffer binding visible to the specified stages. @@ -132,7 +132,7 @@ impl BindGroupLayoutBuilder { visibility: BindingVisibility, ) -> Self { self.entries.push((binding, visibility, false)); - self + return self; } /// Add a uniform buffer binding with dynamic offset support. @@ -142,18 +142,21 @@ impl BindGroupLayoutBuilder { visibility: BindingVisibility, ) -> Self { self.entries.push((binding, visibility, true)); - self + return self; } /// Build the layout using the `RenderContext` device. pub fn build(self, render_context: &RenderContext) -> BindGroupLayout { let mut platform = lambda_platform::wgpu::bind::BindGroupLayoutBuilder::new(); + let dynamic_binding_count = self.entries.iter().filter(|(_, _, d)| *d).count() as u32; + if let Some(label) = &self.label { platform = platform.with_label(label); } + for (binding, vis, dynamic) in self.entries.into_iter() { platform = if dynamic { platform.with_uniform_dynamic(binding, vis.to_platform()) @@ -161,11 +164,13 @@ impl BindGroupLayoutBuilder { platform.with_uniform(binding, vis.to_platform()) }; } + let layout = platform.build(render_context.device()); - BindGroupLayout { + + return BindGroupLayout { layout: Rc::new(layout), dynamic_binding_count, - } + }; } } @@ -179,23 +184,23 @@ pub struct BindGroupBuilder<'a> { impl<'a> BindGroupBuilder<'a> { /// Create a new builder with no layout. pub fn new() -> Self { - Self { + return Self { label: None, layout: None, entries: Vec::new(), - } + }; } /// Attach a label for debugging and profiling. pub fn with_label(mut self, label: &str) -> Self { self.label = Some(label.to_string()); - self + return self; } /// Use a previously created layout for this bind group. pub fn with_layout(mut self, layout: &'a BindGroupLayout) -> Self { self.layout = Some(layout); - self + return self; } /// Bind a uniform buffer to the specified binding index. @@ -207,7 +212,7 @@ impl<'a> BindGroupBuilder<'a> { size: Option, ) -> Self { self.entries.push((binding, buffer, offset, size)); - self + return self; } /// Build the bind group on the current device. @@ -215,12 +220,16 @@ impl<'a> BindGroupBuilder<'a> { let layout = self .layout .expect("BindGroupBuilder requires a layout before build"); + let mut platform = lambda_platform::wgpu::bind::BindGroupBuilder::new() .with_layout(&layout.layout); + if let Some(label) = &self.label { platform = platform.with_label(label); } + let max_binding = render_context.limit_max_uniform_buffer_binding_size(); + for (binding, buffer, offset, size) in self.entries.into_iter() { if let Some(sz) = size { assert!( @@ -233,10 +242,11 @@ impl<'a> BindGroupBuilder<'a> { } platform = platform.with_uniform(binding, buffer.raw(), offset, size); } + let group = platform.build(render_context.device()); - BindGroup { + return BindGroup { group: Rc::new(group), dynamic_binding_count: layout.dynamic_binding_count(), - } + }; } } diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index d8ab72e6..f42109e7 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -152,14 +152,14 @@ impl RenderContext { ) -> ResourceId { let id = self.bind_group_layouts.len(); self.bind_group_layouts.push(layout); - id + return id; } /// Attach a bind group and return a handle for use in render commands. pub fn attach_bind_group(&mut self, group: bind::BindGroup) -> ResourceId { let id = self.bind_groups.len(); self.bind_groups.push(group); - id + return id; } /// Explicitly destroy the context. Dropping also releases resources. @@ -194,39 +194,39 @@ impl RenderContext { /// Borrow a previously attached render pass by id. pub fn get_render_pass(&self, id: ResourceId) -> &RenderPass { - &self.render_passes[id] + return &self.render_passes[id]; } /// Borrow a previously attached render pipeline by id. pub fn get_render_pipeline(&self, id: ResourceId) -> &RenderPipeline { - &self.render_pipelines[id] + return &self.render_pipelines[id]; } pub(crate) fn device(&self) -> &wgpu::Device { - self.gpu.device() + return self.gpu.device(); } pub(crate) fn queue(&self) -> &wgpu::Queue { - self.gpu.queue() + return self.gpu.queue(); } pub(crate) fn surface_format(&self) -> wgpu::TextureFormat { - self.config.format + return self.config.format; } /// Device limit: maximum bytes that can be bound for a single uniform buffer binding. pub fn limit_max_uniform_buffer_binding_size(&self) -> u64 { - self.gpu.limits().max_uniform_buffer_binding_size.into() + return self.gpu.limits().max_uniform_buffer_binding_size.into(); } /// Device limit: number of bind groups that can be used by a pipeline layout. pub fn limit_max_bind_groups(&self) -> u32 { - self.gpu.limits().max_bind_groups + return self.gpu.limits().max_bind_groups; } /// Device limit: required alignment in bytes for dynamic uniform buffer offsets. pub fn limit_min_uniform_buffer_offset_alignment(&self) -> u32 { - self.gpu.limits().min_uniform_buffer_offset_alignment + return self.gpu.limits().min_uniform_buffer_offset_alignment; } /// Encode and submit GPU work for a single frame. @@ -300,7 +300,7 @@ impl RenderContext { self.queue().submit(iter::once(encoder.finish())); frame.present(); - Ok(()) + return Ok(()); } /// Encode a single render pass and consume commands until `EndRenderPass`. @@ -321,7 +321,9 @@ impl RenderContext { RenderCommand::SetPipeline { pipeline } => { let pipeline_ref = self.render_pipelines.get(pipeline).ok_or_else(|| { - RenderError::Configuration(format!("Unknown pipeline {pipeline}")) + return RenderError::Configuration(format!( + "Unknown pipeline {pipeline}" + )); })?; pass.set_pipeline(pipeline_ref.pipeline()); } @@ -342,7 +344,9 @@ impl RenderContext { dynamic_offsets, } => { let group_ref = self.bind_groups.get(group).ok_or_else(|| { - RenderError::Configuration(format!("Unknown bind group {group}")) + return RenderError::Configuration(format!( + "Unknown bind group {group}" + )); })?; // Validate dynamic offsets count and alignment before binding. validation::validate_dynamic_offsets( @@ -357,14 +361,17 @@ impl RenderContext { RenderCommand::BindVertexBuffer { pipeline, buffer } => { let pipeline_ref = self.render_pipelines.get(pipeline).ok_or_else(|| { - RenderError::Configuration(format!("Unknown pipeline {pipeline}")) + return RenderError::Configuration(format!( + "Unknown pipeline {pipeline}" + )); })?; let buffer_ref = pipeline_ref.buffers().get(buffer as usize).ok_or_else(|| { - RenderError::Configuration(format!( + return RenderError::Configuration(format!( "Vertex buffer index {buffer} not found for pipeline {pipeline}" - )) + )); })?; + pass.set_vertex_buffer(buffer as u32, buffer_ref.raw().slice(..)); } RenderCommand::PushConstants { @@ -374,7 +381,9 @@ impl RenderContext { bytes, } => { let _ = self.render_pipelines.get(pipeline).ok_or_else(|| { - RenderError::Configuration(format!("Unknown pipeline {pipeline}")) + return RenderError::Configuration(format!( + "Unknown pipeline {pipeline}" + )); })?; let slice = unsafe { std::slice::from_raw_parts( @@ -395,9 +404,9 @@ impl RenderContext { } } - Err(RenderError::Configuration( + return Err(RenderError::Configuration( "Render pass did not terminate with EndRenderPass".to_string(), - )) + )); } /// Apply both viewport and scissor state to the active pass. @@ -430,7 +439,7 @@ impl RenderContext { self.present_mode = config.present_mode; self.texture_usage = config.usage; self.config = config; - Ok(()) + return Ok(()); } } @@ -443,6 +452,6 @@ pub enum RenderError { impl From for RenderError { fn from(error: wgpu::SurfaceError) -> Self { - RenderError::Surface(error) + return RenderError::Surface(error); } } diff --git a/rustfmt.toml b/rustfmt.toml index 24e4d6e9..77fd6f98 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,6 +1,5 @@ edition="2021" fn_params_layout="Tall" -fn_args_layout = "Tall" force_explicit_abi=true max_width=80 tab_spaces=2 From 47637cc6a24677e9f15cf405ed6d35ca57c3a49f Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sun, 26 Oct 2025 22:46:36 -0700 Subject: [PATCH 10/17] [add] check for duplicate bindings in debug builds. --- crates/lambda-rs/src/render/bind.rs | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/lambda-rs/src/render/bind.rs b/crates/lambda-rs/src/render/bind.rs index 03935d96..a2aff134 100644 --- a/crates/lambda-rs/src/render/bind.rs +++ b/crates/lambda-rs/src/render/bind.rs @@ -147,25 +147,40 @@ impl BindGroupLayoutBuilder { /// Build the layout using the `RenderContext` device. pub fn build(self, render_context: &RenderContext) -> BindGroupLayout { - let mut platform = + let mut builder = lambda_platform::wgpu::bind::BindGroupLayoutBuilder::new(); + #[cfg(debug_assertions)] + { + // In debug builds, check for duplicate binding indices. + use std::collections::HashSet; + let mut seen = HashSet::new(); + + for (binding, _, _) in &self.entries { + assert!( + seen.insert(binding), + "BindGroupLayoutBuilder: duplicate binding index {}", + binding + ); + } + } + let dynamic_binding_count = self.entries.iter().filter(|(_, _, d)| *d).count() as u32; if let Some(label) = &self.label { - platform = platform.with_label(label); + builder = builder.with_label(label); } - for (binding, vis, dynamic) in self.entries.into_iter() { - platform = if dynamic { - platform.with_uniform_dynamic(binding, vis.to_platform()) + for (binding, visibility, dynamic) in self.entries.into_iter() { + builder = if dynamic { + builder.with_uniform_dynamic(binding, visibility.to_platform()) } else { - platform.with_uniform(binding, vis.to_platform()) + builder.with_uniform(binding, visibility.to_platform()) }; } - let layout = platform.build(render_context.device()); + let layout = builder.build(render_context.device()); return BindGroupLayout { layout: Rc::new(layout), From 1f4c3c4613e446246dd32d898545b091f12251f6 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Wed, 29 Oct 2025 14:07:10 -0700 Subject: [PATCH 11/17] [add] safety comment. --- crates/lambda-rs/src/render/buffer.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/lambda-rs/src/render/buffer.rs b/crates/lambda-rs/src/render/buffer.rs index 8c2e73e2..4e88fb46 100644 --- a/crates/lambda-rs/src/render/buffer.rs +++ b/crates/lambda-rs/src/render/buffer.rs @@ -256,6 +256,9 @@ impl BufferBuilder { return Err("Attempted to create a buffer with zero length."); } + // SAFETY: Converting data to bytes is safe because it's underlying + // type, Data, is constrianed to Copy and the lifetime of the slice does + // not outlive data. let bytes = unsafe { std::slice::from_raw_parts( data.as_ptr() as *const u8, From 14880cad15555dfca7090280d8499c8f1b8553c1 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Wed, 29 Oct 2025 14:20:01 -0700 Subject: [PATCH 12/17] [add] return statements. --- crates/lambda-rs/src/render/pipeline.rs | 30 ++++++++++++++----------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/crates/lambda-rs/src/render/pipeline.rs b/crates/lambda-rs/src/render/pipeline.rs index 4029d9e9..e6a7fca1 100644 --- a/crates/lambda-rs/src/render/pipeline.rs +++ b/crates/lambda-rs/src/render/pipeline.rs @@ -29,12 +29,14 @@ impl RenderPipeline { /// Destroy the render pipeline with the render context that created it. pub fn destroy(self, _render_context: &RenderContext) {} + /// Access the vertex buffers associated with this pipeline. pub(super) fn buffers(&self) -> &Vec> { - &self.buffers + return &self.buffers; } + /// Access the underlying wgpu render pipeline. pub(super) fn pipeline(&self) -> &wgpu::RenderPipeline { - self.pipeline.as_ref() + return self.pipeline.as_ref(); } } @@ -52,18 +54,20 @@ impl PipelineStage { pub const COMPUTE: PipelineStage = PipelineStage(wgpu::ShaderStages::COMPUTE); pub(crate) fn to_wgpu(self) -> wgpu::ShaderStages { - self.0 + return self.0; } } +/// Bitwise OR for combining pipeline stages. impl std::ops::BitOr for PipelineStage { type Output = PipelineStage; fn bitor(self, rhs: PipelineStage) -> PipelineStage { - PipelineStage(self.0 | rhs.0) + return PipelineStage(self.0 | rhs.0); } } +/// Bitwise OR assignment for combining pipeline stages. impl std::ops::BitOrAssign for PipelineStage { fn bitor_assign(&mut self, rhs: PipelineStage) { self.0 |= rhs.0; @@ -91,11 +95,11 @@ pub enum CullingMode { impl CullingMode { fn to_wgpu(self) -> Option { - match self { + return match self { CullingMode::None => None, CullingMode::Front => Some(wgpu::Face::Front), CullingMode::Back => Some(wgpu::Face::Back), - } + }; } } @@ -130,7 +134,7 @@ impl RenderPipelineBuilder { buffer: Rc::new(buffer), attributes, }); - self + return self; } /// Declare a push constant range for a shader stage in bytes. @@ -140,19 +144,19 @@ impl RenderPipelineBuilder { bytes: u32, ) -> Self { self.push_constants.push((stage, 0..bytes)); - self + return self; } /// Attach a debug label to the pipeline. pub fn with_label(mut self, label: &str) -> Self { self.label = Some(label.to_string()); - self + return self; } /// Configure triangle face culling. Defaults to culling back faces. pub fn with_culling(mut self, mode: CullingMode) -> Self { self.culling = mode; - self + return self; } /// Provide one or more bind group layouts used to create the pipeline layout. @@ -161,7 +165,7 @@ impl RenderPipelineBuilder { .iter() .map(|l| std::rc::Rc::new(l.raw().clone())) .collect(); - self + return self; } /// Build a graphics pipeline using the provided shader modules and @@ -297,9 +301,9 @@ impl RenderPipelineBuilder { let pipeline = device.create_render_pipeline(&pipeline_descriptor); - RenderPipeline { + return RenderPipeline { pipeline: Rc::new(pipeline), buffers, - } + }; } } From b1e1ef77557ee1f18834e78ccf6f538d2dd14b9d Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 30 Oct 2025 13:39:22 -0700 Subject: [PATCH 13/17] [add] return statements. --- crates/lambda-rs/src/render/buffer.rs | 40 +++++++++++++-------------- crates/lambda-rs/src/render/mod.rs | 8 +++--- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/crates/lambda-rs/src/render/buffer.rs b/crates/lambda-rs/src/render/buffer.rs index 4e88fb46..71e96efa 100644 --- a/crates/lambda-rs/src/render/buffer.rs +++ b/crates/lambda-rs/src/render/buffer.rs @@ -101,20 +101,20 @@ impl Buffer { pub fn destroy(self, _render_context: &RenderContext) {} pub(super) fn raw(&self) -> &wgpu::Buffer { - self.buffer.as_ref() + return self.buffer.as_ref(); } pub(super) fn raw_rc(&self) -> Rc { - self.buffer.clone() + return self.buffer.clone(); } pub(super) fn stride(&self) -> wgpu::BufferAddress { - self.stride + return self.stride; } /// The logical buffer type used by the engine (e.g., Vertex). pub fn buffer_type(&self) -> BufferType { - self.buffer_type + return self.buffer_type; } /// Write a single plain-old-data value into this buffer at the specified @@ -163,15 +163,15 @@ impl UniformBuffer { builder.with_label(l); } let inner = builder.build(render_context, vec![*initial])?; - Ok(Self { + return Ok(Self { inner, _phantom: core::marker::PhantomData, - }) + }); } /// Borrow the underlying generic `Buffer` for binding. pub fn raw(&self) -> &Buffer { - &self.inner + return &self.inner; } /// Write a new value to the GPU buffer at offset 0. @@ -209,31 +209,31 @@ impl BufferBuilder { /// Set the length of the buffer in bytes. Defaults to the size of `data`. pub fn with_length(&mut self, size: usize) -> &mut Self { self.buffer_length = size; - self + return self; } /// Set the logical type of buffer to be created (vertex/index/...). pub fn with_buffer_type(&mut self, buffer_type: BufferType) -> &mut Self { self.buffer_type = buffer_type; - self + return self; } /// Set `wgpu` usage flags (bit‑or `Usage` values). pub fn with_usage(&mut self, usage: Usage) -> &mut Self { self.usage = usage; - self + return self; } /// Control CPU visibility and residency preferences. pub fn with_properties(&mut self, properties: Properties) -> &mut Self { self.properties = properties; - self + return self; } /// Attach a human‑readable label for debugging/profiling. pub fn with_label(&mut self, label: &str) -> &mut Self { self.label = Some(label.to_string()); - self + return self; } /// Create a buffer initialized with the provided `data`. @@ -277,11 +277,11 @@ impl BufferBuilder { usage, }); - Ok(Buffer { + return Ok(Buffer { buffer: Rc::new(buffer), stride: element_size as wgpu::BufferAddress, buffer_type: self.buffer_type, - }) + }); } /// Convenience: create a vertex buffer from a `Mesh`'s vertices. @@ -290,11 +290,11 @@ impl BufferBuilder { render_context: &mut RenderContext, ) -> Result { let mut builder = Self::new(); - builder.with_length(mesh.vertices().len() * std::mem::size_of::()); - builder.with_usage(Usage::VERTEX); - builder.with_properties(Properties::CPU_VISIBLE); - builder.with_buffer_type(BufferType::Vertex); - - builder.build(render_context, mesh.vertices().to_vec()) + return builder + .with_length(mesh.vertices().len() * std::mem::size_of::()) + .with_usage(Usage::VERTEX) + .with_properties(Properties::CPU_VISIBLE) + .with_buffer_type(BufferType::Vertex) + .build(render_context, mesh.vertices().to_vec()); } } diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index f42109e7..7c3072af 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -90,7 +90,7 @@ impl RenderContextBuilder { ) .expect("Failed to configure surface"); - RenderContext { + return RenderContext { label: name, instance, surface, @@ -103,7 +103,7 @@ impl RenderContextBuilder { render_pipelines: vec![], bind_group_layouts: vec![], bind_groups: vec![], - } + }; } } @@ -135,14 +135,14 @@ impl RenderContext { pub fn attach_pipeline(&mut self, pipeline: RenderPipeline) -> ResourceId { let id = self.render_pipelines.len(); self.render_pipelines.push(pipeline); - id + return id; } /// Attach a render pass and return a handle for use in commands. pub fn attach_render_pass(&mut self, render_pass: RenderPass) -> ResourceId { let id = self.render_passes.len(); self.render_passes.push(render_pass); - id + return id; } /// Attach a bind group layout and return a handle for use in pipeline layout composition. From 7adb0ffc9115326c274291624e0f287348a4ca2f Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 30 Oct 2025 13:53:28 -0700 Subject: [PATCH 14/17] [update] to store copies of the bind group layouts since they already wrap the actual gpu resources. --- crates/lambda-rs/src/render/pipeline.rs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/crates/lambda-rs/src/render/pipeline.rs b/crates/lambda-rs/src/render/pipeline.rs index e6a7fca1..f7099f49 100644 --- a/crates/lambda-rs/src/render/pipeline.rs +++ b/crates/lambda-rs/src/render/pipeline.rs @@ -108,7 +108,7 @@ pub struct RenderPipelineBuilder { push_constants: Vec, bindings: Vec, culling: CullingMode, - bind_group_layouts: Vec>, + bind_group_layouts: Vec, label: Option, } @@ -161,10 +161,7 @@ impl RenderPipelineBuilder { /// Provide one or more bind group layouts used to create the pipeline layout. pub fn with_layouts(mut self, layouts: &[&bind::BindGroupLayout]) -> Self { - self.bind_group_layouts = layouts - .iter() - .map(|l| std::rc::Rc::new(l.raw().clone())) - .collect(); + self.bind_group_layouts = layouts.iter().map(|l| (*l).clone()).collect(); return self; } @@ -212,11 +209,8 @@ impl RenderPipelineBuilder { max_bind_groups ); - let bind_group_layout_refs: Vec<&wgpu::BindGroupLayout> = self - .bind_group_layouts - .iter() - .map(|rc| rc.as_ref()) - .collect(); + let bind_group_layout_refs: Vec<&wgpu::BindGroupLayout> = + self.bind_group_layouts.iter().map(|l| l.raw()).collect(); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("lambda-pipeline-layout"), From b1aeb0b279946e133e416308646fdceac7b77272 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 30 Oct 2025 13:56:12 -0700 Subject: [PATCH 15/17] [add] explicit return statements. --- crates/lambda-rs-platform/src/wgpu/bind.rs | 40 +++++++++++----------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/crates/lambda-rs-platform/src/wgpu/bind.rs b/crates/lambda-rs-platform/src/wgpu/bind.rs index 9165c1e5..0b47fdc4 100644 --- a/crates/lambda-rs-platform/src/wgpu/bind.rs +++ b/crates/lambda-rs-platform/src/wgpu/bind.rs @@ -18,12 +18,12 @@ pub struct BindGroupLayout { impl BindGroupLayout { /// Borrow the underlying `wgpu::BindGroupLayout`. pub fn raw(&self) -> &wgpu::BindGroupLayout { - &self.raw + return &self.raw; } /// Optional debug label used during creation. pub fn label(&self) -> Option<&str> { - self.label.as_deref() + return self.label.as_deref(); } } @@ -37,12 +37,12 @@ pub struct BindGroup { impl BindGroup { /// Borrow the underlying `wgpu::BindGroup`. pub fn raw(&self) -> &wgpu::BindGroup { - &self.raw + return &self.raw; } /// Optional debug label used during creation. pub fn label(&self) -> Option<&str> { - self.label.as_deref() + return self.label.as_deref(); } } @@ -58,7 +58,7 @@ pub enum Visibility { impl Visibility { fn to_wgpu(self) -> wgpu::ShaderStages { - match self { + return match self { Visibility::Vertex => wgpu::ShaderStages::VERTEX, Visibility::Fragment => wgpu::ShaderStages::FRAGMENT, Visibility::Compute => wgpu::ShaderStages::COMPUTE, @@ -66,7 +66,7 @@ impl Visibility { wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT } Visibility::All => wgpu::ShaderStages::all(), - } + }; } } @@ -104,16 +104,16 @@ pub struct BindGroupLayoutBuilder { impl BindGroupLayoutBuilder { /// Create a builder with no entries. pub fn new() -> Self { - Self { + return Self { label: None, entries: Vec::new(), - } + }; } /// Attach a human‑readable label. pub fn with_label(mut self, label: &str) -> Self { self.label = Some(label.to_string()); - self + return self; } /// Declare a uniform buffer binding at the provided index. @@ -128,7 +128,7 @@ impl BindGroupLayoutBuilder { }, count: None, }); - self + return self; } /// Declare a uniform buffer binding with dynamic offsets at the provided index. @@ -147,7 +147,7 @@ impl BindGroupLayoutBuilder { }, count: None, }); - self + return self; } /// Build the layout using the provided device. @@ -157,10 +157,10 @@ impl BindGroupLayoutBuilder { label: self.label.as_deref(), entries: &self.entries, }); - BindGroupLayout { + return BindGroupLayout { raw, label: self.label, - } + }; } } @@ -175,23 +175,23 @@ pub struct BindGroupBuilder<'a> { impl<'a> BindGroupBuilder<'a> { /// Create a new builder with no layout or entries. pub fn new() -> Self { - Self { + return Self { label: None, layout: None, entries: Vec::new(), - } + }; } /// Attach a human‑readable label. pub fn with_label(mut self, label: &str) -> Self { self.label = Some(label.to_string()); - self + return self; } /// Specify the layout to use for this bind group. pub fn with_layout(mut self, layout: &'a BindGroupLayout) -> Self { self.layout = Some(layout.raw()); - self + return self; } /// Bind a uniform buffer at a binding index with optional size slice. @@ -210,7 +210,7 @@ impl<'a> BindGroupBuilder<'a> { size, }), }); - self + return self; } /// Build the bind group with the accumulated entries. @@ -223,9 +223,9 @@ impl<'a> BindGroupBuilder<'a> { layout, entries: &self.entries, }); - BindGroup { + return BindGroup { raw, label: self.label, - } + }; } } From 88e99500def9a1c1a123c960ba46b5ba7bdc7bab Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 30 Oct 2025 14:03:17 -0700 Subject: [PATCH 16/17] [fix] redundant cast and add return statements. --- crates/lambda-rs/src/render/scene_math.rs | 18 +++++++++--------- crates/lambda-rs/src/render/validation.rs | 6 +++--- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/lambda-rs/src/render/scene_math.rs b/crates/lambda-rs/src/render/scene_math.rs index 1202b152..fb8d98b6 100644 --- a/crates/lambda-rs/src/render/scene_math.rs +++ b/crates/lambda-rs/src/render/scene_math.rs @@ -15,12 +15,12 @@ use crate::math::{ /// /// This matrix leaves X and Y unchanged and remaps Z as `z = 0.5 * z + 0.5`. fn opengl_to_wgpu_ndc() -> [[f32; 4]; 4] { - [ + return [ [1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.5, 0.0], [0.0, 0.0, 0.5, 1.0], - ] + ]; } /// Simple camera parameters used to produce a perspective projection @@ -63,7 +63,7 @@ pub fn compute_model_matrix( let translation_matrix: [[f32; 4]; 4] = matrix::translation_matrix(translation); - translation_matrix.multiply(&model) + return translation_matrix.multiply(&model); } /// Compute a model matrix that applies a rotation and uniform scale about a @@ -98,10 +98,10 @@ pub fn compute_model_matrix_about_pivot( matrix::translation_matrix(translation); // For column-vector convention: T_world * ( T_pivot * (R*S) * T_-pivot ) - world_translation + return world_translation .multiply(&to_pivot) .multiply(&base) - .multiply(&from_pivot) + .multiply(&from_pivot); } /// Compute a simple view matrix from a camera position. @@ -116,7 +116,7 @@ pub fn compute_view_matrix(camera_position: [f32; 3]) -> [[f32; 4]; 4] { -camera_position[1], -camera_position[2], ]; - matrix::translation_matrix(inverse) + return matrix::translation_matrix(inverse); } /// Compute a perspective projection matrix from camera parameters and the @@ -138,7 +138,7 @@ pub fn compute_perspective_projection( far_clipping_plane, ); let conversion = opengl_to_wgpu_ndc(); - conversion.multiply(&projection_gl) + return conversion.multiply(&projection_gl); } /// Compute a full model-view-projection matrix given a simple camera, a @@ -166,7 +166,7 @@ pub fn compute_model_view_projection_matrix( camera.near_clipping_plane, camera.far_clipping_plane, ); - projection.multiply(&view).multiply(&model) + return projection.multiply(&view).multiply(&model); } /// Compute a full model-view-projection matrix for a rotation around a specific @@ -196,7 +196,7 @@ pub fn compute_model_view_projection_matrix_about_pivot( camera.near_clipping_plane, camera.far_clipping_plane, ); - projection.multiply(&view).multiply(&model) + return projection.multiply(&view).multiply(&model); } #[cfg(test)] diff --git a/crates/lambda-rs/src/render/validation.rs b/crates/lambda-rs/src/render/validation.rs index 1a6f9932..a2d64803 100644 --- a/crates/lambda-rs/src/render/validation.rs +++ b/crates/lambda-rs/src/render/validation.rs @@ -7,7 +7,7 @@ pub fn align_up(value: u64, align: u64) -> u64 { return value; } let mask = align - 1; - (value + mask) & !mask + return (value + mask) & !mask; } /// Validate a set of dynamic offsets against the required count and alignment. @@ -28,14 +28,14 @@ pub fn validate_dynamic_offsets( } let align = alignment.max(1); for (i, off) in offsets.iter().enumerate() { - if (*off as u32) % align != 0 { + if *off % align != 0 { return Err(format!( "Dynamic offset[{}]={} is not {}-byte aligned", i, off, align )); } } - Ok(()) + return Ok(()); } #[cfg(test)] From e82493abd81052fd0e42ba4dc2861e5c0168177d Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 30 Oct 2025 14:35:43 -0700 Subject: [PATCH 17/17] [update] tutorial to have a better structure. --- docs/tutorials/uniform-buffers.md | 99 ++++++++++++++++++++++--------- 1 file changed, 71 insertions(+), 28 deletions(-) diff --git a/docs/tutorials/uniform-buffers.md b/docs/tutorials/uniform-buffers.md index a76b8ac9..ecaa24d2 100644 --- a/docs/tutorials/uniform-buffers.md +++ b/docs/tutorials/uniform-buffers.md @@ -3,46 +3,82 @@ title: "Uniform Buffers: Build a Spinning Triangle" document_id: "uniform-buffers-tutorial-2025-10-17" status: "draft" created: "2025-10-17T00:00:00Z" -last_updated: "2025-10-17T00:15:00Z" -version: "0.2.0" +last_updated: "2025-10-30T00:10:00Z" +version: "0.4.0" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "00aababeb76370ebdeb67fc12ab4393aac5e4193" +repo_commit: "88e99500def9a1c1a123c960ba46b5ba7bdc7bab" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["tutorial", "graphics", "uniform-buffers", "rust", "wgpu"] --- +## Overview 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. Reference implementation: `crates/lambda-rs/examples/uniform_buffer_triangle.rs`. -Goals +## Table of Contents +- [Overview](#overview) +- [Goals](#goals) +- [Prerequisites](#prerequisites) +- [Requirements and Constraints](#requirements-and-constraints) +- [Data Flow](#data-flow) +- [Implementation Steps](#implementation-steps) + - [Step 1 — Runtime and Component Skeleton](#step-1) + - [Step 2 — Vertex and Fragment Shaders](#step-2) + - [Step 3 — Mesh Data and Vertex Layout](#step-3) + - [Step 4 — Uniform Data Layout in Rust](#step-4) + - [Step 5 — Bind Group Layout at Set 0](#step-5) + - [Step 6 — Create the Uniform Buffer and Bind Group](#step-6) + - [Step 7 — Build the Render Pipeline](#step-7) + - [Step 8 — Per‑Frame Update and Write](#step-8) + - [Step 9 — Issue Draw Commands](#step-9) + - [Step 10 — Handle Window Resize](#step-10) +- [Validation](#validation) +- [Notes](#notes) +- [Exercises](#exercises) +- [Changelog](#changelog) + +## Goals + - Build a spinning triangle that reads a model‑view‑projection matrix from a uniform buffer. - Learn how to define a uniform block in shaders and mirror it in Rust. - Learn how to create a bind group layout, allocate a uniform buffer, and write per‑frame data. - Learn how to construct a render pipeline and issue draw commands using Lambda’s builders. -Prerequisites +## Prerequisites - Rust toolchain installed and the workspace builds: `cargo build --workspace`. - Familiarity with basic Rust and the repository’s example layout. - Ability to run examples: `cargo run --example minimal` verifies setup. -Requirements and constraints +## Requirements and Constraints - The uniform block layout in the shader and the Rust structure MUST match in size, alignment, and field order. - The bind group layout in Rust MUST match the shader `set` and `binding` indices. - 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. - Acronyms MUST be defined on first use (e.g., uniform buffer object (UBO)). -Data flow +## Data Flow - CPU writes → UBO → bind group (set 0) → pipeline layout → vertex shader. - A single UBO MAY be reused across multiple draws and pipelines. -Implementation Steps +ASCII diagram + +``` +CPU (matrix calc) + │ write_value + ▼ +Uniform Buffer (UBO) + │ binding 0, set 0 + ▼ +Bind Group ──▶ Pipeline Layout ──▶ Render Pipeline ──▶ Vertex Shader +``` + +## Implementation Steps -1) Runtime and component skeleton +### Step 1 — Runtime and Component Skeleton 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. ```rust @@ -83,7 +119,7 @@ fn main() { } ``` -2) Vertex and fragment shaders +### Step 2 — Vertex and Fragment Shaders 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. ```glsl @@ -138,7 +174,7 @@ let vertex_shader: Shader = shader_builder.build(vertex_virtual); let fragment_shader: Shader = shader_builder.build(fragment_virtual); ``` -3) Mesh data and vertex layout +### Step 3 — Mesh Data and Vertex Layout 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. ```rust @@ -175,7 +211,7 @@ let mesh: Mesh = mesh_builder .build(); ``` -4) Uniform data layout in Rust +### Step 4 — Uniform Data Layout in Rust 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. ```rust @@ -186,7 +222,7 @@ pub struct GlobalsUniform { } ``` -5) Bind group layout at set 0 +### Step 5 — Bind Group Layout at Set 0 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. ```rust @@ -197,7 +233,7 @@ let layout = BindGroupLayoutBuilder::new() .build(render_context); ``` -6) Create the uniform buffer and bind group +### Step 6 — Create the Uniform Buffer and Bind Group 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. ```rust @@ -221,7 +257,7 @@ let bind_group = BindGroupBuilder::new() .build(render_context); ``` -7) Build the render pipeline +### Step 7 — Build the Render Pipeline 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. ```rust @@ -242,7 +278,7 @@ let pipeline = RenderPipelineBuilder::new() .build(render_context, &render_pass, &vertex_shader, Some(&fragment_shader)); ``` -8) Per‑frame update and write +### Step 8 — Per‑Frame Update and Write 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. ```rust @@ -269,7 +305,7 @@ fn update_uniform_each_frame( &camera, width.max(1), height.max(1), - [0.0, -1.0 / 3.0, 0.0], // pivot + [0.0, -1.0 / 3.0, 0.0], // pivot at triangle centroid (geometric center) [0.0, 1.0, 0.0], // axis angle_in_turns, 0.5, // scale @@ -281,7 +317,7 @@ fn update_uniform_each_frame( } ``` -9) Issue draw commands +### Step 9 — Issue Draw Commands 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. ```rust @@ -304,7 +340,7 @@ let commands = vec![ ]; ``` -10) Handle window resize +### Step 10 — Handle Window Resize 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. ```rust @@ -321,18 +357,18 @@ fn on_event(&mut self, event: Events) -> Result { } ``` -Validation +## Validation - Build the workspace: `cargo build --workspace` - Run the example: `cargo run --example uniform_buffer_triangle` -Notes +## Notes - Layout matching: The Rust `GlobalsUniform` MUST match the shader block layout. Keep `#[repr(C)]` and follow alignment rules. - Matrix order: The shader expects column‑major matrices, so the uploaded matrix MUST be transposed if the local math library uses row‑major. - Binding indices: The Rust bind group layout and `.with_uniform(0, ...)`, plus the shader `set = 0, binding = 0`, MUST be consistent. - Update strategy: `CPU_VISIBLE` buffers SHOULD be used for per‑frame updates; device‑local memory MAY be preferred for static data. - Pipeline layout: All bind group layouts used by the pipeline MUST be included via `.with_layouts(...)`. -Exercises +## Exercises - Exercise 1: Time‑based fragment color - Implement a second UBO at set 0, binding 1 with a `float time_seconds`. @@ -344,7 +380,8 @@ Exercises - Add input to adjust orbit speed. - Exercise 3: Two objects with dynamic offsets - - Pack two `GlobalsUniform` matrices into one UBO and issue two draws with different dynamic offsets. + - Pack two `GlobalsUniform` matrices into one UBO and issue two draws with + different dynamic offsets. - Use `dynamic_offsets` in `RenderCommand::SetBindGroup`. - Exercise 4: Basic Lambert lighting @@ -352,14 +389,20 @@ Exercises - Provide a lighting UBO at binding 2 with light position and color. - Exercise 5: Push constants comparison - - Port to push constants (see `crates/lambda-rs/examples/push_constants.rs`) and compare trade‑offs. + - Port to push constants (see `crates/lambda-rs/examples/push_constants.rs`) + and compare trade‑offs. - Exercise 6: Per‑material uniforms - - Split per‑frame and per‑material data; use a shared frame UBO and a per‑material UBO (e.g., tint color). + - Split per‑frame and per‑material data; use a shared frame UBO and a + per‑material UBO (e.g., tint color). - Exercise 7: Shader hot‑reload (stretch) - - Rebuild shaders on file changes and re‑create the pipeline while preserving UBOs and bind groups. + - Rebuild shaders on file changes and re‑create the pipeline while preserving + UBOs and bind groups. + +## Changelog -Changelog -- 0.2.0 (2025‑10‑17): Added goals and book‑style step explanations; expanded rationale before code blocks; refined validation and notes. +- 0.4.0 (2025‑10‑30): Added table of contents with links; converted sections to anchored headings; added ASCII data flow diagram; metadata updated. +- 0.2.0 (2025‑10‑17): Added goals and book‑style step explanations; expanded +rationale before code blocks; refined validation and notes. - 0.1.0 (2025‑10‑17): Initial draft aligned with `crates/lambda-rs/examples/uniform_buffer_triangle.rs`.