gallery/vendor/i-slint-renderer-skia/wgpu_30_surface.rs
Greg Pomerantz 2f4768f354 Fix 120Hz scroll jank end-to-end: render-ahead present + full-cache eviction off the UI thread
Three root causes, all fixed and verified on the Pixel 9 Pro:

1. Present phase (backend + renderer): VSYNC_LOCKED_RENDER defers the
   render to the vsync wake; RENDER_AHEAD presents the previous frame at
   the start of render() (phase = wake + ~1.1 ms) and holds the new
   frame one slot. Crash fix: a held wgpu frame must be PRESENTED, never
   merely dropped, before the next acquire (AlreadyAcquired panic at
   launch otherwise); discard_pending_frame presents too.

2. Full-res prefetch churn (src/lib.rs): the 8-slot full cache cycled
   through a ~20-cell viewport forever (137 decodes/20 s) and each
   eviction free()'d a 50 MB Vec on the UI thread (20-52 ms CPU spikes,
   ~7/s = the residual SLOWEV NonMain jank). Fix: 60 s tombstone
   cooldown on evicted paths (idle prefetch skips, urgent tap bypasses)
   + off-thread reaper for the freed blobs (insert: 21 ms -> 4 us).

3. (earlier) Skia final flush + Mailbox present mode (vendor, M3).

Instrumentation (all behind --features perf): OTHR/INVK/INVK_TAG/FLCK
lines for cross-thread closure timing and push-site tagging.

Warm measured swipe, 8580-file grid: cadence median 8.33 ms (exact
120 Hz), p99 15.5 ms, 5/552 gaps >16.7 ms (0.9%, max 28.3 ms, zero
>33 ms); PRES phase median ~994 us, zero late frames. Docs updated
(docs/BUGS/scroll-jank-120hz.md, full scorecard + line reference).

Also: vendored i-slint-core + wgpu-hal (vkshim interposition patch),
vkshim profiling tooling, backend made a non-optional dep, debuggable
flag for simpleperf (REMOVE before shipping).
2026-10-30 02:49:23 -04:00

911 lines
38 KiB
Rust

// Copyright © SixtyFPS GmbH <info@slint.dev>
// 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);
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<skia_safe::gpu::DirectContext>,
wgpu: Rc<SharedWgpuState>,
surface_config: RefCell<Option<wgpu::SurfaceConfiguration>>,
surface: Option<wgpu::Surface<'static>>,
textures_to_transition_for_sampling: RefCell<Vec<wgpu::Texture>>,
pub(crate) backend: Backend,
alpha_modes: Vec<wgpu::CompositeAlphaMode>,
/// 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<Option<(wgpu::SurfaceTexture, u64)>>,
}
impl WGPUSurface {
pub fn new_with_surface(
surface_target: impl Into<i_slint_core::graphics::wgpu_30::SurfaceTarget>,
size: PhysicalWindowSize,
requested_graphics_api: Option<RequestedGraphicsAPI>,
) -> Result<Self, PlatformError> {
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<SharedWgpuState>,
surface: wgpu::Surface<'static>,
size: PhysicalWindowSize,
) -> Result<Self, PlatformError> {
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<dyn raw_window_handle::HasWindowHandle + Send + Sync>,
display_handle: Arc<dyn raw_window_handle::HasDisplayHandle + Send + Sync>,
size: PhysicalWindowSize,
requested_graphics_api: Option<RequestedGraphicsAPI>,
) -> Result<Self, PlatformError> {
let make_target = || -> Box<dyn wgpu::DisplayAndWindowHandle + 'static> {
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<DirtyRegion>,
pre_present_callback: &RefCell<Option<Box<dyn FnMut()>>>,
) -> Result<DrawOutcome, PlatformError> {
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.)
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={}", __f, __mon, __pre_us);
}
}
// 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<u8, PlatformError> {
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<skia_safe::Image> {
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<dyn raw_window_handle::HasWindowHandle + Send + Sync>,
Arc<dyn raw_window_handle::HasDisplayHandle + Send + Sync>,
);
impl raw_window_handle::HasWindowHandle for WindowAndDisplayHandle {
fn window_handle(
&self,
) -> Result<raw_window_handle::WindowHandle<'_>, raw_window_handle::HandleError> {
self.0.window_handle()
}
}
impl raw_window_handle::HasDisplayHandle for WindowAndDisplayHandle {
fn display_handle(
&self,
) -> Result<raw_window_handle::DisplayHandle<'_>, 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<Self, PlatformError> {
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<skia_safe::gpu::DirectContext> {
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<skia_safe::Surface> {
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<skia_safe::Surface> {
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<skia_safe::Image> {
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) },
}
}
}