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}