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