Files
iris/src/default/render.rs
T
irisandClaude Opus 5 144c402181 Fling: the vsync clock, the frame ask, and a report that can say what it measured
Iris, from her phone: "some stuttering when flinging in particular.
Harder to notice with my finger directly moving the scroll." Her fling
phase was 103fps on a 120Hz screen at p50 6.3ms.

Two of the four things found are corrections to the instrument, not the
renderer. The swapchain acquire -- `get_current_texture`, which *blocks*
until the compositor frees an image -- was inside the span the report
called iris's CPU work, so a fling comfortably ahead of the display read
as milliseconds of being slow. A frame is now three measured parts
(`FrameParts`: build, acquire, submit), per phase as well as per run. And
nothing could say a frame was never *produced*: `late` counts frames that
cost too much, which a reader does not see, while a frame that never
happens leaves the last one up for two refreshes, which is the stutter.
`PhaseStats::missed` counts vsyncs nothing was drawn for. It closes on
the emulator: 1548 frames + 452 missed over 33.0s at 60Hz is 1980
vsyncs.

The other two are the frame loop. `Choreographer.postFrameCallback`
schedules for the next vsync after the call, and iris asked at the *end*
of the callback -- so any frame whose work ran past the boundary
registered too late and got the vsync after, one frame over budget
silently costing a second. It is asked for immediately after
`tick_animations` now, on both backends. And the fling was advanced on
`Instant::now()` rather than the vsync `do_frame` carries: frames are
presented on an even cadence whatever clock computes them, so sampling
the spline at "whenever the callback ran" moves the content unevenly with
no frame late enough to appear in any report -- and a drag never had it,
which is the asymmetry Iris described. `PointerClock` is `DeviceClock`
and the view keeps one, anchored by whichever of a touch or a frame comes
first, so a fling is advanced on the clock its velocity was measured on.

`opt-level` for the Android release build goes from "s" to 3. The table
in RUST.md picked "s" on bytes alone; over the same warm fling eight
times iris's own per-frame work is p90 0.15ms/p99 0.42ms at "s" against
p90 0.09ms/p99 0.26ms at 3, for 1.8 MB of arm64 APK.

`app-rust/tests/fling_profile.rs` is the rig that established what a
fling frame actually costs and is kept for next time (Iris: "please keep
the profiling rig around for future use"): only one frame in six lays
anything out, and the multi-millisecond spikes are all first-pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:49:28 -04:00

256 lines
11 KiB
Rust

use crate::task::RequestRedraw;
use iris_core::{FrameParts, UiData, UiRenderNode, UiRenderState, util::Vec2};
use pollster::FutureExt;
use std::sync::Arc;
use std::time::Instant;
use wgpu::*;
use winit::{dpi::PhysicalSize, window::Window};
pub const CLEAR_COLOR: Color = Color::BLACK;
impl RequestRedraw for Window {
fn request_redraw(&self) {
Window::request_redraw(self);
}
}
pub struct UiRenderer {
window: Arc<Window>,
surface: Surface<'static>,
device: Device,
queue: Queue,
config: SurfaceConfiguration,
encoder: CommandEncoder,
pub ui: UiRenderNode,
}
impl UiRenderer {
pub fn update(&mut self, ui: &mut UiData, render: &mut UiRenderState) {
self.ui.update(&self.device, &self.queue, ui, render);
}
/// The two waits, so a desktop frame divides up the same way an
/// Android one does -- see `AndroidRenderer::draw` for why the
/// swapchain acquire is measured apart from the work.
pub fn draw(&mut self) -> FrameParts {
let acquire_start = Instant::now();
let output = match self.surface.get_current_texture() {
CurrentSurfaceTexture::Success(texture)
| CurrentSurfaceTexture::Suboptimal(texture) => texture,
// wgpu 30 turned this Result into an enum; every arm here was an
// `Err` the previous `.unwrap()` panicked on, except `Occluded`,
// which is new. Named rather than swallowed: a window that stops
// presenting silently is the state this file's `pre_present_notify`
// comment was written about.
other => panic!("no surface texture to draw into: {other:?}"),
};
let acquire = acquire_start.elapsed();
let view = output
.texture
.create_view(&TextureViewDescriptor::default());
let mut encoder = std::mem::replace(&mut self.encoder, Self::create_encoder(&self.device));
{
let render_pass = &mut encoder.begin_render_pass(&RenderPassDescriptor {
color_attachments: &[Some(RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: Operations {
load: LoadOp::Clear(CLEAR_COLOR),
store: StoreOp::Store,
},
depth_slice: None,
})],
..Default::default()
});
self.ui.draw(render_pass);
}
let submit_start = Instant::now();
self.queue.submit(std::iter::once(encoder.finish()));
// Immediately before presenting, so the windowing system can schedule
// the frame. On Wayland this is what ties the commit to the surface's
// frame callback; without it a frame drawn when nothing else follows
// could sit unpresented, and the window kept the layout it had before
// the compositor's first resize -- intermittently, on about a fifth of
// starts, with nothing left to flush it.
self.window.pre_present_notify();
self.queue.present(output);
FrameParts::waits(acquire, submit_start.elapsed())
}
pub fn resize(&mut self, size: &PhysicalSize<u32>) {
self.config.width = size.width;
self.config.height = size.height;
self.surface.configure(&self.device, &self.config);
// Physical, matching `new`'s own seed -- see the comment there.
self.ui.resize(
Vec2::new(size.width as f32, size.height as f32),
&self.queue,
);
}
fn create_encoder(device: &Device) -> CommandEncoder {
device.create_command_encoder(&CommandEncoderDescriptor {
label: Some("Render Encoder"),
})
}
pub fn new(window: Arc<Window>) -> Self {
let size = window.inner_size();
// `force-gles` on the desktop too, not just on Android: the
// GLES backend has behaviour of its own (a one-layer array
// texture is a `GL_TEXTURE_2D` -- see
// `GpuTextures::create_array_texture`), and a machine with a
// real GPU is where that is cheap to reproduce and screenshot.
let mut backends = if cfg!(feature = "force-gles") {
Backends::GL
} else {
Backends::PRIMARY
};
// The display handle comes from the window rather than being left
// out: wgpu 30 asks for it whenever a GLES surface is going to be
// presented on Wayland, which is exactly what the fallback below
// produces on this machine.
let mut instance = Instance::new(InstanceDescriptor {
backends,
..InstanceDescriptor::new_with_display_handle(Box::new(window.clone()))
});
// The same fallback the Android backend grew in 85869d0, and for
// the same reason: a machine can advertise a Vulkan ICD with no
// device behind it, and refusing to draw at all because the only
// usable adapter is a GLES one is iris's bug rather than the
// machine's. On this VM the Vulkan device disappears whenever
// the host refuses a virtio-gpu context, so `run-headless.sh` --
// layer 2 of the test rig -- aborted with `Could not get
// adapter!` while GL was sitting there working. Probed before the
// surface exists, matching Android, where an instance carrying
// both backends fails worse than one carrying the wrong one.
if backends != Backends::GL && instance.enumerate_adapters(backends).block_on().is_empty() {
log::warn!(
"iris renderer: no {backends:?} adapter on this machine, falling back to GLES"
);
backends = Backends::GL;
instance = Instance::new(InstanceDescriptor {
backends,
..InstanceDescriptor::new_with_display_handle(Box::new(window.clone()))
});
}
let surface = instance
.create_surface(window.clone())
.expect("Could not create window surface!");
let adapter = instance
.request_adapter(&RequestAdapterOptions {
power_preference: PowerPreference::default(),
compatible_surface: Some(&surface),
force_fallback_adapter: false,
..Default::default()
})
.block_on()
.unwrap_or_else(|error| {
panic!("No usable GPU adapter for backends {backends:?}: {error}")
});
// Say which adapter won, in the same words the Android backend
// uses. Without it a layer-2 screenshot or frame time from this
// window carries no record of what drew it, and the two cases that
// matter look identical in the PNG: the host's real GPU, and
// llvmpipe after this VM lost its virtio-gpu contexts. That
// happened on 2026-09-08, and the only reason anyone noticed is
// that the fallback above did not exist yet and the app aborted
// instead. A silent fallback needs this line to stay honest.
{
let info = adapter.get_info();
log::info!(
"iris renderer: {name} ({backend:?}, {driver}{driver_info}) on {backends:?}",
name = info.name,
backend = info.backend,
driver = info.driver,
driver_info = if info.driver_info.is_empty() {
String::new()
} else {
format!(" {}", info.driver_info)
},
);
}
// No features beyond what wgpu asks for by default, and no
// binding-array limits: the atlas is one texture_2d_array and a
// standalone image is its own ordinary bind group, neither of which
// needs descriptor indexing. See TEXTURES.md's "Recommended shape"
// for why the old binding array asked for
// VK_EXT_descriptor_indexing unconditionally and did not survive a
// real share of Android GPUs. `iris_core::device_limits()` is
// shared with the Android backend; see its own doc for why it is
// not simply `Limits::default()`.
let (device, queue) = adapter
.request_device(&DeviceDescriptor {
required_limits: iris_core::device_limits(),
..Default::default()
})
.block_on()
.expect("Could not get device!");
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps
.formats
.iter()
.copied()
.find(|f| f.is_srgb())
.unwrap_or(surface_caps.formats[0]);
let config = SurfaceConfiguration {
usage: TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
// wgpu 30's new field; `Auto` is what every earlier version did.
color_space: SurfaceColorSpace::Auto,
width: size.width,
height: size.height,
// Vsync, because a toolkit aiming at battery life must not present
// frames a display will never show: AutoNoVsync accepts them as
// fast as the GPU will take them, so a redraw burst costs whatever
// the hardware can be made to do rather than one frame.
// AutoVsync picks Fifo, which every backend supports.
present_mode: PresentMode::AutoVsync,
alpha_mode: surface_caps.alpha_modes[0],
desired_maximum_frame_latency: 2,
view_formats: vec![],
};
surface.configure(&device, &config);
let encoder = Self::create_encoder(&device);
// Unlike the Android backend, the desktop backend has no on-screen
// fallback to show a diagnostic through, so a renderer-creation
// failure still panics here -- but now with wgpu's full "Caused
// by:" chain as the message, since `UiRenderNode::new` returns it
// rather than letting wgpu's own default handler panic first (see
// that function's doc comment).
// Physical size, the same units the swapchain, `WindowEvent::
// Resized`, the pointer and the widget tree all use -- see
// `default::content_scale` for why this backend stopped dividing
// into a separate logical space, and what disagreed while it did.
let physical_size = Vec2::new(size.width as f32, size.height as f32);
let ui = UiRenderNode::new(&device, &queue, &config, physical_size)
.expect("Could not create iris render node!");
Self {
surface,
device,
queue,
config,
encoder,
ui,
window,
}
}
pub fn window(&self) -> &Window {
self.window.as_ref()
}
}