use crate::task::RequestRedraw; use iris_core::{FrameParts, UiData, UiRenderNode, UiRenderState, util::Vec2}; use pollster::FutureExt; use std::sync::Arc; use std::time::Instant; use wgpu::*; use winit::{dpi::PhysicalSize, window::Window}; pub const CLEAR_COLOR: Color = Color::BLACK; impl RequestRedraw for Window { fn request_redraw(&self) { Window::request_redraw(self); } } pub struct UiRenderer { window: Arc, surface: Surface<'static>, device: Device, queue: Queue, config: SurfaceConfiguration, encoder: CommandEncoder, pub ui: UiRenderNode, } impl UiRenderer { pub fn update(&mut self, ui: &mut UiData, render: &mut UiRenderState) { self.ui.update(&self.device, &self.queue, ui, render); } /// The two waits, so a desktop frame divides up the same way an /// Android one does -- see `AndroidRenderer::draw` for why the /// swapchain acquire is measured apart from the work. pub fn draw(&mut self) -> FrameParts { let acquire_start = Instant::now(); let output = match self.surface.get_current_texture() { CurrentSurfaceTexture::Success(texture) | CurrentSurfaceTexture::Suboptimal(texture) => texture, // wgpu 30 turned this Result into an enum; every arm here was an // `Err` the previous `.unwrap()` panicked on, except `Occluded`, // which is new. Named rather than swallowed: a window that stops // presenting silently is the state this file's `pre_present_notify` // comment was written about. other => panic!("no surface texture to draw into: {other:?}"), }; let acquire = acquire_start.elapsed(); let view = output .texture .create_view(&TextureViewDescriptor::default()); let mut encoder = std::mem::replace(&mut self.encoder, Self::create_encoder(&self.device)); { let render_pass = &mut encoder.begin_render_pass(&RenderPassDescriptor { color_attachments: &[Some(RenderPassColorAttachment { view: &view, resolve_target: None, ops: Operations { load: LoadOp::Clear(CLEAR_COLOR), store: StoreOp::Store, }, depth_slice: None, })], ..Default::default() }); self.ui.draw(render_pass); } let submit_start = Instant::now(); self.queue.submit(std::iter::once(encoder.finish())); // Immediately before presenting, so the windowing system can schedule // the frame. On Wayland this is what ties the commit to the surface's // frame callback; without it a frame drawn when nothing else follows // could sit unpresented, and the window kept the layout it had before // the compositor's first resize -- intermittently, on about a fifth of // starts, with nothing left to flush it. self.window.pre_present_notify(); self.queue.present(output); FrameParts::waits(acquire, submit_start.elapsed()) } pub fn resize(&mut self, size: &PhysicalSize) { self.config.width = size.width; self.config.height = size.height; self.surface.configure(&self.device, &self.config); // Physical, matching `new`'s own seed -- see the comment there. self.ui.resize( Vec2::new(size.width as f32, size.height as f32), &self.queue, ); } fn create_encoder(device: &Device) -> CommandEncoder { device.create_command_encoder(&CommandEncoderDescriptor { label: Some("Render Encoder"), }) } pub fn new(window: Arc) -> Self { let size = window.inner_size(); // `force-gles` on the desktop too, not just on Android: the // GLES backend has behaviour of its own (a one-layer array // texture is a `GL_TEXTURE_2D` -- see // `GpuTextures::create_array_texture`), and a machine with a // real GPU is where that is cheap to reproduce and screenshot. let mut backends = if cfg!(feature = "force-gles") { Backends::GL } else { Backends::PRIMARY }; // The display handle comes from the window rather than being left // out: wgpu 30 asks for it whenever a GLES surface is going to be // presented on Wayland, which is exactly what the fallback below // produces on this machine. let mut instance = Instance::new(InstanceDescriptor { backends, ..InstanceDescriptor::new_with_display_handle(Box::new(window.clone())) }); // The same fallback the Android backend grew in 85869d0, and for // the same reason: a machine can advertise a Vulkan ICD with no // device behind it, and refusing to draw at all because the only // usable adapter is a GLES one is iris's bug rather than the // machine's. On this VM the Vulkan device disappears whenever // the host refuses a virtio-gpu context, so `run-headless.sh` -- // layer 2 of the test rig -- aborted with `Could not get // adapter!` while GL was sitting there working. Probed before the // surface exists, matching Android, where an instance carrying // both backends fails worse than one carrying the wrong one. if backends != Backends::GL && instance.enumerate_adapters(backends).block_on().is_empty() { log::warn!( "iris renderer: no {backends:?} adapter on this machine, falling back to GLES" ); backends = Backends::GL; instance = Instance::new(InstanceDescriptor { backends, ..InstanceDescriptor::new_with_display_handle(Box::new(window.clone())) }); } let surface = instance .create_surface(window.clone()) .expect("Could not create window surface!"); let adapter = instance .request_adapter(&RequestAdapterOptions { power_preference: PowerPreference::default(), compatible_surface: Some(&surface), force_fallback_adapter: false, ..Default::default() }) .block_on() .unwrap_or_else(|error| { panic!("No usable GPU adapter for backends {backends:?}: {error}") }); // Say which adapter won, in the same words the Android backend // uses. Without it a layer-2 screenshot or frame time from this // window carries no record of what drew it, and the two cases that // matter look identical in the PNG: the host's real GPU, and // llvmpipe after this VM lost its virtio-gpu contexts. That // happened on 2026-09-08, and the only reason anyone noticed is // that the fallback above did not exist yet and the app aborted // instead. A silent fallback needs this line to stay honest. { let info = adapter.get_info(); log::info!( "iris renderer: {name} ({backend:?}, {driver}{driver_info}) on {backends:?}", name = info.name, backend = info.backend, driver = info.driver, driver_info = if info.driver_info.is_empty() { String::new() } else { format!(" {}", info.driver_info) }, ); } // No features beyond what wgpu asks for by default, and no // binding-array limits: the atlas is one texture_2d_array and a // standalone image is its own ordinary bind group, neither of which // needs descriptor indexing. See TEXTURES.md's "Recommended shape" // for why the old binding array asked for // VK_EXT_descriptor_indexing unconditionally and did not survive a // real share of Android GPUs. `iris_core::device_limits()` is // shared with the Android backend; see its own doc for why it is // not simply `Limits::default()`. let (device, queue) = adapter .request_device(&DeviceDescriptor { required_limits: iris_core::device_limits(), ..Default::default() }) .block_on() .expect("Could not get device!"); let surface_caps = surface.get_capabilities(&adapter); let surface_format = surface_caps .formats .iter() .copied() .find(|f| f.is_srgb()) .unwrap_or(surface_caps.formats[0]); let config = SurfaceConfiguration { usage: TextureUsages::RENDER_ATTACHMENT, format: surface_format, // wgpu 30's new field; `Auto` is what every earlier version did. color_space: SurfaceColorSpace::Auto, width: size.width, height: size.height, // Vsync, because a toolkit aiming at battery life must not present // frames a display will never show: AutoNoVsync accepts them as // fast as the GPU will take them, so a redraw burst costs whatever // the hardware can be made to do rather than one frame. // AutoVsync picks Fifo, which every backend supports. present_mode: PresentMode::AutoVsync, alpha_mode: surface_caps.alpha_modes[0], desired_maximum_frame_latency: 2, view_formats: vec![], }; surface.configure(&device, &config); let encoder = Self::create_encoder(&device); // Unlike the Android backend, the desktop backend has no on-screen // fallback to show a diagnostic through, so a renderer-creation // failure still panics here -- but now with wgpu's full "Caused // by:" chain as the message, since `UiRenderNode::new` returns it // rather than letting wgpu's own default handler panic first (see // that function's doc comment). // Physical size, the same units the swapchain, `WindowEvent:: // Resized`, the pointer and the widget tree all use -- see // `default::content_scale` for why this backend stopped dividing // into a separate logical space, and what disagreed while it did. let physical_size = Vec2::new(size.width as f32, size.height as f32); let ui = UiRenderNode::new(&device, &queue, &config, physical_size) .expect("Could not create iris render node!"); Self { surface, device, queue, config, encoder, ui, window, } } pub fn window(&self) -> &Window { self.window.as_ref() } }