From bdc09317018f3e76291dd8ee76ca716a9398adf9 Mon Sep 17 00:00:00 2001 From: Apoorv Darshan Date: Tue, 13 Oct 2026 22:43:46 +0530 Subject: [PATCH] perf(rendering): stop scanning every cursor event on each rendered frame The cursor layer and click ripples searched the whole recording's move and click lists on every playback and export frame. With "Hide when idle" on, each idle frame walked the moves from the end of the recording twice, so the cost grew with recording length. PrecomputedCursorTimeline now records whether clicks are ordered (moves already were), ProjectUniforms carries both flags, and ordered recordings use binary search for the click scale, the idle hide/fade, and the ripple window. Unordered or NaN-containing events keep the original scans. --- crates/rendering/src/cursor_interpolation.rs | 23 ++ crates/rendering/src/layers/cursor.rs | 188 ++++++++-- crates/rendering/src/lib.rs | 376 ++++++++++++++++++- 3 files changed, 552 insertions(+), 35 deletions(-) diff --git a/crates/rendering/src/cursor_interpolation.rs b/crates/rendering/src/cursor_interpolation.rs index afe5c8098de..a8ccaa95eb4 100644 --- a/crates/rendering/src/cursor_interpolation.rs +++ b/crates/rendering/src/cursor_interpolation.rs @@ -340,10 +340,17 @@ struct CursorTimelineVariant { timeline: Arc, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct CursorEventOrder { + pub moves: bool, + pub clicks: bool, +} + pub struct PrecomputedCursorTimeline { timeline: Vec, raw_cursor: CursorEvents, raw_moves_are_ordered: bool, + clicks_are_ordered: bool, has_smoothing: bool, variants: Mutex>, } @@ -355,11 +362,19 @@ impl PrecomputedCursorTimeline { click_spring: Option, ) -> Self { let raw_moves_are_ordered = cursor.moves.is_sorted_by(|a, b| a.time_ms <= b.time_ms); + let clicks_are_ordered = cursor + .clicks + .iter() + .try_fold(f64::NEG_INFINITY, |previous, click| { + (previous <= click.time_ms).then_some(click.time_ms) + }) + .is_some(); if cursor.moves.is_empty() || smoothing.is_none() { return Self { timeline: vec![], raw_cursor: cursor.clone(), raw_moves_are_ordered, + clicks_are_ordered, has_smoothing: false, variants: Mutex::new(Vec::new()), }; @@ -379,6 +394,7 @@ impl PrecomputedCursorTimeline { timeline, raw_cursor: CursorEvents::default(), raw_moves_are_ordered, + clicks_are_ordered, has_smoothing: true, variants: Mutex::new(Vec::new()), } @@ -417,6 +433,13 @@ impl PrecomputedCursorTimeline { timeline } + pub(crate) fn event_order(&self) -> CursorEventOrder { + CursorEventOrder { + moves: self.raw_moves_are_ordered, + clicks: self.clicks_are_ordered, + } + } + pub fn interpolate(&self, time_secs: f32) -> Option { let time_ms = (time_secs * 1000.0) as f64; if self.has_smoothing { diff --git a/crates/rendering/src/layers/cursor.rs b/crates/rendering/src/layers/cursor.rs index f5e87b8094b..25f5a5c3a92 100644 --- a/crates/rendering/src/layers/cursor.rs +++ b/crates/rendering/src/layers/cursor.rs @@ -511,6 +511,7 @@ impl CursorLayer { cursor, segment_frames.recording_time as f64 * 1000.0, hide_delay_ms, + uniforms.cursor_event_order.moves, ); if cursor_opacity <= f32::EPSILON { cursor_opacity = 0.0; @@ -568,7 +569,11 @@ impl CursorLayer { }; let size = { - let click_t = get_click_t(&cursor.clicks, (time_s as f64) * 1000.0); + let click_t = get_click_t( + &cursor.clicks, + (time_s as f64) * 1000.0, + uniforms.cursor_event_order.clicks, + ); let click_scale_factor = click_t * 1.0 + (1.0 - click_t) * CLICK_SHRINK_SIZE; let display = &uniforms.display; let size = cursor_height_px( @@ -770,6 +775,7 @@ fn compute_cursor_idle_opacity( cursor: &CursorEvents, current_time_ms: f64, hide_delay_ms: f64, + moves_ordered: bool, ) -> f32 { if cursor.moves.is_empty() { return 0.0; @@ -791,11 +797,15 @@ fn compute_cursor_idle_opacity( return 1.0; } - let Some(last_index) = cursor - .moves - .iter() - .rposition(|event| event.time_ms <= current_time_ms) - else { + let last_index = if moves_ordered { + idx.checked_sub(1) + } else { + cursor + .moves + .iter() + .rposition(|event| event.time_ms <= current_time_ms) + }; + let Some(last_index) = last_index else { return 1.0; }; @@ -815,7 +825,12 @@ fn compute_cursor_idle_opacity( return 0.0; } - let opacity = compute_cursor_fade_in(cursor, current_time_ms, hide_delay_ms); + let elapsed_moves = if moves_ordered { + &cursor.moves[..idx] + } else { + &cursor.moves[..] + }; + let opacity = compute_cursor_fade_in(elapsed_moves, current_time_ms, hide_delay_ms); (opacity * fade_out).clamp(0.0, 1.0) } @@ -828,9 +843,12 @@ fn smoothstep64(edge0: f64, edge1: f64, x: f64) -> f64 { t * t * (3.0 - 2.0 * t) } -fn compute_cursor_fade_in(cursor: &CursorEvents, current_time_ms: f64, hide_delay_ms: f64) -> f32 { - let resume_time = cursor - .moves +fn compute_cursor_fade_in( + moves: &[CursorMoveEvent], + current_time_ms: f64, + hide_delay_ms: f64, +) -> f32 { + let resume_time = moves .windows(2) .rev() .find(|pair| { @@ -849,13 +867,17 @@ fn compute_cursor_fade_in(cursor: &CursorEvents, current_time_ms: f64, hide_dela smoothstep64(0.0, CURSOR_IDLE_FADE_OUT_MS, time_since_resume) as f32 } -fn get_click_t(clicks: &[CursorClickEvent], time_ms: f64) -> f32 { +fn get_click_t(clicks: &[CursorClickEvent], time_ms: f64, clicks_ordered: bool) -> f32 { fn smoothstep(low: f32, high: f32, v: f32) -> f32 { let t = f32::clamp((v - low) / (high - low), 0.0, 1.0); t * t * (3.0 - 2.0 * t) } - let next_click = clicks.iter().find(|c| c.time_ms > time_ms); + let split = clicks_ordered.then(|| clicks.partition_point(|c| c.time_ms <= time_ms)); + let next_click = match split { + Some(split) => clicks.get(split).filter(|c| c.time_ms > time_ms), + None => clicks.iter().find(|c| c.time_ms > time_ms), + }; if let Some(next) = next_click { if next.down && next.time_ms - time_ms <= CURSOR_CLICK_DURATION_MS { @@ -871,7 +893,10 @@ fn get_click_t(clicks: &[CursorClickEvent], time_ms: f64) -> f32 { } } - let prev_click = clicks.iter().rev().find(|c| c.time_ms <= time_ms); + let prev_click = match split { + Some(split) => clicks[..split].last(), + None => clicks.iter().rev().find(|c| c.time_ms <= time_ms), + }; if let Some(prev) = prev_click { if prev.down { @@ -1356,7 +1381,7 @@ mod tests { fn opacity_stays_visible_with_recent_move() { let cursor = cursor_events(&[(0.0, 0.0, 0.0), (1500.0, 0.1, 0.1)]); - let opacity = compute_cursor_idle_opacity(&cursor, 2000.0, 2000.0); + let opacity = compute_cursor_idle_opacity(&cursor, 2000.0, 2000.0, true); assert_eq!(opacity, 1.0); } @@ -1365,7 +1390,7 @@ mod tests { fn opacity_fades_once_past_delay() { let cursor = cursor_events(&[(0.0, 0.0, 0.0)]); - let opacity = compute_cursor_idle_opacity(&cursor, 3000.0, 1000.0); + let opacity = compute_cursor_idle_opacity(&cursor, 3000.0, 1000.0, true); assert_eq!(opacity, 0.0); } @@ -1376,13 +1401,14 @@ mod tests { let hide_delay_ms = 2000.0; - let at_resume = compute_cursor_idle_opacity(&cursor, 5000.0, hide_delay_ms); + let at_resume = compute_cursor_idle_opacity(&cursor, 5000.0, hide_delay_ms, true); assert_eq!(at_resume, 0.0); let halfway = compute_cursor_idle_opacity( &cursor, 5000.0 + CURSOR_IDLE_FADE_OUT_MS / 2.0, hide_delay_ms, + true, ); assert!((halfway - 0.5).abs() < 0.05); @@ -1390,14 +1416,24 @@ mod tests { &cursor, 5000.0 + CURSOR_IDLE_FADE_OUT_MS * 2.0, hide_delay_ms, + true, ); assert_eq!(after_fade, 1.0); } + fn idle_opacity_both_paths(cursor: &CursorEvents, time_ms: f64, hide_delay_ms: f64) -> f32 { + let unordered = compute_cursor_idle_opacity(cursor, time_ms, hide_delay_ms, false); + if cursor.moves.is_sorted_by(|a, b| a.time_ms <= b.time_ms) { + let ordered = compute_cursor_idle_opacity(cursor, time_ms, hide_delay_ms, true); + assert_eq!(ordered.to_bits(), unordered.to_bits()); + } + unordered + } + #[test] fn opacity_handles_empty_history_and_duplicate_timestamps() { assert_eq!( - compute_cursor_idle_opacity(&CursorEvents::default(), 0.0, 1000.0), + idle_opacity_both_paths(&CursorEvents::default(), 0.0, 1000.0), 0.0 ); let cursor = cursor_events(&[ @@ -1407,7 +1443,7 @@ mod tests { (5010.0, 0.7, 0.7), ]); for (time, expected) in [(0.0, 1.0), (5000.0, 1.0), (5200.0, 0.5), (5400.0, 1.0)] { - assert_eq!(compute_cursor_idle_opacity(&cursor, time, 1000.0), expected); + assert_eq!(idle_opacity_both_paths(&cursor, time, 1000.0), expected); } } @@ -1421,7 +1457,7 @@ mod tests { (5000.0, 0.0), (5400.0, 1.0), ] { - assert_eq!(compute_cursor_idle_opacity(&cursor, time, 1000.0), expected); + assert_eq!(idle_opacity_both_paths(&cursor, time, 1000.0), expected); } } @@ -1443,7 +1479,7 @@ mod tests { (10400.0, 1.0), (5200.0, 0.5), ] { - assert_eq!(compute_cursor_idle_opacity(&cursor, time, 1000.0), expected); + assert_eq!(idle_opacity_both_paths(&cursor, time, 1000.0), expected); } } @@ -1451,14 +1487,120 @@ mod tests { fn opacity_preserves_fade_out_boundary() { let cursor = cursor_events(&[(0.0, 0.0, 0.0)]); for (time, expected) in [(1000.0, 1.0), (1200.0, 0.5), (1400.0, 0.0), (1400.001, 0.0)] { - assert_eq!(compute_cursor_idle_opacity(&cursor, time, 1000.0), expected); + assert_eq!(idle_opacity_both_paths(&cursor, time, 1000.0), expected); } - assert!(compute_cursor_idle_opacity(&cursor, 1399.999, 1000.0) > 0.0); + assert!(idle_opacity_both_paths(&cursor, 1399.999, 1000.0) > 0.0); } #[test] fn opacity_preserves_recent_resume_for_out_of_order_history() { let cursor = cursor_events(&[(0.0, 0.0, 0.0), (5000.0, 0.5, 0.5), (4000.0, 0.6, 0.6)]); - assert_eq!(compute_cursor_idle_opacity(&cursor, 5200.0, 1000.0), 0.25); + assert_eq!(idle_opacity_both_paths(&cursor, 5200.0, 1000.0), 0.25); + } + + fn click_event(time_ms: f64, down: bool) -> CursorClickEvent { + CursorClickEvent { + active_modifiers: vec![], + cursor_num: 0, + cursor_id: "pointer".into(), + time_ms, + down, + } + } + + fn event_time_cases() -> Vec> { + vec![ + vec![], + vec![0.0], + vec![f64::NAN], + vec![0.0, 0.0, 10.0, 10.0, 20.0, 100.0, 100.0], + vec![-0.0, 0.0, 0.0, 10.0, 100.0], + vec![0.0, 20.0, 10.0, 50.0, 100.0], + vec![0.0, 10.0, f64::NAN, 30.0, 100.0], + vec![f64::NAN, 10.0, 30.0, 100.0], + vec![0.0, 10.0, f64::NAN], + vec![f64::NEG_INFINITY, 0.0, 10.0, f64::INFINITY], + vec![0.0, f64::INFINITY, 10.0, 100.0], + vec![ + 0.0, 16.0, 400.0, 1200.0, 1300.0, 3000.0, 3100.0, 3150.0, 6000.0, 6000.0, 9000.0, + ], + ] + } + + fn lookup_queries(times: &[f64]) -> Vec { + let mut queries = vec![ + f64::NAN, + f64::NEG_INFINITY, + f64::INFINITY, + -1.0, + -0.0, + 0.0, + 1.0, + 1.0e9, + ]; + for &time in times { + for offset in [ + 0.0, + CURSOR_CLICK_DURATION_MS, + CURSOR_IDLE_RESUME_LOOKAHEAD_MS, + CURSOR_IDLE_FADE_OUT_MS, + 500.0, + 1000.0, + 2000.0, + ] { + for base in [time - offset, time + offset] { + queries.extend([base.next_down(), base, base.next_up()]); + } + } + } + queries + } + + #[test] + fn ordered_cursor_lookups_match_linear_scans() { + let mut ordered_moves = 0; + let mut ordered_clicks = 0; + for times in event_time_cases() { + let mut cursor = cursor_events( + × + .iter() + .enumerate() + .map(|(index, &time)| (time, index as f64 * 0.1, 0.5)) + .collect::>(), + ); + for all_down in [false, true] { + cursor.clicks = times + .iter() + .enumerate() + .map(|(index, &time)| click_event(time, all_down || index % 2 == 0)) + .collect(); + let order = crate::cursor_interpolation::PrecomputedCursorTimeline::new( + &cursor, None, None, + ) + .event_order(); + ordered_moves += usize::from(order.moves); + ordered_clicks += usize::from(order.clicks); + for time in lookup_queries(×) { + if order.clicks { + assert_eq!( + get_click_t(&cursor.clicks, time, true).to_bits(), + get_click_t(&cursor.clicks, time, false).to_bits(), + "{times:?} clicks at {time}" + ); + } + if order.moves { + for delay in [500.0, 1000.0, 2000.0, 2000.5] { + assert_eq!( + compute_cursor_idle_opacity(&cursor, time, delay, true).to_bits(), + compute_cursor_idle_opacity(&cursor, time, delay, false).to_bits(), + "{times:?} moves at {time} with delay {delay}" + ); + } + } + } + } + } + assert_eq!(ordered_moves, 14); + assert_eq!(ordered_clicks, 12); } } diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index dfe7030cf43..91406884e3b 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -8,7 +8,8 @@ use cap_project::{ use composite_frame::{ColorGradeUniformParams, CompositeVideoFrameUniforms}; use core::f64; use cursor_interpolation::{ - InterpolatedCursorPosition, interpolate_cursor, interpolate_cursor_with_click_spring, + CursorEventOrder, InterpolatedCursorPosition, interpolate_cursor, + interpolate_cursor_with_click_spring, }; use decoder::{AsyncVideoDecoderHandle, spawn_decoder}; #[cfg(target_os = "macos")] @@ -2898,6 +2899,7 @@ pub struct ProjectUniforms { /// their edges); the cursor and click-ripple layers scale by it. pub cursor_clip_visibility: f32, pub click_ripples: Vec, + cursor_event_order: CursorEventOrder, pub project: ProjectConfiguration, pub zoom: InterpolatedZoom, pub scene: InterpolatedScene, @@ -2929,6 +2931,7 @@ fn collect_click_ripples( now_ms: f64, cursor_stop_time: Option, cursor_interp_fn: &dyn Fn(f32) -> Option, + clicks_ordered: bool, ) -> Vec { let ripple = &project.cursor.ripple; if project.cursor.hide || !ripple.enabled { @@ -2936,8 +2939,19 @@ fn collect_click_ripples( } let duration_ms = ripple.duration_clamped() as f64 * 1000.0; - let mut ripples: Vec = cursor_events - .clicks + let clicks = cursor_events.clicks.as_slice(); + let clicks = if clicks_ordered { + let start = clicks.partition_point(|click| now_ms - click.time_ms >= duration_ms); + let clicks = &clicks[start..]; + let end = clicks.partition_point(|click| { + let age_ms = now_ms - click.time_ms; + age_ms >= 0.0 || age_ms.is_nan() + }); + &clicks[..end] + } else { + clicks + }; + let mut ripples: Vec = clicks .iter() .filter(|click| click.down) .filter_map(|click| { @@ -3955,6 +3969,7 @@ impl ProjectUniforms { total_duration, zoom_timeline, &cursor_interp_fn, + CursorEventOrder::default(), ) } @@ -4018,6 +4033,7 @@ impl ProjectUniforms { total_duration, zoom_timeline, &cursor_interp_fn, + precomputed_cursor.event_order(), ) } @@ -4033,6 +4049,7 @@ impl ProjectUniforms { total_duration: f64, zoom_timeline: &ZoomTransformTimeline, cursor_interp_fn: &dyn Fn(f32) -> Option, + cursor_event_order: CursorEventOrder, ) -> Self { let options = &constants.options; let output_size = Self::get_output_size(options, project, resolution_base); @@ -4097,6 +4114,7 @@ impl ProjectUniforms { current_recording_time as f64 * 1000.0, cursor_stop_time, cursor_interp_fn, + cursor_event_order.clicks, ); let lookback_t = (cursor_time_for_interp - 0.4).max(0.0); let past_cursor_for_tilt = cursor_interp_fn(lookback_t); @@ -5120,6 +5138,7 @@ impl ProjectUniforms { prev_cursor: prev_interpolated_cursor, cursor_clip_visibility, click_ripples, + cursor_event_order, display_parent_motion_px: display_motion_parent, motion_blur_amount: cursor_motion_blur, masks, @@ -5232,8 +5251,14 @@ mod tests { }; for (now_ms, expected_progress) in [(8_750.0, 0.25), (9_000.0, 0.5)] { - let ripples = - collect_click_ripples(&project, &events, now_ms, Some(8.0), &ripple_cursor_at); + let ripples = collect_click_ripples( + &project, + &events, + now_ms, + Some(8.0), + &ripple_cursor_at, + true, + ); let displayed = ripple_cursor_at(cursor_time_for_interpolation( (now_ms / 1000.0) as f32, Some(8.0), @@ -5257,8 +5282,14 @@ mod tests { }; for (stop_time, expected_position) in [(None, 8.5), (Some(9.0), 8.5), (Some(0.0), 0.0)] { - let ripples = - collect_click_ripples(&project, &events, 8_750.0, stop_time, &ripple_cursor_at); + let ripples = collect_click_ripples( + &project, + &events, + 8_750.0, + stop_time, + &ripple_cursor_at, + true, + ); assert_eq!(ripples.len(), 1); assert_eq!(ripples[0].position.coord.x, expected_position); @@ -5280,8 +5311,14 @@ mod tests { ..Default::default() }; - let ripples = - collect_click_ripples(&project, &events, 9_000.0, Some(8.0), &ripple_cursor_at); + let ripples = collect_click_ripples( + &project, + &events, + 9_000.0, + Some(8.0), + &ripple_cursor_at, + true, + ); assert_eq!(ripples.len(), 1); assert_eq!(ripples[0].progress, 0.0); @@ -5297,14 +5334,20 @@ mod tests { .collect(), ..Default::default() }; - assert!(collect_click_ripples(&project, &events, 10.0, None, &ripple_cursor_at).is_empty()); + assert!( + collect_click_ripples(&project, &events, 10.0, None, &ripple_cursor_at, true) + .is_empty() + ); project.cursor.ripple.enabled = true; project.cursor.hide = true; - assert!(collect_click_ripples(&project, &events, 10.0, None, &ripple_cursor_at).is_empty()); + assert!( + collect_click_ripples(&project, &events, 10.0, None, &ripple_cursor_at, true) + .is_empty() + ); project.cursor.hide = false; - let ripples = collect_click_ripples(&project, &events, 10.0, None, &ripple_cursor_at); + let ripples = collect_click_ripples(&project, &events, 10.0, None, &ripple_cursor_at, true); assert_eq!(ripples.len(), layers::MAX_CLICK_RIPPLES); assert_eq!(ripples[0].position.coord.x, f64::from(0.004_f32)); assert_eq!( @@ -5313,6 +5356,73 @@ mod tests { ); } + #[test] + fn ordered_click_ripple_window_matches_linear_scan() { + let cases = [ + vec![0.0, 0.0, 400.0, 400.0, 900.0, 1500.0, 1500.0, 2600.0], + vec![-0.0, 0.0, 0.0, 10.0, 100.0], + vec![f64::NEG_INFINITY, 0.0, 10.0, f64::INFINITY], + vec![0.0, 400.0, 200.0, 900.0], + vec![0.0, f64::NAN, 900.0], + vec![f64::NAN], + vec![], + ]; + let mut project = ProjectConfiguration::default(); + project.cursor.ripple.enabled = true; + let mut compared = 0; + for times in cases { + let events = CursorEvents { + clicks: times + .iter() + .enumerate() + .map(|(index, &time)| ripple_click(time, index % 3 != 1)) + .collect(), + ..Default::default() + }; + if !PrecomputedCursorTimeline::new(&events, None, None) + .event_order() + .clicks + { + continue; + } + for duration in [0.25, 0.6, 1.0, 5.0] { + project.cursor.ripple.duration = duration; + let duration_ms = project.cursor.ripple.duration_clamped() as f64 * 1000.0; + let queries = times.iter().flat_map(|&time| { + [time, time + duration_ms] + .into_iter() + .flat_map(|base| [base.next_down(), base, base.next_up()]) + }); + for now_ms in queries.chain([f64::NAN, f64::NEG_INFINITY, f64::INFINITY, 1.0e9]) { + for stop_time in [None, Some(0.5)] { + let bits = |ordered| { + collect_click_ripples( + &project, + &events, + now_ms, + stop_time, + &ripple_cursor_at, + ordered, + ) + .iter() + .map(|ripple| { + ( + ripple.position.coord.x.to_bits(), + ripple.position.coord.y.to_bits(), + ripple.progress.to_bits(), + ) + }) + .collect::>() + }; + assert_eq!(bits(true), bits(false), "{times:?} at {now_ms}"); + compared += 1; + } + } + } + } + assert!(compared > 0); + } + fn render_options(screen_width: u32, screen_height: u32) -> RenderOptions { RenderOptions { screen_size: XY::new(screen_width, screen_height), @@ -9250,6 +9360,248 @@ mod project_uniforms_tests { } } + #[tokio::test] + async fn ordered_cursor_event_lookups_preserve_rendered_pixels() { + let mut constants = cursor_test_constants().await; + let size = XY::new(320, 180); + constants.options.screen_size = size; + let events = CursorEvents { + moves: [0.0, 100.0, 200.0, 300.0, 3000.0, 3050.0, 3100.0] + .into_iter() + .enumerate() + .map(|(index, time)| cursor_move(time, 0.3 + index as f64 * 0.05, 0.5)) + .collect(), + clicks: [ + (150.0, true), + (230.0, false), + (3060.0, true), + (3120.0, false), + ] + .into_iter() + .map(|(time_ms, down)| cap_project::CursorClickEvent { + active_modifiers: Vec::new(), + cursor_num: 0, + cursor_id: "primary".to_string(), + time_ms, + down, + }) + .collect(), + }; + let mut project = ProjectConfiguration::default(); + project.cursor.set_cursor_type(CursorType::Circle); + project.cursor.hide_when_idle = true; + project.cursor.hide_when_idle_delay = 0.5; + project.cursor.ripple.enabled = true; + project.timeline = Some( + serde_json::from_value(serde_json::json!({ + "segments": [{ "start": 0.0, "end": 4.0, "timescale": 1.0 }], + "zoomSegments": [] + })) + .unwrap(), + ); + let cursor = PrecomputedCursorTimeline::new( + &events, + Some(SpringMassDamperSimulationConfig { + tension: project.cursor.tension, + mass: project.cursor.mass, + friction: project.cursor.friction, + }), + Some(project.cursor.click_spring_config()), + ); + assert_eq!( + cursor.event_order(), + CursorEventOrder { + moves: true, + clicks: true + } + ); + let zoom = ZoomTransformTimeline::from_project(&project, &events, 4.0, size); + let source = DecodedFrame::new(vec![160; 320 * 180 * 4], size.x, size.y); + let mut ordered_renderer = FrameRenderer::new(&constants); + let mut linear_renderer = FrameRenderer::new(&constants); + let mut ordered_layers = RendererLayers::new(&constants.device, &constants.queue); + let mut linear_layers = RendererLayers::new(&constants.device, &constants.queue); + let mut distinct_frames = std::collections::HashSet::new(); + for frame_number in [10, 12, 14, 18, 54, 60, 66, 72, 120, 181, 184, 188, 192, 200] { + let time = frame_number as f32 / 60.0; + let frames = DecodedSegmentFrames { + screen_size: size, + screen_frame: Some(source.clone()), + camera_frame: None, + segment_time: time, + recording_time: time, + segment_has_camera: false, + }; + let ordered = ProjectUniforms::new_with_precomputed_cursor( + &constants, + &project, + frame_number, + 60, + size, + &events, + &frames, + 4.0, + &zoom, + &cursor, + ); + let mut linear = ordered.clone(); + linear.cursor_event_order = CursorEventOrder::default(); + linear.click_ripples = collect_click_ripples( + &project, + &events, + f64::from(time) * 1000.0, + None, + &|time| cursor.interpolate(time), + false, + ); + let ordered = ordered_renderer + .render_immediate(frames.clone(), ordered, &events, true, &mut ordered_layers) + .await + .unwrap(); + let linear = linear_renderer + .render_immediate(frames, linear, &events, true, &mut linear_layers) + .await + .unwrap(); + assert!( + ordered.data == linear.data, + "frame={frame_number}: rendered cursor pixels changed" + ); + distinct_frames.insert(ordered.data); + } + assert!(distinct_frames.len() > 8); + } + + #[tokio::test] + #[ignore] + async fn benchmark_idle_cursor_frame_preparation() { + let mut constants = cursor_test_constants().await; + let size = XY::new(320, 180); + constants.options.screen_size = size; + println!("idle_cursor adapter={}", constants.adapter_name()); + let source = DecodedFrame::new(vec![160; 320 * 180 * 4], size.x, size.y); + for duration_secs in [60u32, 600, 3600] { + let mut project = ProjectConfiguration::default(); + project.cursor.set_cursor_type(CursorType::Circle); + project.cursor.hide_when_idle = true; + project.cursor.ripple.enabled = true; + project.timeline = Some( + serde_json::from_value(serde_json::json!({ + "segments": [{ "start": 0.0, "end": duration_secs, "timescale": 1.0 }], + "zoomSegments": [] + })) + .unwrap(), + ); + let periods = duration_secs / 6; + let events = CursorEvents { + moves: (0..periods) + .flat_map(|period| { + (0..240).map(move |index| { + cursor_move( + f64::from(period) * 6000.0 + f64::from(index) * 1000.0 / 60.0, + f64::from((period * 240 + index) % 479) / 479.0, + f64::from((period * 240 + index) % 283) / 283.0, + ) + }) + }) + .collect(), + clicks: (0..periods) + .flat_map(|period| { + [1000.0, 1080.0, 3000.0, 3080.0] + .into_iter() + .enumerate() + .map(move |(index, offset)| cap_project::CursorClickEvent { + active_modifiers: Vec::new(), + cursor_num: 0, + cursor_id: "primary".to_string(), + time_ms: f64::from(period) * 6000.0 + offset, + down: index % 2 == 0, + }) + }) + .collect(), + }; + let cursor = PrecomputedCursorTimeline::new( + &events, + Some(SpringMassDamperSimulationConfig { + tension: project.cursor.tension, + mass: project.cursor.mass, + friction: project.cursor.friction, + }), + Some(project.cursor.click_spring_config()), + ); + let zoom = ZoomTransformTimeline::from_project( + &project, + &events, + f64::from(duration_secs), + size, + ); + let mut renderer = FrameRenderer::new(&constants); + let mut layers = RendererLayers::new(&constants.device, &constants.queue); + let mut random = 0x9e37_79b9_u32; + for workload in ["start", "middle", "end", "random_seek"] { + let mut uniform_samples = Vec::new(); + let mut cursor_samples = Vec::new(); + for index in 0..544u32 { + random ^= random << 13; + random ^= random >> 17; + random ^= random << 5; + let period = match workload { + "start" => index % 4, + "middle" => periods / 2 + index % 4, + "end" => periods - 4 + index % 4, + _ => random % periods, + }; + let frame_number = period * 360 + 255 + index % 90; + let time = frame_number as f32 / 60.0; + let frames = DecodedSegmentFrames { + screen_size: size, + screen_frame: Some(source.clone()), + camera_frame: None, + segment_time: time, + recording_time: time, + segment_has_camera: false, + }; + let started = Instant::now(); + let uniforms = ProjectUniforms::new_with_precomputed_cursor( + &constants, + &project, + frame_number, + 60, + size, + &events, + &frames, + f64::from(duration_secs), + &zoom, + &cursor, + ); + let uniforms_us = started.elapsed().as_secs_f64() * 1e6; + let (_, timings) = renderer + .render_immediate_with_timings(frames, uniforms, &events, true, &mut layers) + .await + .unwrap(); + if index >= 32 { + uniform_samples.push(uniforms_us); + cursor_samples.push(timings.cursor_prepare_duration.as_secs_f64() * 1e6); + } + } + uniform_samples.sort_by(f64::total_cmp); + cursor_samples.sort_by(f64::total_cmp); + let percentile = + |samples: &[f64], percent: usize| samples[samples.len() * percent / 100]; + println!( + "idle_cursor duration_secs={duration_secs} moves={} clicks={} workload={workload} \ + uniforms_p50_us={:.3} uniforms_p95_us={:.3} \ + cursor_prepare_p50_us={:.3} cursor_prepare_p95_us={:.3}", + events.moves.len(), + events.clicks.len(), + percentile(&uniform_samples, 50), + percentile(&uniform_samples, 95), + percentile(&cursor_samples, 50), + percentile(&cursor_samples, 95), + ); + } + } + } + #[tokio::test] async fn cursor_tracks_each_clips_display_aspect_ratio() { let constants = cursor_test_constants().await;