Skip to main content

unlit_wgpu/
render_attachments.rs

1//! Attachment management for the single-pass frame.
2//!
3//! A [`RenderAttachments`] holds the views a frame renders into — the color
4//! view, the depth-stencil view and an optional multisample view — all of them
5//! caller-owned. The attachment set allocates nothing: textures live wherever
6//! the caller keeps them (often a [`crate::resources::ResourceGraph`]), and the
7//! set only borrows the views for the duration of a pass.
8//!
9//! Recording is the caller's: [`RenderAttachments::begin_pass`] opens a pass
10//! over the attachments with the caller's load and store ops, so a depth pass
11//! can keep its results (a shadow map, a preprocessed depth buffer) instead of
12//! discarding them.
13//!
14//! Sharing resources more broadly — textures, buffers, bind groups across
15//! scenes — is the job of [`crate::resources::ResourceGraph`], which the
16//! caller keeps separately and registers their own resources in. A
17//! [`crate::scene::Scene`] stays the caller's, assembled per frame from the
18//! handles they hand out.
19//!
20//! ```rust
21//! # use unlit_wgpu::render_attachments::{
22//! #     RenderAttachments, color_clear, depth_clear, stencil_clear,
23//! # };
24//! # use unlit_wgpu::resources::TextureView;
25//! # use unlit_wgpu::scene::Scene;
26//! # fn frame(device: &wgpu::Device, color: TextureView, depth: TextureView, scene: &Scene) {
27//! let attachments = RenderAttachments::from_views(Some(color), Some(depth), None);
28//!
29//! let mut encoder = device.create_command_encoder(&Default::default());
30//! let mut pass = attachments.begin_pass(
31//!     &mut encoder,
32//!     color_clear(),
33//!     depth_clear(), // the reverse-z far plane
34//!     stencil_clear(),
35//! );
36//! scene.record(&mut pass, None);
37//! # }
38//! ```
39
40use crate::resources::{TextureExt, TextureView};
41
42/// The color load op a frame starts from by default: a clear to black.
43#[must_use]
44pub const fn color_clear() -> wgpu::LoadOp<wgpu::Color> {
45    wgpu::LoadOp::Clear(wgpu::Color::BLACK)
46}
47
48/// The depth load op of the renderer's reverse-z convention: a clear to the
49/// far plane.
50///
51/// Pipelines built by [`LitOptions::standard`] compare with
52/// `CompareFunction::Greater`, so depth starts at the far plane and nearer
53/// geometry carries the greater value. Clearing a depth attachment with any
54/// other value would put the convention out of joint.
55///
56/// [`LitOptions::standard`]: crate::pipeline::LitOptions::standard
57#[must_use]
58pub const fn depth_clear() -> wgpu::LoadOp<f32> {
59    wgpu::LoadOp::Clear(0.0)
60}
61
62/// The stencil load op of a frame whose pipelines write no stencil: a clear
63/// to zero.
64///
65/// The built-in pipelines never write stencil, so the pass discards it either
66/// way; this only says what a pass that reads stencil beforehand starts from.
67#[must_use]
68pub const fn stencil_clear() -> wgpu::LoadOp<u32> {
69    wgpu::LoadOp::Clear(0)
70}
71
72/// The depth-stencil format a render target should use on `device`:
73/// `Depth32FloatStencil8` when the device supports it, otherwise the
74/// universally-available `Depth24PlusStencil8`.
75///
76/// Both formats carry a stencil aspect, so the render pass and pipelines must
77/// account for it (see [`RenderAttachments::begin_pass`]).
78pub fn default_depth_stencil_format(device: &wgpu::Device) -> wgpu::TextureFormat {
79    if device
80        .features()
81        .contains(wgpu::Features::DEPTH32FLOAT_STENCIL8)
82    {
83        wgpu::TextureFormat::Depth32FloatStencil8
84    } else {
85        wgpu::TextureFormat::Depth24PlusStencil8
86    }
87}
88
89/// The attachments a frame renders into.
90///
91/// Every view is caller-owned; the set allocates nothing. Build one with
92/// [`Self::from_views`].
93pub struct RenderAttachments {
94    /// The color attachment, or `None` for a depth-only pass.
95    color_view: Option<TextureView>,
96    /// The depth attachment, or `None` for a pass without depth.
97    depth_stencil_view: Option<TextureView>,
98    /// The multisample attachment the pass draws into, resolved into
99    /// [`Self::color_view`]; `None` when multisampling is disabled or the
100    /// pass is depth-only.
101    msaa_view: Option<TextureView>,
102}
103
104impl RenderAttachments {
105    /// Assemble an attachment set entirely from the caller's views: the color
106    /// view, the depth-stencil view and an optional multisample view.
107    ///
108    /// A [`TextureView`] carries the format it was created with, so the color
109    /// format is the color view's own even when it reinterprets its texture —
110    /// an sRGB view over a non-sRGB swap-chain image, the only way to get
111    /// correct gamma on the web. Size and sample count are read from the views
112    /// themselves, so they must agree across views (the caller's
113    /// responsibility).
114    ///
115    /// The depth attachment's transience is read from its texture's usage: a
116    /// depth texture created with
117    /// [`TextureUsages::TRANSIENT_ATTACHMENT`](wgpu::TextureUsages::TRANSIENT_ATTACHMENT)
118    /// is treated as transient (cleared and discarded within a single pass),
119    /// any other as persistent (a pass stores into it so a later pass may
120    /// sample the results).
121    ///
122    /// `msaa_view` must be `None` for a non-multisampled pass; when `Some`, its
123    /// format and size must match the color view and its sample count must be
124    /// greater than one.
125    ///
126    /// # Panics
127    ///
128    /// If neither the color nor the depth view is present, or if the
129    /// multisample view is present without a color view.
130    pub fn from_views(
131        color_view: Option<TextureView>,
132        depth_stencil_view: Option<TextureView>,
133        msaa_view: Option<TextureView>,
134    ) -> Self {
135        assert!(
136            color_view.is_some() || depth_stencil_view.is_some(),
137            "a render pass needs at least one attachment"
138        );
139        assert!(
140            msaa_view.is_none() || color_view.is_some(),
141            "a multisample view needs a color view to resolve into"
142        );
143        Self {
144            color_view,
145            depth_stencil_view,
146            msaa_view,
147        }
148    }
149
150    /// The color attachment, or `None` for a depth-only pass.
151    pub fn color_view(&self) -> Option<&TextureView> {
152        self.color_view.as_ref()
153    }
154
155    /// The multisample attachment, if any.
156    pub fn msaa_view(&self) -> Option<&TextureView> {
157        self.msaa_view.as_ref()
158    }
159
160    /// The color format the pass renders into, or `None` for a depth-only
161    /// pass.
162    ///
163    /// The color view's own — the MSAA view's when one is set, since the
164    /// resolve writes into the color view in that view's format.
165    pub fn color_format(&self) -> Option<wgpu::TextureFormat> {
166        let view = self.msaa_view.as_ref().or(self.color_view.as_ref())?;
167        Some(view.format())
168    }
169
170    /// The depth format the pass renders into, or `None` for a pass without
171    /// depth.
172    pub fn depth_stencil_format(&self) -> Option<wgpu::TextureFormat> {
173        Some(self.depth_stencil_view.as_ref()?.texture().format())
174    }
175
176    /// The sample count the pass renders with: the MSAA attachment's when one
177    /// is set, `1` otherwise.
178    pub fn sample_count(&self) -> u32 {
179        match self.msaa_view.as_ref() {
180            Some(view) => view.texture().sample_count(),
181            None => 1,
182        }
183    }
184
185    /// The [SurfaceKey](crate::specialize::SurfaceKey) of the attachments this
186    /// set owns: their color format, depth format and sample count.
187    ///
188    /// The color format and sample count come from
189    /// [`Self::color_format`] and [`Self::sample_count`], so they always
190    /// describe the same attachment.
191    pub fn surface_key(&self) -> crate::specialize::SurfaceKey {
192        crate::specialize::SurfaceKey::from_attachments(self)
193    }
194
195    /// The width of the attachments, in pixels.
196    ///
197    /// Taken from whichever attachment is present; they must agree.
198    pub fn width(&self) -> u32 {
199        self.attachment_view()
200            .map(|view| view.texture().width())
201            .unwrap_or(1)
202    }
203
204    /// The height of the attachments, in pixels.
205    ///
206    /// Taken from whichever attachment is present; they must agree.
207    pub fn height(&self) -> u32 {
208        self.attachment_view()
209            .map(|view| view.texture().height())
210            .unwrap_or(1)
211    }
212
213    /// The view the attachment dimensions are read from: the MSAA view when
214    /// one is set, the color view otherwise, the depth view last.
215    fn attachment_view(&self) -> Option<&TextureView> {
216        self.msaa_view
217            .as_ref()
218            .or(self.color_view.as_ref())
219            .or(self.depth_stencil_view.as_ref())
220    }
221
222    /// Whether the depth attachment is transient — created with
223    /// [`TextureUsages::TRANSIENT_ATTACHMENT`](wgpu::TextureUsages::TRANSIENT_ATTACHMENT)
224    /// — and so only accepts `Clear + Discard` within a single pass. `false`
225    /// when there is no depth attachment.
226    fn depth_is_transient(&self) -> bool {
227        self.depth_stencil_view.as_ref().is_some_and(|view| {
228            view.texture()
229                .usage()
230                .contains(wgpu::TextureUsages::TRANSIENT_ATTACHMENT)
231        })
232    }
233
234    /// Begin a render pass over the attachments.
235    ///
236    /// Each `*_load` value is the load op of its attachment:
237    /// [`wgpu::LoadOp::Clear(value)`](wgpu::LoadOp) clears it to `value`,
238    /// [`wgpu::LoadOp::Load`] keeps its previous contents. The color
239    /// attachment discards on store with MSAA (the resolve carries the pixels
240    /// into the color view) and stores without it (the color view *is* the
241    /// attachment). The depth attachment discards after a clear and stores
242    /// after a load: a transient depth texture (see
243    /// `depth_is_transient`) only accepts `Clear + Discard`, while a
244    /// persistent depth texture — a shadow map, a preprocessed depth buffer —
245    /// keeps the results for a later pass to sample. The stencil aspect
246    /// follows the same rules (the renderer never writes it, so `Clear`
247    /// clears it and `Load` leaves a persistent buffer's contents alone).
248    ///
249    /// # Panics
250    /// If the attachment set has no attachments, if a clear value is given
251    /// without the matching attachment, or if an existing attachment is given
252    /// no op.
253    pub fn begin_pass<'a>(
254        &'a self,
255        encoder: &'a mut wgpu::CommandEncoder,
256        color_load: wgpu::LoadOp<wgpu::Color>,
257        depth_load: wgpu::LoadOp<f32>,
258        stencil_load: wgpu::LoadOp<u32>,
259    ) -> wgpu::RenderPass<'a> {
260        assert!(
261            self.color_view.is_some() || self.depth_stencil_view.is_some(),
262            "a render pass needs at least one attachment"
263        );
264        if matches!(depth_load, wgpu::LoadOp::Load) {
265            assert!(
266                !self.depth_is_transient(),
267                "the depth texture is transient: load its previous contents \
268                 only on a persistent texture"
269            );
270        }
271        if matches!(stencil_load, wgpu::LoadOp::Load)
272            && self
273                .depth_stencil_view
274                .as_ref()
275                .is_some_and(|view| view.texture().format().has_stencil_aspect())
276        {
277            assert!(
278                !self.depth_is_transient(),
279                "the stencil is transient: load its previous contents only on \
280                 a persistent texture"
281            );
282        }
283
284        // With MSAA, the pass draws into the multisample view and resolves
285        // into the color view.
286        let resolve_target = self
287            .msaa_view
288            .as_ref()
289            .zip(self.color_view.as_ref())
290            .map(|(_, view)| view.view().clone());
291        let attachment_view = self
292            .msaa_view
293            .as_ref()
294            .or(self.color_view.as_ref())
295            .map(|view| view.view().clone());
296
297        let color_attachments = attachment_view
298            .as_ref()
299            .map(|view| {
300                [Some(wgpu::RenderPassColorAttachment {
301                    view,
302                    depth_slice: None,
303                    resolve_target: resolve_target.as_ref(),
304                    ops: wgpu::Operations {
305                        load: color_load,
306                        // With MSAA the resolved pixels reach the color
307                        // view through the resolve, so the multisample
308                        // attachment itself can be discarded. Without MSAA
309                        // the color view *is* the attachment: the frame is
310                        // only there if it is stored.
311                        store: if self.msaa_view.is_some() {
312                            wgpu::StoreOp::Discard
313                        } else {
314                            wgpu::StoreOp::Store
315                        },
316                    },
317                })]
318            })
319            .unwrap_or([None]);
320
321        let depth_stencil_attachment = self.depth_stencil_view.as_ref().map(|view| {
322            // A stencil-capable format must be given stencil load/store ops.
323            // The renderer never writes stencil, so it is discarded either
324            // way.
325            let stencil_ops =
326                view.texture()
327                    .format()
328                    .has_stencil_aspect()
329                    .then_some(wgpu::Operations {
330                        load: stencil_load,
331                        store: wgpu::StoreOp::Discard,
332                    });
333            wgpu::RenderPassDepthStencilAttachment {
334                view: view.view(),
335                depth_ops: Some(wgpu::Operations {
336                    load: depth_load,
337                    // A transient depth texture only accepts `Clear +
338                    // Discard`; a persistent depth texture stores, so a
339                    // later pass can sample the results.
340                    store: if self.depth_is_transient() {
341                        wgpu::StoreOp::Discard
342                    } else {
343                        wgpu::StoreOp::Store
344                    },
345                }),
346                stencil_ops,
347            }
348        });
349
350        encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
351            label: Some("unlit_wgpu::pass"),
352            color_attachments: &color_attachments,
353            depth_stencil_attachment,
354            timestamp_writes: None,
355            occlusion_query_set: None,
356            multiview_mask: None,
357        })
358    }
359}
360
361/// Allocate a persistent color texture for a render target.
362///
363/// It is created with
364/// [`RENDER_ATTACHMENT`](wgpu::TextureUsages::RENDER_ATTACHMENT),
365/// [`COPY_SRC`](wgpu::TextureUsages::COPY_SRC) so the frame can be read back,
366/// and [`TEXTURE_BINDING`](wgpu::TextureUsages::TEXTURE_BINDING) so a later
367/// pass can sample it. A frame that draws into it pairs it with the depth and
368/// multisample textures [`create_depth_target`] and [`create_msaa_target`]
369/// allocate.
370pub fn create_color_target(
371    device: &wgpu::Device,
372    format: wgpu::TextureFormat,
373    width: u32,
374    height: u32,
375) -> wgpu::Texture {
376    device.create_texture(&wgpu::TextureDescriptor {
377        label: Some("unlit_wgpu::color"),
378        size: wgpu::Extent3d {
379            width: width.max(1),
380            height: height.max(1),
381            depth_or_array_layers: 1,
382        },
383        mip_level_count: 1,
384        sample_count: 1,
385        dimension: wgpu::TextureDimension::D2,
386        format,
387        usage: wgpu::TextureUsages::RENDER_ATTACHMENT
388            | wgpu::TextureUsages::COPY_SRC
389            | wgpu::TextureUsages::TEXTURE_BINDING,
390        view_formats: &[],
391    })
392}
393
394/// Allocate a transient depth-stencil texture in the format
395/// [`default_depth_stencil_format`] picks for `device`.
396///
397/// It is created with
398/// [`RENDER_ATTACHMENT`](wgpu::TextureUsages::RENDER_ATTACHMENT) and
399/// [`TRANSIENT_ATTACHMENT`](wgpu::TextureUsages::TRANSIENT_ATTACHMENT): it is
400/// cleared and discarded inside the frame's single pass, so nothing is ever
401/// read back from it. `sample_count` must be the color attachment's, which
402/// this one is paired with in the pass.
403pub fn create_depth_target(
404    device: &wgpu::Device,
405    width: u32,
406    height: u32,
407    sample_count: u32,
408) -> wgpu::Texture {
409    device.create_texture(&wgpu::TextureDescriptor {
410        label: Some("unlit_wgpu::depth"),
411        size: wgpu::Extent3d {
412            width: width.max(1),
413            height: height.max(1),
414            depth_or_array_layers: 1,
415        },
416        mip_level_count: 1,
417        sample_count,
418        dimension: wgpu::TextureDimension::D2,
419        format: default_depth_stencil_format(device),
420        usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TRANSIENT_ATTACHMENT,
421        view_formats: &[],
422    })
423}
424
425/// Allocate a transient multisample texture in `format`.
426///
427/// It resolves into the color attachment, so `format` and `sample_count`
428/// must be that attachment's. Like the depth target it is transient: it is
429/// written and resolved inside the frame's single pass and never read back.
430pub fn create_msaa_target(
431    device: &wgpu::Device,
432    format: wgpu::TextureFormat,
433    width: u32,
434    height: u32,
435    sample_count: u32,
436) -> wgpu::Texture {
437    device.create_texture(&wgpu::TextureDescriptor {
438        label: Some("unlit_wgpu::msaa"),
439        size: wgpu::Extent3d {
440            width: width.max(1),
441            height: height.max(1),
442            depth_or_array_layers: 1,
443        },
444        mip_level_count: 1,
445        sample_count,
446        dimension: wgpu::TextureDimension::D2,
447        format,
448        usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TRANSIENT_ATTACHMENT,
449        view_formats: &[],
450    })
451}
452
453/// Textures a frame loop allocates for offscreen rendering.
454///
455/// Created by [`create_render_target`]: a persistent color texture (read back
456/// or presented by the caller), plus transient depth and multisample textures
457/// that are discarded inside a single pass.
458pub struct FrameTextures {
459    /// The persistent color texture the frame renders into.
460    pub color: wgpu::Texture,
461    /// The transient depth texture.
462    pub depth: wgpu::Texture,
463    /// The transient multisample texture, `None` for a non‑multisampled pass.
464    pub msaa: Option<wgpu::Texture>,
465    /// The attachment set built from the three textures above.
466    pub attachments: RenderAttachments,
467}
468
469/// Allocate a persistent color texture, a transient depth texture, and — when
470/// `sample_count > 1` — a transient multisample texture, each with a matching
471/// view, and assemble them into a [`RenderAttachments`].
472///
473/// The color texture is created with
474/// [`TextureUsages::RENDER_ATTACHMENT`](wgpu::TextureUsages::RENDER_ATTACHMENT),
475/// [`TextureUsages::COPY_SRC`](wgpu::TextureUsages::COPY_SRC) so the caller
476/// can read the frame back, and
477/// [`TextureUsages::TEXTURE_BINDING`](wgpu::TextureUsages::TEXTURE_BINDING) so
478/// a later pass can sample it. The depth and multisample textures use
479/// [`TextureUsages::RENDER_ATTACHMENT`](wgpu::TextureUsages::RENDER_ATTACHMENT) |
480/// [`TRANSIENT_ATTACHMENT`](wgpu::TextureUsages::TRANSIENT_ATTACHMENT). The
481/// depth format is [`default_depth_stencil_format`]'s choice for `device`.
482///
483/// The returned [`FrameTextures`] gives the caller ownership of every texture,
484/// so they can register views in a [`crate::resources::ResourceGraph`] or
485/// otherwise manage lifetimes beyond the attachment set's borrow.
486pub fn create_render_target(
487    device: &wgpu::Device,
488    format: wgpu::TextureFormat,
489    width: u32,
490    height: u32,
491    sample_count: u32,
492) -> FrameTextures {
493    let color = create_color_target(device, format, width, height);
494    let color_view = TextureExt::create_view(&color, &wgpu::TextureViewDescriptor::default());
495    let depth = create_depth_target(device, width, height, sample_count);
496    let depth_view = TextureExt::create_view(&depth, &wgpu::TextureViewDescriptor::default());
497    let msaa =
498        (sample_count > 1).then(|| create_msaa_target(device, format, width, height, sample_count));
499    let msaa_view = msaa
500        .as_ref()
501        .map(|tex| TextureExt::create_view(tex, &wgpu::TextureViewDescriptor::default()));
502    let attachments = RenderAttachments::from_views(Some(color_view), Some(depth_view), msaa_view);
503    FrameTextures {
504        color,
505        depth,
506        msaa,
507        attachments,
508    }
509}