Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion .claude/skills/rustmotion/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -2337,14 +2337,47 @@ With `transition`, background properties (colors, speed, spacing, element_size,
| `colors` | array | `[]` | Gradient colors (hex) |
| `speed` | f32 | `30.0` | Animation speed (degrees/sec or pixels/sec) |
| `gradient_type`| enum | `"linear"` | `"linear"` or `"radial"` |
| `preset` | string | `null` | `"gradient_shift"`, `"concentric_circles"`, `"grid_dots"`, `"halo"` |
| `preset` | string | `null` | `"gradient_shift"`, `"concentric_circles"`, `"grid_dots"`, `"halo"`, `"heropattern"`, `"pixel_grid"` |
| `element_size` | f32 | `4.0` | Dot/circle size for grid_dots; stroke width for concentric_circles |
| `spacing` | f32 | `60.0` | Element spacing for grid_dots/concentric_circles |
| `count` | u32 | `null` | Number of circles for concentric_circles (overrides spacing) |
| `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)`. |
| `$ref` | string | `null` | Reference to a named template in `backgrounds` |
| `transition` | object | `null` | `{ "duration": f64, "easing": "ease_in_out" }` — interpolates from prev scene |

### `pixel_grid` — a lattice of square cells

Two looks from one preset. **Sparse tile field**: one colour under `density: 1`,
cells scattered by a hash of their coordinates. **Checkerboard**: two colours at
`density: 1.0`, which alternate by `(col + row)`.

```json
{ "preset": "pixel_grid", "speed": 1.0, "pixel_grid": {
"colors": ["#FFFFFF26"], // one → field; two+ → alternating checkerboard
"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
"radius": 1, // cell corner radius; 0 for hard pixels
"seed": 7, // stable scatter; same seed → same pattern
"motion": "none" // none | twinkle | sweep
} }
```

| Field | Default | Notes |
| --- | --- | --- |
| `colors` | `["#FFFFFF22"]` | Alternate by `(col + row)`. Alpha in the hex is how a texture stays a texture. |
| `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. |
| `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`. |

> The lattice repeats on `spacing`, so it tiles seamlessly under a `world`
> view's camera pan.

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).

---
Expand Down
96 changes: 96 additions & 0 deletions crates/rustmotion-core/src/schema/background.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,95 @@ pub struct HaloConfig {
pub zones: Vec<HaloZone>,
}

/// Config for the `pixel_grid` preset: a lattice of square cells.
///
/// Covers two looks with one shape. `density: 1.0` with two colours gives a
/// true checkerboard (cells alternate by `(row + col)` parity); a density
/// below 1 with one colour gives the sparse tile field the reference piece
/// uses — squares on a ground, some cells simply absent.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PixelGridConfig {
/// Cell colours. One colour fills every drawn cell; several alternate by
/// `(row + col)`, which is what makes a checkerboard rather than a field.
#[serde(default = "default_pixel_colors")]
pub colors: Vec<String>,
/// Edge of a cell in px.
#[serde(default = "default_pixel_size")]
pub size: f32,
/// Lattice pitch in px — the distance between two cell origins. Clamped to
/// at least `size`, so cells never overlap; `spacing - size` is the gap.
#[serde(default = "default_pixel_spacing")]
pub spacing: f32,
/// Fraction of cells drawn, 0..1. Which cells is decided by a hash of the
/// cell's coordinates, so the pattern is stable from frame to frame — a
/// per-frame random would boil.
#[serde(default = "default_pixel_density")]
pub density: f32,
/// Where the field is densest. The reference piece ramps its density
/// across the frame rather than scattering uniformly, which is what stops
/// the texture reading as noise.
#[serde(default)]
pub density_ramp: PixelDensityRamp,
/// Corner radius of a cell in px. `0` for hard pixels.
#[serde(default)]
pub radius: f32,
/// Stable pattern selector: two backgrounds with the same seed and
/// geometry are identical, different seeds are different scatters.
#[serde(default = "default_pixel_seed")]
pub seed: u32,
/// How the field moves. `speed` on the background scales it.
#[serde(default)]
pub motion: PixelGridMotion,
}

/// Which way the fill density ramps across the frame.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PixelDensityRamp {
/// Uniform: every cell has the same chance of being drawn.
#[default]
None,
Left,
Right,
Top,
Bottom,
/// Dense at the centre, thinning outwards.
Radial,
}

/// How a `pixel_grid` animates.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PixelGridMotion {
/// Still. The lattice is a texture, not an effect.
#[default]
None,
/// Cells fade in and out on their own phase.
Twinkle,
/// A band of extra density travels across the field.
Sweep,
}

fn default_pixel_colors() -> Vec<String> {
vec!["#FFFFFF22".to_string()]
}

fn default_pixel_size() -> f32 {
10.0
}

fn default_pixel_spacing() -> f32 {
24.0
}

fn default_pixel_density() -> f32 {
0.6
}

fn default_pixel_seed() -> u32 {
7
}

/// Config for the `heropattern` preset.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct HeropatternConfig {
Expand Down Expand Up @@ -101,6 +190,7 @@ pub enum BackgroundPreset {
GridDots(GridDotsConfig),
ConcentricCircles(ConcentricCirclesConfig),
Halo(HaloConfig),
PixelGrid(PixelGridConfig),
Heropattern(HeropatternConfig),
}

Expand All @@ -111,6 +201,7 @@ impl BackgroundPreset {
BackgroundPreset::GridDots(_) => "grid_dots",
BackgroundPreset::ConcentricCircles(_) => "concentric_circles",
BackgroundPreset::Halo(_) => "halo",
BackgroundPreset::PixelGrid(_) => "pixel_grid",
BackgroundPreset::Heropattern(_) => "heropattern",
}
}
Expand Down Expand Up @@ -143,6 +234,7 @@ impl Serialize for AnimatedBackground {
map.serialize_entry("concentric_circles", cfg)?
}
BackgroundPreset::Halo(cfg) => map.serialize_entry("halo", cfg)?,
BackgroundPreset::PixelGrid(cfg) => map.serialize_entry("pixel_grid", cfg)?,
BackgroundPreset::Heropattern(cfg) => map.serialize_entry("heropattern", cfg)?,
}
map.serialize_entry("speed", &self.speed)?;
Expand All @@ -168,6 +260,7 @@ const KNOWN_BACKGROUND_PRESETS: &[&str] = &[
"grid_dots",
"concentric_circles",
"halo",
"pixel_grid",
"heropattern",
];

Expand Down Expand Up @@ -367,6 +460,9 @@ fn deserialize_preset_config<E: serde::de::Error>(
"halo" => Ok(BackgroundPreset::Halo(
serde_json::from_value(sub).map_err(E::custom)?,
)),
"pixel_grid" => Ok(BackgroundPreset::PixelGrid(
serde_json::from_value(sub).map_err(E::custom)?,
)),
"heropattern" => Ok(BackgroundPreset::Heropattern(
serde_json::from_value(sub).map_err(E::custom)?,
)),
Expand Down
Loading
Loading