From 2602f2c463982286352ddd09404d4ea9547837a1 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 11 Nov 2025 13:16:59 -0800 Subject: [PATCH 01/25] [add] initial spec. --- docs/specs/depth-stencil-msaa.md | 201 +++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 docs/specs/depth-stencil-msaa.md diff --git a/docs/specs/depth-stencil-msaa.md b/docs/specs/depth-stencil-msaa.md new file mode 100644 index 00000000..305db9d0 --- /dev/null +++ b/docs/specs/depth-stencil-msaa.md @@ -0,0 +1,201 @@ +--- +title: "Depth/Stencil and Multi-Sample Rendering" +document_id: "depth-stencil-msaa-2025-11-11" +status: "draft" +created: "2025-11-11T00:00:00Z" +last_updated: "2025-11-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: "c37e14cfa5fe220557da5e62aa456e42f1d34383" +owners: ["lambda-sh"] +reviewers: ["engine", "rendering"] +tags: ["spec", "rendering", "depth", "stencil", "msaa"] +--- + +# Depth/Stencil and Multi-Sample Rendering + +Summary +- Add configurable depth testing/writes and multi-sample anti-aliasing (MSAA) + to the high-level rendering API via builders, without exposing `wgpu` types. +- Provide strict validation at build time and predictable defaults to enable + 3D scenes and higher-quality rasterization in example and production code. + +## Scope + +- Goals + - Expose depth/stencil and multi-sample configuration on `RenderPassBuilder` + and `RenderPipelineBuilder` using `lambda-rs` types only. + - Validate device capabilities and configuration consistency at build time. + - Define defaults for depth clear, compare operation, and sample count. + - Map high-level configuration to `lambda-rs-platform` and `wgpu` internally. +- Non-Goals + - Advanced per-draw depth bias configuration. + - Post-process or temporal anti-aliasing techniques. + - Vendor-specific tuning beyond standard device limits. + +## Terminology + +- Multi-sample anti-aliasing (MSAA): rasterization technique that stores + multiple coverage samples per pixel and resolves them to a single color. +- Depth/stencil attachment: a `GPU` texture used for depth testing and optional + stencil operations. +- Sample count: number of samples per pixel for targets and pipelines. +- Resolve: the operation that produces a single-sample color target from a + multi-sampled color target at the end of a pass. + +## Architecture Overview + +- High-level builders in `lambda-rs` collect depth/stencil and multi-sample + configuration using engine-defined types. +- `lambda-rs-platform` translates those types into backend-specific + representations for `wgpu` creation of textures, passes, and pipelines. + +``` +App Code + └── lambda-rs (RenderPassBuilder / RenderPipelineBuilder) + └── DepthStencil + MultiSample config (engine types) + └── lambda-rs-platform (mapping/validation) + └── wgpu device/pipeline/pass +``` + +## Design + +- API Surface + - Types (engine-level) + - `enum DepthFormat { Depth32Float, Depth24Plus, Depth24PlusStencil8 }` + - `enum CompareFunction { Never, Less, LessEqual, Greater, GreaterEqual, Equal, NotEqual, Always }` + - `struct DepthStencil { format: DepthFormat, clear_value: f32, write: bool, compare: CompareFunction, stencil: Option }` + - `struct StencilState { read_mask: u32, write_mask: u32, reference: u32 }` (placeholder; operations MAY be extended in a follow-up) + - `struct MultiSample { sample_count: u32 }` (MUST be >= 1 and supported) + - Builders (selected functions) + - `RenderPassBuilder::with_clear_color(Color) -> Self` + - `RenderPassBuilder::with_depth_stencil(DepthStencil) -> Self` + - `RenderPassBuilder::with_multi_sample(MultiSample) -> Self` + - `RenderPipelineBuilder::with_depth_format(DepthFormat) -> Self` + - `RenderPipelineBuilder::with_multi_sample(MultiSample) -> Self` + - Example (engine types only) + ```rust + use lambda_rs::render::{Color, DepthFormat, CompareFunction, DepthStencil, MultiSample}; + + let pass = RenderPassBuilder::new() + .with_clear_color(Color::BLACK) + .with_depth_stencil(DepthStencil { + format: DepthFormat::Depth32Float, + clear_value: 1.0, + write: true, + compare: CompareFunction::Less, + stencil: None, + }) + .with_multi_sample(MultiSample { sample_count: 4 }) + .build(&render_context)?; + + let pipeline = RenderPipelineBuilder::new() + .with_multi_sample(MultiSample { sample_count: 4 }) + .with_depth_format(DepthFormat::Depth32Float) + .build(&mut render_context, &pass, &vertex_shader, Some(&fragment_shader))?; + ``` +- Behavior + - Defaults + - If `with_depth_stencil` is not called, the pass MUST NOT create a depth + attachment and depth testing is disabled. + - `DepthStencil.clear_value` defaults to `1.0` (furthest depth). + - `DepthStencil.compare` defaults to `CompareFunction::Less`. + - `MultiSample.sample_count` defaults to `1` (no multi-sampling). + - Attachment creation + - When `with_depth_stencil` is provided, the pass MUST create a depth (and + stencil, if the format includes stencil) attachment matching `format`. + - The pass MUST clear the depth aspect to `clear_value` at the start of the + pass. Stencil clear behavior is unspecified in this version and MAY be + added when extended stencil operations are introduced. + - Multi-sample semantics + - When `sample_count > 1`, the pass MUST render into a multi-sampled color + target and resolve to the single-sample swap chain target before present. + - The pipeline `sample_count` MUST equal the pass `sample_count`. + - Matching constraints + - If a pipeline declares a depth format, it MUST equal the pass depth + attachment format. Mismatches are errors at build time. + +## Validation and Errors + +- Validation is performed in `lambda-rs` during `build(...)` and by + `lambda-rs-platform` against device limits. +- Error type: `RenderConfigurationError` + - `UnsupportedMultiSampleCount { requested: u32, supported: Vec }` + - `UnsupportedDepthFormat { format: DepthFormat }` + - `DepthFormatMismatch { pass: DepthFormat, pipeline: DepthFormat }` + - `InvalidDepthClearValue { value: f32 }` (MUST be in [0.0, 1.0]) + - `StencilUnsupported { format: DepthFormat }` + - `DeviceLimitExceeded { detail: String }` + +## Constraints and Rules + +- Multi-sample `sample_count` MUST be one of the device-supported counts. It is + typically {1, 2, 4, 8}. Non-supported counts MUST be rejected. +- `Depth24Plus` and `Depth24PlusStencil8` MAY be emulated by the backend. The + platform layer MUST query support before allocation. +- Depth clear values MUST be clamped to [0.0, 1.0] during validation. +- When the pass has no depth attachment, pipelines MUST behave as if depth + testing and depth writes are disabled. + +## Performance Considerations + +- Use 4x multi-sampling by default for higher quality at moderate cost. + - Rationale: 4x is widely supported and balances quality and performance. +- Prefer `Depth24Plus` for memory savings when stencil is not required. + - Rationale: `Depth32Float` increases memory bandwidth and storage. +- Disable depth writes (`write = false`) for purely transparent or overlay + passes. + - Rationale: Skips unnecessary bandwidth and improves parallelism. + +## Requirements Checklist + +- Functionality + - [ ] Feature flags defined (if applicable) + - [ ] Core behavior implemented + - [ ] Edge cases handled (unsupported sample counts, format mismatch, range checks) +- 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 + - Validate mapping of engine types to platform/wgpu types. + - Validate rejection of unsupported sample counts and format mismatches. + - Commands: `cargo test -p lambda-rs -- --nocapture` +- Integration Tests + - Render a depth-tested scene (e.g., overlapping cubes) at sample counts of 1 + and 4; verify occlusion and smoother edges when multi-sampling is enabled. + - Commands: `cargo test --workspace` +- Manual Checks (if necessary) + - Run `cargo run --example minimal` with a toggle for multi-sampling and + observe aliasing reduction with 4x multi-sampling. + +## Compatibility and Migration + +- No breaking changes. New configuration is additive and does not expose `wgpu` + types in the high-level API. Existing examples continue to render with + defaults (no depth, no multi-sampling) unless explicitly configured. + +## Changelog + +- 2025-11-11 (v0.1.0) — Initial draft. From 1ec667d422611875a86888dd7562117c14072bbb Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 11 Nov 2025 13:54:58 -0800 Subject: [PATCH 02/25] [add] initial depth/stencil implementation. --- .../lambda-rs-platform/src/wgpu/pipeline.rs | 13 ++- .../src/wgpu/render_pass.rs | 22 ++++ crates/lambda-rs-platform/src/wgpu/texture.rs | 103 ++++++++++++++++++ crates/lambda-rs/src/render/mod.rs | 45 +++++++- crates/lambda-rs/src/render/pipeline.rs | 38 ++++++- crates/lambda-rs/src/render/render_pass.rs | 14 +++ 6 files changed, 230 insertions(+), 5 deletions(-) diff --git a/crates/lambda-rs-platform/src/wgpu/pipeline.rs b/crates/lambda-rs-platform/src/wgpu/pipeline.rs index 5a4cfba3..31691be6 100644 --- a/crates/lambda-rs-platform/src/wgpu/pipeline.rs +++ b/crates/lambda-rs-platform/src/wgpu/pipeline.rs @@ -212,6 +212,7 @@ pub struct RenderPipelineBuilder<'a> { cull_mode: CullingMode, color_target_format: Option, depth_stencil: Option, + sample_count: u32, } impl<'a> RenderPipelineBuilder<'a> { @@ -224,6 +225,7 @@ impl<'a> RenderPipelineBuilder<'a> { cull_mode: CullingMode::Back, color_target_format: None, depth_stencil: None, + sample_count: 1, }; } @@ -275,6 +277,12 @@ impl<'a> RenderPipelineBuilder<'a> { return self; } + /// Configure multisampling. Count MUST be >= 1 and supported by the device. + pub fn with_sample_count(mut self, count: u32) -> Self { + self.sample_count = count.max(1); + return self; + } + /// Build the render pipeline from provided shader modules. pub fn build( self, @@ -351,7 +359,10 @@ impl<'a> RenderPipelineBuilder<'a> { vertex: vertex_state, primitive: primitive_state, depth_stencil: self.depth_stencil, - multisample: wgpu::MultisampleState::default(), + multisample: wgpu::MultisampleState { + count: self.sample_count, + ..wgpu::MultisampleState::default() + }, fragment, multiview: None, cache: None, diff --git a/crates/lambda-rs-platform/src/wgpu/render_pass.rs b/crates/lambda-rs-platform/src/wgpu/render_pass.rs index 181ab7e7..e4865a5e 100644 --- a/crates/lambda-rs-platform/src/wgpu/render_pass.rs +++ b/crates/lambda-rs-platform/src/wgpu/render_pass.rs @@ -210,6 +210,28 @@ impl<'a> RenderColorAttachments<'a> { return self; } + /// Append a multi-sampled color attachment with a resolve target view. + /// + /// The `msaa_view` MUST have a sample count > 1 and the `resolve_view` MUST + /// be a single-sample view of the same format and size. + pub fn push_msaa_color( + &mut self, + msaa_view: surface::TextureViewRef<'a>, + resolve_view: surface::TextureViewRef<'a>, + ) -> &mut Self { + let attachment = wgpu::RenderPassColorAttachment { + view: msaa_view.raw, + resolve_target: Some(resolve_view.raw), + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }; + self.attachments.push(Some(attachment)); + return self; + } + /// Apply the same operations to all color attachments. pub(crate) fn set_operations_for_all( &mut self, diff --git a/crates/lambda-rs-platform/src/wgpu/texture.rs b/crates/lambda-rs-platform/src/wgpu/texture.rs index b2981631..60acf215 100644 --- a/crates/lambda-rs-platform/src/wgpu/texture.rs +++ b/crates/lambda-rs-platform/src/wgpu/texture.rs @@ -137,6 +137,109 @@ impl DepthTexture { } } +#[derive(Debug)] +/// Wrapper for a multi-sampled color render target with an attachment view. +pub struct ColorAttachmentTexture { + pub(crate) raw: wgpu::Texture, + pub(crate) view: wgpu::TextureView, + pub(crate) label: Option, +} + +impl ColorAttachmentTexture { + /// Borrow the underlying `wgpu::Texture`. + pub fn raw(&self) -> &wgpu::Texture { + return &self.raw; + } + + /// Borrow the full-range `wgpu::TextureView` suitable as a color attachment. + pub fn view(&self) -> &wgpu::TextureView { + return &self.view; + } + + /// Convenience: return a `TextureViewRef` for use in render pass attachments. + pub fn view_ref(&self) -> crate::wgpu::surface::TextureViewRef<'_> { + return crate::wgpu::surface::TextureViewRef { raw: &self.view }; + } +} + +/// Builder for a color render attachment texture (commonly used for MSAA). +pub struct ColorAttachmentTextureBuilder { + label: Option, + width: u32, + height: u32, + format: crate::wgpu::surface::SurfaceFormat, + sample_count: u32, +} + +impl ColorAttachmentTextureBuilder { + /// Create a builder with zero size and sample count 1. + pub fn new(format: crate::wgpu::surface::SurfaceFormat) -> Self { + return Self { + label: None, + width: 0, + height: 0, + format, + sample_count: 1, + }; + } + + /// Set the 2D attachment size in pixels. + pub fn with_size(mut self, width: u32, height: u32) -> Self { + self.width = width; + self.height = height; + return self; + } + + /// Configure multisampling. Count MUST be >= 1. + pub fn with_sample_count(mut self, count: u32) -> Self { + self.sample_count = count.max(1); + return self; + } + + /// Attach a debug label for the created texture. + pub fn with_label(mut self, label: &str) -> Self { + self.label = Some(label.to_string()); + return self; + } + + /// Create the color attachment texture on the device. + pub fn build(self, gpu: &Gpu) -> ColorAttachmentTexture { + let size = wgpu::Extent3d { + width: self.width.max(1), + height: self.height.max(1), + depth_or_array_layers: 1, + }; + let format = self.format.to_wgpu(); + let raw = gpu.device().create_texture(&wgpu::TextureDescriptor { + label: self.label.as_deref(), + size, + mip_level_count: 1, + sample_count: self.sample_count, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let view = raw.create_view(&wgpu::TextureViewDescriptor { + label: None, + format: Some(format), + dimension: Some(wgpu::TextureViewDimension::D2), + aspect: wgpu::TextureAspect::All, + base_mip_level: 0, + mip_level_count: None, + base_array_layer: 0, + array_layer_count: None, + usage: Some(wgpu::TextureUsages::RENDER_ATTACHMENT), + }); + + return ColorAttachmentTexture { + raw, + view, + label: self.label, + }; + } +} + /// Builder for a depth texture attachment sized to the current framebuffer. pub struct DepthTextureBuilder { label: Option, diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index 81e7c22f..5b1bd64d 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -174,6 +174,9 @@ impl RenderContextBuilder { size, depth_texture, depth_format, + depth_sample_count: 1, + msaa_color: None, + msaa_sample_count: 1, render_passes: vec![], render_pipelines: vec![], bind_group_layouts: vec![], @@ -210,6 +213,9 @@ pub struct RenderContext { size: (u32, u32), depth_texture: Option, depth_format: platform::texture::DepthFormat, + depth_sample_count: u32, + msaa_color: Option, + msaa_sample_count: u32, render_passes: Vec, render_pipelines: Vec, bind_group_layouts: Vec, @@ -304,9 +310,12 @@ impl RenderContext { platform::texture::DepthTextureBuilder::new() .with_size(self.size.0.max(1), self.size.1.max(1)) .with_format(self.depth_format) + .with_sample_count(self.depth_sample_count) .with_label("lambda-depth") .build(self.gpu()), ); + // Drop MSAA color target so it is rebuilt on demand with the new size. + self.msaa_color = None; } /// Borrow a previously attached render pass by id. @@ -412,19 +421,51 @@ impl RenderContext { // Create variably sized color attachments and begin the pass. let mut color_attachments = platform::render_pass::RenderColorAttachments::new(); - color_attachments.push_color(view); + let sample_count = pass.sample_count(); + if sample_count > 1 { + let need_recreate = match &self.msaa_color { + Some(_) => self.msaa_sample_count != sample_count, + None => true, + }; + if need_recreate { + self.msaa_color = Some( + platform::texture::ColorAttachmentTextureBuilder::new( + self.config.format, + ) + .with_size(self.size.0.max(1), self.size.1.max(1)) + .with_sample_count(sample_count) + .with_label("lambda-msaa-color") + .build(self.gpu()), + ); + self.msaa_sample_count = sample_count; + } + let msaa_view = self + .msaa_color + .as_ref() + .expect("MSAA color attachment should be created") + .view_ref(); + color_attachments.push_msaa_color(msaa_view, view); + } else { + color_attachments.push_color(view); + } // Optional depth attachment configured by the pass description. let (depth_view, depth_ops) = match pass.depth_operations() { Some(dops) => { - if self.depth_texture.is_none() { + // Ensure depth texture exists and matches the pass's sample count. + let desired_samples = sample_count.max(1); + if self.depth_texture.is_none() + || self.depth_sample_count != desired_samples + { self.depth_texture = Some( platform::texture::DepthTextureBuilder::new() .with_size(self.size.0.max(1), self.size.1.max(1)) .with_format(self.depth_format) + .with_sample_count(desired_samples) .with_label("lambda-depth") .build(self.gpu()), ); + self.depth_sample_count = desired_samples; } let mut view_ref = self .depth_texture diff --git a/crates/lambda-rs/src/render/pipeline.rs b/crates/lambda-rs/src/render/pipeline.rs index 0183d5ad..fdf7a4f6 100644 --- a/crates/lambda-rs/src/render/pipeline.rs +++ b/crates/lambda-rs/src/render/pipeline.rs @@ -51,6 +51,7 @@ use super::{ pub struct RenderPipeline { pipeline: Rc, buffers: Vec>, + sample_count: u32, } impl RenderPipeline { @@ -66,6 +67,11 @@ impl RenderPipeline { pub(super) fn pipeline(&self) -> &platform_pipeline::RenderPipeline { return self.pipeline.as_ref(); } + + /// Multisample count configured on this pipeline. + pub fn sample_count(&self) -> u32 { + return self.sample_count.max(1); + } } /// Public alias for platform shader stage flags used by push constants. @@ -96,6 +102,8 @@ pub struct RenderPipelineBuilder { bind_group_layouts: Vec, label: Option, use_depth: bool, + depth_format: Option, + sample_count: u32, } impl RenderPipelineBuilder { @@ -108,6 +116,8 @@ impl RenderPipelineBuilder { bind_group_layouts: Vec::new(), label: None, use_depth: false, + depth_format: None, + sample_count: 1, } } @@ -158,6 +168,22 @@ impl RenderPipelineBuilder { return self; } + /// Enable depth with an explicit depth format. + pub fn with_depth_format( + mut self, + format: platform_texture::DepthFormat, + ) -> Self { + self.use_depth = true; + self.depth_format = Some(format); + return self; + } + + /// Configure multi-sampling for this pipeline. + pub fn with_multi_sample(mut self, samples: u32) -> Self { + self.sample_count = samples.max(1); + return self; + } + /// Build a graphics pipeline using the provided shader modules and /// previously registered vertex inputs and push constants. pub fn build( @@ -249,10 +275,17 @@ impl RenderPipelineBuilder { } if self.use_depth { - rp_builder = rp_builder - .with_depth_stencil(platform_texture::DepthFormat::Depth32Float); + let dfmt = self + .depth_format + .unwrap_or(platform_texture::DepthFormat::Depth32Float); + // Keep context depth format in sync for attachment creation. + render_context.depth_format = dfmt; + rp_builder = rp_builder.with_depth_stencil(dfmt); } + // Apply multi-sampling to the pipeline. + rp_builder = rp_builder.with_sample_count(self.sample_count); + let pipeline = rp_builder.build( render_context.gpu(), &vertex_module, @@ -262,6 +295,7 @@ impl RenderPipelineBuilder { return RenderPipeline { pipeline: Rc::new(pipeline), buffers, + sample_count: self.sample_count, }; } } diff --git a/crates/lambda-rs/src/render/render_pass.rs b/crates/lambda-rs/src/render/render_pass.rs index 6dc732db..fdf6c6cf 100644 --- a/crates/lambda-rs/src/render/render_pass.rs +++ b/crates/lambda-rs/src/render/render_pass.rs @@ -75,6 +75,7 @@ pub struct RenderPass { label: Option, color_operations: ColorOperations, depth_operations: Option, + sample_count: u32, } impl RenderPass { @@ -96,6 +97,10 @@ impl RenderPass { pub(crate) fn depth_operations(&self) -> Option { return self.depth_operations; } + + pub(crate) fn sample_count(&self) -> u32 { + return self.sample_count.max(1); + } } /// Builder for a `RenderPass` description. @@ -108,6 +113,7 @@ pub struct RenderPassBuilder { label: Option, color_operations: ColorOperations, depth_operations: Option, + sample_count: u32, } impl RenderPassBuilder { @@ -118,6 +124,7 @@ impl RenderPassBuilder { label: None, color_operations: ColorOperations::default(), depth_operations: None, + sample_count: 1, } } @@ -176,6 +183,12 @@ impl RenderPassBuilder { return self; } + /// Configure multi-sample anti-aliasing for this pass. + pub fn with_multi_sample(mut self, samples: u32) -> Self { + self.sample_count = samples.max(1); + return self; + } + /// Build the description used when beginning a render pass. pub fn build(self, _render_context: &RenderContext) -> RenderPass { RenderPass { @@ -183,6 +196,7 @@ impl RenderPassBuilder { label: self.label, color_operations: self.color_operations, depth_operations: self.depth_operations, + sample_count: self.sample_count, } } } From 21d0a5b511144db31f10ee07b2efb640ca990daf Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 11 Nov 2025 14:09:32 -0800 Subject: [PATCH 03/25] [add] validation for sample counts. --- crates/lambda-rs/src/render/pipeline.rs | 32 ++++++++++++++++-- crates/lambda-rs/src/render/render_pass.rs | 16 ++++++++- crates/lambda-rs/src/render/validation.rs | 16 +++++++++ docs/specs/depth-stencil-msaa.md | 39 +++++++++++++--------- 4 files changed, 83 insertions(+), 20 deletions(-) diff --git a/crates/lambda-rs/src/render/pipeline.rs b/crates/lambda-rs/src/render/pipeline.rs index fdf7a4f6..401e481a 100644 --- a/crates/lambda-rs/src/render/pipeline.rs +++ b/crates/lambda-rs/src/render/pipeline.rs @@ -34,6 +34,7 @@ use lambda_platform::wgpu::{ pipeline as platform_pipeline, texture as platform_texture, }; +use logging; use super::{ bind, @@ -43,6 +44,7 @@ use super::{ vertex::VertexAttribute, RenderContext, }; +use crate::render::validation; /// A created graphics pipeline and the vertex buffers it expects. #[derive(Debug)] @@ -180,7 +182,15 @@ impl RenderPipelineBuilder { /// Configure multi-sampling for this pipeline. pub fn with_multi_sample(mut self, samples: u32) -> Self { - self.sample_count = samples.max(1); + match validation::validate_sample_count(samples) { + Ok(()) => { + self.sample_count = samples; + } + Err(msg) => { + logging::error!("{}; falling back to sample_count=1 for pipeline", msg); + self.sample_count = 1; + } + } return self; } @@ -284,7 +294,23 @@ impl RenderPipelineBuilder { } // Apply multi-sampling to the pipeline. - rp_builder = rp_builder.with_sample_count(self.sample_count); + // Ensure pass and pipeline samples match; adjust if needed with a warning. + let pass_samples = _render_pass.sample_count(); + let mut pipeline_samples = self.sample_count; + if pipeline_samples != pass_samples { + logging::error!( + "Pipeline sample_count={} does not match pass sample_count={}; aligning to pass", + pipeline_samples, + pass_samples + ); + pipeline_samples = pass_samples; + } + // Validate again (defensive) in case pass was built with an invalid value + // and clamped by its builder. + if validation::validate_sample_count(pipeline_samples).is_err() { + pipeline_samples = 1; + } + rp_builder = rp_builder.with_sample_count(pipeline_samples); let pipeline = rp_builder.build( render_context.gpu(), @@ -295,7 +321,7 @@ impl RenderPipelineBuilder { return RenderPipeline { pipeline: Rc::new(pipeline), buffers, - sample_count: self.sample_count, + sample_count: pipeline_samples, }; } } diff --git a/crates/lambda-rs/src/render/render_pass.rs b/crates/lambda-rs/src/render/render_pass.rs index fdf6c6cf..248f352e 100644 --- a/crates/lambda-rs/src/render/render_pass.rs +++ b/crates/lambda-rs/src/render/render_pass.rs @@ -4,7 +4,10 @@ //! against the swapchain (currently a single color attachment and clear color). //! The pass is referenced by handle from `RenderCommand::BeginRenderPass`. +use logging; + use super::RenderContext; +use crate::render::validation; /// Color load operation for the first color attachment. #[derive(Debug, Clone, Copy, PartialEq)] @@ -185,7 +188,18 @@ impl RenderPassBuilder { /// Configure multi-sample anti-aliasing for this pass. pub fn with_multi_sample(mut self, samples: u32) -> Self { - self.sample_count = samples.max(1); + match validation::validate_sample_count(samples) { + Ok(()) => { + self.sample_count = samples; + } + Err(msg) => { + logging::error!( + "{}; falling back to sample_count=1 for render pass", + msg + ); + self.sample_count = 1; + } + } return self; } diff --git a/crates/lambda-rs/src/render/validation.rs b/crates/lambda-rs/src/render/validation.rs index a2d64803..0d13e375 100644 --- a/crates/lambda-rs/src/render/validation.rs +++ b/crates/lambda-rs/src/render/validation.rs @@ -38,6 +38,22 @@ pub fn validate_dynamic_offsets( return Ok(()); } +/// Validate that a multi-sample count is supported by the engine. +/// +/// Allowed counts are 1, 2, 4, and 8. Higher or non-power-of-two values are +/// rejected at this layer to provide consistent behavior across platforms. +pub fn validate_sample_count(samples: u32) -> Result<(), String> { + match samples { + 1 | 2 | 4 | 8 => return Ok(()), + other => { + return Err(format!( + "Unsupported multi-sample count {} (allowed: 1, 2, 4, 8)", + other + )); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/specs/depth-stencil-msaa.md b/docs/specs/depth-stencil-msaa.md index 305db9d0..625de7b3 100644 --- a/docs/specs/depth-stencil-msaa.md +++ b/docs/specs/depth-stencil-msaa.md @@ -3,13 +3,13 @@ title: "Depth/Stencil and Multi-Sample Rendering" document_id: "depth-stencil-msaa-2025-11-11" status: "draft" created: "2025-11-11T00:00:00Z" -last_updated: "2025-11-11T00:00:00Z" -version: "0.1.0" +last_updated: "2025-11-11T00:10: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: "c37e14cfa5fe220557da5e62aa456e42f1d34383" +repo_commit: "1ec667d422611875a86888dd7562117c14072bbb" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "rendering", "depth", "stencil", "msaa"] @@ -110,25 +110,30 @@ App Code - The pass MUST clear the depth aspect to `clear_value` at the start of the pass. Stencil clear behavior is unspecified in this version and MAY be added when extended stencil operations are introduced. - - Multi-sample semantics - - When `sample_count > 1`, the pass MUST render into a multi-sampled color - target and resolve to the single-sample swap chain target before present. - - The pipeline `sample_count` MUST equal the pass `sample_count`. +- Multi-sample semantics + - When `sample_count > 1`, the pass MUST render into a multi-sampled color + target and resolve to the single-sample swap chain target before present. + - The pipeline `sample_count` MUST equal the pass `sample_count`. If a + mismatch is detected during pipeline build, the engine aligns the pipeline + to the pass sample count and logs an error. - Matching constraints - If a pipeline declares a depth format, it MUST equal the pass depth attachment format. Mismatches are errors at build time. ## Validation and Errors -- Validation is performed in `lambda-rs` during `build(...)` and by - `lambda-rs-platform` against device limits. -- Error type: `RenderConfigurationError` - - `UnsupportedMultiSampleCount { requested: u32, supported: Vec }` - - `UnsupportedDepthFormat { format: DepthFormat }` - - `DepthFormatMismatch { pass: DepthFormat, pipeline: DepthFormat }` - - `InvalidDepthClearValue { value: f32 }` (MUST be in [0.0, 1.0]) - - `StencilUnsupported { format: DepthFormat }` - - `DeviceLimitExceeded { detail: String }` +- Validation is performed in `lambda-rs` during builder configuration and + `build(...)`. Current behavior prefers logging and safe fallbacks over + returning errors to preserve API stability. +- Multi-sample count validation + - Allowed counts: 1, 2, 4, 8. Other values are rejected with an error log and + clamped to `1` during `with_multi_sample(...)`. + - On pipeline build, if the pipeline sample count differs from the pass, the + engine aligns the pipeline to the pass and logs an error. +- Depth clear validation + - Clear values outside `[0.0, 1.0]` SHOULD be rejected; current engine path + relies on caller-provided sane values and `wgpu` validation. A strict check + MAY be added in a follow-up. ## Constraints and Rules @@ -198,4 +203,6 @@ path that demonstrates the implementation. ## Changelog +- 2025-11-11 (v0.1.1) — Add MSAA validation in builders; align pipeline and + pass sample counts; document logging-based fallback semantics. - 2025-11-11 (v0.1.0) — Initial draft. From e41042ee7c431642f01776942035776505aeb34f Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 13 Nov 2025 16:00:00 -0800 Subject: [PATCH 04/25] [add] stencils and msaa implementations, update specifications. --- .../lambda-rs-platform/src/wgpu/pipeline.rs | 133 ++++++++++++++++++ .../src/wgpu/render_pass.rs | 91 +++++++++--- crates/lambda-rs/src/render/command.rs | 3 + crates/lambda-rs/src/render/mod.rs | 111 +++++++++++---- crates/lambda-rs/src/render/pipeline.rs | 57 +++++++- crates/lambda-rs/src/render/render_pass.rs | 56 +++++++- docs/specs/depth-stencil-msaa.md | 92 ++++++------ 7 files changed, 452 insertions(+), 91 deletions(-) diff --git a/crates/lambda-rs-platform/src/wgpu/pipeline.rs b/crates/lambda-rs-platform/src/wgpu/pipeline.rs index 31691be6..8b99abba 100644 --- a/crates/lambda-rs-platform/src/wgpu/pipeline.rs +++ b/crates/lambda-rs-platform/src/wgpu/pipeline.rs @@ -81,6 +81,95 @@ pub struct VertexAttributeDesc { pub format: ColorFormat, } +/// Compare function used for depth and stencil tests. +#[derive(Clone, Copy, Debug)] +pub enum CompareFunction { + Never, + Less, + LessEqual, + Greater, + GreaterEqual, + Equal, + NotEqual, + Always, +} + +impl CompareFunction { + fn to_wgpu(self) -> wgpu::CompareFunction { + match self { + CompareFunction::Never => wgpu::CompareFunction::Never, + CompareFunction::Less => wgpu::CompareFunction::Less, + CompareFunction::LessEqual => wgpu::CompareFunction::LessEqual, + CompareFunction::Greater => wgpu::CompareFunction::Greater, + CompareFunction::GreaterEqual => wgpu::CompareFunction::GreaterEqual, + CompareFunction::Equal => wgpu::CompareFunction::Equal, + CompareFunction::NotEqual => wgpu::CompareFunction::NotEqual, + CompareFunction::Always => wgpu::CompareFunction::Always, + } + } +} + +/// Stencil operation applied when the stencil test or depth test passes/fails. +#[derive(Clone, Copy, Debug)] +pub enum StencilOperation { + Keep, + Zero, + Replace, + Invert, + IncrementClamp, + DecrementClamp, + IncrementWrap, + DecrementWrap, +} + +impl StencilOperation { + fn to_wgpu(self) -> wgpu::StencilOperation { + match self { + StencilOperation::Keep => wgpu::StencilOperation::Keep, + StencilOperation::Zero => wgpu::StencilOperation::Zero, + StencilOperation::Replace => wgpu::StencilOperation::Replace, + StencilOperation::Invert => wgpu::StencilOperation::Invert, + StencilOperation::IncrementClamp => { + wgpu::StencilOperation::IncrementClamp + } + StencilOperation::DecrementClamp => { + wgpu::StencilOperation::DecrementClamp + } + StencilOperation::IncrementWrap => wgpu::StencilOperation::IncrementWrap, + StencilOperation::DecrementWrap => wgpu::StencilOperation::DecrementWrap, + } + } +} + +/// Per-face stencil state. +#[derive(Clone, Copy, Debug)] +pub struct StencilFaceState { + pub compare: CompareFunction, + pub fail_op: StencilOperation, + pub depth_fail_op: StencilOperation, + pub pass_op: StencilOperation, +} + +impl StencilFaceState { + fn to_wgpu(self) -> wgpu::StencilFaceState { + wgpu::StencilFaceState { + compare: self.compare.to_wgpu(), + fail_op: self.fail_op.to_wgpu(), + depth_fail_op: self.depth_fail_op.to_wgpu(), + pass_op: self.pass_op.to_wgpu(), + } + } +} + +/// Full stencil state (front/back + masks). +#[derive(Clone, Copy, Debug)] +pub struct StencilState { + pub front: StencilFaceState, + pub back: StencilFaceState, + pub read_mask: u32, + pub write_mask: u32, +} + /// Wrapper around `wgpu::ShaderModule` that preserves a label. #[derive(Debug)] pub struct ShaderModule { @@ -277,6 +366,50 @@ impl<'a> RenderPipelineBuilder<'a> { return self; } + /// Set the depth compare function. Requires depth to be enabled. + pub fn with_depth_compare(mut self, compare: CompareFunction) -> Self { + let ds = self.depth_stencil.get_or_insert(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::Less, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }); + ds.depth_compare = compare.to_wgpu(); + return self; + } + + /// Enable or disable depth writes. Requires depth-stencil enabled. + pub fn with_depth_write_enabled(mut self, enabled: bool) -> Self { + let ds = self.depth_stencil.get_or_insert(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::Less, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }); + ds.depth_write_enabled = enabled; + return self; + } + + /// Configure stencil state (front/back ops and masks). Requires depth-stencil enabled. + pub fn with_stencil(mut self, stencil: StencilState) -> Self { + let ds = self.depth_stencil.get_or_insert(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth24PlusStencil8, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::Less, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }); + ds.stencil = wgpu::StencilState { + front: stencil.front.to_wgpu(), + back: stencil.back.to_wgpu(), + read_mask: stencil.read_mask, + write_mask: stencil.write_mask, + }; + return self; + } + /// Configure multisampling. Count MUST be >= 1 and supported by the device. pub fn with_sample_count(mut self, count: u32) -> Self { self.sample_count = count.max(1); diff --git a/crates/lambda-rs-platform/src/wgpu/render_pass.rs b/crates/lambda-rs-platform/src/wgpu/render_pass.rs index e4865a5e..2e823aa5 100644 --- a/crates/lambda-rs-platform/src/wgpu/render_pass.rs +++ b/crates/lambda-rs-platform/src/wgpu/render_pass.rs @@ -79,6 +79,31 @@ impl Default for DepthOperations { } } +/// Stencil load operation for a stencil attachment. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum StencilLoadOp { + /// Load the existing contents of the stencil attachment. + Load, + /// Clear the stencil attachment to the provided value. + Clear(u32), +} + +/// Stencil operations (load/store) for the stencil attachment. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct StencilOperations { + pub load: StencilLoadOp, + pub store: StoreOp, +} + +impl Default for StencilOperations { + fn default() -> Self { + return Self { + load: StencilLoadOp::Clear(0), + store: StoreOp::Store, + }; + } +} + /// Configuration for beginning a render pass. #[derive(Clone, Debug, Default)] pub struct RenderPassConfig { @@ -313,6 +338,7 @@ impl RenderPassBuilder { attachments: &'view mut RenderColorAttachments<'view>, depth_view: Option>, depth_ops: Option, + stencil_ops: Option, ) -> RenderPass<'view> { let operations = match self.config.color_operations.load { ColorLoadOp::Load => wgpu::Operations { @@ -339,28 +365,50 @@ impl RenderPassBuilder { // Apply operations to all provided attachments. attachments.set_operations_for_all(operations); - // Optional depth attachment + // Optional depth/stencil attachment. Include stencil ops only when provided + // to avoid referring to a stencil aspect on depth-only formats. let depth_stencil_attachment = depth_view.map(|v| { + // Map depth ops (defaulting when not provided) let dop = depth_ops.unwrap_or_default(); - wgpu::RenderPassDepthStencilAttachment { - view: v.raw, - depth_ops: Some(match dop.load { - DepthLoadOp::Load => wgpu::Operations { - load: wgpu::LoadOp::Load, - store: match dop.store { - StoreOp::Store => wgpu::StoreOp::Store, - StoreOp::Discard => wgpu::StoreOp::Discard, - }, + let mapped_depth_ops = Some(match dop.load { + DepthLoadOp::Load => wgpu::Operations { + load: wgpu::LoadOp::Load, + store: match dop.store { + StoreOp::Store => wgpu::StoreOp::Store, + StoreOp::Discard => wgpu::StoreOp::Discard, }, - DepthLoadOp::Clear(value) => wgpu::Operations { - load: wgpu::LoadOp::Clear(value), - store: match dop.store { - StoreOp::Store => wgpu::StoreOp::Store, - StoreOp::Discard => wgpu::StoreOp::Discard, - }, + }, + DepthLoadOp::Clear(value) => wgpu::Operations { + load: wgpu::LoadOp::Clear(value), + store: match dop.store { + StoreOp::Store => wgpu::StoreOp::Store, + StoreOp::Discard => wgpu::StoreOp::Discard, }, - }), - stencil_ops: None, + }, + }); + + // Map stencil ops only if explicitly provided + let mapped_stencil_ops = stencil_ops.map(|sop| match sop.load { + StencilLoadOp::Load => wgpu::Operations { + load: wgpu::LoadOp::Load, + store: match sop.store { + StoreOp::Store => wgpu::StoreOp::Store, + StoreOp::Discard => wgpu::StoreOp::Discard, + }, + }, + StencilLoadOp::Clear(value) => wgpu::Operations { + load: wgpu::LoadOp::Clear(value), + store: match sop.store { + StoreOp::Store => wgpu::StoreOp::Store, + StoreOp::Discard => wgpu::StoreOp::Discard, + }, + }, + }); + + wgpu::RenderPassDepthStencilAttachment { + view: v.raw, + depth_ops: mapped_depth_ops, + stencil_ops: mapped_stencil_ops, } }); @@ -376,3 +424,10 @@ impl RenderPassBuilder { return RenderPass { raw: pass }; } } + +impl<'a> RenderPass<'a> { + /// Set the stencil reference value used by the active pipeline's stencil test. + pub fn set_stencil_reference(&mut self, reference: u32) { + self.raw.set_stencil_reference(reference); + } +} diff --git a/crates/lambda-rs/src/render/command.rs b/crates/lambda-rs/src/render/command.rs index 87456e3f..2dce775d 100644 --- a/crates/lambda-rs/src/render/command.rs +++ b/crates/lambda-rs/src/render/command.rs @@ -39,6 +39,9 @@ pub enum RenderCommand { /// End the current render pass. EndRenderPass, + /// Set the stencil reference value for the active pass. + SetStencilReference { reference: u32 }, + /// Upload push constants for the active pipeline/stage at `offset`. /// /// The byte vector is interpreted as tightly packed `u32` words; the diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index 5b1bd64d..36cf1e6d 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -449,30 +449,57 @@ impl RenderContext { color_attachments.push_color(view); } - // Optional depth attachment configured by the pass description. - let (depth_view, depth_ops) = match pass.depth_operations() { - Some(dops) => { - // Ensure depth texture exists and matches the pass's sample count. - let desired_samples = sample_count.max(1); - if self.depth_texture.is_none() - || self.depth_sample_count != desired_samples - { - self.depth_texture = Some( - platform::texture::DepthTextureBuilder::new() - .with_size(self.size.0.max(1), self.size.1.max(1)) - .with_format(self.depth_format) - .with_sample_count(desired_samples) - .with_label("lambda-depth") - .build(self.gpu()), - ); - self.depth_sample_count = desired_samples; - } - let mut view_ref = self - .depth_texture - .as_ref() - .expect("depth texture should be present") - .view_ref(); - let mapped = platform::render_pass::DepthOperations { + // Depth/stencil attachment when either depth or stencil requested. + let want_depth_attachment = pass.depth_operations().is_some() + || pass.stencil_operations().is_some(); + + let (depth_view, depth_ops) = if want_depth_attachment { + // Ensure depth texture exists, with proper sample count and format. + let desired_samples = sample_count.max(1); + + // If stencil is requested on the pass, ensure we use a stencil-capable format. + if pass.stencil_operations().is_some() + && self.depth_format + != platform::texture::DepthFormat::Depth24PlusStencil8 + { + logging::error!( + "Render pass has stencil ops but depth format {:?} lacks stencil; upgrading to Depth24PlusStencil8", + self.depth_format + ); + self.depth_format = + platform::texture::DepthFormat::Depth24PlusStencil8; + } + + let format_mismatch = self + .depth_texture + .as_ref() + .map(|dt| dt.format() != self.depth_format) + .unwrap_or(true); + + if self.depth_texture.is_none() + || self.depth_sample_count != desired_samples + || format_mismatch + { + self.depth_texture = Some( + platform::texture::DepthTextureBuilder::new() + .with_size(self.size.0.max(1), self.size.1.max(1)) + .with_format(self.depth_format) + .with_sample_count(desired_samples) + .with_label("lambda-depth") + .build(self.gpu()), + ); + self.depth_sample_count = desired_samples; + } + + let view_ref = self + .depth_texture + .as_ref() + .expect("depth texture should be present") + .view_ref(); + + // Map depth ops; default when not explicitly provided. + let mapped = match pass.depth_operations() { + Some(dops) => platform::render_pass::DepthOperations { load: match dops.load { render_pass::DepthLoadOp::Load => { platform::render_pass::DepthLoadOp::Load @@ -489,17 +516,42 @@ impl RenderContext { platform::render_pass::StoreOp::Discard } }, - }; - (Some(view_ref), Some(mapped)) - } - None => (None, None), + }, + None => platform::render_pass::DepthOperations::default(), + }; + (Some(view_ref), Some(mapped)) + } else { + (None, None) }; + // Optional stencil operations + let stencil_ops = pass.stencil_operations().map(|sop| { + platform::render_pass::StencilOperations { + load: match sop.load { + render_pass::StencilLoadOp::Load => { + platform::render_pass::StencilLoadOp::Load + } + render_pass::StencilLoadOp::Clear(v) => { + platform::render_pass::StencilLoadOp::Clear(v) + } + }, + store: match sop.store { + render_pass::StoreOp::Store => { + platform::render_pass::StoreOp::Store + } + render_pass::StoreOp::Discard => { + platform::render_pass::StoreOp::Discard + } + }, + } + }); + let mut pass_encoder = rp_builder.build( &mut encoder, &mut color_attachments, depth_view, depth_ops, + stencil_ops, ); self.encode_pass(&mut pass_encoder, viewport, &mut command_iter)?; @@ -533,6 +585,9 @@ impl RenderContext { while let Some(command) = commands.next() { match command { RenderCommand::EndRenderPass => return Ok(()), + RenderCommand::SetStencilReference { reference } => { + pass.set_stencil_reference(reference); + } RenderCommand::SetPipeline { pipeline } => { 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 401e481a..2458d19d 100644 --- a/crates/lambda-rs/src/render/pipeline.rs +++ b/crates/lambda-rs/src/render/pipeline.rs @@ -89,6 +89,12 @@ struct BufferBinding { /// Public alias for platform culling mode used by pipeline builders. pub use platform_pipeline::CullingMode; +pub use platform_pipeline::{ + CompareFunction, + StencilFaceState as PlatformStencilFaceState, + StencilOperation as PlatformStencilOperation, + StencilState as PlatformStencilState, +}; /// Builder for creating a graphics `RenderPipeline`. /// @@ -106,6 +112,9 @@ pub struct RenderPipelineBuilder { use_depth: bool, depth_format: Option, sample_count: u32, + depth_compare: Option, + stencil: Option, + depth_write_enabled: Option, } impl RenderPipelineBuilder { @@ -120,6 +129,9 @@ impl RenderPipelineBuilder { use_depth: false, depth_format: None, sample_count: 1, + depth_compare: None, + stencil: None, + depth_write_enabled: None, } } @@ -194,6 +206,30 @@ impl RenderPipelineBuilder { return self; } + /// Set a non-default depth compare function. + pub fn with_depth_compare( + mut self, + compare: platform_pipeline::CompareFunction, + ) -> Self { + self.depth_compare = Some(compare); + return self; + } + + /// Configure stencil state for the pipeline. + pub fn with_stencil( + mut self, + stencil: platform_pipeline::StencilState, + ) -> Self { + self.stencil = Some(stencil); + return self; + } + + /// Enable or disable depth writes for this pipeline. + pub fn with_depth_write(mut self, enabled: bool) -> Self { + self.depth_write_enabled = Some(enabled); + return self; + } + /// Build a graphics pipeline using the provided shader modules and /// previously registered vertex inputs and push constants. pub fn build( @@ -285,12 +321,31 @@ impl RenderPipelineBuilder { } if self.use_depth { - let dfmt = self + let mut dfmt = self .depth_format .unwrap_or(platform_texture::DepthFormat::Depth32Float); + // If stencil state is configured, ensure a stencil-capable depth format. + if self.stencil.is_some() + && dfmt != platform_texture::DepthFormat::Depth24PlusStencil8 + { + logging::error!( + "Stencil configured but depth format {:?} lacks stencil; upgrading to Depth24PlusStencil8", + dfmt + ); + dfmt = platform_texture::DepthFormat::Depth24PlusStencil8; + } // Keep context depth format in sync for attachment creation. render_context.depth_format = dfmt; rp_builder = rp_builder.with_depth_stencil(dfmt); + if let Some(compare) = self.depth_compare { + rp_builder = rp_builder.with_depth_compare(compare); + } + if let Some(stencil) = self.stencil { + rp_builder = rp_builder.with_stencil(stencil); + } + if let Some(enabled) = self.depth_write_enabled { + rp_builder = rp_builder.with_depth_write_enabled(enabled); + } } // Apply multi-sampling to the pipeline. diff --git a/crates/lambda-rs/src/render/render_pass.rs b/crates/lambda-rs/src/render/render_pass.rs index 248f352e..eba6cd50 100644 --- a/crates/lambda-rs/src/render/render_pass.rs +++ b/crates/lambda-rs/src/render/render_pass.rs @@ -78,6 +78,7 @@ pub struct RenderPass { label: Option, color_operations: ColorOperations, depth_operations: Option, + stencil_operations: Option, sample_count: u32, } @@ -104,6 +105,10 @@ impl RenderPass { pub(crate) fn sample_count(&self) -> u32 { return self.sample_count.max(1); } + + pub(crate) fn stencil_operations(&self) -> Option { + return self.stencil_operations; + } } /// Builder for a `RenderPass` description. @@ -116,19 +121,21 @@ pub struct RenderPassBuilder { label: Option, color_operations: ColorOperations, depth_operations: Option, + stencil_operations: Option, sample_count: u32, } impl RenderPassBuilder { /// Creates a new render pass builder. pub fn new() -> Self { - Self { + return Self { clear_color: [0.0, 0.0, 0.0, 1.0], label: None, color_operations: ColorOperations::default(), depth_operations: None, + stencil_operations: None, sample_count: 1, - } + }; } /// Specify the clear color used for the first color attachment. @@ -138,13 +145,13 @@ impl RenderPassBuilder { load: ColorLoadOp::Clear(color), store: StoreOp::Store, }; - self + return self; } /// Attach a label to the render pass for debugging/profiling. pub fn with_label(mut self, label: &str) -> Self { self.label = Some(label.to_string()); - self + return self; } /// Specify the color load operation for the first color attachment. @@ -186,6 +193,21 @@ impl RenderPassBuilder { return self; } + /// Enable a stencil attachment with default clear to 0 and store. + pub fn with_stencil(mut self) -> Self { + self.stencil_operations = Some(StencilOperations::default()); + return self; + } + + /// Enable a stencil attachment with an explicit clear value. + pub fn with_stencil_clear(mut self, clear: u32) -> Self { + self.stencil_operations = Some(StencilOperations { + load: StencilLoadOp::Clear(clear), + store: StoreOp::Store, + }); + return self; + } + /// Configure multi-sample anti-aliasing for this pass. pub fn with_multi_sample(mut self, samples: u32) -> Self { match validation::validate_sample_count(samples) { @@ -210,7 +232,33 @@ impl RenderPassBuilder { label: self.label, color_operations: self.color_operations, depth_operations: self.depth_operations, + stencil_operations: self.stencil_operations, sample_count: self.sample_count, } } } + +/// Stencil load operation for the stencil attachment. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum StencilLoadOp { + /// Load existing stencil value. + Load, + /// Clear stencil to the provided value. + Clear(u32), +} + +/// Stencil operations for the first stencil attachment. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct StencilOperations { + pub load: StencilLoadOp, + pub store: StoreOp, +} + +impl Default for StencilOperations { + fn default() -> Self { + return Self { + load: StencilLoadOp::Clear(0), + store: StoreOp::Store, + }; + } +} diff --git a/docs/specs/depth-stencil-msaa.md b/docs/specs/depth-stencil-msaa.md index 625de7b3..41630877 100644 --- a/docs/specs/depth-stencil-msaa.md +++ b/docs/specs/depth-stencil-msaa.md @@ -3,13 +3,13 @@ title: "Depth/Stencil and Multi-Sample Rendering" document_id: "depth-stencil-msaa-2025-11-11" status: "draft" created: "2025-11-11T00:00:00Z" -last_updated: "2025-11-11T00:10:00Z" -version: "0.1.1" +last_updated: "2025-11-13T00:00:00Z" +version: "0.1.4" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "1ec667d422611875a86888dd7562117c14072bbb" +repo_commit: "21d0a5b511144db31f10ee07b2efb640ca990daf" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "rendering", "depth", "stencil", "msaa"] @@ -67,34 +67,34 @@ App Code - Types (engine-level) - `enum DepthFormat { Depth32Float, Depth24Plus, Depth24PlusStencil8 }` - `enum CompareFunction { Never, Less, LessEqual, Greater, GreaterEqual, Equal, NotEqual, Always }` - - `struct DepthStencil { format: DepthFormat, clear_value: f32, write: bool, compare: CompareFunction, stencil: Option }` - - `struct StencilState { read_mask: u32, write_mask: u32, reference: u32 }` (placeholder; operations MAY be extended in a follow-up) - `struct MultiSample { sample_count: u32 }` (MUST be >= 1 and supported) + - Stencil per-face state and operations exist at the platform layer and are exposed through `RenderPipelineBuilder::with_stencil(...)`. - Builders (selected functions) - - `RenderPassBuilder::with_clear_color(Color) -> Self` - - `RenderPassBuilder::with_depth_stencil(DepthStencil) -> Self` - - `RenderPassBuilder::with_multi_sample(MultiSample) -> Self` + - `RenderPassBuilder::with_clear_color([f64; 4]) -> Self` + - `RenderPassBuilder::with_depth() -> Self` + - `RenderPassBuilder::with_depth_clear(f64) -> Self` + - `RenderPassBuilder::with_stencil() -> Self` + - `RenderPassBuilder::with_stencil_clear(u32) -> Self` + - `RenderPassBuilder::with_multi_sample(u32) -> Self` - `RenderPipelineBuilder::with_depth_format(DepthFormat) -> Self` - - `RenderPipelineBuilder::with_multi_sample(MultiSample) -> Self` + - `RenderPipelineBuilder::with_depth_compare(CompareFunction) -> Self` + - `RenderPipelineBuilder::with_depth_write(bool) -> Self` + - `RenderPipelineBuilder::with_stencil(StencilState) -> Self` + - `RenderPipelineBuilder::with_multi_sample(u32) -> Self` - Example (engine types only) ```rust use lambda_rs::render::{Color, DepthFormat, CompareFunction, DepthStencil, MultiSample}; let pass = RenderPassBuilder::new() - .with_clear_color(Color::BLACK) - .with_depth_stencil(DepthStencil { - format: DepthFormat::Depth32Float, - clear_value: 1.0, - write: true, - compare: CompareFunction::Less, - stencil: None, - }) - .with_multi_sample(MultiSample { sample_count: 4 }) + .with_clear_color([0.0, 0.0, 0.0, 1.0]) + .with_depth_clear(1.0) + .with_multi_sample(4) .build(&render_context)?; let pipeline = RenderPipelineBuilder::new() - .with_multi_sample(MultiSample { sample_count: 4 }) + .with_multi_sample(4) .with_depth_format(DepthFormat::Depth32Float) + .with_depth_compare(CompareFunction::Less) .build(&mut render_context, &pass, &vertex_shader, Some(&fragment_shader))?; ``` - Behavior @@ -105,11 +105,15 @@ App Code - `DepthStencil.compare` defaults to `CompareFunction::Less`. - `MultiSample.sample_count` defaults to `1` (no multi-sampling). - Attachment creation - - When `with_depth_stencil` is provided, the pass MUST create a depth (and - stencil, if the format includes stencil) attachment matching `format`. - - The pass MUST clear the depth aspect to `clear_value` at the start of the - pass. Stencil clear behavior is unspecified in this version and MAY be - added when extended stencil operations are introduced. + - When depth is requested (`with_depth`/`with_depth_clear`), the pass MUST + create a depth attachment. When stencil operations are requested on the + pass (`with_stencil`/`with_stencil_clear`), the pass MUST attach a + depth/stencil view and the depth format MUST include a stencil aspect. + - If stencil is requested but the current depth format lacks a stencil + aspect, the engine upgrades to `Depth24PlusStencil8` at pass build time + or during encoding and logs an error. + - The pass MUST clear the depth aspect to `1.0` by default (or the provided + value) and clear/load stencil according to the requested ops. - Multi-sample semantics - When `sample_count > 1`, the pass MUST render into a multi-sampled color target and resolve to the single-sample swap chain target before present. @@ -118,7 +122,9 @@ App Code to the pass sample count and logs an error. - Matching constraints - If a pipeline declares a depth format, it MUST equal the pass depth - attachment format. Mismatches are errors at build time. + attachment format. Mismatches are errors at build time. When a pipeline + enables stencil, the engine upgrades its depth format to + `Depth24PlusStencil8` to guarantee compatibility. ## Validation and Errors @@ -158,25 +164,31 @@ App Code ## Requirements Checklist - Functionality - - [ ] Feature flags defined (if applicable) - - [ ] Core behavior implemented - - [ ] Edge cases handled (unsupported sample counts, format mismatch, range checks) + - [x] Depth testing: enable/disable, clear, compare; depth write toggle + (engine: `RenderPipelineBuilder::with_depth`, `.with_depth_clear`, + `.with_depth_compare`, `.with_depth_write`) + - [x] Stencil: clear/load/store, per-face ops, read/write mask, reference + (platform stencil state; pass-level ops + `SetStencilReference`) + - [x] MSAA: sample count selection, resolve path, depth sample matching + - [x] Format selection: `Depth32Float`, `Depth24Plus`, `Depth24PlusStencil8` + - [x] Edge cases: invalid sample counts (clamp/log), pass/pipeline sample + mismatches (align/log); stencil implies stencil-capable format (upgrade) - API Surface - - [ ] Public types and builders implemented - - [ ] Commands/entry points exposed - - [ ] Backwards compatibility assessed + - [x] RenderPassBuilder: color ops, depth ops, stencil ops, MSAA + - [x] RenderPipelineBuilder: depth format/compare, stencil state, depth write, + MSAA + - [x] Commands: set stencil reference; existing draw/bind/viewport remain - Validation and Errors - - [ ] Input validation implemented - - [ ] Device/limit checks implemented - - [ ] Error reporting specified and implemented + - [ ] Sample counts limited to {1,2,4,8}; invalid → log + clamp to 1 + - [ ] Pass/pipeline sample mismatch → align to pass + log + - [ ] Depth clear in [0.0, 1.0] (SHOULD validate); device support (SHOULD) - Performance - - [ ] Critical paths profiled or reasoned - - [ ] Memory usage characterized - - [ ] Recommendations documented + - [ ] 4x MSAA guidance; memory trade-offs for `Depth32Float` vs `Depth24Plus` + - [ ] Recommend disabling depth writes for overlays/transparency - Documentation and Examples - - [ ] User-facing docs updated - - [ ] Minimal example(s) added/updated - - [ ] Migration notes (if applicable) + - [ ] Minimal MSAA + depth example + - [ ] Reflective mirror (stencil) tutorial + - [ ] Migration notes (none; additive API) For each checked item, include a reference to a commit, pull request, or file path that demonstrates the implementation. From 518cf943e6c4ef229ca0db026b4e94320a33d7b9 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 13 Nov 2025 16:24:13 -0800 Subject: [PATCH 05/25] [fix] stencil operations being exposed by lambda-rs. --- crates/lambda-rs/examples/reflective_room.rs | 716 +++++++++++++++++++ crates/lambda-rs/src/render/pipeline.rs | 87 ++- 2 files changed, 791 insertions(+), 12 deletions(-) create mode 100644 crates/lambda-rs/examples/reflective_room.rs diff --git a/crates/lambda-rs/examples/reflective_room.rs b/crates/lambda-rs/examples/reflective_room.rs new file mode 100644 index 00000000..533323cb --- /dev/null +++ b/crates/lambda-rs/examples/reflective_room.rs @@ -0,0 +1,716 @@ +#![allow(clippy::needless_return)] + +//! Example: Reflective floor using the stencil buffer with MSAA. +//! +//! - Phase 1: Write a stencil mask where the floor geometry exists. Disable +//! depth writes and omit the fragment stage so no color is produced. +//! - Phase 2: Render a mirrored (reflected) cube only where stencil == 1. +//! Disable culling to avoid backface issues due to the mirrored transform. +//! - Phase 3 (optional visual): Draw the floor surface with alpha so the +//! reflection appears as if seen in a mirror. +//! - Phase 4: Render the normal, unreflected cube above the floor. +//! +//! The pass enables depth testing/clears and 4x MSAA for smoother edges. + +use lambda::{ + component::Component, + logging, + math::matrix::Matrix, + render::{ + buffer::BufferBuilder, + command::RenderCommand, + mesh::{ + Mesh, + MeshBuilder, + }, + pipeline::{ + CompareFunction, + CullingMode, + PipelineStage, + RenderPipelineBuilder, + StencilFaceState, + StencilOperation, + StencilState, + }, + render_pass::RenderPassBuilder, + scene_math::{ + compute_perspective_projection, + compute_view_matrix, + SimpleCamera, + }, + shader::{ + Shader, + ShaderBuilder, + ShaderKind, + VirtualShader, + }, + vertex::{ + ColorFormat, + Vertex, + VertexAttribute, + VertexBuilder, + VertexElement, + }, + viewport::ViewportBuilder, + ResourceId, + }, + runtime::start_runtime, + runtimes::{ + application::ComponentResult, + ApplicationRuntime, + ApplicationRuntimeBuilder, + }, +}; +use lambda_platform::wgpu::texture::DepthFormat; + +// ------------------------------ 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 = 0) out vec3 v_world_normal; + +layout ( push_constant ) uniform Push { + mat4 mvp; + mat4 model; +} pc; + +void main() { + gl_Position = pc.mvp * vec4(vertex_position, 1.0); + // Rotate normals into world space using the model matrix (no scale/shear needed for this demo). + v_world_normal = mat3(pc.model) * vertex_normal; +} +"#; + +const FRAGMENT_LIT_COLOR_SOURCE: &str = r#" +#version 450 + +layout (location = 0) in vec3 v_world_normal; +layout (location = 0) out vec4 fragment_color; + +void main() { + vec3 N = normalize(v_world_normal); + vec3 L = normalize(vec3(0.4, 0.7, 1.0)); + float diff = max(dot(N, L), 0.0); + vec3 base = vec3(0.2, 0.6, 0.9); + vec3 color = base * (0.25 + 0.75 * diff); + fragment_color = vec4(color, 1.0); +} +"#; + +const FRAGMENT_FLOOR_TINT_SOURCE: &str = r#" +#version 450 + +layout (location = 0) out vec4 fragment_color; + +void main() { + // Slightly tint with alpha so the reflection appears through the floor. + fragment_color = vec4(0.1, 0.1, 0.12, 0.5); +} +"#; + +// ------------------------------ PUSH CONSTANTS ------------------------------- + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct PushConstant { + mvp: [[f32; 4]; 4], + model: [[f32; 4]; 4], +} + +pub fn push_constants_to_words(push_constants: &PushConstant) -> &[u32] { + unsafe { + let size_in_bytes = std::mem::size_of::(); + let size_in_u32 = size_in_bytes / std::mem::size_of::(); + let ptr = push_constants as *const PushConstant as *const u32; + return std::slice::from_raw_parts(ptr, size_in_u32); + } +} + +// --------------------------------- COMPONENT --------------------------------- + +pub struct ReflectiveRoomExample { + shader_vs: Shader, + shader_fs_lit: Shader, + shader_fs_floor: Shader, + cube_mesh: Option, + floor_mesh: Option, + pass_id: Option, + pipe_floor_mask: Option, + pipe_reflected: Option, + pipe_floor_visual: Option, + pipe_normal: Option, + width: u32, + height: u32, + elapsed: f32, +} + +impl Component for ReflectiveRoomExample { + fn on_attach( + &mut self, + render_context: &mut lambda::render::RenderContext, + ) -> Result { + logging::info!("Attaching ReflectiveRoomExample"); + + // Render pass: depth clear, stencil clear, MSAA 4x. + let render_pass = RenderPassBuilder::new() + .with_label("reflective-room-pass") + .with_depth_clear(1.0) + .with_stencil_clear(0) + .with_multi_sample(4) + .build(render_context); + + // Shaders + let mut shader_builder = ShaderBuilder::new(); + let shader_vs = shader_builder.build(VirtualShader::Source { + source: VERTEX_SHADER_SOURCE.to_string(), + kind: ShaderKind::Vertex, + entry_point: "main".to_string(), + name: "reflective-room-vs".to_string(), + }); + let shader_fs_lit = shader_builder.build(VirtualShader::Source { + source: FRAGMENT_LIT_COLOR_SOURCE.to_string(), + kind: ShaderKind::Fragment, + entry_point: "main".to_string(), + name: "reflective-room-fs-lit".to_string(), + }); + let shader_fs_floor = shader_builder.build(VirtualShader::Source { + source: FRAGMENT_FLOOR_TINT_SOURCE.to_string(), + kind: ShaderKind::Fragment, + entry_point: "main".to_string(), + name: "reflective-room-fs-floor".to_string(), + }); + + // Geometry: cube (unit) and floor quad at y = 0. + let cube_mesh = build_unit_cube_mesh(); + let floor_mesh = build_floor_quad_mesh(5.0); + + let push_constants_size = std::mem::size_of::() as u32; + + // Stencil mask pipeline (vertex-only). Writes stencil=1 where the floor exists. + let pipe_floor_mask = RenderPipelineBuilder::new() + .with_label("floor-mask") + .with_culling(CullingMode::Back) + .with_depth_format(DepthFormat::Depth24PlusStencil8) + .with_depth_write(false) + .with_depth_compare(CompareFunction::Always) + .with_push_constant(PipelineStage::VERTEX, push_constants_size) + .with_buffer( + BufferBuilder::build_from_mesh(&floor_mesh, render_context) + .expect("Failed to create floor vertex buffer"), + floor_mesh.attributes().to_vec(), + ) + .with_stencil(StencilState { + front: StencilFaceState { + compare: CompareFunction::Always, + fail_op: StencilOperation::Keep, + depth_fail_op: StencilOperation::Keep, + pass_op: StencilOperation::Replace, + }, + back: StencilFaceState { + compare: CompareFunction::Always, + fail_op: StencilOperation::Keep, + depth_fail_op: StencilOperation::Keep, + pass_op: StencilOperation::Replace, + }, + read_mask: 0xFF, + write_mask: 0xFF, + }) + .with_multi_sample(4) + .build(render_context, &render_pass, &shader_vs, None); + + // Reflected cube pipeline: stencil test Equal, depth test enabled, no culling. + let pipe_reflected = RenderPipelineBuilder::new() + .with_label("reflected-cube") + .with_culling(CullingMode::None) + .with_depth_format(DepthFormat::Depth24PlusStencil8) + .with_depth_write(true) + .with_depth_compare(CompareFunction::LessEqual) + .with_push_constant(PipelineStage::VERTEX, push_constants_size) + .with_buffer( + BufferBuilder::build_from_mesh(&cube_mesh, render_context) + .expect("Failed to create cube vertex buffer"), + cube_mesh.attributes().to_vec(), + ) + .with_stencil(StencilState { + front: StencilFaceState { + compare: CompareFunction::Equal, + fail_op: StencilOperation::Keep, + depth_fail_op: StencilOperation::Keep, + pass_op: StencilOperation::Keep, + }, + back: StencilFaceState { + compare: CompareFunction::Equal, + fail_op: StencilOperation::Keep, + depth_fail_op: StencilOperation::Keep, + pass_op: StencilOperation::Keep, + }, + read_mask: 0xFF, + write_mask: 0x00, + }) + .with_multi_sample(4) + .build( + render_context, + &render_pass, + &shader_vs, + Some(&shader_fs_lit), + ); + + // Floor visual pipeline: draw a tinted surface above the reflection. + let pipe_floor_visual = RenderPipelineBuilder::new() + .with_label("floor-visual") + .with_culling(CullingMode::Back) + .with_depth_format(DepthFormat::Depth24PlusStencil8) + .with_depth_write(false) + .with_depth_compare(CompareFunction::LessEqual) + .with_push_constant(PipelineStage::VERTEX, push_constants_size) + .with_buffer( + BufferBuilder::build_from_mesh(&floor_mesh, render_context) + .expect("Failed to create floor vertex buffer"), + floor_mesh.attributes().to_vec(), + ) + .with_multi_sample(4) + .build( + render_context, + &render_pass, + &shader_vs, + Some(&shader_fs_floor), + ); + + // Normal (unreflected) cube pipeline: standard depth test. + let pipe_normal = RenderPipelineBuilder::new() + .with_label("cube-normal") + .with_culling(CullingMode::Back) + .with_depth_format(DepthFormat::Depth24PlusStencil8) + .with_depth_write(true) + .with_depth_compare(CompareFunction::Less) + .with_push_constant(PipelineStage::VERTEX, push_constants_size) + .with_buffer( + BufferBuilder::build_from_mesh(&cube_mesh, render_context) + .expect("Failed to create cube vertex buffer"), + cube_mesh.attributes().to_vec(), + ) + .with_multi_sample(4) + .build( + render_context, + &render_pass, + &shader_vs, + Some(&shader_fs_lit), + ); + + self.pass_id = Some(render_context.attach_render_pass(render_pass)); + self.pipe_floor_mask = + Some(render_context.attach_pipeline(pipe_floor_mask)); + self.pipe_reflected = Some(render_context.attach_pipeline(pipe_reflected)); + self.pipe_floor_visual = + Some(render_context.attach_pipeline(pipe_floor_visual)); + self.pipe_normal = Some(render_context.attach_pipeline(pipe_normal)); + self.cube_mesh = Some(cube_mesh); + self.floor_mesh = Some(floor_mesh); + self.shader_vs = shader_vs; + self.shader_fs_lit = shader_fs_lit; + self.shader_fs_floor = shader_fs_floor; + + return Ok(ComponentResult::Success); + } + + fn on_detach( + &mut self, + _render_context: &mut lambda::render::RenderContext, + ) -> Result { + return Ok(ComponentResult::Success); + } + + fn on_event( + &mut self, + event: lambda::events::Events, + ) -> Result { + match event { + lambda::events::Events::Window { event, .. } => match event { + lambda::events::WindowEvent::Resize { width, height } => { + self.width = width; + self.height = height; + } + _ => {} + }, + _ => {} + } + return Ok(ComponentResult::Success); + } + + fn on_update( + &mut self, + last_frame: &std::time::Duration, + ) -> Result { + self.elapsed += last_frame.as_secs_f32(); + return Ok(ComponentResult::Success); + } + + fn on_render( + &mut self, + _render_context: &mut lambda::render::RenderContext, + ) -> Vec { + // Camera + let camera = SimpleCamera { + position: [0.0, 1.2, 3.5], + field_of_view_in_turns: 0.24, + near_clipping_plane: 0.1, + far_clipping_plane: 100.0, + }; + + // Cube animation + let angle_y_turns = 0.12 * self.elapsed; + let mut model: [[f32; 4]; 4] = lambda::math::matrix::identity_matrix(4, 4); + model = lambda::math::matrix::rotate_matrix( + model, + [0.0, 1.0, 0.0], + angle_y_turns, + ); + // Translate cube upward by 0.5 on Y + let t_up: [[f32; 4]; 4] = + lambda::math::matrix::translation_matrix([0.0, 0.5, 0.0]); + model = model.multiply(&t_up); + + let view = compute_view_matrix(camera.position); + let projection = compute_perspective_projection( + camera.field_of_view_in_turns, + self.width.max(1), + self.height.max(1), + camera.near_clipping_plane, + camera.far_clipping_plane, + ); + let mvp = projection.multiply(&view).multiply(&model); + + // Reflected model: mirror across the floor (y=0) by scaling Y by -1. + let mut model_reflect: [[f32; 4]; 4] = + lambda::math::matrix::identity_matrix(4, 4); + // Mirror across the floor plane by scaling Y by -1. + let s_mirror: [[f32; 4]; 4] = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, -1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + model_reflect = model_reflect.multiply(&s_mirror); + // Apply the same rotation and translation as the normal cube but mirrored. + model_reflect = lambda::math::matrix::rotate_matrix( + model_reflect, + [0.0, 1.0, 0.0], + angle_y_turns, + ); + let t_down: [[f32; 4]; 4] = + lambda::math::matrix::translation_matrix([0.0, -0.5, 0.0]); + model_reflect = model_reflect.multiply(&t_down); + let mvp_reflect = projection.multiply(&view).multiply(&model_reflect); + + // Floor model: at y = 0 plane + let mut model_floor: [[f32; 4]; 4] = + lambda::math::matrix::identity_matrix(4, 4); + let mvp_floor = projection.multiply(&view).multiply(&model_floor); + + let viewport = ViewportBuilder::new().build(self.width, self.height); + + let pass_id = self.pass_id.expect("render pass not set"); + let pipe_floor_mask = self.pipe_floor_mask.expect("floor mask pipeline"); + let pipe_reflected = self.pipe_reflected.expect("reflected pipeline"); + let pipe_floor_visual = + self.pipe_floor_visual.expect("floor visual pipeline"); + let pipe_normal = self.pipe_normal.expect("normal pipeline"); + + return vec![ + RenderCommand::BeginRenderPass { + render_pass: pass_id, + viewport: viewport.clone(), + }, + // Phase 1: write stencil where the floor geometry exists (stencil = 1). + RenderCommand::SetPipeline { + pipeline: pipe_floor_mask, + }, + RenderCommand::SetViewports { + start_at: 0, + viewports: vec![viewport.clone()], + }, + RenderCommand::SetScissors { + start_at: 0, + viewports: vec![viewport.clone()], + }, + RenderCommand::SetStencilReference { reference: 1 }, + RenderCommand::BindVertexBuffer { + pipeline: pipe_floor_mask, + buffer: 0, + }, + RenderCommand::PushConstants { + pipeline: pipe_floor_mask, + stage: PipelineStage::VERTEX, + offset: 0, + bytes: Vec::from(push_constants_to_words(&PushConstant { + mvp: mvp_floor.transpose(), + model: model_floor.transpose(), + })), + }, + RenderCommand::Draw { + vertices: 0..self.floor_mesh.as_ref().unwrap().vertices().len() as u32, + }, + // Phase 2: draw the reflected cube where stencil == 1. + RenderCommand::SetPipeline { + pipeline: pipe_reflected, + }, + RenderCommand::SetStencilReference { reference: 1 }, + RenderCommand::BindVertexBuffer { + pipeline: pipe_reflected, + buffer: 0, + }, + RenderCommand::PushConstants { + pipeline: pipe_reflected, + stage: PipelineStage::VERTEX, + offset: 0, + bytes: Vec::from(push_constants_to_words(&PushConstant { + mvp: mvp_reflect.transpose(), + model: model_reflect.transpose(), + })), + }, + RenderCommand::Draw { + vertices: 0..self.cube_mesh.as_ref().unwrap().vertices().len() as u32, + }, + // Phase 3: draw the floor surface tint (optional visual). + RenderCommand::SetPipeline { + pipeline: pipe_floor_visual, + }, + RenderCommand::BindVertexBuffer { + pipeline: pipe_floor_visual, + buffer: 0, + }, + RenderCommand::PushConstants { + pipeline: pipe_floor_visual, + stage: PipelineStage::VERTEX, + offset: 0, + bytes: Vec::from(push_constants_to_words(&PushConstant { + mvp: mvp_floor.transpose(), + model: model_floor.transpose(), + })), + }, + RenderCommand::Draw { + vertices: 0..self.floor_mesh.as_ref().unwrap().vertices().len() as u32, + }, + // Phase 4: draw the normal cube above the floor. + RenderCommand::SetPipeline { + pipeline: pipe_normal, + }, + RenderCommand::BindVertexBuffer { + pipeline: pipe_normal, + buffer: 0, + }, + RenderCommand::PushConstants { + pipeline: pipe_normal, + stage: PipelineStage::VERTEX, + offset: 0, + bytes: Vec::from(push_constants_to_words(&PushConstant { + mvp: mvp.transpose(), + model: model.transpose(), + })), + }, + RenderCommand::Draw { + vertices: 0..self.cube_mesh.as_ref().unwrap().vertices().len() as u32, + }, + RenderCommand::EndRenderPass, + ]; + } +} + +impl Default for ReflectiveRoomExample { + fn default() -> Self { + let mut shader_builder = ShaderBuilder::new(); + let shader_vs = shader_builder.build(VirtualShader::Source { + source: VERTEX_SHADER_SOURCE.to_string(), + kind: ShaderKind::Vertex, + entry_point: "main".to_string(), + name: "reflective-room-vs".to_string(), + }); + let shader_fs_lit = shader_builder.build(VirtualShader::Source { + source: FRAGMENT_LIT_COLOR_SOURCE.to_string(), + kind: ShaderKind::Fragment, + entry_point: "main".to_string(), + name: "reflective-room-fs-lit".to_string(), + }); + let shader_fs_floor = shader_builder.build(VirtualShader::Source { + source: FRAGMENT_FLOOR_TINT_SOURCE.to_string(), + kind: ShaderKind::Fragment, + entry_point: "main".to_string(), + name: "reflective-room-fs-floor".to_string(), + }); + + return Self { + shader_vs, + shader_fs_lit, + shader_fs_floor, + cube_mesh: None, + floor_mesh: None, + pass_id: None, + pipe_floor_mask: None, + pipe_reflected: None, + pipe_floor_visual: None, + pipe_normal: None, + width: 800, + height: 600, + elapsed: 0.0, + }; + } +} + +fn build_unit_cube_mesh() -> Mesh { + let mut verts: Vec = Vec::new(); + let mut add_face = + |nx: f32, ny: f32, nz: f32, corners: [(f32, f32, f32); 4]| { + let n = [nx, ny, nz]; + let v = |p: (f32, f32, f32)| { + return VertexBuilder::new() + .with_position([p.0, p.1, p.2]) + .with_normal(n) + .with_color([0.0, 0.0, 0.0]) + .build(); + }; + // Two triangles per face: (0,1,2) and (0,2,3) + let p0 = v(corners[0]); + let p1 = v(corners[1]); + let p2 = v(corners[2]); + let p3 = v(corners[3]); + verts.push(p0); + verts.push(p1); + verts.push(p2); + verts.push(p0); + verts.push(p2); + verts.push(p3); + }; + let h = 0.5f32; + add_face( + 1.0, + 0.0, + 0.0, + [(h, -h, -h), (h, h, -h), (h, h, h), (h, -h, h)], + ); + add_face( + -1.0, + 0.0, + 0.0, + [(-h, -h, -h), (-h, -h, h), (-h, h, h), (-h, h, -h)], + ); + add_face( + 0.0, + 1.0, + 0.0, + [(-h, h, h), (h, h, h), (h, h, -h), (-h, h, -h)], + ); + add_face( + 0.0, + -1.0, + 0.0, + [(-h, -h, -h), (h, -h, -h), (h, -h, h), (-h, -h, h)], + ); + add_face( + 0.0, + 0.0, + 1.0, + [(-h, -h, h), (h, -h, h), (h, h, h), (-h, h, h)], + ); + add_face( + 0.0, + 0.0, + -1.0, + [(h, -h, -h), (-h, -h, -h), (-h, h, -h), (h, h, -h)], + ); + + let mut mesh_builder = MeshBuilder::new(); + for v in verts.into_iter() { + mesh_builder.with_vertex(v); + } + 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, + }, + }, + ]) + .build(); + return mesh; +} + +fn build_floor_quad_mesh(extent: f32) -> Mesh { + // Large quad on XZ plane at Y=0 + let y = 0.0f32; + let h = extent * 0.5; + let normal = [0.0, 1.0, 0.0]; + let v = |x: f32, z: f32| { + return VertexBuilder::new() + .with_position([x, y, z]) + .with_normal(normal) + .with_color([0.0, 0.0, 0.0]) + .build(); + }; + let p0 = v(-h, -h); + let p1 = v(h, -h); + let p2 = v(h, h); + let p3 = v(-h, h); + + let mut mesh_builder = MeshBuilder::new(); + // Tri 1 + mesh_builder.with_vertex(p0); + mesh_builder.with_vertex(p1); + mesh_builder.with_vertex(p2); + // Tri 2 + mesh_builder.with_vertex(p0); + mesh_builder.with_vertex(p2); + mesh_builder.with_vertex(p3); + + 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, + }, + }, + ]) + .build(); + return mesh; +} + +fn main() { + let runtime: ApplicationRuntime = + ApplicationRuntimeBuilder::new("Reflective Room Example") + .with_window_configured_as(|builder| { + builder + .with_dimensions(960, 600) + .with_name("Reflective Room") + }) + .with_component(|runtime, example: ReflectiveRoomExample| { + (runtime, example) + }) + .build(); + + start_runtime(runtime); +} diff --git a/crates/lambda-rs/src/render/pipeline.rs b/crates/lambda-rs/src/render/pipeline.rs index 2458d19d..b7c5ba6d 100644 --- a/crates/lambda-rs/src/render/pipeline.rs +++ b/crates/lambda-rs/src/render/pipeline.rs @@ -87,14 +87,74 @@ struct BufferBinding { attributes: Vec, } +pub use platform_pipeline::CompareFunction; /// Public alias for platform culling mode used by pipeline builders. pub use platform_pipeline::CullingMode; -pub use platform_pipeline::{ - CompareFunction, - StencilFaceState as PlatformStencilFaceState, - StencilOperation as PlatformStencilOperation, - StencilState as PlatformStencilState, -}; + +/// Engine-level stencil operation. +#[derive(Clone, Copy, Debug)] +pub enum StencilOperation { + Keep, + Zero, + Replace, + Invert, + IncrementClamp, + DecrementClamp, + IncrementWrap, + DecrementWrap, +} + +impl StencilOperation { + fn to_platform(self) -> platform_pipeline::StencilOperation { + match self { + StencilOperation::Keep => platform_pipeline::StencilOperation::Keep, + StencilOperation::Zero => platform_pipeline::StencilOperation::Zero, + StencilOperation::Replace => platform_pipeline::StencilOperation::Replace, + StencilOperation::Invert => platform_pipeline::StencilOperation::Invert, + StencilOperation::IncrementClamp => { + platform_pipeline::StencilOperation::IncrementClamp + } + StencilOperation::DecrementClamp => { + platform_pipeline::StencilOperation::DecrementClamp + } + StencilOperation::IncrementWrap => { + platform_pipeline::StencilOperation::IncrementWrap + } + StencilOperation::DecrementWrap => { + platform_pipeline::StencilOperation::DecrementWrap + } + } + } +} + +/// Engine-level per-face stencil state. +#[derive(Clone, Copy, Debug)] +pub struct StencilFaceState { + pub compare: CompareFunction, + pub fail_op: StencilOperation, + pub depth_fail_op: StencilOperation, + pub pass_op: StencilOperation, +} + +impl StencilFaceState { + fn to_platform(self) -> platform_pipeline::StencilFaceState { + platform_pipeline::StencilFaceState { + compare: self.compare, + fail_op: self.fail_op.to_platform(), + depth_fail_op: self.depth_fail_op.to_platform(), + pass_op: self.pass_op.to_platform(), + } + } +} + +/// Engine-level full stencil state. +#[derive(Clone, Copy, Debug)] +pub struct StencilState { + pub front: StencilFaceState, + pub back: StencilFaceState, + pub read_mask: u32, + pub write_mask: u32, +} /// Builder for creating a graphics `RenderPipeline`. /// @@ -215,12 +275,15 @@ impl RenderPipelineBuilder { return self; } - /// Configure stencil state for the pipeline. - pub fn with_stencil( - mut self, - stencil: platform_pipeline::StencilState, - ) -> Self { - self.stencil = Some(stencil); + /// Configure stencil state for the pipeline using engine types. + pub fn with_stencil(mut self, stencil: StencilState) -> Self { + let mapped = platform_pipeline::StencilState { + front: stencil.front.to_platform(), + back: stencil.back.to_platform(), + read_mask: stencil.read_mask, + write_mask: stencil.write_mask, + }; + self.stencil = Some(mapped); return self; } From f566aa7f24c9b370e2a3f83a24691fa49ca67873 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 13 Nov 2025 16:35:40 -0800 Subject: [PATCH 06/25] [fix] windowing issue on macos. --- crates/lambda-rs-platform/src/winit/mod.rs | 24 +++++++++------------- crates/lambda-rs/src/render/window.rs | 9 -------- 2 files changed, 10 insertions(+), 23 deletions(-) diff --git a/crates/lambda-rs-platform/src/winit/mod.rs b/crates/lambda-rs-platform/src/winit/mod.rs index 853eeb57..5e7d7dc9 100644 --- a/crates/lambda-rs-platform/src/winit/mod.rs +++ b/crates/lambda-rs-platform/src/winit/mod.rs @@ -69,7 +69,6 @@ pub struct Loop { pub struct WindowProperties { pub name: String, pub dimensions: (u32, u32), - pub monitor_handle: MonitorHandle, } /// Metadata for Lambda window sizing that supports Copy and Move operations. @@ -85,7 +84,7 @@ pub struct WindowSize { pub struct WindowHandle { pub window_handle: Window, pub size: WindowSize, - pub monitor_handle: MonitorHandle, + pub monitor_handle: Option, } // Should we take the loop as a field right here? Probably a ref or something? IDK @@ -143,22 +142,21 @@ impl WindowHandleBuilder { window_properties: WindowProperties, lambda_loop: &Loop, ) -> Self { - let WindowProperties { - name, - dimensions, - monitor_handle, - } = window_properties; + let WindowProperties { name, dimensions } = window_properties; - // TODO(ahlawat) = Find out if there's a better way to do this. Looks kinda ugly. - self = self.with_window_size(dimensions, monitor_handle.scale_factor()); + // Initialize using a neutral scale factor; recompute after creating the window. + self = self.with_window_size(dimensions, 1.0); - let window_handle = WindowBuilder::new() + let window_handle: Window = WindowBuilder::new() .with_title(name) .with_inner_size(self.size.logical) .build(&lambda_loop.event_loop) .expect("Failed creation of window handle"); - self.monitor_handle = Some(monitor_handle); + // Recompute size using the actual window scale factor and cache current monitor if available. + let scale_factor = window_handle.scale_factor(); + self = self.with_window_size(dimensions, scale_factor); + self.monitor_handle = window_handle.current_monitor(); self.window_handle = Some(window_handle); return self; } @@ -166,9 +164,7 @@ impl WindowHandleBuilder { /// Build the WindowHandle pub fn build(self) -> WindowHandle { return WindowHandle { - monitor_handle: self - .monitor_handle - .expect("Unable to find a MonitorHandle."), + monitor_handle: self.monitor_handle, size: self.size, window_handle: self.window_handle.expect("Unable to find WindowHandle."), }; diff --git a/crates/lambda-rs/src/render/window.rs b/crates/lambda-rs/src/render/window.rs index c0e37dbf..b0aba324 100644 --- a/crates/lambda-rs/src/render/window.rs +++ b/crates/lambda-rs/src/render/window.rs @@ -73,18 +73,9 @@ impl Window { dimensions: (u32, u32), event_loop: &mut Loop, ) -> Self { - // Attempt to get the primary monitor first and then falls back to the first - // available monitor if that isn't found. - let monitor_handle = event_loop.get_primary_monitor().unwrap_or( - event_loop - .get_any_available_monitors() - .expect("No monitors available"), - ); - let window_properties = WindowProperties { name: name.to_string(), dimensions, - monitor_handle, }; let window_handle = WindowHandleBuilder::new() From 668f72bed7d5908e61f0c273f96d9ae4ca4d8551 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 14 Nov 2025 13:28:41 -0800 Subject: [PATCH 07/25] [add] the ability for warnings to only be logged once (Instead of per frame) and update reflective room example. --- .../lambda-rs-platform/src/wgpu/pipeline.rs | 4 + crates/lambda-rs/examples/reflective_room.rs | 57 +++++--- crates/lambda-rs/src/lib.rs | 1 + crates/lambda-rs/src/render/command.rs | 4 +- crates/lambda-rs/src/render/mod.rs | 130 ++++++++++++++---- crates/lambda-rs/src/render/pipeline.rs | 22 +++ crates/lambda-rs/src/render/render_pass.rs | 36 ++++- crates/lambda-rs/src/util/mod.rs | 41 ++++++ 8 files changed, 251 insertions(+), 44 deletions(-) create mode 100644 crates/lambda-rs/src/util/mod.rs diff --git a/crates/lambda-rs-platform/src/wgpu/pipeline.rs b/crates/lambda-rs-platform/src/wgpu/pipeline.rs index 8b99abba..c0a99f90 100644 --- a/crates/lambda-rs-platform/src/wgpu/pipeline.rs +++ b/crates/lambda-rs-platform/src/wgpu/pipeline.rs @@ -291,6 +291,10 @@ impl RenderPipeline { pub(crate) fn into_raw(self) -> wgpu::RenderPipeline { return self.raw; } + /// Pipeline label if provided. + pub fn label(&self) -> Option<&str> { + return self.label.as_deref(); + } } /// Builder for creating a graphics render pipeline. diff --git a/crates/lambda-rs/examples/reflective_room.rs b/crates/lambda-rs/examples/reflective_room.rs index 533323cb..229a773f 100644 --- a/crates/lambda-rs/examples/reflective_room.rs +++ b/crates/lambda-rs/examples/reflective_room.rs @@ -2,8 +2,8 @@ //! Example: Reflective floor using the stencil buffer with MSAA. //! -//! - Phase 1: Write a stencil mask where the floor geometry exists. Disable -//! depth writes and omit the fragment stage so no color is produced. +//! - Phase 1: Depth/stencil-only pass to write a stencil mask where the floor +//! geometry exists. Depth writes are disabled for the mask. //! - Phase 2: Render a mirrored (reflected) cube only where stencil == 1. //! Disable culling to avoid backface issues due to the mirrored transform. //! - Phase 3 (optional visual): Draw the floor surface with alpha so the @@ -112,6 +112,8 @@ void main() { } "#; +// (No extra fragment shaders needed; the floor mask uses a vertex-only pipeline.) + // ------------------------------ PUSH CONSTANTS ------------------------------- #[repr(C)] @@ -138,7 +140,8 @@ pub struct ReflectiveRoomExample { shader_fs_floor: Shader, cube_mesh: Option, floor_mesh: Option, - pass_id: Option, + pass_id_mask: Option, + pass_id_color: Option, pipe_floor_mask: Option, pipe_reflected: Option, pipe_floor_visual: Option, @@ -155,12 +158,21 @@ impl Component for ReflectiveRoomExample { ) -> Result { logging::info!("Attaching ReflectiveRoomExample"); - // Render pass: depth clear, stencil clear, MSAA 4x. - let render_pass = RenderPassBuilder::new() - .with_label("reflective-room-pass") + // Pass 1 (mask): depth clear, stencil clear, MSAA 4x, without color. + let render_pass_mask = RenderPassBuilder::new() + .with_label("reflective-room-pass-mask") .with_depth_clear(1.0) .with_stencil_clear(0) .with_multi_sample(4) + .without_color() + .build(render_context); + + // Pass 2 (color): color + depth clear, stencil LOAD (use mask), MSAA 4x. + let render_pass_color = RenderPassBuilder::new() + .with_label("reflective-room-pass-color") + .with_depth_clear(1.0) + .with_stencil_load() + .with_multi_sample(4) .build(render_context); // Shaders @@ -183,6 +195,8 @@ impl Component for ReflectiveRoomExample { entry_point: "main".to_string(), name: "reflective-room-fs-floor".to_string(), }); + // Note: the mask pipeline is vertex-only and will be used in a pass + // without color attachments. // Geometry: cube (unit) and floor quad at y = 0. let cube_mesh = build_unit_cube_mesh(); @@ -190,7 +204,7 @@ impl Component for ReflectiveRoomExample { let push_constants_size = std::mem::size_of::() as u32; - // Stencil mask pipeline (vertex-only). Writes stencil=1 where the floor exists. + // Stencil mask pipeline. Writes stencil=1 where the floor exists. let pipe_floor_mask = RenderPipelineBuilder::new() .with_label("floor-mask") .with_culling(CullingMode::Back) @@ -220,7 +234,7 @@ impl Component for ReflectiveRoomExample { write_mask: 0xFF, }) .with_multi_sample(4) - .build(render_context, &render_pass, &shader_vs, None); + .build(render_context, &render_pass_mask, &shader_vs, None); // Reflected cube pipeline: stencil test Equal, depth test enabled, no culling. let pipe_reflected = RenderPipelineBuilder::new() @@ -254,7 +268,7 @@ impl Component for ReflectiveRoomExample { .with_multi_sample(4) .build( render_context, - &render_pass, + &render_pass_color, &shader_vs, Some(&shader_fs_lit), ); @@ -275,7 +289,7 @@ impl Component for ReflectiveRoomExample { .with_multi_sample(4) .build( render_context, - &render_pass, + &render_pass_color, &shader_vs, Some(&shader_fs_floor), ); @@ -296,12 +310,15 @@ impl Component for ReflectiveRoomExample { .with_multi_sample(4) .build( render_context, - &render_pass, + &render_pass_color, &shader_vs, Some(&shader_fs_lit), ); - self.pass_id = Some(render_context.attach_render_pass(render_pass)); + self.pass_id_mask = + Some(render_context.attach_render_pass(render_pass_mask)); + self.pass_id_color = + Some(render_context.attach_render_pass(render_pass_color)); self.pipe_floor_mask = Some(render_context.attach_pipeline(pipe_floor_mask)); self.pipe_reflected = Some(render_context.attach_pipeline(pipe_reflected)); @@ -413,7 +430,8 @@ impl Component for ReflectiveRoomExample { let viewport = ViewportBuilder::new().build(self.width, self.height); - let pass_id = self.pass_id.expect("render pass not set"); + let pass_id_mask = self.pass_id_mask.expect("mask pass not set"); + let pass_id_color = self.pass_id_color.expect("color pass not set"); let pipe_floor_mask = self.pipe_floor_mask.expect("floor mask pipeline"); let pipe_reflected = self.pipe_reflected.expect("reflected pipeline"); let pipe_floor_visual = @@ -421,8 +439,9 @@ impl Component for ReflectiveRoomExample { let pipe_normal = self.pipe_normal.expect("normal pipeline"); return vec![ + // Pass 1: depth/stencil-only to write the floor mask. RenderCommand::BeginRenderPass { - render_pass: pass_id, + render_pass: pass_id_mask, viewport: viewport.clone(), }, // Phase 1: write stencil where the floor geometry exists (stencil = 1). @@ -454,6 +473,12 @@ impl Component for ReflectiveRoomExample { RenderCommand::Draw { vertices: 0..self.floor_mesh.as_ref().unwrap().vertices().len() as u32, }, + RenderCommand::EndRenderPass, + // Pass 2: color + depth, stencil loaded from mask. + RenderCommand::BeginRenderPass { + render_pass: pass_id_color, + viewport: viewport.clone(), + }, // Phase 2: draw the reflected cube where stencil == 1. RenderCommand::SetPipeline { pipeline: pipe_reflected, @@ -475,7 +500,6 @@ impl Component for ReflectiveRoomExample { RenderCommand::Draw { vertices: 0..self.cube_mesh.as_ref().unwrap().vertices().len() as u32, }, - // Phase 3: draw the floor surface tint (optional visual). RenderCommand::SetPipeline { pipeline: pipe_floor_visual, }, @@ -548,7 +572,8 @@ impl Default for ReflectiveRoomExample { shader_fs_floor, cube_mesh: None, floor_mesh: None, - pass_id: None, + pass_id_mask: None, + pass_id_color: None, pipe_floor_mask: None, pipe_reflected: None, pipe_floor_visual: None, diff --git a/crates/lambda-rs/src/lib.rs b/crates/lambda-rs/src/lib.rs index 109eb8f9..6bf6ae89 100644 --- a/crates/lambda-rs/src/lib.rs +++ b/crates/lambda-rs/src/lib.rs @@ -17,6 +17,7 @@ pub mod math; pub mod render; pub mod runtime; pub mod runtimes; +pub mod util; /// The logging module provides a simple logging interface for Lambda /// applications. diff --git a/crates/lambda-rs/src/render/command.rs b/crates/lambda-rs/src/render/command.rs index 2dce775d..df4d12b7 100644 --- a/crates/lambda-rs/src/render/command.rs +++ b/crates/lambda-rs/src/render/command.rs @@ -31,7 +31,9 @@ pub enum RenderCommand { }, /// Bind a previously attached graphics pipeline by id. SetPipeline { pipeline: super::ResourceId }, - /// Begin a render pass that targets the swapchain color attachment. + /// Begin a render pass. When the pass is configured with color attachments, + /// it targets the swapchain view (with optional MSAA resolve). Passes may + /// also omit color to perform depth/stencil-only work. BeginRenderPass { render_pass: super::ResourceId, viewport: Viewport, diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index 36cf1e6d..59b3b190 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -44,6 +44,7 @@ pub mod viewport; pub mod window; use std::{ + collections::HashSet, iter, rc::Rc, }; @@ -56,6 +57,7 @@ use self::{ pipeline::RenderPipeline, render_pass::RenderPass as RenderPassDesc, }; +use crate::util; /// Builder for configuring a `RenderContext` tied to one window. /// @@ -182,6 +184,7 @@ impl RenderContextBuilder { bind_group_layouts: vec![], bind_groups: vec![], buffers: vec![], + seen_error_messages: HashSet::new(), }); } } @@ -221,6 +224,7 @@ pub struct RenderContext { bind_group_layouts: Vec, bind_groups: Vec, buffers: Vec>, + seen_error_messages: HashSet, } /// Opaque handle used to refer to resources attached to a `RenderContext`. @@ -290,7 +294,10 @@ impl RenderContext { } if let Err(err) = self.render_internal(commands) { - logging::error!("Render error: {:?}", err); + let key = format!("{:?}", err); + if self.seen_error_messages.insert(key) { + logging::error!("Render error: {:?}", err); + } } } @@ -422,31 +429,33 @@ impl RenderContext { let mut color_attachments = platform::render_pass::RenderColorAttachments::new(); let sample_count = pass.sample_count(); - if sample_count > 1 { - let need_recreate = match &self.msaa_color { - Some(_) => self.msaa_sample_count != sample_count, - None => true, - }; - if need_recreate { - self.msaa_color = Some( - platform::texture::ColorAttachmentTextureBuilder::new( - self.config.format, - ) - .with_size(self.size.0.max(1), self.size.1.max(1)) - .with_sample_count(sample_count) - .with_label("lambda-msaa-color") - .build(self.gpu()), - ); - self.msaa_sample_count = sample_count; + if pass.uses_color() { + if sample_count > 1 { + let need_recreate = match &self.msaa_color { + Some(_) => self.msaa_sample_count != sample_count, + None => true, + }; + if need_recreate { + self.msaa_color = Some( + platform::texture::ColorAttachmentTextureBuilder::new( + self.config.format, + ) + .with_size(self.size.0.max(1), self.size.1.max(1)) + .with_sample_count(sample_count) + .with_label("lambda-msaa-color") + .build(self.gpu()), + ); + self.msaa_sample_count = sample_count; + } + let msaa_view = self + .msaa_color + .as_ref() + .expect("MSAA color attachment should be created") + .view_ref(); + color_attachments.push_msaa_color(msaa_view, view); + } else { + color_attachments.push_color(view); } - let msaa_view = self - .msaa_color - .as_ref() - .expect("MSAA color attachment should be created") - .view_ref(); - color_attachments.push_msaa_color(msaa_view, view); - } else { - color_attachments.push_color(view); } // Depth/stencil attachment when either depth or stencil requested. @@ -554,7 +563,14 @@ impl RenderContext { stencil_ops, ); - self.encode_pass(&mut pass_encoder, viewport, &mut command_iter)?; + self.encode_pass( + &mut pass_encoder, + pass.uses_color(), + pass.depth_operations().is_some(), + pass.stencil_operations().is_some(), + viewport, + &mut command_iter, + )?; } other => { logging::warn!( @@ -574,6 +590,9 @@ impl RenderContext { fn encode_pass( &self, pass: &mut platform::render_pass::RenderPass<'_>, + uses_color: bool, + pass_has_depth: bool, + pass_has_stencil: bool, initial_viewport: viewport::Viewport, commands: &mut I, ) -> Result<(), RenderError> @@ -581,6 +600,9 @@ impl RenderContext { I: Iterator, { Self::apply_viewport(pass, &initial_viewport); + // De-duplicate advisories within this pass + let mut warned_no_stencil_for_pipeline: HashSet = HashSet::new(); + let mut warned_no_depth_for_pipeline: HashSet = HashSet::new(); while let Some(command) = commands.next() { match command { @@ -595,6 +617,62 @@ impl RenderContext { "Unknown pipeline {pipeline}" )); })?; + // Validate pass/pipeline compatibility before deferring to wgpu. + if !uses_color && pipeline_ref.has_color_targets() { + let label = pipeline_ref.pipeline().label().unwrap_or("unnamed"); + return Err(RenderError::Configuration(format!( + "Render pipeline '{}' declares color targets but the current pass has no color attachments", + label + ))); + } + if uses_color && !pipeline_ref.has_color_targets() { + let label = pipeline_ref.pipeline().label().unwrap_or("unnamed"); + return Err(RenderError::Configuration(format!( + "Render pipeline '{}' has no color targets but the current pass declares color attachments", + label + ))); + } + if !pass_has_depth && pipeline_ref.expects_depth_stencil() { + let label = pipeline_ref.pipeline().label().unwrap_or("unnamed"); + return Err(RenderError::Configuration(format!( + "Render pipeline '{}' expects a depth/stencil attachment but the current pass has none", + label + ))); + } + // Advisory checks to help users reason about stencil/depth behavior. + if pass_has_stencil + && !pipeline_ref.uses_stencil() + && warned_no_stencil_for_pipeline.insert(pipeline) + { + let label = pipeline_ref.pipeline().label().unwrap_or("unnamed"); + let key = format!("stencil:no_test:{}", label); + let msg = format!( + "Pass provides stencil ops but pipeline '{}' has no stencil test; stencil will not affect rendering", + label + ); + util::warn_once(&key, &msg); + } + if !pass_has_stencil && pipeline_ref.uses_stencil() { + let label = pipeline_ref.pipeline().label().unwrap_or("unnamed"); + let key = format!("stencil:pass_no_operations:{}", label); + let msg = format!( + "Pipeline '{}' enables stencil but pass has no stencil ops configured; stencil reference/tests may be ineffective", + label + ); + util::warn_once(&key, &msg); + } + if pass_has_depth + && !pipeline_ref.expects_depth_stencil() + && warned_no_depth_for_pipeline.insert(pipeline) + { + let label = pipeline_ref.pipeline().label().unwrap_or("unnamed"); + let key = format!("depth:no_test:{}", label); + let msg = format!( + "Pass has depth attachment but pipeline '{}' does not enable depth testing; depth values will not be tested/written", + label + ); + util::warn_once(&key, &msg); + } pass.set_pipeline(pipeline_ref.pipeline()); } RenderCommand::SetViewports { viewports, .. } => { diff --git a/crates/lambda-rs/src/render/pipeline.rs b/crates/lambda-rs/src/render/pipeline.rs index b7c5ba6d..f75f13c6 100644 --- a/crates/lambda-rs/src/render/pipeline.rs +++ b/crates/lambda-rs/src/render/pipeline.rs @@ -54,6 +54,9 @@ pub struct RenderPipeline { pipeline: Rc, buffers: Vec>, sample_count: u32, + color_target_count: u32, + expects_depth_stencil: bool, + uses_stencil: bool, } impl RenderPipeline { @@ -74,6 +77,21 @@ impl RenderPipeline { pub fn sample_count(&self) -> u32 { return self.sample_count.max(1); } + + /// Whether the pipeline declares one or more color targets. + pub(super) fn has_color_targets(&self) -> bool { + return self.color_target_count > 0; + } + + /// Whether the pipeline expects a depth-stencil attachment. + pub(super) fn expects_depth_stencil(&self) -> bool { + return self.expects_depth_stencil; + } + + /// Whether the pipeline configured a stencil test/state. + pub(super) fn uses_stencil(&self) -> bool { + return self.uses_stencil; + } } /// Public alias for platform shader stage flags used by push constants. @@ -440,6 +458,10 @@ impl RenderPipelineBuilder { pipeline: Rc::new(pipeline), buffers, sample_count: pipeline_samples, + color_target_count: if fragment_module.is_some() { 1 } else { 0 }, + // Depth/stencil is enabled when `with_depth*` was called on the builder. + expects_depth_stencil: self.use_depth, + uses_stencil: self.stencil.is_some(), }; } } diff --git a/crates/lambda-rs/src/render/render_pass.rs b/crates/lambda-rs/src/render/render_pass.rs index eba6cd50..ba69bd77 100644 --- a/crates/lambda-rs/src/render/render_pass.rs +++ b/crates/lambda-rs/src/render/render_pass.rs @@ -1,7 +1,8 @@ //! Render pass descriptions used to clear and begin drawing. //! //! A `RenderPass` captures immutable parameters used when beginning a pass -//! against the swapchain (currently a single color attachment and clear color). +//! against the swapchain. A pass MAY omit color attachments entirely to +//! perform depth/stencil-only operations (e.g., stencil mask pre-pass). //! The pass is referenced by handle from `RenderCommand::BeginRenderPass`. use logging; @@ -80,6 +81,7 @@ pub struct RenderPass { depth_operations: Option, stencil_operations: Option, sample_count: u32, + use_color: bool, } impl RenderPass { @@ -109,6 +111,11 @@ impl RenderPass { pub(crate) fn stencil_operations(&self) -> Option { return self.stencil_operations; } + + /// Whether this pass declares any color attachments. + pub(crate) fn uses_color(&self) -> bool { + return self.use_color; + } } /// Builder for a `RenderPass` description. @@ -123,6 +130,7 @@ pub struct RenderPassBuilder { depth_operations: Option, stencil_operations: Option, sample_count: u32, + use_color: bool, } impl RenderPassBuilder { @@ -135,6 +143,7 @@ impl RenderPassBuilder { depth_operations: None, stencil_operations: None, sample_count: 1, + use_color: true, }; } @@ -178,6 +187,12 @@ impl RenderPassBuilder { return self; } + /// Disable color attachments for this pass. Depth/stencil MAY still be used. + pub fn without_color(mut self) -> Self { + self.use_color = false; + return self; + } + /// Enable a depth attachment with default clear to 1.0 and store. pub fn with_depth(mut self) -> Self { self.depth_operations = Some(DepthOperations::default()); @@ -193,6 +208,15 @@ impl RenderPassBuilder { return self; } + /// Use a depth attachment and load existing contents (do not clear). + pub fn with_depth_load(mut self) -> Self { + self.depth_operations = Some(DepthOperations { + load: DepthLoadOp::Load, + store: StoreOp::Store, + }); + return self; + } + /// Enable a stencil attachment with default clear to 0 and store. pub fn with_stencil(mut self) -> Self { self.stencil_operations = Some(StencilOperations::default()); @@ -208,6 +232,15 @@ impl RenderPassBuilder { return self; } + /// Use a stencil attachment and load existing contents (do not clear). + pub fn with_stencil_load(mut self) -> Self { + self.stencil_operations = Some(StencilOperations { + load: StencilLoadOp::Load, + store: StoreOp::Store, + }); + return self; + } + /// Configure multi-sample anti-aliasing for this pass. pub fn with_multi_sample(mut self, samples: u32) -> Self { match validation::validate_sample_count(samples) { @@ -234,6 +267,7 @@ impl RenderPassBuilder { depth_operations: self.depth_operations, stencil_operations: self.stencil_operations, sample_count: self.sample_count, + use_color: self.use_color, } } } diff --git a/crates/lambda-rs/src/util/mod.rs b/crates/lambda-rs/src/util/mod.rs new file mode 100644 index 00000000..59a127d6 --- /dev/null +++ b/crates/lambda-rs/src/util/mod.rs @@ -0,0 +1,41 @@ +#![allow(clippy::needless_return)] +//! Utility helpers for the `lambda-rs` crate. +//! +//! This module hosts small, reusable helpers that are not part of the public +//! rendering API surface but are useful across modules (e.g., de-duplicated +//! logging of advisories that would otherwise spam every frame). + +use std::{ + collections::HashSet, + sync::{ + Mutex, + OnceLock, + }, +}; + +/// Global, process-wide de-duplication set for warn-once messages. +static WARN_ONCE_KEYS: OnceLock>> = OnceLock::new(); + +/// Log a warning message at most once per unique `key` across the process. +/// +/// - `key` SHOULD be stable and descriptive (e.g., include a pipeline label or +/// other identifier) so distinct advisories are tracked independently. +/// - If the internal lock is poisoned, the message is still logged to avoid +/// panics. +pub fn warn_once(key: &str, message: &str) { + let set = WARN_ONCE_KEYS.get_or_init(|| { + return Mutex::new(HashSet::new()); + }); + match set.lock() { + Ok(mut guard) => { + if guard.insert(key.to_string()) { + logging::warn!("{}", message); + } + return; + } + Err(_) => { + logging::warn!("{}", message); + return; + } + } +} From 294f37c279d33dfbcf7c1d39fac1cb397235f676 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 14 Nov 2025 13:47:41 -0800 Subject: [PATCH 08/25] [add] high level implementations for comparefunction and culling modes. --- crates/lambda-rs/src/render/pipeline.rs | 69 ++++++++++++++++++++----- 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/crates/lambda-rs/src/render/pipeline.rs b/crates/lambda-rs/src/render/pipeline.rs index f75f13c6..49d7dc9b 100644 --- a/crates/lambda-rs/src/render/pipeline.rs +++ b/crates/lambda-rs/src/render/pipeline.rs @@ -105,9 +105,55 @@ struct BufferBinding { attributes: Vec, } -pub use platform_pipeline::CompareFunction; -/// Public alias for platform culling mode used by pipeline builders. -pub use platform_pipeline::CullingMode; +#[derive(Clone, Copy, Debug)] +/// Engine-level compare function for depth/stencil tests. +pub enum CompareFunction { + Never, + Less, + LessEqual, + Greater, + GreaterEqual, + Equal, + NotEqual, + Always, +} + +impl CompareFunction { + fn to_platform(self) -> platform_pipeline::CompareFunction { + return match self { + CompareFunction::Never => platform_pipeline::CompareFunction::Never, + CompareFunction::Less => platform_pipeline::CompareFunction::Less, + CompareFunction::LessEqual => { + platform_pipeline::CompareFunction::LessEqual + } + CompareFunction::Greater => platform_pipeline::CompareFunction::Greater, + CompareFunction::GreaterEqual => { + platform_pipeline::CompareFunction::GreaterEqual + } + CompareFunction::Equal => platform_pipeline::CompareFunction::Equal, + CompareFunction::NotEqual => platform_pipeline::CompareFunction::NotEqual, + CompareFunction::Always => platform_pipeline::CompareFunction::Always, + }; + } +} + +#[derive(Clone, Copy, Debug)] +/// Engine-level face culling mode for graphics pipelines. +pub enum CullingMode { + None, + Front, + Back, +} + +impl CullingMode { + fn to_platform(self) -> platform_pipeline::CullingMode { + return match self { + CullingMode::None => platform_pipeline::CullingMode::None, + CullingMode::Front => platform_pipeline::CullingMode::Front, + CullingMode::Back => platform_pipeline::CullingMode::Back, + }; + } +} /// Engine-level stencil operation. #[derive(Clone, Copy, Debug)] @@ -124,7 +170,7 @@ pub enum StencilOperation { impl StencilOperation { fn to_platform(self) -> platform_pipeline::StencilOperation { - match self { + return match self { StencilOperation::Keep => platform_pipeline::StencilOperation::Keep, StencilOperation::Zero => platform_pipeline::StencilOperation::Zero, StencilOperation::Replace => platform_pipeline::StencilOperation::Replace, @@ -141,7 +187,7 @@ impl StencilOperation { StencilOperation::DecrementWrap => { platform_pipeline::StencilOperation::DecrementWrap } - } + }; } } @@ -157,7 +203,7 @@ pub struct StencilFaceState { impl StencilFaceState { fn to_platform(self) -> platform_pipeline::StencilFaceState { platform_pipeline::StencilFaceState { - compare: self.compare, + compare: self.compare.to_platform(), fail_op: self.fail_op.to_platform(), depth_fail_op: self.depth_fail_op.to_platform(), pass_op: self.pass_op.to_platform(), @@ -190,7 +236,7 @@ pub struct RenderPipelineBuilder { use_depth: bool, depth_format: Option, sample_count: u32, - depth_compare: Option, + depth_compare: Option, stencil: Option, depth_write_enabled: Option, } @@ -285,10 +331,7 @@ impl RenderPipelineBuilder { } /// Set a non-default depth compare function. - pub fn with_depth_compare( - mut self, - compare: platform_pipeline::CompareFunction, - ) -> Self { + pub fn with_depth_compare(mut self, compare: CompareFunction) -> Self { self.depth_compare = Some(compare); return self; } @@ -379,7 +422,7 @@ impl RenderPipelineBuilder { let mut rp_builder = platform_pipeline::RenderPipelineBuilder::new() .with_label(self.label.as_deref().unwrap_or("lambda-render-pipeline")) .with_layout(&pipeline_layout) - .with_cull_mode(self.culling); + .with_cull_mode(self.culling.to_platform()); for binding in &self.bindings { let attributes: Vec = binding @@ -419,7 +462,7 @@ impl RenderPipelineBuilder { render_context.depth_format = dfmt; rp_builder = rp_builder.with_depth_stencil(dfmt); if let Some(compare) = self.depth_compare { - rp_builder = rp_builder.with_depth_compare(compare); + rp_builder = rp_builder.with_depth_compare(compare.to_platform()); } if let Some(stencil) = self.stencil { rp_builder = rp_builder.with_stencil(stencil); From 4172822c609927895d452bfda69e350c7d1b92d3 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 14 Nov 2025 13:57:54 -0800 Subject: [PATCH 09/25] [update] depth/stencil/msaa validation so that it only happens for debug builds. --- crates/lambda-rs/src/render/mod.rs | 115 +++++++++++---------- crates/lambda-rs/src/render/pipeline.rs | 57 ++++++---- crates/lambda-rs/src/render/render_pass.rs | 29 ++++-- 3 files changed, 117 insertions(+), 84 deletions(-) diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index 59b3b190..8c4d93e0 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -471,6 +471,7 @@ impl RenderContext { && self.depth_format != platform::texture::DepthFormat::Depth24PlusStencil8 { + #[cfg(debug_assertions)] logging::error!( "Render pass has stencil ops but depth format {:?} lacks stencil; upgrading to Depth24PlusStencil8", self.depth_format @@ -600,8 +601,10 @@ impl RenderContext { I: Iterator, { Self::apply_viewport(pass, &initial_viewport); - // De-duplicate advisories within this pass + // De-duplicate advisories within this pass (debug builds only) + #[cfg(debug_assertions)] let mut warned_no_stencil_for_pipeline: HashSet = HashSet::new(); + #[cfg(debug_assertions)] let mut warned_no_depth_for_pipeline: HashSet = HashSet::new(); while let Some(command) = commands.next() { @@ -617,61 +620,67 @@ impl RenderContext { "Unknown pipeline {pipeline}" )); })?; - // Validate pass/pipeline compatibility before deferring to wgpu. - if !uses_color && pipeline_ref.has_color_targets() { - let label = pipeline_ref.pipeline().label().unwrap_or("unnamed"); - return Err(RenderError::Configuration(format!( - "Render pipeline '{}' declares color targets but the current pass has no color attachments", - label - ))); - } - if uses_color && !pipeline_ref.has_color_targets() { - let label = pipeline_ref.pipeline().label().unwrap_or("unnamed"); - return Err(RenderError::Configuration(format!( - "Render pipeline '{}' has no color targets but the current pass declares color attachments", - label - ))); - } - if !pass_has_depth && pipeline_ref.expects_depth_stencil() { - let label = pipeline_ref.pipeline().label().unwrap_or("unnamed"); - return Err(RenderError::Configuration(format!( - "Render pipeline '{}' expects a depth/stencil attachment but the current pass has none", - label - ))); - } - // Advisory checks to help users reason about stencil/depth behavior. - if pass_has_stencil - && !pipeline_ref.uses_stencil() - && warned_no_stencil_for_pipeline.insert(pipeline) + // Validate pass/pipeline compatibility before deferring to the platform (debug only). + #[cfg(debug_assertions)] { - let label = pipeline_ref.pipeline().label().unwrap_or("unnamed"); - let key = format!("stencil:no_test:{}", label); - let msg = format!( - "Pass provides stencil ops but pipeline '{}' has no stencil test; stencil will not affect rendering", - label - ); - util::warn_once(&key, &msg); - } - if !pass_has_stencil && pipeline_ref.uses_stencil() { - let label = pipeline_ref.pipeline().label().unwrap_or("unnamed"); - let key = format!("stencil:pass_no_operations:{}", label); - let msg = format!( - "Pipeline '{}' enables stencil but pass has no stencil ops configured; stencil reference/tests may be ineffective", - label - ); - util::warn_once(&key, &msg); + if !uses_color && pipeline_ref.has_color_targets() { + let label = pipeline_ref.pipeline().label().unwrap_or("unnamed"); + return Err(RenderError::Configuration(format!( + "Render pipeline '{}' declares color targets but the current pass has no color attachments", + label + ))); + } + if uses_color && !pipeline_ref.has_color_targets() { + let label = pipeline_ref.pipeline().label().unwrap_or("unnamed"); + return Err(RenderError::Configuration(format!( + "Render pipeline '{}' has no color targets but the current pass declares color attachments", + label + ))); + } + if !pass_has_depth && pipeline_ref.expects_depth_stencil() { + let label = pipeline_ref.pipeline().label().unwrap_or("unnamed"); + return Err(RenderError::Configuration(format!( + "Render pipeline '{}' expects a depth/stencil attachment but the current pass has none", + label + ))); + } } - if pass_has_depth - && !pipeline_ref.expects_depth_stencil() - && warned_no_depth_for_pipeline.insert(pipeline) + // Advisory checks to help reason about stencil/depth behavior (debug only). + #[cfg(debug_assertions)] { - let label = pipeline_ref.pipeline().label().unwrap_or("unnamed"); - let key = format!("depth:no_test:{}", label); - let msg = format!( - "Pass has depth attachment but pipeline '{}' does not enable depth testing; depth values will not be tested/written", - label - ); - util::warn_once(&key, &msg); + if pass_has_stencil + && !pipeline_ref.uses_stencil() + && warned_no_stencil_for_pipeline.insert(pipeline) + { + let label = pipeline_ref.pipeline().label().unwrap_or("unnamed"); + let key = format!("stencil:no_test:{}", label); + let msg = format!( + "Pass provides stencil ops but pipeline '{}' has no stencil test; stencil will not affect rendering", + label + ); + util::warn_once(&key, &msg); + } + if !pass_has_stencil && pipeline_ref.uses_stencil() { + let label = pipeline_ref.pipeline().label().unwrap_or("unnamed"); + let key = format!("stencil:pass_no_operations:{}", label); + let msg = format!( + "Pipeline '{}' enables stencil but pass has no stencil ops configured; stencil reference/tests may be ineffective", + label + ); + util::warn_once(&key, &msg); + } + if pass_has_depth + && !pipeline_ref.expects_depth_stencil() + && warned_no_depth_for_pipeline.insert(pipeline) + { + let label = pipeline_ref.pipeline().label().unwrap_or("unnamed"); + let key = format!("depth:no_test:{}", label); + let msg = format!( + "Pass has depth attachment but pipeline '{}' does not enable depth testing; depth values will not be tested/written", + label + ); + util::warn_once(&key, &msg); + } } pass.set_pipeline(pipeline_ref.pipeline()); } diff --git a/crates/lambda-rs/src/render/pipeline.rs b/crates/lambda-rs/src/render/pipeline.rs index 49d7dc9b..8d9c83e1 100644 --- a/crates/lambda-rs/src/render/pipeline.rs +++ b/crates/lambda-rs/src/render/pipeline.rs @@ -318,15 +318,27 @@ impl RenderPipelineBuilder { /// Configure multi-sampling for this pipeline. pub fn with_multi_sample(mut self, samples: u32) -> Self { - match validation::validate_sample_count(samples) { - Ok(()) => { - self.sample_count = samples; - } - Err(msg) => { - logging::error!("{}; falling back to sample_count=1 for pipeline", msg); - self.sample_count = 1; + #[cfg(debug_assertions)] + { + match validation::validate_sample_count(samples) { + Ok(()) => { + self.sample_count = samples; + } + Err(msg) => { + logging::error!( + "{}; falling back to sample_count=1 for pipeline", + msg + ); + self.sample_count = 1; + } } } + #[cfg(not(debug_assertions))] + { + // In release builds, accept the provided sample count without extra + // engine-level validation. Platform validation MAY still apply. + self.sample_count = samples; + } return self; } @@ -452,6 +464,7 @@ impl RenderPipelineBuilder { if self.stencil.is_some() && dfmt != platform_texture::DepthFormat::Depth24PlusStencil8 { + #[cfg(debug_assertions)] logging::error!( "Stencil configured but depth format {:?} lacks stencil; upgrading to Depth24PlusStencil8", dfmt @@ -473,21 +486,23 @@ impl RenderPipelineBuilder { } // Apply multi-sampling to the pipeline. - // Ensure pass and pipeline samples match; adjust if needed with a warning. - let pass_samples = _render_pass.sample_count(); + // In debug builds, validate and align with the render pass; in release, + // defer to platform validation without engine-side checks. let mut pipeline_samples = self.sample_count; - if pipeline_samples != pass_samples { - logging::error!( - "Pipeline sample_count={} does not match pass sample_count={}; aligning to pass", - pipeline_samples, - pass_samples - ); - pipeline_samples = pass_samples; - } - // Validate again (defensive) in case pass was built with an invalid value - // and clamped by its builder. - if validation::validate_sample_count(pipeline_samples).is_err() { - pipeline_samples = 1; + #[cfg(debug_assertions)] + { + let pass_samples = _render_pass.sample_count(); + if pipeline_samples != pass_samples { + logging::error!( + "Pipeline sample_count={} does not match pass sample_count={}; aligning to pass", + pipeline_samples, + pass_samples + ); + pipeline_samples = pass_samples; + } + if validation::validate_sample_count(pipeline_samples).is_err() { + pipeline_samples = 1; + } } rp_builder = rp_builder.with_sample_count(pipeline_samples); diff --git a/crates/lambda-rs/src/render/render_pass.rs b/crates/lambda-rs/src/render/render_pass.rs index ba69bd77..249eda54 100644 --- a/crates/lambda-rs/src/render/render_pass.rs +++ b/crates/lambda-rs/src/render/render_pass.rs @@ -243,18 +243,27 @@ impl RenderPassBuilder { /// Configure multi-sample anti-aliasing for this pass. pub fn with_multi_sample(mut self, samples: u32) -> Self { - match validation::validate_sample_count(samples) { - Ok(()) => { - self.sample_count = samples; - } - Err(msg) => { - logging::error!( - "{}; falling back to sample_count=1 for render pass", - msg - ); - self.sample_count = 1; + #[cfg(debug_assertions)] + { + match validation::validate_sample_count(samples) { + Ok(()) => { + self.sample_count = samples; + } + Err(msg) => { + logging::error!( + "{}; falling back to sample_count=1 for render pass", + msg + ); + self.sample_count = 1; + } } } + #[cfg(not(debug_assertions))] + { + // In release builds, accept the provided sample count without engine + // validation; platform validation MAY still apply. + self.sample_count = samples; + } return self; } From 1db6f964829e1b3fd91d7206ace0870046759ba4 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sun, 16 Nov 2025 15:48:12 -0800 Subject: [PATCH 10/25] [add] the ability to toggle msaa, depth tests, and stencil tests in the demo. --- crates/lambda-rs/examples/reflective_room.rs | 485 +++++++++++++++---- crates/lambda-rs/src/render/render_pass.rs | 4 +- 2 files changed, 380 insertions(+), 109 deletions(-) diff --git a/crates/lambda-rs/examples/reflective_room.rs b/crates/lambda-rs/examples/reflective_room.rs index 229a773f..69d4433b 100644 --- a/crates/lambda-rs/examples/reflective_room.rs +++ b/crates/lambda-rs/examples/reflective_room.rs @@ -149,6 +149,11 @@ pub struct ReflectiveRoomExample { width: u32, height: u32, elapsed: f32, + // Toggleable demo settings + msaa_samples: u32, + stencil_enabled: bool, + depth_test_enabled: bool, + needs_rebuild: bool, } impl Component for ReflectiveRoomExample { @@ -353,6 +358,36 @@ impl Component for ReflectiveRoomExample { } _ => {} }, + lambda::events::Events::Keyboard { event, .. } => match event { + lambda::events::Key::Pressed { + scan_code: _, + virtual_key, + } => match virtual_key { + Some(lambda::events::VirtualKey::KeyM) => { + self.msaa_samples = if self.msaa_samples > 1 { 1 } else { 4 }; + self.needs_rebuild = true; + logging::info!("Toggled MSAA → {}x (key: M)", self.msaa_samples); + } + Some(lambda::events::VirtualKey::KeyS) => { + self.stencil_enabled = !self.stencil_enabled; + self.needs_rebuild = true; + logging::info!( + "Toggled Stencil → {} (key: S)", + self.stencil_enabled + ); + } + Some(lambda::events::VirtualKey::KeyD) => { + self.depth_test_enabled = !self.depth_test_enabled; + self.needs_rebuild = true; + logging::info!( + "Toggled Depth Test → {} (key: D)", + self.depth_test_enabled + ); + } + _ => {} + }, + _ => {} + }, _ => {} } return Ok(ComponentResult::Success); @@ -368,8 +403,14 @@ impl Component for ReflectiveRoomExample { fn on_render( &mut self, - _render_context: &mut lambda::render::RenderContext, + render_context: &mut lambda::render::RenderContext, ) -> Vec { + if self.needs_rebuild { + // Attempt to rebuild resources according to current toggles + if let Err(err) = self.rebuild_resources(render_context) { + logging::error!("Failed to rebuild resources: {}", err); + } + } // Camera let camera = SimpleCamera { position: [0.0, 1.2, 3.5], @@ -430,117 +471,129 @@ impl Component for ReflectiveRoomExample { let viewport = ViewportBuilder::new().build(self.width, self.height); - let pass_id_mask = self.pass_id_mask.expect("mask pass not set"); + let mut cmds: Vec = Vec::new(); + + if self.stencil_enabled { + // Optional Pass 1: write floor stencil mask + if let (Some(pass_id_mask), Some(pipe_floor_mask)) = + (self.pass_id_mask, self.pipe_floor_mask) + { + cmds.push(RenderCommand::BeginRenderPass { + render_pass: pass_id_mask, + viewport: viewport.clone(), + }); + cmds.push(RenderCommand::SetPipeline { + pipeline: pipe_floor_mask, + }); + cmds.push(RenderCommand::SetViewports { + start_at: 0, + viewports: vec![viewport.clone()], + }); + cmds.push(RenderCommand::SetScissors { + start_at: 0, + viewports: vec![viewport.clone()], + }); + cmds.push(RenderCommand::SetStencilReference { reference: 1 }); + cmds.push(RenderCommand::BindVertexBuffer { + pipeline: pipe_floor_mask, + buffer: 0, + }); + cmds.push(RenderCommand::PushConstants { + pipeline: pipe_floor_mask, + stage: PipelineStage::VERTEX, + offset: 0, + bytes: Vec::from(push_constants_to_words(&PushConstant { + mvp: mvp_floor.transpose(), + model: model_floor.transpose(), + })), + }); + cmds.push(RenderCommand::Draw { + vertices: 0..self.floor_mesh.as_ref().unwrap().vertices().len() + as u32, + }); + cmds.push(RenderCommand::EndRenderPass); + } + } + + // Color pass (with optional depth/stencil configured on the pass itself) let pass_id_color = self.pass_id_color.expect("color pass not set"); - let pipe_floor_mask = self.pipe_floor_mask.expect("floor mask pipeline"); - let pipe_reflected = self.pipe_reflected.expect("reflected pipeline"); + cmds.push(RenderCommand::BeginRenderPass { + render_pass: pass_id_color, + viewport: viewport.clone(), + }); + + if self.stencil_enabled { + if let Some(pipe_reflected) = self.pipe_reflected { + cmds.push(RenderCommand::SetPipeline { + pipeline: pipe_reflected, + }); + cmds.push(RenderCommand::SetStencilReference { reference: 1 }); + cmds.push(RenderCommand::BindVertexBuffer { + pipeline: pipe_reflected, + buffer: 0, + }); + cmds.push(RenderCommand::PushConstants { + pipeline: pipe_reflected, + stage: PipelineStage::VERTEX, + offset: 0, + bytes: Vec::from(push_constants_to_words(&PushConstant { + mvp: mvp_reflect.transpose(), + model: model_reflect.transpose(), + })), + }); + cmds.push(RenderCommand::Draw { + vertices: 0..self.cube_mesh.as_ref().unwrap().vertices().len() as u32, + }); + } + } + + // Floor surface (tinted) let pipe_floor_visual = self.pipe_floor_visual.expect("floor visual pipeline"); + cmds.push(RenderCommand::SetPipeline { + pipeline: pipe_floor_visual, + }); + cmds.push(RenderCommand::BindVertexBuffer { + pipeline: pipe_floor_visual, + buffer: 0, + }); + cmds.push(RenderCommand::PushConstants { + pipeline: pipe_floor_visual, + stage: PipelineStage::VERTEX, + offset: 0, + bytes: Vec::from(push_constants_to_words(&PushConstant { + mvp: mvp_floor.transpose(), + model: model_floor.transpose(), + })), + }); + cmds.push(RenderCommand::Draw { + vertices: 0..self.floor_mesh.as_ref().unwrap().vertices().len() as u32, + }); + + // Normal cube let pipe_normal = self.pipe_normal.expect("normal pipeline"); + cmds.push(RenderCommand::SetPipeline { + pipeline: pipe_normal, + }); + cmds.push(RenderCommand::BindVertexBuffer { + pipeline: pipe_normal, + buffer: 0, + }); + cmds.push(RenderCommand::PushConstants { + pipeline: pipe_normal, + stage: PipelineStage::VERTEX, + offset: 0, + bytes: Vec::from(push_constants_to_words(&PushConstant { + mvp: mvp.transpose(), + model: model.transpose(), + })), + }); + cmds.push(RenderCommand::Draw { + vertices: 0..self.cube_mesh.as_ref().unwrap().vertices().len() as u32, + }); + cmds.push(RenderCommand::EndRenderPass); - return vec![ - // Pass 1: depth/stencil-only to write the floor mask. - RenderCommand::BeginRenderPass { - render_pass: pass_id_mask, - viewport: viewport.clone(), - }, - // Phase 1: write stencil where the floor geometry exists (stencil = 1). - RenderCommand::SetPipeline { - pipeline: pipe_floor_mask, - }, - RenderCommand::SetViewports { - start_at: 0, - viewports: vec![viewport.clone()], - }, - RenderCommand::SetScissors { - start_at: 0, - viewports: vec![viewport.clone()], - }, - RenderCommand::SetStencilReference { reference: 1 }, - RenderCommand::BindVertexBuffer { - pipeline: pipe_floor_mask, - buffer: 0, - }, - RenderCommand::PushConstants { - pipeline: pipe_floor_mask, - stage: PipelineStage::VERTEX, - offset: 0, - bytes: Vec::from(push_constants_to_words(&PushConstant { - mvp: mvp_floor.transpose(), - model: model_floor.transpose(), - })), - }, - RenderCommand::Draw { - vertices: 0..self.floor_mesh.as_ref().unwrap().vertices().len() as u32, - }, - RenderCommand::EndRenderPass, - // Pass 2: color + depth, stencil loaded from mask. - RenderCommand::BeginRenderPass { - render_pass: pass_id_color, - viewport: viewport.clone(), - }, - // Phase 2: draw the reflected cube where stencil == 1. - RenderCommand::SetPipeline { - pipeline: pipe_reflected, - }, - RenderCommand::SetStencilReference { reference: 1 }, - RenderCommand::BindVertexBuffer { - pipeline: pipe_reflected, - buffer: 0, - }, - RenderCommand::PushConstants { - pipeline: pipe_reflected, - stage: PipelineStage::VERTEX, - offset: 0, - bytes: Vec::from(push_constants_to_words(&PushConstant { - mvp: mvp_reflect.transpose(), - model: model_reflect.transpose(), - })), - }, - RenderCommand::Draw { - vertices: 0..self.cube_mesh.as_ref().unwrap().vertices().len() as u32, - }, - RenderCommand::SetPipeline { - pipeline: pipe_floor_visual, - }, - RenderCommand::BindVertexBuffer { - pipeline: pipe_floor_visual, - buffer: 0, - }, - RenderCommand::PushConstants { - pipeline: pipe_floor_visual, - stage: PipelineStage::VERTEX, - offset: 0, - bytes: Vec::from(push_constants_to_words(&PushConstant { - mvp: mvp_floor.transpose(), - model: model_floor.transpose(), - })), - }, - RenderCommand::Draw { - vertices: 0..self.floor_mesh.as_ref().unwrap().vertices().len() as u32, - }, - // Phase 4: draw the normal cube above the floor. - RenderCommand::SetPipeline { - pipeline: pipe_normal, - }, - RenderCommand::BindVertexBuffer { - pipeline: pipe_normal, - buffer: 0, - }, - RenderCommand::PushConstants { - pipeline: pipe_normal, - stage: PipelineStage::VERTEX, - offset: 0, - bytes: Vec::from(push_constants_to_words(&PushConstant { - mvp: mvp.transpose(), - model: model.transpose(), - })), - }, - RenderCommand::Draw { - vertices: 0..self.cube_mesh.as_ref().unwrap().vertices().len() as u32, - }, - RenderCommand::EndRenderPass, - ]; + return cmds; } } @@ -581,10 +634,228 @@ impl Default for ReflectiveRoomExample { width: 800, height: 600, elapsed: 0.0, + msaa_samples: 4, + stencil_enabled: true, + depth_test_enabled: true, + needs_rebuild: false, }; } } +impl ReflectiveRoomExample { + fn rebuild_resources( + &mut self, + render_context: &mut lambda::render::RenderContext, + ) -> Result<(), String> { + self.needs_rebuild = false; + + // Ensure meshes exist (reuse existing buffers on context attach). + if self.cube_mesh.is_none() { + self.cube_mesh = Some(build_unit_cube_mesh()); + } + if self.floor_mesh.is_none() { + self.floor_mesh = Some(build_floor_quad_mesh(5.0)); + } + let cube_mesh = self.cube_mesh.as_ref().unwrap(); + let floor_mesh = self.floor_mesh.as_ref().unwrap(); + let push_constants_size = std::mem::size_of::() as u32; + + // Build pass descriptions locally first + let rp_mask_desc = if self.stencil_enabled { + Some( + RenderPassBuilder::new() + .with_label("reflective-room-pass-mask") + .with_depth_clear(1.0) + .with_stencil_clear(0) + .with_multi_sample(self.msaa_samples) + .without_color() + .build(render_context), + ) + } else { + None + }; + + let mut rp_color_builder = RenderPassBuilder::new() + .with_label("reflective-room-pass-color") + .with_multi_sample(self.msaa_samples); + if self.depth_test_enabled { + rp_color_builder = rp_color_builder.with_depth_clear(1.0); + } else if self.stencil_enabled { + // Ensure a depth-stencil attachment exists even if we are not depth-testing, + // because pipelines with stencil state expect a depth/stencil attachment. + rp_color_builder = rp_color_builder.with_depth_load(); + } + if self.stencil_enabled { + rp_color_builder = rp_color_builder.with_stencil_load(); + } + let rp_color_desc = rp_color_builder.build(render_context); + + // Floor mask pipeline (stencil write) + self.pipe_floor_mask = if self.stencil_enabled { + let p = RenderPipelineBuilder::new() + .with_label("floor-mask") + .with_culling(CullingMode::Back) + .with_depth_format(DepthFormat::Depth24PlusStencil8) + .with_depth_write(false) + .with_depth_compare(CompareFunction::Always) + .with_push_constant(PipelineStage::VERTEX, push_constants_size) + .with_buffer( + BufferBuilder::build_from_mesh(floor_mesh, render_context) + .map_err(|e| format!("Failed to create floor buffer: {}", e))?, + floor_mesh.attributes().to_vec(), + ) + .with_stencil(StencilState { + front: StencilFaceState { + compare: CompareFunction::Always, + fail_op: StencilOperation::Keep, + depth_fail_op: StencilOperation::Keep, + pass_op: StencilOperation::Replace, + }, + back: StencilFaceState { + compare: CompareFunction::Always, + fail_op: StencilOperation::Keep, + depth_fail_op: StencilOperation::Keep, + pass_op: StencilOperation::Replace, + }, + read_mask: 0xFF, + write_mask: 0xFF, + }) + .with_multi_sample(self.msaa_samples) + .build( + render_context, + rp_mask_desc + .as_ref() + .expect("mask pass missing for stencil"), + &self.shader_vs, + None, + ); + Some(render_context.attach_pipeline(p)) + } else { + None + }; + + // Reflected cube pipeline + self.pipe_reflected = if self.stencil_enabled { + let mut builder = RenderPipelineBuilder::new() + .with_label("reflected-cube") + .with_culling(CullingMode::None) + .with_depth_format(DepthFormat::Depth24PlusStencil8) + .with_push_constant(PipelineStage::VERTEX, push_constants_size) + .with_buffer( + BufferBuilder::build_from_mesh(cube_mesh, render_context) + .map_err(|e| format!("Failed to create cube buffer: {}", e))?, + cube_mesh.attributes().to_vec(), + ) + .with_stencil(StencilState { + front: StencilFaceState { + compare: CompareFunction::Equal, + fail_op: StencilOperation::Keep, + depth_fail_op: StencilOperation::Keep, + pass_op: StencilOperation::Keep, + }, + back: StencilFaceState { + compare: CompareFunction::Equal, + fail_op: StencilOperation::Keep, + depth_fail_op: StencilOperation::Keep, + pass_op: StencilOperation::Keep, + }, + read_mask: 0xFF, + write_mask: 0x00, + }) + .with_multi_sample(self.msaa_samples); + if self.depth_test_enabled { + builder = builder + .with_depth_write(true) + .with_depth_compare(CompareFunction::LessEqual); + } else { + builder = builder + .with_depth_write(false) + .with_depth_compare(CompareFunction::Always); + } + let p = builder.build( + render_context, + &rp_color_desc, + &self.shader_vs, + Some(&self.shader_fs_lit), + ); + Some(render_context.attach_pipeline(p)) + } else { + None + }; + + // Floor visual pipeline + let mut floor_builder = RenderPipelineBuilder::new() + .with_label("floor-visual") + .with_culling(CullingMode::Back) + .with_push_constant(PipelineStage::VERTEX, push_constants_size) + .with_buffer( + BufferBuilder::build_from_mesh(floor_mesh, render_context) + .map_err(|e| format!("Failed to create floor buffer: {}", e))?, + floor_mesh.attributes().to_vec(), + ) + .with_multi_sample(self.msaa_samples); + if self.depth_test_enabled || self.stencil_enabled { + floor_builder = floor_builder + .with_depth_format(DepthFormat::Depth24PlusStencil8) + .with_depth_write(false) + .with_depth_compare(if self.depth_test_enabled { + CompareFunction::LessEqual + } else { + CompareFunction::Always + }); + } + let floor_pipe = floor_builder.build( + render_context, + &rp_color_desc, + &self.shader_vs, + Some(&self.shader_fs_floor), + ); + self.pipe_floor_visual = Some(render_context.attach_pipeline(floor_pipe)); + + // Normal cube pipeline + let mut normal_builder = RenderPipelineBuilder::new() + .with_label("cube-normal") + .with_culling(CullingMode::Back) + .with_push_constant(PipelineStage::VERTEX, push_constants_size) + .with_buffer( + BufferBuilder::build_from_mesh(cube_mesh, render_context) + .map_err(|e| format!("Failed to create cube buffer: {}", e))?, + cube_mesh.attributes().to_vec(), + ) + .with_multi_sample(self.msaa_samples); + if self.depth_test_enabled || self.stencil_enabled { + normal_builder = normal_builder + .with_depth_format(DepthFormat::Depth24PlusStencil8) + .with_depth_write(self.depth_test_enabled) + .with_depth_compare(if self.depth_test_enabled { + CompareFunction::Less + } else { + CompareFunction::Always + }); + } + let normal_pipe = normal_builder.build( + render_context, + &rp_color_desc, + &self.shader_vs, + Some(&self.shader_fs_lit), + ); + self.pipe_normal = Some(render_context.attach_pipeline(normal_pipe)); + + // Finally attach the passes and record their handles + self.pass_id_mask = + rp_mask_desc.map(|rp| render_context.attach_render_pass(rp)); + self.pass_id_color = Some(render_context.attach_render_pass(rp_color_desc)); + + logging::info!( + "Rebuilt — MSAA: {}x, Stencil: {}, Depth Test: {}", + self.msaa_samples, + self.stencil_enabled, + self.depth_test_enabled + ); + return Ok(()); + } +} + fn build_unit_cube_mesh() -> Mesh { let mut verts: Vec = Vec::new(); let mut add_face = diff --git a/crates/lambda-rs/src/render/render_pass.rs b/crates/lambda-rs/src/render/render_pass.rs index 249eda54..08790965 100644 --- a/crates/lambda-rs/src/render/render_pass.rs +++ b/crates/lambda-rs/src/render/render_pass.rs @@ -269,7 +269,7 @@ impl RenderPassBuilder { /// Build the description used when beginning a render pass. pub fn build(self, _render_context: &RenderContext) -> RenderPass { - RenderPass { + return RenderPass { clear_color: self.clear_color, label: self.label, color_operations: self.color_operations, @@ -277,7 +277,7 @@ impl RenderPassBuilder { stencil_operations: self.stencil_operations, sample_count: self.sample_count, use_color: self.use_color, - } + }; } } From ceaf345777d871912b2f92ae629a34b8e6f8654a Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sun, 16 Nov 2025 15:58:18 -0800 Subject: [PATCH 11/25] [optimize] resource building. --- crates/lambda-rs/examples/reflective_room.rs | 293 ++++++------------- 1 file changed, 87 insertions(+), 206 deletions(-) diff --git a/crates/lambda-rs/examples/reflective_room.rs b/crates/lambda-rs/examples/reflective_room.rs index 69d4433b..ded5104d 100644 --- a/crates/lambda-rs/examples/reflective_room.rs +++ b/crates/lambda-rs/examples/reflective_room.rs @@ -17,7 +17,12 @@ use lambda::{ logging, math::matrix::Matrix, render::{ - buffer::BufferBuilder, + buffer::{ + BufferBuilder, + BufferType, + Properties, + Usage, + }, command::RenderCommand, mesh::{ Mesh, @@ -163,180 +168,16 @@ impl Component for ReflectiveRoomExample { ) -> Result { logging::info!("Attaching ReflectiveRoomExample"); - // Pass 1 (mask): depth clear, stencil clear, MSAA 4x, without color. - let render_pass_mask = RenderPassBuilder::new() - .with_label("reflective-room-pass-mask") - .with_depth_clear(1.0) - .with_stencil_clear(0) - .with_multi_sample(4) - .without_color() - .build(render_context); - - // Pass 2 (color): color + depth clear, stencil LOAD (use mask), MSAA 4x. - let render_pass_color = RenderPassBuilder::new() - .with_label("reflective-room-pass-color") - .with_depth_clear(1.0) - .with_stencil_load() - .with_multi_sample(4) - .build(render_context); - - // Shaders - let mut shader_builder = ShaderBuilder::new(); - let shader_vs = shader_builder.build(VirtualShader::Source { - source: VERTEX_SHADER_SOURCE.to_string(), - kind: ShaderKind::Vertex, - entry_point: "main".to_string(), - name: "reflective-room-vs".to_string(), - }); - let shader_fs_lit = shader_builder.build(VirtualShader::Source { - source: FRAGMENT_LIT_COLOR_SOURCE.to_string(), - kind: ShaderKind::Fragment, - entry_point: "main".to_string(), - name: "reflective-room-fs-lit".to_string(), - }); - let shader_fs_floor = shader_builder.build(VirtualShader::Source { - source: FRAGMENT_FLOOR_TINT_SOURCE.to_string(), - kind: ShaderKind::Fragment, - entry_point: "main".to_string(), - name: "reflective-room-fs-floor".to_string(), - }); - // Note: the mask pipeline is vertex-only and will be used in a pass - // without color attachments. - - // Geometry: cube (unit) and floor quad at y = 0. - let cube_mesh = build_unit_cube_mesh(); - let floor_mesh = build_floor_quad_mesh(5.0); - - let push_constants_size = std::mem::size_of::() as u32; - - // Stencil mask pipeline. Writes stencil=1 where the floor exists. - let pipe_floor_mask = RenderPipelineBuilder::new() - .with_label("floor-mask") - .with_culling(CullingMode::Back) - .with_depth_format(DepthFormat::Depth24PlusStencil8) - .with_depth_write(false) - .with_depth_compare(CompareFunction::Always) - .with_push_constant(PipelineStage::VERTEX, push_constants_size) - .with_buffer( - BufferBuilder::build_from_mesh(&floor_mesh, render_context) - .expect("Failed to create floor vertex buffer"), - floor_mesh.attributes().to_vec(), - ) - .with_stencil(StencilState { - front: StencilFaceState { - compare: CompareFunction::Always, - fail_op: StencilOperation::Keep, - depth_fail_op: StencilOperation::Keep, - pass_op: StencilOperation::Replace, - }, - back: StencilFaceState { - compare: CompareFunction::Always, - fail_op: StencilOperation::Keep, - depth_fail_op: StencilOperation::Keep, - pass_op: StencilOperation::Replace, - }, - read_mask: 0xFF, - write_mask: 0xFF, - }) - .with_multi_sample(4) - .build(render_context, &render_pass_mask, &shader_vs, None); - - // Reflected cube pipeline: stencil test Equal, depth test enabled, no culling. - let pipe_reflected = RenderPipelineBuilder::new() - .with_label("reflected-cube") - .with_culling(CullingMode::None) - .with_depth_format(DepthFormat::Depth24PlusStencil8) - .with_depth_write(true) - .with_depth_compare(CompareFunction::LessEqual) - .with_push_constant(PipelineStage::VERTEX, push_constants_size) - .with_buffer( - BufferBuilder::build_from_mesh(&cube_mesh, render_context) - .expect("Failed to create cube vertex buffer"), - cube_mesh.attributes().to_vec(), - ) - .with_stencil(StencilState { - front: StencilFaceState { - compare: CompareFunction::Equal, - fail_op: StencilOperation::Keep, - depth_fail_op: StencilOperation::Keep, - pass_op: StencilOperation::Keep, - }, - back: StencilFaceState { - compare: CompareFunction::Equal, - fail_op: StencilOperation::Keep, - depth_fail_op: StencilOperation::Keep, - pass_op: StencilOperation::Keep, - }, - read_mask: 0xFF, - write_mask: 0x00, - }) - .with_multi_sample(4) - .build( - render_context, - &render_pass_color, - &shader_vs, - Some(&shader_fs_lit), - ); - - // Floor visual pipeline: draw a tinted surface above the reflection. - let pipe_floor_visual = RenderPipelineBuilder::new() - .with_label("floor-visual") - .with_culling(CullingMode::Back) - .with_depth_format(DepthFormat::Depth24PlusStencil8) - .with_depth_write(false) - .with_depth_compare(CompareFunction::LessEqual) - .with_push_constant(PipelineStage::VERTEX, push_constants_size) - .with_buffer( - BufferBuilder::build_from_mesh(&floor_mesh, render_context) - .expect("Failed to create floor vertex buffer"), - floor_mesh.attributes().to_vec(), - ) - .with_multi_sample(4) - .build( - render_context, - &render_pass_color, - &shader_vs, - Some(&shader_fs_floor), - ); - - // Normal (unreflected) cube pipeline: standard depth test. - let pipe_normal = RenderPipelineBuilder::new() - .with_label("cube-normal") - .with_culling(CullingMode::Back) - .with_depth_format(DepthFormat::Depth24PlusStencil8) - .with_depth_write(true) - .with_depth_compare(CompareFunction::Less) - .with_push_constant(PipelineStage::VERTEX, push_constants_size) - .with_buffer( - BufferBuilder::build_from_mesh(&cube_mesh, render_context) - .expect("Failed to create cube vertex buffer"), - cube_mesh.attributes().to_vec(), - ) - .with_multi_sample(4) - .build( - render_context, - &render_pass_color, - &shader_vs, - Some(&shader_fs_lit), - ); - - self.pass_id_mask = - Some(render_context.attach_render_pass(render_pass_mask)); - self.pass_id_color = - Some(render_context.attach_render_pass(render_pass_color)); - self.pipe_floor_mask = - Some(render_context.attach_pipeline(pipe_floor_mask)); - self.pipe_reflected = Some(render_context.attach_pipeline(pipe_reflected)); - self.pipe_floor_visual = - Some(render_context.attach_pipeline(pipe_floor_visual)); - self.pipe_normal = Some(render_context.attach_pipeline(pipe_normal)); - self.cube_mesh = Some(cube_mesh); - self.floor_mesh = Some(floor_mesh); - self.shader_vs = shader_vs; - self.shader_fs_lit = shader_fs_lit; - self.shader_fs_floor = shader_fs_floor; - - return Ok(ComponentResult::Success); + // Build resources according to current toggles via the shared path. + match self.rebuild_resources(render_context) { + Ok(()) => { + return Ok(ComponentResult::Success); + } + Err(err) => { + logging::error!("Initial resource build failed: {}", err); + return Err(err); + } + } } fn on_detach( @@ -442,27 +283,27 @@ impl Component for ReflectiveRoomExample { ); let mvp = projection.multiply(&view).multiply(&model); - // Reflected model: mirror across the floor (y=0) by scaling Y by -1. - let mut model_reflect: [[f32; 4]; 4] = - lambda::math::matrix::identity_matrix(4, 4); - // Mirror across the floor plane by scaling Y by -1. - let s_mirror: [[f32; 4]; 4] = [ - [1.0, 0.0, 0.0, 0.0], - [0.0, -1.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.0], - [0.0, 0.0, 0.0, 1.0], - ]; - model_reflect = model_reflect.multiply(&s_mirror); - // Apply the same rotation and translation as the normal cube but mirrored. - model_reflect = lambda::math::matrix::rotate_matrix( - model_reflect, - [0.0, 1.0, 0.0], - angle_y_turns, - ); - let t_down: [[f32; 4]; 4] = - lambda::math::matrix::translation_matrix([0.0, -0.5, 0.0]); - model_reflect = model_reflect.multiply(&t_down); - let mvp_reflect = projection.multiply(&view).multiply(&model_reflect); + // Compute reflected transform only if stencil/reflection is enabled. + let (model_reflect, mvp_reflect) = if self.stencil_enabled { + let mut mr: [[f32; 4]; 4] = lambda::math::matrix::identity_matrix(4, 4); + let s_mirror: [[f32; 4]; 4] = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, -1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + mr = mr.multiply(&s_mirror); + mr = + lambda::math::matrix::rotate_matrix(mr, [0.0, 1.0, 0.0], angle_y_turns); + let t_down: [[f32; 4]; 4] = + lambda::math::matrix::translation_matrix([0.0, -0.5, 0.0]); + mr = mr.multiply(&t_down); + let mvp_r = projection.multiply(&view).multiply(&mr); + (mr, mvp_r) + } else { + // Unused in subsequent commands when stencil is disabled. + (lambda::math::matrix::identity_matrix(4, 4), mvp) + }; // Floor model: at y = 0 plane let mut model_floor: [[f32; 4]; 4] = @@ -473,6 +314,18 @@ impl Component for ReflectiveRoomExample { let mut cmds: Vec = Vec::new(); + // Cache vertex counts locally to avoid repeated lookups. + let cube_vertex_count: u32 = self + .cube_mesh + .as_ref() + .map(|m| m.vertices().len() as u32) + .unwrap_or(0); + let floor_vertex_count: u32 = self + .floor_mesh + .as_ref() + .map(|m| m.vertices().len() as u32) + .unwrap_or(0); + if self.stencil_enabled { // Optional Pass 1: write floor stencil mask if let (Some(pass_id_mask), Some(pipe_floor_mask)) = @@ -508,8 +361,7 @@ impl Component for ReflectiveRoomExample { })), }); cmds.push(RenderCommand::Draw { - vertices: 0..self.floor_mesh.as_ref().unwrap().vertices().len() - as u32, + vertices: 0..floor_vertex_count, }); cmds.push(RenderCommand::EndRenderPass); } @@ -542,7 +394,7 @@ impl Component for ReflectiveRoomExample { })), }); cmds.push(RenderCommand::Draw { - vertices: 0..self.cube_mesh.as_ref().unwrap().vertices().len() as u32, + vertices: 0..cube_vertex_count, }); } } @@ -567,7 +419,7 @@ impl Component for ReflectiveRoomExample { })), }); cmds.push(RenderCommand::Draw { - vertices: 0..self.floor_mesh.as_ref().unwrap().vertices().len() as u32, + vertices: 0..floor_vertex_count, }); // Normal cube @@ -589,7 +441,7 @@ impl Component for ReflectiveRoomExample { })), }); cmds.push(RenderCommand::Draw { - vertices: 0..self.cube_mesh.as_ref().unwrap().vertices().len() as u32, + vertices: 0..cube_vertex_count, }); cmds.push(RenderCommand::EndRenderPass); @@ -700,7 +552,14 @@ impl ReflectiveRoomExample { .with_depth_compare(CompareFunction::Always) .with_push_constant(PipelineStage::VERTEX, push_constants_size) .with_buffer( - BufferBuilder::build_from_mesh(floor_mesh, render_context) + BufferBuilder::new() + .with_length( + floor_mesh.vertices().len() * std::mem::size_of::(), + ) + .with_usage(Usage::VERTEX) + .with_properties(Properties::DEVICE_LOCAL) + .with_buffer_type(BufferType::Vertex) + .build(render_context, floor_mesh.vertices().to_vec()) .map_err(|e| format!("Failed to create floor buffer: {}", e))?, floor_mesh.attributes().to_vec(), ) @@ -742,7 +601,14 @@ impl ReflectiveRoomExample { .with_depth_format(DepthFormat::Depth24PlusStencil8) .with_push_constant(PipelineStage::VERTEX, push_constants_size) .with_buffer( - BufferBuilder::build_from_mesh(cube_mesh, render_context) + BufferBuilder::new() + .with_length( + cube_mesh.vertices().len() * std::mem::size_of::(), + ) + .with_usage(Usage::VERTEX) + .with_properties(Properties::DEVICE_LOCAL) + .with_buffer_type(BufferType::Vertex) + .build(render_context, cube_mesh.vertices().to_vec()) .map_err(|e| format!("Failed to create cube buffer: {}", e))?, cube_mesh.attributes().to_vec(), ) @@ -789,7 +655,14 @@ impl ReflectiveRoomExample { .with_culling(CullingMode::Back) .with_push_constant(PipelineStage::VERTEX, push_constants_size) .with_buffer( - BufferBuilder::build_from_mesh(floor_mesh, render_context) + BufferBuilder::new() + .with_length( + floor_mesh.vertices().len() * std::mem::size_of::(), + ) + .with_usage(Usage::VERTEX) + .with_properties(Properties::DEVICE_LOCAL) + .with_buffer_type(BufferType::Vertex) + .build(render_context, floor_mesh.vertices().to_vec()) .map_err(|e| format!("Failed to create floor buffer: {}", e))?, floor_mesh.attributes().to_vec(), ) @@ -818,7 +691,14 @@ impl ReflectiveRoomExample { .with_culling(CullingMode::Back) .with_push_constant(PipelineStage::VERTEX, push_constants_size) .with_buffer( - BufferBuilder::build_from_mesh(cube_mesh, render_context) + BufferBuilder::new() + .with_length( + cube_mesh.vertices().len() * std::mem::size_of::(), + ) + .with_usage(Usage::VERTEX) + .with_properties(Properties::DEVICE_LOCAL) + .with_buffer_type(BufferType::Vertex) + .build(render_context, cube_mesh.vertices().to_vec()) .map_err(|e| format!("Failed to create cube buffer: {}", e))?, cube_mesh.attributes().to_vec(), ) @@ -857,7 +737,8 @@ impl ReflectiveRoomExample { } fn build_unit_cube_mesh() -> Mesh { - let mut verts: Vec = Vec::new(); + // 6 faces * 2 triangles * 3 vertices = 36 + let mut verts: Vec = Vec::with_capacity(36); let mut add_face = |nx: f32, ny: f32, nz: f32, corners: [(f32, f32, f32); 4]| { let n = [nx, ny, nz]; From 49b393dd2cfbdb6b3853de73cdc38d99ed32db1d Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sun, 16 Nov 2025 16:36:36 -0800 Subject: [PATCH 12/25] [add] high level depth format implementation. --- crates/lambda-rs/examples/reflective_room.rs | 2 +- crates/lambda-rs/src/render/pipeline.rs | 22 ++++++++++---------- crates/lambda-rs/src/render/texture.rs | 22 ++++++++++++++++++++ 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/crates/lambda-rs/examples/reflective_room.rs b/crates/lambda-rs/examples/reflective_room.rs index ded5104d..a461f2a1 100644 --- a/crates/lambda-rs/examples/reflective_room.rs +++ b/crates/lambda-rs/examples/reflective_room.rs @@ -49,6 +49,7 @@ use lambda::{ ShaderKind, VirtualShader, }, + texture::DepthFormat, vertex::{ ColorFormat, Vertex, @@ -66,7 +67,6 @@ use lambda::{ ApplicationRuntimeBuilder, }, }; -use lambda_platform::wgpu::texture::DepthFormat; // ------------------------------ SHADER SOURCE -------------------------------- diff --git a/crates/lambda-rs/src/render/pipeline.rs b/crates/lambda-rs/src/render/pipeline.rs index 8d9c83e1..d9f098df 100644 --- a/crates/lambda-rs/src/render/pipeline.rs +++ b/crates/lambda-rs/src/render/pipeline.rs @@ -41,6 +41,7 @@ use super::{ buffer::Buffer, render_pass::RenderPass, shader::Shader, + texture, vertex::VertexAttribute, RenderContext, }; @@ -234,7 +235,7 @@ pub struct RenderPipelineBuilder { bind_group_layouts: Vec, label: Option, use_depth: bool, - depth_format: Option, + depth_format: Option, sample_count: u32, depth_compare: Option, stencil: Option, @@ -307,10 +308,7 @@ impl RenderPipelineBuilder { } /// Enable depth with an explicit depth format. - pub fn with_depth_format( - mut self, - format: platform_texture::DepthFormat, - ) -> Self { + pub fn with_depth_format(mut self, format: texture::DepthFormat) -> Self { self.use_depth = true; self.depth_format = Some(format); return self; @@ -457,23 +455,25 @@ impl RenderPipelineBuilder { } if self.use_depth { + // Engine-level depth format with default let mut dfmt = self .depth_format - .unwrap_or(platform_texture::DepthFormat::Depth32Float); + .unwrap_or(texture::DepthFormat::Depth32Float); // If stencil state is configured, ensure a stencil-capable depth format. if self.stencil.is_some() - && dfmt != platform_texture::DepthFormat::Depth24PlusStencil8 + && dfmt != texture::DepthFormat::Depth24PlusStencil8 { #[cfg(debug_assertions)] logging::error!( "Stencil configured but depth format {:?} lacks stencil; upgrading to Depth24PlusStencil8", dfmt ); - dfmt = platform_texture::DepthFormat::Depth24PlusStencil8; + dfmt = texture::DepthFormat::Depth24PlusStencil8; } - // Keep context depth format in sync for attachment creation. - render_context.depth_format = dfmt; - rp_builder = rp_builder.with_depth_stencil(dfmt); + // Map to platform and keep context depth format in sync for attachment creation. + let dfmt_platform = dfmt.to_platform(); + render_context.depth_format = dfmt_platform; + rp_builder = rp_builder.with_depth_stencil(dfmt_platform); if let Some(compare) = self.depth_compare { rp_builder = rp_builder.with_depth_compare(compare.to_platform()); } diff --git a/crates/lambda-rs/src/render/texture.rs b/crates/lambda-rs/src/render/texture.rs index 921825d9..acd87060 100644 --- a/crates/lambda-rs/src/render/texture.rs +++ b/crates/lambda-rs/src/render/texture.rs @@ -9,6 +9,28 @@ use lambda_platform::wgpu::texture as platform; use super::RenderContext; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Engine-level depth texture formats. +/// +/// Maps to platform depth formats without exposing `wgpu` in the public API. +pub enum DepthFormat { + Depth32Float, + Depth24Plus, + Depth24PlusStencil8, +} + +impl DepthFormat { + pub(crate) fn to_platform(self) -> platform::DepthFormat { + return match self { + DepthFormat::Depth32Float => platform::DepthFormat::Depth32Float, + DepthFormat::Depth24Plus => platform::DepthFormat::Depth24Plus, + DepthFormat::Depth24PlusStencil8 => { + platform::DepthFormat::Depth24PlusStencil8 + } + }; + } +} + #[derive(Debug, Clone, Copy)] /// Supported color texture formats for sampling. pub enum TextureFormat { From 70670f8ad6bb7ac14a62e7d5847bf21cfe13f665 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sun, 16 Nov 2025 16:36:55 -0800 Subject: [PATCH 13/25] [update] specification and add tutorial for the new demo. --- docs/specs/depth-stencil-msaa.md | 35 +-- docs/tutorials/README.md | 8 +- docs/tutorials/reflective-room.md | 406 ++++++++++++++++++++++++++++++ 3 files changed, 431 insertions(+), 18 deletions(-) create mode 100644 docs/tutorials/reflective-room.md diff --git a/docs/specs/depth-stencil-msaa.md b/docs/specs/depth-stencil-msaa.md index 41630877..e5fea3b1 100644 --- a/docs/specs/depth-stencil-msaa.md +++ b/docs/specs/depth-stencil-msaa.md @@ -3,13 +3,13 @@ title: "Depth/Stencil and Multi-Sample Rendering" document_id: "depth-stencil-msaa-2025-11-11" status: "draft" created: "2025-11-11T00:00:00Z" -last_updated: "2025-11-13T00:00:00Z" -version: "0.1.4" +last_updated: "2025-11-17T00:19:24Z" +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: "21d0a5b511144db31f10ee07b2efb640ca990daf" +repo_commit: "ceaf345777d871912b2f92ae629a34b8e6f8654a" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "rendering", "depth", "stencil", "msaa"] @@ -20,14 +20,15 @@ tags: ["spec", "rendering", "depth", "stencil", "msaa"] Summary - Add configurable depth testing/writes and multi-sample anti-aliasing (MSAA) to the high-level rendering API via builders, without exposing `wgpu` types. -- Provide strict validation at build time and predictable defaults to enable - 3D scenes and higher-quality rasterization in example and production code. +- Provide validation and predictable defaults to enable 3D scenes and + higher-quality rasterization in example and production code. ## Scope - Goals - Expose depth/stencil and multi-sample configuration on `RenderPassBuilder` - and `RenderPipelineBuilder` using `lambda-rs` types only. + and `RenderPipelineBuilder` using engine/platform types; `wgpu` types are + not exposed. - Validate device capabilities and configuration consistency at build time. - Define defaults for depth clear, compare operation, and sample count. - Map high-level configuration to `lambda-rs-platform` and `wgpu` internally. @@ -49,14 +50,14 @@ Summary ## Architecture Overview - High-level builders in `lambda-rs` collect depth/stencil and multi-sample - configuration using engine-defined types. + configuration using engine/platform types. - `lambda-rs-platform` translates those types into backend-specific representations for `wgpu` creation of textures, passes, and pipelines. ``` App Code └── lambda-rs (RenderPassBuilder / RenderPipelineBuilder) - └── DepthStencil + MultiSample config (engine types) + └── DepthStencil + MultiSample config (engine/platform types) └── lambda-rs-platform (mapping/validation) └── wgpu device/pipeline/pass ``` @@ -83,26 +84,30 @@ App Code - `RenderPipelineBuilder::with_multi_sample(u32) -> Self` - Example (engine types only) ```rust - use lambda_rs::render::{Color, DepthFormat, CompareFunction, DepthStencil, MultiSample}; + use lambda::render::render_pass::RenderPassBuilder; + use lambda::render::pipeline::{RenderPipelineBuilder, CompareFunction}; + use lambda::render::texture::DepthFormat; let pass = RenderPassBuilder::new() .with_clear_color([0.0, 0.0, 0.0, 1.0]) .with_depth_clear(1.0) .with_multi_sample(4) - .build(&render_context)?; + .build(&render_context); let pipeline = RenderPipelineBuilder::new() .with_multi_sample(4) .with_depth_format(DepthFormat::Depth32Float) .with_depth_compare(CompareFunction::Less) - .build(&mut render_context, &pass, &vertex_shader, Some(&fragment_shader))?; + .build(&mut render_context, &pass, &vertex_shader, Some(&fragment_shader)); ``` - Behavior - Defaults - - If `with_depth_stencil` is not called, the pass MUST NOT create a depth - attachment and depth testing is disabled. - - `DepthStencil.clear_value` defaults to `1.0` (furthest depth). - - `DepthStencil.compare` defaults to `CompareFunction::Less`. + - If depth is not requested on the pass (`with_depth*`), the pass MUST NOT + create a depth attachment and depth testing is disabled. + - Depth clear defaults to `1.0` when depth is enabled on the pass and no + explicit clear is provided. + - Pipeline depth compare defaults to `CompareFunction::Less` when depth is + enabled for a pipeline and no explicit compare is provided. - `MultiSample.sample_count` defaults to `1` (no multi-sampling). - Attachment creation - When depth is requested (`with_depth`/`with_depth_clear`), the pass MUST diff --git a/docs/tutorials/README.md b/docs/tutorials/README.md index 83898c57..a0b26228 100644 --- a/docs/tutorials/README.md +++ b/docs/tutorials/README.md @@ -3,13 +3,13 @@ title: "Tutorials Index" document_id: "tutorials-index-2025-10-17" status: "living" created: "2025-10-17T00:20:00Z" -last_updated: "2025-11-10T00:00:00Z" -version: "0.2.0" +last_updated: "2025-11-17T00:00:00Z" +version: "0.3.0" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "727dbe9b7706e273c525a6ca92426a1aba61cdb6" +repo_commit: "ceaf345777d871912b2f92ae629a34b8e6f8654a" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["index", "tutorials", "docs"] @@ -20,9 +20,11 @@ This index lists tutorials that teach specific engine tasks through complete, in - Uniform Buffers: Build a Spinning Triangle — [uniform-buffers.md](uniform-buffers.md) - Textured Quad: Sample a 2D Texture — [textured-quad.md](textured-quad.md) - Textured Cube: 3D Push Constants + 2D Sampling — [textured-cube.md](textured-cube.md) +- Reflective Room: Stencil Masked Reflections with MSAA — [reflective-room.md](reflective-room.md) Browse all tutorials in this directory. Changelog +- 0.3.0 (2025-11-17): Add Reflective Room tutorial; update metadata and commit. - 0.2.0 (2025-11-10): Add links for textured quad and textured cube; update metadata and commit. - 0.1.0 (2025-10-17): Initial index with uniform buffers tutorial. diff --git a/docs/tutorials/reflective-room.md b/docs/tutorials/reflective-room.md new file mode 100644 index 00000000..e58d9315 --- /dev/null +++ b/docs/tutorials/reflective-room.md @@ -0,0 +1,406 @@ +--- +title: "Reflective Room: Stencil Masked Reflections with MSAA" +document_id: "reflective-room-tutorial-2025-11-17" +status: "draft" +created: "2025-11-17T00:00:00Z" +last_updated: "2025-11-17T00: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: "ceaf345777d871912b2f92ae629a34b8e6f8654a" +owners: ["lambda-sh"] +reviewers: ["engine", "rendering"] +tags: ["tutorial", "graphics", "stencil", "depth", "msaa", "mirror", "3d", "push-constants", "wgpu", "rust"] +--- + +## Overview +This tutorial builds a reflective floor using the stencil buffer with an optional depth test and 4× multi‑sample anti‑aliasing (MSAA). The scene renders in four phases: a floor mask into stencil, a mirrored cube clipped by the mask, a tinted floor surface, and a normal cube above the plane. + +Reference implementation: `crates/lambda-rs/examples/reflective_room.rs`. + +## 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 — Shaders and Push Constants](#step-2) + - [Step 3 — Meshes: Cube and Floor](#step-3) + - [Step 4 — Render Passes: Mask and Color](#step-4) + - [Step 5 — Pipeline: Floor Mask (Stencil Write)](#step-5) + - [Step 6 — Pipeline: Reflected Cube (Stencil Test)](#step-6) + - [Step 7 — Pipeline: Floor Visual (Tinted)](#step-7) + - [Step 8 — Pipeline: Normal Cube](#step-8) + - [Step 9 — Per‑Frame Transforms and Reflection](#step-9) + - [Step 10 — Record Commands and Draw Order](#step-10) + - [Step 11 — Input, MSAA/Depth/Stencil Toggles, and Resize](#step-11) +- [Validation](#validation) +- [Notes](#notes) +- [Conclusion](#conclusion) +- [Putting It Together](#putting-it-together) +- [Exercises](#exercises) +- [Changelog](#changelog) + +## Goals + +- Use the stencil buffer to restrict rendering to the floor area and show a mirrored reflection of a cube. +- Support depth testing and 4× MSAA to improve geometric correctness and edge quality. +- Drive transforms via push constants for model‑view‑projection (MVP) and model matrices. +- Provide runtime toggles for MSAA, stencil, and depth testing. + +## Prerequisites +- Build the workspace: `cargo build --workspace`. +- Run an example to verify setup: `cargo run --example minimal`. + +## Requirements and Constraints +- A pipeline that uses stencil state MUST render into a pass with a depth‑stencil attachment. Use `DepthFormat::Depth24PlusStencil8`. +- The mask pass MUST disable depth writes and write stencil with `Replace` so the floor area becomes `1`. +- The reflected cube pipeline MUST test stencil `Equal` against reference `1` and SHOULD set stencil write mask to `0x00`. +- Mirroring across the floor plane flips face winding. Culling MUST be disabled for the reflected draw or the front‑face definition MUST be adjusted. This example disables culling. +- Push constant size and stage visibility MUST match the shader declaration. Two `mat4` values are sent to the vertex stage only (128 bytes total). +- Matrix order MUST match the shader’s expectation. The example transposes matrices before upload to match GLSL column‑major multiplication. +- The render pass and pipelines MUST use the same sample count when MSAA is enabled. +- Acronyms: graphics processing unit (GPU), central processing unit (CPU), multi‑sample anti‑aliasing (MSAA), model‑view‑projection (MVP). + +## Data Flow + +``` +CPU (meshes, elapsed time, toggles) + │ + ├─ Build/attach render passes (mask, color) with MSAA + ├─ Build pipelines (mask → reflected → floor → normal) + ▼ +Pass 1: Depth/Stencil‑only (no color) — write stencil where floor covers + │ stencil = 1 inside floor, 0 elsewhere; depth write off + ▼ +Pass 2: Color (with depth/stencil) — draw reflected cube with stencil test == 1 + │ culling off; depth compare configurable + ▼ +Pass 3: Color — draw tinted floor (alpha) to show reflection + ▼ +Pass 4: Color — draw normal cube above the floor +``` + +## Implementation Steps + +### Step 1 — Runtime and Component Skeleton +Define a `Component` that owns shaders, meshes, render passes, pipelines, window size, elapsed time, and user‑toggleable settings for MSAA, stencil, and depth testing. + +```rust +use lambda::{ + component::Component, + runtimes::{application::ComponentResult, ApplicationRuntimeBuilder}, +}; + +pub struct ReflectiveRoomExample { + shader_vs: lambda::render::shader::Shader, + shader_fs_lit: lambda::render::shader::Shader, + shader_fs_floor: lambda::render::shader::Shader, + cube_mesh: Option, + floor_mesh: Option, + pass_id_mask: Option, + pass_id_color: Option, + pipe_floor_mask: Option, + pipe_reflected: Option, + pipe_floor_visual: Option, + pipe_normal: Option, + width: u32, + height: u32, + elapsed: f32, + msaa_samples: u32, + stencil_enabled: bool, + depth_test_enabled: bool, + needs_rebuild: bool, +} + +impl Default for ReflectiveRoomExample { /* create shaders; set defaults */ } +impl Component for ReflectiveRoomExample { /* lifecycle */ } +``` + +Narrative: The component stores GPU handles and toggles. When settings change, mark `needs_rebuild = true` and rebuild pipelines/passes on the next frame. + +### Step 2 — Shaders and Push Constants +Use one vertex shader and two fragment shaders. The vertex shader expects push constants with two `mat4` values: the MVP and the model matrix, used to transform positions and rotate normals to world space. + +```glsl +// Vertex (GLSL 450) +layout (location = 0) in vec3 vertex_position; +layout (location = 1) in vec3 vertex_normal; +layout (location = 0) out vec3 v_world_normal; + +layout ( push_constant ) uniform Push { mat4 mvp; mat4 model; } pc; + +void main() { + gl_Position = pc.mvp * vec4(vertex_position, 1.0); + v_world_normal = mat3(pc.model) * vertex_normal; +} +``` + +```glsl +// Fragment (lit) +layout (location = 0) in vec3 v_world_normal; +layout (location = 0) out vec4 fragment_color; +void main() { + vec3 N = normalize(v_world_normal); + vec3 L = normalize(vec3(0.4, 0.7, 1.0)); + float diff = max(dot(N, L), 0.0); + vec3 base = vec3(0.2, 0.6, 0.9); + fragment_color = vec4(base * (0.25 + 0.75 * diff), 1.0); +} +``` + +```glsl +// Fragment (floor tint) +layout (location = 0) out vec4 fragment_color; +void main() { fragment_color = vec4(0.1, 0.1, 0.12, 0.5); } +``` + +In Rust, pack push constants as 32‑bit words and transpose matrices before upload. + +```rust +#[repr(C)] +pub struct PushConstant { pub mvp: [[f32; 4]; 4], pub model: [[f32; 4]; 4] } + +pub fn push_constants_to_words(pc: &PushConstant) -> &[u32] { + unsafe { + let size = std::mem::size_of::() / std::mem::size_of::(); + let ptr = pc as *const PushConstant as *const u32; + return std::slice::from_raw_parts(ptr, size); + } +} +``` + +### Step 3 — Meshes: Cube and Floor +Build a unit cube (36 vertices) with per‑face normals and a large XZ floor quad at `y = 0`. Provide matching vertex attributes for position and normal at locations 0 and 1. + +Reference: `crates/lambda-rs/examples/reflective_room.rs:740` and `crates/lambda-rs/examples/reflective_room.rs:807`. + +### Step 4 — Render Passes: Mask and Color +Create a depth/stencil‑only pass for the floor mask and a color pass for the scene. Use the same sample count on both. + +```rust +use lambda::render::render_pass::RenderPassBuilder; + +let pass_mask = RenderPassBuilder::new() + .with_label("reflective-room-pass-mask") + .with_depth_clear(1.0) + .with_stencil_clear(0) + .with_multi_sample(msaa_samples) + .without_color() // no color target + .build(ctx); + +let pass_color = RenderPassBuilder::new() + .with_label("reflective-room-pass-color") + .with_multi_sample(msaa_samples) + .with_depth_clear(1.0) // or .with_depth_load() when depth test is off + .with_stencil_load() // preserve mask from pass 1 + .build(ctx); +``` + +Rationale: pipelines that use stencil require a depth‑stencil attachment, even if depth testing is disabled. + +### Step 5 — Pipeline: Floor Mask (Stencil Write) +Draw the floor geometry to write `stencil = 1` where the floor covers. Do not write to color. Disable depth writes and set depth compare to `Always`. + +```rust +use lambda::render::pipeline::{RenderPipelineBuilder, CompareFunction, StencilState, StencilFaceState, StencilOperation, PipelineStage}; + +let pipe_floor_mask = RenderPipelineBuilder::new() + .with_label("floor-mask") + .with_depth_format(lambda::render::texture::DepthFormat::Depth24PlusStencil8) + .with_depth_write(false) + .with_depth_compare(CompareFunction::Always) + .with_push_constant(PipelineStage::VERTEX, std::mem::size_of::() as u32) + .with_buffer(floor_vertex_buffer, floor_attributes) + .with_stencil(StencilState { + front: StencilFaceState { compare: CompareFunction::Always, fail_op: StencilOperation::Keep, depth_fail_op: StencilOperation::Keep, pass_op: StencilOperation::Replace }, + back: StencilFaceState { compare: CompareFunction::Always, fail_op: StencilOperation::Keep, depth_fail_op: StencilOperation::Keep, pass_op: StencilOperation::Replace }, + read_mask: 0xFF, write_mask: 0xFF, + }) + .with_multi_sample(msaa_samples) + .build(ctx, &pass_mask, &shader_vs, None); +``` + +### Step 6 — Pipeline: Reflected Cube (Stencil Test) +Render the mirrored cube only where the floor mask is present. Disable culling to avoid flipped winding after mirroring. Preserve depth testing configuration via toggles. + +```rust +let mut builder = RenderPipelineBuilder::new() + .with_label("reflected-cube") + .with_culling(lambda::render::pipeline::CullingMode::None) + .with_depth_format(lambda::render::texture::DepthFormat::Depth24PlusStencil8) + .with_push_constant(PipelineStage::VERTEX, std::mem::size_of::() as u32) + .with_buffer(cube_vertex_buffer, cube_attributes) + .with_stencil(StencilState { + front: StencilFaceState { compare: CompareFunction::Equal, fail_op: StencilOperation::Keep, depth_fail_op: StencilOperation::Keep, pass_op: StencilOperation::Keep }, + back: StencilFaceState { compare: CompareFunction::Equal, fail_op: StencilOperation::Keep, depth_fail_op: StencilOperation::Keep, pass_op: StencilOperation::Keep }, + read_mask: 0xFF, write_mask: 0x00, + }) + .with_multi_sample(msaa_samples); + +builder = if depth_test_enabled { + builder.with_depth_write(true).with_depth_compare(CompareFunction::LessEqual) +} else { + builder.with_depth_write(false).with_depth_compare(CompareFunction::Always) +}; + +let pipe_reflected = builder.build(ctx, &pass_color, &shader_vs, Some(&shader_fs_lit)); +``` + +### Step 7 — Pipeline: Floor Visual (Tinted) +Draw the floor surface with a translucent tint so the reflection remains visible beneath. + +```rust +let mut floor_vis = RenderPipelineBuilder::new() + .with_label("floor-visual") + .with_push_constant(PipelineStage::VERTEX, std::mem::size_of::() as u32) + .with_buffer(floor_vertex_buffer, floor_attributes) + .with_multi_sample(msaa_samples); + +if depth_test_enabled || stencil_enabled { + floor_vis = floor_vis + .with_depth_format(lambda::render::texture::DepthFormat::Depth24PlusStencil8) + .with_depth_write(false) + .with_depth_compare(if depth_test_enabled { CompareFunction::LessEqual } else { CompareFunction::Always }); +} + +let pipe_floor_visual = floor_vis.build(ctx, &pass_color, &shader_vs, Some(&shader_fs_floor)); +``` + +### Step 8 — Pipeline: Normal Cube +Draw the unreflected cube above the floor using the lit fragment shader. Enable back‑face culling and depth testing when requested. + +```rust +let mut normal = RenderPipelineBuilder::new() + .with_label("cube-normal") + .with_push_constant(PipelineStage::VERTEX, std::mem::size_of::() as u32) + .with_buffer(cube_vertex_buffer, cube_attributes) + .with_multi_sample(msaa_samples); + +if depth_test_enabled || stencil_enabled { + normal = normal + .with_depth_format(lambda::render::texture::DepthFormat::Depth24PlusStencil8) + .with_depth_write(depth_test_enabled) + .with_depth_compare(if depth_test_enabled { CompareFunction::Less } else { CompareFunction::Always }); +} + +let pipe_normal = normal.build(ctx, &pass_color, &shader_vs, Some(&shader_fs_lit)); +``` + +### Step 9 — Per‑Frame Transforms and Reflection +Compute camera, model rotation, and the mirror transform across the floor plane. The reflection scales Y by −1 and translates downward to align with the mirrored cube below the floor. + +```rust +use lambda::render::scene_math::{compute_perspective_projection, compute_view_matrix, SimpleCamera}; + +let camera = SimpleCamera { position: [0.0, 1.2, 3.5], field_of_view_in_turns: 0.24, near_clipping_plane: 0.1, far_clipping_plane: 100.0 }; +let view = compute_view_matrix(camera.position); +let projection = compute_perspective_projection(camera.field_of_view_in_turns, width.max(1), height.max(1), camera.near_clipping_plane, camera.far_clipping_plane); + +let angle_y = 0.12 * elapsed; +let mut model = lambda::math::matrix::identity_matrix(4, 4); +model = lambda::math::matrix::rotate_matrix(model, [0.0, 1.0, 0.0], angle_y); +model = model.multiply(&lambda::math::matrix::translation_matrix([0.0, 0.5, 0.0])); +let mvp = projection.multiply(&view).multiply(&model); + +let mirror = [ [1.0, 0.0, 0.0, 0.0], [0.0, -1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0] ]; +let mut model_reflect = lambda::math::matrix::identity_matrix(4, 4).multiply(&mirror); +model_reflect = lambda::math::matrix::rotate_matrix(model_reflect, [0.0, 1.0, 0.0], angle_y); +model_reflect = model_reflect.multiply(&lambda::math::matrix::translation_matrix([0.0, -0.5, 0.0])); +let mvp_reflect = projection.multiply(&view).multiply(&model_reflect); +``` + +### Step 10 — Record Commands and Draw Order +Record commands in the following order. Set `viewport` and `scissor` to the window dimensions. + +```rust +use lambda::render::command::RenderCommand; +use lambda::render::pipeline::PipelineStage; + +let mut cmds: Vec = Vec::new(); + +// Pass 1: floor stencil mask +cmds.push(RenderCommand::BeginRenderPass { render_pass: pass_id_mask, viewport }); +cmds.push(RenderCommand::SetPipeline { pipeline: pipe_floor_mask }); +cmds.push(RenderCommand::SetStencilReference { reference: 1 }); +cmds.push(RenderCommand::BindVertexBuffer { pipeline: pipe_floor_mask, buffer: 0 }); +cmds.push(RenderCommand::PushConstants { pipeline: pipe_floor_mask, stage: PipelineStage::VERTEX, offset: 0, bytes: Vec::from(push_constants_to_words(&PushConstant { mvp: mvp_floor.transpose(), model: model_floor.transpose() })) }); +cmds.push(RenderCommand::Draw { vertices: 0..floor_vertex_count }); +cmds.push(RenderCommand::EndRenderPass); + +// Pass 2: reflected cube (stencil test == 1) +cmds.push(RenderCommand::BeginRenderPass { render_pass: pass_id_color, viewport }); +cmds.push(RenderCommand::SetPipeline { pipeline: pipe_reflected }); +cmds.push(RenderCommand::SetStencilReference { reference: 1 }); +cmds.push(RenderCommand::BindVertexBuffer { pipeline: pipe_reflected, buffer: 0 }); +cmds.push(RenderCommand::PushConstants { pipeline: pipe_reflected, stage: PipelineStage::VERTEX, offset: 0, bytes: Vec::from(push_constants_to_words(&PushConstant { mvp: mvp_reflect.transpose(), model: model_reflect.transpose() })) }); +cmds.push(RenderCommand::Draw { vertices: 0..cube_vertex_count }); + +// Pass 3: floor visual (tinted) +cmds.push(RenderCommand::SetPipeline { pipeline: pipe_floor_visual }); +cmds.push(RenderCommand::BindVertexBuffer { pipeline: pipe_floor_visual, buffer: 0 }); +cmds.push(RenderCommand::PushConstants { pipeline: pipe_floor_visual, stage: PipelineStage::VERTEX, offset: 0, bytes: Vec::from(push_constants_to_words(&PushConstant { mvp: mvp_floor.transpose(), model: model_floor.transpose() })) }); +cmds.push(RenderCommand::Draw { vertices: 0..floor_vertex_count }); + +// Pass 4: normal cube +cmds.push(RenderCommand::SetPipeline { pipeline: pipe_normal }); +cmds.push(RenderCommand::BindVertexBuffer { pipeline: pipe_normal, buffer: 0 }); +cmds.push(RenderCommand::PushConstants { pipeline: pipe_normal, stage: PipelineStage::VERTEX, offset: 0, bytes: Vec::from(push_constants_to_words(&PushConstant { mvp: mvp.transpose(), model: model.transpose() })) }); +cmds.push(RenderCommand::Draw { vertices: 0..cube_vertex_count }); +cmds.push(RenderCommand::EndRenderPass); +``` + +### Step 11 — Input, MSAA/Depth/Stencil Toggles, and Resize +Support runtime toggles to observe the impact of each setting: + +- `M` toggles MSAA between `1×` and `4×`. Rebuild passes and pipelines when it changes. +- `S` toggles the stencil reflection. When disabled, skip the mask and reflected draw. +- `D` toggles depth testing. When disabled, set depth compare to `Always` and disable depth writes on pipelines. +- On window resize, update stored `width` and `height` and use them when computing the viewport and projection matrix. + +Reference: `crates/lambda-rs/examples/reflective_room.rs:164`. + +## Validation + +- Build and run: `cargo run --example reflective_room`. +- Expected behavior: + - A cube rotates above a reflective floor. The reflection appears only inside the floor area and shows correct mirroring. + - Press `S` to toggle the reflection (stencil). The reflected cube disappears when stencil is off. + - Press `D` to toggle depth testing. With depth off, the reflection still clips to the floor via stencil, but depth ordering may show artifacts if geometry overlaps. + - Press `M` to toggle MSAA. With `4×` MSAA, edges appear smoother; with `1×`, edges appear more aliased. + +## Notes + +- Pipelines that use stencil MUST target a pass with a depth‑stencil attachment; otherwise, pipeline creation or draws will fail. +- Mirroring across a plane flips winding. Either disable culling or adjust front‑face winding for the reflected draw; do not leave back‑face culling enabled with mirrored geometry. +- The mask pass SHOULD clear stencil to `0` and write `1` where the floor renders. Use `Replace` and a write mask of `0xFF`. +- The reflected draw SHOULD use `read_mask = 0xFF`, `write_mask = 0x00`, and `reference = 1` to preserve the mask. +- When depth testing is disabled, set `depth_compare = Always` and `depth_write = false` to avoid unintended depth interactions. +- The pass and all pipelines in the pass MUST use the same MSAA sample count. +- Transpose matrices before uploading when GLSL expects column‑major multiplication. + +## Conclusion + +The reflective floor combines a simple stencil mask with an optional depth test and MSAA to produce a convincing planar reflection. The draw order and precise stencil state are critical: write the mask first, draw the mirrored geometry with a strict stencil test, render a translucent floor, and then render normal scene geometry. + +## Putting It Together + +- Full reference: `crates/lambda-rs/examples/reflective_room.rs`. + +## Exercises + +- Replace the cube with a sphere and observe differences in mirrored normals. +- Move the floor plane to `y = k` and update the mirror transform accordingly. +- Add a blend mode change on the floor to experiment with different reflection intensities. +- Switch stencil reference values to render multiple reflective regions on the floor. +- Re‑enable back‑face culling for the reflected draw and adjust front‑face winding to match the mirrored transform. +- Add a checkerboard texture to the floor and render the reflection beneath it using the same mask. +- Extend the example to toggle a mirrored XZ room (two planes) using different reference values. + +## Changelog + +- 0.1.0 (2025‑11‑17): Initial draft aligned with `crates/lambda-rs/examples/reflective_room.rs`, including stencil mask pass, reflected pipeline, and MSAA/depth toggles. From 709054fcc1fa678ac5f0611877b759bad93decd0 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sun, 16 Nov 2025 18:37:17 -0800 Subject: [PATCH 14/25] [add] features to optionally enable validation checks in production builds. --- crates/lambda-rs/Cargo.toml | 37 +++++++++++++++ crates/lambda-rs/src/render/mod.rs | 35 ++++++++++---- crates/lambda-rs/src/render/pipeline.rs | 55 ++++++++++------------ crates/lambda-rs/src/render/render_pass.rs | 38 +++++++++------ 4 files changed, 112 insertions(+), 53 deletions(-) diff --git a/crates/lambda-rs/Cargo.toml b/crates/lambda-rs/Cargo.toml index 117b996b..8801049b 100644 --- a/crates/lambda-rs/Cargo.toml +++ b/crates/lambda-rs/Cargo.toml @@ -37,6 +37,43 @@ with-shaderc-build-from-source=[ "lambda-rs-platform/shader-backend-shaderc-build-from-source", ] +# ------------------------------ RENDER VALIDATION ----------------------------- +# Granular, opt-in validation flags for release builds. Debug builds enable +# all validations unconditionally via `debug_assertions`. + +# Umbrella features +# - render-validation: enable common, configuration-time checks and logs +# - render-validation-strict: includes render-validation + per-draw checks +# - render-validation-all: enables all validations including device probing +render-validation = [ + "render-validation-msaa", + "render-validation-depth", + "render-validation-stencil", +] +render-validation-strict = [ + "render-validation", + "render-validation-pass-compat", + "render-validation-encoder", +] +render-validation-all = [ + "render-validation-strict", + "render-validation-device", +] + +# Granular feature flags +# - msaa: sample count validity and mismatch logging +# - depth: depth clear clamping log and depth advisories +# - stencil: stencil/format upgrade advisories +# - pass-compat: color/depth presence compatibility checks at SetPipeline +# - device: device/format probing advisories (may consult platform) +# - encoder: per-draw/encoder-time checks (most expensive) +render-validation-msaa = [] +render-validation-depth = [] +render-validation-stencil = [] +render-validation-pass-compat = [] +render-validation-device = [] +render-validation-encoder = [] + # ---------------------------- PLATFORM DEPENDENCIES --------------------------- diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index 8c4d93e0..d36558ee 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -471,7 +471,10 @@ impl RenderContext { && self.depth_format != platform::texture::DepthFormat::Depth24PlusStencil8 { - #[cfg(debug_assertions)] + #[cfg(any( + debug_assertions, + feature = "render-validation-stencil", + ))] logging::error!( "Render pass has stencil ops but depth format {:?} lacks stencil; upgrading to Depth24PlusStencil8", self.depth_format @@ -601,10 +604,18 @@ impl RenderContext { I: Iterator, { Self::apply_viewport(pass, &initial_viewport); - // De-duplicate advisories within this pass (debug builds only) - #[cfg(debug_assertions)] + // De-duplicate advisories within this pass + #[cfg(any( + debug_assertions, + feature = "render-validation-depth", + feature = "render-validation-stencil", + ))] let mut warned_no_stencil_for_pipeline: HashSet = HashSet::new(); - #[cfg(debug_assertions)] + #[cfg(any( + debug_assertions, + feature = "render-validation-depth", + feature = "render-validation-stencil", + ))] let mut warned_no_depth_for_pipeline: HashSet = HashSet::new(); while let Some(command) = commands.next() { @@ -620,8 +631,12 @@ impl RenderContext { "Unknown pipeline {pipeline}" )); })?; - // Validate pass/pipeline compatibility before deferring to the platform (debug only). - #[cfg(debug_assertions)] + // Validate pass/pipeline compatibility before deferring to the platform. + #[cfg(any( + debug_assertions, + feature = "render-validation-pass-compat", + feature = "render-validation-encoder", + ))] { if !uses_color && pipeline_ref.has_color_targets() { let label = pipeline_ref.pipeline().label().unwrap_or("unnamed"); @@ -645,8 +660,12 @@ impl RenderContext { ))); } } - // Advisory checks to help reason about stencil/depth behavior (debug only). - #[cfg(debug_assertions)] + // Advisory checks to help reason about stencil/depth behavior. + #[cfg(any( + debug_assertions, + feature = "render-validation-depth", + feature = "render-validation-stencil", + ))] { if pass_has_stencil && !pipeline_ref.uses_stencil() diff --git a/crates/lambda-rs/src/render/pipeline.rs b/crates/lambda-rs/src/render/pipeline.rs index d9f098df..f33ca05b 100644 --- a/crates/lambda-rs/src/render/pipeline.rs +++ b/crates/lambda-rs/src/render/pipeline.rs @@ -316,26 +316,20 @@ impl RenderPipelineBuilder { /// Configure multi-sampling for this pipeline. pub fn with_multi_sample(mut self, samples: u32) -> Self { - #[cfg(debug_assertions)] - { - match validation::validate_sample_count(samples) { - Ok(()) => { - self.sample_count = samples; - } - Err(msg) => { + // Always apply a cheap validity check; log under feature/debug gates. + if matches!(samples, 1 | 2 | 4 | 8) { + self.sample_count = samples; + } else { + #[cfg(any(debug_assertions, feature = "render-validation-msaa",))] + { + if let Err(msg) = validation::validate_sample_count(samples) { logging::error!( "{}; falling back to sample_count=1 for pipeline", msg ); - self.sample_count = 1; } } - } - #[cfg(not(debug_assertions))] - { - // In release builds, accept the provided sample count without extra - // engine-level validation. Platform validation MAY still apply. - self.sample_count = samples; + self.sample_count = 1; } return self; } @@ -463,7 +457,7 @@ impl RenderPipelineBuilder { if self.stencil.is_some() && dfmt != texture::DepthFormat::Depth24PlusStencil8 { - #[cfg(debug_assertions)] + #[cfg(any(debug_assertions, feature = "render-validation-stencil",))] logging::error!( "Stencil configured but depth format {:?} lacks stencil; upgrading to Depth24PlusStencil8", dfmt @@ -486,23 +480,24 @@ impl RenderPipelineBuilder { } // Apply multi-sampling to the pipeline. - // In debug builds, validate and align with the render pass; in release, - // defer to platform validation without engine-side checks. + // Always align to the pass sample count; gate logs. let mut pipeline_samples = self.sample_count; - #[cfg(debug_assertions)] - { - let pass_samples = _render_pass.sample_count(); - if pipeline_samples != pass_samples { - logging::error!( - "Pipeline sample_count={} does not match pass sample_count={}; aligning to pass", - pipeline_samples, - pass_samples - ); - pipeline_samples = pass_samples; - } - if validation::validate_sample_count(pipeline_samples).is_err() { - pipeline_samples = 1; + let pass_samples = _render_pass.sample_count(); + if pipeline_samples != pass_samples { + #[cfg(any(debug_assertions, feature = "render-validation-msaa",))] + logging::error!( + "Pipeline sample_count={} does not match pass sample_count={}; aligning to pass", + pipeline_samples, + pass_samples + ); + pipeline_samples = pass_samples; + } + if !matches!(pipeline_samples, 1 | 2 | 4 | 8) { + #[cfg(any(debug_assertions, feature = "render-validation-msaa",))] + { + let _ = validation::validate_sample_count(pipeline_samples); } + pipeline_samples = 1; } rp_builder = rp_builder.with_sample_count(pipeline_samples); diff --git a/crates/lambda-rs/src/render/render_pass.rs b/crates/lambda-rs/src/render/render_pass.rs index 08790965..c42e34cf 100644 --- a/crates/lambda-rs/src/render/render_pass.rs +++ b/crates/lambda-rs/src/render/render_pass.rs @@ -201,8 +201,21 @@ impl RenderPassBuilder { /// Enable a depth attachment with an explicit clear value. pub fn with_depth_clear(mut self, clear: f64) -> Self { + // Clamp to the valid range [0.0, 1.0] unconditionally. + let clamped = clear.clamp(0.0, 1.0); + // Optionally log when clamping is applied. + #[cfg(any(debug_assertions, feature = "render-validation-depth",))] + { + if (clamped - clear).abs() > f64::EPSILON { + logging::warn!( + "Depth clear value {} out of range [0,1]; clamped to {}", + clear, + clamped + ); + } + } self.depth_operations = Some(DepthOperations { - load: DepthLoadOp::Clear(clear), + load: DepthLoadOp::Clear(clamped), store: StoreOp::Store, }); return self; @@ -243,26 +256,21 @@ impl RenderPassBuilder { /// Configure multi-sample anti-aliasing for this pass. pub fn with_multi_sample(mut self, samples: u32) -> Self { - #[cfg(debug_assertions)] - { - match validation::validate_sample_count(samples) { - Ok(()) => { - self.sample_count = samples; - } - Err(msg) => { + // Always apply a cheap validity check; log under feature/debug gates. + let allowed = matches!(samples, 1 | 2 | 4 | 8); + if allowed { + self.sample_count = samples; + } else { + #[cfg(any(debug_assertions, feature = "render-validation-msaa",))] + { + if let Err(msg) = validation::validate_sample_count(samples) { logging::error!( "{}; falling back to sample_count=1 for render pass", msg ); - self.sample_count = 1; } } - } - #[cfg(not(debug_assertions))] - { - // In release builds, accept the provided sample count without engine - // validation; platform validation MAY still apply. - self.sample_count = samples; + self.sample_count = 1; } return self; } From 4c3c4e8a20030a29ca94a7a0c21aa3719b3321dd Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sun, 16 Nov 2025 18:50:26 -0800 Subject: [PATCH 15/25] [add] features documentation for all features and update specification to include feature flags added for depth, stencil, and msaa validation. --- docs/features.md | 79 ++++++++++++++++++++++++++++++++ docs/specs/depth-stencil-msaa.md | 30 +++++++++--- 2 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 docs/features.md diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 00000000..04785f79 --- /dev/null +++ b/docs/features.md @@ -0,0 +1,79 @@ +--- +title: "Cargo Features Overview" +document_id: "features-2025-11-17" +status: "living" +created: "2025-11-17T23:59:00Z" +last_updated: "2025-11-17T23:59: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: "70670f8ad6bb7ac14a62e7d5847bf21cfe13f665" +owners: ["lambda-sh"] +reviewers: ["engine", "rendering"] +tags: ["guide", "features", "validation", "cargo"] +--- + +## Overview +This document enumerates the primary Cargo features exposed by the workspace relevant to rendering and validation behavior. It defines defaults, relationships, and expected behavior in debug and release builds. + +## Table of Contents +- [Overview](#overview) +- [Defaults](#defaults) +- [Rendering Backends](#rendering-backends) +- [Shader Backends](#shader-backends) +- [Render Validation](#render-validation) +- [Changelog](#changelog) + +## Defaults +- Workspace defaults prefer `wgpu` on supported platforms and `naga` for shader compilation. +- Debug builds enable all validations unconditionally via `debug_assertions`. +- Release builds enable only cheap safety checks by default; validation logs and per-draw checks MUST be enabled explicitly via features. + +## Rendering Backends +- `lambda-rs` + - `with-wgpu` (default): enables the `wgpu` platform backend via `lambda-rs-platform`. + - Platform specializations: `with-wgpu-vulkan`, `with-wgpu-metal`, `with-wgpu-dx12`, `with-wgpu-gl`. + +## Shader Backends +- `lambda-rs-platform` + - `shader-backend-naga` (default): uses `naga` for shader handling. + - `shader-backend-shaderc`: uses `shaderc`; optional `shader-backend-shaderc-build-from-source`. + +## Render Validation + +Umbrella features (crate: `lambda-rs`) +- `render-validation`: enables common builder/pipeline validation logs (MSAA counts, depth clear advisories, stencil format upgrades). +- `render-validation-strict`: includes `render-validation` and enables per-draw SetPipeline-time compatibility checks. +- `render-validation-all`: superset of `render-validation-strict` and enables device-probing advisories. + +Granular features (crate: `lambda-rs`) +- `render-validation-msaa`: validates/logs MSAA sample counts; logs pass/pipeline sample mismatches. Behavior: + - Builder APIs clamp invalid MSAA counts to `1`. + - Pipelines align `sample_count` to the pass `sample_count`. +- `render-validation-depth`: logs when clamping depth clear to `[0.0, 1.0]`; adds depth usage advisories when a pass has depth but the pipeline does not. +- `render-validation-stencil`: logs when enabling stencil requires upgrading the depth format to `Depth24PlusStencil8`; warns about stencil usage mismatches. +- `render-validation-pass-compat`: SetPipeline-time errors when color targets or depth/stencil expectations do not match the active pass. +- `render-validation-device`: device/format probing advisories (if available via the platform layer). +- `render-validation-encoder`: additional per-draw/encoder-time checks; highest runtime cost. + +Always-on safeguards (debug and release) +- Clamp depth clear values to `[0.0, 1.0]`. +- Align pipeline `sample_count` to the active pass `sample_count`. +- Clamp invalid MSAA sample counts to `1`. + +Behavior by build type +- Debug (`debug_assertions`): all validations active regardless of features. +- Release: validations are active only when the corresponding feature is enabled; safeguards above remain active. + +Usage examples +- Enable common validations in release: + - `cargo build -p lambda-rs --features render-validation` +- Enable strict compatibility checks in release: + - `cargo run -p lambda-rs --features render-validation-strict` +- Enable only MSAA validation in release: + - `cargo test -p lambda-rs --features render-validation-msaa` + +## Changelog +- 0.1.0 (2025-11-17): Initial document introducing validation features and behavior by build type. diff --git a/docs/specs/depth-stencil-msaa.md b/docs/specs/depth-stencil-msaa.md index e5fea3b1..f07423f8 100644 --- a/docs/specs/depth-stencil-msaa.md +++ b/docs/specs/depth-stencil-msaa.md @@ -3,13 +3,13 @@ title: "Depth/Stencil and Multi-Sample Rendering" document_id: "depth-stencil-msaa-2025-11-11" status: "draft" created: "2025-11-11T00:00:00Z" -last_updated: "2025-11-17T00:19:24Z" -version: "0.2.0" +last_updated: "2025-11-17T23:59:59Z" +version: "0.3.1" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "ceaf345777d871912b2f92ae629a34b8e6f8654a" +repo_commit: "709054fcc1fa678ac5f0611877b759bad93decd0" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "rendering", "depth", "stencil", "msaa"] @@ -146,6 +146,21 @@ App Code relies on caller-provided sane values and `wgpu` validation. A strict check MAY be added in a follow-up. +### Validation Feature Flags + +- Debug builds: all validations are enabled unconditionally (`debug_assertions`). +- Release builds: only cheap safety checks remain always-on; logging and + per-draw checks are controlled by Cargo features on `lambda-rs`. +- Feature flags + - `render-validation-msaa`: validate/log MSAA counts; pass/pipeline mismatch logs. + - `render-validation-depth`: clamp/log depth clear; depth usage advisories. + - `render-validation-stencil`: stencil usage/format upgrade advisories. + +Always-on safeguards (release and debug) +- Clamp depth clear to `[0.0, 1.0]`. +- Align pipeline `sample_count` to the pass `sample_count`. +- Clamp invalid MSAA sample counts to `1`. + ## Constraints and Rules - Multi-sample `sample_count` MUST be one of the device-supported counts. It is @@ -184,9 +199,9 @@ App Code MSAA - [x] Commands: set stencil reference; existing draw/bind/viewport remain - Validation and Errors - - [ ] Sample counts limited to {1,2,4,8}; invalid → log + clamp to 1 - - [ ] Pass/pipeline sample mismatch → align to pass + log - - [ ] Depth clear in [0.0, 1.0] (SHOULD validate); device support (SHOULD) + - [x] Sample counts limited to {1,2,4,8}; invalid → clamp to 1 (log via features) + - [x] Pass/pipeline sample mismatch → align to pass (log via features) + - [x] Depth clear clamped to [0.0, 1.0] (log via features); device support (SHOULD) - Performance - [ ] 4x MSAA guidance; memory trade-offs for `Depth32Float` vs `Depth24Plus` - [ ] Recommend disabling depth writes for overlays/transparency @@ -219,7 +234,8 @@ path that demonstrates the implementation. defaults (no depth, no multi-sampling) unless explicitly configured. ## Changelog - +- 2025-11-17 (v0.3.1) — Remove umbrella validation flags from this spec; list + only feature flags related to MSAA, depth, and stencil; metadata updated. - 2025-11-11 (v0.1.1) — Add MSAA validation in builders; align pipeline and pass sample counts; document logging-based fallback semantics. - 2025-11-11 (v0.1.0) — Initial draft. From a8ed5396a20db16649bfa71eab6e3f3f8e6955d3 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 17 Nov 2025 15:25:07 -0800 Subject: [PATCH 16/25] [update] color of floor and winding of the floor quad. --- crates/lambda-rs/examples/reflective_room.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/lambda-rs/examples/reflective_room.rs b/crates/lambda-rs/examples/reflective_room.rs index a461f2a1..7a12dfdc 100644 --- a/crates/lambda-rs/examples/reflective_room.rs +++ b/crates/lambda-rs/examples/reflective_room.rs @@ -113,7 +113,8 @@ layout (location = 0) out vec4 fragment_color; void main() { // Slightly tint with alpha so the reflection appears through the floor. - fragment_color = vec4(0.1, 0.1, 0.12, 0.5); + // Brightened for visibility against a black clear. + fragment_color = vec4(0.2, 0.2, 0.23, 0.6); } "#; @@ -844,14 +845,15 @@ fn build_floor_quad_mesh(extent: f32) -> Mesh { let p3 = v(-h, h); let mut mesh_builder = MeshBuilder::new(); + // Tri winding flipped to face +Y (avoid back-face cull) // Tri 1 mesh_builder.with_vertex(p0); - mesh_builder.with_vertex(p1); mesh_builder.with_vertex(p2); + mesh_builder.with_vertex(p1); // Tri 2 mesh_builder.with_vertex(p0); - mesh_builder.with_vertex(p2); mesh_builder.with_vertex(p3); + mesh_builder.with_vertex(p2); let mesh = mesh_builder .with_attributes(vec![ From bf0e90ae9ce653e1da2e1e594b22038094bada07 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Wed, 19 Nov 2025 14:32:33 -0800 Subject: [PATCH 17/25] [update] camera to allow the angle to be adjusted. --- crates/lambda-rs/examples/reflective_room.rs | 237 ++++++++++++++----- 1 file changed, 179 insertions(+), 58 deletions(-) diff --git a/crates/lambda-rs/examples/reflective_room.rs b/crates/lambda-rs/examples/reflective_room.rs index 7a12dfdc..256a4b52 100644 --- a/crates/lambda-rs/examples/reflective_room.rs +++ b/crates/lambda-rs/examples/reflective_room.rs @@ -39,6 +39,7 @@ use lambda::{ }, render_pass::RenderPassBuilder, scene_math::{ + compute_model_matrix, compute_perspective_projection, compute_view_matrix, SimpleCamera, @@ -85,8 +86,10 @@ layout ( push_constant ) uniform Push { void main() { gl_Position = pc.mvp * vec4(vertex_position, 1.0); - // Rotate normals into world space using the model matrix (no scale/shear needed for this demo). - v_world_normal = mat3(pc.model) * vertex_normal; + // Transform normals into world space using the model matrix. + // Note: This demo uses only rigid transforms and a Y-mirror; `mat3(model)` + // remains adequate and avoids unsupported `inverse` on some backends (MSL). + v_world_normal = normalize(mat3(pc.model) * vertex_normal); } "#; @@ -109,12 +112,18 @@ void main() { const FRAGMENT_FLOOR_TINT_SOURCE: &str = r#" #version 450 +layout (location = 0) in vec3 v_world_normal; layout (location = 0) out vec4 fragment_color; void main() { - // Slightly tint with alpha so the reflection appears through the floor. - // Brightened for visibility against a black clear. - fragment_color = vec4(0.2, 0.2, 0.23, 0.6); + // Lit floor with partial transparency so the reflection shows through. + vec3 N = normalize(v_world_normal); + vec3 L = normalize(vec3(0.4, 0.7, 1.0)); + float diff = max(dot(N, L), 0.0); + // Subtle base tint to suggest a surface, keep alpha low so reflection reads. + vec3 base = vec3(0.10, 0.10, 0.11); + vec3 color = base * (0.35 + 0.65 * diff); + fragment_color = vec4(color, 0.15); } "#; @@ -150,6 +159,7 @@ pub struct ReflectiveRoomExample { pass_id_color: Option, pipe_floor_mask: Option, pipe_reflected: Option, + pipe_reflected_unmasked: Option, pipe_floor_visual: Option, pipe_normal: Option, width: u32, @@ -160,6 +170,15 @@ pub struct ReflectiveRoomExample { stencil_enabled: bool, depth_test_enabled: bool, needs_rebuild: bool, + // Visual tuning + floor_tilt_turns: f32, + camera_distance: f32, + camera_height: f32, + camera_pitch_turns: f32, + // When true, do not draw the floor surface; leaves a clean mirror. + mirror_mode: bool, + // Debug: draw reflection even without stencil to verify visibility path. + force_unmasked_reflection: bool, } impl Component for ReflectiveRoomExample { @@ -226,6 +245,38 @@ impl Component for ReflectiveRoomExample { self.depth_test_enabled ); } + Some(lambda::events::VirtualKey::KeyF) => { + self.mirror_mode = !self.mirror_mode; + logging::info!( + "Toggled Mirror Mode (hide floor overlay) → {} (key: F)", + self.mirror_mode + ); + } + Some(lambda::events::VirtualKey::KeyR) => { + self.force_unmasked_reflection = !self.force_unmasked_reflection; + logging::info!( + "Toggled Force Unmasked Reflection → {} (key: R)", + self.force_unmasked_reflection + ); + } + Some(lambda::events::VirtualKey::KeyI) => { + // Pitch camera up (reduce downward angle) + self.camera_pitch_turns = + (self.camera_pitch_turns - 0.01).clamp(0.0, 0.25); + logging::info!( + "Camera pitch (turns) → {:.3}", + self.camera_pitch_turns + ); + } + Some(lambda::events::VirtualKey::KeyK) => { + // Pitch camera down (increase downward angle) + self.camera_pitch_turns = + (self.camera_pitch_turns + 0.01).clamp(0.0, 0.25); + logging::info!( + "Camera pitch (turns) → {:.3}", + self.camera_pitch_turns + ); + } _ => {} }, _ => {} @@ -255,7 +306,7 @@ impl Component for ReflectiveRoomExample { } // Camera let camera = SimpleCamera { - position: [0.0, 1.2, 3.5], + position: [0.0, self.camera_height, self.camera_distance], field_of_view_in_turns: 0.24, near_clipping_plane: 0.1, far_clipping_plane: 100.0, @@ -263,18 +314,22 @@ impl Component for ReflectiveRoomExample { // Cube animation let angle_y_turns = 0.12 * self.elapsed; - let mut model: [[f32; 4]; 4] = lambda::math::matrix::identity_matrix(4, 4); - model = lambda::math::matrix::rotate_matrix( - model, + // Build model with canonical order using the scene helpers: + // world = T(0, +0.5, 0) * R_y(angle) * S(1) + let model: [[f32; 4]; 4] = compute_model_matrix( + [0.0, 0.5, 0.0], [0.0, 1.0, 0.0], angle_y_turns, + 1.0, ); - // Translate cube upward by 0.5 on Y - let t_up: [[f32; 4]; 4] = - lambda::math::matrix::translation_matrix([0.0, 0.5, 0.0]); - model = model.multiply(&t_up); - let view = compute_view_matrix(camera.position); + // View: pitch downward, then translate by camera position (R * T) + let rot_x: [[f32; 4]; 4] = lambda::math::matrix::rotate_matrix( + lambda::math::matrix::identity_matrix(4, 4), + [1.0, 0.0, 0.0], + -self.camera_pitch_turns, + ); + let view = rot_x.multiply(&compute_view_matrix(camera.position)); let projection = compute_perspective_projection( camera.field_of_view_in_turns, self.width.max(1), @@ -286,19 +341,22 @@ impl Component for ReflectiveRoomExample { // Compute reflected transform only if stencil/reflection is enabled. let (model_reflect, mvp_reflect) = if self.stencil_enabled { - let mut mr: [[f32; 4]; 4] = lambda::math::matrix::identity_matrix(4, 4); + // Reflection across the (possibly tilted) floor plane that passes + // through the origin. Build the plane normal by rotating +Y by the + // configured floor tilt around X. + let angle = self.floor_tilt_turns * std::f32::consts::PI * 2.0; + let nx = 0.0f32; + let ny = angle.cos(); + let nz = -angle.sin(); + // Reflection matrix R = I - 2*n*n^T for a plane through the origin. + let (nx2, ny2, nz2) = (nx * nx, ny * ny, nz * nz); let s_mirror: [[f32; 4]; 4] = [ - [1.0, 0.0, 0.0, 0.0], - [0.0, -1.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.0], + [1.0 - 2.0 * nx2, -2.0 * nx * ny, -2.0 * nx * nz, 0.0], + [-2.0 * ny * nx, 1.0 - 2.0 * ny2, -2.0 * ny * nz, 0.0], + [-2.0 * nz * nx, -2.0 * nz * ny, 1.0 - 2.0 * nz2, 0.0], [0.0, 0.0, 0.0, 1.0], ]; - mr = mr.multiply(&s_mirror); - mr = - lambda::math::matrix::rotate_matrix(mr, [0.0, 1.0, 0.0], angle_y_turns); - let t_down: [[f32; 4]; 4] = - lambda::math::matrix::translation_matrix([0.0, -0.5, 0.0]); - mr = mr.multiply(&t_down); + let mr = s_mirror.multiply(&model); let mvp_r = projection.multiply(&view).multiply(&mr); (mr, mvp_r) } else { @@ -306,9 +364,14 @@ impl Component for ReflectiveRoomExample { (lambda::math::matrix::identity_matrix(4, 4), mvp) }; - // Floor model: at y = 0 plane + // Floor model: plane through origin, tilted slightly around X for clarity let mut model_floor: [[f32; 4]; 4] = lambda::math::matrix::identity_matrix(4, 4); + model_floor = lambda::math::matrix::rotate_matrix( + model_floor, + [1.0, 0.0, 0.0], + self.floor_tilt_turns, + ); let mvp_floor = projection.multiply(&view).multiply(&model_floor); let viewport = ViewportBuilder::new().build(self.width, self.height); @@ -398,30 +461,54 @@ impl Component for ReflectiveRoomExample { vertices: 0..cube_vertex_count, }); } + } else if self.force_unmasked_reflection { + if let Some(pipe_reflected_unmasked) = self.pipe_reflected_unmasked { + cmds.push(RenderCommand::SetPipeline { + pipeline: pipe_reflected_unmasked, + }); + cmds.push(RenderCommand::BindVertexBuffer { + pipeline: pipe_reflected_unmasked, + buffer: 0, + }); + cmds.push(RenderCommand::PushConstants { + pipeline: pipe_reflected_unmasked, + stage: PipelineStage::VERTEX, + offset: 0, + bytes: Vec::from(push_constants_to_words(&PushConstant { + mvp: mvp_reflect.transpose(), + model: model_reflect.transpose(), + })), + }); + cmds.push(RenderCommand::Draw { + vertices: 0..cube_vertex_count, + }); + } } // Floor surface (tinted) - let pipe_floor_visual = - self.pipe_floor_visual.expect("floor visual pipeline"); - cmds.push(RenderCommand::SetPipeline { - pipeline: pipe_floor_visual, - }); - cmds.push(RenderCommand::BindVertexBuffer { - pipeline: pipe_floor_visual, - buffer: 0, - }); - cmds.push(RenderCommand::PushConstants { - pipeline: pipe_floor_visual, - stage: PipelineStage::VERTEX, - offset: 0, - bytes: Vec::from(push_constants_to_words(&PushConstant { - mvp: mvp_floor.transpose(), - model: model_floor.transpose(), - })), - }); - cmds.push(RenderCommand::Draw { - vertices: 0..floor_vertex_count, - }); + if !self.mirror_mode { + let pipe_floor_visual = + self.pipe_floor_visual.expect("floor visual pipeline"); + cmds.push(RenderCommand::SetPipeline { + pipeline: pipe_floor_visual, + }); + cmds.push(RenderCommand::BindVertexBuffer { + pipeline: pipe_floor_visual, + buffer: 0, + }); + cmds.push(RenderCommand::PushConstants { + pipeline: pipe_floor_visual, + stage: PipelineStage::VERTEX, + offset: 0, + bytes: Vec::from(push_constants_to_words(&PushConstant { + mvp: mvp_floor.transpose(), + model: model_floor.transpose(), + })), + }); + cmds.push(RenderCommand::Draw { + vertices: 0..floor_vertex_count, + }); + } // Normal cube let pipe_normal = self.pipe_normal.expect("normal pipeline"); @@ -482,6 +569,7 @@ impl Default for ReflectiveRoomExample { pass_id_color: None, pipe_floor_mask: None, pipe_reflected: None, + pipe_reflected_unmasked: None, pipe_floor_visual: None, pipe_normal: None, width: 800, @@ -491,6 +579,12 @@ impl Default for ReflectiveRoomExample { stencil_enabled: true, depth_test_enabled: true, needs_rebuild: false, + floor_tilt_turns: 0.0, // Keep plane flat; angle comes from camera + camera_distance: 4.0, + camera_height: 3.0, + camera_pitch_turns: 0.10, // ~36 degrees downward + mirror_mode: false, + force_unmasked_reflection: false, }; } } @@ -547,7 +641,8 @@ impl ReflectiveRoomExample { self.pipe_floor_mask = if self.stencil_enabled { let p = RenderPipelineBuilder::new() .with_label("floor-mask") - .with_culling(CullingMode::Back) + // Disable culling to guarantee stencil writes regardless of winding. + .with_culling(CullingMode::None) .with_depth_format(DepthFormat::Depth24PlusStencil8) .with_depth_write(false) .with_depth_compare(CompareFunction::Always) @@ -598,7 +693,8 @@ impl ReflectiveRoomExample { self.pipe_reflected = if self.stencil_enabled { let mut builder = RenderPipelineBuilder::new() .with_label("reflected-cube") - .with_culling(CullingMode::None) + // Mirrored transform reverses winding; cull front to keep visible faces. + .with_culling(CullingMode::Front) .with_depth_format(DepthFormat::Depth24PlusStencil8) .with_push_constant(PipelineStage::VERTEX, push_constants_size) .with_buffer( @@ -629,16 +725,11 @@ impl ReflectiveRoomExample { read_mask: 0xFF, write_mask: 0x00, }) - .with_multi_sample(self.msaa_samples); - if self.depth_test_enabled { - builder = builder - .with_depth_write(true) - .with_depth_compare(CompareFunction::LessEqual); - } else { - builder = builder - .with_depth_write(false) - .with_depth_compare(CompareFunction::Always); - } + .with_multi_sample(self.msaa_samples) + // Render reflection regardless of depth to ensure visibility; + // the floor overlay and stencil confine and visually place it. + .with_depth_write(false) + .with_depth_compare(CompareFunction::Always); let p = builder.build( render_context, &rp_color_desc, @@ -650,6 +741,36 @@ impl ReflectiveRoomExample { None }; + // Reflected cube pipeline without stencil (debug/fallback) + let mut builder_unmasked = RenderPipelineBuilder::new() + .with_label("reflected-cube-unmasked") + .with_culling(CullingMode::Front) + .with_depth_format(DepthFormat::Depth24PlusStencil8) + .with_push_constant(PipelineStage::VERTEX, push_constants_size) + .with_buffer( + BufferBuilder::new() + .with_length( + cube_mesh.vertices().len() * std::mem::size_of::(), + ) + .with_usage(Usage::VERTEX) + .with_properties(Properties::DEVICE_LOCAL) + .with_buffer_type(BufferType::Vertex) + .build(render_context, cube_mesh.vertices().to_vec()) + .map_err(|e| format!("Failed to create cube buffer: {}", e))?, + cube_mesh.attributes().to_vec(), + ) + .with_multi_sample(self.msaa_samples) + .with_depth_write(false) + .with_depth_compare(CompareFunction::Always); + let p_unmasked = builder_unmasked.build( + render_context, + &rp_color_desc, + &self.shader_vs, + Some(&self.shader_fs_lit), + ); + self.pipe_reflected_unmasked = + Some(render_context.attach_pipeline(p_unmasked)); + // Floor visual pipeline let mut floor_builder = RenderPipelineBuilder::new() .with_label("floor-visual") From deb8aff8fe4caed5f6d1941962cefcf2a14b7890 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Wed, 19 Nov 2025 15:44:00 -0800 Subject: [PATCH 18/25] [remove] unmasked reflection toggle and update tutorials. --- crates/lambda-rs/examples/reflective_room.rs | 65 +--------------- docs/tutorials/reflective-room.md | 80 ++++++++++++-------- 2 files changed, 52 insertions(+), 93 deletions(-) diff --git a/crates/lambda-rs/examples/reflective_room.rs b/crates/lambda-rs/examples/reflective_room.rs index 256a4b52..54fa7f27 100644 --- a/crates/lambda-rs/examples/reflective_room.rs +++ b/crates/lambda-rs/examples/reflective_room.rs @@ -159,7 +159,6 @@ pub struct ReflectiveRoomExample { pass_id_color: Option, pipe_floor_mask: Option, pipe_reflected: Option, - pipe_reflected_unmasked: Option, pipe_floor_visual: Option, pipe_normal: Option, width: u32, @@ -177,8 +176,6 @@ pub struct ReflectiveRoomExample { camera_pitch_turns: f32, // When true, do not draw the floor surface; leaves a clean mirror. mirror_mode: bool, - // Debug: draw reflection even without stencil to verify visibility path. - force_unmasked_reflection: bool, } impl Component for ReflectiveRoomExample { @@ -252,13 +249,7 @@ impl Component for ReflectiveRoomExample { self.mirror_mode ); } - Some(lambda::events::VirtualKey::KeyR) => { - self.force_unmasked_reflection = !self.force_unmasked_reflection; - logging::info!( - "Toggled Force Unmasked Reflection → {} (key: R)", - self.force_unmasked_reflection - ); - } + // 'R' previously forced an unmasked reflection; now disabled. Some(lambda::events::VirtualKey::KeyI) => { // Pitch camera up (reduce downward angle) self.camera_pitch_turns = @@ -461,28 +452,6 @@ impl Component for ReflectiveRoomExample { vertices: 0..cube_vertex_count, }); } - } else if self.force_unmasked_reflection { - if let Some(pipe_reflected_unmasked) = self.pipe_reflected_unmasked { - cmds.push(RenderCommand::SetPipeline { - pipeline: pipe_reflected_unmasked, - }); - cmds.push(RenderCommand::BindVertexBuffer { - pipeline: pipe_reflected_unmasked, - buffer: 0, - }); - cmds.push(RenderCommand::PushConstants { - pipeline: pipe_reflected_unmasked, - stage: PipelineStage::VERTEX, - offset: 0, - bytes: Vec::from(push_constants_to_words(&PushConstant { - mvp: mvp_reflect.transpose(), - model: model_reflect.transpose(), - })), - }); - cmds.push(RenderCommand::Draw { - vertices: 0..cube_vertex_count, - }); - } } // Floor surface (tinted) @@ -569,7 +538,6 @@ impl Default for ReflectiveRoomExample { pass_id_color: None, pipe_floor_mask: None, pipe_reflected: None, - pipe_reflected_unmasked: None, pipe_floor_visual: None, pipe_normal: None, width: 800, @@ -584,7 +552,6 @@ impl Default for ReflectiveRoomExample { camera_height: 3.0, camera_pitch_turns: 0.10, // ~36 degrees downward mirror_mode: false, - force_unmasked_reflection: false, }; } } @@ -741,35 +708,7 @@ impl ReflectiveRoomExample { None }; - // Reflected cube pipeline without stencil (debug/fallback) - let mut builder_unmasked = RenderPipelineBuilder::new() - .with_label("reflected-cube-unmasked") - .with_culling(CullingMode::Front) - .with_depth_format(DepthFormat::Depth24PlusStencil8) - .with_push_constant(PipelineStage::VERTEX, push_constants_size) - .with_buffer( - BufferBuilder::new() - .with_length( - cube_mesh.vertices().len() * std::mem::size_of::(), - ) - .with_usage(Usage::VERTEX) - .with_properties(Properties::DEVICE_LOCAL) - .with_buffer_type(BufferType::Vertex) - .build(render_context, cube_mesh.vertices().to_vec()) - .map_err(|e| format!("Failed to create cube buffer: {}", e))?, - cube_mesh.attributes().to_vec(), - ) - .with_multi_sample(self.msaa_samples) - .with_depth_write(false) - .with_depth_compare(CompareFunction::Always); - let p_unmasked = builder_unmasked.build( - render_context, - &rp_color_desc, - &self.shader_vs, - Some(&self.shader_fs_lit), - ); - self.pipe_reflected_unmasked = - Some(render_context.attach_pipeline(p_unmasked)); + // No unmasked reflection pipeline in production example. // Floor visual pipeline let mut floor_builder = RenderPipelineBuilder::new() diff --git a/docs/tutorials/reflective-room.md b/docs/tutorials/reflective-room.md index e58d9315..fc3029f1 100644 --- a/docs/tutorials/reflective-room.md +++ b/docs/tutorials/reflective-room.md @@ -1,22 +1,22 @@ --- -title: "Reflective Room: Stencil Masked Reflections with MSAA" +title: "Reflective Floor: Stencil‑Masked Planar Reflections" document_id: "reflective-room-tutorial-2025-11-17" status: "draft" created: "2025-11-17T00:00:00Z" -last_updated: "2025-11-17T00:00:00Z" -version: "0.1.0" +last_updated: "2025-11-19T00:00:01Z" +version: "0.2.1" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "ceaf345777d871912b2f92ae629a34b8e6f8654a" +repo_commit: "bf0e90ae9ce653e1da2e1e594b22038094bada07" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["tutorial", "graphics", "stencil", "depth", "msaa", "mirror", "3d", "push-constants", "wgpu", "rust"] --- ## Overview -This tutorial builds a reflective floor using the stencil buffer with an optional depth test and 4× multi‑sample anti‑aliasing (MSAA). The scene renders in four phases: a floor mask into stencil, a mirrored cube clipped by the mask, a tinted floor surface, and a normal cube above the plane. +This tutorial builds a reflective floor using the stencil buffer with an optional depth test and 4× multi‑sample anti‑aliasing (MSAA). The scene renders in four phases: a floor mask into stencil, a mirrored cube clipped by the mask, a translucent lit floor surface, and a normal cube above the plane. The camera looks down at a moderate angle so the reflection is clearly visible. Reference implementation: `crates/lambda-rs/examples/reflective_room.rs`. @@ -50,7 +50,7 @@ Reference implementation: `crates/lambda-rs/examples/reflective_room.rs`. - Use the stencil buffer to restrict rendering to the floor area and show a mirrored reflection of a cube. - Support depth testing and 4× MSAA to improve geometric correctness and edge quality. - Drive transforms via push constants for model‑view‑projection (MVP) and model matrices. -- Provide runtime toggles for MSAA, stencil, and depth testing. +- Provide runtime toggles for MSAA, stencil, and depth testing, plus camera pitch and visibility helpers. ## Prerequisites - Build the workspace: `cargo build --workspace`. @@ -60,7 +60,7 @@ Reference implementation: `crates/lambda-rs/examples/reflective_room.rs`. - A pipeline that uses stencil state MUST render into a pass with a depth‑stencil attachment. Use `DepthFormat::Depth24PlusStencil8`. - The mask pass MUST disable depth writes and write stencil with `Replace` so the floor area becomes `1`. - The reflected cube pipeline MUST test stencil `Equal` against reference `1` and SHOULD set stencil write mask to `0x00`. -- Mirroring across the floor plane flips face winding. Culling MUST be disabled for the reflected draw or the front‑face definition MUST be adjusted. This example disables culling. +- Mirroring across the floor plane flips face winding. Culling MUST be disabled for the reflected draw or the front‑face definition MUST be adjusted. This example culls front faces for the reflected cube. - Push constant size and stage visibility MUST match the shader declaration. Two `mat4` values are sent to the vertex stage only (128 bytes total). - Matrix order MUST match the shader’s expectation. The example transposes matrices before upload to match GLSL column‑major multiplication. - The render pass and pipelines MUST use the same sample count when MSAA is enabled. @@ -78,7 +78,7 @@ Pass 1: Depth/Stencil‑only (no color) — write stencil where floor covers │ stencil = 1 inside floor, 0 elsewhere; depth write off ▼ Pass 2: Color (with depth/stencil) — draw reflected cube with stencil test == 1 - │ culling off; depth compare configurable + │ culling front faces; depth compare configurable ▼ Pass 3: Color — draw tinted floor (alpha) to show reflection ▼ @@ -124,7 +124,7 @@ impl Component for ReflectiveRoomExample { /* lifecycle Narrative: The component stores GPU handles and toggles. When settings change, mark `needs_rebuild = true` and rebuild pipelines/passes on the next frame. ### Step 2 — Shaders and Push Constants -Use one vertex shader and two fragment shaders. The vertex shader expects push constants with two `mat4` values: the MVP and the model matrix, used to transform positions and rotate normals to world space. +Use one vertex shader and two fragment shaders. The vertex shader expects push constants with two `mat4` values: the MVP and the model matrix, used to transform positions and rotate normals to world space. The floor fragment shader is lit and translucent so the reflection reads beneath it. ```glsl // Vertex (GLSL 450) @@ -136,6 +136,7 @@ layout ( push_constant ) uniform Push { mat4 mvp; mat4 model; } pc; void main() { gl_Position = pc.mvp * vec4(vertex_position, 1.0); + // Transform normals by the model matrix; sufficient for rigid + mirror. v_world_normal = mat3(pc.model) * vertex_normal; } ``` @@ -154,9 +155,17 @@ void main() { ``` ```glsl -// Fragment (floor tint) +// Fragment (floor: lit + translucent) +layout (location = 0) in vec3 v_world_normal; layout (location = 0) out vec4 fragment_color; -void main() { fragment_color = vec4(0.1, 0.1, 0.12, 0.5); } +void main() { + vec3 N = normalize(v_world_normal); + vec3 L = normalize(vec3(0.4, 0.7, 1.0)); + float diff = max(dot(N, L), 0.0); + vec3 base = vec3(0.10, 0.10, 0.11); + vec3 color = base * (0.35 + 0.65 * diff); + fragment_color = vec4(color, 0.15); +} ``` In Rust, pack push constants as 32‑bit words and transpose matrices before upload. @@ -226,12 +235,12 @@ let pipe_floor_mask = RenderPipelineBuilder::new() ``` ### Step 6 — Pipeline: Reflected Cube (Stencil Test) -Render the mirrored cube only where the floor mask is present. Disable culling to avoid flipped winding after mirroring. Preserve depth testing configuration via toggles. +Render the mirrored cube only where the floor mask is present. Mirroring flips the winding, so cull front faces for the reflected draw. Use `depth_compare = Always` and disable depth writes so the reflection remains visible; the stencil confines it to the floor. ```rust let mut builder = RenderPipelineBuilder::new() .with_label("reflected-cube") - .with_culling(lambda::render::pipeline::CullingMode::None) + .with_culling(lambda::render::pipeline::CullingMode::Front) .with_depth_format(lambda::render::texture::DepthFormat::Depth24PlusStencil8) .with_push_constant(PipelineStage::VERTEX, std::mem::size_of::() as u32) .with_buffer(cube_vertex_buffer, cube_attributes) @@ -240,13 +249,9 @@ let mut builder = RenderPipelineBuilder::new() back: StencilFaceState { compare: CompareFunction::Equal, fail_op: StencilOperation::Keep, depth_fail_op: StencilOperation::Keep, pass_op: StencilOperation::Keep }, read_mask: 0xFF, write_mask: 0x00, }) - .with_multi_sample(msaa_samples); - -builder = if depth_test_enabled { - builder.with_depth_write(true).with_depth_compare(CompareFunction::LessEqual) -} else { - builder.with_depth_write(false).with_depth_compare(CompareFunction::Always) -}; + .with_multi_sample(msaa_samples) + .with_depth_write(false) + .with_depth_compare(CompareFunction::Always); let pipe_reflected = builder.build(ctx, &pass_color, &shader_vs, Some(&shader_fs_lit)); ``` @@ -292,13 +297,16 @@ let pipe_normal = normal.build(ctx, &pass_color, &shader_vs, Some(&shader_fs_lit ``` ### Step 9 — Per‑Frame Transforms and Reflection -Compute camera, model rotation, and the mirror transform across the floor plane. The reflection scales Y by −1 and translates downward to align with the mirrored cube below the floor. +Compute camera, model rotation, and the mirror transform across the floor plane. The camera pitches downward and translates to a higher vantage point. Build the mirror using the plane‑reflection matrix `R = I − 2 n n^T` for a plane through the origin with unit normal `n` (for a flat floor, `n = (0,1,0)`). ```rust use lambda::render::scene_math::{compute_perspective_projection, compute_view_matrix, SimpleCamera}; -let camera = SimpleCamera { position: [0.0, 1.2, 3.5], field_of_view_in_turns: 0.24, near_clipping_plane: 0.1, far_clipping_plane: 100.0 }; -let view = compute_view_matrix(camera.position); +let camera = SimpleCamera { position: [0.0, 3.0, 4.0], field_of_view_in_turns: 0.24, near_clipping_plane: 0.1, far_clipping_plane: 100.0 }; +// View = R_x(-pitch) * T(-position) +let pitch_turns = 0.10; // ~36 degrees downward +let rot_x = lambda::math::matrix::rotate_matrix(lambda::math::matrix::identity_matrix(4,4), [1.0,0.0,0.0], -pitch_turns); +let view = rot_x.multiply(&compute_view_matrix(camera.position)); let projection = compute_perspective_projection(camera.field_of_view_in_turns, width.max(1), height.max(1), camera.near_clipping_plane, camera.far_clipping_plane); let angle_y = 0.12 * elapsed; @@ -307,10 +315,15 @@ model = lambda::math::matrix::rotate_matrix(model, [0.0, 1.0, 0.0], angle_y); model = model.multiply(&lambda::math::matrix::translation_matrix([0.0, 0.5, 0.0])); let mvp = projection.multiply(&view).multiply(&model); -let mirror = [ [1.0, 0.0, 0.0, 0.0], [0.0, -1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0] ]; -let mut model_reflect = lambda::math::matrix::identity_matrix(4, 4).multiply(&mirror); -model_reflect = lambda::math::matrix::rotate_matrix(model_reflect, [0.0, 1.0, 0.0], angle_y); -model_reflect = model_reflect.multiply(&lambda::math::matrix::translation_matrix([0.0, -0.5, 0.0])); +let n = [0.0f32, 1.0, 0.0]; +let (nx, ny, nz) = (n[0], n[1], n[2]); +let mirror = [ + [1.0 - 2.0*nx*nx, -2.0*nx*ny, -2.0*nx*nz, 0.0], + [-2.0*ny*nx, 1.0 - 2.0*ny*ny, -2.0*ny*nz, 0.0], + [-2.0*nz*nx, -2.0*nz*ny, 1.0 - 2.0*nz*nz, 0.0], + [0.0, 0.0, 0.0, 1.0], +]; +let model_reflect = mirror.multiply(&model); let mvp_reflect = projection.multiply(&view).multiply(&model_reflect); ``` @@ -358,8 +371,10 @@ cmds.push(RenderCommand::EndRenderPass); Support runtime toggles to observe the impact of each setting: - `M` toggles MSAA between `1×` and `4×`. Rebuild passes and pipelines when it changes. -- `S` toggles the stencil reflection. When disabled, skip the mask and reflected draw. +- `S` toggles the stencil reflection. When disabled, the example skips the mask and reflected draw. - `D` toggles depth testing. When disabled, set depth compare to `Always` and disable depth writes on pipelines. +- `F` toggles the floor overlay (mirror mode). When enabled, the reflection shows without the translucent floor surface. +- `I` and `K` adjust the camera pitch up/down in small steps. - On window resize, update stored `width` and `height` and use them when computing the viewport and projection matrix. Reference: `crates/lambda-rs/examples/reflective_room.rs:164`. @@ -370,18 +385,22 @@ Reference: `crates/lambda-rs/examples/reflective_room.rs:164`. - Expected behavior: - A cube rotates above a reflective floor. The reflection appears only inside the floor area and shows correct mirroring. - Press `S` to toggle the reflection (stencil). The reflected cube disappears when stencil is off. - - Press `D` to toggle depth testing. With depth off, the reflection still clips to the floor via stencil, but depth ordering may show artifacts if geometry overlaps. - - Press `M` to toggle MSAA. With `4×` MSAA, edges appear smoother; with `1×`, edges appear more aliased. + - Press `F` to hide/show the floor overlay to see a clean mirror. + - Press `I`/`K` to adjust camera pitch; ensure the reflection remains visible at moderate angles. + - Press `D` to toggle depth testing. With depth off, the reflection still clips to the floor via stencil. + - Press `M` to toggle MSAA. With `4×` MSAA, edges appear smoother. ## Notes - Pipelines that use stencil MUST target a pass with a depth‑stencil attachment; otherwise, pipeline creation or draws will fail. - Mirroring across a plane flips winding. Either disable culling or adjust front‑face winding for the reflected draw; do not leave back‑face culling enabled with mirrored geometry. +- This implementation culls front faces for the reflected pipeline to account for mirrored winding; the normal cube uses back‑face culling. - The mask pass SHOULD clear stencil to `0` and write `1` where the floor renders. Use `Replace` and a write mask of `0xFF`. - The reflected draw SHOULD use `read_mask = 0xFF`, `write_mask = 0x00`, and `reference = 1` to preserve the mask. - When depth testing is disabled, set `depth_compare = Always` and `depth_write = false` to avoid unintended depth interactions. - The pass and all pipelines in the pass MUST use the same MSAA sample count. - Transpose matrices before uploading when GLSL expects column‑major multiplication. +- Metal (MSL) portability: avoid calling `inverse()` in shaders for normal transforms; compute the normal matrix on the CPU if needed. The example uses `mat3(model)` for rigid + mirror transforms. ## Conclusion @@ -403,4 +422,5 @@ The reflective floor combines a simple stencil mask with an optional depth test ## Changelog +- 0.2.0 (2025‑11‑19): Updated for camera pitch, front‑face culling on reflection, lit translucent floor, unmasked reflection debug toggle, floor overlay toggle, and Metal portability note. - 0.1.0 (2025‑11‑17): Initial draft aligned with `crates/lambda-rs/examples/reflective_room.rs`, including stencil mask pass, reflected pipeline, and MSAA/depth toggles. From 4c07ca305d05d4a9c4008f607d625df0bcf37f37 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 21 Nov 2025 14:23:48 -0800 Subject: [PATCH 19/25] [add] device validation for sample counts, headless gpu for testing, and tests for the validation. --- crates/lambda-rs-platform/src/wgpu/gpu.rs | 175 +++++++++++++++++ crates/lambda-rs-platform/src/wgpu/surface.rs | 4 + crates/lambda-rs/src/render/mod.rs | 23 +++ crates/lambda-rs/src/render/render_pass.rs | 182 +++++++++++++++++- docs/specs/depth-stencil-msaa.md | 26 +-- 5 files changed, 397 insertions(+), 13 deletions(-) diff --git a/crates/lambda-rs-platform/src/wgpu/gpu.rs b/crates/lambda-rs-platform/src/wgpu/gpu.rs index 2a544e74..6e80961b 100644 --- a/crates/lambda-rs-platform/src/wgpu/gpu.rs +++ b/crates/lambda-rs-platform/src/wgpu/gpu.rs @@ -4,6 +4,7 @@ use super::{ command::CommandBuffer, instance::Instance, surface::Surface, + texture, }; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -197,6 +198,24 @@ pub struct Gpu { } impl Gpu { + /// Whether the provided surface format supports the sample count for render attachments. + pub fn supports_sample_count_for_surface( + &self, + format: super::surface::SurfaceFormat, + sample_count: u32, + ) -> bool { + return self.supports_sample_count(format.to_wgpu(), sample_count); + } + + /// Whether the provided depth format supports the sample count for render attachments. + pub fn supports_sample_count_for_depth( + &self, + format: texture::DepthFormat, + sample_count: u32, + ) -> bool { + return self.supports_sample_count(format.to_wgpu(), sample_count); + } + /// Borrow the adapter used to create the device. /// /// Crate-visible to avoid exposing raw `wgpu` to higher layers. @@ -245,11 +264,50 @@ impl Gpu { let iter = list.into_iter().map(|cb| cb.into_raw()); self.queue.submit(iter); } + + fn supports_sample_count( + &self, + format: wgpu::TextureFormat, + sample_count: u32, + ) -> bool { + if sample_count <= 1 { + return true; + } + + let features = self.adapter.get_texture_format_features(format); + if !features + .allowed_usages + .contains(wgpu::TextureUsages::RENDER_ATTACHMENT) + { + return false; + } + + match sample_count { + 2 => features + .flags + .contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_X2), + 4 => features + .flags + .contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_X4), + 8 => features + .flags + .contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_X8), + 16 => features + .flags + .contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_X16), + _ => false, + } + } } #[cfg(test)] mod tests { use super::*; + use crate::wgpu::{ + instance, + surface, + texture, + }; #[test] fn gpu_build_error_wraps_request_device_error() { @@ -260,4 +318,121 @@ mod tests { fn assert_from_impl>() {} assert_from_impl::(); } + + /// Create an offscreen GPU for sample-count support tests. + /// + /// Returns `None` when no compatible adapter is available so tests can be + /// skipped instead of failing. + fn create_test_gpu() -> Option { + let instance = instance::InstanceBuilder::new() + .with_label("gpu-test-instance") + .build(); + return GpuBuilder::new() + .with_label("gpu-test-device") + .build(&instance, None) + .ok(); + } + + /// Accepts zero or single-sample attachments for any format. + #[test] + fn single_sample_always_supported() { + let gpu = match create_test_gpu() { + Some(gpu) => gpu, + None => { + eprintln!( + "Skipping single_sample_always_supported: no compatible GPU adapter" + ); + return; + } + }; + let surface_format = + surface::SurfaceFormat::from_wgpu(wgpu::TextureFormat::Bgra8UnormSrgb); + let depth_format = texture::DepthFormat::Depth32Float; + + assert!(gpu.supports_sample_count_for_surface(surface_format, 1)); + assert!(gpu.supports_sample_count_for_surface(surface_format, 0)); + assert!(gpu.supports_sample_count_for_depth(depth_format, 1)); + assert!(gpu.supports_sample_count_for_depth(depth_format, 0)); + } + + /// Rejects sample counts that are outside the supported set. + #[test] + fn unsupported_sample_count_rejected() { + let gpu = match create_test_gpu() { + Some(gpu) => gpu, + None => { + eprintln!( + "Skipping unsupported_sample_count_rejected: no compatible GPU adapter" + ); + return; + } + }; + let surface_format = + surface::SurfaceFormat::from_wgpu(wgpu::TextureFormat::Bgra8Unorm); + let depth_format = texture::DepthFormat::Depth32Float; + + assert!(!gpu.supports_sample_count_for_surface(surface_format, 3)); + assert!(!gpu.supports_sample_count_for_depth(depth_format, 3)); + } + + /// Mirrors the adapter's texture feature flags for surface formats. + #[test] + fn surface_support_matches_texture_features() { + let gpu = match create_test_gpu() { + Some(gpu) => gpu, + None => { + eprintln!( + "Skipping surface_support_matches_texture_features: \ +no compatible GPU adapter" + ); + return; + } + }; + let surface_format = + surface::SurfaceFormat::from_wgpu(wgpu::TextureFormat::Bgra8UnormSrgb); + let features = gpu + .adapter + .get_texture_format_features(surface_format.to_wgpu()); + let expected = features + .allowed_usages + .contains(wgpu::TextureUsages::RENDER_ATTACHMENT) + && features + .flags + .contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_X4); + + assert_eq!( + gpu.supports_sample_count_for_surface(surface_format, 4), + expected + ); + } + + /// Mirrors the adapter's texture feature flags for depth formats. + #[test] + fn depth_support_matches_texture_features() { + let gpu = match create_test_gpu() { + Some(gpu) => gpu, + None => { + eprintln!( + "Skipping depth_support_matches_texture_features: \ +no compatible GPU adapter" + ); + return; + } + }; + let depth_format = texture::DepthFormat::Depth32Float; + let features = gpu + .adapter + .get_texture_format_features(depth_format.to_wgpu()); + let expected = features + .allowed_usages + .contains(wgpu::TextureUsages::RENDER_ATTACHMENT) + && features + .flags + .contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_X4); + + assert_eq!( + gpu.supports_sample_count_for_depth(depth_format, 4), + expected + ); + } } diff --git a/crates/lambda-rs-platform/src/wgpu/surface.rs b/crates/lambda-rs-platform/src/wgpu/surface.rs index d1383fbd..ff283100 100644 --- a/crates/lambda-rs-platform/src/wgpu/surface.rs +++ b/crates/lambda-rs-platform/src/wgpu/surface.rs @@ -93,6 +93,10 @@ impl std::ops::BitOr for TextureUsages { pub struct SurfaceFormat(wgpu::TextureFormat); impl SurfaceFormat { + /// Common sRGB swapchain format used for windowed rendering. + pub const BGRA8_UNORM_SRGB: SurfaceFormat = + SurfaceFormat(wgpu::TextureFormat::Bgra8UnormSrgb); + pub(crate) fn to_wgpu(self) -> wgpu::TextureFormat { return self.0; } diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index d36558ee..df2f7156 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -347,6 +347,29 @@ impl RenderContext { return self.config.format; } + pub(crate) fn depth_format(&self) -> platform::texture::DepthFormat { + return self.depth_format; + } + + pub(crate) fn supports_surface_sample_count( + &self, + sample_count: u32, + ) -> bool { + return self + .gpu + .supports_sample_count_for_surface(self.config.format, sample_count); + } + + pub(crate) fn supports_depth_sample_count( + &self, + format: platform::texture::DepthFormat, + sample_count: u32, + ) -> bool { + return self + .gpu + .supports_sample_count_for_depth(format, sample_count); + } + /// Device limit: maximum bytes that can be bound for a single uniform buffer binding. pub fn limit_max_uniform_buffer_binding_size(&self) -> u64 { return self.gpu.limits().max_uniform_buffer_binding_size; diff --git a/crates/lambda-rs/src/render/render_pass.rs b/crates/lambda-rs/src/render/render_pass.rs index c42e34cf..8c4b38ba 100644 --- a/crates/lambda-rs/src/render/render_pass.rs +++ b/crates/lambda-rs/src/render/render_pass.rs @@ -5,6 +5,7 @@ //! perform depth/stencil-only operations (e.g., stencil mask pre-pass). //! The pass is referenced by handle from `RenderCommand::BeginRenderPass`. +use lambda_platform::wgpu as platform; use logging; use super::RenderContext; @@ -276,17 +277,76 @@ impl RenderPassBuilder { } /// Build the description used when beginning a render pass. - pub fn build(self, _render_context: &RenderContext) -> RenderPass { + pub fn build(self, render_context: &RenderContext) -> RenderPass { + let sample_count = self.resolve_sample_count( + self.sample_count, + render_context.surface_format(), + render_context.depth_format(), + |count| render_context.supports_surface_sample_count(count), + |format, count| render_context.supports_depth_sample_count(format, count), + ); + return RenderPass { clear_color: self.clear_color, label: self.label, color_operations: self.color_operations, depth_operations: self.depth_operations, stencil_operations: self.stencil_operations, - sample_count: self.sample_count, + sample_count, use_color: self.use_color, }; } + + /// Validate the requested sample count against surface and depth/stencil + /// capabilities, falling back to `1` when unsupported. + fn resolve_sample_count( + &self, + sample_count: u32, + surface_format: platform::surface::SurfaceFormat, + depth_format: platform::texture::DepthFormat, + supports_surface: FSurface, + supports_depth: FDepth, + ) -> u32 + where + FSurface: Fn(u32) -> bool, + FDepth: Fn(platform::texture::DepthFormat, u32) -> bool, + { + let mut resolved_sample_count = sample_count.max(1); + + if self.use_color + && resolved_sample_count > 1 + && !supports_surface(resolved_sample_count) + { + #[cfg(any(debug_assertions, feature = "render-validation-device",))] + logging::error!( + "Sample count {} unsupported for surface format {:?}; falling back to 1", + resolved_sample_count, + surface_format + ); + resolved_sample_count = 1; + } + + let wants_depth_or_stencil = + self.depth_operations.is_some() || self.stencil_operations.is_some(); + if wants_depth_or_stencil && resolved_sample_count > 1 { + let validated_depth_format = if self.stencil_operations.is_some() { + platform::texture::DepthFormat::Depth24PlusStencil8 + } else { + depth_format + }; + if !supports_depth(validated_depth_format, resolved_sample_count) { + #[cfg(any(debug_assertions, feature = "render-validation-device",))] + logging::error!( + "Sample count {} unsupported for depth format {:?}; falling back to 1", + resolved_sample_count, + validated_depth_format + ); + resolved_sample_count = 1; + } + } + + return resolved_sample_count; + } } /// Stencil load operation for the stencil attachment. @@ -313,3 +373,121 @@ impl Default for StencilOperations { }; } } + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + + use super::*; + + fn surface_format() -> platform::surface::SurfaceFormat { + return platform::surface::SurfaceFormat::BGRA8_UNORM_SRGB; + } + + /// Falls back when the surface format rejects the requested sample count. + #[test] + fn unsupported_surface_sample_count_falls_back_to_one() { + let builder = RenderPassBuilder::new().with_multi_sample(4); + + let resolved = builder.resolve_sample_count( + 4, + surface_format(), + platform::texture::DepthFormat::Depth32Float, + |_samples| { + return false; + }, + |_format, _samples| { + return true; + }, + ); + + assert_eq!(resolved, 1); + } + + /// Falls back when the depth format rejects the requested sample count. + #[test] + fn unsupported_depth_sample_count_falls_back_to_one() { + let builder = RenderPassBuilder::new().with_depth().with_multi_sample(8); + + let resolved = builder.resolve_sample_count( + 8, + surface_format(), + platform::texture::DepthFormat::Depth32Float, + |_samples| { + return true; + }, + |_format, _samples| { + return false; + }, + ); + + assert_eq!(resolved, 1); + } + + /// Uses a stencil-capable depth format when stencil operations are present. + #[test] + fn stencil_support_uses_stencil_capable_depth_format() { + let builder = RenderPassBuilder::new().with_stencil().with_multi_sample(2); + let requested_formats: RefCell> = + RefCell::new(Vec::new()); + + let resolved = builder.resolve_sample_count( + 2, + surface_format(), + platform::texture::DepthFormat::Depth32Float, + |_samples| { + return true; + }, + |format, _samples| { + requested_formats.borrow_mut().push(format); + return true; + }, + ); + + assert_eq!(resolved, 2); + assert_eq!( + requested_formats.borrow().first().copied(), + Some(platform::texture::DepthFormat::Depth24PlusStencil8) + ); + } + + /// Preserves supported sample counts when color and depth permit them. + #[test] + fn supported_sample_count_is_preserved() { + let builder = RenderPassBuilder::new().with_depth().with_multi_sample(4); + + let resolved = builder.resolve_sample_count( + 4, + surface_format(), + platform::texture::DepthFormat::Depth32Float, + |_samples| { + return true; + }, + |_format, _samples| { + return true; + }, + ); + + assert_eq!(resolved, 4); + } + + /// Clamps a zero sample count to one before validation. + #[test] + fn zero_sample_count_is_clamped_to_one() { + let builder = RenderPassBuilder::new().without_color(); + + let resolved = builder.resolve_sample_count( + 0, + surface_format(), + platform::texture::DepthFormat::Depth32Float, + |_samples| { + return true; + }, + |_format, _samples| { + return true; + }, + ); + + assert_eq!(resolved, 1); + } +} diff --git a/docs/specs/depth-stencil-msaa.md b/docs/specs/depth-stencil-msaa.md index f07423f8..65bd6ae7 100644 --- a/docs/specs/depth-stencil-msaa.md +++ b/docs/specs/depth-stencil-msaa.md @@ -3,13 +3,13 @@ title: "Depth/Stencil and Multi-Sample Rendering" document_id: "depth-stencil-msaa-2025-11-11" status: "draft" created: "2025-11-11T00:00:00Z" -last_updated: "2025-11-17T23:59:59Z" -version: "0.3.1" +last_updated: "2025-11-21T21:27:43Z" +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: "709054fcc1fa678ac5f0611877b759bad93decd0" +repo_commit: "deb8aff8fe4caed5f6d1941962cefcf2a14b7890" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "rendering", "depth", "stencil", "msaa"] @@ -22,6 +22,8 @@ Summary to the high-level rendering API via builders, without exposing `wgpu` types. - Provide validation and predictable defaults to enable 3D scenes and higher-quality rasterization in example and production code. +- Reject unsupported sample counts based on device format capabilities while + defaulting to safe fallbacks. ## Scope @@ -141,6 +143,9 @@ App Code clamped to `1` during `with_multi_sample(...)`. - On pipeline build, if the pipeline sample count differs from the pass, the engine aligns the pipeline to the pass and logs an error. + - Device capability validation rejects unsupported sample counts for the + surface format and active depth/stencil format, logging and falling back to + `1` when necessary. - Depth clear validation - Clear values outside `[0.0, 1.0]` SHOULD be rejected; current engine path relies on caller-provided sane values and `wgpu` validation. A strict check @@ -155,6 +160,7 @@ App Code - `render-validation-msaa`: validate/log MSAA counts; pass/pipeline mismatch logs. - `render-validation-depth`: clamp/log depth clear; depth usage advisories. - `render-validation-stencil`: stencil usage/format upgrade advisories. + - `render-validation-device`: device/format capability advisories (MSAA sample support). Always-on safeguards (release and debug) - Clamp depth clear to `[0.0, 1.0]`. @@ -201,17 +207,14 @@ Always-on safeguards (release and debug) - Validation and Errors - [x] Sample counts limited to {1,2,4,8}; invalid → clamp to 1 (log via features) - [x] Pass/pipeline sample mismatch → align to pass (log via features) - - [x] Depth clear clamped to [0.0, 1.0] (log via features); device support (SHOULD) + - [x] Depth clear clamped to [0.0, 1.0] (log via features) + - [x] Device/format MSAA support check with fallback to 1 - Performance - - [ ] 4x MSAA guidance; memory trade-offs for `Depth32Float` vs `Depth24Plus` - - [ ] Recommend disabling depth writes for overlays/transparency + - [x] 4x MSAA guidance; memory trade-offs for `Depth32Float` vs `Depth24Plus` + - [x] Recommend disabling depth writes for overlays/transparency - Documentation and Examples - [ ] Minimal MSAA + depth example - - [ ] Reflective mirror (stencil) tutorial - - [ ] Migration notes (none; additive API) - -For each checked item, include a reference to a commit, pull request, or file -path that demonstrates the implementation. + - [x] Reflective mirror (stencil) tutorial ## Verification and Testing @@ -234,6 +237,7 @@ path that demonstrates the implementation. defaults (no depth, no multi-sampling) unless explicitly configured. ## Changelog +- 2025-11-21 (v0.4.0) — Add device/format sample-count validation with fallback to 1; update metadata and checklist; record implementation references for depth/stencil/MSAA. - 2025-11-17 (v0.3.1) — Remove umbrella validation flags from this spec; list only feature flags related to MSAA, depth, and stencil; metadata updated. - 2025-11-11 (v0.1.1) — Add MSAA validation in builders; align pipeline and From 415167f4238c21debb385eef1192e2da7476c586 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 21 Nov 2025 14:40:41 -0800 Subject: [PATCH 20/25] [update] render context to better handle depth attachment for stencil only passes, fix depth clearing on stencil only passses, and add tests. --- .../src/wgpu/render_pass.rs | 12 +- crates/lambda-rs/src/render/mod.rs | 118 +++++++++++++----- 2 files changed, 95 insertions(+), 35 deletions(-) diff --git a/crates/lambda-rs-platform/src/wgpu/render_pass.rs b/crates/lambda-rs-platform/src/wgpu/render_pass.rs index 2e823aa5..bbaed6c6 100644 --- a/crates/lambda-rs-platform/src/wgpu/render_pass.rs +++ b/crates/lambda-rs-platform/src/wgpu/render_pass.rs @@ -365,12 +365,12 @@ impl RenderPassBuilder { // Apply operations to all provided attachments. attachments.set_operations_for_all(operations); - // Optional depth/stencil attachment. Include stencil ops only when provided - // to avoid referring to a stencil aspect on depth-only formats. + // Optional depth/stencil attachment. Include depth or stencil ops only + // when provided to avoid touching aspects that were not requested. let depth_stencil_attachment = depth_view.map(|v| { - // Map depth ops (defaulting when not provided) - let dop = depth_ops.unwrap_or_default(); - let mapped_depth_ops = Some(match dop.load { + // Map depth ops only when explicitly provided; when `None`, preserve the + // depth aspect, which is important for stencil-only passes. + let mapped_depth_ops = depth_ops.map(|dop| match dop.load { DepthLoadOp::Load => wgpu::Operations { load: wgpu::LoadOp::Load, store: match dop.store { @@ -387,7 +387,7 @@ impl RenderPassBuilder { }, }); - // Map stencil ops only if explicitly provided + // Map stencil ops only if explicitly provided. let mapped_stencil_ops = stencil_ops.map(|sop| match sop.load { StencilLoadOp::Load => wgpu::Operations { load: wgpu::LoadOp::Load, diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index df2f7156..d8e23ce5 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -482,8 +482,10 @@ impl RenderContext { } // Depth/stencil attachment when either depth or stencil requested. - let want_depth_attachment = pass.depth_operations().is_some() - || pass.stencil_operations().is_some(); + let want_depth_attachment = Self::has_depth_attachment( + pass.depth_operations(), + pass.stencil_operations(), + ); let (depth_view, depth_ops) = if want_depth_attachment { // Ensure depth texture exists, with proper sample count and format. @@ -533,29 +535,10 @@ impl RenderContext { .expect("depth texture should be present") .view_ref(); - // Map depth ops; default when not explicitly provided. - let mapped = match pass.depth_operations() { - Some(dops) => platform::render_pass::DepthOperations { - load: match dops.load { - render_pass::DepthLoadOp::Load => { - platform::render_pass::DepthLoadOp::Load - } - render_pass::DepthLoadOp::Clear(v) => { - platform::render_pass::DepthLoadOp::Clear(v as f32) - } - }, - store: match dops.store { - render_pass::StoreOp::Store => { - platform::render_pass::StoreOp::Store - } - render_pass::StoreOp::Discard => { - platform::render_pass::StoreOp::Discard - } - }, - }, - None => platform::render_pass::DepthOperations::default(), - }; - (Some(view_ref), Some(mapped)) + // Map depth operations when explicitly provided; leave depth + // untouched for stencil-only passes. + let depth_ops = Self::map_depth_ops(pass.depth_operations()); + (Some(view_ref), depth_ops) } else { (None, None) }; @@ -593,7 +576,7 @@ impl RenderContext { self.encode_pass( &mut pass_encoder, pass.uses_color(), - pass.depth_operations().is_some(), + want_depth_attachment, pass.stencil_operations().is_some(), viewport, &mut command_iter, @@ -618,7 +601,7 @@ impl RenderContext { &self, pass: &mut platform::render_pass::RenderPass<'_>, uses_color: bool, - pass_has_depth: bool, + pass_has_depth_attachment: bool, pass_has_stencil: bool, initial_viewport: viewport::Viewport, commands: &mut I, @@ -675,7 +658,9 @@ impl RenderContext { label ))); } - if !pass_has_depth && pipeline_ref.expects_depth_stencil() { + if !pass_has_depth_attachment + && pipeline_ref.expects_depth_stencil() + { let label = pipeline_ref.pipeline().label().unwrap_or("unnamed"); return Err(RenderError::Configuration(format!( "Render pipeline '{}' expects a depth/stencil attachment but the current pass has none", @@ -711,7 +696,7 @@ impl RenderContext { ); util::warn_once(&key, &msg); } - if pass_has_depth + if pass_has_depth_attachment && !pipeline_ref.expects_depth_stencil() && warned_no_depth_for_pipeline.insert(pipeline) { @@ -865,6 +850,38 @@ impl RenderContext { self.config = config; return Ok(()); } + + /// Determine whether a pass requires a depth attachment based on depth or + /// stencil operations. + fn has_depth_attachment( + depth_ops: Option, + stencil_ops: Option, + ) -> bool { + return depth_ops.is_some() || stencil_ops.is_some(); + } + + /// Map high-level depth operations to platform depth operations, returning + /// `None` when no depth operations were requested. + fn map_depth_ops( + depth_ops: Option, + ) -> Option { + return depth_ops.map(|dops| platform::render_pass::DepthOperations { + load: match dops.load { + render_pass::DepthLoadOp::Load => { + platform::render_pass::DepthLoadOp::Load + } + render_pass::DepthLoadOp::Clear(value) => { + platform::render_pass::DepthLoadOp::Clear(value as f32) + } + }, + store: match dops.store { + render_pass::StoreOp::Store => platform::render_pass::StoreOp::Store, + render_pass::StoreOp::Discard => { + platform::render_pass::StoreOp::Discard + } + }, + }); + } } /// Errors reported while preparing or presenting a frame. @@ -909,3 +926,46 @@ impl core::fmt::Display for RenderContextError { } impl std::error::Error for RenderContextError {} + +#[cfg(test)] +mod tests { + use super::*; + use crate::render::render_pass; + + #[test] + fn has_depth_attachment_false_when_no_depth_or_stencil() { + let has_attachment = RenderContext::has_depth_attachment(None, None); + assert!(!has_attachment); + } + + #[test] + fn has_depth_attachment_true_for_depth_only() { + let depth_ops = Some(render_pass::DepthOperations::default()); + let has_attachment = RenderContext::has_depth_attachment(depth_ops, None); + assert!(has_attachment); + } + + #[test] + fn has_depth_attachment_true_for_stencil_only() { + let stencil_ops = Some(render_pass::StencilOperations::default()); + let has_attachment = RenderContext::has_depth_attachment(None, stencil_ops); + assert!(has_attachment); + } + + #[test] + fn map_depth_ops_none_when_no_depth_operations() { + let mapped = RenderContext::map_depth_ops(None); + assert!(mapped.is_none()); + } + + #[test] + fn map_depth_ops_maps_clear_and_store() { + let depth_ops = render_pass::DepthOperations { + load: render_pass::DepthLoadOp::Clear(0.5), + store: render_pass::StoreOp::Store, + }; + let mapped = RenderContext::map_depth_ops(Some(depth_ops)).expect("mapped"); + assert_eq!(mapped.load, platform::render_pass::DepthLoadOp::Clear(0.5)); + assert_eq!(mapped.store, platform::render_pass::StoreOp::Store); + } +} From 1f91ff4ec776ec5435fce8a53441010d9e0c86e6 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 21 Nov 2025 14:45:15 -0800 Subject: [PATCH 21/25] [update] specification. --- docs/specs/depth-stencil-msaa.md | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/docs/specs/depth-stencil-msaa.md b/docs/specs/depth-stencil-msaa.md index 65bd6ae7..99be7d10 100644 --- a/docs/specs/depth-stencil-msaa.md +++ b/docs/specs/depth-stencil-msaa.md @@ -3,13 +3,13 @@ title: "Depth/Stencil and Multi-Sample Rendering" document_id: "depth-stencil-msaa-2025-11-11" status: "draft" created: "2025-11-11T00:00:00Z" -last_updated: "2025-11-21T21:27:43Z" -version: "0.4.0" +last_updated: "2025-11-21T22:00:00Z" +version: "0.4.1" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "deb8aff8fe4caed5f6d1941962cefcf2a14b7890" +repo_commit: "415167f4238c21debb385eef1192e2da7476c586" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "rendering", "depth", "stencil", "msaa"] @@ -104,23 +104,27 @@ App Code ``` - Behavior - Defaults - - If depth is not requested on the pass (`with_depth*`), the pass MUST NOT + - If neither depth nor stencil is requested on the pass, the pass MUST NOT create a depth attachment and depth testing is disabled. - - Depth clear defaults to `1.0` when depth is enabled on the pass and no - explicit clear is provided. + - When depth operations are enabled on the pass, the depth aspect defaults + to a clear value of `1.0` when no explicit clear is provided. - Pipeline depth compare defaults to `CompareFunction::Less` when depth is enabled for a pipeline and no explicit compare is provided. - `MultiSample.sample_count` defaults to `1` (no multi-sampling). - Attachment creation - When depth is requested (`with_depth`/`with_depth_clear`), the pass MUST - create a depth attachment. When stencil operations are requested on the - pass (`with_stencil`/`with_stencil_clear`), the pass MUST attach a + create a depth attachment. + - When stencil operations are requested on the pass + (`with_stencil`/`with_stencil_clear`), the pass MUST attach a depth/stencil view and the depth format MUST include a stencil aspect. - If stencil is requested but the current depth format lacks a stencil aspect, the engine upgrades to `Depth24PlusStencil8` at pass build time or during encoding and logs an error. - - The pass MUST clear the depth aspect to `1.0` by default (or the provided - value) and clear/load stencil according to the requested ops. + - When depth operations are present, the depth aspect MUST be cleared or + loaded according to the configured depth ops (defaulting to a clear of + `1.0` when no explicit clear is provided). When only stencil operations + are present, the stencil aspect MUST be cleared or loaded according to + the configured stencil ops and the depth aspect MUST remain untouched. - Multi-sample semantics - When `sample_count > 1`, the pass MUST render into a multi-sampled color target and resolve to the single-sample swap chain target before present. @@ -175,7 +179,9 @@ Always-on safeguards (release and debug) platform layer MUST query support before allocation. - Depth clear values MUST be clamped to [0.0, 1.0] during validation. - When the pass has no depth attachment, pipelines MUST behave as if depth - testing and depth writes are disabled. + testing and depth writes are disabled. Stencil-only passes still bind a + depth/stencil attachment; in this case the stencil aspect is active and the + depth aspect MUST remain unchanged when no depth operations are configured. ## Performance Considerations @@ -237,6 +243,9 @@ Always-on safeguards (release and debug) defaults (no depth, no multi-sampling) unless explicitly configured. ## Changelog +- 2025-11-21 (v0.4.1) — Clarify depth attachment and clear behavior for + stencil-only passes; align specification with engine behavior that preserves + depth when only stencil operations are configured. - 2025-11-21 (v0.4.0) — Add device/format sample-count validation with fallback to 1; update metadata and checklist; record implementation references for depth/stencil/MSAA. - 2025-11-17 (v0.3.1) — Remove umbrella validation flags from this spec; list only feature flags related to MSAA, depth, and stencil; metadata updated. From 4efec32bc2c43bdc91b291757af99c9ba7145988 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 21 Nov 2025 14:53:16 -0800 Subject: [PATCH 22/25] [update] tutorial. --- docs/tutorials/reflective-room.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/tutorials/reflective-room.md b/docs/tutorials/reflective-room.md index fc3029f1..b4d7e4eb 100644 --- a/docs/tutorials/reflective-room.md +++ b/docs/tutorials/reflective-room.md @@ -3,13 +3,13 @@ title: "Reflective Floor: Stencil‑Masked Planar Reflections" document_id: "reflective-room-tutorial-2025-11-17" status: "draft" created: "2025-11-17T00:00:00Z" -last_updated: "2025-11-19T00:00:01Z" -version: "0.2.1" +last_updated: "2025-11-21T00:00:00Z" +version: "0.2.2" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "bf0e90ae9ce653e1da2e1e594b22038094bada07" +repo_commit: "1f91ff4ec776ec5435fce8a53441010d9e0c86e6" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["tutorial", "graphics", "stencil", "depth", "msaa", "mirror", "3d", "push-constants", "wgpu", "rust"] @@ -422,5 +422,6 @@ The reflective floor combines a simple stencil mask with an optional depth test ## Changelog +- 2025-11-21, 0.2.2: Align tutorial with removal of the unmasked reflection debug toggle in the example and update metadata to the current engine workspace commit. - 0.2.0 (2025‑11‑19): Updated for camera pitch, front‑face culling on reflection, lit translucent floor, unmasked reflection debug toggle, floor overlay toggle, and Metal portability note. - 0.1.0 (2025‑11‑17): Initial draft aligned with `crates/lambda-rs/examples/reflective_room.rs`, including stencil mask pass, reflected pipeline, and MSAA/depth toggles. From 2730e86d74fd80188d469684d68b947541a270d2 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 21 Nov 2025 15:03:06 -0800 Subject: [PATCH 23/25] [update] depth format not be global. --- crates/lambda-rs/src/render/pipeline.rs | 35 ++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/lambda-rs/src/render/pipeline.rs b/crates/lambda-rs/src/render/pipeline.rs index f33ca05b..9b40e1c0 100644 --- a/crates/lambda-rs/src/render/pipeline.rs +++ b/crates/lambda-rs/src/render/pipeline.rs @@ -464,10 +464,37 @@ impl RenderPipelineBuilder { ); dfmt = texture::DepthFormat::Depth24PlusStencil8; } - // Map to platform and keep context depth format in sync for attachment creation. - let dfmt_platform = dfmt.to_platform(); - render_context.depth_format = dfmt_platform; - rp_builder = rp_builder.with_depth_stencil(dfmt_platform); + + let requested_depth_format = dfmt.to_platform(); + + // Derive the pass attachment depth format from pass configuration. + let pass_has_stencil = _render_pass.stencil_operations().is_some(); + let pass_depth_format = if pass_has_stencil { + platform_texture::DepthFormat::Depth24PlusStencil8 + } else { + render_context.depth_format() + }; + + // Align the pipeline depth format with the pass attachment format to + // avoid hidden global state on the render context. When formats differ, + // prefer the pass attachment format and log for easier debugging. + let final_depth_format = if requested_depth_format != pass_depth_format { + #[cfg(any( + debug_assertions, + feature = "render-validation-depth", + feature = "render-validation-stencil", + ))] + logging::error!( + "Render pipeline depth format {:?} does not match pass depth attachment format {:?}; aligning pipeline to pass format", + requested_depth_format, + pass_depth_format + ); + pass_depth_format + } else { + pass_depth_format + }; + + rp_builder = rp_builder.with_depth_stencil(final_depth_format); if let Some(compare) = self.depth_compare { rp_builder = rp_builder.with_depth_compare(compare.to_platform()); } From 2874e821050969e69f0ef3c71ed8176160ef0dff Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 21 Nov 2025 15:09:50 -0800 Subject: [PATCH 24/25] [fix] depth clear clamp check. --- crates/lambda-rs/src/render/render_pass.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/lambda-rs/src/render/render_pass.rs b/crates/lambda-rs/src/render/render_pass.rs index 8c4b38ba..6d95fd63 100644 --- a/crates/lambda-rs/src/render/render_pass.rs +++ b/crates/lambda-rs/src/render/render_pass.rs @@ -207,7 +207,7 @@ impl RenderPassBuilder { // Optionally log when clamping is applied. #[cfg(any(debug_assertions, feature = "render-validation-depth",))] { - if (clamped - clear).abs() > f64::EPSILON { + if clamped != clear { logging::warn!( "Depth clear value {} out of range [0,1]; clamped to {}", clear, From 224784146bf164b7c0ca368aa32b33dede712497 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 21 Nov 2025 15:36:14 -0800 Subject: [PATCH 25/25] [fix] warn once logger to ensure that it can't grow too large. --- crates/lambda-rs/src/util/mod.rs | 40 +++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/lambda-rs/src/util/mod.rs b/crates/lambda-rs/src/util/mod.rs index 59a127d6..676be7be 100644 --- a/crates/lambda-rs/src/util/mod.rs +++ b/crates/lambda-rs/src/util/mod.rs @@ -6,15 +6,32 @@ //! logging of advisories that would otherwise spam every frame). use std::{ - collections::HashSet, + collections::{ + HashSet, + VecDeque, + }, sync::{ Mutex, OnceLock, }, }; -/// Global, process-wide de-duplication set for warn-once messages. -static WARN_ONCE_KEYS: OnceLock>> = OnceLock::new(); +/// Maximum number of unique warn-once keys to retain in memory. +const WARN_ONCE_MAX_KEYS: usize = 1024; + +/// Global, process-wide state for warn-once messages. +/// +/// The state uses a hash set for membership checks and a queue to track +/// insertion order, allowing the cache to evict the oldest keys when the +/// capacity is reached. +#[derive(Default)] +struct WarnOnceState { + seen_keys: HashSet, + key_order: VecDeque, +} + +/// Global, process-wide de-duplication cache for warn-once messages. +static WARN_ONCE_KEYS: OnceLock> = OnceLock::new(); /// Log a warning message at most once per unique `key` across the process. /// @@ -24,13 +41,24 @@ static WARN_ONCE_KEYS: OnceLock>> = OnceLock::new(); /// panics. pub fn warn_once(key: &str, message: &str) { let set = WARN_ONCE_KEYS.get_or_init(|| { - return Mutex::new(HashSet::new()); + return Mutex::new(WarnOnceState::default()); }); match set.lock() { Ok(mut guard) => { - if guard.insert(key.to_string()) { - logging::warn!("{}", message); + if guard.seen_keys.contains(key) { + return; } + + if guard.seen_keys.len() >= WARN_ONCE_MAX_KEYS { + if let Some(oldest_key) = guard.key_order.pop_front() { + guard.seen_keys.remove(&oldest_key); + } + } + + let key_string = key.to_string(); + guard.seen_keys.insert(key_string.clone()); + guard.key_order.push_back(key_string); + logging::warn!("{}", message); return; } Err(_) => {