Skip to content

Commit e62999a

Browse files
authored
fix(background): scroll only the presets that are periodic under it (#414)
compute_scroll_offset translated every preset before dispatch. Three of the seven are not periodic under translation and already animate themselves from speed: - gradient_shift uses direction for the rotation sense, and paints its shader over the frame rect with no margin, so any translation left an uncovered band. - concentric_circles computes its own offset = (time * speed) % spacing. The outer translation was a second animation on top, and translating a radial pattern moves its centre. - halo animates its zones internally. PR #154 bounded the offset to one tile period, which turned an unbounded drift into a bounded periodic jump. It is now zero for those three: declaring a direction on one of them is pixel-inert, verified frame by frame against the same scenario with no direction at all. This is a visible rendering change for an existing scenario that declares one. The motion being removed is the motion that dragged the background off the frame. The four that keep it -- grid_dots, grid_lines, pixel_grid, heropattern -- are tiled patterns with no motion of their own, drawn with a whole period of margin on each side. pixel_grid was the exception: its cell loops started at index 0, so scrolling right uncovered a band on the left. They start at -1 now, which is what the other three already did. pixel_grid's own `motion` field does not translate anything, so it composes with the scroll rather than doubling it, and its default (`none`) leaves it exactly in grid_dots's position: a still texture the outer scroll is the only thing that can move.
1 parent 94b38d8 commit e62999a

2 files changed

Lines changed: 199 additions & 2 deletions

File tree

‎crates/rustmotion/skills/rules/continuous-presets.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,24 @@ The presets `pulse`, `float`, `shake`, and `spin` are continuous animations. Wit
1313
```
1414

1515
Continuous presets: `pulse`, `float`, `shake`, `spin`.
16+
17+
## `speed` + `direction` : seuls quatre fonds se laissent faire défiler
18+
19+
`direction` translate la texture du fond. Ça n'a de sens que pour un motif
20+
**périodique sous translation**, qui n'a par ailleurs aucun mouvement propre :
21+
22+
| Preset | `direction` | Pourquoi |
23+
|---|---|---|
24+
| `grid_dots`, `grid_lines`, `pixel_grid`, `heropattern` | **actif** | Motifs pavés, dessinés avec une période entière de marge de chaque côté. Le défilement extérieur est leur seul mouvement. |
25+
| `gradient_shift` | **inerte** | `speed` pilote déjà le sens de rotation du dégradé (`direction` vaut `cw`/`ccw` ici), et le shader est peint sur le rectangle du cadre **sans marge** : toute translation laissait une bande découverte. |
26+
| `concentric_circles` | **inerte** | Calcule déjà son propre `offset = (time * speed) % spacing`. Translater un motif radial déplace son centre — c'était une double animation. |
27+
| `halo` | **inerte** | Anime ses zones lui-même. |
28+
29+
Sur les trois derniers, déclarer une `direction` ne produit plus **rien du tout**
30+
(vérifié frame par frame, pixel pour pixel). C'est un changement de rendu visible
31+
pour un scénario existant qui en déclarait une — mais le mouvement supprimé est
32+
celui qui faisait sortir le fond du cadre, pas un effet qu'on perd.
33+
34+
> `pixel_grid` a son propre champ `motion` (`twinkle`, `sweep`), qui ne translate
35+
> rien : il compose avec le défilement au lieu de le doubler. Son défaut,
36+
> `motion: none`, en fait une texture immobile — exactement le cas de `grid_dots`.

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

Lines changed: 178 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -435,8 +435,8 @@ fn draw_bg_pixel_grid(
435435
let cols = (width / spacing).ceil() as i32 + 1;
436436
let rows = (height / spacing).ceil() as i32 + 1;
437437

438-
for row in 0..rows {
439-
for col in 0..cols {
438+
for row in -1..rows {
439+
for col in -1..cols {
440440
let x = col as f32 * spacing;
441441
let y = row as f32 * spacing;
442442

@@ -766,7 +766,22 @@ fn period_floor(period: f32, floor: f32) -> f32 {
766766
}
767767
}
768768

769+
fn scrolls_under_translation(preset: &BackgroundPreset) -> bool {
770+
match preset {
771+
BackgroundPreset::GridDots(_)
772+
| BackgroundPreset::GridLines(_)
773+
| BackgroundPreset::PixelGrid(_)
774+
| BackgroundPreset::Heropattern(_) => true,
775+
BackgroundPreset::GradientShift(_)
776+
| BackgroundPreset::ConcentricCircles(_)
777+
| BackgroundPreset::Halo(_) => false,
778+
}
779+
}
780+
769781
pub(super) fn compute_scroll_offset(bg: &AnimatedBackground, time: f32) -> (f32, f32) {
782+
if !scrolls_under_translation(&bg.preset) {
783+
return (0.0, 0.0);
784+
}
770785
let (raw_x, raw_y) = raw_scroll_offset(bg, time);
771786
let (spacing_x, spacing_y) = tile_spacing(&bg.preset);
772787
(raw_x % spacing_x, raw_y % spacing_y)
@@ -1058,6 +1073,167 @@ mod halo_zone_transition_interpolation_tests {
10581073
}
10591074
}
10601075

1076+
#[cfg(test)]
1077+
mod non_periodic_presets_do_not_scroll_tests {
1078+
use super::*;
1079+
use crate::encode::video::{build_frame_tasks, render_frame_task, FrameTask};
1080+
use crate::loader::load_scenario_from_source;
1081+
1082+
const W: usize = 240;
1083+
const H: usize = 160;
1084+
1085+
const GRADIENT_SHIFT: &str =
1086+
r##""preset": "gradient_shift", "gradient_shift": { "colors": ["#2B1E5C", "#1E3A8A"] }"##;
1087+
const CONCENTRIC: &str = r##""preset": "concentric_circles", "concentric_circles": { "color": "#3DA5FF", "spacing": 40 }"##;
1088+
const HALO: &str = r##""preset": "halo", "halo": { "zones": [
1089+
{ "color": "#7C6BFF", "x": 0.5, "y": 0.5, "radius": 0.9, "opacity": 1.0 }] }"##;
1090+
const GRID_DOTS: &str =
1091+
r##""preset": "grid_dots", "grid_dots": { "color": "#ffffff", "spacing": 40 }"##;
1092+
const GRID_LINES: &str =
1093+
r##""preset": "grid_lines", "grid_lines": { "color": "#ffffff", "cell": 40 }"##;
1094+
const PIXEL_GRID: &str = r##""preset": "pixel_grid", "pixel_grid": {
1095+
"colors": ["#ffffff"], "spacing": 24, "size": 10, "density": 1.0 }"##;
1096+
const PIXEL_GRID_OPAQUE: &str = r##""preset": "pixel_grid", "pixel_grid": {
1097+
"colors": ["#ffffff"], "spacing": 24, "size": 24, "density": 1.0 }"##;
1098+
const HEROPATTERN: &str =
1099+
r##""preset": "heropattern", "heropattern": { "pattern": "aztec", "color": "#ffffff" }"##;
1100+
1101+
fn scenario_with(preset: &str, scene_background: &str, direction: Option<&str>) -> String {
1102+
let direction = direction
1103+
.map(|d| format!(r#", "direction": "{d}""#))
1104+
.unwrap_or_default();
1105+
format!(
1106+
r##"{{
1107+
"version": "1.0",
1108+
"video": {{ "width": {W}, "height": {H}, "fps": 30, "background": "{scene_background}" }},
1109+
"scenes": [{{
1110+
"duration": 2.0,
1111+
"animated-background": {{ {preset}, "speed": 120{direction} }},
1112+
"children": []
1113+
}}]
1114+
}}"##
1115+
)
1116+
}
1117+
1118+
fn background_of(preset: &str) -> AnimatedBackground {
1119+
let json = scenario_with(preset, "#000000", Some("right"));
1120+
let scenario = load_scenario_from_source(None, Some(&json)).expect("load");
1121+
scenario.views[0].scenes[0]
1122+
.animated_background
1123+
.first()
1124+
.cloned()
1125+
.expect("background")
1126+
}
1127+
1128+
#[test]
1129+
fn a_preset_that_is_not_periodic_under_translation_is_never_translated() {
1130+
for (preset, label) in [
1131+
(
1132+
GRADIENT_SHIFT,
1133+
"gradient_shift draws over the frame rect with no margin",
1134+
),
1135+
(
1136+
CONCENTRIC,
1137+
"concentric_circles already offsets itself, and translating a radial \
1138+
pattern moves its centre",
1139+
),
1140+
(HALO, "halo animates its own zones"),
1141+
] {
1142+
let bg = background_of(preset);
1143+
for t in [0.0f32, 0.3, 1.1, 1.9] {
1144+
assert_eq!(
1145+
compute_scroll_offset(&bg, t),
1146+
(0.0, 0.0),
1147+
"t={t}: {label}, so the outer scroll must be switched off for it entirely"
1148+
);
1149+
}
1150+
}
1151+
}
1152+
1153+
#[test]
1154+
fn a_preset_with_no_motion_of_its_own_still_scrolls() {
1155+
for (preset, label) in [
1156+
(GRID_DOTS, "grid_dots"),
1157+
(GRID_LINES, "grid_lines"),
1158+
(PIXEL_GRID, "pixel_grid"),
1159+
(HEROPATTERN, "heropattern"),
1160+
] {
1161+
let bg = background_of(preset);
1162+
let (dx, _) = compute_scroll_offset(&bg, 0.25);
1163+
assert!(
1164+
dx.abs() > 1.0,
1165+
"{label} has no motion of its own and tiles with a full period of margin, so \
1166+
the outer scroll is the only thing that can move it (dx={dx})"
1167+
);
1168+
}
1169+
}
1170+
1171+
fn frames_of(preset: &str, direction: Option<&str>) -> Vec<Vec<u8>> {
1172+
let json = scenario_with(preset, "#FF00FF", direction);
1173+
let scenario = load_scenario_from_source(None, Some(&json)).expect("load");
1174+
let tasks = build_frame_tasks(&scenario);
1175+
tasks
1176+
.iter()
1177+
.filter(|t| matches!(t, FrameTask::Normal { .. }))
1178+
.step_by(5)
1179+
.map(|task| render_frame_task(&scenario.video, &scenario, task).expect("render"))
1180+
.collect()
1181+
}
1182+
1183+
#[test]
1184+
fn direction_is_inert_on_a_preset_that_cannot_be_translated() {
1185+
for (preset, label) in [
1186+
(GRADIENT_SHIFT, "gradient_shift"),
1187+
(CONCENTRIC, "concentric_circles"),
1188+
(HALO, "halo"),
1189+
] {
1190+
assert_eq!(
1191+
frames_of(preset, Some("right")),
1192+
frames_of(preset, None),
1193+
"{label} renders its own motion from speed; declaring a direction must now be \
1194+
pixel-inert rather than dragging it off the frame"
1195+
);
1196+
}
1197+
}
1198+
1199+
#[test]
1200+
fn direction_still_moves_a_preset_that_has_no_motion_of_its_own() {
1201+
for (preset, label) in [
1202+
(GRID_DOTS, "grid_dots"),
1203+
(GRID_LINES, "grid_lines"),
1204+
(PIXEL_GRID, "pixel_grid"),
1205+
(HEROPATTERN, "heropattern"),
1206+
] {
1207+
assert_ne!(
1208+
frames_of(preset, Some("right")),
1209+
frames_of(preset, None),
1210+
"{label} depends on the outer scroll for all of its motion — switching it off \
1211+
with the others would leave it frozen"
1212+
);
1213+
}
1214+
}
1215+
1216+
#[test]
1217+
fn a_scrolled_tile_never_uncovers_the_band_it_is_dragged_away_from() {
1218+
let leaked: usize = frames_of(PIXEL_GRID_OPAQUE, Some("right"))
1219+
.iter()
1220+
.map(|frame| {
1221+
(0..H)
1222+
.flat_map(|y| (0..60).map(move |x| (y * W + x) * 4))
1223+
.filter(|&i| frame[i] > 240 && frame[i + 1] < 15 && frame[i + 2] > 240)
1224+
.count()
1225+
})
1226+
.sum();
1227+
assert_eq!(
1228+
leaked, 0,
1229+
"pixel_grid scrolls right, so it must also be drawn one period beyond the left \
1230+
edge — {leaked} magenta pixels of the scene's own background came through. \
1231+
heropattern and concentric_circles are not checked this way: both leave \
1232+
transparent gaps by design, so the scene colour showing through is not a defect"
1233+
);
1234+
}
1235+
}
1236+
10611237
#[cfg(test)]
10621238
mod scroll_offset_wrap_tests {
10631239

0 commit comments

Comments
 (0)