// Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 #[cfg(feature = "unstable-wgpu-30")] use i_slint_core::api::GraphicsAPI; use i_slint_core::api::{PhysicalSize as PhysicalWindowSize, Window}; use i_slint_core::graphics::RequestedGraphicsAPI; use i_slint_core::partial_renderer::DirtyRegion; use i_slint_core::platform::PlatformError; use i_slint_core::renderer::DrawOutcome; use std::cell::RefCell; use std::rc::Rc; use std::sync::Arc; use wgpu_30 as wgpu; use crate::SkiaSharedContext; // Jank-hunt: per-stage timing of the Vulkan/wgpu calls inside render(). // Stalls reproduce ONLY on the Mali G715 Vulkan driver (never on the // SwiftShader emulator), so we time each Vulkan call site and log the slow // ones to the perf file (nblog mirrors `VK ` lines). See // docs/BUGS/scroll-jank-120hz.md. pub mod vkprobe { use std::sync::atomic::{AtomicU64, Ordering}; /// Log stages slower than this (microseconds). 3 ms by default; the /// jank-hunt apps lower it (e.g. 300) to see the whole per-frame /// breakdown of a ~4 ms render. pub static THRESHOLD_US: AtomicU64 = AtomicU64::new(3_000); pub static FRAME: AtomicU64 = AtomicU64::new(0); pub static ACQ: AtomicU64 = AtomicU64::new(0); pub static CLR: AtomicU64 = AtomicU64::new(0); pub static SKD: AtomicU64 = AtomicU64::new(0); pub static SKF: AtomicU64 = AtomicU64::new(0); pub static ORD: AtomicU64 = AtomicU64::new(0); pub static PRE: AtomicU64 = AtomicU64::new(0); /// 120Hz experiment: when set, skip the separate wgpu CLEAR pass (the /// scene's opaque background covers the whole window) and move the wgpu /// PRESENT-ordering submit to AFTER Skia's draw, so it only does the /// texture transition. Result: 3 submits/frame instead of 4, and no /// full-screen GPU clear. Safe while the root background is opaque. /// NOTE: DISPROVEN — without the wgpu clear/write pass, `Queue::present` /// clears the frame (black screen). Kept for reference; do not enable. pub static REORDER_NOCLR: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); /// 120Hz experiment: render-ahead pipeline. When set, each `render()` /// call first PRESENTS the frame prepared by the previous call (phase = /// vsync wake + ~1.1 ms present, i.e. locked to the vsync edge), and /// then prepares the NEXT frame (ACQ + CLR + Skia draw + ORD, ~3.5 ms) /// without presenting it. The display shows one slot (8.3 ms) older /// content at a constant, deterministic latency. Requires the loop to /// call render() every vsync while animating (VSYNC_LOCKED_RENDER). pub static RENDER_AHEAD: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); /// RENDER_AHEAD per-frame activation: the event loop sets this to the /// current `locked` state before each `do_render()`, so the render-ahead /// pipeline (hold-without-present) is only used while animating. An /// unlocked render (idle redraw, model update) presents immediately — /// otherwise its content would sit in the held slot forever (no further /// renders coming) and never reach the screen. pub static RENDER_AHEAD_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); // ------------------------------------------------------------------ // PRESENT GATE (vsync-grid quantization, the real 120Hz fix). // // Measured on the phone: the Choreographer vsync callback reaches the // UI thread 2.4–6 ms (median 3.3 ms) after the vsync edge, and the // render takes 3–9 ms — so each frame's present lands at a RANDOM // phase 8–14 ms after the edge against a 13.8 ms latch deadline. // 51% of frames land one slot late (judder) and ~4% two slots late // (visible hitch), independent of where in the render() call the // present happens. // // Fix: quantize the PRESENT to the vsync grid. The vsync driver hands // us the exact edge (Choreographer `frame_time_nanos`, CLOCK_MONOTONIC) // via `note_vsync_edge`; before `Queue::present` we sleep (≤ ~2 ms) so // the present lands at (next edge + 1.5 ms) — constant phase, ~4 ms of // deadline margin, frame-to-frame gaps of exactly one period. See // docs/BUGS/scroll-jank-120hz.md §9. /// Latest Choreographer `frame_time_nanos` (CLOCK_MONOTONIC ns). pub static VSYNC_EDGE_NS: AtomicU64 = AtomicU64::new(0); /// Detected vsync period in ns (from consecutive callback deltas). pub static VSYNC_PERIOD_NS: AtomicU64 = AtomicU64::new(0); /// Gate enabled for the current render (set per-frame by the event /// loop; true while animating/locked, false for one-off redraws so /// taps and model updates keep minimum latency). pub static PRESENT_GATE_ON: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); /// Microseconds the most recent `present_gate()` slept (0 = no sleep). pub static GATE_US: AtomicU64 = AtomicU64::new(0); /// CLOCK_MONOTONIC ns when the last vsync-wake (locked) render STARTED. /// The event loop uses it to skip a second wake (e.g. the Choreographer's /// late fallback wake arriving ~5 ms after the early-waker's edge wake /// already triggered a render) instead of double-rendering the slot. pub static LAST_RENDER_START_NS: AtomicU64 = AtomicU64::new(0); /// Called from the vsync driver on every Choreographer callback. pub fn note_vsync_edge(ftn_ns: u64) { let prev = VSYNC_EDGE_NS.swap(ftn_ns, Ordering::Relaxed); if prev > 0 { let d = ftn_ns.saturating_sub(prev); if (4_000_000..20_000_000).contains(&d) { VSYNC_PERIOD_NS.store(d, Ordering::Relaxed); } } } /// Sleep so the `Queue::present` that follows lands at (latest vsync /// edge + one period + 1.5 ms). No-op when disabled, when the grid is /// unknown/stale (no recent callbacks), or when already past target. pub fn present_gate() { if !PRESENT_GATE_ON.load(Ordering::Acquire) { return; } let edge = VSYNC_EDGE_NS.load(Ordering::Relaxed); let period = VSYNC_PERIOD_NS.load(Ordering::Relaxed); if edge == 0 || period == 0 { return; } let now = monotonic_ns(); if now == 0 || now > edge + 4 * period { return; // stale grid (e.g. after resume) — don't gate } // Earliest grid phase (a vsync edge + 1.5 ms) strictly after now: // T = edge + m*period + 1.5 ms with m = floor((now-edge-1.5ms) // / period) + 1. // NOTE: a Choreographer callback can fire MID-RENDER (the next // edge's callback arrives at edge+~11.6 ms while a slow render is // still running), which advances `edge` by one period — the // smallest-phase rule above then just yields the same absolute T, so // the mid-render callback can never add an extra slot of delay. const PHASE_NS: u64 = 1_500_000; let rel = now.saturating_sub(edge).saturating_sub(PHASE_NS) / period; let target = edge + (rel + 1) * period + PHASE_NS; let wait = target.saturating_sub(now); // Only sleep for close phases; if the next grid phase is >4 ms out // this frame is already running late for it — present immediately // (the next frame re-syncs to the grid). let slept = if wait > 200_000 && wait <= 4_000_000 { wait } else { 0 }; GATE_US.store(slept / 1000, Ordering::Relaxed); if slept > 0 { std::thread::sleep(std::time::Duration::from_nanos(slept)); } } pub fn scn_reset() { crate::scn::reset(); } pub fn scn_report(frame: u64) { crate::scn::report(frame); } pub fn now_us() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_micros() as u64) .unwrap_or(0) } /// CLOCK_THREAD_CPUTIME_ID nanoseconds — CPU time consumed by the /// (current) thread. For a slow frame window: wall ≫ cpu ⇒ the thread /// was descheduled/blocked between calls; wall ≈ cpu ⇒ pure CPU work. pub fn thread_cpu_ns() -> u64 { #[repr(C)] struct Ts { tv_sec: i64, tv_nsec: i64, } unsafe extern "C" { fn clock_gettime(clkid: i32, tp: *mut Ts) -> i32; } const CLOCK_THREAD_CPUTIME_ID: i32 = 3; let mut ts = Ts { tv_sec: 0, tv_nsec: 0 }; if unsafe { clock_gettime(CLOCK_THREAD_CPUTIME_ID, &mut ts) } == 0 { ts.tv_sec as u64 * 1_000_000_000 + ts.tv_nsec as u64 } else { 0 } } /// CLOCK_MONOTONIC nanoseconds — the same clock/epoch as the backend's /// VSYNC `mon=` stamp, so PRES and VSYNC can be correlated offline for /// frame-alignment (phase of present vs the vsync grid). pub fn monotonic_ns() -> u64 { #[repr(C)] struct Ts { tv_sec: i64, tv_nsec: i64, } unsafe extern "C" { fn clock_gettime(clkid: i32, tp: *mut Ts) -> i32; } const CLOCK_MONOTONIC: i32 = 1; let mut ts = Ts { tv_sec: 0, tv_nsec: 0 }; if unsafe { clock_gettime(CLOCK_MONOTONIC, &mut ts) } == 0 { ts.tv_sec as u64 * 1_000_000_000 + ts.tv_nsec as u64 } else { 0 } } // Log one slow stage (>= 3 ms) with its running-max and total so we can // see both the worst case and the accumulated cost per call site. pub fn report(stage: &str, slot: &AtomicU64, us: u64) { let _ = slot.fetch_max(us, Ordering::Relaxed); if us >= THRESHOLD_US.load(Ordering::Relaxed) { let f = FRAME.load(Ordering::Relaxed); log::info!("VK {} {}us f={} max={}us", stage, us, f, slot.load(Ordering::Relaxed)); } } } #[cfg(target_family = "windows")] mod dx12; #[cfg(target_vendor = "apple")] mod metal; #[cfg(skia_wgpu_vulkan)] mod vulkan; /// See [`crate::attachment_color_space`]. pub(crate) fn attachment_color_space(texture: &wgpu::Texture) -> skia_safe::ColorSpace { crate::attachment_color_space(crate::TextureEncoding::from_format_is_srgb( texture.format().is_srgb(), )) } /// See [`crate::sampled_texture_color_space`]. #[cfg_attr(not(feature = "unstable-wgpu-30"), allow(dead_code))] pub(crate) fn sampled_texture_color_space(texture: &wgpu::Texture) -> skia_safe::ColorSpace { crate::sampled_texture_color_space(crate::TextureEncoding::from_format_is_srgb( texture.format().is_srgb(), )) } /// Skia rendering surface backed by WGPU. Supports both on-screen rendering (with a /// window surface) and offscreen rendering into caller-provided textures. pub struct WGPUSurface { pub(crate) gr_context: RefCell, wgpu: Rc, surface_config: RefCell>, surface: Option>, textures_to_transition_for_sampling: RefCell>, pub(crate) backend: Backend, alpha_modes: Vec, /// RENDER_AHEAD: the frame prepared by the previous `render()` call, /// presented at the start of the next one. `1` = frame index it was /// prepared under (for the PRES log line). pending_frame: RefCell>, } impl WGPUSurface { pub fn new_with_surface( surface_target: impl Into, size: PhysicalWindowSize, requested_graphics_api: Option, ) -> Result { let (instance, adapter, device, queue, surface) = i_slint_core::graphics::wgpu_30::init_instance_adapter_device_queue_surface( surface_target, requested_graphics_api, i_slint_core::graphics::wgpu_30::default_backends_to_avoid(), )?; Self::init_with_parts( Rc::new(SharedWgpuState { instance, adapter, device, queue }), surface, size, ) } fn init_with_parts( wgpu: Rc, surface: wgpu::Surface<'static>, size: PhysicalWindowSize, ) -> Result { let SharedWgpuState { adapter, device, queue, .. } = &*wgpu; #[cfg(target_vendor = "apple")] metal::set_layer_contents_gravity(&surface); // On iOS a window has no size until UIKit attaches it to a scene, // and wgpu rejects a zero-sized configure. // Start at 1x1; `resize_event` applies the real size. let mut surface_config = surface .get_default_config(adapter, size.width.max(1), size.height.max(1)) .ok_or_else(|| PlatformError::from("WGPU surface is not compatible with adapter"))?; let swapchain_capabilities = surface.get_capabilities(adapter); let swapchain_format = swapchain_capabilities .formats .iter() .find(|f| { matches!(f, wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Bgra8Unorm) }) .copied() .unwrap_or_else(|| swapchain_capabilities.formats[0]); surface_config.format = swapchain_format; // Prefer Mailbox / Immediate over Fifo/AutoVsync to prevent queue.present // from blocking the render thread on vsync backpressure (which causes // 17-55 ms tail stalls / dropped frames on 120Hz mobile GPUs). surface_config.present_mode = if swapchain_capabilities.present_modes.contains(&wgpu::PresentMode::Mailbox) { wgpu::PresentMode::Mailbox } else if swapchain_capabilities.present_modes.contains(&wgpu::PresentMode::Immediate) { wgpu::PresentMode::Immediate } else { wgpu::PresentMode::AutoVsync }; let error_scope = device.push_error_scope(wgpu::ErrorFilter::Validation); surface.configure(device, &surface_config); if let Some(e) = spin_on::spin_on(error_scope.pop()) { return Err(PlatformError::from(format!("Error configuring WGPU surface: {e}"))); } let backend = Backend::new(adapter, device)?; let gr_context = backend .make_context(adapter, device, queue) .ok_or_else(|| PlatformError::from("Failed to create Skia context from WGPU"))?; Ok(Self { gr_context: gr_context.into(), wgpu, surface_config: Some(surface_config).into(), surface: Some(surface), textures_to_transition_for_sampling: RefCell::new(Vec::new()), backend, alpha_modes: swapchain_capabilities.alpha_modes, pending_frame: RefCell::new(None), }) } pub(crate) fn new_offscreen( instance: wgpu::Instance, adapter: wgpu::Adapter, device: wgpu::Device, queue: wgpu::Queue, backend: Backend, gr_context: skia_safe::gpu::DirectContext, ) -> Self { Self { gr_context: RefCell::new(gr_context), wgpu: Rc::new(SharedWgpuState { instance, adapter, device, queue }), surface_config: None.into(), surface: None, textures_to_transition_for_sampling: RefCell::new(Vec::new()), backend, alpha_modes: vec![], pending_frame: RefCell::new(None), } } /// RENDER_AHEAD: release a held frame before a reconfigure, where a /// stale-size frame would be wrong anyway. PRESENTS it (rather than /// just dropping it): wgpu's Presentation tracks the acquired image /// independently of the Rust Arc, so a bare drop would leave it /// "acquired" and the next get_current_texture would fail with /// AlreadyAcquired. The compositor handles the one-frame size change. fn discard_pending_frame(&self) { if let Some((pf, _)) = self.pending_frame.borrow_mut().take() { self.wgpu.queue.present(pf); } } /// Transitions any imported wgpu textures to sampling state and flushes /// the Skia graphics context. Must be called after rendering to ensure /// Skia's GPU work is submitted. pub(crate) fn flush_and_submit(&self, gr_context: &mut skia_safe::gpu::DirectContext) { let textures_to_transition = self.textures_to_transition_for_sampling.take(); if !textures_to_transition.is_empty() { let mut encoder = self.wgpu.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("Skia texture transition encoder"), }); encoder.transition_resources( std::iter::empty(), textures_to_transition.iter().map(|texture| wgpu::TextureTransition { texture, selector: None, state: wgpu::TextureUses::RESOURCE, }), ); self.wgpu.queue.submit(Some(encoder.finish())); } gr_context.submit(None); } } /// The wgpu stack a surface renders with. /// /// [`SkiaSharedContext`] caches the one the first window created, so that later windows it can /// serve reuse it instead of creating their own. /// That cache holds a weak reference and every surface a strong one, so the resources go away /// with the last surface using them. /// Were they to outlive the surfaces, they'd be destroyed whenever the last renderer happens to /// be dropped, and tearing a wgpu device down from a thread-local destructor aborts the process /// on macOS, where the Metal backend needs an autorelease pool that is gone by then. /// A window that is still shown owns its surface, so that case remains. pub(crate) struct SharedWgpuState { instance: wgpu::Instance, adapter: wgpu::Adapter, device: wgpu::Device, queue: wgpu::Queue, } /// Whether `device` offers everything `settings` asks of it. /// /// The instance and adapter parts of the settings are covered by /// [`adapter_matches_graphics_api_request`]. #[cfg(feature = "unstable-wgpu-30")] fn device_satisfies_settings( device: &wgpu::Device, settings: &i_slint_core::graphics::wgpu_30::api::WGPUSettings, ) -> bool { device.features().contains(settings.device_required_features) && settings.device_required_limits.check_limits(&device.limits()) } fn adapter_matches_graphics_api_request( adapter: &wgpu::Adapter, requested_graphics_api: Option<&RequestedGraphicsAPI>, ) -> bool { let backend = adapter.get_info().backend; #[cfg_attr(slint_nightly_test, allow(non_exhaustive_omitted_patterns))] match requested_graphics_api { None => true, Some(RequestedGraphicsAPI::Metal) => backend == wgpu::Backend::Metal, Some(RequestedGraphicsAPI::Vulkan) => backend == wgpu::Backend::Vulkan, Some(RequestedGraphicsAPI::Direct3D) => backend == wgpu::Backend::Dx12, #[cfg(feature = "unstable-wgpu-30")] Some(RequestedGraphicsAPI::WGPU30( i_slint_core::graphics::wgpu_30::api::WGPUConfiguration::Automatic(settings), )) => { let backend_bit = match backend { wgpu::Backend::Vulkan => wgpu::Backends::VULKAN, wgpu::Backend::Metal => wgpu::Backends::METAL, wgpu::Backend::Dx12 => wgpu::Backends::DX12, wgpu::Backend::Gl => wgpu::Backends::GL, wgpu::Backend::BrowserWebGpu => wgpu::Backends::BROWSER_WEBGPU, _ => return false, }; settings.backends.contains(backend_bit) } Some(_) => false, } } impl crate::Surface for WGPUSurface { fn new( shared_context: &SkiaSharedContext, window_handle: Arc, display_handle: Arc, size: PhysicalWindowSize, requested_graphics_api: Option, ) -> Result { let make_target = || -> Box { Box::new(WindowAndDisplayHandle(window_handle.clone(), display_handle.clone())) }; #[cfg(feature = "unstable-wgpu-30")] let manual_configuration = matches!( &requested_graphics_api, Some(RequestedGraphicsAPI::WGPU30( i_slint_core::graphics::wgpu_30::api::WGPUConfiguration::Manual { .. } )) ); #[cfg(not(feature = "unstable-wgpu-30"))] let manual_configuration = false; #[cfg(feature = "unstable-wgpu-30")] #[cfg_attr(slint_nightly_test, allow(non_exhaustive_omitted_patterns))] let requested_settings = match &requested_graphics_api { Some(RequestedGraphicsAPI::WGPU30( i_slint_core::graphics::wgpu_30::api::WGPUConfiguration::Automatic(settings), )) => Some(settings.clone()), _ => None, }; // try to reuse old / shared graphics primitives from previous windows with matching // settings if !manual_configuration { let shared_state = shared_context.0.wgpu_30_state.borrow().upgrade(); if let Some(shared) = shared_state { #[cfg(feature = "unstable-wgpu-30")] let settings_compatible = requested_settings .as_ref() .is_none_or(|settings| device_satisfies_settings(&shared.device, settings)); #[cfg(not(feature = "unstable-wgpu-30"))] let settings_compatible = true; if settings_compatible && adapter_matches_graphics_api_request( &shared.adapter, requested_graphics_api.as_ref(), ) && let Ok(surface) = shared.instance.create_surface(make_target()) && shared.adapter.is_surface_supported(&surface) { match Self::init_with_parts(shared, surface, size) { Ok(surface) => return Ok(surface), Err(err) => { // The shared device may be lost. Drop it and start over. i_slint_core::debug_log!( "Failed to reuse the shared WGPU device: {err} . Re-initializing" ); shared_context.0.wgpu_30_state.take(); } } } } } let (instance, adapter, device, queue, surface) = i_slint_core::graphics::wgpu_30::init_instance_adapter_device_queue_surface( make_target(), requested_graphics_api, i_slint_core::graphics::wgpu_30::default_backends_to_avoid(), )?; let wgpu = Rc::new(SharedWgpuState { instance, adapter, device, queue }); let new_surface = Self::init_with_parts(wgpu.clone(), surface, size)?; if !manual_configuration { *shared_context.0.wgpu_30_state.borrow_mut() = Rc::downgrade(&wgpu); } Ok(new_surface) } fn name(&self) -> &'static str { if self.surface.is_some() { "wgpu" } else { "wgpu-texture" } } fn resize_event(&self, size: PhysicalWindowSize) -> Result<(), PlatformError> { let mut surface_config_opt = self.surface_config.borrow_mut(); let (Some(surface_config), Some(surface)) = (surface_config_opt.as_mut(), &self.surface) else { return Ok(()); }; self.discard_pending_frame(); // Skip reconfigure if size hasn't changed — DRM/KMS surfaces don't // support being reconfigured. if surface_config.width == size.width && surface_config.height == size.height { return Ok(()); } { let gr_context = &mut self.gr_context.borrow_mut(); // This is brute force, but for the lack of access to the fences this seems to work: Avoid any pending work so that // IDXGISwapChain::ResizeBuffers doesn't complain that the surface is still in use. gr_context.flush_submit_and_sync_cpu(); } surface_config.width = size.width; surface_config.height = size.height; surface.configure(&self.wgpu.device, surface_config); Ok(()) } fn render( &self, _window: &Window, _size: PhysicalWindowSize, callback: &dyn Fn( &skia_safe::Canvas, Option<&mut skia_safe::gpu::DirectContext>, u8, ) -> Option, pre_present_callback: &RefCell>>, ) -> Result { let (Some(surface), Some(surface_config)) = (&self.surface, &*self.surface_config.borrow()) else { return Err("WGPUSurface::render() called on offscreen surface".into()); }; let gr_context = &mut self.gr_context.borrow_mut(); // Jank-hunt: Vulkan stage timing (see vkprobe). let __f = vkprobe::FRAME.fetch_add(1, std::sync::atomic::Ordering::Relaxed); vkprobe::scn_reset(); // RENDER_AHEAD: present the frame prepared by the PREVIOUS render() // call FIRST, so the present lands at (vsync wake + ~1.1 ms) instead // of (vsync wake + full render). The frame prepared below is held // (when ahead is active) until the next call presents it (one slot // of constant latency). // // This block must run in BOTH modes: an unlocked render following a // held frame must PRESENT the held frame (not just drop it) — // wgpu's Presentation tracks the acquired image independently of the // Rust Arc, so a bare drop leaves it "acquired" and the next // get_current_texture fails with AlreadyAcquired. let __ahead = vkprobe::RENDER_AHEAD.load(std::sync::atomic::Ordering::Relaxed) && vkprobe::RENDER_AHEAD_ACTIVE.load(std::sync::atomic::Ordering::Relaxed); if let Some((pf, pf_idx)) = self.pending_frame.borrow_mut().take() { if let Some(cb) = pre_present_callback.borrow_mut().as_mut() { cb(); } let t_pre = vkprobe::now_us(); self.wgpu.queue.present(pf); let pre_us = vkprobe::now_us().saturating_sub(t_pre); vkprobe::report("PRE", &vkprobe::PRE, pre_us); let mon = vkprobe::monotonic_ns(); if mon > 0 { log::info!("PRES f={} mon={} pre_us={}", pf_idx, mon, pre_us); } } let __cpu0 = vkprobe::thread_cpu_ns(); let __t_acq = vkprobe::now_us(); let frame = match surface.get_current_texture() { wgpu::CurrentSurfaceTexture::Success(t) => t, wgpu::CurrentSurfaceTexture::Occluded => return Ok(DrawOutcome::Occluded), wgpu::CurrentSurfaceTexture::Timeout => return Ok(DrawOutcome::Timeout), wgpu::CurrentSurfaceTexture::Validation => { return Err("WGPU surface validation error in get_current_texture".into()); } stale @ (wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Suboptimal(_) | wgpu::CurrentSurfaceTexture::Lost) => { // `Suboptimal` carries a live `SurfaceTexture`; matched with `_` it is not bound, // so the value returned by `get_current_texture()` keeps it alive across the // `surface.configure()` below — which wgpu forbids ("`SurfaceOutput` must be // dropped before a new `Surface` is made"), panicking on the first frame on // Wayland. Drop it first. (`Outdated`/`Lost` carry nothing → no-op.) drop(stale); surface.configure(&self.wgpu.device, surface_config); match surface.get_current_texture() { wgpu::CurrentSurfaceTexture::Success(t) => t, _ => return Ok(DrawOutcome::Occluded), } } }; vkprobe::report("ACQ", &vkprobe::ACQ, vkprobe::now_us().saturating_sub(__t_acq)); if !vkprobe::REORDER_NOCLR.load(std::sync::atomic::Ordering::Relaxed) { // Skia renders through the raw backend queue, invisible to wgpu's usage // tracking, so `Queue::present` would treat the frame as never written // and clear it. Clear it through wgpu instead before Skia draws; the // in-order queue keeps Skia's later-committed work on top. let __t_clr = vkprobe::now_us(); let view = frame.texture.create_view(&wgpu::TextureViewDescriptor::default()); let mut encoder = self.wgpu.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("Slint frame init"), }); encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("Slint frame init"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &view, depth_slice: None, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, multiview_mask: None, }); self.wgpu.queue.submit(Some(encoder.finish())); vkprobe::report("CLR", &vkprobe::CLR, vkprobe::now_us().saturating_sub(__t_clr)); } let __t_skd = vkprobe::now_us(); let skia_surface = self.backend.make_swapchain_surface(gr_context, &frame.texture); let mut skia_surface = skia_surface .ok_or_else(|| PlatformError::from("Failed to create Skia surface from WGPU"))?; callback(skia_surface.canvas(), Some(gr_context), 0); self.backend.release_swapchain_surface(gr_context, &mut skia_surface); vkprobe::report("SKD", &vkprobe::SKD, vkprobe::now_us().saturating_sub(__t_skd)); vkprobe::scn_report(__f); let __t_skf = vkprobe::now_us(); self.flush_and_submit(gr_context); vkprobe::report("SKF", &vkprobe::SKF, vkprobe::now_us().saturating_sub(__t_skf)); // Skia drew via the raw queue behind wgpu's back, and `Queue::present` skips its own // transition when the tracker already says PRESENT, so nothing would order the // presentation after Skia's work: the clear submission above signals its semaphore // before Skia even starts. Referencing the frame texture in a submission of our own // makes present wait for it. (With REORDER_NOCLR this submit is the ONLY // wgpu submission of the frame and happens AFTER Skia's, so the in-order // vkQueue + present's semaphore wait still order presentation after the draw.) let __t_ord = vkprobe::now_us(); let mut encoder = self.wgpu.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("Skia present ordering encoder"), }); encoder.transition_resources( std::iter::empty(), std::iter::once(wgpu::TextureTransition { texture: &frame.texture, selector: None, state: wgpu::TextureUses::PRESENT, }), ); self.wgpu.queue.submit(Some(encoder.finish())); vkprobe::report("ORD", &vkprobe::ORD, vkprobe::now_us().saturating_sub(__t_ord)); if let Some(pre_present_callback) = pre_present_callback.borrow_mut().as_mut() { pre_present_callback(); } let __t_pre = vkprobe::now_us(); let __pre_us = if __ahead { // Render-ahead (locked/animating renders): do NOT present yet; // hold the frame for the next render() call (one slot of // constant latency). The PREVIOUS frame was already presented at // the start of this call. (The very first frame of the app's // lifetime is an unlocked render → presented immediately, so the // screen never stays black.) self.pending_frame.borrow_mut().replace((frame, __f)); 0 } else { // Unlocked render: present immediately. (Any frame left in the // held slot was already presented at the top of this call.) // PRESENT GATE: while animating, land this present at a fixed // vsync-grid phase (next edge + 1.5 ms) so frame-to-frame // cadence is exactly one period (see `present_gate`). vkprobe::present_gate(); self.wgpu.queue.present(frame); vkprobe::now_us().saturating_sub(__t_pre) }; if __pre_us > 0 { vkprobe::report("PRE", &vkprobe::PRE, __pre_us); // Frame-alignment probe: monotonic ns at the moment present() // returned, tagged with the frame counter. Correlated offline // against VSYNC mon= stamps: phase = pres_mon - previous_vsync_mon. let __mon = vkprobe::monotonic_ns(); if __mon > 0 { log::info!( "PRES f={} mon={} pre_us={} gate_us={}", __f, __mon, __pre_us, vkprobe::GATE_US.load(std::sync::atomic::Ordering::Relaxed) ); } } // Jank-hunt: for slow frames, CPU-vs-wall split for the WHOLE render // window. Correlated offline with the VKF line (time inside vk calls // per frame) from the Vulkan shim: // wall ≈ cpu → pure CPU work (Skia/wgpu on the UI thread) // wall ≫ cpu, vk-time high → blocked inside the driver (fence etc.) // wall ≫ cpu, vk-time low → descheduled/between calls let __wall = vkprobe::now_us().saturating_sub(__t_acq); let __cpu = vkprobe::thread_cpu_ns().saturating_sub(__cpu0) / 1000; if __wall >= 8_000 { log::info!("CPUW f={} wall_us={} cpu_us={}", __f, __wall, __cpu); } Ok(DrawOutcome::Success) } fn bits_per_pixel(&self) -> Result { if let Some(surface_config) = &*self.surface_config.borrow() { Ok(match surface_config.format { wgpu_30::TextureFormat::Rgba8Unorm | wgpu_30::TextureFormat::Rgba8UnormSrgb | wgpu_30::TextureFormat::Bgra8Unorm | wgpu_30::TextureFormat::Bgra8UnormSrgb => 32, fmt => return Err(format!("Unsupported surface format {:#?}", fmt).into()), }) } else { // All supported render-target formats (Rgba8Unorm, Bgra8Unorm, and sRGB variants) are 32bpp. Ok(32) } } #[cfg(feature = "unstable-wgpu-30")] fn with_graphics_api(&self, callback: &mut dyn FnMut(GraphicsAPI<'_>)) { let api = i_slint_core::graphics::create_graphics_api_wgpu_30( self.wgpu.instance.clone(), self.wgpu.device.clone(), self.wgpu.queue.clone(), ); callback(api) } #[cfg(any(feature = "unstable-wgpu-29", feature = "unstable-wgpu-30"))] fn import_wgpu_texture( &self, canvas: &skia_safe::Canvas, any_wgpu_texture: &i_slint_core::graphics::WGPUTexture, ) -> Option { let texture: wgpu_30::Texture = match any_wgpu_texture { #[cfg(feature = "unstable-wgpu-29")] i_slint_core::graphics::WGPUTexture::WGPU29Texture(..) => return None, #[cfg(feature = "unstable-wgpu-30")] i_slint_core::graphics::WGPUTexture::WGPU30Texture(texture) => texture.clone(), }; // Skia won't submit commands right away, so remember the texture and transition before // submitting. self.textures_to_transition_for_sampling.borrow_mut().push(texture.clone()); self.backend.import_texture(canvas, texture) } fn set_transparent(&self, transparent: bool) -> Result<(), PlatformError> { // `Opaque` discards the scene's alpha; pick a translucent mode if offered. // Metal (CAMetalLayer) only offers `PostMultiplied`, so it must be a fallback. use wgpu::CompositeAlphaMode::{Opaque, PostMultiplied, PreMultiplied}; let wanted: &[wgpu::CompositeAlphaMode] = if transparent { &[PreMultiplied, PostMultiplied] } else { &[Opaque] }; let Some(mode) = wanted.iter().copied().find(|m| self.alpha_modes.contains(m)) else { return Ok(()); }; let mut surface_config_opt = self.surface_config.borrow_mut(); let (Some(surface_config), Some(surface)) = (surface_config_opt.as_mut(), &self.surface) else { return Ok(()); }; if surface_config.alpha_mode != mode { surface_config.alpha_mode = mode; self.discard_pending_frame(); surface.configure(&self.wgpu.device, surface_config); } Ok(()) } fn as_any(&self) -> &dyn core::any::Any { self } } struct WindowAndDisplayHandle( Arc, Arc, ); impl raw_window_handle::HasWindowHandle for WindowAndDisplayHandle { fn window_handle( &self, ) -> Result, raw_window_handle::HandleError> { self.0.window_handle() } } impl raw_window_handle::HasDisplayHandle for WindowAndDisplayHandle { fn display_handle( &self, ) -> Result, raw_window_handle::HandleError> { self.1.display_handle() } } pub(crate) enum Backend { #[cfg(target_vendor = "apple")] Metal, #[cfg(target_family = "windows")] Dx12, #[cfg(skia_wgpu_vulkan)] Vulkan { /// The family Skia has to hand the swapchain image back to, see /// [`Backend::release_swapchain_surface`]. queue_family_index: u32, }, } impl Backend { pub(crate) fn new( adapter: &wgpu::Adapter, _device: &wgpu::Device, ) -> Result { match adapter.get_info().backend { wgpu_30::Backend::Noop => { Err(PlatformError::from("Cannot use WGPU Noop backend with Skia")) } #[cfg(skia_wgpu_vulkan)] wgpu_30::Backend::Vulkan => Ok(Self::Vulkan { // SAFETY: `_device` is a Vulkan device, as the adapter's backend just said. queue_family_index: unsafe { vulkan::queue_family_index(_device) }.ok_or_else( || { PlatformError::from( "Cannot query the queue family of the WGPU Vulkan device", ) }, )?, }), #[cfg(target_vendor = "apple")] wgpu_30::Backend::Metal => Ok(Self::Metal), #[cfg(target_family = "windows")] wgpu_30::Backend::Dx12 => Ok(Self::Dx12), other => Err(PlatformError::from(format!( "Unsupported WGPU backend for use with Skia: {}", other ))), } } pub(crate) fn make_context( &self, _adapter: &wgpu::Adapter, device: &wgpu::Device, queue: &wgpu::Queue, ) -> Option { match self { #[cfg(target_vendor = "apple")] Self::Metal => metal::make_metal_context(device, queue), #[cfg(target_family = "windows")] Self::Dx12 => unsafe { dx12::make_dx12_context(&_adapter, &device, &queue) }, #[cfg(skia_wgpu_vulkan)] Self::Vulkan { .. } => unsafe { vulkan::make_vulkan_context(device, queue) }, } } pub(crate) fn make_surface( &self, gr_context: &mut skia_safe::gpu::DirectContext, texture: &wgpu::Texture, ) -> Option { match self { #[cfg(target_vendor = "apple")] Self::Metal => unsafe { metal::make_metal_surface(gr_context, texture) }, #[cfg(target_family = "windows")] Self::Dx12 => unsafe { dx12::make_dx12_surface(gr_context, texture) }, #[cfg(skia_wgpu_vulkan)] Self::Vulkan { .. } => unsafe { vulkan::make_vulkan_surface( gr_context, texture, skia_safe::gpu::vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL, ) }, } } /// Like [`Self::make_surface`], but for the swapchain image handed out by /// [`wgpu::Surface::get_current_texture`]. /// /// wgpu returns surface textures to their `PRESENT` state at the end of every submission, /// so that is the layout Skia finds the image in, not the color-attachment one a plain /// render target is left in. Pair every call with [`Self::release_swapchain_surface`]. pub(crate) fn make_swapchain_surface( &self, gr_context: &mut skia_safe::gpu::DirectContext, texture: &wgpu::Texture, ) -> Option { match self { #[cfg(target_vendor = "apple")] Self::Metal => unsafe { metal::make_metal_surface(gr_context, texture) }, #[cfg(target_family = "windows")] Self::Dx12 => unsafe { dx12::make_dx12_surface(gr_context, texture) }, #[cfg(skia_wgpu_vulkan)] Self::Vulkan { .. } => unsafe { vulkan::make_vulkan_surface( gr_context, texture, skia_safe::gpu::vk::ImageLayout::PRESENT_SRC_KHR, ) }, } } /// Hands a surface made by [`Self::make_swapchain_surface`] back in the state /// [`wgpu::Queue::present`] expects to find it in. pub(crate) fn release_swapchain_surface( &self, _gr_context: &mut skia_safe::gpu::DirectContext, _skia_surface: &mut skia_safe::Surface, ) { match self { // Metal and D3D12 have no image layout for Skia to hand back. #[cfg(target_vendor = "apple")] Self::Metal => {} #[cfg(target_family = "windows")] Self::Dx12 => {} #[cfg(skia_wgpu_vulkan)] Self::Vulkan { queue_family_index } => vulkan::release_vulkan_swapchain_surface( _gr_context, _skia_surface, *queue_family_index, ), } } #[cfg_attr(not(feature = "unstable-wgpu-30"), allow(dead_code))] pub(crate) fn import_texture( &self, canvas: &skia_safe::Canvas, texture: wgpu::Texture, ) -> Option { match self { #[cfg(target_vendor = "apple")] Self::Metal => unsafe { metal::import_metal_texture(canvas, texture) }, #[cfg(target_family = "windows")] Self::Dx12 => unsafe { dx12::import_dx12_texture(canvas, texture) }, #[cfg(skia_wgpu_vulkan)] Self::Vulkan { .. } => unsafe { vulkan::import_vulkan_texture(canvas, texture) }, } } }