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>
This commit is contained in:
1 parent
227f5e1295
commit
144c402181
10 files changed
+477
-151
No files matched your search
+24
-15
@@ -4,9 +4,9 @@ use android_view::{
|
||||
jni::{JavaVM, objects::GlobalRef},
|
||||
ndk::native_window::NativeWindow,
|
||||
};
|
||||
use iris_core::{UiData, UiRenderNode, UiRenderState};
|
||||
use iris_core::{FrameParts, UiData, UiRenderNode, UiRenderState};
|
||||
use pollster::FutureExt;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Instant;
|
||||
use wgpu::{
|
||||
rwh::{DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WindowHandle},
|
||||
*,
|
||||
@@ -415,18 +415,26 @@ impl AndroidRenderer {
|
||||
self.frame_count
|
||||
}
|
||||
|
||||
/// Draws and presents one frame, returning the time spent in
|
||||
/// `queue.submit` plus `present()` -- wherever a driver/GPU/compositor
|
||||
/// wait would actually show up. The caller (`android::view::render`)
|
||||
/// already times the whole frame from its own `redraw_to_submit` start;
|
||||
/// subtracting this from that total is `redraw_to_submit` itself
|
||||
/// (layout, text, primitive building, and this method's own render-pass
|
||||
/// recording). RUST.md's I5 "Where iris's frame time goes" diagnosis,
|
||||
/// added 2026-09-05 -- see `iris_core::FrameReport::record_split`'s own
|
||||
/// doc for the caveat this shares: `present()` is not fenced against
|
||||
/// the GPU actually finishing, so this is "how long the CPU was blocked
|
||||
/// handing the frame off", not confirmed GPU time.
|
||||
pub fn draw(&mut self) -> Duration {
|
||||
/// Draws and presents one frame, returning the two parts of it that
|
||||
/// are waits rather than work: the `get_current_texture` acquire and
|
||||
/// `queue.submit` + `present()`. The caller
|
||||
/// (`android::view::render`) times the whole frame and fills in
|
||||
/// `FrameParts::total`, so whatever is left over is iris's own work.
|
||||
///
|
||||
/// **The acquire is why this returns two numbers and not one.**
|
||||
/// `get_current_texture` blocks until the compositor hands back a
|
||||
/// swapchain image, which on an app comfortably ahead of the display
|
||||
/// is most of every frame -- so counting it as CPU work (which this
|
||||
/// did until 2026-09-09) reports a fling as milliseconds of iris
|
||||
/// being slow when they are milliseconds of iris waiting its turn.
|
||||
///
|
||||
/// RUST.md's I5 "Where iris's frame time goes" diagnosis, added
|
||||
/// 2026-09-05 -- see `iris_core::FrameParts`'s own doc for the caveat
|
||||
/// the submit half shares: `present()` is not fenced against the GPU
|
||||
/// actually finishing, so it is "how long the CPU was blocked handing
|
||||
/// the frame off", not confirmed GPU time.
|
||||
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,
|
||||
@@ -435,6 +443,7 @@ impl AndroidRenderer {
|
||||
// which is new.
|
||||
other => panic!("no surface texture to draw into: {other:?}"),
|
||||
};
|
||||
let acquire = acquire_start.elapsed();
|
||||
let view = output
|
||||
.texture
|
||||
.create_view(&TextureViewDescriptor::default());
|
||||
@@ -459,7 +468,7 @@ impl AndroidRenderer {
|
||||
let submit_start = Instant::now();
|
||||
self.queue.submit(std::iter::once(encoder.finish()));
|
||||
self.queue.present(output);
|
||||
submit_start.elapsed()
|
||||
FrameParts::waits(acquire, submit_start.elapsed())
|
||||
}
|
||||
|
||||
/// Physical pixels -- the unit layout and hit-testing use, matching
|
||||
|
||||
+103
-51
@@ -312,11 +312,14 @@ pub struct IrisViewPeer<State: AndroidAppState> {
|
||||
pub(super) render: UiRenderState,
|
||||
pub(super) state: State,
|
||||
task_recv: TaskMsgReceiver<AndroidRsc<State>>,
|
||||
/// Anchored on the first `MotionEvent` this view receives and never
|
||||
/// re-anchored after -- how `on_touch_event` dates every touch sample.
|
||||
/// Its path out is the peer's own drop: it holds nothing but three
|
||||
/// numbers and is meaningless to any other view.
|
||||
input_clock: Option<PointerClock>,
|
||||
/// The one ruler this view dates everything on: touch samples in
|
||||
/// `on_touch_event` and the `Choreographer` frame time in `do_frame`.
|
||||
/// Anchored by whichever of the two arrives first and never
|
||||
/// re-anchored after, which is what lets a fling be advanced on the
|
||||
/// same clock the gesture that launched it was measured on. Its path
|
||||
/// out is the peer's own drop: it holds nothing but three numbers and
|
||||
/// is meaningless to any other view.
|
||||
device_clock: Option<DeviceClock>,
|
||||
}
|
||||
|
||||
impl<State: 'static, I: RscIdx<AndroidRsc<State>>> std::ops::Index<I> for AndroidRsc<State> {
|
||||
@@ -390,6 +393,22 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The view's one clock, anchoring it on `nanos` if nothing has yet
|
||||
/// -- see the `device_clock` field. Either source may be the first to
|
||||
/// arrive: a frame callback fires before any touch on an app that
|
||||
/// animates at startup, and a touch arrives first on one that does
|
||||
/// not.
|
||||
///
|
||||
/// `oldest` is the earliest sample the anchoring event carries, which
|
||||
/// matters only when this is the call that anchors -- see
|
||||
/// `DeviceClock::anchored`. A frame time carries no batch, so it
|
||||
/// passes its own time for both.
|
||||
fn device_clock(&mut self, event_time: i64, oldest: i64) -> DeviceClock {
|
||||
*self
|
||||
.device_clock
|
||||
.get_or_insert_with(|| DeviceClock::anchored(Instant::now(), event_time, oldest))
|
||||
}
|
||||
|
||||
fn window_size(&self) -> Vec2 {
|
||||
let ui_state = self.state.android_state();
|
||||
match &ui_state.renderer {
|
||||
@@ -412,7 +431,7 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
/// every level the app's already-`Debug` install lets through
|
||||
/// regardless of target, so they filled the whole ring in under ten
|
||||
/// seconds at 120Hz and left `Copy report` nothing else to show.
|
||||
fn render(&mut self, ctx: &mut CallbackCtx) {
|
||||
fn render(&mut self, ctx: &mut CallbackCtx, now: Instant) {
|
||||
if self.state.android_state().renderer.is_none() {
|
||||
return;
|
||||
}
|
||||
@@ -476,12 +495,31 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
// both count. See `iris_core::FrameReport`'s own doc for exactly
|
||||
// what this does and does not measure.
|
||||
let frame_start = Instant::now();
|
||||
// Anything moving on its own -- today a `LazySpan` coasting through a
|
||||
// fling -- is advanced here, before the draw, and asks for the
|
||||
// next frame at the end of this one. See
|
||||
// `UiData::tick_animations`; `default/mod.rs`'s
|
||||
// `RedrawRequested` arm is the same two lines for winit.
|
||||
let animating = self.rsc.ui.tick_animations(frame_start);
|
||||
// Anything moving on its own -- today a `LazySpan` coasting
|
||||
// through a fling -- is advanced here, before the draw. **On
|
||||
// `now`, not on `frame_start`**: `now` is the vsync the
|
||||
// `Choreographer` handed this callback, which is evenly spaced,
|
||||
// while `frame_start` is whenever the callback actually got to
|
||||
// run. The frames are *presented* on the even cadence either way,
|
||||
// so sampling the animation on the uneven one moves the content by
|
||||
// an uneven distance per frame -- a fling that shimmers with no
|
||||
// frame late enough to show up in a report. See
|
||||
// `UiData::tick_animations` and `sense::DeviceClock`;
|
||||
// `default/mod.rs`'s `RedrawRequested` arm is the winit half.
|
||||
let animating = self.rsc.ui.tick_animations(now);
|
||||
// **Asked for before the work, not after it.** A frame callback is
|
||||
// one-shot, so an animation that wants another frame has to say so
|
||||
// every frame -- and `Choreographer.postFrameCallback` schedules
|
||||
// for the next vsync *after the call*. Asking at the end of this
|
||||
// function meant any frame whose work ran past the vsync boundary
|
||||
// (the swapchain acquire below alone can sit most of a frame)
|
||||
// registered too late for the next one and got the one after --
|
||||
// so one frame over budget silently cost a second frame as well.
|
||||
// Unlike `after_input`, which only has to ask when input dirtied
|
||||
// something.
|
||||
if animating {
|
||||
ctx.view.post_frame_callback(&mut ctx.env);
|
||||
}
|
||||
let ui_state = self.state.android_state_mut();
|
||||
self.render.update(&ui_state.root, &mut self.rsc);
|
||||
let ui_state = self.state.android_state_mut();
|
||||
@@ -509,18 +547,16 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
renderer.wgpu_errors.snapshot().len(),
|
||||
);
|
||||
}
|
||||
let submit_to_present = renderer.draw();
|
||||
let mut parts = renderer.draw();
|
||||
parts.total = frame_start.elapsed();
|
||||
// Dated on `now` -- the vsync this frame was for -- so the gap
|
||||
// between consecutive frames is the display's own cadence and
|
||||
// `PhaseStats::missed` counts vsyncs nothing was drawn for.
|
||||
self.state
|
||||
.android_state_mut()
|
||||
.frame_report
|
||||
.record_split(frame_start.elapsed(), submit_to_present);
|
||||
crate::diagnostics::log_frame(&self.render, frame_start, submit_to_present, animating);
|
||||
// A frame callback is one-shot, so an animation that wants
|
||||
// another frame has to say so every frame -- unlike `after_input`,
|
||||
// which only has to ask when input dirtied something.
|
||||
if animating {
|
||||
ctx.view.post_frame_callback(&mut ctx.env);
|
||||
}
|
||||
.record(now, parts);
|
||||
crate::diagnostics::log_frame(&self.render, now, parts, animating);
|
||||
if crate::diagnostics::trace_enabled() {
|
||||
let ui_state = self.state.android_state();
|
||||
log::debug!(
|
||||
@@ -632,28 +668,32 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
// -- see `AndroidUiState::content_scale`'s field comment.
|
||||
let x = event.x(&mut ctx.env);
|
||||
let y = event.y(&mut ctx.env);
|
||||
// The event's own clock, converted through one anchor taken on the
|
||||
// first touch this view ever sees. Android reports sample times in
|
||||
// the `SystemClock.uptimeMillis()` base, which is the same
|
||||
// `CLOCK_MONOTONIC` an `Instant` reads, so a single
|
||||
// `(Instant, nanos)` pair converts every later sample exactly.
|
||||
// Anchoring **once** rather than per event is what keeps the times
|
||||
// ordered, and anchoring on the first event's *oldest* sample
|
||||
// rather than on its own time is what keeps that event's batch
|
||||
// from collapsing onto one instant -- `sense::PointerClock`'s doc
|
||||
// has both, and owns the arithmetic so it can be unit-tested off a
|
||||
// device (`sense_tests.rs`). See `CursorState::time`.
|
||||
// The event's own clock, converted through the view's one anchor
|
||||
// -- taken on whichever of a touch or a frame callback came first.
|
||||
// Android reports sample times in the `SystemClock.uptimeMillis()`
|
||||
// base, which is the same `CLOCK_MONOTONIC` an `Instant` reads, so
|
||||
// a single `(Instant, nanos)` pair converts every later sample
|
||||
// exactly. Anchoring **once** rather than per event is what keeps
|
||||
// the times ordered, and anchoring on the first event's *oldest*
|
||||
// sample rather than on its own time is what keeps that event's
|
||||
// batch from collapsing onto one instant -- `sense::DeviceClock`'s
|
||||
// doc has both, and owns the arithmetic so it can be unit-tested
|
||||
// off a device (`sense_tests.rs`). See `CursorState::time`.
|
||||
let event_time = event.event_time_nanos(&mut ctx.env);
|
||||
if self.input_clock.is_none() {
|
||||
let history = event.history_size(&mut ctx.env);
|
||||
let oldest = if history > 0 {
|
||||
event.historical_event_time_nanos(&mut ctx.env, 0)
|
||||
} else {
|
||||
event_time
|
||||
};
|
||||
self.input_clock = Some(PointerClock::anchored(Instant::now(), event_time, oldest));
|
||||
}
|
||||
let mut clock = self.input_clock.expect("anchored just above");
|
||||
let history = event.history_size(&mut ctx.env);
|
||||
let mut clock = match self.device_clock {
|
||||
Some(clock) => clock,
|
||||
// Only the call that anchors needs the batch's oldest sample,
|
||||
// so the JNI read for it stays off the per-event path.
|
||||
None => {
|
||||
let oldest = if history > 0 {
|
||||
event.historical_event_time_nanos(&mut ctx.env, 0)
|
||||
} else {
|
||||
event_time
|
||||
};
|
||||
self.device_clock(event_time, oldest)
|
||||
}
|
||||
};
|
||||
// `iris::input`'s own doc (`sense::log_input_event`): collected
|
||||
// only when tracing is on, since this is otherwise a `Vec` per
|
||||
// `MotionEvent` for a line nobody is reading -- the JNI reads
|
||||
@@ -676,12 +716,11 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
// motion the finger actually made; only the last sample ends the
|
||||
// frame (`after_input`).
|
||||
if matches!(action, MotionAction::Move) {
|
||||
let history = event.history_size(&mut ctx.env);
|
||||
// Android documents the historical samples as oldest first and
|
||||
// the event's own sample as the newest of the batch; everything
|
||||
// downstream (`VelocityTracker`, `DragArbiter`'s long-press
|
||||
// clock) assumes it, so say so here rather than at each reader.
|
||||
// `PointerClock::sample` is what asserts it, and it carries the
|
||||
// `DeviceClock::sample` is what asserts it, and it carries the
|
||||
// last sample seen *across* events, so the first sample of
|
||||
// every event is checked against the previous event's last one
|
||||
// rather than against the anchor.
|
||||
@@ -702,7 +741,7 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
|
||||
let event_at = clock.sample(event_time);
|
||||
let event_ms = clock.ms_since_anchor(event_time);
|
||||
self.input_clock = Some(clock);
|
||||
self.device_clock = Some(clock);
|
||||
let ui_state = self.state.android_state_mut();
|
||||
ui_state.cursor.time = event_at;
|
||||
match action {
|
||||
@@ -837,7 +876,7 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
.as_mut()
|
||||
.expect("checked Some above")
|
||||
.resize(width as u32, height as u32);
|
||||
self.render(ctx);
|
||||
self.render(ctx, Instant::now());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -888,7 +927,7 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
);
|
||||
self.rsc.ui.textures.reupload();
|
||||
self.state.android_state_mut().renderer = Some(renderer);
|
||||
self.render(ctx);
|
||||
self.render(ctx, Instant::now());
|
||||
}
|
||||
Err(report) => {
|
||||
// One line for logcat (UI_RULES.md: "the full text for
|
||||
@@ -930,9 +969,20 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
self.state.android_state_mut().renderer = None;
|
||||
}
|
||||
|
||||
fn do_frame(&mut self, ctx: &mut CallbackCtx, _frame_time_nanos: i64) {
|
||||
fn do_frame(&mut self, ctx: &mut CallbackCtx, frame_time_nanos: i64) {
|
||||
self.drain_tasks();
|
||||
self.render(ctx);
|
||||
// The vsync this frame is for, dated on the same ruler touch
|
||||
// samples are (`DeviceClock`), rather than `Instant::now()` here:
|
||||
// this callback runs some variable distance after that vsync --
|
||||
// behind `drain_tasks`, behind whatever else the UI thread was
|
||||
// doing -- and anything advanced by that variable amount moves
|
||||
// unevenly between frames the display shows evenly. `at` rather
|
||||
// than `sample`, since a frame time is not part of the touch
|
||||
// samples' own ordering.
|
||||
let now = self
|
||||
.device_clock(frame_time_nanos, frame_time_nanos)
|
||||
.at(frame_time_nanos);
|
||||
self.render(ctx, now);
|
||||
}
|
||||
|
||||
/// Where `AndroidRedrawHandle::request_redraw` (`android/render.rs`)
|
||||
@@ -946,7 +996,9 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
/// one.
|
||||
fn delayed_callback(&mut self, ctx: &mut CallbackCtx) {
|
||||
self.drain_tasks();
|
||||
self.render(ctx);
|
||||
// No vsync to date this one on -- it is a background task's
|
||||
// "there is new state", not a frame the display asked for.
|
||||
self.render(ctx, Instant::now());
|
||||
}
|
||||
|
||||
fn as_input_connection(&mut self) -> Option<&mut dyn InputConnection> {
|
||||
@@ -1072,7 +1124,7 @@ pub fn new_peer<'local, State: AndroidAppState>(
|
||||
render,
|
||||
state,
|
||||
task_recv,
|
||||
input_clock: None,
|
||||
device_clock: None,
|
||||
};
|
||||
let id = android_view::register_view_peer(peer);
|
||||
super::insets::register(id, shared);
|
||||
|
||||
+11
-5
@@ -342,14 +342,20 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
||||
let frame_start = std::time::Instant::now();
|
||||
let animating = rsc.ui_mut().tick_animations(frame_start);
|
||||
let ui_state = state.default_state_mut();
|
||||
render.update(&ui_state.root, rsc);
|
||||
ui_state.renderer.update(&mut rsc.ui, render);
|
||||
let draw_start = std::time::Instant::now();
|
||||
ui_state.renderer.draw();
|
||||
crate::diagnostics::log_frame(render, frame_start, draw_start.elapsed(), animating);
|
||||
// Asked for before the work rather than after it, the same
|
||||
// way `IrisViewPeer::render` does and for the same reason
|
||||
// -- see the longer comment there. winit coalesces
|
||||
// repeated requests, so the only thing the order changes
|
||||
// is whether the request is in before this frame's draw
|
||||
// can push it past a vsync boundary.
|
||||
if animating {
|
||||
ui_state.window.request_redraw();
|
||||
}
|
||||
render.update(&ui_state.root, rsc);
|
||||
ui_state.renderer.update(&mut rsc.ui, render);
|
||||
let mut parts = ui_state.renderer.draw();
|
||||
parts.total = frame_start.elapsed();
|
||||
crate::diagnostics::log_frame(render, frame_start, parts, animating);
|
||||
// I4 (RUST.md): only produces a `TreeUpdate` when the named
|
||||
// set actually changed this frame -- see `AccessTree`'s doc
|
||||
// comment. `render` reflects the draw that just happened,
|
||||
|
||||
+10
-2
@@ -1,7 +1,8 @@
|
||||
use crate::task::RequestRedraw;
|
||||
use iris_core::{UiData, UiRenderNode, UiRenderState, util::Vec2};
|
||||
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};
|
||||
|
||||
@@ -28,7 +29,11 @@ impl UiRenderer {
|
||||
self.ui.update(&self.device, &self.queue, ui, render);
|
||||
}
|
||||
|
||||
pub fn draw(&mut self) {
|
||||
/// 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,
|
||||
@@ -39,6 +44,7 @@ impl UiRenderer {
|
||||
// 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());
|
||||
@@ -60,6 +66,7 @@ impl UiRenderer {
|
||||
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
|
||||
@@ -69,6 +76,7 @@ impl UiRenderer {
|
||||
// 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>) {
|
||||
|
||||
+8
-6
@@ -26,9 +26,9 @@
|
||||
//! has open at the same time this was written. `set_trace` is the whole
|
||||
//! surface a button needs; wiring one is a follow-up.
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Instant;
|
||||
|
||||
use iris_core::UiRenderState;
|
||||
use iris_core::{FrameParts, UiRenderState};
|
||||
|
||||
static TRACE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
@@ -61,7 +61,7 @@ pub fn trace_enabled() -> bool {
|
||||
/// its own draw call are the only two `Instant` pairs in the whole path --
|
||||
/// see each call site's own comment for why it is not restructured to fit
|
||||
/// this instead.
|
||||
pub fn log_frame(render: &UiRenderState, now: Instant, draw: Duration, animating: bool) {
|
||||
pub fn log_frame(render: &UiRenderState, now: Instant, parts: FrameParts, animating: bool) {
|
||||
if !trace_enabled() {
|
||||
return;
|
||||
}
|
||||
@@ -71,12 +71,14 @@ pub fn log_frame(render: &UiRenderState, now: Instant, draw: Duration, animating
|
||||
.unwrap_or_else(|| "none".to_string());
|
||||
log::debug!(
|
||||
target: "iris::frame",
|
||||
"iris frame: n={} now={}ms since_input={since_input} layout={:?} draw={:?} \
|
||||
redraw={:?} primitives={} animating={animating}",
|
||||
"iris frame: n={} now={}ms since_input={since_input} layout={:?} build={:?} \
|
||||
acquire={:?} submit={:?} redraw={:?} primitives={} animating={animating}",
|
||||
render.frame_number(),
|
||||
now.duration_since(render.epoch()).as_millis(),
|
||||
render.last_layout_duration(),
|
||||
draw,
|
||||
parts.build(),
|
||||
parts.acquire,
|
||||
parts.submit,
|
||||
render.last_redraw_kind(),
|
||||
render.active_primitive_count(),
|
||||
);
|
||||
|
||||
+12
-5
@@ -359,14 +359,21 @@ impl Harness {
|
||||
update(&mut self.state, &mut self.rsc);
|
||||
}
|
||||
let now = self.at(t_ms);
|
||||
let at = Instant::now();
|
||||
let animating = self.rsc.ui.tick_animations(now);
|
||||
self.render.update(&self.state.root, &mut self.rsc);
|
||||
// No GPU here, so there is no draw phase to time -- `draw` is
|
||||
// always zero. `layout`/`redraw`/`primitives` are still real,
|
||||
// because `render.update` just ran; see
|
||||
// `iris::diagnostics::log_frame`'s own doc for why this reads
|
||||
// No GPU here, so there is nothing to acquire and nothing to
|
||||
// submit: the frame is all `build`, which is honest rather than
|
||||
// zero-filled (`FrameParts::whole`). `layout`/`redraw`/
|
||||
// `primitives` are still real, because `render.update` just ran;
|
||||
// see `iris::diagnostics::log_frame`'s own doc for why this reads
|
||||
// those back rather than timing anything itself.
|
||||
crate::diagnostics::log_frame(&self.render, now, Duration::ZERO, animating);
|
||||
crate::diagnostics::log_frame(
|
||||
&self.render,
|
||||
now,
|
||||
FrameParts::whole(at.elapsed()),
|
||||
animating,
|
||||
);
|
||||
}
|
||||
|
||||
/// Frames every `step_ms` up to and including `end_ms` -- what a
|
||||
|
||||
+21
-7
@@ -816,14 +816,27 @@ pub fn log_input_event(action: &str, x: f32, y: f32, t_ms: u64, historical: &[(u
|
||||
);
|
||||
}
|
||||
|
||||
/// Converts a platform's own monotonic input timestamps into [`Instant`]s
|
||||
/// through **one** anchor taken at the first event, so that every sample
|
||||
/// this process ever sees is dated on a single ruler.
|
||||
/// Converts a platform's own monotonic timestamps into [`Instant`]s
|
||||
/// through **one** anchor, so that everything this process is told the
|
||||
/// time of is dated on a single ruler.
|
||||
///
|
||||
/// Two kinds of timestamp go through it on Android and they have to agree,
|
||||
/// which is why there is one type and not one per source: a touch
|
||||
/// sample's `MotionEvent` time, and the `Choreographer` frame time a
|
||||
/// `doFrame` callback carries. Both are `CLOCK_MONOTONIC` in nanoseconds
|
||||
/// (`SystemClock.uptimeMillis`'s base and `System.nanoTime`'s are the same
|
||||
/// clock), so one `(Instant, nanos)` pair converts either exactly, and a
|
||||
/// fling launched by a gesture is then advanced on the clock its velocity
|
||||
/// was measured on.
|
||||
///
|
||||
/// A fresh `Instant::now()` per event, minus each sample's age inside it,
|
||||
/// can date a later event's first sample before the previous event's last
|
||||
/// one whenever delivery jitters by more than the batch spans -- which
|
||||
/// [`VelocityTracker`] would rightly reject.
|
||||
/// [`VelocityTracker`] would rightly reject. The same jitter in a *frame*
|
||||
/// clock is what makes a fling stutter: the display presents frames on an
|
||||
/// even cadence, so sampling the animation at "whenever the callback got
|
||||
/// to run" moves the content by an uneven distance each time even when no
|
||||
/// frame is late.
|
||||
///
|
||||
/// The anchor is taken from the **earliest sample of the first event**,
|
||||
/// not from that event's own time: an event batches samples that are by
|
||||
@@ -834,16 +847,17 @@ pub fn log_input_event(action: &str, x: f32, y: f32, t_ms: u64, historical: &[(u
|
||||
/// view sees is a `Move` (the `Down` went to another view, or the view was
|
||||
/// attached mid-gesture). Found by review, 2026-09-07.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PointerClock {
|
||||
pub struct DeviceClock {
|
||||
anchor_at: Instant,
|
||||
anchor_nanos: i64,
|
||||
last_nanos: i64,
|
||||
}
|
||||
|
||||
impl PointerClock {
|
||||
impl DeviceClock {
|
||||
/// `now` is when the first event arrived, `event_time` its own
|
||||
/// timestamp, and `oldest` the timestamp of the earliest sample it
|
||||
/// carries -- equal to `event_time` when it batches none.
|
||||
/// carries -- equal to `event_time` when it batches none, which is
|
||||
/// also what a frame time anchoring this passes for both.
|
||||
pub fn anchored(now: Instant, event_time: i64, oldest: i64) -> Self {
|
||||
let batch_span = Duration::from_nanos(event_time.saturating_sub(oldest).max(0) as u64);
|
||||
Self {
|
||||
|
||||
+2
-2
@@ -338,7 +338,7 @@ fn the_first_events_batched_samples_are_dated_apart() {
|
||||
let now = Instant::now();
|
||||
// A 120Hz batch: three historical samples at 0/4/8ms and the event's
|
||||
// own at 12ms.
|
||||
let clock = PointerClock::anchored(now, 12 * MS, 0);
|
||||
let clock = DeviceClock::anchored(now, 12 * MS, 0);
|
||||
|
||||
assert_eq!(
|
||||
clock.at(12 * MS),
|
||||
@@ -363,7 +363,7 @@ fn the_first_events_batched_samples_are_dated_apart() {
|
||||
#[test]
|
||||
fn the_clock_orders_samples_across_events() {
|
||||
const MS: i64 = 1_000_000;
|
||||
let mut clock = PointerClock::anchored(Instant::now(), 12 * MS, 0);
|
||||
let mut clock = DeviceClock::anchored(Instant::now(), 12 * MS, 0);
|
||||
let first = clock.sample(12 * MS);
|
||||
let second = clock.sample(28 * MS);
|
||||
assert!(second > first);
|
||||
|
||||
Reference in new issue
Block a user