feat: basic liquid simulation

feat/simulation
hheik 2022-12-25 18:31:52 +02:00
parent 5f2b2b9d06
commit 4fea9d4220
10 changed files with 252 additions and 69 deletions

View File

@ -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."*"]

View File

@ -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::<NoUserData>::default())
.add_plugin(Terrain2DPlugin)
.add_plugin(DebugPlugin)

View File

@ -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)
}
}
}
}

View File

@ -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::<TerrainChunk2D>()
.insert_resource(Terrain2D::new())
.add_event::<TerrainEvent2D>()
.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<Terrain2D>, frame_counter: Res<FrameCounter>) {
let simulation_frame = (frame_counter.frame % u8::MAX as u64) as u8 + 1;
let indices = terrain
.chunk_iter()
.map(|(chunk_index, _)| *chunk_index)
.collect::<Vec<Chunk2DIndex>>()
.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<Texel2D> {
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<u8>) {
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),
}
}

View File

@ -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<u8>) -> 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<u8> {
@ -275,7 +280,7 @@ impl Chunk2D {
| if local.x == 0 { 1 << 3 } else { 0 };
let mut sides: Vec<Segment2I>;
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 {

View File

@ -39,7 +39,7 @@ impl TerrainGen2D {
id = 3;
}
chunk.set_texel(&local, id);
chunk.set_texel(&local, id, None);
}
chunk
}

View File

@ -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
}
}

View File

@ -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<Self> {
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)
}
}

View File

@ -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::*;

19
src/util/frame_counter.rs Normal file
View File

@ -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<FrameCounter>) {
frame_counter.frame += 1;
}