From f7c5e8c32a799f15cb4e751950e0bcb6a65642bc Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Tue, 29 Sep 2026 09:56:05 +0200 Subject: [PATCH] fix(background): scroll only the presets that are periodic under it 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. --- .../skills/rules/continuous-presets.md | 21 ++ .../src/engine/render/background.rs | 180 +++++++++++++++++- 2 files changed, 199 insertions(+), 2 deletions(-) diff --git a/crates/rustmotion/skills/rules/continuous-presets.md b/crates/rustmotion/skills/rules/continuous-presets.md index d1c10350..116aa1c4 100644 --- a/crates/rustmotion/skills/rules/continuous-presets.md +++ b/crates/rustmotion/skills/rules/continuous-presets.md @@ -13,3 +13,24 @@ The presets `pulse`, `float`, `shake`, and `spin` are continuous animations. Wit ``` Continuous presets: `pulse`, `float`, `shake`, `spin`. + +## `speed` + `direction` : seuls quatre fonds se laissent faire défiler + +`direction` translate la texture du fond. Ça n'a de sens que pour un motif +**périodique sous translation**, qui n'a par ailleurs aucun mouvement propre : + +| Preset | `direction` | Pourquoi | +|---|---|---| +| `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. | +| `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. | +| `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. | +| `halo` | **inerte** | Anime ses zones lui-même. | + +Sur les trois derniers, déclarer une `direction` ne produit plus **rien du tout** +(vérifié frame par frame, pixel pour pixel). C'est un changement de rendu visible +pour un scénario existant qui en déclarait une — mais le mouvement supprimé est +celui qui faisait sortir le fond du cadre, pas un effet qu'on perd. + +> `pixel_grid` a son propre champ `motion` (`twinkle`, `sweep`), qui ne translate +> rien : il compose avec le défilement au lieu de le doubler. Son défaut, +> `motion: none`, en fait une texture immobile — exactement le cas de `grid_dots`. diff --git a/crates/rustmotion/src/engine/render/background.rs b/crates/rustmotion/src/engine/render/background.rs index bddcba8e..d35079b1 100644 --- a/crates/rustmotion/src/engine/render/background.rs +++ b/crates/rustmotion/src/engine/render/background.rs @@ -435,8 +435,8 @@ fn draw_bg_pixel_grid( let cols = (width / spacing).ceil() as i32 + 1; let rows = (height / spacing).ceil() as i32 + 1; - for row in 0..rows { - for col in 0..cols { + for row in -1..rows { + for col in -1..cols { let x = col as f32 * spacing; let y = row as f32 * spacing; @@ -766,7 +766,22 @@ fn period_floor(period: f32, floor: f32) -> f32 { } } +fn scrolls_under_translation(preset: &BackgroundPreset) -> bool { + match preset { + BackgroundPreset::GridDots(_) + | BackgroundPreset::GridLines(_) + | BackgroundPreset::PixelGrid(_) + | BackgroundPreset::Heropattern(_) => true, + BackgroundPreset::GradientShift(_) + | BackgroundPreset::ConcentricCircles(_) + | BackgroundPreset::Halo(_) => false, + } +} + pub(super) fn compute_scroll_offset(bg: &AnimatedBackground, time: f32) -> (f32, f32) { + if !scrolls_under_translation(&bg.preset) { + return (0.0, 0.0); + } let (raw_x, raw_y) = raw_scroll_offset(bg, time); let (spacing_x, spacing_y) = tile_spacing(&bg.preset); (raw_x % spacing_x, raw_y % spacing_y) @@ -1058,6 +1073,167 @@ mod halo_zone_transition_interpolation_tests { } } +#[cfg(test)] +mod non_periodic_presets_do_not_scroll_tests { + use super::*; + use crate::encode::video::{build_frame_tasks, render_frame_task, FrameTask}; + use crate::loader::load_scenario_from_source; + + const W: usize = 240; + const H: usize = 160; + + const GRADIENT_SHIFT: &str = + r##""preset": "gradient_shift", "gradient_shift": { "colors": ["#2B1E5C", "#1E3A8A"] }"##; + const CONCENTRIC: &str = r##""preset": "concentric_circles", "concentric_circles": { "color": "#3DA5FF", "spacing": 40 }"##; + const HALO: &str = r##""preset": "halo", "halo": { "zones": [ + { "color": "#7C6BFF", "x": 0.5, "y": 0.5, "radius": 0.9, "opacity": 1.0 }] }"##; + const GRID_DOTS: &str = + r##""preset": "grid_dots", "grid_dots": { "color": "#ffffff", "spacing": 40 }"##; + const GRID_LINES: &str = + r##""preset": "grid_lines", "grid_lines": { "color": "#ffffff", "cell": 40 }"##; + const PIXEL_GRID: &str = r##""preset": "pixel_grid", "pixel_grid": { + "colors": ["#ffffff"], "spacing": 24, "size": 10, "density": 1.0 }"##; + const PIXEL_GRID_OPAQUE: &str = r##""preset": "pixel_grid", "pixel_grid": { + "colors": ["#ffffff"], "spacing": 24, "size": 24, "density": 1.0 }"##; + const HEROPATTERN: &str = + r##""preset": "heropattern", "heropattern": { "pattern": "aztec", "color": "#ffffff" }"##; + + fn scenario_with(preset: &str, scene_background: &str, direction: Option<&str>) -> String { + let direction = direction + .map(|d| format!(r#", "direction": "{d}""#)) + .unwrap_or_default(); + format!( + r##"{{ + "version": "1.0", + "video": {{ "width": {W}, "height": {H}, "fps": 30, "background": "{scene_background}" }}, + "scenes": [{{ + "duration": 2.0, + "animated-background": {{ {preset}, "speed": 120{direction} }}, + "children": [] + }}] + }}"## + ) + } + + fn background_of(preset: &str) -> AnimatedBackground { + let json = scenario_with(preset, "#000000", Some("right")); + let scenario = load_scenario_from_source(None, Some(&json)).expect("load"); + scenario.views[0].scenes[0] + .animated_background + .first() + .cloned() + .expect("background") + } + + #[test] + fn a_preset_that_is_not_periodic_under_translation_is_never_translated() { + for (preset, label) in [ + ( + GRADIENT_SHIFT, + "gradient_shift draws over the frame rect with no margin", + ), + ( + CONCENTRIC, + "concentric_circles already offsets itself, and translating a radial \ + pattern moves its centre", + ), + (HALO, "halo animates its own zones"), + ] { + let bg = background_of(preset); + for t in [0.0f32, 0.3, 1.1, 1.9] { + assert_eq!( + compute_scroll_offset(&bg, t), + (0.0, 0.0), + "t={t}: {label}, so the outer scroll must be switched off for it entirely" + ); + } + } + } + + #[test] + fn a_preset_with_no_motion_of_its_own_still_scrolls() { + for (preset, label) in [ + (GRID_DOTS, "grid_dots"), + (GRID_LINES, "grid_lines"), + (PIXEL_GRID, "pixel_grid"), + (HEROPATTERN, "heropattern"), + ] { + let bg = background_of(preset); + let (dx, _) = compute_scroll_offset(&bg, 0.25); + assert!( + dx.abs() > 1.0, + "{label} has no motion of its own and tiles with a full period of margin, so \ + the outer scroll is the only thing that can move it (dx={dx})" + ); + } + } + + fn frames_of(preset: &str, direction: Option<&str>) -> Vec> { + let json = scenario_with(preset, "#FF00FF", direction); + let scenario = load_scenario_from_source(None, Some(&json)).expect("load"); + let tasks = build_frame_tasks(&scenario); + tasks + .iter() + .filter(|t| matches!(t, FrameTask::Normal { .. })) + .step_by(5) + .map(|task| render_frame_task(&scenario.video, &scenario, task).expect("render")) + .collect() + } + + #[test] + fn direction_is_inert_on_a_preset_that_cannot_be_translated() { + for (preset, label) in [ + (GRADIENT_SHIFT, "gradient_shift"), + (CONCENTRIC, "concentric_circles"), + (HALO, "halo"), + ] { + assert_eq!( + frames_of(preset, Some("right")), + frames_of(preset, None), + "{label} renders its own motion from speed; declaring a direction must now be \ + pixel-inert rather than dragging it off the frame" + ); + } + } + + #[test] + fn direction_still_moves_a_preset_that_has_no_motion_of_its_own() { + for (preset, label) in [ + (GRID_DOTS, "grid_dots"), + (GRID_LINES, "grid_lines"), + (PIXEL_GRID, "pixel_grid"), + (HEROPATTERN, "heropattern"), + ] { + assert_ne!( + frames_of(preset, Some("right")), + frames_of(preset, None), + "{label} depends on the outer scroll for all of its motion — switching it off \ + with the others would leave it frozen" + ); + } + } + + #[test] + fn a_scrolled_tile_never_uncovers_the_band_it_is_dragged_away_from() { + let leaked: usize = frames_of(PIXEL_GRID_OPAQUE, Some("right")) + .iter() + .map(|frame| { + (0..H) + .flat_map(|y| (0..60).map(move |x| (y * W + x) * 4)) + .filter(|&i| frame[i] > 240 && frame[i + 1] < 15 && frame[i + 2] > 240) + .count() + }) + .sum(); + assert_eq!( + leaked, 0, + "pixel_grid scrolls right, so it must also be drawn one period beyond the left \ + edge — {leaked} magenta pixels of the scene's own background came through. \ + heropattern and concentric_circles are not checked this way: both leave \ + transparent gaps by design, so the scene colour showing through is not a defect" + ); + } +} + #[cfg(test)] mod scroll_offset_wrap_tests {