diff --git a/.github/workflows/compile_lambda_rs.yml b/.github/workflows/compile_lambda_rs.yml index c93acafa..7c91d0eb 100644 --- a/.github/workflows/compile_lambda_rs.yml +++ b/.github/workflows/compile_lambda_rs.yml @@ -72,8 +72,23 @@ jobs: if: ${{ matrix.os == 'ubuntu-latest' }} run: | echo "WGPU_BACKEND=vulkan" >> "$GITHUB_ENV" - # Prefer Mesa's software Vulkan (lavapipe) to ensure headless availability - echo "VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json" >> "$GITHUB_ENV" + # Prefer Mesa's software Vulkan (lavapipe) to ensure headless availability. + # The exact ICD filename can differ across Ubuntu images, so discover it. + LVP_ICD="$( + if [[ -d /usr/share/vulkan/icd.d ]]; then + find /usr/share/vulkan/icd.d -maxdepth 1 -type f \ + \( -name '*lvp_icd*.json' -o -name '*lavapipe*.json' \) \ + -print 2>/dev/null | head -n1 + fi + )" + if [[ -z "$LVP_ICD" ]]; then + echo "lavapipe Vulkan ICD not found under /usr/share/vulkan/icd.d" >&2 + ls -la /usr/share/vulkan/icd.d || true + else + echo "Using lavapipe ICD: $LVP_ICD" + echo "VK_ICD_FILENAMES=$LVP_ICD" >> "$GITHUB_ENV" + fi + echo "LAMBDA_REQUIRE_GPU_ADAPTER=1" >> "$GITHUB_ENV" vulkaninfo --summary || true # Windows runners already include the required toolchain for DX12 builds. diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 18ed3e0d..d9c68179 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -23,6 +23,8 @@ jobs: coverage: name: Generate code coverage with cargo-llvm-cov runs-on: ubuntu-latest + env: + LAMBDA_REQUIRE_GPU_ADAPTER: "1" steps: - name: Checkout Repository @@ -60,8 +62,22 @@ jobs: - name: Configure Vulkan (Ubuntu) run: | echo "WGPU_BACKEND=vulkan" >> "$GITHUB_ENV" - # Prefer Mesa's software Vulkan (lavapipe) for headless availability - echo "VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json" >> "$GITHUB_ENV" + # Prefer Mesa's software Vulkan (lavapipe) for headless availability. + # The exact ICD filename can differ across Ubuntu images, so discover it. + LVP_ICD="$( + if [[ -d /usr/share/vulkan/icd.d ]]; then + find /usr/share/vulkan/icd.d -maxdepth 1 -type f \ + \( -name '*lvp_icd*.json' -o -name '*lavapipe*.json' \) \ + -print 2>/dev/null | head -n1 + fi + )" + if [[ -z "$LVP_ICD" ]]; then + echo "lavapipe Vulkan ICD not found under /usr/share/vulkan/icd.d" >&2 + ls -la /usr/share/vulkan/icd.d || true + else + echo "Using lavapipe ICD: $LVP_ICD" + echo "VK_ICD_FILENAMES=$LVP_ICD" >> "$GITHUB_ENV" + fi vulkaninfo --summary || true - name: Generate full coverage JSON diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 38a356e5..7c35d4b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,7 +53,23 @@ jobs: - name: Configure Vulkan for headless CI run: | echo "WGPU_BACKEND=vulkan" >> "$GITHUB_ENV" - echo "VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json" >> "$GITHUB_ENV" + # Prefer Mesa's software Vulkan (lavapipe) for headless availability. + # The exact ICD filename can differ across Ubuntu images, so discover it. + LVP_ICD="$( + if [[ -d /usr/share/vulkan/icd.d ]]; then + find /usr/share/vulkan/icd.d -maxdepth 1 -type f \ + \( -name '*lvp_icd*.json' -o -name '*lavapipe*.json' \) \ + -print 2>/dev/null | head -n1 + fi + )" + if [[ -z "$LVP_ICD" ]]; then + echo "lavapipe Vulkan ICD not found under /usr/share/vulkan/icd.d" >&2 + ls -la /usr/share/vulkan/icd.d || true + else + echo "Using lavapipe ICD: $LVP_ICD" + echo "VK_ICD_FILENAMES=$LVP_ICD" >> "$GITHUB_ENV" + fi + echo "LAMBDA_REQUIRE_GPU_ADAPTER=1" >> "$GITHUB_ENV" vulkaninfo --summary || true - name: Format check diff --git a/.gitignore b/.gitignore index 9e031c8e..30315733 100644 --- a/.gitignore +++ b/.gitignore @@ -146,7 +146,10 @@ Temporary Items # End of https://www.gitignore.io/api/linux,cpp,c,cmake,macos,opengl -imgui.ini +imgui.ini -# Planning -docs/plans/ +# Planning +docs/plans/ + +# Coverage reports +coverage/ diff --git a/crates/lambda-rs/src/render/bind.rs b/crates/lambda-rs/src/render/bind.rs index 1420e9dd..93144b0f 100644 --- a/crates/lambda-rs/src/render/bind.rs +++ b/crates/lambda-rs/src/render/bind.rs @@ -64,7 +64,184 @@ impl BindingVisibility { } #[cfg(test)] -mod tests {} +mod tests { + use super::*; + use crate::render::{ + buffer::{ + BufferBuilder, + BufferType, + Properties, + Usage, + }, + gpu::create_test_gpu, + texture::{ + SamplerBuilder, + TextureBuilder, + TextureFormat, + ViewDimension, + }, + }; + + /// Ensures engine-facing shader stage visibility flags map to the platform + /// wgpu visibility flags. + #[test] + fn binding_visibility_maps_to_platform() { + assert!(matches!( + BindingVisibility::Vertex.to_platform(), + lambda_platform::wgpu::bind::Visibility::Vertex + )); + assert!(matches!( + BindingVisibility::Fragment.to_platform(), + lambda_platform::wgpu::bind::Visibility::Fragment + )); + assert!(matches!( + BindingVisibility::Compute.to_platform(), + lambda_platform::wgpu::bind::Visibility::Compute + )); + assert!(matches!( + BindingVisibility::VertexAndFragment.to_platform(), + lambda_platform::wgpu::bind::Visibility::VertexAndFragment + )); + assert!(matches!( + BindingVisibility::All.to_platform(), + lambda_platform::wgpu::bind::Visibility::All + )); + } + + /// Rejects duplicated binding indices within a single bind group layout in + /// debug builds. + #[test] + #[cfg(debug_assertions)] + fn bind_group_layout_builder_rejects_duplicate_binding() { + let Some(gpu) = create_test_gpu("lambda-bind-test") else { + return; + }; + + // Duplicate binding index 0 across entries should panic in debug builds. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _layout = BindGroupLayoutBuilder::new() + .with_uniform(0, BindingVisibility::Vertex) + .with_uniform_dynamic(0, BindingVisibility::Vertex) + .build(&gpu); + })); + assert!(result.is_err()); + } + + /// Tracks the number of dynamic uniform bindings so callers can validate + /// dynamic offset counts at bind time. + #[test] + fn bind_group_layout_counts_dynamic_uniforms() { + let Some(gpu) = create_test_gpu("lambda-bind-test") else { + return; + }; + + let layout = BindGroupLayoutBuilder::new() + .with_uniform(0, BindingVisibility::VertexAndFragment) + .with_uniform_dynamic(1, BindingVisibility::VertexAndFragment) + .build(&gpu); + + assert_eq!(layout.dynamic_binding_count(), 1); + } + + /// Ensures building a bind group without providing a layout fails loudly. + #[test] + fn bind_group_builder_requires_layout() { + let Some(gpu) = create_test_gpu("lambda-bind-test") else { + return; + }; + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _group = BindGroupBuilder::new().build(&gpu); + })); + assert!(result.is_err()); + } + + /// Ensures a bind group exposes the same dynamic binding count as its layout. + #[test] + fn bind_group_dynamic_binding_count_matches_layout() { + let Some(gpu) = create_test_gpu("lambda-bind-test") else { + return; + }; + + let layout = BindGroupLayoutBuilder::new() + .with_uniform_dynamic(0, BindingVisibility::VertexAndFragment) + .build(&gpu); + + let uniform = BufferBuilder::new() + .with_label("bind-test-uniform") + .with_usage(Usage::UNIFORM) + .with_properties(Properties::CPU_VISIBLE) + .with_buffer_type(BufferType::Uniform) + .build(&gpu, vec![0u32; 4]) + .expect("build uniform buffer"); + + let group = BindGroupBuilder::new() + .with_layout(&layout) + .with_uniform(0, &uniform, 0, None) + .build(&gpu); + + assert_eq!( + group.dynamic_binding_count(), + layout.dynamic_binding_count() + ); + } + + /// Builds a bind group with multiple resource kinds (2D sampled texture, 3D + /// sampled texture, sampler) to validate layout/view dimension compatibility. + #[test] + fn bind_group_supports_textures_and_samplers() { + let Some(gpu) = create_test_gpu("lambda-bind-test") else { + return; + }; + + let texture_2d = TextureBuilder::new_2d(TextureFormat::Rgba8Unorm) + .with_size(1, 1) + .build(&gpu) + .expect("build 2d texture"); + let texture_3d = TextureBuilder::new_3d(TextureFormat::Rgba8Unorm) + .with_size_3d(1, 1, 2) + .build(&gpu) + .expect("build 3d texture"); + let sampler = SamplerBuilder::new().linear().build(&gpu); + + let layout = BindGroupLayoutBuilder::new() + .with_sampled_texture(0) + .with_sampled_texture_dim( + 1, + ViewDimension::D3, + BindingVisibility::Fragment, + ) + .with_sampler(2) + .build(&gpu); + + let group = BindGroupBuilder::new() + .with_layout(&layout) + .with_texture(0, &texture_2d) + .with_texture(1, &texture_3d) + .with_sampler(2, &sampler) + .build(&gpu); + + assert_eq!(group.dynamic_binding_count(), 0); + } + + /// Rejects duplicated binding indices even when the duplicates are across + /// different resource kinds (uniform vs sampler) in debug builds. + #[test] + #[cfg(debug_assertions)] + fn bind_group_layout_rejects_duplicate_binding_across_resource_kinds() { + let Some(gpu) = create_test_gpu("lambda-bind-test") else { + return; + }; + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _layout = BindGroupLayoutBuilder::new() + .with_uniform(0, BindingVisibility::Vertex) + .with_sampler(0) + .build(&gpu); + })); + assert!(result.is_err()); + } +} /// Bind group layout used when creating pipelines and bind groups. #[derive(Debug, Clone)] @@ -348,7 +525,7 @@ impl<'a> BindGroupBuilder<'a> { return self; } - /// Bind a 2D texture at the specified binding index. + /// Bind a texture at the specified binding index. pub fn with_texture(mut self, binding: u32, texture: &'a Texture) -> Self { self.textures.push((binding, texture.platform_texture())); return self; diff --git a/crates/lambda-rs/src/render/buffer.rs b/crates/lambda-rs/src/render/buffer.rs index f4e80094..48282a24 100644 --- a/crates/lambda-rs/src/render/buffer.rs +++ b/crates/lambda-rs/src/render/buffer.rs @@ -426,6 +426,7 @@ impl BufferBuilder { mod tests { use super::*; + /// Rejects constructing a buffer with a logical length of zero elements. #[test] fn resolve_length_rejects_zero() { let builder = BufferBuilder::new(); @@ -433,6 +434,7 @@ mod tests { assert!(result.is_err()); } + /// Ensures builder labels are stored for later propagation/debugging. #[test] fn label_is_recorded_on_builder() { let builder = BufferBuilder::new().with_label("buffer-test"); @@ -441,6 +443,7 @@ mod tests { assert_eq!(builder.label.as_deref(), Some("buffer-test")); } + /// Rejects length computations that would overflow `usize`. #[test] fn resolve_length_rejects_overflow() { let builder = BufferBuilder::new(); @@ -448,6 +451,7 @@ mod tests { assert!(result.is_err()); } + /// Confirms `value_as_bytes` uses native-endian byte order and size. #[test] fn value_as_bytes_matches_native_bytes() { let value: u32 = 0x1122_3344; @@ -455,6 +459,7 @@ mod tests { assert_eq!(value_as_bytes(&value), expected.as_slice()); } + /// Confirms `slice_as_bytes` flattens a typed slice to the native bytes. #[test] fn slice_as_bytes_matches_native_bytes() { let values: [u16; 3] = [0x1122, 0x3344, 0x5566]; @@ -465,15 +470,79 @@ mod tests { assert_eq!(slice_as_bytes(&values).unwrap(), expected.as_slice()); } + /// Ensures converting an empty slice to bytes yields an empty output slice. #[test] fn slice_as_bytes_empty_is_empty() { let values: [u32; 0] = []; assert_eq!(slice_as_bytes(&values).unwrap(), &[]); } + /// Rejects byte length computations that would overflow `usize`. #[test] fn checked_byte_len_rejects_overflow() { let result = checked_byte_len(usize::MAX, 2); assert!(result.is_err()); } + + /// Validates default flags and bitwise-OR behavior for buffer usage and + /// memory properties. + #[test] + fn usage_and_properties_support_defaults_and_bit_ops() { + let default_usage = Usage::default(); + let _ = default_usage.to_platform(); + + let combined = Usage::VERTEX | Usage::INDEX; + let _ = combined.to_platform(); + + assert!(Properties::default().cpu_visible()); + assert!(!Properties::DEVICE_LOCAL.cpu_visible()); + } + + /// Confirms `BufferType` stays a small Copy enum and is `Debug`-printable. + #[test] + fn buffer_type_is_copy_and_debug() { + let t = BufferType::Uniform; + let _ = format!("{:?}", t); + let copied = t; + assert!(matches!(copied, BufferType::Uniform)); + } + + /// Exercises the GPU-backed write helpers to ensure they are callable and + /// wired to the platform API. + #[test] + fn buffer_write_value_and_slice_paths_are_callable() { + let Some(gpu) = crate::render::gpu::create_test_gpu("lambda-buffer-test") + else { + return; + }; + + let buffer = BufferBuilder::new() + .with_label("lambda-buffer-write-test") + .with_usage(Usage::UNIFORM) + .with_properties(Properties::CPU_VISIBLE) + .with_buffer_type(BufferType::Uniform) + .build(&gpu, vec![0_u32; 16]) + .expect("build uniform buffer"); + + buffer.write_value(&gpu, 0, &0x1122_3344_u32); + buffer + .write_slice(&gpu, 0, &[1_u32, 2_u32, 3_u32]) + .expect("write slice"); + } + + /// Builds a typed uniform buffer wrapper and performs an update write. + #[test] + fn uniform_buffer_wrapper_builds_and_writes() { + let Some(gpu) = crate::render::gpu::create_test_gpu("lambda-buffer-test") + else { + return; + }; + + let initial = 7_u32; + let ubo = + UniformBuffer::new(&gpu, &initial, Some("lambda-ubo-test")).unwrap(); + ubo.write(&gpu, &9_u32); + + let _ = ubo.raw(); + } } diff --git a/crates/lambda-rs/src/render/color_attachments.rs b/crates/lambda-rs/src/render/color_attachments.rs index e0344e5f..0d7e8886 100644 --- a/crates/lambda-rs/src/render/color_attachments.rs +++ b/crates/lambda-rs/src/render/color_attachments.rs @@ -121,3 +121,113 @@ impl<'view> RenderColorAttachments<'view> { return attachments; } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::render::texture::{ + ColorAttachmentTextureBuilder, + TextureBuilder, + TextureFormat, + }; + + /// Ensures `for_surface_pass` produces no color attachments when color output + /// is disabled. + #[test] + fn for_surface_pass_returns_empty_when_color_disabled() { + let Some(gpu) = + crate::render::gpu::create_test_gpu("lambda-color-attachments-test") + else { + return; + }; + + let texture = TextureBuilder::new_2d(TextureFormat::Rgba8Unorm) + .with_size(1, 1) + .for_render_target() + .build(&gpu) + .expect("build resolve texture"); + + let view = texture.view_ref(); + let mut attachments = + RenderColorAttachments::for_surface_pass(false, 1, None, view); + let _ = attachments.as_platform_attachments_mut(); + } + + /// Builds a single-sample offscreen attachment list (no MSAA) and ensures it + /// can be passed through to the platform render pass builder. + #[test] + fn for_offscreen_pass_builds_single_sample_color_attachment() { + let Some(gpu) = + crate::render::gpu::create_test_gpu("lambda-color-attachments-test") + else { + return; + }; + + let texture = TextureBuilder::new_2d(TextureFormat::Rgba8Unorm) + .with_size(4, 4) + .for_render_target() + .build(&gpu) + .expect("build resolve texture"); + + let view = texture.view_ref(); + let mut attachments = + RenderColorAttachments::for_offscreen_pass(true, 1, None, view); + let _ = attachments.as_platform_attachments_mut(); + } + + /// Builds an MSAA offscreen attachment list with a resolve target. + #[test] + fn for_offscreen_pass_builds_msaa_color_attachment() { + let Some(gpu) = + crate::render::gpu::create_test_gpu("lambda-color-attachments-test") + else { + return; + }; + + let resolve = TextureBuilder::new_2d(TextureFormat::Rgba8Unorm) + .with_size(4, 4) + .for_render_target() + .build(&gpu) + .expect("build resolve texture"); + + let msaa = ColorAttachmentTextureBuilder::new(TextureFormat::Rgba8Unorm) + .with_size(4, 4) + .with_sample_count(4) + .build(&gpu); + + let mut attachments = RenderColorAttachments::for_offscreen_pass( + true, + 4, + Some(msaa.view_ref()), + resolve.view_ref(), + ); + let _ = attachments.as_platform_attachments_mut(); + } + + /// Validates the builder rejects MSAA configurations that omit the required + /// MSAA view. + #[test] + fn for_offscreen_pass_panics_when_msaa_view_missing() { + let Some(gpu) = + crate::render::gpu::create_test_gpu("lambda-color-attachments-test") + else { + return; + }; + + let resolve = TextureBuilder::new_2d(TextureFormat::Rgba8Unorm) + .with_size(1, 1) + .for_render_target() + .build(&gpu) + .expect("build resolve texture"); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = RenderColorAttachments::for_offscreen_pass( + true, + 4, + None, + resolve.view_ref(), + ); + })); + assert!(result.is_err()); + } +} diff --git a/crates/lambda-rs/src/render/command.rs b/crates/lambda-rs/src/render/command.rs index d3b6825b..f6066bdc 100644 --- a/crates/lambda-rs/src/render/command.rs +++ b/crates/lambda-rs/src/render/command.rs @@ -130,6 +130,7 @@ pub enum RenderCommand { mod tests { use super::IndexFormat; + /// Ensures engine-facing index formats map to platform wgpu index formats. #[test] fn index_format_maps_to_platform() { let u16_platform = IndexFormat::Uint16.to_platform(); diff --git a/crates/lambda-rs/src/render/encoder.rs b/crates/lambda-rs/src/render/encoder.rs index 0707e1ca..296f79e6 100644 --- a/crates/lambda-rs/src/render/encoder.rs +++ b/crates/lambda-rs/src/render/encoder.rs @@ -692,3 +692,361 @@ impl std::fmt::Display for RenderPassError { } impl std::error::Error for RenderPassError {} + +#[cfg(test)] +pub(crate) fn new_render_pass_encoder_for_tests<'pass>( + encoder: &'pass mut platform::command::CommandEncoder, + pass: &'pass RenderPass, + destination_info: RenderPassDestinationInfo, + color_attachments: &'pass mut RenderColorAttachments<'pass>, + depth_texture: Option<&'pass DepthTexture>, +) -> RenderPassEncoder<'pass> { + return RenderPassEncoder::new( + encoder, + pass, + destination_info, + color_attachments, + depth_texture, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::render::{ + bind::{ + BindGroupBuilder, + BindGroupLayoutBuilder, + BindingVisibility, + }, + buffer::{ + BufferBuilder, + BufferType, + Properties, + Usage, + }, + pipeline::RenderPipelineBuilder, + render_pass::RenderPassBuilder, + shader::{ + ShaderBuilder, + ShaderKind, + VirtualShader, + }, + texture::{ + DepthFormat, + DepthTextureBuilder, + TextureBuilder, + TextureFormat, + }, + viewport::Viewport, + }; + + fn compile_triangle_shaders( + ) -> (crate::render::shader::Shader, crate::render::shader::Shader) { + let vert_path = format!( + "{}/assets/shaders/triangle.vert", + env!("CARGO_MANIFEST_DIR") + ); + let frag_path = format!( + "{}/assets/shaders/triangle.frag", + env!("CARGO_MANIFEST_DIR") + ); + + let mut builder = ShaderBuilder::new(); + let vs = builder.build(VirtualShader::File { + path: vert_path, + kind: ShaderKind::Vertex, + name: "triangle-vert".to_string(), + entry_point: "main".to_string(), + }); + let fs = builder.build(VirtualShader::File { + path: frag_path, + kind: ShaderKind::Fragment, + name: "triangle-frag".to_string(), + entry_point: "main".to_string(), + }); + return (vs, fs); + } + + /// Ensures the `Display` implementation for `RenderPassError` forwards the + /// underlying message without modification. + #[test] + fn render_pass_error_display_is_passthrough() { + let err = RenderPassError::NoPipeline("oops".to_string()); + assert_eq!(err.to_string(), "oops"); + } + + /// Validates the encoder reports an error when a draw is issued before + /// setting a pipeline (when validation is enabled). + #[test] + fn render_pass_encoder_draw_requires_pipeline_when_validation_enabled() { + let Some(gpu) = crate::render::gpu::create_test_gpu("lambda-encoder-test") + else { + return; + }; + + let resolve = TextureBuilder::new_2d(TextureFormat::Rgba8Unorm) + .with_size(4, 4) + .for_render_target() + .build(&gpu) + .expect("build resolve texture"); + + let pass = RenderPassBuilder::new() + .with_label("no-pipeline-pass") + .build(&gpu, TextureFormat::Rgba8Unorm, DepthFormat::Depth24Plus); + + let mut encoder = platform::command::CommandEncoder::new( + gpu.platform(), + Some("lambda-no-pipeline-encoder"), + ); + + let mut attachments = RenderColorAttachments::for_offscreen_pass( + pass.uses_color(), + pass.sample_count(), + None, + resolve.view_ref(), + ); + + let mut rp = RenderPassEncoder::new( + &mut encoder, + &pass, + RenderPassDestinationInfo { + color_format: Some(TextureFormat::Rgba8Unorm), + depth_format: None, + }, + &mut attachments, + None, + ); + + let result = rp.draw(0..3, 0..1); + if cfg!(any(debug_assertions, feature = "render-validation-encoder")) { + let err = result.expect_err("draw must error without a pipeline"); + assert!(matches!(err, RenderPassError::NoPipeline(_))); + } else { + result.expect("draw ok without validation"); + } + + drop(rp); + let _ = encoder.finish(); + } + + /// In debug builds, checks the engine's pipeline/pass compatibility checks + /// fire before provoking underlying wgpu validation errors. + #[test] + fn render_pass_encoder_validates_pipeline_compatibility_in_debug() { + if !cfg!(debug_assertions) { + // The explicit pass/pipeline compatibility checks are debug- or + // feature-gated; don't attempt to provoke wgpu validation in release. + return; + } + + let Some(gpu) = crate::render::gpu::create_test_gpu("lambda-encoder-test") + else { + return; + }; + + let (vs, fs) = compile_triangle_shaders(); + + let pass = RenderPassBuilder::new() + .with_label("depth-only-pass") + .without_color() + .with_depth() + .build(&gpu, TextureFormat::Rgba8Unorm, DepthFormat::Depth24Plus); + + let pipeline = RenderPipelineBuilder::new() + .with_label("color-pipeline") + .build( + &gpu, + TextureFormat::Rgba8Unorm, + DepthFormat::Depth24Plus, + &pass, + &vs, + Some(&fs), + ); + + let mut encoder = platform::command::CommandEncoder::new( + gpu.platform(), + Some("lambda-pass-compat-encoder"), + ); + + let mut attachments = RenderColorAttachments::new(); + let depth_texture = DepthTextureBuilder::new() + .with_label("lambda-pass-compat-depth") + .with_size(1, 1) + .with_format(DepthFormat::Depth24Plus) + .build(&gpu); + + let mut rp = RenderPassEncoder::new( + &mut encoder, + &pass, + RenderPassDestinationInfo { + color_format: None, + depth_format: Some(DepthFormat::Depth24Plus), + }, + &mut attachments, + Some(&depth_texture), + ); + + let err = rp + .set_pipeline(&pipeline) + .expect_err("pipeline with color targets must be incompatible"); + assert!(matches!(err, RenderPassError::PipelineIncompatible(_))); + + drop(rp); + let _ = encoder.finish(); + } + + /// Exercises the common command encoding path (viewport/scissor/pipeline), + /// plus validation branches for bind group dynamic offsets and index buffers. + #[test] + fn render_pass_encoder_encodes_commands_and_validates_index_buffers() { + let Some(gpu) = crate::render::gpu::create_test_gpu("lambda-encoder-test") + else { + return; + }; + + let (vs, fs) = compile_triangle_shaders(); + + let pass = RenderPassBuilder::new().with_label("basic-pass").build( + &gpu, + TextureFormat::Rgba8Unorm, + DepthFormat::Depth24Plus, + ); + + let pipeline = RenderPipelineBuilder::new() + .with_label("basic-pipeline") + .build( + &gpu, + TextureFormat::Rgba8Unorm, + DepthFormat::Depth24Plus, + &pass, + &vs, + Some(&fs), + ); + + let resolve = TextureBuilder::new_2d(TextureFormat::Rgba8Unorm) + .with_size(4, 4) + .for_render_target() + .build(&gpu) + .expect("build resolve texture"); + + let mut encoder = platform::command::CommandEncoder::new( + gpu.platform(), + Some("lambda-encode-commands-encoder"), + ); + + let mut attachments = RenderColorAttachments::for_offscreen_pass( + pass.uses_color(), + pass.sample_count(), + None, + resolve.view_ref(), + ); + + let mut rp = RenderPassEncoder::new( + &mut encoder, + &pass, + RenderPassDestinationInfo { + color_format: Some(TextureFormat::Rgba8Unorm), + depth_format: None, + }, + &mut attachments, + None, + ); + + let viewport = Viewport { + x: 0, + y: 0, + width: 4, + height: 4, + min_depth: 0.0, + max_depth: 1.0, + }; + rp.set_viewport(&viewport); + rp.set_scissor(&viewport); + + rp.set_pipeline(&pipeline).expect("set pipeline"); + + // Bind group validation: dynamic binding count mismatch. + let layout = BindGroupLayoutBuilder::new() + .with_uniform_dynamic(0, BindingVisibility::VertexAndFragment) + .build(&gpu); + + let uniform = BufferBuilder::new() + .with_label("encoder-test-uniform") + .with_usage(Usage::UNIFORM) + .with_properties(Properties::CPU_VISIBLE) + .with_buffer_type(BufferType::Uniform) + .build(&gpu, vec![0u32; 4]) + .expect("build uniform buffer"); + + let group = BindGroupBuilder::new() + .with_layout(&layout) + .with_uniform(0, &uniform, 0, None) + .build(&gpu); + + let err = rp + .set_bind_group( + 0, + &group, + &[], + gpu.limit_min_uniform_buffer_offset_alignment(), + ) + .expect_err("dynamic offsets must be provided"); + assert!(matches!(err, RenderPassError::Validation(_))); + + // Index buffer validation should catch wrong logical type and stride when enabled. + let vertex_buffer = BufferBuilder::new() + .with_label("encoder-test-vertex") + .with_usage(Usage::VERTEX) + .with_properties(Properties::CPU_VISIBLE) + .with_buffer_type(BufferType::Vertex) + .build(&gpu, vec![0u32; 4]) + .expect("build vertex buffer"); + + let bad_type = rp.set_index_buffer(&vertex_buffer, IndexFormat::Uint16); + if cfg!(any(debug_assertions, feature = "render-validation-encoder")) { + assert!(matches!(bad_type, Err(RenderPassError::Validation(_)))); + } else { + assert!(bad_type.is_ok()); + } + + let bad_stride = BufferBuilder::new() + .with_label("encoder-test-index-bad-stride") + .with_usage(Usage::INDEX) + .with_properties(Properties::CPU_VISIBLE) + .with_buffer_type(BufferType::Index) + .build(&gpu, vec![0u8; 8]) + .expect("build index buffer"); + + let bad_stride = rp.set_index_buffer(&bad_stride, IndexFormat::Uint16); + if cfg!(any(debug_assertions, feature = "render-validation-encoder")) { + assert!(matches!(bad_stride, Err(RenderPassError::Validation(_)))); + } else { + assert!(bad_stride.is_ok()); + } + + let index_buffer = BufferBuilder::new() + .with_label("encoder-test-index") + .with_usage(Usage::INDEX) + .with_properties(Properties::CPU_VISIBLE) + .with_buffer_type(BufferType::Index) + .build(&gpu, vec![0u16, 1u16, 2u16]) + .expect("build index buffer"); + + rp.set_index_buffer(&index_buffer, IndexFormat::Uint16) + .expect("set index buffer"); + + rp.draw(0..3, 0..1).expect("draw"); + + let indexed = rp.draw_indexed(0..4, 0, 0..1); + if cfg!(any(debug_assertions, feature = "render-validation-encoder")) { + assert!(matches!(indexed, Err(RenderPassError::Validation(_)))); + } else { + assert!(indexed.is_ok()); + } + + drop(rp); + let cb = encoder.finish(); + gpu.submit(std::iter::once(cb)); + } +} diff --git a/crates/lambda-rs/src/render/gpu.rs b/crates/lambda-rs/src/render/gpu.rs index 9bf50328..4884f9cb 100644 --- a/crates/lambda-rs/src/render/gpu.rs +++ b/crates/lambda-rs/src/render/gpu.rs @@ -220,6 +220,16 @@ impl GpuBuilder { return self; } + /// Force using a fallback/software adapter when available. + /// + /// This is useful for CI environments that may provide a virtual adapter but + /// not a hardware-backed one. If no fallback adapter exists, build will + /// still return `AdapterUnavailable`. + pub fn force_fallback(mut self, force: bool) -> Self { + self.inner = self.inner.force_fallback(force); + return self; + } + /// Build the GPU using the provided instance and optional surface. /// /// The surface is used to ensure the adapter is compatible with @@ -244,6 +254,56 @@ impl Default for GpuBuilder { } } +#[cfg(test)] +pub(crate) fn require_gpu_adapter_for_tests() -> bool { + return matches!( + std::env::var("LAMBDA_REQUIRE_GPU_ADAPTER").as_deref(), + Ok("1") | Ok("true") | Ok("TRUE") + ); +} + +#[cfg(test)] +pub(crate) fn create_test_gpu(label_base: &str) -> Option { + let instance = super::instance::InstanceBuilder::new() + .with_label(&format!("{}-instance", label_base)) + .build(); + return create_test_gpu_with_instance(&instance, label_base); +} + +#[cfg(test)] +pub(crate) fn create_test_gpu_with_instance( + instance: &Instance, + label_base: &str, +) -> Option { + let primary_err = match GpuBuilder::new() + .with_label(&format!("{}-gpu", label_base)) + .build(instance, None) + { + Ok(gpu) => return Some(gpu), + Err(err) => err, + }; + + let fallback_err = match GpuBuilder::new() + .with_label(&format!("{}-gpu-fallback", label_base)) + .force_fallback(true) + .build(instance, None) + { + Ok(gpu) => return Some(gpu), + Err(err) => err, + }; + + if require_gpu_adapter_for_tests() { + panic!( + "No GPU adapter available for tests (label_base={}).\nPrimary adapter attempt: {}\nFallback adapter attempt: {}\n(Set LAMBDA_REQUIRE_GPU_ADAPTER=0 to allow skipping)", + label_base, + primary_err, + fallback_err, + ); + } + + return None; +} + // --------------------------------------------------------------------------- // GpuBuildError // --------------------------------------------------------------------------- @@ -294,3 +354,54 @@ impl std::fmt::Display for GpuBuildError { } impl std::error::Error for GpuBuildError {} + +#[cfg(test)] +mod tests { + use super::*; + + /// Ensures `GpuLimits` correctly copies all platform limit fields. + #[test] + fn gpu_limits_from_platform_maps_fields() { + let platform_limits = platform::gpu::GpuLimits { + max_uniform_buffer_binding_size: 1024, + max_bind_groups: 4, + max_vertex_buffers: 8, + max_vertex_attributes: 16, + min_uniform_buffer_offset_alignment: 256, + }; + + let limits = GpuLimits::from_platform(platform_limits); + assert_eq!(limits.max_uniform_buffer_binding_size, 1024); + assert_eq!(limits.max_bind_groups, 4); + assert_eq!(limits.max_vertex_buffers, 8); + assert_eq!(limits.max_vertex_attributes, 16); + assert_eq!(limits.min_uniform_buffer_offset_alignment, 256); + } + + /// Ensures `GpuBuildError` string formatting stays user-actionable. + #[test] + fn gpu_build_error_display_messages_are_actionable() { + assert_eq!( + GpuBuildError::AdapterUnavailable.to_string(), + "No compatible GPU adapter found" + ); + + let missing = GpuBuildError::MissingFeatures("missing".to_string()); + assert_eq!(missing.to_string(), "missing"); + + let create_failed = GpuBuildError::DeviceCreationFailed("boom".to_string()); + assert_eq!(create_failed.to_string(), "Device creation failed: boom"); + } + + /// Ensures platform `RequestDevice` errors map into the engine-facing error + /// type without losing the underlying message. + #[test] + fn gpu_build_error_from_platform_maps_request_device() { + let platform_error = + platform::gpu::GpuBuildError::RequestDevice("device error".to_string()); + let mapped = GpuBuildError::from_platform(platform_error); + + assert!(matches!(mapped, GpuBuildError::DeviceCreationFailed(_))); + assert!(mapped.to_string().contains("device error")); + } +} diff --git a/crates/lambda-rs/src/render/instance.rs b/crates/lambda-rs/src/render/instance.rs index 87e423df..fd836d1c 100644 --- a/crates/lambda-rs/src/render/instance.rs +++ b/crates/lambda-rs/src/render/instance.rs @@ -339,18 +339,22 @@ impl Default for InstanceBuilder { mod tests { use super::*; + /// Ensures labels applied in `InstanceBuilder` are preserved on the built + /// instance for debugging/profiling. #[test] fn instance_builder_sets_label() { let instance = InstanceBuilder::new().with_label("Test Instance").build(); assert_eq!(instance.label(), Some("Test Instance")); } + /// Confirms the instance builder can build with default settings. #[test] fn instance_builder_default_backends() { // Just ensure we can build with defaults without panicking let _instance = InstanceBuilder::new().build(); } + /// Ensures backend flags support bitwise-OR composition. #[test] fn backends_bitor() { let combined = Backends::VULKAN | Backends::METAL; @@ -358,4 +362,91 @@ mod tests { assert_ne!(combined, Backends::VULKAN); assert_ne!(combined, Backends::METAL); } + + /// Ensures instance flag bitfields map to the platform bitflags correctly. + #[test] + fn instance_flags_bitor_maps_to_platform() { + let flags = InstanceFlags::VALIDATION | InstanceFlags::DEBUG; + let platform = flags.to_platform(); + assert_eq!( + platform, + platform::instance::InstanceFlags::VALIDATION + | platform::instance::InstanceFlags::DEBUG + ); + } + + /// Ensures the DX12 shader compiler selection maps to the platform enum. + #[test] + fn dx12_compiler_maps_to_platform() { + assert!(matches!( + Dx12Compiler::Fxc.to_platform(), + platform::instance::Dx12Compiler::Fxc + )); + } + + /// Ensures the GLES minor version selection maps to the platform enum. + #[test] + fn gles_minor_version_maps_to_platform() { + assert!(matches!( + Gles3MinorVersion::Automatic.to_platform(), + platform::instance::Gles3MinorVersion::Automatic + )); + assert!(matches!( + Gles3MinorVersion::Version0.to_platform(), + platform::instance::Gles3MinorVersion::Version0 + )); + assert!(matches!( + Gles3MinorVersion::Version1.to_platform(), + platform::instance::Gles3MinorVersion::Version1 + )); + assert!(matches!( + Gles3MinorVersion::Version2.to_platform(), + platform::instance::Gles3MinorVersion::Version2 + )); + } + + /// Confirms the `Debug` output includes stable, helpful fields (like label). + #[test] + fn instance_debug_includes_label_field() { + let instance = InstanceBuilder::new().with_label("debug instance").build(); + let formatted = format!("{:?}", instance); + assert!(formatted.contains("Instance")); + assert!(formatted.contains("label")); + } + + /// Ensures each engine backend constant maps to the corresponding platform + /// backend constant. + #[test] + fn backends_map_to_platform_constants() { + assert_eq!( + Backends::PRIMARY.to_platform(), + platform::instance::Backends::PRIMARY + ); + assert_eq!( + Backends::VULKAN.to_platform(), + platform::instance::Backends::VULKAN + ); + assert_eq!( + Backends::METAL.to_platform(), + platform::instance::Backends::METAL + ); + assert_eq!( + Backends::DX12.to_platform(), + platform::instance::Backends::DX12 + ); + assert_eq!(Backends::GL.to_platform(), platform::instance::Backends::GL); + } + + /// Smoke-tests the builder with every option set to ensure the fluent API is + /// wired correctly. + #[test] + fn instance_builder_accepts_all_options() { + let _instance = InstanceBuilder::new() + .with_label("options") + .with_backends(Backends::VULKAN | Backends::METAL) + .with_flags(InstanceFlags::VALIDATION | InstanceFlags::DEBUG) + .with_dx12_shader_compiler(Dx12Compiler::Fxc) + .with_gles_minor_version(Gles3MinorVersion::Version2) + .build(); + } } diff --git a/crates/lambda-rs/src/render/mesh.rs b/crates/lambda-rs/src/render/mesh.rs index 9015a750..c96525da 100644 --- a/crates/lambda-rs/src/render/mesh.rs +++ b/crates/lambda-rs/src/render/mesh.rs @@ -155,10 +155,63 @@ impl MeshBuilder { #[cfg(test)] mod tests { + use super::MeshBuilder; + use crate::render::vertex::Vertex; + + /// Confirms a newly constructed mesh builder starts with no vertices. #[test] fn mesh_building() { - let mesh = super::MeshBuilder::new(); + let mesh = MeshBuilder::new(); assert_eq!(mesh.vertices.len(), 0); } + + /// Ensures capacity resizing and subsequent vertex pushes are reflected in + /// the built mesh. + #[test] + fn mesh_builder_capacity_and_attributes_are_applied() { + let mut builder = MeshBuilder::new(); + builder.with_capacity(2); + assert_eq!(builder.vertices.len(), 2); + + builder.with_vertex(Vertex { + position: [1.0, 2.0, 3.0], + normal: [0.0, 1.0, 0.0], + color: [0.5, 0.5, 0.5], + }); + + let mesh = builder.build(); + assert_eq!(mesh.vertices().len(), 3); + } + + /// Validates the OBJ loader path parses a minimal triangle and produces the + /// expected vertex + attribute counts. + #[test] + fn mesh_build_from_obj_parses_vertices() { + use std::fs; + + // Minimal OBJ with one triangle, normals, and texture coordinates. + let obj = r#" +v 0.0 0.0 0.0 +v 1.0 0.0 0.0 +v 0.0 1.0 0.0 +vt 0.0 0.0 +vt 1.0 0.0 +vt 0.0 1.0 +vn 0.0 0.0 1.0 +f 1/1/1 2/2/1 3/3/1 +"#; + + let mut path = std::env::temp_dir(); + path.push("lambda_mesh_test.obj"); + fs::write(&path, obj).expect("write temp obj"); + + let builder = super::MeshBuilder::new(); + let mesh = builder + .build_from_obj(path.to_str().expect("temp path must be valid utf-8")); + + // The platform loader expands the face into vertices. + assert_eq!(mesh.vertices().len(), 3); + assert_eq!(mesh.attributes().len(), 3); + } } diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index b3552d72..be85d765 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -220,7 +220,7 @@ impl RenderContextBuilder { let mut render_context = RenderContext { label: name, instance, - surface, + surface: Some(surface), gpu, config, texture_usage, @@ -289,7 +289,7 @@ pub struct RenderContext { label: String, #[allow(dead_code)] instance: instance::Instance, - surface: targets::surface::WindowSurface, + surface: Option, gpu: gpu::Gpu, config: targets::surface::SurfaceConfig, texture_usage: texture::TextureUsages, @@ -443,8 +443,10 @@ impl RenderContext { } self.size = (width, height); - if let Err(err) = self.reconfigure_surface(self.size) { - logging::error!("Failed to resize surface: {:?}", err); + if self.surface.is_some() { + if let Err(err) = self.reconfigure_surface(self.size) { + logging::error!("Failed to resize surface: {:?}", err); + } } // Recreate depth texture to match new size. @@ -568,19 +570,56 @@ impl RenderContext { return Ok(()); } - let frame = match self.surface.acquire_frame() { - Ok(frame) => frame, - Err(err) => match err { - targets::surface::SurfaceError::Lost - | targets::surface::SurfaceError::Outdated => { - self.reconfigure_surface(self.size)?; - self.surface.acquire_frame().map_err(RenderError::Surface)? - } - _ => return Err(RenderError::Surface(err)), - }, + // Determine whether this command list needs access to the presentation + // surface. We only acquire a surface frame when a surface-backed pass is + // requested; offscreen-only command lists can render without a window. + let requires_surface = commands.iter().any(|cmd| { + return matches!( + cmd, + RenderCommand::BeginRenderPass { .. } + | RenderCommand::BeginRenderPassTo { + destination: RenderDestination::Surface, + .. + } + ); + }); + + let mut frame = if requires_surface { + // Acquire exactly one surface frame up-front and reuse its `TextureView` + // for all surface-backed render passes in this command list. The acquired + // frame is presented after encoding completes. + // + // If acquisition fails due to surface loss/outdated config, attempt to + // reconfigure the surface to the current context size and retry once. + let acquired = { + let surface = self.surface.as_mut().ok_or_else(|| { + RenderError::Configuration( + "No surface attached to RenderContext".to_string(), + ) + })?; + surface.acquire_frame() + }; + + Some(match acquired { + Ok(frame) => frame, + Err(err) => match err { + targets::surface::SurfaceError::Lost + | targets::surface::SurfaceError::Outdated => { + self.reconfigure_surface(self.size)?; + let surface = self.surface.as_mut().ok_or_else(|| { + RenderError::Configuration( + "No surface attached to RenderContext".to_string(), + ) + })?; + surface.acquire_frame().map_err(RenderError::Surface)? + } + _ => return Err(RenderError::Surface(err)), + }, + }) + } else { + None }; - let view = frame.texture_view(); let mut encoder = CommandEncoder::new(self, "lambda-render-command-encoder"); @@ -591,6 +630,15 @@ impl RenderContext { render_pass, viewport, } => { + let view = frame + .as_ref() + .ok_or_else(|| { + RenderError::Configuration( + "Surface render pass requested but no surface is attached" + .to_string(), + ) + })? + .texture_view(); self.encode_surface_render_pass( &mut encoder, &mut command_iter, @@ -605,6 +653,15 @@ impl RenderContext { destination, } => match destination { RenderDestination::Surface => { + let view = frame + .as_ref() + .ok_or_else(|| { + RenderError::Configuration( + "Surface render pass requested but no surface is attached" + .to_string(), + ) + })? + .texture_view(); self.encode_surface_render_pass( &mut encoder, &mut command_iter, @@ -633,7 +690,9 @@ impl RenderContext { } encoder.finish(self); - frame.present(); + if let Some(frame) = frame.take() { + frame.present(); + } return Ok(()); } @@ -1007,12 +1066,17 @@ impl RenderContext { &mut self, size: (u32, u32), ) -> Result<(), RenderError> { - self - .surface + let surface = self.surface.as_mut().ok_or_else(|| { + RenderError::Configuration( + "No surface attached to RenderContext".to_string(), + ) + })?; + + surface .resize(&self.gpu, size) .map_err(RenderError::Configuration)?; - let config = self.surface.configuration().ok_or_else(|| { + let config = surface.configuration().ok_or_else(|| { RenderError::Configuration("Surface was not configured".to_string()) })?; @@ -1085,12 +1149,16 @@ mod tests { use super::*; use crate::render::render_pass; + /// Ensures the internal attachment predicate returns false when neither depth + /// nor stencil operations are configured. #[test] fn has_depth_attachment_false_when_no_depth_or_stencil() { let has_attachment = RenderContext::has_depth_attachment(None, None); assert!(!has_attachment); } + /// Ensures depth-only passes are recognized as having a depth/stencil + /// attachment. #[test] fn has_depth_attachment_true_for_depth_only() { let depth_ops = Some(render_pass::DepthOperations::default()); @@ -1098,6 +1166,8 @@ mod tests { assert!(has_attachment); } + /// Ensures stencil-only passes are recognized as having a depth/stencil + /// attachment. #[test] fn has_depth_attachment_true_for_stencil_only() { let stencil_ops = Some(render_pass::StencilOperations::default()); @@ -1105,6 +1175,8 @@ mod tests { assert!(has_attachment); } + /// Ensures render context validation rejects references to missing pipelines + /// with an actionable error. #[test] fn immediates_validate_pipeline_exists_rejects_unknown_pipeline() { let pipelines: Vec = vec![]; @@ -1113,6 +1185,863 @@ mod tests { assert!(err.to_string().contains("Unknown pipeline 7")); } + /// Exercises the core command encoding loop against a real device, covering + /// common command variants (viewport/scissor/pipeline/bind group/draw). + #[test] + fn encode_active_render_pass_commands_executes_common_commands() { + use lambda_platform::wgpu as platform; + + let instance = instance::InstanceBuilder::new() + .with_label("lambda-render-mod-test-instance") + .build(); + let Some(gpu) = + gpu::create_test_gpu_with_instance(&instance, "lambda-render-mod-test") + else { + return; + }; + + let (vs, fs) = { + let vert_path = format!( + "{}/assets/shaders/triangle.vert", + env!("CARGO_MANIFEST_DIR") + ); + let frag_path = format!( + "{}/assets/shaders/triangle.frag", + env!("CARGO_MANIFEST_DIR") + ); + let mut builder = shader::ShaderBuilder::new(); + let vs = builder.build(shader::VirtualShader::File { + path: vert_path, + kind: shader::ShaderKind::Vertex, + name: "triangle-vert".to_string(), + entry_point: "main".to_string(), + }); + let fs = builder.build(shader::VirtualShader::File { + path: frag_path, + kind: shader::ShaderKind::Fragment, + name: "triangle-frag".to_string(), + entry_point: "main".to_string(), + }); + (vs, fs) + }; + + let pass = render_pass::RenderPassBuilder::new() + .with_label("lambda-mod-encode-pass") + .build( + &gpu, + texture::TextureFormat::Rgba8Unorm, + texture::DepthFormat::Depth24Plus, + ); + + let pipeline = pipeline::RenderPipelineBuilder::new() + .with_label("lambda-mod-encode-pipeline") + .build( + &gpu, + texture::TextureFormat::Rgba8Unorm, + texture::DepthFormat::Depth24Plus, + &pass, + &vs, + Some(&fs), + ); + + let uniform = buffer::BufferBuilder::new() + .with_label("lambda-mod-uniform") + .with_usage(buffer::Usage::UNIFORM) + .with_properties(buffer::Properties::CPU_VISIBLE) + .with_buffer_type(buffer::BufferType::Uniform) + .build(&gpu, vec![0u32; 4]) + .expect("build uniform buffer"); + + let layout = bind::BindGroupLayoutBuilder::new() + .with_uniform(0, bind::BindingVisibility::VertexAndFragment) + .build(&gpu); + let group = bind::BindGroupBuilder::new() + .with_label("lambda-mod-bind-group") + .with_layout(&layout) + .with_uniform(0, &uniform, 0, None) + .build(&gpu); + + let index_buffer = buffer::BufferBuilder::new() + .with_label("lambda-mod-index") + .with_usage(buffer::Usage::INDEX) + .with_properties(buffer::Properties::CPU_VISIBLE) + .with_buffer_type(buffer::BufferType::Index) + .build(&gpu, vec![0u16, 1u16, 2u16]) + .expect("build index buffer"); + + let resolve = + texture::TextureBuilder::new_2d(texture::TextureFormat::Rgba8Unorm) + .with_size(4, 4) + .for_render_target() + .build(&gpu) + .expect("build resolve texture"); + + let mut platform_encoder = platform::command::CommandEncoder::new( + gpu.platform(), + Some("lambda-mod-command-encoder"), + ); + + let mut attachments = + color_attachments::RenderColorAttachments::for_offscreen_pass( + pass.uses_color(), + pass.sample_count(), + None, + resolve.view_ref(), + ); + + let mut rp_encoder = encoder::new_render_pass_encoder_for_tests( + &mut platform_encoder, + &pass, + encoder::RenderPassDestinationInfo { + color_format: Some(texture::TextureFormat::Rgba8Unorm), + depth_format: None, + }, + &mut attachments, + None, + ); + + let initial_viewport = viewport::Viewport { + x: 0, + y: 0, + width: 4, + height: 4, + min_depth: 0.0, + max_depth: 1.0, + }; + + let render_pipelines = vec![pipeline]; + let bind_groups = vec![group]; + let buffers = vec![Rc::new(index_buffer)]; + let min_align = gpu.limit_min_uniform_buffer_offset_alignment(); + + let commands = vec![ + RenderCommand::SetViewports { + start_at: 0, + viewports: vec![initial_viewport.clone()], + }, + RenderCommand::SetScissors { + start_at: 0, + viewports: vec![initial_viewport.clone()], + }, + RenderCommand::SetPipeline { pipeline: 0 }, + RenderCommand::SetBindGroup { + set: 0, + group: 0, + dynamic_offsets: vec![], + }, + RenderCommand::BindIndexBuffer { + buffer: 0, + format: command::IndexFormat::Uint16, + }, + RenderCommand::Draw { + vertices: 0..3, + instances: 0..1, + }, + RenderCommand::DrawIndexed { + indices: 0..3, + base_vertex: 0, + instances: 0..1, + }, + RenderCommand::EndRenderPass, + ]; + + let mut iter = commands.into_iter(); + RenderContext::encode_active_render_pass_commands( + &mut iter, + &mut rp_encoder, + &initial_viewport, + &render_pipelines, + &bind_groups, + &buffers, + min_align, + ) + .expect("encode commands"); + + drop(rp_encoder); + let buffer = platform_encoder.finish(); + gpu.submit(std::iter::once(buffer)); + } + + /// Ensures the command encoding loop rejects frames that omit an + /// `EndRenderPass` terminator. + #[test] + fn encode_active_render_pass_commands_requires_end_render_pass() { + use lambda_platform::wgpu as platform; + + let instance = instance::InstanceBuilder::new() + .with_label("lambda-render-mod-test-instance-2") + .build(); + let Some(gpu) = + gpu::create_test_gpu_with_instance(&instance, "lambda-render-mod-test-2") + else { + return; + }; + + let pass = render_pass::RenderPassBuilder::new() + .with_label("lambda-mod-missing-end-pass") + .build( + &gpu, + texture::TextureFormat::Rgba8Unorm, + texture::DepthFormat::Depth24Plus, + ); + + let resolve = + texture::TextureBuilder::new_2d(texture::TextureFormat::Rgba8Unorm) + .with_size(1, 1) + .for_render_target() + .build(&gpu) + .expect("build resolve texture"); + + let mut platform_encoder = platform::command::CommandEncoder::new( + gpu.platform(), + Some("lambda-mod-missing-end-encoder"), + ); + + let mut attachments = + color_attachments::RenderColorAttachments::for_offscreen_pass( + pass.uses_color(), + pass.sample_count(), + None, + resolve.view_ref(), + ); + + let mut rp_encoder = encoder::new_render_pass_encoder_for_tests( + &mut platform_encoder, + &pass, + encoder::RenderPassDestinationInfo { + color_format: Some(texture::TextureFormat::Rgba8Unorm), + depth_format: None, + }, + &mut attachments, + None, + ); + + let initial_viewport = viewport::Viewport { + x: 0, + y: 0, + width: 1, + height: 1, + min_depth: 0.0, + max_depth: 1.0, + }; + + let mut iter = + vec![RenderCommand::SetStencilReference { reference: 1 }].into_iter(); + let err = RenderContext::encode_active_render_pass_commands( + &mut iter, + &mut rp_encoder, + &initial_viewport, + &[], + &[], + &[], + gpu.limit_min_uniform_buffer_offset_alignment(), + ) + .expect_err("must require EndRenderPass"); + + assert!(err.to_string().contains("EndRenderPass")); + } + + /// End-to-end GPU test that renders into both "surface-style" and offscreen + /// passes without requiring an actual window/surface. + #[test] + fn render_context_builder_renders_surface_and_offscreen_passes() { + use std::num::NonZeroU64; + + use crate::render::{ + bind::{ + BindGroupBuilder, + BindGroupLayoutBuilder, + BindingVisibility, + }, + buffer::{ + BufferBuilder, + BufferType, + Properties, + Usage, + }, + command::{ + IndexFormat, + RenderCommand, + }, + encoder::CommandEncoder, + instance::InstanceBuilder, + pipeline::RenderPipelineBuilder, + shader::{ + ShaderBuilder, + ShaderKind, + VirtualShader, + }, + targets::offscreen::OffscreenTargetBuilder, + texture::{ + DepthFormat, + DepthTextureBuilder, + TextureBuilder, + TextureFormat, + TextureUsages, + }, + vertex::{ + ColorFormat, + VertexAttribute, + VertexElement, + }, + viewport::ViewportBuilder, + }; + + fn compile_shaders( + ) -> (crate::render::shader::Shader, crate::render::shader::Shader) { + let vs_source = r#" + #version 450 + #extension GL_ARB_separate_shader_objects : enable + + layout(location = 0) in vec3 a_pos; + + layout(push_constant) uniform Immediates { + vec4 v; + } imms; + + void main() { + // Reference immediates to keep push constants alive. + gl_Position = vec4(a_pos, 1.0) + imms.v * 0.0; + } + "#; + + let fs_source = r#" + #version 450 + #extension GL_ARB_separate_shader_objects : enable + + layout(set = 0, binding = 0) uniform ColorData { + vec4 color; + } u_color; + + layout(location = 0) out vec4 fragment_color; + + void main() { + fragment_color = u_color.color; + } + "#; + + let mut builder = ShaderBuilder::new(); + let vs = builder.build(VirtualShader::Source { + source: vs_source.to_string(), + kind: ShaderKind::Vertex, + name: "lambda-e2e-vert".to_string(), + entry_point: "main".to_string(), + }); + let fs = builder.build(VirtualShader::Source { + source: fs_source.to_string(), + kind: ShaderKind::Fragment, + name: "lambda-e2e-frag".to_string(), + entry_point: "main".to_string(), + }); + return (vs, fs); + } + + let instance = InstanceBuilder::new() + .with_label("lambda-render-context-e2e-instance") + .build(); + let Some(gpu) = gpu::create_test_gpu_with_instance( + &instance, + "lambda-render-context-e2e", + ) else { + return; + }; + + let config = targets::surface::SurfaceConfig { + width: 64, + height: 64, + format: TextureFormat::Rgba8Unorm, + present_mode: targets::surface::PresentMode::Fifo, + usage: TextureUsages::RENDER_ATTACHMENT, + }; + + let depth_texture = DepthTextureBuilder::new() + .with_size(64, 64) + .with_format(DepthFormat::Depth32Float) + .with_label("lambda-depth") + .build(&gpu); + + let mut render_context = RenderContext { + label: "lambda-render-context-e2e".to_string(), + instance, + surface: None, + gpu, + config: config.clone(), + texture_usage: config.usage, + size: (64, 64), + depth_texture: Some(depth_texture), + depth_format: DepthFormat::Depth32Float, + depth_sample_count: 1, + msaa_color: None, + msaa_sample_count: 1, + offscreen_targets: vec![], + render_passes: vec![], + render_pipelines: vec![], + bind_group_layouts: vec![], + bind_groups: vec![], + buffers: vec![], + seen_error_messages: Default::default(), + }; + + assert_eq!(render_context.label(), "lambda-render-context-e2e"); + assert_eq!(render_context.surface_size(), (64, 64)); + assert_eq!(render_context.surface_format(), TextureFormat::Rgba8Unorm); + + let msaa_samples = [4_u32, 2, 1] + .into_iter() + .find(|&count| { + render_context.gpu().supports_sample_count_for_format( + render_context.surface_format(), + count, + ) && render_context + .gpu() + .supports_sample_count_for_depth(DepthFormat::Depth32Float, count) + }) + .unwrap_or(1); + + // Build an offscreen destination matching the headless surface config. + let offscreen = OffscreenTargetBuilder::new() + .with_label("lambda-e2e-offscreen") + .with_color(TextureFormat::Rgba8Unorm, 64, 64) + .with_depth(DepthFormat::Depth24PlusStencil8) + .with_multi_sample(1) + .build(render_context.gpu()) + .expect("build offscreen target"); + let offscreen_id = render_context.attach_offscreen_target(offscreen); + + // Exercise error path for unknown ids. + assert!(render_context + .replace_offscreen_target( + 999, + OffscreenTargetBuilder::new() + .with_color(TextureFormat::Rgba8Unorm, 1, 1) + .build(render_context.gpu()) + .expect("build replacement target"), + ) + .is_err()); + + // Create a pass that requests depth + stencil (single-sample for offscreen compatibility). + let supported_samples = 1_u32; + let pass = render_pass::RenderPassBuilder::new() + .with_label("lambda-e2e-pass") + .with_multi_sample(supported_samples) + .with_depth() + .with_stencil() + .build( + render_context.gpu(), + render_context.surface_format(), + DepthFormat::Depth24PlusStencil8, + ); + let pass_id = render_context.attach_render_pass(pass); + + // One dynamic uniform at set=0,binding=0. + let layout = BindGroupLayoutBuilder::new() + .with_label("lambda-e2e-bgl") + .with_uniform_dynamic(0, BindingVisibility::Fragment) + .build(render_context.gpu()); + let layout_id = render_context.attach_bind_group_layout(layout.clone()); + assert_eq!(layout_id, 0); + + let min_alignment = + render_context.limit_min_uniform_buffer_offset_alignment() as usize; + let ubo_byte_len = (min_alignment * 2).max(256); + let ubo_u32_len = ubo_byte_len / std::mem::size_of::(); + let uniform = BufferBuilder::new() + .with_label("lambda-e2e-uniform") + .with_usage(Usage::UNIFORM) + .with_properties(Properties::CPU_VISIBLE) + .with_buffer_type(BufferType::Uniform) + .build(render_context.gpu(), vec![0_u32; ubo_u32_len]) + .expect("build uniform buffer"); + + let group = BindGroupBuilder::new() + .with_label("lambda-e2e-bg") + .with_layout(&layout) + .with_uniform(0, &uniform, 0, Some(NonZeroU64::new(16).unwrap())) + .build(render_context.gpu()); + let group_id = render_context.attach_bind_group(group.clone()); + assert_eq!(group_id, 0); + + assert!(render_context.replace_bind_group(999, group).is_err()); + + // Vertex + index buffers for a simple triangle. + let vertices: Vec<[f32; 3]> = + vec![[0.0, -0.5, 0.0], [-0.5, 0.5, 0.0], [0.5, 0.5, 0.0]]; + let vertex_buffer = BufferBuilder::new() + .with_label("lambda-e2e-vertex") + .with_usage(Usage::VERTEX) + .with_properties(Properties::CPU_VISIBLE) + .with_buffer_type(BufferType::Vertex) + .build(render_context.gpu(), vertices) + .expect("build vertex buffer"); + + let vertices_msaa: Vec<[f32; 3]> = + vec![[0.0, -0.5, 0.0], [-0.5, 0.5, 0.0], [0.5, 0.5, 0.0]]; + let vertex_buffer_msaa = BufferBuilder::new() + .with_label("lambda-e2e-vertex-msaa") + .with_usage(Usage::VERTEX) + .with_properties(Properties::CPU_VISIBLE) + .with_buffer_type(BufferType::Vertex) + .build(render_context.gpu(), vertices_msaa) + .expect("build msaa vertex buffer"); + + let indices: Vec = vec![0, 1, 2]; + let index_buffer = BufferBuilder::new() + .with_label("lambda-e2e-index") + .with_usage(Usage::INDEX) + .with_properties(Properties::CPU_VISIBLE) + .with_buffer_type(BufferType::Index) + .build(render_context.gpu(), indices) + .expect("build index buffer"); + let index_id = render_context.attach_buffer(index_buffer); + + let (vs, fs) = compile_shaders(); + + let attributes = vec![VertexAttribute { + location: 0, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 0, + }, + }]; + + let pipeline = RenderPipelineBuilder::new() + .with_label("lambda-e2e-pipeline") + .with_layouts(&[&layout]) + .with_immediate_data(16) + .with_buffer(vertex_buffer, attributes.clone()) + .with_multi_sample(supported_samples) + .with_depth_format(DepthFormat::Depth24PlusStencil8) + .build( + render_context.gpu(), + render_context.surface_format(), + DepthFormat::Depth24PlusStencil8, + render_context.get_render_pass(pass_id), + &vs, + Some(&fs), + ); + let pipeline_id = render_context.attach_pipeline(pipeline); + + let viewport = ViewportBuilder::new().build(64, 64); + let viewport_small = ViewportBuilder::new().build(16, 16); + let viewport_offset = + ViewportBuilder::new().with_coordinates(8, 8).build(8, 8); + + // Exercise encoding for a "surface" pass using an offscreen texture view. + let resolve = TextureBuilder::new_2d(TextureFormat::Rgba8Unorm) + .with_size(64, 64) + .for_render_target() + .build(render_context.gpu()) + .expect("build resolve texture"); + let surface_view = resolve.view_ref(); + + let dynamic_offset = min_alignment as u32; + let mut encoder = + CommandEncoder::new(&render_context, "lambda-e2e-encoder"); + + let surface_pass_commands = vec![ + RenderCommand::SetViewports { + start_at: 0, + viewports: vec![viewport_small.clone(), viewport_offset.clone()], + }, + RenderCommand::SetScissors { + start_at: 0, + viewports: vec![viewport_small.clone(), viewport_offset.clone()], + }, + RenderCommand::SetStencilReference { reference: 1 }, + RenderCommand::SetPipeline { + pipeline: pipeline_id, + }, + RenderCommand::SetBindGroup { + set: 0, + group: group_id, + dynamic_offsets: vec![dynamic_offset], + }, + RenderCommand::BindVertexBuffer { + pipeline: pipeline_id, + buffer: 0, + }, + RenderCommand::BindIndexBuffer { + buffer: index_id, + format: IndexFormat::Uint16, + }, + RenderCommand::Immediates { + pipeline: pipeline_id, + offset: 0, + bytes: vec![0_u32; 4], + }, + RenderCommand::DrawIndexed { + indices: 0..3, + base_vertex: 0, + instances: 0..1, + }, + RenderCommand::EndRenderPass, + ]; + let mut surface_pass_iter = surface_pass_commands.into_iter(); + render_context + .encode_surface_render_pass( + &mut encoder, + &mut surface_pass_iter, + pass_id, + viewport.clone(), + surface_view, + ) + .expect("encode headless surface pass"); + + // Encode an offscreen pass as well. + let offscreen_commands = vec![ + RenderCommand::SetPipeline { + pipeline: pipeline_id, + }, + RenderCommand::SetBindGroup { + set: 0, + group: group_id, + dynamic_offsets: vec![0], + }, + RenderCommand::BindVertexBuffer { + pipeline: pipeline_id, + buffer: 0, + }, + RenderCommand::BindIndexBuffer { + buffer: index_id, + format: IndexFormat::Uint16, + }, + RenderCommand::DrawIndexed { + indices: 0..3, + base_vertex: 0, + instances: 0..1, + }, + RenderCommand::EndRenderPass, + ]; + let mut offscreen_iter = offscreen_commands.into_iter(); + render_context + .encode_offscreen_render_pass( + &mut encoder, + &mut offscreen_iter, + pass_id, + viewport.clone(), + offscreen_id, + ) + .expect("encode offscreen pass"); + + if msaa_samples > 1 { + let pass_msaa = render_pass::RenderPassBuilder::new() + .with_label("lambda-e2e-pass-msaa") + .with_multi_sample(msaa_samples) + .with_depth() + .with_stencil() + .build( + render_context.gpu(), + render_context.surface_format(), + DepthFormat::Depth24PlusStencil8, + ); + let pass_msaa_id = render_context.attach_render_pass(pass_msaa); + + let pipeline_msaa = RenderPipelineBuilder::new() + .with_label("lambda-e2e-pipeline-msaa") + .with_layouts(&[&layout]) + .with_immediate_data(16) + .with_buffer(vertex_buffer_msaa, attributes) + .with_multi_sample(msaa_samples) + .with_depth_format(DepthFormat::Depth24PlusStencil8) + .build( + render_context.gpu(), + render_context.surface_format(), + DepthFormat::Depth24PlusStencil8, + render_context.get_render_pass(pass_msaa_id), + &vs, + Some(&fs), + ); + let pipeline_msaa_id = render_context.attach_pipeline(pipeline_msaa); + + let msaa_pass_commands = vec![ + RenderCommand::SetPipeline { + pipeline: pipeline_msaa_id, + }, + RenderCommand::SetBindGroup { + set: 0, + group: group_id, + dynamic_offsets: vec![0], + }, + RenderCommand::BindVertexBuffer { + pipeline: pipeline_msaa_id, + buffer: 0, + }, + RenderCommand::BindIndexBuffer { + buffer: index_id, + format: IndexFormat::Uint16, + }, + RenderCommand::DrawIndexed { + indices: 0..3, + base_vertex: 0, + instances: 0..1, + }, + RenderCommand::EndRenderPass, + ]; + let mut msaa_iter = msaa_pass_commands.into_iter(); + render_context + .encode_surface_render_pass( + &mut encoder, + &mut msaa_iter, + pass_msaa_id, + viewport.clone(), + surface_view, + ) + .expect("encode msaa surface pass"); + } + + encoder.finish(&render_context); + + // Cover headless `render_internal` (offscreen-only) as well. + render_context + .render_internal(vec![ + RenderCommand::SetPipeline { + pipeline: pipeline_id, + }, + RenderCommand::BeginRenderPassTo { + render_pass: pass_id, + viewport: viewport.clone(), + destination: command::RenderDestination::Offscreen(offscreen_id), + }, + RenderCommand::SetPipeline { + pipeline: pipeline_id, + }, + RenderCommand::SetBindGroup { + set: 0, + group: group_id, + dynamic_offsets: vec![0], + }, + RenderCommand::BindVertexBuffer { + pipeline: pipeline_id, + buffer: 0, + }, + RenderCommand::BindIndexBuffer { + buffer: index_id, + format: IndexFormat::Uint16, + }, + RenderCommand::DrawIndexed { + indices: 0..3, + base_vertex: 0, + instances: 0..1, + }, + RenderCommand::EndRenderPass, + ]) + .expect("headless render_internal should support offscreen passes"); + + let err = render_context + .render_internal(vec![RenderCommand::BeginRenderPassTo { + render_pass: pass_id, + viewport: viewport.clone(), + destination: command::RenderDestination::Surface, + }]) + .expect_err("surface passes require an attached surface"); + assert!(matches!( + err, + RenderError::Configuration(msg) if msg.contains("No surface") + )); + + // Cover offscreen configuration error paths. + let mismatch_samples = [4_u32, 2] + .into_iter() + .find(|&count| { + render_context + .gpu() + .supports_sample_count_for_format(TextureFormat::Rgba8Unorm, count) + }) + .unwrap_or(1); + if mismatch_samples != 1 { + let mismatch_pass = render_pass::RenderPassBuilder::new() + .with_label("lambda-e2e-mismatch-pass") + .with_multi_sample(mismatch_samples) + .build( + render_context.gpu(), + TextureFormat::Rgba8Unorm, + DepthFormat::Depth24Plus, + ); + let mismatch_pass_id = render_context.attach_render_pass(mismatch_pass); + let mut mismatch_iter = vec![RenderCommand::EndRenderPass].into_iter(); + let mut mismatch_encoder = + CommandEncoder::new(&render_context, "lambda-e2e-mismatch-encoder"); + let mismatch_err = render_context + .encode_offscreen_render_pass( + &mut mismatch_encoder, + &mut mismatch_iter, + mismatch_pass_id, + viewport.clone(), + offscreen_id, + ) + .expect_err("mismatched pass/target sample counts must error"); + assert!(matches!( + mismatch_err, + RenderError::Configuration(msg) if msg.contains("sample_count") + )); + } + + let target_no_depth = OffscreenTargetBuilder::new() + .with_label("lambda-e2e-offscreen-no-depth") + .with_color(TextureFormat::Rgba8Unorm, 8, 8) + .build(render_context.gpu()) + .expect("build offscreen target without depth"); + let target_no_depth_id = + render_context.attach_offscreen_target(target_no_depth); + let mut no_depth_iter = vec![RenderCommand::EndRenderPass].into_iter(); + let mut no_depth_encoder = + CommandEncoder::new(&render_context, "lambda-e2e-no-depth-encoder"); + let no_depth_err = render_context + .encode_offscreen_render_pass( + &mut no_depth_encoder, + &mut no_depth_iter, + pass_id, + viewport.clone(), + target_no_depth_id, + ) + .expect_err( + "pass with depth/stencil must require a target depth attachment", + ); + assert!(matches!( + no_depth_err, + RenderError::Configuration(msg) if msg.contains("no depth attachment") + )); + + let target_no_stencil = OffscreenTargetBuilder::new() + .with_label("lambda-e2e-offscreen-no-stencil") + .with_color(TextureFormat::Rgba8Unorm, 8, 8) + .with_depth(DepthFormat::Depth24Plus) + .build(render_context.gpu()) + .expect("build offscreen target without stencil"); + let target_no_stencil_id = + render_context.attach_offscreen_target(target_no_stencil); + let stencil_pass = render_pass::RenderPassBuilder::new() + .with_label("lambda-e2e-stencil-pass") + .with_stencil() + .build( + render_context.gpu(), + TextureFormat::Rgba8Unorm, + DepthFormat::Depth24Plus, + ); + let stencil_pass_id = render_context.attach_render_pass(stencil_pass); + let mut stencil_iter = vec![RenderCommand::EndRenderPass].into_iter(); + let mut stencil_encoder = + CommandEncoder::new(&render_context, "lambda-e2e-stencil-encoder"); + let stencil_err = render_context + .encode_offscreen_render_pass( + &mut stencil_encoder, + &mut stencil_iter, + stencil_pass_id, + viewport.clone(), + target_no_stencil_id, + ) + .expect_err("stencil pass must require stencil-capable depth format"); + assert!(matches!( + stencil_err, + RenderError::Configuration(msg) if msg.contains("stencil") + )); + + // Resize exercises headless depth/MSAA rebuild paths without touching a surface. + render_context.resize(32, 32); + assert_eq!(render_context.surface_size(), (32, 32)); + } + #[test] fn present_mode_defaults_to_window_vsync_true() { let mode = resolve_surface_present_mode(None, true); diff --git a/crates/lambda-rs/src/render/pipeline.rs b/crates/lambda-rs/src/render/pipeline.rs index 28cbf15f..3bb7206b 100644 --- a/crates/lambda-rs/src/render/pipeline.rs +++ b/crates/lambda-rs/src/render/pipeline.rs @@ -692,6 +692,7 @@ impl RenderPipelineBuilder { mod tests { use super::*; + /// Ensures vertex step modes map to the platform vertex step modes. #[test] fn engine_step_mode_maps_to_platform_step_mode() { let per_vertex = to_platform_step_mode(VertexStepMode::PerVertex); @@ -706,4 +707,288 @@ mod tests { platform_pipeline::VertexStepMode::Instance )); } + + /// Ensures depth compare functions map to the platform compare functions. + #[test] + fn compare_function_maps_to_platform() { + assert!(matches!( + CompareFunction::Less.to_platform(), + platform_pipeline::CompareFunction::Less + )); + assert!(matches!( + CompareFunction::Always.to_platform(), + platform_pipeline::CompareFunction::Always + )); + } + + /// Ensures culling mode configuration maps to the platform culling modes. + #[test] + fn culling_mode_maps_to_platform() { + assert!(matches!( + CullingMode::None.to_platform(), + platform_pipeline::CullingMode::None + )); + assert!(matches!( + CullingMode::Back.to_platform(), + platform_pipeline::CullingMode::Back + )); + } + + /// Ensures invalid MSAA sample counts are clamped/fallen back to `1`. + #[test] + fn pipeline_builder_invalid_sample_count_falls_back_to_one() { + let builder = RenderPipelineBuilder::new().with_multi_sample(3); + assert_eq!(builder.sample_count, 1); + } + + /// Builds a pipeline with depth+stencil enabled and both per-vertex and + /// per-instance buffers, covering format upgrade and instance slot tracking. + #[test] + fn pipeline_builds_with_depth_stencil_and_instance_layout() { + use crate::render::{ + bind::{ + BindGroupLayoutBuilder, + BindingVisibility, + }, + buffer::{ + BufferBuilder, + BufferType, + Properties, + Usage, + }, + gpu::create_test_gpu, + render_pass::RenderPassBuilder, + shader::{ + ShaderBuilder, + ShaderKind, + VirtualShader, + }, + texture::{ + DepthFormat, + TextureFormat, + }, + vertex::{ + ColorFormat, + VertexAttribute, + VertexElement, + }, + }; + + let Some(gpu) = create_test_gpu("lambda-pipeline-depth-test") else { + return; + }; + + let mut shaders = ShaderBuilder::new(); + let vs = shaders.build(VirtualShader::Source { + source: r#" + #version 450 + #extension GL_ARB_separate_shader_objects : enable + layout(location = 0) in vec3 a_pos; + layout(location = 1) in vec3 a_inst; + void main() { gl_Position = vec4(a_pos + a_inst * 0.0, 1.0); } + "# + .to_string(), + kind: ShaderKind::Vertex, + name: "lambda-pipeline-depth-vs".to_string(), + entry_point: "main".to_string(), + }); + let fs = shaders.build(VirtualShader::Source { + source: r#" + #version 450 + #extension GL_ARB_separate_shader_objects : enable + layout(location = 0) out vec4 fragment_color; + void main() { fragment_color = vec4(1.0); } + "# + .to_string(), + kind: ShaderKind::Fragment, + name: "lambda-pipeline-depth-fs".to_string(), + entry_point: "main".to_string(), + }); + + let pass = RenderPassBuilder::new() + .with_label("lambda-pipeline-depth-pass") + .with_depth() + .with_stencil() + .build(&gpu, TextureFormat::Rgba8Unorm, DepthFormat::Depth24Plus); + + let layout = BindGroupLayoutBuilder::new() + .with_uniform(0, BindingVisibility::VertexAndFragment) + .build(&gpu); + + let vertex_buffer = BufferBuilder::new() + .with_label("lambda-pipeline-depth-vertex") + .with_usage(Usage::VERTEX) + .with_properties(Properties::CPU_VISIBLE) + .with_buffer_type(BufferType::Vertex) + .build(&gpu, vec![[0.0_f32; 3]; 3]) + .expect("build vertex buffer"); + + let instance_buffer = BufferBuilder::new() + .with_label("lambda-pipeline-depth-instance") + .with_usage(Usage::VERTEX) + .with_properties(Properties::CPU_VISIBLE) + .with_buffer_type(BufferType::Vertex) + .build(&gpu, vec![[0.0_f32; 3]; 1]) + .expect("build instance buffer"); + + let attrs_pos = vec![VertexAttribute { + location: 0, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 0, + }, + }]; + let attrs_inst = vec![VertexAttribute { + location: 1, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 0, + }, + }]; + + let 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, + }; + + // Intentionally request a mismatched depth format; build should align to the pass. + let pipeline = RenderPipelineBuilder::new() + .with_label("lambda-pipeline-depth-pipeline") + .with_layouts(&[&layout]) + .with_buffer(vertex_buffer, attrs_pos) + .with_instance_buffer(instance_buffer, attrs_inst) + .with_depth_format(DepthFormat::Depth32Float) + .with_depth_compare(CompareFunction::Less) + .with_depth_write(true) + .with_stencil(stencil) + .build( + &gpu, + TextureFormat::Rgba8Unorm, + DepthFormat::Depth24Plus, + &pass, + &vs, + Some(&fs), + ); + + assert!(pipeline.expects_depth_stencil()); + assert!(pipeline.uses_stencil()); + assert_eq!( + pipeline.depth_format(), + Some(DepthFormat::Depth24PlusStencil8) + ); + assert_eq!(pipeline.per_instance_slots().len(), 2); + } + + /// Ensures pipeline construction aligns its MSAA sample count to the render + /// pass sample count to avoid target incompatibility. + #[test] + fn pipeline_build_aligns_sample_count_to_render_pass() { + use crate::render::{ + buffer::{ + BufferBuilder, + BufferType, + Properties, + Usage, + }, + gpu::create_test_gpu, + render_pass::RenderPassBuilder, + shader::{ + ShaderBuilder, + ShaderKind, + VirtualShader, + }, + texture::{ + DepthFormat, + TextureFormat, + }, + vertex::{ + ColorFormat, + VertexAttribute, + VertexElement, + }, + }; + + let Some(gpu) = create_test_gpu("lambda-pipeline-test") else { + return; + }; + + let vert_path = format!( + "{}/assets/shaders/triangle.vert", + env!("CARGO_MANIFEST_DIR") + ); + let frag_path = format!( + "{}/assets/shaders/triangle.frag", + env!("CARGO_MANIFEST_DIR") + ); + let mut shaders = ShaderBuilder::new(); + let vs = shaders.build(VirtualShader::File { + path: vert_path, + kind: ShaderKind::Vertex, + name: "triangle-vert".to_string(), + entry_point: "main".to_string(), + }); + let fs = shaders.build(VirtualShader::File { + path: frag_path, + kind: ShaderKind::Fragment, + name: "triangle-frag".to_string(), + entry_point: "main".to_string(), + }); + + let pass = RenderPassBuilder::new() + .with_label("pipeline-sample-align-pass") + .with_multi_sample(4) + .build(&gpu, TextureFormat::Rgba8Unorm, DepthFormat::Depth24Plus); + + let vertex_buffer = BufferBuilder::new() + .with_label("pipeline-test-vertex-buffer") + .with_usage(Usage::VERTEX) + .with_properties(Properties::CPU_VISIBLE) + .with_buffer_type(BufferType::Vertex) + .build(&gpu, vec![[0.0_f32; 3]; 4]) + .expect("build vertex buffer"); + + let attributes = vec![VertexAttribute { + location: 0, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 0, + }, + }]; + + // Intentionally request a different sample count; build should align to the pass. + let pipeline = RenderPipelineBuilder::new() + .with_label("pipeline-sample-align-pipeline") + .with_multi_sample(1) + .with_buffer(vertex_buffer, attributes) + .build( + &gpu, + TextureFormat::Rgba8Unorm, + DepthFormat::Depth24Plus, + &pass, + &vs, + Some(&fs), + ); + + assert_eq!(pipeline.sample_count(), 4); + assert!(pipeline.has_color_targets()); + assert_eq!( + pipeline.color_target_format(), + Some(TextureFormat::Rgba8Unorm) + ); + } } diff --git a/crates/lambda-rs/src/render/render_pass.rs b/crates/lambda-rs/src/render/render_pass.rs index c5a25966..82d0ba97 100644 --- a/crates/lambda-rs/src/render/render_pass.rs +++ b/crates/lambda-rs/src/render/render_pass.rs @@ -581,4 +581,47 @@ mod tests { assert_eq!(resolved, 1); } + + /// Clamps depth clear values into the valid `[0, 1]` range used by wgpu depth + /// attachments. + #[test] + fn with_depth_clear_clamps_to_unit_interval() { + let builder = RenderPassBuilder::new().with_depth_clear(2.0); + let depth_ops = builder.depth_operations.expect("depth ops"); + assert!( + matches!(depth_ops.load, DepthLoadOp::Clear(v) if (v - 1.0).abs() < f64::EPSILON) + ); + + let builder = RenderPassBuilder::new().with_depth_clear(-5.0); + let depth_ops = builder.depth_operations.expect("depth ops"); + assert!( + matches!(depth_ops.load, DepthLoadOp::Clear(v) if (v - 0.0).abs() < f64::EPSILON) + ); + } + + /// Ensures invalid MSAA sample counts are sanitized to `1`. + #[test] + fn with_multi_sample_invalid_values_fall_back_to_one() { + let builder = RenderPassBuilder::new().with_multi_sample(3); + assert_eq!(builder.sample_count, 1); + } + + /// Ensures callers can explicitly disable color outputs on a render pass. + #[test] + fn without_color_disables_color_attachments() { + let builder = RenderPassBuilder::new().without_color(); + assert!(!builder.use_color); + } + + /// Ensures overriding color operations updates the stored clear color. + #[test] + fn with_color_operations_updates_clear_color() { + let builder = + RenderPassBuilder::new().with_color_operations(ColorOperations { + load: ColorLoadOp::Clear([0.1, 0.2, 0.3, 0.4]), + store: StoreOp::Store, + }); + + assert_eq!(builder.clear_color, [0.1, 0.2, 0.3, 0.4]); + } } diff --git a/crates/lambda-rs/src/render/shader.rs b/crates/lambda-rs/src/render/shader.rs index 16d0960c..ce078ca4 100644 --- a/crates/lambda-rs/src/render/shader.rs +++ b/crates/lambda-rs/src/render/shader.rs @@ -90,3 +90,54 @@ impl Shader { return &self.virtual_shader; } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Compiles a minimal inline shader and verifies the compiled SPIR‑V is + /// accessible via both borrowing and owned accessors. + #[test] + fn shader_builder_compiles_source_and_exposes_binary() { + let source = r#" + #version 450 + #extension GL_ARB_separate_shader_objects : enable + void main() { + gl_Position = vec4(0.0, 0.0, 0.0, 1.0); + } + "#; + + let mut builder = ShaderBuilder::new(); + let shader = builder.build(VirtualShader::Source { + source: source.to_string(), + kind: ShaderKind::Vertex, + name: "test-vert".to_string(), + entry_point: "main".to_string(), + }); + + assert!(!shader.binary().is_empty()); + assert_eq!(shader.as_binary(), shader.binary()); + assert_eq!(shader.virtual_shader().name(), "test-vert"); + assert!(matches!(shader.virtual_shader().kind(), ShaderKind::Vertex)); + } + + /// Compiles a shader from a file path and validates the output is non-empty. + #[test] + fn shader_builder_compiles_file_shader() { + let vert_path = format!( + "{}/assets/shaders/triangle.vert", + env!("CARGO_MANIFEST_DIR") + ); + + let mut builder = ShaderBuilder::new(); + let shader = builder.build(VirtualShader::File { + path: vert_path, + kind: ShaderKind::Vertex, + name: "triangle-vert".to_string(), + entry_point: "main".to_string(), + }); + + assert!(!shader.binary().is_empty()); + assert_eq!(shader.virtual_shader().name(), "triangle-vert"); + } +} diff --git a/crates/lambda-rs/src/render/surface.rs b/crates/lambda-rs/src/render/surface.rs index c6fc0cef..948fe964 100644 --- a/crates/lambda-rs/src/render/surface.rs +++ b/crates/lambda-rs/src/render/surface.rs @@ -192,3 +192,77 @@ impl From for SurfaceError { }; } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Ensures present mode conversions to/from the platform are lossless. + #[test] + fn present_mode_round_trips_through_platform() { + let modes = [ + PresentMode::Fifo, + PresentMode::FifoRelaxed, + PresentMode::Immediate, + PresentMode::Mailbox, + PresentMode::AutoVsync, + PresentMode::AutoNoVsync, + ]; + + for mode in modes { + let platform = mode.to_platform(); + let back = PresentMode::from_platform(platform); + assert_eq!(back, mode); + } + } + + /// Ensures each platform surface error maps to the corresponding engine + /// surface error variant. + #[test] + fn surface_error_maps_platform_variants() { + assert!(matches!( + SurfaceError::from(platform_surface::SurfaceError::Lost), + SurfaceError::Lost + )); + assert!(matches!( + SurfaceError::from(platform_surface::SurfaceError::Outdated), + SurfaceError::Outdated + )); + assert!(matches!( + SurfaceError::from(platform_surface::SurfaceError::OutOfMemory), + SurfaceError::OutOfMemory + )); + assert!(matches!( + SurfaceError::from(platform_surface::SurfaceError::Timeout), + SurfaceError::Timeout + )); + + let other = SurfaceError::from(platform_surface::SurfaceError::Other( + "opaque".to_string(), + )); + assert!(matches!(other, SurfaceError::Other(_))); + } + + /// Ensures surface configuration fields are preserved when mapping from the + /// platform configuration type. + #[test] + fn surface_config_from_platform_maps_fields() { + let platform_config = platform_surface::SurfaceConfig { + width: 640, + height: 480, + format: lambda_platform::wgpu::texture::TextureFormat::BGRA8_UNORM_SRGB, + present_mode: platform_surface::PresentMode::Fifo, + usage: lambda_platform::wgpu::texture::TextureUsages::RENDER_ATTACHMENT + | lambda_platform::wgpu::texture::TextureUsages::TEXTURE_BINDING, + view_formats: vec![], + }; + + let config = SurfaceConfig::from_platform(&platform_config); + assert_eq!(config.width, 640); + assert_eq!(config.height, 480); + assert_eq!(config.present_mode, PresentMode::Fifo); + assert!(config + .usage + .contains(crate::render::texture::TextureUsages::RENDER_ATTACHMENT)); + } +} diff --git a/crates/lambda-rs/src/render/targets/offscreen.rs b/crates/lambda-rs/src/render/targets/offscreen.rs index ee46fa48..4b047930 100644 --- a/crates/lambda-rs/src/render/targets/offscreen.rs +++ b/crates/lambda-rs/src/render/targets/offscreen.rs @@ -316,10 +316,6 @@ mod tests { use lambda_platform::wgpu as platform; use super::*; - use crate::render::{ - gpu::GpuBuilder, - instance::InstanceBuilder, - }; /// Fails when the builder has a zero dimension. #[test] @@ -347,21 +343,14 @@ mod tests { assert_eq!(builder.sample_count, 1); } - fn create_test_gpu() -> Option { - let instance = InstanceBuilder::new() - .with_label("lambda-offscreen-target-test-instance") - .build(); - return GpuBuilder::new() - .with_label("lambda-offscreen-target-test-gpu") - .build(&instance, None) - .ok(); - } - + /// Ensures the builder rejects attempts to build without configuring a color + /// attachment. #[test] fn build_rejects_missing_color_attachment() { - let gpu = match create_test_gpu() { - Some(gpu) => gpu, - None => return, + let Some(gpu) = + crate::render::gpu::create_test_gpu("lambda-offscreen-target-test") + else { + return; }; let built = OffscreenTargetBuilder::new().build(&gpu); @@ -371,11 +360,14 @@ mod tests { ); } + /// Ensures unsupported MSAA sample counts are rejected with an explicit + /// error rather than silently falling back. #[test] fn build_rejects_unsupported_sample_count() { - let gpu = match create_test_gpu() { - Some(gpu) => gpu, - None => return, + let Some(gpu) = + crate::render::gpu::create_test_gpu("lambda-offscreen-target-test") + else { + return; }; let built = OffscreenTargetBuilder::new() @@ -389,11 +381,14 @@ mod tests { ); } + /// Ensures the resolve texture can be bound for sampling and also used as a + /// render attachment (required for render-to-texture workflows). #[test] fn resolve_texture_supports_sampling_and_render_attachment() { - let gpu = match create_test_gpu() { - Some(gpu) => gpu, - None => return, + let Some(gpu) = + crate::render::gpu::create_test_gpu("lambda-offscreen-target-test") + else { + return; }; let target = OffscreenTargetBuilder::new() @@ -437,11 +432,14 @@ mod tests { gpu.platform().submit(std::iter::once(buffer)); } + /// Ensures MSAA offscreen targets use compatible sample counts across color + /// and depth attachments so they can be encoded into a single render pass. #[test] fn msaa_target_depth_attachment_matches_sample_count() { - let gpu = match create_test_gpu() { - Some(gpu) => gpu, - None => return, + let Some(gpu) = + crate::render::gpu::create_test_gpu("lambda-offscreen-target-test") + else { + return; }; let target = OffscreenTargetBuilder::new() diff --git a/crates/lambda-rs/src/render/targets/surface.rs b/crates/lambda-rs/src/render/targets/surface.rs index 84b13fe2..92053f95 100644 --- a/crates/lambda-rs/src/render/targets/surface.rs +++ b/crates/lambda-rs/src/render/targets/surface.rs @@ -371,3 +371,61 @@ impl std::fmt::Display for WindowSurfaceError { }; } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Ensures present mode conversions to/from the platform are lossless. + #[test] + fn present_mode_round_trips_through_platform() { + let modes = [ + PresentMode::Fifo, + PresentMode::FifoRelaxed, + PresentMode::Immediate, + PresentMode::Mailbox, + PresentMode::AutoVsync, + PresentMode::AutoNoVsync, + ]; + + for mode in modes { + let platform = mode.to_platform(); + let back = PresentMode::from_platform(platform); + assert_eq!(back, mode); + } + } + + /// Ensures each platform surface error maps to the corresponding engine + /// surface error variant. + #[test] + fn surface_error_maps_platform_variants() { + assert!(matches!( + SurfaceError::from(platform::surface::SurfaceError::Lost), + SurfaceError::Lost + )); + assert!(matches!( + SurfaceError::from(platform::surface::SurfaceError::Outdated), + SurfaceError::Outdated + )); + assert!(matches!( + SurfaceError::from(platform::surface::SurfaceError::OutOfMemory), + SurfaceError::OutOfMemory + )); + assert!(matches!( + SurfaceError::from(platform::surface::SurfaceError::Timeout), + SurfaceError::Timeout + )); + let other = SurfaceError::from(platform::surface::SurfaceError::Other( + "opaque".to_string(), + )); + assert!(matches!(other, SurfaceError::Other(_))); + } + + /// Ensures window surface errors are displayed using the underlying message. + #[test] + fn window_surface_error_is_displayed() { + let error = + WindowSurfaceError::CreationFailed("creation failed".to_string()); + assert_eq!(error.to_string(), "creation failed"); + } +} diff --git a/crates/lambda-rs/src/render/texture.rs b/crates/lambda-rs/src/render/texture.rs index a33f6799..c68d925d 100644 --- a/crates/lambda-rs/src/render/texture.rs +++ b/crates/lambda-rs/src/render/texture.rs @@ -646,6 +646,99 @@ impl SamplerBuilder { mod tests { use super::*; + /// Ensures texture formats round-trip to/from the platform and report the + /// expected bytes-per-pixel and sRGB classification. + #[test] + fn texture_format_round_trips_through_platform() { + let formats = [ + TextureFormat::Rgba8Unorm, + TextureFormat::Rgba8UnormSrgb, + TextureFormat::Bgra8Unorm, + TextureFormat::Bgra8UnormSrgb, + ]; + + for fmt in formats { + let platform = fmt.to_platform(); + let back = TextureFormat::from_platform(platform).expect("round trip"); + assert_eq!(back, fmt); + assert_eq!(fmt.bytes_per_pixel(), 4); + } + + assert!(TextureFormat::Rgba8UnormSrgb.is_srgb()); + assert!(!TextureFormat::Rgba8Unorm.is_srgb()); + } + + /// Ensures depth formats map to the platform depth formats. + #[test] + fn depth_format_maps_to_platform() { + assert!(matches!( + DepthFormat::Depth32Float.to_platform(), + platform::DepthFormat::Depth32Float + )); + assert!(matches!( + DepthFormat::Depth24Plus.to_platform(), + platform::DepthFormat::Depth24Plus + )); + assert!(matches!( + DepthFormat::Depth24PlusStencil8.to_platform(), + platform::DepthFormat::Depth24PlusStencil8 + )); + } + + /// Ensures view dimensions map to the platform view dimension types. + #[test] + fn view_dimension_maps_to_platform() { + assert!(matches!( + ViewDimension::D2.to_platform(), + platform::ViewDimension::TwoDimensional + )); + assert!(matches!( + ViewDimension::D3.to_platform(), + platform::ViewDimension::ThreeDimensional + )); + } + + /// Ensures sampler-related enums map correctly to the platform enums. + #[test] + fn sampler_modes_map_to_platform() { + assert!(matches!( + FilterMode::Nearest.to_platform(), + platform::FilterMode::Nearest + )); + assert!(matches!( + FilterMode::Linear.to_platform(), + platform::FilterMode::Linear + )); + + assert!(matches!( + AddressMode::ClampToEdge.to_platform(), + platform::AddressMode::ClampToEdge + )); + assert!(matches!( + AddressMode::Repeat.to_platform(), + platform::AddressMode::Repeat + )); + assert!(matches!( + AddressMode::MirrorRepeat.to_platform(), + platform::AddressMode::MirrorRepeat + )); + } + + /// Ensures texture usage flags support bit ops and `contains` checks. + #[test] + fn texture_usages_support_bit_ops_and_contains() { + let mut usages = TextureUsages::empty(); + assert!(!usages.contains(TextureUsages::RENDER_ATTACHMENT)); + + usages |= TextureUsages::RENDER_ATTACHMENT; + assert!(usages.contains(TextureUsages::RENDER_ATTACHMENT)); + + let combined = TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST; + assert!(combined.contains(TextureUsages::TEXTURE_BINDING)); + assert!(combined.contains(TextureUsages::COPY_DST)); + } + + /// Ensures `for_render_target` toggles the internal render-target usage flag. #[test] fn texture_builder_marks_render_target_usage() { let builder = @@ -654,9 +747,44 @@ mod tests { assert!(builder.is_render_target); } + /// Ensures depth texture builders clamp invalid sample counts to `1`. #[test] fn depth_texture_builder_clamps_sample_count() { let builder = DepthTextureBuilder::new().with_sample_count(0); assert_eq!(builder.sample_count, 1); } + + /// Ensures textures with invalid dimensions fail during build with an + /// actionable error message. + #[test] + fn texture_builder_rejects_invalid_dimensions() { + let Some(gpu) = crate::render::gpu::create_test_gpu("lambda-texture-test") + else { + return; + }; + + let err = TextureBuilder::new_2d(TextureFormat::Rgba8Unorm) + .with_size(0, 0) + .build(&gpu) + .expect_err("invalid dimensions should error"); + assert_eq!(err, "Invalid texture dimensions"); + } + + /// Ensures the 3D texture builder selects the platform 3D creation path when + /// the configured depth is greater than `1`. + #[test] + fn texture_builder_builds_3d_texture_path() { + let Some(gpu) = + crate::render::gpu::create_test_gpu("lambda-texture-3d-test") + else { + return; + }; + + // 3D texture builder selects the 3D upload path when depth > 1. + let tex = TextureBuilder::new_3d(TextureFormat::Rgba8Unorm) + .with_size_3d(2, 2, 2) + .build(&gpu) + .expect("build 3d texture"); + let _ = tex.platform_texture(); + } } diff --git a/crates/lambda-rs/src/render/validation.rs b/crates/lambda-rs/src/render/validation.rs index 82d72090..c10c3b3e 100644 --- a/crates/lambda-rs/src/render/validation.rs +++ b/crates/lambda-rs/src/render/validation.rs @@ -104,11 +104,13 @@ pub fn validate_instance_bindings( mod tests { use super::*; + /// Ensures `align_up` is a no-op when alignment is zero. #[test] fn align_up_noop_on_zero_align() { assert_eq!(align_up(13, 0), 13); } + /// Ensures `align_up` rounds values up to the next alignment multiple. #[test] fn align_up_rounds_to_multiple() { assert_eq!(align_up(0, 256), 0); @@ -118,6 +120,8 @@ mod tests { assert_eq!(align_up(257, 256), 512); } + /// Validates dynamic uniform offset validation checks both count and + /// alignment, and returns actionable error messages. #[test] fn validate_dynamic_offsets_count_and_alignment() { // Correct count and alignment @@ -136,12 +140,16 @@ mod tests { assert!(err.contains("not 256-byte aligned")); } + /// Ensures instance ranges with start <= end are accepted (including empty + /// ranges). #[test] fn validate_instance_range_accepts_valid_ranges() { assert!(validate_instance_range("Draw", &(0..1)).is_ok()); assert!(validate_instance_range("DrawIndexed", &(2..2)).is_ok()); } + /// Ensures instance ranges with start > end are rejected with a detailed + /// error. #[test] fn validate_instance_range_rejects_negative_length() { let start = 5_u32; @@ -151,6 +159,8 @@ mod tests { assert!(err.contains("Draw instance range start 5 is greater than end 1")); } + /// Ensures instance binding validation passes when all per-instance slots + /// are bound. #[test] fn validate_instance_bindings_accepts_bound_slots() { let per_instance_slots = vec![true, false, true]; @@ -166,6 +176,8 @@ mod tests { .is_ok()); } + /// Ensures instance binding validation reports missing per-instance vertex + /// buffer slots. #[test] fn validate_instance_bindings_rejects_missing_slot() { let per_instance_slots = vec![true, false, true]; diff --git a/crates/lambda-rs/src/render/vertex.rs b/crates/lambda-rs/src/render/vertex.rs index 57519ab0..b2087834 100644 --- a/crates/lambda-rs/src/render/vertex.rs +++ b/crates/lambda-rs/src/render/vertex.rs @@ -136,6 +136,8 @@ impl VertexBuilder { mod test { use super::*; + /// Ensures `VertexBuilder` defaults and chained setters produce the expected + /// `Vertex` output. #[test] fn vertex_building() { let mut vertex = VertexBuilder::new(); diff --git a/crates/lambda-rs/src/render/viewport.rs b/crates/lambda-rs/src/render/viewport.rs index 2a1eea43..7827ae4a 100644 --- a/crates/lambda-rs/src/render/viewport.rs +++ b/crates/lambda-rs/src/render/viewport.rs @@ -76,3 +76,38 @@ impl ViewportBuilder { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Ensures negative viewport coordinates are clamped to zero to avoid + /// underflow when converting into unsigned pixel coordinates. + #[test] + fn viewport_builder_clamps_negative_coordinates() { + let viewport = ViewportBuilder::new() + .with_coordinates(-10, -20) + .build(3, 4); + assert_eq!(viewport.x, 0); + assert_eq!(viewport.y, 0); + assert_eq!(viewport.width, 3); + assert_eq!(viewport.height, 4); + } + + /// Ensures helper methods return the expected tuple forms used by the + /// platform viewport/scissor APIs. + #[test] + fn viewport_helpers_return_expected_tuples() { + let viewport = Viewport { + x: 1, + y: 2, + width: 3, + height: 4, + min_depth: 0.25, + max_depth: 0.75, + }; + + assert_eq!(viewport.viewport_f32(), (1.0, 2.0, 3.0, 4.0, 0.25, 0.75)); + assert_eq!(viewport.scissor_u32(), (1, 2, 3, 4)); + } +} diff --git a/crates/lambda-rs/src/render/window.rs b/crates/lambda-rs/src/render/window.rs index 17a2c89f..be204bb5 100644 --- a/crates/lambda-rs/src/render/window.rs +++ b/crates/lambda-rs/src/render/window.rs @@ -127,3 +127,57 @@ impl Window { return self.vsync; } } + +#[cfg(test)] +mod tests { + #[cfg(not(target_os = "macos"))] + use lambda_platform::winit::LoopBuilder; + + use super::*; + + /// Ensures `WindowBuilder` initializes with stable, sensible defaults. + #[test] + fn window_builder_defaults_are_sensible() { + let builder = WindowBuilder::new(); + assert_eq!(builder.name, "Window"); + assert_eq!(builder.dimensions, (480, 360)); + assert!(builder.vsync); + } + + /// Ensures the fluent builder setters update the stored window properties. + #[test] + fn window_builder_allows_overriding_properties() { + let builder = WindowBuilder::new() + .with_name("Hello") + .with_dimensions(800, 600) + .with_vsync(false); + + assert_eq!(builder.name, "Hello"); + assert_eq!(builder.dimensions, (800, 600)); + assert!(!builder.vsync); + } + + /// Best-effort window construction smoke test for environments that support + /// window creation (skips on macOS test threads and in headless runners). + #[test] + #[cfg(not(target_os = "macos"))] + fn window_build_is_best_effort_in_headless_envs() { + let attempt = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut event_loop = LoopBuilder::new().build(); + WindowBuilder::new() + .with_name("lambda-window-test") + .with_dimensions(10, 20) + .with_vsync(true) + .build(&mut event_loop) + })); + + let window = match attempt { + Ok(window) => window, + Err(_) => return, // likely headless environment + }; + + assert_eq!(window.dimensions(), (10, 20)); + assert!(window.vsync_requested()); + } +}