From 1708917a211ac74cd1fffd322b4e801d37d0eab8 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:37:43 +0000 Subject: [PATCH 01/67] allocate chunk meshes from budgeted arenas and evict far columns when full --- pomme-client/src/cpu_bench/fade.rs | 8 +- pomme-client/src/harness/exec.rs | 3 + pomme-client/src/harness/protocol.rs | 2 + pomme-client/src/harness/script.rs | 4 + pomme-client/src/renderer/chunk/buffer.rs | 1494 +++++++++++------ pomme-client/src/renderer/chunk/mod.rs | 1 + pomme-client/src/renderer/chunk/tlsf.rs | 269 +++ pomme-client/src/renderer/mod.rs | 8 +- .../src/renderer/rhi/vulkan/context.rs | 6 + pomme-client/src/renderer/rhi/vulkan/mod.rs | 52 +- pomme-client/src/ui/menu/options.rs | 2 +- tests/ingame/README.md | 5 +- 12 files changed, 1364 insertions(+), 490 deletions(-) create mode 100644 pomme-client/src/renderer/chunk/tlsf.rs diff --git a/pomme-client/src/cpu_bench/fade.rs b/pomme-client/src/cpu_bench/fade.rs index a19b1c6ba..d9787c548 100644 --- a/pomme-client/src/cpu_bench/fade.rs +++ b/pomme-client/src/cpu_bench/fade.rs @@ -26,16 +26,16 @@ pub fn bench(c: &mut Criterion) { vis.insert(pos, u32::MAX); } let now = uploaded_at + Duration::from_millis(300); - let mut meta = Vec::new(); + let mut metas = [Vec::new()]; // `fading`: every section mid-fade, as while streaming. `static`: the // same rebuild with fading off, as after an edit or visibility change. for (case, fade_enabled) in [("fading", true), ("static", false)] { group.bench_function(format!("{case}/rd{rd}"), |b| { b.iter(|| { - build_meta(&chunks, Some(&vis), fade_enabled, EYE, now, &mut meta); - sort_meta(&mut meta, EYE); - black_box(meta.len()) + build_meta(&chunks, Some(&vis), fade_enabled, EYE, now, &mut metas); + sort_meta(&mut metas[0], EYE); + black_box(metas[0].len()) }) }); } diff --git a/pomme-client/src/harness/exec.rs b/pomme-client/src/harness/exec.rs index 1c955db79..3d2953e6a 100644 --- a/pomme-client/src/harness/exec.rs +++ b/pomme-client/src/harness/exec.rs @@ -219,6 +219,9 @@ fn set_options(core: &mut AppCore, options: &protocol::OptionSet) -> Result, /// A shader pack's name in `shaderpacks/`, or `""` for none. pub shader_pack: Option, + /// Render Distance in chunks, clamped to the slider's range. + pub render_distance: Option, } #[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)] diff --git a/pomme-client/src/harness/script.rs b/pomme-client/src/harness/script.rs index b3a1c37a8..a8ece3a3f 100644 --- a/pomme-client/src/harness/script.rs +++ b/pomme-client/src/harness/script.rs @@ -694,6 +694,9 @@ fn check(expect: &Expect, phase: &AppPhase) -> Result<(), String> { if let Some(pos) = game.first_unmeshed_column() { return Err(format!("{pos:?} isn't meshed yet")); } + if let Some(pos) = gfx.renderer.starved_chunk_column() { + return Err(format!("{pos:?}'s mesh doesn't fit the chunk pools")); + } gfx.renderer.chunk_slice_overlap().map_or(Ok(()), Err) } Expect::Biome(BiomeExpect { biome, at }) => { @@ -823,6 +826,7 @@ mod tests { menu_background_blur: None, brightness: None, shader_pack: None, + render_distance: None, })) ); assert_eq!( diff --git a/pomme-client/src/renderer/chunk/buffer.rs b/pomme-client/src/renderer/chunk/buffer.rs index 366a9574c..888b402f3 100644 --- a/pomme-client/src/renderer/chunk/buffer.rs +++ b/pomme-client/src/renderer/chunk/buffer.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::mem::offset_of; use glam::DVec3; @@ -6,18 +6,39 @@ use glam::DVec3; use super::mesher::{ ChunkAABB, ChunkMeshData, PackVertex, PackedVertex, SectionMesh, TerrainFormat, }; +use super::tlsf::Tlsf; use crate::position::ChunkPos; use crate::renderer::rhi::IndirectDrawFeatures; use crate::renderer::{MAX_FRAMES_IN_FLIGHT, rhi, shader, util}; -const BUCKET_VERTICES: u32 = 32768; -const BUCKET_INDICES: u32 = 49152; +/// Vertices per arena: vanilla's 128 MiB vertex heap (`UberGpuBuffer`) at +/// the vanilla format's 16-byte vertex. +const ARENA_VERTICES: u32 = 8 << 20; +/// Indices per arena: six per four-vertex quad. +const ARENA_INDICES: u32 = ARENA_VERTICES / 2 * 3; const INDEX_SIZE: u64 = size_of::() as u64; -const BYTES_PER_BUCKET: u64 = - BUCKET_VERTICES as u64 * size_of::() as u64 + BUCKET_INDICES as u64 * INDEX_SIZE; -const MIN_BUCKETS: u32 = 128; -const MAX_BUCKETS: u32 = 2048; +/// Discrete GPUs batch a frame's uploads through the staging buffer in one +/// transfer, so it holds several columns to keep sub-flushes rare. Sized for +/// vanilla vertices; a shader pack's larger ones flush sooner. +const STAGING_SIZE: u64 = 12 << 20; +/// Without `VK_EXT_memory_budget`, the share of device-local memory the +/// arenas may take. const VRAM_BUDGET_FRACTION: f64 = 0.25; +/// With it, the least of the budget left for everything else. +const MIN_BUDGET_HEADROOM: u64 = 512 << 20; +/// Compaction starts once the arenas' contents would fit one fewer arena +/// filled this far. +const COMPACT_FILL: f64 = 0.8; +/// Vertices compaction moves per frame. +const COMPACT_VERTICES_PER_FRAME: u32 = 1 << 18; +/// Past the eviction frontier, starved columns come back only while this +/// fraction of an arena (as `1 / n`) stays free after them. +const FRONTIER_SLACK: u64 = 16; +/// Frames before compaction tries again after running out of room. +const COMPACT_BACKOFF_FRAMES: u64 = 600; +/// Draws an arena's meta and indirect buffers start with room for. Growing +/// them waits the GPU out, so start generously. +const INITIAL_META: usize = 8192; /// The cull pass's bindings: meta, frustum, solid indirect/count, cutout /// indirect/count. @@ -41,85 +62,14 @@ const NEARBY_DIST_SQ: f32 = 768.0; /// (vanilla `isNearby`), rebased in f64 for precision at extreme coordinates. /// Also gates mesh-scheduling tiers (`in_game::apply_visibility`). pub fn column_is_near(pos: ChunkPos, eye: DVec3) -> bool { - let dx = pos.x as f64 * 16.0 + 8.0 - eye.x; - let dz = pos.z as f64 * 16.0 + 8.0 - eye.z; - dx * dx + dz * dz < NEARBY_DIST_SQ as f64 -} - -/// First-fit free-list sub-allocator over a fixed element range, coalescing on -/// free. Each section gets an exact-size vertex (and index) slice instead of -/// whole fixed buckets — vanilla's `UberGpuBuffer` model — so re-uploading one -/// section never disturbs the rest and there is no per-section bucket waste. -struct FreeList { - capacity: u32, - /// Free regions `(offset, len)`, sorted by offset and coalesced (no two - /// adjacent). - free: Vec<(u32, u32)>, + column_dist_sq(pos, eye) < NEARBY_DIST_SQ as f64 } -impl FreeList { - fn new(capacity: u32) -> Self { - Self { - capacity, - free: vec![(0, capacity)], - } - } - - fn reset(&mut self) { - self.free.clear(); - self.free.push((0, self.capacity)); - } - - /// Allocate `n` contiguous elements; `None` if no region is large enough. - fn alloc(&mut self, n: u32) -> Option { - for i in 0..self.free.len() { - let (off, len) = self.free[i]; - if len >= n { - if len == n { - self.free.remove(i); - } else { - self.free[i] = (off + n, len - n); - } - return Some(off); - } - } - None - } - - /// Return a region, coalescing with an adjacent free region on either side. - fn free_region(&mut self, off: u32, n: u32) { - let pos = self.free.partition_point(|&(o, _)| o < off); - self.free.insert(pos, (off, n)); - if pos + 1 < self.free.len() { - let (o, l) = self.free[pos]; - let (no, nl) = self.free[pos + 1]; - if o + l == no { - self.free[pos] = (o, l + nl); - self.free.remove(pos + 1); - } - } - if pos > 0 { - let (po, pl) = self.free[pos - 1]; - let (o, l) = self.free[pos]; - if po + pl == o { - self.free[pos - 1] = (po, pl + l); - self.free.remove(pos); - } - } - } -} - -fn compute_bucket_count(device_local_bytes: u64) -> u32 { - let budget = (device_local_bytes as f64 * VRAM_BUDGET_FRACTION) as u64; - let buckets = (budget / BYTES_PER_BUCKET) as u32; - let count = buckets.clamp(MIN_BUCKETS, MAX_BUCKETS); - tracing::info!( - "GPU VRAM: {} MB, chunk budget: {} MB, buckets: {}", - device_local_bytes / (1024 * 1024), - (count as u64 * BYTES_PER_BUCKET) / (1024 * 1024), - count - ); - count +/// The squared X/Z distance from `eye` to a column's center. +fn column_dist_sq(pos: ChunkPos, eye: DVec3) -> f64 { + let dx = pos.x as f64 * 16.0 + 8.0 - eye.x; + let dz = pos.z as f64 * 16.0 + 8.0 - eye.z; + dx * dx + dz * dz } #[repr(C)] @@ -296,18 +246,18 @@ fn column_nearby(fade_enabled: bool, pos: ChunkPos, eye: DVec3) -> bool { !fade_enabled || column_is_near(pos, eye) } -/// Rebuilds the draw list into `out`: one [`ChunkMeta`] per uploaded section -/// the occlusion mask keeps (all of them without one), with its fade-in -/// factor at `now`. +/// Rebuilds the draw lists into `out`, one per arena: one [`ChunkMeta`] per +/// uploaded section the occlusion mask keeps (all of them without one), with +/// its fade-in factor at `now`. pub(crate) fn build_meta( chunks: &HashMap, chunk_visibility: Option<&HashMap>, fade_enabled: bool, eye: DVec3, now: std::time::Instant, - out: &mut Vec, + out: &mut [Vec], ) { - out.clear(); + out.iter_mut().for_each(Vec::clear); for (pos, alloc) in chunks.iter() { // Near columns never fade; otherwise each section fades on its own // timer (X/Z distance is per-column). @@ -324,7 +274,7 @@ pub(crate) fn build_meta( continue; } let vis = ChunkBufferStore::section_visibility(nearby, sec, now); - out.push(ChunkMeta { + out[sec.arena as usize].push(ChunkMeta { aabb_min: sec.aabb.min, aabb_max: sec.aabb.max, index_count: sec.index_count, @@ -374,16 +324,17 @@ struct FrustumData { /// One uploaded 16³ section: a self-contained indexed draw plus its tight AABB. /// `first_index`/`index_count` are the section's index slice and -/// `vertex_offset` its vertex slice base; `vtx_len` is the slice length, kept -/// so the slices can be returned to the free-lists on removal. `uploaded_at` -/// drives the per-section fade so editing one section never re-fades the rest -/// of the column. +/// `vertex_offset` its vertex slice base, both in `arena`; `vtx_len` is the +/// slice length, kept so the slices can be returned to the arena on removal. +/// `uploaded_at` drives the per-section fade so editing one section never +/// re-fades the rest of the column. pub(crate) struct SectionAlloc { section_index: i32, aabb: ChunkAABB, /// Section world origin (`chunk*16`, `min_y + si*16`), used to rebase the /// quantized vertices and passed to the GPU via `ChunkMeta.origin`. origin: [i32; 3], + arena: u16, first_index: u32, /// Opaque index count (the GPU-culled draw); water is excluded. index_count: u32, @@ -404,6 +355,18 @@ pub(crate) struct SectionAlloc { epoch: u64, } +impl SectionAlloc { + fn slice(&self) -> Slice { + Slice { + arena: self.arena, + vertex_offset: self.vertex_offset as u32, + vertex_len: self.vtx_len, + first_index: self.first_index, + index_len: self.idx_len, + } + } +} + pub(crate) struct ChunkAlloc { sections: Vec, } @@ -427,6 +390,7 @@ impl ChunkAlloc { max: [16.0, 16.0, 16.0, 0.0], }, origin: [pos.x * 16, min_y + si * 16, pos.z * 16], + arena: 0, first_index: 0, index_count: 6, solid_index_count: 6, @@ -443,48 +407,267 @@ impl ChunkAlloc { } } -pub struct ChunkBufferStore { - indirect_draw: IndirectDrawFeatures, - /// Capacity (in draws) of the per-frame meta/indirect buffers. Grown on - /// demand because per-section packing yields many more draws than buckets. - max_meta: usize, +/// A slice of one arena's vertex and index heaps. +#[derive(Clone, Copy)] +struct Slice { + arena: u16, + vertex_offset: u32, + vertex_len: u32, + first_index: u32, + index_len: u32, +} + +/// What a column's sections take: in total, and the largest one's slices. +#[derive(Clone, Copy, Default)] +struct Need { + vertices: u64, + indices: u64, + max_vertices: u32, + max_indices: u32, +} + +impl Need { + fn add(&mut self, vertices: u32, indices: u32) { + self.vertices += u64::from(vertices); + self.indices += u64::from(indices); + self.max_vertices = self.max_vertices.max(vertices); + self.max_indices = self.max_indices.max(indices); + } + + fn max(self, other: Self) -> Self { + Self { + vertices: self.vertices.max(other.vertices), + indices: self.indices.max(other.indices), + max_vertices: self.max_vertices.max(other.max_vertices), + max_indices: self.max_indices.max(other.max_indices), + } + } +} + +/// A column's sections that found no room, and what they need. +#[derive(Default)] +struct Starved { + sections: HashSet, + need: Need, +} + +/// One vertex and one index heap, sub-allocated by [`Tlsf`], with the draw +/// lists culled over the sections they hold: a heap of vanilla's +/// `UberGpuBuffer`, its vertex and index halves paired so each arena draws in +/// one call. +struct Arena { vertex_buffer: rhi::backend::Buffer, index_buffer: rhi::backend::Buffer, + vertices: Tlsf, + indices: Tlsf, + /// Capacity (in draws) of the draw lists' meta and indirect buffers. + max_meta: usize, + camera: DrawList, + /// A shader pack's shadow view, made on its first cull. + shadow: Option, + /// The camera's draw counts, copied after each cull for the debug overlay. + count_readbacks: Vec, + /// Empty, and destroyed at this `frame_seq` once no in-flight frame reads + /// it. Never allocated from or drawn meanwhile. + doomed: Option, +} + +impl Arena { + fn new( + device: &rhi::backend::Device, + use_staging: bool, + format: TerrainFormat, + cull_layout: rhi::backend::BindGroupLayout, + ) -> Self { + let (meta_size, indirect_size) = list_sizes(INITIAL_META); + Self { + vertex_buffer: create_pool( + device, + use_staging, + "vertex_arena", + u64::from(ARENA_VERTICES) * format.vertex_size(), + rhi::BufferUsages::VERTEX, + ), + index_buffer: create_pool( + device, + use_staging, + "index_arena", + u64::from(ARENA_INDICES) * INDEX_SIZE, + rhi::BufferUsages::INDEX, + ), + vertices: Tlsf::new(ARENA_VERTICES), + indices: Tlsf::new(ARENA_INDICES), + max_meta: INITIAL_META, + camera: DrawList::new(device, cull_layout, meta_size, indirect_size), + shadow: None, + count_readbacks: (0..MAX_FRAMES_IN_FLIGHT) + .map(|_| { + rhi::backend::Buffer::new( + device, + "draw_count_readback", + 2 * COUNT_SIZE, + rhi::BufferUsages::COPY_DST, + rhi::MemoryLocation::Readback, + ) + }) + .collect(), + doomed: None, + } + } + + fn is_empty(&self) -> bool { + self.vertices.used() == 0 && self.indices.used() == 0 + } + + /// Grows the draw lists to hold `needed` draws. The caller waits for + /// in-flight frames first, since their bind groups use the old buffers. + fn regrow( + &mut self, + device: &rhi::backend::Device, + layout: rhi::backend::BindGroupLayout, + needed: usize, + ) { + let max_meta = (needed.saturating_mul(3) / 2) + .next_power_of_two() + .max(self.max_meta * 2); + let (meta_size, indirect_size) = list_sizes(max_meta); + for list in std::iter::once(&mut self.camera).chain(&mut self.shadow) { + list.regrow(device, layout, meta_size, indirect_size); + } + self.max_meta = max_meta; + } + + fn destroy(mut self, device: &rhi::backend::Device) { + self.vertex_buffer.destroy(device); + self.index_buffer.destroy(device); + for list in std::iter::once(&mut self.camera).chain(&mut self.shadow) { + list.destroy(device); + } + util::destroy_buffers(device, &mut self.count_readbacks); + } +} + +/// The meta and indirect buffer sizes for `draws` draws. +fn list_sizes(draws: usize) -> (u64, u64) { + ( + (draws * size_of::()) as u64, + (draws * size_of::()) as u64, + ) +} + +/// The arena that can be drawn from and allocated in: neither destroyed nor +/// doomed. +fn live(slot: &Option) -> Option<&Arena> { + slot.as_ref().filter(|arena| arena.doomed.is_none()) +} + +fn live_mut(slot: &mut Option) -> Option<&mut Arena> { + slot.as_mut().filter(|arena| arena.doomed.is_none()) +} + +/// A vertex and an index slice from one live arena other than `exclude`, +/// trying the fullest first so the emptiest can drain. +fn alloc_in( + arenas: &mut [Option], + exclude: Option, + vertices: u32, + indices: u32, +) -> Option<(u16, u32, u32)> { + let mut order: Vec = (0..arenas.len()) + .filter(|&id| live(&arenas[id]).is_some() && exclude != Some(id as u16)) + .collect(); + order.sort_unstable_by_key(|&id| { + std::cmp::Reverse(arenas[id].as_ref().map_or(0, |a| a.vertices.used())) + }); + for id in order { + let Some(arena) = arenas[id].as_mut() else { + continue; + }; + let Some(vertex) = arena.vertices.alloc(vertices) else { + continue; + }; + let Some(index) = arena.indices.alloc(indices) else { + arena.vertices.free(vertex); + continue; + }; + return Some((id as u16, vertex, index)); + } + None +} + +/// Groups `copies` by destination arena and records them from `src`'s +/// buffers, or the staging buffer's halves when `src` is `None`. +fn record_copies( + encoder: &rhi::backend::CommandEncoder, + arenas: &[Option], + src: Option<&Arena>, + staging: &rhi::backend::Buffer, + copies: &mut [(u16, rhi::BufferCopy, rhi::BufferCopy)], +) { + copies.sort_unstable_by_key(|&(arena, ..)| arena); + for group in copies.chunk_by(|a, b| a.0 == b.0) { + let Some(dst) = arenas[group[0].0 as usize].as_ref() else { + continue; + }; + let vertex: Vec<_> = group.iter().map(|&(_, v, _)| v).collect(); + let index: Vec<_> = group.iter().map(|&(.., i)| i).collect(); + let (src_vertex, src_index) = match src { + Some(src) => (src.vertex_buffer.handle(), src.index_buffer.handle()), + None => (staging.handle(), staging.handle()), + }; + encoder.copy_buffer(src_vertex, dst.vertex_buffer.handle(), &vertex); + encoder.copy_buffer(src_index, dst.index_buffer.handle(), &index); + } +} + +pub struct ChunkBufferStore { + indirect_draw: IndirectDrawFeatures, + /// By id; a destroyed arena's slot is reused by the next one. + arenas: Vec>, staging_buffer: rhi::backend::Buffer, - staging_size: u64, use_staging: bool, format: TerrainFormat, - /// Vertices the pool holds, in either format. - pool_vertices: u32, + /// Without `VK_EXT_memory_budget`, what the arenas' share is of. + device_local_bytes: u64, + /// Columns whose sections found no room, or were evicted for nearer + /// ones; handed back for re-meshing once there's room for them. + starved: HashMap, + /// A slice was reclaimed since the last [`Self::upload_batch`]. + reclaimed: bool, + /// Columns this far from the eye, squared, or farther were evicted for + /// nearer ones, or are past those that were, and wait for room. It moves + /// back out as room frees. + frontier_sq: f64, + /// The arena compaction is emptying, kept out of allocation. + draining: Option, + /// The `frame_seq` before which compaction doesn't start. + compact_after: u64, - /// Exact-size sub-allocators over the vertex and index pools (in elements). - vtx_free: FreeList, - idx_free: FreeList, chunks: HashMap, /// Per-column bitmask of occlusion-visible section indices (bit `si`), from /// the CPU visibility graph. A column absent here defaults to fully /// visible, so freshly-loaded-but-not-yet-graphed columns still draw. chunk_visibility: HashMap, + /// Per arena, the camera's and the shadow view's draw lists. + camera_meta: Vec>, + shadow_meta: Vec>, meta_dirty: bool, /// End of the current fade-in window. While `now < fade_until` the - /// per-section fade values change each frame, so `cached_meta` must be + /// per-section fade values change each frame, so the draw lists must be /// rebuilt; an O(1) check replacing the old all-sections scan. fade_until: std::time::Instant, /// Eye position at the last front-to-back sort; the sort (an early-Z /// optimization) is only redone once the camera moves past a threshold. last_sort_cam: DVec3, + /// The eye of the last cull, which eviction measures distance from. + eye: Option, compute_pipeline: rhi::backend::ComputePipeline, compute_layout: rhi::backend::PipelineLayout, cull_layout: rhi::backend::BindGroupLayout, - camera: DrawList, - /// A shader pack's shadow view, made on its first cull. - shadow: Option, - /// The shadow list needs rebuilding from the sections. + /// The shadow lists need rebuilding from the sections. shadow_dirty: bool, - /// The camera's draw counts, copied after each cull for the debug overlay. - count_readbacks: Vec, fade_enabled: bool, /// Post-cull section draw count read back from the GPU (lags a few frames); /// exposed for the debug overlay so occlusion's effect is visible. @@ -493,93 +676,46 @@ pub struct ChunkBufferStore { /// Monotonic frame counter, bumped once per rendered frame in /// `begin_frame`. frame_seq: u64, - /// Slices freed by a re-mesh or unload, each tagged with the `frame_seq` at - /// which it's safe to reclaim (`MAX_FRAMES_IN_FLIGHT` out, so no in-flight - /// frame still draws it). Drained in `begin_frame`. - pending_free: VecDeque<(u64, (u32, u32, u32, u32))>, + /// Slices freed by a re-mesh, unload, eviction or compaction, each tagged + /// with the `frame_seq` at which it's safe to reclaim + /// (`MAX_FRAMES_IN_FLIGHT` out, so no in-flight frame still draws it). + /// Drained in `begin_frame`. + pending_free: VecDeque<(u64, Slice)>, } impl ChunkBufferStore { pub fn new(device: &rhi::backend::Device, indirect_draw: IndirectDrawFeatures) -> Self { - let total_buckets = compute_bucket_count(device.device_local_bytes); - let pool_vertices = total_buckets * BUCKET_VERTICES; - let format = TerrainFormat::Vanilla; - let vertex_size = u64::from(pool_vertices) * format.vertex_size(); - let index_size = total_buckets as u64 * BUCKET_INDICES as u64 * INDEX_SIZE; - let use_staging = device.discrete; + let format = TerrainFormat::Vanilla; - let vertex_buffer = create_pool( - device, - use_staging, - "vertex_pool", - vertex_size, - rhi::BufferUsages::VERTEX, - ); - let index_buffer = create_pool( - device, - use_staging, - "index_pool", - index_size, - rhi::BufferUsages::INDEX, - ); - - // Discrete GPUs batch a frame's uploads through this buffer in one - // transfer, so size it to hold several columns and keep sub-flushes rare. - // The integrated path writes mapped memory directly and never touches it. - // Sized for vanilla vertices; a shader pack's larger ones flush sooner. - let staging_size = if use_staging { - BYTES_PER_BUCKET * 16 - } else { - BYTES_PER_BUCKET * 4 - }; + // The integrated path writes mapped memory directly and never touches + // the staging buffer. let staging_buffer = rhi::backend::Buffer::new( device, "staging", - staging_size, + if use_staging { STAGING_SIZE } else { 64 << 10 }, rhi::BufferUsages::COPY_SRC, rhi::MemoryLocation::Host, ); tracing::info!( - "Chunk buffers: {} (vertex={} MB, index={} MB, staging={} KB)", + "Chunk arenas: {}, {} MB each; GPU VRAM {} MB, budget query {}", if use_staging { "DEVICE_LOCAL + staging" } else { "HOST_VISIBLE" }, - vertex_size / (1024 * 1024), - index_size / (1024 * 1024), - staging_size / 1024, + arena_bytes(format) / (1024 * 1024), + device.device_local_bytes / (1024 * 1024), + if device.device_local_budget().is_some() { + "on" + } else { + "off" + }, ); - let vtx_free = FreeList::new(pool_vertices); - let idx_free = FreeList::new(total_buckets * BUCKET_INDICES); - - // Per-section packing yields many more draws than buckets, so pre-size - // generously: growth (`ensure_meta_capacity`) needs a `device.wait_idle` - // to safely rewrite the bind groups, and we don't want that stall - // firing mid-stream. The remaining grow path stays as a rare safety net. - let max_meta = (total_buckets * 16).max(8192) as usize; - let meta_size = (max_meta * size_of::()) as u64; - let indirect_size = (max_meta * size_of::()) as u64; - - let count_readbacks = (0..MAX_FRAMES_IN_FLIGHT) - .map(|_| { - rhi::backend::Buffer::new( - device, - "draw_count_readback", - 2 * COUNT_SIZE, - rhi::BufferUsages::COPY_DST, - rhi::MemoryLocation::Readback, - ) - }) - .collect(); - let cull_layout = rhi::backend::create_bind_group_layout(device, CULL_BINDINGS); - let camera = DrawList::new(device, cull_layout, meta_size, indirect_size); let compute_layout = rhi::backend::create_pipeline_layout(device, &[cull_layout], None); - let compute_pipeline = rhi::backend::create_compute_pipeline( device, compute_layout, @@ -589,28 +725,28 @@ impl ChunkBufferStore { Self { indirect_draw, - max_meta, - vertex_buffer, - index_buffer, + arenas: Vec::new(), staging_buffer, - staging_size, use_staging, format, - pool_vertices, - vtx_free, - idx_free, + device_local_bytes: device.device_local_bytes, + starved: HashMap::new(), + reclaimed: false, + frontier_sq: f64::INFINITY, + draining: None, + compact_after: 0, chunks: HashMap::new(), chunk_visibility: HashMap::new(), + camera_meta: Vec::new(), + shadow_meta: Vec::new(), meta_dirty: true, fade_until: std::time::Instant::now(), last_sort_cam: DVec3::MAX, + eye: None, compute_pipeline, compute_layout, cull_layout, - camera, - shadow: None, shadow_dirty: true, - count_readbacks, fade_enabled: false, last_draw_count: 0, frame_seq: 0, @@ -627,36 +763,36 @@ impl ChunkBufferStore { /// Submit the accumulated staging copies as a single transfer and wait for /// it, so a frame's uploads synchronize once instead of once per mesh. fn flush_transfer( - &mut self, + &self, device: &rhi::backend::Device, - copy_v: &[rhi::BufferCopy], - copy_i: &[rhi::BufferCopy], + copies: &mut Vec<(u16, rhi::BufferCopy, rhi::BufferCopy)>, ) { - if copy_v.is_empty() && copy_i.is_empty() { + if copies.is_empty() { return; } device.submit(|encoder| { - for (dst, copies) in [(&self.vertex_buffer, copy_v), (&self.index_buffer, copy_i)] { - if !copies.is_empty() { - encoder.copy_buffer(self.staging_buffer.handle(), dst.handle(), copies); - } - } + record_copies(encoder, &self.arenas, None, &self.staging_buffer, copies); }); + copies.clear(); } /// Upload a batch of mesh results, each replacing the sections in its /// `mesh.replaced` range. Staging copies for the whole batch are coalesced /// into as few transfers as the staging buffer holds (one per overflow, /// plus a final flush), each synchronized by a single fence wait — so a - /// streaming frame stalls once, not once per mesh. Returns, per mesh - /// that hit pool exhaustion, the section indices that were dropped and - /// need re-meshing. + /// streaming frame stalls once, not once per mesh. Returns the starved + /// sections that may fit now, per column, for re-meshing. pub fn upload_batch( &mut self, device: &rhi::backend::Device, meshes: &[ChunkMeshData], ) -> Vec<(ChunkPos, Vec)> { let mut needs_remesh: Vec<(ChunkPos, Vec)> = Vec::new(); + // Starved sections retry only once a slice has been reclaimed; until + // then the arenas are as full as before and the retry would fail too. + if std::mem::take(&mut self.reclaimed) { + self.retry_starved(device, &mut needs_remesh); + } if meshes.is_empty() { return needs_remesh; } @@ -670,6 +806,7 @@ impl ChunkBufferStore { vertex_count: u32, indices: &'a [u32], water_indices: &'a [u32], + arena: u16, vtx_off: u32, idx_off: u32, solid_index_count: u32, @@ -686,6 +823,21 @@ impl ChunkBufferStore { buffer.write_at(off + opaque.len(), bytemuck::cast_slice(self.water_indices)); opaque.len() + size_of_val(self.water_indices) } + + /// Opaque then water indices, which share one slice. + fn index_len(&self) -> u32 { + (self.indices.len() + self.water_indices.len()) as u32 + } + + fn slice(&self) -> Slice { + Slice { + arena: self.arena, + vertex_offset: self.vtx_off, + vertex_len: self.vertex_count, + first_index: self.idx_off, + index_len: self.index_len(), + } + } } // Retired slices only reclaim in `begin_frame`; if rendering is paused @@ -696,14 +848,15 @@ impl ChunkBufferStore { device.wait_idle(); while let Some((_, slice)) = self.pending_free.pop_front() { self.free_slice(slice); + self.reclaimed = true; } } - let staging_half = self.staging_size as usize / 2; + let staging_half = STAGING_SIZE as usize / 2; + let vertex_size = self.format.vertex_size(); // Copies accumulated for the current (not-yet-submitted) transfer, and the // running write cursors into each half of the staging buffer. - let mut copy_v: Vec = Vec::new(); - let mut copy_i: Vec = Vec::new(); + let mut copies: Vec<(u16, rhi::BufferCopy, rhi::BufferCopy)> = Vec::new(); let mut stg_v = 0usize; let mut stg_i = 0usize; @@ -711,7 +864,7 @@ impl ChunkBufferStore { // The covered sections this job is authoritative for: reject any where // a newer upload (higher epoch) already landed. See // `ChunkMeshData::upload_epoch`. - let accepted: std::collections::HashSet = mesh + let accepted: HashSet = mesh .replaced .clone() .filter(|si| { @@ -725,30 +878,6 @@ impl ChunkBufferStore { }) .collect(); - // Retire the slices of every accepted covered section: the re-meshed - // ones are re-allocated below, the now-empty ones simply vanish. - // Remember which were present so a re-meshed section swaps instantly - // while a freshly revealed one still fades in. Rejected sections are - // left untouched. - let mut freed: Vec<(u32, u32, u32, u32)> = Vec::new(); - let mut was_present: std::collections::HashSet = std::collections::HashSet::new(); - if let Some(entry) = self.chunks.get_mut(&mesh.pos) { - entry.sections.retain(|s| { - if accepted.contains(&s.section_index) { - was_present.insert(s.section_index); - freed.push((s.vertex_offset as u32, s.vtx_len, s.first_index, s.idx_len)); - false - } else { - true - } - }); - } - self.retire_slices(freed.iter().copied()); - // Sections were removed/replaced, so the draw list must be rebuilt even - // if this mesh is skipped below (otherwise it keeps drawing a retired, - // soon-reused slice). - self.meta_dirty = true; - // A mesh from before a format change is remeshed anyway. let upload_secs: Vec<&SectionMesh> = mesh .sections @@ -757,22 +886,9 @@ impl ChunkBufferStore { .filter(|s| s.vertices.format() == self.format) .collect(); - if upload_secs.is_empty() { - // Every accepted section is now empty (freed above); drop the - // column if nothing remains. - if self - .chunks - .get(&mesh.pos) - .is_some_and(|c| c.sections.is_empty()) - { - self.chunks.remove(&mesh.pos); - } - continue; - } - - if self.use_staging { + if self.use_staging && !upload_secs.is_empty() { // Verts and indices share the staging buffer (two halves). A chunk - // too large for one half is skipped rather than overflowing the + // too large for one half is dropped rather than overflowing the // buffer. This is permanent, so it's not reported for retry. let v_bytes: usize = upload_secs.iter().map(|s| s.vertices.bytes().len()).sum(); let i_bytes: usize = upload_secs @@ -786,15 +902,18 @@ impl ChunkBufferStore { v_bytes, i_bytes, ); + self.retire_sections(mesh.pos, &accepted); continue; } } + // Allocate the new slices before retiring the old: no room then + // keeps the old mesh drawn instead of dropping it. let mut plans: Vec = Vec::with_capacity(upload_secs.len()); - // (vtx_off, vtx_len, idx_off, idx_len) taken for this mesh, for - // rollback if the pool runs out partway through a column. - let mut taken: Vec<(u32, u32, u32, u32)> = Vec::new(); - let mut pool_full = false; + let mut need = Need::default(); + // Past the eviction frontier a column waits for room instead. + let beyond = self.beyond_frontier(mesh.pos); + let mut no_room = beyond; for sec in &upload_secs { let vcount = sec.vertices.len() as u32; // Opaque and water indices share one slice (opaque first, water after). @@ -802,30 +921,26 @@ impl ChunkBufferStore { if vcount == 0 || icount == 0 { continue; } - let Some(vtx_off) = self.vtx_free.alloc(vcount) else { - self.free_slices(&taken); - tracing::debug!("Vertex pool full, skipping {:?}", mesh.pos); - pool_full = true; - break; - }; - let Some(idx_off) = self.idx_free.alloc(icount) else { - self.vtx_free.free_region(vtx_off, vcount); - self.free_slices(&taken); - tracing::debug!("Index pool full, skipping {:?}", mesh.pos); - pool_full = true; - break; + need.add(vcount, icount); + if no_room { + continue; + } + let Some((arena, vtx_off, idx_off)) = self.alloc_slice(device, vcount, icount) + else { + no_room = true; + continue; }; debug_assert_eq!( sec.vertices.bytes().len() as u64, - u64::from(vcount) * self.format.vertex_size() + u64::from(vcount) * vertex_size ); - taken.push((vtx_off, vcount, idx_off, icount)); plans.push(Plan { section_index: sec.section_index, verts: sec.vertices.bytes(), vertex_count: vcount, indices: &sec.indices, water_indices: &sec.water_indices, + arena, vtx_off, idx_off, solid_index_count: sec.solid_index_count, @@ -837,14 +952,32 @@ impl ChunkBufferStore { ], }); } - if pool_full { - // The accepted sections were retired above; report them so the - // next rescan re-enqueues them. - needs_remesh.push((mesh.pos, accepted.iter().copied().collect())); + if no_room { + // Never drawn, so these go straight back. + for p in &plans { + self.free_slice(p.slice()); + } + tracing::debug!("No room for {:?}'s mesh, keeping its old one", mesh.pos); + if !beyond { + self.evict_beyond(mesh.pos, need); + } + self.starve(mesh.pos, accepted, need); continue; } + + // Remember which sections were present so a re-meshed one swaps + // instantly while a freshly revealed one still fades in. + let was_present = self.retire_sections(mesh.pos, &accepted); if plans.is_empty() { - // Nothing to upload (all accepted sections were empty). + // Every accepted section is now empty; drop the column if + // nothing remains. + if self + .chunks + .get(&mesh.pos) + .is_some_and(|c| c.sections.is_empty()) + { + self.chunks.remove(&mesh.pos); + } continue; } @@ -852,42 +985,44 @@ impl ChunkBufferStore { let mv: usize = plans.iter().map(|p| p.verts.len()).sum(); let mi: usize = plans .iter() - .map(|p| (p.indices.len() + p.water_indices.len()) * INDEX_SIZE as usize) + .map(|p| p.index_len() as usize * INDEX_SIZE as usize) .sum(); // This mesh alone fits a half (checked above), so a flush always // makes room: submit the pending transfer and reset the cursors. if stg_v + mv > staging_half || stg_i + mi > staging_half { - self.flush_transfer(device, ©_v, ©_i); - copy_v.clear(); - copy_i.clear(); + self.flush_transfer(device, &mut copies); stg_v = 0; stg_i = 0; } for p in &plans { self.staging_buffer.write_at(stg_v, p.verts); let vbytes = p.verts.len(); - copy_v.push(rhi::BufferCopy { + let vertex = rhi::BufferCopy { src_offset: stg_v as u64, - dst_offset: u64::from(p.vtx_off) * self.format.vertex_size(), + dst_offset: u64::from(p.vtx_off) * vertex_size, size: vbytes as u64, - }); + }; stg_v += vbytes; let off = staging_half + stg_i; let ibytes = p.write_indices(&mut self.staging_buffer, off); - copy_i.push(rhi::BufferCopy { + let index = rhi::BufferCopy { src_offset: off as u64, - dst_offset: p.idx_off as u64 * INDEX_SIZE, + dst_offset: u64::from(p.idx_off) * INDEX_SIZE, size: ibytes as u64, - }); + }; stg_i += ibytes; + copies.push((p.arena, vertex, index)); } } else { for p in &plans { - let base = u64::from(p.vtx_off) * self.format.vertex_size(); - self.vertex_buffer.write_at(base as usize, p.verts); + let Some(arena) = self.arenas[p.arena as usize].as_mut() else { + continue; + }; + let base = u64::from(p.vtx_off) * vertex_size; + arena.vertex_buffer.write_at(base as usize, p.verts); p.write_indices( - &mut self.index_buffer, + &mut arena.index_buffer, p.idx_off as usize * INDEX_SIZE as usize, ); } @@ -898,12 +1033,13 @@ impl ChunkBufferStore { section_index: p.section_index, aabb: p.aabb, origin: p.origin, + arena: p.arena, first_index: p.idx_off, index_count: p.indices.len() as u32, solid_index_count: p.solid_index_count, water_first_index: p.idx_off + p.indices.len() as u32, water_index_count: p.water_indices.len() as u32, - idx_len: (p.indices.len() + p.water_indices.len()) as u32, + idx_len: p.index_len(), vertex_offset: p.vtx_off as i32, vtx_len: p.vertex_count, // A re-meshed section swaps instantly; a freshly revealed one fades in. @@ -939,71 +1075,274 @@ impl ChunkBufferStore { } // Flush whatever remains accumulated from the last (or only) batch. - if self.use_staging { - self.flush_transfer(device, ©_v, ©_i); + self.flush_transfer(device, &mut copies); + self.ensure_meta_capacity(device); + + needs_remesh + } + + /// Grow each arena's meta and indirect buffers to hold its sections' + /// draws. No-op while capacity suffices. + fn ensure_meta_capacity(&mut self, device: &rhi::backend::Device) { + let mut sections = vec![0usize; self.arenas.len()]; + for sec in self.chunks.values().flat_map(|c| &c.sections) { + sections[sec.arena as usize] += 1; + } + let mut waited = false; + for (arena, needed) in self.arenas.iter_mut().zip(sections) { + let Some(arena) = arena.as_mut().filter(|a| needed > a.max_meta) else { + continue; + }; + // The meta/indirect buffers are referenced by every in-flight frame's + // bind group; wait the GPU out before freeing them. + if !std::mem::replace(&mut waited, true) { + device.wait_idle(); + } + arena.regrow(device, self.cull_layout, needed); } + } - let total_sections: usize = self.chunks.values().map(|c| c.sections.len()).sum(); - self.ensure_meta_capacity(device, total_sections); + /// Return one slice's vertex and index ranges to its arena. + fn free_slice(&mut self, slice: Slice) { + if let Some(arena) = self.arenas[slice.arena as usize].as_mut() { + arena.vertices.free(slice.vertex_offset); + arena.indices.free(slice.first_index); + } + } - needs_remesh + /// A vertex and an index slice in one arena, adding an arena when they're + /// all full; `None` once that would pass the budget. + fn alloc_slice( + &mut self, + device: &rhi::backend::Device, + vertices: u32, + indices: u32, + ) -> Option<(u16, u32, u32)> { + if let Some(found) = alloc_in(&mut self.arenas, self.draining, vertices, indices) { + return Some(found); + } + if !self.can_add_arena(device) { + return None; + } + self.add_arena(device); + alloc_in(&mut self.arenas, self.draining, vertices, indices) } - /// Grow the per-frame meta and indirect buffers so they can hold `needed` - /// section draws. No-op while capacity suffices. - fn ensure_meta_capacity(&mut self, device: &rhi::backend::Device, needed: usize) { - if needed <= self.max_meta { - return; + /// Whether one more arena fits the budget: what `VK_EXT_memory_budget` + /// says this process may use, less headroom for everything else, or + /// without it a share of device-local memory. The first always fits. + fn can_add_arena(&self, device: &rhi::backend::Device) -> bool { + let arenas = self.arena_count(); + if arenas == 0 { + return true; } - let new_max = (needed.saturating_mul(3) / 2) - .next_power_of_two() - .max(self.max_meta * 2); + let bytes = arena_bytes(self.format); + match device.device_local_budget() { + Some(budget) => { + let headroom = MIN_BUDGET_HEADROOM.max(budget.budget / 8); + budget.usage + bytes + headroom <= budget.budget + } + None => { + (arenas + 1) * bytes + <= (self.device_local_bytes as f64 * VRAM_BUDGET_FRACTION) as u64 + } + } + } - // The meta/indirect buffers are referenced by every in-flight frame's - // bind group; wait the GPU out before freeing them. - device.wait_idle(); + fn add_arena(&mut self, device: &rhi::backend::Device) { + let arena = Arena::new(device, self.use_staging, self.format, self.cull_layout); + match self.arenas.iter().position(Option::is_none) { + Some(id) => self.arenas[id] = Some(arena), + None => self.arenas.push(Some(arena)), + } + self.camera_meta.resize_with(self.arenas.len(), Vec::new); + self.shadow_meta.resize_with(self.arenas.len(), Vec::new); + self.log_arenas("added"); + } - let meta_size = (new_max * size_of::()) as u64; - let indirect_size = (new_max * size_of::()) as u64; - for list in std::iter::once(&mut self.camera).chain(&mut self.shadow) { - list.regrow(device, self.cull_layout, meta_size, indirect_size); + /// Arenas allocated, doomed ones included. + fn arena_count(&self) -> u64 { + self.arenas.iter().flatten().count() as u64 + } + + fn log_arenas(&self, change: &str) { + let arenas = self.arena_count(); + tracing::info!( + "Chunk arena {change}: {arenas} arenas, {} MB", + arenas * arena_bytes(self.format) / (1024 * 1024) + ); + } + + /// Evicts the columns farthest from the eye, all farther than `pos`, until + /// they free what `pos` needs, so a full budget draws a shorter distance + /// instead of leaving holes nearby. The evicted columns starve until + /// there's room for them again. + fn evict_beyond(&mut self, pos: ChunkPos, need: Need) { + let Some(eye) = self.eye else { + return; + }; + let limit = column_dist_sq(pos, eye); + let mut nearest = f64::INFINITY; + let mut far: Vec<(f64, ChunkPos)> = self + .chunks + .keys() + .map(|&p| (column_dist_sq(p, eye), p)) + .filter(|&(dist, _)| dist > limit) + .collect(); + far.sort_unstable_by(|a, b| b.0.total_cmp(&a.0)); + let mut freed = Need::default(); + for (dist, victim) in far { + if freed.vertices >= need.vertices && freed.indices >= need.indices { + break; + } + nearest = dist; + let Some(alloc) = self.chunks.remove(&victim) else { + continue; + }; + let mut taken = Need::default(); + for sec in &alloc.sections { + taken.add(sec.vtx_len, sec.idx_len); + } + freed.vertices += taken.vertices; + freed.indices += taken.indices; + self.retire_slices(alloc.sections.iter().map(SectionAlloc::slice)); + let sections = alloc.sections.iter().map(|s| s.section_index); + self.starve(victim, sections, taken); + tracing::debug!("Evicted {victim:?} for {pos:?}"); } + self.frontier_sq = self.frontier_sq.min(nearest); + self.meta_dirty = true; + } - self.max_meta = new_max; + fn starve(&mut self, pos: ChunkPos, sections: impl IntoIterator, need: Need) { + let starved = self.starved.entry(pos).or_default(); + starved.sections.extend(sections); + starved.need = starved.need.max(need); } - /// Return one slice's vertex and index ranges to the pools. - fn free_slice(&mut self, (vo, vl, io, il): (u32, u32, u32, u32)) { - self.vtx_free.free_region(vo, vl); - self.idx_free.free_region(io, il); + /// Whether `pos` is at or past the eviction frontier. + fn beyond_frontier(&self, pos: ChunkPos) -> bool { + self.eye + .is_some_and(|eye| column_dist_sq(pos, eye) >= self.frontier_sq) } - /// Return slices immediately. Only safe for slices never submitted to a - /// frame (e.g. rolling back allocations made earlier in the same `upload`); - /// slices that may still be drawn by an in-flight frame must go through - /// `retire_slices`. - fn free_slices(&mut self, slices: &[(u32, u32, u32, u32)]) { - for &slice in slices { - self.free_slice(slice); + /// Hands the starved columns that could fit now back for re-meshing, + /// nearest first: all of them while another arena fits the budget, else + /// those whose total fits the arenas' free space and whose largest + /// section fits some arena. Past the frontier, only while + /// [`FRONTIER_SLACK`] of an arena stays free after, so the frontier + /// doesn't swing back and forth as columns come and go. + fn retry_starved( + &mut self, + device: &rhi::backend::Device, + out: &mut Vec<(ChunkPos, Vec)>, + ) { + if self.starved.is_empty() { + return; + } + if self.can_add_arena(device) { + self.frontier_sq = f64::INFINITY; + out.extend( + self.starved + .drain() + .map(|(pos, starved)| (pos, starved.sections.into_iter().collect())), + ); + return; + } + let arenas: Vec<&Arena> = self + .arenas + .iter() + .enumerate() + .filter(|&(id, _)| self.draining != Some(id as u16)) + .filter_map(|(_, slot)| live(slot)) + .collect(); + let mut free_vertices: u64 = arenas + .iter() + .map(|a| u64::from(a.vertices.capacity() - a.vertices.used())) + .sum(); + let mut free_indices: u64 = arenas + .iter() + .map(|a| u64::from(a.indices.capacity() - a.indices.used())) + .sum(); + let mut fitting: Vec<(f64, ChunkPos, Need)> = self + .starved + .iter() + .filter(|(_, s)| { + arenas.iter().any(|a| { + a.vertices.fits(s.need.max_vertices) && a.indices.fits(s.need.max_indices) + }) + }) + .map(|(&pos, s)| { + let dist = self.eye.map_or(0.0, |eye| column_dist_sq(pos, eye)); + (dist, pos, s.need) + }) + .collect(); + fitting.sort_unstable_by(|a, b| a.0.total_cmp(&b.0)); + let slack_vertices = u64::from(ARENA_VERTICES) / FRONTIER_SLACK; + let slack_indices = u64::from(ARENA_INDICES) / FRONTIER_SLACK; + for (dist, pos, need) in fitting { + let past = dist >= self.frontier_sq; + let (keep_vertices, keep_indices) = if past { + (slack_vertices, slack_indices) + } else { + (0, 0) + }; + if need.vertices + keep_vertices > free_vertices + || need.indices + keep_indices > free_indices + { + if past { + break; + } + continue; + } + free_vertices -= need.vertices; + free_indices -= need.indices; + if past { + self.frontier_sq = dist.next_up(); + } + if let Some(starved) = self.starved.remove(&pos) { + out.push((pos, starved.sections.into_iter().collect())); + } } } - /// Defer returning slices to the pools until `MAX_FRAMES_IN_FLIGHT` frames - /// have passed, so the GPU can't still be reading them from an in-flight - /// frame. Use for slices that were potentially drawn (re-mesh replacement, - /// chunk unload). - fn retire_slices(&mut self, slices: impl IntoIterator) { + /// Retires `pos`'s `sections`, returning those it had; the others stay. + fn retire_sections(&mut self, pos: ChunkPos, sections: &HashSet) -> HashSet { + let mut freed = Vec::new(); + let mut was_present = HashSet::new(); + if let Some(entry) = self.chunks.get_mut(&pos) { + entry.sections.retain(|s| { + if sections.contains(&s.section_index) { + was_present.insert(s.section_index); + freed.push(s.slice()); + false + } else { + true + } + }); + } + self.retire_slices(freed); + // The draw list must drop them before their slices are reused. + self.meta_dirty = true; + was_present + } + + /// Defer returning slices to the arenas until `MAX_FRAMES_IN_FLIGHT` + /// frames have passed, so the GPU can't still be reading them from an + /// in-flight frame. Use for slices that were potentially drawn. + fn retire_slices(&mut self, slices: impl IntoIterator) { let retire_at = self.frame_seq + MAX_FRAMES_IN_FLIGHT as u64; for slice in slices { self.pending_free.push_back((retire_at, slice)); } } - /// Advance one frame and reclaim any slices whose retirement deadline has - /// passed. Call once per rendered frame, right after the frame's fence has - /// been waited (that wait guarantees the frame from `MAX_FRAMES_IN_FLIGHT` - /// ago — and everything before it — is done on the GPU). - pub fn begin_frame(&mut self) { + /// Advance one frame, reclaim any slices whose retirement deadline has + /// passed, then free empty arenas and compact. Call once per rendered + /// frame, right after the frame's fence has been waited (that wait + /// guarantees the frame from `MAX_FRAMES_IN_FLIGHT` ago — and everything + /// before it — is done on the GPU). + pub fn begin_frame(&mut self, device: &rhi::backend::Device) { self.frame_seq += 1; while self .pending_free @@ -1012,39 +1351,163 @@ impl ChunkBufferStore { { let (_, slice) = self.pending_free.pop_front().unwrap(); self.free_slice(slice); + self.reclaimed = true; } + self.free_empty_arenas(device); + self.compact(device); + } + + /// Destroys doomed arenas no frame reads any more, and dooms empty ones, + /// as vanilla destroys an empty heap, keeping one for the next upload. + fn free_empty_arenas(&mut self, device: &rhi::backend::Device) { + let mut destroyed = false; + for (id, slot) in self.arenas.iter_mut().enumerate() { + if slot + .as_ref() + .and_then(|a| a.doomed) + .is_some_and(|at| at <= self.frame_seq) + { + if let Some(arena) = slot.take() { + arena.destroy(device); + } + self.camera_meta[id].clear(); + self.shadow_meta[id].clear(); + destroyed = true; + } + } + if destroyed { + self.log_arenas("freed"); + } + let mut live_count = self.arenas.iter().filter_map(live).count(); + for (id, slot) in self.arenas.iter_mut().enumerate() { + let Some(arena) = slot.as_mut() else { + continue; + }; + if live_count > 1 && arena.doomed.is_none() && arena.is_empty() { + // Frames from here on skip it; earlier ones are done by then. + arena.doomed = Some(self.frame_seq + MAX_FRAMES_IN_FLIGHT as u64); + live_count -= 1; + if self.draining == Some(id as u16) { + self.draining = None; + } + } + } + } + + /// Once the arenas' contents would fit one fewer, drains the emptiest + /// into the rest, a bounded amount per frame, so freeing it can shrink + /// the total. + fn compact(&mut self, device: &rhi::backend::Device) { + if self.frame_seq < self.compact_after { + return; + } + let from = match self.draining { + Some(id) => id, + None => { + let live: Vec<(usize, &Arena)> = self + .arenas + .iter() + .enumerate() + .filter_map(|(id, slot)| live(slot).map(|a| (id, a))) + .collect(); + if live.len() < 2 { + return; + } + let used_vertices: u64 = + live.iter().map(|(_, a)| u64::from(a.vertices.used())).sum(); + let used_indices: u64 = live.iter().map(|(_, a)| u64::from(a.indices.used())).sum(); + let room = (live.len() - 1) as f64 * COMPACT_FILL; + if used_vertices as f64 > room * f64::from(ARENA_VERTICES) + || used_indices as f64 > room * f64::from(ARENA_INDICES) + { + return; + } + let Some(&(id, _)) = live.iter().min_by_key(|(_, a)| a.vertices.used()) else { + return; + }; + tracing::debug!("Compacting chunk arena {id}"); + self.draining = Some(id as u16); + id as u16 + } + }; + + let vertex_size = self.format.vertex_size(); + let mut copies: Vec<(u16, rhi::BufferCopy, rhi::BufferCopy)> = Vec::new(); + let mut retired = Vec::new(); + let mut moved = 0; + 'columns: for alloc in self.chunks.values_mut() { + for sec in alloc.sections.iter_mut().filter(|s| s.arena == from) { + if moved >= COMPACT_VERTICES_PER_FRAME { + break 'columns; + } + let Some((to, vertex, index)) = + alloc_in(&mut self.arenas, Some(from), sec.vtx_len, sec.idx_len) + else { + tracing::debug!("No room to compact chunk arena {from}"); + self.draining = None; + self.compact_after = self.frame_seq + COMPACT_BACKOFF_FRAMES; + break 'columns; + }; + copies.push(( + to, + rhi::BufferCopy { + src_offset: sec.vertex_offset as u64 * vertex_size, + dst_offset: u64::from(vertex) * vertex_size, + size: u64::from(sec.vtx_len) * vertex_size, + }, + rhi::BufferCopy { + src_offset: u64::from(sec.first_index) * INDEX_SIZE, + dst_offset: u64::from(index) * INDEX_SIZE, + size: u64::from(sec.idx_len) * INDEX_SIZE, + }, + )); + retired.push(sec.slice()); + sec.arena = to; + sec.water_first_index = index + (sec.water_first_index - sec.first_index); + sec.first_index = index; + sec.vertex_offset = vertex as i32; + moved += sec.vtx_len; + } + } + if copies.is_empty() { + return; + } + let src = self.arenas[from as usize].as_ref(); + device.submit(|encoder| { + record_copies( + encoder, + &self.arenas, + src, + &self.staging_buffer, + &mut copies, + ); + }); + self.retire_slices(retired); + self.meta_dirty = true; + self.ensure_meta_capacity(device); } pub fn remove(&mut self, pos: &ChunkPos) { + self.starved.remove(pos); if let Some(alloc) = self.chunks.remove(pos) { - self.retire_slices(alloc.sections.iter().map(|sec| { - ( - sec.vertex_offset as u32, - sec.vtx_len, - sec.first_index, - sec.idx_len, - ) - })); + self.retire_slices(alloc.sections.iter().map(SectionAlloc::slice)); self.meta_dirty = true; } } - /// Drops every mesh and, for a new format, remakes the vertex pool at its - /// stride. The caller waits for in-flight frames first. + /// Drops every mesh and, for a new format, the arenas, remade at its + /// stride on the next upload. The caller waits for in-flight frames first. pub fn set_format(&mut self, device: &rhi::backend::Device, format: TerrainFormat) { self.clear(); if format == self.format { return; } self.format = format; - std::mem::take(&mut self.vertex_buffer).destroy(device); - self.vertex_buffer = create_pool( - device, - self.use_staging, - "vertex_pool", - u64::from(self.pool_vertices) * format.vertex_size(), - rhi::BufferUsages::VERTEX, - ); + for arena in self.arenas.drain(..).flatten() { + arena.destroy(device); + } + self.camera_meta.clear(); + self.shadow_meta.clear(); } pub fn format(&self) -> TerrainFormat { @@ -1053,38 +1516,73 @@ impl ChunkBufferStore { pub fn clear(&mut self) { self.chunks.clear(); - self.vtx_free.reset(); - self.idx_free.reset(); + for arena in self.arenas.iter_mut().flatten() { + arena.vertices.reset(); + arena.indices.reset(); + } self.pending_free.clear(); - self.camera.meta.clear(); + self.starved.clear(); + self.frontier_sq = f64::INFINITY; + self.draining = None; + for meta in self.camera_meta.iter_mut().chain(&mut self.shadow_meta) { + meta.clear(); + } self.meta_dirty = true; self.fade_enabled = false; } - /// Where the pools' ranges overlap, if they do: live sections, slices + /// A column not drawing its newest mesh, or none, for lack of room. + #[cfg(feature = "test-harness")] + pub fn starved_column(&self) -> Option { + self.starved.keys().next().copied() + } + + /// Where an arena's ranges overlap, if they do: live sections, slices /// awaiting reclaim and free ranges must never share a vertex or index. #[cfg(feature = "test-harness")] pub fn slice_overlap(&self) -> Option { - let mut vertex = self.vtx_free.free.clone(); - let mut index = self.idx_free.free.clone(); + let mut ranges: Vec<[Vec<(u32, u32)>; 2]> = self + .arenas + .iter() + .map(|slot| match slot { + Some(a) => [ + a.vertices.free_blocks().collect(), + a.indices.free_blocks().collect(), + ], + None => Default::default(), + }) + .collect(); let live = self .chunks .values() .flat_map(|c| &c.sections) - .map(|s| (s.vertex_offset as u32, s.vtx_len, s.first_index, s.idx_len)); + .map(SectionAlloc::slice); let retired = self.pending_free.iter().map(|&(_, slice)| slice); - for (vtx_off, vtx_len, idx_off, idx_len) in live.chain(retired) { - vertex.push((vtx_off, vtx_len)); - index.push((idx_off, idx_len)); + for slice in live.chain(retired) { + let Some([vertex, index]) = ranges + .get_mut(slice.arena as usize) + .filter(|_| self.arenas[slice.arena as usize].is_some()) + else { + return Some(format!("a slice in arena {}, which is gone", slice.arena)); + }; + vertex.push((slice.vertex_offset, slice.vertex_len)); + index.push((slice.first_index, slice.index_len)); } - [("vertex", vertex), ("index", index)] + ranges .into_iter() - .find_map(|(pool, mut ranges)| { + .enumerate() + .flat_map(|(id, [vertex, index])| [(id, "vertex", vertex), (id, "index", index)]) + .find_map(|(id, pool, mut ranges)| { ranges.sort_unstable(); ranges .windows(2) .find(|w| w[0].0 + w[0].1 > w[1].0) - .map(|w| format!("{pool} ranges {:?} and {:?} overlap", w[0], w[1])) + .map(|w| { + format!( + "arena {id}'s {pool} ranges {:?} and {:?} overlap", + w[0], w[1] + ) + }) }) } @@ -1111,6 +1609,7 @@ impl ChunkBufferStore { anchor: DVec3, eye: DVec3, ) { + self.eye = Some(eye); if self.chunks.is_empty() { return; } @@ -1133,7 +1632,7 @@ impl ChunkBufferStore { self.fade_enabled, eye, now, - &mut self.camera.meta, + &mut self.camera_meta, ); self.meta_dirty = false; self.shadow_dirty = true; @@ -1141,46 +1640,62 @@ impl ChunkBufferStore { let cam_moved = (eye - self.last_sort_cam).length_squared() > SORT_RECAM_SQ; if content_changed || cam_moved { - sort_meta(&mut self.camera.meta, eye); + for meta in &mut self.camera_meta { + sort_meta(meta, eye); + } self.last_sort_cam = eye; - // Draw list reordered: every frame slot's meta buffer needs the refresh. - self.camera.upload_pending = MAX_FRAMES_IN_FLIGHT as u32; + // Draw lists reordered: every frame slot's meta buffer needs the refresh. + for arena in self.arenas.iter_mut().flatten() { + arena.camera.upload_pending = MAX_FRAMES_IN_FLIGHT as u32; + } } // This frame slot's GPU work has completed (fence-waited at frame start), - // so its readback holds the previous cull's total (solid + cutout draws). - self.last_draw_count = self.count_readbacks[frame].read(|bytes| { - let counts: [u32; 2] = bytemuck::pod_read_unaligned(&bytes[..8]); - counts[0] + counts[1] - }); - self.camera.cull( - encoder, - &self.compute_pipeline, - frame, - frustum, - anchor, - eye, - !self.indirect_draw.count, - ); - encoder.transition_buffers(rhi::BufferUse::StorageWrite, rhi::BufferUse::CopySrc); - let readback = self.count_readbacks[frame].handle(); - for (i, count) in [ - &self.camera.count_buffers[frame], - &self.camera.count_cutout_buffers[frame], - ] - .into_iter() - .enumerate() - { - encoder.copy_buffer( - count.handle(), - readback, - &[rhi::BufferCopy { - src_offset: 0, - dst_offset: i as u64 * COUNT_SIZE, - size: COUNT_SIZE, - }], + // so its readbacks hold the previous cull's totals (solid + cutout draws). + self.last_draw_count = 0; + for (slot, meta) in self.arenas.iter_mut().zip(&self.camera_meta) { + let Some(arena) = live_mut(slot).filter(|_| !meta.is_empty()) else { + continue; + }; + self.last_draw_count += arena.count_readbacks[frame].read(|bytes| { + let counts: [u32; 2] = bytemuck::pod_read_unaligned(&bytes[..8]); + counts[0] + counts[1] + }); + arena.camera.cull( + encoder, + &self.compute_pipeline, + frame, + meta, + frustum, + anchor, + eye, + !self.indirect_draw.count, ); } + encoder.transition_buffers(rhi::BufferUse::StorageWrite, rhi::BufferUse::CopySrc); + for (slot, meta) in self.arenas.iter().zip(&self.camera_meta) { + let Some(arena) = live(slot).filter(|_| !meta.is_empty()) else { + continue; + }; + let readback = arena.count_readbacks[frame].handle(); + for (i, count) in [ + &arena.camera.count_buffers[frame], + &arena.camera.count_cutout_buffers[frame], + ] + .into_iter() + .enumerate() + { + encoder.copy_buffer( + count.handle(), + readback, + &[rhi::BufferCopy { + src_offset: 0, + dst_offset: i as u64 * COUNT_SIZE, + size: COUNT_SIZE, + }], + ); + } + } encoder.transition_buffers(rhi::BufferUse::CopyDst, rhi::BufferUse::HostRead); if !self.fade_enabled { @@ -1203,11 +1718,6 @@ impl ChunkBufferStore { if self.chunks.is_empty() { return; } - let shadow = self.shadow.get_or_insert_with(|| { - let meta_size = (self.max_meta * size_of::()) as u64; - let indirect_size = (self.max_meta * size_of::()) as u64; - DrawList::new(device, self.cull_layout, meta_size, indirect_size) - }); if self.shadow_dirty { build_meta( &self.chunks, @@ -1215,26 +1725,44 @@ impl ChunkBufferStore { self.fade_enabled, eye, std::time::Instant::now(), - &mut shadow.meta, + &mut self.shadow_meta, ); - shadow.upload_pending = MAX_FRAMES_IN_FLIGHT as u32; + for shadow in self + .arenas + .iter_mut() + .flatten() + .filter_map(|a| a.shadow.as_mut()) + { + shadow.upload_pending = MAX_FRAMES_IN_FLIGHT as u32; + } self.shadow_dirty = false; } - shadow.cull( - encoder, - &self.compute_pipeline, - frame, - frustum, - anchor, - eye, - !self.indirect_draw.count, - ); + for (slot, meta) in self.arenas.iter_mut().zip(&self.shadow_meta) { + let Some(arena) = live_mut(slot).filter(|_| !meta.is_empty()) else { + continue; + }; + let (meta_size, indirect_size) = list_sizes(arena.max_meta); + let shadow = arena.shadow.get_or_insert_with(|| { + DrawList::new(device, self.cull_layout, meta_size, indirect_size) + }); + shadow.cull( + encoder, + &self.compute_pipeline, + frame, + meta, + frustum, + anchor, + eye, + !self.indirect_draw.count, + ); + } } - /// Issue one render layer's indirect draws for `view`. `cutout` selects - /// the discard pass's draw list (drawn after `solid`, which lays down - /// depth); the caller binds the matching pipeline first. Both layers share - /// the vertex/index/meta buffers and the cull-written draw lists. + /// Issue one render layer's indirect draws for `view`, one call per + /// arena. `cutout` selects the discard pass's draw lists (drawn after + /// `solid`, which lays down depth); the caller binds the matching pipeline + /// first. Both layers share each arena's vertex/index/meta buffers and + /// the cull-written draw lists. pub fn draw_indirect( &self, pass: &rhi::backend::RenderPassEncoder, @@ -1242,47 +1770,52 @@ impl ChunkBufferStore { cutout: bool, view: View, ) { - let list = match view { - View::Camera => &self.camera, - View::Shadow => match &self.shadow { - Some(shadow) => shadow, - None => return, - }, - }; if self.chunks.is_empty() { return; } - - let max_draws = self - .chunks - .values() - .map(|c| c.sections.len() as u32) - .sum::(); - let (indirect, count) = if cutout { - ( - list.indirect_cutout_buffers[frame].handle(), - list.count_cutout_buffers[frame].handle(), - ) - } else { - ( - list.indirect_buffers[frame].handle(), - list.count_buffers[frame].handle(), - ) + let metas = match view { + View::Camera => &self.camera_meta, + View::Shadow => &self.shadow_meta, }; - - // Binding 0: packed vertex pool. Binding 1: the meta buffer, read per - // instance for the section origin + fade (indexed by `first_instance`). - pass.set_vertex_buffer(0, self.vertex_buffer.handle()); - pass.set_vertex_buffer(1, list.meta_buffers[frame].handle()); - pass.set_index_buffer(self.index_buffer.handle()); - let stride = size_of::() as u32; - if self.indirect_draw.count { - pass.draw_indexed_indirect_count(indirect, count, max_draws, stride); - } else if self.indirect_draw.multi_draw { - pass.draw_indexed_indirect(indirect, 0, max_draws, stride); - } else { - for i in 0..max_draws { - pass.draw_indexed_indirect(indirect, (i * stride) as u64, 1, stride); + for (slot, meta) in self.arenas.iter().zip(metas) { + let Some(arena) = live(slot).filter(|_| !meta.is_empty()) else { + continue; + }; + let list = match view { + View::Camera => &arena.camera, + View::Shadow => match &arena.shadow { + Some(shadow) => shadow, + None => continue, + }, + }; + let max_draws = meta.len() as u32; + let (indirect, count) = if cutout { + ( + list.indirect_cutout_buffers[frame].handle(), + list.count_cutout_buffers[frame].handle(), + ) + } else { + ( + list.indirect_buffers[frame].handle(), + list.count_buffers[frame].handle(), + ) + }; + + // Binding 0: the arena's packed vertices. Binding 1: its meta + // buffer, read per instance for the section origin + fade (indexed + // by `first_instance`). + pass.set_vertex_buffer(0, arena.vertex_buffer.handle()); + pass.set_vertex_buffer(1, list.meta_buffers[frame].handle()); + pass.set_index_buffer(arena.index_buffer.handle()); + let stride = size_of::() as u32; + if self.indirect_draw.count { + pass.draw_indexed_indirect_count(indirect, count, max_draws, stride); + } else if self.indirect_draw.multi_draw { + pass.draw_indexed_indirect(indirect, 0, max_draws, stride); + } else { + for i in 0..max_draws { + pass.draw_indexed_indirect(indirect, (i * stride) as u64, 1, stride); + } } } } @@ -1300,7 +1833,7 @@ impl ChunkBufferStore { } /// Draw the translucent water of every section that survives a CPU frustum - /// cull. Reuses the shared vertex/index buffers (water indices live right + /// cull. Reuses the arenas' vertex/index buffers (water indices live right /// after the opaque ones in each section's slice); the caller binds the /// blended water pipeline first. Not GPU-culled — water sections are a /// small subset, so a per-section draw is cheap and keeps the opaque @@ -1325,9 +1858,7 @@ impl ChunkBufferStore { return; } - pass.set_vertex_buffer(0, self.vertex_buffer.handle()); - pass.set_index_buffer(self.index_buffer.handle()); - + let mut bound = None; let now = std::time::Instant::now(); for (pos, alloc) in self.chunks.iter() { let col_vis = self.chunk_visibility.get(pos).copied().unwrap_or(u32::MAX); @@ -1339,6 +1870,14 @@ impl ChunkBufferStore { { continue; } + if bound != Some(sec.arena) { + let Some(arena) = self.arenas[sec.arena as usize].as_ref() else { + continue; + }; + pass.set_vertex_buffer(0, arena.vertex_buffer.handle()); + pass.set_index_buffer(arena.index_buffer.handle()); + bound = Some(sec.arena); + } let vis = Self::section_visibility(nearby, sec, now); let rel = (origin_dvec(sec.origin) - anchor).as_vec3(); let origin_fade = [rel.x, rel.y, rel.z, vis]; @@ -1359,13 +1898,9 @@ impl ChunkBufferStore { } pub fn destroy(&mut self, device: &rhi::backend::Device) { - for buffer in [&mut self.vertex_buffer, &mut self.index_buffer] { - std::mem::take(buffer).destroy(device); + for arena in self.arenas.drain(..).flatten() { + arena.destroy(device); } - for list in std::iter::once(&mut self.camera).chain(&mut self.shadow) { - list.destroy(device); - } - util::destroy_buffers(device, &mut self.count_readbacks); std::mem::take(&mut self.staging_buffer).destroy(device); self.compute_pipeline.destroy(device); @@ -1374,7 +1909,13 @@ impl ChunkBufferStore { } } -/// A chunk pool: device-local behind the staging buffer, or host-visible. +/// One arena's vertex and index heaps in bytes, at `format`'s vertex size. +fn arena_bytes(format: TerrainFormat) -> u64 { + u64::from(ARENA_VERTICES) * format.vertex_size() + u64::from(ARENA_INDICES) * INDEX_SIZE +} + +/// An arena heap: device-local behind the staging buffer, or host-visible. +/// Either way a copy source and target, for compaction. fn create_pool( device: &rhi::backend::Device, use_staging: bool, @@ -1382,17 +1923,13 @@ fn create_pool( size: u64, usage: rhi::BufferUsages, ) -> rhi::backend::Buffer { - if use_staging { - rhi::backend::Buffer::new( - device, - label, - size, - usage | rhi::BufferUsages::COPY_DST, - rhi::MemoryLocation::Device, - ) + let usage = usage | rhi::BufferUsages::COPY_SRC | rhi::BufferUsages::COPY_DST; + let location = if use_staging { + rhi::MemoryLocation::Device } else { - rhi::backend::Buffer::new(device, label, size, usage, rhi::MemoryLocation::Host) - } + rhi::MemoryLocation::Host + }; + rhi::backend::Buffer::new(device, label, size, usage, location) } /// Which view's draw lists [`ChunkBufferStore::draw_indirect`] draws. @@ -1406,8 +1943,7 @@ pub enum View { /// One view's sections and, per frame slot, the cull's frustum and the draw /// lists it writes. struct DrawList { - meta: Vec, - /// Frame slots still needing the latest `meta` uploaded. Set to + /// Frame slots still needing the latest meta uploaded. Set to /// `MAX_FRAMES_IN_FLIGHT` whenever the draw list changes, decremented per /// frame; at steady state the per-frame meta copy stops. upload_pending: u32, @@ -1441,7 +1977,6 @@ impl DrawList { ) }; let mut this = Self { - meta: Vec::new(), upload_pending: 0, cull_sets: vec![rhi::backend::BindGroup::default(); MAX_FRAMES_IN_FLIGHT], meta_buffers: Vec::new(), @@ -1509,8 +2044,9 @@ impl DrawList { } } - /// Records the cull of `meta` against `frustum` into this frame slot's - /// draw lists, uploading `meta` first if the slot hasn't seen it. + /// Records the cull of `meta`, this list's sections, against `frustum` + /// into this frame slot's draw lists, uploading `meta` first if the slot + /// hasn't seen it. /// `zero_indirect`: without drawIndirectCount the whole indirect buffer /// is drawn, so slots the cull leaves unfilled must read as no-op draws. #[allow(clippy::too_many_arguments)] @@ -1519,16 +2055,17 @@ impl DrawList { encoder: &rhi::backend::CommandEncoder, pipeline: &rhi::backend::ComputePipeline, frame: usize, + meta: &[ChunkMeta], frustum: &[[f32; 4]; 6], anchor: DVec3, eye: DVec3, zero_indirect: bool, ) { if self.upload_pending > 0 { - self.meta_buffers[frame].write(bytemuck::cast_slice(&self.meta)); + self.meta_buffers[frame].write(bytemuck::cast_slice(meta)); self.upload_pending -= 1; } - let count = self.meta.len() as u32; + let count = meta.len() as u32; let frustum_data = FrustumData { planes: *frustum, chunk_count: count, @@ -1591,9 +2128,10 @@ mod tests { let vis = HashMap::from([(near, 1u32 << 4)]); let eye = DVec3::new(8.0, 8.0, 8.0); - let mut meta = Vec::new(); - build_meta(&chunks, Some(&vis), true, eye, now, &mut meta); - sort_meta(&mut meta, eye); + let mut metas = [Vec::new()]; + build_meta(&chunks, Some(&vis), true, eye, now, &mut metas); + let meta = &mut metas[0]; + sort_meta(meta, eye); assert_eq!(meta.len(), 2); assert_eq!(meta[0].origin, [0, 0, 0]); @@ -1602,10 +2140,10 @@ mod tests { let far_vis = f32::from_bits(meta[1].visibility); assert!((far_vis - 0.5).abs() < 0.01, "far fade {far_vis}"); - build_meta(&chunks, Some(&vis), false, eye, now, &mut meta); - assert!(meta.iter().all(|m| f32::from_bits(m.visibility) == 1.0)); + build_meta(&chunks, Some(&vis), false, eye, now, &mut metas); + assert!(metas[0].iter().all(|m| f32::from_bits(m.visibility) == 1.0)); - build_meta(&chunks, None, false, eye, now, &mut meta); - assert_eq!(meta.len(), 3); + build_meta(&chunks, None, false, eye, now, &mut metas); + assert_eq!(metas[0].len(), 3); } } diff --git a/pomme-client/src/renderer/chunk/mod.rs b/pomme-client/src/renderer/chunk/mod.rs index c9ca5ebb1..f262b9a98 100644 --- a/pomme-client/src/renderer/chunk/mod.rs +++ b/pomme-client/src/renderer/chunk/mod.rs @@ -6,3 +6,4 @@ pub mod mesh_source; pub mod mesher; pub mod moving_blocks; pub mod occlusion_graph; +mod tlsf; diff --git a/pomme-client/src/renderer/chunk/tlsf.rs b/pomme-client/src/renderer/chunk/tlsf.rs new file mode 100644 index 000000000..719d3e9da --- /dev/null +++ b/pomme-client/src/renderer/chunk/tlsf.rs @@ -0,0 +1,269 @@ +//! A two-level segregated fit (TLSF) allocator over a range of `u32` +//! elements: a good fit in constant time from per-size-class free lists, and +//! freed blocks merged with free neighbours so the range doesn't fragment. + +use std::collections::{BTreeMap, BTreeSet}; + +/// Second-level classes per power of two. +const SL_BITS: u32 = 4; +const SL_COUNT: u32 = 1 << SL_BITS; +/// Sizes below this are classed one per size, in first level 0. +const SMALL: u32 = SL_COUNT; +/// First level 0 plus one per power of two from `SMALL` up to `u32::MAX`'s. +const FL_COUNT: usize = (32 - SL_BITS) as usize + 1; + +/// The `(first, second)` level class holding blocks of `size`. +fn class(size: u32) -> (usize, usize) { + if size < SMALL { + return (0, size as usize); + } + let log = 31 - size.leading_zeros(); + let fl = (log - SL_BITS + 1) as usize; + let sl = ((size >> (log - SL_BITS)) ^ SL_COUNT) as usize; + (fl, sl) +} + +/// The class whose every block holds `size`: `size` rounded up to the next +/// class boundary. +fn fitting_class(size: u32) -> (usize, usize) { + if size < SMALL { + return class(size); + } + let log = 31 - size.leading_zeros(); + let round = (1u32 << (log - SL_BITS)) - 1; + class(size.saturating_add(round)) +} + +pub struct Tlsf { + capacity: u32, + /// Every block by offset: its length and whether it's free. + blocks: BTreeMap, + /// Free block offsets per class. + free: Vec<[BTreeSet; SL_COUNT as usize]>, + /// Bit `fl` set when first level `fl` has a free block. + fl_bitmap: u64, + /// Per first level, bit `sl` set when that class has a free block. + sl_bitmap: [u32; FL_COUNT], + used: u32, +} + +impl Tlsf { + pub fn new(capacity: u32) -> Self { + let mut this = Self { + capacity, + blocks: BTreeMap::new(), + free: (0..FL_COUNT).map(|_| Default::default()).collect(), + fl_bitmap: 0, + sl_bitmap: [0; FL_COUNT], + used: 0, + }; + this.reset(); + this + } + + /// Frees everything. + pub fn reset(&mut self) { + self.blocks.clear(); + for level in &mut self.free { + level.iter_mut().for_each(BTreeSet::clear); + } + self.fl_bitmap = 0; + self.sl_bitmap = [0; FL_COUNT]; + self.used = 0; + if self.capacity > 0 { + self.insert_free(0, self.capacity); + } + } + + pub fn capacity(&self) -> u32 { + self.capacity + } + + /// Elements in allocated blocks. + pub fn used(&self) -> u32 { + self.used + } + + /// `n` contiguous elements, or `None` if no free block holds them. + pub fn alloc(&mut self, n: u32) -> Option { + let (offset, len) = self.find(n)?; + self.remove_free(offset, len); + self.blocks.insert(offset, (n, false)); + if len > n { + self.insert_free(offset + n, len - n); + } + self.used += n; + Some(offset) + } + + /// Whether [`Self::alloc`] would find `n` elements. + pub fn fits(&self, n: u32) -> bool { + self.find(n).is_some() + } + + /// The free block `alloc(n)` takes, as `(offset, len)`. + fn find(&self, n: u32) -> Option<(u32, u32)> { + if n == 0 || n > self.capacity { + return None; + } + self.find_class(fitting_class(n)) + .and_then(|(fl, sl)| self.free[fl][sl].first().map(|&off| (off, self.len(off)))) + // Rounding up can pass over a block of `n`'s own class that fits. + .or_else(|| { + let (fl, sl) = class(n); + self.free[fl][sl] + .iter() + .map(|&off| (off, self.len(off))) + .find(|&(_, len)| len >= n) + }) + } + + /// Returns the block at `offset`, merging it with free neighbours. + pub fn free(&mut self, offset: u32) { + let Some(&(len, false)) = self.blocks.get(&offset) else { + debug_assert!(false, "freeing {offset}, which isn't an allocated block"); + return; + }; + self.blocks.remove(&offset); + self.used -= len; + let (mut start, mut len) = (offset, len); + if let Some(&(next_len, true)) = self.blocks.get(&(start + len)) { + self.remove_free(start + len, next_len); + len += next_len; + } + if let Some((&prev, &(prev_len, true))) = self.blocks.range(..start).next_back() + && prev + prev_len == start + { + self.remove_free(prev, prev_len); + start = prev; + len += prev_len; + } + self.insert_free(start, len); + } + + /// The length of the block at `offset`. + fn len(&self, offset: u32) -> u32 { + self.blocks[&offset].0 + } + + /// The first non-empty class at or above `(fl, sl)`. + fn find_class(&self, (fl, sl): (usize, usize)) -> Option<(usize, usize)> { + let in_level = self.sl_bitmap[fl] & (u32::MAX << sl); + if in_level != 0 { + return Some((fl, in_level.trailing_zeros() as usize)); + } + let above = self.fl_bitmap & u64::MAX.checked_shl(fl as u32 + 1).unwrap_or(0); + if above == 0 { + return None; + } + let fl = above.trailing_zeros() as usize; + Some((fl, self.sl_bitmap[fl].trailing_zeros() as usize)) + } + + fn insert_free(&mut self, offset: u32, len: u32) { + self.blocks.insert(offset, (len, true)); + let (fl, sl) = class(len); + self.free[fl][sl].insert(offset); + self.sl_bitmap[fl] |= 1 << sl; + self.fl_bitmap |= 1 << fl; + } + + fn remove_free(&mut self, offset: u32, len: u32) { + self.blocks.remove(&offset); + let (fl, sl) = class(len); + self.free[fl][sl].remove(&offset); + if self.free[fl][sl].is_empty() { + self.sl_bitmap[fl] &= !(1 << sl); + if self.sl_bitmap[fl] == 0 { + self.fl_bitmap &= !(1 << fl); + } + } + } + + /// Free blocks as `(offset, len)`, by offset. + #[cfg(any(test, feature = "test-harness"))] + pub fn free_blocks(&self) -> impl Iterator + '_ { + self.blocks + .iter() + .filter(|(_, (_, free))| *free) + .map(|(&off, &(len, _))| (off, len)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classes_split_each_power_of_two_sixteen_ways() { + assert_eq!(class(5), (0, 5)); + assert_eq!(class(16), (1, 0)); + assert_eq!(class(31), (1, 15)); + assert_eq!(class(32), (2, 0)); + assert_eq!(class(33), (2, 0)); + assert_eq!(class(34), (2, 1)); + // Every block in the fitting class holds the request. + assert_eq!(fitting_class(33), (2, 1)); + assert_eq!(fitting_class(32), (2, 0)); + assert_eq!(class(u32::MAX).0, FL_COUNT - 1); + } + + #[test] + fn freed_blocks_merge_with_free_neighbours() { + let mut tlsf = Tlsf::new(100); + let a = tlsf.alloc(10).unwrap(); + let b = tlsf.alloc(20).unwrap(); + let c = tlsf.alloc(30).unwrap(); + assert_eq!((a, b, c), (0, 10, 30)); + assert_eq!(tlsf.used(), 60); + tlsf.free(b); + tlsf.free(a); + assert_eq!(tlsf.free_blocks().collect::>(), [(0, 30), (60, 40)]); + tlsf.free(c); + assert_eq!(tlsf.free_blocks().collect::>(), [(0, 100)]); + assert_eq!(tlsf.used(), 0); + } + + #[test] + fn a_block_of_the_requests_own_class_is_found() { + let mut tlsf = Tlsf::new(33); + // The only free block, 33, shares 33's class rather than rounding up. + assert_eq!(tlsf.alloc(33), Some(0)); + assert_eq!(tlsf.alloc(1), None); + } + + /// Random allocs and frees never overlap and always succeed while some + /// free block is big enough. + #[test] + fn matches_a_reference_model() { + let mut rng = fastrand::Rng::with_seed(7); + let capacity = 4096; + let mut tlsf = Tlsf::new(capacity); + let mut live: Vec<(u32, u32)> = Vec::new(); + for _ in 0..20_000 { + if live.is_empty() || rng.bool() { + let n = rng.u32(1..200); + let largest = tlsf.free_blocks().map(|(_, len)| len).max().unwrap_or(0); + let fits = tlsf.fits(n); + let alloc = tlsf.alloc(n); + assert_eq!(alloc.is_some(), fits); + match alloc { + Some(off) => { + assert!(off + n <= capacity); + assert!(live.iter().all(|&(o, l)| off + n <= o || o + l <= off)); + live.push((off, n)); + } + None => assert!(largest < n, "{n} refused with {largest} free"), + } + } else { + let (off, _) = live.swap_remove(rng.usize(..live.len())); + tlsf.free(off); + } + assert_eq!(tlsf.used(), live.iter().map(|&(_, l)| l).sum::()); + } + for (off, _) in live.drain(..) { + tlsf.free(off); + } + assert_eq!(tlsf.free_blocks().collect::>(), [(0, capacity)]); + } +} diff --git a/pomme-client/src/renderer/mod.rs b/pomme-client/src/renderer/mod.rs index ce40653c0..504434e89 100644 --- a/pomme-client/src/renderer/mod.rs +++ b/pomme-client/src/renderer/mod.rs @@ -1299,6 +1299,12 @@ impl Renderer { self.chunk_buffers.slice_overlap() } + /// See [`ChunkBufferStore::starved_column`]. + #[cfg(feature = "test-harness")] + pub fn starved_chunk_column(&self) -> Option { + self.chunk_buffers.starved_column() + } + /// Sections actually drawn after frustum culling (lags a few frames). The /// graph's occluded sections are omitted before the cull, so this also /// drops when occlusion hides geometry — useful for the F3 overlay. @@ -1753,7 +1759,7 @@ impl Renderer { // Fence signalled: reclaim chunk slices the GPU is now provably done with, // and read back any screenshot copy recorded for this frame index. - self.chunk_buffers.begin_frame(); + self.chunk_buffers.begin_frame(&self.ctx.device); self.screenshot.collect_ready(frame, &self.ctx.device); let t_acquire = std::time::Instant::now(); diff --git a/pomme-client/src/renderer/rhi/vulkan/context.rs b/pomme-client/src/renderer/rhi/vulkan/context.rs index 1bad84825..87735d53a 100644 --- a/pomme-client/src/renderer/rhi/vulkan/context.rs +++ b/pomme-client/src/renderer/rhi/vulkan/context.rs @@ -227,9 +227,14 @@ impl Context { } else { &[] }; + // Its struct chains on `vkGetPhysicalDeviceMemoryProperties2`, core in 1.1. + let memory_budget = physical_device.get_properties().api_version >= vk::API_VERSION_1_1 + && supports_extensions(&physical_device, &[pyronyx::ext::memory_budget::NAME]); + tracing::info!("VK_EXT_memory_budget: {memory_budget}"); let device_extension_names: Vec<*const c_char> = DEVICE_EXTENSIONS .iter() .chain(optional_extensions) + .chain(memory_budget.then_some(&pyronyx::ext::memory_budget::NAME)) .map(|ext| ext.as_ptr()) .collect(); @@ -291,6 +296,7 @@ impl Context { allocator, graphics_queue, command_pool, + memory_budget, ), graphics_family, present_queue, diff --git a/pomme-client/src/renderer/rhi/vulkan/mod.rs b/pomme-client/src/renderer/rhi/vulkan/mod.rs index 85f62b1ac..4423c275d 100644 --- a/pomme-client/src/renderer/rhi/vulkan/mod.rs +++ b/pomme-client/src/renderer/rhi/vulkan/mod.rs @@ -74,6 +74,18 @@ pub struct Device { /// The widest 2D texture, in texels. pub max_texture_size: u32, depth_clamp: bool, + physical_device: vk::PhysicalDevice, + /// `VK_EXT_memory_budget` is enabled. + memory_budget: bool, + /// The heap `device_local_bytes` measures. + device_local_heap: usize, +} + +/// A memory heap's budget for this process and what it uses of it, in bytes. +#[derive(Clone, Copy, Debug)] +pub struct MemoryBudget { + pub budget: u64, + pub usage: u64, } impl Device { @@ -83,17 +95,22 @@ impl Device { allocator: Allocator, queue: vk::Queue, command_pool: vk::CommandPool, + memory_budget: bool, ) -> Self { let memory = physical_device.get_memory_properties(); - let device_local_bytes = memory.memory_types[..memory.memory_type_count as usize] + let (device_local_heap, device_local_bytes) = memory.memory_types + [..memory.memory_type_count as usize] .iter() .filter(|ty| { ty.property_flags .contains(vk::MemoryPropertyFlags::DeviceLocal) }) - .map(|ty| memory.memory_heaps[ty.heap_index as usize].size) - .max() - .unwrap_or(0); + .map(|ty| { + let heap = ty.heap_index as usize; + (heap, memory.memory_heaps[heap].size) + }) + .max_by_key(|&(_, size)| size) + .unwrap_or((0, 0)); let properties = physical_device.get_properties(); Self { raw, @@ -107,9 +124,36 @@ impl Device { max_texture_array_layers: properties.limits.max_image_array_layers, max_texture_size: properties.limits.max_image_dimension2_d, depth_clamp: physical_device.get_features().depth_clamp == vk::TRUE, + physical_device: *physical_device, + memory_budget, + device_local_heap, } } + /// The device-local heap's budget and use right now, from + /// `VK_EXT_memory_budget`; `None` without it. + pub fn device_local_budget(&self) -> Option { + if !self.memory_budget { + return None; + } + let call = self + .physical_device + .fns() + .v1_1 + .get_physical_device_memory_properties2?; + let mut budget = vk::PhysicalDeviceMemoryBudgetPropertiesEXT::default(); + let mut properties = vk::PhysicalDeviceMemoryProperties2 { + next: (&raw mut budget).cast(), + ..Default::default() + }; + // SAFETY: both structs outlive the call, which only writes them. + unsafe { call(self.physical_device.handle(), &raw mut properties) }; + Some(MemoryBudget { + budget: budget.heap_budget[self.device_local_heap], + usage: budget.heap_usage[self.device_local_heap], + }) + } + pub fn wait_idle(&self) { let _ = self.raw.wait_idle(); } diff --git a/pomme-client/src/ui/menu/options.rs b/pomme-client/src/ui/menu/options.rs index b32ce1d31..47f1c5aa4 100644 --- a/pomme-client/src/ui/menu/options.rs +++ b/pomme-client/src/ui/menu/options.rs @@ -244,7 +244,7 @@ impl MainMenu { /// Upper bound of the render distance slider: the server-announced view /// distance while connected (can exceed 32), 32 otherwise. - fn render_distance_max(&self) -> u32 { + pub(crate) fn render_distance_max(&self) -> u32 { if self.server_render_distance > 0 { self.server_render_distance.max(3) } else { diff --git a/tests/ingame/README.md b/tests/ingame/README.md index 88957797d..31ad169e6 100644 --- a/tests/ingame/README.md +++ b/tests/ingame/README.md @@ -143,7 +143,8 @@ bind = { action = "key.drop", keys = ["key.keyboard.j"] } # this session only; option = { main_hand = "left" } # sets client options, as the # options screen does; also # menu_background_blur, - # brightness, and shader_pack + # brightness, render_distance + # (chunks) and shader_pack # (a name in fixtures/shaderpacks, # "" for none) [[step]] @@ -180,7 +181,7 @@ An `expect` is retried every frame until it holds or times out. It can check: | A chat line | `chat` (text it contains) | | A block | `block` (state name, e.g. `air`), `at`, optional `block_entity` (type) | | A biome | `biome` (name, e.g. `swamp`), `at` | -| Settled terrain | `terrain = "settled"`: every loaded column meshed, and no two of the chunk pools' ranges overlapping | +| Settled terrain | `terrain = "settled"`: every loaded column meshed and uploaded (none waiting for pool space), and no two of the chunk pools' ranges overlapping | | The local player | `player = { ... }` with any of `health`, `food`, `level`, `game_mode`, `effect`, `no_effect`, `max_health`, `raining`, `hand`, `dimension`, `day_time`, `position` (within 0.01), `y_rot` (within 0.01), `spectating` (the kind of entity the camera follows, empty for none) and `post_effect` (the spectator view effect drawn: `creeper`, `spider`, `invert`, or empty) | | The open menu | `menu` (a container's menu type, e.g. `generic_9x3`, or `inventory`, `creative`, `pause`, `none`), optional `title`; on the creative screen also `tab` (its vanilla id, e.g. `building_blocks`), `row` (the first catalog row shown) and `search` | | The HUD text | `hud = { ... }` with any of `title`, `subtitle` and `sidebar_title` (`""` when none is shown), `boss_bars` (every name, top to bottom), `sidebar` (every row as `"name number"`, top to bottom) and `tab_list` (every listed player as `{ name, game_mode? }`, in tab order) | From 39bfbe384e1b8e52edc3d70ab119ca489d65632e Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:54:15 +0000 Subject: [PATCH 02/67] share draw lists across chunk arenas and keep a spare memory block --- pomme-client/src/renderer/chunk/buffer.rs | 423 ++++++++++---------- pomme-client/src/renderer/rhi/vulkan/mod.rs | 14 +- pomme-client/src/renderer/shaders/cull.comp | 30 +- pomme-gpu-allocator/src/vulkan/mod.rs | 26 +- 4 files changed, 258 insertions(+), 235 deletions(-) diff --git a/pomme-client/src/renderer/chunk/buffer.rs b/pomme-client/src/renderer/chunk/buffer.rs index 888b402f3..35837b014 100644 --- a/pomme-client/src/renderer/chunk/buffer.rs +++ b/pomme-client/src/renderer/chunk/buffer.rs @@ -36,9 +36,12 @@ const COMPACT_VERTICES_PER_FRAME: u32 = 1 << 18; const FRONTIER_SLACK: u64 = 16; /// Frames before compaction tries again after running out of room. const COMPACT_BACKOFF_FRAMES: u64 = 600; -/// Draws an arena's meta and indirect buffers start with room for. Growing -/// them waits the GPU out, so start generously. -const INITIAL_META: usize = 8192; +/// Draws the meta and indirect buffers start with room for. Growing them +/// waits the GPU out, so start generously. +const INITIAL_META: usize = 16384; +/// Arenas the draw count buffers have room for: far more than any budget +/// allows. +const MAX_ARENAS: usize = 256; /// The cull pass's bindings: meta, frustum, solid indirect/count, cutout /// indirect/count. @@ -50,7 +53,8 @@ const CULL_BINDINGS: &[rhi::BindGroupLayoutEntry] = &[ rhi::BindGroupLayoutEntry::storage(rhi::ShaderStages::COMPUTE), rhi::BindGroupLayoutEntry::storage(rhi::ShaderStages::COMPUTE), ]; -const COUNT_SIZE: u64 = 4; +/// A draw count per arena. +const COUNTS_SIZE: u64 = (MAX_ARENAS * size_of::()) as u64; const FRUSTUM_SIZE: u64 = size_of::() as u64; /// Per-section fade-in length, shared by the opaque indirect path and water. const FADE_DURATION_MS: f32 = 1000.0; @@ -76,8 +80,12 @@ fn column_dist_sq(pos: ChunkPos, eye: DVec3) -> f64 { #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub(crate) struct ChunkMeta { /// Section-local vertex bounds; the cull shader rebases them via `origin`. - aabb_min: [f32; 4], - aabb_max: [f32; 4], + aabb_min: [f32; 3], + /// Where the section's arena's draws start in the draw lists. + draw_base: u32, + aabb_max: [f32; 3], + /// The section's arena, which picks its draw count. + arena: u32, index_count: u32, first_index: u32, vertex_offset: i32, @@ -248,7 +256,8 @@ fn column_nearby(fade_enabled: bool, pos: ChunkPos, eye: DVec3) -> bool { /// Rebuilds the draw lists into `out`, one per arena: one [`ChunkMeta`] per /// uploaded section the occlusion mask keeps (all of them without one), with -/// its fade-in factor at `now`. +/// its fade-in factor at `now`. The lists are uploaded back to back, each +/// arena's draws starting where the previous arena's end. pub(crate) fn build_meta( chunks: &HashMap, chunk_visibility: Option<&HashMap>, @@ -274,9 +283,13 @@ pub(crate) fn build_meta( continue; } let vis = ChunkBufferStore::section_visibility(nearby, sec, now); + let [min_x, min_y, min_z, _] = sec.aabb.min; + let [max_x, max_y, max_z, _] = sec.aabb.max; out[sec.arena as usize].push(ChunkMeta { - aabb_min: sec.aabb.min, - aabb_max: sec.aabb.max, + aabb_min: [min_x, min_y, min_z], + draw_base: 0, + aabb_max: [max_x, max_y, max_z], + arena: u32::from(sec.arena), index_count: sec.index_count, first_index: sec.first_index, vertex_offset: sec.vertex_offset, @@ -286,6 +299,13 @@ pub(crate) fn build_meta( }); } } + let mut base = 0; + for list in out { + for meta in list.iter_mut() { + meta.draw_base = base; + } + base += list.len() as u32; + } } /// Sorts the draw list front-to-back by section center distance to `eye`. @@ -451,35 +471,22 @@ struct Starved { need: Need, } -/// One vertex and one index heap, sub-allocated by [`Tlsf`], with the draw -/// lists culled over the sections they hold: a heap of vanilla's -/// `UberGpuBuffer`, its vertex and index halves paired so each arena draws in -/// one call. +/// One vertex and one index heap, sub-allocated by [`Tlsf`]: a heap of +/// vanilla's `UberGpuBuffer`, its vertex and index halves paired so each +/// arena draws in one call. Only device memory, so adding one never needs a +/// fresh host memory block, whose first use stalls. struct Arena { vertex_buffer: rhi::backend::Buffer, index_buffer: rhi::backend::Buffer, vertices: Tlsf, indices: Tlsf, - /// Capacity (in draws) of the draw lists' meta and indirect buffers. - max_meta: usize, - camera: DrawList, - /// A shader pack's shadow view, made on its first cull. - shadow: Option, - /// The camera's draw counts, copied after each cull for the debug overlay. - count_readbacks: Vec, /// Empty, and destroyed at this `frame_seq` once no in-flight frame reads /// it. Never allocated from or drawn meanwhile. doomed: Option, } impl Arena { - fn new( - device: &rhi::backend::Device, - use_staging: bool, - format: TerrainFormat, - cull_layout: rhi::backend::BindGroupLayout, - ) -> Self { - let (meta_size, indirect_size) = list_sizes(INITIAL_META); + fn new(device: &rhi::backend::Device, use_staging: bool, format: TerrainFormat) -> Self { Self { vertex_buffer: create_pool( device, @@ -497,20 +504,6 @@ impl Arena { ), vertices: Tlsf::new(ARENA_VERTICES), indices: Tlsf::new(ARENA_INDICES), - max_meta: INITIAL_META, - camera: DrawList::new(device, cull_layout, meta_size, indirect_size), - shadow: None, - count_readbacks: (0..MAX_FRAMES_IN_FLIGHT) - .map(|_| { - rhi::backend::Buffer::new( - device, - "draw_count_readback", - 2 * COUNT_SIZE, - rhi::BufferUsages::COPY_DST, - rhi::MemoryLocation::Readback, - ) - }) - .collect(), doomed: None, } } @@ -519,31 +512,9 @@ impl Arena { self.vertices.used() == 0 && self.indices.used() == 0 } - /// Grows the draw lists to hold `needed` draws. The caller waits for - /// in-flight frames first, since their bind groups use the old buffers. - fn regrow( - &mut self, - device: &rhi::backend::Device, - layout: rhi::backend::BindGroupLayout, - needed: usize, - ) { - let max_meta = (needed.saturating_mul(3) / 2) - .next_power_of_two() - .max(self.max_meta * 2); - let (meta_size, indirect_size) = list_sizes(max_meta); - for list in std::iter::once(&mut self.camera).chain(&mut self.shadow) { - list.regrow(device, layout, meta_size, indirect_size); - } - self.max_meta = max_meta; - } - - fn destroy(mut self, device: &rhi::backend::Device) { + fn destroy(self, device: &rhi::backend::Device) { self.vertex_buffer.destroy(device); self.index_buffer.destroy(device); - for list in std::iter::once(&mut self.camera).chain(&mut self.shadow) { - list.destroy(device); - } - util::destroy_buffers(device, &mut self.count_readbacks); } } @@ -561,10 +532,6 @@ fn live(slot: &Option) -> Option<&Arena> { slot.as_ref().filter(|arena| arena.doomed.is_none()) } -fn live_mut(slot: &mut Option) -> Option<&mut Arena> { - slot.as_mut().filter(|arena| arena.doomed.is_none()) -} - /// A vertex and an index slice from one live arena other than `exclude`, /// trying the fullest first so the emptiest can drain. fn alloc_in( @@ -648,9 +615,18 @@ pub struct ChunkBufferStore { /// the CPU visibility graph. A column absent here defaults to fully /// visible, so freshly-loaded-but-not-yet-graphed columns still draw. chunk_visibility: HashMap, - /// Per arena, the camera's and the shadow view's draw lists. + /// Per arena, the camera's and the shadow view's draws. camera_meta: Vec>, shadow_meta: Vec>, + /// Capacity (in draws, over every arena) of the draw lists' meta and + /// indirect buffers. + max_meta: usize, + camera: DrawList, + /// A shader pack's shadow view, made on its first cull. + shadow: Option, + /// The camera's per-arena draw counts, solid then cutout, copied after + /// each cull for the debug overlay. + count_readbacks: Vec, meta_dirty: bool, /// End of the current fade-in window. While `now < fade_until` the /// per-section fade values change each frame, so the draw lists must be @@ -739,6 +715,20 @@ impl ChunkBufferStore { chunk_visibility: HashMap::new(), camera_meta: Vec::new(), shadow_meta: Vec::new(), + max_meta: INITIAL_META, + camera: DrawList::new(device, cull_layout, INITIAL_META), + shadow: None, + count_readbacks: (0..MAX_FRAMES_IN_FLIGHT) + .map(|_| { + rhi::backend::Buffer::new( + device, + "draw_count_readback", + 2 * COUNTS_SIZE, + rhi::BufferUsages::COPY_DST, + rhi::MemoryLocation::Readback, + ) + }) + .collect(), meta_dirty: true, fade_until: std::time::Instant::now(), last_sort_cam: DVec3::MAX, @@ -1081,27 +1071,30 @@ impl ChunkBufferStore { needs_remesh } - /// Grow each arena's meta and indirect buffers to hold its sections' - /// draws. No-op while capacity suffices. + /// Grow the draw lists' meta and indirect buffers to hold every + /// section's draw. No-op while capacity suffices. fn ensure_meta_capacity(&mut self, device: &rhi::backend::Device) { - let mut sections = vec![0usize; self.arenas.len()]; - for sec in self.chunks.values().flat_map(|c| &c.sections) { - sections[sec.arena as usize] += 1; + let needed: usize = self.chunks.values().map(|c| c.sections.len()).sum(); + if needed <= self.max_meta { + return; } - let mut waited = false; - for (arena, needed) in self.arenas.iter_mut().zip(sections) { - let Some(arena) = arena.as_mut().filter(|a| needed > a.max_meta) else { - continue; - }; - // The meta/indirect buffers are referenced by every in-flight frame's - // bind group; wait the GPU out before freeing them. - if !std::mem::replace(&mut waited, true) { - device.wait_idle(); - } - arena.regrow(device, self.cull_layout, needed); + // The meta/indirect buffers are referenced by every in-flight frame's + // bind group; wait the GPU out before freeing them. + device.wait_idle(); + self.max_meta = (needed.saturating_mul(3) / 2) + .next_power_of_two() + .max(self.max_meta * 2); + let (layout, draws) = (self.cull_layout, self.max_meta); + for list in self.draw_lists_mut() { + list.regrow(device, layout, draws); } } + /// The camera's draw list and a shader pack's shadow one, if made. + fn draw_lists_mut(&mut self) -> impl Iterator { + std::iter::once(&mut self.camera).chain(&mut self.shadow) + } + /// Return one slice's vertex and index ranges to its arena. fn free_slice(&mut self, slice: Slice) { if let Some(arena) = self.arenas[slice.arena as usize].as_mut() { @@ -1136,6 +1129,9 @@ impl ChunkBufferStore { if arenas == 0 { return true; } + if arenas as usize >= MAX_ARENAS { + return false; + } let bytes = arena_bytes(self.format); match device.device_local_budget() { Some(budget) => { @@ -1150,7 +1146,7 @@ impl ChunkBufferStore { } fn add_arena(&mut self, device: &rhi::backend::Device) { - let arena = Arena::new(device, self.use_staging, self.format, self.cull_layout); + let arena = Arena::new(device, self.use_staging, self.format); match self.arenas.iter().position(Option::is_none) { Some(id) => self.arenas[id] = Some(arena), None => self.arenas.push(Some(arena)), @@ -1645,56 +1641,43 @@ impl ChunkBufferStore { } self.last_sort_cam = eye; // Draw lists reordered: every frame slot's meta buffer needs the refresh. - for arena in self.arenas.iter_mut().flatten() { - arena.camera.upload_pending = MAX_FRAMES_IN_FLIGHT as u32; - } + self.camera.upload_pending = MAX_FRAMES_IN_FLIGHT as u32; } // This frame slot's GPU work has completed (fence-waited at frame start), - // so its readbacks hold the previous cull's totals (solid + cutout draws). - self.last_draw_count = 0; - for (slot, meta) in self.arenas.iter_mut().zip(&self.camera_meta) { - let Some(arena) = live_mut(slot).filter(|_| !meta.is_empty()) else { - continue; - }; - self.last_draw_count += arena.count_readbacks[frame].read(|bytes| { - let counts: [u32; 2] = bytemuck::pod_read_unaligned(&bytes[..8]); - counts[0] + counts[1] - }); - arena.camera.cull( - encoder, - &self.compute_pipeline, - frame, - meta, - frustum, - anchor, - eye, - !self.indirect_draw.count, - ); - } + // so its readback holds the previous cull's totals (solid + cutout draws). + self.last_draw_count = self.count_readbacks[frame].read(|bytes| { + let (counts, _) = bytes.as_chunks::<4>(); + counts.iter().map(|&count| u32::from_ne_bytes(count)).sum() + }); + self.camera.cull( + encoder, + &self.compute_pipeline, + frame, + &self.camera_meta, + frustum, + anchor, + eye, + !self.indirect_draw.count, + ); encoder.transition_buffers(rhi::BufferUse::StorageWrite, rhi::BufferUse::CopySrc); - for (slot, meta) in self.arenas.iter().zip(&self.camera_meta) { - let Some(arena) = live(slot).filter(|_| !meta.is_empty()) else { - continue; - }; - let readback = arena.count_readbacks[frame].handle(); - for (i, count) in [ - &arena.camera.count_buffers[frame], - &arena.camera.count_cutout_buffers[frame], - ] - .into_iter() - .enumerate() - { - encoder.copy_buffer( - count.handle(), - readback, - &[rhi::BufferCopy { - src_offset: 0, - dst_offset: i as u64 * COUNT_SIZE, - size: COUNT_SIZE, - }], - ); - } + let readback = self.count_readbacks[frame].handle(); + for (i, count) in [ + &self.camera.count_buffers[frame], + &self.camera.count_cutout_buffers[frame], + ] + .into_iter() + .enumerate() + { + encoder.copy_buffer( + count.handle(), + readback, + &[rhi::BufferCopy { + src_offset: 0, + dst_offset: i as u64 * COUNTS_SIZE, + size: COUNTS_SIZE, + }], + ); } encoder.transition_buffers(rhi::BufferUse::CopyDst, rhi::BufferUse::HostRead); @@ -1727,42 +1710,32 @@ impl ChunkBufferStore { std::time::Instant::now(), &mut self.shadow_meta, ); - for shadow in self - .arenas - .iter_mut() - .flatten() - .filter_map(|a| a.shadow.as_mut()) - { + if let Some(shadow) = &mut self.shadow { shadow.upload_pending = MAX_FRAMES_IN_FLIGHT as u32; } self.shadow_dirty = false; } - for (slot, meta) in self.arenas.iter_mut().zip(&self.shadow_meta) { - let Some(arena) = live_mut(slot).filter(|_| !meta.is_empty()) else { - continue; - }; - let (meta_size, indirect_size) = list_sizes(arena.max_meta); - let shadow = arena.shadow.get_or_insert_with(|| { - DrawList::new(device, self.cull_layout, meta_size, indirect_size) - }); - shadow.cull( - encoder, - &self.compute_pipeline, - frame, - meta, - frustum, - anchor, - eye, - !self.indirect_draw.count, - ); - } + let shadow = self + .shadow + .get_or_insert_with(|| DrawList::new(device, self.cull_layout, self.max_meta)); + shadow.cull( + encoder, + &self.compute_pipeline, + frame, + &self.shadow_meta, + frustum, + anchor, + eye, + !self.indirect_draw.count, + ); } /// Issue one render layer's indirect draws for `view`, one call per - /// arena. `cutout` selects the discard pass's draw lists (drawn after - /// `solid`, which lays down depth); the caller binds the matching pipeline - /// first. Both layers share each arena's vertex/index/meta buffers and - /// the cull-written draw lists. + /// arena over its range of the draw lists. `cutout` selects the discard + /// pass's draw lists (drawn after `solid`, which lays down depth); the + /// caller binds the matching pipeline first. Both layers share each + /// arena's vertex/index buffers, the meta buffer and the cull-written + /// draw lists. pub fn draw_indirect( &self, pass: &rhi::backend::RenderPassEncoder, @@ -1773,48 +1746,50 @@ impl ChunkBufferStore { if self.chunks.is_empty() { return; } - let metas = match view { - View::Camera => &self.camera_meta, - View::Shadow => &self.shadow_meta, + let (metas, list) = match (view, &self.shadow) { + (View::Camera, _) => (&self.camera_meta, &self.camera), + (View::Shadow, Some(shadow)) => (&self.shadow_meta, shadow), + (View::Shadow, None) => return, }; - for (slot, meta) in self.arenas.iter().zip(metas) { + let (indirect, count) = if cutout { + ( + list.indirect_cutout_buffers[frame].handle(), + list.count_cutout_buffers[frame].handle(), + ) + } else { + ( + list.indirect_buffers[frame].handle(), + list.count_buffers[frame].handle(), + ) + }; + // Binding 1: the meta buffer, read per instance for the section + // origin + fade (indexed by `first_instance`). + pass.set_vertex_buffer(1, list.meta_buffers[frame].handle()); + let stride = size_of::() as u32; + for (id, (slot, meta)) in self.arenas.iter().zip(metas).enumerate() { let Some(arena) = live(slot).filter(|_| !meta.is_empty()) else { continue; }; - let list = match view { - View::Camera => &arena.camera, - View::Shadow => match &arena.shadow { - Some(shadow) => shadow, - None => continue, - }, - }; - let max_draws = meta.len() as u32; - let (indirect, count) = if cutout { - ( - list.indirect_cutout_buffers[frame].handle(), - list.count_cutout_buffers[frame].handle(), - ) - } else { - ( - list.indirect_buffers[frame].handle(), - list.count_buffers[frame].handle(), - ) - }; - - // Binding 0: the arena's packed vertices. Binding 1: its meta - // buffer, read per instance for the section origin + fade (indexed - // by `first_instance`). + // Binding 0: the arena's packed vertices. pass.set_vertex_buffer(0, arena.vertex_buffer.handle()); - pass.set_vertex_buffer(1, list.meta_buffers[frame].handle()); pass.set_index_buffer(arena.index_buffer.handle()); - let stride = size_of::() as u32; + let max_draws = meta.len() as u32; + let offset = u64::from(meta[0].draw_base * stride); if self.indirect_draw.count { - pass.draw_indexed_indirect_count(indirect, count, max_draws, stride); + let count_offset = (id * size_of::()) as u64; + pass.draw_indexed_indirect_count( + indirect, + offset, + count, + count_offset, + max_draws, + stride, + ); } else if self.indirect_draw.multi_draw { - pass.draw_indexed_indirect(indirect, 0, max_draws, stride); + pass.draw_indexed_indirect(indirect, offset, max_draws, stride); } else { for i in 0..max_draws { - pass.draw_indexed_indirect(indirect, (i * stride) as u64, 1, stride); + pass.draw_indexed_indirect(indirect, offset + u64::from(i * stride), 1, stride); } } } @@ -1902,6 +1877,10 @@ impl ChunkBufferStore { arena.destroy(device); } std::mem::take(&mut self.staging_buffer).destroy(device); + for list in self.draw_lists_mut() { + list.destroy(device); + } + util::destroy_buffers(device, &mut self.count_readbacks); self.compute_pipeline.destroy(device); self.compute_layout.destroy(device); @@ -1940,8 +1919,8 @@ pub enum View { Shadow, } -/// One view's sections and, per frame slot, the cull's frustum and the draw -/// lists it writes. +/// One view's sections over every arena and, per frame slot, the cull's +/// frustum and the draw lists it writes, a range and a draw count per arena. struct DrawList { /// Frame slots still needing the latest meta uploaded. Set to /// `MAX_FRAMES_IN_FLIGHT` whenever the draw list changes, decremented per @@ -1963,13 +1942,12 @@ impl DrawList { fn new( device: &rhi::backend::Device, layout: rhi::backend::BindGroupLayout, - meta_size: u64, - indirect_size: u64, + draws: usize, ) -> Self { let count = |name: &str| { util::create_per_frame_host_buffers( device, - COUNT_SIZE, + COUNTS_SIZE, rhi::BufferUsages::STORAGE | rhi::BufferUsages::INDIRECT | rhi::BufferUsages::COPY_SRC, @@ -1991,19 +1969,19 @@ impl DrawList { indirect_cutout_buffers: Vec::new(), count_cutout_buffers: count("draw_count_cutout"), }; - this.regrow(device, layout, meta_size, indirect_size); + this.regrow(device, layout, draws); this } - /// Remakes the per-frame meta and indirect buffers at these sizes and + /// Remakes the per-frame meta and indirect buffers to hold `draws` and /// points each frame's cull group at its buffers. fn regrow( &mut self, device: &rhi::backend::Device, layout: rhi::backend::BindGroupLayout, - meta_size: u64, - indirect_size: u64, + draws: usize, ) { + let (meta_size, indirect_size) = list_sizes(draws); for buffers in [ &mut self.meta_buffers, &mut self.indirect_buffers, @@ -2036,17 +2014,17 @@ impl DrawList { (0, storage(&self.meta_buffers[i], meta_size)), (1, uniform(&self.frustum_buffers[i], FRUSTUM_SIZE)), (2, storage(&self.indirect_buffers[i], indirect_size)), - (3, storage(&self.count_buffers[i], COUNT_SIZE)), + (3, storage(&self.count_buffers[i], COUNTS_SIZE)), (4, storage(&self.indirect_cutout_buffers[i], indirect_size)), - (5, storage(&self.count_cutout_buffers[i], COUNT_SIZE)), + (5, storage(&self.count_cutout_buffers[i], COUNTS_SIZE)), ]; self.cull_sets[i].replace(device, layout, &bindings); } } - /// Records the cull of `meta`, this list's sections, against `frustum` - /// into this frame slot's draw lists, uploading `meta` first if the slot - /// hasn't seen it. + /// Records the cull of `metas`, this list's sections per arena, against + /// `frustum` into this frame slot's draw lists, uploading `metas` back to + /// back first if the slot hasn't seen them. /// `zero_indirect`: without drawIndirectCount the whole indirect buffer /// is drawn, so slots the cull leaves unfilled must read as no-op draws. #[allow(clippy::too_many_arguments)] @@ -2055,17 +2033,22 @@ impl DrawList { encoder: &rhi::backend::CommandEncoder, pipeline: &rhi::backend::ComputePipeline, frame: usize, - meta: &[ChunkMeta], + metas: &[Vec], frustum: &[[f32; 4]; 6], anchor: DVec3, eye: DVec3, zero_indirect: bool, ) { if self.upload_pending > 0 { - self.meta_buffers[frame].write(bytemuck::cast_slice(meta)); + let mut offset = 0; + for meta in metas { + let bytes: &[u8] = bytemuck::cast_slice(meta); + self.meta_buffers[frame].write_at(offset, bytes); + offset += bytes.len(); + } self.upload_pending -= 1; } - let count = meta.len() as u32; + let count = metas.iter().map(Vec::len).sum::() as u32; let frustum_data = FrustumData { planes: *frustum, chunk_count: count, @@ -2078,7 +2061,7 @@ impl DrawList { &mut self.count_buffers[frame], &mut self.count_cutout_buffers[frame], ] { - buffer.write(&0u32.to_ne_bytes()); + buffer.zero(); } if zero_indirect { for buffer in [ @@ -2146,4 +2129,22 @@ mod tests { build_meta(&chunks, None, false, eye, now, &mut metas); assert_eq!(metas[0].len(), 3); } + + #[test] + fn build_meta_lays_arenas_out_back_to_back() { + let now = Instant::now(); + let pos = ChunkPos::new(0, 0); + let mut alloc = ChunkAlloc::synthetic(pos, -64, [0, 1, 2], now); + alloc.sections[2].arena = 2; + let chunks = HashMap::from([(pos, alloc)]); + + let mut metas = [Vec::new(), Vec::new(), Vec::new()]; + build_meta(&chunks, None, false, DVec3::ZERO, now, &mut metas); + + let layout: Vec> = metas + .iter() + .map(|list| list.iter().map(|m| (m.arena, m.draw_base)).collect()) + .collect(); + assert_eq!(layout, [vec![(0, 0), (0, 0)], vec![], vec![(2, 2)]]); + } } diff --git a/pomme-client/src/renderer/rhi/vulkan/mod.rs b/pomme-client/src/renderer/rhi/vulkan/mod.rs index 4423c275d..bb086402f 100644 --- a/pomme-client/src/renderer/rhi/vulkan/mod.rs +++ b/pomme-client/src/renderer/rhi/vulkan/mod.rs @@ -1552,16 +1552,24 @@ impl RenderPassEncoder { } /// Like [`Self::draw_indexed_indirect`], with the draw count read from - /// `count_buffer` and capped at `max_draws`. + /// `count_buffer` at `count_offset` and capped at `max_draws`. pub fn draw_indexed_indirect_count( &self, buffer: BufferHandle, + offset: u64, count_buffer: BufferHandle, + count_offset: u64, max_draws: u32, stride: u32, ) { - self.cmd - .draw_indexed_indirect_count(buffer.0, 0, count_buffer.0, 0, max_draws, stride); + self.cmd.draw_indexed_indirect_count( + buffer.0, + offset, + count_buffer.0, + count_offset, + max_draws, + stride, + ); } /// Clears depth to 1.0 inside `rect` mid-pass. diff --git a/pomme-client/src/renderer/shaders/cull.comp b/pomme-client/src/renderer/shaders/cull.comp index a11480ded..6a0ca9e36 100644 --- a/pomme-client/src/renderer/shaders/cull.comp +++ b/pomme-client/src/renderer/shaders/cull.comp @@ -3,8 +3,12 @@ layout(local_size_x = 64) in; struct ChunkMeta { - vec4 aabb_min; - vec4 aabb_max; + vec3 aabb_min; + // Where the section's arena's draws start in the draw lists. + uint draw_base; + vec3 aabb_max; + // The section's arena, which picks its draw count. + uint arena; uint index_count; uint first_index; int vertex_offset; @@ -45,7 +49,7 @@ layout(set = 0, binding = 2) writeonly buffer IndirectBuf { }; layout(set = 0, binding = 3) buffer CountBuf { - uint draw_count; + uint draw_counts[]; }; layout(set = 0, binding = 4) writeonly buffer IndirectCutoutBuf { @@ -53,24 +57,24 @@ layout(set = 0, binding = 4) writeonly buffer IndirectCutoutBuf { }; layout(set = 0, binding = 5) buffer CutoutCountBuf { - uint cutout_draw_count; + uint cutout_draw_counts[]; }; -// Emit one indexed draw covering [first..first+count) for this section. The -// per-instance attributes (origin + fade) come from meta entry `idx`, routed via -// first_instance. -void emit(uint count, uint first, int vertex_offset, uint idx, bool cutout) { +// Emit one indexed draw covering [first..first+count) for this section into +// its arena's draws. The per-instance attributes (origin + fade) come from +// meta entry `idx`, routed via first_instance. +void emit(ChunkMeta m, uint count, uint first, uint idx, bool cutout) { if (count == 0u) return; DrawCmd d; d.index_count = count; d.instance_count = 1u; d.first_index = first; - d.vertex_offset = vertex_offset; + d.vertex_offset = m.vertex_offset; d.first_instance = idx; if (cutout) { - cutout_draws[atomicAdd(cutout_draw_count, 1u)] = d; + cutout_draws[m.draw_base + atomicAdd(cutout_draw_counts[m.arena], 1u)] = d; } else { - draws[atomicAdd(draw_count, 1u)] = d; + draws[m.draw_base + atomicAdd(draw_counts[m.arena], 1u)] = d; } } @@ -97,6 +101,6 @@ void main() { // Indices split solid-first. uint solid_count = m.solid_index_count; - emit(solid_count, m.first_index, m.vertex_offset, idx, false); - emit(m.index_count - solid_count, m.first_index + solid_count, m.vertex_offset, idx, true); + emit(m, solid_count, m.first_index, idx, false); + emit(m, m.index_count - solid_count, m.first_index + solid_count, idx, true); } diff --git a/pomme-gpu-allocator/src/vulkan/mod.rs b/pomme-gpu-allocator/src/vulkan/mod.rs index fc8560f51..dd7d03078 100644 --- a/pomme-gpu-allocator/src/vulkan/mod.rs +++ b/pomme-gpu-allocator/src/vulkan/mod.rs @@ -705,14 +705,24 @@ impl MemoryType { mem_block.sub_allocator.free(allocation.chunk_id)?; - // We only want to destroy this now-empty block if it is either a - // dedicated/personal allocation, or a block supporting sub-allocations - // that is not the last one (ensuring there's always at least one - // block/allocator readily available). - let is_dedicated_or_not_last_general_block = - !mem_block.sub_allocator.supports_general_allocations() - || self.active_general_blocks > 1; - if mem_block.sub_allocator.is_empty() && is_dedicated_or_not_last_general_block { + // A now-empty dedicated/personal block is destroyed. A general one is + // kept unless another general block is already empty, as VMA does: a + // fresh block stalls its first submit, so allocating one back and + // forth would stall each time. + let is_spare = |block: &MemoryBlock| { + block.sub_allocator.supports_general_allocations() && block.sub_allocator.is_empty() + }; + let destroy = if mem_block.sub_allocator.supports_general_allocations() { + is_spare(mem_block) + && self + .memory_blocks + .iter() + .enumerate() + .any(|(i, block)| i != block_idx && block.as_ref().is_some_and(is_spare)) + } else { + mem_block.sub_allocator.is_empty() + }; + if destroy { let block = self.memory_blocks[block_idx] .take() .ok_or_else(|| AllocationError::Internal("Memory block must be Some.".into()))?; From c512ea002aa76412253a22eeda69333efdbefb91 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:34:03 +0000 Subject: [PATCH 03/67] retry starved columns that eviction can make room for, evict stale far meshes and give arenas their own memory --- pomme-client/src/renderer/chunk/buffer.rs | 424 +++++++++++++++----- pomme-client/src/renderer/rhi/vulkan/mod.rs | 34 +- tests/ingame/world/render_distance.toml | 64 +++ 3 files changed, 410 insertions(+), 112 deletions(-) create mode 100644 tests/ingame/world/render_distance.toml diff --git a/pomme-client/src/renderer/chunk/buffer.rs b/pomme-client/src/renderer/chunk/buffer.rs index 35837b014..47f01bd0b 100644 --- a/pomme-client/src/renderer/chunk/buffer.rs +++ b/pomme-client/src/renderer/chunk/buffer.rs @@ -7,7 +7,7 @@ use super::mesher::{ ChunkAABB, ChunkMeshData, PackVertex, PackedVertex, SectionMesh, TerrainFormat, }; use super::tlsf::Tlsf; -use crate::position::ChunkPos; +use crate::position::{BlockPos, ChunkPos}; use crate::renderer::rhi::IndirectDrawFeatures; use crate::renderer::{MAX_FRAMES_IN_FLIGHT, rhi, shader, util}; @@ -36,6 +36,8 @@ const COMPACT_VERTICES_PER_FRAME: u32 = 1 << 18; const FRONTIER_SLACK: u64 = 16; /// Frames before compaction tries again after running out of room. const COMPACT_BACKOFF_FRAMES: u64 = 600; +/// Frames between checks, while columns are starved, for a budget that grew. +const BUDGET_RECHECK_FRAMES: u64 = 60; /// Draws the meta and indirect buffers start with room for. Growing them /// waits the GPU out, so start generously. const INITIAL_META: usize = 16384; @@ -380,8 +382,10 @@ impl SectionAlloc { Slice { arena: self.arena, vertex_offset: self.vertex_offset as u32, + #[cfg(feature = "test-harness")] vertex_len: self.vtx_len, first_index: self.first_index, + #[cfg(feature = "test-harness")] index_len: self.idx_len, } } @@ -391,6 +395,17 @@ pub(crate) struct ChunkAlloc { sections: Vec, } +impl ChunkAlloc { + /// What the column's sections take. + fn need(&self) -> Need { + let mut need = Need::default(); + for sec in &self.sections { + need.add(sec.vtx_len, sec.idx_len); + } + need + } +} + #[cfg(any(test, feature = "cpu-bench"))] impl ChunkAlloc { /// A column of synthetic full-size sections at `section_indices`, all @@ -427,13 +442,16 @@ impl ChunkAlloc { } } -/// A slice of one arena's vertex and index heaps. +/// A slice of one arena's vertex and index heaps. Freeing goes by offset; +/// the lengths are for the harness's overlap check. #[derive(Clone, Copy)] struct Slice { arena: u16, vertex_offset: u32, + #[cfg(feature = "test-harness")] vertex_len: u32, first_index: u32, + #[cfg(feature = "test-harness")] index_len: u32, } @@ -454,6 +472,11 @@ impl Need { self.max_indices = self.max_indices.max(indices); } + /// Whether `free_vertices` and `free_indices` hold this. + fn fits_in(self, free_vertices: u64, free_indices: u64) -> bool { + self.vertices <= free_vertices && self.indices <= free_indices + } + fn max(self, other: Self) -> Self { Self { vertices: self.vertices.max(other.vertices), @@ -473,8 +496,9 @@ struct Starved { /// One vertex and one index heap, sub-allocated by [`Tlsf`]: a heap of /// vanilla's `UberGpuBuffer`, its vertex and index halves paired so each -/// arena draws in one call. Only device memory, so adding one never needs a -/// fresh host memory block, whose first use stalls. +/// arena draws in one call. Device-local behind the staging buffer, in +/// memory of its own so destroying it hands the VRAM back; host-visible on +/// integrated GPUs. struct Arena { vertex_buffer: rhi::backend::Buffer, index_buffer: rhi::backend::Buffer, @@ -599,8 +623,10 @@ pub struct ChunkBufferStore { /// Columns whose sections found no room, or were evicted for nearer /// ones; handed back for re-meshing once there's room for them. starved: HashMap, - /// A slice was reclaimed since the last [`Self::upload_batch`]. - reclaimed: bool, + /// The starved columns are worth retrying in the next + /// [`Self::upload_batch`]: a slice was reclaimed, the eye reached another + /// column, or the budget grew. + retry_due: bool, /// Columns this far from the eye, squared, or farther were evicted for /// nearer ones, or are past those that were, and wait for room. It moves /// back out as room frees. @@ -707,7 +733,7 @@ impl ChunkBufferStore { format, device_local_bytes: device.device_local_bytes, starved: HashMap::new(), - reclaimed: false, + retry_due: false, frontier_sq: f64::INFINITY, draining: None, compact_after: 0, @@ -778,9 +804,9 @@ impl ChunkBufferStore { meshes: &[ChunkMeshData], ) -> Vec<(ChunkPos, Vec)> { let mut needs_remesh: Vec<(ChunkPos, Vec)> = Vec::new(); - // Starved sections retry only once a slice has been reclaimed; until - // then the arenas are as full as before and the retry would fail too. - if std::mem::take(&mut self.reclaimed) { + // Until something changes, the arenas are as full as before and the + // retry would fail too. + if std::mem::take(&mut self.retry_due) { self.retry_starved(device, &mut needs_remesh); } if meshes.is_empty() { @@ -823,8 +849,10 @@ impl ChunkBufferStore { Slice { arena: self.arena, vertex_offset: self.vtx_off, + #[cfg(feature = "test-harness")] vertex_len: self.vertex_count, first_index: self.idx_off, + #[cfg(feature = "test-harness")] index_len: self.index_len(), } } @@ -838,7 +866,7 @@ impl ChunkBufferStore { device.wait_idle(); while let Some((_, slice)) = self.pending_free.pop_front() { self.free_slice(slice); - self.reclaimed = true; + self.retry_due = true; } } @@ -947,8 +975,11 @@ impl ChunkBufferStore { for p in &plans { self.free_slice(p.slice()); } - tracing::debug!("No room for {:?}'s mesh, keeping its old one", mesh.pos); - if !beyond { + if beyond { + // Its old mesh would go stale; it waits with the rest. + self.evict(mesh.pos); + } else { + tracing::debug!("No room for {:?}'s mesh, keeping its old one", mesh.pos); self.evict_beyond(mesh.pos, need); } self.starve(mesh.pos, accepted, need); @@ -1177,36 +1208,29 @@ impl ChunkBufferStore { let Some(eye) = self.eye else { return; }; - let limit = column_dist_sq(pos, eye); - let mut nearest = f64::INFINITY; - let mut far: Vec<(f64, ChunkPos)> = self + let loaded = self .chunks - .keys() - .map(|&p| (column_dist_sq(p, eye), p)) - .filter(|&(dist, _)| dist > limit) - .collect(); - far.sort_unstable_by(|a, b| b.0.total_cmp(&a.0)); - let mut freed = Need::default(); - for (dist, victim) in far { - if freed.vertices >= need.vertices && freed.indices >= need.indices { - break; - } - nearest = dist; - let Some(alloc) = self.chunks.remove(&victim) else { - continue; - }; - let mut taken = Need::default(); - for sec in &alloc.sections { - taken.add(sec.vtx_len, sec.idx_len); - } - freed.vertices += taken.vertices; - freed.indices += taken.indices; - self.retire_slices(alloc.sections.iter().map(SectionAlloc::slice)); - let sections = alloc.sections.iter().map(|s| s.section_index); - self.starve(victim, sections, taken); + .iter() + .map(|(&p, alloc)| (column_dist_sq(p, eye), p, alloc.need())); + let victims = eviction_victims(loaded, column_dist_sq(pos, eye), need); + let Some(&(nearest, _)) = victims.last() else { + return; + }; + for (_, victim) in victims { + self.evict(victim); tracing::debug!("Evicted {victim:?} for {pos:?}"); } self.frontier_sq = self.frontier_sq.min(nearest); + } + + /// Drops `pos`'s mesh, starving the column until there's room for it. + fn evict(&mut self, pos: ChunkPos) { + let Some(alloc) = self.chunks.remove(&pos) else { + return; + }; + self.retire_slices(alloc.sections.iter().map(SectionAlloc::slice)); + let sections = alloc.sections.iter().map(|s| s.section_index); + self.starve(pos, sections, alloc.need()); self.meta_dirty = true; } @@ -1222,12 +1246,9 @@ impl ChunkBufferStore { .is_some_and(|eye| column_dist_sq(pos, eye) >= self.frontier_sq) } - /// Hands the starved columns that could fit now back for re-meshing, - /// nearest first: all of them while another arena fits the budget, else - /// those whose total fits the arenas' free space and whose largest - /// section fits some arena. Past the frontier, only while - /// [`FRONTIER_SLACK`] of an arena stays free after, so the frontier - /// doesn't swing back and forth as columns come and go. + /// Hands the starved columns that could fit now back for re-meshing: all + /// of them while another arena fits the budget, else those + /// [`columns_to_retry`] picks. fn retry_starved( &mut self, device: &rhi::backend::Device, @@ -1252,50 +1273,36 @@ impl ChunkBufferStore { .filter(|&(id, _)| self.draining != Some(id as u16)) .filter_map(|(_, slot)| live(slot)) .collect(); - let mut free_vertices: u64 = arenas + let free_vertices: u64 = arenas .iter() .map(|a| u64::from(a.vertices.capacity() - a.vertices.used())) .sum(); - let mut free_indices: u64 = arenas + let free_indices: u64 = arenas .iter() .map(|a| u64::from(a.indices.capacity() - a.indices.used())) .sum(); - let mut fitting: Vec<(f64, ChunkPos, Need)> = self - .starved - .iter() - .filter(|(_, s)| { - arenas.iter().any(|a| { - a.vertices.fits(s.need.max_vertices) && a.indices.fits(s.need.max_indices) - }) - }) - .map(|(&pos, s)| { - let dist = self.eye.map_or(0.0, |eye| column_dist_sq(pos, eye)); - (dist, pos, s.need) - }) - .collect(); - fitting.sort_unstable_by(|a, b| a.0.total_cmp(&b.0)); - let slack_vertices = u64::from(ARENA_VERTICES) / FRONTIER_SLACK; - let slack_indices = u64::from(ARENA_INDICES) / FRONTIER_SLACK; - for (dist, pos, need) in fitting { - let past = dist >= self.frontier_sq; - let (keep_vertices, keep_indices) = if past { - (slack_vertices, slack_indices) - } else { - (0, 0) - }; - if need.vertices + keep_vertices > free_vertices - || need.indices + keep_indices > free_indices - { - if past { - break; - } - continue; - } - free_vertices -= need.vertices; - free_indices -= need.indices; - if past { - self.frontier_sq = dist.next_up(); - } + let dist = |pos| self.eye.map_or(0.0, |eye| column_dist_sq(pos, eye)); + let farthest_loaded = self + .chunks + .keys() + .map(|&pos| dist(pos)) + .fold(f64::NEG_INFINITY, f64::max); + let waiting = self.starved.iter().map(|(&pos, s)| Waiting { + dist: dist(pos), + pos, + need: s.need, + fits_an_arena: arenas.iter().any(|a| { + a.vertices.fits(s.need.max_vertices) && a.indices.fits(s.need.max_indices) + }), + }); + let (retry, frontier_sq) = columns_to_retry( + waiting, + (free_vertices, free_indices), + self.frontier_sq, + farthest_loaded, + ); + self.frontier_sq = frontier_sq; + for pos in retry { if let Some(starved) = self.starved.remove(&pos) { out.push((pos, starved.sections.into_iter().collect())); } @@ -1347,7 +1354,13 @@ impl ChunkBufferStore { { let (_, slice) = self.pending_free.pop_front().unwrap(); self.free_slice(slice); - self.reclaimed = true; + self.retry_due = true; + } + if !self.starved.is_empty() + && self.frame_seq.is_multiple_of(BUDGET_RECHECK_FRAMES) + && self.can_add_arena(device) + { + self.retry_due = true; } self.free_empty_arenas(device); self.compact(device); @@ -1393,37 +1406,28 @@ impl ChunkBufferStore { /// Once the arenas' contents would fit one fewer, drains the emptiest /// into the rest, a bounded amount per frame, so freeing it can shrink /// the total. + /// Not while columns are starved: they want the room, and the arena it + /// frees would only be added back for them. fn compact(&mut self, device: &rhi::backend::Device) { + if !self.starved.is_empty() { + self.draining = None; + return; + } if self.frame_seq < self.compact_after { return; } let from = match self.draining { Some(id) => id, None => { - let live: Vec<(usize, &Arena)> = self - .arenas - .iter() - .enumerate() - .filter_map(|(id, slot)| live(slot).map(|a| (id, a))) - .collect(); - if live.len() < 2 { - return; - } - let used_vertices: u64 = - live.iter().map(|(_, a)| u64::from(a.vertices.used())).sum(); - let used_indices: u64 = live.iter().map(|(_, a)| u64::from(a.indices.used())).sum(); - let room = (live.len() - 1) as f64 * COMPACT_FILL; - if used_vertices as f64 > room * f64::from(ARENA_VERTICES) - || used_indices as f64 > room * f64::from(ARENA_INDICES) - { - return; - } - let Some(&(id, _)) = live.iter().min_by_key(|(_, a)| a.vertices.used()) else { + let live = self.arenas.iter().enumerate().filter_map(|(id, slot)| { + live(slot).map(|a| (id as u16, a.vertices.used(), a.indices.used())) + }); + let Some(id) = arena_to_drain(live) else { return; }; tracing::debug!("Compacting chunk arena {id}"); - self.draining = Some(id as u16); - id as u16 + self.draining = Some(id); + id } }; @@ -1480,7 +1484,6 @@ impl ChunkBufferStore { }); self.retire_slices(retired); self.meta_dirty = true; - self.ensure_meta_capacity(device); } pub fn remove(&mut self, pos: &ChunkPos) { @@ -1605,6 +1608,13 @@ impl ChunkBufferStore { anchor: DVec3, eye: DVec3, ) { + let column = |eye| { + let block = BlockPos::containing(eye); + ChunkPos::new(block.x >> 4, block.z >> 4) + }; + if !self.starved.is_empty() && self.eye.is_some_and(|last| column(last) != column(eye)) { + self.retry_due = true; + } self.eye = Some(eye); if self.chunks.is_empty() { return; @@ -1893,6 +1903,105 @@ fn arena_bytes(format: TerrainFormat) -> u64 { u64::from(ARENA_VERTICES) * format.vertex_size() + u64::from(ARENA_INDICES) * INDEX_SIZE } +/// Of the `loaded` columns, as (distance from the eye squared, position, +/// what they take), those to evict for `need`: the farthest first, all +/// farther than `limit`, until they free it. Nearest last. +fn eviction_victims( + loaded: impl Iterator, + limit: f64, + need: Need, +) -> Vec<(f64, ChunkPos)> { + let mut far: Vec<_> = loaded.filter(|&(dist, ..)| dist > limit).collect(); + far.sort_unstable_by(|a, b| b.0.total_cmp(&a.0)); + let mut freed = Need::default(); + let mut victims = Vec::new(); + for (dist, pos, taken) in far { + if need.fits_in(freed.vertices, freed.indices) { + break; + } + freed.vertices += taken.vertices; + freed.indices += taken.indices; + victims.push((dist, pos)); + } + victims +} + +/// A starved column, for [`columns_to_retry`]. +struct Waiting { + /// From the eye, squared. + dist: f64, + pos: ChunkPos, + need: Need, + /// Its largest section fits some arena. + fits_an_arena: bool, +} + +/// The starved columns to hand back, nearest first, and the eviction +/// frontier after. Inside the frontier a column comes back when it fits the +/// free vertices and indices, or when a loaded column farther out (the +/// farthest is at `farthest_loaded`) can be evicted for it. Past it, only +/// while [`FRONTIER_SLACK`] of an arena stays free after, moving the frontier +/// out past it, so the frontier doesn't swing back and forth as columns come +/// and go. +fn columns_to_retry( + waiting: impl Iterator, + (mut free_vertices, mut free_indices): (u64, u64), + mut frontier_sq: f64, + farthest_loaded: f64, +) -> (Vec, f64) { + let mut waiting: Vec = waiting.collect(); + waiting.sort_unstable_by(|a, b| a.dist.total_cmp(&b.dist)); + let mut retry = Vec::new(); + for column in waiting { + let past = column.dist >= frontier_sq; + let (keep_vertices, keep_indices) = if past { + ( + u64::from(ARENA_VERTICES) / FRONTIER_SLACK, + u64::from(ARENA_INDICES) / FRONTIER_SLACK, + ) + } else { + (0, 0) + }; + let fits = column.fits_an_arena + && column.need.fits_in( + free_vertices.saturating_sub(keep_vertices), + free_indices.saturating_sub(keep_indices), + ); + if fits { + free_vertices -= column.need.vertices; + free_indices -= column.need.indices; + if past { + frontier_sq = column.dist.next_up(); + } + retry.push(column.pos); + } else if past { + break; + } else if column.dist < farthest_loaded { + retry.push(column.pos); + } + } + (retry, frontier_sq) +} + +/// The arena compaction should drain, of the `live` ones as (id, vertices +/// used, indices used): the emptiest, once the others could hold everything +/// filled to [`COMPACT_FILL`]. +fn arena_to_drain(live: impl Iterator) -> Option { + let live: Vec<_> = live.collect(); + if live.len() < 2 { + return None; + } + let used_vertices: u64 = live.iter().map(|&(_, v, _)| u64::from(v)).sum(); + let used_indices: u64 = live.iter().map(|&(.., i)| u64::from(i)).sum(); + let room = (live.len() - 1) as f64 * COMPACT_FILL; + if used_vertices as f64 > room * f64::from(ARENA_VERTICES) + || used_indices as f64 > room * f64::from(ARENA_INDICES) + { + return None; + } + live.iter().min_by_key(|&&(_, v, _)| v).map(|&(id, ..)| id) +} + /// An arena heap: device-local behind the staging buffer, or host-visible. /// Either way a copy source and target, for compaction. fn create_pool( @@ -1903,12 +2012,11 @@ fn create_pool( usage: rhi::BufferUsages, ) -> rhi::backend::Buffer { let usage = usage | rhi::BufferUsages::COPY_SRC | rhi::BufferUsages::COPY_DST; - let location = if use_staging { - rhi::MemoryLocation::Device + if use_staging { + rhi::backend::Buffer::dedicated(device, label, size, usage, rhi::MemoryLocation::Device) } else { - rhi::MemoryLocation::Host - }; - rhi::backend::Buffer::new(device, label, size, usage, location) + rhi::backend::Buffer::new(device, label, size, usage, rhi::MemoryLocation::Host) + } } /// Which view's draw lists [`ChunkBufferStore::draw_indirect`] draws. @@ -2147,4 +2255,98 @@ mod tests { .collect(); assert_eq!(layout, [vec![(0, 0), (0, 0)], vec![], vec![(2, 2)]]); } + + fn need(vertices: u64) -> Need { + let indices = vertices / 2 * 3; + Need { + vertices, + indices, + max_vertices: vertices as u32, + max_indices: indices as u32, + } + } + + #[test] + fn eviction_takes_the_farthest_until_the_need_is_met() { + let loaded = [ + (1.0, ChunkPos::new(1, 0), need(100)), + (9.0, ChunkPos::new(3, 0), need(100)), + (4.0, ChunkPos::new(2, 0), need(100)), + (16.0, ChunkPos::new(4, 0), need(100)), + ]; + let victims = eviction_victims(loaded.into_iter(), 2.0, need(150)); + assert_eq!( + victims, + [(16.0, ChunkPos::new(4, 0)), (9.0, ChunkPos::new(3, 0))] + ); + // Never one nearer than the column it's for. + let victims = eviction_victims(loaded.into_iter(), 8.0, need(1000)); + assert_eq!( + victims, + [(16.0, ChunkPos::new(4, 0)), (9.0, ChunkPos::new(3, 0))] + ); + assert!(eviction_victims(loaded.into_iter(), 0.0, Need::default()).is_empty()); + } + + fn waiting(dist: f64, x: i32, vertices: u64) -> Waiting { + Waiting { + dist, + pos: ChunkPos::new(x, 0), + need: need(vertices), + fits_an_arena: true, + } + } + + #[test] + fn starved_columns_inside_the_frontier_come_back_for_eviction() { + let free = (100, 150); + // The near one fits; the next doesn't, but a farther loaded column + // can be evicted for it. + let (retry, frontier) = columns_to_retry( + [waiting(1.0, 1, 50), waiting(4.0, 2, 500)].into_iter(), + free, + 100.0, + 9.0, + ); + assert_eq!(retry, [ChunkPos::new(1, 0), ChunkPos::new(2, 0)]); + assert_eq!(frontier, 100.0); + // Nothing loaded farther out, so it waits. + let (retry, _) = columns_to_retry([waiting(4.0, 2, 500)].into_iter(), free, 100.0, 1.0); + assert!(retry.is_empty()); + } + + #[test] + fn starved_columns_past_the_frontier_need_slack() { + let slack = u64::from(ARENA_VERTICES) / FRONTIER_SLACK; + let free = (slack + 100, u64::MAX); + let (retry, frontier) = columns_to_retry( + [ + waiting(25.0, 5, 100), + waiting(36.0, 6, 100), + waiting(49.0, 7, 1), + ] + .into_iter(), + free, + 16.0, + f64::INFINITY, + ); + // The first fits with the slack left and moves the frontier past it; + // the second doesn't, and nothing farther comes back. + assert_eq!(retry, [ChunkPos::new(5, 0)]); + assert_eq!(frontier, 25.0f64.next_up()); + } + + #[test] + fn compaction_drains_the_emptiest_once_the_rest_hold_it() { + let quarter = ARENA_VERTICES / 4; + assert_eq!(arena_to_drain([(0, quarter, 0)].into_iter()), None); + assert_eq!( + arena_to_drain([(0, quarter * 2, 0), (3, quarter / 2, 0)].into_iter()), + Some(3) + ); + assert_eq!( + arena_to_drain([(0, quarter * 3, 0), (3, quarter, 0)].into_iter()), + None + ); + } } diff --git a/pomme-client/src/renderer/rhi/vulkan/mod.rs b/pomme-client/src/renderer/rhi/vulkan/mod.rs index bb086402f..36e20add2 100644 --- a/pomme-client/src/renderer/rhi/vulkan/mod.rs +++ b/pomme-client/src/renderer/rhi/vulkan/mod.rs @@ -281,6 +281,31 @@ impl Buffer { size: u64, usage: BufferUsages, location: MemoryLocation, + ) -> Result { + Self::create(device, label, size, usage, location, false) + } + + /// Like [`Self::new`], in memory of its own, which goes back to the + /// driver when the buffer is destroyed instead of staying in a shared + /// block. + pub fn dedicated( + device: &Device, + label: &str, + size: u64, + usage: BufferUsages, + location: MemoryLocation, + ) -> Self { + Self::create(device, label, size, usage, location, true) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn create( + device: &Device, + label: &str, + size: u64, + usage: BufferUsages, + location: MemoryLocation, + dedicated: bool, ) -> Result { let info = vk::BufferCreateInfo { size, @@ -302,6 +327,11 @@ impl Buffer { MemoryLocation::Readback => pomme_gpu_allocator::MemoryLocation::GpuToCpu, }, true, + if dedicated { + AllocationScheme::DedicatedBuffer(raw) + } else { + AllocationScheme::GpuAllocatorManaged + }, ) { Ok(allocation) => allocation, Err(error) => { @@ -392,6 +422,7 @@ fn allocate( requirements: vk::MemoryRequirements, location: pomme_gpu_allocator::MemoryLocation, linear: bool, + allocation_scheme: AllocationScheme, ) -> Result { device .allocator() @@ -400,7 +431,7 @@ fn allocate( requirements, location, linear, - allocation_scheme: AllocationScheme::GpuAllocatorManaged, + allocation_scheme, }) .map_err(|error| format!("failed to allocate memory for {label}: {error}")) } @@ -481,6 +512,7 @@ impl Texture { device.raw.get_image_memory_requirements(raw), pomme_gpu_allocator::MemoryLocation::GpuOnly, false, + AllocationScheme::GpuAllocatorManaged, ) { Ok(allocation) => allocation, Err(error) => { diff --git a/tests/ingame/world/render_distance.toml b/tests/ingame/world/render_distance.toml new file mode 100644 index 000000000..296c4efc7 --- /dev/null +++ b/tests/ingame/world/render_distance.toml @@ -0,0 +1,64 @@ +# Render Distance down and back up: the far columns go when it drops and +# come back when it rises, settling each time with every loaded column meshed +# and uploaded and no two pool ranges overlapping. The test server's view +# distance is 4, so 4 is the most this reaches. + +[[step]] +wait_ms = 2000 + +[[step]] +chat = "/gamemode creative" + +[[step]] +chat = "/gamerule advance_time false" + +[[step]] +chat = "/time set 6000" + +# A pillar to look out from. +[[step]] +chat = "/fill 0 -60 0 0 -51 0 minecraft:stone" + +[[step]] +chat = "/tp @s 0.5 -50 0.5 0 20" + +[[step]] +deterministic = true + +[[step]] +option = { render_distance = 2 } + +[[step]] +expect = { terrain = "settled" } +timeout_ms = 30000 + +# Chat fades after 10 seconds. +[[step]] +wait_ms = 11000 + +[[step]] +screenshot = "near" + +[[step]] +option = { render_distance = 4 } + +[[step]] +expect = { terrain = "settled" } +timeout_ms = 30000 + +# Vanilla's sections can report compiled before its chunks are back. +[[step]] +wait_ms = 3000 + +[[step]] +screenshot = "far" + +# Back to the default, which the server's 4 caps. +[[step]] +option = { render_distance = 12 } + +[[step]] +chat = "/fill 0 -60 0 0 -51 0 minecraft:air" + +[[step]] +chat = "/gamerule advance_time true" From 411f67be14923cd8052c97b0252efe7fe56a04e3 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:03:23 +0000 Subject: [PATCH 04/67] add ChunkPos::containing for block to column conversions --- pomme-client/src/app/core.rs | 5 ++--- pomme-client/src/app/phases/in_game.rs | 6 +++++- pomme-client/src/position.rs | 5 +++++ pomme-client/src/renderer/chunk/buffer.rs | 6 ++---- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index 1e3a7e91e..b7c3df59c 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -249,7 +249,7 @@ impl crate::world::piston::PistonLevel for ClientPistonLevel<'_> { } fn is_loaded(&self, pos: crate::position::BlockPos) -> bool { - let chunk = crate::position::ChunkPos::new(pos.x.div_euclid(16), pos.z.div_euclid(16)); + let chunk = crate::position::ChunkPos::containing(pos); self.game.chunk_store.get_chunk(&chunk).is_some() } @@ -2368,8 +2368,7 @@ impl AppCore { } } NetworkEvent::BlockEntityUpdate { pos, kind, nbt } => { - let chunk_pos = - crate::position::ChunkPos::new(pos.x.div_euclid(16), pos.z.div_euclid(16)); + let chunk_pos = crate::position::ChunkPos::containing(pos); if game.chunk_store.get_chunk(&chunk_pos).is_some() { game.chunk_store.block_entities.insert( pos, diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index f8b417458..c3691e911 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -1346,7 +1346,11 @@ impl GameState { /// neighbourhood was loaded and lit, and it must have a mesh — an empty one /// counts, as vanilla's empty `CompiledSectionMesh` does. pub fn camera_section_ready(&self, camera_block: glam::IVec3) -> bool { - let column = ChunkPos::new(camera_block.x >> 4, camera_block.z >> 4); + let column = ChunkPos::containing(BlockPos::new( + camera_block.x, + camera_block.y, + camera_block.z, + )); let neighbourhood_lit = crate::world::chunk::column_neighborhood(column).all(|p| { self.chunk_store.get_chunk(&p).is_some() && self.light_engine.light_on_in_column((p.x, p.z)) diff --git a/pomme-client/src/position.rs b/pomme-client/src/position.rs index 0a856b274..1124de5ed 100644 --- a/pomme-client/src/position.rs +++ b/pomme-client/src/position.rs @@ -43,6 +43,11 @@ impl ChunkPos { pub const fn new(x: i32, z: i32) -> Self { Self { x, z } } + + /// Vanilla `ChunkPos.containing`: the column holding `pos`. + pub const fn containing(pos: BlockPos) -> Self { + Self::new(pos.x >> 4, pos.z >> 4) + } } // TODO: drop this once the serverbound packets carrying positions are diff --git a/pomme-client/src/renderer/chunk/buffer.rs b/pomme-client/src/renderer/chunk/buffer.rs index 47f01bd0b..35d5a0d3a 100644 --- a/pomme-client/src/renderer/chunk/buffer.rs +++ b/pomme-client/src/renderer/chunk/buffer.rs @@ -1406,6 +1406,7 @@ impl ChunkBufferStore { /// Once the arenas' contents would fit one fewer, drains the emptiest /// into the rest, a bounded amount per frame, so freeing it can shrink /// the total. + /// /// Not while columns are starved: they want the room, and the arena it /// frees would only be added back for them. fn compact(&mut self, device: &rhi::backend::Device) { @@ -1608,10 +1609,7 @@ impl ChunkBufferStore { anchor: DVec3, eye: DVec3, ) { - let column = |eye| { - let block = BlockPos::containing(eye); - ChunkPos::new(block.x >> 4, block.z >> 4) - }; + let column = |eye| ChunkPos::containing(BlockPos::containing(eye)); if !self.starved.is_empty() && self.eye.is_some_and(|last| column(last) != column(eye)) { self.retry_due = true; } From 54233418b8e5f6afdd53161a4d6461398dca746e Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:52:33 +0000 Subject: [PATCH 05/67] keep chunks the server forgets and draw them as distant terrain --- pomme-client/build.rs | 2 + pomme-client/src/app/core.rs | 26 +- pomme-client/src/app/phases/in_game.rs | 26 ++ pomme-client/src/harness/exec.rs | 3 + pomme-client/src/harness/protocol.rs | 2 + pomme-client/src/harness/script.rs | 21 ++ pomme-client/src/renderer/camera.rs | 27 +- pomme-client/src/renderer/chunk/atlas.rs | 52 ++- pomme-client/src/renderer/chunk/mesher.rs | 11 + pomme-client/src/renderer/chunk/mod.rs | 2 +- pomme-client/src/renderer/distant/colors.rs | 168 ++++++++++ pomme-client/src/renderer/distant/column.rs | 306 ++++++++++++++++++ pomme-client/src/renderer/distant/gpu.rs | 296 +++++++++++++++++ pomme-client/src/renderer/distant/mesh.rs | 253 +++++++++++++++ pomme-client/src/renderer/distant/mod.rs | 12 + pomme-client/src/renderer/distant/store.rs | 298 +++++++++++++++++ pomme-client/src/renderer/distant/worker.rs | 286 ++++++++++++++++ pomme-client/src/renderer/mod.rs | 102 +++++- .../src/renderer/pipelines/distant.rs | 149 +++++++++ pomme-client/src/renderer/pipelines/mod.rs | 1 + .../src/renderer/shaders/distant.frag | 31 ++ .../src/renderer/shaders/distant.vert | 51 +++ pomme-client/src/ui/menu/mod.rs | 50 +++ pomme-client/src/ui/menu/options.rs | 15 +- pomme-client/src/world/chunk.rs | 7 +- pomme-client/src/world/light/mod.rs | 5 + tests/ingame/README.md | 9 +- tests/ingame/world/distant_terrain.toml | 82 +++++ 28 files changed, 2278 insertions(+), 15 deletions(-) create mode 100644 pomme-client/src/renderer/distant/colors.rs create mode 100644 pomme-client/src/renderer/distant/column.rs create mode 100644 pomme-client/src/renderer/distant/gpu.rs create mode 100644 pomme-client/src/renderer/distant/mesh.rs create mode 100644 pomme-client/src/renderer/distant/mod.rs create mode 100644 pomme-client/src/renderer/distant/store.rs create mode 100644 pomme-client/src/renderer/distant/worker.rs create mode 100644 pomme-client/src/renderer/pipelines/distant.rs create mode 100644 pomme-client/src/renderer/shaders/distant.frag create mode 100644 pomme-client/src/renderer/shaders/distant.vert create mode 100644 tests/ingame/world/distant_terrain.toml diff --git a/pomme-client/build.rs b/pomme-client/build.rs index 8a8b1dcb5..43a2c98fd 100644 --- a/pomme-client/build.rs +++ b/pomme-client/build.rs @@ -97,6 +97,8 @@ fn main() { ("chunk_solid.frag", shaderc::ShaderKind::Fragment), ("water.vert", shaderc::ShaderKind::Vertex), ("water.frag", shaderc::ShaderKind::Fragment), + ("distant.vert", shaderc::ShaderKind::Vertex), + ("distant.frag", shaderc::ShaderKind::Fragment), ("cube.vert", shaderc::ShaderKind::Vertex), ("cube.frag", shaderc::ShaderKind::Fragment), ("panorama.vert", shaderc::ShaderKind::Vertex), diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index b7c3df59c..b194588b7 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -430,6 +430,8 @@ impl GameState { self.mesh_dispatcher = self .mesh_dispatcher .recreate_for_version(self.block_registry.owned_render_view()); + self.distant + .set_colors(self.mesh_dispatcher.distant_colors()); } } @@ -441,6 +443,8 @@ impl GameState { self.biome_climate = Arc::new(colors); self.mesh_dispatcher .set_biome_climate(Arc::clone(&self.biome_climate)); + self.distant + .set_colors(self.mesh_dispatcher.distant_colors()); } /// Resets the level for a new dimension, with a mesh dispatcher for its @@ -473,6 +477,12 @@ impl GameState { // next tick samples the new level's. self.biome_weights = Default::default(); self.environment_probe = EnvironmentProbe::new(self.resolve_environment()); + // The old level's columns stay as its distant terrain; the name + // still names it until `DimensionName`. + for pos in self.chunk_store.loaded_positions() { + self.distant + .capture(&self.dimension, pos, &self.chunk_store); + } self.chunk_store = ChunkStore::new_with_dimension(render_distance, height, min_y); self.light_engine = crate::world::light::LevelLightEngine::new(height, min_y, has_skylight); // The old level's block entities, moving blocks among them. @@ -498,6 +508,8 @@ impl GameState { None, cardinal_light, ); + self.distant + .set_colors(self.mesh_dispatcher.distant_colors()); self.reset_mesh_columns(); } @@ -558,6 +570,8 @@ impl GameState { /// The caller removes the column's mesh. pub fn unload_chunk(&mut self, pos: crate::position::ChunkPos) { + self.distant + .capture(&self.dimension, pos, &self.chunk_store); self.chunk_store.unload_chunk(&pos); self.block_entity_anim.drop_chunk(pos.x, pos.z); self.light_engine.on_chunk_unloaded((pos.x, pos.z)); @@ -1233,6 +1247,7 @@ impl AppCore { self.menu.restore_bench_render_distance(); self.clear_server_resource_packs(&mut gfx.renderer); gfx.renderer.clear_chunk_meshes(); + gfx.renderer.reset_distant(); if let Some(p) = &mut self.presence { p.set_in_menu(&self.version); } @@ -1646,6 +1661,8 @@ impl AppCore { ); let (grass, foliage, dry_foliage) = mesh_dispatcher.colormaps(); game.mesh_dispatcher = mesh_dispatcher; + game.distant + .set_colors(game.mesh_dispatcher.distant_colors()); game.particle_store = crate::particle::ParticleStore::new( renderer.uv_map().clone(), grass, @@ -1770,6 +1787,7 @@ impl AppCore { NetworkEvent::DimensionInfo(dim) => { game.enter_dimension(dim, self.menu.render_distance, &*renderer); renderer.clear_chunk_meshes(); + renderer.reset_distant(); } NetworkEvent::DimensionName { name } => { game.dimension = name; @@ -1779,12 +1797,18 @@ impl AppCore { data, heightmaps, light, - } => game.load_chunk(pos, &data, &heightmaps, &light), + } => { + game.load_chunk(pos, &data, &heightmaps, &light); + if game.chunk_store.get_chunk(&pos).is_some() { + renderer.set_distant_column_loaded(pos, true); + } + } NetworkEvent::LightUpdate { pos, light } => game.apply_light_update(pos, &light), NetworkEvent::ChunkBiomes { chunks } => game.replace_biomes(&chunks), NetworkEvent::ChunkUnloaded { pos } => { game.unload_chunk(pos); renderer.remove_chunk_mesh(&pos); + renderer.set_distant_column_loaded(pos, false); } NetworkEvent::ChunkCacheCenter { x, z } => { tracing::debug!("Chunk cache center: [{x}, {z}]"); diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index c3691e911..d531d5ce6 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -148,6 +148,9 @@ pub struct GameState { pub player_walk_speed: f32, pub player_prev_walk_speed: f32, pub mesh_dispatcher: MeshDispatcher, + /// The chunks the server has forgotten, kept to draw past the loaded + /// ones. + pub(crate) distant: crate::renderer::distant::worker::DistantTerrain, /// Whether the server is this process, which the pause menu labels. pub singleplayer: bool, pub paused: bool, @@ -463,6 +466,8 @@ impl GameState { ); let chunk_store = ChunkStore::new(render_distance); + let distant = + crate::renderer::distant::worker::DistantTerrain::new(mesh_dispatcher.distant_colors()); Self { light_engine: crate::world::light::LevelLightEngine::new( chunk_store.height(), @@ -504,6 +509,7 @@ impl GameState { player_walk_speed: 0.0, player_prev_walk_speed: 0.0, mesh_dispatcher, + distant, singleplayer, paused: false, dead: false, @@ -1293,6 +1299,12 @@ impl GameState { pub fn drain_and_upload_meshes(&mut self, renderer: &mut Renderer) { let batch = self.drain_meshes(); let upload_start = std::time::Instant::now(); + let dimension = self.dimension.as_str(); + renderer.upload_distant( + self.distant + .drain() + .filter(|update| &*update.dimension == dimension), + ); let dropped = renderer.upload_chunk_meshes(&batch); self.last_update_phases.upload_ms = upload_start.elapsed().as_secs_f32() * 1000.0; self.clear_dropped_meshed(dropped); @@ -4385,6 +4397,19 @@ pub fn update_game( } else { core.menu.render_distance }; + // Off without sky light (see `renderer::distant`'s TODO); capturing + // stops with it. + let distant_terrain = if game.light_engine.has_sky() { + core.menu.distant_terrain + } else { + 0 + }; + game.distant.set_view( + &game.dimension, + game.player.position.x, + game.player.position.z, + distant_terrain, + ); let inventory = &game.player.inventory; let main_stack = &inventory.hotbar_slots()[core.input.selected_slot() as usize]; let shown_hands = game @@ -4483,6 +4508,7 @@ pub fn update_game( core.menu.cloud_mode }, effective_rd, + distant_terrain, player_preview, book_preview, game.player.eyes_in_water, diff --git a/pomme-client/src/harness/exec.rs b/pomme-client/src/harness/exec.rs index 3d2953e6a..35e635be4 100644 --- a/pomme-client/src/harness/exec.rs +++ b/pomme-client/src/harness/exec.rs @@ -222,6 +222,9 @@ fn set_options(core: &mut AppCore, options: &protocol::OptionSet) -> Result, /// Render Distance in chunks, clamped to the slider's range. pub render_distance: Option, + /// Distant Terrain in chunks, 0 for off, else clamped to 32..256. + pub distant_terrain: Option, } #[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)] diff --git a/pomme-client/src/harness/script.rs b/pomme-client/src/harness/script.rs index bc99972c5..09dd09c8a 100644 --- a/pomme-client/src/harness/script.rs +++ b/pomme-client/src/harness/script.rs @@ -76,6 +76,7 @@ enum Expect { Block(BlockExpect), Biome(BiomeExpect), Terrain(TerrainExpect), + Distant(DistantExpect), Menu(MenuExpect), Advancement(AdvancementExpect), Chat(ChatExpect), @@ -228,6 +229,20 @@ struct TerrainExpect { terrain: String, } +/// Distant terrain is drawn at a block column, `{ at = [x, z] }`: the +/// finest section holding it is meshed and uploaded. +#[derive(Debug, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +struct DistantExpect { + distant_terrain: DistantAt, +} + +#[derive(Debug, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +struct DistantAt { + at: [i32; 2], +} + /// The open menu: a container's menu type (`generic_9x3`), `inventory`, /// `creative`, `pause` or `none`, and optionally its title. #[derive(Debug, PartialEq, Deserialize)] @@ -704,6 +719,11 @@ fn check(expect: &Expect, phase: &AppPhase) -> Result<(), String> { } gfx.renderer.chunk_slice_overlap().map_or(Ok(()), Err) } + Expect::Distant(DistantExpect { + distant_terrain: DistantAt { at: [x, z] }, + }) => ensure(gfx.renderer.has_distant_column(*x, *z), || { + format!("no distant terrain at {x}, {z}") + }), Expect::Biome(BiomeExpect { biome, at }) => { let seen = protocol::biome(game, *at); ensure(seen == *biome, || format!("{at:?} is in {seen}")) @@ -833,6 +853,7 @@ mod tests { brightness: None, shader_pack: None, render_distance: None, + distant_terrain: None, })) ); assert_eq!( diff --git a/pomme-client/src/renderer/camera.rs b/pomme-client/src/renderer/camera.rs index 8741ebfcf..62de73988 100644 --- a/pomme-client/src/renderer/camera.rs +++ b/pomme-client/src/renderer/camera.rs @@ -13,6 +13,10 @@ pub const MIN_FOV_DEGREES: f32 = 30.0; #[allow(dead_code)] pub const MAX_FOV_DEGREES: f32 = 110.0; const NEAR: f32 = 0.1; +/// Near plane of distant terrain's projection. Its nearest column is past +/// the loaded chunks, so a near plane this far out keeps depth precise +/// across thousands of blocks. +const DISTANT_NEAR: f32 = 16.0; /// Floor of the dynamic far plane (vanilla floors at `cloudRange * 16` /// instead; pomme's fixed-reach clouds derive their fade from this). pub(crate) const MIN_FAR: f32 = 1000.0; @@ -577,10 +581,23 @@ impl Camera { } pub fn view_projection_with_fov(&self, fov: f32) -> Mat4 { + self.view_projection_with_planes(fov, NEAR, self.depth_far) + } + + /// The view projection distant terrain draws with, out to `far` blocks. + pub fn distant_view_projection(&self, far: f32) -> Mat4 { + self.view_projection_with_planes( + self.fov_radians(self.render_partial_tick), + DISTANT_NEAR, + far, + ) + } + + fn view_projection_with_planes(&self, fov: f32, near: f32, far: f32) -> Mat4 { let offset = self.third_person_offset(); let (forward, up) = self.view_basis(); let view = self.view_effect_matrix() * view::look_to_mat4(offset, forward, up); - let mut proj = proj::directx::perspective(fov, self.aspect_ratio, NEAR, self.depth_far); + let mut proj = proj::directx::perspective(fov, self.aspect_ratio, near, far); proj.y_axis.y *= -1.0; // Vulkan NDC has +Y down proj * view } @@ -616,13 +633,15 @@ const WATER_FOG_END: f32 = 96.0; impl CameraUniform { /// The camera with its fog: `sky`'s fog colour, and the environmental band - /// from its fog distances. + /// from its fog distances, scaled by `haze_scale` out of water (distant + /// terrain stretches the haze over its distance). /// TODO: the rain offsets `AtmosphericFogEnvironment` applies to them. pub fn new( camera: &Camera, sky: &SkyState, render_distance_chunks: u32, eyes_in_water: bool, + haze_scale: f32, ) -> Self { let fog_color = sky.fog_color.to_array(); let anchor = camera.anchor(); @@ -654,8 +673,8 @@ impl CameraUniform { ( blocks - span, blocks, - attributes.fog_start_distance, - attributes.fog_end_distance, + attributes.fog_start_distance * haze_scale, + attributes.fog_end_distance * haze_scale, fog_color, ) }; diff --git a/pomme-client/src/renderer/chunk/atlas.rs b/pomme-client/src/renderer/chunk/atlas.rs index 2190ce71e..047d6ab07 100644 --- a/pomme-client/src/renderer/chunk/atlas.rs +++ b/pomme-client/src/renderer/chunk/atlas.rs @@ -54,6 +54,9 @@ pub struct AtlasUVMap { /// built; later ones fall back to the name lookup. by_texture: Vec, sprite_alpha_masks: HashMap, + /// Each sprite's alpha-weighted average colour (its first frame), for + /// terrain drawn too far out to texture. + average_colors: HashMap, /// Level-0 rectangles by sprite index, as `(x, y, width, height)`. rects: Vec<[u32; 4]>, missing: AtlasRegion, @@ -63,6 +66,7 @@ impl AtlasUVMap { fn new( regions: HashMap, sprite_alpha_masks: HashMap, + average_colors: HashMap, rects: Vec<[u32; 4]>, missing: AtlasRegion, ) -> Self { @@ -74,6 +78,7 @@ impl AtlasUVMap { regions, by_texture, sprite_alpha_masks, + average_colors, rects, missing, } @@ -102,6 +107,10 @@ impl AtlasUVMap { self.sprite_alpha_masks.get(name) } + pub(crate) fn average_color(&self, name: &str) -> Option<[u8; 4]> { + self.average_colors.get(name).copied() + } + /// Each sprite's texture name, by sprite index. #[cfg(feature = "cpu-bench")] pub fn sprite_names(&self) -> HashMap { @@ -966,6 +975,7 @@ impl TextureAtlas { let mut regions = HashMap::new(); let mut sprite_alpha_masks = HashMap::new(); + let mut average_colors = HashMap::new(); let mut rects = vec![missing_region.pixel_rect.map(u32::from)]; for src in &sources { match placements.get(src.name.as_str()) { @@ -992,6 +1002,7 @@ impl TextureAtlas { if let Some(mask) = &src.alpha_mask { sprite_alpha_masks.insert(src.name.clone(), mask.clone()); } + average_colors.insert(src.name.clone(), average_rgba(&src.data, src.w, src.h)); regions.insert(src.name.clone(), region); } _ => { @@ -1000,7 +1011,13 @@ impl TextureAtlas { } } - let uv_map = AtlasUVMap::new(regions, sprite_alpha_masks, rects, missing_region); + let uv_map = AtlasUVMap::new( + regions, + sprite_alpha_masks, + average_colors, + rects, + missing_region, + ); AtlasCpu { uv_map, @@ -1035,6 +1052,27 @@ fn effective_mip_level(sources: &[Source]) -> u32 { }) } +/// The alpha-weighted mean colour of the first `w`x`h` RGBA texels, with +/// their mean alpha: a sprite's first frame, transparent texels not +/// darkening it. +fn average_rgba(data: &[u8], w: u32, h: u32) -> [u8; 4] { + let texels = (w * h) as usize; + let mut sum = [0u64; 3]; + let mut alpha = 0u64; + for texel in data.as_chunks::<4>().0.iter().take(texels) { + let a = u64::from(texel[3]); + for (total, &channel) in sum.iter_mut().zip(texel) { + *total += u64::from(channel) * a; + } + alpha += a; + } + if alpha == 0 { + return [0; 4]; + } + let [r, g, b] = sum.map(|total| (total / alpha) as u8); + [r, g, b, (alpha / texels as u64) as u8] +} + fn build_mip_chain( atlas_pixels: Vec, atlas_size: u32, @@ -1567,6 +1605,16 @@ mod tests { use super::*; use crate::test_util::test_temp_dir; + #[test] + fn the_average_colour_skips_transparent_texels_and_later_frames() { + // A 2x1 sprite: opaque red, clear green; then a white second frame. + let data = [ + 255, 0, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, + ]; + assert_eq!(average_rgba(&data, 2, 1), [255, 0, 0, 127]); + assert_eq!(average_rgba(&[0; 8], 2, 1), [0; 4]); + } + #[test] fn texture_ids_find_the_region_their_name_does() { let built = TextureId::intern("test/region_before_build"); @@ -1580,7 +1628,7 @@ mod tests { ("test/region_before_build".to_owned(), named), ("test/region_after_build".to_owned(), late_named), ]); - let uv_map = AtlasUVMap::new(regions, HashMap::new(), Vec::new(), missing); + let uv_map = AtlasUVMap::new(regions, HashMap::new(), HashMap::new(), Vec::new(), missing); // Interned after the map was built: found through the name instead. let late = TextureId::intern("test/region_after_build"); assert_eq!(uv_map.region(built).pixel_rect, named.pixel_rect); diff --git a/pomme-client/src/renderer/chunk/mesher.rs b/pomme-client/src/renderer/chunk/mesher.rs index f4a6cef04..674998011 100644 --- a/pomme-client/src/renderer/chunk/mesher.rs +++ b/pomme-client/src/renderer/chunk/mesher.rs @@ -986,6 +986,17 @@ impl MeshDispatcher { /// (grass, foliage, dry foliage) colormaps, shared with the particle /// store for break-particle tinting. + /// How each block state looks in distant terrain, from this + /// dispatcher's textures, colormaps and biomes. + pub(crate) fn distant_colors(&self) -> Arc { + Arc::new(crate::renderer::distant::colors::DistantColors::new( + self.render_view.as_view(), + &self.uv_map, + Arc::clone(&self.biome_climate), + self.colormaps(), + )) + } + pub fn colormaps(&self) -> (Arc, Arc, Arc) { ( Arc::clone(&self.grass_colormap), diff --git a/pomme-client/src/renderer/chunk/mod.rs b/pomme-client/src/renderer/chunk/mod.rs index f262b9a98..cbc7c055d 100644 --- a/pomme-client/src/renderer/chunk/mod.rs +++ b/pomme-client/src/renderer/chunk/mod.rs @@ -6,4 +6,4 @@ pub mod mesh_source; pub mod mesher; pub mod moving_blocks; pub mod occlusion_graph; -mod tlsf; +pub(crate) mod tlsf; diff --git a/pomme-client/src/renderer/distant/colors.rs b/pomme-client/src/renderer/distant/colors.rs new file mode 100644 index 000000000..d3e627120 --- /dev/null +++ b/pomme-client/src/renderer/distant/colors.rs @@ -0,0 +1,168 @@ +//! How each block state looks in distant terrain: whether it shows, and the +//! average colour of its top and side textures, tinted by biome where its +//! model is. + +use std::collections::HashMap; +use std::sync::Arc; + +use azalea_block::BlockState; + +use super::column::{RunKey, Shows}; +use crate::renderer::chunk::atlas::AtlasUVMap; +use crate::renderer::chunk::mesher::{ + BiomeClimate, Colormap, dry_foliage_color, foliage_color, grass_color, int_to_rgb, +}; +use crate::world::block::FluidKind; +use crate::world::block::render_assets::{BlockRenderView, Tint}; + +/// Texture colour where a sprite is missing. +const UNKNOWN: [u8; 3] = [128, 128, 128]; + +#[derive(Clone, Copy)] +struct StateLook { + shows: Shows, + /// Fills its cell, so nothing behind it shows. + fills: bool, + top: [u8; 3], + side: [u8; 3], + /// The biome tint, `None` once a fixed tint is baked into the colours. + tint: Tint, + /// The tint covers the sides too; a grass block's sides keep their + /// dirt. + tint_sides: bool, +} + +const HIDDEN: StateLook = StateLook { + shows: Shows::No, + fills: false, + top: UNKNOWN, + side: UNKNOWN, + tint: Tint::None, + tint_sides: false, +}; + +pub(crate) struct DistantColors { + /// By state id. + states: Vec, + climates: Arc>, + grass: Arc, + foliage: Arc, + dry_foliage: Arc, +} + +impl DistantColors { + pub fn new( + view: BlockRenderView<'_>, + uv_map: &AtlasUVMap, + climates: Arc>, + (grass, foliage, dry_foliage): (Arc, Arc, Arc), + ) -> Self { + let table = view.block_state_table(); + let color = |name: &str| { + uv_map + .average_color(name) + .map_or(UNKNOWN, |[r, g, b, _]| [r, g, b]) + }; + let states = (0..table.state_count() as u32) + .map(|id| { + let Ok(state) = BlockState::try_from(id) else { + return HIDDEN; + }; + let data = table.block_state_data(state); + let (top, side, tint, tint_sides) = match data.fluid().kind { + FluidKind::Water => { + let water = color("water_still"); + (water, water, Tint::Water, true) + } + FluidKind::Lava => { + let lava = color("lava_still"); + (lava, lava, Tint::None, false) + } + FluidKind::Empty => { + if data.is_invisible() || !data.has_collision() { + return HIDDEN; + } + let Some(faces) = view.get_textures(state) else { + return HIDDEN; + }; + let fixed = match faces.tint { + Tint::Constant(rgb) => Some(int_to_rgb(rgb as i32)), + Tint::Redstone => Some(data.redstone_wire_rgb()), + Tint::Stem => Some(data.stem_rgb()), + _ => None, + }; + let (mut top, mut side) = (color(&faces.top), color(&faces.north)); + let tint = match fixed { + Some(rgb) => { + top = multiply(top, rgb); + side = multiply(side, rgb); + Tint::None + } + None => faces.tint, + }; + (top, side, tint, faces.side_overlay.is_none()) + } + }; + StateLook { + shows: Shows::Yes { + tinted: tint != Tint::None, + }, + fills: data.occludes_neighbor_faces(), + top, + side, + tint, + tint_sides, + } + }) + .collect(); + Self { + states, + climates, + grass, + foliage, + dry_foliage, + } + } + + fn look(&self, state: BlockState) -> StateLook { + self.states + .get(usize::from(state.id())) + .copied() + .unwrap_or(HIDDEN) + } + + pub fn shows(&self, state: BlockState) -> Shows { + self.look(state).shows + } + + pub fn fills(&self, state: BlockState) -> bool { + self.look(state).fills + } + + /// A run's top and side colours, tinted for its biome. + pub fn colors(&self, key: RunKey) -> ([u8; 3], [u8; 3]) { + let look = self.look(key.state); + let climate = self.climates.get(&key.biome).copied().unwrap_or_default(); + let tint = match look.tint { + // Without a position, the biome's base colour (no swamp or dark + // forest noise). + Tint::Grass | Tint::DoubleGrass => grass_color(&climate, &self.grass, 0, 0), + Tint::Foliage => foliage_color(&climate, &self.foliage), + Tint::DryFoliage => dry_foliage_color(&climate, &self.dry_foliage), + Tint::Water => climate.water_color, + Tint::None | Tint::Constant(_) | Tint::Redstone | Tint::Stem => { + return (look.top, look.side); + } + }; + let side = if look.tint_sides { + multiply(look.side, tint) + } else { + look.side + }; + (multiply(look.top, tint), side) + } +} + +fn multiply(rgb: [u8; 3], tint: [f32; 3]) -> [u8; 3] { + std::array::from_fn(|i| (f32::from(rgb[i]) * tint[i]).round().clamp(0.0, 255.0) as u8) +} diff --git a/pomme-client/src/renderer/distant/column.rs b/pomme-client/src/renderer/distant/column.rs new file mode 100644 index 000000000..c80a054eb --- /dev/null +++ b/pomme-client/src/renderer/distant/column.rs @@ -0,0 +1,306 @@ +//! Distant terrain columns: a block column, or several merged, as the +//! vertical runs it's made of, top to bottom, one per change of block or +//! biome. + +use azalea_block::BlockState; + +use crate::world::chunk::{Chunk, ChunkLightData, SectionBlocks}; + +/// What a run is made of. `biome` is 0 for blocks whose colour doesn't +/// depend on it, so their runs merge across biomes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct RunKey { + pub state: BlockState, + pub biome: u32, +} + +/// One vertical run, `top` down to `bottom` inclusive, both as offsets from +/// the dimension's `min_y`, with the light of the cell above it: a solid +/// block's own cell is dark, so its faces take the light of the air they +/// face. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Run { + pub key: RunKey, + pub top: u16, + pub bottom: u16, + pub sky: u8, + pub block: u8, +} + +/// How a block state shows in distant terrain. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Shows { + /// Not at all: air, and blocks too small to matter from afar (flowers, + /// torches, grass). + No, + /// As a full box; `tinted` when its colour depends on the biome. + Yes { tinted: bool }, +} + +/// The runs of local column `x`/`z` of `chunk`, top to bottom. Walks down +/// from the highest non-empty section and stops at the first run that +/// fills its cell under no sky light: that run reaches the bottom of the +/// world, since nothing under it is seen from afar. +pub(crate) fn build_column( + chunk: &Chunk, + light: Option<&ChunkLightData>, + x: u32, + z: u32, + min_y: i32, + shows: impl Fn(BlockState) -> Shows, + fills: impl Fn(BlockState) -> bool, +) -> Vec { + let mut runs: Vec = Vec::new(); + let mut extending = false; + for (index, section) in chunk.sections.iter().enumerate().rev() { + if section.block_count == 0 { + extending = false; + continue; + } + for local_y in (0..16).rev() { + let state = section.get_block_state(x, local_y, z); + let tinted = match shows(state) { + Shows::No => { + extending = false; + continue; + } + Shows::Yes { tinted } => tinted, + }; + let y = index as i32 * 16 + local_y as i32; + let biome = if tinted { + section.biome(x / 4, local_y / 4, z / 4) + } else { + 0 + }; + let key = RunKey { state, biome }; + if extending && let Some(run) = runs.last_mut().filter(|run| run.key == key) { + run.bottom = y as u16; + continue; + } + let world_y = y + min_y + 1; + let (sky, block) = light.map_or((15, 0), |light| { + ( + light.get_sky_light(x as i32, world_y, z as i32), + light.get_block_light(x as i32, world_y, z as i32), + ) + }); + if sky == 0 && fills(state) { + runs.push(Run { + key, + top: y as u16, + bottom: 0, + sky, + block, + }); + return runs; + } + runs.push(Run { + key, + top: y as u16, + bottom: y as u16, + sky, + block, + }); + extending = true; + } + } + runs +} + +/// `columns` side by side as one coarser column: each height slice between +/// the inputs' run edges takes the block most of them have there (air never +/// wins, so a hollow doesn't open a hole), with the light of those that +/// have it. +pub(crate) fn merge_columns(columns: &[&[Run]]) -> Vec { + // Edges as `[low, high)` bounds, highest first. + let mut edges: Vec = columns + .iter() + .flat_map(|runs| runs.iter().flat_map(|run| [run.top + 1, run.bottom])) + .collect(); + edges.sort_unstable_by(|a, b| b.cmp(a)); + edges.dedup(); + + let mut merged: Vec = Vec::new(); + for pair in edges.windows(2) { + let (high, low) = (pair[0], pair[1]); + let at = |runs: &&[Run]| { + runs.iter() + .find(|run| run.bottom <= low && low <= run.top) + .copied() + }; + let present: Vec = columns.iter().filter_map(at).collect(); + let Some(winner) = present + .iter() + .max_by_key(|run| { + let votes = present.iter().filter(|other| other.key == run.key).count(); + // On a tie, the first column's block. + let first = present.iter().position(|other| other.key == run.key); + (votes, std::cmp::Reverse(first)) + }) + .map(|run| run.key) + else { + continue; + }; + let (count, sky, block) = present + .iter() + .filter(|run| run.key == winner) + .fold((0u32, 0u32, 0u32), |(n, s, b), run| { + (n + 1, s + u32::from(run.sky), b + u32::from(run.block)) + }); + let sky = ((sky + count / 2) / count) as u8; + let block = ((block + count / 2) / count) as u8; + let top = high - 1; + if let Some(last) = merged.last_mut() + && last.key == winner + && last.bottom == high + && last.sky == sky + && last.block == block + { + last.bottom = low; + continue; + } + merged.push(Run { + key: winner, + top, + bottom: low, + sky, + block, + }); + } + merged +} + +#[cfg(test)] +mod tests { + use pomme_protocol::section::Section; + + use super::*; + + fn state(id: u32) -> BlockState { + BlockState::try_from(id).unwrap() + } + + fn stone() -> BlockState { + state(1) + } + + fn grass() -> BlockState { + state(2) + } + + fn dirt() -> BlockState { + state(3) + } + + fn flower() -> BlockState { + state(4) + } + + fn shows(state: BlockState) -> Shows { + if state.is_air() || state == flower() { + Shows::No + } else { + Shows::Yes { + tinted: state == grass(), + } + } + } + + fn fills(state: BlockState) -> bool { + state != flower() + } + + fn run(key: BlockState, top: u16, bottom: u16, sky: u8) -> Run { + Run { + key: RunKey { + state: key, + biome: 0, + }, + top, + bottom, + sky, + block: 0, + } + } + + /// A 4-section column (y 0..64 above `min_y`) with `blocks` set at local + /// x = z = 0. + fn column_of(blocks: &[(u32, BlockState)]) -> Chunk { + let mut chunk = Chunk::empty(4); + for &(y, block) in blocks { + let section: &mut Section = &mut chunk.sections[(y / 16) as usize]; + section + .states + .get_and_set(0, y % 16, 0, u32::from(block.id()), 16); + section.block_count += 1; + } + chunk + } + + #[test] + fn a_column_is_its_runs_top_down_without_air_or_decoration() { + let chunk = column_of(&[ + (40, flower()), + (39, grass()), + (38, dirt()), + (37, dirt()), + (30, stone()), + (29, stone()), + ]); + let runs = build_column(&chunk, None, 0, 0, -64, shows, fills); + let tops: Vec<_> = runs + .iter() + .map(|r| (r.key.state, r.top, r.bottom)) + .collect(); + assert_eq!( + tops, + [(grass(), 39, 39), (dirt(), 38, 37), (stone(), 30, 29)], + "unlit columns keep every run" + ); + // Grass is tinted, so it carries its biome; dirt doesn't. + assert_eq!(runs[1].key.biome, 0); + } + + #[test] + fn a_run_under_no_sky_reaches_the_bottom_of_the_world() { + // Sky light 0 below local y 32 (light sections 1 and 2), open sky + // above it. + let dark = || Some(crate::world::light::DataLayer::new()); + let light = ChunkLightData { + sky_sections: vec![None, dark(), dark(), None, None, None], + block_sections: vec![None; 6], + min_y: -64, + has_sky: true, + sky_top_section: Some(3), + }; + let chunk = column_of(&[(40, dirt()), (30, stone()), (29, stone()), (10, dirt())]); + let runs = build_column(&chunk, Some(&light), 0, 0, -64, shows, fills); + assert_eq!(runs, [run(dirt(), 40, 40, 15), run(stone(), 30, 0, 0)]); + } + + #[test] + fn merging_takes_the_majority_block_and_never_air() { + let hill = [run(stone(), 10, 0, 15)]; + let flat = [run(dirt(), 4, 0, 15)]; + let empty: [Run; 0] = []; + let merged = merge_columns(&[&hill, &hill, &flat, &empty]); + // Above 4 only the two hills have a block: stone, though two + // columns are air there. + assert_eq!(merged, [run(stone(), 10, 0, 15)]); + + let merged = merge_columns(&[&flat, &flat, &hill, &empty]); + assert_eq!(merged, [run(stone(), 10, 5, 15), run(dirt(), 4, 0, 15)]); + } + + #[test] + fn merged_light_averages_only_the_winners() { + let lit = [run(stone(), 3, 0, 15)]; + let dark = [run(stone(), 3, 0, 5)]; + let water = [Run { + sky: 0, + ..run(dirt(), 3, 0, 0) + }]; + let merged = merge_columns(&[&lit, &dark, &water]); + assert_eq!(merged, [run(stone(), 3, 0, 10)]); + } +} diff --git a/pomme-client/src/renderer/distant/gpu.rs b/pomme-client/src/renderer/distant/gpu.rs new file mode 100644 index 000000000..df9c96f8a --- /dev/null +++ b/pomme-client/src/renderer/distant/gpu.rs @@ -0,0 +1,296 @@ +//! Distant terrain on the GPU: section meshes in one vertex arena, the +//! loaded-column mask that keeps them out of real chunks, and the draws. + +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; + +use glam::DVec3; + +use super::mesh::DistantVertex; +use super::store::{LEVELS, SectionKey}; +use super::worker::DistantUpdate; +use crate::position::ChunkPos; +use crate::renderer::camera::Camera; +use crate::renderer::chunk::tlsf::Tlsf; +use crate::renderer::{MAX_FRAMES_IN_FLIGHT, rhi}; +use crate::world::chunk::{RING_SIDE, ring_slot}; + +/// Vertices the arena holds: 48 MB at 12 bytes each. +const ARENA_VERTICES: u32 = 4 << 20; +/// Quads one draw covers, which the shared index buffer has room for; a +/// larger section draws in several. +const QUADS_PER_DRAW: u32 = 1 << 16; +const VERTEX_SIZE: u64 = size_of::() as u64; +/// `distant.frag` hard-codes the ring's side. +const _: () = assert!(RING_SIDE == 257); +const MASK_WORDS: usize = ((RING_SIDE * RING_SIDE) as usize).div_ceil(32); + +/// A section's mesh in the arena, and the height it spans. +struct Uploaded { + first_vertex: u32, + vertices: u32, + min_y: u16, + max_y: u16, +} + +pub(crate) struct DistantBuffers { + vertex_buffer: rhi::backend::Buffer, + vertices: Tlsf, + index_buffer: rhi::backend::Buffer, + sections: HashMap, + /// The dimension the sections are of, and its `min_y`. + dimension: Option>, + min_y: i32, + /// Bit per [`ring_slot`]: a real chunk is loaded there. + mask: rhi::backend::Buffer, + mask_words: Vec, + frame_seq: u64, + /// Freed vertex ranges, by the frame from which no in-flight frame can + /// still draw them. + pending_free: VecDeque<(u64, u32)>, +} + +impl DistantBuffers { + pub fn new(device: &rhi::backend::Device) -> Self { + let quad_indices: Vec = (0..QUADS_PER_DRAW) + .flat_map(|quad| [0, 1, 2, 2, 3, 0].map(|i| quad * 4 + i)) + .collect(); + Self { + vertex_buffer: rhi::backend::Buffer::new( + device, + "distant_vertices", + u64::from(ARENA_VERTICES) * VERTEX_SIZE, + rhi::BufferUsages::VERTEX, + rhi::MemoryLocation::Host, + ), + vertices: Tlsf::new(ARENA_VERTICES), + index_buffer: rhi::backend::Buffer::with_data( + device, + "distant_quad_indices", + bytemuck::cast_slice(&quad_indices), + rhi::BufferUsages::INDEX, + ), + sections: HashMap::new(), + dimension: None, + min_y: 0, + mask: rhi::backend::Buffer::new( + device, + "distant_mask", + (MASK_WORDS * size_of::()) as u64, + rhi::BufferUsages::STORAGE, + rhi::MemoryLocation::Host, + ), + mask_words: vec![0; MASK_WORDS], + frame_seq: 0, + pending_free: VecDeque::new(), + } + } + + pub fn mask(&self) -> &rhi::backend::Buffer { + &self.mask + } + + pub fn mask_bytes() -> u64 { + (MASK_WORDS * size_of::()) as u64 + } + + /// Reclaims the ranges no in-flight frame reads any more. Call once a + /// frame, after its fence wait. + pub fn begin_frame(&mut self) { + self.frame_seq += 1; + while let Some(&(at, first_vertex)) = self.pending_free.front() + && at <= self.frame_seq + { + self.pending_free.pop_front(); + self.vertices.free(first_vertex); + } + } + + fn retire(&mut self, key: SectionKey) { + if let Some(old) = self.sections.remove(&key) { + let at = self.frame_seq + MAX_FRAMES_IN_FLIGHT as u64; + self.pending_free.push_back((at, old.first_vertex)); + } + } + + /// Drops every section, as on a dimension change. The mask stays: it + /// tracks the chunk store, which [`Self::clear_mask`] follows. + pub fn clear(&mut self) { + let keys: Vec = self.sections.keys().copied().collect(); + for key in keys { + self.retire(key); + } + self.dimension = None; + } + + pub fn clear_mask(&mut self) { + self.mask_words.fill(0); + self.mask.zero(); + } + + pub fn set_column_loaded(&mut self, pos: ChunkPos, loaded: bool) { + let slot = ring_slot(pos); + let (word, bit) = (slot / 32, 1u32 << (slot % 32)); + let value = &mut self.mask_words[word]; + if loaded { + *value |= bit; + } else { + *value &= !bit; + } + self.mask + .write_at(word * size_of::(), &value.to_ne_bytes()); + } + + pub fn upload(&mut self, update: DistantUpdate) { + if self.dimension.as_ref() != Some(&update.dimension) { + self.clear(); + self.dimension = Some(Arc::clone(&update.dimension)); + } + self.min_y = update.min_y; + for key in update.removed { + self.retire(key); + } + for mesh in update.meshes { + self.retire(mesh.key); + let count = mesh.vertices.len() as u32; + if count == 0 { + continue; + } + let Some(first_vertex) = self.vertices.alloc(count) else { + tracing::warn!("No room for distant terrain section {:?}", mesh.key); + continue; + }; + self.vertex_buffer.write_at( + (u64::from(first_vertex) * VERTEX_SIZE) as usize, + bytemuck::cast_slice(&mesh.vertices), + ); + self.sections.insert( + mesh.key, + Uploaded { + first_vertex, + vertices: count, + min_y: mesh.min_y, + max_y: mesh.max_y, + }, + ); + } + } + + /// Whether the finest section holding block column `x`/`z` is uploaded. + #[cfg(feature = "test-harness")] + pub fn has_column(&self, x: i32, z: i32) -> bool { + let width = super::store::SECTION_COLUMNS; + self.sections.contains_key(&SectionKey { + level: 0, + x: x.div_euclid(width), + z: z.div_euclid(width), + }) + } + + /// The sections to draw from `eye` out to `radius` blocks: each at the + /// coarsest level its distance allows, finer ones where they're + /// uploaded. + fn visible(&self, eye: DVec3, radius: f64) -> Vec { + let mut out = Vec::new(); + let mut stack: Vec = self + .sections + .keys() + .copied() + .filter(|key| key.level == LEVELS - 1) + .collect(); + while let Some(key) = stack.pop() { + if key.distance(eye.x, eye.z) > radius { + continue; + } + let uploaded: Vec = key + .children() + .filter(|_| key.splits(eye.x, eye.z)) + .into_iter() + .flatten() + .filter(|child| self.sections.contains_key(child)) + .collect(); + if uploaded.is_empty() { + out.push(key); + } else { + stack.extend(uploaded); + } + } + out + } + + /// Draws the sections in view out to `radius` blocks. The caller has + /// bound the distant pipeline. + pub fn draw( + &self, + pass: &rhi::backend::RenderPassEncoder, + camera: &Camera, + eye: DVec3, + radius: f64, + ) { + if self.sections.is_empty() { + return; + } + // Past the radius, so the edge fogs out before it clips. + let view_proj = camera.distant_view_projection((radius * 1.5) as f32); + let anchor = camera.anchor(); + let planes = Camera::planes_from_view_projection(view_proj); + pass.set_push_constants( + rhi::ShaderStages::VERTEX, + 0, + bytemuck::cast_slice(&view_proj.to_cols_array()), + ); + pass.set_vertex_buffer(0, self.vertex_buffer.handle()); + pass.set_index_buffer(self.index_buffer.handle()); + for key in self.visible(eye, radius) { + let section = &self.sections[&key]; + let (ox, oz) = key.origin(); + let origin = DVec3::new(f64::from(ox), f64::from(self.min_y), f64::from(oz)); + let width = f64::from(key.width()); + let min = origin + DVec3::new(0.0, f64::from(section.min_y), 0.0) - eye; + let max = origin + DVec3::new(width, f64::from(section.max_y), width) - eye; + if !in_frustum(&planes, min.as_vec3(), max.as_vec3()) { + continue; + } + let rel = (origin - anchor).as_vec3(); + pass.set_push_constants( + rhi::ShaderStages::VERTEX, + 64, + bytemuck::cast_slice(&[rel.x, rel.y, rel.z, 0.0]), + ); + let quads = section.vertices / 4; + for first_quad in (0..quads).step_by(QUADS_PER_DRAW as usize) { + let count = (quads - first_quad).min(QUADS_PER_DRAW); + pass.draw_indexed( + count * 6, + 1, + 0, + (section.first_vertex + first_quad * 4) as i32, + 0, + ); + } + } + } + + pub fn destroy(&mut self, device: &rhi::backend::Device) { + for buffer in [ + &mut self.vertex_buffer, + &mut self.index_buffer, + &mut self.mask, + ] { + std::mem::take(buffer).destroy(device); + } + } +} + +/// Whether the box between `min` and `max` (eye-relative) is at least +/// partly inside `planes`. +fn in_frustum(planes: &[[f32; 4]; 6], min: glam::Vec3, max: glam::Vec3) -> bool { + planes.iter().all(|p| { + let corner = |n: f32, lo: f32, hi: f32| if n >= 0.0 { hi } else { lo }; + p[0] * corner(p[0], min.x, max.x) + + p[1] * corner(p[1], min.y, max.y) + + p[2] * corner(p[2], min.z, max.z) + + p[3] + >= 0.0 + }) +} diff --git a/pomme-client/src/renderer/distant/mesh.rs b/pomme-client/src/renderer/distant/mesh.rs new file mode 100644 index 000000000..4925570e8 --- /dev/null +++ b/pomme-client/src/renderer/distant/mesh.rs @@ -0,0 +1,253 @@ +//! Distant terrain sections as geometry: each run a box, faces another run +//! covers left out. + +use super::column::{Run, RunKey}; +use super::store::{SECTION_COLUMNS, Section, SectionKey}; + +/// One corner of a quad; four in a row make a quad, drawn with a shared +/// `0, 1, 2, 2, 3, 0` index pattern. +#[repr(C)] +#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] +pub(crate) struct DistantVertex { + /// Section-local block `x` in the low 16 bits, `z` in the high. + pub xz: u32, + /// Blocks above the dimension's `min_y`. + pub y: u16, + /// Sky light in the high nibble, block light in the low. + pub light: u8, + /// Which way the face points ([`Face`]), so the shaders can tell + /// whose column it is. + pub face: u8, + pub color: [u8; 4], +} + +/// A face's direction, as `distant.vert` reads it. +#[derive(Clone, Copy)] +#[repr(u8)] +enum Face { + Up, + Down, + North, + South, + West, + East, +} + +impl Face { + /// Vanilla's directional shade (`Direction` brightness under the + /// overworld's cardinal lighting). + fn shade(self) -> f32 { + match self { + Self::Up => 1.0, + Self::Down => 0.5, + Self::North | Self::South => 0.8, + Self::West | Self::East => 0.6, + } + } +} + +/// The sides, with the offset of the column each faces. +const SIDES: [(Face, i32, i32); 4] = [ + (Face::North, 0, -1), + (Face::South, 0, 1), + (Face::West, -1, 0), + (Face::East, 1, 0), +]; + +/// A section's geometry and the height it spans, in blocks above `min_y`. +pub(crate) struct SectionMesh { + pub key: SectionKey, + pub vertices: Vec, + pub min_y: u16, + pub max_y: u16, +} + +/// `colors` gives a run's top and side colour. +pub(crate) fn mesh_section( + key: SectionKey, + section: &Section, + colors: impl Fn(RunKey) -> ([u8; 3], [u8; 3]), +) -> SectionMesh { + let w = key.column_width() as u32; + let mut vertices = Vec::new(); + let (mut min_y, mut max_y) = (u16::MAX, 0); + for z in 0..SECTION_COLUMNS { + for x in 0..SECTION_COLUMNS { + let runs: Vec = section.column(x, z).collect(); + if runs.is_empty() { + continue; + } + let neighbours: [Vec; 4] = + SIDES.map(|(_, dx, dz)| section.column(x + dx, z + dz).collect()); + let (x0, z0) = (x as u32 * w, z as u32 * w); + let (x1, z1) = (x0 + w, z0 + w); + for (i, run) in runs.iter().enumerate() { + let (top_rgb, side_rgb) = colors(run.key); + let light = run.sky << 4 | run.block; + let (bottom, top) = (u32::from(run.bottom), u32::from(run.top) + 1); + min_y = min_y.min(run.bottom); + max_y = max_y.max(run.top + 1); + let mut quad = + |corners, face, rgb| push_quad(&mut vertices, corners, light, face, rgb); + let above_touches = i > 0 && runs[i - 1].bottom == run.top + 1; + if !above_touches { + quad( + [[x0, top, z0], [x1, top, z0], [x1, top, z1], [x0, top, z1]], + Face::Up, + top_rgb, + ); + } + let below_touches = runs + .get(i + 1) + .is_some_and(|below| below.top + 1 == run.bottom); + if run.bottom > 0 && !below_touches { + quad( + [ + [x0, bottom, z0], + [x0, bottom, z1], + [x1, bottom, z1], + [x1, bottom, z0], + ], + Face::Down, + top_rgb, + ); + } + for (&(face, ..), neighbour) in SIDES.iter().zip(&neighbours) { + for (low, high) in uncovered(bottom, top, neighbour) { + let corners = match face { + Face::North => { + [[x0, low, z0], [x0, high, z0], [x1, high, z0], [x1, low, z0]] + } + Face::South => { + [[x1, low, z1], [x1, high, z1], [x0, high, z1], [x0, low, z1]] + } + Face::West => { + [[x0, low, z1], [x0, high, z1], [x0, high, z0], [x0, low, z0]] + } + Face::Up | Face::Down | Face::East => { + [[x1, low, z0], [x1, high, z0], [x1, high, z1], [x1, low, z1]] + } + }; + quad(corners, face, side_rgb); + } + } + } + } + } + SectionMesh { + key, + vertices, + min_y: min_y.min(max_y), + max_y, + } +} + +/// The parts of `[low, high)` none of `neighbour`'s runs cover, highest +/// first. A neighbour outside the section has no runs, so the section's +/// border faces always draw: a coarser or missing neighbour can't hide them. +fn uncovered(low: u32, high: u32, neighbour: &[Run]) -> Vec<(u32, u32)> { + let mut open = Vec::new(); + let mut cursor = high; + // Runs go top to bottom, so walk down. + for run in neighbour { + let (run_low, run_high) = (u32::from(run.bottom), u32::from(run.top) + 1); + if run_low >= cursor { + continue; + } + if run_high <= low { + break; + } + if run_high < cursor { + open.push((run_high, cursor)); + } + cursor = run_low.max(low); + if cursor <= low { + return open; + } + } + if cursor > low { + open.push((low, cursor)); + } + open +} + +/// A quad of `face`, its colour shaded for the direction it points. +fn push_quad( + vertices: &mut Vec, + corners: [[u32; 3]; 4], + light: u8, + face: Face, + rgb: [u8; 3], +) { + let [r, g, b] = rgb.map(|c| (f32::from(c) * face.shade()).round() as u8); + vertices.extend(corners.map(|[x, y, z]| DistantVertex { + xz: x | z << 16, + y: y as u16, + light, + face: face as u8, + color: [r, g, b, 255], + })); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::position::ChunkPos; + use crate::renderer::distant::store::Store; + + fn run(top: u16, bottom: u16) -> Run { + Run { + key: RunKey { + state: azalea_block::BlockState::AIR, + biome: 0, + }, + top, + bottom, + sky: 15, + block: 0, + } + } + + #[test] + fn a_neighbour_hides_the_side_it_covers() { + // A 0..10 box beside a 0..4 one shows only 4..10. + assert_eq!(uncovered(0, 10, &[run(3, 0)]), [(4, 10)]); + // Beside a floating 4..6 run: above and below it. + assert_eq!(uncovered(0, 10, &[run(5, 4)]), [(6, 10), (0, 4)]); + // Beside a taller one: nothing. + assert!(uncovered(2, 5, &[run(9, 0)]).is_empty()); + // Beside nothing, or a run wholly above or below: the whole side. + assert_eq!(uncovered(2, 5, &[]), [(2, 5)]); + assert_eq!(uncovered(2, 5, &[run(9, 7), run(0, 0)]), [(2, 5)]); + } + + /// Quads in the level-0 mesh of chunk 0 with `columns` (by `z * 16 + x`). + fn quads(columns: impl Fn(usize) -> Vec) -> usize { + let mut store = Store::default(); + store.capture(ChunkPos::new(0, 0), (0..256).map(columns).collect()); + let key = SectionKey { + level: 0, + x: 0, + z: 0, + }; + let mesh = mesh_section(key, store.get(key).unwrap(), |_| ([0; 3], [0; 3])); + mesh.vertices.len() / 4 + } + + #[test] + fn a_lone_column_is_a_box_without_its_world_bottom() { + let lone = |i| if i == 0 { vec![run(5, 0)] } else { Vec::new() }; + assert_eq!(quads(lone), 5); + let floating = |i| if i == 0 { vec![run(5, 3)] } else { Vec::new() }; + assert_eq!(quads(floating), 6); + } + + #[test] + fn equal_neighbours_hide_their_shared_sides() { + let pair = |i| if i < 2 { vec![run(5, 0)] } else { Vec::new() }; + assert_eq!(quads(pair), 2 + 2 * 3); + // A flat chunk: one top per column, and only the chunk's outer + // sides, which stay because they're in the middle of nothing. + assert_eq!(quads(|_| vec![run(5, 0)]), 256 + 4 * 16); + } +} diff --git a/pomme-client/src/renderer/distant/mod.rs b/pomme-client/src/renderer/distant/mod.rs new file mode 100644 index 000000000..d5b202e46 --- /dev/null +++ b/pomme-client/src/renderer/distant/mod.rs @@ -0,0 +1,12 @@ +//! Distant terrain: the chunks the server has forgotten, kept as columns and +//! drawn coarsely past the loaded ones, like Distant Horizons' client-only +//! mode. A Pomme-only system; the game has nothing like it. +// TODO: disk cache, generation beyond what's been seen, textures, shader +// packs, and dimensions without sky light (a cave roof would cover them). + +pub(crate) mod colors; +pub(crate) mod column; +pub(crate) mod gpu; +pub(crate) mod mesh; +pub(crate) mod store; +pub(crate) mod worker; diff --git a/pomme-client/src/renderer/distant/store.rs b/pomme-client/src/renderer/distant/store.rs new file mode 100644 index 000000000..9c84e74a6 --- /dev/null +++ b/pomme-client/src/renderer/distant/store.rs @@ -0,0 +1,298 @@ +//! What the client keeps of the terrain it has seen: per level of detail, +//! sections of 64x64 columns. + +use std::collections::HashMap; + +use super::column::{Run, RunKey, merge_columns}; +use crate::position::ChunkPos; + +/// Columns along each side of a section. +pub(crate) const SECTION_COLUMNS: i32 = 64; +/// Levels of detail: at level `l` a column is `1 << l` blocks wide, so a +/// chunk is `16 >> l` columns across and level 4 is one column per chunk. +pub(crate) const LEVELS: u8 = 5; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct SectionKey { + pub level: u8, + pub x: i32, + pub z: i32, +} + +impl SectionKey { + /// The section at `level` holding level column `x`/`z`, and that + /// column's index in it. + fn holding(level: u8, x: i32, z: i32) -> (Self, usize) { + let key = Self { + level, + x: x.div_euclid(SECTION_COLUMNS), + z: z.div_euclid(SECTION_COLUMNS), + }; + let index = z.rem_euclid(SECTION_COLUMNS) * SECTION_COLUMNS + x.rem_euclid(SECTION_COLUMNS); + (key, index as usize) + } + + /// Width of one column, in blocks. + pub fn column_width(self) -> i32 { + 1 << self.level + } + + /// Width of the section, in blocks. + pub fn width(self) -> i32 { + SECTION_COLUMNS << self.level + } + + /// The block `x`/`z` of the section's corner. + pub fn origin(self) -> (i32, i32) { + (self.x * self.width(), self.z * self.width()) + } + + /// The four sections one level finer covering this one. + pub fn children(self) -> Option<[Self; 4]> { + let level = self.level.checked_sub(1)?; + let child = |dx, dz| Self { + level, + x: self.x * 2 + dx, + z: self.z * 2 + dz, + }; + Some([child(0, 0), child(1, 0), child(0, 1), child(1, 1)]) + } + + fn parent(self) -> Option { + (self.level + 1 < LEVELS).then(|| Self { + level: self.level + 1, + x: self.x.div_euclid(2), + z: self.z.div_euclid(2), + }) + } + + /// Whether, seen from `x`/`z`, this section is too close to draw at its + /// level and its four children draw instead. + pub fn splits(self, x: f64, z: f64) -> bool { + self.level > 0 && self.distance(x, z) < 1.5 * f64::from(self.width()) + } + + /// Whether drawing from `x`/`z` out to `radius` blocks can reach this + /// section: it's in range and its parent splits (the coarsest level + /// always draws). + pub fn wanted(self, x: f64, z: f64, radius: f64) -> bool { + self.distance(x, z) <= radius && self.parent().is_none_or(|parent| parent.splits(x, z)) + } + + /// The horizontal distance from block position `x`/`z` to the + /// section's nearest edge; 0 inside it. + pub fn distance(self, x: f64, z: f64) -> f64 { + let (ox, oz) = self.origin(); + let width = f64::from(self.width()); + let dx = (f64::from(ox) - x).max(x - f64::from(ox) - width).max(0.0); + let dz = (f64::from(oz) - z).max(z - f64::from(oz) - width).max(0.0); + dx.hypot(dz) + } +} + +/// A run packed in a `u64`: palette index (24 bits), top, bottom (12 each), +/// sky and block light (4 each). +fn pack(run: &Run, palette_index: u32) -> u64 { + u64::from(palette_index) + | u64::from(run.top) << 24 + | u64::from(run.bottom) << 36 + | u64::from(run.sky) << 48 + | u64::from(run.block) << 52 +} + +fn unpack(packed: u64, palette: &[RunKey]) -> Run { + Run { + key: palette[(packed & 0xFF_FFFF) as usize], + top: (packed >> 24 & 0xFFF) as u16, + bottom: (packed >> 36 & 0xFFF) as u16, + sky: (packed >> 48 & 0xF) as u8, + block: (packed >> 52 & 0xF) as u8, + } +} + +/// 64x64 columns at one level, their runs packed against a palette of what +/// they're made of. +pub(crate) struct Section { + palette: Vec, + lookup: HashMap, + /// By `z * 64 + x`. + columns: Vec>, +} + +impl Section { + fn new() -> Self { + Self { + palette: Vec::new(), + lookup: HashMap::new(), + columns: vec![Box::default(); (SECTION_COLUMNS * SECTION_COLUMNS) as usize], + } + } + + fn set_column(&mut self, index: usize, runs: &[Run]) { + self.columns[index] = runs + .iter() + .map(|run| { + let palette_index = *self.lookup.entry(run.key).or_insert_with(|| { + self.palette.push(run.key); + (self.palette.len() - 1) as u32 + }); + pack(run, palette_index) + }) + .collect(); + } + + /// Column `x`/`z`'s runs, top to bottom; none outside the section. + pub fn column(&self, x: i32, z: i32) -> impl Iterator + '_ { + let inside = (0..SECTION_COLUMNS).contains(&x) && (0..SECTION_COLUMNS).contains(&z); + let runs: &[u64] = if inside { + &self.columns[(z * SECTION_COLUMNS + x) as usize] + } else { + &[] + }; + runs.iter().map(|&packed| unpack(packed, &self.palette)) + } +} + +/// One dimension's sections at every level. +#[derive(Default)] +pub(crate) struct Store { + sections: HashMap, +} + +impl Store { + /// Writes a chunk's columns, `level0` by `z * 16 + x`, into every level, + /// merging them down for the coarser ones. Returns the sections it + /// touched. + pub fn capture(&mut self, pos: ChunkPos, level0: Vec>) -> Vec { + let mut touched = Vec::with_capacity(LEVELS as usize); + let mut columns = level0; + for level in 0..LEVELS { + let across = 16 >> level; + if level > 0 { + let finer = across * 2; + columns = (0..across * across) + .map(|i| { + let (x, z) = (i % across * 2, i / across * 2); + let at = + |dx: usize, dz: usize| columns[(z + dz) * finer + x + dx].as_slice(); + merge_columns(&[at(0, 0), at(1, 0), at(0, 1), at(1, 1)]) + }) + .collect(); + } + for (i, runs) in columns.iter().enumerate() { + let x = pos.x * across as i32 + (i % across) as i32; + let z = pos.z * across as i32 + (i / across) as i32; + let (key, index) = SectionKey::holding(level, x, z); + self.sections + .entry(key) + .or_insert_with(Section::new) + .set_column(index, runs); + if !touched.contains(&key) { + touched.push(key); + } + } + } + touched + } + + pub fn get(&self, key: SectionKey) -> Option<&Section> { + self.sections.get(&key) + } + + pub fn keys(&self) -> impl Iterator + '_ { + self.sections.keys().copied() + } + + /// Drops the sections farther than `radius` blocks from `x`/`z`, + /// returning their keys. + pub fn evict_beyond(&mut self, x: f64, z: f64, radius: f64) -> Vec { + let far: Vec = self + .sections + .keys() + .copied() + .filter(|key| key.distance(x, z) > radius) + .collect(); + for key in &far { + self.sections.remove(key); + } + far + } +} + +#[cfg(test)] +mod tests { + use azalea_block::BlockState; + + use super::*; + + fn run(state: u32, top: u16, bottom: u16) -> Run { + Run { + key: RunKey { + state: BlockState::try_from(state).unwrap(), + biome: 7, + }, + top, + bottom, + sky: 15, + block: 3, + } + } + + #[test] + fn runs_survive_packing() { + let mut section = Section::new(); + let runs = [run(5, 4095, 100), run(9, 99, 0)]; + section.set_column(65, &runs); + assert_eq!(section.column(1, 1).collect::>(), runs); + assert_eq!(section.column(64, 0).count(), 0); + } + + #[test] + fn a_capture_writes_every_level_down_to_a_column_per_chunk() { + let mut store = Store::default(); + let pos = ChunkPos::new(-1, 70); + let level0 = (0..256).map(|i| vec![run(1, 10 + i % 2, 0)]).collect(); + let touched = store.capture(pos, level0); + assert_eq!(touched.len(), LEVELS as usize); + + // Level 4: chunk x -1 is column -1, in section -1 at column 63. + let level4 = SectionKey { + level: 4, + x: -1, + z: 1, + }; + assert!(touched.contains(&level4)); + let section = store.get(level4).unwrap(); + // Half the columns reach 11, half 10: the merged column takes the + // taller run's top, as two of four vote stone there. + assert_eq!(section.column(63, 6).collect::>(), [run(1, 11, 0)]); + } + + #[test] + fn nearer_sections_split_into_finer_levels() { + let key = |level, x| SectionKey { level, x, z: 0 }; + // A level-4 section (1024 wide) splits within 1536 blocks. + assert!(key(4, 0).splits(500.0, 8.0)); + assert!(!key(4, 3).splits(500.0, 8.0)); + // Level 0 never splits. + assert!(!key(0, 0).splits(8.0, 8.0)); + // A level-3 child of a far level-4 section isn't wanted; one under + // the player is. + assert!(!key(3, 6).wanted(8.0, 8.0, 4096.0)); + assert!(key(3, 0).wanted(8.0, 8.0, 4096.0)); + assert!(!key(4, 9).wanted(8.0, 8.0, 4096.0), "past the radius"); + } + + #[test] + fn eviction_drops_sections_beyond_the_radius() { + let mut store = Store::default(); + store.capture(ChunkPos::new(0, 0), vec![Vec::new(); 256]); + store.capture(ChunkPos::new(100, 0), vec![Vec::new(); 256]); + // Chunk 100 is 1600 blocks out; even its level-4 section starts at + // 1024. + let gone = store.evict_beyond(8.0, 8.0, 500.0); + assert_eq!(gone.len(), LEVELS as usize); + assert!(gone.iter().all(|key| key.x > 0)); + assert_eq!(store.keys().count(), LEVELS as usize); + } +} diff --git a/pomme-client/src/renderer/distant/worker.rs b/pomme-client/src/renderer/distant/worker.rs new file mode 100644 index 000000000..23de4e155 --- /dev/null +++ b/pomme-client/src/renderer/distant/worker.rs @@ -0,0 +1,286 @@ +//! The distant terrain thread: turns chunks the server forgets into columns, +//! keeps them per dimension, and meshes the sections around the player. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use crossbeam_channel::{Receiver, Sender}; +use parking_lot::RwLock; + +use super::colors::DistantColors; +use super::column::build_column; +use super::mesh::{SectionMesh, mesh_section}; +use super::store::{SectionKey, Store}; +use crate::position::ChunkPos; +use crate::world::chunk::{Chunk, ChunkLightData, ChunkStore}; + +/// Sections meshed between checks for new jobs. +const MESHES_PER_BATCH: usize = 16; +/// How far past the view radius sections are kept, in blocks: one level-4 +/// section, so walking back and forth doesn't drop and rebuild them. +const KEEP_MARGIN: f64 = 1024.0; +/// How far the player moves, in blocks, before the thread hears of it. +const VIEW_STEP: f64 = 32.0; + +enum Job { + Capture { + dimension: Arc, + pos: ChunkPos, + chunk: Arc>, + light: Option>, + min_y: i32, + }, + Colors(Arc), + View(View), +} + +#[derive(Clone)] +struct View { + dimension: Arc, + x: f64, + z: f64, + /// In blocks. + radius: f64, +} + +impl View { + fn wants(&self, key: SectionKey) -> bool { + key.wanted(self.x, self.z, self.radius) + } +} + +/// New and dropped section meshes for one dimension. +pub(crate) struct DistantUpdate { + pub dimension: Arc, + pub min_y: i32, + pub meshes: Vec, + pub removed: Vec, +} + +/// The game's handle on the thread. +pub(crate) struct DistantTerrain { + jobs: Sender, + updates: Receiver, + /// The last view sent, so small moves don't flood the thread. + view: Option, +} + +impl DistantTerrain { + pub fn new(colors: Arc) -> Self { + let (jobs, job_rx) = crossbeam_channel::unbounded(); + let (update_tx, updates) = crossbeam_channel::unbounded(); + std::thread::Builder::new() + .name("distant-terrain".into()) + .spawn(move || Worker::new(colors, update_tx).run(&job_rx)) + .expect("failed to spawn the distant terrain thread"); + Self { + jobs, + updates, + view: None, + } + } + + /// Whether chunks are being kept: a view with a radius was set. + pub fn enabled(&self) -> bool { + self.view.is_some() + } + + /// Keeps `pos`'s column of `chunks` as distant terrain of `dimension`, + /// before the chunk goes. + pub fn capture(&self, dimension: &str, pos: ChunkPos, chunks: &ChunkStore) { + if !self.enabled() { + return; + } + let Some(chunk) = chunks.get_chunk(&pos) else { + return; + }; + let _ = self.jobs.send(Job::Capture { + dimension: dimension.into(), + pos, + chunk, + light: chunks.light_data.get(&(pos.x, pos.z)).cloned(), + min_y: chunks.min_y(), + }); + } + + pub fn set_colors(&self, colors: Arc) { + let _ = self.jobs.send(Job::Colors(colors)); + } + + /// Where the player is and how far to keep and draw, in chunks; 0 stops + /// keeping new chunks. + pub fn set_view(&mut self, dimension: &str, x: f64, z: f64, chunks: u32) { + if chunks == 0 { + self.view = None; + return; + } + let view = View { + dimension: dimension.into(), + x, + z, + radius: f64::from(chunks * 16), + }; + let unchanged = self.view.as_ref().is_some_and(|last| { + last.dimension == view.dimension + && last.radius == view.radius + && (last.x - x).hypot(last.z - z) < VIEW_STEP + }); + if !unchanged { + self.view = Some(view.clone()); + let _ = self.jobs.send(Job::View(view)); + } + } + + pub fn drain(&self) -> impl Iterator + '_ { + self.updates.try_iter() + } +} + +struct Worker { + colors: Arc, + updates: Sender, + /// Per dimension, its sections and `min_y`. + stores: HashMap, (Store, i32)>, + view: Option, + /// The viewed dimension's sections to mesh. + dirty: HashSet, + /// The viewed dimension's sections with a mesh sent. + meshed: HashSet, +} + +impl Worker { + fn new(colors: Arc, updates: Sender) -> Self { + Self { + colors, + updates, + stores: HashMap::new(), + view: None, + dirty: HashSet::new(), + meshed: HashSet::new(), + } + } + + fn run(mut self, jobs: &Receiver) { + loop { + if self.dirty.is_empty() { + match jobs.recv() { + Ok(job) => self.handle(job), + Err(_) => return, + } + } + while let Ok(job) = jobs.try_recv() { + self.handle(job); + } + self.mesh_nearest(); + } + } + + fn handle(&mut self, job: Job) { + match job { + Job::Capture { + dimension, + pos, + chunk, + light, + min_y, + } => { + let columns = { + let chunk = chunk.read(); + (0..256) + .map(|i| { + build_column( + &chunk, + light.as_deref(), + i % 16, + i / 16, + min_y, + |state| self.colors.shows(state), + |state| self.colors.fills(state), + ) + }) + .collect() + }; + let (store, store_min_y) = self.stores.entry(Arc::clone(&dimension)).or_default(); + *store_min_y = min_y; + let touched = store.capture(pos, columns); + if let Some(view) = &self.view + && view.dimension == dimension + { + self.dirty + .extend(touched.into_iter().filter(|key| view.wants(*key))); + } + } + Job::Colors(colors) => { + self.colors = colors; + self.dirty.extend(self.meshed.iter().copied()); + } + Job::View(view) => { + if self + .view + .as_ref() + .is_some_and(|last| last.dimension != view.dimension) + { + // The renderer drops the old dimension's meshes itself. + self.meshed.clear(); + self.dirty.clear(); + } + let (store, min_y) = self.stores.entry(Arc::clone(&view.dimension)).or_default(); + store.evict_beyond(view.x, view.z, view.radius + KEEP_MARGIN); + let wanted: HashSet = + store.keys().filter(|key| view.wants(*key)).collect(); + let removed: Vec = self.meshed.difference(&wanted).copied().collect(); + for key in &removed { + self.meshed.remove(key); + } + self.dirty.retain(|key| wanted.contains(key)); + self.dirty.extend(wanted.difference(&self.meshed).copied()); + if !removed.is_empty() { + let _ = self.updates.send(DistantUpdate { + dimension: Arc::clone(&view.dimension), + min_y: *min_y, + meshes: Vec::new(), + removed, + }); + } + self.view = Some(view); + } + } + } + + /// Meshes the dirty sections nearest the player, a batch at a time. + fn mesh_nearest(&mut self) { + if self.dirty.is_empty() { + return; + } + let Some(view) = &self.view else { + self.dirty.clear(); + return; + }; + let Some((store, min_y)) = self.stores.get(&view.dimension) else { + self.dirty.clear(); + return; + }; + let mut nearest: Vec = self.dirty.iter().copied().collect(); + nearest.sort_by(|a, b| { + a.distance(view.x, view.z) + .total_cmp(&b.distance(view.x, view.z)) + .then(b.level.cmp(&a.level)) + }); + nearest.truncate(MESHES_PER_BATCH); + let meshes = nearest + .iter() + .filter_map(|&key| { + self.dirty.remove(&key); + self.meshed.insert(key); + let section = store.get(key)?; + Some(mesh_section(key, section, |run| self.colors.colors(run))) + }) + .collect(); + let _ = self.updates.send(DistantUpdate { + dimension: Arc::clone(&view.dimension), + min_y: *min_y, + meshes, + removed: Vec::new(), + }); + } +} diff --git a/pomme-client/src/renderer/mod.rs b/pomme-client/src/renderer/mod.rs index 504434e89..a54e61f7b 100644 --- a/pomme-client/src/renderer/mod.rs +++ b/pomme-client/src/renderer/mod.rs @@ -2,6 +2,7 @@ pub mod block_entity_model; pub mod boot; pub mod camera; pub mod chunk; +pub(crate) mod distant; pub mod entity_model; pub(crate) mod fog; pub(crate) mod packing; @@ -12,6 +13,11 @@ pub(crate) mod shader; pub(crate) mod util; pub(crate) const MAX_FRAMES_IN_FLIGHT: usize = 3; + +/// The overworld's ambient haze end (`fog_end_distance`), which distant +/// terrain scales up to its own distance. +const AMBIENT_HAZE_END: f32 = 1024.0; + /// iOS keeps a backgrounded app's view and Metal layer, so its surface /// outlives a suspend; Android takes the native window away. const KEEPS_SURFACE_WHEN_SUSPENDED: bool = cfg!(target_os = "ios"); @@ -209,6 +215,8 @@ enum RenderMode<'a> { weather: &'a [WeatherColumn], cloud_mode: CloudMode, render_distance: u32, + /// How far distant terrain draws, in chunks; 0 for none. + distant_terrain: u32, player_preview: Option, book_preview: Option, eyes_in_water: bool, @@ -290,6 +298,8 @@ pub struct Renderer { /// What the selected pack reads about the player's surroundings. pack_world: pipelines::shaderpack::PackWorld, chunk_buffers: ChunkBufferStore, + distant_buffers: distant::gpu::DistantBuffers, + distant_pipeline: pipelines::distant::DistantPipeline, frames: rhi::backend::Frames, screenshot: screenshot::ScreenshotCapture, swapchain_dirty: bool, @@ -486,6 +496,13 @@ impl Renderer { ); let chunk_buffers = ChunkBufferStore::new(&ctx.device, ctx.indirect_draw); + let distant_buffers = distant::gpu::DistantBuffers::new(&ctx.device); + let distant_pipeline = pipelines::distant::DistantPipeline::new( + &ctx.device, + swapchain_state.targets(), + &lightmap, + &distant_buffers, + ); let mut item_entity_pipeline = pipelines::item_entity::ItemEntityPipeline::new( &ctx.device, @@ -560,6 +577,8 @@ impl Renderer { gui_item_atlas, font_options, chunk_buffers, + distant_buffers, + distant_pipeline, frames, screenshot: screenshot::ScreenshotCapture::new(game_dir.to_path_buf()), swapchain_dirty: false, @@ -995,6 +1014,7 @@ impl Renderer { self.chunk_pipeline.destroy(&self.ctx.device); self.chunk_pipeline = ChunkPipeline::new(&self.ctx.device, targets, &self.atlas, &self.lightmap); + self.distant_pipeline.rebuild(&self.ctx.device, targets); self.hand_pipeline .recreate_pipeline(&self.ctx.device, targets); @@ -1360,6 +1380,60 @@ impl Renderer { self.chunk_buffers.clear(); } + /// New and dropped distant terrain sections. + pub(crate) fn upload_distant( + &mut self, + updates: impl IntoIterator, + ) { + for update in updates { + self.distant_buffers.upload(update); + } + } + + /// Whether a real chunk is loaded at `pos`, where distant terrain gives + /// way to it. + pub fn set_distant_column_loaded(&mut self, pos: ChunkPos, loaded: bool) { + self.distant_buffers.set_column_loaded(pos, loaded); + } + + /// Drops all distant terrain and forgets every loaded chunk, for a new + /// level. + pub fn reset_distant(&mut self) { + self.wait_for_all_frames(); + self.distant_buffers.clear(); + self.distant_buffers.clear_mask(); + } + + /// Whether distant terrain is uploaded at block column `x`/`z`. + #[cfg(feature = "test-harness")] + pub fn has_distant_column(&self, x: i32, z: i32) -> bool { + self.distant_buffers.has_column(x, z) + } + + /// Whether distant terrain out to `chunks` draws this frame: on, and + /// the vanilla terrain path drawing (not a shader pack's, nor the + /// top-down benchmark view). + fn distant_draws(&self, chunks: u32) -> bool { + chunks > 0 + && self.shader_pack.is_none() + && self.chunk_buffers.format() == TerrainFormat::Vanilla + && self.camera.top_down().is_none() + } + + /// The render distance the fog bands use, in chunks, and the scale of + /// the ambient haze: pushed out to distant terrain's distance while it + /// draws, so real chunks run into it without a wall of fog. + fn distant_fog(&self, render_distance: u32, distant_terrain: u32) -> (u32, f32) { + if !self.distant_draws(distant_terrain) { + return (render_distance, 1.0); + } + let blocks = (distant_terrain * 16) as f32; + ( + render_distance.max(distant_terrain), + (blocks / AMBIENT_HAZE_END).max(1.0), + ) + } + /// The block ids to mesh terrain for: the shader pack's while one can /// draw the world, else none for vanilla. pub fn pack_block_ids(&self) -> Option> { @@ -1415,6 +1489,7 @@ impl Renderer { weather: &[WeatherColumn], cloud_mode: CloudMode, render_distance: u32, + distant_terrain: u32, player_preview: Option, book_preview: Option, eyes_in_water: bool, @@ -1472,6 +1547,7 @@ impl Renderer { weather, cloud_mode, render_distance, + distant_terrain, player_preview, book_preview, eyes_in_water, @@ -1760,6 +1836,7 @@ impl Renderer { // Fence signalled: reclaim chunk slices the GPU is now provably done with, // and read back any screenshot copy recorded for this frame index. self.chunk_buffers.begin_frame(&self.ctx.device); + self.distant_buffers.begin_frame(); self.screenshot.collect_ready(frame, &self.ctx.device); let t_acquire = std::time::Instant::now(); @@ -1777,11 +1854,15 @@ impl Renderer { if let RenderMode::World { ref sky, render_distance, + distant_terrain, eyes_in_water, .. } = mode { - let uniform = CameraUniform::new(&self.camera, sky, render_distance, eyes_in_water); + let (fog_distance, haze_scale) = self.distant_fog(render_distance, distant_terrain); + let uniform = + CameraUniform::new(&self.camera, sky, fog_distance, eyes_in_water, haze_scale); + self.distant_pipeline.update_camera(frame, &uniform); self.chunk_pipeline.update_camera(frame, &uniform); self.block_overlay_pipeline.update_camera(frame, &uniform); self.entity_renderer.update_camera(frame, &uniform); @@ -2006,6 +2087,7 @@ impl Renderer { weather, cloud_mode, render_distance, + distant_terrain, player_preview, book_preview, eyes_in_water, @@ -2019,14 +2101,28 @@ impl Renderer { // Vanilla water fog hides the sky dome and clouds; the framebuffer // is cleared to the water fog color, so skipping them tints the view // when looking up out of geometry. + let (fog_distance, _) = self.distant_fog(*render_distance, *distant_terrain); if !*eyes_in_water { self.sky_pipeline.update_and_draw( &mut pass, frame, &self.camera, sky, - *render_distance, + fog_distance, + ); + } + if self.distant_draws(*distant_terrain) { + // Its own projection, so its depth means nothing to + // the chunks: they start from a cleared buffer, and + // the loaded-column mask keeps it out of theirs. + self.distant_pipeline.bind(&mut pass, frame); + self.distant_buffers.draw( + &pass, + &self.camera, + self.camera_render_position(), + f64::from(*distant_terrain * 16), ); + pass.clear_depth(scissor); } let t_cull = std::time::Instant::now(); @@ -2697,6 +2793,8 @@ impl Drop for Renderer { } self.chunk_buffers.destroy(&self.ctx.device); self.chunk_pipeline.destroy(&self.ctx.device); + self.distant_buffers.destroy(&self.ctx.device); + self.distant_pipeline.destroy(&self.ctx.device); self.lightmap.destroy(&self.ctx.device); self.hand_pipeline.destroy(&self.ctx.device); self.block_overlay_pipeline.destroy(&self.ctx.device); diff --git a/pomme-client/src/renderer/pipelines/distant.rs b/pomme-client/src/renderer/pipelines/distant.rs new file mode 100644 index 000000000..2adde38cd --- /dev/null +++ b/pomme-client/src/renderer/pipelines/distant.rs @@ -0,0 +1,149 @@ +use crate::renderer::camera::CameraUniform; +use crate::renderer::distant::gpu::DistantBuffers; +use crate::renderer::pipelines::lightmap::Lightmap; +use crate::renderer::{rhi, shader, util}; + +/// Set 1: the loaded-column mask `distant.frag` discards with. +const MASK_LAYOUT: &[rhi::BindGroupLayoutEntry] = &[rhi::BindGroupLayoutEntry::storage( + rhi::ShaderStages::FRAGMENT, +)]; + +/// [`crate::renderer::distant::mesh::DistantVertex`]: packed x/z, y, light +/// and face, colour. +const VERTEX_BUFFERS: [rhi::VertexBufferLayout<'static>; 1] = [rhi::VertexBufferLayout { + stride: 12, + step_mode: rhi::VertexStepMode::Vertex, + attributes: &[ + rhi::VertexAttribute { + location: 0, + format: rhi::VertexFormat::Uint32, + offset: 0, + }, + rhi::VertexAttribute { + location: 1, + format: rhi::VertexFormat::Uint16, + offset: 4, + }, + rhi::VertexAttribute { + location: 2, + format: rhi::VertexFormat::Uint8x2, + offset: 6, + }, + rhi::VertexAttribute { + location: 3, + format: rhi::VertexFormat::Unorm8x4, + offset: 8, + }, + ], +}]; + +/// Distant terrain: coloured boxes behind the real chunks, under the +/// camera's fog and the lightmap. +pub struct DistantPipeline { + pipeline: rhi::backend::RenderPipeline, + pipeline_layout: rhi::backend::PipelineLayout, + camera_layout: rhi::backend::BindGroupLayout, + mask_layout: rhi::backend::BindGroupLayout, + camera_sets: Vec, + camera_buffers: Vec, + mask_set: rhi::backend::BindGroup, + /// Set 2, which [`Lightmap`] owns. + lightmap_set: rhi::backend::BindGroup, +} + +impl DistantPipeline { + pub fn new( + device: &rhi::backend::Device, + targets: rhi::backend::PipelineTargets, + lightmap: &Lightmap, + buffers: &DistantBuffers, + ) -> Self { + let camera_layout = rhi::backend::create_bind_group_layout(device, util::VERTEX_UNIFORM); + let mask_layout = rhi::backend::create_bind_group_layout(device, MASK_LAYOUT); + // The view projection (once a frame), then the section origin. + let pipeline_layout = rhi::backend::create_pipeline_layout( + device, + &[camera_layout, mask_layout, lightmap.layout], + Some(rhi::PushConstants { + visibility: rhi::ShaderStages::VERTEX, + size: 80, + }), + ); + let pipeline = create_pipeline(device, targets, pipeline_layout); + let (camera_sets, camera_buffers) = util::create_uniform_sets( + device, + camera_layout, + size_of::() as u64, + "distant_camera_uniform", + ); + let mask_set = device.create_bind_group( + mask_layout, + &[( + 0, + rhi::backend::BindingResource::storage( + buffers.mask(), + DistantBuffers::mask_bytes(), + ), + )], + ); + Self { + pipeline, + pipeline_layout, + camera_layout, + mask_layout, + camera_sets, + camera_buffers, + mask_set, + lightmap_set: lightmap.group, + } + } + + pub fn rebuild( + &mut self, + device: &rhi::backend::Device, + targets: rhi::backend::PipelineTargets, + ) { + self.pipeline.destroy(device); + self.pipeline = create_pipeline(device, targets, self.pipeline_layout); + } + + pub fn update_camera(&mut self, frame: usize, uniform: &CameraUniform) { + self.camera_buffers[frame].write(bytemuck::bytes_of(uniform)); + } + + pub fn bind(&self, pass: &mut rhi::backend::RenderPassEncoder, frame: usize) { + pass.set_pipeline(&self.pipeline); + pass.set_bind_groups( + 0, + &[self.camera_sets[frame], self.mask_set, self.lightmap_set], + ); + } + + pub fn destroy(&mut self, device: &rhi::backend::Device) { + util::destroy_buffers(device, &mut self.camera_buffers); + util::destroy_bind_groups(device, self.camera_sets.drain(..)); + self.mask_set.destroy(device); + self.pipeline.destroy(device); + self.pipeline_layout.destroy(device); + self.camera_layout.destroy(device); + self.mask_layout.destroy(device); + } +} + +/// Faces are unculled: the boxes are drawn from every side as easily. +fn create_pipeline( + device: &rhi::backend::Device, + targets: rhi::backend::PipelineTargets, + layout: rhi::backend::PipelineLayout, +) -> rhi::backend::RenderPipeline { + let desc = rhi::RenderPipelineDesc { + depth: Some(rhi::DepthState::WRITE), + ..rhi::RenderPipelineDesc::new( + "distant terrain", + shader::include_shader!("distant.vert"), + shader::include_shader!("distant.frag"), + &VERTEX_BUFFERS, + ) + }; + rhi::backend::create_render_pipeline(device, targets, layout, &desc) +} diff --git a/pomme-client/src/renderer/pipelines/mod.rs b/pomme-client/src/renderer/pipelines/mod.rs index d99eb04d2..3aa468c2c 100644 --- a/pomme-client/src/renderer/pipelines/mod.rs +++ b/pomme-client/src/renderer/pipelines/mod.rs @@ -4,6 +4,7 @@ pub mod book_preview; pub mod chunk; pub mod chunk_borders; pub mod clouds; +pub mod distant; pub mod entity_outline; pub mod entity_renderer; pub mod entity_shadow; diff --git a/pomme-client/src/renderer/shaders/distant.frag b/pomme-client/src/renderer/shaders/distant.frag new file mode 100644 index 000000000..dfe7c29bf --- /dev/null +++ b/pomme-client/src/renderer/shaders/distant.frag @@ -0,0 +1,31 @@ +#version 450 + +#include "fog.glsl" + +// One bit per chunk column, ring-indexed as `world/chunk.rs` `ring_slot`: +// set where a real chunk is loaded, which draws there instead. +layout(set = 1, binding = 0) readonly buffer LoadedColumns { + uint loaded[]; +}; + +layout(location = 0) in vec3 v_color; +layout(location = 1) in vec3 v_fog_color; +layout(location = 2) in float v_fog; +layout(location = 3) in vec2 v_column_pos; +layout(location = 4) flat in ivec2 v_anchor; + +layout(location = 0) out vec4 out_color; + +// `world/chunk.rs` RING_SIDE. +const int RING_SIDE = 257; + +void main() { + ivec2 block = v_anchor + ivec2(floor(v_column_pos)); + ivec2 chunk = block >> 4; + ivec2 ring = ((chunk % RING_SIDE) + RING_SIDE) % RING_SIDE; + uint slot = uint(ring.y * RING_SIDE + ring.x); + if ((loaded[slot >> 5] & (1u << (slot & 31u))) != 0u) { + discard; + } + out_color = vec4(apply_fog(v_color, v_fog, v_fog_color), 1.0); +} diff --git a/pomme-client/src/renderer/shaders/distant.vert b/pomme-client/src/renderer/shaders/distant.vert new file mode 100644 index 000000000..3ddc6ce92 --- /dev/null +++ b/pomme-client/src/renderer/shaders/distant.vert @@ -0,0 +1,51 @@ +#version 450 + +// Distant terrain (renderer/distant): section-local boxes, coloured and +// shaded on the CPU, lit through the lightmap and fogged like terrain. + +#include "fog.glsl" +#include "camera_ubo.glsl" +#include "lightmap.glsl" + +layout(push_constant) uniform DistantPc { + // The camera's view projection with the distant near and far planes. + mat4 distant_view_proj; + // The section's corner (at the dimension's min_y), relative to the + // camera anchor. + vec4 origin; +}; + +layout(location = 0) in uint in_xz; +layout(location = 1) in uint in_y; +layout(location = 2) in uvec2 in_light_face; +layout(location = 3) in vec4 in_color; + +layout(location = 0) out vec3 v_color; +layout(location = 1) out vec3 v_fog_color; +layout(location = 2) out float v_fog; +// Anchor-relative x/z, nudged half a block into the face's own column, and +// the anchor: what distant.frag finds the column's chunk from. +layout(location = 3) out vec2 v_column_pos; +layout(location = 4) flat out ivec2 v_anchor; + +// mesh.rs `Face`: up, down, north, south, west, east. +const vec2 FACE_NUDGE[6] = vec2[]( + vec2(0.0), vec2(0.0), + vec2(0.0, 0.5), vec2(0.0, -0.5), + vec2(0.5, 0.0), vec2(-0.5, 0.0) +); + +void main() { + vec3 local = vec3(float(in_xz & 0xFFFFu), float(in_y), float(in_xz >> 16)); + vec3 world = origin.xyz + local; + vec3 rel = world - camera_pos.xyz; + gl_Position = distant_view_proj * vec4(rel, 1.0); + + uint light = in_light_face.x; + vec2 lightmap_uv = vec2(float(light & 0xFu), float(light >> 4)) * 16.0; + v_color = in_color.rgb * sample_lightmap(lightmap_uv).rgb; + v_fog_color = fog_color.rgb; + v_fog = total_fog_value(rel, fog_env, camera_pos.w, fog_color.w); + v_column_pos = world.xz + FACE_NUDGE[min(in_light_face.y, 5u)]; + v_anchor = camera_block.xz; +} diff --git a/pomme-client/src/ui/menu/mod.rs b/pomme-client/src/ui/menu/mod.rs index b412cde2a..76a3a5abf 100644 --- a/pomme-client/src/ui/menu/mod.rs +++ b/pomme-client/src/ui/menu/mod.rs @@ -40,6 +40,8 @@ struct Settings { // of 8 move to Off too. #[serde(rename = "chunk_detail_v2", default = "default_chunk_detail")] chunk_detail: u32, + #[serde(default)] + distant_terrain: u32, simulation_distance: u32, #[serde(default = "default_fov")] fov: u32, @@ -149,6 +151,30 @@ const CHUNK_DETAIL_MIN: u32 = 8; pub const CHUNK_DETAIL_OFF: u32 = 49; const CHUNK_DETAIL_SPAN: f32 = (CHUNK_DETAIL_OFF - CHUNK_DETAIL_MIN) as f32; +/// The Distant Terrain slider's steps past Off: 32 to 256 chunks, 16 apart. +const DISTANT_TERRAIN_STEPS: u32 = 15; +const DISTANT_TERRAIN_STEP: u32 = 16; + +/// The Distant Terrain slider step nearest `chunks`: 0 for off, else +/// `1..=DISTANT_TERRAIN_STEPS`. +fn distant_terrain_step(chunks: u32) -> u32 { + if chunks == 0 { + return 0; + } + ((chunks + DISTANT_TERRAIN_STEP / 2) / DISTANT_TERRAIN_STEP) + .saturating_sub(1) + .clamp(1, DISTANT_TERRAIN_STEPS) +} + +/// The chunks Distant Terrain slider step `step` draws out to. +fn distant_terrain_chunks(step: u32) -> u32 { + if step == 0 { + 0 + } else { + DISTANT_TERRAIN_STEP * (step + 1) + } +} + fn default_fov_effect_scale() -> f32 { 1.0 } @@ -220,6 +246,7 @@ impl Default for Settings { gui_scale: 0, render_distance: 12, chunk_detail: CHUNK_DETAIL_OFF, + distant_terrain: 0, simulation_distance: 12, fov: 70, fov_effect_scale: 1.0, @@ -719,6 +746,9 @@ pub struct MainMenu { /// beyond it. Pomme-custom: vanilla has no LOD, this buys its look back /// within a VRAM budget. pub chunk_detail: u32, + /// How far distant terrain draws, in chunks, 0 for off. Pomme-custom: + /// the chunks the server forgets, drawn coarsely past the loaded ones. + pub distant_terrain: u32, pub simulation_distance: u32, /// Server-announced view distance cap; 0 when unknown (slider runs 2..32). pub server_render_distance: u32, @@ -881,6 +911,7 @@ impl MainMenu { gui_scale_setting: settings.gui_scale, render_distance: settings.render_distance, chunk_detail: settings.chunk_detail, + distant_terrain: settings.distant_terrain, simulation_distance: settings.simulation_distance, server_render_distance: 0, fov: settings.fov, @@ -1133,6 +1164,7 @@ impl MainMenu { .saved() .unwrap_or(self.render_distance), chunk_detail: self.chunk_detail, + distant_terrain: self.distant_terrain, simulation_distance: self.simulation_distance, fov: self.fov, fov_effect_scale: self.fov_effect_scale, @@ -1205,6 +1237,13 @@ impl MainMenu { } } + /// Sets Distant Terrain to `chunks`, 0 for off, else snapped to the + /// slider's steps, as a script asks. + #[cfg(feature = "test-harness")] + pub(crate) fn set_distant_terrain(&mut self, chunks: u32) { + self.distant_terrain = distant_terrain_chunks(distant_terrain_step(chunks)); + } + pub fn set_menu_background_blurriness(&mut self, radius: u8) { self.menu_background_blurriness = radius.min(MAX_MENU_BACKGROUND_BLURRINESS); self.settings_dirty = true; @@ -1671,6 +1710,17 @@ mod tests { assert_eq!(settled(&mut menu), both); } + #[test] + fn distant_terrain_snaps_to_its_slider_steps() { + assert_eq!(distant_terrain_step(0), 0); + assert_eq!(distant_terrain_chunks(distant_terrain_step(8)), 32); + assert_eq!(distant_terrain_chunks(distant_terrain_step(100)), 96); + assert_eq!(distant_terrain_chunks(distant_terrain_step(1000)), 256); + for step in 0..=DISTANT_TERRAIN_STEPS { + assert_eq!(distant_terrain_step(distant_terrain_chunks(step)), step); + } + } + #[test] fn saves_from_before_chunk_detail_off_load_as_off() { let mut json = serde_json::to_value(Settings::default()).unwrap(); diff --git a/pomme-client/src/ui/menu/options.rs b/pomme-client/src/ui/menu/options.rs index 42d2e1a51..2cfb1a23d 100644 --- a/pomme-client/src/ui/menu/options.rs +++ b/pomme-client/src/ui/menu/options.rs @@ -50,6 +50,7 @@ const CHAT_SCALE: &str = "Chat Text Size:"; const CHAT_LINE_SPACING: &str = "Line Spacing:"; const CHAT_DELAY: &str = "Chat Delay:"; const MENU_BACKGROUND_BLUR: &str = "Menu Background Blur:"; +const DISTANT_TERRAIN: &str = "Distant Terrain:"; /// Vanilla `genericValueOrOffLabel`. fn menu_background_blur_label(radius: u8) -> String { @@ -228,6 +229,7 @@ impl MainMenu { Some(match prefix { "Render Distance:" => self.render_distance_max() as f32 - 2.0, "Chunk Detail:" => super::CHUNK_DETAIL_SPAN, + DISTANT_TERRAIN => super::DISTANT_TERRAIN_STEPS as f32, "Simulation Distance:" => 27.0, "Max Framerate:" => 25.0, "FOV:" => 80.0, @@ -275,6 +277,11 @@ impl MainMenu { } else { format!("Chunk Detail: {} chunks", self.chunk_detail) }; + let dt = if self.distant_terrain == 0 { + format!("{DISTANT_TERRAIN} Off") + } else { + format!("{DISTANT_TERRAIN} {} chunks", self.distant_terrain) + }; let sd = format!("Simulation Distance: {} chunks", self.simulation_distance); let mf = if self.max_framerate >= super::MAX_FRAMERATE_UNLIMITED { "Max Framerate: Unlimited".to_string() @@ -327,7 +334,7 @@ impl MainMenu { // TODO: static stub, not wired (see mesher::enqueue). OptRow::Pair("Prioritize Chunk Updates: None", &sd), OptRow::Pair("Smooth Lighting: ON", &clouds_label), - OptRow::PairLeft(&cd), + OptRow::Pair(&cd, &dt), OptRow::Pair("Particles: All", "Mipmap Levels: 4"), OptRow::Pair(entity_shadows_label, "Entity Distance: 100%"), OptRow::Pair(&blur_label, "Cloud Range: 128"), @@ -342,11 +349,14 @@ impl MainMenu { let cd_frac = (self.chunk_detail.saturating_sub(super::CHUNK_DETAIL_MIN) as f32 / super::CHUNK_DETAIL_SPAN) .clamp(0.0, 1.0); + let dt_frac = super::distant_terrain_step(self.distant_terrain) as f32 + / super::DISTANT_TERRAIN_STEPS as f32; let sd_frac = (self.simulation_distance as f32 - 5.0) / 27.0; let mf_frac = (self.max_framerate as f32 - 10.0) / 250.0; let sliders: &[(&str, f32)] = &[ ("Render Distance:", rd_frac), ("Chunk Detail:", cd_frac), + (DISTANT_TERRAIN, dt_frac), ("Simulation Distance:", sd_frac), ("Max Framerate:", mf_frac), (BRIGHTNESS, self.brightness), @@ -1571,6 +1581,9 @@ impl MainMenu { "Chunk Detail:" => { self.chunk_detail = super::CHUNK_DETAIL_MIN + (v * span).round() as u32 } + DISTANT_TERRAIN => { + self.distant_terrain = super::distant_terrain_chunks((v * span).round() as u32) + } "Simulation Distance:" => { self.simulation_distance = (5.0 + v * span).round() as u32 } diff --git a/pomme-client/src/world/chunk.rs b/pomme-client/src/world/chunk.rs index 680a2c111..c16914369 100644 --- a/pomme-client/src/world/chunk.rs +++ b/pomme-client/src/world/chunk.rs @@ -475,13 +475,18 @@ pub struct ChunkRing { slots: Box<[T]>, } -const RING_SIDE: i32 = MAX_VIEW_DISTANCE as i32 * 2 + 1; +pub(crate) const RING_SIDE: i32 = MAX_VIEW_DISTANCE as i32 * 2 + 1; /// A column's slot in a `side`×`side` square that wraps around the world. fn ring_index(pos: ChunkPos, side: i32) -> usize { (pos.z.rem_euclid(side) * side + pos.x.rem_euclid(side)) as usize } +/// A column's slot in a [`ChunkRing`]. +pub(crate) fn ring_slot(pos: ChunkPos) -> usize { + ring_index(pos, RING_SIDE) +} + impl ChunkRing { pub fn new(mut init: impl FnMut() -> T) -> Self { Self { diff --git a/pomme-client/src/world/light/mod.rs b/pomme-client/src/world/light/mod.rs index d9a23b6e5..f2e72e806 100644 --- a/pomme-client/src/world/light/mod.rs +++ b/pomme-client/src/world/light/mod.rs @@ -206,6 +206,11 @@ pub(crate) struct LevelLightEngine { } impl LevelLightEngine { + /// Whether the dimension has sky light. + pub fn has_sky(&self) -> bool { + self.sky.is_some() + } + pub fn new(height: u32, min_y: i32, has_sky: bool) -> Self { Self { block: BlockLightEngine::new(), diff --git a/tests/ingame/README.md b/tests/ingame/README.md index de077c222..dbfdf394b 100644 --- a/tests/ingame/README.md +++ b/tests/ingame/README.md @@ -144,9 +144,11 @@ option = { main_hand = "left" } # sets client options, as the # options screen does; also # menu_background_blur, # brightness, render_distance - # (chunks) and shader_pack - # (a name in fixtures/shaderpacks, - # "" for none) + # (chunks), distant_terrain + # (chunks, 0 for off) and + # shader_pack (a name in + # fixtures/shaderpacks, "" for + # none) [[step]] deterministic = true # holds flicker, particles, # animated textures, clouds, view @@ -184,6 +186,7 @@ An `expect` is retried every frame until it holds or times out. It can check: | A block | `block` (state name, e.g. `air`), `at`, optional `block_entity` (type) | | A biome | `biome` (name, e.g. `swamp`), `at` | | Settled terrain | `terrain = "settled"`: every loaded column meshed and uploaded (none waiting for pool space), and no two of the chunk pools' ranges overlapping | +| Distant terrain | `distant_terrain = { at = [x, z] }`: the finest distant terrain section holding that block column is meshed and uploaded (Pomme-only) | | The local player | `player = { ... }` with any of `health`, `food`, `level`, `game_mode`, `effect`, `no_effect`, `max_health`, `raining`, `hand`, `dimension`, `day_time`, `position` (within 0.01), `y_rot` (within 0.01), `spectating` (the kind of entity the camera follows, empty for none) and `post_effect` (the spectator view effect drawn: `creeper`, `spider`, `invert`, or empty) | | The open menu | `menu` (a container's menu type, e.g. `generic_9x3`, or `inventory`, `creative`, `pause`, `none`), optional `title`; on the creative screen also `tab` (its vanilla id, e.g. `building_blocks`), `row` (the first catalog row shown) and `search` | | The HUD text | `hud = { ... }` with any of `title`, `subtitle` and `sidebar_title` (`""` when none is shown), `boss_bars` (every name, top to bottom), `sidebar` (every row as `"name number"`, top to bottom) and `tab_list` (every listed player as `{ name, game_mode? }`, in tab order) | diff --git a/tests/ingame/world/distant_terrain.toml b/tests/ingame/world/distant_terrain.toml new file mode 100644 index 000000000..e1ee31c08 --- /dev/null +++ b/tests/ingame/world/distant_terrain.toml @@ -0,0 +1,82 @@ +# Distant Terrain keeps the chunks the server forgets and draws them past the +# loaded ones: a tower, left 200 blocks behind at Render Distance 2, still +# shows on the horizon, and goes with the option. Pomme-only (the vanilla +# harness has no Distant Terrain), so it fails there by design. + +[[step]] +wait_ms = 2000 + +[[step]] +chat = "/gamemode creative" + +[[step]] +chat = "/gamerule advance_time false" + +[[step]] +chat = "/time set 6000" + +[[step]] +chat = "/fill 0 -60 0 2 -20 2 minecraft:stone" + +[[step]] +expect = { block = "stone", at = [1, -20, 1] } + +[[step]] +option = { render_distance = 2, distant_terrain = 32 } + +# Far enough that the tower's chunks unload, which keeps them. +[[step]] +chat = "/tp @s 200.5 -50 1.5" + +[[step]] +expect = { player = { position = [200.5, -50.0, 1.5] } } +timeout_ms = 20000 + +[[step]] +expect = { distant_terrain = { at = [1, 1] } } +timeout_ms = 20000 + +[[step]] +expect = { terrain = "settled" } +timeout_ms = 30000 + +[[step]] +look_at = [1.5, -40.0, 1.5] + +[[step]] +deterministic = true + +# Chat fades after 10 seconds. +[[step]] +wait_ms = 11000 + +[[step]] +screenshot = "on" + +[[step]] +option = { distant_terrain = 0 } + +[[step]] +wait_ms = 500 + +[[step]] +screenshot = "off" + +[[step]] +chat = "/tp @s 1.5 -50 8.5" + +[[step]] +expect = { player = { position = [1.5, -50.0, 8.5] } } +timeout_ms = 20000 + +[[step]] +chat = "/fill 0 -60 0 2 -20 2 minecraft:air" + +[[step]] +expect = { block = "air", at = [1, -20, 1] } + +[[step]] +option = { render_distance = 12 } + +[[step]] +chat = "/gamerule advance_time true" From c1314c6f70cff1d04d3a51344ffc863883dc28d9 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:38:40 +0000 Subject: [PATCH 06/67] merge distant tops into rectangles, grow arenas instead of dropping sections and bench distant capture and meshing --- pomme-client/src/cpu_bench/distant.rs | 45 ++++++++ pomme-client/src/cpu_bench/mod.rs | 8 +- pomme-client/src/renderer/distant/column.rs | 15 +++ pomme-client/src/renderer/distant/gpu.rs | 102 +++++++++++++----- pomme-client/src/renderer/distant/mesh.rs | 91 +++++++++++----- pomme-client/src/renderer/distant/worker.rs | 19 +--- pomme-client/src/renderer/mod.rs | 2 +- .../src/renderer/shaders/distant.frag | 16 ++- .../src/renderer/shaders/distant.vert | 7 +- 9 files changed, 226 insertions(+), 79 deletions(-) create mode 100644 pomme-client/src/cpu_bench/distant.rs diff --git a/pomme-client/src/cpu_bench/distant.rs b/pomme-client/src/cpu_bench/distant.rs new file mode 100644 index 000000000..80804f369 --- /dev/null +++ b/pomme-client/src/cpu_bench/distant.rs @@ -0,0 +1,45 @@ +//! Distant terrain's worker: a forgotten chunk turned into columns and +//! merged down every level, and a section meshed, per scene. + +use std::hint::black_box; + +use criterion::Criterion; + +use super::fixtures::{Assets, Scene, build_world, columns_within}; +use crate::position::ChunkPos; +use crate::renderer::distant::column::build_chunk; +use crate::renderer::distant::mesh::mesh_section; +use crate::renderer::distant::store::{LEVELS, SectionKey, Store}; + +pub fn bench(c: &mut Criterion, assets: &Assets) { + let colors = assets.mesh_dispatcher().distant_colors(); + + let mut group = c.benchmark_group("distant"); + for scene in Scene::ALL { + let world = build_world(scene, assets.block_state_table); + let capture = |store: &mut Store, pos: ChunkPos| { + let chunk = world + .get_chunk(&pos) + .expect("the scene's columns are loaded"); + let light = world.light_data.get(&(pos.x, pos.z)).map(|light| &**light); + let columns = build_chunk(&chunk.read(), light, world.min_y(), &colors); + store.capture(pos, columns) + }; + group.bench_function(format!("capture/{}", scene.name()), |b| { + b.iter(|| black_box(capture(&mut Store::default(), ChunkPos::new(0, 0)))) + }); + + let mut store = Store::default(); + for pos in columns_within(1) { + capture(&mut store, pos); + } + for level in [0, LEVELS - 1] { + let key = SectionKey { level, x: 0, z: 0 }; + let section = store.get(key).expect("chunk (0, 0)'s sections"); + group.bench_function(format!("mesh/{}/level{level}", scene.name()), |b| { + b.iter(|| black_box(mesh_section(key, section, |run| colors.colors(run)))) + }); + } + } + group.finish(); +} diff --git a/pomme-client/src/cpu_bench/mod.rs b/pomme-client/src/cpu_bench/mod.rs index cd8145da1..a115d5653 100644 --- a/pomme-client/src/cpu_bench/mod.rs +++ b/pomme-client/src/cpu_bench/mod.rs @@ -1,10 +1,11 @@ //! Headless CPU benchmarks (`just bench [filter]`): the chunk mesher and its -//! worker queue, the per-frame fade rebuild, cave culling and chunk packet -//! decoding, with no window or Vulkan device. +//! worker queue, the per-frame fade rebuild, cave culling, chunk packet +//! decoding and distant terrain, with no window or Vulkan device. //! Criterion reports each run's change against the last. mod chunk_decode; mod dispatch; +mod distant; mod fade; mod fixtures; mod mesh; @@ -28,7 +29,7 @@ pub fn run(version: &str, dirs: &DataDirs, filter: Option<&str>) -> i32 { // The mesh and visgraph fixtures load block render assets and the atlas from // disk; skip that when the filter leaves neither group to run. let needs_assets = filter.is_none_or(|f| { - ["mesh", "dispatch", "visgraph"] + ["mesh", "dispatch", "visgraph", "distant"] .iter() .any(|group| group.contains(f) || f.contains(group)) }); @@ -39,6 +40,7 @@ pub fn run(version: &str, dirs: &DataDirs, filter: Option<&str>) -> i32 { mesh::bench(&mut c, &assets); dispatch::bench(&mut c, &assets); occlusion::bench_sections(&mut c, &assets); + distant::bench(&mut c, &assets); } chunk_decode::bench(&mut c, block_state_table); fade::bench(&mut c); diff --git a/pomme-client/src/renderer/distant/column.rs b/pomme-client/src/renderer/distant/column.rs index c80a054eb..ebc777cce 100644 --- a/pomme-client/src/renderer/distant/column.rs +++ b/pomme-client/src/renderer/distant/column.rs @@ -4,6 +4,7 @@ use azalea_block::BlockState; +use super::colors::DistantColors; use crate::world::chunk::{Chunk, ChunkLightData, SectionBlocks}; /// What a run is made of. `biome` is 0 for blocks whose colour doesn't @@ -107,6 +108,20 @@ pub(crate) fn build_column( runs } +/// Every column of `chunk`, by `z * 16 + x`. +pub(crate) fn build_chunk( + chunk: &Chunk, + light: Option<&ChunkLightData>, + min_y: i32, + colors: &DistantColors, +) -> Vec> { + let shows = |state| colors.shows(state); + let fills = |state| colors.fills(state); + (0..256) + .map(|i| build_column(chunk, light, i % 16, i / 16, min_y, shows, fills)) + .collect() +} + /// `columns` side by side as one coarser column: each height slice between /// the inputs' run edges takes the block most of them have there (air never /// wins, so a hollow doesn't open a hole), with the light of those that diff --git a/pomme-client/src/renderer/distant/gpu.rs b/pomme-client/src/renderer/distant/gpu.rs index df9c96f8a..d5ce57c1a 100644 --- a/pomme-client/src/renderer/distant/gpu.rs +++ b/pomme-client/src/renderer/distant/gpu.rs @@ -1,4 +1,4 @@ -//! Distant terrain on the GPU: section meshes in one vertex arena, the +//! Distant terrain on the GPU: section meshes in vertex arenas, the //! loaded-column mask that keeps them out of real chunks, and the draws. use std::collections::{HashMap, VecDeque}; @@ -15,8 +15,10 @@ use crate::renderer::chunk::tlsf::Tlsf; use crate::renderer::{MAX_FRAMES_IN_FLIGHT, rhi}; use crate::world::chunk::{RING_SIDE, ring_slot}; -/// Vertices the arena holds: 48 MB at 12 bytes each. +/// Vertices an arena holds: 48 MB at 12 bytes each. const ARENA_VERTICES: u32 = 4 << 20; +/// Arenas there may be: 384 MB of vertices. +const MAX_ARENAS: usize = 8; /// Quads one draw covers, which the shared index buffer has room for; a /// larger section draws in several. const QUADS_PER_DRAW: u32 = 1 << 16; @@ -25,8 +27,30 @@ const VERTEX_SIZE: u64 = size_of::() as u64; const _: () = assert!(RING_SIDE == 257); const MASK_WORDS: usize = ((RING_SIDE * RING_SIDE) as usize).div_ceil(32); -/// A section's mesh in the arena, and the height it spans. +/// A vertex buffer, sub-allocated. +struct Arena { + buffer: rhi::backend::Buffer, + vertices: Tlsf, +} + +impl Arena { + fn new(device: &rhi::backend::Device) -> Self { + Self { + buffer: rhi::backend::Buffer::new( + device, + "distant_vertices", + u64::from(ARENA_VERTICES) * VERTEX_SIZE, + rhi::BufferUsages::VERTEX, + rhi::MemoryLocation::Host, + ), + vertices: Tlsf::new(ARENA_VERTICES), + } + } +} + +/// A section's mesh in an arena, and the height it spans. struct Uploaded { + arena: usize, first_vertex: u32, vertices: u32, min_y: u16, @@ -34,8 +58,8 @@ struct Uploaded { } pub(crate) struct DistantBuffers { - vertex_buffer: rhi::backend::Buffer, - vertices: Tlsf, + /// Grown one at a time as sections fill them. + arenas: Vec, index_buffer: rhi::backend::Buffer, sections: HashMap, /// The dimension the sections are of, and its `min_y`. @@ -45,9 +69,9 @@ pub(crate) struct DistantBuffers { mask: rhi::backend::Buffer, mask_words: Vec, frame_seq: u64, - /// Freed vertex ranges, by the frame from which no in-flight frame can - /// still draw them. - pending_free: VecDeque<(u64, u32)>, + /// Freed vertex ranges as (arena, first vertex), by the frame from + /// which no in-flight frame can still draw them. + pending_free: VecDeque<(u64, usize, u32)>, } impl DistantBuffers { @@ -56,14 +80,7 @@ impl DistantBuffers { .flat_map(|quad| [0, 1, 2, 2, 3, 0].map(|i| quad * 4 + i)) .collect(); Self { - vertex_buffer: rhi::backend::Buffer::new( - device, - "distant_vertices", - u64::from(ARENA_VERTICES) * VERTEX_SIZE, - rhi::BufferUsages::VERTEX, - rhi::MemoryLocation::Host, - ), - vertices: Tlsf::new(ARENA_VERTICES), + arenas: vec![Arena::new(device)], index_buffer: rhi::backend::Buffer::with_data( device, "distant_quad_indices", @@ -98,18 +115,19 @@ impl DistantBuffers { /// frame, after its fence wait. pub fn begin_frame(&mut self) { self.frame_seq += 1; - while let Some(&(at, first_vertex)) = self.pending_free.front() + while let Some(&(at, arena, first_vertex)) = self.pending_free.front() && at <= self.frame_seq { self.pending_free.pop_front(); - self.vertices.free(first_vertex); + self.arenas[arena].vertices.free(first_vertex); } } fn retire(&mut self, key: SectionKey) { if let Some(old) = self.sections.remove(&key) { let at = self.frame_seq + MAX_FRAMES_IN_FLIGHT as u64; - self.pending_free.push_back((at, old.first_vertex)); + self.pending_free + .push_back((at, old.arena, old.first_vertex)); } } @@ -141,7 +159,27 @@ impl DistantBuffers { .write_at(word * size_of::(), &value.to_ne_bytes()); } - pub fn upload(&mut self, update: DistantUpdate) { + /// Room for `count` vertices: in an arena with it, else in a new one + /// while there may be more. + fn alloc(&mut self, device: &rhi::backend::Device, count: u32) -> Option<(usize, u32)> { + if let Some(found) = self + .arenas + .iter_mut() + .enumerate() + .find_map(|(i, arena)| Some((i, arena.vertices.alloc(count)?))) + { + return Some(found); + } + if self.arenas.len() == MAX_ARENAS { + return None; + } + let mut arena = Arena::new(device); + let first_vertex = arena.vertices.alloc(count)?; + self.arenas.push(arena); + Some((self.arenas.len() - 1, first_vertex)) + } + + pub fn upload(&mut self, device: &rhi::backend::Device, update: DistantUpdate) { if self.dimension.as_ref() != Some(&update.dimension) { self.clear(); self.dimension = Some(Arc::clone(&update.dimension)); @@ -156,17 +194,18 @@ impl DistantBuffers { if count == 0 { continue; } - let Some(first_vertex) = self.vertices.alloc(count) else { + let Some((arena, first_vertex)) = self.alloc(device, count) else { tracing::warn!("No room for distant terrain section {:?}", mesh.key); continue; }; - self.vertex_buffer.write_at( + self.arenas[arena].buffer.write_at( (u64::from(first_vertex) * VERTEX_SIZE) as usize, bytemuck::cast_slice(&mesh.vertices), ); self.sections.insert( mesh.key, Uploaded { + arena, first_vertex, vertices: count, min_y: mesh.min_y, @@ -239,9 +278,11 @@ impl DistantBuffers { 0, bytemuck::cast_slice(&view_proj.to_cols_array()), ); - pass.set_vertex_buffer(0, self.vertex_buffer.handle()); pass.set_index_buffer(self.index_buffer.handle()); - for key in self.visible(eye, radius) { + let mut visible = self.visible(eye, radius); + visible.sort_unstable_by_key(|key| self.sections[key].arena); + let mut bound = None; + for key in visible { let section = &self.sections[&key]; let (ox, oz) = key.origin(); let origin = DVec3::new(f64::from(ox), f64::from(self.min_y), f64::from(oz)); @@ -251,6 +292,10 @@ impl DistantBuffers { if !in_frustum(&planes, min.as_vec3(), max.as_vec3()) { continue; } + if bound != Some(section.arena) { + pass.set_vertex_buffer(0, self.arenas[section.arena].buffer.handle()); + bound = Some(section.arena); + } let rel = (origin - anchor).as_vec3(); pass.set_push_constants( rhi::ShaderStages::VERTEX, @@ -272,11 +317,10 @@ impl DistantBuffers { } pub fn destroy(&mut self, device: &rhi::backend::Device) { - for buffer in [ - &mut self.vertex_buffer, - &mut self.index_buffer, - &mut self.mask, - ] { + for arena in self.arenas.drain(..) { + arena.buffer.destroy(device); + } + for buffer in [&mut self.index_buffer, &mut self.mask] { std::mem::take(buffer).destroy(device); } } diff --git a/pomme-client/src/renderer/distant/mesh.rs b/pomme-client/src/renderer/distant/mesh.rs index 4925570e8..818a128ee 100644 --- a/pomme-client/src/renderer/distant/mesh.rs +++ b/pomme-client/src/renderer/distant/mesh.rs @@ -1,6 +1,8 @@ //! Distant terrain sections as geometry: each run a box, faces another run //! covers left out. +use std::collections::HashMap; + use super::column::{Run, RunKey}; use super::store::{SECTION_COLUMNS, Section, SectionKey}; @@ -22,7 +24,7 @@ pub(crate) struct DistantVertex { } /// A face's direction, as `distant.vert` reads it. -#[derive(Clone, Copy)] +#[derive(Clone, Copy, PartialEq, Eq, Hash)] #[repr(u8)] enum Face { Up, @@ -62,7 +64,12 @@ pub(crate) struct SectionMesh { pub max_y: u16, } -/// `colors` gives a run's top and side colour. +/// Top or bottom faces alike in height, colour and light, as a bit per +/// column: bit `x` of row `z`. +type FlatFaces = HashMap<(Face, u32, [u8; 3], u8), [u64; SECTION_COLUMNS as usize]>; + +/// `colors` gives a run's top and side colour. Tops and bottoms alike merge +/// into rectangles; sides are a quad per uncovered stretch. pub(crate) fn mesh_section( key: SectionKey, section: &Section, @@ -70,6 +77,7 @@ pub(crate) fn mesh_section( ) -> SectionMesh { let w = key.column_width() as u32; let mut vertices = Vec::new(); + let mut flat = FlatFaces::new(); let (mut min_y, mut max_y) = (u16::MAX, 0); for z in 0..SECTION_COLUMNS { for x in 0..SECTION_COLUMNS { @@ -87,30 +95,19 @@ pub(crate) fn mesh_section( let (bottom, top) = (u32::from(run.bottom), u32::from(run.top) + 1); min_y = min_y.min(run.bottom); max_y = max_y.max(run.top + 1); - let mut quad = - |corners, face, rgb| push_quad(&mut vertices, corners, light, face, rgb); + let mut flat_face = |face: Face, y| { + flat.entry((face, y, top_rgb, light)) + .or_insert([0; SECTION_COLUMNS as usize])[z as usize] |= 1 << x; + }; let above_touches = i > 0 && runs[i - 1].bottom == run.top + 1; if !above_touches { - quad( - [[x0, top, z0], [x1, top, z0], [x1, top, z1], [x0, top, z1]], - Face::Up, - top_rgb, - ); + flat_face(Face::Up, top); } let below_touches = runs .get(i + 1) .is_some_and(|below| below.top + 1 == run.bottom); if run.bottom > 0 && !below_touches { - quad( - [ - [x0, bottom, z0], - [x0, bottom, z1], - [x1, bottom, z1], - [x1, bottom, z0], - ], - Face::Down, - top_rgb, - ); + flat_face(Face::Down, bottom); } for (&(face, ..), neighbour) in SIDES.iter().zip(&neighbours) { for (low, high) in uncovered(bottom, top, neighbour) { @@ -128,12 +125,23 @@ pub(crate) fn mesh_section( [[x1, low, z0], [x1, high, z0], [x1, high, z1], [x1, low, z1]] } }; - quad(corners, face, side_rgb); + push_quad(&mut vertices, corners, light, face, side_rgb); } } } } } + for ((face, y, rgb, light), mut rows) in flat { + for (x, z, width, depth) in rectangles(&mut rows) { + let (x0, z0) = (x * w, z * w); + let (x1, z1) = (x0 + width * w, z0 + depth * w); + let corners = match face { + Face::Up => [[x0, y, z0], [x1, y, z0], [x1, y, z1], [x0, y, z1]], + _ => [[x0, y, z0], [x0, y, z1], [x1, y, z1], [x1, y, z0]], + }; + push_quad(&mut vertices, corners, light, face, rgb); + } + } SectionMesh { key, vertices, @@ -142,6 +150,27 @@ pub(crate) fn mesh_section( } } +/// Greedy rectangles covering the set bits of `rows` (bit `x` of row `z`), +/// as `(x, z, width, depth)` in columns; clears `rows`. +fn rectangles(rows: &mut [u64; SECTION_COLUMNS as usize]) -> Vec<(u32, u32, u32, u32)> { + let mut out = Vec::new(); + for z in 0..rows.len() { + while rows[z] != 0 { + let x = rows[z].trailing_zeros(); + let width = (!(rows[z] >> x)).trailing_zeros(); + let mask = (u64::MAX >> (64 - width)) << x; + let mut end = z + 1; + while end < rows.len() && rows[end] & mask == mask { + rows[end] &= !mask; + end += 1; + } + rows[z] &= !mask; + out.push((x, z as u32, width, (end - z) as u32)); + } + } + out +} + /// The parts of `[low, high)` none of `neighbour`'s runs cover, highest /// first. A neighbour outside the section has no runs, so the section's /// border faces always draw: a coarser or missing neighbour can't hide them. @@ -244,10 +273,24 @@ mod tests { #[test] fn equal_neighbours_hide_their_shared_sides() { + // Two side by side: their tops merge, their shared sides go. let pair = |i| if i < 2 { vec![run(5, 0)] } else { Vec::new() }; - assert_eq!(quads(pair), 2 + 2 * 3); - // A flat chunk: one top per column, and only the chunk's outer - // sides, which stay because they're in the middle of nothing. - assert_eq!(quads(|_| vec![run(5, 0)]), 256 + 4 * 16); + assert_eq!(quads(pair), 1 + 2 * 3); + // A flat chunk: one top, and the chunk's outer sides, which stay + // because they're in the middle of nothing. + assert_eq!(quads(|_| vec![run(5, 0)]), 1 + 4 * 16); + } + + #[test] + fn flat_faces_merge_into_rectangles() { + let mut rows = [0u64; SECTION_COLUMNS as usize]; + // An L: a 3x2 block with one more cell under its left edge. + rows[0] = 0b111; + rows[1] = 0b111; + rows[2] = 0b001; + assert_eq!(rectangles(&mut rows), [(0, 0, 3, 2), (0, 2, 1, 1)]); + assert!(rows.iter().all(|&row| row == 0)); + rows[5] = u64::MAX; + assert_eq!(rectangles(&mut rows), [(0, 5, 64, 1)]); } } diff --git a/pomme-client/src/renderer/distant/worker.rs b/pomme-client/src/renderer/distant/worker.rs index 23de4e155..95dc37ea7 100644 --- a/pomme-client/src/renderer/distant/worker.rs +++ b/pomme-client/src/renderer/distant/worker.rs @@ -8,7 +8,7 @@ use crossbeam_channel::{Receiver, Sender}; use parking_lot::RwLock; use super::colors::DistantColors; -use super::column::build_column; +use super::column::build_chunk; use super::mesh::{SectionMesh, mesh_section}; use super::store::{SectionKey, Store}; use crate::position::ChunkPos; @@ -184,22 +184,7 @@ impl Worker { light, min_y, } => { - let columns = { - let chunk = chunk.read(); - (0..256) - .map(|i| { - build_column( - &chunk, - light.as_deref(), - i % 16, - i / 16, - min_y, - |state| self.colors.shows(state), - |state| self.colors.fills(state), - ) - }) - .collect() - }; + let columns = build_chunk(&chunk.read(), light.as_deref(), min_y, &self.colors); let (store, store_min_y) = self.stores.entry(Arc::clone(&dimension)).or_default(); *store_min_y = min_y; let touched = store.capture(pos, columns); diff --git a/pomme-client/src/renderer/mod.rs b/pomme-client/src/renderer/mod.rs index a54e61f7b..8de8edbd0 100644 --- a/pomme-client/src/renderer/mod.rs +++ b/pomme-client/src/renderer/mod.rs @@ -1386,7 +1386,7 @@ impl Renderer { updates: impl IntoIterator, ) { for update in updates { - self.distant_buffers.upload(update); + self.distant_buffers.upload(&self.ctx.device, update); } } diff --git a/pomme-client/src/renderer/shaders/distant.frag b/pomme-client/src/renderer/shaders/distant.frag index dfe7c29bf..97d99b790 100644 --- a/pomme-client/src/renderer/shaders/distant.frag +++ b/pomme-client/src/renderer/shaders/distant.frag @@ -13,18 +13,26 @@ layout(location = 1) in vec3 v_fog_color; layout(location = 2) in float v_fog; layout(location = 3) in vec2 v_column_pos; layout(location = 4) flat in ivec2 v_anchor; +layout(location = 5) flat in vec2 v_outward; layout(location = 0) out vec4 out_color; // `world/chunk.rs` RING_SIDE. const int RING_SIDE = 257; -void main() { - ivec2 block = v_anchor + ivec2(floor(v_column_pos)); - ivec2 chunk = block >> 4; +// Whether a real chunk is loaded under anchor-relative `pos`. +bool loaded_at(vec2 pos) { + ivec2 chunk = (v_anchor + ivec2(floor(pos))) >> 4; ivec2 ring = ((chunk % RING_SIDE) + RING_SIDE) % RING_SIDE; uint slot = uint(ring.y * RING_SIDE + ring.x); - if ((loaded[slot >> 5] & (1u << (slot & 31u))) != 0u) { + return (loaded[slot >> 5] & (1u << (slot & 31u))) != 0u; +} + +void main() { + // Nothing over a real chunk, nor a side facing one: the chunk draws its + // own edge, and through its water this one would show as a wall. + if (loaded_at(v_column_pos) + || (v_outward != vec2(0.0) && loaded_at(v_column_pos + v_outward))) { discard; } out_color = vec4(apply_fog(v_color, v_fog, v_fog_color), 1.0); diff --git a/pomme-client/src/renderer/shaders/distant.vert b/pomme-client/src/renderer/shaders/distant.vert index 3ddc6ce92..260644d1b 100644 --- a/pomme-client/src/renderer/shaders/distant.vert +++ b/pomme-client/src/renderer/shaders/distant.vert @@ -27,6 +27,9 @@ layout(location = 2) out float v_fog; // the anchor: what distant.frag finds the column's chunk from. layout(location = 3) out vec2 v_column_pos; layout(location = 4) flat out ivec2 v_anchor; +// A side face's step from v_column_pos into the column it faces; zero for +// tops and bottoms. +layout(location = 5) flat out vec2 v_outward; // mesh.rs `Face`: up, down, north, south, west, east. const vec2 FACE_NUDGE[6] = vec2[]( @@ -46,6 +49,8 @@ void main() { v_color = in_color.rgb * sample_lightmap(lightmap_uv).rgb; v_fog_color = fog_color.rgb; v_fog = total_fog_value(rel, fog_env, camera_pos.w, fog_color.w); - v_column_pos = world.xz + FACE_NUDGE[min(in_light_face.y, 5u)]; + vec2 nudge = FACE_NUDGE[min(in_light_face.y, 5u)]; + v_column_pos = world.xz + nudge; v_anchor = camera_block.xz; + v_outward = -2.0 * nudge; } From d0873b26534804136a63b9ef33d415eef4ce6ee7 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:58:00 +0000 Subject: [PATCH 07/67] save distant terrain to disk per server or world and load it back as the player returns --- pomme-client/src/app/core.rs | 27 +- pomme-client/src/app/mod.rs | 15 +- pomme-client/src/app/phases/in_game.rs | 15 +- pomme-client/src/harness/script.rs | 12 +- pomme-client/src/headless.rs | 1 + pomme-client/src/renderer/distant/mod.rs | 11 +- pomme-client/src/renderer/distant/store.rs | 270 ++++++++++++++++++-- pomme-client/src/renderer/distant/worker.rs | 146 +++++++++-- pomme-client/src/singleplayer.rs | 13 +- tests/ingame/README.md | 2 +- tests/ingame/world/distant_terrain.toml | 29 ++- 11 files changed, 485 insertions(+), 56 deletions(-) diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index b194588b7..f57b8136e 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -432,6 +432,27 @@ impl GameState { .recreate_for_version(self.block_registry.owned_render_view()); self.distant .set_colors(self.mesh_dispatcher.distant_colors()); + self.set_distant_cache_key(); + } + } + + /// Distant terrain stores block states and biomes by id, so it keeps + /// them apart per block table and biome registry. + fn set_distant_cache_key(&mut self) { + if let Some(biomes) = crate::net::connection::biome_registry(&self.registries) { + self.distant.set_cache_key( + self.block_registry.block_state_table().table_slot(), + biomes.map.keys(), + ); + } + } + + /// Keeps every loaded column as distant terrain of the current + /// dimension. + pub fn keep_loaded_columns(&self) { + for pos in self.chunk_store.loaded_positions() { + self.distant + .capture(&self.dimension, pos, &self.chunk_store); } } @@ -479,10 +500,7 @@ impl GameState { self.environment_probe = EnvironmentProbe::new(self.resolve_environment()); // The old level's columns stay as its distant terrain; the name // still names it until `DimensionName`. - for pos in self.chunk_store.loaded_positions() { - self.distant - .capture(&self.dimension, pos, &self.chunk_store); - } + self.keep_loaded_columns(); self.chunk_store = ChunkStore::new_with_dimension(render_distance, height, min_y); self.light_engine = crate::world::light::LevelLightEngine::new(height, min_y, has_skylight); // The old level's block entities, moving blocks among them. @@ -2002,6 +2020,7 @@ impl AppCore { } NetworkEvent::Registries(registries) => { game.registries = registries; + game.set_distant_cache_key(); } NetworkEvent::DialogRegistry(registry) => { game.dialog_registry = registry; diff --git a/pomme-client/src/app/mod.rs b/pomme-client/src/app/mod.rs index ad37bfc35..62296501f 100644 --- a/pomme-client/src/app/mod.rs +++ b/pomme-client/src/app/mod.rs @@ -28,7 +28,7 @@ use crate::app::phases::saving::update_saving; use crate::app::phases::{AfterSaving, AppPhase, ConnectionPhase, FpsCounter, Gfx, Panorama}; use crate::app::state_slot::StateSlot; use crate::dirs::DataDirs; -use crate::net::connection::{ConnectArgs, ConnectionHandle, spawn_connection}; +use crate::net::connection::{ConnectArgs, ConnectionHandle, Transport, spawn_connection}; use crate::renderer::{self, Renderer}; use crate::singleplayer::World; use crate::user::UserData; @@ -194,6 +194,18 @@ fn start_connecting( // a different session protocol. The registry receives explicit metadata. let protocol = crate::version::selected_protocol(); let block_definitions = Rc::clone(&core.block_definitions); + // A world keeps its distant terrain in its own folder; a server's goes + // under the game folder. + let distant_cache = match (&world, &connect_args.transport) { + (Some(world), _) => Some(world.dir().join("distant_terrain")), + (None, Transport::Remote { server, .. }) => Some( + core.data_dirs + .game_dir + .join("distant_terrain") + .join(crate::renderer::distant::worker::folder_name(server)), + ), + (None, Transport::Memory { .. }) => None, + }; let connection = spawn_connection(&core.tokio_rt, connect_args); let game = GameState::new( &*gfx.renderer, @@ -204,6 +216,7 @@ fn start_connecting( core.menu.skin_options(), block_definitions, protocol, + distant_cache, ); core.apply_cursor_grab(&gfx.window, None); AppPhase::Connecting { diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index d531d5ce6..5dc0dc9d9 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -367,6 +367,14 @@ pub struct GameState { pub chunk_occlusion_enabled: bool, } +impl Drop for GameState { + /// The columns still loaded stay as distant terrain, written to disk as + /// `distant` drops. + fn drop(&mut self) { + self.keep_loaded_columns(); + } +} + impl GameState { /// The day time is the dimension's default clock. pub fn sync_day_time(&mut self) { @@ -449,6 +457,7 @@ impl GameState { skin_options: crate::ui::menu::SkinOptions, block_definitions: Rc, protocol: i32, + distant_cache: Option, ) -> Self { let block_registry = BlockRegistry::new( block_definitions, @@ -466,8 +475,10 @@ impl GameState { ); let chunk_store = ChunkStore::new(render_distance); - let distant = - crate::renderer::distant::worker::DistantTerrain::new(mesh_dispatcher.distant_colors()); + let distant = crate::renderer::distant::worker::DistantTerrain::new( + mesh_dispatcher.distant_colors(), + distant_cache, + ); Self { light_engine: crate::world::light::LevelLightEngine::new( chunk_store.height(), diff --git a/pomme-client/src/harness/script.rs b/pomme-client/src/harness/script.rs index 09dd09c8a..9db80ef40 100644 --- a/pomme-client/src/harness/script.rs +++ b/pomme-client/src/harness/script.rs @@ -230,7 +230,8 @@ struct TerrainExpect { } /// Distant terrain is drawn at a block column, `{ at = [x, z] }`: the -/// finest section holding it is meshed and uploaded. +/// finest section holding it is meshed and uploaded; with `gone = true`, +/// it isn't. #[derive(Debug, PartialEq, Deserialize)] #[serde(deny_unknown_fields)] struct DistantExpect { @@ -241,6 +242,8 @@ struct DistantExpect { #[serde(deny_unknown_fields)] struct DistantAt { at: [i32; 2], + #[serde(default)] + gone: bool, } /// The open menu: a container's menu type (`generic_9x3`), `inventory`, @@ -720,9 +723,10 @@ fn check(expect: &Expect, phase: &AppPhase) -> Result<(), String> { gfx.renderer.chunk_slice_overlap().map_or(Ok(()), Err) } Expect::Distant(DistantExpect { - distant_terrain: DistantAt { at: [x, z] }, - }) => ensure(gfx.renderer.has_distant_column(*x, *z), || { - format!("no distant terrain at {x}, {z}") + distant_terrain: DistantAt { at: [x, z], gone }, + }) => ensure(gfx.renderer.has_distant_column(*x, *z) != *gone, || { + let state = if *gone { "still" } else { "no" }; + format!("{state} distant terrain at {x}, {z}") }), Expect::Biome(BiomeExpect { biome, at }) => { let seen = protocol::biome(game, *at); diff --git a/pomme-client/src/headless.rs b/pomme-client/src/headless.rs index 9ac52958b..9b59480a4 100644 --- a/pomme-client/src/headless.rs +++ b/pomme-client/src/headless.rs @@ -93,6 +93,7 @@ pub fn run( skin_options, Rc::new(BlockDefinitions::new()), crate::version::selected_protocol(), + None, ); let code = drive( diff --git a/pomme-client/src/renderer/distant/mod.rs b/pomme-client/src/renderer/distant/mod.rs index d5b202e46..efdb59130 100644 --- a/pomme-client/src/renderer/distant/mod.rs +++ b/pomme-client/src/renderer/distant/mod.rs @@ -1,8 +1,9 @@ -//! Distant terrain: the chunks the server has forgotten, kept as columns and -//! drawn coarsely past the loaded ones, like Distant Horizons' client-only -//! mode. A Pomme-only system; the game has nothing like it. -// TODO: disk cache, generation beyond what's been seen, textures, shader -// packs, and dimensions without sky light (a cave roof would cover them). +//! Distant terrain: the chunks the server has forgotten, kept as columns +//! (on disk too, per server or world) and drawn coarsely past the loaded +//! ones, like Distant Horizons' client-only mode. A Pomme-only system; the game +//! has nothing like it. +// TODO: generation beyond what's been seen, textures, shader packs, and +// dimensions without sky light (a cave roof would cover them). pub(crate) mod colors; pub(crate) mod column; diff --git a/pomme-client/src/renderer/distant/store.rs b/pomme-client/src/renderer/distant/store.rs index 9c84e74a6..9766bf8b2 100644 --- a/pomme-client/src/renderer/distant/store.rs +++ b/pomme-client/src/renderer/distant/store.rs @@ -1,7 +1,11 @@ //! What the client keeps of the terrain it has seen: per level of detail, -//! sections of 64x64 columns. +//! sections of 64x64 columns, in memory around the player and on disk. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::io::{Read, Write}; +use std::path::PathBuf; + +use azalea_block::BlockState; use super::column::{Run, RunKey, merge_columns}; use crate::position::ChunkPos; @@ -11,6 +15,8 @@ pub(crate) const SECTION_COLUMNS: i32 = 64; /// Levels of detail: at level `l` a column is `1 << l` blocks wide, so a /// chunk is `16 >> l` columns across and level 4 is one column per chunk. pub(crate) const LEVELS: u8 = 5; +/// The layout of a section file; a file of another is ignored. +const FORMAT: u8 = 1; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(crate) struct SectionKey { @@ -79,6 +85,28 @@ impl SectionKey { self.distance(x, z) <= radius && self.parent().is_none_or(|parent| parent.splits(x, z)) } + /// Every section [`Self::wanted`] from `x`/`z` out to `radius` blocks. + pub fn all_wanted(x: f64, z: f64, radius: f64) -> Vec { + let top = LEVELS - 1; + let width = f64::from(SECTION_COLUMNS << top); + let range = + |c: f64| ((c - radius) / width).floor() as i32..=((c + radius) / width).floor() as i32; + let mut stack: Vec = range(z) + .flat_map(|z| range(x).map(move |x| Self { level: top, x, z })) + .collect(); + let mut out = Vec::new(); + while let Some(key) = stack.pop() { + if key.distance(x, z) > radius { + continue; + } + out.push(key); + if key.splits(x, z) { + stack.extend(key.children().into_iter().flatten()); + } + } + out + } + /// The horizontal distance from block position `x`/`z` to the /// section's nearest edge; 0 inside it. pub fn distance(self, x: f64, z: f64) -> f64 { @@ -100,9 +128,13 @@ fn pack(run: &Run, palette_index: u32) -> u64 { | u64::from(run.block) << 52 } +fn palette_index(packed: u64) -> usize { + (packed & 0xFF_FFFF) as usize +} + fn unpack(packed: u64, palette: &[RunKey]) -> Run { Run { - key: palette[(packed & 0xFF_FFFF) as usize], + key: palette[palette_index(packed)], top: (packed >> 24 & 0xFFF) as u16, bottom: (packed >> 36 & 0xFFF) as u16, sky: (packed >> 48 & 0xF) as u8, @@ -128,19 +160,74 @@ impl Section { } } + /// `key`'s palette index, adding it if it's new. + fn intern(&mut self, key: RunKey) -> u32 { + *self.lookup.entry(key).or_insert_with(|| { + self.palette.push(key); + (self.palette.len() - 1) as u32 + }) + } + fn set_column(&mut self, index: usize, runs: &[Run]) { self.columns[index] = runs .iter() - .map(|run| { - let palette_index = *self.lookup.entry(run.key).or_insert_with(|| { - self.palette.push(run.key); - (self.palette.len() - 1) as u32 - }); - pack(run, palette_index) - }) + .map(|run| pack(run, self.intern(run.key))) .collect(); } + /// The section as a section file: the palette, then each column's + /// packed runs, zlib-compressed. + fn encode(&self) -> Vec { + let mut out = vec![FORMAT]; + out.extend((self.palette.len() as u32).to_le_bytes()); + for key in &self.palette { + out.extend(key.state.id().to_le_bytes()); + out.extend(key.biome.to_le_bytes()); + } + for column in &self.columns { + out.extend((column.len() as u16).to_le_bytes()); + for packed in column { + out.extend(packed.to_le_bytes()); + } + } + let mut zlib = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + zlib.write_all(&out) + .and_then(|()| zlib.finish()) + .expect("compressing into memory") + } + + /// The section [`Self::encode`] wrote; `None` for a file of another + /// format or a damaged one. + fn decode(file: &[u8]) -> Option { + let mut bytes = Vec::new(); + flate2::read::ZlibDecoder::new(file) + .read_to_end(&mut bytes) + .ok()?; + let mut rest = bytes.as_slice(); + if take::<1>(&mut rest)? != [FORMAT] { + return None; + } + let mut section = Self::new(); + for _ in 0..u32::from_le_bytes(take(&mut rest)?) { + let state = u16::from_le_bytes(take(&mut rest)?); + section.intern(RunKey { + state: BlockState::try_from(u32::from(state)).ok()?, + biome: u32::from_le_bytes(take(&mut rest)?), + }); + } + let palette = section.palette.len(); + for column in &mut section.columns { + let runs = u16::from_le_bytes(take(&mut rest)?); + *column = (0..runs) + .map(|_| { + let packed = u64::from_le_bytes(take(&mut rest)?); + (palette_index(packed) < palette).then_some(packed) + }) + .collect::>()?; + } + Some(section) + } + /// Column `x`/`z`'s runs, top to bottom; none outside the section. pub fn column(&self, x: i32, z: i32) -> impl Iterator + '_ { let inside = (0..SECTION_COLUMNS).contains(&x) && (0..SECTION_COLUMNS).contains(&z); @@ -153,13 +240,93 @@ impl Section { } } -/// One dimension's sections at every level. +/// The next `N` bytes of `rest`, taking them off it. +fn take(rest: &mut &[u8]) -> Option<[u8; N]> { + let (taken, after) = rest.split_first_chunk()?; + *rest = after; + Some(*taken) +} + +/// One dimension's sections at every level: those near the player in +/// memory, and every one seen in `dir`, a file per section. #[derive(Default)] pub(crate) struct Store { sections: HashMap, + dir: Option, + /// Sections changed since they were last written. + unsaved: HashSet, + /// Sections `dir` has no file for. + absent: HashSet, } impl Store { + pub fn new(dir: Option) -> Self { + Self { + dir, + ..Self::default() + } + } + + /// Writes what's unsaved, then keeps further sections in `dir`. + pub fn set_dir(&mut self, dir: Option) { + if self.dir != dir { + self.flush(); + self.dir = dir; + self.absent.clear(); + } + } + + fn path(&self, key: SectionKey) -> Option { + let SectionKey { level, x, z } = key; + Some(self.dir.as_ref()?.join(format!("{level}.{x}.{z}.bin"))) + } + + /// Reads `key` from disk unless it's in memory already. Returns whether + /// it's in memory now. + pub fn load(&mut self, key: SectionKey) -> bool { + if self.sections.contains_key(&key) { + return true; + } + if self.absent.contains(&key) { + return false; + } + let Some(path) = self.path(key) else { + return false; + }; + let read = std::fs::read(&path).ok(); + match read.as_deref().and_then(Section::decode) { + Some(section) => { + self.sections.insert(key, section); + true + } + None => { + if read.is_some() { + tracing::warn!("Ignoring unreadable distant terrain {}", path.display()); + } + self.absent.insert(key); + false + } + } + } + + fn save(&self, key: SectionKey) { + let (Some(path), Some(section)) = (self.path(key), self.sections.get(&key)) else { + return; + }; + let written = std::fs::create_dir_all(path.parent().expect("a section file has a folder")) + .and_then(|()| crate::util::write_atomic(&path, §ion.encode())); + if let Err(error) = written { + tracing::warn!("Failed to save distant terrain {}: {error}", path.display()); + } + } + + /// Writes every section changed since it was last written. + pub fn flush(&mut self) { + for key in std::mem::take(&mut self.unsaved) { + self.save(key); + } + } + /// Writes a chunk's columns, `level0` by `z * 16 + x`, into every level, /// merging them down for the coarser ones. Returns the sections it /// touched. @@ -183,13 +350,17 @@ impl Store { let x = pos.x * across as i32 + (i % across) as i32; let z = pos.z * across as i32 + (i / across) as i32; let (key, index) = SectionKey::holding(level, x, z); + if !touched.contains(&key) { + // Onto what's on disk, so the rest of it isn't lost. + self.load(key); + self.absent.remove(&key); + self.unsaved.insert(key); + touched.push(key); + } self.sections .entry(key) .or_insert_with(Section::new) .set_column(index, runs); - if !touched.contains(&key) { - touched.push(key); - } } } touched @@ -199,12 +370,8 @@ impl Store { self.sections.get(&key) } - pub fn keys(&self) -> impl Iterator + '_ { - self.sections.keys().copied() - } - - /// Drops the sections farther than `radius` blocks from `x`/`z`, - /// returning their keys. + /// Drops the sections farther than `radius` blocks from `x`/`z`, saving + /// those unsaved, and returns their keys. pub fn evict_beyond(&mut self, x: f64, z: f64, radius: f64) -> Vec { let far: Vec = self .sections @@ -213,6 +380,9 @@ impl Store { .filter(|key| key.distance(x, z) > radius) .collect(); for key in &far { + if self.unsaved.remove(key) { + self.save(*key); + } self.sections.remove(key); } far @@ -283,6 +453,64 @@ mod tests { assert!(!key(4, 9).wanted(8.0, 8.0, 4096.0), "past the radius"); } + #[test] + fn wanted_sections_are_found_without_a_store() { + let (x, z, radius) = (-300.0, 2000.0, 1500.0); + let found: HashSet = SectionKey::all_wanted(x, z, radius).into_iter().collect(); + let brute: HashSet = (0..LEVELS) + .flat_map(|level| { + let span = 4096 >> level >> 6; + (-span..=span).flat_map(move |sx| { + (-span..=span).map(move |sz| SectionKey { + level, + x: sx, + z: sz, + }) + }) + }) + .filter(|key| key.wanted(x, z, radius)) + .collect(); + assert!(!brute.is_empty()); + assert_eq!(found, brute); + } + + #[test] + fn sections_survive_a_file() { + let mut section = Section::new(); + let runs = [run(5, 4095, 100), run(9, 99, 0)]; + section.set_column(0, &runs); + section.set_column(4095, &[run(9, 7, 7)]); + let read = Section::decode(§ion.encode()).unwrap(); + assert_eq!(read.column(0, 0).collect::>(), runs); + assert_eq!(read.column(63, 63).collect::>(), [run(9, 7, 7)]); + assert_eq!(read.column(1, 0).count(), 0); + assert!(Section::decode(b"not a section").is_none()); + } + + #[test] + fn evicted_sections_come_back_from_disk() { + let dir = std::env::temp_dir().join(format!("pomme-distant-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut store = Store::new(Some(dir.clone())); + store.capture(ChunkPos::new(100, 0), vec![vec![run(1, 10, 0)]; 256]); + store.evict_beyond(8.0, 8.0, 500.0); + assert_eq!(store.sections.len(), 0); + + // A later capture beside it shares its level-4 section, which keeps + // what was saved. + let mut store = Store::new(Some(dir.clone())); + store.capture(ChunkPos::new(101, 0), vec![vec![run(2, 20, 0)]; 256]); + let section = store.get(SectionKey::holding(4, 100, 0).0).unwrap(); + assert_eq!(section.column(36, 0).collect::>(), [run(1, 10, 0)]); + assert_eq!(section.column(37, 0).collect::>(), [run(2, 20, 0)]); + assert!(!store.load(SectionKey { + level: 0, + x: -9, + z: 0 + })); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn eviction_drops_sections_beyond_the_radius() { let mut store = Store::default(); @@ -293,6 +521,6 @@ mod tests { let gone = store.evict_beyond(8.0, 8.0, 500.0); assert_eq!(gone.len(), LEVELS as usize); assert!(gone.iter().all(|key| key.x > 0)); - assert_eq!(store.keys().count(), LEVELS as usize); + assert_eq!(store.sections.len(), LEVELS as usize); } } diff --git a/pomme-client/src/renderer/distant/worker.rs b/pomme-client/src/renderer/distant/worker.rs index 95dc37ea7..02793f136 100644 --- a/pomme-client/src/renderer/distant/worker.rs +++ b/pomme-client/src/renderer/distant/worker.rs @@ -1,10 +1,14 @@ //! The distant terrain thread: turns chunks the server forgets into columns, -//! keeps them per dimension, and meshes the sections around the player. +//! keeps them per dimension, on disk too, and meshes the sections around the +//! player. use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; -use crossbeam_channel::{Receiver, Sender}; +use crossbeam_channel::{Receiver, RecvTimeoutError, Sender}; use parking_lot::RwLock; use super::colors::DistantColors; @@ -21,6 +25,8 @@ const MESHES_PER_BATCH: usize = 16; const KEEP_MARGIN: f64 = 1024.0; /// How far the player moves, in blocks, before the thread hears of it. const VIEW_STEP: f64 = 32.0; +/// How often changed sections are written to disk. +const SAVE_EVERY: Duration = Duration::from_secs(60); enum Job { Capture { @@ -32,6 +38,11 @@ enum Job { }, Colors(Arc), View(View), + /// Where sections are kept on disk, a folder per dimension; `None` + /// keeps them in memory only. + Cache(Option), + /// Writes what's unsaved and ends the thread. + Stop, } #[derive(Clone)] @@ -57,26 +68,59 @@ pub(crate) struct DistantUpdate { pub removed: Vec, } -/// The game's handle on the thread. +/// The game's handle on the thread. Dropping it waits for the thread to +/// write what's unsaved. pub(crate) struct DistantTerrain { jobs: Sender, updates: Receiver, + thread: Option>, /// The last view sent, so small moves don't flood the thread. view: Option, + /// This server's or world's folder of distant terrain. + cache_root: Option, + /// The folder last sent, under `cache_root`. + cache: Option, } impl DistantTerrain { - pub fn new(colors: Arc) -> Self { + pub fn new(colors: Arc, cache_root: Option) -> Self { let (jobs, job_rx) = crossbeam_channel::unbounded(); let (update_tx, updates) = crossbeam_channel::unbounded(); - std::thread::Builder::new() + let thread = std::thread::Builder::new() .name("distant-terrain".into()) .spawn(move || Worker::new(colors, update_tx).run(&job_rx)) .expect("failed to spawn the distant terrain thread"); Self { jobs, updates, + thread: Some(thread), view: None, + cache_root, + cache: None, + } + } + + /// Keeps sections under the cache root, in a folder for the block + /// states of table `slot` and the server's `biomes`, since the runs + /// store both by id. + pub fn set_cache_key( + &mut self, + slot: usize, + biomes: impl IntoIterator, + ) { + let mut hash = sha1_smol::Sha1::new(); + for biome in biomes { + hash.update(biome.to_string().as_bytes()); + hash.update(&[0]); + } + let digest = hash.digest().to_string(); + let cache = self + .cache_root + .as_ref() + .map(|root| root.join(format!("{slot}-{}", &digest[..16]))); + if cache != self.cache { + self.cache.clone_from(&cache); + let _ = self.jobs.send(Job::Cache(cache)); } } @@ -136,11 +180,51 @@ impl DistantTerrain { } } +impl Drop for DistantTerrain { + fn drop(&mut self) { + let _ = self.jobs.send(Job::Stop); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +/// `name` as a single folder name: anything but letters, digits, `-`, `_` +/// and `.` becomes `_`. +pub(crate) fn folder_name(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '_' + } + }) + .collect() +} + +fn dimension_dir(cache: Option<&Path>, dimension: &str) -> Option { + Some(cache?.join(folder_name(dimension))) +} + +/// `dimension`'s store and `min_y`, made with its folder under `cache`. +fn store<'a>( + stores: &'a mut HashMap, (Store, i32)>, + cache: Option<&Path>, + dimension: &Arc, +) -> &'a mut (Store, i32) { + stores + .entry(Arc::clone(dimension)) + .or_insert_with(|| (Store::new(dimension_dir(cache, dimension)), 0)) +} + struct Worker { colors: Arc, updates: Sender, /// Per dimension, its sections and `min_y`. stores: HashMap, (Store, i32)>, + cache: Option, + last_save: Instant, view: Option, /// The viewed dimension's sections to mesh. dirty: HashSet, @@ -154,6 +238,8 @@ impl Worker { colors, updates, stores: HashMap::new(), + cache: None, + last_save: Instant::now(), view: None, dirty: HashSet::new(), meshed: HashSet::new(), @@ -161,18 +247,36 @@ impl Worker { } fn run(mut self, jobs: &Receiver) { - loop { - if self.dirty.is_empty() { - match jobs.recv() { - Ok(job) => self.handle(job), - Err(_) => return, + 'run: loop { + let wait = if self.dirty.is_empty() { + SAVE_EVERY.saturating_sub(self.last_save.elapsed()) + } else { + Duration::ZERO + }; + let first = match jobs.recv_timeout(wait) { + Ok(job) => Some(job), + Err(RecvTimeoutError::Timeout) => None, + Err(RecvTimeoutError::Disconnected) => break, + }; + for job in first.into_iter().chain(jobs.try_iter()) { + if matches!(job, Job::Stop) { + break 'run; } - } - while let Ok(job) = jobs.try_recv() { self.handle(job); } self.mesh_nearest(); + if self.last_save.elapsed() >= SAVE_EVERY { + self.save(); + } } + self.save(); + } + + fn save(&mut self) { + for (store, _) in self.stores.values_mut() { + store.flush(); + } + self.last_save = Instant::now(); } fn handle(&mut self, job: Job) { @@ -185,7 +289,8 @@ impl Worker { min_y, } => { let columns = build_chunk(&chunk.read(), light.as_deref(), min_y, &self.colors); - let (store, store_min_y) = self.stores.entry(Arc::clone(&dimension)).or_default(); + let (store, store_min_y) = + store(&mut self.stores, self.cache.as_deref(), &dimension); *store_min_y = min_y; let touched = store.capture(pos, columns); if let Some(view) = &self.view @@ -209,10 +314,14 @@ impl Worker { self.meshed.clear(); self.dirty.clear(); } - let (store, min_y) = self.stores.entry(Arc::clone(&view.dimension)).or_default(); + let (store, min_y) = + store(&mut self.stores, self.cache.as_deref(), &view.dimension); store.evict_beyond(view.x, view.z, view.radius + KEEP_MARGIN); let wanted: HashSet = - store.keys().filter(|key| view.wants(*key)).collect(); + SectionKey::all_wanted(view.x, view.z, view.radius) + .into_iter() + .filter(|key| store.load(*key)) + .collect(); let removed: Vec = self.meshed.difference(&wanted).copied().collect(); for key in &removed { self.meshed.remove(key); @@ -229,6 +338,13 @@ impl Worker { } self.view = Some(view); } + Job::Cache(cache) => { + for (dimension, (store, _)) in &mut self.stores { + store.set_dir(dimension_dir(cache.as_deref(), dimension)); + } + self.cache = cache; + } + Job::Stop => unreachable!("the run loop ends on it"), } } diff --git a/pomme-client/src/singleplayer.rs b/pomme-client/src/singleplayer.rs index d2a13f007..0eb98eff7 100644 --- a/pomme-client/src/singleplayer.rs +++ b/pomme-client/src/singleplayer.rs @@ -18,7 +18,7 @@ pub const UNAVAILABLE_MESSAGE: &str = "This build was compiled without singlepla #[cfg(feature = "singleplayer")] mod steel { use std::fs::{File, OpenOptions, TryLockError}; - use std::path::Path; + use std::path::{Path, PathBuf}; use pomme_singleplayer::{ Difficulty, GameType, LaunchOptions, PendingWorld, Progress, ServerTransport, WorldHandle, @@ -67,6 +67,7 @@ mod steel { // released, not after. server: Server, _lock: SessionLock, + dir: PathBuf, } impl World { @@ -108,6 +109,11 @@ mod steel { pub fn is_closed(&self) -> bool { self.handle().is_none_or(WorldHandle::is_finished) } + + /// The save folder. + pub fn dir(&self) -> &Path { + &self.dir + } } /// Starts `world` and hands back the client's end of the pipe. @@ -149,6 +155,7 @@ mod steel { World { server: Server::Starting(pending), _lock: lock, + dir: dir.to_path_buf(), }, client, )) @@ -178,6 +185,10 @@ mod steel { pub const fn is_closed(&self) -> bool { match *self {} } + + pub const fn dir(&self) -> &Path { + match *self {} + } } pub fn open( diff --git a/tests/ingame/README.md b/tests/ingame/README.md index dbfdf394b..1fb4d76b7 100644 --- a/tests/ingame/README.md +++ b/tests/ingame/README.md @@ -186,7 +186,7 @@ An `expect` is retried every frame until it holds or times out. It can check: | A block | `block` (state name, e.g. `air`), `at`, optional `block_entity` (type) | | A biome | `biome` (name, e.g. `swamp`), `at` | | Settled terrain | `terrain = "settled"`: every loaded column meshed and uploaded (none waiting for pool space), and no two of the chunk pools' ranges overlapping | -| Distant terrain | `distant_terrain = { at = [x, z] }`: the finest distant terrain section holding that block column is meshed and uploaded (Pomme-only) | +| Distant terrain | `distant_terrain = { at = [x, z] }`: the finest distant terrain section holding that block column is meshed and uploaded; `gone = true` for not (Pomme-only) | | The local player | `player = { ... }` with any of `health`, `food`, `level`, `game_mode`, `effect`, `no_effect`, `max_health`, `raining`, `hand`, `dimension`, `day_time`, `position` (within 0.01), `y_rot` (within 0.01), `spectating` (the kind of entity the camera follows, empty for none) and `post_effect` (the spectator view effect drawn: `creeper`, `spider`, `invert`, or empty) | | The open menu | `menu` (a container's menu type, e.g. `generic_9x3`, or `inventory`, `creative`, `pause`, `none`), optional `title`; on the creative screen also `tab` (its vanilla id, e.g. `building_blocks`), `row` (the first catalog row shown) and `search` | | The HUD text | `hud = { ... }` with any of `title`, `subtitle` and `sidebar_title` (`""` when none is shown), `boss_bars` (every name, top to bottom), `sidebar` (every row as `"name number"`, top to bottom) and `tab_list` (every listed player as `{ name, game_mode? }`, in tab order) | diff --git a/tests/ingame/world/distant_terrain.toml b/tests/ingame/world/distant_terrain.toml index e1ee31c08..45f082f3b 100644 --- a/tests/ingame/world/distant_terrain.toml +++ b/tests/ingame/world/distant_terrain.toml @@ -1,7 +1,8 @@ # Distant Terrain keeps the chunks the server forgets and draws them past the # loaded ones: a tower, left 200 blocks behind at Render Distance 2, still -# shows on the horizon, and goes with the option. Pomme-only (the vanilla -# harness has no Distant Terrain), so it fails there by design. +# shows on the horizon, comes back from disk after going far enough to drop +# it from memory, and goes with the option. Pomme-only (the vanilla harness +# has no Distant Terrain), so it fails there by design. [[step]] wait_ms = 2000 @@ -36,6 +37,30 @@ timeout_ms = 20000 expect = { distant_terrain = { at = [1, 1] } } timeout_ms = 20000 +# Past the view and the margin kept in memory (32 + 64 chunks), which +# writes the tower's sections to disk and drops them. +[[step]] +chat = "/tp @s 3000.5 -50 1.5" + +[[step]] +expect = { player = { position = [3000.5, -50.0, 1.5] } } +timeout_ms = 20000 + +[[step]] +expect = { distant_terrain = { at = [1, 1], gone = true } } +timeout_ms = 20000 + +[[step]] +chat = "/tp @s 200.5 -50 1.5" + +[[step]] +expect = { player = { position = [200.5, -50.0, 1.5] } } +timeout_ms = 20000 + +[[step]] +expect = { distant_terrain = { at = [1, 1] } } +timeout_ms = 20000 + [[step]] expect = { terrain = "settled" } timeout_ms = 30000 From e7510b7b7addd8fe9ac3fe96d58b47e657265060 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:10:03 +0000 Subject: [PATCH 08/67] budget arenas against the heaps they land in, keep evicting columns from retaking freed room and test eviction and compaction in game --- pomme-client/src/harness/exec.rs | 9 + pomme-client/src/harness/protocol.rs | 11 + pomme-client/src/harness/script.rs | 23 +- pomme-client/src/renderer/chunk/buffer.rs | 309 +++++++++++++------- pomme-client/src/renderer/mod.rs | 14 + pomme-client/src/renderer/rhi/vulkan/mod.rs | 78 +++-- tests/ingame/README.md | 8 +- tests/ingame/world/chunk_arenas.toml | 71 +++++ tests/ingame/world/render_distance.toml | 5 +- 9 files changed, 394 insertions(+), 134 deletions(-) create mode 100644 tests/ingame/world/chunk_arenas.toml diff --git a/pomme-client/src/harness/exec.rs b/pomme-client/src/harness/exec.rs index 3d2953e6a..8ed5c75a3 100644 --- a/pomme-client/src/harness/exec.rs +++ b/pomme-client/src/harness/exec.rs @@ -83,6 +83,15 @@ pub fn run(command: &Command, core: &mut AppCore, phase: &mut AppPhase) -> Outco game.block_light_flicker = 0.0; Ok(Value::Null) } + Command::ChunkArenas(arenas) => { + if gfx + .renderer + .set_chunk_arena_limits(arenas.vertices, arenas.max) + { + game.rebuild_meshes(); + } + Ok(Value::Null) + } Command::Hover(target) => hover(core, gfx, game, target), Command::Cursor { at } => { let gs = gui_scale(core, gfx); diff --git a/pomme-client/src/harness/protocol.rs b/pomme-client/src/harness/protocol.rs index 49f655fae..e7e58c4ba 100644 --- a/pomme-client/src/harness/protocol.rs +++ b/pomme-client/src/harness/protocol.rs @@ -80,6 +80,17 @@ pub enum Command { /// bobbing and the autosave indicator, so two runs of a script draw the /// same frames. Deterministic, + /// Shrinks or caps the chunk arenas, so a small render distance fills + /// them. + ChunkArenas(ChunkArenas), +} + +/// Chunk arenas of `vertices` each, at most `max` of them. +#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ChunkArenas { + pub vertices: u32, + pub max: usize, } #[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)] diff --git a/pomme-client/src/harness/script.rs b/pomme-client/src/harness/script.rs index bc99972c5..11b10106e 100644 --- a/pomme-client/src/harness/script.rs +++ b/pomme-client/src/harness/script.rs @@ -48,6 +48,7 @@ struct StepFile { bind: Option, option: Option, deterministic: Option, + chunk_arenas: Option, hover: Option, cursor: Option<[f32; 2]>, scroll: Option, @@ -221,7 +222,8 @@ struct BiomeExpect { } /// `settled`: every loaded column meshed, and the chunk pools' ranges -/// consistent (vanilla's `hasRenderedAllSections`). +/// consistent (vanilla's `hasRenderedAllSections`). `compacted`: that, with +/// no chunk arena left to drain or free. #[derive(Debug, PartialEq, Deserialize)] #[serde(deny_unknown_fields)] struct TerrainExpect { @@ -304,6 +306,9 @@ impl StepFile { if self.deterministic == Some(true) { steps.push(Step::Run(Command::Deterministic)); } + if let Some(arenas) = self.chunk_arenas { + steps.push(Step::Run(Command::ChunkArenas(arenas))); + } if let Some(hover) = self.hover { steps.push(Step::Run(Command::Hover(hover))); } @@ -693,16 +698,24 @@ fn check(expect: &Expect, phase: &AppPhase) -> Result<(), String> { ) } Expect::Terrain(TerrainExpect { terrain }) => { - if terrain != "settled" { - return Err(format!("unknown terrain state {terrain}")); - } + let compacted = match terrain.as_str() { + "settled" => false, + "compacted" => true, + _ => return Err(format!("unknown terrain state {terrain}")), + }; if let Some(pos) = game.first_unmeshed_column() { return Err(format!("{pos:?} isn't meshed yet")); } if let Some(pos) = gfx.renderer.starved_chunk_column() { return Err(format!("{pos:?}'s mesh doesn't fit the chunk pools")); } - gfx.renderer.chunk_slice_overlap().map_or(Ok(()), Err) + if let Some(overlap) = gfx.renderer.chunk_slice_overlap() { + return Err(overlap); + } + ensure( + !compacted || !gfx.renderer.chunk_arenas_compacting(), + || "the chunk arenas are still compacting".into(), + ) } Expect::Biome(BiomeExpect { biome, at }) => { let seen = protocol::biome(game, *at); diff --git a/pomme-client/src/renderer/chunk/buffer.rs b/pomme-client/src/renderer/chunk/buffer.rs index 35d5a0d3a..59579de93 100644 --- a/pomme-client/src/renderer/chunk/buffer.rs +++ b/pomme-client/src/renderer/chunk/buffer.rs @@ -11,19 +11,17 @@ use crate::position::{BlockPos, ChunkPos}; use crate::renderer::rhi::IndirectDrawFeatures; use crate::renderer::{MAX_FRAMES_IN_FLIGHT, rhi, shader, util}; -/// Vertices per arena: vanilla's 128 MiB vertex heap (`UberGpuBuffer`) at -/// the vanilla format's 16-byte vertex. -const ARENA_VERTICES: u32 = 8 << 20; -/// Indices per arena: six per four-vertex quad. -const ARENA_INDICES: u32 = ARENA_VERTICES / 2 * 3; +/// An arena of vanilla's 128 MiB vertex heap (`UberGpuBuffer`) at the +/// vanilla format's 16-byte vertex. +const ARENA_SIZE: ArenaSize = ArenaSize::new(8 << 20); const INDEX_SIZE: u64 = size_of::() as u64; /// Discrete GPUs batch a frame's uploads through the staging buffer in one /// transfer, so it holds several columns to keep sub-flushes rare. Sized for /// vanilla vertices; a shader pack's larger ones flush sooner. const STAGING_SIZE: u64 = 12 << 20; -/// Without `VK_EXT_memory_budget`, the share of device-local memory the -/// arenas may take. -const VRAM_BUDGET_FRACTION: f64 = 0.25; +/// Without `VK_EXT_memory_budget`, the share of the arenas' memory heaps +/// they may take. +const BUDGET_FRACTION: f64 = 0.25; /// With it, the least of the budget left for everything else. const MIN_BUDGET_HEADROOM: u64 = 512 << 20; /// Compaction starts once the arenas' contents would fit one fewer arena @@ -45,6 +43,28 @@ const INITIAL_META: usize = 16384; /// allows. const MAX_ARENAS: usize = 256; +/// One arena's vertex and index heap lengths. +#[derive(Clone, Copy, PartialEq, Eq)] +struct ArenaSize { + vertices: u32, + indices: u32, +} + +impl ArenaSize { + /// Room for `vertices`, and six indices per four-vertex quad. + const fn new(vertices: u32) -> Self { + Self { + vertices, + indices: vertices / 2 * 3, + } + } + + /// Both heaps in bytes, at `format`'s vertex size. + fn bytes(self, format: TerrainFormat) -> u64 { + u64::from(self.vertices) * format.vertex_size() + u64::from(self.indices) * INDEX_SIZE + } +} + /// The cull pass's bindings: meta, frustum, solid indirect/count, cutout /// indirect/count. const CULL_BINDINGS: &[rhi::BindGroupLayoutEntry] = &[ @@ -510,24 +530,29 @@ struct Arena { } impl Arena { - fn new(device: &rhi::backend::Device, use_staging: bool, format: TerrainFormat) -> Self { + fn new( + device: &rhi::backend::Device, + use_staging: bool, + format: TerrainFormat, + size: ArenaSize, + ) -> Self { Self { vertex_buffer: create_pool( device, use_staging, "vertex_arena", - u64::from(ARENA_VERTICES) * format.vertex_size(), + u64::from(size.vertices) * format.vertex_size(), rhi::BufferUsages::VERTEX, ), index_buffer: create_pool( device, use_staging, "index_arena", - u64::from(ARENA_INDICES) * INDEX_SIZE, + u64::from(size.indices) * INDEX_SIZE, rhi::BufferUsages::INDEX, ), - vertices: Tlsf::new(ARENA_VERTICES), - indices: Tlsf::new(ARENA_INDICES), + vertices: Tlsf::new(size.vertices), + indices: Tlsf::new(size.indices), doomed: None, } } @@ -618,8 +643,9 @@ pub struct ChunkBufferStore { staging_buffer: rhi::backend::Buffer, use_staging: bool, format: TerrainFormat, - /// Without `VK_EXT_memory_budget`, what the arenas' share is of. - device_local_bytes: u64, + arena_size: ArenaSize, + /// The most arenas there may be, whatever the budget. + max_arenas: usize, /// Columns whose sections found no room, or were evicted for nearer /// ones; handed back for re-meshing once there's room for them. starved: HashMap, @@ -700,16 +726,17 @@ impl ChunkBufferStore { rhi::MemoryLocation::Host, ); + let location = arena_location(use_staging); tracing::info!( - "Chunk arenas: {}, {} MB each; GPU VRAM {} MB, budget query {}", + "Chunk arenas: {}, {} MB each; their heaps {} MB, budget query {}", if use_staging { "DEVICE_LOCAL + staging" } else { "HOST_VISIBLE" }, - arena_bytes(format) / (1024 * 1024), - device.device_local_bytes / (1024 * 1024), - if device.device_local_budget().is_some() { + ARENA_SIZE.bytes(format) / (1024 * 1024), + device.memory_bytes(location) / (1024 * 1024), + if device.memory_budget(location).is_some() { "on" } else { "off" @@ -731,7 +758,8 @@ impl ChunkBufferStore { staging_buffer, use_staging, format, - device_local_bytes: device.device_local_bytes, + arena_size: ARENA_SIZE, + max_arenas: MAX_ARENAS, starved: HashMap::new(), retry_due: false, frontier_sq: f64::INFINITY, @@ -921,6 +949,7 @@ impl ChunkBufferStore { i_bytes, ); self.retire_sections(mesh.pos, &accepted); + self.drop_if_empty(mesh.pos); continue; } } @@ -990,15 +1019,8 @@ impl ChunkBufferStore { // instantly while a freshly revealed one still fades in. let was_present = self.retire_sections(mesh.pos, &accepted); if plans.is_empty() { - // Every accepted section is now empty; drop the column if - // nothing remains. - if self - .chunks - .get(&mesh.pos) - .is_some_and(|c| c.sections.is_empty()) - { - self.chunks.remove(&mesh.pos); - } + // Every accepted section is now empty. + self.drop_if_empty(mesh.pos); continue; } @@ -1126,6 +1148,13 @@ impl ChunkBufferStore { std::iter::once(&mut self.camera).chain(&mut self.shadow) } + /// Drops `pos`'s column once none of its sections remain. + fn drop_if_empty(&mut self, pos: ChunkPos) { + if self.chunks.get(&pos).is_some_and(|c| c.sections.is_empty()) { + self.chunks.remove(&pos); + } + } + /// Return one slice's vertex and index ranges to its arena. fn free_slice(&mut self, slice: Slice) { if let Some(arena) = self.arenas[slice.arena as usize].as_mut() { @@ -1152,32 +1181,34 @@ impl ChunkBufferStore { alloc_in(&mut self.arenas, self.draining, vertices, indices) } - /// Whether one more arena fits the budget: what `VK_EXT_memory_budget` - /// says this process may use, less headroom for everything else, or - /// without it a share of device-local memory. The first always fits. + /// Whether one more arena fits the budget of the heaps arenas go in: + /// what `VK_EXT_memory_budget` says this process may use, less headroom + /// for everything else, or without it a share of them. The first always + /// fits. fn can_add_arena(&self, device: &rhi::backend::Device) -> bool { let arenas = self.arena_count(); if arenas == 0 { return true; } - if arenas as usize >= MAX_ARENAS { + if arenas as usize >= self.max_arenas { return false; } - let bytes = arena_bytes(self.format); - match device.device_local_budget() { + let bytes = self.arena_size.bytes(self.format); + let location = arena_location(self.use_staging); + match device.memory_budget(location) { Some(budget) => { let headroom = MIN_BUDGET_HEADROOM.max(budget.budget / 8); budget.usage + bytes + headroom <= budget.budget } None => { (arenas + 1) * bytes - <= (self.device_local_bytes as f64 * VRAM_BUDGET_FRACTION) as u64 + <= (device.memory_bytes(location) as f64 * BUDGET_FRACTION) as u64 } } } fn add_arena(&mut self, device: &rhi::backend::Device) { - let arena = Arena::new(device, self.use_staging, self.format); + let arena = Arena::new(device, self.use_staging, self.format, self.arena_size); match self.arenas.iter().position(Option::is_none) { Some(id) => self.arenas[id] = Some(arena), None => self.arenas.push(Some(arena)), @@ -1196,31 +1227,34 @@ impl ChunkBufferStore { let arenas = self.arena_count(); tracing::info!( "Chunk arena {change}: {arenas} arenas, {} MB", - arenas * arena_bytes(self.format) / (1024 * 1024) + arenas * self.arena_size.bytes(self.format) / (1024 * 1024) ); } /// Evicts the columns farthest from the eye, all farther than `pos`, until /// they free what `pos` needs, so a full budget draws a shorter distance /// instead of leaving holes nearby. The evicted columns starve until - /// there's room for them again. + /// there's room for them again; when even all of them can't make room, + /// so does `pos`. fn evict_beyond(&mut self, pos: ChunkPos, need: Need) { let Some(eye) = self.eye else { return; }; + let dist = column_dist_sq(pos, eye); let loaded = self .chunks .iter() .map(|(&p, alloc)| (column_dist_sq(p, eye), p, alloc.need())); - let victims = eviction_victims(loaded, column_dist_sq(pos, eye), need); - let Some(&(nearest, _)) = victims.last() else { - return; + let (victims, freed) = eviction_victims(loaded, dist, need); + let frontier = match victims.last() { + Some(&(nearest, _)) if freed => nearest, + _ => dist, }; for (_, victim) in victims { self.evict(victim); tracing::debug!("Evicted {victim:?} for {pos:?}"); } - self.frontier_sq = self.frontier_sq.min(nearest); + self.frontier_sq = self.frontier_sq.min(frontier); } /// Drops `pos`'s mesh, starving the column until there's room for it. @@ -1300,6 +1334,7 @@ impl ChunkBufferStore { (free_vertices, free_indices), self.frontier_sq, farthest_loaded, + self.arena_size, ); self.frontier_sq = frontier_sq; for pos in retry { @@ -1420,10 +1455,7 @@ impl ChunkBufferStore { let from = match self.draining { Some(id) => id, None => { - let live = self.arenas.iter().enumerate().filter_map(|(id, slot)| { - live(slot).map(|a| (id as u16, a.vertices.used(), a.indices.used())) - }); - let Some(id) = arena_to_drain(live) else { + let Some(id) = arena_to_drain(self.live_use(), self.arena_size) else { return; }; tracing::debug!("Compacting chunk arena {id}"); @@ -1487,6 +1519,13 @@ impl ChunkBufferStore { self.meta_dirty = true; } + /// Each live arena as (id, vertices used, indices used). + fn live_use(&self) -> impl Iterator + '_ { + self.arenas.iter().enumerate().filter_map(|(id, slot)| { + live(slot).map(|a| (id as u16, a.vertices.used(), a.indices.used())) + }) + } + pub fn remove(&mut self, pos: &ChunkPos) { self.starved.remove(pos); if let Some(alloc) = self.chunks.remove(pos) { @@ -1503,6 +1542,11 @@ impl ChunkBufferStore { return; } self.format = format; + self.drop_arenas(device); + } + + /// Destroys every arena; the next upload makes them anew. + fn drop_arenas(&mut self, device: &rhi::backend::Device) { for arena in self.arenas.drain(..).flatten() { arena.destroy(device); } @@ -1510,6 +1554,28 @@ impl ChunkBufferStore { self.shadow_meta.clear(); } + /// Arenas of `vertices`, at most `max` of them, so the harness fills + /// them at a small render distance. A new size drops every mesh as + /// [`Self::set_format`] does; returns whether it did. The caller waits + /// for in-flight frames first. + #[cfg(feature = "test-harness")] + pub fn set_arena_limits( + &mut self, + device: &rhi::backend::Device, + vertices: u32, + max: usize, + ) -> bool { + self.max_arenas = max.clamp(1, MAX_ARENAS); + let size = ArenaSize::new(vertices); + if size == self.arena_size { + return false; + } + self.arena_size = size; + self.clear(); + self.drop_arenas(device); + true + } + pub fn format(&self) -> TerrainFormat { self.format } @@ -1531,10 +1597,22 @@ impl ChunkBufferStore { self.fade_enabled = false; } - /// A column not drawing its newest mesh, or none, for lack of room. + /// A column not drawing its newest mesh, or none, for lack of room, + /// nearer than the eviction frontier: one that should fit. #[cfg(feature = "test-harness")] pub fn starved_column(&self) -> Option { - self.starved.keys().next().copied() + self.starved + .keys() + .find(|&&pos| !self.beyond_frontier(pos)) + .copied() + } + + /// Compaction has an arena to drain, or a drained one to free. + #[cfg(feature = "test-harness")] + pub fn compacting(&self) -> bool { + self.draining.is_some() + || self.arenas.iter().flatten().any(|a| a.doomed.is_some()) + || arena_to_drain(self.live_use(), self.arena_size).is_some() } /// Where an arena's ranges overlap, if they do: live sections, slices @@ -1881,9 +1959,7 @@ impl ChunkBufferStore { } pub fn destroy(&mut self, device: &rhi::backend::Device) { - for arena in self.arenas.drain(..).flatten() { - arena.destroy(device); - } + self.drop_arenas(device); std::mem::take(&mut self.staging_buffer).destroy(device); for list in self.draw_lists_mut() { list.destroy(device); @@ -1896,19 +1972,15 @@ impl ChunkBufferStore { } } -/// One arena's vertex and index heaps in bytes, at `format`'s vertex size. -fn arena_bytes(format: TerrainFormat) -> u64 { - u64::from(ARENA_VERTICES) * format.vertex_size() + u64::from(ARENA_INDICES) * INDEX_SIZE -} - /// Of the `loaded` columns, as (distance from the eye squared, position, /// what they take), those to evict for `need`: the farthest first, all -/// farther than `limit`, until they free it. Nearest last. +/// farther than `limit`, until they free it, nearest last; and whether they +/// do. fn eviction_victims( loaded: impl Iterator, limit: f64, need: Need, -) -> Vec<(f64, ChunkPos)> { +) -> (Vec<(f64, ChunkPos)>, bool) { let mut far: Vec<_> = loaded.filter(|&(dist, ..)| dist > limit).collect(); far.sort_unstable_by(|a, b| b.0.total_cmp(&a.0)); let mut freed = Need::default(); @@ -1921,7 +1993,7 @@ fn eviction_victims( freed.indices += taken.indices; victims.push((dist, pos)); } - victims + (victims, need.fits_in(freed.vertices, freed.indices)) } /// A starved column, for [`columns_to_retry`]. @@ -1937,15 +2009,17 @@ struct Waiting { /// The starved columns to hand back, nearest first, and the eviction /// frontier after. Inside the frontier a column comes back when it fits the /// free vertices and indices, or when a loaded column farther out (the -/// farthest is at `farthest_loaded`) can be evicted for it. Past it, only -/// while [`FRONTIER_SLACK`] of an arena stays free after, moving the frontier -/// out past it, so the frontier doesn't swing back and forth as columns come -/// and go. +/// farthest is at `farthest_loaded`) can be evicted for it; either way the +/// free room is its, so no farther column takes it first. Past the frontier, +/// only while [`FRONTIER_SLACK`] of an arena stays free after, moving the +/// frontier out past it, so the frontier doesn't swing back and forth as +/// columns come and go. fn columns_to_retry( waiting: impl Iterator, (mut free_vertices, mut free_indices): (u64, u64), mut frontier_sq: f64, farthest_loaded: f64, + arena: ArenaSize, ) -> (Vec, f64) { let mut waiting: Vec = waiting.collect(); waiting.sort_unstable_by(|a, b| a.dist.total_cmp(&b.dist)); @@ -1954,8 +2028,8 @@ fn columns_to_retry( let past = column.dist >= frontier_sq; let (keep_vertices, keep_indices) = if past { ( - u64::from(ARENA_VERTICES) / FRONTIER_SLACK, - u64::from(ARENA_INDICES) / FRONTIER_SLACK, + u64::from(arena.vertices) / FRONTIER_SLACK, + u64::from(arena.indices) / FRONTIER_SLACK, ) } else { (0, 0) @@ -1965,18 +2039,20 @@ fn columns_to_retry( free_vertices.saturating_sub(keep_vertices), free_indices.saturating_sub(keep_indices), ); - if fits { - free_vertices -= column.need.vertices; - free_indices -= column.need.indices; + if !fits { if past { - frontier_sq = column.dist.next_up(); + break; } - retry.push(column.pos); - } else if past { - break; - } else if column.dist < farthest_loaded { - retry.push(column.pos); + if column.dist >= farthest_loaded { + continue; + } + } + free_vertices = free_vertices.saturating_sub(column.need.vertices); + free_indices = free_indices.saturating_sub(column.need.indices); + if past { + frontier_sq = column.dist.next_up(); } + retry.push(column.pos); } (retry, frontier_sq) } @@ -1984,7 +2060,7 @@ fn columns_to_retry( /// The arena compaction should drain, of the `live` ones as (id, vertices /// used, indices used): the emptiest, once the others could hold everything /// filled to [`COMPACT_FILL`]. -fn arena_to_drain(live: impl Iterator) -> Option { +fn arena_to_drain(live: impl Iterator, arena: ArenaSize) -> Option { let live: Vec<_> = live.collect(); if live.len() < 2 { return None; @@ -1992,16 +2068,26 @@ fn arena_to_drain(live: impl Iterator) -> Option { let used_vertices: u64 = live.iter().map(|&(_, v, _)| u64::from(v)).sum(); let used_indices: u64 = live.iter().map(|&(.., i)| u64::from(i)).sum(); let room = (live.len() - 1) as f64 * COMPACT_FILL; - if used_vertices as f64 > room * f64::from(ARENA_VERTICES) - || used_indices as f64 > room * f64::from(ARENA_INDICES) + if used_vertices as f64 > room * f64::from(arena.vertices) + || used_indices as f64 > room * f64::from(arena.indices) { return None; } live.iter().min_by_key(|&&(_, v, _)| v).map(|&(id, ..)| id) } -/// An arena heap: device-local behind the staging buffer, or host-visible. -/// Either way a copy source and target, for compaction. +/// Where arenas live: device-local behind the staging buffer, or +/// host-visible. +fn arena_location(use_staging: bool) -> rhi::MemoryLocation { + if use_staging { + rhi::MemoryLocation::Device + } else { + rhi::MemoryLocation::Host + } +} + +/// An arena heap, in [`arena_location`]; a copy source and target, for +/// compaction. fn create_pool( device: &rhi::backend::Device, use_staging: bool, @@ -2010,10 +2096,11 @@ fn create_pool( usage: rhi::BufferUsages, ) -> rhi::backend::Buffer { let usage = usage | rhi::BufferUsages::COPY_SRC | rhi::BufferUsages::COPY_DST; + let location = arena_location(use_staging); if use_staging { - rhi::backend::Buffer::dedicated(device, label, size, usage, rhi::MemoryLocation::Device) + rhi::backend::Buffer::dedicated(device, label, size, usage, location) } else { - rhi::backend::Buffer::new(device, label, size, usage, rhi::MemoryLocation::Host) + rhi::backend::Buffer::new(device, label, size, usage, location) } } @@ -2272,18 +2359,21 @@ mod tests { (4.0, ChunkPos::new(2, 0), need(100)), (16.0, ChunkPos::new(4, 0), need(100)), ]; - let victims = eviction_victims(loaded.into_iter(), 2.0, need(150)); + let far = vec![(16.0, ChunkPos::new(4, 0)), (9.0, ChunkPos::new(3, 0))]; + assert_eq!( + eviction_victims(loaded.into_iter(), 2.0, need(150)), + (far.clone(), true) + ); + // Never one nearer than the column it's for, even when the farther + // ones can't free enough. assert_eq!( - victims, - [(16.0, ChunkPos::new(4, 0)), (9.0, ChunkPos::new(3, 0))] + eviction_victims(loaded.into_iter(), 8.0, need(1000)), + (far, false) ); - // Never one nearer than the column it's for. - let victims = eviction_victims(loaded.into_iter(), 8.0, need(1000)); assert_eq!( - victims, - [(16.0, ChunkPos::new(4, 0)), (9.0, ChunkPos::new(3, 0))] + eviction_victims(loaded.into_iter(), 0.0, Need::default()), + (vec![], true) ); - assert!(eviction_victims(loaded.into_iter(), 0.0, Need::default()).is_empty()); } fn waiting(dist: f64, x: i32, vertices: u64) -> Waiting { @@ -2305,17 +2395,36 @@ mod tests { free, 100.0, 9.0, + ARENA_SIZE, ); assert_eq!(retry, [ChunkPos::new(1, 0), ChunkPos::new(2, 0)]); assert_eq!(frontier, 100.0); + // One coming back for eviction keeps the free room from a farther + // one, past the frontier, that would fit it otherwise. + let slack = u64::from(ARENA_SIZE.vertices) / FRONTIER_SLACK; + let (retry, frontier) = columns_to_retry( + [waiting(4.0, 2, slack + 11), waiting(200.0, 9, 1)].into_iter(), + (slack + 10, u64::MAX), + 100.0, + 300.0, + ARENA_SIZE, + ); + assert_eq!(retry, [ChunkPos::new(2, 0)]); + assert_eq!(frontier, 100.0); // Nothing loaded farther out, so it waits. - let (retry, _) = columns_to_retry([waiting(4.0, 2, 500)].into_iter(), free, 100.0, 1.0); + let (retry, _) = columns_to_retry( + [waiting(4.0, 2, 500)].into_iter(), + free, + 100.0, + 1.0, + ARENA_SIZE, + ); assert!(retry.is_empty()); } #[test] fn starved_columns_past_the_frontier_need_slack() { - let slack = u64::from(ARENA_VERTICES) / FRONTIER_SLACK; + let slack = u64::from(ARENA_SIZE.vertices) / FRONTIER_SLACK; let free = (slack + 100, u64::MAX); let (retry, frontier) = columns_to_retry( [ @@ -2327,6 +2436,7 @@ mod tests { free, 16.0, f64::INFINITY, + ARENA_SIZE, ); // The first fits with the slack left and moves the frontier past it; // the second doesn't, and nothing farther comes back. @@ -2336,15 +2446,10 @@ mod tests { #[test] fn compaction_drains_the_emptiest_once_the_rest_hold_it() { - let quarter = ARENA_VERTICES / 4; - assert_eq!(arena_to_drain([(0, quarter, 0)].into_iter()), None); - assert_eq!( - arena_to_drain([(0, quarter * 2, 0), (3, quarter / 2, 0)].into_iter()), - Some(3) - ); - assert_eq!( - arena_to_drain([(0, quarter * 3, 0), (3, quarter, 0)].into_iter()), - None - ); + let quarter = ARENA_SIZE.vertices / 4; + let drain = |live: &[(u16, u32, u32)]| arena_to_drain(live.iter().copied(), ARENA_SIZE); + assert_eq!(drain(&[(0, quarter, 0)]), None); + assert_eq!(drain(&[(0, quarter * 2, 0), (3, quarter / 2, 0)]), Some(3)); + assert_eq!(drain(&[(0, quarter * 3, 0), (3, quarter, 0)]), None); } } diff --git a/pomme-client/src/renderer/mod.rs b/pomme-client/src/renderer/mod.rs index 504434e89..f856a087c 100644 --- a/pomme-client/src/renderer/mod.rs +++ b/pomme-client/src/renderer/mod.rs @@ -1305,6 +1305,20 @@ impl Renderer { self.chunk_buffers.starved_column() } + /// See [`ChunkBufferStore::compacting`]. + #[cfg(feature = "test-harness")] + pub fn chunk_arenas_compacting(&self) -> bool { + self.chunk_buffers.compacting() + } + + /// See [`ChunkBufferStore::set_arena_limits`]. + #[cfg(feature = "test-harness")] + pub fn set_chunk_arena_limits(&mut self, vertices: u32, max: usize) -> bool { + self.wait_for_all_frames(); + self.chunk_buffers + .set_arena_limits(&self.ctx.device, vertices, max) + } + /// Sections actually drawn after frustum culling (lags a few frames). The /// graph's occluded sections are omitted before the cull, so this also /// drops when occlusion hides geometry — useful for the F3 overlay. diff --git a/pomme-client/src/renderer/rhi/vulkan/mod.rs b/pomme-client/src/renderer/rhi/vulkan/mod.rs index 36e20add2..226519b64 100644 --- a/pomme-client/src/renderer/rhi/vulkan/mod.rs +++ b/pomme-client/src/renderer/rhi/vulkan/mod.rs @@ -66,8 +66,6 @@ pub struct Device { command_pool: vk::CommandPool, targets: RenderTargets, bind_groups: BindGroupPools, - /// The largest heap of GPU-local memory, in bytes. - pub device_local_bytes: u64, /// A GPU with memory of its own, which uploads reach through a copy. pub discrete: bool, pub max_texture_array_layers: u32, @@ -77,11 +75,13 @@ pub struct Device { physical_device: vk::PhysicalDevice, /// `VK_EXT_memory_budget` is enabled. memory_budget: bool, - /// The heap `device_local_bytes` measures. - device_local_heap: usize, + /// Each memory type's flags and heap. + memory_types: Vec<(vk::MemoryPropertyFlags, usize)>, + /// Each heap's size, in bytes. + heap_sizes: Vec, } -/// A memory heap's budget for this process and what it uses of it, in bytes. +/// Memory heaps' budget for this process and what it uses of them, in bytes. #[derive(Clone, Copy, Debug)] pub struct MemoryBudget { pub budget: u64, @@ -98,19 +98,14 @@ impl Device { memory_budget: bool, ) -> Self { let memory = physical_device.get_memory_properties(); - let (device_local_heap, device_local_bytes) = memory.memory_types - [..memory.memory_type_count as usize] + let memory_types = memory.memory_types[..memory.memory_type_count as usize] .iter() - .filter(|ty| { - ty.property_flags - .contains(vk::MemoryPropertyFlags::DeviceLocal) - }) - .map(|ty| { - let heap = ty.heap_index as usize; - (heap, memory.memory_heaps[heap].size) - }) - .max_by_key(|&(_, size)| size) - .unwrap_or((0, 0)); + .map(|ty| (ty.property_flags, ty.heap_index as usize)) + .collect(); + let heap_sizes = memory.memory_heaps[..memory.memory_heap_count as usize] + .iter() + .map(|heap| heap.size) + .collect(); let properties = physical_device.get_properties(); Self { raw, @@ -119,20 +114,54 @@ impl Device { command_pool, targets: RenderTargets::default(), bind_groups: BindGroupPools::default(), - device_local_bytes, discrete: properties.device_type == vk::PhysicalDeviceType::DiscreteGpu, max_texture_array_layers: properties.limits.max_image_array_layers, max_texture_size: properties.limits.max_image_dimension2_d, depth_clamp: physical_device.get_features().depth_clamp == vk::TRUE, physical_device: *physical_device, memory_budget, - device_local_heap, + memory_types, + heap_sizes, } } - /// The device-local heap's budget and use right now, from - /// `VK_EXT_memory_budget`; `None` without it. - pub fn device_local_budget(&self) -> Option { + /// The heaps the allocator may put `location` memory in: the first + /// memory type with the flags it prefers, and for host memory the first + /// with the ones it falls back to, as an APU's small VRAM carve-out + /// fills and system memory takes over. + fn heaps(&self, location: MemoryLocation) -> Vec { + use vk::MemoryPropertyFlags as Flags; + let first = |flags: Flags| { + self.memory_types + .iter() + .find(|(ty, _)| ty.contains(flags)) + .map(|&(_, heap)| heap) + }; + let host = Flags::HostVisible | Flags::HostCoherent; + let mut heaps: Vec = match location { + MemoryLocation::Device => vec![first(Flags::DeviceLocal)], + MemoryLocation::Host => vec![first(host | Flags::DeviceLocal), first(host)], + MemoryLocation::Readback => vec![first(host | Flags::HostCached), first(host)], + } + .into_iter() + .flatten() + .collect(); + heaps.sort_unstable(); + heaps.dedup(); + heaps + } + + /// The size of the heaps `location` memory may go in, in bytes. + pub fn memory_bytes(&self, location: MemoryLocation) -> u64 { + self.heaps(location) + .into_iter() + .map(|heap| self.heap_sizes[heap]) + .sum() + } + + /// The budget and use right now of the heaps `location` memory may go + /// in, from `VK_EXT_memory_budget`; `None` without it. + pub fn memory_budget(&self, location: MemoryLocation) -> Option { if !self.memory_budget { return None; } @@ -148,9 +177,10 @@ impl Device { }; // SAFETY: both structs outlive the call, which only writes them. unsafe { call(self.physical_device.handle(), &raw mut properties) }; + let heaps = self.heaps(location); Some(MemoryBudget { - budget: budget.heap_budget[self.device_local_heap], - usage: budget.heap_usage[self.device_local_heap], + budget: heaps.iter().map(|&heap| budget.heap_budget[heap]).sum(), + usage: heaps.iter().map(|&heap| budget.heap_usage[heap]).sum(), }) } diff --git a/tests/ingame/README.md b/tests/ingame/README.md index de077c222..6710948f1 100644 --- a/tests/ingame/README.md +++ b/tests/ingame/README.md @@ -153,6 +153,12 @@ deterministic = true # holds flicker, particles, # bobbing and the autosave # indicator still for the rest of # the script +[[step]] +chunk_arenas = { vertices = 65536, max = 1 } # chunk arenas this small, this + # many at most, so a small render + # distance fills them; a new size + # remeshes everything (no-op on + # vanilla) [[step]] wait_ms = 1000 @@ -183,7 +189,7 @@ An `expect` is retried every frame until it holds or times out. It can check: | A chat line | `chat` (text it contains) | | A block | `block` (state name, e.g. `air`), `at`, optional `block_entity` (type) | | A biome | `biome` (name, e.g. `swamp`), `at` | -| Settled terrain | `terrain = "settled"`: every loaded column meshed and uploaded (none waiting for pool space), and no two of the chunk pools' ranges overlapping | +| Settled terrain | `terrain = "settled"`: every loaded column meshed and uploaded (none nearer than the eviction frontier waiting for pool space), and no two of the chunk pools' ranges overlapping; `terrain = "compacted"` also waits for no chunk arena left to drain or free | | The local player | `player = { ... }` with any of `health`, `food`, `level`, `game_mode`, `effect`, `no_effect`, `max_health`, `raining`, `hand`, `dimension`, `day_time`, `position` (within 0.01), `y_rot` (within 0.01), `spectating` (the kind of entity the camera follows, empty for none) and `post_effect` (the spectator view effect drawn: `creeper`, `spider`, `invert`, or empty) | | The open menu | `menu` (a container's menu type, e.g. `generic_9x3`, or `inventory`, `creative`, `pause`, `none`), optional `title`; on the creative screen also `tab` (its vanilla id, e.g. `building_blocks`), `row` (the first catalog row shown) and `search` | | The HUD text | `hud = { ... }` with any of `title`, `subtitle` and `sidebar_title` (`""` when none is shown), `boss_bars` (every name, top to bottom), `sidebar` (every row as `"name number"`, top to bottom) and `tab_list` (every listed player as `{ name, game_mode? }`, in tab order) | diff --git a/tests/ingame/world/chunk_arenas.toml b/tests/ingame/world/chunk_arenas.toml new file mode 100644 index 000000000..d008ddad9 --- /dev/null +++ b/tests/ingame/world/chunk_arenas.toml @@ -0,0 +1,71 @@ +# Chunk arenas too small for the render distance: the farthest columns are +# evicted and wait while every nearer one draws, come back once arenas can be +# added, and compaction drains what a lower distance leaves empty. The arena +# steps do nothing on vanilla. + +[[step]] +wait_ms = 2000 + +[[step]] +chat = "/gamemode creative" + +[[step]] +chat = "/gamerule advance_time false" + +[[step]] +chat = "/time set 6000" + +# A pillar to look out from. +[[step]] +chat = "/fill 0 -60 0 0 -51 0 minecraft:stone" + +[[step]] +chat = "/tp @s 0.5 -50 0.5 0 20" + +[[step]] +deterministic = true + +[[step]] +option = { render_distance = 4 } + +[[step]] +chunk_arenas = { vertices = 32768, max = 1 } + +[[step]] +expect = { terrain = "settled" } +timeout_ms = 30000 + +[[step]] +chunk_arenas = { vertices = 32768, max = 256 } + +[[step]] +expect = { terrain = "settled" } +timeout_ms = 30000 + +# Chat fades after 10 seconds. +[[step]] +wait_ms = 11000 + +[[step]] +screenshot = "far" + +[[step]] +option = { render_distance = 2 } + +# The far columns unload over a few ticks. +[[step]] +wait_ms = 3000 + +[[step]] +expect = { terrain = "compacted" } +timeout_ms = 30000 + +# Back to the default, which the server's 4 caps. +[[step]] +option = { render_distance = 12 } + +[[step]] +chat = "/fill 0 -60 0 0 -51 0 minecraft:air" + +[[step]] +chat = "/gamerule advance_time true" diff --git a/tests/ingame/world/render_distance.toml b/tests/ingame/world/render_distance.toml index 296c4efc7..a20c366f3 100644 --- a/tests/ingame/world/render_distance.toml +++ b/tests/ingame/world/render_distance.toml @@ -46,9 +46,10 @@ option = { render_distance = 4 } expect = { terrain = "settled" } timeout_ms = 30000 -# Vanilla's sections can report compiled before its chunks are back. +# Vanilla's sections can report compiled before its chunks are back, and +# chat fades after 10 seconds. [[step]] -wait_ms = 3000 +wait_ms = 11000 [[step]] screenshot = "far" From 7282b047b6f442764b64efebf564b5dad399fea2 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:35:19 +0000 Subject: [PATCH 09/67] follow distant horizons for detail distances, parent fallback, micro offset, side light, cave fill and waterlogged colours --- pomme-client/src/renderer/distant/colors.rs | 33 ++++---- pomme-client/src/renderer/distant/column.rs | 45 +++++++---- pomme-client/src/renderer/distant/gpu.rs | 67 +++++++++------- pomme-client/src/renderer/distant/mesh.rs | 76 ++++++++++++++----- pomme-client/src/renderer/distant/store.rs | 23 ++++-- pomme-client/src/renderer/distant/worker.rs | 14 +++- .../src/renderer/shaders/distant.vert | 13 +++- 7 files changed, 185 insertions(+), 86 deletions(-) diff --git a/pomme-client/src/renderer/distant/colors.rs b/pomme-client/src/renderer/distant/colors.rs index d3e627120..c9994a588 100644 --- a/pomme-client/src/renderer/distant/colors.rs +++ b/pomme-client/src/renderer/distant/colors.rs @@ -69,22 +69,14 @@ impl DistantColors { return HIDDEN; }; let data = table.block_state_data(state); - let (top, side, tint, tint_sides) = match data.fluid().kind { - FluidKind::Water => { - let water = color("water_still"); - (water, water, Tint::Water, true) - } - FluidKind::Lava => { - let lava = color("lava_still"); - (lava, lava, Tint::None, false) - } - FluidKind::Empty => { - if data.is_invisible() || !data.has_collision() { - return HIDDEN; - } - let Some(faces) = view.get_textures(state) else { - return HIDDEN; - }; + // A solid block shows as itself, waterlogged or not; a fluid + // shows where nothing solid holds it, seagrass and kelp + // as water (Distant Horizons' `FullDataToRenderDataTransformer`). + let solid = (!data.is_invisible() && data.has_collision()) + .then(|| view.get_textures(state)) + .flatten(); + let (top, side, tint, tint_sides) = match (solid, data.fluid().kind) { + (Some(faces), _) => { let fixed = match faces.tint { Tint::Constant(rgb) => Some(int_to_rgb(rgb as i32)), Tint::Redstone => Some(data.redstone_wire_rgb()), @@ -102,6 +94,15 @@ impl DistantColors { }; (top, side, tint, faces.side_overlay.is_none()) } + (None, FluidKind::Water) => { + let water = color("water_still"); + (water, water, Tint::Water, true) + } + (None, FluidKind::Lava) => { + let lava = color("lava_still"); + (lava, lava, Tint::None, false) + } + (None, FluidKind::Empty) => return HIDDEN, }; StateLook { shows: Shows::Yes { diff --git a/pomme-client/src/renderer/distant/column.rs b/pomme-client/src/renderer/distant/column.rs index ebc777cce..92f18867b 100644 --- a/pomme-client/src/renderer/distant/column.rs +++ b/pomme-client/src/renderer/distant/column.rs @@ -51,6 +51,16 @@ pub(crate) fn build_column( shows: impl Fn(BlockState) -> Shows, fills: impl Fn(BlockState) -> bool, ) -> Vec { + // Sky and block light `y` above `min_y`. + let light_at = |y: i32| { + light.map_or((15, 0), |light| { + let world_y = y + min_y; + ( + light.get_sky_light(x as i32, world_y, z as i32), + light.get_block_light(x as i32, world_y, z as i32), + ) + }) + }; let mut runs: Vec = Vec::new(); let mut extending = false; for (index, section) in chunk.sections.iter().enumerate().rev() { @@ -78,21 +88,25 @@ pub(crate) fn build_column( run.bottom = y as u16; continue; } - let world_y = y + min_y + 1; - let (sky, block) = light.map_or((15, 0), |light| { - ( - light.get_sky_light(x as i32, world_y, z as i32), - light.get_block_light(x as i32, world_y, z as i32), - ) - }); + let (sky, block) = light_at(y + 1); if sky == 0 && fills(state) { - runs.push(Run { - key, - top: y as u16, - bottom: 0, - sky, - block, + // A dark cave under a roof fills in, the roof reaching down + // over it (Distant Horizons' cave culling); else this run + // reaches the bottom. + let roof = runs.last_mut().filter(|roof| { + let gap_top = i32::from(roof.bottom) - 1; + gap_top > y && fills(roof.key.state) && light_at(gap_top).0 == 0 }); + match roof { + Some(roof) => roof.bottom = 0, + None => runs.push(Run { + key, + top: y as u16, + bottom: 0, + sky, + block, + }), + } return runs; } runs.push(Run { @@ -291,6 +305,11 @@ mod tests { let chunk = column_of(&[(40, dirt()), (30, stone()), (29, stone()), (10, dirt())]); let runs = build_column(&chunk, Some(&light), 0, 0, -64, shows, fills); assert_eq!(runs, [run(dirt(), 40, 40, 15), run(stone(), 30, 0, 0)]); + + // A cave dark to its roof: the roof reaches down over it instead. + let chunk = column_of(&[(40, dirt()), (31, stone()), (20, stone()), (10, dirt())]); + let runs = build_column(&chunk, Some(&light), 0, 0, -64, shows, fills); + assert_eq!(runs, [run(dirt(), 40, 40, 15), run(stone(), 31, 0, 15)]); } #[test] diff --git a/pomme-client/src/renderer/distant/gpu.rs b/pomme-client/src/renderer/distant/gpu.rs index d5ce57c1a..9a2941ff5 100644 --- a/pomme-client/src/renderer/distant/gpu.rs +++ b/pomme-client/src/renderer/distant/gpu.rs @@ -48,7 +48,8 @@ impl Arena { } } -/// A section's mesh in an arena, and the height it spans. +/// A section's mesh in an arena, and the height it spans; no vertices for +/// a section with nothing in it. struct Uploaded { arena: usize, first_vertex: u32, @@ -124,7 +125,9 @@ impl DistantBuffers { } fn retire(&mut self, key: SectionKey) { - if let Some(old) = self.sections.remove(&key) { + if let Some(old) = self.sections.remove(&key) + && old.vertices > 0 + { let at = self.frame_seq + MAX_FRAMES_IN_FLIGHT as u64; self.pending_free .push_back((at, old.arena, old.first_vertex)); @@ -191,17 +194,19 @@ impl DistantBuffers { for mesh in update.meshes { self.retire(mesh.key); let count = mesh.vertices.len() as u32; - if count == 0 { - continue; - } - let Some((arena, first_vertex)) = self.alloc(device, count) else { - tracing::warn!("No room for distant terrain section {:?}", mesh.key); - continue; + let (arena, first_vertex) = if count == 0 { + (0, 0) + } else { + let Some((arena, first_vertex)) = self.alloc(device, count) else { + tracing::warn!("No room for distant terrain section {:?}", mesh.key); + continue; + }; + self.arenas[arena].buffer.write_at( + (u64::from(first_vertex) * VERTEX_SIZE) as usize, + bytemuck::cast_slice(&mesh.vertices), + ); + (arena, first_vertex) }; - self.arenas[arena].buffer.write_at( - (u64::from(first_vertex) * VERTEX_SIZE) as usize, - bytemuck::cast_slice(&mesh.vertices), - ); self.sections.insert( mesh.key, Uploaded { @@ -215,20 +220,25 @@ impl DistantBuffers { } } - /// Whether the finest section holding block column `x`/`z` is uploaded. + /// Whether the finest section holding block column `x`/`z` is uploaded + /// with something in it. #[cfg(feature = "test-harness")] pub fn has_column(&self, x: i32, z: i32) -> bool { let width = super::store::SECTION_COLUMNS; - self.sections.contains_key(&SectionKey { + let key = SectionKey { level: 0, x: x.div_euclid(width), z: z.div_euclid(width), - }) + }; + self.sections + .get(&key) + .is_some_and(|section| section.vertices > 0) } /// The sections to draw from `eye` out to `radius` blocks: each at the - /// coarsest level its distance allows, finer ones where they're - /// uploaded. + /// finest level its distance allows, or its parent's until all four + /// children in range are uploaded (Distant Horizons' + /// `LodQuadTree.onDetailLevelTooLow`). fn visible(&self, eye: DVec3, radius: f64) -> Vec { let mut out = Vec::new(); let mut stack: Vec = self @@ -241,17 +251,15 @@ impl DistantBuffers { if key.distance(eye.x, eye.z) > radius { continue; } - let uploaded: Vec = key - .children() - .filter(|_| key.splits(eye.x, eye.z)) - .into_iter() - .flatten() - .filter(|child| self.sections.contains_key(child)) - .collect(); - if uploaded.is_empty() { - out.push(key); - } else { - stack.extend(uploaded); + let ready = key.children().filter(|children| { + key.splits(eye.x, eye.z) + && children.iter().all(|child| { + self.sections.contains_key(child) || child.distance(eye.x, eye.z) > radius + }) + }); + match ready { + Some(children) => stack.extend(children), + None => out.push(key), } } out @@ -284,6 +292,9 @@ impl DistantBuffers { let mut bound = None; for key in visible { let section = &self.sections[&key]; + if section.vertices == 0 { + continue; + } let (ox, oz) = key.origin(); let origin = DVec3::new(f64::from(ox), f64::from(self.min_y), f64::from(oz)); let width = f64::from(key.width()); diff --git a/pomme-client/src/renderer/distant/mesh.rs b/pomme-client/src/renderer/distant/mesh.rs index 818a128ee..556aa0c2c 100644 --- a/pomme-client/src/renderer/distant/mesh.rs +++ b/pomme-client/src/renderer/distant/mesh.rs @@ -17,8 +17,9 @@ pub(crate) struct DistantVertex { pub y: u16, /// Sky light in the high nibble, block light in the low. pub light: u8, - /// Which way the face points ([`Face`]), so the shaders can tell - /// whose column it is. + /// Which way the face points ([`Face`]) in the low 3 bits, so the + /// shaders can tell whose column it is; then which way the corner moves + /// out along `x` (bits 3-4) and `z` (5-6): 1 for positive, 3 negative. pub face: u8, pub color: [u8; 4], } @@ -64,6 +65,18 @@ pub(crate) struct SectionMesh { pub max_y: u16, } +impl SectionMesh { + /// A section with nothing seen in it. + pub fn empty(key: SectionKey) -> Self { + Self { + key, + vertices: Vec::new(), + min_y: 0, + max_y: 0, + } + } +} + /// Top or bottom faces alike in height, colour and light, as a bit per /// column: bit `x` of row `z`. type FlatFaces = HashMap<(Face, u32, [u8; 3], u8), [u64; SECTION_COLUMNS as usize]>; @@ -110,7 +123,7 @@ pub(crate) fn mesh_section( flat_face(Face::Down, bottom); } for (&(face, ..), neighbour) in SIDES.iter().zip(&neighbours) { - for (low, high) in uncovered(bottom, top, neighbour) { + for (low, high, sky) in uncovered(bottom, top, neighbour) { let corners = match face { Face::North => { [[x0, low, z0], [x0, high, z0], [x1, high, z0], [x1, low, z0]] @@ -125,6 +138,7 @@ pub(crate) fn mesh_section( [[x1, low, z0], [x1, high, z0], [x1, high, z1], [x1, low, z1]] } }; + let light = sky.map_or(light, |sky| sky << 4 | run.block); push_quad(&mut vertices, corners, light, face, side_rgb); } } @@ -172,9 +186,12 @@ fn rectangles(rows: &mut [u64; SECTION_COLUMNS as usize]) -> Vec<(u32, u32, u32, } /// The parts of `[low, high)` none of `neighbour`'s runs cover, highest -/// first. A neighbour outside the section has no runs, so the section's -/// border faces always draw: a coarser or missing neighbour can't hide them. -fn uncovered(low: u32, high: u32, neighbour: &[Run]) -> Vec<(u32, u32)> { +/// first, each with the sky light of the air it faces: that over the +/// neighbour's run under it, as Distant Horizons lights a side under an +/// overhang, or `None` with no run under it. A neighbour outside the +/// section has no runs, so the section's border faces always draw: a +/// coarser or missing neighbour can't hide them. +fn uncovered(low: u32, high: u32, neighbour: &[Run]) -> Vec<(u32, u32, Option)> { let mut open = Vec::new(); let mut cursor = high; // Runs go top to bottom, so walk down. @@ -183,19 +200,16 @@ fn uncovered(low: u32, high: u32, neighbour: &[Run]) -> Vec<(u32, u32)> { if run_low >= cursor { continue; } - if run_high <= low { - break; - } if run_high < cursor { - open.push((run_high, cursor)); + open.push((run_high.max(low), cursor, Some(run.sky))); } - cursor = run_low.max(low); + cursor = run_low; if cursor <= low { return open; } } if cursor > low { - open.push((low, cursor)); + open.push((low, cursor, None)); } open } @@ -209,11 +223,25 @@ fn push_quad( rgb: [u8; 3], ) { let [r, g, b] = rgb.map(|c| (f32::from(c) * face.shade()).round() as u8); + // Each corner moves out along the quad's own plane, closing pinholes + // where quads meet (Distant Horizons' `LodQuadBuilder` micro offset). + let micro_offset = |axis: usize, value: u32| { + let (min, max) = corners.iter().fold((u32::MAX, 0), |(lo, hi), c| { + (lo.min(c[axis]), hi.max(c[axis])) + }); + if min == max { + 0 + } else if value == min { + 3 + } else { + 1 + } + }; vertices.extend(corners.map(|[x, y, z]| DistantVertex { xz: x | z << 16, y: y as u16, light, - face: face as u8, + face: face as u8 | micro_offset(0, x) << 3 | micro_offset(2, z) << 5, color: [r, g, b, 255], })); } @@ -225,6 +253,10 @@ mod tests { use crate::renderer::distant::store::Store; fn run(top: u16, bottom: u16) -> Run { + lit(top, bottom, 15) + } + + fn lit(top: u16, bottom: u16, sky: u8) -> Run { Run { key: RunKey { state: azalea_block::BlockState::AIR, @@ -232,7 +264,7 @@ mod tests { }, top, bottom, - sky: 15, + sky, block: 0, } } @@ -240,14 +272,22 @@ mod tests { #[test] fn a_neighbour_hides_the_side_it_covers() { // A 0..10 box beside a 0..4 one shows only 4..10. - assert_eq!(uncovered(0, 10, &[run(3, 0)]), [(4, 10)]); + assert_eq!(uncovered(0, 10, &[run(3, 0)]), [(4, 10, Some(15))]); // Beside a floating 4..6 run: above and below it. - assert_eq!(uncovered(0, 10, &[run(5, 4)]), [(6, 10), (0, 4)]); + assert_eq!( + uncovered(0, 10, &[run(5, 4)]), + [(6, 10, Some(15)), (0, 4, None)] + ); // Beside a taller one: nothing. assert!(uncovered(2, 5, &[run(9, 0)]).is_empty()); // Beside nothing, or a run wholly above or below: the whole side. - assert_eq!(uncovered(2, 5, &[]), [(2, 5)]); - assert_eq!(uncovered(2, 5, &[run(9, 7), run(0, 0)]), [(2, 5)]); + assert_eq!(uncovered(2, 5, &[]), [(2, 5, None)]); + assert_eq!(uncovered(2, 5, &[run(9, 7), run(0, 0)]), [(2, 5, Some(15))]); + // Under an overhang, lit by the dim air over the floor beside it. + assert_eq!( + uncovered(2, 9, &[run(12, 9), lit(3, 0, 4)]), + [(4, 9, Some(4))] + ); } /// Quads in the level-0 mesh of chunk 0 with `columns` (by `z * 16 + x`). diff --git a/pomme-client/src/renderer/distant/store.rs b/pomme-client/src/renderer/distant/store.rs index 9766bf8b2..d2536d533 100644 --- a/pomme-client/src/renderer/distant/store.rs +++ b/pomme-client/src/renderer/distant/store.rs @@ -17,6 +17,9 @@ pub(crate) const SECTION_COLUMNS: i32 = 64; pub(crate) const LEVELS: u8 = 5; /// The layout of a section file; a file of another is ignored. const FORMAT: u8 = 1; +/// Distant Horizons' default (Medium) horizontal quality: columns `2^l` +/// blocks wide from `DROP_OFF << l` blocks out, so full detail reaches 384. +const DROP_OFF: f64 = 192.0; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(crate) struct SectionKey { @@ -73,9 +76,13 @@ impl SectionKey { } /// Whether, seen from `x`/`z`, this section is too close to draw at its - /// level and its four children draw instead. + /// level and its four children draw instead: Distant Horizons' + /// `LodQuadTree.calcExpectedDetailLevel`, from the section's centre. pub fn splits(self, x: f64, z: f64) -> bool { - self.level > 0 && self.distance(x, z) < 1.5 * f64::from(self.width()) + let (ox, oz) = self.origin(); + let half = f64::from(self.width()) / 2.0; + let centre = (f64::from(ox) + half - x).hypot(f64::from(oz) + half - z); + self.level > 0 && centre < DROP_OFF * f64::from(self.column_width()) } /// Whether drawing from `x`/`z` out to `radius` blocks can reach this @@ -342,7 +349,8 @@ impl Store { let (x, z) = (i % across * 2, i / across * 2); let at = |dx: usize, dz: usize| columns[(z + dz) * finer + x + dx].as_slice(); - merge_columns(&[at(0, 0), at(1, 0), at(0, 1), at(1, 1)]) + // Distant Horizons' order, which settles ties. + merge_columns(&[at(0, 0), at(0, 1), at(1, 0), at(1, 1)]) }) .collect(); } @@ -441,9 +449,12 @@ mod tests { #[test] fn nearer_sections_split_into_finer_levels() { let key = |level, x| SectionKey { level, x, z: 0 }; - // A level-4 section (1024 wide) splits within 1536 blocks. - assert!(key(4, 0).splits(500.0, 8.0)); - assert!(!key(4, 3).splits(500.0, 8.0)); + // Full detail out to 384 blocks from a section's centre, then a + // level per doubling. + assert!(key(1, 0).splits(64.0 + 383.0, 64.0)); + assert!(!key(1, 0).splits(64.0 + 385.0, 64.0)); + assert!(key(4, 0).splits(512.0 + 3071.0, 512.0)); + assert!(!key(4, 0).splits(512.0 + 3073.0, 512.0)); // Level 0 never splits. assert!(!key(0, 0).splits(8.0, 8.0)); // A level-3 child of a far level-4 section isn't wanted; one under diff --git a/pomme-client/src/renderer/distant/worker.rs b/pomme-client/src/renderer/distant/worker.rs index 02793f136..b9e6960b8 100644 --- a/pomme-client/src/renderer/distant/worker.rs +++ b/pomme-client/src/renderer/distant/worker.rs @@ -317,10 +317,14 @@ impl Worker { let (store, min_y) = store(&mut self.stores, self.cache.as_deref(), &view.dimension); store.evict_beyond(view.x, view.z, view.radius + KEEP_MARGIN); + // Those with nothing seen mesh empty, so a parent knows its + // children are all there. let wanted: HashSet = SectionKey::all_wanted(view.x, view.z, view.radius) .into_iter() - .filter(|key| store.load(*key)) + .inspect(|key| { + store.load(*key); + }) .collect(); let removed: Vec = self.meshed.difference(&wanted).copied().collect(); for key in &removed { @@ -370,11 +374,13 @@ impl Worker { nearest.truncate(MESHES_PER_BATCH); let meshes = nearest .iter() - .filter_map(|&key| { + .map(|&key| { self.dirty.remove(&key); self.meshed.insert(key); - let section = store.get(key)?; - Some(mesh_section(key, section, |run| self.colors.colors(run))) + match store.get(key) { + Some(section) => mesh_section(key, section, |run| self.colors.colors(run)), + None => SectionMesh::empty(key), + } }) .collect(); let _ = self.updates.send(DistantUpdate { diff --git a/pomme-client/src/renderer/shaders/distant.vert b/pomme-client/src/renderer/shaders/distant.vert index 260644d1b..046a2d77e 100644 --- a/pomme-client/src/renderer/shaders/distant.vert +++ b/pomme-client/src/renderer/shaders/distant.vert @@ -31,6 +31,10 @@ layout(location = 4) flat out ivec2 v_anchor; // tops and bottoms. layout(location = 5) flat out vec2 v_outward; +// Distant Horizons' micro offset: how far a corner moves out along its +// quad's plane. +const float MICRO_OFFSET = 0.01; + // mesh.rs `Face`: up, down, north, south, west, east. const vec2 FACE_NUDGE[6] = vec2[]( vec2(0.0), vec2(0.0), @@ -38,8 +42,15 @@ const vec2 FACE_NUDGE[6] = vec2[]( vec2(0.5, 0.0), vec2(-0.5, 0.0) ); +// A corner's micro offset along one axis, from its 2 bits (mesh.rs +// `DistantVertex::face`). +float micro_offset(uint bits) { + return (bits & 1u) == 0u ? 0.0 : ((bits & 2u) == 0u ? MICRO_OFFSET : -MICRO_OFFSET); +} + void main() { vec3 local = vec3(float(in_xz & 0xFFFFu), float(in_y), float(in_xz >> 16)); + local.xz += vec2(micro_offset(in_light_face.y >> 3), micro_offset(in_light_face.y >> 5)); vec3 world = origin.xyz + local; vec3 rel = world - camera_pos.xyz; gl_Position = distant_view_proj * vec4(rel, 1.0); @@ -49,7 +60,7 @@ void main() { v_color = in_color.rgb * sample_lightmap(lightmap_uv).rgb; v_fog_color = fog_color.rgb; v_fog = total_fog_value(rel, fog_env, camera_pos.w, fog_color.w); - vec2 nudge = FACE_NUDGE[min(in_light_face.y, 5u)]; + vec2 nudge = FACE_NUDGE[min(in_light_face.y & 7u, 5u)]; v_column_pos = world.xz + nudge; v_anchor = camera_block.xz; v_outward = -2.0 * nudge; From 53cb3729dec918ba70cff147db633862527d73e8 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:42:52 +0000 Subject: [PATCH 10/67] light distant sides facing nothing with the sky over their column --- pomme-client/src/renderer/distant/mesh.rs | 36 +++++++++++++++++++---- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/pomme-client/src/renderer/distant/mesh.rs b/pomme-client/src/renderer/distant/mesh.rs index 556aa0c2c..5e0cbdeb9 100644 --- a/pomme-client/src/renderer/distant/mesh.rs +++ b/pomme-client/src/renderer/distant/mesh.rs @@ -138,7 +138,10 @@ pub(crate) fn mesh_section( [[x1, low, z0], [x1, high, z0], [x1, high, z1], [x1, low, z1]] } }; - let light = sky.map_or(light, |sky| sky << 4 | run.block); + // Facing nothing, as at the edge of what's been seen: the + // sky over the column, as the run's own can be deep water's. + let sky = sky.unwrap_or(runs[0].sky); + let light = sky << 4 | run.block; push_quad(&mut vertices, corners, light, face, side_rgb); } } @@ -290,8 +293,8 @@ mod tests { ); } - /// Quads in the level-0 mesh of chunk 0 with `columns` (by `z * 16 + x`). - fn quads(columns: impl Fn(usize) -> Vec) -> usize { + /// The level-0 mesh of chunk 0 with `columns` (by `z * 16 + x`). + fn mesh(columns: impl Fn(usize) -> Vec) -> SectionMesh { let mut store = Store::default(); store.capture(ChunkPos::new(0, 0), (0..256).map(columns).collect()); let key = SectionKey { @@ -299,8 +302,31 @@ mod tests { x: 0, z: 0, }; - let mesh = mesh_section(key, store.get(key).unwrap(), |_| ([0; 3], [0; 3])); - mesh.vertices.len() / 4 + mesh_section(key, store.get(key).unwrap(), |_| ([0; 3], [0; 3])) + } + + fn quads(columns: impl Fn(usize) -> Vec) -> usize { + mesh(columns).vertices.len() / 4 + } + + #[test] + fn sides_facing_nothing_take_the_sky_over_their_column() { + // A lit run over a dark one at the section's west edge: both west + // sides face nothing, so neither is drawn dark. + let mesh = mesh(|i| { + if i == 0 { + vec![lit(20, 15, 15), lit(10, 0, 0)] + } else { + Vec::new() + } + }); + let west: Vec = mesh + .vertices + .iter() + .filter(|v| v.face & 7 == Face::West as u8) + .map(|v| v.light >> 4) + .collect(); + assert_eq!(west, [15; 8]); } #[test] From 2cd72a4164ca74bb812109b8564f212a4333e240 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:02:35 +0000 Subject: [PATCH 11/67] close distant water off at the loaded edge with seams along chunk borders --- pomme-client/src/cpu_bench/distant.rs | 9 +- pomme-client/src/renderer/distant/colors.rs | 9 ++ pomme-client/src/renderer/distant/mesh.rs | 145 ++++++++++++++---- pomme-client/src/renderer/distant/worker.rs | 7 +- .../src/renderer/shaders/distant.frag | 8 +- .../src/renderer/shaders/distant.vert | 3 + 6 files changed, 142 insertions(+), 39 deletions(-) diff --git a/pomme-client/src/cpu_bench/distant.rs b/pomme-client/src/cpu_bench/distant.rs index 80804f369..d4bd3f544 100644 --- a/pomme-client/src/cpu_bench/distant.rs +++ b/pomme-client/src/cpu_bench/distant.rs @@ -37,7 +37,14 @@ pub fn bench(c: &mut Criterion, assets: &Assets) { let key = SectionKey { level, x: 0, z: 0 }; let section = store.get(key).expect("chunk (0, 0)'s sections"); group.bench_function(format!("mesh/{}/level{level}", scene.name()), |b| { - b.iter(|| black_box(mesh_section(key, section, |run| colors.colors(run)))) + b.iter(|| { + black_box(mesh_section( + key, + section, + |run| colors.colors(run), + |run| colors.fluid(run.state), + )) + }) }); } } diff --git a/pomme-client/src/renderer/distant/colors.rs b/pomme-client/src/renderer/distant/colors.rs index c9994a588..cc7d74f29 100644 --- a/pomme-client/src/renderer/distant/colors.rs +++ b/pomme-client/src/renderer/distant/colors.rs @@ -30,6 +30,7 @@ struct StateLook { /// The tint covers the sides too; a grass block's sides keep their /// dirt. tint_sides: bool, + fluid: bool, } const HIDDEN: StateLook = StateLook { @@ -39,6 +40,7 @@ const HIDDEN: StateLook = StateLook { side: UNKNOWN, tint: Tint::None, tint_sides: false, + fluid: false, }; pub(crate) struct DistantColors { @@ -75,6 +77,7 @@ impl DistantColors { let solid = (!data.is_invisible() && data.has_collision()) .then(|| view.get_textures(state)) .flatten(); + let fluid = solid.is_none(); let (top, side, tint, tint_sides) = match (solid, data.fluid().kind) { (Some(faces), _) => { let fixed = match faces.tint { @@ -108,6 +111,7 @@ impl DistantColors { shows: Shows::Yes { tinted: tint != Tint::None, }, + fluid, fills: data.occludes_neighbor_faces(), top, side, @@ -140,6 +144,11 @@ impl DistantColors { self.look(state).fills } + /// Whether it shows as water or lava. + pub fn fluid(&self, state: BlockState) -> bool { + self.look(state).fluid + } + /// A run's top and side colours, tinted for its biome. pub fn colors(&self, key: RunKey) -> ([u8; 3], [u8; 3]) { let look = self.look(key.state); diff --git a/pomme-client/src/renderer/distant/mesh.rs b/pomme-client/src/renderer/distant/mesh.rs index 5e0cbdeb9..e8190f6cd 100644 --- a/pomme-client/src/renderer/distant/mesh.rs +++ b/pomme-client/src/renderer/distant/mesh.rs @@ -19,7 +19,8 @@ pub(crate) struct DistantVertex { pub light: u8, /// Which way the face points ([`Face`]) in the low 3 bits, so the /// shaders can tell whose column it is; then which way the corner moves - /// out along `x` (bits 3-4) and `z` (5-6): 1 for positive, 3 negative. + /// out along `x` (bits 3-4) and `z` (5-6): 1 for positive, 3 negative; + /// bit 7 for a seam ([`mesh_section`]). pub face: u8, pub color: [u8; 4], } @@ -81,16 +82,29 @@ impl SectionMesh { /// column: bit `x` of row `z`. type FlatFaces = HashMap<(Face, u32, [u8; 3], u8), [u64; SECTION_COLUMNS as usize]>; -/// `colors` gives a run's top and side colour. Tops and bottoms alike merge -/// into rectangles; sides are a quad per uncovered stretch. +/// Seams alike in height, colour and light along one chunk border, as a +/// bit per column along it: by face, the border's column, then the rest. +type Seams = HashMap<(Face, u32, u32, [u8; 3], u8), u64>; + +/// `colors` gives a run's top and side colour, `fluid` whether it's water +/// or lava. Tops and bottoms alike merge into rectangles; sides are a quad +/// per uncovered stretch. +/// +/// A fluid's surface also gets seams: sides along every chunk border, from +/// its top to the bottom of the world, which `distant.frag` keeps only where +/// they face a loaded chunk. They close the fluid off where the loaded area +/// ends, which mesh time can't know, so the real water's edge doesn't look +/// into it. pub(crate) fn mesh_section( key: SectionKey, section: &Section, colors: impl Fn(RunKey) -> ([u8; 3], [u8; 3]), + fluid: impl Fn(RunKey) -> bool, ) -> SectionMesh { let w = key.column_width() as u32; let mut vertices = Vec::new(); let mut flat = FlatFaces::new(); + let mut seams = Seams::new(); let (mut min_y, mut max_y) = (u16::MAX, 0); for z in 0..SECTION_COLUMNS { for x in 0..SECTION_COLUMNS { @@ -102,6 +116,25 @@ pub(crate) fn mesh_section( SIDES.map(|(_, dx, dz)| section.column(x + dx, z + dz).collect()); let (x0, z0) = (x as u32 * w, z as u32 * w); let (x1, z1) = (x0 + w, z0 + w); + if fluid(runs[0].key) { + let surface = &runs[0]; + let (top, rgb, light) = ( + u32::from(surface.top) + 1, + colors(surface.key).1, + surface.sky << 4 | surface.block, + ); + let (x, z) = (x as u32, z as u32); + for (face, on_border, line, along) in [ + (Face::North, z0 % 16 == 0, z, x), + (Face::South, z1 % 16 == 0, z, x), + (Face::West, x0 % 16 == 0, x, z), + (Face::East, x1 % 16 == 0, x, z), + ] { + if on_border { + *seams.entry((face, line, top, rgb, light)).or_default() |= 1 << along; + } + } + } for (i, run) in runs.iter().enumerate() { let (top_rgb, side_rgb) = colors(run.key); let light = run.sky << 4 | run.block; @@ -124,25 +157,12 @@ pub(crate) fn mesh_section( } for (&(face, ..), neighbour) in SIDES.iter().zip(&neighbours) { for (low, high, sky) in uncovered(bottom, top, neighbour) { - let corners = match face { - Face::North => { - [[x0, low, z0], [x0, high, z0], [x1, high, z0], [x1, low, z0]] - } - Face::South => { - [[x1, low, z1], [x1, high, z1], [x0, high, z1], [x0, low, z1]] - } - Face::West => { - [[x0, low, z1], [x0, high, z1], [x0, high, z0], [x0, low, z0]] - } - Face::Up | Face::Down | Face::East => { - [[x1, low, z0], [x1, high, z0], [x1, high, z1], [x1, low, z1]] - } - }; + let corners = side_corners(face, [x0, x1], [z0, z1], low, high); // Facing nothing, as at the edge of what's been seen: the // sky over the column, as the run's own can be deep water's. let sky = sky.unwrap_or(runs[0].sky); let light = sky << 4 | run.block; - push_quad(&mut vertices, corners, light, face, side_rgb); + push_quad(&mut vertices, corners, light, face, side_rgb, false); } } } @@ -156,7 +176,21 @@ pub(crate) fn mesh_section( Face::Up => [[x0, y, z0], [x1, y, z0], [x1, y, z1], [x0, y, z1]], _ => [[x0, y, z0], [x0, y, z1], [x1, y, z1], [x1, y, z0]], }; - push_quad(&mut vertices, corners, light, face, rgb); + push_quad(&mut vertices, corners, light, face, rgb, false); + } + } + for ((face, line, top, rgb, light), mut bits) in seams { + while bits != 0 { + let (start, len, mask) = lowest_span(bits); + bits &= !mask; + let along = [start * w, (start + len) * w]; + let across = [line * w, (line + 1) * w]; + let (xs, zs) = match face { + Face::North | Face::South => (along, across), + _ => (across, along), + }; + let corners = side_corners(face, xs, zs, 0, top); + push_quad(&mut vertices, corners, light, face, rgb, true); } } SectionMesh { @@ -167,15 +201,39 @@ pub(crate) fn mesh_section( } } +/// The corners of the side facing `face` of the box spanning `xs` and `zs`, +/// from `low` to `high`. +fn side_corners( + face: Face, + [x0, x1]: [u32; 2], + [z0, z1]: [u32; 2], + low: u32, + high: u32, +) -> [[u32; 3]; 4] { + match face { + Face::North => [[x0, low, z0], [x0, high, z0], [x1, high, z0], [x1, low, z0]], + Face::South => [[x1, low, z1], [x1, high, z1], [x0, high, z1], [x0, low, z1]], + Face::West => [[x0, low, z1], [x0, high, z1], [x0, high, z0], [x0, low, z0]], + Face::Up | Face::Down | Face::East => { + [[x1, low, z0], [x1, high, z0], [x1, high, z1], [x1, low, z1]] + } + } +} + +/// The lowest stretch of set bits in `bits`: its first bit, length and mask. +fn lowest_span(bits: u64) -> (u32, u32, u64) { + let start = bits.trailing_zeros(); + let len = (!(bits >> start)).trailing_zeros(); + (start, len, (u64::MAX >> (64 - len)) << start) +} + /// Greedy rectangles covering the set bits of `rows` (bit `x` of row `z`), /// as `(x, z, width, depth)` in columns; clears `rows`. fn rectangles(rows: &mut [u64; SECTION_COLUMNS as usize]) -> Vec<(u32, u32, u32, u32)> { let mut out = Vec::new(); for z in 0..rows.len() { while rows[z] != 0 { - let x = rows[z].trailing_zeros(); - let width = (!(rows[z] >> x)).trailing_zeros(); - let mask = (u64::MAX >> (64 - width)) << x; + let (x, width, mask) = lowest_span(rows[z]); let mut end = z + 1; while end < rows.len() && rows[end] & mask == mask { rows[end] &= !mask; @@ -224,6 +282,7 @@ fn push_quad( light: u8, face: Face, rgb: [u8; 3], + seam: bool, ) { let [r, g, b] = rgb.map(|c| (f32::from(c) * face.shade()).round() as u8); // Each corner moves out along the quad's own plane, closing pinholes @@ -244,7 +303,7 @@ fn push_quad( xz: x | z << 16, y: y as u16, light, - face: face as u8 | micro_offset(0, x) << 3 | micro_offset(2, z) << 5, + face: face as u8 | micro_offset(0, x) << 3 | micro_offset(2, z) << 5 | u8::from(seam) << 7, color: [r, g, b, 255], })); } @@ -293,8 +352,9 @@ mod tests { ); } - /// The level-0 mesh of chunk 0 with `columns` (by `z * 16 + x`). - fn mesh(columns: impl Fn(usize) -> Vec) -> SectionMesh { + /// The level-0 mesh of chunk 0 with `columns` (by `z * 16 + x`), every + /// run water or none. + fn mesh(columns: impl Fn(usize) -> Vec, water: bool) -> SectionMesh { let mut store = Store::default(); store.capture(ChunkPos::new(0, 0), (0..256).map(columns).collect()); let key = SectionKey { @@ -302,24 +362,41 @@ mod tests { x: 0, z: 0, }; - mesh_section(key, store.get(key).unwrap(), |_| ([0; 3], [0; 3])) + mesh_section( + key, + store.get(key).unwrap(), + |_| ([0; 3], [0; 3]), + |_| water, + ) } fn quads(columns: impl Fn(usize) -> Vec) -> usize { - mesh(columns).vertices.len() / 4 + mesh(columns, false).vertices.len() / 4 + } + + #[test] + fn water_gets_a_merged_seam_along_each_chunk_border() { + let mesh = mesh(|_| vec![run(10, 0)], true); + let seams: Vec<_> = mesh.vertices.iter().filter(|v| v.face >> 7 == 1).collect(); + // One quad down from the surface to the bottom per side of the chunk. + assert_eq!(seams.len(), 4 * 4); + assert!(seams.iter().all(|v| v.y == 0 || v.y == 11)); } #[test] fn sides_facing_nothing_take_the_sky_over_their_column() { // A lit run over a dark one at the section's west edge: both west // sides face nothing, so neither is drawn dark. - let mesh = mesh(|i| { - if i == 0 { - vec![lit(20, 15, 15), lit(10, 0, 0)] - } else { - Vec::new() - } - }); + let mesh = mesh( + |i| { + if i == 0 { + vec![lit(20, 15, 15), lit(10, 0, 0)] + } else { + Vec::new() + } + }, + false, + ); let west: Vec = mesh .vertices .iter() diff --git a/pomme-client/src/renderer/distant/worker.rs b/pomme-client/src/renderer/distant/worker.rs index b9e6960b8..74185f943 100644 --- a/pomme-client/src/renderer/distant/worker.rs +++ b/pomme-client/src/renderer/distant/worker.rs @@ -378,7 +378,12 @@ impl Worker { self.dirty.remove(&key); self.meshed.insert(key); match store.get(key) { - Some(section) => mesh_section(key, section, |run| self.colors.colors(run)), + Some(section) => mesh_section( + key, + section, + |run| self.colors.colors(run), + |run| self.colors.fluid(run.state), + ), None => SectionMesh::empty(key), } }) diff --git a/pomme-client/src/renderer/shaders/distant.frag b/pomme-client/src/renderer/shaders/distant.frag index 97d99b790..b007ef579 100644 --- a/pomme-client/src/renderer/shaders/distant.frag +++ b/pomme-client/src/renderer/shaders/distant.frag @@ -14,6 +14,7 @@ layout(location = 2) in float v_fog; layout(location = 3) in vec2 v_column_pos; layout(location = 4) flat in ivec2 v_anchor; layout(location = 5) flat in vec2 v_outward; +layout(location = 6) flat in uint v_seam; layout(location = 0) out vec4 out_color; @@ -30,9 +31,10 @@ bool loaded_at(vec2 pos) { void main() { // Nothing over a real chunk, nor a side facing one: the chunk draws its - // own edge, and through its water this one would show as a wall. - if (loaded_at(v_column_pos) - || (v_outward != vec2(0.0) && loaded_at(v_column_pos + v_outward))) { + // own edge, and through its water this one would show as a wall. A + // seam is the opposite, drawn only facing one. + bool faces_loaded = v_outward != vec2(0.0) && loaded_at(v_column_pos + v_outward); + if (loaded_at(v_column_pos) || faces_loaded == (v_seam == 0u)) { discard; } out_color = vec4(apply_fog(v_color, v_fog, v_fog_color), 1.0); diff --git a/pomme-client/src/renderer/shaders/distant.vert b/pomme-client/src/renderer/shaders/distant.vert index 046a2d77e..a7890ac99 100644 --- a/pomme-client/src/renderer/shaders/distant.vert +++ b/pomme-client/src/renderer/shaders/distant.vert @@ -30,6 +30,8 @@ layout(location = 4) flat out ivec2 v_anchor; // A side face's step from v_column_pos into the column it faces; zero for // tops and bottoms. layout(location = 5) flat out vec2 v_outward; +// 1 for a seam (mesh.rs `mesh_section`). +layout(location = 6) flat out uint v_seam; // Distant Horizons' micro offset: how far a corner moves out along its // quad's plane. @@ -64,4 +66,5 @@ void main() { v_column_pos = world.xz + nudge; v_anchor = camera_block.xz; v_outward = -2.0 * nudge; + v_seam = in_light_face.y >> 7; } From f7a721aa30fd134053e5dfd72fc5b2d1870652d9 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:10:07 +0000 Subject: [PATCH 12/67] draw distant water see-through with the floor under it and give way to real chunks once they draw --- pomme-client/src/app/core.rs | 5 +- pomme-client/src/app/phases/in_game.rs | 6 + pomme-client/src/cpu_bench/distant.rs | 2 +- pomme-client/src/renderer/distant/colors.rs | 17 +- pomme-client/src/renderer/distant/gpu.rs | 27 ++- pomme-client/src/renderer/distant/mesh.rs | 206 +++++++++++------- pomme-client/src/renderer/distant/worker.rs | 2 +- pomme-client/src/renderer/mod.rs | 27 ++- .../src/renderer/pipelines/distant.rs | 48 ++-- .../src/renderer/shaders/distant.frag | 20 +- .../src/renderer/shaders/distant.vert | 4 +- 11 files changed, 228 insertions(+), 136 deletions(-) diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index f57b8136e..ab593aed1 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -1817,16 +1817,13 @@ impl AppCore { light, } => { game.load_chunk(pos, &data, &heightmaps, &light); - if game.chunk_store.get_chunk(&pos).is_some() { - renderer.set_distant_column_loaded(pos, true); - } } NetworkEvent::LightUpdate { pos, light } => game.apply_light_update(pos, &light), NetworkEvent::ChunkBiomes { chunks } => game.replace_biomes(&chunks), NetworkEvent::ChunkUnloaded { pos } => { game.unload_chunk(pos); renderer.remove_chunk_mesh(&pos); - renderer.set_distant_column_loaded(pos, false); + renderer.set_distant_column_drawn(pos, false); } NetworkEvent::ChunkCacheCenter { x, z } => { tracing::debug!("Chunk cache center: [{x}, {z}]"); diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 5dc0dc9d9..03f477a05 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -1319,6 +1319,12 @@ impl GameState { let dropped = renderer.upload_chunk_meshes(&batch); self.last_update_phases.upload_ms = upload_start.elapsed().as_secs_f32() * 1000.0; self.clear_dropped_meshed(dropped); + // Distant terrain gives way once a column draws, not once it loads: + // the ring loaded around the view waits on its neighbours to compile. + for mesh in &batch { + let drawn = self.compiled.get(&mesh.pos).is_some_and(|mask| *mask != 0); + renderer.set_distant_column_drawn(mesh.pos, drawn); + } } /// The frame's ready meshes, stale ones dropped and the rest's CPU-side diff --git a/pomme-client/src/cpu_bench/distant.rs b/pomme-client/src/cpu_bench/distant.rs index d4bd3f544..bdbbe04cd 100644 --- a/pomme-client/src/cpu_bench/distant.rs +++ b/pomme-client/src/cpu_bench/distant.rs @@ -42,7 +42,7 @@ pub fn bench(c: &mut Criterion, assets: &Assets) { key, section, |run| colors.colors(run), - |run| colors.fluid(run.state), + |run| colors.alpha(run.state), )) }) }); diff --git a/pomme-client/src/renderer/distant/colors.rs b/pomme-client/src/renderer/distant/colors.rs index cc7d74f29..5fbbd1a78 100644 --- a/pomme-client/src/renderer/distant/colors.rs +++ b/pomme-client/src/renderer/distant/colors.rs @@ -30,7 +30,8 @@ struct StateLook { /// The tint covers the sides too; a grass block's sides keep their /// dirt. tint_sides: bool, - fluid: bool, + /// How opaque it draws: 255 but for water. + alpha: u8, } const HIDDEN: StateLook = StateLook { @@ -40,7 +41,7 @@ const HIDDEN: StateLook = StateLook { side: UNKNOWN, tint: Tint::None, tint_sides: false, - fluid: false, + alpha: 255, }; pub(crate) struct DistantColors { @@ -65,6 +66,7 @@ impl DistantColors { .average_color(name) .map_or(UNKNOWN, |[r, g, b, _]| [r, g, b]) }; + let water_alpha = uv_map.average_color("water_still").map_or(255, |[.., a]| a); let states = (0..table.state_count() as u32) .map(|id| { let Ok(state) = BlockState::try_from(id) else { @@ -77,7 +79,7 @@ impl DistantColors { let solid = (!data.is_invisible() && data.has_collision()) .then(|| view.get_textures(state)) .flatten(); - let fluid = solid.is_none(); + let mut alpha = 255; let (top, side, tint, tint_sides) = match (solid, data.fluid().kind) { (Some(faces), _) => { let fixed = match faces.tint { @@ -99,6 +101,7 @@ impl DistantColors { } (None, FluidKind::Water) => { let water = color("water_still"); + alpha = water_alpha; (water, water, Tint::Water, true) } (None, FluidKind::Lava) => { @@ -111,7 +114,7 @@ impl DistantColors { shows: Shows::Yes { tinted: tint != Tint::None, }, - fluid, + alpha, fills: data.occludes_neighbor_faces(), top, side, @@ -144,9 +147,9 @@ impl DistantColors { self.look(state).fills } - /// Whether it shows as water or lava. - pub fn fluid(&self, state: BlockState) -> bool { - self.look(state).fluid + /// How opaque it draws: 255 but for water. + pub fn alpha(&self, state: BlockState) -> u8 { + self.look(state).alpha } /// A run's top and side colours, tinted for its biome. diff --git a/pomme-client/src/renderer/distant/gpu.rs b/pomme-client/src/renderer/distant/gpu.rs index 9a2941ff5..b1fc174c7 100644 --- a/pomme-client/src/renderer/distant/gpu.rs +++ b/pomme-client/src/renderer/distant/gpu.rs @@ -1,5 +1,5 @@ //! Distant terrain on the GPU: section meshes in vertex arenas, the -//! loaded-column mask that keeps them out of real chunks, and the draws. +//! drawn-column mask that keeps them out of real chunks, and the draws. use std::collections::{HashMap, VecDeque}; use std::sync::Arc; @@ -54,6 +54,8 @@ struct Uploaded { arena: usize, first_vertex: u32, vertices: u32, + /// [`super::mesh::SectionMesh::translucent_start`]. + translucent_start: u32, min_y: u16, max_y: u16, } @@ -66,7 +68,7 @@ pub(crate) struct DistantBuffers { /// The dimension the sections are of, and its `min_y`. dimension: Option>, min_y: i32, - /// Bit per [`ring_slot`]: a real chunk is loaded there. + /// Bit per [`ring_slot`]: a real chunk draws there. mask: rhi::backend::Buffer, mask_words: Vec, frame_seq: u64, @@ -149,11 +151,11 @@ impl DistantBuffers { self.mask.zero(); } - pub fn set_column_loaded(&mut self, pos: ChunkPos, loaded: bool) { + pub fn set_column_drawn(&mut self, pos: ChunkPos, drawn: bool) { let slot = ring_slot(pos); let (word, bit) = (slot / 32, 1u32 << (slot % 32)); let value = &mut self.mask_words[word]; - if loaded { + if drawn { *value |= bit; } else { *value &= !bit; @@ -213,6 +215,7 @@ impl DistantBuffers { arena, first_vertex, vertices: count, + translucent_start: mesh.translucent_start, min_y: mesh.min_y, max_y: mesh.max_y, }, @@ -265,14 +268,15 @@ impl DistantBuffers { out } - /// Draws the sections in view out to `radius` blocks. The caller has - /// bound the distant pipeline. + /// Draws the opaque or see-through faces of the sections in view out to + /// `radius` blocks. The caller has bound the matching distant pipeline. pub fn draw( &self, pass: &rhi::backend::RenderPassEncoder, camera: &Camera, eye: DVec3, radius: f64, + translucent: bool, ) { if self.sections.is_empty() { return; @@ -292,7 +296,12 @@ impl DistantBuffers { let mut bound = None; for key in visible { let section = &self.sections[&key]; - if section.vertices == 0 { + let (first, end) = if translucent { + (section.translucent_start, section.vertices) + } else { + (0, section.translucent_start) + }; + if first == end { continue; } let (ox, oz) = key.origin(); @@ -313,14 +322,14 @@ impl DistantBuffers { 64, bytemuck::cast_slice(&[rel.x, rel.y, rel.z, 0.0]), ); - let quads = section.vertices / 4; + let quads = (end - first) / 4; for first_quad in (0..quads).step_by(QUADS_PER_DRAW as usize) { let count = (quads - first_quad).min(QUADS_PER_DRAW); pass.draw_indexed( count * 6, 1, 0, - (section.first_vertex + first_quad * 4) as i32, + (section.first_vertex + first + first_quad * 4) as i32, 0, ); } diff --git a/pomme-client/src/renderer/distant/mesh.rs b/pomme-client/src/renderer/distant/mesh.rs index e8190f6cd..611e0d635 100644 --- a/pomme-client/src/renderer/distant/mesh.rs +++ b/pomme-client/src/renderer/distant/mesh.rs @@ -61,7 +61,9 @@ const SIDES: [(Face, i32, i32); 4] = [ /// A section's geometry and the height it spans, in blocks above `min_y`. pub(crate) struct SectionMesh { pub key: SectionKey, + /// Opaque faces, then see-through ones from `translucent_start`. pub vertices: Vec, + pub translucent_start: u32, pub min_y: u16, pub max_y: u16, } @@ -72,6 +74,7 @@ impl SectionMesh { Self { key, vertices: Vec::new(), + translucent_start: 0, min_y: 0, max_y: 0, } @@ -80,29 +83,34 @@ impl SectionMesh { /// Top or bottom faces alike in height, colour and light, as a bit per /// column: bit `x` of row `z`. -type FlatFaces = HashMap<(Face, u32, [u8; 3], u8), [u64; SECTION_COLUMNS as usize]>; +type FlatFaces = HashMap<(Face, u32, [u8; 4], u8), [u64; SECTION_COLUMNS as usize]>; -/// Seams alike in height, colour and light along one chunk border, as a -/// bit per column along it: by face, the border's column, then the rest. -type Seams = HashMap<(Face, u32, u32, [u8; 3], u8), u64>; +/// Seams alike in span, colour and light along one chunk border, as a bit +/// per column along it: by face, the border's column, top and bottom, then +/// the rest. +type Seams = HashMap<(Face, u32, u32, u32, [u8; 4], u8), u64>; -/// `colors` gives a run's top and side colour, `fluid` whether it's water -/// or lava. Tops and bottoms alike merge into rectangles; sides are a quad -/// per uncovered stretch. +/// `colors` gives a run's top and side colour, `alpha` how opaque it is. +/// Tops and bottoms alike merge into rectangles; sides are a quad per +/// uncovered stretch. /// -/// A fluid's surface also gets seams: sides along every chunk border, from -/// its top to the bottom of the world, which `distant.frag` keeps only where -/// they face a loaded chunk. They close the fluid off where the loaded area -/// ends, which mesh time can't know, so the real water's edge doesn't look -/// into it. +/// See-through runs, water, hide nothing behind them, so the sea floor +/// keeps its top and its sides beside water. They draw no side facing +/// nothing, which at a section's border would be a sheet in the middle of +/// the sea. Instead they get seams, sides along every chunk border, which +/// `distant.frag` keeps only where they face a drawn chunk: where the real +/// water ends, which mesh time can't know, looking on through its edge +/// still passes through water. pub(crate) fn mesh_section( key: SectionKey, section: &Section, colors: impl Fn(RunKey) -> ([u8; 3], [u8; 3]), - fluid: impl Fn(RunKey) -> bool, + alpha: impl Fn(RunKey) -> u8, ) -> SectionMesh { let w = key.column_width() as u32; - let mut vertices = Vec::new(); + let opaque = |run: &Run| alpha(run.key) == 255; + // Opaque quads, then see-through ones. + let mut quads = [Vec::new(), Vec::new()]; let mut flat = FlatFaces::new(); let mut seams = Seams::new(); let (mut min_y, mut max_y) = (u16::MAX, 0); @@ -116,59 +124,68 @@ pub(crate) fn mesh_section( SIDES.map(|(_, dx, dz)| section.column(x + dx, z + dz).collect()); let (x0, z0) = (x as u32 * w, z as u32 * w); let (x1, z1) = (x0 + w, z0 + w); - if fluid(runs[0].key) { - let surface = &runs[0]; - let (top, rgb, light) = ( - u32::from(surface.top) + 1, - colors(surface.key).1, - surface.sky << 4 | surface.block, - ); - let (x, z) = (x as u32, z as u32); - for (face, on_border, line, along) in [ - (Face::North, z0 % 16 == 0, z, x), - (Face::South, z1 % 16 == 0, z, x), - (Face::West, x0 % 16 == 0, x, z), - (Face::East, x1 % 16 == 0, x, z), - ] { - if on_border { - *seams.entry((face, line, top, rgb, light)).or_default() |= 1 << along; - } - } - } for (i, run) in runs.iter().enumerate() { - let (top_rgb, side_rgb) = colors(run.key); + let a = alpha(run.key); + let see_through = a < 255; + let ([r, g, b], [sr, sg, sb]) = colors(run.key); + let (top_rgba, side_rgba) = ([r, g, b, a], [sr, sg, sb, a]); let light = run.sky << 4 | run.block; let (bottom, top) = (u32::from(run.bottom), u32::from(run.top) + 1); min_y = min_y.min(run.bottom); max_y = max_y.max(run.top + 1); let mut flat_face = |face: Face, y| { - flat.entry((face, y, top_rgb, light)) + flat.entry((face, y, top_rgba, light)) .or_insert([0; SECTION_COLUMNS as usize])[z as usize] |= 1 << x; }; - let above_touches = i > 0 && runs[i - 1].bottom == run.top + 1; - if !above_touches { + // A face against a run hides unless only the run is see-through. + let hidden_by = |other: &Run| opaque(other) || see_through; + let above_hides = + i > 0 && runs[i - 1].bottom == run.top + 1 && hidden_by(&runs[i - 1]); + if !above_hides { flat_face(Face::Up, top); } - let below_touches = runs + let below_hides = runs .get(i + 1) - .is_some_and(|below| below.top + 1 == run.bottom); - if run.bottom > 0 && !below_touches { + .is_some_and(|below| below.top + 1 == run.bottom && hidden_by(below)); + if run.bottom > 0 && !below_hides { flat_face(Face::Down, bottom); } + if see_through { + let (x, z) = (x as u32, z as u32); + for (face, on_border, line, along) in [ + (Face::North, z0 % 16 == 0, z, x), + (Face::South, z1 % 16 == 0, z, x), + (Face::West, x0 % 16 == 0, x, z), + (Face::East, x1 % 16 == 0, x, z), + ] { + if on_border { + *seams + .entry((face, line, top, bottom, side_rgba, light)) + .or_default() |= 1 << along; + } + } + } for (&(face, ..), neighbour) in SIDES.iter().zip(&neighbours) { - for (low, high, sky) in uncovered(bottom, top, neighbour) { + // Water beside an opaque run doesn't hide it. + let covering = neighbour + .iter() + .filter(|other| see_through || opaque(other)); + for (low, high, sky) in uncovered(bottom, top, covering) { + if see_through && sky.is_none() { + continue; + } let corners = side_corners(face, [x0, x1], [z0, z1], low, high); // Facing nothing, as at the edge of what's been seen: the // sky over the column, as the run's own can be deep water's. let sky = sky.unwrap_or(runs[0].sky); let light = sky << 4 | run.block; - push_quad(&mut vertices, corners, light, face, side_rgb, false); + push_quad(&mut quads, corners, light, face, side_rgba, false); } } } } } - for ((face, y, rgb, light), mut rows) in flat { + for ((face, y, rgba, light), mut rows) in flat { for (x, z, width, depth) in rectangles(&mut rows) { let (x0, z0) = (x * w, z * w); let (x1, z1) = (x0 + width * w, z0 + depth * w); @@ -176,10 +193,10 @@ pub(crate) fn mesh_section( Face::Up => [[x0, y, z0], [x1, y, z0], [x1, y, z1], [x0, y, z1]], _ => [[x0, y, z0], [x0, y, z1], [x1, y, z1], [x1, y, z0]], }; - push_quad(&mut vertices, corners, light, face, rgb, false); + push_quad(&mut quads, corners, light, face, rgba, false); } } - for ((face, line, top, rgb, light), mut bits) in seams { + for ((face, line, top, bottom, rgba, light), mut bits) in seams { while bits != 0 { let (start, len, mask) = lowest_span(bits); bits &= !mask; @@ -189,13 +206,17 @@ pub(crate) fn mesh_section( Face::North | Face::South => (along, across), _ => (across, along), }; - let corners = side_corners(face, xs, zs, 0, top); - push_quad(&mut vertices, corners, light, face, rgb, true); + let corners = side_corners(face, xs, zs, bottom, top); + push_quad(&mut quads, corners, light, face, rgba, true); } } + let [mut vertices, translucent] = quads; + let translucent_start = vertices.len() as u32; + vertices.extend(translucent); SectionMesh { key, vertices, + translucent_start, min_y: min_y.min(max_y), max_y, } @@ -252,7 +273,11 @@ fn rectangles(rows: &mut [u64; SECTION_COLUMNS as usize]) -> Vec<(u32, u32, u32, /// overhang, or `None` with no run under it. A neighbour outside the /// section has no runs, so the section's border faces always draw: a /// coarser or missing neighbour can't hide them. -fn uncovered(low: u32, high: u32, neighbour: &[Run]) -> Vec<(u32, u32, Option)> { +fn uncovered<'a>( + low: u32, + high: u32, + neighbour: impl IntoIterator, +) -> Vec<(u32, u32, Option)> { let mut open = Vec::new(); let mut cursor = high; // Runs go top to bottom, so walk down. @@ -275,16 +300,17 @@ fn uncovered(low: u32, high: u32, neighbour: &[Run]) -> Vec<(u32, u32, Option, + quads: &mut [Vec; 2], corners: [[u32; 3]; 4], light: u8, face: Face, - rgb: [u8; 3], + [r, g, b, a]: [u8; 4], seam: bool, ) { - let [r, g, b] = rgb.map(|c| (f32::from(c) * face.shade()).round() as u8); + let [r, g, b] = [r, g, b].map(|c| (f32::from(c) * face.shade()).round() as u8); // Each corner moves out along the quad's own plane, closing pinholes // where quads meet (Distant Horizons' `LodQuadBuilder` micro offset). let micro_offset = |axis: usize, value: u32| { @@ -299,12 +325,12 @@ fn push_quad( 1 } }; - vertices.extend(corners.map(|[x, y, z]| DistantVertex { + quads[usize::from(a < 255)].extend(corners.map(|[x, y, z]| DistantVertex { xz: x | z << 16, y: y as u16, light, face: face as u8 | micro_offset(0, x) << 3 | micro_offset(2, z) << 5 | u8::from(seam) << 7, - color: [r, g, b, 255], + color: [r, g, b, a], })); } @@ -352,9 +378,9 @@ mod tests { ); } - /// The level-0 mesh of chunk 0 with `columns` (by `z * 16 + x`), every - /// run water or none. - fn mesh(columns: impl Fn(usize) -> Vec, water: bool) -> SectionMesh { + /// The level-0 mesh of chunk 0 with `columns` (by `z * 16 + x`), runs + /// of biome 1 see-through as water. + fn mesh(columns: impl Fn(usize) -> Vec) -> SectionMesh { let mut store = Store::default(); store.capture(ChunkPos::new(0, 0), (0..256).map(columns).collect()); let key = SectionKey { @@ -366,37 +392,67 @@ mod tests { key, store.get(key).unwrap(), |_| ([0; 3], [0; 3]), - |_| water, + |key| if key.biome == 1 { 128 } else { 255 }, ) } fn quads(columns: impl Fn(usize) -> Vec) -> usize { - mesh(columns, false).vertices.len() / 4 + mesh(columns).vertices.len() / 4 + } + + fn water(top: u16, bottom: u16) -> Run { + Run { + key: RunKey { + biome: 1, + ..run(top, bottom).key + }, + ..run(top, bottom) + } } #[test] - fn water_gets_a_merged_seam_along_each_chunk_border() { - let mesh = mesh(|_| vec![run(10, 0)], true); - let seams: Vec<_> = mesh.vertices.iter().filter(|v| v.face >> 7 == 1).collect(); - // One quad down from the surface to the bottom per side of the chunk. - assert_eq!(seams.len(), 4 * 4); - assert!(seams.iter().all(|v| v.y == 0 || v.y == 11)); + fn the_sea_floor_shows_through_water() { + // A flat chunk of sea: the water's top and the floor's under it, and + // the floor's sides beside water. + let sea = mesh(|_| vec![water(10, 6), run(5, 0)]); + let see_through = |v: &DistantVertex| v.color[3] < 255; + let (opaque, clear) = sea.vertices.split_at(sea.translucent_start as usize); + assert!(opaque.iter().all(|v| !see_through(v))); + assert!(clear.iter().all(see_through)); + // The water's top and a seam from it down to the floor per side of + // the chunk: its sides at the chunk's border face nothing. + assert_eq!(clear.len() / 4, 1 + 4); + let seams = clear.iter().filter(|v| v.face >> 7 == 1); + assert!(seams.clone().all(|v| v.y == 6 || v.y == 11)); + assert_eq!(seams.count(), 4 * 4); + // The floor's top and its sides at the border. + assert_eq!(opaque.len() / 4, 1 + 4 * 16); + // A floor step under water keeps its side. + let step = mesh(|i| { + if i % 16 == 0 { + vec![water(10, 8), run(7, 0)] + } else { + vec![water(10, 6), run(5, 0)] + } + }); + assert!( + step.vertices[..step.translucent_start as usize] + .iter() + .any(|v| v.face & 7 == Face::East as u8 && v.y == 8) + ); } #[test] fn sides_facing_nothing_take_the_sky_over_their_column() { // A lit run over a dark one at the section's west edge: both west // sides face nothing, so neither is drawn dark. - let mesh = mesh( - |i| { - if i == 0 { - vec![lit(20, 15, 15), lit(10, 0, 0)] - } else { - Vec::new() - } - }, - false, - ); + let mesh = mesh(|i| { + if i == 0 { + vec![lit(20, 15, 15), lit(10, 0, 0)] + } else { + Vec::new() + } + }); let west: Vec = mesh .vertices .iter() diff --git a/pomme-client/src/renderer/distant/worker.rs b/pomme-client/src/renderer/distant/worker.rs index 74185f943..c385f87f7 100644 --- a/pomme-client/src/renderer/distant/worker.rs +++ b/pomme-client/src/renderer/distant/worker.rs @@ -382,7 +382,7 @@ impl Worker { key, section, |run| self.colors.colors(run), - |run| self.colors.fluid(run.state), + |run| self.colors.alpha(run.state), ), None => SectionMesh::empty(key), } diff --git a/pomme-client/src/renderer/mod.rs b/pomme-client/src/renderer/mod.rs index 8de8edbd0..53ce57bca 100644 --- a/pomme-client/src/renderer/mod.rs +++ b/pomme-client/src/renderer/mod.rs @@ -1390,10 +1390,10 @@ impl Renderer { } } - /// Whether a real chunk is loaded at `pos`, where distant terrain gives - /// way to it. - pub fn set_distant_column_loaded(&mut self, pos: ChunkPos, loaded: bool) { - self.distant_buffers.set_column_loaded(pos, loaded); + /// Whether a real chunk draws at `pos`, where distant terrain gives way + /// to it. + pub fn set_distant_column_drawn(&mut self, pos: ChunkPos, drawn: bool) { + self.distant_buffers.set_column_drawn(pos, drawn); } /// Drops all distant terrain and forgets every loaded chunk, for a new @@ -2114,14 +2114,17 @@ impl Renderer { if self.distant_draws(*distant_terrain) { // Its own projection, so its depth means nothing to // the chunks: they start from a cleared buffer, and - // the loaded-column mask keeps it out of theirs. - self.distant_pipeline.bind(&mut pass, frame); - self.distant_buffers.draw( - &pass, - &self.camera, - self.camera_render_position(), - f64::from(*distant_terrain * 16), - ); + // the drawn-column mask keeps it out of theirs. + for translucent in [false, true] { + self.distant_pipeline.bind(&mut pass, frame, translucent); + self.distant_buffers.draw( + &pass, + &self.camera, + self.camera_render_position(), + f64::from(*distant_terrain * 16), + translucent, + ); + } pass.clear_depth(scissor); } diff --git a/pomme-client/src/renderer/pipelines/distant.rs b/pomme-client/src/renderer/pipelines/distant.rs index 2adde38cd..678ce2252 100644 --- a/pomme-client/src/renderer/pipelines/distant.rs +++ b/pomme-client/src/renderer/pipelines/distant.rs @@ -3,7 +3,7 @@ use crate::renderer::distant::gpu::DistantBuffers; use crate::renderer::pipelines::lightmap::Lightmap; use crate::renderer::{rhi, shader, util}; -/// Set 1: the loaded-column mask `distant.frag` discards with. +/// Set 1: the drawn-column mask `distant.frag` discards with. const MASK_LAYOUT: &[rhi::BindGroupLayoutEntry] = &[rhi::BindGroupLayoutEntry::storage( rhi::ShaderStages::FRAGMENT, )]; @@ -38,9 +38,9 @@ const VERTEX_BUFFERS: [rhi::VertexBufferLayout<'static>; 1] = [rhi::VertexBuffer }]; /// Distant terrain: coloured boxes behind the real chunks, under the -/// camera's fog and the lightmap. +/// camera's fog and the lightmap; opaque, then see-through blended over it. pub struct DistantPipeline { - pipeline: rhi::backend::RenderPipeline, + pipelines: [rhi::backend::RenderPipeline; 2], pipeline_layout: rhi::backend::PipelineLayout, camera_layout: rhi::backend::BindGroupLayout, mask_layout: rhi::backend::BindGroupLayout, @@ -69,7 +69,7 @@ impl DistantPipeline { size: 80, }), ); - let pipeline = create_pipeline(device, targets, pipeline_layout); + let pipelines = create_pipelines(device, targets, pipeline_layout); let (camera_sets, camera_buffers) = util::create_uniform_sets( device, camera_layout, @@ -87,7 +87,7 @@ impl DistantPipeline { )], ); Self { - pipeline, + pipelines, pipeline_layout, camera_layout, mask_layout, @@ -103,16 +103,23 @@ impl DistantPipeline { device: &rhi::backend::Device, targets: rhi::backend::PipelineTargets, ) { - self.pipeline.destroy(device); - self.pipeline = create_pipeline(device, targets, self.pipeline_layout); + for pipeline in &mut self.pipelines { + pipeline.destroy(device); + } + self.pipelines = create_pipelines(device, targets, self.pipeline_layout); } pub fn update_camera(&mut self, frame: usize, uniform: &CameraUniform) { self.camera_buffers[frame].write(bytemuck::bytes_of(uniform)); } - pub fn bind(&self, pass: &mut rhi::backend::RenderPassEncoder, frame: usize) { - pass.set_pipeline(&self.pipeline); + pub fn bind( + &self, + pass: &mut rhi::backend::RenderPassEncoder, + frame: usize, + translucent: bool, + ) { + pass.set_pipeline(&self.pipelines[usize::from(translucent)]); pass.set_bind_groups( 0, &[self.camera_sets[frame], self.mask_set, self.lightmap_set], @@ -123,20 +130,24 @@ impl DistantPipeline { util::destroy_buffers(device, &mut self.camera_buffers); util::destroy_bind_groups(device, self.camera_sets.drain(..)); self.mask_set.destroy(device); - self.pipeline.destroy(device); + for pipeline in &mut self.pipelines { + pipeline.destroy(device); + } self.pipeline_layout.destroy(device); self.camera_layout.destroy(device); self.mask_layout.destroy(device); } } -/// Faces are unculled: the boxes are drawn from every side as easily. -fn create_pipeline( +/// The opaque pipeline, then the see-through one, which blends and leaves +/// depth alone like chunk water. Faces are unculled: the boxes are drawn +/// from every side as easily. +fn create_pipelines( device: &rhi::backend::Device, targets: rhi::backend::PipelineTargets, layout: rhi::backend::PipelineLayout, -) -> rhi::backend::RenderPipeline { - let desc = rhi::RenderPipelineDesc { +) -> [rhi::backend::RenderPipeline; 2] { + let opaque = rhi::RenderPipelineDesc { depth: Some(rhi::DepthState::WRITE), ..rhi::RenderPipelineDesc::new( "distant terrain", @@ -145,5 +156,12 @@ fn create_pipeline( &VERTEX_BUFFERS, ) }; - rhi::backend::create_render_pipeline(device, targets, layout, &desc) + let translucent = rhi::RenderPipelineDesc { + label: "distant water", + depth: Some(rhi::DepthState::READ_ONLY), + blend: Some(rhi::BlendState::TRANSLUCENT), + ..opaque + }; + [opaque, translucent] + .map(|desc| rhi::backend::create_render_pipeline(device, targets, layout, &desc)) } diff --git a/pomme-client/src/renderer/shaders/distant.frag b/pomme-client/src/renderer/shaders/distant.frag index b007ef579..56e567c4e 100644 --- a/pomme-client/src/renderer/shaders/distant.frag +++ b/pomme-client/src/renderer/shaders/distant.frag @@ -3,12 +3,12 @@ #include "fog.glsl" // One bit per chunk column, ring-indexed as `world/chunk.rs` `ring_slot`: -// set where a real chunk is loaded, which draws there instead. -layout(set = 1, binding = 0) readonly buffer LoadedColumns { - uint loaded[]; +// set where a real chunk draws instead. +layout(set = 1, binding = 0) readonly buffer DrawnColumns { + uint drawn[]; }; -layout(location = 0) in vec3 v_color; +layout(location = 0) in vec4 v_color; layout(location = 1) in vec3 v_fog_color; layout(location = 2) in float v_fog; layout(location = 3) in vec2 v_column_pos; @@ -21,21 +21,21 @@ layout(location = 0) out vec4 out_color; // `world/chunk.rs` RING_SIDE. const int RING_SIDE = 257; -// Whether a real chunk is loaded under anchor-relative `pos`. -bool loaded_at(vec2 pos) { +// Whether a real chunk draws under anchor-relative `pos`. +bool drawn_at(vec2 pos) { ivec2 chunk = (v_anchor + ivec2(floor(pos))) >> 4; ivec2 ring = ((chunk % RING_SIDE) + RING_SIDE) % RING_SIDE; uint slot = uint(ring.y * RING_SIDE + ring.x); - return (loaded[slot >> 5] & (1u << (slot & 31u))) != 0u; + return (drawn[slot >> 5] & (1u << (slot & 31u))) != 0u; } void main() { // Nothing over a real chunk, nor a side facing one: the chunk draws its // own edge, and through its water this one would show as a wall. A // seam is the opposite, drawn only facing one. - bool faces_loaded = v_outward != vec2(0.0) && loaded_at(v_column_pos + v_outward); - if (loaded_at(v_column_pos) || faces_loaded == (v_seam == 0u)) { + bool faces_drawn = v_outward != vec2(0.0) && drawn_at(v_column_pos + v_outward); + if (drawn_at(v_column_pos) || faces_drawn == (v_seam == 0u)) { discard; } - out_color = vec4(apply_fog(v_color, v_fog, v_fog_color), 1.0); + out_color = vec4(apply_fog(v_color.rgb, v_fog, v_fog_color), v_color.a); } diff --git a/pomme-client/src/renderer/shaders/distant.vert b/pomme-client/src/renderer/shaders/distant.vert index a7890ac99..451123b45 100644 --- a/pomme-client/src/renderer/shaders/distant.vert +++ b/pomme-client/src/renderer/shaders/distant.vert @@ -20,7 +20,7 @@ layout(location = 1) in uint in_y; layout(location = 2) in uvec2 in_light_face; layout(location = 3) in vec4 in_color; -layout(location = 0) out vec3 v_color; +layout(location = 0) out vec4 v_color; layout(location = 1) out vec3 v_fog_color; layout(location = 2) out float v_fog; // Anchor-relative x/z, nudged half a block into the face's own column, and @@ -59,7 +59,7 @@ void main() { uint light = in_light_face.x; vec2 lightmap_uv = vec2(float(light & 0xFu), float(light >> 4)) * 16.0; - v_color = in_color.rgb * sample_lightmap(lightmap_uv).rgb; + v_color = vec4(in_color.rgb * sample_lightmap(lightmap_uv).rgb, in_color.a); v_fog_color = fog_color.rgb; v_fog = total_fog_value(rel, fog_env, camera_pos.w, fog_color.w); vec2 nudge = FACE_NUDGE[min(in_light_face.y & 7u, 5u)]; From fc47ae26b6b8b14b5d8a6736e9e0e2bfa69b5fd9 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:02:03 +0000 Subject: [PATCH 13/67] texture distant terrain with each block's baked model --- pomme-client/src/cpu_bench/distant.rs | 13 +- pomme-client/src/renderer/chunk/atlas.rs | 48 +- pomme-client/src/renderer/chunk/mesher.rs | 4 +- pomme-client/src/renderer/distant/bakery.rs | 535 ++++++++++++++++++ pomme-client/src/renderer/distant/colors.rs | 99 +++- pomme-client/src/renderer/distant/gpu.rs | 151 ++++- pomme-client/src/renderer/distant/mesh.rs | 192 +++++-- pomme-client/src/renderer/distant/mod.rs | 2 + pomme-client/src/renderer/distant/models.rs | 434 ++++++++++++++ pomme-client/src/renderer/distant/worker.rs | 22 +- pomme-client/src/renderer/mod.rs | 1 + .../src/renderer/pipelines/distant.rs | 54 +- .../src/renderer/shaders/distant.frag | 42 +- .../src/renderer/shaders/distant.vert | 43 +- .../src/renderer/shaders/distant_models.glsl | 11 + 15 files changed, 1518 insertions(+), 133 deletions(-) create mode 100644 pomme-client/src/renderer/distant/bakery.rs create mode 100644 pomme-client/src/renderer/distant/models.rs create mode 100644 pomme-client/src/renderer/shaders/distant_models.glsl diff --git a/pomme-client/src/cpu_bench/distant.rs b/pomme-client/src/cpu_bench/distant.rs index bdbbe04cd..dd643974c 100644 --- a/pomme-client/src/cpu_bench/distant.rs +++ b/pomme-client/src/cpu_bench/distant.rs @@ -9,6 +9,7 @@ use super::fixtures::{Assets, Scene, build_world, columns_within}; use crate::position::ChunkPos; use crate::renderer::distant::column::build_chunk; use crate::renderer::distant::mesh::mesh_section; +use crate::renderer::distant::models::ModelFactory; use crate::renderer::distant::store::{LEVELS, SectionKey, Store}; pub fn bench(c: &mut Criterion, assets: &Assets) { @@ -36,14 +37,14 @@ pub fn bench(c: &mut Criterion, assets: &Assets) { for level in [0, LEVELS - 1] { let key = SectionKey { level, x: 0, z: 0 }; let section = store.get(key).expect("chunk (0, 0)'s sections"); + // Models bake once, before the meshing measured. + let mut models = ModelFactory::new(); + mesh_section(key, section, |run| colors.run_look(&mut models, run)); group.bench_function(format!("mesh/{}/level{level}", scene.name()), |b| { b.iter(|| { - black_box(mesh_section( - key, - section, - |run| colors.colors(run), - |run| colors.alpha(run.state), - )) + black_box(mesh_section(key, section, |run| { + colors.run_look(&mut models, run) + })) }) }); } diff --git a/pomme-client/src/renderer/chunk/atlas.rs b/pomme-client/src/renderer/chunk/atlas.rs index 047d6ab07..852415bc6 100644 --- a/pomme-client/src/renderer/chunk/atlas.rs +++ b/pomme-client/src/renderer/chunk/atlas.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use std::sync::Arc; use crate::assets::{AssetId, AssetIndex}; use crate::renderer::{packing, rhi, util}; @@ -47,6 +48,17 @@ impl AtlasRegion { }; } +/// A block sprite's first frame, level 0. +#[derive(Clone)] +pub(crate) struct SpritePixels { + pub width: u32, + pub height: u32, + /// RGBA, row by row from the top. + pub rgba: Arc<[u8]>, + /// Mipped as `dark_cutout`, which leaves transparent texels black. + pub dark_cutout: bool, +} + #[derive(Clone)] pub struct AtlasUVMap { regions: HashMap, @@ -54,9 +66,9 @@ pub struct AtlasUVMap { /// built; later ones fall back to the name lookup. by_texture: Vec, sprite_alpha_masks: HashMap, - /// Each sprite's alpha-weighted average colour (its first frame), for - /// terrain drawn too far out to texture. - average_colors: HashMap, + /// Each block sprite's pixels, which distant terrain bakes its models + /// from. + sprite_pixels: HashMap, /// Level-0 rectangles by sprite index, as `(x, y, width, height)`. rects: Vec<[u32; 4]>, missing: AtlasRegion, @@ -66,7 +78,7 @@ impl AtlasUVMap { fn new( regions: HashMap, sprite_alpha_masks: HashMap, - average_colors: HashMap, + sprite_pixels: HashMap, rects: Vec<[u32; 4]>, missing: AtlasRegion, ) -> Self { @@ -78,7 +90,7 @@ impl AtlasUVMap { regions, by_texture, sprite_alpha_masks, - average_colors, + sprite_pixels, rects, missing, } @@ -107,8 +119,15 @@ impl AtlasUVMap { self.sprite_alpha_masks.get(name) } + pub(crate) fn sprite_pixels(&self, name: &str) -> Option<&SpritePixels> { + self.sprite_pixels.get(name) + } + + /// A block sprite's alpha-weighted average colour, for terrain drawn too + /// far out to texture. pub(crate) fn average_color(&self, name: &str) -> Option<[u8; 4]> { - self.average_colors.get(name).copied() + let sprite = self.sprite_pixels(name)?; + Some(average_rgba(&sprite.rgba, sprite.width, sprite.height)) } /// Each sprite's texture name, by sprite index. @@ -975,7 +994,7 @@ impl TextureAtlas { let mut regions = HashMap::new(); let mut sprite_alpha_masks = HashMap::new(); - let mut average_colors = HashMap::new(); + let mut sprite_pixels = HashMap::new(); let mut rects = vec![missing_region.pixel_rect.map(u32::from)]; for src in &sources { match placements.get(src.name.as_str()) { @@ -1002,7 +1021,18 @@ impl TextureAtlas { if let Some(mask) = &src.alpha_mask { sprite_alpha_masks.insert(src.name.clone(), mask.clone()); } - average_colors.insert(src.name.clone(), average_rgba(&src.data, src.w, src.h)); + if let Some(mip) = &src.mip_source { + let texels = (src.w * src.h * 4) as usize; + sprite_pixels.insert( + src.name.clone(), + SpritePixels { + width: src.w, + height: src.h, + rgba: src.data[..texels].into(), + dark_cutout: mip.strategy == MipmapStrategy::DarkCutout, + }, + ); + } regions.insert(src.name.clone(), region); } _ => { @@ -1014,7 +1044,7 @@ impl TextureAtlas { let uv_map = AtlasUVMap::new( regions, sprite_alpha_masks, - average_colors, + sprite_pixels, rects, missing_region, ); diff --git a/pomme-client/src/renderer/chunk/mesher.rs b/pomme-client/src/renderer/chunk/mesher.rs index 674998011..9462ce430 100644 --- a/pomme-client/src/renderer/chunk/mesher.rs +++ b/pomme-client/src/renderer/chunk/mesher.rs @@ -990,8 +990,8 @@ impl MeshDispatcher { /// dispatcher's textures, colormaps and biomes. pub(crate) fn distant_colors(&self) -> Arc { Arc::new(crate::renderer::distant::colors::DistantColors::new( - self.render_view.as_view(), - &self.uv_map, + self.render_view.clone(), + Arc::clone(&self.uv_map), Arc::clone(&self.biome_climate), self.colormaps(), )) diff --git a/pomme-client/src/renderer/distant/bakery.rs b/pomme-client/src/renderer/distant/bakery.rs new file mode 100644 index 000000000..13caeffd5 --- /dev/null +++ b/pomme-client/src/renderer/distant/bakery.rs @@ -0,0 +1,535 @@ +//! Voxy's model texture bakery (`SoftwareModelTextureBakery`, +//! `SoftwareRasterizer`): a block state's model drawn from each of its six +//! sides into a 16x16 face texture on the CPU, colour, depth and which +//! texels are tinted. + +use azalea_block::BlockState; +use glam::{Mat4, Quat, Vec3, Vec4}; + +use crate::renderer::chunk::atlas::{AtlasUVMap, SpritePixels}; +use crate::world::block::FluidKind; +use crate::world::block::model::{BakedQuad, Direction, face_positions, face_uvs}; +use crate::world::block::render_assets::{BlockRenderView, Tint}; + +/// Each face texture's side, in texels. +pub(crate) const MODEL_TEXTURE_SIZE: usize = 16; +pub(crate) const TEXELS: usize = MODEL_TEXTURE_SIZE * MODEL_TEXTURE_SIZE; + +const INTEGER_BITS: u32 = 13; +const TOTAL_INTEGER_BITS: u32 = INTEGER_BITS + 1; +const FIXED_POINT_BITS: u32 = 32 - TOTAL_INTEGER_BITS; +const FIXED_POINT_BIT_SCALE: i64 = (1 << FIXED_POINT_BITS) - 1; + +const DEPTH_MASK: u64 = ((1 << 24) - 1) << (64 - 24); +/// Depth at its farthest, the rest zero. +const CLEAR_VALUE: u64 = DEPTH_MASK; + +/// Quad meta: texels with no alpha are left out. +const META_DISCARD: u32 = 1; +/// Quad meta: the texels take the block's tint. +const META_TINTED: u32 = 4; + +/// One face's bake: per texel, bottom row first, its ABGR colour and its +/// depth word (24-bit depth high, bit 7 tinted, the low bits how many quads +/// covered it). +#[derive(Clone, PartialEq, Eq, Hash)] +pub(crate) struct FaceTexture { + pub colour: Box<[u32; TEXELS]>, + pub depth: Box<[u32; TEXELS]>, +} + +/// A model quad as the rasterizer takes it: block-local corners, sprite +/// UVs and the sprite they sample. +struct Quad<'a> { + positions: [Vec3; 4], + uvs: [[f32; 2]; 4], + meta: u32, + sprite: &'a SpritePixels, +} + +/// Voxy's `SoftwareRasterizer`: a 16x16 target of 64-bit texels, colour in +/// the low 32 bits, a stencil count, the tint bit and a 24-bit depth in the +/// high. +struct Rasterizer { + framebuffer: [u64; TEXELS], + cull_back_face: bool, + blending: bool, +} + +/// A transformed corner: screen position (fixed point) and its attributes. +#[derive(Clone, Copy, Default)] +struct Corner { + x: i32, + y: i32, + z: i32, + meta: u32, + u: f32, + v: f32, +} + +impl Rasterizer { + fn new() -> Self { + Self { + framebuffer: [CLEAR_VALUE; TEXELS], + cull_back_face: false, + blending: false, + } + } + + fn clear(&mut self) { + self.framebuffer.fill(CLEAR_VALUE); + } + + fn raster(&mut self, mvp: &Mat4, quads: &[Quad]) { + for quad in quads { + self.raster_quad(mvp, quad); + } + } + + fn raster_quad(&mut self, transform: &Mat4, quad: &Quad) { + let corners: [Corner; 4] = std::array::from_fn(|i| load_transform_pos(transform, quad, i)); + // 0, 1, 2 | 2, 3, 0 + self.raster_triangle(quad.sprite, [corners[0], corners[1], corners[2]], false); + self.raster_triangle(quad.sprite, [corners[2], corners[3], corners[0]], true); + } + + fn raster_triangle(&mut self, sprite: &SpritePixels, [v1, v2, v3]: [Corner; 3], or_zero: bool) { + let area = edge(&v1, &v2, v3.x, v3.y); + // A negative area winds counterclockwise. + if (area < 0) == self.cull_back_face { + return; + } + if from_fixed(area).abs() < 0.001 { + return; + } + let last = to_fixed_int(MODEL_TEXTURE_SIZE as i32 - 1); + let min_x = from_fixed_to_int(v1.x.min(v2.x).min(v3.x).max(0)); + let max_x = from_fixed_to_int(v1.x.max(v2.x).max(v3.x).min(last)); + let min_y = from_fixed_to_int(v1.y.min(v2.y).min(v3.y).max(0)); + let max_y = from_fixed_to_int(v1.y.max(v2.y).max(v3.y).min(last)); + for py in min_y..=max_y { + for px in min_x..=max_x { + let cx = to_fixed_int(px) + to_fixed(0.5); + let cy = to_fixed_int(py) + to_fixed(0.5); + let w1 = fixed_div(edge(&v2, &v3, cx, cy), area); + let w2 = fixed_div(edge(&v3, &v1, cx, cy), area); + let w3 = to_fixed(1.0) - w1 - w2; + let inside = w1 > 0 && w2 > 0 && w3 > 0; + if inside || (or_zero && w1 >= 0 && w2 >= 0 && w3 >= 0) { + let (b1, b2, b3) = (from_fixed(w1), from_fixed(w2), from_fixed(w3)); + let z = b1.mul_add( + from_fixed(v1.z), + b2.mul_add(from_fixed(v2.z), b3 * from_fixed(v3.z)), + ); + let index = (px + py * MODEL_TEXTURE_SIZE as i32) as usize; + self.raster_pixel(sprite, index, [b1, b2, b3], [v1, v2, v3], z); + } + } + } + } + + fn raster_pixel( + &mut self, + sprite: &SpritePixels, + index: usize, + [b1, b2, b3]: [f32; 3], + [a1, a2, a3]: [Corner; 3], + z: f32, + ) { + let mut z = z.mul_add(0.5, 0.5); + if (-0.000_001..0.0).contains(&z) { + z = 0.0; + } + if !(0.0..=1.0).contains(&z) { + return; + } + let meta = a1.meta; + let u = b1.mul_add(a1.u, b2.mul_add(a2.u, b3 * a3.u)); + let v = b1.mul_add(a1.v, b2.mul_add(a2.v, b3 * a3.v)); + let mut colour = sample(sprite, u, v); + if meta & META_DISCARD != 0 && colour >> 24 == 0 { + return; + } + let texel = &mut self.framebuffer[index]; + // The stencil counts first. + *texel = texel.wrapping_add(1 << 32); + let mut depth = ((f64::from(z) * f64::from((1 << 24) - 1)) as u64) << (64 - 24); + if depth == DEPTH_MASK { + // Something shows even at the far plane. + depth -= 1; + } + // Strictly less than. + if *texel <= depth { + return; + } + *texel = (*texel & !DEPTH_MASK) | depth; + *texel = (*texel & !(1 << 39)) | u64::from(meta & META_TINTED) << 37; + let below = *texel as u32; + *texel &= !u64::from(u32::MAX); + if self.blending { + colour = blend(below, colour); + } + *texel |= u64::from(colour); + } +} + +/// The nearest texel of `sprite` at `u`, `v`, as ABGR. +fn sample(sprite: &SpritePixels, u: f32, v: f32) -> u32 { + let (w, h) = (sprite.width as i32, sprite.height as i32); + // Java's `Math.round(u * w - 0.5)`. + let pu = ((u * w as f32).floor() as i32).clamp(0, w - 1); + let pv = ((v * h as f32).floor() as i32).clamp(0, h - 1); + let at = (pv * w + pu) as usize * 4; + u32::from_le_bytes(sprite.rgba[at..at + 4].try_into().expect("four bytes")) +} + +/// Voxy's `doBlending`: `top` over `below`, both ABGR. +fn blend(below: u32, top: u32) -> u32 { + let below_alpha = below >> 24; + if below_alpha == 0 { + return top; + } + let top_alpha = top >> 24; + let alpha = (below_alpha + ((top_alpha * (255 - below_alpha)) >> 8)).min(0xFF); + mix(top & 0xFF_FFFF, below & 0xFF_FFFF, top_alpha) | alpha << 24 +} + +/// Sodium's `ColorMixer.mix`: `start * weight + end * (255 - weight)` per +/// channel, rounded. +fn mix(start: u32, end: u32, weight: u32) -> u32 { + (0..4).fold(0, |out, i| { + let (a, b) = ((start >> (i * 8)) & 0xFF, (end >> (i * 8)) & 0xFF); + out | ((a * weight + b * (255 - weight) + 127) / 255) << (i * 8) + }) +} + +fn load_transform_pos(transform: &Mat4, quad: &Quad, vert: usize) -> Corner { + let pos = *transform * quad.positions[vert].extend(1.0); + assert!((pos.w - 1.0).abs() <= 0.000_001, "the views are affine"); + let size = MODEL_TEXTURE_SIZE as f32; + let [u, v] = quad.uvs[vert]; + Corner { + x: to_fixed(pos.x.mul_add(0.5, 0.5) * size), + y: to_fixed(pos.y.mul_add(0.5, 0.5) * size), + z: to_fixed(pos.z), + meta: quad.meta, + u, + v, + } +} + +fn edge(a: &Corner, b: &Corner, cx: i32, cy: i32) -> i32 { + fixed_mul(cx - a.x, b.y - a.y) - fixed_mul(cy - a.y, b.x - a.x) +} + +fn to_fixed(a: f32) -> i32 { + (f64::from(a) * FIXED_POINT_BIT_SCALE as f64) as i32 +} + +fn to_fixed_int(a: i32) -> i32 { + (i64::from(a) * FIXED_POINT_BIT_SCALE) as i32 +} + +fn from_fixed(a: i32) -> f32 { + (f64::from(a) / FIXED_POINT_BIT_SCALE as f64) as f32 +} + +fn from_fixed_to_int(a: i32) -> i32 { + (i64::from(a) / FIXED_POINT_BIT_SCALE) as i32 +} + +fn fixed_mul(a: i32, b: i32) -> i32 { + (i64::from(a) * i64::from(b) / FIXED_POINT_BIT_SCALE) as i32 +} + +fn fixed_div(a: i32, b: i32) -> i32 { + (i64::from(a) * FIXED_POINT_BIT_SCALE / i64::from(b)) as i32 +} + +/// The six views, by Voxy face index ([`Direction::VALUES`]): +/// each looks at the block from that side and maps it to clip space, the +/// face's texels at `x` and `y`. +fn views() -> [Mat4; 6] { + [ + view(-90.0, 0.0, 0.0, 0), + view(90.0, 0.0, 0.0, 0b100), + view(0.0, 180.0, 0.0, 0b001), + view(0.0, 0.0, 0.0, 0), + view(0.0, 90.0, 270.0, 0b100), + view(0.0, 270.0, 270.0, 0), + ] +} + +/// Voxy's `addView`: the pose (about the block's centre, flipped per +/// `flip`), then the orthographic projection of the unit cube. +fn view(pitch: f32, yaw: f32, rotation: f32, flip: i32) -> Mat4 { + let scale = Vec3::new( + (1 - 2 * (flip & 1)) as f32, + (1 - (flip & 2)) as f32, + (1 - ((flip >> 1) & 2)) as f32, + ); + let pose = Mat4::from_translation(Vec3::splat(0.5)) + * Mat4::from_quat(quat(Vec3::Z, rotation)) + * Mat4::from_quat(quat(Vec3::X, pitch)) + * Mat4::from_quat(quat(Vec3::Y, yaw)) + * Mat4::from_scale(scale) + * Mat4::from_translation(Vec3::splat(-0.5)); + let ortho = Mat4::from_cols( + Vec4::new(2.0, 0.0, 0.0, 0.0), + Vec4::new(0.0, 2.0, 0.0, 0.0), + Vec4::new(0.0, 0.0, -2.0, 0.0), + Vec4::new(-1.0, -1.0, 1.0, 1.0), + ); + ortho * pose +} + +/// Voxy's `makeQuatFromAxisExact`, `angle` in degrees. +fn quat(axis: Vec3, angle: f32) -> Quat { + let half = angle.to_radians() / 2.0; + let sin = f64::from(half).sin() as f32; + let axis = axis / axis.length(); + Quat::from_xyzw( + axis.x * sin, + axis.y * sin, + axis.z * sin, + f64::from(half).cos() as f32, + ) +} + +/// What a bake found across the model's quads. +#[derive(Clone, Copy, Default)] +pub(crate) struct BakeFlags { + /// Some quad's sprite mips as `dark_cutout`. + pub darkened: bool, + /// Some quad is in the translucent layer. + pub translucent: bool, + /// Some opaque-layer quad leaves texels out. + pub discard: bool, +} + +/// Voxy's `SoftwareModelTextureBakery`. +pub(crate) struct Bakery { + rasterizer: Rasterizer, + views: [Mat4; 6], +} + +impl Bakery { + pub fn new() -> Self { + Self { + rasterizer: Rasterizer::new(), + views: views(), + } + } + + /// `state`'s six faces, by Voxy face index, or `None` when it draws + /// nothing. + pub fn render<'a>( + &mut self, + view: BlockRenderView<'a>, + uv_map: &'a AtlasUVMap, + state: BlockState, + ) -> Option<([FaceTexture; 6], BakeFlags)> { + let data = view.block_state_table().block_state_data(state); + if data.is_invisible() { + return None; + } + // Voxy forces leaves into the solid layer. + let leaves = data.id().ends_with("_leaves"); + let mut flags = BakeFlags::default(); + let (mut opaque, mut translucent): (Vec>, Vec>) = + (Vec::new(), Vec::new()); + let mut add = |quad: Quad<'a>, layer_translucent: bool, opaque_texels: bool| { + flags.darkened |= quad.sprite.dark_cutout; + if layer_translucent { + flags.translucent = true; + translucent.push(Quad { + meta: quad.meta | META_DISCARD, + ..quad + }); + } else { + let discard = !leaves && !opaque_texels; + flags.discard |= discard; + opaque.push(Quad { + meta: quad.meta | if discard { META_DISCARD } else { 0 }, + ..quad + }); + } + }; + let fluid = data.fluid().kind; + if fluid != FluidKind::Empty && !data.has_collision() { + for quad in fluid_quads(uv_map, fluid) { + add(quad, fluid == FluidKind::Water, true); + } + } else { + let quads: Vec<&BakedQuad> = match view.get_baked_model(state) { + Some(model) => model.quads.iter().collect(), + None => view.get_multipart_quads(state).unwrap_or_default(), + }; + for quad in quads { + let name = quad.texture.name(); + let Some(sprite) = uv_map.sprite_pixels(name) else { + continue; + }; + let region = uv_map.region(quad.texture); + let meta = if quad.tint == Tint::None { + 0 + } else { + META_TINTED + }; + add( + Quad { + positions: quad.positions.map(Vec3::from), + uvs: quad.uvs, + meta, + sprite, + }, + region.translucent, + region.opaque, + ); + } + } + if opaque.is_empty() && translucent.is_empty() { + return None; + } + let faces = std::array::from_fn(|i| { + let r = &mut self.rasterizer; + r.cull_back_face = i == 1 || i == 2 || i == 4; + r.clear(); + r.blending = false; + r.raster(&self.views[i], &opaque); + r.blending = true; + r.raster(&self.views[i], &translucent); + FaceTexture { + colour: Box::new(r.framebuffer.map(|texel| texel as u32)), + depth: Box::new(r.framebuffer.map(|texel| (texel >> 32) as u32)), + } + }); + Some((faces, flags)) + } +} + +/// A full block of `fluid`: still on top and bottom, flowing on the sides +/// over vanilla's half of the sprite. Voxy runs vanilla's `FluidRenderer` +/// per view instead; a source with nothing around it comes out the same +/// but for its lowered top, which a distant run doesn't keep. +fn fluid_quads(uv_map: &AtlasUVMap, fluid: FluidKind) -> Vec> { + let (still, flow, meta) = match fluid { + FluidKind::Water => ("water_still", "water_flow", META_TINTED), + _ => ("lava_still", "lava_flow", 0), + }; + let (from, to) = ([0.0; 3], [1.0; 3]); + Direction::VALUES + .into_iter() + .filter_map(|dir| { + let (name, uv) = match dir { + Direction::Up | Direction::Down => (still, [0.0, 0.0, 16.0, 16.0]), + _ => (flow, [0.0, 0.0, 8.0, 8.0]), + }; + Some(Quad { + positions: face_positions(dir, from, to).map(Vec3::from), + uvs: face_uvs(dir, from, to, Some(&uv), None), + meta, + sprite: uv_map.sprite_pixels(name)?, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + fn sprite(rgba: [u8; 4]) -> SpritePixels { + SpritePixels { + width: 1, + height: 1, + rgba: Arc::from(rgba.as_slice()), + dark_cutout: false, + } + } + + /// The unit cube's `dir` face. + fn face(dir: Direction, sprite: &SpritePixels) -> Quad<'_> { + let (from, to) = ([0.0; 3], [1.0; 3]); + Quad { + positions: face_positions(dir, from, to).map(Vec3::from), + uvs: face_uvs(dir, from, to, None, None), + meta: 0, + sprite, + } + } + + fn bake(quads: &[Quad], view: usize) -> [u64; TEXELS] { + let mut r = Rasterizer::new(); + r.cull_back_face = view == 1 || view == 2 || view == 4; + r.raster(&views()[view], quads); + r.framebuffer + } + + #[test] + fn each_view_sees_its_face_of_a_cube_and_nothing_else() { + let colours = [[1, 0, 0, 255], [2, 0, 0, 255], [3, 0, 0, 255]]; + let sprites = colours.map(sprite); + let dirs = Direction::VALUES; + for (view, &dir) in dirs.iter().enumerate() { + let quads: Vec = dirs + .iter() + .map(|&other| face(other, &sprites[usize::from(other == dir)])) + .collect(); + let texels = bake(&quads, view); + assert!( + texels + .iter() + .all(|&t| t as u32 == u32::from_le_bytes([2, 0, 0, 255])), + "view {view} shows its own face over the whole texture" + ); + } + } + + #[test] + fn the_nearer_quad_wins_and_blending_puts_the_top_over() { + let (red, blue) = (sprite([255, 0, 0, 255]), sprite([0, 0, 255, 128])); + let mut back = face(Direction::North, &red); + let mut front = face(Direction::North, &blue); + // The north view looks at z = 0 from outside: nearer is lower z. + for p in &mut back.positions { + p.z = 0.5; + } + for p in &mut front.positions { + p.z = 0.25; + } + let texels = bake(&[back, front], 2); + assert_eq!(texels[0] as u32, u32::from_le_bytes([0, 0, 255, 128])); + assert_eq!(blend(0xFF00_00FF, 0x8000_00FF) >> 24, 255); + assert_eq!(blend(0, 0x8012_3456), 0x8012_3456); + } + + /// Per view, the block axes a face texture's `x` and `y` run along: + /// every view maps them one to one, unflipped. `distant.vert` mirrors + /// this. + const TILE_AXES: [[usize; 2]; 6] = [[0, 2], [0, 2], [0, 1], [0, 1], [1, 2], [1, 2]]; + + #[test] + fn tile_axes_follow_the_views() { + for (view, axes) in views().iter().zip(TILE_AXES) { + let rows = view.transpose(); + for (row, axis) in [rows.x_axis, rows.y_axis].into_iter().zip(axes) { + // Clip space to texels, in blocks. + let texel = row * 0.5 + Vec4::W * 0.5; + assert!(texel.abs_diff_eq(Vec3::AXES[axis].extend(0.0), 1e-6)); + } + } + } + + #[test] + fn discarded_texels_leave_nothing() { + let clear = sprite([9, 9, 9, 0]); + let quad = Quad { + meta: META_DISCARD, + ..face(Direction::Up, &clear) + }; + assert!(bake(&[quad], 1).iter().all(|&t| t == CLEAR_VALUE)); + } +} diff --git a/pomme-client/src/renderer/distant/colors.rs b/pomme-client/src/renderer/distant/colors.rs index 5fbbd1a78..e6b7b6e11 100644 --- a/pomme-client/src/renderer/distant/colors.rs +++ b/pomme-client/src/renderer/distant/colors.rs @@ -1,6 +1,7 @@ -//! How each block state looks in distant terrain: whether it shows, and the -//! average colour of its top and side textures, tinted by biome where its -//! model is. +//! How each block state looks in distant terrain: whether it shows, its +//! baked model ([`super::models`]) and tint, and for a state with no model +//! the average colour of its top and side textures, tinted by biome where +//! its model is. use std::collections::HashMap; use std::sync::Arc; @@ -8,12 +9,14 @@ use std::sync::Arc; use azalea_block::BlockState; use super::column::{RunKey, Shows}; +use super::mesh::RunLook; +use super::models::ModelFactory; use crate::renderer::chunk::atlas::AtlasUVMap; use crate::renderer::chunk::mesher::{ BiomeClimate, Colormap, dry_foliage_color, foliage_color, grass_color, int_to_rgb, }; use crate::world::block::FluidKind; -use crate::world::block::render_assets::{BlockRenderView, Tint}; +use crate::world::block::render_assets::{OwnedBlockRenderView, Tint}; /// Texture colour where a sprite is missing. const UNKNOWN: [u8; 3] = [128, 128, 128]; @@ -25,7 +28,9 @@ struct StateLook { fills: bool, top: [u8; 3], side: [u8; 3], - /// The biome tint, `None` once a fixed tint is baked into the colours. + /// A fixed tint's colour. + fixed_tint: Option<[f32; 3]>, + /// The biome tint, `None` for a fixed one. tint: Tint, /// The tint covers the sides too; a grass block's sides keep their /// dirt. @@ -39,12 +44,16 @@ const HIDDEN: StateLook = StateLook { fills: false, top: UNKNOWN, side: UNKNOWN, + fixed_tint: None, tint: Tint::None, tint_sides: false, alpha: 255, }; pub(crate) struct DistantColors { + /// What models bake from. + render_view: OwnedBlockRenderView, + uv_map: Arc, /// By state id. states: Vec, climates: Arc>, @@ -55,11 +64,12 @@ pub(crate) struct DistantColors { impl DistantColors { pub fn new( - view: BlockRenderView<'_>, - uv_map: &AtlasUVMap, + render_view: OwnedBlockRenderView, + uv_map: Arc, climates: Arc>, (grass, foliage, dry_foliage): (Arc, Arc, Arc), ) -> Self { + let view = render_view.as_view(); let table = view.block_state_table(); let color = |name: &str| { uv_map @@ -80,24 +90,20 @@ impl DistantColors { .then(|| view.get_textures(state)) .flatten(); let mut alpha = 255; + let mut fixed_tint = None; let (top, side, tint, tint_sides) = match (solid, data.fluid().kind) { (Some(faces), _) => { - let fixed = match faces.tint { + fixed_tint = match faces.tint { Tint::Constant(rgb) => Some(int_to_rgb(rgb as i32)), Tint::Redstone => Some(data.redstone_wire_rgb()), Tint::Stem => Some(data.stem_rgb()), _ => None, }; - let (mut top, mut side) = (color(&faces.top), color(&faces.north)); - let tint = match fixed { - Some(rgb) => { - top = multiply(top, rgb); - side = multiply(side, rgb); - Tint::None - } - None => faces.tint, + let (tint, tint_sides) = match fixed_tint { + Some(_) => (Tint::None, true), + None => (faces.tint, faces.side_overlay.is_none()), }; - (top, side, tint, faces.side_overlay.is_none()) + (color(&faces.top), color(&faces.north), tint, tint_sides) } (None, FluidKind::Water) => { let water = color("water_still"); @@ -118,12 +124,15 @@ impl DistantColors { fills: data.occludes_neighbor_faces(), top, side, + fixed_tint, tint, tint_sides, } }) .collect(); Self { + render_view, + uv_map, states, climates, grass, @@ -147,25 +156,61 @@ impl DistantColors { self.look(state).fills } - /// How opaque it draws: 255 but for water. - pub fn alpha(&self, state: BlockState) -> u8 { - self.look(state).alpha + /// Whether `models` were baked from the same textures and state ids as + /// these colours would bake them. + pub fn same_models(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.uv_map, &other.uv_map) + && self.render_view.block_state_table().table_slot() + == other.render_view.block_state_table().table_slot() } - /// A run's top and side colours, tinted for its biome. - pub fn colors(&self, key: RunKey) -> ([u8; 3], [u8; 3]) { + /// How `key`'s run draws: its model, baked into `models` the first time, + /// under its tint; or with none its colours. + pub fn run_look(&self, models: &mut ModelFactory, key: RunKey) -> RunLook { let look = self.look(key.state); - let climate = self.climates.get(&key.biome).copied().unwrap_or_default(); - let tint = match look.tint { + let model = models.model(self.render_view.as_view(), &self.uv_map, key.state); + if model == 0 { + let ([r, g, b], [sr, sg, sb]) = self.colors(look, key.biome); + let a = look.alpha; + return RunLook { + model, + translucent: a < 255, + occludes: a == 255, + faces: [0; 6], + top: [r, g, b, a], + side: [sr, sg, sb, a], + }; + } + let [r, g, b] = multiply([255; 3], self.tint(look, key.biome).unwrap_or([1.0; 3])); + let info = models.info(model); + RunLook { + model, + translucent: info.translucent, + occludes: info.occludes, + faces: info.faces, + top: [r, g, b, 255], + side: [r, g, b, 255], + } + } + + /// The tint over `look` in `biome`, fixed or the biome's. + fn tint(&self, look: StateLook, biome: u32) -> Option<[f32; 3]> { + let climate = self.climates.get(&biome).copied().unwrap_or_default(); + Some(match look.tint { // Without a position, the biome's base colour (no swamp or dark // forest noise). Tint::Grass | Tint::DoubleGrass => grass_color(&climate, &self.grass, 0, 0), Tint::Foliage => foliage_color(&climate, &self.foliage), Tint::DryFoliage => dry_foliage_color(&climate, &self.dry_foliage), Tint::Water => climate.water_color, - Tint::None | Tint::Constant(_) | Tint::Redstone | Tint::Stem => { - return (look.top, look.side); - } + Tint::None | Tint::Constant(_) | Tint::Redstone | Tint::Stem => look.fixed_tint?, + }) + } + + /// A run's top and side colours, tinted for its biome. + fn colors(&self, look: StateLook, biome: u32) -> ([u8; 3], [u8; 3]) { + let Some(tint) = self.tint(look, biome) else { + return (look.top, look.side); }; let side = if look.tint_sides { multiply(look.side, tint) diff --git a/pomme-client/src/renderer/distant/gpu.rs b/pomme-client/src/renderer/distant/gpu.rs index b1fc174c7..d6d33632c 100644 --- a/pomme-client/src/renderer/distant/gpu.rs +++ b/pomme-client/src/renderer/distant/gpu.rs @@ -1,5 +1,6 @@ -//! Distant terrain on the GPU: section meshes in vertex arenas, the -//! drawn-column mask that keeps them out of real chunks, and the draws. +//! Distant terrain on the GPU: section meshes in vertex arenas, the model +//! atlas they sample, the drawn-column mask that keeps them out of real +//! chunks, and the draws. use std::collections::{HashMap, VecDeque}; use std::sync::Arc; @@ -7,17 +8,20 @@ use std::sync::Arc; use glam::DVec3; use super::mesh::DistantVertex; +use super::models::{ + ATLAS_COLUMNS, ATLAS_ROWS, MODEL_MIP_LEVELS, ModelTile, TILE_HEIGHT, TILE_WIDTH, +}; use super::store::{LEVELS, SectionKey}; use super::worker::DistantUpdate; use crate::position::ChunkPos; use crate::renderer::camera::Camera; use crate::renderer::chunk::tlsf::Tlsf; -use crate::renderer::{MAX_FRAMES_IN_FLIGHT, rhi}; +use crate::renderer::{MAX_FRAMES_IN_FLIGHT, rhi, util}; use crate::world::chunk::{RING_SIDE, ring_slot}; -/// Vertices an arena holds: 48 MB at 12 bytes each. +/// Vertices an arena holds: 64 MB at 16 bytes each. const ARENA_VERTICES: u32 = 4 << 20; -/// Arenas there may be: 384 MB of vertices. +/// Arenas there may be: 512 MB of vertices. const MAX_ARENAS: usize = 8; /// Quads one draw covers, which the shared index buffer has room for; a /// larger section draws in several. @@ -26,6 +30,18 @@ const VERTEX_SIZE: u64 = size_of::() as u64; /// `distant.frag` hard-codes the ring's side. const _: () = assert!(RING_SIDE == 257); const MASK_WORDS: usize = ((RING_SIDE * RING_SIDE) as usize).div_ceil(32); +/// A model tile's bytes, every level. +const TILE_BYTES: usize = { + let mut bytes = 0; + let mut level = 0; + while level < MODEL_MIP_LEVELS { + bytes += ((TILE_WIDTH >> level) * (TILE_HEIGHT >> level) * 4) as usize; + level += 1; + } + bytes +}; +/// Model tiles uploaded a frame; more wait for the next. +const TILES_PER_FRAME: usize = 256; /// A vertex buffer, sub-allocated. struct Arena { @@ -75,6 +91,12 @@ pub(crate) struct DistantBuffers { /// Freed vertex ranges as (arena, first vertex), by the frame from /// which no in-flight frame can still draw them. pending_free: VecDeque<(u64, usize, u32)>, + /// Every model's six faces and their mips, a tile each. + model_atlas: rhi::backend::Texture, + model_sampler: rhi::backend::Sampler, + /// Tiles waiting for [`Self::record_model_upload`]. + pending_tiles: VecDeque, + tile_staging: Vec, } impl DistantBuffers { @@ -82,6 +104,31 @@ impl DistantBuffers { let quad_indices: Vec = (0..QUADS_PER_DRAW) .flat_map(|quad| [0, 1, 2, 2, 3, 0].map(|i| quad * 4 + i)) .collect(); + let model_atlas = rhi::backend::Texture::new( + device, + &rhi::TextureDesc { + mip_levels: MODEL_MIP_LEVELS, + ..util::sampled_texture_desc( + "distant_models", + ATLAS_COLUMNS * TILE_WIDTH, + ATLAS_ROWS * TILE_HEIGHT, + ) + }, + ); + let atlas = model_atlas.handle(); + device.submit(|encoder| { + encoder.transition_textures(&[atlas_transition( + atlas, + rhi::TextureUse::Undefined, + rhi::TextureUse::CopyDst, + )]); + encoder.clear_texture(atlas, MODEL_ATLAS_RANGE, [0.0; 4]); + encoder.transition_textures(&[atlas_transition( + atlas, + rhi::TextureUse::CopyDst, + rhi::TextureUse::ShaderRead, + )]); + }); Self { arenas: vec![Arena::new(device)], index_buffer: rhi::backend::Buffer::with_data( @@ -103,9 +150,78 @@ impl DistantBuffers { mask_words: vec![0; MASK_WORDS], frame_seq: 0, pending_free: VecDeque::new(), + model_atlas, + // Voxy's `ModelStore` sampler. + model_sampler: rhi::backend::create_sampler( + device, + &rhi::SamplerDesc { + mipmap_filter: rhi::FilterMode::Linear, + lod_max_clamp: (MODEL_MIP_LEVELS - 1) as f32, + ..rhi::SamplerDesc::NEAREST + }, + ), + pending_tiles: VecDeque::new(), + tile_staging: util::create_per_frame_host_buffers( + device, + (TILE_BYTES * TILES_PER_FRAME) as u64, + rhi::BufferUsages::COPY_SRC, + "distant_model_staging", + ), } } + pub fn model_atlas(&self) -> rhi::backend::TextureHandle { + self.model_atlas.handle() + } + + pub fn model_sampler(&self) -> rhi::backend::Sampler { + self.model_sampler + } + + /// Writes the waiting model tiles into the atlas, as many as fit this + /// frame's staging. + pub fn record_model_upload(&mut self, encoder: &rhi::backend::CommandEncoder, frame: usize) { + if self.pending_tiles.is_empty() { + return; + } + let staging = &mut self.tile_staging[frame]; + let mut copies = Vec::new(); + let mut offset = 0; + for tile in self + .pending_tiles + .drain(..TILES_PER_FRAME.min(self.pending_tiles.len())) + { + let (x, y) = ( + u32::from(tile.id) % ATLAS_COLUMNS * TILE_WIDTH, + u32::from(tile.id) / ATLAS_COLUMNS * TILE_HEIGHT, + ); + for (level, pixels) in (0..).zip(&tile.levels) { + staging.write_at(offset, pixels); + copies.push(rhi::TexelCopy { + buffer_offset: offset as u64, + mip_level: level, + layer: 0, + origin: [x >> level, y >> level], + width: TILE_WIDTH >> level, + height: TILE_HEIGHT >> level, + }); + offset += pixels.len(); + } + } + let atlas = self.model_atlas.handle(); + encoder.transition_textures(&[atlas_transition( + atlas, + rhi::TextureUse::ShaderRead, + rhi::TextureUse::CopyDst, + )]); + encoder.copy_buffer_to_texture(staging.handle(), atlas, &copies); + encoder.transition_textures(&[atlas_transition( + atlas, + rhi::TextureUse::CopyDst, + rhi::TextureUse::ShaderRead, + )]); + } + pub fn mask(&self) -> &rhi::backend::Buffer { &self.mask } @@ -190,6 +306,7 @@ impl DistantBuffers { self.dimension = Some(Arc::clone(&update.dimension)); } self.min_y = update.min_y; + self.pending_tiles.extend(update.tiles); for key in update.removed { self.retire(key); } @@ -320,7 +437,7 @@ impl DistantBuffers { pass.set_push_constants( rhi::ShaderStages::VERTEX, 64, - bytemuck::cast_slice(&[rel.x, rel.y, rel.z, 0.0]), + bytemuck::cast_slice(&[rel.x, rel.y, rel.z, width as f32]), ); let quads = (end - first) / 4; for first_quad in (0..quads).step_by(QUADS_PER_DRAW as usize) { @@ -343,6 +460,28 @@ impl DistantBuffers { for buffer in [&mut self.index_buffer, &mut self.mask] { std::mem::take(buffer).destroy(device); } + util::destroy_buffers(device, &mut self.tile_staging); + self.model_sampler.destroy(device); + std::mem::take(&mut self.model_atlas).destroy(device); + } +} + +/// Every level of the model atlas. +const MODEL_ATLAS_RANGE: rhi::TextureRange = rhi::TextureRange { + mips: MODEL_MIP_LEVELS, + ..rhi::TextureRange::BASE +}; + +fn atlas_transition( + atlas: rhi::backend::TextureHandle, + from: rhi::TextureUse, + to: rhi::TextureUse, +) -> rhi::backend::TextureTransition { + rhi::backend::TextureTransition { + texture: atlas, + range: MODEL_ATLAS_RANGE, + from, + to, } } diff --git a/pomme-client/src/renderer/distant/mesh.rs b/pomme-client/src/renderer/distant/mesh.rs index 611e0d635..c77512809 100644 --- a/pomme-client/src/renderer/distant/mesh.rs +++ b/pomme-client/src/renderer/distant/mesh.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use super::column::{Run, RunKey}; +use super::models::{FACE_DISCARD, FACE_DISCARD_WHEN_MERGED, FACE_EMPTY, FACE_TRANSLUCENT}; use super::store::{SECTION_COLUMNS, Section, SectionKey}; /// One corner of a quad; four in a row make a quad, drawn with a shared @@ -22,7 +23,57 @@ pub(crate) struct DistantVertex { /// out along `x` (bits 3-4) and `z` (5-6): 1 for positive, 3 negative; /// bit 7 for a seam ([`mesh_section`]). pub face: u8, + /// The tint over the model's texture, or with no model the run's own + /// colour. pub color: [u8; 4], + /// [`RunLook::model`]. + pub model: u16, + /// The face's [`super::models`] flags, discarding resolved for the + /// quad's size. + pub flags: u8, + pub _pad: u8, +} + +/// How a run draws. +#[derive(Clone, Copy)] +pub(crate) struct RunLook { + /// Its baked model ([`super::models`]); 0 for none, drawn in its colours + /// alone. + pub model: u16, + /// Blended over what's behind it: water. + pub translucent: bool, + /// Hides the faces of runs it touches. + pub occludes: bool, + /// The model's face flags, by Voxy face index. + pub faces: [u8; 6], + /// The top's and bottom's colour, then the sides'. + pub top: [u8; 4], + pub side: [u8; 4], +} + +impl RunLook { + /// What `face` draws with; `None` where the model draws nothing. + fn paint(&self, face: Face) -> Option { + let flags = self.faces[face.voxy_index()]; + (flags & FACE_EMPTY == 0).then_some(Paint { + model: self.model, + flags, + color: match face { + Face::Up | Face::Down => self.top, + _ => self.side, + }, + translucent: self.translucent, + }) + } +} + +/// What a face draws with: faces alike in it (and in light) merge. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +struct Paint { + model: u16, + flags: u8, + color: [u8; 4], + translucent: bool, } /// A face's direction, as `distant.vert` reads it. @@ -38,14 +89,16 @@ enum Face { } impl Face { - /// Vanilla's directional shade (`Direction` brightness under the - /// overworld's cardinal lighting). - fn shade(self) -> f32 { + /// Its index in a model's faces + /// ([`crate::world::block::model::Direction::VALUES`]). + fn voxy_index(self) -> usize { match self { - Self::Up => 1.0, - Self::Down => 0.5, - Self::North | Self::South => 0.8, - Self::West | Self::East => 0.6, + Self::Down => 0, + Self::Up => 1, + Self::North => 2, + Self::South => 3, + Self::West => 4, + Self::East => 5, } } } @@ -81,18 +134,17 @@ impl SectionMesh { } } -/// Top or bottom faces alike in height, colour and light, as a bit per +/// Top or bottom faces alike in height, paint and light, as a bit per /// column: bit `x` of row `z`. -type FlatFaces = HashMap<(Face, u32, [u8; 4], u8), [u64; SECTION_COLUMNS as usize]>; +type FlatFaces = HashMap<(Face, u32, Paint, u8), [u64; SECTION_COLUMNS as usize]>; -/// Seams alike in span, colour and light along one chunk border, as a bit +/// Seams alike in span, paint and light along one chunk border, as a bit /// per column along it: by face, the border's column, top and bottom, then /// the rest. -type Seams = HashMap<(Face, u32, u32, u32, [u8; 4], u8), u64>; +type Seams = HashMap<(Face, u32, u32, u32, Paint, u8), u64>; -/// `colors` gives a run's top and side colour, `alpha` how opaque it is. -/// Tops and bottoms alike merge into rectangles; sides are a quad per -/// uncovered stretch. +/// `look` tells how a run draws. Tops and bottoms alike merge into +/// rectangles; sides are a quad per uncovered stretch. /// /// See-through runs, water, hide nothing behind them, so the sea floor /// keeps its top and its sides beside water. They draw no side facing @@ -104,11 +156,11 @@ type Seams = HashMap<(Face, u32, u32, u32, [u8; 4], u8), u64>; pub(crate) fn mesh_section( key: SectionKey, section: &Section, - colors: impl Fn(RunKey) -> ([u8; 3], [u8; 3]), - alpha: impl Fn(RunKey) -> u8, + mut look: impl FnMut(RunKey) -> RunLook, ) -> SectionMesh { let w = key.column_width() as u32; - let opaque = |run: &Run| alpha(run.key) == 255; + let mut looks = HashMap::new(); + let mut look_of = |run: Run| *looks.entry(run.key).or_insert_with(|| look(run.key)); // Opaque quads, then see-through ones. let mut quads = [Vec::new(), Vec::new()]; let mut flat = FlatFaces::new(); @@ -120,33 +172,37 @@ pub(crate) fn mesh_section( if runs.is_empty() { continue; } - let neighbours: [Vec; 4] = - SIDES.map(|(_, dx, dz)| section.column(x + dx, z + dz).collect()); + let run_looks: Vec = runs.iter().map(|&run| look_of(run)).collect(); + let neighbours: [Vec<(Run, RunLook)>; 4] = SIDES.map(|(_, dx, dz)| { + section + .column(x + dx, z + dz) + .map(|run| (run, look_of(run))) + .collect() + }); let (x0, z0) = (x as u32 * w, z as u32 * w); let (x1, z1) = (x0 + w, z0 + w); - for (i, run) in runs.iter().enumerate() { - let a = alpha(run.key); - let see_through = a < 255; - let ([r, g, b], [sr, sg, sb]) = colors(run.key); - let (top_rgba, side_rgba) = ([r, g, b, a], [sr, sg, sb, a]); + for (i, (run, look)) in runs.iter().zip(&run_looks).enumerate() { + let see_through = look.translucent; let light = run.sky << 4 | run.block; let (bottom, top) = (u32::from(run.bottom), u32::from(run.top) + 1); min_y = min_y.min(run.bottom); max_y = max_y.max(run.top + 1); let mut flat_face = |face: Face, y| { - flat.entry((face, y, top_rgba, light)) - .or_insert([0; SECTION_COLUMNS as usize])[z as usize] |= 1 << x; + if let Some(paint) = look.paint(face) { + flat.entry((face, y, paint, light)) + .or_insert([0; SECTION_COLUMNS as usize])[z as usize] |= 1 << x; + } }; // A face against a run hides unless only the run is see-through. - let hidden_by = |other: &Run| opaque(other) || see_through; + let hidden_by = |other: &RunLook| other.occludes || see_through; let above_hides = - i > 0 && runs[i - 1].bottom == run.top + 1 && hidden_by(&runs[i - 1]); + i > 0 && runs[i - 1].bottom == run.top + 1 && hidden_by(&run_looks[i - 1]); if !above_hides { flat_face(Face::Up, top); } - let below_hides = runs - .get(i + 1) - .is_some_and(|below| below.top + 1 == run.bottom && hidden_by(below)); + let below_hides = runs.get(i + 1).is_some_and(|below| { + below.top + 1 == run.bottom && hidden_by(&run_looks[i + 1]) + }); if run.bottom > 0 && !below_hides { flat_face(Face::Down, bottom); } @@ -158,18 +214,22 @@ pub(crate) fn mesh_section( (Face::West, x0 % 16 == 0, x, z), (Face::East, x1 % 16 == 0, x, z), ] { - if on_border { + if on_border && let Some(paint) = look.paint(face) { *seams - .entry((face, line, top, bottom, side_rgba, light)) + .entry((face, line, top, bottom, paint, light)) .or_default() |= 1 << along; } } } for (&(face, ..), neighbour) in SIDES.iter().zip(&neighbours) { + let Some(paint) = look.paint(face) else { + continue; + }; // Water beside an opaque run doesn't hide it. let covering = neighbour .iter() - .filter(|other| see_through || opaque(other)); + .filter(|(_, other)| see_through || other.occludes) + .map(|(run, _)| run); for (low, high, sky) in uncovered(bottom, top, covering) { if see_through && sky.is_none() { continue; @@ -179,13 +239,13 @@ pub(crate) fn mesh_section( // sky over the column, as the run's own can be deep water's. let sky = sky.unwrap_or(runs[0].sky); let light = sky << 4 | run.block; - push_quad(&mut quads, corners, light, face, side_rgba, false); + push_quad(&mut quads, corners, light, face, paint, false, w); } } } } } - for ((face, y, rgba, light), mut rows) in flat { + for ((face, y, paint, light), mut rows) in flat { for (x, z, width, depth) in rectangles(&mut rows) { let (x0, z0) = (x * w, z * w); let (x1, z1) = (x0 + width * w, z0 + depth * w); @@ -193,10 +253,10 @@ pub(crate) fn mesh_section( Face::Up => [[x0, y, z0], [x1, y, z0], [x1, y, z1], [x0, y, z1]], _ => [[x0, y, z0], [x0, y, z1], [x1, y, z1], [x1, y, z0]], }; - push_quad(&mut quads, corners, light, face, rgba, false); + push_quad(&mut quads, corners, light, face, paint, false, w); } } - for ((face, line, top, bottom, rgba, light), mut bits) in seams { + for ((face, line, top, bottom, paint, light), mut bits) in seams { while bits != 0 { let (start, len, mask) = lowest_span(bits); bits &= !mask; @@ -207,7 +267,7 @@ pub(crate) fn mesh_section( _ => (across, along), }; let corners = side_corners(face, xs, zs, bottom, top); - push_quad(&mut quads, corners, light, face, rgba, true); + push_quad(&mut quads, corners, light, face, paint, true, w); } } let [mut vertices, translucent] = quads; @@ -300,23 +360,38 @@ fn uncovered<'a>( open } -/// A quad of `face`, its colour shaded for the direction it points, into -/// `quads`' opaque or see-through half by its alpha. +/// A quad of `face` into `quads`' opaque or see-through half. A model face +/// short of its side discards once the quad spans more than one column, +/// where its gaps would otherwise stretch. fn push_quad( quads: &mut [Vec; 2], corners: [[u32; 3]; 4], light: u8, face: Face, - [r, g, b, a]: [u8; 4], + paint: Paint, seam: bool, + w: u32, ) { - let [r, g, b] = [r, g, b].map(|c| (f32::from(c) * face.shade()).round() as u8); + let extent = |axis: usize| { + corners.iter().fold((u32::MAX, 0), |(lo, hi), c| { + (lo.min(c[axis]), hi.max(c[axis])) + }) + }; + let merged = (0..3).any(|axis| { + let (min, max) = extent(axis); + max - min > w + }); + let mut flags = paint.flags & !FACE_DISCARD_WHEN_MERGED; + if merged && paint.flags & FACE_DISCARD_WHEN_MERGED != 0 { + flags |= FACE_DISCARD; + } + if paint.translucent { + flags |= FACE_TRANSLUCENT; + } // Each corner moves out along the quad's own plane, closing pinholes // where quads meet (Distant Horizons' `LodQuadBuilder` micro offset). let micro_offset = |axis: usize, value: u32| { - let (min, max) = corners.iter().fold((u32::MAX, 0), |(lo, hi), c| { - (lo.min(c[axis]), hi.max(c[axis])) - }); + let (min, max) = extent(axis); if min == max { 0 } else if value == min { @@ -325,12 +400,15 @@ fn push_quad( 1 } }; - quads[usize::from(a < 255)].extend(corners.map(|[x, y, z]| DistantVertex { + quads[usize::from(paint.translucent)].extend(corners.map(|[x, y, z]| DistantVertex { xz: x | z << 16, y: y as u16, light, face: face as u8 | micro_offset(0, x) << 3 | micro_offset(2, z) << 5 | u8::from(seam) << 7, - color: [r, g, b, a], + color: paint.color, + model: paint.model, + flags, + _pad: 0, })); } @@ -388,12 +466,18 @@ mod tests { x: 0, z: 0, }; - mesh_section( - key, - store.get(key).unwrap(), - |_| ([0; 3], [0; 3]), - |key| if key.biome == 1 { 128 } else { 255 }, - ) + mesh_section(key, store.get(key).unwrap(), |key| { + let water = key.biome == 1; + let alpha = if water { 128 } else { 255 }; + RunLook { + model: 0, + translucent: water, + occludes: !water, + faces: [0; 6], + top: [0, 0, 0, alpha], + side: [0, 0, 0, alpha], + } + }) } fn quads(columns: impl Fn(usize) -> Vec) -> usize { diff --git a/pomme-client/src/renderer/distant/mod.rs b/pomme-client/src/renderer/distant/mod.rs index efdb59130..b8961507c 100644 --- a/pomme-client/src/renderer/distant/mod.rs +++ b/pomme-client/src/renderer/distant/mod.rs @@ -5,9 +5,11 @@ // TODO: generation beyond what's been seen, textures, shader packs, and // dimensions without sky light (a cave roof would cover them). +pub(crate) mod bakery; pub(crate) mod colors; pub(crate) mod column; pub(crate) mod gpu; pub(crate) mod mesh; +pub(crate) mod models; pub(crate) mod store; pub(crate) mod worker; diff --git a/pomme-client/src/renderer/distant/models.rs b/pomme-client/src/renderer/distant/models.rs new file mode 100644 index 000000000..96ee7cf4d --- /dev/null +++ b/pomme-client/src/renderer/distant/models.rs @@ -0,0 +1,434 @@ +//! Voxy's `ModelFactory`, `TextureUtils` and `MipGen`: block states baked +//! into models, each six face textures with mips in a tile of the model +//! atlas, plus what the mesher and shaders need to know of each face. + +use std::collections::HashMap; + +use azalea_block::BlockState; + +use super::bakery::{Bakery, FaceTexture, MODEL_TEXTURE_SIZE, TEXELS}; +use crate::renderer::chunk::atlas::AtlasUVMap; +use crate::world::block::render_assets::BlockRenderView; + +const SIZE: usize = MODEL_TEXTURE_SIZE; +/// Mip levels of a face texture: 16, 8, 4 and 2 texels. +pub(crate) const MODEL_MIP_LEVELS: u32 = SIZE.trailing_zeros(); +/// A model's tile: its six faces two rows of three, Voxy face index `i` at +/// column `i / 2`, row `i % 2`. +pub(crate) const TILE_WIDTH: u32 = SIZE as u32 * 3; +pub(crate) const TILE_HEIGHT: u32 = SIZE as u32 * 2; +/// Tiles a row of the model atlas holds, and rows. +pub(crate) const ATLAS_COLUMNS: u32 = 64; +pub(crate) const ATLAS_ROWS: u32 = 32; +/// Models the atlas holds; the first is the untextured one. +const CAPACITY: usize = (ATLAS_COLUMNS * ATLAS_ROWS) as usize; + +/// A face's flags as `distant.frag` reads them. +pub(crate) const FACE_DISCARD: u8 = 1; +/// Discards too once a quad spans more than one model: the face doesn't +/// cover its whole side. +pub(crate) const FACE_DISCARD_WHEN_MERGED: u8 = 2; +/// The tint, in bits 2-3: 1 only on grey texels, 2 everywhere. +pub(crate) const FACE_TINT_SHIFT: u8 = 2; +/// Set by the mesher on the faces it blends. +pub(crate) const FACE_TRANSLUCENT: u8 = 0x10; +/// Nothing drawn on that side. +pub(crate) const FACE_EMPTY: u8 = 0x80; + +/// Which layer a model draws in (vanilla `ChunkSectionLayer`). +#[derive(Clone, Copy, PartialEq, Eq)] +enum Layer { + Solid, + Cutout, + Translucent, +} + +/// What the mesher needs of a model. +#[derive(Clone, Copy)] +pub(crate) struct ModelInfo { + /// Blended over what's behind it, as water. + pub translucent: bool, + /// Every face fills its side, so it hides what it touches. + pub occludes: bool, + /// By Voxy face index ([`crate::world::block::model::Direction::VALUES`]). + pub faces: [u8; 6], +} + +/// Model 0's place: states without a model draw in their colours, never +/// asking for its info. +const UNTEXTURED: ModelInfo = ModelInfo { + translucent: false, + occludes: true, + faces: [0; 6], +}; + +/// A new model's tile and its mips, to upload to the atlas. +pub(crate) struct ModelTile { + pub id: u16, + /// RGBA per level, the level-0 tile first, row by row. + pub levels: Vec>, +} + +pub(crate) struct ModelFactory { + bakery: Bakery, + /// Each state's model, once baked. + id_mappings: HashMap, + /// Models by their faces, so states that look alike share one. + by_faces: HashMap, u16>, + models: Vec, + /// Tiles baked since the last [`Self::take_tiles`]. + tiles: Vec, +} + +impl ModelFactory { + pub fn new() -> Self { + Self { + bakery: Bakery::new(), + id_mappings: HashMap::new(), + by_faces: HashMap::new(), + models: vec![UNTEXTURED], + tiles: Vec::new(), + } + } + + pub fn info(&self, model: u16) -> ModelInfo { + self.models[usize::from(model)] + } + + pub fn take_tiles(&mut self) -> Vec { + std::mem::take(&mut self.tiles) + } + + /// `state`'s model, baked the first time; 0, untextured, when it draws + /// nothing or the atlas is full. + pub fn model( + &mut self, + view: BlockRenderView<'_>, + uv_map: &AtlasUVMap, + state: BlockState, + ) -> u16 { + if let Some(&id) = self.id_mappings.get(&state) { + return id; + } + let id = self.bake(view, uv_map, state); + self.id_mappings.insert(state, id); + id + } + + /// Voxy's `processModelResult` and `processTextureBakeResult`. + fn bake(&mut self, view: BlockRenderView<'_>, uv_map: &AtlasUVMap, state: BlockState) -> u16 { + let Some((faces, flags)) = self.bakery.render(view, uv_map, state) else { + return 0; + }; + let faces = Box::new(faces); + if let Some(&id) = self.by_faces.get(&faces) { + return id; + } + if self.models.len() == CAPACITY { + // TODO: a model atlas that grows past its capacity. + return 0; + } + let leaves = view + .block_state_table() + .block_state_data(state) + .id() + .ends_with("_leaves"); + let mut layer = None; + if flags.translucent { + layer = Some(if faces.iter().any(has_translucent_pixel) { + Layer::Translucent + } else if faces.iter().all(is_solid_where_drawn) { + Layer::Solid + } else { + Layer::Cutout + }); + } + if layer.is_none() && flags.discard { + layer = Some(Layer::Cutout); + } + if leaves { + layer = Some(Layer::Solid); + } + let layer = layer.unwrap_or(Layer::Solid); + let check = if layer == Layer::Solid { + WriteCheck::Stencil + } else { + WriteCheck::Alpha + }; + let depth_mode = if layer == Layer::Solid { + DepthMode::Avg + } else { + DepthMode::Min + }; + let mut fully_opaque = true; + let face_flags = std::array::from_fn(|i| { + let face = &faces[i]; + let Some(offset) = compute_depth(face, depth_mode, check) else { + fully_opaque = false; + return FACE_EMPTY; + }; + let [min_x, max_x, min_y, max_y] = compute_bounds(face, check); + let written = written_pixel_count(face, check) as f32; + let covers_block = min_x == 0 && min_y == 0 && max_x == SIZE - 1 && max_y == SIZE - 1; + let occludes = layer != Layer::Translucent + // A face drawn far in from its side doesn't hide the next block. + && offset < 0.1 + && written / TEXELS as f32 > 0.9; + fully_opaque &= occludes; + let area = + ((max_x + 1).saturating_sub(min_x) * (max_y + 1).saturating_sub(min_y)) as f32; + // Fences and the like are solid with gaps between their parts. + let discard = + (written / area < 0.9 || layer != Layer::Solid) && layer != Layer::Translucent; + let mut flags = u8::from(discard) * FACE_DISCARD; + if !covers_block && layer != Layer::Translucent { + flags |= FACE_DISCARD_WHEN_MERGED; + } + // Voxy asks only of tinted blocks; untinted quads mark no texel. + flags |= match face_tint(face, check) { + FaceTint::Some => 1, + FaceTint::All => 2, + FaceTint::Nothing | FaceTint::None => 0, + } << FACE_TINT_SHIFT; + flags + }); + let id = self.models.len() as u16; + self.models.push(ModelInfo { + translucent: layer == Layer::Translucent, + occludes: fully_opaque, + faces: face_flags, + }); + self.tiles.push(ModelTile { + id, + levels: mip_tile(flags.darkened, &faces), + }); + self.by_faces.insert(faces, id); + id + } +} + +/// How `TextureUtils` tells a texel was drawn. +#[derive(Clone, Copy)] +enum WriteCheck { + /// Some quad covered it. + Stencil, + /// Its alpha isn't (near) zero. + Alpha, +} + +fn was_pixel_written(face: &FaceTexture, check: WriteCheck, i: usize) -> bool { + match check { + WriteCheck::Stencil => face.depth[i] & 0xFF != 0, + // Voxy: some come out with an alpha of 1 where they're meant to be 0. + WriteCheck::Alpha => (face.colour[i] >> 24) & 0xFF > 1, + } +} + +fn written_pixel_count(face: &FaceTexture, check: WriteCheck) -> usize { + (0..TEXELS) + .filter(|&i| was_pixel_written(face, check, i)) + .count() +} + +fn has_translucent_pixel(face: &FaceTexture) -> bool { + (0..TEXELS).any(|i| { + let alpha = face.colour[i] >> 24; + face.depth[i] & 0xFF != 0 && alpha != 0 && alpha != 255 + }) +} + +fn is_solid_where_drawn(face: &FaceTexture) -> bool { + (0..TEXELS).all(|i| face.depth[i] & 0xFF == 0 || face.colour[i] >> 24 == 255) +} + +enum FaceTint { + Nothing, + None, + Some, + All, +} + +/// Voxy's `computeFaceTint`, over the drawn texels that aren't black or +/// clear. +fn face_tint(face: &FaceTexture, check: WriteCheck) -> FaceTint { + let (mut all, mut some, mut written) = (true, false, false); + for i in 0..TEXELS { + let colour = face.colour[i]; + if !was_pixel_written(face, check, i) || colour & 0xFF_FFFF == 0 || colour >> 24 == 0 { + continue; + } + let tinted = face.depth[i] & (1 << 7) != 0; + written = true; + all &= tinted; + some |= tinted; + } + match (written, some, all) { + (false, ..) => FaceTint::Nothing, + (true, false, _) => FaceTint::None, + (true, true, false) => FaceTint::Some, + (true, true, true) => FaceTint::All, + } +} + +#[derive(Clone, Copy)] +enum DepthMode { + Avg, + Min, +} + +/// Voxy's `computeDepth`: how far in from its side the face's drawn texels +/// sit, 0 to 1; `None` with nothing drawn. +fn compute_depth(face: &FaceTexture, mode: DepthMode, check: WriteCheck) -> Option { + let depths = (0..TEXELS) + .filter(|&i| was_pixel_written(face, check, i)) + .map(|i| u64::from(face.depth[i] >> 8)); + let depth = match mode { + DepthMode::Avg => { + let (count, sum) = depths.fold((0, 0), |(n, sum), d| (n + 1, sum + d)); + (count != 0).then(|| sum / count)? + } + DepthMode::Min => depths.min()?, + }; + Some((depth as f64 / f64::from((1 << 24) - 1)) as f32) +} + +/// Voxy's `computeBounds`: the drawn texels' `[min_x, max_x, min_y, max_y]`. +fn compute_bounds(face: &FaceTexture, check: WriteCheck) -> [usize; 4] { + let written = |x: usize, y: usize| was_pixel_written(face, check, x + y * SIZE); + let column = |x| (0..SIZE).any(|y| written(x, y)); + let row = |y| (0..SIZE).any(|x| written(x, y)); + let min_x = (0..SIZE).find(|&x| column(x)).unwrap_or(SIZE); + let max_x = (0..SIZE).rev().find(|&x| column(x)).unwrap_or(0); + let min_y = (0..SIZE).find(|&y| row(y)).unwrap_or(SIZE); + let max_y = (0..SIZE).rev().find(|&y| row(y)).unwrap_or(0); + [min_x, max_x, min_y, max_y] +} + +/// Voxy's `MipGen.putTextures`: the faces laid out in a tile, transparent +/// texels taking the colour of the nearest drawn one (unless the sprites +/// mip dark), then halved down to 2x2 faces. +fn mip_tile(darkened: bool, faces: &[FaceTexture; 6]) -> Vec> { + let width = TILE_WIDTH as usize; + let mut tile = vec![0u32; width * TILE_HEIGHT as usize]; + for (i, face) in faces.iter().enumerate() { + let (x, y) = ((i >> 1) * SIZE, (i & 1) * SIZE); + let mut colours = *face.colour; + if !darkened && colours.iter().any(|c| c >> 24 == 0) { + solidify(&mut colours); + } + for (j, &colour) in colours.iter().enumerate() { + tile[(y + j / SIZE) * width + x + j % SIZE] = colour; + } + } + let mut levels = vec![tile]; + for level in 1..MODEL_MIP_LEVELS as usize { + let source = &levels[level - 1]; + let source_width = width >> (level - 1); + let (w, h) = (width >> level, TILE_HEIGHT as usize >> level); + let next = (0..w * h) + .map(|i| { + let at = (i % w) * 2 + (i / w) * 2 * source_width; + mip_colours( + darkened, + [ + source[at], + source[at + source_width], + source[at + 1], + source[at + source_width + 1], + ], + ) + }) + .collect(); + levels.push(next); + } + levels + .into_iter() + .map(|level| level.iter().flat_map(|c| c.to_le_bytes()).collect()) + .collect() +} + +/// Voxy's `MipGen.solidify`: a breadth-first walk out from the drawn +/// texels, each clear one taking the nearest one's colour and keeping its +/// own zero alpha, so mips don't bleed black. +fn solidify(colours: &mut [u32; TEXELS]) { + // Per texel, the distance walked (high byte) and the drawn texel reached. + let mut nearest = [u16::MAX; TEXELS]; + let mut queue = std::collections::VecDeque::new(); + for (i, colour) in colours.iter().enumerate() { + if colour & 0xFF00_0000 != 0 { + nearest[i] = i as u16; + queue.push_back(i); + } + } + while let Some(at) = queue.pop_front() { + let (x, y) = ((at % SIZE) as i32, (at / SIZE) as i32); + let next = nearest[at] + 0x0100; + for (dx, dy) in [(0, 1), (0, -1), (1, 0), (-1, 0)] { + let (x2, y2) = (x + dx, y + dy); + if !(0..SIZE as i32).contains(&x2) || !(0..SIZE as i32).contains(&y2) { + continue; + } + let to = x2 as usize + y2 as usize * SIZE; + if next & 0xFF00 < nearest[to] & 0xFF00 { + nearest[to] = next; + queue.push_back(to); + } + } + } + for i in 0..TEXELS { + if nearest[i] & 0xFF00 != 0 { + colours[i] = colours[usize::from(nearest[i] & 0xFF)] & 0x00FF_FFFF; + } + } +} + +/// Voxy's `TextureUtils.mipColours`: the mean of four ABGR texels in linear +/// space. Dark-mipped sprites count only texels with alpha and still divide +/// by four, darkening their edges as vanilla's `dark_cutout` does. +fn mip_colours(darkened: bool, texels: [u32; 4]) -> u32 { + let mut sum = [0.0f32; 4]; + for texel in texels { + if darkened && texel >> 24 == 0 { + continue; + } + for (channel, total) in sum.iter_mut().enumerate().take(3) { + *total += srgb_to_linear((texel >> (channel * 8)) as u8); + } + let alpha = (texel >> 24) as u8; + sum[3] += if darkened { + srgb_to_linear(alpha) + } else { + f32::from(alpha) + }; + } + let [r, g, b, a] = sum.map(|total| total / 4.0); + let alpha = if darkened { + linear_to_srgb(a) + } else { + (sum[3] as u32 / 4) as u8 + }; + u32::from_le_bytes([ + linear_to_srgb(r), + linear_to_srgb(g), + linear_to_srgb(b), + alpha, + ]) +} + +fn srgb_to_linear(channel: u8) -> f32 { + let c = f32::from(channel) / 255.0; + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } +} + +fn linear_to_srgb(linear: f32) -> u8 { + let c = if linear <= 0.003_130_8 { + linear * 12.92 + } else { + 1.055 * linear.powf(1.0 / 2.4) - 0.055 + }; + (c * 255.0).round().clamp(0.0, 255.0) as u8 +} diff --git a/pomme-client/src/renderer/distant/worker.rs b/pomme-client/src/renderer/distant/worker.rs index c385f87f7..8c0b6889d 100644 --- a/pomme-client/src/renderer/distant/worker.rs +++ b/pomme-client/src/renderer/distant/worker.rs @@ -14,6 +14,7 @@ use parking_lot::RwLock; use super::colors::DistantColors; use super::column::build_chunk; use super::mesh::{SectionMesh, mesh_section}; +use super::models::{ModelFactory, ModelTile}; use super::store::{SectionKey, Store}; use crate::position::ChunkPos; use crate::world::chunk::{Chunk, ChunkLightData, ChunkStore}; @@ -60,12 +61,14 @@ impl View { } } -/// New and dropped section meshes for one dimension. +/// New and dropped section meshes for one dimension, and the models new +/// ones are the first to use. pub(crate) struct DistantUpdate { pub dimension: Arc, pub min_y: i32, pub meshes: Vec, pub removed: Vec, + pub tiles: Vec, } /// The game's handle on the thread. Dropping it waits for the thread to @@ -220,6 +223,7 @@ fn store<'a>( struct Worker { colors: Arc, + models: ModelFactory, updates: Sender, /// Per dimension, its sections and `min_y`. stores: HashMap, (Store, i32)>, @@ -236,6 +240,7 @@ impl Worker { fn new(colors: Arc, updates: Sender) -> Self { Self { colors, + models: ModelFactory::new(), updates, stores: HashMap::new(), cache: None, @@ -301,6 +306,10 @@ impl Worker { } } Job::Colors(colors) => { + if !colors.same_models(&self.colors) { + // Ids start over; the new tiles overwrite the old. + self.models = ModelFactory::new(); + } self.colors = colors; self.dirty.extend(self.meshed.iter().copied()); } @@ -338,6 +347,7 @@ impl Worker { min_y: *min_y, meshes: Vec::new(), removed, + tiles: Vec::new(), }); } self.view = Some(view); @@ -378,12 +388,9 @@ impl Worker { self.dirty.remove(&key); self.meshed.insert(key); match store.get(key) { - Some(section) => mesh_section( - key, - section, - |run| self.colors.colors(run), - |run| self.colors.alpha(run.state), - ), + Some(section) => mesh_section(key, section, |run| { + self.colors.run_look(&mut self.models, run) + }), None => SectionMesh::empty(key), } }) @@ -393,6 +400,7 @@ impl Worker { min_y: *min_y, meshes, removed: Vec::new(), + tiles: self.models.take_tiles(), }); } } diff --git a/pomme-client/src/renderer/mod.rs b/pomme-client/src/renderer/mod.rs index 53ce57bca..e444b4068 100644 --- a/pomme-client/src/renderer/mod.rs +++ b/pomme-client/src/renderer/mod.rs @@ -1881,6 +1881,7 @@ impl Renderer { let encoder = self.frames.begin(&self.ctx.device)?; self.atlas.record_animation_upload(&encoder, frame); + self.distant_buffers.record_model_upload(&encoder, frame); self.lightmap.record(&self.ctx.device, &encoder); let scissor = rhi::Rect::covering(self.swapchain.extent); diff --git a/pomme-client/src/renderer/pipelines/distant.rs b/pomme-client/src/renderer/pipelines/distant.rs index 678ce2252..fbfb9b610 100644 --- a/pomme-client/src/renderer/pipelines/distant.rs +++ b/pomme-client/src/renderer/pipelines/distant.rs @@ -3,15 +3,18 @@ use crate::renderer::distant::gpu::DistantBuffers; use crate::renderer::pipelines::lightmap::Lightmap; use crate::renderer::{rhi, shader, util}; -/// Set 1: the drawn-column mask `distant.frag` discards with. -const MASK_LAYOUT: &[rhi::BindGroupLayoutEntry] = &[rhi::BindGroupLayoutEntry::storage( - rhi::ShaderStages::FRAGMENT, -)]; +/// Set 1: the drawn-column mask `distant.frag` discards with, then the +/// model atlas and its sampler. +const MASK_MODELS_LAYOUT: &[rhi::BindGroupLayoutEntry] = &[ + rhi::BindGroupLayoutEntry::storage(rhi::ShaderStages::FRAGMENT), + rhi::BindGroupLayoutEntry::texture(rhi::ShaderStages::FRAGMENT), + rhi::BindGroupLayoutEntry::sampler(rhi::ShaderStages::FRAGMENT), +]; /// [`crate::renderer::distant::mesh::DistantVertex`]: packed x/z, y, light -/// and face, colour. +/// and face, colour, model and flags. const VERTEX_BUFFERS: [rhi::VertexBufferLayout<'static>; 1] = [rhi::VertexBufferLayout { - stride: 12, + stride: 16, step_mode: rhi::VertexStepMode::Vertex, attributes: &[ rhi::VertexAttribute { @@ -34,11 +37,22 @@ const VERTEX_BUFFERS: [rhi::VertexBufferLayout<'static>; 1] = [rhi::VertexBuffer format: rhi::VertexFormat::Unorm8x4, offset: 8, }, + rhi::VertexAttribute { + location: 4, + format: rhi::VertexFormat::Uint16, + offset: 12, + }, + rhi::VertexAttribute { + location: 5, + format: rhi::VertexFormat::Uint8x2, + offset: 14, + }, ], }]; -/// Distant terrain: coloured boxes behind the real chunks, under the -/// camera's fog and the lightmap; opaque, then see-through blended over it. +/// Distant terrain: boxes textured with their block's model behind the +/// real chunks, under the camera's fog and the lightmap; opaque, then +/// see-through blended over it. pub struct DistantPipeline { pipelines: [rhi::backend::RenderPipeline; 2], pipeline_layout: rhi::backend::PipelineLayout, @@ -59,7 +73,7 @@ impl DistantPipeline { buffers: &DistantBuffers, ) -> Self { let camera_layout = rhi::backend::create_bind_group_layout(device, util::VERTEX_UNIFORM); - let mask_layout = rhi::backend::create_bind_group_layout(device, MASK_LAYOUT); + let mask_layout = rhi::backend::create_bind_group_layout(device, MASK_MODELS_LAYOUT); // The view projection (once a frame), then the section origin. let pipeline_layout = rhi::backend::create_pipeline_layout( device, @@ -78,13 +92,23 @@ impl DistantPipeline { ); let mask_set = device.create_bind_group( mask_layout, - &[( - 0, - rhi::backend::BindingResource::storage( - buffers.mask(), - DistantBuffers::mask_bytes(), + &[ + ( + 0, + rhi::backend::BindingResource::storage( + buffers.mask(), + DistantBuffers::mask_bytes(), + ), + ), + ( + 1, + rhi::backend::BindingResource::Texture(buffers.model_atlas()), + ), + ( + 2, + rhi::backend::BindingResource::Sampler(buffers.model_sampler()), ), - )], + ], ); Self { pipelines, diff --git a/pomme-client/src/renderer/shaders/distant.frag b/pomme-client/src/renderer/shaders/distant.frag index 56e567c4e..7a7f3971a 100644 --- a/pomme-client/src/renderer/shaders/distant.frag +++ b/pomme-client/src/renderer/shaders/distant.frag @@ -1,12 +1,16 @@ #version 450 #include "fog.glsl" +#include "distant_models.glsl" // One bit per chunk column, ring-indexed as `world/chunk.rs` `ring_slot`: // set where a real chunk draws instead. layout(set = 1, binding = 0) readonly buffer DrawnColumns { uint drawn[]; }; +// Every model's face textures (models.rs). +layout(set = 1, binding = 1) uniform texture2D model_atlas; +layout(set = 1, binding = 2) uniform sampler model_sampler; layout(location = 0) in vec4 v_color; layout(location = 1) in vec3 v_fog_color; @@ -15,6 +19,10 @@ layout(location = 3) in vec2 v_column_pos; layout(location = 4) flat in ivec2 v_anchor; layout(location = 5) flat in vec2 v_outward; layout(location = 6) flat in uint v_seam; +layout(location = 7) in vec3 v_light; +layout(location = 8) in vec2 v_uv; +layout(location = 9) flat in vec2 v_tile; +layout(location = 10) flat in uint v_flags; layout(location = 0) out vec4 out_color; @@ -29,7 +37,38 @@ bool drawn_at(vec2 pos) { return (drawn[slot >> 5] & (1u << (slot & 31u))) != 0u; } +// Voxy's `quads.frag`: the face's texel, mipped by how fast the unwrapped +// texture coordinate moves so the wrap from column to column doesn't pick +// the smallest mip; cut out by its unmipped alpha; tinted per the face's +// tint state. +vec4 model_color(vec2 dx, vec2 dy) { + vec2 pos = (v_tile + fract(v_uv) * FACE_SIZE) / ATLAS_SIZE; + vec4 color = textureGrad(sampler2D(model_atlas, model_sampler), pos, dx, dy); + vec4 exact = textureLod(sampler2D(model_atlas, model_sampler), pos, 0.0); + if ((v_flags & FACE_TRANSLUCENT) != 0u) { + if (exact.a == 0.0) { + discard; + } + } else { + color.a = 1.0; + if ((v_flags & FACE_DISCARD) != 0u && exact.a <= 0.1) { + discard; + } + } + uint tint = (v_flags >> 2) & 3u; + // Partly tinted, as a grass block's side: only its grey texels. + bool grey = abs(exact.r - exact.g) < 0.02 && abs(exact.g - exact.b) < 0.02; + if (tint == 2u || (tint == 1u && grey)) { + color.rgb *= v_color.rgb; + } + return color; +} + void main() { + // Before anything discards. + vec2 unwrapped = v_uv * (FACE_SIZE / ATLAS_SIZE); + vec2 dx = dFdx(unwrapped); + vec2 dy = dFdy(unwrapped); // Nothing over a real chunk, nor a side facing one: the chunk draws its // own edge, and through its water this one would show as a wall. A // seam is the opposite, drawn only facing one. @@ -37,5 +76,6 @@ void main() { if (drawn_at(v_column_pos) || faces_drawn == (v_seam == 0u)) { discard; } - out_color = vec4(apply_fog(v_color.rgb, v_fog, v_fog_color), v_color.a); + vec4 color = (v_flags & TEXTURED) != 0u ? model_color(dx, dy) : v_color; + out_color = vec4(apply_fog(color.rgb * v_light, v_fog, v_fog_color), color.a); } diff --git a/pomme-client/src/renderer/shaders/distant.vert b/pomme-client/src/renderer/shaders/distant.vert index 451123b45..3e4053ac9 100644 --- a/pomme-client/src/renderer/shaders/distant.vert +++ b/pomme-client/src/renderer/shaders/distant.vert @@ -1,17 +1,19 @@ #version 450 -// Distant terrain (renderer/distant): section-local boxes, coloured and -// shaded on the CPU, lit through the lightmap and fogged like terrain. +// Distant terrain (renderer/distant): section-local boxes textured with +// their block's baked model (Voxy's `quads3.vert`, `quad_util.glsl`), lit +// through the lightmap and fogged like terrain. #include "fog.glsl" #include "camera_ubo.glsl" #include "lightmap.glsl" +#include "distant_models.glsl" layout(push_constant) uniform DistantPc { // The camera's view projection with the distant near and far planes. mat4 distant_view_proj; // The section's corner (at the dimension's min_y), relative to the - // camera anchor. + // camera anchor, and its column width in blocks. vec4 origin; }; @@ -19,7 +21,10 @@ layout(location = 0) in uint in_xz; layout(location = 1) in uint in_y; layout(location = 2) in uvec2 in_light_face; layout(location = 3) in vec4 in_color; +layout(location = 4) in uint in_model; +layout(location = 5) in uvec2 in_flags; +// The tint over the texture, or with no model the colour, and its alpha. layout(location = 0) out vec4 v_color; layout(location = 1) out vec3 v_fog_color; layout(location = 2) out float v_fog; @@ -32,6 +37,15 @@ layout(location = 4) flat out ivec2 v_anchor; layout(location = 5) flat out vec2 v_outward; // 1 for a seam (mesh.rs `mesh_section`). layout(location = 6) flat out uint v_seam; +// The lightmap's colour under the face's directional shade. +layout(location = 7) out vec3 v_light; +// Where on its face texture the fragment is, a texture per column: the +// integer part steps a column, the fraction is the texel. +layout(location = 8) out vec2 v_uv; +// The face texture's corner in the model atlas, in texels. +layout(location = 9) flat out vec2 v_tile; +// The face's flags (distant_models.glsl). +layout(location = 10) flat out uint v_flags; // Distant Horizons' micro offset: how far a corner moves out along its // quad's plane. @@ -43,7 +57,15 @@ const vec2 FACE_NUDGE[6] = vec2[]( vec2(0.0, 0.5), vec2(0.0, -0.5), vec2(0.5, 0.0), vec2(-0.5, 0.0) ); - +// Vanilla's directional shade (the overworld's cardinal lighting). +const float FACE_SHADE[6] = float[](1.0, 0.5, 0.8, 0.8, 0.6, 0.6); +// The face's index in its model's (mesh.rs `Face::voxy_index`). +const uint MODEL_FACE[6] = uint[](1u, 0u, 2u, 3u, 4u, 5u); +// Per model face, the block axes its texture's x and y run along +// (bakery.rs `TILE_AXES`). +const ivec2 TILE_AXES[6] = ivec2[]( + ivec2(0, 2), ivec2(0, 2), ivec2(0, 1), ivec2(0, 1), ivec2(1, 2), ivec2(1, 2) +); // A corner's micro offset along one axis, from its 2 bits (mesh.rs // `DistantVertex::face`). float micro_offset(uint bits) { @@ -52,6 +74,14 @@ float micro_offset(uint bits) { void main() { vec3 local = vec3(float(in_xz & 0xFFFFu), float(in_y), float(in_xz >> 16)); + uint face = min(in_light_face.y & 7u, 5u); + uint model_face = MODEL_FACE[face]; + vec3 columns = local / origin.w; + v_uv = vec2(columns[TILE_AXES[model_face].x], columns[TILE_AXES[model_face].y]); + v_tile = vec2(in_model % ATLAS_COLUMNS, in_model / ATLAS_COLUMNS) * TILE_SIZE + + vec2(model_face >> 1u, model_face & 1u) * FACE_SIZE; + v_flags = in_flags.x | (in_model == 0u ? 0u : TEXTURED); + local.xz += vec2(micro_offset(in_light_face.y >> 3), micro_offset(in_light_face.y >> 5)); vec3 world = origin.xyz + local; vec3 rel = world - camera_pos.xyz; @@ -59,10 +89,11 @@ void main() { uint light = in_light_face.x; vec2 lightmap_uv = vec2(float(light & 0xFu), float(light >> 4)) * 16.0; - v_color = vec4(in_color.rgb * sample_lightmap(lightmap_uv).rgb, in_color.a); + v_light = sample_lightmap(lightmap_uv).rgb * FACE_SHADE[face]; + v_color = in_color; v_fog_color = fog_color.rgb; v_fog = total_fog_value(rel, fog_env, camera_pos.w, fog_color.w); - vec2 nudge = FACE_NUDGE[min(in_light_face.y & 7u, 5u)]; + vec2 nudge = FACE_NUDGE[face]; v_column_pos = world.xz + nudge; v_anchor = camera_block.xz; v_outward = -2.0 * nudge; diff --git a/pomme-client/src/renderer/shaders/distant_models.glsl b/pomme-client/src/renderer/shaders/distant_models.glsl new file mode 100644 index 000000000..7eda81a0a --- /dev/null +++ b/pomme-client/src/renderer/shaders/distant_models.glsl @@ -0,0 +1,11 @@ +// Distant terrain's model atlas (renderer/distant/models.rs): a tile per +// model, its six face textures two rows of three. +const uint ATLAS_COLUMNS = 64u; +const vec2 TILE_SIZE = vec2(48.0, 32.0); +const vec2 ATLAS_SIZE = vec2(ATLAS_COLUMNS, 32u) * TILE_SIZE; +const float FACE_SIZE = 16.0; + +// models.rs face flags, and the one distant.vert adds for a textured model. +const uint FACE_DISCARD = 1u; +const uint FACE_TRANSLUCENT = 0x10u; +const uint TEXTURED = 0x100u; From 500049002305263dafef7b4f2ed33a7b13a7aa4c Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:54:27 +0000 Subject: [PATCH 14/67] evict the farthest distant sections when full and let captures settle before meshing --- pomme-client/src/app/phases/in_game.rs | 3 +- pomme-client/src/renderer/distant/gpu.rs | 148 +++++++++++++++----- pomme-client/src/renderer/distant/store.rs | 2 +- pomme-client/src/renderer/distant/worker.rs | 92 ++++++++++-- pomme-client/src/renderer/mod.rs | 10 +- 5 files changed, 199 insertions(+), 56 deletions(-) diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 03f477a05..b261a119a 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -1311,11 +1311,12 @@ impl GameState { let batch = self.drain_meshes(); let upload_start = std::time::Instant::now(); let dimension = self.dimension.as_str(); - renderer.upload_distant( + let evicted = renderer.upload_distant( self.distant .drain() .filter(|update| &*update.dimension == dimension), ); + self.distant.evicted(evicted); let dropped = renderer.upload_chunk_meshes(&batch); self.last_update_phases.upload_ms = upload_start.elapsed().as_secs_f32() * 1000.0; self.clear_dropped_meshed(dropped); diff --git a/pomme-client/src/renderer/distant/gpu.rs b/pomme-client/src/renderer/distant/gpu.rs index d6d33632c..702d52d71 100644 --- a/pomme-client/src/renderer/distant/gpu.rs +++ b/pomme-client/src/renderer/distant/gpu.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use glam::DVec3; -use super::mesh::DistantVertex; +use super::mesh::{DistantVertex, SectionMesh}; use super::models::{ ATLAS_COLUMNS, ATLAS_ROWS, MODEL_MIP_LEVELS, ModelTile, TILE_HEIGHT, TILE_WIDTH, }; @@ -96,6 +96,9 @@ pub(crate) struct DistantBuffers { model_sampler: rhi::backend::Sampler, /// Tiles waiting for [`Self::record_model_upload`]. pending_tiles: VecDeque, + /// Meshes waiting for the room evicting made, which frees a few frames + /// on. + waiting: Vec, tile_staging: Vec, } @@ -161,6 +164,7 @@ impl DistantBuffers { }, ), pending_tiles: VecDeque::new(), + waiting: Vec::new(), tile_staging: util::create_per_frame_host_buffers( device, (TILE_BYTES * TILES_PER_FRAME) as u64, @@ -259,6 +263,7 @@ impl DistantBuffers { for key in keys { self.retire(key); } + self.waiting.clear(); self.dimension = None; } @@ -300,44 +305,119 @@ impl DistantBuffers { Some((self.arenas.len() - 1, first_vertex)) } - pub fn upload(&mut self, device: &rhi::backend::Device, update: DistantUpdate) { - if self.dimension.as_ref() != Some(&update.dimension) { - self.clear(); - self.dimension = Some(Arc::clone(&update.dimension)); - } - self.min_y = update.min_y; - self.pending_tiles.extend(update.tiles); - for key in update.removed { - self.retire(key); + /// Takes the worker's updates, then the meshes waiting for room. With + /// the arenas full, a mesh makes room by evicting the finer sections + /// farther from `eye` than it, farthest first, their parents drawing in + /// their place; a mesh with none farther is dropped. Returns the + /// sections evicted or dropped, for the worker to mesh again once + /// nearer. + pub fn upload( + &mut self, + device: &rhi::backend::Device, + updates: impl IntoIterator, + eye: DVec3, + ) -> Vec { + let mut meshes = std::mem::take(&mut self.waiting); + for update in updates { + if self.dimension.as_ref() != Some(&update.dimension) { + self.clear(); + meshes.clear(); + self.dimension = Some(Arc::clone(&update.dimension)); + } + self.min_y = update.min_y; + self.pending_tiles.extend(update.tiles); + for key in update.removed { + self.retire(key); + meshes.retain(|mesh| mesh.key != key); + } + for mesh in update.meshes { + meshes.retain(|waiting| waiting.key != mesh.key); + meshes.push(mesh); + } } - for mesh in update.meshes { - self.retire(mesh.key); - let count = mesh.vertices.len() as u32; - let (arena, first_vertex) = if count == 0 { - (0, 0) + let mut gone = Vec::new(); + for mesh in meshes { + let Err(mesh) = self.place(device, mesh) else { + continue; + }; + let distance = mesh.key.distance(eye.x, eye.z); + if self.evict_beyond(distance, mesh.vertices.len() as u32, eye, &mut gone) { + self.waiting.push(mesh); } else { - let Some((arena, first_vertex)) = self.alloc(device, count) else { - tracing::warn!("No room for distant terrain section {:?}", mesh.key); - continue; - }; - self.arenas[arena].buffer.write_at( - (u64::from(first_vertex) * VERTEX_SIZE) as usize, - bytemuck::cast_slice(&mesh.vertices), - ); - (arena, first_vertex) + gone.push(mesh.key); + } + } + gone + } + + /// Puts `mesh` in an arena in place of its section's last; gives it + /// back without room. + fn place( + &mut self, + device: &rhi::backend::Device, + mesh: SectionMesh, + ) -> Result<(), SectionMesh> { + let count = mesh.vertices.len() as u32; + let (arena, first_vertex) = if count == 0 { + (0, 0) + } else { + let Some((arena, first_vertex)) = self.alloc(device, count) else { + return Err(mesh); }; - self.sections.insert( - mesh.key, - Uploaded { - arena, - first_vertex, - vertices: count, - translucent_start: mesh.translucent_start, - min_y: mesh.min_y, - max_y: mesh.max_y, - }, + self.arenas[arena].buffer.write_at( + (u64::from(first_vertex) * VERTEX_SIZE) as usize, + bytemuck::cast_slice(&mesh.vertices), ); + (arena, first_vertex) + }; + self.retire(mesh.key); + self.sections.insert( + mesh.key, + Uploaded { + arena, + first_vertex, + vertices: count, + translucent_start: mesh.translucent_start, + min_y: mesh.min_y, + max_y: mesh.max_y, + }, + ); + Ok(()) + } + + /// Retires sections farther from `eye` than `distance` whose parent is + /// uploaded to draw instead, farthest first, until they held `count` + /// vertices; whether there were any. + fn evict_beyond( + &mut self, + distance: f64, + count: u32, + eye: DVec3, + gone: &mut Vec, + ) -> bool { + let mut far: Vec<(f64, SectionKey)> = self + .sections + .iter() + .filter(|(key, section)| { + section.vertices > 0 + && key + .parent() + .is_some_and(|parent| self.sections.contains_key(&parent)) + }) + .map(|(key, _)| (key.distance(eye.x, eye.z), *key)) + .filter(|&(far, _)| far > distance) + .collect(); + far.sort_by(|a, b| b.0.total_cmp(&a.0)); + let mut freed = 0; + for &(_, key) in &far { + if freed >= count { + break; + } + freed += self.sections[&key].vertices; + self.retire(key); + gone.push(key); } + !far.is_empty() } /// Whether the finest section holding block column `x`/`z` is uploaded diff --git a/pomme-client/src/renderer/distant/store.rs b/pomme-client/src/renderer/distant/store.rs index d2536d533..c88d115d3 100644 --- a/pomme-client/src/renderer/distant/store.rs +++ b/pomme-client/src/renderer/distant/store.rs @@ -67,7 +67,7 @@ impl SectionKey { Some([child(0, 0), child(1, 0), child(0, 1), child(1, 1)]) } - fn parent(self) -> Option { + pub fn parent(self) -> Option { (self.level + 1 < LEVELS).then(|| Self { level: self.level + 1, x: self.x.div_euclid(2), diff --git a/pomme-client/src/renderer/distant/worker.rs b/pomme-client/src/renderer/distant/worker.rs index 8c0b6889d..5ceb2308c 100644 --- a/pomme-client/src/renderer/distant/worker.rs +++ b/pomme-client/src/renderer/distant/worker.rs @@ -28,6 +28,9 @@ const KEEP_MARGIN: f64 = 1024.0; const VIEW_STEP: f64 = 32.0; /// How often changed sections are written to disk. const SAVE_EVERY: Duration = Duration::from_secs(60); +/// How long a section a capture changed waits for more before it's meshed, +/// so a burst of captures meshes it once. +const SETTLE: Duration = Duration::from_millis(250); enum Job { Capture { @@ -39,6 +42,8 @@ enum Job { }, Colors(Arc), View(View), + /// Sections the renderer evicted or dropped for room. + Evicted(Vec), /// Where sections are kept on disk, a folder per dimension; `None` /// keeps them in memory only. Cache(Option), @@ -154,6 +159,13 @@ impl DistantTerrain { let _ = self.jobs.send(Job::Colors(colors)); } + /// Sections the renderer had no room for, meshed again once nearer. + pub fn evicted(&self, keys: Vec) { + if !keys.is_empty() { + let _ = self.jobs.send(Job::Evicted(keys)); + } + } + /// Where the player is and how far to keep and draw, in chunks; 0 stops /// keeping new chunks. pub fn set_view(&mut self, dimension: &str, x: f64, z: f64, chunks: u32) { @@ -230,10 +242,12 @@ struct Worker { cache: Option, last_save: Instant, view: Option, - /// The viewed dimension's sections to mesh. - dirty: HashSet, + /// The viewed dimension's sections to mesh, by when. + dirty: HashMap, /// The viewed dimension's sections with a mesh sent. meshed: HashSet, + /// Sections the renderer had no room for, by how far they were. + evicted: HashMap, } impl Worker { @@ -246,17 +260,30 @@ impl Worker { cache: None, last_save: Instant::now(), view: None, - dirty: HashSet::new(), + dirty: HashMap::new(), meshed: HashSet::new(), + evicted: HashMap::new(), } } + /// Queues `key` to mesh from `ready`, no longer evicted. + fn mark_dirty(&mut self, key: SectionKey, ready: Instant) { + self.evicted.remove(&key); + self.dirty.insert(key, ready); + } + + /// Whether `key`, if evicted, is now a section nearer than it was. + fn wants_back(&self, view: &View, key: SectionKey) -> bool { + self.evicted + .get(&key) + .is_none_or(|&was| key.distance(view.x, view.z) + f64::from(key.width()) < was) + } + fn run(mut self, jobs: &Receiver) { 'run: loop { - let wait = if self.dirty.is_empty() { - SAVE_EVERY.saturating_sub(self.last_save.elapsed()) - } else { - Duration::ZERO + let wait = match self.dirty.values().min() { + Some(ready) => ready.saturating_duration_since(Instant::now()), + None => SAVE_EVERY.saturating_sub(self.last_save.elapsed()), }; let first = match jobs.recv_timeout(wait) { Ok(job) => Some(job), @@ -298,11 +325,15 @@ impl Worker { store(&mut self.stores, self.cache.as_deref(), &dimension); *store_min_y = min_y; let touched = store.capture(pos, columns); - if let Some(view) = &self.view + if let Some(view) = self.view.clone() && view.dimension == dimension { - self.dirty - .extend(touched.into_iter().filter(|key| view.wants(*key))); + let ready = Instant::now() + SETTLE; + for key in touched { + if view.wants(key) && self.wants_back(&view, key) { + self.mark_dirty(key, ready); + } + } } } Job::Colors(colors) => { @@ -311,7 +342,8 @@ impl Worker { self.models = ModelFactory::new(); } self.colors = colors; - self.dirty.extend(self.meshed.iter().copied()); + let now = Instant::now(); + self.dirty.extend(self.meshed.iter().map(|&key| (key, now))); } Job::View(view) => { if self @@ -322,6 +354,7 @@ impl Worker { // The renderer drops the old dimension's meshes itself. self.meshed.clear(); self.dirty.clear(); + self.evicted.clear(); } let (store, min_y) = store(&mut self.stores, self.cache.as_deref(), &view.dimension); @@ -335,16 +368,26 @@ impl Worker { store.load(*key); }) .collect(); + let min_y = *min_y; let removed: Vec = self.meshed.difference(&wanted).copied().collect(); for key in &removed { self.meshed.remove(key); } - self.dirty.retain(|key| wanted.contains(key)); - self.dirty.extend(wanted.difference(&self.meshed).copied()); + self.dirty.retain(|key, _| wanted.contains(key)); + self.evicted.retain(|key, _| wanted.contains(key)); + let now = Instant::now(); + let back: Vec = wanted + .difference(&self.meshed) + .copied() + .filter(|&key| self.wants_back(&view, key)) + .collect(); + for key in back { + self.mark_dirty(key, now); + } if !removed.is_empty() { let _ = self.updates.send(DistantUpdate { dimension: Arc::clone(&view.dimension), - min_y: *min_y, + min_y, meshes: Vec::new(), removed, tiles: Vec::new(), @@ -352,6 +395,16 @@ impl Worker { } self.view = Some(view); } + Job::Evicted(keys) => { + let Some(view) = &self.view else { + return; + }; + for key in keys { + self.meshed.remove(&key); + self.dirty.remove(&key); + self.evicted.insert(key, key.distance(view.x, view.z)); + } + } Job::Cache(cache) => { for (dimension, (store, _)) in &mut self.stores { store.set_dir(dimension_dir(cache.as_deref(), dimension)); @@ -375,7 +428,16 @@ impl Worker { self.dirty.clear(); return; }; - let mut nearest: Vec = self.dirty.iter().copied().collect(); + let now = Instant::now(); + let mut nearest: Vec = self + .dirty + .iter() + .filter(|&(_, &ready)| ready <= now) + .map(|(&key, _)| key) + .collect(); + if nearest.is_empty() { + return; + } nearest.sort_by(|a, b| { a.distance(view.x, view.z) .total_cmp(&b.distance(view.x, view.z)) diff --git a/pomme-client/src/renderer/mod.rs b/pomme-client/src/renderer/mod.rs index e444b4068..128a9235c 100644 --- a/pomme-client/src/renderer/mod.rs +++ b/pomme-client/src/renderer/mod.rs @@ -1380,14 +1380,14 @@ impl Renderer { self.chunk_buffers.clear(); } - /// New and dropped distant terrain sections. + /// New and dropped distant terrain sections; returns those evicted for + /// room ([`distant::gpu::DistantBuffers::upload`]). pub(crate) fn upload_distant( &mut self, updates: impl IntoIterator, - ) { - for update in updates { - self.distant_buffers.upload(&self.ctx.device, update); - } + ) -> Vec { + let eye = self.camera_render_position(); + self.distant_buffers.upload(&self.ctx.device, updates, eye) } /// Whether a real chunk draws at `pos`, where distant terrain gives way From 18a06fdb51c37d95efdef519c19f6867c6bd2588 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:24:20 +0000 Subject: [PATCH 15/67] place distant terrain loaded from disk at the dimension's floor before anything is captured --- pomme-client/src/app/phases/in_game.rs | 1 + pomme-client/src/renderer/distant/worker.rs | 38 ++++++++++----------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index b261a119a..edab1c164 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -4424,6 +4424,7 @@ pub fn update_game( }; game.distant.set_view( &game.dimension, + game.chunk_store.min_y(), game.player.position.x, game.player.position.z, distant_terrain, diff --git a/pomme-client/src/renderer/distant/worker.rs b/pomme-client/src/renderer/distant/worker.rs index 5ceb2308c..08edae30a 100644 --- a/pomme-client/src/renderer/distant/worker.rs +++ b/pomme-client/src/renderer/distant/worker.rs @@ -54,6 +54,8 @@ enum Job { #[derive(Clone)] struct View { dimension: Arc, + /// The dimension's lowest block, which the runs stand on. + min_y: i32, x: f64, z: f64, /// In blocks. @@ -168,19 +170,21 @@ impl DistantTerrain { /// Where the player is and how far to keep and draw, in chunks; 0 stops /// keeping new chunks. - pub fn set_view(&mut self, dimension: &str, x: f64, z: f64, chunks: u32) { + pub fn set_view(&mut self, dimension: &str, min_y: i32, x: f64, z: f64, chunks: u32) { if chunks == 0 { self.view = None; return; } let view = View { dimension: dimension.into(), + min_y, x, z, radius: f64::from(chunks * 16), }; let unchanged = self.view.as_ref().is_some_and(|last| { last.dimension == view.dimension + && last.min_y == view.min_y && last.radius == view.radius && (last.x - x).hypot(last.z - z) < VIEW_STEP }); @@ -222,23 +226,23 @@ fn dimension_dir(cache: Option<&Path>, dimension: &str) -> Option { Some(cache?.join(folder_name(dimension))) } -/// `dimension`'s store and `min_y`, made with its folder under `cache`. +/// `dimension`'s store, made with its folder under `cache`. fn store<'a>( - stores: &'a mut HashMap, (Store, i32)>, + stores: &'a mut HashMap, Store>, cache: Option<&Path>, dimension: &Arc, -) -> &'a mut (Store, i32) { +) -> &'a mut Store { stores .entry(Arc::clone(dimension)) - .or_insert_with(|| (Store::new(dimension_dir(cache, dimension)), 0)) + .or_insert_with(|| Store::new(dimension_dir(cache, dimension))) } struct Worker { colors: Arc, models: ModelFactory, updates: Sender, - /// Per dimension, its sections and `min_y`. - stores: HashMap, (Store, i32)>, + /// Per dimension, its sections. + stores: HashMap, Store>, cache: Option, last_save: Instant, view: Option, @@ -305,7 +309,7 @@ impl Worker { } fn save(&mut self) { - for (store, _) in self.stores.values_mut() { + for store in self.stores.values_mut() { store.flush(); } self.last_save = Instant::now(); @@ -321,10 +325,8 @@ impl Worker { min_y, } => { let columns = build_chunk(&chunk.read(), light.as_deref(), min_y, &self.colors); - let (store, store_min_y) = - store(&mut self.stores, self.cache.as_deref(), &dimension); - *store_min_y = min_y; - let touched = store.capture(pos, columns); + let touched = store(&mut self.stores, self.cache.as_deref(), &dimension) + .capture(pos, columns); if let Some(view) = self.view.clone() && view.dimension == dimension { @@ -356,8 +358,7 @@ impl Worker { self.dirty.clear(); self.evicted.clear(); } - let (store, min_y) = - store(&mut self.stores, self.cache.as_deref(), &view.dimension); + let store = store(&mut self.stores, self.cache.as_deref(), &view.dimension); store.evict_beyond(view.x, view.z, view.radius + KEEP_MARGIN); // Those with nothing seen mesh empty, so a parent knows its // children are all there. @@ -368,7 +369,6 @@ impl Worker { store.load(*key); }) .collect(); - let min_y = *min_y; let removed: Vec = self.meshed.difference(&wanted).copied().collect(); for key in &removed { self.meshed.remove(key); @@ -387,7 +387,7 @@ impl Worker { if !removed.is_empty() { let _ = self.updates.send(DistantUpdate { dimension: Arc::clone(&view.dimension), - min_y, + min_y: view.min_y, meshes: Vec::new(), removed, tiles: Vec::new(), @@ -406,7 +406,7 @@ impl Worker { } } Job::Cache(cache) => { - for (dimension, (store, _)) in &mut self.stores { + for (dimension, store) in &mut self.stores { store.set_dir(dimension_dir(cache.as_deref(), dimension)); } self.cache = cache; @@ -424,7 +424,7 @@ impl Worker { self.dirty.clear(); return; }; - let Some((store, min_y)) = self.stores.get(&view.dimension) else { + let Some(store) = self.stores.get(&view.dimension) else { self.dirty.clear(); return; }; @@ -459,7 +459,7 @@ impl Worker { .collect(); let _ = self.updates.send(DistantUpdate { dimension: Arc::clone(&view.dimension), - min_y: *min_y, + min_y: view.min_y, meshes, removed: Vec::new(), tiles: self.models.take_tiles(), From b218d4590cb9600674b54d8223b982c3cd60df26 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:03:46 +0000 Subject: [PATCH 16/67] cut distant leaf holes out at full detail --- pomme-client/src/renderer/distant/colors.rs | 2 + pomme-client/src/renderer/distant/mesh.rs | 68 +++++++++++++++++---- pomme-client/src/renderer/distant/models.rs | 4 ++ 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/pomme-client/src/renderer/distant/colors.rs b/pomme-client/src/renderer/distant/colors.rs index e6b7b6e11..2a2452346 100644 --- a/pomme-client/src/renderer/distant/colors.rs +++ b/pomme-client/src/renderer/distant/colors.rs @@ -177,6 +177,7 @@ impl DistantColors { translucent: a < 255, occludes: a == 255, faces: [0; 6], + holes: false, top: [r, g, b, a], side: [sr, sg, sb, a], }; @@ -188,6 +189,7 @@ impl DistantColors { translucent: info.translucent, occludes: info.occludes, faces: info.faces, + holes: info.holes, top: [r, g, b, 255], side: [r, g, b, 255], } diff --git a/pomme-client/src/renderer/distant/mesh.rs b/pomme-client/src/renderer/distant/mesh.rs index c77512809..ec6e8dc1e 100644 --- a/pomme-client/src/renderer/distant/mesh.rs +++ b/pomme-client/src/renderer/distant/mesh.rs @@ -46,12 +46,24 @@ pub(crate) struct RunLook { pub occludes: bool, /// The model's face flags, by Voxy face index. pub faces: [u8; 6], + /// [`super::models::ModelInfo::holes`]. + pub holes: bool, /// The top's and bottom's colour, then the sides'. pub top: [u8; 4], pub side: [u8; 4], } impl RunLook { + /// Its texture's holes cut out, as vanilla draws leaves, so it hides + /// nothing behind it. + fn cut_out(self) -> Self { + Self { + occludes: false, + faces: self.faces.map(|flags| flags | FACE_DISCARD), + ..self + } + } + /// What `face` draws with; `None` where the model draws nothing. fn paint(&self, face: Face) -> Option { let flags = self.faces[face.voxy_index()]; @@ -160,7 +172,18 @@ pub(crate) fn mesh_section( ) -> SectionMesh { let w = key.column_width() as u32; let mut looks = HashMap::new(); - let mut look_of = |run: Run| *looks.entry(run.key).or_insert_with(|| look(run.key)); + // Holes show at full detail, beside real chunks; coarser, the mips + // already blend them away, and staying solid keeps forests cheap. + let mut look_of = |run: Run| { + *looks.entry(run.key).or_insert_with(|| { + let look = look(run.key); + if key.level == 0 && look.holes { + look.cut_out() + } else { + look + } + }) + }; // Opaque quads, then see-through ones. let mut quads = [Vec::new(), Vec::new()]; let mut flat = FlatFaces::new(); @@ -456,16 +479,12 @@ mod tests { ); } - /// The level-0 mesh of chunk 0 with `columns` (by `z * 16 + x`), runs - /// of biome 1 see-through as water. - fn mesh(columns: impl Fn(usize) -> Vec) -> SectionMesh { + /// The `level` mesh of chunk 0 with `columns` (by `z * 16 + x`), runs + /// of biome 1 see-through as water, of biome 2 leaves. + fn mesh_at(level: u8, columns: impl Fn(usize) -> Vec) -> SectionMesh { let mut store = Store::default(); store.capture(ChunkPos::new(0, 0), (0..256).map(columns).collect()); - let key = SectionKey { - level: 0, - x: 0, - z: 0, - }; + let key = SectionKey { level, x: 0, z: 0 }; mesh_section(key, store.get(key).unwrap(), |key| { let water = key.biome == 1; let alpha = if water { 128 } else { 255 }; @@ -474,26 +493,35 @@ mod tests { translucent: water, occludes: !water, faces: [0; 6], + holes: key.biome == 2, top: [0, 0, 0, alpha], side: [0, 0, 0, alpha], } }) } + fn mesh(columns: impl Fn(usize) -> Vec) -> SectionMesh { + mesh_at(0, columns) + } + fn quads(columns: impl Fn(usize) -> Vec) -> usize { mesh(columns).vertices.len() / 4 } - fn water(top: u16, bottom: u16) -> Run { + fn in_biome(biome: u32, top: u16, bottom: u16) -> Run { Run { key: RunKey { - biome: 1, + biome, ..run(top, bottom).key }, ..run(top, bottom) } } + fn water(top: u16, bottom: u16) -> Run { + in_biome(1, top, bottom) + } + #[test] fn the_sea_floor_shows_through_water() { // A flat chunk of sea: the water's top and the floor's under it, and @@ -546,6 +574,24 @@ mod tests { assert_eq!(west, [15; 8]); } + #[test] + fn leaves_cut_their_holes_out_at_full_detail_only() { + let pair = |i| { + if i < 2 { + vec![in_biome(2, 5, 0)] + } else { + Vec::new() + } + }; + // The pair's shared sides stay, seen through each other's holes. + let full = mesh_at(0, pair); + assert_eq!(full.vertices.len() / 4, 1 + 2 * 4); + assert!(full.vertices.iter().all(|v| v.flags & FACE_DISCARD != 0)); + let coarse = mesh_at(1, pair); + assert!(!coarse.vertices.is_empty()); + assert!(coarse.vertices.iter().all(|v| v.flags & FACE_DISCARD == 0)); + } + #[test] fn a_lone_column_is_a_box_without_its_world_bottom() { let lone = |i| if i == 0 { vec![run(5, 0)] } else { Vec::new() }; diff --git a/pomme-client/src/renderer/distant/models.rs b/pomme-client/src/renderer/distant/models.rs index 96ee7cf4d..701a9203d 100644 --- a/pomme-client/src/renderer/distant/models.rs +++ b/pomme-client/src/renderer/distant/models.rs @@ -52,6 +52,8 @@ pub(crate) struct ModelInfo { pub occludes: bool, /// By Voxy face index ([`crate::world::block::model::Direction::VALUES`]). pub faces: [u8; 6], + /// Solid only as Voxy draws leaves; their texture has holes. + pub holes: bool, } /// Model 0's place: states without a model draw in their colours, never @@ -60,6 +62,7 @@ const UNTEXTURED: ModelInfo = ModelInfo { translucent: false, occludes: true, faces: [0; 6], + holes: false, }; /// A new model's tile and its mips, to upload to the atlas. @@ -197,6 +200,7 @@ impl ModelFactory { translucent: layer == Layer::Translucent, occludes: fully_opaque, faces: face_flags, + holes: leaves, }); self.tiles.push(ModelTile { id, From df6dbd02a56e85272d7927b558056ac7848e13b3 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:21:29 +0000 Subject: [PATCH 17/67] fill distant leaf holes so coarse forests draw green --- pomme-client/src/renderer/distant/models.rs | 48 ++++++++++++++++++--- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/pomme-client/src/renderer/distant/models.rs b/pomme-client/src/renderer/distant/models.rs index 701a9203d..d7c6ff36a 100644 --- a/pomme-client/src/renderer/distant/models.rs +++ b/pomme-client/src/renderer/distant/models.rs @@ -204,7 +204,7 @@ impl ModelFactory { }); self.tiles.push(ModelTile { id, - levels: mip_tile(flags.darkened, &faces), + levels: mip_tile(flags.darkened, leaves, &faces), }); self.by_faces.insert(faces, id); id @@ -309,15 +309,17 @@ fn compute_bounds(face: &FaceTexture, check: WriteCheck) -> [usize; 4] { } /// Voxy's `MipGen.putTextures`: the faces laid out in a tile, transparent -/// texels taking the colour of the nearest drawn one (unless the sprites -/// mip dark), then halved down to 2x2 faces. -fn mip_tile(darkened: bool, faces: &[FaceTexture; 6]) -> Vec> { +/// texels taking the colour of the nearest drawn one, then halved down to +/// 2x2 faces. Voxy leaves sprites that mip dark black there; leaves fill +/// theirs anyway, since coarse distant leaves draw them solid +/// ([`ModelInfo::holes`]). +fn mip_tile(darkened: bool, leaves: bool, faces: &[FaceTexture; 6]) -> Vec> { let width = TILE_WIDTH as usize; let mut tile = vec![0u32; width * TILE_HEIGHT as usize]; for (i, face) in faces.iter().enumerate() { let (x, y) = ((i >> 1) * SIZE, (i & 1) * SIZE); let mut colours = *face.colour; - if !darkened && colours.iter().any(|c| c >> 24 == 0) { + if (!darkened || leaves) && colours.iter().any(|c| c >> 24 == 0) { solidify(&mut colours); } for (j, &colour) in colours.iter().enumerate() { @@ -436,3 +438,39 @@ fn linear_to_srgb(linear: f32) -> u8 { }; (c * 255.0).round().clamp(0.0, 255.0) as u8 } + +#[cfg(test)] +mod tests { + use super::*; + + /// Six faces of green with a clear texel at the corner of each. + fn holey_faces() -> [FaceTexture; 6] { + std::array::from_fn(|_| { + let mut colour = Box::new([u32::from_le_bytes([0, 200, 0, 255]); TEXELS]); + colour[0] = 0; + FaceTexture { + colour, + depth: Box::new([1; TEXELS]), + } + }) + } + + #[test] + fn leaves_fill_their_holes_where_dark_sprites_stay_black() { + // The clear texel takes its neighbour's colour, keeping zero alpha so + // full detail still cuts it out. + let filled = [0, 200, 0, 0]; + for (darkened, leaves, corner) in [ + (true, true, filled), + (true, false, [0; 4]), + (false, false, filled), + ] { + let levels = mip_tile(darkened, leaves, &holey_faces()); + assert_eq!( + levels[0][..4], + corner, + "darkened {darkened}, leaves {leaves}" + ); + } + } +} From 901e48844b964c8e565277b8f9184ed25533c62a Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:09:54 +0000 Subject: [PATCH 18/67] repeat distant textures per column, rebuild shaders on include changes and keep the renderer's floor current --- pomme-client/build.rs | 1 + pomme-client/src/renderer/distant/gpu.rs | 2 +- pomme-client/src/renderer/distant/worker.rs | 18 +++++++++--------- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/pomme-client/build.rs b/pomme-client/build.rs index 43a2c98fd..409214037 100644 --- a/pomme-client/build.rs +++ b/pomme-client/build.rs @@ -84,6 +84,7 @@ fn main() { "item_instance.glsl", "lightmap.glsl", "light.glsl", + "distant_models.glsl", ] { println!( "cargo:rerun-if-changed={}", diff --git a/pomme-client/src/renderer/distant/gpu.rs b/pomme-client/src/renderer/distant/gpu.rs index 702d52d71..f4cf29d16 100644 --- a/pomme-client/src/renderer/distant/gpu.rs +++ b/pomme-client/src/renderer/distant/gpu.rs @@ -517,7 +517,7 @@ impl DistantBuffers { pass.set_push_constants( rhi::ShaderStages::VERTEX, 64, - bytemuck::cast_slice(&[rel.x, rel.y, rel.z, width as f32]), + bytemuck::cast_slice(&[rel.x, rel.y, rel.z, key.column_width() as f32]), ); let quads = (end - first) / 4; for first_quad in (0..quads).step_by(QUADS_PER_DRAW as usize) { diff --git a/pomme-client/src/renderer/distant/worker.rs b/pomme-client/src/renderer/distant/worker.rs index 08edae30a..4a0832735 100644 --- a/pomme-client/src/renderer/distant/worker.rs +++ b/pomme-client/src/renderer/distant/worker.rs @@ -384,15 +384,15 @@ impl Worker { for key in back { self.mark_dirty(key, now); } - if !removed.is_empty() { - let _ = self.updates.send(DistantUpdate { - dimension: Arc::clone(&view.dimension), - min_y: view.min_y, - meshes: Vec::new(), - removed, - tiles: Vec::new(), - }); - } + // Sent even with nothing removed: the renderer draws every + // section at the view's `min_y`, which may have changed. + let _ = self.updates.send(DistantUpdate { + dimension: Arc::clone(&view.dimension), + min_y: view.min_y, + meshes: Vec::new(), + removed, + tiles: Vec::new(), + }); self.view = Some(view); } Job::Evicted(keys) => { From ae969be9aa7d7f8dd21e34d773ab530732699aeb Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:18:44 +0000 Subject: [PATCH 19/67] add world sections at five levels of detail, voxelization and mipping beside the column store --- pomme-client/src/cpu_bench/distant.rs | 26 +- pomme-client/src/renderer/distant/bakery.rs | 2 +- pomme-client/src/renderer/distant/colors.rs | 6 +- pomme-client/src/renderer/distant/mod.rs | 3 + pomme-client/src/renderer/distant/models.rs | 6 +- pomme-client/src/renderer/distant/section.rs | 561 +++++++++++++++++++ pomme-client/src/renderer/distant/voxel.rs | 166 ++++++ pomme-client/src/renderer/distant/worker.rs | 31 +- pomme-client/src/renderer/distant/world.rs | 284 ++++++++++ pomme-client/src/world/block/mod.rs | 5 + 10 files changed, 1079 insertions(+), 11 deletions(-) create mode 100644 pomme-client/src/renderer/distant/section.rs create mode 100644 pomme-client/src/renderer/distant/voxel.rs create mode 100644 pomme-client/src/renderer/distant/world.rs diff --git a/pomme-client/src/cpu_bench/distant.rs b/pomme-client/src/cpu_bench/distant.rs index dd643974c..3568072f2 100644 --- a/pomme-client/src/cpu_bench/distant.rs +++ b/pomme-client/src/cpu_bench/distant.rs @@ -11,6 +11,8 @@ use crate::renderer::distant::column::build_chunk; use crate::renderer::distant::mesh::mesh_section; use crate::renderer::distant::models::ModelFactory; use crate::renderer::distant::store::{LEVELS, SectionKey, Store}; +use crate::renderer::distant::voxel::States; +use crate::renderer::distant::world::World; pub fn bench(c: &mut Criterion, assets: &Assets) { let colors = assets.mesh_dispatcher().distant_colors(); @@ -18,17 +20,35 @@ pub fn bench(c: &mut Criterion, assets: &Assets) { let mut group = c.benchmark_group("distant"); for scene in Scene::ALL { let world = build_world(scene, assets.block_state_table); - let capture = |store: &mut Store, pos: ChunkPos| { + let column = |pos: ChunkPos| { let chunk = world .get_chunk(&pos) .expect("the scene's columns are loaded"); - let light = world.light_data.get(&(pos.x, pos.z)).map(|light| &**light); - let columns = build_chunk(&chunk.read(), light, world.min_y(), &colors); + let light = world.light_data.get(&(pos.x, pos.z)).cloned(); + (chunk, light) + }; + let capture = |store: &mut Store, pos: ChunkPos| { + let (chunk, light) = column(pos); + let columns = build_chunk(&chunk.read(), light.as_deref(), world.min_y(), &colors); store.capture(pos, columns) }; group.bench_function(format!("capture/{}", scene.name()), |b| { b.iter(|| black_box(capture(&mut Store::default(), ChunkPos::new(0, 0)))) }); + let states = States::new(assets.block_state_table); + group.bench_function(format!("ingest/{}", scene.name()), |b| { + b.iter(|| { + let pos = ChunkPos::new(0, 0); + let (chunk, light) = column(pos); + black_box(World::default().ingest_chunk( + &states, + pos, + &chunk.read(), + light.as_deref(), + world.min_y(), + )) + }) + }); let mut store = Store::default(); for pos in columns_within(1) { diff --git a/pomme-client/src/renderer/distant/bakery.rs b/pomme-client/src/renderer/distant/bakery.rs index 13caeffd5..e581c88a0 100644 --- a/pomme-client/src/renderer/distant/bakery.rs +++ b/pomme-client/src/renderer/distant/bakery.rs @@ -334,7 +334,7 @@ impl Bakery { return None; } // Voxy forces leaves into the solid layer. - let leaves = data.id().ends_with("_leaves"); + let leaves = data.is_leaves(); let mut flags = BakeFlags::default(); let (mut opaque, mut translucent): (Vec>, Vec>) = (Vec::new(), Vec::new()); diff --git a/pomme-client/src/renderer/distant/colors.rs b/pomme-client/src/renderer/distant/colors.rs index 2a2452346..0964294b3 100644 --- a/pomme-client/src/renderer/distant/colors.rs +++ b/pomme-client/src/renderer/distant/colors.rs @@ -15,8 +15,8 @@ use crate::renderer::chunk::atlas::AtlasUVMap; use crate::renderer::chunk::mesher::{ BiomeClimate, Colormap, dry_foliage_color, foliage_color, grass_color, int_to_rgb, }; -use crate::world::block::FluidKind; use crate::world::block::render_assets::{OwnedBlockRenderView, Tint}; +use crate::world::block::{BlockStateTable, FluidKind}; /// Texture colour where a sprite is missing. const UNKNOWN: [u8; 3] = [128, 128, 128]; @@ -156,6 +156,10 @@ impl DistantColors { self.look(state).fills } + pub fn table(&self) -> BlockStateTable { + self.render_view.block_state_table() + } + /// Whether `models` were baked from the same textures and state ids as /// these colours would bake them. pub fn same_models(&self, other: &Self) -> bool { diff --git a/pomme-client/src/renderer/distant/mod.rs b/pomme-client/src/renderer/distant/mod.rs index b8961507c..0afe552eb 100644 --- a/pomme-client/src/renderer/distant/mod.rs +++ b/pomme-client/src/renderer/distant/mod.rs @@ -11,5 +11,8 @@ pub(crate) mod column; pub(crate) mod gpu; pub(crate) mod mesh; pub(crate) mod models; +pub(crate) mod section; pub(crate) mod store; +pub(crate) mod voxel; pub(crate) mod worker; +pub(crate) mod world; diff --git a/pomme-client/src/renderer/distant/models.rs b/pomme-client/src/renderer/distant/models.rs index d7c6ff36a..208332cfb 100644 --- a/pomme-client/src/renderer/distant/models.rs +++ b/pomme-client/src/renderer/distant/models.rs @@ -131,11 +131,7 @@ impl ModelFactory { // TODO: a model atlas that grows past its capacity. return 0; } - let leaves = view - .block_state_table() - .block_state_data(state) - .id() - .ends_with("_leaves"); + let leaves = view.block_state_table().block_state_data(state).is_leaves(); let mut layer = None; if flags.translucent { layer = Some(if faces.iter().any(has_translucent_pixel) { diff --git a/pomme-client/src/renderer/distant/section.rs b/pomme-client/src/renderer/distant/section.rs new file mode 100644 index 000000000..ae56c8ef8 --- /dev/null +++ b/pomme-client/src/renderer/distant/section.rs @@ -0,0 +1,561 @@ +//! Voxy's world sections: 32x32x32 voxels at each of five levels of detail +//! (`WorldSection`), what a vanilla section becomes at every level before +//! it's written in (`VoxelizedSection`, `WorldConversionFactory`, +//! `WorldVoxilizedSectionMipper`), and a section's file (`SaveLoadSystem3`). + +use std::io::{Read, Write}; + +use super::voxel::{self, States}; +use crate::world::chunk::ChunkLightData; + +/// Levels of detail: at level `l` a voxel is `1 << l` blocks wide. +pub(crate) const LEVELS: u8 = 5; +pub(crate) const SIDE: usize = 32; +pub(crate) const VOLUME: usize = SIDE * SIDE * SIDE; + +/// A section at one level, in section coordinates of that level. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct SectionKey { + pub level: u8, + pub x: i32, + pub y: i32, + pub z: i32, +} + +impl SectionKey { + /// The level-`level` section holding vanilla section `x`/`y`/`z`. + pub fn of_vanilla(level: u8, x: i32, y: i32, z: i32) -> Self { + let shift = level + 1; + Self { + level, + x: x >> shift, + y: y >> shift, + z: z >> shift, + } + } + + /// Voxy's `getWorldSectionId`: level in the top nibble, then y (8 + /// bits), z and x (24 each), 4 bits spare. + pub fn pack(self) -> u64 { + u64::from(self.level) << 60 + | (self.y as u64 & 0xFF) << 52 + | (self.z as u64 & 0xFF_FFFF) << 28 + | (self.x as u64 & 0xFF_FFFF) << 4 + } + + #[cfg(test)] + pub fn unpack(id: u64) -> Self { + Self { + level: (id >> 60) as u8, + x: ((id << 36) as i64 >> 40) as i32, + y: ((id << 4) as i64 >> 56) as i32, + z: ((id << 12) as i64 >> 40) as i32, + } + } + + /// Blocks along a side. + pub fn width(self) -> i32 { + (SIDE as i32) << self.level + } + + /// The block position of the section's corner. + pub fn origin(self) -> [i32; 3] { + [self.x, self.y, self.z].map(|c| c * self.width()) + } + + /// Which of its parent's eight children this is (`getChildIndex`). + pub fn child_index(self) -> u8 { + (self.x & 1 | (self.z & 1) << 1 | (self.y & 1) << 2) as u8 + } + + /// The horizontal distance from `x`/`z` to the section's nearest edge. + pub fn distance(self, x: f64, z: f64) -> f64 { + let [ox, _, oz] = self.origin(); + let width = f64::from(self.width()); + let dx = (f64::from(ox) - x).max(x - f64::from(ox) - width).max(0.0); + let dz = (f64::from(oz) - z).max(z - f64::from(oz) - width).max(0.0); + dx.hypot(dz) + } +} + +/// Air under full sky, which an unseen section is made of. +const UNSEEN: u64 = voxel::compose(0x0F, voxel::AIR, 0); + +/// A voxel's index: `y`, then `z`, then `x`. +pub(crate) fn index(x: usize, y: usize, z: usize) -> usize { + y << 10 | z << 5 | x +} + +/// 32³ voxels at one level. +pub(crate) struct WorldSection { + pub data: Box<[u64]>, + /// A bit per child octant with anything in it; at level 0, 0xFF if any + /// voxel is a block. + pub non_empty_children: u8, + /// Level 0 only: how many voxels are blocks. + pub non_empty_count: u32, +} + +impl WorldSection { + /// What a section nothing was seen in holds: air under full sky. + pub fn unseen() -> Self { + Self { + data: vec![UNSEEN; VOLUME].into(), + non_empty_children: 0, + non_empty_count: 0, + } + } + + /// Whether it still holds only that, so a file for it says nothing. + pub fn is_unseen(&self) -> bool { + self.non_empty_children == 0 && self.data.iter().all(|&v| v == UNSEEN) + } + + /// Level 0's `non_empty_children` from its count; whether it changed. + fn update_lvl0_state(&mut self) -> bool { + let next = if self.non_empty_count == 0 { 0 } else { 0xFF }; + std::mem::replace(&mut self.non_empty_children, next) != next + } + + /// Records whether child `index` has anything in it: 0 for no change, + /// 1 for a change, 2 for this section going empty or non-empty. + fn update_empty_child_state(&mut self, index: u8, child_non_empty: bool) -> u8 { + let mask = 1 << index; + let prev = self.non_empty_children; + let next = prev & !mask | if child_non_empty { mask } else { 0 }; + self.non_empty_children = next; + if (prev != 0) != (next != 0) { + 2 + } else { + u8::from(prev != next) + } + } +} + +/// Sides of the five levels of a vanilla section: 16, 8, 4, 2, 1. +const fn level_side(level: u8) -> usize { + 1 << level_shift(level) +} + +/// `log2` of [`level_side`]. +const fn level_shift(level: u8) -> u32 { + 4 - level as u32 +} + +/// Voxel `i` of a cube `1 << shift` wide laid out `y`-major, then `z`, then +/// `x`. +fn position(i: usize, shift: u32) -> (usize, usize, usize) { + let mask = (1 << shift) - 1; + (i & mask, i >> (2 * shift), i >> shift & mask) +} + +const fn level_base(level: u8) -> usize { + let mut base = 0; + let mut l = 0; + while l < level { + base += level_side(l).pow(3); + l += 1; + } + base +} + +const WORK_VOLUME: usize = level_base(LEVELS); + +/// A vanilla 16³ section as voxels at every level, mipped down to one. +pub(crate) struct VoxelizedSection { + /// Vanilla section coordinates. + pub x: i32, + pub y: i32, + pub z: i32, + pub non_air_count: u32, + data: Box<[u64; WORK_VOLUME]>, +} + +impl VoxelizedSection { + pub fn new(x: i32, y: i32, z: i32) -> Self { + Self { + x, + y, + z, + non_air_count: 0, + data: Box::new([0; WORK_VOLUME]), + } + } + + /// The level's voxels, `side³` of them, `y`-major then `z` then `x`. + pub fn level(&self, level: u8) -> &[u64] { + let base = level_base(level); + &self.data[base..base + level_side(level).pow(3)] + } + + /// `WorldConversionFactory.convert`: `section`'s blocks, biomes and + /// light as level 0; `light` gives a block's `block << 4 | sky`. + pub fn convert( + &mut self, + states: &States, + section: &pomme_protocol::section::Section, + light: impl Fn(usize, usize, usize) -> u8, + ) { + let biomes: [u32; 64] = std::array::from_fn(|i| { + let (x, y, z) = position(i, 2); + section.biome(x as u32, y as u32, z as u32) & 0x1FF + }); + self.non_air_count = 0; + for i in 0..level_side(0).pow(3) { + let (x, y, z) = position(i, level_shift(0)); + let block = states.block(section.states.get(x as u32, y as u32, z as u32)); + self.non_air_count += u32::from(block != voxel::AIR); + let biome = biomes[(y >> 2) << 4 | (z >> 2) << 2 | x >> 2]; + self.data[i] = voxel::compose(light(x, y, z), block, biome); + } + } + + /// Air, unlit (`VoxelizedSection.zero`). + pub fn zero(&mut self) { + self.non_air_count = 0; + self.data.fill(0); + } + + /// Level 0 all `voxel`, mipped as if every level were too. + #[cfg(test)] + pub fn fill_for_test(&mut self, voxel: u64, non_air_count: u32) { + self.data.fill(voxel); + self.non_air_count = non_air_count; + } + + /// `WorldVoxilizedSectionMipper.mipSection`: each level from the one + /// below, eight voxels to one. + pub fn mip(&mut self, states: &States) { + for level in 1..LEVELS { + let (from, to) = (level_base(level - 1), level_base(level)); + let finer = level_side(level - 1); + for i in 0..level_side(level).pow(3) { + let (x, y, z) = position(i, level_shift(level)); + let child = |dx: usize, dy: usize, dz: usize| { + self.data + [from + (y * 2 + dy) * finer * finer + (z * 2 + dz) * finer + x * 2 + dx] + }; + self.data[to + i] = + states.mip(std::array::from_fn(|c| child(c >> 2, c >> 1 & 1, c & 1))); + } + } + } +} + +/// `VoxelIngestService.getLightingSupplier` over a column's light at +/// vanilla section `section_y`: `block << 4 | sky`, 0 without data. +pub(crate) fn light_of( + light: Option<&ChunkLightData>, + section_y: i32, +) -> impl Fn(usize, usize, usize) -> u8 + '_ { + move |x, y, z| { + let Some(light) = light else { + return 0; + }; + let (x, y, z) = (x as i32, section_y * 16 + y as i32, z as i32); + light.get_block_light(x, y, z) << 4 | light.get_sky_light(x, y, z) + } +} + +/// Writes `section` over `into` where the vanilla section lands at the +/// section's level (`WorldUpdater.insertSectionLvlIntoWorld`): whether +/// anything changed, and how many blocks the covered voxels held before. +pub(crate) fn insert_level( + section: &VoxelizedSection, + into: &mut WorldSection, + level: u8, +) -> (bool, u32) { + let mask = (1 << (level + 1)) - 1; + let base = |c: i32| ((c & mask) << level_shift(level)) as usize; + let (bx, by, bz) = (base(section.x), base(section.y), base(section.z)); + let (mut changed, mut non_air_before) = (false, 0); + for (i, &new) in section.level(level).iter().enumerate() { + let (x, y, z) = position(i, level_shift(level)); + let slot = &mut into.data[index(bx + x, by + y, bz + z)]; + non_air_before += u32::from(!voxel::is_air(*slot)); + changed |= *slot != new; + *slot = new; + } + (changed, non_air_before) +} + +/// `WorldUpdater.insertUpdate`'s per-level bookkeeping after +/// [`insert_level`]: level 0's count and emptiness; whether the section +/// changed and whether its emptiness flipped (bubbled to the parent). +pub(crate) fn settle_level( + into: &mut WorldSection, + level: u8, + changed: bool, + non_air_before: u32, + non_air_now: u32, + child: Option<(u8, bool)>, +) -> (bool, u8) { + let mut emptiness = match child { + Some((index, non_empty)) if level != 0 => into.update_empty_child_state(index, non_empty), + _ => 0, + }; + if level == 0 && non_air_now != non_air_before { + into.non_empty_count = into.non_empty_count + non_air_now - non_air_before; + emptiness = if into.update_lvl0_state() { 2 } else { 0 }; + } + (changed || emptiness != 0, emptiness) +} + +/// `SaveLoadSystem3.serialize`: the key, a palette's size and the +/// non-empty children, a `u16` palette index per voxel, then the palette; +/// little-endian, zlib-compressed (Voxy uses zstd). +pub(crate) fn encode(key: SectionKey, section: &WorldSection) -> Vec { + let mut palette: Vec = Vec::new(); + let mut lookup = std::collections::HashMap::new(); + let mut indices = Vec::with_capacity(VOLUME * 2); + for &voxel in §ion.data { + let next = palette.len(); + let index = *lookup.entry(voxel).or_insert_with(|| { + palette.push(voxel); + next + }); + indices.extend((index as u16).to_le_bytes()); + } + let meta = palette.len() as u64 | u64::from(section.non_empty_children) << 16; + let mut out = Vec::with_capacity(16 + indices.len() + palette.len() * 8); + out.extend(key.pack().to_le_bytes()); + out.extend(meta.to_le_bytes()); + out.extend(indices); + for voxel in palette { + out.extend(voxel.to_le_bytes()); + } + let mut zlib = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + zlib.write_all(&out) + .and_then(|()| zlib.finish()) + .expect("compressing into memory") +} + +/// The section [`encode`] wrote for `key`; `None` for another's or a +/// damaged file. +pub(crate) fn decode(key: SectionKey, file: &[u8]) -> Option { + let mut bytes = Vec::new(); + flate2::read::ZlibDecoder::new(file) + .read_to_end(&mut bytes) + .ok()?; + let mut rest = bytes.as_slice(); + if u64::from_le_bytes(take(&mut rest)?) != key.pack() { + return None; + } + let meta = u64::from_le_bytes(take(&mut rest)?); + let palette_len = (meta & 0xFFFF) as usize; + let (indices, after) = rest.split_at_checked(VOLUME * 2)?; + let palette: Vec = after + .as_chunks::<8>() + .0 + .iter() + .take(palette_len) + .map(|&bytes| u64::from_le_bytes(bytes)) + .collect(); + if palette.len() != palette_len { + return None; + } + let data: Option> = indices + .as_chunks::<2>() + .0 + .iter() + .map(|&bytes| palette.get(u16::from_le_bytes(bytes) as usize).copied()) + .collect(); + let data = data?; + let non_empty_count = if key.level == 0 { + data.iter().filter(|&&v| !voxel::is_air(v)).count() as u32 + } else { + 0 + }; + Some(WorldSection { + data, + non_empty_children: (meta >> 16) as u8, + non_empty_count, + }) +} + +/// The next `N` bytes of `rest`, taking them off it. +fn take(rest: &mut &[u8]) -> Option<[u8; N]> { + let (taken, after) = rest.split_first_chunk()?; + *rest = after; + Some(*taken) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::world::block::BlockStateTable; + + #[test] + fn keys_pack_like_voxy_and_find_their_parents() { + for key in [ + SectionKey { + level: 0, + x: 0, + y: 0, + z: 0, + }, + SectionKey { + level: 4, + x: -1, + y: -2, + z: 8_000_000, + }, + SectionKey { + level: 2, + x: 123_456, + y: 127, + z: -654_321, + }, + ] { + assert_eq!(SectionKey::unpack(key.pack()), key); + } + let vanilla = SectionKey::of_vanilla(0, -1, 3, 33); + assert_eq!( + vanilla, + SectionKey { + level: 0, + x: -1, + y: 1, + z: 16 + } + ); + assert_eq!( + SectionKey::of_vanilla(4, -1, 3, 33), + SectionKey { + level: 4, + x: -1, + y: 0, + z: 1 + } + ); + assert_eq!(vanilla.child_index(), 1 | 1 << 2, "x odd, z even, y odd"); + assert_eq!( + SectionKey { + level: 1, + x: 1, + y: 0, + z: 0 + } + .origin(), + [64, 0, 0] + ); + } + + fn states() -> States { + States::new(BlockStateTable::native()) + } + + fn state_id(state: &str) -> u32 { + u32::from(BlockStateTable::native().find_state(state, &[]).id()) + } + + /// A vanilla section of `state` everywhere, lit `light`. + fn uniform(state: &str, light: u8) -> VoxelizedSection { + let table = BlockStateTable::native(); + let mut section = pomme_protocol::section::Section::default(); + let id = state_id(state); + for i in 0..4096u32 { + section + .states + .get_and_set(i & 15, i >> 8, i >> 4 & 15, id, table.state_count()); + } + let mut voxelized = VoxelizedSection::new(3, -2, 5); + voxelized.convert(&states(), §ion, |_, _, _| light); + voxelized + } + + #[test] + fn a_section_converts_and_mips_down_to_one_voxel() { + let mut stone = uniform("stone", 0x0F); + assert_eq!(stone.non_air_count, 4096); + stone.mip(&states()); + for level in 0..LEVELS { + let voxels = stone.level(level); + assert_eq!(voxels.len(), 4096 >> (3 * level)); + assert!( + voxels + .iter() + .all(|&v| voxel::block_id(v) == state_id("stone")) + ); + } + let mut air = uniform("cave_air", 0x3F); + assert_eq!(air.non_air_count, 0); + air.mip(&states()); + assert!( + air.level(4) + .iter() + .all(|&v| voxel::is_air(v) && voxel::light(v) == 0x3F) + ); + } + + #[test] + fn insertion_lands_each_level_in_its_sub_cube() { + let mut stone = uniform("stone", 0); + stone.mip(&states()); + // Where vanilla section (3, -2, 5)'s sub-cube starts at each level. + let corners = [ + [16, 0, 16], + [24, 16, 8], + [12, 24, 20], + [6, 28, 10], + [3, 30, 5], + ]; + for (level, [x, y, z]) in (0..LEVELS).zip(corners) { + let mut into = WorldSection::unseen(); + let (changed, before) = insert_level(&stone, &mut into, level); + assert!(changed); + assert_eq!(before, 0); + let side = level_side(level); + let filled = into.data.iter().filter(|&&v| !voxel::is_air(v)).count(); + assert_eq!(filled, side.pow(3)); + assert!(!voxel::is_air(into.data[index(x, y, z)])); + assert!(voxel::is_air(into.data[index(x + side, y, z)])); + let (changed, before) = insert_level(&stone, &mut into, level); + assert!(!changed); + assert_eq!(before, side.pow(3) as u32); + } + } + + #[test] + fn emptiness_bubbles_up() { + let mut level0 = WorldSection::unseen(); + assert_eq!(settle_level(&mut level0, 0, true, 0, 10, None), (true, 2)); + assert_eq!(level0.non_empty_children, 0xFF); + assert_eq!(settle_level(&mut level0, 0, true, 10, 4, None), (true, 0)); + let mut parent = WorldSection::unseen(); + assert_eq!( + settle_level(&mut parent, 1, false, 0, 0, Some((5, true))), + (true, 2) + ); + assert_eq!(parent.non_empty_children, 1 << 5); + assert_eq!( + settle_level(&mut parent, 1, false, 0, 0, Some((2, true))), + (true, 1) + ); + assert_eq!( + settle_level(&mut parent, 1, false, 0, 0, Some((2, true))), + (false, 0) + ); + } + + #[test] + fn sections_survive_a_file() { + let key = SectionKey { + level: 0, + x: -5, + y: 2, + z: 9, + }; + let mut section = WorldSection::unseen(); + section.data[index(1, 2, 3)] = voxel::compose(0x12, 77, 3); + section.data[index(31, 31, 31)] = voxel::compose(0, 78, 3); + section.non_empty_count = 2; + section.non_empty_children = 0xFF; + let read = decode(key, &encode(key, §ion)).unwrap(); + assert_eq!(read.data, section.data); + assert_eq!(read.non_empty_children, 0xFF); + assert_eq!(read.non_empty_count, 2); + assert!(decode(SectionKey { level: 1, ..key }, &encode(key, §ion)).is_none()); + assert!(decode(key, b"not a section").is_none()); + } +} diff --git a/pomme-client/src/renderer/distant/voxel.rs b/pomme-client/src/renderer/distant/voxel.rs new file mode 100644 index 000000000..9627bb51c --- /dev/null +++ b/pomme-client/src/renderer/distant/voxel.rs @@ -0,0 +1,166 @@ +//! Voxy's voxel (`Mapper`): a `u64` of block state, biome and light; and +//! how eight of them merge into one of the next level (`Mipper`). + +use crate::world::block::BlockStateTable; + +const BLOCK_SHIFT: u32 = 27; +const BIOME_SHIFT: u32 = 47; +const LIGHT_SHIFT: u32 = 56; +const BLOCK_MASK: u64 = ((1 << 20) - 1) << BLOCK_SHIFT; +const LIGHT_MASK: u64 = 0xFF << LIGHT_SHIFT; + +/// Block state 0 is air, whatever kind (`Mapper.AIR`). +pub(crate) const AIR: u32 = 0; + +/// `light` is `block << 4 | sky`. Air keeps no biome. +pub(crate) const fn compose(light: u8, block: u32, biome: u32) -> u64 { + let light = (light as u64) << LIGHT_SHIFT; + if block == AIR { + return light; + } + light | (biome as u64) << BIOME_SHIFT | (block as u64) << BLOCK_SHIFT +} + +pub(crate) fn is_air(voxel: u64) -> bool { + voxel & BLOCK_MASK == 0 +} + +pub(crate) fn block_id(voxel: u64) -> u32 { + ((voxel & BLOCK_MASK) >> BLOCK_SHIFT) as u32 +} + +pub(crate) fn light(voxel: u64) -> u8 { + (voxel >> LIGHT_SHIFT) as u8 +} + +pub(crate) fn with_light(voxel: u64, light: u8) -> u64 { + voxel & !LIGHT_MASK | u64::from(light) << LIGHT_SHIFT +} + +/// What voxelizing and mipping ask of each block state (`Mapper.StateEntry`): +/// whether it's air, and its opacity, leaves counting as solid and the rest +/// as their light dampening. +pub(crate) struct States { + air: Box<[bool]>, + opacity: Box<[u8]>, +} + +impl States { + pub fn new(table: BlockStateTable) -> Self { + let data = (0..table.state_count() as u32).map(|id| { + azalea_block::BlockState::try_from(id).map(|state| table.block_state_data(state)) + }); + let air = data + .clone() + .map(|data| data.is_ok_and(|data| data.is_air())); + let opacity = data.map(|data| match data { + Ok(data) if data.is_leaves() => 15, + Ok(data) => data.light_props().dampening, + Err(_) => 0, + }); + Self { + air: air.collect(), + opacity: opacity.collect(), + } + } + + /// State `id` as a voxel's block: `AIR` for every kind of air. + pub fn block(&self, id: u32) -> u32 { + if self.air.get(id as usize).copied().unwrap_or(true) { + AIR + } else { + id + } + } + + fn opacity(&self, voxel: u64) -> u8 { + self.opacity + .get(block_id(voxel) as usize) + .copied() + .unwrap_or(0) + } + + /// Voxy's `Mipper.mip`: of eight children, indexed `x << 2 | y << 1 | z`, + /// the most opaque block wins, ties going to the highest index (x, then + /// y, then z). All air: air lit by the mean of their light, block light + /// rounded down, sky up. + pub fn mip(&self, children: [u64; 8]) -> u64 { + let best = children + .iter() + .enumerate() + .filter(|&(_, &child)| !is_air(child)) + .map(|(i, &child)| (u32::from(self.opacity(child)) << 4 | i as u32, child)) + .max_by_key(|&(score, _)| score); + if let Some((_, winner)) = best { + return winner; + } + let lights = children.map(|child| u32::from(light(child))); + let block = (lights.iter().map(|l| l & 0xF0).sum::() / 8) & 0xF0; + let sky = lights.iter().map(|l| l & 0x0F).sum::().div_ceil(8); + with_light(children[7], (block | sky) as u8) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn voxels_pack_block_biome_and_light() { + let voxel = compose(0xA5, 1234, 300); + assert_eq!(block_id(voxel), 1234); + assert_eq!(voxel >> BIOME_SHIFT & 0x1FF, 300); + assert_eq!(light(voxel), 0xA5); + assert!(!is_air(voxel)); + // Air keeps only its light. + let air = compose(0x0F, AIR, 300); + assert!(is_air(air)); + assert_eq!(air, 0x0F << LIGHT_SHIFT); + assert_eq!(light(with_light(voxel, 3)), 3); + assert_eq!(block_id(with_light(voxel, 3)), 1234); + } + + fn states(opacity: &[u8]) -> States { + States { + air: vec![false; opacity.len()].into(), + opacity: opacity.into(), + } + } + + #[test] + fn the_most_opaque_child_wins_and_ties_go_to_the_top_corner() { + // States: 1 glass (0), 2 stone (15), 3 leaves (15). + let states = states(&[0, 0, 15, 15]); + let (glass, stone, leaves) = (compose(0, 1, 0), compose(0, 2, 0), compose(0, 3, 0)); + let mut children = [0; 8]; + children[0] = stone; + children[7] = glass; + assert_eq!(states.mip(children), stone, "stone beats glass"); + children[3] = leaves; + assert_eq!( + states.mip(children), + leaves, + "a tie goes to the higher corner" + ); + assert_eq!( + states.mip([0, 0, 0, 0, 0, 0, 0, glass]), + glass, + "any block beats air" + ); + } + + #[test] + fn all_air_children_average_their_light() { + let states = states(&[0]); + // Block light 0x10 x 4 and 0: mean 8 floors to 0; sky 1 x 3 ceils to 1. + let mut children = [0u64; 8]; + for (i, child) in children.iter_mut().enumerate() { + let block = if i < 4 { 0x10 } else { 0 }; + let sky = u8::from(i < 3); + *child = compose(block | sky, AIR, 0); + } + assert_eq!(light(states.mip(children)), 0x01); + // Full sky everywhere stays full. + assert_eq!(light(states.mip([compose(0x0F, AIR, 0); 8])), 0x0F); + } +} diff --git a/pomme-client/src/renderer/distant/worker.rs b/pomme-client/src/renderer/distant/worker.rs index 4a0832735..f3ad209a1 100644 --- a/pomme-client/src/renderer/distant/worker.rs +++ b/pomme-client/src/renderer/distant/worker.rs @@ -16,6 +16,8 @@ use super::column::build_chunk; use super::mesh::{SectionMesh, mesh_section}; use super::models::{ModelFactory, ModelTile}; use super::store::{SectionKey, Store}; +use super::voxel::States; +use super::world::World; use crate::position::ChunkPos; use crate::world::chunk::{Chunk, ChunkLightData, ChunkStore}; @@ -237,12 +239,21 @@ fn store<'a>( .or_insert_with(|| Store::new(dimension_dir(cache, dimension))) } +/// Voxy's sections go in a folder of their own beside the columns' until +/// the columns go. +fn world_dir(cache: Option<&Path>, dimension: &str) -> Option { + Some(dimension_dir(cache, dimension)?.join("voxy")) +} + struct Worker { colors: Arc, models: ModelFactory, updates: Sender, /// Per dimension, its sections. stores: HashMap, Store>, + /// Per dimension, its Voxy sections. + worlds: HashMap, World>, + states: States, cache: Option, last_save: Instant, view: Option, @@ -257,10 +268,12 @@ struct Worker { impl Worker { fn new(colors: Arc, updates: Sender) -> Self { Self { + states: States::new(colors.table()), colors, models: ModelFactory::new(), updates, stores: HashMap::new(), + worlds: HashMap::new(), cache: None, last_save: Instant::now(), view: None, @@ -312,6 +325,9 @@ impl Worker { for store in self.stores.values_mut() { store.flush(); } + for world in self.worlds.values_mut() { + world.flush(); + } self.last_save = Instant::now(); } @@ -324,9 +340,15 @@ impl Worker { light, min_y, } => { - let columns = build_chunk(&chunk.read(), light.as_deref(), min_y, &self.colors); + let chunk = chunk.read(); + let columns = build_chunk(&chunk, light.as_deref(), min_y, &self.colors); let touched = store(&mut self.stores, self.cache.as_deref(), &dimension) .capture(pos, columns); + let cache = self.cache.as_deref(); + self.worlds + .entry(Arc::clone(&dimension)) + .or_insert_with(|| World::new(world_dir(cache, &dimension))) + .ingest_chunk(&self.states, pos, &chunk, light.as_deref(), min_y); if let Some(view) = self.view.clone() && view.dimension == dimension { @@ -342,6 +364,7 @@ impl Worker { if !colors.same_models(&self.colors) { // Ids start over; the new tiles overwrite the old. self.models = ModelFactory::new(); + self.states = States::new(colors.table()); } self.colors = colors; let now = Instant::now(); @@ -360,6 +383,9 @@ impl Worker { } let store = store(&mut self.stores, self.cache.as_deref(), &view.dimension); store.evict_beyond(view.x, view.z, view.radius + KEEP_MARGIN); + if let Some(world) = self.worlds.get_mut(&view.dimension) { + world.evict_beyond(view.x, view.z, view.radius + KEEP_MARGIN); + } // Those with nothing seen mesh empty, so a parent knows its // children are all there. let wanted: HashSet = @@ -409,6 +435,9 @@ impl Worker { for (dimension, store) in &mut self.stores { store.set_dir(dimension_dir(cache.as_deref(), dimension)); } + for (dimension, world) in &mut self.worlds { + world.set_dir(world_dir(cache.as_deref(), dimension)); + } self.cache = cache; } Job::Stop => unreachable!("the run loop ends on it"), diff --git a/pomme-client/src/renderer/distant/world.rs b/pomme-client/src/renderer/distant/world.rs new file mode 100644 index 000000000..df2464216 --- /dev/null +++ b/pomme-client/src/renderer/distant/world.rs @@ -0,0 +1,284 @@ +//! One dimension's Voxy world (`WorldEngine`, `WorldUpdater`): its sections +//! at every level, those near the player in memory and every one seen on +//! disk, a file per section. + +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +use super::section::{self, LEVELS, SectionKey, VoxelizedSection, WorldSection}; +use super::voxel::States; +use crate::position::ChunkPos; +use crate::world::chunk::{Chunk, ChunkLightData}; + +/// A section an update changed, and which of its sides the change touched +/// (`WorldUpdater`'s neighbour mask: down, up, -x, +x, -z, +z), so the +/// sections across them can be meshed again. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Changed { + pub key: SectionKey, + pub sides: u8, +} + +#[derive(Default)] +pub(crate) struct World { + sections: HashMap, + dir: Option, + /// Sections changed since they were last written. + unsaved: HashSet, + /// Sections `dir` has no file for. + absent: HashSet, +} + +impl World { + pub fn new(dir: Option) -> Self { + Self { + dir, + ..Self::default() + } + } + + /// Writes what's unsaved, then keeps further sections in `dir`. + pub fn set_dir(&mut self, dir: Option) { + if self.dir != dir { + self.flush(); + self.dir = dir; + self.absent.clear(); + } + } + + fn path(&self, key: SectionKey) -> Option { + let SectionKey { level, x, y, z } = key; + Some(self.dir.as_ref()?.join(format!("{level}.{x}.{y}.{z}.bin"))) + } + + /// Reads `key` from disk unless it's in memory already. Returns whether + /// it's in memory now. + pub fn load(&mut self, key: SectionKey) -> bool { + if self.sections.contains_key(&key) { + return true; + } + if self.absent.contains(&key) { + return false; + } + let Some(path) = self.path(key) else { + return false; + }; + let read = std::fs::read(&path).ok(); + match read.as_deref().and_then(|file| section::decode(key, file)) { + Some(section) => { + self.sections.insert(key, section); + true + } + None => { + if read.is_some() { + tracing::warn!("Ignoring unreadable distant terrain {}", path.display()); + } + self.absent.insert(key); + false + } + } + } + + /// `key`'s section, loaded, or unseen air where there's none + /// (`ActiveSectionTracker`). + fn acquire(&mut self, key: SectionKey) -> &mut WorldSection { + self.load(key); + self.sections + .entry(key) + .or_insert_with(WorldSection::unseen) + } + + /// `WorldUpdater.insertUpdate`: writes a vanilla section's voxels into + /// every level, stopping at the first level nothing changed in, and + /// bubbles a section going empty or non-empty up to its parent. + pub fn insert_update(&mut self, update: &VoxelizedSection) -> Vec { + let mut changed = Vec::new(); + let mut child: Option<(u8, bool)> = None; + for level in 0..LEVELS { + let key = SectionKey::of_vanilla(level, update.x, update.y, update.z); + let into = self.acquire(key); + let (written, before) = section::insert_level(update, into, level); + let (touched, emptiness) = + section::settle_level(into, level, written, before, update.non_air_count, child); + let non_empty = into.non_empty_children != 0; + if touched { + self.absent.remove(&key); + self.unsaved.insert(key); + changed.push(Changed { + key, + sides: if written { + neighbour_sides(update, level) + } else { + 0 + }, + }); + } + if !written && emptiness != 2 { + break; + } + child = (emptiness == 2).then_some((key.child_index(), non_empty)); + } + changed + } + + /// `VoxelIngestService.processJob` for a column: every vanilla section + /// converted, mipped and written in. Returns what changed. + pub fn ingest_chunk( + &mut self, + states: &States, + pos: ChunkPos, + chunk: &Chunk, + light: Option<&ChunkLightData>, + min_y: i32, + ) -> Vec { + let mut changed = Vec::new(); + for (i, section) in chunk.sections.iter().enumerate() { + let section_y = i as i32 + min_y.div_euclid(16); + let mut update = VoxelizedSection::new(pos.x, section_y, pos.z); + if section.block_count == 0 && light.is_none() { + update.zero(); + } else { + update.convert(states, section, section::light_of(light, section_y)); + update.mip(states); + } + changed.extend(self.insert_update(&update)); + } + changed + } + + /// Writes `key`'s section, or removes its file once nothing is left in + /// it: a missing section reads as unseen. + fn save(&self, key: SectionKey) { + let (Some(path), Some(section)) = (self.path(key), self.sections.get(&key)) else { + return; + }; + if section.is_unseen() { + let _ = std::fs::remove_file(&path); + return; + } + let written = std::fs::create_dir_all(path.parent().expect("a section file has a folder")) + .and_then(|()| crate::util::write_atomic(&path, §ion::encode(key, section))); + if let Err(error) = written { + tracing::warn!("Failed to save distant terrain {}: {error}", path.display()); + } + } + + /// Writes every section changed since it was last written. + pub fn flush(&mut self) { + for key in std::mem::take(&mut self.unsaved) { + self.save(key); + } + } + + /// Drops the sections farther than `radius` blocks from `x`/`z`, saving + /// those unsaved, and returns their keys. + pub fn evict_beyond(&mut self, x: f64, z: f64, radius: f64) -> Vec { + let far: Vec = self + .sections + .keys() + .copied() + .filter(|key| key.distance(x, z) > radius) + .collect(); + for key in &far { + if self.unsaved.remove(key) { + self.save(*key); + } + self.sections.remove(key); + } + far + } +} + +/// Which sides of the level-`level` section a vanilla section's update +/// reaches: those it borders on. +fn neighbour_sides(update: &VoxelizedSection, level: u8) -> u8 { + let shift = level + 1; + let crosses = |c: i32, step: i32| u8::from((c ^ (c + step)) >> shift != 0); + crosses(update.y, -1) + | crosses(update.y, 1) << 1 + | crosses(update.x, -1) << 2 + | crosses(update.x, 1) << 3 + | crosses(update.z, -1) << 4 + | crosses(update.z, 1) << 5 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::renderer::distant::voxel; + + /// A vanilla section at `x`/`y`/`z` of `block` everywhere, mipped. + fn stone(x: i32, y: i32, z: i32, block: u32) -> VoxelizedSection { + let mut section = VoxelizedSection::new(x, y, z); + section.fill_for_test( + voxel::compose(0, block, 1), + if block == voxel::AIR { 0 } else { 4096 }, + ); + section + } + + #[test] + fn an_update_reaches_every_level_and_stops_once_nothing_changes() { + let mut world = World::default(); + let changed = world.insert_update(&stone(1, 0, 0, 7)); + assert_eq!(changed.len(), LEVELS as usize); + // Level 0 holds vanilla sections 0..1 per axis, so from (1, 0, 0) + // the update reaches the -y, +x and -z sides. + assert_eq!(changed[0].sides, 1 | 1 << 3 | 1 << 4); + let level4 = SectionKey { + level: 4, + x: 0, + y: 0, + z: 0, + }; + let section = &world.sections[&level4]; + assert!(!voxel::is_air(section.data[section::index(1, 0, 0)])); + assert_eq!(section.non_empty_children, 1); + let level0 = SectionKey { level: 0, ..level4 }; + assert_eq!(world.sections[&level0].non_empty_count, 4096); + // The same again changes nothing past level 0. + assert!(world.insert_update(&stone(1, 0, 0, 7)).is_empty()); + // Clearing it empties every level. + let cleared = world.insert_update(&stone(1, 0, 0, voxel::AIR)); + assert_eq!(cleared.len(), LEVELS as usize); + assert_eq!(world.sections[&level4].non_empty_children, 0); + } + + #[test] + fn evicted_sections_come_back_from_disk() { + let dir = std::env::temp_dir().join(format!("pomme-voxy-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut world = World::new(Some(dir.clone())); + world.insert_update(&stone(100, 0, 0, 7)); + world.evict_beyond(8.0, 8.0, 500.0); + assert!(world.sections.is_empty()); + + let mut world = World::new(Some(dir.clone())); + world.insert_update(&stone(101, 0, 0, 8)); + let level4 = &world.sections[&SectionKey { + level: 4, + x: 3, + y: 0, + z: 0, + }]; + assert_eq!(voxel::block_id(level4.data[section::index(4, 0, 0)]), 7); + assert_eq!(voxel::block_id(level4.data[section::index(5, 0, 0)]), 8); + assert!(!world.load(SectionKey { + level: 0, + x: -9, + y: 0, + z: 0 + })); + // A section cleared back to unseen loses its file. + let level0 = SectionKey::of_vanilla(0, 103, 0, 0); + world.insert_update(&stone(103, 0, 0, 9)); + world.flush(); + assert!(world.path(level0).unwrap().exists()); + let mut cleared = stone(103, 0, 0, voxel::AIR); + cleared.fill_for_test(voxel::compose(0x0F, voxel::AIR, 0), 0); + world.insert_update(&cleared); + world.flush(); + assert!(!world.path(level0).unwrap().exists()); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/pomme-client/src/world/block/mod.rs b/pomme-client/src/world/block/mod.rs index e78dd620e..427e6e55f 100644 --- a/pomme-client/src/world/block/mod.rs +++ b/pomme-client/src/world/block/mod.rs @@ -256,6 +256,11 @@ impl BlockStateData { self.is_air } + /// Vanilla `LeavesBlock`: every leaf block's id ends so. + pub(crate) fn is_leaves(&self) -> bool { + self.id.ends_with("_leaves") + } + /// `getRenderShape() == RenderShape.INVISIBLE`: the blocks overriding it. pub(crate) fn is_invisible(&self) -> bool { self.is_air From 30b00b70f7f8e9db090879089decce8c0771e4ed Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:35:16 +0000 Subject: [PATCH 20/67] mesh and draw the distant sections, traversed on the cpu until the gpu pass --- pomme-client/src/app/phases/in_game.rs | 19 +- pomme-client/src/cpu_bench/distant.rs | 68 +- pomme-client/src/renderer/chunk/atlas.rs | 38 - pomme-client/src/renderer/distant/bakery.rs | 30 +- pomme-client/src/renderer/distant/build.rs | 1082 +++++++++++++++++ pomme-client/src/renderer/distant/colors.rs | 237 +--- pomme-client/src/renderer/distant/column.rs | 340 ------ pomme-client/src/renderer/distant/gpu.rs | 802 ++++++++---- pomme-client/src/renderer/distant/mesh.rs | 625 ---------- pomme-client/src/renderer/distant/mod.rs | 5 +- pomme-client/src/renderer/distant/models.rs | 351 ++++-- pomme-client/src/renderer/distant/scan.rs | 346 ++++++ pomme-client/src/renderer/distant/section.rs | 13 +- pomme-client/src/renderer/distant/store.rs | 537 -------- pomme-client/src/renderer/distant/voxel.rs | 3 +- pomme-client/src/renderer/distant/worker.rs | 281 ++--- pomme-client/src/renderer/distant/world.rs | 5 + pomme-client/src/renderer/mod.rs | 43 +- .../src/renderer/pipelines/distant.rs | 108 +- .../src/renderer/shaders/distant.frag | 87 +- .../src/renderer/shaders/distant.vert | 161 +-- .../src/renderer/shaders/distant_models.glsl | 23 +- pomme-client/src/world/block/mod.rs | 26 + tests/ingame/world/distant_terrain.toml | 8 + 24 files changed, 2749 insertions(+), 2489 deletions(-) create mode 100644 pomme-client/src/renderer/distant/build.rs delete mode 100644 pomme-client/src/renderer/distant/column.rs delete mode 100644 pomme-client/src/renderer/distant/mesh.rs create mode 100644 pomme-client/src/renderer/distant/scan.rs delete mode 100644 pomme-client/src/renderer/distant/store.rs diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index edab1c164..4099d9f52 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -37,6 +37,7 @@ use crate::renderer::chunk::mesh_columns::MeshColumns; use crate::renderer::chunk::mesh_source::MeshSource; use crate::renderer::chunk::mesher::{BiomeClimate, ChunkMeshData, MeshDispatcher}; use crate::renderer::chunk::occlusion_graph::{self, VisibilitySet}; +use crate::renderer::distant::gpu::View as DistantView; use crate::renderer::entity_model::{lerp, triangle_wave}; use crate::renderer::pipelines::block_entity; use crate::renderer::pipelines::entity_renderer::{ @@ -1310,13 +1311,9 @@ impl GameState { pub fn drain_and_upload_meshes(&mut self, renderer: &mut Renderer) { let batch = self.drain_meshes(); let upload_start = std::time::Instant::now(); - let dimension = self.dimension.as_str(); - let evicted = renderer.upload_distant( - self.distant - .drain() - .filter(|update| &*update.dimension == dimension), - ); - self.distant.evicted(evicted); + renderer.upload_distant(self.distant.drain()); + let (wanted, gone) = renderer.take_distant_requests(); + self.distant.request(wanted, gone); let dropped = renderer.upload_chunk_meshes(&batch); self.last_update_phases.upload_ms = upload_start.elapsed().as_secs_f32() * 1000.0; self.clear_dropped_meshed(dropped); @@ -4424,11 +4421,17 @@ pub fn update_game( }; game.distant.set_view( &game.dimension, - game.chunk_store.min_y(), game.player.position.x, game.player.position.z, distant_terrain, ); + gfx.renderer + .set_distant_view((distant_terrain > 0).then(|| DistantView { + dimension: game.dimension.as_str().into(), + min_y: game.chunk_store.min_y(), + height: game.chunk_store.height() as i32, + radius: f64::from(distant_terrain * 16), + })); let inventory = &game.player.inventory; let main_stack = &inventory.hotbar_slots()[core.input.selected_slot() as usize]; let shown_hands = game diff --git a/pomme-client/src/cpu_bench/distant.rs b/pomme-client/src/cpu_bench/distant.rs index 3568072f2..64ff632f1 100644 --- a/pomme-client/src/cpu_bench/distant.rs +++ b/pomme-client/src/cpu_bench/distant.rs @@ -1,5 +1,5 @@ -//! Distant terrain's worker: a forgotten chunk turned into columns and -//! merged down every level, and a section meshed, per scene. +//! Distant terrain's worker: a forgotten chunk voxelized and mipped into +//! every level, and a section meshed, per scene. use std::hint::black_box; @@ -7,65 +7,53 @@ use criterion::Criterion; use super::fixtures::{Assets, Scene, build_world, columns_within}; use crate::position::ChunkPos; -use crate::renderer::distant::column::build_chunk; -use crate::renderer::distant::mesh::mesh_section; +use crate::renderer::distant::build::Factory; use crate::renderer::distant::models::ModelFactory; -use crate::renderer::distant::store::{LEVELS, SectionKey, Store}; +use crate::renderer::distant::section::{LEVELS, SectionKey}; use crate::renderer::distant::voxel::States; use crate::renderer::distant::world::World; pub fn bench(c: &mut Criterion, assets: &Assets) { let colors = assets.mesh_dispatcher().distant_colors(); + let states = States::new(assets.block_state_table); let mut group = c.benchmark_group("distant"); for scene in Scene::ALL { let world = build_world(scene, assets.block_state_table); - let column = |pos: ChunkPos| { + let ingest = |into: &mut World, pos: ChunkPos| { let chunk = world .get_chunk(&pos) .expect("the scene's columns are loaded"); let light = world.light_data.get(&(pos.x, pos.z)).cloned(); - (chunk, light) + into.ingest_chunk(&states, pos, &chunk.read(), light.as_deref(), world.min_y()) }; - let capture = |store: &mut Store, pos: ChunkPos| { - let (chunk, light) = column(pos); - let columns = build_chunk(&chunk.read(), light.as_deref(), world.min_y(), &colors); - store.capture(pos, columns) - }; - group.bench_function(format!("capture/{}", scene.name()), |b| { - b.iter(|| black_box(capture(&mut Store::default(), ChunkPos::new(0, 0)))) - }); - let states = States::new(assets.block_state_table); group.bench_function(format!("ingest/{}", scene.name()), |b| { - b.iter(|| { - let pos = ChunkPos::new(0, 0); - let (chunk, light) = column(pos); - black_box(World::default().ingest_chunk( - &states, - pos, - &chunk.read(), - light.as_deref(), - world.min_y(), - )) - }) + b.iter(|| black_box(ingest(&mut World::default(), ChunkPos::new(0, 0)))) }); - let mut store = Store::default(); + let mut voxy = World::default(); for pos in columns_within(1) { - capture(&mut store, pos); + ingest(&mut voxy, pos); } - for level in [0, LEVELS - 1] { - let key = SectionKey { level, x: 0, z: 0 }; - let section = store.get(key).expect("chunk (0, 0)'s sections"); - // Models bake once, before the meshing measured. + // The section holding the surface at full detail, and the one + // holding everything at the coarsest. + for key in [ + SectionKey::of_vanilla(0, 0, 4, 0), + SectionKey::of_vanilla(LEVELS - 1, 0, 0, 0), + ] { + let section = voxy.get(key).expect("chunk (0, 0)'s sections"); let mut models = ModelFactory::new(); - mesh_section(key, section, |run| colors.run_look(&mut models, run)); - group.bench_function(format!("mesh/{}/level{level}", scene.name()), |b| { - b.iter(|| { - black_box(mesh_section(key, section, |run| { - colors.run_look(&mut models, run) - })) - }) + let mut factory = Factory::new(); + let build = |factory: &mut Factory, models: &mut ModelFactory| { + factory + .build(key, section, |k| voxy.get(k), &mut colors.baking(models)) + .quads + .len() + }; + // Models bake once, before the meshing measured. + build(&mut factory, &mut models); + group.bench_function(format!("build/{}/level{}", scene.name(), key.level), |b| { + b.iter(|| black_box(build(&mut factory, &mut models))) }); } } diff --git a/pomme-client/src/renderer/chunk/atlas.rs b/pomme-client/src/renderer/chunk/atlas.rs index 852415bc6..b940af206 100644 --- a/pomme-client/src/renderer/chunk/atlas.rs +++ b/pomme-client/src/renderer/chunk/atlas.rs @@ -123,13 +123,6 @@ impl AtlasUVMap { self.sprite_pixels.get(name) } - /// A block sprite's alpha-weighted average colour, for terrain drawn too - /// far out to texture. - pub(crate) fn average_color(&self, name: &str) -> Option<[u8; 4]> { - let sprite = self.sprite_pixels(name)?; - Some(average_rgba(&sprite.rgba, sprite.width, sprite.height)) - } - /// Each sprite's texture name, by sprite index. #[cfg(feature = "cpu-bench")] pub fn sprite_names(&self) -> HashMap { @@ -1082,27 +1075,6 @@ fn effective_mip_level(sources: &[Source]) -> u32 { }) } -/// The alpha-weighted mean colour of the first `w`x`h` RGBA texels, with -/// their mean alpha: a sprite's first frame, transparent texels not -/// darkening it. -fn average_rgba(data: &[u8], w: u32, h: u32) -> [u8; 4] { - let texels = (w * h) as usize; - let mut sum = [0u64; 3]; - let mut alpha = 0u64; - for texel in data.as_chunks::<4>().0.iter().take(texels) { - let a = u64::from(texel[3]); - for (total, &channel) in sum.iter_mut().zip(texel) { - *total += u64::from(channel) * a; - } - alpha += a; - } - if alpha == 0 { - return [0; 4]; - } - let [r, g, b] = sum.map(|total| (total / alpha) as u8); - [r, g, b, (alpha / texels as u64) as u8] -} - fn build_mip_chain( atlas_pixels: Vec, atlas_size: u32, @@ -1635,16 +1607,6 @@ mod tests { use super::*; use crate::test_util::test_temp_dir; - #[test] - fn the_average_colour_skips_transparent_texels_and_later_frames() { - // A 2x1 sprite: opaque red, clear green; then a white second frame. - let data = [ - 255, 0, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, - ]; - assert_eq!(average_rgba(&data, 2, 1), [255, 0, 0, 127]); - assert_eq!(average_rgba(&[0; 8], 2, 1), [0; 4]); - } - #[test] fn texture_ids_find_the_region_their_name_does() { let built = TextureId::intern("test/region_before_build"); diff --git a/pomme-client/src/renderer/distant/bakery.rs b/pomme-client/src/renderer/distant/bakery.rs index e581c88a0..974668d62 100644 --- a/pomme-client/src/renderer/distant/bakery.rs +++ b/pomme-client/src/renderer/distant/bakery.rs @@ -297,7 +297,7 @@ fn quat(axis: Vec3, angle: f32) -> Quat { } /// What a bake found across the model's quads. -#[derive(Clone, Copy, Default)] +#[derive(Clone, Copy)] pub(crate) struct BakeFlags { /// Some quad's sprite mips as `dark_cutout`. pub darkened: bool, @@ -305,6 +305,10 @@ pub(crate) struct BakeFlags { pub translucent: bool, /// Some opaque-layer quad leaves texels out. pub discard: bool, + /// Some quad takes ambient occlusion, so the model shades per face. + pub shaded: bool, + /// The first tinted quad's tint. + pub tint: Tint, } /// Voxy's `SoftwareModelTextureBakery`. @@ -330,12 +334,20 @@ impl Bakery { state: BlockState, ) -> Option<([FaceTexture; 6], BakeFlags)> { let data = view.block_state_table().block_state_data(state); - if data.is_invisible() { + // Fluids draw through vanilla's `FluidRenderer`, their block being + // invisible. + if data.is_invisible() && !data.is_fluid_block() { return None; } // Voxy forces leaves into the solid layer. let leaves = data.is_leaves(); - let mut flags = BakeFlags::default(); + let mut flags = BakeFlags { + darkened: false, + translucent: false, + discard: false, + shaded: false, + tint: Tint::None, + }; let (mut opaque, mut translucent): (Vec>, Vec>) = (Vec::new(), Vec::new()); let mut add = |quad: Quad<'a>, layer_translucent: bool, opaque_texels: bool| { @@ -355,8 +367,12 @@ impl Bakery { }); } }; - let fluid = data.fluid().kind; - if fluid != FluidKind::Empty && !data.has_collision() { + if data.is_fluid_block() { + let fluid = data.fluid().kind; + flags.shaded = true; + if fluid == FluidKind::Water { + flags.tint = Tint::Water; + } for quad in fluid_quads(uv_map, fluid) { add(quad, fluid == FluidKind::Water, true); } @@ -370,6 +386,10 @@ impl Bakery { let Some(sprite) = uv_map.sprite_pixels(name) else { continue; }; + flags.shaded |= quad.ambient_occlusion; + if flags.tint == Tint::None { + flags.tint = quad.tint; + } let region = uv_map.region(quad.texture); let meta = if quad.tint == Tint::None { 0 diff --git a/pomme-client/src/renderer/distant/build.rs b/pomme-client/src/renderer/distant/build.rs new file mode 100644 index 000000000..3fd225ff1 --- /dev/null +++ b/pomme-client/src/renderer/distant/build.rs @@ -0,0 +1,1082 @@ +//! Voxy's `RenderDataFactory`: a world section meshed into quads, a pass +//! per axis over its opaque blocks, its fluids and its other blocks, each +//! face culled against what it touches, inside the section and in the six +//! around it. + +use super::models::{Metadata, Models}; +use super::scan::{ + GROUPS, Mesher, QUAD_BIOME_SHIFT, QUAD_LIGHT, QUAD_LIGHT_SHIFT, QUAD_MODEL_SHIFT, Quads, +}; +use super::section::{self, SectionKey, UNSEEN, VOLUME, WorldSection}; +use super::voxel; + +/// Model ids in a quad, where the mesher compares them. +const MODEL: u64 = 0xFFFF << QUAD_MODEL_SHIFT; +/// A quad's block light. +const BLOCK_LIGHT: u64 = 0xF << (QUAD_LIGHT_SHIFT + 4); + +/// A meshed section (`BuiltSection`). +pub(crate) struct BuiltSection { + pub key: SectionKey, + /// [`WorldSection::non_empty_children`] when it was meshed. + pub child_existence: u8, + /// Its quads' bounds: min x, y, z in 5 bits each, then the extents less + /// one in 5 bits each from bit 15. + pub aabb: u32, + /// Every group's quads in turn ([`GROUPS`]); empty for a section with + /// nothing to draw. + pub quads: Vec, + /// Where each group starts in `quads`. + pub offsets: [u32; GROUPS], +} + +impl BuiltSection { + pub fn empty(key: SectionKey, child_existence: u8) -> Self { + Self { + key, + child_existence, + aabb: 0, + quads: Vec::new(), + offsets: [0; GROUPS], + } + } +} + +/// The neighbouring sections' slices the outer passes read: -x, +x, -y, +y, +/// -z, +z, each 32x32. +const SIDES: [[i32; 3]; 6] = [ + [-1, 0, 0], + [1, 0, 0], + [0, -1, 0], + [0, 1, 0], + [0, 0, -1], + [0, 0, 1], +]; + +pub(crate) struct Factory { + /// Per voxel, its quad without a position (`packPartialQuadData`), then + /// its model's metadata; air keeps only its light. + section: Box<[u64]>, + /// The six neighbours' facing slices ([`SIDES`]). + neighbours: Box<[u64]>, + /// A bit per x of each row `y * 32 + z`: fully opaque blocks, blocks + /// neither opaque nor fluid, and fluids or blocks holding one. + opaque: Box<[u32; 1024]>, + non_opaque: Box<[u32; 1024]>, + fluid: Box<[u32; 1024]>, + /// The y and z passes' meshers: positive faces, then negative ones of + /// the blocks that aren't opaque. + mesher: Mesher, + secondary: Mesher, + /// The x pass's, one per layer, the same way. + x_meshers: Box<[Mesher; 32]>, + x_secondary: Box<[Mesher; 32]>, + out: Quads, +} + +impl Factory { + pub fn new() -> Self { + Self { + section: vec![0; VOLUME * 2].into(), + neighbours: vec![UNSEEN; 6 * 1024].into(), + opaque: Box::new([0; 1024]), + non_opaque: Box::new([0; 1024]), + fluid: Box::new([0; 1024]), + mesher: Mesher::new(0, 0, true), + secondary: Mesher::new(0, 0, false), + x_meshers: Box::new(std::array::from_fn(|i| Mesher::new(2, i as u8, true))), + x_secondary: Box::new(std::array::from_fn(|i| Mesher::new(2, i as u8, false))), + out: Quads::new(), + } + } + + /// `generateMesh`: `section` at `key` meshed, `neighbour` giving the + /// sections around it (missing ones are unseen air). + pub fn build<'a>( + &mut self, + key: SectionKey, + section: &'a WorldSection, + neighbour: impl Fn(SectionKey) -> Option<&'a WorldSection>, + models: &mut dyn Models, + ) -> BuiltSection { + self.out.clear(); + let needed = self.prepare(§ion.data, models); + for (side, offset) in SIDES.iter().enumerate() { + if needed & 1 << side == 0 { + continue; + } + let slice = &mut self.neighbours[side * 1024..][..1024]; + let next = SectionKey { + level: key.level, + x: key.x + offset[0], + y: key.y + offset[1], + z: key.z + offset[2], + }; + match neighbour(next) { + Some(next) => { + for (i, voxel) in slice.iter_mut().enumerate() { + *voxel = next.data[neighbour_index(side, i)]; + } + } + None => slice.fill(UNSEEN), + } + } + for axis in 0..2 { + self.mesher.axis = axis; + self.secondary.axis = axis; + self.opaque_inner(axis); + self.opaque_outer(axis, models); + self.fluid_inner(axis, models); + self.fluid_outer(axis, models); + self.non_opaque_inner(axis); + self.non_opaque_outer(axis, models); + } + self.x_opaque_inner(); + self.x_opaque_outer(models); + self.finish_x(); + self.x_fluid_inner(models); + self.x_fluid_outer(models); + self.finish_x(); + self.x_non_opaque_inner(); + self.x_non_opaque_outer(models); + self.finish_x(); + + if self.out.count() == 0 { + return BuiltSection::empty(key, section.non_empty_children); + } + let mut offsets = [0; GROUPS]; + let mut quads = Vec::with_capacity(self.out.count()); + for (group, offset) in self.out.groups.iter().zip(&mut offsets) { + *offset = quads.len() as u32; + quads.extend_from_slice(group); + } + let [min_x, min_y, min_z] = self.out.min.map(|c| c as u32); + let extent = |axis: usize| (self.out.max[axis] - self.out.min[axis] - 1).max(0) as u32; + let aabb = + min_x | min_y << 5 | min_z << 10 | extent(0) << 15 | extent(1) << 20 | extent(2) << 25; + BuiltSection { + key, + child_existence: section.non_empty_children, + aabb, + quads, + offsets, + } + } + + /// `prepareSectionData`: each voxel's quad and metadata and the row + /// masks; returns which sides have blocks against them ([`SIDES`]). + fn prepare(&mut self, data: &[u64], models: &mut dyn Models) -> u8 { + let mut needed = 0; + for y in 0..32 { + for z in 0..32 { + let (mut not_empty, mut opaque, mut pure_fluid, mut partial_fluid) = + (0u32, 0, 0, 0); + for x in 0..32 { + let i = section::index(x, y, z); + let voxel = data[i]; + let model = if voxel::is_air(voxel) { + 0 + } else { + models.model(voxel::block_id(voxel)) + }; + let (quad, meta) = if model == 0 { + ((voxel & voxel::LIGHT_MASK) >> 1, Metadata(0)) + } else { + let meta = models.metadata(model); + not_empty |= 1 << x; + opaque |= u32::from(meta.is_fully_opaque()) << x; + pure_fluid |= u32::from(meta.is_fluid()) << x; + partial_fluid |= u32::from(meta.contains_fluid()) << x; + (pack_partial(model, voxel, meta), meta) + }; + self.section[i * 2] = quad; + self.section[i * 2 + 1] = meta.0; + } + let row = y * 32 + z; + self.opaque[row] = opaque; + self.non_opaque[row] = (not_empty ^ opaque) & !pure_fluid; + self.fluid[row] = pure_fluid | partial_fluid; + if not_empty != 0 { + needed |= (not_empty & 1) as u8 + | ((not_empty >> 31) << 1) as u8 + | u8::from(y == 0) << 2 + | u8::from(y == 31) << 3 + | u8::from(z == 0) << 4 + | u8::from(z == 31) << 5; + } + } + } + needed + } + + fn quad(&self, i: usize) -> u64 { + self.section[i * 2] + } + + fn meta(&self, i: usize) -> Metadata { + Metadata(self.section[i * 2 + 1]) + } + + fn neighbour(&self, side: usize, i: usize) -> u64 { + self.neighbours[side * 1024 + i] + } + + /// The neighbouring section's voxel against row `other`, x `index` of + /// `axis`'s outer layer on `side`. + fn across(&self, axis: u8, side: usize, other: usize, index: i32) -> u64 { + self.neighbour((axis as usize + 1) * 2 + side, other * 32 + index as usize) + } + + /// The neighbouring block's model and metadata, 0 for air. + fn neighbour_model(models: &mut dyn Models, voxel: u64) -> (u16, Metadata) { + if voxel::is_air(voxel) { + return (0, Metadata(0)); + } + let model = models.model(voxel::block_id(voxel)); + (model, models.metadata(model)) + } + + /// Voxel `index` of the y or z pass's row `pidx` in `axis`'s layer. + fn row_index(axis: u8, layer: usize, other: usize) -> usize { + if axis == 0 { + layer * 32 + other + } else { + other * 32 + layer + } + } + + /// The voxel `idx` a face belongs to and the one across it, `step` + /// voxels on: `idx` faces forward when it's the solid one. + fn pair(idx: usize, step: usize, forward: u32) -> (usize, usize) { + if forward == 1 { + (idx, idx + step) + } else { + (idx + step, idx) + } + } + + /// The y and z passes' walk of `axis`'s layer `layer`, row by row: + /// `mask` gives a row's faces by x, `each` meshes one, and the meshers + /// skip the rest (the secondary too when `both`). + fn walk( + &mut self, + axis: u8, + layer: usize, + both: bool, + mask: impl Fn(&Self, usize) -> u32, + mut each: impl FnMut(&mut Self, usize, usize, i32), + ) { + self.mesher.position = layer as u8; + self.secondary.position = layer as u8; + let mut skips = 0; + for other in 0..32 { + let pidx = Self::row_index(axis, layer, other); + let mask = mask(self, pidx); + if mask == 0 { + skips += 32; + continue; + } + self.mesher.skip(skips, &mut self.out); + if both { + self.secondary.skip(skips, &mut self.out); + } + skips = 0; + for (index, gap) in faces(mask) { + self.mesher.skip(gap, &mut self.out); + if both { + self.secondary.skip(gap, &mut self.out); + } + each(self, pidx, other, index); + } + self.mesher.end_row(&mut self.out); + if both { + self.secondary.end_row(&mut self.out); + } + } + self.mesher.finish(&mut self.out); + if both { + self.secondary.finish(&mut self.out); + } + } + + /// Mask rows between the layers along `axis`. + fn step(axis: u8) -> usize { + if axis == 0 { 32 } else { 1 } + } + + /// `generateYZOpaqueInnerGeometry`: the faces between opaque blocks and + /// everything else, between the layers along `axis`. + fn opaque_inner(&mut self, axis: u8) { + self.mesher.face_offset = true; + let step = Self::step(axis); + for layer in 0..31 { + self.walk( + axis, + layer, + false, + |s, pidx| s.opaque[pidx] ^ s.opaque[pidx + step], + |s, pidx, _, index| { + let forward = s.opaque[pidx] >> index & 1; + let (own, next) = Self::pair(index as usize + pidx * 32, step * 32, forward); + let (own_quad, next_quad, next_meta) = + (s.quad(own), s.quad(next), s.meta(next)); + let culls = (own_quad ^ next_quad) & MODEL == 0 && next_meta.culls_same() + || next_meta.face_occludes(axis << 1 | (1 - forward) as u8); + let quad = (!culls).then(|| { + apply_light( + u64::from(forward) | own_quad & !QUAD_LIGHT | next_quad & QUAD_LIGHT, + s.meta(own), + ) + }); + put_or_skip(&mut s.mesher, quad, &mut s.out); + }, + ); + } + } + + /// `generateYZOpaqueOuterGeometry`: the opaque faces on the section's two + /// sides along `axis`, against the neighbouring sections. + fn opaque_outer(&mut self, axis: u8, models: &mut dyn Models) { + self.mesher.face_offset = false; + for side in 0..2usize { + self.walk( + axis, + side * 31, + false, + |s, pidx| s.opaque[pidx], + |s, pidx, other, index| { + let idx = index as usize + pidx * 32; + let voxel = s.across(axis, side, other, index); + let own_quad = s.quad(idx); + let (model, meta) = Self::neighbour_model(models, voxel); + let culls = model != 0 + && (meta.is_fully_opaque() + || u64::from(model) << QUAD_MODEL_SHIFT == own_quad & MODEL + && meta.culls_same() + || meta.face_occludes(axis << 1 | (1 - side) as u8)); + let quad = (!culls).then(|| { + apply_light( + side as u64 | own_quad & !QUAD_LIGHT | neighbour_light(voxel), + s.meta(idx), + ) + }); + put_or_skip(&mut s.mesher, quad, &mut s.out); + }, + ); + } + self.mesher.face_offset = true; + } + + /// `generateYZFluidInnerGeometry`: fluid faces between layers, a block + /// holding a fluid drawing the fluid's model instead. + fn fluid_inner(&mut self, axis: u8, models: &mut dyn Models) { + let step = Self::step(axis); + for layer in 0..31 { + self.walk( + axis, + layer, + false, + |s, pidx| { + let (current, next) = (s.fluid[pidx], s.fluid[pidx + step]); + ((current | s.opaque[pidx]) ^ (next | s.opaque[pidx + step])) & (current | next) + }, + |s, pidx, _, index| { + let forward = s.fluid[pidx] >> index & 1; + let (own, next) = Self::pair(index as usize + pidx * 32, step * 32, forward); + let quad = + (!s.meta(next).face_occludes(axis << 1 | (1 - forward) as u8)).then(|| { + let (quad, meta) = as_fluid(models, s.quad(own), s.meta(own)); + apply_light( + u64::from(forward) | quad & !QUAD_LIGHT | s.quad(next) & QUAD_LIGHT, + meta, + ) + }); + put_or_skip(&mut s.mesher, quad, &mut s.out); + }, + ); + } + } + + /// `generateYZFluidOuterGeometry`. + fn fluid_outer(&mut self, axis: u8, models: &mut dyn Models) { + self.mesher.face_offset = false; + for side in 0..2usize { + self.walk( + axis, + side * 31, + false, + |s, pidx| s.fluid[pidx], + |s, pidx, other, index| { + let idx = index as usize + pidx * 32; + let voxel = s.across(axis, side, other, index); + let (quad, meta) = as_fluid(models, s.quad(idx), s.meta(idx)); + let (mut model, next_meta) = Self::neighbour_model(models, voxel); + if model != 0 && next_meta.contains_fluid() { + model = models.fluid_model(model); + } + let culls = model != 0 + && (meta.culls_same() + && u64::from(model) << QUAD_MODEL_SHIFT == quad & MODEL + || next_meta.face_occludes(axis << 1 | (1 - side) as u8)); + let quad = (!culls).then(|| { + apply_light( + side as u64 | quad & !QUAD_LIGHT | neighbour_light(voxel), + meta, + ) + }); + put_or_skip(&mut s.mesher, quad, &mut s.out); + }, + ); + } + self.mesher.face_offset = true; + } + + /// `generateYZNonOpaqueInnerGeometry`: both faces of every block that's + /// neither opaque nor fluid, in the inner layers. + fn non_opaque_inner(&mut self, axis: u8) { + let step = Self::step(axis) * 32; + for layer in 1..31 { + self.walk( + axis, + layer, + true, + |s, pidx| s.non_opaque[pidx], + |s, pidx, _, index| { + let idx = index as usize + pidx * 32; + let (quad, meta) = (s.quad(idx), s.meta(idx)); + let below = non_opaque_face( + axis << 1, + quad, + meta, + s.quad(idx - step), + s.meta(idx - step), + ); + let above = non_opaque_face( + axis << 1 | 1, + quad, + meta, + s.quad(idx + step), + s.meta(idx + step), + ); + put_or_skip(&mut s.secondary, below, &mut s.out); + put_or_skip(&mut s.mesher, above, &mut s.out); + }, + ); + } + } + + /// `generateYZNonOpaqueOuterGeometry`: the same on the two outer layers, + /// the outward face against the neighbouring section. + fn non_opaque_outer(&mut self, axis: u8, models: &mut dyn Models) { + for side in 0..2usize { + let step = Self::step(axis) as isize * 32 * (1 - side as isize * 2); + self.walk( + axis, + side * 31, + true, + |s, pidx| s.non_opaque[pidx], + |s, pidx, other, index| { + let idx = index as usize + pidx * 32; + let voxel = s.across(axis, side, other, index); + let (quad, meta) = (s.quad(idx), s.meta(idx)); + // Outward, against the neighbouring section's block. + let (model, next_meta) = Self::neighbour_model(models, voxel); + let fail = model != 0 + && (meta.culls_same() + && u64::from(model) << QUAD_MODEL_SHIFT == quad & MODEL + || next_meta.face_occludes(axis << 1 | (1 - side) as u8)); + // Inward, against the next layer in. + let inner = (idx as isize + step) as usize; + let (inner_quad, inner_meta) = (s.quad(inner), s.meta(inner)); + let fail_inner = inner_meta.culls_same() && inner_quad & MODEL == quad & MODEL + || inner_meta.face_occludes(axis << 1 | side as u8); + // Voxy leaves these faces with the block's own light. + let positive = meta.face_exists(axis << 1 | 1) + && ((side == 1 && !fail) || (side == 0 && !fail_inner)); + let negative = meta.face_exists(axis << 1) + && ((side == 0 && !fail) || (side == 1 && !fail_inner)); + put_or_skip( + &mut s.mesher, + positive.then(|| apply_light(1 | quad, meta)), + &mut s.out, + ); + put_or_skip( + &mut s.secondary, + negative.then(|| apply_light(quad, meta)), + &mut s.out, + ); + }, + ); + } + } + + fn finish_x(&mut self) { + for mesher in self.x_meshers.iter_mut().chain(self.x_secondary.iter_mut()) { + mesher.finish(&mut self.out); + } + } + + /// The x pass's walk: every row `y * 32 + z` once, a mesher per x layer + /// (the secondary too when `both`); `mask` gives a row's faces, `each` + /// meshes one. Voxy keeps the layers' pending skips in packed 5-bit + /// counters; plain counters here, flushed before a face lands in a layer + /// and at the end of every row. + fn walk_x( + &mut self, + both: bool, + mask: impl Fn(&Self, usize) -> u32, + mut each: impl FnMut(&mut Self, usize, usize), + ) { + for y in 0..32 { + let mut pending = [0u32; 32]; + for z in 0..32 { + let i = y * 32 + z; + let mask = mask(self, i); + add_skips(&mut pending, mask); + for (index, _) in faces(mask) { + let index = index as usize; + let skips = std::mem::take(&mut pending[index]); + self.x_meshers[index].skip(skips, &mut self.out); + if both { + self.x_secondary[index].skip(skips, &mut self.out); + } + each(self, i, index); + } + } + for (index, skips) in pending.into_iter().enumerate() { + self.x_meshers[index].skip(skips, &mut self.out); + if both { + self.x_secondary[index].skip(skips, &mut self.out); + } + } + } + } + + /// `generateXOpaqueInnerGeometry`. + fn x_opaque_inner(&mut self) { + self.walk_x( + false, + |s, i| { + let row = s.opaque[i]; + (row ^ row >> 1) & !(1 << 31) + }, + |s, i, index| { + let forward = s.opaque[i] >> index & 1; + let (own, next) = Self::pair(index + i * 32, 1, forward); + let quad = (!s.meta(next).face_occludes(2 << 1 | (1 - forward) as u8)).then(|| { + apply_light( + u64::from(forward) | s.quad(own) & !QUAD_LIGHT | s.quad(next) & QUAD_LIGHT, + s.meta(own), + ) + }); + put_or_skip(&mut s.x_meshers[index], quad, &mut s.out); + }, + ); + } + + /// `generateXInnerFluidGeometry`. + fn x_fluid_inner(&mut self, models: &mut dyn Models) { + self.walk_x( + false, + |s, i| { + let fluid = s.fluid[i]; + let row = s.opaque[i] | fluid; + (row ^ row >> 1) & !(1 << 31) & (fluid | fluid >> 1) + }, + |s, i, index| { + let forward = (s.opaque[i] | s.fluid[i]) >> index & 1; + let (own, next) = Self::pair(index + i * 32, 1, forward); + let quad = (!s.meta(next).face_occludes(2 << 1 | (1 - forward) as u8)).then(|| { + let (quad, meta) = as_fluid(models, s.quad(own), s.meta(own)); + apply_light( + u64::from(forward) | quad & !QUAD_LIGHT | s.quad(next) & QUAD_LIGHT, + meta, + ) + }); + put_or_skip(&mut s.x_meshers[index], quad, &mut s.out); + }, + ); + } + + /// `generateXNonOpaqueInnerGeometry`. + fn x_non_opaque_inner(&mut self) { + self.walk_x( + true, + |s, i| s.non_opaque[i] & !0x8000_0001, + |s, i, index| { + let idx = index + i * 32; + let (quad, meta) = (s.quad(idx), s.meta(idx)); + let below = non_opaque_face(2 << 1, quad, meta, s.quad(idx - 1), s.meta(idx - 1)); + let above = + non_opaque_face(2 << 1 | 1, quad, meta, s.quad(idx + 1), s.meta(idx + 1)); + put_or_skip(&mut s.x_secondary[index], below, &mut s.out); + put_or_skip(&mut s.x_meshers[index], above, &mut s.out); + }, + ); + } + + /// The x outer passes' walk: at x 0 and 31 of every row `mask` marks, + /// `each` gives the quads for the layer's mesher and its secondary (or + /// none to skip), against the neighbouring section's voxel. The + /// layers' meshers start afresh, with `face_offset` for the pass. + fn walk_x_outer( + &mut self, + models: &mut dyn Models, + face_offset: bool, + mask: impl Fn(&Self, usize) -> u32, + mut each: impl FnMut(&Self, &mut dyn Models, usize, usize) -> [Option; 2], + ) { + for layer in [0, 31] { + self.x_meshers[layer].finish(&mut self.out); + self.x_secondary[layer].finish(&mut self.out); + self.x_meshers[layer].face_offset = face_offset; + } + for y in 0..32 { + let mut skips = [[0u32; 2]; 2]; + for z in 0..32 { + let i = y * 32 + z; + let row = mask(self, i); + for side in 0..2usize { + if row & 1 << (side * 31) == 0 { + skips[side] = skips[side].map(|n| n + 1); + continue; + } + let quads = each(self, models, side, i); + let layer = side * 31; + let [own_skips, secondary_skips] = &mut skips[side]; + for (mesher, quad, skips) in [ + (&mut self.x_meshers[layer], quads[0], own_skips), + (&mut self.x_secondary[layer], quads[1], secondary_skips), + ] { + match quad { + Some(quad) => { + mesher.skip(std::mem::take(skips), &mut self.out); + mesher.put(quad, &mut self.out); + } + None => *skips += 1, + } + } + } + } + for (side, [own_skips, secondary_skips]) in skips.into_iter().enumerate() { + let layer = side * 31; + self.x_meshers[layer].skip(own_skips, &mut self.out); + self.x_secondary[layer].skip(secondary_skips, &mut self.out); + } + } + for layer in [0, 31] { + self.x_meshers[layer].finish(&mut self.out); + self.x_secondary[layer].finish(&mut self.out); + self.x_meshers[layer].face_offset = true; + } + } + + /// `generateXOuterOpaqueGeometry`. + fn x_opaque_outer(&mut self, models: &mut dyn Models) { + self.walk_x_outer( + models, + false, + |s, i| s.opaque[i], + |s, models, side, i| { + let voxel = s.neighbour(side, i); + let (model, meta) = Self::neighbour_model(models, voxel); + let culls = model != 0 + && (meta.is_fully_opaque() || meta.face_occludes(2 << 1 | (1 - side) as u8)); + let idx = i * 32 + side * 31; + let quad = (!culls).then(|| { + apply_light( + side as u64 | s.quad(idx) & !QUAD_LIGHT | neighbour_light(voxel), + s.meta(idx), + ) + }); + [quad, None] + }, + ); + } + + /// `generateXOuterFluidGeometry`. + fn x_fluid_outer(&mut self, models: &mut dyn Models) { + self.walk_x_outer( + models, + false, + |s, i| s.fluid[i], + |s, models, side, i| { + let idx = i * 32 + side * 31; + let (quad, meta) = as_fluid(models, s.quad(idx), s.meta(idx)); + let voxel = s.neighbour(side, i); + let (mut model, next_meta) = Self::neighbour_model(models, voxel); + let mut culls = model != 0 + && (next_meta.is_fully_opaque() + || next_meta.face_occludes(2 << 1 | (1 - side) as u8)); + if model != 0 && next_meta.contains_fluid() { + model = models.fluid_model(model); + } + culls |= model != 0 + && meta.culls_same() + && u64::from(model) << QUAD_MODEL_SHIFT == quad & MODEL; + let quad = (!culls).then(|| { + apply_light( + side as u64 | quad & !QUAD_LIGHT | neighbour_light(voxel), + meta, + ) + }); + [quad, None] + }, + ); + } + + /// `generateXNonOpaqueOuterGeometry`: at x 0 and 31, the outward face + /// against the neighbouring section and the inward one against the + /// next block in (`dualMeshNonOpaqueOuterX`). At x 0 the outward face + /// is -x, the secondary mesher's; at 31 it's +x, the layer's own. + fn x_non_opaque_outer(&mut self, models: &mut dyn Models) { + self.walk_x_outer( + models, + true, + |s, i| s.non_opaque[i], + |s, models, side, i| { + let idx = i * 32 + side * 31; + let (quad, meta) = (s.quad(idx), s.meta(idx)); + let voxel = s.neighbour(side, i); + let (model, next_meta) = Self::neighbour_model(models, voxel); + let out_face = (2 << 1) ^ side as u8; + let draw_out = if model == 0 { + meta.face_exists(out_face) + } else { + should_mesh_non_opaque( + out_face, + quad, + meta, + u64::from(model) << QUAD_MODEL_SHIFT, + next_meta, + ) + }; + let light = if meta.face_uses_self_lighting(out_face) { + quad & QUAD_LIGHT + } else { + neighbour_light(voxel) + }; + let outward = + draw_out.then(|| apply_light(side as u64 | quad & !QUAD_LIGHT | light, meta)); + let in_idx = if side == 0 { idx + 1 } else { idx - 1 }; + let inward = non_opaque_face( + (2 << 1 | 1) ^ side as u8, + quad, + meta, + s.quad(in_idx), + s.meta(in_idx), + ); + if side == 0 { + [inward, outward] + } else { + [outward, inward] + } + }, + ); + } +} + +/// The set bits of a row's `mask`: each face's x and the gap of nothing +/// since the one before. +fn faces(mask: u32) -> impl Iterator { + let mut rest = mask; + let mut last = -1; + std::iter::from_fn(move || { + if rest == 0 { + return None; + } + let index = rest.trailing_zeros() as i32; + rest &= rest - 1; + let gap = (index - last - 1) as u32; + last = index; + Some((index, gap)) + }) +} + +/// One more face of nothing for every x layer `faces` has no face in. +fn add_skips(pending: &mut [u32; 32], faces: u32) { + for (index, skips) in pending.iter_mut().enumerate() { + *skips += u32::from(faces & 1 << index == 0); + } +} + +/// The index in neighbour `side`'s data of the voxel facing this section at +/// slice position `i` (`acquireNeighborData`). +fn neighbour_index(side: usize, i: usize) -> usize { + let (a, b) = (i & 31, i >> 5); + match side { + 0 => section::index(31, b, a), + 1 => section::index(0, b, a), + 2 => section::index(a, 31, b), + 3 => section::index(a, 0, b), + 4 => section::index(a, b, 31), + _ => section::index(a, b, 0), + } +} + +/// `packPartialQuadData`: the voxel's biome and light moved down a bit into +/// their quad fields, the biome kept only for biome-coloured models, the +/// light dropped for fully opaque ones (they take the neighbour's); then the +/// model and its group. +fn pack_partial(model: u16, voxel: u64, meta: Metadata) -> u64 { + let mut light_biome = (voxel & (voxel::BIOME_MASK | voxel::LIGHT_MASK)) >> 1; + if !meta.is_biome_coloured() { + light_biome &= !(0x1FF << QUAD_BIOME_SHIFT); + } + if meta.is_fully_opaque() { + light_biome &= !QUAD_LIGHT; + } + light_biome | u64::from(model) << QUAD_MODEL_SHIFT | quad_group(meta) +} + +/// `getQuadTyping`: the group in bits 1-2: translucent 0, double-sided 1, +/// else 2 for the face's own. +fn quad_group(meta: Metadata) -> u64 { + 0b111 + & (0b000_000_010_100 + >> (u64::from(meta.is_translucent()) * 6 + u64::from(meta.is_double_sided()) * 3)) +} + +/// A neighbouring section's voxel's light, in a quad's field. +fn neighbour_light(voxel: u64) -> u64 { + (voxel & voxel::LIGHT_MASK) >> 1 +} + +/// `applyQuadLight`: the block light at least the model's emission. +fn apply_light(quad: u64, meta: Metadata) -> u64 { + let light = (quad & BLOCK_LIGHT).max(meta.light_emission() << (QUAD_LIGHT_SHIFT + 4)); + quad & !BLOCK_LIGHT | light +} + +/// A block holding a fluid takes the fluid's model and group for the fluid +/// passes. +fn as_fluid(models: &mut dyn Models, quad: u64, meta: Metadata) -> (u64, Metadata) { + if !meta.contains_fluid() { + return (quad, meta); + } + let fluid = models.fluid_model(((quad & MODEL) >> QUAD_MODEL_SHIFT) as u16); + let meta = models.metadata(fluid); + ( + quad & !MODEL & !0b110 | u64::from(fluid) << QUAD_MODEL_SHIFT | quad_group(meta), + meta, + ) +} + +/// `shouldMeshNonOpaqueBlockFace`. +fn should_mesh_non_opaque( + face: u8, + quad: u64, + meta: Metadata, + next_quad: u64, + next_meta: Metadata, +) -> bool { + if (quad ^ next_quad) & MODEL == 0 && meta.culls_same() { + return false; + } + if !meta.face_exists(face) { + return false; + } + !(meta.face_can_be_occluded(face) && next_meta.face_occludes(face ^ 1)) +} + +/// `meshNonOpaqueFace`: the quad to draw for `face` against `next`, if any. +fn non_opaque_face( + face: u8, + quad: u64, + meta: Metadata, + next_quad: u64, + next_meta: Metadata, +) -> Option { + should_mesh_non_opaque(face, quad, meta, next_quad, next_meta).then(|| { + let light = if meta.face_uses_self_lighting(face) { + quad + } else { + next_quad + } & QUAD_LIGHT; + apply_light(u64::from(face & 1) | quad & !QUAD_LIGHT | light, meta) + }) +} + +fn put_or_skip(mesher: &mut Mesher, quad: Option, out: &mut Quads) { + match quad { + Some(quad) => mesher.put(quad, out), + None => mesher.skip(1, out), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Models: 1 stone (opaque), 2 glass (sees through, culls its own + /// kind), 3 water (translucent fluid), 4 a waterlogged slab-like block + /// (holds water, open on top). + struct Stub; + + const OPAQUE_FACE: u64 = 0b0111; + const CLEAR_FACE: u64 = 0b1110; + + fn faces(byte: u64) -> u64 { + (0..6).fold(0, |m, f| m | byte << (8 * f)) + } + + impl Models for Stub { + fn model(&mut self, block: u32) -> u16 { + block as u16 + } + + fn metadata(&self, model: u16) -> Metadata { + Metadata(match model { + 1 => faces(OPAQUE_FACE) | 1 << 54, + 2 => faces(CLEAR_FACE) | 32 << 48, + 3 => faces(CLEAR_FACE) | (2 | 16 | 32) << 48, + 4 => faces(OPAQUE_FACE) & !(0xFF << 8) | 0xFF << 8 | 8 << 48, + _ => 0, + }) + } + + fn fluid_model(&self, model: u16) -> u16 { + if model == 4 { 3 } else { model } + } + } + + fn key() -> SectionKey { + SectionKey { + level: 0, + x: 0, + y: 0, + z: 0, + } + } + + fn build(section: &WorldSection) -> BuiltSection { + Factory::new().build(key(), section, |_| None, &mut Stub) + } + + fn with(blocks: &[((usize, usize, usize), u32)]) -> WorldSection { + let mut section = WorldSection::unseen(); + for &((x, y, z), block) in blocks { + section.data[section::index(x, y, z)] = voxel::compose(0x0F, block, 0); + } + section + } + + fn face(quad: u64) -> u8 { + (quad & 7) as u8 + } + + fn position(quad: u64) -> (u32, u32, u32) { + ( + (quad >> 21 & 31) as u32, + (quad >> 16 & 31) as u32, + (quad >> 11 & 31) as u32, + ) + } + + fn model(quad: u64) -> u16 { + ((quad & MODEL) >> QUAD_MODEL_SHIFT) as u16 + } + + #[test] + fn a_lone_cube_draws_its_six_faces_where_it_stands() { + let built = build(&with(&[((5, 6, 7), 1)])); + assert_eq!(built.quads.len(), 6); + let mut seen: Vec = built.quads.iter().map(|&q| face(q)).collect(); + seen.sort_unstable(); + assert_eq!(seen, [0, 1, 2, 3, 4, 5]); + for &quad in &built.quads { + assert_eq!(position(quad), (5, 6, 7)); + assert_eq!(model(quad), 1); + assert_eq!(quad >> 3 & 0xFF, 0, "one block wide and high"); + assert_eq!( + quad >> QUAD_LIGHT_SHIFT & 0xFF, + 0x0F, + "lit by the air around" + ); + } + // Each face in its own group, after the empty translucent and + // double-sided ones. + assert_eq!(built.offsets, [0, 0, 0, 1, 2, 3, 4, 5]); + assert_eq!(built.aabb & 0x7FFF, 5 | 6 << 5 | 7 << 10); + } + + #[test] + fn touching_cubes_hide_their_shared_faces_and_merge() { + let pair = build(&with(&[((5, 6, 7), 1), ((6, 6, 7), 1)])); + // Their faces merge into one quad per side. + assert_eq!(pair.quads.len(), 6); + let tops: Vec = pair + .quads + .iter() + .copied() + .filter(|&q| face(q) == 1) + .collect(); + assert_eq!(tops.len(), 1); + assert_eq!(tops[0] >> 3 & 0xF, 1, "two wide"); + // A full section shows only its outside, each side in 16x16 quads. + let mut full = WorldSection::unseen(); + full.data.fill(voxel::compose(0, 1, 0)); + assert_eq!(build(&full).quads.len(), 6 * 4); + } + + #[test] + fn glass_culls_against_glass_but_not_stone() { + let glass = build(&with(&[((5, 6, 7), 2), ((6, 6, 7), 2)])); + assert_eq!(glass.quads.len(), 6); + // Beside stone, glass keeps its face (stone's hides behind glass). + let mixed = build(&with(&[((5, 6, 7), 2), ((6, 6, 7), 1)])); + assert_eq!(mixed.quads.iter().filter(|&&q| model(q) == 2).count(), 5); + assert_eq!(mixed.quads.iter().filter(|&&q| model(q) == 1).count(), 6); + } + + #[test] + fn a_waterlogged_block_draws_its_water_around_it() { + let built = build(&with(&[((5, 6, 7), 4)])); + // The water fills the block, so it shows on every open side; the + // block itself has no top face. + let water: Vec = built + .quads + .iter() + .copied() + .filter(|&q| model(q) == 3) + .collect(); + assert_eq!(water.len(), 6); + assert_eq!( + built.offsets[1], 6, + "the water quads are the translucent group" + ); + let block: Vec = built + .quads + .iter() + .copied() + .filter(|&q| model(q) == 4) + .collect(); + assert_eq!(block.len(), 5); + assert!(block.iter().all(|&q| face(q) != 1)); + } + + #[test] + fn section_borders_face_the_neighbouring_section() { + let mut stone = WorldSection::unseen(); + stone.data.fill(voxel::compose(0, 1, 0)); + let edge = with(&[((0, 6, 7), 1)]); + // A stone neighbour on -x hides the face against it. + let hidden = + Factory::new().build(key(), &edge, |k| (k.x == -1).then_some(&stone), &mut Stub); + assert_eq!(hidden.quads.len(), 5); + assert!(hidden.quads.iter().all(|&q| face(q) != 4)); + // Nothing there: drawn, lit by the unseen sky. + let open = build(&edge); + let west = open.quads.iter().find(|&&q| face(q) == 4).unwrap(); + assert_eq!(west >> QUAD_LIGHT_SHIFT & 0xFF, 0x0F); + } + + #[test] + fn empty_sections_build_nothing() { + let mut empty = WorldSection::unseen(); + empty.non_empty_children = 0b101; + let built = build(&empty); + assert!(built.quads.is_empty()); + assert_eq!(built.child_existence, 0b101); + } +} diff --git a/pomme-client/src/renderer/distant/colors.rs b/pomme-client/src/renderer/distant/colors.rs index 0964294b3..9e2852b67 100644 --- a/pomme-client/src/renderer/distant/colors.rs +++ b/pomme-client/src/renderer/distant/colors.rs @@ -1,61 +1,27 @@ -//! How each block state looks in distant terrain: whether it shows, its -//! baked model ([`super::models`]) and tint, and for a state with no model -//! the average colour of its top and side textures, tinted by biome where -//! its model is. +//! What distant terrain's models bake from, and the biome tints the shaders +//! look them up under. use std::collections::HashMap; use std::sync::Arc; use azalea_block::BlockState; -use super::column::{RunKey, Shows}; -use super::mesh::RunLook; -use super::models::ModelFactory; +use super::gpu::TINT_BIOMES; +use super::models::{Metadata, ModelFactory, Models, pack_tint}; use crate::renderer::chunk::atlas::AtlasUVMap; use crate::renderer::chunk::mesher::{ - BiomeClimate, Colormap, dry_foliage_color, foliage_color, grass_color, int_to_rgb, + BiomeClimate, Colormap, dry_foliage_color, foliage_color, grass_color, }; -use crate::world::block::render_assets::{OwnedBlockRenderView, Tint}; -use crate::world::block::{BlockStateTable, FluidKind}; +use crate::world::block::BlockStateTable; +use crate::world::block::render_assets::OwnedBlockRenderView; -/// Texture colour where a sprite is missing. -const UNKNOWN: [u8; 3] = [128, 128, 128]; - -#[derive(Clone, Copy)] -struct StateLook { - shows: Shows, - /// Fills its cell, so nothing behind it shows. - fills: bool, - top: [u8; 3], - side: [u8; 3], - /// A fixed tint's colour. - fixed_tint: Option<[f32; 3]>, - /// The biome tint, `None` for a fixed one. - tint: Tint, - /// The tint covers the sides too; a grass block's sides keep their - /// dirt. - tint_sides: bool, - /// How opaque it draws: 255 but for water. - alpha: u8, -} - -const HIDDEN: StateLook = StateLook { - shows: Shows::No, - fills: false, - top: UNKNOWN, - side: UNKNOWN, - fixed_tint: None, - tint: Tint::None, - tint_sides: false, - alpha: 255, -}; +/// Biome tint kinds in the tint buffer: grass, foliage, dry foliage, water +/// ([`super::models::ModelTint`]'s biome kinds, in order). +pub(crate) const TINT_KINDS: usize = 4; pub(crate) struct DistantColors { - /// What models bake from. render_view: OwnedBlockRenderView, uv_map: Arc, - /// By state id. - states: Vec, climates: Arc>, grass: Arc, foliage: Arc, @@ -69,71 +35,9 @@ impl DistantColors { climates: Arc>, (grass, foliage, dry_foliage): (Arc, Arc, Arc), ) -> Self { - let view = render_view.as_view(); - let table = view.block_state_table(); - let color = |name: &str| { - uv_map - .average_color(name) - .map_or(UNKNOWN, |[r, g, b, _]| [r, g, b]) - }; - let water_alpha = uv_map.average_color("water_still").map_or(255, |[.., a]| a); - let states = (0..table.state_count() as u32) - .map(|id| { - let Ok(state) = BlockState::try_from(id) else { - return HIDDEN; - }; - let data = table.block_state_data(state); - // A solid block shows as itself, waterlogged or not; a fluid - // shows where nothing solid holds it, seagrass and kelp - // as water (Distant Horizons' `FullDataToRenderDataTransformer`). - let solid = (!data.is_invisible() && data.has_collision()) - .then(|| view.get_textures(state)) - .flatten(); - let mut alpha = 255; - let mut fixed_tint = None; - let (top, side, tint, tint_sides) = match (solid, data.fluid().kind) { - (Some(faces), _) => { - fixed_tint = match faces.tint { - Tint::Constant(rgb) => Some(int_to_rgb(rgb as i32)), - Tint::Redstone => Some(data.redstone_wire_rgb()), - Tint::Stem => Some(data.stem_rgb()), - _ => None, - }; - let (tint, tint_sides) = match fixed_tint { - Some(_) => (Tint::None, true), - None => (faces.tint, faces.side_overlay.is_none()), - }; - (color(&faces.top), color(&faces.north), tint, tint_sides) - } - (None, FluidKind::Water) => { - let water = color("water_still"); - alpha = water_alpha; - (water, water, Tint::Water, true) - } - (None, FluidKind::Lava) => { - let lava = color("lava_still"); - (lava, lava, Tint::None, false) - } - (None, FluidKind::Empty) => return HIDDEN, - }; - StateLook { - shows: Shows::Yes { - tinted: tint != Tint::None, - }, - alpha, - fills: data.occludes_neighbor_faces(), - top, - side, - fixed_tint, - tint, - tint_sides, - } - }) - .collect(); Self { render_view, uv_map, - states, climates, grass, foliage, @@ -141,92 +45,73 @@ impl DistantColors { } } - fn look(&self, state: BlockState) -> StateLook { - self.states - .get(usize::from(state.id())) - .copied() - .unwrap_or(HIDDEN) - } - - pub fn shows(&self, state: BlockState) -> Shows { - self.look(state).shows - } - - pub fn fills(&self, state: BlockState) -> bool { - self.look(state).fills - } - pub fn table(&self) -> BlockStateTable { self.render_view.block_state_table() } /// Whether `models` were baked from the same textures and state ids as - /// these colours would bake them. + /// these would bake them. pub fn same_models(&self, other: &Self) -> bool { Arc::ptr_eq(&self.uv_map, &other.uv_map) - && self.render_view.block_state_table().table_slot() - == other.render_view.block_state_table().table_slot() + && self.table().table_slot() == other.table().table_slot() } - /// How `key`'s run draws: its model, baked into `models` the first time, - /// under its tint; or with none its colours. - pub fn run_look(&self, models: &mut ModelFactory, key: RunKey) -> RunLook { - let look = self.look(key.state); - let model = models.model(self.render_view.as_view(), &self.uv_map, key.state); - if model == 0 { - let ([r, g, b], [sr, sg, sb]) = self.colors(look, key.biome); - let a = look.alpha; - return RunLook { - model, - translucent: a < 255, - occludes: a == 255, - faces: [0; 6], - holes: false, - top: [r, g, b, a], - side: [sr, sg, sb, a], - }; + /// The models as the mesher asks for them, baked into `models` the + /// first time. + pub fn baking<'a>(&'a self, models: &'a mut ModelFactory) -> Baking<'a> { + Baking { + colors: self, + models, } - let [r, g, b] = multiply([255; 3], self.tint(look, key.biome).unwrap_or([1.0; 3])); - let info = models.info(model); - RunLook { - model, - translucent: info.translucent, - occludes: info.occludes, - faces: info.faces, - holes: info.holes, - top: [r, g, b, 255], - side: [r, g, b, 255], + } + + /// The tint buffer, [`TINT_KINDS`] rows of [`TINT_BIOMES`]: each biome's + /// grass, foliage, dry foliage and water colours; white for biomes + /// unknown. Without a position, the biome's base colour (no swamp or + /// dark forest noise). + pub fn tint_lut(&self) -> Vec { + let mut lut = vec![u32::MAX; TINT_KINDS * TINT_BIOMES]; + for (&biome, climate) in self.climates.iter() { + let biome = biome as usize; + if biome >= TINT_BIOMES { + continue; + } + let colours = [ + grass_color(climate, &self.grass, 0, 0), + foliage_color(climate, &self.foliage), + dry_foliage_color(climate, &self.dry_foliage), + climate.water_color, + ]; + for (kind, rgb) in colours.into_iter().enumerate() { + lut[kind * TINT_BIOMES + biome] = pack_tint(rgb); + } } + lut } +} - /// The tint over `look` in `biome`, fixed or the biome's. - fn tint(&self, look: StateLook, biome: u32) -> Option<[f32; 3]> { - let climate = self.climates.get(&biome).copied().unwrap_or_default(); - Some(match look.tint { - // Without a position, the biome's base colour (no swamp or dark - // forest noise). - Tint::Grass | Tint::DoubleGrass => grass_color(&climate, &self.grass, 0, 0), - Tint::Foliage => foliage_color(&climate, &self.foliage), - Tint::DryFoliage => dry_foliage_color(&climate, &self.dry_foliage), - Tint::Water => climate.water_color, - Tint::None | Tint::Constant(_) | Tint::Redstone | Tint::Stem => look.fixed_tint?, +/// Block states' models for the mesher. +pub(crate) struct Baking<'a> { + colors: &'a DistantColors, + models: &'a mut ModelFactory, +} + +impl Models for Baking<'_> { + fn model(&mut self, block: u32) -> u16 { + BlockState::try_from(block).map_or(0, |state| { + self.models.model( + self.colors.render_view.as_view(), + &self.colors.uv_map, + state, + ) }) } - /// A run's top and side colours, tinted for its biome. - fn colors(&self, look: StateLook, biome: u32) -> ([u8; 3], [u8; 3]) { - let Some(tint) = self.tint(look, biome) else { - return (look.top, look.side); - }; - let side = if look.tint_sides { - multiply(look.side, tint) - } else { - look.side - }; - (multiply(look.top, tint), side) + fn metadata(&self, model: u16) -> Metadata { + self.models.info(model).metadata } -} -fn multiply(rgb: [u8; 3], tint: [f32; 3]) -> [u8; 3] { - std::array::from_fn(|i| (f32::from(rgb[i]) * tint[i]).round().clamp(0.0, 255.0) as u8) + fn fluid_model(&self, model: u16) -> u16 { + self.models.info(model).fluid + } } diff --git a/pomme-client/src/renderer/distant/column.rs b/pomme-client/src/renderer/distant/column.rs deleted file mode 100644 index 92f18867b..000000000 --- a/pomme-client/src/renderer/distant/column.rs +++ /dev/null @@ -1,340 +0,0 @@ -//! Distant terrain columns: a block column, or several merged, as the -//! vertical runs it's made of, top to bottom, one per change of block or -//! biome. - -use azalea_block::BlockState; - -use super::colors::DistantColors; -use crate::world::chunk::{Chunk, ChunkLightData, SectionBlocks}; - -/// What a run is made of. `biome` is 0 for blocks whose colour doesn't -/// depend on it, so their runs merge across biomes. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub(crate) struct RunKey { - pub state: BlockState, - pub biome: u32, -} - -/// One vertical run, `top` down to `bottom` inclusive, both as offsets from -/// the dimension's `min_y`, with the light of the cell above it: a solid -/// block's own cell is dark, so its faces take the light of the air they -/// face. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct Run { - pub key: RunKey, - pub top: u16, - pub bottom: u16, - pub sky: u8, - pub block: u8, -} - -/// How a block state shows in distant terrain. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum Shows { - /// Not at all: air, and blocks too small to matter from afar (flowers, - /// torches, grass). - No, - /// As a full box; `tinted` when its colour depends on the biome. - Yes { tinted: bool }, -} - -/// The runs of local column `x`/`z` of `chunk`, top to bottom. Walks down -/// from the highest non-empty section and stops at the first run that -/// fills its cell under no sky light: that run reaches the bottom of the -/// world, since nothing under it is seen from afar. -pub(crate) fn build_column( - chunk: &Chunk, - light: Option<&ChunkLightData>, - x: u32, - z: u32, - min_y: i32, - shows: impl Fn(BlockState) -> Shows, - fills: impl Fn(BlockState) -> bool, -) -> Vec { - // Sky and block light `y` above `min_y`. - let light_at = |y: i32| { - light.map_or((15, 0), |light| { - let world_y = y + min_y; - ( - light.get_sky_light(x as i32, world_y, z as i32), - light.get_block_light(x as i32, world_y, z as i32), - ) - }) - }; - let mut runs: Vec = Vec::new(); - let mut extending = false; - for (index, section) in chunk.sections.iter().enumerate().rev() { - if section.block_count == 0 { - extending = false; - continue; - } - for local_y in (0..16).rev() { - let state = section.get_block_state(x, local_y, z); - let tinted = match shows(state) { - Shows::No => { - extending = false; - continue; - } - Shows::Yes { tinted } => tinted, - }; - let y = index as i32 * 16 + local_y as i32; - let biome = if tinted { - section.biome(x / 4, local_y / 4, z / 4) - } else { - 0 - }; - let key = RunKey { state, biome }; - if extending && let Some(run) = runs.last_mut().filter(|run| run.key == key) { - run.bottom = y as u16; - continue; - } - let (sky, block) = light_at(y + 1); - if sky == 0 && fills(state) { - // A dark cave under a roof fills in, the roof reaching down - // over it (Distant Horizons' cave culling); else this run - // reaches the bottom. - let roof = runs.last_mut().filter(|roof| { - let gap_top = i32::from(roof.bottom) - 1; - gap_top > y && fills(roof.key.state) && light_at(gap_top).0 == 0 - }); - match roof { - Some(roof) => roof.bottom = 0, - None => runs.push(Run { - key, - top: y as u16, - bottom: 0, - sky, - block, - }), - } - return runs; - } - runs.push(Run { - key, - top: y as u16, - bottom: y as u16, - sky, - block, - }); - extending = true; - } - } - runs -} - -/// Every column of `chunk`, by `z * 16 + x`. -pub(crate) fn build_chunk( - chunk: &Chunk, - light: Option<&ChunkLightData>, - min_y: i32, - colors: &DistantColors, -) -> Vec> { - let shows = |state| colors.shows(state); - let fills = |state| colors.fills(state); - (0..256) - .map(|i| build_column(chunk, light, i % 16, i / 16, min_y, shows, fills)) - .collect() -} - -/// `columns` side by side as one coarser column: each height slice between -/// the inputs' run edges takes the block most of them have there (air never -/// wins, so a hollow doesn't open a hole), with the light of those that -/// have it. -pub(crate) fn merge_columns(columns: &[&[Run]]) -> Vec { - // Edges as `[low, high)` bounds, highest first. - let mut edges: Vec = columns - .iter() - .flat_map(|runs| runs.iter().flat_map(|run| [run.top + 1, run.bottom])) - .collect(); - edges.sort_unstable_by(|a, b| b.cmp(a)); - edges.dedup(); - - let mut merged: Vec = Vec::new(); - for pair in edges.windows(2) { - let (high, low) = (pair[0], pair[1]); - let at = |runs: &&[Run]| { - runs.iter() - .find(|run| run.bottom <= low && low <= run.top) - .copied() - }; - let present: Vec = columns.iter().filter_map(at).collect(); - let Some(winner) = present - .iter() - .max_by_key(|run| { - let votes = present.iter().filter(|other| other.key == run.key).count(); - // On a tie, the first column's block. - let first = present.iter().position(|other| other.key == run.key); - (votes, std::cmp::Reverse(first)) - }) - .map(|run| run.key) - else { - continue; - }; - let (count, sky, block) = present - .iter() - .filter(|run| run.key == winner) - .fold((0u32, 0u32, 0u32), |(n, s, b), run| { - (n + 1, s + u32::from(run.sky), b + u32::from(run.block)) - }); - let sky = ((sky + count / 2) / count) as u8; - let block = ((block + count / 2) / count) as u8; - let top = high - 1; - if let Some(last) = merged.last_mut() - && last.key == winner - && last.bottom == high - && last.sky == sky - && last.block == block - { - last.bottom = low; - continue; - } - merged.push(Run { - key: winner, - top, - bottom: low, - sky, - block, - }); - } - merged -} - -#[cfg(test)] -mod tests { - use pomme_protocol::section::Section; - - use super::*; - - fn state(id: u32) -> BlockState { - BlockState::try_from(id).unwrap() - } - - fn stone() -> BlockState { - state(1) - } - - fn grass() -> BlockState { - state(2) - } - - fn dirt() -> BlockState { - state(3) - } - - fn flower() -> BlockState { - state(4) - } - - fn shows(state: BlockState) -> Shows { - if state.is_air() || state == flower() { - Shows::No - } else { - Shows::Yes { - tinted: state == grass(), - } - } - } - - fn fills(state: BlockState) -> bool { - state != flower() - } - - fn run(key: BlockState, top: u16, bottom: u16, sky: u8) -> Run { - Run { - key: RunKey { - state: key, - biome: 0, - }, - top, - bottom, - sky, - block: 0, - } - } - - /// A 4-section column (y 0..64 above `min_y`) with `blocks` set at local - /// x = z = 0. - fn column_of(blocks: &[(u32, BlockState)]) -> Chunk { - let mut chunk = Chunk::empty(4); - for &(y, block) in blocks { - let section: &mut Section = &mut chunk.sections[(y / 16) as usize]; - section - .states - .get_and_set(0, y % 16, 0, u32::from(block.id()), 16); - section.block_count += 1; - } - chunk - } - - #[test] - fn a_column_is_its_runs_top_down_without_air_or_decoration() { - let chunk = column_of(&[ - (40, flower()), - (39, grass()), - (38, dirt()), - (37, dirt()), - (30, stone()), - (29, stone()), - ]); - let runs = build_column(&chunk, None, 0, 0, -64, shows, fills); - let tops: Vec<_> = runs - .iter() - .map(|r| (r.key.state, r.top, r.bottom)) - .collect(); - assert_eq!( - tops, - [(grass(), 39, 39), (dirt(), 38, 37), (stone(), 30, 29)], - "unlit columns keep every run" - ); - // Grass is tinted, so it carries its biome; dirt doesn't. - assert_eq!(runs[1].key.biome, 0); - } - - #[test] - fn a_run_under_no_sky_reaches_the_bottom_of_the_world() { - // Sky light 0 below local y 32 (light sections 1 and 2), open sky - // above it. - let dark = || Some(crate::world::light::DataLayer::new()); - let light = ChunkLightData { - sky_sections: vec![None, dark(), dark(), None, None, None], - block_sections: vec![None; 6], - min_y: -64, - has_sky: true, - sky_top_section: Some(3), - }; - let chunk = column_of(&[(40, dirt()), (30, stone()), (29, stone()), (10, dirt())]); - let runs = build_column(&chunk, Some(&light), 0, 0, -64, shows, fills); - assert_eq!(runs, [run(dirt(), 40, 40, 15), run(stone(), 30, 0, 0)]); - - // A cave dark to its roof: the roof reaches down over it instead. - let chunk = column_of(&[(40, dirt()), (31, stone()), (20, stone()), (10, dirt())]); - let runs = build_column(&chunk, Some(&light), 0, 0, -64, shows, fills); - assert_eq!(runs, [run(dirt(), 40, 40, 15), run(stone(), 31, 0, 15)]); - } - - #[test] - fn merging_takes_the_majority_block_and_never_air() { - let hill = [run(stone(), 10, 0, 15)]; - let flat = [run(dirt(), 4, 0, 15)]; - let empty: [Run; 0] = []; - let merged = merge_columns(&[&hill, &hill, &flat, &empty]); - // Above 4 only the two hills have a block: stone, though two - // columns are air there. - assert_eq!(merged, [run(stone(), 10, 0, 15)]); - - let merged = merge_columns(&[&flat, &flat, &hill, &empty]); - assert_eq!(merged, [run(stone(), 10, 5, 15), run(dirt(), 4, 0, 15)]); - } - - #[test] - fn merged_light_averages_only_the_winners() { - let lit = [run(stone(), 3, 0, 15)]; - let dark = [run(stone(), 3, 0, 5)]; - let water = [Run { - sky: 0, - ..run(dirt(), 3, 0, 0) - }]; - let merged = merge_columns(&[&lit, &dark, &water]); - assert_eq!(merged, [run(stone(), 3, 0, 10)]); - } -} diff --git a/pomme-client/src/renderer/distant/gpu.rs b/pomme-client/src/renderer/distant/gpu.rs index f4cf29d16..9a3c63226 100644 --- a/pomme-client/src/renderer/distant/gpu.rs +++ b/pomme-client/src/renderer/distant/gpu.rs @@ -1,17 +1,23 @@ -//! Distant terrain on the GPU: section meshes in vertex arenas, the model -//! atlas they sample, the drawn-column mask that keeps them out of real -//! chunks, and the draws. +//! Distant terrain on the GPU (Voxy's `BasicSectionGeometryManager`, +//! `MDICSectionRenderer` and the node graph `NodeManager` keeps): built +//! sections' quads in arenas, the models they index, the tree walked each +//! frame to pick which to draw (`traversal_dev.comp`, on the CPU until the +//! GPU traversal lands), and the draws. -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; +use std::time::{Duration, Instant}; -use glam::DVec3; +use glam::{DVec3, Mat4, Vec3, Vec4Swizzles}; -use super::mesh::{DistantVertex, SectionMesh}; +use super::build::BuiltSection; +use super::colors::TINT_KINDS; use super::models::{ - ATLAS_COLUMNS, ATLAS_ROWS, MODEL_MIP_LEVELS, ModelTile, TILE_HEIGHT, TILE_WIDTH, + ATLAS_COLUMNS, ATLAS_ROWS, BlockModel, CAPACITY, MODEL_MIP_LEVELS, ModelTile, TILE_HEIGHT, + TILE_WIDTH, }; -use super::store::{LEVELS, SectionKey}; +use super::scan::GROUPS; +use super::section::{LEVELS, SIDE, SectionKey}; use super::worker::DistantUpdate; use crate::position::ChunkPos; use crate::renderer::camera::Camera; @@ -19,17 +25,18 @@ use crate::renderer::chunk::tlsf::Tlsf; use crate::renderer::{MAX_FRAMES_IN_FLIGHT, rhi, util}; use crate::world::chunk::{RING_SIDE, ring_slot}; -/// Vertices an arena holds: 64 MB at 16 bytes each. -const ARENA_VERTICES: u32 = 4 << 20; -/// Arenas there may be: 512 MB of vertices. +/// Quads an arena holds: 64 MB at 8 bytes each. +const ARENA_QUADS: u32 = 8 << 20; +/// Arenas there may be: 512 MB of quads. const MAX_ARENAS: usize = 8; -/// Quads one draw covers, which the shared index buffer has room for; a -/// larger section draws in several. +/// Quads the shared index buffer covers, the most a group can hold. const QUADS_PER_DRAW: u32 = 1 << 16; -const VERTEX_SIZE: u64 = size_of::() as u64; /// `distant.frag` hard-codes the ring's side. const _: () = assert!(RING_SIDE == 257); const MASK_WORDS: usize = ((RING_SIDE * RING_SIDE) as usize).div_ceil(32); +const MASK_BYTES: u64 = (MASK_WORDS * size_of::()) as u64; +const MODELS_BYTES: u64 = (CAPACITY * size_of::()) as u64; +const TINTS_BYTES: u64 = (TINT_BIOMES * TINT_KINDS * size_of::()) as u64; /// A model tile's bytes, every level. const TILE_BYTES: usize = { let mut bytes = 0; @@ -42,70 +49,122 @@ const TILE_BYTES: usize = { }; /// Model tiles uploaded a frame; more wait for the next. const TILES_PER_FRAME: usize = 256; +/// Biome tints per kind in the colour buffer ([`super::colors`]). +pub(crate) const TINT_BIOMES: usize = 512; +/// Voxy's `subDivisionSize`: a node is split while it covers more than this +/// many pixels squared. +const SUBDIVISION_SIZE: f32 = 64.0; +/// Meshes asked of the worker at once (Voxy's `TARGET_COUNT`). +const MAX_REQUESTS: usize = 4000; +/// How long a request waits before it's asked again. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +/// Voxy's far plane: 3000 chunks. +const FAR: f32 = 3000.0 * 16.0; -/// A vertex buffer, sub-allocated. +/// A quad buffer, sub-allocated, with the bind group that reads it. struct Arena { buffer: rhi::backend::Buffer, - vertices: Tlsf, + quads: Tlsf, + group: rhi::backend::BindGroup, } -impl Arena { - fn new(device: &rhi::backend::Device) -> Self { - Self { - buffer: rhi::backend::Buffer::new( - device, - "distant_vertices", - u64::from(ARENA_VERTICES) * VERTEX_SIZE, - rhi::BufferUsages::VERTEX, - rhi::MemoryLocation::Host, - ), - vertices: Tlsf::new(ARENA_VERTICES), - } +/// A section's quads in an arena (`SectionMeta`). +#[derive(Clone, Copy)] +struct Uploaded { + arena: usize, + /// Its first quad. + ptr: u32, + quads: u32, + offsets: [u32; GROUPS], + /// [`BuiltSection::aabb`]. + aabb: u32, + child_existence: u8, +} + +impl Uploaded { + /// Where group `group` starts and how many quads it has. + fn group(&self, group: usize) -> (u32, u32) { + let start = self.offsets[group]; + let end = self.offsets.get(group + 1).copied().unwrap_or(self.quads); + (self.ptr + start, end - start) + } + + /// Its quads' bounds in the section, in voxels of its level. + fn bounds(&self) -> (Vec3, Vec3) { + let field = |shift: u32| (self.aabb >> shift & 31) as f32; + let min = Vec3::new(field(0), field(5), field(10)); + let size = Vec3::new(field(15), field(20), field(25)) + 1.0; + (min, min + size) } } -/// A section's mesh in an arena, and the height it spans; no vertices for -/// a section with nothing in it. -struct Uploaded { - arena: usize, - first_vertex: u32, - vertices: u32, - /// [`super::mesh::SectionMesh::translucent_start`]. - translucent_start: u32, - min_y: u16, - max_y: u16, +/// Where the player is and how far distant terrain draws. +#[derive(Clone, PartialEq)] +pub(crate) struct View { + pub dimension: Arc, + /// The dimension's block range, so the tree spans its height. + pub min_y: i32, + pub height: i32, + /// In blocks. + pub radius: f64, } pub(crate) struct DistantBuffers { + layout: rhi::backend::BindGroupLayout, /// Grown one at a time as sections fill them. arenas: Vec, index_buffer: rhi::backend::Buffer, + /// Every section the renderer knows, empty ones too (`EMPTY_MESH`). sections: HashMap, - /// The dimension the sections are of, and its `min_y`. - dimension: Option>, - min_y: i32, + /// Sections asked of the worker, and when. + requested: HashMap, + /// Requests since the last [`Self::take_requests`]. + requests: Vec, + /// Sections dropped since the last [`Self::take_requests`], for the + /// worker to stop keeping up to date. + dropped: Vec, + /// Each section's last frame drawn, for evicting the least recent. + last_drawn: HashMap, + /// The sections to draw this frame, from the last traversal. + visible: Vec, + view: Option, /// Bit per [`ring_slot`]: a real chunk draws there. mask: rhi::backend::Buffer, mask_words: Vec, frame_seq: u64, - /// Freed vertex ranges as (arena, first vertex), by the frame from - /// which no in-flight frame can still draw them. + /// Freed quad ranges as (arena, first quad), by the frame from which no + /// in-flight frame can still draw them. pending_free: VecDeque<(u64, usize, u32)>, /// Every model's six faces and their mips, a tile each. model_atlas: rhi::backend::Texture, model_sampler: rhi::backend::Sampler, + /// Every model's face data ([`BlockModel`]). + models: rhi::backend::Buffer, + /// Biome tints by kind, then biome. + tints: rhi::backend::Buffer, /// Tiles waiting for [`Self::record_model_upload`]. pending_tiles: VecDeque, - /// Meshes waiting for the room evicting made, which frees a few frames - /// on. - waiting: Vec, tile_staging: Vec, } +/// Set 1: the arena's quads, the models, the biome tints, the model atlas +/// and its sampler, then the drawn-column mask `distant.frag` discards +/// with. +const LAYOUT: &[rhi::BindGroupLayoutEntry] = &[ + rhi::BindGroupLayoutEntry::storage(rhi::ShaderStages::VERTEX), + rhi::BindGroupLayoutEntry::storage(rhi::ShaderStages::VERTEX), + rhi::BindGroupLayoutEntry::storage(rhi::ShaderStages::VERTEX), + rhi::BindGroupLayoutEntry::texture(rhi::ShaderStages::FRAGMENT), + rhi::BindGroupLayoutEntry::sampler(rhi::ShaderStages::FRAGMENT), + rhi::BindGroupLayoutEntry::storage(rhi::ShaderStages::FRAGMENT), +]; + impl DistantBuffers { pub fn new(device: &rhi::backend::Device) -> Self { + // Voxy's `SharedIndexBuffer`: corner 1 leads both triangles, so it's + // the provoking vertex the flat attributes come from. let quad_indices: Vec = (0..QUADS_PER_DRAW) - .flat_map(|quad| [0, 1, 2, 2, 3, 0].map(|i| quad * 4 + i)) + .flat_map(|quad| [1, 2, 0, 1, 3, 2].map(|i| quad * 4 + i)) .collect(); let model_atlas = rhi::backend::Texture::new( device, @@ -132,8 +191,18 @@ impl DistantBuffers { rhi::TextureUse::ShaderRead, )]); }); - Self { - arenas: vec![Arena::new(device)], + let storage = |name, bytes| { + rhi::backend::Buffer::new( + device, + name, + bytes, + rhi::BufferUsages::STORAGE, + rhi::MemoryLocation::Host, + ) + }; + let mut buffers = Self { + layout: rhi::backend::create_bind_group_layout(device, LAYOUT), + arenas: Vec::new(), index_buffer: rhi::backend::Buffer::with_data( device, "distant_quad_indices", @@ -141,15 +210,13 @@ impl DistantBuffers { rhi::BufferUsages::INDEX, ), sections: HashMap::new(), - dimension: None, - min_y: 0, - mask: rhi::backend::Buffer::new( - device, - "distant_mask", - (MASK_WORDS * size_of::()) as u64, - rhi::BufferUsages::STORAGE, - rhi::MemoryLocation::Host, - ), + requested: HashMap::new(), + requests: Vec::new(), + dropped: Vec::new(), + last_drawn: HashMap::new(), + visible: Vec::new(), + view: None, + mask: storage("distant_mask", MASK_BYTES), mask_words: vec![0; MASK_WORDS], frame_seq: 0, pending_free: VecDeque::new(), @@ -163,23 +230,66 @@ impl DistantBuffers { ..rhi::SamplerDesc::NEAREST }, ), + models: storage("distant_block_models", MODELS_BYTES), + tints: storage("distant_biome_tints", TINTS_BYTES), pending_tiles: VecDeque::new(), - waiting: Vec::new(), tile_staging: util::create_per_frame_host_buffers( device, (TILE_BYTES * TILES_PER_FRAME) as u64, rhi::BufferUsages::COPY_SRC, "distant_model_staging", ), - } + }; + buffers.add_arena(device); + buffers } - pub fn model_atlas(&self) -> rhi::backend::TextureHandle { - self.model_atlas.handle() + pub fn layout(&self) -> rhi::backend::BindGroupLayout { + self.layout } - pub fn model_sampler(&self) -> rhi::backend::Sampler { - self.model_sampler + fn add_arena(&mut self, device: &rhi::backend::Device) { + let buffer = rhi::backend::Buffer::new( + device, + "distant_quads", + u64::from(ARENA_QUADS) * 8, + rhi::BufferUsages::STORAGE, + rhi::MemoryLocation::Host, + ); + let group = device.create_bind_group( + self.layout, + &[ + ( + 0, + rhi::backend::BindingResource::storage(&buffer, u64::from(ARENA_QUADS) * 8), + ), + ( + 1, + rhi::backend::BindingResource::storage(&self.models, MODELS_BYTES), + ), + ( + 2, + rhi::backend::BindingResource::storage(&self.tints, TINTS_BYTES), + ), + ( + 3, + rhi::backend::BindingResource::Texture(self.model_atlas.handle()), + ), + ( + 4, + rhi::backend::BindingResource::Sampler(self.model_sampler), + ), + ( + 5, + rhi::backend::BindingResource::storage(&self.mask, MASK_BYTES), + ), + ], + ); + self.arenas.push(Arena { + buffer, + quads: Tlsf::new(ARENA_QUADS), + group, + }); } /// Writes the waiting model tiles into the atlas, as many as fit this @@ -226,34 +336,27 @@ impl DistantBuffers { )]); } - pub fn mask(&self) -> &rhi::backend::Buffer { - &self.mask - } - - pub fn mask_bytes() -> u64 { - (MASK_WORDS * size_of::()) as u64 - } - /// Reclaims the ranges no in-flight frame reads any more. Call once a /// frame, after its fence wait. pub fn begin_frame(&mut self) { self.frame_seq += 1; - while let Some(&(at, arena, first_vertex)) = self.pending_free.front() + while let Some(&(at, arena, ptr)) = self.pending_free.front() && at <= self.frame_seq { self.pending_free.pop_front(); - self.arenas[arena].vertices.free(first_vertex); + self.arenas[arena].quads.free(ptr); } } + /// Forgets `key`'s section, freeing its quads once no frame draws them. fn retire(&mut self, key: SectionKey) { if let Some(old) = self.sections.remove(&key) - && old.vertices > 0 + && old.quads > 0 { let at = self.frame_seq + MAX_FRAMES_IN_FLIGHT as u64; - self.pending_free - .push_back((at, old.arena, old.first_vertex)); + self.pending_free.push_back((at, old.arena, old.ptr)); } + self.last_drawn.remove(&key); } /// Drops every section, as on a dimension change. The mask stays: it @@ -263,8 +366,11 @@ impl DistantBuffers { for key in keys { self.retire(key); } - self.waiting.clear(); - self.dimension = None; + self.requested.clear(); + self.requests.clear(); + self.dropped.clear(); + self.visible.clear(); + self.view = None; } pub fn clear_mask(&mut self) { @@ -285,267 +391,424 @@ impl DistantBuffers { .write_at(word * size_of::(), &value.to_ne_bytes()); } - /// Room for `count` vertices: in an arena with it, else in a new one - /// while there may be more. - fn alloc(&mut self, device: &rhi::backend::Device, count: u32) -> Option<(usize, u32)> { - if let Some(found) = self - .arenas - .iter_mut() - .enumerate() - .find_map(|(i, arena)| Some((i, arena.vertices.alloc(count)?))) - { - return Some(found); + /// Where the player is and how far to draw; `None` draws nothing. + pub fn set_view(&mut self, view: Option) { + if self.view != view { + if view.as_ref().is_none_or(|v| { + self.view + .as_ref() + .is_some_and(|last| last.dimension != v.dimension) + }) { + self.clear(); + } + self.view = view; } - if self.arenas.len() == MAX_ARENAS { - return None; + } + + /// Room for `count` quads: in an arena with it, in a new one while + /// there may be more, else by dropping the sections drawn least + /// recently, whose parents draw in their place. + fn alloc(&mut self, device: &rhi::backend::Device, count: u32) -> Option<(usize, u32)> { + loop { + if let Some(found) = self + .arenas + .iter_mut() + .enumerate() + .find_map(|(i, arena)| Some((i, arena.quads.alloc(count)?))) + { + return Some(found); + } + if self.arenas.len() < MAX_ARENAS { + self.add_arena(device); + continue; + } + // Voxy's `NodeCleaner`: the least recently drawn go, but never a + // top-level section, which nothing stands in for. + let mut stale: Vec<(u64, SectionKey)> = self + .sections + .iter() + .filter(|(key, section)| section.quads > 0 && key.level != LEVELS - 1) + .map(|(key, _)| (self.last_drawn.get(key).copied().unwrap_or(0), *key)) + .collect(); + if stale.is_empty() { + return None; + } + stale.sort_unstable_by_key(|&(frame, _)| frame); + let mut freed = 0; + for &(_, key) in &stale { + if freed >= count { + break; + } + freed += self.sections[&key].quads; + self.retire(key); + self.dropped.push(key); + } + // Freed ranges come back a few frames on; the mesh waits. + if freed < count + || self + .pending_free + .iter() + .any(|&(at, ..)| at > self.frame_seq) + { + return None; + } } - let mut arena = Arena::new(device); - let first_vertex = arena.vertices.alloc(count)?; - self.arenas.push(arena); - Some((self.arenas.len() - 1, first_vertex)) } - /// Takes the worker's updates, then the meshes waiting for room. With - /// the arenas full, a mesh makes room by evicting the finer sections - /// farther from `eye` than it, farthest first, their parents drawing in - /// their place; a mesh with none farther is dropped. Returns the - /// sections evicted or dropped, for the worker to mesh again once - /// nearer. + /// Takes the worker's updates. A section with no room yet is dropped + /// and asked for again once there is. pub fn upload( &mut self, device: &rhi::backend::Device, updates: impl IntoIterator, - eye: DVec3, - ) -> Vec { - let mut meshes = std::mem::take(&mut self.waiting); + ) { for update in updates { - if self.dimension.as_ref() != Some(&update.dimension) { - self.clear(); - meshes.clear(); - self.dimension = Some(Arc::clone(&update.dimension)); + if self + .view + .as_ref() + .is_none_or(|view| view.dimension != update.dimension) + { + continue; } - self.min_y = update.min_y; - self.pending_tiles.extend(update.tiles); - for key in update.removed { - self.retire(key); - meshes.retain(|mesh| mesh.key != key); + if let Some(tints) = update.tints { + self.tints.write_at(0, bytemuck::cast_slice(&tints)); } - for mesh in update.meshes { - meshes.retain(|waiting| waiting.key != mesh.key); - meshes.push(mesh); + for tile in update.tiles { + self.models.write_at( + usize::from(tile.id) * size_of::(), + bytemuck::bytes_of(&tile.model), + ); + self.pending_tiles.push_back(tile); } - } - let mut gone = Vec::new(); - for mesh in meshes { - let Err(mesh) = self.place(device, mesh) else { - continue; - }; - let distance = mesh.key.distance(eye.x, eye.z); - if self.evict_beyond(distance, mesh.vertices.len() as u32, eye, &mut gone) { - self.waiting.push(mesh); - } else { - gone.push(mesh.key); + for section in update.sections { + self.requested.remove(§ion.key); + self.place(device, section); } } - gone } - /// Puts `mesh` in an arena in place of its section's last; gives it - /// back without room. - fn place( - &mut self, - device: &rhi::backend::Device, - mesh: SectionMesh, - ) -> Result<(), SectionMesh> { - let count = mesh.vertices.len() as u32; - let (arena, first_vertex) = if count == 0 { + /// Puts `section` in an arena in place of its last. + fn place(&mut self, device: &rhi::backend::Device, section: BuiltSection) { + let count = section.quads.len() as u32; + let (arena, ptr) = if count == 0 { (0, 0) } else { - let Some((arena, first_vertex)) = self.alloc(device, count) else { - return Err(mesh); + let Some((arena, ptr)) = self.alloc(device, count) else { + return; }; - self.arenas[arena].buffer.write_at( - (u64::from(first_vertex) * VERTEX_SIZE) as usize, - bytemuck::cast_slice(&mesh.vertices), - ); - (arena, first_vertex) + self.arenas[arena] + .buffer + .write_at(ptr as usize * 8, bytemuck::cast_slice(§ion.quads)); + (arena, ptr) }; - self.retire(mesh.key); + self.retire(section.key); self.sections.insert( - mesh.key, + section.key, Uploaded { arena, - first_vertex, - vertices: count, - translucent_start: mesh.translucent_start, - min_y: mesh.min_y, - max_y: mesh.max_y, + ptr, + quads: count, + offsets: section.offsets, + aabb: section.aabb, + child_existence: section.child_existence, }, ); - Ok(()) } - /// Retires sections farther from `eye` than `distance` whose parent is - /// uploaded to draw instead, farthest first, until they held `count` - /// vertices; whether there were any. - fn evict_beyond( - &mut self, - distance: f64, - count: u32, - eye: DVec3, - gone: &mut Vec, - ) -> bool { - let mut far: Vec<(f64, SectionKey)> = self - .sections - .iter() - .filter(|(key, section)| { - section.vertices > 0 - && key - .parent() - .is_some_and(|parent| self.sections.contains_key(&parent)) - }) - .map(|(key, _)| (key.distance(eye.x, eye.z), *key)) - .filter(|&(far, _)| far > distance) - .collect(); - far.sort_by(|a, b| b.0.total_cmp(&a.0)); - let mut freed = 0; - for &(_, key) in &far { - if freed >= count { - break; - } - freed += self.sections[&key].vertices; - self.retire(key); - gone.push(key); + /// The sections asked for since last time, and those dropped. + pub fn take_requests(&mut self) -> (Vec, Vec) { + ( + std::mem::take(&mut self.requests), + std::mem::take(&mut self.dropped), + ) + } + + fn request(&mut self, key: SectionKey, now: Instant) { + if self.requested.len() >= MAX_REQUESTS { + return; + } + let stale = self + .requested + .get(&key) + .is_none_or(|&at| now.duration_since(at) > REQUEST_TIMEOUT); + if stale { + self.requested.insert(key, now); + self.requests.push(key); } - !far.is_empty() + } + + /// Whether every child `key` has is known, so the traversal can step + /// down into them (Voxy's child request finishing). + fn children_ready(&self, key: SectionKey) -> bool { + self.sections.get(&key).is_some_and(|section| { + children(key, section.child_existence).all(|child| self.sections.contains_key(&child)) + }) } /// Whether the finest section holding block column `x`/`z` is uploaded - /// with something in it. + /// with something in it, at any height. #[cfg(feature = "test-harness")] pub fn has_column(&self, x: i32, z: i32) -> bool { - let width = super::store::SECTION_COLUMNS; - let key = SectionKey { - level: 0, - x: x.div_euclid(width), - z: z.div_euclid(width), - }; - self.sections - .get(&key) - .is_some_and(|section| section.vertices > 0) + self.sections.iter().any(|(key, section)| { + key.level == 0 + && key.x == x.div_euclid(SIDE as i32) + && key.z == z.div_euclid(SIDE as i32) + && section.quads > 0 + }) } - /// The sections to draw from `eye` out to `radius` blocks: each at the - /// finest level its distance allows, or its parent's until all four - /// children in range are uploaded (Distant Horizons' - /// `LodQuadTree.onDetailLevelTooLow`). - fn visible(&self, eye: DVec3, radius: f64) -> Vec { - let mut out = Vec::new(); - let mut stack: Vec = self + /// `traversal_dev.comp`: walks the tree from the top-level sections + /// around `eye`, down into every section that covers more than + /// [`SUBDIVISION_SIZE`]² pixels and whose children are all known, asking + /// the worker for what's missing; collects what to draw into + /// `visible` and drops what the ring left behind. + pub fn traverse(&mut self, camera: &Camera, eye: DVec3, viewport: [u32; 2]) { + self.visible.clear(); + let Some(view) = self.view.clone() else { + return; + }; + let now = Instant::now(); + let view_proj = camera.distant_view_projection(FAR); + let planes = Camera::planes_from_view_projection(view_proj); + let min_sss = SUBDIVISION_SIZE * SUBDIVISION_SIZE / (viewport[0] * viewport[1]) as f32; + let render_distance = view.radius * view.radius; + + // `RenderDistanceTracker`: a ring of top-level sections around the + // camera, over the dimension's height. + let top = LEVELS - 1; + let top_width = (SIDE as i32) << top; + let ring = (view.radius / f64::from(top_width)).ceil() as i32 + 1; + let (cx, cz) = ( + (eye.x.floor() as i32).div_euclid(top_width), + (eye.z.floor() as i32).div_euclid(top_width), + ); + let (min_sec, max_sec) = ( + view.min_y.div_euclid(top_width), + (view.min_y + view.height - 1).div_euclid(top_width), + ); + let mut queue: VecDeque = VecDeque::new(); + let mut roots = HashSet::new(); + for x in cx - ring..=cx + ring { + for z in cz - ring..=cz + ring { + for y in min_sec..=max_sec { + let key = SectionKey { + level: top, + x, + y, + z, + }; + roots.insert(key); + queue.push_back(key); + } + } + } + let gone: Vec = self .sections .keys() .copied() - .filter(|key| key.level == LEVELS - 1) + .filter(|key| !roots.contains(&key.ancestor(top))) .collect(); - while let Some(key) = stack.pop() { - if key.distance(eye.x, eye.z) > radius { + for key in gone { + self.retire(key); + self.dropped.push(key); + } + self.requested + .retain(|key, _| roots.contains(&key.ancestor(top))); + + while let Some(key) = queue.pop_front() { + let min = origin(&key) - eye; + let size = f64::from(key.width()); + let max = min + size; + // `isWithinRenderDistance`: the nearest point's distance along + // x/z. + let near = |lo: f64, hi: f64| lo.max(0.0) + hi.min(0.0); + let (nx, nz) = (near(min.x, max.x), near(min.z, max.z)); + if nx * nx + nz * nz > render_distance { continue; } - let ready = key.children().filter(|children| { - key.splits(eye.x, eye.z) - && children.iter().all(|child| { - self.sections.contains_key(child) || child.distance(eye.x, eye.z) > radius - }) - }); - match ready { - Some(children) => stack.extend(children), - None => out.push(key), + if !in_frustum(&planes, min.as_vec3(), max.as_vec3()) { + continue; } + let has_mesh = self.sections.contains_key(&key); + if key.level != 0 && screen_size(view_proj, min.as_vec3(), size as f32) > min_sss { + if self.children_ready(key) { + let existence = self.sections[&key].child_existence; + queue.extend(children(key, existence)); + } else { + match self + .sections + .get(&key) + .map(|section| section.child_existence) + { + Some(existence) => { + for child in children(key, existence) { + if !self.sections.contains_key(&child) { + self.request(child, now); + } + } + } + None => self.request(key, now), + } + // Draw this one meanwhile, if it's wholly within reach + // (`furthestPointToCamera`, with Voxy's buffer room). + let far = |lo: f64, hi: f64| { + if lo > 0.0 { + hi + } else if hi < 0.0 { + lo + } else { + lo.abs().max(hi) + } + }; + let (fx, fz) = (far(min.x, max.x), far(min.z, max.z)); + if has_mesh && fx * fx + fz * fz + 16.0 * 16.0 * 16.0 <= render_distance { + self.visible.push(key); + } + } + } else if has_mesh { + self.visible.push(key); + } else { + self.request(key, now); + } + } + for key in &self.visible { + self.last_drawn.insert(*key, self.frame_seq); } - out } - /// Draws the opaque or see-through faces of the sections in view out to - /// `radius` blocks. The caller has bound the matching distant pipeline. + /// Draws the opaque or see-through groups of the visible sections + /// (`cmdgen.comp`, `buildtranslucents.comp`): face groups turned away + /// from the camera are left out; translucent sections go far to near. + /// The caller has bound the matching distant pipeline. pub fn draw( &self, pass: &rhi::backend::RenderPassEncoder, camera: &Camera, eye: DVec3, - radius: f64, translucent: bool, ) { - if self.sections.is_empty() { + if self.visible.is_empty() { return; } - // Past the radius, so the edge fogs out before it clips. - let view_proj = camera.distant_view_projection((radius * 1.5) as f32); - let anchor = camera.anchor(); + let view_proj = camera.distant_view_projection(FAR); let planes = Camera::planes_from_view_projection(view_proj); + let anchor = camera.anchor(); pass.set_push_constants( rhi::ShaderStages::VERTEX, 0, bytemuck::cast_slice(&view_proj.to_cols_array()), ); pass.set_index_buffer(self.index_buffer.handle()); - let mut visible = self.visible(eye, radius); - visible.sort_unstable_by_key(|key| self.sections[key].arena); + let camera_section = + [eye.x, eye.y, eye.z].map(|c| (c.floor() as i32).div_euclid(SIDE as i32)); + let relative = |key: &SectionKey| { + [key.x, key.y, key.z] + .into_iter() + .zip(camera_section) + .map(|(c, cam)| c - (cam >> key.level)) + .collect::>() + }; + // Sections whose quads all lie outside the view go, as Voxy's raster + // cull of their bounds drops them until the GPU traversal lands. + let mut order: Vec<&SectionKey> = self + .visible + .iter() + .filter(|key| { + let section = &self.sections[key]; + if section.quads == 0 || (translucent && section.group(0).1 == 0) { + return false; + } + let corner = (origin(key) - eye).as_vec3(); + let (min, max) = section.bounds(); + let scale = (1 << key.level) as f32; + in_frustum(&planes, corner + min * scale, corner + max * scale) + }) + .collect(); + if translucent { + order.sort_by_key(|key| { + let rel = relative(key); + std::cmp::Reverse(rel.iter().map(|c| c.abs()).sum::() << key.level) + }); + } else { + order.sort_unstable_by_key(|key| self.sections[key].arena); + } let mut bound = None; - for key in visible { - let section = &self.sections[&key]; - let (first, end) = if translucent { - (section.translucent_start, section.vertices) - } else { - (0, section.translucent_start) - }; - if first == end { - continue; - } - let (ox, oz) = key.origin(); - let origin = DVec3::new(f64::from(ox), f64::from(self.min_y), f64::from(oz)); - let width = f64::from(key.width()); - let min = origin + DVec3::new(0.0, f64::from(section.min_y), 0.0) - eye; - let max = origin + DVec3::new(width, f64::from(section.max_y), width) - eye; - if !in_frustum(&planes, min.as_vec3(), max.as_vec3()) { - continue; - } + for key in order { + let section = &self.sections[key]; if bound != Some(section.arena) { - pass.set_vertex_buffer(0, self.arenas[section.arena].buffer.handle()); + pass.set_bind_group(1, self.arenas[section.arena].group); bound = Some(section.arena); } - let rel = (origin - anchor).as_vec3(); + let origin = (origin(key) - anchor).as_vec3(); pass.set_push_constants( rhi::ShaderStages::VERTEX, 64, - bytemuck::cast_slice(&[rel.x, rel.y, rel.z, key.column_width() as f32]), + bytemuck::cast_slice(&[origin.x, origin.y, origin.z, (1 << key.level) as f32]), ); - let quads = (end - first) / 4; - for first_quad in (0..quads).step_by(QUADS_PER_DRAW as usize) { - let count = (quads - first_quad).min(QUADS_PER_DRAW); - pass.draw_indexed( - count * 6, - 1, - 0, - (section.first_vertex + first + first_quad * 4) as i32, - 0, - ); + let groups: Vec = if translucent { + vec![0] + } else { + let rel = relative(key); + let facing = [ + rel[1] > -1, + rel[1] < 1, + rel[2] > -1, + rel[2] < 1, + rel[0] > -1, + rel[0] < 1, + ]; + std::iter::once(1) + .chain((0..6).filter(|&face| facing[face]).map(|face| face + 2)) + .collect() + }; + for group in groups { + let (first, count) = section.group(group); + if count == 0 { + continue; + } + pass.draw_indexed(count * 6, 1, 0, (first * 4) as i32, 0); } } } pub fn destroy(&mut self, device: &rhi::backend::Device) { for arena in self.arenas.drain(..) { + arena.group.destroy(device); arena.buffer.destroy(device); } - for buffer in [&mut self.index_buffer, &mut self.mask] { + for buffer in [ + &mut self.index_buffer, + &mut self.mask, + &mut self.models, + &mut self.tints, + ] { std::mem::take(buffer).destroy(device); } util::destroy_buffers(device, &mut self.tile_staging); self.model_sampler.destroy(device); std::mem::take(&mut self.model_atlas).destroy(device); + self.layout.destroy(device); } } +/// The block position of `key`'s corner. +fn origin(key: &SectionKey) -> DVec3 { + let [x, y, z] = key.origin(); + DVec3::new(f64::from(x), f64::from(y), f64::from(z)) +} + +/// `key`'s children that `existence` has something in. +fn children(key: SectionKey, existence: u8) -> impl Iterator { + (0..8u8) + .filter(move |&i| existence & 1 << i != 0) + .map(move |i| SectionKey { + level: key.level - 1, + x: key.x * 2 + i32::from(i & 1), + z: key.z * 2 + i32::from(i >> 1 & 1), + y: key.y * 2 + i32::from(i >> 2), + }) +} + /// Every level of the model atlas. const MODEL_ATLAS_RANGE: rhi::TextureRange = rhi::TextureRange { mips: MODEL_MIP_LEVELS, @@ -567,7 +830,7 @@ fn atlas_transition( /// Whether the box between `min` and `max` (eye-relative) is at least /// partly inside `planes`. -fn in_frustum(planes: &[[f32; 4]; 6], min: glam::Vec3, max: glam::Vec3) -> bool { +fn in_frustum(planes: &[[f32; 4]; 6], min: Vec3, max: Vec3) -> bool { planes.iter().all(|p| { let corner = |n: f32, lo: f32, hi: f32| if n >= 0.0 { hi } else { lo }; p[0] * corner(p[0], min.x, max.x) @@ -577,3 +840,30 @@ fn in_frustum(planes: &[[f32; 4]; 6], min: glam::Vec3, max: glam::Vec3) -> bool >= 0.0 }) } + +/// `screenspace.glsl`'s screen size: half the summed areas of the cube's +/// projected faces, in units of the viewport. +fn screen_size(view_proj: Mat4, min: Vec3, size: f32) -> f32 { + let project = |p: Vec3| { + let clip = view_proj * p.extend(1.0); + // NDC to the viewport's 0..1. + (clip.xy() / clip.w) * 0.5 + 0.5 + }; + let p = |dx: f32, dy: f32, dz: f32| project(min + Vec3::new(dx, dy, dz) * size); + let cross = |a: glam::Vec2, b: glam::Vec2| (a.x * b.y - b.x * a.y).abs(); + let (p000, p100, p010, p001) = ( + p(0.0, 0.0, 0.0), + p(1.0, 0.0, 0.0), + p(0.0, 1.0, 0.0), + p(0.0, 0.0, 1.0), + ); + let (p111, p011, p101, p110) = ( + p(1.0, 1.0, 1.0), + p(0.0, 1.0, 1.0), + p(1.0, 0.0, 1.0), + p(1.0, 1.0, 0.0), + ); + let (a, b, c) = (p100 - p000, p010 - p000, p001 - p000); + let (d, e, f) = (p011 - p111, p101 - p111, p110 - p111); + 0.5 * (cross(a, b) + cross(a, c) + cross(c, b) + cross(d, e) + cross(d, f) + cross(f, e)) +} diff --git a/pomme-client/src/renderer/distant/mesh.rs b/pomme-client/src/renderer/distant/mesh.rs deleted file mode 100644 index ec6e8dc1e..000000000 --- a/pomme-client/src/renderer/distant/mesh.rs +++ /dev/null @@ -1,625 +0,0 @@ -//! Distant terrain sections as geometry: each run a box, faces another run -//! covers left out. - -use std::collections::HashMap; - -use super::column::{Run, RunKey}; -use super::models::{FACE_DISCARD, FACE_DISCARD_WHEN_MERGED, FACE_EMPTY, FACE_TRANSLUCENT}; -use super::store::{SECTION_COLUMNS, Section, SectionKey}; - -/// One corner of a quad; four in a row make a quad, drawn with a shared -/// `0, 1, 2, 2, 3, 0` index pattern. -#[repr(C)] -#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] -pub(crate) struct DistantVertex { - /// Section-local block `x` in the low 16 bits, `z` in the high. - pub xz: u32, - /// Blocks above the dimension's `min_y`. - pub y: u16, - /// Sky light in the high nibble, block light in the low. - pub light: u8, - /// Which way the face points ([`Face`]) in the low 3 bits, so the - /// shaders can tell whose column it is; then which way the corner moves - /// out along `x` (bits 3-4) and `z` (5-6): 1 for positive, 3 negative; - /// bit 7 for a seam ([`mesh_section`]). - pub face: u8, - /// The tint over the model's texture, or with no model the run's own - /// colour. - pub color: [u8; 4], - /// [`RunLook::model`]. - pub model: u16, - /// The face's [`super::models`] flags, discarding resolved for the - /// quad's size. - pub flags: u8, - pub _pad: u8, -} - -/// How a run draws. -#[derive(Clone, Copy)] -pub(crate) struct RunLook { - /// Its baked model ([`super::models`]); 0 for none, drawn in its colours - /// alone. - pub model: u16, - /// Blended over what's behind it: water. - pub translucent: bool, - /// Hides the faces of runs it touches. - pub occludes: bool, - /// The model's face flags, by Voxy face index. - pub faces: [u8; 6], - /// [`super::models::ModelInfo::holes`]. - pub holes: bool, - /// The top's and bottom's colour, then the sides'. - pub top: [u8; 4], - pub side: [u8; 4], -} - -impl RunLook { - /// Its texture's holes cut out, as vanilla draws leaves, so it hides - /// nothing behind it. - fn cut_out(self) -> Self { - Self { - occludes: false, - faces: self.faces.map(|flags| flags | FACE_DISCARD), - ..self - } - } - - /// What `face` draws with; `None` where the model draws nothing. - fn paint(&self, face: Face) -> Option { - let flags = self.faces[face.voxy_index()]; - (flags & FACE_EMPTY == 0).then_some(Paint { - model: self.model, - flags, - color: match face { - Face::Up | Face::Down => self.top, - _ => self.side, - }, - translucent: self.translucent, - }) - } -} - -/// What a face draws with: faces alike in it (and in light) merge. -#[derive(Clone, Copy, PartialEq, Eq, Hash)] -struct Paint { - model: u16, - flags: u8, - color: [u8; 4], - translucent: bool, -} - -/// A face's direction, as `distant.vert` reads it. -#[derive(Clone, Copy, PartialEq, Eq, Hash)] -#[repr(u8)] -enum Face { - Up, - Down, - North, - South, - West, - East, -} - -impl Face { - /// Its index in a model's faces - /// ([`crate::world::block::model::Direction::VALUES`]). - fn voxy_index(self) -> usize { - match self { - Self::Down => 0, - Self::Up => 1, - Self::North => 2, - Self::South => 3, - Self::West => 4, - Self::East => 5, - } - } -} - -/// The sides, with the offset of the column each faces. -const SIDES: [(Face, i32, i32); 4] = [ - (Face::North, 0, -1), - (Face::South, 0, 1), - (Face::West, -1, 0), - (Face::East, 1, 0), -]; - -/// A section's geometry and the height it spans, in blocks above `min_y`. -pub(crate) struct SectionMesh { - pub key: SectionKey, - /// Opaque faces, then see-through ones from `translucent_start`. - pub vertices: Vec, - pub translucent_start: u32, - pub min_y: u16, - pub max_y: u16, -} - -impl SectionMesh { - /// A section with nothing seen in it. - pub fn empty(key: SectionKey) -> Self { - Self { - key, - vertices: Vec::new(), - translucent_start: 0, - min_y: 0, - max_y: 0, - } - } -} - -/// Top or bottom faces alike in height, paint and light, as a bit per -/// column: bit `x` of row `z`. -type FlatFaces = HashMap<(Face, u32, Paint, u8), [u64; SECTION_COLUMNS as usize]>; - -/// Seams alike in span, paint and light along one chunk border, as a bit -/// per column along it: by face, the border's column, top and bottom, then -/// the rest. -type Seams = HashMap<(Face, u32, u32, u32, Paint, u8), u64>; - -/// `look` tells how a run draws. Tops and bottoms alike merge into -/// rectangles; sides are a quad per uncovered stretch. -/// -/// See-through runs, water, hide nothing behind them, so the sea floor -/// keeps its top and its sides beside water. They draw no side facing -/// nothing, which at a section's border would be a sheet in the middle of -/// the sea. Instead they get seams, sides along every chunk border, which -/// `distant.frag` keeps only where they face a drawn chunk: where the real -/// water ends, which mesh time can't know, looking on through its edge -/// still passes through water. -pub(crate) fn mesh_section( - key: SectionKey, - section: &Section, - mut look: impl FnMut(RunKey) -> RunLook, -) -> SectionMesh { - let w = key.column_width() as u32; - let mut looks = HashMap::new(); - // Holes show at full detail, beside real chunks; coarser, the mips - // already blend them away, and staying solid keeps forests cheap. - let mut look_of = |run: Run| { - *looks.entry(run.key).or_insert_with(|| { - let look = look(run.key); - if key.level == 0 && look.holes { - look.cut_out() - } else { - look - } - }) - }; - // Opaque quads, then see-through ones. - let mut quads = [Vec::new(), Vec::new()]; - let mut flat = FlatFaces::new(); - let mut seams = Seams::new(); - let (mut min_y, mut max_y) = (u16::MAX, 0); - for z in 0..SECTION_COLUMNS { - for x in 0..SECTION_COLUMNS { - let runs: Vec = section.column(x, z).collect(); - if runs.is_empty() { - continue; - } - let run_looks: Vec = runs.iter().map(|&run| look_of(run)).collect(); - let neighbours: [Vec<(Run, RunLook)>; 4] = SIDES.map(|(_, dx, dz)| { - section - .column(x + dx, z + dz) - .map(|run| (run, look_of(run))) - .collect() - }); - let (x0, z0) = (x as u32 * w, z as u32 * w); - let (x1, z1) = (x0 + w, z0 + w); - for (i, (run, look)) in runs.iter().zip(&run_looks).enumerate() { - let see_through = look.translucent; - let light = run.sky << 4 | run.block; - let (bottom, top) = (u32::from(run.bottom), u32::from(run.top) + 1); - min_y = min_y.min(run.bottom); - max_y = max_y.max(run.top + 1); - let mut flat_face = |face: Face, y| { - if let Some(paint) = look.paint(face) { - flat.entry((face, y, paint, light)) - .or_insert([0; SECTION_COLUMNS as usize])[z as usize] |= 1 << x; - } - }; - // A face against a run hides unless only the run is see-through. - let hidden_by = |other: &RunLook| other.occludes || see_through; - let above_hides = - i > 0 && runs[i - 1].bottom == run.top + 1 && hidden_by(&run_looks[i - 1]); - if !above_hides { - flat_face(Face::Up, top); - } - let below_hides = runs.get(i + 1).is_some_and(|below| { - below.top + 1 == run.bottom && hidden_by(&run_looks[i + 1]) - }); - if run.bottom > 0 && !below_hides { - flat_face(Face::Down, bottom); - } - if see_through { - let (x, z) = (x as u32, z as u32); - for (face, on_border, line, along) in [ - (Face::North, z0 % 16 == 0, z, x), - (Face::South, z1 % 16 == 0, z, x), - (Face::West, x0 % 16 == 0, x, z), - (Face::East, x1 % 16 == 0, x, z), - ] { - if on_border && let Some(paint) = look.paint(face) { - *seams - .entry((face, line, top, bottom, paint, light)) - .or_default() |= 1 << along; - } - } - } - for (&(face, ..), neighbour) in SIDES.iter().zip(&neighbours) { - let Some(paint) = look.paint(face) else { - continue; - }; - // Water beside an opaque run doesn't hide it. - let covering = neighbour - .iter() - .filter(|(_, other)| see_through || other.occludes) - .map(|(run, _)| run); - for (low, high, sky) in uncovered(bottom, top, covering) { - if see_through && sky.is_none() { - continue; - } - let corners = side_corners(face, [x0, x1], [z0, z1], low, high); - // Facing nothing, as at the edge of what's been seen: the - // sky over the column, as the run's own can be deep water's. - let sky = sky.unwrap_or(runs[0].sky); - let light = sky << 4 | run.block; - push_quad(&mut quads, corners, light, face, paint, false, w); - } - } - } - } - } - for ((face, y, paint, light), mut rows) in flat { - for (x, z, width, depth) in rectangles(&mut rows) { - let (x0, z0) = (x * w, z * w); - let (x1, z1) = (x0 + width * w, z0 + depth * w); - let corners = match face { - Face::Up => [[x0, y, z0], [x1, y, z0], [x1, y, z1], [x0, y, z1]], - _ => [[x0, y, z0], [x0, y, z1], [x1, y, z1], [x1, y, z0]], - }; - push_quad(&mut quads, corners, light, face, paint, false, w); - } - } - for ((face, line, top, bottom, paint, light), mut bits) in seams { - while bits != 0 { - let (start, len, mask) = lowest_span(bits); - bits &= !mask; - let along = [start * w, (start + len) * w]; - let across = [line * w, (line + 1) * w]; - let (xs, zs) = match face { - Face::North | Face::South => (along, across), - _ => (across, along), - }; - let corners = side_corners(face, xs, zs, bottom, top); - push_quad(&mut quads, corners, light, face, paint, true, w); - } - } - let [mut vertices, translucent] = quads; - let translucent_start = vertices.len() as u32; - vertices.extend(translucent); - SectionMesh { - key, - vertices, - translucent_start, - min_y: min_y.min(max_y), - max_y, - } -} - -/// The corners of the side facing `face` of the box spanning `xs` and `zs`, -/// from `low` to `high`. -fn side_corners( - face: Face, - [x0, x1]: [u32; 2], - [z0, z1]: [u32; 2], - low: u32, - high: u32, -) -> [[u32; 3]; 4] { - match face { - Face::North => [[x0, low, z0], [x0, high, z0], [x1, high, z0], [x1, low, z0]], - Face::South => [[x1, low, z1], [x1, high, z1], [x0, high, z1], [x0, low, z1]], - Face::West => [[x0, low, z1], [x0, high, z1], [x0, high, z0], [x0, low, z0]], - Face::Up | Face::Down | Face::East => { - [[x1, low, z0], [x1, high, z0], [x1, high, z1], [x1, low, z1]] - } - } -} - -/// The lowest stretch of set bits in `bits`: its first bit, length and mask. -fn lowest_span(bits: u64) -> (u32, u32, u64) { - let start = bits.trailing_zeros(); - let len = (!(bits >> start)).trailing_zeros(); - (start, len, (u64::MAX >> (64 - len)) << start) -} - -/// Greedy rectangles covering the set bits of `rows` (bit `x` of row `z`), -/// as `(x, z, width, depth)` in columns; clears `rows`. -fn rectangles(rows: &mut [u64; SECTION_COLUMNS as usize]) -> Vec<(u32, u32, u32, u32)> { - let mut out = Vec::new(); - for z in 0..rows.len() { - while rows[z] != 0 { - let (x, width, mask) = lowest_span(rows[z]); - let mut end = z + 1; - while end < rows.len() && rows[end] & mask == mask { - rows[end] &= !mask; - end += 1; - } - rows[z] &= !mask; - out.push((x, z as u32, width, (end - z) as u32)); - } - } - out -} - -/// The parts of `[low, high)` none of `neighbour`'s runs cover, highest -/// first, each with the sky light of the air it faces: that over the -/// neighbour's run under it, as Distant Horizons lights a side under an -/// overhang, or `None` with no run under it. A neighbour outside the -/// section has no runs, so the section's border faces always draw: a -/// coarser or missing neighbour can't hide them. -fn uncovered<'a>( - low: u32, - high: u32, - neighbour: impl IntoIterator, -) -> Vec<(u32, u32, Option)> { - let mut open = Vec::new(); - let mut cursor = high; - // Runs go top to bottom, so walk down. - for run in neighbour { - let (run_low, run_high) = (u32::from(run.bottom), u32::from(run.top) + 1); - if run_low >= cursor { - continue; - } - if run_high < cursor { - open.push((run_high.max(low), cursor, Some(run.sky))); - } - cursor = run_low; - if cursor <= low { - return open; - } - } - if cursor > low { - open.push((low, cursor, None)); - } - open -} - -/// A quad of `face` into `quads`' opaque or see-through half. A model face -/// short of its side discards once the quad spans more than one column, -/// where its gaps would otherwise stretch. -fn push_quad( - quads: &mut [Vec; 2], - corners: [[u32; 3]; 4], - light: u8, - face: Face, - paint: Paint, - seam: bool, - w: u32, -) { - let extent = |axis: usize| { - corners.iter().fold((u32::MAX, 0), |(lo, hi), c| { - (lo.min(c[axis]), hi.max(c[axis])) - }) - }; - let merged = (0..3).any(|axis| { - let (min, max) = extent(axis); - max - min > w - }); - let mut flags = paint.flags & !FACE_DISCARD_WHEN_MERGED; - if merged && paint.flags & FACE_DISCARD_WHEN_MERGED != 0 { - flags |= FACE_DISCARD; - } - if paint.translucent { - flags |= FACE_TRANSLUCENT; - } - // Each corner moves out along the quad's own plane, closing pinholes - // where quads meet (Distant Horizons' `LodQuadBuilder` micro offset). - let micro_offset = |axis: usize, value: u32| { - let (min, max) = extent(axis); - if min == max { - 0 - } else if value == min { - 3 - } else { - 1 - } - }; - quads[usize::from(paint.translucent)].extend(corners.map(|[x, y, z]| DistantVertex { - xz: x | z << 16, - y: y as u16, - light, - face: face as u8 | micro_offset(0, x) << 3 | micro_offset(2, z) << 5 | u8::from(seam) << 7, - color: paint.color, - model: paint.model, - flags, - _pad: 0, - })); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::position::ChunkPos; - use crate::renderer::distant::store::Store; - - fn run(top: u16, bottom: u16) -> Run { - lit(top, bottom, 15) - } - - fn lit(top: u16, bottom: u16, sky: u8) -> Run { - Run { - key: RunKey { - state: azalea_block::BlockState::AIR, - biome: 0, - }, - top, - bottom, - sky, - block: 0, - } - } - - #[test] - fn a_neighbour_hides_the_side_it_covers() { - // A 0..10 box beside a 0..4 one shows only 4..10. - assert_eq!(uncovered(0, 10, &[run(3, 0)]), [(4, 10, Some(15))]); - // Beside a floating 4..6 run: above and below it. - assert_eq!( - uncovered(0, 10, &[run(5, 4)]), - [(6, 10, Some(15)), (0, 4, None)] - ); - // Beside a taller one: nothing. - assert!(uncovered(2, 5, &[run(9, 0)]).is_empty()); - // Beside nothing, or a run wholly above or below: the whole side. - assert_eq!(uncovered(2, 5, &[]), [(2, 5, None)]); - assert_eq!(uncovered(2, 5, &[run(9, 7), run(0, 0)]), [(2, 5, Some(15))]); - // Under an overhang, lit by the dim air over the floor beside it. - assert_eq!( - uncovered(2, 9, &[run(12, 9), lit(3, 0, 4)]), - [(4, 9, Some(4))] - ); - } - - /// The `level` mesh of chunk 0 with `columns` (by `z * 16 + x`), runs - /// of biome 1 see-through as water, of biome 2 leaves. - fn mesh_at(level: u8, columns: impl Fn(usize) -> Vec) -> SectionMesh { - let mut store = Store::default(); - store.capture(ChunkPos::new(0, 0), (0..256).map(columns).collect()); - let key = SectionKey { level, x: 0, z: 0 }; - mesh_section(key, store.get(key).unwrap(), |key| { - let water = key.biome == 1; - let alpha = if water { 128 } else { 255 }; - RunLook { - model: 0, - translucent: water, - occludes: !water, - faces: [0; 6], - holes: key.biome == 2, - top: [0, 0, 0, alpha], - side: [0, 0, 0, alpha], - } - }) - } - - fn mesh(columns: impl Fn(usize) -> Vec) -> SectionMesh { - mesh_at(0, columns) - } - - fn quads(columns: impl Fn(usize) -> Vec) -> usize { - mesh(columns).vertices.len() / 4 - } - - fn in_biome(biome: u32, top: u16, bottom: u16) -> Run { - Run { - key: RunKey { - biome, - ..run(top, bottom).key - }, - ..run(top, bottom) - } - } - - fn water(top: u16, bottom: u16) -> Run { - in_biome(1, top, bottom) - } - - #[test] - fn the_sea_floor_shows_through_water() { - // A flat chunk of sea: the water's top and the floor's under it, and - // the floor's sides beside water. - let sea = mesh(|_| vec![water(10, 6), run(5, 0)]); - let see_through = |v: &DistantVertex| v.color[3] < 255; - let (opaque, clear) = sea.vertices.split_at(sea.translucent_start as usize); - assert!(opaque.iter().all(|v| !see_through(v))); - assert!(clear.iter().all(see_through)); - // The water's top and a seam from it down to the floor per side of - // the chunk: its sides at the chunk's border face nothing. - assert_eq!(clear.len() / 4, 1 + 4); - let seams = clear.iter().filter(|v| v.face >> 7 == 1); - assert!(seams.clone().all(|v| v.y == 6 || v.y == 11)); - assert_eq!(seams.count(), 4 * 4); - // The floor's top and its sides at the border. - assert_eq!(opaque.len() / 4, 1 + 4 * 16); - // A floor step under water keeps its side. - let step = mesh(|i| { - if i % 16 == 0 { - vec![water(10, 8), run(7, 0)] - } else { - vec![water(10, 6), run(5, 0)] - } - }); - assert!( - step.vertices[..step.translucent_start as usize] - .iter() - .any(|v| v.face & 7 == Face::East as u8 && v.y == 8) - ); - } - - #[test] - fn sides_facing_nothing_take_the_sky_over_their_column() { - // A lit run over a dark one at the section's west edge: both west - // sides face nothing, so neither is drawn dark. - let mesh = mesh(|i| { - if i == 0 { - vec![lit(20, 15, 15), lit(10, 0, 0)] - } else { - Vec::new() - } - }); - let west: Vec = mesh - .vertices - .iter() - .filter(|v| v.face & 7 == Face::West as u8) - .map(|v| v.light >> 4) - .collect(); - assert_eq!(west, [15; 8]); - } - - #[test] - fn leaves_cut_their_holes_out_at_full_detail_only() { - let pair = |i| { - if i < 2 { - vec![in_biome(2, 5, 0)] - } else { - Vec::new() - } - }; - // The pair's shared sides stay, seen through each other's holes. - let full = mesh_at(0, pair); - assert_eq!(full.vertices.len() / 4, 1 + 2 * 4); - assert!(full.vertices.iter().all(|v| v.flags & FACE_DISCARD != 0)); - let coarse = mesh_at(1, pair); - assert!(!coarse.vertices.is_empty()); - assert!(coarse.vertices.iter().all(|v| v.flags & FACE_DISCARD == 0)); - } - - #[test] - fn a_lone_column_is_a_box_without_its_world_bottom() { - let lone = |i| if i == 0 { vec![run(5, 0)] } else { Vec::new() }; - assert_eq!(quads(lone), 5); - let floating = |i| if i == 0 { vec![run(5, 3)] } else { Vec::new() }; - assert_eq!(quads(floating), 6); - } - - #[test] - fn equal_neighbours_hide_their_shared_sides() { - // Two side by side: their tops merge, their shared sides go. - let pair = |i| if i < 2 { vec![run(5, 0)] } else { Vec::new() }; - assert_eq!(quads(pair), 1 + 2 * 3); - // A flat chunk: one top, and the chunk's outer sides, which stay - // because they're in the middle of nothing. - assert_eq!(quads(|_| vec![run(5, 0)]), 1 + 4 * 16); - } - - #[test] - fn flat_faces_merge_into_rectangles() { - let mut rows = [0u64; SECTION_COLUMNS as usize]; - // An L: a 3x2 block with one more cell under its left edge. - rows[0] = 0b111; - rows[1] = 0b111; - rows[2] = 0b001; - assert_eq!(rectangles(&mut rows), [(0, 0, 3, 2), (0, 2, 1, 1)]); - assert!(rows.iter().all(|&row| row == 0)); - rows[5] = u64::MAX; - assert_eq!(rectangles(&mut rows), [(0, 5, 64, 1)]); - } -} diff --git a/pomme-client/src/renderer/distant/mod.rs b/pomme-client/src/renderer/distant/mod.rs index 0afe552eb..7e3e9682d 100644 --- a/pomme-client/src/renderer/distant/mod.rs +++ b/pomme-client/src/renderer/distant/mod.rs @@ -6,13 +6,12 @@ // dimensions without sky light (a cave roof would cover them). pub(crate) mod bakery; +pub(crate) mod build; pub(crate) mod colors; -pub(crate) mod column; pub(crate) mod gpu; -pub(crate) mod mesh; pub(crate) mod models; +pub(crate) mod scan; pub(crate) mod section; -pub(crate) mod store; pub(crate) mod voxel; pub(crate) mod worker; pub(crate) mod world; diff --git a/pomme-client/src/renderer/distant/models.rs b/pomme-client/src/renderer/distant/models.rs index 208332cfb..f020ae966 100644 --- a/pomme-client/src/renderer/distant/models.rs +++ b/pomme-client/src/renderer/distant/models.rs @@ -1,6 +1,7 @@ -//! Voxy's `ModelFactory`, `TextureUtils` and `MipGen`: block states baked -//! into models, each six face textures with mips in a tile of the model -//! atlas, plus what the mesher and shaders need to know of each face. +//! Voxy's `ModelFactory`, `ModelQueries`, `TextureUtils` and `MipGen`: +//! block states baked into models, each six face textures with mips in a +//! tile of the model atlas, plus what the mesher and shaders need to know of +//! each face. use std::collections::HashMap; @@ -8,7 +9,9 @@ use azalea_block::BlockState; use super::bakery::{Bakery, FaceTexture, MODEL_TEXTURE_SIZE, TEXELS}; use crate::renderer::chunk::atlas::AtlasUVMap; -use crate::world::block::render_assets::BlockRenderView; +use crate::renderer::chunk::mesher::int_to_rgb; +use crate::world::block::FluidKind; +use crate::world::block::render_assets::{BlockRenderView, Tint}; const SIZE: usize = MODEL_TEXTURE_SIZE; /// Mip levels of a face texture: 16, 8, 4 and 2 texels. @@ -20,20 +23,8 @@ pub(crate) const TILE_HEIGHT: u32 = SIZE as u32 * 2; /// Tiles a row of the model atlas holds, and rows. pub(crate) const ATLAS_COLUMNS: u32 = 64; pub(crate) const ATLAS_ROWS: u32 = 32; -/// Models the atlas holds; the first is the untextured one. -const CAPACITY: usize = (ATLAS_COLUMNS * ATLAS_ROWS) as usize; - -/// A face's flags as `distant.frag` reads them. -pub(crate) const FACE_DISCARD: u8 = 1; -/// Discards too once a quad spans more than one model: the face doesn't -/// cover its whole side. -pub(crate) const FACE_DISCARD_WHEN_MERGED: u8 = 2; -/// The tint, in bits 2-3: 1 only on grey texels, 2 everywhere. -pub(crate) const FACE_TINT_SHIFT: u8 = 2; -/// Set by the mesher on the faces it blends. -pub(crate) const FACE_TRANSLUCENT: u8 = 0x10; -/// Nothing drawn on that side. -pub(crate) const FACE_EMPTY: u8 = 0x80; +/// Models the atlas holds; the first is air, drawn as nothing. +pub(crate) const CAPACITY: usize = (ATLAS_COLUMNS * ATLAS_ROWS) as usize; /// Which layer a model draws in (vanilla `ChunkSectionLayer`). #[derive(Clone, Copy, PartialEq, Eq)] @@ -43,41 +34,177 @@ enum Layer { Translucent, } +/// What the mesher asks of each model (`ModelQueries`): a byte per face, +/// bit 0 the face occludes what it touches, 1 it covers its whole side, 2 it +/// can be occluded, 3 it's lit by its own block, 0xFF for no face; in byte 6 +/// whether the model is biome-coloured, translucent, double-sided, holds a +/// fluid, is a fluid, culls its own kind and is fully opaque; the light it +/// gives off in bits 55-58. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Metadata(pub u64); + +impl Metadata { + fn face(self, face: u8) -> u64 { + self.0 >> (8 * face) & 0xFF + } + + pub fn face_exists(self, face: u8) -> bool { + self.face(face) != 0xFF + } + + pub fn face_occludes(self, face: u8) -> bool { + self.face_exists(face) && self.face(face) & 1 != 0 + } + + pub fn face_can_be_occluded(self, face: u8) -> bool { + self.face(face) & 4 != 0 + } + + pub fn face_uses_self_lighting(self, face: u8) -> bool { + self.face(face) & 8 != 0 + } + + fn flag(self, bit: u32) -> bool { + self.0 >> 48 & 1 << bit != 0 + } + + pub fn is_biome_coloured(self) -> bool { + self.flag(0) + } + + pub fn is_translucent(self) -> bool { + self.flag(1) + } + + pub fn is_double_sided(self) -> bool { + self.flag(2) + } + + pub fn contains_fluid(self) -> bool { + self.flag(3) + } + + pub fn is_fluid(self) -> bool { + self.flag(4) + } + + pub fn culls_same(self) -> bool { + self.flag(5) + } + + pub fn is_fully_opaque(self) -> bool { + self.flag(6) + } + + pub fn light_emission(self) -> u64 { + self.0 >> 55 & 0xF + } +} + +/// The models the mesher reads, baked as it asks. +pub(crate) trait Models { + /// Block state `block`'s model, 0 for none. + fn model(&mut self, block: u32) -> u16; + fn metadata(&self, model: u16) -> Metadata; + /// The model of the fluid a model's block holds, for a fluid its own. + fn fluid_model(&self, model: u16) -> u16; +} + +/// What tints a model's texture: a fixed colour, or the biome's (in the +/// order of [`super::colors::TINT_KINDS`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum ModelTint { + None, + Constant([u8; 3]), + Grass, + Foliage, + DryFoliage, + Water, +} + +impl ModelTint { + fn biome_dependent(self) -> bool { + !matches!(self, Self::None | Self::Constant(_)) + } + + /// [`BlockModel::tint`]: the colour, or the kind to look the biome's up + /// by. + fn packed(self) -> u32 { + match self { + Self::None => u32::MAX, + Self::Constant([r, g, b]) => u32::from_le_bytes([r, g, b, 255]), + Self::Grass => 0, + Self::Foliage => 1, + Self::DryFoliage => 2, + Self::Water => 3, + } + } +} + +/// A tint colour as the shaders unpack it. +pub(crate) fn pack_tint(rgb: [f32; 3]) -> u32 { + let [r, g, b] = rgb.map(|c| (c * 255.0).round().clamp(0.0, 255.0) as u8); + u32::from_le_bytes([r, g, b, 255]) +} + +/// A model as the shaders read it (`block_model.glsl`). +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, bytemuck::Pod, bytemuck::Zeroable)] +pub(crate) struct BlockModel { + /// Per face: the drawn texels' min x, max x, min y and max y in 4 bits + /// each, how far in from its side the face sits in 64ths from bit 16, + /// whether it cuts clear texels out (bit 22) and does so once a quad + /// spans more than one block (23), and its tint state in bits 24-25: 1 + /// on some texels, 2 on all. + pub face_data: [u32; 6], + /// Tinted (1), by the biome (2), translucent (4), shaded per face as + /// vanilla does blocks with ambient occlusion (8). + pub flags: u32, + /// [`ModelTint::packed`]. + pub tint: u32, +} + +/// What a model is deduplicated by (`ModelEntry`): states that look alike +/// share one. +#[derive(PartialEq, Eq, Hash)] +struct ModelEntry { + faces: Box<[FaceTexture; 6]>, + /// The fluid a non-fluid block holds, by model. + fluid: Option, + tint: ModelTint, +} + +/// A state's model is still to bake. +const UNBAKED: u16 = u16::MAX; + /// What the mesher needs of a model. #[derive(Clone, Copy)] pub(crate) struct ModelInfo { - /// Blended over what's behind it, as water. - pub translucent: bool, - /// Every face fills its side, so it hides what it touches. - pub occludes: bool, - /// By Voxy face index ([`crate::world::block::model::Direction::VALUES`]). - pub faces: [u8; 6], - /// Solid only as Voxy draws leaves; their texture has holes. - pub holes: bool, + pub metadata: Metadata, + /// [`Models::fluid_model`]. + pub fluid: u16, } -/// Model 0's place: states without a model draw in their colours, never -/// asking for its info. -const UNTEXTURED: ModelInfo = ModelInfo { - translucent: false, - occludes: true, - faces: [0; 6], - holes: false, +/// Model 0's place: air. +const AIR: ModelInfo = ModelInfo { + metadata: Metadata(0), + fluid: 0, }; -/// A new model's tile and its mips, to upload to the atlas. +/// A new model: its record and its tile with mips, to upload. pub(crate) struct ModelTile { pub id: u16, + pub model: BlockModel, /// RGBA per level, the level-0 tile first, row by row. pub levels: Vec>, } pub(crate) struct ModelFactory { bakery: Bakery, - /// Each state's model, once baked. - id_mappings: HashMap, - /// Models by their faces, so states that look alike share one. - by_faces: HashMap, u16>, + /// Each state's model by state id, once baked. + id_mappings: Vec, + /// Models by what they're made of, so states that look alike share one. + entries: HashMap, models: Vec, /// Tiles baked since the last [`Self::take_tiles`]. tiles: Vec, @@ -87,9 +214,9 @@ impl ModelFactory { pub fn new() -> Self { Self { bakery: Bakery::new(), - id_mappings: HashMap::new(), - by_faces: HashMap::new(), - models: vec![UNTEXTURED], + id_mappings: Vec::new(), + entries: HashMap::new(), + models: vec![AIR], tiles: Vec::new(), } } @@ -102,19 +229,23 @@ impl ModelFactory { std::mem::take(&mut self.tiles) } - /// `state`'s model, baked the first time; 0, untextured, when it draws - /// nothing or the atlas is full. + /// `state`'s model, baked the first time; 0, air, when it draws nothing + /// or the atlas is full. pub fn model( &mut self, view: BlockRenderView<'_>, uv_map: &AtlasUVMap, state: BlockState, ) -> u16 { - if let Some(&id) = self.id_mappings.get(&state) { - return id; + let slot = usize::from(state.id()); + if slot >= self.id_mappings.len() { + self.id_mappings.resize(slot + 1, UNBAKED); + } + if self.id_mappings[slot] != UNBAKED { + return self.id_mappings[slot]; } let id = self.bake(view, uv_map, state); - self.id_mappings.insert(state, id); + self.id_mappings[slot] = id; id } @@ -123,15 +254,39 @@ impl ModelFactory { let Some((faces, flags)) = self.bakery.render(view, uv_map, state) else { return 0; }; - let faces = Box::new(faces); - if let Some(&id) = self.by_faces.get(&faces) { + let table = view.block_state_table(); + let data = table.block_state_data(state); + let tint = match flags.tint { + Tint::None => ModelTint::None, + Tint::Grass | Tint::DoubleGrass => ModelTint::Grass, + Tint::Foliage => ModelTint::Foliage, + Tint::DryFoliage => ModelTint::DryFoliage, + Tint::Water => ModelTint::Water, + Tint::Constant(rgb) => ModelTint::Constant(to_bytes(int_to_rgb(rgb as i32))), + Tint::Redstone => ModelTint::Constant(to_bytes(data.redstone_wire_rgb())), + Tint::Stem => ModelTint::Constant(to_bytes(data.stem_rgb())), + }; + let is_fluid = data.is_fluid_block(); + // A block holding a fluid draws the fluid's model around it. + let held_fluid = match data.fluid().kind { + _ if is_fluid => None, + FluidKind::Empty => Some(0), + _ => Some(self.model(view, uv_map, table.fluid_block(data.fluid()))), + }; + let entry = ModelEntry { + faces: Box::new(faces), + fluid: held_fluid, + tint, + }; + if let Some(&id) = self.entries.get(&entry) { return id; } if self.models.len() == CAPACITY { // TODO: a model atlas that grows past its capacity. return 0; } - let leaves = view.block_state_table().block_state_data(state).is_leaves(); + let faces = &entry.faces; + let leaves = data.is_leaves(); let mut layer = None; if flags.translucent { layer = Some(if faces.iter().any(has_translucent_pixel) { @@ -159,54 +314,89 @@ impl ModelFactory { } else { DepthMode::Min }; + let depths: [Option; 6] = + std::array::from_fn(|i| compute_depth(&faces[i], depth_mode, check)); + // Nothing on either side of an axis: plants and the like, drawn from + // both sides. + let double_sided = [0, 2, 4] + .iter() + .any(|&a| depths[a].is_none() && depths[a + 1].is_none()); + let mut metadata = u64::from(tint.biome_dependent()) + | u64::from(layer == Layer::Translucent) << 1 + | u64::from(double_sided) << 2 + | u64::from(!is_fluid && data.fluid().kind != FluidKind::Empty) << 3 + | u64::from(is_fluid) << 4 + | u64::from(data.culls_same()) << 5; let mut fully_opaque = true; - let face_flags = std::array::from_fn(|i| { - let face = &faces[i]; - let Some(offset) = compute_depth(face, depth_mode, check) else { + let mut face_data = [u32::MAX; 6]; + // Highest face first, so each shifts into place. + for face in (0..6).rev() { + metadata <<= 8; + let Some(offset) = depths[face] else { + metadata |= 0xFF; fully_opaque = false; - return FACE_EMPTY; + continue; }; - let [min_x, max_x, min_y, max_y] = compute_bounds(face, check); - let written = written_pixel_count(face, check) as f32; + let texture = &faces[face]; + let [min_x, max_x, min_y, max_y] = compute_bounds(texture, check); + let written = written_pixel_count(texture, check) as f32; let covers_block = min_x == 0 && min_y == 0 && max_x == SIZE - 1 && max_y == SIZE - 1; + metadata |= u64::from(covers_block) << 1; let occludes = layer != Layer::Translucent // A face drawn far in from its side doesn't hide the next block. && offset < 0.1 && written / TEXELS as f32 > 0.9; + metadata |= u64::from(occludes); fully_opaque &= occludes; - let area = - ((max_x + 1).saturating_sub(min_x) * (max_y + 1).saturating_sub(min_y)) as f32; + metadata |= u64::from(offset < 0.3) << 2; + metadata |= u64::from(offset > 0.01 || layer == Layer::Translucent) << 3; + let area = ((max_x + 1 - min_x) * (max_y + 1 - min_y)) as f32; // Fences and the like are solid with gaps between their parts. let discard = (written / area < 0.9 || layer != Layer::Solid) && layer != Layer::Translucent; - let mut flags = u8::from(discard) * FACE_DISCARD; - if !covers_block && layer != Layer::Translucent { - flags |= FACE_DISCARD_WHEN_MERGED; - } - // Voxy asks only of tinted blocks; untinted quads mark no texel. - flags |= match face_tint(face, check) { + let discard_when_merged = !covers_block && layer != Layer::Translucent; + let tint_state = match face_tint(texture, check) { + _ if tint == ModelTint::None => 0, FaceTint::Some => 1, FaceTint::All => 2, FaceTint::Nothing | FaceTint::None => 0, - } << FACE_TINT_SHIFT; - flags - }); + }; + face_data[face] = (min_x | max_x << 4 | min_y << 8 | max_y << 12) as u32 + | ((offset * 64.0).round() as u32).min(62) << 16 + | u32::from(discard) << 22 + | u32::from(discard_when_merged) << 23 + | tint_state << 24; + } + metadata |= u64::from(fully_opaque) << 54; + metadata |= u64::from(data.light_props().emission) << 55; let id = self.models.len() as u16; self.models.push(ModelInfo { - translucent: layer == Layer::Translucent, - occludes: fully_opaque, - faces: face_flags, - holes: leaves, + metadata: Metadata(metadata), + fluid: held_fluid.unwrap_or(id), }); self.tiles.push(ModelTile { id, - levels: mip_tile(flags.darkened, leaves, &faces), + model: BlockModel { + face_data, + flags: u32::from(tint != ModelTint::None) + | u32::from(tint.biome_dependent()) << 1 + | u32::from(layer == Layer::Translucent) << 2 + | u32::from(flags.shaded) << 3, + tint: tint.packed(), + }, + levels: mip_tile(flags.darkened, leaves, faces), }); - self.by_faces.insert(faces, id); + self.entries.insert(entry, id); id } } +fn to_bytes(rgb: [f32; 3]) -> [u8; 3] { + pack_tint(rgb).to_le_bytes()[..3] + .try_into() + .expect("three channels") +} + /// How `TextureUtils` tells a texel was drawn. #[derive(Clone, Copy)] enum WriteCheck { @@ -307,8 +497,7 @@ fn compute_bounds(face: &FaceTexture, check: WriteCheck) -> [usize; 4] { /// Voxy's `MipGen.putTextures`: the faces laid out in a tile, transparent /// texels taking the colour of the nearest drawn one, then halved down to /// 2x2 faces. Voxy leaves sprites that mip dark black there; leaves fill -/// theirs anyway, since coarse distant leaves draw them solid -/// ([`ModelInfo::holes`]). +/// theirs anyway, since coarse distant leaves draw them solid. fn mip_tile(darkened: bool, leaves: bool, faces: &[FaceTexture; 6]) -> Vec> { let width = TILE_WIDTH as usize; let mut tile = vec![0u32; width * TILE_HEIGHT as usize]; @@ -469,4 +658,18 @@ mod tests { ); } } + + #[test] + fn metadata_fields_unpack() { + let faces: u64 = (0..6).fold(0, |m, f| m | 0b1011 << (8 * f)) & !(0xFF << 16) | 0xFF << 16; + let meta = Metadata(faces | (2 | 32 | 64) << 48 | 7 << 55); + assert!(meta.face_exists(0) && !meta.face_exists(2)); + assert!(meta.face_occludes(1) && !meta.face_occludes(2)); + assert!(!meta.face_can_be_occluded(0) && meta.face_uses_self_lighting(0)); + assert!(meta.is_translucent() && meta.culls_same() && meta.is_fully_opaque()); + assert!(!meta.is_fluid() && !meta.contains_fluid() && !meta.is_double_sided()); + assert_eq!(meta.light_emission(), 7); + assert_eq!(ModelTint::Constant([1, 2, 3]).packed(), 0xFF03_0201); + assert_eq!(pack_tint([1.0, 0.0, 0.5]), 0xFF80_00FF); + } } diff --git a/pomme-client/src/renderer/distant/scan.rs b/pomme-client/src/renderer/distant/scan.rs new file mode 100644 index 000000000..2e0c53f16 --- /dev/null +++ b/pomme-client/src/renderer/distant/scan.rs @@ -0,0 +1,346 @@ +//! Voxy's `ScanMesher2D` and `RenderDataFactory.Mesher`: a 32-wide plane of +//! voxel faces fed one at a time, row by row, merged greedily into quads of +//! up to 16x16 and written out as Voxy quads. + +/// A quad's bits (`quad_format.glsl`): face in 0-2 (`axis << 1 | side`, +/// axis 0 for y, 1 for z, 2 for x), width-1 in 3-6, height-1 in 7-10, z in +/// 11-15, y in 16-20, x in 21-25, model id in 26-45, biome in 46-54, light +/// in 55-62. +pub(crate) const QUAD_MODEL_SHIFT: u32 = 26; +pub(crate) const QUAD_BIOME_SHIFT: u32 = 46; +pub(crate) const QUAD_LIGHT_SHIFT: u32 = 55; +/// The quad's light byte: sky in the low nibble, block in the high. +pub(crate) const QUAD_LIGHT: u64 = 0xFF << QUAD_LIGHT_SHIFT; +/// The bits below the model id, which the mesher uses for the face's side +/// (bit 0) and its group (bits 1-2) until the quad is placed. +const AUX: u64 = (1 << QUAD_MODEL_SHIFT) - 1; + +const MAX_SIZE: u8 = 16; + +/// The groups a section's quads come in: translucent, double-sided, then +/// one per face. +pub(crate) const GROUPS: usize = 8; + +/// Where a section's quads land, and the bounds they span. +pub(crate) struct Quads { + pub groups: [Vec; GROUPS], + pub min: [i32; 3], + pub max: [i32; 3], +} + +impl Quads { + pub fn new() -> Self { + Self { + groups: Default::default(), + min: [i32::MAX; 3], + max: [i32::MIN; 3], + } + } + + pub fn clear(&mut self) { + for group in &mut self.groups { + group.clear(); + } + self.min = [i32::MAX; 3]; + self.max = [i32::MIN; 3]; + } + + pub fn count(&self) -> usize { + self.groups.iter().map(Vec::len).sum() + } +} + +/// One plane's mesher. `axis` is the plane's normal; `position` which layer +/// along it, and `face_offset` whether a face on the negative side belongs +/// to the next layer up (the inner passes) or to this one (the outer). +pub(crate) struct Mesher { + row_data: [u64; 32], + row_length: [u8; 32], + row_depth: [u8; 32], + row_bitset: u32, + index: u32, + sum: u8, + data: u64, + pub axis: u8, + pub position: u8, + pub face_offset: bool, +} + +impl Mesher { + pub fn new(axis: u8, position: u8, face_offset: bool) -> Self { + Self { + row_data: [0; 32], + row_length: [0; 32], + row_depth: [0; 32], + row_bitset: 0, + index: 0, + sum: 0, + data: 0, + axis, + position, + face_offset, + } + } + + /// The next face along the row; 0 for none. + pub fn put(&mut self, data: u64, out: &mut Quads) { + let idx = (self.index & 31) as usize; + self.index += 1; + if idx == 0 { + // A row starts: what the last row ended on couldn't merge + // sideways, so it goes in slot 31. + if self.data != 0 { + if self.row_bitset & (1 << 31) != 0 { + self.emit(31, ((self.index - 1) >> 5) - 1, out); + } + self.row_bitset |= 1 << 31; + self.row_length[31] = self.sum; + self.row_depth[31] = 1; + self.row_data[31] = self.data; + } + self.data = data; + self.sum = 0; + } + if data != self.data || self.sum == MAX_SIZE { + if self.data != 0 { + let prev = idx - 1; + self.row_depth[prev] = 1; + self.row_length[prev] = self.sum; + self.row_data[prev] = self.data; + self.row_bitset |= 1 << prev; + } + self.data = data; + self.sum = 0; + } + self.sum += 1; + + let is_set = self.row_bitset & (1 << idx) != 0; + let mut by_depth_max = false; + // The run above, ending here with the same data and length, takes + // this one. + if self.data != 0 + && is_set + && self.row_length[idx] == self.sum + && self.row_data[idx] == self.data + { + self.row_depth[idx] += 1; + self.sum = 0; + self.data = 0; + if self.row_depth[idx] != MAX_SIZE { + return; + } + by_depth_max = true; + } + if is_set { + let row = ((self.index - 1) >> 5) - u32::from(!by_depth_max); + self.emit(idx, row, out); + self.row_bitset &= !(1 << idx); + } + } + + /// Writes out every pending run in `mask`'s slots. + fn emit_ranged(&mut self, mask: u32, out: &mut Quads) { + let mut set = self.row_bitset & mask; + self.row_bitset &= !mask; + while set != 0 { + let idx = set.trailing_zeros() as usize; + set &= set - 1; + self.emit(idx, (self.index >> 5) - 1, out); + } + } + + /// `count` faces of nothing. + pub fn skip(&mut self, mut count: u32, out: &mut Quads) { + if count == 0 { + return; + } + if self.data != 0 { + self.put(0, out); + count -= 1; + } + if count > 0 { + let mask = (((1u64 << count.min(32)) - 1) << (self.index & 31)) as u32; + self.emit_ranged(mask, out); + self.index += count; + } + } + + pub fn end_row(&mut self, out: &mut Quads) { + if self.index & 31 != 0 { + self.skip(32 - (self.index & 31), out); + } + } + + /// Writes out whatever is pending and starts over. + pub fn finish(&mut self, out: &mut Quads) { + if self.index != 0 { + self.skip(32 - (self.index & 31), out); + self.emit_ranged(u32::MAX, out); + } + self.row_bitset = 0; + self.sum = 0; + self.data = 0; + self.index = 0; + } + + /// `RenderDataFactory.Mesher.emitQuad`: the run ending at column `x` of + /// row `z`, `length` along the row and `width` rows deep, placed as a + /// quad. + fn emit(&self, x: usize, z: u32, out: &mut Quads) { + let (mut length, mut width) = (u32::from(self.row_length[x]), u32::from(self.row_depth[x])); + let data = self.row_data[x]; + let mut x = x as u32 + 1 - length; + let mut z = z + 1 - width; + if self.axis == 2 { + // Along x, rows run up y and columns along z. + std::mem::swap(&mut x, &mut z); + std::mem::swap(&mut length, &mut width); + } + let aux = (data & AUX) as u32; + let data = data & !AUX; + let side = aux & 1; + let group = aux >> 1 & 3; + let position = u32::from(self.position) + 1 - if self.face_offset { side } else { 1 }; + let axis = u32::from(self.axis); + let face = axis << 1 | side; + let mut encoded = face | (width - 1) << 7 | (length - 1) << 3; + encoded |= x << if axis == 2 { 16 } else { 21 }; + encoded |= z << if axis == 1 { 16 } else { 11 }; + encoded |= position + << match axis { + 0 => 16, + 1 => 11, + _ => 21, + }; + let group = group as usize + if group == 2 { face as usize } else { 0 }; + out.groups[group].push(data | u64::from(encoded)); + + let (position, x, z, length, width) = ( + position as i32, + x as i32, + z as i32, + length as i32, + width as i32, + ); + // The bounds along the normal span only the layers, Voxy's quirk. + let (lo, hi) = match axis { + 0 => ([x, position, z], [x + length, position, z + width]), + 1 => ([x, z, position], [x + length, z + width, position]), + _ => ([position, x, z], [position, x + length, z + width]), + }; + for i in 0..3 { + out.min[i] = out.min[i].min(lo[i]); + out.max[i] = out.max[i].max(hi[i]); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Feeds a 32x32 plane of face data, row by row, and returns each + /// quad's `(x, z, length, width, data)`, the data's aux bits cleared. + fn mesh(plane: &[u64; 1024]) -> Vec<(u32, u32, u32, u32, u64)> { + let mut mesher = Mesher::new(0, 0, true); + let mut out = Quads::new(); + for &data in plane { + if data == 0 { + mesher.skip(1, &mut out); + } else { + mesher.put(data, &mut out); + } + } + mesher.finish(&mut out); + out.groups + .iter() + .flatten() + .map(|&quad| { + let field = |shift: u32, bits: u32| (quad >> shift) as u32 & ((1 << bits) - 1); + ( + field(21, 5), + field(11, 5), + field(3, 4) + 1, + field(7, 4) + 1, + quad & !((1 << 26) - 1), + ) + }) + .collect() + } + + const A: u64 = 1 << 26; + const B: u64 = 2 << 26; + + #[test] + fn runs_merge_across_rows_up_to_sixteen() { + let mut plane = [0u64; 1024]; + for z in 0..20 { + for x in 0..20 { + plane[z * 32 + x] = A; + } + } + let mut quads = mesh(&plane); + quads.sort_unstable(); + assert_eq!( + quads, + [ + (0, 0, 16, 16, A), + (0, 16, 16, 4, A), + (16, 0, 4, 16, A), + (16, 16, 4, 4, A) + ] + ); + } + + #[test] + fn every_face_is_covered_exactly_once() { + // A fixed pseudo-random plane of two kinds of face and gaps. + let mut seed = 12345u32; + let plane: [u64; 1024] = std::array::from_fn(|_| { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12345); + match seed >> 28 { + 0..=5 => A, + 6..=12 => B, + _ => 0, + } + }); + let mut covered = [0u64; 1024]; + for (x, z, length, width, data) in mesh(&plane) { + assert!((1..=16).contains(&length) && (1..=16).contains(&width)); + for dz in 0..width { + for dx in 0..length { + let at = ((z + dz) * 32 + x + dx) as usize; + assert_eq!(covered[at], 0, "face {at} covered twice"); + covered[at] = data; + } + } + } + assert_eq!(covered, plane); + } + + #[test] + fn quads_land_on_their_face_plane() { + // The negative face of an inner layer belongs to the layer above. + let mut out = Quads::new(); + let mut mesher = Mesher::new(0, 5, true); + mesher.put(A | 1, &mut out); + mesher.finish(&mut out); + mesher.put(A, &mut out); + mesher.finish(&mut out); + let positions: Vec = out.groups.iter().flatten().map(|q| q >> 16 & 31).collect(); + assert_eq!(positions, [5, 6]); + assert_eq!(out.min, [0, 5, 0]); + assert_eq!(out.max, [1, 6, 1]); + // Along x the row runs up y and the column along z. + let mut out = Quads::new(); + let mut mesher = Mesher::new(2, 7, false); + mesher.skip(3, &mut out); + mesher.put(B | 0b101, &mut out); + mesher.finish(&mut out); + let quad = out.groups[7][0]; + assert_eq!( + (quad >> 21 & 31, quad >> 16 & 31, quad >> 11 & 31), + (7, 0, 3) + ); + } +} diff --git a/pomme-client/src/renderer/distant/section.rs b/pomme-client/src/renderer/distant/section.rs index ae56c8ef8..ab0e6fe45 100644 --- a/pomme-client/src/renderer/distant/section.rs +++ b/pomme-client/src/renderer/distant/section.rs @@ -53,6 +53,17 @@ impl SectionKey { } } + /// The level-`level` section this one lies in. + pub fn ancestor(self, level: u8) -> Self { + let shift = level - self.level; + Self { + level, + x: self.x >> shift, + y: self.y >> shift, + z: self.z >> shift, + } + } + /// Blocks along a side. pub fn width(self) -> i32 { (SIDE as i32) << self.level @@ -79,7 +90,7 @@ impl SectionKey { } /// Air under full sky, which an unseen section is made of. -const UNSEEN: u64 = voxel::compose(0x0F, voxel::AIR, 0); +pub(crate) const UNSEEN: u64 = voxel::compose(0x0F, voxel::AIR, 0); /// A voxel's index: `y`, then `z`, then `x`. pub(crate) fn index(x: usize, y: usize, z: usize) -> usize { diff --git a/pomme-client/src/renderer/distant/store.rs b/pomme-client/src/renderer/distant/store.rs deleted file mode 100644 index c88d115d3..000000000 --- a/pomme-client/src/renderer/distant/store.rs +++ /dev/null @@ -1,537 +0,0 @@ -//! What the client keeps of the terrain it has seen: per level of detail, -//! sections of 64x64 columns, in memory around the player and on disk. - -use std::collections::{HashMap, HashSet}; -use std::io::{Read, Write}; -use std::path::PathBuf; - -use azalea_block::BlockState; - -use super::column::{Run, RunKey, merge_columns}; -use crate::position::ChunkPos; - -/// Columns along each side of a section. -pub(crate) const SECTION_COLUMNS: i32 = 64; -/// Levels of detail: at level `l` a column is `1 << l` blocks wide, so a -/// chunk is `16 >> l` columns across and level 4 is one column per chunk. -pub(crate) const LEVELS: u8 = 5; -/// The layout of a section file; a file of another is ignored. -const FORMAT: u8 = 1; -/// Distant Horizons' default (Medium) horizontal quality: columns `2^l` -/// blocks wide from `DROP_OFF << l` blocks out, so full detail reaches 384. -const DROP_OFF: f64 = 192.0; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub(crate) struct SectionKey { - pub level: u8, - pub x: i32, - pub z: i32, -} - -impl SectionKey { - /// The section at `level` holding level column `x`/`z`, and that - /// column's index in it. - fn holding(level: u8, x: i32, z: i32) -> (Self, usize) { - let key = Self { - level, - x: x.div_euclid(SECTION_COLUMNS), - z: z.div_euclid(SECTION_COLUMNS), - }; - let index = z.rem_euclid(SECTION_COLUMNS) * SECTION_COLUMNS + x.rem_euclid(SECTION_COLUMNS); - (key, index as usize) - } - - /// Width of one column, in blocks. - pub fn column_width(self) -> i32 { - 1 << self.level - } - - /// Width of the section, in blocks. - pub fn width(self) -> i32 { - SECTION_COLUMNS << self.level - } - - /// The block `x`/`z` of the section's corner. - pub fn origin(self) -> (i32, i32) { - (self.x * self.width(), self.z * self.width()) - } - - /// The four sections one level finer covering this one. - pub fn children(self) -> Option<[Self; 4]> { - let level = self.level.checked_sub(1)?; - let child = |dx, dz| Self { - level, - x: self.x * 2 + dx, - z: self.z * 2 + dz, - }; - Some([child(0, 0), child(1, 0), child(0, 1), child(1, 1)]) - } - - pub fn parent(self) -> Option { - (self.level + 1 < LEVELS).then(|| Self { - level: self.level + 1, - x: self.x.div_euclid(2), - z: self.z.div_euclid(2), - }) - } - - /// Whether, seen from `x`/`z`, this section is too close to draw at its - /// level and its four children draw instead: Distant Horizons' - /// `LodQuadTree.calcExpectedDetailLevel`, from the section's centre. - pub fn splits(self, x: f64, z: f64) -> bool { - let (ox, oz) = self.origin(); - let half = f64::from(self.width()) / 2.0; - let centre = (f64::from(ox) + half - x).hypot(f64::from(oz) + half - z); - self.level > 0 && centre < DROP_OFF * f64::from(self.column_width()) - } - - /// Whether drawing from `x`/`z` out to `radius` blocks can reach this - /// section: it's in range and its parent splits (the coarsest level - /// always draws). - pub fn wanted(self, x: f64, z: f64, radius: f64) -> bool { - self.distance(x, z) <= radius && self.parent().is_none_or(|parent| parent.splits(x, z)) - } - - /// Every section [`Self::wanted`] from `x`/`z` out to `radius` blocks. - pub fn all_wanted(x: f64, z: f64, radius: f64) -> Vec { - let top = LEVELS - 1; - let width = f64::from(SECTION_COLUMNS << top); - let range = - |c: f64| ((c - radius) / width).floor() as i32..=((c + radius) / width).floor() as i32; - let mut stack: Vec = range(z) - .flat_map(|z| range(x).map(move |x| Self { level: top, x, z })) - .collect(); - let mut out = Vec::new(); - while let Some(key) = stack.pop() { - if key.distance(x, z) > radius { - continue; - } - out.push(key); - if key.splits(x, z) { - stack.extend(key.children().into_iter().flatten()); - } - } - out - } - - /// The horizontal distance from block position `x`/`z` to the - /// section's nearest edge; 0 inside it. - pub fn distance(self, x: f64, z: f64) -> f64 { - let (ox, oz) = self.origin(); - let width = f64::from(self.width()); - let dx = (f64::from(ox) - x).max(x - f64::from(ox) - width).max(0.0); - let dz = (f64::from(oz) - z).max(z - f64::from(oz) - width).max(0.0); - dx.hypot(dz) - } -} - -/// A run packed in a `u64`: palette index (24 bits), top, bottom (12 each), -/// sky and block light (4 each). -fn pack(run: &Run, palette_index: u32) -> u64 { - u64::from(palette_index) - | u64::from(run.top) << 24 - | u64::from(run.bottom) << 36 - | u64::from(run.sky) << 48 - | u64::from(run.block) << 52 -} - -fn palette_index(packed: u64) -> usize { - (packed & 0xFF_FFFF) as usize -} - -fn unpack(packed: u64, palette: &[RunKey]) -> Run { - Run { - key: palette[palette_index(packed)], - top: (packed >> 24 & 0xFFF) as u16, - bottom: (packed >> 36 & 0xFFF) as u16, - sky: (packed >> 48 & 0xF) as u8, - block: (packed >> 52 & 0xF) as u8, - } -} - -/// 64x64 columns at one level, their runs packed against a palette of what -/// they're made of. -pub(crate) struct Section { - palette: Vec, - lookup: HashMap, - /// By `z * 64 + x`. - columns: Vec>, -} - -impl Section { - fn new() -> Self { - Self { - palette: Vec::new(), - lookup: HashMap::new(), - columns: vec![Box::default(); (SECTION_COLUMNS * SECTION_COLUMNS) as usize], - } - } - - /// `key`'s palette index, adding it if it's new. - fn intern(&mut self, key: RunKey) -> u32 { - *self.lookup.entry(key).or_insert_with(|| { - self.palette.push(key); - (self.palette.len() - 1) as u32 - }) - } - - fn set_column(&mut self, index: usize, runs: &[Run]) { - self.columns[index] = runs - .iter() - .map(|run| pack(run, self.intern(run.key))) - .collect(); - } - - /// The section as a section file: the palette, then each column's - /// packed runs, zlib-compressed. - fn encode(&self) -> Vec { - let mut out = vec![FORMAT]; - out.extend((self.palette.len() as u32).to_le_bytes()); - for key in &self.palette { - out.extend(key.state.id().to_le_bytes()); - out.extend(key.biome.to_le_bytes()); - } - for column in &self.columns { - out.extend((column.len() as u16).to_le_bytes()); - for packed in column { - out.extend(packed.to_le_bytes()); - } - } - let mut zlib = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); - zlib.write_all(&out) - .and_then(|()| zlib.finish()) - .expect("compressing into memory") - } - - /// The section [`Self::encode`] wrote; `None` for a file of another - /// format or a damaged one. - fn decode(file: &[u8]) -> Option { - let mut bytes = Vec::new(); - flate2::read::ZlibDecoder::new(file) - .read_to_end(&mut bytes) - .ok()?; - let mut rest = bytes.as_slice(); - if take::<1>(&mut rest)? != [FORMAT] { - return None; - } - let mut section = Self::new(); - for _ in 0..u32::from_le_bytes(take(&mut rest)?) { - let state = u16::from_le_bytes(take(&mut rest)?); - section.intern(RunKey { - state: BlockState::try_from(u32::from(state)).ok()?, - biome: u32::from_le_bytes(take(&mut rest)?), - }); - } - let palette = section.palette.len(); - for column in &mut section.columns { - let runs = u16::from_le_bytes(take(&mut rest)?); - *column = (0..runs) - .map(|_| { - let packed = u64::from_le_bytes(take(&mut rest)?); - (palette_index(packed) < palette).then_some(packed) - }) - .collect::>()?; - } - Some(section) - } - - /// Column `x`/`z`'s runs, top to bottom; none outside the section. - pub fn column(&self, x: i32, z: i32) -> impl Iterator + '_ { - let inside = (0..SECTION_COLUMNS).contains(&x) && (0..SECTION_COLUMNS).contains(&z); - let runs: &[u64] = if inside { - &self.columns[(z * SECTION_COLUMNS + x) as usize] - } else { - &[] - }; - runs.iter().map(|&packed| unpack(packed, &self.palette)) - } -} - -/// The next `N` bytes of `rest`, taking them off it. -fn take(rest: &mut &[u8]) -> Option<[u8; N]> { - let (taken, after) = rest.split_first_chunk()?; - *rest = after; - Some(*taken) -} - -/// One dimension's sections at every level: those near the player in -/// memory, and every one seen in `dir`, a file per section. -#[derive(Default)] -pub(crate) struct Store { - sections: HashMap, - dir: Option, - /// Sections changed since they were last written. - unsaved: HashSet, - /// Sections `dir` has no file for. - absent: HashSet, -} - -impl Store { - pub fn new(dir: Option) -> Self { - Self { - dir, - ..Self::default() - } - } - - /// Writes what's unsaved, then keeps further sections in `dir`. - pub fn set_dir(&mut self, dir: Option) { - if self.dir != dir { - self.flush(); - self.dir = dir; - self.absent.clear(); - } - } - - fn path(&self, key: SectionKey) -> Option { - let SectionKey { level, x, z } = key; - Some(self.dir.as_ref()?.join(format!("{level}.{x}.{z}.bin"))) - } - - /// Reads `key` from disk unless it's in memory already. Returns whether - /// it's in memory now. - pub fn load(&mut self, key: SectionKey) -> bool { - if self.sections.contains_key(&key) { - return true; - } - if self.absent.contains(&key) { - return false; - } - let Some(path) = self.path(key) else { - return false; - }; - let read = std::fs::read(&path).ok(); - match read.as_deref().and_then(Section::decode) { - Some(section) => { - self.sections.insert(key, section); - true - } - None => { - if read.is_some() { - tracing::warn!("Ignoring unreadable distant terrain {}", path.display()); - } - self.absent.insert(key); - false - } - } - } - - fn save(&self, key: SectionKey) { - let (Some(path), Some(section)) = (self.path(key), self.sections.get(&key)) else { - return; - }; - let written = std::fs::create_dir_all(path.parent().expect("a section file has a folder")) - .and_then(|()| crate::util::write_atomic(&path, §ion.encode())); - if let Err(error) = written { - tracing::warn!("Failed to save distant terrain {}: {error}", path.display()); - } - } - - /// Writes every section changed since it was last written. - pub fn flush(&mut self) { - for key in std::mem::take(&mut self.unsaved) { - self.save(key); - } - } - - /// Writes a chunk's columns, `level0` by `z * 16 + x`, into every level, - /// merging them down for the coarser ones. Returns the sections it - /// touched. - pub fn capture(&mut self, pos: ChunkPos, level0: Vec>) -> Vec { - let mut touched = Vec::with_capacity(LEVELS as usize); - let mut columns = level0; - for level in 0..LEVELS { - let across = 16 >> level; - if level > 0 { - let finer = across * 2; - columns = (0..across * across) - .map(|i| { - let (x, z) = (i % across * 2, i / across * 2); - let at = - |dx: usize, dz: usize| columns[(z + dz) * finer + x + dx].as_slice(); - // Distant Horizons' order, which settles ties. - merge_columns(&[at(0, 0), at(0, 1), at(1, 0), at(1, 1)]) - }) - .collect(); - } - for (i, runs) in columns.iter().enumerate() { - let x = pos.x * across as i32 + (i % across) as i32; - let z = pos.z * across as i32 + (i / across) as i32; - let (key, index) = SectionKey::holding(level, x, z); - if !touched.contains(&key) { - // Onto what's on disk, so the rest of it isn't lost. - self.load(key); - self.absent.remove(&key); - self.unsaved.insert(key); - touched.push(key); - } - self.sections - .entry(key) - .or_insert_with(Section::new) - .set_column(index, runs); - } - } - touched - } - - pub fn get(&self, key: SectionKey) -> Option<&Section> { - self.sections.get(&key) - } - - /// Drops the sections farther than `radius` blocks from `x`/`z`, saving - /// those unsaved, and returns their keys. - pub fn evict_beyond(&mut self, x: f64, z: f64, radius: f64) -> Vec { - let far: Vec = self - .sections - .keys() - .copied() - .filter(|key| key.distance(x, z) > radius) - .collect(); - for key in &far { - if self.unsaved.remove(key) { - self.save(*key); - } - self.sections.remove(key); - } - far - } -} - -#[cfg(test)] -mod tests { - use azalea_block::BlockState; - - use super::*; - - fn run(state: u32, top: u16, bottom: u16) -> Run { - Run { - key: RunKey { - state: BlockState::try_from(state).unwrap(), - biome: 7, - }, - top, - bottom, - sky: 15, - block: 3, - } - } - - #[test] - fn runs_survive_packing() { - let mut section = Section::new(); - let runs = [run(5, 4095, 100), run(9, 99, 0)]; - section.set_column(65, &runs); - assert_eq!(section.column(1, 1).collect::>(), runs); - assert_eq!(section.column(64, 0).count(), 0); - } - - #[test] - fn a_capture_writes_every_level_down_to_a_column_per_chunk() { - let mut store = Store::default(); - let pos = ChunkPos::new(-1, 70); - let level0 = (0..256).map(|i| vec![run(1, 10 + i % 2, 0)]).collect(); - let touched = store.capture(pos, level0); - assert_eq!(touched.len(), LEVELS as usize); - - // Level 4: chunk x -1 is column -1, in section -1 at column 63. - let level4 = SectionKey { - level: 4, - x: -1, - z: 1, - }; - assert!(touched.contains(&level4)); - let section = store.get(level4).unwrap(); - // Half the columns reach 11, half 10: the merged column takes the - // taller run's top, as two of four vote stone there. - assert_eq!(section.column(63, 6).collect::>(), [run(1, 11, 0)]); - } - - #[test] - fn nearer_sections_split_into_finer_levels() { - let key = |level, x| SectionKey { level, x, z: 0 }; - // Full detail out to 384 blocks from a section's centre, then a - // level per doubling. - assert!(key(1, 0).splits(64.0 + 383.0, 64.0)); - assert!(!key(1, 0).splits(64.0 + 385.0, 64.0)); - assert!(key(4, 0).splits(512.0 + 3071.0, 512.0)); - assert!(!key(4, 0).splits(512.0 + 3073.0, 512.0)); - // Level 0 never splits. - assert!(!key(0, 0).splits(8.0, 8.0)); - // A level-3 child of a far level-4 section isn't wanted; one under - // the player is. - assert!(!key(3, 6).wanted(8.0, 8.0, 4096.0)); - assert!(key(3, 0).wanted(8.0, 8.0, 4096.0)); - assert!(!key(4, 9).wanted(8.0, 8.0, 4096.0), "past the radius"); - } - - #[test] - fn wanted_sections_are_found_without_a_store() { - let (x, z, radius) = (-300.0, 2000.0, 1500.0); - let found: HashSet = SectionKey::all_wanted(x, z, radius).into_iter().collect(); - let brute: HashSet = (0..LEVELS) - .flat_map(|level| { - let span = 4096 >> level >> 6; - (-span..=span).flat_map(move |sx| { - (-span..=span).map(move |sz| SectionKey { - level, - x: sx, - z: sz, - }) - }) - }) - .filter(|key| key.wanted(x, z, radius)) - .collect(); - assert!(!brute.is_empty()); - assert_eq!(found, brute); - } - - #[test] - fn sections_survive_a_file() { - let mut section = Section::new(); - let runs = [run(5, 4095, 100), run(9, 99, 0)]; - section.set_column(0, &runs); - section.set_column(4095, &[run(9, 7, 7)]); - let read = Section::decode(§ion.encode()).unwrap(); - assert_eq!(read.column(0, 0).collect::>(), runs); - assert_eq!(read.column(63, 63).collect::>(), [run(9, 7, 7)]); - assert_eq!(read.column(1, 0).count(), 0); - assert!(Section::decode(b"not a section").is_none()); - } - - #[test] - fn evicted_sections_come_back_from_disk() { - let dir = std::env::temp_dir().join(format!("pomme-distant-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let mut store = Store::new(Some(dir.clone())); - store.capture(ChunkPos::new(100, 0), vec![vec![run(1, 10, 0)]; 256]); - store.evict_beyond(8.0, 8.0, 500.0); - assert_eq!(store.sections.len(), 0); - - // A later capture beside it shares its level-4 section, which keeps - // what was saved. - let mut store = Store::new(Some(dir.clone())); - store.capture(ChunkPos::new(101, 0), vec![vec![run(2, 20, 0)]; 256]); - let section = store.get(SectionKey::holding(4, 100, 0).0).unwrap(); - assert_eq!(section.column(36, 0).collect::>(), [run(1, 10, 0)]); - assert_eq!(section.column(37, 0).collect::>(), [run(2, 20, 0)]); - assert!(!store.load(SectionKey { - level: 0, - x: -9, - z: 0 - })); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn eviction_drops_sections_beyond_the_radius() { - let mut store = Store::default(); - store.capture(ChunkPos::new(0, 0), vec![Vec::new(); 256]); - store.capture(ChunkPos::new(100, 0), vec![Vec::new(); 256]); - // Chunk 100 is 1600 blocks out; even its level-4 section starts at - // 1024. - let gone = store.evict_beyond(8.0, 8.0, 500.0); - assert_eq!(gone.len(), LEVELS as usize); - assert!(gone.iter().all(|key| key.x > 0)); - assert_eq!(store.sections.len(), LEVELS as usize); - } -} diff --git a/pomme-client/src/renderer/distant/voxel.rs b/pomme-client/src/renderer/distant/voxel.rs index 9627bb51c..54f15cad4 100644 --- a/pomme-client/src/renderer/distant/voxel.rs +++ b/pomme-client/src/renderer/distant/voxel.rs @@ -7,7 +7,8 @@ const BLOCK_SHIFT: u32 = 27; const BIOME_SHIFT: u32 = 47; const LIGHT_SHIFT: u32 = 56; const BLOCK_MASK: u64 = ((1 << 20) - 1) << BLOCK_SHIFT; -const LIGHT_MASK: u64 = 0xFF << LIGHT_SHIFT; +pub(crate) const BIOME_MASK: u64 = 0x1FF << BIOME_SHIFT; +pub(crate) const LIGHT_MASK: u64 = 0xFF << LIGHT_SHIFT; /// Block state 0 is air, whatever kind (`Mapper.AIR`). pub(crate) const AIR: u32 = 0; diff --git a/pomme-client/src/renderer/distant/worker.rs b/pomme-client/src/renderer/distant/worker.rs index f3ad209a1..ae7c1161c 100644 --- a/pomme-client/src/renderer/distant/worker.rs +++ b/pomme-client/src/renderer/distant/worker.rs @@ -1,6 +1,7 @@ -//! The distant terrain thread: turns chunks the server forgets into columns, -//! keeps them per dimension, on disk too, and meshes the sections around the -//! player. +//! The distant terrain thread: turns chunks the server forgets into Voxy +//! sections (`VoxelIngestService`), keeps them per dimension, on disk too, +//! and meshes the sections the renderer asks for and keeps them up to date +//! (`RenderGenerationService`). use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -11,28 +12,38 @@ use std::time::{Duration, Instant}; use crossbeam_channel::{Receiver, RecvTimeoutError, Sender}; use parking_lot::RwLock; +use super::build::{BuiltSection, Factory}; use super::colors::DistantColors; -use super::column::build_chunk; -use super::mesh::{SectionMesh, mesh_section}; use super::models::{ModelFactory, ModelTile}; -use super::store::{SectionKey, Store}; +use super::section::{SectionKey, WorldSection}; use super::voxel::States; use super::world::World; use crate::position::ChunkPos; use crate::world::chunk::{Chunk, ChunkLightData, ChunkStore}; /// Sections meshed between checks for new jobs. -const MESHES_PER_BATCH: usize = 16; -/// How far past the view radius sections are kept, in blocks: one level-4 -/// section, so walking back and forth doesn't drop and rebuild them. +const MESHES_PER_BATCH: usize = 32; +/// How far past the view radius sections are kept in memory, in blocks: one +/// top-level section, so walking back and forth doesn't drop and reload +/// them. const KEEP_MARGIN: f64 = 1024.0; /// How far the player moves, in blocks, before the thread hears of it. const VIEW_STEP: f64 = 32.0; /// How often changed sections are written to disk. const SAVE_EVERY: Duration = Duration::from_secs(60); -/// How long a section a capture changed waits for more before it's meshed, -/// so a burst of captures meshes it once. +/// How long a section a capture changed waits for more before it's meshed +/// again, so a burst of captures meshes it once. const SETTLE: Duration = Duration::from_millis(250); +/// The sides a change reaches ([`super::world::Changed::sides`]), as +/// neighbour offsets. +const SIDES: [[i32; 3]; 6] = [ + [0, -1, 0], + [0, 1, 0], + [-1, 0, 0], + [1, 0, 0], + [0, 0, -1], + [0, 0, 1], +]; enum Job { Capture { @@ -44,8 +55,10 @@ enum Job { }, Colors(Arc), View(View), - /// Sections the renderer evicted or dropped for room. - Evicted(Vec), + /// Sections the renderer wants meshed, and kept meshed as they change. + Request(Vec), + /// Sections the renderer dropped. + Dropped(Vec), /// Where sections are kept on disk, a folder per dimension; `None` /// keeps them in memory only. Cache(Option), @@ -56,28 +69,19 @@ enum Job { #[derive(Clone)] struct View { dimension: Arc, - /// The dimension's lowest block, which the runs stand on. - min_y: i32, x: f64, z: f64, /// In blocks. radius: f64, } -impl View { - fn wants(&self, key: SectionKey) -> bool { - key.wanted(self.x, self.z, self.radius) - } -} - -/// New and dropped section meshes for one dimension, and the models new -/// ones are the first to use. +/// Newly meshed sections of one dimension, the models new ones are the +/// first to use, and the biome tints when they changed. pub(crate) struct DistantUpdate { pub dimension: Arc, - pub min_y: i32, - pub meshes: Vec, - pub removed: Vec, + pub sections: Vec, pub tiles: Vec, + pub tints: Option>, } /// The game's handle on the thread. Dropping it waits for the thread to @@ -113,7 +117,7 @@ impl DistantTerrain { } /// Keeps sections under the cache root, in a folder for the block - /// states of table `slot` and the server's `biomes`, since the runs + /// states of table `slot` and the server's `biomes`, since the voxels /// store both by id. pub fn set_cache_key( &mut self, @@ -163,30 +167,31 @@ impl DistantTerrain { let _ = self.jobs.send(Job::Colors(colors)); } - /// Sections the renderer had no room for, meshed again once nearer. - pub fn evicted(&self, keys: Vec) { - if !keys.is_empty() { - let _ = self.jobs.send(Job::Evicted(keys)); + /// Sections the renderer wants, and those it dropped. + pub fn request(&self, wanted: Vec, dropped: Vec) { + if !wanted.is_empty() { + let _ = self.jobs.send(Job::Request(wanted)); + } + if !dropped.is_empty() { + let _ = self.jobs.send(Job::Dropped(dropped)); } } - /// Where the player is and how far to keep and draw, in chunks; 0 stops - /// keeping new chunks. - pub fn set_view(&mut self, dimension: &str, min_y: i32, x: f64, z: f64, chunks: u32) { + /// Where the player is and how far to keep, in chunks; 0 stops keeping + /// new chunks. + pub fn set_view(&mut self, dimension: &str, x: f64, z: f64, chunks: u32) { if chunks == 0 { self.view = None; return; } let view = View { dimension: dimension.into(), - min_y, x, z, radius: f64::from(chunks * 16), }; let unchanged = self.view.as_ref().is_some_and(|last| { last.dimension == view.dimension - && last.min_y == view.min_y && last.radius == view.radius && (last.x - x).hypot(last.z - z) < VIEW_STEP }); @@ -224,45 +229,40 @@ pub(crate) fn folder_name(name: &str) -> String { .collect() } +/// Where `dimension`'s sections go under `cache`: a folder of their own, so +/// the column files of earlier builds don't mix in. fn dimension_dir(cache: Option<&Path>, dimension: &str) -> Option { - Some(cache?.join(folder_name(dimension))) + Some(cache?.join(folder_name(dimension)).join("sections")) } -/// `dimension`'s store, made with its folder under `cache`. -fn store<'a>( - stores: &'a mut HashMap, Store>, +/// `dimension`'s world, made with its folder under `cache`. +fn world<'a>( + worlds: &'a mut HashMap, World>, cache: Option<&Path>, dimension: &Arc, -) -> &'a mut Store { - stores +) -> &'a mut World { + worlds .entry(Arc::clone(dimension)) - .or_insert_with(|| Store::new(dimension_dir(cache, dimension))) -} - -/// Voxy's sections go in a folder of their own beside the columns' until -/// the columns go. -fn world_dir(cache: Option<&Path>, dimension: &str) -> Option { - Some(dimension_dir(cache, dimension)?.join("voxy")) + .or_insert_with(|| World::new(dimension_dir(cache, dimension))) } struct Worker { colors: Arc, models: ModelFactory, + states: States, + factory: Factory, updates: Sender, /// Per dimension, its sections. - stores: HashMap, Store>, - /// Per dimension, its Voxy sections. worlds: HashMap, World>, - states: States, cache: Option, last_save: Instant, view: Option, - /// The viewed dimension's sections to mesh, by when. + /// Sections to mesh, each from when. dirty: HashMap, - /// The viewed dimension's sections with a mesh sent. + /// Sections the renderer holds, kept up to date as captures change them. meshed: HashSet, - /// Sections the renderer had no room for, by how far they were. - evicted: HashMap, + /// Whether the renderer has the current colours' biome tints. + tints_sent: bool, } impl Worker { @@ -271,31 +271,18 @@ impl Worker { states: States::new(colors.table()), colors, models: ModelFactory::new(), + factory: Factory::new(), updates, - stores: HashMap::new(), worlds: HashMap::new(), cache: None, last_save: Instant::now(), view: None, dirty: HashMap::new(), meshed: HashSet::new(), - evicted: HashMap::new(), + tints_sent: false, } } - /// Queues `key` to mesh from `ready`, no longer evicted. - fn mark_dirty(&mut self, key: SectionKey, ready: Instant) { - self.evicted.remove(&key); - self.dirty.insert(key, ready); - } - - /// Whether `key`, if evicted, is now a section nearer than it was. - fn wants_back(&self, view: &View, key: SectionKey) -> bool { - self.evicted - .get(&key) - .is_none_or(|&was| key.distance(view.x, view.z) + f64::from(key.width()) < was) - } - fn run(mut self, jobs: &Receiver) { 'run: loop { let wait = match self.dirty.values().min() { @@ -322,9 +309,6 @@ impl Worker { } fn save(&mut self) { - for store in self.stores.values_mut() { - store.flush(); - } for world in self.worlds.values_mut() { world.flush(); } @@ -341,21 +325,33 @@ impl Worker { min_y, } => { let chunk = chunk.read(); - let columns = build_chunk(&chunk, light.as_deref(), min_y, &self.colors); - let touched = store(&mut self.stores, self.cache.as_deref(), &dimension) - .capture(pos, columns); - let cache = self.cache.as_deref(); - self.worlds - .entry(Arc::clone(&dimension)) - .or_insert_with(|| World::new(world_dir(cache, &dimension))) + let changed = world(&mut self.worlds, self.cache.as_deref(), &dimension) .ingest_chunk(&self.states, pos, &chunk, light.as_deref(), min_y); - if let Some(view) = self.view.clone() - && view.dimension == dimension + if self + .view + .as_ref() + .is_none_or(|view| view.dimension != dimension) { - let ready = Instant::now() + SETTLE; - for key in touched { - if view.wants(key) && self.wants_back(&view, key) { - self.mark_dirty(key, ready); + return; + } + // The section and, across the sides the change reached, the + // neighbours meshed against it (`WorldUpdater`'s dirty marks). + let ready = Instant::now() + SETTLE; + for change in changed { + let key = change.key; + let neighbours = SIDES + .iter() + .enumerate() + .filter(|&(side, _)| change.sides & 1 << side != 0) + .map(|(_, [dx, dy, dz])| SectionKey { + x: key.x + dx, + y: key.y + dy, + z: key.z + dz, + ..key + }); + for key in std::iter::once(key).chain(neighbours) { + if self.meshed.contains(&key) { + self.dirty.entry(key).or_insert(ready); } } } @@ -367,6 +363,7 @@ impl Worker { self.states = States::new(colors.table()); } self.colors = colors; + self.tints_sent = false; let now = Instant::now(); self.dirty.extend(self.meshed.iter().map(|&key| (key, now))); } @@ -374,69 +371,35 @@ impl Worker { if self .view .as_ref() - .is_some_and(|last| last.dimension != view.dimension) + .is_none_or(|last| last.dimension != view.dimension) { // The renderer drops the old dimension's meshes itself. self.meshed.clear(); self.dirty.clear(); - self.evicted.clear(); } - let store = store(&mut self.stores, self.cache.as_deref(), &view.dimension); - store.evict_beyond(view.x, view.z, view.radius + KEEP_MARGIN); - if let Some(world) = self.worlds.get_mut(&view.dimension) { - world.evict_beyond(view.x, view.z, view.radius + KEEP_MARGIN); - } - // Those with nothing seen mesh empty, so a parent knows its - // children are all there. - let wanted: HashSet = - SectionKey::all_wanted(view.x, view.z, view.radius) - .into_iter() - .inspect(|key| { - store.load(*key); - }) - .collect(); - let removed: Vec = self.meshed.difference(&wanted).copied().collect(); - for key in &removed { - self.meshed.remove(key); - } - self.dirty.retain(|key, _| wanted.contains(key)); - self.evicted.retain(|key, _| wanted.contains(key)); + world(&mut self.worlds, self.cache.as_deref(), &view.dimension).evict_beyond( + view.x, + view.z, + view.radius + KEEP_MARGIN, + ); + self.view = Some(view); + } + Job::Request(keys) => { let now = Instant::now(); - let back: Vec = wanted - .difference(&self.meshed) - .copied() - .filter(|&key| self.wants_back(&view, key)) - .collect(); - for key in back { - self.mark_dirty(key, now); + for key in keys { + self.meshed.insert(key); + self.dirty.insert(key, now); } - // Sent even with nothing removed: the renderer draws every - // section at the view's `min_y`, which may have changed. - let _ = self.updates.send(DistantUpdate { - dimension: Arc::clone(&view.dimension), - min_y: view.min_y, - meshes: Vec::new(), - removed, - tiles: Vec::new(), - }); - self.view = Some(view); } - Job::Evicted(keys) => { - let Some(view) = &self.view else { - return; - }; + Job::Dropped(keys) => { for key in keys { self.meshed.remove(&key); self.dirty.remove(&key); - self.evicted.insert(key, key.distance(view.x, view.z)); } } Job::Cache(cache) => { - for (dimension, store) in &mut self.stores { - store.set_dir(dimension_dir(cache.as_deref(), dimension)); - } for (dimension, world) in &mut self.worlds { - world.set_dir(world_dir(cache.as_deref(), dimension)); + world.set_dir(dimension_dir(cache.as_deref(), dimension)); } self.cache = cache; } @@ -444,16 +407,12 @@ impl Worker { } } - /// Meshes the dirty sections nearest the player, a batch at a time. + /// Meshes the ready sections nearest the player, a batch at a time. fn mesh_nearest(&mut self) { if self.dirty.is_empty() { return; } - let Some(view) = &self.view else { - self.dirty.clear(); - return; - }; - let Some(store) = self.stores.get(&view.dimension) else { + let Some(view) = self.view.clone() else { self.dirty.clear(); return; }; @@ -473,25 +432,33 @@ impl Worker { .then(b.level.cmp(&a.level)) }); nearest.truncate(MESHES_PER_BATCH); - let meshes = nearest - .iter() - .map(|&key| { - self.dirty.remove(&key); - self.meshed.insert(key); - match store.get(key) { - Some(section) => mesh_section(key, section, |run| { - self.colors.run_look(&mut self.models, run) - }), - None => SectionMesh::empty(key), - } - }) - .collect(); + let world = world(&mut self.worlds, self.cache.as_deref(), &view.dimension); + let mut sections = Vec::with_capacity(nearest.len()); + let unseen = WorldSection::unseen(); + for key in nearest { + self.dirty.remove(&key); + // The section and the six around it, read from disk if need be. + world.load(key); + for [dx, dy, dz] in SIDES { + world.load(SectionKey { + x: key.x + dx, + y: key.y + dy, + z: key.z + dz, + ..key + }); + } + let section = world.get(key).unwrap_or(&unseen); + let mut models = self.colors.baking(&mut self.models); + sections.push( + self.factory + .build(key, section, |k| world.get(k), &mut models), + ); + } let _ = self.updates.send(DistantUpdate { dimension: Arc::clone(&view.dimension), - min_y: view.min_y, - meshes, - removed: Vec::new(), + sections, tiles: self.models.take_tiles(), + tints: (!std::mem::replace(&mut self.tints_sent, true)).then(|| self.colors.tint_lut()), }); } } diff --git a/pomme-client/src/renderer/distant/world.rs b/pomme-client/src/renderer/distant/world.rs index df2464216..77256ac05 100644 --- a/pomme-client/src/renderer/distant/world.rs +++ b/pomme-client/src/renderer/distant/world.rs @@ -79,6 +79,11 @@ impl World { } } + /// `key`'s section if it's in memory. + pub fn get(&self, key: SectionKey) -> Option<&WorldSection> { + self.sections.get(&key) + } + /// `key`'s section, loaded, or unseen air where there's none /// (`ActiveSectionTracker`). fn acquire(&mut self, key: SectionKey) -> &mut WorldSection { diff --git a/pomme-client/src/renderer/mod.rs b/pomme-client/src/renderer/mod.rs index 128a9235c..a404c9c42 100644 --- a/pomme-client/src/renderer/mod.rs +++ b/pomme-client/src/renderer/mod.rs @@ -501,7 +501,7 @@ impl Renderer { &ctx.device, swapchain_state.targets(), &lightmap, - &distant_buffers, + distant_buffers.layout(), ); let mut item_entity_pipeline = pipelines::item_entity::ItemEntityPipeline::new( @@ -1380,14 +1380,29 @@ impl Renderer { self.chunk_buffers.clear(); } - /// New and dropped distant terrain sections; returns those evicted for - /// room ([`distant::gpu::DistantBuffers::upload`]). + /// Newly meshed distant terrain sections + /// ([`distant::gpu::DistantBuffers::upload`]). pub(crate) fn upload_distant( &mut self, updates: impl IntoIterator, - ) -> Vec { - let eye = self.camera_render_position(); - self.distant_buffers.upload(&self.ctx.device, updates, eye) + ) { + self.distant_buffers.upload(&self.ctx.device, updates); + } + + /// The distant sections the last frame's traversal asked for, and those + /// it dropped ([`distant::gpu::DistantBuffers::take_requests`]). + pub(crate) fn take_distant_requests( + &mut self, + ) -> ( + Vec, + Vec, + ) { + self.distant_buffers.take_requests() + } + + /// Where distant terrain draws from and how far; `None` for not at all. + pub(crate) fn set_distant_view(&mut self, view: Option) { + self.distant_buffers.set_view(view); } /// Whether a real chunk draws at `pos`, where distant terrain gives way @@ -2116,15 +2131,17 @@ impl Renderer { // Its own projection, so its depth means nothing to // the chunks: they start from a cleared buffer, and // the drawn-column mask keeps it out of theirs. + let eye = self.camera_render_position(); + let extent = self.swapchain.extent; + self.distant_buffers.traverse( + &self.camera, + eye, + [extent.width, extent.height], + ); for translucent in [false, true] { self.distant_pipeline.bind(&mut pass, frame, translucent); - self.distant_buffers.draw( - &pass, - &self.camera, - self.camera_render_position(), - f64::from(*distant_terrain * 16), - translucent, - ); + self.distant_buffers + .draw(&pass, &self.camera, eye, translucent); } pass.clear_depth(scissor); } diff --git a/pomme-client/src/renderer/pipelines/distant.rs b/pomme-client/src/renderer/pipelines/distant.rs index fbfb9b610..0d2b2ef1f 100644 --- a/pomme-client/src/renderer/pipelines/distant.rs +++ b/pomme-client/src/renderer/pipelines/distant.rs @@ -1,83 +1,36 @@ use crate::renderer::camera::CameraUniform; -use crate::renderer::distant::gpu::DistantBuffers; use crate::renderer::pipelines::lightmap::Lightmap; use crate::renderer::{rhi, shader, util}; -/// Set 1: the drawn-column mask `distant.frag` discards with, then the -/// model atlas and its sampler. -const MASK_MODELS_LAYOUT: &[rhi::BindGroupLayoutEntry] = &[ - rhi::BindGroupLayoutEntry::storage(rhi::ShaderStages::FRAGMENT), - rhi::BindGroupLayoutEntry::texture(rhi::ShaderStages::FRAGMENT), - rhi::BindGroupLayoutEntry::sampler(rhi::ShaderStages::FRAGMENT), -]; - -/// [`crate::renderer::distant::mesh::DistantVertex`]: packed x/z, y, light -/// and face, colour, model and flags. -const VERTEX_BUFFERS: [rhi::VertexBufferLayout<'static>; 1] = [rhi::VertexBufferLayout { - stride: 16, - step_mode: rhi::VertexStepMode::Vertex, - attributes: &[ - rhi::VertexAttribute { - location: 0, - format: rhi::VertexFormat::Uint32, - offset: 0, - }, - rhi::VertexAttribute { - location: 1, - format: rhi::VertexFormat::Uint16, - offset: 4, - }, - rhi::VertexAttribute { - location: 2, - format: rhi::VertexFormat::Uint8x2, - offset: 6, - }, - rhi::VertexAttribute { - location: 3, - format: rhi::VertexFormat::Unorm8x4, - offset: 8, - }, - rhi::VertexAttribute { - location: 4, - format: rhi::VertexFormat::Uint16, - offset: 12, - }, - rhi::VertexAttribute { - location: 5, - format: rhi::VertexFormat::Uint8x2, - offset: 14, - }, - ], -}]; - -/// Distant terrain: boxes textured with their block's model behind the -/// real chunks, under the camera's fog and the lightmap; opaque, then -/// see-through blended over it. +/// Distant terrain: Voxy quads read straight from their arena, textured +/// with their block's model, under the camera's fog and the lightmap; +/// opaque, then see-through blended over it. pub struct DistantPipeline { pipelines: [rhi::backend::RenderPipeline; 2], pipeline_layout: rhi::backend::PipelineLayout, camera_layout: rhi::backend::BindGroupLayout, - mask_layout: rhi::backend::BindGroupLayout, camera_sets: Vec, camera_buffers: Vec, - mask_set: rhi::backend::BindGroup, /// Set 2, which [`Lightmap`] owns. lightmap_set: rhi::backend::BindGroup, } impl DistantPipeline { + /// `arena_layout` is set 1 + /// ([`crate::renderer::distant::gpu::DistantBuffers::layout`]), + /// bound per arena as it draws. pub fn new( device: &rhi::backend::Device, targets: rhi::backend::PipelineTargets, lightmap: &Lightmap, - buffers: &DistantBuffers, + arena_layout: rhi::backend::BindGroupLayout, ) -> Self { let camera_layout = rhi::backend::create_bind_group_layout(device, util::VERTEX_UNIFORM); - let mask_layout = rhi::backend::create_bind_group_layout(device, MASK_MODELS_LAYOUT); - // The view projection (once a frame), then the section origin. + // The view projection (once a pass), then the section's origin and + // scale. let pipeline_layout = rhi::backend::create_pipeline_layout( device, - &[camera_layout, mask_layout, lightmap.layout], + &[camera_layout, arena_layout, lightmap.layout], Some(rhi::PushConstants { visibility: rhi::ShaderStages::VERTEX, size: 80, @@ -90,34 +43,12 @@ impl DistantPipeline { size_of::() as u64, "distant_camera_uniform", ); - let mask_set = device.create_bind_group( - mask_layout, - &[ - ( - 0, - rhi::backend::BindingResource::storage( - buffers.mask(), - DistantBuffers::mask_bytes(), - ), - ), - ( - 1, - rhi::backend::BindingResource::Texture(buffers.model_atlas()), - ), - ( - 2, - rhi::backend::BindingResource::Sampler(buffers.model_sampler()), - ), - ], - ); Self { pipelines, pipeline_layout, camera_layout, - mask_layout, camera_sets, camera_buffers, - mask_set, lightmap_set: lightmap.group, } } @@ -144,28 +75,24 @@ impl DistantPipeline { translucent: bool, ) { pass.set_pipeline(&self.pipelines[usize::from(translucent)]); - pass.set_bind_groups( - 0, - &[self.camera_sets[frame], self.mask_set, self.lightmap_set], - ); + pass.set_bind_group(0, self.camera_sets[frame]); + pass.set_bind_group(2, self.lightmap_set); } pub fn destroy(&mut self, device: &rhi::backend::Device) { util::destroy_buffers(device, &mut self.camera_buffers); util::destroy_bind_groups(device, self.camera_sets.drain(..)); - self.mask_set.destroy(device); for pipeline in &mut self.pipelines { pipeline.destroy(device); } self.pipeline_layout.destroy(device); self.camera_layout.destroy(device); - self.mask_layout.destroy(device); } } -/// The opaque pipeline, then the see-through one, which blends and leaves -/// depth alone like chunk water. Faces are unculled: the boxes are drawn -/// from every side as easily. +/// The opaque pipeline, then the see-through one, which blends as Voxy's +/// translucent pass does. Neither culls faces: the quads are drawn from +/// every side. fn create_pipelines( device: &rhi::backend::Device, targets: rhi::backend::PipelineTargets, @@ -177,12 +104,11 @@ fn create_pipelines( "distant terrain", shader::include_shader!("distant.vert"), shader::include_shader!("distant.frag"), - &VERTEX_BUFFERS, + &[], ) }; let translucent = rhi::RenderPipelineDesc { - label: "distant water", - depth: Some(rhi::DepthState::READ_ONLY), + label: "distant translucent", blend: Some(rhi::BlendState::TRANSLUCENT), ..opaque }; diff --git a/pomme-client/src/renderer/shaders/distant.frag b/pomme-client/src/renderer/shaders/distant.frag index 7a7f3971a..516f139d6 100644 --- a/pomme-client/src/renderer/shaders/distant.frag +++ b/pomme-client/src/renderer/shaders/distant.frag @@ -1,28 +1,26 @@ #version 450 +// Voxy's `quads.frag`: the quad's face texture repeated per block from the +// model atlas, cut out, tinted and lit. + #include "fog.glsl" #include "distant_models.glsl" +// Every model's face textures (models.rs). +layout(set = 1, binding = 3) uniform texture2D model_atlas; +layout(set = 1, binding = 4) uniform sampler model_sampler; // One bit per chunk column, ring-indexed as `world/chunk.rs` `ring_slot`: // set where a real chunk draws instead. -layout(set = 1, binding = 0) readonly buffer DrawnColumns { +layout(set = 1, binding = 5) readonly buffer DrawnColumns { uint drawn[]; }; -// Every model's face textures (models.rs). -layout(set = 1, binding = 1) uniform texture2D model_atlas; -layout(set = 1, binding = 2) uniform sampler model_sampler; -layout(location = 0) in vec4 v_color; -layout(location = 1) in vec3 v_fog_color; -layout(location = 2) in float v_fog; -layout(location = 3) in vec2 v_column_pos; -layout(location = 4) flat in ivec2 v_anchor; -layout(location = 5) flat in vec2 v_outward; -layout(location = 6) flat in uint v_seam; -layout(location = 7) in vec3 v_light; -layout(location = 8) in vec2 v_uv; -layout(location = 9) flat in vec2 v_tile; -layout(location = 10) flat in uint v_flags; +layout(location = 0) flat in uvec3 v_data; +layout(location = 1) in vec2 v_uv; +layout(location = 2) in vec3 v_fog_color; +layout(location = 3) in float v_fog; +layout(location = 4) in vec2 v_column_pos; +layout(location = 5) flat in ivec2 v_anchor; layout(location = 0) out vec4 out_color; @@ -37,45 +35,46 @@ bool drawn_at(vec2 pos) { return (drawn[slot >> 5] & (1u << (slot & 31u))) != 0u; } -// Voxy's `quads.frag`: the face's texel, mipped by how fast the unwrapped -// texture coordinate moves so the wrap from column to column doesn't pick -// the smallest mip; cut out by its unmipped alpha; tinted per the face's -// tint state. -vec4 model_color(vec2 dx, vec2 dy) { - vec2 pos = (v_tile + fract(v_uv) * FACE_SIZE) / ATLAS_SIZE; - vec4 color = textureGrad(sampler2D(model_atlas, model_sampler), pos, dx, dy); +void main() { + uint flags = v_data.x; + uint face = flags >> 4u & 7u; + uint model_id = flags >> 16u; + vec2 size = vec2(flags >> 8u & 0xFu, flags >> 12u & 0xFu); + // The block within the quad, and the texel within its face. + vec2 tile; + vec2 texel = modf(v_uv, tile); + vec2 face_origin = vec2(model_id % ATLAS_COLUMNS, model_id / ATLAS_COLUMNS) * TILE_SIZE + + vec2(face >> 1u, face & 1u) * FACE_SIZE; + vec2 pos = (face_origin + texel * FACE_SIZE) / ATLAS_SIZE; + // Mipped by how fast the unwrapped coordinate moves, so the wrap from + // block to block doesn't pick the smallest mip. + vec2 unwrapped = v_uv * (FACE_SIZE / ATLAS_SIZE); + vec4 color = textureGrad(sampler2D(model_atlas, model_sampler), pos, dFdx(unwrapped), dFdy(unwrapped)); + if (any(notEqual(clamp(tile, vec2(0.0), size), tile))) { + discard; + } + if (drawn_at(v_column_pos)) { + discard; + } + // Cut out by the unmipped alpha, which mips would smear. vec4 exact = textureLod(sampler2D(model_atlas, model_sampler), pos, 0.0); - if ((v_flags & FACE_TRANSLUCENT) != 0u) { + if ((flags & 2u) != 0u) { if (exact.a == 0.0) { discard; } } else { color.a = 1.0; - if ((v_flags & FACE_DISCARD) != 0u && exact.a <= 0.1) { + if ((flags & 1u) != 0u && exact.a <= 0.1) { discard; } } - uint tint = (v_flags >> 2) & 3u; - // Partly tinted, as a grass block's side: only its grey texels. + // `computeColour`: tint state 2 everywhere, 1 only on grey texels, as a + // grass block's side. + uint tint = flags >> 2u & 3u; bool grey = abs(exact.r - exact.g) < 0.02 && abs(exact.g - exact.b) < 0.02; if (tint == 2u || (tint == 1u && grey)) { - color.rgb *= v_color.rgb; - } - return color; -} - -void main() { - // Before anything discards. - vec2 unwrapped = v_uv * (FACE_SIZE / ATLAS_SIZE); - vec2 dx = dFdx(unwrapped); - vec2 dy = dFdy(unwrapped); - // Nothing over a real chunk, nor a side facing one: the chunk draws its - // own edge, and through its water this one would show as a wall. A - // seam is the opposite, drawn only facing one. - bool faces_drawn = v_outward != vec2(0.0) && drawn_at(v_column_pos + v_outward); - if (drawn_at(v_column_pos) || faces_drawn == (v_seam == 0u)) { - discard; + color.rgb *= unpackUnorm4x8(v_data.z).rgb; } - vec4 color = (v_flags & TEXTURED) != 0u ? model_color(dx, dy) : v_color; - out_color = vec4(apply_fog(color.rgb * v_light, v_fog, v_fog_color), color.a); + color.rgb *= unpackUnorm4x8(v_data.y).rgb; + out_color = vec4(apply_fog(color.rgb, v_fog, v_fog_color), color.a); } diff --git a/pomme-client/src/renderer/shaders/distant.vert b/pomme-client/src/renderer/shaders/distant.vert index 3e4053ac9..76a6cc6bd 100644 --- a/pomme-client/src/renderer/shaders/distant.vert +++ b/pomme-client/src/renderer/shaders/distant.vert @@ -1,7 +1,8 @@ #version 450 -// Distant terrain (renderer/distant): section-local boxes textured with -// their block's baked model (Voxy's `quads3.vert`, `quad_util.glsl`), lit +// Distant terrain (renderer/distant): Voxy's `quads3.vert` and +// `quad_util.glsl`. Each quad is eight bytes in the bound arena, read by +// `gl_VertexIndex >> 2`; its corners come from its model's face data, lit // through the lightmap and fogged like terrain. #include "fog.glsl" @@ -12,90 +13,102 @@ layout(push_constant) uniform DistantPc { // The camera's view projection with the distant near and far planes. mat4 distant_view_proj; - // The section's corner (at the dimension's min_y), relative to the - // camera anchor, and its column width in blocks. - vec4 origin; + // The section's corner relative to the camera anchor, and its level's + // scale: blocks per voxel. + vec4 origin_scale; }; -layout(location = 0) in uint in_xz; -layout(location = 1) in uint in_y; -layout(location = 2) in uvec2 in_light_face; -layout(location = 3) in vec4 in_color; -layout(location = 4) in uint in_model; -layout(location = 5) in uvec2 in_flags; +layout(set = 1, binding = 0) readonly buffer Quads { + uvec2 quads[]; +}; +layout(set = 1, binding = 1) readonly buffer Models { + BlockModel models[]; +}; +// Biome tints by kind (distant_models.glsl TINT_BIOMES per kind). +layout(set = 1, binding = 2) readonly buffer Tints { + uint tints[]; +}; + +// Voxy's `interData`: quad flags (cutout bit 0, tint state 2-3, face 4-6, +// width-1 8-11, height-1 12-15, model 16-31), the lightmap colour, the +// tint colour. +layout(location = 0) flat out uvec3 v_data; +layout(location = 1) out vec2 v_uv; +layout(location = 2) out vec3 v_fog_color; +layout(location = 3) out float v_fog; +// Anchor-relative x/z and the anchor: what distant.frag finds the column's +// chunk from. +layout(location = 4) out vec2 v_column_pos; +layout(location = 5) flat out ivec2 v_anchor; -// The tint over the texture, or with no model the colour, and its alpha. -layout(location = 0) out vec4 v_color; -layout(location = 1) out vec3 v_fog_color; -layout(location = 2) out float v_fog; -// Anchor-relative x/z, nudged half a block into the face's own column, and -// the anchor: what distant.frag finds the column's chunk from. -layout(location = 3) out vec2 v_column_pos; -layout(location = 4) flat out ivec2 v_anchor; -// A side face's step from v_column_pos into the column it faces; zero for -// tops and bottoms. -layout(location = 5) flat out vec2 v_outward; -// 1 for a seam (mesh.rs `mesh_section`). -layout(location = 6) flat out uint v_seam; -// The lightmap's colour under the face's directional shade. -layout(location = 7) out vec3 v_light; -// Where on its face texture the fragment is, a texture per column: the -// integer part steps a column, the fraction is the texel. -layout(location = 8) out vec2 v_uv; -// The face texture's corner in the model atlas, in texels. -layout(location = 9) flat out vec2 v_tile; -// The face's flags (distant_models.glsl). -layout(location = 10) flat out uint v_flags; +// Vanilla's directional shade (the overworld's cardinal lighting), by face. +const float FACE_SHADE[6] = float[](0.5, 1.0, 0.8, 0.8, 0.6, 0.6); -// Distant Horizons' micro offset: how far a corner moves out along its -// quad's plane. -const float MICRO_OFFSET = 0.01; +uint field(uvec2 quad, uint bits, uint shift) { + uint word = shift < 32u ? quad.x : quad.y; + return (word >> (shift & 31u)) & ((1u << bits) - 1u); +} -// mesh.rs `Face`: up, down, north, south, west, east. -const vec2 FACE_NUDGE[6] = vec2[]( - vec2(0.0), vec2(0.0), - vec2(0.0, 0.5), vec2(0.0, -0.5), - vec2(0.5, 0.0), vec2(-0.5, 0.0) -); -// Vanilla's directional shade (the overworld's cardinal lighting). -const float FACE_SHADE[6] = float[](1.0, 0.5, 0.8, 0.8, 0.6, 0.6); -// The face's index in its model's (mesh.rs `Face::voxy_index`). -const uint MODEL_FACE[6] = uint[](1u, 0u, 2u, 3u, 4u, 5u); -// Per model face, the block axes its texture's x and y run along -// (bakery.rs `TILE_AXES`). -const ivec2 TILE_AXES[6] = ivec2[]( - ivec2(0, 2), ivec2(0, 2), ivec2(0, 1), ivec2(0, 1), ivec2(1, 2), ivec2(1, 2) -); -// A corner's micro offset along one axis, from its 2 bits (mesh.rs -// `DistantVertex::face`). -float micro_offset(uint bits) { - return (bits & 1u) == 0u ? 0.0 : ((bits & 2u) == 0u ? MICRO_OFFSET : -MICRO_OFFSET); +// `swizzelDataAxis`: a plane's two extents and its depth put on the axes the +// face's normal leaves free. +vec3 swizzle(uint axis, vec3 data) { + return axis == 0u ? data.xzy : (axis == 1u ? data : data.zxy); } void main() { - vec3 local = vec3(float(in_xz & 0xFFFFu), float(in_y), float(in_xz >> 16)); - uint face = min(in_light_face.y & 7u, 5u); - uint model_face = MODEL_FACE[face]; - vec3 columns = local / origin.w; - v_uv = vec2(columns[TILE_AXES[model_face].x], columns[TILE_AXES[model_face].y]); - v_tile = vec2(in_model % ATLAS_COLUMNS, in_model / ATLAS_COLUMNS) * TILE_SIZE - + vec2(model_face >> 1u, model_face & 1u) * FACE_SIZE; - v_flags = in_flags.x | (in_model == 0u ? 0u : TEXTURED); + uvec2 quad = quads[gl_VertexIndex >> 2]; + uint corner = gl_VertexIndex & 3u; + uint face = field(quad, 3u, 0u); + uint axis = face >> 1u; + uint model_id = field(quad, 6u, 26u) | field(quad, 14u, 32u) << 6u; + BlockModel model = models[model_id]; + uint face_data = model.face_data[face]; + vec2 size = vec2(field(quad, 4u, 3u), field(quad, 4u, 7u)) + 1.0; + + // `getFaceSize`: the drawn texels' span, grown a hair so quads meet. + vec4 bounds = vec4(face_data & 0xFu, face_data >> 4u & 0xFu, face_data >> 8u & 0xFu, face_data >> 12u & 0xFu) / 16.0 + + vec4(0.0, 1.0 / 16.0, 0.0, 1.0 / 16.0); + bounds.xz -= 0.00005; + bounds.yw -= bounds.xz; + uint inset = face_data >> 16u & 63u; + inset += uint(inset == 63u); + float depth = float(inset) / 64.0; - local.xz += vec2(micro_offset(in_light_face.y >> 3), micro_offset(in_light_face.y >> 5)); - vec3 world = origin.xyz + local; - vec3 rel = world - camera_pos.xyz; + vec3 start = vec3(field(quad, 5u, 21u), field(quad, 5u, 16u), field(quad, 5u, 11u)); + start += swizzle(axis, vec3(bounds.xz, mix(depth, 1.0 - depth, float(face & 1u)))); + vec2 extent = bounds.yw + size - 1.0; + vec2 mask = vec2(corner >> 1u & 1u, corner & 1u); + vec3 point = origin_scale.xyz + (start + swizzle(axis, vec3(extent * mask, 0.0))) * origin_scale.w; + vec3 rel = point - camera_pos.xyz; gl_Position = distant_view_proj * vec4(rel, 1.0); + v_uv = bounds.xz + extent * mask; + + // `makeQuadFlags`: a face short of its side cuts out once a quad spans + // more than one block. + uint flags = face_data >> 22u & 1u; + flags |= uint(any(greaterThan(size, vec2(1.0)))) & (face_data >> 23u & 1u); + flags |= uint((model.flags & MODEL_TRANSLUCENT) != 0u) << 1u; + flags |= (face_data >> 24u & 3u) << 2u; + flags |= face << 4u; + flags |= (uint(size.x) - 1u) << 8u | (uint(size.y) - 1u) << 12u; + flags |= model_id << 16u; + + // `makeRemainingAttributes`: the quad's light (block high, sky low) + // through the lightmap, shaded per face for models with ambient + // occlusion; the model's tint, constant or by the quad's biome. + uint light = field(quad, 8u, 55u); + vec3 lit = sample_lightmap(vec2(float(light >> 4u), float(light & 0xFu)) * 16.0).rgb; + if ((model.flags & MODEL_SHADED) != 0u) { + lit *= FACE_SHADE[face]; + } + uint tint = model.tint; + if ((model.flags & MODEL_BIOME_TINT) != 0u) { + tint = tints[tint * TINT_BIOMES + field(quad, 9u, 46u)]; + } + v_data = uvec3(flags, packUnorm4x8(vec4(lit, 1.0)), tint); - uint light = in_light_face.x; - vec2 lightmap_uv = vec2(float(light & 0xFu), float(light >> 4)) * 16.0; - v_light = sample_lightmap(lightmap_uv).rgb * FACE_SHADE[face]; - v_color = in_color; v_fog_color = fog_color.rgb; v_fog = total_fog_value(rel, fog_env, camera_pos.w, fog_color.w); - vec2 nudge = FACE_NUDGE[face]; - v_column_pos = world.xz + nudge; + v_column_pos = point.xz; v_anchor = camera_block.xz; - v_outward = -2.0 * nudge; - v_seam = in_light_face.y >> 7; } diff --git a/pomme-client/src/renderer/shaders/distant_models.glsl b/pomme-client/src/renderer/shaders/distant_models.glsl index 7eda81a0a..03ea9dac0 100644 --- a/pomme-client/src/renderer/shaders/distant_models.glsl +++ b/pomme-client/src/renderer/shaders/distant_models.glsl @@ -1,11 +1,22 @@ -// Distant terrain's model atlas (renderer/distant/models.rs): a tile per -// model, its six face textures two rows of three. +// Distant terrain's models (renderer/distant/models.rs): the atlas, a tile +// per model with its six face textures two rows of three, and Voxy's +// `block_model.glsl` record of each. const uint ATLAS_COLUMNS = 64u; const vec2 TILE_SIZE = vec2(48.0, 32.0); const vec2 ATLAS_SIZE = vec2(ATLAS_COLUMNS, 32u) * TILE_SIZE; const float FACE_SIZE = 16.0; -// models.rs face flags, and the one distant.vert adds for a textured model. -const uint FACE_DISCARD = 1u; -const uint FACE_TRANSLUCENT = 0x10u; -const uint TEXTURED = 0x100u; +// Per face: the drawn texels' min x, max x, min y, max y in 4 bits each, +// the inset from its side in 64ths from bit 16, cutout at 22 and +// cutout-once-merged at 23, the tint state in 24-25. +struct BlockModel { + uint face_data[6]; + uint flags; + // ABGR with MODEL_BIOME_TINT clear, else the tint kind. + uint tint; +}; + +const uint MODEL_BIOME_TINT = 2u; +const uint MODEL_TRANSLUCENT = 4u; +const uint MODEL_SHADED = 8u; +const uint TINT_BIOMES = 512u; diff --git a/pomme-client/src/world/block/mod.rs b/pomme-client/src/world/block/mod.rs index 427e6e55f..2febecc97 100644 --- a/pomme-client/src/world/block/mod.rs +++ b/pomme-client/src/world/block/mod.rs @@ -261,6 +261,32 @@ impl BlockStateData { self.id.ends_with("_leaves") } + /// Vanilla `LiquidBlock`: the fluid itself, not a block holding one. + pub(crate) fn is_fluid_block(&self) -> bool { + matches!(self.id, "water" | "lava") + } + + /// Vanilla `skipRendering` against a neighbour of its own kind on every + /// side: `HalfTransparentBlock`, `LiquidBlock`, mangrove roots and + /// powder snow. + pub(crate) fn culls_same(&self) -> bool { + matches!( + self.id, + "water" + | "lava" + | "glass" + | "tinted_glass" + | "ice" + | "frosted_ice" + | "blue_ice" + | "slime_block" + | "honey_block" + | "mangrove_roots" + | "powder_snow" + ) || self.id.ends_with("_stained_glass") + || self.id.ends_with("copper_grate") + } + /// `getRenderShape() == RenderShape.INVISIBLE`: the blocks overriding it. pub(crate) fn is_invisible(&self) -> bool { self.is_air diff --git a/tests/ingame/world/distant_terrain.toml b/tests/ingame/world/distant_terrain.toml index 45f082f3b..d4f183466 100644 --- a/tests/ingame/world/distant_terrain.toml +++ b/tests/ingame/world/distant_terrain.toml @@ -33,6 +33,10 @@ chat = "/tp @s 200.5 -50 1.5" expect = { player = { position = [200.5, -50.0, 1.5] } } timeout_ms = 20000 +# Sections are meshed as they come into view. +[[step]] +look_at = [1.5, -40.0, 1.5] + [[step]] expect = { distant_terrain = { at = [1, 1] } } timeout_ms = 20000 @@ -57,6 +61,10 @@ chat = "/tp @s 200.5 -50 1.5" expect = { player = { position = [200.5, -50.0, 1.5] } } timeout_ms = 20000 +# Sections are meshed as they come into view. +[[step]] +look_at = [1.5, -40.0, 1.5] + [[step]] expect = { distant_terrain = { at = [1, 1] } } timeout_ms = 20000 From 47d4f165210c2aed46d123caa41072f0377f2c44 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:13:44 +0000 Subject: [PATCH 21/67] bound distant terrain by the drawn chunks, blit it under the frame and draw it indirect --- pomme-client/build.rs | 4 + pomme-client/src/app/core.rs | 2 +- pomme-client/src/app/phases/in_game.rs | 10 +- pomme-client/src/harness/script.rs | 69 ++- pomme-client/src/renderer/chunk/buffer.rs | 13 +- pomme-client/src/renderer/distant/gpu.rs | 552 +++++++++++++----- pomme-client/src/renderer/mod.rs | 84 ++- .../src/renderer/pipelines/distant.rs | 538 +++++++++++++++-- pomme-client/src/renderer/rhi/mod.rs | 4 +- pomme-client/src/renderer/rhi/vulkan/mod.rs | 4 + .../src/renderer/rhi/vulkan/swapchain.rs | 18 + .../src/renderer/shaders/distant.frag | 29 +- .../src/renderer/shaders/distant.vert | 32 +- .../src/renderer/shaders/distant_blit.frag | 47 ++ .../src/renderer/shaders/distant_bound.frag | 5 + .../src/renderer/shaders/distant_bound.vert | 37 ++ .../src/renderer/shaders/distant_setup.frag | 19 + pomme-client/src/world/chunk.rs | 5 - tests/ingame/README.md | 6 + 19 files changed, 1185 insertions(+), 293 deletions(-) create mode 100644 pomme-client/src/renderer/shaders/distant_blit.frag create mode 100644 pomme-client/src/renderer/shaders/distant_bound.frag create mode 100644 pomme-client/src/renderer/shaders/distant_bound.vert create mode 100644 pomme-client/src/renderer/shaders/distant_setup.frag diff --git a/pomme-client/build.rs b/pomme-client/build.rs index 409214037..1c7ed8c0c 100644 --- a/pomme-client/build.rs +++ b/pomme-client/build.rs @@ -100,6 +100,10 @@ fn main() { ("water.frag", shaderc::ShaderKind::Fragment), ("distant.vert", shaderc::ShaderKind::Vertex), ("distant.frag", shaderc::ShaderKind::Fragment), + ("distant_bound.vert", shaderc::ShaderKind::Vertex), + ("distant_bound.frag", shaderc::ShaderKind::Fragment), + ("distant_setup.frag", shaderc::ShaderKind::Fragment), + ("distant_blit.frag", shaderc::ShaderKind::Fragment), ("cube.vert", shaderc::ShaderKind::Vertex), ("cube.frag", shaderc::ShaderKind::Fragment), ("panorama.vert", shaderc::ShaderKind::Vertex), diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index ab593aed1..4cc41acd1 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -1823,7 +1823,7 @@ impl AppCore { NetworkEvent::ChunkUnloaded { pos } => { game.unload_chunk(pos); renderer.remove_chunk_mesh(&pos); - renderer.set_distant_column_drawn(pos, false); + renderer.set_distant_column_sections(pos, 0, 0); } NetworkEvent::ChunkCacheCenter { x, z } => { tracing::debug!("Chunk cache center: [{x}, {z}]"); diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 4099d9f52..951854847 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -1317,11 +1317,13 @@ impl GameState { let dropped = renderer.upload_chunk_meshes(&batch); self.last_update_phases.upload_ms = upload_start.elapsed().as_secs_f32() * 1000.0; self.clear_dropped_meshed(dropped); - // Distant terrain gives way once a column draws, not once it loads: - // the ring loaded around the view waits on its neighbours to compile. + // Distant terrain gives way once a column's sections draw, not once + // they load: the ring loaded around the view waits on its + // neighbours to compile. + let min_section = self.chunk_store.min_y().div_euclid(16); for mesh in &batch { - let drawn = self.compiled.get(&mesh.pos).is_some_and(|mask| *mask != 0); - renderer.set_distant_column_drawn(mesh.pos, drawn); + let sections = self.compiled.get(&mesh.pos).copied().unwrap_or(0); + renderer.set_distant_column_sections(mesh.pos, sections, min_section); } } diff --git a/pomme-client/src/harness/script.rs b/pomme-client/src/harness/script.rs index 9db80ef40..14bd4eb58 100644 --- a/pomme-client/src/harness/script.rs +++ b/pomme-client/src/harness/script.rs @@ -53,6 +53,7 @@ struct StepFile { scroll: Option, text: Option, wait_ms: Option, + frame_times: Option, screenshot: Option, match_screenshots: Option<[String; 2]>, expect: Option, @@ -61,6 +62,14 @@ struct StepFile { max_differing_pixels: Option, } +/// Frames timed over `ms`: the report gets the frame rate and where the +/// time went. +#[derive(Debug, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +struct FrameTimes { + ms: u64, +} + /// What an `expect` step waits for, told apart by its fields. #[derive(Debug, PartialEq, Deserialize)] #[serde(untagged)] @@ -275,6 +284,7 @@ struct ChatExpect { enum Step { Run(Command), Wait(Duration), + FrameTimes(Duration), Screenshot(String), /// Two of the script's screenshots, which must match pixel for pixel to /// within [`MATCH_TOLERANCE`], but for at most this many pixels. @@ -337,6 +347,9 @@ impl StepFile { if let Some(ms) = self.wait_ms { steps.push(Step::Wait(Duration::from_millis(ms))); } + if let Some(FrameTimes { ms }) = self.frame_times { + steps.push(Step::FrameTimes(Duration::from_millis(ms))); + } if let Some(name) = self.screenshot { steps.push(Step::Screenshot(name)); } @@ -372,6 +385,35 @@ struct StepReport { ok: bool, error: Option, elapsed_ms: u128, + /// What a `frame_times` step measured. + info: Option, +} + +/// One frame of a `frame_times` step: the wall time since the last, and +/// the renderer's own split of it. +struct FrameSample { + interval_ms: f32, + frame_ms: f32, + fence_ms: f32, +} + +/// `samples` summed up: the frame rate, its 1% low, and the CPU and GPU-wait +/// shares of a frame. +fn summarize(samples: &[FrameSample]) -> String { + let mean = + |f: fn(&FrameSample) -> f32| samples.iter().map(f).sum::() / samples.len() as f32; + let mut intervals: Vec = samples.iter().map(|s| s.interval_ms).collect(); + intervals.sort_by(f32::total_cmp); + let slowest = &intervals[intervals.len() - intervals.len().div_ceil(100)..]; + let low = slowest.iter().sum::() / slowest.len() as f32; + format!( + "{} frames: {:.1} fps, 1% low {:.1} fps; cpu {:.2} ms, gpu wait {:.2} ms", + samples.len(), + 1000.0 / mean(|s| s.interval_ms), + 1000.0 / low, + mean(|s| s.frame_ms), + mean(|s| s.fence_ms), + ) } #[derive(Serialize)] @@ -390,6 +432,9 @@ pub struct Script { step_started: Option, /// The screenshot the current step is waiting on. pending_shot: Option, + /// The frames a `frame_times` step has seen, and when the last was. + samples: Vec, + last_frame: Option, reports: Vec, } @@ -410,6 +455,8 @@ impl Script { next: 0, step_started: None, pending_shot: None, + samples: Vec::new(), + last_frame: None, reports: Vec::new(), }) } @@ -436,6 +483,19 @@ impl Script { Outcome::Screenshot(_) => unreachable!("screenshots are their own step"), }, Step::Wait(duration) => (started.elapsed() >= *duration).then_some(Ok(())), + Step::FrameTimes(duration) => { + let now = Instant::now(); + if let (Some(last), AppPhase::InGame { gfx, .. }) = (self.last_frame, &*phase) { + let timings = gfx.renderer.last_timings(); + self.samples.push(FrameSample { + interval_ms: (now - last).as_secs_f32() * 1000.0, + frame_ms: timings.frame_ms, + fence_ms: timings.fence_ms, + }); + } + self.last_frame = Some(now); + (started.elapsed() >= *duration && !self.samples.is_empty()).then_some(Ok(())) + } Step::Screenshot(name) => { let path = match &self.pending_shot { Some(path) => path.clone(), @@ -456,16 +516,20 @@ impl Script { }, }; let result = result?; + let info = matches!(step, Step::FrameTimes(_)).then(|| summarize(&self.samples)); self.reports.push(StepReport { step: self.next + 1, action: format!("{step:?}"), ok: result.is_ok(), error: result.as_ref().err().cloned(), elapsed_ms: started.elapsed().as_millis(), + info, }); self.next += 1; self.step_started = None; self.pending_shot = None; + self.samples.clear(); + self.last_frame = None; match result { Ok(()) => None, Err(_) => Some(self.finish(false)), @@ -511,6 +575,7 @@ impl Script { ok: false, error: Some(error), elapsed_ms: self.started.elapsed().as_millis(), + info: None, }); self.finish(false) } @@ -522,8 +587,8 @@ impl Script { "[{}] step {} {mark}: {}", self.name, report.step, report.action ); - if let Some(error) = &report.error { - println!(" {error}"); + if let Some(line) = report.error.as_ref().or(report.info.as_ref()) { + println!(" {line}"); } } let report = Report { diff --git a/pomme-client/src/renderer/chunk/buffer.rs b/pomme-client/src/renderer/chunk/buffer.rs index 35d5a0d3a..2087aeb33 100644 --- a/pomme-client/src/renderer/chunk/buffer.rs +++ b/pomme-client/src/renderer/chunk/buffer.rs @@ -207,14 +207,15 @@ const fn chunk_vertex_buffers( ] } +/// `VkDrawIndexedIndirectCommand`. #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] -struct DrawCommand { - index_count: u32, - instance_count: u32, - first_index: u32, - vertex_offset: i32, - first_instance: u32, +pub(crate) struct DrawCommand { + pub index_count: u32, + pub instance_count: u32, + pub first_index: u32, + pub vertex_offset: i32, + pub first_instance: u32, } /// Camera-relative frustum test of a section-local AABB, mirroring `cull.comp` diff --git a/pomme-client/src/renderer/distant/gpu.rs b/pomme-client/src/renderer/distant/gpu.rs index 9a3c63226..72179dde4 100644 --- a/pomme-client/src/renderer/distant/gpu.rs +++ b/pomme-client/src/renderer/distant/gpu.rs @@ -21,9 +21,10 @@ use super::section::{LEVELS, SIDE, SectionKey}; use super::worker::DistantUpdate; use crate::position::ChunkPos; use crate::renderer::camera::Camera; +use crate::renderer::chunk::buffer::DrawCommand; use crate::renderer::chunk::tlsf::Tlsf; +use crate::renderer::rhi::IndirectDrawFeatures; use crate::renderer::{MAX_FRAMES_IN_FLIGHT, rhi, util}; -use crate::world::chunk::{RING_SIDE, ring_slot}; /// Quads an arena holds: 64 MB at 8 bytes each. const ARENA_QUADS: u32 = 8 << 20; @@ -31,10 +32,6 @@ const ARENA_QUADS: u32 = 8 << 20; const MAX_ARENAS: usize = 8; /// Quads the shared index buffer covers, the most a group can hold. const QUADS_PER_DRAW: u32 = 1 << 16; -/// `distant.frag` hard-codes the ring's side. -const _: () = assert!(RING_SIDE == 257); -const MASK_WORDS: usize = ((RING_SIDE * RING_SIDE) as usize).div_ceil(32); -const MASK_BYTES: u64 = (MASK_WORDS * size_of::()) as u64; const MODELS_BYTES: u64 = (CAPACITY * size_of::()) as u64; const TINTS_BYTES: u64 = (TINT_BIOMES * TINT_KINDS * size_of::()) as u64; /// A model tile's bytes, every level. @@ -59,7 +56,13 @@ const MAX_REQUESTS: usize = 4000; /// How long a request waits before it's asked again. const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); /// Voxy's far plane: 3000 chunks. -const FAR: f32 = 3000.0 * 16.0; +pub(crate) const DISTANT_FAR: f32 = 3000.0 * 16.0; +/// Drawn chunk sections the bound store starts with room for. +const BOUND_CAPACITY: u32 = 1 << 15; +/// Sections a frame may draw, and the draws they may take (one per group). +const MAX_DRAW_SECTIONS: usize = 1 << 15; +const MAX_DRAWS: usize = MAX_DRAW_SECTIONS * GROUPS; +pub(crate) const POSITIONS_BYTES: u64 = (MAX_DRAW_SECTIONS * 16) as u64; /// A quad buffer, sub-allocated, with the bind group that reads it. struct Arena { @@ -79,6 +82,16 @@ struct Uploaded { /// [`BuiltSection::aabb`]. aabb: u32, child_existence: u8, + /// The last frame drawn, for evicting the least recent. + last_drawn: u64, +} + +/// One arena's share of a frame's draw list: where its opaque and its +/// translucent commands start, and how many. +#[derive(Clone, Copy, Default)] +struct ArenaDraws { + opaque: (u32, u32), + translucent: (u32, u32), } impl Uploaded { @@ -123,14 +136,21 @@ pub(crate) struct DistantBuffers { /// Sections dropped since the last [`Self::take_requests`], for the /// worker to stop keeping up to date. dropped: Vec, - /// Each section's last frame drawn, for evicting the least recent. - last_drawn: HashMap, /// The sections to draw this frame, from the last traversal. - visible: Vec, + visible: Vec<(SectionKey, Uploaded)>, + /// The ring of top-level sections the last traversal walked: its + /// centre, radius and height range, so sections outside it are swept + /// only when it moves. + ring: Option<(i32, i32, i32, i32, i32)>, + indirect: IndirectDrawFeatures, + /// Per frame in flight, the draw list (`cmdBuffer`) and each draw's + /// section origin and scale (`positionBuffer`), by `first_instance`. + commands: Vec, + positions: Vec, + /// Each arena's draws in the current frame's list. + draws: Vec, view: Option, - /// Bit per [`ring_slot`]: a real chunk draws there. - mask: rhi::backend::Buffer, - mask_words: Vec, + bounds: BoundStore, frame_seq: u64, /// Freed quad ranges as (arena, first quad), by the frame from which no /// in-flight frame can still draw them. @@ -148,19 +168,17 @@ pub(crate) struct DistantBuffers { } /// Set 1: the arena's quads, the models, the biome tints, the model atlas -/// and its sampler, then the drawn-column mask `distant.frag` discards -/// with. +/// and its sampler. const LAYOUT: &[rhi::BindGroupLayoutEntry] = &[ rhi::BindGroupLayoutEntry::storage(rhi::ShaderStages::VERTEX), rhi::BindGroupLayoutEntry::storage(rhi::ShaderStages::VERTEX), rhi::BindGroupLayoutEntry::storage(rhi::ShaderStages::VERTEX), rhi::BindGroupLayoutEntry::texture(rhi::ShaderStages::FRAGMENT), rhi::BindGroupLayoutEntry::sampler(rhi::ShaderStages::FRAGMENT), - rhi::BindGroupLayoutEntry::storage(rhi::ShaderStages::FRAGMENT), ]; impl DistantBuffers { - pub fn new(device: &rhi::backend::Device) -> Self { + pub fn new(device: &rhi::backend::Device, indirect: IndirectDrawFeatures) -> Self { // Voxy's `SharedIndexBuffer`: corner 1 leads both triangles, so it's // the provoking vertex the flat attributes come from. let quad_indices: Vec = (0..QUADS_PER_DRAW) @@ -213,11 +231,24 @@ impl DistantBuffers { requested: HashMap::new(), requests: Vec::new(), dropped: Vec::new(), - last_drawn: HashMap::new(), visible: Vec::new(), + ring: None, + indirect, + commands: util::create_per_frame_host_buffers( + device, + (MAX_DRAWS * size_of::()) as u64, + rhi::BufferUsages::INDIRECT, + "distant_draws", + ), + positions: util::create_per_frame_host_buffers( + device, + POSITIONS_BYTES, + rhi::BufferUsages::STORAGE, + "distant_draw_positions", + ), + draws: Vec::new(), view: None, - mask: storage("distant_mask", MASK_BYTES), - mask_words: vec![0; MASK_WORDS], + bounds: BoundStore::new(device), frame_seq: 0, pending_free: VecDeque::new(), model_atlas, @@ -279,10 +310,6 @@ impl DistantBuffers { 4, rhi::backend::BindingResource::Sampler(self.model_sampler), ), - ( - 5, - rhi::backend::BindingResource::storage(&self.mask, MASK_BYTES), - ), ], ); self.arenas.push(Arena { @@ -336,10 +363,15 @@ impl DistantBuffers { )]); } - /// Reclaims the ranges no in-flight frame reads any more. Call once a - /// frame, after its fence wait. - pub fn begin_frame(&mut self) { + pub fn bounds(&self) -> &BoundStore { + &self.bounds + } + + /// Reclaims the ranges and buffers no in-flight frame reads any more. + /// Call once a frame, after its fence wait. + pub fn begin_frame(&mut self, device: &rhi::backend::Device) { self.frame_seq += 1; + self.bounds.free_retired(device, self.frame_seq); while let Some(&(at, arena, ptr)) = self.pending_free.front() && at <= self.frame_seq { @@ -356,11 +388,10 @@ impl DistantBuffers { let at = self.frame_seq + MAX_FRAMES_IN_FLIGHT as u64; self.pending_free.push_back((at, old.arena, old.ptr)); } - self.last_drawn.remove(&key); } - /// Drops every section, as on a dimension change. The mask stays: it - /// tracks the chunk store, which [`Self::clear_mask`] follows. + /// Drops every section, as on a dimension change. The bounds stay: they + /// track the chunk store, which [`Self::clear_bounds`] follows. pub fn clear(&mut self) { let keys: Vec = self.sections.keys().copied().collect(); for key in keys { @@ -370,25 +401,26 @@ impl DistantBuffers { self.requests.clear(); self.dropped.clear(); self.visible.clear(); + self.ring = None; self.view = None; } - pub fn clear_mask(&mut self) { - self.mask_words.fill(0); - self.mask.zero(); + pub fn clear_bounds(&mut self) { + self.bounds.clear(); } - pub fn set_column_drawn(&mut self, pos: ChunkPos, drawn: bool) { - let slot = ring_slot(pos); - let (word, bit) = (slot / 32, 1u32 << (slot % 32)); - let value = &mut self.mask_words[word]; - if drawn { - *value |= bit; - } else { - *value &= !bit; - } - self.mask - .write_at(word * size_of::(), &value.to_ne_bytes()); + /// Which of column `pos`'s sections draw (bit `i` for the `i`th from + /// the dimension's bottom, `min_section`), bounding distant terrain + /// there. + pub fn set_column_sections( + &mut self, + device: &rhi::backend::Device, + pos: ChunkPos, + sections: u32, + min_section: i32, + ) { + self.bounds + .set_column(device, self.frame_seq, pos, sections, min_section); } /// Where the player is and how far to draw; `None` draws nothing. @@ -428,7 +460,7 @@ impl DistantBuffers { .sections .iter() .filter(|(key, section)| section.quads > 0 && key.level != LEVELS - 1) - .map(|(key, _)| (self.last_drawn.get(key).copied().unwrap_or(0), *key)) + .map(|(key, section)| (section.last_drawn, *key)) .collect(); if stale.is_empty() { return None; @@ -511,6 +543,7 @@ impl DistantBuffers { offsets: section.offsets, aabb: section.aabb, child_existence: section.child_existence, + last_drawn: self.frame_seq, }, ); } @@ -539,10 +572,17 @@ impl DistantBuffers { /// Whether every child `key` has is known, so the traversal can step /// down into them (Voxy's child request finishing). - fn children_ready(&self, key: SectionKey) -> bool { - self.sections.get(&key).is_some_and(|section| { - children(key, section.child_existence).all(|child| self.sections.contains_key(&child)) - }) + fn children_ready(&self, key: SectionKey, existence: u8) -> bool { + children(key, existence).all(|child| self.sections.contains_key(&child)) + } + + /// Draws `key` this frame. + fn show(&mut self, key: SectionKey, section: Uploaded) { + self.sections + .get_mut(&key) + .expect("a copy of a known section") + .last_drawn = self.frame_seq; + self.visible.push((key, section)); } /// Whether the finest section holding block column `x`/`z` is uploaded @@ -568,7 +608,7 @@ impl DistantBuffers { return; }; let now = Instant::now(); - let view_proj = camera.distant_view_projection(FAR); + let view_proj = camera.distant_view_projection(DISTANT_FAR); let planes = Camera::planes_from_view_projection(view_proj); let min_sss = SUBDIVISION_SIZE * SUBDIVISION_SIZE / (viewport[0] * viewport[1]) as f32; let render_distance = view.radius * view.radius; @@ -587,33 +627,34 @@ impl DistantBuffers { (view.min_y + view.height - 1).div_euclid(top_width), ); let mut queue: VecDeque = VecDeque::new(); - let mut roots = HashSet::new(); for x in cx - ring..=cx + ring { for z in cz - ring..=cz + ring { for y in min_sec..=max_sec { - let key = SectionKey { + queue.push_back(SectionKey { level: top, x, y, z, - }; - roots.insert(key); - queue.push_back(key); + }); } } } - let gone: Vec = self - .sections - .keys() - .copied() - .filter(|key| !roots.contains(&key.ancestor(top))) - .collect(); - for key in gone { - self.retire(key); - self.dropped.push(key); + if self.ring != Some((cx, cz, ring, min_sec, max_sec)) { + self.ring = Some((cx, cz, ring, min_sec, max_sec)); + let roots: HashSet = queue.iter().copied().collect(); + let gone: Vec = self + .sections + .keys() + .copied() + .filter(|key| !roots.contains(&key.ancestor(top))) + .collect(); + for key in gone { + self.retire(key); + self.dropped.push(key); + } + self.requested + .retain(|key, _| roots.contains(&key.ancestor(top))); } - self.requested - .retain(|key, _| roots.contains(&key.ancestor(top))); while let Some(key) = queue.pop_front() { let min = origin(&key) - eye; @@ -629,19 +670,16 @@ impl DistantBuffers { if !in_frustum(&planes, min.as_vec3(), max.as_vec3()) { continue; } - let has_mesh = self.sections.contains_key(&key); + let section = self.sections.get(&key).copied(); if key.level != 0 && screen_size(view_proj, min.as_vec3(), size as f32) > min_sss { - if self.children_ready(key) { - let existence = self.sections[&key].child_existence; - queue.extend(children(key, existence)); + if let Some(section) = section + && self.children_ready(key, section.child_existence) + { + queue.extend(children(key, section.child_existence)); } else { - match self - .sections - .get(&key) - .map(|section| section.child_existence) - { - Some(existence) => { - for child in children(key, existence) { + match section { + Some(section) => { + for child in children(key, section.child_existence) { if !self.sections.contains_key(&child) { self.request(child, now); } @@ -661,94 +699,87 @@ impl DistantBuffers { } }; let (fx, fz) = (far(min.x, max.x), far(min.z, max.z)); - if has_mesh && fx * fx + fz * fz + 16.0 * 16.0 * 16.0 <= render_distance { - self.visible.push(key); + if let Some(section) = section + && fx * fx + fz * fz + 16.0 * 16.0 * 16.0 <= render_distance + { + self.show(key, section); } } - } else if has_mesh { - self.visible.push(key); + } else if let Some(section) = section { + self.show(key, section); } else { self.request(key, now); } } - for key in &self.visible { - self.last_drawn.insert(*key, self.frame_seq); - } } - /// Draws the opaque or see-through groups of the visible sections - /// (`cmdgen.comp`, `buildtranslucents.comp`): face groups turned away - /// from the camera are left out; translucent sections go far to near. - /// The caller has bound the matching distant pipeline. - pub fn draw( - &self, - pass: &rhi::backend::RenderPassEncoder, - camera: &Camera, - eye: DVec3, - translucent: bool, - ) { - if self.visible.is_empty() { - return; - } - let view_proj = camera.distant_view_projection(FAR); - let planes = Camera::planes_from_view_projection(view_proj); - let anchor = camera.anchor(); - pass.set_push_constants( - rhi::ShaderStages::VERTEX, - 0, - bytemuck::cast_slice(&view_proj.to_cols_array()), - ); - pass.set_index_buffer(self.index_buffer.handle()); + pub fn position_buffer(&self, frame: usize) -> &rhi::backend::Buffer { + &self.positions[frame] + } + + /// `cmdgen.comp` and `buildtranslucents.comp` on the CPU, until the GPU + /// traversal: the visible sections' draws into `frame`'s list, an arena + /// at a time, face groups turned away from the camera left out, each + /// draw's section in the position list by its `first_instance`; the + /// translucent draws after, far to near within each arena. + pub fn build_draws(&mut self, frame: usize, camera: &Camera, eye: DVec3) { + let eye_offset = (eye - camera.anchor()).as_vec3(); let camera_section = [eye.x, eye.y, eye.z].map(|c| (c.floor() as i32).div_euclid(SIDE as i32)); - let relative = |key: &SectionKey| { - [key.x, key.y, key.z] - .into_iter() - .zip(camera_section) - .map(|(c, cam)| c - (cam >> key.level)) - .collect::>() - }; + let view_proj = camera.distant_view_projection(DISTANT_FAR); + let planes = Camera::planes_from_view_projection(view_proj); // Sections whose quads all lie outside the view go, as Voxy's raster // cull of their bounds drops them until the GPU traversal lands. - let mut order: Vec<&SectionKey> = self + // The rest by arena, each with its anchor-relative origin and scale + // (its position entry) and where it stands from the camera's + // section at its level. + let mut visible: Vec<(Uploaded, [f32; 4], [i32; 3])> = self .visible .iter() - .filter(|key| { - let section = &self.sections[key]; - if section.quads == 0 || (translucent && section.group(0).1 == 0) { - return false; + .filter_map(|&(key, section)| { + if section.quads == 0 { + return None; } - let corner = (origin(key) - eye).as_vec3(); + let origin = (origin(&key) - camera.anchor()).as_vec3(); + let corner = origin - eye_offset; let (min, max) = section.bounds(); let scale = (1 << key.level) as f32; - in_frustum(&planes, corner + min * scale, corner + max * scale) + let cam = camera_section.map(|c| c >> key.level); + in_frustum(&planes, corner + min * scale, corner + max * scale).then(|| { + ( + section, + [origin.x, origin.y, origin.z, scale], + [key.x - cam[0], key.y - cam[1], key.z - cam[2]], + ) + }) }) + .take(MAX_DRAW_SECTIONS) .collect(); - if translucent { - order.sort_by_key(|key| { - let rel = relative(key); - std::cmp::Reverse(rel.iter().map(|c| c.abs()).sum::() << key.level) - }); - } else { - order.sort_unstable_by_key(|key| self.sections[key].arena); - } - let mut bound = None; - for key in order { - let section = &self.sections[key]; - if bound != Some(section.arena) { - pass.set_bind_group(1, self.arenas[section.arena].group); - bound = Some(section.arena); - } - let origin = (origin(key) - anchor).as_vec3(); - pass.set_push_constants( - rhi::ShaderStages::VERTEX, - 64, - bytemuck::cast_slice(&[origin.x, origin.y, origin.z, (1 << key.level) as f32]), - ); - let groups: Vec = if translucent { - vec![0] - } else { - let rel = relative(key); + visible.sort_unstable_by_key(|(section, ..)| section.arena); + + let mut commands: Vec = Vec::with_capacity(visible.len() * 4); + let push = + |commands: &mut Vec, section: &Uploaded, group: usize, id: usize| { + let (first, count) = section.group(group); + if count > 0 { + commands.push(DrawCommand { + index_count: count * 6, + instance_count: 1, + first_index: 0, + vertex_offset: (first * 4) as i32, + first_instance: id as u32, + }); + } + }; + let span = |commands: &[DrawCommand], first: u32| (first, commands.len() as u32 - first); + self.draws = vec![ArenaDraws::default(); self.arenas.len()]; + let mut start = 0; + for run in visible.chunk_by(|a, b| a.0.arena == b.0.arena) { + let ids = start..start + run.len(); + start = ids.end; + let first = commands.len() as u32; + for (id, (section, _, rel)) in ids.clone().zip(run) { + push(&mut commands, section, 1, id); let facing = [ rel[1] > -1, rel[1] < 1, @@ -757,16 +788,61 @@ impl DistantBuffers { rel[0] > -1, rel[0] < 1, ]; - std::iter::once(1) - .chain((0..6).filter(|&face| facing[face]).map(|face| face + 2)) - .collect() + for (face, _) in facing.iter().enumerate().filter(|&(_, &faces)| faces) { + push(&mut commands, section, face + 2, id); + } + } + let opaque = span(&commands, first); + let first = commands.len() as u32; + let mut order: Vec<(i32, usize)> = ids + .zip(run) + .filter(|(_, (section, ..))| section.group(0).1 > 0) + .map(|(id, (_, position, rel))| { + // The section's distance in its level's voxels. + ( + rel.iter().map(|c| c.abs()).sum::() * position[3] as i32, + id, + ) + }) + .collect(); + order.sort_unstable_by_key(|&(distance, _)| std::cmp::Reverse(distance)); + for (_, id) in order { + push(&mut commands, &visible[id].0, 0, id); + } + self.draws[run[0].0.arena] = ArenaDraws { + opaque, + translucent: span(&commands, first), }; - for group in groups { - let (first, count) = section.group(group); - if count == 0 { - continue; + } + let positions: Vec<[f32; 4]> = visible.iter().map(|(_, position, _)| *position).collect(); + self.commands[frame].write_at(0, bytemuck::cast_slice(&commands)); + self.positions[frame].write_at(0, bytemuck::cast_slice(&positions)); + } + + /// Issues `frame`'s draw list ([`Self::build_draws`]), the opaque or + /// the translucent draws, an indirect draw per arena. The caller has + /// bound the matching distant pipeline and its sets. + pub fn draw(&self, pass: &rhi::backend::RenderPassEncoder, frame: usize, translucent: bool) { + pass.set_index_buffer(self.index_buffer.handle()); + let stride = size_of::() as u32; + for (arena, draws) in self.arenas.iter().zip(&self.draws) { + let (first, count) = if translucent { + draws.translucent + } else { + draws.opaque + }; + if count == 0 { + continue; + } + pass.set_bind_group(1, arena.group); + let buffer = self.commands[frame].handle(); + let offset = u64::from(first * stride); + if self.indirect.multi_draw { + pass.draw_indexed_indirect(buffer, offset, count, stride); + } else { + for i in 0..count { + pass.draw_indexed_indirect(buffer, offset + u64::from(i * stride), 1, stride); } - pass.draw_indexed(count * 6, 1, 0, (first * 4) as i32, 0); } } } @@ -776,14 +852,12 @@ impl DistantBuffers { arena.group.destroy(device); arena.buffer.destroy(device); } - for buffer in [ - &mut self.index_buffer, - &mut self.mask, - &mut self.models, - &mut self.tints, - ] { + for buffer in [&mut self.index_buffer, &mut self.models, &mut self.tints] { std::mem::take(buffer).destroy(device); } + util::destroy_buffers(device, &mut self.commands); + util::destroy_buffers(device, &mut self.positions); + self.bounds.destroy(device); util::destroy_buffers(device, &mut self.tile_staging); self.model_sampler.destroy(device); std::mem::take(&mut self.model_atlas).destroy(device); @@ -791,6 +865,166 @@ impl DistantBuffers { } } +/// Voxy's `ChunkBoundStore`: the drawn chunk sections' coordinates for the +/// bound pass, a slot each in a buffer the pass instances over, the last +/// slot moving into a freed one. +pub(crate) struct BoundStore { + layout: rhi::backend::BindGroupLayout, + buffer: rhi::backend::Buffer, + group: rhi::backend::BindGroup, + capacity: u32, + slots: Vec<[i32; 3]>, + index: HashMap<[i32; 3], u32>, + /// Per column, which of its sections have a slot. + columns: HashMap, + /// Buffers in-flight frames may still read, by the frame they're free + /// from. + retired: VecDeque<(u64, rhi::backend::Buffer, rhi::backend::BindGroup)>, +} + +impl BoundStore { + fn new(device: &rhi::backend::Device) -> Self { + let layout = rhi::backend::create_bind_group_layout( + device, + &[rhi::BindGroupLayoutEntry::storage( + rhi::ShaderStages::VERTEX, + )], + ); + let (buffer, group) = Self::make_buffer(device, layout, BOUND_CAPACITY); + Self { + layout, + buffer, + group, + capacity: BOUND_CAPACITY, + slots: Vec::new(), + index: HashMap::new(), + columns: HashMap::new(), + retired: VecDeque::new(), + } + } + + fn make_buffer( + device: &rhi::backend::Device, + layout: rhi::backend::BindGroupLayout, + capacity: u32, + ) -> (rhi::backend::Buffer, rhi::backend::BindGroup) { + let bytes = u64::from(capacity) * 16; + let buffer = rhi::backend::Buffer::new( + device, + "distant_chunk_bounds", + bytes, + rhi::BufferUsages::STORAGE, + rhi::MemoryLocation::Host, + ); + let group = device.create_bind_group( + layout, + &[(0, rhi::backend::BindingResource::storage(&buffer, bytes))], + ); + (buffer, group) + } + + pub fn layout(&self) -> rhi::backend::BindGroupLayout { + self.layout + } + + pub fn group(&self) -> rhi::backend::BindGroup { + self.group + } + + pub fn count(&self) -> u32 { + self.slots.len() as u32 + } + + fn write(&mut self, slot: u32) { + let [x, y, z] = self.slots[slot as usize]; + self.buffer + .write_at(slot as usize * 16, bytemuck::bytes_of(&[x, y, z, 0])); + } + + fn set_column( + &mut self, + device: &rhi::backend::Device, + frame_seq: u64, + pos: ChunkPos, + sections: u32, + min_section: i32, + ) { + let was = self.columns.get(&pos).copied().unwrap_or(0); + if was == sections { + return; + } + for bit in 0..32 { + let (had, has) = (was & 1 << bit != 0, sections & 1 << bit != 0); + let key = [pos.x, min_section + bit, pos.z]; + if had && !has { + self.remove(key); + } else if has && !had { + self.add(device, frame_seq, key); + } + } + if sections == 0 { + self.columns.remove(&pos); + } else { + self.columns.insert(pos, sections); + } + } + + fn add(&mut self, device: &rhi::backend::Device, frame_seq: u64, key: [i32; 3]) { + if self.count() == self.capacity { + self.capacity *= 2; + let (buffer, group) = Self::make_buffer(device, self.layout, self.capacity); + let old = ( + frame_seq + MAX_FRAMES_IN_FLIGHT as u64, + std::mem::replace(&mut self.buffer, buffer), + std::mem::replace(&mut self.group, group), + ); + self.retired.push_back(old); + for slot in 0..self.count() { + self.write(slot); + } + } + let slot = self.count(); + self.slots.push(key); + self.index.insert(key, slot); + self.write(slot); + } + + fn remove(&mut self, key: [i32; 3]) { + let Some(slot) = self.index.remove(&key) else { + return; + }; + let last = self.slots.pop().expect("a slot was indexed"); + if slot < self.count() { + self.slots[slot as usize] = last; + self.index.insert(last, slot); + self.write(slot); + } + } + + fn clear(&mut self) { + self.slots.clear(); + self.index.clear(); + self.columns.clear(); + } + + fn free_retired(&mut self, device: &rhi::backend::Device, frame_seq: u64) { + while let Some(&(at, ..)) = self.retired.front() + && at <= frame_seq + { + let (_, buffer, group) = self.retired.pop_front().expect("peeked"); + group.destroy(device); + buffer.destroy(device); + } + } + + fn destroy(&mut self, device: &rhi::backend::Device) { + self.free_retired(device, u64::MAX); + self.group.destroy(device); + std::mem::take(&mut self.buffer).destroy(device); + self.layout.destroy(device); + } +} + /// The block position of `key`'s corner. fn origin(key: &SectionKey) -> DVec3 { let [x, y, z] = key.origin(); diff --git a/pomme-client/src/renderer/mod.rs b/pomme-client/src/renderer/mod.rs index a404c9c42..81b722e47 100644 --- a/pomme-client/src/renderer/mod.rs +++ b/pomme-client/src/renderer/mod.rs @@ -496,12 +496,13 @@ impl Renderer { ); let chunk_buffers = ChunkBufferStore::new(&ctx.device, ctx.indirect_draw); - let distant_buffers = distant::gpu::DistantBuffers::new(&ctx.device); + let distant_buffers = distant::gpu::DistantBuffers::new(&ctx.device, ctx.indirect_draw); let distant_pipeline = pipelines::distant::DistantPipeline::new( &ctx.device, swapchain_state.targets(), &lightmap, distant_buffers.layout(), + distant_buffers.bounds().layout(), ); let mut item_entity_pipeline = pipelines::item_entity::ItemEntityPipeline::new( @@ -1405,10 +1406,11 @@ impl Renderer { self.distant_buffers.set_view(view); } - /// Whether a real chunk draws at `pos`, where distant terrain gives way - /// to it. - pub fn set_distant_column_drawn(&mut self, pos: ChunkPos, drawn: bool) { - self.distant_buffers.set_column_drawn(pos, drawn); + /// Which of column `pos`'s sections draw (a bit per section from + /// `min_section` up), where distant terrain gives way to them. + pub fn set_distant_column_sections(&mut self, pos: ChunkPos, sections: u32, min_section: i32) { + self.distant_buffers + .set_column_sections(&self.ctx.device, pos, sections, min_section); } /// Drops all distant terrain and forgets every loaded chunk, for a new @@ -1416,7 +1418,7 @@ impl Renderer { pub fn reset_distant(&mut self) { self.wait_for_all_frames(); self.distant_buffers.clear(); - self.distant_buffers.clear_mask(); + self.distant_buffers.clear_bounds(); } /// Whether distant terrain is uploaded at block column `x`/`z`. @@ -1851,7 +1853,7 @@ impl Renderer { // Fence signalled: reclaim chunk slices the GPU is now provably done with, // and read back any screenshot copy recorded for this frame index. self.chunk_buffers.begin_frame(&self.ctx.device); - self.distant_buffers.begin_frame(); + self.distant_buffers.begin_frame(&self.ctx.device); self.screenshot.collect_ready(frame, &self.ctx.device); let t_acquire = std::time::Instant::now(); @@ -2067,11 +2069,15 @@ impl Renderer { let pack_drawn = self.render_pack_world(&encoder, frame, &mode); + // Depth kept past the pass: distant terrain draws against it in + // passes of its own, then the frame goes on. let mut pass = encoder.begin_render_pass( &self.ctx.device, - &self - .swapchain - .pass(image_index, rhi::LoadOp::Clear(clear_color)), + &self.swapchain.pass_keeping_depth( + image_index, + rhi::LoadOp::Clear(clear_color), + rhi::LoadOp::Clear(1.0), + ), ); let sw = self.swapchain.extent.width as f32; @@ -2127,25 +2133,6 @@ impl Renderer { fog_distance, ); } - if self.distant_draws(*distant_terrain) { - // Its own projection, so its depth means nothing to - // the chunks: they start from a cleared buffer, and - // the drawn-column mask keeps it out of theirs. - let eye = self.camera_render_position(); - let extent = self.swapchain.extent; - self.distant_buffers.traverse( - &self.camera, - eye, - [extent.width, extent.height], - ); - for translucent in [false, true] { - self.distant_pipeline.bind(&mut pass, frame, translucent); - self.distant_buffers - .draw(&pass, &self.camera, eye, translucent); - } - pass.clear_depth(scissor); - } - let t_cull = std::time::Instant::now(); // A pack that failed to build leaves its vertex format until // the next frame remeshes; skip those. @@ -2167,6 +2154,45 @@ impl Renderer { } let cull_ms = t_cull.elapsed().as_secs_f32() * 1000.0; + if self.distant_draws(*distant_terrain) { + // Voxy draws after the chunks' solid and cutout + // passes: bounded by their depth, into its own + // image, then laid into the frame. + let eye = self.camera_render_position(); + let extent = self.swapchain.extent; + self.distant_buffers.traverse( + &self.camera, + eye, + [extent.width, extent.height], + ); + pass.end(); + self.distant_pipeline.render( + &self.ctx.device, + &encoder, + frame, + &mut self.distant_buffers, + &self.camera, + eye, + self.swapchain.depth.handle(), + extent, + ); + pass = encoder.begin_render_pass( + &self.ctx.device, + &self.swapchain.pass_keeping_depth( + image_index, + rhi::LoadOp::Load, + rhi::LoadOp::Load, + ), + ); + self.distant_pipeline.composite( + &mut pass, + frame, + &self.camera, + (*render_distance * 16) as f32, + (*distant_terrain * 16) as f32, + ); + } + let anchor = self.camera.anchor(); let eye = self.camera_render_position(); diff --git a/pomme-client/src/renderer/pipelines/distant.rs b/pomme-client/src/renderer/pipelines/distant.rs index 0d2b2ef1f..fe6ca5635 100644 --- a/pomme-client/src/renderer/pipelines/distant.rs +++ b/pomme-client/src/renderer/pipelines/distant.rs @@ -1,103 +1,504 @@ -use crate::renderer::camera::CameraUniform; +//! Distant terrain's passes (Voxy's `BoundRenderer`, `AbstractRenderPipeline` +//! and `NormalRenderPipeline`): the drawn chunks' boxes into a depth bound, +//! the far terrain into its own image under its own projection where the +//! chunks drew nothing, and that image blitted into the frame with its depth +//! carried over. + +use glam::DVec3; + +use crate::renderer::camera::{Camera, CameraUniform}; +use crate::renderer::distant::gpu::{DISTANT_FAR, DistantBuffers, POSITIONS_BYTES}; use crate::renderer::pipelines::lightmap::Lightmap; -use crate::renderer::{rhi, shader, util}; +use crate::renderer::rhi::TextureUse; +use crate::renderer::rhi::backend::TextureTransition; +use crate::renderer::{MAX_FRAMES_IN_FLIGHT, rhi, shader, util}; + +/// Distant terrain's image: plain RGBA8, as Voxy's. +const FORMAT: rhi::TextureFormat = rhi::TextureFormat::Rgba8Unorm; +const DEPTH: rhi::TextureFormat = rhi::TextureFormat::Depth32Float; + +/// A sampled texture and its sampler, for the fragment stage. +const TEXTURE_SAMPLER: &[rhi::BindGroupLayoutEntry] = &[ + rhi::BindGroupLayoutEntry::texture(rhi::ShaderStages::FRAGMENT), + rhi::BindGroupLayoutEntry::sampler(rhi::ShaderStages::FRAGMENT), +]; +/// The blit's inputs: the image, its depth, and the sampler. +const BLIT_INPUTS: &[rhi::BindGroupLayoutEntry] = &[ + rhi::BindGroupLayoutEntry::texture(rhi::ShaderStages::FRAGMENT), + rhi::BindGroupLayoutEntry::texture(rhi::ShaderStages::FRAGMENT), + rhi::BindGroupLayoutEntry::sampler(rhi::ShaderStages::FRAGMENT), +]; +/// The quads' set 3: each draw's section position, and the depth bound. +const DRAW_SET: &[rhi::BindGroupLayoutEntry] = &[ + rhi::BindGroupLayoutEntry::storage(rhi::ShaderStages::VERTEX), + rhi::BindGroupLayoutEntry::texture(rhi::ShaderStages::FRAGMENT), +]; + +/// The screen-size targets, remade with the swapchain. +struct Targets { + extent: rhi::Extent, + color: rhi::backend::Texture, + depth: rhi::backend::Texture, + /// The far side of the drawn chunks' boxes (`depthBoundingBuffer`). + bound: rhi::backend::Texture, + /// Per frame in flight, its position list with the depth bound. + draw_sets: Vec, + setup_set: rhi::backend::BindGroup, + blit_set: rhi::backend::BindGroup, +} -/// Distant terrain: Voxy quads read straight from their arena, textured -/// with their block's model, under the camera's fog and the lightmap; -/// opaque, then see-through blended over it. pub struct DistantPipeline { - pipelines: [rhi::backend::RenderPipeline; 2], - pipeline_layout: rhi::backend::PipelineLayout, + /// The quads, opaque then translucent, into the image. + quads: [rhi::backend::RenderPipeline; 2], + bound: rhi::backend::RenderPipeline, + setup: rhi::backend::RenderPipeline, + blit: rhi::backend::RenderPipeline, + quads_layout: rhi::backend::PipelineLayout, + bound_layout: rhi::backend::PipelineLayout, + setup_layout: rhi::backend::PipelineLayout, + blit_layout: rhi::backend::PipelineLayout, camera_layout: rhi::backend::BindGroupLayout, + camera_fragment_layout: rhi::backend::BindGroupLayout, + texture_layout: rhi::backend::BindGroupLayout, + blit_inputs_layout: rhi::backend::BindGroupLayout, + draw_layout: rhi::backend::BindGroupLayout, camera_sets: Vec, + /// The same buffers for the blit, which fogs in the fragment stage. + camera_fragment_sets: Vec, camera_buffers: Vec, - /// Set 2, which [`Lightmap`] owns. + sampler: rhi::backend::Sampler, + targets: Option, + /// Set 2 of the quads, which [`Lightmap`] owns. lightmap_set: rhi::backend::BindGroup, } impl DistantPipeline { - /// `arena_layout` is set 1 - /// ([`crate::renderer::distant::gpu::DistantBuffers::layout`]), - /// bound per arena as it draws. + /// `arena_layout` is the quads' set 1 + /// ([`DistantBuffers::layout`]), bound per arena as they draw; + /// `bound_store_layout` the bound pass's section list + /// ([`crate::renderer::distant::gpu::BoundStore::layout`]). pub fn new( device: &rhi::backend::Device, - targets: rhi::backend::PipelineTargets, + swapchain: rhi::backend::PipelineTargets, lightmap: &Lightmap, arena_layout: rhi::backend::BindGroupLayout, + bound_store_layout: rhi::backend::BindGroupLayout, ) -> Self { - let camera_layout = rhi::backend::create_bind_group_layout(device, util::VERTEX_UNIFORM); - // The view projection (once a pass), then the section's origin and - // scale. - let pipeline_layout = rhi::backend::create_pipeline_layout( - device, - &[camera_layout, arena_layout, lightmap.layout], - Some(rhi::PushConstants { - visibility: rhi::ShaderStages::VERTEX, - size: 80, - }), + let layout = |entries| rhi::backend::create_bind_group_layout(device, entries); + let camera_layout = layout(util::VERTEX_UNIFORM); + let camera_fragment_layout = layout(util::VERTEX_FRAGMENT_UNIFORM); + let texture_layout = layout(TEXTURE_SAMPLER); + let blit_inputs_layout = layout(BLIT_INPUTS); + let draw_layout = layout(DRAW_SET); + let push = |visibility, size| Some(rhi::PushConstants { visibility, size }); + let pipeline_layout = |sets: &[rhi::backend::BindGroupLayout], push| { + rhi::backend::create_pipeline_layout(device, sets, push) + }; + // The view projection, once a pass. + let quads_layout = pipeline_layout( + &[camera_layout, arena_layout, lightmap.layout, draw_layout], + push(rhi::ShaderStages::VERTEX, 64), + ); + let bound_layout = pipeline_layout( + &[camera_layout, bound_store_layout], + push(rhi::ShaderStages::VERTEX, 64), + ); + let setup_layout = pipeline_layout(&[texture_layout], None); + // The inverse distant view projection, then the fade. + let blit_layout = pipeline_layout( + &[camera_fragment_layout, blit_inputs_layout], + push(rhi::ShaderStages::FRAGMENT, 80), ); - let pipelines = create_pipelines(device, targets, pipeline_layout); let (camera_sets, camera_buffers) = util::create_uniform_sets( device, camera_layout, size_of::() as u64, "distant_camera_uniform", ); + let camera_fragment_sets = camera_buffers + .iter() + .map(|buffer| { + device.create_bind_group( + camera_fragment_layout, + &[( + 0, + rhi::backend::BindingResource::uniform( + buffer, + size_of::() as u64, + ), + )], + ) + }) + .collect(); Self { - pipelines, - pipeline_layout, + quads: create_quad_pipelines(device, quads_layout), + bound: create_bound_pipeline(device, bound_layout), + setup: create_setup_pipeline(device, setup_layout), + blit: create_blit_pipeline(device, swapchain, blit_layout), + quads_layout, + bound_layout, + setup_layout, + blit_layout, camera_layout, + camera_fragment_layout, + texture_layout, + blit_inputs_layout, + draw_layout, camera_sets, + camera_fragment_sets, camera_buffers, + sampler: rhi::backend::create_sampler(device, &rhi::SamplerDesc::NEAREST), + targets: None, lightmap_set: lightmap.group, } } + /// The swapchain changed: the blit draws into it, and the targets + /// follow its size. pub fn rebuild( &mut self, device: &rhi::backend::Device, - targets: rhi::backend::PipelineTargets, + swapchain: rhi::backend::PipelineTargets, ) { - for pipeline in &mut self.pipelines { - pipeline.destroy(device); + self.blit.destroy(device); + self.blit = create_blit_pipeline(device, swapchain, self.blit_layout); + if let Some(targets) = self.targets.take() { + self.destroy_targets(device, targets); } - self.pipelines = create_pipelines(device, targets, self.pipeline_layout); } pub fn update_camera(&mut self, frame: usize, uniform: &CameraUniform) { self.camera_buffers[frame].write(bytemuck::bytes_of(uniform)); } - pub fn bind( + /// The targets at `extent`, made with `frame_depth` (the swapchain's, + /// which the setup pass reads) as the swapchain is. + fn ensure_targets( + &mut self, + device: &rhi::backend::Device, + extent: rhi::Extent, + frame_depth: rhi::backend::TextureHandle, + buffers: &DistantBuffers, + ) { + if self.targets.as_ref().is_some_and(|t| t.extent != extent) { + let targets = self.targets.take().expect("checked"); + self.destroy_targets(device, targets); + } + if self.targets.is_none() { + let texture = |label, format, resting| { + let texture = rhi::backend::Texture::new( + device, + &rhi::TextureDesc::new( + label, + extent.width.max(1), + extent.height.max(1), + format, + rhi::TextureUsages::RENDER_ATTACHMENT | rhi::TextureUsages::TEXTURE_BINDING, + ), + ); + device.submit(|encoder| { + encoder.transition_textures(&[TextureTransition::color( + texture.handle(), + TextureUse::Undefined, + resting, + )]); + }); + texture + }; + let color = texture("distant_color", FORMAT, TextureUse::ShaderRead); + let depth = texture("distant_depth", DEPTH, TextureUse::DepthRead); + let bound = texture("distant_depth_bound", DEPTH, TextureUse::DepthRead); + let draw_sets = (0..MAX_FRAMES_IN_FLIGHT) + .map(|frame| { + device.create_bind_group( + self.draw_layout, + &[ + ( + 0, + rhi::backend::BindingResource::storage( + buffers.position_buffer(frame), + POSITIONS_BYTES, + ), + ), + (1, rhi::backend::BindingResource::Texture(bound.handle())), + ], + ) + }) + .collect(); + let blit_set = device.create_bind_group( + self.blit_inputs_layout, + &[ + (0, rhi::backend::BindingResource::Texture(color.handle())), + (1, rhi::backend::BindingResource::Texture(depth.handle())), + (2, rhi::backend::BindingResource::Sampler(self.sampler)), + ], + ); + self.targets = Some(Targets { + extent, + setup_set: util::create_texture_group( + device, + self.texture_layout, + frame_depth, + self.sampler, + ), + color, + depth, + bound, + draw_sets, + blit_set, + }); + } + } + + /// The bound pass, then distant terrain into its image: Voxy's + /// `renderOpaque` and `runPipeline` up to the blit, after the chunks' + /// solid and cutout passes, whose depth `frame_depth` holds. + #[allow(clippy::too_many_arguments)] + pub fn render( + &mut self, + device: &rhi::backend::Device, + encoder: &rhi::backend::CommandEncoder, + frame: usize, + buffers: &mut DistantBuffers, + camera: &Camera, + eye: DVec3, + frame_depth: rhi::backend::TextureHandle, + extent: rhi::Extent, + ) { + self.ensure_targets(device, extent, frame_depth, buffers); + buffers.build_draws(frame, camera, eye); + let targets = self.targets.as_ref().expect("made above"); + let (color, depth, bound) = ( + targets.color.handle(), + targets.depth.handle(), + targets.bound.handle(), + ); + let view_proj = camera.distant_view_projection(DISTANT_FAR); + + // `BoundRenderer`: the farthest back face of each drawn section's + // box, from a depth cleared to the near plane. + encoder.transition_textures(&[TextureTransition::color( + bound, + TextureUse::DepthRead, + TextureUse::DepthAttachment, + )]); + let mut pass = encoder.begin_render_pass( + device, + &rhi::backend::RenderPassDesc { + color: None, + extra_colors: Vec::new(), + depth: Some(depth_attachment(bound, 0.0)), + extent, + }, + ); + let bounds = buffers.bounds(); + if bounds.count() > 0 { + pass.set_pipeline(&self.bound); + pass.set_bind_groups(0, &[self.camera_sets[frame], bounds.group()]); + pass.set_push_constants( + rhi::ShaderStages::VERTEX, + 0, + bytemuck::cast_slice(&view_proj.to_cols_array()), + ); + pass.draw(36, bounds.count(), 0, 0); + } + pass.end(); + encoder.transition_textures(&[ + TextureTransition::color(bound, TextureUse::DepthAttachment, TextureUse::DepthRead), + TextureTransition::color( + frame_depth, + TextureUse::DepthAttachment, + TextureUse::DepthRead, + ), + TextureTransition::color(color, TextureUse::ShaderRead, TextureUse::ColorAttachment), + TextureTransition::color(depth, TextureUse::DepthRead, TextureUse::DepthAttachment), + ]); + + // `initDepthStencil`, then the quads. + let mut pass = encoder.begin_render_pass( + device, + &rhi::backend::RenderPassDesc { + color: Some(rhi::backend::ColorAttachment { + texture: color, + format: rhi::backend::texture_format(FORMAT), + load: rhi::LoadOp::Clear([0.0; 4]), + store: rhi::StoreOp::Store, + }), + extra_colors: Vec::new(), + depth: Some(depth_attachment(depth, 1.0)), + extent, + }, + ); + pass.set_pipeline(&self.setup); + pass.set_bind_group(0, targets.setup_set); + pass.draw(3, 1, 0, 0); + for (pipeline, translucent) in self.quads.iter().zip([false, true]) { + pass.set_pipeline(pipeline); + pass.set_bind_group(0, self.camera_sets[frame]); + pass.set_bind_group(2, self.lightmap_set); + pass.set_bind_group(3, targets.draw_sets[frame]); + pass.set_push_constants( + rhi::ShaderStages::VERTEX, + 0, + bytemuck::cast_slice(&view_proj.to_cols_array()), + ); + buffers.draw(&pass, frame, translucent); + } + pass.end(); + encoder.transition_textures(&[ + TextureTransition::color( + frame_depth, + TextureUse::DepthRead, + TextureUse::DepthAttachment, + ), + TextureTransition::color(color, TextureUse::ColorAttachment, TextureUse::ShaderRead), + TextureTransition::color(depth, TextureUse::DepthAttachment, TextureUse::DepthRead), + ]); + } + + /// `NormalRenderPipeline.finish`: the image into the frame, fogged, and + /// faded over the last tenth of `distant_blocks` (never short of + /// `render_distance_blocks`, where the chunks end). + pub fn composite( &self, pass: &mut rhi::backend::RenderPassEncoder, frame: usize, - translucent: bool, + camera: &Camera, + render_distance_blocks: f32, + distant_blocks: f32, ) { - pass.set_pipeline(&self.pipelines[usize::from(translucent)]); - pass.set_bind_group(0, self.camera_sets[frame]); - pass.set_bind_group(2, self.lightmap_set); + let Some(targets) = &self.targets else { + return; + }; + let rd = distant_blocks - 32.0; + let start = render_distance_blocks.max(rd * 0.9); + let end = render_distance_blocks.max(rd); + let scale = if end > start { + 1.0 / (end - start) + } else { + 0.0 + }; + let inverse = camera.distant_view_projection(DISTANT_FAR).inverse(); + let mut constants = inverse.to_cols_array().to_vec(); + constants.extend([-start * scale, scale, 0.0, 0.0]); + pass.set_pipeline(&self.blit); + pass.set_bind_groups(0, &[self.camera_fragment_sets[frame], targets.blit_set]); + pass.set_push_constants( + rhi::ShaderStages::FRAGMENT, + 0, + bytemuck::cast_slice(&constants), + ); + pass.draw(3, 1, 0, 0); + } + + fn destroy_targets(&self, device: &rhi::backend::Device, mut targets: Targets) { + for set in targets + .draw_sets + .iter() + .chain([&targets.setup_set, &targets.blit_set]) + { + set.destroy(device); + } + for texture in [&mut targets.color, &mut targets.depth, &mut targets.bound] { + std::mem::take(texture).destroy(device); + } } pub fn destroy(&mut self, device: &rhi::backend::Device) { + if let Some(targets) = self.targets.take() { + self.destroy_targets(device, targets); + } util::destroy_buffers(device, &mut self.camera_buffers); util::destroy_bind_groups(device, self.camera_sets.drain(..)); - for pipeline in &mut self.pipelines { + util::destroy_bind_groups(device, self.camera_fragment_sets.drain(..)); + for pipeline in + self.quads + .iter_mut() + .chain([&mut self.bound, &mut self.setup, &mut self.blit]) + { pipeline.destroy(device); } - self.pipeline_layout.destroy(device); - self.camera_layout.destroy(device); + for layout in [ + self.quads_layout, + self.bound_layout, + self.setup_layout, + self.blit_layout, + ] { + layout.destroy(device); + } + for layout in [ + self.camera_layout, + self.camera_fragment_layout, + self.texture_layout, + self.blit_inputs_layout, + self.draw_layout, + ] { + layout.destroy(device); + } + self.sampler.destroy(device); } } -/// The opaque pipeline, then the see-through one, which blends as Voxy's -/// translucent pass does. Neither culls faces: the quads are drawn from -/// every side. -fn create_pipelines( +/// A `texture` cleared to `clear` and kept after the pass. +fn depth_attachment( + texture: rhi::backend::TextureHandle, + clear: f32, +) -> rhi::backend::DepthAttachment { + rhi::backend::DepthAttachment { + texture, + load: rhi::LoadOp::Clear(clear), + store: rhi::StoreOp::Store, + } +} + +/// What the quads and the setup draw into: the image and its depth. +fn image_targets() -> rhi::backend::PipelineTargets { + rhi::backend::PipelineTargets::new(rhi::backend::texture_format(FORMAT), true) +} + +/// Writing depth under `compare`. +fn depth_state(compare: rhi::CompareFunction) -> Option { + Some(rhi::DepthState { + compare, + ..rhi::DepthState::WRITE + }) +} + +/// A full-screen pass of `fragment`. +fn fullscreen_pipeline( device: &rhi::backend::Device, targets: rhi::backend::PipelineTargets, layout: rhi::backend::PipelineLayout, + label: &str, + fragment: rhi::Shader, + depth: Option, + blend: Option, +) -> rhi::backend::RenderPipeline { + rhi::backend::create_render_pipeline( + device, + targets, + layout, + &rhi::RenderPipelineDesc { + depth, + blend, + ..rhi::RenderPipelineDesc::new( + label, + shader::include_shader!("blur.vert"), + fragment, + &[], + ) + }, + ) +} + +/// The opaque quads, then the translucent ones blended as Voxy's translucent +/// pass does; both write depth under the distant projection. Neither culls +/// faces: the quads are drawn from every side. +fn create_quad_pipelines( + device: &rhi::backend::Device, + layout: rhi::backend::PipelineLayout, ) -> [rhi::backend::RenderPipeline; 2] { + let targets = image_targets(); let opaque = rhi::RenderPipelineDesc { depth: Some(rhi::DepthState::WRITE), ..rhi::RenderPipelineDesc::new( @@ -115,3 +516,58 @@ fn create_pipelines( [opaque, translucent] .map(|desc| rhi::backend::create_render_pipeline(device, targets, layout, &desc)) } + +/// The drawn chunks' boxes, back faces only and the farthest kept. +fn create_bound_pipeline( + device: &rhi::backend::Device, + layout: rhi::backend::PipelineLayout, +) -> rhi::backend::RenderPipeline { + rhi::backend::create_render_pipeline( + device, + rhi::backend::PipelineTargets::depth_only(), + layout, + &rhi::RenderPipelineDesc { + cull_mode: rhi::CullMode::Front, + depth: depth_state(rhi::CompareFunction::Greater), + ..rhi::RenderPipelineDesc::new( + "distant chunk bounds", + shader::include_shader!("distant_bound.vert"), + shader::include_shader!("distant_bound.frag"), + &[], + ) + }, + ) +} + +/// The image's depth set to the near plane where the chunks drew. +fn create_setup_pipeline( + device: &rhi::backend::Device, + layout: rhi::backend::PipelineLayout, +) -> rhi::backend::RenderPipeline { + fullscreen_pipeline( + device, + image_targets(), + layout, + "distant depth setup", + shader::include_shader!("distant_setup.frag"), + depth_state(rhi::CompareFunction::Always), + None, + ) +} + +/// The image over the frame, depth-tested against the chunks and written. +fn create_blit_pipeline( + device: &rhi::backend::Device, + swapchain: rhi::backend::PipelineTargets, + layout: rhi::backend::PipelineLayout, +) -> rhi::backend::RenderPipeline { + fullscreen_pipeline( + device, + swapchain, + layout, + "distant blit", + shader::include_shader!("distant_blit.frag"), + Some(rhi::DepthState::WRITE), + Some(rhi::BlendState::TRANSLUCENT), + ) +} diff --git a/pomme-client/src/renderer/rhi/mod.rs b/pomme-client/src/renderer/rhi/mod.rs index eb8a128ff..95f487e10 100644 --- a/pomme-client/src/renderer/rhi/mod.rs +++ b/pomme-client/src/renderer/rhi/mod.rs @@ -307,7 +307,7 @@ impl TextureDesc<'static> { extent.width, extent.height, TextureFormat::Depth32Float, - TextureUsages::RENDER_ATTACHMENT, + TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING, ) } } @@ -670,6 +670,8 @@ pub enum FrontFace { pub enum CompareFunction { Less, LessEqual, + Greater, + Always, } #[derive(Clone, Copy)] diff --git a/pomme-client/src/renderer/rhi/vulkan/mod.rs b/pomme-client/src/renderer/rhi/vulkan/mod.rs index 36e20add2..4c68330da 100644 --- a/pomme-client/src/renderer/rhi/vulkan/mod.rs +++ b/pomme-client/src/renderer/rhi/vulkan/mod.rs @@ -666,6 +666,8 @@ pub fn try_create_sampler(device: &Device, desc: &SamplerDesc) -> Result vk::CompareOp::Less, Some(CompareFunction::LessEqual) | None => vk::CompareOp::LessOrEqual, + Some(CompareFunction::Greater) => vk::CompareOp::Greater, + Some(CompareFunction::Always) => vk::CompareOp::Always, }, ..Default::default() }; @@ -994,6 +996,8 @@ pub fn try_create_render_pipeline( depth_compare_op: match depth.compare { CompareFunction::Less => vk::CompareOp::Less, CompareFunction::LessEqual => vk::CompareOp::LessOrEqual, + CompareFunction::Greater => vk::CompareOp::Greater, + CompareFunction::Always => vk::CompareOp::Always, }, ..Default::default() }, diff --git a/pomme-client/src/renderer/rhi/vulkan/swapchain.rs b/pomme-client/src/renderer/rhi/vulkan/swapchain.rs index d17aefd01..bca5669a2 100644 --- a/pomme-client/src/renderer/rhi/vulkan/swapchain.rs +++ b/pomme-client/src/renderer/rhi/vulkan/swapchain.rs @@ -228,6 +228,24 @@ impl Swapchain { ) } + /// As [`Self::pass`], but the depth buffer loads as `depth` says and is + /// kept after the pass, for a frame drawn in several passes. + pub fn pass_keeping_depth( + &self, + index: u32, + color: rhi::LoadOp<[f32; 4]>, + depth: rhi::LoadOp, + ) -> super::RenderPassDesc { + super::RenderPassDesc { + depth: Some(super::DepthAttachment { + texture: self.depth.handle(), + load: depth, + store: rhi::StoreOp::Store, + }), + ..self.pass(index, color) + } + } + pub fn is_fifo(&self) -> bool { self.present_mode == vk::PresentModeKHR::Fifo } diff --git a/pomme-client/src/renderer/shaders/distant.frag b/pomme-client/src/renderer/shaders/distant.frag index 516f139d6..fa4fe5b90 100644 --- a/pomme-client/src/renderer/shaders/distant.frag +++ b/pomme-client/src/renderer/shaders/distant.frag @@ -1,40 +1,21 @@ #version 450 // Voxy's `quads.frag`: the quad's face texture repeated per block from the -// model atlas, cut out, tinted and lit. +// model atlas, cut out, tinted and lit; nothing nearer than the far side of +// the drawn chunks' boxes (distant_bound.vert). -#include "fog.glsl" #include "distant_models.glsl" // Every model's face textures (models.rs). layout(set = 1, binding = 3) uniform texture2D model_atlas; layout(set = 1, binding = 4) uniform sampler model_sampler; -// One bit per chunk column, ring-indexed as `world/chunk.rs` `ring_slot`: -// set where a real chunk draws instead. -layout(set = 1, binding = 5) readonly buffer DrawnColumns { - uint drawn[]; -}; +layout(set = 3, binding = 1) uniform texture2D depth_bound; layout(location = 0) flat in uvec3 v_data; layout(location = 1) in vec2 v_uv; -layout(location = 2) in vec3 v_fog_color; -layout(location = 3) in float v_fog; -layout(location = 4) in vec2 v_column_pos; -layout(location = 5) flat in ivec2 v_anchor; layout(location = 0) out vec4 out_color; -// `world/chunk.rs` RING_SIDE. -const int RING_SIDE = 257; - -// Whether a real chunk draws under anchor-relative `pos`. -bool drawn_at(vec2 pos) { - ivec2 chunk = (v_anchor + ivec2(floor(pos))) >> 4; - ivec2 ring = ((chunk % RING_SIDE) + RING_SIDE) % RING_SIDE; - uint slot = uint(ring.y * RING_SIDE + ring.x); - return (drawn[slot >> 5] & (1u << (slot & 31u))) != 0u; -} - void main() { uint flags = v_data.x; uint face = flags >> 4u & 7u; @@ -53,7 +34,7 @@ void main() { if (any(notEqual(clamp(tile, vec2(0.0), size), tile))) { discard; } - if (drawn_at(v_column_pos)) { + if (gl_FragCoord.z < texelFetch(sampler2D(depth_bound, model_sampler), ivec2(gl_FragCoord.xy), 0).r) { discard; } // Cut out by the unmipped alpha, which mips would smear. @@ -76,5 +57,5 @@ void main() { color.rgb *= unpackUnorm4x8(v_data.z).rgb; } color.rgb *= unpackUnorm4x8(v_data.y).rgb; - out_color = vec4(apply_fog(color.rgb, v_fog, v_fog_color), color.a); + out_color = color; } diff --git a/pomme-client/src/renderer/shaders/distant.vert b/pomme-client/src/renderer/shaders/distant.vert index 76a6cc6bd..7de8ef23a 100644 --- a/pomme-client/src/renderer/shaders/distant.vert +++ b/pomme-client/src/renderer/shaders/distant.vert @@ -3,9 +3,8 @@ // Distant terrain (renderer/distant): Voxy's `quads3.vert` and // `quad_util.glsl`. Each quad is eight bytes in the bound arena, read by // `gl_VertexIndex >> 2`; its corners come from its model's face data, lit -// through the lightmap and fogged like terrain. +// through the lightmap. Fog comes later, in distant_blit.frag. -#include "fog.glsl" #include "camera_ubo.glsl" #include "lightmap.glsl" #include "distant_models.glsl" @@ -13,9 +12,6 @@ layout(push_constant) uniform DistantPc { // The camera's view projection with the distant near and far planes. mat4 distant_view_proj; - // The section's corner relative to the camera anchor, and its level's - // scale: blocks per voxel. - vec4 origin_scale; }; layout(set = 1, binding = 0) readonly buffer Quads { @@ -28,18 +24,17 @@ layout(set = 1, binding = 1) readonly buffer Models { layout(set = 1, binding = 2) readonly buffer Tints { uint tints[]; }; +// Per draw (`gl_InstanceIndex`): the section's corner relative to the +// camera anchor, and its level's scale, blocks per voxel. +layout(set = 3, binding = 0) readonly buffer Positions { + vec4 positions[]; +}; -// Voxy's `interData`: quad flags (cutout bit 0, tint state 2-3, face 4-6, -// width-1 8-11, height-1 12-15, model 16-31), the lightmap colour, the -// tint colour. +// Voxy's `interData`: quad flags (cutout bit 0, translucent 1, tint state +// 2-3, face 4-6, width-1 8-11, height-1 12-15, model 16-31), the lightmap +// colour, the tint colour. layout(location = 0) flat out uvec3 v_data; layout(location = 1) out vec2 v_uv; -layout(location = 2) out vec3 v_fog_color; -layout(location = 3) out float v_fog; -// Anchor-relative x/z and the anchor: what distant.frag finds the column's -// chunk from. -layout(location = 4) out vec2 v_column_pos; -layout(location = 5) flat out ivec2 v_anchor; // Vanilla's directional shade (the overworld's cardinal lighting), by face. const float FACE_SHADE[6] = float[](0.5, 1.0, 0.8, 0.8, 0.6, 0.6); @@ -56,6 +51,7 @@ vec3 swizzle(uint axis, vec3 data) { } void main() { + vec4 origin_scale = positions[gl_InstanceIndex]; uvec2 quad = quads[gl_VertexIndex >> 2]; uint corner = gl_VertexIndex & 3u; uint face = field(quad, 3u, 0u); @@ -79,8 +75,7 @@ void main() { vec2 extent = bounds.yw + size - 1.0; vec2 mask = vec2(corner >> 1u & 1u, corner & 1u); vec3 point = origin_scale.xyz + (start + swizzle(axis, vec3(extent * mask, 0.0))) * origin_scale.w; - vec3 rel = point - camera_pos.xyz; - gl_Position = distant_view_proj * vec4(rel, 1.0); + gl_Position = distant_view_proj * vec4(point - camera_pos.xyz, 1.0); v_uv = bounds.xz + extent * mask; // `makeQuadFlags`: a face short of its side cuts out once a quad spans @@ -106,9 +101,4 @@ void main() { tint = tints[tint * TINT_BIOMES + field(quad, 9u, 46u)]; } v_data = uvec3(flags, packUnorm4x8(vec4(lit, 1.0)), tint); - - v_fog_color = fog_color.rgb; - v_fog = total_fog_value(rel, fog_env, camera_pos.w, fog_color.w); - v_column_pos = point.xz; - v_anchor = camera_block.xz; } diff --git a/pomme-client/src/renderer/shaders/distant_blit.frag b/pomme-client/src/renderer/shaders/distant_blit.frag new file mode 100644 index 000000000..db3a61599 --- /dev/null +++ b/pomme-client/src/renderer/shaders/distant_blit.frag @@ -0,0 +1,47 @@ +#version 450 + +// Voxy's `post/blit_texture_depth_cutout.frag`: distant terrain's image laid +// into the frame, its depth carried over from the distant projection to the +// camera's so what's drawn after still sorts against it, fogged like the +// chunks and faded out over the last tenth of its distance. + +#include "camera_ubo.glsl" +#include "fog.glsl" + +layout(push_constant) uniform BlitPc { + // The inverse of the view projection distant terrain drew with. + mat4 inverse_distant_view_proj; + // The fade: `alpha *= 1 - saturate(distance * fade.y + fade.x)`. + vec4 fade; +}; + +layout(set = 1, binding = 0) uniform texture2D distant_color; +layout(set = 1, binding = 1) uniform texture2D distant_depth; +layout(set = 1, binding = 2) uniform sampler distant_sampler; + +layout(location = 0) in vec2 v_uv; + +layout(location = 0) out vec4 out_color; + +void main() { + ivec2 pixel = ivec2(gl_FragCoord.xy); + float depth = texelFetch(sampler2D(distant_depth, distant_sampler), pixel, 0).r; + // Nothing drawn, or the chunks' own pixels. + if (depth == 0.0 || depth == 1.0) { + discard; + } + vec4 point = inverse_distant_view_proj * vec4(v_uv * 2.0 - 1.0, depth, 1.0); + vec3 rel = point.xyz / point.w; + vec4 clip = view_proj * vec4(rel, 1.0); + // Kept just inside the far plane, where the chunks' depth clears to. + gl_FragDepth = min(clip.z / clip.w, 1.0 - 2.0 / 16777215.0); + + vec4 color = texelFetch(sampler2D(distant_color, distant_sampler), pixel, 0); + if (color.a == 0.0) { + discard; + } + float fog = total_fog_value(rel, fog_env, camera_pos.w, fog_color.w); + color.rgb = apply_fog(color.rgb, fog, fog_color.rgb); + color.a *= 1.0 - clamp(length(rel.xz) * fade.y + fade.x, 0.0, 1.0); + out_color = color; +} diff --git a/pomme-client/src/renderer/shaders/distant_bound.frag b/pomme-client/src/renderer/shaders/distant_bound.frag new file mode 100644 index 000000000..a81dbbd31 --- /dev/null +++ b/pomme-client/src/renderer/shaders/distant_bound.frag @@ -0,0 +1,5 @@ +#version 450 + +// Voxy's `chunkoutline/outline.fsh`: depth alone. + +void main() {} diff --git a/pomme-client/src/renderer/shaders/distant_bound.vert b/pomme-client/src/renderer/shaders/distant_bound.vert new file mode 100644 index 000000000..8e5577f0e --- /dev/null +++ b/pomme-client/src/renderer/shaders/distant_bound.vert @@ -0,0 +1,37 @@ +#version 450 + +// Voxy's `chunkoutline/outline.vsh`: a 16³ box per drawn chunk section, +// drawn depth-only so the far side of each box bounds what distant terrain +// may draw within loaded chunks (distant.frag discards nearer fragments). + +#include "camera_ubo.glsl" + +layout(push_constant) uniform BoundPc { + // The camera's view projection with the distant near and far planes. + mat4 distant_view_proj; +}; + +// Each drawn section's coordinates (gpu.rs `BoundStore`). +layout(set = 1, binding = 0) readonly buffer Sections { + ivec4 sections[]; +}; + +// The box's 12 triangles, wound counterclockwise from outside; corner bits +// are x, y, z. +const uint CORNERS[36] = uint[]( + 0u, 2u, 6u, 6u, 4u, 0u, + 1u, 5u, 7u, 7u, 3u, 1u, + 0u, 4u, 5u, 5u, 1u, 0u, + 2u, 3u, 7u, 7u, 6u, 2u, + 0u, 1u, 3u, 3u, 2u, 0u, + 4u, 6u, 7u, 7u, 5u, 4u +); + +void main() { + ivec3 origin = sections[gl_InstanceIndex].xyz * 16 - camera_block.xyz; + uint corner = CORNERS[gl_VertexIndex]; + vec3 point = vec3(origin + ivec3(corner & 1u, corner >> 1u & 1u, corner >> 2u) * 16); + gl_Position = distant_view_proj * vec4(point - camera_pos.xyz, 1.0); + // Brought a hair closer, so a face on the box's edge still counts as inside. + gl_Position.z -= 0.0005; +} diff --git a/pomme-client/src/renderer/shaders/distant_setup.frag b/pomme-client/src/renderer/shaders/distant_setup.frag new file mode 100644 index 000000000..2a32a0038 --- /dev/null +++ b/pomme-client/src/renderer/shaders/distant_setup.frag @@ -0,0 +1,19 @@ +#version 450 + +// Voxy's `post/setup_stencil_depth.frag`: distant terrain's depth target +// starts at the near plane wherever the chunks drew something, so distant +// terrain draws only where they didn't (Voxy uses a stencil bit; the depth +// alone does here, the swapchain depth having no stencil). + +layout(set = 0, binding = 0) uniform texture2D frame_depth; +layout(set = 0, binding = 1) uniform sampler depth_sampler; + +layout(location = 0) out vec4 out_color; + +void main() { + if (texelFetch(sampler2D(frame_depth, depth_sampler), ivec2(gl_FragCoord.xy), 0).r == 1.0) { + discard; + } + gl_FragDepth = 0.0; + out_color = vec4(0.0); +} diff --git a/pomme-client/src/world/chunk.rs b/pomme-client/src/world/chunk.rs index c16914369..e971b3d3d 100644 --- a/pomme-client/src/world/chunk.rs +++ b/pomme-client/src/world/chunk.rs @@ -482,11 +482,6 @@ fn ring_index(pos: ChunkPos, side: i32) -> usize { (pos.z.rem_euclid(side) * side + pos.x.rem_euclid(side)) as usize } -/// A column's slot in a [`ChunkRing`]. -pub(crate) fn ring_slot(pos: ChunkPos) -> usize { - ring_index(pos, RING_SIDE) -} - impl ChunkRing { pub fn new(mut init: impl FnMut() -> T) -> Self { Self { diff --git a/tests/ingame/README.md b/tests/ingame/README.md index 1fb4d76b7..8bb641a02 100644 --- a/tests/ingame/README.md +++ b/tests/ingame/README.md @@ -159,6 +159,12 @@ deterministic = true # holds flicker, particles, [[step]] wait_ms = 1000 +[[step]] +frame_times = { ms = 5000 } # times the frames for 5 s; the + # report line gives the frame + # rate, its 1% low, and the CPU + # and GPU-wait share of a frame + [[step]] screenshot = "equipped" # target/ingame/