feat: improve data layout and calculation

This commit is contained in:
Jet Pham 2026-03-05 00:01:55 -08:00
parent 2ee196f43d
commit 7b726be760
No known key found for this signature in database

View file

@ -10,387 +10,364 @@ use js_sys::Math;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData, Window}; use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData, Window};
const CELL_SIZE: u32 = 20; const CELL_SIZE: u32 = 20;
const SIMULATION_FPS: f64 = 60.0; const TICK_MS: f64 = 1000.0 / 60.0;
const SIMULATION_FRAME_MS: f64 = 1000.0 / SIMULATION_FPS; const HUE_PERIOD_MS: f64 = 3000.0;
const HUE_ROTATION_PERIOD_MS: f64 = 3000.0;
fn random_hue() -> u8 { /// Cells: 0 = dead, 1-255 = alive with that hue value.
(Math::random() * 256.0) as u8 const DEAD: u8 = 0;
#[inline(always)]
fn safe_hue(h: u8) -> u8 {
if h == DEAD { 1 } else { h }
} }
fn mix_colors(hues: &[u8]) -> u8 { // ── Lookup tables (computed once) ──────────────────────────────────
if hues.is_empty() {
return 0; struct Luts {
/// RGBA for each hue. Index 0 = black (dead cell).
rgb: [[u8; 4]; 256],
sin: [f32; 256],
cos: [f32; 256],
}
impl Luts {
fn new() -> Self {
let mut t = Self {
rgb: [[0, 0, 0, 255]; 256],
sin: [0.0; 256],
cos: [0.0; 256],
};
for i in 1..256u16 {
let h = i as f32 / 255.0 * 6.0;
let x = 1.0 - (h % 2.0 - 1.0).abs();
let (r, g, b) = match h as u32 {
0 => (1.0f32, x, 0.0),
1 => (x, 1.0, 0.0),
2 => (0.0, 1.0, x),
3 => (0.0, x, 1.0),
4 => (x, 0.0, 1.0),
_ => (1.0, 0.0, x),
};
t.rgb[i as usize] = [(r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255];
}
for i in 0..256 {
let rad = i as f32 * std::f32::consts::TAU / 256.0;
t.sin[i] = rad.sin();
t.cos[i] = rad.cos();
}
t
} }
let (sum_sin, sum_cos) = hues #[inline]
.iter() fn mix(&self, hues: &[u8]) -> u8 {
.map(|&hue| (hue as f64 * 360.0 / 255.0).to_radians()) let mut s = 0.0f32;
.fold((0.0, 0.0), |acc, h_rad| { let mut c = 0.0f32;
(acc.0 + h_rad.sin(), acc.1 + h_rad.cos()) for &h in hues {
}); s += self.sin[h as usize];
let avg_hue_degrees = sum_sin.atan2(sum_cos).to_degrees().rem_euclid(360.0); c += self.cos[h as usize];
(avg_hue_degrees * 255.0 / 360.0) as u8 }
safe_hue((s.atan2(c).rem_euclid(std::f32::consts::TAU) * 256.0 / std::f32::consts::TAU) as u8)
}
} }
/// Convert HSL hue (0-255) to RGB // ── Grid ───────────────────────────────────────────────────────────
/// Using full saturation (100%) and lightness (50%)
fn hue_to_rgb(hue: u8) -> (u8, u8, u8) {
let h = hue as f64 / 255.0 * 6.0;
let x = 1.0 - (h % 2.0 - 1.0).abs();
let (r, g, b) = match h as u32 { struct Grid {
0 => (1.0, x, 0.0), w: u32,
1 => (x, 1.0, 0.0), h: u32,
2 => (0.0, 1.0, x), cells: Vec<u8>,
3 => (0.0, x, 1.0), buf: Vec<u8>,
4 => (x, 0.0, 1.0), hues: Vec<u8>,
_ => (1.0, 0.0, x),
};
((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8)
} }
#[derive(Clone, Copy, PartialEq)] impl Grid {
enum Cell { fn new(w: u32, h: u32) -> Self {
Dead, let n = (w * h) as usize;
Alive { hue: u8 }, let mut g = Self {
} w,
h,
struct Universe { cells: vec![DEAD; n],
width: u32, buf: vec![DEAD; n],
height: u32, hues: Vec::with_capacity(8),
cells: Vec<Cell>,
next_cells: Vec<Cell>,
neighbor_hues_buffer: Vec<u8>,
}
impl Universe {
fn new(width: u32, height: u32) -> Self {
let size = (width * height) as usize;
let mut u = Self {
width,
height,
cells: vec![Cell::Dead; size],
next_cells: vec![Cell::Dead; size],
neighbor_hues_buffer: Vec::with_capacity(8),
}; };
u.randomize(); g.randomize();
u g
} }
fn randomize(&mut self) { fn randomize(&mut self) {
for c in &mut self.cells { for c in &mut self.cells {
*c = if Math::random() < 0.5 { *c = if Math::random() < 0.5 {
Cell::Alive { hue: random_hue() } safe_hue((Math::random() * 255.0) as u8)
} else { } else {
Cell::Dead DEAD
}; };
} }
} }
#[inline(always)] fn tick(&mut self, luts: &Luts) {
fn index(&self, row: u32, col: u32) -> usize { let (w, h) = (self.w, self.h);
(row * self.width + col) as usize
}
fn count_neighbors_and_get_hues(&mut self, row: u32, col: u32) -> u8 { for row in 0..h {
self.neighbor_hues_buffer.clear(); let n_off = (if row == 0 { h - 1 } else { row - 1 }) * w;
let r_off = row * w;
let s_off = (if row == h - 1 { 0 } else { row + 1 }) * w;
let north = if row == 0 { self.height - 1 } else { row - 1 }; for col in 0..w {
let south = if row == self.height - 1 { 0 } else { row + 1 }; let we = if col == 0 { w - 1 } else { col - 1 };
let west = if col == 0 { self.width - 1 } else { col - 1 }; let ea = if col == w - 1 { 0 } else { col + 1 };
let east = if col == self.width - 1 { 0 } else { col + 1 };
let neighbors = [ // Read all 8 neighbors
(north, west), let ns = [
(north, col), self.cells[(n_off + we) as usize],
(north, east), self.cells[(n_off + col) as usize],
(row, west), self.cells[(n_off + ea) as usize],
(row, east), self.cells[(r_off + we) as usize],
(south, west), self.cells[(r_off + ea) as usize],
(south, col), self.cells[(s_off + we) as usize],
(south, east), self.cells[(s_off + col) as usize],
]; self.cells[(s_off + ea) as usize],
];
for (nr, nc) in neighbors { // Count alive neighbors (branchless)
let idx = self.index(nr, nc); let count = ns.iter().fold(0u8, |a, &v| a + (v != DEAD) as u8);
if let Cell::Alive { hue } = self.cells[idx] {
self.neighbor_hues_buffer.push(hue);
}
}
self.neighbor_hues_buffer.len() as u8 let i = (r_off + col) as usize;
} let cell = self.cells[i];
let alive = cell != DEAD;
fn tick(&mut self) { self.buf[i] = if alive {
for row in 0..self.height { if count == 2 || count == 3 { cell } else { DEAD }
for col in 0..self.width { } else if count == 3 {
let idx = self.index(row, col); // Only collect hues for births
let cell = self.cells[idx]; self.hues.clear();
let neighbor_count = self.count_neighbors_and_get_hues(row, col); for &v in &ns {
if v != DEAD {
self.next_cells[idx] = match (cell, neighbor_count) { self.hues.push(v);
(Cell::Alive { .. }, x) if x < 2 => Cell::Dead, }
(Cell::Alive { hue }, 2) | (Cell::Alive { hue }, 3) => Cell::Alive { hue },
(Cell::Alive { .. }, x) if x > 3 => Cell::Dead,
(Cell::Dead, 3) => {
let mixed_hue = mix_colors(&self.neighbor_hues_buffer);
Cell::Alive { hue: mixed_hue }
} }
(otherwise, _) => otherwise, luts.mix(&self.hues)
} else {
DEAD
}; };
} }
} }
std::mem::swap(&mut self.cells, &mut self.next_cells); std::mem::swap(&mut self.cells, &mut self.buf);
} }
fn set_alive_block(&mut self, center_row: i32, center_col: i32, half: i32, hue: u8) { fn stamp(&mut self, cr: i32, cc: i32, half: i32, hue: u8) {
let h = self.height as i32; let (h, w) = (self.h as i32, self.w as i32);
let w = self.width as i32; let hue = safe_hue(hue);
for dr in -half..=half { for dr in -half..=half {
for dc in -half..=half { for dc in -half..=half {
let r = (center_row + dr).rem_euclid(h) as u32; let r = (cr + dr).rem_euclid(h) as u32;
let c = (center_col + dc).rem_euclid(w) as u32; let c = (cc + dc).rem_euclid(w) as u32;
let idx = self.index(r, c); self.cells[(r * self.w + c) as usize] = hue;
self.cells[idx] = Cell::Alive { hue };
} }
} }
} }
} }
struct AppState { // ── App state ──────────────────────────────────────────────────────
window: Window,
struct App {
win: Window,
canvas: HtmlCanvasElement, canvas: HtmlCanvasElement,
ctx: CanvasRenderingContext2d, ctx: CanvasRenderingContext2d,
universe: Universe, luts: Luts,
cursor_row: i32, grid: Grid,
cursor_col: i32, pixels: Vec<u8>,
last_frame_time: f64, cw: u32,
cursor_active: bool, ch: u32,
// Reusable pixel buffer for ImageData (avoids allocation each frame) crow: i32,
pixel_buffer: Vec<u8>, ccol: i32,
canvas_width: u32, cursor_on: bool,
canvas_height: u32, last_tick: f64,
dirty: bool,
} }
impl AppState { impl App {
fn new() -> Result<Self, JsValue> { fn new() -> Result<Self, JsValue> {
utils::set_panic_hook(); utils::set_panic_hook();
let win = web_sys::window().ok_or("no window")?;
let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; let doc = win.document().ok_or("no document")?;
let document = window let canvas: HtmlCanvasElement = doc
.document()
.ok_or_else(|| JsValue::from_str("no document"))?;
let canvas = document
.get_element_by_id("canvas") .get_element_by_id("canvas")
.and_then(|e| e.dyn_into::<HtmlCanvasElement>().ok()) .ok_or("no canvas")?
.ok_or_else(|| JsValue::from_str("canvas element not found"))?; .dyn_into()?;
let ctx: CanvasRenderingContext2d = canvas.get_context("2d")?.ok_or("no 2d ctx")?.dyn_into()?;
let ctx = canvas
.get_context("2d")?
.ok_or_else(|| JsValue::from_str("no 2d context"))?
.dyn_into::<CanvasRenderingContext2d>()?;
// Disable image smoothing for sharp pixel scaling
ctx.set_image_smoothing_enabled(false); ctx.set_image_smoothing_enabled(false);
let mut state = Self { let mut app = Self {
window, win,
canvas, canvas,
ctx, ctx,
universe: Universe::new(1, 1), luts: Luts::new(),
cursor_row: 0, grid: Grid::new(1, 1),
cursor_col: 0, pixels: Vec::new(),
last_frame_time: 0.0, cw: 0,
cursor_active: false, ch: 0,
pixel_buffer: Vec::new(), crow: 0,
canvas_width: 0, ccol: 0,
canvas_height: 0, cursor_on: false,
last_tick: 0.0,
dirty: true,
}; };
app.resize();
state.resize_canvas_and_universe(); Ok(app)
Ok(state)
} }
fn get_current_time(&self) -> f64 { fn now(&self) -> f64 {
self.window.performance().unwrap().now() self.win.performance().unwrap().now()
} }
fn resize_canvas_and_universe(&mut self) { fn resize(&mut self) {
let css_w = self.window.inner_width().unwrap().as_f64().unwrap(); let vw = self.win.inner_width().unwrap().as_f64().unwrap();
let css_h = self.window.inner_height().unwrap().as_f64().unwrap(); let vh = self.win.inner_height().unwrap().as_f64().unwrap();
let cols = (vw as u32 / CELL_SIZE).max(1);
let rows = (vh as u32 / CELL_SIZE).max(1);
let cols = (css_w as u32 / CELL_SIZE).max(1); self.cw = cols;
let rows = (css_h as u32 / CELL_SIZE).max(1); self.ch = rows;
// Set canvas to grid resolution (1 pixel per cell), CSS scales it up
self.canvas_width = cols;
self.canvas_height = rows;
self.canvas.set_width(cols); self.canvas.set_width(cols);
self.canvas.set_height(rows); self.canvas.set_height(rows);
self.canvas
let element = self.canvas.dyn_ref::<web_sys::Element>().unwrap(); .dyn_ref::<web_sys::Element>()
element .unwrap()
.set_attribute( .set_attribute(
"style", "style",
&format!( &format!(
"position:fixed;inset:0;width:{}px;height:{}px;image-rendering:pixelated", "position:fixed;inset:0;width:{vw}px;height:{vh}px;image-rendering:pixelated"
css_w, css_h
), ),
) )
.ok(); .ok();
self.ctx.set_image_smoothing_enabled(false); self.ctx.set_image_smoothing_enabled(false);
self.universe = Universe::new(cols, rows); self.grid = Grid::new(cols, rows);
self.pixel_buffer = vec![0u8; (cols * rows * 4) as usize]; self.pixels = vec![0u8; (cols * rows * 4) as usize];
self.dirty = true;
} }
fn draw(&mut self) { fn draw(&mut self) {
let total = (self.universe.width * self.universe.height) as usize; if !self.dirty {
return;
}
self.dirty = false;
for i in 0..total { let cells = &self.grid.cells;
let px = i * 4; let rgb = &self.luts.rgb;
match self.universe.cells[i] { let px = &mut self.pixels;
Cell::Alive { hue } => {
let (r, g, b) = hue_to_rgb(hue); for (i, &cell) in cells.iter().enumerate() {
self.pixel_buffer[px] = r; px[i * 4..i * 4 + 4].copy_from_slice(&rgb[cell as usize]);
self.pixel_buffer[px + 1] = g;
self.pixel_buffer[px + 2] = b;
self.pixel_buffer[px + 3] = 255;
}
Cell::Dead => {
self.pixel_buffer[px] = 0;
self.pixel_buffer[px + 1] = 0;
self.pixel_buffer[px + 2] = 0;
self.pixel_buffer[px + 3] = 255;
}
}
} }
if let Ok(image_data) = ImageData::new_with_u8_clamped_array_and_sh( if let Ok(img) =
Clamped(&self.pixel_buffer), ImageData::new_with_u8_clamped_array_and_sh(Clamped(px), self.cw, self.ch)
self.canvas_width, {
self.canvas_height, self.ctx.put_image_data(&img, 0.0, 0.0).ok();
) {
self.ctx.put_image_data(&image_data, 0.0, 0.0).ok();
} }
} }
} }
// ── Entry point ────────────────────────────────────────────────────
#[wasm_bindgen] #[wasm_bindgen]
pub fn start() -> Result<(), JsValue> { pub fn start() -> Result<(), JsValue> {
let state = AppState::new()?; let app = App::new()?;
let state_rc = Rc::new(RefCell::new(state)); let rc = Rc::new(RefCell::new(app));
let window = state_rc.borrow().window.clone(); let win = rc.borrow().win.clone();
let document = window.document().unwrap(); let doc = win.document().unwrap();
// Mouse move handler // Mouse
let state_for_mouse = state_rc.clone(); let s = rc.clone();
let mouse_closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| { let cb = Closure::wrap(Box::new(move |e: web_sys::MouseEvent| {
let mut s = state_for_mouse.borrow_mut(); let mut a = s.borrow_mut();
s.cursor_col = event.client_x() / CELL_SIZE as i32; a.ccol = e.client_x() / CELL_SIZE as i32;
s.cursor_row = event.client_y() / CELL_SIZE as i32; a.crow = e.client_y() / CELL_SIZE as i32;
s.cursor_active = true; a.cursor_on = true;
}) as Box<dyn FnMut(_)>); }) as Box<dyn FnMut(_)>);
doc.add_event_listener_with_callback("mousemove", cb.as_ref().unchecked_ref())?;
cb.forget();
document.add_event_listener_with_callback("mousemove", mouse_closure.as_ref().unchecked_ref())?; // Touch move
mouse_closure.forget(); let s = rc.clone();
let cb = Closure::wrap(Box::new(move |e: web_sys::TouchEvent| {
// Touch move handler e.prevent_default();
let state_for_touch_move = state_rc.clone(); if let Some(t) = e.touches().get(0) {
let touch_move_closure = Closure::wrap(Box::new(move |event: web_sys::TouchEvent| { let mut a = s.borrow_mut();
event.prevent_default(); a.ccol = t.client_x() / CELL_SIZE as i32;
if let Some(touch) = event.touches().get(0) { a.crow = t.client_y() / CELL_SIZE as i32;
let mut s = state_for_touch_move.borrow_mut(); a.cursor_on = true;
s.cursor_col = touch.client_x() / CELL_SIZE as i32;
s.cursor_row = touch.client_y() / CELL_SIZE as i32;
s.cursor_active = true;
} }
}) as Box<dyn FnMut(_)>); }) as Box<dyn FnMut(_)>);
doc.add_event_listener_with_callback("touchmove", cb.as_ref().unchecked_ref())?;
cb.forget();
document // Touch start
.add_event_listener_with_callback("touchmove", touch_move_closure.as_ref().unchecked_ref())?; let s = rc.clone();
touch_move_closure.forget(); let cb = Closure::wrap(Box::new(move |e: web_sys::TouchEvent| {
e.prevent_default();
// Touch start handler if let Some(t) = e.touches().get(0) {
let state_for_touch_start = state_rc.clone(); let mut a = s.borrow_mut();
let touch_start_closure = Closure::wrap(Box::new(move |event: web_sys::TouchEvent| { a.ccol = t.client_x() / CELL_SIZE as i32;
event.prevent_default(); a.crow = t.client_y() / CELL_SIZE as i32;
if let Some(touch) = event.touches().get(0) { a.cursor_on = true;
let mut s = state_for_touch_start.borrow_mut();
s.cursor_col = touch.client_x() / CELL_SIZE as i32;
s.cursor_row = touch.client_y() / CELL_SIZE as i32;
s.cursor_active = true;
} }
}) as Box<dyn FnMut(_)>); }) as Box<dyn FnMut(_)>);
doc.add_event_listener_with_callback("touchstart", cb.as_ref().unchecked_ref())?;
cb.forget();
document // Touch end
.add_event_listener_with_callback("touchstart", touch_start_closure.as_ref().unchecked_ref())?; let s = rc.clone();
touch_start_closure.forget(); let cb = Closure::wrap(Box::new(move |_: web_sys::TouchEvent| {
s.borrow_mut().cursor_on = false;
// Touch end handler
let state_for_touch_end = state_rc.clone();
let touch_end_closure = Closure::wrap(Box::new(move |_event: web_sys::TouchEvent| {
let mut s = state_for_touch_end.borrow_mut();
s.cursor_active = false;
}) as Box<dyn FnMut(_)>); }) as Box<dyn FnMut(_)>);
doc.add_event_listener_with_callback("touchend", cb.as_ref().unchecked_ref())?;
cb.forget();
document // Resize
.add_event_listener_with_callback("touchend", touch_end_closure.as_ref().unchecked_ref())?; let s = rc.clone();
touch_end_closure.forget(); let cb = Closure::wrap(Box::new(move || {
s.borrow_mut().resize();
// Resize handler
let state_for_resize = state_rc.clone();
let resize_closure = Closure::wrap(Box::new(move || {
let mut s = state_for_resize.borrow_mut();
s.resize_canvas_and_universe();
}) as Box<dyn FnMut()>); }) as Box<dyn FnMut()>);
win.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?;
window.add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref())?; cb.forget();
resize_closure.forget();
// Animation loop // Animation loop
let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>)); let f: Rc<RefCell<Option<Closure<dyn FnMut()>>>> = Rc::new(RefCell::new(None));
let g = f.clone(); let g = f.clone();
let state_for_anim = state_rc.clone(); let s = rc.clone();
let window_for_anim = window.clone(); let w = win.clone();
*g.borrow_mut() = Some(Closure::wrap(Box::new(move || { *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
let current_time = state_for_anim.borrow().get_current_time(); let now = s.borrow().now();
{ {
let mut s = state_for_anim.borrow_mut(); let mut a = s.borrow_mut();
// Run simulation FIRST (throttled to 60 FPS max) if now - a.last_tick >= TICK_MS {
// This way cursor-placed cells won't be immediately killed a.last_tick = now;
if current_time - s.last_frame_time >= SIMULATION_FRAME_MS { let luts = &a.luts as *const Luts;
s.last_frame_time = current_time; // SAFETY: luts is not mutated during tick()
s.universe.tick(); a.grid.tick(unsafe { &*luts });
a.dirty = true;
} }
// Process cursor input AFTER tick for responsiveness if a.cursor_on {
// Cells placed here survive until the next tick let (cr, cc) = (a.crow, a.ccol);
if s.cursor_active { let hue = ((now % HUE_PERIOD_MS) / HUE_PERIOD_MS * 256.0) as u8;
let cursor_row = s.cursor_row; a.grid.stamp(cr, cc, 2, hue);
let cursor_col = s.cursor_col; a.dirty = true;
let hue = ((current_time % HUE_ROTATION_PERIOD_MS) / HUE_ROTATION_PERIOD_MS * 256.0) as u8;
s.universe.set_alive_block(cursor_row, cursor_col, 2, hue);
} }
s.draw(); a.draw();
} }
window_for_anim w.request_animation_frame(f.borrow().as_ref().unwrap().as_ref().unchecked_ref())
.request_animation_frame(f.borrow().as_ref().unwrap().as_ref().unchecked_ref())
.ok(); .ok();
}) as Box<dyn FnMut()>)); }) as Box<dyn FnMut()>));
window.request_animation_frame(g.borrow().as_ref().unwrap().as_ref().unchecked_ref())?; win.request_animation_frame(g.borrow().as_ref().unwrap().as_ref().unchecked_ref())?;
Ok(()) Ok(())
} }