Skip to content

Commit 80b2fd6

Browse files
feat: smooth auto zoom focus based on cursor velocity (#1090)
1 parent 166cad8 commit 80b2fd6

File tree

1 file changed

+192
-17
lines changed

1 file changed

+192
-17
lines changed

crates/rendering/src/lib.rs

Lines changed: 192 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,104 @@ impl ProjectUniforms {
522522
end - display_offset
523523
}
524524

525+
fn auto_zoom_focus(
526+
cursor_events: &CursorEvents,
527+
time_secs: f32,
528+
smoothing: Option<SpringMassDamperSimulationConfig>,
529+
current_cursor: Option<InterpolatedCursorPosition>,
530+
) -> Coord<RawDisplayUVSpace> {
531+
const PREVIOUS_SAMPLE_DELTA: f32 = 0.1;
532+
const MIN_LOOKAHEAD: f64 = 0.05;
533+
const MAX_LOOKAHEAD: f64 = 0.18;
534+
const MIN_FOLLOW_FACTOR: f64 = 0.2;
535+
const MAX_FOLLOW_FACTOR: f64 = 0.65;
536+
const SPEED_RESPONSE: f64 = 12.0;
537+
const VELOCITY_BLEND: f64 = 0.25;
538+
const MAX_SHIFT: f64 = 0.25;
539+
const MIN_SPEED: f64 = 0.002;
540+
541+
let fallback = Coord::<RawDisplayUVSpace>::new(XY::new(0.5, 0.5));
542+
543+
let current_cursor = match current_cursor
544+
.or_else(|| interpolate_cursor(cursor_events, time_secs, smoothing))
545+
{
546+
Some(cursor) => cursor,
547+
None => return fallback,
548+
};
549+
550+
let previous_time = (time_secs - PREVIOUS_SAMPLE_DELTA).max(0.0);
551+
let previous_cursor = if previous_time < time_secs {
552+
interpolate_cursor(cursor_events, previous_time, smoothing)
553+
} else {
554+
None
555+
};
556+
557+
let current_position = current_cursor.position.coord;
558+
let previous_position = previous_cursor
559+
.as_ref()
560+
.map(|c| c.position.coord)
561+
.unwrap_or(current_position);
562+
563+
let delta_time = (time_secs - previous_time).max(f32::EPSILON) as f64;
564+
565+
let simulation_velocity = XY::new(
566+
current_cursor.velocity.x as f64,
567+
current_cursor.velocity.y as f64,
568+
);
569+
570+
let finite_velocity = if previous_cursor.is_some() {
571+
(current_position - previous_position) / delta_time
572+
} else {
573+
XY::new(0.0, 0.0)
574+
};
575+
576+
let mut velocity = if smoothing.is_some() {
577+
simulation_velocity * (1.0 - VELOCITY_BLEND) + finite_velocity * VELOCITY_BLEND
578+
} else {
579+
finite_velocity
580+
};
581+
582+
if velocity.x.is_nan() || velocity.y.is_nan() {
583+
velocity = XY::new(0.0, 0.0);
584+
}
585+
586+
let speed = (velocity.x * velocity.x + velocity.y * velocity.y).sqrt();
587+
588+
if speed < MIN_SPEED {
589+
return Coord::new(XY::new(
590+
current_position.x.clamp(0.0, 1.0),
591+
current_position.y.clamp(0.0, 1.0),
592+
));
593+
}
594+
595+
let speed_factor = (1.0 - (-speed / SPEED_RESPONSE).exp()).clamp(0.0, 1.0);
596+
597+
let lookahead = MIN_LOOKAHEAD + (MAX_LOOKAHEAD - MIN_LOOKAHEAD) * speed_factor;
598+
let follow_strength =
599+
MIN_FOLLOW_FACTOR + (MAX_FOLLOW_FACTOR - MIN_FOLLOW_FACTOR) * speed_factor;
600+
601+
let predicted_shift = XY::new(
602+
(velocity.x * lookahead).clamp(-MAX_SHIFT, MAX_SHIFT),
603+
(velocity.y * lookahead).clamp(-MAX_SHIFT, MAX_SHIFT),
604+
);
605+
606+
let predicted_center = current_position + predicted_shift;
607+
let base_center = previous_cursor
608+
.map(|prev| {
609+
let retention = 0.45 + 0.25 * speed_factor;
610+
prev.position.coord * retention + current_position * (1.0 - retention)
611+
})
612+
.unwrap_or(current_position);
613+
614+
let final_center =
615+
base_center * (1.0 - follow_strength) + predicted_center * follow_strength;
616+
617+
Coord::new(XY::new(
618+
final_center.x.clamp(0.0, 1.0),
619+
final_center.y.clamp(0.0, 1.0),
620+
))
621+
}
622+
525623
pub fn new(
526624
constants: &RenderVideoConstants,
527625
project: &ProjectConfiguration,
@@ -541,14 +639,23 @@ impl ProjectUniforms {
541639

542640
let crop = Self::get_crop(options, project);
543641

642+
let cursor_smoothing = (!project.cursor.raw).then_some(SpringMassDamperSimulationConfig {
643+
tension: project.cursor.tension,
644+
mass: project.cursor.mass,
645+
friction: project.cursor.friction,
646+
});
647+
544648
let interpolated_cursor = interpolate_cursor(
545649
cursor_events,
546650
segment_frames.recording_time,
547-
(!project.cursor.raw).then_some(SpringMassDamperSimulationConfig {
548-
tension: project.cursor.tension,
549-
mass: project.cursor.mass,
550-
friction: project.cursor.friction,
551-
}),
651+
cursor_smoothing,
652+
);
653+
654+
let zoom_focus = Self::auto_zoom_focus(
655+
cursor_events,
656+
segment_frames.recording_time,
657+
cursor_smoothing,
658+
interpolated_cursor.clone(),
552659
);
553660

554661
let zoom = InterpolatedZoom::new(
@@ -560,18 +667,7 @@ impl ProjectUniforms {
560667
.map(|t| t.zoom_segments.as_slice())
561668
.unwrap_or(&[]),
562669
),
563-
interpolate_cursor(
564-
cursor_events,
565-
(segment_frames.recording_time - 0.2).max(0.0),
566-
(!project.cursor.raw).then_some(SpringMassDamperSimulationConfig {
567-
tension: project.cursor.tension,
568-
mass: project.cursor.mass,
569-
friction: project.cursor.friction,
570-
}),
571-
)
572-
.as_ref()
573-
.map(|i| i.position)
574-
.unwrap_or_else(|| Coord::new(XY::new(0.5, 0.5))),
670+
zoom_focus,
575671
);
576672

577673
let scene = InterpolatedScene::new(SceneSegmentsCursor::new(
@@ -1080,6 +1176,85 @@ impl RendererLayers {
10801176
}
10811177
}
10821178

1179+
#[cfg(test)]
1180+
mod project_uniforms_tests {
1181+
use super::*;
1182+
use cap_project::CursorMoveEvent;
1183+
1184+
fn cursor_move(time_ms: f64, x: f64, y: f64) -> CursorMoveEvent {
1185+
CursorMoveEvent {
1186+
active_modifiers: vec![],
1187+
cursor_id: "primary".to_string(),
1188+
time_ms,
1189+
x,
1190+
y,
1191+
}
1192+
}
1193+
1194+
fn default_smoothing() -> SpringMassDamperSimulationConfig {
1195+
SpringMassDamperSimulationConfig {
1196+
tension: 100.0,
1197+
mass: 1.0,
1198+
friction: 20.0,
1199+
}
1200+
}
1201+
1202+
#[test]
1203+
fn auto_zoom_focus_defaults_without_cursor_data() {
1204+
let events = CursorEvents {
1205+
clicks: vec![],
1206+
moves: vec![],
1207+
};
1208+
1209+
let focus = ProjectUniforms::auto_zoom_focus(&events, 0.3, None, None);
1210+
1211+
assert_eq!(focus.coord.x, 0.5);
1212+
assert_eq!(focus.coord.y, 0.5);
1213+
}
1214+
1215+
#[test]
1216+
fn auto_zoom_focus_is_stable_for_slow_motion() {
1217+
let events = CursorEvents {
1218+
clicks: vec![],
1219+
moves: vec![
1220+
cursor_move(0.0, 0.5, 0.5),
1221+
cursor_move(200.0, 0.55, 0.5),
1222+
cursor_move(400.0, 0.6, 0.5),
1223+
],
1224+
};
1225+
1226+
let smoothing = Some(default_smoothing());
1227+
1228+
let current = interpolate_cursor(&events, 0.4, smoothing).expect("cursor position");
1229+
let focus =
1230+
ProjectUniforms::auto_zoom_focus(&events, 0.4, smoothing, Some(current.clone()));
1231+
1232+
let dx = (focus.coord.x - current.position.coord.x).abs();
1233+
let dy = (focus.coord.y - current.position.coord.y).abs();
1234+
1235+
assert!(dx < 0.05, "expected minimal horizontal drift, got {dx}");
1236+
assert!(dy < 0.05, "expected minimal vertical drift, got {dy}");
1237+
}
1238+
1239+
#[test]
1240+
fn auto_zoom_focus_leans_into_velocity_for_fast_motion() {
1241+
let events = CursorEvents {
1242+
clicks: vec![],
1243+
moves: vec![cursor_move(0.0, 0.1, 0.5), cursor_move(40.0, 0.9, 0.5)],
1244+
};
1245+
1246+
let smoothing = Some(default_smoothing());
1247+
let query_time = 0.045; // slightly after the fast movement
1248+
1249+
let current = interpolate_cursor(&events, query_time, smoothing).expect("cursor position");
1250+
let focus =
1251+
ProjectUniforms::auto_zoom_focus(&events, query_time, smoothing, Some(current.clone()));
1252+
let delta = focus.coord.x - current.position.coord.x;
1253+
assert!(delta < 0.2, "focus moved too far ahead: {delta}");
1254+
assert!(delta > -0.25, "focus lagged too far behind: {delta}");
1255+
}
1256+
}
1257+
10831258
pub struct RenderSession {
10841259
textures: (wgpu::Texture, wgpu::Texture),
10851260
texture_views: (wgpu::TextureView, wgpu::TextureView),

0 commit comments

Comments
 (0)