Weather: rain heights from a cached MOTION_BLOCKING heightmap, no per-column allocations

WeatherEffectRenderer reads level.getHeight(MOTION_BLOCKING, x, z) for
every column within the weather radius. Horizon scanned each column block
by block from 10 blocks above the radius down to the ground, with a
string search per block, every frame: about half a millisecond of CPU per
frame in rain on a Ryzen, more on laptops (#899). Client chunks now keep
a MOTION_BLOCKING map next to their WORLD_SURFACE one, built on the first
read and dropped by writes that can move it, from the facts world
generation already uses (blocks_motion_in_heightmap or a fluid).

This is also vanilla's rule where the scan differed: slabs, stairs and
other motion-blocking blocks without a full collision box now stop rain,
and a roof more than 20 blocks above the camera does too.

Tiling a column at each texture repeat no longer allocates two vectors
per column.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-21 18:57:30 +02:00
1 parent e66c35b2ef
commit b591c9dc64
4 files changed
+119 -46

No files matched your search

+68 -13
View File
@@ -126,6 +126,8 @@ pub struct Chunk {
/// the highest non-air block, or `MIN_Y`. Built on the first read; a
/// `set` that can move a column's surface drops it.
surface: OnceLock<Box<[i16; 256]>>,
/// `Heightmap.Types.MOTION_BLOCKING` per column, kept like `surface`.
motion_blocking: OnceLock<Box<[i16; 256]>>,
pub lit: bool,
/// Light read from a save with `isLightOn`: no light job is needed.
pub light_loaded: bool,
@@ -155,6 +157,7 @@ impl Chunk {
.map(|_| Arc::new(Section::default()))
.collect(),
surface: OnceLock::new(),
motion_blocking: OnceLock::new(),
lit: false,
light_loaded: false,
fresh: false,
@@ -197,13 +200,17 @@ impl Chunk {
return v;
}
let previous = Arc::make_mut(&mut self.sections[s as usize]).set(index(x, y, z), v);
// `Heightmap.update`: a write two or more below the surface can't
// move it. Any other change rebuilds the map on the next read.
if previous != v
&& let Some(surface) = self.surface.get()
&& y >= i32::from(surface[column(x, z)]) - 1
{
self.surface = OnceLock::new();
// `Heightmap.update`: a write two or more below a map's height can't
// move it. Any other change rebuilds that map on the next read.
if previous != v {
for map in [&mut self.surface, &mut self.motion_blocking] {
if map
.get()
.is_some_and(|heights| y >= i32::from(heights[column(x, z)]) - 1)
{
*map = OnceLock::new();
}
}
}
previous
}
@@ -211,12 +218,19 @@ impl Chunk {
/// chunk): one above the highest block that isn't `air`, or `MIN_Y`.
/// Sections must change only through `set` once this has been read.
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(u32) -> bool) -> i32 {
let surface = self.surface.get_or_init(|| self.prime_surface(air));
let surface = self.surface.get_or_init(|| self.prime(|v| !air(v)));
i32::from(surface[column(x, z)])
}
/// `Heightmap.primeHeightmaps` for WORLD_SURFACE: each section from the
/// top, skipping those of one air state, until every column is found.
fn prime_surface(&self, air: impl Fn(u32) -> bool) -> Box<[i16; 256]> {
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)` (`x`, `z` within
/// the chunk): one above the highest state where `blocks` (a motion
/// blocking block or a fluid), or `MIN_Y`. Kept as `world_surface` is.
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(u32) -> bool) -> i32 {
let heights = self.motion_blocking.get_or_init(|| self.prime(blocks));
i32::from(heights[column(x, z)])
}
/// `Heightmap.primeHeightmaps`: each section from the top, skipping
/// those of one state that doesn't count, until every column is found.
fn prime(&self, counts: impl Fn(u32) -> bool) -> Box<[i16; 256]> {
let mut surface = Box::new([MIN_Y as i16; 256]);
let mut found = [false; 256];
let mut left = 256;
@@ -225,7 +239,7 @@ impl Chunk {
if left == 0 {
break;
}
if section.blocks.uniform().is_some_and(&air) {
if section.blocks.uniform().is_some_and(|v| !counts(v)) {
continue;
}
section.unpack(&mut values);
@@ -234,7 +248,7 @@ impl Chunk {
if found[i] {
continue;
}
if let Some(dy) = (0..16).rev().find(|&dy| !air(values[dy << 8 | i])) {
if let Some(dy) = (0..16).rev().find(|&dy| counts(values[dy << 8 | i])) {
surface[i] = (base + dy as i32 + 1) as i16;
found[i] = true;
left -= 1;
@@ -358,4 +372,45 @@ mod tests {
assert!(chunk.surface.get().is_some());
assert_eq!(chunk.world_surface(3, 3, |_| unreachable!()), before);
}
/// MOTION_BLOCKING ignores states that don't count (air, grass) and
/// follows writes as the surface map does, both maps kept at once.
#[test]
fn motion_blocking_follows_writes() {
let (air, grass, water, stone) = (0, 3, 5, 1);
let blocks = |v: u32| v == water || v == stone;
let scan = |c: &Chunk, x: i32, z: i32| {
(MIN_Y..MIN_Y + HEIGHT)
.rev()
.find(|&y| blocks(c.get(x, y, z)))
.map_or(MIN_Y, |y| y + 1)
};
let mut chunk = Chunk::empty(0, 0);
assert_eq!(chunk.motion_blocking(5, 5, blocks), MIN_Y);
for y in MIN_Y..MIN_Y + 70 {
for i in 0..256 {
chunk.set(i & 15, y, i >> 4, stone);
}
}
for i in 0..4096 {
Arc::make_mut(&mut chunk.sections[8]).set(i, grass);
}
let mut random = horizon_core::random::LegacyRandom::new(11);
use horizon_core::random::RandomSource;
for step in 0..4000 {
let x = random.next_int_bound(16).unwrap();
let z = random.next_int_bound(16).unwrap();
let y = MIN_Y + 40 + random.next_int_bound(120).unwrap();
let v = [air, grass, water, stone][random.next_int_bound(4).unwrap() as usize];
chunk.set(x, y, z, v);
if step % 7 == 0 {
for i in 0..256 {
assert_eq!(
chunk.motion_blocking(i & 15, i >> 4, blocks),
scan(&chunk, i & 15, i >> 4)
);
chunk.world_surface(i & 15, i >> 4, |v| v == air);
}
}
}
}
}
+10 -14
View File
@@ -8521,18 +8521,14 @@ impl Game<'_> {
};
Some(t < 0.15)
}
/// The first motion-blocking block from the top (Heightmap MOTION_BLOCKING).
fn rain_height(&self, x: i32, z: i32, from: i32) -> i32 {
let mut y = from;
while y > MIN_Y {
let s = self.world.block(x, y, z);
let f = self.res.facts().get(s);
if f.full_collision || f.fluid != Fluid::None || f.name.contains("leaves") {
return y + 1;
}
y -= 1;
}
MIN_Y
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)`: one above the
/// highest block in `blocks_motion_in_heightmap` or holding a fluid.
fn rain_height(&self, x: i32, z: i32) -> i32 {
use horizon_world::{heightmap::HeightmapType, worldgen::terrain::TerrainBlockStates};
let properties = &self.pack.assets.properties;
self.world.motion_blocking(x, z, |s| {
HeightmapType::MotionBlocking.is_opaque(properties.heightmap_state(s))
})
}
fn tick_weather_effects(&mut self) {
if self.flash > 0 {
@@ -8550,7 +8546,7 @@ impl Game<'_> {
r.next_int_bound(21).unwrap_or(10) - 10,
);
let (x, z) = (eye[0].floor() as i32 + dx, eye[2].floor() as i32 + dz);
let top = self.rain_height(x, z, eye[1] as i32 + 10);
let top = self.rain_height(x, z);
if self.precipitation(x, top, z) == Some(false)
&& (top - eye[1] as i32).abs() < 10
&& self.world.random.next_int_bound(5).unwrap_or(1) == 0
@@ -8597,7 +8593,7 @@ impl Game<'_> {
let mut columns = (Vec::new(), Vec::new());
for z in cz - radius..=cz + radius {
for x in cx - radius..=cx + radius {
let top = self.rain_height(x, z, cy + radius + 10);
let top = self.rain_height(x, z);
let y0 = (cy - radius).max(top);
let y1 = (cy + radius).max(top);
if y1 == y0 {
+33 -19
View File
@@ -180,28 +180,17 @@ pub fn tile(quad: &[ColumnVertex; 4], mut emit: impl FnMut([[f32; 3]; 4], [[f32;
t,
)
};
let cuts = |a: f32, b: f32| {
let mut cuts = vec![a];
let mut k = a.floor() + 1.0;
while k < b {
cuts.push(k);
k += 1.0;
}
cuts.push(b);
cuts
};
if !(u1 > u0 && v1 > v0) {
return;
}
let (us, vs) = (cuts(u0, u1), cuts(v0, v1));
for v in vs.windows(2) {
let repeat_v = v[0].floor();
let (ta, tb) = ((v[0] - v0) / (v1 - v0), (v[1] - v0) / (v1 - v0));
let (la, lb) = (v[0] - repeat_v, v[1] - repeat_v);
for u in us.windows(2) {
let repeat_u = u[0].floor();
let (sa, sb) = ((u[0] - u0) / (u1 - u0), (u[1] - u0) / (u1 - u0));
let (ka, kb) = (u[0] - repeat_u, u[1] - repeat_u);
for (va, vb) in pieces(v0, v1) {
let repeat_v = va.floor();
let (ta, tb) = ((va - v0) / (v1 - v0), (vb - v0) / (v1 - v0));
let (la, lb) = (va - repeat_v, vb - repeat_v);
for (ua, ub) in pieces(u0, u1) {
let repeat_u = ua.floor();
let (sa, sb) = ((ua - u0) / (u1 - u0), (ub - u0) / (u1 - u0));
let (ka, kb) = (ua - repeat_u, ub - repeat_u);
emit(
[at(sa, ta), at(sb, ta), at(sb, tb), at(sa, tb)],
[[ka, la], [kb, la], [kb, lb], [ka, lb]],
@@ -210,6 +199,22 @@ pub fn tile(quad: &[ColumnVertex; 4], mut emit: impl FnMut([[f32; 3]; 4], [[f32;
}
}
/// `a..b` cut at every whole number in between, as consecutive pairs
/// (without allocating: this runs for every column of every frame).
fn pieces(a: f32, b: f32) -> impl Iterator<Item = (f32, f32)> {
let first = a.floor() + 1.0;
let cuts = move || {
std::iter::once(a)
.chain(
(0..)
.map(move |k| first + k as f32)
.take_while(move |&k| k < b),
)
.chain(std::iter::once(b))
};
cuts().zip(cuts().skip(1))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -256,6 +261,15 @@ mod tests {
assert_eq!(pieces[1].1[2], [0.25, 0.5]);
}
#[test]
fn pieces_cut_at_whole_numbers() {
let all = |a, b| pieces(a, b).collect::<Vec<_>>();
assert_eq!(all(0.25, 2.5), [(0.25, 1.0), (1.0, 2.0), (2.0, 2.5)]);
assert_eq!(all(1.0, 2.0), [(1.0, 2.0)]);
assert_eq!(all(-0.5, 0.5), [(-0.5, 0.0), (0.0, 0.5)]);
assert_eq!(all(3.25, 3.75), [(3.25, 3.75)]);
}
#[test]
fn camera_column_is_degenerate() {
let column = Column {
+8
View File
@@ -294,6 +294,14 @@ impl World {
chunk.world_surface(x, z, |v| air(BlockStateId(v)))
})
}
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)`: one above the
/// highest block where `blocks` (motion blocking or holding a fluid),
/// or `MIN_Y` (also where the chunk isn't loaded).
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(BlockStateId) -> bool) -> i32 {
self.chunks.get(&(x >> 4, z >> 4)).map_or(MIN_Y, |chunk| {
chunk.motion_blocking(x & 15, z & 15, |v| blocks(BlockStateId(v)))
})
}
pub fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
self.chunks
.get(&(x >> 4, z >> 4))