From 93313f68c30cf1f94fc52661ad748e9281b7cd1b Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 12 Aug 2026 21:23:44 +0200 Subject: [PATCH] feat(background): add the `edges` density ramp to pixel_grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pixel_grid shipped with `radial` — dense at the centre, thinning outwards. Measuring the reference texture showed the opposite is what a background actually needs: across the tenths of a band clear of its window, density runs 10.9 · 6.5 · 0.8 · 0.2 · 0.2 · 0.2 · 0.2 · 0.9 · 7.8 · 8.8 % heavy at both edges, effectively empty through the middle 60 %. That is a vignette, and it is what keeps the texture off whatever sits in the centre — here a terminal window. None of the five existing ramps can express it: the one-way ramps are monotonic and `radial` is its exact inverse. `Edges` is that inverse, asserted as such in the test rather than described: `radial(p) + edges(p) == 1` at every sampled point. --- .claude/skills/rustmotion/SKILL.md | 4 ++-- crates/rustmotion-core/src/schema/background.rs | 8 ++++++++ .../rustmotion/src/engine/render/background.rs | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.claude/skills/rustmotion/SKILL.md b/.claude/skills/rustmotion/SKILL.md index 35c40dd..bec9ef6 100644 --- a/.claude/skills/rustmotion/SKILL.md +++ b/.claude/skills/rustmotion/SKILL.md @@ -2357,7 +2357,7 @@ cells scattered by a hash of their coordinates. **Checkerboard**: two colours at "size": 9, // cell edge, px "spacing": 22, // lattice pitch, px — clamped to at least `size` "density": 0.75, // 0..1 fraction of cells drawn - "density_ramp": "right", // none | left | right | top | bottom | radial + "density_ramp": "edges", // none | left | right | top | bottom | radial | edges "radius": 1, // cell corner radius; 0 for hard pixels "seed": 7, // stable scatter; same seed → same pattern "motion": "none" // none | twinkle | sweep @@ -2370,7 +2370,7 @@ cells scattered by a hash of their coordinates. **Checkerboard**: two colours at | `size` | `10.0` | Cell edge in px. | | `spacing` | `24.0` | Pitch, **clamped to `size`**: a smaller value would draw a solid sheet and lose the lattice. | | `density` | `0.6` | Fraction of cells drawn. `1.0` fills every cell — required for a real checkerboard. | -| `density_ramp` | `"none"` | Where the field is densest. A ramp is what stops a scatter reading as noise. | +| `density_ramp` | `"none"` | Where the field is densest. A ramp is what stops a scatter reading as noise. `edges` is a vignette — heavy at the border, clear through the middle, so the texture stays off whatever sits in the centre; `radial` is its exact inverse. | | `radius` | `0.0` | `0` keeps the pixels hard-edged; anti-aliasing turns on above `0`. | | `seed` | `7` | Occupancy is a hash of `(col, row, seed)`, so the pattern holds still across frames and is identical between two renders. | | `motion` | `"none"` | `twinkle` fades cells on their own phase; `sweep` runs a band of extra density across the field. Scaled by the background's `speed`. | diff --git a/crates/rustmotion-core/src/schema/background.rs b/crates/rustmotion-core/src/schema/background.rs index 5f4c6eb..e9904d9 100644 --- a/crates/rustmotion-core/src/schema/background.rs +++ b/crates/rustmotion-core/src/schema/background.rs @@ -123,6 +123,14 @@ pub enum PixelDensityRamp { Bottom, /// Dense at the centre, thinning outwards. Radial, + /// Dense at the frame's edges, thinning toward the centre — a vignette. + /// + /// Measured on the reference piece, in a band clear of its window, the + /// density runs 10.9 · 6.5 · 0.8 · 0.2 · 0.2 · 0.2 · 0.2 · 0.9 · 7.8 · 8.8 % + /// across the tenths of the frame: heavy at both edges, effectively empty + /// through the middle 60 %. That is what keeps the texture off whatever + /// sits in the centre, and `Radial` is its exact inverse. + Edges, } /// How a `pixel_grid` animates. diff --git a/crates/rustmotion/src/engine/render/background.rs b/crates/rustmotion/src/engine/render/background.rs index 1b52488..c42ee84 100644 --- a/crates/rustmotion/src/engine/render/background.rs +++ b/crates/rustmotion/src/engine/render/background.rs @@ -431,6 +431,11 @@ fn ramp_at(ramp: PixelDensityRamp, x: f32, y: f32, width: f32, height: f32) -> f let (dx, dy) = (fx - 0.5, fy - 0.5); (1.0 - (dx * dx + dy * dy).sqrt() * 2.0).clamp(0.0, 1.0) } + // The inverse: a vignette that leaves the middle clear. + PixelDensityRamp::Edges => { + let (dx, dy) = (fx - 0.5, fy - 0.5); + ((dx * dx + dy * dy).sqrt() * 2.0).clamp(0.0, 1.0) + } } } @@ -1173,6 +1178,17 @@ mod pixel_grid_tests { // Radial is full at the centre and gone at the corners. assert!(ramp_at(PixelDensityRamp::Radial, 50.0, 50.0, w, h) > 0.99); assert_eq!(ramp_at(PixelDensityRamp::Radial, 0.0, 0.0, w, h), 0.0); + // `Edges` is the vignette: clear in the middle, heavy at the border. + assert_eq!(ramp_at(PixelDensityRamp::Edges, 50.0, 50.0, w, h), 0.0); + assert!(ramp_at(PixelDensityRamp::Edges, 0.0, 50.0, w, h) > 0.99); + assert!(ramp_at(PixelDensityRamp::Edges, 100.0, 50.0, w, h) > 0.99); + // …and the exact inverse of `Radial`, which is the point of having both. + for (x, y) in [(0.0, 0.0), (25.0, 60.0), (100.0, 50.0)] { + let r = ramp_at(PixelDensityRamp::Radial, x, y, w, h); + let e = ramp_at(PixelDensityRamp::Edges, x, y, w, h); + assert!((r + e - 1.0).abs() < 1e-6, "at ({x},{y}): {r} + {e} != 1"); + } + // `None` is uniform: the same everywhere, including the edges. for x in [0.0, 50.0, 100.0] { assert_eq!(ramp_at(PixelDensityRamp::None, x, 0.0, w, h), 1.0);