Skip to content

Commit ffe7ec0

Browse files
authored
feat(background): add pixel_grid, a configurable lattice of square cells (#204)
grid_dots draws circles on a fixed lattice with a built-in pulse — there was no way to get a field of square tiles, to control how many of them appear, or to make the density vary across the frame. Reproducing a reference piece's texture with it meant settling for regular dots where the reference has a scatter that thickens toward one edge. pixel_grid covers two looks with one shape: one colour under density 1 gives a sparse tile field; two colours at density 1.0 alternate by (col + row) and give a real checkerboard. Occupancy is a hash of (col, row, seed), not a random draw — the same cell must resolve the same way on every frame or the whole field boils, and two renders of one scenario have to match. `spacing` is clamped to `size`: a smaller pitch would draw a solid sheet and silently lose the lattice the preset exists for. `tile_spacing` reports the pitch so a world view's camera can wrap on it. Six tests: cell stability, seed actually scattering, neighbours uncorrelated (a hash that walks with the coordinate draws stripes, not a scatter), each ramp running the direction it names, the spacing clamp, and degenerate configs staying inert rather than panicking.
1 parent b5b3b4d commit ffe7ec0

3 files changed

Lines changed: 367 additions & 2 deletions

File tree

.claude/skills/rustmotion/SKILL.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2337,14 +2337,47 @@ With `transition`, background properties (colors, speed, spacing, element_size,
23372337
| `colors` | array | `[]` | Gradient colors (hex) |
23382338
| `speed` | f32 | `30.0` | Animation speed (degrees/sec or pixels/sec) |
23392339
| `gradient_type`| enum | `"linear"` | `"linear"` or `"radial"` |
2340-
| `preset` | string | `null` | `"gradient_shift"`, `"concentric_circles"`, `"grid_dots"`, `"halo"` |
2340+
| `preset` | string | `null` | `"gradient_shift"`, `"concentric_circles"`, `"grid_dots"`, `"halo"`, `"heropattern"`, `"pixel_grid"` |
23412341
| `element_size` | f32 | `4.0` | Dot/circle size for grid_dots; stroke width for concentric_circles |
23422342
| `spacing` | f32 | `60.0` | Element spacing for grid_dots/concentric_circles |
23432343
| `count` | u32 | `null` | Number of circles for concentric_circles (overrides spacing) |
23442344
| `zones` | array | `[]` | `halo` only — `[{ "color": "#hex", "x": 0.0-1.0, "y": 0.0-1.0, "radius": 0.0-1.0 }]`. `x`/`y` are fractions of width/height, `radius` a fraction of `max(width, height)`. |
23452345
| `$ref` | string | `null` | Reference to a named template in `backgrounds` |
23462346
| `transition` | object | `null` | `{ "duration": f64, "easing": "ease_in_out" }` — interpolates from prev scene |
23472347

2348+
### `pixel_grid` — a lattice of square cells
2349+
2350+
Two looks from one preset. **Sparse tile field**: one colour under `density: 1`,
2351+
cells scattered by a hash of their coordinates. **Checkerboard**: two colours at
2352+
`density: 1.0`, which alternate by `(col + row)`.
2353+
2354+
```json
2355+
{ "preset": "pixel_grid", "speed": 1.0, "pixel_grid": {
2356+
"colors": ["#FFFFFF26"], // one → field; two+ → alternating checkerboard
2357+
"size": 9, // cell edge, px
2358+
"spacing": 22, // lattice pitch, px — clamped to at least `size`
2359+
"density": 0.75, // 0..1 fraction of cells drawn
2360+
"density_ramp": "right", // none | left | right | top | bottom | radial
2361+
"radius": 1, // cell corner radius; 0 for hard pixels
2362+
"seed": 7, // stable scatter; same seed → same pattern
2363+
"motion": "none" // none | twinkle | sweep
2364+
} }
2365+
```
2366+
2367+
| Field | Default | Notes |
2368+
| --- | --- | --- |
2369+
| `colors` | `["#FFFFFF22"]` | Alternate by `(col + row)`. Alpha in the hex is how a texture stays a texture. |
2370+
| `size` | `10.0` | Cell edge in px. |
2371+
| `spacing` | `24.0` | Pitch, **clamped to `size`**: a smaller value would draw a solid sheet and lose the lattice. |
2372+
| `density` | `0.6` | Fraction of cells drawn. `1.0` fills every cell — required for a real checkerboard. |
2373+
| `density_ramp` | `"none"` | Where the field is densest. A ramp is what stops a scatter reading as noise. |
2374+
| `radius` | `0.0` | `0` keeps the pixels hard-edged; anti-aliasing turns on above `0`. |
2375+
| `seed` | `7` | Occupancy is a hash of `(col, row, seed)`, so the pattern holds still across frames and is identical between two renders. |
2376+
| `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`. |
2377+
2378+
> The lattice repeats on `spacing`, so it tiles seamlessly under a `world`
2379+
> view's camera pan.
2380+
23482381
The same `background` field also exists at the **view** level (`composition[].background`) — that's the recommended place for an ambient `halo` glow in a `world` view, since a per-scene shape glow either fails viewport validation or, once clipped to pass, becomes a visible hard-edged rectangle during a camera pan. See [rules/world-view.md](rules/world-view.md).
23492382

23502383
---

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

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,95 @@ pub struct HaloConfig {
6969
pub zones: Vec<HaloZone>,
7070
}
7171

72+
/// Config for the `pixel_grid` preset: a lattice of square cells.
73+
///
74+
/// Covers two looks with one shape. `density: 1.0` with two colours gives a
75+
/// true checkerboard (cells alternate by `(row + col)` parity); a density
76+
/// below 1 with one colour gives the sparse tile field the reference piece
77+
/// uses — squares on a ground, some cells simply absent.
78+
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
79+
pub struct PixelGridConfig {
80+
/// Cell colours. One colour fills every drawn cell; several alternate by
81+
/// `(row + col)`, which is what makes a checkerboard rather than a field.
82+
#[serde(default = "default_pixel_colors")]
83+
pub colors: Vec<String>,
84+
/// Edge of a cell in px.
85+
#[serde(default = "default_pixel_size")]
86+
pub size: f32,
87+
/// Lattice pitch in px — the distance between two cell origins. Clamped to
88+
/// at least `size`, so cells never overlap; `spacing - size` is the gap.
89+
#[serde(default = "default_pixel_spacing")]
90+
pub spacing: f32,
91+
/// Fraction of cells drawn, 0..1. Which cells is decided by a hash of the
92+
/// cell's coordinates, so the pattern is stable from frame to frame — a
93+
/// per-frame random would boil.
94+
#[serde(default = "default_pixel_density")]
95+
pub density: f32,
96+
/// Where the field is densest. The reference piece ramps its density
97+
/// across the frame rather than scattering uniformly, which is what stops
98+
/// the texture reading as noise.
99+
#[serde(default)]
100+
pub density_ramp: PixelDensityRamp,
101+
/// Corner radius of a cell in px. `0` for hard pixels.
102+
#[serde(default)]
103+
pub radius: f32,
104+
/// Stable pattern selector: two backgrounds with the same seed and
105+
/// geometry are identical, different seeds are different scatters.
106+
#[serde(default = "default_pixel_seed")]
107+
pub seed: u32,
108+
/// How the field moves. `speed` on the background scales it.
109+
#[serde(default)]
110+
pub motion: PixelGridMotion,
111+
}
112+
113+
/// Which way the fill density ramps across the frame.
114+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
115+
#[serde(rename_all = "snake_case")]
116+
pub enum PixelDensityRamp {
117+
/// Uniform: every cell has the same chance of being drawn.
118+
#[default]
119+
None,
120+
Left,
121+
Right,
122+
Top,
123+
Bottom,
124+
/// Dense at the centre, thinning outwards.
125+
Radial,
126+
}
127+
128+
/// How a `pixel_grid` animates.
129+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
130+
#[serde(rename_all = "snake_case")]
131+
pub enum PixelGridMotion {
132+
/// Still. The lattice is a texture, not an effect.
133+
#[default]
134+
None,
135+
/// Cells fade in and out on their own phase.
136+
Twinkle,
137+
/// A band of extra density travels across the field.
138+
Sweep,
139+
}
140+
141+
fn default_pixel_colors() -> Vec<String> {
142+
vec!["#FFFFFF22".to_string()]
143+
}
144+
145+
fn default_pixel_size() -> f32 {
146+
10.0
147+
}
148+
149+
fn default_pixel_spacing() -> f32 {
150+
24.0
151+
}
152+
153+
fn default_pixel_density() -> f32 {
154+
0.6
155+
}
156+
157+
fn default_pixel_seed() -> u32 {
158+
7
159+
}
160+
72161
/// Config for the `heropattern` preset.
73162
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
74163
pub struct HeropatternConfig {
@@ -101,6 +190,7 @@ pub enum BackgroundPreset {
101190
GridDots(GridDotsConfig),
102191
ConcentricCircles(ConcentricCirclesConfig),
103192
Halo(HaloConfig),
193+
PixelGrid(PixelGridConfig),
104194
Heropattern(HeropatternConfig),
105195
}
106196

@@ -111,6 +201,7 @@ impl BackgroundPreset {
111201
BackgroundPreset::GridDots(_) => "grid_dots",
112202
BackgroundPreset::ConcentricCircles(_) => "concentric_circles",
113203
BackgroundPreset::Halo(_) => "halo",
204+
BackgroundPreset::PixelGrid(_) => "pixel_grid",
114205
BackgroundPreset::Heropattern(_) => "heropattern",
115206
}
116207
}
@@ -143,6 +234,7 @@ impl Serialize for AnimatedBackground {
143234
map.serialize_entry("concentric_circles", cfg)?
144235
}
145236
BackgroundPreset::Halo(cfg) => map.serialize_entry("halo", cfg)?,
237+
BackgroundPreset::PixelGrid(cfg) => map.serialize_entry("pixel_grid", cfg)?,
146238
BackgroundPreset::Heropattern(cfg) => map.serialize_entry("heropattern", cfg)?,
147239
}
148240
map.serialize_entry("speed", &self.speed)?;
@@ -168,6 +260,7 @@ const KNOWN_BACKGROUND_PRESETS: &[&str] = &[
168260
"grid_dots",
169261
"concentric_circles",
170262
"halo",
263+
"pixel_grid",
171264
"heropattern",
172265
];
173266

@@ -367,6 +460,9 @@ fn deserialize_preset_config<E: serde::de::Error>(
367460
"halo" => Ok(BackgroundPreset::Halo(
368461
serde_json::from_value(sub).map_err(E::custom)?,
369462
)),
463+
"pixel_grid" => Ok(BackgroundPreset::PixelGrid(
464+
serde_json::from_value(sub).map_err(E::custom)?,
465+
)),
370466
"heropattern" => Ok(BackgroundPreset::Heropattern(
371467
serde_json::from_value(sub).map_err(E::custom)?,
372468
)),

0 commit comments

Comments
 (0)