diff --git a/cgol/Cargo.toml b/cgol/Cargo.toml index f225420..a8d6dec 100644 --- a/cgol/Cargo.toml +++ b/cgol/Cargo.toml @@ -11,7 +11,7 @@ crate-type = ["cdylib", "rlib"] default = ["console_error_panic_hook"] [dependencies] -wasm-bindgen = "0.2.84" +wasm-bindgen = "0.2" js-sys = "0.3" web-sys = { version = "0.3", features = [ "console", @@ -21,16 +21,12 @@ web-sys = { version = "0.3", features = [ "Window", "MouseEvent", "Element", - "HtmlElement", "EventTarget", "Performance", "DomRect", - "CssStyleDeclaration", "TouchEvent", "Touch", "TouchList", - "OffscreenCanvas", - "OffscreenCanvasRenderingContext2d", "ImageData", ] } @@ -38,12 +34,12 @@ web-sys = { version = "0.3", features = [ # logging them with `console.error`. This is great for development, but requires # all the `std::fmt` and `std::panicking` infrastructure, so isn't great for # code size when deploying. -console_error_panic_hook = { version = "0.1.7", optional = true } +console_error_panic_hook = { version = "0.1", optional = true } [dev-dependencies] -wasm-bindgen-test = "0.3.34" +wasm-bindgen-test = "0.3" [profile.release] -# Tell `rustc` to optimize for small code size. -opt-level = "z" +opt-level = 3 lto = true +codegen-units = 1 diff --git a/cgol/src/lib.rs b/cgol/src/lib.rs index 661eda1..0a204a5 100644 --- a/cgol/src/lib.rs +++ b/cgol/src/lib.rs @@ -3,64 +3,26 @@ mod utils; use std::cell::RefCell; use std::rc::Rc; use wasm_bindgen::prelude::*; +use wasm_bindgen::Clamped; use wasm_bindgen::JsCast; use js_sys::Math; -use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, Window, OffscreenCanvas, OffscreenCanvasRenderingContext2d, ImageData}; +use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData, Window}; const CELL_SIZE: u32 = 20; -const TARGET_FPS: f64 = 45.0; -const FRAME_DURATION_MS: f64 = 1000.0 / TARGET_FPS; - -struct CellCache { - ctx: OffscreenCanvasRenderingContext2d, - cached_cells: std::collections::HashMap, -} - -impl CellCache { - fn new() -> Result { - let offscreen_canvas = OffscreenCanvas::new(CELL_SIZE, CELL_SIZE)?; - let context_options = js_sys::Object::new(); - js_sys::Reflect::set(&context_options, &"willReadFrequently".into(), &true.into())?; - let ctx = offscreen_canvas - .get_context_with_context_options("2d", &context_options)? - .ok_or_else(|| JsValue::from_str("no 2d context"))? - .dyn_into::()?; - - Ok(Self { - ctx, - cached_cells: std::collections::HashMap::new(), - }) - } - - fn get_or_create_cell(&mut self, hue: u8) -> Result<&ImageData, JsValue> { - if !self.cached_cells.contains_key(&hue) { - self.ctx.set_fill_style_str("#000"); - self.ctx.fill_rect(0.0, 0.0, CELL_SIZE as f64, CELL_SIZE as f64); - - let hue_degrees = hue as f64 * 360.0 / 255.0; - let color_str = format!("hsl({}, 100%, 50%)", hue_degrees); - self.ctx.set_fill_style_str(&color_str); - self.ctx.fill_rect(0.0, 0.0, CELL_SIZE as f64, CELL_SIZE as f64); - - let image_data = self.ctx.get_image_data(0.0, 0.0, CELL_SIZE as f64, CELL_SIZE as f64)?; - self.cached_cells.insert(hue, image_data); - } - - Ok(self.cached_cells.get(&hue).unwrap()) - } -} - +const SIMULATION_FPS: f64 = 60.0; +const SIMULATION_FRAME_MS: f64 = 1000.0 / SIMULATION_FPS; +const HUE_ROTATION_PERIOD_MS: f64 = 3000.0; fn random_hue() -> u8 { (Math::random() * 256.0) as u8 } -fn mix_colors(hues: Vec) -> u8 { +fn mix_colors(hues: &[u8]) -> u8 { if hues.is_empty() { return 0; } - + let (sum_sin, sum_cos) = hues .iter() .map(|&hue| (hue as f64 * 360.0 / 255.0).to_radians()) @@ -71,6 +33,24 @@ fn mix_colors(hues: Vec) -> u8 { (avg_hue_degrees * 255.0 / 360.0) 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) +} + #[derive(Clone, Copy, PartialEq)] enum Cell { Dead, @@ -82,16 +62,18 @@ struct Universe { 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, + 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(); u @@ -99,26 +81,27 @@ impl Universe { fn randomize(&mut self) { for c in &mut self.cells { - *c = if Math::random() < 0.5 { + *c = if Math::random() < 0.5 { Cell::Alive { hue: random_hue() } - } else { - Cell::Dead + } else { + Cell::Dead }; } } + #[inline(always)] fn index(&self, row: u32, col: u32) -> usize { (row * self.width + col) as usize } - fn get_neighbor_hues(&self, row: u32, col: u32) -> Vec { - let mut hues = Vec::new(); - + fn count_neighbors_and_get_hues(&mut self, row: u32, col: u32) -> u8 { + self.neighbor_hues_buffer.clear(); + 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 }; - + let neighbors = [ (north, west), (north, col), @@ -129,15 +112,15 @@ impl Universe { (south, col), (south, east), ]; - + for (nr, nc) in neighbors { let idx = self.index(nr, nc); - if let Cell::Alive { hue } = &self.cells[idx] { - hues.push(*hue); + if let Cell::Alive { hue } = self.cells[idx] { + self.neighbor_hues_buffer.push(hue); } } - - hues + + self.neighbor_hues_buffer.len() as u8 } fn tick(&mut self) { @@ -145,16 +128,16 @@ impl Universe { for col in 0..self.width { let idx = self.index(row, col); let cell = self.cells[idx]; - let neighbor_hues = self.get_neighbor_hues(row, col); - let neighbor_count = neighbor_hues.len() as u8; + 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(neighbor_hues); + let mixed_hue = mix_colors(&self.neighbor_hues_buffer); Cell::Alive { hue: mixed_hue } - }, + } (otherwise, _) => otherwise, }; } @@ -162,7 +145,7 @@ impl Universe { std::mem::swap(&mut self.cells, &mut self.next_cells); } - fn set_alive_block(&mut self, center_row: i32, center_col: i32, half: i32) { + 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; for dr in -half..=half { @@ -170,13 +153,12 @@ impl Universe { 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: random_hue() }; + self.cells[idx] = Cell::Alive { hue }; } } } } - struct AppState { window: Window, canvas: HtmlCanvasElement, @@ -186,7 +168,10 @@ struct AppState { cursor_col: i32, last_frame_time: f64, cursor_active: bool, - cell_cache: CellCache, + // Reusable pixel buffer for ImageData (avoids allocation each frame) + pixel_buffer: Vec, + canvas_width: u32, + canvas_height: u32, } impl AppState { @@ -194,7 +179,9 @@ impl AppState { 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 document = window + .document() + .ok_or_else(|| JsValue::from_str("no document"))?; let canvas = document .get_element_by_id("canvas") @@ -206,6 +193,9 @@ impl AppState { .ok_or_else(|| JsValue::from_str("no 2d context"))? .dyn_into::()?; + // Disable image smoothing for sharp pixel scaling + ctx.set_image_smoothing_enabled(false); + let mut state = Self { window, canvas, @@ -215,7 +205,9 @@ impl AppState { cursor_col: 0, last_frame_time: 0.0, cursor_active: false, - cell_cache: CellCache::new()?, + pixel_buffer: Vec::new(), + canvas_width: 0, + canvas_height: 0, }; state.resize_canvas_and_universe(); @@ -223,10 +215,7 @@ impl AppState { } fn get_current_time(&self) -> f64 { - self.window - .performance() - .unwrap() - .now() + self.window.performance().unwrap().now() } fn resize_canvas_and_universe(&mut self) { @@ -236,186 +225,239 @@ impl AppState { let dpr = self.window.device_pixel_ratio(); let css_w = width; let css_h = height; - let style = self.canvas.style(); - style.set_property("position", "fixed").ok(); - style.set_property("inset", "0").ok(); - style.set_property("width", &format!("{}px", css_w)).ok(); - style.set_property("height", &format!("{}px", css_h)).ok(); - self.canvas.set_width((css_w * dpr) as u32); - self.canvas.set_height((css_h * dpr) as u32); + let element = self.canvas.dyn_ref::().unwrap(); + element + .set_attribute( + "style", + &format!( + "position:fixed;inset:0;width:{}px;height:{}px;image-rendering:pixelated", + css_w, css_h + ), + ) + .ok(); + + self.canvas_width = (css_w * dpr) as u32; + self.canvas_height = (css_h * dpr) as u32; + self.canvas.set_width(self.canvas_width); + self.canvas.set_height(self.canvas_height); + + // Disable image smoothing after resize self.ctx.set_image_smoothing_enabled(false); + + // Clear canvas self.ctx.set_fill_style_str("black"); - self.ctx.fill_rect(0.0, 0.0, self.canvas.width() as f64, self.canvas.height() as f64); + self.ctx.fill_rect(0.0, 0.0, self.canvas_width as f64, self.canvas_height as f64); - let cols = (self.canvas.width() / CELL_SIZE).max(1); - let rows = (self.canvas.height() / CELL_SIZE).max(1); + let cols = (self.canvas_width / CELL_SIZE).max(1); + let rows = (self.canvas_height / CELL_SIZE).max(1); self.universe = Universe::new(cols, rows); + + // Allocate pixel buffer for the universe size (1 pixel per cell) + // We'll draw at universe resolution and let CSS scale it up + let buffer_size = (cols * rows * 4) as usize; + self.pixel_buffer = vec![0u8; buffer_size]; } - fn draw(&mut self) { - self.ctx.set_fill_style_str("#000"); - self.ctx.fill_rect(0.0, 0.0, self.canvas.width() as f64, self.canvas.height() as f64); + fn draw_scaled(&mut self) { + let grid_width = self.universe.width; + let grid_height = self.universe.height; + let cell_w = CELL_SIZE; + let cell_h = CELL_SIZE; - let cell_w = CELL_SIZE as f64; - let cell_h = CELL_SIZE as f64; - - for row in 0..self.universe.height { - for col in 0..self.universe.width { - let idx = self.universe.index(row, col); - if let Cell::Alive { hue } = self.universe.cells[idx] { - let x = col as f64 * cell_w; - let y = row as f64 * cell_h; + // Fill pixel buffer at full canvas resolution + let canvas_w = self.canvas_width as usize; + let canvas_h = self.canvas_height as usize; + + // Resize buffer if needed + let needed_size = canvas_w * canvas_h * 4; + if self.pixel_buffer.len() != needed_size { + self.pixel_buffer.resize(needed_size, 0); + } + + // Fill with black first (for dead cells) + for chunk in self.pixel_buffer.chunks_exact_mut(4) { + chunk[0] = 0; + chunk[1] = 0; + chunk[2] = 0; + chunk[3] = 255; + } + + // Draw each cell as a CELL_SIZE × CELL_SIZE block + for row in 0..grid_height { + for col in 0..grid_width { + let cell_idx = self.universe.index(row, col); + + if let Cell::Alive { hue } = self.universe.cells[cell_idx] { + let (r, g, b) = hue_to_rgb(hue); - if let Ok(image_data) = self.cell_cache.get_or_create_cell(hue) { - self.ctx.put_image_data(image_data, x, y).ok(); + let start_x = (col * cell_w) as usize; + let start_y = (row * cell_h) as usize; + + for py in 0..cell_h as usize { + let y = start_y + py; + if y >= canvas_h { + break; + } + + for px in 0..cell_w as usize { + let x = start_x + px; + if x >= canvas_w { + break; + } + + let pixel_idx = (y * canvas_w + x) * 4; + self.pixel_buffer[pixel_idx] = r; + self.pixel_buffer[pixel_idx + 1] = g; + self.pixel_buffer[pixel_idx + 2] = b; + self.pixel_buffer[pixel_idx + 3] = 255; + } } } } } + + // Single putImageData call with full canvas + 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(); + } } } #[wasm_bindgen] pub fn start() -> Result<(), JsValue> { let state = AppState::new()?; + let state_rc = Rc::new(RefCell::new(state)); - { - let canvas = state.canvas.clone(); - let window = state.window.clone(); - let state_rc = Rc::new(RefCell::new(state)); - let state_for_mouse = state_rc.clone(); - let mouse_closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| { - let rect = canvas.get_bounding_client_rect(); - let dpr = window.device_pixel_ratio(); - let x = (event.client_x() as f64 - rect.left()) * dpr; - let y = (event.client_y() as f64 - rect.top()) * dpr; - let mut s = state_for_mouse.borrow_mut(); + let canvas = state_rc.borrow().canvas.clone(); + let window = state_rc.borrow().window.clone(); + let document = window.document().unwrap(); + + // Mouse move handler + let state_for_mouse = state_rc.clone(); + let canvas_for_mouse = canvas.clone(); + let window_for_mouse = window.clone(); + let mouse_closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| { + let rect = canvas_for_mouse.get_bounding_client_rect(); + let dpr = window_for_mouse.device_pixel_ratio(); + let x = (event.client_x() as f64 - rect.left()) * dpr; + let y = (event.client_y() as f64 - rect.top()) * dpr; + let mut s = state_for_mouse.borrow_mut(); + s.cursor_col = (x / CELL_SIZE as f64) as i32; + s.cursor_row = (y / CELL_SIZE as f64) as i32; + s.cursor_active = true; + }) as Box); + + 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 canvas_for_touch_move = canvas.clone(); + let window_for_touch_move = window.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 rect = canvas_for_touch_move.get_bounding_client_rect(); + let dpr = window_for_touch_move.device_pixel_ratio(); + let x = (touch.client_x() as f64 - rect.left()) * dpr; + let y = (touch.client_y() as f64 - rect.top()) * dpr; + let mut s = state_for_touch_move.borrow_mut(); s.cursor_col = (x / CELL_SIZE as f64) as i32; s.cursor_row = (y / CELL_SIZE as f64) as i32; s.cursor_active = true; - }) as Box); - state_rc - .borrow() - .window - .document() - .unwrap() - .add_event_listener_with_callback("mousemove", mouse_closure.as_ref().unchecked_ref())?; - mouse_closure.forget(); - - let canvas_touch_move = state_rc.borrow().canvas.clone(); - let window_touch_move = state_rc.borrow().window.clone(); - 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 rect = canvas_touch_move.get_bounding_client_rect(); - let dpr = window_touch_move.device_pixel_ratio(); - let x = (touch.client_x() as f64 - rect.left()) * dpr; - let y = (touch.client_y() as f64 - rect.top()) * dpr; - let mut s = state_for_touch_move.borrow_mut(); - s.cursor_col = (x / CELL_SIZE as f64) as i32; - s.cursor_row = (y / CELL_SIZE as f64) as i32; - s.cursor_active = true; - } - }) as Box); - - let canvas_touch_start = state_rc.borrow().canvas.clone(); - let window_touch_start = state_rc.borrow().window.clone(); - 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 rect = canvas_touch_start.get_bounding_client_rect(); - let dpr = window_touch_start.device_pixel_ratio(); - let x = (touch.client_x() as f64 - rect.left()) * dpr; - let y = (touch.client_y() as f64 - rect.top()) * dpr; - let mut s = state_for_touch_start.borrow_mut(); - s.cursor_col = (x / CELL_SIZE as f64) as i32; - s.cursor_row = (y / CELL_SIZE as f64) as i32; - s.cursor_active = true; - } - }) as Box); - - state_rc - .borrow() - .window - .document() - .unwrap() - .add_event_listener_with_callback("touchmove", touch_move_closure.as_ref().unchecked_ref())?; - state_rc - .borrow() - .window - .document() - .unwrap() - .add_event_listener_with_callback("touchstart", touch_start_closure.as_ref().unchecked_ref())?; - - touch_move_closure.forget(); - touch_start_closure.forget(); + } + }) as Box); - 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); - - state_rc - .borrow() - .window - .document() - .unwrap() - .add_event_listener_with_callback("touchend", touch_end_closure.as_ref().unchecked_ref())?; - touch_end_closure.forget(); + document + .add_event_listener_with_callback("touchmove", touch_move_closure.as_ref().unchecked_ref())?; + touch_move_closure.forget(); - 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); - state_rc - .borrow() - .window - .add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref())?; - resize_closure.forget(); + // Touch start handler + let state_for_touch_start = state_rc.clone(); + let canvas_for_touch_start = canvas.clone(); + let window_for_touch_start = window.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 rect = canvas_for_touch_start.get_bounding_client_rect(); + let dpr = window_for_touch_start.device_pixel_ratio(); + let x = (touch.client_x() as f64 - rect.left()) * dpr; + let y = (touch.client_y() as f64 - rect.top()) * dpr; + let mut s = state_for_touch_start.borrow_mut(); + s.cursor_col = (x / CELL_SIZE as f64) as i32; + s.cursor_row = (y / CELL_SIZE as f64) as i32; + s.cursor_active = true; + } + }) as Box); - let f = Rc::new(RefCell::new(None::>)); - let g = f.clone(); - let state_for_anim = state_rc.clone(); - let window_for_anim = state_rc.borrow().window.clone(); - *g.borrow_mut() = Some(Closure::wrap(Box::new(move || { - let current_time = state_for_anim.borrow().get_current_time(); - let mut should_update = false; + 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; + }) as Box); + + 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(); + }) as Box); + + window.add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref())?; + resize_closure.forget(); + + // Animation loop + let f = Rc::new(RefCell::new(None::>)); + let g = f.clone(); + let state_for_anim = state_rc.clone(); + let window_for_anim = window.clone(); + + *g.borrow_mut() = Some(Closure::wrap(Box::new(move || { + let current_time = state_for_anim.borrow().get_current_time(); + + { + let mut s = state_for_anim.borrow_mut(); - { - let mut s = state_for_anim.borrow_mut(); - if current_time - s.last_frame_time >= FRAME_DURATION_MS { - should_update = true; - s.last_frame_time = current_time; - } - - if should_update { - if s.cursor_active { - let cursor_row = s.cursor_row; - let cursor_col = s.cursor_col; - s.universe.set_alive_block(cursor_row, cursor_col, 2); - } - s.universe.tick(); - } - - s.draw(); + // 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(); } - window_for_anim - .request_animation_frame(f.borrow().as_ref().unwrap().as_ref().unchecked_ref()) - .ok(); - }) as Box)); + // 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); + } - state_rc - .borrow() - .window - .request_animation_frame(g.borrow().as_ref().unwrap().as_ref().unchecked_ref())?; - } + // Draw every frame + s.draw_scaled(); + } + + window_for_anim + .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())?; Ok(()) }