Skip to main content

frus_gpu/
renderer.rs

1//! The renderer bound to a surface (a window): it configures wgpu and delegates
2//! primitive drawing to the [`Painter`].
3
4use frus_core::Scene;
5
6use crate::compositor::{preferred_sample_count, Painters};
7
8/// The background colour, a midnight blue.
9const CLEAR_COLOR: wgpu::Color = wgpu::Color {
10    r: 0.05,
11    g: 0.05,
12    b: 0.08,
13    a: 1.0,
14};
15
16/// **The window's background** where nothing is drawn — the clear colour, as a scene colour —
17/// for a [`see-through`](Renderer::see_through) renderer, whose frames start transparent and
18/// whose owner paints it where the window is not meant to be seen through (milestone 643).
19pub fn backdrop() -> frus_core::Color {
20    // The clear colour is linear; a scene colour is sRGB.
21    frus_core::Color::rgb(
22        CLEAR_COLOR.r as f32,
23        CLEAR_COLOR.g as f32,
24        CLEAR_COLOR.b as f32,
25    )
26    .to_srgb()
27}
28
29/// Where the compiled pipelines are kept between launches, where the device can keep them
30/// (Vulkan): set by the shell — on Android the application's own storage — before the
31/// renderer is made (milestone 685, #217).
32static PIPELINE_CACHE_DIR: std::sync::Mutex<Option<std::path::PathBuf>> =
33    std::sync::Mutex::new(None);
34
35/// Keeps the renderer's compiled pipelines in `dir` between launches, where the device can: the
36/// next start reads them instead of compiling them again (Vulkan; nothing elsewhere).
37pub fn set_pipeline_cache_dir(dir: std::path::PathBuf) {
38    if let Ok(mut slot) = PIPELINE_CACHE_DIR.lock() {
39        *slot = Some(dir);
40    }
41}
42
43/// Holds the GPU state bound to a surface and presents the frames.
44pub struct Renderer {
45    surface: wgpu::Surface<'static>,
46    device: wgpu::Device,
47    queue: wgpu::Queue,
48    config: wgpu::SurfaceConfiguration,
49    /// The format the frames are drawn in: the surface's own when it is sRGB, its sRGB twin
50    /// (a view of the same image) when the surface offers none, as a WebGPU canvas (#234).
51    render_format: wgpu::TextureFormat,
52    painters: Painters,
53    /// What making it took, phase by phase.
54    startup: StartupTimes,
55    /// Set by the device when it is lost.
56    lost: std::sync::Arc<std::sync::atomic::AtomicBool>,
57    /// Where the last frame's rendering spent its time.
58    timings: RenderTimings,
59    /// The GPU's own timing of each frame, where the device has a clock (milestone 612).
60    gpu_timer: Option<crate::gpu_timer::GpuTimer>,
61    /// Whether the frames are composed with what is behind the window (milestone 643).
62    see_through: bool,
63}
64
65impl Renderer {
66    /// Initialises the GPU context for a given surface.
67    ///
68    /// `target` is typically an `Arc<Window>` supplied by the platform layer.
69    /// `width` and `height` must both be > 0.
70    pub async fn new(
71        target: impl Into<wgpu::SurfaceTarget<'static>>,
72        width: u32,
73        height: u32,
74    ) -> anyhow::Result<Self> {
75        Self::make(target, width, height, false).await
76    }
77
78    /// **A renderer whose frames let what is behind the window show** where they are
79    /// transparent (milestone 643): its frames start transparent, are composed with the
80    /// window's surroundings by the system's compositor, and are made of premultiplied
81    /// colours — which is what this renderer's blending produces over a transparent start.
82    ///
83    /// On Windows it presents through DirectComposition (Direct3D 12): where a frame is
84    /// transparent on the title bar's line, the system's own caption shows — its backdrop
85    /// and its three buttons. Fails where that is not available — another system, no
86    /// Direct3D 12 adapter, no premultiplied composition — and the caller then makes an
87    /// ordinary [`new`](Self::new) one. Its owner paints [`backdrop`] wherever the window is
88    /// not meant to be seen through.
89    pub async fn see_through(
90        target: impl Into<wgpu::SurfaceTarget<'static>>,
91        width: u32,
92        height: u32,
93    ) -> anyhow::Result<Self> {
94        Self::make(target, width, height, true).await
95    }
96
97    /// Whether this renderer's frames let what is behind the window show where they are
98    /// transparent: made by [`see_through`](Self::see_through).
99    pub fn sees_through(&self) -> bool {
100        self.see_through
101    }
102
103    async fn make(
104        target: impl Into<wgpu::SurfaceTarget<'static>>,
105        width: u32,
106        height: u32,
107        see_through: bool,
108    ) -> anyhow::Result<Self> {
109        if see_through && !cfg!(windows) {
110            anyhow::bail!("a see-through window is composed by DirectComposition, on Windows");
111        }
112        let started = web_time::Instant::now();
113        let mut descriptor = wgpu::InstanceDescriptor {
114            backends: wgpu::Backends::PRIMARY,
115            ..wgpu::InstanceDescriptor::new_without_display_handle()
116        };
117        if see_through {
118            // Presented through a DirectComposition visual made from the window: the one
119            // way a Direct3D swap chain is composed with what is behind it.
120            descriptor.backends = wgpu::Backends::DX12;
121            descriptor.backend_options.dx12.presentation_system =
122                wgpu::Dx12SwapchainKind::DxgiFromVisual;
123        }
124        // No debug names for GPU objects on Android: a debug build asks for them, and the
125        // emulator's Vulkan driver crashes in `vkSetDebugUtilsObjectNameEXT` as the device is
126        // made (a release build never asks). `WGPU_DEBUG=1` still asks for them on a device.
127        #[cfg(target_os = "android")]
128        if std::env::var_os("WGPU_DEBUG").is_none() {
129            descriptor.flags.remove(wgpu::InstanceFlags::DEBUG);
130        }
131        // Android and Linux draw through Vulkan alone. Where there is none — an old device, a
132        // driver missing — or only the Android emulator's (gfxstream, which crashes in its own
133        // code at the first submission), the GPU is reached through OpenGL ES instead
134        // (milestones 681, 686).
135        #[cfg(any(target_os = "android", target_os = "linux"))]
136        if let Some(why) = vulkan_unusable(descriptor.flags).await {
137            log::info!("{why}: OpenGL ES instead");
138            descriptor.backends = wgpu::Backends::GL;
139        }
140        // `WGPU_BACKEND` (`vulkan`, `gl`, `dx12`, `metal`, `webgpu`, comma-separated) decides
141        // over all of the above: a device whose driver frus's choice fails on can be given
142        // another backend without rebuilding the application (milestone 686, #217).
143        if let Some(backends) = wgpu::Backends::from_env() {
144            log::info!("GPU backends from WGPU_BACKEND: {backends:?}");
145            descriptor.backends = backends;
146        }
147        // DirectX 12 compiles the shaders with DXC when its DLL is beside the program —
148        // `frus run` and `frus build desktop` put it there —, several times faster than
149        // Windows' own compiler (FXC): about half a second of a start (milestone 684, #217).
150        #[cfg(windows)]
151        {
152            let dxc = std::env::current_exe()
153                .ok()
154                .and_then(|exe| Some(exe.parent()?.join("dxcompiler.dll")))
155                .filter(|dll| dll.is_file());
156            match dxc {
157                Some(dll) => {
158                    log::info!("DirectX 12 shaders: DXC ({})", dll.display());
159                    descriptor.backend_options.dx12.shader_compiler =
160                        wgpu::Dx12Compiler::DynamicDxc {
161                            dxc_path: dll.display().to_string(),
162                        };
163                }
164                None => log::info!(
165                    "DirectX 12 shaders: Windows' own compiler (no dxcompiler.dll beside the \
166                     program: a slower start)"
167                ),
168            }
169        }
170        let instance = wgpu::Instance::new(descriptor);
171
172        let surface = instance.create_surface(target)?;
173
174        let adapter = instance
175            .request_adapter(&wgpu::RequestAdapterOptions {
176                // The discrete GPU where there is a choice — but not in a browser, which
177                // ignores the hint and **says so** in the console (Chrome, on Windows) on
178                // every page load, for something the page cannot change.
179                power_preference: if cfg!(target_arch = "wasm32") {
180                    wgpu::PowerPreference::None
181                } else {
182                    wgpu::PowerPreference::HighPerformance
183                },
184                compatible_surface: Some(&surface),
185                force_fallback_adapter: false,
186                ..Default::default()
187            })
188            .await?;
189
190        log::info!("GPU adapter: {:?}", adapter.get_info());
191
192        // Downlevel limits, for GLES compatibility, but with the adapter's **real**
193        // resolution: on mobile a screen of, say, 1080x2340 exceeds the downlevel
194        // maximum texture size of 2048, and without this `surface.configure` panics.
195        // A GPU without compute shaders (OpenGL ES 3.0, as the Android emulator's) cannot give
196        // even the downlevel compute limits: the WebGL 2 ones, which ask for none.
197        let downlevel = if adapter
198            .get_downlevel_capabilities()
199            .flags
200            .contains(wgpu::DownlevelFlags::COMPUTE_SHADERS)
201        {
202            wgpu::Limits::downlevel_defaults()
203        } else {
204            wgpu::Limits::downlevel_webgl2_defaults()
205        };
206        let required_limits = downlevel.using_resolution(adapter.limits());
207
208        let adapter_time = started.elapsed();
209        let device_started = web_time::Instant::now();
210        let (device, queue) = adapter
211            .request_device(&wgpu::DeviceDescriptor {
212                label: Some("frus.device"),
213                // The GPU's own clock where it has one, for the frame statistics
214                // (milestone 612). Nothing is asked of a device that has not.
215                required_features: adapter.features()
216                    & (wgpu::Features::TIMESTAMP_QUERY | wgpu::Features::PIPELINE_CACHE),
217                required_limits,
218                memory_hints: wgpu::MemoryHints::Performance,
219                ..Default::default()
220            })
221            .await?;
222        let device_time = device_started.elapsed();
223        // A lost device — a driver reset, a GPU removed — says so here; the renderer then
224        // answers `DeviceLost`, and its owner makes a new one (milestone 686).
225        let lost = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
226        {
227            let lost = lost.clone();
228            device.set_device_lost_callback(move |reason, message| {
229                log::warn!("the GPU device was lost ({reason:?}): {message}");
230                lost.store(true, std::sync::atomic::Ordering::SeqCst);
231            });
232        }
233
234        let caps = surface.get_capabilities(&adapter);
235        let alpha_mode = if see_through {
236            if !caps
237                .alpha_modes
238                .contains(&wgpu::CompositeAlphaMode::PreMultiplied)
239            {
240                anyhow::bail!(
241                    "no premultiplied composition on this surface: {:?}",
242                    caps.alpha_modes
243                );
244            }
245            wgpu::CompositeAlphaMode::PreMultiplied
246        } else {
247            caps.alpha_modes[0]
248        };
249        let format = caps
250            .formats
251            .iter()
252            .copied()
253            .find(|f| f.is_srgb())
254            .unwrap_or(caps.formats[0]);
255        // The shaders write linear values, which an sRGB target encodes. A WebGPU canvas
256        // offers only plain formats (`bgra8unorm`): drawn as they are, the linear values are
257        // shown as if encoded, and every colour comes out darker (#234). The canvas lets its
258        // image be drawn through an sRGB view: the frames are drawn in that twin, and the
259        // hardware encodes, as everywhere else.
260        let render_format = format.add_srgb_suffix();
261        let view_formats = if render_format == format {
262            vec![]
263        } else {
264            vec![render_format]
265        };
266
267        let config = wgpu::SurfaceConfiguration {
268            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
269            format,
270            color_space: wgpu::SurfaceColorSpace::Auto,
271            width,
272            height,
273            // **Fifo, always** (milestone 610): one image per refresh of the display, in
274            // order. It was the first mode the surface offered, and on the test phone that
275            // was Mailbox, under which the application draws as fast as it can, the
276            // display shows whichever image is latest, and the rest are drawn for nothing:
277            // frames at 14 ms intervals on a 16.7 ms display, the battery spent on images
278            // never seen, and a motion that is not paced by the display. Fifo is the one
279            // mode every surface supports.
280            present_mode: wgpu::PresentMode::Fifo,
281            alpha_mode,
282            view_formats,
283            desired_maximum_frame_latency: 2,
284        };
285        log::info!(
286            "present mode: {:?}, of {:?}",
287            config.present_mode,
288            caps.present_modes
289        );
290        surface.configure(&device, &config);
291
292        // MSAA when the adapter supports it for this format; otherwise 1, disabled.
293        let sample_count = preferred_sample_count(&adapter, render_format);
294        log::info!("MSAA: {sample_count}×");
295
296        let pipelines_started = web_time::Instant::now();
297        let (pipeline_cache, cache_file, cached) = open_pipeline_cache(&adapter, &device);
298        let mut painters = Painters::new(
299            &device,
300            &queue,
301            render_format,
302            sample_count,
303            pipeline_cache.as_ref(),
304        );
305        let pipelines = pipelines_started.elapsed();
306        // Warms every pipeline before the first real frame, to avoid jank.
307        let warm_up_started = web_time::Instant::now();
308        painters.warm_up(&device, &queue, render_format);
309        if let (Some(cache), Some(file)) = (pipeline_cache.as_ref(), cache_file.as_ref()) {
310            keep_pipeline_cache(cache, file, cached.as_deref());
311        }
312        let startup = StartupTimes {
313            adapter: adapter_time,
314            device: device_time,
315            pipelines,
316            warm_up: warm_up_started.elapsed(),
317        };
318
319        let gpu_timer = crate::gpu_timer::GpuTimer::new(&device, &queue);
320        log::info!(
321            "GPU clock: {}",
322            if gpu_timer.is_some() {
323                "frames timed on the GPU"
324            } else {
325                "none, frames timed on the CPU only"
326            }
327        );
328        Ok(Self {
329            surface,
330            device,
331            queue,
332            config,
333            render_format,
334            gpu_timer,
335            painters,
336            startup,
337            lost,
338            timings: RenderTimings::default(),
339            see_through,
340        })
341    }
342
343    /// Whether the device is gone: see [`RenderOutcome::DeviceLost`].
344    pub fn is_lost(&self) -> bool {
345        self.lost.load(std::sync::atomic::Ordering::SeqCst)
346    }
347
348    /// Destroys the device as a driver reset would, for testing what follows: the next
349    /// [`Renderer::render`] says [`RenderOutcome::DeviceLost`].
350    #[doc(hidden)]
351    pub fn lose_device_for_testing(&self) {
352        self.device.destroy();
353    }
354
355    /// What making this renderer took, phase by phase (#211).
356    pub fn startup_times(&self) -> StartupTimes {
357        self.startup
358    }
359
360    /// Reconfigures the surface after the window is resized. The painters' viewports
361    /// are set every frame by `Painters::render`.
362    pub fn resize(&mut self, width: u32, height: u32) {
363        if width > 0 && height > 0 {
364            self.config.width = width;
365            self.config.height = height;
366            self.surface.configure(&self.device, &self.config);
367        }
368    }
369
370    /// Reapplies the current configuration, after a lost or outdated surface.
371    pub fn reconfigure(&mut self) {
372        self.surface.configure(&self.device, &self.config);
373    }
374
375    /// Where the last [`render`](Self::render) spent its time, in milliseconds: waiting for
376    /// a surface image to draw into, drawing into it (tessellating, encoding and submitting),
377    /// and handing it to the display (milestone 609). Zero on the web, which has no clock
378    /// here.
379    pub fn last_timings(&self) -> RenderTimings {
380        self.timings
381    }
382
383    /// Draws the scene — rectangles, images, paths, text, layers — and presents it.
384    pub fn render(&mut self, scene: &Scene) -> RenderOutcome {
385        // Without waiting: a lost device is reported when the device is polled (milestone 686).
386        let _ = self.device.poll(wgpu::PollType::Poll);
387        if self.lost.load(std::sync::atomic::Ordering::SeqCst) {
388            return RenderOutcome::DeviceLost;
389        }
390        let clock = Clock::start();
391        let frame = match self.surface.get_current_texture() {
392            wgpu::CurrentSurfaceTexture::Success(frame)
393            | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
394            wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => {
395                return RenderOutcome::Skipped;
396            }
397            wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
398                return RenderOutcome::NeedsReconfigure;
399            }
400            wgpu::CurrentSurfaceTexture::Validation => {
401                log::warn!("frame skipped: surface validation error");
402                return RenderOutcome::Skipped;
403            }
404        };
405        let acquired = clock.lap();
406        let timed = self
407            .gpu_timer
408            .as_mut()
409            .and_then(|timer| timer.begin(&self.device, &self.queue));
410        let view = frame.texture.create_view(&wgpu::TextureViewDescriptor {
411            format: Some(self.render_format),
412            ..Default::default()
413        });
414
415        self.painters.render(
416            &self.device,
417            &self.queue,
418            self.render_format,
419            &view,
420            self.config.width,
421            self.config.height,
422            scene,
423            // A see-through frame starts transparent: what its owner paints is all there is.
424            Some(if self.see_through {
425                wgpu::Color::TRANSPARENT
426            } else {
427                CLEAR_COLOR
428            }),
429        );
430
431        if let (Some(timer), Some(at)) = (self.gpu_timer.as_mut(), timed) {
432            timer.end(&self.device, &self.queue, at);
433        }
434        let drawn = clock.lap();
435        self.queue.present(frame);
436        let presented = clock.lap();
437        self.timings = RenderTimings {
438            acquire: acquired,
439            draw: drawn - acquired,
440            present: presented - drawn,
441            gpu: self.gpu_timer.as_ref().and_then(|timer| timer.last_ms()),
442        };
443        RenderOutcome::Presented
444    }
445}
446
447/// Whether the Vulkan this device offers is unusable: only the Android emulator's (gfxstream, the
448/// "Goldfish" GPU), which crashes in its own code at the first submission — or none at all:
449/// why, then. `WGPU_BACKEND`, when set, decides instead.
450#[cfg(any(target_os = "android", target_os = "linux"))]
451async fn vulkan_unusable(flags: wgpu::InstanceFlags) -> Option<&'static str> {
452    if std::env::var_os("WGPU_BACKEND").is_some() {
453        return None;
454    }
455    let probe = wgpu::Instance::new(wgpu::InstanceDescriptor {
456        backends: wgpu::Backends::VULKAN,
457        flags,
458        ..wgpu::InstanceDescriptor::new_without_display_handle()
459    });
460    let adapters = probe.enumerate_adapters(wgpu::Backends::VULKAN).await;
461    let emulator = |adapter: &wgpu::Adapter| {
462        let info = adapter.get_info();
463        info.driver.eq_ignore_ascii_case("gfxstream") || info.name.contains("Goldfish")
464    };
465    if adapters.is_empty() {
466        Some("no Vulkan driver on this device")
467    } else if adapters.iter().all(emulator) {
468        Some("the emulator's Vulkan driver fails at the first frame")
469    } else {
470        None
471    }
472}
473
474/// Where a frame's rendering spent its time, in milliseconds. See
475/// [`Renderer::last_timings`].
476#[derive(Clone, Copy, Debug, Default, PartialEq)]
477pub struct RenderTimings {
478    /// Waiting for a surface image: time the display, not the drawing, decides.
479    pub acquire: f32,
480    /// Tessellating, encoding and submitting the scene.
481    pub draw: f32,
482    /// Handing the image to the display.
483    pub present: f32,
484    /// What the GPU took over the latest frame it has finished, by its own clock, a few
485    /// frames behind: `None` where the device has no clock (milestone 612).
486    pub gpu: Option<f32>,
487}
488
489/// Milliseconds since it started; nothing on the web, where `std` has no clock.
490struct Clock {
491    #[cfg(not(target_arch = "wasm32"))]
492    start: std::time::Instant,
493}
494
495impl Clock {
496    fn start() -> Self {
497        Self {
498            #[cfg(not(target_arch = "wasm32"))]
499            start: std::time::Instant::now(),
500        }
501    }
502
503    fn lap(&self) -> f32 {
504        #[cfg(not(target_arch = "wasm32"))]
505        return self.start.elapsed().as_secs_f32() * 1000.0;
506        #[cfg(target_arch = "wasm32")]
507        0.0
508    }
509}
510
511/// What happened when [`Renderer::render`] tried to present a frame.
512pub enum RenderOutcome {
513    /// The frame was drawn and presented.
514    Presented,
515    /// The frame was skipped (occlusion, timeout, or a validation error); the caller
516    /// should just try again on the next redraw.
517    Skipped,
518    /// The surface is lost or outdated: call [`Renderer::reconfigure`] before retrying.
519    NeedsReconfigure,
520    /// The GPU device is gone — a driver reset, a GPU removed or restarted: this renderer can
521    /// draw nothing more, and a new one is to be made (milestone 686, #217).
522    DeviceLost,
523}
524
525/// The device's pipeline cache, filled from the file a previous launch wrote for this adapter
526/// and driver, where the device has one and a folder was given: the cache, its file, and what
527/// was read from it.
528fn open_pipeline_cache(
529    adapter: &wgpu::Adapter,
530    device: &wgpu::Device,
531) -> (
532    Option<wgpu::PipelineCache>,
533    Option<std::path::PathBuf>,
534    Option<Vec<u8>>,
535) {
536    if !device.features().contains(wgpu::Features::PIPELINE_CACHE) {
537        return (None, None, None);
538    }
539    let dir = PIPELINE_CACHE_DIR.lock().ok().and_then(|slot| slot.clone());
540    let Some(file) = dir
541        .zip(wgpu::util::pipeline_cache_key(&adapter.get_info()))
542        .map(|(dir, key)| dir.join(format!("frus-{key}")))
543    else {
544        return (None, None, None);
545    };
546    let cached = std::fs::read(&file).ok();
547    // SAFETY: the data is what `PipelineCache::get_data` gave on a previous launch, kept in a
548    // file named for this adapter and driver; wgpu checks its header against the device, and
549    // `fallback` starts an empty cache when it does not match.
550    let cache = unsafe {
551        device.create_pipeline_cache(&wgpu::PipelineCacheDescriptor {
552            label: Some("frus.pipelines"),
553            data: cached.as_deref(),
554            fallback: true,
555        })
556    };
557    log::info!(
558        "pipeline cache: {}",
559        match &cached {
560            Some(data) => format!("{} KB read from the previous launch", data.len() / 1024),
561            None => "none yet: the pipelines are compiled, and kept for the next launch".into(),
562        }
563    );
564    (Some(cache), Some(file), cached)
565}
566
567/// The cache written back to its file when it holds more than what was read.
568fn keep_pipeline_cache(cache: &wgpu::PipelineCache, file: &std::path::Path, read: Option<&[u8]>) {
569    let Some(data) = cache.get_data() else {
570        return;
571    };
572    if read == Some(data.as_slice()) {
573        return;
574    }
575    if let Some(dir) = file.parent() {
576        let _ = std::fs::create_dir_all(dir);
577    }
578    // Written aside, then moved over: a launch that stops halfway leaves no half a cache.
579    let partial = file.with_extension("partial");
580    if std::fs::write(&partial, &data).is_ok() {
581        let _ = std::fs::rename(&partial, file);
582    }
583}
584
585/// What making the renderer took, phase by phase (milestone 683, #211): a part of an
586/// application's time to its first frame. See [`Renderer::startup_times`].
587#[derive(Clone, Copy, Debug, Default, PartialEq)]
588pub struct StartupTimes {
589    /// The GPU instance, the surface and the adapter.
590    pub adapter: std::time::Duration,
591    /// The device and its queue.
592    pub device: std::time::Duration,
593    /// The pipelines and resources every frame draws with.
594    pub pipelines: std::time::Duration,
595    /// A frame drawn before the first real one, which builds what drivers build lazily.
596    pub warm_up: std::time::Duration,
597}