mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge remote-tracking branch 'origin/main' into feat/launcher-glass-overview
# Conflicts: # launcher/lang/bg.json # launcher/lang/da.json # launcher/lang/fi.json # launcher/lang/he.json # launcher/lang/hu.json # launcher/lang/lt.json # launcher/lang/no.json # launcher/lang/ro.json # launcher/lang/sk.json
This commit is contained in:
commit
d2cf230cb6
128 files changed
+3565
-434
No files matched your search
@@ -0,0 +1,27 @@
|
||||
# Particle culling review measurements — 2026-11-02
|
||||
|
||||
Ordinary-scene captures and measurements for PR #7054. The scene contains 24 mobs, about 9,400–10,100 rain particles, and about 50–100 campfire smoke particles. Two opposite-order passes were run with 2 s warmup and 8 s measurement per condition. Host: Intel Core i7-3770S, Intel HD Graphics 4000, Vulkan/Mesa, 675×727, render distance 4, VSync off, 260 FPS cap.
|
||||
|
||||
These are means of the two samples. CPU is the harness's average CPU time per frame; p99 is frame time. The normal scene is not expected to show large gains because most particles are visible.
|
||||
|
||||
| Condition | FPS | CPU ms/frame | Frame p99 ms |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Main, culling Off | 88.39 | 1.930 | 23.85 |
|
||||
| Particle branch, culling Off | 88.55 | 1.828 | 24.88 |
|
||||
| Main, Entity Culling Synchronous | 89.12 | 1.887 | 23.07 |
|
||||
| Particle branch, Entity Culling Synchronous, Particle Culling Off | 89.06 | 1.897 | 25.18 |
|
||||
| Particle Culling Synchronous | 88.87 | 1.774 | 24.94 |
|
||||
| Particle Culling Asynchronous* | 89.00 | 1.754 | 24.90 |
|
||||
|
||||
*The asynchronous samples predate the final switch to a dedicated particle worker. They are retained only as historical context and are not a measurement of the final worker implementation. No FPS improvement is claimed from these ordinary-scene runs. The Off path delegates directly to `render`; `disabled_culled_render_matches_the_regular_particle_path` checks that it produces the same vertices.
|
||||
|
||||
The FPS and CPU differences are within run-to-run variability for this GPU-limited scene. The first four conditions are the reviewer's requested Main-versus-branch default and existing synchronous-mode comparisons. Particle Sync/Async rows are branch-internal mode comparisons.
|
||||
|
||||
## Captures
|
||||
|
||||
The screenshots show the same ordinary test world and FPS overlay:
|
||||
|
||||
- `main-off.png`
|
||||
- `particle-off.png`
|
||||
- `particle-sync.png`
|
||||
- `particle-async.png`
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 342 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 343 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 341 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 341 KiB |
@@ -207,6 +207,20 @@ impl Assets {
|
||||
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load(root, self)
|
||||
}
|
||||
/// [`Assets::reload`] with the model library already parsed.
|
||||
///
|
||||
/// Parsing it reads every model file under `assets/`, which needs no
|
||||
/// block registry, so a caller that has the files to spare can start
|
||||
/// it before `Assets::load` finishes and hand the result in here. On
|
||||
/// a cold cache that is most of a second of reading moved off the
|
||||
/// critical path.
|
||||
pub fn reload_with_models(
|
||||
&self,
|
||||
root: &Path,
|
||||
parsed: crate::model_library::ParsedModels,
|
||||
) -> Result<RenderAssets> {
|
||||
RenderAssets::load_with_parsed(root, self, true, Some(parsed))
|
||||
}
|
||||
/// [`Assets::reload`] for a server, which draws nothing: the sprites'
|
||||
/// layers, without the block or item models or the textures' pixels.
|
||||
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
|
||||
@@ -218,6 +232,16 @@ impl RenderAssets {
|
||||
Self::load_with(root, assets, true)
|
||||
}
|
||||
fn load_with(root: &Path, assets: &Assets, draws: bool) -> Result<Self> {
|
||||
Self::load_with_parsed(root, assets, draws, None)
|
||||
}
|
||||
/// `parsed`: the model library when a caller parsed it ahead of this
|
||||
/// (see [`Assets::reload_with_models`]); otherwise it is parsed here.
|
||||
fn load_with_parsed(
|
||||
root: &Path,
|
||||
assets: &Assets,
|
||||
draws: bool,
|
||||
parsed: Option<crate::model_library::ParsedModels>,
|
||||
) -> Result<Self> {
|
||||
let registry = assets.registry;
|
||||
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
|
||||
for name in MATERIALS {
|
||||
@@ -225,17 +249,24 @@ impl RenderAssets {
|
||||
}
|
||||
// A server draws no block and no item: its block models (about 24 MB
|
||||
// baked) and item model library (7 MB) stay empty.
|
||||
let parsed = if draws {
|
||||
crate::model_library::ParsedModels::load(root)
|
||||
} else {
|
||||
crate::model_library::ParsedModels::default()
|
||||
let parsed = match parsed {
|
||||
Some(parsed) => parsed,
|
||||
None if draws => {
|
||||
let _step = crate::spans::startup::step("core.models_parse");
|
||||
crate::model_library::ParsedModels::load(root)
|
||||
}
|
||||
None => crate::model_library::ParsedModels::default(),
|
||||
};
|
||||
let mut models = if draws {
|
||||
let _step = crate::spans::startup::step("core.models_bake");
|
||||
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
|
||||
} else {
|
||||
crate::models::PreparedModels::default()
|
||||
};
|
||||
let model_library = parsed.into_item_library();
|
||||
let model_library = {
|
||||
let _step = crate::spans::startup::step("core.item_library");
|
||||
parsed.into_item_library()
|
||||
};
|
||||
// Fluids are drawn by the fluid renderer, not block models; breaking
|
||||
// progress uses the destroy-stage overlays.
|
||||
for name in [
|
||||
@@ -251,6 +282,7 @@ impl RenderAssets {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
let fusion = if draws {
|
||||
let _step = crate::spans::startup::step("core.fusion");
|
||||
crate::fusion::FusionTextures::load(root, registry, &mut sprites, &mut models.catalog)
|
||||
} else {
|
||||
Default::default()
|
||||
|
||||
@@ -7684,6 +7684,7 @@ impl Renderer {
|
||||
frame.entities,
|
||||
frame.entity_spans,
|
||||
frame.crumbling_sprite,
|
||||
frame.end_portal_sprites[1],
|
||||
&mut self.pack_entity_rest,
|
||||
skin.filter(|_| pack_skin),
|
||||
);
|
||||
@@ -10589,6 +10590,18 @@ impl Frustum {
|
||||
p.truncate().dot(v) + p.w < 0.0
|
||||
})
|
||||
}
|
||||
/// Whether a camera-relative sphere is at least partly inside. Frustum
|
||||
/// planes are normalized, so each test needs only one dot product and a
|
||||
/// radius comparison.
|
||||
pub fn intersects_sphere(&self, center: Vec3, radius: f32) -> bool {
|
||||
if !center.is_finite() || !radius.is_finite() || radius < 0.0 {
|
||||
return true;
|
||||
}
|
||||
!self
|
||||
.planes
|
||||
.iter()
|
||||
.any(|plane| plane.truncate().dot(center) + plane.w < -radius)
|
||||
}
|
||||
/// Whether the camera-relative box `min..max` is entirely inside. Then
|
||||
/// `intersects` accepts every box within it: each plane's distance only
|
||||
/// grows from this box's nearest corner to the inner box's farthest, and
|
||||
|
||||
@@ -123,6 +123,11 @@ fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_world.wgsl"),
|
||||
),
|
||||
(
|
||||
"visibility buffer",
|
||||
vec!["terrain.wgsl", "visbuffer.wgsl"],
|
||||
include_str!("../terrain.wgsl").to_owned() + include_str!("../visbuffer.wgsl"),
|
||||
),
|
||||
(
|
||||
"improved transparency world border",
|
||||
vec!["border.wgsl", "oit.wgsl", "oit_border.wgsl"],
|
||||
|
||||
@@ -194,6 +194,8 @@ pub(super) struct Draw {
|
||||
/// The pack's shadow program draws it into the shadow map too.
|
||||
pub shadow: bool,
|
||||
pub slot: u32,
|
||||
/// End portal or gateway faces: drawn with `end_portal.png` bound as the atlas.
|
||||
pub portal: bool,
|
||||
}
|
||||
|
||||
/// Which entity runs a pack's shadow pass draws (2.4, 3.2).
|
||||
@@ -375,38 +377,87 @@ impl PackEntities {
|
||||
pack,
|
||||
casts,
|
||||
overlay,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
|
||||
rest.extend_from_slice(&entities[..first.min(entities.len())]);
|
||||
extend_without_portals(rest, &entities[..first.min(entities.len())]);
|
||||
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for (span, range) in runs(entities.len(), spans) {
|
||||
let run = &entities[range];
|
||||
let program = program_of(&span).filter(|&p| {
|
||||
plain(run) || (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
|
||||
});
|
||||
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
|
||||
let casts = program.is_some_and(|p| shadow.cast(&span, p));
|
||||
if !pack && !span.hidden {
|
||||
rest.extend_from_slice(run);
|
||||
}
|
||||
let Some(program) = program.filter(|_| (pack || casts) && !run.is_empty()) else {
|
||||
continue;
|
||||
let whole = &entities[range];
|
||||
// A block entity's run can hold End portal faces and beams beside plain parts (an End
|
||||
// gateway: its cube and its beam): each goes to its own program.
|
||||
// Beacon beams come in a run of their own, at stage NONE (game.rs: no stage of 5.4
|
||||
// is theirs), for gbuffers_beaconbeam.
|
||||
let split = (program_of(&span) == Some(BLOCK) || span.stage == render_stage("NONE"))
|
||||
&& whole
|
||||
.chunks(4)
|
||||
.any(|q| matches!(q[0].layer & 0xffff, 6 | 9 | 10));
|
||||
let parts: [(&[Vertex], Part); 3] = if split {
|
||||
other.clear();
|
||||
portal_faces.clear();
|
||||
beam_faces.clear();
|
||||
for quad in whole.chunks(4) {
|
||||
match quad[0].layer & 0xffff {
|
||||
9 | 10 => portal_faces.extend_from_slice(quad),
|
||||
6 => beam_faces.extend_from_slice(quad),
|
||||
_ => other.extend_from_slice(quad),
|
||||
}
|
||||
}
|
||||
[
|
||||
(&other, Part::Plain),
|
||||
(&portal_faces, Part::Portal),
|
||||
(&beam_faces, Part::Beam),
|
||||
]
|
||||
} else {
|
||||
[(whole, Part::Plain), (&[], Part::Plain), (&[], Part::Plain)]
|
||||
};
|
||||
let start = self.scratch.len() as u32;
|
||||
self.scratch.extend_from_slice(run);
|
||||
if program == DESTROY {
|
||||
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
|
||||
for (run, part) in parts {
|
||||
if run.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let program = match part {
|
||||
Part::Portal => Some(BLOCK),
|
||||
Part::Beam => Some(BEAM),
|
||||
Part::Plain => program_of(&span).filter(|&p| {
|
||||
plain(run)
|
||||
|| (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
|
||||
}),
|
||||
};
|
||||
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
|
||||
let casts = part == Part::Plain && program.is_some_and(|p| shadow.cast(&span, p));
|
||||
if !pack && !span.hidden {
|
||||
extend_without_portals(rest, run);
|
||||
}
|
||||
let Some(program) = program.filter(|_| pack || casts) else {
|
||||
continue;
|
||||
};
|
||||
let start = self.scratch.len() as u32;
|
||||
match part {
|
||||
Part::Beam => beam_segments(run, &mut self.scratch),
|
||||
_ => self.scratch.extend_from_slice(run),
|
||||
}
|
||||
if program == DESTROY {
|
||||
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
|
||||
}
|
||||
if part == Part::Portal {
|
||||
as_portal_texture(&mut self.scratch[start as usize..]);
|
||||
}
|
||||
let vertices = self.scratch.len() as u32 - start;
|
||||
let overlay = self.scratch[start as usize].layer & 0xffff;
|
||||
let portal = part == Part::Portal;
|
||||
self.push_draw(
|
||||
span,
|
||||
posed + start,
|
||||
vertices,
|
||||
program,
|
||||
pack,
|
||||
casts,
|
||||
overlay,
|
||||
portal,
|
||||
);
|
||||
}
|
||||
self.push_draw(
|
||||
span,
|
||||
posed + start,
|
||||
run.len() as u32,
|
||||
program,
|
||||
pack,
|
||||
casts,
|
||||
run[0].layer & 0xffff,
|
||||
);
|
||||
}
|
||||
if self.draws.is_empty() && posed == 0 {
|
||||
return;
|
||||
@@ -453,6 +504,7 @@ impl PackEntities {
|
||||
pack: bool,
|
||||
casts: bool,
|
||||
overlay: u32,
|
||||
portal: bool,
|
||||
) {
|
||||
let (entity_id, block_id) = match program {
|
||||
BLOCK => (0, span.id),
|
||||
@@ -476,6 +528,7 @@ impl PackEntities {
|
||||
pack,
|
||||
shadow: casts,
|
||||
slot: self.draws.len() as u32,
|
||||
portal,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -504,13 +557,104 @@ fn plain(run: &[Vertex]) -> bool {
|
||||
.all(|v| !matches!(v.layer & 0xffff, 2 | 4 | 6 | 9 | 10 | 11 | 12 | 34))
|
||||
}
|
||||
|
||||
/// What part of a block entity's run goes to which program (`prepare`).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Part {
|
||||
Plain,
|
||||
Portal,
|
||||
Beam,
|
||||
}
|
||||
|
||||
/// Beam faces (`block_entity_render::beam`: layer 6, v counting the sprite's repeats up the
|
||||
/// beam, the sprite's v range in the light bytes) for a pack: each face cut where its texture
|
||||
/// repeats, so every piece holds one copy of the sprite in plain atlas coordinates (at most 64
|
||||
/// pieces a face; a taller beam stretches its copies), white light and its colour kept.
|
||||
fn beam_segments(run: &[Vertex], out: &mut Vec<Vertex>) {
|
||||
for quad in run.as_chunks::<4>().0 {
|
||||
let wrap = quad[0].light;
|
||||
let (v0, size) = (
|
||||
(wrap & 65535) as f32 / 65535.0,
|
||||
(wrap >> 16) as f32 / 65535.0,
|
||||
);
|
||||
// Corners 0 and 3 at the beam's end (v1), 1 and 2 at its start (v2); 0 and 1 on one
|
||||
// side, 2 and 3 on the other.
|
||||
let (v1, v2) = (quad[0].uv[1], quad[1].uv[1]);
|
||||
if (v1 - v2).abs() < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let (lo, hi) = (v1.min(v2), v1.max(v2));
|
||||
let first = lo.floor();
|
||||
let repeats = (hi.ceil() - first).max(1.0);
|
||||
let step = (repeats / 64.0).ceil().max(1.0);
|
||||
let pieces = (repeats / step).ceil() as u32;
|
||||
let at = |side: usize, v: f32| {
|
||||
let (a, b) = if side == 0 {
|
||||
(quad[1], quad[0])
|
||||
} else {
|
||||
(quad[2], quad[3])
|
||||
};
|
||||
let t = (v - v2) / (v1 - v2);
|
||||
glam::Vec3::from(a.pos)
|
||||
.lerp(glam::Vec3::from(b.pos), t)
|
||||
.to_array()
|
||||
};
|
||||
for k in 0..pieces {
|
||||
let base = first + k as f32 * step;
|
||||
let (a, b) = (base.max(lo), (base + step).min(hi));
|
||||
if b <= a {
|
||||
continue;
|
||||
}
|
||||
// The end-side corners take the piece's bound nearer v1.
|
||||
let (end_v, start_v) = if v1 > v2 { (b, a) } else { (a, b) };
|
||||
for (i, corner) in quad.iter().enumerate() {
|
||||
let side = usize::from(i >= 2);
|
||||
let v = if i == 0 || i == 3 { end_v } else { start_v };
|
||||
let mut out_v = *corner;
|
||||
out_v.pos = at(side, v);
|
||||
out_v.uv = [corner.uv[0], v0 + (v - base) / step * size];
|
||||
out_v.layer = 0;
|
||||
out_v.light = 0x0000_f0f0;
|
||||
out.push(out_v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `run` added to Horizon's own draw without End portal faces: under a shader pack Horizon's
|
||||
/// entity shader would put its screen-projected starfield into the pack's buffers as if they
|
||||
/// were its own frame (misplaced where the pack renders at another size), so a portal the pack
|
||||
/// does not draw (its programs not there, or not yet) is better left out.
|
||||
fn extend_without_portals(rest: &mut Vec<Vertex>, run: &[Vertex]) {
|
||||
for quad in run.chunks(4) {
|
||||
if !quad.iter().all(|v| matches!(v.layer & 0xffff, 6 | 9 | 10)) {
|
||||
rest.extend_from_slice(quad);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// End portal faces for a pack's `gbuffers_block` (block entities): each face textured with the
|
||||
/// whole of `end_portal.png`, which the draw binds as the atlas (a texture of its own, so a
|
||||
/// pack's own portal effect can wrap it), white, at full light; the pack tells the portal by its
|
||||
/// `blockEntityId`.
|
||||
fn as_portal_texture(run: &mut [Vertex]) {
|
||||
const CORNERS: [[f32; 2]; 4] = [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]];
|
||||
for (i, v) in run.iter_mut().enumerate() {
|
||||
v.uv = CORNERS[i % 4];
|
||||
v.layer = 0;
|
||||
v.color = 0xFFFF_FFFF;
|
||||
v.light = 0x0000_f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
/// The programs of entity runs, in the order they draw.
|
||||
pub const ENTITY: usize = 0;
|
||||
pub const BLOCK: usize = 1;
|
||||
pub const TRANSLUCENT: usize = 2;
|
||||
pub const PARTICLE: usize = 3;
|
||||
pub const DESTROY: usize = 4;
|
||||
pub const PROGRAMS: usize = 5;
|
||||
/// Beacon and End gateway beams (`gbuffers_beaconbeam`).
|
||||
pub const BEAM: usize = 5;
|
||||
pub const PROGRAMS: usize = 6;
|
||||
|
||||
/// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has;
|
||||
/// `None`: Horizon draws it.
|
||||
@@ -587,11 +731,11 @@ mod tests {
|
||||
assert_eq!(drawn_by_pack(&spans[0], all), Some(ENTITY));
|
||||
assert_eq!(drawn_by_pack(&spans[1], all), Some(PARTICLE));
|
||||
assert_eq!(
|
||||
drawn_by_pack(&spans[1], [true, true, true, false, true]),
|
||||
drawn_by_pack(&spans[1], [true, true, true, false, true, true]),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
drawn_by_pack(&spans[2], [true, false, true, true, true]),
|
||||
drawn_by_pack(&spans[2], [true, false, true, true, true, true]),
|
||||
None
|
||||
);
|
||||
assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK));
|
||||
@@ -654,6 +798,103 @@ fn project_cracks(vertices: &mut [Vertex], sprite: [f32; 4], atlas: [u32; 2]) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod portal_beam_tests {
|
||||
use super::*;
|
||||
|
||||
fn quad(layer: u32, light: u32, corners: [([f32; 3], [f32; 2]); 4]) -> [Vertex; 4] {
|
||||
corners.map(|(pos, uv)| Vertex {
|
||||
pos,
|
||||
uv,
|
||||
layer,
|
||||
color: 0xFF20_40FF,
|
||||
light,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_beam_face_becomes_one_sprite_copy_per_repeat() {
|
||||
// `block_entity_render::beam`'s corner order: 0 and 3 at the end (v1), 1 and 2 at the
|
||||
// start (v2); the sprite's v range (0.25, height 0.5) in the light bytes.
|
||||
let wrap = (0.25f32 * 65535.0).round() as u32 | ((0.5f32 * 65535.0).round() as u32) << 16;
|
||||
let face = quad(
|
||||
6,
|
||||
wrap,
|
||||
[
|
||||
([0.0, 3.0, 0.0], [0.9, 3.0]),
|
||||
([0.0, 0.0, 0.0], [0.9, 0.0]),
|
||||
([1.0, 0.0, 0.0], [0.1, 0.0]),
|
||||
([1.0, 3.0, 0.0], [0.1, 3.0]),
|
||||
],
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
beam_segments(&face, &mut out);
|
||||
assert_eq!(out.len(), 12, "three repeats, three pieces");
|
||||
for (k, piece) in out.chunks(4).enumerate() {
|
||||
let (bottom, top) = (k as f32, k as f32 + 1.0);
|
||||
assert_eq!([piece[0].pos[1], piece[1].pos[1]], [top, bottom]);
|
||||
assert_eq!([piece[2].pos[1], piece[3].pos[1]], [bottom, top]);
|
||||
for v in piece {
|
||||
assert!((0.25 - 1e-4..=0.75 + 1e-4).contains(&v.uv[1]), "{:?}", v.uv);
|
||||
assert_eq!(v.layer, 0);
|
||||
assert_eq!(v.light, 0x0000_f0f0);
|
||||
assert_eq!(v.color, 0xFF20_40FF);
|
||||
}
|
||||
assert!((piece[0].uv[1] - 0.75).abs() < 1e-4 && (piece[1].uv[1] - 0.25).abs() < 1e-4);
|
||||
assert_eq!([piece[0].uv[0], piece[2].uv[0]], [0.9, 0.1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_very_tall_beam_is_cut_into_at_most_64_pieces() {
|
||||
let face = quad(
|
||||
6,
|
||||
0xFFFF_0000,
|
||||
[
|
||||
([0.0, 2000.0, 0.0], [1.0, 2000.0]),
|
||||
([0.0, 0.0, 0.0], [1.0, 0.0]),
|
||||
([1.0, 0.0, 0.0], [0.0, 0.0]),
|
||||
([1.0, 2000.0, 0.0], [0.0, 2000.0]),
|
||||
],
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
beam_segments(&face, &mut out);
|
||||
assert!(out.len() / 4 <= 64, "{} pieces", out.len() / 4);
|
||||
let top = out.iter().map(|v| v.pos[1]).fold(f32::MIN, f32::max);
|
||||
let bottom = out.iter().map(|v| v.pos[1]).fold(f32::MAX, f32::min);
|
||||
assert_eq!(
|
||||
(bottom, top),
|
||||
(0.0, 2000.0),
|
||||
"the pieces still span the whole beam"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portal_faces_take_the_whole_portal_texture_at_full_light() {
|
||||
let mut face = quad(9, 0, [([0.0; 3], [0.0; 2]); 4]).to_vec();
|
||||
as_portal_texture(&mut face);
|
||||
let uvs: Vec<[f32; 2]> = face.iter().map(|v| v.uv).collect();
|
||||
assert_eq!(uvs, [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]);
|
||||
assert!(
|
||||
face.iter()
|
||||
.all(|v| v.layer == 0 && v.color == 0xFFFF_FFFF && v.light == 0x0000_f0f0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizon_keeps_no_portal_or_beam_face_under_a_pack() {
|
||||
let mut run = Vec::new();
|
||||
for layer in [0, 6, 9, 10, 2] {
|
||||
run.extend(quad(layer, 0, [([0.0; 3], [0.0; 2]); 4]));
|
||||
}
|
||||
let mut rest = Vec::new();
|
||||
extend_without_portals(&mut rest, &run);
|
||||
let kept: Vec<u32> = rest.chunks(4).map(|q| q[0].layer).collect();
|
||||
assert_eq!(kept, [0, 2]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod crack_projection_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -371,6 +371,8 @@ struct Pass {
|
||||
/// Bind groups by the sides read (bits 0–15), the buffers whose mip chain is current (bits
|
||||
/// 16–31) and the shadowcolor sides read (bits 32–33).
|
||||
groups: HashMap<u64, wgpu::BindGroup>,
|
||||
/// The same for End portal draws, with `end_portal.png` bound as the atlas.
|
||||
portal_groups: HashMap<u64, wgpu::BindGroup>,
|
||||
/// (binding, target) of each image: for a buffer, the side its sampler reads.
|
||||
images: Vec<(u32, Image)>,
|
||||
/// Binds the pack's storage buffers in set 4.
|
||||
@@ -545,6 +547,10 @@ pub struct PackPost {
|
||||
shadow_targets: Option<super::pack_shadow::ShadowTargets>,
|
||||
/// What draws terrain from the light (`shadow`), and translucent terrain (`shadow_water`).
|
||||
shadow_terrain: Option<Pass>,
|
||||
/// `end_portal.png`'s atlas rect this frame, and a texture of its own copied from it that
|
||||
/// End portal draws bind as their atlas.
|
||||
portal_rect: [f32; 4],
|
||||
portal_texture: Option<([f32; 4], wgpu::TextureView)>,
|
||||
shadow_water: Option<Pass>,
|
||||
shadow_comp: Vec<Pass>,
|
||||
/// Buffers with mipmaps (filterable ones a program asks them for).
|
||||
@@ -1648,6 +1654,7 @@ fn build_pass(
|
||||
atlas: geometry.and_then(|g| g.atlas.clone()),
|
||||
ids: geometry.is_some_and(|g| g.ids.is_some()),
|
||||
groups: HashMap::new(),
|
||||
portal_groups: HashMap::new(),
|
||||
copies: image_buffers(&images) & reads,
|
||||
reads: reads | image_buffers(&images),
|
||||
buffers: !interface.buffers.is_empty(),
|
||||
@@ -1782,6 +1789,7 @@ fn build_compute(
|
||||
atlas: None,
|
||||
ids: false,
|
||||
groups: HashMap::new(),
|
||||
portal_groups: HashMap::new(),
|
||||
buffers: !interface.buffers.is_empty(),
|
||||
images,
|
||||
compute: Vec::new(),
|
||||
@@ -2077,6 +2085,7 @@ impl PackPost {
|
||||
post.entities_translucent,
|
||||
post.particles,
|
||||
post.damaged_block,
|
||||
post.beacon_beam,
|
||||
];
|
||||
let late = [
|
||||
false,
|
||||
@@ -2084,6 +2093,7 @@ impl PackPost {
|
||||
post.translucent_late,
|
||||
post.particles_late,
|
||||
false,
|
||||
true,
|
||||
];
|
||||
let shadow_only = (post.shadow_only_player, post.shadow_only_lit_blocks);
|
||||
let shadow_programs = [post.shadow_entities, post.shadow_block];
|
||||
@@ -2488,6 +2498,8 @@ impl PackPost {
|
||||
depth2,
|
||||
shadow_targets,
|
||||
shadow_terrain,
|
||||
portal_rect: [0.0; 4],
|
||||
portal_texture: None,
|
||||
shadow_water,
|
||||
shadow_comp,
|
||||
mipped,
|
||||
@@ -3092,6 +3104,42 @@ impl PackPost {
|
||||
continue;
|
||||
}
|
||||
let group = self.bind(device, queue, encoder, pass, size);
|
||||
// End portal faces bind `end_portal.png` as the atlas, a texture of its own, so packs
|
||||
// that wrap it with their own coordinates sample the portal sprite only.
|
||||
let portal_group = if geometry
|
||||
.draws
|
||||
.iter()
|
||||
.any(|d| d.pack && d.program == program && d.portal)
|
||||
&& let Some(atlas) = pass.atlas.clone().or_else(|| self.atlas.clone())
|
||||
&& self.portal_rect[2] > 0.0
|
||||
{
|
||||
if self
|
||||
.portal_texture
|
||||
.as_ref()
|
||||
.is_none_or(|(rect, _)| *rect != self.portal_rect)
|
||||
{
|
||||
pass.portal_groups.clear();
|
||||
let view = super::pack_weather::copy_sprite(
|
||||
device,
|
||||
encoder,
|
||||
atlas.texture(),
|
||||
self.portal_rect,
|
||||
);
|
||||
self.portal_texture = Some((self.portal_rect, view));
|
||||
}
|
||||
let view = self.portal_texture.as_ref().map(|p| p.1.clone());
|
||||
// `bind` caches groups by a key without the atlas: the portal groups are kept
|
||||
// in a cache of their own, swapped in with the texture.
|
||||
let saved = std::mem::replace(&mut pass.atlas, view);
|
||||
let groups =
|
||||
std::mem::replace(&mut pass.groups, std::mem::take(&mut pass.portal_groups));
|
||||
let g = self.bind(device, queue, encoder, pass, size);
|
||||
pass.portal_groups = std::mem::replace(&mut pass.groups, groups);
|
||||
pass.atlas = saved;
|
||||
Some(g)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
{
|
||||
let mut rp = self.begin(encoder, pass);
|
||||
rp.set_pipeline(pass.render());
|
||||
@@ -3104,7 +3152,14 @@ impl PackPost {
|
||||
rp.set_bind_group(1, &geometry.table_group, &[]);
|
||||
rp.set_vertex_buffer(0, geometry.vertices());
|
||||
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
let mut on_portal = false;
|
||||
for d in draws {
|
||||
if let Some(portal) = &portal_group
|
||||
&& d.portal != on_portal
|
||||
{
|
||||
rp.set_bind_group(0, if d.portal { portal } else { &group }, &[]);
|
||||
on_portal = d.portal;
|
||||
}
|
||||
if pass.draw {
|
||||
rp.set_bind_group(2, &geometry.group, &[geometry.offset(d.slot)]);
|
||||
}
|
||||
@@ -3178,9 +3233,11 @@ impl PackPost {
|
||||
entities: &[super::Vertex],
|
||||
spans: &[super::pack_draw::EntitySpan],
|
||||
crumbling: [f32; 4],
|
||||
portal: [f32; 4],
|
||||
rest: &mut Vec<super::Vertex>,
|
||||
skin: Option<&super::entity_skin::SkinFrame>,
|
||||
) {
|
||||
self.portal_rect = portal;
|
||||
let shadow = self.shadow_casters();
|
||||
let Some(e) = &mut self.entities else {
|
||||
rest.clear();
|
||||
@@ -4774,6 +4831,7 @@ mod tests {
|
||||
indices: None,
|
||||
gpu: Gpu::of(&adapter, device.features()),
|
||||
storable: 0,
|
||||
rgb: 0,
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
};
|
||||
|
||||
@@ -162,7 +162,7 @@ impl PackWeather {
|
||||
|
||||
/// A texture holding the atlas's texels under `rect` (normalised x, y, width, height), as
|
||||
/// Horizon's weather shader reads them (`weather_texel`).
|
||||
fn copy_sprite(
|
||||
pub(super) fn copy_sprite(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
atlas: &wgpu::Texture,
|
||||
|
||||
@@ -128,6 +128,44 @@ impl EntityCullingMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Particle visibility strategy, independent of Entity Culling.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum ParticleCullingMode {
|
||||
#[default]
|
||||
Off,
|
||||
Synchronous,
|
||||
Asynchronous,
|
||||
}
|
||||
impl ParticleCullingMode {
|
||||
pub const ALL: [ParticleCullingMode; 3] = [
|
||||
ParticleCullingMode::Off,
|
||||
ParticleCullingMode::Synchronous,
|
||||
ParticleCullingMode::Asynchronous,
|
||||
];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Off => "off",
|
||||
Self::Synchronous => "sync",
|
||||
Self::Asynchronous => "async",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Off => "Off",
|
||||
Self::Synchronous => "Synchronous",
|
||||
Self::Asynchronous => "Asynchronous",
|
||||
}
|
||||
}
|
||||
fn parse(s: &str) -> Self {
|
||||
match s {
|
||||
"synchronous" | "sync" => Self::Synchronous,
|
||||
"asynchronous" | "async" => Self::Asynchronous,
|
||||
"off" => Self::Off,
|
||||
_ => Self::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the swapchain presents frames.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum PresentMode {
|
||||
@@ -561,6 +599,8 @@ pub struct Graphics {
|
||||
pub cave_culling: bool,
|
||||
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
|
||||
pub entity_culling: EntityCullingMode,
|
||||
/// Skip particles outside the view or hidden behind opaque terrain.
|
||||
pub particle_culling: ParticleCullingMode,
|
||||
/// Terrain leaf faces inside a canopy (see [`LeafCullingMode`]); `Off` draws
|
||||
/// vanilla's fancy leaves. Ignored with a resource pack that may replace
|
||||
/// the vanilla leaf models ([`Graphics::leaf_culling_effective`]).
|
||||
@@ -658,6 +698,7 @@ impl Default for Graphics {
|
||||
blue_map: crate::bluemap::Settings::default(),
|
||||
cave_culling: true,
|
||||
entity_culling: EntityCullingMode::Off,
|
||||
particle_culling: ParticleCullingMode::Off,
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
cache_shulker_boxes: false,
|
||||
// Sign text has used its renderer cache independently of the new model caches.
|
||||
@@ -751,6 +792,7 @@ impl Graphics {
|
||||
"blueMapAutoDetect" => g.blue_map.auto_detect = flag(g.blue_map.auto_detect),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
|
||||
"particleCulling" => g.particle_culling = ParticleCullingMode::parse(value),
|
||||
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
|
||||
"blockEntityCache" => {
|
||||
let enabled = flag(false);
|
||||
@@ -841,12 +883,13 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
bool_text(self.cave_culling),
|
||||
self.entity_culling.name(),
|
||||
self.particle_culling.name(),
|
||||
self.leaf_culling.name(),
|
||||
bool_text(self.cache_shulker_boxes),
|
||||
bool_text(self.cache_signs),
|
||||
@@ -1025,6 +1068,7 @@ mod tests {
|
||||
},
|
||||
cave_culling: false,
|
||||
entity_culling: EntityCullingMode::Synchronous,
|
||||
particle_culling: ParticleCullingMode::Asynchronous,
|
||||
leaf_culling: LeafCullingMode::SolidAggressive,
|
||||
cache_shulker_boxes: true,
|
||||
cache_signs: true,
|
||||
@@ -1071,6 +1115,16 @@ mod tests {
|
||||
};
|
||||
assert_eq!(Graphics::parse(&graphics.serialize()), graphics);
|
||||
}
|
||||
for mode in ParticleCullingMode::ALL {
|
||||
let graphics = Graphics {
|
||||
particle_culling: mode,
|
||||
..Graphics::default()
|
||||
};
|
||||
assert_eq!(
|
||||
Graphics::parse(&graphics.serialize()).particle_culling,
|
||||
mode
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
Graphics::parse("entityCulling:true\n").entity_culling,
|
||||
EntityCullingMode::Synchronous
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use crate::{
|
||||
graphics::{
|
||||
EntityCullingMode, FarDetail, FrameGeneration, GraphicsApi, LeafCullingMode, Overlay,
|
||||
Pacing, PresentMode, Preset, Upscaling,
|
||||
Pacing, ParticleCullingMode, PresentMode, Preset, Upscaling,
|
||||
},
|
||||
graphics_style::{self as style, Cost},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
@@ -66,6 +66,7 @@ pub enum H {
|
||||
BlueMapRadius,
|
||||
CaveCulling,
|
||||
EntityCulling,
|
||||
ParticleCulling,
|
||||
LeafCulling,
|
||||
CacheShulkerBoxes,
|
||||
CacheSigns,
|
||||
@@ -379,6 +380,11 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
None,
|
||||
"Hides entities behind opaque blocks.",
|
||||
),
|
||||
h(
|
||||
H::ParticleCulling,
|
||||
None,
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.",
|
||||
),
|
||||
h(
|
||||
H::LeafCulling,
|
||||
Saves,
|
||||
@@ -631,6 +637,15 @@ fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
|
||||
}
|
||||
LeafCullingMode::Hollow => "Drops faces between leaves: faster, but gaps look through.",
|
||||
},
|
||||
(Item::Horizon(H::ParticleCulling), _) => match s.horizon.particle_culling {
|
||||
ParticleCullingMode::Off => "Draws every particle.",
|
||||
ParticleCullingMode::Synchronous => {
|
||||
"Skips offscreen, subpixel and terrain-hidden particles."
|
||||
}
|
||||
ParticleCullingMode::Asynchronous => {
|
||||
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work."
|
||||
}
|
||||
},
|
||||
_ => text,
|
||||
}
|
||||
}
|
||||
@@ -722,6 +737,7 @@ impl H {
|
||||
),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
|
||||
H::ParticleCulling => labelled("Particle Culling", g.particle_culling.label()),
|
||||
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
|
||||
H::CacheShulkerBoxes => labelled("Shulker Boxes", on_off(g.cache_shulker_boxes)),
|
||||
H::CacheSigns => labelled("Signs", on_off(g.cache_signs)),
|
||||
@@ -874,6 +890,9 @@ impl H {
|
||||
H::EntityCulling => {
|
||||
g.entity_culling = step(&EntityCullingMode::ALL, g.entity_culling, back)
|
||||
}
|
||||
H::ParticleCulling => {
|
||||
g.particle_culling = step(&ParticleCullingMode::ALL, g.particle_culling, back)
|
||||
}
|
||||
H::LeafCulling => {
|
||||
g.leaf_culling = step(&LeafCullingMode::ALL, g.leaf_culling, back);
|
||||
}
|
||||
@@ -2994,6 +3013,42 @@ mod tests {
|
||||
assert_eq!(item.label(&s).plain(), "Entity Culling: Asynchronous");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particle_culling_modes_are_opt_in_and_cycle_from_its_row() {
|
||||
let mut s = Settings::default();
|
||||
let item = Item::Horizon(H::ParticleCulling);
|
||||
assert_eq!(s.horizon.particle_culling, ParticleCullingMode::Off);
|
||||
assert_eq!(item.label(&s).plain(), "Particle Culling: Off");
|
||||
for mode in ParticleCullingMode::ALL {
|
||||
s.horizon.particle_culling = mode;
|
||||
for preset in Preset::ALL {
|
||||
crate::graphics::apply_preset(preset, &mut s);
|
||||
assert_eq!(s.horizon.particle_culling, mode);
|
||||
}
|
||||
assert_eq!(
|
||||
crate::graphics::Graphics::parse(&s.horizon.serialize()).particle_culling,
|
||||
mode
|
||||
);
|
||||
}
|
||||
s.horizon.particle_culling = ParticleCullingMode::Off;
|
||||
let mut ui = GraphicsUi::default();
|
||||
let r = row(&mut ui, &s, item);
|
||||
for (mode, label) in [
|
||||
(ParticleCullingMode::Synchronous, "Synchronous"),
|
||||
(ParticleCullingMode::Asynchronous, "Asynchronous"),
|
||||
(ParticleCullingMode::Off, "Off"),
|
||||
] {
|
||||
s.horizon.preset = Preset::Balanced;
|
||||
assert_eq!(
|
||||
press(&mut ui, &mut s, (r[0] + 4.0, r[1] + 4.0)),
|
||||
[Action::ButtonClick, Action::Changed]
|
||||
);
|
||||
assert_eq!(s.horizon.particle_culling, mode);
|
||||
assert_eq!(s.horizon.preset, Preset::Custom);
|
||||
assert_eq!(item.label(&s).plain(), format!("Particle Culling: {label}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_cycle_button_cycles_forward_and_shift_click_cycles_backward_with_wrapping() {
|
||||
let mut s = Settings::default();
|
||||
@@ -3120,7 +3175,12 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(culling.0, Page::Performance);
|
||||
let block: Vec<Item> = culling.1.iter().map(|r| r.item).collect();
|
||||
for h in [H::CaveCulling, H::EntityCulling, H::LeafCulling] {
|
||||
for h in [
|
||||
H::CaveCulling,
|
||||
H::EntityCulling,
|
||||
H::ParticleCulling,
|
||||
H::LeafCulling,
|
||||
] {
|
||||
assert!(block.contains(&Item::Horizon(h)), "{h:?}");
|
||||
}
|
||||
// The block-entity caches are the group after it, in this order.
|
||||
@@ -3336,36 +3396,47 @@ mod tests {
|
||||
let mut s = Settings::default();
|
||||
s.horizon.leaf_culling = LeafCullingMode::Hollow;
|
||||
s.horizon.entity_culling = EntityCullingMode::Asynchronous;
|
||||
for (code, leaf, entity, leaf_text) in [
|
||||
s.horizon.particle_culling = ParticleCullingMode::Asynchronous;
|
||||
for (code, leaf, entity, leaf_text, particle, particle_text) in [
|
||||
(
|
||||
"de_de",
|
||||
"Laub-Culling: Hohl",
|
||||
"Objekt-Culling: Asynchron",
|
||||
"Lässt Flächen zwischen Blättern weg: schneller, aber Lücken sind durchsichtig.",
|
||||
"Partikel-Culling: Asynchron",
|
||||
"Überspringt Partikel außerhalb des Bildes, unterhalb eines Pixels oder hinter Terrain und entlastet den Render-Thread.",
|
||||
),
|
||||
(
|
||||
"pl_pl",
|
||||
"Pomijanie liści: Puste",
|
||||
"Pomijanie bytów: Asynchroniczne",
|
||||
"Pomija ściany między liśćmi: szybciej, ale luki są przezroczyste.",
|
||||
"Pomijanie cząsteczek: Asynchroniczne",
|
||||
"Pomija cząsteczki poza ekranem, mniejsze niż piksel i zasłonięte przez teren, odciążając wątek renderowania.",
|
||||
),
|
||||
(
|
||||
"uk_ua",
|
||||
"Відсікання листя: Порожнє",
|
||||
"Відсік. сутностей: Асинхронно",
|
||||
"Прибирає грані між листям: швидше, але прогалини прозорі.",
|
||||
"Відсікання частинок: Асинхронно",
|
||||
"Пропускає частинки поза екраном, менші за піксель і закриті місцевістю, зменшуючи роботу потоку рендерингу.",
|
||||
),
|
||||
(
|
||||
"zh_cn",
|
||||
"树叶剔除: 镂空",
|
||||
"实体剔除: 异步",
|
||||
"省略树叶之间的面:更快,但缝隙会透视。",
|
||||
"粒子剔除: 异步",
|
||||
"跳过屏幕外、小于一个像素和被地形遮挡的粒子,减少渲染线程的工作量。",
|
||||
),
|
||||
(
|
||||
"fr_fr",
|
||||
"Occlusion feuillage: Creux",
|
||||
"Occlusion des entités: Asynchrone",
|
||||
"Supprime les faces entre feuilles : plus rapide, mais on voit à travers les trous.",
|
||||
"Occlusion des particules: Asynchrone",
|
||||
"Ignore les particules hors écran, trop petites ou masquées par le terrain avec moins de travail sur le thread de rendu.",
|
||||
),
|
||||
] {
|
||||
let (shown, _) = lang::scoped(lang::Language::load(&[], code), || {
|
||||
@@ -3374,11 +3445,19 @@ mod tests {
|
||||
item(H::LeafCulling).label(&s).plain(),
|
||||
item(H::EntityCulling).label(&s).plain(),
|
||||
lang::get(description(item(H::LeafCulling), &s, "")),
|
||||
item(H::ParticleCulling).label(&s).plain(),
|
||||
lang::get(description(item(H::ParticleCulling), &s, "")),
|
||||
)
|
||||
});
|
||||
assert_eq!(
|
||||
shown,
|
||||
(leaf.into(), entity.into(), leaf_text.into()),
|
||||
(
|
||||
leaf.into(),
|
||||
entity.into(),
|
||||
leaf_text.into(),
|
||||
particle.into(),
|
||||
particle_text.into(),
|
||||
),
|
||||
"{code}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3008,8 +3008,14 @@ mod tests {
|
||||
}
|
||||
/// A resource root holding one animated block texture: a strip of two
|
||||
/// 16x16 frames, red then blue, whose mcmeta starts on the blue one.
|
||||
/// Each call gets its own folder: tests run side by side, and one adds
|
||||
/// a strip of its own that another would otherwise find.
|
||||
fn animated_root() -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("horizon-atlas-anim-{}", std::process::id()));
|
||||
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-anim-{}-{n}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let block = root.join("assets/minecraft/textures/block");
|
||||
fs::create_dir_all(&block).unwrap();
|
||||
write_strip(
|
||||
|
||||
@@ -196,7 +196,11 @@ pub struct Column {
|
||||
}
|
||||
impl Column {
|
||||
/// Reduce an entire horizontal footprint, rather than inflating its corner
|
||||
/// column (a single leaf used to become a whole distant tree-sized slab).
|
||||
/// column. A height is drawn where any column of the footprint holds a
|
||||
/// block there, in the material most of them hold: anything joined to
|
||||
/// the ground stays joined at every width. Drawing only heights most of
|
||||
/// the footprint held dropped trunks (one column of 16 or 256) under the
|
||||
/// crowns they hold up, and the crowns hung in the air as slabs.
|
||||
pub fn reduced(columns: &[Column; 256], x: usize, z: usize, width: usize) -> Self {
|
||||
if width == 1 {
|
||||
return columns[z * 16 + x].clone();
|
||||
@@ -220,7 +224,7 @@ impl Column {
|
||||
let mut bottom = MIN_Y as i16;
|
||||
for (y, material, change) in events {
|
||||
if bottom < y {
|
||||
if occupied * 2 >= (width * width) as i32
|
||||
if occupied > 0
|
||||
&& let Some(&((state, biome), _)) =
|
||||
votes.iter().max_by_key(|&(material, count)| {
|
||||
(*count, std::cmp::Reverse((material.0.0, material.1)))
|
||||
@@ -3308,7 +3312,7 @@ mod tests {
|
||||
assert_eq!(kelp.surface.unwrap().height, 40);
|
||||
}
|
||||
#[test]
|
||||
fn coarse_voxels_reduce_the_area_instead_of_inflating_one_leaf() {
|
||||
fn coarse_voxels_keep_what_any_column_holds_so_crowns_stay_on_trunks() {
|
||||
let span = |min, max, state| Span {
|
||||
min,
|
||||
max,
|
||||
@@ -3320,8 +3324,8 @@ mod tests {
|
||||
surface: None,
|
||||
spans: vec![span(0, 4, 1)].into(),
|
||||
});
|
||||
columns[0].spans.push(span(8, 10, 2));
|
||||
assert_eq!(Column::reduced(&columns, 0, 0, 4).spans, [span(0, 4, 1)]);
|
||||
// One trunk column under a crown over the whole footprint.
|
||||
columns[0].spans.push(span(4, 12, 2));
|
||||
for z in 0..4 {
|
||||
for x in 0..4 {
|
||||
columns[z * 16 + x].spans.push(span(12, 14, 3));
|
||||
@@ -3329,17 +3333,20 @@ mod tests {
|
||||
}
|
||||
assert_eq!(
|
||||
Column::reduced(&columns, 0, 0, 4).spans,
|
||||
[span(0, 4, 1), span(12, 14, 3)]
|
||||
[span(0, 4, 1), span(4, 12, 2), span(12, 14, 3)]
|
||||
);
|
||||
assert_eq!(Column::reduced(&columns, 0, 0, 1), columns[0]);
|
||||
let reduced = ReducedColumns::new(Arc::new(columns));
|
||||
assert_eq!(
|
||||
reduced.get(0, 0, 1).spans,
|
||||
[span(0, 4, 1), span(8, 10, 2), span(12, 14, 3)]
|
||||
reduced.get(0, 0, 4).spans,
|
||||
[span(0, 4, 1), span(4, 12, 2), span(12, 14, 3)]
|
||||
);
|
||||
assert_eq!(reduced.get(0, 0, 4).spans, [span(0, 4, 1), span(12, 14, 3)]);
|
||||
assert_eq!(reduced.get(4, 8, 4).spans, [span(0, 4, 1)]);
|
||||
assert_eq!(reduced.get(0, 0, 16).spans, [span(0, 4, 1)]);
|
||||
assert_eq!(
|
||||
reduced.get(0, 0, 16).spans,
|
||||
[span(0, 4, 1), span(4, 12, 2), span(12, 14, 3)],
|
||||
"a crown over one corner of a wide cell still sits on its trunk"
|
||||
);
|
||||
}
|
||||
/// Detail read before a refresh over its chunk is dropped (asked
|
||||
/// again); elsewhere, or read after, it is kept.
|
||||
|
||||
@@ -99,9 +99,14 @@ pub(crate) fn prepare_with_models(
|
||||
.filter(|name| registry.default_state(name).is_some())
|
||||
.map(|name| name.strip_prefix("minecraft:").unwrap_or(name))
|
||||
.collect();
|
||||
let mut catalog =
|
||||
StateModels::from_facts_with_models(root, registry, &blocks, sprites, &facts, models)?;
|
||||
catalog.bake_standalone(root, sprites, models, standalone())?;
|
||||
let mut catalog = {
|
||||
let _step = crate::spans::startup::step("core.bake_states");
|
||||
StateModels::from_facts_with_models(root, registry, &blocks, sprites, &facts, models)?
|
||||
};
|
||||
{
|
||||
let _step = crate::spans::startup::step("core.bake_standalone");
|
||||
catalog.bake_standalone(root, sprites, models, standalone())?;
|
||||
}
|
||||
let mut placements = Vec::new();
|
||||
let mut states = Vec::new();
|
||||
for (index, (name, properties)) in GALLERY.iter().enumerate() {
|
||||
|
||||
@@ -19,6 +19,7 @@ use horizon_core::{
|
||||
};
|
||||
use horizon_world::block_collision::BlockCollisionRegistry;
|
||||
use std::{
|
||||
array,
|
||||
collections::{HashMap, HashSet},
|
||||
path::Path,
|
||||
};
|
||||
@@ -527,7 +528,7 @@ pub fn breaking_position(
|
||||
}
|
||||
}
|
||||
// Vanilla consumes all three doubles before replacing the hit-face coordinate.
|
||||
let mut p = std::array::from_fn(|i| {
|
||||
let mut p = array::from_fn(|i| {
|
||||
f64::from(pos[i])
|
||||
+ random.next_double() * (bounds[i + 3] - bounds[i] - f64::from(0.2f32))
|
||||
+ f64::from(0.1f32)
|
||||
@@ -659,18 +660,16 @@ pub fn destroy_positions(
|
||||
mut emit: impl FnMut([f64; 3], [f64; 3]),
|
||||
) {
|
||||
for b in boxes {
|
||||
let widths: [f64; 3] = std::array::from_fn(|i| (b[i + 3] - b[i]).min(1.0));
|
||||
let widths: [f64; 3] = array::from_fn(|i| (b[i + 3] - b[i]).min(1.0));
|
||||
let counts: [usize; 3] = widths.map(|w| ((w / 0.25).ceil() as usize).max(2));
|
||||
for x in 0..counts[0] {
|
||||
for y in 0..counts[1] {
|
||||
for z in 0..counts[2] {
|
||||
let cells = [x, y, z];
|
||||
let relative: [f64; 3] =
|
||||
std::array::from_fn(|i| (cells[i] as f64 + 0.5) / counts[i] as f64);
|
||||
array::from_fn(|i| (cells[i] as f64 + 0.5) / counts[i] as f64);
|
||||
emit(
|
||||
std::array::from_fn(|i| {
|
||||
f64::from(pos[i]) + (relative[i] * widths[i] + b[i])
|
||||
}),
|
||||
array::from_fn(|i| f64::from(pos[i]) + (relative[i] * widths[i] + b[i])),
|
||||
relative.map(|v| v - 0.5),
|
||||
);
|
||||
}
|
||||
@@ -751,7 +750,7 @@ pub fn on_block_faces_between(
|
||||
let count = random.next_int_bound(max - min + 1).unwrap_or(0) + min;
|
||||
for _ in 0..count {
|
||||
// getRandomSpeedRanges, then spawnParticleOnFace with step factor 0.55.
|
||||
let speed: [f64; 3] = std::array::from_fn(|_| next_double_between(random, -0.5, 0.5));
|
||||
let speed: [f64; 3] = array::from_fn(|_| next_double_between(random, -0.5, 0.5));
|
||||
let mut p = [0.0; 3];
|
||||
let mut v = [0.0; 3];
|
||||
for i in 0..3 {
|
||||
@@ -780,12 +779,12 @@ pub fn along_axis(
|
||||
) {
|
||||
let count = random.next_int_bound(max - min + 1).unwrap_or(0) + min;
|
||||
for _ in 0..count {
|
||||
let p: [f64; 3] = std::array::from_fn(|i| {
|
||||
let p: [f64; 3] = array::from_fn(|i| {
|
||||
f64::from(pos[i])
|
||||
+ 0.5
|
||||
+ next_double_between(random, -1.0, 1.0) * if i == axis { 0.5 } else { 0.125 }
|
||||
});
|
||||
let v: [f64; 3] = std::array::from_fn(|i| {
|
||||
let v: [f64; 3] = array::from_fn(|i| {
|
||||
if i == axis {
|
||||
next_double_between(random, -1.0, 1.0)
|
||||
} else {
|
||||
@@ -1268,6 +1267,113 @@ impl ParticleLightCache {
|
||||
}
|
||||
}
|
||||
|
||||
const PARTICLE_SECTION_CACHE_SIZE: usize = 128;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SectionFrustumResult {
|
||||
Outside,
|
||||
Intersects,
|
||||
Inside,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CachedSectionFrustum {
|
||||
section: [i32; 3],
|
||||
radius_quarters: i32,
|
||||
result: SectionFrustumResult,
|
||||
}
|
||||
|
||||
struct ParticleSectionFrustumCache {
|
||||
entries: [Option<CachedSectionFrustum>; PARTICLE_SECTION_CACHE_SIZE],
|
||||
}
|
||||
|
||||
impl Default for ParticleSectionFrustumCache {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entries: [None; PARTICLE_SECTION_CACHE_SIZE],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ParticleSectionFrustumCache {
|
||||
fn result(
|
||||
&mut self,
|
||||
world: [f64; 3],
|
||||
camera: DVec3,
|
||||
radius: f64,
|
||||
frustum: &crate::gfx::Frustum,
|
||||
) -> SectionFrustumResult {
|
||||
let Some(section) = world_section(world) else {
|
||||
return SectionFrustumResult::Intersects;
|
||||
};
|
||||
if !radius.is_finite() || radius < 0.0 || radius * 4.0 > f64::from(i32::MAX) {
|
||||
return SectionFrustumResult::Intersects;
|
||||
}
|
||||
let radius_quarters = (radius * 4.0).ceil() as i32;
|
||||
let hash = (section[0] as u32).wrapping_mul(73_856_093)
|
||||
^ (section[1] as u32).wrapping_mul(19_349_663)
|
||||
^ (section[2] as u32).wrapping_mul(83_492_791)
|
||||
^ (radius_quarters as u32).wrapping_mul(2_654_435_761);
|
||||
let slot = hash as usize & (PARTICLE_SECTION_CACHE_SIZE - 1);
|
||||
if let Some(entry) = self.entries[slot]
|
||||
&& entry.section == section
|
||||
&& entry.radius_quarters == radius_quarters
|
||||
{
|
||||
return entry.result;
|
||||
}
|
||||
|
||||
let pad = f64::from(radius_quarters) * 0.25;
|
||||
let camera = camera.to_array();
|
||||
let min = array::from_fn(|axis| f64::from(section[axis]) * 16.0 - pad - camera[axis]);
|
||||
let max = array::from_fn(|axis| min[axis] + 16.0 + 2.0 * pad);
|
||||
let result = if min.into_iter().chain(max).any(|v| !v.is_finite()) {
|
||||
SectionFrustumResult::Intersects
|
||||
} else {
|
||||
let min = FVec3::from_array(min.map(|v| v as f32));
|
||||
let max = FVec3::from_array(max.map(|v| v as f32));
|
||||
if !min.is_finite() || !max.is_finite() {
|
||||
SectionFrustumResult::Intersects
|
||||
} else if frustum.contains(min, max) {
|
||||
SectionFrustumResult::Inside
|
||||
} else if frustum.intersects(min, max) {
|
||||
SectionFrustumResult::Intersects
|
||||
} else {
|
||||
SectionFrustumResult::Outside
|
||||
}
|
||||
};
|
||||
self.entries[slot] = Some(CachedSectionFrustum {
|
||||
section,
|
||||
radius_quarters,
|
||||
result,
|
||||
});
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn world_section(position: [f64; 3]) -> Option<[i32; 3]> {
|
||||
let mut block = [0; 3];
|
||||
for axis in 0..3 {
|
||||
let value = position[axis].floor();
|
||||
if !value.is_finite() || value < f64::from(i32::MIN) || value > f64::from(i32::MAX) {
|
||||
return None;
|
||||
}
|
||||
block[axis] = value as i32;
|
||||
}
|
||||
Some([block[0] >> 4, block[1] >> 4, block[2] >> 4])
|
||||
}
|
||||
|
||||
fn below_half_pixel(radius: f64, distance_squared: f64, pixel_focal_length: f32) -> bool {
|
||||
let projected_limit = 4.0 * radius * f64::from(pixel_focal_length);
|
||||
radius.is_finite()
|
||||
&& radius >= 0.0
|
||||
&& distance_squared.is_finite()
|
||||
&& distance_squared > 0.0
|
||||
&& pixel_focal_length.is_finite()
|
||||
&& pixel_focal_length > 0.0
|
||||
&& projected_limit.is_finite()
|
||||
&& projected_limit * projected_limit < distance_squared
|
||||
}
|
||||
|
||||
/// `ClientExplosionTracker.ExplosionInfo`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct TrackedExplosion {
|
||||
@@ -1551,7 +1657,7 @@ impl ParticleEngine {
|
||||
for _ in 0..128 {
|
||||
let r = &mut self.level_random;
|
||||
let t = r.next_double();
|
||||
let velocity = std::array::from_fn(|_| f64::from((r.next_float() - 0.5) * 0.2));
|
||||
let velocity = array::from_fn(|_| f64::from((r.next_float() - 0.5) * 0.2));
|
||||
let position = [
|
||||
to[0] + t * (from[0] - to[0]) + (r.next_double() - 0.5) + 0.5,
|
||||
to[1] + t * (from[1] - to[1]) + r.next_double() - 0.5,
|
||||
@@ -1579,7 +1685,7 @@ impl ParticleEngine {
|
||||
for _ in 0..128 {
|
||||
let r = &mut self.level_random;
|
||||
let t = r.next_double();
|
||||
let velocity = std::array::from_fn(|_| f64::from((r.next_float() - 0.5) * 0.2));
|
||||
let velocity = array::from_fn(|_| f64::from((r.next_float() - 0.5) * 0.2));
|
||||
// Mth.lerp(t, to, from).
|
||||
let position = [
|
||||
to[0] + t * (from[0] - to[0]) + (r.next_double() - 0.5) * WIDTH * 2.0,
|
||||
@@ -4391,8 +4497,7 @@ impl ParticleEngine {
|
||||
.filter(|p| p.rgba[3] > 0.0)
|
||||
.filter_map(|p| {
|
||||
let (_, rgb, reach) = shining.iter().find(|(ty, ..)| *ty == p.ty)?;
|
||||
let at =
|
||||
std::array::from_fn(|i| p.previous[i] + (p.position[i] - p.previous[i]) * f);
|
||||
let at = array::from_fn(|i| p.previous[i] + (p.position[i] - p.previous[i]) * f);
|
||||
Some((at, *rgb, *reach, p.rgba[3].clamp(0.0, 1.0)))
|
||||
})
|
||||
.collect()
|
||||
@@ -4407,11 +4512,70 @@ impl ParticleEngine {
|
||||
partial: f32,
|
||||
light_at: &dyn Fn([i32; 3]) -> u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
self.render_inner::<false>(
|
||||
camera,
|
||||
yaw,
|
||||
pitch,
|
||||
partial,
|
||||
0.0,
|
||||
None,
|
||||
&mut |_, _| false,
|
||||
light_at,
|
||||
out,
|
||||
);
|
||||
}
|
||||
|
||||
/// Render particles after rejecting view-frustum misses and any particles
|
||||
/// the caller proves hidden behind opaque terrain.
|
||||
pub fn render_culled(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
partial: f32,
|
||||
pixel_focal_length: f32,
|
||||
culling_enabled: bool,
|
||||
frustum: Option<&crate::gfx::Frustum>,
|
||||
mut occluded: impl FnMut([f64; 3], [f64; 3]) -> bool,
|
||||
light_at: &dyn Fn([i32; 3]) -> u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if !culling_enabled {
|
||||
self.render(camera, yaw, pitch, partial, light_at, out);
|
||||
return;
|
||||
}
|
||||
self.render_inner::<true>(
|
||||
camera,
|
||||
yaw,
|
||||
pitch,
|
||||
partial,
|
||||
pixel_focal_length,
|
||||
frustum,
|
||||
&mut occluded,
|
||||
light_at,
|
||||
out,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_inner<const CULL: bool>(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
partial: f32,
|
||||
pixel_focal_length: f32,
|
||||
frustum: Option<&crate::gfx::Frustum>,
|
||||
occluded: &mut dyn FnMut([f64; 3], [f64; 3]) -> bool,
|
||||
light_at: &dyn Fn([i32; 3]) -> u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
// Camera.setRotation: rotationYXZ(PI - yRot, -xRot, 0).
|
||||
let base = Quat::from_rotation_y(std::f32::consts::PI - yaw.to_radians())
|
||||
* Quat::from_rotation_x(-pitch.to_radians());
|
||||
let mut light_cache = ParticleLightCache::default();
|
||||
let mut section_cache =
|
||||
(CULL && frustum.is_some()).then(ParticleSectionFrustumCache::default);
|
||||
for p in &self.particles {
|
||||
if p.no_render {
|
||||
continue;
|
||||
@@ -4423,6 +4587,39 @@ impl ParticleEngine {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if CULL && matches!(p.behaviour, Behaviour::Shriek { delay } if delay > 0) {
|
||||
continue;
|
||||
}
|
||||
let culled_geometry = if CULL {
|
||||
let a = f64::from(partial);
|
||||
let world = array::from_fn(|i| p.previous[i] + (p.position[i] - p.previous[i]) * a);
|
||||
let center = (DVec3::from_array(world) - camera).as_vec3();
|
||||
let size = p.quad_size(partial);
|
||||
// Every generated quad vertex is sqrt(2) * size from its center,
|
||||
// including the rotated two-quad particle types.
|
||||
let radius = f64::from(size.abs()) * std::f64::consts::SQRT_2 + 0.05;
|
||||
if let (Some(frustum), Some(section_cache)) = (frustum, section_cache.as_mut()) {
|
||||
match section_cache.result(world, camera, radius, frustum) {
|
||||
SectionFrustumResult::Outside => continue,
|
||||
SectionFrustumResult::Intersects
|
||||
if !frustum.intersects_sphere(center, radius as f32) =>
|
||||
{
|
||||
continue;
|
||||
}
|
||||
SectionFrustumResult::Inside | SectionFrustumResult::Intersects => {}
|
||||
}
|
||||
}
|
||||
let distance_squared = f64::from(center.length_squared());
|
||||
if below_half_pixel(radius, distance_squared, pixel_focal_length) {
|
||||
continue;
|
||||
}
|
||||
if occluded(world.map(|v| v - radius), world.map(|v| v + radius)) {
|
||||
continue;
|
||||
}
|
||||
Some((center, size))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// `SubmitNodeCollection.submitQuadParticleGroup`: the translucent
|
||||
// layer draws after translucent terrain, so a crumb of stained
|
||||
// glass cannot hide the glass behind it (`getLayer`).
|
||||
@@ -4438,19 +4635,20 @@ impl ParticleEngine {
|
||||
},
|
||||
};
|
||||
let Some(r) = rect else { continue };
|
||||
let a = f64::from(partial);
|
||||
let pos = DVec3::new(
|
||||
p.previous[0] + (p.position[0] - p.previous[0]) * a,
|
||||
p.previous[1] + (p.position[1] - p.previous[1]) * a,
|
||||
p.previous[2] + (p.position[2] - p.previous[2]) * a,
|
||||
) - camera;
|
||||
let center = pos.as_vec3();
|
||||
let (center, size) = culled_geometry.unwrap_or_else(|| {
|
||||
let a = f64::from(partial);
|
||||
let pos = DVec3::new(
|
||||
p.previous[0] + (p.position[0] - p.previous[0]) * a,
|
||||
p.previous[1] + (p.position[1] - p.previous[1]) * a,
|
||||
p.previous[2] + (p.position[2] - p.previous[2]) * a,
|
||||
) - camera;
|
||||
(pos.as_vec3(), p.quad_size(partial))
|
||||
});
|
||||
let rotation = if p.roll != 0.0 {
|
||||
base * Quat::from_rotation_z(p.o_roll + (p.roll - p.o_roll) * partial)
|
||||
} else {
|
||||
base
|
||||
};
|
||||
let size = p.quad_size(partial);
|
||||
let mut alpha = p.rgba[3];
|
||||
// OverlayParticle.extract: the flash fades as it pulses.
|
||||
if p.ty == ParticleType::Flash {
|
||||
@@ -4464,7 +4662,7 @@ impl ParticleEngine {
|
||||
match p.behaviour {
|
||||
// ShriekParticle.extract: two tilted quads, fading out.
|
||||
Behaviour::Shriek { delay } => {
|
||||
if delay > 0 {
|
||||
if !CULL && delay > 0 {
|
||||
continue;
|
||||
}
|
||||
alpha = 1.0 - ((p.age as f32 + partial) / p.lifetime as f32).clamp(0.0, 1.0);
|
||||
@@ -4507,7 +4705,7 @@ impl ParticleEngine {
|
||||
// DustColorTransitionParticle.lerpColors.
|
||||
Behaviour::Transition { from, to } => {
|
||||
let t = (p.age as f32 + partial) / (p.lifetime as f32 + 1.0);
|
||||
rgb = std::array::from_fn(|i| from[i] + (to[i] - from[i]) * t);
|
||||
rgb = array::from_fn(|i| from[i] + (to[i] - from[i]) * t);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -4642,6 +4840,86 @@ mod tests {
|
||||
assert_eq!(reads.get(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subpixel_culling_uses_the_frame_projection_and_fails_open() {
|
||||
assert!(below_half_pixel(0.01, 41.0_f64.powi(2), 1_000.0));
|
||||
assert!(!below_half_pixel(0.01, 40.0_f64.powi(2), 1_000.0));
|
||||
assert!(!below_half_pixel(0.01, 41.0_f64.powi(2), 0.0));
|
||||
assert!(!below_half_pixel(0.01, f64::NAN, 1_000.0));
|
||||
assert!(!below_half_pixel(f64::NAN, 41.0_f64.powi(2), 1_000.0));
|
||||
|
||||
for distance in [0.25_f32, 1.0, 8.0, 40.0, 128.0] {
|
||||
for radius in [0.01, 0.05, 0.2] {
|
||||
let center = FVec3::new(distance, distance * 0.3, -distance * 0.8);
|
||||
let old = 2.0 * radius * 900.0 / f64::from(center.length()) < 0.5;
|
||||
assert_eq!(
|
||||
below_half_pixel(radius, f64::from(center.length_squared()), 900.0),
|
||||
old
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_culled_render_matches_the_regular_particle_path() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
let sprite = "test:smoke".to_owned();
|
||||
engine.atlas.insert(
|
||||
sprite.clone(),
|
||||
(
|
||||
Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
false,
|
||||
),
|
||||
);
|
||||
let mut random = LegacyRandom::new(7);
|
||||
let mut particle =
|
||||
Particle::moving(&mut random, ParticleType::Smoke, [0.0, 0.0, -3.0], [0.0; 3]);
|
||||
particle.sprite = Sprite::Named(sprite);
|
||||
engine.particles.push(particle);
|
||||
|
||||
let light_at = |_: [i32; 3]| 0xf0;
|
||||
let mut regular = Vec::new();
|
||||
engine.render(DVec3::ZERO, 0.0, 0.0, 0.5, &light_at, &mut regular);
|
||||
let mut disabled = Vec::new();
|
||||
engine.render_culled(
|
||||
DVec3::ZERO,
|
||||
0.0,
|
||||
0.0,
|
||||
0.5,
|
||||
900.0,
|
||||
false,
|
||||
None,
|
||||
|_, _| panic!("disabled culling must not query particle visibility"),
|
||||
&light_at,
|
||||
&mut disabled,
|
||||
);
|
||||
|
||||
let attributes = |vertices: &[Vertex]| {
|
||||
vertices
|
||||
.iter()
|
||||
.map(|vertex| {
|
||||
(
|
||||
vertex.pos,
|
||||
vertex.uv,
|
||||
vertex.layer,
|
||||
vertex.color,
|
||||
vertex.light,
|
||||
vertex.normal,
|
||||
vertex.glint_uv,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(attributes(&disabled), attributes(®ular));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mod_particles_json_becomes_its_sprite_set() {
|
||||
let dir =
|
||||
|
||||
@@ -441,11 +441,21 @@ pub mod startup {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Span;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
/// Spans on for every thread ([`super::enable`]) is process-wide: a test that turns them on
|
||||
/// would make another, run beside it, record what it expects left out. Those tests take
|
||||
/// this lock.
|
||||
fn spans_switch() -> MutexGuard<'static, ()> {
|
||||
static SWITCH: Mutex<()> = Mutex::new(());
|
||||
SWITCH.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// A slow tick's notes are its slowest steps, slowest first; short steps and steps on a
|
||||
/// thread that records no spans leave none.
|
||||
#[test]
|
||||
fn notes_keep_the_slowest_steps() {
|
||||
let _switch = spans_switch();
|
||||
super::record_here(false);
|
||||
super::note(500.0, || "unrecorded".into());
|
||||
assert!(super::take_notes().is_empty());
|
||||
@@ -577,6 +587,7 @@ mod tests {
|
||||
/// Laps record the steps between them while spans are on, and nothing while off.
|
||||
#[test]
|
||||
fn laps_time_consecutive_steps() {
|
||||
let _switch = spans_switch();
|
||||
let _ = super::take();
|
||||
let mut off = super::laps();
|
||||
off.lap("unseen");
|
||||
|
||||
@@ -333,6 +333,10 @@
|
||||
"horizon.pages.split_settings": "Splitscreen-Einstellungen",
|
||||
"horizon.pages.split_setup": "Splitscreen",
|
||||
"horizon.pages.split_setup.tooltip": "Spieler an diesem Computer: Controller können deiner Welt beitreten.",
|
||||
"Particle Culling": "Partikel-Culling",
|
||||
"Draws every particle.": "Zeichnet alle Partikel.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "Überspringt Partikel außerhalb des Bildes, unterhalb eines Pixels oder hinter Terrain.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Überspringt Partikel außerhalb des Bildes, unterhalb eines Pixels oder hinter Terrain und entlastet den Render-Thread.",
|
||||
"horizon.extras.header.cosmetics": "Kosmetik",
|
||||
"horizon.extras.cosmetics_mine": "Meine Kosmetik",
|
||||
"horizon.extras.cosmetics_mine.tooltip": "Ausgeblendet: Alle Horizon-Spieler sehen dich ohne deine Kosmetik, du selbst auch. Derselbe Schalter wie in der Garderobe des Launchers.",
|
||||
|
||||
@@ -483,6 +483,10 @@
|
||||
"horizon.repro.error": "Repro: %s",
|
||||
"horizon.repro.no_recording": "There is no recording to export yet.",
|
||||
"horizon.repro.replay_started": "Replay started: %s",
|
||||
"Particle Culling": "Particle Culling",
|
||||
"Draws every particle.": "Draws every particle.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "Skips offscreen, subpixel and terrain-hidden particles.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.",
|
||||
"horizon.pack.add": "+",
|
||||
"horizon.pack.addInfo": "Add a resource pack from Modrinth",
|
||||
"horizon.pack.addWorking": "Asking Modrinth...",
|
||||
|
||||
@@ -199,6 +199,10 @@
|
||||
"horizon.shader_options.open": "Opciones de sombreadores…",
|
||||
"horizon.shader_options.profile": "Perfil",
|
||||
"horizon.shader_options.loading": "Leyendo las opciones del paquete de sombreadores…",
|
||||
"Particle Culling": "Particle Culling",
|
||||
"Draws every particle.": "Draws every particle.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "Skips offscreen, subpixel and terrain-hidden particles.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.",
|
||||
"General": "General",
|
||||
"Advanced": "Avanzado",
|
||||
"Undo": "Deshacer",
|
||||
|
||||
@@ -370,6 +370,10 @@
|
||||
"horizon.extras.splitscreen.tooltip": "Dans un monde, une autre manette peut rejoindre en tant que nouveau joueur : appuyez sur A quand c'est demandé, puis choisissez un profil et où jouer.",
|
||||
"horizon.extras.ui_sound_reserve": "Canaux pour sons UI",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu.",
|
||||
"Particle Culling": "Occlusion des particules",
|
||||
"Draws every particle.": "Dessine toutes les particules.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "Ignore les particules hors écran, trop petites ou masquées par le terrain.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Ignore les particules hors écran, trop petites ou masquées par le terrain avec moins de travail sur le thread de rendu.",
|
||||
"horizon.extras.header.cosmetics": "Cosmétiques",
|
||||
"horizon.extras.cosmetics_mine": "Mes cosmétiques",
|
||||
"horizon.extras.cosmetics_mine.tooltip": "Masqués : tous les joueurs Horizon vous voient sans vos cosmétiques, vous compris. Le même réglage que la garde-robe du launcher.",
|
||||
|
||||
@@ -356,5 +356,9 @@
|
||||
"horizon.pages.open.split_setup": "Podzielony ekran…",
|
||||
"horizon.pages.split_settings": "Ustawienia podzielonego ekranu",
|
||||
"horizon.pages.split_setup": "Podzielony ekran",
|
||||
"horizon.pages.split_setup.tooltip": "Gracze na tym komputerze: kontrolery mogą dołączyć do twojego świata."
|
||||
"horizon.pages.split_setup.tooltip": "Gracze na tym komputerze: kontrolery mogą dołączyć do twojego świata.",
|
||||
"Particle Culling": "Pomijanie cząsteczek",
|
||||
"Draws every particle.": "Rysuje wszystkie cząsteczki.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "Pomija cząsteczki poza ekranem, mniejsze niż piksel i zasłonięte przez teren.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Pomija cząsteczki poza ekranem, mniejsze niż piksel i zasłonięte przez teren, odciążając wątek renderowania."
|
||||
}
|
||||
@@ -198,6 +198,10 @@
|
||||
"horizon.shader_options.open": "Настройки шейдеров…",
|
||||
"horizon.shader_options.profile": "Профиль",
|
||||
"horizon.shader_options.loading": "Чтение настроек набора шейдеров…",
|
||||
"Particle Culling": "Particle Culling",
|
||||
"Draws every particle.": "Draws every particle.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "Skips offscreen, subpixel and terrain-hidden particles.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.",
|
||||
"General": "Общие",
|
||||
"Advanced": "Дополнительно",
|
||||
"Undo": "Отменить",
|
||||
|
||||
@@ -194,6 +194,10 @@
|
||||
"horizon.shader_options.open": "Gölgelendirici Seçenekleri…",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"horizon.shader_options.loading": "Gölgelendirici paketinin seçenekleri okunuyor…",
|
||||
"Particle Culling": "Particle Culling",
|
||||
"Draws every particle.": "Draws every particle.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "Skips offscreen, subpixel and terrain-hidden particles.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.",
|
||||
"General": "Genel",
|
||||
"Advanced": "Gelişmiş",
|
||||
"Undo": "Geri al",
|
||||
|
||||
@@ -456,5 +456,9 @@
|
||||
"horizon.repro.bundle": "Пакет відтворення: %s",
|
||||
"horizon.repro.error": "Відтворення: %s",
|
||||
"horizon.repro.no_recording": "Поки немає запису для експорту.",
|
||||
"horizon.repro.replay_started": "Відтворення почато: %s"
|
||||
"horizon.repro.replay_started": "Відтворення почато: %s",
|
||||
"Particle Culling": "Відсікання частинок",
|
||||
"Draws every particle.": "Малює всі частинки.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "Пропускає частинки поза екраном, менші за піксель і закриті місцевістю.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Пропускає частинки поза екраном, менші за піксель і закриті місцевістю, зменшуючи роботу потоку рендерингу."
|
||||
}
|
||||
@@ -354,5 +354,9 @@
|
||||
"horizon.agent.off": "关闭:代理无法访问此游戏。",
|
||||
"horizon.agent.session_opened": "由启动游戏的程序为本次会话开启。",
|
||||
"horizon.agent.socket": "套接字:%s(复制详细信息以获取完整路径)",
|
||||
"horizon.agent.local_only": "仅限本机:此电脑上的代理可用,网络不可用。"
|
||||
"horizon.agent.local_only": "仅限本机:此电脑上的代理可用,网络不可用。",
|
||||
"Particle Culling": "粒子剔除",
|
||||
"Draws every particle.": "绘制所有粒子。",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "跳过屏幕外、小于一个像素和被地形遮挡的粒子。",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "跳过屏幕外、小于一个像素和被地形遮挡的粒子,减少渲染线程的工作量。"
|
||||
}
|
||||
@@ -1146,6 +1146,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
sleeping: false,
|
||||
sleep_timer: 0,
|
||||
passenger: false,
|
||||
riding_vehicle: None,
|
||||
vehicle: None,
|
||||
}
|
||||
});
|
||||
@@ -1174,6 +1175,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
collision: &collision,
|
||||
data: &data,
|
||||
player: player.clone(),
|
||||
controlling_player_movement: None,
|
||||
nearest_player: player.as_ref().filter(|p| !p.spectator).map(|p| p.position),
|
||||
others: $snapshot,
|
||||
dragon: None,
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod ambience;
|
||||
pub mod animate_tick;
|
||||
pub mod arrows;
|
||||
pub mod block_contact;
|
||||
pub mod powder_snow;
|
||||
pub use horizon_client_render::{
|
||||
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities,
|
||||
block_entity_anim, block_entity_render, block_sprites, bluemap, bluemap_detect, border_render,
|
||||
|
||||
@@ -79,7 +79,8 @@ impl Base<'_> {
|
||||
}
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
for (key, value) in [
|
||||
("Fire", Tag::Short(-1)),
|
||||
// Entity.remainingFireTicks starts at 0 (-1 was the old default).
|
||||
("Fire", Tag::Short(0)),
|
||||
("Air", Tag::Short(300)),
|
||||
("Invulnerable", Tag::Byte(0)),
|
||||
("PortalCooldown", Tag::Int(0)),
|
||||
@@ -1139,15 +1140,14 @@ pub fn mob_to_nbt(
|
||||
// Zombie and Piglin.setBaby (even for an adult) the speed, Rabbit.setVariant
|
||||
// the attack damage.
|
||||
use crate::mobs::finalize::Attr;
|
||||
let made = if crate::mobs::doors::is_zombie(species) || species == crate::mobs::Species::Piglin
|
||||
{
|
||||
Some(Attr::MovementSpeed)
|
||||
} else if species == crate::mobs::Species::Rabbit {
|
||||
Some(Attr::AttackDamage)
|
||||
// Every other living entity's first tick makes the movement_speed instance
|
||||
// (LivingEntity.removeFrost reads it through getAttribute).
|
||||
let made = if species == crate::mobs::Species::Rabbit {
|
||||
vec![Attr::MovementSpeed, Attr::AttackDamage]
|
||||
} else {
|
||||
None
|
||||
vec![Attr::MovementSpeed]
|
||||
};
|
||||
if let Some(attr) = made {
|
||||
for attr in made {
|
||||
let base = crate::mobs::finalize::base_value(m, attr);
|
||||
let mut list = match map.get(&"attributes".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
@@ -3194,7 +3194,15 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
("AbsorptionAmount", Tag::Float(0)),
|
||||
("FallFlying", Tag::Byte(0)),
|
||||
("current_impulse_context_reset_grace_time", Tag::Int(0)),
|
||||
("attributes", Tag::List(Vec::new())),
|
||||
// AttributeMap.pack: the movement_speed instance every living
|
||||
// entity's first tick makes (removeFrost), at the registry default.
|
||||
(
|
||||
"attributes",
|
||||
Tag::List(vec![Tag::compound([
|
||||
("id", string("minecraft:movement_speed")),
|
||||
("base", Tag::Double(0.7f64.to_bits())),
|
||||
])]),
|
||||
),
|
||||
(
|
||||
"Brain",
|
||||
Tag::compound([("memories", Tag::Compound(Compound::new()))]),
|
||||
@@ -4133,6 +4141,32 @@ mod tests {
|
||||
assert_eq!(number(tag.get("Health")), Some(5));
|
||||
}
|
||||
#[test]
|
||||
fn entities_without_fire_save_zero_and_a_mob_saves_its_speed_instance() {
|
||||
// Entity.remainingFireTicks starts at 0; LivingEntity.removeFrost makes
|
||||
// the movement_speed instance AttributeMap.pack then saves.
|
||||
let orb = ExperienceOrb {
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 0,
|
||||
position: Vec3::ZERO,
|
||||
previous: Vec3::ZERO,
|
||||
velocity: Vec3::ZERO,
|
||||
value: 1,
|
||||
age: 0,
|
||||
on_ground: false,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
};
|
||||
let tag = orb_to_nbt(&orb, [0; 4]);
|
||||
assert_eq!(tag.get("Fire"), Some(&Tag::Short(0)));
|
||||
let stand = armor_stand_to_nbt(&ArmorStand::new(1, Vec3::ZERO, 0.0), [0; 4], None);
|
||||
let Some(Tag::List(attrs)) = stand.get("attributes") else {
|
||||
panic!("no attributes");
|
||||
};
|
||||
assert_eq!(attrs.len(), 1);
|
||||
}
|
||||
#[test]
|
||||
fn orbs_keep_their_count() {
|
||||
let o = ExperienceOrb {
|
||||
join: Default::default(),
|
||||
|
||||
@@ -109,6 +109,16 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
max_stack,
|
||||
);
|
||||
},
|
||||
player_chat: |player, name, message, replies, cancel| {
|
||||
crate::mod_api::dispatch_player_chat(
|
||||
crate::mod_api::mods(),
|
||||
player,
|
||||
name,
|
||||
message,
|
||||
replies,
|
||||
cancel,
|
||||
);
|
||||
},
|
||||
living_heal: |entity, amount, cancel| {
|
||||
crate::mod_api::dispatch_living_heal(crate::mod_api::mods(), entity, amount, cancel);
|
||||
},
|
||||
|
||||
@@ -252,6 +252,7 @@ mod pad;
|
||||
mod pad_menu;
|
||||
mod paintings;
|
||||
mod pandas;
|
||||
mod particle_culling;
|
||||
mod passengers;
|
||||
mod persist;
|
||||
mod perspective;
|
||||
@@ -2936,6 +2937,7 @@ struct Game<'a> {
|
||||
entity_culler: Option<horizon_client::entity_culling::Culler>,
|
||||
async_entity_culler: Option<entity_culling::AsyncCuller>,
|
||||
async_entity_culling_failed: bool,
|
||||
async_particle_culler: RefCell<particle_culling::ParticleAsyncState>,
|
||||
/// `Renderer::pack_shadow_casters` for this frame's entities: (keep what the culler hides,
|
||||
/// build the player's model in first person); (false, false) without a shader pack.
|
||||
pack_shadow_casters: (bool, bool),
|
||||
@@ -4226,6 +4228,15 @@ impl Game<'_> {
|
||||
&nearby,
|
||||
Some(self.border.geometry()),
|
||||
);
|
||||
if merging && !no_physics {
|
||||
self.entity_powder_snow_particles(
|
||||
item.previous,
|
||||
item.position,
|
||||
item.bounds(),
|
||||
item.velocity,
|
||||
false,
|
||||
);
|
||||
}
|
||||
cells.moved(i, item.previous, item.position);
|
||||
// Entity.handleOnAboveBubbleColumn: the server sends the particles of
|
||||
// the surface of a bubble column the item is at the top of.
|
||||
@@ -5887,6 +5898,13 @@ impl Game<'_> {
|
||||
for pos in tops {
|
||||
self.bubble_column_particles(pos);
|
||||
}
|
||||
self.entity_powder_snow_particles(
|
||||
from,
|
||||
f.position,
|
||||
horizon_client::falling_block::bounds(f.position),
|
||||
f.velocity,
|
||||
false,
|
||||
);
|
||||
// FallingBlockEntity.tick handles its portal after the move's block
|
||||
// effects, before placing/dropping on the floor under an End portal.
|
||||
let teleported = self.falling_through_portal(f);
|
||||
@@ -10790,6 +10808,7 @@ impl Game<'_> {
|
||||
&mut self,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
fov_degrees: f32,
|
||||
mut out: Vec<Vertex>,
|
||||
) -> (
|
||||
Vec<Vertex>,
|
||||
@@ -11325,14 +11344,43 @@ impl Game<'_> {
|
||||
// Particles (ParticleEngine.extract): camera-facing quads.
|
||||
self.mark_stage(out.len(), "PARTICLES");
|
||||
let world = &self.world;
|
||||
self.particles.render(
|
||||
camera,
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
alpha as f32,
|
||||
&|b| world.light(b[0], b[1], b[2]),
|
||||
&mut out,
|
||||
);
|
||||
let particle_mode = self.settings.horizon.particle_culling;
|
||||
if particle_mode == horizon_client::graphics::ParticleCullingMode::Off {
|
||||
self.particles.render(
|
||||
camera,
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
alpha as f32,
|
||||
&|b| world.light(b[0], b[1], b[2]),
|
||||
&mut out,
|
||||
);
|
||||
} else {
|
||||
let frustum = self.view_frustum;
|
||||
let pixel_focal_length = self.renderer.as_ref().map_or(0.0, |renderer| {
|
||||
let tangent = (f64::from(fov_degrees).to_radians() * 0.5).tan();
|
||||
if tangent.is_finite() && tangent > 0.0 {
|
||||
(f64::from(renderer.config.height.max(1)) / (2.0 * tangent)) as f32
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
});
|
||||
{
|
||||
let mut particle_culling = self.particle_occlusion_context(camera);
|
||||
self.particles.render_culled(
|
||||
camera,
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
alpha as f32,
|
||||
pixel_focal_length,
|
||||
true,
|
||||
frustum.as_ref(),
|
||||
&mut |min, max| particle_culling.hidden(min, max),
|
||||
&|b| world.light(b[0], b[1], b[2]),
|
||||
&mut out,
|
||||
);
|
||||
}
|
||||
self.finish_async_particle_culling();
|
||||
}
|
||||
laps.lap("eg_particles");
|
||||
// Breaking progress overlays (destroy_stage_0..9): ours and the other players'.
|
||||
self.mark_stage(out.len(), "DESTROY");
|
||||
@@ -11589,7 +11637,7 @@ impl Game<'_> {
|
||||
state: None,
|
||||
at,
|
||||
spread,
|
||||
speed: 0.0,
|
||||
speed: [0.0; 3],
|
||||
count: 0,
|
||||
randomization: 0,
|
||||
});
|
||||
@@ -11817,7 +11865,7 @@ impl Game<'_> {
|
||||
state: None,
|
||||
at: [feet[0], feet[1] + height * 0.5, feet[2]],
|
||||
spread: [0.1, 0.0, 0.1],
|
||||
speed: 0.2,
|
||||
speed: [0.2; 3],
|
||||
count: (actual * 0.5) as u32 as i32,
|
||||
randomization: 0,
|
||||
});
|
||||
@@ -15804,7 +15852,7 @@ impl Game<'_> {
|
||||
if !title {
|
||||
let (built, static_sections, visible_static_sections, static_stats) =
|
||||
spans::time("entity_geometry", || {
|
||||
self.entity_geometry(camera, alpha, entities)
|
||||
self.entity_geometry(camera, alpha, fov_degrees, entities)
|
||||
});
|
||||
entities = built;
|
||||
static_block_entities = static_sections;
|
||||
@@ -18404,7 +18452,6 @@ impl Game<'_> {
|
||||
self.frame_stats.record_gpu(&r.gpu_times);
|
||||
r.gpu_times.clear();
|
||||
}
|
||||
horizon_client::ram_limit::frame();
|
||||
self.limit_frame();
|
||||
self.pause = self.pauses_game();
|
||||
if let Some(server) = &self.integrated {
|
||||
@@ -19055,6 +19102,7 @@ impl<'a> Game<'a> {
|
||||
entity_culler: None,
|
||||
async_entity_culler: None,
|
||||
async_entity_culling_failed: false,
|
||||
async_particle_culler: RefCell::new(Default::default()),
|
||||
pack_shadow_casters: (false, false),
|
||||
pending_uploads: Vec::new(),
|
||||
frame_graph: Default::default(),
|
||||
@@ -19779,7 +19827,24 @@ fn load_core(
|
||||
progress: &Progress,
|
||||
launch_world: Option<&str>,
|
||||
) -> std::result::Result<std::result::Result<CoreTables, String>, String> {
|
||||
let assets = match startup::time("core.assets", || Assets::load(root)) {
|
||||
// The model library reads every file under `assets/` and needs no
|
||||
// block registry, so it is parsed beside `Assets::load` rather than
|
||||
// after it. It used to sit inside `reload` behind both the registry
|
||||
// read and the block facts; on a cold cache that read was most of a
|
||||
// second on the critical path, and warm it is still ~30 ms.
|
||||
// A server draws nothing and parses no models, so it skips this.
|
||||
let (assets, parsed_models) = std::thread::scope(|scope| {
|
||||
let models = (parallel && !headless).then(|| {
|
||||
scope.spawn(|| {
|
||||
let _step = startup::step("core.models_parse");
|
||||
horizon_client::model_library::ParsedModels::load(root)
|
||||
})
|
||||
});
|
||||
let assets = startup::time("core.assets", || Assets::load(root));
|
||||
let models = models.map(|m| m.join().unwrap_or_else(|e| std::panic::resume_unwind(e)));
|
||||
(assets, models)
|
||||
});
|
||||
let assets = match assets {
|
||||
Ok(assets) => assets,
|
||||
Err(e) => return Ok(Err(e.to_string())),
|
||||
};
|
||||
@@ -19791,9 +19856,12 @@ fn load_core(
|
||||
let render = Step::spawn(scope, parallel, move || {
|
||||
// A server reads no block sprites' pixels, which only the
|
||||
// renderer draws (32 MB).
|
||||
let parsed_models = parsed_models;
|
||||
let render = startup::time("core.block_models_sprites", || {
|
||||
if headless {
|
||||
assets.reload_for_server(root)
|
||||
} else if let Some(parsed) = parsed_models {
|
||||
assets.reload_with_models(root, parsed)
|
||||
} else {
|
||||
assets.reload(root)
|
||||
}
|
||||
|
||||
@@ -864,6 +864,16 @@ impl Game<'_> {
|
||||
self.armor_stand_location_effects(i, true);
|
||||
}
|
||||
self.armor_stand_turtle_eggs(i, landed)?;
|
||||
let a = &self.armor_stands[i];
|
||||
if !a.marker && !a.no_gravity {
|
||||
self.entity_powder_snow_particles(
|
||||
a.previous,
|
||||
a.position,
|
||||
a.bounds(),
|
||||
a.velocity,
|
||||
true,
|
||||
);
|
||||
}
|
||||
let a = &mut self.armor_stands[i];
|
||||
if !a.environment_tick(&self.world, self.res.facts(), self.pack.assets.registry) {
|
||||
dead.push(i);
|
||||
|
||||
@@ -331,6 +331,7 @@ impl Game<'_> {
|
||||
sleeping: self.sleeping.is_some(),
|
||||
sleep_timer: self.sleep_timer,
|
||||
passenger: self.riding.is_some(),
|
||||
riding_vehicle: self.riding,
|
||||
vehicle: self.riding.and_then(|id| {
|
||||
if let Some(b) = self.boats.iter().find(|b| b.id == id) {
|
||||
// A boat's box: 1.375 wide and 0.5625 high around its position.
|
||||
@@ -812,6 +813,7 @@ impl Game<'_> {
|
||||
movement: &self.motion.blocks,
|
||||
collision: self.collision,
|
||||
data: self.res.mob_data(),
|
||||
controlling_player_movement: None,
|
||||
nearest_player: player.as_ref().filter(|p| !p.spectator).map(|p| p.position),
|
||||
player,
|
||||
others,
|
||||
@@ -1032,6 +1034,20 @@ impl Game<'_> {
|
||||
..base.clone()
|
||||
};
|
||||
let m = &mut mobs[i];
|
||||
w.controlling_player_movement = horizon_client::mobs::equine::controlled(m)
|
||||
.then(|| {
|
||||
base.player
|
||||
.as_ref()
|
||||
.filter(|p| p.riding_vehicle == Some(m.id))
|
||||
.map(|p| p.velocity)
|
||||
.or_else(|| {
|
||||
guests
|
||||
.iter()
|
||||
.find(|(_, p)| p.riding_vehicle == Some(m.id))
|
||||
.map(|(_, p)| p.velocity)
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
// getCurrentDifficultyAt(blockPosition()): the mob's own chunk,
|
||||
// not the local player's (a server thread has no player).
|
||||
w.difficulty = self.local_difficulty([m.position.x, m.position.y, m.position.z]);
|
||||
@@ -1907,6 +1923,9 @@ impl Game<'_> {
|
||||
.map_or([0.0; 3], |m| [m.velocity.x, m.velocity.y, m.velocity.z]);
|
||||
self.drown_particles(at, v);
|
||||
}
|
||||
MobEvent::PowderSnowParticle { at, velocity } => {
|
||||
self.snowflake_particle(at, velocity);
|
||||
}
|
||||
MobEvent::SunProtectionWear { mob } => self.wear_sun_protection(mob),
|
||||
MobEvent::ArmorHurt { mob, amount, kind } => self.hurt_mob_armor(mob, amount, kind),
|
||||
MobEvent::EntityEvent { mob, event, at } => {
|
||||
@@ -3800,6 +3819,7 @@ impl Game<'_> {
|
||||
let mut shoot_events = Vec::new();
|
||||
let mut trails = Vec::new();
|
||||
let mut bubble_tops = Vec::new();
|
||||
let mut snow_contacts = Vec::new();
|
||||
let mut keep_flags = vec![true; projectiles.len()];
|
||||
// The impacts the mods may cancel (`native_projectile_impact`), on a server.
|
||||
let hear = self.native_hears_impacts();
|
||||
@@ -3914,6 +3934,19 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
};
|
||||
if (p.is_thrown() && !matches!(p.kind, ProjectileKind::LlamaSpit))
|
||||
|| p.is_arrow() && p.arrow.as_ref().is_none_or(|a| !a.no_physics)
|
||||
{
|
||||
for block in horizon_client::powder_snow::contacts(
|
||||
p.previous,
|
||||
p.position,
|
||||
p.bounds(),
|
||||
false,
|
||||
|at| w.block_name(at.x, at.y, at.z) == "minecraft:powder_snow",
|
||||
) {
|
||||
snow_contacts.push((block, p.position, p.velocity));
|
||||
}
|
||||
}
|
||||
if shooter.is_some() {
|
||||
p.owner = Some(Who::Player);
|
||||
}
|
||||
@@ -4004,6 +4037,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (block, position, movement) in snow_contacts {
|
||||
self.powder_snow_particle(block, position, movement);
|
||||
}
|
||||
let listed = keep_flags.clone();
|
||||
let mut idx = 0;
|
||||
projectiles.retain(|_| {
|
||||
@@ -6073,7 +6109,7 @@ impl Game<'_> {
|
||||
state: None,
|
||||
at,
|
||||
spread,
|
||||
speed: 0.2,
|
||||
speed: [0.2; 3],
|
||||
count: count as i32,
|
||||
randomization: 0,
|
||||
});
|
||||
@@ -7722,6 +7758,7 @@ mod pearl_tests {
|
||||
collision: &collision,
|
||||
data: &data,
|
||||
player: None,
|
||||
controlling_player_movement: None,
|
||||
nearest_player: None,
|
||||
others: &[],
|
||||
dragon: None,
|
||||
|
||||
@@ -550,7 +550,9 @@ impl Game<'_> {
|
||||
}
|
||||
/// `LocalPlayer.isReducedDebugInfo`: the server's rule or the option.
|
||||
pub(super) fn reduced_debug_info(&self) -> bool {
|
||||
self.settings.reduced_debug_info || self.ui.rules.bool("reduced_debug_info")
|
||||
self.settings.reduced_debug_info
|
||||
|| self.ui.rules.bool("reduced_debug_info")
|
||||
|| self.remote.as_ref().is_some_and(|r| r.reduced_debug_info)
|
||||
}
|
||||
/// `DebugScreenEntryList.isCurrentlyEnabled` for an entry's path.
|
||||
pub(super) fn debug_entry(&self, path: &str) -> bool {
|
||||
|
||||
@@ -441,6 +441,37 @@ pub(super) struct Lists {
|
||||
pub stop: bool,
|
||||
/// `MinecraftServer.isAutoSave`: all of Horizon's server levels share this save flag.
|
||||
pub autosave: bool,
|
||||
/// UUID prefixes (lower case, with dashes) that pass the whitelist: `whitelist-bypass.txt`.
|
||||
pub whitelist_bypass: Vec<String>,
|
||||
}
|
||||
|
||||
/// The lines of `whitelist-bypass.txt`: one UUID prefix each, e.g. `00000000-0000-0000-0009-` for Floodgate's
|
||||
/// Bedrock players. `#` starts a comment. A prefix has to be a dashed UUID start of at least 8 characters, so
|
||||
/// a typo can't open the whitelist to everybody.
|
||||
fn parse_whitelist_bypass(text: &str) -> std::result::Result<Vec<String>, String> {
|
||||
let mut out = Vec::new();
|
||||
for (n, line) in text.lines().enumerate() {
|
||||
let line = line
|
||||
.split('#')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if line.len() < 8
|
||||
|| line.len() > 36
|
||||
|| !line.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
|
||||
{
|
||||
return Err(format!(
|
||||
"whitelist-bypass.txt line {}: {line:?} is not a UUID prefix",
|
||||
n + 1
|
||||
));
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
impl Lists {
|
||||
fn load() -> Self {
|
||||
@@ -455,7 +486,24 @@ impl Lists {
|
||||
player_idle_timeout: 0,
|
||||
stop: false,
|
||||
autosave: true,
|
||||
whitelist_bypass: Vec::new(),
|
||||
};
|
||||
// Horizon only: players from a proxy's Bedrock bridge (Floodgate) are never on whitelist.json.
|
||||
if let Ok(text) = std::fs::read_to_string("whitelist-bypass.txt") {
|
||||
match parse_whitelist_bypass(&text) {
|
||||
Ok(prefixes) => {
|
||||
log(
|
||||
"INFO",
|
||||
&format!(
|
||||
"whitelist-bypass.txt: {} UUID prefix(es) skip the whitelist",
|
||||
prefixes.len()
|
||||
),
|
||||
);
|
||||
l.whitelist_bypass = prefixes;
|
||||
}
|
||||
Err(e) => log("ERROR", &format!("{e}; the file is ignored")),
|
||||
}
|
||||
}
|
||||
// Each list tells the event stream what changes in it, as vanilla's tell their `NotificationService`.
|
||||
l.ops.watch(|change| {
|
||||
let (kind, e) = listed("operators", change);
|
||||
@@ -501,7 +549,16 @@ impl Lists {
|
||||
}
|
||||
/// PlayerList.isWhiteListed.
|
||||
fn whitelisted(&self, user: &NameAndId) -> bool {
|
||||
!self.use_whitelist || self.ops.contains(user) || self.white.contains(user)
|
||||
!self.use_whitelist
|
||||
|| self.ops.contains(user)
|
||||
|| self.white.contains(user)
|
||||
|| self.bypasses_whitelist(user.id)
|
||||
}
|
||||
fn bypasses_whitelist(&self, id: u128) -> bool {
|
||||
let id = horizon_protocol::crypto::uuid_string(id);
|
||||
self.whitelist_bypass
|
||||
.iter()
|
||||
.any(|p| id.starts_with(p.as_str()))
|
||||
}
|
||||
/// PlayerList.canPlayerLogin before the player count.
|
||||
fn can_login(&mut self, address: std::net::SocketAddr, user: &NameAndId) -> Option<Tag> {
|
||||
@@ -2165,7 +2222,9 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{capture_log, log, log_from, overload_warning, panic_message};
|
||||
use super::{
|
||||
capture_log, log, log_from, overload_warning, panic_message, parse_whitelist_bypass,
|
||||
};
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, BufRead, BufReader, Read, Write},
|
||||
@@ -2173,6 +2232,18 @@ mod tests {
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn whitelist_bypass_file_takes_uuid_prefixes_and_refuses_junk() {
|
||||
let ok =
|
||||
parse_whitelist_bypass("# Bedrock\n00000000-0000-0000-0009- # floodgate\n\n").unwrap();
|
||||
assert_eq!(ok, vec!["00000000-0000-0000-0009-".to_owned()]);
|
||||
assert!(
|
||||
parse_whitelist_bypass("0-").is_err(),
|
||||
"a short prefix would match nearly everyone"
|
||||
);
|
||||
assert!(parse_whitelist_bypass("not-a-uuid-at-all").is_err());
|
||||
}
|
||||
|
||||
/// `SaveAllCommand.saveAll`: a save that throws is the `ERROR_FAILED` the
|
||||
/// source is told about, not a log line the caller never sees.
|
||||
#[test]
|
||||
@@ -2325,6 +2396,7 @@ mod tests {
|
||||
player_idle_timeout: 0,
|
||||
stop: false,
|
||||
autosave: true,
|
||||
whitelist_bypass: Vec::new(),
|
||||
};
|
||||
// Every translation key and string in a message, in the order they come.
|
||||
fn words(tag: &super::Tag, out: &mut Vec<String>) {
|
||||
|
||||
@@ -187,6 +187,28 @@ impl Game<'_> {
|
||||
self.set_default_game_mode(state.game_mode);
|
||||
return json!({ "value": GAME_MODES[state.game_mode as usize] });
|
||||
}
|
||||
"serversettings/operator_user_permission_level" => {
|
||||
return json!({ "value": state.op_level });
|
||||
}
|
||||
"serversettings/operator_user_permission_level/set" => {
|
||||
let Some(level) = body["value"]
|
||||
.as_i64()
|
||||
.and_then(|value| i32::try_from(value).ok())
|
||||
.filter(|level| (0..=4).contains(level))
|
||||
else {
|
||||
return json!({
|
||||
"error": "operator user permission level must be an integer between 0 and 4"
|
||||
});
|
||||
};
|
||||
rpc(format!(
|
||||
"Update operator user permission level from {} to {}",
|
||||
state.op_level, level
|
||||
));
|
||||
state.op_level = level;
|
||||
state.lists.lock().unwrap().op_level = level;
|
||||
self.store_property("op-permission-level", level.to_string());
|
||||
return json!({ "value": state.op_level });
|
||||
}
|
||||
"serversettings/force_game_mode" => {
|
||||
return json!({ "value": state.force_game_mode });
|
||||
}
|
||||
|
||||
@@ -409,6 +409,13 @@ impl Game<'_> {
|
||||
};
|
||||
r.free_flight(&mut scene);
|
||||
}
|
||||
self.entity_powder_snow_particles(
|
||||
r.previous,
|
||||
r.position,
|
||||
r.bounds(),
|
||||
r.velocity,
|
||||
false,
|
||||
);
|
||||
// hitTargetOrDeflectSelf on an entity: a breeze turns it back
|
||||
// (ProjectileDeflection.REVERSE), anything else sets it off.
|
||||
// onHitBlock: only a rocket with explosions (hasExplosion) goes
|
||||
|
||||
@@ -602,7 +602,7 @@ impl Game<'_> {
|
||||
state: None,
|
||||
at,
|
||||
spread,
|
||||
speed,
|
||||
speed: [speed; 3],
|
||||
count: count as i32,
|
||||
randomization: 0,
|
||||
});
|
||||
@@ -839,6 +839,7 @@ impl Game<'_> {
|
||||
h.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
self.entity_powder_snow_particles(h.previous, h.position, h.bounds(), h.velocity, false);
|
||||
h.velocity = Vec3::new(
|
||||
h.velocity.x * 0.92,
|
||||
h.velocity.y * 0.92,
|
||||
|
||||
@@ -2377,6 +2377,8 @@ impl Game<'_> {
|
||||
}
|
||||
host.hub
|
||||
.set_immediate_respawn(self.ui.rules.bool("immediate_respawn"));
|
||||
host.hub
|
||||
.set_reduced_debug_info(self.ui.rules.bool("reduced_debug_info"));
|
||||
host.hub
|
||||
.set_limited_crafting(self.ui.rules.bool("limited_crafting"));
|
||||
// A server with several levels runs a pass for each
|
||||
|
||||
@@ -1364,7 +1364,11 @@ impl Game<'_> {
|
||||
// around, the opener included (`playSound(null, …)`, which the capture
|
||||
// relay sends on to the guests). Without it the host's copy never
|
||||
// learned of the open, so a guest's box opened silently.
|
||||
if counts_openers && self.container_openers_changed(pos, true) {
|
||||
// A spectator never joins the openers (`startOpen` returns on
|
||||
// `player.isSpectator()`): its menu opens but the lid stays shut, no
|
||||
// sound plays, and `stopOpen` has nothing to decrement, which is why
|
||||
// the count must not be raised here either (#8517).
|
||||
if counts_openers && !spectator && self.container_openers_changed(pos, true) {
|
||||
self.container_edge_sound(pos, true);
|
||||
}
|
||||
true
|
||||
|
||||
@@ -494,6 +494,7 @@ mod tests {
|
||||
sleeping: false,
|
||||
sleep_timer: 0,
|
||||
passenger: false,
|
||||
riding_vehicle: None,
|
||||
vehicle: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::lan::hub::Hub;
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_protocol::{
|
||||
entity_data::{MOD_PARTICLE_ID, Particle, ParticleData, PositionSource},
|
||||
packets::play::{self, Clientbound as C},
|
||||
@@ -18,12 +22,22 @@ pub(super) struct QueuedParticle {
|
||||
pub state: Option<BlockStateId>,
|
||||
pub at: [f64; 3],
|
||||
pub spread: [f64; 3],
|
||||
pub speed: f64,
|
||||
/// Per-axis maximum speed used by the packet's ALTERNATIVE randomization.
|
||||
pub speed: [f64; 3],
|
||||
pub count: i32,
|
||||
/// The packet's `RandomizationType` (0 DEFAULT, 1 ALTERNATIVE).
|
||||
pub randomization: i32,
|
||||
}
|
||||
|
||||
/// `PowderSnowBlock.entityInside`'s server roll. The branch and both float
|
||||
/// draws follow Java's `getKnownMovement`/`randomBetween` order.
|
||||
pub(super) fn powder_snow_velocity(
|
||||
known: Vec3,
|
||||
random: &mut impl RandomSource,
|
||||
) -> Option<[f64; 3]> {
|
||||
horizon_client::powder_snow::particle_velocity(known, random)
|
||||
}
|
||||
|
||||
fn send_native_nearby(
|
||||
host: &LanHost,
|
||||
dimension: &str,
|
||||
@@ -300,6 +314,62 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared block callback for any entity family that applies block effects.
|
||||
pub(super) fn entity_powder_snow_particles(
|
||||
&mut self,
|
||||
previous: Vec3,
|
||||
position: Vec3,
|
||||
bounds: Aabb,
|
||||
known_movement: Vec3,
|
||||
living: bool,
|
||||
) {
|
||||
// `particle_velocity` refuses no horizontal movement before any draw,
|
||||
// so a resting entity skips the sweep (most items, orbs and carts).
|
||||
if self.remote.is_some() || (known_movement.x == 0.0 && known_movement.z == 0.0) {
|
||||
return;
|
||||
}
|
||||
let snow = horizon_client::powder_snow::contacts(previous, position, bounds, living, |p| {
|
||||
&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name == "minecraft:powder_snow"
|
||||
});
|
||||
for block in snow {
|
||||
self.powder_snow_particle(block, position, known_movement);
|
||||
}
|
||||
}
|
||||
|
||||
/// `PowderSnowBlock.entityInside` for an entity with known movement.
|
||||
pub(super) fn powder_snow_particle(
|
||||
&mut self,
|
||||
block: BlockPos,
|
||||
position: Vec3,
|
||||
known_movement: Vec3,
|
||||
) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(velocity) = powder_snow_velocity(known_movement, &mut self.world.random) else {
|
||||
return;
|
||||
};
|
||||
self.snowflake_particle([position.x, f64::from(block.y + 1), position.z], velocity);
|
||||
}
|
||||
|
||||
/// Locally draw the level's own particle, then send it to LAN players.
|
||||
pub(super) fn snowflake_particle(&mut self, at: [f64; 3], velocity: [f64; 3]) {
|
||||
let options = ParticleOptions::simple(ParticleType::Snowflake);
|
||||
if self.options.execution_role.is_client() {
|
||||
self.particles.add(&options, at, velocity);
|
||||
}
|
||||
self.lan_send_particles(QueuedParticle {
|
||||
dimension: self.dim.id(),
|
||||
options,
|
||||
state: None,
|
||||
at,
|
||||
spread: [0.0; 3],
|
||||
speed: velocity,
|
||||
count: 1,
|
||||
randomization: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/// `ParticleTypes.STREAM_CODEC`'s value for `options`: the type's
|
||||
/// network id and the data it carries (`state` for the block particles).
|
||||
/// None while not serving, or for a particle whose data isn't sent from
|
||||
@@ -545,7 +615,7 @@ impl Game<'_> {
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: spread.map(|d| d as f32),
|
||||
max_speed: [speed as f32; 3],
|
||||
max_speed: speed.map(|v| v as f32),
|
||||
count,
|
||||
randomization,
|
||||
})
|
||||
@@ -613,7 +683,7 @@ impl Game<'_> {
|
||||
state: None,
|
||||
at,
|
||||
spread: [1.0; 3],
|
||||
speed: 0.0,
|
||||
speed: [0.0; 3],
|
||||
count: 8,
|
||||
randomization: 1,
|
||||
});
|
||||
@@ -802,6 +872,36 @@ mod tests {
|
||||
assert!(below, "DEFAULT is a Gaussian around the position");
|
||||
}
|
||||
|
||||
/// `PowderSnowBlock.entityInside`: vertical-only movement skips the roll;
|
||||
/// horizontal movement uses nextBoolean, then X and Z floats in order.
|
||||
#[test]
|
||||
fn powder_snow_snowflakes_use_the_vanilla_movement_gate_and_random_order() {
|
||||
let mut stationary = LegacyRandom::new(1);
|
||||
let mut untouched = LegacyRandom::new(1);
|
||||
assert_eq!(
|
||||
powder_snow_velocity(Vec3::new(0.0, 1.0, 0.0), &mut stationary),
|
||||
None
|
||||
);
|
||||
assert_eq!(stationary.next_int(), untouched.next_int());
|
||||
|
||||
let seed = (0..1000)
|
||||
.find(|seed| {
|
||||
let mut random = LegacyRandom::new(*seed);
|
||||
random.next_boolean()
|
||||
})
|
||||
.unwrap();
|
||||
let mut vanilla = LegacyRandom::new(seed);
|
||||
assert!(vanilla.next_boolean());
|
||||
let x = (vanilla.next_float() * 2.0 - 1.0) * 0.083_333_336_f32;
|
||||
let z = (vanilla.next_float() * 2.0 - 1.0) * 0.083_333_336_f32;
|
||||
let expected = [f64::from(x), f64::from(0.05_f32), f64::from(z)];
|
||||
let mut random = LegacyRandom::new(seed);
|
||||
assert_eq!(
|
||||
powder_snow_velocity(Vec3::new(-0.2, 0.0, 0.0), &mut random),
|
||||
Some(expected)
|
||||
);
|
||||
}
|
||||
|
||||
/// `ShulkerBullet.hurtServer`: fifteen crit particles (spread 0.2, speed 0.0)
|
||||
/// are sent to tracking players within 32 blocks.
|
||||
#[test]
|
||||
|
||||
@@ -1203,8 +1203,8 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `applyEffectsFromBlocks` for a cart: a bubble column lifts or drags it,
|
||||
/// and `onInsideBubbleColumn` resets the fall distance.
|
||||
/// `applyEffectsFromBlocks` for a cart: a bubble column lifts or drags it
|
||||
/// and resets the fall distance.
|
||||
fn cart_block_effects(&self, c: &mut Minecart) {
|
||||
if c.push_by_bubble_column(
|
||||
&self.world,
|
||||
@@ -1290,6 +1290,10 @@ impl Game<'_> {
|
||||
if c.in_water {
|
||||
c.fall_distance = 0.0;
|
||||
}
|
||||
// Entity.checkInsideBlocks runs once after the cart tick, sweeping every
|
||||
// movement through powder snow. PowderSnowBlock uses getKnownMovement,
|
||||
// which is getDeltaMovement for a minecart.
|
||||
self.entity_powder_snow_particles(c.previous, c.position, c.bounds(), c.velocity, false);
|
||||
if self.minecart_improvements() {
|
||||
// MinecartTNT.tick reads the final delta after AbstractMinecart.tick,
|
||||
// including the pushes and fluid interaction after the track steps.
|
||||
|
||||
@@ -7984,7 +7984,7 @@ impl GameLevel<'_> {
|
||||
state,
|
||||
at,
|
||||
spread,
|
||||
speed,
|
||||
speed: [speed; 3],
|
||||
count,
|
||||
randomization: 0,
|
||||
});
|
||||
@@ -10596,6 +10596,8 @@ mod tests {
|
||||
let hunger_hooks = free_hooks(include_str!("../hunger.rs"));
|
||||
let landing_hooks = free_hooks(include_str!("../landing.rs"));
|
||||
let motion_hooks = free_hooks(include_str!("../motion.rs"));
|
||||
// The chat relay's (`horizon_client_lib::lan::hub::chat`).
|
||||
let chat_hooks = free_hooks(include_str!("../lan/hub/chat.rs"));
|
||||
let declared: BTreeSet<_> = section
|
||||
.lines()
|
||||
.chain(level_section.lines())
|
||||
@@ -10614,6 +10616,7 @@ mod tests {
|
||||
.chain(hunger_hooks.iter().copied())
|
||||
.chain(landing_hooks.iter().copied())
|
||||
.chain(motion_hooks.iter().copied())
|
||||
.chain(chat_hooks.iter().copied())
|
||||
.collect();
|
||||
let mut names = BTreeSet::new();
|
||||
let mut contracts = BTreeSet::new();
|
||||
@@ -10641,6 +10644,8 @@ mod tests {
|
||||
format!("horizon_client_lib::landing::native_{name}")
|
||||
} else if motion_hooks.contains(name) {
|
||||
format!("horizon_client_lib::motion::native_{name}")
|
||||
} else if chat_hooks.contains(name) {
|
||||
format!("horizon_client_lib::lan::hub::chat::native_{name}")
|
||||
} else if menu_hooks.contains(name) {
|
||||
// menu.rs compiles into horizon-client-lib (#5446's
|
||||
// split): horizon-mixins resolves the crate by its package.
|
||||
|
||||
@@ -191,6 +191,10 @@ impl Game<'_> {
|
||||
Some(self.border.geometry()),
|
||||
&self.res.motion().fluids,
|
||||
);
|
||||
// PowderSnowBlock.entityInside during the move, before an
|
||||
// orb that is done is removed.
|
||||
let snow = (o.previous, o.position, o.bounds(), o.velocity);
|
||||
self.entity_powder_snow_particles(snow.0, snow.1, snow.2, snow.3, false);
|
||||
if !stays {
|
||||
self.orbs.remove(i);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,784 @@
|
||||
//! Particle visibility checks and their worker, separate from entity culling.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
blocks::{BlockFacts, RenderShape, StateFacts},
|
||||
chunk::Chunk,
|
||||
entity_culling::Culler,
|
||||
graphics::ParticleCullingMode,
|
||||
world::World,
|
||||
};
|
||||
use std::{
|
||||
array,
|
||||
cell::RefCell,
|
||||
sync::{
|
||||
Arc, Weak,
|
||||
mpsc::{self, Receiver, SyncSender, TrySendError},
|
||||
},
|
||||
thread::JoinHandle,
|
||||
};
|
||||
|
||||
type ChunkKey = (i32, i32);
|
||||
type SectionKey = (i32, i32, i32);
|
||||
const MAX_SNAPSHOT_CHUNKS: i64 = 4_096;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct CullKey {
|
||||
camera: [u64; 3],
|
||||
min: [u64; 3],
|
||||
max: [u64; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct SnapshotKey {
|
||||
world_revision: u64,
|
||||
chunks_revision: u64,
|
||||
bottom_y: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct ChunkBounds {
|
||||
min_x: i32,
|
||||
max_x: i32,
|
||||
min_z: i32,
|
||||
max_z: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct OcclusionSnapshot {
|
||||
chunks: Vec<(ChunkKey, Weak<Chunk>)>,
|
||||
opaque_states: Arc<[bool]>,
|
||||
}
|
||||
|
||||
impl OcclusionSnapshot {
|
||||
fn chunk(&self, key: ChunkKey) -> Option<&Weak<Chunk>> {
|
||||
self.chunks
|
||||
.binary_search_by_key(&key, |(key, _)| *key)
|
||||
.ok()
|
||||
.map(|index| &self.chunks[index].1)
|
||||
}
|
||||
|
||||
fn refresh_chunks(&mut self, world: &World, bounds: ChunkBounds) {
|
||||
self.chunks.clear();
|
||||
for x in bounds.min_x..=bounds.max_x {
|
||||
for z in bounds.min_z..=bounds.max_z {
|
||||
if let Some(chunk) = world.chunks.get(&(x, z)) {
|
||||
self.chunks.push(((x, z), Arc::downgrade(chunk)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn chunks_match(&self, world: &World) -> bool {
|
||||
self.chunks.iter().all(|(key, expected)| {
|
||||
world
|
||||
.chunks
|
||||
.get(key)
|
||||
.is_some_and(|current| std::ptr::eq(expected.as_ptr(), Arc::as_ptr(current)))
|
||||
})
|
||||
}
|
||||
|
||||
fn opaque(&self, pos: [i32; 3], last_chunk: &mut LastChunk) -> bool {
|
||||
let key = (pos[0] >> 4, pos[2] >> 4);
|
||||
let Some(chunk) = last_chunk.get(self, key) else {
|
||||
return false;
|
||||
};
|
||||
let state = chunk.block(pos[0] & 15, pos[1], pos[2] & 15);
|
||||
self.opaque_states
|
||||
.get(state.0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct LastChunk {
|
||||
key: Option<ChunkKey>,
|
||||
value: Option<Arc<Chunk>>,
|
||||
}
|
||||
|
||||
impl LastChunk {
|
||||
fn get(&mut self, snapshot: &OcclusionSnapshot, key: ChunkKey) -> Option<&Arc<Chunk>> {
|
||||
if self.key != Some(key) {
|
||||
self.key = Some(key);
|
||||
self.value = snapshot.chunk(key).and_then(Weak::upgrade);
|
||||
}
|
||||
self.value.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
struct OcclusionProof {
|
||||
sections: Vec<SectionKey>,
|
||||
chunks: Vec<(ChunkKey, Weak<Chunk>)>,
|
||||
}
|
||||
|
||||
struct CullResults {
|
||||
stamp: SnapshotKey,
|
||||
answers: Vec<(CullKey, Option<Arc<OcclusionProof>>)>,
|
||||
scratch_queries: Vec<CullKey>,
|
||||
}
|
||||
|
||||
struct CullJob {
|
||||
stamp: SnapshotKey,
|
||||
snapshot: Arc<OcclusionSnapshot>,
|
||||
queries: Vec<CullKey>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ParticleAsyncState {
|
||||
culler: Option<ParticleAsyncCuller>,
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
/// Particle occlusion has a dedicated worker so its queue cannot delay entity visibility jobs.
|
||||
struct ParticleAsyncCuller {
|
||||
jobs: SyncSender<CullJob>,
|
||||
results_rx: Receiver<CullResults>,
|
||||
_thread: JoinHandle<()>,
|
||||
snapshot: Option<(SnapshotKey, ChunkBounds, Arc<OcclusionSnapshot>)>,
|
||||
opaque_states: Option<Arc<[bool]>>,
|
||||
results: Option<CullResults>,
|
||||
queries: RefCell<Vec<CullKey>>,
|
||||
camera: [f64; 3],
|
||||
stamp: SnapshotKey,
|
||||
pending: bool,
|
||||
}
|
||||
|
||||
impl ParticleAsyncCuller {
|
||||
fn new() -> std::io::Result<Self> {
|
||||
let (job_tx, job_rx) = mpsc::sync_channel::<CullJob>(1);
|
||||
let (result_tx, result_rx) = mpsc::channel::<CullResults>();
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("particle-culling".into())
|
||||
.spawn(move || {
|
||||
while let Ok(job) = job_rx.recv() {
|
||||
if result_tx.send(cull_job(job)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(Self {
|
||||
jobs: job_tx,
|
||||
results_rx: result_rx,
|
||||
_thread: thread,
|
||||
snapshot: None,
|
||||
opaque_states: None,
|
||||
results: None,
|
||||
queries: RefCell::new(Vec::new()),
|
||||
camera: [0.0; 3],
|
||||
stamp: SnapshotKey {
|
||||
world_revision: 0,
|
||||
chunks_revision: 0,
|
||||
bottom_y: 0,
|
||||
},
|
||||
pending: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn begin_frame(&mut self, camera: glam::DVec3, world: &World) {
|
||||
while let Ok(mut results) = self.results_rx.try_recv() {
|
||||
self.pending = false;
|
||||
let scratch = std::mem::take(&mut results.scratch_queries);
|
||||
if scratch.capacity() > self.queries.get_mut().capacity() {
|
||||
*self.queries.get_mut() = scratch;
|
||||
}
|
||||
self.results = Some(results);
|
||||
}
|
||||
self.camera = camera.to_array();
|
||||
self.stamp = SnapshotKey {
|
||||
world_revision: world.revision,
|
||||
chunks_revision: world.chunks.membership_revision(),
|
||||
bottom_y: world.bottom_y(),
|
||||
};
|
||||
if self
|
||||
.results
|
||||
.as_ref()
|
||||
.is_some_and(|results| results.stamp != self.stamp)
|
||||
{
|
||||
self.results = None;
|
||||
}
|
||||
self.queries.get_mut().clear();
|
||||
}
|
||||
|
||||
fn query(&self, min: [f64; 3], max: [f64; 3]) -> Option<CullKey> {
|
||||
if !self.camera.iter().all(|value| value.is_finite())
|
||||
|| (0..3).any(|axis| {
|
||||
!min[axis].is_finite() || !max[axis].is_finite() || min[axis] > max[axis]
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(CullKey {
|
||||
camera: self.camera.map(f64::to_bits),
|
||||
min: min.map(f64::to_bits),
|
||||
max: max.map(f64::to_bits),
|
||||
})
|
||||
}
|
||||
|
||||
fn cached(&self, key: &CullKey) -> Option<Option<&Arc<OcclusionProof>>> {
|
||||
let results = self
|
||||
.results
|
||||
.as_ref()
|
||||
.filter(|results| results.stamp == self.stamp)?;
|
||||
let index = results
|
||||
.answers
|
||||
.binary_search_by_key(key, |(key, _)| *key)
|
||||
.ok()?;
|
||||
Some(results.answers[index].1.as_ref())
|
||||
}
|
||||
|
||||
fn queue(&self, key: CullKey) {
|
||||
self.queries.borrow_mut().push(key);
|
||||
}
|
||||
|
||||
fn finish_frame(&mut self, world: &World, facts: &BlockFacts) {
|
||||
let queries = self.queries.get_mut();
|
||||
if self.pending {
|
||||
queries.clear();
|
||||
return;
|
||||
}
|
||||
if queries.is_empty() {
|
||||
return;
|
||||
}
|
||||
queries.sort_unstable();
|
||||
queries.dedup();
|
||||
let Some(bounds) = snapshot_bounds(self.camera, queries) else {
|
||||
queries.clear();
|
||||
return;
|
||||
};
|
||||
let opaque_states = Arc::clone(self.opaque_states.get_or_insert_with(|| {
|
||||
facts
|
||||
.states
|
||||
.iter()
|
||||
.map(state_is_opaque)
|
||||
.collect::<Vec<_>>()
|
||||
.into()
|
||||
}));
|
||||
let snapshot = match &mut self.snapshot {
|
||||
Some((stamp, cached_bounds, snapshot)) => {
|
||||
if stamp != &self.stamp || *cached_bounds != bounds || !snapshot.chunks_match(world)
|
||||
{
|
||||
let snapshot = Arc::make_mut(snapshot);
|
||||
snapshot.opaque_states = Arc::clone(&opaque_states);
|
||||
snapshot.refresh_chunks(world, bounds);
|
||||
*stamp = self.stamp;
|
||||
*cached_bounds = bounds;
|
||||
}
|
||||
Arc::clone(snapshot)
|
||||
}
|
||||
None => {
|
||||
let mut snapshot = OcclusionSnapshot {
|
||||
chunks: Vec::new(),
|
||||
opaque_states,
|
||||
};
|
||||
snapshot.refresh_chunks(world, bounds);
|
||||
let snapshot = Arc::new(snapshot);
|
||||
self.snapshot = Some((self.stamp, bounds, Arc::clone(&snapshot)));
|
||||
snapshot
|
||||
}
|
||||
};
|
||||
let queries = std::mem::take(queries);
|
||||
let job = CullJob {
|
||||
stamp: self.stamp,
|
||||
snapshot,
|
||||
queries,
|
||||
};
|
||||
match self.jobs.try_send(job) {
|
||||
Ok(()) => self.pending = true,
|
||||
Err(TrySendError::Full(job) | TrySendError::Disconnected(job)) => {
|
||||
*self.queries.get_mut() = job.queries;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_bounds(camera: [f64; 3], queries: &[CullKey]) -> Option<ChunkBounds> {
|
||||
let mut min = [i32::MAX; 3];
|
||||
let mut max = [i32::MIN; 3];
|
||||
let mut include = |point: [f64; 3]| {
|
||||
for axis in 0..3 {
|
||||
let value = point[axis].floor();
|
||||
if !value.is_finite() || value < f64::from(i32::MIN) || value > f64::from(i32::MAX) {
|
||||
return false;
|
||||
}
|
||||
let value = value as i32;
|
||||
min[axis] = min[axis].min(value);
|
||||
max[axis] = max[axis].max(value);
|
||||
}
|
||||
true
|
||||
};
|
||||
if !include(camera) {
|
||||
return None;
|
||||
}
|
||||
for query in queries {
|
||||
if !include(query.min.map(f64::from_bits)) || !include(query.max.map(f64::from_bits)) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let bounds = ChunkBounds {
|
||||
min_x: min[0] >> 4,
|
||||
max_x: max[0] >> 4,
|
||||
min_z: min[2] >> 4,
|
||||
max_z: max[2] >> 4,
|
||||
};
|
||||
let width = i64::from(bounds.max_x) - i64::from(bounds.min_x) + 1;
|
||||
let depth = i64::from(bounds.max_z) - i64::from(bounds.min_z) + 1;
|
||||
(width > 0 && depth > 0 && width * depth <= MAX_SNAPSHOT_CHUNKS).then_some(bounds)
|
||||
}
|
||||
|
||||
fn cull_job(mut job: CullJob) -> CullResults {
|
||||
let camera = job.queries[0].camera.map(f64::from_bits);
|
||||
let culler = Culler::new(camera, 8_192);
|
||||
let mut last_chunk = LastChunk::default();
|
||||
let mut answers = Vec::with_capacity(job.queries.len());
|
||||
let mut covered = Vec::new();
|
||||
for &key in &job.queries {
|
||||
covered.clear();
|
||||
let min = key.min.map(f64::from_bits);
|
||||
let max = key.max.map(f64::from_bits);
|
||||
let proof = if culler.occluded(min, max, |pos| {
|
||||
let opaque = job.snapshot.opaque(pos, &mut last_chunk);
|
||||
if opaque {
|
||||
covered.push(pos);
|
||||
}
|
||||
opaque
|
||||
}) {
|
||||
let mut sections = Vec::with_capacity(covered.len());
|
||||
let mut chunks: Vec<(ChunkKey, Weak<Chunk>)> = Vec::new();
|
||||
let mut valid = true;
|
||||
for &pos in &covered {
|
||||
let chunk_key = (pos[0] >> 4, pos[2] >> 4);
|
||||
let Some(y) = pos[1].checked_sub(job.stamp.bottom_y) else {
|
||||
valid = false;
|
||||
break;
|
||||
};
|
||||
sections.push((chunk_key.0, y.div_euclid(16), chunk_key.1));
|
||||
if let Err(index) = chunks.binary_search_by_key(&chunk_key, |(key, _)| *key) {
|
||||
let Some(chunk) = job.snapshot.chunk(chunk_key) else {
|
||||
valid = false;
|
||||
break;
|
||||
};
|
||||
if chunk.strong_count() == 0 {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
chunks.insert(index, (chunk_key, chunk.clone()));
|
||||
}
|
||||
}
|
||||
if valid {
|
||||
sections.sort_unstable();
|
||||
sections.dedup();
|
||||
Some(Arc::new(OcclusionProof { sections, chunks }))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
answers.push((key, proof));
|
||||
}
|
||||
job.queries.clear();
|
||||
CullResults {
|
||||
stamp: job.stamp,
|
||||
answers,
|
||||
scratch_queries: job.queries,
|
||||
}
|
||||
}
|
||||
|
||||
fn state_is_opaque(facts: &StateFacts) -> bool {
|
||||
facts.render_shape == RenderShape::Model
|
||||
&& facts.can_occlude
|
||||
&& facts.solid_render
|
||||
&& facts.full_collision
|
||||
}
|
||||
|
||||
const CLUSTER_CACHE_SIZE: usize = 1_024;
|
||||
const CLUSTER_SIZE: f64 = 8.0;
|
||||
const MAX_CLUSTER_OCCLUSION_RADIUS: f64 = 1.0;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ClusterCacheEntry {
|
||||
cluster: [i32; 3],
|
||||
hidden: bool,
|
||||
}
|
||||
|
||||
struct ClusterVisibilityCache {
|
||||
entries: [Option<ClusterCacheEntry>; CLUSTER_CACHE_SIZE],
|
||||
}
|
||||
|
||||
impl Default for ClusterVisibilityCache {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entries: [None; CLUSTER_CACHE_SIZE],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClusterVisibilityCache {
|
||||
fn get(&self, cluster: [i32; 3]) -> Option<bool> {
|
||||
self.entries[cluster_cache_slot(cluster)]
|
||||
.filter(|entry| entry.cluster == cluster)
|
||||
.map(|entry| entry.hidden)
|
||||
}
|
||||
|
||||
fn get_or_compute(&mut self, cluster: [i32; 3], compute: impl FnOnce() -> bool) -> bool {
|
||||
if let Some(hidden) = self.get(cluster) {
|
||||
return hidden;
|
||||
}
|
||||
let hidden = compute();
|
||||
self.insert(cluster, hidden);
|
||||
hidden
|
||||
}
|
||||
|
||||
fn insert(&mut self, cluster: [i32; 3], hidden: bool) {
|
||||
self.entries[cluster_cache_slot(cluster)] = Some(ClusterCacheEntry { cluster, hidden });
|
||||
}
|
||||
}
|
||||
|
||||
fn cluster_cache_slot(cluster: [i32; 3]) -> usize {
|
||||
let hash = (cluster[0] as u32).wrapping_mul(73_856_093)
|
||||
^ (cluster[1] as u32).wrapping_mul(19_349_663)
|
||||
^ (cluster[2] as u32).wrapping_mul(83_492_791);
|
||||
hash as usize & (CLUSTER_CACHE_SIZE - 1)
|
||||
}
|
||||
|
||||
/// Use one conservatively expanded 8-block cluster bound for small particles.
|
||||
fn cluster_query(min: [f64; 3], max: [f64; 3]) -> Option<([i32; 3], [f64; 3], [f64; 3])> {
|
||||
if (0..3).any(|axis| !min[axis].is_finite() || !max[axis].is_finite() || min[axis] > max[axis])
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let center = array::from_fn(|axis| min[axis] + (max[axis] - min[axis]) * 0.5);
|
||||
let radius = (0..3)
|
||||
.map(|axis| (max[axis] - min[axis]) * 0.5)
|
||||
.fold(0.0_f64, f64::max);
|
||||
if !radius.is_finite() || radius > MAX_CLUSTER_OCCLUSION_RADIUS {
|
||||
return None;
|
||||
}
|
||||
let cluster = center.map(|value| {
|
||||
let coordinate = (value / CLUSTER_SIZE).floor();
|
||||
(coordinate.is_finite()
|
||||
&& coordinate >= f64::from(i32::MIN)
|
||||
&& coordinate <= f64::from(i32::MAX))
|
||||
.then_some(coordinate as i32)
|
||||
});
|
||||
let [Some(x), Some(y), Some(z)] = cluster else {
|
||||
return None;
|
||||
};
|
||||
let cluster = [x, y, z];
|
||||
let min = cluster
|
||||
.map(|coordinate| f64::from(coordinate) * CLUSTER_SIZE - MAX_CLUSTER_OCCLUSION_RADIUS);
|
||||
let max = cluster.map(|coordinate| {
|
||||
(f64::from(coordinate) + 1.0) * CLUSTER_SIZE + MAX_CLUSTER_OCCLUSION_RADIUS
|
||||
});
|
||||
Some((cluster, min, max))
|
||||
}
|
||||
|
||||
pub(super) struct ParticleOcclusionContext<'borrow, 'game> {
|
||||
game: &'borrow Game<'game>,
|
||||
mode: ParticleCullingMode,
|
||||
sync_culler: Option<Culler>,
|
||||
clusters: ClusterVisibilityCache,
|
||||
}
|
||||
|
||||
impl ParticleOcclusionContext<'_, '_> {
|
||||
pub(super) fn hidden(&mut self, min: [f64; 3], max: [f64; 3]) -> bool {
|
||||
if self.mode == ParticleCullingMode::Off {
|
||||
return false;
|
||||
}
|
||||
let Some((cluster, min, max)) = cluster_query(min, max) else {
|
||||
// Large particles can extend beyond the cluster's conservative
|
||||
// bounds. Keep them visible rather than scheduling their own ray job.
|
||||
return false;
|
||||
};
|
||||
let mode = self.mode;
|
||||
let game = self.game;
|
||||
let sync_culler = self.sync_culler.as_ref();
|
||||
self.clusters.get_or_compute(cluster, || match mode {
|
||||
ParticleCullingMode::Off => false,
|
||||
ParticleCullingMode::Synchronous => {
|
||||
sync_culler.is_some_and(|culler| game.particle_occluded_sync(culler, min, max))
|
||||
}
|
||||
ParticleCullingMode::Asynchronous => {
|
||||
game.async_particle_hidden(min, max).unwrap_or_else(|| {
|
||||
sync_culler.is_some_and(|culler| game.particle_occluded_sync(culler, min, max))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn particle_occlusion_context(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
) -> ParticleOcclusionContext<'_, '_> {
|
||||
let at = camera.to_array().map(|value| value.floor() as i32);
|
||||
let camera_inside_opaque =
|
||||
state_is_opaque(self.res.facts().get(self.world.block(at[0], at[1], at[2])));
|
||||
let mode =
|
||||
if self.model_packs_are_vanilla() && self.player.is_some() && !camera_inside_opaque {
|
||||
self.settings.horizon.particle_culling
|
||||
} else {
|
||||
ParticleCullingMode::Off
|
||||
};
|
||||
if mode == ParticleCullingMode::Asynchronous {
|
||||
let mut state = self.async_particle_culler.borrow_mut();
|
||||
if state.culler.is_none() && !state.failed {
|
||||
match ParticleAsyncCuller::new() {
|
||||
Ok(culler) => state.culler = Some(culler),
|
||||
Err(error) => {
|
||||
log::warn!("Horizon: cannot start particle-culling worker: {error}");
|
||||
state.failed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(culler) = &mut state.culler {
|
||||
culler.begin_frame(camera, &self.world);
|
||||
}
|
||||
} else {
|
||||
let mut state = self.async_particle_culler.borrow_mut();
|
||||
state.culler = None;
|
||||
state.failed = false;
|
||||
}
|
||||
ParticleOcclusionContext {
|
||||
game: self,
|
||||
mode,
|
||||
sync_culler: (mode != ParticleCullingMode::Off)
|
||||
.then(|| Culler::new(camera.to_array(), 8_192)),
|
||||
clusters: ClusterVisibilityCache::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn particle_occluded_sync(
|
||||
&self,
|
||||
culler: &Culler,
|
||||
min: [f64; 3],
|
||||
max: [f64; 3],
|
||||
) -> bool {
|
||||
culler.occluded(min, max, |pos| self.particle_occluder_ready(pos))
|
||||
}
|
||||
|
||||
fn particle_occluder_ready(&self, pos: [i32; 3]) -> bool {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !state_is_opaque(self.res.facts().get(state)) {
|
||||
return false;
|
||||
}
|
||||
let Some(y) = pos[1].checked_sub(self.world.bottom_y()) else {
|
||||
return false;
|
||||
};
|
||||
let section = (pos[0] >> 4, y.div_euclid(16), pos[2] >> 4);
|
||||
self.renderer
|
||||
.as_ref()
|
||||
.is_some_and(|renderer| renderer.has_section(section))
|
||||
&& !self.world.dirty_sections.contains(§ion)
|
||||
&& !self.streaming.is_meshing(section)
|
||||
&& !self.pending_uploads.iter().any(|(key, _)| *key == section)
|
||||
}
|
||||
|
||||
pub(super) fn async_particle_hidden(&self, min: [f64; 3], max: [f64; 3]) -> Option<bool> {
|
||||
enum Answer {
|
||||
Miss,
|
||||
Visible,
|
||||
Hidden(Arc<OcclusionProof>),
|
||||
}
|
||||
|
||||
let query = {
|
||||
let state = self.async_particle_culler.borrow();
|
||||
let culler = state.culler.as_ref()?;
|
||||
culler.query(min, max)?
|
||||
};
|
||||
let answer = {
|
||||
let state = self.async_particle_culler.borrow();
|
||||
let culler = state.culler.as_ref()?;
|
||||
match culler.cached(&query) {
|
||||
None => Answer::Miss,
|
||||
Some(None) => Answer::Visible,
|
||||
Some(Some(proof)) => Answer::Hidden(Arc::clone(proof)),
|
||||
}
|
||||
};
|
||||
match answer {
|
||||
Answer::Visible => Some(false),
|
||||
Answer::Hidden(proof) if self.particle_proof_is_current(&proof) => Some(true),
|
||||
Answer::Miss | Answer::Hidden(_) => {
|
||||
if let Some(culler) = self.async_particle_culler.borrow().culler.as_ref() {
|
||||
culler.queue(query);
|
||||
}
|
||||
Some(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn particle_proof_is_current(&self, proof: &OcclusionProof) -> bool {
|
||||
proof.chunks.iter().all(|(key, expected)| {
|
||||
self.world
|
||||
.chunks
|
||||
.get(key)
|
||||
.is_some_and(|current| std::ptr::eq(Arc::as_ptr(current), expected.as_ptr()))
|
||||
}) && proof.sections.iter().all(|§ion| {
|
||||
self.renderer
|
||||
.as_ref()
|
||||
.is_some_and(|renderer| renderer.has_section(section))
|
||||
&& !self.world.dirty_sections.contains(§ion)
|
||||
&& !self.streaming.is_meshing(section)
|
||||
&& !self.pending_uploads.iter().any(|(key, _)| *key == section)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn finish_async_particle_culling(&self) {
|
||||
if let Some(culler) = &mut self.async_particle_culler.borrow_mut().culler {
|
||||
culler.finish_frame(&self.world, self.res.facts());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::chunk::ColumnLayout;
|
||||
|
||||
fn wall(hole: Option<[i32; 3]>) -> (Arc<OcclusionSnapshot>, Vec<Arc<Chunk>>) {
|
||||
let mut live = Vec::new();
|
||||
let mut chunks = Vec::new();
|
||||
for chunk_z in [-1, 0] {
|
||||
let mut chunk = Chunk::with_layout(0, chunk_z, ColumnLayout::VANILLA);
|
||||
for y in -1..=0 {
|
||||
for z in -1..=0 {
|
||||
let pos = [4, y, z];
|
||||
if hole != Some(pos) {
|
||||
chunk.set(pos[0] & 15, pos[1], pos[2] & 15, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
let chunk = Arc::new(chunk);
|
||||
chunks.push(((0, chunk_z), Arc::downgrade(&chunk)));
|
||||
live.push(chunk);
|
||||
}
|
||||
chunks.sort_unstable_by_key(|(key, _)| *key);
|
||||
(
|
||||
Arc::new(OcclusionSnapshot {
|
||||
chunks,
|
||||
opaque_states: Arc::from([false, true]),
|
||||
}),
|
||||
live,
|
||||
)
|
||||
}
|
||||
|
||||
fn wall_job(snapshot: Arc<OcclusionSnapshot>) -> CullJob {
|
||||
CullJob {
|
||||
stamp: SnapshotKey {
|
||||
world_revision: 1,
|
||||
chunks_revision: 1,
|
||||
bottom_y: ColumnLayout::VANILLA.min_y(),
|
||||
},
|
||||
snapshot,
|
||||
queries: vec![CullKey {
|
||||
camera: [0.0; 3].map(f64::to_bits),
|
||||
min: [7.95, -0.55, -0.55].map(f64::to_bits),
|
||||
max: [10.05, 0.55, 0.55].map(f64::to_bits),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cluster_query_covers_small_particles_across_negative_boundaries() {
|
||||
let min = [-0.2, 15.6, -16.2];
|
||||
let max = [0.2, 16.0, -15.8];
|
||||
let (cluster, query_min, query_max) = cluster_query(min, max).unwrap();
|
||||
|
||||
assert_eq!(cluster, [0, 1, -2]);
|
||||
for axis in 0..3 {
|
||||
assert!(query_min[axis] <= min[axis]);
|
||||
assert!(query_max[axis] >= max[axis]);
|
||||
}
|
||||
assert_eq!(query_min, [-1.0, 7.0, -17.0]);
|
||||
assert_eq!(query_max, [9.0, 17.0, -7.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_clusters_keep_distinct_visibility_bounds() {
|
||||
let left = cluster_query([7.0, 0.0, 0.0], [7.2, 0.2, 0.2]).unwrap();
|
||||
let right = cluster_query([8.1, 0.0, 0.0], [8.3, 0.2, 0.2]).unwrap();
|
||||
|
||||
assert_eq!(left.0, [0, 0, 0]);
|
||||
assert_eq!(right.0, [1, 0, 0]);
|
||||
assert_ne!((left.1, left.2), (right.1, right.2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_maximum_supported_particle_radius_is_still_fully_covered() {
|
||||
let min = [15.0, 15.0, -1.0];
|
||||
let max = [17.0, 17.0, 1.0];
|
||||
let (cluster, query_min, query_max) = cluster_query(min, max).unwrap();
|
||||
|
||||
assert_eq!(cluster, [2, 2, 0]);
|
||||
assert_eq!(query_min, [15.0, 15.0, -1.0]);
|
||||
assert_eq!(query_max, [25.0, 25.0, 9.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_or_large_particle_bounds_fail_open() {
|
||||
assert!(cluster_query([0.0, f64::NAN, 0.0], [1.0; 3]).is_none());
|
||||
assert!(cluster_query([1.0; 3], [0.0; 3]).is_none());
|
||||
assert!(cluster_query([-1.1, 0.0, 0.0], [1.1, 0.0, 0.0]).is_none());
|
||||
assert!(cluster_query([f64::MAX; 3], [f64::MAX; 3]).is_none());
|
||||
assert!(cluster_query([-f64::MAX; 3], [f64::MAX; 3]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particles_in_one_cluster_share_one_occlusion_query() {
|
||||
let first = cluster_query([0.1, 1.0, 2.0], [0.3, 1.2, 2.2]).unwrap();
|
||||
let second = cluster_query([3.0, 3.0, 3.0], [3.4, 3.4, 3.4]).unwrap();
|
||||
|
||||
assert_eq!(first, second);
|
||||
|
||||
let mut cache = ClusterVisibilityCache::default();
|
||||
let mut queries = 0;
|
||||
assert!(cache.get_or_compute(first.0, || {
|
||||
queries += 1;
|
||||
true
|
||||
}));
|
||||
assert!(cache.get_or_compute(second.0, || {
|
||||
queries += 1;
|
||||
false
|
||||
}));
|
||||
assert_eq!(queries, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_direct_mapped_cache_collision_never_reuses_another_cluster_answer() {
|
||||
let candidates = (0..=CLUSTER_CACHE_SIZE)
|
||||
.map(|x| [i32::try_from(x).unwrap(), 0, 0])
|
||||
.collect::<Vec<_>>();
|
||||
let (first, second) = (0..candidates.len())
|
||||
.find_map(|i| {
|
||||
((i + 1)..candidates.len())
|
||||
.find(|&j| {
|
||||
cluster_cache_slot(candidates[i]) == cluster_cache_slot(candidates[j])
|
||||
})
|
||||
.map(|j| (candidates[i], candidates[j]))
|
||||
})
|
||||
.expect("1025 distinct keys must contain a collision in a 1024-slot cache");
|
||||
let mut cache = ClusterVisibilityCache::default();
|
||||
|
||||
cache.insert(first, true);
|
||||
assert_eq!(cache.get(first), Some(true));
|
||||
cache.insert(second, false);
|
||||
assert_eq!(cache.get(first), None);
|
||||
assert_eq!(cache.get(second), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particle_worker_requires_a_complete_opaque_plane_to_hide() {
|
||||
let (snapshot, live) = wall(None);
|
||||
let results = cull_job(wall_job(snapshot));
|
||||
let proof = results.answers[0].1.as_ref().unwrap();
|
||||
assert_eq!(proof.sections.len(), 4);
|
||||
assert_eq!(proof.chunks.len(), 2);
|
||||
drop(live);
|
||||
|
||||
let (snapshot, live) = wall(Some([4, 0, 0]));
|
||||
let results = cull_job(wall_job(snapshot));
|
||||
assert!(results.answers[0].1.is_none());
|
||||
drop(live);
|
||||
}
|
||||
}
|
||||
@@ -352,6 +352,8 @@ impl Game<'_> {
|
||||
// Whatever was added since the last tick joins before this one.
|
||||
self.native_joins_flush(false, 0);
|
||||
let kind = entity_nbt::kind(&tag).unwrap_or_default();
|
||||
let is_mob = self.res.mob_types().contains_key(kind.as_str());
|
||||
drop_unleashable_leash(&mut tag, &kind, is_mob);
|
||||
let uuid = entity_nbt::uuid(&tag);
|
||||
if uuid.is_some_and(|u| self.restored_vehicle(u).is_some()) {
|
||||
continue;
|
||||
@@ -1204,6 +1206,19 @@ impl Game<'_> {
|
||||
if let Some(item @ Tag::Compound(_)) = tag.get("Item") {
|
||||
map.insert("Item".into(), item.clone());
|
||||
}
|
||||
// Entity.saveWithoutId's optional fields Horizon models nowhere for
|
||||
// these entities, kept as loaded (written only when set).
|
||||
for key in [
|
||||
"Silent",
|
||||
"Glowing",
|
||||
"Invulnerable",
|
||||
"CustomName",
|
||||
"CustomNameVisible",
|
||||
] {
|
||||
if let Some(v) = tag.get(key) {
|
||||
map.insert(key.into(), v.clone());
|
||||
}
|
||||
}
|
||||
if !map.is_empty() {
|
||||
self.persist.raw.insert(id, Tag::Compound(map));
|
||||
}
|
||||
@@ -1863,12 +1878,50 @@ fn vehicle_tree(root: u32, mounts: &[(u32, Option<u32>)]) -> Vec<u32> {
|
||||
tree
|
||||
}
|
||||
|
||||
/// Only a Mob or an AbstractBoat is Leashable: any other kind never reads
|
||||
/// `leash`, so a summoned or loaded tag must not be kept and saved.
|
||||
fn drop_unleashable_leash(tag: &mut Tag, kind: &str, is_mob: bool) {
|
||||
if !is_mob
|
||||
&& super::boats::any_boat_wood(kind).is_none()
|
||||
&& let Tag::Compound(c) = tag
|
||||
{
|
||||
c.remove(&"leash".into());
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const MOUNT: [i32; 4] = [1, 2, 3, 4];
|
||||
|
||||
#[test]
|
||||
fn only_mobs_and_boats_keep_a_leash_tag() {
|
||||
let with_leash = |kind: &str| {
|
||||
let mut t = entity(kind);
|
||||
if let Tag::Compound(c) = &mut t {
|
||||
c.insert("leash".into(), Tag::IntArray(vec![6, 100, 0]));
|
||||
}
|
||||
t
|
||||
};
|
||||
let has = |t: &Tag| t.get("leash").is_some();
|
||||
for kind in [
|
||||
"minecraft:armor_stand",
|
||||
"minecraft:minecart",
|
||||
"minecraft:tnt",
|
||||
"minecraft:item",
|
||||
] {
|
||||
let mut t = with_leash(kind);
|
||||
drop_unleashable_leash(&mut t, kind, false);
|
||||
assert!(!has(&t), "{kind}");
|
||||
}
|
||||
let mut cow = with_leash("minecraft:cow");
|
||||
drop_unleashable_leash(&mut cow, "minecraft:cow", true);
|
||||
assert!(has(&cow));
|
||||
let mut boat = with_leash("minecraft:oak_boat");
|
||||
drop_unleashable_leash(&mut boat, "minecraft:oak_boat", false);
|
||||
assert!(has(&boat));
|
||||
}
|
||||
|
||||
fn entity(kind: &str) -> Tag {
|
||||
Tag::compound([("id", Tag::String(kind.into()))])
|
||||
}
|
||||
|
||||
@@ -1228,6 +1228,11 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
t.position = next;
|
||||
let bounds = Aabb::new(
|
||||
next.add_vector(Vec3::new(-0.125, 0.0, -0.125)),
|
||||
next.add_vector(Vec3::new(0.125, 0.25, 0.125)),
|
||||
);
|
||||
self.entity_powder_snow_particles(t.previous, next, bounds, t.velocity, false);
|
||||
t.age < 1200 && t.position.y > -128.0
|
||||
});
|
||||
self.potions.thrown = thrown;
|
||||
|
||||
@@ -27,7 +27,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let has_far = self.settings.horizon.lod_distance > 0;
|
||||
let pressure = ram_limit::pressure(limit, self.fps);
|
||||
let pressure = ram_limit::pressure(limit);
|
||||
// Every ten seconds, what the limit sees: a report can show the trace.
|
||||
if self.ticks.is_multiple_of(200) {
|
||||
let (commit, _) = horizon_threads::allocator_commit();
|
||||
|
||||
@@ -654,6 +654,8 @@ pub(super) struct Remote {
|
||||
death_message: Option<horizon_client::text::Text>,
|
||||
/// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`.
|
||||
show_death_screen: bool,
|
||||
/// `Player.isReducedDebugInfo`: the server's `reduced_debug_info` rule.
|
||||
pub(super) reduced_debug_info: bool,
|
||||
/// `LocalPlayer.doLimitedCrafting`: whether limited crafting is enabled.
|
||||
pub(super) do_limited_crafting: bool,
|
||||
player_type: Option<Arc<MobType>>,
|
||||
@@ -1091,6 +1093,7 @@ impl Game<'_> {
|
||||
flat: false,
|
||||
death_message: None,
|
||||
show_death_screen: true,
|
||||
reduced_debug_info: false,
|
||||
do_limited_crafting: false,
|
||||
player_type,
|
||||
synced: vec![None; CREATIVE_SYNCED],
|
||||
@@ -3077,6 +3080,7 @@ impl Game<'_> {
|
||||
r.view_distance = p.chunk_radius;
|
||||
r.simulation_distance = p.simulation_distance;
|
||||
r.show_death_screen = p.show_death_screen;
|
||||
r.reduced_debug_info = p.reduced_debug_info;
|
||||
r.do_limited_crafting = p.limited_crafting;
|
||||
}
|
||||
self.streaming.radius =
|
||||
@@ -3983,6 +3987,12 @@ impl Game<'_> {
|
||||
self.ui.registries.permission = (p.event - 24) as u8;
|
||||
self.remote_world_options_permission(p.event >= 26);
|
||||
}
|
||||
// Player.handleEntityEvent 22 / 23: reduced debug info on / off.
|
||||
if (22..=23).contains(&p.event)
|
||||
&& let Some(r) = self.remote.as_mut().filter(|r| r.entity_id == p.id)
|
||||
{
|
||||
r.reduced_debug_info = p.event == 22;
|
||||
}
|
||||
// Ravager.handleEntityEvent 69: the roar's puff and push.
|
||||
if p.event == 69 {
|
||||
self.remote_ravager_roar(p.id);
|
||||
|
||||
@@ -283,7 +283,7 @@ impl Game<'_> {
|
||||
state: None,
|
||||
at: [feet[0], feet[1] + height * 0.5, feet[2]],
|
||||
spread: [0.1, 0.0, 0.1],
|
||||
speed: 0.2,
|
||||
speed: [0.2; 3],
|
||||
count: (actual * 0.5) as u32 as i32,
|
||||
randomization: 0,
|
||||
});
|
||||
|
||||
@@ -241,6 +241,8 @@ impl Game<'_> {
|
||||
&self.res.motion().fluids,
|
||||
Some(self.border.geometry()),
|
||||
)?;
|
||||
let snow = (t.previous, t.position, t.bounds(), t.velocity);
|
||||
self.entity_powder_snow_particles(snow.0, snow.1, snow.2, snow.3, false);
|
||||
for pos in tops {
|
||||
self.bubble_column_particles(pos);
|
||||
}
|
||||
|
||||
@@ -803,6 +803,16 @@ impl Game<'_> {
|
||||
t.pitch = t.velocity.y.atan2(h).to_degrees() as f32;
|
||||
}
|
||||
}
|
||||
if !t.no_physics {
|
||||
let half = 0.25;
|
||||
let bounds = Aabb::new(
|
||||
t.position.add_vector(Vec3::new(-half, 0.0, -half)),
|
||||
t.position.add_vector(Vec3::new(half, 0.5, half)),
|
||||
);
|
||||
self.entity_powder_snow_particles(
|
||||
t.previous, t.position, bounds, t.velocity, false,
|
||||
);
|
||||
}
|
||||
// playerTouch: stuck or returning tridents go back to a player.
|
||||
// ThrownTrident.playerTouch: only its owner takes it, or anyone
|
||||
// once the owner is not in this level (`getOwner() == null`), and
|
||||
|
||||
@@ -308,6 +308,8 @@ pub struct Hub {
|
||||
pub far: Option<super::far::Far>,
|
||||
/// The `immediate_respawn` game rule, as the players were last told.
|
||||
immediate_respawn: bool,
|
||||
/// The `reduced_debug_info` game rule, as the players were last told.
|
||||
reduced_debug_info: bool,
|
||||
/// The `limited_crafting` game rule, as the players were last told.
|
||||
pub limited_crafting: bool,
|
||||
/// An integrated server's owner (`MinecraftServer.isSingleplayerOwner`),
|
||||
@@ -630,6 +632,7 @@ impl Hub {
|
||||
seat_order: 0,
|
||||
sound_seeds: Mutex::new(LegacyRandom::new(crate::sound_engine::unique_seed())),
|
||||
immediate_respawn: false,
|
||||
reduced_debug_info: false,
|
||||
limited_crafting: false,
|
||||
movement_checks: (true, true),
|
||||
anticheat: None,
|
||||
@@ -742,6 +745,20 @@ impl Hub {
|
||||
}));
|
||||
}
|
||||
}
|
||||
/// MinecraftServer.onGameRuleChanged: every player gets entity event 22
|
||||
/// (reduced) or 23 (full) for themselves when `reduced_debug_info`
|
||||
/// changes; a joining player's Login carries it.
|
||||
pub fn set_reduced_debug_info(&mut self, on: bool) {
|
||||
if self.reduced_debug_info != on {
|
||||
self.reduced_debug_info = on;
|
||||
for g in self.guests.iter().filter(|g| g.placed) {
|
||||
g.send(&C::EntityEvent(play::EntityEvent {
|
||||
id: g.entity_id,
|
||||
event: if on { 22 } else { 23 },
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `MinecraftServer.onGameRuleChanged`: every player is told when
|
||||
/// `limited_crafting` changes; a joining player's Login carries it.
|
||||
pub fn set_limited_crafting(&mut self, on: bool) {
|
||||
@@ -1144,7 +1161,7 @@ impl Hub {
|
||||
max_players: self.config.max_players as i32,
|
||||
chunk_radius: self.chunk_radius(),
|
||||
simulation_distance: self.config.simulation_distance,
|
||||
reduced_debug_info: false,
|
||||
reduced_debug_info: self.reduced_debug_info,
|
||||
show_death_screen: !self.immediate_respawn,
|
||||
limited_crafting: self.limited_crafting,
|
||||
spawn: self.spawn_info(i, 0),
|
||||
@@ -4429,6 +4446,32 @@ mod tests {
|
||||
(hub, rx)
|
||||
}
|
||||
|
||||
/// `onGameRuleChanged`: a changed `reduced_debug_info` reaches each player
|
||||
/// as their own entity event 22 / 23, and an unchanged one sends nothing.
|
||||
#[test]
|
||||
fn reduced_debug_info_changes_reach_players_as_entity_events() {
|
||||
let (mut hub, rx) = hub_with_a_guest();
|
||||
let id = hub.guests[0].entity_id;
|
||||
let events = |rx: &std::sync::mpsc::Receiver<crate::lan::Out>| {
|
||||
let mut sent = Vec::new();
|
||||
while let Ok(crate::lan::Out::Packet(body)) = rx.try_recv() {
|
||||
let mut r = horizon_protocol::buf::Reader::new(&body);
|
||||
let pid = r.varint().unwrap();
|
||||
if let Ok(C::EntityEvent(p)) = C::decode(pid, r.rest()) {
|
||||
sent.push((p.id, p.event));
|
||||
}
|
||||
}
|
||||
sent
|
||||
};
|
||||
hub.set_reduced_debug_info(false);
|
||||
assert!(events(&rx).is_empty());
|
||||
hub.set_reduced_debug_info(true);
|
||||
hub.set_reduced_debug_info(true);
|
||||
assert_eq!(events(&rx), vec![(id, 22)]);
|
||||
hub.set_reduced_debug_info(false);
|
||||
assert_eq!(events(&rx), vec![(id, 23)]);
|
||||
}
|
||||
|
||||
/// The blocks the guest was sent as `ClientboundBlockUpdatePacket`s, in order, with the
|
||||
/// state each one carries.
|
||||
fn sent_block_updates(rx: &std::sync::mpsc::Receiver<crate::lan::Out>) -> Vec<([i32; 3], i32)> {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! messages checked link by link, the acknowledged window, and messages
|
||||
//! relayed with each connection's numbering and signature cache.
|
||||
use super::{HostEvent, Hub};
|
||||
use horizon_client_core::text::Text;
|
||||
use horizon_protocol::{
|
||||
packets::play::{self, Clientbound as C},
|
||||
secure_chat::{self, Body, Chain, ChatSession, DecodeError, ProfileKey, Signature, Unpacked},
|
||||
@@ -10,6 +11,22 @@ use horizon_protocol::{
|
||||
};
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
|
||||
/// A player's chat message is about to be sent to everyone (after the secure-chat chain and the chat
|
||||
/// filter took it). A mod may cancel it, which drops it for everybody (the sender is not told, so push
|
||||
/// a line to `replies` to answer them), or only answer. It cannot change the text: a signed message
|
||||
/// would no longer verify. Once for each message a player sends, on a server. Commands are
|
||||
/// `native_player_command`. API @API:player_chat@.
|
||||
#[inline(never)]
|
||||
pub fn native_player_chat(
|
||||
player: u128,
|
||||
name: &str,
|
||||
message: &str,
|
||||
replies: &mut Vec<Text>,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
crate::native_client_hooks::player_chat(player, name, message, replies, cancel);
|
||||
}
|
||||
|
||||
/// Now, in epoch milliseconds (Instant.now()).
|
||||
pub fn now_millis() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
@@ -221,6 +238,20 @@ impl Hub {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// The mods may refuse it (a muted player), before anyone sees it.
|
||||
if self.mods_loaded {
|
||||
let (mut cancel, mut replies) = (false, Vec::new());
|
||||
native_player_chat(uuid, &name, &message.content, &mut replies, &mut cancel);
|
||||
for line in replies {
|
||||
self.guests[i].send(&C::SystemChat(play::SystemChat {
|
||||
content: crate::lan::data::component_nbt(&line.component()),
|
||||
overlay: false,
|
||||
}));
|
||||
}
|
||||
if cancel {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let bound = play::ChatTypeBound {
|
||||
chat_type: self.chat_type,
|
||||
name: self.player_display_name(uuid, &name),
|
||||
|
||||
@@ -3609,6 +3609,7 @@ pub(crate) mod tests {
|
||||
*max_stack = 7;
|
||||
}
|
||||
},
|
||||
player_chat: |_, _, _, _, _| {},
|
||||
living_heal: |_, _, _| {},
|
||||
living_breathe: |_, _, _, _, _| {},
|
||||
living_drown: |_, _, _, _, _, _| {},
|
||||
|
||||
@@ -1358,6 +1358,11 @@ pub enum MobEvent {
|
||||
mob: u32,
|
||||
at: [f64; 3],
|
||||
},
|
||||
/// PowderSnowBlock.entityInside's snowflake with vanilla's sampled velocity.
|
||||
PowderSnowParticle {
|
||||
at: [f64; 3],
|
||||
velocity: [f64; 3],
|
||||
},
|
||||
/// `Mob.burnUndead` in daylight on a mob wearing something in its
|
||||
/// `sunProtectionSlot`: the item loses up to 1 durability (the game knows
|
||||
/// the item data) instead of the mob catching fire.
|
||||
@@ -2575,6 +2580,8 @@ pub struct PlayerView {
|
||||
pub sleep_timer: u32,
|
||||
/// `isPassenger`.
|
||||
pub passenger: bool,
|
||||
/// The vehicle entity id, including mounts without a `Vehicle` follow view.
|
||||
pub riding_vehicle: Option<u32>,
|
||||
/// The boat or nautilus the player rides and steers
|
||||
/// (`getControllingPassenger`).
|
||||
pub vehicle: Option<Vehicle>,
|
||||
@@ -2615,6 +2622,8 @@ pub struct MobWorld<'a> {
|
||||
pub collision: &'a BlockCollisionRegistry<'a>,
|
||||
pub data: &'a MobData,
|
||||
pub player: Option<PlayerView>,
|
||||
/// `getKnownMovement` of a mob's controlling player passenger, if any.
|
||||
pub controlling_player_movement: Option<Vec3>,
|
||||
/// `getNearestPlayer(mob, -1)`: where the nearest non-spectator of every
|
||||
/// player in the level stands, alive or not (the despawn distance).
|
||||
pub nearest_player: Option<Vec3>,
|
||||
@@ -3504,8 +3513,38 @@ impl Mob {
|
||||
self.heal(heal);
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.canBeAffected`: whether an effect of this kind may be added at
|
||||
/// all, asked by `addEffect` before it stores anything. Five classes narrow the
|
||||
/// rule first — a spider (a cave spider with it) and a nautilus refuse poison, a
|
||||
/// wither and a wither skeleton the wither effect, a parched weakness — and the
|
||||
/// entity type tags answer for the rest: `immune_to_infested` keeps infested off
|
||||
/// a silverfish, `immune_to_oozing` keeps oozing off a slime, and
|
||||
/// `ignores_poison_and_regen` (which is `#minecraft:undead`) keeps poison and
|
||||
/// regeneration off a zombie, a skeleton, a phantom, a wither and the rest.
|
||||
pub fn can_be_affected(&self, effect: &'static str) -> bool {
|
||||
let effect = horizon_client_core::potions::namespaced(effect);
|
||||
let refused = match self.species() {
|
||||
Species::Spider | Species::CaveSpider | Species::Nautilus | Species::ZombieNautilus => {
|
||||
effect == "minecraft:poison"
|
||||
}
|
||||
Species::Wither | Species::WitherSkeleton => effect == "minecraft:wither",
|
||||
Species::Parched => effect == "minecraft:weakness",
|
||||
Species::Silverfish => effect == "minecraft:infested",
|
||||
Species::Slime => effect == "minecraft:oozing",
|
||||
_ => false,
|
||||
};
|
||||
if refused {
|
||||
return false;
|
||||
}
|
||||
!(self.kind.flags.undead && matches!(effect, "minecraft:poison" | "minecraft:regeneration"))
|
||||
}
|
||||
pub fn add_effect(&mut self, effect: &'static str, ticks: i32, amplifier: i32) {
|
||||
let effect = horizon_client_core::potions::namespaced(effect);
|
||||
// LivingEntity.addEffect: an effect the mob refuses is never added, so it
|
||||
// never joins the list the client draws or an effect tick reads.
|
||||
if !self.can_be_affected(effect) {
|
||||
return;
|
||||
}
|
||||
// AbsorptionMobEffect.addAttributeModifiers: at least 4 hearts-halves per level.
|
||||
if effect == "minecraft:absorption"
|
||||
&& self
|
||||
@@ -3561,6 +3600,11 @@ impl Mob {
|
||||
if self.species() == Species::HappyGhast {
|
||||
return 1.0;
|
||||
}
|
||||
// Parrot.getVoicePitch: `getPitch(random)`, the plain range, so a baby
|
||||
// parrot does not take the 1.5 that AgeableMob raises a baby by.
|
||||
if self.species() == Species::Parrot {
|
||||
return (self.random.next_float() - self.random.next_float()) * 0.2 + 1.0;
|
||||
}
|
||||
// ZombieVillager.getVoicePitch: babies are 2.0, not 1.5.
|
||||
let baby = if self.species() == Species::ZombieVillager {
|
||||
2.0
|
||||
@@ -4883,6 +4927,94 @@ mod totem_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod effect_refusal_tests {
|
||||
use super::*;
|
||||
|
||||
/// A mob of a real type, from the extracted data (`entity-types.json`), so the
|
||||
/// `undead`, `MobCategory` and species facts are the ones the game has.
|
||||
fn mob(types: &MobTypes, id: &str) -> Mob {
|
||||
let kind = Arc::new(types.types.get(id).expect(id).clone());
|
||||
Mob::new(1, kind, Vec3::new(0.0, 64.0, 0.0), 0.0)
|
||||
}
|
||||
/// `LivingEntity.addEffect` asks `canBeAffected` before it stores anything: an
|
||||
/// effect the mob's type refuses never joins the list, so the cloud on its head,
|
||||
/// the `/effect` reply and an effect tick never see it. The refused ones are the
|
||||
/// `ignores_poison_and_regen` tag (which is `#minecraft:undead`), a silverfish's
|
||||
/// infested and a slime's oozing, plus the five classes that override the rule.
|
||||
#[test]
|
||||
fn an_effect_the_mobs_type_refuses_is_never_added() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("entity-types.json").exists() {
|
||||
return;
|
||||
}
|
||||
let types = MobTypes::load_for_server(&root).unwrap();
|
||||
let refused = |id: &str, effect: &'static str| {
|
||||
let mut m = mob(&types, id);
|
||||
m.add_effect(effect, 200, 0);
|
||||
let listed = m.has_effect(effect);
|
||||
(listed, m.extra.effects.len())
|
||||
};
|
||||
// `#minecraft:undead` is the `ignores_poison_and_regen` tag: a zombie, a
|
||||
// skeleton and a phantom turn both poison and regeneration away.
|
||||
for undead in [
|
||||
"minecraft:zombie",
|
||||
"minecraft:skeleton",
|
||||
"minecraft:phantom",
|
||||
"minecraft:zombie_horse",
|
||||
"minecraft:wither",
|
||||
] {
|
||||
for effect in ["minecraft:poison", "minecraft:regeneration"] {
|
||||
assert_eq!(refused(undead, effect), (false, 0), "{undead} {effect}");
|
||||
}
|
||||
// Every other effect still lands on it.
|
||||
assert_eq!(refused(undead, "minecraft:strength"), (true, 1), "{undead}");
|
||||
}
|
||||
// The tag's other two effects, each refused by the one type in it.
|
||||
assert_eq!(
|
||||
refused("minecraft:silverfish", "minecraft:infested"),
|
||||
(false, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
refused("minecraft:silverfish", "minecraft:poison"),
|
||||
(true, 1)
|
||||
);
|
||||
assert_eq!(refused("minecraft:slime", "minecraft:oozing"), (false, 0));
|
||||
// A magma cube is not in `immune_to_oozing`, so oozing takes.
|
||||
assert_eq!(
|
||||
refused("minecraft:magma_cube", "minecraft:oozing"),
|
||||
(true, 1)
|
||||
);
|
||||
// Spider.canBeAffected and CaveSpider's inheritance: poison is refused, and a
|
||||
// nautilus refuses it too (AbstractNautilus.canBeAffected), the zombie one
|
||||
// through the same override, an ordinary mob not at all.
|
||||
for spider in [
|
||||
"minecraft:spider",
|
||||
"minecraft:cave_spider",
|
||||
"minecraft:nautilus",
|
||||
] {
|
||||
assert_eq!(refused(spider, "minecraft:poison"), (false, 0), "{spider}");
|
||||
}
|
||||
assert_eq!(refused("minecraft:cow", "minecraft:poison"), (true, 1));
|
||||
// WitherBoss.canBeAffected and WitherSkeleton.canBeAffected: the wither
|
||||
// effect only, which a zombie takes.
|
||||
for wither in ["minecraft:wither", "minecraft:wither_skeleton"] {
|
||||
assert_eq!(refused(wither, "minecraft:wither"), (false, 0), "{wither}");
|
||||
}
|
||||
assert_eq!(refused("minecraft:zombie", "minecraft:wither"), (true, 1));
|
||||
// Parched.canBeAffected: weakness only, slowness takes.
|
||||
assert_eq!(
|
||||
refused("minecraft:parched", "minecraft:weakness"),
|
||||
(false, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
refused("minecraft:parched", "minecraft:slowness"),
|
||||
(true, 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
crate::impl_joined!(Mob);
|
||||
#[cfg(test)]
|
||||
mod silent_voice_tests {
|
||||
|
||||
@@ -18,6 +18,8 @@ use horizon_core::{
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
|
||||
use crate::powder_snow::particle_velocity as powder_snow_particle_velocity;
|
||||
|
||||
/// The inside-block effects of fire and lava (`InsideBlockEffectType`
|
||||
/// FIRE_IGNITE and LAVA_IGNITE): ignite, then hurt.
|
||||
fn block_contact_damage(m: &mut Mob, w: &MobWorld, fire: f32) {
|
||||
@@ -837,6 +839,16 @@ fn effects_from_blocks(
|
||||
m.fall_distance = 0.0;
|
||||
m.stuck_multiplier =
|
||||
Vec3::new(f64::from(0.9_f32), 1.5, f64::from(0.9_f32));
|
||||
let known_movement =
|
||||
w.controlling_player_movement.unwrap_or(m.velocity);
|
||||
if let Some(velocity) =
|
||||
powder_snow_particle_velocity(known_movement, level_random)
|
||||
{
|
||||
events.push(MobEvent::PowderSnowParticle {
|
||||
at: [m.position.x, f64::from(y + 1), m.position.z],
|
||||
velocity,
|
||||
});
|
||||
}
|
||||
}
|
||||
"minecraft:honey_block" => honey_slide(m, p, level_random, events),
|
||||
_ => continue,
|
||||
@@ -846,6 +858,69 @@ fn effects_from_blocks(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod powder_snow_particle_tests {
|
||||
use super::*;
|
||||
|
||||
/// A ridden mount uses the controlling player's known movement for the
|
||||
/// powder-snow roll, even when the mount's own delta is zero.
|
||||
#[test]
|
||||
#[ignore = "requires prepared vanilla resources in .local/horizon-client"]
|
||||
fn a_controlled_mount_uses_its_riders_known_movement() {
|
||||
let seed = (0..1000)
|
||||
.find(|seed| {
|
||||
let mut random = LegacyRandom::new(*seed);
|
||||
random.next_boolean()
|
||||
})
|
||||
.unwrap();
|
||||
let player_movement = Vec3::new(0.2, 0.0, 0.0);
|
||||
let mut expected_random = LegacyRandom::new(seed);
|
||||
let expected =
|
||||
powder_snow_particle_velocity(player_movement, &mut expected_random).unwrap();
|
||||
|
||||
let ran = crate::mobs::world::with_built_test_world(
|
||||
&[],
|
||||
|world, registry| {
|
||||
for cx in -1..1 {
|
||||
for cz in -1..1 {
|
||||
world.chunks.insert(
|
||||
(cx, cz),
|
||||
std::sync::Arc::new(crate::chunk::Chunk::empty(cx, cz)),
|
||||
);
|
||||
}
|
||||
}
|
||||
world
|
||||
.set(
|
||||
0,
|
||||
64,
|
||||
0,
|
||||
registry.default_state("minecraft:powder_snow").unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
},
|
||||
|w, types| {
|
||||
let mut w = w.clone();
|
||||
w.controlling_player_movement = Some(player_movement);
|
||||
let kind = std::sync::Arc::new(types.types["minecraft:horse"].clone());
|
||||
let mut horse = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
horse.extra.saddled = true;
|
||||
horse.rider = Some(crate::mobs::equine::Rider::default());
|
||||
let mut random = LegacyRandom::new(seed);
|
||||
let mut events = Vec::new();
|
||||
|
||||
effects_from_blocks(&mut horse, &w, &mut random, &mut events);
|
||||
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
MobEvent::PowderSnowParticle { velocity, .. } if *velocity == expected
|
||||
)));
|
||||
},
|
||||
);
|
||||
assert!(ran, "the vanilla test resources must be available");
|
||||
}
|
||||
}
|
||||
|
||||
/// `HoneyBlock.entityInside` for a mob: a falling mob beside the honey block at
|
||||
/// `p` slides down it (`isSlidingDown`, `doSlideMovement`, `maybeDoSlideEffects`).
|
||||
fn honey_slide(
|
||||
@@ -2630,13 +2705,9 @@ fn flower_contact_effects(m: &mut Mob, w: &MobWorld) {
|
||||
"minecraft:wither_rose" => {
|
||||
// World difficulty, not the regional one, and
|
||||
// isInvulnerableTo(wither) (the invulnerable
|
||||
// flag, not the hit timer); WitherBoss and
|
||||
// WitherSkeleton refuse the effect
|
||||
// (`canBeAffected`).
|
||||
if w.difficulty.base != 0
|
||||
&& !m.invulnerable_flag
|
||||
&& !matches!(m.species(), Species::Wither | Species::WitherSkeleton)
|
||||
{
|
||||
// flag, not the hit timer); a wither and a wither
|
||||
// skeleton refuse the effect in `addEffect`.
|
||||
if w.difficulty.base != 0 && !m.invulnerable_flag {
|
||||
m.add_effect("minecraft:wither", 40, 0);
|
||||
}
|
||||
}
|
||||
@@ -2742,17 +2813,24 @@ fn tick_effects(m: &mut Mob, mods: Option<&dyn super::ModEffectTicks>) {
|
||||
.retain(|h| m.extra.effects.iter().any(|e| e.0 == *h));
|
||||
}
|
||||
|
||||
/// `Entity.getMovementEmission` is not NONE (silverfish, endermites,
|
||||
/// shulkers, guardians and breezes emit only events, no step sounds).
|
||||
/// `Entity.getMovementEmission` is not NONE: only a shulker moves without
|
||||
/// emitting anything at all, neither events nor sounds.
|
||||
pub fn emits_movement(m: &Mob) -> bool {
|
||||
!matches!(m.species(), Species::Shulker)
|
||||
}
|
||||
/// The movement emission includes sounds.
|
||||
/// `Entity.getMovementEmission` includes sounds (`MovementEmission.ALL`): a
|
||||
/// silverfish, an endermite, a bat, a squid and a glow squid, a guardian, an
|
||||
/// elder guardian and a breeze answer `EVENTS`, which keeps their footfalls and
|
||||
/// swim strokes quiet while `Entity.vibrationAndSoundEffectsFromBlock` still
|
||||
/// sends the step game event. Every other type is `ALL`.
|
||||
pub fn movement_sounds(m: &Mob) -> bool {
|
||||
!matches!(
|
||||
m.species(),
|
||||
Species::Silverfish
|
||||
| Species::Endermite
|
||||
| Species::Bat
|
||||
| Species::Squid
|
||||
| Species::GlowSquid
|
||||
| Species::Guardian
|
||||
| Species::ElderGuardian
|
||||
| Species::Breeze
|
||||
@@ -2769,9 +2847,10 @@ pub fn step_sound(
|
||||
let baby = m.is_baby();
|
||||
let quiet = |s: &'static str| Some((s, 0.15, 1.0));
|
||||
match m.species() {
|
||||
// Silent footsteps, or only movement events.
|
||||
// `playStepSound` overridden with an empty body: a fish, an allay, a bee,
|
||||
// a happy ghast and a nautilus have no footfall of their own, so the
|
||||
// block's step sound is not taken either.
|
||||
S::Allay
|
||||
| S::Bat
|
||||
| S::Bee
|
||||
| S::Cod
|
||||
| S::Salmon
|
||||
@@ -2780,12 +2859,7 @@ pub fn step_sound(
|
||||
| S::Tadpole
|
||||
| S::HappyGhast
|
||||
| S::Nautilus
|
||||
| S::ZombieNautilus
|
||||
| S::Silverfish
|
||||
| S::Endermite
|
||||
| S::Guardian
|
||||
| S::ElderGuardian
|
||||
| S::Breeze => None,
|
||||
| S::ZombieNautilus => None,
|
||||
S::Armadillo => quiet("entity.armadillo.step"),
|
||||
S::Chicken | S::Cow | S::Pig | S::Wolf => {
|
||||
super::finalize::variant_sound(m, "step").and_then(quiet)
|
||||
@@ -2813,6 +2887,11 @@ pub fn step_sound(
|
||||
S::Parched => quiet("entity.parched.step"),
|
||||
S::WitherSkeleton => quiet("entity.wither_skeleton.step"),
|
||||
S::Spider | S::CaveSpider => quiet("entity.spider.step"),
|
||||
// Silverfish.playStepSound and Endermite.playStepSound: both ask for
|
||||
// their own step at 15%, though neither type's movement emission plays
|
||||
// it (`movement_sounds`).
|
||||
S::Silverfish => quiet("entity.silverfish.step"),
|
||||
S::Endermite => quiet("entity.endermite.step"),
|
||||
S::Zombie | S::ZombifiedPiglin => quiet("entity.zombie.step"),
|
||||
S::Husk => quiet("entity.husk.step"),
|
||||
S::Drowned => quiet("entity.drowned.step"),
|
||||
|
||||
@@ -535,6 +535,7 @@ mod golem_touch_tests {
|
||||
sleeping: false,
|
||||
sleep_timer: 0,
|
||||
passenger: false,
|
||||
riding_vehicle: None,
|
||||
vehicle: None,
|
||||
});
|
||||
let mut slime = Mob::new(
|
||||
|
||||
@@ -857,12 +857,17 @@ impl Mob {
|
||||
&& (self.on_ground || climbable)
|
||||
&& !self.fluids_swimming();
|
||||
if produced {
|
||||
// `getMovementEmission().emitsSounds()`: the footfall is played
|
||||
// only for a type whose emission is `ALL`, while the step game
|
||||
// event below is sent by `EVENTS` types too.
|
||||
let heard = super::ai::movement_sounds(self);
|
||||
if super::equine::hoof_steps(self.species()) {
|
||||
if !self.silent {
|
||||
if heard && !self.silent {
|
||||
super::equine::play_step(self, w, legacy, legacy_state, events);
|
||||
}
|
||||
} else if let Some((sound, volume, pitch)) =
|
||||
super::ai::step_sound(self, w, legacy, legacy_state)
|
||||
&& heard
|
||||
&& !self.silent
|
||||
{
|
||||
let at = [self.position.x, self.position.y, self.position.z];
|
||||
@@ -1909,6 +1914,7 @@ mod tests {
|
||||
collision: &collision,
|
||||
data: &data,
|
||||
player: None,
|
||||
controlling_player_movement: None,
|
||||
nearest_player: None,
|
||||
others: &[],
|
||||
dragon: None,
|
||||
@@ -2021,6 +2027,7 @@ mod tests {
|
||||
collision: &collision,
|
||||
data: &data,
|
||||
player: None,
|
||||
controlling_player_movement: None,
|
||||
nearest_player: None,
|
||||
others: &[],
|
||||
dragon: None,
|
||||
@@ -2508,4 +2515,68 @@ mod tests {
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `Entity.applyMovementEmissionAndPlaySound` asks `getMovementEmission`:
|
||||
/// a squid, a glow squid and a bat answer `EVENTS`, so a beached squid
|
||||
/// flopping across stone sends the `minecraft:step` game event and no
|
||||
/// sound, where a sheep walking the same block is heard at the block's
|
||||
/// step volume times 0.15. A silverfish's own `playStepSound` names
|
||||
/// `entity.silverfish.step` and is never heard either, and a shulker, whose
|
||||
/// emission is `NONE`, sends nothing at all.
|
||||
#[test]
|
||||
fn a_squid_and_a_bat_step_without_a_sound() {
|
||||
let build = |world: &mut crate::world::World,
|
||||
registry: &horizon_world::registry::BlockRegistry| {
|
||||
world.chunks.insert(
|
||||
(0, 0),
|
||||
std::sync::Arc::new(crate::chunk::Chunk::empty(0, 0)),
|
||||
);
|
||||
let id = registry.default_state("minecraft:stone").unwrap();
|
||||
for x in 0..=8 {
|
||||
for z in 0..=8 {
|
||||
world.set(x, 0, z, id).unwrap();
|
||||
}
|
||||
}
|
||||
};
|
||||
crate::mobs::world::with_built_test_world(&[], build, |w, types| {
|
||||
// One step's worth of walking: 2 blocks times the 0.6 factor, over
|
||||
// the 1.0 the first step waits for.
|
||||
let walk = |id: &str| {
|
||||
let kind = std::sync::Arc::new(types.types[id].clone());
|
||||
let mut m = Mob::new(1, kind, Vec3::new(0.5, 1.0, 0.5), 0.0);
|
||||
m.on_ground = true;
|
||||
m.supporting = Some([0, 0, 0]);
|
||||
let mut events = Vec::new();
|
||||
m.movement_emission(w, Vec3::new(2.0, 0.0, 0.0), &mut events);
|
||||
events
|
||||
};
|
||||
let heard = |events: &[MobEvent]| {
|
||||
events.iter().find_map(|e| match e {
|
||||
MobEvent::Sound(sound, _, volume, pitch) => Some((*sound, *volume, *pitch)),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let stepped = |events: &[MobEvent]| {
|
||||
events
|
||||
.iter()
|
||||
.any(|e| matches!(e, MobEvent::GameEvent { event, .. } if *event == "minecraft:step"))
|
||||
};
|
||||
for quiet in ["minecraft:squid", "minecraft:glow_squid", "minecraft:bat"] {
|
||||
let events = walk(quiet);
|
||||
assert_eq!(heard(&events), None, "{quiet} is silent");
|
||||
assert!(stepped(&events), "{quiet} still sends the step event");
|
||||
}
|
||||
// A silverfish asks for its own step sound and its type's emission
|
||||
// never plays it.
|
||||
let events = walk("minecraft:silverfish");
|
||||
assert_eq!(heard(&events), None);
|
||||
assert!(stepped(&events));
|
||||
// The same walk from a type whose emission is ALL is heard.
|
||||
let events = walk("minecraft:sheep");
|
||||
assert_eq!(heard(&events), Some(("entity.sheep.step", 0.15, 1.0)));
|
||||
// Shulker.getMovementEmission is NONE: not even the event.
|
||||
let events = walk("minecraft:shulker");
|
||||
assert_eq!(heard(&events), None);
|
||||
assert!(!stepped(&events), "a shulker emits nothing");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -314,6 +314,34 @@ mod tests {
|
||||
assert!(m.ai.goals.has("ParrotWander"));
|
||||
}
|
||||
|
||||
/// `Parrot.getVoicePitch` is `getPitch(random)`, the plain 1.0 ± 0.2, and
|
||||
/// `getPitch` takes no age into account: a baby parrot does not get the
|
||||
/// 1.5 that `LivingEntity.getVoicePitch` raises every other baby's voice
|
||||
/// by. A baby chicken is the control.
|
||||
#[test]
|
||||
fn a_baby_parrot_keeps_the_plain_voice_pitch() {
|
||||
let mut parrot = parrot();
|
||||
parrot.extra.age = -24_000;
|
||||
assert!(parrot.is_baby());
|
||||
let mut baby = parrot.clone();
|
||||
let pitches: Vec<f32> = (0..64).map(|_| baby.voice_pitch()).collect();
|
||||
assert!(
|
||||
pitches.iter().all(|p| (0.8..=1.2).contains(p)),
|
||||
"{pitches:?}"
|
||||
);
|
||||
// It is not a constant: the two draws move it around 1.0.
|
||||
assert!(pitches.iter().any(|&p| p != pitches[0]));
|
||||
assert!((pitches.iter().sum::<f32>() / pitches.len() as f32 - 1.0).abs() < 0.1);
|
||||
|
||||
let mut k = (*kind()).clone();
|
||||
k.id = "minecraft:chicken".into();
|
||||
k.species = Species::Chicken;
|
||||
let mut chicken = Mob::new(1, Arc::new(k), Vec3::ZERO, 0.0);
|
||||
chicken.extra.age = -24_000;
|
||||
let raised: Vec<f32> = (0..64).map(|_| chicken.voice_pitch()).collect();
|
||||
assert!(raised.iter().all(|p| (1.3..=1.7).contains(p)), "{raised:?}");
|
||||
}
|
||||
|
||||
/// `Parrot.calculateFlapping`: in the air the wings beat and a fall is
|
||||
/// slowed to 0.6; on the ground or a shoulder they fold.
|
||||
#[test]
|
||||
|
||||
@@ -465,6 +465,7 @@ pub(crate) fn with_resource_test_world(
|
||||
collision: &collision,
|
||||
data: &data,
|
||||
player: None,
|
||||
controlling_player_movement: None,
|
||||
nearest_player: None,
|
||||
others,
|
||||
dragon: None,
|
||||
@@ -533,6 +534,7 @@ pub(crate) fn test_player(position: Vec3) -> super::PlayerView {
|
||||
sleeping: false,
|
||||
sleep_timer: 0,
|
||||
passenger: false,
|
||||
riding_vehicle: None,
|
||||
vehicle: None,
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,7 @@ mod tests {
|
||||
collision: &collision,
|
||||
data: &data,
|
||||
player: None,
|
||||
controlling_player_movement: None,
|
||||
nearest_player: None,
|
||||
others: &[],
|
||||
dragon: None,
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::sync::OnceLock;
|
||||
pub struct NativeClientHooks {
|
||||
pub menu_slot_rules: fn(&str, usize, &ItemStack, &mut bool, &mut u32),
|
||||
pub living_heal: fn(u32, &mut f32, &mut bool),
|
||||
pub player_chat: fn(u128, &str, &str, &mut Vec<horizon_client_core::text::Text>, &mut bool),
|
||||
pub living_breathe: fn(u32, &str, &mut bool, &mut i32, &mut i32),
|
||||
pub living_drown: fn(u32, &str, &mut bool, &mut f32, i32, &mut bool),
|
||||
pub living_fall: fn(u32, &str, &mut f64, &mut f32, &mut bool),
|
||||
@@ -34,6 +35,18 @@ pub(crate) fn menu_slot_rules(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn player_chat(
|
||||
player: u128,
|
||||
name: &str,
|
||||
message: &str,
|
||||
replies: &mut Vec<horizon_client_core::text::Text>,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
if let Some(hooks) = HOOKS.get() {
|
||||
(hooks.player_chat)(player, name, message, replies, cancel);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn living_heal(entity: u32, amount: &mut f32, cancel: &mut bool) {
|
||||
if let Some(hooks) = HOOKS.get() {
|
||||
(hooks.living_heal)(entity, amount, cancel);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! `PowderSnowBlock.entityInside`, shared by living and other entities.
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3, sweep_intersected_blocks},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
|
||||
/// Living entities must have their feet in snow; other entities only need
|
||||
/// their swept box to touch it. Preserve callback order and visit each cell once.
|
||||
pub fn contacts(
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
bounds: Aabb,
|
||||
living: bool,
|
||||
mut is_snow: impl FnMut(BlockPos) -> bool,
|
||||
) -> Vec<BlockPos> {
|
||||
if living && !is_snow(BlockPos::containing(to)) {
|
||||
return Vec::new();
|
||||
}
|
||||
let epsilon = f64::from(1.0e-5_f32);
|
||||
let bounds = bounds.deflate(Vec3::new(epsilon, epsilon, epsilon));
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
let mut contacts = Vec::new();
|
||||
let _ = sweep_intersected_blocks(from, to, bounds, 1_000_000, |p, step| {
|
||||
if step >= 16 {
|
||||
return false;
|
||||
}
|
||||
if visited.insert(p) && is_snow(p) {
|
||||
contacts.push(p);
|
||||
}
|
||||
true
|
||||
});
|
||||
contacts
|
||||
}
|
||||
|
||||
/// The server's movement gate and Boolean/float draws, in Java order.
|
||||
pub fn particle_velocity(known_movement: Vec3, random: &mut impl RandomSource) -> Option<[f64; 3]> {
|
||||
if (known_movement.x == 0.0 && known_movement.z == 0.0) || !random.next_boolean() {
|
||||
return None;
|
||||
}
|
||||
let x = (random.next_float() * 2.0 - 1.0) * 0.083_333_336_f32;
|
||||
let z = (random.next_float() * 2.0 - 1.0) * 0.083_333_336_f32;
|
||||
Some([f64::from(x), f64::from(0.05_f32), f64::from(z)])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn swept_contacts_cover_nonliving_entities_and_gate_living_feet() {
|
||||
let from = Vec3::new(0.5, 0.0, 0.5);
|
||||
let to = Vec3::new(3.5, 0.0, 0.5);
|
||||
let bounds = Aabb::new(Vec3::new(3.25, 0.0, 0.25), Vec3::new(3.75, 0.5, 0.75));
|
||||
let snow = BlockPos::new(1, 0, 0);
|
||||
assert_eq!(contacts(from, to, bounds, false, |p| p == snow), vec![snow]);
|
||||
assert!(contacts(from, to, bounds, true, |p| p == snow).is_empty());
|
||||
let feet = BlockPos::new(3, 0, 0);
|
||||
assert_eq!(contacts(to, to, bounds, true, |p| p == feet), vec![feet]);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The player's RAM limit: from the launcher's slider at the start (`HORIZON_MEMORY_LIMIT_MB`), and
|
||||
//! `/ramlimit` while the game runs. The window answers that command itself, so it works on any
|
||||
//! server. On Windows the system holds the game under the limit (`horizon_threads::memory_limit`).
|
||||
//! Under pressure, [`Governor`] turns far terrain off and lowers the render distance in use, never
|
||||
//! server. Under pressure, before the game reaches the limit (`horizon_threads::memory_limit`),
|
||||
//! on every system, [`Governor`] turns far terrain off and lowers the render distance in use, never
|
||||
//! the option the player saved, and gives them back when there is room. Only a game that can't run
|
||||
//! under the limit at all ends with a crash report.
|
||||
use horizon_threads::capped;
|
||||
@@ -127,17 +127,8 @@ pub fn used() -> Option<u64> {
|
||||
memory_limit::resident_bytes()
|
||||
}
|
||||
|
||||
/// See [`memory_limit::frame`].
|
||||
pub fn frame() {
|
||||
memory_limit::frame();
|
||||
}
|
||||
|
||||
/// A game this slow, faulting hard under a cap the system enforces, is short of memory.
|
||||
const SLOW_FPS: f32 = 30.;
|
||||
|
||||
/// See [`memory_limit::pressure`]. Under a cap the system enforces the game is already under the
|
||||
/// limit, so nothing the player sees goes unless the game is slow as well.
|
||||
pub fn pressure(limit: u64, fps: f32) -> Pressure {
|
||||
/// See [`memory_limit::pressure`].
|
||||
pub fn pressure(limit: u64) -> Pressure {
|
||||
if capped::limit().is_some() {
|
||||
// A hard ceiling is a budget of its own: what the process commits
|
||||
// decides, and a smooth frame does not excuse holding more. Shedding
|
||||
@@ -149,15 +140,7 @@ pub fn pressure(limit: u64, fps: f32) -> Pressure {
|
||||
memory_limit::pressure_of_commit(commit, limit)
|
||||
});
|
||||
}
|
||||
let pressure = memory_limit::pressure(limit);
|
||||
needed(pressure, memory_limit::system_enforced(), fps)
|
||||
}
|
||||
|
||||
fn needed(pressure: Pressure, enforced: bool, fps: f32) -> Pressure {
|
||||
match pressure {
|
||||
Pressure::Over { .. } if enforced && fps >= SLOW_FPS => Pressure::Steady,
|
||||
pressure => pressure,
|
||||
}
|
||||
memory_limit::pressure(limit)
|
||||
}
|
||||
|
||||
/// See [`memory_limit::can_shrink`].
|
||||
@@ -302,19 +285,6 @@ pub fn run(command: &str) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn under_a_cap_the_system_enforces_only_a_slow_game_gives_anything_up() {
|
||||
const OVER: Pressure = Pressure::Over { keep: 0.8 };
|
||||
assert_eq!(needed(OVER, true, 200.), Pressure::Steady);
|
||||
assert_eq!(needed(OVER, true, 12.), OVER);
|
||||
assert_eq!(
|
||||
needed(OVER, false, 200.),
|
||||
OVER,
|
||||
"nothing else holds this game under"
|
||||
);
|
||||
assert_eq!(needed(Pressure::Room, true, 12.), Pressure::Room);
|
||||
}
|
||||
|
||||
const FAR_OVER: Pressure = Pressure::Over { keep: 0.125 };
|
||||
const OVER: Pressure = Pressure::Over { keep: 0.8 };
|
||||
|
||||
|
||||
@@ -7689,3 +7689,245 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Far-terrain floating-cell census on a real save: `HORIZON_TEST_RESOURCES`
|
||||
/// (prepared data), `HORIZON_FAR_SAVE` (a world folder), `HORIZON_FAR_SEED`
|
||||
/// and `HORIZON_FAR_AT` ("x,z", the camera). Samples the tiles each level
|
||||
/// draws around the camera as the far sampler gives them (saved chunks up to
|
||||
/// 16 blocks a cell, the seed beyond) and counts components of drawn cells
|
||||
/// that touch neither the world's bottom nor the tile's halo.
|
||||
#[cfg(test)]
|
||||
mod far_census {
|
||||
use super::*;
|
||||
use crate::lod::Column;
|
||||
use crate::streaming::ChunkGenerator;
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
struct Census {
|
||||
tiles: usize,
|
||||
floating: usize,
|
||||
cells: usize,
|
||||
kinds: std::collections::BTreeMap<&'static str, usize>,
|
||||
water_over_air: usize,
|
||||
}
|
||||
|
||||
fn kind(name: &str) -> &'static str {
|
||||
if name.ends_with("_leaves") {
|
||||
"leaves"
|
||||
} else if name.ends_with("_log") || name.ends_with("_wood") || name.contains("stem") {
|
||||
"log"
|
||||
} else if name == "minecraft:water" || name == "minecraft:lava" {
|
||||
"fluid"
|
||||
} else if name.contains("snow") || name.contains("ice") {
|
||||
"snow/ice"
|
||||
} else {
|
||||
"ground"
|
||||
}
|
||||
}
|
||||
|
||||
/// `columns`: `side` x `side`, the outer ring a halo.
|
||||
fn count(
|
||||
columns: &[Column],
|
||||
side: usize,
|
||||
name: &dyn Fn(horizon_world::registry::BlockStateId) -> String,
|
||||
out: &mut Census,
|
||||
origin: (i32, i32, i32),
|
||||
) {
|
||||
// Runs of touching spans, per column: (min, max, top state).
|
||||
let runs: Vec<Vec<(i16, i16, horizon_world::registry::BlockStateId)>> = columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
let mut runs: Vec<(i16, i16, _)> = Vec::new();
|
||||
for span in column.spans.iter().filter(|s| s.min < s.max) {
|
||||
match runs.last_mut() {
|
||||
Some(run) if run.1 == span.min => {
|
||||
run.1 = span.max;
|
||||
run.2 = span.state;
|
||||
}
|
||||
_ => runs.push((span.min, span.max, span.state)),
|
||||
}
|
||||
}
|
||||
runs
|
||||
})
|
||||
.collect();
|
||||
let mut first = Vec::with_capacity(runs.len());
|
||||
let mut total = 0;
|
||||
for column in &runs {
|
||||
first.push(total);
|
||||
total += column.len();
|
||||
}
|
||||
let mut parent: Vec<usize> = (0..total).collect();
|
||||
fn find(parent: &mut [usize], mut i: usize) -> usize {
|
||||
while parent[i] != i {
|
||||
parent[i] = parent[parent[i]];
|
||||
i = parent[i];
|
||||
}
|
||||
i
|
||||
}
|
||||
let union = |parent: &mut Vec<usize>, a: usize, b: usize| {
|
||||
let (a, b) = (find(parent, a), find(parent, b));
|
||||
if a != b {
|
||||
parent[a] = b;
|
||||
}
|
||||
};
|
||||
for z in 0..side {
|
||||
for x in 0..side {
|
||||
let i = z * side + x;
|
||||
for j in [
|
||||
(x + 1 < side).then(|| i + 1),
|
||||
(z + 1 < side).then(|| i + side),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
for (a, &(lo, hi, _)) in runs[i].iter().enumerate() {
|
||||
for (b, &(other_lo, other_hi, _)) in runs[j].iter().enumerate() {
|
||||
// Cells meeting at an edge (an acacia's
|
||||
// diagonal branch) look joined too.
|
||||
if lo <= other_hi && other_lo <= hi {
|
||||
union(&mut parent, first[i] + a, first[j] + b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Per component: grounded, touches the halo, cells, top state.
|
||||
let mut grounded = vec![false; total];
|
||||
let mut halo = vec![false; total];
|
||||
let mut cells = vec![0usize; total];
|
||||
let mut top = vec![None; total];
|
||||
let mut at = vec![(0, 0); total];
|
||||
for z in 0..side {
|
||||
for x in 0..side {
|
||||
let i = z * side + x;
|
||||
let ring = x == 0 || z == 0 || x + 1 == side || z + 1 == side;
|
||||
for (a, &(lo, hi, state)) in runs[i].iter().enumerate() {
|
||||
let root = find(&mut parent, first[i] + a);
|
||||
grounded[root] |= i32::from(lo) <= crate::chunk::MIN_Y;
|
||||
halo[root] |= ring;
|
||||
cells[root] += 1;
|
||||
if top[root].is_none_or(|(y, _)| hi > y) {
|
||||
top[root] = Some((hi, state));
|
||||
at[root] = (
|
||||
origin.0 + x as i32 * origin.2,
|
||||
origin.1 + z as i32 * origin.2,
|
||||
);
|
||||
}
|
||||
}
|
||||
if !ring {
|
||||
let spans = &columns[i].spans;
|
||||
for (k, span) in spans.iter().enumerate() {
|
||||
let n = name(span.state);
|
||||
if kind(&n) == "fluid" && (k == 0 || spans[k - 1].max < span.min) {
|
||||
out.water_over_air += 1;
|
||||
if std::env::var_os("HORIZON_FAR_DEBUG").is_some() {
|
||||
println!(
|
||||
" fluid over air at {} {} y {}..{} {n}",
|
||||
origin.0 + x as i32 * origin.2,
|
||||
origin.1 + z as i32 * origin.2,
|
||||
span.min,
|
||||
span.max
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for root in 0..total {
|
||||
if find(&mut parent, root) != root || grounded[root] || halo[root] {
|
||||
continue;
|
||||
}
|
||||
out.floating += 1;
|
||||
out.cells += cells[root];
|
||||
let (y, state) = top[root].unwrap();
|
||||
*out.kinds.entry(kind(&name(state))).or_default() += 1;
|
||||
if std::env::var_os("HORIZON_FAR_DEBUG").is_some() {
|
||||
println!(
|
||||
" floating at {:?} top {y} {} ({} cells)",
|
||||
at[root],
|
||||
name(state),
|
||||
cells[root]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared data and a save (see the module docs)"]
|
||||
fn far_floating_cells_per_level() {
|
||||
let var = |name: &str| std::env::var(name).unwrap_or_else(|_| panic!("{name}"));
|
||||
let root = PathBuf::from(var("HORIZON_TEST_RESOURCES"));
|
||||
let world = PathBuf::from(var("HORIZON_FAR_SAVE"));
|
||||
let seed: i64 = var("HORIZON_FAR_SEED").parse().unwrap();
|
||||
let at: Vec<i32> = var("HORIZON_FAR_AT")
|
||||
.split(',')
|
||||
.map(|n| n.trim().parse().unwrap())
|
||||
.collect();
|
||||
let assets = Arc::new(crate::assets::Assets::load(&root).expect("assets"));
|
||||
let biomes = Arc::new(Biomes::load(&root).expect("biomes"));
|
||||
let registry = assets.registry;
|
||||
let terrain = Arc::new(
|
||||
crate::vanillagen::VanillaTerrain::new(&root.join("worldgen"), registry, &biomes, seed)
|
||||
.expect("terrain"),
|
||||
);
|
||||
let save = Arc::new(SaveDir::new(
|
||||
world.join("dimensions/minecraft/overworld"),
|
||||
LevelShape::OVERWORLD,
|
||||
));
|
||||
let generator = Arc::new(Persistent {
|
||||
inner: terrain,
|
||||
save: save.clone(),
|
||||
registry: assets.clone(),
|
||||
biomes: biomes.clone(),
|
||||
});
|
||||
let mut sampler = ChunkGenerator::lod_sampler(generator);
|
||||
let name = |state| {
|
||||
registry
|
||||
.state(state)
|
||||
.map_or_else(String::new, |s| s.name.to_string())
|
||||
};
|
||||
let limit: usize = std::env::var("HORIZON_FAR_TILES")
|
||||
.ok()
|
||||
.and_then(|n| n.parse().ok())
|
||||
.unwrap_or(48);
|
||||
for level in 1..=6u8 {
|
||||
let size = 16 << level;
|
||||
let width = size / 16;
|
||||
// The ring this level is drawn in (`Plan::visit` splits a tile
|
||||
// closer than 8 of its widths).
|
||||
let (near, far) = (8 * size, 16 * size);
|
||||
let mut tiles = Vec::new();
|
||||
for tz in (at[1] - far).div_euclid(size)..=(at[1] + far).div_euclid(size) {
|
||||
for tx in (at[0] - far).div_euclid(size)..=(at[0] + far).div_euclid(size) {
|
||||
let (cx, cz) = (tx * size + size / 2, tz * size + size / 2);
|
||||
let d = (((cx - at[0]) as f64).powi(2) + ((cz - at[1]) as f64).powi(2)).sqrt();
|
||||
if d >= f64::from(near) && d < f64::from(far) {
|
||||
tiles.push((tx, tz));
|
||||
}
|
||||
}
|
||||
}
|
||||
let step = tiles.len().div_ceil(limit).max(1);
|
||||
let mut saved = Census::default();
|
||||
let mut seeded = Census::default();
|
||||
let started = std::time::Instant::now();
|
||||
for &(tx, tz) in tiles.iter().step_by(step) {
|
||||
let (x, z) = (tx * size - width, tz * size - width);
|
||||
let columns = sampler.grid(x, z, width, 18);
|
||||
let all_saved = width <= SAVED_LOD_STRIDE
|
||||
&& (x.div_euclid(16)..=(x + 18 * width - 1).div_euclid(16)).all(|cx| {
|
||||
(z.div_euclid(16)..=(z + 18 * width - 1).div_euclid(16))
|
||||
.all(|cz| save.lod_surfaces_shared(cx, cz, &assets, &biomes).is_some())
|
||||
});
|
||||
let out = if all_saved { &mut saved } else { &mut seeded };
|
||||
out.tiles += 1;
|
||||
count(&columns, 18, &name, out, (x, z, width));
|
||||
}
|
||||
println!(
|
||||
"level {level} (cells {width}, {near}..{far} blocks, {:.1}s): saved {saved:?} | seed {seeded:?}",
|
||||
started.elapsed().as_secs_f32()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -385,6 +385,42 @@ fn shade_sky(column: &mut Column, states: &GenBlockStates) {
|
||||
column.spans = shaded.into();
|
||||
}
|
||||
|
||||
/// A wide cell's column is one point of the terrain: a cave, an overhang's
|
||||
/// lip or the water an aquifer-less sample puts in caves under sea level stood
|
||||
/// for the whole cell, a slab hanging over air or a band of water in a cliff
|
||||
/// once its neighbours were lower. Under the top solid span the column is
|
||||
/// ground: gaps take the span below, buried fluids the ground under them.
|
||||
fn fill_under_surface(column: &mut Column, fluid: impl Fn(BlockStateId) -> bool) {
|
||||
let Some(top) = column.spans.iter().rposition(|span| !fluid(span.state)) else {
|
||||
return;
|
||||
};
|
||||
let Some(ground) = column.spans[..=top].iter().find(|span| !fluid(span.state)) else {
|
||||
return;
|
||||
};
|
||||
let mut state = ground.state;
|
||||
let mut filled = Column {
|
||||
surface: column.surface,
|
||||
spans: crate::lod::Spans::with_capacity(column.spans.len()),
|
||||
};
|
||||
for (index, &span) in column.spans.iter().enumerate() {
|
||||
let mut span = span;
|
||||
if index <= top {
|
||||
if fluid(span.state) {
|
||||
span.state = state;
|
||||
} else {
|
||||
state = span.state;
|
||||
}
|
||||
if let Some(below) = filled.spans.last_mut()
|
||||
&& below.max < span.min
|
||||
{
|
||||
below.max = span.min;
|
||||
}
|
||||
}
|
||||
filled.push(span);
|
||||
}
|
||||
*column = filled;
|
||||
}
|
||||
|
||||
/// A whole tile inside visited chunks needs no density work at all. This
|
||||
/// used to evaluate the lattice first and throw all of it away in the per-cell
|
||||
/// cache check below.
|
||||
@@ -599,6 +635,10 @@ impl VanillaLodSampler {
|
||||
}
|
||||
let nether = self.generator.dimension == "minecraft:the_nether";
|
||||
let light = if nether { 0 } else { 240 };
|
||||
if width > 1 && self.generator.dimension == "minecraft:overworld" {
|
||||
let states = self.generator.generator.data().block_states();
|
||||
fill_under_surface(&mut column, |state| states.get(state).has_fluid());
|
||||
}
|
||||
self.vegetation.apply(&mut column, x, z, width, light);
|
||||
self.islands.apply(&mut column, x, z, width, light);
|
||||
if !nether {
|
||||
|
||||
@@ -350,11 +350,13 @@ impl Vegetation {
|
||||
.exp();
|
||||
if coverage >= 0.25 {
|
||||
let height = tree.height + ((n >> 32) & 1) as i32;
|
||||
// The cell's representative tree stands in it: its trunk
|
||||
// is drawn under the crown.
|
||||
best = Some((
|
||||
surface.height + height.max(4),
|
||||
surface.height + height.max(4) - tree.radius * 2,
|
||||
*tree,
|
||||
false,
|
||||
true,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
@@ -377,14 +379,26 @@ impl Vegetation {
|
||||
let cap = ((tree.radius.pow(2) - d2) as f32).sqrt().ceil() as i32;
|
||||
let top = surface.height + height + cap.min(2);
|
||||
let bottom = surface.height + height - tree.radius;
|
||||
// `x`, `z` are the cell's centre: its trunk is the
|
||||
// cell's if it stands anywhere in the cell.
|
||||
let (x0, z0) = (x - width / 2, z - width / 2);
|
||||
let inside = (x0..x0 + width).contains(&tx) && (z0..z0 + width).contains(&tz);
|
||||
if best.as_ref().is_none_or(|&(y, _, _, _)| top > y) {
|
||||
best = Some((top, bottom, tree, x == tx && z == tz));
|
||||
best = Some((top, bottom, tree, inside));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((top, bottom, tree, trunk)) = best {
|
||||
let bottom = bottom.max(surface.height + 1);
|
||||
// In a cell wider than a block, a crown whose trunk stands in
|
||||
// another cell reaches down to the ground, as reducing real
|
||||
// chunks fills it (`Column::reduced`): a crown over air read from
|
||||
// afar as a box hanging in the sky. Single blocks keep the gap.
|
||||
let bottom = if trunk || width == 1 {
|
||||
bottom.max(surface.height + 1)
|
||||
} else {
|
||||
surface.height
|
||||
};
|
||||
if trunk {
|
||||
column.push(Span {
|
||||
min: surface.height as i16,
|
||||
|
||||
@@ -806,12 +806,8 @@ fn patch_config(shared: &Shared, body: &[u8]) -> Response {
|
||||
lines.push(format!("{key}={text}"));
|
||||
}
|
||||
let lines: Vec<&str> = lines.iter().map(String::as_str).collect();
|
||||
let live = |line: &str| {
|
||||
shared.phase() == Phase::Running
|
||||
&& engine::Control::connect(&shared.dir)
|
||||
.and_then(|mut c| c.ask(&format!("console {line}")))
|
||||
.is_ok()
|
||||
};
|
||||
let live =
|
||||
|line: &str| shared.phase() == Phase::Running && settings::ask_game(&shared.dir, line);
|
||||
let running = matches!(shared.phase(), Phase::Running | Phase::Restarting);
|
||||
match settings::apply(
|
||||
&shared.dir,
|
||||
|
||||
@@ -80,6 +80,12 @@ pub fn call(shared: &Shared, client: u32, short: &str, argument: &Value) -> Resu
|
||||
.ok_or_else(|| invalid_params("Not a string"))?,
|
||||
),
|
||||
),
|
||||
"serversettings/operator_user_permission_level" => {
|
||||
game.operator_user_permission_level(client, None)
|
||||
}
|
||||
"serversettings/operator_user_permission_level/set" => {
|
||||
game.operator_user_permission_level(client, Some(management_int(argument)?))
|
||||
}
|
||||
"serversettings/force_game_mode" => game.force_game_mode(client, None),
|
||||
"serversettings/force_game_mode/set" => game.force_game_mode(
|
||||
client,
|
||||
@@ -643,6 +649,37 @@ impl Game<'_> {
|
||||
Ok(reply["value"].clone())
|
||||
}
|
||||
|
||||
/// `MinecraftServerSettingsServiceImpl.getOperatorUserPermissions` and
|
||||
/// `setOperatorUserPermissionLevel`.
|
||||
fn operator_user_permission_level(
|
||||
&self,
|
||||
client: u32,
|
||||
value: Option<i32>,
|
||||
) -> Result<Value, Failure> {
|
||||
let mut request = json!({
|
||||
"method": "serversettings/operator_user_permission_level"
|
||||
});
|
||||
if let Some(value) = value {
|
||||
request["method"] = json!("serversettings/operator_user_permission_level/set");
|
||||
request["value"] = json!(value);
|
||||
}
|
||||
let reply = self.ask(&format!("management {client} {request}"))?;
|
||||
if let Some(error) = reply["error"].as_str() {
|
||||
return Err((INTERNAL_ERROR, Some(error.to_owned())));
|
||||
}
|
||||
if let Some(value) = value {
|
||||
let value = reply["value"].as_i64().unwrap_or(i64::from(value));
|
||||
crate::config::update(
|
||||
&self.shared.dir,
|
||||
"properties",
|
||||
"op-permission-level",
|
||||
toml::Value::Integer(value),
|
||||
)
|
||||
.map_err(|e| (INTERNAL_ERROR, Some(e)))?;
|
||||
}
|
||||
Ok(reply["value"].clone())
|
||||
}
|
||||
|
||||
/// `MinecraftServerSettingsServiceImpl.getMotd` and `setMotd`: the live server-list message.
|
||||
fn motd(&self, client: u32, value: Option<&str>) -> Result<Value, Failure> {
|
||||
let mut request = json!({ "method": "serversettings/motd" });
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! `horizon-server config`: read server.toml's settings, and change them the way the server checks them
|
||||
//! (docs/server.md, section 4). A running server takes what vanilla can change live through its console
|
||||
//! (difficulty, the default game mode, the whitelist) at once; the rest applies at the next start.
|
||||
//! (difficulty, the default game mode, the whitelist) and through its management settings (the message of the
|
||||
//! day, the player limit) at once; the rest applies at the next start.
|
||||
|
||||
use crate::{api::Client, config};
|
||||
use crate::{api::Client, config, engine};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -147,6 +148,35 @@ fn range(section: &str, field: &str) -> Option<std::ops::RangeInclusive<i64>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The control line that applies a setting to a running game, if there is a way to: a console command, or the
|
||||
/// management setting (`serversettings/motd/set`, `serversettings/max_players/set`) that the management
|
||||
/// protocol uses; `Some("")` for the settings the supervisor itself reads every time (backups, updates).
|
||||
pub fn live_line(section: &str, field: &str, value: &toml::Value) -> Option<String> {
|
||||
let setting = |name: &str, value: Value| {
|
||||
let request = json!({ "method": format!("serversettings/{name}/set"), "value": value });
|
||||
// Connection 0: no RPC client made this change, the command line did.
|
||||
Some(format!("management 0 {request}"))
|
||||
};
|
||||
match (section, field, value) {
|
||||
("server", "motd", toml::Value::String(text)) => setting("motd", json!(text)),
|
||||
("server", "max_players", toml::Value::Integer(n)) => setting("max_players", json!(n)),
|
||||
_ => live_command(section, field, value).map(|line| {
|
||||
if line.is_empty() {
|
||||
line
|
||||
} else {
|
||||
format!("console {line}")
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the running game one control line on its endpoint, and says whether it took it.
|
||||
pub fn ask_game(dir: &Path, line: &str) -> bool {
|
||||
engine::Control::connect(dir)
|
||||
.and_then(|mut game| game.ask(line))
|
||||
.is_ok_and(|reply| reply.get("error").is_none())
|
||||
}
|
||||
|
||||
/// The console command that applies a setting to a running game, if vanilla has one; `Some("")` for the
|
||||
/// settings the supervisor itself reads every time (backups, updates).
|
||||
pub fn live_command(section: &str, field: &str, value: &toml::Value) -> Option<String> {
|
||||
@@ -176,17 +206,12 @@ pub fn set(dir: &Path, changes: &[&str]) -> Result<Outcome, String> {
|
||||
c.call("GET", "/api/v1/status", None)
|
||||
.is_ok_and(|s| s["phase"] == "running")
|
||||
});
|
||||
let run = |line: &str| {
|
||||
up && client.as_ref().is_some_and(|c| {
|
||||
c.call("POST", "/api/v1/command", Some(&json!({ "line": line })))
|
||||
.is_ok()
|
||||
})
|
||||
};
|
||||
let run = |line: &str| up && ask_game(dir, line);
|
||||
apply(dir, changes, client.is_some().then_some(&run))
|
||||
}
|
||||
|
||||
/// Writes the changes to server.toml, then sends a running server (`live` runs a console command and says
|
||||
/// whether it did) what it can take at once.
|
||||
/// Writes the changes to server.toml, then sends a running server (`live` sends the game a control line and
|
||||
/// says whether it took it) what it can take at once.
|
||||
pub fn apply(
|
||||
dir: &Path,
|
||||
changes: &[&str],
|
||||
@@ -209,7 +234,7 @@ pub fn apply(
|
||||
for (section, field, value) in &parsed {
|
||||
let key = format!("{section}.{field}");
|
||||
let unchanged = current.get(section).and_then(|s| s.get(field)) == Some(value);
|
||||
match live_command(section, field, value) {
|
||||
match live_line(section, field, value) {
|
||||
_ if unchanged => {}
|
||||
Some(line) if line.is_empty() => outcome.applied.push(key),
|
||||
Some(line) if live(&line) => outcome.applied.push(key),
|
||||
@@ -318,6 +343,76 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_message_and_the_player_limit_go_to_the_management_settings() {
|
||||
let s = |v: &str| toml::Value::String(v.into());
|
||||
assert_eq!(
|
||||
live_line("server", "motd", &s("Friends \"only\"")).as_deref(),
|
||||
Some(r#"management 0 {"method":"serversettings/motd/set","value":"Friends \"only\""}"#)
|
||||
);
|
||||
assert_eq!(
|
||||
live_line("server", "max_players", &toml::Value::Integer(8)).as_deref(),
|
||||
Some(r#"management 0 {"method":"serversettings/max_players/set","value":8}"#)
|
||||
);
|
||||
// The console ones keep their command, the supervisor's own settings need none.
|
||||
assert_eq!(
|
||||
live_line("world", "difficulty", &s("hard")).as_deref(),
|
||||
Some("console difficulty hard")
|
||||
);
|
||||
assert_eq!(live_line("backups", "every", &s("1h")).as_deref(), Some(""));
|
||||
assert_eq!(
|
||||
live_line("server", "view_distance", &toml::Value::Integer(8)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_running_server_is_sent_only_what_changed() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-server-live-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(config::FILE), config::TEMPLATE).unwrap();
|
||||
let sent = std::cell::RefCell::new(Vec::new());
|
||||
let game = |line: &str| {
|
||||
sent.borrow_mut().push(line.to_owned());
|
||||
true
|
||||
};
|
||||
let outcome = apply(
|
||||
&dir,
|
||||
&[
|
||||
"server.motd=Back at noon",
|
||||
"server.max_players=5",
|
||||
"server.view_distance=9",
|
||||
],
|
||||
Some(&game),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(outcome.applied, ["server.motd", "server.max_players"]);
|
||||
assert_eq!(outcome.restart, ["server.view_distance"]);
|
||||
assert_eq!(
|
||||
*sent.borrow(),
|
||||
[
|
||||
r#"management 0 {"method":"serversettings/motd/set","value":"Back at noon"}"#,
|
||||
r#"management 0 {"method":"serversettings/max_players/set","value":5}"#,
|
||||
]
|
||||
);
|
||||
// The same values again change nothing, so nothing is sent.
|
||||
sent.borrow_mut().clear();
|
||||
let outcome = apply(
|
||||
&dir,
|
||||
&["server.motd=Back at noon", "server.max_players=5"],
|
||||
Some(&game),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(outcome.applied.is_empty() && outcome.restart.is_empty());
|
||||
assert!(sent.borrow().is_empty());
|
||||
// A game that refuses leaves the setting for the next start.
|
||||
let refuses = |_: &str| false;
|
||||
let outcome = apply(&dir, &["server.motd=Closed"], Some(&refuses)).unwrap();
|
||||
assert_eq!(outcome.restart, ["server.motd"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stopped_server_saves_and_keeps_comments() {
|
||||
let dir =
|
||||
|
||||
@@ -113,6 +113,16 @@ pub fn markdown() -> String {
|
||||
}
|
||||
out += "## The frame\n\n";
|
||||
out += &table(&Frame::WGSL, Some(Frame::OFFSETS));
|
||||
out += "## Anti-aliasing\n\n\
|
||||
While a pack's `[[post]]` chain draws the frame, the player's anti-aliasing settings do not run: `taa`, `fxaa`\n\
|
||||
and `upscaling` in `horizon-graphics.txt` have no effect, and the frame's projection is never jittered, because the\n\
|
||||
last post pass draws the picture itself and Horizon's own resolve, which does TAA, FXAA and TAA upscaling, is the\n\
|
||||
step it replaces. The world is still rendered at `resolutionScale` before the pack reads it.\n\n\
|
||||
A pack that wants smooth edges draws them itself, in a post pass: a spatial filter (FXAA, SMAA and the like) works on\n\
|
||||
`previous` as it is. A temporal pass cannot do what TAA does here, even though `history` and\n\
|
||||
`frame.previous_view_projection` give it the last frame: nothing moves the camera by a fraction of a texel from one\n\
|
||||
frame to the next, so a still camera gives it the same picture twice and there is no sub-pixel detail to gather.\n\n\
|
||||
Without a pack the settings apply as the player set them.\n\n";
|
||||
out += &format!(
|
||||
"## Options\n\n\
|
||||
Each `[[options]]` entry sets the WGSL `override` of the same name when the pipelines are built:\n\n\
|
||||
|
||||
@@ -182,6 +182,10 @@ const ENTITIES_TRANSLUCENT: [&str; 5] = [
|
||||
"gbuffers_basic",
|
||||
];
|
||||
|
||||
/// `gbuffers_beaconbeam` and its fallbacks (the shaders.properties reference): beacon and End
|
||||
/// gateway beams.
|
||||
const BEACON_BEAM: [&str; 3] = ["gbuffers_beaconbeam", "gbuffers_textured", "gbuffers_basic"];
|
||||
|
||||
/// `gbuffers_particles` and its fallbacks (2.3).
|
||||
const PARTICLES: [&str; 4] = [
|
||||
"gbuffers_particles",
|
||||
@@ -408,6 +412,8 @@ pub struct Post {
|
||||
pub entities_translucent: Option<PostProgram>,
|
||||
/// What draws particles.
|
||||
pub particles: Option<PostProgram>,
|
||||
/// What draws beacon and End gateway beams (after deferred, blended).
|
||||
pub beacon_beam: Option<PostProgram>,
|
||||
pub damaged_block: Option<PostProgram>,
|
||||
/// What draws the game's clouds.
|
||||
pub clouds: Option<PostProgram>,
|
||||
@@ -605,6 +611,7 @@ pub fn load_post(
|
||||
block: None,
|
||||
entities_translucent: None,
|
||||
particles: None,
|
||||
beacon_beam: None,
|
||||
damaged_block: None,
|
||||
clouds: None,
|
||||
lines: None,
|
||||
@@ -780,6 +787,7 @@ pub fn load_post(
|
||||
let mut gbuffers_dyn = |names: &[&str], i: &GbuffersInputs| gbuffers(names, Group::Gbuffers, i);
|
||||
let entities_translucent = translucent(&ENTITIES_TRANSLUCENT, 0.0, &mut gbuffers_dyn);
|
||||
let particles = translucent(&PARTICLES, 0.1, &mut gbuffers_dyn);
|
||||
let beacon_beam = translucent(&BEACON_BEAM, 0.0, &mut gbuffers_dyn);
|
||||
// Clouds blend, as vanilla's do (OQ-23).
|
||||
let clouds = inputs
|
||||
.clouds
|
||||
@@ -842,6 +850,7 @@ pub fn load_post(
|
||||
loader.post.block = block;
|
||||
loader.post.entities_translucent = entities_translucent;
|
||||
loader.post.particles = particles;
|
||||
loader.post.beacon_beam = beacon_beam;
|
||||
loader.post.damaged_block = damaged_block;
|
||||
loader.post.clouds_off = properties.get("clouds").is_some_and(|v| v.trim() == "off");
|
||||
loader.post.clouds = clouds.filter(|_| !loader.post.clouds_off);
|
||||
@@ -888,6 +897,7 @@ fn load_shadow(
|
||||
&post.block,
|
||||
&post.entities_translucent,
|
||||
&post.particles,
|
||||
&post.beacon_beam,
|
||||
&post.damaged_block,
|
||||
&post.clouds,
|
||||
&post.weather,
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
//! The player's cap on the memory the game holds, and the last resort behind it.
|
||||
//!
|
||||
//! On Windows the system enforces it: a hard working set limit never lets the game hold more in
|
||||
//! RAM, and moves what it used least out instead of failing, so a low cap costs speed. Elsewhere
|
||||
//! no such limit exists, and the game keeps itself under the cap by holding less
|
||||
//! (`horizon_client::ram_limit`, which reads [`pressure`]).
|
||||
//! On every system the game keeps itself under the cap by holding less, before it reaches it
|
||||
//! (`horizon_client::ram_limit`, which reads [`pressure`]). Nothing asks the system to page the
|
||||
//! game out: on Windows a hard working set limit did, and a low cap made the game slow instead of
|
||||
//! making it hold less.
|
||||
//!
|
||||
//! A watcher thread reads the process's resident memory and, over the cap, gives free pages back.
|
||||
//! Only when the game has nothing left to give up ([`can_shrink`] has stopped being called) and
|
||||
//! stays over for [`GRACE`] does it call the game's `exceeded`. A cap the system enforces can't be
|
||||
//! gone over, so there the sign is a game frozen by it: no [`frame`] for [`STALL`] while it faults
|
||||
//! hard. No thread runs until a cap is set, and there is no lowest cap: one the game can't live
|
||||
//! under ends the game.
|
||||
//! stays over for [`GRACE`] does it call the game's `exceeded`. No thread runs until a cap is set,
|
||||
//! and there is no lowest cap: one the game can't live under ends the game.
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Once, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -20,12 +18,6 @@ const EVERY: Duration = Duration::from_secs(1);
|
||||
/// How long the game may stay over the cap with nothing left to give up: a world's first chunks,
|
||||
/// or a burst the last step down has not freed yet, pass within it.
|
||||
pub const GRACE: Duration = Duration::from_secs(20);
|
||||
/// How long a game may draw nothing while faulting hard under a cap the system enforces.
|
||||
pub const STALL: Duration = Duration::from_secs(60);
|
||||
/// Page faults a second that say a game drawing nothing is busy fetching memory back, not just
|
||||
/// minimized or waiting.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
const STALL_FAULTS: f64 = 20_000.;
|
||||
/// How long one [`can_shrink`] counts for.
|
||||
const SHRINK_WORD: Duration = Duration::from_secs(5);
|
||||
|
||||
@@ -34,18 +26,11 @@ static WATCHER: Once = Once::new();
|
||||
static STARTED: OnceLock<Instant> = OnceLock::new();
|
||||
/// Until when, in milliseconds since [`STARTED`], the game says it can still shrink.
|
||||
static SHRINKS_UNTIL: AtomicU64 = AtomicU64::new(0);
|
||||
/// When the last [`frame`] was, in milliseconds since [`STARTED`].
|
||||
static LAST_FRAME: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn since_start() -> u64 {
|
||||
STARTED.get_or_init(Instant::now).elapsed().as_millis() as u64
|
||||
}
|
||||
|
||||
/// The game drew a frame.
|
||||
pub fn frame() {
|
||||
LAST_FRAME.store(since_start(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The game can still hold less. Said again every second while that is true, so it ends by itself
|
||||
/// when the world closes.
|
||||
pub fn can_shrink() {
|
||||
@@ -57,23 +42,18 @@ pub fn can_shrink() {
|
||||
/// the game can't get under the cap; only the first call's function is kept.
|
||||
pub fn set(limit: Option<u64>, exceeded: fn(u64, u64)) {
|
||||
LIMIT.store(limit.unwrap_or(NONE), Ordering::Relaxed);
|
||||
// A new cap gets the whole of STALL.
|
||||
frame();
|
||||
system_cap(limit);
|
||||
if limit.is_some() {
|
||||
WATCHER.call_once(|| {
|
||||
let watcher = std::thread::Builder::new()
|
||||
.name("Memory limit".into())
|
||||
.spawn(move || {
|
||||
let mut over = Over::default();
|
||||
let mut faults = Faults::default();
|
||||
loop {
|
||||
std::thread::sleep(EVERY);
|
||||
let per_second = faults.per_second();
|
||||
let limit = get();
|
||||
over.follow(limit);
|
||||
if let Some(limit) = limit {
|
||||
check(&mut over, per_second, limit, exceeded);
|
||||
check(&mut over, limit, exceeded);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -101,7 +81,7 @@ pub enum Pressure {
|
||||
Steady,
|
||||
}
|
||||
|
||||
/// Against a cap nothing enforces: what the game holds. It should act before it gets there.
|
||||
/// What the game holds against the cap: it should act before it gets there.
|
||||
fn pressure_of_usage(used: u64, limit: u64) -> Pressure {
|
||||
if used > limit / 10 * 9 {
|
||||
Pressure::Over {
|
||||
@@ -114,25 +94,8 @@ fn pressure_of_usage(used: u64, limit: u64) -> Pressure {
|
||||
}
|
||||
}
|
||||
|
||||
/// Under a cap the system enforces the game sits at the cap whether it needs the memory or not, so
|
||||
/// usage says nothing: how often it has to fetch back what was moved out does. Measured on a real
|
||||
/// game loading chunks at 30 frames a second: 30,000-50,000 page faults a second with no cap or any
|
||||
/// cap down to 500 MB, 140,000 at 300 MB, 290,000 at 150 MB. Loading chunks fast faults a lot too,
|
||||
/// so `horizon_client::ram_limit` also asks whether frames are suffering.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn pressure_of_faults(per_second: f64) -> Pressure {
|
||||
if per_second > 200_000. {
|
||||
Pressure::Over { keep: 0.8 }
|
||||
} else if per_second < 75_000. {
|
||||
Pressure::Room
|
||||
} else {
|
||||
Pressure::Steady
|
||||
}
|
||||
}
|
||||
|
||||
/// Against a hard ceiling, what the process commits is the budget: the working set sits at its
|
||||
/// cap whatever the game needs, and page faults only say how hard it pages, not how much it
|
||||
/// holds. A ceiling counts every page the process committed, the renderer's own included.
|
||||
/// Against a hard ceiling, what the process commits is the budget, not what stays in RAM: a
|
||||
/// ceiling counts every page the process committed, the renderer's own included.
|
||||
pub fn pressure_of_commit(commit: u64, limit: u64) -> Pressure {
|
||||
if commit > limit / 10 * 9 {
|
||||
Pressure::Over {
|
||||
@@ -145,27 +108,11 @@ pub fn pressure_of_commit(commit: u64, limit: u64) -> Pressure {
|
||||
}
|
||||
}
|
||||
|
||||
/// How tight memory is under `limit`. Reads a difference since the last call: call it once a
|
||||
/// second, from one place.
|
||||
/// How tight memory is under `limit`: what the game holds in RAM against it.
|
||||
pub fn pressure(limit: u64) -> Pressure {
|
||||
#[cfg(windows)]
|
||||
if SYSTEM_CAP.load(Ordering::Relaxed) {
|
||||
// The first reading has nothing to compare with.
|
||||
static FAULTS: std::sync::Mutex<Faults> = std::sync::Mutex::new(Faults::new());
|
||||
let per_second = FAULTS.lock().ok().and_then(|mut f| f.per_second());
|
||||
return per_second.map_or(Pressure::Steady, pressure_of_faults);
|
||||
}
|
||||
resident_bytes().map_or(Pressure::Steady, |used| pressure_of_usage(used, limit))
|
||||
}
|
||||
|
||||
/// Whether the system itself holds the game under the cap.
|
||||
pub fn system_enforced() -> bool {
|
||||
#[cfg(windows)]
|
||||
return SYSTEM_CAP.load(Ordering::Relaxed);
|
||||
#[cfg(not(windows))]
|
||||
false
|
||||
}
|
||||
|
||||
/// How long the game has been over the cap with nothing left to give up.
|
||||
#[derive(Default)]
|
||||
struct Over {
|
||||
@@ -192,12 +139,7 @@ impl Over {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a game has frozen under a cap the system enforces.
|
||||
fn frozen(no_frame_for: Duration, faults_per_second: f64) -> bool {
|
||||
no_frame_for >= STALL && faults_per_second >= STALL_FAULTS
|
||||
}
|
||||
|
||||
fn check(state: &mut Over, faults: Option<f64>, limit: u64, exceeded: fn(u64, u64)) {
|
||||
fn check(state: &mut Over, limit: u64, exceeded: fn(u64, u64)) {
|
||||
// A hard ceiling counts what the process committed, the renderer's own
|
||||
// pages included, since that is the number the player set.
|
||||
let hard = crate::capped::limit().is_some();
|
||||
@@ -209,17 +151,6 @@ fn check(state: &mut Over, faults: Option<f64>, limit: u64, exceeded: fn(u64, u6
|
||||
let Some(mut used) = measured else {
|
||||
return;
|
||||
};
|
||||
// A cap the system enforces can't be gone over, so there the sign is a game
|
||||
// frozen by it. A hard ceiling can be gone over, and says so.
|
||||
if system_enforced() && !hard {
|
||||
let no_frame_for = since_start().saturating_sub(LAST_FRAME.load(Ordering::Relaxed));
|
||||
if frozen(Duration::from_millis(no_frame_for), faults.unwrap_or(0.)) {
|
||||
// The report has to be written, which a frozen game can't do under the cap.
|
||||
system_cap(None);
|
||||
exceeded(used, limit);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if used > limit {
|
||||
crate::release_free_memory();
|
||||
used = if hard {
|
||||
@@ -234,47 +165,6 @@ fn check(state: &mut Over, faults: Option<f64>, limit: u64, exceeded: fn(u64, u6
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the system is enforcing the cap.
|
||||
#[cfg(windows)]
|
||||
static SYSTEM_CAP: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// Sets the system's hard limit on the working set, or lifts it. Refused (a cap below what the
|
||||
/// system allows), the watcher treats the game as any other that can't get under its cap.
|
||||
#[cfg(windows)]
|
||||
fn system_cap(limit: Option<u64>) {
|
||||
use windows_sys::Win32::System::Memory::{
|
||||
QUOTA_LIMITS_HARDWS_MAX_DISABLE, QUOTA_LIMITS_HARDWS_MAX_ENABLE,
|
||||
QUOTA_LIMITS_HARDWS_MIN_DISABLE, SetProcessWorkingSetSizeEx,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::GetCurrentProcess;
|
||||
const MINIMUM: u64 = 16 << 20;
|
||||
// Windows' own defaults for a process, with the limit lifted.
|
||||
let (low, high, flags) = match limit {
|
||||
Some(limit) => (
|
||||
MINIMUM.min(limit / 2) as usize,
|
||||
usize::try_from(limit).unwrap_or(usize::MAX),
|
||||
QUOTA_LIMITS_HARDWS_MAX_ENABLE | QUOTA_LIMITS_HARDWS_MIN_DISABLE,
|
||||
),
|
||||
None => (
|
||||
200 << 10,
|
||||
1380 << 10,
|
||||
QUOTA_LIMITS_HARDWS_MAX_DISABLE | QUOTA_LIMITS_HARDWS_MIN_DISABLE,
|
||||
),
|
||||
};
|
||||
// SAFETY: passes plain values; the current process's pseudo-handle needs no closing.
|
||||
let done = unsafe { SetProcessWorkingSetSizeEx(GetCurrentProcess(), low, high, flags) != 0 };
|
||||
if !done {
|
||||
eprintln!(
|
||||
"Windows refused the memory limit: {}",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
}
|
||||
SYSTEM_CAP.store(done && limit.is_some(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn system_cap(_limit: Option<u64>) {}
|
||||
|
||||
/// The process's memory counters.
|
||||
#[cfg(windows)]
|
||||
fn counters() -> Option<windows_sys::Win32::System::ProcessStatus::PROCESS_MEMORY_COUNTERS> {
|
||||
@@ -292,35 +182,6 @@ fn counters() -> Option<windows_sys::Win32::System::ProcessStatus::PROCESS_MEMOR
|
||||
}
|
||||
}
|
||||
|
||||
/// Page fault rates, between one reading and the next.
|
||||
#[derive(Default)]
|
||||
struct Faults {
|
||||
#[cfg(windows)]
|
||||
last: Option<(Instant, u32)>,
|
||||
}
|
||||
|
||||
impl Faults {
|
||||
#[cfg(windows)]
|
||||
const fn new() -> Self {
|
||||
Faults { last: None }
|
||||
}
|
||||
|
||||
/// Page faults a second since the last call; none on the first, or where they aren't known.
|
||||
#[cfg(windows)]
|
||||
fn per_second(&mut self) -> Option<f64> {
|
||||
let faults = counters()?.PageFaultCount;
|
||||
let now = Instant::now();
|
||||
let before = self.last.replace((now, faults))?;
|
||||
let seconds = now.duration_since(before.0).as_secs_f64();
|
||||
(seconds > 0.).then(|| f64::from(faults.wrapping_sub(before.1)) / seconds)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn per_second(&mut self) -> Option<f64> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The memory this process holds in RAM, in bytes.
|
||||
#[cfg(windows)]
|
||||
pub fn resident_bytes() -> Option<u64> {
|
||||
@@ -400,40 +261,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_faults_say_how_tight_a_cap_the_system_enforces_is() {
|
||||
assert_eq!(pressure_of_faults(50_000.), Pressure::Room);
|
||||
assert_eq!(pressure_of_faults(140_000.), Pressure::Steady);
|
||||
assert!(matches!(
|
||||
pressure_of_faults(290_000.),
|
||||
Pressure::Over { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn the_system_holds_the_game_under_a_cap_and_lifting_it_lets_memory_back() {
|
||||
fn every_system_steps_down_before_its_cap() {
|
||||
let _alone = WORKING_SET.lock().unwrap();
|
||||
const SIZE: usize = 256 << 20;
|
||||
const CAP: u64 = 128 << 20;
|
||||
let mut block = vec![1u8; SIZE];
|
||||
system_cap(Some(CAP));
|
||||
block.iter_mut().step_by(4096).for_each(|b| *b = 2);
|
||||
let capped = resident_bytes().unwrap();
|
||||
assert!(capped <= CAP, "{capped} bytes under a cap of {CAP}");
|
||||
system_cap(None);
|
||||
block.iter_mut().step_by(4096).for_each(|b| *b = 3);
|
||||
let lifted = resident_bytes().unwrap();
|
||||
std::hint::black_box(&block);
|
||||
assert!(lifted > CAP, "{lifted} bytes with the cap lifted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_drawing_nothing_while_faulting_hard_counts_as_frozen() {
|
||||
assert!(frozen(STALL, STALL_FAULTS * 10.));
|
||||
// Slow but drawing, as at 1 frame a second.
|
||||
assert!(!frozen(Duration::from_secs(1), STALL_FAULTS * 10.));
|
||||
// Minimized or waiting: no frames, and no memory being fetched back.
|
||||
assert!(!frozen(STALL * 10, 100.));
|
||||
let used = resident_bytes().unwrap();
|
||||
// Under a cap at half what it holds, the game is told to hold less, on Windows as
|
||||
// elsewhere: nothing pages it out to meet the cap instead.
|
||||
let Pressure::Over { keep } = pressure(used / 2) else {
|
||||
panic!("holding {used} bytes is over a cap of half that");
|
||||
};
|
||||
assert!(keep < 0.5, "{keep}");
|
||||
// Far below the cap it may hold more again.
|
||||
assert_eq!(pressure(used.saturating_mul(4)), Pressure::Room);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -13,13 +13,7 @@ use crate::{
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Direction, ShapeOp, Vec3, VoxelShape};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::{self, File},
|
||||
io,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
use std::{collections::HashMap, fs, io, path::Path, sync::Arc};
|
||||
#[path = "data/cache.rs"]
|
||||
mod cache;
|
||||
pub(super) use cache::set_dir as set_cache_dir;
|
||||
@@ -636,7 +630,8 @@ pub fn load_registry_with_flags(root: &Path) -> io::Result<(BlockRegistry, Vec<(
|
||||
if let Some(cached) = cache::load_registry(root) {
|
||||
return Ok(cached);
|
||||
}
|
||||
let loaded = BlockRegistry::from_facts_json_with_flags(File::open(root.join("facts.json"))?)?;
|
||||
// Read whole, then parsed from the slice: see `BlockRegistry::from_facts_json_bytes`.
|
||||
let loaded = BlockRegistry::from_facts_json_bytes(&fs::read(root.join("facts.json"))?)?;
|
||||
cache::store_registry(root, &loaded.0, &loaded.1);
|
||||
Ok(loaded)
|
||||
}
|
||||
|
||||
@@ -1111,7 +1111,7 @@ mod tests {
|
||||
return;
|
||||
};
|
||||
let (registry, flags) = BlockRegistry::from_facts_json_with_flags(
|
||||
File::open(root.0.join("facts.json")).unwrap(),
|
||||
std::fs::File::open(root.0.join("facts.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
// The game's cache folder, apart from the resources.
|
||||
|
||||
@@ -79,6 +79,23 @@ impl BlockRegistry {
|
||||
let facts: facts::Facts =
|
||||
facts::with_shared_strings(|| serde_json::from_reader(io::BufReader::new(reader)))
|
||||
.map_err(|e| invalid(e.to_string()))?;
|
||||
Self::from_facts(facts)
|
||||
}
|
||||
|
||||
/// [`Self::from_facts_json_with_flags`] over bytes already in memory.
|
||||
///
|
||||
/// `serde_json` cannot borrow out of a reader, so `from_reader` copies
|
||||
/// every string it parses; over one slice it borrows instead. The facts
|
||||
/// are about 10 MB and sit at the head of the start, before anything
|
||||
/// else in the core bundle can begin, so the difference is on the
|
||||
/// critical path.
|
||||
pub fn from_facts_json_bytes(bytes: &[u8]) -> io::Result<(Self, Vec<(bool, bool)>)> {
|
||||
let facts: facts::Facts = facts::with_shared_strings(|| serde_json::from_slice(bytes))
|
||||
.map_err(|e| invalid(e.to_string()))?;
|
||||
Self::from_facts(facts)
|
||||
}
|
||||
|
||||
fn from_facts(facts: facts::Facts) -> io::Result<(Self, Vec<(bool, bool)>)> {
|
||||
let flags = facts.states.iter().map(|r| (r.air, r.fluid)).collect();
|
||||
let states = facts.states.into_iter().map(|r| r.state).collect();
|
||||
Ok((Self::from_states(states, facts.defaults)?, flags))
|
||||
|
||||
+4
-2
@@ -26,14 +26,16 @@ Every setting applies at once, except worker threads, the graphics API and trans
|
||||
| Quality | clouds, cloud distance, weather radius, particles, smooth lighting, biome blend, entity distance, entity shadows, vignette, chunk fade, mipmap levels, cutout leaves, improved transparency | `options.txt` |
|
||||
| | sky, fog, weather, animated textures, distant plant dither (`ditherCutoutMips`, off by default: cutouts from 64 texels up), resolution scale, anti-aliasing (FXAA or TAA), TAA upscaling, screenshot quality, frame generation | `horizon-graphics.txt` |
|
||||
| Performance | chunk builder | `options.txt` |
|
||||
| | chunk updates and upload budget per frame, worker threads, cave culling, entity culling mode, leaf culling, shadow cache, battery mode, and the block-entity caches (shulker boxes, signs, decorated pots, bells, chests, banners, copper golem statues) | `horizon-graphics.txt` |
|
||||
| | chunk updates and upload budget per frame, worker threads, cave culling, entity and particle culling modes, leaf culling, shadow cache, battery mode, and the block-entity caches (shulker boxes, signs, decorated pots, bells, chests, banners, copper golem statues) | `horizon-graphics.txt` |
|
||||
| Advanced | present mode, frame pacing, graphics API, translucent batching, performance overlay (FPS, frame/CPU/GPU times, frame-time graph) | `horizon-graphics.txt` |
|
||||
| HDR | HDR, calibration, black level, peak brightness, paper white, UI brightness, a shortcut key to the page | `horizon-graphics.txt` |
|
||||
|
||||
`options.txt` keeps exactly vanilla's keys and values, so the options harness still compares it with vanilla; Horizon's settings live in `horizon-graphics.txt` beside it. Their defaults render as vanilla does: cave culling on, entity and leaf culling off, no upload budgets, sky, fog and weather on, 100% resolution, no anti-aliasing, HDR off, the present mode following VSync. VSync on always waits for the display (Fifo), in a window and in fullscreen, as in vanilla; the present mode setting chooses how frames are shown only while VSync is off. Frame pacing defaults to Efficient: the limiter sleeps through the last half millisecond where vanilla spins, and paces frames on a fixed grid of slots (vanilla starts each slot when the thread wakes up, so every late wake-up lengthens a frame), which changes timing, not pixels.
|
||||
`options.txt` keeps exactly vanilla's keys and values, so the options harness still compares it with vanilla; Horizon's settings live in `horizon-graphics.txt` beside it. Their defaults render as vanilla does: cave culling on, entity, particle and leaf culling off, no upload budgets, sky, fog and weather on, 100% resolution, no anti-aliasing, HDR off, the present mode following VSync. VSync on always waits for the display (Fifo), in a window and in fullscreen, as in vanilla; the present mode setting chooses how frames are shown only while VSync is off. Frame pacing defaults to Efficient: the limiter sleeps through the last half millisecond where vanilla spins, and paces frames on a fixed grid of slots (vanilla starts each slot when the thread wakes up, so every late wake-up lengthens a frame), which changes timing, not pixels.
|
||||
|
||||
**Entity Culling** is off by default and disabled with non-vanilla resource packs. It applies to entity models; block entities keep their section and per-block frustum culling and use the separate Block Entities caches. Synchronous mode uses the existing same-frame CPU test. Asynchronous mode checks meshes on a worker and uses a result only when the camera and mesh bounds still match and the opaque chunks and rendered sections that proved occlusion are unchanged and ready. Until then, it leaves the mesh visible. Both modes leave simulation and interaction unchanged; older `entityCulling:true` settings select Synchronous.
|
||||
|
||||
**Particle Culling** is off by default. Synchronous and asynchronous modes skip particles outside the camera frustum, below half a projected pixel, or hidden by opaque terrain. Asynchronous mode defers occlusion checks and leaves particles visible until a current result is available. Particle simulation is unchanged.
|
||||
|
||||
**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.
|
||||
|
||||
**Block Entities** (Performance, after Culling) has separate switches for shulker boxes, sign text, decorated pots, bells, chests, banners and copper-golem statues. The model caches are off by default; the sign-text cache stays on by default as before. Settled shulkers, pots and bells, closed chests, and statue geometry are cached; active animations keep their regular path. Banner poles are cached while flags and patterns still update. Nearby static models are prepared independently of the block-level frustum and stored in per-section GPU buffers, including when entity culling is enabled. Camera motion updates one shared transform containing the integer camera block and its fractional offset; the shader subtracts section origins in integer coordinates before converting to floats. Camera rotation changes only the visible section draw list. Mesh/light and block-entity membership revisions invalidate only their own section, and membership reconciliation runs only when the global membership revision changes. Each new atlas identity invalidates cached UV geometry even when sprite rectangles are unchanged. The static pipeline skips blending for opaque and cutout models; resource-pack sprites with fractional alpha stay on the ordinary blended path. The benchmark summary reports snapshot rebuilds, CPU snapshot bytes, GPU bytes written, and static/dynamic block entities drawn. This remains a separate entity draw, not geometry merged into terrain section meshes. When shader-pack callbacks are required, cached model vertices use the regular per-frame entity buffer instead. Sign text also remains in the regular entity buffer, using its existing CPU geometry cache. Beds and sign boards already use the terrain's block models in 26.3; Mojang removed the bed block entity and moved beds and signs to block models in 26.2 ([release notes](https://www.minecraft.net/en-us/article/minecraft-java-edition-26-2)), so they have no separate renderer cache switch.
|
||||
|
||||
+10
-4
@@ -3,14 +3,14 @@
|
||||
`horizon-server` hosts a Minecraft Java Edition 26.3 server with Horizon's own game as the engine: one native
|
||||
program, no Java, one settings file, a command line, and a web panel. Vanilla 26.3 clients and Horizon clients
|
||||
can join it. This page covers what works on `main` today and says plainly what doesn't yet. The design and the
|
||||
plan are in [docs/server.md](https://github.com/horizon-rust/horizon/blob/main/docs/server.md).
|
||||
plan are in [server.md](server.md).
|
||||
|
||||
## Before you start
|
||||
|
||||
- **Online mode checks accounts** (release v2026.10.17.07 and newer). With `online_mode = true`, the
|
||||
default, every player joins with their own account, as on a vanilla server: vanilla players signed in with
|
||||
the official Minecraft launcher, Horizon players with the Microsoft account they signed in with in Horizon's
|
||||
launcher ([docs/microsoft-account.md](https://github.com/horizon-rust/horizon/blob/main/docs/microsoft-account.md)).
|
||||
launcher ([microsoft-account.md](microsoft-account.md)).
|
||||
- **Names you add are accounts** (release v2026.10.17.14 and newer). `init --player`, `horizon-server
|
||||
whitelist add` and `op`, and the console's `whitelist add`, `op` and `ban` look a name up the way vanilla
|
||||
does: players already seen (kept in `usercache.json`), then Mojang's profile service. In online mode a name
|
||||
@@ -254,6 +254,12 @@ through the proxy is refused, and so is a login signed with another secret. With
|
||||
`online_mode = true` the file is ignored (with a warning), and an empty file stops the start, because an empty
|
||||
secret could be signed by anyone. How the exchange works: [velocity-forwarding.md](velocity-forwarding.md).
|
||||
|
||||
Bedrock players that join through Geyser and Floodgate arrive with a Floodgate UUID (`00000000-0000-0000-0009-` and
|
||||
the Xbox id), which is never on `whitelist.json`. With `white-list=true`, list that prefix in a file
|
||||
`whitelist-bypass.txt` beside server.properties (one UUID prefix per line, `#` comments, at least 8 characters) and
|
||||
everyone whose UUID starts with it passes the whitelist. Bans and the player limit still apply. The log says
|
||||
`whitelist-bypass.txt: 1 UUID prefix(es) skip the whitelist`.
|
||||
|
||||
## 5. Commands
|
||||
|
||||
```text
|
||||
@@ -337,8 +343,8 @@ two seconds behind says `Can't keep up!` as vanilla's does, at most every 15 sec
|
||||
`config set` changes `server.toml` without opening it: `horizon-server config set world.difficulty=hard
|
||||
server.max_players=10`. Each key is a section and a name from the file (`properties.spawn-protection=0` for any
|
||||
other vanilla key). The values are checked as the server checks them, and nothing is written if one is wrong.
|
||||
A running server takes the difficulty, the default game mode, the whitelist and the backup settings at once;
|
||||
the rest applies at its next start, and `config set` says which. `--json` prints that as `saved`, `applied`
|
||||
A running server takes the difficulty, the default game mode, the whitelist, the message of the day, the
|
||||
player limit and the backup settings at once; the rest applies at its next start, and `config set` says which. `--json` prints that as `saved`, `applied`
|
||||
and `restart`.
|
||||
|
||||
The server restarts the game by itself if it crashes: after 5 s, then waiting longer each time, up to 5
|
||||
|
||||
@@ -245,14 +245,14 @@ presentation, so `bench` reports what is on screen.
|
||||
|
||||
### RAM limit
|
||||
|
||||
`HORIZON_MEMORY_LIMIT_MB` (the launcher sets it) and `/ramlimit`, `/ramlimit <MB>`, `/ramlimit off` limit
|
||||
the RAM the game holds; the command lasts until the game closes and works on any server, as the window
|
||||
answers it. On Windows the system enforces the limit with a hard working set limit, so memory the game
|
||||
uses least moves out of RAM and a low limit costs speed. If the game is also slow and faulting hard, far
|
||||
terrain goes off and the render distance in use drops, never the saved option, and both come back when
|
||||
there is room. Linux and macOS have no such limit: there the game steps down before it reaches the limit.
|
||||
The game ends with a crash report only when it can't run under the limit: on Windows no frame for a minute
|
||||
while faulting hard, elsewhere 20 seconds over the limit with nothing left to give up.
|
||||
`HORIZON_MEMORY_LIMIT_MB` (the launcher sets it when the player picks a value; there is none by default)
|
||||
and `/ramlimit`, `/ramlimit <MB>`, `/ramlimit off` limit the RAM the game holds; the command lasts until
|
||||
the game closes and works on any server, as the window answers it. Once a second the game compares what
|
||||
it holds in RAM with the limit and, past nine tenths of it, turns far terrain off and then lowers the
|
||||
render distance in use, never the saved option, until it fits; both come back when there is room. This
|
||||
is the same on Windows, Linux and macOS: nothing asks the system to page the game out to meet the limit.
|
||||
The game ends with a crash report only when it can't run under the limit: 20 seconds over it with nothing
|
||||
left to give up.
|
||||
|
||||
**`HORIZON_MEMORY_HARD_MB` / `/ramlimit hard <MB>`** is the other contract, the one `-Xmx` gives
|
||||
Java: a budget for the memory the process commits, not only for the part that stays in RAM. Nothing is
|
||||
@@ -838,7 +838,6 @@ without an error. Both views were inspected. The
|
||||
seven client tests and all-target client Clippy also passed. These checks do not
|
||||
establish full vanilla gameplay or final Linux release acceptance on the target machine.
|
||||
|
||||
|
||||
## Pending attribute updates
|
||||
|
||||
The resident simulation now dispatches pending attribute callbacks after movement
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ These belong to `horizon-server`, not to the game:
|
||||
|
||||
## Planned: limiting a server to Horizon clients
|
||||
|
||||
The design (`docs/design.md`, section 6 of horizon-rust/horizon-server) adds one more setting. It is not in
|
||||
The design ([server-next.md](server-next.md#6-who-can-join), section 6) adds one more setting. It is not in
|
||||
`server.toml` yet.
|
||||
|
||||
```toml
|
||||
|
||||
@@ -5,6 +5,15 @@ Horizon's server does the same. That covers the dedicated server and single play
|
||||
(`Game::serving`, the default since #1114). So a LAN world's host and each guest can be in different
|
||||
dimensions at the same time.
|
||||
|
||||
## Contents
|
||||
|
||||
- [How the single-level `Game` maps onto vanilla's levels](#how-the-single-level-game-maps-onto-vanillas-levels)
|
||||
- [One code path for both](#one-code-path-for-both)
|
||||
- [Cost of an extra level](#cost-of-an-extra-level)
|
||||
- [Saving](#saving)
|
||||
- [What's checked](#whats-checked)
|
||||
- [Not done yet](#not-done-yet)
|
||||
|
||||
## How the single-level `Game` maps onto vanilla's levels
|
||||
|
||||
The game's tick code reads one level: the one held in `Game`'s own fields (the world, its streaming
|
||||
|
||||
+5
-4
@@ -7,8 +7,7 @@ should beat vanilla, Paper and Fabric + Lithium on TPS/MSPT, RAM, CPU, chunk gen
|
||||
Gameplay stays vanilla: there are no Paper-style rule changes unless the owner turns one on.
|
||||
|
||||
This page covers what the server does today (measured), the design of `horizon-server`, and what ships for the
|
||||
first working version (2026-10-23). The owner's guide is docs/hosting.md, also at
|
||||
https://playhorizon.dev/server.
|
||||
first working version (2026-10-23). The owner's guide is [docs/hosting.md](hosting.md).
|
||||
|
||||
## 1. Today
|
||||
|
||||
@@ -317,9 +316,11 @@ refuse. There is no `backups.before_update` setting: an update always backs up t
|
||||
- **Changes without opening the file.** `horizon-server config set KEY=VALUE...` (the launcher's Servers
|
||||
page uses it) writes through `toml_edit`, checks the result as `server.toml` is checked, and writes nothing
|
||||
if a value is wrong. On a running server it sends what vanilla changes live through its console:
|
||||
`difficulty`, `defaultgamemode` and `whitelist on|off`; the backup settings apply at once because the
|
||||
`difficulty`, `defaultgamemode` and `whitelist on|off`; `server.motd` and `server.max_players` go to the
|
||||
game as the management protocol's `serversettings/motd/set` and `serversettings/max_players/set` do, so
|
||||
the server list and the next logins use them at once; the backup settings apply at once because the
|
||||
supervisor reads them each time. Everything else waits for the next start, and the command says which. A
|
||||
real hot reload (view distance, MOTD) needs engine support and comes later.
|
||||
hot reload of the view distance needs engine support and comes later.
|
||||
- `server.properties`, `ops.json`, `whitelist.json`, `banned-*.json` and `world/` keep vanilla's names and
|
||||
formats. A vanilla server folder works as is, and so does going back to vanilla.
|
||||
|
||||
|
||||
@@ -189,6 +189,7 @@
|
||||
"New instance": "نسخة جديدة",
|
||||
"Loading filters": "جاري تحميل المرشحات",
|
||||
"Stopped": "متوقف",
|
||||
"{mode} · stopped": "{mode} · متوقف",
|
||||
"Extra arguments for the game, passed through as-is (quotes group a word with spaces). Applies the next time the game starts.": "وسائط إضافية للعبة، تُمرر كما هي (علامات التنصيص تجموع الكلمات التي تحتوي على مسافات). تُطبق في المرة القادمة التي تبدأ فيها اللعبة.",
|
||||
"Horizon never turns on Funnel: the server isn't put on the public internet, only shared with the people you invite.": "لا يفعّل هورايزون ميزة Funnel أبدًا: لا يتم وضع الخادم على الإنترنت العام، بل يتم مشاركته فقط مع الأشخاص الذين تدعوهم.",
|
||||
"Minecraft folder": "مجلد ماين كرافت",
|
||||
@@ -425,6 +426,7 @@
|
||||
"No version matches.": "لا يوجد إصدار مطابق.",
|
||||
"Invite a friend": "دعوة صديق",
|
||||
"Offline": "غير متصل",
|
||||
"{online} of {max}": "{online} من {max}",
|
||||
"Shortcut": "اختصار",
|
||||
"Update to {version}": "التحديث إلى {version}",
|
||||
"{name} is off.": "{name} مُعطّل.",
|
||||
|
||||
@@ -189,6 +189,7 @@
|
||||
"New instance": "Нов екземпляр",
|
||||
"Loading filters": "Зареждане на филтри",
|
||||
"Stopped": "Спряно",
|
||||
"{mode} · stopped": "{mode} · спряно",
|
||||
"Extra arguments for the game, passed through as-is (quotes group a word with spaces). Applies the next time the game starts.": "Допълнителни аргументи за играта, предадени както са (кавичките групират дума с интервали). Прилага се при следващото стартиране на играта.",
|
||||
"Horizon never turns on Funnel: the server isn't put on the public internet, only shared with the people you invite.": "Horizon никога не включва Funnel: сървърът не се поставя в публичния интернет, а се споделя само с хората, които поканите.",
|
||||
"Minecraft folder": "Minecraft folder",
|
||||
@@ -425,6 +426,7 @@
|
||||
"No version matches.": "Няма съответстваща версия.",
|
||||
"Invite a friend": "Поканете приятел",
|
||||
"Offline": "Офлайн",
|
||||
"{online} of {max}": "{online} от {max}",
|
||||
"Shortcut": "Пряк път",
|
||||
"Update to {version}": "Актуализиране до {version}",
|
||||
"{name} is off.": "{name} е изключен.",
|
||||
@@ -1073,8 +1075,8 @@
|
||||
"Shader pack": "Пакет с шейдъри",
|
||||
"soon": "скоро",
|
||||
"{support} in Horizon": "{support} в Horizon",
|
||||
"My Horizon": "Моят Horizon",
|
||||
"Game log": "Дневник на играта",
|
||||
"1 line": "1 ред",
|
||||
"{count} lines": "{count} реда",
|
||||
"My Horizon": "Моят Horizon"
|
||||
"{count} lines": "{count} реда"
|
||||
}
|
||||
@@ -189,6 +189,7 @@
|
||||
"New instance": "Ny instans",
|
||||
"Loading filters": "Indlæser filtre",
|
||||
"Stopped": "Stoppet",
|
||||
"{mode} · stopped": "{mode} · stoppet",
|
||||
"Extra arguments for the game, passed through as-is (quotes group a word with spaces). Applies the next time the game starts.": "Ekstra argumenter for spillet, som de er (anførselstegn grupperer et ord med mellemrum). Gælder næste gang spillet starter.",
|
||||
"Horizon never turns on Funnel: the server isn't put on the public internet, only shared with the people you invite.": "Horizon slår aldrig tragt til: Serveren sættes ikke på det offentlige internet, kun deles med de personer, du inviterer.",
|
||||
"Minecraft folder": "Minecraft mappe",
|
||||
@@ -425,6 +426,7 @@
|
||||
"No version matches.": "Ingen version matcher.",
|
||||
"Invite a friend": "Inviter en ven",
|
||||
"Offline": "Offline",
|
||||
"{online} of {max}": "{online} af {max}",
|
||||
"Shortcut": "Genvej",
|
||||
"Update to {version}": "Opdater til {version}",
|
||||
"{name} is off.": "{name} er slukket.",
|
||||
@@ -1073,8 +1075,8 @@
|
||||
"Shader pack": "Shaderpakke",
|
||||
"soon": "snart",
|
||||
"{support} in Horizon": "{support} i Horizon",
|
||||
"My Horizon": "Mit Horizon",
|
||||
"Game log": "Spillog",
|
||||
"1 line": "1 linje",
|
||||
"{count} lines": "{count} linjer",
|
||||
"My Horizon": "Min Horizon"
|
||||
"{count} lines": "{count} linjer"
|
||||
}
|
||||
@@ -189,6 +189,7 @@
|
||||
"New instance": "Neue Instanz",
|
||||
"Loading filters": "Filter werden geladen",
|
||||
"Stopped": "Angehalten",
|
||||
"{mode} · stopped": "{mode} · gestoppt",
|
||||
"Extra arguments for the game, passed through as-is (quotes group a word with spaces). Applies the next time the game starts.": "Zusätzliche Argumente für das Spiel, die unverändert weitergegeben werden (Anführungszeichen gruppieren Wörter mit Leerzeichen). Gilt beim nächsten Spielstart.",
|
||||
"Horizon never turns on Funnel: the server isn't put on the public internet, only shared with the people you invite.": "Horizon aktiviert niemals Funnel: Der Server wird nicht ins öffentliche Internet gestellt, sondern nur mit den von dir eingeladenen Personen geteilt.",
|
||||
"Minecraft folder": "Minecraft-Ordner",
|
||||
@@ -425,6 +426,7 @@
|
||||
"No version matches.": "Keine Version passt.",
|
||||
"Invite a friend": "Einen Freund einladen",
|
||||
"Offline": "Offline",
|
||||
"{online} of {max}": "{online} von {max}",
|
||||
"Shortcut": "Verknüpfung",
|
||||
"Update to {version}": "Auf {version} aktualisieren",
|
||||
"{name} is off.": "{name} ist ausgeschaltet.",
|
||||
|
||||
@@ -189,6 +189,7 @@
|
||||
"New instance": "Nueva instancia",
|
||||
"Loading filters": "Cargando filtros",
|
||||
"Stopped": "Detenido",
|
||||
"{mode} · stopped": "{mode} · detenido",
|
||||
"Extra arguments for the game, passed through as-is (quotes group a word with spaces). Applies the next time the game starts.": "Argumentos adicionales para el juego, transmitidos tal cual (las comillas agrupan palabras con espacios). Se aplica la próxima vez que inicie el juego.",
|
||||
"Horizon never turns on Funnel: the server isn't put on the public internet, only shared with the people you invite.": "Horizon nunca activa Funnel: el servidor no se expone al internet público, solo se comparte con las personas que invites.",
|
||||
"Minecraft folder": "Carpeta de Minecraft",
|
||||
@@ -425,6 +426,7 @@
|
||||
"No version matches.": "Ninguna versión coincide.",
|
||||
"Invite a friend": "Invitar a un amigo",
|
||||
"Offline": "Sin conexión",
|
||||
"{online} of {max}": "{online} de {max}",
|
||||
"Shortcut": "Acceso directo",
|
||||
"Update to {version}": "Actualizar a {version}",
|
||||
"{name} is off.": "{name} está desactivado.",
|
||||
|
||||
@@ -189,6 +189,7 @@
|
||||
"New instance": "Uusi esiintymä",
|
||||
"Loading filters": "Ladataan suodattimia",
|
||||
"Stopped": "Pysähtynyt",
|
||||
"{mode} · stopped": "{mode} · pysähtynyt",
|
||||
"Extra arguments for the game, passed through as-is (quotes group a word with spaces). Applies the next time the game starts.": "Pelin lisäargumentit, läpikäyneet sellaisenaan (lainausmerkit ryhmittelevät sanan välilyönneillä). Pätee, kun peli seuraavan kerran alkaa.",
|
||||
"Horizon never turns on Funnel: the server isn't put on the public internet, only shared with the people you invite.": "Horizon ei koskaan kytke suppiloa päälle: palvelinta ei ole asetettu julkiseen Internetiin, vaan se jaetaan vain kutsumiesi ihmisten kanssa.",
|
||||
"Minecraft folder": "Minecraft kansio",
|
||||
@@ -425,6 +426,7 @@
|
||||
"No version matches.": "Yksikään versio ei vastaa.",
|
||||
"Invite a friend": "Kutsu ystävä",
|
||||
"Offline": "Offline-tilassa",
|
||||
"{online} of {max}": "{online}/{max}",
|
||||
"Shortcut": "Pikakuvake",
|
||||
"Update to {version}": "Päivitä {version}",
|
||||
"{name} is off.": "{name} on pois päältä.",
|
||||
@@ -1073,8 +1075,8 @@
|
||||
"Shader pack": "Varjostinpaketti",
|
||||
"soon": "pian",
|
||||
"{support} in Horizon": "{support} Horizonissa",
|
||||
"My Horizon": "Oma Horizon",
|
||||
"Game log": "Pelin loki",
|
||||
"1 line": "1 rivi",
|
||||
"{count} lines": "{count} riviä",
|
||||
"My Horizon": "Oma Horizon"
|
||||
"{count} lines": "{count} riviä"
|
||||
}
|
||||
@@ -189,6 +189,7 @@
|
||||
"New instance": "Nouvelle instance",
|
||||
"Loading filters": "Chargement des filtres",
|
||||
"Stopped": "Arrêté",
|
||||
"{mode} · stopped": "{mode} · arrêté",
|
||||
"Extra arguments for the game, passed through as-is (quotes group a word with spaces). Applies the next time the game starts.": "Arguments supplémentaires pour le jeu, transmis tels quels (les guillemets regroupent les mots avec des espaces). S'applique au prochain lancement du jeu.",
|
||||
"Horizon never turns on Funnel: the server isn't put on the public internet, only shared with the people you invite.": "Horizon n'active jamais Funnel : le serveur n'est pas exposé sur l'Internet public, seulement partagé avec les personnes que vous invitez.",
|
||||
"Minecraft folder": "Dossier Minecraft",
|
||||
@@ -425,6 +426,7 @@
|
||||
"No version matches.": "Aucune version ne correspond.",
|
||||
"Invite a friend": "Inviter un ami",
|
||||
"Offline": "Hors ligne",
|
||||
"{online} of {max}": "{online} sur {max}",
|
||||
"Shortcut": "Raccourci",
|
||||
"Update to {version}": "Mettre à jour vers {version}",
|
||||
"{name} is off.": "{name} est désactivé.",
|
||||
|
||||
@@ -189,6 +189,7 @@
|
||||
"New instance": "מופע חדש",
|
||||
"Loading filters": "טוען מסננים",
|
||||
"Stopped": "נעצר",
|
||||
"{mode} · stopped": "{mode} · נעצר",
|
||||
"Extra arguments for the game, passed through as-is (quotes group a word with spaces). Applies the next time the game starts.": "ארגומנטים נוספים למשחק, מועברים כפי שהם (מרכאות מקבץ מילה עם רווחים). תקף בפעם הבאה שהמשחק מתחיל.",
|
||||
"Horizon never turns on Funnel: the server isn't put on the public internet, only shared with the people you invite.": "Horizon אף פעם לא מפעיל את משפך: השרת לא מוכנס לאינטרנט הציבורי, אלא משותף רק עם האנשים שאתה מזמין.",
|
||||
"Minecraft folder": "תיקיית Minecraft",
|
||||
@@ -425,6 +426,7 @@
|
||||
"No version matches.": "אין גרסה תואמת.",
|
||||
"Invite a friend": "הזמן חבר",
|
||||
"Offline": "לא מקוון",
|
||||
"{online} of {max}": "{online} מתוך {max}",
|
||||
"Shortcut": "קיצור דרך",
|
||||
"Update to {version}": "עדכן ל-{version}",
|
||||
"{name} is off.": "{name} כבוי.",
|
||||
@@ -1073,8 +1075,8 @@
|
||||
"Shader pack": "חבילת שיידרים",
|
||||
"soon": "בקרוב",
|
||||
"{support} in Horizon": "{support} ב-Horizon",
|
||||
"My Horizon": "ה-Horizon שלי",
|
||||
"Game log": "יומן המשחק",
|
||||
"1 line": "שורה אחת",
|
||||
"{count} lines": "{count} שורות",
|
||||
"My Horizon": "ה־Horizon שלי"
|
||||
"{count} lines": "{count} שורות"
|
||||
}
|
||||
@@ -189,6 +189,7 @@
|
||||
"New instance": "नया इंस्टेंस",
|
||||
"Loading filters": "फ़िल्टर लोड हो रहे हैं",
|
||||
"Stopped": "बंद कर दिया गया",
|
||||
"{mode} · stopped": "{mode} · बंद",
|
||||
"Extra arguments for the game, passed through as-is (quotes group a word with spaces). Applies the next time the game starts.": "गेम के लिए अतिरिक्त तर्क, जैसे का तैसा पास किए जाते हैं (उद्धरण चिह्नों से रिक्त स्थान वाले शब्द समूहित होते हैं)। अगली बार गेम शुरू होने पर लागू होता है।",
|
||||
"Horizon never turns on Funnel: the server isn't put on the public internet, only shared with the people you invite.": "Horizon कभी भी Funnel चालू नहीं करता है: सर्वर को सार्वजनिक इंटरनेट पर नहीं रखा जाता है, केवल आपके द्वारा आमंत्रित लोगों के साथ साझा किया जाता है।",
|
||||
"Minecraft folder": "Minecraft फ़ोल्डर",
|
||||
@@ -425,6 +426,7 @@
|
||||
"No version matches.": "कोई संस्करण मेल नहीं खाता।",
|
||||
"Invite a friend": "मित्र को आमंत्रित करें",
|
||||
"Offline": "ऑफ़लाइन",
|
||||
"{online} of {max}": "{max} में से {online}",
|
||||
"Shortcut": "शॉर्टकट",
|
||||
"Update to {version}": "{version} पर अपडेट करें",
|
||||
"{name} is off.": "{name} बंद है।",
|
||||
|
||||
@@ -189,6 +189,7 @@
|
||||
"New instance": "Nova instanca",
|
||||
"Loading filters": "Učitavanje filtera",
|
||||
"Stopped": "Zaustavljeno",
|
||||
"{mode} · stopped": "{mode} · zaustavljen",
|
||||
"Extra arguments for the game, passed through as-is (quotes group a word with spaces). Applies the next time the game starts.": "Dodatni argumenti za igru, proslijeđeni kakvi jesu (navodnici grupiraju riječ s razmacima). Primjenjuje se pri sljedećem pokretanju igre.",
|
||||
"Horizon never turns on Funnel: the server isn't put on the public internet, only shared with the people you invite.": "Horizon nikada ne uključuje Funnel: poslužitelj se ne stavlja na javni internet, već se dijeli samo s ljudima koje pozovete.",
|
||||
"Minecraft folder": "Minecraft mapa",
|
||||
@@ -425,6 +426,7 @@
|
||||
"No version matches.": "Nema odgovarajuće verzije.",
|
||||
"Invite a friend": "Pozovi prijatelja",
|
||||
"Offline": "Izvan mreže",
|
||||
"{online} of {max}": "{online} od {max}",
|
||||
"Shortcut": "Prečac",
|
||||
"Update to {version}": "Ažuriraj na {version}",
|
||||
"{name} is off.": "{name} je isključen.",
|
||||
|
||||
@@ -189,6 +189,7 @@
|
||||
"New instance": "Új példány",
|
||||
"Loading filters": "Szűrők betöltése",
|
||||
"Stopped": "Megállt",
|
||||
"{mode} · stopped": "{mode} · megállt",
|
||||
"Extra arguments for the game, passed through as-is (quotes group a word with spaces). Applies the next time the game starts.": "További érvek a játékhoz, ahogy vannak (idézőjelek csoportosítanak egy szót szóközökkel). A játék következő indításakor érvényes.",
|
||||
"Horizon never turns on Funnel: the server isn't put on the public internet, only shared with the people you invite.": "A Horizon soha nem kapcsolja be a tölcsért: a szerver nem kerül fel a nyilvános internetre, csak az Ön által meghívott személyekkel osztják meg.",
|
||||
"Minecraft folder": "Minecraft mappa",
|
||||
@@ -425,6 +426,7 @@
|
||||
"No version matches.": "Egyetlen verzió sem egyezik.",
|
||||
"Invite a friend": "Hívj meg egy barátot",
|
||||
"Offline": "Offline",
|
||||
"{online} of {max}": "{online}/{max}",
|
||||
"Shortcut": "Parancsikon",
|
||||
"Update to {version}": "Frissítés erre: {version}",
|
||||
"{name} is off.": "{name} ki van kapcsolva.",
|
||||
@@ -1073,8 +1075,8 @@
|
||||
"Shader pack": "Shadercsomag",
|
||||
"soon": "hamarosan",
|
||||
"{support} in Horizon": "{support} a Horizonban",
|
||||
"My Horizon": "Saját Horizon",
|
||||
"Game log": "Játéknapló",
|
||||
"1 line": "1 sor",
|
||||
"{count} lines": "{count} sor",
|
||||
"My Horizon": "Saját Horizon"
|
||||
"{count} lines": "{count} sor"
|
||||
}
|
||||
@@ -189,6 +189,7 @@
|
||||
"New instance": "Instans baru",
|
||||
"Loading filters": "Memuat filter",
|
||||
"Stopped": "Berhenti",
|
||||
"{mode} · stopped": "{mode} · berhenti",
|
||||
"Extra arguments for the game, passed through as-is (quotes group a word with spaces). Applies the next time the game starts.": "Argumen tambahan untuk game, diteruskan apa adanya (tanda kutip mengelompokkan kata dengan spasi). Berlaku pada saat game berikutnya dimulai.",
|
||||
"Horizon never turns on Funnel: the server isn't put on the public internet, only shared with the people you invite.": "Horizon tidak pernah mengaktifkan Funnel: server tidak dimasukkan ke internet publik, melainkan hanya dibagikan kepada orang yang Anda undang.",
|
||||
"Minecraft folder": "Folder Minecraft",
|
||||
@@ -425,6 +426,7 @@
|
||||
"No version matches.": "Tidak ada versi yang cocok.",
|
||||
"Invite a friend": "Undang teman",
|
||||
"Offline": "Offline",
|
||||
"{online} of {max}": "{online} dari {max}",
|
||||
"Shortcut": "Pintasan",
|
||||
"Update to {version}": "Perbarui ke {version}",
|
||||
"{name} is off.": "{name} nonaktif.",
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"Play offline": "オフラインでプレイ",
|
||||
"Sign out": "サインアウト",
|
||||
"Offline": "オフライン",
|
||||
"{online} of {max}": "{online} / {max}",
|
||||
"Microsoft": "Microsoft",
|
||||
"Offline, the game's name": "オフライン、ゲーム名",
|
||||
"Manage accounts": "アカウント管理",
|
||||
@@ -94,6 +95,7 @@
|
||||
"Play": "プレイ",
|
||||
"Running": "実行中",
|
||||
"Stopped": "停止中",
|
||||
"{mode} · stopped": "{mode} · 停止中",
|
||||
"Start the game, or run a server for others to join.": "ゲームを起動するか、他の人が参加できるサーバーを実行します。",
|
||||
"No version installed yet.": "まだバージョンがインストールされていません。",
|
||||
"Choose a version": "バージョンを選択",
|
||||
|
||||
@@ -189,6 +189,7 @@
|
||||
"New instance": "새 인스턴스",
|
||||
"Loading filters": "필터 로딩 중",
|
||||
"Stopped": "중지됨",
|
||||
"{mode} · stopped": "{mode} · 중지됨",
|
||||
"Extra arguments for the game, passed through as-is (quotes group a word with spaces). Applies the next time the game starts.": "게임에 전달할 추가 인수입니다(따옴표로 공백 포함 단어 그룹화 가능). 다음 게임 시작 시 적용됩니다.",
|
||||
"Horizon never turns on Funnel: the server isn't put on the public internet, only shared with the people you invite.": "Horizon은 Funnel을 절대 활성화하지 않습니다. 서버가 공용 인터넷에 노출되지 않으며 초대된 사람들과만 공유됩니다.",
|
||||
"Minecraft folder": "Minecraft 폴더",
|
||||
@@ -425,6 +426,7 @@
|
||||
"No version matches.": "일치하는 버전이 없습니다.",
|
||||
"Invite a friend": "친구 초대",
|
||||
"Offline": "오프라인",
|
||||
"{online} of {max}": "{online}/{max}",
|
||||
"Shortcut": "바로 가기",
|
||||
"Update to {version}": "{version}(으)로 업데이트",
|
||||
"{name} is off.": "{name} 꺼짐.",
|
||||
|
||||
@@ -189,6 +189,7 @@
|
||||
"New instance": "Naujas pavyzdys",
|
||||
"Loading filters": "Įkeliami filtrai",
|
||||
"Stopped": "Sustojo",
|
||||
"{mode} · stopped": "{mode} · sustojo",
|
||||
"Extra arguments for the game, passed through as-is (quotes group a word with spaces). Applies the next time the game starts.": "Papildomi žaidimo argumentai, perduodami tokie, kokie yra (kabatos sugrupuoja žodį su tarpais). Taikoma kitą kartą prasidėjus žaidimui.",
|
||||
"Horizon never turns on Funnel: the server isn't put on the public internet, only shared with the people you invite.": "Horizontas niekada neįjungia kanalo: serveris nėra įtrauktas į viešąjį internetą, tik bendrinamas su žmonėmis, kuriuos pakviečiate.",
|
||||
"Minecraft folder": "Minecraft aplankas",
|
||||
@@ -425,6 +426,7 @@
|
||||
"No version matches.": "Neatitinka nė viena versija.",
|
||||
"Invite a friend": "Pakviesk draugą",
|
||||
"Offline": "Neprisijungus",
|
||||
"{online} of {max}": "{online} iš {max}",
|
||||
"Shortcut": "Spartusis klavišas",
|
||||
"Update to {version}": "Atnaujinti į {version}",
|
||||
"{name} is off.": "{name} išjungtas.",
|
||||
@@ -1073,8 +1075,8 @@
|
||||
"Shader pack": "Šešėliavimo paketas",
|
||||
"soon": "netrukus",
|
||||
"{support} in Horizon": "{support} Horizon",
|
||||
"My Horizon": "Mano Horizon",
|
||||
"Game log": "Žaidimo žurnalas",
|
||||
"1 line": "1 eilutė",
|
||||
"{count} lines": "{count} eilučių",
|
||||
"My Horizon": "Mano Horizon"
|
||||
"{count} lines": "{count} eilučių"
|
||||
}
|
||||
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user