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/// Holds the GPU state bound to a surface and presents the frames.
30pub struct Renderer {
31 surface: wgpu::Surface<'static>,
32 device: wgpu::Device,
33 queue: wgpu::Queue,
34 config: wgpu::SurfaceConfiguration,
35 /// The format the frames are drawn in: the surface's own when it is sRGB, its sRGB twin
36 /// (a view of the same image) when the surface offers none, as a WebGPU canvas (#234).
37 render_format: wgpu::TextureFormat,
38 painters: Painters,
39 /// What making it took, phase by phase.
40 startup: StartupTimes,
41 /// Set by the device when it is lost.
42 lost: std::sync::Arc<std::sync::atomic::AtomicBool>,
43 /// Where the last frame's rendering spent its time.
44 timings: RenderTimings,
45 /// The GPU's own timing of each frame, where the device has a clock (milestone 612).
46 gpu_timer: Option<crate::gpu_timer::GpuTimer>,
47 /// Whether the frames are composed with what is behind the window (milestone 643).
48 see_through: bool,
49}
50
51impl Renderer {
52 /// Initialises the GPU context for a given surface.
53 ///
54 /// `target` is typically an `Arc<Window>` supplied by the platform layer.
55 /// `width` and `height` must both be > 0.
56 pub async fn new(
57 target: impl Into<wgpu::SurfaceTarget<'static>>,
58 width: u32,
59 height: u32,
60 ) -> anyhow::Result<Self> {
61 Self::make(target, width, height, false).await
62 }
63
64 /// **A renderer whose frames let what is behind the window show** where they are
65 /// transparent (milestone 643): its frames start transparent, are composed with the
66 /// window's surroundings by the system's compositor, and are made of premultiplied
67 /// colours — which is what this renderer's blending produces over a transparent start.
68 ///
69 /// On Windows it presents through DirectComposition (Direct3D 12): where a frame is
70 /// transparent on the title bar's line, the system's own caption shows — its backdrop
71 /// and its three buttons. Fails where that is not available — another system, no
72 /// Direct3D 12 adapter, no premultiplied composition — and the caller then makes an
73 /// ordinary [`new`](Self::new) one. Its owner paints [`backdrop`] wherever the window is
74 /// not meant to be seen through.
75 pub async fn see_through(
76 target: impl Into<wgpu::SurfaceTarget<'static>>,
77 width: u32,
78 height: u32,
79 ) -> anyhow::Result<Self> {
80 Self::make(target, width, height, true).await
81 }
82
83 /// Whether this renderer's frames let what is behind the window show where they are
84 /// transparent: made by [`see_through`](Self::see_through).
85 pub fn sees_through(&self) -> bool {
86 self.see_through
87 }
88
89 async fn make(
90 target: impl Into<wgpu::SurfaceTarget<'static>>,
91 width: u32,
92 height: u32,
93 see_through: bool,
94 ) -> anyhow::Result<Self> {
95 if see_through && !cfg!(windows) {
96 anyhow::bail!("a see-through window is composed by DirectComposition, on Windows");
97 }
98 let started = web_time::Instant::now();
99 let mut descriptor = wgpu::InstanceDescriptor {
100 backends: wgpu::Backends::PRIMARY,
101 ..wgpu::InstanceDescriptor::new_without_display_handle()
102 };
103 if see_through {
104 // Presented through a DirectComposition visual made from the window: the one
105 // way a Direct3D swap chain is composed with what is behind it.
106 descriptor.backends = wgpu::Backends::DX12;
107 descriptor.backend_options.dx12.presentation_system =
108 wgpu::Dx12SwapchainKind::DxgiFromVisual;
109 }
110 // No debug names for GPU objects on Android: a debug build asks for them, and the
111 // emulator's Vulkan driver crashes in `vkSetDebugUtilsObjectNameEXT` as the device is
112 // made (a release build never asks). `WGPU_DEBUG=1` still asks for them on a device.
113 #[cfg(target_os = "android")]
114 if std::env::var_os("WGPU_DEBUG").is_none() {
115 descriptor.flags.remove(wgpu::InstanceFlags::DEBUG);
116 }
117 // Android and Linux draw through Vulkan alone. Where there is none — an old device, a
118 // driver missing — or only the Android emulator's (gfxstream, which crashes in its own
119 // code at the first submission), the GPU is reached through OpenGL ES instead
120 // (milestones 681, 686).
121 #[cfg(any(target_os = "android", target_os = "linux"))]
122 if let Some(why) = vulkan_unusable(descriptor.flags).await {
123 log::info!("{why}: OpenGL ES instead");
124 descriptor.backends = wgpu::Backends::GL;
125 }
126 // `WGPU_BACKEND` (`vulkan`, `gl`, `dx12`, `metal`, `webgpu`, comma-separated) decides
127 // over all of the above: a device whose driver frus's choice fails on can be given
128 // another backend without rebuilding the application (milestone 686, #217).
129 if let Some(backends) = wgpu::Backends::from_env() {
130 log::info!("GPU backends from WGPU_BACKEND: {backends:?}");
131 descriptor.backends = backends;
132 }
133 // DirectX 12 compiles the shaders with DXC when its DLL is beside the program —
134 // `frus run` and `frus build desktop` put it there —, several times faster than
135 // Windows' own compiler (FXC): about half a second of a start (milestone 684, #217).
136 #[cfg(windows)]
137 {
138 let dxc = std::env::current_exe()
139 .ok()
140 .and_then(|exe| Some(exe.parent()?.join("dxcompiler.dll")))
141 .filter(|dll| dll.is_file());
142 match dxc {
143 Some(dll) => {
144 log::info!("DirectX 12 shaders: DXC ({})", dll.display());
145 descriptor.backend_options.dx12.shader_compiler =
146 wgpu::Dx12Compiler::DynamicDxc {
147 dxc_path: dll.display().to_string(),
148 };
149 }
150 None => log::info!(
151 "DirectX 12 shaders: Windows' own compiler (no dxcompiler.dll beside the \
152 program: a slower start)"
153 ),
154 }
155 }
156 let instance = wgpu::Instance::new(descriptor);
157
158 let surface = instance.create_surface(target)?;
159
160 let adapter = instance
161 .request_adapter(&wgpu::RequestAdapterOptions {
162 // The discrete GPU where there is a choice — but not in a browser, which
163 // ignores the hint and **says so** in the console (Chrome, on Windows) on
164 // every page load, for something the page cannot change.
165 power_preference: if cfg!(target_arch = "wasm32") {
166 wgpu::PowerPreference::None
167 } else {
168 wgpu::PowerPreference::HighPerformance
169 },
170 compatible_surface: Some(&surface),
171 force_fallback_adapter: false,
172 ..Default::default()
173 })
174 .await?;
175
176 log::info!("GPU adapter: {:?}", adapter.get_info());
177
178 // Downlevel limits, for GLES compatibility, but with the adapter's **real**
179 // resolution: on mobile a screen of, say, 1080x2340 exceeds the downlevel
180 // maximum texture size of 2048, and without this `surface.configure` panics.
181 // A GPU without compute shaders (OpenGL ES 3.0, as the Android emulator's) cannot give
182 // even the downlevel compute limits: the WebGL 2 ones, which ask for none.
183 let downlevel = if adapter
184 .get_downlevel_capabilities()
185 .flags
186 .contains(wgpu::DownlevelFlags::COMPUTE_SHADERS)
187 {
188 wgpu::Limits::downlevel_defaults()
189 } else {
190 wgpu::Limits::downlevel_webgl2_defaults()
191 };
192 let required_limits = downlevel.using_resolution(adapter.limits());
193
194 let adapter_time = started.elapsed();
195 let device_started = web_time::Instant::now();
196 let (device, queue) = adapter
197 .request_device(&wgpu::DeviceDescriptor {
198 label: Some("frus.device"),
199 // The GPU's own clock where it has one, for the frame statistics
200 // (milestone 612). Nothing is asked of a device that has not.
201 required_features: adapter.features()
202 & (wgpu::Features::TIMESTAMP_QUERY | wgpu::Features::PIPELINE_CACHE),
203 required_limits,
204 memory_hints: wgpu::MemoryHints::Performance,
205 ..Default::default()
206 })
207 .await?;
208 let device_time = device_started.elapsed();
209 let surface_started = web_time::Instant::now();
210 // A lost device — a driver reset, a GPU removed — says so here; the renderer then
211 // answers `DeviceLost`, and its owner makes a new one (milestone 686).
212 let lost = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
213 {
214 let lost = lost.clone();
215 device.set_device_lost_callback(move |reason, message| {
216 log::warn!("the GPU device was lost ({reason:?}): {message}");
217 lost.store(true, std::sync::atomic::Ordering::SeqCst);
218 });
219 }
220
221 let caps = surface.get_capabilities(&adapter);
222 let alpha_mode = if see_through {
223 if !caps
224 .alpha_modes
225 .contains(&wgpu::CompositeAlphaMode::PreMultiplied)
226 {
227 anyhow::bail!(
228 "no premultiplied composition on this surface: {:?}",
229 caps.alpha_modes
230 );
231 }
232 wgpu::CompositeAlphaMode::PreMultiplied
233 } else {
234 caps.alpha_modes[0]
235 };
236 let format = caps
237 .formats
238 .iter()
239 .copied()
240 .find(|f| f.is_srgb())
241 .unwrap_or(caps.formats[0]);
242 // The shaders write linear values, which an sRGB target encodes. A WebGPU canvas
243 // offers only plain formats (`bgra8unorm`): drawn as they are, the linear values are
244 // shown as if encoded, and every colour comes out darker (#234). The canvas lets its
245 // image be drawn through an sRGB view: the frames are drawn in that twin, and the
246 // hardware encodes, as everywhere else.
247 let can_view = adapter
248 .get_downlevel_capabilities()
249 .flags
250 .contains(wgpu::DownlevelFlags::SURFACE_VIEW_FORMATS);
251 let (render_format, view_formats) = surface_formats(format, can_view);
252 if render_format != format.add_srgb_suffix() {
253 // Asking for the view anyway is a validation error, which wgpu treats as fatal: the
254 // Android emulator's OpenGL ES offers neither an sRGB surface nor views of one, and
255 // every application closed as it opened there. Drawn plain, it runs, a little dark.
256 log::warn!(
257 "this surface has no sRGB format ({format:?}) and cannot be viewed as one: \
258 colours are drawn a little dark here"
259 );
260 }
261
262 let config = wgpu::SurfaceConfiguration {
263 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
264 format,
265 color_space: wgpu::SurfaceColorSpace::Auto,
266 width,
267 height,
268 // **Fifo, always** (milestone 610): one image per refresh of the display, in
269 // order. It was the first mode the surface offered, and on the test phone that
270 // was Mailbox, under which the application draws as fast as it can, the
271 // display shows whichever image is latest, and the rest are drawn for nothing:
272 // frames at 14 ms intervals on a 16.7 ms display, the battery spent on images
273 // never seen, and a motion that is not paced by the display. Fifo is the one
274 // mode every surface supports.
275 present_mode: wgpu::PresentMode::Fifo,
276 alpha_mode,
277 view_formats,
278 desired_maximum_frame_latency: 2,
279 };
280 log::info!(
281 "present mode: {:?}, of {:?}",
282 config.present_mode,
283 caps.present_modes
284 );
285 surface.configure(&device, &config);
286
287 // MSAA when the adapter supports it for this format; otherwise 1, disabled.
288 let sample_count = preferred_sample_count(&adapter, render_format);
289 log::info!("MSAA: {sample_count}×");
290
291 let surface_time = surface_started.elapsed();
292 let pipelines_started = web_time::Instant::now();
293 let pipeline_cache =
294 crate::pipeline_cache::open(&adapter, &device, crate::pipeline_cache::cache_dir());
295 let mut painters = Painters::new(
296 &device,
297 &queue,
298 render_format,
299 sample_count,
300 pipeline_cache.as_ref().map(|(cache, _, _)| cache),
301 );
302 let pipelines = pipelines_started.elapsed();
303 // Warms every pipeline before the first real frame, to avoid jank.
304 let warm_up_started = web_time::Instant::now();
305 painters.warm_up(&device, &queue, render_format);
306 // Kept once the warm-up has built what drivers build lazily, so the next launch finds
307 // every pipeline of a frame.
308 if let Some((cache, file, read)) = &pipeline_cache {
309 crate::pipeline_cache::keep(cache, file, read.as_deref());
310 }
311 let startup = StartupTimes {
312 adapter: adapter_time,
313 device: device_time,
314 surface: surface_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/// What making the renderer took, phase by phase (milestone 683, #211): a part of an
526/// application's time to its first frame. See [`Renderer::startup_times`].
527#[derive(Clone, Copy, Debug, Default, PartialEq)]
528pub struct StartupTimes {
529 /// The GPU instance, the surface and the adapter.
530 pub adapter: std::time::Duration,
531 /// The device and its queue.
532 pub device: std::time::Duration,
533 /// The surface's capabilities read, and the surface configured for the device
534 /// (milestone 696).
535 pub surface: std::time::Duration,
536 /// The pipelines and resources every frame draws with.
537 pub pipelines: std::time::Duration,
538 /// A frame drawn before the first real one, which builds what drivers build lazily.
539 pub warm_up: std::time::Duration,
540}
541
542/// The format the frames are drawn in, and the view formats the surface is configured with:
543/// the surface's own format when it is sRGB; its sRGB twin, through a view, when the device can
544/// view a surface in another format (the Web); and the plain format itself when it cannot.
545fn surface_formats(
546 format: wgpu::TextureFormat,
547 can_view: bool,
548) -> (wgpu::TextureFormat, Vec<wgpu::TextureFormat>) {
549 let srgb = format.add_srgb_suffix();
550 if srgb == format {
551 (format, vec![])
552 } else if can_view {
553 (srgb, vec![srgb])
554 } else {
555 (format, vec![])
556 }
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562
563 /// An sRGB surface is drawn as it is; a plain one through its sRGB twin where views are
564 /// supported, and plain where they are not (the emulator's OpenGL ES), rather than asking
565 /// for a view the device refuses.
566 #[test]
567 fn a_surface_is_drawn_in_srgb_when_it_can_be() {
568 use wgpu::TextureFormat::*;
569 assert_eq!(
570 surface_formats(Bgra8UnormSrgb, false),
571 (Bgra8UnormSrgb, vec![])
572 );
573 assert_eq!(
574 surface_formats(Bgra8Unorm, true),
575 (Bgra8UnormSrgb, vec![Bgra8UnormSrgb])
576 );
577 assert_eq!(surface_formats(Rgba8Unorm, false), (Rgba8Unorm, vec![]));
578 }
579}