From a304f14f52067ae264bbd04f20498a2701679aba Mon Sep 17 00:00:00 2001 From: Purdze Date: Thu, 2 Jul 2026 21:53:15 +0100 Subject: [PATCH 1/3] Block break particles --- pomme-client/build.rs | 2 + pomme-client/src/app/core.rs | 29 ++ pomme-client/src/app/phases/in_game.rs | 32 +- pomme-client/src/main.rs | 1 + pomme-client/src/net/handler.rs | 7 + pomme-client/src/net/mod.rs | 5 + pomme-client/src/particle.rs | 335 ++++++++++++++ pomme-client/src/player/interaction.rs | 34 +- pomme-client/src/renderer/camera.rs | 21 + pomme-client/src/renderer/chunk/mesher.rs | 57 ++- pomme-client/src/renderer/mod.rs | 31 ++ pomme-client/src/renderer/pipelines/mod.rs | 1 + .../src/renderer/pipelines/particle.rs | 419 ++++++++++++++++++ .../src/renderer/shaders/particle.frag | 21 + .../src/renderer/shaders/particle.vert | 29 ++ pomme-client/src/world/block/model.rs | 12 + pomme-client/src/world/block/registry.rs | 7 +- 17 files changed, 1017 insertions(+), 26 deletions(-) create mode 100644 pomme-client/src/particle.rs create mode 100644 pomme-client/src/renderer/pipelines/particle.rs create mode 100644 pomme-client/src/renderer/shaders/particle.frag create mode 100644 pomme-client/src/renderer/shaders/particle.vert diff --git a/pomme-client/build.rs b/pomme-client/build.rs index bbeafed0..84413e40 100644 --- a/pomme-client/build.rs +++ b/pomme-client/build.rs @@ -62,6 +62,8 @@ fn main() { ("item_entity.frag", shaderc::ShaderKind::Fragment), ("weather.vert", shaderc::ShaderKind::Vertex), ("weather.frag", shaderc::ShaderKind::Fragment), + ("particle.vert", shaderc::ShaderKind::Vertex), + ("particle.frag", shaderc::ShaderKind::Fragment), ("clouds.vert", shaderc::ShaderKind::Vertex), ("clouds.frag", shaderc::ShaderKind::Fragment), ]; diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index 5be13cd0..6fc43947 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -764,6 +764,30 @@ impl AppCore { .update_living_rotation(id, y_rot_deg, x_rot_deg); game.item_entity_store.teleport(id, position); } + NetworkEvent::LevelEvent { + event_type, + pos, + data, + } => { + // Vanilla `LevelEventHandler` case 2001 (block break). + // The server excludes the breaking player from the + // broadcast; the local break's effects come from + // `predict_destroy`. TODO: the other level events. + if event_type == 2001 + && let Ok(state) = azalea_block::BlockState::try_from(data) + { + if !state.is_air() { + crate::player::interaction::play_break_sound(&self.audio, state, pos); + } + game.particle_store.add_destroy_block_effect( + pos, + state, + renderer.registry(), + &game.chunk_store, + &game.biome_climate, + ); + } + } NetworkEvent::EntitiesRemoved { ids } => { for id in &ids { if let Some(entity) = game.entity_store.remove_living(*id) @@ -1092,6 +1116,11 @@ impl AppCore { game.player.game_mode == 1, input.selected_slot(), place_block, + &mut crate::player::interaction::BreakEffects { + particles: &mut game.particle_store, + registry: renderer.registry(), + biome_climate: &game.biome_climate, + }, ); if !dirty.is_empty() { let min_y = game.chunk_store.min_y(); diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index d4c93326..f33585a0 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -89,6 +89,7 @@ pub struct GameState { pub server_render_distance: u32, pub server_simulation_distance: u32, pub item_entity_store: ItemEntityStore, + pub particle_store: crate::particle::ParticleStore, pub block_entity_anim: BlockEntityAnimStore, pub benchmark: Option, pub benchmark_result: Option, @@ -172,6 +173,15 @@ impl GameState { server_render_distance: 0, server_simulation_distance: 0, item_entity_store: ItemEntityStore::new(), + particle_store: { + let (grass, foliage, dry_foliage) = mesh_dispatcher.colormaps(); + crate::particle::ParticleStore::new( + renderer.atlas_uv_map().clone(), + grass, + foliage, + dry_foliage, + ) + }, block_entity_anim: BlockEntityAnimStore::default(), player: LocalPlayer::new(), biome_climate: Arc::new(HashMap::new()), @@ -778,6 +788,7 @@ pub fn update_game( while core.tick_accumulator >= TICK_RATE { core.tick_physics(&mut gfx.renderer, connection, game); game.item_entity_store.tick(); + game.particle_store.tick(&game.chunk_store); game.block_entity_anim.tick(); core.tick_accumulator -= TICK_RATE; } @@ -1458,6 +1469,12 @@ pub fn update_game( ) }; + let particle_quads = if benchmark_running { + Vec::new() + } else { + game.particle_store.extract(partial_tick) + }; + let effective_rd = if game.server_render_distance > 0 { core.menu.render_distance.min(game.server_render_distance) } else { @@ -1493,6 +1510,7 @@ pub fn update_game( &entity_renders, &item_renders, &block_entity_renders, + &particle_quads, &weather_columns, if benchmark_running { crate::renderer::CloudMode::Off @@ -1637,14 +1655,12 @@ fn stack_render_count(count: i32) -> usize { } fn get_entity_light(chunk_store: &ChunkStore, pos: Position) -> f32 { - use crate::renderer::chunk::mesher::LIGHT_TABLE; - let bx = pos.x.floor() as i32; - let by = pos.y.floor() as i32; - let bz = pos.z.floor() as i32; - let level = chunk_store - .get_sky_light(bx, by, bz) - .max(chunk_store.get_block_light(bx, by, bz)); - LIGHT_TABLE[level as usize] + crate::renderer::chunk::mesher::world_brightness( + chunk_store, + pos.x.floor() as i32, + pos.y.floor() as i32, + pos.z.floor() as i32, + ) } /// Builds the rain/snow columns in a square around the camera (vanilla diff --git a/pomme-client/src/main.rs b/pomme-client/src/main.rs index 84e39bae..6e894f90 100644 --- a/pomme-client/src/main.rs +++ b/pomme-client/src/main.rs @@ -9,6 +9,7 @@ mod entity; mod lang; mod logging; mod net; +mod particle; mod physics; mod player; mod renderer; diff --git a/pomme-client/src/net/handler.rs b/pomme-client/src/net/handler.rs index 72472c98..d4c6334c 100644 --- a/pomme-client/src/net/handler.rs +++ b/pomme-client/src/net/handler.rs @@ -337,6 +337,13 @@ pub fn handle_game_packet( x_rot_deg: p.values.look_direction.x_rot(), }); } + ClientboundGamePacket::LevelEvent(p) => { + let _ = event_tx.try_send(NetworkEvent::LevelEvent { + event_type: p.event_type, + pos: p.pos, + data: p.data, + }); + } ClientboundGamePacket::RemoveEntities(p) => { let ids: Vec = p.entity_ids.iter().map(|id| id.0).collect(); let _ = event_tx.try_send(NetworkEvent::EntitiesRemoved { ids }); diff --git a/pomme-client/src/net/mod.rs b/pomme-client/src/net/mod.rs index d4cd9173..22b2c63b 100644 --- a/pomme-client/src/net/mod.rs +++ b/pomme-client/src/net/mod.rs @@ -166,6 +166,11 @@ pub enum NetworkEvent { y_rot_deg: f32, x_rot_deg: f32, }, + LevelEvent { + event_type: u32, + pos: BlockPos, + data: u32, + }, EntitiesRemoved { ids: Vec, }, diff --git a/pomme-client/src/particle.rs b/pomme-client/src/particle.rs new file mode 100644 index 00000000..217430b0 --- /dev/null +++ b/pomme-client/src/particle.rs @@ -0,0 +1,335 @@ +//! Block-break particles: a port of vanilla `TerrainParticle`, +//! `Particle`, and `ClientLevel.addDestroyBlockEffect`. + +use std::collections::HashMap; +use std::sync::Arc; + +use azalea_block::BlockState; +use azalea_core::position::BlockPos; +use glam::DVec3; + +use crate::physics::aabb::Aabb; +use crate::physics::block_shape::{self, LocalBox}; +use crate::physics::collision::resolve_collision; +use crate::renderer::ParticleQuad; +use crate::renderer::chunk::atlas::{AtlasRegion, AtlasUVMap}; +use crate::renderer::pipelines::particle::MAX_PARTICLE_QUADS as MAX_PARTICLES; +use crate::renderer::chunk::mesher::{ + BiomeClimate, Colormap, dry_foliage_color, foliage_color, grass_color, world_brightness, +}; +use crate::world::block::registry::{BlockRegistry, Tint}; +use crate::world::chunk::ChunkStore; + +/// Vanilla `ParticleGroup.RESERVOIR_START` — above this, new particles are +/// probabilistically dropped. +const RESERVOIR_START: usize = 12288; +/// Vanilla `Particle.MAXIMUM_COLLISION_VELOCITY_SQUARED` (100²). +const MAX_COLLISION_VELOCITY_SQ: f64 = 10000.0; +/// Terrain particles use the default 0.2-wide, 0.2-tall bounding box. +const HALF_WIDTH: f64 = 0.1; + +pub struct Particle { + /// Bounding-box bottom-center, like vanilla `Particle.setPos`. + pos: DVec3, + prev_pos: DVec3, + vel: DVec3, + age: i32, + lifetime: i32, + on_ground: bool, + stopped_by_collision: bool, + /// Vanilla `quadSize`; the billboard spans twice this. + size: f32, + u0: f32, + u1: f32, + v0: f32, + v1: f32, + color: [f32; 3], + light: f32, +} + +impl Particle { + /// Vanilla `TerrainParticle` constructor chain: velocity jitter + + /// normalization from `Particle(level, x, y, z, xa, ya, za)`, then the + /// terrain quad size and random quarter sub-tile. + fn terrain( + pos: DVec3, + velocity_arg: DVec3, + region: AtlasRegion, + color: [f32; 3], + light: f32, + ) -> Self { + let jitter = || (fastrand::f64() * 2.0 - 1.0) * 0.4; + let dir = velocity_arg + DVec3::new(jitter(), jitter(), jitter()); + let speed = (fastrand::f64() + fastrand::f64() + 1.0) * 0.15; + let mut vel = dir / dir.length() * speed * 0.4; + vel.y += 0.1; + + let lifetime = (4.0 / (fastrand::f32() * 0.9 + 0.1)) as i32; + // SingleQuadParticle base size, halved by TerrainParticle. + let size = 0.1 * (fastrand::f32() * 0.5 + 0.5) * 2.0 / 2.0; + + // Random quarter sub-tile of the block sprite. The u0/u1 flip is + // vanilla (`getU0` samples uo+1, `getU1` samples uo). + let sprite_u = |f: f32| region.u_min + f * (region.u_max - region.u_min); + let sprite_v = |f: f32| region.v_min + f * (region.v_max - region.v_min); + let uo = fastrand::f32() * 3.0; + let vo = fastrand::f32() * 3.0; + + Self { + pos, + prev_pos: pos, + vel, + age: 0, + lifetime, + on_ground: false, + stopped_by_collision: false, + size, + u0: sprite_u((uo + 1.0) / 4.0), + u1: sprite_u(uo / 4.0), + v0: sprite_v(vo / 4.0), + v1: sprite_v((vo + 1.0) / 4.0), + color, + light, + } + } + + /// Vanilla `Particle.tick` (gravity 1.0, friction 0.98). Returns false + /// when the particle expires. + fn tick(&mut self, chunks: &ChunkStore) -> bool { + self.prev_pos = self.pos; + if self.age >= self.lifetime { + return false; + } + self.age += 1; + self.vel.y -= 0.04; + self.move_with_collision(chunks); + self.vel *= 0.98; + if self.on_ground { + self.vel.x *= 0.7; + self.vel.z *= 0.7; + } + self.light = world_brightness( + chunks, + self.pos.x.floor() as i32, + self.pos.y.floor() as i32, + self.pos.z.floor() as i32, + ); + true + } + + /// Vanilla `Particle.move`. + fn move_with_collision(&mut self, chunks: &ChunkStore) { + if self.stopped_by_collision { + return; + } + let orig = self.vel; + let mut delta = orig; + if delta != DVec3::ZERO && delta.length_squared() < MAX_COLLISION_VELOCITY_SQ { + let aabb = Aabb::from_center(self.pos, HALF_WIDTH, HALF_WIDTH); + (delta, _) = resolve_collision(chunks, aabb, orig.into(), 0.0); + } + self.pos += delta; + if orig.y.abs() >= 1e-5 && delta.y.abs() < 1e-5 { + self.stopped_by_collision = true; + } + self.on_ground = orig.y != delta.y && orig.y < 0.0; + if orig.x != delta.x { + self.vel.x = 0.0; + } + if orig.z != delta.z { + self.vel.z = 0.0; + } + } +} + +pub struct ParticleStore { + particles: Vec, + /// Spawned this tick; drained after live particles tick, so a particle's + /// first physics tick is the tick after it spawns (vanilla + /// `ParticleEngine.particlesToAdd`). + pending: Vec, + uv_map: AtlasUVMap, + grass_colormap: Arc, + foliage_colormap: Arc, + dry_foliage_colormap: Arc, +} + +impl ParticleStore { + pub fn new( + uv_map: AtlasUVMap, + grass_colormap: Arc, + foliage_colormap: Arc, + dry_foliage_colormap: Arc, + ) -> Self { + Self { + particles: Vec::new(), + pending: Vec::new(), + uv_map, + grass_colormap, + foliage_colormap, + dry_foliage_colormap, + } + } + + /// Vanilla `ClientLevel.addDestroyBlockEffect`: a grid of terrain + /// particles across each box of the block's shape (4x4x4 for a full + /// cube). + pub fn add_destroy_block_effect( + &mut self, + pos: BlockPos, + state: BlockState, + registry: &BlockRegistry, + chunks: &ChunkStore, + biome_climate: &HashMap, + ) { + if state.is_air() { + return; + } + let block: Box = state.into(); + // The only vanilla `noTerrainParticles` blocks. + if matches!(block.id(), "barrier" | "structure_void") { + return; + } + + // TerrainParticle base grey, multiplied by the block's biome tint. + // grass_block is exempt (vanilla `colorAsTerrainParticle` returns + // white for it — its particle texture is dirt). + let mut color = [0.6f32; 3]; + let faces = registry.get_textures(state); + if let Some(faces) = faces + && faces.tint != Tint::None + && block.id() != "grass_block" + { + let tint = self.blend_tint(faces.tint, pos, chunks, biome_climate); + for (c, t) in color.iter_mut().zip(tint) { + *c *= t; + } + } + + let region = match faces { + Some(faces) => self + .uv_map + .get_region(faces.particle.as_deref().unwrap_or(&faces.top)), + // No face textures at all: the missing-texture region. + None => self.uv_map.get_region(""), + }; + let light = world_brightness(chunks, pos.x, pos.y, pos.z); + + const FULL_CUBE: LocalBox = [0.0, 0.0, 0.0, 1.0, 1.0, 1.0]; + let boxes: &[LocalBox] = match block_shape::partial_shape(state) { + Some(boxes) if !boxes.is_empty() => boxes, + // None = full cube. Some(&[]) = no collision (flowers, torches): + // vanilla scatters over the outline shape, which pomme doesn't + // have, so fall back to the full cube. + _ => &[FULL_CUBE], + }; + + for b in boxes { + let width_x = (b[3] - b[0]).min(1.0); + let width_y = (b[4] - b[1]).min(1.0); + let width_z = (b[5] - b[2]).min(1.0); + let count_x = ((width_x / 0.25).ceil() as i32).max(2); + let count_y = ((width_y / 0.25).ceil() as i32).max(2); + let count_z = ((width_z / 0.25).ceil() as i32).max(2); + for xx in 0..count_x { + for yy in 0..count_y { + for zz in 0..count_z { + let rel_x = (xx as f64 + 0.5) / count_x as f64; + let rel_y = (yy as f64 + 0.5) / count_y as f64; + let rel_z = (zz as f64 + 0.5) / count_z as f64; + let spawn = DVec3::new( + pos.x as f64 + rel_x * width_x + b[0], + pos.y as f64 + rel_y * width_y + b[1], + pos.z as f64 + rel_z * width_z + b[2], + ); + self.push(Particle::terrain( + spawn, + DVec3::new(rel_x - 0.5, rel_y - 0.5, rel_z - 0.5), + region, + color, + light, + )); + } + } + } + } + } + + /// The block's biome tint averaged over the vanilla 5x5 biome blend + /// (`BiomeColors` with the default blend radius of 2). + fn blend_tint( + &self, + tint: Tint, + pos: BlockPos, + chunks: &ChunkStore, + biome_climate: &HashMap, + ) -> [f32; 3] { + const RADIUS: i32 = 2; + const COUNT: f32 = ((RADIUS * 2 + 1) * (RADIUS * 2 + 1)) as f32; + let mut sum = [0.0f32; 3]; + for dz in -RADIUS..=RADIUS { + for dx in -RADIUS..=RADIUS { + let (x, z) = (pos.x + dx, pos.z + dz); + let climate = biome_climate + .get(&chunks.biome_id(x, pos.y, z)) + .copied() + .unwrap_or_default(); + let c = match tint { + Tint::Grass => grass_color(&climate, &self.grass_colormap, x, z), + Tint::Foliage => foliage_color(&climate, &self.foliage_colormap), + Tint::DryFoliage => dry_foliage_color(&climate, &self.dry_foliage_colormap), + Tint::None => [1.0; 3], + }; + for (s, v) in sum.iter_mut().zip(c) { + *s += v; + } + } + } + sum.map(|s| s / COUNT) + } + + /// Vanilla `ParticleGroup` caps: hard limit plus probabilistic rejection + /// once the reservoir fills. + fn push(&mut self, particle: Particle) { + let count = self.particles.len() + self.pending.len(); + if count >= MAX_PARTICLES { + return; + } + if count >= RESERVOIR_START { + let free = (MAX_PARTICLES - count) as f32 / 4096.0; + if fastrand::f32() >= free * free { + return; + } + } + self.pending.push(particle); + } + + pub fn tick(&mut self, chunks: &ChunkStore) { + self.particles.retain_mut(|p| p.tick(chunks)); + self.particles.append(&mut self.pending); + } + + pub fn extract(&self, partial_tick: f32) -> Vec { + self.particles + .iter() + .map(|p| { + let pos = p.prev_pos.lerp(p.pos, partial_tick as f64).as_vec3(); + let channel = |c: f32| (c * p.light * 255.0).round() as u8; + ParticleQuad { + pos: pos.into(), + size: p.size, + u0: p.u0, + u1: p.u1, + v0: p.v0, + v1: p.v1, + color: u32::from_le_bytes([ + channel(p.color[0]), + channel(p.color[1]), + channel(p.color[2]), + 255, + ]), + } + }) + .collect() + } +} diff --git a/pomme-client/src/player/interaction.rs b/pomme-client/src/player/interaction.rs index 1d06c7cb..5b864bb8 100644 --- a/pomme-client/src/player/interaction.rs +++ b/pomme-client/src/player/interaction.rs @@ -18,9 +18,11 @@ use crate::audio::{AudioEngine, CATEGORY_BLOCKS, SoundRef}; use crate::entity::EntityStore; use crate::entity::components::{LookDirection, Position}; use crate::net::sender::PacketSender; +use crate::particle::ParticleStore; use crate::physics::aabb::Aabb; use crate::physics::collision::has_collision; use crate::physics::movement::{PLAYER_HALF_WIDTH, PLAYER_HEIGHT}; +use crate::world::block::registry::BlockRegistry; use crate::world::block::sound::block_sounds; use crate::world::chunk::ChunkStore; @@ -32,6 +34,14 @@ const MISS_COOLDOWN: u32 = 10; const USE_DELAY: u32 = 4; const SWING_DURATION: i32 = 6; +/// Handles the predicted-break effects need (vanilla level event 2001 spawns +/// break particles alongside the sound). +pub struct BreakEffects<'a> { + pub particles: &'a mut ParticleStore, + pub registry: &'a BlockRegistry, + pub biome_climate: &'a HashMap, +} + #[derive(Debug, Clone, Copy)] pub struct BlockHitResult { pub block_pos: BlockPos, @@ -139,6 +149,7 @@ impl InteractionState { /// Applies a predicted break locally: remembers the server state for /// rollback, clears the block, and plays the break effects. + #[allow(clippy::too_many_arguments)] fn predict_destroy( &mut self, pos: BlockPos, @@ -146,12 +157,20 @@ impl InteractionState { player_pos: DVec3, chunks: &ChunkStore, audio: &AudioEngine, + effects: &mut BreakEffects, dirty_chunks: &mut Vec, ) { self.retain_known_server_state(pos, state, player_pos); chunks.set_block_state(pos.x, pos.y, pos.z, BlockState::AIR); mark_dirty(&pos, dirty_chunks); play_break_sound(audio, state, pos); + effects.particles.add_destroy_block_effect( + pos, + state, + effects.registry, + chunks, + effects.biome_climate, + ); self.destroy_delay = DESTROY_COOLDOWN; } @@ -275,6 +294,7 @@ impl InteractionState { self.target = block_hit.map(HitResult::Block); } + #[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)] pub fn tick( &mut self, @@ -287,6 +307,7 @@ impl InteractionState { creative: bool, selected_slot: u8, place_block: Option, + effects: &mut BreakEffects, ) -> Vec { let mut dirty_chunks = Vec::new(); @@ -311,6 +332,7 @@ impl InteractionState { player_pos, on_ground, creative, + effects, &mut dirty_chunks, ); } @@ -323,6 +345,7 @@ impl InteractionState { player_pos, on_ground, creative, + effects, &mut dirty_chunks, ); } else { @@ -365,6 +388,7 @@ impl InteractionState { player_pos: DVec3, on_ground: bool, creative: bool, + effects: &mut BreakEffects, dirty_chunks: &mut Vec, ) { if self.miss_time > 0 { @@ -403,6 +427,7 @@ impl InteractionState { player_pos, on_ground, creative, + effects, dirty_chunks, ); self.swing(sender); @@ -417,6 +442,7 @@ impl InteractionState { player_pos: DVec3, on_ground: bool, creative: bool, + effects: &mut BreakEffects, dirty_chunks: &mut Vec, ) { if self.miss_time > 0 { @@ -444,6 +470,7 @@ impl InteractionState { player_pos, on_ground, creative, + effects, dirty_chunks, ); self.swing(sender); @@ -535,6 +562,7 @@ impl InteractionState { player_pos: DVec3, on_ground: bool, creative: bool, + effects: &mut BreakEffects, dirty_chunks: &mut Vec, ) { let state = chunks.get_block_state(hit.block_pos.x, hit.block_pos.y, hit.block_pos.z); @@ -571,6 +599,7 @@ impl InteractionState { player_pos, chunks, audio, + effects, dirty_chunks, ); return; @@ -616,6 +645,7 @@ impl InteractionState { player_pos: DVec3, on_ground: bool, creative: bool, + effects: &mut BreakEffects, dirty_chunks: &mut Vec, ) { if self.destroy_delay > 0 { @@ -632,6 +662,7 @@ impl InteractionState { player_pos, on_ground, creative, + effects, dirty_chunks, ); return; @@ -665,6 +696,7 @@ impl InteractionState { player_pos, chunks, audio, + effects, dirty_chunks, ); self.is_destroying = false; @@ -746,7 +778,7 @@ fn play_hit_sound(audio: &AudioEngine, state: BlockState, pos: BlockPos) { /// Plays a block's break sound, matching vanilla `LevelEventHandler` event /// 2001: volume `(volume + 1) / 2`, pitch `pitch * 0.8`. -fn play_break_sound(audio: &AudioEngine, state: BlockState, pos: BlockPos) { +pub fn play_break_sound(audio: &AudioEngine, state: BlockState, pos: BlockPos) { let s = block_sounds(state); play_block_sound( audio, diff --git a/pomme-client/src/renderer/camera.rs b/pomme-client/src/renderer/camera.rs index 9ef2713a..906339f5 100644 --- a/pomme-client/src/renderer/camera.rs +++ b/pomme-client/src/renderer/camera.rs @@ -258,6 +258,27 @@ impl Camera { } } + /// Screen-space right/up axes for camera-facing particle billboards. + /// Equivalent to rotating quad corners by vanilla's roll-free camera + /// quaternion; derived from yaw/pitch analytically so there's no + /// degeneracy looking straight up or down. + pub fn billboard_axes(&self) -> (Vec3, Vec3) { + if self.top_down.is_some() { + // Looking straight down with north up. + return (Vec3::X, Vec3::NEG_Z); + } + let (sin_yaw, cos_yaw) = self.look_dir.y_rot_rad().sin_cos(); + let (sin_pitch, cos_pitch) = self.look_dir.x_rot_rad().sin_cos(); + let right = Vec3::new(-cos_yaw, 0.0, -sin_yaw); + let up = Vec3::new(-sin_yaw * sin_pitch, cos_pitch, cos_yaw * sin_pitch); + if self.mode == CameraMode::ThirdPersonFront { + // The camera faces the opposite way: right flips, up stays. + (-right, up) + } else { + (right, up) + } + } + /// Forward and up vectors for the view matrix, accounting for the top-down /// override and front-facing third person. fn view_basis(&self) -> (Vec3, Vec3) { diff --git a/pomme-client/src/renderer/chunk/mesher.rs b/pomme-client/src/renderer/chunk/mesher.rs index ab0e16e4..e8cafff2 100644 --- a/pomme-client/src/renderer/chunk/mesher.rs +++ b/pomme-client/src/renderer/chunk/mesher.rs @@ -212,6 +212,25 @@ impl Colormap { } } +pub fn grass_color(climate: &BiomeClimate, colormap: &Colormap, x: i32, z: i32) -> [f32; 3] { + let base = climate + .grass_color_override + .unwrap_or_else(|| colormap.lookup(climate.temperature, climate.downfall)); + apply_grass_modifier(climate.grass_color_modifier, base, x, z) +} + +pub fn foliage_color(climate: &BiomeClimate, colormap: &Colormap) -> [f32; 3] { + climate + .foliage_color_override + .unwrap_or_else(|| colormap.lookup(climate.temperature, climate.downfall)) +} + +pub fn dry_foliage_color(climate: &BiomeClimate, colormap: &Colormap) -> [f32; 3] { + climate + .dry_foliage_color_override + .unwrap_or_else(|| colormap.lookup(climate.temperature, climate.downfall)) +} + fn apply_grass_modifier(modifier: GrassColorModifier, base: [f32; 3], x: i32, z: i32) -> [f32; 3] { match modifier { GrassColorModifier::None => base, @@ -452,6 +471,16 @@ impl MeshDispatcher { self.biome_climate = climate; } + /// (grass, foliage, dry foliage) colormaps, shared with the particle + /// store for break-particle tinting. + pub fn colormaps(&self) -> (Arc, Arc, Arc) { + ( + Arc::clone(&self.grass_colormap), + Arc::clone(&self.foliage_colormap), + Arc::clone(&self.dry_foliage_colormap), + ) + } + // Always async, matching vanilla's default `prioritizeChunkUpdates = NONE`. // TODO: the PLAYER_AFFECTED/NEARBY modes add a synchronous same-frame rebuild // (a `mesh_now` path); deferred — pomme meshes whole columns, so it'd hitch @@ -798,28 +827,15 @@ impl ChunkStoreSnapshot { } fn grass_color_at(&self, x: i32, y: i32, z: i32) -> [f32; 3] { - let climate = self.climate_at(x, y, z); - let base = climate.grass_color_override.unwrap_or_else(|| { - self.grass_colormap - .lookup(climate.temperature, climate.downfall) - }); - apply_grass_modifier(climate.grass_color_modifier, base, x, z) + grass_color(&self.climate_at(x, y, z), &self.grass_colormap, x, z) } fn foliage_color_at(&self, x: i32, y: i32, z: i32) -> [f32; 3] { - let climate = self.climate_at(x, y, z); - climate.foliage_color_override.unwrap_or_else(|| { - self.foliage_colormap - .lookup(climate.temperature, climate.downfall) - }) + foliage_color(&self.climate_at(x, y, z), &self.foliage_colormap) } fn dry_foliage_color_at(&self, x: i32, y: i32, z: i32) -> [f32; 3] { - let climate = self.climate_at(x, y, z); - climate.dry_foliage_color_override.unwrap_or_else(|| { - self.dry_foliage_colormap - .lookup(climate.temperature, climate.downfall) - }) + dry_foliage_color(&self.climate_at(x, y, z), &self.dry_foliage_colormap) } fn grass_tint(&self, x: i32, y: i32, z: i32) -> [f32; 3] { @@ -878,6 +894,15 @@ pub const LIGHT_TABLE: [f32; 16] = [ 0.679, 0.815, 1.0, ]; +/// Brightness at a block position from the chunk store's light data: +/// `LIGHT_TABLE[max(sky, block)]`. +pub fn world_brightness(chunks: &ChunkStore, x: i32, y: i32, z: i32) -> f32 { + let level = chunks + .get_sky_light(x, y, z) + .max(chunks.get_block_light(x, y, z)); + LIGHT_TABLE[level as usize] +} + struct GreedyBlockInfo { textures: FaceTextures, } diff --git a/pomme-client/src/renderer/mod.rs b/pomme-client/src/renderer/mod.rs index e37b50d0..a70c4499 100644 --- a/pomme-client/src/renderer/mod.rs +++ b/pomme-client/src/renderer/mod.rs @@ -33,6 +33,7 @@ use pipelines::entity_renderer::{EntityRenderInfo, EntityRenderer}; use pipelines::hand::HandPipeline; use pipelines::menu_overlay::{MenuElement, MenuOverlayPipeline}; use pipelines::panorama::PanoramaPipeline; +pub use pipelines::particle::{ParticlePipeline, ParticleQuad}; use pipelines::skin_preview::SkinPreviewPipeline; pub use pipelines::sky::{SkyPipeline, SkyState}; pub use pipelines::weather::{WeatherColumn, WeatherPipeline}; @@ -77,6 +78,7 @@ enum RenderMode<'a> { entities: &'a [EntityRenderInfo], item_entities: &'a [pipelines::item_entity::ItemRenderInfo], block_entities: &'a [BlockEntityRenderInfo], + particles: &'a [ParticleQuad], weather: &'a [WeatherColumn], cloud_mode: CloudMode, render_distance: u32, @@ -123,6 +125,7 @@ pub struct Renderer { item_entity_pipeline: ItemEntityPipeline, held_item_pipeline: pipelines::held_item::HeldItemPipeline, weather_pipeline: WeatherPipeline, + particle_pipeline: ParticlePipeline, cloud_pipeline: CloudPipeline, gui_item_pipeline: pipelines::gui_item::GuiItemPipeline, gui_item_atlas: pipelines::gui_item_atlas::GuiItemAtlas, @@ -264,6 +267,13 @@ impl Renderer { asset_index, ); + let particle_pipeline = ParticlePipeline::new( + &ctx.device, + swapchain_state.render_pass, + &ctx.allocator, + &atlas, + ); + let cloud_pipeline = CloudPipeline::new( &ctx.device, swapchain_state.render_pass, @@ -415,6 +425,7 @@ impl Renderer { item_entity_pipeline, held_item_pipeline, weather_pipeline, + particle_pipeline, cloud_pipeline, gui_item_pipeline, gui_item_atlas, @@ -659,6 +670,8 @@ impl Renderer { .recreate_pipeline(&self.ctx.device, self.swapchain.render_pass); self.weather_pipeline .recreate_pipeline(&self.ctx.device, self.swapchain.render_pass); + self.particle_pipeline + .recreate_pipeline(&self.ctx.device, self.swapchain.render_pass); self.cloud_pipeline .recreate_pipeline(&self.ctx.device, self.swapchain.render_pass); self.blur_pipeline.resize( @@ -882,6 +895,10 @@ impl Renderer { &self.registry } + pub fn atlas_uv_map(&self) -> &crate::renderer::chunk::atlas::AtlasUVMap { + &self.atlas.uv_map + } + pub fn create_mesh_dispatcher( &self, biome_climate: std::sync::Arc< @@ -937,6 +954,7 @@ impl Renderer { entities: &[EntityRenderInfo], item_entities: &[pipelines::item_entity::ItemRenderInfo], block_entities: &[BlockEntityRenderInfo], + particles: &[ParticleQuad], weather: &[WeatherColumn], cloud_mode: CloudMode, render_distance: u32, @@ -974,6 +992,7 @@ impl Renderer { entities, item_entities, block_entities, + particles, weather, cloud_mode, render_distance, @@ -1251,6 +1270,7 @@ impl Renderer { self.chunk_border_pipeline.update_camera(frame, &uniform); self.item_entity_pipeline.update_camera(frame, &uniform); self.weather_pipeline.update_camera(frame, &uniform); + self.particle_pipeline.update_camera(frame, &uniform); self.cloud_pipeline.update_camera(frame, &uniform); } @@ -1443,6 +1463,7 @@ impl Renderer { entities, item_entities, block_entities, + particles, weather, cloud_mode, render_distance, @@ -1497,6 +1518,14 @@ impl Renderer { self.item_entity_pipeline.draw(cmd, frame, item_entities); + // Break particles draw after entities but before translucent + // water: they write depth, and pomme's water doesn't, so this + // lets water blend over particles behind it (vanilla draws + // particles after all translucents into a depth-sharing + // target). + self.particle_pipeline + .update_and_draw(cmd, frame, &self.camera, particles); + // Translucent water draws after opaque terrain and entities so it // blends over them; depth-tested (occluded by geometry in front) // but doesn't write depth. CPU frustum-culled, reusing the entity @@ -1900,6 +1929,8 @@ impl Drop for Renderer { .destroy(&self.ctx.device, &self.ctx.allocator); self.weather_pipeline .destroy(&self.ctx.device, &self.ctx.allocator); + self.particle_pipeline + .destroy(&self.ctx.device, &self.ctx.allocator); self.cloud_pipeline .destroy(&self.ctx.device, &self.ctx.allocator); self.gui_item_pipeline diff --git a/pomme-client/src/renderer/pipelines/mod.rs b/pomme-client/src/renderer/pipelines/mod.rs index 932fd7e9..b0c7cacb 100644 --- a/pomme-client/src/renderer/pipelines/mod.rs +++ b/pomme-client/src/renderer/pipelines/mod.rs @@ -13,6 +13,7 @@ pub mod item_display; pub mod item_entity; pub mod menu_overlay; pub mod panorama; +pub mod particle; pub mod skin_preview; pub mod sky; pub mod weather; diff --git a/pomme-client/src/renderer/pipelines/particle.rs b/pomme-client/src/renderer/pipelines/particle.rs new file mode 100644 index 00000000..cfde534c --- /dev/null +++ b/pomme-client/src/renderer/pipelines/particle.rs @@ -0,0 +1,419 @@ +use std::slice; +use std::sync::{Arc, Mutex}; + +use glam::Vec3; +use pomme_gpu_allocator::vulkan::{Allocation, Allocator}; +use pyronyx::vk; + +use crate::renderer::camera::{Camera, CameraUniform}; +use crate::renderer::chunk::atlas::TextureAtlas; +use crate::renderer::{MAX_FRAMES_IN_FLIGHT, shader, util}; + +/// Vanilla `ParticleGroup.MAX_PARTICLES`: the particle store's hard cap, +/// and thus the per-frame vertex buffer size. +pub const MAX_PARTICLE_QUADS: usize = 16384; +const MAX_VERTS: usize = MAX_PARTICLE_QUADS * 6; + +/// One camera-facing particle billboard, extracted per frame from the +/// particle store. +pub struct ParticleQuad { + /// Partial-tick-lerped world-space position (quad center). + pub pos: [f32; 3], + /// Vanilla `quadSize`; the quad spans twice this. + pub size: f32, + pub u0: f32, + pub u1: f32, + pub v0: f32, + pub v1: f32, + /// Packed RGBA8; rgb already multiplied by tint and world light. + pub color: u32, +} + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct ParticleVertex { + position: [f32; 3], + uv: [f32; 2], + color: u32, +} + +pub struct ParticlePipeline { + pipeline: vk::Pipeline, + pipeline_layout: vk::PipelineLayout, + camera_layout: vk::DescriptorSetLayout, + atlas_layout: vk::DescriptorSetLayout, + descriptor_pool: vk::DescriptorPool, + camera_sets: Vec, + atlas_set: vk::DescriptorSet, + camera_buffers: Vec, + camera_allocations: Vec>, + vertex_buffers: Vec, + vertex_allocations: Vec>, +} + +impl ParticlePipeline { + pub fn new( + device: &vk::Device, + render_pass: vk::RenderPass, + allocator: &Arc>, + atlas: &TextureAtlas, + ) -> Self { + let camera_layout = util::create_descriptor_set_layout( + device, + vk::DescriptorType::UniformBuffer, + vk::ShaderStageFlags::Vertex, + ); + let atlas_layout = util::create_descriptor_set_layout( + device, + vk::DescriptorType::CombinedImageSampler, + vk::ShaderStageFlags::Fragment, + ); + + let layouts = [camera_layout, atlas_layout]; + let layout_info = vk::PipelineLayoutCreateInfo { + set_layout_count: layouts.len() as u32, + set_layouts: layouts.as_ptr(), + ..Default::default() + }; + let pipeline_layout = device + .create_pipeline_layout(&layout_info, None) + .expect("failed to create particle pipeline layout"); + + let pipeline = create_pipeline(device, render_pass, pipeline_layout); + + let pool_sizes = [ + vk::DescriptorPoolSize { + ty: vk::DescriptorType::UniformBuffer, + descriptor_count: MAX_FRAMES_IN_FLIGHT as u32, + }, + vk::DescriptorPoolSize { + ty: vk::DescriptorType::CombinedImageSampler, + descriptor_count: 1, + }, + ]; + let pool_info = vk::DescriptorPoolCreateInfo { + max_sets: (MAX_FRAMES_IN_FLIGHT + 1) as u32, + pool_size_count: pool_sizes.len() as u32, + pool_sizes: pool_sizes.as_ptr(), + ..Default::default() + }; + let descriptor_pool = device + .create_descriptor_pool(&pool_info, None) + .expect("failed to create particle descriptor pool"); + + let camera_layouts: Vec<_> = (0..MAX_FRAMES_IN_FLIGHT).map(|_| camera_layout).collect(); + let camera_alloc_info = vk::DescriptorSetAllocateInfo { + descriptor_pool, + descriptor_set_count: camera_layouts.len() as u32, + set_layouts: camera_layouts.as_ptr(), + ..Default::default() + }; + let mut camera_sets = vec![vk::DescriptorSet::null(); camera_layouts.len()]; + device + .allocate_descriptor_sets(&camera_alloc_info, &mut camera_sets) + .expect("failed to allocate particle camera sets"); + + let atlas_alloc_info = vk::DescriptorSetAllocateInfo { + descriptor_pool, + descriptor_set_count: 1, + set_layouts: &atlas_layout, + ..Default::default() + }; + let mut atlas_set = vk::DescriptorSet::null(); + device + .allocate_descriptor_sets(&atlas_alloc_info, slice::from_mut(&mut atlas_set)) + .expect("failed to allocate particle atlas set"); + + let mut camera_buffers = Vec::with_capacity(MAX_FRAMES_IN_FLIGHT); + let mut camera_allocations: Vec> = + Vec::with_capacity(MAX_FRAMES_IN_FLIGHT); + for &set in &camera_sets { + let (buf, alloc) = util::create_uniform_buffer( + device, + allocator, + std::mem::size_of::() as u64, + "particle_camera", + ); + let buffer_info = vk::DescriptorBufferInfo { + buffer: buf, + offset: 0, + range: std::mem::size_of::() as u64, + }; + let write = vk::WriteDescriptorSet { + dst_set: set, + dst_binding: 0, + descriptor_type: vk::DescriptorType::UniformBuffer, + descriptor_count: 1, + buffer_info: &buffer_info, + ..Default::default() + }; + device.update_descriptor_sets(&[write], &[]); + camera_buffers.push(buf); + camera_allocations.push(Some(alloc)); + } + + let mut vertex_buffers = Vec::with_capacity(MAX_FRAMES_IN_FLIGHT); + let mut vertex_allocations: Vec> = + Vec::with_capacity(MAX_FRAMES_IN_FLIGHT); + let vertex_bytes = (MAX_VERTS * std::mem::size_of::()) as u64; + for _ in 0..MAX_FRAMES_IN_FLIGHT { + let (buf, alloc) = util::create_host_buffer( + device, + allocator, + vertex_bytes, + vk::BufferUsageFlags::VertexBuffer, + "particle_vertices", + ); + vertex_buffers.push(buf); + vertex_allocations.push(Some(alloc)); + } + + let this = Self { + pipeline, + pipeline_layout, + camera_layout, + atlas_layout, + descriptor_pool, + camera_sets, + atlas_set, + camera_buffers, + camera_allocations, + vertex_buffers, + vertex_allocations, + }; + this.bind_atlas(device, atlas); + this + } + + fn bind_atlas(&self, device: &vk::Device, atlas: &TextureAtlas) { + let image_info = vk::DescriptorImageInfo { + sampler: atlas.sampler, + image_view: atlas.view, + image_layout: vk::ImageLayout::ShaderReadOnlyOptimal, + }; + let write = vk::WriteDescriptorSet { + dst_set: self.atlas_set, + dst_binding: 0, + descriptor_type: vk::DescriptorType::CombinedImageSampler, + descriptor_count: 1, + image_info: &image_info, + ..Default::default() + }; + device.update_descriptor_sets(&[write], &[]); + } + + pub fn update_camera(&mut self, frame: usize, uniform: &CameraUniform) { + let bytes = bytemuck::bytes_of(uniform); + if let Some(alloc) = self.camera_allocations[frame].as_mut() { + alloc.mapped_slice_mut().unwrap()[..bytes.len()].copy_from_slice(bytes); + } + } + + pub fn update_and_draw( + &mut self, + cmd: vk::CommandBuffer, + frame: usize, + camera: &Camera, + quads: &[ParticleQuad], + ) { + if quads.is_empty() { + return; + } + + let (right, up) = camera.billboard_axes(); + let mut verts: Vec = + Vec::with_capacity(quads.len().min(MAX_PARTICLE_QUADS) * 6); + for quad in quads.iter().take(MAX_PARTICLE_QUADS) { + let center = Vec3::from(quad.pos); + let corner = |nx: f32, ny: f32, u: f32, v: f32| ParticleVertex { + position: (center + (right * nx + up * ny) * quad.size).into(), + uv: [u, v], + color: quad.color, + }; + // Vanilla QuadParticleRenderState corner order and UV mapping. + let corners = [ + corner(1.0, -1.0, quad.u1, quad.v1), + corner(1.0, 1.0, quad.u1, quad.v0), + corner(-1.0, 1.0, quad.u0, quad.v0), + corner(-1.0, -1.0, quad.u0, quad.v1), + ]; + for &i in &[0usize, 1, 2, 0, 2, 3] { + verts.push(corners[i]); + } + } + + let bytes = bytemuck::cast_slice::(&verts); + if let Some(alloc) = self.vertex_allocations[frame].as_mut() { + alloc.mapped_slice_mut().unwrap()[..bytes.len()].copy_from_slice(bytes); + } + + cmd.bind_pipeline(vk::PipelineBindPoint::Graphics, self.pipeline); + cmd.bind_vertex_buffers(0, &[self.vertex_buffers[frame]], &[0]); + cmd.bind_descriptor_sets( + vk::PipelineBindPoint::Graphics, + self.pipeline_layout, + 0, + &[self.camera_sets[frame], self.atlas_set], + &[], + ); + cmd.draw(verts.len() as u32, 1, 0, 0); + } + + pub fn recreate_pipeline(&mut self, device: &vk::Device, render_pass: vk::RenderPass) { + device.destroy_pipeline(self.pipeline, None); + self.pipeline = create_pipeline(device, render_pass, self.pipeline_layout); + } + + pub fn destroy(&mut self, device: &vk::Device, allocator: &Arc>) { + let mut alloc = allocator.lock().unwrap(); + for i in 0..MAX_FRAMES_IN_FLIGHT { + device.destroy_buffer(self.camera_buffers[i], None); + if let Some(a) = self.camera_allocations[i].take() { + alloc.free(a).ok(); + } + device.destroy_buffer(self.vertex_buffers[i], None); + if let Some(a) = self.vertex_allocations[i].take() { + alloc.free(a).ok(); + } + } + drop(alloc); + + device.destroy_pipeline(self.pipeline, None); + device.destroy_pipeline_layout(self.pipeline_layout, None); + device.destroy_descriptor_pool(self.descriptor_pool, None); + device.destroy_descriptor_set_layout(self.camera_layout, None); + device.destroy_descriptor_set_layout(self.atlas_layout, None); + } +} + +fn create_pipeline( + device: &vk::Device, + render_pass: vk::RenderPass, + layout: vk::PipelineLayout, +) -> vk::Pipeline { + let vert_spv = shader::include_spirv!("particle.vert.spv"); + let frag_spv = shader::include_spirv!("particle.frag.spv"); + let vert_mod = shader::create_shader_module(device, vert_spv); + let frag_mod = shader::create_shader_module(device, frag_spv); + + let stages = [ + vk::PipelineShaderStageCreateInfo { + stage: vk::ShaderStageFlags::Vertex, + module: vert_mod, + name: c"main".as_ptr(), + ..Default::default() + }, + vk::PipelineShaderStageCreateInfo { + stage: vk::ShaderStageFlags::Fragment, + module: frag_mod, + name: c"main".as_ptr(), + ..Default::default() + }, + ]; + + let binding_descs = [vk::VertexInputBindingDescription { + binding: 0, + stride: std::mem::size_of::() as u32, + input_rate: vk::VertexInputRate::Vertex, + }]; + let attr_descs = [ + vk::VertexInputAttributeDescription { + location: 0, + binding: 0, + format: vk::Format::R32G32B32Sfloat, + offset: 0, + }, + vk::VertexInputAttributeDescription { + location: 1, + binding: 0, + format: vk::Format::R32G32Sfloat, + offset: 12, + }, + vk::VertexInputAttributeDescription { + location: 2, + binding: 0, + format: vk::Format::R8G8B8A8Unorm, + offset: 20, + }, + ]; + let vertex_input = vk::PipelineVertexInputStateCreateInfo { + vertex_binding_description_count: binding_descs.len() as u32, + vertex_binding_descriptions: binding_descs.as_ptr(), + vertex_attribute_description_count: attr_descs.len() as u32, + vertex_attribute_descriptions: attr_descs.as_ptr(), + ..Default::default() + }; + let input_assembly = vk::PipelineInputAssemblyStateCreateInfo { + topology: vk::PrimitiveTopology::TriangleList, + ..Default::default() + }; + let viewport_state = vk::PipelineViewportStateCreateInfo { + viewport_count: 1, + scissor_count: 1, + ..Default::default() + }; + // Billboards always face the camera, so culling is a no-op; vanilla's + // back-face cull is skipped rather than fighting winding conventions. + let rasterizer = vk::PipelineRasterizationStateCreateInfo { + polygon_mode: vk::PolygonMode::Fill, + cull_mode: vk::CullModeFlags::None, + front_face: vk::FrontFace::CounterClockwise, + line_width: 1.0, + ..Default::default() + }; + let multisampling = vk::PipelineMultisampleStateCreateInfo { + rasterization_samples: vk::SampleCountFlags::Type1, + ..Default::default() + }; + // Vanilla OPAQUE_PARTICLE: depth test AND write, no blending (alpha is + // handled by the fragment discard). + let depth_stencil = vk::PipelineDepthStencilStateCreateInfo { + depth_test_enable: vk::TRUE, + depth_write_enable: vk::TRUE, + depth_compare_op: vk::CompareOp::Less, + ..Default::default() + }; + let blend_attachment = vk::PipelineColorBlendAttachmentState { + blend_enable: vk::FALSE, + color_write_mask: vk::ColorComponentFlags::RGBA, + ..Default::default() + }; + let color_blending = vk::PipelineColorBlendStateCreateInfo { + attachment_count: 1, + attachments: &blend_attachment, + ..Default::default() + }; + let dynamic_states = [vk::DynamicState::Viewport, vk::DynamicState::Scissor]; + let dynamic_state = vk::PipelineDynamicStateCreateInfo { + dynamic_state_count: dynamic_states.len() as u32, + dynamic_states: dynamic_states.as_ptr(), + ..Default::default() + }; + + let info = [vk::GraphicsPipelineCreateInfo { + stage_count: stages.len() as u32, + stages: stages.as_ptr(), + vertex_input_state: &vertex_input, + input_assembly_state: &input_assembly, + viewport_state: &viewport_state, + rasterization_state: &rasterizer, + multisample_state: &multisampling, + depth_stencil_state: &depth_stencil, + color_blend_state: &color_blending, + dynamic_state: &dynamic_state, + layout, + render_pass, + subpass: 0, + ..Default::default() + }]; + + let mut pipeline = [vk::Pipeline::null()]; + device + .create_graphics_pipelines(vk::PipelineCache::null(), &info, None, &mut pipeline) + .expect("failed to create particle pipeline"); + + device.destroy_shader_module(vert_mod, None); + device.destroy_shader_module(frag_mod, None); + + pipeline[0] +} diff --git a/pomme-client/src/renderer/shaders/particle.frag b/pomme-client/src/renderer/shaders/particle.frag new file mode 100644 index 00000000..517d011f --- /dev/null +++ b/pomme-client/src/renderer/shaders/particle.frag @@ -0,0 +1,21 @@ +#version 450 + +#include "fog.glsl" + +layout(set = 1, binding = 0) uniform sampler2D atlas_texture; + +layout(location = 0) in vec2 v_uv; +layout(location = 1) in vec4 v_color; +layout(location = 2) in float v_fog; +layout(location = 3) in vec3 v_fog_color; + +layout(location = 0) out vec4 out_color; + +void main() { + vec4 tex = texture(atlas_texture, v_uv); + // Vanilla particle.fsh discards below 0.1 (items use 0.5). + if (tex.a < 0.1) discard; + vec3 color = tex.rgb * v_color.rgb; + color = apply_fog(color, v_fog, v_fog_color); + out_color = vec4(color, 1.0); +} diff --git a/pomme-client/src/renderer/shaders/particle.vert b/pomme-client/src/renderer/shaders/particle.vert new file mode 100644 index 00000000..10dcbe49 --- /dev/null +++ b/pomme-client/src/renderer/shaders/particle.vert @@ -0,0 +1,29 @@ +#version 450 + +#include "fog.glsl" + +layout(set = 0, binding = 0) uniform CameraUniform { + mat4 view_proj; + vec4 camera_pos; + vec4 fog_color; +}; + +layout(location = 0) in vec3 position; +layout(location = 1) in vec2 uv; +layout(location = 2) in vec4 color; + +layout(location = 0) out vec2 v_uv; +layout(location = 1) out vec4 v_color; +layout(location = 2) out float v_fog; +layout(location = 3) out vec3 v_fog_color; + +void main() { + // Positions are absolute world-space; render camera-relative for precision + // (matches item_entity.vert). + vec3 rel = position - camera_pos.xyz; + gl_Position = view_proj * vec4(rel, 1.0); + v_uv = uv; + v_color = color; + v_fog = fog_factor(rel, camera_pos.w, fog_color.w); + v_fog_color = fog_color.rgb; +} diff --git a/pomme-client/src/world/block/model.rs b/pomme-client/src/world/block/model.rs index 7957d6e2..fee11a24 100644 --- a/pomme-client/src/world/block/model.rs +++ b/pomme-client/src/world/block/model.rs @@ -1187,6 +1187,18 @@ fn rotate_positions(mut positions: [[f32; 3]; 4], rot_x: i32, rot_y: i32) -> [[f fn build_face_textures( block_name: &str, textures: &HashMap, +) -> Option { + let mut faces = face_textures_base(block_name, textures)?; + faces.particle = textures + .get("particle") + .and_then(|v| texture_to_name(v)) + .map(Into::into); + Some(faces) +} + +fn face_textures_base( + block_name: &str, + textures: &HashMap, ) -> Option { let get = |key: &str| -> Option<&str> { textures.get(key).and_then(|v| texture_to_name(v)) }; diff --git a/pomme-client/src/world/block/registry.rs b/pomme-client/src/world/block/registry.rs index 12fac320..9dc0bed6 100644 --- a/pomme-client/src/world/block/registry.rs +++ b/pomme-client/src/world/block/registry.rs @@ -4,7 +4,7 @@ use std::path::Path; use azalea_block::BlockState; use serde::{Deserialize, Serialize}; -pub const BLOCK_CACHE_FILE: &str = "block_cache.json"; +pub const BLOCK_CACHE_FILE: &str = "block_cache_v2.json"; use super::model; use super::model::BakedModel; @@ -28,6 +28,10 @@ pub struct FaceTextures { pub west: String, pub side_overlay: Option, pub tint: Tint, + /// The model's `particle` texture slot (vanilla `getParticleMaterial`), + /// used for block-break particles. + #[serde(default)] + pub particle: Option, } impl FaceTextures { @@ -51,6 +55,7 @@ impl FaceTextures { west: west.into(), side_overlay: side_overlay.map(Into::into), tint, + particle: None, } } From 600ec7ceacf4b124b4f0ed0e32437bb88c520cf5 Mon Sep 17 00:00:00 2001 From: Purdze Date: Thu, 2 Jul 2026 21:55:27 +0100 Subject: [PATCH 2/3] Dropped item physics --- pomme-client/src/app/core.rs | 24 ++++- pomme-client/src/app/phases/in_game.rs | 2 +- pomme-client/src/entity/mod.rs | 136 +++++++++++++++++++++---- pomme-client/src/net/handler.rs | 22 +++- pomme-client/src/net/mod.rs | 13 +++ pomme-client/src/player/mod.rs | 10 +- 6 files changed, 179 insertions(+), 28 deletions(-) diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index 6fc43947..d9f7b1ca 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -707,6 +707,7 @@ impl AppCore { uuid, entity_type, position, + velocity, y_rot_deg, x_rot_deg, head_y_rot_deg, @@ -733,12 +734,18 @@ impl AppCore { } } if entity_type == azalea_registry::builtin::EntityKind::Item { - game.item_entity_store.spawn_item(id, position); + game.item_entity_store.spawn_item(id, position, velocity); } } - NetworkEvent::EntityMoved { id, dx, dy, dz } => { + NetworkEvent::EntityMoved { + id, + dx, + dy, + dz, + on_ground, + } => { game.entity_store.move_living_delta(id, dx, dy, dz); - game.item_entity_store.move_delta(id, dx, dy, dz); + game.item_entity_store.move_delta(id, dx, dy, dz, on_ground); } NetworkEvent::EntityMovedRotated { id, @@ -747,22 +754,29 @@ impl AppCore { dz, y_rot_deg, x_rot_deg, + on_ground, } => { game.entity_store.move_living_delta(id, dx, dy, dz); game.entity_store .update_living_rotation(id, y_rot_deg, x_rot_deg); - game.item_entity_store.move_delta(id, dx, dy, dz); + game.item_entity_store.move_delta(id, dx, dy, dz, on_ground); + } + NetworkEvent::EntityMotion { id, velocity } => { + game.item_entity_store.set_motion(id, velocity); } NetworkEvent::EntityTeleported { id, position, + velocity, y_rot_deg, x_rot_deg, + on_ground, } => { game.entity_store.teleport_living(id, position); game.entity_store .update_living_rotation(id, y_rot_deg, x_rot_deg); - game.item_entity_store.teleport(id, position); + game.item_entity_store + .teleport(id, position, velocity, on_ground); } NetworkEvent::LevelEvent { event_type, diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index f33585a0..cbcef47e 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -787,7 +787,7 @@ pub fn update_game( core.tick_accumulator += dt; while core.tick_accumulator >= TICK_RATE { core.tick_physics(&mut gfx.renderer, connection, game); - game.item_entity_store.tick(); + game.item_entity_store.tick(&game.chunk_store); game.particle_store.tick(&game.chunk_store); game.block_entity_anim.tick(); core.tick_accumulator -= TICK_RATE; diff --git a/pomme-client/src/entity/mod.rs b/pomme-client/src/entity/mod.rs index 9469afd3..d4204a17 100644 --- a/pomme-client/src/entity/mod.rs +++ b/pomme-client/src/entity/mod.rs @@ -2,10 +2,15 @@ pub mod components; use std::collections::HashMap; +use azalea_core::position::ChunkPos; use azalea_registry::builtin::EntityKind; use glam::DVec3; use crate::entity::components::{LookDirection, Position}; +use crate::physics::aabb::Aabb; +use crate::physics::collision::resolve_collision; +use crate::player::{is_lava_block, is_water_block}; +use crate::world::chunk::ChunkStore; const INTERPOLATION_STEPS: i32 = 3; const HURT_DURATION: u8 = 10; @@ -184,8 +189,10 @@ pub struct ItemEntity { /// used for hover height and the 3D-vs-flat copy layout. pub min_y: f32, pub z_size: f32, - interp_target: Position, - interp_steps: i32, + velocity: DVec3, + on_ground: bool, + /// Server-authoritative position, tracked from move/teleport packets. + server_pos: Position, } struct PickupAnimation { @@ -229,7 +236,7 @@ impl ItemEntityStore { } } - pub fn spawn_item(&mut self, id: i32, position: Position) { + pub fn spawn_item(&mut self, id: i32, position: Position, velocity: DVec3) { let bob_offset = ((id as u32).wrapping_mul(2654435761)) as f32 / u32::MAX as f32 * std::f32::consts::TAU; self.items.insert( @@ -245,8 +252,9 @@ impl ItemEntityStore { is_block_model: false, min_y: -0.5, z_size: 1.0, - interp_target: position, - interp_steps: 0, + velocity, + on_ground: false, + server_pos: position, }, ); } @@ -272,19 +280,44 @@ impl ItemEntityStore { } } - /// Apply a server position delta via 3-step interpolation, mirroring - /// `move_living_delta`. Items are not simulated locally. - pub fn move_delta(&mut self, id: i32, dx: f64, dy: f64, dz: f64) { + /// Apply a server position delta: advance the authoritative base and + /// snap to it. Items have no interpolation handler in vanilla + /// (`moveOrInterpolateTo` -> `setPos`); local physics predicts between + /// packets and `prev_position` smooths the render lerp. + pub fn move_delta(&mut self, id: i32, dx: f64, dy: f64, dz: f64, on_ground: bool) { if let Some(entity) = self.items.get_mut(&id) { - entity.interp_target += DVec3::new(dx, dy, dz); - entity.interp_steps = INTERPOLATION_STEPS; + entity.server_pos += DVec3::new(dx, dy, dz); + entity.position = entity.server_pos; + entity.on_ground = on_ground; } } - pub fn teleport(&mut self, id: i32, position: Position) { + pub fn teleport( + &mut self, + id: i32, + position: Position, + velocity: Option, + on_ground: bool, + ) { if let Some(entity) = self.items.get_mut(&id) { - entity.interp_target = position; - entity.interp_steps = INTERPOLATION_STEPS; + // Vanilla suppresses the render lerp on jumps over 64 blocks + // (`tooBigToInterpolate`). + if entity.position.distance_squared(*position) > 4096.0 { + entity.prev_position = position; + } + entity.server_pos = position; + entity.position = position; + if let Some(velocity) = velocity { + entity.velocity = velocity; + } + entity.on_ground = on_ground; + } + } + + /// Vanilla `handleSetEntityMotion`. + pub fn set_motion(&mut self, id: i32, velocity: DVec3) { + if let Some(entity) = self.items.get_mut(&id) { + entity.velocity = velocity; } } @@ -327,14 +360,10 @@ impl ItemEntityStore { } } - pub fn tick(&mut self) { - for entity in self.items.values_mut() { + pub fn tick(&mut self, chunk_store: &ChunkStore) { + for (&id, entity) in self.items.iter_mut() { entity.prev_position = entity.position; - if entity.interp_steps > 0 { - let alpha = 1.0 / entity.interp_steps as f64; - entity.position = entity.position.lerp(entity.interp_target, alpha); - entity.interp_steps -= 1; - } + tick_item_physics(id, entity, chunk_store); entity.age += 1; } for pickup in &mut self.pickups { @@ -376,6 +405,73 @@ impl ItemEntityStore { } } +/// Vanilla `ItemEntity.getDefaultGravity`. +const ITEM_GRAVITY: f64 = 0.04; +/// Vanilla `Entity.getAirDrag`. +const ITEM_AIR_DRAG: f64 = 0.98; +/// Item hitbox is 0.25 cubed (`EntityType.ITEM` dimensions). +const ITEM_HALF_WIDTH: f64 = 0.125; + +/// Client-side port of `ItemEntity.tick` movement: gravity or fluid drift, +/// collide-and-slide, friction, then the half-speed landing bounce. +/// Server-only parts (merging, despawn, pickup delay) are omitted. +fn tick_item_physics(id: i32, entity: &mut ItemEntity, chunk_store: &ChunkStore) { + let block_x = entity.position.x.floor() as i32; + let block_y = entity.position.y.floor() as i32; + let block_z = entity.position.z.floor() as i32; + let chunk_pos = ChunkPos::new(block_x.div_euclid(16), block_z.div_euclid(16)); + if chunk_store.get_chunk(&chunk_pos).is_none() { + // Don't simulate (and fall) through unloaded terrain. + return; + } + + let state = chunk_store.get_block_state(block_x, block_y, block_z); + if is_water_block(state) { + apply_item_fluid_movement(entity, 0.99); + } else if is_lava_block(state) { + apply_item_fluid_movement(entity, 0.95); + } else { + entity.velocity.y -= ITEM_GRAVITY; + } + + // Vanilla rest throttle: a settled item only re-runs collision every + // 4th tick. + let horizontal_sq = + entity.velocity.x * entity.velocity.x + entity.velocity.z * entity.velocity.z; + if entity.on_ground && horizontal_sq <= 1e-5 && (entity.age as i64 + id as i64) % 4 != 0 { + return; + } + + let aabb = Aabb::from_center(entity.position.into(), ITEM_HALF_WIDTH, ITEM_HALF_WIDTH); + let (delta, on_ground) = resolve_collision(chunk_store, aabb, entity.velocity.into(), 0.0); + entity.position += delta; + entity.on_ground = on_ground; + + // TODO: per-block slipperiness (ice/slime); vanilla multiplies by the + // friction of the block below, default 0.6. + let ground_friction = if on_ground { + ITEM_AIR_DRAG * 0.6 + } else { + ITEM_AIR_DRAG + }; + entity.velocity.x *= ground_friction; + entity.velocity.y *= ITEM_AIR_DRAG; + entity.velocity.z *= ground_friction; + if on_ground && entity.velocity.y < 0.0 { + entity.velocity.y *= -0.5; + } +} + +/// Vanilla `ItemEntity.setFluidMovement`: horizontal drag plus a slow +/// upward drift toward the surface. +fn apply_item_fluid_movement(entity: &mut ItemEntity, multiplier: f64) { + entity.velocity.x *= multiplier; + entity.velocity.z *= multiplier; + if entity.velocity.y < 0.06 { + entity.velocity.y += 5.0e-4; + } +} + pub struct EntityStore { pub living: HashMap, } diff --git a/pomme-client/src/net/handler.rs b/pomme-client/src/net/handler.rs index d4c6334c..d71a64eb 100644 --- a/pomme-client/src/net/handler.rs +++ b/pomme-client/src/net/handler.rs @@ -291,6 +291,7 @@ pub fn handle_game_packet( uuid: p.uuid, entity_type: p.entity_type, position: p.position.into(), + velocity: lp_to_dvec3(&p.movement), y_rot_deg, x_rot_deg, head_y_rot_deg, @@ -307,7 +308,7 @@ pub fn handle_game_packet( }); } ClientboundGamePacket::MoveEntityPos(p) => { - send_entity_moved(event_tx, p.entity_id.0, &p.delta); + send_entity_moved(event_tx, p.entity_id.0, &p.delta, p.on_ground); } ClientboundGamePacket::MoveEntityPosRot(p) => { use azalea_core::delta::PositionDeltaTrait; @@ -319,22 +320,34 @@ pub fn handle_game_packet( dz: p.delta.z(), y_rot_deg: look.y_rot(), x_rot_deg: look.x_rot(), + on_ground: p.on_ground, }); } ClientboundGamePacket::TeleportEntity(p) => { + let delta = p.change.delta; let _ = event_tx.try_send(NetworkEvent::EntityTeleported { id: p.id.0, position: p.change.pos.into(), + velocity: Some(glam::DVec3::new(delta.x, delta.y, delta.z)), y_rot_deg: p.change.look_direction.y_rot(), x_rot_deg: p.change.look_direction.x_rot(), + on_ground: p.on_ground, }); } ClientboundGamePacket::EntityPositionSync(p) => { let _ = event_tx.try_send(NetworkEvent::EntityTeleported { id: p.id.0, position: p.values.pos.into(), + velocity: None, y_rot_deg: p.values.look_direction.y_rot(), x_rot_deg: p.values.look_direction.x_rot(), + on_ground: p.on_ground, + }); + } + ClientboundGamePacket::SetEntityMotion(p) => { + let _ = event_tx.try_send(NetworkEvent::EntityMotion { + id: p.id.0, + velocity: lp_to_dvec3(&p.delta), }); } ClientboundGamePacket::LevelEvent(p) => { @@ -608,15 +621,22 @@ fn resolve_sound( } } +fn lp_to_dvec3(v: &azalea_core::delta::LpVec3) -> glam::DVec3 { + let v = v.to_vec3(); + glam::DVec3::new(v.x, v.y, v.z) +} + fn send_entity_moved( event_tx: &Sender, id: i32, delta: &azalea_core::delta::PositionDelta8, + on_ground: bool, ) { let _ = event_tx.try_send(NetworkEvent::EntityMoved { id, dx: delta.xa as f64 / 4096.0, dy: delta.ya as f64 / 4096.0, dz: delta.za as f64 / 4096.0, + on_ground, }); } diff --git a/pomme-client/src/net/mod.rs b/pomme-client/src/net/mod.rs index 22b2c63b..0f12a3f9 100644 --- a/pomme-client/src/net/mod.rs +++ b/pomme-client/src/net/mod.rs @@ -11,6 +11,7 @@ use azalea_core::heightmap_kind::HeightmapKind; use azalea_core::position::{BlockPos, ChunkPos}; use azalea_inventory::ItemStack; use azalea_registry::builtin::{BlockEntityKind, EntityKind}; +use glam::DVec3; use simdnbt::owned::NbtCompound; use crate::entity::components::Position; @@ -142,6 +143,7 @@ pub enum NetworkEvent { uuid: uuid::Uuid, entity_type: EntityKind, position: Position, + velocity: DVec3, y_rot_deg: f32, x_rot_deg: f32, head_y_rot_deg: f32, @@ -151,6 +153,7 @@ pub enum NetworkEvent { dx: f64, dy: f64, dz: f64, + on_ground: bool, }, EntityMovedRotated { id: i32, @@ -159,12 +162,22 @@ pub enum NetworkEvent { dz: f64, y_rot_deg: f32, x_rot_deg: f32, + on_ground: bool, + }, + EntityMotion { + id: i32, + velocity: DVec3, }, EntityTeleported { id: i32, position: Position, + /// `TeleportEntity` applies the packet's velocity; `EntityPositionSync` + /// doesn't (vanilla `setValuesFromPositionPacket` vs + /// `handleEntityPositionSync`). + velocity: Option, y_rot_deg: f32, x_rot_deg: f32, + on_ground: bool, }, LevelEvent { event_type: u32, diff --git a/pomme-client/src/player/mod.rs b/pomme-client/src/player/mod.rs index 2a68418f..827e7e0a 100644 --- a/pomme-client/src/player/mod.rs +++ b/pomme-client/src/player/mod.rs @@ -24,7 +24,7 @@ pub fn is_survival(game_mode: u8) -> bool { game_mode == 0 || game_mode == 2 } -fn is_water_block(state: azalea_block::BlockState) -> bool { +pub fn is_water_block(state: azalea_block::BlockState) -> bool { if state.is_air() { return false; } @@ -39,6 +39,14 @@ fn is_water_block(state: azalea_block::BlockState) -> bool { .is_some_and(|v| *v == "true") } +pub fn is_lava_block(state: azalea_block::BlockState) -> bool { + if state.is_air() { + return false; + } + let block: Box = state.into(); + block.id() == "lava" +} + pub struct LocalPlayer { pub position: Position, pub prev_position: Position, From df11b8239f52d9ec9e7facffc44f1d4871d8a6f5 Mon Sep 17 00:00:00 2001 From: Purdze Date: Thu, 2 Jul 2026 22:33:29 +0100 Subject: [PATCH 3/3] Sync remesh for player block edits --- pomme-client/src/app/core.rs | 3 +- pomme-client/src/app/phases/in_game.rs | 73 +++++++++----- pomme-client/src/particle.rs | 2 +- pomme-client/src/renderer/chunk/mesher.rs | 117 ++++++++++------------ 4 files changed, 104 insertions(+), 91 deletions(-) diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index d9f7b1ca..a495cee5 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -1143,9 +1143,8 @@ impl AppCore { for b in dirty { dirty_sections_for_block(&mut sections, b.x, b.y, b.z, min_y, n); } - // Player edits are always adjacent (lod 0). for (col, si) in sections { - game.enqueue_section_edit(col, si, 0); + game.mesh_section_edit_now(renderer, col, si); } } diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index cbcef47e..67552dd1 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -20,7 +20,7 @@ use crate::net::connection::ConnectionHandle; use crate::player::LocalPlayer; use crate::player::interaction::{HitResult, InteractionState}; use crate::player::tab_list::TabList; -use crate::renderer::chunk::mesher::{BiomeClimate, MeshDispatcher}; +use crate::renderer::chunk::mesher::{BiomeClimate, ChunkMeshData, MeshDispatcher}; use crate::renderer::chunk::occlusion_graph::{self, VisibilitySet}; use crate::renderer::pipelines::entity_renderer::{ EntityRenderInfo, WHITE_TINT, jeb_sheep_tint, wool_color_tint, @@ -308,13 +308,56 @@ impl GameState { /// visibility. Bumps that section's generation so the result is dropped /// only if the same section is edited again before it lands. pub fn enqueue_section_edit(&mut self, col: ChunkPos, si: i32, lod: u32) { - self.next_section_gen += 1; - let g = self.next_section_gen; - self.section_gen.insert((col, si), g); + let g = self.bump_section_gen(col, si); self.mesh_dispatcher .enqueue(&self.chunk_store, col, lod, true, g, si..si + 1); } + /// Vanilla `compileSync` under `PrioritizeChunkUpdates.PLAYER_AFFECTED`: + /// mesh and upload a player-edited section on the spot so the edit shows + /// the same frame. (Vanilla defaults to NONE/async, but pomme's async + /// round-trip is several frames, which leaves a broken block visibly + /// lingering after its crack overlay completes.) + pub fn mesh_section_edit_now(&mut self, renderer: &mut Renderer, col: ChunkPos, si: i32) { + // The gen bump also invalidates any in-flight async result for this + // section, so it can't clobber the sync upload at drain time. + let g = self.bump_section_gen(col, si); + let mesh = self + .mesh_dispatcher + .mesh_section_now(&self.chunk_store, col, si, g); + self.apply_mesh_upload(renderer, mesh); + } + + fn bump_section_gen(&mut self, col: ChunkPos, si: i32) -> u64 { + self.next_section_gen += 1; + self.section_gen.insert((col, si), self.next_section_gen); + self.next_section_gen + } + + /// Upload a finished mesh and apply its bookkeeping: re-arm the rescan + /// for sections dropped on pool exhaustion, and adopt the per-section + /// visibility sets. + fn apply_mesh_upload(&mut self, renderer: &mut Renderer, mesh: ChunkMeshData) { + let dropped = renderer.upload_chunk_mesh(&mesh); + let pos = mesh.pos; + // Sections dropped on pool exhaustion were retired from the buffer; + // clear their meshed bit so the next rescan re-enqueues them. + if !dropped.is_empty() + && let Some(m) = self.meshed.get_mut(&pos) + { + for si in dropped { + m.mask &= !(1u32 << si); + } + } + for (si, vis) in mesh.visibility { + let e = self.section_vis_epoch.entry((pos, si)).or_insert(0); + if mesh.upload_epoch >= *e { + *e = mesh.upload_epoch; + self.section_vis.insert((pos, si), vis); + } + } + } + /// Drive the cave-cull occlusion walk: apply a finished async walk to the /// per-column draw masks, then schedule the next one on 8-block camera /// movement or chunk loads (one at a time, off the main thread — vanilla's @@ -721,7 +764,8 @@ pub fn update_game( return GameUpdateResult::Disconnected { reason }; } - for mesh in game.mesh_dispatcher.drain_results() { + let meshes: Vec<_> = game.mesh_dispatcher.drain_results().collect(); + for mesh in meshes { // Drop a mesh built from an out-of-date snapshot. Edits (priority lane, // single section) are keyed per section so editing one section never // drops a sibling's in-flight result; bulk loads keep the column key. @@ -747,24 +791,7 @@ pub fn update_game( ms(t.enqueued_at.elapsed()), ); } - let dropped = gfx.renderer.upload_chunk_mesh(&mesh); - let pos = mesh.pos; - // Sections dropped on pool exhaustion were retired from the buffer; clear - // their meshed bit so the next rescan re-enqueues them. - if !dropped.is_empty() - && let Some(m) = game.meshed.get_mut(&pos) - { - for si in dropped { - m.mask &= !(1u32 << si); - } - } - for (si, vis) in mesh.visibility { - let e = game.section_vis_epoch.entry((pos, si)).or_insert(0); - if mesh.upload_epoch >= *e { - *e = mesh.upload_epoch; - game.section_vis.insert((pos, si), vis); - } - } + game.apply_mesh_upload(&mut gfx.renderer, mesh); } game.mesh_dispatcher diff --git a/pomme-client/src/particle.rs b/pomme-client/src/particle.rs index 217430b0..fbae50b3 100644 --- a/pomme-client/src/particle.rs +++ b/pomme-client/src/particle.rs @@ -13,10 +13,10 @@ use crate::physics::block_shape::{self, LocalBox}; use crate::physics::collision::resolve_collision; use crate::renderer::ParticleQuad; use crate::renderer::chunk::atlas::{AtlasRegion, AtlasUVMap}; -use crate::renderer::pipelines::particle::MAX_PARTICLE_QUADS as MAX_PARTICLES; use crate::renderer::chunk::mesher::{ BiomeClimate, Colormap, dry_foliage_color, foliage_color, grass_color, world_brightness, }; +use crate::renderer::pipelines::particle::MAX_PARTICLE_QUADS as MAX_PARTICLES; use crate::world::block::registry::{BlockRegistry, Tint}; use crate::world::chunk::ChunkStore; diff --git a/pomme-client/src/renderer/chunk/mesher.rs b/pomme-client/src/renderer/chunk/mesher.rs index e8cafff2..ab8bd996 100644 --- a/pomme-client/src/renderer/chunk/mesher.rs +++ b/pomme-client/src/renderer/chunk/mesher.rs @@ -481,10 +481,8 @@ impl MeshDispatcher { ) } - // Always async, matching vanilla's default `prioritizeChunkUpdates = NONE`. - // TODO: the PLAYER_AFFECTED/NEARBY modes add a synchronous same-frame rebuild - // (a `mesh_now` path); deferred — pomme meshes whole columns, so it'd hitch - // ~200ms. + // Async worker path, vanilla's default `prioritizeChunkUpdates = NONE`. + // Player edits use `mesh_section_now` instead. pub fn enqueue( &self, chunk_store: &ChunkStore, @@ -494,12 +492,6 @@ impl MeshDispatcher { content_gen: u64, sections: std::ops::Range, ) { - let registry = Arc::clone(&self.registry); - let uv_map = Arc::clone(&self.uv_map); - let grass_colormap = Arc::clone(&self.grass_colormap); - let foliage_colormap = Arc::clone(&self.foliage_colormap); - let dry_foliage_colormap = Arc::clone(&self.dry_foliage_colormap); - let biome_climate = Arc::clone(&self.biome_climate); let tx = if priority { self.priority_tx.clone() } else { @@ -508,26 +500,6 @@ impl MeshDispatcher { let enqueued_at = priority.then(std::time::Instant::now); let upload_epoch = self.next_epoch.fetch_add(1, Ordering::Relaxed); - let chunks_needed = chunk::mesh_neighborhood(pos); - let chunk_arcs: Vec<_> = chunks_needed - .iter() - .map(|p| chunk_store.get_chunk(p)) - .collect(); - - let min_y = chunk_store.min_y(); - let height = chunk_store.height(); - - let light: std::collections::HashMap<(i32, i32), crate::world::chunk::ChunkLightData> = - chunks_needed - .iter() - .filter_map(|p| { - chunk_store - .light_data - .get(&(p.x, p.z)) - .map(|ld| ((p.x, p.z), ld.clone())) - }) - .collect(); - self.queue.push(PendingJob { pos, lod, @@ -537,21 +509,58 @@ impl MeshDispatcher { // An edit re-meshes an already-shown chunk (vanilla's "recompile"). is_recompile: priority, enqueued_at, - chunks_needed, - chunk_arcs, - light, - registry, - uv_map, - grass_colormap, - foliage_colormap, - dry_foliage_colormap, - biome_climate, - min_y, - height, + snapshot: self.build_snapshot(chunk_store, pos), + registry: Arc::clone(&self.registry), + uv_map: Arc::clone(&self.uv_map), tx, }); } + /// Vanilla `compileSync` (`PrioritizeChunkUpdates.PLAYER_AFFECTED`): mesh + /// one section on the calling thread so a player edit is renderable the + /// same frame, skipping the worker round-trip. + pub fn mesh_section_now( + &self, + chunk_store: &ChunkStore, + pos: ChunkPos, + si: i32, + content_gen: u64, + ) -> ChunkMeshData { + let snapshot = self.build_snapshot(chunk_store, pos); + let mut mesh = + mesh_chunk_snapshot(&snapshot, pos, &self.registry, &self.uv_map, 0, si..si + 1); + mesh.content_gen = content_gen; + mesh.upload_epoch = self.next_epoch.fetch_add(1, Ordering::Relaxed); + mesh + } + + /// Point-in-time snapshot of `pos`'s mesh neighbourhood: chunk arcs plus + /// a deep copy of their light data. + fn build_snapshot(&self, chunk_store: &ChunkStore, pos: ChunkPos) -> ChunkStoreSnapshot { + let chunks_needed = chunk::mesh_neighborhood(pos); + ChunkStoreSnapshot { + chunks: chunks_needed + .iter() + .map(|p| (*p, chunk_store.get_chunk(p))) + .collect(), + light: chunks_needed + .iter() + .filter_map(|p| { + chunk_store + .light_data + .get(&(p.x, p.z)) + .map(|ld| ((p.x, p.z), ld.clone())) + }) + .collect(), + grass_colormap: Arc::clone(&self.grass_colormap), + foliage_colormap: Arc::clone(&self.foliage_colormap), + dry_foliage_colormap: Arc::clone(&self.dry_foliage_colormap), + biome_climate: Arc::clone(&self.biome_climate), + min_y: chunk_store.min_y(), + height: chunk_store.height(), + } + } + /// Latest camera position, used to mesh the nearest pending chunk first. pub fn set_camera_position(&self, pos: glam::DVec3) { self.queue.set_camera(pos); @@ -587,39 +596,17 @@ struct PendingJob { sections: std::ops::Range, is_recompile: bool, enqueued_at: Option, - chunks_needed: [ChunkPos; 5], - chunk_arcs: Vec>>>, - light: HashMap<(i32, i32), crate::world::chunk::ChunkLightData>, + snapshot: ChunkStoreSnapshot, registry: Arc, uv_map: Arc, - grass_colormap: Arc, - foliage_colormap: Arc, - dry_foliage_colormap: Arc, - biome_climate: Arc>, - min_y: i32, - height: u32, tx: crossbeam_channel::Sender, } impl PendingJob { fn run(self) { let started_at = self.enqueued_at.map(|_| std::time::Instant::now()); - let snapshot = ChunkStoreSnapshot { - chunks: self - .chunks_needed - .into_iter() - .zip(self.chunk_arcs) - .collect(), - light: self.light, - grass_colormap: self.grass_colormap, - foliage_colormap: self.foliage_colormap, - dry_foliage_colormap: self.dry_foliage_colormap, - biome_climate: self.biome_climate, - min_y: self.min_y, - height: self.height, - }; let mut mesh = mesh_chunk_snapshot( - &snapshot, + &self.snapshot, self.pos, &self.registry, &self.uv_map,