From 43edd3dd90ae506a1039453fbe1d993c372dbbcb Mon Sep 17 00:00:00 2001 From: JeffreyLin1 Date: Wed, 14 Oct 2026 02:22:24 -0400 Subject: [PATCH 1/3] perf(rendering): rebuild zoom timelines without per-step segment scans The editor drops its zoom timelines on every project change and simulates them again from 0:00 to the playhead. A paused live edit pays for the whole recording up to the playhead, and so does pressing play or editing during playback. Most of that time was repeated work. targets_at scanned every zoom segment twice per 8 ms step, and now uses a small index with binary searches. Each auto segment filtered every cursor move to build its clusters; time-ordered moves are now narrowed by binary search first and the same filters run on what is left. The spring solver also recomputed exp, sin and cos terms on every step even though they only depend on the step length and the spring constants, so each simulation now caches them. Samples are bit-identical to the previous implementation. On a 30 minute recording with 60 auto zooms, a paused edit at 29:24 goes from 28.7 ms to 8.4 ms. --- crates/rendering/src/spring_mass_damper.rs | 263 ++++++++++++---- crates/rendering/src/zoom_spring.rs | 330 +++++++++++++++++++-- 2 files changed, 524 insertions(+), 69 deletions(-) diff --git a/crates/rendering/src/spring_mass_damper.rs b/crates/rendering/src/spring_mass_damper.rs index 088db96c9bd..01933bc9f2e 100644 --- a/crates/rendering/src/spring_mass_damper.rs +++ b/crates/rendering/src/spring_mass_damper.rs @@ -14,67 +14,152 @@ pub struct SpringMassDamperSimulation { pub position: XY, pub velocity: XY, pub target_position: XY, + cached_step: Option, } const REST_VELOCITY_THRESHOLD: f32 = 0.0001; const REST_DISPLACEMENT_THRESHOLD: f32 = 0.00001; -fn solve_spring_1d(displacement: f32, velocity: f32, t: f32, omega0: f32, zeta: f32) -> (f32, f32) { - const CRITICAL_EPSILON: f32 = 0.01; - - if zeta < 1.0 - CRITICAL_EPSILON { - let omega_d = omega0 * (1.0 - zeta * zeta).sqrt(); - let decay = (-zeta * omega0 * t).exp(); - let cos_term = (omega_d * t).cos(); - let sin_term = (omega_d * t).sin(); - - let a = displacement; - let b = (velocity + displacement * zeta * omega0) / omega_d.max(1e-4); - - let new_disp = decay * (a * cos_term + b * sin_term); - let new_vel = decay - * ((b * omega_d - a * zeta * omega0) * cos_term - - (a * omega_d + b * zeta * omega0) * sin_term); - - (new_disp, new_vel) - } else if zeta > 1.0 + CRITICAL_EPSILON { - let sqrt_term = (zeta * zeta - 1.0).sqrt(); - let s1 = -omega0 * (zeta - sqrt_term); - let s2 = -omega0 * (zeta + sqrt_term); - let denom = s1 - s2; - - if denom.abs() < 1e-10 { - let s_avg = 0.5 * (s1 + s2); - let decay = (s_avg * t).exp(); - let new_disp = decay * (displacement + (velocity - displacement * s_avg) * t); - let new_vel = decay - * ((velocity - displacement * s_avg) - + s_avg * (displacement + (velocity - displacement * s_avg) * t)); - (new_disp, new_vel) - } else { - let c1 = (velocity - displacement * s2) / denom; - let c2 = displacement - c1; +/// `solve` keeps the original per-step operand order, so cached steps stay +/// bit-identical to solving afresh. +#[derive(Clone, Copy)] +enum SpringStep { + Underdamped { + omega0: f32, + zeta: f32, + omega_d: f32, + b_divisor: f32, + decay: f32, + cos_term: f32, + sin_term: f32, + }, + Overdamped { + s1: f32, + s2: f32, + denom: f32, + e1: f32, + e2: f32, + }, + OverdampedDegenerate { + t: f32, + s_avg: f32, + decay: f32, + }, + CriticallyDamped { + t: f32, + omega0: f32, + decay: f32, + }, +} - let e1 = (s1 * t).exp(); - let e2 = (s2 * t).exp(); +impl SpringStep { + fn new(t: f32, omega0: f32, zeta: f32) -> Self { + const CRITICAL_EPSILON: f32 = 0.01; - let new_disp = c1 * e1 + c2 * e2; - let new_vel = c1 * s1 * e1 + c2 * s2 * e2; + if zeta < 1.0 - CRITICAL_EPSILON { + let omega_d = omega0 * (1.0 - zeta * zeta).sqrt(); + Self::Underdamped { + omega0, + zeta, + omega_d, + b_divisor: omega_d.max(1e-4), + decay: (-zeta * omega0 * t).exp(), + cos_term: (omega_d * t).cos(), + sin_term: (omega_d * t).sin(), + } + } else if zeta > 1.0 + CRITICAL_EPSILON { + let sqrt_term = (zeta * zeta - 1.0).sqrt(); + let s1 = -omega0 * (zeta - sqrt_term); + let s2 = -omega0 * (zeta + sqrt_term); + let denom = s1 - s2; - (new_disp, new_vel) + if denom.abs() < 1e-10 { + let s_avg = 0.5 * (s1 + s2); + Self::OverdampedDegenerate { + t, + s_avg, + decay: (s_avg * t).exp(), + } + } else { + Self::Overdamped { + s1, + s2, + denom, + e1: (s1 * t).exp(), + e2: (s2 * t).exp(), + } + } + } else { + Self::CriticallyDamped { + t, + omega0, + decay: (-omega0 * t).exp(), + } } - } else { - let decay = (-omega0 * t).exp(); - let a = displacement; - let b = velocity + displacement * omega0; + } + + fn solve(self, displacement: f32, velocity: f32) -> (f32, f32) { + match self { + Self::Underdamped { + omega0, + zeta, + omega_d, + b_divisor, + decay, + cos_term, + sin_term, + } => { + let a = displacement; + let b = (velocity + displacement * zeta * omega0) / b_divisor; + + let new_disp = decay * (a * cos_term + b * sin_term); + let new_vel = decay + * ((b * omega_d - a * zeta * omega0) * cos_term + - (a * omega_d + b * zeta * omega0) * sin_term); + + (new_disp, new_vel) + } + Self::Overdamped { + s1, + s2, + denom, + e1, + e2, + } => { + let c1 = (velocity - displacement * s2) / denom; + let c2 = displacement - c1; + + let new_disp = c1 * e1 + c2 * e2; + let new_vel = c1 * s1 * e1 + c2 * s2 * e2; - let new_disp = decay * (a + b * t); - let new_vel = decay * (b - omega0 * (a + b * t)); + (new_disp, new_vel) + } + Self::OverdampedDegenerate { t, s_avg, decay } => { + let new_disp = decay * (displacement + (velocity - displacement * s_avg) * t); + let new_vel = decay + * ((velocity - displacement * s_avg) + + s_avg * (displacement + (velocity - displacement * s_avg) * t)); + (new_disp, new_vel) + } + Self::CriticallyDamped { t, omega0, decay } => { + let a = displacement; + let b = velocity + displacement * omega0; - (new_disp, new_vel) + let new_disp = decay * (a + b * t); + let new_vel = decay * (b - omega0 * (a + b * t)); + + (new_disp, new_vel) + } + } } } +#[derive(Clone, Copy)] +struct CachedSpringStep { + key: [u32; 4], + step: SpringStep, +} + impl SpringMassDamperSimulation { pub fn new(config: SpringMassDamperSimulationConfig) -> Self { Self { @@ -84,6 +169,7 @@ impl SpringMassDamperSimulation { position: XY::new(0.0, 0.0), velocity: XY::new(0.0, 0.0), target_position: XY::new(0.0, 0.0), + cached_step: None, } } @@ -103,9 +189,12 @@ impl SpringMassDamperSimulation { self.target_position = target_position; } - pub fn run(&mut self, dt_ms: f32) -> XY { - if dt_ms <= 0.0 { - return self.position; + fn step(&mut self, dt_ms: f32) -> SpringStep { + let key = [dt_ms, self.tension, self.mass, self.friction].map(f32::to_bits); + if let Some(cached) = self.cached_step + && cached.key == key + { + return cached.step; } let t = dt_ms / 1000.0; @@ -116,11 +205,23 @@ impl SpringMassDamperSimulation { let omega0 = (stiffness / mass).sqrt(); let zeta = damping / (2.0 * (stiffness * mass).sqrt()); + let step = SpringStep::new(t, omega0, zeta); + self.cached_step = Some(CachedSpringStep { key, step }); + step + } + + pub fn run(&mut self, dt_ms: f32) -> XY { + if dt_ms <= 0.0 { + return self.position; + } + + let step = self.step(dt_ms); + let disp_x = self.position.x - self.target_position.x; let disp_y = self.position.y - self.target_position.y; - let (new_disp_x, new_vel_x) = solve_spring_1d(disp_x, self.velocity.x, t, omega0, zeta); - let (new_disp_y, new_vel_y) = solve_spring_1d(disp_y, self.velocity.y, t, omega0, zeta); + let (new_disp_x, new_vel_x) = step.solve(disp_x, self.velocity.x); + let (new_disp_y, new_vel_y) = step.solve(disp_y, self.velocity.y); self.position = XY::new( self.target_position.x + new_disp_x, @@ -139,3 +240,61 @@ impl SpringMassDamperSimulation { self.position } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cached_spring_steps_match_fresh_simulations() { + let configs = [ + SpringMassDamperSimulationConfig { + tension: 200.0, + mass: 2.25, + friction: 40.0, + }, + SpringMassDamperSimulationConfig { + tension: 100.0, + mass: 1.0, + friction: 20.0, + }, + SpringMassDamperSimulationConfig { + tension: 50.0, + mass: 1.0, + friction: 200.0, + }, + SpringMassDamperSimulationConfig { + tension: 400.0, + mass: 0.0, + friction: 1.0, + }, + ]; + let mut cached = SpringMassDamperSimulation::new(configs[0]); + cached.set_position(XY::new(0.1, 0.9)); + let mut position = cached.position; + let mut velocity = cached.velocity; + for step in 0..2_000usize { + let config = configs[(step / 97) % configs.len()]; + let target = XY::new(((step / 150) % 3) as f32 * 0.4, 0.5); + let dt_ms = if step % 11 == 0 { 16.0 } else { 8.0 }; + + let mut fresh = SpringMassDamperSimulation::new(config); + fresh.set_position(position); + fresh.set_velocity(velocity); + fresh.set_target_position(target); + let expected = fresh.run(dt_ms); + + cached.set_config(config); + cached.set_target_position(target); + let actual = cached.run(dt_ms); + + assert_eq!( + [actual.x, actual.y, cached.velocity.x, cached.velocity.y].map(f32::to_bits), + [expected.x, expected.y, fresh.velocity.x, fresh.velocity.y].map(f32::to_bits), + "step {step}" + ); + position = fresh.position; + velocity = fresh.velocity; + } + } +} diff --git a/crates/rendering/src/zoom_spring.rs b/crates/rendering/src/zoom_spring.rs index 462c931d3d7..eb85f8b8ed6 100644 --- a/crates/rendering/src/zoom_spring.rs +++ b/crates/rendering/src/zoom_spring.rs @@ -205,6 +205,7 @@ enum ZoomClusters { impl ZoomClusters { fn new( cursor_events: &CursorEvents, + moves_time_ordered: bool, recording_start: f64, recording_end: f64, segment: &ZoomSegment, @@ -213,6 +214,7 @@ impl ZoomClusters { let build = |crop| { build_clusters( cursor_events, + moves_time_ordered, recording_start, recording_end, segment.amount, @@ -330,6 +332,7 @@ impl ClickCluster { /// need no separate pass. pub(crate) fn build_clusters( cursor_events: &CursorEvents, + moves_time_ordered: bool, segment_start_secs: f64, segment_end_secs: f64, zoom_amount: f64, @@ -351,23 +354,38 @@ pub(crate) fn build_clusters( m.x.is_finite() && m.y.is_finite() && m.time_ms.is_finite() }; - let events_in_range: Vec<&cap_project::CursorMoveEvent> = cursor_events - .moves + // The narrowed slices only drop moves the filters below would reject, so + // the filters stay and NaN bounds still match a full scan. + let moves = cursor_events.moves.as_slice(); + let (range_moves, moves_until_start, moves_from_start) = if moves_time_ordered { + let from_start = moves.partition_point(|m| m.time_ms < start_ms); + let through_end = moves + .partition_point(|m| m.time_ms <= end_ms) + .max(from_start); + let through_start = moves.partition_point(|m| m.time_ms <= start_ms); + ( + &moves[from_start..through_end], + &moves[..through_start], + &moves[from_start..], + ) + } else { + (moves, moves, moves) + }; + + let events_in_range: Vec<&cap_project::CursorMoveEvent> = range_moves .iter() .filter(finite) .filter(|m| m.time_ms >= start_ms && m.time_ms <= end_ms) .collect(); if events_in_range.is_empty() { - let fallback = cursor_events - .moves + let fallback = moves_until_start .iter() .filter(finite) .rev() .find(|m| m.time_ms <= start_ms) .or_else(|| { - cursor_events - .moves + moves_from_start .iter() .filter(finite) .find(|m| m.time_ms >= start_ms) @@ -580,6 +598,56 @@ struct StepTargets { snap: bool, } +/// Segments with a NaN bound can never be active, so they are left out of the +/// binary-searched index. +#[derive(Default)] +struct ZoomSegmentIndex { + ordered: Vec, + starts: Vec, + /// Running maximum of `end`, so the first entry reaching `t` is also the + /// first segment whose own end reaches `t`. + max_ends: Vec, + instant_windows: Vec<(f64, f64)>, +} + +impl ZoomSegmentIndex { + fn new(zoom_segments: &[ZoomSegment]) -> Self { + let mut index = Self::default(); + let mut max_end = f64::NEG_INFINITY; + for (i, segment) in zoom_segments.iter().enumerate() { + if segment.instant_animation { + index.instant_windows.push(( + segment.start - INSTANT_SNAP_WINDOW_SECS, + segment.end + INSTANT_SNAP_WINDOW_SECS, + )); + } + if segment.start.is_nan() || segment.end.is_nan() { + continue; + } + max_end = max_end.max(segment.end); + index.ordered.push(i); + index.starts.push(segment.start); + index.max_ends.push(max_end); + } + index + } + + /// Same active predicate `SegmentsCursor` used: (start, end]. + fn active_at(&self, timeline_secs: f64) -> Option { + let started = self.starts.partition_point(|&start| timeline_secs > start); + let first_reaching = self + .max_ends + .partition_point(|&max_end| max_end < timeline_secs); + (first_reaching < started).then(|| self.ordered[first_reaching]) + } + + fn snaps_at(&self, timeline_secs: f64) -> bool { + self.instant_windows + .iter() + .any(|&(start, end)| timeline_secs >= start && timeline_secs <= end) + } +} + /// Deterministic, lazily precomputed zoom transform timeline. /// /// Construction is cheap (clusters only); integration happens on demand via @@ -590,6 +658,7 @@ pub struct ZoomTransformTimeline { samples: Vec, state: Option, zoom_segments: Vec, + segment_index: ZoomSegmentIndex, /// Parallel to `zoom_segments`: prebuilt clusters (RECORDING-time ms) for /// Auto segments, `None` for Manual ones. clusters: Vec>, @@ -646,6 +715,13 @@ impl ZoomTransformTimeline { } = selection; let mut zoom_segments = zoom_segments.to_vec(); zoom_segments.sort_by(|a, b| a.start.total_cmp(&b.start).then(a.end.total_cmp(&b.end))); + let segment_index = ZoomSegmentIndex::new(&zoom_segments); + let moves_time_ordered = zoom_segments + .iter() + .any(|segment| matches!(segment.mode, ZoomMode::Auto)) + && cursor_events + .moves + .is_sorted_by(|a, b| a.time_ms <= b.time_ms); let time_map = if zoom_segments.is_empty() { Vec::new() @@ -671,6 +747,7 @@ impl ZoomTransformTimeline { .max(recording_start); Some(ZoomClusters::new( cursor_events, + moves_time_ordered, recording_start, recording_end, segment, @@ -694,6 +771,7 @@ impl ZoomTransformTimeline { samples: Vec::new(), state: None, zoom_segments, + segment_index, clusters, time_map, recording_clip, @@ -712,6 +790,7 @@ impl ZoomTransformTimeline { samples: Vec::new(), state: None, zoom_segments, + segment_index, clusters, time_map, recording_clip, @@ -976,17 +1055,8 @@ impl ZoomTransformTimeline { } fn targets_at(&self, timeline_secs: f64, held_center: XY) -> StepTargets { - // Same active predicate `SegmentsCursor` used: (start, end]. - let active = self - .zoom_segments - .iter() - .position(|s| timeline_secs > s.start && timeline_secs <= s.end); - - let snap = self.zoom_segments.iter().any(|s| { - s.instant_animation - && timeline_secs >= s.start - INSTANT_SNAP_WINDOW_SECS - && timeline_secs <= s.end + INSTANT_SNAP_WINDOW_SECS - }); + let active = self.segment_index.active_at(timeline_secs); + let snap = self.segment_index.snaps_at(timeline_secs); match active { Some(index) => { @@ -2094,6 +2164,125 @@ mod tests { ); } + fn benchmark_project(duration_secs: f64, zoom_count: usize) -> ProjectConfiguration { + let spacing = duration_secs / zoom_count as f64; + let zoom_segments = (0..zoom_count) + .map(|i| { + let start = i as f64 * spacing + spacing * 0.25; + auto_segment(start, start + 4.0, 2.0) + }) + .collect(); + let mut project = project_with_crop_styles(zoom_segments, Vec::new()); + if let Some(timeline) = project.timeline.as_mut() { + timeline.segments.push(TimelineSegment { + recording_clip: 0, + timescale: 1.0, + start: 0.0, + end: duration_secs, + name: None, + speed_audio_mode: None, + volume: None, + hide_cursor: None, + }); + } + project + } + + fn benchmark_cursor(duration_secs: f64) -> CursorEvents { + let moves = (0..(duration_secs * 60.0) as usize) + .map(|i| { + move_event( + i as f64 * 1000.0 / 60.0, + ((i as f64 * 0.0011).sin() + 1.0) / 2.0, + ((i as f64 * 0.0007).cos() + 1.0) / 2.0, + ) + }) + .collect(); + CursorEvents { + moves, + clicks: Vec::new(), + } + } + + fn samples_hash(timeline: &ZoomTransformTimeline) -> u64 { + timeline + .samples + .iter() + .fold(0xcbf2_9ce4_8422_2325, |hash, sample| { + [ + sample.amount.to_bits(), + sample.center.x.to_bits(), + sample.center.y.to_bits(), + sample.activity.to_bits(), + u32::from(sample.snapped), + ] + .into_iter() + .fold(hash, |hash, word| { + (hash ^ u64::from(word)).wrapping_mul(0x0100_0000_01b3) + }) + }) + } + + #[test] + #[ignore = "benchmark; run with --release --ignored --nocapture"] + fn benchmark_zoom_timeline_rebuild() { + use std::time::{Duration, Instant}; + + const RUNS: usize = 15; + let screen_size = XY::new(1920, 1080); + let percentile = |times: &mut Vec, p: f64| { + times.sort_unstable(); + let index = ((times.len() - 1) as f64 * p).round() as usize; + times[index].as_secs_f64() * 1000.0 + }; + + println!( + "| recording | auto zooms | playhead | build p50 | precompute p50 | rebuild p50 | rebuild p95 | samples hash |" + ); + println!("| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |"); + for (minutes, zoom_count) in [(10.0, 20), (30.0, 60), (60.0, 120)] { + let duration_secs = minutes * 60.0; + let project = benchmark_project(duration_secs, zoom_count); + let cursor = benchmark_cursor(duration_secs); + for playhead_fraction in [0.5, 1.0] { + let playhead = (duration_secs * playhead_fraction) as f32; + let mut build_times = Vec::with_capacity(RUNS); + let mut precompute_times = Vec::with_capacity(RUNS); + let mut rebuild_times = Vec::with_capacity(RUNS); + let mut checksum = 0.0f64; + let mut hash = 0; + for _ in 0..RUNS { + let build_start = Instant::now(); + let mut timeline = ZoomTransformTimeline::from_project_for_clip( + &project, + &cursor, + duration_secs, + screen_size, + 0, + ); + let build_elapsed = build_start.elapsed(); + let precompute_start = Instant::now(); + timeline.ensure_precomputed_until(playhead); + let precompute_elapsed = precompute_start.elapsed(); + checksum += timeline.sample(playhead).display_amount(); + hash = samples_hash(&timeline); + build_times.push(build_elapsed); + precompute_times.push(precompute_elapsed); + rebuild_times.push(build_elapsed + precompute_elapsed); + } + std::hint::black_box(checksum); + println!( + "| {minutes} min | {zoom_count} | {:.0}% | {:.3} ms | {:.3} ms | {:.3} ms | {:.3} ms | {hash:016x} |", + playhead_fraction * 100.0, + percentile(&mut build_times, 0.5), + percentile(&mut precompute_times, 0.5), + percentile(&mut rebuild_times, 0.5), + percentile(&mut rebuild_times, 0.95), + ); + } + } + } + #[test] fn timeline_mapping_filters_clusters_in_recording_time() { // Timeline: single segment showing recording range [10s, 20s] at 1x, @@ -2317,4 +2506,111 @@ mod tests { settled.bounds ); } + + #[test] + fn segment_index_matches_linear_segment_scan() { + let mut instant = manual_segment(4.0, 4.5, 2.0, 0.5, 0.5); + instant.instant_animation = true; + let mut instant_nan = manual_segment(f64::NAN, 9.0, 2.0, 0.5, 0.5); + instant_nan.instant_animation = true; + let cases = [ + vec![], + vec![manual_segment(1.0, 2.0, 2.0, 0.5, 0.5)], + vec![ + manual_segment(1.0, 6.0, 2.0, 0.5, 0.5), + manual_segment(2.0, 3.0, 3.0, 0.5, 0.5), + manual_segment(2.0, 3.0, 4.0, 0.5, 0.5), + manual_segment(3.0, 3.0, 2.0, 0.5, 0.5), + manual_segment(5.0, 4.0, 2.0, 0.5, 0.5), + instant, + ], + vec![ + manual_segment(f64::NAN, 2.0, 2.0, 0.5, 0.5), + manual_segment(-f64::NAN, 2.0, 2.0, 0.5, 0.5), + manual_segment(1.0, f64::NAN, 2.0, 0.5, 0.5), + manual_segment(-0.0, 0.0, 2.0, 0.5, 0.5), + manual_segment(f64::NEG_INFINITY, 0.5, 2.0, 0.5, 0.5), + manual_segment(7.0, f64::INFINITY, 2.0, 0.5, 0.5), + instant_nan, + ], + ]; + for segments in cases { + let timeline = timeline_for(&segments, &CursorEvents::default(), 10.0); + let mut times: Vec = segments + .iter() + .flat_map(|s| [s.start, s.end, s.start - 0.1, s.end + 0.1]) + .flat_map(|t| [t, t.next_down(), t.next_up()]) + .collect(); + times.extend((0..=1300).map(|step| step as f64 * STEP_MS / 1000.0)); + for t in times { + let linear_active = timeline + .zoom_segments + .iter() + .position(|s| t > s.start && t <= s.end); + let linear_snap = timeline.zoom_segments.iter().any(|s| { + s.instant_animation + && t >= s.start - INSTANT_SNAP_WINDOW_SECS + && t <= s.end + INSTANT_SNAP_WINDOW_SECS + }); + assert_eq!(timeline.segment_index.active_at(t), linear_active, "t={t}"); + assert_eq!(timeline.segment_index.snaps_at(t), linear_snap, "t={t}"); + } + } + } + + #[test] + fn ordered_cluster_search_matches_full_scan() { + let moves = vec![ + move_event(f64::NEG_INFINITY, 0.1, 0.1), + move_event(0.0, 0.2, 0.2), + move_event(500.0, f64::NAN, 0.5), + move_event(500.0, 0.9, 0.9), + move_event(500.0, 0.1, 0.9), + move_event(1_000.0, 0.5, f64::INFINITY), + move_event(2_000.0, 0.95, 0.05), + move_event(2_000.0, 0.05, 0.95), + move_event(3_500.0, 0.6, 0.4), + move_event(9_000.0, 0.3, 0.7), + move_event(f64::INFINITY, 0.8, 0.2), + ]; + let cursor = CursorEvents { + moves, + clicks: vec![], + }; + assert!(cursor.moves.is_sorted_by(|a, b| a.time_ms <= b.time_ms)); + let crop = CursorCropMap::from_crop( + &Crop { + position: XY::new(100, 50), + size: XY::new(1600, 900), + }, + XY::new(1920, 1080), + ); + let bounds = [ + (0.0, 0.0), + (0.0, 0.5), + (0.5, 0.5), + (0.5, 2.0), + (0.6, 0.9), + (2.5, 3.0), + (4.0, 8.0), + (10.0, 20.0), + (-5.0, -1.0), + (f64::NAN, 2.0), + (0.5, f64::NAN), + (f64::NEG_INFINITY, f64::INFINITY), + ]; + for (start, end) in bounds { + for amount in [1.0, 2.0, 4.0] { + for crop in [None, crop] { + let ordered = build_clusters(&cursor, true, start, end, amount, crop); + let scanned = build_clusters(&cursor, false, start, end, amount, crop); + assert_eq!( + format!("{ordered:?}"), + format!("{scanned:?}"), + "range {start}..{end} amount {amount}" + ); + } + } + } + } } From 3156dd230b6fbabd8f50b4157397d8cfdb989cd7 Mon Sep 17 00:00:00 2001 From: JeffreyLin1 Date: Wed, 14 Oct 2026 02:22:24 -0400 Subject: [PATCH 2/3] chore(desktop-gpui): add a paused live-edit preview benchmark --- .../examples/live-edit-benchmark.rs | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 apps/desktop-gpui/examples/live-edit-benchmark.rs diff --git a/apps/desktop-gpui/examples/live-edit-benchmark.rs b/apps/desktop-gpui/examples/live-edit-benchmark.rs new file mode 100644 index 00000000000..134dc309982 --- /dev/null +++ b/apps/desktop-gpui/examples/live-edit-benchmark.rs @@ -0,0 +1,188 @@ +//! The editor can write `project-config.json` back into the project, so point +//! it at a copy rather than a recording in the library. +//! +//! Run: cargo run --release --example live-edit-benchmark -- [TICKS] + +use std::{path::PathBuf, sync::Arc, time::Duration, time::Instant}; + +use cap_editor::{AudioOutput, EditorFrameFormat, EditorFrameOutput, EditorInstance}; +use cap_project::XY; + +const PREVIEW_FPS: u32 = 60; +const PREVIEW_RESOLUTION: XY = XY { x: 1248, y: 702 }; +const REGION_FRACTIONS: [f64; 3] = [0.02, 0.5, 0.98]; + +#[cfg(unix)] +fn cpu_secs() -> f64 { + let mut usage = std::mem::MaybeUninit::::zeroed(); + unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) }; + let usage = unsafe { usage.assume_init() }; + let secs = |time: libc::timeval| time.tv_sec as f64 + time.tv_usec as f64 / 1_000_000.0; + secs(usage.ru_utime) + secs(usage.ru_stime) +} + +#[cfg(windows)] +fn cpu_secs() -> f64 { + use windows_sys::Win32::{ + Foundation::FILETIME, + System::Threading::{GetCurrentProcess, GetProcessTimes}, + }; + + let empty = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let (mut creation, mut exit, mut kernel, mut user) = (empty, empty, empty, empty); + unsafe { + GetProcessTimes( + GetCurrentProcess(), + &mut creation, + &mut exit, + &mut kernel, + &mut user, + ) + }; + let secs = |time: FILETIME| { + ((u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime)) as f64 + / 10_000_000.0 + }; + secs(kernel) + secs(user) +} + +fn percentile(sorted: &[f64], fraction: f64) -> f64 { + sorted[((sorted.len() - 1) as f64 * fraction).round() as usize] +} + +fn frame_number(output: &EditorFrameOutput) -> u32 { + match output { + EditorFrameOutput::Rgba(frame) => frame.frame_number, + EditorFrameOutput::Nv12(frame) => frame.frame_number, + #[cfg(target_os = "macos")] + EditorFrameOutput::Surface(frame) => frame.frame_number, + } +} + +async fn render( + instance: &EditorInstance, + frames: &flume::Receiver, + frame: u32, +) -> anyhow::Result { + while frames.try_recv().is_ok() {} + let requested = Instant::now(); + instance + .preview_tx + .send_modify(|value| *value = Some((frame, PREVIEW_FPS, PREVIEW_RESOLUTION))); + loop { + let rendered = tokio::time::timeout(Duration::from_secs(30), frames.recv_async()) + .await + .map_err(|_| anyhow::anyhow!("timed out waiting for frame {frame}"))??; + if rendered == frame { + return Ok(requested.elapsed().as_secs_f64() * 1000.0); + } + } +} + +async fn run(project_path: PathBuf, ticks: u32) -> anyhow::Result<()> { + #[cfg(target_os = "macos")] + let format = EditorFrameFormat::BgraSurface; + #[cfg(not(target_os = "macos"))] + let format = EditorFrameFormat::Rgba; + + let (frame_tx, frame_rx) = flume::unbounded::(); + let audio_output = Arc::new(AudioOutput::new_headless(Box::new(|_samples, _at| {}))); + let instance = EditorInstance::new_with_audio_output_and_frame_format( + project_path.clone(), + |_state| {}, + Box::new(move |output, _layout| { + let _ = frame_tx.send(frame_number(&output)); + }), + None, + format, + audio_output, + ) + .await + .map_err(anyhow::Error::msg)?; + + let mut project = instance.project_config.1.borrow().clone(); + let duration = project + .timeline + .as_ref() + .map_or(0.0, |timeline| timeline.duration()); + let zoom_segments = project + .timeline + .as_ref() + .map_or(0, |timeline| timeline.zoom_segments.len()); + let last_frame = ((duration * PREVIEW_FPS as f64) as u32).saturating_sub(1); + let base_padding = project.background.padding; + render(&instance, &frame_rx, 0).await?; + + let mut regions = Vec::new(); + let mut edits = Vec::new(); + let mut edit_cpu_secs = 0.0; + for fraction in REGION_FRACTIONS { + let frame = (last_frame as f64 * fraction) as u32; + let seek_ms = render(&instance, &frame_rx, frame).await?; + let cpu_started = cpu_secs(); + let mut latencies = Vec::with_capacity(ticks as usize); + for tick in 1..=ticks { + project.background.padding = base_padding + f64::from(tick % 20) * 0.5; + while frame_rx.try_recv().is_ok() {} + let edited = Instant::now(); + instance.project_config.0.send(project.clone()).ok(); + render(&instance, &frame_rx, frame).await?; + latencies.push(edited.elapsed().as_secs_f64() * 1000.0); + } + edit_cpu_secs += cpu_secs() - cpu_started; + edits.extend_from_slice(&latencies); + latencies.sort_by(f64::total_cmp); + regions.push(serde_json::json!({ + "atSecs": frame as f64 / PREVIEW_FPS as f64, + "seekMs": seek_ms, + "editMedianMs": percentile(&latencies, 0.5), + "editP95Ms": percentile(&latencies, 0.95), + })); + } + edits.sort_by(f64::total_cmp); + println!( + "{}", + serde_json::json!({ + "project": project_path, + "durationSecs": duration, + "zoomSegments": zoom_segments, + "ticks": ticks, + "regions": regions, + "editMedianMs": percentile(&edits, 0.5), + "editP95Ms": percentile(&edits, 0.95), + "editCpuSecs": edit_cpu_secs, + }) + ); + instance.dispose().await; + Ok(()) +} + +fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "warn".into()), + ) + .with_writer(std::io::stderr) + .init(); + + let mut arguments = std::env::args().skip(1); + let project_path = arguments + .next() + .map(PathBuf::from) + .ok_or_else(|| anyhow::anyhow!("Pass a studio .cap project path"))?; + let ticks = arguments + .next() + .and_then(|value| value.parse().ok()) + .unwrap_or(60u32) + .max(1); + + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_stack_size(16 * 1024 * 1024) + .enable_all() + .build()? + .block_on(run(project_path, ticks)) +} From 4131a8497d2a327662caba4fe29e038332544322 Mon Sep 17 00:00:00 2001 From: JeffreyLin1 Date: Wed, 14 Oct 2026 02:32:55 -0400 Subject: [PATCH 3/3] test(rendering): check cached spring steps against the original solver The spring test compared the cached simulation with fresh instances of the same new solver, so a mistake in the moved formulas would pass on both sides. Compare against a test-only copy of the original solver instead, and add a config that reaches the degenerate overdamped branch. --- crates/rendering/src/spring_mass_damper.rs | 122 +++++++++++++++++++-- 1 file changed, 111 insertions(+), 11 deletions(-) diff --git a/crates/rendering/src/spring_mass_damper.rs b/crates/rendering/src/spring_mass_damper.rs index 01933bc9f2e..966cec52e3e 100644 --- a/crates/rendering/src/spring_mass_damper.rs +++ b/crates/rendering/src/spring_mass_damper.rs @@ -245,8 +245,103 @@ impl SpringMassDamperSimulation { mod tests { use super::*; + fn original_solve_spring_1d( + displacement: f32, + velocity: f32, + t: f32, + omega0: f32, + zeta: f32, + ) -> (f32, f32) { + const CRITICAL_EPSILON: f32 = 0.01; + + if zeta < 1.0 - CRITICAL_EPSILON { + let omega_d = omega0 * (1.0 - zeta * zeta).sqrt(); + let decay = (-zeta * omega0 * t).exp(); + let cos_term = (omega_d * t).cos(); + let sin_term = (omega_d * t).sin(); + + let a = displacement; + let b = (velocity + displacement * zeta * omega0) / omega_d.max(1e-4); + + let new_disp = decay * (a * cos_term + b * sin_term); + let new_vel = decay + * ((b * omega_d - a * zeta * omega0) * cos_term + - (a * omega_d + b * zeta * omega0) * sin_term); + + (new_disp, new_vel) + } else if zeta > 1.0 + CRITICAL_EPSILON { + let sqrt_term = (zeta * zeta - 1.0).sqrt(); + let s1 = -omega0 * (zeta - sqrt_term); + let s2 = -omega0 * (zeta + sqrt_term); + let denom = s1 - s2; + + if denom.abs() < 1e-10 { + let s_avg = 0.5 * (s1 + s2); + let decay = (s_avg * t).exp(); + let new_disp = decay * (displacement + (velocity - displacement * s_avg) * t); + let new_vel = decay + * ((velocity - displacement * s_avg) + + s_avg * (displacement + (velocity - displacement * s_avg) * t)); + (new_disp, new_vel) + } else { + let c1 = (velocity - displacement * s2) / denom; + let c2 = displacement - c1; + + let e1 = (s1 * t).exp(); + let e2 = (s2 * t).exp(); + + let new_disp = c1 * e1 + c2 * e2; + let new_vel = c1 * s1 * e1 + c2 * s2 * e2; + + (new_disp, new_vel) + } + } else { + let decay = (-omega0 * t).exp(); + let a = displacement; + let b = velocity + displacement * omega0; + + let new_disp = decay * (a + b * t); + let new_vel = decay * (b - omega0 * (a + b * t)); + + (new_disp, new_vel) + } + } + + fn original_run( + config: SpringMassDamperSimulationConfig, + position: &mut XY, + velocity: &mut XY, + target: XY, + dt_ms: f32, + ) { + let t = dt_ms / 1000.0; + let mass = config.mass.max(0.001); + let stiffness = config.tension; + let damping = config.friction; + + let omega0 = (stiffness / mass).sqrt(); + let zeta = damping / (2.0 * (stiffness * mass).sqrt()); + + let disp_x = position.x - target.x; + let disp_y = position.y - target.y; + + let (new_disp_x, new_vel_x) = original_solve_spring_1d(disp_x, velocity.x, t, omega0, zeta); + let (new_disp_y, new_vel_y) = original_solve_spring_1d(disp_y, velocity.y, t, omega0, zeta); + + *position = XY::new(target.x + new_disp_x, target.y + new_disp_y); + *velocity = XY::new(new_vel_x, new_vel_y); + + let disp_mag = (new_disp_x * new_disp_x + new_disp_y * new_disp_y).sqrt(); + let vel_mag = (new_vel_x * new_vel_x + new_vel_y * new_vel_y).sqrt(); + + if disp_mag < REST_DISPLACEMENT_THRESHOLD && vel_mag < REST_VELOCITY_THRESHOLD { + *position = target; + *velocity = XY::new(0.0, 0.0); + } + } + #[test] - fn cached_spring_steps_match_fresh_simulations() { + fn cached_spring_steps_match_original_solver() { let configs = [ SpringMassDamperSimulationConfig { tension: 200.0, @@ -263,6 +358,11 @@ mod tests { mass: 1.0, friction: 200.0, }, + SpringMassDamperSimulationConfig { + tension: 1e-24, + mass: 1.0, + friction: 1e-11, + }, SpringMassDamperSimulationConfig { tension: 400.0, mass: 0.0, @@ -278,23 +378,23 @@ mod tests { let target = XY::new(((step / 150) % 3) as f32 * 0.4, 0.5); let dt_ms = if step % 11 == 0 { 16.0 } else { 8.0 }; - let mut fresh = SpringMassDamperSimulation::new(config); - fresh.set_position(position); - fresh.set_velocity(velocity); - fresh.set_target_position(target); - let expected = fresh.run(dt_ms); + original_run(config, &mut position, &mut velocity, target, dt_ms); cached.set_config(config); cached.set_target_position(target); - let actual = cached.run(dt_ms); + cached.run(dt_ms); assert_eq!( - [actual.x, actual.y, cached.velocity.x, cached.velocity.y].map(f32::to_bits), - [expected.x, expected.y, fresh.velocity.x, fresh.velocity.y].map(f32::to_bits), + [ + cached.position.x, + cached.position.y, + cached.velocity.x, + cached.velocity.y + ] + .map(f32::to_bits), + [position.x, position.y, velocity.x, velocity.y].map(f32::to_bits), "step {step}" ); - position = fresh.position; - velocity = fresh.velocity; } } }