Renderer: shrink the large translucent pages once the view has settled

Translucent Batching's vertex and sorted-index pages grow up to about 2 GiB
and never shrank, so a page kept its high-water size after the water that
filled it was gone (an ocean left behind, a lower render distance). Now a
page of 64 MiB or more that a right-sized page (grown(used)) would halve,
or make 256 MiB smaller, gets that page in video memory and is closed:
#3466's move_off_page empties it into the new page within the same copy
budget as the section arena's compaction (32 MiB a frame, 8 MiB for sorted
indices), and trim drops it. Each mesh's pointer switches in the step that
records its copy. With no room for the new page, or a range no mesh owns,
the page stays as it is and the next try waits 5 s.

A shrink starts only a second after terrain stops arriving, in a frame the
section arena's compaction copied nothing (ShrinkGate). The game reports
terrain arriving: the view's outermost columns not all there yet
(ViewArrival, capped at the server's view distance and given up 30 s after
the view last changed), events the server sent that wait to be handled, far
tiles being sampled, and, for those 30 s, re-meshes and pending uploads. The
renderer also waits while translucent sections or far tiles wait to upload.
A shrink under way finishes. move_off_page always moves the first range it
is given, so a range larger than the budget no longer keeps a page closed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
NerfPhaselockandClaude Opus 5.5 committed 2026-11-01 14:19:36 -05:00
1 parent 904ef97e51
commit b91226cac9
7 files changed
+580 -37

No files matched your search

+215 -28
View File
@@ -399,28 +399,65 @@ impl SectionGpu {
)
}
}
/// `Renderer::compact_arena`'s moves: the meshes' ranges on `page` of `arena`
/// go to its other pages in video memory, `budget` bytes of them at most, the
/// copies into `encoder` and the ranges' flat draw ids (`flat`) along. A mesh
/// moves what it holds in `arena`: split (`SectionGpu::vertex_ranges`), its
/// solid quads alone. False when the other pages have no room left.
/// Which of a mesh's ranges `move_off_page` moves: what it holds in the section
/// arena (`split`: its solid quads alone, `SectionGpu::vertex_ranges`), its
/// translucent quads in `translucent_arena`, or its sorted indices.
#[derive(Clone, Copy)]
enum Part {
Section { split: bool },
Translucent,
Indices,
}
impl Part {
/// (page, first vertex or index, count), as the release paths free it.
fn of(self, s: &SectionGpu) -> Option<(u32, u64, u64)> {
match self {
Self::Section { split } => s.vertex_ranges(split).0,
Self::Translucent => s.vertex_ranges(true).1,
Self::Indices => s
.sorted_indices
.map(|(page, at)| (page, at, u64::from(s.translucent) * 6)),
}
}
/// Points the mesh where its range moved.
fn set(self, s: &mut SectionGpu, to: (u32, u64)) {
match self {
Self::Section { split } => {
(s.page, s.start) = to;
if !split {
// Its translucent quads came along, after the solid ones.
s.translucent_at = (to.0, to.1 + u64::from(s.solid) * 4);
}
}
Self::Translucent => s.translucent_at = to,
Self::Indices => s.sorted_indices = Some(to),
}
}
}
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
/// until the next range would pass `budget` bytes (it keeps what is left; the
/// first range moves whatever its size, so none keeps the page closed), the
/// copies into `encoder` and, in the section arena, the ranges' flat draw ids
/// (`flat`) along. False when the other pages have no room left.
fn move_off_page<'a>(
arena: &mut arena::Arena,
encoder: &mut wgpu::CommandEncoder,
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
meshes: impl Iterator<Item = &'a mut SectionGpu>,
page: u32,
split: bool,
mut budget: u64,
part: Part,
budget: &mut u64,
) -> bool {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let mut moved = false;
for s in meshes {
// Split, a mesh without solid quads has nothing here: its (0, 0) is no
// range of page 0.
let Some((_, start, vertices)) = s.vertex_ranges(split).0.filter(|r| r.0 == page) else {
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
// range of page 0); in the other arenas, one without translucent quads.
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
continue;
};
if vertices * stride > budget {
let bytes = vertices * arena.stride();
if moved && bytes > *budget {
break;
}
let Some(to) = arena.alloc_moved(vertices) else {
@@ -432,15 +469,36 @@ fn move_off_page<'a>(
flat.remove(id);
s.flat_id = Some(flat.add(device, encoder, uploads, to.0, to.1, vertices));
}
(s.page, s.start) = to;
if !split {
// Its translucent quads came along, after the solid ones.
s.translucent_at = (to.0, to.1 + u64::from(s.solid) * 4);
}
budget -= vertices * stride;
part.set(s, to);
*budget = budget.saturating_sub(bytes);
moved = true;
}
true
}
/// Starts moving a page of a large arena far larger than its ranges in use
/// (`Arena::oversized`) into a new page of the size they get (`Arena::open_page`),
/// for `Renderer::shrink_translucent`: the page closes and `move_off_page` empties
/// it. None: no page to shrink. False, and nothing changes: no video memory for
/// the new page, or a range there without its mesh in `meshes`, which would keep
/// the page from ever emptying.
fn begin_shrink<'a>(
arena: &mut arena::Arena,
device: &wgpu::Device,
meshes: impl Iterator<Item = &'a mut SectionGpu>,
part: Part,
) -> Option<bool> {
let (page, capacity) = arena.oversized()?;
let held: u64 = meshes
.filter_map(|s| part.of(s).filter(|r| r.0 == page))
.map(|(.., n)| arena::Arena::span(n))
.sum();
debug_assert_eq!(held, arena.used(page), "a range in use without its mesh");
if held != arena.used(page) || !arena.open_page(device, capacity) {
return Some(false);
}
arena.close(page);
Some(true)
}
/// Line segments the hit outline can hold.
const MAX_OUTLINE_EDGES: u64 = 256;
/// Staging buffer size for section meshes: a frame of uploads while
@@ -449,6 +507,30 @@ const SECTION_UPLOAD_CHUNK: u64 = 4 << 20;
/// Section copies are submitted early past this many staged bytes, which
/// bounds the staging memory frames that are not drawn can hold.
const SECTION_COPY_LIMIT: u64 = 64 << 20;
/// What compaction copies in a frame, `compact_arena` first and
/// `shrink_translucent` with what it left, and a range more where a call's
/// first is larger (`move_off_page`): a page empties over a few frames, with
/// no hitch.
const COMPACT_BUDGET: u64 = 32 << 20;
/// A large translucent page starts to shrink (`Renderer::shrink_translucent`)
/// this long after terrain last arrived, and `SHRINK_RETRY` after a try found
/// no room.
const SHRINK_SETTLE: std::time::Duration = std::time::Duration::from_secs(1);
const SHRINK_RETRY: std::time::Duration = std::time::Duration::from_secs(5);
/// Not before then may a large translucent page start to shrink.
#[derive(Default)]
struct ShrinkGate(Option<Instant>);
impl ShrinkGate {
/// Not before `wait` from `now`, nor before an earlier wait is over.
fn wait(&mut self, now: Instant, wait: std::time::Duration) {
self.0 = self.0.max(Some(now + wait));
}
/// Whether one may start now: the waits are over, and `compact_arena` copied
/// nothing this frame (it `left` the whole budget).
fn may_start(&self, now: Instant, left: u64) -> bool {
left == COMPACT_BUDGET && self.0.is_none_or(|at| now >= at)
}
}
pub struct Frame<'a> {
pub camera: DVec3,
pub yaw: f32,
@@ -795,6 +877,8 @@ pub struct Renderer {
section_copies: Option<wgpu::CommandEncoder>,
/// Bytes staged in `section_copies`.
section_copy_bytes: u64,
/// When a large translucent page may start to shrink (`shrink_translucent`).
shrink_gate: ShrinkGate,
/// Whether `lightmap` holds `lightmap_pixels` yet.
lightmap_uploaded: bool,
sprite_minimum_size: u32,
@@ -3546,6 +3630,7 @@ impl Renderer {
section_uploads,
section_copies: None,
section_copy_bytes: 0,
shrink_gate: ShrinkGate::default(),
lightmap_uploaded: false,
sprite_minimum_size,
indices,
@@ -3902,6 +3987,10 @@ impl Renderer {
if !self.lod_retiring.is_empty() {
self.lod_retiring.remove(&key);
}
if !mesh.translucent.is_empty() && !self.sections.contains(key) {
// New translucent terrain, not an edit's remesh: large pages wait for it.
self.shrink_gate.wait(Instant::now(), SHRINK_SETTLE);
}
let fading = match self.sections.find(key) {
Some(old) => old.fading,
None => (!self.lods.is_empty()).then(std::time::Instant::now),
@@ -4136,19 +4225,18 @@ impl Renderer {
/// replaced): moves the meshes off the sparsest page, a few MiB a frame,
/// and `Arena::trim` returns the page once it is empty. Frames with the
/// arena in system memory (video memory full) are slow, so this is what
/// keeps the terrain in video memory. The section arena's alone: the
/// translucent arena and the sorted indices keep their pages
/// (`Arena::translucent`).
fn compact_arena(&mut self) {
// Copied per frame: a page empties over a few frames, with no hitch.
const BUDGET: u64 = 32 << 20;
/// keeps the terrain in video memory. The section arena's: the large pages
/// of the translucent arena and the sorted indices are `shrink_translucent`'s.
/// Returns what is left of the frame's `COMPACT_BUDGET`.
fn compact_arena(&mut self) -> u64 {
let mut budget = COMPACT_BUDGET;
if self.arena.closing().is_none()
&& let Some(page) = self.arena.sparsest()
{
self.arena.close(page);
}
let Some(page) = self.arena.closing() else {
return;
return budget;
};
let encoder = self.section_copies.get_or_insert_with(|| {
self.device
@@ -4165,12 +4253,77 @@ impl Renderer {
.as_mut()
.map(|f| (f, &self.device, &mut self.section_uploads));
let split = self.translucent_arena.is_some();
if !move_off_page(&mut self.arena, encoder, flat, meshes, page, split, BUDGET) {
let (arena, part) = (&mut self.arena, Part::Section { split });
if !move_off_page(arena, encoder, flat, meshes, page, part, &mut budget) {
// The other pages have no room after all.
self.arena.reopen();
arena.reopen();
}
// With every mesh moved off, the page stays closed so this frame's
// uploads can't refill it: `trim` returns it and clears `closing`.
budget
}
/// Shrinks the large pages of the translucent arena and the sorted indices
/// once terrain has settled (`set_terrain_loading`): one far larger than what
/// it holds moves into a new page of their size (`begin_shrink`), through
/// `move_off_page` with what `compact_arena` left of the frame's budget
/// (`left`), and `trim` returns it once empty. One page at a time, vertices
/// first; only starts wait, a move under way finishes even if terrain comes
/// again (stopped, the page would stay closed while the new one fills).
fn shrink_translucent(&mut self, left: u64) {
let Some(vertices) = self.translucent_arena.as_mut() else {
return; // split off: nothing to shrink
};
let now = Instant::now();
let mut arenas = [
(vertices, Part::Translucent),
(&mut self.sorted_indices, Part::Indices),
];
if arenas.iter().all(|(arena, _)| arena.closing().is_none()) {
if !self.shrink_gate.may_start(now, left) {
return;
}
// The next start in a second, or `SHRINK_RETRY` after a try found no room.
let mut wait = SHRINK_SETTLE;
for (arena, part) in &mut arenas {
let meshes = self
.sections
.values_mut()
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
match begin_shrink(arena, &self.device, meshes, *part) {
Some(true) => break,
Some(false) => wait = SHRINK_RETRY, // the other arena may still go
None => {}
}
}
self.shrink_gate.wait(now, wait);
}
let Some((arena, part, page)) = arenas.into_iter().find_map(|(arena, part)| {
let page = arena.closing()?;
Some((arena, part, page))
}) else {
return;
};
let encoder = self.section_copies.get_or_insert_with(|| {
self.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Section uploads"),
})
});
let meshes = self
.sections
.values_mut()
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
// As many quads a frame either way: a quad's indices are a quarter of its bytes.
let cap = match part {
Part::Indices => COMPACT_BUDGET / 4,
_ => COMPACT_BUDGET,
};
let mut budget = left.min(cap);
if !move_off_page(arena, encoder, None, meshes, page, part, &mut budget) {
// New ranges took the room: both pages stay, drawn, until a later try.
arena.reopen();
self.shrink_gate.wait(now, SHRINK_RETRY);
}
}
/// Block-entity blocks of every meshed section (see `SectionMesh::block_entities`).
pub fn block_entities(&self) -> impl Iterator<Item = &([i32; 3], u32)> {
@@ -4265,6 +4418,17 @@ impl Renderer {
pub fn take_dropped_sections(&mut self) -> bool {
std::mem::take(&mut self.sections_dropped)
}
/// Whether terrain is on its way (the view not all in, events, chunks or
/// uploads still waiting, far tiles sampled): a large translucent page shrinks
/// once that, new translucent sections (`upload`) and far tiles to upload
/// (`upload_lods`) have been over for `SHRINK_SETTLE`, not at the low point
/// between a view's columns leaving and the next one's arriving, after which
/// it would grow back by whole copies.
pub fn set_terrain_loading(&mut self, loading: bool) {
if loading {
self.shrink_gate.wait(Instant::now(), SHRINK_SETTLE);
}
}
/// A column whose every section has its mesh (`lod_detail_columns`).
fn complete_column(&self) -> SectionMask {
SectionMask::below(self.layout.sections())
@@ -5099,11 +5263,12 @@ impl Renderer {
self.follow_translucent_layout();
}
self.arena.trim();
self.compact_arena();
let left = self.compact_arena();
if let Some(arena) = &mut self.translucent_arena {
arena.trim();
}
self.sorted_indices.trim();
self.shrink_translucent(left);
if let Some(flat) = &mut self.flat {
flat.trim(|page| self.arena.has_page(page));
}
@@ -8558,6 +8723,28 @@ mod tests {
assert!(!split(false, false, false, false));
}
/// No large translucent page starts to shrink until `SHRINK_SETTLE` after terrain
/// last arrived, nor in a frame `compact_arena` copied in; terrain arriving after
/// a try that found no room does not cut its `SHRINK_RETRY` short. Only starts
/// wait: a move under way finishes, or its page would stay closed while the new
/// one fills.
#[test]
fn shrinking_starts_once_terrain_has_settled() {
use super::{COMPACT_BUDGET, SHRINK_RETRY, SHRINK_SETTLE, ShrinkGate};
use std::time::{Duration, Instant};
let (now, ms) = (Instant::now(), Duration::from_millis(1));
let mut gate = ShrinkGate::default();
assert!(gate.may_start(now, COMPACT_BUDGET));
assert!(!gate.may_start(now, COMPACT_BUDGET - 1));
gate.wait(now, SHRINK_SETTLE);
assert!(!gate.may_start(now + SHRINK_SETTLE - ms, COMPACT_BUDGET));
assert!(gate.may_start(now + SHRINK_SETTLE, COMPACT_BUDGET));
gate.wait(now, SHRINK_RETRY);
gate.wait(now + ms, SHRINK_SETTLE);
assert!(!gate.may_start(now + SHRINK_RETRY - ms, COMPACT_BUDGET));
assert!(gate.may_start(now + SHRINK_RETRY, COMPACT_BUDGET));
}
#[test]
fn the_mirror_sees_each_point_where_its_reflection_is() {
use glam::{Mat4, Vec3, Vec4};
+242 -8
View File
@@ -100,6 +100,9 @@ impl Page {
/// An arena with this many bytes in free ranges, at least half of its pages,
/// has a page worth emptying (`Arena::sparsest`).
const COMPACT_MIN_FREE: u64 = 128 << 20;
/// A page of at least this many bytes shrinks (`Arena::oversized`) when a page
/// of its ranges in use halves it or is `PAGE_BYTES` smaller.
const SHRINK_MIN_BYTES: u64 = 64 << 20;
/// Terrain vertices in pages of at most `page_limit` vertices (devices cap
/// buffer sizes): a section's vertices sit in one page.
@@ -139,10 +142,10 @@ impl Arena {
Self::with_usage(device, 4, capacity, usage, PAGE_BYTES)
}
/// Translucent quads' vertices, which no shader reads as storage: pages
/// up to `LARGE_PAGE_BYTES`. The section arena's compaction never runs on
/// it (`Renderer::compact_arena`): a page this large has no others to
/// empty into, and the compaction moves sections' `page` ranges, not their
/// `translucent_at`.
/// up to `LARGE_PAGE_BYTES`. A page this large has no others to empty into
/// (`sparsest`): once the view has settled, one far larger than its ranges
/// in use moves into a new page of their size (`oversized`, `open_page`;
/// `Renderer::shrink_translucent`).
pub fn translucent(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
let usage = wgpu::BufferUsages::VERTEX;
Self::with_usage(device, stride, capacity, usage, LARGE_PAGE_BYTES)
@@ -241,7 +244,7 @@ impl Arena {
})
}
/// The rounded length a request of `vertices` occupies.
fn span(vertices: u64) -> u64 {
pub fn span(vertices: u64) -> u64 {
vertices.max(1).next_multiple_of(GRAIN)
}
/// Bytes of every page's buffer.
@@ -278,6 +281,15 @@ impl Arena {
.expect("live arena page")
.buffer
}
/// Bytes of one vertex (or index).
pub fn stride(&self) -> u64 {
self.stride
}
/// Vertices in `page`'s ranges in use (as `span` rounds them).
pub fn used(&self, page: u32) -> u64 {
let page = self.pages[page as usize].as_ref().expect("live arena page");
page.capacity - page.free_vertices()
}
/// A range of `vertices`: (page, first vertex). Grows the newest page
/// (copying its contents on the GPU, in `encoder`) or starts a new one
/// when nothing free is large enough; `None` when no memory is left for
@@ -402,6 +414,49 @@ impl Arena {
.min()
.map(|(_, _, i)| i)
}
/// A page far larger than its ranges in use, and the capacity a page of them
/// gets (`grown`: twice theirs, or `PAGE_BYTES` more; at least the first
/// page's): one of `SHRINK_MIN_BYTES` or more, with ranges in use (an empty
/// one is `trim`'s), that this halves or makes `PAGE_BYTES` smaller. In
/// system memory too (`open_page` makes the new one in video memory). None
/// while a page is closing; the page it leads to is not oversized until its
/// ranges change.
pub fn oversized(&self) -> Option<(u32, u64)> {
if self.closing.is_some() {
return None;
}
let saving = PAGE_BYTES / self.stride;
self.pages.iter().enumerate().find_map(|(i, p)| {
let p = p.as_ref()?;
let used = p.capacity - p.free_vertices();
let capacity = self
.grown(used, 0)
.max(self.initial_capacity)
.next_multiple_of(GRAIN)
.min(self.page_limit);
let smaller = 2 * capacity <= p.capacity || capacity + saving <= p.capacity;
(p.capacity * self.stride >= SHRINK_MIN_BYTES && used > 0 && smaller)
.then_some((i as u32, capacity))
})
}
/// A new, empty page of `capacity` vertices in video memory, made the newest
/// (the one that grows), for what `Renderer::shrink_translucent` moves off an
/// `oversized` page (`alloc_moved`) and for new ranges meanwhile. False, and
/// nothing changes, when video memory has no room for it: in system memory it
/// would slow every frame that draws it.
pub fn open_page(&mut self, device: &wgpu::Device, capacity: u64) -> bool {
let Some((buffer, host)) = self.create(device, capacity, false) else {
return false;
};
let free = FreeList::new(capacity);
self.pages.push(Some(Page {
buffer,
capacity,
free,
host,
}));
true
}
/// Stops placing vertices in `page` until `reopen`.
pub fn close(&mut self, page: u32) {
self.closing = Some(page);
@@ -690,7 +745,7 @@ mod tests {
/// `translucent_at` along.
#[test]
fn compaction_moves_what_sections_hold_in_the_section_arena() {
use super::super::{SectionGpu, move_off_page};
use super::super::{Part, SectionGpu, move_off_page};
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
@@ -756,14 +811,15 @@ mod tests {
arena.close(0);
let mut encoder = device.create_command_encoder(&Default::default());
let meshes = sections.iter_mut();
let mut budget = u64::MAX;
assert!(move_off_page(
&mut arena,
&mut encoder,
None,
meshes,
0,
split,
u64::MAX
Part::Section { split },
&mut budget
));
queue.submit([encoder.finish()]);
let word =
@@ -954,6 +1010,184 @@ mod tests {
);
}
/// A large page far larger than its ranges in use moves into a new page of their
/// size (`begin_shrink`) over frames, through `move_off_page`: a budget a call,
/// its first range whatever its size, data and all, while new ranges, writes and
/// releases go on around the moves in one encoder, as the renderer records them.
/// The other range of each mesh stays; the emptied page takes no new range until
/// `trim` returns it. Translucent vertices and sorted indices alike.
#[test]
fn a_large_page_moves_into_one_of_the_size_its_ranges_get() {
use super::super::{Part, SectionGpu, begin_shrink, move_off_page};
// Where system memory could take the new page, it must not.
let Some((device, queue)) = device_with(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS) else {
eprintln!("no GPU adapter: skipped");
return;
};
for (mut arena, part) in [
(Arena::translucent(&device, 24, GRAIN), Part::Translucent),
(Arena::translucent_indices(&device, GRAIN), Part::Indices),
] {
let indices = matches!(part, Part::Indices);
let stride = arena.stride;
// 64 quads a mesh: their vertices, or their sorted indices.
let len: u64 = if indices { 64 * 6 } else { 64 * 4 };
let span = len.next_multiple_of(GRAIN);
// Its range here at `at`; the other one names a page that is not there.
let mesh = |at: (u32, u64), solid: u32, translucent: u32| SectionGpu {
page: 0,
start: 0,
translucent_at: if indices { (7, 7) } else { at },
solid,
sides: crate::mesher::Sides::unsorted(solid),
translucent,
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: (translucent > 0).then_some(if indices { at } else { (7, 7) }),
flat_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
};
let mut uploads = super::super::upload::Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
// As the renderer writes: through a belt, in the encoder's order.
let mut write = |encoder: &mut _, buffer: &wgpu::Buffer, at: u64, value: u32| {
uploads.buffer(encoder, buffer, at * stride, &value.to_le_bytes())
};
// Sixteen meshes with their number at both ends of their range, and one
// of solid quads alone (nothing here).
let mut meshes: Vec<(u32, SectionGpu)> = (0..16)
.map(|i| {
let at = arena.alloc(&device, &mut encoder, len).unwrap();
write(&mut encoder, arena.buffer(at.0), at.1, i);
write(&mut encoder, arena.buffer(at.0), at.1 + len - 1, i);
(i, mesh(at, 0, 64))
})
.collect();
meshes.push((99, mesh((0, 0), 64, 0)));
// Grown past `SHRINK_MIN_BYTES` in the same encoder (the writes before
// reach the grown buffer), then mostly freed, as after a teleport.
let big = (64u64 << 20).div_ceil(stride);
let at = arena.alloc(&device, &mut encoder, big).unwrap();
arena.release(at.0, at.1, big);
for (_, s) in meshes.extract_if(.., |(i, _)| ![0, 3, 6, 9, 12, 99].contains(i)) {
let (page, start, n) = part.of(&s).unwrap();
arena.release(page, start, n);
}
// No video memory for the new page: nothing changes, nothing spills.
arena.video_memory_full = true;
let held = meshes.iter_mut().map(|(_, s)| s);
assert_eq!(begin_shrink(&mut arena, &device, held, part), Some(false));
assert_eq!((arena.closing(), arena.pages.len()), (None, 1));
assert!(!arena.spilled);
arena.video_memory_full = false;
let held = meshes.iter_mut().map(|(_, s)| s);
assert_eq!(begin_shrink(&mut arena, &device, held, part), Some(true));
assert_eq!(arena.closing(), Some(0));
// Twice the five meshes' ranges.
assert_eq!(arena.pages[1].as_ref().unwrap().capacity, 10 * span);
// New ranges go to the new page meanwhile.
let upload = arena.alloc(&device, &mut encoder, GRAIN).unwrap();
assert_eq!(upload.0, 1);
write(&mut encoder, arena.buffer(1), upload.1, 77);
// Moves what page 0 holds within `budget`: how many meshes page 1 has then.
let moved = |arena: &mut Arena,
encoder: &mut _,
meshes: &mut [(u32, SectionGpu)],
budget: &mut u64| {
let held = meshes.iter_mut().map(|(_, s)| s);
assert!(move_off_page(arena, encoder, None, held, 0, part, budget));
let on = |s: &SectionGpu| part.of(s).is_some_and(|r| r.0 == 1);
meshes.iter().filter(|(_, s)| on(s)).count()
};
let mut budget = 2 * len * stride;
let on = moved(&mut arena, &mut encoder, &mut meshes, &mut budget);
assert_eq!((on, budget), (2, 0));
// Out of budget, a call still moves its first range.
let on = moved(&mut arena, &mut encoder, &mut meshes, &mut budget);
assert_eq!(on, 3);
// The sorter writes where #0 is now; a mesh still on the old page goes.
let (page, start, _) = part.of(&meshes[0].1).unwrap();
write(&mut encoder, arena.buffer(page), start, 100);
let gone = meshes
.iter()
.position(|(_, s)| part.of(s).is_some_and(|r| r.0 == 0));
let (page, start, n) = part.of(&meshes.remove(gone.unwrap()).1).unwrap();
arena.release(page, start, n);
budget = u64::MAX;
let on = moved(&mut arena, &mut encoder, &mut meshes, &mut budget);
assert_eq!(on, 4);
// Emptied, it stays closed until `trim`.
assert_eq!((arena.used(0), arena.closing()), (0, Some(0)));
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
let word = |page, at: u64| read(&device, &queue, arena.buffer(page), at * stride);
for (i, s) in &meshes {
let Some((page, start, n)) = part.of(s) else {
// Solid quads alone: as it was.
let at = if indices { (7, 7) } else { (0, 0) };
assert_eq!((*i, s.translucent_at, s.sorted_indices), (99, at, None));
continue;
};
assert_eq!(page, 1);
assert_eq!(word(page, start), if *i == 0 { 100 } else { *i });
assert_eq!(word(page, start + n - 1), *i);
// Its other range stays where it was.
assert!(s.translucent_at == (7, 7) || s.sorted_indices == Some((7, 7)));
}
assert_eq!(word(upload.0, upload.1), 77);
arena.trim();
assert!(!arena.has_page(0));
assert_eq!(arena.closing(), None);
assert_eq!(arena.bytes(), 10 * span * stride);
}
}
/// Large pages shrink when a page of their ranges in use (what `grown` makes of
/// them, at least the first page's size) halves them or is `PAGE_BYTES`
/// smaller: at the smallest page that shrinks and at the largest.
#[test]
fn large_pages_shrink_when_that_halves_them_or_saves_a_page() {
let Some((device, _)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::translucent(&device, 24, 1 << 18);
arena.page_limit = LARGE_PAGE_BYTES / 24 / GRAIN * GRAIN;
let (limit, step) = (arena.page_limit, PAGE_BYTES / 24);
// `oversized` looks at the counts only: a small buffer stands in.
let buffer = try_create(&device, 4, wgpu::BufferUsages::VERTEX).unwrap();
let mut shrinks = |capacity: u64, used: u64, host: bool| {
let free = FreeList(BTreeMap::from([(used, capacity - used)]));
let buffer = buffer.clone();
arena.pages = vec![Some(Page {
buffer,
capacity,
free,
host,
})];
arena.oversized().map(|(_, to)| to)
};
let least = (64u64 << 20).div_ceil(24).next_multiple_of(GRAIN);
let quarter = least / 4 / GRAIN * GRAIN;
assert_eq!(shrinks(least, quarter, false), Some(2 * quarter));
assert_eq!(shrinks(least, quarter + GRAIN, false), None);
let used = (limit - 2 * step) / GRAIN * GRAIN;
let target = (used + step).next_multiple_of(GRAIN);
assert_eq!(shrinks(limit, used, false), Some(target));
assert_eq!(shrinks(limit, used + GRAIN, false), None);
// The page it leads to, holding the same, is not oversized.
assert_eq!(shrinks(target, used, false), None);
// Not below the first page's size; smaller pages and empty ones stay.
assert_eq!(shrinks(least, GRAIN, false), Some(1 << 18));
assert_eq!(shrinks(least - GRAIN, GRAIN, false), None);
assert_eq!(shrinks(least, 0, false), None);
// One in system memory goes back to video memory; none while one closes.
assert_eq!(shrinks(least, quarter, true), Some(2 * quarter));
arena.closing = Some(0);
assert_eq!(arena.oversized(), None);
}
#[test]
fn clearing_an_arena_releases_buffers_and_can_allocate_again() {
let Some((device, queue)) = device() else {
@@ -276,6 +276,10 @@ impl Renderer {
/// once all are in, fades them in over the tiles they replace.
pub fn upload_lods(&mut self, budget: Duration) {
let start = Instant::now();
if self.lod_pending.is_some() {
// Far tiles fill the arenas too: large translucent pages wait for them.
self.shrink_gate.wait(start, super::SHRINK_SETTLE);
}
self.release_left(start);
let faded: Vec<_> = self
.lods
+5
View File
@@ -1611,6 +1611,11 @@ impl Lod {
pub fn observing(&self) -> usize {
self.shared.observed.lock().unwrap().len()
}
/// Whether tiles new to the plan are being sampled: far terrain on its way,
/// meshed as soon as it is.
pub fn sampling(&self) -> bool {
!self.pending.is_empty()
}
/// Keeps a visited chunk's summary, unless it is unchanged.
fn finish_observed(&mut self, key: (i32, i32), columns: ReducedColumns) {
if self
@@ -2073,6 +2073,59 @@ pub fn in_view_distance(center: (i32, i32), radius: i32, x: i32, z: i32) -> bool
let (dx, dz) = (gap(x - center.0), gap(z - center.1));
dx * dx + dz * dz < i64::from(radius) * i64::from(radius)
}
/// How long a view's columns are waited for (`ViewArrival`): a server may never
/// fill it.
const VIEW_WAIT: std::time::Duration = std::time::Duration::from_secs(30);
/// Whether the columns of a view (centre, radius) have all arrived, as a client
/// sees them come: a server forgets the old view's columns, then sends the new
/// ones nearest first, so the view's outermost ones come last. Single player
/// joins its integrated server the same way, so `Streaming::busy` cannot see
/// what is not sent yet.
#[derive(Default)]
pub struct ViewArrival {
view: ((i32, i32), i32),
since: Option<std::time::Instant>,
arrived: bool,
}
impl ViewArrival {
/// Whether columns of `view` have yet to arrive (`has`): its outermost ones,
/// at both ends of each of its rows and columns (`in_view_distance`). Once
/// they have, false until the view changes; false too once it is no longer
/// `recent`, and for a radius below 0 (no view).
pub fn arriving(
&mut self,
view: ((i32, i32), i32),
now: std::time::Instant,
has: impl Fn((i32, i32)) -> bool,
) -> bool {
if self.since.is_none() || view != self.view {
(self.view, self.since, self.arrived) = (view, Some(now), false);
}
let ((x, z), r) = view;
if self.arrived || r < 0 || !self.recent(now) {
return false;
}
self.arrived = (-r..=r).all(|d| {
// How far row (and column) `d` reaches: `in_view_distance` solved for it.
let gap = i64::from((d.abs() - 1).max(0));
let room = i64::from(r).pow(2) - gap * gap;
room <= 0 || {
let e = (room - 1).isqrt() as i32 + 1;
// Both ends of row `d`, and of column `d`.
[-e, e]
.into_iter()
.all(|e| has((x + d, z + e)) && has((x + e, z + d)))
}
});
!self.arrived
}
/// Whether the view `arriving` was last given changed less than `VIEW_WAIT`
/// ago: until then sections meshed are its terrain coming in, later an edit's.
pub fn recent(&self, now: std::time::Instant) -> bool {
self.since
.is_some_and(|since| now.saturating_duration_since(since) < VIEW_WAIT)
}
}
/// The rings around a `/forceload`ed chunk, in chunks. Vanilla's FORCED
/// ticket is level 31 (`ChunkMap.FORCED_TICKET_LEVEL`), the level rises one
/// per ring (`ChunkTracker.computeLevelFromNeighbor`), and the level picks
@@ -2801,6 +2854,45 @@ mod tests {
}
}
/// A view has arrived once the columns at both ends of each of its rows and
/// columns are in: exactly those with a neighbour outside it, at every radius.
/// Then it stays arrived until it changes; a view is `recent`, and waited for,
/// `VIEW_WAIT` at most, and one of no radius not at all.
#[test]
fn a_view_has_arrived_once_its_outermost_columns_are_in() {
let start = std::time::Instant::now();
let center = (3, -4);
for r in 0..=16 {
let view: FxHashSet<(i32, i32)> = (-r - 1..=r + 1)
.flat_map(|x| (-r - 1..=r + 1).map(move |z| (center.0 + x, center.1 + z)))
.filter(|&(x, z)| in_view_distance(center, r, x, z))
.collect();
let arriving = |has: &dyn Fn((i32, i32)) -> bool| {
ViewArrival::default().arriving((center, r), start, has)
};
assert!(!arriving(&|c| view.contains(&c)), "radius {r}");
for &(x, z) in &view {
let edge = [(x + 1, z), (x - 1, z), (x, z + 1), (x, z - 1)]
.iter()
.any(|c| !view.contains(c));
let missing = arriving(&|c| c != (x, z) && view.contains(&c));
assert_eq!(missing, edge, "radius {r}, column ({x}, {z})");
}
}
let mut arrival = ViewArrival::default();
let (none, all) = (|_| false, |_| true);
assert!(arrival.arriving(((0, 0), 2), start, none));
assert!(!arrival.arriving(((0, 0), 2), start, all));
assert!(!arrival.arriving(((0, 0), 2), start, none));
// Moved: waited for again, until `VIEW_WAIT` after the move.
let (late, ms) = (start + VIEW_WAIT, std::time::Duration::from_millis(1));
assert!(arrival.arriving(((1, 0), 2), start, none));
assert!(arrival.arriving(((1, 0), 2), late - ms, none));
assert!(arrival.recent(late - ms) && !arrival.recent(late));
assert!(!arrival.arriving(((1, 0), 2), late, none));
assert!(!arrival.arriving(((1, 0), -1), start, none));
}
struct GenerationFixture {
world: World,
center: (i32, i32),
+21
View File
@@ -2172,6 +2172,9 @@ struct Game<'a> {
far_beacons: HashMap<(i32, i32, i32), Vec<(u32, i32)>>,
/// The detail revision, centre and radius `Lod::update` last had covered for.
lod_covered_at: Option<(u64, (i32, i32), i32)>,
/// Whether the view's columns have all arrived, and how new it is: large
/// translucent pages wait for both before they shrink (`set_terrain_loading`).
view_arrival: horizon_client::streaming::ViewArrival,
options: Options,
player: Option<PlayerMotion>,
spawn: Option<Vec3>,
@@ -16048,6 +16051,17 @@ impl Game<'_> {
log::warn!("Horizon: failed to save unloaded chunks: {e}");
}
let (far_distance, far_reduced) = self.far_terrain_choice();
// Terrain still on its way from the server (single player's integrated one
// too): the view's outermost columns not all in, or its events not all handled.
let radius = match &self.remote {
// As far as the server fills it: its view distance and one.
Some(r) => self.streaming.radius.min(r.view_distance + 1),
None => self.streaming.radius,
};
let view = (self.streaming.center, radius);
let has = |column: (i32, i32)| self.world.chunks.contains_key(&column);
let now = Instant::now();
let arriving = self.view_arrival.arriving(view, now, has) || self.remote_backlog() > 0;
if let Some(renderer) = self.renderer.as_mut() {
let g = &self.settings.horizon;
let budgeted = g.chunk_updates > 0 || g.upload_budget_kib > 0;
@@ -16118,6 +16132,12 @@ impl Game<'_> {
self.streaming.free(spent);
}
renderer.remove_columns(unloaded.iter().map(|c| (c.x, c.z)));
// Meshes in the works are the view's terrain while it is new, then edits'.
let meshing = self.streaming.busy() || !self.pending_uploads.is_empty();
let sampling = self.lod.as_ref().is_some_and(|lod| lod.sampling());
renderer.set_terrain_loading(
arriving || (meshing && self.view_arrival.recent(now)) || sampling,
);
if let (Some(lod), Some(player)) = (&mut self.lod, &self.player) {
let lod_distance =
horizon_client::graphics::lod_distance_capped(far_distance, &renderer.info);
@@ -17824,6 +17844,7 @@ impl<'a> Game<'a> {
far_beacons: HashMap::new(),
lod_observe: Default::default(),
lod_covered_at: None,
view_arrival: Default::default(),
options,
player: None,
spawn: None,
+1 -1
View File
@@ -33,7 +33,7 @@ Every row has a one-line description and its cost in frame time (none, low, medi
**Battery mode** (Performance, off by default) is for laptops and handhelds: at most 60 frames per second in a world, 30 in menus without a world or while another window has focus, and half the automatic worker threads (chunks load slower). It changes only when frames are drawn: what they show and the game's 20 ticks per second stay as they are.
**Translucent batching** (only in the file for now: `translucentBatching` in `horizon-graphics.txt`, off by default; edit it with the game closed, it applies at the next start) gives water, glass, ice and other translucent quads a vertex buffer of their own, with pages up to 2 GiB, and their sorted indices the same: the back-to-front list then stays in one page and goes out in a few draw calls instead of switching pages every few sections. The picture is the same; it costs a little video memory, as solid and translucent quads no longer share free space. It needs indirect multi-draw that isn't OpenGL's (which loops over the entries on the CPU), and devices whose vanilla frames draw flat (Metal, Intel Gen11 and Gen12 on Vulkan) don't offer it. A `shaders/` pack's terrain and shadow passes, and the `[shadow]` pass of one of Horizon's own packs, draw flat where the device can, reading translucent quads beside their section's solid ones: while such a pack runs they go back there. Such a pack picked at the start has the world laid out for it from the first; switching to or from one later meshes the world again.
**Translucent batching** (only in the file for now: `translucentBatching` in `horizon-graphics.txt`, off by default; edit it with the game closed, it applies at the next start) gives water, glass, ice and other translucent quads a vertex buffer of their own, with pages up to 2 GiB, and their sorted indices the same: the back-to-front list then stays in one page and goes out in a few draw calls instead of switching pages every few sections. The picture is the same; it costs a little video memory, as solid and translucent quads no longer share free space. The large pages shrink once the view has settled (a second after terrain stops arriving): one much larger than what it holds, as after a teleport or a lower render distance, moves into a page of the right size, about 32 MiB a frame. It needs indirect multi-draw that isn't OpenGL's (which loops over the entries on the CPU), and devices whose vanilla frames draw flat (Metal, Intel Gen11 and Gen12 on Vulkan) don't offer it. A `shaders/` pack's terrain and shadow passes, and the `[shadow]` pass of one of Horizon's own packs, draw flat where the device can, reading translucent quads beside their section's solid ones: while such a pack runs they go back there. Such a pack picked at the start has the world laid out for it from the first; switching to or from one later meshes the world again.
**HDR** (off by default) lives on its own screen, Graphics: HDR, behind **HDR Settings...** under Display, which is greyed where the display path offers no HDR output (so players without an HDR display don't take its settings for their display's); there the switch comes first and the sliders below it only while it is on. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), asked and then watched while HDR output is on (with HDR off the game doesn't ask), so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; these often come from the display's EDID and may run optimistic, and this is untested so far. **Calibrate...** (`hdr_calibration.rs`) sets Black Level, Peak Brightness and Paper White full screen, one test picture at a time, drawn by the last pass straight in nits above a band of controls: Horizon's logo at the slider on black, lowered until it disappears; the logo at the slider inside a tenth of the screen at 10 000 nits (small, so the display doesn't dim), raised until it disappears; a white card, set to a white that's comfortable in the light the player plays in. Save writes the three, Cancel or Escape keeps what was set, and a step left untouched keeps Auto. The Peak step needs a path that doesn't tone-map: on KWin the logo never disappears, so where the system reports its values the button reads **Calibrate Anyway...** and says the display is already set up (**Use Automatic Setup** once they were changed by hand). Where nothing is reported, Auto is 203 and 1000 nits, and the Graphics: HDR screen asks the player to calibrate. **Black Level** (0 by default, for an OLED's true black) is the display's darkest black, in nits: the last pass lifts the shadows onto it with ITU-R BT.2390's black level lift in PQ, which leaves the peak where it is and fades out by the fourth power above black, and the metadata gives it as the minimum luminance; at 0 the picture is unchanged. **UI Brightness** (100% by default) sets the HUD and menus against paper white: once the world is kept for the last pass, the frame's alpha is zeroed, everything drawn after raises it where it covers the world, the first-person hand's is then scaled down to 1/255 (it stays at the world's brightness), and the last pass scales the rest by UI Brightness and lifts none of it (so text over the sun stays text). With HDR on, F2 writes vanilla's screenshot as always and an HDR10 copy beside it (`…-hdr.png`): the HDR pass drawn once more into a 10-bit PQ target, whatever the swapchain is, saved as a 16-bit PNG tagged BT.2020 with PQ (`cICP`, PNG's third edition) and the peak and paper white as content light levels (`cLLI`); viewers that read `cICP` show it in HDR, others show it washed out. Not lifted yet: translucent blocks (the nether portal), distant LOD terrain.