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/pipeline.rs b/crates/lambda-rs-platform/src/wgpu/pipeline.rs index 5a4cfba3..c0a99f90 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 { @@ -202,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. @@ -212,6 +305,7 @@ pub struct RenderPipelineBuilder<'a> { cull_mode: CullingMode, color_target_format: Option, depth_stencil: Option, + sample_count: u32, } impl<'a> RenderPipelineBuilder<'a> { @@ -224,6 +318,7 @@ impl<'a> RenderPipelineBuilder<'a> { cull_mode: CullingMode::Back, color_target_format: None, depth_stencil: None, + sample_count: 1, }; } @@ -275,6 +370,56 @@ 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); + return self; + } + /// Build the render pipeline from provided shader modules. pub fn build( self, @@ -351,7 +496,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..bbaed6c6 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 { @@ -210,6 +235,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, @@ -291,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 { @@ -317,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 depth or stencil ops only + // when provided to avoid touching aspects that were not requested. let depth_stencil_attachment = depth_view.map(|v| { - 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, - }, + // 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 { + 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, } }); @@ -354,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-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-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-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/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/examples/reflective_room.rs b/crates/lambda-rs/examples/reflective_room.rs new file mode 100644 index 00000000..54fa7f27 --- /dev/null +++ b/crates/lambda-rs/examples/reflective_room.rs @@ -0,0 +1,955 @@ +#![allow(clippy::needless_return)] + +//! Example: Reflective floor using the stencil buffer with MSAA. +//! +//! - 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 +//! 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, + BufferType, + Properties, + Usage, + }, + command::RenderCommand, + mesh::{ + Mesh, + MeshBuilder, + }, + pipeline::{ + CompareFunction, + CullingMode, + PipelineStage, + RenderPipelineBuilder, + StencilFaceState, + StencilOperation, + StencilState, + }, + render_pass::RenderPassBuilder, + scene_math::{ + compute_model_matrix, + compute_perspective_projection, + compute_view_matrix, + SimpleCamera, + }, + shader::{ + Shader, + ShaderBuilder, + ShaderKind, + VirtualShader, + }, + texture::DepthFormat, + vertex::{ + ColorFormat, + Vertex, + VertexAttribute, + VertexBuilder, + VertexElement, + }, + viewport::ViewportBuilder, + ResourceId, + }, + runtime::start_runtime, + runtimes::{ + application::ComponentResult, + ApplicationRuntime, + ApplicationRuntimeBuilder, + }, +}; + +// ------------------------------ SHADER SOURCE -------------------------------- + +const VERTEX_SHADER_SOURCE: &str = r#" +#version 450 + +layout (location = 0) in vec3 vertex_position; +layout (location = 1) in vec3 vertex_normal; + +layout (location = 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); + // 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); +} +"#; + +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) in vec3 v_world_normal; +layout (location = 0) out vec4 fragment_color; + +void main() { + // 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); +} +"#; + +// (No extra fragment shaders needed; the floor mask uses a vertex-only pipeline.) + +// ------------------------------ 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_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, + // Toggleable demo settings + msaa_samples: u32, + 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, +} + +impl Component for ReflectiveRoomExample { + fn on_attach( + &mut self, + render_context: &mut lambda::render::RenderContext, + ) -> Result { + logging::info!("Attaching ReflectiveRoomExample"); + + // 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( + &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; + } + _ => {} + }, + 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 + ); + } + Some(lambda::events::VirtualKey::KeyF) => { + self.mirror_mode = !self.mirror_mode; + logging::info!( + "Toggled Mirror Mode (hide floor overlay) → {} (key: F)", + self.mirror_mode + ); + } + // 'R' previously forced an unmasked reflection; now disabled. + 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 + ); + } + _ => {} + }, + _ => {} + }, + _ => {} + } + 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 { + 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, self.camera_height, self.camera_distance], + 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; + // 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, + ); + + // 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), + self.height.max(1), + camera.near_clipping_plane, + camera.far_clipping_plane, + ); + let mvp = projection.multiply(&view).multiply(&model); + + // Compute reflected transform only if stencil/reflection is enabled. + let (model_reflect, mvp_reflect) = if self.stencil_enabled { + // 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 - 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], + ]; + let mr = s_mirror.multiply(&model); + 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: 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); + + 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)) = + (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..floor_vertex_count, + }); + 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"); + 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..cube_vertex_count, + }); + } + } + + // Floor surface (tinted) + 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"); + 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); + + return cmds; + } +} + +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_mask: None, + pass_id_color: None, + pipe_floor_mask: None, + pipe_reflected: None, + pipe_floor_visual: None, + pipe_normal: None, + width: 800, + height: 600, + elapsed: 0.0, + msaa_samples: 4, + 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, + }; + } +} + +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") + // 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) + .with_push_constant(PipelineStage::VERTEX, push_constants_size) + .with_buffer( + 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(), + ) + .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") + // 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( + 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_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) + // 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, + &self.shader_vs, + Some(&self.shader_fs_lit), + ); + Some(render_context.attach_pipeline(p)) + } else { + None + }; + + // No unmasked reflection pipeline in production example. + + // 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::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(), + ) + .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::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); + 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 { + // 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]; + 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 winding flipped to face +Y (avoid back-face cull) + // Tri 1 + mesh_builder.with_vertex(p0); + mesh_builder.with_vertex(p2); + mesh_builder.with_vertex(p1); + // Tri 2 + mesh_builder.with_vertex(p0); + mesh_builder.with_vertex(p3); + mesh_builder.with_vertex(p2); + + 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/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 87456e3f..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, @@ -39,6 +41,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 81e7c22f..d8e23ce5 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. /// @@ -174,11 +176,15 @@ 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![], bind_groups: vec![], buffers: vec![], + seen_error_messages: HashSet::new(), }); } } @@ -210,11 +216,15 @@ 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, bind_groups: Vec, buffers: Vec>, + seen_error_messages: HashSet, } /// Opaque handle used to refer to resources attached to a `RenderContext`. @@ -284,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); + } } } @@ -304,9 +317,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. @@ -331,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; @@ -412,56 +451,136 @@ 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); - - // 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() { - 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_label("lambda-depth") - .build(self.gpu()), + let sample_count = pass.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 mut view_ref = self - .depth_texture + let msaa_view = self + .msaa_color .as_ref() - .expect("depth texture should be present") + .expect("MSAA color attachment should be created") .view_ref(); - let mapped = 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 - } - }, - }; - (Some(view_ref), Some(mapped)) + color_attachments.push_msaa_color(msaa_view, view); + } else { + color_attachments.push_color(view); + } + } + + // Depth/stencil attachment when either depth or stencil requested. + 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. + 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 + { + #[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 + ); + self.depth_format = + platform::texture::DepthFormat::Depth24PlusStencil8; } - None => (None, None), + + 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 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) }; + // 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)?; + self.encode_pass( + &mut pass_encoder, + pass.uses_color(), + want_depth_attachment, + pass.stencil_operations().is_some(), + viewport, + &mut command_iter, + )?; } other => { logging::warn!( @@ -481,6 +600,9 @@ impl RenderContext { fn encode_pass( &self, pass: &mut platform::render_pass::RenderPass<'_>, + uses_color: bool, + pass_has_depth_attachment: bool, + pass_has_stencil: bool, initial_viewport: viewport::Viewport, commands: &mut I, ) -> Result<(), RenderError> @@ -488,10 +610,26 @@ impl RenderContext { I: Iterator, { Self::apply_viewport(pass, &initial_viewport); + // 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(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() { 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(|| { @@ -499,6 +637,78 @@ impl RenderContext { "Unknown pipeline {pipeline}" )); })?; + // 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"); + 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_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", + label + ))); + } + } + // 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() + && 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_attachment + && !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, .. } => { @@ -640,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. @@ -684,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); + } +} diff --git a/crates/lambda-rs/src/render/pipeline.rs b/crates/lambda-rs/src/render/pipeline.rs index 0183d5ad..9b40e1c0 100644 --- a/crates/lambda-rs/src/render/pipeline.rs +++ b/crates/lambda-rs/src/render/pipeline.rs @@ -34,15 +34,18 @@ use lambda_platform::wgpu::{ pipeline as platform_pipeline, texture as platform_texture, }; +use logging; use super::{ bind, buffer::Buffer, render_pass::RenderPass, shader::Shader, + texture, vertex::VertexAttribute, RenderContext, }; +use crate::render::validation; /// A created graphics pipeline and the vertex buffers it expects. #[derive(Debug)] @@ -51,6 +54,10 @@ use super::{ pub struct RenderPipeline { pipeline: Rc, buffers: Vec>, + sample_count: u32, + color_target_count: u32, + expects_depth_stencil: bool, + uses_stencil: bool, } impl RenderPipeline { @@ -66,6 +73,26 @@ 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); + } + + /// 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. @@ -79,8 +106,120 @@ struct BufferBinding { attributes: Vec, } -/// 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)] +pub enum StencilOperation { + Keep, + Zero, + Replace, + Invert, + IncrementClamp, + DecrementClamp, + IncrementWrap, + DecrementWrap, +} + +impl StencilOperation { + fn to_platform(self) -> platform_pipeline::StencilOperation { + return 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.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(), + } + } +} + +/// 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`. /// @@ -96,6 +235,11 @@ pub struct RenderPipelineBuilder { bind_group_layouts: Vec, label: Option, use_depth: bool, + depth_format: Option, + sample_count: u32, + depth_compare: Option, + stencil: Option, + depth_write_enabled: Option, } impl RenderPipelineBuilder { @@ -108,6 +252,11 @@ impl RenderPipelineBuilder { bind_group_layouts: Vec::new(), label: None, use_depth: false, + depth_format: None, + sample_count: 1, + depth_compare: None, + stencil: None, + depth_write_enabled: None, } } @@ -158,6 +307,57 @@ impl RenderPipelineBuilder { return self; } + /// Enable depth with an explicit depth format. + pub fn with_depth_format(mut self, format: 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 { + // 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; + } + return self; + } + + /// Set a non-default depth compare function. + pub fn with_depth_compare(mut self, compare: CompareFunction) -> Self { + self.depth_compare = Some(compare); + return self; + } + + /// 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; + } + + /// 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( @@ -226,7 +426,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 @@ -249,9 +449,84 @@ impl RenderPipelineBuilder { } if self.use_depth { - rp_builder = rp_builder - .with_depth_stencil(platform_texture::DepthFormat::Depth32Float); + // Engine-level depth format with default + let mut dfmt = self + .depth_format + .unwrap_or(texture::DepthFormat::Depth32Float); + // If stencil state is configured, ensure a stencil-capable depth format. + if self.stencil.is_some() + && dfmt != texture::DepthFormat::Depth24PlusStencil8 + { + #[cfg(any(debug_assertions, feature = "render-validation-stencil",))] + logging::error!( + "Stencil configured but depth format {:?} lacks stencil; upgrading to Depth24PlusStencil8", + dfmt + ); + dfmt = texture::DepthFormat::Depth24PlusStencil8; + } + + 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()); + } + 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. + // Always align to the pass sample count; gate logs. + let mut pipeline_samples = self.sample_count; + 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); let pipeline = rp_builder.build( render_context.gpu(), @@ -262,6 +537,11 @@ impl RenderPipelineBuilder { return RenderPipeline { 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 6dc732db..6d95fd63 100644 --- a/crates/lambda-rs/src/render/render_pass.rs +++ b/crates/lambda-rs/src/render/render_pass.rs @@ -1,10 +1,15 @@ //! 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 lambda_platform::wgpu as platform; +use logging; + use super::RenderContext; +use crate::render::validation; /// Color load operation for the first color attachment. #[derive(Debug, Clone, Copy, PartialEq)] @@ -75,6 +80,9 @@ pub struct RenderPass { label: Option, color_operations: ColorOperations, depth_operations: Option, + stencil_operations: Option, + sample_count: u32, + use_color: bool, } impl RenderPass { @@ -96,6 +104,19 @@ 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); + } + + 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. @@ -108,17 +129,23 @@ pub struct RenderPassBuilder { label: Option, color_operations: ColorOperations, depth_operations: Option, + stencil_operations: Option, + sample_count: u32, + use_color: bool, } 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, + use_color: true, + }; } /// Specify the clear color used for the first color attachment. @@ -128,13 +155,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. @@ -161,6 +188,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()); @@ -169,20 +202,292 @@ 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 { + logging::warn!( + "Depth clear value {} out of range [0,1]; clamped to {}", + clear, + clamped + ); + } + } + self.depth_operations = Some(DepthOperations { + load: DepthLoadOp::Clear(clamped), + store: StoreOp::Store, + }); + 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::Clear(clear), + 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()); + 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; + } + + /// 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 { + // 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; + } + return self; + } + /// Build the description used when beginning a render pass. - pub fn build(self, _render_context: &RenderContext) -> RenderPass { - 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, + 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. +#[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, + }; + } +} + +#[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/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 { 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/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() diff --git a/crates/lambda-rs/src/util/mod.rs b/crates/lambda-rs/src/util/mod.rs new file mode 100644 index 00000000..676be7be --- /dev/null +++ b/crates/lambda-rs/src/util/mod.rs @@ -0,0 +1,69 @@ +#![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, + VecDeque, + }, + sync::{ + Mutex, + OnceLock, + }, +}; + +/// 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. +/// +/// - `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(WarnOnceState::default()); + }); + match set.lock() { + Ok(mut guard) => { + 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(_) => { + logging::warn!("{}", message); + return; + } + } +} 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 new file mode 100644 index 00000000..99be7d10 --- /dev/null +++ b/docs/specs/depth-stencil-msaa.md @@ -0,0 +1,254 @@ +--- +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-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: "415167f4238c21debb385eef1192e2da7476c586" +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 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 + +- Goals + - Expose depth/stencil and multi-sample configuration on `RenderPassBuilder` + 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. +- 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/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/platform 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 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([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_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::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); + + 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)); + ``` +- Behavior + - Defaults + - If neither depth nor stencil is requested on the pass, the pass MUST NOT + create a depth attachment and depth testing is disabled. + - 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 + 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. + - 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. + - 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. When a pipeline + enables stencil, the engine upgrades its depth format to + `Depth24PlusStencil8` to guarantee compatibility. + +## Validation and Errors + +- 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. + - 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 + 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. + - `render-validation-device`: device/format capability advisories (MSAA sample support). + +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 + 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. 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 + +- 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 + - [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 + - [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 + - [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) + - [x] Device/format MSAA support check with fallback to 1 +- Performance + - [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 + - [x] Reflective mirror (stencil) tutorial + +## 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-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. +- 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. 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..b4d7e4eb --- /dev/null +++ b/docs/tutorials/reflective-room.md @@ -0,0 +1,427 @@ +--- +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-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: "1f91ff4ec776ec5435fce8a53441010d9e0c86e6" +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 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`. + +## 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, plus camera pitch and visibility helpers. + +## 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 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. +- 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 front faces; 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. The floor fragment shader is lit and translucent so the reflection reads beneath it. + +```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); + // Transform normals by the model matrix; sufficient for rigid + mirror. + 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: lit + translucent) +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.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. + +```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. 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::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) + .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) + .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 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, 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; +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 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); +``` + +### 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, 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`. + +## 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 `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 + +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 + +- 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.