diff --git a/crates/rustmotion-core/src/schema/background.rs b/crates/rustmotion-core/src/schema/background.rs index bfba1cb9..1dfc3bf6 100644 --- a/crates/rustmotion-core/src/schema/background.rs +++ b/crates/rustmotion-core/src/schema/background.rs @@ -436,13 +436,16 @@ pub struct HaloZone { /// Zone color (hex string). May itself carry an alpha channel /// (`#rrggbbaa`); see [`HaloZone::opacity`] for how the two combine. pub color: String, - /// X position as fraction of width (0.0 = left, 1.0 = right). + /// X position as a fraction of the surface the halo is painted on: + /// the viewport in a `slide` view, the world the camera travels in a + /// `world` view (`WorldTimeline::world_extent`). 0.0 = left, 1.0 = right. #[serde(default = "default_half")] pub x: f32, - /// Y position as fraction of height (0.0 = top, 1.0 = bottom). + /// Y position as a fraction of that same surface. 0.0 = top, 1.0 = bottom. #[serde(default = "default_half")] pub y: f32, - /// Radius as fraction of max(width, height). + /// Radius as a fraction of that surface's `max(width, height)` — so the + /// same value covers proportionally the same area whichever view it is in. #[serde(default = "default_halo_radius")] pub radius: f32, /// Zone opacity, multiplied with any alpha already encoded in `color`. diff --git a/crates/rustmotion/src/engine/render/background.rs b/crates/rustmotion/src/engine/render/background.rs index 64928580..bb287f96 100644 --- a/crates/rustmotion/src/engine/render/background.rs +++ b/crates/rustmotion/src/engine/render/background.rs @@ -62,13 +62,20 @@ pub(super) fn draw_world_bg_with_parallax( height: f32, cam_x: f32, cam_y: f32, + world: (f32, f32, f32, f32), ) { match &bg.preset { BackgroundPreset::Halo(cfg) => { - let world_w = width * 5.0; - let world_h = height * 5.0; + // `HaloZone`'s x/y/radius are fractions of the surface it is painted + // on. In a slide view that is the viewport; here it is the world the + // camera travels, so it has to be the *actual* extent + // (`WorldTimeline::world_extent`). It used to be `viewport * 5.0`, + // a constant unrelated to the scenes' own positions: the same + // `radius: 0.55` that reads as a half-screen glow in a slide became + // a five-screen wash, and calibrating one was trial and error. + let (world_x, world_y, world_w, world_h) = world; canvas.save(); - canvas.translate((-cam_x, -cam_y)); + canvas.translate((world_x - cam_x, world_y - cam_y)); draw_bg_halo(canvas, cfg, bg.speed, time, world_w, world_h); canvas.restore(); } diff --git a/crates/rustmotion/src/engine/render/scene.rs b/crates/rustmotion/src/engine/render/scene.rs index 98d3fc03..08c91ee2 100644 --- a/crates/rustmotion/src/engine/render/scene.rs +++ b/crates/rustmotion/src/engine/render/scene.rs @@ -841,6 +841,9 @@ pub fn render_world_frame_scaled( // Pre-compute camera position for background parallax let (cam_x, cam_y) = timeline.camera_at(time, &view.camera_easing); + // The surface world-spanning backgrounds are painted on. Derived from the + // camera's own waypoints, not a fixed multiple of the viewport. + let world = timeline.world_extent(vw, vh); let viewport_cx = vw / 2.0; let viewport_cy = vh / 2.0; @@ -917,7 +920,16 @@ pub fn render_world_frame_scaled( // crossfade of a layer with itself is that layer, so just // paint it once instead of doing the work twice. for bg in bgs_a { - draw_world_bg_with_parallax(canvas, bg, time as f32, vw, vh, cam_x, cam_y); + draw_world_bg_with_parallax( + canvas, + bg, + time as f32, + vw, + vh, + cam_x, + cam_y, + world, + ); } } else { // Distinct per-scene backgrounds: render each side into its @@ -942,6 +954,7 @@ pub fn render_world_frame_scaled( vh, cam_x, cam_y, + world, scaled_w, scaled_h, scale_factor, @@ -953,6 +966,7 @@ pub fn render_world_frame_scaled( vh, cam_x, cam_y, + world, scaled_w, scaled_h, scale_factor, @@ -1006,13 +1020,13 @@ pub fn render_world_frame_scaled( time as f32 }; for bg in active_bgs { - draw_world_bg_with_parallax(canvas, bg, bg_time, vw, vh, cam_x, cam_y); + draw_world_bg_with_parallax(canvas, bg, bg_time, vw, vh, cam_x, cam_y, world); } } } else { // No active scene — draw view-level backgrounds for bg in &view.background.animated { - draw_world_bg_with_parallax(canvas, bg, time as f32, vw, vh, cam_x, cam_y); + draw_world_bg_with_parallax(canvas, bg, time as f32, vw, vh, cam_x, cam_y, world); } } @@ -1143,6 +1157,7 @@ fn render_world_bg_layer_pixels( vh: f32, cam_x: f32, cam_y: f32, + world: (f32, f32, f32, f32), scaled_w: i32, scaled_h: i32, scale_factor: f32, @@ -1160,7 +1175,7 @@ fn render_world_bg_layer_pixels( } canvas.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0)); for bg in bgs { - draw_world_bg_with_parallax(canvas, bg, time, vw, vh, cam_x, cam_y); + draw_world_bg_with_parallax(canvas, bg, time, vw, vh, cam_x, cam_y, world); } let row_bytes = scaled_w as usize * 4; let mut pixels = vec![0u8; row_bytes * scaled_h as usize]; diff --git a/crates/rustmotion/src/engine/world.rs b/crates/rustmotion/src/engine/world.rs index 698b9150..2b39784d 100644 --- a/crates/rustmotion/src/engine/world.rs +++ b/crates/rustmotion/src/engine/world.rs @@ -126,6 +126,37 @@ impl WorldTimeline { } /// Total number of frames for this world view. + /// The rectangle of world space the camera ever shows, in world + /// coordinates: `(x, y, width, height)`. + /// + /// Each waypoint puts that world point at the viewport's top-left, so the + /// span is the union of one viewport-sized rect per waypoint. Backgrounds + /// painted across the world need this rather than a fixed multiple of the + /// viewport: a world spanning two screens and one spanning ten are not the + /// same canvas, and a `halo` zone expressed as a fraction of the wrong one + /// lands nowhere near where its author aimed it. + /// + /// Falls back to the viewport itself when there are no waypoints. + pub fn world_extent(&self, viewport_w: f32, viewport_h: f32) -> (f32, f32, f32, f32) { + let Some(first) = self.camera_waypoints.first() else { + return (0.0, 0.0, viewport_w, viewport_h); + }; + let (mut min_x, mut min_y) = (first.x, first.y); + let (mut max_x, mut max_y) = (first.x, first.y); + for wp in &self.camera_waypoints { + min_x = min_x.min(wp.x); + min_y = min_y.min(wp.y); + max_x = max_x.max(wp.x); + max_y = max_y.max(wp.y); + } + ( + min_x, + min_y, + (max_x - min_x) + viewport_w, + (max_y - min_y) + viewport_h, + ) + } + pub fn total_frames(&self, fps: u32) -> u32 { (self.total_duration * fps as f64).round() as u32 } @@ -606,3 +637,54 @@ mod world_timeline_tests { } } } + +#[cfg(test)] +mod world_extent_tests { + use super::*; + + fn timeline_with(waypoints: &[(f32, f32)]) -> WorldTimeline { + WorldTimeline { + scene_windows: Vec::new(), + camera_waypoints: waypoints + .iter() + .map(|&(x, y)| CameraWaypoint { time: 0.0, x, y }) + .collect(), + total_duration: 0.0, + camera_pan_duration: 0.0, + boundary_pan_duration: Vec::new(), + } + } + + /// The extent is the union of one viewport per waypoint — the span the + /// camera actually shows — not a fixed multiple of the viewport. + #[test] + fn extent_spans_the_waypoints_plus_one_viewport() { + let t = timeline_with(&[(0.0, 0.0), (2016.0, 0.0), (2016.0, 1080.0)]); + assert_eq!(t.world_extent(1920.0, 1080.0), (0.0, 0.0, 3936.0, 2160.0)); + } + + /// A single-waypoint world is exactly one screen, where `viewport * 5.0` + /// used to claim five — and divided every halo radius by five with it. + #[test] + fn a_single_waypoint_world_is_one_viewport() { + let t = timeline_with(&[(0.0, 0.0)]); + assert_eq!(t.world_extent(1920.0, 1080.0), (0.0, 0.0, 1920.0, 1080.0)); + } + + /// Negative waypoints are inside the world, not outside it: the origin + /// moves rather than the span being measured from zero. + #[test] + fn negative_waypoints_move_the_origin() { + let t = timeline_with(&[(-1920.0, -540.0), (0.0, 0.0)]); + assert_eq!( + t.world_extent(1920.0, 1080.0), + (-1920.0, -540.0, 3840.0, 1620.0) + ); + } + + #[test] + fn no_waypoints_falls_back_to_the_viewport() { + let t = timeline_with(&[]); + assert_eq!(t.world_extent(1920.0, 1080.0), (0.0, 0.0, 1920.0, 1080.0)); + } +}