From 4fea9d4220d074d8573100211b22210c855c2a8c Mon Sep 17 00:00:00 2001 From: hheik <4469778+hheik@users.noreply.github.com> Date: Sun, 25 Dec 2022 18:31:52 +0200 Subject: [PATCH] feat: basic liquid simulation --- Cargo.toml | 2 +- src/game.rs | 5 +- src/game/debug/terrain.rs | 34 +++--- src/terrain2d.rs | 181 +++++++++++++++++++++++++---- src/terrain2d/chunk2d.rs | 43 ++++--- src/terrain2d/terrain_gen2d.rs | 2 +- src/terrain2d/texel2d.rs | 5 +- src/terrain2d/texel_behaviour2d.rs | 29 ++++- src/util.rs | 1 + src/util/frame_counter.rs | 19 +++ 10 files changed, 252 insertions(+), 69 deletions(-) create mode 100644 src/util/frame_counter.rs diff --git a/Cargo.toml b/Cargo.toml index cfcf103..e609484 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ noise = "0.8.2" # Enable a small amount of optimization in debug mode [profile.dev] -opt-level = 0 +opt-level = 1 # Enable high optimizations for dependencies (incl. Bevy), but not for our code: [profile.dev.package."*"] diff --git a/src/game.rs b/src/game.rs index 21fc182..a4d93ff 100644 --- a/src/game.rs +++ b/src/game.rs @@ -2,8 +2,8 @@ use bevy::prelude::*; use bevy_rapier2d::prelude::*; use crate::{ - terrain2d::{Chunk2D, Terrain2D, Terrain2DPlugin, TerrainGen2D}, - util::Vector2I, + terrain2d::*, + util::{frame_counter::FrameCounterPlugin, Vector2I}, }; use self::{ @@ -21,6 +21,7 @@ pub mod player; pub fn init() { App::new() .add_plugins(DefaultPlugins) + .add_plugin(FrameCounterPlugin) .add_plugin(RapierPhysicsPlugin::::default()) .add_plugin(Terrain2DPlugin) .add_plugin(DebugPlugin) diff --git a/src/game/debug/terrain.rs b/src/game/debug/terrain.rs index 47a50ee..8bb3409 100644 --- a/src/game/debug/terrain.rs +++ b/src/game/debug/terrain.rs @@ -8,10 +8,7 @@ impl Plugin for TerrainDebugPlugin { fn build(&self, app: &mut App) { app.insert_resource(TerrainBrush2D::default()) .add_system(debug_painter) - .add_system_to_stage( - TerrainStages::First, - dirty_rect_visualizer, - ); + .add_system_to_stage(TerrainStages::EventHandler, dirty_rect_visualizer); } } @@ -92,16 +89,18 @@ fn debug_painter( if key_input.just_pressed(KeyCode::Key3) { brush.tile = 3; } - - let colors = vec![ - Color::rgba(1.0, 0.25, 0.25, 1.0), - Color::rgba(0.25, 1.0, 0.25, 1.0), - Color::rgba(0.25, 0.25, 1.0, 1.0), - ]; + if key_input.just_pressed(KeyCode::Key4) { + brush.tile = 4; + } + if key_input.just_pressed(KeyCode::Key5) { + brush.tile = 5; + } + if key_input.just_pressed(KeyCode::Key6) { + brush.tile = 6; + } let origin = Vector2I::from(world_pos); let radius = brush.radius; - let color = colors[brush.tile as usize % colors.len()]; let id = match ( mouse_input.pressed(MouseButton::Left), mouse_input.pressed(MouseButton::Right), @@ -109,6 +108,8 @@ fn debug_painter( (true, false) => brush.tile, (_, _) => 0, }; + let color = TexelBehaviour2D::from_id(&brush.tile) + .map_or(Color::rgba(0.0, 0.0, 0.0, 0.0), |tb| tb.color); for y in origin.y - (radius - 1)..origin.y + radius { for x in origin.x - (radius - 1)..origin.x + radius { @@ -118,14 +119,19 @@ fn debug_painter( if dx * dx + dy * dy <= (radius - 1) * (radius - 1) { let pos: Vector2I = Vector2I { x, y }; debug_draw.line_colored( - Vec3::from(pos) + Vec3::new(0.45, 0.45, 0.0), - Vec3::from(pos) + Vec3::new(0.55, 0.55, 0.0), + Vec3::from(pos) + Vec3::new(0.45, 0.45, 1.0), + Vec3::from(pos) + Vec3::new(0.55, 0.55, 1.0), 0.0, color, ); if mouse_input.pressed(MouseButton::Left) || mouse_input.pressed(MouseButton::Right) { - terrain.set_texel(&pos, id) + // 6 is special + if id == 6 { + terrain.mark_dirty(&pos) + } else { + terrain.set_texel(&pos, id, None) + } } } } diff --git a/src/terrain2d.rs b/src/terrain2d.rs index 9e14fb2..9f76923 100644 --- a/src/terrain2d.rs +++ b/src/terrain2d.rs @@ -17,37 +17,38 @@ pub use terrain_gen2d::*; pub use texel2d::*; pub use texel_behaviour2d::*; -use crate::util::{math::*, Vector2I}; +use crate::util::{frame_counter::FrameCounter, math::*, Vector2I}; pub struct Terrain2DPlugin; impl Plugin for Terrain2DPlugin { fn build(&self, app: &mut App) { - // Add terrain stages. They should go between CoreStage::Update and Rapier's own stages + // Add terrain stages + app.add_stage_before( + CoreStage::Update, + TerrainStages::Simulation, + SystemStage::parallel(), + ); + // After update, but before rapier app.add_stage_before( PhysicsStages::SyncBackend, - TerrainStages::First, - SystemStage::parallel(), - ).add_stage_after( - TerrainStages::First, TerrainStages::EventHandler, SystemStage::parallel(), - ).add_stage_after( + ) + .add_stage_after( TerrainStages::EventHandler, TerrainStages::ChunkSync, SystemStage::parallel(), - ).add_stage_after( - TerrainStages::ChunkSync, - TerrainStages::Last, - SystemStage::parallel(), ); app.register_type::() .insert_resource(Terrain2D::new()) .add_event::() + .add_system_to_stage(TerrainStages::Simulation, terrain_simulation) .add_system_to_stage(TerrainStages::EventHandler, emit_terrain_events) .add_system_to_stage( TerrainStages::EventHandler, + // TODO: Figure out why .after() creates a lagspike for the first frame chunk_spawner.before(emit_terrain_events), ) .add_system_to_stage(TerrainStages::ChunkSync, chunk_sprite_sync) @@ -57,14 +58,115 @@ impl Plugin for Terrain2DPlugin { #[derive(StageLabel)] pub enum TerrainStages { - /// First of terrain stages to be called - First, - /// The stage that Handles collected events and creates new chunk entities as needed + /// Terrain simulation stage. Should run before update. + Simulation, + /// The stage that Handles collected events and creates new chunk entities as needed. Should run after update. EventHandler, - /// Chunk sync systems (e.g. collsion and sprite) run in this stage + /// Chunk sync systems (e.g. collsion and sprite) run in this stage. ChunkSync, - /// Last of terrain stages to be called - Last, +} + +// TODO: Add simulation boundaries +fn terrain_simulation(mut terrain: ResMut, frame_counter: Res) { + let simulation_frame = (frame_counter.frame % u8::MAX as u64) as u8 + 1; + + let indices = terrain + .chunk_iter() + .map(|(chunk_index, _)| *chunk_index) + .collect::>() + .clone(); + + for chunk_index in indices.iter() { + // // DEBUG: mark few chunks dirty in interval + // if let Some(chunk) = terrain.index_to_chunk_mut(&chunk_index) { + // let interval = 2; + // if frame_counter.frame % interval == 0 { + // let i = ((frame_counter.frame / interval) % 100) as i32; + // if (chunk_index.y % 10) * 10 + (chunk_index.x % 10) == i { + // chunk.mark_all_dirty(); + // println!("chunk {:?} is now dirty", chunk_index); + // } + // } + // }; + + if let Some(rect) = &terrain + .index_to_chunk(&chunk_index) + .map_or(None, |chunk| chunk.dirty_rect.clone()) + { + if let Some(chunk) = terrain.index_to_chunk_mut(&chunk_index) { + chunk.mark_clean(); + }; + let mut y_range: Vec<_> = (rect.min.y..rect.max.y + 1).collect(); + let mut x_range: Vec<_> = (rect.min.x..rect.max.x + 1).collect(); + + if frame_counter.frame % 2 == 0 { + y_range.reverse(); + } + if frame_counter.frame / 2 % 2 == 0 { + x_range.reverse(); + } + + for y in y_range.iter() { + 'texel_loop: for x in x_range.iter() { + let local = Vector2I::new(*x, *y); + let global = local_to_global(&local, &chunk_index); + + let texel = if let Some(texel) = terrain.get_texel(&global) { + if texel.last_simulation == simulation_frame { + continue 'texel_loop; + } + texel + } else { + continue; + }; + let tb = if let Some(tb) = TexelBehaviour2D::from_id(&texel.id) { + tb + } else { + continue; + }; + + match tb.form { + TexelForm::Liquid => { + // Check if there is space below + { + let below_pos = global + Vector2I::DOWN; + if terrain.get_texel(&below_pos).map_or(true, |texel| { + TexelBehaviour2D::is_empty(&texel.id) + || TexelBehaviour2D::is_gas(&texel.id) + }) { + let below_id = + terrain.get_texel(&below_pos).map_or(0, |texel| texel.id); + terrain.set_texel(&below_pos, texel.id, Some(simulation_frame)); + terrain.set_texel(&global, below_id, Some(simulation_frame)); + continue; + } + } + + // Check if there is space to the side + let mut dirs = vec![Vector2I::RIGHT, Vector2I::LEFT]; + if (frame_counter.frame / 3) % 2 == 0 { + dirs.reverse(); + } + for dir in dirs.iter() { + let side_pos = global + *dir; + if terrain.get_texel(&side_pos).map_or(true, |texel| { + TexelBehaviour2D::is_empty(&texel.id) + || TexelBehaviour2D::is_gas(&texel.id) + }) { + let side_id = + terrain.get_texel(&side_pos).map_or(0, |texel| texel.id); + terrain.set_texel(&side_pos, texel.id, Some(simulation_frame)); + terrain.set_texel(&global, side_id, Some(simulation_frame)); + continue 'texel_loop; + }; + } + } + _ => (), + } + } + } + } + } } fn emit_terrain_events( @@ -77,7 +179,6 @@ fn emit_terrain_events( for (chunk_index, chunk) in terrain.chunk_iter_mut() { if let Some(rect) = &chunk.dirty_rect { terrain_events.send(TerrainEvent2D::TexelsUpdated(*chunk_index, *rect)); - chunk.mark_clean(); } } } @@ -150,15 +251,34 @@ impl Terrain2D { } } - pub fn set_texel(&mut self, global: &Vector2I, id: TexelID) { + pub fn mark_dirty(&mut self, global: &Vector2I) { let index = global_to_chunk_index(global); - match self.index_to_chunk_mut(&index) { - Some(chunk) => chunk.set_texel(&global_to_local(global), id), + if let Some(chunk) = self.index_to_chunk_mut(&index) { + chunk.mark_dirty(&global_to_local(global)); + } + } + + pub fn get_texel(&self, global: &Vector2I) -> Option { + self.global_to_chunk(global) + .map_or(None, |chunk| chunk.get_texel(&global_to_local(global))) + } + + pub fn set_texel(&mut self, global: &Vector2I, id: TexelID, simulation_frame: Option) { + let index = global_to_chunk_index(global); + let changed = match self.index_to_chunk_mut(&index) { + Some(chunk) => chunk.set_texel(&global_to_local(global), id, simulation_frame), None => { let mut chunk = Chunk2D::new(); - chunk.set_texel(&global_to_local(global), id); + let changed = chunk.set_texel(&global_to_local(global), id, simulation_frame); self.add_chunk(index, chunk); + changed } + }; + if changed { + self.mark_dirty(&(*global + Vector2I::UP)); + self.mark_dirty(&(*global + Vector2I::RIGHT)); + self.mark_dirty(&(*global + Vector2I::DOWN)); + self.mark_dirty(&(*global + Vector2I::LEFT)); } } } @@ -181,10 +301,21 @@ pub fn texel_index_to_local(i: usize) -> Vector2I { } } -pub fn global_to_local(position: &Vector2I) -> Vector2I { +pub fn texel_index_to_global(i: usize, chunk_index: &Chunk2DIndex) -> Vector2I { Vector2I { - x: wrapping_remainder(position.x, Chunk2D::SIZE.x), - y: wrapping_remainder(position.y, Chunk2D::SIZE.y), + x: i as i32 % Chunk2D::SIZE.x, + y: i as i32 / Chunk2D::SIZE.y, + } + chunk_index_to_global(chunk_index) +} + +pub fn local_to_global(local: &Vector2I, chunk_index: &Chunk2DIndex) -> Vector2I { + chunk_index_to_global(chunk_index) + *local +} + +pub fn global_to_local(global: &Vector2I) -> Vector2I { + Vector2I { + x: wrapping_remainder(global.x, Chunk2D::SIZE.x), + y: wrapping_remainder(global.y, Chunk2D::SIZE.y), } } diff --git a/src/terrain2d/chunk2d.rs b/src/terrain2d/chunk2d.rs index 8c745a1..2f0ea94 100644 --- a/src/terrain2d/chunk2d.rs +++ b/src/terrain2d/chunk2d.rs @@ -87,6 +87,15 @@ pub struct ChunkRect { pub max: Vector2I, } +impl ChunkRect { + pub fn include_point(&self, point: Vector2I) -> Self { + ChunkRect { + min: Vector2I::min(&self.min, &point), + max: Vector2I::max(&self.max, &point), + } + } +} + pub struct Chunk2D { pub texels: [Texel2D; (Self::SIZE_X * Self::SIZE_Y) as usize], // TODO: handle multiple dirty rects @@ -115,7 +124,7 @@ impl Chunk2D { }; for y in 0..Self::SIZE_Y { for x in 0..Self::SIZE_X { - chunk.set_texel(&Vector2I::new(x as i32, y as i32), 1); + chunk.set_texel(&Vector2I::new(x as i32, y as i32), 1, None); } } chunk @@ -129,7 +138,7 @@ impl Chunk2D { for y in 0..Self::SIZE_Y { for x in 0..Self::SIZE_X { if x <= Self::SIZE_Y - y { - chunk.set_texel(&Vector2I::new(x as i32, y as i32), 1); + chunk.set_texel(&Vector2I::new(x as i32, y as i32), 1, None); } } } @@ -148,7 +157,7 @@ impl Chunk2D { let dx = (x as i32 - origin.x).abs(); let dy = (y as i32 - origin.y).abs(); if dx * dx + dy * dy <= (radius - 1) * (radius - 1) { - chunk.set_texel(&Vector2I::new(x as i32, y as i32), 1); + chunk.set_texel(&Vector2I::new(x as i32, y as i32), 1, None); } } } @@ -175,18 +184,13 @@ impl Chunk2D { pub fn mark_all_dirty(&mut self) { self.dirty_rect = Some(ChunkRect { min: Vector2I::ZERO, - max: Self::SIZE, + max: Self::SIZE - Vector2I::ONE, }); } pub fn mark_dirty(&mut self, position: &Vector2I) { match &self.dirty_rect { - Some(rect) => { - self.dirty_rect = Some(ChunkRect { - min: Vector2I::min(&rect.min, position), - max: Vector2I::max(&rect.max, position), - }) - } + Some(rect) => self.dirty_rect = Some(rect.include_point(*position)), None => { self.dirty_rect = Some(ChunkRect { min: *position, @@ -208,18 +212,18 @@ impl Chunk2D { local_to_texel_index(position).map(|i| &mut self.texels[i]) } - pub fn set_texel(&mut self, position: &Vector2I, id: TexelID) { + pub fn set_texel(&mut self, position: &Vector2I, id: TexelID, simulation_frame: Option) -> bool { let i = local_to_texel_index(position).expect("Texel index out of range"); if self.texels[i].id != id { self.mark_dirty(position); } - let update_neighbours = self.texels[i].is_empty() - != (Texel2D { - id, - ..self.texels[i] - }) - .is_empty(); + let update_neighbours = + TexelBehaviour2D::is_solid(&self.texels[i].id) != TexelBehaviour2D::is_solid(&id); + let changed = self.texels[i].id != id; self.texels[i].id = id; + if let Some(simulation_frame) = simulation_frame { + self.texels[i].last_simulation = simulation_frame; + } // Update neighbour mask if update_neighbours { for offset in Texel2D::NEIGHBOUR_OFFSET_VECTORS { @@ -232,6 +236,7 @@ impl Chunk2D { } } } + changed } pub fn create_texture_data(&self) -> Vec { @@ -275,7 +280,7 @@ impl Chunk2D { | if local.x == 0 { 1 << 3 } else { 0 }; let mut sides: Vec; - if self.texels[i].is_empty() { + if !TexelBehaviour2D::is_solid(&self.texels[i].id) { sides = MST_CASE_MAP[self.texels[i].neighbour_mask as usize] .iter() .clone() @@ -284,7 +289,7 @@ impl Chunk2D { to: side.to + local, }) .collect(); - } else if !self.texels[i].is_empty() && edge_mask != 0 { + } else if TexelBehaviour2D::is_solid(&self.texels[i].id) && edge_mask != 0 { sides = Vec::with_capacity(Chunk2D::SIZE_X * 2 + Chunk2D::SIZE_Y * 2); for i in 0..MST_EDGE_CASE_MAP.len() { if edge_mask & (1 << i) != 0 { diff --git a/src/terrain2d/terrain_gen2d.rs b/src/terrain2d/terrain_gen2d.rs index 9ba4b2e..effb239 100644 --- a/src/terrain2d/terrain_gen2d.rs +++ b/src/terrain2d/terrain_gen2d.rs @@ -39,7 +39,7 @@ impl TerrainGen2D { id = 3; } - chunk.set_texel(&local, id); + chunk.set_texel(&local, id, None); } chunk } diff --git a/src/terrain2d/texel2d.rs b/src/terrain2d/texel2d.rs index 1de5059..e823aa6 100644 --- a/src/terrain2d/texel2d.rs +++ b/src/terrain2d/texel2d.rs @@ -11,6 +11,7 @@ pub struct Texel2D { pub id: TexelID, /// bitmask of empty/non-empty neighbours, see NEIGHBOUR_OFFSET_VECTORS for the order pub neighbour_mask: NeighbourMask, + pub last_simulation: u8, } lazy_static! { @@ -31,8 +32,4 @@ impl Texel2D { Vector2I { x: 0, y: -1 }, Vector2I { x: -1, y: 0 }, ]; - - pub fn is_empty(&self) -> bool { - self.id == 0 - } } diff --git a/src/terrain2d/texel_behaviour2d.rs b/src/terrain2d/texel_behaviour2d.rs index caf1e00..469d790 100644 --- a/src/terrain2d/texel_behaviour2d.rs +++ b/src/terrain2d/texel_behaviour2d.rs @@ -21,9 +21,15 @@ lazy_static! { color: Color::rgb(0.11, 0.11, 0.11), ..default() }); - + result.insert(4, TexelBehaviour2D { - color: Color::rgb(1.0, 0.0, 0.0), + color: Color::rgb(0.0, 0.0, 1.0), + form: TexelForm::Liquid, + ..default() + }); + + result.insert(5, TexelBehaviour2D { + color: Color::rgb(0.0, 1.0, 0.0), form: TexelForm::Gas, ..default() }); @@ -32,7 +38,7 @@ lazy_static! { }; } -#[derive(Clone, Copy, Default)] +#[derive(Clone, Copy, Default, PartialEq)] pub enum TexelForm { #[default] Solid, @@ -48,8 +54,25 @@ pub struct TexelBehaviour2D { pub color: Color, } +// TODO: change form-based functions like is_solid to behaviour based (e.g. has_collision) impl TexelBehaviour2D { pub fn from_id(id: &TexelID) -> Option { ID_MAP.get(id).copied() } + + pub fn is_empty(id: &TexelID) -> bool { + ID_MAP.get(id).is_none() + } + + pub fn is_solid(id: &TexelID) -> bool { + ID_MAP.get(id).map_or(false, |tb| tb.form == TexelForm::Solid) + } + + pub fn is_liquid(id: &TexelID) -> bool { + ID_MAP.get(id).map_or(false, |tb| tb.form == TexelForm::Liquid) + } + + pub fn is_gas(id: &TexelID) -> bool { + ID_MAP.get(id).map_or(false, |tb| tb.form == TexelForm::Gas) + } } diff --git a/src/util.rs b/src/util.rs index 3e48652..6ad14a4 100644 --- a/src/util.rs +++ b/src/util.rs @@ -5,6 +5,7 @@ pub mod math; mod segment2_i32; mod vector2; mod vector2_i32; +pub mod frame_counter; pub use collision_layers::*; pub use segment2_i32::*; diff --git a/src/util/frame_counter.rs b/src/util/frame_counter.rs new file mode 100644 index 0000000..caa5dd4 --- /dev/null +++ b/src/util/frame_counter.rs @@ -0,0 +1,19 @@ +use bevy::prelude::*; + +pub struct FrameCounterPlugin; + +impl Plugin for FrameCounterPlugin { + fn build(&self, app: &mut App) { + app.insert_resource(FrameCounter { frame: 0 }) + .add_system_to_stage(CoreStage::First, frame_increment); + } +} + +#[derive(Resource)] +pub struct FrameCounter { + pub frame: u64, +} + +fn frame_increment(mut frame_counter: ResMut) { + frame_counter.frame += 1; +}