From 86bb78c9c8d250d82d37f9539738d6cc936a00f3 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 12 Aug 2026 21:41:19 +0200 Subject: [PATCH] fix(background): make pixel_grid's twinkle reach both ends of the range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cell alpha oscillated over 0.10..1.00, so a cell never actually went out. A lattice whose cells only dip to a tenth still reads as a fixed field of permanent dots, just dimmer — which is not what an animated texture is for. 0.5 + 0.5 * sin gives the full 0 → 1 → 0. The phase still comes from each cell's own hash, or the whole field blinks in unison. The test pins the range rather than the fact that something moves: it samples 400 instants and asserts the floor goes under 0.01 and the ceiling over 0.99. "It animates" and "it reaches zero" are different claims. --- .../src/engine/render/background.rs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/rustmotion/src/engine/render/background.rs b/crates/rustmotion/src/engine/render/background.rs index c42ee84..c5d0ae2 100644 --- a/crates/rustmotion/src/engine/render/background.rs +++ b/crates/rustmotion/src/engine/render/background.rs @@ -498,9 +498,12 @@ fn draw_bg_pixel_grid( let paint = &mut paints[idx]; if cfg.motion == PixelGridMotion::Twinkle { - // Each cell on its own phase, or they blink in unison. + // Full 0 → 1 → 0, not a partial dip: a cell that only fades to + // 10 % still reads as a permanent dot, so the lattice looks + // fixed and merely dimmer. Each cell gets its own phase from + // its own hash, or the whole field blinks in unison. let phase = cell_hash(col, row, cfg.seed, 1) * std::f32::consts::TAU; - let a = 0.55 + 0.45 * (t * 1.6 + phase).sin(); + let a = 0.5 + 0.5 * (t * 1.6 + phase).sin(); paint.set_alpha_f(paint.alpha_f() * a); } @@ -1205,6 +1208,19 @@ mod pixel_grid_tests { assert_eq!(tile_spacing(&BackgroundPreset::PixelGrid(c)), 40.0); } + /// `twinkle` has to reach both ends. A cell that only dips to 10 % still + /// reads as a permanent dot: the field looks fixed, just dimmer. + #[test] + fn twinkle_spans_the_whole_opacity_range() { + let phase = 0.0f32; + let alpha = |t: f32| 0.5 + 0.5 * (t * 1.6 + phase).sin(); + let samples: Vec = (0..400).map(|i| alpha(i as f32 * 0.01)).collect(); + let lo = samples.iter().cloned().fold(f32::MAX, f32::min); + let hi = samples.iter().cloned().fold(f32::MIN, f32::max); + assert!(lo < 0.01, "must fade all the way out, floor was {lo}"); + assert!(hi > 0.99, "must come all the way back, ceiling was {hi}"); + } + /// Degenerate configs must be inert, not panic: an empty palette has /// nothing to draw with, and a zero size no area to draw. #[test]