diff --git a/crates/rendering/src/cursor_interpolation.rs b/crates/rendering/src/cursor_interpolation.rs index afe5c8098d..a8ccaa95eb 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 f5e87b8094..25f5a5c3a9 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 dfe7030cf4..91406884e3 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;