Files
dtourolle 4b4c9e2e6d Pre-rotate Slint's Skia drawing on the wgpu swapchain on Android
The other half of the wgpu-hal patch: that one lets a caller promise a
pre-rotated swapchain, and this is the caller keeping the promise.

On configure, `WGPUSurface` reads the surface's `currentTransform`,
sizes the swapchain in the panel's orientation (swapped for a quarter
turn), tells wgpu-hal to use that transform, and before each frame
concatenates the matching rotation onto the Skia canvas. Everything
Slint draws goes through that one matrix, so an imported wgpu texture is
rotated with the rest of the window. Input is not rotated, and must not
be, because Android delivers it in window coordinates.

Three details that would each have been a visible bug:

- `resize_event` compared the new size against the swapchain's. The
  swapchain is transposed while a quarter turn is in effect, so the
  comparison now uses the window's size, kept beside it.
- A half turn, landscape to reverse landscape, changes the transform
  without resizing the window, and wgpu-hal hides the SUBOPTIMAL that
  would report it. So the transform is re-read before every frame. That
  costs one query into the native window.
- The item renderer snapped the origin to the pixel grid only when the
  canvas matrix was a pure translation. Under a rotation that is never
  true, so portrait would have lost pixel alignment everywhere. The check
  now accepts right-angle rotations and flips without scaling.

The direction of each rotation follows the Vulkan spec's reading of
preTransform (the image is drawn already rotated clockwise by the
transform). It has not been confirmed on the device yet.
2026-09-25 04:19:05 -04:00

538 lines
20 KiB
Rust
Raw Permalink 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-29")]
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::{Cell, RefCell};
use std::sync::Arc;
use wgpu_29 as wgpu;
use crate::SkiaSharedContext;
#[cfg(target_family = "windows")]
mod dx12;
#[cfg(target_vendor = "apple")]
mod metal;
#[cfg(all(target_family = "unix", not(target_vendor = "apple")))]
mod vulkan;
/// 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>,
instance: wgpu::Instance,
device: wgpu::Device,
queue: wgpu::Queue,
surface_config: RefCell<Option<wgpu::SurfaceConfiguration>>,
surface: Option<wgpu::Surface<'static>>,
textures_to_transition_for_sampling: RefCell<Vec<wgpu::Texture>>,
pub(crate) backend: Backend,
// DarkRoom patch: pre-rotation. The adapter is kept to ask the surface
// for its `currentTransform`; `window_size` is the size the window
// reported, which differs from the swapchain's when `rotation` is 90° or
// 270°; `rotation` is what the current swapchain was promised.
adapter: Option<wgpu::Adapter>,
window_size: Cell<PhysicalWindowSize>,
rotation: Cell<Rotation>,
}
/// DarkRoom patch: the right-angle transforms a swapchain can be pre-rotated
/// by, clockwise, as the presentation engine will apply them.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Rotation {
None,
Cw90,
Cw180,
Cw270,
}
impl Rotation {
fn swaps_axes(self) -> bool {
matches!(self, Rotation::Cw90 | Rotation::Cw270)
}
/// Map window coordinates onto a buffer sized for this rotation.
///
/// The window is `w` × `h`. A clockwise quarter turn sends its top-left
/// corner to the buffer's top-right, so a point (x, y) lands at
/// (h − y, x): Skia's `rotate(90)` gives (−y, x), and translating by the
/// buffer width `h` first brings that back on screen. The other two follow
/// the same way.
fn apply(self, canvas: &skia_safe::Canvas, w: f32, h: f32) {
match self {
Rotation::None => {}
Rotation::Cw90 => {
canvas.translate((h, 0.));
canvas.rotate(90., None);
}
Rotation::Cw180 => {
canvas.translate((w, h));
canvas.rotate(180., None);
}
Rotation::Cw270 => {
canvas.translate((0., w));
canvas.rotate(270., None);
}
}
}
}
impl WGPUSurface {
pub fn new_with_surface(
surface_target: impl Into<i_slint_core::graphics::wgpu_29::SurfaceTarget>,
size: PhysicalWindowSize,
requested_graphics_api: Option<RequestedGraphicsAPI>,
) -> Result<Self, PlatformError> {
let (instance, adapter, device, queue, surface) =
i_slint_core::graphics::wgpu_29::init_instance_adapter_device_queue_surface(
surface_target,
requested_graphics_api,
wgpu::Backends::GL /* we're not mapping that to skia because we can't save/restore state */
.union(if cfg!(target_os = "windows") {
wgpu::Backends::VULKAN
} else {
wgpu::Backends::empty()
}),
)?;
let mut surface_config =
surface.get_default_config(&adapter, size.width, size.height).unwrap();
let rotation = pre_rotate(&surface, &adapter, &mut surface_config, size);
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;
surface.configure(&device, &surface_config);
let backend: Backend = adapter.get_info().backend.try_into()?;
let gr_context = backend.make_context(&adapter, &device, &queue);
Ok(Self {
gr_context: RefCell::new(
gr_context.ok_or_else(|| {
PlatformError::from("Failed to create Skia context from WGPU")
})?,
),
instance,
device,
queue,
surface_config: Some(surface_config).into(),
surface: Some(surface),
textures_to_transition_for_sampling: RefCell::new(Vec::new()),
backend,
adapter: Some(adapter),
window_size: Cell::new(size),
rotation: Cell::new(rotation),
})
}
pub(crate) fn new_offscreen(
instance: wgpu::Instance,
device: wgpu::Device,
queue: wgpu::Queue,
backend: Backend,
gr_context: skia_safe::gpu::DirectContext,
) -> Self {
Self {
gr_context: RefCell::new(gr_context),
instance,
device,
queue,
surface_config: None.into(),
surface: None,
textures_to_transition_for_sampling: RefCell::new(Vec::new()),
backend,
adapter: None,
window_size: Cell::new(PhysicalWindowSize::default()),
rotation: Cell::new(Rotation::None),
}
}
/// 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.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.queue.submit(Some(encoder.finish()));
}
gr_context.submit(None);
}
/// DarkRoom patch: configure the surface for a window of `size`, sized
/// and pre-rotated for the transform the display has right now.
fn configure_for(
&self,
surface_config: &mut wgpu::SurfaceConfiguration,
size: PhysicalWindowSize,
) {
let (Some(surface), Some(adapter)) = (&self.surface, &self.adapter) else { return };
let rotation = pre_rotate(surface, adapter, surface_config, size);
surface.configure(&self.device, surface_config);
self.window_size.set(size);
self.rotation.set(rotation);
}
}
/// DarkRoom patch: size `config` for a window of `size` under the display's
/// current transform, and tell wgpu-hal to promise that transform to the
/// next swapchain. Returns the rotation every frame must then be drawn with.
///
/// Android only. Everywhere else, and whenever the transform is not a plain
/// rotation (a mirrored one, or a query that failed), this leaves the
/// configuration at the window's size with `IDENTITY` — upstream's
/// behaviour, under which the compositor rotates the buffer itself.
fn pre_rotate(
surface: &wgpu::Surface<'static>,
adapter: &wgpu::Adapter,
config: &mut wgpu::SurfaceConfiguration,
size: PhysicalWindowSize,
) -> Rotation {
let rotation = current_rotation(surface, adapter);
#[cfg(target_os = "android")]
unsafe {
use ash::vk::SurfaceTransformFlagsKHR as T;
if let Some(hal) = surface.as_hal::<wgpu::wgc::api::Vulkan>() {
hal.set_pre_transform(match rotation {
Rotation::None => T::IDENTITY,
Rotation::Cw90 => T::ROTATE_90,
Rotation::Cw180 => T::ROTATE_180,
Rotation::Cw270 => T::ROTATE_270,
});
}
}
(config.width, config.height) =
if rotation.swaps_axes() { (size.height, size.width) } else { (size.width, size.height) };
rotation
}
#[cfg(target_os = "android")]
fn current_rotation(surface: &wgpu::Surface<'static>, adapter: &wgpu::Adapter) -> Rotation {
use ash::vk::SurfaceTransformFlagsKHR as T;
let transform = unsafe {
let (Some(surface), Some(adapter)) = (
surface.as_hal::<wgpu::wgc::api::Vulkan>(),
adapter.as_hal::<wgpu::wgc::api::Vulkan>(),
) else {
return Rotation::None;
};
surface.current_transform(&adapter)
};
match transform {
Some(T::ROTATE_90) => Rotation::Cw90,
Some(T::ROTATE_180) => Rotation::Cw180,
Some(T::ROTATE_270) => Rotation::Cw270,
_ => Rotation::None,
}
}
#[cfg(not(target_os = "android"))]
fn current_rotation(_surface: &wgpu::Surface<'static>, _adapter: &wgpu::Adapter) -> Rotation {
Rotation::None
}
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> {
Self::new_with_surface(
Box::new(WindowAndDisplayHandle(window_handle, display_handle))
as Box<dyn wgpu::DisplayAndWindowHandle + 'static>,
size,
requested_graphics_api,
)
}
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_config_opt.as_mut(), &self.surface)
else {
return Ok(());
};
// Skip reconfigure if size hasn't changed — DRM/KMS surfaces don't
// support being reconfigured.
//
// DarkRoom patch: compared against the window's size rather than the
// swapchain's, which is transposed while pre-rotated a quarter turn.
if self.window_size.get() == size {
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();
}
// Prefer FIFO modes over possible Mailbox setting for frame pacing and better energy efficiency.
surface_config.present_mode = wgpu::PresentMode::AutoVsync;
self.configure_for(surface_config, size);
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> {
// DarkRoom patch: a turn from landscape to reverse landscape changes
// the display's transform without changing the window's size, so no
// resize arrives to say so — and wgpu-hal hides the
// `VK_SUBOPTIMAL_KHR` that would, on Android. Check before every
// frame and reconfigure when it moved; the query is one call into
// the native window.
if let (Some(surface), Some(adapter)) = (&self.surface, &self.adapter)
&& current_rotation(surface, adapter) != self.rotation.get()
&& let Some(surface_config) = self.surface_config.borrow_mut().as_mut()
{
self.gr_context.borrow_mut().flush_submit_and_sync_cpu();
self.configure_for(surface_config, self.window_size.get());
}
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();
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.device, surface_config);
match surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(t) => t,
_ => return Ok(DrawOutcome::Occluded),
}
}
};
let skia_surface = self.backend.make_surface(gr_context, &frame.texture);
let mut skia_surface = skia_surface
.ok_or_else(|| PlatformError::from("Failed to create Skia surface from WGPU"))?;
// DarkRoom patch: everything Slint draws, in window coordinates, goes
// through this one matrix into the pre-rotated buffer.
let window_size = self.window_size.get();
self.rotation.get().apply(
skia_surface.canvas(),
window_size.width as f32,
window_size.height as f32,
);
callback(skia_surface.canvas(), Some(gr_context), 0);
self.flush_and_submit(gr_context);
if let Some(pre_present_callback) = pre_present_callback.borrow_mut().as_mut() {
pre_present_callback();
}
frame.present();
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_29::TextureFormat::Rgba8Unorm
| wgpu_29::TextureFormat::Rgba8UnormSrgb
| wgpu_29::TextureFormat::Bgra8Unorm
| wgpu_29::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-29")]
fn with_graphics_api(&self, callback: &mut dyn FnMut(GraphicsAPI<'_>)) {
let api = i_slint_core::graphics::create_graphics_api_wgpu_29(
self.instance.clone(),
self.device.clone(),
self.queue.clone(),
);
callback(api)
}
#[cfg(any(feature = "unstable-wgpu-28", feature = "unstable-wgpu-29"))]
fn import_wgpu_texture(
&self,
canvas: &skia_safe::Canvas,
any_wgpu_texture: &i_slint_core::graphics::WGPUTexture,
) -> Option<skia_safe::Image> {
let texture = match any_wgpu_texture {
#[cfg(feature = "unstable-wgpu-28")]
i_slint_core::graphics::WGPUTexture::WGPU28Texture(..) => return None,
#[cfg(feature = "unstable-wgpu-29")]
i_slint_core::graphics::WGPUTexture::WGPU29Texture(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)
}
}
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(all(target_family = "unix", not(target_vendor = "apple")))]
Vulkan,
}
impl TryFrom<wgpu::Backend> for Backend {
type Error = PlatformError;
fn try_from(wgpu_backend: wgpu::Backend) -> Result<Self, Self::Error> {
match wgpu_backend {
wgpu_29::Backend::Noop => {
Err(PlatformError::from("Cannot use WGPU Noop backend with Skia"))
}
#[cfg(all(target_family = "unix", not(target_vendor = "apple")))]
wgpu_29::Backend::Vulkan => Ok(Self::Vulkan),
#[cfg(target_vendor = "apple")]
wgpu_29::Backend::Metal => Ok(Self::Metal),
#[cfg(target_family = "windows")]
wgpu_29::Backend::Dx12 => Ok(Self::Dx12),
other => Err(PlatformError::from(format!(
"Unsupported WGPU backend for use with Skia: {}",
other
))),
}
}
}
impl Backend {
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(all(target_family = "unix", not(target_vendor = "apple")))]
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(all(target_family = "unix", not(target_vendor = "apple")))]
Self::Vulkan => unsafe { vulkan::make_vulkan_surface(gr_context, texture) },
}
}
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(all(target_family = "unix", not(target_vendor = "apple")))]
Self::Vulkan => unsafe { vulkan::import_vulkan_texture(canvas, texture) },
}
}
}