Skip to content

Commit 2e99161

Browse files
authored
fix(world): size world backgrounds from the camera's extent, not viewport * 5 (#199)
A halo config behaved differently in a slide view and a world view: the same radius: 0.55 that reads as a half-screen glow became a wash flooding every frame. HaloZone documents its fields as fractions of width/height; in a world view they were fractions of a hardcoded 5x viewport. Two problems in one line. The unit changed silently with the view type, and 5.0 was unrelated to the world's actual extent — a world spanning two viewports got the same canvas as one spanning ten, so calibrating a halo was trial and error with no rule to reason from. WorldTimeline::world_extent derives the surface from the camera's own waypoints: the union of one viewport-sized rect per waypoint, which is exactly the span the camera ever shows. The origin travels with it, so negative world-positions are inside the world rather than off its edge. HaloZone's doc comments now name the surface for both view types. Visual change: any world-view halo calibrated against the old 5x canvas covers a different share of the frame now — proportionally larger for a world smaller than five screens, which is most of them.
1 parent eeed5b0 commit 2e99161

4 files changed

Lines changed: 117 additions & 10 deletions

File tree

crates/rustmotion-core/src/schema/background.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -436,13 +436,16 @@ pub struct HaloZone {
436436
/// Zone color (hex string). May itself carry an alpha channel
437437
/// (`#rrggbbaa`); see [`HaloZone::opacity`] for how the two combine.
438438
pub color: String,
439-
/// X position as fraction of width (0.0 = left, 1.0 = right).
439+
/// X position as a fraction of the surface the halo is painted on:
440+
/// the viewport in a `slide` view, the world the camera travels in a
441+
/// `world` view (`WorldTimeline::world_extent`). 0.0 = left, 1.0 = right.
440442
#[serde(default = "default_half")]
441443
pub x: f32,
442-
/// Y position as fraction of height (0.0 = top, 1.0 = bottom).
444+
/// Y position as a fraction of that same surface. 0.0 = top, 1.0 = bottom.
443445
#[serde(default = "default_half")]
444446
pub y: f32,
445-
/// Radius as fraction of max(width, height).
447+
/// Radius as a fraction of that surface's `max(width, height)` — so the
448+
/// same value covers proportionally the same area whichever view it is in.
446449
#[serde(default = "default_halo_radius")]
447450
pub radius: f32,
448451
/// Zone opacity, multiplied with any alpha already encoded in `color`.

crates/rustmotion/src/engine/render/background.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,20 @@ pub(super) fn draw_world_bg_with_parallax(
6262
height: f32,
6363
cam_x: f32,
6464
cam_y: f32,
65+
world: (f32, f32, f32, f32),
6566
) {
6667
match &bg.preset {
6768
BackgroundPreset::Halo(cfg) => {
68-
let world_w = width * 5.0;
69-
let world_h = height * 5.0;
69+
// `HaloZone`'s x/y/radius are fractions of the surface it is painted
70+
// on. In a slide view that is the viewport; here it is the world the
71+
// camera travels, so it has to be the *actual* extent
72+
// (`WorldTimeline::world_extent`). It used to be `viewport * 5.0`,
73+
// a constant unrelated to the scenes' own positions: the same
74+
// `radius: 0.55` that reads as a half-screen glow in a slide became
75+
// a five-screen wash, and calibrating one was trial and error.
76+
let (world_x, world_y, world_w, world_h) = world;
7077
canvas.save();
71-
canvas.translate((-cam_x, -cam_y));
78+
canvas.translate((world_x - cam_x, world_y - cam_y));
7279
draw_bg_halo(canvas, cfg, bg.speed, time, world_w, world_h);
7380
canvas.restore();
7481
}

crates/rustmotion/src/engine/render/scene.rs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,9 @@ pub fn render_world_frame_scaled(
841841

842842
// Pre-compute camera position for background parallax
843843
let (cam_x, cam_y) = timeline.camera_at(time, &view.camera_easing);
844+
// The surface world-spanning backgrounds are painted on. Derived from the
845+
// camera's own waypoints, not a fixed multiple of the viewport.
846+
let world = timeline.world_extent(vw, vh);
844847
let viewport_cx = vw / 2.0;
845848
let viewport_cy = vh / 2.0;
846849

@@ -917,7 +920,16 @@ pub fn render_world_frame_scaled(
917920
// crossfade of a layer with itself is that layer, so just
918921
// paint it once instead of doing the work twice.
919922
for bg in bgs_a {
920-
draw_world_bg_with_parallax(canvas, bg, time as f32, vw, vh, cam_x, cam_y);
923+
draw_world_bg_with_parallax(
924+
canvas,
925+
bg,
926+
time as f32,
927+
vw,
928+
vh,
929+
cam_x,
930+
cam_y,
931+
world,
932+
);
921933
}
922934
} else {
923935
// Distinct per-scene backgrounds: render each side into its
@@ -942,6 +954,7 @@ pub fn render_world_frame_scaled(
942954
vh,
943955
cam_x,
944956
cam_y,
957+
world,
945958
scaled_w,
946959
scaled_h,
947960
scale_factor,
@@ -953,6 +966,7 @@ pub fn render_world_frame_scaled(
953966
vh,
954967
cam_x,
955968
cam_y,
969+
world,
956970
scaled_w,
957971
scaled_h,
958972
scale_factor,
@@ -1006,13 +1020,13 @@ pub fn render_world_frame_scaled(
10061020
time as f32
10071021
};
10081022
for bg in active_bgs {
1009-
draw_world_bg_with_parallax(canvas, bg, bg_time, vw, vh, cam_x, cam_y);
1023+
draw_world_bg_with_parallax(canvas, bg, bg_time, vw, vh, cam_x, cam_y, world);
10101024
}
10111025
}
10121026
} else {
10131027
// No active scene — draw view-level backgrounds
10141028
for bg in &view.background.animated {
1015-
draw_world_bg_with_parallax(canvas, bg, time as f32, vw, vh, cam_x, cam_y);
1029+
draw_world_bg_with_parallax(canvas, bg, time as f32, vw, vh, cam_x, cam_y, world);
10161030
}
10171031
}
10181032

@@ -1143,6 +1157,7 @@ fn render_world_bg_layer_pixels(
11431157
vh: f32,
11441158
cam_x: f32,
11451159
cam_y: f32,
1160+
world: (f32, f32, f32, f32),
11461161
scaled_w: i32,
11471162
scaled_h: i32,
11481163
scale_factor: f32,
@@ -1160,7 +1175,7 @@ fn render_world_bg_layer_pixels(
11601175
}
11611176
canvas.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0));
11621177
for bg in bgs {
1163-
draw_world_bg_with_parallax(canvas, bg, time, vw, vh, cam_x, cam_y);
1178+
draw_world_bg_with_parallax(canvas, bg, time, vw, vh, cam_x, cam_y, world);
11641179
}
11651180
let row_bytes = scaled_w as usize * 4;
11661181
let mut pixels = vec![0u8; row_bytes * scaled_h as usize];

crates/rustmotion/src/engine/world.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,37 @@ impl WorldTimeline {
126126
}
127127

128128
/// Total number of frames for this world view.
129+
/// The rectangle of world space the camera ever shows, in world
130+
/// coordinates: `(x, y, width, height)`.
131+
///
132+
/// Each waypoint puts that world point at the viewport's top-left, so the
133+
/// span is the union of one viewport-sized rect per waypoint. Backgrounds
134+
/// painted across the world need this rather than a fixed multiple of the
135+
/// viewport: a world spanning two screens and one spanning ten are not the
136+
/// same canvas, and a `halo` zone expressed as a fraction of the wrong one
137+
/// lands nowhere near where its author aimed it.
138+
///
139+
/// Falls back to the viewport itself when there are no waypoints.
140+
pub fn world_extent(&self, viewport_w: f32, viewport_h: f32) -> (f32, f32, f32, f32) {
141+
let Some(first) = self.camera_waypoints.first() else {
142+
return (0.0, 0.0, viewport_w, viewport_h);
143+
};
144+
let (mut min_x, mut min_y) = (first.x, first.y);
145+
let (mut max_x, mut max_y) = (first.x, first.y);
146+
for wp in &self.camera_waypoints {
147+
min_x = min_x.min(wp.x);
148+
min_y = min_y.min(wp.y);
149+
max_x = max_x.max(wp.x);
150+
max_y = max_y.max(wp.y);
151+
}
152+
(
153+
min_x,
154+
min_y,
155+
(max_x - min_x) + viewport_w,
156+
(max_y - min_y) + viewport_h,
157+
)
158+
}
159+
129160
pub fn total_frames(&self, fps: u32) -> u32 {
130161
(self.total_duration * fps as f64).round() as u32
131162
}
@@ -606,3 +637,54 @@ mod world_timeline_tests {
606637
}
607638
}
608639
}
640+
641+
#[cfg(test)]
642+
mod world_extent_tests {
643+
use super::*;
644+
645+
fn timeline_with(waypoints: &[(f32, f32)]) -> WorldTimeline {
646+
WorldTimeline {
647+
scene_windows: Vec::new(),
648+
camera_waypoints: waypoints
649+
.iter()
650+
.map(|&(x, y)| CameraWaypoint { time: 0.0, x, y })
651+
.collect(),
652+
total_duration: 0.0,
653+
camera_pan_duration: 0.0,
654+
boundary_pan_duration: Vec::new(),
655+
}
656+
}
657+
658+
/// The extent is the union of one viewport per waypoint — the span the
659+
/// camera actually shows — not a fixed multiple of the viewport.
660+
#[test]
661+
fn extent_spans_the_waypoints_plus_one_viewport() {
662+
let t = timeline_with(&[(0.0, 0.0), (2016.0, 0.0), (2016.0, 1080.0)]);
663+
assert_eq!(t.world_extent(1920.0, 1080.0), (0.0, 0.0, 3936.0, 2160.0));
664+
}
665+
666+
/// A single-waypoint world is exactly one screen, where `viewport * 5.0`
667+
/// used to claim five — and divided every halo radius by five with it.
668+
#[test]
669+
fn a_single_waypoint_world_is_one_viewport() {
670+
let t = timeline_with(&[(0.0, 0.0)]);
671+
assert_eq!(t.world_extent(1920.0, 1080.0), (0.0, 0.0, 1920.0, 1080.0));
672+
}
673+
674+
/// Negative waypoints are inside the world, not outside it: the origin
675+
/// moves rather than the span being measured from zero.
676+
#[test]
677+
fn negative_waypoints_move_the_origin() {
678+
let t = timeline_with(&[(-1920.0, -540.0), (0.0, 0.0)]);
679+
assert_eq!(
680+
t.world_extent(1920.0, 1080.0),
681+
(-1920.0, -540.0, 3840.0, 1620.0)
682+
);
683+
}
684+
685+
#[test]
686+
fn no_waypoints_falls_back_to_the_viewport() {
687+
let t = timeline_with(&[]);
688+
assert_eq!(t.world_extent(1920.0, 1080.0), (0.0, 0.0, 1920.0, 1080.0));
689+
}
690+
}

0 commit comments

Comments
 (0)