From 4940d584b7e4818fd19077c82de1cb09832278f3 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 25 Nov 2025 14:23:06 -0800 Subject: [PATCH 01/20] [add] initial specification and update features document. --- docs/features.md | 15 +- .../offscreen-render-targets-and-multipass.md | 504 ++++++++++++++++++ 2 files changed, 514 insertions(+), 5 deletions(-) create mode 100644 docs/specs/offscreen-render-targets-and-multipass.md diff --git a/docs/features.md b/docs/features.md index 5da596aa..f92e1a22 100644 --- a/docs/features.md +++ b/docs/features.md @@ -3,13 +3,13 @@ title: "Cargo Features Overview" document_id: "features-2025-11-17" status: "living" created: "2025-11-17T23:59:00Z" -last_updated: "2025-11-25T02:20:00Z" -version: "0.1.3" +last_updated: "2025-11-25T12:00:00Z" +version: "0.1.4" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "c8f727f3774029135ed1f7a7224288faf7b9e442" +repo_commit: "1cca6ebdf7cb0b786b3c46561b60fa2e44eecea4" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["guide", "features", "validation", "cargo"] @@ -44,8 +44,8 @@ This document enumerates the primary Cargo features exposed by the workspace rel ## Render Validation Umbrella features (crate: `lambda-rs`) -- `render-validation`: enables common builder/pipeline validation logs (MSAA counts, depth clear advisories, stencil format upgrades) by composing granular validation features. -- `render-validation-strict`: includes `render-validation` and enables per-draw SetPipeline-time compatibility checks by composing additional granular encoder features. +- `render-validation`: enables common builder/pipeline validation logs (MSAA counts, depth clear advisories, stencil format upgrades, render-target compatibility) by composing granular validation features. This umbrella includes `render-validation-msaa`, `render-validation-depth`, `render-validation-stencil`, `render-validation-pass-compat`, and `render-validation-render-targets`. +- `render-validation-strict`: includes `render-validation` and enables per-draw SetPipeline-time compatibility checks by composing additional granular encoder features. This umbrella additionally enables `render-validation-encoder`. - `render-validation-all`: superset of `render-validation-strict` and enables device-probing advisories and instancing validation. This umbrella includes all granular render-validation flags, including `render-validation-instancing`. Granular features (crate: `lambda-rs`) @@ -61,6 +61,10 @@ Granular features (crate: `lambda-rs`) - Validates that `instances.start <= instances.end` and treats `start == end` as a no-op (draw is skipped). - Ensures that all vertex buffer slots marked as per-instance on the active pipeline have been bound in the current render pass. - Adds per-draw checks proportional to the number of instanced draws and per-instance slots; SHOULD be enabled only when diagnosing instancing issues. +- `render-validation-render-targets`: validates compatibility between offscreen `RenderTarget`s, `RenderPass` descriptions, and `RenderPipeline`s. Behavior: + - Verifies that pass and pipeline color formats and sample counts match the selected render target. + - Emits configuration logs when a pass targets an offscreen surface with significantly different size from the presentation surface or when a target lacks the attachments implied by pass configuration. + - Expected runtime cost is low to moderate; checks run at builder time and at the start of each pass, not per draw. Always-on safeguards (debug and release) - Clamp depth clear values to `[0.0, 1.0]`. @@ -82,6 +86,7 @@ Usage examples - `cargo test -p lambda-rs --features render-validation-msaa` ## Changelog +- 0.1.4 (2025-11-25): Document `render-validation-render-targets`, record its inclusion in the `render-validation` umbrella feature, and update metadata. - 0.1.3 (2025-11-25): Rename the instancing validation feature to `render-validation-instancing`, clarify umbrella composition, and update metadata. - 0.1.2 (2025-11-25): Clarify umbrella versus granular validation features, record that `render-validation-all` includes `render-instancing-validation`, and update metadata. - 0.1.1 (2025-11-25): Document `render-instancing-validation` behavior and update metadata. diff --git a/docs/specs/offscreen-render-targets-and-multipass.md b/docs/specs/offscreen-render-targets-and-multipass.md new file mode 100644 index 00000000..a6802f35 --- /dev/null +++ b/docs/specs/offscreen-render-targets-and-multipass.md @@ -0,0 +1,504 @@ +--- +title: "Offscreen Render Targets and Multipass Rendering" +document_id: "offscreen-render-targets-2025-11-25" +status: "draft" +created: "2025-11-25T00:00:00Z" +last_updated: "2025-11-25T00: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: "1cca6ebdf7cb0b786b3c46561b60fa2e44eecea4" +owners: ["lambda-sh"] +reviewers: ["engine", "rendering"] +tags: ["spec", "rendering", "offscreen", "multipass"] +--- + +# Offscreen Render Targets and Multipass Rendering + +Summary +- Introduces offscreen render targets as first-class resources in `lambda-rs` + for render-to-texture workflows (post-processing, shadow maps, UI + composition). +- Defines multipass rendering semantics and API changes so passes can write to + and sample from offscreen targets without exposing `wgpu` types. +- Preserves existing builder and command patterns while extending + `lambda-rs-platform` to support textures that are both render attachments and + sampled resources. + +## Scope + +### Goals + +- Add first-class offscreen render targets with color and optional depth + attachments in `lambda-rs`. +- Allow render passes to target either the presentation surface or an offscreen + render target. +- Enable multipass workflows where later passes sample from textures produced + by earlier passes. +- Provide validation and feature flags for render-target compatibility, sample + count and format mismatches, and common configuration pitfalls. + +### Non-Goals + +- Multiple simultaneous color attachments (MRT) per pass; a single color + attachment per pass remains the default in this specification. +- Compute pipelines, storage textures, and general framegraph scheduling; + separate specifications cover these areas. +- Headless contexts without a presentation surface; this specification assumes + a window-backed `RenderContext`. +- Vendor-specific optimizations beyond what `wgpu` exposes via limits and + capabilities. + +## Terminology + +- Offscreen render target: A 2D color texture with an optional depth attachment + that can be bound as a render attachment but is not presented directly to the + window surface. +- Render target: Either the default presentation surface or an offscreen render + target. +- Multipass rendering: A sequence of two or more render passes in a single + frame where later passes consume the results of earlier passes (for example, + post-processing or shadow map sampling). +- Default render target: The swapchain-backed surface associated with a + `RenderContext`. +- Ping-pong target: A pair of offscreen render targets alternated between read + and write roles across passes. + +## Architecture Overview + +- High-level (`lambda-rs`) + - Introduces `RenderTarget` and `RenderTargetBuilder` in + `lambda::render::target` to construct offscreen color (and optional depth) + attachments sized independently of the window. + - Extends `RenderPassBuilder` so a pass can declare a target: the default + surface or a specific `RenderTarget`. + - Extends `RenderPipelineBuilder` so pipelines can declare the expected color + format independently of the surface while still aligning sample counts and + depth formats with the active pass. +- Platform (`lambda-rs-platform`) + - Extends `TextureBuilder` to create textures that include + `RENDER_ATTACHMENT` usage in addition to sampling usage. + - Reuses the existing render pass and pipeline builders to bind offscreen + texture views as color attachments and to configure color target formats + from texture formats instead of only surface formats. + +Data flow (setup → per-frame multipass): +``` +RenderTargetBuilder + --> RenderTarget { color_texture, depth_format, sample_count } + └── bound into bind groups for sampling + +RenderPassBuilder::new() + .with_target(&offscreen) // or default surface + .with_depth_clear(1.0) // optional depth ops + .with_multi_sample(1 | 2 | 4 | 8) + --> RenderPass + └── RenderContext::attach_render_pass(...) + +RenderPipelineBuilder::new() + .with_color_format(TextureFormat::Rgba8UnormSrgb) + .with_depth_format(DepthFormat::Depth32Float) + .with_multi_sample(...) + --> RenderPipeline + +Per-frame commands: + BeginRenderPass { pass_id, viewport } // surface or offscreen target + SetPipeline / SetBindGroup / Draw... + EndRenderPass + (repeat for additional passes) +``` + +## Design + +### API Surface + +#### High-level layer (`lambda-rs`) + +- Module `lambda::render::target` + - `pub struct RenderTarget` + - Represents a 2D offscreen render target with a single color attachment + and an optional depth attachment. + - Encapsulates texture size, color format, depth format (if any), and + sample count. + - Exposes immutable accessors for binding in shaders and builders: + - `pub fn size(&self) -> (u32, u32)` + - `pub fn color_format(&self) -> texture::TextureFormat` + - `pub fn depth_format(&self) -> Option` + - `pub fn sample_count(&self) -> u32` + - `pub fn color_texture(&self) -> &texture::Texture` + - Provides explicit destruction: + - `pub fn destroy(self, render_context: &mut RenderContext)` + - `pub struct RenderTargetBuilder` + - Builder for constructing `RenderTarget` values. + - API: + - `pub fn new() -> Self` + - `pub fn with_color(mut self, format: texture::TextureFormat, width: u32, height: u32) -> Self` + - `pub fn with_depth(mut self, format: texture::DepthFormat) -> Self` + - `pub fn with_multi_sample(mut self, samples: u32) -> Self` + - `pub fn with_label(mut self, label: &str) -> Self` + - `pub fn build(self, render_context: &mut RenderContext) -> Result` + - Behavior: + - Fails with `RenderTargetError::MissingColorAttachment` when no color + attachment was configured. + - Fails with `RenderTargetError::InvalidSize` when width or height is + zero. + - Defaults: + - Size defaults to the current surface size when not explicitly + provided. + - Sample count defaults to `1` (no multi-sampling). + - `pub enum RenderTargetError` + - `InvalidSize { width: u32, height: u32 }` + - `UnsupportedSampleCount { requested: u32 }` + - `UnsupportedFormat { message: String }` + - `DeviceError(String)` for device-level failures returned by the platform + layer. + +- Module `lambda::render::render_pass` + - Extend `RenderPassBuilder` with target selection: + - `pub fn with_target(mut self, target: &RenderTarget) -> Self` + - Configures the pass to use the provided `RenderTarget` color and depth + attachments instead of the default surface and context-managed depth + texture. + - The pass inherits the target size and sample count; explicit + `with_multi_sample` on the pass MUST align with the target sample count + (see Behavior). + - Existing methods (for example, `with_clear_color`, `with_depth_clear`, + `with_stencil_clear`, `with_multi_sample`) remain unchanged and apply to + the selected target. + - Extend `RenderPass` to expose its target: + - `pub(crate) fn uses_default_surface(&self) -> bool` + - `pub(crate) fn target(&self) -> Option` + - Used internally by `RenderContext` to choose attachments when encoding + passes. + +- Module `lambda::render::pipeline` + - Extend `RenderPipelineBuilder` to allow explicit color format selection: + - `pub fn with_color_format(mut self, format: texture::TextureFormat) -> Self` + - Declares the color format expected by the fragment stage. + - When omitted: + - For surface-backed passes, defaults to the current surface format. + - For offscreen passes with a `RenderTarget`, defaults to the target + color format. + - `RenderPipelineBuilder::build` behavior changes: + - Derives the color target format from: + - Explicit `with_color_format`, when provided. + - Otherwise from the associated `RenderPass` target: + - `RenderContext::surface_format()` for the default surface. + - `RenderTarget::color_format()` for offscreen targets. + - Retains depth and sample count alignment rules: + - Depth format continues to be derived from `with_depth_format` or the + pass depth attachment, including stencil upgrades. + - Sample count is aligned to the pass sample count, as in the MSAA spec. + +- Module `lambda::render::texture` + - Extend `TextureBuilder` to support render-target usage: + - `pub fn for_render_target(mut self) -> Self` + - Marks the texture for combined sampling and render-attachment usage. + - Maps to `TEXTURE_BINDING | RENDER_ATTACHMENT | COPY_SRC` at the + platform layer. + - Existing uses that do not call `for_render_target` continue to produce + sampled-only textures. + +- Module `lambda::render::command` + - No new commands are required; multipass rendering continues to use + `RenderCommand::BeginRenderPass` / `EndRenderPass` with different pass + handles. + - The semantics of `BeginRenderPass` change to: + - If the referenced `RenderPass` uses the default surface, the pass writes + to the swapchain (with optional MSAA resolve). + - If the `RenderPass` references an offscreen `RenderTarget`, the pass + writes to the target's color attachment (with optional depth). + +- Module `lambda::render::RenderContext` + - Extend internal state with an optional pool of offscreen resources owned by + `RenderTarget`: + - `render_targets` remains managed by application code via `RenderTarget`; + `RenderContext` only borrows platform textures when encoding passes. + - Expose the surface size for convenience: + - `pub fn surface_size(&self) -> (u32, u32)` + - Used by `RenderTargetBuilder::new()` as a default size when none is + provided. + +#### Platform layer (`lambda-rs-platform`) + +- Module `lambda_platform::wgpu::texture` + - Extend `TextureBuilder` usage flags: + - Add internal `usage_render_attachment: bool` field. + - Add `pub fn with_render_attachment_usage(mut self, enabled: bool) -> Self` + - When enabled, include `wgpu::TextureUsages::RENDER_ATTACHMENT` in the + created texture and its default view usage. + - Offscreen color targets use: + - `TEXTURE_BINDING | RENDER_ATTACHMENT | COPY_SRC` for flexible sampling + and optional readback. +- Module `lambda_platform::wgpu::pipeline` + - Extend `RenderPipelineBuilder`: + - Add `pub fn with_color_target_format(mut self, format: texture::TextureFormat) -> Self` + - Converts the texture format into a `wgpu::TextureFormat` and stores it + as the color target format. + - `with_surface_color_target` remains for surface-backed pipelines and is + used when the color target should match the swapchain format. +- Module `lambda_platform::wgpu::render_pass` + - No structural changes are required; existing `RenderColorAttachments` + already accepts arbitrary `TextureView` references. + - Offscreen passes provide `TextureViewRef` from `Texture` at pass-encode + time. + +### Behavior + +#### RenderTarget creation and lifetime + +- Creation + - `RenderTargetBuilder::build` MUST fail when: + - `with_color` was never called. + - Width or height is zero. + - The requested sample count is not supported by the device for the chosen + format. + - When no explicit size is set, the builder uses the current + `RenderContext::surface_size()` as the color attachment size. + - Depth is optional: + - When `with_depth` is omitted, the target has no depth attachment. + - When `with_depth` is provided, the target allocates a depth texture using + `texture::DepthFormat`. +- Lifetime + - `RenderTarget` owns its color (and optional depth) textures. + - `RenderPassBuilder::with_target` clones the target handle; the application + MUST keep the `RenderTarget` alive for as long as any attached passes and + pipelines are used. + - `RenderTarget::destroy` releases underlying resources; further use in + passes is invalid and SHOULD be prevented by application code. + +#### Render pass targeting semantics + +- Default behavior (existing) + - When `RenderPassBuilder` is used without `with_target`, the pass targets + the presentation surface: + - Color attachment: swapchain view (with optional MSAA resolve). + - Depth attachment: `RenderContext`-managed depth texture. +- Offscreen behavior (new) + - When `with_target(&offscreen)` is used: + - Color attachment: offscreen target color texture view. + - Depth attachment: + - When the target has a depth format, a depth texture is allocated for + the target and used as the pass depth attachment. + - When the target has no depth format, depth is disabled unless the pass + explicitly requests depth operations, in which case the pass MUST + produce a configuration error. + - Sample count: + - The pass sample count MUST equal the target sample count. + - When `with_multi_sample` is called with a different value, the pass + aligns its sample count to `RenderTarget::sample_count()` and, under + validation features, logs an error. + - Color load/store operations, depth operations, and stencil operations apply + to the offscreen attachments exactly as they apply to the surface-backed + attachments. + +#### Multipass flows + +- Command ordering + - Multipass rendering is expressed as multiple + `BeginRenderPass`/`EndRenderPass` pairs in a single command list. + - Nested passes remain invalid and MUST continue to be rejected by + `RenderContext::encode_pass`. +- Data dependencies + - Passes that render into an offscreen target produce textures that MAY be + sampled in subsequent passes: + - Typical pattern: + - Pass 1: scene → offscreen color (and depth). + - Pass 2: fullscreen quad sampling offscreen.color → surface. + - The specification does not introduce an explicit framegraph; ordering is + determined solely by the command sequence. +- Hazards + - Writing to a `RenderTarget` and sampling from the same texture in the same + pass is undefined behavior and MUST NOT be supported; validation MAY detect + obvious cases but cannot guarantee all hazards are caught. + - Using the same `RenderTarget` as the destination for multiple passes in one + frame is supported; the clear/load operations on each pass determine + whether results accumulate or overwrite. + +#### Pipeline and target compatibility + +- Color format + - For surface-backed passes: + - When `with_color_format` is omitted, the pipeline color format is derived + from `RenderContext::surface_format()` (existing behavior). + - When `with_color_format` is provided and differs from the surface + format, pipeline creation MUST fail under `render-validation-pass-compat` + or debug assertions. + - For offscreen passes: + - When `with_color_format` is omitted, the pipeline color format is derived + from `RenderTarget::color_format()`. + - When `with_color_format` is provided and differs from the target format, + pipeline creation MUST either: + - Fail configuration-time validation, or + - Align to the target format and log an error under + `render-validation-render-targets`. +- Depth format + - Depth behavior follows the depth/stencil specification: + - When the pass requests stencil operations, the depth format MUST include + a stencil aspect; otherwise, the engine upgrades to + `Depth24PlusStencil8` and logs an error under + `render-validation-stencil`. + - For offscreen targets with a depth format, pipeline depth format MUST + match the target depth format; mismatches are treated as configuration + errors or aligned with logging, consistent with the depth spec. +- Sample count + - Pass and pipeline sample counts are aligned as in the MSAA specification: + - Pipeline sample count is aligned to the pass sample count. + - For offscreen passes, both pass and pipeline sample counts MUST equal the + target sample count; invalid sample counts are clamped to `1` with + validation logs. + +### Validation and Errors + +- Builder-level validation (always on) + - `RenderTargetBuilder::build` MUST: + - Reject zero width or height. + - Clamp sample counts less than `1` to `1`. + - `RenderPassBuilder::with_target` MUST ensure that a target has a color + attachment; targets without color are invalid for the current specification + (depth-only targets MAY be added later). + - `RenderPipelineBuilder::build` MUST: + - Validate that the chosen color format is supported for render attachments + on the device. +- Runtime validation (feature-gated) + - New granular feature (crate: `lambda-rs`): + - `render-validation-render-targets` + - Validates compatibility between `RenderTarget`, `RenderPass`, and + `RenderPipeline`: + - Verifies that pass and pipeline color formats match the target color + format. + - Verifies that pass and pipeline sample counts equal the target sample + count. + - Logs when a pass references a `RenderTarget` whose size differs + significantly from the surface size (for example, for debug + visibility issues). + - Expected runtime cost: low to moderate; checks occur at pass/pipeline + build time and pass begin time, not per draw. + - Existing granular features: + - `render-validation-pass-compat` continues to enforce SetPipeline-time + compatibility checks and MUST be updated to consider offscreen targets. + - `render-validation-msaa`, `render-validation-depth`, + `render-validation-stencil`, and `render-validation-device` remain + unchanged but apply equally to offscreen passes. +- Build-type behavior + - Debug builds (`debug_assertions`): + - All render-target validations are active regardless of feature flags. + - Release builds: + - Only cheap size and sample-count clamps are always on. + - Detailed compatibility logs require `render-validation-render-targets` or + the appropriate umbrella features. + +## Constraints and Rules + +- RenderTarget constraints + - Width and height MUST be strictly positive. + - Color formats are limited to `TextureFormat::Rgba8Unorm` and + `TextureFormat::Rgba8UnormSrgb` in the initial implementation. + - Depth formats are limited to `DepthFormat::Depth32Float`, + `DepthFormat::Depth24Plus`, and `DepthFormat::Depth24PlusStencil8`. + - Sample counts MUST be one of the device-supported values; the initial + spec assumes {1, 2, 4, 8}. +- Pass constraints + - A pass with an offscreen target MUST not also target the surface; the + target is exclusive. + - Nested `BeginRenderPass`/`EndRenderPass` sequences remain invalid. + - Viewport and scissor rectangles are expressed in target-relative + coordinates when an offscreen target is selected. +- Pipeline constraints + - Pipelines used with offscreen passes MUST declare a color target; vertex- + only pipelines without a fragment stage are not compatible with offscreen + color passes in this revision. + +## Performance Considerations + +- Use reduced-resolution offscreen targets for expensive post-processing + effects (for example, half-resolution bloom). + - Rationale: Smaller render targets reduce fill-rate and bandwidth demands + while preserving acceptable visual quality for blurred or combined passes. +- Reuse `RenderTarget` instances across frames instead of recreating them. + - Rationale: Repeated allocation and destruction of GPU textures can fragment + memory and increase driver overhead; long-lived targets amortize setup + costs. +- Prefer sample count `1` for intermediate post-processing passes and limit + multi-sampling to geometry passes. + - Rationale: MSAA increases memory bandwidth and shader cost; geometric + passes benefit most, while post-process passes typically do not. +- Pack related passes that use the same `RenderTarget` close together in the + command stream. + - Rationale: Grouping passes reduces state changes and keeps relevant + resources warm in caches and descriptor pools. + +## Requirements Checklist + +- Functionality + - [ ] `RenderTarget` and `RenderTargetBuilder` implemented in + `crates/lambda-rs/src/render/target.rs`. + - [ ] Offscreen targeting added to `RenderPassBuilder` and `RenderPass`. + - [ ] Offscreen targeting supported in `RenderContext::render`. + - [ ] Edge cases handled (invalid size, unsupported sample count, missing + depth when required). +- API Surface + - [ ] High-level public types and builders added in `lambda-rs`. + - [ ] Platform texture usage and pipeline color target changes implemented in + `lambda-rs-platform`. + - [ ] Backwards compatibility assessed for existing surface-backed paths. +- Validation and Errors + - [ ] `render-validation-render-targets` feature implemented and composed + into umbrella validation features. + - [ ] Pass/pipeline/target compatibility checks implemented. + - [ ] Device limit checks for offscreen formats/sample counts implemented. +- Performance + - [ ] Critical render-target creation paths profiled or reasoned about. + - [ ] Memory usage for long-lived render targets characterized. + - [ ] Performance recommendations validated against representative examples. +- Documentation and Examples + - [ ] Rendering guide updated to include offscreen/multipass examples. + - [ ] Minimal render-to-texture example added under + `crates/lambda-rs/examples/`. + - [ ] Migration notes added for consumers adopting offscreen targets. + +## Verification and Testing + +- Unit tests + - `RenderTargetBuilder` validation: + - Invalid sizes and sample counts. + - Mapping to platform texture builder usage flags. + - Pipeline color format selection: + - Surface-backed vs offscreen-backed passes. + - Commands: + - `cargo test --workspace` +- Integration tests and examples + - Render-to-texture example: + - Pass 1: scene → offscreen. + - Pass 2: fullscreen quad sampling offscreen → surface. + - Shadow-map-style example: + - Depth-only offscreen target feeding a lighting pass. + - Commands: + - `cargo run -p lambda-rs --example offscreen_post` +- Manual checks + - Visual confirmation that: + - Offscreen-only passes do not produce visible output until sampled. + - Misconfigured formats or sample counts emit actionable validation logs + when validation features are enabled. + +## Compatibility and Migration + +- The offscreen render target and multipass API is additive: + - Existing code that uses only surface-backed passes and pipelines continues + to compile and render unchanged. + - New APIs are exposed via `RenderTargetBuilder`, `RenderPassBuilder`, and + `RenderPipelineBuilder` methods; existing method signatures remain + compatible. +- Consumers MAY adopt offscreen targets incrementally: + - Start with post-processing or UI composition that samples a single + offscreen color target. + - Extend to depth-based passes (for example, shadow maps) when depth + attachment support is implemented. + +## Changelog + +- 2025-11-25 (v0.1.0) — Initial draft specifying offscreen render targets, + multipass semantics, high-level and platform API additions, validation + behavior, and testing expectations. From e924836491759efa083e9e27e462b2bce22ac317 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 25 Nov 2025 14:36:31 -0800 Subject: [PATCH 02/20] [update] texture to prepare for offscreen rendering. --- crates/lambda-rs-platform/src/wgpu/texture.rs | 25 +++++++++++++++++++ crates/lambda-rs/src/render/mod.rs | 8 ++++++ crates/lambda-rs/src/render/texture.rs | 11 ++++++++ .../offscreen-render-targets-and-multipass.md | 3 ++- 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/lambda-rs-platform/src/wgpu/texture.rs b/crates/lambda-rs-platform/src/wgpu/texture.rs index 60acf215..6d91b032 100644 --- a/crates/lambda-rs-platform/src/wgpu/texture.rs +++ b/crates/lambda-rs-platform/src/wgpu/texture.rs @@ -555,6 +555,9 @@ pub struct TextureBuilder { usage_texture_binding: bool, /// Include `COPY_DST` usage when uploading initial data. usage_copy_dst: bool, + /// Include `RENDER_ATTACHMENT` usage when the texture is used as a color + /// render target. + usage_render_attachment: bool, /// Optional tightly‑packed pixel payload for level 0 (rows are `width*bpp`). data: Option>, } @@ -571,6 +574,7 @@ impl TextureBuilder { depth: 1, usage_texture_binding: true, usage_copy_dst: true, + usage_render_attachment: false, data: None, }; } @@ -586,6 +590,7 @@ impl TextureBuilder { depth: 0, usage_texture_binding: true, usage_copy_dst: true, + usage_render_attachment: false, data: None, }; } @@ -619,6 +624,13 @@ impl TextureBuilder { return self; } + /// Control render attachment usage. Defaults to `false` so existing sampled + /// textures remain sampled‑only. + pub fn with_render_attachment_usage(mut self, enabled: bool) -> Self { + self.usage_render_attachment = enabled; + return self; + } + /// Attach a debug label. pub fn with_label(mut self, label: &str) -> Self { self.label = Some(label.to_string()); @@ -683,6 +695,9 @@ impl TextureBuilder { if self.usage_copy_dst { usage |= wgpu::TextureUsages::COPY_DST; } + if self.usage_render_attachment { + usage |= wgpu::TextureUsages::RENDER_ATTACHMENT; + } let descriptor = wgpu::TextureDescriptor { label: self.label.as_deref(), @@ -910,4 +925,14 @@ mod tests { assert_eq!(d.min_filter, wgpu::FilterMode::Linear); assert_eq!(d.mipmap_filter, wgpu::FilterMode::Linear); } + + #[test] + fn texture_builder_enables_render_attachment_usage() { + let format = TextureFormat::Rgba8Unorm; + let builder = TextureBuilder::new_2d(format) + .with_size(4, 4) + .with_render_attachment_usage(true); + + assert!(builder.usage_render_attachment); + } } diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index 1f1f66e3..2281d4bf 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -231,6 +231,14 @@ pub struct RenderContext { pub type ResourceId = usize; impl RenderContext { + /// Current surface size in pixels. + /// + /// This reflects the most recent configured surface dimensions and is used + /// as a default for render‑target creation and viewport setup. + pub fn surface_size(&self) -> (u32, u32) { + return self.size; + } + /// Attach a render pipeline and return a handle for use in commands. pub fn attach_pipeline(&mut self, pipeline: RenderPipeline) -> ResourceId { let id = self.render_pipelines.len(); diff --git a/crates/lambda-rs/src/render/texture.rs b/crates/lambda-rs/src/render/texture.rs index acd87060..df46c0d9 100644 --- a/crates/lambda-rs/src/render/texture.rs +++ b/crates/lambda-rs/src/render/texture.rs @@ -188,6 +188,17 @@ impl TextureBuilder { return self; } + /// Configure this texture for use as a render target. + /// + /// Render target textures are created with usage flags suitable for both + /// sampling and attachment, and allow copying from the texture for + /// readback. + pub fn for_render_target(self) -> Self { + // At the engine layer this is a marker method. Usage flags are wired + // in `build` when constructing the platform builder. + return self; + } + /// Create the texture and upload initial data if provided. pub fn build( self, diff --git a/docs/specs/offscreen-render-targets-and-multipass.md b/docs/specs/offscreen-render-targets-and-multipass.md index a6802f35..2b797269 100644 --- a/docs/specs/offscreen-render-targets-and-multipass.md +++ b/docs/specs/offscreen-render-targets-and-multipass.md @@ -441,8 +441,9 @@ Per-frame commands: depth when required). - API Surface - [ ] High-level public types and builders added in `lambda-rs`. - - [ ] Platform texture usage and pipeline color target changes implemented in + - [x] Platform texture usage for render targets implemented in `lambda-rs-platform`. + - [ ] Pipeline color target changes implemented in `lambda-rs-platform`. - [ ] Backwards compatibility assessed for existing surface-backed paths. - Validation and Errors - [ ] `render-validation-render-targets` feature implemented and composed From 4e3bc922a076b259922210cfab89c8ebcc1ca7d1 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 25 Nov 2025 14:50:11 -0800 Subject: [PATCH 03/20] [update] texture to support being a render target and copy source destination flags. --- crates/lambda-rs-platform/src/wgpu/texture.rs | 24 +++++++++++++++++ crates/lambda-rs/src/render/texture.rs | 27 ++++++++++++++++--- .../offscreen-render-targets-and-multipass.md | 11 +++++--- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/crates/lambda-rs-platform/src/wgpu/texture.rs b/crates/lambda-rs-platform/src/wgpu/texture.rs index 6d91b032..44dbd92a 100644 --- a/crates/lambda-rs-platform/src/wgpu/texture.rs +++ b/crates/lambda-rs-platform/src/wgpu/texture.rs @@ -555,6 +555,8 @@ pub struct TextureBuilder { usage_texture_binding: bool, /// Include `COPY_DST` usage when uploading initial data. usage_copy_dst: bool, + /// Include `COPY_SRC` usage when the texture is used as a readback source. + usage_copy_source: bool, /// Include `RENDER_ATTACHMENT` usage when the texture is used as a color /// render target. usage_render_attachment: bool, @@ -574,6 +576,7 @@ impl TextureBuilder { depth: 1, usage_texture_binding: true, usage_copy_dst: true, + usage_copy_source: false, usage_render_attachment: false, data: None, }; @@ -590,6 +593,7 @@ impl TextureBuilder { depth: 0, usage_texture_binding: true, usage_copy_dst: true, + usage_copy_source: false, usage_render_attachment: false, data: None, }; @@ -624,6 +628,13 @@ impl TextureBuilder { return self; } + /// Control copy‑source usage. Defaults to `false` so sampled textures do + /// not incur additional usage flags unless explicitly requested. + pub fn with_copy_source_usage(mut self, enabled: bool) -> Self { + self.usage_copy_source = enabled; + return self; + } + /// Control render attachment usage. Defaults to `false` so existing sampled /// textures remain sampled‑only. pub fn with_render_attachment_usage(mut self, enabled: bool) -> Self { @@ -695,6 +706,9 @@ impl TextureBuilder { if self.usage_copy_dst { usage |= wgpu::TextureUsages::COPY_DST; } + if self.usage_copy_source { + usage |= wgpu::TextureUsages::COPY_SRC; + } if self.usage_render_attachment { usage |= wgpu::TextureUsages::RENDER_ATTACHMENT; } @@ -935,4 +949,14 @@ mod tests { assert!(builder.usage_render_attachment); } + + #[test] + fn texture_builder_enables_copy_source_usage() { + let format = TextureFormat::Rgba8Unorm; + let builder = TextureBuilder::new_2d(format) + .with_size(4, 4) + .with_copy_source_usage(true); + + assert!(builder.usage_copy_source); + } } diff --git a/crates/lambda-rs/src/render/texture.rs b/crates/lambda-rs/src/render/texture.rs index df46c0d9..7a065daa 100644 --- a/crates/lambda-rs/src/render/texture.rs +++ b/crates/lambda-rs/src/render/texture.rs @@ -128,6 +128,7 @@ pub struct TextureBuilder { height: u32, depth: u32, data: Option>, // tightly packed rows + is_render_target: bool, } impl TextureBuilder { @@ -140,6 +141,7 @@ impl TextureBuilder { height: 0, depth: 1, data: None, + is_render_target: false, }; } @@ -157,6 +159,7 @@ impl TextureBuilder { // Depth > 1 ensures the 3D path is chosen once size is provided. depth: 2, data: None, + is_render_target: false, }; } @@ -193,9 +196,8 @@ impl TextureBuilder { /// Render target textures are created with usage flags suitable for both /// sampling and attachment, and allow copying from the texture for /// readback. - pub fn for_render_target(self) -> Self { - // At the engine layer this is a marker method. Usage flags are wired - // in `build` when constructing the platform builder. + pub fn for_render_target(mut self) -> Self { + self.is_render_target = true; return self; } @@ -213,6 +215,12 @@ impl TextureBuilder { .with_size_3d(self.width, self.height, self.depth) }; + if self.is_render_target { + builder = builder + .with_render_attachment_usage(true) + .with_copy_source_usage(true); + } + if let Some(ref label) = self.label { builder = builder.with_label(label); } @@ -319,3 +327,16 @@ impl SamplerBuilder { }; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn texture_builder_marks_render_target_usage() { + let builder = + TextureBuilder::new_2d(TextureFormat::Rgba8Unorm).for_render_target(); + + assert!(builder.is_render_target); + } +} diff --git a/docs/specs/offscreen-render-targets-and-multipass.md b/docs/specs/offscreen-render-targets-and-multipass.md index 2b797269..4ca5d4d9 100644 --- a/docs/specs/offscreen-render-targets-and-multipass.md +++ b/docs/specs/offscreen-render-targets-and-multipass.md @@ -3,13 +3,13 @@ title: "Offscreen Render Targets and Multipass Rendering" document_id: "offscreen-render-targets-2025-11-25" status: "draft" created: "2025-11-25T00:00:00Z" -last_updated: "2025-11-25T00:00:00Z" -version: "0.1.0" +last_updated: "2025-11-25T01: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: "1cca6ebdf7cb0b786b3c46561b60fa2e44eecea4" +repo_commit: "e924836491759efa083e9e27e462b2bce22ac317" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "rendering", "offscreen", "multipass"] @@ -443,6 +443,8 @@ Per-frame commands: - [ ] High-level public types and builders added in `lambda-rs`. - [x] Platform texture usage for render targets implemented in `lambda-rs-platform`. + - [x] Engine texture builder helpers for render targets implemented in + `lambda-rs`. - [ ] Pipeline color target changes implemented in `lambda-rs-platform`. - [ ] Backwards compatibility assessed for existing surface-backed paths. - Validation and Errors @@ -500,6 +502,9 @@ Per-frame commands: ## Changelog +- 2025-11-25 (v0.1.1) — Updated requirements checklist to reflect implemented + engine texture builder helpers and aligned metadata with current workspace + revision. - 2025-11-25 (v0.1.0) — Initial draft specifying offscreen render targets, multipass semantics, high-level and platform API additions, validation behavior, and testing expectations. From aabd30388e2d111ed1c6f42c355c2af9d53f8d5c Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 25 Nov 2025 15:50:47 -0800 Subject: [PATCH 04/20] [add] depth texture implementation to prepare for offscreen rendering and update the render context to not rely on the platform depth texture. --- crates/lambda-rs/src/render/mod.rs | 72 ++++++++--------- crates/lambda-rs/src/render/texture.rs | 106 +++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 36 deletions(-) diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index 2281d4bf..c04e4025 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -155,17 +155,10 @@ 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), - ); + // Initialize the render context with an engine-level depth format. + let depth_format = texture::DepthFormat::Depth32Float; - return Ok(RenderContext { + let mut render_context = RenderContext { label: name, instance, surface, @@ -174,8 +167,8 @@ impl RenderContextBuilder { present_mode, texture_usage, size, - depth_texture, depth_format, + depth_texture: None, depth_sample_count: 1, msaa_color: None, msaa_sample_count: 1, @@ -185,7 +178,18 @@ impl RenderContextBuilder { bind_groups: vec![], buffers: vec![], seen_error_messages: HashSet::new(), - }); + }; + + // Initialize a depth texture matching the surface size using the + // high-level depth texture builder. + let depth_texture = texture::DepthTextureBuilder::new() + .with_size(size.0.max(1), size.1.max(1)) + .with_format(depth_format) + .with_label("lambda-depth") + .build(&render_context); + render_context.depth_texture = Some(depth_texture); + + return Ok(render_context); } } @@ -214,8 +218,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, + depth_texture: Option, + depth_format: texture::DepthFormat, depth_sample_count: u32, msaa_color: Option, msaa_sample_count: u32, @@ -320,15 +324,14 @@ impl RenderContext { 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_sample_count(self.depth_sample_count) - .with_label("lambda-depth") - .build(self.gpu()), - ); + // Recreate depth texture to match new size using the high-level builder. + let depth_texture = texture::DepthTextureBuilder::new() + .with_size(self.size.0.max(1), self.size.1.max(1)) + .with_format(self.depth_format) + .with_sample_count(self.depth_sample_count) + .with_label("lambda-depth") + .build(self); + self.depth_texture = Some(depth_texture); // Drop MSAA color target so it is rebuilt on demand with the new size. self.msaa_color = None; } @@ -356,7 +359,7 @@ impl RenderContext { } pub(crate) fn depth_format(&self) -> platform::texture::DepthFormat { - return self.depth_format; + return self.depth_format.to_platform(); } pub(crate) fn supports_surface_sample_count( @@ -511,8 +514,7 @@ impl RenderContext { // If stencil is requested on the pass, ensure we use a stencil-capable format. if pass.stencil_operations().is_some() - && self.depth_format - != platform::texture::DepthFormat::Depth24PlusStencil8 + && self.depth_format != texture::DepthFormat::Depth24PlusStencil8 { #[cfg(any( debug_assertions, @@ -522,8 +524,7 @@ impl RenderContext { "Render pass has stencil ops but depth format {:?} lacks stencil; upgrading to Depth24PlusStencil8", self.depth_format ); - self.depth_format = - platform::texture::DepthFormat::Depth24PlusStencil8; + self.depth_format = texture::DepthFormat::Depth24PlusStencil8; } let format_mismatch = self @@ -536,14 +537,13 @@ impl RenderContext { || self.depth_sample_count != desired_samples || format_mismatch { - self.depth_texture = Some( - platform::texture::DepthTextureBuilder::new() - .with_size(self.size.0.max(1), self.size.1.max(1)) - .with_format(self.depth_format) - .with_sample_count(desired_samples) - .with_label("lambda-depth") - .build(self.gpu()), - ); + let depth_texture = texture::DepthTextureBuilder::new() + .with_size(self.size.0.max(1), self.size.1.max(1)) + .with_format(self.depth_format) + .with_sample_count(desired_samples) + .with_label("lambda-depth") + .build(self); + self.depth_texture = Some(depth_texture); self.depth_sample_count = desired_samples; } diff --git a/crates/lambda-rs/src/render/texture.rs b/crates/lambda-rs/src/render/texture.rs index 7a065daa..f4bc5bbf 100644 --- a/crates/lambda-rs/src/render/texture.rs +++ b/crates/lambda-rs/src/render/texture.rs @@ -108,6 +108,39 @@ impl Texture { } } +#[derive(Debug, Clone)] +/// High‑level depth texture wrapper that owns a platform depth texture. +/// +/// This type mirrors `Texture` for depth attachments and keeps the underlying +/// `wgpu` depth texture internal to the platform crate. +pub struct DepthTexture { + inner: Rc, + format: DepthFormat, +} + +impl DepthTexture { + pub(crate) fn platform_depth_texture(&self) -> Rc { + return self.inner.clone(); + } + + pub(crate) fn view_ref( + &self, + ) -> lambda_platform::wgpu::surface::TextureViewRef<'_> { + return self.inner.view_ref(); + } + + /// Depth format used by this texture. + pub fn format(&self) -> DepthFormat { + return self.format; + } + + /// Explicitly destroy this depth texture. + /// + /// Dropping the texture will release GPU resources; this method exists to + /// mirror other engine resource destruction patterns. + pub fn destroy(self, _render_context: &mut RenderContext) {} +} + #[derive(Debug, Clone)] /// High‑level sampler wrapper that owns a platform sampler. pub struct Sampler { @@ -201,6 +234,8 @@ impl TextureBuilder { return self; } + /// Create the texture and upload initial data if provided. + /// Create the texture and upload initial data if provided. pub fn build( self, @@ -246,6 +281,71 @@ impl TextureBuilder { } } +/// Builder for creating a depth texture attachment. +pub struct DepthTextureBuilder { + label: Option, + width: u32, + height: u32, + format: DepthFormat, + sample_count: u32, +} + +impl DepthTextureBuilder { + /// Create a new depth texture 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 depth texture 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 multisampling. Count values less than one are clamped to `1`. + pub fn with_sample_count(mut self, count: u32) -> Self { + self.sample_count = count.max(1); + 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 depth texture on the device. + pub fn build(self, render_context: &RenderContext) -> DepthTexture { + let mut builder = platform::DepthTextureBuilder::new() + .with_size(self.width.max(1), self.height.max(1)) + .with_format(self.format.to_platform()) + .with_sample_count(self.sample_count); + + if let Some(ref label) = self.label { + builder = builder.with_label(label); + } + + let depth = builder.build(render_context.gpu()); + return DepthTexture { + inner: Rc::new(depth), + format: self.format, + }; + } +} + /// Builder for creating a sampler. pub struct SamplerBuilder { inner: platform::SamplerBuilder, @@ -339,4 +439,10 @@ mod tests { assert!(builder.is_render_target); } + + #[test] + fn depth_texture_builder_clamps_sample_count() { + let builder = DepthTextureBuilder::new().with_sample_count(0); + assert_eq!(builder.sample_count, 1); + } } From b2ac77da543c603651c0027a7a023f19021fd98a Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 25 Nov 2025 16:05:23 -0800 Subject: [PATCH 05/20] [add] first render target implementation. --- crates/lambda-rs/src/render/mod.rs | 1 + crates/lambda-rs/src/render/target.rs | 288 ++++++++++++++++++ .../offscreen-render-targets-and-multipass.md | 8 +- 3 files changed, 293 insertions(+), 4 deletions(-) create mode 100644 crates/lambda-rs/src/render/target.rs diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index c04e4025..27822a34 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -37,6 +37,7 @@ pub mod pipeline; pub mod render_pass; pub mod scene_math; pub mod shader; +pub mod target; pub mod texture; pub mod validation; pub mod vertex; diff --git a/crates/lambda-rs/src/render/target.rs b/crates/lambda-rs/src/render/target.rs new file mode 100644 index 00000000..dc9199ec --- /dev/null +++ b/crates/lambda-rs/src/render/target.rs @@ -0,0 +1,288 @@ +//! Offscreen render targets and builders. +//! +//! Provides `RenderTarget` and `RenderTargetBuilder` for render‑to‑texture +//! workflows without exposing platform texture types at call sites. + +use logging; + +use super::{ + texture, + RenderContext, +}; +use crate::render::validation; + +#[derive(Debug, Clone)] +/// Offscreen render target with color and optional depth attachments. +/// +/// A `RenderTarget` owns a color texture (and optional depth texture) sized +/// independently of the presentation surface. It is intended for render‑to‑ +/// texture workflows such as post‑processing, shadow maps, and UI composition. +pub struct RenderTarget { + color: texture::Texture, + depth: Option, + size: (u32, u32), + color_format: texture::TextureFormat, + depth_format: Option, + sample_count: u32, + label: Option, +} + +impl RenderTarget { + /// Texture size in pixels. + pub fn size(&self) -> (u32, u32) { + return self.size; + } + + /// Color format of the render target. + pub fn color_format(&self) -> texture::TextureFormat { + return self.color_format; + } + + /// Optional depth format configured for this target. + pub fn depth_format(&self) -> Option { + return self.depth_format; + } + + /// Multi‑sample count configured for this target. Always at least `1`. + pub fn sample_count(&self) -> u32 { + return self.sample_count.max(1); + } + + /// Access the color attachment texture for sampling. + pub fn color_texture(&self) -> &texture::Texture { + return &self.color; + } + + /// Access the optional depth attachment texture. + pub(crate) fn depth_texture(&self) -> Option<&texture::DepthTexture> { + return self.depth.as_ref(); + } + + /// Optional debug label assigned at creation time. + pub(crate) fn label(&self) -> Option<&str> { + return self.label.as_deref(); + } + + /// Explicitly destroy this render target. + /// + /// Dropping the value also releases the underlying GPU resources; this + /// method mirrors other engine resource destruction patterns. + pub fn destroy(self, _render_context: &mut RenderContext) {} +} + +#[derive(Debug, Clone, PartialEq, Eq)] +/// Errors returned when building a `RenderTarget`. +pub enum RenderTargetError { + /// Color attachment was not configured. + MissingColorAttachment, + /// Width or height was zero after resolving defaults. + InvalidSize { width: u32, height: u32 }, + /// Sample count is not supported for the chosen format or device limits. + UnsupportedSampleCount { requested: u32 }, + /// Color or depth format incompatible with render‑target usage. + UnsupportedFormat { message: String }, + /// Device‑level failure propagated from the platform layer. + DeviceError(String), +} + +/// Builder for creating a `RenderTarget`. +pub struct RenderTargetBuilder { + label: Option, + color_format: Option, + width: u32, + height: u32, + depth_format: Option, + sample_count: u32, +} + +impl RenderTargetBuilder { + /// Create a new builder with no attachments configured. + pub fn new() -> Self { + return Self { + label: None, + color_format: None, + width: 0, + height: 0, + depth_format: None, + sample_count: 1, + }; + } + + /// Configure the color attachment format and size. + /// + /// When `width` or `height` is zero, the builder falls back to the current + /// `RenderContext` surface size during `build`. A resolved size of zero in + /// either dimension is treated as an error. + pub fn with_color( + mut self, + format: texture::TextureFormat, + width: u32, + height: u32, + ) -> Self { + self.color_format = Some(format); + self.width = width; + self.height = height; + return self; + } + + /// Configure an optional depth attachment for this target. + pub fn with_depth(mut self, format: texture::DepthFormat) -> Self { + self.depth_format = Some(format); + return self; + } + + /// Configure multi‑sampling for this target. + /// + /// Values outside the supported set `{1, 2, 4, 8}` fall back to `1` and + /// emit validation logs under `render-validation-msaa` or debug assertions. + pub fn with_multi_sample(mut self, samples: u32) -> Self { + let allowed = matches!(samples, 1 | 2 | 4 | 8); + if allowed { + self.sample_count = samples; + } else { + #[cfg(any(debug_assertions, feature = "render-validation-msaa",))] + { + if let Err(message) = validation::validate_sample_count(samples) { + logging::error!( + "{}; falling back to sample_count=1 for render target", + message + ); + } + } + self.sample_count = 1; + } + return self; + } + + /// Attach a debug label to the render target. + pub fn with_label(mut self, label: &str) -> Self { + self.label = Some(label.to_string()); + return self; + } + + /// Create the render target color (and optional depth) attachments. + pub fn build( + self, + render_context: &mut RenderContext, + ) -> Result { + let format = match self.color_format { + Some(format) => format, + None => return Err(RenderTargetError::MissingColorAttachment), + }; + + let surface_size = render_context.surface_size(); + let (width, height) = self.resolve_size(surface_size)?; + + // Clamp to at least one sample; device‑limit checks are added in a + // validation milestone. + let sample_count = self.sample_count.max(1); + + let mut color_builder = texture::TextureBuilder::new_2d(format) + .with_size(width, height) + .for_render_target(); + + if let Some(ref label) = self.label { + color_builder = color_builder.with_label(label); + } + + let color_texture = match color_builder.build(render_context) { + Ok(texture) => texture, + Err(message) => { + return Err(RenderTargetError::DeviceError(message.to_string())); + } + }; + + let depth_texture = if let Some(depth_format) = self.depth_format { + let mut depth_builder = texture::DepthTextureBuilder::new() + .with_size(width, height) + .with_format(depth_format); + + if let Some(ref label) = self.label { + depth_builder = depth_builder.with_label(label); + } + + Some(depth_builder.build(render_context)) + } else { + None + }; + + return Ok(RenderTarget { + color: color_texture, + depth: depth_texture, + size: (width, height), + color_format: format, + depth_format: self.depth_format, + sample_count, + label: self.label, + }); + } + + /// Resolve the final size using an optional explicit size and surface default. + /// + /// When no explicit size was provided, the builder falls back to + /// `surface_size`. A resolved size with zero width or height is treated as + /// an error. + pub(crate) fn resolve_size( + &self, + surface_size: (u32, u32), + ) -> Result<(u32, u32), RenderTargetError> { + let mut width = self.width; + let mut height = self.height; + if width == 0 || height == 0 { + width = surface_size.0; + height = surface_size.1; + } + + if width == 0 || height == 0 { + return Err(RenderTargetError::InvalidSize { width, height }); + } + + return Ok((width, height)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Defaults size to the surface when no explicit dimensions are provided. + #[test] + fn resolve_size_defaults_to_surface_size() { + let builder = RenderTargetBuilder::new().with_color( + texture::TextureFormat::Rgba8Unorm, + 0, + 0, + ); + let surface_size = (800, 600); + + let resolved = builder.resolve_size(surface_size).unwrap(); + assert_eq!(resolved, surface_size); + } + + /// Fails when the resolved size has a zero dimension. + #[test] + fn resolve_size_rejects_zero_dimensions() { + let builder = RenderTargetBuilder::new().with_color( + texture::TextureFormat::Rgba8Unorm, + 0, + 0, + ); + let surface_size = (0, 0); + + let resolved = builder.resolve_size(surface_size); + assert_eq!( + resolved, + Err(RenderTargetError::InvalidSize { + width: 0, + height: 0 + }) + ); + } + + /// Clamps sample counts less than one to one. + #[test] + fn sample_count_is_clamped_to_one() { + let builder = RenderTargetBuilder::new().with_multi_sample(0); + assert_eq!(builder.sample_count, 1); + } +} diff --git a/docs/specs/offscreen-render-targets-and-multipass.md b/docs/specs/offscreen-render-targets-and-multipass.md index 4ca5d4d9..b8623d2c 100644 --- a/docs/specs/offscreen-render-targets-and-multipass.md +++ b/docs/specs/offscreen-render-targets-and-multipass.md @@ -3,13 +3,13 @@ title: "Offscreen Render Targets and Multipass Rendering" document_id: "offscreen-render-targets-2025-11-25" status: "draft" created: "2025-11-25T00:00:00Z" -last_updated: "2025-11-25T01:00:00Z" -version: "0.1.1" +last_updated: "2025-11-26T00:00:00Z" +version: "0.1.2" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "e924836491759efa083e9e27e462b2bce22ac317" +repo_commit: "aabd30388e2d111ed1c6f42c355c2af9d53f8d5c" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "rendering", "offscreen", "multipass"] @@ -433,7 +433,7 @@ Per-frame commands: ## Requirements Checklist - Functionality - - [ ] `RenderTarget` and `RenderTargetBuilder` implemented in + - [x] `RenderTarget` and `RenderTargetBuilder` implemented in `crates/lambda-rs/src/render/target.rs`. - [ ] Offscreen targeting added to `RenderPassBuilder` and `RenderPass`. - [ ] Offscreen targeting supported in `RenderContext::render`. From 9d16168136e560133c937d5202e6e1c80c3b2d28 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Wed, 17 Dec 2025 13:33:57 -0800 Subject: [PATCH 06/20] [fix] a few problems after the botched merge. --- crates/lambda-rs-platform/src/wgpu/texture.rs | 34 ----- crates/lambda-rs/src/math/matrix.rs | 6 +- crates/lambda-rs/src/render/bind.rs | 4 +- crates/lambda-rs/src/render/mesh.rs | 2 +- crates/lambda-rs/src/render/mod.rs | 8 +- crates/lambda-rs/src/render/target.rs | 4 +- crates/lambda-rs/src/render/texture.rs | 127 +++--------------- crates/lambda-rs/src/runtimes/application.rs | 22 ++- 8 files changed, 39 insertions(+), 168 deletions(-) diff --git a/crates/lambda-rs-platform/src/wgpu/texture.rs b/crates/lambda-rs-platform/src/wgpu/texture.rs index 521fbe4f..d8837a43 100644 --- a/crates/lambda-rs-platform/src/wgpu/texture.rs +++ b/crates/lambda-rs-platform/src/wgpu/texture.rs @@ -740,20 +740,6 @@ impl TextureBuilder { return self; } - /// Control copy‑source usage. Defaults to `false` so sampled textures do - /// not incur additional usage flags unless explicitly requested. - pub fn with_copy_source_usage(mut self, enabled: bool) -> Self { - self.usage_copy_source = enabled; - return self; - } - - /// Control render attachment usage. Defaults to `false` so existing sampled - /// textures remain sampled‑only. - pub fn with_render_attachment_usage(mut self, enabled: bool) -> Self { - self.usage_render_attachment = enabled; - return self; - } - /// Attach a debug label. pub fn with_label(mut self, label: &str) -> Self { self.label = Some(label.to_string()); @@ -1055,24 +1041,4 @@ mod tests { assert_eq!(d.min_filter, wgpu::FilterMode::Linear); assert_eq!(d.mipmap_filter, wgpu::FilterMode::Linear); } - - #[test] - fn texture_builder_enables_render_attachment_usage() { - let format = TextureFormat::Rgba8Unorm; - let builder = TextureBuilder::new_2d(format) - .with_size(4, 4) - .with_render_attachment_usage(true); - - assert!(builder.usage_render_attachment); - } - - #[test] - fn texture_builder_enables_copy_source_usage() { - let format = TextureFormat::Rgba8Unorm; - let builder = TextureBuilder::new_2d(format) - .with_size(4, 4) - .with_copy_source_usage(true); - - assert!(builder.usage_copy_source); - } } diff --git a/crates/lambda-rs/src/math/matrix.rs b/crates/lambda-rs/src/math/matrix.rs index 4bb37eb6..0a5e6453 100644 --- a/crates/lambda-rs/src/math/matrix.rs +++ b/crates/lambda-rs/src/math/matrix.rs @@ -1,7 +1,5 @@ //! Matrix math types and functions. -use lambda_platform::rand::get_uniformly_random_floats_between; - use super::{ turns_to_radians, vector::Vector, @@ -108,7 +106,7 @@ pub fn rotate_matrix< let cosine_of_angle = angle_in_radians.cos(); let sin_of_angle = angle_in_radians.sin(); - let t = 1.0 - cosine_of_angle; + let _t = 1.0 - cosine_of_angle; let x = axis_to_rotate.at(0); let y = axis_to_rotate.at(1); let z = axis_to_rotate.at(2); @@ -322,7 +320,7 @@ where todo!() } - fn transform(&self, other: &V) -> V { + fn transform(&self, _other: &V) -> V { todo!() } diff --git a/crates/lambda-rs/src/render/bind.rs b/crates/lambda-rs/src/render/bind.rs index 0282331b..78e9db20 100644 --- a/crates/lambda-rs/src/render/bind.rs +++ b/crates/lambda-rs/src/render/bind.rs @@ -64,9 +64,7 @@ impl BindingVisibility { } #[cfg(test)] -mod tests { - use super::*; -} +mod tests {} /// Bind group layout used when creating pipelines and bind groups. #[derive(Debug, Clone)] diff --git a/crates/lambda-rs/src/render/mesh.rs b/crates/lambda-rs/src/render/mesh.rs index 1a8025d7..1fb56e40 100644 --- a/crates/lambda-rs/src/render/mesh.rs +++ b/crates/lambda-rs/src/render/mesh.rs @@ -151,7 +151,7 @@ impl MeshBuilder { mod tests { #[test] fn mesh_building() { - let mut mesh = super::MeshBuilder::new(); + let mesh = super::MeshBuilder::new(); assert_eq!(mesh.vertices.len(), 0); } diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index 7361d84b..c99c480c 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -57,7 +57,6 @@ use std::{ rc::Rc, }; -use lambda_platform::wgpu as platform; use logging; use self::{ @@ -176,9 +175,8 @@ impl RenderContextBuilder { config, texture_usage, size, - depth_texture, - depth_format, depth_texture: None, + depth_format, depth_sample_count: 1, msaa_color: None, msaa_sample_count: 1, @@ -196,7 +194,7 @@ impl RenderContextBuilder { .with_size(size.0.max(1), size.1.max(1)) .with_format(depth_format) .with_label("lambda-depth") - .build(&render_context); + .build(render_context.gpu()); render_context.depth_texture = Some(depth_texture); return Ok(render_context); @@ -229,8 +227,6 @@ pub struct RenderContext { size: (u32, u32), depth_texture: Option, depth_format: texture::DepthFormat, - depth_texture: Option, - depth_format: texture::DepthFormat, depth_sample_count: u32, msaa_color: Option, msaa_sample_count: u32, diff --git a/crates/lambda-rs/src/render/target.rs b/crates/lambda-rs/src/render/target.rs index dc9199ec..7ed88828 100644 --- a/crates/lambda-rs/src/render/target.rs +++ b/crates/lambda-rs/src/render/target.rs @@ -185,7 +185,7 @@ impl RenderTargetBuilder { color_builder = color_builder.with_label(label); } - let color_texture = match color_builder.build(render_context) { + let color_texture = match color_builder.build(render_context.gpu()) { Ok(texture) => texture, Err(message) => { return Err(RenderTargetError::DeviceError(message.to_string())); @@ -201,7 +201,7 @@ impl RenderTargetBuilder { depth_builder = depth_builder.with_label(label); } - Some(depth_builder.build(render_context)) + Some(depth_builder.build(render_context.gpu())) } else { None }; diff --git a/crates/lambda-rs/src/render/texture.rs b/crates/lambda-rs/src/render/texture.rs index 86f87675..e1cc2895 100644 --- a/crates/lambda-rs/src/render/texture.rs +++ b/crates/lambda-rs/src/render/texture.rs @@ -287,13 +287,23 @@ impl ColorAttachmentTextureBuilder { /// operations in render passes. #[derive(Debug)] pub struct DepthTexture { - inner: platform::DepthTexture, + inner: Rc, +} + +impl Clone for DepthTexture { + fn clone(&self) -> Self { + return DepthTexture { + inner: self.inner.clone(), + }; + } } impl DepthTexture { /// Create a high-level depth texture from a platform texture. pub(crate) fn from_platform(texture: platform::DepthTexture) -> Self { - return DepthTexture { inner: texture }; + return DepthTexture { + inner: Rc::new(texture), + }; } /// The depth format used by this attachment. @@ -325,68 +335,6 @@ impl DepthTexture { } } -/// Builder for creating a depth texture attachment. -pub struct DepthTextureBuilder { - label: Option, - format: DepthFormat, - width: u32, - height: u32, - sample_count: u32, -} - -impl DepthTextureBuilder { - /// Create a builder with no size and `Depth32Float` format. - pub fn new() -> Self { - return Self { - label: None, - format: DepthFormat::Depth32Float, - width: 0, - height: 0, - sample_count: 1, - }; - } - - /// Set the 2D attachment size in pixels. - pub fn with_size(mut self, width: u32, height: u32) -> Self { - self.width = width; - self.height = height; - return self; - } - - /// Choose a depth format. - pub fn with_format(mut self, format: DepthFormat) -> Self { - self.format = format; - return self; - } - - /// Configure multi-sampling. - pub fn with_sample_count(mut self, count: u32) -> Self { - self.sample_count = count.max(1); - return self; - } - - /// Attach a debug label for the created texture. - pub fn with_label(mut self, label: &str) -> Self { - self.label = Some(label.to_string()); - return self; - } - - /// Create the depth texture on the device. - pub fn build(self, gpu: &Gpu) -> DepthTexture { - let mut builder = platform::DepthTextureBuilder::new() - .with_size(self.width, self.height) - .with_format(self.format.to_platform()) - .with_sample_count(self.sample_count); - - if let Some(ref label) = self.label { - builder = builder.with_label(label); - } - - let texture = builder.build(gpu.platform()); - return DepthTexture::from_platform(texture); - } -} - // --------------------------------------------------------------------------- // Texture (sampled) // --------------------------------------------------------------------------- @@ -403,39 +351,6 @@ impl Texture { } } -#[derive(Debug, Clone)] -/// High‑level depth texture wrapper that owns a platform depth texture. -/// -/// This type mirrors `Texture` for depth attachments and keeps the underlying -/// `wgpu` depth texture internal to the platform crate. -pub struct DepthTexture { - inner: Rc, - format: DepthFormat, -} - -impl DepthTexture { - pub(crate) fn platform_depth_texture(&self) -> Rc { - return self.inner.clone(); - } - - pub(crate) fn view_ref( - &self, - ) -> lambda_platform::wgpu::surface::TextureViewRef<'_> { - return self.inner.view_ref(); - } - - /// Depth format used by this texture. - pub fn format(&self) -> DepthFormat { - return self.format; - } - - /// Explicitly destroy this depth texture. - /// - /// Dropping the texture will release GPU resources; this method exists to - /// mirror other engine resource destruction patterns. - pub fn destroy(self, _render_context: &mut RenderContext) {} -} - #[derive(Debug, Clone)] /// High‑level sampler wrapper that owns a platform sampler. pub struct Sampler { @@ -543,9 +458,12 @@ impl TextureBuilder { }; if self.is_render_target { - builder = builder - .with_render_attachment_usage(true) - .with_copy_source_usage(true); + builder = builder.with_usage( + platform::TextureUsages::TEXTURE_BINDING + | platform::TextureUsages::RENDER_ATTACHMENT + | platform::TextureUsages::COPY_SRC + | platform::TextureUsages::COPY_DST, + ); } if let Some(ref label) = self.label { @@ -623,7 +541,7 @@ impl DepthTextureBuilder { } /// Create the depth texture on the device. - pub fn build(self, render_context: &RenderContext) -> DepthTexture { + pub fn build(self, gpu: &Gpu) -> DepthTexture { let mut builder = platform::DepthTextureBuilder::new() .with_size(self.width.max(1), self.height.max(1)) .with_format(self.format.to_platform()) @@ -633,11 +551,8 @@ impl DepthTextureBuilder { builder = builder.with_label(label); } - let depth = builder.build(render_context.gpu()); - return DepthTexture { - inner: Rc::new(depth), - format: self.format, - }; + let texture = builder.build(gpu.platform()); + return DepthTexture::from_platform(texture); } } diff --git a/crates/lambda-rs/src/runtimes/application.rs b/crates/lambda-rs/src/runtimes/application.rs index 5509dc3a..c720f7dd 100644 --- a/crates/lambda-rs/src/runtimes/application.rs +++ b/crates/lambda-rs/src/runtimes/application.rs @@ -8,13 +8,10 @@ use lambda_platform::winit::{ winit_exports::{ ElementState, Event as WinitEvent, - KeyCode as WinitKeyCode, - KeyEvent as WinitKeyEvent, MouseButton, PhysicalKey as WinitPhysicalKey, WindowEvent as WinitWindowEvent, }, - Loop, LoopBuilder, }; use logging; @@ -23,7 +20,6 @@ use crate::{ component::Component, events::{ Button, - ComponentEvent, Events, Key, Mouse, @@ -31,11 +27,7 @@ use crate::{ WindowEvent, }, render::{ - window::{ - Window, - WindowBuilder, - }, - RenderContext, + window::WindowBuilder, RenderContextBuilder, }, runtime::Runtime, @@ -149,7 +141,7 @@ impl Runtime<(), String> for ApplicationRuntime { let mut event_loop = LoopBuilder::new().build(); let window = self.window_builder.build(&mut event_loop); let mut component_stack = self.component_stack; - let mut render_context = match self.render_context_builder.build(&window) { + let render_context = match self.render_context_builder.build(&window) { Ok(ctx) => ctx, Err(err) => { let msg = format!("Failed to initialize render context: {}", err); @@ -341,9 +333,15 @@ impl Runtime<(), String> for ApplicationRuntime { } // Redraw requests are handled implicitly when AboutToWait fires; ignore explicit requests WinitEvent::NewEvents(_) => None, - WinitEvent::DeviceEvent { device_id, event } => None, + WinitEvent::DeviceEvent { + device_id: _, + event: _, + } => None, WinitEvent::UserEvent(lambda_event) => match lambda_event { - Events::Runtime { event, issued_at } => match event { + Events::Runtime { + event, + issued_at: _, + } => match event { RuntimeEvent::Initialized => { logging::debug!( "Initializing all of the components for the runtime: {}", From f1743e5528bc4c8326a46e20123ffac62f717ec9 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Wed, 17 Dec 2025 14:51:46 -0800 Subject: [PATCH 07/20] [update] specification to align with recent changes made to lambda-rs. --- .../offscreen-render-targets-and-multipass.md | 622 ++++++++---------- 1 file changed, 264 insertions(+), 358 deletions(-) diff --git a/docs/specs/offscreen-render-targets-and-multipass.md b/docs/specs/offscreen-render-targets-and-multipass.md index b8623d2c..9ee1fb22 100644 --- a/docs/specs/offscreen-render-targets-and-multipass.md +++ b/docs/specs/offscreen-render-targets-and-multipass.md @@ -3,13 +3,13 @@ title: "Offscreen Render Targets and Multipass Rendering" document_id: "offscreen-render-targets-2025-11-25" status: "draft" created: "2025-11-25T00:00:00Z" -last_updated: "2025-11-26T00:00:00Z" -version: "0.1.2" +last_updated: "2025-12-17T00: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: "aabd30388e2d111ed1c6f42c355c2af9d53f8d5c" +repo_commit: "9d16168136e560133c937d5202e6e1c80c3b2d28" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "rendering", "offscreen", "multipass"] @@ -18,23 +18,35 @@ tags: ["spec", "rendering", "offscreen", "multipass"] # Offscreen Render Targets and Multipass Rendering Summary -- Introduces offscreen render targets as first-class resources in `lambda-rs` - for render-to-texture workflows (post-processing, shadow maps, UI - composition). -- Defines multipass rendering semantics and API changes so passes can write to - and sample from offscreen targets without exposing `wgpu` types. -- Preserves existing builder and command patterns while extending - `lambda-rs-platform` to support textures that are both render attachments and - sampled resources. +- Defines an offscreen render-to-texture resource that produces a sampleable + color texture. +- Extends the command-driven renderer so a pass begin selects a render + destination: the presentation surface or an offscreen target. +- Defines the MSAA resolve model for offscreen targets so later passes sample a + single-sample resolve texture. + +## Table of Contents +- [Scope](#scope) +- [Terminology](#terminology) +- [Architecture Overview](#architecture-overview) +- [Design](#design) +- [Behavior](#behavior) +- [Validation and Errors](#validation-and-errors) +- [Constraints and Rules](#constraints-and-rules) +- [Performance Considerations](#performance-considerations) +- [Requirements Checklist](#requirements-checklist) +- [Verification and Testing](#verification-and-testing) +- [Compatibility and Migration](#compatibility-and-migration) +- [Changelog](#changelog) ## Scope ### Goals -- Add first-class offscreen render targets with color and optional depth - attachments in `lambda-rs`. -- Allow render passes to target either the presentation surface or an offscreen - render target. +- Add a first-class offscreen target resource with one color output and + optional depth. +- Allow a render pass begin command to select a destination: the surface or a + specific offscreen target. - Enable multipass workflows where later passes sample from textures produced by earlier passes. - Provide validation and feature flags for render-target compatibility, sample @@ -44,8 +56,7 @@ Summary - Multiple simultaneous color attachments (MRT) per pass; a single color attachment per pass remains the default in this specification. -- Compute pipelines, storage textures, and general framegraph scheduling; - separate specifications cover these areas. +- A full framegraph scheduler; ordering remains the explicit command sequence. - Headless contexts without a presentation surface; this specification assumes a window-backed `RenderContext`. - Vendor-specific optimizations beyond what `wgpu` exposes via limits and @@ -53,58 +64,49 @@ Summary ## Terminology -- Offscreen render target: A 2D color texture with an optional depth attachment - that can be bound as a render attachment but is not presented directly to the - window surface. -- Render target: Either the default presentation surface or an offscreen render - target. +- Presentation render target: A window-backed render target that acquires and + presents swapchain frames (see `render_target::WindowSurface`). +- Offscreen target: A persistent resource that owns textures for render-to- + texture workflows and exposes a sampleable color texture. +- Render destination: The destination selected when beginning a render pass: + the presentation surface or a specific offscreen target. +- Resolve texture: The single-sample color texture produced by resolving an + MSAA color attachment; this is the texture sampled by later passes. - Multipass rendering: A sequence of two or more render passes in a single frame where later passes consume the results of earlier passes (for example, post-processing or shadow map sampling). -- Default render target: The swapchain-backed surface associated with a - `RenderContext`. - Ping-pong target: A pair of offscreen render targets alternated between read and write roles across passes. ## Architecture Overview -- High-level (`lambda-rs`) - - Introduces `RenderTarget` and `RenderTargetBuilder` in - `lambda::render::target` to construct offscreen color (and optional depth) - attachments sized independently of the window. - - Extends `RenderPassBuilder` so a pass can declare a target: the default - surface or a specific `RenderTarget`. - - Extends `RenderPipelineBuilder` so pipelines can declare the expected color - format independently of the surface while still aligning sample counts and - depth formats with the active pass. -- Platform (`lambda-rs-platform`) - - Extends `TextureBuilder` to create textures that include - `RENDER_ATTACHMENT` usage in addition to sampling usage. - - Reuses the existing render pass and pipeline builders to bind offscreen - texture views as color attachments and to configure color target formats - from texture formats instead of only surface formats. +`lambda-rs` currently has two distinct concepts that collide in naming: +- `lambda::render::render_target::RenderTarget`: trait for acquiring and + presenting frames. +- `lambda::render::target::RenderTarget`: offscreen render-to-texture resource. + +This specification treats the trait in `render_target` as the canonical meaning +of \"render target\". The offscreen resource is specified as `OffscreenTarget`. +The implementation SHOULD rename `lambda::render::target::RenderTarget` to +avoid API ambiguity. Data flow (setup → per-frame multipass): ``` -RenderTargetBuilder - --> RenderTarget { color_texture, depth_format, sample_count } - └── bound into bind groups for sampling - RenderPassBuilder::new() - .with_target(&offscreen) // or default surface - .with_depth_clear(1.0) // optional depth ops .with_multi_sample(1 | 2 | 4 | 8) --> RenderPass └── RenderContext::attach_render_pass(...) +OffscreenTargetBuilder + --> OffscreenTarget { resolve_texture, msaa_texture?, depth_texture? } + └── RenderContext::attach_offscreen_target(...) + RenderPipelineBuilder::new() - .with_color_format(TextureFormat::Rgba8UnormSrgb) - .with_depth_format(DepthFormat::Depth32Float) .with_multi_sample(...) - --> RenderPipeline + --> RenderPipeline (built for a specific color format) Per-frame commands: - BeginRenderPass { pass_id, viewport } // surface or offscreen target + BeginRenderPassTo { pass_id, viewport, destination } // surface or offscreen SetPipeline / SetBindGroup / Draw... EndRenderPass (repeat for additional passes) @@ -116,300 +118,208 @@ Per-frame commands: #### High-level layer (`lambda-rs`) -- Module `lambda::render::target` - - `pub struct RenderTarget` - - Represents a 2D offscreen render target with a single color attachment - and an optional depth attachment. - - Encapsulates texture size, color format, depth format (if any), and - sample count. - - Exposes immutable accessors for binding in shaders and builders: - - `pub fn size(&self) -> (u32, u32)` - - `pub fn color_format(&self) -> texture::TextureFormat` - - `pub fn depth_format(&self) -> Option` - - `pub fn sample_count(&self) -> u32` - - `pub fn color_texture(&self) -> &texture::Texture` - - Provides explicit destruction: - - `pub fn destroy(self, render_context: &mut RenderContext)` - - `pub struct RenderTargetBuilder` - - Builder for constructing `RenderTarget` values. - - API: - - `pub fn new() -> Self` - - `pub fn with_color(mut self, format: texture::TextureFormat, width: u32, height: u32) -> Self` - - `pub fn with_depth(mut self, format: texture::DepthFormat) -> Self` - - `pub fn with_multi_sample(mut self, samples: u32) -> Self` - - `pub fn with_label(mut self, label: &str) -> Self` - - `pub fn build(self, render_context: &mut RenderContext) -> Result` - - Behavior: - - Fails with `RenderTargetError::MissingColorAttachment` when no color - attachment was configured. - - Fails with `RenderTargetError::InvalidSize` when width or height is - zero. - - Defaults: - - Size defaults to the current surface size when not explicitly - provided. - - Sample count defaults to `1` (no multi-sampling). - - `pub enum RenderTargetError` +- Module `lambda::render::target` (offscreen resource) + - `pub struct OffscreenTarget` + - Represents a 2D offscreen destination with a single color output and + optional depth attachment. + - `OffscreenTarget::color_texture()` MUST return the single-sample resolve + texture (even when MSAA is enabled on the destination). + - `pub struct OffscreenTargetBuilder` + - `pub fn new() -> Self` + - `pub fn with_color(self, format: texture::TextureFormat, width: u32, height: u32) -> Self` + - `pub fn with_depth(self, format: texture::DepthFormat) -> Self` + - `pub fn with_multi_sample(self, samples: u32) -> Self` + - `pub fn with_label(self, label: &str) -> Self` + - `pub fn build(self, render_context: &mut RenderContext) -> Result` + - Defaults: + - When width or height is zero, the builder uses + `RenderContext::surface_size()` as the size. + - When size is defaulted from the surface, the target MUST NOT + auto-resize; the application rebuilds it on resize. + - `pub enum OffscreenTargetError` + - `MissingColorAttachment` - `InvalidSize { width: u32, height: u32 }` - `UnsupportedSampleCount { requested: u32 }` - `UnsupportedFormat { message: String }` - - `DeviceError(String)` for device-level failures returned by the platform - layer. + - `DeviceError(String)` + - Note: The current implementation uses the name `RenderTarget` in + `lambda::render::target`. The public API SHOULD be renamed to + `OffscreenTarget` to avoid confusion with `render_target::RenderTarget`. + +- Module `lambda::render::command` + - Add explicit destination selection for pass begins: + - `pub enum RenderDestination { Surface, Offscreen(ResourceId) }` + - `RenderCommand::BeginRenderPassTo { render_pass, viewport, destination }` + - `RenderCommand::BeginRenderPass { render_pass, viewport }` MUST remain + and be equivalent to `BeginRenderPassTo { destination: Surface, ... }`. + +- Module `lambda::render::RenderContext` + - Add an offscreen target registry: + - `pub fn attach_offscreen_target(&mut self, target: OffscreenTarget) -> ResourceId` + - `pub fn get_offscreen_target(&self, id: ResourceId) -> &OffscreenTarget` - Module `lambda::render::render_pass` - - Extend `RenderPassBuilder` with target selection: - - `pub fn with_target(mut self, target: &RenderTarget) -> Self` - - Configures the pass to use the provided `RenderTarget` color and depth - attachments instead of the default surface and context-managed depth - texture. - - The pass inherits the target size and sample count; explicit - `with_multi_sample` on the pass MUST align with the target sample count - (see Behavior). - - Existing methods (for example, `with_clear_color`, `with_depth_clear`, - `with_stencil_clear`, `with_multi_sample`) remain unchanged and apply to - the selected target. - - Extend `RenderPass` to expose its target: - - `pub(crate) fn uses_default_surface(&self) -> bool` - - `pub(crate) fn target(&self) -> Option` - - Used internally by `RenderContext` to choose attachments when encoding - passes. + - The pass description remains destination-agnostic (clear/load/store, + depth/stencil ops, sample count, and `uses_color`). + - Destination selection occurs in `BeginRenderPassTo`, not in the pass + builder. - Module `lambda::render::pipeline` - - Extend `RenderPipelineBuilder` to allow explicit color format selection: - - `pub fn with_color_format(mut self, format: texture::TextureFormat) -> Self` - - Declares the color format expected by the fragment stage. - - When omitted: - - For surface-backed passes, defaults to the current surface format. - - For offscreen passes with a `RenderTarget`, defaults to the target - color format. - - `RenderPipelineBuilder::build` behavior changes: - - Derives the color target format from: - - Explicit `with_color_format`, when provided. - - Otherwise from the associated `RenderPass` target: - - `RenderContext::surface_format()` for the default surface. - - `RenderTarget::color_format()` for offscreen targets. - - Retains depth and sample count alignment rules: - - Depth format continues to be derived from `with_depth_format` or the - pass depth attachment, including stencil upgrades. - - Sample count is aligned to the pass sample count, as in the MSAA spec. + - Pipelines with a fragment stage are built for one color target format. + - `RenderPipelineBuilder::build` MUST treat its `surface_format` parameter as + the active color target format: + - Surface passes pass `RenderContext::surface_format()`. + - Offscreen passes pass `OffscreenTarget::color_format()`. - Module `lambda::render::texture` - - Extend `TextureBuilder` to support render-target usage: - - `pub fn for_render_target(mut self) -> Self` - - Marks the texture for combined sampling and render-attachment usage. - - Maps to `TEXTURE_BINDING | RENDER_ATTACHMENT | COPY_SRC` at the - platform layer. - - Existing uses that do not call `for_render_target` continue to produce - sampled-only textures. - -- Module `lambda::render::command` - - No new commands are required; multipass rendering continues to use - `RenderCommand::BeginRenderPass` / `EndRenderPass` with different pass - handles. - - The semantics of `BeginRenderPass` change to: - - If the referenced `RenderPass` uses the default surface, the pass writes - to the swapchain (with optional MSAA resolve). - - If the `RenderPass` references an offscreen `RenderTarget`, the pass - writes to the target's color attachment (with optional depth). - -- Module `lambda::render::RenderContext` - - Extend internal state with an optional pool of offscreen resources owned by - `RenderTarget`: - - `render_targets` remains managed by application code via `RenderTarget`; - `RenderContext` only borrows platform textures when encoding passes. - - Expose the surface size for convenience: - - `pub fn surface_size(&self) -> (u32, u32)` - - Used by `RenderTargetBuilder::new()` as a default size when none is - provided. + - `TextureBuilder::for_render_target` MUST create textures with usage flags + suitable for both sampling and render attachments. #### Platform layer (`lambda-rs-platform`) - Module `lambda_platform::wgpu::texture` - - Extend `TextureBuilder` usage flags: - - Add internal `usage_render_attachment: bool` field. - - Add `pub fn with_render_attachment_usage(mut self, enabled: bool) -> Self` - - When enabled, include `wgpu::TextureUsages::RENDER_ATTACHMENT` in the - created texture and its default view usage. - - Offscreen color targets use: - - `TEXTURE_BINDING | RENDER_ATTACHMENT | COPY_SRC` for flexible sampling - and optional readback. + - Offscreen resolve textures MUST support both `RENDER_ATTACHMENT` and + `TEXTURE_BINDING` usage. + - Offscreen MSAA attachment textures MUST support `RENDER_ATTACHMENT` usage. - Module `lambda_platform::wgpu::pipeline` - - Extend `RenderPipelineBuilder`: - - Add `pub fn with_color_target_format(mut self, format: texture::TextureFormat) -> Self` - - Converts the texture format into a `wgpu::TextureFormat` and stores it - as the color target format. - - `with_surface_color_target` remains for surface-backed pipelines and is - used when the color target should match the swapchain format. + - Pipelines use `RenderPipelineBuilder::with_color_target` to declare the + active color target format. - Module `lambda_platform::wgpu::render_pass` - - No structural changes are required; existing `RenderColorAttachments` - already accepts arbitrary `TextureView` references. - - Offscreen passes provide `TextureViewRef` from `Texture` at pass-encode - time. + - Existing `RenderColorAttachments` already supports arbitrary texture views, + including MSAA attachments with resolve views. -### Behavior +## Behavior -#### RenderTarget creation and lifetime +### Offscreen target creation and lifetime - Creation - - `RenderTargetBuilder::build` MUST fail when: + - `OffscreenTargetBuilder::build` MUST fail when: - `with_color` was never called. - - Width or height is zero. - - The requested sample count is not supported by the device for the chosen - format. - - When no explicit size is set, the builder uses the current - `RenderContext::surface_size()` as the color attachment size. - - Depth is optional: - - When `with_depth` is omitted, the target has no depth attachment. - - When `with_depth` is provided, the target allocates a depth texture using - `texture::DepthFormat`. + - Resolved width or height is zero. + - The requested sample count is unsupported for the chosen color format. + - The requested sample count is unsupported for the chosen depth format + when depth is enabled. + - When no explicit size is set, the builder MUST use the current + `RenderContext::surface_size()` as the default size. +- MSAA resolve model + - When `sample_count == 1`, the destination owns a single-sample color + texture that is both rendered into and sampled by later passes. + - When `sample_count > 1`, the destination MUST own: + - A multi-sampled color attachment texture used only as the render + attachment. + - A single-sample resolve texture used as the resolve destination and later + sampled. + - `OffscreenTarget::color_texture()` MUST return the single-sample resolve + texture in both cases. - Lifetime - - `RenderTarget` owns its color (and optional depth) textures. - - `RenderPassBuilder::with_target` clones the target handle; the application - MUST keep the `RenderTarget` alive for as long as any attached passes and - pipelines are used. - - `RenderTarget::destroy` releases underlying resources; further use in - passes is invalid and SHOULD be prevented by application code. - -#### Render pass targeting semantics - -- Default behavior (existing) - - When `RenderPassBuilder` is used without `with_target`, the pass targets - the presentation surface: - - Color attachment: swapchain view (with optional MSAA resolve). - - Depth attachment: `RenderContext`-managed depth texture. -- Offscreen behavior (new) - - When `with_target(&offscreen)` is used: - - Color attachment: offscreen target color texture view. - - Depth attachment: - - When the target has a depth format, a depth texture is allocated for - the target and used as the pass depth attachment. - - When the target has no depth format, depth is disabled unless the pass - explicitly requests depth operations, in which case the pass MUST - produce a configuration error. - - Sample count: - - The pass sample count MUST equal the target sample count. - - When `with_multi_sample` is called with a different value, the pass - aligns its sample count to `RenderTarget::sample_count()` and, under - validation features, logs an error. - - Color load/store operations, depth operations, and stencil operations apply - to the offscreen attachments exactly as they apply to the surface-backed - attachments. - -#### Multipass flows + - When an offscreen target is attached to a `RenderContext` and referenced by + id, the application MUST keep the target attached for as long as any + commands reference that id. + +### Render pass destination semantics + +- Destination selection occurs in `RenderCommand::BeginRenderPassTo`. +- `RenderCommand::BeginRenderPass` is equivalent to `RenderDestination::Surface`. +- `RenderDestination::Surface` + - Color attachment is the swapchain view (with optional MSAA resolve). + - Depth attachment is the `RenderContext`-managed depth texture. +- `RenderDestination::Offscreen(target_id)` + - Color attachment is the offscreen target: + - When `sample_count == 1`, the resolve texture view. + - When `sample_count > 1`, the MSAA attachment view with resolve to the + resolve texture view. + - Depth attachment is the offscreen depth texture view when present. + - When the offscreen target has no depth attachment, depth and stencil + operations MUST be rejected as configuration errors. +- Sample count + - The pass sample count MUST equal the destination sample count. + - The pipeline sample count MUST equal the pass sample count. + +### Multipass flows - Command ordering - - Multipass rendering is expressed as multiple - `BeginRenderPass`/`EndRenderPass` pairs in a single command list. - - Nested passes remain invalid and MUST continue to be rejected by - `RenderContext::encode_pass`. + - Multipass rendering is expressed as multiple `BeginRenderPass` / + `BeginRenderPassTo` / `EndRenderPass` pairs in a single command list. + - Nested passes remain invalid and MUST be rejected by `RenderContext::render`. - Data dependencies - - Passes that render into an offscreen target produce textures that MAY be - sampled in subsequent passes: - - Typical pattern: - - Pass 1: scene → offscreen color (and depth). - - Pass 2: fullscreen quad sampling offscreen.color → surface. - - The specification does not introduce an explicit framegraph; ordering is - determined solely by the command sequence. + - Passes that render into an offscreen destination produce resolve textures + that MAY be sampled in subsequent passes. - Hazards - - Writing to a `RenderTarget` and sampling from the same texture in the same - pass is undefined behavior and MUST NOT be supported; validation MAY detect - obvious cases but cannot guarantee all hazards are caught. - - Using the same `RenderTarget` as the destination for multiple passes in one - frame is supported; the clear/load operations on each pass determine - whether results accumulate or overwrite. + - Sampling from a resolve texture while writing to that resolve texture in + the same pass is undefined behavior and MUST NOT be supported. -#### Pipeline and target compatibility +### Pipeline and destination compatibility - Color format - - For surface-backed passes: - - When `with_color_format` is omitted, the pipeline color format is derived - from `RenderContext::surface_format()` (existing behavior). - - When `with_color_format` is provided and differs from the surface - format, pipeline creation MUST fail under `render-validation-pass-compat` - or debug assertions. - - For offscreen passes: - - When `with_color_format` is omitted, the pipeline color format is derived - from `RenderTarget::color_format()`. - - When `with_color_format` is provided and differs from the target format, - pipeline creation MUST either: - - Fail configuration-time validation, or - - Align to the target format and log an error under - `render-validation-render-targets`. + - Pipelines with a fragment stage MUST be built for the destination color + format: + - Surface destinations use `RenderContext::surface_format()`. + - Offscreen destinations use `OffscreenTarget::color_format()`. - Depth format - - Depth behavior follows the depth/stencil specification: - - When the pass requests stencil operations, the depth format MUST include - a stencil aspect; otherwise, the engine upgrades to - `Depth24PlusStencil8` and logs an error under - `render-validation-stencil`. - - For offscreen targets with a depth format, pipeline depth format MUST - match the target depth format; mismatches are treated as configuration - errors or aligned with logging, consistent with the depth spec. + - When the pass requests stencil operations, the destination depth format + MUST include a stencil aspect. + - For offscreen destinations with a depth format, pipeline depth format MUST + match the destination depth format. - Sample count - - Pass and pipeline sample counts are aligned as in the MSAA specification: - - Pipeline sample count is aligned to the pass sample count. - - For offscreen passes, both pass and pipeline sample counts MUST equal the - target sample count; invalid sample counts are clamped to `1` with - validation logs. - -### Validation and Errors - -- Builder-level validation (always on) - - `RenderTargetBuilder::build` MUST: - - Reject zero width or height. - - Clamp sample counts less than `1` to `1`. - - `RenderPassBuilder::with_target` MUST ensure that a target has a color - attachment; targets without color are invalid for the current specification - (depth-only targets MAY be added later). - - `RenderPipelineBuilder::build` MUST: - - Validate that the chosen color format is supported for render attachments - on the device. -- Runtime validation (feature-gated) - - New granular feature (crate: `lambda-rs`): - - `render-validation-render-targets` - - Validates compatibility between `RenderTarget`, `RenderPass`, and - `RenderPipeline`: - - Verifies that pass and pipeline color formats match the target color - format. - - Verifies that pass and pipeline sample counts equal the target sample - count. - - Logs when a pass references a `RenderTarget` whose size differs - significantly from the surface size (for example, for debug - visibility issues). - - Expected runtime cost: low to moderate; checks occur at pass/pipeline - build time and pass begin time, not per draw. - - Existing granular features: - - `render-validation-pass-compat` continues to enforce SetPipeline-time - compatibility checks and MUST be updated to consider offscreen targets. - - `render-validation-msaa`, `render-validation-depth`, - `render-validation-stencil`, and `render-validation-device` remain - unchanged but apply equally to offscreen passes. -- Build-type behavior - - Debug builds (`debug_assertions`): - - All render-target validations are active regardless of feature flags. - - Release builds: - - Only cheap size and sample-count clamps are always on. - - Detailed compatibility logs require `render-validation-render-targets` or - the appropriate umbrella features. + - Pipelines MUST match the pass sample count, and the pass sample count MUST + match the destination sample count. + +## Validation and Errors + +### Always-on safeguards + +- Reject zero-sized offscreen targets at build time. +- Clamp invalid MSAA sample count inputs to `1` in builder APIs. + +### Feature-gated validation + +Crate: `lambda-rs` +- Granular feature: + - `render-validation-render-targets` + - Validates compatibility between: + - `RenderDestination` selection at `BeginRenderPassTo`. + - Offscreen target attachments (color + optional depth). + - The active `RenderPass` description (sample count, depth/stencil ops). + - The active `RenderPipeline` (color target presence, format, and sample + count). + - Checks MUST occur at pass begin and at `SetPipeline` time, not per draw. + - Logs SHOULD include: + - Destination size mismatches versus `RenderContext::surface_size()`. + - Missing depth attachment when depth or stencil ops are requested. + - Color format mismatches between destination and pipeline. + - Expected runtime cost is low to moderate. + +Umbrella composition (crate: `lambda-rs`) +- `render-validation` MUST include `render-validation-render-targets`. +- Umbrella features MUST only compose granular features. + +Build-type behavior +- Debug builds (`debug_assertions`) MAY enable offscreen validation regardless + of features. +- Release builds MUST keep offscreen validation disabled by default and enable + it only via `render-validation-render-targets` (or umbrellas that include it). ## Constraints and Rules -- RenderTarget constraints - - Width and height MUST be strictly positive. - - Color formats are limited to `TextureFormat::Rgba8Unorm` and - `TextureFormat::Rgba8UnormSrgb` in the initial implementation. - - Depth formats are limited to `DepthFormat::Depth32Float`, - `DepthFormat::Depth24Plus`, and `DepthFormat::Depth24PlusStencil8`. - - Sample counts MUST be one of the device-supported values; the initial - spec assumes {1, 2, 4, 8}. +- Offscreen target constraints + - Width and height MUST be strictly positive after resolving defaults. + - A destination produces exactly one color output. + - Color formats MUST be limited to formats supported by `texture::TextureFormat`. + - Depth formats MUST be limited to `texture::DepthFormat`. + - Sample counts MUST be supported by the device for the chosen color and + depth formats; the initial spec assumes {1, 2, 4, 8}. + - When `sample_count > 1`, the destination MUST provide a single-sample + resolve texture for sampling. - Pass constraints - - A pass with an offscreen target MUST not also target the surface; the - target is exclusive. - - Nested `BeginRenderPass`/`EndRenderPass` sequences remain invalid. - - Viewport and scissor rectangles are expressed in target-relative - coordinates when an offscreen target is selected. + - Each `BeginRenderPassTo` MUST select exactly one destination. + - Nested `BeginRenderPass`/`BeginRenderPassTo`/`EndRenderPass` sequences + remain invalid. + - Viewport and scissor rectangles are expressed in destination-relative + coordinates when an offscreen destination is selected. - Pipeline constraints - - Pipelines used with offscreen passes MUST declare a color target; vertex- - only pipelines without a fragment stage are not compatible with offscreen - color passes in this revision. + - Pipelines used with destinations that have color output MUST declare a + color target (a fragment stage must be present). + - Pipelines MUST match destination format and sample count. ## Performance Considerations @@ -417,7 +327,7 @@ Per-frame commands: effects (for example, half-resolution bloom). - Rationale: Smaller render targets reduce fill-rate and bandwidth demands while preserving acceptable visual quality for blurred or combined passes. -- Reuse `RenderTarget` instances across frames instead of recreating them. +- Reuse offscreen targets across frames instead of recreating them. - Rationale: Repeated allocation and destruction of GPU textures can fragment memory and increase driver overhead; long-lived targets amortize setup costs. @@ -425,61 +335,53 @@ Per-frame commands: multi-sampling to geometry passes. - Rationale: MSAA increases memory bandwidth and shader cost; geometric passes benefit most, while post-process passes typically do not. -- Pack related passes that use the same `RenderTarget` close together in the - command stream. +- Pack related passes that use the same offscreen destination close together in + the command stream. - Rationale: Grouping passes reduces state changes and keeps relevant resources warm in caches and descriptor pools. ## Requirements Checklist - Functionality - - [x] `RenderTarget` and `RenderTargetBuilder` implemented in - `crates/lambda-rs/src/render/target.rs`. - - [ ] Offscreen targeting added to `RenderPassBuilder` and `RenderPass`. - - [ ] Offscreen targeting supported in `RenderContext::render`. - - [ ] Edge cases handled (invalid size, unsupported sample count, missing - depth when required). + - [x] Offscreen target resource exists in `crates/lambda-rs/src/render/target.rs`. + - [ ] Rename public API to `OffscreenTarget` to avoid collision with + `render_target::RenderTarget`. + - [ ] Add `RenderDestination` and `RenderCommand::BeginRenderPassTo`. + - [ ] Add `RenderContext::{attach,get}_offscreen_target`. + - [ ] Support offscreen destinations in `RenderContext::render`. + - [ ] Implement offscreen MSAA resolve textures (render to MSAA, resolve to + single-sample, sample resolve). + - [ ] Ensure offscreen depth sample count matches destination sample count. - API Surface - - [ ] High-level public types and builders added in `lambda-rs`. - - [x] Platform texture usage for render targets implemented in - `lambda-rs-platform`. - - [x] Engine texture builder helpers for render targets implemented in - `lambda-rs`. - - [ ] Pipeline color target changes implemented in `lambda-rs-platform`. - - [ ] Backwards compatibility assessed for existing surface-backed paths. + - [x] Platform pipeline supports explicit color targets. + - [x] Engine `TextureBuilder::for_render_target` sets attachment-capable usage. - Validation and Errors - [ ] `render-validation-render-targets` feature implemented and composed into umbrella validation features. - - [ ] Pass/pipeline/target compatibility checks implemented. - - [ ] Device limit checks for offscreen formats/sample counts implemented. -- Performance - - [ ] Critical render-target creation paths profiled or reasoned about. - - [ ] Memory usage for long-lived render targets characterized. - - [ ] Performance recommendations validated against representative examples. + - [ ] Pass/pipeline/destination compatibility checks implemented. + - [ ] `docs/features.md` updated to list the feature, default state, and cost. - Documentation and Examples - - [ ] Rendering guide updated to include offscreen/multipass examples. - - [ ] Minimal render-to-texture example added under - `crates/lambda-rs/examples/`. - - [ ] Migration notes added for consumers adopting offscreen targets. + - [ ] Minimal render-to-texture example added under `crates/lambda-rs/examples/`. + - [ ] Rendering guide updated to include an offscreen multipass walkthrough. + - [ ] Migration notes added for consumers adopting destination-based passes. ## Verification and Testing - Unit tests - - `RenderTargetBuilder` validation: - - Invalid sizes and sample counts. - - Mapping to platform texture builder usage flags. - - Pipeline color format selection: - - Surface-backed vs offscreen-backed passes. - - Commands: - - `cargo test --workspace` + - Offscreen target builder validation: + - Invalid sizes. + - Unsupported sample counts for color and depth formats. + - Resolve texture usage flags suitable for attachment and sampling. + - Destination validation: + - Surface versus offscreen attachment selection at `BeginRenderPassTo`. + - Sample count mismatch handling (destination, pass, pipeline). + - Depth/stencil requested with no offscreen depth attachment. + - Commands: `cargo test --workspace` - Integration tests and examples - Render-to-texture example: - - Pass 1: scene → offscreen. - - Pass 2: fullscreen quad sampling offscreen → surface. - - Shadow-map-style example: - - Depth-only offscreen target feeding a lighting pass. - - Commands: - - `cargo run -p lambda-rs --example offscreen_post` + - Pass 1: scene → offscreen destination. + - Pass 2: fullscreen quad sampling `offscreen.color_texture()` → surface. + - Commands: `cargo run -p lambda-rs --example offscreen_post` - Manual checks - Visual confirmation that: - Offscreen-only passes do not produce visible output until sampled. @@ -488,20 +390,24 @@ Per-frame commands: ## Compatibility and Migration -- The offscreen render target and multipass API is additive: - - Existing code that uses only surface-backed passes and pipelines continues - to compile and render unchanged. - - New APIs are exposed via `RenderTargetBuilder`, `RenderPassBuilder`, and - `RenderPipelineBuilder` methods; existing method signatures remain - compatible. -- Consumers MAY adopt offscreen targets incrementally: - - Start with post-processing or UI composition that samples a single - offscreen color target. - - Extend to depth-based passes (for example, shadow maps) when depth - attachment support is implemented. +- Existing surface-only command streams remain valid: + - `RenderCommand::BeginRenderPass` continues to target the surface. + - Pipelines built against `RenderContext::surface_format()` remain compatible. +- Migration path + - Create and attach one offscreen target. + - Render to it using `RenderCommand::BeginRenderPassTo` with + `RenderDestination::Offscreen(target_id)`. + - Sample `offscreen.color_texture()` in a later surface pass. +- Naming migration + - If `RenderTarget` (offscreen resource) is renamed to `OffscreenTarget`, the + rename SHOULD be introduced with a deprecated type alias to preserve source + compatibility for consumers. ## Changelog +- 2025-12-17 (v0.2.0) — Align terminology with `render_target::RenderTarget`, + specify destination-based pass targeting, define the offscreen MSAA resolve + model, and define feature-gated validation requirements. - 2025-11-25 (v0.1.1) — Updated requirements checklist to reflect implemented engine texture builder helpers and aligned metadata with current workspace revision. From 58e7dd9f9b98b05302b8b4cfe4d653e61796c153 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 19 Dec 2025 12:56:46 -0800 Subject: [PATCH 08/20] [update] specification. --- .../offscreen-render-targets-and-multipass.md | 91 +++++++++++-------- 1 file changed, 53 insertions(+), 38 deletions(-) diff --git a/docs/specs/offscreen-render-targets-and-multipass.md b/docs/specs/offscreen-render-targets-and-multipass.md index 9ee1fb22..6c7e165b 100644 --- a/docs/specs/offscreen-render-targets-and-multipass.md +++ b/docs/specs/offscreen-render-targets-and-multipass.md @@ -3,13 +3,13 @@ title: "Offscreen Render Targets and Multipass Rendering" document_id: "offscreen-render-targets-2025-11-25" status: "draft" created: "2025-11-25T00:00:00Z" -last_updated: "2025-12-17T00:00:00Z" -version: "0.2.0" +last_updated: "2025-12-17T23:00:02Z" +version: "0.2.1" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "9d16168136e560133c937d5202e6e1c80c3b2d28" +repo_commit: "f1743e5528bc4c8326a46e20123ffac62f717ec9" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "rendering", "offscreen", "multipass"] @@ -18,6 +18,7 @@ tags: ["spec", "rendering", "offscreen", "multipass"] # Offscreen Render Targets and Multipass Rendering Summary + - Defines an offscreen render-to-texture resource that produces a sampleable color texture. - Extends the command-driven renderer so a pass begin selects a render @@ -41,41 +42,43 @@ Summary ## Scope -### Goals - -- Add a first-class offscreen target resource with one color output and - optional depth. -- Allow a render pass begin command to select a destination: the surface or a - specific offscreen target. -- Enable multipass workflows where later passes sample from textures produced - by earlier passes. -- Provide validation and feature flags for render-target compatibility, sample - count and format mismatches, and common configuration pitfalls. - -### Non-Goals - -- Multiple simultaneous color attachments (MRT) per pass; a single color - attachment per pass remains the default in this specification. -- A full framegraph scheduler; ordering remains the explicit command sequence. -- Headless contexts without a presentation surface; this specification assumes - a window-backed `RenderContext`. -- Vendor-specific optimizations beyond what `wgpu` exposes via limits and - capabilities. +- Goals + - Add a first-class offscreen target resource with one color output and + optional depth. + - Allow a pass begin command to select a destination: the surface or a + specific offscreen target. + - Enable multipass workflows where later passes sample from textures + produced by earlier passes. + - Provide validation and feature flags for render-target compatibility, + sample count and format mismatches, and common configuration pitfalls. +- Non-Goals + - Multiple render targets (MRT) per pass; a single color attachment per pass + remains the default in this document. + - A full framegraph scheduler; ordering remains the explicit command + sequence. + - Headless contexts without a presentation surface; the current design + requires a window-backed `RenderContext`. + - Vendor-specific optimizations beyond what `wgpu` exposes via limits and + capabilities. ## Terminology -- Presentation render target: A window-backed render target that acquires and +- Multi-sample anti-aliasing (MSAA): rasterization technique that stores + multiple coverage samples per pixel and resolves them to a single color. +- Multiple render targets (MRT): rendering to more than one color attachment + within a single pass. +- Presentation render target: window-backed render target that acquires and presents swapchain frames (see `render_target::WindowSurface`). -- Offscreen target: A persistent resource that owns textures for render-to- +- Offscreen target: persistent resource that owns textures for render-to- texture workflows and exposes a sampleable color texture. -- Render destination: The destination selected when beginning a render pass: +- Render destination: destination selected when beginning a render pass: the presentation surface or a specific offscreen target. -- Resolve texture: The single-sample color texture produced by resolving an +- Resolve texture: single-sample color texture produced by resolving an MSAA color attachment; this is the texture sampled by later passes. -- Multipass rendering: A sequence of two or more render passes in a single +- Multipass rendering: sequence of two or more render passes in a single frame where later passes consume the results of earlier passes (for example, post-processing or shadow map sampling). -- Ping-pong target: A pair of offscreen render targets alternated between read +- Ping-pong target: pair of offscreen render targets alternated between read and write roles across passes. ## Architecture Overview @@ -85,10 +88,13 @@ Summary presenting frames. - `lambda::render::target::RenderTarget`: offscreen render-to-texture resource. -This specification treats the trait in `render_target` as the canonical meaning -of \"render target\". The offscreen resource is specified as `OffscreenTarget`. -The implementation SHOULD rename `lambda::render::target::RenderTarget` to -avoid API ambiguity. +Terminology in this document: +- "Render target" refers to `lambda::render::render_target::RenderTarget`. +- The offscreen resource is specified as `OffscreenTarget`. + +Implementation note: +- `lambda::render::target::RenderTarget` SHOULD be renamed to avoid API + ambiguity. Data flow (setup → per-frame multipass): ``` @@ -106,7 +112,7 @@ RenderPipelineBuilder::new() --> RenderPipeline (built for a specific color format) Per-frame commands: - BeginRenderPassTo { pass_id, viewport, destination } // surface or offscreen + BeginRenderPassTo { render_pass, viewport, destination } // surface or offscreen SetPipeline / SetBindGroup / Draw... EndRenderPass (repeat for additional passes) @@ -269,7 +275,7 @@ Per-frame commands: ### Always-on safeguards - Reject zero-sized offscreen targets at build time. -- Clamp invalid MSAA sample count inputs to `1` in builder APIs. +- Treat `sample_count == 0` as `1` in builder APIs. ### Feature-gated validation @@ -284,7 +290,9 @@ Crate: `lambda-rs` count). - Checks MUST occur at pass begin and at `SetPipeline` time, not per draw. - Logs SHOULD include: - - Destination size mismatches versus `RenderContext::surface_size()`. + - Destination size mismatches versus `RenderContext::surface_size()` when + the offscreen target is surface-sized by default and the surface + resizes. - Missing depth attachment when depth or stencil ops are requested. - Color format mismatches between destination and pipeline. - Expected runtime cost is low to moderate. @@ -294,11 +302,15 @@ Umbrella composition (crate: `lambda-rs`) - Umbrella features MUST only compose granular features. Build-type behavior -- Debug builds (`debug_assertions`) MAY enable offscreen validation regardless - of features. +- Debug builds (`debug_assertions`) MAY enable offscreen validation. - Release builds MUST keep offscreen validation disabled by default and enable it only via `render-validation-render-targets` (or umbrellas that include it). +Gating requirements +- Offscreen validation MUST be gated behind + `cfg(any(debug_assertions, feature = "render-validation-render-targets"))`. +- Offscreen validation MUST NOT be gated behind umbrella feature names. + ## Constraints and Rules - Offscreen target constraints @@ -405,6 +417,9 @@ Build-type behavior ## Changelog +- 2025-12-17 (v0.2.1) — Polish language for style consistency, clarify MSAA + terminology and builder safeguards, and specify validation gating + requirements. - 2025-12-17 (v0.2.0) — Align terminology with `render_target::RenderTarget`, specify destination-based pass targeting, define the offscreen MSAA resolve model, and define feature-gated validation requirements. From f8dd9027983ceb65ebf8a55fc2ee60c7875a5929 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 22 Dec 2025 13:19:12 -0800 Subject: [PATCH 09/20] [add] texture helpers. --- crates/lambda-rs-platform/src/wgpu/texture.rs | 5 +++++ crates/lambda-rs/src/render/texture.rs | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/crates/lambda-rs-platform/src/wgpu/texture.rs b/crates/lambda-rs-platform/src/wgpu/texture.rs index d8837a43..ec3fe653 100644 --- a/crates/lambda-rs-platform/src/wgpu/texture.rs +++ b/crates/lambda-rs-platform/src/wgpu/texture.rs @@ -640,6 +640,11 @@ impl Texture { return &self.view; } + /// Convenience: return a `TextureViewRef` for use in render pass attachments. + pub fn view_ref(&self) -> crate::wgpu::surface::TextureViewRef<'_> { + return crate::wgpu::surface::TextureViewRef { raw: &self.view }; + } + /// Optional debug label used during creation. pub fn label(&self) -> Option<&str> { return self.label.as_deref(); diff --git a/crates/lambda-rs/src/render/texture.rs b/crates/lambda-rs/src/render/texture.rs index e1cc2895..bda18e21 100644 --- a/crates/lambda-rs/src/render/texture.rs +++ b/crates/lambda-rs/src/render/texture.rs @@ -349,6 +349,13 @@ impl Texture { pub(crate) fn platform_texture(&self) -> Rc { return self.inner.clone(); } + + /// Borrow a texture view reference for use in render pass attachments. + pub(crate) fn view_ref(&self) -> crate::render::surface::TextureView<'_> { + return crate::render::surface::TextureView::from_platform( + self.inner.view_ref(), + ); + } } #[derive(Debug, Clone)] From 547d7da4cdd8423ad9f435251fc3d3a509c8fe13 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 22 Dec 2025 13:21:57 -0800 Subject: [PATCH 10/20] [add] features for offscreen validation. --- crates/lambda-rs/Cargo.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/lambda-rs/Cargo.toml b/crates/lambda-rs/Cargo.toml index ad4332df..0df201a6 100644 --- a/crates/lambda-rs/Cargo.toml +++ b/crates/lambda-rs/Cargo.toml @@ -49,10 +49,11 @@ render-validation = [ "render-validation-msaa", "render-validation-depth", "render-validation-stencil", + "render-validation-pass-compat", + "render-validation-render-targets", ] render-validation-strict = [ "render-validation", - "render-validation-pass-compat", "render-validation-encoder", ] render-validation-all = [ @@ -75,6 +76,7 @@ render-validation-pass-compat = [] render-validation-device = [] render-validation-encoder = [] render-validation-instancing = [] +render-validation-render-targets = [] # ---------------------------- PLATFORM DEPENDENCIES --------------------------- From d67fe98e3baaccad70c7ada8e7bec9b02da4b595 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 22 Dec 2025 13:22:26 -0800 Subject: [PATCH 11/20] [add] BeginRenderPassTo for choosing a render destination. --- crates/lambda-rs/src/render/command.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/lambda-rs/src/render/command.rs b/crates/lambda-rs/src/render/command.rs index be31ba69..05398c32 100644 --- a/crates/lambda-rs/src/render/command.rs +++ b/crates/lambda-rs/src/render/command.rs @@ -30,6 +30,15 @@ impl IndexFormat { } } +/// Render destination selected when beginning a render pass. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RenderDestination { + /// Target the presentation surface. + Surface, + /// Target a previously attached offscreen destination by id. + Offscreen(super::ResourceId), +} + /// Commands recorded and executed by the `RenderContext` to produce a frame. /// /// Order and validity are enforced by the encoder where possible. Invalid @@ -56,6 +65,15 @@ pub enum RenderCommand { render_pass: super::ResourceId, viewport: Viewport, }, + /// Begin a render pass targeting an explicit destination. + /// + /// `BeginRenderPass` remains valid and is equivalent to beginning a pass + /// with `RenderDestination::Surface`. + BeginRenderPassTo { + render_pass: super::ResourceId, + viewport: Viewport, + destination: RenderDestination, + }, /// End the current render pass. EndRenderPass, From e8bd8e9022567a553714bb488d230682020dcfa4 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 22 Dec 2025 13:26:34 -0800 Subject: [PATCH 12/20] [add] offscreen render target implementation to the render context and command encoder. --- .../lambda-rs/src/render/color_attachments.rs | 29 + crates/lambda-rs/src/render/encoder.rs | 69 +- crates/lambda-rs/src/render/mod.rs | 673 ++++++++++++------ crates/lambda-rs/src/render/pipeline.rs | 20 + crates/lambda-rs/src/render/target.rs | 159 +++-- 5 files changed, 681 insertions(+), 269 deletions(-) diff --git a/crates/lambda-rs/src/render/color_attachments.rs b/crates/lambda-rs/src/render/color_attachments.rs index 30895db1..ce1e19b2 100644 --- a/crates/lambda-rs/src/render/color_attachments.rs +++ b/crates/lambda-rs/src/render/color_attachments.rs @@ -91,4 +91,33 @@ impl<'view> RenderColorAttachments<'view> { return attachments; } + + /// Build color attachments for an offscreen render pass. + /// + /// This helper configures single-sample or multi-sample color attachments + /// targeting an offscreen resolve texture. When MSAA is enabled, the + /// `msaa_view` is used as the multi-sampled render target and `resolve_view` + /// receives the resolved output. + pub(crate) fn for_offscreen_pass( + uses_color: bool, + sample_count: u32, + msaa_view: Option>, + resolve_view: TextureView<'view>, + ) -> Self { + let mut attachments = RenderColorAttachments::new(); + + if !uses_color { + return attachments; + } + + if sample_count > 1 { + let msaa = + msaa_view.expect("MSAA view must be provided when sample_count > 1"); + attachments.push_msaa_color(msaa, resolve_view); + } else { + attachments.push_color(resolve_view); + } + + return attachments; + } } diff --git a/crates/lambda-rs/src/render/encoder.rs b/crates/lambda-rs/src/render/encoder.rs index 75df7a16..e13a206f 100644 --- a/crates/lambda-rs/src/render/encoder.rs +++ b/crates/lambda-rs/src/render/encoder.rs @@ -15,7 +15,12 @@ //! //! ```ignore //! let mut encoder = CommandEncoder::new(&render_context, "frame-encoder"); -//! encoder.with_render_pass(&pass, &mut attachments, depth, |rp_encoder| { +//! encoder.with_render_pass( +//! &pass, +//! RenderPassDestinationInfo { color_format: None, depth_format: None }, +//! &mut attachments, +//! depth, +//! |rp_encoder| { //! rp_encoder.set_pipeline(&pipeline)?; //! rp_encoder.draw(0..3, 0..1)?; //! Ok(()) @@ -42,7 +47,11 @@ use super::{ pipeline, pipeline::RenderPipeline, render_pass::RenderPass, - texture::DepthTexture, + texture::{ + DepthFormat, + DepthTexture, + TextureFormat, + }, validation, viewport::Viewport, RenderContext, @@ -53,6 +62,13 @@ use crate::util; // CommandEncoder // --------------------------------------------------------------------------- +/// Destination metadata needed for render-target compatibility validation. +#[derive(Clone, Copy, Debug)] +pub(crate) struct RenderPassDestinationInfo { + pub(crate) color_format: Option, + pub(crate) depth_format: Option, +} + /// High-level command encoder for recording GPU work. /// /// Created per-frame via `CommandEncoder::new()`. Commands are recorded by @@ -99,6 +115,7 @@ impl CommandEncoder { pub(crate) fn with_render_pass<'pass, PassFn, Output>( &'pass mut self, pass: &'pass RenderPass, + destination_info: RenderPassDestinationInfo, color_attachments: &'pass mut RenderColorAttachments<'pass>, depth_texture: Option<&'pass DepthTexture>, func: PassFn, @@ -110,6 +127,7 @@ impl CommandEncoder { let pass_encoder = RenderPassEncoder::new( &mut self.inner, pass, + destination_info, color_attachments, depth_texture, ); @@ -162,6 +180,10 @@ pub struct RenderPassEncoder<'pass> { has_stencil: bool, /// Sample count for MSAA validation. sample_count: u32, + /// Destination color format when the pass has color output. + destination_color_format: Option, + /// Destination depth format when a depth attachment is present. + destination_depth_format: Option, // Validation state (compiled out in release without features) #[cfg(any(debug_assertions, feature = "render-validation-encoder"))] @@ -206,6 +228,7 @@ impl<'pass> RenderPassEncoder<'pass> { fn new( encoder: &'pass mut platform::command::CommandEncoder, pass: &'pass RenderPass, + destination_info: RenderPassDestinationInfo, color_attachments: &'pass mut RenderColorAttachments<'pass>, depth_texture: Option<&'pass DepthTexture>, ) -> Self { @@ -243,6 +266,8 @@ impl<'pass> RenderPassEncoder<'pass> { has_depth_attachment, has_stencil, sample_count: pass.sample_count(), + destination_color_format: destination_info.color_format, + destination_depth_format: destination_info.depth_format, #[cfg(any(debug_assertions, feature = "render-validation-encoder"))] current_pipeline: None, #[cfg(any(debug_assertions, feature = "render-validation-encoder"))] @@ -304,6 +329,46 @@ impl<'pass> RenderPassEncoder<'pass> { } } + #[cfg(any(debug_assertions, feature = "render-validation-render-targets",))] + { + let label = pipeline.pipeline().label().unwrap_or("unnamed"); + + if pipeline.sample_count() != self.sample_count { + return Err(RenderPassError::PipelineIncompatible(format!( + "Render pipeline '{}' has sample_count={} but pass sample_count={}", + label, + pipeline.sample_count(), + self.sample_count + ))); + } + + if self.uses_color { + if let Some(dest_format) = self.destination_color_format { + if pipeline.color_target_format() != Some(dest_format) { + return Err(RenderPassError::PipelineIncompatible(format!( + "Render pipeline '{}' color format {:?} does not match destination color format {:?}", + label, + pipeline.color_target_format(), + dest_format + ))); + } + } + } + + if self.has_depth_attachment && pipeline.expects_depth_stencil() { + if let Some(dest_depth_format) = self.destination_depth_format { + if pipeline.depth_format() != Some(dest_depth_format) { + return Err(RenderPassError::PipelineIncompatible(format!( + "Render pipeline '{}' depth format {:?} does not match destination depth format {:?}", + label, + pipeline.depth_format(), + dest_depth_format + ))); + } + } + } + } + // Track current pipeline for draw validation #[cfg(any(debug_assertions, feature = "render-validation-encoder"))] { diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index c99c480c..ae658933 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -60,9 +60,13 @@ use std::{ use logging; use self::{ - command::RenderCommand, + command::{ + RenderCommand, + RenderDestination, + }, encoder::{ CommandEncoder, + RenderPassDestinationInfo, RenderPassError, }, pipeline::RenderPipeline, @@ -180,6 +184,7 @@ impl RenderContextBuilder { depth_sample_count: 1, msaa_color: None, msaa_sample_count: 1, + offscreen_targets: vec![], render_passes: vec![], render_pipelines: vec![], bind_group_layouts: vec![], @@ -230,6 +235,7 @@ pub struct RenderContext { depth_sample_count: u32, msaa_color: Option, msaa_sample_count: u32, + offscreen_targets: Vec, render_passes: Vec, render_pipelines: Vec, bind_group_layouts: Vec, @@ -267,6 +273,33 @@ impl RenderContext { return id; } + /// Attach an offscreen target and return a handle for use in destinations. + pub fn attach_offscreen_target( + &mut self, + target: target::OffscreenTarget, + ) -> ResourceId { + let id = self.offscreen_targets.len(); + self.offscreen_targets.push(target); + return id; + } + + /// Replace an attached offscreen target in-place. + /// + /// Returns an error when `id` does not refer to an attached offscreen + /// target. + pub fn replace_offscreen_target( + &mut self, + id: ResourceId, + target: target::OffscreenTarget, + ) -> Result<(), String> { + let slot = match self.offscreen_targets.get_mut(id) { + Some(slot) => slot, + None => return Err(format!("Unknown offscreen target id {}", id)), + }; + *slot = target; + return Ok(()); + } + /// Attach a bind group layout and return a handle for use in pipeline layout composition. pub fn attach_bind_group_layout( &mut self, @@ -284,6 +317,20 @@ impl RenderContext { return id; } + /// Replace an attached bind group in-place. + pub fn replace_bind_group( + &mut self, + id: ResourceId, + group: bind::BindGroup, + ) -> Result<(), String> { + let slot = match self.bind_groups.get_mut(id) { + Some(slot) => slot, + None => return Err(format!("Unknown bind group id {}", id)), + }; + *slot = group; + return Ok(()); + } + /// Attach a generic GPU buffer and return a handle for render commands. pub fn attach_buffer(&mut self, buffer: buffer::Buffer) -> ResourceId { let id = self.buffers.len(); @@ -358,6 +405,16 @@ impl RenderContext { return &self.render_pipelines[id]; } + /// Borrow a previously attached offscreen target by id. + /// + /// Panics if `id` does not refer to an attached offscreen target. + pub fn get_offscreen_target( + &self, + id: ResourceId, + ) -> &target::OffscreenTarget { + return &self.offscreen_targets[id]; + } + /// Access the GPU device for resource creation. /// /// Use this to pass to resource builders (buffers, textures, bind groups, @@ -486,227 +543,38 @@ impl RenderContext { render_pass, viewport, } => { - // Clone the render pass descriptor to avoid borrowing self while we - // need mutable access for MSAA texture creation. - let pass = self - .render_passes - .get(render_pass) - .ok_or_else(|| { - RenderError::Configuration(format!( - "Unknown render pass {render_pass}" - )) - })? - .clone(); - - // Ensure MSAA texture exists if needed. - let sample_count = pass.sample_count(); - let uses_color = pass.uses_color(); - if uses_color && sample_count > 1 { - self.ensure_msaa_color_texture(sample_count); - } - - // Create color attachments for the surface pass. The MSAA view is - // retrieved here after the mutable borrow for texture creation ends. - let msaa_view = if sample_count > 1 { - self.msaa_color.as_ref().map(|t| t.view_ref()) - } else { - None - }; - let mut color_attachments = - color_attachments::RenderColorAttachments::for_surface_pass( - uses_color, - sample_count, - msaa_view, - view, - ); - - // Depth/stencil attachment when either depth or stencil requested. - let want_depth_attachment = Self::has_depth_attachment( - pass.depth_operations(), - pass.stencil_operations(), - ); - - // Prepare depth texture if needed. - if want_depth_attachment { - // Ensure depth texture exists, with proper sample count and format. - let desired_samples = sample_count.max(1); - - // If stencil is requested on the pass, ensure we use a - // stencil-capable format. - if pass.stencil_operations().is_some() - && self.depth_format != texture::DepthFormat::Depth24PlusStencil8 - { - #[cfg(any( - debug_assertions, - feature = "render-validation-stencil", - ))] - logging::error!( - "Render pass has stencil ops but depth format {:?} lacks \ - stencil; upgrading to Depth24PlusStencil8", - self.depth_format - ); - self.depth_format = texture::DepthFormat::Depth24PlusStencil8; - } - - let format_mismatch = self - .depth_texture - .as_ref() - .map(|dt| dt.format() != self.depth_format) - .unwrap_or(true); - - if self.depth_texture.is_none() - || self.depth_sample_count != desired_samples - || format_mismatch - { - self.depth_texture = Some( - texture::DepthTextureBuilder::new() - .with_size(self.size.0.max(1), self.size.1.max(1)) - .with_format(self.depth_format) - .with_sample_count(desired_samples) - .with_label("lambda-depth") - .build(&self.gpu), - ); - self.depth_sample_count = desired_samples; - } - } - - let depth_texture_ref = if want_depth_attachment { - self.depth_texture.as_ref() - } else { - None - }; - - // Use the high-level encoder's with_render_pass callback API. - let min_uniform_buffer_offset_alignment = - self.limit_min_uniform_buffer_offset_alignment(); - let render_pipelines = &self.render_pipelines; - let bind_groups = &self.bind_groups; - let buffers = &self.buffers; - - encoder.with_render_pass( - &pass, - &mut color_attachments, - depth_texture_ref, - |rp_encoder| { - rp_encoder.set_viewport(&viewport); - - while let Some(cmd) = command_iter.next() { - match cmd { - RenderCommand::EndRenderPass => return Ok(()), - RenderCommand::SetStencilReference { reference } => { - rp_encoder.set_stencil_reference(reference); - } - RenderCommand::SetPipeline { pipeline } => { - let pipeline_ref = - render_pipelines.get(pipeline).ok_or_else(|| { - RenderPassError::Validation(format!( - "Unknown pipeline {pipeline}" - )) - })?; - rp_encoder.set_pipeline(pipeline_ref)?; - } - RenderCommand::SetViewports { viewports, .. } => { - for vp in viewports { - rp_encoder.set_viewport(&vp); - } - } - RenderCommand::SetScissors { viewports, .. } => { - for vp in viewports { - rp_encoder.set_scissor(&vp); - } - } - RenderCommand::SetBindGroup { - set, - group, - dynamic_offsets, - } => { - let group_ref = - bind_groups.get(group).ok_or_else(|| { - RenderPassError::Validation(format!( - "Unknown bind group {group}" - )) - })?; - rp_encoder.set_bind_group( - set, - group_ref, - &dynamic_offsets, - min_uniform_buffer_offset_alignment, - )?; - } - RenderCommand::BindVertexBuffer { pipeline, buffer } => { - let pipeline_ref = - render_pipelines.get(pipeline).ok_or_else(|| { - RenderPassError::Validation(format!( - "Unknown pipeline {pipeline}" - )) - })?; - let buffer_ref = pipeline_ref - .buffers() - .get(buffer as usize) - .ok_or_else(|| { - RenderPassError::Validation(format!( - "Vertex buffer index {buffer} not found for \ - pipeline {pipeline}" - )) - })?; - rp_encoder.set_vertex_buffer(buffer as u32, buffer_ref); - } - RenderCommand::BindIndexBuffer { buffer, format } => { - let buffer_ref = buffers.get(buffer).ok_or_else(|| { - RenderPassError::Validation(format!( - "Index buffer id {} not found", - buffer - )) - })?; - rp_encoder.set_index_buffer(buffer_ref, format)?; - } - RenderCommand::PushConstants { - pipeline, - stage, - offset, - bytes, - } => { - let _ = - render_pipelines.get(pipeline).ok_or_else(|| { - RenderPassError::Validation(format!( - "Unknown pipeline {pipeline}" - )) - })?; - let slice = unsafe { - std::slice::from_raw_parts( - bytes.as_ptr() as *const u8, - bytes.len() * std::mem::size_of::(), - ) - }; - rp_encoder.set_push_constants(stage, offset, slice); - } - RenderCommand::Draw { - vertices, - instances, - } => { - rp_encoder.draw(vertices, instances)?; - } - RenderCommand::DrawIndexed { - indices, - base_vertex, - instances, - } => { - rp_encoder.draw_indexed(indices, base_vertex, instances)?; - } - RenderCommand::BeginRenderPass { .. } => { - return Err(RenderPassError::Validation( - "Nested render passes are not supported.".to_string(), - )); - } - } - } - - return Err(RenderPassError::Validation( - "Render pass did not terminate with EndRenderPass".to_string(), - )); - }, + self.encode_surface_render_pass( + &mut encoder, + &mut command_iter, + render_pass, + viewport, + view, )?; } + RenderCommand::BeginRenderPassTo { + render_pass, + viewport, + destination, + } => match destination { + RenderDestination::Surface => { + self.encode_surface_render_pass( + &mut encoder, + &mut command_iter, + render_pass, + viewport, + view, + )?; + } + RenderDestination::Offscreen(target_id) => { + self.encode_offscreen_render_pass( + &mut encoder, + &mut command_iter, + render_pass, + viewport, + target_id, + )?; + } + }, other => { logging::warn!( "Ignoring render command outside of a render pass: {:?}", @@ -721,6 +589,371 @@ impl RenderContext { return Ok(()); } + fn encode_surface_render_pass<'view>( + &mut self, + encoder: &mut CommandEncoder, + command_iter: &mut std::vec::IntoIter, + render_pass: ResourceId, + viewport: viewport::Viewport, + surface_view: surface::TextureView<'view>, + ) -> Result<(), RenderError> { + // Clone the render pass descriptor to avoid borrowing self while we need + // mutable access for MSAA texture creation. + let pass = self + .render_passes + .get(render_pass) + .ok_or_else(|| { + RenderError::Configuration(format!("Unknown render pass {render_pass}")) + })? + .clone(); + + // Ensure MSAA texture exists if needed. + let sample_count = pass.sample_count(); + let uses_color = pass.uses_color(); + if uses_color && sample_count > 1 { + self.ensure_msaa_color_texture(sample_count); + } + + // Create color attachments for the surface pass. The MSAA view is + // retrieved here after the mutable borrow for texture creation ends. + let msaa_view = if sample_count > 1 { + self.msaa_color.as_ref().map(|t| t.view_ref()) + } else { + None + }; + let mut color_attachments = + color_attachments::RenderColorAttachments::for_surface_pass( + uses_color, + sample_count, + msaa_view, + surface_view, + ); + + // Depth/stencil attachment when either depth or stencil requested. + let want_depth_attachment = Self::has_depth_attachment( + pass.depth_operations(), + pass.stencil_operations(), + ); + + // Prepare depth texture if needed. + if want_depth_attachment { + // Ensure depth texture exists, with proper sample count and format. + let desired_samples = sample_count.max(1); + + // If stencil is requested on the pass, ensure we use a stencil-capable format. + if pass.stencil_operations().is_some() + && self.depth_format != texture::DepthFormat::Depth24PlusStencil8 + { + #[cfg(any(debug_assertions, feature = "render-validation-stencil",))] + logging::error!( + "Render pass has stencil ops but depth format {:?} lacks stencil; upgrading to Depth24PlusStencil8", + self.depth_format + ); + self.depth_format = texture::DepthFormat::Depth24PlusStencil8; + } + + let format_mismatch = self + .depth_texture + .as_ref() + .map(|dt| dt.format() != self.depth_format) + .unwrap_or(true); + + if self.depth_texture.is_none() + || self.depth_sample_count != desired_samples + || format_mismatch + { + self.depth_texture = Some( + texture::DepthTextureBuilder::new() + .with_size(self.size.0.max(1), self.size.1.max(1)) + .with_format(self.depth_format) + .with_sample_count(desired_samples) + .with_label("lambda-depth") + .build(&self.gpu), + ); + self.depth_sample_count = desired_samples; + } + } + + let depth_texture_ref = if want_depth_attachment { + self.depth_texture.as_ref() + } else { + None + }; + + let min_uniform_buffer_offset_alignment = + self.limit_min_uniform_buffer_offset_alignment(); + let render_pipelines = &self.render_pipelines; + let bind_groups = &self.bind_groups; + let buffers = &self.buffers; + + encoder.with_render_pass( + &pass, + RenderPassDestinationInfo { + color_format: if uses_color { + Some(self.surface_format()) + } else { + None + }, + depth_format: if want_depth_attachment { + Some(self.depth_format()) + } else { + None + }, + }, + &mut color_attachments, + depth_texture_ref, + |rp_encoder| { + return Self::encode_active_render_pass_commands( + command_iter, + rp_encoder, + &viewport, + render_pipelines, + bind_groups, + buffers, + min_uniform_buffer_offset_alignment, + ); + }, + )?; + + return Ok(()); + } + + fn encode_offscreen_render_pass( + &mut self, + encoder: &mut CommandEncoder, + command_iter: &mut std::vec::IntoIter, + render_pass: ResourceId, + viewport: viewport::Viewport, + target_id: ResourceId, + ) -> Result<(), RenderError> { + let pass = self + .render_passes + .get(render_pass) + .ok_or_else(|| { + RenderError::Configuration(format!("Unknown render pass {render_pass}")) + })? + .clone(); + + let target = self.offscreen_targets.get(target_id).ok_or_else(|| { + RenderError::Configuration(format!( + "Unknown offscreen target {target_id}" + )) + })?; + + let pass_samples = pass.sample_count(); + let target_samples = target.sample_count(); + if pass_samples != target_samples { + return Err(RenderError::Configuration(format!( + "Pass sample_count={} does not match offscreen target sample_count={}", + pass_samples, target_samples + ))); + } + + let uses_color = pass.uses_color(); + let mut color_attachments = + color_attachments::RenderColorAttachments::for_offscreen_pass( + uses_color, + target_samples, + target.msaa_view(), + target.resolve_view(), + ); + + let want_depth_attachment = Self::has_depth_attachment( + pass.depth_operations(), + pass.stencil_operations(), + ); + + if want_depth_attachment && target.depth_texture().is_none() { + return Err(RenderError::Configuration( + "Render pass requests depth/stencil operations but the selected offscreen target has no depth attachment" + .to_string(), + )); + } + + if pass.stencil_operations().is_some() + && target.depth_format() + != Some(texture::DepthFormat::Depth24PlusStencil8) + { + return Err(RenderError::Configuration( + "Render pass requests stencil operations but the selected offscreen target depth format lacks stencil" + .to_string(), + )); + } + + #[cfg(any(debug_assertions, feature = "render-validation-render-targets",))] + { + if target.defaulted_from_surface_size() && target.size() != self.size { + logging::warn!( + "Offscreen target size {:?} does not match surface size {:?}; rebuild the target to match the new surface size", + target.size(), + self.size + ); + } + } + + let depth_texture_ref = if want_depth_attachment { + target.depth_texture() + } else { + None + }; + + let min_uniform_buffer_offset_alignment = + self.limit_min_uniform_buffer_offset_alignment(); + let render_pipelines = &self.render_pipelines; + let bind_groups = &self.bind_groups; + let buffers = &self.buffers; + + encoder.with_render_pass( + &pass, + RenderPassDestinationInfo { + color_format: if uses_color { + Some(target.color_format()) + } else { + None + }, + depth_format: if want_depth_attachment { + target.depth_format() + } else { + None + }, + }, + &mut color_attachments, + depth_texture_ref, + |rp_encoder| { + return Self::encode_active_render_pass_commands( + command_iter, + rp_encoder, + &viewport, + render_pipelines, + bind_groups, + buffers, + min_uniform_buffer_offset_alignment, + ); + }, + )?; + + return Ok(()); + } + + fn encode_active_render_pass_commands( + command_iter: &mut std::vec::IntoIter, + rp_encoder: &mut encoder::RenderPassEncoder<'_>, + initial_viewport: &viewport::Viewport, + render_pipelines: &Vec, + bind_groups: &Vec, + buffers: &Vec>, + min_uniform_buffer_offset_alignment: u32, + ) -> Result<(), RenderPassError> { + rp_encoder.set_viewport(initial_viewport); + + while let Some(cmd) = command_iter.next() { + match cmd { + RenderCommand::EndRenderPass => return Ok(()), + RenderCommand::SetStencilReference { reference } => { + rp_encoder.set_stencil_reference(reference); + } + RenderCommand::SetPipeline { pipeline } => { + let pipeline_ref = + render_pipelines.get(pipeline).ok_or_else(|| { + RenderPassError::Validation(format!( + "Unknown pipeline {pipeline}" + )) + })?; + rp_encoder.set_pipeline(pipeline_ref)?; + } + RenderCommand::SetViewports { viewports, .. } => { + for vp in viewports { + rp_encoder.set_viewport(&vp); + } + } + RenderCommand::SetScissors { viewports, .. } => { + for vp in viewports { + rp_encoder.set_scissor(&vp); + } + } + RenderCommand::SetBindGroup { + set, + group, + dynamic_offsets, + } => { + let group_ref = bind_groups.get(group).ok_or_else(|| { + RenderPassError::Validation(format!("Unknown bind group {group}")) + })?; + rp_encoder.set_bind_group( + set, + group_ref, + &dynamic_offsets, + min_uniform_buffer_offset_alignment, + )?; + } + RenderCommand::BindVertexBuffer { pipeline, buffer } => { + let pipeline_ref = + render_pipelines.get(pipeline).ok_or_else(|| { + RenderPassError::Validation(format!( + "Unknown pipeline {pipeline}" + )) + })?; + let buffer_ref = + pipeline_ref.buffers().get(buffer as usize).ok_or_else(|| { + RenderPassError::Validation(format!( + "Vertex buffer index {buffer} not found for pipeline {pipeline}" + )) + })?; + rp_encoder.set_vertex_buffer(buffer as u32, buffer_ref); + } + RenderCommand::BindIndexBuffer { buffer, format } => { + let buffer_ref = buffers.get(buffer).ok_or_else(|| { + RenderPassError::Validation(format!( + "Index buffer id {} not found", + buffer + )) + })?; + rp_encoder.set_index_buffer(buffer_ref, format)?; + } + RenderCommand::PushConstants { + pipeline, + stage, + offset, + bytes, + } => { + let _ = render_pipelines.get(pipeline).ok_or_else(|| { + RenderPassError::Validation(format!("Unknown pipeline {pipeline}")) + })?; + let slice = unsafe { + std::slice::from_raw_parts( + bytes.as_ptr() as *const u8, + bytes.len() * std::mem::size_of::(), + ) + }; + rp_encoder.set_push_constants(stage, offset, slice); + } + RenderCommand::Draw { + vertices, + instances, + } => { + rp_encoder.draw(vertices, instances)?; + } + RenderCommand::DrawIndexed { + indices, + base_vertex, + instances, + } => { + rp_encoder.draw_indexed(indices, base_vertex, instances)?; + } + RenderCommand::BeginRenderPass { .. } + | RenderCommand::BeginRenderPassTo { .. } => { + return Err(RenderPassError::Validation( + "Nested render passes are not supported.".to_string(), + )); + } + } + } + + return Err(RenderPassError::Validation( + "Render pass did not terminate with EndRenderPass".to_string(), + )); + } + /// Reconfigure the presentation surface using current present mode/usage. fn reconfigure_surface( &mut self, diff --git a/crates/lambda-rs/src/render/pipeline.rs b/crates/lambda-rs/src/render/pipeline.rs index bff32e07..a096434d 100644 --- a/crates/lambda-rs/src/render/pipeline.rs +++ b/crates/lambda-rs/src/render/pipeline.rs @@ -61,7 +61,9 @@ pub struct RenderPipeline { buffers: Vec>, sample_count: u32, color_target_count: u32, + color_target_format: Option, expects_depth_stencil: bool, + depth_format: Option, uses_stencil: bool, per_instance_slots: Vec, } @@ -90,11 +92,19 @@ impl RenderPipeline { return self.color_target_count > 0; } + pub(super) fn color_target_format(&self) -> Option { + return self.color_target_format; + } + /// Whether the pipeline expects a depth-stencil attachment. pub(super) fn expects_depth_stencil(&self) -> bool { return self.expects_depth_stencil; } + pub(super) fn depth_format(&self) -> Option { + return self.depth_format; + } + /// Whether the pipeline configured a stencil test/state. pub(super) fn uses_stencil(&self) -> bool { return self.uses_stencil; @@ -563,6 +573,13 @@ impl RenderPipelineBuilder { rp_builder = rp_builder.with_color_target(surface_format.to_platform()); } + let pipeline_color_target_format = if fragment_module.is_some() { + Some(surface_format) + } else { + None + }; + + let mut pipeline_depth_format: Option = None; if self.use_depth { // Engine-level depth format with default let mut dfmt = self @@ -589,6 +606,7 @@ impl RenderPipelineBuilder { } else { depth_format }; + pipeline_depth_format = Some(pass_depth_format); // Align the pipeline depth format with the pass attachment format to // avoid hidden global state on the render context. When formats differ, @@ -656,8 +674,10 @@ impl RenderPipelineBuilder { buffers, sample_count: pipeline_samples, color_target_count: if fragment_module.is_some() { 1 } else { 0 }, + color_target_format: pipeline_color_target_format, // Depth/stencil is enabled when `with_depth*` was called on the builder. expects_depth_stencil: self.use_depth, + depth_format: pipeline_depth_format, uses_stencil: self.stencil.is_some(), per_instance_slots, }; diff --git a/crates/lambda-rs/src/render/target.rs b/crates/lambda-rs/src/render/target.rs index 7ed88828..65d40153 100644 --- a/crates/lambda-rs/src/render/target.rs +++ b/crates/lambda-rs/src/render/target.rs @@ -1,33 +1,36 @@ //! Offscreen render targets and builders. //! -//! Provides `RenderTarget` and `RenderTargetBuilder` for render‑to‑texture +//! Provides `OffscreenTarget` and `OffscreenTargetBuilder` for render‑to‑texture //! workflows without exposing platform texture types at call sites. use logging; use super::{ + surface, texture, RenderContext, }; use crate::render::validation; -#[derive(Debug, Clone)] +#[derive(Debug)] /// Offscreen render target with color and optional depth attachments. /// -/// A `RenderTarget` owns a color texture (and optional depth texture) sized +/// An `OffscreenTarget` owns a color texture (and optional depth texture) sized /// independently of the presentation surface. It is intended for render‑to‑ /// texture workflows such as post‑processing, shadow maps, and UI composition. -pub struct RenderTarget { - color: texture::Texture, +pub struct OffscreenTarget { + resolve_color: texture::Texture, + msaa_color: Option, depth: Option, size: (u32, u32), color_format: texture::TextureFormat, depth_format: Option, sample_count: u32, label: Option, + defaulted_from_surface_size: bool, } -impl RenderTarget { +impl OffscreenTarget { /// Texture size in pixels. pub fn size(&self) -> (u32, u32) { return self.size; @@ -48,9 +51,9 @@ impl RenderTarget { return self.sample_count.max(1); } - /// Access the color attachment texture for sampling. + /// Access the resolve color texture for sampling. pub fn color_texture(&self) -> &texture::Texture { - return &self.color; + return &self.resolve_color; } /// Access the optional depth attachment texture. @@ -58,11 +61,30 @@ impl RenderTarget { return self.depth.as_ref(); } + /// Access the multi-sampled color attachment used for rendering. + pub(crate) fn msaa_color_texture( + &self, + ) -> Option<&texture::ColorAttachmentTexture> { + return self.msaa_color.as_ref(); + } + + pub(crate) fn resolve_view(&self) -> surface::TextureView<'_> { + return self.resolve_color.view_ref(); + } + + pub(crate) fn msaa_view(&self) -> Option> { + return self.msaa_color.as_ref().map(|t| t.view_ref()); + } + /// Optional debug label assigned at creation time. pub(crate) fn label(&self) -> Option<&str> { return self.label.as_deref(); } + pub(crate) fn defaulted_from_surface_size(&self) -> bool { + return self.defaulted_from_surface_size; + } + /// Explicitly destroy this render target. /// /// Dropping the value also releases the underlying GPU resources; this @@ -71,8 +93,8 @@ impl RenderTarget { } #[derive(Debug, Clone, PartialEq, Eq)] -/// Errors returned when building a `RenderTarget`. -pub enum RenderTargetError { +/// Errors returned when building an `OffscreenTarget`. +pub enum OffscreenTargetError { /// Color attachment was not configured. MissingColorAttachment, /// Width or height was zero after resolving defaults. @@ -85,8 +107,8 @@ pub enum RenderTargetError { DeviceError(String), } -/// Builder for creating a `RenderTarget`. -pub struct RenderTargetBuilder { +/// Builder for creating an `OffscreenTarget`. +pub struct OffscreenTargetBuilder { label: Option, color_format: Option, width: u32, @@ -95,7 +117,7 @@ pub struct RenderTargetBuilder { sample_count: u32, } -impl RenderTargetBuilder { +impl OffscreenTargetBuilder { /// Create a new builder with no attachments configured. pub fn new() -> Self { return Self { @@ -133,24 +155,8 @@ impl RenderTargetBuilder { /// Configure multi‑sampling for this target. /// - /// Values outside the supported set `{1, 2, 4, 8}` fall back to `1` and - /// emit validation logs under `render-validation-msaa` or debug assertions. pub fn with_multi_sample(mut self, samples: u32) -> Self { - let allowed = matches!(samples, 1 | 2 | 4 | 8); - if allowed { - self.sample_count = samples; - } else { - #[cfg(any(debug_assertions, feature = "render-validation-msaa",))] - { - if let Err(message) = validation::validate_sample_count(samples) { - logging::error!( - "{}; falling back to sample_count=1 for render target", - message - ); - } - } - self.sample_count = 1; - } + self.sample_count = samples.max(1); return self; } @@ -164,41 +170,85 @@ impl RenderTargetBuilder { pub fn build( self, render_context: &mut RenderContext, - ) -> Result { + ) -> Result { let format = match self.color_format { Some(format) => format, - None => return Err(RenderTargetError::MissingColorAttachment), + None => return Err(OffscreenTargetError::MissingColorAttachment), }; let surface_size = render_context.surface_size(); + let defaulted_from_surface_size = self.width == 0 || self.height == 0; let (width, height) = self.resolve_size(surface_size)?; - // Clamp to at least one sample; device‑limit checks are added in a - // validation milestone. let sample_count = self.sample_count.max(1); + if let Err(_) = validation::validate_sample_count(sample_count) { + return Err(OffscreenTargetError::UnsupportedSampleCount { + requested: sample_count, + }); + } + + if sample_count > 1 + && !render_context + .gpu() + .supports_sample_count_for_format(format, sample_count) + { + return Err(OffscreenTargetError::UnsupportedSampleCount { + requested: sample_count, + }); + } + + if let Some(depth_format) = self.depth_format { + if sample_count > 1 + && !render_context + .gpu() + .supports_sample_count_for_depth(depth_format, sample_count) + { + return Err(OffscreenTargetError::UnsupportedSampleCount { + requested: sample_count, + }); + } + } let mut color_builder = texture::TextureBuilder::new_2d(format) .with_size(width, height) .for_render_target(); if let Some(ref label) = self.label { - color_builder = color_builder.with_label(label); + if sample_count > 1 { + color_builder = color_builder.with_label(&format!("{}-resolve", label)); + } else { + color_builder = color_builder.with_label(label); + } } - let color_texture = match color_builder.build(render_context.gpu()) { + let resolve_texture = match color_builder.build(render_context.gpu()) { Ok(texture) => texture, Err(message) => { - return Err(RenderTargetError::DeviceError(message.to_string())); + return Err(OffscreenTargetError::DeviceError(message.to_string())); } }; + let msaa_texture = if sample_count > 1 { + let mut msaa_builder = + texture::ColorAttachmentTextureBuilder::new(format) + .with_size(width, height) + .with_sample_count(sample_count); + if let Some(ref label) = self.label { + msaa_builder = msaa_builder.with_label(&format!("{}-msaa", label)); + } + Some(msaa_builder.build(render_context.gpu())) + } else { + None + }; + let depth_texture = if let Some(depth_format) = self.depth_format { let mut depth_builder = texture::DepthTextureBuilder::new() .with_size(width, height) - .with_format(depth_format); + .with_format(depth_format) + .with_sample_count(sample_count); if let Some(ref label) = self.label { - depth_builder = depth_builder.with_label(label); + depth_builder = depth_builder.with_label(&format!("{}-depth", label)); } Some(depth_builder.build(render_context.gpu())) @@ -206,14 +256,16 @@ impl RenderTargetBuilder { None }; - return Ok(RenderTarget { - color: color_texture, + return Ok(OffscreenTarget { + resolve_color: resolve_texture, + msaa_color: msaa_texture, depth: depth_texture, size: (width, height), color_format: format, depth_format: self.depth_format, sample_count, label: self.label, + defaulted_from_surface_size, }); } @@ -225,7 +277,7 @@ impl RenderTargetBuilder { pub(crate) fn resolve_size( &self, surface_size: (u32, u32), - ) -> Result<(u32, u32), RenderTargetError> { + ) -> Result<(u32, u32), OffscreenTargetError> { let mut width = self.width; let mut height = self.height; if width == 0 || height == 0 { @@ -234,13 +286,26 @@ impl RenderTargetBuilder { } if width == 0 || height == 0 { - return Err(RenderTargetError::InvalidSize { width, height }); + return Err(OffscreenTargetError::InvalidSize { width, height }); } return Ok((width, height)); } } +#[deprecated( + note = "Use `lambda::render::target::OffscreenTarget` to avoid confusion with `lambda::render::render_target::RenderTarget`." +)] +pub type RenderTarget = OffscreenTarget; + +#[deprecated( + note = "Use `lambda::render::target::OffscreenTargetBuilder` to avoid confusion with `lambda::render::render_target::RenderTarget`." +)] +pub type RenderTargetBuilder = OffscreenTargetBuilder; + +#[deprecated(note = "Use `lambda::render::target::OffscreenTargetError`.")] +pub type RenderTargetError = OffscreenTargetError; + #[cfg(test)] mod tests { use super::*; @@ -248,7 +313,7 @@ mod tests { /// Defaults size to the surface when no explicit dimensions are provided. #[test] fn resolve_size_defaults_to_surface_size() { - let builder = RenderTargetBuilder::new().with_color( + let builder = OffscreenTargetBuilder::new().with_color( texture::TextureFormat::Rgba8Unorm, 0, 0, @@ -262,7 +327,7 @@ mod tests { /// Fails when the resolved size has a zero dimension. #[test] fn resolve_size_rejects_zero_dimensions() { - let builder = RenderTargetBuilder::new().with_color( + let builder = OffscreenTargetBuilder::new().with_color( texture::TextureFormat::Rgba8Unorm, 0, 0, @@ -272,7 +337,7 @@ mod tests { let resolved = builder.resolve_size(surface_size); assert_eq!( resolved, - Err(RenderTargetError::InvalidSize { + Err(OffscreenTargetError::InvalidSize { width: 0, height: 0 }) @@ -282,7 +347,7 @@ mod tests { /// Clamps sample counts less than one to one. #[test] fn sample_count_is_clamped_to_one() { - let builder = RenderTargetBuilder::new().with_multi_sample(0); + let builder = OffscreenTargetBuilder::new().with_multi_sample(0); assert_eq!(builder.sample_count, 1); } } From bb025e0e8a1e63405650a93d1aac232368bbd3a0 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sat, 27 Dec 2025 14:39:03 -0800 Subject: [PATCH 13/20] [add] new module targets for implementing explicit render targets (surface & offscreen) --- crates/lambda-rs/src/render/gpu.rs | 2 +- crates/lambda-rs/src/render/mod.rs | 38 +++---- crates/lambda-rs/src/render/targets/mod.rs | 8 ++ .../{target.rs => targets/offscreen.rs} | 82 ++++------------ .../{render_target.rs => targets/surface.rs} | 48 ++------- docs/features.md | 9 +- .../offscreen-render-targets-and-multipass.md | 98 ++++++++++--------- 7 files changed, 109 insertions(+), 176 deletions(-) create mode 100644 crates/lambda-rs/src/render/targets/mod.rs rename crates/lambda-rs/src/render/{target.rs => targets/offscreen.rs} (76%) rename crates/lambda-rs/src/render/{render_target.rs => targets/surface.rs} (74%) diff --git a/crates/lambda-rs/src/render/gpu.rs b/crates/lambda-rs/src/render/gpu.rs index 3e8cf053..2b1036e1 100644 --- a/crates/lambda-rs/src/render/gpu.rs +++ b/crates/lambda-rs/src/render/gpu.rs @@ -25,7 +25,7 @@ use lambda_platform::wgpu as platform; use super::{ instance::Instance, - render_target::WindowSurface, + targets::surface::WindowSurface, texture::{ DepthFormat, TextureFormat, diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index ae658933..be4bb571 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -38,11 +38,10 @@ pub mod instance; pub mod mesh; pub mod pipeline; pub mod render_pass; -pub mod render_target; pub mod scene_math; pub mod shader; pub mod surface; -pub mod target; +pub mod targets; pub mod texture; pub mod validation; pub mod vertex; @@ -71,7 +70,7 @@ use self::{ }, pipeline::RenderPipeline, render_pass::RenderPass as RenderPassDesc, - render_target::RenderTarget, + targets::surface::RenderTarget, }; /// Builder for configuring a `RenderContext` tied to one window. @@ -127,13 +126,13 @@ impl RenderContextBuilder { .with_label(&format!("{} Instance", name)) .build(); - let mut surface = render_target::WindowSurface::new(&instance, window) + let mut surface = targets::surface::WindowSurface::new(&instance, window) .map_err(|e| { - RenderContextError::SurfaceCreate(format!( - "Failed to create rendering surface: {:?}", - e - )) - })?; + RenderContextError::SurfaceCreate(format!( + "Failed to create rendering surface: {:?}", + e + )) + })?; let gpu = gpu::GpuBuilder::new() .with_label(&format!("{} Device", name)) @@ -225,7 +224,7 @@ impl RenderContextBuilder { pub struct RenderContext { label: String, instance: instance::Instance, - surface: render_target::WindowSurface, + surface: targets::surface::WindowSurface, gpu: gpu::Gpu, config: surface::SurfaceConfig, texture_usage: texture::TextureUsages, @@ -235,7 +234,7 @@ pub struct RenderContext { depth_sample_count: u32, msaa_color: Option, msaa_sample_count: u32, - offscreen_targets: Vec, + offscreen_targets: Vec, render_passes: Vec, render_pipelines: Vec, bind_group_layouts: Vec, @@ -276,7 +275,7 @@ impl RenderContext { /// Attach an offscreen target and return a handle for use in destinations. pub fn attach_offscreen_target( &mut self, - target: target::OffscreenTarget, + target: targets::offscreen::OffscreenTarget, ) -> ResourceId { let id = self.offscreen_targets.len(); self.offscreen_targets.push(target); @@ -290,7 +289,7 @@ impl RenderContext { pub fn replace_offscreen_target( &mut self, id: ResourceId, - target: target::OffscreenTarget, + target: targets::offscreen::OffscreenTarget, ) -> Result<(), String> { let slot = match self.offscreen_targets.get_mut(id) { Some(slot) => slot, @@ -411,7 +410,7 @@ impl RenderContext { pub fn get_offscreen_target( &self, id: ResourceId, - ) -> &target::OffscreenTarget { + ) -> &targets::offscreen::OffscreenTarget { return &self.offscreen_targets[id]; } @@ -780,17 +779,6 @@ impl RenderContext { )); } - #[cfg(any(debug_assertions, feature = "render-validation-render-targets",))] - { - if target.defaulted_from_surface_size() && target.size() != self.size { - logging::warn!( - "Offscreen target size {:?} does not match surface size {:?}; rebuild the target to match the new surface size", - target.size(), - self.size - ); - } - } - let depth_texture_ref = if want_depth_attachment { target.depth_texture() } else { diff --git a/crates/lambda-rs/src/render/targets/mod.rs b/crates/lambda-rs/src/render/targets/mod.rs new file mode 100644 index 00000000..2f0e448d --- /dev/null +++ b/crates/lambda-rs/src/render/targets/mod.rs @@ -0,0 +1,8 @@ +//! Render targets for surface presentation and offscreen rendering. +//! +//! This module owns the engine-level APIs for: +//! - Surface presentation (`targets::surface`) +//! - Offscreen render-to-texture (`targets::offscreen`) + +pub mod offscreen; +pub mod surface; diff --git a/crates/lambda-rs/src/render/target.rs b/crates/lambda-rs/src/render/targets/offscreen.rs similarity index 76% rename from crates/lambda-rs/src/render/target.rs rename to crates/lambda-rs/src/render/targets/offscreen.rs index 65d40153..910f6e33 100644 --- a/crates/lambda-rs/src/render/target.rs +++ b/crates/lambda-rs/src/render/targets/offscreen.rs @@ -3,14 +3,13 @@ //! Provides `OffscreenTarget` and `OffscreenTargetBuilder` for render‑to‑texture //! workflows without exposing platform texture types at call sites. -use logging; - -use super::{ +use crate::render::{ + gpu::Gpu, surface, texture, + validation, RenderContext, }; -use crate::render::validation; #[derive(Debug)] /// Offscreen render target with color and optional depth attachments. @@ -27,7 +26,6 @@ pub struct OffscreenTarget { depth_format: Option, sample_count: u32, label: Option, - defaulted_from_surface_size: bool, } impl OffscreenTarget { @@ -81,10 +79,6 @@ impl OffscreenTarget { return self.label.as_deref(); } - pub(crate) fn defaulted_from_surface_size(&self) -> bool { - return self.defaulted_from_surface_size; - } - /// Explicitly destroy this render target. /// /// Dropping the value also releases the underlying GPU resources; this @@ -97,7 +91,7 @@ impl OffscreenTarget { pub enum OffscreenTargetError { /// Color attachment was not configured. MissingColorAttachment, - /// Width or height was zero after resolving defaults. + /// Width or height was zero. InvalidSize { width: u32, height: u32 }, /// Sample count is not supported for the chosen format or device limits. UnsupportedSampleCount { requested: u32 }, @@ -131,10 +125,6 @@ impl OffscreenTargetBuilder { } /// Configure the color attachment format and size. - /// - /// When `width` or `height` is zero, the builder falls back to the current - /// `RenderContext` surface size during `build`. A resolved size of zero in - /// either dimension is treated as an error. pub fn with_color( mut self, format: texture::TextureFormat, @@ -154,7 +144,6 @@ impl OffscreenTargetBuilder { } /// Configure multi‑sampling for this target. - /// pub fn with_multi_sample(mut self, samples: u32) -> Self { self.sample_count = samples.max(1); return self; @@ -169,16 +158,14 @@ impl OffscreenTargetBuilder { /// Create the render target color (and optional depth) attachments. pub fn build( self, - render_context: &mut RenderContext, + gpu: &Gpu, ) -> Result { let format = match self.color_format { Some(format) => format, None => return Err(OffscreenTargetError::MissingColorAttachment), }; - let surface_size = render_context.surface_size(); - let defaulted_from_surface_size = self.width == 0 || self.height == 0; - let (width, height) = self.resolve_size(surface_size)?; + let (width, height) = self.resolve_size()?; let sample_count = self.sample_count.max(1); if let Err(_) = validation::validate_sample_count(sample_count) { @@ -188,9 +175,7 @@ impl OffscreenTargetBuilder { } if sample_count > 1 - && !render_context - .gpu() - .supports_sample_count_for_format(format, sample_count) + && !gpu.supports_sample_count_for_format(format, sample_count) { return Err(OffscreenTargetError::UnsupportedSampleCount { requested: sample_count, @@ -199,9 +184,7 @@ impl OffscreenTargetBuilder { if let Some(depth_format) = self.depth_format { if sample_count > 1 - && !render_context - .gpu() - .supports_sample_count_for_depth(depth_format, sample_count) + && !gpu.supports_sample_count_for_depth(depth_format, sample_count) { return Err(OffscreenTargetError::UnsupportedSampleCount { requested: sample_count, @@ -221,7 +204,7 @@ impl OffscreenTargetBuilder { } } - let resolve_texture = match color_builder.build(render_context.gpu()) { + let resolve_texture = match color_builder.build(gpu) { Ok(texture) => texture, Err(message) => { return Err(OffscreenTargetError::DeviceError(message.to_string())); @@ -236,7 +219,7 @@ impl OffscreenTargetBuilder { if let Some(ref label) = self.label { msaa_builder = msaa_builder.with_label(&format!("{}-msaa", label)); } - Some(msaa_builder.build(render_context.gpu())) + Some(msaa_builder.build(gpu)) } else { None }; @@ -251,7 +234,7 @@ impl OffscreenTargetBuilder { depth_builder = depth_builder.with_label(&format!("{}-depth", label)); } - Some(depth_builder.build(render_context.gpu())) + Some(depth_builder.build(gpu)) } else { None }; @@ -265,26 +248,14 @@ impl OffscreenTargetBuilder { depth_format: self.depth_format, sample_count, label: self.label, - defaulted_from_surface_size, }); } - /// Resolve the final size using an optional explicit size and surface default. - /// - /// When no explicit size was provided, the builder falls back to - /// `surface_size`. A resolved size with zero width or height is treated as - /// an error. pub(crate) fn resolve_size( &self, - surface_size: (u32, u32), ) -> Result<(u32, u32), OffscreenTargetError> { - let mut width = self.width; - let mut height = self.height; - if width == 0 || height == 0 { - width = surface_size.0; - height = surface_size.1; - } - + let width = self.width; + let height = self.height; if width == 0 || height == 0 { return Err(OffscreenTargetError::InvalidSize { width, height }); } @@ -294,37 +265,25 @@ impl OffscreenTargetBuilder { } #[deprecated( - note = "Use `lambda::render::target::OffscreenTarget` to avoid confusion with `lambda::render::render_target::RenderTarget`." + note = "Use `lambda::render::targets::offscreen::OffscreenTarget` to avoid confusion with `lambda::render::targets::surface::RenderTarget`." )] pub type RenderTarget = OffscreenTarget; #[deprecated( - note = "Use `lambda::render::target::OffscreenTargetBuilder` to avoid confusion with `lambda::render::render_target::RenderTarget`." + note = "Use `lambda::render::targets::offscreen::OffscreenTargetBuilder` to avoid confusion with `lambda::render::targets::surface::RenderTarget`." )] pub type RenderTargetBuilder = OffscreenTargetBuilder; -#[deprecated(note = "Use `lambda::render::target::OffscreenTargetError`.")] +#[deprecated( + note = "Use `lambda::render::targets::offscreen::OffscreenTargetError`." +)] pub type RenderTargetError = OffscreenTargetError; #[cfg(test)] mod tests { use super::*; - /// Defaults size to the surface when no explicit dimensions are provided. - #[test] - fn resolve_size_defaults_to_surface_size() { - let builder = OffscreenTargetBuilder::new().with_color( - texture::TextureFormat::Rgba8Unorm, - 0, - 0, - ); - let surface_size = (800, 600); - - let resolved = builder.resolve_size(surface_size).unwrap(); - assert_eq!(resolved, surface_size); - } - - /// Fails when the resolved size has a zero dimension. + /// Fails when the builder has a zero dimension. #[test] fn resolve_size_rejects_zero_dimensions() { let builder = OffscreenTargetBuilder::new().with_color( @@ -332,9 +291,8 @@ mod tests { 0, 0, ); - let surface_size = (0, 0); - let resolved = builder.resolve_size(surface_size); + let resolved = builder.resolve_size(); assert_eq!( resolved, Err(OffscreenTargetError::InvalidSize { diff --git a/crates/lambda-rs/src/render/render_target.rs b/crates/lambda-rs/src/render/targets/surface.rs similarity index 74% rename from crates/lambda-rs/src/render/render_target.rs rename to crates/lambda-rs/src/render/targets/surface.rs index d5f2a55f..9fe7a132 100644 --- a/crates/lambda-rs/src/render/render_target.rs +++ b/crates/lambda-rs/src/render/targets/surface.rs @@ -1,24 +1,11 @@ -//! Render target abstraction for different presentation backends. +//! Presentation surface render targets. //! -//! The `RenderTarget` trait defines the interface for acquiring frames and -//! presenting rendered content. Implementations include: -//! -//! - `WindowSurface`: Renders to a window's swapchain (the common case) -//! - Future: `OffscreenTarget` for headless rendering to textures -//! -//! # Usage -//! -//! Render targets are used by the render context to acquire frames: -//! -//! ```ignore -//! let frame = render_target.acquire_frame()?; -//! // ... encode and submit commands ... -//! frame.present(); -//! ``` +//! A surface render target acquires a frame from a swapchain-like surface and +//! presents it after encoding completes. This is the on-screen rendering path. use lambda_platform::wgpu as platform; -use super::{ +use crate::render::{ gpu::Gpu, instance::Instance, surface::{ @@ -38,23 +25,12 @@ use super::{ // RenderTarget trait // --------------------------------------------------------------------------- -/// Trait for render targets that can acquire and present frames. -/// -/// This abstraction enables different rendering backends: -/// - Window surfaces for on-screen rendering -/// - Offscreen textures for headless/screenshot rendering -/// - Custom targets for specialized use cases +/// Presentation render target that can acquire and present frames. pub trait RenderTarget { /// Acquire the next frame for rendering. - /// - /// Returns a `Frame` that can be rendered to and then presented. The frame - /// owns the texture view for the duration of rendering. fn acquire_frame(&mut self) -> Result; /// Resize the render target to the specified dimensions. - /// - /// This reconfigures the underlying resources (swapchain, textures) to - /// match the new size. Pass the `Gpu` for resource recreation. fn resize(&mut self, gpu: &Gpu, size: (u32, u32)) -> Result<(), String>; /// Get the texture format used by this render target. @@ -74,7 +50,7 @@ pub trait RenderTarget { /// Render target for window-based presentation. /// /// Wraps a platform surface bound to a window, providing frame acquisition -/// and presentation through the GPU's swapchain. +/// and presentation through the GPU's surface configuration. pub struct WindowSurface { inner: platform::surface::Surface<'static>, config: Option, @@ -84,7 +60,7 @@ pub struct WindowSurface { impl WindowSurface { /// Create a new window surface bound to the given window. /// - /// The surface must be configured before use by calling + /// The surface MUST be configured before use by calling /// `configure_with_defaults` or `resize`. pub fn new( instance: &Instance, @@ -107,10 +83,6 @@ impl WindowSurface { } /// Configure the surface with sensible defaults for the given GPU. - /// - /// This selects an sRGB format if available, uses the specified present - /// mode (falling back to Fifo if unsupported), and enables render - /// attachment usage. pub fn configure_with_defaults( &mut self, gpu: &Gpu, @@ -128,7 +100,6 @@ impl WindowSurface { ) .map_err(|e| e)?; - // Cache the configuration if let Some(platform_config) = self.inner.configuration() { self.config = Some(SurfaceConfig::from_platform(platform_config)); } @@ -164,7 +135,6 @@ impl RenderTarget for WindowSurface { fn resize(&mut self, gpu: &Gpu, size: (u32, u32)) -> Result<(), String> { self.inner.resize(gpu.platform(), size)?; - // Update cached configuration if let Some(platform_config) = self.inner.configuration() { self.config = Some(SurfaceConfig::from_platform(platform_config)); } @@ -214,9 +184,7 @@ pub enum WindowSurfaceError { impl std::fmt::Display for WindowSurfaceError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { return match self { - WindowSurfaceError::CreationFailed(msg) => write!(f, "{}", msg), + WindowSurfaceError::CreationFailed(message) => write!(f, "{}", message), }; } } - -impl std::error::Error for WindowSurfaceError {} diff --git a/docs/features.md b/docs/features.md index f92e1a22..c0c4f3a4 100644 --- a/docs/features.md +++ b/docs/features.md @@ -3,13 +3,13 @@ title: "Cargo Features Overview" document_id: "features-2025-11-17" status: "living" created: "2025-11-17T23:59:00Z" -last_updated: "2025-11-25T12:00:00Z" -version: "0.1.4" +last_updated: "2025-12-22T00:00:00Z" +version: "0.1.5" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "1cca6ebdf7cb0b786b3c46561b60fa2e44eecea4" +repo_commit: "58e7dd9f9b98b05302b8b4cfe4d653e61796c153" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["guide", "features", "validation", "cargo"] @@ -86,6 +86,9 @@ Usage examples - `cargo test -p lambda-rs --features render-validation-msaa` ## Changelog +- 0.1.5 (2025-12-22): Align `lambda-rs` Cargo feature umbrella composition with + the documented render-validation feature set, including `render-validation-pass-compat` + and `render-validation-render-targets`. - 0.1.4 (2025-11-25): Document `render-validation-render-targets`, record its inclusion in the `render-validation` umbrella feature, and update metadata. - 0.1.3 (2025-11-25): Rename the instancing validation feature to `render-validation-instancing`, clarify umbrella composition, and update metadata. - 0.1.2 (2025-11-25): Clarify umbrella versus granular validation features, record that `render-validation-all` includes `render-instancing-validation`, and update metadata. diff --git a/docs/specs/offscreen-render-targets-and-multipass.md b/docs/specs/offscreen-render-targets-and-multipass.md index 6c7e165b..b5e06cb2 100644 --- a/docs/specs/offscreen-render-targets-and-multipass.md +++ b/docs/specs/offscreen-render-targets-and-multipass.md @@ -3,13 +3,13 @@ title: "Offscreen Render Targets and Multipass Rendering" document_id: "offscreen-render-targets-2025-11-25" status: "draft" created: "2025-11-25T00:00:00Z" -last_updated: "2025-12-17T23:00:02Z" -version: "0.2.1" +last_updated: "2025-12-25T00:00:00Z" +version: "0.2.4" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "f1743e5528bc4c8326a46e20123ffac62f717ec9" +repo_commit: "e8bd8e9022567a553714bb488d230682020dcfa4" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "rendering", "offscreen", "multipass"] @@ -68,7 +68,8 @@ Summary - Multiple render targets (MRT): rendering to more than one color attachment within a single pass. - Presentation render target: window-backed render target that acquires and - presents swapchain frames (see `render_target::WindowSurface`). + presents swapchain frames (see + `lambda::render::targets::surface::WindowSurface`). - Offscreen target: persistent resource that owns textures for render-to- texture workflows and exposes a sampleable color texture. - Render destination: destination selected when beginning a render pass: @@ -83,18 +84,24 @@ Summary ## Architecture Overview -`lambda-rs` currently has two distinct concepts that collide in naming: -- `lambda::render::render_target::RenderTarget`: trait for acquiring and - presenting frames. -- `lambda::render::target::RenderTarget`: offscreen render-to-texture resource. +`lambda-rs` exposes two render target concepts: +- `lambda::render::targets::surface::RenderTarget`: trait for acquiring and + presenting frames from a window-backed surface. +- `lambda::render::targets::offscreen::OffscreenTarget`: persistent render-to- + texture resource that owns textures and exposes a sampleable resolve color. + +Compatibility shims: +- `lambda::render::render_target` re-exports `lambda::render::targets::surface`. +- `lambda::render::target` re-exports `lambda::render::targets::offscreen`. Terminology in this document: -- "Render target" refers to `lambda::render::render_target::RenderTarget`. -- The offscreen resource is specified as `OffscreenTarget`. +- "Render target" refers to `lambda::render::targets::surface::RenderTarget`. +- The offscreen resource is `OffscreenTarget`. -Implementation note: -- `lambda::render::target::RenderTarget` SHOULD be renamed to avoid API - ambiguity. +Implementation notes: +- `RenderTarget`, `RenderTargetBuilder`, and `RenderTargetError` in the offscreen + module are deprecated aliases for `OffscreenTarget`, `OffscreenTargetBuilder`, + and `OffscreenTargetError` and MUST NOT be used in new code. Data flow (setup → per-frame multipass): ``` @@ -124,7 +131,7 @@ Per-frame commands: #### High-level layer (`lambda-rs`) -- Module `lambda::render::target` (offscreen resource) +- Module `lambda::render::targets::offscreen` (offscreen resource) - `pub struct OffscreenTarget` - Represents a 2D offscreen destination with a single color output and optional depth attachment. @@ -136,21 +143,19 @@ Per-frame commands: - `pub fn with_depth(self, format: texture::DepthFormat) -> Self` - `pub fn with_multi_sample(self, samples: u32) -> Self` - `pub fn with_label(self, label: &str) -> Self` - - `pub fn build(self, render_context: &mut RenderContext) -> Result` + - `pub fn build(self, gpu: &Gpu) -> Result` - Defaults: - - When width or height is zero, the builder uses - `RenderContext::surface_size()` as the size. - - When size is defaulted from the surface, the target MUST NOT - auto-resize; the application rebuilds it on resize. + - Offscreen targets MUST NOT auto-resize; applications rebuild targets + when their desired size changes. - `pub enum OffscreenTargetError` - `MissingColorAttachment` - `InvalidSize { width: u32, height: u32 }` - `UnsupportedSampleCount { requested: u32 }` - `UnsupportedFormat { message: String }` - `DeviceError(String)` - - Note: The current implementation uses the name `RenderTarget` in - `lambda::render::target`. The public API SHOULD be renamed to - `OffscreenTarget` to avoid confusion with `render_target::RenderTarget`. + - Note: Deprecated aliases (`RenderTarget`, `RenderTargetBuilder`, + `RenderTargetError`) exist for short-term source compatibility and are + re-exported from `lambda::render::target`. - Module `lambda::render::command` - Add explicit destination selection for pass begins: @@ -201,12 +206,10 @@ Per-frame commands: - Creation - `OffscreenTargetBuilder::build` MUST fail when: - `with_color` was never called. - - Resolved width or height is zero. + - Width or height is zero. - The requested sample count is unsupported for the chosen color format. - The requested sample count is unsupported for the chosen depth format when depth is enabled. - - When no explicit size is set, the builder MUST use the current - `RenderContext::surface_size()` as the default size. - MSAA resolve model - When `sample_count == 1`, the destination owns a single-sample color texture that is both rendered into and sampled by later passes. @@ -290,9 +293,6 @@ Crate: `lambda-rs` count). - Checks MUST occur at pass begin and at `SetPipeline` time, not per draw. - Logs SHOULD include: - - Destination size mismatches versus `RenderContext::surface_size()` when - the offscreen target is surface-sized by default and the surface - resizes. - Missing depth attachment when depth or stencil ops are requested. - Color format mismatches between destination and pipeline. - Expected runtime cost is low to moderate. @@ -314,7 +314,7 @@ Gating requirements ## Constraints and Rules - Offscreen target constraints - - Width and height MUST be strictly positive after resolving defaults. + - Width and height MUST be strictly positive. - A destination produces exactly one color output. - Color formats MUST be limited to formats supported by `texture::TextureFormat`. - Depth formats MUST be limited to `texture::DepthFormat`. @@ -355,25 +355,26 @@ Gating requirements ## Requirements Checklist - Functionality - - [x] Offscreen target resource exists in `crates/lambda-rs/src/render/target.rs`. - - [ ] Rename public API to `OffscreenTarget` to avoid collision with - `render_target::RenderTarget`. - - [ ] Add `RenderDestination` and `RenderCommand::BeginRenderPassTo`. - - [ ] Add `RenderContext::{attach,get}_offscreen_target`. - - [ ] Support offscreen destinations in `RenderContext::render`. - - [ ] Implement offscreen MSAA resolve textures (render to MSAA, resolve to + - [x] Offscreen target resource exists in + `crates/lambda-rs/src/render/targets/offscreen.rs`. + - [x] Rename public API to `OffscreenTarget` to avoid collision with + `lambda::render::targets::surface::RenderTarget`. + - [x] Add `RenderDestination` and `RenderCommand::BeginRenderPassTo`. + - [x] Add `RenderContext::{attach,get}_offscreen_target`. + - [x] Support offscreen destinations in `RenderContext::render`. + - [x] Implement offscreen MSAA resolve textures (render to MSAA, resolve to single-sample, sample resolve). - - [ ] Ensure offscreen depth sample count matches destination sample count. + - [x] Ensure offscreen depth sample count matches destination sample count. - API Surface - [x] Platform pipeline supports explicit color targets. - [x] Engine `TextureBuilder::for_render_target` sets attachment-capable usage. - Validation and Errors - - [ ] `render-validation-render-targets` feature implemented and composed + - [x] `render-validation-render-targets` feature implemented and composed into umbrella validation features. - - [ ] Pass/pipeline/destination compatibility checks implemented. - - [ ] `docs/features.md` updated to list the feature, default state, and cost. + - [x] Pass/pipeline/destination compatibility checks implemented. + - [x] `docs/features.md` updated to list the feature, default state, and cost. - Documentation and Examples - - [ ] Minimal render-to-texture example added under `crates/lambda-rs/examples/`. + - [x] Minimal render-to-texture example added under `crates/lambda-rs/examples/`. - [ ] Rendering guide updated to include an offscreen multipass walkthrough. - [ ] Migration notes added for consumers adopting destination-based passes. @@ -411,16 +412,23 @@ Gating requirements `RenderDestination::Offscreen(target_id)`. - Sample `offscreen.color_texture()` in a later surface pass. - Naming migration - - If `RenderTarget` (offscreen resource) is renamed to `OffscreenTarget`, the - rename SHOULD be introduced with a deprecated type alias to preserve source - compatibility for consumers. + - `RenderTarget` (offscreen resource) is a deprecated alias for + `OffscreenTarget` and SHOULD remain available until a major version bump. ## Changelog +- 2025-12-25 (v0.2.4) — Decouple `OffscreenTargetBuilder::build` from + `RenderContext` by requiring an explicit size and a `Gpu`. +- 2025-12-22 (v0.2.3) — Document `lambda::render::targets::{surface,offscreen}` + as the canonical module structure and note compatibility shims. +- 2025-12-22 (v0.2.2) — Update checklist and implementation notes to reflect + destination-based offscreen passes, MSAA resolve targets, and validation + feature wiring. - 2025-12-17 (v0.2.1) — Polish language for style consistency, clarify MSAA terminology and builder safeguards, and specify validation gating requirements. -- 2025-12-17 (v0.2.0) — Align terminology with `render_target::RenderTarget`, +- 2025-12-17 (v0.2.0) — Align terminology with + `lambda::render::targets::surface::RenderTarget`, specify destination-based pass targeting, define the offscreen MSAA resolve model, and define feature-gated validation requirements. - 2025-11-25 (v0.1.1) — Updated requirements checklist to reflect implemented From 84c672a1c7375ca84abeb12d32c3a9647835f3bc Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 29 Dec 2025 13:34:34 -0800 Subject: [PATCH 14/20] [update] TextureViewRef to live with the render target. --- .../lambda-rs/src/render/color_attachments.rs | 2 +- crates/lambda-rs/src/render/mod.rs | 18 +- .../lambda-rs/src/render/targets/offscreen.rs | 6 +- .../lambda-rs/src/render/targets/surface.rs | 206 +++++++++++++++++- crates/lambda-rs/src/render/texture.rs | 18 +- 5 files changed, 225 insertions(+), 25 deletions(-) diff --git a/crates/lambda-rs/src/render/color_attachments.rs b/crates/lambda-rs/src/render/color_attachments.rs index ce1e19b2..e0344e5f 100644 --- a/crates/lambda-rs/src/render/color_attachments.rs +++ b/crates/lambda-rs/src/render/color_attachments.rs @@ -6,7 +6,7 @@ use lambda_platform::wgpu as platform; -use super::surface::TextureView; +use super::targets::surface::TextureView; #[derive(Debug, Default)] /// High‑level color attachments collection used when beginning a render pass. diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index be4bb571..027a3a01 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -40,7 +40,6 @@ pub mod pipeline; pub mod render_pass; pub mod scene_math; pub mod shader; -pub mod surface; pub mod targets; pub mod texture; pub mod validation; @@ -149,7 +148,7 @@ impl RenderContextBuilder { .configure_with_defaults( &gpu, size, - surface::PresentMode::default(), + targets::surface::PresentMode::default(), texture::TextureUsages::RENDER_ATTACHMENT, ) .map_err(|e| { @@ -226,7 +225,7 @@ pub struct RenderContext { instance: instance::Instance, surface: targets::surface::WindowSurface, gpu: gpu::Gpu, - config: surface::SurfaceConfig, + config: targets::surface::SurfaceConfig, texture_usage: texture::TextureUsages, size: (u32, u32), depth_texture: Option, @@ -484,7 +483,7 @@ impl RenderContext { fn ensure_msaa_color_texture( &mut self, sample_count: u32, - ) -> surface::TextureView<'_> { + ) -> targets::surface::TextureView<'_> { let need_recreate = match &self.msaa_color { Some(_) => self.msaa_sample_count != sample_count, None => true, @@ -520,7 +519,8 @@ impl RenderContext { let frame = match self.surface.acquire_frame() { Ok(frame) => frame, Err(err) => match err { - surface::SurfaceError::Lost | surface::SurfaceError::Outdated => { + targets::surface::SurfaceError::Lost + | targets::surface::SurfaceError::Outdated => { self.reconfigure_surface(self.size)?; self .surface @@ -594,7 +594,7 @@ impl RenderContext { command_iter: &mut std::vec::IntoIter, render_pass: ResourceId, viewport: viewport::Viewport, - surface_view: surface::TextureView<'view>, + surface_view: targets::surface::TextureView<'view>, ) -> Result<(), RenderError> { // Clone the render pass descriptor to avoid borrowing self while we need // mutable access for MSAA texture creation. @@ -978,12 +978,12 @@ impl RenderContext { /// acquisition or command encoding. The renderer logs these and continues when /// possible; callers SHOULD treat them as warnings unless persistent. pub enum RenderError { - Surface(surface::SurfaceError), + Surface(targets::surface::SurfaceError), Configuration(String), } -impl From for RenderError { - fn from(error: surface::SurfaceError) -> Self { +impl From for RenderError { + fn from(error: targets::surface::SurfaceError) -> Self { return RenderError::Surface(error); } } diff --git a/crates/lambda-rs/src/render/targets/offscreen.rs b/crates/lambda-rs/src/render/targets/offscreen.rs index 910f6e33..57872f0d 100644 --- a/crates/lambda-rs/src/render/targets/offscreen.rs +++ b/crates/lambda-rs/src/render/targets/offscreen.rs @@ -3,9 +3,9 @@ //! Provides `OffscreenTarget` and `OffscreenTargetBuilder` for render‑to‑texture //! workflows without exposing platform texture types at call sites. +use super::surface::TextureView; use crate::render::{ gpu::Gpu, - surface, texture, validation, RenderContext, @@ -66,11 +66,11 @@ impl OffscreenTarget { return self.msaa_color.as_ref(); } - pub(crate) fn resolve_view(&self) -> surface::TextureView<'_> { + pub(crate) fn resolve_view(&self) -> TextureView<'_> { return self.resolve_color.view_ref(); } - pub(crate) fn msaa_view(&self) -> Option> { + pub(crate) fn msaa_view(&self) -> Option> { return self.msaa_color.as_ref().map(|t| t.view_ref()); } diff --git a/crates/lambda-rs/src/render/targets/surface.rs b/crates/lambda-rs/src/render/targets/surface.rs index 9fe7a132..99eb084b 100644 --- a/crates/lambda-rs/src/render/targets/surface.rs +++ b/crates/lambda-rs/src/render/targets/surface.rs @@ -8,12 +8,6 @@ use lambda_platform::wgpu as platform; use crate::render::{ gpu::Gpu, instance::Instance, - surface::{ - Frame, - PresentMode, - SurfaceConfig, - SurfaceError, - }, texture::{ TextureFormat, TextureUsages, @@ -21,6 +15,206 @@ use crate::render::{ window::Window, }; +// --------------------------------------------------------------------------- +// TextureView +// --------------------------------------------------------------------------- + +/// High-level reference to a texture view for render pass attachments. +/// +/// This type wraps the platform `TextureViewRef` and provides a stable +/// engine-level API for referencing texture views without exposing `wgpu` +/// types at call sites. +#[derive(Clone, Copy)] +pub struct TextureView<'a> { + inner: platform::surface::TextureViewRef<'a>, +} + +impl<'a> TextureView<'a> { + /// Create a high-level texture view from a platform texture view reference. + #[inline] + pub(crate) fn from_platform( + view: platform::surface::TextureViewRef<'a>, + ) -> Self { + return TextureView { inner: view }; + } + + /// Convert to the platform texture view reference for internal use. + #[inline] + pub(crate) fn to_platform(&self) -> platform::surface::TextureViewRef<'a> { + return self.inner; + } +} + +impl<'a> std::fmt::Debug for TextureView<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return f.debug_struct("TextureView").finish_non_exhaustive(); + } +} + +// --------------------------------------------------------------------------- +// Frame +// --------------------------------------------------------------------------- + +/// A single acquired frame from the presentation surface. +/// +/// This type wraps the platform `Frame` and provides access to its texture +/// view for rendering. The frame must be presented after rendering is complete +/// by calling `present()`. +pub struct Frame { + inner: platform::surface::Frame, +} + +impl Frame { + /// Create a high-level frame from a platform frame. + #[inline] + pub(crate) fn from_platform(frame: platform::surface::Frame) -> Self { + return Frame { inner: frame }; + } + + /// Borrow the default texture view for rendering to this frame. + #[inline] + pub fn texture_view(&self) -> TextureView<'_> { + return TextureView::from_platform(self.inner.texture_view()); + } + + /// Present the frame to the swapchain. + /// + /// This consumes the frame and submits it for display. After calling this + /// method, the frame's texture is no longer valid for rendering. + #[inline] + pub fn present(self) { + self.inner.present(); + } +} + +impl std::fmt::Debug for Frame { + fn fmt(&self, frame: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return frame.debug_struct("Frame").finish_non_exhaustive(); + } +} + +// --------------------------------------------------------------------------- +// PresentMode +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PresentMode { + /// Vsync enabled; frames wait for vertical blanking interval. + Fifo, + /// Vsync with relaxed timing; may tear if frames miss the interval. + FifoRelaxed, + /// No Vsync; immediate presentation (may tear). + Immediate, + /// Triple-buffered presentation when supported. + Mailbox, + /// Automatic Vsync selection by the platform. + AutoVsync, + /// Automatic non-Vsync selection by the platform. + AutoNoVsync, +} + +impl PresentMode { + #[inline] + pub(crate) fn to_platform(&self) -> platform::surface::PresentMode { + return match self { + PresentMode::Fifo => platform::surface::PresentMode::Fifo, + PresentMode::FifoRelaxed => platform::surface::PresentMode::FifoRelaxed, + PresentMode::Immediate => platform::surface::PresentMode::Immediate, + PresentMode::Mailbox => platform::surface::PresentMode::Mailbox, + PresentMode::AutoVsync => platform::surface::PresentMode::AutoVsync, + PresentMode::AutoNoVsync => platform::surface::PresentMode::AutoNoVsync, + }; + } + + #[inline] + pub(crate) fn from_platform( + mode: platform::surface::PresentMode, + ) -> PresentMode { + return match mode { + platform::surface::PresentMode::Fifo => PresentMode::Fifo, + platform::surface::PresentMode::FifoRelaxed => PresentMode::FifoRelaxed, + platform::surface::PresentMode::Immediate => PresentMode::Immediate, + platform::surface::PresentMode::Mailbox => PresentMode::Mailbox, + platform::surface::PresentMode::AutoVsync => PresentMode::AutoVsync, + platform::surface::PresentMode::AutoNoVsync => PresentMode::AutoNoVsync, + }; + } +} + +impl Default for PresentMode { + fn default() -> Self { + return PresentMode::Fifo; + } +} + +// --------------------------------------------------------------------------- +// SurfaceConfig +// --------------------------------------------------------------------------- + +/// High-level surface configuration. +/// +/// Contains the current surface dimensions, format, present mode, and usage +/// flags without exposing platform types. +#[derive(Clone, Debug)] +pub struct SurfaceConfig { + /// Width in pixels. + pub width: u32, + /// Height in pixels. + pub height: u32, + /// The texture format used by the surface. + pub format: TextureFormat, + /// The presentation mode (vsync behavior). + pub present_mode: PresentMode, + /// Texture usage flags for the surface. + pub usage: TextureUsages, +} + +impl SurfaceConfig { + pub(crate) fn from_platform( + config: &platform::surface::SurfaceConfig, + ) -> Self { + return SurfaceConfig { + width: config.width, + height: config.height, + format: TextureFormat::from_platform(config.format) + .unwrap_or(TextureFormat::Bgra8UnormSrgb), + present_mode: PresentMode::from_platform(config.present_mode), + usage: TextureUsages::from_platform(config.usage), + }; + } +} + +// --------------------------------------------------------------------------- +// SurfaceError +// --------------------------------------------------------------------------- + +/// Error wrapper for surface acquisition and presentation errors. +#[derive(Clone, Debug)] +pub enum SurfaceError { + /// The surface has been lost and must be recreated. + Lost, + /// The surface configuration is outdated and must be reconfigured. + Outdated, + /// Out of memory. + OutOfMemory, + /// Timed out waiting for a frame. + Timeout, + /// Other/unclassified error. + Other(String), +} + +impl From for SurfaceError { + fn from(error: platform::surface::SurfaceError) -> Self { + return match error { + platform::surface::SurfaceError::Lost => SurfaceError::Lost, + platform::surface::SurfaceError::Outdated => SurfaceError::Outdated, + platform::surface::SurfaceError::OutOfMemory => SurfaceError::OutOfMemory, + platform::surface::SurfaceError::Timeout => SurfaceError::Timeout, + platform::surface::SurfaceError::Other(msg) => SurfaceError::Other(msg), + }; + } +} + // --------------------------------------------------------------------------- // RenderTarget trait // --------------------------------------------------------------------------- diff --git a/crates/lambda-rs/src/render/texture.rs b/crates/lambda-rs/src/render/texture.rs index bda18e21..c085d241 100644 --- a/crates/lambda-rs/src/render/texture.rs +++ b/crates/lambda-rs/src/render/texture.rs @@ -214,8 +214,10 @@ impl ColorAttachmentTexture { } /// Borrow a texture view reference for use in render pass attachments. - pub(crate) fn view_ref(&self) -> crate::render::surface::TextureView<'_> { - return crate::render::surface::TextureView::from_platform( + pub(crate) fn view_ref( + &self, + ) -> crate::render::targets::surface::TextureView<'_> { + return crate::render::targets::surface::TextureView::from_platform( self.inner.view_ref(), ); } @@ -318,8 +320,10 @@ impl DepthTexture { } /// Borrow a texture view reference for use in render pass attachments. - pub(crate) fn view_ref(&self) -> crate::render::surface::TextureView<'_> { - return crate::render::surface::TextureView::from_platform( + pub(crate) fn view_ref( + &self, + ) -> crate::render::targets::surface::TextureView<'_> { + return crate::render::targets::surface::TextureView::from_platform( self.inner.view_ref(), ); } @@ -351,8 +355,10 @@ impl Texture { } /// Borrow a texture view reference for use in render pass attachments. - pub(crate) fn view_ref(&self) -> crate::render::surface::TextureView<'_> { - return crate::render::surface::TextureView::from_platform( + pub(crate) fn view_ref( + &self, + ) -> crate::render::targets::surface::TextureView<'_> { + return crate::render::targets::surface::TextureView::from_platform( self.inner.view_ref(), ); } From bc191ae9d47e9339390b9c21e47933a36d737987 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 29 Dec 2025 14:00:34 -0800 Subject: [PATCH 15/20] [update] documentation. --- crates/lambda-rs/src/render/mod.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/lambda-rs/src/render/mod.rs b/crates/lambda-rs/src/render/mod.rs index 027a3a01..0d0bc0e8 100644 --- a/crates/lambda-rs/src/render/mod.rs +++ b/crates/lambda-rs/src/render/mod.rs @@ -1,10 +1,7 @@ //! High‑level rendering API for cross‑platform windowed applications. //! //! The rendering module provides a small set of stable, engine‑facing types -//! that assemble a frame using explicit commands. It hides lower‑level -//! platform details (the `wgpu` device, queue, surfaces, and raw descriptors) -//! behind builders and handles while keeping configuration visible and -//! predictable. +//! that assemble a frame using explicit commands. //! //! Concepts //! - `RenderContext`: owns the graphics instance, presentation surface, and From bc2ca687922db601998e7e5a0c0b2e870c857be1 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 29 Dec 2025 14:24:26 -0800 Subject: [PATCH 16/20] [fix] tests. --- .../lambda-rs/src/render/targets/offscreen.rs | 207 ++++++++++++++++-- 1 file changed, 191 insertions(+), 16 deletions(-) diff --git a/crates/lambda-rs/src/render/targets/offscreen.rs b/crates/lambda-rs/src/render/targets/offscreen.rs index 57872f0d..501f03b8 100644 --- a/crates/lambda-rs/src/render/targets/offscreen.rs +++ b/crates/lambda-rs/src/render/targets/offscreen.rs @@ -101,6 +101,36 @@ pub enum OffscreenTargetError { DeviceError(String), } +impl std::fmt::Display for OffscreenTargetError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return match self { + OffscreenTargetError::MissingColorAttachment => { + write!(f, "Missing color attachment configuration") + } + OffscreenTargetError::InvalidSize { width, height } => write!( + f, + "Invalid offscreen target size {}x{} (width and height must be > 0)", + width, height + ), + OffscreenTargetError::UnsupportedSampleCount { requested } => { + write!( + f, + "Unsupported sample count {} (allowed: 1, 2, 4, 8)", + requested + ) + } + OffscreenTargetError::UnsupportedFormat { message } => { + write!(f, "Unsupported format: {}", message) + } + OffscreenTargetError::DeviceError(message) => { + write!(f, "Device error: {}", message) + } + }; + } +} + +impl std::error::Error for OffscreenTargetError {} + /// Builder for creating an `OffscreenTarget`. pub struct OffscreenTargetBuilder { label: Option, @@ -207,7 +237,20 @@ impl OffscreenTargetBuilder { let resolve_texture = match color_builder.build(gpu) { Ok(texture) => texture, Err(message) => { - return Err(OffscreenTargetError::DeviceError(message.to_string())); + const UNSUPPORTED_FORMAT_MESSAGE: &str = + "Texture format does not support bytes_per_pixel calculation"; + let error_message = message.to_string(); + if message == UNSUPPORTED_FORMAT_MESSAGE { + return Err(OffscreenTargetError::UnsupportedFormat { + message: error_message, + }); + } + + let label = self.label.as_deref().unwrap_or("unnamed offscreen target"); + return Err(OffscreenTargetError::DeviceError(format!( + "Failed to build resolve color texture for '{}': {}", + label, error_message + ))); } }; @@ -264,24 +307,15 @@ impl OffscreenTargetBuilder { } } -#[deprecated( - note = "Use `lambda::render::targets::offscreen::OffscreenTarget` to avoid confusion with `lambda::render::targets::surface::RenderTarget`." -)] -pub type RenderTarget = OffscreenTarget; - -#[deprecated( - note = "Use `lambda::render::targets::offscreen::OffscreenTargetBuilder` to avoid confusion with `lambda::render::targets::surface::RenderTarget`." -)] -pub type RenderTargetBuilder = OffscreenTargetBuilder; - -#[deprecated( - note = "Use `lambda::render::targets::offscreen::OffscreenTargetError`." -)] -pub type RenderTargetError = OffscreenTargetError; - #[cfg(test)] mod tests { + use lambda_platform::wgpu as platform; + use super::*; + use crate::render::{ + gpu::GpuBuilder, + instance::InstanceBuilder, + }; /// Fails when the builder has a zero dimension. #[test] @@ -308,4 +342,145 @@ mod tests { let builder = OffscreenTargetBuilder::new().with_multi_sample(0); assert_eq!(builder.sample_count, 1); } + + fn create_test_gpu() -> Option { + let instance = InstanceBuilder::new() + .with_label("lambda-offscreen-target-test-instance") + .build(); + return GpuBuilder::new() + .with_label("lambda-offscreen-target-test-gpu") + .build(&instance, None) + .ok(); + } + + #[test] + fn build_rejects_missing_color_attachment() { + let gpu = match create_test_gpu() { + Some(gpu) => gpu, + None => return, + }; + + let built = OffscreenTargetBuilder::new().build(&gpu); + assert_eq!( + built.unwrap_err(), + OffscreenTargetError::MissingColorAttachment + ); + } + + #[test] + fn build_rejects_unsupported_sample_count() { + let gpu = match create_test_gpu() { + Some(gpu) => gpu, + None => return, + }; + + let built = OffscreenTargetBuilder::new() + .with_color(texture::TextureFormat::Rgba8Unorm, 1, 1) + .with_multi_sample(3) + .build(&gpu); + + assert_eq!( + built.unwrap_err(), + OffscreenTargetError::UnsupportedSampleCount { requested: 3 } + ); + } + + #[test] + fn resolve_texture_supports_sampling_and_render_attachment() { + let gpu = match create_test_gpu() { + Some(gpu) => gpu, + None => return, + }; + + let target = OffscreenTargetBuilder::new() + .with_color(texture::TextureFormat::Rgba8Unorm, 4, 4) + .with_label("offscreen-usage-test") + .build(&gpu) + .expect("build offscreen target"); + + let resolve_platform_texture = target.color_texture().platform_texture(); + + let sampler = platform::texture::SamplerBuilder::new() + .nearest_clamp() + .with_label("offscreen-usage-sampler") + .build(gpu.platform()); + + let layout = platform::bind::BindGroupLayoutBuilder::new() + .with_sampled_texture_2d(1, platform::bind::Visibility::Fragment) + .with_sampler(2, platform::bind::Visibility::Fragment) + .build(gpu.platform()); + + let _group = platform::bind::BindGroupBuilder::new() + .with_layout(&layout) + .with_texture(1, resolve_platform_texture.as_ref()) + .with_sampler(2, &sampler) + .build(gpu.platform()); + + let mut encoder = platform::command::CommandEncoder::new( + gpu.platform(), + Some("offscreen-usage-encoder"), + ); + { + let mut attachments = + platform::render_pass::RenderColorAttachments::new(); + attachments.push_color(target.resolve_view().to_platform()); + let _pass = platform::render_pass::RenderPassBuilder::new() + .with_clear_color([0.0, 0.0, 0.0, 1.0]) + .build(&mut encoder, &mut attachments, None, None, None, None); + } + + let buffer = encoder.finish(); + gpu.platform().submit(std::iter::once(buffer)); + } + + #[test] + fn msaa_target_depth_attachment_matches_sample_count() { + let gpu = match create_test_gpu() { + Some(gpu) => gpu, + None => return, + }; + + let target = OffscreenTargetBuilder::new() + .with_color(texture::TextureFormat::Rgba8Unorm, 4, 4) + .with_depth(texture::DepthFormat::Depth32Float) + .with_multi_sample(4) + .with_label("offscreen-msaa-depth-test") + .build(&gpu) + .expect("build offscreen target"); + + let msaa_view = target.msaa_view().expect("MSAA view"); + let resolve_view = target.resolve_view(); + let depth_view = target + .depth_texture() + .expect("depth texture") + .platform_view_ref(); + + let mut encoder = platform::command::CommandEncoder::new( + gpu.platform(), + Some("offscreen-msaa-depth-encoder"), + ); + { + let mut attachments = + platform::render_pass::RenderColorAttachments::new(); + attachments + .push_msaa_color(msaa_view.to_platform(), resolve_view.to_platform()); + let depth_ops = Some(platform::render_pass::DepthOperations { + load: platform::render_pass::DepthLoadOp::Clear(1.0), + store: platform::render_pass::StoreOp::Store, + }); + let _pass = platform::render_pass::RenderPassBuilder::new() + .with_clear_color([0.0, 0.0, 0.0, 1.0]) + .build( + &mut encoder, + &mut attachments, + Some(depth_view), + depth_ops, + None, + None, + ); + } + + let buffer = encoder.finish(); + gpu.platform().submit(std::iter::once(buffer)); + } } From 86976eceefb424ca0e27955100a5c14276623926 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 30 Dec 2025 16:17:55 -0800 Subject: [PATCH 17/20] [add] offscreen rendering example alongside a tutorial for how to build it. --- crates/lambda-rs/examples/offscreen_post.rs | 503 ++++++++++++++++++ .../offscreen-render-targets-and-multipass.md | 17 +- docs/tutorials/README.md | 8 +- docs/tutorials/offscreen-post.md | 311 +++++++++++ 4 files changed, 827 insertions(+), 12 deletions(-) create mode 100644 crates/lambda-rs/examples/offscreen_post.rs create mode 100644 docs/tutorials/offscreen-post.md diff --git a/crates/lambda-rs/examples/offscreen_post.rs b/crates/lambda-rs/examples/offscreen_post.rs new file mode 100644 index 00000000..bd66935f --- /dev/null +++ b/crates/lambda-rs/examples/offscreen_post.rs @@ -0,0 +1,503 @@ +#![allow(clippy::needless_return)] + +//! Example: Render to an offscreen target, then sample it to the surface. + +use lambda::{ + component::Component, + events::Events, + logging, + render::{ + bind::{ + BindGroupBuilder, + BindGroupLayout, + BindGroupLayoutBuilder, + }, + buffer::BufferBuilder, + command::{ + RenderCommand, + RenderDestination, + }, + mesh::{ + Mesh, + MeshBuilder, + }, + pipeline::{ + CullingMode, + RenderPipelineBuilder, + }, + render_pass::RenderPassBuilder, + shader::{ + Shader, + ShaderBuilder, + ShaderKind, + VirtualShader, + }, + targets::offscreen::OffscreenTargetBuilder, + texture::SamplerBuilder, + vertex::{ + ColorFormat, + Vertex, + VertexAttribute, + VertexBuilder, + VertexElement, + }, + viewport::ViewportBuilder, + RenderContext, + ResourceId, + }, + runtime::start_runtime, + runtimes::{ + application::ComponentResult, + ApplicationRuntimeBuilder, + }, +}; + +// ------------------------------ SHADER SOURCE -------------------------------- + +const POST_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 POST_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 OffscreenPostExample { + triangle_vs: Shader, + triangle_fs: Shader, + post_vs: Shader, + post_fs: Shader, + quad_mesh: Option, + + offscreen_pass: Option, + offscreen_pipeline: Option, + offscreen_target: Option, + + post_pass: Option, + post_pipeline: Option, + post_bind_group: Option, + post_layout: Option, + + width: u32, + height: u32, +} + +impl Component for OffscreenPostExample { + fn on_attach( + &mut self, + render_context: &mut RenderContext, + ) -> Result { + logging::info!("Attaching OffscreenPostExample"); + + let surface_size = render_context.surface_size(); + let offscreen_target = OffscreenTargetBuilder::new() + .with_color( + render_context.surface_format(), + surface_size.0, + surface_size.1, + ) + .with_label("offscreen-post-target") + .build(render_context.gpu()) + .map_err(|e| format!("Failed to build offscreen target: {:?}", e))?; + let offscreen_target_id = + render_context.attach_offscreen_target(offscreen_target); + + let offscreen_pass = + RenderPassBuilder::new().with_label("offscreen-pass").build( + render_context.gpu(), + render_context.surface_format(), + render_context.depth_format(), + ); + + let offscreen_pipeline = RenderPipelineBuilder::new() + .with_label("offscreen-pipeline") + .with_culling(CullingMode::None) + .build( + render_context.gpu(), + render_context.surface_format(), + render_context.depth_format(), + &offscreen_pass, + &self.triangle_vs, + Some(&self.triangle_fs), + ); + + let post_pass = RenderPassBuilder::new().with_label("post-pass").build( + render_context.gpu(), + render_context.surface_format(), + render_context.depth_format(), + ); + + let post_layout = BindGroupLayoutBuilder::new() + .with_sampled_texture(1) + .with_sampler(2) + .build(render_context.gpu()); + + let sampler = SamplerBuilder::new() + .linear_clamp() + .with_label("offscreen-post-sampler") + .build(render_context.gpu()); + + let offscreen_ref = + render_context.get_offscreen_target(offscreen_target_id); + let post_bind_group = BindGroupBuilder::new() + .with_layout(&post_layout) + .with_texture(1, offscreen_ref.color_texture()) + .with_sampler(2, &sampler) + .build(render_context.gpu()); + + let quad_mesh = Self::build_fullscreen_quad_mesh(); + let quad_vertex_buffer = + BufferBuilder::build_from_mesh(&quad_mesh, render_context.gpu()) + .map_err(|e| format!("Failed to build quad vertex buffer: {:?}", e))?; + + let post_pipeline = RenderPipelineBuilder::new() + .with_label("post-pipeline") + .with_culling(CullingMode::None) + .with_layouts(&[&post_layout]) + .with_buffer(quad_vertex_buffer, quad_mesh.attributes().to_vec()) + .build( + render_context.gpu(), + render_context.surface_format(), + render_context.depth_format(), + &post_pass, + &self.post_vs, + Some(&self.post_fs), + ); + + self.offscreen_pass = + Some(render_context.attach_render_pass(offscreen_pass)); + self.offscreen_pipeline = + Some(render_context.attach_pipeline(offscreen_pipeline)); + self.offscreen_target = Some(offscreen_target_id); + + self.post_pass = Some(render_context.attach_render_pass(post_pass)); + self.post_pipeline = Some(render_context.attach_pipeline(post_pipeline)); + self.post_bind_group = + Some(render_context.attach_bind_group(post_bind_group)); + self.post_layout = Some(post_layout); + self.quad_mesh = Some(quad_mesh); + + let (width, height) = render_context.surface_size(); + self.width = width; + self.height = height; + + return Ok(ComponentResult::Success); + } + + fn on_detach( + &mut self, + _render_context: &mut RenderContext, + ) -> Result { + return Ok(ComponentResult::Success); + } + + fn on_event(&mut self, event: Events) -> Result { + if let Events::Window { + event: lambda::events::WindowEvent::Resize { width, height }, + .. + } = event + { + self.width = width; + self.height = height; + } + return Ok(ComponentResult::Success); + } + + fn on_update( + &mut self, + _last_frame: &std::time::Duration, + ) -> Result { + return Ok(ComponentResult::Success); + } + + fn on_render( + &mut self, + render_context: &mut RenderContext, + ) -> Vec { + self.ensure_offscreen_matches_surface(render_context); + + let offscreen_viewport = + ViewportBuilder::new().build(self.width.max(1), self.height.max(1)); + let surface_viewport = + ViewportBuilder::new().build(self.width.max(1), self.height.max(1)); + + return vec![ + RenderCommand::BeginRenderPassTo { + render_pass: self.offscreen_pass.expect("offscreen pass not set"), + viewport: offscreen_viewport.clone(), + destination: RenderDestination::Offscreen( + self.offscreen_target.expect("offscreen target not set"), + ), + }, + RenderCommand::SetPipeline { + pipeline: self.offscreen_pipeline.expect("offscreen pipeline not set"), + }, + RenderCommand::SetViewports { + start_at: 0, + viewports: vec![offscreen_viewport.clone()], + }, + RenderCommand::SetScissors { + start_at: 0, + viewports: vec![offscreen_viewport.clone()], + }, + RenderCommand::Draw { + vertices: 0..3, + instances: 0..1, + }, + RenderCommand::EndRenderPass, + RenderCommand::BeginRenderPass { + render_pass: self.post_pass.expect("post pass not set"), + viewport: surface_viewport.clone(), + }, + RenderCommand::SetPipeline { + pipeline: self.post_pipeline.expect("post pipeline not set"), + }, + RenderCommand::SetBindGroup { + set: 0, + group: self.post_bind_group.expect("post bind group not set"), + dynamic_offsets: vec![], + }, + RenderCommand::BindVertexBuffer { + pipeline: self.post_pipeline.expect("post pipeline not set"), + buffer: 0, + }, + RenderCommand::SetViewports { + start_at: 0, + viewports: vec![surface_viewport.clone()], + }, + RenderCommand::SetScissors { + start_at: 0, + viewports: vec![surface_viewport.clone()], + }, + RenderCommand::Draw { + vertices: 0..6, + instances: 0..1, + }, + RenderCommand::EndRenderPass, + ]; + } +} + +impl OffscreenPostExample { + fn build_fullscreen_quad_mesh() -> Mesh { + let vertices: [Vertex; 6] = [ + VertexBuilder::new() + .with_position([-1.0, -1.0, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([0.0, 0.0, 0.0]) + .build(), + VertexBuilder::new() + .with_position([1.0, -1.0, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([1.0, 0.0, 0.0]) + .build(), + VertexBuilder::new() + .with_position([1.0, 1.0, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([1.0, 1.0, 0.0]) + .build(), + VertexBuilder::new() + .with_position([-1.0, -1.0, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([0.0, 0.0, 0.0]) + .build(), + VertexBuilder::new() + .with_position([1.0, 1.0, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([1.0, 1.0, 0.0]) + .build(), + VertexBuilder::new() + .with_position([-1.0, 1.0, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([0.0, 1.0, 0.0]) + .build(), + ]; + + let mut mesh_builder = MeshBuilder::new(); + for v in vertices { + mesh_builder.with_vertex(v); + } + + return 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(); + } + + fn ensure_offscreen_matches_surface( + &mut self, + render_context: &mut RenderContext, + ) { + let offscreen_id = match self.offscreen_target { + Some(id) => id, + None => return, + }; + let post_layout = match self.post_layout.as_ref() { + Some(layout) => layout, + None => return, + }; + let bind_group_id = match self.post_bind_group { + Some(id) => id, + None => return, + }; + + let surface_size = render_context.surface_size(); + let target_size = render_context.get_offscreen_target(offscreen_id).size(); + if target_size == surface_size { + return; + } + + let new_target = match OffscreenTargetBuilder::new() + .with_color( + render_context.surface_format(), + surface_size.0, + surface_size.1, + ) + .with_label("offscreen-post-target") + .build(render_context.gpu()) + { + Ok(target) => target, + Err(error) => { + logging::error!("Failed to rebuild offscreen target: {:?}", error); + return; + } + }; + + if let Err(error) = + render_context.replace_offscreen_target(offscreen_id, new_target) + { + logging::error!("Failed to replace offscreen target: {}", error); + return; + } + + let offscreen_ref = render_context.get_offscreen_target(offscreen_id); + let sampler = SamplerBuilder::new() + .linear_clamp() + .with_label("offscreen-post-sampler") + .build(render_context.gpu()); + let new_bind_group = BindGroupBuilder::new() + .with_layout(post_layout) + .with_texture(1, offscreen_ref.color_texture()) + .with_sampler(2, &sampler) + .build(render_context.gpu()); + + if let Err(error) = + render_context.replace_bind_group(bind_group_id, new_bind_group) + { + logging::error!("Failed to replace post bind group: {}", error); + } + } +} + +impl Default for OffscreenPostExample { + fn default() -> Self { + let triangle_vertex = VirtualShader::Source { + source: include_str!("../assets/shaders/triangle.vert").to_string(), + kind: ShaderKind::Vertex, + name: String::from("triangle"), + entry_point: String::from("main"), + }; + + let triangle_fragment = VirtualShader::Source { + source: include_str!("../assets/shaders/triangle.frag").to_string(), + kind: ShaderKind::Fragment, + name: String::from("triangle"), + entry_point: String::from("main"), + }; + + let mut builder = ShaderBuilder::new(); + let triangle_vs = builder.build(triangle_vertex); + let triangle_fs = builder.build(triangle_fragment); + + let post_vs = builder.build(VirtualShader::Source { + source: POST_VERTEX_SHADER_SOURCE.to_string(), + kind: ShaderKind::Vertex, + entry_point: "main".to_string(), + name: "offscreen-post".to_string(), + }); + let post_fs = builder.build(VirtualShader::Source { + source: POST_FRAGMENT_SHADER_SOURCE.to_string(), + kind: ShaderKind::Fragment, + entry_point: "main".to_string(), + name: "offscreen-post".to_string(), + }); + + return OffscreenPostExample { + triangle_vs, + triangle_fs, + post_vs, + post_fs, + quad_mesh: None, + offscreen_pass: None, + offscreen_pipeline: None, + offscreen_target: None, + post_pass: None, + post_pipeline: None, + post_bind_group: None, + post_layout: None, + width: 800, + height: 600, + }; + } +} + +fn main() { + let runtime = ApplicationRuntimeBuilder::new("Offscreen Post Process") + .with_window_configured_as(move |window_builder| { + return window_builder + .with_dimensions(1200, 600) + .with_name("Offscreen Post Process"); + }) + .with_component(move |runtime, component: OffscreenPostExample| { + return (runtime, component); + }) + .build(); + + start_runtime(runtime); +} diff --git a/docs/specs/offscreen-render-targets-and-multipass.md b/docs/specs/offscreen-render-targets-and-multipass.md index b5e06cb2..61abb5be 100644 --- a/docs/specs/offscreen-render-targets-and-multipass.md +++ b/docs/specs/offscreen-render-targets-and-multipass.md @@ -3,13 +3,13 @@ title: "Offscreen Render Targets and Multipass Rendering" document_id: "offscreen-render-targets-2025-11-25" status: "draft" created: "2025-11-25T00:00:00Z" -last_updated: "2025-12-25T00:00:00Z" -version: "0.2.4" +last_updated: "2025-12-29T00:00:00Z" +version: "0.2.5" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "e8bd8e9022567a553714bb488d230682020dcfa4" +repo_commit: "bc191ae9d47e9339390b9c21e47933a36d737987" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "rendering", "offscreen", "multipass"] @@ -90,10 +90,6 @@ Summary - `lambda::render::targets::offscreen::OffscreenTarget`: persistent render-to- texture resource that owns textures and exposes a sampleable resolve color. -Compatibility shims: -- `lambda::render::render_target` re-exports `lambda::render::targets::surface`. -- `lambda::render::target` re-exports `lambda::render::targets::offscreen`. - Terminology in this document: - "Render target" refers to `lambda::render::targets::surface::RenderTarget`. - The offscreen resource is `OffscreenTarget`. @@ -154,8 +150,7 @@ Per-frame commands: - `UnsupportedFormat { message: String }` - `DeviceError(String)` - Note: Deprecated aliases (`RenderTarget`, `RenderTargetBuilder`, - `RenderTargetError`) exist for short-term source compatibility and are - re-exported from `lambda::render::target`. + `RenderTargetError`) exist for short-term source compatibility. - Module `lambda::render::command` - Add explicit destination selection for pass begins: @@ -417,6 +412,10 @@ Gating requirements ## Changelog +- 2025-12-29 (v0.2.5) — Remove references to `lambda::render::target` and + `lambda::render::render_target` compatibility shims; document + `lambda::render::targets::{surface,offscreen}` as the canonical module + layout. - 2025-12-25 (v0.2.4) — Decouple `OffscreenTargetBuilder::build` from `RenderContext` by requiring an explicit size and a `Gpu`. - 2025-12-22 (v0.2.3) — Document `lambda::render::targets::{surface,offscreen}` diff --git a/docs/tutorials/README.md b/docs/tutorials/README.md index a46ed084..c86d8162 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-12-16T00:00:00Z" -version: "0.5.0" +last_updated: "2025-12-29T00:00:00Z" +version: "0.6.0" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "797047468a927f1e4ba111b43381a607ac53c0d1" +repo_commit: "bc2ca687922db601998e7e5a0c0b2e870c857be1" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["index", "tutorials", "docs"] @@ -22,12 +22,14 @@ This index lists tutorials that teach specific `lambda-rs` tasks through complet - 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) +- Offscreen Post: Render to a Texture and Sample to the Surface — [offscreen-post.md](offscreen-post.md) - Reflective Room: Stencil Masked Reflections with MSAA — [reflective-room.md](reflective-room.md) - Instanced Rendering: Grid of Colored Quads — [instanced-quads.md](instanced-quads.md) Browse all tutorials in this directory. Changelog +- 0.6.0 (2025-12-29): Add offscreen post tutorial; update metadata and commit. - 0.5.0 (2025-12-16): Add basic triangle and multi-triangle push constants tutorials; update metadata and commit. - 0.4.0 (2025-11-25): Add Instanced Quads tutorial; update metadata and commit. - 0.3.0 (2025-11-17): Add Reflective Room tutorial; update metadata and commit. diff --git a/docs/tutorials/offscreen-post.md b/docs/tutorials/offscreen-post.md new file mode 100644 index 00000000..b115be24 --- /dev/null +++ b/docs/tutorials/offscreen-post.md @@ -0,0 +1,311 @@ +--- +title: "Offscreen Post: Render to a Texture and Sample to the Surface" +document_id: "offscreen-post-tutorial-2025-12-29" +status: "draft" +created: "2025-12-29T00:00:00Z" +last_updated: "2025-12-29T00: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: "bc2ca687922db601998e7e5a0c0b2e870c857be1" +owners: ["lambda-sh"] +reviewers: ["engine", "rendering"] +tags: ["tutorial", "graphics", "offscreen", "render-targets", "multipass", "post-processing", "texture", "sampler", "wgpu", "rust"] +--- + +## Overview + +This tutorial renders a triangle into an offscreen render target, then samples +that target in a second pass to present the result on the window surface. The +implementation demonstrates multi-pass rendering, bind groups for texture +sampling, and resource replacement during window resize. + +Reference implementation: `crates/lambda-rs/examples/offscreen_post.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 — Post Shaders: Fullscreen Sample](#step-2) + - [Step 3 — Build and Attach an Offscreen Target](#step-3) + - [Step 4 — Passes and Pipelines](#step-4) + - [Step 5 — Sampling Bind Group](#step-5) + - [Step 6 — Fullscreen Quad Mesh and Vertex Buffer](#step-6) + - [Step 7 — Render Commands and Resize Replacement](#step-7) +- [Validation](#validation) +- [Notes](#notes) +- [Conclusion](#conclusion) +- [Exercises](#exercises) +- [Changelog](#changelog) + +## Goals + +- Render into an offscreen color texture using `RenderDestination::Offscreen`. +- Sample the offscreen result in a second pass using a bind group. +- Replace the offscreen target and dependent bind group on window resize. + +## Prerequisites + +- The workspace builds: `cargo build --workspace`. +- The `lambda-rs` crate examples run: + `cargo run -p lambda-rs --example minimal`. + +## Requirements and Constraints + +- The offscreen target color texture MUST be created with both render-attachment + and sampled usage. Use `OffscreenTargetBuilder` to ensure correct usage. +- The offscreen pass/pipeline color format MUST match the offscreen target + format. This example uses `render_context.surface_format()` for both. +- The bind group layout bindings MUST match the shader declarations: + `layout (set = 0, binding = 1)` for the texture and `binding = 2` for the + sampler. +- Replacing an offscreen target MUST also replace any bind groups that reference + the previous target’s texture view. +- Acronyms: graphics processing unit (GPU), central processing unit (CPU), + texture coordinates (UV). + +## Data Flow + +``` +Component::on_attach + ├─ OffscreenTargetBuilder → OffscreenTarget (attached) + ├─ RenderPassBuilder → offscreen pass + post pass (attached) + ├─ RenderPipelineBuilder → offscreen pipeline + post pipeline (attached) + └─ BindGroupLayout/BindGroup → sample offscreen color texture + +Component::on_render (each frame) + Pass A (Offscreen): draw triangle → offscreen color texture + Pass B (Surface): sample offscreen texture → fullscreen quad +``` + +## Implementation Steps + +### Step 1 — Runtime and Component Skeleton + +Create an `ApplicationRuntime` and register a component that stores shader +handles and resource IDs for two passes. + +```rust +pub struct OffscreenPostExample { + triangle_vs: Shader, + triangle_fs: Shader, + post_vs: Shader, + post_fs: Shader, + + offscreen_pass: Option, + offscreen_pipeline: Option, + offscreen_target: Option, + + post_pass: Option, + post_pipeline: Option, + post_bind_group: Option, + post_layout: Option, + + width: u32, + height: u32, +} +``` + +The component builds GPU resources in `on_attach`, emits commands in `on_render`, +and updates the stored dimensions in `on_event`. + +### Step 2 — Post Shaders: Fullscreen Sample + +Define a post vertex shader that passes UV coordinates and a fragment shader +that samples a `texture2D` with a `sampler`. + +```glsl +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); +} +``` + +The shader interface defines the bind group layout requirements for Step 5. + +### Step 3 — Build and Attach an Offscreen Target + +Build an offscreen target sized to the current surface and attach it to the +`RenderContext`. + +```rust +let (width, height) = render_context.surface_size(); +let offscreen_target = OffscreenTargetBuilder::new() + .with_color(render_context.surface_format(), width, height) + .with_label("offscreen-post-target") + .build(render_context.gpu()) + .map_err(|e| format!("Failed to build offscreen target: {:?}", e))?; + +let offscreen_target_id = + render_context.attach_offscreen_target(offscreen_target); +``` + +The attached ID is used later with `RenderDestination::Offscreen`. + +### Step 4 — Passes and Pipelines + +Create two passes: one for offscreen rendering and one for the surface. Build +one pipeline per pass. + +```rust +let offscreen_pass = + RenderPassBuilder::new().with_label("offscreen-pass").build( + render_context.gpu(), + render_context.surface_format(), + render_context.depth_format(), + ); + +let offscreen_pipeline = RenderPipelineBuilder::new() + .with_label("offscreen-pipeline") + .with_culling(CullingMode::None) + .build( + render_context.gpu(), + render_context.surface_format(), + render_context.depth_format(), + &offscreen_pass, + &self.triangle_vs, + Some(&self.triangle_fs), + ); +``` + +The post pipeline adds a layout and a vertex buffer in Step 5 and Step 6. + +### Step 5 — Sampling Bind Group + +Build a bind group layout that matches the post fragment shader and create a +sampler and bind group that reference the offscreen target’s color texture. + +```rust +let post_layout = BindGroupLayoutBuilder::new() + .with_sampled_texture(1) + .with_sampler(2) + .build(render_context.gpu()); + +let sampler = SamplerBuilder::new() + .linear_clamp() + .with_label("offscreen-post-sampler") + .build(render_context.gpu()); + +let offscreen_ref = + render_context.get_offscreen_target(offscreen_target_id); +let post_bind_group = BindGroupBuilder::new() + .with_layout(&post_layout) + .with_texture(1, offscreen_ref.color_texture()) + .with_sampler(2, &sampler) + .build(render_context.gpu()); +``` + +The post pipeline uses `.with_layouts(&[&post_layout])` so set `0` is defined. + +### Step 6 — Fullscreen Quad Mesh and Vertex Buffer + +Build a fullscreen quad (two triangles) and pack UV coordinates into the +`Vertex` color attribute at location `2` to match the post vertex shader. + +```rust +VertexBuilder::new() + .with_position([-1.0, -1.0, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([0.0, 0.0, 0.0]) + .build(); +``` + +Upload the mesh to a vertex buffer and attach it to the post pipeline: +`BufferBuilder::build_from_mesh(&quad_mesh, render_context.gpu())`. + +### Step 7 — Render Commands and Resize Replacement + +Emit two passes per frame. Use `BeginRenderPassTo` with an offscreen destination +for the first pass, then sample the result to the surface in the second pass. + +```rust +RenderCommand::BeginRenderPassTo { + render_pass: offscreen_pass_id, + viewport, + destination: RenderDestination::Offscreen(offscreen_target_id), +}, +// draw triangle ... +RenderCommand::BeginRenderPass { render_pass: post_pass_id, viewport }, +// set bind group + bind vertex buffer + draw quad ... +``` + +When the window resizes, rebuild the offscreen target and replace both the +target and the post bind group. + +```rust +if target_size != surface_size { + let new_target = OffscreenTargetBuilder::new() + .with_color(render_context.surface_format(), surface_size.0, surface_size.1) + .with_label("offscreen-post-target") + .build(render_context.gpu())?; + + render_context.replace_offscreen_target(offscreen_id, new_target)?; + // Rebuild bind group with the new target’s `color_texture()`. +} +``` + +The reference implementation performs this replacement in +`ensure_offscreen_matches_surface`. + +## Validation + +- Build: `cargo build --workspace` +- Run: `cargo run -p lambda-rs --example offscreen_post` +- Expected behavior: + - A window opens and shows a solid-color triangle. + - Resizing the window preserves the rendering without stretching artifacts. + +## Notes + +- Format matching + - The offscreen target and the offscreen pass/pipeline MUST agree on the + color format. Use `render_context.surface_format()` to match the window. +- Bindings + - `BindGroupLayoutBuilder::with_sampled_texture(1)` MUST match + `layout (set = 0, binding = 1)` in the fragment shader. + - The sampler binding index MUST also match (`binding = 2`). +- Resize + - Replacing the offscreen target invalidates the previous texture view. + Rebuild the bind group after calling + `render_context.replace_offscreen_target`. + +## Conclusion + +This tutorial demonstrates a minimal multi-pass post path in `lambda-rs`: +render into an offscreen texture, then sample that texture to the surface using +a fullscreen quad and a bind group. + +## Exercises + +- Exercise 1: Apply a post effect + - Modify the post fragment shader to invert colors or apply a grayscale + conversion before writing `fragment_color`. +- Exercise 2: Render offscreen at half resolution + - Create the offscreen target at `width / 2`, `height / 2` and adjust UVs or + sampling to upsample to the surface. +- Exercise 3: Add a debug border + - Draw a second quad in the post pass that outlines the viewport to validate + scissor and viewport behavior. +- Exercise 4: Add MSAA to the offscreen target + - Enable multi-sampling on the offscreen target and ensure the pipeline and + pass use the same sample count. +- Exercise 5: Add a second post pass + - Render the first offscreen result into a second offscreen target, then + sample the second target to the surface. +- Exercise 6: Sample with nearest filtering + - Replace `.linear_clamp()` with nearest sampling and compare the result when + rendering offscreen at reduced resolution. + +## Changelog + +- 0.1.0 (2025-12-29): Initial draft aligned with + `crates/lambda-rs/examples/offscreen_post.rs`. From a365f53df1733063553f733cc6e3fff5af9b7d0f Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 30 Dec 2025 16:19:26 -0800 Subject: [PATCH 18/20] [update] spec. --- docs/specs/offscreen-render-targets-and-multipass.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/specs/offscreen-render-targets-and-multipass.md b/docs/specs/offscreen-render-targets-and-multipass.md index 61abb5be..ec710c8c 100644 --- a/docs/specs/offscreen-render-targets-and-multipass.md +++ b/docs/specs/offscreen-render-targets-and-multipass.md @@ -27,6 +27,7 @@ Summary single-sample resolve texture. ## Table of Contents + - [Scope](#scope) - [Terminology](#terminology) - [Architecture Overview](#architecture-overview) @@ -85,21 +86,25 @@ Summary ## Architecture Overview `lambda-rs` exposes two render target concepts: + - `lambda::render::targets::surface::RenderTarget`: trait for acquiring and presenting frames from a window-backed surface. - `lambda::render::targets::offscreen::OffscreenTarget`: persistent render-to- texture resource that owns textures and exposes a sampleable resolve color. Terminology in this document: + - "Render target" refers to `lambda::render::targets::surface::RenderTarget`. - The offscreen resource is `OffscreenTarget`. Implementation notes: + - `RenderTarget`, `RenderTargetBuilder`, and `RenderTargetError` in the offscreen module are deprecated aliases for `OffscreenTarget`, `OffscreenTargetBuilder`, and `OffscreenTargetError` and MUST NOT be used in new code. Data flow (setup → per-frame multipass): + ``` RenderPassBuilder::new() .with_multi_sample(1 | 2 | 4 | 8) @@ -278,6 +283,7 @@ Per-frame commands: ### Feature-gated validation Crate: `lambda-rs` + - Granular feature: - `render-validation-render-targets` - Validates compatibility between: @@ -293,15 +299,17 @@ Crate: `lambda-rs` - Expected runtime cost is low to moderate. Umbrella composition (crate: `lambda-rs`) + - `render-validation` MUST include `render-validation-render-targets`. -- Umbrella features MUST only compose granular features. Build-type behavior + - Debug builds (`debug_assertions`) MAY enable offscreen validation. - Release builds MUST keep offscreen validation disabled by default and enable it only via `render-validation-render-targets` (or umbrellas that include it). Gating requirements + - Offscreen validation MUST be gated behind `cfg(any(debug_assertions, feature = "render-validation-render-targets"))`. - Offscreen validation MUST NOT be gated behind umbrella feature names. From 1164d8162c55f9fb9d89e640f1e17d112a65e8d8 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 30 Dec 2025 16:29:39 -0800 Subject: [PATCH 19/20] [remove] Rc wrapper around the depth texture. --- crates/lambda-rs/src/render/texture.rs | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/crates/lambda-rs/src/render/texture.rs b/crates/lambda-rs/src/render/texture.rs index c085d241..20c04286 100644 --- a/crates/lambda-rs/src/render/texture.rs +++ b/crates/lambda-rs/src/render/texture.rs @@ -289,23 +289,13 @@ impl ColorAttachmentTextureBuilder { /// operations in render passes. #[derive(Debug)] pub struct DepthTexture { - inner: Rc, -} - -impl Clone for DepthTexture { - fn clone(&self) -> Self { - return DepthTexture { - inner: self.inner.clone(), - }; - } + inner: platform::DepthTexture, } impl DepthTexture { /// Create a high-level depth texture from a platform texture. pub(crate) fn from_platform(texture: platform::DepthTexture) -> Self { - return DepthTexture { - inner: Rc::new(texture), - }; + return DepthTexture { inner: texture }; } /// The depth format used by this attachment. From 73282b0914b134cf0bae42b098c17548b983be6e Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 30 Dec 2025 16:51:42 -0800 Subject: [PATCH 20/20] [update] tutorial. --- docs/tutorials/offscreen-post.md | 660 +++++++++++++++++++++++++------ 1 file changed, 539 insertions(+), 121 deletions(-) diff --git a/docs/tutorials/offscreen-post.md b/docs/tutorials/offscreen-post.md index b115be24..4b991ef6 100644 --- a/docs/tutorials/offscreen-post.md +++ b/docs/tutorials/offscreen-post.md @@ -3,8 +3,8 @@ title: "Offscreen Post: Render to a Texture and Sample to the Surface" document_id: "offscreen-post-tutorial-2025-12-29" status: "draft" created: "2025-12-29T00:00:00Z" -last_updated: "2025-12-29T00:00:00Z" -version: "0.1.0" +last_updated: "2025-12-31T00:00:00Z" +version: "0.2.0" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" @@ -32,13 +32,14 @@ Reference implementation: `crates/lambda-rs/examples/offscreen_post.rs`. - [Requirements and Constraints](#requirements-and-constraints) - [Data Flow](#data-flow) - [Implementation Steps](#implementation-steps) - - [Step 1 — Runtime and Component Skeleton](#step-1) - - [Step 2 — Post Shaders: Fullscreen Sample](#step-2) - - [Step 3 — Build and Attach an Offscreen Target](#step-3) - - [Step 4 — Passes and Pipelines](#step-4) - - [Step 5 — Sampling Bind Group](#step-5) - - [Step 6 — Fullscreen Quad Mesh and Vertex Buffer](#step-6) - - [Step 7 — Render Commands and Resize Replacement](#step-7) + - [Step 1 — Imports and Shader Sources](#step-1) + - [Step 2 — Component State](#step-2) + - [Step 3 — Compile Shaders in `Default`](#step-3) + - [Step 4 — Implement `Component` and Build Resources](#step-4) + - [Step 5 — Fullscreen Quad Mesh](#step-5) + - [Step 6 — Record Commands in `on_render`](#step-6) + - [Step 7 — Resize Events and Resource Replacement](#step-7) + - [Step 8 — Main Entry Point](#step-8) - [Validation](#validation) - [Notes](#notes) - [Conclusion](#conclusion) @@ -63,6 +64,8 @@ Reference implementation: `crates/lambda-rs/examples/offscreen_post.rs`. and sampled usage. Use `OffscreenTargetBuilder` to ensure correct usage. - The offscreen pass/pipeline color format MUST match the offscreen target format. This example uses `render_context.surface_format()` for both. +- The render path MUST handle `0x0` sizes during resize. This example clamps + viewport sizes via `width.max(1)` and `height.max(1)`. - The bind group layout bindings MUST match the shader declarations: `layout (set = 0, binding = 1)` for the texture and `binding = 2` for the sampler. @@ -74,6 +77,9 @@ Reference implementation: `crates/lambda-rs/examples/offscreen_post.rs`. ## Data Flow ``` +Default::default + └─ ShaderBuilder → Shader handles + Component::on_attach ├─ OffscreenTargetBuilder → OffscreenTarget (attached) ├─ RenderPassBuilder → offscreen pass + post pass (attached) @@ -87,10 +93,100 @@ Component::on_render (each frame) ## Implementation Steps -### Step 1 — Runtime and Component Skeleton +### Step 1 — Imports and Shader Sources + +Start with the imports and the embedded post shaders. + +```rust +#![allow(clippy::needless_return)] + +//! Example: Render to an offscreen target, then sample it to the surface. + +use lambda::{ + component::Component, + events::Events, + logging, + render::{ + bind::{ + BindGroupBuilder, + BindGroupLayout, + BindGroupLayoutBuilder, + }, + buffer::BufferBuilder, + command::{ + RenderCommand, + RenderDestination, + }, + mesh::{ + Mesh, + MeshBuilder, + }, + pipeline::{ + CullingMode, + RenderPipelineBuilder, + }, + render_pass::RenderPassBuilder, + shader::{ + Shader, + ShaderBuilder, + ShaderKind, + VirtualShader, + }, + targets::offscreen::OffscreenTargetBuilder, + texture::SamplerBuilder, + vertex::{ + ColorFormat, + Vertex, + VertexAttribute, + VertexBuilder, + VertexElement, + }, + viewport::ViewportBuilder, + RenderContext, + ResourceId, + }, + runtime::start_runtime, + runtimes::{ + application::ComponentResult, + ApplicationRuntimeBuilder, + }, +}; + +const POST_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 POST_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); +} +"#; +``` + +The offscreen pass uses `crates/lambda-rs/assets/shaders/triangle.vert` and +`crates/lambda-rs/assets/shaders/triangle.frag`. -Create an `ApplicationRuntime` and register a component that stores shader -handles and resource IDs for two passes. +### Step 2 — Component State + +Define the component state used by the example. ```rust pub struct OffscreenPostExample { @@ -98,6 +194,7 @@ pub struct OffscreenPostExample { triangle_fs: Shader, post_vs: Shader, post_fs: Shader, + quad_mesh: Option, offscreen_pass: Option, offscreen_pipeline: Option, @@ -113,148 +210,465 @@ pub struct OffscreenPostExample { } ``` -The component builds GPU resources in `on_attach`, emits commands in `on_render`, -and updates the stored dimensions in `on_event`. +This struct matches the example’s fields and keeps the shader handles alongside +the IDs returned by `RenderContext::attach_*`. -### Step 2 — Post Shaders: Fullscreen Sample +### Step 3 — Compile Shaders in `Default` -Define a post vertex shader that passes UV coordinates and a fragment shader -that samples a `texture2D` with a `sampler`. +Compile the triangle and post shaders in `Default`, matching the example. -```glsl -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); +```rust +impl Default for OffscreenPostExample { + fn default() -> Self { + let triangle_vertex = VirtualShader::Source { + source: include_str!("../assets/shaders/triangle.vert").to_string(), + kind: ShaderKind::Vertex, + name: String::from("triangle"), + entry_point: String::from("main"), + }; + + let triangle_fragment = VirtualShader::Source { + source: include_str!("../assets/shaders/triangle.frag").to_string(), + kind: ShaderKind::Fragment, + name: String::from("triangle"), + entry_point: String::from("main"), + }; + + let mut builder = ShaderBuilder::new(); + let triangle_vs = builder.build(triangle_vertex); + let triangle_fs = builder.build(triangle_fragment); + + let post_vs = builder.build(VirtualShader::Source { + source: POST_VERTEX_SHADER_SOURCE.to_string(), + kind: ShaderKind::Vertex, + entry_point: "main".to_string(), + name: "offscreen-post".to_string(), + }); + let post_fs = builder.build(VirtualShader::Source { + source: POST_FRAGMENT_SHADER_SOURCE.to_string(), + kind: ShaderKind::Fragment, + entry_point: "main".to_string(), + name: "offscreen-post".to_string(), + }); + + return OffscreenPostExample { + triangle_vs, + triangle_fs, + post_vs, + post_fs, + quad_mesh: None, + offscreen_pass: None, + offscreen_pipeline: None, + offscreen_target: None, + post_pass: None, + post_pipeline: None, + post_bind_group: None, + post_layout: None, + width: 800, + height: 600, + }; + } } ``` -The shader interface defines the bind group layout requirements for Step 5. +This keeps shader construction out of `on_attach` so the component can build +pipelines immediately from the stored `Shader` values. -### Step 3 — Build and Attach an Offscreen Target +### Step 4 — Implement `Component` and Build Resources -Build an offscreen target sized to the current surface and attach it to the -`RenderContext`. +Implement the component lifecycle. This example creates the offscreen target, +passes, pipelines, and bind group in `on_attach`, and records two render passes +each frame in `on_render`. ```rust -let (width, height) = render_context.surface_size(); -let offscreen_target = OffscreenTargetBuilder::new() - .with_color(render_context.surface_format(), width, height) - .with_label("offscreen-post-target") - .build(render_context.gpu()) - .map_err(|e| format!("Failed to build offscreen target: {:?}", e))?; - -let offscreen_target_id = - render_context.attach_offscreen_target(offscreen_target); +impl Component for OffscreenPostExample { + fn on_attach( + &mut self, + render_context: &mut RenderContext, + ) -> Result { + logging::info!("Attaching OffscreenPostExample"); + + let surface_size = render_context.surface_size(); + let offscreen_target = OffscreenTargetBuilder::new() + .with_color( + render_context.surface_format(), + surface_size.0, + surface_size.1, + ) + .with_label("offscreen-post-target") + .build(render_context.gpu()) + .map_err(|e| format!("Failed to build offscreen target: {:?}", e))?; + let offscreen_target_id = + render_context.attach_offscreen_target(offscreen_target); + + let offscreen_pass = + RenderPassBuilder::new().with_label("offscreen-pass").build( + render_context.gpu(), + render_context.surface_format(), + render_context.depth_format(), + ); + + let offscreen_pipeline = RenderPipelineBuilder::new() + .with_label("offscreen-pipeline") + .with_culling(CullingMode::None) + .build( + render_context.gpu(), + render_context.surface_format(), + render_context.depth_format(), + &offscreen_pass, + &self.triangle_vs, + Some(&self.triangle_fs), + ); + + let post_pass = RenderPassBuilder::new().with_label("post-pass").build( + render_context.gpu(), + render_context.surface_format(), + render_context.depth_format(), + ); + + let post_layout = BindGroupLayoutBuilder::new() + .with_sampled_texture(1) + .with_sampler(2) + .build(render_context.gpu()); + + let sampler = SamplerBuilder::new() + .linear_clamp() + .with_label("offscreen-post-sampler") + .build(render_context.gpu()); + + let offscreen_ref = + render_context.get_offscreen_target(offscreen_target_id); + let post_bind_group = BindGroupBuilder::new() + .with_layout(&post_layout) + .with_texture(1, offscreen_ref.color_texture()) + .with_sampler(2, &sampler) + .build(render_context.gpu()); + + let quad_mesh = Self::build_fullscreen_quad_mesh(); + let quad_vertex_buffer = + BufferBuilder::build_from_mesh(&quad_mesh, render_context.gpu()) + .map_err(|e| format!("Failed to build quad vertex buffer: {:?}", e))?; + + let post_pipeline = RenderPipelineBuilder::new() + .with_label("post-pipeline") + .with_culling(CullingMode::None) + .with_layouts(&[&post_layout]) + .with_buffer(quad_vertex_buffer, quad_mesh.attributes().to_vec()) + .build( + render_context.gpu(), + render_context.surface_format(), + render_context.depth_format(), + &post_pass, + &self.post_vs, + Some(&self.post_fs), + ); + + self.offscreen_pass = + Some(render_context.attach_render_pass(offscreen_pass)); + self.offscreen_pipeline = + Some(render_context.attach_pipeline(offscreen_pipeline)); + self.offscreen_target = Some(offscreen_target_id); + + self.post_pass = Some(render_context.attach_render_pass(post_pass)); + self.post_pipeline = Some(render_context.attach_pipeline(post_pipeline)); + self.post_bind_group = + Some(render_context.attach_bind_group(post_bind_group)); + self.post_layout = Some(post_layout); + self.quad_mesh = Some(quad_mesh); + + let (width, height) = render_context.surface_size(); + self.width = width; + self.height = height; + + return Ok(ComponentResult::Success); + } + + fn on_detach( + &mut self, + _render_context: &mut RenderContext, + ) -> Result { + return Ok(ComponentResult::Success); + } + + fn on_event(&mut self, event: Events) -> Result { + if let Events::Window { + event: lambda::events::WindowEvent::Resize { width, height }, + .. + } = event + { + self.width = width; + self.height = height; + } + return Ok(ComponentResult::Success); + } + + fn on_update( + &mut self, + _last_frame: &std::time::Duration, + ) -> Result { + return Ok(ComponentResult::Success); + } + + fn on_render( + &mut self, + render_context: &mut RenderContext, + ) -> Vec { + self.ensure_offscreen_matches_surface(render_context); + + let offscreen_viewport = + ViewportBuilder::new().build(self.width.max(1), self.height.max(1)); + let surface_viewport = + ViewportBuilder::new().build(self.width.max(1), self.height.max(1)); + + return vec![ + RenderCommand::BeginRenderPassTo { + render_pass: self.offscreen_pass.expect("offscreen pass not set"), + viewport: offscreen_viewport.clone(), + destination: RenderDestination::Offscreen( + self.offscreen_target.expect("offscreen target not set"), + ), + }, + RenderCommand::SetPipeline { + pipeline: self.offscreen_pipeline.expect("offscreen pipeline not set"), + }, + RenderCommand::SetViewports { + start_at: 0, + viewports: vec![offscreen_viewport.clone()], + }, + RenderCommand::SetScissors { + start_at: 0, + viewports: vec![offscreen_viewport.clone()], + }, + RenderCommand::Draw { + vertices: 0..3, + instances: 0..1, + }, + RenderCommand::EndRenderPass, + RenderCommand::BeginRenderPass { + render_pass: self.post_pass.expect("post pass not set"), + viewport: surface_viewport.clone(), + }, + RenderCommand::SetPipeline { + pipeline: self.post_pipeline.expect("post pipeline not set"), + }, + RenderCommand::SetBindGroup { + set: 0, + group: self.post_bind_group.expect("post bind group not set"), + dynamic_offsets: vec![], + }, + RenderCommand::BindVertexBuffer { + pipeline: self.post_pipeline.expect("post pipeline not set"), + buffer: 0, + }, + RenderCommand::SetViewports { + start_at: 0, + viewports: vec![surface_viewport.clone()], + }, + RenderCommand::SetScissors { + start_at: 0, + viewports: vec![surface_viewport.clone()], + }, + RenderCommand::Draw { + vertices: 0..6, + instances: 0..1, + }, + RenderCommand::EndRenderPass, + ]; + } +} ``` -The attached ID is used later with `RenderDestination::Offscreen`. +This produces two render passes: an offscreen triangle render and a post pass +that samples the offscreen color texture and draws a fullscreen quad. -### Step 4 — Passes and Pipelines +### Step 5 — Fullscreen Quad Mesh -Create two passes: one for offscreen rendering and one for the surface. Build -one pipeline per pass. +Build the fullscreen quad mesh used by the post pass. ```rust -let offscreen_pass = - RenderPassBuilder::new().with_label("offscreen-pass").build( - render_context.gpu(), - render_context.surface_format(), - render_context.depth_format(), - ); - -let offscreen_pipeline = RenderPipelineBuilder::new() - .with_label("offscreen-pipeline") - .with_culling(CullingMode::None) - .build( - render_context.gpu(), - render_context.surface_format(), - render_context.depth_format(), - &offscreen_pass, - &self.triangle_vs, - Some(&self.triangle_fs), - ); +impl OffscreenPostExample { + fn build_fullscreen_quad_mesh() -> Mesh { + let vertices: [Vertex; 6] = [ + VertexBuilder::new() + .with_position([-1.0, -1.0, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([0.0, 0.0, 0.0]) + .build(), + VertexBuilder::new() + .with_position([1.0, -1.0, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([1.0, 0.0, 0.0]) + .build(), + VertexBuilder::new() + .with_position([1.0, 1.0, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([1.0, 1.0, 0.0]) + .build(), + VertexBuilder::new() + .with_position([-1.0, -1.0, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([0.0, 0.0, 0.0]) + .build(), + VertexBuilder::new() + .with_position([1.0, 1.0, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([1.0, 1.0, 0.0]) + .build(), + VertexBuilder::new() + .with_position([-1.0, 1.0, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([0.0, 1.0, 0.0]) + .build(), + ]; + + let mut mesh_builder = MeshBuilder::new(); + for v in vertices { + mesh_builder.with_vertex(v); + } + + return 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(); + } +} ``` -The post pipeline adds a layout and a vertex buffer in Step 5 and Step 6. +The post vertex shader reads UV from `vertex_color.xy` at `location = 2`, which +is why the quad’s `VertexAttribute` for `location = 2` uses `offset: 24`. -### Step 5 — Sampling Bind Group +### Step 6 — Record Commands in `on_render` -Build a bind group layout that matches the post fragment shader and create a -sampler and bind group that reference the offscreen target’s color texture. +`on_render` records two passes each frame. The offscreen pass targets +`RenderDestination::Offscreen` and draws `0..3` vertices. The post pass targets +the surface, binds set `0` and vertex buffer slot `0`, and draws `0..6` +vertices for the fullscreen quad. -```rust -let post_layout = BindGroupLayoutBuilder::new() - .with_sampled_texture(1) - .with_sampler(2) - .build(render_context.gpu()); - -let sampler = SamplerBuilder::new() - .linear_clamp() - .with_label("offscreen-post-sampler") - .build(render_context.gpu()); - -let offscreen_ref = - render_context.get_offscreen_target(offscreen_target_id); -let post_bind_group = BindGroupBuilder::new() - .with_layout(&post_layout) - .with_texture(1, offscreen_ref.color_texture()) - .with_sampler(2, &sampler) - .build(render_context.gpu()); -``` - -The post pipeline uses `.with_layouts(&[&post_layout])` so set `0` is defined. +### Step 7 — Resize Events and Resource Replacement -### Step 6 — Fullscreen Quad Mesh and Vertex Buffer - -Build a fullscreen quad (two triangles) and pack UV coordinates into the -`Vertex` color attribute at location `2` to match the post vertex shader. +`on_event` stores the new width/height and `ensure_offscreen_matches_surface` +rebuilds the offscreen target (and dependent bind group) when the sizes +diverge. ```rust -VertexBuilder::new() - .with_position([-1.0, -1.0, 0.0]) - .with_normal([0.0, 0.0, 1.0]) - .with_color([0.0, 0.0, 0.0]) - .build(); +impl OffscreenPostExample { + fn ensure_offscreen_matches_surface( + &mut self, + render_context: &mut RenderContext, + ) { + let offscreen_id = match self.offscreen_target { + Some(id) => id, + None => return, + }; + let post_layout = match self.post_layout.as_ref() { + Some(layout) => layout, + None => return, + }; + let bind_group_id = match self.post_bind_group { + Some(id) => id, + None => return, + }; + + let surface_size = render_context.surface_size(); + let target_size = + render_context.get_offscreen_target(offscreen_id).size(); + if target_size == surface_size { + return; + } + + let new_target = match OffscreenTargetBuilder::new() + .with_color( + render_context.surface_format(), + surface_size.0, + surface_size.1, + ) + .with_label("offscreen-post-target") + .build(render_context.gpu()) + { + Ok(target) => target, + Err(error) => { + logging::error!("Failed to rebuild offscreen target: {:?}", error); + return; + } + }; + + if let Err(error) = + render_context.replace_offscreen_target(offscreen_id, new_target) + { + logging::error!("Failed to replace offscreen target: {}", error); + return; + } + + let offscreen_ref = render_context.get_offscreen_target(offscreen_id); + let sampler = SamplerBuilder::new() + .linear_clamp() + .with_label("offscreen-post-sampler") + .build(render_context.gpu()); + let new_bind_group = BindGroupBuilder::new() + .with_layout(post_layout) + .with_texture(1, offscreen_ref.color_texture()) + .with_sampler(2, &sampler) + .build(render_context.gpu()); + + if let Err(error) = + render_context.replace_bind_group(bind_group_id, new_bind_group) + { + logging::error!("Failed to replace post bind group: {}", error); + } + } +} ``` -Upload the mesh to a vertex buffer and attach it to the post pipeline: -`BufferBuilder::build_from_mesh(&quad_mesh, render_context.gpu())`. +This replacement path rebuilds both the offscreen target and the bind group so +the post pass samples the updated texture view after a resize. -### Step 7 — Render Commands and Resize Replacement - -Emit two passes per frame. Use `BeginRenderPassTo` with an offscreen destination -for the first pass, then sample the result to the surface in the second pass. - -```rust -RenderCommand::BeginRenderPassTo { - render_pass: offscreen_pass_id, - viewport, - destination: RenderDestination::Offscreen(offscreen_target_id), -}, -// draw triangle ... -RenderCommand::BeginRenderPass { render_pass: post_pass_id, viewport }, -// set bind group + bind vertex buffer + draw quad ... -``` +### Step 8 — Main Entry Point -When the window resizes, rebuild the offscreen target and replace both the -target and the post bind group. +Start the runtime using the example’s `main`. ```rust -if target_size != surface_size { - let new_target = OffscreenTargetBuilder::new() - .with_color(render_context.surface_format(), surface_size.0, surface_size.1) - .with_label("offscreen-post-target") - .build(render_context.gpu())?; - - render_context.replace_offscreen_target(offscreen_id, new_target)?; - // Rebuild bind group with the new target’s `color_texture()`. +fn main() { + let runtime = ApplicationRuntimeBuilder::new("Offscreen Post Process") + .with_window_configured_as(move |window_builder| { + return window_builder + .with_dimensions(1200, 600) + .with_name("Offscreen Post Process"); + }) + .with_component(move |runtime, component: OffscreenPostExample| { + return (runtime, component); + }) + .build(); + + start_runtime(runtime); } ``` -The reference implementation performs this replacement in -`ensure_offscreen_matches_surface`. +The resulting program opens a window, renders into an offscreen texture, and +presents the sampled result to the surface each frame. ## Validation @@ -277,6 +691,8 @@ The reference implementation performs this replacement in - Replacing the offscreen target invalidates the previous texture view. Rebuild the bind group after calling `render_context.replace_offscreen_target`. + - Viewports are built from `width.max(1)` and `height.max(1)` to avoid + zero-size viewport creation during resize. ## Conclusion @@ -307,5 +723,7 @@ a fullscreen quad and a bind group. ## Changelog +- 0.2.0 (2025-12-31): Update the tutorial to match the example’s `Default`, + `on_attach`, `on_render`, and resize replacement structure. - 0.1.0 (2025-12-29): Initial draft aligned with `crates/lambda-rs/examples/offscreen_post.rs`.