unlit3d 0.1.0-dev.2

The high-level WebGPU rendering API over unlit_wgpu and unlit_ecs: ECS components, frame sources, input, UI and winit presentation.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
//! Presenting a [`Renderer`] into a [winit](https://docs.rs/winit) window.
//!
//! A window presents through a swap chain, which the frame's own attachment
//! helpers do not cover: every frame hands out a new color image, and the
//! attachments the frame draws with have to match the window's size.
//! [`WindowSurface`] owns the surface and its configuration, keeps the frame's
//! color image registered in the frame's resource graph, and hands it back from
//! [`WindowSurface::acquire`]. The depth and multisample attachments belong to
//! the caller — see [`FrameAttachments`](crate::attachments::FrameAttachments)
//! — so a frame loop is acquire, bind, render, present.
//!
//! The caller creates the `wgpu::Surface` itself, because the adapter has to
//! be requested with it as the compatible surface. That request is async —
//! on the web it resolves on the browser's task queue, so a frame loop must not
//! block on it — and so is the device's, which leaves both to the caller:
//!
//! ```rust
//! use std::sync::Arc;
//!
//! use unlit3d::attachments::FrameAttachments;
//! use unlit3d::prelude::*;
//! use unlit3d::winit::WindowSurface;
//! use winit::window::Window;
//!
//! # fn setup(
//! #     world: &World,
//! #     renderer: Entity,
//! #     instance: &wgpu::Instance,
//! #     adapter: &wgpu::Adapter,
//! #     window: Arc<Window>,
//! #     surface: wgpu::Surface<'static>,
//! # ) -> (WindowSurface, FrameAttachments) {
//! // The renderer the surface draws through, spawned once as a resource.
//! let window_surface = world
//!     .with_mut::<Renderer, _>(renderer, |r| {
//!         WindowSurface::new(world, r, instance, adapter, window, surface)
//!     })
//!     .unwrap();
//! // The depth and multisample attachments the frame draws with; `true` asks
//! // for a depth-stencil one, which a frame of depth-tested geometry needs.
//! let context = world.get::<Renderer>(renderer).unwrap().context();
//! let attachments = FrameAttachments::new(
//!     world,
//!     context,
//!     window_surface.color_format(),
//!     window_surface.size(),
//!     4,
//!     true,
//! );
//! (window_surface, attachments)
//! # }
//! ```
//!
//! and the frame loop then only has to bind, render and present:
//!
//! ```rust
//! # use unlit3d::attachments::FrameAttachments;
//! # use unlit3d::prelude::*;
//! # use unlit3d::winit::WindowSurface;
//! # fn frame(
//! #     world: &World,
//! #     queue: &wgpu::Queue,
//! #     renderer_entity: Entity,
//! #     window_surface: &mut WindowSurface,
//! #     attachments: &FrameAttachments,
//! # ) {
//! let Some(frame) = world
//!     .with_mut::<Renderer, _>(renderer_entity, |r| window_surface.acquire(world, r))
//!     .unwrap()
//! else {
//!     return;
//! };
//! world
//!     .with_mut::<Renderer, _>(renderer_entity, |r| {
//!         attachments.bind(world, r, frame.color_view().clone());
//!     })
//!     .unwrap();
//! world
//!     .with_mut::<Renderer, _>(renderer_entity, |r| r.render(world))
//!     .unwrap();
//! frame.present(queue);
//! # }
//! ```

use std::sync::Arc;

use unlit_ecs::World;
use unlit_wgpu::resources::{ResHandle, TextureExt, TextureView};

use crate::renderer::Renderer;

/// The device the frame's context was created with, cloned out of `world`.
///
/// # Panics
///
/// If the context's device resource is gone.
fn context_device(world: &World, renderer: &Renderer) -> wgpu::Device {
    world
        .get::<wgpu::Device>(renderer.context().device)
        .expect("the context's device resource exists")
        .clone()
}

/// The frame's resource graph.
///
/// # Panics
///
/// If the context's graph resource is gone.
fn context_graph<'w>(
    world: &'w World,
    renderer: &Renderer,
) -> impl core::ops::DerefMut<Target = unlit_wgpu::resources::ResourceGraph> + 'w {
    world
        .get_mut::<unlit_wgpu::resources::ResourceGraph>(renderer.context().graph)
        .expect("the context's resource graph exists")
}

/// A winit window's swap chain, presented through a [`Renderer`].
///
/// The surface is configured for the window's current size and keeps the
/// frame's color image registered in the frame's resource graph.
/// [`Self::acquire`] hands back the frame's color view for the caller to bind
/// with its own depth and multisample attachments — see
/// [`FrameAttachments`](crate::attachments::FrameAttachments) — and
/// [`Self::resize`] keeps the surface in step with the window.
///
/// A platform can invalidate the render surface while the app is not in the
/// foreground, and Android does for as long as it is suspended. [`Self::release`]
/// is for that: it drops the swap chain and its color view without touching the
/// device, the pipelines or anything else the frame draws from, so a scene keeps
/// its state and presents again through a surface built from the same window.
pub struct WindowSurface {
    instance: wgpu::Instance,
    window: Arc<::winit::window::Window>,
    surface: wgpu::Surface<'static>,
    config: wgpu::SurfaceConfiguration,
    /// The format the frame's color attachment is viewed as.
    ///
    /// Usually the swap chain's own format; an sRGB view of it when the
    /// surface offers no sRGB format of its own.
    color_format: wgpu::TextureFormat,
    /// The color view of the most recently acquired frame, in the frame's
    /// graph. One id is kept for the surface's whole life; `None` until the
    /// first frame is acquired.
    color_view: Option<ResHandle<TextureView>>,
}

impl WindowSurface {
    /// Configure `surface` for `window`.
    ///
    /// `surface` must have been created from `window`, and `adapter` must be
    /// able to present to it — the adapter the renderer's device was requested
    /// from.
    ///
    /// The surface format is the first sRGB format the surface supports. A
    /// surface that supports none — the web's canvas does not — is configured
    /// with a non-sRGB format instead and its frames are viewed as sRGB, which
    /// encodes them on write in exactly the same way. An sRGB attachment is
    /// what lets the built-in unlit shader's colors reach the display
    /// unchanged.
    ///
    /// # Panics
    ///
    /// If `adapter` cannot present to `surface`.
    pub fn new(
        world: &World,
        renderer: &mut Renderer,
        instance: &wgpu::Instance,
        adapter: &wgpu::Adapter,
        window: Arc<::winit::window::Window>,
        surface: wgpu::Surface<'static>,
    ) -> Self {
        let size = window.inner_size();
        let config = surface_config(&surface, adapter, size.width, size.height);
        let color_format = color_format(&config);
        let device = context_device(world, renderer);
        surface.configure(&device, &config);

        Self {
            instance: instance.clone(),
            window,
            surface,
            config,
            color_format,
            color_view: None,
        }
    }

    /// The size the surface is configured for, in physical pixels.
    pub fn size(&self) -> (u32, u32) {
        (self.config.width, self.config.height)
    }

    /// The format the swap chain presents in.
    ///
    /// A surface that offers no sRGB format of its own still presents as one:
    /// the frames are viewed as [`Self::color_format`], which differs from
    /// this.
    pub fn format(&self) -> wgpu::TextureFormat {
        self.config.format
    }

    /// The format the frame's color attachment is viewed as — the format the
    /// render target's pipelines are specialized for.
    pub fn color_format(&self) -> wgpu::TextureFormat {
        self.color_format
    }

    /// The window the surface presents into.
    ///
    /// The surface owns a handle to it, so a caller that needs the window —
    /// to request a redraw, or to read its size — can borrow it back here.
    pub fn window(&self) -> &Arc<::winit::window::Window> {
        &self.window
    }

    /// Reconfigure the surface for a new size.
    ///
    /// A zero width or height — a minimized window — is ignored: a surface
    /// cannot be configured with a zero dimension, and there is nothing to
    /// draw.
    pub fn resize(&mut self, world: &World, renderer: &mut Renderer, width: u32, height: u32) {
        if width == 0 || height == 0 {
            return;
        }
        if (width, height) == (self.config.width, self.config.height) {
            return;
        }
        self.config.width = width;
        self.config.height = height;
        let device = context_device(world, renderer);
        self.surface.configure(&device, &self.config);
    }

    /// Release the swap chain.
    ///
    /// Drops the surface and the color view it registered in the frame's
    /// graph, so nothing is left naming a swap chain that is gone. The device,
    /// the pipelines and everything else the scene draws from are untouched:
    /// the caller's own window presents the same scene again through a surface
    /// built from it.
    ///
    /// This is what a suspension needs. Android invalidates the native surface
    /// for as long as the app is not in the foreground, which outlives the swap
    /// chain but not the window, the scene or the GPU context.
    pub fn release(self, world: &World, renderer: &mut Renderer) {
        // Giving up the id is the whole removal. Dropping it — with the
        // surface whose images it views — leaves the graph's pass below to
        // collect the view, before anything can be left naming a swap chain
        // that is gone.
        drop(self);
        context_graph(world, renderer).maintain();
    }

    /// Acquire the next frame.
    ///
    /// Returns `None` when the frame should be skipped — the window is
    /// occluded or minimized, or the swap chain had to be reconfigured. The
    /// caller must not render in that case.
    ///
    /// The frame's image replaces the previous one in the resource graph, so
    /// at most the frame just presented is still referenced — never a longer
    /// history of swap-chain images. The caller binds the returned frame's
    /// color view as its render target, together with whatever depth and
    /// multisample attachments it draws with.
    pub fn acquire(&mut self, world: &World, renderer: &mut Renderer) -> Option<Frame> {
        let device = context_device(world, renderer);
        let surface_texture = match self.surface.get_current_texture() {
            wgpu::CurrentSurfaceTexture::Success(texture)
            | wgpu::CurrentSurfaceTexture::Suboptimal(texture) => texture,
            wgpu::CurrentSurfaceTexture::Outdated => {
                self.surface.configure(&device, &self.config);
                return None;
            }
            wgpu::CurrentSurfaceTexture::Lost => {
                self.recreate_surface(&device);
                return None;
            }
            wgpu::CurrentSurfaceTexture::Timeout
            | wgpu::CurrentSurfaceTexture::Occluded
            | wgpu::CurrentSurfaceTexture::Validation => return None,
        };

        // The color view keeps one id for the surface's whole life: replacing
        // the resource swaps in the new frame's view and drops the previous
        // one, and a skipped frame leaves the id untouched.
        let view = color_view_resource(&surface_texture.texture, self.color_format);
        let color = match self.color_view.as_ref() {
            Some(id) => {
                context_graph(world, renderer)
                    .replace(id, view)
                    .expect("the color view is in the graph");
                id.clone()
            }
            None => context_graph(world, renderer).insert(view, None),
        };
        self.color_view = Some(color.clone());
        // Replacing the color view marked every reader dirty; drop the old
        // frame's view now rather than holding it until the next scene build.
        context_graph(world, renderer).maintain();
        Some(Frame {
            surface_texture,
            color_view: color,
        })
    }

    /// Rebuild the surface from the window after the swap chain was lost.
    ///
    /// A surface that cannot be rebuilt — the window is gone — leaves the
    /// current one in place, and the next acquire retries.
    fn recreate_surface(&mut self, device: &wgpu::Device) {
        if let Ok(surface) = self.instance.create_surface(self.window.clone()) {
            self.surface = surface;
            self.surface.configure(device, &self.config);
        }
    }
}

/// A swap-chain image acquired for one frame.
pub struct Frame {
    surface_texture: wgpu::SurfaceTexture,
    color_view: ResHandle<TextureView>,
}

impl Frame {
    /// The image this frame draws into.
    pub fn texture(&self) -> &wgpu::Texture {
        &self.surface_texture.texture
    }

    /// The frame's color view, in the frame's resource graph.
    ///
    /// This is what a caller binds as its render target — together with the
    /// depth and multisample views its own attachments hold.
    pub fn color_view(&self) -> &ResHandle<TextureView> {
        &self.color_view
    }

    /// Present the frame.
    pub fn present(self, queue: &wgpu::Queue) {
        queue.present(self.surface_texture);
    }
}

/// The configuration to present `surface` with on `adapter`.
///
/// # Panics
///
/// If `adapter` cannot present to `surface`.
fn surface_config(
    surface: &wgpu::Surface<'_>,
    adapter: &wgpu::Adapter,
    width: u32,
    height: u32,
) -> wgpu::SurfaceConfiguration {
    let caps = surface.get_capabilities(adapter);
    let view_formats_supported = adapter
        .get_downlevel_capabilities()
        .flags
        .contains(wgpu::DownlevelFlags::SURFACE_VIEW_FORMATS);
    let (format, view_formats) = surface_format(&caps.formats, view_formats_supported);
    wgpu::SurfaceConfiguration {
        usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
        format,
        color_space: wgpu::SurfaceColorSpace::Auto,
        // A surface cannot be configured with a zero dimension.
        width: width.max(1),
        height: height.max(1),
        present_mode: caps
            .present_modes
            .first()
            .copied()
            .unwrap_or(wgpu::PresentMode::Fifo),
        alpha_mode: caps
            .alpha_modes
            .first()
            .copied()
            .unwrap_or(wgpu::CompositeAlphaMode::Auto),
        view_formats,
        desired_maximum_frame_latency: 2,
    }
}

/// The format to configure a surface whose capabilities offer `formats` with,
/// together with the view formats to advertise alongside it.
///
/// The format is the first sRGB one. When the surface offers none, a format
/// with an sRGB counterpart is configured and that counterpart advertised as a
/// view — so the hardware still encodes the shader's output on write and the
/// display sees what an sRGB surface would have shown it.
///
/// Advertising that view needs
/// [`wgpu::DownlevelFlags::SURFACE_VIEW_FORMATS`], which the GLES and WebGL2
/// backends lack, so `view_formats_supported` gates it. Without it the frame is
/// rendered into the presented format itself: the built-in shaders write
/// sRGB-encoded colors, and a non-sRGB target passes them through unconverted,
/// which is the same result by a different route. A surface that is neither
/// sRGB nor has an sRGB counterpart — an fp16 one, say — is presented as it is.
///
/// # Panics
///
/// If `formats` is empty, which means the adapter cannot present to the
/// surface at all.
fn surface_format(
    formats: &[wgpu::TextureFormat],
    view_formats_supported: bool,
) -> (wgpu::TextureFormat, Vec<wgpu::TextureFormat>) {
    let srgb = formats.iter().copied().find(wgpu::TextureFormat::is_srgb);
    let format = srgb
        .or_else(|| {
            // A format that has an sRGB counterpart, so the frames can still be
            // encoded by hardware; the surface's own preference comes first.
            formats
                .iter()
                .copied()
                .find(|format| format.add_srgb_suffix() != *format)
        })
        .unwrap_or_else(|| {
            // Neither sRGB nor viewable as one. The frames are then rendered in
            // this format itself.
            *formats
                .first()
                .expect("the adapter can present to the surface")
        });
    // A format that already encodes sRGB needs no view, nor does one whose
    // backend cannot be given a view at all. `add_srgb_suffix` returns the
    // format itself when it has no counterpart, which the filter drops.
    let view_formats = (view_formats_supported && !format.is_srgb())
        .then(|| format.add_srgb_suffix())
        .filter(|srgb| *srgb != format)
        .into_iter()
        .collect();
    (format, view_formats)
}

/// The format a frame's color attachment is viewed as, for a surface
/// configured with `config`.
///
/// The swap chain's own format when it encodes sRGB. When it does not — the
/// web's canvas offers only non-sRGB formats, and writes to those are taken as
/// already encoded — the swap chain is configured with an sRGB view of the
/// format, so the hardware encodes the shader's linear output on write and the
/// display sees exactly what an sRGB swap chain would have shown it.
///
/// A backend that cannot be given that view has none to pick, so the frame is
/// rendered in the presented format and the shader's own values reach the
/// display unconverted — which is the right thing for the sRGB-encoded colors
/// the built-in shaders write, and why this falls back to `config.format`
/// rather than refusing to pick one.
fn color_format(config: &wgpu::SurfaceConfiguration) -> wgpu::TextureFormat {
    if config.format.is_srgb() {
        return config.format;
    }
    config
        .view_formats
        .iter()
        .copied()
        .find(|format| *format != config.format)
        .unwrap_or(config.format)
}

/// A graph view of `texture` reinterpreted as `format`.
///
/// Used for the swap chain's color image, which the surface configures with a
/// non-sRGB format on the web: the view is what makes the frame sRGB-encoded.
/// The format goes into the descriptor, so the view and the format the graph
/// records cannot disagree.
fn color_view_resource(texture: &wgpu::Texture, format: wgpu::TextureFormat) -> TextureView {
    TextureExt::create_view(
        texture,
        &wgpu::TextureViewDescriptor {
            format: Some(format),
            ..Default::default()
        },
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The formats a GLES or WebGL2 surface offers: the non-sRGB pair, with
    /// the sRGB one added only when the platform's frame buffer can encode.
    const GLES_FORMATS: &[wgpu::TextureFormat] = &[
        wgpu::TextureFormat::Rgba8Unorm,
        wgpu::TextureFormat::Bgra8Unorm,
    ];

    /// A configuration with every field but the format at a harmless value, so
    /// a test can vary only what it is about.
    fn surface_configuration(format: wgpu::TextureFormat) -> wgpu::SurfaceConfiguration {
        wgpu::SurfaceConfiguration {
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
            format,
            color_space: wgpu::SurfaceColorSpace::Auto,
            width: 1,
            height: 1,
            present_mode: wgpu::PresentMode::Fifo,
            alpha_mode: wgpu::CompositeAlphaMode::Auto,
            view_formats: Vec::new(),
            desired_maximum_frame_latency: 2,
        }
    }

    #[test]
    fn an_srgb_surface_format_is_used_as_it_is() {
        let formats = [
            wgpu::TextureFormat::Bgra8Unorm,
            wgpu::TextureFormat::Bgra8UnormSrgb,
        ];

        // Already sRGB, so there is nothing to view it as, on either backend.
        for supported in [true, false] {
            let (format, views) = surface_format(&formats, supported);
            assert_eq!(format, wgpu::TextureFormat::Bgra8UnormSrgb);
            assert!(views.is_empty(), "an sRGB format needs no view");
        }
    }

    #[test]
    fn a_surface_without_srgb_is_viewed_as_srgb_where_views_are_supported() {
        let (format, views) = surface_format(GLES_FORMATS, true);

        assert_eq!(format, wgpu::TextureFormat::Rgba8Unorm);
        assert_eq!(views, vec![wgpu::TextureFormat::Rgba8UnormSrgb]);
    }

    #[test]
    fn a_surface_without_srgb_advertises_no_view_where_they_are_unsupported() {
        // GLES and WebGL2 have no `SURFACE_VIEW_FORMATS`, so configuring one
        // with a view is rejected outright. The frame is then rendered into the
        // presented format, which `color_format` follows.
        let (format, views) = surface_format(GLES_FORMATS, false);

        assert_eq!(format, wgpu::TextureFormat::Rgba8Unorm);
        assert!(views.is_empty());
        let config = wgpu::SurfaceConfiguration {
            format,
            view_formats: views,
            ..surface_configuration(format)
        };
        assert_eq!(color_format(&config), wgpu::TextureFormat::Rgba8Unorm);
    }

    #[test]
    fn a_surface_with_no_srgb_counterpart_is_presented_as_it_is() {
        // An fp16 surface: neither sRGB nor viewable as one.
        let formats = [wgpu::TextureFormat::Rgba16Float];

        for supported in [true, false] {
            let (format, views) = surface_format(&formats, supported);
            assert_eq!(format, wgpu::TextureFormat::Rgba16Float);
            assert!(views.is_empty(), "there is no sRGB view of an fp16 format");
        }
    }
}