gallery/vendor/i-slint-renderer-skia/wgpu_30_surface.rs
Greg Pomerantz 0a50f9b832 120Hz: fix the ALooper wake-fd race (root cause of slot drops) + phase-slip-robust referee
ROOT CAUSE of the 8-12% slot drops (uniform dec=1/0/1/0: locked, no wake
seen, dirty, no render): AOSP Looper::pollInner (system/core/libutils/
Looper.cpp) drains the wake fd in the SAME epoll batch (up to 16 fds) as
any ready input fd, and pollOnce returns the input's ident — the POLL_WAKE
result is a local that gets dropped. ALooper_wake's byte is consumed
WITHOUT PollEvent::Wake ever reaching Slint, unrecoverable. With motion
events arriving 1-4 ms after the edge (always for a real 120-240 Hz
finger; ~1 in 5 frames for 20 Hz adb input) the wake is lost and the
locked decision (saw_wake && slot-guard) drops the dirty frame for that
slot.

Fix (vendor backend): a dedicated eventfd registered with the MAIN looper
under ALOOPER_POLL_CALLBACK. The Looper invokes the C trampoline INSIDE
pollInner (before pollOnce returns, regardless of which ident is handed
back), so delivery can never be swallowed by a co-batched input response.
- frame_callback: note the edge, write the eventfd, then ALooper_wake
  (secondary). The fd is leaked: it belongs to the main looper for the
  process lifetime.
- trampoline: DRAINS the eventfd (level-triggered — without the drain the
  counter stays >0, the fd stays readable and the loop busy-spins on
  POLL_CALLBACK at ~10 kHz: 1812 spin iterations measured) and publishes
  the edge into delivered_edge.
- main loop: saw_wake |= (delivered_edge newer than last seen).
- DIRTY BACKSTOP: the 6 ms poll cap was never a backstop — timeout
  iterations have saw_wake=false so the locked decision never rendered
  (the 20-40 ms silences). Now: dirty && last render >= 2 slots old
  renders regardless of wake state.

Measured on the phone (Pixel 9 Pro, 120 Hz, sltest, identical capture):
  buildC (before): 127 drops + 12 stalls in 1142 frames
  buildE (after):    8 drops +  0 stalls in 1266 frames
Phase (p10-p90): 0.72-1.00 ms in all windows. Remaining 8/1266 (0.63%)
are slip-and-recover pairs where the UI thread is DESCHEDULED 6-8 ms
while sleeping in epoll (wakes arrive on time, cb-lat 0.5 ms) — EAS
scheduling variance, no app-side contention in sltest.

Referee fixes found while judging this data (tools/jank_eval.py):
- NEAREST-OBSERVED-EDGE slot grid: the 120 Hz panel clock phase-slips
  after idle blanking (re-locks at arbitrary phase), invalidating any
  grid anchored at ftns[0]. An entire drag window read late=230/
  dropped=24 while per-cycle tracing showed presents at edge+2.0 ms.
  slot_of_ftn assigns each present to the nearest observed hardware
  edge in a time-continuous slot domain (slip-immune: a constant delta
  shifts all slots, differences preserved; stall-accurate: ftn times
  are CLOCK_MONOTONIC).
- tools/jank_capture.py: capture is INVALID unless the screen is Awake
  AND the keyguard is down (a run on a sleeping phone silently injects
  no input: 49 IT lines, 1 present).
2026-10-30 16:08:48 -04:00

1023 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);
// ------------------------------------------------------------------
// 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);
/// WAKE PROBE: log every early-waker attempt (EW lines) — H1 check.
pub static EW_ON: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// 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<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();
// precpu = CPU time inside Queue::present (vkprobe::thread_cpu_ns
// exists already). pre_us≫precpu ⇒ present BLOCKED (binder/fence
// wait on the driver/SF — the "Android API call" contention);
// pre_us≈precpu ⇒ CPU-bound present.
let __pc0 = vkprobe::thread_cpu_ns();
self.wgpu.queue.present(pf);
let pre_us = vkprobe::now_us().saturating_sub(t_pre);
let precpu_us = vkprobe::thread_cpu_ns().saturating_sub(__pc0) / 1000;
vkprobe::report("PRE", &vkprobe::PRE, pre_us);
let mon = vkprobe::monotonic_ns();
if mon > 0 {
log::info!("PRES f={} mon={} pre_us={} precpu={}us", pf_idx, mon, pre_us, precpu_us);
}
}
let __cpu0 = vkprobe::thread_cpu_ns();
let __t_acq = vkprobe::now_us();
// NOPRES log (2026-10-30): an early return here leaves pending_frame
// EMPTY (the held frame was already presented at the top of this
// call), so the NEXT draw has nothing to present — one display slot
// vanishes with no PRES line. The cause is logged HERE, at the
// failing draw. Balance check: #FR == #PRES + #NOPRES + first-draw.
let __nopres = |why: &str| {
let mon = vkprobe::monotonic_ns();
if mon > 0 {
log::info!("NOPRES f={} mon={} why={}", __f, mon, why);
}
};
let frame = match surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(t) => t,
wgpu::CurrentSurfaceTexture::Occluded => { __nopres("occluded"); return Ok(DrawOutcome::Occluded); },
wgpu::CurrentSurfaceTexture::Timeout => { __nopres("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,
_ => { __nopres("reconfig"); 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<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) },
}
}
}