展示HN:Cellarium:细胞自动机的游乐场
Show HN: Cellarium: A Playground for Cellular Automata

原始链接: https://github.com/andrewosh/cellarium

## Cellarium:Rust 中的 GPU 加速细胞自动机 Cellarium 是一个 Rust 库,用于使用 WebGPU (wgpu) 在 GPU 上完全创建和运行细胞自动机模拟。它允许开发者使用 Rust 子集定义细胞行为,然后通过过程宏编译成 WGSL 着色器。这能够对所有细胞同时进行高度并行、GPU 加速的更新。 主要特性包括使用 `#[derive(CellState)]` 定义细胞状态,使用 `#[cell]` 实现细胞逻辑(包括初始化、更新规则和可视化),以及指定邻域类型(摩尔、冯·诺依曼、基于半径)。可在细胞实现中将运行时可调参数定义为常量。 邻居交互通过 `sum`、`mean`、`count` 和 `laplacian` 等方法简化。空间访问器提供邻居的距离、偏移和方向信息。内置终端 UI 允许实时调整参数,并且可以从 JSON 保存/加载模拟以实现可重现性。 示例包括经典模拟,如生命游戏和 Gray-Scott 反应扩散,展示了该库的多功能性。Cellarium 提供了一种强大而高效的方式,通过 GPU 加速的细胞自动机探索复杂系统。

## Cellarium:细胞自动机游乐场 开发者 andrewosh 分享了“Cellarium”,一个用于探索细胞自动机的全新项目。这个“氛围编码”的实验允许用户使用编译到 WGSL 的 Rust 子集编写和运行这些模拟。 Cellarium 的关键特性是一个动态的、基于文本的用户界面 (TUI),能够在模拟*期间*实时调整参数。这便于轻松发现涌现行为,而无需重启。用户还可以将参数更改的完整历史记录保存到 JSON 文件中,以便重放和重现有趣的结果。 导航直观,具有平移/缩放功能和箭头键用于参数控制。开发者感谢 Claude 提供的技术协助,并俏皮地建议它是一个引人入胜的电子游戏替代品。该项目可在 GitHub 上找到:[github.com/andrewosh](github.com/andrewosh)。
相关文章

原文

Note: This is a 100% vibe-coded experiment

GPU-accelerated cellular automata in Rust. Write cell behavior in a subset of Rust; a proc macro cross-compiles it to WGSL shaders that run entirely on the GPU via wgpu.

use cellarium::prelude::*;

#[derive(CellState, Default)]
struct Life {
    alive: f32,
}

#[cell(neighborhood = moore)]
impl Cell for Life {
    fn init(x: f32, y: f32, w: f32, h: f32) -> Self {
        let hash = ((x * 12.9898 + y * 78.233).sin() * 43758.5453).fract();
        Self { alive: if hash > 0.6 { 1.0 } else { 0.0 } }
    }

    fn update(self, nb: Neighbors) -> Self {
        let n = nb.count(|c| c.alive > 0.5);
        let alive = if self.alive > 0.5 && (n == 2.0 || n == 3.0) {
            1.0
        } else if self.alive < 0.5 && n == 3.0 {
            1.0
        } else {
            0.0
        };
        Self { alive }
    }

    fn view(self) -> Color {
        if self.alive > 0.5 { Color::WHITE } else { Color::BLACK }
    }
}

fn main() {
    Simulation::<Life>::new(2048, 2048)
        .title("Game of Life")
        .run();
}
cargo run --example game_of_life
  1. #[derive(CellState)] packs your struct fields into GPU textures (rgba32float, 4 channels each). Fields are never split across textures.
  2. #[cell] compiles your update, view, and optional init methods into WGSL fragment shaders at compile time.
  3. Simulation::run() opens a window and runs the simulation loop on the GPU — all cells update simultaneously each tick via double-buffered render passes.

All cells read from the same tick-N state and write to tick-N+1. No cell ever sees another cell's in-progress update.

Field types: f32 (1 channel), Vec2 (2), Vec3 (3), Vec4 (4). Up to 8 textures (32 floats total).

#[derive(CellState, Default)]
struct MyCell {
    concentration: f32,   // tex0.r
    velocity: Vec2,       // tex0.gb
    phase: f32,           // tex0.a
    color: Vec3,          // tex1.rgb (didn't fit in tex0)
}

The #[cell] attribute takes a neighborhood:

  • moore — 8 adjacent cells
  • von_neumann — 4 cardinal cells
  • radius(N) — all cells within Chebyshev distance N

update(self, nb: Neighbors) -> Self — the transition rule, called every tick. Access own state via self.field, neighbors via nb.* methods.

view(self) -> Color — maps state to an RGBA display color.

init(x: f32, y: f32, w: f32, h: f32) -> Self — programmatic initialization. Runs once on the GPU. x/y are grid coordinates, w/h are grid dimensions. If omitted, cells initialize from Default.

Constants declared in the impl block become runtime-tunable parameters:

#[cell(neighborhood = moore)]
impl Cell for MyCell {
    const SPEED: f32 = 0.3;
    const DAMPING: f32 = 0.9999;
    // ...
}

These are adjustable live via the TUI or from saved JSON files (see Runtime Controls).

All neighbor operations take closures where c accesses a neighbor's fields:

nb.sum(|c| c.field)          // Sum
nb.mean(|c| c.field)         // Average
nb.min(|c| c.field)          // Minimum (f32 only)
nb.max(|c| c.field)          // Maximum (f32 only)
nb.count(|c| c.field > 0.5)  // Count where true (returns f32)
nb.sum_where(|c| c.value, |c| c.distance() < 5.0)
nb.mean_where(|c| c.value, |c| c.distance() <= INNER_R)

mean_where divides by the count of neighbors passing the filter, not the total neighborhood size.

nb.laplacian(|c| c.height)       // Discrete Laplacian (isotropic 9-point stencil)
nb.gradient(|c| c.pressure)      // Central differences -> Vec2
nb.divergence(|c| c.velocity)    // Divergence of a Vec2 field -> f32

Spatial Accessors (inside closures)

c.distance()    // Euclidean distance to this neighbor (f32)
c.offset()      // Grid offset (dx, dy) as Vec2
c.direction()   // Normalized direction as Vec2

The macro accepts a limited subset of Rust that maps cleanly to WGSL:

  • Arithmetic: +, -, *, /, unary -
  • Comparison: ==, !=, <, >, <=, >=
  • Logic: &&, ||, !
  • Control flow: if/else (both branches required, same type). No match, loop, for.
  • Let bindings: let x = expr;
  • f32 methods: sin, cos, tan, sqrt, abs, floor, ceil, round, exp, ln, log2, fract, signum, powf, clamp, min, max
  • Vector methods: length, normalize, dot, distance, cross (Vec3)
  • Free functions: mix, step, smoothstep, atan2, vec2, vec3, vec4
  • Color constructors: Color::rgb(r, g, b), Color::rgba(r, g, b, a), Color::hsv(h, s, v), Color::WHITE, Color::BLACK
  • Built-in values: tick, cell_x, cell_y, grid_width, grid_height, PI, TAU
Simulation::<MyCell>::new(1024, 1024)
    .title("My Simulation")
    .ticks_per_frame(8)       // simulation steps per rendered frame
    .paused(true)             // start paused
    .window_size(1920, 1080)  // explicit window size (default: maximized)
    .run();
Key Action
Space Pause / resume
R Reset to initial state
+ / - Adjust ticks per frame
Esc Quit
Scroll / pinch Zoom
Click + drag Pan

When a simulation has constants, a terminal UI launches automatically for live parameter tuning. The same keys work from both the simulation window and the TUI:

Key Action
Up / Down Select parameter
Left / Right Adjust selected parameter (x1.05)
Shift + Left / Right Coarse adjust (x1.2)
D Reset selected parameter to default
S Save parameters to JSON

Save with S, load by passing the JSON file as a CLI argument:

cargo run --example gray_scott -- my_params.json

Saved files include a full replay of every parameter change with tick numbers, so loading one reproduces the exact parameter trajectory from the start of the simulation.

Full Example: Gray-Scott Reaction Diffusion

use cellarium::prelude::*;

#[derive(CellState)]
struct GrayScott {
    a: f32,
    b: f32,
}

impl Default for GrayScott {
    fn default() -> Self {
        Self { a: 1.0, b: 0.0 }
    }
}

#[cell(neighborhood = moore)]
impl Cell for GrayScott {
    const DA: f32 = 0.21;
    const DB: f32 = 0.105;
    const FEED: f32 = 0.026;
    const KILL: f32 = 0.052;

    fn init(x: f32, y: f32, w: f32, h: f32) -> Self {
        let h1 = ((x * 12.9898 + y * 78.233).sin() * 43758.5453).fract();
        let h2 = ((x * 63.7264 + y * 10.873).sin() * 43758.5453).fract();

        // Tile space into 60px patches, seed ~25% with a blob of chemical B
        let px = (x / 60.0).floor();
        let py = (y / 60.0).floor();
        let phash = ((px * 43.17 + py * 91.53).sin() * 43758.5453).fract();

        let lx = x - (px + 0.5) * 60.0;
        let ly = y - (py + 0.5) * 60.0;
        let dist = (lx * lx + ly * ly).sqrt();

        let seeded = if phash < 0.25 && dist < 10.0 { 1.0 } else { 0.0 };
        let noise = h2 * 0.01;

        Self {
            a: 1.0 - seeded * 0.5,
            b: seeded * (0.25 + h1 * 0.1) + noise,
        }
    }

    fn update(self, nb: Neighbors) -> Self {
        let lap_a = nb.laplacian(|c| c.a);
        let lap_b = nb.laplacian(|c| c.b);
        let reaction = self.a * self.b * self.b;
        Self {
            a: (self.a + DA * lap_a - reaction + FEED * (1.0 - self.a)).clamp(0.0, 1.0),
            b: (self.b + DB * lap_b + reaction - (KILL + FEED) * self.b).clamp(0.0, 1.0),
        }
    }

    fn view(self) -> Color {
        let b = self.b;
        let t = (b * 3.5).clamp(0.0, 1.0);
        Color::hsv(0.58 - t * 0.25, 0.5 + t * 0.4, 0.04 + t * 0.96)
    }
}

fn main() {
    Simulation::<GrayScott>::new(1024, 1024)
        .title("Gray-Scott Reaction Diffusion")
        .ticks_per_frame(32)
        .run();
}

This runs a two-chemical reaction-diffusion system in the turbulent regime — the patterns never converge, producing endlessly shifting spots and waves. The four constants (DA, DB, FEED, KILL) are tunable at runtime via the TUI.

cargo run --example game_of_life
cargo run --example gray_scott
cargo run --example wave
cargo run --example lenia
cargo run --example smoothlife
cargo run --example brians_brain
cargo run --example wireworld
cargo run --example cyclic
cargo run --example predator_prey
cargo run --example rock_paper_scissors
cargo run --example erosion
cargo run --example sandpile
cargo run --example cascade

Apache-2.0

联系我们 contact @ memedata.com