diff --git a/.github/workflows/compile_lambda_rs.yml b/.github/workflows/compile_lambda_rs.yml index 33449125..747066f9 100644 --- a/.github/workflows/compile_lambda_rs.yml +++ b/.github/workflows/compile_lambda_rs.yml @@ -47,7 +47,16 @@ jobs: sudo apt-get install -y \ pkg-config libx11-dev libxcb1-dev libxcb-render0-dev \ libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev \ - libwayland-dev libudev-dev libvulkan-dev + libwayland-dev libudev-dev \ + libvulkan-dev libvulkan1 mesa-vulkan-drivers vulkan-tools + + - name: Configure Vulkan (Ubuntu) + 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" + vulkaninfo --summary || true # Windows runners already include the required toolchain for DX12 builds. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 328969bc..b458b49c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,6 +41,21 @@ jobs: - name: Rust cache uses: Swatinem/rust-cache@v2 + - name: Install Linux deps for winit/wgpu + run: | + sudo apt-get update + sudo apt-get install -y \ + pkg-config libx11-dev libxcb1-dev libxcb-render0-dev \ + libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev \ + libwayland-dev libudev-dev \ + libvulkan-dev libvulkan1 mesa-vulkan-drivers vulkan-tools + + - 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" + vulkaninfo --summary || true + - name: Format check run: cargo fmt --all -- --check diff --git a/crates/lambda-rs-platform/src/wgpu/bind.rs b/crates/lambda-rs-platform/src/wgpu/bind.rs index 4cc5968e..128a7687 100644 --- a/crates/lambda-rs-platform/src/wgpu/bind.rs +++ b/crates/lambda-rs-platform/src/wgpu/bind.rs @@ -97,6 +97,35 @@ mod tests { ); assert_eq!(Visibility::All.to_wgpu(), wgpu::ShaderStages::all()); } + + #[test] + fn sampled_texture_2d_layout_entry_is_correct() { + let builder = BindGroupLayoutBuilder::new() + .with_sampled_texture_2d(1, Visibility::Fragment) + .with_sampler(2, Visibility::Fragment); + assert_eq!(builder.entries.len(), 2); + match builder.entries[0].ty { + wgpu::BindingType::Texture { + sample_type, + view_dimension, + multisampled, + } => { + assert_eq!(view_dimension, wgpu::TextureViewDimension::D2); + assert_eq!(multisampled, false); + match sample_type { + wgpu::TextureSampleType::Float { filterable } => assert!(filterable), + _ => panic!("expected float sample type"), + } + } + _ => panic!("expected texture binding type"), + } + match builder.entries[1].ty { + wgpu::BindingType::Sampler(kind) => { + assert_eq!(kind, wgpu::SamplerBindingType::Filtering); + } + _ => panic!("expected sampler binding type"), + } + } } /// Builder for creating a `wgpu::BindGroupLayout`. @@ -155,6 +184,56 @@ impl BindGroupLayoutBuilder { return self; } + /// Declare a sampled texture binding (2D) at the provided index. + pub fn with_sampled_texture_2d( + mut self, + binding: u32, + visibility: Visibility, + ) -> Self { + self.entries.push(wgpu::BindGroupLayoutEntry { + binding, + visibility: visibility.to_wgpu(), + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }); + return self; + } + + /// Declare a sampled texture binding with an explicit view dimension. + pub fn with_sampled_texture_dim( + mut self, + binding: u32, + visibility: Visibility, + view_dimension: crate::wgpu::texture::ViewDimension, + ) -> Self { + self.entries.push(wgpu::BindGroupLayoutEntry { + binding, + visibility: visibility.to_wgpu(), + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: view_dimension.to_wgpu(), + multisampled: false, + }, + count: None, + }); + return self; + } + + /// Declare a filtering sampler binding at the provided index. + pub fn with_sampler(mut self, binding: u32, visibility: Visibility) -> Self { + self.entries.push(wgpu::BindGroupLayoutEntry { + binding, + visibility: visibility.to_wgpu(), + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }); + return self; + } + /// Build the layout using the provided device. pub fn build(self, gpu: &Gpu) -> BindGroupLayout { let raw = @@ -220,6 +299,32 @@ impl<'a> BindGroupBuilder<'a> { return self; } + /// Bind a texture view at a binding index. + pub fn with_texture( + mut self, + binding: u32, + texture: &'a crate::wgpu::texture::Texture, + ) -> Self { + self.entries.push(wgpu::BindGroupEntry { + binding, + resource: wgpu::BindingResource::TextureView(texture.view()), + }); + return self; + } + + /// Bind a sampler at a binding index. + pub fn with_sampler( + mut self, + binding: u32, + sampler: &'a crate::wgpu::texture::Sampler, + ) -> Self { + self.entries.push(wgpu::BindGroupEntry { + binding, + resource: wgpu::BindingResource::Sampler(sampler.raw()), + }); + return self; + } + /// Build the bind group with the accumulated entries. pub fn build(self, gpu: &Gpu) -> BindGroup { let layout = self diff --git a/crates/lambda-rs-platform/src/wgpu/mod.rs b/crates/lambda-rs-platform/src/wgpu/mod.rs index 7213041c..900f7c20 100644 --- a/crates/lambda-rs-platform/src/wgpu/mod.rs +++ b/crates/lambda-rs-platform/src/wgpu/mod.rs @@ -1,13 +1,11 @@ //! Cross‑platform GPU abstraction built on top of `wgpu`. //! //! This module exposes a small, opinionated wrapper around core `wgpu` types -//! to make engine code concise while keeping configuration explicit. The -//! builders here (for the instance, surface, and device/queue) provide sane -//! defaults and narrow the surface area used by Lambda, without hiding -//! important handles when you need to drop down to raw `wgpu`. - -// keep this module focused on exports and submodules +//! organized into focused submodules (instance, surface, gpu, pipeline, etc.). +//! Higher layers import these modules rather than raw `wgpu` to keep Lambda’s +//! API compact and stable. +// Keep this module focused on exports and submodules only. pub mod bind; pub mod buffer; pub mod command; @@ -16,4 +14,5 @@ pub mod instance; pub mod pipeline; pub mod render_pass; pub mod surface; +pub mod texture; pub mod vertex; diff --git a/crates/lambda-rs-platform/src/wgpu/pipeline.rs b/crates/lambda-rs-platform/src/wgpu/pipeline.rs index fadbe2e7..5a4cfba3 100644 --- a/crates/lambda-rs-platform/src/wgpu/pipeline.rs +++ b/crates/lambda-rs-platform/src/wgpu/pipeline.rs @@ -8,6 +8,7 @@ use crate::wgpu::{ bind, gpu::Gpu, surface::SurfaceFormat, + texture::DepthFormat, vertex::ColorFormat, }; @@ -210,6 +211,7 @@ pub struct RenderPipelineBuilder<'a> { vertex_buffers: Vec<(u64, Vec)>, cull_mode: CullingMode, color_target_format: Option, + depth_stencil: Option, } impl<'a> RenderPipelineBuilder<'a> { @@ -221,6 +223,7 @@ impl<'a> RenderPipelineBuilder<'a> { vertex_buffers: Vec::new(), cull_mode: CullingMode::Back, color_target_format: None, + depth_stencil: None, }; } @@ -258,6 +261,20 @@ impl<'a> RenderPipelineBuilder<'a> { return self; } + /// Enable depth testing/writes using the provided depth format and default compare/write settings. + /// + /// Defaults: compare Less, depth writes enabled, no stencil. + pub fn with_depth_stencil(mut self, format: DepthFormat) -> Self { + self.depth_stencil = Some(wgpu::DepthStencilState { + format: format.to_wgpu(), + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::Less, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }); + return self; + } + /// Build the render pipeline from provided shader modules. pub fn build( self, @@ -333,7 +350,7 @@ impl<'a> RenderPipelineBuilder<'a> { layout: layout_ref, vertex: vertex_state, primitive: primitive_state, - depth_stencil: None, + depth_stencil: self.depth_stencil, multisample: wgpu::MultisampleState::default(), fragment, multiview: None, diff --git a/crates/lambda-rs-platform/src/wgpu/render_pass.rs b/crates/lambda-rs-platform/src/wgpu/render_pass.rs index 71d09b0a..181ab7e7 100644 --- a/crates/lambda-rs-platform/src/wgpu/render_pass.rs +++ b/crates/lambda-rs-platform/src/wgpu/render_pass.rs @@ -54,6 +54,31 @@ impl Default for ColorOperations { } } +/// Depth load operation for a depth attachment. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum DepthLoadOp { + /// Load the existing contents of the depth attachment. + Load, + /// Clear the depth attachment to the provided value in [0,1]. + Clear(f32), +} + +/// Depth operations (load/store) for the depth attachment. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DepthOperations { + pub load: DepthLoadOp, + pub store: StoreOp, +} + +impl Default for DepthOperations { + fn default() -> Self { + return Self { + load: DepthLoadOp::Clear(1.0), + store: StoreOp::Store, + }; + } +} + /// Configuration for beginning a render pass. #[derive(Clone, Debug, Default)] pub struct RenderPassConfig { @@ -255,6 +280,8 @@ impl RenderPassBuilder { return self; } + // Depth attachment is supplied at build time by the caller. + /// Build (begin) the render pass on the provided encoder using the provided /// color attachments list. The attachments list MUST outlive the returned /// render pass value. @@ -262,6 +289,8 @@ impl RenderPassBuilder { &'view self, encoder: &'view mut command::CommandEncoder, attachments: &'view mut RenderColorAttachments<'view>, + depth_view: Option>, + depth_ops: Option, ) -> RenderPass<'view> { let operations = match self.config.color_operations.load { ColorLoadOp::Load => wgpu::Operations { @@ -288,10 +317,35 @@ impl RenderPassBuilder { // Apply operations to all provided attachments. attachments.set_operations_for_all(operations); + // Optional depth attachment + let depth_stencil_attachment = depth_view.map(|v| { + let dop = depth_ops.unwrap_or_default(); + wgpu::RenderPassDepthStencilAttachment { + view: v.raw, + depth_ops: Some(match dop.load { + DepthLoadOp::Load => wgpu::Operations { + load: wgpu::LoadOp::Load, + store: match dop.store { + StoreOp::Store => wgpu::StoreOp::Store, + StoreOp::Discard => wgpu::StoreOp::Discard, + }, + }, + DepthLoadOp::Clear(value) => wgpu::Operations { + load: wgpu::LoadOp::Clear(value), + store: match dop.store { + StoreOp::Store => wgpu::StoreOp::Store, + StoreOp::Discard => wgpu::StoreOp::Discard, + }, + }, + }), + stencil_ops: None, + } + }); + let desc: wgpu::RenderPassDescriptor<'view> = wgpu::RenderPassDescriptor { label: self.config.label.as_deref(), color_attachments: attachments.as_slice(), - depth_stencil_attachment: None, + depth_stencil_attachment, timestamp_writes: None, occlusion_query_set: None, }; diff --git a/crates/lambda-rs-platform/src/wgpu/texture.rs b/crates/lambda-rs-platform/src/wgpu/texture.rs new file mode 100644 index 00000000..b2981631 --- /dev/null +++ b/crates/lambda-rs-platform/src/wgpu/texture.rs @@ -0,0 +1,810 @@ +//! Texture and sampler enums and mappings for the platform layer. +//! +//! This module defines stable enums for texture formats, dimensions, view +//! dimensions, filtering, and addressing. It provides explicit mappings to the +//! underlying `wgpu` types and basic helpers such as bytes‑per‑pixel. + +use wgpu; + +use crate::wgpu::gpu::Gpu; + +#[derive(Debug)] +/// Errors returned when building a texture or preparing its initial upload. +pub enum TextureBuildError { + /// Width or height is zero or exceeds device limits. + InvalidDimensions { width: u32, height: u32 }, + /// Provided data length does not match expected tightly packed size. + DataLengthMismatch { expected: usize, actual: usize }, + /// Internal arithmetic overflow while computing sizes or paddings. + Overflow, +} + +/// Align `value` up to the next multiple of `alignment`. +/// +/// `alignment` must be a power of two. This is used to compute +/// `bytes_per_row` for `Queue::write_texture` (256‑byte requirement). +fn align_up(value: u32, alignment: u32) -> u32 { + let mask = alignment - 1; + return (value + mask) & !mask; +} + +/// Filter function used for sampling. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum FilterMode { + Nearest, + Linear, +} + +impl FilterMode { + pub(crate) fn to_wgpu(self) -> wgpu::FilterMode { + return match self { + FilterMode::Nearest => wgpu::FilterMode::Nearest, + FilterMode::Linear => wgpu::FilterMode::Linear, + }; + } +} + +/// Texture addressing mode when sampling outside the [0,1] range. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum AddressMode { + ClampToEdge, + Repeat, + MirrorRepeat, +} + +impl AddressMode { + pub(crate) fn to_wgpu(self) -> wgpu::AddressMode { + return match self { + AddressMode::ClampToEdge => wgpu::AddressMode::ClampToEdge, + AddressMode::Repeat => wgpu::AddressMode::Repeat, + AddressMode::MirrorRepeat => wgpu::AddressMode::MirrorRepeat, + }; + } +} + +/// Supported color texture formats for sampling. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum TextureFormat { + Rgba8Unorm, + Rgba8UnormSrgb, +} + +impl TextureFormat { + /// Map to the corresponding `wgpu::TextureFormat`. + pub(crate) fn to_wgpu(self) -> wgpu::TextureFormat { + return match self { + TextureFormat::Rgba8Unorm => wgpu::TextureFormat::Rgba8Unorm, + TextureFormat::Rgba8UnormSrgb => wgpu::TextureFormat::Rgba8UnormSrgb, + }; + } + + /// Number of bytes per pixel for tightly packed data. + pub fn bytes_per_pixel(self) -> u32 { + return match self { + TextureFormat::Rgba8Unorm | TextureFormat::Rgba8UnormSrgb => 4, + }; + } +} + +/// Depth/stencil texture formats supported for render attachments. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum DepthFormat { + Depth32Float, + Depth24Plus, + Depth24PlusStencil8, +} + +impl DepthFormat { + pub(crate) fn to_wgpu(self) -> wgpu::TextureFormat { + return match self { + DepthFormat::Depth32Float => wgpu::TextureFormat::Depth32Float, + DepthFormat::Depth24Plus => wgpu::TextureFormat::Depth24Plus, + DepthFormat::Depth24PlusStencil8 => { + wgpu::TextureFormat::Depth24PlusStencil8 + } + }; + } +} + +#[derive(Debug)] +/// Wrapper for a depth (and optional stencil) texture used as a render attachment. +pub struct DepthTexture { + pub(crate) raw: wgpu::Texture, + pub(crate) view: wgpu::TextureView, + pub(crate) label: Option, + pub(crate) format: DepthFormat, +} + +impl DepthTexture { + /// Borrow the underlying `wgpu::Texture`. + pub fn raw(&self) -> &wgpu::Texture { + return &self.raw; + } + + /// Borrow the full‑range `wgpu::TextureView` for depth attachment. + pub fn view(&self) -> &wgpu::TextureView { + return &self.view; + } + + /// The depth format used by this attachment. + pub fn format(&self) -> DepthFormat { + return self.format; + } + + /// Convenience: return a `TextureViewRef` for use in render pass attachments. + pub fn view_ref(&self) -> crate::wgpu::surface::TextureViewRef<'_> { + return crate::wgpu::surface::TextureViewRef { raw: &self.view }; + } +} + +/// Builder for a depth texture attachment sized to the current framebuffer. +pub struct DepthTextureBuilder { + label: Option, + width: u32, + height: u32, + format: DepthFormat, + sample_count: u32, +} + +impl DepthTextureBuilder { + /// Create a builder with no size and `Depth32Float` format. + pub fn new() -> Self { + return Self { + label: None, + width: 0, + height: 0, + format: DepthFormat::Depth32Float, + sample_count: 1, + }; + } + + /// Set the 2D attachment size in pixels. + pub fn with_size(mut self, width: u32, height: u32) -> Self { + self.width = width; + self.height = height; + return self; + } + + /// Choose a depth format. + pub fn with_format(mut self, format: DepthFormat) -> Self { + self.format = format; + return self; + } + + /// Configure multi‑sampling. + pub fn with_sample_count(mut self, count: u32) -> Self { + self.sample_count = count.max(1); + return self; + } + + /// Attach a debug label for the created texture. + pub fn with_label(mut self, label: &str) -> Self { + self.label = Some(label.to_string()); + return self; + } + + /// Create the depth texture on the device. + pub fn build(self, gpu: &Gpu) -> DepthTexture { + let size = wgpu::Extent3d { + width: self.width.max(1), + height: self.height.max(1), + depth_or_array_layers: 1, + }; + let format = self.format.to_wgpu(); + let raw = gpu.device().create_texture(&wgpu::TextureDescriptor { + label: self.label.as_deref(), + size, + mip_level_count: 1, + sample_count: self.sample_count, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let view = raw.create_view(&wgpu::TextureViewDescriptor { + label: None, + format: Some(format), + dimension: Some(wgpu::TextureViewDimension::D2), + aspect: match self.format { + DepthFormat::Depth24PlusStencil8 => wgpu::TextureAspect::All, + _ => wgpu::TextureAspect::DepthOnly, + }, + base_mip_level: 0, + mip_level_count: None, + base_array_layer: 0, + array_layer_count: None, + usage: Some(wgpu::TextureUsages::RENDER_ATTACHMENT), + }); + + return DepthTexture { + raw, + view, + label: self.label, + format: self.format, + }; + } +} + +/// Physical storage dimension of a texture. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum TextureDimension { + TwoDimensional, + ThreeDimensional, +} + +impl TextureDimension { + pub(crate) fn to_wgpu(self) -> wgpu::TextureDimension { + return match self { + TextureDimension::TwoDimensional => wgpu::TextureDimension::D2, + TextureDimension::ThreeDimensional => wgpu::TextureDimension::D3, + }; + } +} + +/// View dimensionality exposed to shaders when sampling. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum ViewDimension { + TwoDimensional, + ThreeDimensional, +} + +impl ViewDimension { + pub(crate) fn to_wgpu(self) -> wgpu::TextureViewDimension { + return match self { + ViewDimension::TwoDimensional => wgpu::TextureViewDimension::D2, + ViewDimension::ThreeDimensional => wgpu::TextureViewDimension::D3, + }; + } +} + +#[derive(Debug)] +/// Wrapper around `wgpu::Sampler` that preserves a label. +pub struct Sampler { + pub(crate) raw: wgpu::Sampler, + pub(crate) label: Option, +} + +impl Sampler { + /// Borrow the underlying `wgpu::Sampler`. + pub fn raw(&self) -> &wgpu::Sampler { + return &self.raw; + } + + /// Optional debug label used during creation. + pub fn label(&self) -> Option<&str> { + return self.label.as_deref(); + } +} + +/// Builder for creating a sampler. +pub struct SamplerBuilder { + label: Option, + min_filter: FilterMode, + mag_filter: FilterMode, + mipmap_filter: FilterMode, + address_u: AddressMode, + address_v: AddressMode, + address_w: AddressMode, + lod_min: f32, + lod_max: f32, +} + +impl SamplerBuilder { + /// Create a new builder with nearest filtering and clamp addressing. + pub fn new() -> Self { + return Self { + label: None, + min_filter: FilterMode::Nearest, + mag_filter: FilterMode::Nearest, + mipmap_filter: FilterMode::Nearest, + address_u: AddressMode::ClampToEdge, + address_v: AddressMode::ClampToEdge, + address_w: AddressMode::ClampToEdge, + lod_min: 0.0, + lod_max: 32.0, + }; + } + + /// Set both min and mag filter to nearest. + pub fn nearest(mut self) -> Self { + self.min_filter = FilterMode::Nearest; + self.mag_filter = FilterMode::Nearest; + return self; + } + + /// Set both min and mag filter to linear. + pub fn linear(mut self) -> Self { + self.min_filter = FilterMode::Linear; + self.mag_filter = FilterMode::Linear; + return self; + } + + /// Convenience: nearest filtering with clamp-to-edge addressing. + pub fn nearest_clamp(mut self) -> Self { + self = self.nearest(); + self.address_u = AddressMode::ClampToEdge; + self.address_v = AddressMode::ClampToEdge; + self.address_w = AddressMode::ClampToEdge; + return self; + } + + /// Convenience: linear filtering with clamp-to-edge addressing. + pub fn linear_clamp(mut self) -> Self { + self = self.linear(); + self.address_u = AddressMode::ClampToEdge; + self.address_v = AddressMode::ClampToEdge; + self.address_w = AddressMode::ClampToEdge; + return self; + } + + /// Set address mode for U (x) coordinate. + pub fn with_address_mode_u(mut self, mode: AddressMode) -> Self { + self.address_u = mode; + return self; + } + + /// Set address mode for V (y) coordinate. + pub fn with_address_mode_v(mut self, mode: AddressMode) -> Self { + self.address_v = mode; + return self; + } + + /// Set address mode for W (z) coordinate. + pub fn with_address_mode_w(mut self, mode: AddressMode) -> Self { + self.address_w = mode; + return self; + } + + /// Set mipmap filtering mode. + pub fn with_mip_filter(mut self, mode: FilterMode) -> Self { + self.mipmap_filter = mode; + return self; + } + + /// Set minimum and maximum level-of-detail clamps. + pub fn with_lod(mut self, min: f32, max: f32) -> Self { + self.lod_min = min; + self.lod_max = max; + return self; + } + + /// Attach a debug label. + pub fn with_label(mut self, label: &str) -> Self { + self.label = Some(label.to_string()); + return self; + } + + fn to_descriptor(&self) -> wgpu::SamplerDescriptor<'_> { + return wgpu::SamplerDescriptor { + label: self.label.as_deref(), + address_mode_u: self.address_u.to_wgpu(), + address_mode_v: self.address_v.to_wgpu(), + address_mode_w: self.address_w.to_wgpu(), + mag_filter: self.mag_filter.to_wgpu(), + min_filter: self.min_filter.to_wgpu(), + mipmap_filter: self.mipmap_filter.to_wgpu(), + lod_min_clamp: self.lod_min, + lod_max_clamp: self.lod_max, + ..Default::default() + }; + } + + /// Create the sampler on the provided device. + pub fn build(self, gpu: &Gpu) -> Sampler { + let desc = self.to_descriptor(); + let raw = gpu.device().create_sampler(&desc); + return Sampler { + raw, + label: self.label, + }; + } +} +#[derive(Debug)] +/// Wrapper around `wgpu::Texture` and its default `TextureView`. +/// +/// The view covers the full resource with a view dimension that matches the +/// texture (D2/D3). The view usage mirrors the texture usage to satisfy wgpu +/// validation rules. +pub struct Texture { + pub(crate) raw: wgpu::Texture, + pub(crate) view: wgpu::TextureView, + pub(crate) label: Option, +} + +impl Texture { + /// Borrow the underlying `wgpu::Texture`. + pub fn raw(&self) -> &wgpu::Texture { + return &self.raw; + } + + /// Borrow the default full‑range `wgpu::TextureView`. + pub fn view(&self) -> &wgpu::TextureView { + return &self.view; + } + + /// Optional debug label used during creation. + pub fn label(&self) -> Option<&str> { + return self.label.as_deref(); + } +} + +/// Builder for creating a sampled texture with optional initial data upload. +/// +/// - 2D path: call `new_2d()` then `with_size(w, h)`. +/// - 3D path: call `new_3d()` then `with_size_3d(w, h, d)`. +/// +/// The `with_data` payload is expected to be tightly packed (no row or image +/// padding). Row padding and `rows_per_image` are computed internally. +pub struct TextureBuilder { + /// Optional debug label propagated to the created texture. + label: Option, + /// Color format for the texture (filterable formats only). + format: TextureFormat, + /// Physical storage dimension (D2/D3). + dimension: TextureDimension, + /// Width in texels. + width: u32, + /// Height in texels. + height: u32, + /// Depth in texels (1 for 2D). + depth: u32, + /// Include `TEXTURE_BINDING` usage. + usage_texture_binding: bool, + /// Include `COPY_DST` usage when uploading initial data. + usage_copy_dst: bool, + /// Optional tightly‑packed pixel payload for level 0 (rows are `width*bpp`). + data: Option>, +} + +impl TextureBuilder { + /// Construct a new 2D texture builder for a color format. + pub fn new_2d(format: TextureFormat) -> Self { + return Self { + label: None, + format, + dimension: TextureDimension::TwoDimensional, + width: 0, + height: 0, + depth: 1, + usage_texture_binding: true, + usage_copy_dst: true, + data: None, + }; + } + + /// Construct a new 3D texture builder for a color format. + pub fn new_3d(format: TextureFormat) -> Self { + return Self { + label: None, + format, + dimension: TextureDimension::ThreeDimensional, + width: 0, + height: 0, + depth: 0, + usage_texture_binding: true, + usage_copy_dst: true, + data: None, + }; + } + + /// Set the 2D texture size in pixels. + pub fn with_size(mut self, width: u32, height: u32) -> Self { + self.width = width; + self.height = height; + self.depth = 1; + return self; + } + + /// Set the 3D texture size in voxels. + pub fn with_size_3d(mut self, width: u32, height: u32, depth: u32) -> Self { + self.width = width; + self.height = height; + self.depth = depth; + return self; + } + + /// Provide tightly packed pixel data for level 0 upload. + pub fn with_data(mut self, pixels: &[u8]) -> Self { + self.data = Some(pixels.to_vec()); + return self; + } + + /// Control usage flags. Defaults are suitable for sampling with initial upload. + pub fn with_usage(mut self, texture_binding: bool, copy_dst: bool) -> Self { + self.usage_texture_binding = texture_binding; + self.usage_copy_dst = copy_dst; + return self; + } + + /// Attach a debug label. + pub fn with_label(mut self, label: &str) -> Self { + self.label = Some(label.to_string()); + return self; + } + + /// Create the GPU texture and upload initial data if provided. + pub fn build(self, gpu: &Gpu) -> Result { + // Validate dimensions + if self.width == 0 || self.height == 0 { + return Err(TextureBuildError::InvalidDimensions { + width: self.width, + height: self.height, + }); + } + if let TextureDimension::ThreeDimensional = self.dimension { + if self.depth == 0 { + return Err(TextureBuildError::InvalidDimensions { + width: self.width, + height: self.height, + }); + } + } + + let size = wgpu::Extent3d { + width: self.width, + height: self.height, + depth_or_array_layers: match self.dimension { + TextureDimension::TwoDimensional => 1, + TextureDimension::ThreeDimensional => self.depth, + }, + }; + + // Validate data length if provided + if let Some(ref pixels) = self.data { + let bpp = self.format.bytes_per_pixel() as usize; + let wh = (self.width as usize) + .checked_mul(self.height as usize) + .ok_or(TextureBuildError::Overflow)?; + let expected = match self.dimension { + TextureDimension::TwoDimensional => { + wh.checked_mul(bpp).ok_or(TextureBuildError::Overflow)? + } + TextureDimension::ThreeDimensional => wh + .checked_mul(self.depth as usize) + .and_then(|n| n.checked_mul(bpp)) + .ok_or(TextureBuildError::Overflow)?, + }; + if pixels.len() != expected { + return Err(TextureBuildError::DataLengthMismatch { + expected, + actual: pixels.len(), + }); + } + } + + // Resolve usage flags + let mut usage = wgpu::TextureUsages::empty(); + if self.usage_texture_binding { + usage |= wgpu::TextureUsages::TEXTURE_BINDING; + } + if self.usage_copy_dst { + usage |= wgpu::TextureUsages::COPY_DST; + } + + let descriptor = wgpu::TextureDescriptor { + label: self.label.as_deref(), + size, + mip_level_count: 1, + sample_count: 1, + dimension: self.dimension.to_wgpu(), + format: self.format.to_wgpu(), + usage, + view_formats: &[], + }; + + let texture = gpu.device().create_texture(&descriptor); + let view_dimension = match self.dimension { + TextureDimension::TwoDimensional => wgpu::TextureViewDimension::D2, + TextureDimension::ThreeDimensional => wgpu::TextureViewDimension::D3, + }; + let view = texture.create_view(&wgpu::TextureViewDescriptor { + label: None, + format: None, + dimension: Some(view_dimension), + aspect: wgpu::TextureAspect::All, + base_mip_level: 0, + mip_level_count: None, + base_array_layer: 0, + array_layer_count: None, + usage: Some(usage), + }); + + if let Some(pixels) = self.data.as_ref() { + // Compute 256-byte aligned bytes_per_row and pad rows if necessary. + let bpp = self.format.bytes_per_pixel(); + let row_bytes = self + .width + .checked_mul(bpp) + .ok_or(TextureBuildError::Overflow)?; + let padded_row_bytes = align_up(row_bytes, 256); + + // Prepare a staging buffer with zeroed padding between rows (and images). + let images = match self.dimension { + TextureDimension::TwoDimensional => 1, + TextureDimension::ThreeDimensional => self.depth, + } as u64; + let total_bytes = (padded_row_bytes as u64) + .checked_mul(self.height as u64) + .and_then(|n| n.checked_mul(images)) + .ok_or(TextureBuildError::Overflow)? as usize; + let mut staging = vec![0u8; total_bytes]; + + let src_row_stride = row_bytes as usize; + let dst_row_stride = padded_row_bytes as usize; + match self.dimension { + TextureDimension::TwoDimensional => { + for row in 0..(self.height as usize) { + let src_off = row + .checked_mul(src_row_stride) + .ok_or(TextureBuildError::Overflow)?; + let dst_off = row + .checked_mul(dst_row_stride) + .ok_or(TextureBuildError::Overflow)?; + staging[dst_off..(dst_off + src_row_stride)] + .copy_from_slice(&pixels[src_off..(src_off + src_row_stride)]); + } + } + TextureDimension::ThreeDimensional => { + let slice_stride = (self.height as usize) + .checked_mul(src_row_stride) + .ok_or(TextureBuildError::Overflow)?; + let dst_image_stride = (self.height as usize) + .checked_mul(dst_row_stride) + .ok_or(TextureBuildError::Overflow)?; + for z in 0..(self.depth as usize) { + for y in 0..(self.height as usize) { + let z_base_src = z + .checked_mul(slice_stride) + .ok_or(TextureBuildError::Overflow)?; + let y_off_src = y + .checked_mul(src_row_stride) + .ok_or(TextureBuildError::Overflow)?; + let src_off = z_base_src + .checked_add(y_off_src) + .ok_or(TextureBuildError::Overflow)?; + + let z_base_dst = z + .checked_mul(dst_image_stride) + .ok_or(TextureBuildError::Overflow)?; + let y_off_dst = y + .checked_mul(dst_row_stride) + .ok_or(TextureBuildError::Overflow)?; + let dst_off = z_base_dst + .checked_add(y_off_dst) + .ok_or(TextureBuildError::Overflow)?; + + staging[dst_off..(dst_off + src_row_stride)] + .copy_from_slice(&pixels[src_off..(src_off + src_row_stride)]); + } + } + } + } + + let data_layout = wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded_row_bytes), + rows_per_image: Some(self.height), + }; + + let copy_dst = wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d { x: 0, y: 0, z: 0 }, + aspect: wgpu::TextureAspect::All, + }; + + gpu + .queue() + .write_texture(copy_dst, &staging, data_layout, size); + } + + return Ok(Texture { + raw: texture, + view, + label: self.label, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filter_mode_maps() { + assert_eq!(FilterMode::Nearest.to_wgpu(), wgpu::FilterMode::Nearest); + assert_eq!(FilterMode::Linear.to_wgpu(), wgpu::FilterMode::Linear); + } + + #[test] + fn address_mode_maps() { + assert_eq!( + AddressMode::ClampToEdge.to_wgpu(), + wgpu::AddressMode::ClampToEdge + ); + assert_eq!(AddressMode::Repeat.to_wgpu(), wgpu::AddressMode::Repeat); + assert_eq!( + AddressMode::MirrorRepeat.to_wgpu(), + wgpu::AddressMode::MirrorRepeat + ); + } + + #[test] + fn texture_format_maps() { + assert_eq!( + TextureFormat::Rgba8Unorm.to_wgpu(), + wgpu::TextureFormat::Rgba8Unorm + ); + assert_eq!( + TextureFormat::Rgba8UnormSrgb.to_wgpu(), + wgpu::TextureFormat::Rgba8UnormSrgb + ); + } + + #[test] + fn bytes_per_pixel_is_correct() { + assert_eq!(TextureFormat::Rgba8Unorm.bytes_per_pixel(), 4); + assert_eq!(TextureFormat::Rgba8UnormSrgb.bytes_per_pixel(), 4); + } + + #[test] + fn dimensions_map() { + assert_eq!( + TextureDimension::TwoDimensional.to_wgpu(), + wgpu::TextureDimension::D2 + ); + assert_eq!( + TextureDimension::ThreeDimensional.to_wgpu(), + wgpu::TextureDimension::D3 + ); + } + + #[test] + fn view_dimensions_map() { + assert_eq!( + ViewDimension::TwoDimensional.to_wgpu(), + wgpu::TextureViewDimension::D2 + ); + assert_eq!( + ViewDimension::ThreeDimensional.to_wgpu(), + wgpu::TextureViewDimension::D3 + ); + } + + #[test] + fn align_up_computes_expected_values() { + assert_eq!(super::align_up(0, 256), 0); + assert_eq!(super::align_up(1, 256), 256); + assert_eq!(super::align_up(255, 256), 256); + assert_eq!(super::align_up(256, 256), 256); + assert_eq!(super::align_up(300, 256), 512); + } + + #[test] + fn sampler_builder_defaults_map() { + let b = SamplerBuilder::new(); + let d = b.to_descriptor(); + assert_eq!(d.address_mode_u, wgpu::AddressMode::ClampToEdge); + assert_eq!(d.address_mode_v, wgpu::AddressMode::ClampToEdge); + assert_eq!(d.address_mode_w, wgpu::AddressMode::ClampToEdge); + assert_eq!(d.mag_filter, wgpu::FilterMode::Nearest); + assert_eq!(d.min_filter, wgpu::FilterMode::Nearest); + assert_eq!(d.mipmap_filter, wgpu::FilterMode::Nearest); + assert_eq!(d.lod_min_clamp, 0.0); + assert_eq!(d.lod_max_clamp, 32.0); + } + + #[test] + fn sampler_builder_linear_clamp_map() { + let b = SamplerBuilder::new() + .linear_clamp() + .with_mip_filter(FilterMode::Linear); + let d = b.to_descriptor(); + assert_eq!(d.address_mode_u, wgpu::AddressMode::ClampToEdge); + assert_eq!(d.address_mode_v, wgpu::AddressMode::ClampToEdge); + assert_eq!(d.address_mode_w, wgpu::AddressMode::ClampToEdge); + assert_eq!(d.mag_filter, wgpu::FilterMode::Linear); + assert_eq!(d.min_filter, wgpu::FilterMode::Linear); + assert_eq!(d.mipmap_filter, wgpu::FilterMode::Linear); + } +} diff --git a/crates/lambda-rs-platform/tests/wgpu_bind_layout_and_group_texture_sampler.rs b/crates/lambda-rs-platform/tests/wgpu_bind_layout_and_group_texture_sampler.rs new file mode 100644 index 00000000..3339c751 --- /dev/null +++ b/crates/lambda-rs-platform/tests/wgpu_bind_layout_and_group_texture_sampler.rs @@ -0,0 +1,48 @@ +#![allow(clippy::needless_return)] + +// Integration tests for `lambda-rs-platform::wgpu::bind` with textures/samplers + +fn create_test_device() -> lambda_platform::wgpu::gpu::Gpu { + let instance = lambda_platform::wgpu::instance::InstanceBuilder::new() + .with_label("platform-bind-itest") + .build(); + return lambda_platform::wgpu::gpu::GpuBuilder::new() + .with_label("platform-bind-itest-device") + .build(&instance, None) + .expect("create offscreen device"); +} + +#[test] +fn wgpu_bind_layout_and_group_texture_sampler() { + let gpu = create_test_device(); + + let (w, h) = (4u32, 4u32); + let pixels = vec![255u8; (w * h * 4) as usize]; + let texture = lambda_platform::wgpu::texture::TextureBuilder::new_2d( + lambda_platform::wgpu::texture::TextureFormat::Rgba8Unorm, + ) + .with_size(w, h) + .with_data(&pixels) + .with_label("p-itest-bind-texture") + .build(&gpu) + .expect("texture created"); + + let sampler = lambda_platform::wgpu::texture::SamplerBuilder::new() + .nearest_clamp() + .with_label("p-itest-bind-sampler") + .build(&gpu); + + let layout = lambda_platform::wgpu::bind::BindGroupLayoutBuilder::new() + .with_sampled_texture_2d( + 1, + lambda_platform::wgpu::bind::Visibility::Fragment, + ) + .with_sampler(2, lambda_platform::wgpu::bind::Visibility::Fragment) + .build(&gpu); + + let _group = lambda_platform::wgpu::bind::BindGroupBuilder::new() + .with_layout(&layout) + .with_texture(1, &texture) + .with_sampler(2, &sampler) + .build(&gpu); +} diff --git a/crates/lambda-rs-platform/tests/wgpu_bind_layout_dim3_and_group.rs b/crates/lambda-rs-platform/tests/wgpu_bind_layout_dim3_and_group.rs new file mode 100644 index 00000000..63bdff27 --- /dev/null +++ b/crates/lambda-rs-platform/tests/wgpu_bind_layout_dim3_and_group.rs @@ -0,0 +1,44 @@ +#![allow(clippy::needless_return)] + +// Bind group layout and group test for 3D texture dimension + +#[test] +fn wgpu_bind_layout_dim3_and_group() { + let instance = lambda_platform::wgpu::instance::InstanceBuilder::new() + .with_label("p-itest-3d-bind") + .build(); + let gpu = lambda_platform::wgpu::gpu::GpuBuilder::new() + .with_label("p-itest-3d-bind-device") + .build(&instance, None) + .expect("create device"); + + let (w, h, d) = (2u32, 2u32, 2u32); + let pixels = vec![255u8; (w * h * d * 4) as usize]; + let tex3d = lambda_platform::wgpu::texture::TextureBuilder::new_3d( + lambda_platform::wgpu::texture::TextureFormat::Rgba8Unorm, + ) + .with_size_3d(w, h, d) + .with_data(&pixels) + .with_label("p-itest-3d-view") + .build(&gpu) + .expect("3D texture build"); + + let sampler = lambda_platform::wgpu::texture::SamplerBuilder::new() + .nearest_clamp() + .build(&gpu); + + let layout = lambda_platform::wgpu::bind::BindGroupLayoutBuilder::new() + .with_sampled_texture_dim( + 1, + lambda_platform::wgpu::bind::Visibility::Fragment, + lambda_platform::wgpu::texture::ViewDimension::ThreeDimensional, + ) + .with_sampler(2, lambda_platform::wgpu::bind::Visibility::Fragment) + .build(&gpu); + + let _group = lambda_platform::wgpu::bind::BindGroupBuilder::new() + .with_layout(&layout) + .with_texture(1, &tex3d) + .with_sampler(2, &sampler) + .build(&gpu); +} diff --git a/crates/lambda-rs-platform/tests/wgpu_texture3d_build_and_upload.rs b/crates/lambda-rs-platform/tests/wgpu_texture3d_build_and_upload.rs new file mode 100644 index 00000000..4d19bec5 --- /dev/null +++ b/crates/lambda-rs-platform/tests/wgpu_texture3d_build_and_upload.rs @@ -0,0 +1,26 @@ +#![allow(clippy::needless_return)] + +// Integration tests for 3D textures in the platform layer + +#[test] +fn wgpu_texture3d_build_and_upload() { + let instance = lambda_platform::wgpu::instance::InstanceBuilder::new() + .with_label("p-itest-3d") + .build(); + let gpu = lambda_platform::wgpu::gpu::GpuBuilder::new() + .with_label("p-itest-3d-device") + .build(&instance, None) + .expect("create device"); + + let (w, h, d) = (4u32, 4u32, 3u32); + let pixels = vec![180u8; (w * h * d * 4) as usize]; + + let _tex3d = lambda_platform::wgpu::texture::TextureBuilder::new_3d( + lambda_platform::wgpu::texture::TextureFormat::Rgba8Unorm, + ) + .with_size_3d(w, h, d) + .with_data(&pixels) + .with_label("p-itest-3d-texture") + .build(&gpu) + .expect("3D texture build"); +} diff --git a/crates/lambda-rs-platform/tests/wgpu_texture_build_and_upload.rs b/crates/lambda-rs-platform/tests/wgpu_texture_build_and_upload.rs new file mode 100644 index 00000000..259197f6 --- /dev/null +++ b/crates/lambda-rs-platform/tests/wgpu_texture_build_and_upload.rs @@ -0,0 +1,56 @@ +#![allow(clippy::needless_return)] + +// Integration tests for `lambda-rs-platform::wgpu::texture` + +fn create_test_device() -> lambda_platform::wgpu::gpu::Gpu { + let instance = lambda_platform::wgpu::instance::InstanceBuilder::new() + .with_label("platform-itest") + .build(); + return lambda_platform::wgpu::gpu::GpuBuilder::new() + .with_label("platform-itest-device") + .build(&instance, None) + .expect("create offscreen device"); +} + +#[test] +fn wgpu_texture_build_and_upload_succeeds() { + let gpu = create_test_device(); + + let (w, h) = (8u32, 8u32); + let mut pixels = vec![0u8; (w * h * 4) as usize]; + for y in 0..h { + for x in 0..w { + let idx = ((y * w + x) * 4) as usize; + let c = if ((x + y) % 2) == 0 { 255 } else { 0 }; + pixels[idx + 0] = c; + pixels[idx + 1] = c; + pixels[idx + 2] = c; + pixels[idx + 3] = 255; + } + } + + let _texture = lambda_platform::wgpu::texture::TextureBuilder::new_2d( + lambda_platform::wgpu::texture::TextureFormat::Rgba8UnormSrgb, + ) + .with_size(w, h) + .with_data(&pixels) + .with_label("p-itest-texture") + .build(&gpu) + .expect("texture created"); +} + +#[test] +fn wgpu_texture_upload_with_padding_bytes_per_row() { + let gpu = create_test_device(); + + let (w, h) = (13u32, 7u32); + let pixels = vec![128u8; (w * h * 4) as usize]; + let _ = lambda_platform::wgpu::texture::TextureBuilder::new_2d( + lambda_platform::wgpu::texture::TextureFormat::Rgba8Unorm, + ) + .with_size(w, h) + .with_data(&pixels) + .with_label("p-itest-pad") + .build(&gpu) + .expect("padded write_texture works"); +} diff --git a/crates/lambda-rs/examples/textured_cube.rs b/crates/lambda-rs/examples/textured_cube.rs new file mode 100644 index 00000000..4822da42 --- /dev/null +++ b/crates/lambda-rs/examples/textured_cube.rs @@ -0,0 +1,509 @@ +#![allow(clippy::needless_return)] + +//! Example: Spinning 3D cube sampled with a 3D texture. +//! - Uses MVP push constants (vertex stage) for classic camera + rotation. +//! - Colors come from a 2D checkerboard texture sampled in the fragment +//! shader. Each face projects model-space coordinates to UVs. + +use lambda::{ + component::Component, + logging, + math::matrix::Matrix, + render::{ + bind::{ + BindGroupBuilder, + BindGroupLayoutBuilder, + }, + buffer::BufferBuilder, + command::RenderCommand, + mesh::{ + Mesh, + MeshBuilder, + }, + pipeline::{ + PipelineStage, + RenderPipelineBuilder, + }, + render_pass::RenderPassBuilder, + scene_math::{ + compute_perspective_projection, + compute_view_matrix, + SimpleCamera, + }, + shader::{ + Shader, + ShaderBuilder, + ShaderKind, + VirtualShader, + }, + texture::{ + SamplerBuilder, + TextureBuilder, + TextureFormat, + }, + vertex::{ + ColorFormat, + Vertex, + VertexAttribute, + VertexBuilder, + VertexElement, + }, + viewport::ViewportBuilder, + ResourceId, + }, + runtime::start_runtime, + runtimes::{ + application::ComponentResult, + ApplicationRuntime, + ApplicationRuntimeBuilder, + }, +}; + +// ------------------------------ SHADER SOURCE -------------------------------- + +const VERTEX_SHADER_SOURCE: &str = r#" +#version 450 + +layout (location = 0) in vec3 vertex_position; +layout (location = 1) in vec3 vertex_normal; +layout (location = 2) in vec3 vertex_color; // unused + +layout (location = 0) out vec3 v_model_pos; +layout (location = 1) out vec3 v_model_normal; +layout (location = 2) out vec3 v_world_normal; + +layout ( push_constant ) uniform Push { + mat4 mvp; + mat4 model; +} pc; + +void main() { + gl_Position = pc.mvp * vec4(vertex_position, 1.0); + v_model_pos = vertex_position; + v_model_normal = vertex_normal; + // Rotate normals into world space using the model matrix (no scale/shear). + v_world_normal = mat3(pc.model) * vertex_normal; +} + +"#; + +const FRAGMENT_SHADER_SOURCE: &str = r#" +#version 450 + +layout (location = 0) in vec3 v_model_pos; +layout (location = 1) in vec3 v_model_normal; +layout (location = 2) in vec3 v_world_normal; + +layout (location = 0) out vec4 fragment_color; + +layout (set = 0, binding = 1) uniform texture2D tex; +layout (set = 0, binding = 2) uniform sampler samp; + +// Project model-space position to 2D UVs based on the dominant normal axis. +vec2 project_uv(vec3 p, vec3 n) { + vec3 a = abs(n); + if (a.x > a.y && a.x > a.z) { + // +/-X faces: map Z,Y + return p.zy * 0.5 + 0.5; + } else if (a.y > a.z) { + // +/-Y faces: map X,Z + return p.xz * 0.5 + 0.5; + } else { + // +/-Z faces: map X,Y + return p.xy * 0.5 + 0.5; + } +} + +void main() { + // Sample color from 2D checkerboard using projected UVs in [0,1] + vec3 N_model = normalize(v_model_normal); + vec2 uv = clamp(project_uv(v_model_pos, N_model), 0.0, 1.0); + vec3 base = texture(sampler2D(tex, samp), uv).rgb; + + // Simple lambert lighting to emphasize shape + vec3 N = normalize(v_world_normal); + vec3 L = normalize(vec3(0.4, 0.7, 1.0)); + float diff = max(dot(N, L), 0.0); + vec3 color = base * (0.25 + 0.75 * diff); + fragment_color = vec4(color, 1.0); +} + +"#; + +// ------------------------------ PUSH CONSTANTS ------------------------------- + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct PushConstant { + mvp: [[f32; 4]; 4], + model: [[f32; 4]; 4], +} + +pub fn push_constants_to_bytes(push_constants: &PushConstant) -> &[u32] { + unsafe { + let size_in_bytes = std::mem::size_of::(); + let size_in_u32 = size_in_bytes / std::mem::size_of::(); + let ptr = push_constants as *const PushConstant as *const u32; + std::slice::from_raw_parts(ptr, size_in_u32) + } +} + +// --------------------------------- COMPONENT --------------------------------- + +pub struct TexturedCubeExample { + shader_vs: Shader, + shader_fs: Shader, + mesh: Option, + render_pipeline: Option, + render_pass: Option, + bind_group: Option, + width: u32, + height: u32, + elapsed: f32, +} + +impl Component for TexturedCubeExample { + fn on_attach( + &mut self, + render_context: &mut lambda::render::RenderContext, + ) -> Result { + logging::info!("Attaching TexturedCubeExample"); + // Render pass and shaders + let render_pass = RenderPassBuilder::new() + .with_label("textured-cube-pass") + .with_depth() + .build(render_context); + + let mut shader_builder = ShaderBuilder::new(); + let shader_vs = shader_builder.build(VirtualShader::Source { + source: VERTEX_SHADER_SOURCE.to_string(), + kind: ShaderKind::Vertex, + entry_point: "main".to_string(), + name: "textured-cube".to_string(), + }); + let shader_fs = shader_builder.build(VirtualShader::Source { + source: FRAGMENT_SHADER_SOURCE.to_string(), + kind: ShaderKind::Fragment, + entry_point: "main".to_string(), + name: "textured-cube".to_string(), + }); + + // Build a unit cube centered at origin (6 faces × 2 tris × 3 verts) + let mut verts: Vec = Vec::new(); + let mut add_face = + |nx: f32, ny: f32, nz: f32, corners: [(f32, f32, f32); 4]| { + // corners are 4 corners in CCW order on that face, coords in [-0.5,0.5] + let n = [nx, ny, nz]; + let v = |p: (f32, f32, f32)| { + VertexBuilder::new() + .with_position([p.0, p.1, p.2]) + .with_normal(n) + .with_color([0.0, 0.0, 0.0]) + .build() + }; + // Two triangles: (0,1,2) and (0,2,3) + let p0 = v(corners[0]); + let p1 = v(corners[1]); + let p2 = v(corners[2]); + let p3 = v(corners[3]); + verts.push(p0); + verts.push(p1); + verts.push(p2); + verts.push(p0); + verts.push(p2); + verts.push(p3); + }; + let h = 0.5f32; + // +X (corrected CCW winding) + add_face( + 1.0, + 0.0, + 0.0, + [(h, -h, -h), (h, h, -h), (h, h, h), (h, -h, h)], + ); + // -X (corrected CCW winding) + add_face( + -1.0, + 0.0, + 0.0, + [(-h, -h, -h), (-h, -h, h), (-h, h, h), (-h, h, -h)], + ); + // +Y (top) — CCW when viewed from +Y + add_face( + 0.0, + 1.0, + 0.0, + [(-h, h, h), (h, h, h), (h, h, -h), (-h, h, -h)], + ); + // -Y (bottom) — CCW when viewed from -Y + add_face( + 0.0, + -1.0, + 0.0, + [(-h, -h, -h), (h, -h, -h), (h, -h, h), (-h, -h, h)], + ); + // +Z + add_face( + 0.0, + 0.0, + 1.0, + [(-h, -h, h), (h, -h, h), (h, h, h), (-h, h, h)], + ); + // -Z + add_face( + 0.0, + 0.0, + -1.0, + [(h, -h, -h), (-h, -h, -h), (-h, h, -h), (h, h, -h)], + ); + + let mut mesh_builder = MeshBuilder::new(); + for v in verts.into_iter() { + mesh_builder.with_vertex(v); + } + let mesh = mesh_builder + .with_attributes(vec![ + VertexAttribute { + location: 0, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 0, + }, + }, + VertexAttribute { + location: 1, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 12, + }, + }, + VertexAttribute { + location: 2, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 24, + }, + }, + ]) + .build(); + + // 2D checkerboard texture used on all faces + let tex_w = 64u32; + let tex_h = 64u32; + let mut pixels = vec![0u8; (tex_w * tex_h * 4) as usize]; + for y in 0..tex_h { + for x in 0..tex_w { + let i = ((y * tex_w + x) * 4) as usize; + let checker = ((x / 8) % 2) ^ ((y / 8) % 2); + let c: u8 = if checker == 0 { 40 } else { 220 }; + pixels[i + 0] = c; + pixels[i + 1] = c; + pixels[i + 2] = c; + pixels[i + 3] = 255; + } + } + + let texture2d = TextureBuilder::new_2d(TextureFormat::Rgba8UnormSrgb) + .with_size(tex_w, tex_h) + .with_data(&pixels) + .with_label("checkerboard") + .build(render_context) + .expect("Failed to create 2D texture"); + let sampler = SamplerBuilder::new().linear_clamp().build(render_context); + + let layout = BindGroupLayoutBuilder::new() + .with_sampled_texture(1) + .with_sampler(2) + .build(render_context); + let bind_group = BindGroupBuilder::new() + .with_layout(&layout) + .with_texture(1, &texture2d) + .with_sampler(2, &sampler) + .build(render_context); + + let push_constants_size = std::mem::size_of::() as u32; + let pipeline = RenderPipelineBuilder::new() + .with_culling(lambda::render::pipeline::CullingMode::Back) + .with_depth() + .with_push_constant(PipelineStage::VERTEX, push_constants_size) + .with_buffer( + BufferBuilder::build_from_mesh(&mesh, render_context) + .expect("Failed to create vertex buffer"), + mesh.attributes().to_vec(), + ) + .with_layouts(&[&layout]) + .build(render_context, &render_pass, &shader_vs, Some(&shader_fs)); + + self.render_pass = Some(render_context.attach_render_pass(render_pass)); + self.render_pipeline = Some(render_context.attach_pipeline(pipeline)); + self.bind_group = Some(render_context.attach_bind_group(bind_group)); + self.mesh = Some(mesh); + self.shader_vs = shader_vs; + self.shader_fs = shader_fs; + + return Ok(ComponentResult::Success); + } + + fn on_detach( + &mut self, + _render_context: &mut lambda::render::RenderContext, + ) -> Result { + return Ok(ComponentResult::Success); + } + + fn on_event( + &mut self, + event: lambda::events::Events, + ) -> Result { + match event { + lambda::events::Events::Window { event, .. } => match event { + lambda::events::WindowEvent::Resize { width, height } => { + self.width = width; + self.height = height; + } + _ => {} + }, + _ => {} + } + return Ok(ComponentResult::Success); + } + + fn on_update( + &mut self, + last_frame: &std::time::Duration, + ) -> Result { + self.elapsed += last_frame.as_secs_f32(); + return Ok(ComponentResult::Success); + } + + fn on_render( + &mut self, + _render_context: &mut lambda::render::RenderContext, + ) -> Vec { + // Camera and rotation + let camera = SimpleCamera { + position: [0.0, 0.0, 2.2], + field_of_view_in_turns: 0.24, + near_clipping_plane: 0.1, + far_clipping_plane: 100.0, + }; + // Rotate around both X and Y over time so top/bottom/side faces become visible. + let angle_y_turns = 0.15 * self.elapsed; // yaw + let angle_x_turns = 0.10 * self.elapsed; // pitch + + // Build model: combine yaw (Y) and pitch (X). The multiplication order in + // rotate_matrix composes as model = model * R(axis), so the last call here + // (X) applies first to the vertex, then Y. + let mut model: [[f32; 4]; 4] = lambda::math::matrix::identity_matrix(4, 4); + model = lambda::math::matrix::rotate_matrix( + model, + [0.0, 1.0, 0.0], + angle_y_turns, + ); + model = lambda::math::matrix::rotate_matrix( + model, + [1.0, 0.0, 0.0], + angle_x_turns, + ); + + let view = compute_view_matrix(camera.position); + let projection = compute_perspective_projection( + camera.field_of_view_in_turns, + self.width.max(1), + self.height.max(1), + camera.near_clipping_plane, + camera.far_clipping_plane, + ); + let mvp = projection.multiply(&view).multiply(&model); + + let viewport = ViewportBuilder::new().build(self.width, self.height); + let pipeline = self.render_pipeline.expect("pipeline not set"); + let group = self.bind_group.expect("bind group not set"); + let mesh_len = self.mesh.as_ref().unwrap().vertices().len() as u32; + + return vec![ + RenderCommand::BeginRenderPass { + render_pass: self.render_pass.expect("render pass not set"), + viewport: viewport.clone(), + }, + RenderCommand::SetPipeline { pipeline }, + RenderCommand::SetViewports { + start_at: 0, + viewports: vec![viewport.clone()], + }, + RenderCommand::SetScissors { + start_at: 0, + viewports: vec![viewport.clone()], + }, + RenderCommand::SetBindGroup { + set: 0, + group, + dynamic_offsets: vec![], + }, + RenderCommand::BindVertexBuffer { + pipeline, + buffer: 0, + }, + RenderCommand::PushConstants { + pipeline, + stage: PipelineStage::VERTEX, + offset: 0, + bytes: Vec::from(push_constants_to_bytes(&PushConstant { + mvp: mvp.transpose(), + model: model.transpose(), + })), + }, + RenderCommand::Draw { + vertices: 0..mesh_len, + }, + RenderCommand::EndRenderPass, + ]; + } +} + +impl Default for TexturedCubeExample { + fn default() -> Self { + let mut shader_builder = ShaderBuilder::new(); + let shader_vs = shader_builder.build(VirtualShader::Source { + source: VERTEX_SHADER_SOURCE.to_string(), + kind: ShaderKind::Vertex, + entry_point: "main".to_string(), + name: "textured-cube".to_string(), + }); + let shader_fs = shader_builder.build(VirtualShader::Source { + source: FRAGMENT_SHADER_SOURCE.to_string(), + kind: ShaderKind::Fragment, + entry_point: "main".to_string(), + name: "textured-cube".to_string(), + }); + + return Self { + shader_vs, + shader_fs, + mesh: None, + render_pipeline: None, + render_pass: None, + bind_group: None, + width: 800, + height: 600, + elapsed: 0.0, + }; + } +} + +fn main() { + let runtime: ApplicationRuntime = + ApplicationRuntimeBuilder::new("Textured Cube Example") + .with_window_configured_as(|builder| { + builder.with_dimensions(800, 600).with_name("Textured Cube") + }) + .with_component(|runtime, example: TexturedCubeExample| { + (runtime, example) + }) + .build(); + + start_runtime(runtime); +} diff --git a/crates/lambda-rs/examples/textured_quad.rs b/crates/lambda-rs/examples/textured_quad.rs new file mode 100644 index 00000000..1049a1b9 --- /dev/null +++ b/crates/lambda-rs/examples/textured_quad.rs @@ -0,0 +1,355 @@ +#![allow(clippy::needless_return)] + +//! Example: Draw a textured quad using a sampled 2D texture and sampler. + +use lambda::{ + component::Component, + events::Events, + logging, + render::{ + bind::{ + BindGroupBuilder, + BindGroupLayoutBuilder, + }, + buffer::BufferBuilder, + command::RenderCommand, + mesh::{ + Mesh, + MeshBuilder, + }, + pipeline::{ + CullingMode, + RenderPipelineBuilder, + }, + render_pass::RenderPassBuilder, + shader::{ + Shader, + ShaderBuilder, + ShaderKind, + VirtualShader, + }, + texture::{ + SamplerBuilder, + TextureBuilder, + TextureFormat, + }, + vertex::{ + ColorFormat, + Vertex, + VertexAttribute, + VertexBuilder, + VertexElement, + }, + viewport::ViewportBuilder, + ResourceId, + }, + runtime::start_runtime, + runtimes::{ + application::ComponentResult, + ApplicationRuntime, + ApplicationRuntimeBuilder, + }, +}; + +// ------------------------------ SHADER SOURCE -------------------------------- + +const VERTEX_SHADER_SOURCE: &str = r#" +#version 450 + +layout (location = 0) in vec3 vertex_position; +layout (location = 2) in vec3 vertex_color; // uv packed into .xy + +layout (location = 0) out vec2 v_uv; + +void main() { + gl_Position = vec4(vertex_position, 1.0); + v_uv = vertex_color.xy; +} + +"#; + +const FRAGMENT_SHADER_SOURCE: &str = r#" +#version 450 + +layout (location = 0) in vec2 v_uv; +layout (location = 0) out vec4 fragment_color; + +layout (set = 0, binding = 1) uniform texture2D tex; +layout (set = 0, binding = 2) uniform sampler samp; + +void main() { + fragment_color = texture(sampler2D(tex, samp), v_uv); +} + +"#; + +// --------------------------------- COMPONENT --------------------------------- + +pub struct TexturedQuadExample { + shader_vs: Shader, + shader_fs: Shader, + mesh: Option, + render_pipeline: Option, + render_pass: Option, + bind_group: Option, + width: u32, + height: u32, +} + +impl Component for TexturedQuadExample { + fn on_attach( + &mut self, + render_context: &mut lambda::render::RenderContext, + ) -> Result { + logging::info!("Attaching TexturedQuadExample"); + + // Build render pass and shaders + let render_pass = RenderPassBuilder::new() + .with_label("textured-quad-pass") + .build(render_context); + + let mut shader_builder = ShaderBuilder::new(); + let shader_vs = shader_builder.build(VirtualShader::Source { + source: VERTEX_SHADER_SOURCE.to_string(), + kind: ShaderKind::Vertex, + entry_point: "main".to_string(), + name: "textured-quad".to_string(), + }); + let shader_fs = shader_builder.build(VirtualShader::Source { + source: FRAGMENT_SHADER_SOURCE.to_string(), + kind: ShaderKind::Fragment, + entry_point: "main".to_string(), + name: "textured-quad".to_string(), + }); + + // Quad vertices (two triangles), uv packed into vertex.color.xy + let vertices: [Vertex; 6] = [ + VertexBuilder::new() + .with_position([-0.5, -0.5, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([0.0, 0.0, 0.0]) + .build(), // uv (0,0) + VertexBuilder::new() + .with_position([0.5, -0.5, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([1.0, 0.0, 0.0]) + .build(), // uv (1,0) + VertexBuilder::new() + .with_position([0.5, 0.5, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([1.0, 1.0, 0.0]) + .build(), // uv (1,1) + VertexBuilder::new() + .with_position([-0.5, -0.5, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([0.0, 0.0, 0.0]) + .build(), // uv (0,0) + VertexBuilder::new() + .with_position([0.5, 0.5, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([1.0, 1.0, 0.0]) + .build(), // uv (1,1) + VertexBuilder::new() + .with_position([-0.5, 0.5, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([0.0, 1.0, 0.0]) + .build(), // uv (0,1) + ]; + + let mut mesh_builder = MeshBuilder::new(); + vertices.iter().for_each(|v| { + mesh_builder.with_vertex(*v); + }); + let mesh = mesh_builder + .with_attributes(vec![ + VertexAttribute { + location: 0, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 0, + }, + }, + VertexAttribute { + location: 1, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 12, + }, + }, + VertexAttribute { + location: 2, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 24, + }, + }, + ]) + .build(); + + // Create a small checkerboard texture + let tex_w = 64u32; + let tex_h = 64u32; + let mut pixels = vec![0u8; (tex_w * tex_h * 4) as usize]; + for y in 0..tex_h { + for x in 0..tex_w { + let i = ((y * tex_w + x) * 4) as usize; + let checker = ((x / 8) % 2) ^ ((y / 8) % 2); + let c = if checker == 0 { 40 } else { 220 }; + pixels[i + 0] = c; // R + pixels[i + 1] = c; // G + pixels[i + 2] = c; // B + pixels[i + 3] = 255; // A + } + } + + let texture = TextureBuilder::new_2d(TextureFormat::Rgba8UnormSrgb) + .with_size(tex_w, tex_h) + .with_data(&pixels) + .with_label("checkerboard") + .build(render_context) + .expect("Failed to create texture"); + + let sampler = SamplerBuilder::new() + .linear_clamp() + .with_label("linear-clamp") + .build(render_context); + + // Layout: binding(1) texture2D, binding(2) sampler + let layout = BindGroupLayoutBuilder::new() + .with_sampled_texture(1) + .with_sampler(2) + .build(render_context); + + let bind_group = BindGroupBuilder::new() + .with_layout(&layout) + .with_texture(1, &texture) + .with_sampler(2, &sampler) + .build(render_context); + + let pipeline = RenderPipelineBuilder::new() + .with_culling(CullingMode::None) + .with_layouts(&[&layout]) + .with_buffer( + BufferBuilder::build_from_mesh(&mesh, render_context) + .expect("Failed to create vertex buffer"), + mesh.attributes().to_vec(), + ) + .build(render_context, &render_pass, &shader_vs, Some(&shader_fs)); + + self.render_pass = Some(render_context.attach_render_pass(render_pass)); + self.render_pipeline = Some(render_context.attach_pipeline(pipeline)); + self.bind_group = Some(render_context.attach_bind_group(bind_group)); + self.mesh = Some(mesh); + self.shader_vs = shader_vs; + self.shader_fs = shader_fs; + + return Ok(ComponentResult::Success); + } + + fn on_detach( + &mut self, + _render_context: &mut lambda::render::RenderContext, + ) -> Result { + return Ok(ComponentResult::Success); + } + + fn on_event(&mut self, event: Events) -> Result { + if let Events::Window { + event: lambda::events::WindowEvent::Resize { width, height }, + .. + } = event + { + self.width = width; + self.height = height; + } + return Ok(ComponentResult::Success); + } + + fn on_update( + &mut self, + _last_frame: &std::time::Duration, + ) -> Result { + return Ok(ComponentResult::Success); + } + + fn on_render( + &mut self, + _render_context: &mut lambda::render::RenderContext, + ) -> Vec { + let mut commands = vec![]; + // Center a square viewport to keep aspect ratio and center the quad. + let win_w = self.width.max(1); + let win_h = self.height.max(1); + let side = u32::min(win_w, win_h); + let x = ((win_w - side) / 2) as i32; + let y = ((win_h - side) / 2) as i32; + let viewport = ViewportBuilder::new() + .with_coordinates(x, y) + .build(side, side); + commands.push(RenderCommand::BeginRenderPass { + render_pass: self.render_pass.expect("render pass not set"), + viewport, + }); + commands.push(RenderCommand::SetPipeline { + pipeline: self.render_pipeline.expect("pipeline not set"), + }); + commands.push(RenderCommand::SetBindGroup { + set: 0, + group: self.bind_group.expect("bind group not set"), + dynamic_offsets: vec![], + }); + commands.push(RenderCommand::BindVertexBuffer { + pipeline: self.render_pipeline.expect("pipeline not set"), + buffer: 0, + }); + commands.push(RenderCommand::Draw { vertices: 0..6 }); + commands.push(RenderCommand::EndRenderPass); + return commands; + } +} + +impl Default for TexturedQuadExample { + fn default() -> Self { + let mut shader_builder = ShaderBuilder::new(); + let shader_vs = shader_builder.build(VirtualShader::Source { + source: VERTEX_SHADER_SOURCE.to_string(), + kind: ShaderKind::Vertex, + entry_point: "main".to_string(), + name: "textured-quad".to_string(), + }); + let shader_fs = shader_builder.build(VirtualShader::Source { + source: FRAGMENT_SHADER_SOURCE.to_string(), + kind: ShaderKind::Fragment, + entry_point: "main".to_string(), + name: "textured-quad".to_string(), + }); + + return Self { + shader_vs, + shader_fs, + mesh: None, + render_pipeline: None, + render_pass: None, + bind_group: None, + width: 800, + height: 600, + }; + } +} + +fn main() { + let runtime: ApplicationRuntime = + ApplicationRuntimeBuilder::new("Textured Quad Example") + .with_window_configured_as(|builder| { + builder.with_dimensions(800, 600).with_name("Textured Quad") + }) + .with_component(|runtime, example: TexturedQuadExample| { + (runtime, example) + }) + .build(); + + start_runtime(runtime); +} diff --git a/crates/lambda-rs/src/render/bind.rs b/crates/lambda-rs/src/render/bind.rs index d932e83b..bc318344 100644 --- a/crates/lambda-rs/src/render/bind.rs +++ b/crates/lambda-rs/src/render/bind.rs @@ -20,6 +20,16 @@ use std::rc::Rc; +use super::{ + buffer::Buffer, + texture::{ + Sampler, + Texture, + ViewDimension, + }, + RenderContext, +}; + /// Visibility of a binding across shader stages (engine‑facing). /// /// Select one or more shader stages that read a bound resource. Use @@ -53,11 +63,6 @@ impl BindingVisibility { } } -use super::{ - buffer::Buffer, - RenderContext, -}; - #[cfg(test)] mod tests { use super::*; @@ -127,6 +132,9 @@ impl BindGroup { pub struct BindGroupLayoutBuilder { label: Option, entries: Vec<(u32, BindingVisibility, bool)>, + textures_2d: Vec<(u32, BindingVisibility)>, + textures_dim: Vec<(u32, BindingVisibility, ViewDimension)>, + samplers: Vec<(u32, BindingVisibility)>, } impl BindGroupLayoutBuilder { @@ -135,6 +143,9 @@ impl BindGroupLayoutBuilder { Self { label: None, entries: Vec::new(), + textures_2d: Vec::new(), + textures_dim: Vec::new(), + samplers: Vec::new(), } } @@ -164,6 +175,31 @@ impl BindGroupLayoutBuilder { return self; } + /// Add a sampled 2D texture binding, defaulting to fragment visibility. + pub fn with_sampled_texture(mut self, binding: u32) -> Self { + self + .textures_2d + .push((binding, BindingVisibility::Fragment)); + return self; + } + + /// Add a filtering sampler binding, defaulting to fragment visibility. + pub fn with_sampler(mut self, binding: u32) -> Self { + self.samplers.push((binding, BindingVisibility::Fragment)); + return self; + } + + /// Add a sampled texture binding with an explicit view dimension and visibility. + pub fn with_sampled_texture_dim( + mut self, + binding: u32, + dim: ViewDimension, + visibility: BindingVisibility, + ) -> Self { + self.textures_dim.push((binding, visibility, dim)); + return self; + } + /// Build the layout using the `RenderContext` device. pub fn build(self, render_context: &RenderContext) -> BindGroupLayout { let mut builder = @@ -171,10 +207,9 @@ impl BindGroupLayoutBuilder { #[cfg(debug_assertions)] { - // In debug builds, check for duplicate binding indices. + // In debug builds, check for duplicate binding indices across all kinds. use std::collections::HashSet; let mut seen = HashSet::new(); - for (binding, _, _) in &self.entries { assert!( seen.insert(binding), @@ -182,6 +217,27 @@ impl BindGroupLayoutBuilder { binding ); } + for (binding, _) in &self.textures_2d { + assert!( + seen.insert(binding), + "BindGroupLayoutBuilder: duplicate binding index {}", + binding + ); + } + for (binding, _, _) in &self.textures_dim { + assert!( + seen.insert(binding), + "BindGroupLayoutBuilder: duplicate binding index {}", + binding + ); + } + for (binding, _) in &self.samplers { + assert!( + seen.insert(binding), + "BindGroupLayoutBuilder: duplicate binding index {}", + binding + ); + } } let dynamic_binding_count = @@ -199,6 +255,23 @@ impl BindGroupLayoutBuilder { }; } + for (binding, visibility) in self.textures_2d.into_iter() { + builder = + builder.with_sampled_texture_2d(binding, visibility.to_platform()); + } + + for (binding, visibility, dim) in self.textures_dim.into_iter() { + builder = builder.with_sampled_texture_dim( + binding, + visibility.to_platform(), + dim.to_platform(), + ); + } + + for (binding, visibility) in self.samplers.into_iter() { + builder = builder.with_sampler(binding, visibility.to_platform()); + } + let layout = builder.build(render_context.gpu()); return BindGroupLayout { @@ -225,6 +298,8 @@ pub struct BindGroupBuilder<'a> { label: Option, layout: Option<&'a BindGroupLayout>, entries: Vec<(u32, &'a Buffer, u64, Option)>, + textures: Vec<(u32, Rc)>, + samplers: Vec<(u32, Rc)>, } impl<'a> BindGroupBuilder<'a> { @@ -234,6 +309,8 @@ impl<'a> BindGroupBuilder<'a> { label: None, layout: None, entries: Vec::new(), + textures: Vec::new(), + samplers: Vec::new(), }; } @@ -261,6 +338,18 @@ impl<'a> BindGroupBuilder<'a> { return self; } + /// Bind a 2D 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; + } + + /// Bind a sampler at the specified binding index. + pub fn with_sampler(mut self, binding: u32, sampler: &'a Sampler) -> Self { + self.samplers.push((binding, sampler.platform_sampler())); + return self; + } + /// Build the bind group on the current device. pub fn build(self, render_context: &RenderContext) -> BindGroup { let layout = self @@ -297,6 +386,17 @@ impl<'a> BindGroupBuilder<'a> { platform = platform.with_uniform(binding, buffer.raw(), offset, size); } + let textures_hold = self.textures; + let samplers_hold = self.samplers; + + for (binding, texture_handle) in textures_hold.iter() { + platform = platform.with_texture(*binding, texture_handle.as_ref()); + } + + for (binding, sampler_handle) in samplers_hold.iter() { + platform = platform.with_sampler(*binding, sampler_handle.as_ref()); + } + let group = platform.build(render_context.gpu()); return BindGroup { group: Rc::new(group), diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index 2f600964..81e7c22f 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -37,6 +37,7 @@ pub mod pipeline; pub mod render_pass; pub mod scene_math; pub mod shader; +pub mod texture; pub mod validation; pub mod vertex; pub mod viewport; @@ -152,6 +153,16 @@ impl RenderContextBuilder { let present_mode = config.present_mode; let texture_usage = config.usage; + // Initialize a depth texture matching the surface size. + let depth_format = platform::texture::DepthFormat::Depth32Float; + let depth_texture = Some( + platform::texture::DepthTextureBuilder::new() + .with_size(size.0.max(1), size.1.max(1)) + .with_format(depth_format) + .with_label("lambda-depth") + .build(&gpu), + ); + return Ok(RenderContext { label: name, instance, @@ -161,6 +172,8 @@ impl RenderContextBuilder { present_mode, texture_usage, size, + depth_texture, + depth_format, render_passes: vec![], render_pipelines: vec![], bind_group_layouts: vec![], @@ -195,6 +208,8 @@ pub struct RenderContext { present_mode: platform::surface::PresentMode, texture_usage: platform::surface::TextureUsages, size: (u32, u32), + depth_texture: Option, + depth_format: platform::texture::DepthFormat, render_passes: Vec, render_pipelines: Vec, bind_group_layouts: Vec, @@ -283,6 +298,15 @@ impl RenderContext { if let Err(err) = self.reconfigure_surface(self.size) { logging::error!("Failed to resize surface: {:?}", err); } + + // Recreate depth texture to match new size. + self.depth_texture = Some( + platform::texture::DepthTextureBuilder::new() + .with_size(self.size.0.max(1), self.size.1.max(1)) + .with_format(self.depth_format) + .with_label("lambda-depth") + .build(self.gpu()), + ); } /// Borrow a previously attached render pass by id. @@ -390,8 +414,52 @@ impl RenderContext { platform::render_pass::RenderColorAttachments::new(); color_attachments.push_color(view); - let mut pass_encoder = - rp_builder.build(&mut encoder, &mut color_attachments); + // Optional depth attachment configured by the pass description. + let (depth_view, depth_ops) = match pass.depth_operations() { + Some(dops) => { + if self.depth_texture.is_none() { + self.depth_texture = Some( + platform::texture::DepthTextureBuilder::new() + .with_size(self.size.0.max(1), self.size.1.max(1)) + .with_format(self.depth_format) + .with_label("lambda-depth") + .build(self.gpu()), + ); + } + let mut view_ref = self + .depth_texture + .as_ref() + .expect("depth texture should be present") + .view_ref(); + let mapped = platform::render_pass::DepthOperations { + load: match dops.load { + render_pass::DepthLoadOp::Load => { + platform::render_pass::DepthLoadOp::Load + } + render_pass::DepthLoadOp::Clear(v) => { + platform::render_pass::DepthLoadOp::Clear(v as f32) + } + }, + store: match dops.store { + render_pass::StoreOp::Store => { + platform::render_pass::StoreOp::Store + } + render_pass::StoreOp::Discard => { + platform::render_pass::StoreOp::Discard + } + }, + }; + (Some(view_ref), Some(mapped)) + } + None => (None, None), + }; + + let mut pass_encoder = rp_builder.build( + &mut encoder, + &mut color_attachments, + depth_view, + depth_ops, + ); self.encode_pass(&mut pass_encoder, viewport, &mut command_iter)?; } @@ -411,7 +479,7 @@ impl RenderContext { /// Encode a single render pass and consume commands until `EndRenderPass`. fn encode_pass( - &mut self, + &self, pass: &mut platform::render_pass::RenderPass<'_>, initial_viewport: viewport::Viewport, commands: &mut I, diff --git a/crates/lambda-rs/src/render/pipeline.rs b/crates/lambda-rs/src/render/pipeline.rs index d3e1c383..0183d5ad 100644 --- a/crates/lambda-rs/src/render/pipeline.rs +++ b/crates/lambda-rs/src/render/pipeline.rs @@ -30,7 +30,10 @@ use std::{ rc::Rc, }; -use lambda_platform::wgpu::pipeline as platform_pipeline; +use lambda_platform::wgpu::{ + pipeline as platform_pipeline, + texture as platform_texture, +}; use super::{ bind, @@ -92,6 +95,7 @@ pub struct RenderPipelineBuilder { culling: CullingMode, bind_group_layouts: Vec, label: Option, + use_depth: bool, } impl RenderPipelineBuilder { @@ -103,6 +107,7 @@ impl RenderPipelineBuilder { culling: CullingMode::Back, bind_group_layouts: Vec::new(), label: None, + use_depth: false, } } @@ -147,6 +152,12 @@ impl RenderPipelineBuilder { return self; } + /// Enable depth testing/writes using the render context's depth format. + pub fn with_depth(mut self) -> Self { + self.use_depth = true; + return self; + } + /// Build a graphics pipeline using the provided shader modules and /// previously registered vertex inputs and push constants. pub fn build( @@ -237,6 +248,11 @@ impl RenderPipelineBuilder { rp_builder = rp_builder.with_surface_color_target(surface_format); } + if self.use_depth { + rp_builder = rp_builder + .with_depth_stencil(platform_texture::DepthFormat::Depth32Float); + } + let pipeline = rp_builder.build( render_context.gpu(), &vertex_module, diff --git a/crates/lambda-rs/src/render/render_pass.rs b/crates/lambda-rs/src/render/render_pass.rs index 99c5ddd2..6dc732db 100644 --- a/crates/lambda-rs/src/render/render_pass.rs +++ b/crates/lambda-rs/src/render/render_pass.rs @@ -40,6 +40,31 @@ impl Default for ColorOperations { } } +/// Depth load operation for the depth attachment. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DepthLoadOp { + /// Load existing depth. + Load, + /// Clear to the provided depth value in [0,1]. + Clear(f64), +} + +/// Depth operations for the first depth attachment. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DepthOperations { + pub load: DepthLoadOp, + pub store: StoreOp, +} + +impl Default for DepthOperations { + fn default() -> Self { + return Self { + load: DepthLoadOp::Clear(1.0), + store: StoreOp::Store, + }; + } +} + /// Immutable parameters used when beginning a render pass. #[derive(Debug, Clone)] /// @@ -49,6 +74,7 @@ pub struct RenderPass { clear_color: [f64; 4], label: Option, color_operations: ColorOperations, + depth_operations: Option, } impl RenderPass { @@ -66,6 +92,10 @@ impl RenderPass { pub(crate) fn color_operations(&self) -> ColorOperations { return self.color_operations; } + + pub(crate) fn depth_operations(&self) -> Option { + return self.depth_operations; + } } /// Builder for a `RenderPass` description. @@ -77,6 +107,7 @@ pub struct RenderPassBuilder { clear_color: [f64; 4], label: Option, color_operations: ColorOperations, + depth_operations: Option, } impl RenderPassBuilder { @@ -86,6 +117,7 @@ impl RenderPassBuilder { clear_color: [0.0, 0.0, 0.0, 1.0], label: None, color_operations: ColorOperations::default(), + depth_operations: None, } } @@ -129,12 +161,28 @@ impl RenderPassBuilder { return self; } + /// Enable a depth attachment with default clear to 1.0 and store. + pub fn with_depth(mut self) -> Self { + self.depth_operations = Some(DepthOperations::default()); + return self; + } + + /// Enable a depth attachment with an explicit clear value. + pub fn with_depth_clear(mut self, clear: f64) -> Self { + self.depth_operations = Some(DepthOperations { + load: DepthLoadOp::Clear(clear), + store: StoreOp::Store, + }); + return self; + } + /// Build the description used when beginning a render pass. pub fn build(self, _render_context: &RenderContext) -> RenderPass { RenderPass { clear_color: self.clear_color, label: self.label, color_operations: self.color_operations, + depth_operations: self.depth_operations, } } } diff --git a/crates/lambda-rs/src/render/texture.rs b/crates/lambda-rs/src/render/texture.rs new file mode 100644 index 00000000..921825d9 --- /dev/null +++ b/crates/lambda-rs/src/render/texture.rs @@ -0,0 +1,288 @@ +//! High‑level textures and samplers. +//! +//! Provides `TextureBuilder` and `SamplerBuilder` wrappers that delegate to the +//! platform layer and keep `wgpu` details internal to the platform crate. + +use std::rc::Rc; + +use lambda_platform::wgpu::texture as platform; + +use super::RenderContext; + +#[derive(Debug, Clone, Copy)] +/// Supported color texture formats for sampling. +pub enum TextureFormat { + Rgba8Unorm, + Rgba8UnormSrgb, +} + +impl TextureFormat { + fn to_platform(self) -> platform::TextureFormat { + return match self { + TextureFormat::Rgba8Unorm => platform::TextureFormat::Rgba8Unorm, + TextureFormat::Rgba8UnormSrgb => platform::TextureFormat::Rgba8UnormSrgb, + }; + } +} + +#[derive(Debug, Clone, Copy)] +/// View dimensionality exposed to shaders when sampling. +pub enum ViewDimension { + D2, + D3, +} + +impl ViewDimension { + pub(crate) fn to_platform(self) -> platform::ViewDimension { + return match self { + ViewDimension::D2 => platform::ViewDimension::TwoDimensional, + ViewDimension::D3 => platform::ViewDimension::ThreeDimensional, + }; + } +} + +#[derive(Debug, Clone, Copy)] +/// Sampler filtering mode. +pub enum FilterMode { + Nearest, + Linear, +} + +impl FilterMode { + fn to_platform(self) -> platform::FilterMode { + return match self { + FilterMode::Nearest => platform::FilterMode::Nearest, + FilterMode::Linear => platform::FilterMode::Linear, + }; + } +} + +#[derive(Debug, Clone, Copy)] +/// Sampler address mode. +pub enum AddressMode { + ClampToEdge, + Repeat, + MirrorRepeat, +} + +impl AddressMode { + fn to_platform(self) -> platform::AddressMode { + return match self { + AddressMode::ClampToEdge => platform::AddressMode::ClampToEdge, + AddressMode::Repeat => platform::AddressMode::Repeat, + AddressMode::MirrorRepeat => platform::AddressMode::MirrorRepeat, + }; + } +} +#[derive(Debug, Clone)] +/// High‑level texture wrapper that owns a platform texture. +pub struct Texture { + inner: Rc, +} + +impl Texture { + pub(crate) fn platform_texture(&self) -> Rc { + return self.inner.clone(); + } +} + +#[derive(Debug, Clone)] +/// High‑level sampler wrapper that owns a platform sampler. +pub struct Sampler { + inner: Rc, +} + +impl Sampler { + pub(crate) fn platform_sampler(&self) -> Rc { + return self.inner.clone(); + } +} + +/// Builder for creating a 2D sampled texture with optional initial data. +pub struct TextureBuilder { + label: Option, + format: TextureFormat, + width: u32, + height: u32, + depth: u32, + data: Option>, // tightly packed rows +} + +impl TextureBuilder { + /// Begin building a 2D texture. + pub fn new_2d(format: TextureFormat) -> Self { + return Self { + label: None, + format, + width: 0, + height: 0, + depth: 1, + data: None, + }; + } + + /// Begin building a 3D texture. + /// + /// Call `with_size_3d(width, height, depth)` to specify the voxel size + /// before building. The builder will select the 3D upload path based on the + /// configured depth. + pub fn new_3d(format: TextureFormat) -> Self { + return Self { + label: None, + format, + width: 0, + height: 0, + // Depth > 1 ensures the 3D path is chosen once size is provided. + depth: 2, + data: None, + }; + } + + /// Set the texture size in pixels. + pub fn with_size(mut self, width: u32, height: u32) -> Self { + self.width = width; + self.height = height; + self.depth = 1; + return self; + } + + /// Set the 3D texture size in voxels. + pub fn with_size_3d(mut self, width: u32, height: u32, depth: u32) -> Self { + self.width = width; + self.height = height; + self.depth = depth; + return self; + } + + /// Provide tightly packed pixel data for full‑image upload. + pub fn with_data(mut self, pixels: &[u8]) -> Self { + self.data = Some(pixels.to_vec()); + return self; + } + + /// Attach a debug label. + pub fn with_label(mut self, label: &str) -> Self { + self.label = Some(label.to_string()); + return self; + } + + /// Create the texture and upload initial data if provided. + pub fn build( + self, + render_context: &mut RenderContext, + ) -> Result { + let mut builder = + if self.depth <= 1 { + platform::TextureBuilder::new_2d(self.format.to_platform()) + .with_size(self.width, self.height) + } else { + platform::TextureBuilder::new_3d(self.format.to_platform()) + .with_size_3d(self.width, self.height, self.depth) + }; + + if let Some(ref label) = self.label { + builder = builder.with_label(label); + } + + if let Some(ref pixels) = self.data { + builder = builder.with_data(pixels); + } + + return match builder.build(render_context.gpu()) { + Ok(texture) => Ok(Texture { + inner: Rc::new(texture), + }), + Err(platform::TextureBuildError::InvalidDimensions { .. }) => { + Err("Invalid texture dimensions") + } + Err(platform::TextureBuildError::DataLengthMismatch { .. }) => { + Err("Texture data length does not match width * height * bpp") + } + Err(platform::TextureBuildError::Overflow) => { + Err("Overflow while computing texture layout") + } + }; + } +} + +/// Builder for creating a sampler. +pub struct SamplerBuilder { + inner: platform::SamplerBuilder, +} + +impl SamplerBuilder { + /// Create a new sampler builder with nearest/clamp defaults. + pub fn new() -> Self { + return Self { + inner: platform::SamplerBuilder::new(), + }; + } + + /// Linear min/mag filter. + pub fn linear(mut self) -> Self { + self.inner = self.inner.linear(); + return self; + } + + /// Nearest min/mag filter. + pub fn nearest(mut self) -> Self { + self.inner = self.inner.nearest(); + return self; + } + + /// Convenience: linear filter + clamp addressing. + pub fn linear_clamp(mut self) -> Self { + self.inner = self.inner.linear_clamp(); + return self; + } + + /// Convenience: nearest filter + clamp addressing. + pub fn nearest_clamp(mut self) -> Self { + self.inner = self.inner.nearest_clamp(); + return self; + } + + /// Set address mode for U (x) coordinate. + pub fn with_address_mode_u(mut self, mode: AddressMode) -> Self { + self.inner = self.inner.with_address_mode_u(mode.to_platform()); + return self; + } + + /// Set address mode for V (y) coordinate. + pub fn with_address_mode_v(mut self, mode: AddressMode) -> Self { + self.inner = self.inner.with_address_mode_v(mode.to_platform()); + return self; + } + + /// Set address mode for W (z) coordinate. + pub fn with_address_mode_w(mut self, mode: AddressMode) -> Self { + self.inner = self.inner.with_address_mode_w(mode.to_platform()); + return self; + } + + /// Set mipmap filtering. + pub fn with_mip_filter(mut self, mode: FilterMode) -> Self { + self.inner = self.inner.with_mip_filter(mode.to_platform()); + return self; + } + + /// Set LOD clamp range. + pub fn with_lod(mut self, min: f32, max: f32) -> Self { + self.inner = self.inner.with_lod(min, max); + return self; + } + + /// Attach a debug label. + pub fn with_label(mut self, label: &str) -> Self { + self.inner = self.inner.with_label(label); + return self; + } + + /// Create the sampler on the current device. + pub fn build(self, render_context: &mut RenderContext) -> Sampler { + let sampler = self.inner.build(render_context.gpu()); + return Sampler { + inner: Rc::new(sampler), + }; + } +} diff --git a/crates/lambda-rs/tests/runnables.rs b/crates/lambda-rs/tests/runnables.rs deleted file mode 100644 index fc4bb623..00000000 --- a/crates/lambda-rs/tests/runnables.rs +++ /dev/null @@ -1,3 +0,0 @@ -#![allow(clippy::needless_return)] -#[test] -fn lambda_runnable() {} diff --git a/docs/specs/textures-and-samplers.md b/docs/specs/textures-and-samplers.md new file mode 100644 index 00000000..80ce5f12 --- /dev/null +++ b/docs/specs/textures-and-samplers.md @@ -0,0 +1,403 @@ +--- +title: "Textures and Samplers" +document_id: "texture-sampler-spec-2025-10-30" +status: "draft" +created: "2025-10-30T00:00:00Z" +last_updated: "2025-11-10T00:00:00Z" +version: "0.3.1" +engine_workspace_version: "2023.1.30" +wgpu_version: "26.0.1" +shader_backend_default: "naga" +winit_version: "0.29.10" +repo_commit: "fc5eb52c74eb0835225959f941db8e991112b87d" +owners: ["lambda-sh"] +reviewers: ["engine", "rendering"] +tags: ["spec", "rendering", "textures", "samplers", "wgpu"] +--- + +# Textures and Samplers + +Summary +- Introduces first-class 2D and 3D sampled textures and samplers with a + builder-based application programming interface and platform abstraction. +- Rationale: Texture sampling is foundational for images, sprites, materials, + volume data, and user interface elements; this specification establishes a + portable surface to upload image data to the graphics processing unit (GPU) + and sample in fragment shaders. + +## Scope + +### Goals + +- Provide 2D and 3D color textures with initial data upload from the central + processing unit (CPU) to the GPU. +- Provide samplers with common filtering and address modes (including W for + 3D). +- Integrate textures and samplers into bind group layouts and bind groups with + explicit view dimensions. +- Expose a stable high-level application programming interface in `lambda-rs` + backed by `wgpu` through `lambda-rs-platform`. +- Support sRGB and linear color formats appropriate for filtering. + +### Non-Goals + +- Storage textures, depth textures, cube maps, and 2D/3D array textures + are out of scope for this revision and tracked under “Future Extensions”. +- Multisampled textures and render target (color attachment) workflows are + out of scope for this revision and tracked under “Future Extensions”. +- Mipmap generation (automatic or offline); only level 0 is supported in this + revision. See “Future Extensions”. +- Partial sub-rect updates; uploads are whole-image at creation time in this + revision. See “Future Extensions”. + +## Terminology + +- Sampled texture: A read-only texture resource accessed in shaders with a + separate sampler. +- Texture dimension: The physical dimensionality of the texture storage + (`D2`, `D3`). +- View dimension: The dimensionality exposed to shader sampling + (`D2`, `D3`). +- Texture view: A typed view of a texture used for binding; by default a full + view is created for the specified dimension. +- Sampler: A state object defining filtering and address (wrap) behavior. +- Mipmap level (mip): A downscaled level of a texture. Level 0 is the base. +- sRGB: A gamma-encoded color format (`*Srgb`) with conversion to linear space + on sampling. + +## Architecture Overview + +- Platform (`lambda-rs-platform`) + - Wraps `wgpu::Texture`, `wgpu::TextureView`, and `wgpu::Sampler` with + builders that perform validation and queue uploads with + `wgpu::Queue::write_texture`. + - Maps public `TextureFormat`, `TextureDimension`, `ViewDimension`, filter + and address enums to `wgpu` types. + - Owns raw `wgpu` handles; high-level layer interacts via opaque wrappers. + +- High level (`lambda-rs`) + - Public builders: `TextureBuilder` and `SamplerBuilder` in + `lambda::render::texture`. + - Bind group integration: extend existing builders to declare and bind + sampled textures and samplers alongside uniform buffers. Layout entries + specify view dimension explicitly or use 2D shorthands. + - Keep `wgpu` details internal; expose stable enums and validation errors. + +Data flow (creation and use): + +```ascii +CPU pixels --> TextureBuilder (platform upload + validation) + --> GPU Texture + default TextureView +SamplerBuilder --> GPU Sampler + +BindGroupLayoutBuilder: uniform | sampled_texture | sampler +BindGroupBuilder: buffer | texture(view) | sampler + +Render pass: SetPipeline -> SetBindGroup -> Draw +``` + +## Design + +### API Surface + +- Platform layer (`lambda-rs-platform`, module `lambda_platform::wgpu::texture`) + - Types: `Texture`, `Sampler` (own raw `wgpu` handles). A default + full-range view is created and owned by `Texture` for binding. + - Enums: `TextureFormat`, `TextureDimension` (`D2`, `D3`), `ViewDimension` + (`D2`, `D3`), `FilterMode`, `AddressMode`. + - Builders: + - `TextureBuilder` + - `new_2d(format: TextureFormat)` — convenience for `dimension = D2`. + - `new_3d(format: TextureFormat)` — convenience for `dimension = D3`. + - `with_size(width: u32, height: u32)` — required for 2D textures. + - `with_size_3d(width: u32, height: u32, depth: u32)` — required for 3D. + - `with_data(pixels: &[u8])` — upload full level 0; platform pads rows + to satisfy `bytes_per_row` and sets `rows_per_image` for 3D. + - `with_usage(binding: bool, copy_dst: bool)` — controls + `TEXTURE_BINDING` and `COPY_DST` (default true for both). + - `with_label(label: &str)` + - `build(&mut RenderContext)` -> `Result` + - `SamplerBuilder` + - `new()` + - Filtering: `nearest()`, `linear()`; shorthands `nearest_clamp()`, + `linear_clamp()`. + - Addressing: `with_address_mode_u(mode)`, `with_address_mode_v(mode)`, + `with_address_mode_w(mode)`; default `ClampToEdge`. + - Mip filtering and level-of-detail: `with_lod(min, max)`, + `with_mip_filter(mode)` (default `Nearest`). + - `with_label(label: &str)` + - `build(&mut RenderContext)` -> `Sampler` + +- High-level layer (`lambda-rs`, module `lambda::render::texture`) + - Mirrors platform builders and enums; returns high-level `Texture` and + `Sampler` wrappers with no `wgpu` exposure. + - Adds convenience methods consistent with the repository style (for example, + `SamplerBuilder::linear_clamp()`). Usage toggles MAY be exposed at the + high level or fixed to stable defaults. + +- Bind group integration (`lambda::render::bind`) + - `BindGroupLayoutBuilder` additions: + - `with_sampled_texture(binding: u32)` — 2D, filterable float; shorthand, + default visibility Fragment. + - `with_sampled_texture_dim(binding: u32, dim: ViewDimension, visibility: BindingVisibility)` — + explicit dimension (`D2` or `D3`), float sample type, not multisampled. + - `with_sampler(binding: u32)` — filtering sampler type; default visibility + Fragment. + - `BindGroupBuilder` additions: + - `with_texture(binding: u32, texture: &Texture)` — uses the default view + that matches the texture’s dimension. + - `with_sampler(binding: u32, sampler: &Sampler)`. + +### Behavior + +- Texture creation + - The builder constructs a texture with `mip_level_count = 1`, + `sample_count = 1`, and `dimension` equal to `D2` or `D3`. + - Usage includes `TEXTURE_BINDING` and `COPY_DST` by default. + - When `with_data` is provided, the upload MUST cover the entire level 0. + The platform layer pads rows to a `bytes_per_row` multiple of 256 bytes and + sets `rows_per_image` for 3D before calling `Queue::write_texture`. + - A default full-range `TextureView` is created and retained by the texture + with `ViewDimension` matching the texture dimension. + +- Sampler creation + - Defaults: `FilterMode::Nearest` for min/mag/mip, `ClampToEdge` for all + address modes, `lod_min_clamp = 0.0`, `lod_max_clamp = 32.0`. + - Shorthands (`nearest_clamp`, `linear_clamp`) set min/mag filter and all + address modes to `ClampToEdge`. + +- Binding + - `with_sampled_texture` declares a 2D filterable float texture binding at + the specified index with Fragment visibility; shaders declare + `texture_2d`. + - `with_sampled_texture_dim` declares a texture binding with explicit view + dimension and visibility; shaders declare `texture_2d` or + `texture_3d`. + - `with_sampler` declares a filtering sampler binding at the specified index + with Fragment visibility; shaders declare `sampler` and combine with the + texture in sampling calls. + +### Validation and Errors + +- Limits and dimensions + - Width and height MUST be > 0 and ≤ the corresponding device limit for the + chosen dimension. + - 2D check: `≤ limits.max_texture_dimension_2d`. + - 3D check: `≤ limits.max_texture_dimension_3d` for each axis. + - Only textures with `mip_level_count = 1` are allowed in this revision. + +- Format and sampling + - `TextureFormat` MUST map to a filterable, color texture format in `wgpu`. + - If a sampled texture is declared, the sample type is float with + `filterable = true`; incompatible formats MUST be rejected at build time. + +- Upload data + - For 2D, `with_data` length MUST equal `width * height * bytes_per_pixel` of + the chosen format for tightly packed rows. + - For 3D, `with_data` length MUST equal + `width * height * depth * bytes_per_pixel` for tightly packed input. + - The platform layer performs padding to satisfy the 256-byte + `bytes_per_row` requirement and sets `rows_per_image` appropriately; + mismatched lengths or overflows MUST return an error before encoding. + - If `with_data` is used, usage MUST include `COPY_DST`. An implementation + MAY automatically add `COPY_DST` for build-time uploads to avoid errors. + +- Bindings + - `with_texture` and `with_sampler` MUST reference resources compatible with + the corresponding layout entries, including view dimension; violations + surface as validation errors from `wgpu` during bind group creation or + render pass encoding. + +## Constraints and Rules + +- Alignment and layout + - `bytes_per_row` MUST be a multiple of 256 bytes for `write_texture`. + - The platform layer pads each source row to meet this requirement and sets + `rows_per_image` for 3D writes. + +- Supported formats (initial) + - `Rgba8Unorm`, `Rgba8UnormSrgb`. Additional filterable color formats MAY be + added in future revisions. + +- Usage flags + - Textures created for sampling MUST include `TEXTURE_BINDING`. When initial + data is uploaded at creation, `COPY_DST` SHOULD be included. + +## Performance Considerations + +- Upload efficiency + - Use a single `Queue::write_texture` per texture to minimize driver + overhead. Rationale: Batching uploads reduces command submission costs. +- Large volume data + - Prefer `copy_buffer_to_texture` for very large 3D uploads to reduce CPU + staging and allow asynchronous transfers. Rationale: Improves throughput + for multi-megabyte volumes. +- Filtering choice + - Prefer `Linear` filtering for downscaled content; use `Nearest` for pixel + art. Rationale: Matches visual expectations and avoids unintended blurring. +- Address modes + - Use `ClampToEdge` for user interface and sprites; `Repeat` for tiled + backgrounds. Rationale: Prevents sampling beyond image borders where not + intended. + +## Requirements Checklist + +- Functionality + - [x] Feature flags defined (if applicable) (N/A) + - [x] 2D texture creation and upload + - [x] 3D texture creation and upload + - [x] Sampler creation (U, V, W addressing) + - [x] Bind group layout and binding for texture + sampler (2D/3D) +- API Surface + - [x] Public builders and enums in `lambda-rs` + - [x] Platform wrappers in `lambda-rs-platform` + - [x] Backwards compatibility assessed +- Validation and Errors + - [ ] Dimension and limit checks (2D/3D) + - [x] Format compatibility checks + - [x] Data length and row padding/rows-per-image validation +- Performance + - [x] Upload path reasoned and documented + - [ ] Memory footprint characterized for common formats +- Documentation and Examples + - [x] User-facing docs updated + - [x] Minimal example rendering a textured quad (equivalent) + - [x] Migration notes (if applicable) (N/A) + +- Extensions (Planned) + - [ ] Mipmapping: generation, `mip_level_count`, mip view selection + - [ ] Texture arrays and cube maps: array/cube view dimensions and layout entries + - [ ] Storage textures: read-write bindings and storage-capable formats + - [ ] Render-target textures (color): `RENDER_ATTACHMENT` usage and MSAA resolve + - [ ] Additional color formats: `R8Unorm`, `Rg8Unorm`, `Rgba16Float`, others + - [ ] Compressed textures: BCn/ASTC/ETC via KTX2/BasisU + - [ ] Anisotropic filtering and border color: anisotropy and `ClampToBorder` + - [ ] Sub-rect updates and streaming: partial `write_texture`, buffer-to-texture + - [ ] Alternate view formats: `view_formats` and view creation over subsets + - [ ] Compare samplers (shadow sampling): comparison binding type and sampling + - [ ] LOD bias and per-sample control: expose LOD bias and overrides + +## Verification and Testing + +- Unit Tests + - Compute `bytes_per_row` padding and data size validation (2D/3D). + - Compute `rows_per_image` for 3D uploads. + - Format mapping from `TextureFormat` to `wgpu::TextureFormat`. + - Commands: `cargo test -p lambda-rs-platform -- --nocapture` + +- Integration Tests + - Render a quad sampling a test checkerboard (2D); verify no validation + errors and expected color histogram bounds. + - Render a slice of a 3D texture by fixing W (for example, `uvw.z = 0.5`) in + the fragment shader; verify sampling and addressing. + - Commands: `cargo test --workspace` + +- Manual Checks (if necessary) + - Run example that draws a textured quad. Confirm correct sampling with + `Nearest` and `Linear` and correct addressing with `ClampToEdge` and + `Repeat`. + +## Compatibility and Migration + +- None. This is a new feature area. Future revisions MAY extend formats, + dimensions, and render-target usage. + +## Example Usage + +Rust (2D high level) +```rust +use lambda::render::texture::{TextureBuilder, SamplerBuilder, TextureFormat}; +use lambda::render::bind::{BindGroupLayoutBuilder, BindGroupBuilder, BindingVisibility}; +use lambda::render::command::RenderCommand as RC; + +let texture2d = TextureBuilder::new_2d(TextureFormat::Rgba8UnormSrgb) + .with_size(512, 512) + .with_data(&pixels) + .with_label("albedo") + .build(&mut render_context)?; + +let sampler = SamplerBuilder::new() + .linear_clamp() + .with_label("albedo-sampler") + .build(&mut render_context); + +let layout2d = BindGroupLayoutBuilder::new() + .with_uniform(0, BindingVisibility::VertexAndFragment) + .with_sampled_texture(1) // 2D shorthand + .with_sampler(2) + .build(&mut render_context); + +let group2d = BindGroupBuilder::new() + .with_layout(&layout2d) + .with_uniform(0, &uniform_buffer) + .with_texture(1, &texture2d) + .with_sampler(2, &sampler) + .build(&mut render_context); + +RC::SetBindGroup { set: 0, group: group_id, dynamic_offsets: vec![] }; +``` + +WGSL snippet (2D) +```wgsl +@group(0) @binding(1) var texture_color: texture_2d; +@group(0) @binding(2) var sampler_color: sampler; + +@fragment +fn fs_main(in_uv: vec2) -> @location(0) vec4 { + let color = textureSample(texture_color, sampler_color, in_uv); + return color; +} +``` + +Rust (3D high level) +```rust +use lambda::render::texture::{TextureBuilder, TextureFormat}; +use lambda::render::bind::{BindGroupLayoutBuilder, BindGroupBuilder}; +use lambda::render::texture::ViewDimension; +use lambda::render::bind::BindingVisibility; + +let texture3d = TextureBuilder::new_3d(TextureFormat::Rgba8Unorm) + .with_size_3d(128, 128, 64) + .with_data(&voxels) + .with_label("volume") + .build(&mut render_context)?; + +let layout3d = BindGroupLayoutBuilder::new() + .with_sampled_texture_dim(1, ViewDimension::D3, BindingVisibility::Fragment) + .with_sampler(2) + .build(&mut render_context); + +let group3d = BindGroupBuilder::new() + .with_layout(&layout3d) + .with_texture(1, &texture3d) + .with_sampler(2, &sampler) + .build(&mut render_context); +``` + +WGSL snippet (3D) +```wgsl +@group(0) @binding(1) var volume_tex: texture_3d; +@group(0) @binding(2) var volume_samp: sampler; + +@fragment +fn fs_main(in_uv: vec2) -> @location(0) vec4 { + // Sample a middle slice at z = 0.5 + let uvw = vec3(in_uv, 0.5); + return textureSample(volume_tex, volume_samp, uvw); +} +``` + +## Changelog + +- 2025-11-10 (v0.3.1) — Merge “Future Extensions” into the Requirements + Checklist and mark implemented status; metadata updated. +- 2025-11-09 (v0.3.0) — Clarify layout visibility parameters; make sampler + build infallible; correct `BindingVisibility` usage in examples; + add “Future Extensions” with planned texture features; metadata updated. +- 2025-10-30 (v0.2.0) — Add 3D textures, explicit dimensions in layout and + builders, W address mode, validation and examples updated. +- 2025-10-30 (v0.1.0) — Initial draft. + +## Future Extensions + +Moved into the Requirements Checklist under “Extensions (Planned)”. diff --git a/docs/tutorials/README.md b/docs/tutorials/README.md index 30c05674..83898c57 100644 --- a/docs/tutorials/README.md +++ b/docs/tutorials/README.md @@ -3,13 +3,13 @@ title: "Tutorials Index" document_id: "tutorials-index-2025-10-17" status: "living" created: "2025-10-17T00:20:00Z" -last_updated: "2025-10-17T00:20:00Z" -version: "0.1.0" +last_updated: "2025-11-10T00:00:00Z" +version: "0.2.0" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "93c85ccbc3863ecffc73e68fb340c5d45df89377" +repo_commit: "727dbe9b7706e273c525a6ca92426a1aba61cdb6" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["index", "tutorials", "docs"] @@ -17,9 +17,12 @@ tags: ["index", "tutorials", "docs"] This index lists tutorials that teach specific engine tasks through complete, incremental builds. -- Uniform Buffers: Build a Spinning Triangle — `docs/tutorials/uniform-buffers.md` +- Uniform Buffers: Build a Spinning Triangle — [uniform-buffers.md](uniform-buffers.md) +- Textured Quad: Sample a 2D Texture — [textured-quad.md](textured-quad.md) +- Textured Cube: 3D Push Constants + 2D Sampling — [textured-cube.md](textured-cube.md) Browse all tutorials in this directory. Changelog +- 0.2.0 (2025-11-10): Add links for textured quad and textured cube; update metadata and commit. - 0.1.0 (2025-10-17): Initial index with uniform buffers tutorial. diff --git a/docs/tutorials/textured-cube.md b/docs/tutorials/textured-cube.md new file mode 100644 index 00000000..45cc52f1 --- /dev/null +++ b/docs/tutorials/textured-cube.md @@ -0,0 +1,524 @@ +--- +title: "Textured Cube: 3D Push Constants + 2D Sampling" +document_id: "textured-cube-tutorial-2025-11-10" +status: "draft" +created: "2025-11-10T00:00:00Z" +last_updated: "2025-11-10T03:00:00Z" +version: "0.1.1" +engine_workspace_version: "2023.1.30" +wgpu_version: "26.0.1" +shader_backend_default: "naga" +winit_version: "0.29.10" +repo_commit: "fe79756541e33270eca76638400bb64c6ec9f732" +owners: ["lambda-sh"] +reviewers: ["engine", "rendering"] +tags: ["tutorial", "graphics", "3d", "push-constants", "textures", "samplers", "rust", "wgpu"] +--- + +## Overview +This tutorial builds a spinning 3D cube that uses push constants to provide model‑view‑projection (MVP) and model matrices to the vertex shader, and samples a 2D checkerboard texture in the fragment shader. Depth testing and back‑face culling are enabled so hidden faces do not render. + +Reference implementation: `crates/lambda-rs/examples/textured_cube.rs`. + +## Table of Contents +- [Overview](#overview) +- [Goals](#goals) +- [Prerequisites](#prerequisites) +- [Requirements and Constraints](#requirements-and-constraints) +- [Data Flow](#data-flow) +- [Implementation Steps](#implementation-steps) + - [Step 1 — Runtime and Component Skeleton](#step-1) + - [Step 2 — Shaders with Push Constants](#step-2) + - [Step 3 — Cube Mesh and Vertex Layout](#step-3) + - [Step 4 — Build a 2D Checkerboard Texture](#step-4) + - [Step 5 — Create a Sampler](#step-5) + - [Step 6 — Bind Group Layout and Bind Group](#step-6) + - [Step 7 — Render Pipeline with Depth and Culling](#step-7) + - [Step 8 — Per‑Frame Camera and Transforms](#step-8) + - [Step 9 — Record Draw Commands with Push Constants](#step-9) + - [Step 10 — Handle Window Resize](#step-10) +- [Validation](#validation) +- [Notes](#notes) +- [Conclusion](#conclusion) +- [Putting It Together](#putting-it-together) +- [Exercises](#exercises) +- [Changelog](#changelog) + +## Goals + +- Render a rotating cube with correct occlusion using depth testing and back‑face culling. +- Pass model‑view‑projection (MVP) and model matrices via push constants to the vertex stage. +- Sample a 2D texture in the fragment stage using a separate sampler, and apply simple Lambert lighting to emphasize shape. + +## Prerequisites +- Workspace builds: `cargo build --workspace`. +- Run a quick example: `cargo run --example minimal`. + +## Requirements and Constraints +- Push constant size and stage visibility MUST match the shader declaration. This example sends 128 bytes (two `mat4`), at vertex stage only. +- The push constant byte order MUST match the shader’s expected matrix layout. This example transposes matrices before upload to match column‑major multiplication in GLSL. +- Face winding MUST be counter‑clockwise (CCW) for back‑face culling to work with `CullingMode::Back`. +- The model matrix MUST NOT include non‑uniform scale if normals are transformed with `mat3(model)`. Rationale: non‑uniform scale skews normals; either avoid it or compute a proper normal matrix. +- Binding indices MUST match between Rust and shaders: set 0, binding 1 is the 2D texture; set 0, binding 2 is the sampler. +- Acronyms: central processing unit (CPU), graphics processing unit (GPU), model‑view‑projection (MVP). + +## Data Flow + +``` +CPU (mesh + pixels, elapsed time) + │ build cube, checkerboard + ▼ +TextureBuilder (2D sRGB) + SamplerBuilder (linear clamp) + │ + ▼ +BindGroup(set0): binding1=texture2D, binding2=sampler + │ ▲ + ▼ │ +Render Pipeline (vertex: push constants, fragment: sampling) + │ MVP + model (push constants) │ + ▼ │ +Render Pass (depth enabled, back‑face culling) → Draw 36 vertices +``` + +## Implementation Steps + +### Step 1 — Runtime and Component Skeleton +Create the application runtime and a `Component` that stores shader handles, GPU resource identifiers, window size, and elapsed time for animation. + +```rust +use lambda::{ + component::Component, + runtime::start_runtime, + runtimes::{ + application::ComponentResult, + ApplicationRuntimeBuilder, + }, +}; + +pub struct TexturedCubeExample { + shader_vs: lambda::render::shader::Shader, + shader_fs: lambda::render::shader::Shader, + mesh: Option, + render_pipeline: Option, + render_pass: Option, + bind_group: Option, + width: u32, + height: u32, + elapsed: f32, +} + +impl Default for TexturedCubeExample { + fn default() -> Self { + use lambda::render::shader::{ShaderBuilder, ShaderKind, VirtualShader}; + let mut builder = ShaderBuilder::new(); + let shader_vs = builder.build(VirtualShader::Source { + source: VERTEX_SHADER_SOURCE.to_string(), + kind: ShaderKind::Vertex, + entry_point: "main".to_string(), + name: "textured-cube".to_string(), + }); + let shader_fs = builder.build(VirtualShader::Source { + source: FRAGMENT_SHADER_SOURCE.to_string(), + kind: ShaderKind::Fragment, + entry_point: "main".to_string(), + name: "textured-cube".to_string(), + }); + + return Self { + shader_vs, + shader_fs, + mesh: None, + render_pipeline: None, + render_pass: None, + bind_group: None, + width: 800, + height: 600, + elapsed: 0.0, + }; + } +} + +fn main() { + let runtime = ApplicationRuntimeBuilder::new("Textured Cube Example") + .with_window_configured_as(|b| b.with_dimensions(800, 600).with_name("Textured Cube")) + .with_component(|runtime, example: TexturedCubeExample| { return (runtime, example); }) + .build(); + + start_runtime(runtime); +} +``` + +This scaffold establishes the runtime and stores component state required to create resources and animate the cube. + +### Step 2 — Shaders with Push Constants +Define GLSL 450 shaders. The vertex shader declares a push constant block with two `mat4` values: `mvp` and `model`. The fragment shader samples a 2D texture using a separate sampler and applies simple Lambert lighting for shape definition. + +```glsl +// Vertex (GLSL 450) +#version 450 + +layout (location = 0) in vec3 vertex_position; +layout (location = 1) in vec3 vertex_normal; +layout (location = 2) in vec3 vertex_color; // unused + +layout (location = 0) out vec3 v_model_pos; +layout (location = 1) out vec3 v_model_normal; +layout (location = 2) out vec3 v_world_normal; + +layout ( push_constant ) uniform Push { + mat4 mvp; + mat4 model; +} pc; + +void main() { + gl_Position = pc.mvp * vec4(vertex_position, 1.0); + v_model_pos = vertex_position; + v_model_normal = vertex_normal; + // Rotate normals into world space using the model matrix (no scale/shear). + v_world_normal = mat3(pc.model) * vertex_normal; +} +``` + +```glsl +// Fragment (GLSL 450) +#version 450 + +layout (location = 0) in vec3 v_model_pos; +layout (location = 1) in vec3 v_model_normal; +layout (location = 2) in vec3 v_world_normal; + +layout (location = 0) out vec4 fragment_color; + +layout (set = 0, binding = 1) uniform texture2D tex; +layout (set = 0, binding = 2) uniform sampler samp; + +// Project model-space position to 2D UVs based on the dominant normal axis. +vec2 project_uv(vec3 p, vec3 n) { + vec3 a = abs(n); + if (a.x > a.y && a.x > a.z) { + return p.zy * 0.5 + 0.5; // +/-X faces: map Z,Y + } else if (a.y > a.z) { + return p.xz * 0.5 + 0.5; // +/-Y faces: map X,Z + } else { + return p.xy * 0.5 + 0.5; // +/-Z faces: map X,Y + } +} + +void main() { + vec3 N_model = normalize(v_model_normal); + vec2 uv = clamp(project_uv(v_model_pos, N_model), 0.0, 1.0); + vec3 base = texture(sampler2D(tex, samp), uv).rgb; + + // Simple Lambert lighting to emphasize shape + vec3 N = normalize(v_world_normal); + vec3 L = normalize(vec3(0.4, 0.7, 1.0)); + float diff = max(dot(N, L), 0.0); + vec3 color = base * (0.25 + 0.75 * diff); + fragment_color = vec4(color, 1.0); +} +``` + +Compile these as `VirtualShader::Source` instances using `ShaderBuilder` during `on_attach` or `Default`. Keep the binding indices in the shader consistent with the Rust side. + +### Step 3 — Cube Mesh and Vertex Layout +Build a unit cube centered at the origin. The following snippet uses a helper to add a face as two triangles with a shared normal. Attribute layout matches the shaders: location 0 = position, 1 = normal, 2 = color (unused). + +```rust +use lambda::render::{ + mesh::{Mesh, MeshBuilder}, + vertex::{ColorFormat, Vertex, VertexAttribute, VertexBuilder, VertexElement}, +}; + +let mut verts: Vec = Vec::new(); +let mut add_face = |nx: f32, ny: f32, nz: f32, corners: [(f32, f32, f32); 4]| { + let n = [nx, ny, nz]; + let v = |p: (f32, f32, f32)| { + return VertexBuilder::new() + .with_position([p.0, p.1, p.2]) + .with_normal(n) + .with_color([0.0, 0.0, 0.0]) + .build(); + }; + // CCW winding: (0,1,2) and (0,2,3) + let p0 = v(corners[0]); + let p1 = v(corners[1]); + let p2 = v(corners[2]); + let p3 = v(corners[3]); + verts.extend([p0, p1, p2, p0, p2, p3]); +}; + +let h = 0.5f32; +// +X, -X, +Y, -Y, +Z, -Z (all CCW from the outside) +add_face( 1.0, 0.0, 0.0, [( h, -h, -h), ( h, h, -h), ( h, h, h), ( h, -h, h)]); +add_face(-1.0, 0.0, 0.0, [(-h, -h, -h), (-h, -h, h), (-h, h, h), (-h, h, -h)]); +add_face( 0.0, 1.0, 0.0, [(-h, h, h), ( h, h, h), ( h, h, -h), (-h, h, -h)]); +add_face( 0.0, -1.0, 0.0, [(-h, -h, -h), ( h, -h, -h), ( h, -h, h), (-h, -h, h)]); +add_face( 0.0, 0.0, 1.0, [(-h, -h, h), ( h, -h, h), ( h, h, h), (-h, h, h)]); +add_face( 0.0, 0.0, -1.0, [( h, -h, -h), (-h, -h, -h), (-h, h, -h), ( h, h, -h)]); + +let mut mesh_builder = MeshBuilder::new(); +verts.into_iter().for_each(|v| { mesh_builder.with_vertex(v); }); + +let mesh: Mesh = mesh_builder + .with_attributes(vec![ + VertexAttribute { // position @ location 0 + location: 0, offset: 0, + element: VertexElement { format: ColorFormat::Rgb32Sfloat, offset: 0 }, + }, + VertexAttribute { // normal @ location 1 + location: 1, offset: 0, + element: VertexElement { format: ColorFormat::Rgb32Sfloat, offset: 12 }, + }, + VertexAttribute { // color (unused) @ location 2 + location: 2, offset: 0, + element: VertexElement { format: ColorFormat::Rgb32Sfloat, offset: 24 }, + }, + ]) + .build(); +``` + +This produces 36 vertices (6 faces × 2 triangles × 3 vertices) with CCW winding and per‑face normals. + +### Step 4 — Build a 2D Checkerboard Texture +Generate a simple grayscale checkerboard and upload it as an sRGB 2D texture. + +```rust +use lambda::render::texture::{TextureBuilder, TextureFormat}; + +let tex_w = 64u32; +let tex_h = 64u32; +let mut pixels = vec![0u8; (tex_w * tex_h * 4) as usize]; +for y in 0..tex_h { + for x in 0..tex_w { + let i = ((y * tex_w + x) * 4) as usize; + let checker = ((x / 8) % 2) ^ ((y / 8) % 2); + let c: u8 = if checker == 0 { 40 } else { 220 }; + pixels[i + 0] = c; // R + pixels[i + 1] = c; // G + pixels[i + 2] = c; // B + pixels[i + 3] = 255; // A + } +} + +let texture2d = TextureBuilder::new_2d(TextureFormat::Rgba8UnormSrgb) + .with_size(tex_w, tex_h) + .with_data(&pixels) + .with_label("checkerboard") + .build(render_context) + .expect("Failed to create 2D texture"); +``` + +Using `Rgba8UnormSrgb` ensures sampling converts from sRGB to linear space before shading. + +### Step 5 — Create a Sampler +Create a linear filtering sampler with clamp‑to‑edge addressing. + +```rust +use lambda::render::texture::SamplerBuilder; + +let sampler = SamplerBuilder::new() + .linear_clamp() + .build(render_context); +``` + +### Step 6 — Bind Group Layout and Bind Group +Declare the layout and bind the texture and sampler at set 0, bindings 1 and 2. + +```rust +use lambda::render::bind::{BindGroupBuilder, BindGroupLayoutBuilder}; + +let layout = BindGroupLayoutBuilder::new() + .with_sampled_texture(1) + .with_sampler(2) + .build(render_context); + +let bind_group = BindGroupBuilder::new() + .with_layout(&layout) + .with_texture(1, &texture2d) + .with_sampler(2, &sampler) + .build(render_context); +``` + +### Step 7 — Render Pipeline with Depth and Culling +Enable depth, back‑face culling, and declare a vertex buffer built from the mesh. Add a push constant range for the vertex stage. + +```rust +use lambda::render::{ + buffer::BufferBuilder, + pipeline::{PipelineStage, RenderPipelineBuilder, CullingMode}, + render_pass::RenderPassBuilder, +}; + +let render_pass = RenderPassBuilder::new() + .with_label("textured-cube-pass") + .with_depth() + .build(render_context); + +let push_constants_size = std::mem::size_of::() as u32; + +let pipeline = RenderPipelineBuilder::new() + .with_culling(CullingMode::Back) + .with_depth() + .with_push_constant(PipelineStage::VERTEX, push_constants_size) + .with_buffer( + BufferBuilder::build_from_mesh(&mesh, render_context) + .expect("Failed to create vertex buffer"), + mesh.attributes().to_vec(), + ) + .with_layouts(&[&layout]) + .build(render_context, &render_pass, &self.shader_vs, Some(&self.shader_fs)); + +// Attach to obtain ResourceId handles +self.render_pass = Some(render_context.attach_render_pass(render_pass)); +self.render_pipeline = Some(render_context.attach_pipeline(pipeline)); +self.bind_group = Some(render_context.attach_bind_group(bind_group)); +``` + +### Step 8 — Per‑Frame Camera and Transforms +Compute yaw and pitch from elapsed time, build `model`, `view`, and perspective `projection`, then combine to an MVP matrix. Update `elapsed` in `on_update`. + +```rust +use lambda::render::scene_math::{compute_perspective_projection, compute_view_matrix, SimpleCamera}; + +// on_update +self.elapsed += last_frame.as_secs_f32(); + +// on_render +let camera = SimpleCamera { + position: [0.0, 0.0, 2.2], + field_of_view_in_turns: 0.24, + near_clipping_plane: 0.1, + far_clipping_plane: 100.0, +}; + +let angle_y_turns = 0.15 * self.elapsed; // yaw +let angle_x_turns = 0.10 * self.elapsed; // pitch + +let mut model = lambda::math::matrix::identity_matrix(4, 4); +model = lambda::math::matrix::rotate_matrix(model, [0.0, 1.0, 0.0], angle_y_turns); +model = lambda::math::matrix::rotate_matrix(model, [1.0, 0.0, 0.0], angle_x_turns); + +let view = compute_view_matrix(camera.position); +let projection = compute_perspective_projection( + camera.field_of_view_in_turns, + self.width.max(1), + self.height.max(1), + camera.near_clipping_plane, + camera.far_clipping_plane, +); +let mvp = projection.multiply(&view).multiply(&model); +``` + +This multiplication order produces clip‑space positions as `mvp * vec4(position, 1)`. The final upload transposes matrices to match GLSL column‑major layout. + +### Step 9 — Record Draw Commands with Push Constants +Define a push constant struct and a helper to reinterpret it as `[u32]`. Record commands to begin the pass, set pipeline state, bind the texture and sampler, push constants, and draw 36 vertices. + +```rust +#[repr(C)] +#[derive(Clone, Copy)] +pub struct PushConstant { + mvp: [[f32; 4]; 4], + model: [[f32; 4]; 4], +} + +pub fn push_constants_to_bytes(push_constants: &PushConstant) -> &[u32] { + unsafe { + let size_in_bytes = std::mem::size_of::(); + let size_in_u32 = size_in_bytes / std::mem::size_of::(); + let ptr = push_constants as *const PushConstant as *const u32; + return std::slice::from_raw_parts(ptr, size_in_u32); + } +} + +use lambda::render::{command::RenderCommand, pipeline::PipelineStage, viewport::ViewportBuilder}; + +let viewport = ViewportBuilder::new().build(self.width, self.height); +let pipeline = self.render_pipeline.expect("pipeline not set"); +let group = self.bind_group.expect("bind group not set"); +let mesh_len = self.mesh.as_ref().unwrap().vertices().len() as u32; + +let commands = vec![ + RenderCommand::BeginRenderPass { + render_pass: self.render_pass.expect("render pass not set"), + viewport: viewport.clone(), + }, + RenderCommand::SetPipeline { pipeline }, + RenderCommand::SetViewports { start_at: 0, viewports: vec![viewport.clone()] }, + RenderCommand::SetScissors { start_at: 0, viewports: vec![viewport.clone()] }, + RenderCommand::SetBindGroup { set: 0, group, dynamic_offsets: vec![] }, + RenderCommand::BindVertexBuffer { pipeline, buffer: 0 }, + RenderCommand::PushConstants { + pipeline, + stage: PipelineStage::VERTEX, + offset: 0, + bytes: Vec::from(push_constants_to_bytes(&PushConstant { + mvp: mvp.transpose(), + model: model.transpose(), + })), + }, + RenderCommand::Draw { vertices: 0..mesh_len }, + RenderCommand::EndRenderPass, +]; +``` + +### Step 10 — Handle Window Resize +Track window size from events so the projection and viewport use current dimensions. + +```rust +use lambda::events::{Events, WindowEvent}; + +fn on_event(&mut self, event: Events) -> Result { + if let Events::Window { event: WindowEvent::Resize { width, height }, .. } = event { + self.width = width; + self.height = height; + } + return Ok(ComponentResult::Success); +} +``` + +## Validation +- Build the workspace: `cargo build --workspace` +- Run the example: `cargo run -p lambda-rs --example textured_cube` +- Expected behavior: a spinning cube shows a gray checkerboard on all faces, shaded by a directional light. Hidden faces do not render due to back‑face culling and depth testing. + +## Notes +- Push constant limits: total size MUST be within the device’s push constant limit. This example uses 128 bytes, which fits common defaults. +- Matrix layout: GLSL multiplies column‑major by default; transposing on upload aligns memory layout and multiplication order. +- Normal transform: `mat3(model)` is correct when the model matrix contains only rotations and uniform scale. For non‑uniform scale, compute the normal matrix as the inverse‑transpose of the upper‑left 3×3. +- Texture color space: use `Rgba8UnormSrgb` for color images so sampling returns linear values. +- Winding and culling: keep face winding CCW to work with `CullingMode::Back`. Toggle to `CullingMode::None` when debugging geometry. +- Indices: the cube uses non‑indexed vertices for clarity. An index buffer SHOULD be used for efficiency in production code. + +## Conclusion +This tutorial delivered a rotating, textured cube with depth testing and +back‑face culling. It compiled shaders that use a vertex push‑constant block +for model‑view‑projection and model matrices, built a cube mesh and vertex +layout, created an sRGB texture and sampler, and constructed a pipeline with +depth and culling. Per‑frame transforms were computed and uploaded via push +constants, and draw commands were recorded. The result demonstrates push +constants for per‑draw transforms alongside 2D sampling in a 3D render path. + +## Putting It Together +- Full reference: `crates/lambda-rs/examples/textured_cube.rs` +- The example includes logging in `on_attach` and uses the same builders and commands shown here. + +## Exercises +- Exercise 1: Add roll + - Add a Z‑axis rotation to the model matrix and verify culling remains correct. +- Exercise 2: Nearest filtering + - Replace `linear_clamp()` with nearest filtering and observe pixelated edges. +- Exercise 3: Image texture + - Load a PNG or JPEG into RGBA bytes and upload with `TextureBuilder::new_2d`. +- Exercise 4: Normal matrix + - Add non‑uniform scale and implement a proper normal matrix for correct lighting. +- Exercise 5: Index buffer + - Replace the non‑indexed mesh with an indexed mesh and draw with an index buffer. +- Exercise 6: Phong or Blinn‑Phong + - Extend the fragment shader with specular highlights for shinier faces. +- Exercise 7: Multiple materials + - Bind two textures and blend per face based on projected UVs. + +## Changelog +- 0.1.1 (2025-11-10): Add Conclusion section summarizing outcomes; update metadata and commit. +- 0.1.0 (2025-11-10): Initial draft aligned with `crates/lambda-rs/examples/textured_cube.rs` including push constants, depth, culling, and projected UV sampling. diff --git a/docs/tutorials/textured-quad.md b/docs/tutorials/textured-quad.md new file mode 100644 index 00000000..d9ffbe82 --- /dev/null +++ b/docs/tutorials/textured-quad.md @@ -0,0 +1,480 @@ +--- +title: "Textured Quad: Sample a 2D Texture" +document_id: "textured-quad-tutorial-2025-11-01" +status: "draft" +created: "2025-11-01T00:00:00Z" +last_updated: "2025-11-10T03:00:00Z" +version: "0.3.3" +engine_workspace_version: "2023.1.30" +wgpu_version: "26.0.1" +shader_backend_default: "naga" +winit_version: "0.29.10" +repo_commit: "fe79756541e33270eca76638400bb64c6ec9f732" +owners: ["lambda-sh"] +reviewers: ["engine", "rendering"] +tags: ["tutorial", "graphics", "textures", "samplers", "rust", "wgpu"] +--- + +## Overview +This tutorial builds a textured quad using a sampled 2D texture and sampler. It covers creating pixel data on the central processing unit (CPU), uploading to a graphics processing unit (GPU) texture, defining a sampler, wiring a bind group layout, and sampling the texture in the fragment shader. + +Reference implementation: `crates/lambda-rs/examples/textured_quad.rs`. + +## Table of Contents +- [Overview](#overview) +- [Goals](#goals) +- [Prerequisites](#prerequisites) +- [Requirements and Constraints](#requirements-and-constraints) +- [Data Flow](#data-flow) +- [Implementation Steps](#implementation-steps) + - [Step 1 — Runtime and Component Skeleton](#step-1) + - [Step 2 — Vertex and Fragment Shaders](#step-2) + - [Step 3 — Mesh Data and Vertex Layout](#step-3) + - [Step 4 — Build a 2D Texture (Checkerboard)](#step-4) + - [Step 5 — Create a Sampler](#step-5) + - [Step 6 — Bind Group Layout and Bind Group](#step-6) + - [Step 7 — Create the Render Pipeline](#step-7) + - [Step 8 — Record Draw Commands](#step-8) + - [Step 9 — Handle Window Resize](#step-9) +- [Validation](#validation) +- [Notes](#notes) +- [Conclusion](#conclusion) +- [Putting It Together](#putting-it-together) +- [Exercises](#exercises) +- [Changelog](#changelog) + +## Goals + +- Render a screen‑space quad textured with a procedurally generated checkerboard. +- Define GLSL shader stages that pass texture coordinates (UV) and sample a 2D texture with a sampler. +- Create a `Texture` and `Sampler`, bind them in a layout, and draw using Lambda’s builders. + +## Prerequisites +- Workspace builds: `cargo build --workspace`. +- Run any example to verify setup: `cargo run --example minimal`. + +## Requirements and Constraints +- Binding indices MUST match between Rust and shaders: set 0, binding 1 is the 2D texture; set 0, binding 2 is the sampler. +- The example uses `TextureFormat::Rgba8UnormSrgb` so sampling converts from sRGB to linear space before shading. Rationale: produces correct color and filtering behavior for color images. +- The CPU pixel buffer length MUST equal `width * height * 4` bytes for `Rgba8*` formats. +- The vertex attribute at location 2 carries the UV in `.xy` (the example reuses the color field to pack UV for simplicity). + +## Data Flow + +``` +CPU pixels -> TextureBuilder (2D, sRGB) + -> GPU Texture + default view +SamplerBuilder -> GPU Sampler (linear, clamp) + +BindGroupLayout (set 0): binding 1 = texture2D, binding 2 = sampler +BindGroup (set 0): attach texture + sampler + +Render pass -> SetPipeline -> SetBindGroup -> Draw (fragment samples) +``` + +## Implementation Steps + +### Step 1 — Runtime and Component Skeleton +Create the application runtime and a `Component` that receives lifecycle +callbacks and a render context for resource creation and command submission. + +```rust +use lambda::{ + component::Component, + runtime::start_runtime, + runtimes::{ + application::ComponentResult, + ApplicationRuntimeBuilder, + }, +}; + +// Placement guidance +// - Resource creation (shaders, mesh, textures, pipeline): on_attach +// - Window resize handling: on_event +// - Rendering (record commands): on_render + +pub struct TexturedQuadExample { + // Shaders (created in Default and set again on attach) + shader_vs: lambda::render::shader::Shader, + shader_fs: lambda::render::shader::Shader, + // GPU resources (attached later) + mesh: Option, + render_pipeline: Option, + render_pass: Option, + bind_group: Option, + // window state + width: u32, + height: u32, +} + +impl Default for TexturedQuadExample { + fn default() -> Self { + use lambda::render::shader::{ShaderBuilder, ShaderKind, VirtualShader}; + let mut builder = ShaderBuilder::new(); + let shader_vs = builder.build(VirtualShader::Source { + source: VERTEX_SHADER_SOURCE.to_string(), + kind: ShaderKind::Vertex, + entry_point: "main".to_string(), + name: "textured-quad".to_string(), + }); + let shader_fs = builder.build(VirtualShader::Source { + source: FRAGMENT_SHADER_SOURCE.to_string(), + kind: ShaderKind::Fragment, + entry_point: "main".to_string(), + name: "textured-quad".to_string(), + }); + + return Self { + shader_vs, + shader_fs, + mesh: None, + render_pipeline: None, + render_pass: None, + bind_group: None, + width: 800, + height: 600, + }; + } +} + +// Minimal component scaffold to place later steps +impl Component for TexturedQuadExample { + fn on_attach( + &mut self, + _render_context: &mut lambda::render::RenderContext, + ) -> Result { + return Ok(ComponentResult::Success); + } + + fn on_event(&mut self, _event: lambda::events::Events) -> Result { + return Ok(ComponentResult::Success); + } + + fn on_render( + &mut self, + _render_context: &mut lambda::render::RenderContext, + ) -> Vec { + return vec![]; + } +} + +fn main() { + let runtime = ApplicationRuntimeBuilder::new("Textured Quad Example") + .with_window_configured_as(|builder| builder.with_dimensions(800, 600).with_name("Textured Quad")) + .with_component(|runtime, example: TexturedQuadExample| { return (runtime, example); }) + .build(); + + start_runtime(runtime); +} +``` + +This scaffold establishes the runtime entry point and a component that participates in the engine lifecycle. The struct stores shader handles and placeholders for GPU resources that will be created during attachment. The `Default` implementation compiles inline GLSL into `Shader` objects up front so pipeline creation can proceed deterministically. At this stage the window is created and ready; no rendering occurs yet. + +### Step 2 — Vertex and Fragment Shaders +Define GLSL 450 shaders. The vertex shader forwards UV to the fragment shader; the fragment samples `sampler2D(tex, samp)`. + +Place these constants near the top of `textured_quad.rs`: + +```rust +const VERTEX_SHADER_SOURCE: &str = r#" +#version 450 + +layout (location = 0) in vec3 vertex_position; +layout (location = 2) in vec3 vertex_color; // uv packed into .xy + +layout (location = 0) out vec2 v_uv; + +void main() { + gl_Position = vec4(vertex_position, 1.0); + v_uv = vertex_color.xy; +} +"#; + +const FRAGMENT_SHADER_SOURCE: &str = r#" +#version 450 + +layout (location = 0) in vec2 v_uv; +layout (location = 0) out vec4 fragment_color; + +layout (set = 0, binding = 1) uniform texture2D tex; +layout (set = 0, binding = 2) uniform sampler samp; + +void main() { + fragment_color = texture(sampler2D(tex, samp), v_uv); +} +"#; +``` + +These constants define the GPU programs. The vertex stage forwards texture coordinates by packing UV into the color attribute’s `.xy` at location 2; the fragment stage samples a 2D texture using a separate sampler bound at set 0, bindings 1 and 2. Keeping the sources inline makes binding indices explicit and co‑located with the Rust layout defined later. + +Placement: on_attach (store in `self`). + +```rust +use lambda::render::shader::{ShaderBuilder, ShaderKind, VirtualShader}; + +let mut shader_builder = ShaderBuilder::new(); +let shader_vs = shader_builder.build(VirtualShader::Source { + source: VERTEX_SHADER_SOURCE.to_string(), + kind: ShaderKind::Vertex, + entry_point: "main".to_string(), + name: "textured-quad".to_string(), +}); +let shader_fs = shader_builder.build(VirtualShader::Source { + source: FRAGMENT_SHADER_SOURCE.to_string(), + kind: ShaderKind::Fragment, + entry_point: "main".to_string(), + name: "textured-quad".to_string(), +}); +// Keep local variables for pipeline creation and persist on the component. +self.shader_vs = shader_vs; +self.shader_fs = shader_fs; +``` + +This compiles the virtual shaders to SPIR‑V using the engine’s shader builder and stores the resulting `Shader` objects on the component. The shaders are now ready for pipeline creation; drawing will begin only after a pipeline and render pass are created and attached. + +### Step 3 — Mesh Data and Vertex Layout +Placement: on_attach. + +Define two triangles forming a quad. Pack UV into the vertex attribute at +location 2 using the color slot’s `.xy` for simplicity. + +```rust +use lambda::render::{ + mesh::{Mesh, MeshBuilder}, + vertex::{Vertex, VertexAttribute, VertexBuilder, VertexElement}, + ColorFormat, +}; + +let vertices: [Vertex; 6] = [ + VertexBuilder::new().with_position([-0.5, -0.5, 0.0]).with_normal([0.0, 0.0, 1.0]).with_color([0.0, 0.0, 0.0]).build(), // uv (0,0) + VertexBuilder::new().with_position([ 0.5, -0.5, 0.0]).with_normal([0.0, 0.0, 1.0]).with_color([1.0, 0.0, 0.0]).build(), // uv (1,0) + VertexBuilder::new().with_position([ 0.5, 0.5, 0.0]).with_normal([0.0, 0.0, 1.0]).with_color([1.0, 1.0, 0.0]).build(), // uv (1,1) + VertexBuilder::new().with_position([-0.5, -0.5, 0.0]).with_normal([0.0, 0.0, 1.0]).with_color([0.0, 0.0, 0.0]).build(), // uv (0,0) + VertexBuilder::new().with_position([ 0.5, 0.5, 0.0]).with_normal([0.0, 0.0, 1.0]).with_color([1.0, 1.0, 0.0]).build(), // uv (1,1) + VertexBuilder::new().with_position([-0.5, 0.5, 0.0]).with_normal([0.0, 0.0, 1.0]).with_color([0.0, 1.0, 0.0]).build(), // uv (0,1) +]; + +let mut mesh_builder = MeshBuilder::new(); +vertices.iter().for_each(|v| { mesh_builder.with_vertex(*v); }); + +let mesh: Mesh = mesh_builder + .with_attributes(vec![ + VertexAttribute { // position @ location 0 + location: 0, offset: 0, + element: VertexElement { format: ColorFormat::Rgb32Sfloat, offset: 0 }, + }, + VertexAttribute { // normal @ location 1 + location: 1, offset: 0, + element: VertexElement { format: ColorFormat::Rgb32Sfloat, offset: 12 }, + }, + VertexAttribute { // color (uv.xy) @ location 2 + location: 2, offset: 0, + element: VertexElement { format: ColorFormat::Rgb32Sfloat, offset: 24 }, + }, + ]) + .build(); +// Persist on component state for later use +self.mesh = Some(mesh); +``` + +This builds a quad from two triangles and declares the vertex attribute layout that the shaders consume. Positions map to location 0, normals to location 1, and UVs are encoded in the color field at location 2. The mesh currently resides on the CPU; a vertex buffer is created when building the pipeline. + +### Step 4 — Build a 2D Texture (Checkerboard) +Placement: on_attach. + +Generate a simple checkerboard and upload it as an sRGB 2D texture. + +```rust +use lambda::render::texture::{TextureBuilder, TextureFormat}; + +let texture_width = 64u32; +let texture_height = 64u32; +let mut pixels = vec![0u8; (texture_width * texture_height * 4) as usize]; +for y in 0..texture_height { + for x in 0..texture_width { + let i = ((y * texture_width + x) * 4) as usize; + let checker = ((x / 8) % 2) ^ ((y / 8) % 2); + let c = if checker == 0 { 40 } else { 220 }; + pixels[i + 0] = c; // R + pixels[i + 1] = c; // G + pixels[i + 2] = c; // B + pixels[i + 3] = 255; // A + } +} + +let texture = TextureBuilder::new_2d(TextureFormat::Rgba8UnormSrgb) + .with_size(texture_width, texture_height) + .with_data(&pixels) + .with_label("checkerboard") + .build(render_context) + .expect("Failed to create texture"); +``` + +This produces a GPU texture in `Rgba8UnormSrgb` format containing a checkerboard pattern. The builder uploads the CPU byte buffer and returns a handle suitable for binding. Using an sRGB color format ensures correct linearization during sampling in the fragment shader. + +### Step 5 — Create a Sampler +Create a linear filtering sampler with clamp‑to‑edge addressing. + +```rust +use lambda::render::texture::SamplerBuilder; + +let sampler = SamplerBuilder::new() + .linear_clamp() + .with_label("linear-clamp") + .build(render_context); +``` + +This sampler selects linear minification and magnification with clamp‑to‑edge addressing. Linear filtering smooths the checkerboard when scaled, while clamping prevents wrapping at the texture borders. + +### Step 6 — Bind Group Layout and Bind Group +Declare the layout and bind the texture and sampler at set 0, bindings 1 and 2. + +```rust +use lambda::render::bind::{BindGroupLayoutBuilder, BindGroupBuilder}; + +let layout = BindGroupLayoutBuilder::new() + .with_sampled_texture(1) // texture2D at binding 1 + .with_sampler(2) // sampler at binding 2 + .build(render_context); + +let bind_group = BindGroupBuilder::new() + .with_layout(&layout) + .with_texture(1, &texture) + .with_sampler(2, &sampler) + .build(render_context); +``` + +The bind group layout declares the shader‑visible interface for set 0: a sampled `texture2D` at binding 1 and a `sampler` at binding 2. The bind group then binds the concrete texture and sampler objects to those indices so the fragment shader can sample them during rendering. + +### Step 7 — Create the Render Pipeline +Build a pipeline that consumes the mesh vertex buffer and the layout. Disable face culling for simplicity. + +```rust +use lambda::render::{ + buffer::BufferBuilder, + pipeline::{RenderPipelineBuilder, CullingMode}, + render_pass::RenderPassBuilder, +}; + +let render_pass = RenderPassBuilder::new() + .with_label("textured-quad-pass") + .build(render_context); + +let mesh = self.mesh.as_ref().expect("mesh must be created"); + +let pipeline = RenderPipelineBuilder::new() + .with_culling(CullingMode::None) + .with_layouts(&[&layout]) + .with_buffer( + BufferBuilder::build_from_mesh(mesh, render_context) + .expect("Failed to create vertex buffer"), + mesh.attributes().to_vec(), + ) + .build(render_context, &render_pass, &self.shader_vs, Some(&self.shader_fs)); + +// Attach resources to obtain `ResourceId`s for rendering +self.render_pass = Some(render_context.attach_render_pass(render_pass)); +self.render_pipeline = Some(render_context.attach_pipeline(pipeline)); +self.bind_group = Some(render_context.attach_bind_group(bind_group)); +``` + +The render pass targets the surface’s color attachment. The pipeline uses the compiled shaders, disables face culling for clarity, and declares a vertex buffer built from the mesh with attribute descriptors that match the shader locations. Attaching the pass, pipeline, and bind group to the render context yields stable `ResourceId`s that render commands will reference. + +### Step 8 — Record Draw Commands +Center a square viewport inside the window, bind pipeline, bind group, and draw six vertices. + +```rust +use lambda::render::{command::RenderCommand, viewport::ViewportBuilder}; + +let win_w = self.width.max(1); +let win_h = self.height.max(1); +let side = u32::min(win_w, win_h); +let x = ((win_w - side) / 2) as i32; +let y = ((win_h - side) / 2) as i32; +let viewport = ViewportBuilder::new().with_coordinates(x, y).build(side, side); + +let commands = vec![ + RenderCommand::BeginRenderPass { + render_pass: self.render_pass.expect("render pass not set"), + viewport, + }, + RenderCommand::SetPipeline { + pipeline: self.render_pipeline.expect("pipeline not set"), + }, + RenderCommand::SetBindGroup { + set: 0, + group: self.bind_group.expect("bind group not set"), + dynamic_offsets: vec![], + }, + RenderCommand::BindVertexBuffer { + pipeline: self.render_pipeline.expect("pipeline not set"), + buffer: 0, + }, + RenderCommand::Draw { vertices: 0..6 }, + RenderCommand::EndRenderPass, +]; +``` + +These commands open a render pass with a centered square viewport, select the pipeline, bind the texture and sampler group at set 0, bind the vertex buffer at slot 0, draw six vertices, and end the pass. When submitted, they render a textured quad while preserving aspect ratio via the viewport. + +### Step 9 — Handle Window Resize +Track window size from events and recompute the centered square viewport. + +```rust +use lambda::events::{Events, WindowEvent}; + +fn on_event(&mut self, event: Events) -> Result { + if let Events::Window { event: WindowEvent::Resize { width, height }, .. } = event { + self.width = width; + self.height = height; + } + return Ok(ComponentResult::Success); +} +``` + +This event handler updates the stored window dimensions when a resize occurs. The render path uses these values to recompute the centered square viewport so the quad remains square and centered as the window changes size. + +## Validation +- Build the workspace: `cargo build --workspace` +- Run the example (workspace root): `cargo run --example textured_quad` + - If needed, specify the package: `cargo run -p lambda-rs --example textured_quad` +- Expected behavior: a centered square quad shows a gray checkerboard. Resizing the window preserves square aspect ratio by letterboxing with the viewport. With linear filtering, downscaling appears smooth. + +## Notes +- sRGB vs linear formats: `Rgba8UnormSrgb` SHOULD be used for color images so sampling converts to linear space automatically. Use non‑sRGB formats (for example, `Rgba8Unorm`) for data textures like normal maps. +- Binding indices: The `BindGroupLayout` and `BindGroup` indices MUST match shader `set` and `binding` qualifiers. Mismatches surface as validation errors. +- Vertex attributes: Packing UV into the color slot is a simplification for the example. Defining a dedicated UV attribute at its own location is RECOMMENDED for production code. +- Filtering and addressing: `linear_clamp` sets linear min/mag and clamp‑to‑edge. Pixel art MAY prefer `nearest_*`. Tiling textures SHOULD use `Repeat` address modes. +- Pipeline layout: Include all used layouts via `.with_layouts(...)` when creating the pipeline; otherwise binding state is incomplete at draw time. + +## Conclusion +This tutorial implemented a complete 2D sampling path. It generated a +checkerboard on the CPU, uploaded it as an sRGB texture, created a +linear‑clamp sampler, and defined matching binding layouts. Shaders forwarded +UV and sampled the texture; a mesh and render pipeline were built; commands +were recorded using a centered viewport. The result renders a textured quad +with correct color space handling and filtering. + +## Putting It Together +- Full reference: `crates/lambda-rs/examples/textured_quad.rs` +- Minimal differences: the example includes empty `on_detach` and `on_update` hooks and a log line in `on_attach`. + +## Exercises +- Exercise 1: Nearest filtering + - Replace `linear_clamp()` with `nearest_clamp()` and observe sharper scaling. +- Exercise 2: Repeat addressing + - Change UVs to extend beyond `[0, 1]` and use repeat addressing to tile the checkerboard. +- Exercise 3: Load an image file + - Decode a PNG into RGBA bytes (any Rust image decoder) and upload with `TextureBuilder::new_2d`. +- Exercise 4: Vertical flip + - Flip UV.y to compare conventions; document expected orientation. +- Exercise 5: Dual‑texture blend + - Bind two textures and blend them in the fragment shader based on UV. +- Exercise 6: Mipmap exploration (conceptual) + - Discuss artifacts without mipmaps and how multiple levels would improve minification. + +## Changelog +- 0.3.3 (2025-11-10): Add Conclusion section summarizing outcomes; update metadata and commit. +- 0.3.2 (2025-11-10): Add narrative explanations after each code block; clarify lifecycle and binding flow. +- 0.3.1 (2025-11-10): Align with example; add shader constants; attach resources; fix variable names; add missing section. +- 0.3.0 (2025-11-01): Initial draft aligned with `crates/lambda-rs/examples/textured_quad.rs`. diff --git a/docs/tutorials/uniform-buffers.md b/docs/tutorials/uniform-buffers.md index ecaa24d2..c399cd5b 100644 --- a/docs/tutorials/uniform-buffers.md +++ b/docs/tutorials/uniform-buffers.md @@ -3,13 +3,13 @@ title: "Uniform Buffers: Build a Spinning Triangle" document_id: "uniform-buffers-tutorial-2025-10-17" status: "draft" created: "2025-10-17T00:00:00Z" -last_updated: "2025-10-30T00:10:00Z" -version: "0.4.0" +last_updated: "2025-11-10T03:00:00Z" +version: "0.4.1" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "88e99500def9a1c1a123c960ba46b5ba7bdc7bab" +repo_commit: "fe79756541e33270eca76638400bb64c6ec9f732" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["tutorial", "graphics", "uniform-buffers", "rust", "wgpu"] @@ -39,6 +39,7 @@ Reference implementation: `crates/lambda-rs/examples/uniform_buffer_triangle.rs` - [Step 10 — Handle Window Resize](#step-10) - [Validation](#validation) - [Notes](#notes) +- [Conclusion](#conclusion) - [Exercises](#exercises) - [Changelog](#changelog) @@ -368,6 +369,15 @@ fn on_event(&mut self, event: Events) -> Result { - Update strategy: `CPU_VISIBLE` buffers SHOULD be used for per‑frame updates; device‑local memory MAY be preferred for static data. - Pipeline layout: All bind group layouts used by the pipeline MUST be included via `.with_layouts(...)`. +## Conclusion +This tutorial produced a spinning triangle that reads a model‑view‑projection +matrix from a uniform buffer. The implementation aligned the shader and Rust +layouts, created shaders and a mesh, defined a bind group layout and uniform +buffer, built a render pipeline, wrote per‑frame matrix updates from the CPU, +and recorded draw commands with resize‑aware projection. The result establishes +a minimal, reusable path for per‑frame data via uniform buffers that scales to +multiple objects and passes. + ## Exercises - Exercise 1: Time‑based fragment color @@ -402,6 +412,9 @@ fn on_event(&mut self, event: Events) -> Result { ## Changelog +- 0.4.1 (2025‑11‑10): Add Conclusion section summarizing accomplishments; update +metadata and commit. + - 0.4.0 (2025‑10‑30): Added table of contents with links; converted sections to anchored headings; added ASCII data flow diagram; metadata updated. - 0.2.0 (2025‑10‑17): Added goals and book‑style step explanations; expanded rationale before code blocks; refined validation and notes.