Draw with OpenGL on Android, where the driver owns the display rotation

The grid tore while scrolling on the tablet, in portrait only, and was
flawless in landscape. It was not vsync and it was not the grid.

Measured on the device, same build, only the tablet rotated:

    landscape   bufferTransform=ROT_180   composition=DEVICE (2)   clean
    portrait    bufferTransform=ROT_270   composition=CLIENT (1)   torn

The panel is mounted landscape — 1920x3000 at installOrientation 3 — so a
portrait window needs a 90 degree rotation before scanout. wgpu-hal hardcodes
the swapchain's `preTransform` to `IDENTITY` and says so in a comment beside
the line:

    // On Android 10+, libvulkan's `vkQueuePresentKHR` returns
    // `VK_SUBOPTIMAL_KHR` if not doing pre-rotation ... This is always the
    // case when the device orientation is anything other than the identity
    // one, as we unconditionally use `VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR`.

That is gfx-rs/wgpu#3345, and it cannot be fixed by setting the field:
`preTransform` is a *promise* that the content is already rotated, so keeping
it needs the renderer to rotate what it draws, which wgpu cannot do on Skia's
behalf.

We do not have to be on that swapchain. `AndroidWindowAdapter` chooses
`SkiaRenderer::default_wgpu_29` only because this crate enables
`unstable-wgpu-29`; without it `SkiaRenderer::default` resolves — through
i-slint-renderer-skia's build script, which selects OpenGL on anything that is
not Apple, Windows or wasm — to Skia over OpenGL, where the driver owns the
rotation and there is no transform to get wrong. So both renderer features
move to the desktop-only dependency, and desktop is untouched.

# The cost, stated rather than hidden

Skia over OpenGL cannot sample a `wgpu::Texture`, so the develop view's frame
comes back through memory: `AdjustPass::export_pixels`, already ungated and
already used by the export path, into a `SharedPixelBuffer`. That is the
round-trip ARCH §6.1 and AC-8 exist to forbid, and it is the right trade only
because of what the alternative actually is — not a faster develop view, but a
grid that tears in the orientation a tablet is mostly held in.

Two things keep it small. The device is still opened on Android, so demosaic
and the adjust pass are untouched on the GPU; only the last hop changes. And
`render` fits the pass to the canvas before it runs, so the readback is at
viewport resolution, a fraction of the ~7 ms at 4K the original measurement
was taken against.

Four other explanations died on the way here, each by measurement rather than
argument: the present mode (a patch confirmed in the installed binary reached
`AutoVsync`, and the rows still duplicated), our shared wgpu device (Slint
opened its own, unchanged), Skia's partial rendering (off for GPU surfaces),
and client composition itself (unavoidable in portrait on this panel, so it
cannot be what distinguishes a torn frame from a clean one).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 21:47:02 +02:00
co-authored by Claude Opus 5
parent f14176de29
commit 626780276d
4 changed files with 98 additions and 30 deletions
+13 -6
View File
@@ -62,11 +62,14 @@ rusqlite.workspace = true
# Consequence worth stating plainly: the desktop app now needs a working wgpu
# adapter to open a window at all. Slint refuses a CPU adapter for this
# renderer unless `SLINT_WGPU_CPU` is set in the environment.
slint = { workspace = true, features = [
"compat-1-2",
"renderer-femtovg-wgpu",
"unstable-wgpu-29",
] }
# TEST BUILD: the wgpu renderer features have moved to the desktop-only
# dependency below. On Android they made `AndroidWindowAdapter` choose
# `SkiaRenderer::default_wgpu_29`, and so put the app on wgpu's Vulkan
# swapchain — which hardcodes `preTransform = IDENTITY` (gfx-rs/wgpu#3345).
# Without them the Android backend uses `SkiaRenderer::default`, which on
# Android resolves to Skia over OpenGL, where the driver owns the display
# rotation and there is no transform to get wrong.
slint = { workspace = true, features = ["compat-1-2"] }
wgpu.workspace = true
anyhow.workspace = true
# `SettingsError` distinguishes an io failure from a malformed file, which the
@@ -82,7 +85,11 @@ serde_norway = { workspace = true, optional = true }
# under `cfg(target_os = "android")`, so this only has to name the feature;
# cargo resolves it away entirely on desktop.
[target.'cfg(not(target_os = "android"))'.dependencies]
slint = { workspace = true, features = ["backend-winit"] }
slint = { workspace = true, features = [
"backend-winit",
"renderer-femtovg-wgpu",
"unstable-wgpu-29",
] }
[target.'cfg(target_os = "android")'.dependencies]
slint = { workspace = true, features = ["backend-android-activity-06"] }
+23 -2
View File
@@ -2199,8 +2199,29 @@ impl DevelopSession {
// in `AdjustPass`'s texture descriptor — so a failure here is a
// descriptor that drifted, not anything the caller did. Say that,
// rather than surfacing "InvalidUsage" to a photographer.
slint::Image::try_from(texture.clone())
.map_err(|e| format!("the render target is not importable by the compositor: {e}"))
#[cfg(not(target_os = "android"))]
{
slint::Image::try_from(texture.clone())
.map_err(|e| format!("the render target is not importable by the compositor: {e}"))
}
// Android draws with Skia over OpenGL and cannot sample a
// `wgpu::Texture`, so the frame comes back through memory. See
// `crate::shared_gpu`'s Android arm for why that is the trade on this
// platform. The pass still runs on the GPU; only this last hop does not.
#[cfg(target_os = "android")]
{
let _ = texture;
let (rgba, w, h) = self
.adjust
.export_pixels()
.map_err(|e| format!("reading the rendered frame back: {e}"))?;
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(w, h);
let wanted = (w as usize) * (h as usize) * 4;
let src = &rgba[..wanted.min(rgba.len())];
buf.make_mut_bytes()[..src.len()].copy_from_slice(src);
Ok(slint::Image::from_rgba8(buf))
}
}
/// TRACES: FR-DSP-7
+40
View File
@@ -735,6 +735,7 @@ enum PointsUpdate {
///
/// `None` means develop is unavailable and the viewer falls back to embedded
/// previews — the same degradation as a machine with no adapter at all.
#[cfg(not(target_os = "android"))]
fn shared_gpu() -> Option<dr_gpu::GpuContext> {
let shared = match pollster::block_on(dr_gpu::GpuContext::new_shared()) {
Ok(shared) => shared,
@@ -775,6 +776,45 @@ fn shared_gpu() -> Option<dr_gpu::GpuContext> {
Some(ctx)
}
/// TRACES: FR-DSP-1 | AC-8
/// Open the compute device on Android — and do **not** give it to Slint.
///
/// # Why this platform is different
///
/// The desktop version above exists so one `wgpu::Texture` can be written by
/// the adjust pass and sampled by the compositor without a round-trip. That
/// requires Slint to be drawing with wgpu, and on Android drawing with wgpu
/// means drawing on wgpu's Vulkan swapchain — which hardcodes
/// `preTransform = IDENTITY` (gfx-rs/wgpu#3345).
///
/// On a tablet whose panel is mounted landscape, portrait then presents an
/// unrotated buffer, every present returns `VK_SUBOPTIMAL_KHR`, and the frames
/// arrive torn. Measured on the device: portrait sits on `composition=CLIENT`
/// with `bufferTransform=ROT_270`, landscape on `composition=DEVICE` with
/// `ROT_180`, and only portrait tore. Taking Slint off wgpu — it then uses
/// Skia over OpenGL, where the driver owns the rotation — fixed it.
///
/// # What it costs, and why that is the right trade here
///
/// Without a shared device the display path needs a readback:
/// `AdjustPass::export_pixels` instead of `Image::try_from(texture)`. That is
/// the transfer ARCH §6.1 and AC-8 exist to avoid, and it is still the better
/// bargain on this platform — the alternative is not a faster develop view, it
/// is a torn one, in the orientation a tablet is mostly held in.
///
/// The compute passes are untouched: demosaic and adjust still run on the GPU,
/// on this device. Only the last hop to the screen goes through memory.
#[cfg(target_os = "android")]
fn shared_gpu() -> Option<dr_gpu::GpuContext> {
match pollster::block_on(dr_gpu::GpuContext::new_headless()) {
Ok(ctx) => Some(ctx),
Err(e) => {
log::warn!("no GPU for the develop passes: {e}");
None
}
}
}
/// TRACES: M-13 | M-14
/// Build and run the viewer.
pub fn run(paths: Vec<PathBuf>) -> Result<()> {