diff options
| author | Kai Stevenson <kai@kaistevenson.com> | 2026-08-09 16:30:29 -0700 |
|---|---|---|
| committer | Kai Stevenson <kai@kaistevenson.com> | 2026-08-09 16:30:29 -0700 |
| commit | 167e31655b63aa4d4548532cf0410cea9fd145ca (patch) | |
| tree | 2b9b3d3832404979fecfa3972464c37613d43db2 | |
| parent | 2eff3a26da27f68355ee2316f956e1bdfb6f1b68 (diff) | |
drawing
| -rw-r--r-- | src/camera.rs | 58 | ||||
| -rw-r--r-- | src/config.rs | 3 | ||||
| -rw-r--r-- | src/main.rs | 280 | ||||
| -rw-r--r-- | src/sim/board.rs | 72 | ||||
| -rw-r--r-- | src/sim/materials.rs | 22 | ||||
| -rw-r--r-- | src/sim/mod.rs | 4 | ||||
| -rw-r--r-- | src/sim/overlay.rs | 50 | ||||
| -rw-r--r-- | src/sim/sim.rs | 3 | ||||
| -rw-r--r-- | src/ui.rs | 33 |
9 files changed, 424 insertions, 101 deletions
diff --git a/src/camera.rs b/src/camera.rs new file mode 100644 index 0000000..695baf5 --- /dev/null +++ b/src/camera.rs @@ -0,0 +1,58 @@ +use crate::{ + config::{PIXEL_BUFFER_HEIGHT, PIXEL_BUFFER_WIDTH}, + sim::{board::Board, materials::MATERIALS}, +}; + +pub struct CameraState { + // centre coords + pub x: f64, + pub y: f64, + // zoom scale factor, 1.0 = board <-> frame + pub zoom: f64, +} + +pub fn screen_position_to_board( + board: &Board, + camera: &CameraState, + screen_x: f64, + screen_y: f64, +) -> (f64, f64) { + let camera_width = camera.zoom * f64::from(board.size_x); + let camera_height = camera.zoom * f64::from(board.size_y); + let camera_start_x = camera.x - camera_width / 2.0; + let camera_start_y = camera.y - camera_height / 2.0; + ( + (screen_x / PIXEL_BUFFER_WIDTH as f64 * camera_width) + camera_start_x, + (screen_y / PIXEL_BUFFER_HEIGHT as f64 * camera_height) + camera_start_y, + ) +} + +pub fn write_frame_view( + frame: &mut [u8], + board: &Board, + camera: &CameraState, + get_overlay: impl Fn(i32, i32) -> (u8, u8, u8, u8), +) { + for frame_y in 0..PIXEL_BUFFER_HEIGHT { + for frame_x in 0..PIXEL_BUFFER_WIDTH { + let (x_coord, y_coord) = + screen_position_to_board(board, camera, frame_x as f64, frame_y as f64); + + let cell = board.cell_at_position(x_coord as i32, y_coord as i32); + let cell_color: Option<(u8, u8, u8, u8)> = cell.map(|c| { + let m = MATERIALS[c.material as usize]; + (m.r, m.g, m.b, 0xFF) + }); + let off_grid_color: (u8, u8, u8, u8) = (0x00, 0x00, 0x00, 0xFF); + + let target_color: (u8, u8, u8, u8) = cell_color.unwrap_or(off_grid_color); + let overlay = get_overlay(x_coord as i32, y_coord as i32); + + let frame_idx = ((frame_y * PIXEL_BUFFER_WIDTH + frame_x) * 4) as usize; + frame[frame_idx] = target_color.0.saturating_add(overlay.0); + frame[frame_idx + 1] = target_color.1.saturating_add(overlay.1); + frame[frame_idx + 2] = target_color.2.saturating_add(overlay.2); + frame[frame_idx + 3] = target_color.3.saturating_add(overlay.3); + } + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..75cb581 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,3 @@ +pub const WINDOW_TITLE: &str = "pxs"; +pub const PIXEL_BUFFER_WIDTH: u32 = 320; +pub const PIXEL_BUFFER_HEIGHT: u32 = 240; diff --git a/src/main.rs b/src/main.rs index d818b04..64e57df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,26 +1,51 @@ +mod camera; +mod config; +mod sim; +mod ui; + +use egui::Id; use egui_wgpu::{RendererOptions, ScreenDescriptor}; use egui_winit::egui::{self, Context}; -use pixels::{Pixels, SurfaceTexture}; +use pixels::{Pixels, ScalingMode, SurfaceTexture}; use std::time::{Duration, Instant}; use winit::{ application::ApplicationHandler, event::{ElementState, MouseButton, WindowEvent}, event_loop::{ActiveEventLoop, ControlFlow, EventLoop}, + keyboard::Key::{self}, window::Window, }; -const WIDTH: u32 = 320; -const HEIGHT: u32 = 240; -const FPS: u64 = 60; -const FRAME_DURATION: Duration = Duration::from_micros(1_000_000 / FPS); +use crate::{ + camera::{CameraState, screen_position_to_board, write_frame_view}, + config::{PIXEL_BUFFER_HEIGHT, PIXEL_BUFFER_WIDTH, WINDOW_TITLE}, + sim::{board::Board, overlay::create_compute_combined_overlay_offset}, + ui::draw_egui, +}; pub type Error = Box<dyn std::error::Error>; pub type Result<T> = std::result::Result<T, Error>; -struct Clicked { - x: usize, - y: usize, - s: f64, +struct Config { + fps: u16, + brush_size: u8, +} + +struct Input { + last_mouse_pos_on_screen: (f64, f64), + last_mouse_pos_on_board: (i32, i32), + is_lmb_down: bool, +} + +struct State { + // debug + red_level: u8, + green_level: u8, + blue_level: u8, +} + +struct Diagnostics { + fps: f32, } struct App { @@ -30,47 +55,62 @@ struct App { egui_context: Option<egui::Context>, window: Option<&'static Window>, pixels: Option<Pixels<'static>>, - last_frame: Instant, - // input - last_mouse_pos: Option<(f64, f64)>, + // input state + input: Input, - // experiemnts - clicked: Vec<Clicked>, + // camera state + camera: Option<CameraState>, - // debug - red_level: u8, - green_level: u8, - blue_level: u8, -} + // game state + board: Option<Board>, -impl App { - fn set_px(f: &mut [u8], x: u32, y: u32, r: u8, g: u8, b: u8, a: u8) { - let idx = ((y * WIDTH + x) * 4) as usize; - f[idx] = r; - f[idx + 1] = g; - f[idx + 2] = b; - f[idx + 3] = a; - } + // used to wait for drawing + last_frame_requested: Instant, + // used to compute delta_time + last_frame_real: Instant, + + config: Config, + state: State, + diagnostics: Diagnostics, } impl Default for App { fn default() -> Self { Self { - egui_context: None, - egui_state: None, egui_renderer: None, + egui_state: None, + egui_context: None, window: None, pixels: None, - last_frame: Instant::now(), - last_mouse_pos: Some((0.0, 0.0)), + input: Input { + last_mouse_pos_on_screen: (0.0, 0.0), + last_mouse_pos_on_board: (0, 0), + is_lmb_down: false, + }, - clicked: Vec::new(), + camera: Some(CameraState { + x: 0.0, + y: 0.0, + zoom: 0.5, + }), - red_level: 0xFF, - green_level: 0xFF, - blue_level: 0xFF, + board: Some(Board::empty()), + + last_frame_requested: Instant::now(), + last_frame_real: Instant::now(), + + config: Config { + fps: 120, + brush_size: 10, + }, + state: State { + red_level: 0xFF, + green_level: 0xFF, + blue_level: 0xFF, + }, + diagnostics: Diagnostics { fps: 0.0 }, } } } @@ -78,14 +118,16 @@ impl Default for App { impl ApplicationHandler for App { fn resumed(&mut self, event_loop: &ActiveEventLoop) { let window = event_loop - .create_window(Window::default_attributes().with_title("pxs")) + .create_window(Window::default_attributes().with_title(WINDOW_TITLE)) .unwrap(); let size = window.inner_size(); let window_ref: &'static Window = Box::leak(Box::new(window)); let surface = SurfaceTexture::new(size.width, size.height, window_ref); - let pixels = Pixels::new(WIDTH, HEIGHT, surface).unwrap(); + let mut pixels = Pixels::new(PIXEL_BUFFER_WIDTH, PIXEL_BUFFER_HEIGHT, surface).unwrap(); + + pixels.set_scaling_mode(ScalingMode::Fill); let egui_context = Context::default(); let egui_state = egui_winit::State::new( @@ -122,27 +164,35 @@ impl ApplicationHandler for App { && let Some(egui_context) = &self.egui_context && let Some(egui_state) = &mut self.egui_state && let Some(egui_renderer) = &mut self.egui_renderer + && let Some(board) = &mut self.board + && let Some(camera) = &mut self.camera { - // TODO how to use the response? - let _ = egui_state.on_window_event(window, &event); + let egui_response = egui_state.on_window_event(window, &event); + // if egui consumed the event, it means we shouldn't treat any e.g., mouse clicks + if egui_response.consumed { + return; + } match event { + WindowEvent::KeyboardInput { event, .. } => match event.logical_key { + Key::Character(char) => { + if char == "z" { + camera.zoom += 0.1; + } + } + _ => {} + }, WindowEvent::CursorMoved { position, .. } => { - self.last_mouse_pos = Some((position.x, position.y)); + self.input.last_mouse_pos_on_screen = (position.x, position.y); + self.input.last_mouse_pos_on_board = pixels + .window_pos_to_pixel((position.x as f32, position.y as f32)) + .map(|v| screen_position_to_board(board, camera, v.0 as f64, v.1 as f64)) + .map(|v| (v.0 as i32, v.1 as i32)) + .unwrap(); } WindowEvent::MouseInput { state, button, .. } => { - if state == ElementState::Pressed - && button == MouseButton::Left - && let Some(last_mouse_pos) = self.last_mouse_pos - { - let coords = pixels - .window_pos_to_pixel((last_mouse_pos.0 as f32, last_mouse_pos.1 as f32)) - .unwrap(); - self.clicked.push(Clicked { - x: coords.0, - y: coords.1, - s: 25.0, - }); + if button == MouseButton::Left { + self.input.is_lmb_down = state == ElementState::Pressed } } WindowEvent::Resized(size) => { @@ -153,55 +203,81 @@ impl ApplicationHandler for App { event_loop.exit(); } WindowEvent::RedrawRequested => { - // pixels logic + // compute frame delta + let now = Instant::now(); + let secs_since_last_frame = (now - self.last_frame_real).as_secs_f32(); + let delta_time = secs_since_last_frame / (1.0 / 60.0); + self.last_frame_real = now; + let instantaneous_fps = 1.0 / secs_since_last_frame; + // TODO: can smooth and round this + self.diagnostics.fps = instantaneous_fps; + + // pixels/camera logic let frame = pixels.frame_mut(); frame.fill(0); - for y in 0..HEIGHT { - for x in 0..WIDTH { - let active = x % 2 == 0 - && y % 2 == 0 - && self.clicked.iter().all(|c| { - (((x as i64) - (c.x as i64)).pow(2) - + ((y as i64) - (c.y as i64)).pow(2)) - .isqrt() as f64 - > c.s - }); + // --TEST DRAWING-- + if self.input.is_lmb_down { + // start with the bounding box of the drawing brush circle + some margin + let bb_xl = self.input.last_mouse_pos_on_board.0 + - self.config.brush_size as i32 / 2 + - 5; + let bb_xu = self.input.last_mouse_pos_on_board.0 + + self.config.brush_size as i32 / 2 + + 5; + let bb_yl = self.input.last_mouse_pos_on_board.1 + - self.config.brush_size as i32 / 2 + - 5; + let bb_yu = self.input.last_mouse_pos_on_board.1 + + self.config.brush_size as i32 / 2 + + 5; - Self::set_px( - frame, - x, - y, - if active { self.red_level } else { 0x00 }, - if active { self.green_level } else { 0x00 }, - if active { self.blue_level } else { 0x00 }, - 0xFF, - ); + // for each point, check if the distance is less than the brush size and write the pixel + for x in bb_xl..bb_xu { + for y in bb_yl..bb_yu { + // brush/selection + if (((x - self.input.last_mouse_pos_on_board.0).pow(2) + + (y - self.input.last_mouse_pos_on_board.1).pow(2)) + as f32) + .sqrt() + < self.config.brush_size as f32 + { + board.set_cell_at_position( + x, + y, + sim::board::Cell { + material: 1, + velocity_x: 0, + velocity_y: 0, + flags: 0, + }, + ); + } + } } } - for i in 0..self.clicked.len() { - self.clicked[i].s -= 0.3; - if self.clicked[i].s <= 0.0 { - self.clicked.remove(i); - break; - } - } + let get_overlay = + create_compute_combined_overlay_offset(board, &self.config, &self.input); + write_frame_view(frame, board, camera, get_overlay); // egui logic let raw_input = egui_state.take_egui_input(window); let full_output = egui_context.run_ui(raw_input, |ui| { - ui.heading("Debug"); - ui.label( - self.clicked - .iter() - .map(|c| format!("x={x},y={y},s={s}", x = c.x, y = c.y, s = c.s)) - .collect::<Vec<String>>() - .join("\n"), - ); - ui.add(egui::Slider::new(&mut self.red_level, 0..=120).text("Red")); - ui.add(egui::Slider::new(&mut self.green_level, 0..=120).text("Green")); - ui.add(egui::Slider::new(&mut self.blue_level, 0..=120).text("Blue")); + let right_panel = egui::Panel::right(Id::new("right_panel")); + right_panel + .resizable(false) + // TODO collapse button + .show_collapsible(ui, &mut true, |panel_ui| { + draw_egui( + panel_ui, + &mut self.config, + &mut self.state, + camera, + &self.diagnostics, + &self.input, + ) + }); }); egui_state.handle_platform_output(window, full_output.platform_output); @@ -225,10 +301,8 @@ impl ApplicationHandler for App { } let _ = pixels.render_with(|encoder, render_target, context| { - // 1. pixels' own blit of the framebuffer to the surface context.scaling_renderer.render(encoder, render_target); - // 2. prep egui's GPU-side buffers for this frame's draws egui_renderer.update_buffers( device, &queue, @@ -237,15 +311,14 @@ impl ApplicationHandler for App { &screen_descriptor, ); - // 3. open a pass on the same target, preserving the blit - let mut pass = encoder + let mut egui_pass = encoder .begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("egui pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: render_target, resolve_target: None, ops: wgpu::Operations { - load: wgpu::LoadOp::Load, // keep the blit, don't clear + load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, depth_slice: None, @@ -255,10 +328,14 @@ impl ApplicationHandler for App { occlusion_query_set: None, multiview_mask: None, }) - .forget_lifetime(); // bridge wgpu 30's pass-lifetime requirement + .forget_lifetime(); - egui_renderer.render(&mut pass, &clipped_primitives, &screen_descriptor); - drop(pass); + egui_renderer.render( + &mut egui_pass, + &clipped_primitives, + &screen_descriptor, + ); + drop(egui_pass); Ok(()) }); @@ -270,9 +347,10 @@ impl ApplicationHandler for App { fn about_to_wait(&mut self, _: &ActiveEventLoop) { let now = Instant::now(); - // Limit to 60 FPS - if now - self.last_frame >= FRAME_DURATION { - self.last_frame = now; + let frame_duration: Duration = Duration::from_micros(1_000_000 / self.config.fps as u64); + // limit our internal redraw requests to (fps) + if now - self.last_frame_requested >= frame_duration { + self.last_frame_requested = now; self.window .expect("Bug - Window should exist") .request_redraw(); diff --git a/src/sim/board.rs b/src/sim/board.rs new file mode 100644 index 0000000..4cc5756 --- /dev/null +++ b/src/sim/board.rs @@ -0,0 +1,72 @@ +use crate::config::{PIXEL_BUFFER_HEIGHT, PIXEL_BUFFER_WIDTH}; + +type MaterialId = u16; + +#[derive(Clone, Copy)] +pub struct Cell { + pub material: MaterialId, + pub velocity_x: i8, + pub velocity_y: i8, + pub flags: u8, +} + +impl Cell { + fn empty() -> Cell { + Cell { + material: 0, + velocity_x: 0, + velocity_y: 0, + flags: 0, + } + } +} + +pub struct Board { + pub size_x: u32, + pub size_y: u32, + pub cells: Vec<Cell>, +} + +impl Board { + pub fn index_to_position(&self, idx: usize) -> (i32, i32) { + let y = idx / self.size_x as usize; + let x = idx % self.size_x as usize; + let board_x = x as i32 - self.size_x as i32 / 2; + let board_y = y as i32 - self.size_x as i32 / 2; + return (board_x, board_y); + } + pub fn set_cell_at_position(&mut self, x: i32, y: i32, c: Cell) { + // TODO: option? + let idx = self.position_to_index(x, y).unwrap(); + self.cells[idx] = c; + } + pub fn cell_at_position(&self, x: i32, y: i32) -> Option<&Cell> { + Some(&self.cells[self.position_to_index(x, y)?]) + } + pub fn position_to_index(&self, x: i32, y: i32) -> Option<usize> { + let board_x = x + self.size_x as i32 / 2; + let board_y = y + self.size_y as i32 / 2; + + let on_board = board_x >= 0 + && board_x < self.size_x as i32 + && board_y >= 0 + && board_y < self.size_y as i32; + + if !on_board { + return None; + } + + return Some((board_y * self.size_x as i32 + board_x) as usize); + } + pub fn empty() -> Board { + let size_x = PIXEL_BUFFER_WIDTH * 2; + let size_y = PIXEL_BUFFER_HEIGHT * 2; + let cells: Vec<Cell> = vec![Cell::empty(); (size_x * size_y) as usize]; + + Board { + size_x, + size_y, + cells, + } + } +} diff --git a/src/sim/materials.rs b/src/sim/materials.rs new file mode 100644 index 0000000..13a45f3 --- /dev/null +++ b/src/sim/materials.rs @@ -0,0 +1,22 @@ +#[derive(Clone, Copy)] +pub struct Material<'a> { + pub name: &'a str, + pub r: u8, + pub g: u8, + pub b: u8, +} + +pub static MATERIALS: [Material; 2] = [ + Material { + name: "Void", + r: 0x00, + g: 0x00, + b: 0x00, + }, + Material { + name: "Sand", + r: 0xFF, + g: 0x00, + b: 0xFF, + }, +]; diff --git a/src/sim/mod.rs b/src/sim/mod.rs new file mode 100644 index 0000000..2b29628 --- /dev/null +++ b/src/sim/mod.rs @@ -0,0 +1,4 @@ +pub mod board; +pub mod materials; +pub mod overlay; +pub mod sim; diff --git a/src/sim/overlay.rs b/src/sim/overlay.rs new file mode 100644 index 0000000..36a810b --- /dev/null +++ b/src/sim/overlay.rs @@ -0,0 +1,50 @@ +use crate::{Config, Input, sim::board::Board}; + +pub fn create_compute_combined_overlay_offset( + board: &Board, + config: &Config, + input: &Input, +) -> impl Fn(i32, i32) -> (u8, u8, u8, u8) { + |x: i32, y: i32| { + // could allow negative offsets too + let mut offset: (u8, u8, u8, u8) = (0x00, 0x00, 0x00, 0x00); + + // bounds + // left + let xl = -((board.size_x / 2 + 1) as i32); + // right + let xu = (board.size_x / 2 + 1) as i32; + // bottom + let yl = -((board.size_y / 2 + 1) as i32); + // top + let yu = (board.size_y / 2 + 1) as i32; + + if ((x == xl || x == xu) && (y <= yu && y >= yl)) + || (y == yl || y == yu) && (x <= xu && x >= xl) + { + offset.0 = offset.0.saturating_add(0xFF); + offset.1 = offset.1.saturating_add(0xFF); + offset.2 = offset.2.saturating_add(0xFF); + } + + // grid + if x % 30 == 0 || y % 30 == 0 { + offset.0 = offset.0.saturating_add(0x10); + offset.1 = offset.1.saturating_add(0x10); + offset.2 = offset.2.saturating_add(0x10); + } + + // brush/selection + if (((x - input.last_mouse_pos_on_board.0).pow(2) + + (y - input.last_mouse_pos_on_board.1).pow(2)) as f32) + .sqrt() + < config.brush_size as f32 + { + offset.0 = offset.0.saturating_add(0xAA); + offset.1 = offset.1.saturating_add(0x00); + offset.2 = offset.2.saturating_add(0xAA); + } + + return offset; + } +} diff --git a/src/sim/sim.rs b/src/sim/sim.rs new file mode 100644 index 0000000..bb68306 --- /dev/null +++ b/src/sim/sim.rs @@ -0,0 +1,3 @@ +use crate::Board; + +fn sim_tick(board: Board, seqno: u64) {} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..d55c5fb --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,33 @@ +use egui::Ui; + +use crate::{Config, Diagnostics, Input, State, camera::CameraState}; + +pub fn draw_egui( + ui: &mut Ui, + config: &mut Config, + state: &mut State, + camera: &mut CameraState, + diagnostics: &Diagnostics, + input: &Input, +) { + ui.heading("Config"); + ui.add(egui::Slider::new(&mut config.brush_size, 1..=100).text("Brush size")); + ui.add(egui::Slider::new(&mut config.fps, 1..=1000).text("Max FPS")); + ui.label(format!("Real FPS: {}", diagnostics.fps)); + ui.heading("Camera"); + ui.add(egui::Slider::new(&mut camera.zoom, 0.0..=10.0).text("Zoom")); + ui.add(egui::Slider::new(&mut camera.x, -1000.0..=1000.0).text("X")); + ui.add(egui::Slider::new(&mut camera.y, -1000.0..=1000.0).text("Y")); + ui.heading("Input"); + ui.label(format!( + "Mouse: x,y=({x}, {y}), lmb_pressed={lmb}", + x = input.last_mouse_pos_on_screen.0, + y = input.last_mouse_pos_on_screen.1, + lmb = input.is_lmb_down + )); + ui.label(format!( + "Mouse (board): x,y=({x}, {y})", + x = input.last_mouse_pos_on_board.0, + y = input.last_mouse_pos_on_board.1, + )); +} |
