Merge pull request #8621 from itarqos5/feat/lod-detail-when-zoomed

🤖 Agent: the zoom cone only boosts tiles covers() says are looked at, and the test pins the plan behind the player as identical zoomed or not; I made lodZoomDetail off by default until a measurement shows the extra far tiles land while the zoom is held.
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-11-04 16:19:19 +01:00
commit 2e5a2629a0
4 files changed
+330 -42

No files matched your search

+11 -1
View File
@@ -78,6 +78,13 @@ pub struct Extras {
pub zoom_smooth: bool,
/// Divide mouse turning by the zoom.
pub zoom_sensitivity: bool,
/// Far terrain a zoom magnifies is planned finer, so a spyglass does not
/// show the coarse cells it was given for an unzoomed screen
/// (`lod::Aim`). Costs far tiles while the zoom is held and gives them
/// back after, and does nothing at all unless far terrain is on
/// (`lodDistance`) and the view is actually zoomed. Off by default until
/// a measurement shows the extra tiles land while the zoom is still held.
pub lod_zoom_detail: bool,
/// The FPS counter and keystrokes extras are official mods now: their
/// old settings stay here, kept as they were.
pub fps: bool,
@@ -175,6 +182,7 @@ impl Default for Extras {
zoom_level: 4,
zoom_smooth: true,
zoom_sensitivity: true,
lod_zoom_detail: false,
fps: false,
fps_corner: Corner::TopLeft,
fps_lows: false,
@@ -250,6 +258,7 @@ impl Extras {
}
"zoomSmooth" => e.zoom_smooth = flag(e.zoom_smooth),
"zoomSensitivity" => e.zoom_sensitivity = flag(e.zoom_sensitivity),
"lodZoomDetail" => e.lod_zoom_detail = flag(e.lod_zoom_detail),
"fps" => e.fps = flag(e.fps),
"fpsCorner" => e.fps_corner = Corner::parse(value, e.fps_corner),
"fpsLows" => e.fps_lows = flag(e.fps_lows),
@@ -322,7 +331,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\ncosmeticsOthers:{}\ncosmeticsAuras:{}\n{}:{}\n",
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\ncosmeticsOthers:{}\ncosmeticsAuras:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
@@ -331,6 +340,7 @@ impl Extras {
self.zoom_level,
bool_text(self.zoom_smooth),
bool_text(self.zoom_sensitivity),
bool_text(self.lod_zoom_detail),
bool_text(self.fps),
self.fps_corner.name(),
bool_text(self.fps_lows),
+280 -35
View File
@@ -77,6 +77,110 @@ const HELD_SAMPLE_STEP: std::time::Duration = std::time::Duration::from_millis(5
/// How long a tile whose sampling failed (`LodSampler::grid` gave no
/// columns: a server refused or lost it) waits before it is asked again.
const RETRY: std::time::Duration = std::time::Duration::from_secs(2);
/// The most a tile a zoomed view magnifies may split past the plan it would
/// have had ([`ZoomAim`]): cells three times smaller, so nine times the tiles
/// where it applies. A spyglass covers about a sixteenth of the circle, so at
/// the 4078 tiles and ~1500 quads a tile measured at `lodDistance` 256 this
/// asks for roughly 45% more far tiles while the zoom is held, and gives them
/// back when it is released. Four would be about 85%: raise it only with a
/// measurement, because the cost grows with the square.
const MAX_ZOOM_BOOST: i32 = 3;
/// Yaw is rounded to this many degrees before it reaches the plan, so turning
/// the mouse does not rebuild the quadtree every frame. The cone is widened by
/// as much, so rounding never swings it off a tile being looked at.
const ZOOM_YAW_STEP: f32 = 5.0;
/// Where a zoomed view asks for finer far tiles (the `lodZoomDetail` extra).
///
/// `Plan::visit`'s falloff keeps a tile's cells roughly the same size on
/// screen, which silently assumes the field of view never changes. A spyglass
/// (`SpyglassItem`, a tenth of it) or the zoom mod breaks that: the same tile
/// covers many times more pixels, so its cells look as coarse as a tile ten
/// times nearer would. This asks the tiles the zoom actually magnifies to
/// split further.
///
/// Only those: lifting the whole circle would ask for about a hundred times
/// the tiles, which at the quads measured above is gigabytes of vertices. The
/// cone is the narrowed view's, and `MAX_ZOOM_BOOST` caps how far it goes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ZoomAim {
/// The view direction in x/z, in thousandths of a unit vector.
dir: [i32; 2],
/// The cosine of the cone's half-angle, in thousandths.
cos_half: i32,
/// How many times further than the unzoomed plan a tile inside the cone
/// may split. Two or more; [`ZoomAim::new`] gives `None` for less.
boost: i32,
}
impl ZoomAim {
/// `fov_degrees` is the zoomed field of view and `magnification` how many
/// times the view is magnified (1 when it is not). `None` when the zoom is
/// too slight to be worth replanning for.
pub fn new(yaw: f32, fov_degrees: f32, magnification: f32) -> Option<Self> {
if !magnification.is_finite() || !fov_degrees.is_finite() || fov_degrees <= 0.0 {
return None;
}
let boost = (magnification.round() as i64).clamp(1, i64::from(MAX_ZOOM_BOOST)) as i32;
if boost < 2 {
return None;
}
// Half the field of view spans the screen's width; three quarters of
// it reaches the corners, and the rounding step is added on top.
let half = (fov_degrees * 0.75 + ZOOM_YAW_STEP).clamp(5.0, 90.0);
let radians = ((yaw / ZOOM_YAW_STEP).round() * ZOOM_YAW_STEP).to_radians();
// `view_vector`'s x and z: yaw 0 looks toward +z.
let thousandths = |v: f32| (v * 1000.0).round() as i32;
Some(Self {
dir: [thousandths(-radians.sin()), thousandths(radians.cos())],
cos_half: thousandths(half.to_radians().cos()),
boost,
})
}
/// Whether the cone reaches `key`, so the zoom magnifies it.
///
/// The tile is taken as the circle around its centre that covers it, and
/// that circle's own angular radius widens the cone for the test, so a
/// tile straddling the edge counts in rather than out. A tile whose circle
/// reaches the camera is always in: there is no direction to compare, and
/// it fills the view whichever way the player faces.
fn covers(&self, key: TileKey, camera: [i32; 2]) -> bool {
let [ox, _, oz] = key.origin();
let size = key.size();
// The camera stands in the tile: it is in view whichever way the
// player faces, and there is no direction to compare it with. Tested
// on the box rather than on the circle below, whose radius a camera
// on a corner matches exactly -- a float coin-toss.
// In i64: a level 22 tile is 67 million blocks wide, so the sum
// at the far edge of the world overflows an i32.
let (ox64, oz64, size64) = (i64::from(ox), i64::from(oz), i64::from(size));
if (ox64..ox64 + size64).contains(&i64::from(camera[0]))
&& (oz64..oz64 + size64).contains(&i64::from(camera[1]))
{
return true;
}
let half = f64::from(size) / 2.0;
let cx = f64::from(ox) + half - f64::from(camera[0]);
let cz = f64::from(oz) + half - f64::from(camera[1]);
let distance = (cx * cx + cz * cz).sqrt();
let radius = half * std::f64::consts::SQRT_2;
if distance <= radius {
return true;
}
let dir = [
f64::from(self.dir[0]) / 1000.0,
f64::from(self.dir[1]) / 1000.0,
];
let cos = (cx * dir[0] + cz * dir[1]) / distance;
let sin = (1.0 - cos * cos).max(0.0).sqrt();
// cos(angle - tile radius), expanded so no inverse trigonometry runs
// per tile: the quadtree asks this of every node it visits.
let radius_sin = (radius / distance).min(1.0);
let radius_cos = (1.0 - radius_sin * radius_sin).max(0.0).sqrt();
cos * radius_cos + sin * radius_sin >= f64::from(self.cos_half) / 1000.0
}
}
/// The coarsest tile level: `Plan` roots cover at most `MAX_CHUNKS` chunks
/// around (a level-22 tile is 67 million blocks wide: the whole world).
pub(crate) const MAX_LEVEL: u8 = 22;
@@ -598,6 +702,8 @@ struct Plan {
/// The squared distance within which planned tiles load before the
/// coarse backdrop (`wanted_tiles`).
first: i64,
/// Where a zoomed view asks for finer tiles, if it does ([`ZoomAim`]).
aim: Option<ZoomAim>,
}
/// Chunks past the render distance where only planned tiles are drawn (two
/// past the last near chunk, `streaming::in_view_distance`), and where they
@@ -607,7 +713,7 @@ const FIRST_CHUNKS: i32 = 4;
impl Plan {
#[cfg(test)]
fn new(camera: [i32; 2], detail: i32, distance: u32) -> Self {
Self::with_previous(camera, detail, distance, &Self::default(), 0, false)
Self::with_previous(camera, detail, distance, &Self::default(), 0, false, None)
}
/// `reduced`: Reduced far detail (`graphics::FarDetail`), each level from
/// half the distance.
@@ -618,6 +724,7 @@ impl Plan {
previous: &Self,
elevation: i32,
reduced: bool,
aim: Option<ZoomAim>,
) -> Self {
let range = distance.min(MAX_CHUNKS) as i32 * 16;
let mut level = 0;
@@ -630,6 +737,7 @@ impl Plan {
camera,
near: i64::from((detail + SEAM_CHUNKS) * 16).pow(2),
first: i64::from((detail + FIRST_CHUNKS) * 16).pow(2),
aim,
..Self::default()
};
for z in (camera[1] - range).div_euclid(size)..=(camera[1] + range).div_euclid(size) {
@@ -665,6 +773,14 @@ impl Plan {
// Beyond the old 1024-chunk limit the cells are as large as `reduced`'s:
// the whole world is 22 levels, not all of them worth full cells.
let world = key.distance(camera) >= WORLD_START;
// A zoomed view magnifies the tiles its cone covers, so those split
// further; every other tile keeps the plan it would have had
// ([`ZoomAim`]). The world-wide threshold is boosted too: the horizon is
// where a spyglass shows the coarsest cells of all.
let boost = match self.aim {
Some(aim) if aim.covers(key, camera) => i64::from(aim.boost),
_ => 1,
};
let threshold = i64::from(key.size())
* i64::from(if world {
WORLD_SPLIT
@@ -672,7 +788,8 @@ impl Plan {
split / 2
} else {
split
});
})
* boost;
// The 8 to 10 band above is wide already. The ring of full detail
// around the camera and the world-wide threshold have none: a tile
// split last plan stays split until it is 5% past them, or it
@@ -1059,7 +1176,7 @@ pub struct Lod {
context: Option<Arc<WorkerContext>>,
/// A finished mesh job may have changed the selection of its ancestors.
reselect: bool,
area: Option<([i32; 2], i32, u32, i32, bool)>,
area: Option<([i32; 2], i32, u32, i32, bool, Option<ZoomAim>)>,
plan: Plan,
/// Reduced far detail (`set_reduced_detail`).
reduced_detail: bool,
@@ -2220,29 +2337,35 @@ impl Lod {
self.shared.wake.notify_all();
}
}
/// `aim` asks the tiles a zoomed view magnifies for finer detail
/// ([`ZoomAim`]); `None` plans exactly as before.
pub fn update(
&mut self,
camera: [f64; 3],
detail: i32,
distance: u32,
covered: Option<FxHashSet<(i32, i32)>>,
aim: Option<ZoomAim>,
) -> Vec<(TileKey, SectionMesh)> {
let elevation =
(((camera[1] - f64::from(MIN_Y + crate::chunk::HEIGHT)).clamp(0., 16384.)) as i32 / 32)
* 32;
let camera = [camera[0].floor() as i32, camera[2].floor() as i32];
// Replan at chunk boundaries, not on every camera movement.
// `ZoomAim` rounds its yaw, so a zoom held still keeps one area and the
// quadtree is not rebuilt as the player nudges the mouse.
let area = (
[camera[0].div_euclid(16), camera[1].div_euclid(16)],
detail,
distance,
elevation,
self.reduced_detail,
aim,
);
if self.area != Some(area) {
crate::spans::time("lod_plan", || {
self.area = Some(area);
self.replan(camera, detail, distance, elevation);
self.replan(camera, detail, distance, elevation, aim);
});
}
let mut meshes = Vec::new();
@@ -2265,7 +2388,14 @@ impl Lod {
}
/// Rebuilds the quadtree and the selection it implies. Only runs when the
/// planned area changes (a chunk boundary), never per finished sample.
fn replan(&mut self, camera: [i32; 2], detail: i32, distance: u32, elevation: i32) {
fn replan(
&mut self,
camera: [i32; 2],
detail: i32,
distance: u32,
elevation: i32,
aim: Option<ZoomAim>,
) {
let plan = Plan::with_previous(
camera,
detail,
@@ -2273,6 +2403,7 @@ impl Lod {
&self.plan,
elevation,
self.reduced_detail,
aim,
);
// A tile that leaves the range before it was drawn stops being
// unknown to its neighbours (`Snapshot::planned_leaf`): they remesh
@@ -2962,6 +3093,104 @@ fn retiring_detail<'a>(
}
#[cfg(test)]
mod tests {
/// A spyglass or the zoom mod magnifies only what the narrowed view
/// covers, so `ZoomAim` asks for finer tiles there and nowhere else. Without
/// that cone the same request would reach the whole circle, which at the
/// measured quads a tile is gigabytes of vertices.
#[test]
fn a_zoomed_view_asks_for_finer_tiles_only_where_it_looks() {
// Yaw 0 looks toward +z (`view_vector`), ten times magnified.
let aim = ZoomAim::new(0.0, 7.0, 10.0).expect("a tenth of the field of view zooms");
// Capped, however far the zoom goes.
assert_eq!(aim.boost, MAX_ZOOM_BOOST);
let camera = [0, 0];
let tile = |x, z, level| TileKey { x, z, level };
// Straight ahead, far enough that the direction decides it.
assert!(aim.covers(tile(0, 8, 4), camera), "ahead");
// Behind, beside, and the opposite diagonal: the zoom does not
// magnify these, so they keep the plan they would have had.
assert!(!aim.covers(tile(0, -9, 4), camera), "behind");
assert!(!aim.covers(tile(8, 0, 4), camera), "beside");
assert!(!aim.covers(tile(-9, -9, 4), camera), "diagonally behind");
// A tile the camera stands in is in view whichever way it faces.
assert!(aim.covers(tile(0, 0, 4), camera), "underfoot");
}
/// A zoom too slight to change which level a tile wants is not worth
/// rebuilding the quadtree for, and an unzoomed view must plan exactly as
/// it did before this feature.
#[test]
fn a_slight_zoom_does_not_replan() {
assert_eq!(ZoomAim::new(0.0, 70.0, 1.0), None, "not zoomed");
assert_eq!(
ZoomAim::new(0.0, 60.0, 1.2),
None,
"a fifth is under one level"
);
assert!(ZoomAim::new(0.0, 35.0, 2.0).is_some(), "twice is worth it");
// Nothing finite comes of a broken field of view.
assert_eq!(ZoomAim::new(0.0, f32::NAN, 4.0), None);
assert_eq!(ZoomAim::new(0.0, 0.0, 4.0), None);
assert_eq!(ZoomAim::new(0.0, 70.0, f32::INFINITY), None);
}
/// The cone turns with the player, and its yaw is rounded so that a
/// mouse-twitch does not rebuild the quadtree: two aims within the
/// rounding step compare equal, and `Lod::update` replans on that.
#[test]
fn the_cone_turns_and_rounds_with_the_view() {
let camera = [0, 0];
let east = ZoomAim::new(-90.0, 7.0, 10.0).expect("zoomed");
let tile = |x, z| TileKey { x, z, level: 4 };
// Yaw -90 looks toward +x.
assert!(east.covers(tile(8, 0), camera), "ahead when facing +x");
assert!(!east.covers(tile(0, 8), camera), "+z is no longer ahead");
// Within the rounding step: the same aim, so no replan.
assert_eq!(ZoomAim::new(0.0, 7.0, 10.0), ZoomAim::new(1.0, 7.0, 10.0));
// Past it: a different aim, so the cone follows.
assert_ne!(ZoomAim::new(0.0, 7.0, 10.0), ZoomAim::new(20.0, 7.0, 10.0));
}
/// Inside the cone a tile splits further than it would unzoomed, and
/// outside it the plan is unchanged, tile for tile.
#[test]
fn the_plan_is_finer_ahead_and_untouched_behind() {
let camera = [0, 0];
let plain = Plan::with_previous(camera, 12, 256, &Plan::default(), 0, false, None);
let zoomed = Plan::with_previous(
camera,
12,
256,
&Plan::default(),
0,
false,
ZoomAim::new(0.0, 7.0, 10.0),
);
// Finer somewhere: the zoomed plan holds tiles the plain one split
// no further.
assert!(
zoomed.nodes.len() > plain.nodes.len(),
"zoomed {} tiles, plain {}",
zoomed.nodes.len(),
plain.nodes.len()
);
// Behind the camera the two agree. Tiles there are the same level in
// both, so every plain node well behind is still a node when zoomed.
let behind = |key: &&TileKey| {
let [_, _, oz] = key.origin();
oz + key.size() < -1024
};
let plain_behind: std::collections::BTreeSet<_> =
plain.nodes.iter().filter(behind).copied().collect();
let zoomed_behind: std::collections::BTreeSet<_> =
zoomed.nodes.iter().filter(behind).copied().collect();
assert!(!plain_behind.is_empty(), "nothing behind to compare");
assert_eq!(
plain_behind, zoomed_behind,
"the zoom changed the plan behind the player"
);
}
use super::*;
use crate::mesher::TerrainVertex;
#[test]
@@ -3033,7 +3262,8 @@ mod tests {
for camera in [[0, 0], [100, -40]] {
for distance in [64, 128, 512, 1024] {
let full = Plan::new(camera, 12, distance);
let reduced = Plan::with_previous(camera, 12, distance, &Plan::default(), 0, true);
let reduced =
Plan::with_previous(camera, 12, distance, &Plan::default(), 0, true, None);
// Never finer than Full anywhere, and as fine next to the
// full-detail area, whose edge the finest tiles match.
assert!(reduced.split.is_subset(&full.split));
@@ -3093,10 +3323,24 @@ mod tests {
([115, 135], 49_000_000),
([-30_000_000, 29_999_000], 90),
] {
let plan =
Plan::with_previous(camera, 12, MAX_CHUNKS, &Plan::default(), elevation, false);
let reduced =
Plan::with_previous(camera, 12, MAX_CHUNKS, &Plan::default(), elevation, true);
let plan = Plan::with_previous(
camera,
12,
MAX_CHUNKS,
&Plan::default(),
elevation,
false,
None,
);
let reduced = Plan::with_previous(
camera,
12,
MAX_CHUNKS,
&Plan::default(),
elevation,
true,
None,
);
let top = plan.nodes.iter().map(|k| k.level).max().unwrap();
assert_eq!(top, MAX_LEVEL, "{camera:?} y={elevation}: roots stop short");
assert!(
@@ -3123,7 +3367,7 @@ mod tests {
#[test]
fn aerial_distance_avoids_unseen_full_detail_outside_the_near_area() {
let ground = Plan::new([0, 0], 12, 1024);
let high = Plan::with_previous([0, 0], 12, 1024, &Plan::default(), 1024, false);
let high = Plan::with_previous([0, 0], 12, 1024, &Plan::default(), 1024, false, None);
assert!(high.nodes.len() < ground.nodes.len());
assert!(
high.nodes.contains(&TileKey {
@@ -3178,7 +3422,7 @@ mod tests {
// stand-in for a covered column, and only the roots are sampled.
let camera = [8, 8];
let before = Plan::new(camera, 64, 64);
let after = Plan::with_previous(camera, 8, 64, &before, 0, false);
let after = Plan::with_previous(camera, 8, 64, &before, 0, false, None);
let mut ready: FxHashMap<TileKey, ()> = before
.nodes
.iter()
@@ -3217,7 +3461,7 @@ mod tests {
#[test]
fn detail_survives_movement_across_a_split_threshold() {
let before = Plan::new([0, 0], 2, 256);
let after = Plan::with_previous([16, 0], 2, 256, &before, 0, false);
let after = Plan::with_previous([16, 0], 2, 256, &before, 0, false, None);
let mut retained = 0;
let range = i64::from(256 * 16);
for key in &before.split {
@@ -3242,12 +3486,12 @@ mod tests {
let before = Plan::new([0, 0], 40, 256);
assert!(before.split.contains(&tile));
// 670 blocks: past the ring, inside 5% of it.
let held = Plan::with_previous([-30, 0], 40, 256, &before, 0, false);
let held = Plan::with_previous([-30, 0], 40, 256, &before, 0, false, None);
assert!(held.split.contains(&tile));
// Not held without having been split: it was never in the ring.
assert!(!Plan::new([-30, 0], 40, 256).split.contains(&tile));
// 700 blocks: past the margin.
let gone = Plan::with_previous([-60, 0], 40, 256, &before, 0, false);
let gone = Plan::with_previous([-60, 0], 40, 256, &before, 0, false, None);
assert!(!gone.split.contains(&tile));
}
#[test]
@@ -3749,7 +3993,7 @@ mod tests {
// Exercise the actual worker/update seam: a parent stays displayed
// until all four child meshes can replace it in the same update.
let camera = [-256.0, 100.0, -256.0];
lod.area = Some(([-16, -16], 2, 64, 0, false));
lod.area = Some(([-16, -16], 2, 64, 0, false, None));
lod.plan.roots = vec![key];
lod.plan.nodes.insert(key);
// As a replan does (`update` only selects after a change).
@@ -3757,7 +4001,7 @@ mod tests {
let await_meshes = |lod: &mut Lod| {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
let meshes = lod.update(camera, 2, 64, None);
let meshes = lod.update(camera, 2, 64, None, None);
if !meshes.is_empty() {
return meshes;
}
@@ -3775,7 +4019,7 @@ mod tests {
lod.ready.insert(child, lod.ready[&key].clone());
}
lod.reselect = true;
assert!(lod.update(camera, 2, 64, None).is_empty());
assert!(lod.update(camera, 2, 64, None, None).is_empty());
assert!(
lod.active.contains(&key),
"parent retired before child meshes"
@@ -3804,7 +4048,7 @@ mod tests {
lod.reselect = true;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
lod.update(camera, 2, 64, None);
lod.update(camera, 2, 64, None, None);
if lod.outstanding_meshes == 0 && lod.active.is_empty() {
break;
}
@@ -3844,7 +4088,7 @@ mod tests {
.collect();
lod.detail_request_pending = true;
lod.wanted_stale = true;
lod.update(camera, 2, 64, Some(FxHashSet::default()));
lod.update(camera, 2, 64, Some(FxHashSet::default()), None);
let Some(Job::Sample(nearby)) = lod.shared.jobs.lock().unwrap().pop_front() else {
panic!("nearby detail was not scheduled");
};
@@ -3855,7 +4099,7 @@ mod tests {
"the nearest tiles load first"
);
lod.finish_sample(nearby, vec![Column::default(); 256]);
lod.update(camera, 2, 64, Some(FxHashSet::default()));
lod.update(camera, 2, 64, Some(FxHashSet::default()), None);
let Some(Job::Sample(next)) = lod.shared.jobs.lock().unwrap().pop_front() else {
panic!("the next ring was not scheduled");
};
@@ -3894,7 +4138,7 @@ mod tests {
level: 0,
})
.collect();
lod.update(camera, 2, 64, Some(FxHashSet::default()));
lod.update(camera, 2, 64, Some(FxHashSet::default()), None);
let Some(Job::Sample(first)) = lod.shared.jobs.lock().unwrap().pop_front() else {
panic!("no final tile scheduled")
};
@@ -3938,7 +4182,7 @@ mod tests {
lod.shared.jobs.lock().unwrap().clear();
lod.pending.clear();
lod.wanted_stale = true;
lod.update([-256., 64., -256.], 2, 64, Some(FxHashSet::default()));
lod.update([-256., 64., -256.], 2, 64, Some(FxHashSet::default()), None);
let jobs = lod.shared.jobs.lock().unwrap();
jobs.iter()
.filter(|job| matches!(job, Job::Sample(_)))
@@ -3954,7 +4198,7 @@ mod tests {
// Leaves' cells: 1 block to 256 blocks out, 2 to 512 (Full); Reduced
// keeps 1 to the near terrain's edge and 2 to 256.
for (reduced, [one, two]) in [(false, [256, 512]), (true, [208, 256])] {
let plan = Plan::with_previous([8, 8], 12, 128, &Plan::default(), 0, reduced);
let plan = Plan::with_previous([8, 8], 12, 128, &Plan::default(), 0, reduced, None);
for key in plan.nodes.iter().filter(|key| !plan.split.contains(key)) {
let distance = key.distance([8, 8]);
if distance < one {
@@ -3984,7 +4228,8 @@ mod tests {
}
}
}
let plan = Plan::with_previous(camera, detail, distance, &Plan::default(), 0, reduced);
let plan =
Plan::with_previous(camera, detail, distance, &Plan::default(), 0, reduced, None);
let leaf = |x: i32, z: i32| {
(0..=MAX_LEVEL)
.map(|level| TileKey {
@@ -4250,7 +4495,7 @@ mod tests {
lod.finish_sample(a, vec![Column::default(); CELLS * CELLS]);
lod.dirty.clear();
// A range of one chunk around (-10, 0) keeps `a` and drops `b`.
lod.replan([-10, 0], 0, 1, 0);
lod.replan([-10, 0], 0, 1, 0, None);
assert!(lod.plan.nodes.contains(&a) && !lod.plan.nodes.contains(&b));
assert!(lod.dirty.contains_key(&a), "its east wall is drawn now");
}
@@ -4305,7 +4550,7 @@ mod tests {
keys
};
let none = || Some(FxHashSet::default());
lod.update([8., 64., 8.], 2, 64, none());
lod.update([8., 64., 8.], 2, 64, none(), None);
let before = queued(&lod);
assert_eq!(before.len(), 16, "a full queue once the near terrain is in");
lod.shared
@@ -4315,7 +4560,7 @@ mod tests {
.push_back(Job::Observe((5, 5)));
// A chunk's move: the tiles still planned keep their place in line.
lod.update([24., 64., 8.], 2, 64, none());
lod.update([24., 64., 8.], 2, 64, none(), None);
let moved = queued(&lod);
let kept: Vec<_> = before
.iter()
@@ -4328,7 +4573,7 @@ mod tests {
assert!(observing(&lod), "only sample jobs are dropped");
// A teleport: none of the old view's tiles is sampled any more.
lod.update([100_000., 64., 100_000.], 2, 64, none());
lod.update([100_000., 64., 100_000.], 2, 64, none(), None);
let after = queued(&lod);
assert_eq!(after.len(), 16);
assert!(after.iter().all(|key| !moved.contains(key)));
@@ -4346,7 +4591,7 @@ mod tests {
assert!(lod.pending.is_empty());
assert!(observing(&lod));
lod.set_near_loading(false);
lod.update([100_000., 64., 100_000.], 2, 64, none());
lod.update([100_000., 64., 100_000.], 2, 64, none(), None);
assert_eq!(queued(&lod), after);
// Larger machines keep the first `SAMPLE_JOBS` of the queue.
@@ -4754,7 +4999,7 @@ mod tests {
};
for _ in 0..2 {
for _ in 0..4000 {
lod.update(camera, 2, 64, Some(FxHashSet::default()));
lod.update(camera, 2, 64, Some(FxHashSet::default()), None);
check(&lod);
if !finish_queued(&mut lod) {
break;
@@ -4765,11 +5010,11 @@ mod tests {
check(&lod);
// Move a chunk: retained samples stay selected, new ones join.
camera[0] += 16.0;
lod.update(camera, 2, 64, Some(FxHashSet::default()));
lod.update(camera, 2, 64, Some(FxHashSet::default()), None);
check(&lod);
}
// A covered level-0 stand-in completes its parent but is not drawn.
lod.update(camera, 2, 64, Some([(0, 0)].into_iter().collect()));
lod.update(camera, 2, 64, Some([(0, 0)].into_iter().collect()), None);
check(&lod);
assert!(lod.ready.contains_key(&TileKey {
x: 0,
@@ -4782,7 +5027,7 @@ mod tests {
level: 0
}));
// Shrinking the distance retires tiles and reports them once.
lod.update(camera, 0, 32, Some(FxHashSet::default()));
lod.update(camera, 0, 32, Some(FxHashSet::default()), None);
check(&lod);
let _ = lod.removed();
}
@@ -4866,7 +5111,7 @@ mod tests {
let mut covered = Some(covered.clone());
let mut quiet = 0;
while quiet < if settle { 50 } else { 3 } {
lod.update(camera, detail, distance, covered.take());
lod.update(camera, detail, distance, covered.take(), None);
lod.removed();
// Every tile sampled, but those waiting for their data.
let unsampled = lod.plan.nodes.iter().any(|key| {
+11 -6
View File
@@ -17018,6 +17018,15 @@ impl Game<'_> {
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;
// Which far tiles a zoom magnifies (`lod::ZoomAim`), read before
// `self.renderer.as_mut()` below borrows self mutably for the rest of
// this function. Cheap: trigonometry on one angle, or None when the
// view is not zoomed.
let lod_aim = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z])
.and_then(|camera| self.lod_aim(camera));
if let Some(renderer) = self.renderer.as_mut() {
let g = &self.settings.horizon;
let budgeted = g.chunk_updates > 0 || g.upload_budget_kib > 0;
@@ -17129,13 +17138,9 @@ impl Game<'_> {
let detail = self.remote.as_ref().map_or(self.streaming.radius, |r| {
self.streaming.radius.min(r.sent_radius())
});
let camera = [player.position.x, player.position.y, player.position.z];
let meshes = horizon_client::spans::time("lod_update", || {
lod.update(
[player.position.x, player.position.y, player.position.z],
detail,
lod_distance,
covered,
)
lod.update(camera, detail, lod_distance, covered, lod_aim)
});
renderer.queue_lods(meshes, lod.removed(), lod.take_cutout());
// A finished job can hold hundreds of tiles: spread them
+28
View File
@@ -144,6 +144,34 @@ impl Game<'_> {
vanilla
}
}
/// How many times the view is magnified right now: the player's field of
/// view over the one actually in use, and 1 when nothing is zooming.
///
/// Reads the result rather than any one zoom, so it covers all three:
/// the spyglass (`AbstractClientPlayer.getFieldOfViewModifier`'s tenth
/// while `isScoping`), the zoom mod (`native_zoom`) and any mod that
/// narrows the field of view through `native_compute_fov`.
pub(super) fn zoom_magnification(&self, camera: [f64; 3]) -> f32 {
let now = self.fov_degrees(1.0, camera);
let base = self.settings.fov.clamp(1.0, 179.0);
if now.is_finite() && now > 0.5 {
(base / now).max(1.0)
} else {
1.0
}
}
/// What far terrain should plan for a zoomed view, or `None` when the
/// `lodZoomDetail` extra is off or nothing is zooming (`lod::ZoomAim`).
pub(super) fn lod_aim(&self, camera: [f64; 3]) -> Option<horizon_client::lod::ZoomAim> {
if !self.settings.extras.lod_zoom_detail {
return None;
}
horizon_client::lod::ZoomAim::new(
self.yaw,
self.fov_degrees(1.0, camera),
self.zoom_magnification(camera),
)
}
/// `Camera.calculateHudFov`: the first-person hand's field of view.
pub(super) fn hud_fov_degrees(&self, partial: f32, camera: [f64; 3]) -> f32 {
self.view.calculate_hud_fov(