Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions crates/rustmotion-core/src/schema/background.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
13 changes: 10 additions & 3 deletions crates/rustmotion/src/engine/render/background.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
23 changes: 19 additions & 4 deletions crates/rustmotion/src/engine/render/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand All @@ -942,6 +954,7 @@ pub fn render_world_frame_scaled(
vh,
cam_x,
cam_y,
world,
scaled_w,
scaled_h,
scale_factor,
Expand All @@ -953,6 +966,7 @@ pub fn render_world_frame_scaled(
vh,
cam_x,
cam_y,
world,
scaled_w,
scaled_h,
scale_factor,
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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,
Expand All @@ -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];
Expand Down
82 changes: 82 additions & 0 deletions crates/rustmotion/src/engine/world.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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));
}
}
Loading