From ba6e707ec4a699b0845bec674f15d950639d76b4 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 30 Oct 2025 15:36:22 -0700 Subject: [PATCH 01/27] [add] initial spec for textures and samplers. --- docs/specs/textures-and-samplers.md | 368 ++++++++++++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 docs/specs/textures-and-samplers.md diff --git a/docs/specs/textures-and-samplers.md b/docs/specs/textures-and-samplers.md new file mode 100644 index 00000000..f202f3f2 --- /dev/null +++ b/docs/specs/textures-and-samplers.md @@ -0,0 +1,368 @@ +--- +title: "Textures and Samplers" +document_id: "texture-sampler-spec-2025-10-30" +status: "draft" +created: "2025-10-30T00:00:00Z" +last_updated: "2025-10-30T00:10: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: "d3dc4356c165c596e0b9f84b3687b1018eeb1a91" +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. +- Multisampled textures and render target (color attachment) workflows. +- Mipmap generation (automatic or offline); only level 0 is supported. +- Partial sub-rect updates; uploads are whole-image at creation time. + +## 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`, `TextureView`, `Sampler` (own raw `wgpu` handles). + - 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)` -> `Result` + +- 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()`). + +- Bind group integration (`lambda::render::bind`) + - `BindGroupLayoutBuilder` additions: + - `with_sampled_texture(binding: u32)` — 2D, filterable float; shorthand. + - `with_sampled_texture_dim(binding: u32, dim: ViewDimension)` — explicit + dimension (`D2` or `D3`), float sample type, not multisampled. + - `with_sampler(binding: u32)` — filtering sampler type. + - `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; shaders declare `texture_2d`. + - `with_sampled_texture_dim` declares a texture binding with explicit view + dimension; shaders declare `texture_2d` or `texture_3d`. + - `with_sampler` declares a filtering sampler binding at the specified index; + 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`. + +- 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 + - [ ] Feature flags defined (if applicable) + - [ ] 2D texture creation and upload + - [ ] 3D texture creation and upload + - [ ] Sampler creation (U, V, W addressing) + - [ ] Bind group layout and binding for texture + sampler (2D/3D) +- API Surface + - [ ] Public builders and enums in `lambda-rs` + - [ ] Platform wrappers in `lambda-rs-platform` + - [ ] Backwards compatibility assessed +- Validation and Errors + - [ ] Dimension and limit checks (2D/3D) + - [ ] Format compatibility checks + - [ ] Data length and row padding/rows-per-image validation +- Performance + - [ ] Upload path reasoned and documented + - [ ] Memory footprint characterized for common formats +- Documentation and Examples + - [ ] User-facing docs updated + - [ ] Minimal example rendering a textured triangle + - [ ] Migration notes (if applicable) + +## 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::Vertex | BindingVisibility::Fragment) + .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; + +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) + .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-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. From 549f0a1c2ca309cdf3f4bbdfa230ea3ce8d8cfe4 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 30 Oct 2025 15:36:34 -0700 Subject: [PATCH 02/27] [remove] docs from ignore. --- .ignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.ignore b/.ignore index eca8cf50..a3c8e83d 100644 --- a/.ignore +++ b/.ignore @@ -1,4 +1,3 @@ -docs archive/lambda_cpp/vendor \.git target From b6865b42a1b6f92d9b769b53e59d94a51e2041ce Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 30 Oct 2025 15:46:12 -0700 Subject: [PATCH 03/27] [add] enums and helpers for mapping wgpu types. --- crates/lambda-rs-platform/src/wgpu/mod.rs | 1 + crates/lambda-rs-platform/src/wgpu/texture.rs | 163 ++++++++++++++++++ crates/lambda-rs/src/render/mod.rs | 1 + crates/lambda-rs/src/render/texture.rs | 6 + 4 files changed, 171 insertions(+) create mode 100644 crates/lambda-rs-platform/src/wgpu/texture.rs create mode 100644 crates/lambda-rs/src/render/texture.rs diff --git a/crates/lambda-rs-platform/src/wgpu/mod.rs b/crates/lambda-rs-platform/src/wgpu/mod.rs index b15807dc..56842a31 100644 --- a/crates/lambda-rs-platform/src/wgpu/mod.rs +++ b/crates/lambda-rs-platform/src/wgpu/mod.rs @@ -16,6 +16,7 @@ use wgpu::rwh::{ use crate::winit::WindowHandle; pub mod bind; +pub mod texture; #[derive(Debug, Clone)] /// Builder for creating a `wgpu::Instance` with consistent defaults. 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..20ccbf57 --- /dev/null +++ b/crates/lambda-rs-platform/src/wgpu/texture.rs @@ -0,0 +1,163 @@ +//! 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 super::types as wgpu; + +/// 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, + }; + } +} + +/// 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, + }; + } +} + +#[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 + ); + } +} diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index 7c3072af..b2ddc8ba 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -10,6 +10,7 @@ pub mod pipeline; pub mod render_pass; pub mod scene_math; pub mod shader; +pub mod texture; pub mod validation; pub mod vertex; pub mod viewport; diff --git a/crates/lambda-rs/src/render/texture.rs b/crates/lambda-rs/src/render/texture.rs new file mode 100644 index 00000000..e36b886e --- /dev/null +++ b/crates/lambda-rs/src/render/texture.rs @@ -0,0 +1,6 @@ +//! High‑level texture and sampler scaffolding. +//! +//! This module will expose `TextureBuilder`, `SamplerBuilder`, and supporting +//! enums/types as stable, `wgpu`-agnostic wrappers that delegate to the +//! platform layer. It is intentionally empty in Milestone 0 and will be +//! incrementally filled in later milestones per the textures/samplers spec. From 6851576c34a5a4864030eb7cc9118d0407d9099e Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 30 Oct 2025 15:58:08 -0700 Subject: [PATCH 04/27] [add] texture builder and texture implementations. --- crates/lambda-rs-platform/src/wgpu/texture.rs | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) diff --git a/crates/lambda-rs-platform/src/wgpu/texture.rs b/crates/lambda-rs-platform/src/wgpu/texture.rs index 20ccbf57..9e20a966 100644 --- a/crates/lambda-rs-platform/src/wgpu/texture.rs +++ b/crates/lambda-rs-platform/src/wgpu/texture.rs @@ -6,6 +6,22 @@ use super::types as wgpu; +#[derive(Debug)] +/// Errors returned when building textures fails validation. +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, +} + +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 { @@ -96,6 +112,194 @@ impl ViewDimension { } } +#[derive(Debug)] +/// Wrapper around `wgpu::Texture` and its default `TextureView`. +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 2D sampled texture with optional initial data upload. +pub struct TextureBuilder { + label: Option, + format: TextureFormat, + dimension: TextureDimension, + width: u32, + height: u32, + usage_texture_binding: bool, + usage_copy_dst: bool, + data: Option>, // tightly packed rows (width * bpp) +} + +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, + 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; + 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, + device: &wgpu::Device, + queue: &wgpu::Queue, + ) -> Result { + // Validate dimensions + if self.width == 0 || self.height == 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: 1, + }; + + // Validate data length if provided + if let Some(ref pixels) = self.data { + let bpp = self.format.bytes_per_pixel() as usize; + let expected = (self.width as usize) + .checked_mul(self.height 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 = device.create_texture(&descriptor); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + 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. + let total_bytes = (padded_row_bytes as u64) + .checked_mul(self.height as u64) + .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; + 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)]); + } + + 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, + }; + + queue.write_texture(copy_dst, &staging, data_layout, size); + } + + return Ok(Texture { + raw: texture, + view, + label: self.label, + }); + } +} + #[cfg(test)] mod tests { use super::*; @@ -160,4 +364,13 @@ mod tests { 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); + } } From d040ebe4d4b8f3f5dc5baa4a44ad8e5fafae1535 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 31 Oct 2025 13:00:51 -0700 Subject: [PATCH 05/27] [add] texture sampler API in both platform and lambda-rs crates. --- crates/lambda-rs-platform/src/wgpu/bind.rs | 105 +++++++++++ crates/lambda-rs-platform/src/wgpu/texture.rs | 170 +++++++++++++++++ crates/lambda-rs/src/render/bind.rs | 75 +++++++- crates/lambda-rs/src/render/texture.rs | 177 +++++++++++++++++- 4 files changed, 520 insertions(+), 7 deletions(-) diff --git a/crates/lambda-rs-platform/src/wgpu/bind.rs b/crates/lambda-rs-platform/src/wgpu/bind.rs index 0b47fdc4..0a88a431 100644 --- a/crates/lambda-rs-platform/src/wgpu/bind.rs +++ b/crates/lambda-rs-platform/src/wgpu/bind.rs @@ -92,6 +92,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"), + } + } } #[derive(Default)] @@ -150,6 +179,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: wgpu::TextureViewDimension, + ) -> Self { + self.entries.push(wgpu::BindGroupLayoutEntry { + binding, + visibility: visibility.to_wgpu(), + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension, + 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, device: &wgpu::Device) -> BindGroupLayout { let raw = @@ -213,6 +292,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, device: &wgpu::Device) -> BindGroup { let layout = self diff --git a/crates/lambda-rs-platform/src/wgpu/texture.rs b/crates/lambda-rs-platform/src/wgpu/texture.rs index 9e20a966..41437577 100644 --- a/crates/lambda-rs-platform/src/wgpu/texture.rs +++ b/crates/lambda-rs-platform/src/wgpu/texture.rs @@ -112,6 +112,148 @@ impl ViewDimension { } } +#[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, device: &wgpu::Device) -> Sampler { + let desc = self.to_descriptor(); + let raw = device.create_sampler(&desc); + return Sampler { + raw, + label: self.label, + }; + } +} #[derive(Debug)] /// Wrapper around `wgpu::Texture` and its default `TextureView`. pub struct Texture { @@ -373,4 +515,32 @@ mod tests { 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/src/render/bind.rs b/crates/lambda-rs/src/render/bind.rs index a2aff134..0989ef06 100644 --- a/crates/lambda-rs/src/render/bind.rs +++ b/crates/lambda-rs/src/render/bind.rs @@ -10,6 +10,10 @@ use lambda_platform::wgpu::types as wgpu; use super::{ buffer::Buffer, + texture::{ + Sampler, + Texture, + }, RenderContext, }; @@ -108,6 +112,8 @@ impl BindGroup { pub struct BindGroupLayoutBuilder { label: Option, entries: Vec<(u32, BindingVisibility, bool)>, + textures_2d: Vec<(u32, BindingVisibility)>, + samplers: Vec<(u32, BindingVisibility)>, } impl BindGroupLayoutBuilder { @@ -116,6 +122,8 @@ impl BindGroupLayoutBuilder { Self { label: None, entries: Vec::new(), + textures_2d: Vec::new(), + samplers: Vec::new(), } } @@ -145,6 +153,20 @@ 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; + } + /// Build the layout using the `RenderContext` device. pub fn build(self, render_context: &RenderContext) -> BindGroupLayout { let mut builder = @@ -152,10 +174,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), @@ -163,6 +184,20 @@ impl BindGroupLayoutBuilder { binding ); } + for (binding, _) in &self.textures_2d { + 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 = @@ -180,6 +215,15 @@ impl BindGroupLayoutBuilder { }; } + for (binding, visibility) in self.textures_2d.into_iter() { + builder = + builder.with_sampled_texture_2d(binding, visibility.to_platform()); + } + + for (binding, visibility) in self.samplers.into_iter() { + builder = builder.with_sampler(binding, visibility.to_platform()); + } + let layout = builder.build(render_context.device()); return BindGroupLayout { @@ -194,6 +238,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> { @@ -203,6 +249,8 @@ impl<'a> BindGroupBuilder<'a> { label: None, layout: None, entries: Vec::new(), + textures: Vec::new(), + samplers: Vec::new(), }; } @@ -230,6 +278,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 @@ -258,6 +318,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.device()); return BindGroup { group: Rc::new(group), diff --git a/crates/lambda-rs/src/render/texture.rs b/crates/lambda-rs/src/render/texture.rs index e36b886e..62376a94 100644 --- a/crates/lambda-rs/src/render/texture.rs +++ b/crates/lambda-rs/src/render/texture.rs @@ -1,6 +1,173 @@ -//! High‑level texture and sampler scaffolding. +//! High‑level textures and samplers. //! -//! This module will expose `TextureBuilder`, `SamplerBuilder`, and supporting -//! enums/types as stable, `wgpu`-agnostic wrappers that delegate to the -//! platform layer. It is intentionally empty in Milestone 0 and will be -//! incrementally filled in later milestones per the textures/samplers spec. +//! 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)] +/// 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, + 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, + 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; + 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 = + platform::TextureBuilder::new_2d(self.format.to_platform()) + .with_size(self.width, self.height); + if let Some(ref label) = self.label { + builder = builder.with_label(label); + } + if let Some(ref pixels) = self.data { + builder = builder.with_data(pixels); + } + match builder.build(render_context.device(), render_context.queue()) { + 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; + } + + /// 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.device()); + return Sampler { + inner: Rc::new(sampler), + }; + } +} From 02a57cd1f125424995fca939a7ca872b6a8c8980 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 31 Oct 2025 14:17:38 -0700 Subject: [PATCH 06/27] [add] 2D textured quad demo. --- crates/lambda-rs/examples/textured_quad.rs | 340 +++++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 crates/lambda-rs/examples/textured_quad.rs diff --git a/crates/lambda-rs/examples/textured_quad.rs b/crates/lambda-rs/examples/textured_quad.rs new file mode 100644 index 00000000..911b1642 --- /dev/null +++ b/crates/lambda-rs/examples/textured_quad.rs @@ -0,0 +1,340 @@ +#![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::{ + Vertex, + VertexAttribute, + VertexBuilder, + VertexElement, + }, + viewport::ViewportBuilder, + ColorFormat, + ResourceId, + }, + runtime::start_runtime, + runtimes::{ + application::ComponentResult, + ApplicationRuntime, + ApplicationRuntimeBuilder, + }, +}; + +// ------------------------------ SHADER SOURCE -------------------------------- + +const VERTEX_SHADER_SOURCE: &str = r#" +#version 450 + +layout (location = 0) in vec3 vertex_position; +layout (location = 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 { + 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![]; + let viewport = + ViewportBuilder::new().build(self.width.max(1), self.height.max(1)); + 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); +} From 33777ca4e1e1d79a342c6ac50f81e320cdb5bb9b Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 31 Oct 2025 14:20:25 -0700 Subject: [PATCH 07/27] [fix] quad size. --- crates/lambda-rs/examples/textured_quad.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/lambda-rs/examples/textured_quad.rs b/crates/lambda-rs/examples/textured_quad.rs index 911b1642..0bd62a6a 100644 --- a/crates/lambda-rs/examples/textured_quad.rs +++ b/crates/lambda-rs/examples/textured_quad.rs @@ -272,8 +272,15 @@ impl Component for TexturedQuadExample { _render_context: &mut lambda::render::RenderContext, ) -> Vec { let mut commands = vec![]; - let viewport = - ViewportBuilder::new().build(self.width.max(1), self.height.max(1)); + // 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, From 2d6e9ef936506b722da767bb16bb2526ea7b6d2d Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 31 Oct 2025 14:24:18 -0700 Subject: [PATCH 08/27] [fix] quad centering. --- crates/lambda-rs/examples/textured_quad.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/lambda-rs/examples/textured_quad.rs b/crates/lambda-rs/examples/textured_quad.rs index 0bd62a6a..61d12e8c 100644 --- a/crates/lambda-rs/examples/textured_quad.rs +++ b/crates/lambda-rs/examples/textured_quad.rs @@ -256,7 +256,15 @@ impl Component for TexturedQuadExample { return Ok(ComponentResult::Success); } - fn on_event(&mut self, _event: Events) -> Result { + 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); } From d77334a6f77f0166fe8c6fbe48d4264a3f857fae Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 31 Oct 2025 15:56:41 -0700 Subject: [PATCH 09/27] [add] integration tests. --- crates/lambda-rs/tests/runnables.rs | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 crates/lambda-rs/tests/runnables.rs 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() {} From af141177fc8e15d73c267f079685fd6c68ce7b05 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 31 Oct 2025 15:56:59 -0700 Subject: [PATCH 10/27] [add] integration tests. --- ...u_bind_layout_and_group_texture_sampler.rs | 50 ++++++++++++++++ .../tests/wgpu_texture_build_and_upload.rs | 60 +++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 crates/lambda-rs-platform/tests/wgpu_bind_layout_and_group_texture_sampler.rs create mode 100644 crates/lambda-rs-platform/tests/wgpu_texture_build_and_upload.rs 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..09fabfbe --- /dev/null +++ b/crates/lambda-rs-platform/tests/wgpu_bind_layout_and_group_texture_sampler.rs @@ -0,0 +1,50 @@ +#![allow(clippy::needless_return)] + +// Integration tests for `lambda-rs-platform::wgpu::bind` with textures/samplers + +fn create_test_device() -> lambda_platform::wgpu::Gpu { + let instance = lambda_platform::wgpu::InstanceBuilder::new() + .with_label("platform-bind-itest") + .build(); + return lambda_platform::wgpu::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 device = gpu.device(); + let queue = gpu.queue(); + + 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(device, queue) + .expect("texture created"); + + let sampler = lambda_platform::wgpu::texture::SamplerBuilder::new() + .nearest_clamp() + .with_label("p-itest-bind-sampler") + .build(device); + + 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(device); + + let _group = lambda_platform::wgpu::bind::BindGroupBuilder::new() + .with_layout(&layout) + .with_texture(1, &texture) + .with_sampler(2, &sampler) + .build(device); +} 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..b6ee16a5 --- /dev/null +++ b/crates/lambda-rs-platform/tests/wgpu_texture_build_and_upload.rs @@ -0,0 +1,60 @@ +#![allow(clippy::needless_return)] + +// Integration tests for `lambda-rs-platform::wgpu::texture` + +fn create_test_device() -> lambda_platform::wgpu::Gpu { + let instance = lambda_platform::wgpu::InstanceBuilder::new() + .with_label("platform-itest") + .build(); + return lambda_platform::wgpu::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 device = gpu.device(); + let queue = gpu.queue(); + + 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(device, queue) + .expect("texture created"); +} + +#[test] +fn wgpu_texture_upload_with_padding_bytes_per_row() { + let gpu = create_test_device(); + let device = gpu.device(); + let queue = gpu.queue(); + + 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(device, queue) + .expect("padded write_texture works"); +} From a1bc46589608cbf5bfe340b91049e7e7372d0a7a Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sat, 1 Nov 2025 15:01:35 -0700 Subject: [PATCH 11/27] [add] support for 3d textures. --- crates/lambda-rs-platform/src/wgpu/texture.rs | 161 ++++++++++++++++-- .../tests/wgpu_bind_layout_dim3_and_group.rs | 46 +++++ .../tests/wgpu_texture3d_build_and_upload.rs | 28 +++ crates/lambda-rs/src/render/bind.rs | 29 ++++ crates/lambda-rs/src/render/texture.rs | 105 +++++++++++- 5 files changed, 349 insertions(+), 20 deletions(-) create mode 100644 crates/lambda-rs-platform/tests/wgpu_bind_layout_dim3_and_group.rs create mode 100644 crates/lambda-rs-platform/tests/wgpu_texture3d_build_and_upload.rs diff --git a/crates/lambda-rs-platform/src/wgpu/texture.rs b/crates/lambda-rs-platform/src/wgpu/texture.rs index 41437577..92044aa3 100644 --- a/crates/lambda-rs-platform/src/wgpu/texture.rs +++ b/crates/lambda-rs-platform/src/wgpu/texture.rs @@ -4,10 +4,10 @@ //! dimensions, filtering, and addressing. It provides explicit mappings to the //! underlying `wgpu` types and basic helpers such as bytes‑per‑pixel. -use super::types as wgpu; +use wgpu; #[derive(Debug)] -/// Errors returned when building textures fails validation. +/// 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 }, @@ -17,6 +17,10 @@ pub enum TextureBuildError { 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; @@ -256,6 +260,10 @@ impl SamplerBuilder { } #[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, @@ -279,16 +287,32 @@ impl Texture { } } -/// Builder for creating a 2D sampled texture with optional initial data upload. +/// 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, - data: Option>, // tightly packed rows (width * bpp) + /// Optional tightly‑packed pixel payload for level 0 (rows are `width*bpp`). + data: Option>, } impl TextureBuilder { @@ -300,6 +324,22 @@ impl TextureBuilder { 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, @@ -310,6 +350,15 @@ impl TextureBuilder { 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; } @@ -345,20 +394,39 @@ impl TextureBuilder { 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: 1, + 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 expected = (self.width as usize) + let wh = (self.width as usize) .checked_mul(self.height as usize) - .and_then(|n| n.checked_mul(bpp)) .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, @@ -388,7 +456,21 @@ impl TextureBuilder { }; let texture = device.create_texture(&descriptor); - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + 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. @@ -399,23 +481,66 @@ impl TextureBuilder { .ok_or(TextureBuildError::Overflow)?; let padded_row_bytes = align_up(row_bytes, 256); - // Prepare a staging buffer with zeroed padding between rows. + // 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; - 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)]); + 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 { 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..fbc713e9 --- /dev/null +++ b/crates/lambda-rs-platform/tests/wgpu_bind_layout_dim3_and_group.rs @@ -0,0 +1,46 @@ +#![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::InstanceBuilder::new() + .with_label("p-itest-3d-bind") + .build(); + let gpu = lambda_platform::wgpu::GpuBuilder::new() + .with_label("p-itest-3d-bind-device") + .build(&instance, None) + .expect("create device"); + let device = gpu.device(); + let queue = gpu.queue(); + + 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(device, queue) + .expect("3D texture build"); + + let sampler = lambda_platform::wgpu::texture::SamplerBuilder::new() + .nearest_clamp() + .build(device); + + let layout = lambda_platform::wgpu::bind::BindGroupLayoutBuilder::new() + .with_sampled_texture_dim( + 1, + lambda_platform::wgpu::bind::Visibility::Fragment, + lambda_platform::wgpu::types::TextureViewDimension::D3, + ) + .with_sampler(2, lambda_platform::wgpu::bind::Visibility::Fragment) + .build(device); + + let _group = lambda_platform::wgpu::bind::BindGroupBuilder::new() + .with_layout(&layout) + .with_texture(1, &tex3d) + .with_sampler(2, &sampler) + .build(device); +} 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..a43b1702 --- /dev/null +++ b/crates/lambda-rs-platform/tests/wgpu_texture3d_build_and_upload.rs @@ -0,0 +1,28 @@ +#![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::InstanceBuilder::new() + .with_label("p-itest-3d") + .build(); + let gpu = lambda_platform::wgpu::GpuBuilder::new() + .with_label("p-itest-3d-device") + .build(&instance, None) + .expect("create device"); + let device = gpu.device(); + let queue = gpu.queue(); + + 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(device, queue) + .expect("3D texture build"); +} diff --git a/crates/lambda-rs/src/render/bind.rs b/crates/lambda-rs/src/render/bind.rs index 0989ef06..1134f8dc 100644 --- a/crates/lambda-rs/src/render/bind.rs +++ b/crates/lambda-rs/src/render/bind.rs @@ -13,6 +13,7 @@ use super::{ texture::{ Sampler, Texture, + ViewDimension, }, RenderContext, }; @@ -113,6 +114,7 @@ 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)>, } @@ -123,6 +125,7 @@ impl BindGroupLayoutBuilder { label: None, entries: Vec::new(), textures_2d: Vec::new(), + textures_dim: Vec::new(), samplers: Vec::new(), } } @@ -167,6 +170,17 @@ impl BindGroupLayoutBuilder { 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 = @@ -191,6 +205,13 @@ impl BindGroupLayoutBuilder { 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), @@ -220,6 +241,14 @@ impl BindGroupLayoutBuilder { 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_wgpu(), + ); + } + for (binding, visibility) in self.samplers.into_iter() { builder = builder.with_sampler(binding, visibility.to_platform()); } diff --git a/crates/lambda-rs/src/render/texture.rs b/crates/lambda-rs/src/render/texture.rs index 62376a94..c98d5785 100644 --- a/crates/lambda-rs/src/render/texture.rs +++ b/crates/lambda-rs/src/render/texture.rs @@ -25,6 +25,61 @@ impl TextureFormat { } } +#[derive(Debug, Clone, Copy)] +/// View dimensionality exposed to shaders when sampling. +pub enum ViewDimension { + D2, + D3, +} + +impl ViewDimension { + pub(crate) fn to_wgpu( + self, + ) -> lambda_platform::wgpu::types::TextureViewDimension { + match self { + ViewDimension::D2 => { + lambda_platform::wgpu::types::TextureViewDimension::D2 + } + ViewDimension::D3 => { + lambda_platform::wgpu::types::TextureViewDimension::D3 + } + } + } +} + +#[derive(Debug, Clone, Copy)] +/// Sampler filtering mode. +pub enum FilterMode { + Nearest, + Linear, +} + +impl FilterMode { + fn to_platform(self) -> platform::FilterMode { + 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 { + 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 { @@ -55,6 +110,7 @@ pub struct TextureBuilder { format: TextureFormat, width: u32, height: u32, + depth: u32, data: Option>, // tightly packed rows } @@ -66,6 +122,7 @@ impl TextureBuilder { format, width: 0, height: 0, + depth: 1, data: None, }; } @@ -74,6 +131,15 @@ impl TextureBuilder { 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; } @@ -95,8 +161,13 @@ impl TextureBuilder { render_context: &mut RenderContext, ) -> Result { let mut builder = - platform::TextureBuilder::new_2d(self.format.to_platform()) - .with_size(self.width, self.height); + 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); } @@ -157,6 +228,36 @@ impl SamplerBuilder { 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); From aa27df5d864679613f2a5ee85ec8ac3cbd29bb8d Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sat, 1 Nov 2025 16:19:25 -0700 Subject: [PATCH 12/27] [add] 3D example prototype (Still needs refining). --- crates/lambda-rs-platform/src/wgpu/texture.rs | 134 +++++ crates/lambda-rs/examples/textured_cube.rs | 477 ++++++++++++++++ crates/lambda-rs/examples/textured_volume.rs | 519 ++++++++++++++++++ crates/lambda-rs/src/render/mod.rs | 36 +- crates/lambda-rs/src/render/pipeline.rs | 22 +- crates/lambda-rs/src/render/texture.rs | 17 + 6 files changed, 1203 insertions(+), 2 deletions(-) create mode 100644 crates/lambda-rs/examples/textured_cube.rs create mode 100644 crates/lambda-rs/examples/textured_volume.rs diff --git a/crates/lambda-rs-platform/src/wgpu/texture.rs b/crates/lambda-rs-platform/src/wgpu/texture.rs index 92044aa3..cdeb142b 100644 --- a/crates/lambda-rs-platform/src/wgpu/texture.rs +++ b/crates/lambda-rs-platform/src/wgpu/texture.rs @@ -84,6 +84,140 @@ impl TextureFormat { } } +/// 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; + } +} + +/// 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, device: &wgpu::Device) -> 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 = 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 { diff --git a/crates/lambda-rs/examples/textured_cube.rs b/crates/lambda-rs/examples/textured_cube.rs new file mode 100644 index 00000000..35150ff7 --- /dev/null +++ b/crates/lambda-rs/examples/textured_cube.rs @@ -0,0 +1,477 @@ +#![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 3D volume (gradient) sampled in the fragment shader. + +use lambda::{ + component::Component, + logging, + math::matrix::Matrix, + render::{ + bind::{ + BindGroupBuilder, + BindGroupLayoutBuilder, + BindingVisibility, + }, + buffer::BufferBuilder, + command::RenderCommand, + mesh::{ + Mesh, + MeshBuilder, + }, + pipeline::{ + PipelineStage, + RenderPipelineBuilder, + }, + render_pass::RenderPassBuilder, + scene_math::{ + compute_model_view_projection_matrix_about_pivot, + SimpleCamera, + }, + shader::{ + Shader, + ShaderBuilder, + ShaderKind, + VirtualShader, + }, + texture::{ + SamplerBuilder, + TextureBuilder, + TextureFormat, + ViewDimension, + }, + vertex::{ + Vertex, + VertexAttribute, + VertexBuilder, + VertexElement, + }, + viewport::ViewportBuilder, + ColorFormat, + ResourceId, + }, + runtime::start_runtime, + runtimes::{ + application::ComponentResult, + ApplicationRuntime, + ApplicationRuntimeBuilder, + }, +}; + +// ------------------------------ SHADER SOURCE -------------------------------- + +const VERTEX_SHADER_SOURCE: &str = r#" +#version 450 + +layout (location = 0) in vec3 vertex_position; +layout (location = 1) in vec3 vertex_normal; +layout (location = 2) in vec3 vertex_color; // unused + +layout (location = 0) out vec3 v_model_pos; +layout (location = 1) out vec3 v_normal; + +layout ( push_constant ) uniform Push { + mat4 mvp; +} pc; + +void main() { + gl_Position = pc.mvp * vec4(vertex_position, 1.0); + v_model_pos = vertex_position; + v_normal = vertex_normal; +} + +"#; + +const FRAGMENT_SHADER_SOURCE: &str = r#" +#version 450 + +layout (location = 0) in vec3 v_model_pos; +layout (location = 1) in vec3 v_normal; + +layout (location = 0) out vec4 fragment_color; + +layout (set = 0, binding = 1) uniform texture3D tex3d; +layout (set = 0, binding = 2) uniform sampler samp; + +void main() { + // Sample color from 3D volume using model-space position mapped to [0,1] + vec3 uvw = clamp(v_model_pos * 0.5 + 0.5, 0.0, 1.0); + vec3 base = texture(sampler3D(tex3d, samp), uvw).rgb; + + // Simple lambert lighting to emphasize shape + vec3 N = normalize(v_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], +} + +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 { + // Render pass and shaders + let render_pass = RenderPassBuilder::new() + .with_label("textured-cube-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-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 + add_face( + 1.0, + 0.0, + 0.0, + [(h, -h, -h), (h, -h, h), (h, h, h), (h, h, -h)], + ); + // -X + add_face( + -1.0, + 0.0, + 0.0, + [(-h, -h, h), (-h, -h, -h), (-h, h, -h), (-h, h, h)], + ); + // +Y + add_face( + 0.0, + 1.0, + 0.0, + [(-h, h, -h), (h, h, -h), (h, h, h), (-h, h, h)], + ); + // -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(); + + // 3D checkerboard volume for coloring + let (w, h3d, d3d) = (32u32, 32u32, 32u32); + let mut voxels = vec![0u8; (w * h3d * d3d * 4) as usize]; + let block = 4u32; + for z in 0..d3d { + for y in 0..h3d { + for x in 0..w { + let idx = ((z * h3d * w + y * w + x) * 4) as usize; + let c = ((x / block + y / block + z / block) % 2) as u32; + let (r, g, b) = if c == 0 { + (40u8, 40u8, 40u8) + } else { + (220u8, 50u8, 220u8) + }; + voxels[idx + 0] = r; + voxels[idx + 1] = g; + voxels[idx + 2] = b; + voxels[idx + 3] = 255; + } + } + } + + let texture3d = TextureBuilder::new_3d(TextureFormat::Rgba8UnormSrgb) + .with_size_3d(w, h3d, d3d) + .with_data(&voxels) + .with_label("cube-volume") + .build(render_context) + .expect("Failed to create 3D texture"); + let sampler = SamplerBuilder::new().linear_clamp().build(render_context); + + let layout = BindGroupLayoutBuilder::new() + .with_sampled_texture_dim( + 1, + ViewDimension::D3, + BindingVisibility::Fragment, + ) + .with_sampler(2) + .build(render_context); + let bind_group = BindGroupBuilder::new() + .with_layout(&layout) + .with_texture(1, &texture3d) + .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, + }; + let angle_turns = 0.15 * self.elapsed; // slow rotation + let mvp = compute_model_view_projection_matrix_about_pivot( + &camera, + self.width.max(1), + self.height.max(1), + [0.0, 0.0, 0.0], // pivot + [0.0, 1.0, 0.0], // axis + angle_turns, + 1.0, // scale + [0.0, 0.0, 0.0], // translation + ); + + 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(), + })), + }, + 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_volume.rs b/crates/lambda-rs/examples/textured_volume.rs new file mode 100644 index 00000000..c213f8ae --- /dev/null +++ b/crates/lambda-rs/examples/textured_volume.rs @@ -0,0 +1,519 @@ +#![allow(clippy::needless_return)] + +//! Example: Draw a quad sampling a 3D texture slice with a push constant. + +use lambda::{ + component::Component, + events::Events, + logging, + render::{ + bind::{ + BindGroupBuilder, + BindGroupLayoutBuilder, + BindingVisibility, + }, + buffer::BufferBuilder, + command::RenderCommand, + mesh::{ + Mesh, + MeshBuilder, + }, + pipeline::{ + CullingMode, + PipelineStage, + RenderPipelineBuilder, + }, + render_pass::RenderPassBuilder, + shader::{ + Shader, + ShaderBuilder, + ShaderKind, + VirtualShader, + }, + texture::{ + SamplerBuilder, + TextureBuilder, + TextureFormat, + ViewDimension, + }, + vertex::{ + Vertex, + VertexAttribute, + VertexBuilder, + VertexElement, + }, + viewport::ViewportBuilder, + ColorFormat, + ResourceId, + }, + runtime::start_runtime, + runtimes::{ + application::ComponentResult, + ApplicationRuntime, + ApplicationRuntimeBuilder, + }, +}; + +// ------------------------------ SHADER SOURCE -------------------------------- + +const VERTEX_SHADER_SOURCE: &str = r#" +#version 450 + +layout (location = 0) in vec3 vertex_position; +layout (location = 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 texture3D tex3d; +layout (set = 0, binding = 2) uniform sampler samp; + +layout (push_constant) uniform Push { + float pitch; // rotation around X + float yaw; // rotation around Y +} pc; + +mat3 rot_x(float a) { + float c = cos(a); + float s = sin(a); + return mat3( + 1.0, 0.0, 0.0, + 0.0, c, -s, + 0.0, s, c + ); +} + +mat3 rot_y(float a) { + float c = cos(a); + float s = sin(a); + return mat3( + c, 0.0, s, + 0.0, 1.0, 0.0, + -s, 0.0, c + ); +} + +bool intersect_box( + vec3 ro, + vec3 rd, + vec3 bmin, + vec3 bmax, + out float t0, + out float t1 +) { + vec3 inv = 1.0 / rd; + vec3 tminTemp = (bmin - ro) * inv; + vec3 tmaxTemp = (bmax - ro) * inv; + vec3 tsmaller = min(tminTemp, tmaxTemp); + vec3 tbigger = max(tminTemp, tmaxTemp); + t0 = max(max(tsmaller.x, tsmaller.y), tsmaller.z); + t1 = min(min(tbigger.x, tbigger.y), tbigger.z); + return t1 >= max(t0, 0.0); +} + +void main() { + // Screen to ray setup + vec2 uv_ndc = v_uv * 2.0 - 1.0; // [-1,1] + float fov = 1.2; + vec3 ro = vec3(0.5, 0.5, -2.0); + // Camera ray through pixel; keep camera stable and rotate the volume instead + vec3 rd = normalize(vec3(uv_ndc.x * fov, uv_ndc.y * fov, 1.5)); + + float t0, t1; + bool hit = intersect_box(ro, rd, vec3(0.0), vec3(1.0), t0, t1); + if (!hit) { fragment_color = vec4(0.02, 0.02, 0.03, 1.0); return; } + + t0 = max(t0, 0.0); + int STEPS = 64; + float dt = (t1 - t0) / float(STEPS); + vec3 color = vec3(0.0); + float alpha = 0.0; + + mat3 R = rot_y(pc.yaw) * rot_x(pc.pitch); + vec3 center = vec3(0.5); + for (int i = 0; i < STEPS; ++i) { + float t = t0 + (float(i) + 0.5) * dt; + vec3 pos = ro + rd * t; // position in [0,1]^3 + // Rotate the volume about its center; sample rotated coords + vec3 pos_rot = R * (pos - center) + center; + vec3 vox = texture(sampler3D(tex3d, samp), pos_rot).rgb; + float density = clamp((vox.r + vox.g + vox.b) * 0.3333, 0.0, 1.0); + // Convert density to opacity; small scale for pleasant appearance + float a = 1.0 - exp(-density * 6.0 * dt * 64.0); + color += (1.0 - alpha) * vox * a; + alpha += (1.0 - alpha) * a; + if (alpha > 0.98) break; + } + + color = mix(vec3(0.02, 0.02, 0.03), color, clamp(alpha, 0.0, 1.0)); + fragment_color = vec4(color, 1.0); +} + +"#; + +// ------------------------------- PUSH CONSTANT ------------------------------- + +#[repr(C)] +#[derive(Copy, Clone)] +struct PushAngles { + pitch: f32, + yaw: f32, +} + +fn push_constant_to_words(pc: &PushAngles) -> &[u32] { + unsafe { + std::slice::from_raw_parts( + (pc as *const PushAngles) as *const u32, + std::mem::size_of::() / 4, + ) + } +} + +// --------------------------------- COMPONENT --------------------------------- + +pub struct TexturedVolumeExample { + shader_vs: Shader, + shader_fs: Shader, + mesh: Option, + render_pipeline: Option, + render_pass: Option, + bind_group: Option, + width: u32, + height: u32, + pitch: f32, + yaw: f32, + pitch_speed: f32, + yaw_speed: f32, +} + +impl Component for TexturedVolumeExample { + fn on_attach( + &mut self, + render_context: &mut lambda::render::RenderContext, + ) -> Result { + logging::info!("Attaching TexturedVolumeExample"); + + // Build render pass and shaders + let render_pass = RenderPassBuilder::new() + .with_label("textured-volume-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-volume".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-volume".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 3D volume (gradient) + let (w, h, d) = (32u32, 32u32, 32u32); + let mut voxels = vec![0u8; (w * h * d * 4) as usize]; + for zz in 0..d { + for yy in 0..h { + for xx in 0..w { + let idx = ((zz * h * w + yy * w + xx) * 4) as usize; + let r = ((xx as f32) / (w as f32 - 1.0) * 255.0) as u8; + let g = ((yy as f32) / (h as f32 - 1.0) * 255.0) as u8; + let b = ((zz as f32) / (d as f32 - 1.0) * 255.0) as u8; + voxels[idx + 0] = r; + voxels[idx + 1] = g; + voxels[idx + 2] = b; + voxels[idx + 3] = 255; + } + } + } + + let texture3d = TextureBuilder::new_3d(TextureFormat::Rgba8UnormSrgb) + .with_size_3d(w, h, d) + .with_data(&voxels) + .with_label("volume-gradient") + .build(render_context) + .expect("Failed to create 3D texture"); + + let sampler = SamplerBuilder::new() + .linear_clamp() + .with_label("linear-clamp") + .build(render_context); + + // Layout: binding(1) texture3D, binding(2) sampler + let layout = BindGroupLayoutBuilder::new() + .with_sampled_texture_dim( + 1, + ViewDimension::D3, + BindingVisibility::Fragment, + ) + .with_sampler(2) + .build(render_context); + + let bind_group = BindGroupBuilder::new() + .with_layout(&layout) + .with_texture(1, &texture3d) + .with_sampler(2, &sampler) + .build(render_context); + + let push_constants_size = std::mem::size_of::() as u32; + 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(), + ) + .with_push_constant(PipelineStage::FRAGMENT, push_constants_size) + .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 { + match event { + Events::Window { + event: lambda::events::WindowEvent::Resize { width, height }, + .. + } => { + self.width = width; + self.height = height; + } + Events::Keyboard { event, .. } => match event { + lambda::events::Key::Pressed { virtual_key, .. } => match virtual_key { + Some(lambda::events::VirtualKey::KeyW) => { + self.pitch_speed += 0.1; + } + Some(lambda::events::VirtualKey::KeyS) => { + self.pitch_speed -= 0.1; + } + Some(lambda::events::VirtualKey::KeyA) => { + self.yaw_speed -= 0.1; + } + Some(lambda::events::VirtualKey::KeyD) => { + self.yaw_speed += 0.1; + } + Some(lambda::events::VirtualKey::Space) => { + self.pitch_speed = 0.0; + self.yaw_speed = 0.0; + } + _ => {} + }, + _ => {} + }, + _ => {} + } + return Ok(ComponentResult::Success); + } + + fn on_update( + &mut self, + last_frame: &std::time::Duration, + ) -> Result { + // Auto-rotate slice orientation over time. + let dt = (last_frame.as_micros() as f32) / 1_000_000.0; + self.pitch += self.pitch_speed * dt; + self.yaw += self.yaw_speed * dt; + // Wrap angles to [-pi, pi] for numerical stability + let pi = std::f32::consts::PI; + if self.pitch > pi { + self.pitch -= 2.0 * pi; + } + if self.pitch < -pi { + self.pitch += 2.0 * pi; + } + if self.yaw > pi { + self.yaw -= 2.0 * pi; + } + if self.yaw < -pi { + self.yaw += 2.0 * pi; + } + return Ok(ComponentResult::Success); + } + + fn on_render( + &mut self, + _render_context: &mut lambda::render::RenderContext, + ) -> Vec { + let mut commands = vec![]; + // Render to full window dimensions. + let viewport = ViewportBuilder::new().build(self.width, self.height); + + 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, + }); + + let pc = PushAngles { + pitch: self.pitch, + yaw: self.yaw, + }; + commands.push(RenderCommand::PushConstants { + pipeline: self.render_pipeline.expect("pipeline not set"), + stage: PipelineStage::FRAGMENT, + offset: 0, + bytes: Vec::from(push_constant_to_words(&pc)), + }); + + commands.push(RenderCommand::Draw { vertices: 0..6 }); + commands.push(RenderCommand::EndRenderPass); + return commands; + } +} + +impl Default for TexturedVolumeExample { + 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-volume".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-volume".to_string(), + }); + + return Self { + shader_vs, + shader_fs, + mesh: None, + render_pipeline: None, + render_pass: None, + bind_group: None, + width: 800, + height: 600, + pitch: 0.0, + yaw: 0.0, + pitch_speed: 0.7, + yaw_speed: 0.45, + }; + } +} + +fn main() { + let runtime: ApplicationRuntime = + ApplicationRuntimeBuilder::new("3D Texture Slice Example") + .with_window_configured_as(|builder| { + builder + .with_dimensions(800, 600) + .with_name("Texture 3D Slice") + }) + .with_component(|runtime, example: TexturedVolumeExample| { + (runtime, example) + }) + .build(); + + start_runtime(runtime); +} diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index b2ddc8ba..7beee4b6 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -91,6 +91,14 @@ impl RenderContextBuilder { ) .expect("Failed to configure surface"); + let depth = Some( + lambda_platform::wgpu::texture::DepthTextureBuilder::new() + .with_label("lambda-depth") + .with_size(size.0, size.1) + .with_format(lambda_platform::wgpu::texture::DepthFormat::Depth32Float) + .build(gpu.device()), + ); + return RenderContext { label: name, instance, @@ -104,6 +112,7 @@ impl RenderContextBuilder { render_pipelines: vec![], bind_group_layouts: vec![], bind_groups: vec![], + depth, }; } } @@ -126,6 +135,7 @@ pub struct RenderContext { render_pipelines: Vec, bind_group_layouts: Vec, bind_groups: Vec, + depth: Option, } /// Opaque handle used to refer to resources attached to a `RenderContext`. @@ -215,6 +225,11 @@ impl RenderContext { return self.config.format; } + /// Depth format used for pipelines and attachments. + pub(crate) fn depth_format(&self) -> wgpu::TextureFormat { + return wgpu::TextureFormat::Depth32Float; + } + /// Device limit: maximum bytes that can be bound for a single uniform buffer binding. pub fn limit_max_uniform_buffer_binding_size(&self) -> u64 { return self.gpu.limits().max_uniform_buffer_binding_size.into(); @@ -279,11 +294,22 @@ impl RenderContext { ops: pass.color_ops(), }; let color_attachments = [Some(color_attachment)]; + let depth_attachment = self.depth.as_ref().map(|d| { + wgpu::RenderPassDepthStencilAttachment { + view: d.view(), + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + } + }); + let mut pass_encoder = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: pass.label(), color_attachments: &color_attachments, - depth_stencil_attachment: None, + depth_stencil_attachment: depth_attachment, timestamp_writes: None, occlusion_query_set: None, }); @@ -440,6 +466,14 @@ impl RenderContext { self.present_mode = config.present_mode; self.texture_usage = config.usage; self.config = config; + // Recreate depth attachment with the new surface size + self.depth = Some( + lambda_platform::wgpu::texture::DepthTextureBuilder::new() + .with_label("lambda-depth") + .with_size(size.0, size.1) + .with_format(lambda_platform::wgpu::texture::DepthFormat::Depth32Float) + .build(self.gpu.device()), + ); return Ok(()); } } diff --git a/crates/lambda-rs/src/render/pipeline.rs b/crates/lambda-rs/src/render/pipeline.rs index f7099f49..fbdac423 100644 --- a/crates/lambda-rs/src/render/pipeline.rs +++ b/crates/lambda-rs/src/render/pipeline.rs @@ -110,6 +110,7 @@ pub struct RenderPipelineBuilder { culling: CullingMode, bind_group_layouts: Vec, label: Option, + use_depth: bool, } impl RenderPipelineBuilder { @@ -121,6 +122,7 @@ impl RenderPipelineBuilder { culling: CullingMode::Back, bind_group_layouts: Vec::new(), label: None, + use_depth: false, } } @@ -165,6 +167,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( @@ -281,12 +289,24 @@ impl RenderPipelineBuilder { ..wgpu::PrimitiveState::default() }; + let depth_stencil = if self.use_depth { + Some(wgpu::DepthStencilState { + format: render_context.depth_format(), + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::Less, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }) + } else { + None + }; + let pipeline_descriptor = wgpu::RenderPipelineDescriptor { label: self.label.as_deref(), layout: Some(&pipeline_layout), vertex: vertex_state, primitive: primitive_state, - depth_stencil: None, + depth_stencil, multisample: wgpu::MultisampleState::default(), fragment, multiview: None, diff --git a/crates/lambda-rs/src/render/texture.rs b/crates/lambda-rs/src/render/texture.rs index c98d5785..e670a808 100644 --- a/crates/lambda-rs/src/render/texture.rs +++ b/crates/lambda-rs/src/render/texture.rs @@ -127,6 +127,23 @@ impl TextureBuilder { }; } + /// 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; From dd8e54d9682100eb2a62983130fd6b57665de579 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sat, 8 Nov 2025 19:41:59 -0800 Subject: [PATCH 13/27] [fix] textured_cube demo. --- .../lambda-rs-platform/src/wgpu/pipeline.rs | 19 +++- .../src/wgpu/render_pass.rs | 56 ++++++++++- crates/lambda-rs-platform/src/wgpu/texture.rs | 5 + crates/lambda-rs/examples/textured_cube.rs | 94 ++++++++++--------- crates/lambda-rs/src/render/mod.rs | 73 +++++++++++++- crates/lambda-rs/src/render/pipeline.rs | 10 +- crates/lambda-rs/src/render/render_pass.rs | 48 ++++++++++ 7 files changed, 255 insertions(+), 50 deletions(-) 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..9f98a4c4 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 as f32), + 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 index 7fffc2f2..b2981631 100644 --- a/crates/lambda-rs-platform/src/wgpu/texture.rs +++ b/crates/lambda-rs-platform/src/wgpu/texture.rs @@ -130,6 +130,11 @@ impl DepthTexture { 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. diff --git a/crates/lambda-rs/examples/textured_cube.rs b/crates/lambda-rs/examples/textured_cube.rs index 5f248ea2..5d7f71b4 100644 --- a/crates/lambda-rs/examples/textured_cube.rs +++ b/crates/lambda-rs/examples/textured_cube.rs @@ -2,7 +2,8 @@ //! Example: Spinning 3D cube sampled with a 3D texture. //! - Uses MVP push constants (vertex stage) for classic camera + rotation. -//! - Colors come from a 3D volume (gradient) sampled in the fragment shader. +//! - Colors come from a 2D checkerboard texture sampled in the fragment +//! shader. Each face projects model-space coordinates to UVs. use lambda::{ component::Component, @@ -12,7 +13,6 @@ use lambda::{ bind::{ BindGroupBuilder, BindGroupLayoutBuilder, - BindingVisibility, }, buffer::BufferBuilder, command::RenderCommand, @@ -39,7 +39,6 @@ use lambda::{ SamplerBuilder, TextureBuilder, TextureFormat, - ViewDimension, }, vertex::{ ColorFormat, @@ -91,16 +90,31 @@ layout (location = 1) in vec3 v_normal; layout (location = 0) out vec4 fragment_color; -layout (set = 0, binding = 1) uniform texture3D tex3d; +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 3D volume using model-space position mapped to [0,1] - vec3 uvw = clamp(v_model_pos * 0.5 + 0.5, 0.0, 1.0); - vec3 base = texture(sampler3D(tex3d, samp), uvw).rgb; + // Sample color from 2D checkerboard using projected UVs in [0,1] + vec3 N = normalize(v_normal); + vec2 uv = clamp(project_uv(v_model_pos, N), 0.0, 1.0); + vec3 base = texture(sampler2D(tex, samp), uv).rgb; // Simple lambert lighting to emphasize shape - vec3 N = normalize(v_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); @@ -145,9 +159,11 @@ impl Component for TexturedCubeExample { &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(); @@ -190,28 +206,28 @@ impl Component for TexturedCubeExample { verts.push(p3); }; let h = 0.5f32; - // +X + // +X (corrected CCW winding) add_face( 1.0, 0.0, 0.0, - [(h, -h, -h), (h, -h, h), (h, h, h), (h, h, -h)], + [(h, -h, -h), (h, h, -h), (h, h, h), (h, -h, h)], ); - // -X + // -X (corrected CCW winding) add_face( -1.0, 0.0, 0.0, - [(-h, -h, h), (-h, -h, -h), (-h, h, -h), (-h, h, h)], + [(-h, -h, -h), (-h, -h, h), (-h, h, h), (-h, h, -h)], ); - // +Y + // +Y (original correct winding) add_face( 0.0, 1.0, 0.0, [(-h, h, -h), (h, h, -h), (h, h, h), (-h, h, h)], ); - // -Y + // -Y (original correct winding) add_face( 0.0, -1.0, @@ -266,47 +282,37 @@ impl Component for TexturedCubeExample { ]) .build(); - // 3D checkerboard volume for coloring - let (w, h3d, d3d) = (32u32, 32u32, 32u32); - let mut voxels = vec![0u8; (w * h3d * d3d * 4) as usize]; - let block = 4u32; - for z in 0..d3d { - for y in 0..h3d { - for x in 0..w { - let idx = ((z * h3d * w + y * w + x) * 4) as usize; - let c = ((x / block + y / block + z / block) % 2) as u32; - let (r, g, b) = if c == 0 { - (40u8, 40u8, 40u8) - } else { - (220u8, 50u8, 220u8) - }; - voxels[idx + 0] = r; - voxels[idx + 1] = g; - voxels[idx + 2] = b; - voxels[idx + 3] = 255; - } + // 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 texture3d = TextureBuilder::new_3d(TextureFormat::Rgba8UnormSrgb) - .with_size_3d(w, h3d, d3d) - .with_data(&voxels) - .with_label("cube-volume") + 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 3D texture"); + .expect("Failed to create 2D texture"); let sampler = SamplerBuilder::new().linear_clamp().build(render_context); let layout = BindGroupLayoutBuilder::new() - .with_sampled_texture_dim( - 1, - ViewDimension::D3, - BindingVisibility::Fragment, - ) + .with_sampled_texture(1) .with_sampler(2) .build(render_context); let bind_group = BindGroupBuilder::new() .with_layout(&layout) - .with_texture(1, &texture3d) + .with_texture(1, &texture2d) .with_sampler(2, &sampler) .build(render_context); diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index 8781cc65..81e7c22f 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -153,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, @@ -162,6 +172,8 @@ impl RenderContextBuilder { present_mode, texture_usage, size, + depth_texture, + depth_format, render_passes: vec![], render_pipelines: vec![], bind_group_layouts: vec![], @@ -196,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, @@ -284,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. @@ -391,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)?; } @@ -412,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 1bdc7d12..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, @@ -245,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, } } } From ffdca154cf2639903ea7de54b7dcbcb8afa1683e Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 10 Nov 2025 12:25:22 -0800 Subject: [PATCH 14/27] [update] cube to rotate around the x & y axis. --- crates/lambda-rs/examples/textured_cube.rs | 36 ++++++++++++++++------ 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/crates/lambda-rs/examples/textured_cube.rs b/crates/lambda-rs/examples/textured_cube.rs index 5d7f71b4..135f34fd 100644 --- a/crates/lambda-rs/examples/textured_cube.rs +++ b/crates/lambda-rs/examples/textured_cube.rs @@ -26,7 +26,8 @@ use lambda::{ }, render_pass::RenderPassBuilder, scene_math::{ - compute_model_view_projection_matrix_about_pivot, + compute_perspective_projection, + compute_view_matrix, SimpleCamera, }, shader::{ @@ -382,17 +383,34 @@ impl Component for TexturedCubeExample { near_clipping_plane: 0.1, far_clipping_plane: 100.0, }; - let angle_turns = 0.15 * self.elapsed; // slow rotation - let mvp = compute_model_view_projection_matrix_about_pivot( - &camera, + // 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), - [0.0, 0.0, 0.0], // pivot - [0.0, 1.0, 0.0], // axis - angle_turns, - 1.0, // scale - [0.0, 0.0, 0.0], // translation + 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"); From 9c986471e74463e03201a7cf3847395c68c43e73 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 10 Nov 2025 12:30:26 -0800 Subject: [PATCH 15/27] [fix] missing faces. --- crates/lambda-rs/examples/textured_cube.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/lambda-rs/examples/textured_cube.rs b/crates/lambda-rs/examples/textured_cube.rs index 135f34fd..8fa2dbe0 100644 --- a/crates/lambda-rs/examples/textured_cube.rs +++ b/crates/lambda-rs/examples/textured_cube.rs @@ -221,19 +221,19 @@ impl Component for TexturedCubeExample { 0.0, [(-h, -h, -h), (-h, -h, h), (-h, h, h), (-h, h, -h)], ); - // +Y (original correct winding) + // +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)], + [(-h, h, h), (h, h, h), (h, h, -h), (-h, h, -h)], ); - // -Y (original correct winding) + // -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)], + [(-h, -h, -h), (h, -h, -h), (h, -h, h), (-h, -h, h)], ); // +Z add_face( From d9b4e0abd7c953d51f78ce1623aa276191df709d Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 10 Nov 2025 12:35:27 -0800 Subject: [PATCH 16/27] [fix] brightness calculations. --- crates/lambda-rs/examples/textured_cube.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/lambda-rs/examples/textured_cube.rs b/crates/lambda-rs/examples/textured_cube.rs index 8fa2dbe0..4822da42 100644 --- a/crates/lambda-rs/examples/textured_cube.rs +++ b/crates/lambda-rs/examples/textured_cube.rs @@ -69,16 +69,20 @@ 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_normal; +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_normal = vertex_normal; + 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; } "#; @@ -87,7 +91,8 @@ const FRAGMENT_SHADER_SOURCE: &str = r#" #version 450 layout (location = 0) in vec3 v_model_pos; -layout (location = 1) in vec3 v_normal; +layout (location = 1) in vec3 v_model_normal; +layout (location = 2) in vec3 v_world_normal; layout (location = 0) out vec4 fragment_color; @@ -111,11 +116,12 @@ vec2 project_uv(vec3 p, vec3 n) { void main() { // Sample color from 2D checkerboard using projected UVs in [0,1] - vec3 N = normalize(v_normal); - vec2 uv = clamp(project_uv(v_model_pos, N), 0.0, 1.0); + 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); @@ -130,6 +136,7 @@ void main() { #[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] { @@ -446,6 +453,7 @@ impl Component for TexturedCubeExample { offset: 0, bytes: Vec::from(push_constants_to_bytes(&PushConstant { mvp: mvp.transpose(), + model: model.transpose(), })), }, RenderCommand::Draw { From fc5eb52c74eb0835225959f941db8e991112b87d Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 10 Nov 2025 12:38:53 -0800 Subject: [PATCH 17/27] [remove] textured volume demo. --- crates/lambda-rs/examples/textured_volume.rs | 519 ------------------- 1 file changed, 519 deletions(-) delete mode 100644 crates/lambda-rs/examples/textured_volume.rs diff --git a/crates/lambda-rs/examples/textured_volume.rs b/crates/lambda-rs/examples/textured_volume.rs deleted file mode 100644 index 9bd22d64..00000000 --- a/crates/lambda-rs/examples/textured_volume.rs +++ /dev/null @@ -1,519 +0,0 @@ -#![allow(clippy::needless_return)] - -//! Example: Draw a quad sampling a 3D texture slice with a push constant. - -use lambda::{ - component::Component, - events::Events, - logging, - render::{ - bind::{ - BindGroupBuilder, - BindGroupLayoutBuilder, - BindingVisibility, - }, - buffer::BufferBuilder, - command::RenderCommand, - mesh::{ - Mesh, - MeshBuilder, - }, - pipeline::{ - CullingMode, - PipelineStage, - RenderPipelineBuilder, - }, - render_pass::RenderPassBuilder, - shader::{ - Shader, - ShaderBuilder, - ShaderKind, - VirtualShader, - }, - texture::{ - SamplerBuilder, - TextureBuilder, - TextureFormat, - ViewDimension, - }, - 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 texture3D tex3d; -layout (set = 0, binding = 2) uniform sampler samp; - -layout (push_constant) uniform Push { - float pitch; // rotation around X - float yaw; // rotation around Y -} pc; - -mat3 rot_x(float a) { - float c = cos(a); - float s = sin(a); - return mat3( - 1.0, 0.0, 0.0, - 0.0, c, -s, - 0.0, s, c - ); -} - -mat3 rot_y(float a) { - float c = cos(a); - float s = sin(a); - return mat3( - c, 0.0, s, - 0.0, 1.0, 0.0, - -s, 0.0, c - ); -} - -bool intersect_box( - vec3 ro, - vec3 rd, - vec3 bmin, - vec3 bmax, - out float t0, - out float t1 -) { - vec3 inv = 1.0 / rd; - vec3 tminTemp = (bmin - ro) * inv; - vec3 tmaxTemp = (bmax - ro) * inv; - vec3 tsmaller = min(tminTemp, tmaxTemp); - vec3 tbigger = max(tminTemp, tmaxTemp); - t0 = max(max(tsmaller.x, tsmaller.y), tsmaller.z); - t1 = min(min(tbigger.x, tbigger.y), tbigger.z); - return t1 >= max(t0, 0.0); -} - -void main() { - // Screen to ray setup - vec2 uv_ndc = v_uv * 2.0 - 1.0; // [-1,1] - float fov = 1.2; - vec3 ro = vec3(0.5, 0.5, -2.0); - // Camera ray through pixel; keep camera stable and rotate the volume instead - vec3 rd = normalize(vec3(uv_ndc.x * fov, uv_ndc.y * fov, 1.5)); - - float t0, t1; - bool hit = intersect_box(ro, rd, vec3(0.0), vec3(1.0), t0, t1); - if (!hit) { fragment_color = vec4(0.02, 0.02, 0.03, 1.0); return; } - - t0 = max(t0, 0.0); - int STEPS = 64; - float dt = (t1 - t0) / float(STEPS); - vec3 color = vec3(0.0); - float alpha = 0.0; - - mat3 R = rot_y(pc.yaw) * rot_x(pc.pitch); - vec3 center = vec3(0.5); - for (int i = 0; i < STEPS; ++i) { - float t = t0 + (float(i) + 0.5) * dt; - vec3 pos = ro + rd * t; // position in [0,1]^3 - // Rotate the volume about its center; sample rotated coords - vec3 pos_rot = R * (pos - center) + center; - vec3 vox = texture(sampler3D(tex3d, samp), pos_rot).rgb; - float density = clamp((vox.r + vox.g + vox.b) * 0.3333, 0.0, 1.0); - // Convert density to opacity; small scale for pleasant appearance - float a = 1.0 - exp(-density * 6.0 * dt * 64.0); - color += (1.0 - alpha) * vox * a; - alpha += (1.0 - alpha) * a; - if (alpha > 0.98) break; - } - - color = mix(vec3(0.02, 0.02, 0.03), color, clamp(alpha, 0.0, 1.0)); - fragment_color = vec4(color, 1.0); -} - -"#; - -// ------------------------------- PUSH CONSTANT ------------------------------- - -#[repr(C)] -#[derive(Copy, Clone)] -struct PushAngles { - pitch: f32, - yaw: f32, -} - -fn push_constant_to_words(pc: &PushAngles) -> &[u32] { - unsafe { - std::slice::from_raw_parts( - (pc as *const PushAngles) as *const u32, - std::mem::size_of::() / 4, - ) - } -} - -// --------------------------------- COMPONENT --------------------------------- - -pub struct TexturedVolumeExample { - shader_vs: Shader, - shader_fs: Shader, - mesh: Option, - render_pipeline: Option, - render_pass: Option, - bind_group: Option, - width: u32, - height: u32, - pitch: f32, - yaw: f32, - pitch_speed: f32, - yaw_speed: f32, -} - -impl Component for TexturedVolumeExample { - fn on_attach( - &mut self, - render_context: &mut lambda::render::RenderContext, - ) -> Result { - logging::info!("Attaching TexturedVolumeExample"); - - // Build render pass and shaders - let render_pass = RenderPassBuilder::new() - .with_label("textured-volume-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-volume".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-volume".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 3D volume (gradient) - let (w, h, d) = (32u32, 32u32, 32u32); - let mut voxels = vec![0u8; (w * h * d * 4) as usize]; - for zz in 0..d { - for yy in 0..h { - for xx in 0..w { - let idx = ((zz * h * w + yy * w + xx) * 4) as usize; - let r = ((xx as f32) / (w as f32 - 1.0) * 255.0) as u8; - let g = ((yy as f32) / (h as f32 - 1.0) * 255.0) as u8; - let b = ((zz as f32) / (d as f32 - 1.0) * 255.0) as u8; - voxels[idx + 0] = r; - voxels[idx + 1] = g; - voxels[idx + 2] = b; - voxels[idx + 3] = 255; - } - } - } - - let texture3d = TextureBuilder::new_3d(TextureFormat::Rgba8UnormSrgb) - .with_size_3d(w, h, d) - .with_data(&voxels) - .with_label("volume-gradient") - .build(render_context) - .expect("Failed to create 3D texture"); - - let sampler = SamplerBuilder::new() - .linear_clamp() - .with_label("linear-clamp") - .build(render_context); - - // Layout: binding(1) texture3D, binding(2) sampler - let layout = BindGroupLayoutBuilder::new() - .with_sampled_texture_dim( - 1, - ViewDimension::D3, - BindingVisibility::Fragment, - ) - .with_sampler(2) - .build(render_context); - - let bind_group = BindGroupBuilder::new() - .with_layout(&layout) - .with_texture(1, &texture3d) - .with_sampler(2, &sampler) - .build(render_context); - - let push_constants_size = std::mem::size_of::() as u32; - 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(), - ) - .with_push_constant(PipelineStage::FRAGMENT, push_constants_size) - .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 { - match event { - Events::Window { - event: lambda::events::WindowEvent::Resize { width, height }, - .. - } => { - self.width = width; - self.height = height; - } - Events::Keyboard { event, .. } => match event { - lambda::events::Key::Pressed { virtual_key, .. } => match virtual_key { - Some(lambda::events::VirtualKey::KeyW) => { - self.pitch_speed += 0.1; - } - Some(lambda::events::VirtualKey::KeyS) => { - self.pitch_speed -= 0.1; - } - Some(lambda::events::VirtualKey::KeyA) => { - self.yaw_speed -= 0.1; - } - Some(lambda::events::VirtualKey::KeyD) => { - self.yaw_speed += 0.1; - } - Some(lambda::events::VirtualKey::Space) => { - self.pitch_speed = 0.0; - self.yaw_speed = 0.0; - } - _ => {} - }, - _ => {} - }, - _ => {} - } - return Ok(ComponentResult::Success); - } - - fn on_update( - &mut self, - last_frame: &std::time::Duration, - ) -> Result { - // Auto-rotate slice orientation over time. - let dt = (last_frame.as_micros() as f32) / 1_000_000.0; - self.pitch += self.pitch_speed * dt; - self.yaw += self.yaw_speed * dt; - // Wrap angles to [-pi, pi] for numerical stability - let pi = std::f32::consts::PI; - if self.pitch > pi { - self.pitch -= 2.0 * pi; - } - if self.pitch < -pi { - self.pitch += 2.0 * pi; - } - if self.yaw > pi { - self.yaw -= 2.0 * pi; - } - if self.yaw < -pi { - self.yaw += 2.0 * pi; - } - return Ok(ComponentResult::Success); - } - - fn on_render( - &mut self, - _render_context: &mut lambda::render::RenderContext, - ) -> Vec { - let mut commands = vec![]; - // Render to full window dimensions. - let viewport = ViewportBuilder::new().build(self.width, self.height); - - 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, - }); - - let pc = PushAngles { - pitch: self.pitch, - yaw: self.yaw, - }; - commands.push(RenderCommand::PushConstants { - pipeline: self.render_pipeline.expect("pipeline not set"), - stage: PipelineStage::FRAGMENT, - offset: 0, - bytes: Vec::from(push_constant_to_words(&pc)), - }); - - commands.push(RenderCommand::Draw { vertices: 0..6 }); - commands.push(RenderCommand::EndRenderPass); - return commands; - } -} - -impl Default for TexturedVolumeExample { - 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-volume".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-volume".to_string(), - }); - - return Self { - shader_vs, - shader_fs, - mesh: None, - render_pipeline: None, - render_pass: None, - bind_group: None, - width: 800, - height: 600, - pitch: 0.0, - yaw: 0.0, - pitch_speed: 0.7, - yaw_speed: 0.45, - }; - } -} - -fn main() { - let runtime: ApplicationRuntime = - ApplicationRuntimeBuilder::new("3D Texture Slice Example") - .with_window_configured_as(|builder| { - builder - .with_dimensions(800, 600) - .with_name("Texture 3D Slice") - }) - .with_component(|runtime, example: TexturedVolumeExample| { - (runtime, example) - }) - .build(); - - start_runtime(runtime); -} From a3a92ced471e5b0057c321e4231577febe90bc50 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 10 Nov 2025 12:46:11 -0800 Subject: [PATCH 18/27] [add] tutorial for textured_quad example. --- docs/tutorials/textured-quad.md | 449 ++++++++++++++++++++++++++++++++ 1 file changed, 449 insertions(+) create mode 100644 docs/tutorials/textured-quad.md diff --git a/docs/tutorials/textured-quad.md b/docs/tutorials/textured-quad.md new file mode 100644 index 00000000..8ee880e0 --- /dev/null +++ b/docs/tutorials/textured-quad.md @@ -0,0 +1,449 @@ +--- +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-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: ["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) +- [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); +} +``` + +### 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); +} +"#; +``` + +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; +``` + +### 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); +``` + +### 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"); +``` + +### 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); +``` + +### 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); +``` + +### 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)); +``` + +### 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, +]; +``` + +### 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); +} +``` + +## 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. + +## 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.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`. From 727dbe9b7706e273c525a6ca92426a1aba61cdb6 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 10 Nov 2025 12:53:54 -0800 Subject: [PATCH 19/27] [update] step descriptions. --- docs/tutorials/textured-quad.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/tutorials/textured-quad.md b/docs/tutorials/textured-quad.md index 8ee880e0..4e87dad5 100644 --- a/docs/tutorials/textured-quad.md +++ b/docs/tutorials/textured-quad.md @@ -3,8 +3,8 @@ 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-10T00:00:00Z" -version: "0.3.1" +last_updated: "2025-11-10T02:00:00Z" +version: "0.3.2" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" @@ -167,6 +167,8 @@ fn main() { } ``` +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)`. @@ -202,6 +204,8 @@ void main() { "#; ``` +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 @@ -225,6 +229,8 @@ 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. @@ -270,6 +276,8 @@ let mesh: Mesh = mesh_builder 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. @@ -301,6 +309,8 @@ let texture = TextureBuilder::new_2d(TextureFormat::Rgba8UnormSrgb) .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. @@ -313,6 +323,8 @@ let sampler = SamplerBuilder::new() .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. @@ -331,6 +343,8 @@ let bind_group = BindGroupBuilder::new() .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. @@ -363,6 +377,8 @@ 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. @@ -398,6 +414,8 @@ let commands = vec![ ]; ``` +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. @@ -413,6 +431,8 @@ fn on_event(&mut self, event: Events) -> Result { } ``` +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` @@ -445,5 +465,6 @@ fn on_event(&mut self, event: Events) -> Result { - Discuss artifacts without mipmaps and how multiple levels would improve minification. ## Changelog +- 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`. From fe79756541e33270eca76638400bb64c6ec9f732 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 10 Nov 2025 13:43:07 -0800 Subject: [PATCH 20/27] [add] textured_cube tutorial and update tutorial links. --- docs/tutorials/README.md | 11 +- docs/tutorials/textured-cube.md | 513 ++++++++++++++++++++++++++++++++ 2 files changed, 520 insertions(+), 4 deletions(-) create mode 100644 docs/tutorials/textured-cube.md 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..6bb77b0a --- /dev/null +++ b/docs/tutorials/textured-cube.md @@ -0,0 +1,513 @@ +--- +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-10T00:00:00Z" +version: "0.1.0" +engine_workspace_version: "2023.1.30" +wgpu_version: "26.0.1" +shader_backend_default: "naga" +winit_version: "0.29.10" +repo_commit: "727dbe9b7706e273c525a6ca92426a1aba61cdb6" +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) +- [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. + +## 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.0 (2025-11-10): Initial draft aligned with `crates/lambda-rs/examples/textured_cube.rs` including push constants, depth, culling, and projected UV sampling. From 70bfaff1816051afb0228eb326225a12a02b623e Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 10 Nov 2025 14:48:07 -0800 Subject: [PATCH 21/27] [update] specification to match current state of texture implementation. --- docs/specs/textures-and-samplers.md | 115 ++++++++++++++++++---------- 1 file changed, 75 insertions(+), 40 deletions(-) diff --git a/docs/specs/textures-and-samplers.md b/docs/specs/textures-and-samplers.md index f202f3f2..aec6f069 100644 --- a/docs/specs/textures-and-samplers.md +++ b/docs/specs/textures-and-samplers.md @@ -3,13 +3,13 @@ title: "Textures and Samplers" document_id: "texture-sampler-spec-2025-10-30" status: "draft" created: "2025-10-30T00:00:00Z" -last_updated: "2025-10-30T00:10:00Z" -version: "0.2.0" +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: "d3dc4356c165c596e0b9f84b3687b1018eeb1a91" +repo_commit: "fc5eb52c74eb0835225959f941db8e991112b87d" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "rendering", "textures", "samplers", "wgpu"] @@ -41,10 +41,14 @@ Summary ### Non-Goals -- Storage textures, depth textures, cube maps, and 2D/3D array textures. -- Multisampled textures and render target (color attachment) workflows. -- Mipmap generation (automatic or offline); only level 0 is supported. -- Partial sub-rect updates; uploads are whole-image at creation time. +- 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 @@ -97,7 +101,8 @@ Render pass: SetPipeline -> SetBindGroup -> Draw ### API Surface - Platform layer (`lambda-rs-platform`, module `lambda_platform::wgpu::texture`) - - Types: `Texture`, `TextureView`, `Sampler` (own raw `wgpu` handles). + - 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: @@ -121,20 +126,23 @@ Render pass: SetPipeline -> SetBindGroup -> Draw - Mip filtering and level-of-detail: `with_lod(min, max)`, `with_mip_filter(mode)` (default `Nearest`). - `with_label(label: &str)` - - `build(&mut RenderContext)` -> `Result` + - `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()`). + `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. - - `with_sampled_texture_dim(binding: u32, dim: ViewDimension)` — explicit - dimension (`D2` or `D3`), float sample type, not multisampled. - - `with_sampler(binding: u32)` — filtering sampler type. + - `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. @@ -160,11 +168,14 @@ Render pass: SetPipeline -> SetBindGroup -> Draw - Binding - `with_sampled_texture` declares a 2D filterable float texture binding at - the specified index; shaders declare `texture_2d`. + the specified index with Fragment visibility; shaders declare + `texture_2d`. - `with_sampled_texture_dim` declares a texture binding with explicit view - dimension; shaders declare `texture_2d` or `texture_3d`. - - `with_sampler` declares a filtering sampler binding at the specified index; - shaders declare `sampler` and combine with the texture in sampling calls. + 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 @@ -188,7 +199,8 @@ Render pass: SetPipeline -> SetBindGroup -> Draw - 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`. + - 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 @@ -231,26 +243,39 @@ Render pass: SetPipeline -> SetBindGroup -> Draw ## Requirements Checklist - Functionality - - [ ] Feature flags defined (if applicable) - - [ ] 2D texture creation and upload - - [ ] 3D texture creation and upload - - [ ] Sampler creation (U, V, W addressing) - - [ ] Bind group layout and binding for texture + sampler (2D/3D) + - [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 - - [ ] Public builders and enums in `lambda-rs` - - [ ] Platform wrappers in `lambda-rs-platform` - - [ ] Backwards compatibility assessed + - [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) - - [ ] Format compatibility checks - - [ ] Data length and row padding/rows-per-image validation + - [x] Format compatibility checks + - [x] Data length and row padding/rows-per-image validation - Performance - - [ ] Upload path reasoned and documented + - [x] Upload path reasoned and documented - [ ] Memory footprint characterized for common formats - Documentation and Examples - - [ ] User-facing docs updated - - [ ] Minimal example rendering a textured triangle - - [ ] Migration notes (if applicable) + - [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 @@ -294,20 +319,20 @@ let texture2d = TextureBuilder::new_2d(TextureFormat::Rgba8UnormSrgb) let sampler = SamplerBuilder::new() .linear_clamp() .with_label("albedo-sampler") - .build(&mut render_context)?; + .build(&mut render_context); let layout2d = BindGroupLayoutBuilder::new() - .with_uniform(0, BindingVisibility::Vertex | BindingVisibility::Fragment) + .with_uniform(0, BindingVisibility::VertexAndFragment) .with_sampled_texture(1) // 2D shorthand .with_sampler(2) - .build(&mut render_context)?; + .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)?; + .build(&mut render_context); RC::SetBindGroup { set: 0, group: group_id, dynamic_offsets: vec![] }; ``` @@ -329,6 +354,7 @@ Rust (3D high level) 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) @@ -337,15 +363,15 @@ let texture3d = TextureBuilder::new_3d(TextureFormat::Rgba8Unorm) .build(&mut render_context)?; let layout3d = BindGroupLayoutBuilder::new() - .with_sampled_texture_dim(1, ViewDimension::D3) + .with_sampled_texture_dim(1, ViewDimension::D3, BindingVisibility::Fragment) .with_sampler(2) - .build(&mut render_context)?; + .build(&mut render_context); let group3d = BindGroupBuilder::new() .with_layout(&layout3d) .with_texture(1, &texture3d) .with_sampler(2, &sampler) - .build(&mut render_context)?; + .build(&mut render_context); ``` WGSL snippet (3D) @@ -363,6 +389,15 @@ fn fs_main(in_uv: vec2) -> @location(0) vec4 { ## 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)”. From 7f4307b4e957f0d94a3a72c9ca131ffda976a452 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 10 Nov 2025 14:48:21 -0800 Subject: [PATCH 22/27] [update] tutorials to include conclusions. --- docs/tutorials/textured-cube.md | 17 ++++++++++++++--- docs/tutorials/textured-quad.md | 16 +++++++++++++--- docs/tutorials/uniform-buffers.md | 18 ++++++++++++++++-- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/docs/tutorials/textured-cube.md b/docs/tutorials/textured-cube.md index 6bb77b0a..45cc52f1 100644 --- a/docs/tutorials/textured-cube.md +++ b/docs/tutorials/textured-cube.md @@ -3,13 +3,13 @@ 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-10T00:00:00Z" -version: "0.1.0" +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: "727dbe9b7706e273c525a6ca92426a1aba61cdb6" +repo_commit: "fe79756541e33270eca76638400bb64c6ec9f732" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["tutorial", "graphics", "3d", "push-constants", "textures", "samplers", "rust", "wgpu"] @@ -39,6 +39,7 @@ Reference implementation: `crates/lambda-rs/examples/textured_cube.rs`. - [Step 10 — Handle Window Resize](#step-10) - [Validation](#validation) - [Notes](#notes) +- [Conclusion](#conclusion) - [Putting It Together](#putting-it-together) - [Exercises](#exercises) - [Changelog](#changelog) @@ -489,6 +490,15 @@ fn on_event(&mut self, event: Events) -> Result { - 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. @@ -510,4 +520,5 @@ fn on_event(&mut self, event: Events) -> Result { - 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 index 4e87dad5..d9ffbe82 100644 --- a/docs/tutorials/textured-quad.md +++ b/docs/tutorials/textured-quad.md @@ -3,13 +3,13 @@ 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-10T02:00:00Z" -version: "0.3.2" +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: "fc5eb52c74eb0835225959f941db8e991112b87d" +repo_commit: "fe79756541e33270eca76638400bb64c6ec9f732" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["tutorial", "graphics", "textures", "samplers", "rust", "wgpu"] @@ -38,6 +38,7 @@ Reference implementation: `crates/lambda-rs/examples/textured_quad.rs`. - [Step 9 — Handle Window Resize](#step-9) - [Validation](#validation) - [Notes](#notes) +- [Conclusion](#conclusion) - [Putting It Together](#putting-it-together) - [Exercises](#exercises) - [Changelog](#changelog) @@ -446,6 +447,14 @@ This event handler updates the stored window dimensions when a resize occurs. Th - 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`. @@ -465,6 +474,7 @@ This event handler updates the stored window dimensions when a resize occurs. Th - 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..cc28e66d 100644 --- a/docs/tutorials/uniform-buffers.md +++ b/docs/tutorials/uniform-buffers.md @@ -4,12 +4,13 @@ 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 +40,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 +370,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 +413,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. From 37e02b43d083dac83c61fe705c096846ffec6f1f Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 10 Nov 2025 16:10:43 -0800 Subject: [PATCH 23/27] [update] pipelines to support headless builds on ubuntu. --- .github/workflows/compile_lambda_rs.yml | 11 ++++++++++- .github/workflows/release.yml | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) 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 From ad764ad8822196ab8186c2206a288eaf3db19f81 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 10 Nov 2025 16:20:59 -0800 Subject: [PATCH 24/27] [remove] redundant cast. --- crates/lambda-rs-platform/src/wgpu/render_pass.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/lambda-rs-platform/src/wgpu/render_pass.rs b/crates/lambda-rs-platform/src/wgpu/render_pass.rs index 9f98a4c4..181ab7e7 100644 --- a/crates/lambda-rs-platform/src/wgpu/render_pass.rs +++ b/crates/lambda-rs-platform/src/wgpu/render_pass.rs @@ -331,7 +331,7 @@ impl RenderPassBuilder { }, }, DepthLoadOp::Clear(value) => wgpu::Operations { - load: wgpu::LoadOp::Clear(value as f32), + load: wgpu::LoadOp::Clear(value), store: match dop.store { StoreOp::Store => wgpu::StoreOp::Store, StoreOp::Discard => wgpu::StoreOp::Discard, From 966bebf1c6d1410d24bf82a5f8fab3d7ae96b223 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 10 Nov 2025 16:22:03 -0800 Subject: [PATCH 25/27] [fix] redundant -- --- docs/specs/textures-and-samplers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/textures-and-samplers.md b/docs/specs/textures-and-samplers.md index aec6f069..80ce5f12 100644 --- a/docs/specs/textures-and-samplers.md +++ b/docs/specs/textures-and-samplers.md @@ -179,7 +179,7 @@ Render pass: SetPipeline -> SetBindGroup -> Draw ### Validation and Errors --- Limits and dimensions +- 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`. From 03e1104e5c186761c25a6e7d97ec59db9c2b64e9 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 10 Nov 2025 16:23:37 -0800 Subject: [PATCH 26/27] [remove] redundant field. --- docs/tutorials/uniform-buffers.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/tutorials/uniform-buffers.md b/docs/tutorials/uniform-buffers.md index cc28e66d..c399cd5b 100644 --- a/docs/tutorials/uniform-buffers.md +++ b/docs/tutorials/uniform-buffers.md @@ -3,7 +3,6 @@ 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" last_updated: "2025-11-10T03:00:00Z" version: "0.4.1" engine_workspace_version: "2023.1.30" From c37e14cfa5fe220557da5e62aa456e42f1d34383 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 10 Nov 2025 16:32:07 -0800 Subject: [PATCH 27/27] [add] return statements. --- crates/lambda-rs/src/render/texture.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/lambda-rs/src/render/texture.rs b/crates/lambda-rs/src/render/texture.rs index 429c3109..921825d9 100644 --- a/crates/lambda-rs/src/render/texture.rs +++ b/crates/lambda-rs/src/render/texture.rs @@ -50,10 +50,10 @@ pub enum FilterMode { impl FilterMode { fn to_platform(self) -> platform::FilterMode { - match self { + return match self { FilterMode::Nearest => platform::FilterMode::Nearest, FilterMode::Linear => platform::FilterMode::Linear, - } + }; } } @@ -67,11 +67,11 @@ pub enum AddressMode { impl AddressMode { fn to_platform(self) -> platform::AddressMode { - match self { + return match self { AddressMode::ClampToEdge => platform::AddressMode::ClampToEdge, AddressMode::Repeat => platform::AddressMode::Repeat, AddressMode::MirrorRepeat => platform::AddressMode::MirrorRepeat, - } + }; } } #[derive(Debug, Clone)] @@ -179,13 +179,16 @@ impl TextureBuilder { 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); } - match builder.build(render_context.gpu()) { + + return match builder.build(render_context.gpu()) { Ok(texture) => Ok(Texture { inner: Rc::new(texture), }), @@ -198,7 +201,7 @@ impl TextureBuilder { Err(platform::TextureBuildError::Overflow) => { Err("Overflow while computing texture layout") } - } + }; } }