Merge upstream main (Setup PR merged) into mouse controls

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
martipopsandClaude Opus 5.5 committed 2026-11-02 01:39:57 -05:00
commit ab8be4adc3
85 files changed
+10572 -1026

No files matched your search

+60 -2
View File
@@ -14,7 +14,8 @@
//!
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
//!
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
//! It prints one JSON line a second (bots online, chunks received), one with the first bot's spawn position, and
//! writes a summary with `--report`.
//! A bot moves only while it holds the chunk it stands in: vanilla's client stops ticking its player in a chunk
//! it hasn't received, so a player can't outrun the server's chunk sending, on any server.
//! Each bot also keeps the chunks it holds, as a client's chunk cache does, so the report tells chunks that
@@ -102,6 +103,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
to test what a server takes
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
--report FILE a JSON summary at the end (- prints it after the per-second lines)
--capture FILE record the first bot's play packets (milliseconds since it joined, id, body)
for crates/horizon-protocol/examples/net_replay.rs
";
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -177,6 +180,8 @@ struct Options {
/// The `minecraft:brand` to send, if any.
brand: Option<String>,
report: Option<String>,
/// --capture: where the first bot's play packets go.
capture: Option<String>,
/// How many times as fast as vanilla's 20 Hz the bots tick (a timer cheat above 1).
timer: f64,
}
@@ -207,6 +212,7 @@ fn options() -> Result<Options, String> {
click: None,
name: "Bot".into(),
report: None,
capture: None,
brand: None,
timer: 1.0,
};
@@ -256,6 +262,7 @@ fn options() -> Result<Options, String> {
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--capture" => o.capture = Some(value),
"--brand" => o.brand = Some(value),
"--give" => {
for part in value.split(',') {
@@ -819,12 +826,53 @@ impl BatchSize {
}
}
/// `--capture`: one record per play packet, little-endian: u32 milliseconds since the
/// bot joined, i32 packet id, u32 body length, then the body (without the id).
struct Capture {
out: std::io::BufWriter<std::fs::File>,
start: Instant,
/// The second last flushed: the process may end before the reader thread does.
flushed: u32,
}
impl Capture {
fn create(path: &str) -> Option<Self> {
match std::fs::File::create(path) {
Ok(file) => Some(Self {
out: std::io::BufWriter::new(file),
start: Instant::now(),
flushed: 0,
}),
Err(e) => {
eprintln!("--capture {path}: {e}");
None
}
}
}
fn record(&mut self, id: i32, body: &[u8]) {
use std::io::Write;
let ms = self.start.elapsed().as_millis() as u32;
let mut head = [0u8; 12];
head[..4].copy_from_slice(&ms.to_le_bytes());
head[4..8].copy_from_slice(&id.to_le_bytes());
head[8..].copy_from_slice(&(body.len() as u32).to_le_bytes());
let _ = self
.out
.write_all(&head)
.and_then(|()| self.out.write_all(body));
if ms / 1000 != self.flushed {
self.flushed = ms / 1000;
let _ = self.out.flush();
}
}
}
/// Reads the play state: answers what a client must answer, counts chunks, records teleports.
fn read(
mut reader: horizon_protocol::connection::ConnReader,
writer: Arc<Mutex<ConnWriter>>,
bot: Arc<Bot>,
track: bool,
mut capture: Option<Capture>,
) {
use ids::play::clientbound as cb;
use ids::play::serverbound as sb;
@@ -848,6 +896,9 @@ fn read(
return;
}
};
if let Some(c) = &mut capture {
c.record(id, &body);
}
let mut r = Reader::new(&body);
let sent = match id {
cb::KEEP_ALIVE => match r.i64() {
@@ -1007,7 +1058,10 @@ fn run_bot(
{
let (writer, bot) = (writer.clone(), bot.clone());
let track = behaviour == Behaviour::Fight;
thread::spawn(move || read(reader, writer, bot, track));
let capture = (index == 0)
.then(|| o.capture.as_deref().and_then(Capture::create))
.flatten();
thread::spawn(move || read(reader, writer, bot, track, capture));
}
use ids::play::serverbound as sb;
// Wait for the spawn position.
@@ -1025,6 +1079,10 @@ fn run_bot(
};
bot.joined_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
if index == 0 {
// Where the bots gather, for a client that comes to watch them (tools/perf_bench.py --server-bots).
println!("{}", json!({ "spawn": spawn }));
}
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
if !o.no_loaded {
send(raw(sb::PLAYER_LOADED, |_| {}));
+12 -6
View File
@@ -564,14 +564,20 @@ impl Chunk {
}
#[inline]
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
let s = (y - self.min_y) >> 4;
if s < 0 {
return 0;
match self.section_at(y) {
Ok(section) => section.light(index(x, y, z)),
Err(light) => light,
}
if s >= self.sections.len() as i32 {
return 0xF0;
}
/// The section holding block height `y`, or the light of the air outside the
/// column's sections there: none below them, full sky above.
#[inline]
pub fn section_at(&self, y: i32) -> Result<&Section, u8> {
match self.section_index(y) {
Some(s) => Ok(&self.sections[s]),
None if y < self.min_y => Err(0),
None => Err(0xF0),
}
self.sections[s as usize].light(index(x, y, z))
}
/// Heap bytes held by the sections (benchmark accounting).
pub fn heap_bytes(&self) -> usize {
+2 -1
View File
@@ -518,7 +518,8 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
}
}
/// Whether `name` is one of the streaming pool's threads, vanilla's `Util` worker threads.
/// Whether `name` is one of the shared worker pool's threads (`horizon_threads::jobs`), vanilla's
/// `Util` worker threads.
fn is_worker_thread(name: Option<&str>) -> bool {
name.is_some_and(|n| n.starts_with("horizon-worker-"))
}
+132 -28
View File
@@ -110,6 +110,7 @@ mod arena;
pub mod compositor;
mod cull;
mod end_sky;
pub mod entity_skin;
mod flat;
mod frame_gen;
mod hdr;
@@ -627,6 +628,9 @@ pub struct Frame<'a> {
pub glowing: &'a [Vertex],
/// Runs of `entities` by entity, while a shader pack draws them (`pack_draw::EntitySpan`).
pub entity_spans: &'a [pack_draw::EntitySpan],
/// Ordinary entity models posed on the GPU (`entity_skin`), drawn before `entities`'
/// ordinary ones; only when `Renderer::skins_entities` said so.
pub skin: Option<&'a entity_skin::SkinFrame>,
pub weather: &'a [crate::weather::Vertex],
pub weather_time: crate::weather::Time,
/// Horizon Extras' dynamic lights, nearest first (`dynamic_light::select`); empty when off.
@@ -856,6 +860,8 @@ pub struct Renderer {
entity_buffer: wgpu::Buffer,
entity_capacity: u64,
entity_batch: EntityBatch,
/// Ordinary entity models posed on the GPU; `None` without compute shaders.
entity_skin: Option<entity_skin::EntitySkin>,
group: wgpu::BindGroup,
globals: wgpu::Buffer,
/// The resource packs' LabPBR maps (normals, specular), for native shader packs.
@@ -1523,37 +1529,64 @@ async fn open_device(
api: GraphicsApi,
layers: u32,
) -> Result<Opened> {
// A software adapter (WARP, llvmpipe) is a last resort: a later backend may
// have the real GPU (#3978: a Fermi GPU with no Vulkan and no DX12 driver
// still has OpenGL).
match open_device_with(window, offscreen, api, layers, false).await {
Ok(opened) => Ok(opened),
Err(first) => match open_device_with(window, offscreen, api, layers, true).await {
let wgpu_backend = std::env::var_os("WGPU_BACKEND").is_some();
let mut first = None;
for (order, allow_cpu) in device_search(api.with_env(), std::env::consts::OS, wgpu_backend) {
match open_device_with(window, offscreen, &order, layers, allow_cpu).await {
Ok(opened) => {
log::warn!(
"Horizon renderer: no GPU adapter could be used ({first}); \
rendering on the CPU, which is slow"
);
Ok(opened)
if let Some(first) = &first {
log::warn!(
"Horizon renderer: the chosen graphics API gave no GPU ({first}); {}",
if allow_cpu {
"rendering on the CPU, which is slow"
} else {
"using the GPU through another one"
}
);
}
return Ok(opened);
}
Err(_) => Err(first),
},
Err(e) => {
first.get_or_insert(e);
}
}
}
Err(first.unwrap_or_else(|| NO_GPU_DRIVER.into()))
}
/// `open_device`, taking an adapter of the CPU type (software rendering) only
/// when `allow_cpu`.
/// The tries `open_device` makes, in turn: the chosen API's backends (`backend_order`) on a
/// real GPU; when the player chose one API, the other backends `Auto` would try, on a real
/// GPU; and last the chosen backends with a software adapter (WARP, llvmpipe) allowed.
/// Software rendering takes most of a weak CPU even on the title screen: an Intel HD 2500 has
/// DirectX 12 only through WARP and its GPU through OpenGL (#5185), and a Fermi GPU has no
/// Vulkan or DirectX 12 driver but OpenGL (#3978). DirectX 12 never falls back to Vulkan: the
/// launcher chooses it after Vulkan crashed in the driver (#746), which the game can't catch.
fn device_search(
api: GraphicsApi,
os: &str,
wgpu_backend: bool,
) -> Vec<(Vec<wgpu::Backends>, bool)> {
let chosen = backend_order(api, os, wgpu_backend);
let others: Vec<_> = backend_order(GraphicsApi::Auto, os, wgpu_backend)
.into_iter()
.filter(|b| !chosen.contains(b))
.filter(|&b| !(api == GraphicsApi::Dx12 && b == wgpu::Backends::VULKAN))
.collect();
let mut tries = vec![(chosen.clone(), false)];
if !others.is_empty() {
tries.push((others, false));
}
tries.push((chosen, true));
tries
}
/// `open_device` on `order`'s backends in turn, taking an adapter of the CPU type
/// (software rendering) only when `allow_cpu`.
async fn open_device_with(
window: &Arc<Window>,
offscreen: bool,
api: GraphicsApi,
order: &[wgpu::Backends],
layers: u32,
allow_cpu: bool,
) -> Result<Opened> {
let order = backend_order(
api.with_env(),
std::env::consts::OS,
std::env::var_os("WGPU_BACKEND").is_some(),
);
let mut failures = Vec::new();
for (attempt, backends) in order.iter().copied().enumerate() {
let name = backends_name(backends);
@@ -3443,10 +3476,14 @@ impl Renderer {
mapped_at_creation: false,
});
let entity_capacity = 1 << 14;
let entity_skin = entity_skin::EntitySkin::new(
&device,
animate::SpriteAnimator::compute(&adapter, &device),
);
let entity_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Entity vertices"),
size: entity_capacity * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
usage: entity_skin::EntitySkin::usage(entity_skin.is_some()),
mapped_at_creation: false,
});
let flat_wanted = flat::wanted(&adapter, &device);
@@ -3672,6 +3709,7 @@ impl Renderer {
entity_buffer,
entity_capacity,
entity_batch: EntityBatch::default(),
entity_skin,
group,
globals,
pbr_maps,
@@ -4841,6 +4879,23 @@ impl Renderer {
}
/// The share of real frames frame generation shows a made frame before (1 while it
/// keeps up), `None` while it isn't running.
/// Whether the game may pose ordinary entity models on the GPU (`Frame::skin`): not
/// while a shader pack or a native pack draws entities from their vertices, nor while
/// frame generation follows them.
/// The posed models' store size (`SkinFrame::base`), with entity skinning.
pub fn skinned_models(&self) -> Option<u32> {
self.entity_skin
.as_ref()
.map(entity_skin::EntitySkin::stored)
}
pub fn skins_entities(&self) -> bool {
self.entity_skin.is_some()
&& !self.frame_gen.on
&& self.pack_post.is_none()
&& self.native.is_none()
}
pub fn frame_generation_share(&self) -> Option<f32> {
self.frame_gen.made_share
}
@@ -6412,6 +6467,13 @@ impl Renderer {
oit_on,
);
self.entity_batch.append_shadow_only(frame.shadow_only);
// The posed models' vertices come first: the ordinary entities' draw takes them in.
let skin = frame.skin.filter(|skin| match &mut self.entity_skin {
Some(skinner) => skinner.register(&self.device, &mut self.uploads, &mut encoder, skin),
None => false,
});
let posed = skin.map_or(0, |s| s.vertices);
let entity_quads = entity_quads + posed / 4;
let weather_quads = frame.weather.len() as u32 / 4;
let world_quads = entity_quads
+ text_quads
@@ -6467,7 +6529,7 @@ impl Renderer {
camera,
);
// Frame generation's made frame draws its own hand, halfway, after the others.
let made_hand_vertex = self.entity_batch.combined.len() as i32;
let made_hand_vertex = (posed as usize + self.entity_batch.combined.len()) as i32;
if fg {
self.frame_gen
.halfway_hand(frame.hand, hand_view, &mut self.entity_batch.combined);
@@ -6484,24 +6546,34 @@ impl Renderer {
let additive_quads = (frame.additive.len() / 4) as u32 - self.entity_batch.moved_additive;
let see_through_quads = (frame.see_through.len() / 4) as u32;
let shadow_only_quads = (frame.shadow_only.len() / 4) as u32;
let entity_vertices = all_entities.len() as u64;
let entity_vertices = u64::from(posed) + all_entities.len() as u64;
if entity_vertices > self.entity_capacity {
self.entity_capacity = entity_vertices.next_power_of_two();
self.entity_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Entity vertices"),
size: self.entity_capacity * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
usage: entity_skin::EntitySkin::usage(self.entity_skin.is_some()),
mapped_at_creation: false,
});
}
if entity_vertices > 0 {
if !all_entities.is_empty() {
self.uploads.buffer(
&mut encoder,
&self.entity_buffer,
0,
u64::from(posed) * std::mem::size_of::<Vertex>() as u64,
bytemuck::cast_slice(all_entities),
);
}
if let (Some(skinner), Some(skin)) = (&mut self.entity_skin, skin) {
skinner.dispatch(
&self.device,
&mut self.uploads,
&mut encoder,
skin,
&self.entity_buffer,
0,
);
}
self.entity_outline.prepare(
&self.device,
&mut self.uploads,
@@ -8522,8 +8594,9 @@ impl Renderer {
if let Some(radius) = menu_blur {
let format = self.config.format;
let device = &self.device;
let software = is_software(self.info.device_type);
self.menu_blur
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format))
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format, software))
.apply(
&self.device,
&self.queue,
@@ -9312,6 +9385,37 @@ mod tests {
assert_eq!(order(api, "macos"), [Backends::default()]);
}
}
#[test]
fn a_chosen_api_without_a_gpu_tries_the_others_before_software() {
use super::{GraphicsApi, device_search};
use wgpu::Backends;
let (v, d, g) = (Backends::VULKAN, Backends::DX12, Backends::GL);
// #5185: DirectX 12 chosen on an Intel HD 2500 (WARP only): OpenGL's GPU before WARP.
assert_eq!(
device_search(GraphicsApi::Dx12, "windows", false),
[(vec![d], false), (vec![g], false), (vec![d], true)]
);
assert_eq!(
device_search(GraphicsApi::Vulkan, "windows", false),
[(vec![v], false), (vec![d, g], false), (vec![v], true)]
);
assert_eq!(
device_search(GraphicsApi::Auto, "windows", false),
[(vec![v, d, g], false), (vec![v, d, g], true)]
);
assert_eq!(
device_search(GraphicsApi::OpenGl, "linux", false),
[(vec![g], false), (vec![v], false), (vec![g], true)]
);
// WGPU_BACKEND and macOS: one set of backends, nothing else to try.
let all = Backends::default();
for (os, env) in [("windows", true), ("macos", false)] {
assert_eq!(
device_search(GraphicsApi::Vulkan, os, env),
[(vec![all], false), (vec![all], true)]
);
}
}
/// The second try at a backend shows the adapters wgpu hides for reporting
/// no Vulkan conformance version (Mesa's hasvk, which drives Ivy Bridge and
/// Haswell, reports 0.0.0.0 and works).
@@ -0,0 +1,511 @@
//! Entity models posed on the GPU. The CPU path (`mobs::render::draw`) walks a
//! model's parts and pushes every vertex of every polygon, transformed by its
//! part's matrix: ~280 vertices of 44 bytes per mob, then copied by material
//! and uploaded each frame. Here each model's vertices are uploaded once, in
//! model space, and a frame only sends each drawn part's matrices and one job
//! per run of drawn parts; `entity_skin.wgsl` writes the same vertices, in the
//! same order, into the entity vertex buffer, after the CPU's ordinary entities
//! (`EntityBatch` leaves the gap). Every pass that draws ordinary entities
//! then draws them as before.
//!
//! The game decides which draws come here (`SkinFrame`): models whose
//! polygons all face an axis, drawn as ordinary opaque or cutout entities.
//! The rest stays on the CPU path.
use super::upload::Uploads;
use crate::mesher::Vertex;
/// A model vertex: position / 16, model UV, the face's normal as an axis (0..2
/// = +x, +y, +z; 3..5 = -x, -y, -z), the part's index in its model.
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SkinVertex {
pub pos: [f32; 3],
pub uv: [f32; 2],
pub axis: u32,
pub part: u32,
}
/// A drawn part: its geometry matrix and its normal matrix, by columns, the
/// normal matrix's columns normalized when the part's scale is not uniform.
#[repr(C)]
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SkinPart {
pub matrix: [f32; 16],
pub normal: [f32; 9],
}
/// One run of a model's vertices drawn with one texture rect, colour, light
/// and overlay. `dst` counts from the frame's first posed vertex.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SkinJob {
pub first: u32,
pub count: u32,
pub part: u32,
pub dst: u32,
pub rect: [f32; 4],
pub scroll: f32,
pub color: u32,
pub light: u32,
pub overlay: u32,
/// 1 when the overlay is the armour glint: the model UV rides in `glint_uv`.
pub glint: u32,
pub spare: [u32; 3],
}
const _: () = assert!(std::mem::size_of::<Vertex>() == 44);
const _: () = assert!(std::mem::size_of::<SkinVertex>() == 7 * 4);
const _: () = assert!(std::mem::size_of::<SkinPart>() == 25 * 4);
const _: () = assert!(std::mem::size_of::<SkinJob>() == 16 * 4);
/// What the game hands the renderer for one frame.
#[derive(Default)]
pub struct SkinFrame {
/// The model store restarted (models are registered again from 0).
pub reset: bool,
/// The store's size before `new_model`: the renderer takes the frame only
/// when its own store is that large (it missed none of the earlier ones).
pub base: u32,
/// Model vertices registered since the last frame, at the store's end.
pub new_model: Vec<SkinVertex>,
pub parts: Vec<SkinPart>,
pub jobs: Vec<SkinJob>,
/// Vertices the jobs write: a whole number of quads.
pub vertices: u32,
}
impl SkinFrame {
/// Starts a frame's draws; models not yet handed to the renderer stay.
pub fn clear_draws(&mut self) {
self.parts.clear();
self.jobs.clear();
self.vertices = 0;
}
/// The renderer took the new models.
pub fn handed(&mut self) {
self.base += self.new_model.len() as u32;
self.new_model.clear();
self.reset = false;
}
/// Where the draws stand, to take back a mob's (`truncate`).
pub fn mark(&self) -> (usize, usize, u32) {
(self.jobs.len(), self.parts.len(), self.vertices)
}
/// Takes back the draws since `mark` (a mob culled after drawing).
pub fn truncate(&mut self, (jobs, parts, vertices): (usize, usize, u32)) {
self.jobs.truncate(jobs);
self.parts.truncate(parts);
self.vertices = vertices;
}
/// Whether the renderer has anything to take.
pub fn pending(&self) -> bool {
self.reset || !self.new_model.is_empty() || !self.jobs.is_empty()
}
}
/// Jobs per row of workgroups (the dispatch limit is 65535 per dimension).
const ROW: u32 = 32_768;
pub(super) struct EntitySkin {
pipeline: wgpu::ComputePipeline,
layout: wgpu::BindGroupLayout,
model: Vec<SkinVertex>,
model_buffer: Option<wgpu::Buffer>,
parts: Grow,
jobs: Grow,
/// The jobs with `dst` moved to the frame's gap, as uploaded.
staged: Vec<SkinJob>,
/// The bind group and the buffers it binds (buffers compare by identity).
group: Option<(wgpu::BindGroup, [wgpu::Buffer; 4])>,
}
/// A storage buffer that grows to the next power of two.
struct Grow {
label: &'static str,
buffer: Option<wgpu::Buffer>,
}
impl Grow {
fn ensure(&mut self, device: &wgpu::Device, bytes: u64) -> bool {
let bytes = bytes.max(256).next_power_of_two();
if self.buffer.as_ref().is_some_and(|b| b.size() >= bytes) {
return false;
}
self.buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some(self.label),
size: bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}));
true
}
}
impl EntitySkin {
/// The entity vertex buffer's usage: the jobs write into it.
pub(super) fn usage(skinning: bool) -> wgpu::BufferUsages {
let usage = wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST;
if skinning {
usage | wgpu::BufferUsages::STORAGE
} else {
usage
}
}
/// The compute pipeline, or `None` without compute shaders (the CPU path
/// then draws every model).
pub(super) fn new(device: &wgpu::Device, compute: bool) -> Option<Self> {
// HORIZON_NO_ENTITY_SKIN: every model on the CPU path (A/B runs, a driver's bug).
if !compute
|| device.limits().max_storage_buffers_per_shader_stage < 4
|| std::env::var_os("HORIZON_NO_ENTITY_SKIN").is_some()
{
return None;
}
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Entity skinning"),
source: wgpu::ShaderSource::Wgsl(include_str!("entity_skin.wgsl").into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Entity skinning"),
layout: None,
module: &module,
entry_point: Some("skin"),
compilation_options: Default::default(),
cache: None,
});
let layout = pipeline.get_bind_group_layout(0);
let internal = pollster::block_on(internal.pop());
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
log::warn!("Horizon renderer: entity models posed on the CPU ({e})");
return None;
}
Some(Self {
pipeline,
layout,
model: Vec::new(),
model_buffer: None,
parts: Grow {
label: "Entity skinning parts",
buffer: None,
},
jobs: Grow {
label: "Entity skinning jobs",
buffer: None,
},
staged: Vec::new(),
group: None,
})
}
/// The model vertices stored (`SkinFrame::base` of the next frame).
pub(super) fn stored(&self) -> u32 {
self.model.len() as u32
}
/// Takes the frame's new models; whether its jobs can run (the store
/// holds every model they read).
pub(super) fn register(
&mut self,
device: &wgpu::Device,
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
frame: &SkinFrame,
) -> bool {
if frame.reset {
self.model.clear();
self.model_buffer = None;
}
if self.model.len() as u32 != frame.base {
// Out of step with the game's store: it restarts it.
return false;
}
if frame.new_model.is_empty() {
return self.model_buffer.is_some() || frame.jobs.is_empty();
}
let old = self.model.len();
self.model.extend_from_slice(&frame.new_model);
let bytes = (self.model.len() * std::mem::size_of::<SkinVertex>()) as u64;
if self
.model_buffer
.as_ref()
.is_some_and(|b| b.size() >= bytes)
{
let buffer = self.model_buffer.as_ref().expect("checked");
uploads.buffer(
encoder,
buffer,
(old * std::mem::size_of::<SkinVertex>()) as u64,
bytemuck::cast_slice(&frame.new_model),
);
} else {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Entity skinning models"),
size: bytes.max(256).next_power_of_two(),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
uploads.buffer(encoder, &buffer, 0, bytemuck::cast_slice(&self.model));
self.model_buffer = Some(buffer);
}
true
}
/// Records the frame's jobs: they write `frame.vertices` vertices into
/// `out` from vertex `base` on. Call after the CPU vertices' upload.
#[allow(clippy::too_many_arguments)]
pub(super) fn dispatch(
&mut self,
device: &wgpu::Device,
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
frame: &SkinFrame,
out: &wgpu::Buffer,
base: u32,
) {
if frame.jobs.is_empty() {
return;
}
let Some(model) = &self.model_buffer else {
return;
};
// Whole rows of workgroups past `ROW` jobs: the padding jobs write nothing.
let count = frame.jobs.len() as u32;
let rows = count.div_ceil(ROW);
let padded = if rows > 1 {
(rows * ROW) as usize
} else {
frame.jobs.len()
};
self.staged.clear();
self.staged.extend(frame.jobs.iter().map(|job| SkinJob {
dst: job.dst + base,
..*job
}));
self.staged.resize(padded, SkinJob::default());
self.parts.ensure(
device,
(frame.parts.len() * std::mem::size_of::<SkinPart>()) as u64,
);
self.jobs
.ensure(device, (padded * std::mem::size_of::<SkinJob>()) as u64);
let (Some(parts), Some(jobs)) = (&self.parts.buffer, &self.jobs.buffer) else {
return;
};
uploads.buffer(encoder, parts, 0, bytemuck::cast_slice(&frame.parts));
uploads.buffer(encoder, jobs, 0, bytemuck::cast_slice(&self.staged));
let key = [model.clone(), parts.clone(), jobs.clone(), out.clone()];
if self.group.as_ref().is_none_or(|(_, k)| *k != key) {
let entries: Vec<_> = key
.iter()
.enumerate()
.map(|(binding, buffer)| wgpu::BindGroupEntry {
binding: binding as u32,
resource: buffer.as_entire_binding(),
})
.collect();
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Entity skinning"),
layout: &self.layout,
entries: &entries,
});
self.group = Some((group, key));
}
let Some((group, _)) = &self.group else {
return;
};
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Entity skinning"),
timestamp_writes: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups(count.min(ROW), rows, 1);
}
}
/// What `entity_skin.wgsl` writes for `frame`'s jobs, computed on the CPU
/// the same way: the reference its tests hold the shader and the game's walk
/// (`mobs::skin`) to. `model` is the whole store.
pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
let mut out = vec![bytemuck::Zeroable::zeroed(); frame.vertices as usize];
for job in &frame.jobs {
for k in 0..job.count {
let v = model[(job.first + k) as usize];
let part = &frame.parts[(job.part + v.part) as usize];
let m = glam::Mat4::from_cols_array(&part.matrix);
let pos = m.transform_point3(glam::Vec3::from_array(v.pos));
let c = (v.axis % 3) as usize * 3;
let column = glam::Vec3::from_slice(&part.normal[c..c + 3]);
let n = if v.axis >= 3 { -column } else { column };
let packed = n.to_array().map(|v| (v.clamp(-1.0, 1.0) * 127.0) as i8);
let u = if job.scroll == 0.0 {
v.uv[0]
} else {
(v.uv[0] + job.scroll) % 1.0
};
let r = job.rect;
out[(job.dst + k) as usize] = Vertex {
pos: pos.to_array(),
uv: [r[0] + (r[2] - r[0]) * u, r[1] + (r[3] - r[1]) * v.uv[1]],
layer: job.overlay,
color: job.color,
light: job.light,
normal: [packed[0], packed[1], packed[2], -127],
glint_uv: if job.glint != 0 { v.uv } else { [0.0, 0.0] },
};
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// A cube's 24 vertices for part `part`, its faces on every axis.
fn cube(part: u32) -> Vec<SkinVertex> {
let mut out = Vec::new();
for axis in 0..6u32 {
for corner in 0..4u32 {
let a = (axis % 3) as usize;
let sign = if axis >= 3 { -0.25 } else { 0.25 };
let mut pos = [0.0f32; 3];
pos[a] = sign;
pos[(a + 1) % 3] = if corner & 1 == 0 { -0.25 } else { 0.25 } + 0.1 * part as f32;
pos[(a + 2) % 3] = if corner & 2 == 0 { -0.3 } else { 0.3 };
out.push(SkinVertex {
pos,
uv: [corner as f32 / 3.0, 0.125 * axis as f32 + 0.0625],
axis,
part,
});
}
}
out
}
fn part(m: glam::Mat4, normal: glam::Mat3) -> SkinPart {
SkinPart {
matrix: m.to_cols_array(),
normal: normal.to_cols_array(),
}
}
/// Two draws of a two-part model with turned, scaled parts, a scrolled
/// texture and the armour glint: the shader writes what `expand` does,
/// to the bit, after the vertices before them.
#[test]
fn the_shader_writes_what_the_cpu_reference_computes() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let Some(mut skin) = EntitySkin::new(&device, true) else {
eprintln!("no compute shaders: skipped");
return;
};
let model: Vec<SkinVertex> = cube(0).into_iter().chain(cube(1)).collect();
let turn =
glam::Mat4::from_quat(glam::Quat::from_euler(glam::EulerRot::ZYX, 0.3, -1.1, 2.0));
let base = glam::Mat4::from_translation(glam::Vec3::new(-3.7, 1.25, 9.1)) * turn;
let child = base
* glam::Mat4::from_translation(glam::Vec3::new(0.2, -0.4, 0.05))
* glam::Mat4::from_scale(glam::Vec3::new(1.0, 2.5, 0.5));
let normals = glam::Mat3::from_mat4(child).inverse().transpose();
let normals = glam::Mat3::from_cols(
normals.x_axis.normalize_or_zero(),
normals.y_axis.normalize_or_zero(),
normals.z_axis.normalize_or_zero(),
);
let frame = SkinFrame {
reset: false,
base: 0,
new_model: model.clone(),
parts: vec![
part(base, glam::Mat3::from_mat4(turn)),
part(child, normals),
part(base * turn, glam::Mat3::from_mat4(turn * turn)),
part(child * turn, glam::Mat3::IDENTITY),
],
jobs: vec![
SkinJob {
first: 0,
count: 48,
part: 0,
dst: 0,
rect: [0.25, 0.5, 0.375, 0.5625],
scroll: 0.0,
color: 0xff80_40ff,
light: 0xf0_70,
overlay: 1,
..Default::default()
},
SkinJob {
first: 24,
count: 24,
part: 2,
dst: 48,
rect: [0.0, 0.0, 0.0625, 0.0625],
scroll: 0.73,
color: u32::MAX,
light: 0xf0f0,
overlay: 36,
glint: 1,
..Default::default()
},
],
vertices: 72,
};
let expected = expand(&model, &frame);
// The posed vertices land after 5 others, which they leave alone.
let before = 5u32;
let out = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(before + frame.vertices) * 44,
usage: EntitySkin::usage(true) | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let sentinel = vec![0xabu8; before as usize * 44];
queue.write_buffer(&out, 0, &sentinel);
let mut uploads = Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
assert!(skin.register(&device, &mut uploads, &mut encoder, &frame));
skin.dispatch(&device, &mut uploads, &mut encoder, &frame, &out, before);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: out.size(),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
encoder.copy_buffer_to_buffer(&out, 0, &read, 0, out.size());
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = read.slice(..).get_mapped_range().unwrap();
assert_eq!(&data[..sentinel.len()], &sentinel[..]);
let got: &[Vertex] = bytemuck::cast_slice(&data[sentinel.len()..]);
for (i, (got, want)) in got.iter().zip(&expected).enumerate() {
assert_eq!(
bytemuck::bytes_of(got),
bytemuck::bytes_of(want),
"vertex {i}: {got:?} != {want:?}"
);
}
}
}
@@ -0,0 +1,110 @@
// Entity models posed on the GPU (gfx/entity_skin.rs): each job is one draw of
// a model (`mobs::render::draw`), and writes the same vertices the CPU path
// would push, in the same order, into the entity vertex buffer. One workgroup
// per job; its invocations walk the model's vertices.
//
// Words, not structs: every record is read and written as u32 words, so the
// layouts are the Rust ones byte for byte.
// A model vertex (7 words): position / 16, model UV, the face's normal as an
// axis (0..2 = +x, +y, +z; 3..5 = -x, -y, -z), the part's index in the model.
const MODEL_WORDS: u32 = 7u;
// A posed part (25 words): the geometry matrix (16, by columns), then the
// normal matrix (9, by columns), its columns already normalized when the
// part's scale is not uniform (`ModelNormals.trusted` false).
const PART_WORDS: u32 = 25u;
// A job (16 words): first model vertex, vertex count, first part, first output
// vertex, the texture rect (4 floats), scroll, colour, light, overlay,
// whether the overlay is the armour glint (its model UV in `glint_uv`), 3 spare.
const JOB_WORDS: u32 = 16u;
// `mesher::Vertex` (11 words): pos (3), uv (2), layer, color, light, normal
// (4 x i8), glint_uv (2).
const VERTEX_WORDS: u32 = 11u;
@group(0) @binding(0) var<storage, read> model: array<u32>;
@group(0) @binding(1) var<storage, read> parts: array<u32>;
@group(0) @binding(2) var<storage, read> jobs: array<u32>;
@group(0) @binding(3) var<storage, read_write> out: array<u32>;
fn f(words: u32) -> f32 {
return bitcast<f32>(words);
}
fn part_f(part: u32, i: u32) -> f32 {
return f(parts[part * PART_WORDS + i]);
}
// `mobs::render::scroll_u`.
fn scroll_u(u: f32, scroll: f32) -> f32 {
if scroll == 0.0 {
return u;
}
// Rust's `%` on floats: the remainder with the dividend's sign.
let s = u + scroll;
return s - trunc(s);
}
// `Polygon::vertex_normal`'s packing: clamped, times 127, cut toward zero.
fn pack(n: vec3<f32>) -> u32 {
let b = vec3<i32>(clamp(n, vec3(-1.0), vec3(1.0)) * 127.0);
return (u32(b.x) & 0xffu) | ((u32(b.y) & 0xffu) << 8u) | ((u32(b.z) & 0xffu) << 16u)
| (u32(-127 & 0xff) << 24u);
}
@compute @workgroup_size(64)
fn skin(
@builtin(workgroup_id) group: vec3<u32>,
@builtin(num_workgroups) groups: vec3<u32>,
@builtin(local_invocation_index) lane: u32,
) {
let job = group.y * groups.x + group.x;
if job >= arrayLength(&jobs) / JOB_WORDS {
return;
}
let j = job * JOB_WORDS;
let first = jobs[j];
let count = jobs[j + 1u];
let first_part = jobs[j + 2u];
let dst = jobs[j + 3u];
let rect = vec4(f(jobs[j + 4u]), f(jobs[j + 5u]), f(jobs[j + 6u]), f(jobs[j + 7u]));
let scroll = f(jobs[j + 8u]);
let color = jobs[j + 9u];
let light = jobs[j + 10u];
let overlay = jobs[j + 11u];
let glint = jobs[j + 12u] != 0u;
for (var k = lane; k < count; k += 64u) {
let v = (first + k) * MODEL_WORDS;
let p = vec3(f(model[v]), f(model[v + 1u]), f(model[v + 2u]));
let uv = vec2(f(model[v + 3u]), f(model[v + 4u]));
let axis = model[v + 5u];
let part = first_part + model[v + 6u];
// glam's Mat4::transform_point3, in its order.
var pos = vec3(part_f(part, 0u), part_f(part, 1u), part_f(part, 2u)) * p.x;
pos = vec3(part_f(part, 4u), part_f(part, 5u), part_f(part, 6u)) * p.y + pos;
pos = vec3(part_f(part, 8u), part_f(part, 9u), part_f(part, 10u)) * p.z + pos;
pos = vec3(part_f(part, 12u), part_f(part, 13u), part_f(part, 14u)) + pos;
// An axis normal times the normal matrix is that column, exactly.
let c = 16u + (axis % 3u) * 3u;
var n = vec3(part_f(part, c), part_f(part, c + 1u), part_f(part, c + 2u));
if axis >= 3u {
n = -n;
}
let o = (dst + k) * VERTEX_WORDS;
out[o] = bitcast<u32>(pos.x);
out[o + 1u] = bitcast<u32>(pos.y);
out[o + 2u] = bitcast<u32>(pos.z);
out[o + 3u] = bitcast<u32>(rect.x + (rect.z - rect.x) * scroll_u(uv.x, scroll));
out[o + 4u] = bitcast<u32>(rect.y + (rect.w - rect.y) * uv.y);
out[o + 5u] = overlay;
out[o + 6u] = color;
out[o + 7u] = light;
out[o + 8u] = pack(n);
if glint {
out[o + 9u] = bitcast<u32>(uv.x);
out[o + 10u] = bitcast<u32>(uv.y);
} else {
out[o + 9u] = 0u;
out[o + 10u] = 0u;
}
}
}
@@ -969,6 +969,7 @@ mod initial_history_tests {
emissive_layers: &[],
shadow_only: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
clouds: None,
+157 -47
View File
@@ -4,6 +4,11 @@
//! framebuffer pixels wide, before the screen is drawn over it. Works in the
//! frame's own (gamma) values, as vanilla's main target does, and needs a
//! swapchain that can be copied from.
//!
//! On a software adapter (WARP, llvmpipe) the rounds run at half the frame's
//! width and height with half the radius: the same blur, a quarter of the
//! pixels. At full size they took 250 ms of a CPU per frame at 1440x900, and
//! every blurred menu ran at 3 FPS (#5185).
/// `blur.json`: the passes' directions, ping-ponging between the copied
/// frame and a swap texture; the last one writes the frame.
@@ -27,15 +32,20 @@ pub struct MenuBlur {
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
sampler: wgpu::Sampler,
/// One per pass, then the half-size copy's.
uniforms: Vec<wgpu::Buffer>,
/// The copied frame (`minecraft:main`) and `swap`.
/// The copied frame (`minecraft:main`) and `swap`; at half size, then the
/// frame copied at full size.
targets: Vec<(wgpu::Texture, wgpu::TextureView)>,
size: [u32; 2],
format: wgpu::TextureFormat,
/// Blur at half size (`new`'s `software`).
half: bool,
}
impl MenuBlur {
/// `format` is the swapchain's; the blur works in its non-sRGB view.
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
/// `format` is the swapchain's; the blur works in its non-sRGB view. `software`:
/// the adapter renders on the CPU, so the blur runs at half size.
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, software: bool) -> Self {
let format = format.remove_srgb_suffix();
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Menu blur"),
@@ -111,8 +121,7 @@ impl MenuBlur {
address_mode_v: wgpu::AddressMode::ClampToEdge,
..Default::default()
});
let uniforms = PASSES
.iter()
let uniforms = (0..=PASSES.len())
.map(|_| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Menu blur pass"),
@@ -130,6 +139,15 @@ impl MenuBlur {
targets: Vec::new(),
size: [0, 0],
format,
half: software,
}
}
/// The size the blur's rounds run at for a frame of `size`.
fn work_size(&self, size: [u32; 2]) -> [u32; 2] {
if self.half {
size.map(|s| s.div_ceil(2))
} else {
size
}
}
fn ensure(&mut self, device: &wgpu::Device, size: [u32; 2]) {
@@ -137,8 +155,15 @@ impl MenuBlur {
return;
}
self.size = size;
self.targets = (0..2)
.map(|_| {
let work = self.work_size(size);
let sizes = if self.half {
vec![work, work, size]
} else {
vec![work, work]
};
self.targets = sizes
.into_iter()
.map(|size| {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Menu blur target"),
size: wgpu::Extent3d {
@@ -180,65 +205,112 @@ impl MenuBlur {
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
self.ensure(device, size);
let work = self.work_size(size);
let copy = if self.half { 2 } else { 0 };
encoder.copy_texture_to_texture(
frame.as_image_copy(),
self.targets[0].0.as_image_copy(),
self.targets[copy].0.as_image_copy(),
wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
);
let radius = if self.half { radius / 2.0 } else { radius };
if self.half {
// A radius of 0 samples each half-size pixel's 2x2 block at its shared
// corner: their mean.
let data = Uniform {
size_dir: [size[0] as f32, size[1] as f32, 0.0, 0.0],
radius: [0.0; 4],
};
let (source, output) = (&self.targets[2].1, &self.targets[0].1);
self.pass(
device,
queue,
encoder,
PASSES.len(),
data,
source,
output,
None,
);
}
for (i, dir) in PASSES.iter().enumerate() {
let data = Uniform {
size_dir: [size[0] as f32, size[1] as f32, dir[0], dir[1]],
size_dir: [work[0] as f32, work[1] as f32, dir[0], dir[1]],
radius: [radius, 0.0, 0.0, 0.0],
};
queue.write_buffer(&self.uniforms[i], 0, bytemuck::bytes_of(&data));
let source = &self.targets[i % 2].1;
// The last pass writes the frame; from half size it samples up as it blurs.
let output = if i + 1 == PASSES.len() {
target
} else {
&self.targets[(i + 1) % 2].1
};
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Menu blur pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.uniforms[i].as_entire_binding(),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: output,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu blur pass"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Menu blur")),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
self.pass(
device,
queue,
encoder,
i,
data,
source,
output,
timing.as_deref_mut(),
);
}
}
/// One `box_blur` pass from `source` into `output` with uniform buffer `slot`.
#[allow(clippy::too_many_arguments)]
fn pass(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
slot: usize,
data: Uniform,
source: &wgpu::TextureView,
output: &wgpu::TextureView,
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
queue.write_buffer(&self.uniforms[slot], 0, bytemuck::bytes_of(&data));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Menu blur pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.uniforms[slot].as_entire_binding(),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: output,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu blur pass"),
timestamp_writes: timing.and_then(|t| t.writes("Menu blur")),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
}
#[cfg(test)]
@@ -318,7 +390,7 @@ mod tests {
else {
return;
};
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb);
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, false);
let size = [64, 8];
let first = blurred(&device, &queue, &mut blur, size);
// The stripes are blurred into greys.
@@ -327,6 +399,44 @@ mod tests {
assert_eq!(blurred(&device, &queue, &mut blur, size), first);
}
/// On a software adapter the blur runs at half size and still blurs the
/// frame much as at full size: stripes two pixels wide turn to an even grey.
#[test]
fn the_half_size_blur_blurs_like_the_full_size_one() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
let size = [64, 8];
let full = blurred(
&device,
&queue,
&mut MenuBlur::new(&device, format, false),
size,
);
let half = blurred(
&device,
&queue,
&mut MenuBlur::new(&device, format, true),
size,
);
// Away from the edges, both are within a few levels of the stripes' mean grey.
for row in 0..size[1] as usize {
for x in 8..56 {
let at = (row * size[0] as usize + x) * 4;
for (name, frame) in [("full", &full), ("half", &half)] {
let v = frame[at];
assert!((120..=136).contains(&v), "{name} size: {v} at ({x}, {row})");
}
}
}
}
#[test]
fn box_blur_shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("menu_blur.wgsl")).unwrap();
+356 -123
View File
@@ -10,9 +10,9 @@ mod sides;
use crate::connected::{Pick, TileLayer};
use crate::{
assets::{Assets, RenderAssets},
biome::Biomes,
biome::{Biomes, GrassModifier},
blocks::{BlockFacts, RenderShape},
chunk::{Chunk, MIN_Y},
chunk::{Chunk, Light, MIN_Y},
graphics::LeafCullingMode,
models::catalog::BakedFace,
};
@@ -73,6 +73,15 @@ pub struct TerrainVertex {
/// RGBA8: tint multiplied by directional shade and ambient occlusion.
pub color: u32,
}
/// `v.round().clamp(0.0, 65535.0) as u16`, without the `roundf` call x86-64's
/// baseline instruction set makes of `f32::round`: clamped first (the same
/// result), the fraction is exact below 2^23, and every float above is whole.
#[inline]
fn round_to_u16(v: f32) -> u16 {
let v = v.clamp(0.0, f32::from(u16::MAX));
let whole = v as u32;
(whole + u32::from(v - whole as f32 >= 0.5)) as u16
}
/// Fixed-point steps per unit of `TerrainVertex` UVs, which span `[0, 2)`.
pub const UV_STEPS: f32 = 32768.0;
/// Fixed-point steps per block of distant-terrain UVs (`TerrainVertex::tiled`),
@@ -82,7 +91,7 @@ impl TerrainVertex {
pub fn new(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
Self {
pos,
uv: uv.map(|v| (v * UV_STEPS).round().clamp(0.0, f32::from(u16::MAX)) as u16),
uv: uv.map(|v| round_to_u16(v * UV_STEPS)),
layer,
light,
color,
@@ -93,7 +102,7 @@ impl TerrainVertex {
pub fn tiled(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
Self {
pos,
uv: uv.map(|v| (v * TILED_UV_STEPS).round().clamp(0.0, f32::from(u16::MAX)) as u16),
uv: uv.map(|v| round_to_u16(v * TILED_UV_STEPS)),
layer,
light,
color,
@@ -116,6 +125,30 @@ mod vertex_tests {
}
}
#[test]
fn uv_rounding_matches_f32_round() {
let reference = |v: f32| v.round().clamp(0.0, f32::from(u16::MAX)) as u16;
let mut values = vec![
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
-0.0,
0.5,
1.5,
65534.5,
1e9,
];
for bits in (0..u32::MAX).step_by(9973) {
values.push(f32::from_bits(bits));
}
for i in 0..=140_000 {
values.push(i as f32 * 0.5 - 2.0);
values.push(i as f32 * 0.5 - 2.0 + f32::EPSILON);
}
for v in values {
assert_eq!(super::round_to_u16(v), reference(v), "{v:e}");
}
}
#[test]
fn other_uvs_round_within_a_step() {
// A flowing fluid's rotated UV.
let uv = [0.5 + 0.25 * 0.3f32.sin(), 0.5 - 0.25 * 0.3f32.cos()];
@@ -333,94 +366,112 @@ pub const ALL_VISIBLE: u64 = (1 << 36) - 1;
/// VisGraph.resolve: flood fill non-opaque cells and connect the faces each region touches.
#[cfg(test)]
fn visibility(pad: &Padded, info: &[Info]) -> u64 {
visibility_reusing(pad, info, &mut Vec::new())
let solid: Vec<bool> = info.iter().map(|i| i.solid).collect();
let mut rows = Vec::new();
solid_rows(pad, &solid, &mut rows);
visibility_rows(&open_rows(&rows))
}
fn visibility_reusing(pad: &Padded, info: &[Info], stack: &mut Vec<usize>) -> u64 {
// The all-visible shortcut can leave a partially filled stack behind.
stack.clear();
// Opaque cells are already visited: resolve each state's opacity once,
// then reuse this map for the flood fill and the uniform-section shortcuts.
let mut seen = [false; 4096];
let mut opaque_count = 0;
for y in 0..16 {
for z in 0..16 {
let row = pi(0, y, z);
for x in 0..16 {
let opaque = info[pad.blocks[row + x] as usize].solid;
seen[(y as usize) << 8 | (z as usize) << 4 | x] = opaque;
opaque_count += usize::from(opaque);
}
}
/// The section's cells that are not `isSolidRender`, by row (`y << 4 | z`, bit
/// x), from the padded rows of `MeshScratch::solid_rows`.
fn open_rows(solid: &[u32]) -> [u16; 256] {
std::array::from_fn(|r| {
let (y, z) = ((r >> 4) as i32, (r & 15) as i32);
!(solid[((y + B) as usize) * P + (z + B) as usize] >> B) as u16
})
}
/// The cells of `open` connected to `seeds` (a subset) within the row: an
/// occluded fill each way, doubling its reach at each step.
#[inline]
fn fill_row(seeds: u16, open: u16) -> u16 {
let (mut up, mut through_up) = (seeds, open);
let (mut down, mut through_down) = (seeds, open);
for step in [1, 2, 4, 8] {
up |= through_up & up << step;
through_up &= through_up << step;
down |= through_down & down >> step;
through_down &= through_down >> step;
}
if opaque_count == 0 {
up | down
}
/// `visibility` on bit rows, one region at a time: a region grows a row at
/// a time (each row filled along its open runs at once) by sweeps over the
/// rows it can reach, until a sweep adds nothing.
fn visibility_rows(open: &[u16; 256]) -> u64 {
let open_count: u32 = open.iter().map(|r| r.count_ones()).sum();
if open_count == 4096 {
return ALL_VISIBLE;
}
if opaque_count == 4096 {
if open_count == 0 {
return 0;
}
let mut unvisited = *open;
let mut region = [0u16; 256];
let mut result = 0u64;
for start in 0..4096usize {
if seen[start] {
continue;
}
let mut faces = 0u8;
seen[start] = true;
stack.push(start);
while let Some(c) = stack.pop() {
let (x, y, z) = ((c & 15) as i32, (c >> 8) as i32, ((c >> 4) & 15) as i32);
if y == 0 {
faces |= 1 << 0;
for start in 0..256 {
while unvisited[start] != 0 {
let seed = unvisited[start] & unvisited[start].wrapping_neg();
region[start] = fill_row(seed, unvisited[start]);
let (mut lo, mut hi) = (start, start);
loop {
let mut grown = false;
let (first, last) = (lo.saturating_sub(16), (hi + 16).min(255));
for r in (first..=last).chain((first..=last).rev()) {
let mut near = region[r];
if r >= 16 {
near |= region[r - 16];
}
if r + 16 < 256 {
near |= region[r + 16];
}
if r & 15 != 0 {
near |= region[r - 1];
}
if r & 15 != 15 {
near |= region[r + 1];
}
let reached = near & unvisited[r];
if reached & !region[r] != 0 {
region[r] = fill_row(reached, unvisited[r]);
(lo, hi) = (lo.min(r), hi.max(r));
grown = true;
}
}
if !grown {
break;
}
}
if y == 15 {
faces |= 1 << 1;
}
if z == 0 {
faces |= 1 << 2;
}
if z == 15 {
faces |= 1 << 3;
}
if x == 0 {
faces |= 1 << 4;
}
if x == 15 {
faces |= 1 << 5;
let mut faces = 0u8;
for r in lo..=hi {
let row = std::mem::take(&mut region[r]);
if row == 0 {
continue;
}
unvisited[r] &= !row;
let (y, z) = (r >> 4, r & 15);
faces |= u8::from(y == 0)
| u8::from(y == 15) << 1
| u8::from(z == 0) << 2
| u8::from(z == 15) << 3
| u8::from(row & 1 != 0) << 4
| u8::from(row & 0x8000 != 0) << 5;
}
// A single connected region touching all six faces sets every
// pair (including the diagonal); unvisited cavities cannot add more.
if faces == 0b11_1111 {
return ALL_VISIBLE;
}
for (dx, dy, dz) in [
(1, 0, 0),
(-1, 0, 0),
(0, 1, 0),
(0, -1, 0),
(0, 0, 1),
(0, 0, -1),
] {
let (nx, ny, nz) = (x + dx, y + dy, z + dz);
if !(0..16).contains(&nx) || !(0..16).contains(&ny) || !(0..16).contains(&nz) {
continue;
}
let n = (ny << 8 | nz << 4 | nx) as usize;
if !seen[n] {
seen[n] = true;
stack.push(n);
}
}
}
for a in 0..6 {
if faces & (1 << a) != 0 {
for b in 0..6 {
if faces & (1 << b) != 0 {
result |= 1 << (a * 6 + b);
for a in 0..6 {
if faces & (1 << a) != 0 {
for b in 0..6 {
if faces & (1 << b) != 0 {
result |= 1 << (a * 6 + b);
}
}
}
}
}
if result == ALL_VISIBLE {
return result;
if result == ALL_VISIBLE {
return result;
}
}
}
result
@@ -505,6 +556,13 @@ pub struct MeshFacts {
}
pub struct MeshTables {
info: Vec<Info>,
/// `Info::solid` by state, packed apart: the opacity mask of a section
/// reads 8,000 states, and a few bytes each stay in cache where `Info`
/// does not.
solid: Vec<bool>,
/// By state, bit `d` set where its occlusion face toward direction `d` is the
/// full block (`Info::face` 1): a face looking at it never shows.
full_faces: Vec<u8>,
pub visibility: FaceVisibility,
water_still: u16,
water_flow: u16,
@@ -673,6 +731,11 @@ impl MeshTables {
});
}
Ok(Self {
solid: info.iter().map(|i| i.solid).collect(),
full_faces: info
.iter()
.map(|i| (0..6).fold(0, |bits, d| bits | u8::from(i.face[d] == 1) << d))
.collect(),
info,
visibility,
water_still: layer("minecraft:block/water_still")?,
@@ -842,49 +905,103 @@ impl Padded {
}
fn fill(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32, own: &mut [u32; 4096]) {
self.blocks.resize(P * P * P, 0);
self.blocks.fill(0);
self.light.resize(P * P * P, 0);
let (blocks, light) = (&mut self.blocks, &mut self.light);
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
// The section itself is unpacked in one pass; the border cells of the
// neighbouring sections are read one by one.
// The section itself is unpacked in one pass and copied row by row;
// the border cells of the neighbouring sections are read in runs, from
// sections found once per layer.
own.fill(0);
let center = region[1][1].map(|c| &c.sections[sy as usize]);
if let Some(section) = center {
section.unpack(own);
let center = region[1][1];
if let Some(c) = center {
c.sections[sy as usize].unpack(own);
}
for y in -B..16 + B {
let wy = base_y + y;
// Where each chunk column's cells of this layer come from, found once.
let sources: [[Source; 3]; 3] = std::array::from_fn(|rz| {
std::array::from_fn(|rx| match region[rz][rx] {
None => Source::Missing,
Some(c) => match c.section_at(wy) {
Ok(section) => Source::Section(section),
Err(light) => Source::Outside(light),
},
})
});
for z in -B..16 + B {
for x in -B..16 + B {
let chunk = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
let i = pi(x, y, z);
if let Some(c) = chunk {
blocks[i] =
if (0..16).contains(&x) && (0..16).contains(&y) && (0..16).contains(&z)
{
own[(y << 8 | z << 4 | x) as usize]
} else {
c.get(x & 15, wy, z & 15)
};
light[i] = c.light(x & 15, wy, z & 15);
} else if let Some(c) = region[1][1] {
// A neighbour not loaded continues this chunk's edge
// column. As lit air, the outermost chunks' water drew
// a wall toward it (fluids show both sides), a light
// sheet in the water where far terrain carries on.
// Vanilla draws no chunk without its neighbours.
let (ex, ez) = (x.clamp(0, 15), z.clamp(0, 15));
blocks[i] = c.get(ex, wy, ez);
light[i] = c.light(ex, wy, ez);
} else {
light[i] = 0xF0;
}
}
let row = pi(-B, y, z);
let rz = ((z + 16) >> 4) as usize;
let (west, rest) = blocks[row..row + P].split_at_mut(B as usize);
let (middle, east) = rest.split_at_mut(16);
let (west_light, rest) = light[row..row + P].split_at_mut(B as usize);
let (middle_light, east_light) = rest.split_at_mut(16);
let own_row = (rz == 1 && (0..16).contains(&y))
.then(|| &own[(y << 8 | z << 4) as usize..][..16]);
let at = |x: i32| crate::chunk::index(x, wy, z);
let [w, m, e] = &sources[rz];
let edge = (center, wy, z);
fill_run(west, west_light, w, None, at(-B), -B, edge);
fill_run(middle, middle_light, m, own_row, at(0), 0, edge);
fill_run(east, east_light, e, None, at(16), 16, edge);
}
}
}
}
/// Where the cells of one chunk column at one height come from (`Padded::fill`).
enum Source<'a> {
/// A neighbour not loaded.
Missing,
/// Above or below the column's sections: air with this light.
Outside(u8),
Section(&'a crate::chunk::Section),
}
/// Fills one run of a padded row, `blocks.len()` cells from x = `x0`, starting
/// at section index `at`; `own` is the centre section's row, already unpacked.
#[inline(always)]
fn fill_run(
blocks: &mut [u32],
light: &mut [u8],
source: &Source,
own: Option<&[u32]>,
at: usize,
x0: i32,
(center, wy, z): (Option<&Chunk>, i32, i32),
) {
match source {
Source::Section(section) => {
match own {
Some(own) => blocks.copy_from_slice(own),
None => section.blocks.get_run(at, blocks),
}
match &section.light {
Light::Uniform(v) => light.fill(*v),
Light::Full(all) => light.copy_from_slice(&all[at..at + light.len()]),
}
}
Source::Outside(v) => {
blocks.fill(0);
light.fill(*v);
}
// A neighbour not loaded continues this chunk's edge column. As lit
// air, the outermost chunks' water drew a wall toward it (fluids show
// both sides), a light sheet in the water where far terrain carries
// on. Vanilla draws no chunk without its neighbours.
Source::Missing => match center {
Some(c) => {
let ez = z.clamp(0, 15);
for (k, (block, light)) in blocks.iter_mut().zip(light.iter_mut()).enumerate() {
let ex = (x0 + k as i32).clamp(0, 15);
*block = c.get(ex, wy, ez);
*light = c.light(ex, wy, ez);
}
}
None => {
blocks.fill(0);
light.fill(0xF0);
}
},
}
}
/// Per-worker temporary arrays. They never hold resource-pack references or
/// geometry still owned by the renderer, and total about 150 KiB per worker
/// (the leaf mask adds 8,000 cells, depths and a queue).
@@ -892,13 +1009,36 @@ impl Padded {
pub struct MeshScratch {
padded: Padded,
own: Option<Box<[u32; 4096]>>,
visibility: Vec<usize>,
leaf: leaf_culling::LeafMask,
/// Per padded row (`pi(-B, y, z) / P`), bit `x + B` set where the block
/// is `isSolidRender` (`MeshTables::solid`).
solid_rows: Vec<u32>,
}
/// Fills `rows` with the padded section's `isSolidRender` blocks, one bit each (`MeshScratch::solid_rows`).
fn solid_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
rows.clear();
rows.extend(pad.blocks.as_chunks::<P>().0.iter().map(|row| {
row.iter().enumerate().fold(0u32, |bits, (x, &id)| {
bits | u32::from(solid[id as usize]) << x
})
}));
}
/// Whether the block at `(x, y, z)` (section-local) and its six neighbours
/// are all `isSolidRender`: none of its faces can show.
#[inline]
fn hidden(rows: &[u32], x: i32, y: i32, z: i32) -> bool {
let row = |y: i32, z: i32| rows[((y + B) as usize) * P + (z + B) as usize];
let r = row(y, z);
let around =
r & r << 1 & r >> 1 & row(y - 1, z) & row(y + 1, z) & row(y, z - 1) & row(y, z + 1);
around >> (x + B) & 1 != 0
}
#[cfg(test)]
mod leaf_culling_tests;
#[cfg(test)]
mod reuse_tests;
#[cfg(test)]
mod tint_tests;
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
pub const OPAQUE_LAYER: u16 = 1 << 15;
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
@@ -990,6 +1130,12 @@ enum Resolver {
/// layer plus the blend radius around it, and the blended colours per column.
struct Layer {
zoomed: Vec<u16>,
/// Each resolver's colour of every cell of `zoomed`, filled on first use:
/// a cell is in the blend square of up to `(2r+1)^2` columns.
cells: [Vec<u32>; 4],
/// `BiomeZoom::corner_jitter` of the quart cells `zoomed` reaches, by
/// their offset from the first: each is shared by up to 16 cells of the layer.
jitter: Vec<Option<[[f64; 3]; 8]>>,
colors: [[u32; 256]; 4],
}
/// `ClientLevel.calculateBlockTint` behind a per-layer `BlockTintCache`: each
@@ -1005,6 +1151,8 @@ struct Tints<'a> {
/// Chunk x, chunk z and the section's lowest block y.
origin: [i32; 3],
layers: [Option<Box<Layer>>; 16],
/// `uniform_biome`, once computed.
uniform: Option<Option<u16>>,
}
impl<'a> Tints<'a> {
fn new(
@@ -1022,6 +1170,7 @@ impl<'a> Tints<'a> {
plains: biomes.id("minecraft:plains"),
origin,
layers: Default::default(),
uniform: None,
}
}
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
@@ -1041,6 +1190,16 @@ impl<'a> Tints<'a> {
[x, y, z]: [i32; 3],
) -> u16 {
let q = zoom.quart_at_block(BlockPos::new(cx * 16 + x, base_y + y, cz * 16 + z));
Self::quart_biome(region, plains, [cx, cz], q)
}
/// The biome of quart cell `q` in the loaded chunks around chunk `(cx, cz)`, plains where
/// there is none.
fn quart_biome(
region: &[[Option<&Chunk>; 3]; 3],
plains: u16,
[cx, cz]: [i32; 2],
q: [i32; 3],
) -> u16 {
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
if (0..3).contains(&rx) && (0..3).contains(&rz) {
region[rz as usize][rx as usize].map_or(plains, |c| {
@@ -1050,13 +1209,59 @@ impl<'a> Tints<'a> {
plains
}
}
/// The biome every block of the section's blend squares zooms to, when they
/// all do: `quart_at_block` picks one of the eight quart cells at its base
/// cell plus 0 or 1 on each axis, so if every such cell the section reaches
/// holds one biome, every zoomed cell is that biome.
fn uniform_biome(&mut self) -> Option<u16> {
if let Some(uniform) = self.uniform {
return uniform;
}
let (r, [cx, cz, base_y]) = (self.radius, self.origin);
// The base cells of blocks lo..=hi, and the cell past the last.
let span = |lo: i32, hi: i32| (lo.wrapping_sub(2) >> 2)..=(hi.wrapping_sub(2) >> 2) + 1;
let (xs, ys, zs) = (
span(cx * 16 - r, cx * 16 + 15 + r),
span(base_y, base_y + 15),
span(cz * 16 - r, cz * 16 + 15 + r),
);
let first = Self::quart_biome(
self.region,
self.plains,
[cx, cz],
[*xs.start(), *ys.start(), *zs.start()],
);
let uniform = ys
.flat_map(|y| zs.clone().map(move |z| (y, z)))
.flat_map(|(y, z)| xs.clone().map(move |x| [x, y, z]))
.all(|q| Self::quart_biome(self.region, self.plains, [cx, cz], q) == first)
.then_some(first);
self.uniform = Some(uniform);
uniform
}
/// The blended tint of block `(x, y, z)` (section-local x/z, y in 0..16).
fn get(&mut self, resolver: Resolver, x: i32, y: i32, z: i32) -> u32 {
// One biome over every blend square: the mean of (2r+1)^2 equal
// colours is that colour, unless it varies by position (swamp grass).
if let Some(biome) = self.uniform_biome() {
let info = self.biomes.get(biome);
match resolver {
Resolver::Grass if !matches!(info.modifier, GrassModifier::Swamp) => {
return self.biomes.grass(biome, 0.0, 0.0) & 0xFF_FFFF;
}
Resolver::Grass => {}
Resolver::Foliage => return info.foliage & 0xFF_FFFF,
Resolver::Dry => return info.dry_foliage & 0xFF_FFFF,
Resolver::Water => return info.water & 0xFF_FFFF,
}
}
let r = self.radius;
let dim = (16 + 2 * r) as usize;
let layer = self.layers[y as usize].get_or_insert_with(|| {
Box::new(Layer {
zoomed: vec![NO_BIOME; dim * dim],
cells: Default::default(),
jitter: Vec::new(),
colors: [[UNSET; 256]; 4],
})
});
@@ -1065,21 +1270,45 @@ impl<'a> Tints<'a> {
if cached != UNSET {
return cached;
}
let [cx, cz, _] = self.origin;
let (bx, bz) = (cx * 16, cz * 16);
let [cx, cz, base_y] = self.origin;
let (bx, by, bz) = (cx * 16, base_y + y, cz * 16);
let (mut red, mut green, mut blue) = (0, 0, 0);
let cells = &mut layer.cells[resolver as usize];
if cells.is_empty() {
cells.resize(dim * dim, UNSET);
}
for dz in -r..=r {
for dx in -r..=r {
let cell = ((z + dz + r) as usize) * dim + (x + dx + r) as usize;
let known = cells[cell];
if known != UNSET {
red += known >> 16 & 255;
green += known >> 8 & 255;
blue += known & 255;
continue;
}
let mut biome = layer.zoomed[cell];
if biome == NO_BIOME {
biome = Self::biome_id(
&self.zoom,
self.region,
self.plains,
self.origin,
[x + dx, y, z + dz],
);
// BiomeManager.getBiome over the loaded chunks (plains
// where there is none): `quart_at_block`, its cell's
// corner jitter mixed once per layer.
let block = BlockPos::new(bx + x + dx, by, bz + z + dz);
let base = [block.x, block.y, block.z].map(|v| v.wrapping_sub(2) >> 2);
let first = [bx - r, bz - r].map(|v| v.wrapping_sub(2) >> 2);
let span = dim / 4 + 2;
let at = (base[2] - first[1]) as usize * span + (base[0] - first[0]) as usize;
if layer.jitter.is_empty() {
layer.jitter.resize(span * span, None);
}
let jitter =
layer.jitter[at].get_or_insert_with(|| self.zoom.corner_jitter(base));
let corner = BiomeZoom::select_corner(block, jitter);
let q = [
base[0].wrapping_add((corner >> 2 & 1) as i32),
base[1].wrapping_add((corner >> 1 & 1) as i32),
base[2].wrapping_add((corner & 1) as i32),
];
biome = Self::quart_biome(self.region, self.plains, [cx, cz], q);
layer.zoomed[cell] = biome;
}
let c = match resolver {
@@ -1091,6 +1320,7 @@ impl<'a> Tints<'a> {
Resolver::Dry => self.biomes.get(biome).dry_foliage,
Resolver::Water => self.biomes.get(biome).water,
};
cells[cell] = c;
red += c >> 16 & 255;
green += c >> 8 & 255;
blue += c & 255;
@@ -1162,7 +1392,9 @@ impl Mesher<'_> {
let own = scratch.own.get_or_insert_with(|| Box::new([0; 4096]));
scratch.padded.fill(region, sy, own);
let pad = &scratch.padded;
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
solid_rows(pad, &self.tables.solid, &mut scratch.solid_rows);
let opaque = &scratch.solid_rows;
out.visibility = visibility_rows(&open_rows(opaque));
let base_y = center.min_y() + sy * 16;
if self.leaf_culling.is_interior() {
scratch
@@ -1177,6 +1409,7 @@ impl Mesher<'_> {
[center.x, center.z, base_y],
);
let info = &self.tables.info;
let full_faces = &self.tables.full_faces;
let catalog = &self.render.model_catalog;
let mut models = Vec::new();
for y in 0..16 {
@@ -1195,12 +1428,7 @@ impl Mesher<'_> {
out.lights
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
}
if state.solid
&& Direction::ALL.iter().all(|d| {
let [dx, dy, dz] = d.normal();
info[pad.blocks[pi(x + dx, y + dy, z + dz)] as usize].solid
})
{
if hidden(opaque, x, y, z) {
continue;
}
if state.fluid.kind() != FluidKind::Empty {
@@ -1239,6 +1467,11 @@ impl Mesher<'_> {
for d in Direction::ALL {
let [dx, dy, dz] = d.normal();
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
// `should_render_face`'s first test, without its lookups: a
// full occlusion face toward this block hides the face.
if full_faces[neighbor as usize] >> d.opposite() as u8 & 1 != 0 {
continue;
}
let other = &info[neighbor as usize];
// LeavesBlock.skipRendering with opaque leaves.
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
@@ -83,9 +83,17 @@ fn weighted_blend(c: [u32; 4], w: [f32; 4]) -> u32 {
};
channel(0) | channel(16) << 16
}
/// `ARGB.gray` channel: `Mth.floor(brightness * 255)`.
/// `ARGB.gray` channel: `Mth.floor(brightness * 255)`. Without the `floorf`
/// call x86-64's baseline instruction set makes of `f32::floor`: truncation,
/// one lower where that rounded a negative value up (saturating as `as` does).
pub(super) fn gray(brightness: f32) -> i32 {
(brightness * 255.0).floor() as i32
let v = brightness * 255.0;
let whole = v as i32;
if whole as f32 > v {
whole.saturating_sub(1)
} else {
whole
}
}
/// One channel of `ARGB.scaleRGB(color, float)`.
pub(super) fn scale(channel: i32, by: f32) -> i32 {
@@ -406,6 +414,28 @@ mod tests {
);
}
#[test]
fn gray_floors_like_f32_floor() {
let mut values = vec![
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
-0.0,
1.0,
-1.0,
1e10,
-1e10,
];
for bits in (0..u32::MAX).step_by(7919) {
values.push(f32::from_bits(bits));
}
for i in -3000..3000 {
values.push(i as f32 / 1024.0);
}
for v in values {
assert_eq!(gray(v), (v * 255.0).floor() as i32, "{v:e}");
}
}
#[test]
fn colour_arithmetic_matches_argb_helpers() {
assert_eq!(gray(1.0), 255);
assert_eq!(gray(0.8), 204);
@@ -45,6 +45,65 @@ fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts(
}
}
#[test]
fn padded_fill_reads_every_neighbour_and_its_light_like_chunk_reads() {
let mut state = 0x2545_F491_4F6C_DD1Du64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
let mut pad = Padded::default();
let mut own = Box::new([0; 4096]);
for min_y in [MIN_Y, 0] {
let layout = crate::chunk::ColumnLayout::new(min_y, 32 * 16).unwrap();
// Nine chunks with their own blocks and per-block light, near the
// bottom and top sections too, where the padding leaves the column.
let chunks: Vec<Chunk> = (0..9)
.map(|i| {
let mut c = Chunk::with_layout(i % 3 - 1, i / 3 - 1, layout);
for s in [0, 1, 30, 31] {
for _ in 0..300 {
let (x, y, z) = (next() % 16, next() % 16, next() % 16);
let y = min_y + s * 16 + y as i32;
c.set(x as i32, y, z as i32, 1 + (next() % 40) as u32);
}
let mut light = Box::new([0u8; 4096]);
light.iter_mut().for_each(|l| *l = next() as u8);
std::sync::Arc::make_mut(&mut c.sections[s as usize]).light =
Light::Full(light);
}
c
})
.collect();
let region: [[Option<&Chunk>; 3]; 3] =
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz * 3 + rx])));
for sy in [0, 1, 30, 31] {
pad.fill(&region, sy, &mut own);
let base_y = min_y + sy * 16;
for y in -B..16 + B {
for z in -B..16 + B {
for x in -B..16 + B {
let c = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize].unwrap();
let (cx, cy, cz) = (x & 15, base_y + y, z & 15);
assert_eq!(
pad.blocks[pi(x, y, z)],
c.get(cx, cy, cz),
"{min_y} {sy} {x} {y} {z}"
);
assert_eq!(
pad.light[pi(x, y, z)],
c.light(cx, cy, cz),
"{min_y} {sy} {x} {y} {z}"
);
}
}
}
}
}
}
/// Run explicitly with prepared assets; never silently pass without exercising geometry.
/// With a shader pack lighting the faces, every face of a lone stone block has one colour; the
/// game's face shading (up 1.0, down 0.5, north/south 0.8, east/west 0.6) is left to the pack.
@@ -181,7 +240,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
assert!(scratch.own.is_none());
assert_eq!(scratch.padded.blocks.capacity(), 0);
assert_eq!(scratch.padded.light.capacity(), 0);
assert_eq!(scratch.visibility.capacity(), 0);
assert_eq!(scratch.solid_rows.capacity(), 0);
}
fresh.sort_solid();
recycled.sort_solid();
+124 -32
View File
@@ -203,13 +203,37 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
/// Reorders `solid`'s quads by bucket, opaque ones first in each. Solid
/// cutout quads are also ordered front to back within each axis-facing
/// bucket; stable sorting preserves the mesher's order for coplanar overlays.
/// bucket; quads that tie keep the mesher's order (the quad's index ends its
/// sort key), so coplanar overlays still draw over their base.
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
sort_parts(solid, GROUPED.load(Ordering::Relaxed))
}
/// `sort`, with all the opaque parts first when `grouped`.
fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, grouped, scratch))
}
/// `sort`'s buffers, kept by each meshing thread so sorting a mesh allocates
/// nothing: each quad's part, its sort key, and the sorted quads, which are
/// copied back into the mesh's own (recycled) buffer.
#[derive(Default)]
struct SortScratch {
slots: Vec<u8>,
keys: Vec<u64>,
sorted: Vec<[TerrainVertex; 4]>,
}
thread_local! {
static SORT_SCRATCH: std::cell::RefCell<SortScratch> = Default::default();
}
/// `f32::total_cmp`'s order as an unsigned integer's.
fn total_order_key(v: f32) -> u32 {
let bits = v.to_bits() as i32;
(bits ^ (((bits >> 31) as u32) >> 1) as i32) as u32 ^ 0x8000_0000
}
fn sort_with(solid: &mut Vec<TerrainVertex>, grouped: bool, scratch: &mut SortScratch) -> Sides {
let (quads, rest) = solid.as_chunks::<4>();
debug_assert!(rest.is_empty(), "solid vertices come in quads");
let cutout = |q: &[TerrainVertex; 4]| q[0].layer & OPAQUE_LAYER == 0;
@@ -218,15 +242,14 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
true => b + 7 * usize::from(cutout),
false => b * 2 + usize::from(cutout),
};
let slots: Vec<u8> = quads
.iter()
.map(|q| position(bucket(q), cutout(q)) as u8)
.collect();
let slots = &mut scratch.slots;
slots.clear();
slots.extend(quads.iter().map(|q| position(bucket(q), cutout(q)) as u8));
let has_cutout = quads.iter().any(cutout);
let mut counts = [0u32; 7];
let mut slot_counts = [0u32; 14];
let mut planes = [0f32; 7];
for (quad, &part) in quads.iter().zip(&slots) {
for (quad, &part) in quads.iter().zip(slots.iter()) {
slot_counts[usize::from(part)] += 1;
let b = slot(grouped, usize::from(part)).0;
if let Bucket::Side(axis, positive) = BUCKETS[b] {
@@ -246,33 +269,34 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
*end = total;
}
if has_cutout || counts.iter().filter(|&&c| c > 0).count() > 1 {
let mut next: [u32; 14] = std::array::from_fn(|s| slot_ends[s] - slot_counts[s]);
let mut sorted = vec![quads[0]; quads.len()];
for (quad, &part) in quads.iter().zip(&slots) {
let at = &mut next[usize::from(part)];
sorted[*at as usize] = *quad;
*at += 1;
}
if has_cutout {
let mut start = 0;
for (s, &end) in slot_ends.iter().enumerate() {
let end = end as usize;
let slice = &mut sorted[start..end];
if let Bucket::Side(axis, positive) = BUCKETS[slot(grouped, s).0] {
slice.sort_by(|a, b| {
let a = a[0].pos[axis];
let b = b[0].pos[axis];
if positive {
b.total_cmp(&a)
} else {
a.total_cmp(&b)
// One integer key per quad: its part, then (cutout meshes, side
// buckets) its plane from the outside in, then its index, which keeps
// the order of equal quads as a stable sort does.
let keys = &mut scratch.keys;
keys.clear();
keys.extend(
quads
.iter()
.zip(slots.iter())
.enumerate()
.map(|(i, (quad, &part))| {
let plane = match BUCKETS[slot(grouped, usize::from(part)).0] {
Bucket::Side(axis, positive) if has_cutout => {
let key = total_order_key(quad[0].pos[axis]);
if positive { !key } else { key }
}
});
}
start = end;
}
}
*solid = sorted.into_flattened();
_ => 0,
};
u64::from(part) << 56 | u64::from(plane) << 24 | i as u64
}),
);
debug_assert!(quads.len() < 1 << 24);
keys.sort_unstable();
let sorted = &mut scratch.sorted;
sorted.clear();
sorted.extend(keys.iter().map(|&k| quads[(k & 0xFF_FFFF) as usize]));
solid.clear();
solid.extend_from_slice(sorted.as_flattened());
}
Sides {
ends: slot_ends,
@@ -286,6 +310,74 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
mod tests {
use super::*;
/// The sort as it was before the integer keys: quads moved to their
/// parts in order, then a stable sort by plane in each side bucket.
fn reference(solid: &[TerrainVertex], grouped: bool) -> Vec<TerrainVertex> {
let quads = solid.as_chunks::<4>().0;
let has_cutout = quads.iter().any(|q| q[0].layer & OPAQUE_LAYER == 0);
let mut sorted = Vec::new();
for n in 0..14 {
let (b, cutout) = slot(grouped, n);
let mut part: Vec<[TerrainVertex; 4]> = quads
.iter()
.filter(|q| bucket(q) == b && (q[0].layer & OPAQUE_LAYER == 0) == cutout)
.copied()
.collect();
if let (Bucket::Side(axis, positive), true) = (BUCKETS[b], has_cutout) {
part.sort_by(|a, b| {
let (a, b) = (a[0].pos[axis], b[0].pos[axis]);
if positive {
b.total_cmp(&a)
} else {
a.total_cmp(&b)
}
});
}
sorted.extend(part);
}
sorted.into_flattened()
}
#[test]
fn integer_keys_sort_as_the_stable_sort_did() {
let mut state = 0x2545_F491_4F6C_DD1Du64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
for round in 0..100 {
let grouped = round % 2 == 1;
let mut solid = Vec::new();
for _ in 0..(next() % 300) {
let origin = [0, 1, 2].map(|_| (next() % 34) as f32 / 2.0 - 0.5);
// Some negative and signed-zero planes too.
let origin = if next() % 9 == 0 {
origin.map(|v| -v)
} else {
origin
};
let layer = if round % 3 == 0 {
OPAQUE_LAYER
} else {
(next() % 2) as u16 * OPAQUE_LAYER
};
let faces = cube(origin, layer);
let face = (next() % 6) as usize;
solid.extend_from_slice(&faces[face * 4..face * 4 + 4]);
}
let expected = reference(&solid, grouped);
let sides = sort_parts(&mut solid, grouped);
assert_eq!(
bytemuck::cast_slice::<_, u8>(&solid),
bytemuck::cast_slice::<_, u8>(&expected),
"round {round}"
);
assert_eq!(sides.quads() as usize * 4, solid.len());
}
}
fn quad(corners: [[f32; 3]; 4], layer: u16) -> [TerrainVertex; 4] {
corners.map(|p| TerrainVertex::new(p, [0.0; 2], layer, 0, 0))
}
@@ -0,0 +1,114 @@
//! The one-biome shortcut of `Tints::get` against the full blend. Needs the
//! prepared biome data, like the other mesher tests that load resources.
use super::*;
fn biomes() -> Biomes {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
Biomes::load(&root).unwrap()
}
/// A chunk whose every quart cell holds `biome`.
fn filled(cx: i32, cz: i32, biome: u16) -> Chunk {
let mut chunk = Chunk::empty(cx, cz);
for qy in 0..(384 / 4) {
for qz in 0..4 {
for qx in 0..4 {
chunk.set_biome_cell(qx, qy, qz, biome);
}
}
}
chunk
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn one_biome_shortcut_blends_like_the_full_square() {
let biomes = biomes();
let desert = biomes.id("minecraft:desert");
let (cx, cz) = (3, -2);
// Grass without and with the dark forest modifier, swamp noise, and
// biomes whose foliage, dry foliage and water differ from the defaults.
for name in [
"minecraft:plains",
"minecraft:dark_forest",
"minecraft:swamp",
"minecraft:mangrove_swamp",
"minecraft:cherry_grove",
"minecraft:warm_ocean",
"minecraft:badlands",
] {
let biome = biomes.id(name);
let chunks: Vec<Vec<Chunk>> = (0..3)
.map(|rz| {
(0..3)
.map(|rx| filled(cx + rx - 1, cz + rz - 1, biome))
.collect()
})
.collect();
// One desert column in the east neighbour, next to the section's edge,
// where every blend radius reaches it.
let mut east = filled(cx + 1, cz, biome);
for qy in 0..(384 / 4) {
east.set_biome_cell(0, qy, 2, desert);
}
let all = |rz: usize, rx: usize| Some(&chunks[rz][rx]);
let uniform: [[Option<&Chunk>; 3]; 3] =
std::array::from_fn(|rz| std::array::from_fn(|rx| all(rz, rx)));
let mut mixed = uniform;
mixed[1][2] = Some(&east);
// A missing neighbour reads as plains.
let mut missing = uniform;
missing[0][0] = None;
for (region, one_biome) in [
(&uniform, true),
(&mixed, false),
(&missing, name == "minecraft:plains"),
] {
for base_y in [MIN_Y, 0, 304] {
for (radius, seed) in [(0, 0), (1, 42), (2, -7), (5, 1234), (7, i64::MAX)] {
let new = || {
Tints::new(
region,
&biomes,
BiomeZoom::new(seed),
radius,
[cx, cz, base_y],
)
};
let mut fast = new();
let mut full = new();
full.uniform = Some(None);
assert_eq!(
fast.uniform_biome().is_some(),
one_biome,
"{name} r{radius} y{base_y}"
);
for resolver in [
Resolver::Grass,
Resolver::Foliage,
Resolver::Dry,
Resolver::Water,
] {
for y in 0..16 {
for z in 0..16 {
for x in 0..16 {
assert_eq!(
fast.get(resolver, x, y, z),
full.get(resolver, x, y, z),
"{name} resolver {} r{radius} at {x} {y} {z} of y{base_y}",
resolver as usize
);
}
}
}
}
}
}
}
}
}
@@ -190,20 +190,74 @@ fn visibility_matches_reference() {
}
#[test]
fn visibility_scratch_reuse_keeps_shortcuts_and_subsequent_fills_exact() {
let info = [info(false), info(true), info(false), info(true)];
let mut stack = vec![usize::MAX];
for _ in 0..3 {
for (name, pad) in cases() {
assert_eq!(
visibility_reusing(&pad, &info, &mut stack),
reference(&pad, &info),
"{name}"
);
fn row_fill_matches_a_cell_by_cell_fill() {
let naive = |seeds: u16, open: u16| {
let mut fill = seeds;
loop {
let next = (fill | fill << 1 | fill >> 1) & open;
if next == fill {
return fill;
}
fill = next;
}
};
let mut state = 0x9E37_79B9u32;
for _ in 0..200_000 {
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
let open = state as u16;
let seeds = (state >> 16) as u16 & open;
assert_eq!(
fill_row(seeds, open),
naive(seeds, open),
"{open:016b} {seeds:016b}"
);
}
// Several seeds in one run, the case a carry-based fill gets wrong.
assert_eq!(fill_row(0b0110, 0b1111), 0b1111);
assert_eq!(fill_row(0xC000, 0xF000), 0xF000);
}
#[test]
fn row_fill_follows_open_runs() {
for open in [0u16, 0xFFFF, 0b0111_0110, 0x8001, 0xF0F0, 0x5555] {
for bit in 0..16 {
let seed = 1u16 << bit;
if open & seed == 0 {
continue;
}
let mut expected = seed;
for x in (0..bit).rev().take_while(|&x| open & 1 << x != 0) {
expected |= 1 << x;
}
for x in (bit + 1..16).take_while(|&x| open & 1 << x != 0) {
expected |= 1 << x;
}
assert_eq!(fill_row(seed, open), expected, "{open:016b} from {bit}");
}
}
}
/// Mazes: one long winding cavity, and many small ones, where the row
/// sweeps are slowest.
#[test]
fn winding_and_scattered_cavities_match_reference() {
let info = [info(false), info(true), info(false), info(true)];
let serpentine = fixture(|x, y, z| {
let lane = z % 4 == 0 && y % 4 == 0;
let turn = (x == 0 && z % 8 == 4) || (x == 15 && z % 8 == 0);
let climb = x == 0 && z == 0 && y % 4 != 0;
!(lane || (turn && y % 4 == 0) || climb)
});
assert_eq!(
visibility(&serpentine, &info),
reference(&serpentine, &info)
);
let pockets = fixture(|x, y, z| !(x % 3 == 1 && y % 3 == 1 && z % 3 == 1));
assert_eq!(visibility(&pockets, &info), reference(&pockets, &info));
}
#[test]
fn visibility_preserves_every_face_pair() {
let info = [info(false), info(true), info(false), info(true)];
+358 -221
View File
@@ -6,21 +6,19 @@ use crate::fast_hash::{FxHashMap, FxHashSet};
use crate::section_mask::SectionMask;
use crate::{
chunk::Chunk,
chunk_map::ChunkMembershipDelta,
graphics::LeafCullingMode,
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, borrow},
world::World,
};
use horizon_core::biome::BiomeZoom;
use horizon_threads::jobs::{Class, PerWorker, Rank, Registration, Source};
use horizon_world::block_render::FaceVisibility;
use horizon_world::worldgen::decoration_order::DecorationOrder;
use std::{
collections::{BinaryHeap, HashSet, VecDeque},
sync::{
Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
thread,
sync::{Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError},
};
/// How long a worker waits with nothing to do before it gives back its
@@ -33,7 +31,8 @@ pub type Proto = Box<dyn std::any::Any + Send>;
/// (biomes, noise, surface, carvers), decoration of a 3x3 neighbourhood
/// (FEATURES, which may write into neighbours), then conversion of a chunk
/// whose eight neighbours are decorated into a final column. Implementations
/// must be thread-safe; each worker thread owns one `Worker`.
/// must be thread-safe; each worker of the shared pool
/// (`horizon_threads::jobs`) keeps one `Worker` per streamer.
pub trait ChunkGenerator: Send + Sync + 'static {
type Worker: Send + 'static;
fn worker(&self) -> Self::Worker;
@@ -276,8 +275,6 @@ impl Ord for Queued {
}
struct Shared {
queue: Mutex<BinaryHeap<Queued>>,
ready: Condvar,
stop: AtomicBool,
/// What meshing reads (block models and sprites), swapped when resource
/// packs reload; each mesh job takes the current one.
context: RwLock<Arc<WorkerContext>>,
@@ -285,11 +282,6 @@ struct Shared {
/// Only workers use this pool; frames never wait for its lock. Retaining
/// cleared buffers avoids racing the renderer for the process heap.
meshes: Mutex<MeshPool>,
/// Workers that have freed their buffers ([`IDLE_RELEASE`]) and taken no
/// job since: the last of them gives the freed memory back to the OS.
released: AtomicUsize,
/// The workers that started.
workers: AtomicUsize,
}
#[derive(Default)]
struct MeshPool {
@@ -364,7 +356,11 @@ pub struct Streaming {
/// next arrive, though read from the save (`World::loaded_chunks`).
dropped_new: FxHashSet<(i32, i32)>,
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
threads: Vec<thread::JoinHandle<()>>,
/// This streamer's place in the shared pool (`horizon_threads::jobs`);
/// dropping it waits for its running jobs.
jobs: Registration,
/// The workers its in-flight work is sized for.
workers: usize,
order: u64,
/// Ticks since the columns and protos that fell out of range were swept.
scan_age: u8,
@@ -476,6 +472,14 @@ pub struct Streaming {
/// Snapshot only after a scan found no missing candidates. Public range fields
/// and `World::chunks` can change without going through Streaming, so compare
/// their values and structural membership token rather than a frame timeout.
///
/// A scan also keeps every candidate it found, nearest first (`plan`): while
/// the ranges stay the same and no column leaves (`World::chunks` removals, or
/// `invalidate` for proto chunks dropped), no column becomes wanted, so the
/// next batches are the plan's entries still missing. A full scan looks at
/// every column of the areas (thousands at render distance 32), and one ran
/// whenever a worker finished: it took a quarter of the thread that runs
/// `Streaming::update` while a world loaded.
#[derive(Default)]
struct GenerationScan {
exhausted: bool,
@@ -485,17 +489,52 @@ struct GenerationScan {
forced: HashSet<(i32, i32)>,
band: Option<Band>,
chunk_revision: u64,
plan: Vec<(i64, i32, i32)>,
/// Entries before it are no longer missing.
next: usize,
/// What `plan` was made for: the ranges, the loading screen and
/// decoration; the chunk map's revision is `chunk_revision`.
plan_basis: Option<(bool, bool)>,
}
impl GenerationScan {
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
self.exhausted
&& self.center == ranges.center
fn same_ranges(&self, ranges: &Ranges<'_>) -> bool {
self.center == ranges.center
&& self.radius == ranges.radius
&& self.extra == ranges.extra
&& self.forced == *ranges.forced
&& self.band.as_ref() == ranges.band
}
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
self.exhausted
&& self.same_ranges(ranges)
&& self.chunk_revision == world.chunks.membership_revision()
}
/// A column may be wanted again (a proto chunk was dropped): the next
/// call scans the areas again.
fn invalidate(&mut self) {
self.exhausted = false;
self.plan_basis = None;
}
/// Whether `plan` still holds every candidate: same ranges, and no
/// column left the chunk map since it was made.
fn plan_holds(&mut self, input: &GenerationInputs<'_>) -> bool {
if self.plan_basis != Some((input.loading, input.decorates))
|| !self.same_ranges(&input.ranges)
{
return false;
}
let revision = input.world.chunks.membership_revision();
if revision != self.chunk_revision {
let Some(mut changes) = input.world.chunks.deltas_since(self.chunk_revision) else {
return false;
};
if !changes.all(|(_, change)| matches!(change, ChunkMembershipDelta::Added(_))) {
return false;
}
self.chunk_revision = revision;
}
true
}
fn remember(&mut self, ranges: Ranges<'_>, world: &World) {
self.exhausted = true;
self.center = ranges.center;
@@ -533,6 +572,30 @@ impl GenerationScan {
{
return None;
}
let missing = |&(_, x, z): &(i64, i32, i32)| {
!input.world.chunks.contains_key(&(x, z))
&& !input.generating.contains(&(x, z))
&& !input.protos.contains_key(&(x, z))
&& !input.finishing.contains(&(x, z))
};
if self.plan_holds(&input) {
while self.next < self.plan.len() && !missing(&self.plan[self.next]) {
self.next += 1;
}
let room = capacity - input.generating.len();
let batch: Vec<_> = self.plan[self.next..]
.iter()
.filter(|c| missing(c))
.take(room)
.copied()
.collect();
// Past the plan's end, the full scan below decides whether
// anything is left (and remembers when nothing is).
if !batch.is_empty() {
self.exhausted = false;
return Some(batch);
}
}
{
let mut wanted = Vec::new();
// Each area's square of columns, as one run per row. Players'
@@ -592,28 +655,196 @@ impl GenerationScan {
}
}
}
let ranges = Ranges {
center: input.ranges.center,
radius: input.ranges.radius,
extra: input.ranges.extra,
forced: input.ranges.forced,
cover: input.ranges.cover,
band: input.ranges.band,
};
self.remember(ranges, input.world);
if wanted.is_empty() {
let ranges = Ranges {
center: input.ranges.center,
radius: input.ranges.radius,
extra: input.ranges.extra,
forced: input.ranges.forced,
cover: input.ranges.cover,
band: input.ranges.band,
};
self.remember(ranges, input.world);
self.plan.clear();
self.plan_basis = None;
} else {
// A bounded batch may leave candidates behind. Keep scanning
// as capacity opens, even if no range or membership changed.
// A bounded batch may leave candidates behind: the next calls
// take them from the plan as capacity opens.
self.exhausted = false;
self.plan_basis = Some((input.loading, input.decorates));
}
wanted.sort_unstable();
wanted.dedup();
self.plan.clear();
self.plan.extend_from_slice(&wanted);
self.next = 0;
wanted.truncate(capacity - input.generating.len());
Some(wanted)
}
}
}
/// What a pool worker keeps for one streamer between its jobs: the
/// generator's worker, and the face visibility table and its occlusion
/// cache (like vanilla's per-thread cache: cloning it per job copied every
/// state and face shape) and the mesher's scratch.
struct WorkerLocal<W> {
worker: W,
faces: Option<(Arc<MeshTables>, FaceVisibility)>,
mesh_scratch: MeshScratch,
}
/// A streamer's jobs, run by the process's shared pool (`horizon_threads::jobs`).
struct Jobs<G: ChunkGenerator> {
shared: Arc<Shared>,
generator: Arc<G>,
light: Arc<LightTables>,
locals: PerWorker<WorkerLocal<G::Worker>>,
}
impl<G: ChunkGenerator> Source for Jobs<G> {
fn next(&self) -> Option<Rank> {
let queue = self.shared.queue.lock().unwrap();
queue.peek().map(|q| {
// Jobs without a column (saves, meshes to free) go first.
let class = if q.at.is_none() {
Class::Urgent
} else {
Class::Near
};
Rank::new(class, q.priority)
})
}
fn run(&self, worker: usize) -> bool {
let Some(job) = self.shared.queue.lock().unwrap().pop() else {
return false;
};
let done = self.locals.with(
worker,
|| WorkerLocal {
worker: self.generator.worker(),
faces: None,
mesh_scratch: MeshScratch::default(),
},
|local| self.run_job(job.job, local),
);
if let Some(done) = done {
self.shared.done.send(done);
}
true
}
fn idle(&self, worker: usize) {
// A server nobody is on, a client standing still: free the
// generator's buffers (and the mesher's face cache and scratch)
// until the next job needs them.
self.locals.with_existing(worker, |local| {
self.generator.idle(&mut local.worker);
local.faces = None;
local.mesh_scratch = MeshScratch::default();
});
}
fn all_idle(&self) {
// No worker needs retained mesh capacity now. Drop it outside the
// pool lock; the pool then asks the allocator to return freed pages.
let meshes = std::mem::take(&mut *self.shared.meshes.lock().unwrap());
drop(meshes);
}
}
impl<G: ChunkGenerator> Jobs<G> {
fn run_job(&self, job: Job, local: &mut WorkerLocal<G::Worker>) -> Option<Done> {
let (generator, shared, light) = (&self.generator, &self.shared, &self.light);
let WorkerLocal {
worker,
faces,
mesh_scratch,
} = local;
let done = match job {
Job::Generate(x, z) => match generator.load(x, z) {
Some(chunk) => {
generator.prepare_loaded(&mut *worker, &chunk);
Done::Generated(chunk)
}
None => {
let proto = generator.proto(&mut *worker, x, z);
let decorated = generator.is_decorated(&proto);
Done::Proto(x, z, proto, decorated)
}
},
Job::Decorate(x, z, mut region, finals) => {
// The final chunks' proto chunks, made here: a
// generated proto chunk holding their blocks.
for (i, snapshot) in &finals {
if let Some(proto) = generator.proto_beside_final(&mut *worker, snapshot) {
region[*i] = proto;
}
}
generator.decorate(&mut *worker, &mut region);
let mut writes = FinalWrites::default();
for (i, snapshot) in &finals {
let blocks = generator.final_writes(snapshot, &region[*i]);
writes
.block_entities
.extend(generator.final_block_entities(&region[*i], &blocks));
writes.blocks.extend(blocks);
region[*i] = Box::new(());
}
let finals = finals.into_iter().map(|(i, _)| i).collect();
Done::Decorated(x, z, region, finals, writes)
}
Job::Finish(proto) => {
let mut chunk = generator.finish(proto);
chunk.generated = true;
Done::Generated(chunk)
}
Job::Save(protos) => {
let refs: Vec<&Proto> = protos.iter().map(|(_, p)| p).collect();
generator.save_protos(&refs);
Done::Saved(protos)
}
Job::LightView(x, z, proto) => {
let view = generator.light_view(&proto);
Done::LightView(x, z, proto, view)
}
Job::Free(meshes) => {
for (_, mesh) in meshes {
let rejected = shared.meshes.lock().unwrap().put(mesh);
drop(rejected);
}
return None;
}
Job::Light(x, z, region, seq, sky) => {
Done::Lit(x, z, light_chunk(&borrow(&region), light, sky), seq)
}
Job::Mesh(x, sy, z, region, seq, settings) => {
let context = shared.context.read().unwrap().clone();
let visibility = match faces.take() {
Some((tables, visibility)) if Arc::ptr_eq(&tables, &context.tables) => {
visibility
}
_ => context.tables.visibility.clone(),
};
let mut mesher = Mesher {
nether_shading: settings.nether,
shader_pack_lighting: settings.shader_pack_lighting,
zoom: settings.zoom,
blend_radius: settings.blend_radius,
smooth_lighting: settings.smooth_lighting,
cutout_leaves: settings.cutout_leaves,
translucent_layers: &settings.translucent_layers,
leaf_culling: settings.leaf_culling,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
biomes: &context.biomes,
visibility,
};
let recycled = shared.meshes.lock().unwrap().take();
let mut mesh = mesher.mesh_reusing(&borrow(&region), sy, recycled, mesh_scratch);
mesh.sort_solid();
*faces = Some((context.tables.clone(), mesher.visibility));
Done::Meshed(x, sy, z, mesh, seq)
}
};
Some(done)
}
}
pub struct WorkerContext {
pub assets: Arc<crate::assets::Assets>,
pub render: Arc<crate::assets::RenderAssets>,
@@ -629,190 +860,23 @@ impl Streaming {
) -> Self {
let shared = Arc::new(Shared {
queue: Mutex::new(BinaryHeap::new()),
ready: Condvar::new(),
stop: AtomicBool::new(false),
context: RwLock::new(context.clone()),
done: Mailbox::default(),
meshes: Mutex::new(MeshPool::default()),
released: AtomicUsize::new(0),
workers: AtomicUsize::new(0),
});
let light_tables = context.light.clone();
let saves: Arc<dyn Saves> = generator.clone();
let threads = (0..threads.max(1))
.map(|i| {
let shared = shared.clone();
let generator = generator.clone();
let light = context.light.clone();
thread::Builder::new()
.name(format!("horizon-worker-{i}"))
.spawn(move || {
// Ticks and frames come first (horizon_threads).
horizon_threads::behind_server();
let mut worker = generator.worker();
// The face visibility table and its occlusion cache stay with
// the worker across mesh jobs, like vanilla's per-thread cache:
// cloning it per job copied every state and face shape.
let mut faces: Option<(Arc<MeshTables>, FaceVisibility)> = None;
let mut mesh_scratch = MeshScratch::default();
loop {
let job = {
let mut queue = shared.queue.lock().unwrap();
let mut released = false;
loop {
if shared.stop.load(Ordering::Relaxed) {
return;
}
if let Some(job) = queue.pop() {
if released {
shared.released.fetch_sub(1, Ordering::Relaxed);
}
break job.job;
}
if released {
queue = shared.ready.wait(queue).unwrap();
continue;
}
let (woken, wait) =
shared.ready.wait_timeout(queue, IDLE_RELEASE).unwrap();
queue = woken;
if wait.timed_out() && queue.is_empty() {
// A server nobody is on, a client standing still:
// free the generator's buffers (and the mesher's
// face cache and scratch) until the next job
// needs them.
drop(queue);
generator.idle(&mut worker);
faces = None;
mesh_scratch = MeshScratch::default();
released = true;
// The allocator keeps freed pages until memory
// runs short: once every worker has freed its own,
// hand them back.
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
== shared.workers.load(Ordering::Relaxed)
{
// No worker needs retained mesh capacity now.
// Drop it outside the pool lock before asking
// the allocator to return freed pages.
let meshes =
std::mem::take(&mut *shared.meshes.lock().unwrap());
drop(meshes);
horizon_threads::release_free_memory();
}
queue = shared.queue.lock().unwrap();
}
}
};
let done = match job {
Job::Generate(x, z) => match generator.load(x, z) {
Some(chunk) => {
generator.prepare_loaded(&mut worker, &chunk);
Done::Generated(chunk)
}
None => {
let proto = generator.proto(&mut worker, x, z);
let decorated = generator.is_decorated(&proto);
Done::Proto(x, z, proto, decorated)
}
},
Job::Decorate(x, z, mut region, finals) => {
// The final chunks' proto chunks, made here: a
// generated proto chunk holding their blocks.
for (i, snapshot) in &finals {
if let Some(proto) =
generator.proto_beside_final(&mut worker, snapshot)
{
region[*i] = proto;
}
}
generator.decorate(&mut worker, &mut region);
let mut writes = FinalWrites::default();
for (i, snapshot) in &finals {
let blocks = generator.final_writes(snapshot, &region[*i]);
writes.block_entities.extend(
generator.final_block_entities(&region[*i], &blocks),
);
writes.blocks.extend(blocks);
region[*i] = Box::new(());
}
let finals = finals.into_iter().map(|(i, _)| i).collect();
Done::Decorated(x, z, region, finals, writes)
}
Job::Finish(proto) => {
let mut chunk = generator.finish(proto);
chunk.generated = true;
Done::Generated(chunk)
}
Job::Save(protos) => {
let refs: Vec<&Proto> = protos.iter().map(|(_, p)| p).collect();
generator.save_protos(&refs);
Done::Saved(protos)
}
Job::LightView(x, z, proto) => {
let view = generator.light_view(&proto);
Done::LightView(x, z, proto, view)
}
Job::Free(meshes) => {
for (_, mesh) in meshes {
let rejected = shared.meshes.lock().unwrap().put(mesh);
drop(rejected);
}
continue;
}
Job::Light(x, z, region, seq, sky) => {
Done::Lit(x, z, light_chunk(&borrow(&region), &light, sky), seq)
}
Job::Mesh(x, sy, z, region, seq, settings) => {
let context = shared.context.read().unwrap().clone();
let visibility = match faces.take() {
Some((tables, visibility))
if Arc::ptr_eq(&tables, &context.tables) =>
{
visibility
}
_ => context.tables.visibility.clone(),
};
let mut mesher = Mesher {
nether_shading: settings.nether,
shader_pack_lighting: settings.shader_pack_lighting,
zoom: settings.zoom,
blend_radius: settings.blend_radius,
smooth_lighting: settings.smooth_lighting,
cutout_leaves: settings.cutout_leaves,
translucent_layers: &settings.translucent_layers,
leaf_culling: settings.leaf_culling,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
biomes: &context.biomes,
visibility,
};
let recycled = shared.meshes.lock().unwrap().take();
let mut mesh = mesher.mesh_reusing(
&borrow(&region),
sy,
recycled,
&mut mesh_scratch,
);
mesh.sort_solid();
faces = Some((context.tables.clone(), mesher.visibility));
Done::Meshed(x, sy, z, mesh, seq)
}
};
shared.done.send(done);
}
})
})
// A thread the system refuses (a container's process limit reached) leaves the pool a
// worker short: a server that panicked here went down with its players' moves unsaved.
.filter_map(|spawned| {
spawned
.inspect_err(|e| log::error!("Horizon: chunk worker not started: {e}"))
.ok()
})
.collect::<Vec<_>>();
shared.workers.store(threads.len(), Ordering::Relaxed);
let pool = horizon_threads::jobs::pool();
let source = Arc::new(Jobs {
shared: shared.clone(),
generator: generator.clone(),
light: context.light.clone(),
locals: horizon_threads::jobs::PerWorker::new(pool.threads()),
});
let jobs = pool.register(source);
// The work kept in flight is sized for the workers this streamer may
// use: the pool's, or fewer when asked (tests, a placeholder).
let workers = threads.clamp(1, pool.threads());
let non_air_sections = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.non_air_sections(p))
@@ -820,7 +884,8 @@ impl Streaming {
Self {
shared,
non_air_sections,
threads,
jobs,
workers,
order: 0,
scan_age: 0,
scan_area: ((i32::MIN, i32::MIN), i32::MIN),
@@ -1019,7 +1084,7 @@ impl Streaming {
order: self.order,
job,
});
self.shared.ready.notify_one();
self.jobs.notify();
}
/// Rank the queued jobs again when what their priorities were computed
/// from changed: the centre, the extra areas, the band, or the loading
@@ -1489,7 +1554,7 @@ impl Streaming {
.map(|(k, _)| *k)
.collect();
if !far.is_empty() {
self.generation_scan.exhausted = false;
self.generation_scan.invalidate();
}
// Those decoration wrote to are saved first (see
// `ChunkGenerator::save_protos`), and dropped once back.
@@ -1538,7 +1603,7 @@ impl Streaming {
// Decorate chunks whose 3x3 proto neighbourhood is available, in
// vanilla's order: decoration is order dependent where write regions
// overlap (see `DecorationOrder`).
let workers = self.threads.len();
let workers = self.workers;
if self.decorates && self.order_area != Some((self.center, self.radius)) {
let (decorated, chunks) = (&self.decorated, &world.chunks);
// The render distance is the server view distance in singleplayer.
@@ -1600,14 +1665,24 @@ impl Streaming {
w.0 = self.decoration_order.rank_or_append(w.2, w.3);
}
wanted.sort_unstable();
// Most wanted chunks are blocked, and the same neighbours are
// asked about for each of them: what `waits_for` says is kept for
// the pass (nothing it reads changes in it but `decorated`, which
// updates the entry). Without it, scheduling at render distance 32
// took most of the thread that runs it while the world loaded.
let waiting: std::cell::RefCell<FxHashMap<(i32, i32), bool>> = Default::default();
for (_, _, x, z) in wanted {
if self.decorating >= workers * 2 {
break;
}
if self
.decoration_order
.blocked(x, z, |nx, nz| waits_for(self, nx, nz))
{
if self.decoration_order.blocked(x, z, |nx, nz| {
if let Some(&known) = waiting.borrow().get(&(nx, nz)) {
return known;
}
let known = waits_for(self, nx, nz);
waiting.borrow_mut().insert((nx, nz), known);
known
}) {
continue;
}
let keys: Vec<(i32, i32)> =
@@ -1627,6 +1702,7 @@ impl Streaming {
.collect();
if !finals.is_empty() && !self.saves_beside_final {
self.decorated.insert((x, z));
waiting.borrow_mut().insert((x, z), false);
continue;
}
if !keys.iter().enumerate().all(|(i, k)| {
@@ -1680,7 +1756,7 @@ impl Streaming {
for key in gone {
world.dirty_light.remove(&key);
}
let room = (self.threads.len() * 4).saturating_sub(self.lighting.len());
let room = (self.workers * 4).saturating_sub(self.lighting.len());
nearest_first(&mut light, room);
for (_, x, z) in light {
let Some(region) = self.light_region(world, x, z) else {
@@ -1766,7 +1842,7 @@ impl Streaming {
// jobs went stale long before a worker reached them (#3629). What
// stays dirty is asked for again, from the current centre, as
// capacity frees; `requeue` then has a small queue to keep close.
let room = (self.threads.len() * 32).saturating_sub(self.meshing.len());
let room = (self.workers * 32).saturating_sub(self.meshing.len());
nearest_first(&mut candidates, room);
for (_, x, z, sections) in candidates {
let Some(region) = Self::region(world, x, z) else {
@@ -2438,11 +2514,8 @@ impl Drop for Streaming {
None => break,
}
}
self.shared.stop.store(true, Ordering::Relaxed);
self.shared.ready.notify_all();
for t in self.threads.drain(..) {
let _ = t.join();
}
// `jobs` leaves the pool after this, once its running jobs are
// done; the jobs still queued go with the queue.
}
}
/// The queued jobs' priorities computed again from each one's column
@@ -3143,6 +3216,70 @@ mod tests {
}
}
/// Batches taken from the plan are the full scan's, as jobs finish,
/// columns and proto chunks leave and the centre moves.
#[test]
fn generation_plan_matches_full_scans_as_columns_arrive() {
for decorates in [false, true] {
let mut f = GenerationFixture::new(6, decorates);
let mut gate = GenerationScan::default();
let chunk = Arc::new(Chunk::empty(0, 0));
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let mut rand = |n: u64| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed % n
};
let mut scheduled = 0;
for step in 0..600 {
let capacity = 3 + rand(6) as usize;
let expected = baseline_candidates(f.input(), capacity);
let batch = gate.candidates(f.input(), capacity).unwrap_or_default();
assert_eq!(batch, expected, "step {step}");
for (_, x, z) in batch {
f.generating.insert((x, z));
scheduled += 1;
}
// Some jobs finish: as a proto chunk, or a final column.
let mut done: Vec<_> = f.generating.iter().copied().collect();
done.sort_unstable();
for key in done {
if rand(3) != 0 {
continue;
}
f.generating.remove(&key);
if rand(2) == 0 {
f.protos.insert(key, None);
} else {
f.world.chunks.insert(key, chunk.clone());
}
gate.exhausted = false;
}
// Now and then a column or a proto chunk leaves.
if rand(20) == 0 {
let mut keys: Vec<_> = f.world.chunks.keys().copied().collect();
keys.sort_unstable();
if let Some(key) = keys.get(rand(keys.len().max(1) as u64) as usize) {
f.world.chunks.remove(key);
}
}
if rand(20) == 0 {
let mut keys: Vec<_> = f.protos.keys().copied().collect();
keys.sort_unstable();
if let Some(key) = keys.get(rand(keys.len().max(1) as u64) as usize) {
f.protos.remove(key);
gate.invalidate();
}
}
if step == 300 {
f.center = (3, -2);
}
}
assert!(scheduled > 200);
}
}
#[test]
fn generation_scan_looks_at_overlapping_areas_once() {
// A server's players: many areas around one spot, some apart, one
@@ -89,8 +89,9 @@ fn main() -> Result<(), Error> {
});
let terrain = VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed)?;
// Generation and light, on the game's worker pool. Meshing is left out
// here and measured on its own below.
// Generation and light, on the game's worker pool (`threads` workers).
// Meshing is left out here and measured on its own below.
horizon_threads::jobs::configure(threads);
let mut streaming = Streaming::new(Arc::new(terrain), context.clone(), threads);
streaming.mesh = false;
streaming.center = center;
+90 -1
View File
@@ -116,6 +116,39 @@ impl Control {
}
}
/// The wait before a scene's flight, spent timing Horizon's own render passes on the GPU:
/// `gpu_pass.<scene>.<pass>_ms`, where a slow GPU spends its frame. The timing is off again for
/// the flight, whose frames it would slow. False, having waited for nothing, on Metal: its passes
/// overlap on the GPU, so their times add up to more than the frame.
fn time_passes(control: &Control, scene: &str, report: &mut Report) -> Result<bool, String> {
if report
.hardware
.gpu
.as_ref()
.is_some_and(|g| g.backend == "metal")
{
return Ok(false);
}
control.call("gpu-passes on")?;
let held = control.call(&format!("bench {}", SETTLE.as_secs_f64()));
control.call("gpu-passes off")?;
for (pass, ms) in held?["gpu_passes"].as_object().into_iter().flatten() {
let pass: String = pass
.trim()
.chars()
.map(|c| match c {
'a'..='z' | '0'..='9' => c,
'A'..='Z' => c.to_ascii_lowercase(),
_ => '_',
})
.collect();
if let Some(ms) = ms.as_f64() {
report.metric(&format!("gpu_pass.{scene}.{pass}_ms"), &[ms]);
}
}
Ok(true)
}
fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
let scenes =
scenes(&setup.scenario).ok_or_else(|| format!("unknown scenario {}", setup.scenario))?;
@@ -204,7 +237,9 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
&[chunks / load.as_secs_f64()],
);
}
thread::sleep(SETTLE);
if !time_passes(control, name, &mut report)? {
thread::sleep(SETTLE);
}
let [ex, ey, ez] = end;
let begin = Instant::now();
let flight = control.call(&format!(
@@ -499,6 +534,60 @@ mod tests {
let _ = server.join();
}
/// The wait before a flight times the passes and turns the timing off again before the
/// flight; the report gets one metric per pass. Not on Metal.
#[test]
fn the_wait_times_the_passes_but_not_the_flight() {
let time_on = |backend: &str| {
let (tx, rx) = mpsc::channel::<Request>();
let server = thread::spawn(move || {
let mut lines = Vec::new();
while let Ok(request) = rx.recv() {
let reply = if request.line.starts_with("bench ") {
serde_json::json!({
"frames": 120,
"gpu_passes": {"World": 1.5, "World after culling": 0.25, "HUD": 0.01},
})
} else {
serde_json::json!({ "ok": true })
};
let _ = request.reply.send(reply.to_string());
lines.push(request.line);
}
lines
});
let hardware = horizon_bench::Hardware {
gpu: Some(horizon_bench::Gpu {
backend: backend.into(),
..Default::default()
}),
..Default::default()
};
let mut report = Report::with_hardware(Kind::Game, App::default(), hardware);
let control = Control(tx);
let timed = time_passes(&control, "forest", &mut report).expect("the wait runs");
drop(control);
(timed, server.join().unwrap(), report.metrics)
};
let (timed, lines, metrics) = time_on("vulkan");
assert!(timed);
assert_eq!(lines, ["gpu-passes on", "bench 2", "gpu-passes off"]);
let mean = |name: &str| metrics.get(name).map(|s| s.mean);
assert_eq!(mean("gpu_pass.forest.world_ms"), Some(1.5));
assert_eq!(mean("gpu_pass.forest.world_after_culling_ms"), Some(0.25));
assert_eq!(mean("gpu_pass.forest.hud_ms"), Some(0.01));
assert_eq!(metrics.len(), 3);
let (timed, lines, metrics) = time_on("metal");
assert!(!timed);
assert!(
lines.is_empty(),
"Metal's overlapping passes are not timed: {lines:?}"
);
assert!(metrics.is_empty());
}
#[test]
fn yaw_matches_vanilla() {
// South (+Z) is 0, west (-X) 90, east (+X) -90.
+1 -1
View File
@@ -36,7 +36,7 @@ pub fn native_living_breathe(
consume: &mut i32,
refill: &mut i32,
) {
let _ = (entity, kind, can_breathe, consume, refill);
crate::native_client_hooks::living_breathe(entity, kind, can_breathe, consume, refill);
}
/// `vanilla`'s breathing after the mods have had their say.
+1
View File
@@ -89,6 +89,7 @@ pub mod mod_content;
pub mod motion;
pub mod multiplayer;
pub mod music;
pub mod native_client_hooks;
pub mod native_data;
pub mod net;
pub mod nether_portal;
+1 -1
View File
@@ -33,7 +33,7 @@ pub fn native_ingredient(
data: &serde_json::Map<String, Value>,
items: &mut Vec<String>,
) {
let _ = (kind, data, items);
crate::native_client_hooks::ingredient(kind, data, items);
}
/// Keep first-seen order, drop the rest.
+47
View File
@@ -74,12 +74,59 @@ pub fn main_with(mods: crate::mod_api::Mods) {
components,
);
},
loot_function: |kind, data, ctx, stack| {
crate::mod_api::dispatch_loot_function(
crate::mod_api::mods(),
kind,
data,
ctx,
stack,
);
},
loot_condition: |kind, data, ctx, holds| {
crate::mod_api::dispatch_loot_condition(
crate::mod_api::mods(),
kind,
data,
ctx,
holds,
);
},
},
)
.is_err()
{
log::warn!("Native data-load hooks already installed; keeping the first callbacks");
}
if crate::native_client_hooks::install(crate::native_client_hooks::NativeClientHooks {
menu_slot_rules: |menu, slot, stack, may_place, max_stack| {
crate::mod_api::dispatch_menu_slot_rules(
crate::mod_api::mods(),
menu,
slot,
stack,
may_place,
max_stack,
);
},
living_breathe: |entity, kind, can_breathe, consume, refill| {
crate::mod_api::dispatch_living_breathe(
crate::mod_api::mods(),
entity,
kind,
can_breathe,
consume,
refill,
);
},
ingredient: |kind, data, items| {
crate::mod_api::dispatch_ingredient(crate::mod_api::mods(), kind, data, items);
},
})
.is_err()
{
log::warn!("Native client hooks already installed; keeping the first callbacks");
}
raise_open_file_limit();
if let Err(error) = game::run_with() {
// A bad command line is the caller's mistake, not a crash: no report.
+40 -3
View File
@@ -183,6 +183,7 @@ mod maps;
mod minecarts;
mod mob_blocks;
mod mob_gear;
mod mob_index;
mod mob_looting;
mod mob_sounds;
mod mod_api_host;
@@ -385,6 +386,16 @@ use horizon_world::{
},
registry::BlockStateId,
};
pub use maps::{NativeMapDecoration, NativeMapInfo};
pub use native_block_entity_draw::BlockEntityDraw;
pub use native_claimed_items::{NativeHeldItem, NativeHit, NativeModEvent};
pub use native_data_maps::NativeDataMapType;
pub use native_falling::NativeFalling;
pub use native_hooks::{NativeClock, NativeShake};
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
pub use native_server::NativeExperienceOrb;
pub use native_world_columns::NativeColumnBlocks;
pub use native_world_edit::{NativeBlockEdit, NativeBlockRun, NativeHeightmap};
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
@@ -2345,6 +2356,10 @@ struct Game<'a> {
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
/// Which entity this frame's entity vertices belong to (`Frame::entity_motion`).
entity_motion: std::cell::RefCell<Vec<(u32, u64)>>,
/// Mob models posed on the GPU (`Frame::skin`, `mobs::skin`): the models stored there,
/// and this frame's draws.
skin_models: std::cell::RefCell<horizon_client::mobs::skin::SkinModels>,
skin_frame: std::cell::RefCell<horizon_client::gfx::entity_skin::SkinFrame>,
/// The glowing entities' silhouettes this frame (game/glowing.rs).
glowing_vertices: std::cell::RefCell<Vec<Vertex>>,
/// The shader pack's entity and block entity ids, while one draws entities.
@@ -2441,6 +2456,8 @@ struct Game<'a> {
/// player's own (taken by `update_world`).
spawn_wait: Vec<(i32, i32)>,
mobs: Vec<Mob>,
/// `mobs` by network id, for the packets that name one (`game/mob_index.rs`).
mob_index: mob_index::MobIndex,
creatures: creatures::Creatures,
sculk: sculk::SculkState,
raiding: raids::Raiding,
@@ -15550,6 +15567,7 @@ impl Game<'_> {
let entity_motion = std::mem::take(self.entity_motion.get_mut());
let glowing = std::mem::take(self.glowing_vertices.get_mut());
let shadow_only = std::mem::take(self.shadow_only_vertices.get_mut());
let skin = std::mem::take(self.skin_frame.get_mut());
let frame = Frame {
weather: &weather,
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
@@ -15633,6 +15651,7 @@ impl Game<'_> {
entities: &entities,
entity_motion: &entity_motion,
entity_spans: &entity_spans,
skin: skin.pending().then_some(&skin),
glowing: &glowing,
dynamic_lights: &self.dynamic_lights,
colored_lights: &colored_lights,
@@ -15713,6 +15732,18 @@ impl Game<'_> {
*self.entity_motion.get_mut() = entity_motion;
*self.glowing_vertices.get_mut() = glowing;
*self.shadow_only_vertices.get_mut() = shadow_only;
let mut skin = skin;
// The renderer took the new models when its store grew by them; otherwise they stay
// pending, or the stores restart (`SkinModels::begin`).
if self
.renderer
.as_ref()
.and_then(|r| r.skinned_models())
.is_some_and(|stored| stored == skin.base + skin.new_model.len() as u32)
{
skin.handed();
}
*self.skin_frame.get_mut() = skin;
self.see_through_vertices = see_through;
result
}
@@ -18266,6 +18297,8 @@ impl<'a> Game<'a> {
shadow_only_vertices: Default::default(),
entity_spans: Default::default(),
entity_motion: Default::default(),
skin_models: Default::default(),
skin_frame: Default::default(),
glowing_vertices: Default::default(),
pack_ids: None,
health_hud: hud_layout::HealthHud::default(),
@@ -18327,6 +18360,7 @@ impl<'a> Game<'a> {
player_spawn: None,
spawn_wait: Vec::new(),
mobs: Vec::new(),
mob_index: Default::default(),
creatures: Default::default(),
sculk: Default::default(),
raiding: Default::default(),
@@ -19376,6 +19410,9 @@ fn load_resources(
publish: &mut dyn FnMut(UiResources, &std::path::Path),
) -> Result<Loaded> {
let threads = settings.horizon.worker_threads();
// One pool of workers for every level's streaming, the client's and the
// server thread's (horizon_threads::jobs), sized before any world opens.
horizon_threads::jobs::configure(threads);
horizon_client::sprites::set_texture_budget(settings.horizon.texture_budget_mib);
horizon_client::sprites::set_labpbr_maps(shader_packs::picks_native_pack(settings));
// Stage A: the menu bundle (atlas and panorama). With a loading screen it is
@@ -19958,6 +19995,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
entities: &[],
entity_motion: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
colored_lights: &[],
@@ -20694,9 +20732,8 @@ pub fn run_with() -> Result<()> {
horizon_threads::put_helpers_behind();
if options.execution_role.is_client() {
// The render thread, like the server thread, ahead of the workers on
// macOS and Windows. While terrain loads, the worker pools (two with
// single player's server thread) and other programs outnumber the
// cores, and a frame that gave its core up (in a GPU submit) waited
// macOS and Windows. While terrain loads, the workers
// (horizon_threads::jobs) and other programs outnumber the cores, and a frame that gave its core up (in a GPU submit) waited
// 10 to 200 ms to get one back.
horizon_threads::favour_current();
}
+77 -14
View File
@@ -6098,6 +6098,19 @@ impl Game<'_> {
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = self.view_scale();
self.rendered_mobs.set(0);
// Mob models posed on the GPU (mobs::skin), when the renderer takes them this frame.
let skinning = self
.renderer
.as_ref()
.filter(|r| r.skins_entities())
.and_then(|r| r.skinned_models());
match skinning {
Some(stored) => self
.skin_models
.borrow_mut()
.begin(&mut self.skin_frame.borrow_mut(), stored),
None => self.skin_frame.borrow_mut().clear_draws(),
}
// What custom entity model animations read of the frame.
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
time: self.world.game_time as f64 + alpha,
@@ -6240,21 +6253,55 @@ impl Game<'_> {
Vec::new()
};
let body = out.len();
let render = if layers_only {
horizon_client::mobs::render::render_layers
// The GPU path: an ordinary mob, no outline, no layers the CPU path changes after
// drawing them (a player's, the HDR tiers below, the sulfur cube's shell).
let posable = skinning.is_some()
&& !is_player
&& !layers_only
&& outline.is_none()
&& armor.is_empty()
&& !matches!(
mob.species(),
Species::Blaze | Species::MagmaCube | Species::GlowSquid | Species::SulfurCube
);
let mut skinned = None;
if posable {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mark = frame.mark();
let mut skin = horizon_client::mobs::skin::Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
horizon_client::mobs::render::render_skinned(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
out,
&mut skin,
);
skinned = Some((mark, skin.bounds));
} else {
horizon_client::mobs::render::render_with
};
render(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
&armor,
out,
);
let render = if layers_only {
horizon_client::mobs::render::render_layers
} else {
horizon_client::mobs::render::render_with
};
render(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
&armor,
out,
);
}
// HDR output: mobs that are light shine by their kind, as blocks do (entity.wgsl
// fs_hdr_mark); a tier's window lifts only their bright texels.
if let Some(tier) = match mob.species() {
@@ -6302,7 +6349,23 @@ impl Game<'_> {
if outline.is_none()
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
{
// What the GPU draws of the mob counts by its bounds' corners.
let corners = skinned.and_then(|(_, bounds)| bounds);
if let Some((lo, hi)) = corners {
out.extend([lo, hi].map(|pos| Vertex {
pos: pos.to_array(),
..bytemuck::Zeroable::zeroed()
}));
}
let len = out.len();
self.cull_entity_vertices(out, entity_start);
if out.len() < len {
if let Some((mark, _)) = skinned {
self.skin_frame.borrow_mut().truncate(mark);
}
} else if corners.is_some() {
out.truncate(len - 2);
}
}
// GuardianRenderer: the laser to the locked target.
if let Some((time, scale)) = horizon_client::mobs::guardian::beam(mob, alpha as f32)
@@ -712,6 +712,17 @@ mod tests {
assert_eq!(range, 48.0);
}
/// `Entity.shouldRenderAtSqrDistance` for a dropped item (`ItemEntity`
/// keeps the default and its box is 0.25 on each side): 24 blocks at
/// render distance 12, 32 at 16, 40 from 20 on; never 64.
#[test]
fn a_dropped_item_is_drawn_sixteen_blocks_times_the_view_scale() {
let item = glam::DVec3::splat(0.25);
assert_eq!(default_range(item, entity_view_scale(12, 1.0)), 24.0);
assert_eq!(default_range(item, entity_view_scale(16, 1.0)), 32.0);
assert_eq!(default_range(item, entity_view_scale(32, 1.0)), 40.0);
}
/// `Entity.shouldRenderAtSqrDistance` on a broken box: the size falls
/// back to one block.
#[test]
+11 -1
View File
@@ -483,12 +483,22 @@ impl Game<'_> {
}
_ => None,
});
// Entity.shouldRenderAtSqrDistance for the item's 0.25 box: 16 blocks
// times the view scale (32 at render distance 16), from its current
// position. A pickup particle is not an entity and keeps 64 blocks.
let range =
super::entity_culling::default_range(glam::DVec3::splat(0.25), self.view_scale());
for (item, at, partial, entity) in live.chain(taken) {
let entity_start = out.len();
self.mark_motion(entity_start, (2 << 32) | u64::from(item.id));
let q = item.position;
let pos = glam::DVec3::from_array(at) - camera;
if pos.length_squared() > 64.0 * 64.0 {
let far = if entity {
(glam::DVec3::new(q.x, q.y, q.z) - camera).length_squared() >= range * range
} else {
pos.length_squared() > 64.0 * 64.0
};
if far {
continue;
}
// EntityRenderer.shouldRender: the item's 0.25 box, grown by 0.5,
-1
View File
@@ -40,7 +40,6 @@ pub(super) struct Decoration {
}
/// A map as a native mod sees it when `native_map_decorations` is asked.
// Native mods read it; nothing in Horizon does.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub struct NativeMapInfo<'a> {
/// The map's id (`map_<id>`).
@@ -0,0 +1,90 @@
//! Finding a mob by its network id without walking the whole list: a
//! multiplayer client looks one up for every movement, data, motion and
//! event packet. A `Mob` is ~3 KB, so a linear search touches a cache line
//! per mob: 1000 movement packets in a 3000-mob farm cost ~5 ms a tick that
//! way, against ~0.01 ms through the index.
//!
//! The index is a cache: the mob list changes in many places (spawns,
//! removals, dimension swaps), and none of them has to tell it. A hit is
//! checked against the list, and a miss falls back to the linear search,
//! which rebuilds the index when it finds the mob. Ids are unique in the list
//! (`spawn_entity` drops a mob with the same id before adding one), so the
//! answer is always the linear search's.
use horizon_client::fast_hash::FxHashMap;
use horizon_client::mobs::Mob;
#[derive(Default)]
pub(super) struct MobIndex {
slots: FxHashMap<u32, u32>,
}
impl MobIndex {
/// The position of the mob with this id in `mobs`, as
/// `mobs.iter().position(|m| m.id == id)`.
pub(super) fn find(&mut self, mobs: &[Mob], id: u32) -> Option<usize> {
self.position(mobs, |m| m.id, id)
}
fn position<T>(&mut self, items: &[T], id_of: impl Fn(&T) -> u32, id: u32) -> Option<usize> {
if let Some(&i) = self.slots.get(&id)
&& items.get(i as usize).is_some_and(|m| id_of(m) == id)
{
return Some(i as usize);
}
let i = items.iter().position(|m| id_of(m) == id)?;
self.slots.clear();
for (i, m) in items.iter().enumerate() {
self.slots.entry(id_of(m)).or_insert(i as u32);
}
Some(i)
}
/// The mob with this id, as `mobs.iter_mut().find(|m| m.id == id)`.
pub(super) fn get_mut<'a>(&mut self, mobs: &'a mut [Mob], id: u32) -> Option<&'a mut Mob> {
let i = self.find(mobs, id)?;
Some(&mut mobs[i])
}
/// The mob with this id, as `mobs.iter().find(|m| m.id == id)`.
pub(super) fn get<'a>(&mut self, mobs: &'a [Mob], id: u32) -> Option<&'a Mob> {
let i = self.find(mobs, id)?;
Some(&mobs[i])
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Whatever the list went through since the last lookup, the index
/// answers as the linear search does.
#[test]
fn finds_what_a_linear_search_finds_after_any_change() {
let mut index = MobIndex::default();
let check = |index: &mut MobIndex, ids: &[u32]| {
for id in 0..20 {
let found = index.position(ids, |&m| m, id);
assert_eq!(
found,
ids.iter().position(|&m| m == id),
"id {id} in {ids:?}"
);
}
};
let mut ids = vec![7, 3, 9, 12];
check(&mut index, &ids);
ids.remove(1);
check(&mut index, &ids);
ids.push(3);
ids.swap(0, 2);
check(&mut index, &ids);
ids.retain(|&m| m != 9);
ids.insert(0, 15);
check(&mut index, &ids);
// Another list altogether: a dimension swapped in.
check(&mut index, &[12, 15, 1]);
ids.clear();
check(&mut index, &ids);
}
}
+109
View File
@@ -159,6 +159,115 @@ impl Host for super::Game<'_> {
) -> bool {
self.native_set_block(dimension, pos, state, flags)
}
fn edit_blocks(&mut self, dimension: &str, edit: &NativeBlockEdit) -> Result<u64, String> {
self.native_edit_blocks(dimension, edit)
}
fn surface_heights(
&self,
dimension: &str,
columns: &[(i32, i32)],
heightmap: NativeHeightmap,
) -> Result<Vec<Option<i32>>, String> {
self.native_surface_heights(dimension, columns, heightmap)
}
fn read_columns(
&self,
dimension: &str,
columns: &[(i32, i32)],
heightmap: NativeHeightmap,
range: (u32, u32),
) -> Result<NativeColumnBlocks, String> {
self.native_read_columns(dimension, columns, heightmap, range)
}
fn refresh_biome_colors(&mut self) {
self.native_refresh_biome_colors()
}
fn neighbor_signal(&mut self, dimension: &str, pos: [i32; 3]) -> i32 {
self.native_neighbor_signal(dimension, pos)
}
fn light(&self, pos: [i32; 3]) -> (u8, u8) {
self.native_light(pos)
}
fn weather(&self) -> (f32, f32) {
self.native_weather()
}
fn day_time(&self) -> i64 {
self.native_day_time()
}
fn world_height(&self) -> (i32, i32) {
self.native_world_height()
}
fn max_stack_size(&self, item: &str) -> Option<u32> {
self.native_max_stack_size(item)
}
fn item_model(
&self,
batch: &mut HudBatch,
item: &str,
model: &str,
at: [f32; 2],
size: f32,
) -> bool {
self.native_item_model(batch, item, model, at, size)
}
fn quad(&self, batch: &mut HudBatch, points: [[f32; 2]; 4], argb: u32) {
self.native_quad(batch, points, argb)
}
fn mod_screen_open(&mut self, mod_id: &str) -> bool {
self.native_mod_screen_open(mod_id)
}
fn mod_screen_close(&mut self, mod_id: &str) {
self.native_mod_screen_close(mod_id)
}
fn mod_screen(&self) -> Option<&str> {
self.native_mod_screen()
}
fn set_experience_orb(
&mut self,
dimension: &str,
id: u32,
value: Option<u32>,
count: Option<u32>,
age: Option<u32>,
) -> bool {
self.native_set_experience_orb(dimension, id, value, count, age)
}
fn experience_orbs(
&self,
dimension: &str,
min: [f64; 3],
max: [f64; 3],
) -> Vec<NativeExperienceOrb> {
self.native_experience_orbs(dimension, min, max)
}
fn clock(&self) -> Option<NativeClock> {
self.native_clock()
}
fn spawn_falling_block(
&mut self,
dimension: &str,
state: BlockStateId,
pos: [f64; 3],
velocity: [f64; 3],
falling: NativeFalling,
) -> bool {
self.native_spawn_falling_block(dimension, state, pos, velocity, falling)
}
fn block_state_property(&self, state: BlockStateId, key: &str) -> Option<&str> {
self.native_block_state_property(state, key)
}
fn raycast(&self, distance: f32) -> Option<NativeHit> {
self.native_raycast(distance)
}
fn view_hit(&self) -> Option<(f32, NativeHit)> {
self.native_view_hit()
}
fn claim_items(&mut self, mod_id: &str, ids: &[String]) {
self.native_claim_items(mod_id, ids)
}
fn poll(&mut self, mod_id: &str) -> Vec<NativeModEvent> {
self.native_poll(mod_id)
}
fn set_item_entity(&mut self, dimension: &str, id: u32, stack: ItemStack) -> bool {
self.native_set_item_entity(dimension, id, stack)
}
@@ -43,7 +43,6 @@ fn in_frustum(frustum: Option<&impl Cull>, origin: Vec3, bounds: [f32; 6]) -> bo
/// partial tick, game time and light, and the pose stack the helpers draw
/// under (`native_draw_quad`, `native_draw_cube`, `native_draw_block`).
// Native mods read and drive it; nothing in Horizon does.
#[allow(dead_code)]
pub struct BlockEntityDraw {
/// The frame's partial tick, 0 to 1 (`BlockEntityRenderState`).
pub partial: f32,
@@ -174,7 +173,6 @@ impl BlockEntityDraw {
/// One face of a `native_draw_cube` box: its sprite (`mymod:entity/cog`,
/// `mymod:block/casing`) and its UVs in texels of 16 per side, as a model
/// face's `uv` (none takes the box's own, as a model's default).
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub struct CubeFace<'a> {
pub sprite: &'a str,
@@ -5,36 +5,33 @@ use super::Game;
use std::collections::BTreeMap;
/// A stack as a client mod's use events carry it.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct NativeHeldItem {
pub(crate) id: String,
pub(crate) count: u32,
pub struct NativeHeldItem {
pub id: String,
pub count: u32,
/// The inventory slot: 0 to 8, the hotbar (a mod use is the main hand's).
pub(crate) slot: u32,
pub slot: u32,
/// `minecraft:custom_name` as SNBT, when the stack has one.
pub(crate) custom_name: Option<String>,
pub custom_name: Option<String>,
/// `minecraft:custom_data` as SNBT, when the stack has some.
pub(crate) custom_data: Option<String>,
pub custom_data: Option<String>,
}
/// The block a ray along the view meets.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct NativeHit {
pub(crate) block_x: i32,
pub(crate) block_y: i32,
pub(crate) block_z: i32,
pub struct NativeHit {
pub block_x: i32,
pub block_y: i32,
pub block_z: i32,
/// Where the ray meets the block.
pub(crate) x: f64,
pub(crate) y: f64,
pub(crate) z: f64,
pub x: f64,
pub y: f64,
pub z: f64,
/// The block's id (`minecraft:stone`).
pub(crate) block: String,
pub block: String,
}
/// What happened to a mod's claimed item, oldest first.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum NativeModEvent {
pub enum NativeModEvent {
/// A right-click with the item began its use: the stack, and 0 ticks.
UseStart(NativeHeldItem, u32),
/// The use ended: the stack it began with, and the ticks it was held. It
@@ -15,12 +15,12 @@ use std::collections::{BTreeMap, HashMap, HashSet};
/// A data map type a mod registers (`RegisterDataMapTypesEvent`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct NativeDataMapType {
pub struct NativeDataMapType {
/// `mod_id:name`: not in the `minecraft` namespace. Its files are
/// `data/<mod_id>/data_maps/<registry path>/<name>.json`.
pub(crate) id: String,
pub id: String,
/// The registry it is for: `minecraft:item` or `minecraft:block`.
pub(crate) registry: String,
pub registry: String,
}
/// The registries data maps can be attached to so far, with their folder
@@ -16,7 +16,7 @@ const NATIVE_FALLING_MAX_SPEED: f64 = 3.9;
/// What a mod's falling block does when it lands and when it hits.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct NativeFalling {
pub struct NativeFalling {
/// `FallingBlockEntity.dropItem`: whether it drops as an item where it
/// can't be placed.
pub drop_item: bool,
File diff suppressed because it is too large. Load diff
@@ -20,7 +20,6 @@ use horizon_client::{
/// `LivingEntityRenderState`: what a model reads to pose and animate itself.
/// Angles are in degrees, interpolated to the frame's partial tick.
// Native mods read it; nothing in Horizon does.
#[allow(dead_code)]
#[derive(Clone, Debug)]
pub struct NativeMobRender<'a> {
/// The mob's network id, as the mob helpers and mod messages name it.
@@ -83,7 +82,6 @@ impl<'a> NativeMobRender<'a> {
/// Which player a [`NativeMobRender`] is, for `native_player_submit` and
/// `native_mob_layer`.
// Native mods read it; nothing in Horizon does.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub struct NativePlayerRender<'a> {
/// The player's profile name.
@@ -722,9 +722,8 @@ impl Game<'_> {
/// An experience orb as a server mod reads it (`native_experience_orbs`).
/// Read by mods only.
#[allow(dead_code)]
#[derive(Clone, Debug)]
pub(super) struct NativeExperienceOrb {
pub struct NativeExperienceOrb {
/// The entity's ID, as `native_entity_join_level` names it.
pub id: u32,
pub position: [f64; 3],
@@ -7,11 +7,11 @@ use super::native_world_edit::NativeHeightmap;
/// What `native_read_columns` read: the blocks round each column's height (`bases`, none where it isn't loaded),
/// as indexes into `palette`, a column after another, and three biome tints a column (grass, foliage, water).
#[derive(Clone, Debug, PartialEq, Default)]
pub(crate) struct NativeColumnBlocks {
pub(crate) palette: Vec<String>,
pub(crate) bases: Vec<Option<i32>>,
pub(crate) blocks: Vec<u16>,
pub(crate) tints: Vec<u32>,
pub struct NativeColumnBlocks {
pub palette: Vec<String>,
pub bases: Vec<Option<i32>>,
pub blocks: Vec<u16>,
pub tints: Vec<u32>,
}
#[allow(dead_code)]
@@ -51,8 +51,9 @@ impl Game<'_> {
dimension: &str,
columns: &[(i32, i32)],
heightmap: NativeHeightmap,
(below, above): (u32, u32),
range: (u32, u32),
) -> Result<NativeColumnBlocks, String> {
let (below, above) = range;
if !self.native_level_open(dimension) {
return Err(format!("{dimension} is not the level being ticked"));
}
@@ -3,9 +3,8 @@
use super::Game;
/// Which of a column's heights (`Heightmap.Types`) a run's `y` counts from.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum NativeHeightmap {
pub enum NativeHeightmap {
WorldSurface,
MotionBlocking,
MotionBlockingNoLeaves,
@@ -14,22 +13,22 @@ pub(crate) enum NativeHeightmap {
/// A run: `height` blocks up a column from `y` (from the column's `surface` height when one is
/// given), set to palette entry `block`, only where filter `only` matches when one is given.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct NativeBlockRun {
pub(crate) x: i32,
pub(crate) z: i32,
pub(crate) y: i32,
pub(crate) height: u32,
pub(crate) surface: Option<NativeHeightmap>,
pub(crate) block: u32,
pub(crate) only: Option<u32>,
pub struct NativeBlockRun {
pub x: i32,
pub z: i32,
pub y: i32,
pub height: u32,
pub surface: Option<NativeHeightmap>,
pub block: u32,
pub only: Option<u32>,
}
/// A bulk edit: block states and block predicates as commands write them (without NBT), and the runs naming them
/// by index.
#[derive(Clone, Debug, PartialEq, Default)]
pub(crate) struct NativeBlockEdit {
pub(crate) palette: Vec<String>,
pub(crate) filters: Vec<String>,
pub(crate) runs: Vec<NativeBlockRun>,
pub struct NativeBlockEdit {
pub palette: Vec<String>,
pub filters: Vec<String>,
pub runs: Vec<NativeBlockRun>,
}
#[allow(dead_code)]
+23 -24
View File
@@ -2230,7 +2230,7 @@ impl Game<'_> {
)
})
} else {
self.mobs.iter().find(|m| m.id == id as u32).map(|m| {
self.mob_index.get(&self.mobs, id as u32).map(|m| {
(
[m.position.x, m.position.y, m.position.z],
m.kind.width,
@@ -2448,9 +2448,8 @@ impl Game<'_> {
let dragon = self.dragon.as_ref().is_some_and(|d| d.id as i32 == id);
let driven = self.driven_mount().is_some_and(|m| m.id as i32 == id);
let mob = self
.mobs
.iter_mut()
.find(|m| m.id == id as u32)
.mob_index
.get_mut(&mut self.mobs, id as u32)
.filter(|_| !dragon);
let tracked = self
.remote
@@ -2852,7 +2851,7 @@ impl Game<'_> {
value("minecraft:movement_speed"),
value("minecraft:jump_strength"),
);
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) else {
let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) else {
return;
};
// A horse its rider's client moves goes at its own speed and jumps
@@ -3284,7 +3283,7 @@ impl Game<'_> {
None => Some(Holder::Mob(d as u32)),
},
};
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.source as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.source as u32) {
m.extra.leash = holder;
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id == p.source as u32) {
// A leashed boat or raft (AbstractBoat is Leashable).
@@ -3304,7 +3303,7 @@ impl Game<'_> {
// Entity.DATA_SHARED_FLAGS_ID, DATA_CUSTOM_NAME and
// DATA_CUSTOM_NAME_VISIBLE: server NPCs and holograms rely on
// these instead of saved NBT.
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
for (index, _, value) in &p.values {
match (index, value) {
(0, Value::Byte(flags)) => {
@@ -3337,7 +3336,7 @@ impl Game<'_> {
}
}
self.remote_mob_data(p.id, &p.values);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
&& m.species() == horizon_client::mobs::Species::Creeper
{
for (index, _, value) in &p.values {
@@ -3350,7 +3349,7 @@ impl Game<'_> {
}
}
}
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
for (index, _, value) in &p.values {
match (index, value) {
// Entity.DATA_TICKS_FROZEN: isFullyFrozen shakes it.
@@ -3382,7 +3381,7 @@ impl Game<'_> {
for (index, _, value) in &p.values {
if let (14, Value::OptPos(bed)) = (index, value) {
let code = bed.map_or(0, |b| self.bed_facing(b));
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
m.extra.ints[1] = code;
if let Some(head) = *bed {
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): setPosToBed.
@@ -3401,14 +3400,14 @@ impl Game<'_> {
// Avatar.DATA_PLAYER_MAIN_HAND (HumanoidArm: 0 left, 1
// right): the arm a swing of the main hand moves.
if let (15, Value::Id(arm)) = (index, value)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
m.extra.main_hand_left = *arm == 0;
}
// Avatar.DATA_PLAYER_MODE_CUSTOMISATION: the skin layers
// the player shows (PlayerModelPart bits).
if let (16, Value::Byte(bits)) = (index, value)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
m.extra.hidden_model_parts = !(*bits as u8) & 0x7F;
}
@@ -3518,7 +3517,7 @@ impl Game<'_> {
.remote
.as_ref()
.is_some_and(|r| r.level_players.contains_key(&p.id)))
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
for (index, _, value) in &p.values {
if let (0, Value::Byte(flags)) = (index, value) {
@@ -3571,7 +3570,7 @@ impl Game<'_> {
)
})
});
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
if let Some(charged) = charged
&& &*m.kind.id == "minecraft:player"
{
@@ -3753,7 +3752,7 @@ impl Game<'_> {
pitch,
);
}
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
} else if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
m.limb_speed = 1.5;
m.hurt_time = 10;
} else {
@@ -3766,7 +3765,7 @@ impl Game<'_> {
if let Some(pl) = self.player.as_mut() {
pl.health.animate_hurt_dir(p.yaw);
}
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
} else if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
m.hurt_time = 10;
} else {
self.remote_dragon_hurt(p.id);
@@ -3810,20 +3809,20 @@ impl Game<'_> {
self.fox_eat_particles(p.id as u32);
}
if p.event == 1
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
&& m.species() == horizon_client::mobs::Species::Rabbit
{
horizon_client::mobs::rabbit::hop_event(m);
}
if p.event == 19
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
horizon_client::mobs::aquatic::squid_cycle_event(m);
}
// LivingEntity.handleEntityEvent 55: another player swapped
// its main and off hand (`swapHandItems`).
if p.event == 55
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
std::mem::swap(&mut m.extra.main_hand, &mut m.extra.off_hand);
}
@@ -3835,7 +3834,7 @@ impl Game<'_> {
}
// EntityEvent.DEATH: start the death animation.
if p.event == 3
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
m.health = 0.0;
}
@@ -3859,12 +3858,12 @@ impl Game<'_> {
);
}
// The events that start a mob's animation (the warden's).
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
horizon_client::mobs::sync::entity_event(m, p.event as u8);
}
// LivingEntity.handleEntityEvent 60: the poof as the dead body goes.
if p.event == 60
&& let Some(m) = self.mobs.iter().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get(&self.mobs, p.id as u32)
{
let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height()));
self.death_poof([at.x, at.y, at.z], w, h);
@@ -3941,7 +3940,7 @@ impl Game<'_> {
};
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
self.swing_hand(hand, animation);
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
} else if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
m.swing_hand_modified(hand == Hand::Off, animation);
}
}
@@ -5219,7 +5218,7 @@ impl Game<'_> {
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
self.remote_dragon_motion(id, velocity);
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) {
// Squid.aiStep derives its body pitch and roll from network motion.
m.velocity = v;
} else if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
@@ -487,7 +487,7 @@ impl Game<'_> {
(14, _, Value::OptPos(bed)) => Some(bed.map_or(0, |b| self.bed_facing(b))),
_ => None,
});
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) {
apply(m, values, &r.builtin, r.entity_id);
apply_registry_data(m, values, &r.registries, self.world.game_time);
if let Some(code) = bed
@@ -170,7 +170,7 @@ impl Game<'_> {
} else if self.riding == Some(vehicle_id) {
// The server takes the player out, then teleports it.
self.riding = None;
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == vehicle_id) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, vehicle_id) {
m.rider = None;
}
// HappyGhast.removePassenger: a still timeout of 10 (the server's
@@ -286,7 +286,7 @@ impl Game<'_> {
pub(super) fn remote_move_vehicle(&mut self, position: [f64; 3], yaw: f32) {
let at = Vec3::new(position[0], position[1], position[2]);
if let Some(id) = self.driven_mount().map(|m| m.id)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, id)
{
m.previous = at;
m.position = at;
+26 -1
View File
@@ -406,7 +406,7 @@ pub fn native_menu_slot_rules(
may_place: &mut bool,
max_stack: &mut u32,
) {
let _ = (menu, slot, stack, may_place, max_stack);
crate::native_client_hooks::menu_slot_rules(menu, slot, stack, may_place, max_stack);
}
/// An index outside the slot list: vanilla's `IndexOutOfBoundsException`.
@@ -3459,6 +3459,31 @@ pub(crate) mod tests {
});
}
#[test]
fn installed_slot_rules_change_mod_menu_placement() {
let installed =
crate::native_client_hooks::install(crate::native_client_hooks::NativeClientHooks {
menu_slot_rules: |menu, slot, _, may_place, max_stack| {
if menu == "test:rules" && slot == 0 {
*may_place = false;
*max_stack = 7;
}
},
living_breathe: |_, _, _, _, _| {},
ingredient: |_, _, _| {},
});
assert!(installed.is_ok());
let kind = Kind::Mod(Box::leak(Box::new(crate::containers::ModMenuType {
id: "test:rules",
slots: 1,
data_slots: 0,
results: vec![],
})));
let menu = Menu::new(kind);
let stack = ItemStack::new("minecraft:stone", 1);
assert_eq!(menu.mod_slot(0, &stack), (false, 7));
}
/// A mod menu type: three slots, then a result (slot 3).
fn mod_menu() -> Kind {
Kind::Mod(Box::leak(Box::new(crate::containers::ModMenuType {
+1
View File
@@ -73,6 +73,7 @@ pub mod render;
pub mod riders;
pub mod seen_grid;
pub mod shulker;
pub mod skin;
pub mod sniffer;
pub mod spawn_rules;
pub mod spawn_selection;
+39 -26
View File
@@ -2,6 +2,7 @@
//! (quadrupeds, humanoids, creepers, spiders, chickens...) applied to a map of
//! part poses before the model is drawn.
use super::{Mob, Part, keyframes::AnimationState, living::wrap};
use crate::fast_hash::{FxHashMap, FxHashSet};
use horizon_core::geometry::trig::Mth;
use std::collections::HashMap;
use std::f32::consts::{FRAC_PI_6, PI};
@@ -101,15 +102,27 @@ impl AnimState {
}
pub fn end_tick(&mut self, _swinging: bool) {}
}
/// A part name as `Poses` keys it: its Fx hash, so filling a pose map allocates
/// nothing (a mob farm resets one per layer per mob each frame). Names are short
/// and few per model; `no_two_part_names_share_a_key` checks every vanilla model.
#[inline]
pub(crate) fn key(name: &str) -> u64 {
use std::hash::Hasher;
let mut h = crate::fast_hash::FxHasher::default();
h.write(name.as_bytes());
// The length too, so "a" and "a\0" differ (str's own Hash adds a terminator).
h.write_usize(name.len());
h.finish()
}
/// Part poses for one draw, keyed by part name.
#[derive(Clone, Default)]
pub struct Poses {
map: HashMap<String, [f32; 9]>,
map: FxHashMap<u64, [f32; 9]>,
dummy: [f32; 9],
/// Parts with `skipDraw` set: their own cubes are not drawn, children are.
skipped: std::collections::HashSet<String>,
skipped: FxHashSet<u64>,
/// Parts with `visible` cleared: neither they nor their children draw.
hidden: std::collections::HashSet<String>,
hidden: FxHashSet<u64>,
}
impl Poses {
pub fn new(model: &Part) -> Self {
@@ -120,9 +133,9 @@ impl Poses {
/// Refills this for another model, keeping the map and the sets' capacity:
/// a mob draws its body and every equipment layer each frame.
pub fn reset(&mut self, model: &Part) {
fn walk(p: &Part, map: &mut HashMap<String, [f32; 9]>) {
fn walk(p: &Part, map: &mut FxHashMap<u64, [f32; 9]>) {
// ModelPart.createPartLookup fills its map with putIfAbsent: the first part keeps a name.
map.entry(p.name.clone()).or_insert(p.pose);
map.entry(key(&p.name)).or_insert(p.pose);
for c in &p.children {
walk(c, map);
}
@@ -134,26 +147,26 @@ impl Poses {
walk(model, &mut self.map);
}
pub fn get(&mut self, name: &str) -> &mut [f32; 9] {
match self.map.get_mut(name) {
match self.map.get_mut(&key(name)) {
Some(p) => p,
None => &mut self.dummy,
}
}
pub fn pose(&self, part: &Part) -> [f32; 9] {
self.map.get(&part.name).copied().unwrap_or(part.pose)
self.map.get(&key(&part.name)).copied().unwrap_or(part.pose)
}
pub fn skip_draw(&mut self, name: &str) {
self.skipped.insert(name.to_owned());
self.skipped.insert(key(name));
}
pub fn skips(&self, part: &Part) -> bool {
self.skips_named(&part.name)
}
/// `skips` by the part's name.
pub fn skips_named(&self, name: &str) -> bool {
self.skipped.contains(name)
!self.skipped.is_empty() && self.skipped.contains(&key(name))
}
pub fn has(&self, name: &str) -> bool {
self.map.contains_key(name)
self.map.contains_key(&key(name))
}
pub fn xrot(&mut self, n: &str, v: f32) {
self.get(n)[3] = v;
@@ -167,9 +180,9 @@ impl Poses {
/// `ModelPart.visible = v`; a hidden part and its children do not draw.
pub fn visible(&mut self, n: &str, v: bool) {
if v {
self.hidden.remove(n);
} else if self.map.contains_key(n) {
self.hidden.insert(n.to_owned());
self.hidden.remove(&key(n));
} else if self.map.contains_key(&key(n)) {
self.hidden.insert(key(n));
}
}
pub fn is_hidden(&self, part: &Part) -> bool {
@@ -177,7 +190,7 @@ impl Poses {
}
/// `is_hidden` by the part's name.
pub fn is_hidden_named(&self, name: &str) -> bool {
self.hidden.contains(name)
!self.hidden.is_empty() && self.hidden.contains(&key(name))
}
}
/// The LivingEntityRenderState values setupAnim reads.
@@ -1003,7 +1016,7 @@ mod flight_pose_tests {
"left_leg",
]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1011,13 +1024,13 @@ mod flight_pose_tests {
};
player_model_visibility(&mut p, 0, true);
for part in ["body", "right_arm", "left_arm", "right_leg", "left_leg"] {
assert!(p.hidden.contains(part), "{part}");
assert!(p.hidden.contains(&key(part)), "{part}");
}
assert!(!p.hidden.contains("head") && !p.hidden.contains("hat"));
assert!(!p.hidden.contains(&key("head")) && !p.hidden.contains(&key("hat")));
// Back in another mode they all show, but for the layers the player hid.
player_model_visibility(&mut p, 1 << 6, false);
assert!(!p.hidden.contains("body") && !p.hidden.contains("left_leg"));
assert!(p.hidden.contains("hat"));
assert!(!p.hidden.contains(&key("body")) && !p.hidden.contains(&key("left_leg")));
assert!(p.hidden.contains(&key("hat")));
}
#[test]
fn blocking_arm_follows_the_head_with_limits_in_both_hands() {
@@ -1030,7 +1043,7 @@ mod flight_pose_tests {
let mut p = Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1087,7 +1100,7 @@ mod flight_pose_tests {
"left_leg",
]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1142,7 +1155,7 @@ mod flight_pose_tests {
"body",
]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1183,7 +1196,7 @@ mod flight_pose_tests {
let make = || Poses {
map: ["head", "right_arm", "left_arm", "right_leg", "left_leg"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1289,7 +1302,7 @@ mod flight_pose_tests {
let mut p = Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1310,7 +1323,7 @@ mod flight_pose_tests {
let mut p = Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1338,7 +1351,7 @@ mod flight_pose_tests {
let make = || Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
+326 -65
View File
@@ -1278,15 +1278,7 @@ fn draw_normals(
out: &mut Vec<Vertex>,
) {
let p = poses.pose(part);
let m = base
* glam::Mat4::from_translation(glam::Vec3::new(p[0], p[1], p[2]) / 16.0)
* glam::Mat4::from_quat(glam::Quat::from_euler(
glam::EulerRot::ZYX,
p[5],
p[4],
p[3],
))
* glam::Mat4::from_scale(glam::Vec3::new(p[6], p[7], p[8]));
let m = posed(base, p);
if poses.is_hidden(part) || p[6] == 0.0 && p[7] == 0.0 && p[8] == 0.0 {
return;
}
@@ -1324,6 +1316,46 @@ fn draw_normals(
draw_normals(c, m, normals, poses, uv, scroll, color, light, overlay, out);
}
}
/// A part's matrix under `base` for its pose (`ModelPart.translateAndRotate`
/// and its scale), as `draw` and the GPU path (`super::skin`) both use it.
pub(super) fn posed(base: glam::Mat4, p: [f32; 9]) -> glam::Mat4 {
base * glam::Mat4::from_translation(glam::Vec3::new(p[0], p[1], p[2]) / 16.0)
* glam::Mat4::from_quat(glam::Quat::from_euler(
glam::EulerRot::ZYX,
p[5],
p[4],
p[3],
))
* glam::Mat4::from_scale(glam::Vec3::new(p[6], p[7], p[8]))
}
/// `draw_scrolled` through the GPU path when the mob takes it (`skin`).
#[allow(clippy::too_many_arguments)]
fn put(
skin: &mut Option<&mut super::skin::Skin>,
part: &std::sync::Arc<Part>,
base: glam::Mat4,
poses: &Poses,
uv: [f32; 4],
scroll: f32,
color: u32,
light: u32,
overlay: u32,
out: &mut Vec<Vertex>,
) {
let paint = super::skin::Paint {
uv,
scroll,
color,
light,
overlay,
};
if let Some(skin) = skin
&& skin.draw(part, base, poses, paint)
{
return;
}
draw_scrolled(part, base, poses, uv, scroll, color, light, overlay, out);
}
/// Render a mob with its variant model, texture and layers.
pub fn render(
m: &Mob,
@@ -1360,7 +1392,7 @@ pub fn render_with(
let shifted =
camera - glam::DVec3::new(off[0] + w[0], off[1] + w[1], off[2] + w[2]);
let base = base_transform(m, alpha, shifted);
render_visible(m, alpha, base, atlas, light, data, extra, true, out);
render_visible(m, alpha, base, atlas, light, data, extra, true, out, None);
}
}
return;
@@ -1375,6 +1407,37 @@ pub fn render_with(
extra,
true,
out,
None,
);
}
/// `render` with the model's ordinary draws posed on the GPU when the mob
/// allows it (`super::skin`): the same picture, no vertices on the CPU.
#[allow(clippy::too_many_arguments)]
pub fn render_skinned(
m: &Mob,
alpha: f32,
camera: glam::DVec3,
atlas: Atlas,
light: u32,
data: &MobData,
out: &mut Vec<Vertex>,
skin: &mut super::skin::Skin,
) {
if super::trader::is_invisible(m) {
render_with(m, alpha, camera, atlas, light, data, &[], out);
return;
}
render_visible(
m,
alpha,
base_transform(m, alpha, camera),
atlas,
light,
data,
&[],
true,
out,
Some(skin),
);
}
/// `CapeLayer.submit` with `PlayerCapeModel`: the player model's body posed
@@ -1632,7 +1695,7 @@ pub fn render_layers(
out: &mut Vec<Vertex>,
) {
let base = base_transform(m, alpha, camera);
render_visible(m, alpha, base, atlas, light, data, extra, false, out);
render_visible(m, alpha, base, atlas, light, data, extra, false, out, None);
}
/// A mob drawn under `origin` instead of at its position (the spinning mob
/// of `SpawnerRenderer.submitEntityInSpawner`).
@@ -1646,7 +1709,7 @@ pub fn render_posed(
out: &mut Vec<Vertex>,
) {
let base = base_transform_at(m, alpha, origin);
render_visible(m, alpha, base, atlas, light, data, &[], true, out);
render_visible(m, alpha, base, atlas, light, data, &[], true, out, None);
}
/// The custom model's key of a body model: `main` is the wide player model, `kind.model`.
fn cem_key(model_key: &str) -> &str {
@@ -1730,6 +1793,7 @@ fn render_visible(
extra: &[(&Part, [f32; 4], u32, u32, bool)],
body_visible: bool,
out: &mut Vec<Vertex>,
skin: Option<&mut super::skin::Skin>,
) {
let overlay = u32::from(red_overlay(m));
let s = anim::state(m, alpha);
@@ -1799,56 +1863,6 @@ fn render_visible(
if let Some(animation) = body_animation {
animation.run(m, &s, &mut poses);
}
let draw_body = |out: &mut Vec<Vertex>| {
// A player or mannequin whose skin has no atlas sprite (an evicted or
// never stored download) draws with its default skin, not invisibly.
let drawn = sprite(texture).or_else(|| Some((texture, atlas(texture)?)));
let uv = drawn.map(|(_, uv)| uv).or_else(|| {
if avatar(m) {
atlas(&m.kind.texture)
} else {
None
}
});
if body_visible && let Some(uv) = uv {
let start = out.len();
draw(
&model,
base,
&poses,
uv,
body_color(m, alpha),
light,
body_overlay(m, alpha),
out,
);
if let Some(glow) = drawn.and_then(|(name, _)| emissive_uv(name)) {
draw(
&model,
base,
&poses,
glow,
body_color(m, alpha),
FULL_BRIGHT,
0,
out,
);
}
if translucent_body(m) {
mark_oit(&mut out[start..]);
}
}
};
// The translucent shell writes depth: draw the opaque core before it.
let shell_last = m.species() == Species::SulfurCube;
if !shell_last {
draw_body(out);
}
// The worn armour's layers (and their emissive overlays, full bright).
for (part, uv, color, layer_overlay, glows) in extra {
let light = if *glows { FULL_BRIGHT } else { light };
draw(part, base, &poses, *uv, *color, light, *layer_overlay, out);
}
let mut all_layers = layers(m, s.age);
// WardenRenderer: the translucent emissive layers fade with the animations.
if m.species() == Species::Warden {
@@ -1864,6 +1878,87 @@ fn render_visible(
}
}
}
// The GPU path takes the whole mob or none of it, so its draws keep their
// order: only plain opaque and cutout draws of models it can pose (no
// custom models, translucency, eyes or energy swirl, which the CPU path
// changes after drawing).
let mut skin = skin;
let posable = match skin.as_deref_mut() {
Some(skin) => {
custom_body.is_none()
&& extra.is_empty()
&& m.species() != Species::SulfurCube
&& !translucent_body(m)
&& energy_swirl(m, s.age).is_none()
&& skin.supports(&model)
&& all_layers.iter().all(|layer| {
!layer.full_bright
&& !translucent_layer(m, layer)
&& cem.layer(&layer.model).is_none()
&& m.kind
.layer(&layer.model)
.is_none_or(|part| skin.supports(part))
})
}
None => false,
};
if !posable {
skin = None;
}
let draw_body = |out: &mut Vec<Vertex>, skin: &mut Option<&mut super::skin::Skin>| {
// A player or mannequin whose skin has no atlas sprite (an evicted or
// never stored download) draws with its default skin, not invisibly.
let drawn = sprite(texture).or_else(|| Some((texture, atlas(texture)?)));
let uv = drawn.map(|(_, uv)| uv).or_else(|| {
if avatar(m) {
atlas(&m.kind.texture)
} else {
None
}
});
if body_visible && let Some(uv) = uv {
let start = out.len();
put(
skin,
&model,
base,
&poses,
uv,
0.0,
body_color(m, alpha),
light,
body_overlay(m, alpha),
out,
);
if let Some(glow) = drawn.and_then(|(name, _)| emissive_uv(name)) {
put(
skin,
&model,
base,
&poses,
glow,
0.0,
body_color(m, alpha),
FULL_BRIGHT,
0,
out,
);
}
if translucent_body(m) {
mark_oit(&mut out[start..]);
}
}
};
// The translucent shell writes depth: draw the opaque core before it.
let shell_last = m.species() == Species::SulfurCube;
if !shell_last {
draw_body(out, &mut skin);
}
// The worn armour's layers (and their emissive overlays, full bright).
for (part, uv, color, layer_overlay, glows) in extra {
let light = if *glows { FULL_BRIGHT } else { light };
draw(part, base, &poses, *uv, *color, light, *layer_overlay, out);
}
// One Poses refilled for every layer (armour draws up to seven): it keeps
// its maps' capacity.
let mut layer_poses = Poses::default();
@@ -1917,7 +2012,8 @@ fn render_visible(
None => overlay,
};
let start = out.len();
draw_scrolled(
put(
&mut skin,
&part,
base,
&layer_poses,
@@ -1929,7 +2025,8 @@ fn render_visible(
out,
);
if let Some(glow) = emissive_uv(drawn) {
draw_scrolled(
put(
&mut skin,
&part,
base,
&layer_poses,
@@ -1979,7 +2076,7 @@ fn render_visible(
}
}
if shell_last {
draw_body(out);
draw_body(out, &mut skin);
}
}
/// `EnergySwirlLayer`'s model, texture and `xOffset(t)` for a charged creeper
@@ -3084,6 +3181,170 @@ mod scroll_tests {
]
);
}
/// `anim::Poses` keys parts by a hash of their name: within any vanilla model, two
/// different names never share one (they would share a pose).
#[test]
fn no_two_part_names_share_a_key() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("entity-models.json").exists() {
return;
}
fn names<'a>(p: &'a Part, out: &mut Vec<&'a str>) {
out.push(&p.name);
for c in &p.children {
names(c, out);
}
}
let types = super::super::MobTypes::load(&root).unwrap();
let mut models = 0;
for kind in types.types.values() {
for model in std::iter::once(&kind.model).chain(kind.layers.values()) {
let mut all = Vec::new();
names(model, &mut all);
let mut keys = std::collections::HashMap::new();
for name in all {
let other = *keys.entry(super::super::anim::key(name)).or_insert(name);
assert_eq!(other, name, "{} shares a key", kind.id);
}
models += 1;
}
}
assert!(models > 100);
}
/// The CPU cost of posing a mob farm (tools/stress_scenes.py `stress_mobs`): 1000 mobs of
/// 8 kinds through `render_with`, as `mob_geometry` draws them each frame. Prints the median
/// thread CPU time per frame (busy machines stretch wall time), the vertices and a hash of them (the same hash before and after a speed-up
/// means the same picture). `cargo test --profile fast -p horizon-client --lib
/// benchmark_mob_farm_geometry -- --ignored --nocapture`.
#[test]
#[ignore = "manual benchmark; needs the prepared resources"]
fn benchmark_mob_farm_geometry() {
use std::{hash::Hasher, hint::black_box, time::Instant};
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("entity-models.json").exists() {
return;
}
let types = super::super::MobTypes::load(&root).unwrap();
let kinds = [
"husk", "creeper", "cow", "pig", "sheep", "chicken", "villager", "spider",
]
.map(|k| std::sync::Arc::new(types.types[&format!("minecraft:{k}")].clone()));
let mobs: Vec<Mob> = (0..1000u32)
.map(|i| {
let (layer, row, col) = (i / 200, i / 20 % 10, i % 20);
let at = horizon_core::geometry::Vec3::new(
-14.25 + 1.5 * f64::from(col),
1.0 + 2.5 * f64::from(layer),
8.0 + 2.0 * f64::from(row),
);
let mut m = Mob::new(i + 1, kinds[i as usize % 8].clone(), at, 180.0);
m.previous = at;
m.age = i;
m
})
.collect();
let data = MobData {
states: Vec::new(),
placements: Default::default(),
heightmaps: Default::default(),
animations: Default::default(),
biome_tags: Default::default(),
entity_tags: Default::default(),
villager_hats: Default::default(),
cem: Default::default(),
random_textures: Default::default(),
emissive: Default::default(),
blinks: Default::default(),
pois: Default::default(),
species_placements: Default::default(),
};
let atlas = |name: &str| {
let h = name.len() as f32 / 64.0;
Some([h, h, h + 0.25, h + 0.25])
};
let camera = glam::DVec3::new(0.5, 9.6, 0.5);
let mut out = Vec::new();
let frame = |out: &mut Vec<Vertex>| {
out.clear();
for m in &mobs {
render_with(m, 0.5, camera, &atlas, 0xf0f0, &data, &[], out);
}
};
frame(&mut out);
let mut hash = std::collections::hash_map::DefaultHasher::new();
hash.write(bytemuck::cast_slice(&out));
// The thread's own CPU time where the platform gives it, else wall time.
let timed = |f: &mut dyn FnMut()| {
let (wall, cpu) = (Instant::now(), horizon_client_render::spans::thread_cpu());
f();
match (horizon_client_render::spans::thread_cpu(), cpu) {
(Some(end), Some(start)) => (end - start).as_secs_f64() * 1000.0,
_ => wall.elapsed().as_secs_f64() * 1000.0,
}
};
let mut times = Vec::new();
for _ in 0..30 {
times.push(timed(&mut || frame(black_box(&mut out))));
}
times.sort_by(f64::total_cmp);
println!(
"mob farm: 1000 mobs, {} vertices, {:.3} ms/frame (median of 30, min {:.3}), hash {:016x}",
out.len(),
times[15],
times[0],
hash.finish()
);
// Where it goes: the body's poses alone (state, pose map, setupAnim), then the
// body's vertices alone from those poses.
let median = |mut f: Box<dyn FnMut() + '_>| {
let mut t: Vec<f64> = (0..30).map(|_| timed(&mut f)).collect();
t.sort_by(f64::total_cmp);
t[15]
};
let posed: Vec<(std::sync::Arc<Part>, Poses)> = mobs
.iter()
.map(|m| {
let model = m.kind.model.clone();
let mut poses = Poses::new(&model);
anim::setup(m, &anim::state(m, 0.5), &mut poses, &data.animations);
(model, poses)
})
.collect();
let poses_ms = median(Box::new(|| {
for m in &mobs {
let model = m.kind.model.clone();
let mut poses = Poses::new(&model);
anim::setup(m, &anim::state(m, 0.5), &mut poses, &data.animations);
black_box(&poses);
}
}));
let mut body = Vec::new();
let draw_ms = median(Box::new(|| {
body.clear();
for (m, (model, poses)) in mobs.iter().zip(&posed) {
let base = base_transform(m, 0.5, camera);
draw(
model,
base,
poses,
[0.0, 0.0, 0.25, 0.25],
u32::MAX,
0xf0f0,
0,
&mut body,
);
}
black_box(&body);
}));
println!(
" body poses only {poses_ms:.3} ms; body vertices only {draw_ms:.3} ms ({} vertices)",
body.len()
);
}
}
#[cfg(test)]
+422
View File
@@ -0,0 +1,422 @@
//! Mob models posed on the GPU (`gfx::entity_skin`): the store of models
//! uploaded once, and the walk that turns one `render::draw` into the parts'
//! matrices and jobs the shader expands into the very vertices `draw` would
//! push, in its order.
//!
//! A model takes this path only when every polygon faces an axis: the
//! normal matrix times an axis is one of its columns, exactly, so the shader's
//! packed normals are the CPU's to the bit. Positions and UVs are the same
//! sums in the same order (`Mat4::transform_point3`).
use super::{ModelNormals, Part, anim::Poses};
use crate::fast_hash::FxHashMap;
use crate::gfx::entity_skin::{SkinFrame, SkinJob, SkinPart, SkinVertex};
use std::sync::Arc;
/// A stored model: where its vertices start, and per part (pre-order) its
/// own vertices and the end of its subtree, for the walk.
struct Stored {
first: u32,
parts: Vec<StoredPart>,
/// The model's bounds in its parts' own space, per part: min and max of
/// its own vertices (none: no polygons).
bounds: Vec<Option<([f32; 3], [f32; 3])>>,
}
#[derive(Clone, Copy)]
struct StoredPart {
/// The part's first vertex, from the model's.
first: u32,
count: u32,
/// One past the last part of its subtree.
end: u32,
}
/// The models on the GPU, by the `Arc` they are drawn from. An entry holds
/// its `Arc`, so the address is never reused while stored; models no one
/// else holds any more are dropped by `begin`.
#[derive(Default)]
pub struct SkinModels {
stored: FxHashMap<usize, (Arc<Part>, Option<Stored>)>,
vertices: u32,
}
/// Models whose store would grow past this restart it (a pack reload's
/// models pile up otherwise).
const STORE_LIMIT: u32 = 4 << 20;
impl SkinModels {
/// Starts a frame: drops the models nothing else holds (their vertices
/// stay in the store until it restarts), restarts the store when it grew
/// too large or the renderer's (`stored`, its size) is out of step.
pub fn begin(&mut self, frame: &mut SkinFrame, stored: u32) {
if self.vertices > STORE_LIMIT || stored + frame.new_model.len() as u32 != self.vertices {
self.restart(frame);
}
self.stored
.retain(|_, (model, _)| Arc::strong_count(model) > 1);
frame.clear_draws();
}
fn restart(&mut self, frame: &mut SkinFrame) {
self.stored.clear();
self.vertices = 0;
frame.new_model.clear();
frame.base = 0;
frame.reset = true;
}
fn entry(&mut self, model: &Arc<Part>, frame: &mut SkinFrame) -> Option<&Stored> {
let key = Arc::as_ptr(model) as usize;
let vertices = &mut self.vertices;
let (_, stored) = self.stored.entry(key).or_insert_with(|| {
// The renderer appends `new_model` to its store in order, so a
// model's first vertex is the store's size when it is added.
let before = frame.new_model.len();
let stored = flatten(model, *vertices, &mut frame.new_model);
*vertices += (frame.new_model.len() - before) as u32;
(model.clone(), stored)
});
stored.as_ref()
}
}
/// The model's vertices in pre-order (a part's polygons, then its children),
/// appended to `out`; `None` when a polygon does not face an axis.
fn flatten(model: &Part, first: u32, out: &mut Vec<SkinVertex>) -> Option<Stored> {
let start = out.len();
let mut parts = Vec::new();
let mut bounds = Vec::new();
fn walk(
part: &Part,
start: usize,
out: &mut Vec<SkinVertex>,
parts: &mut Vec<StoredPart>,
bounds: &mut Vec<Option<([f32; 3], [f32; 3])>>,
) -> bool {
let index = parts.len();
let own = (out.len() - start) as u32;
parts.push(StoredPart {
first: own,
count: 0,
end: 0,
});
bounds.push(None);
let mut lo = [f32::INFINITY; 3];
let mut hi = [f32::NEG_INFINITY; 3];
for poly in &part.polygons {
let Some(axis) = axis(poly.normal) else {
return false;
};
for v in poly.vertices {
let pos = (glam::Vec3::new(v[0], v[1], v[2]) / 16.0).to_array();
for a in 0..3 {
lo[a] = lo[a].min(pos[a]);
hi[a] = hi[a].max(pos[a]);
}
out.push(SkinVertex {
pos,
uv: [v[3], v[4]],
axis,
part: index as u32,
});
}
}
parts[index].count = (out.len() - start) as u32 - own;
if !part.polygons.is_empty() {
bounds[index] = Some((lo, hi));
}
for child in &part.children {
if !walk(child, start, out, parts, bounds) {
return false;
}
}
parts[index].end = parts.len() as u32;
true
}
if !walk(model, start, out, &mut parts, &mut bounds) {
out.truncate(start);
return None;
}
Some(Stored {
first,
parts,
bounds,
})
}
/// 0..2 for +x, +y, +z, 3..5 for -x, -y, -z; `None` for any other normal.
fn axis(n: [f32; 3]) -> Option<u32> {
let mut found = None;
for (a, &c) in n.iter().enumerate() {
if c == 1.0 || c == -1.0 {
if found.is_some() {
return None;
}
found = Some(a as u32 + if c < 0.0 { 3 } else { 0 });
} else if c != 0.0 {
return None;
}
}
found
}
/// One draw's parameters (`render::draw_scrolled`'s).
#[derive(Clone, Copy)]
pub struct Paint {
pub uv: [f32; 4],
pub scroll: f32,
pub color: u32,
pub light: u32,
pub overlay: u32,
}
/// Where a frame's posed draws go, and the bounds of the current mob's.
pub struct Skin<'a> {
pub models: &'a mut SkinModels,
pub frame: &'a mut SkinFrame,
/// Camera-relative bounds of what this mob drew here (for culling).
pub bounds: Option<(glam::Vec3, glam::Vec3)>,
}
impl Skin<'_> {
/// Whether this model can be posed here (stored on first sight).
pub fn supports(&mut self, model: &Arc<Part>) -> bool {
self.models.entry(model, self.frame).is_some()
}
/// `render::draw_scrolled` on the GPU: false (nothing done) when the
/// model cannot take this path.
pub fn draw(
&mut self,
model: &Arc<Part>,
base: glam::Mat4,
poses: &Poses,
paint: Paint,
) -> bool {
let Some(stored) = self.models.entry(model, self.frame) else {
return false;
};
let first_part = self.frame.parts.len() as u32;
self.frame.parts.resize(
self.frame.parts.len() + stored.parts.len(),
bytemuck::Zeroable::zeroed(),
);
let mut walk = Walk {
stored,
poses,
parts: &mut self.frame.parts[first_part as usize..],
jobs: &mut self.frame.jobs,
vertices: &mut self.frame.vertices,
bounds: &mut self.bounds,
first_part,
paint,
index: 0,
};
walk.part(model, base, ModelNormals::new(base));
true
}
}
struct Walk<'a> {
stored: &'a Stored,
poses: &'a Poses,
parts: &'a mut [SkinPart],
jobs: &'a mut Vec<SkinJob>,
vertices: &'a mut u32,
bounds: &'a mut Option<(glam::Vec3, glam::Vec3)>,
first_part: u32,
paint: Paint,
/// The pre-order index of the part being walked.
index: u32,
}
impl Walk<'_> {
/// `render::draw_normals` without the vertices: the part's matrices, and
/// its polygons as (part of) a job.
fn part(&mut self, part: &Part, base: glam::Mat4, normals: ModelNormals) {
let index = self.index;
let stored = self.stored.parts[index as usize];
let p = self.poses.pose(part);
let m = super::render::posed(base, p);
if self.poses.is_hidden(part) || p[6] == 0.0 && p[7] == 0.0 && p[8] == 0.0 {
// The whole subtree is left out.
self.index = stored.end;
return;
}
let normals = normals.part(p);
let n = if normals.trusted {
normals.matrix
} else {
glam::Mat3::from_cols(
normals.matrix.x_axis.normalize_or_zero(),
normals.matrix.y_axis.normalize_or_zero(),
normals.matrix.z_axis.normalize_or_zero(),
)
};
self.parts[index as usize] = SkinPart {
matrix: m.to_cols_array(),
normal: n.to_cols_array(),
};
if !self.poses.skips(part) && stored.count > 0 {
self.emit(stored);
if let Some((lo, hi)) = self.stored.bounds[index as usize] {
let (lo, hi) = (glam::Vec3::from_array(lo), glam::Vec3::from_array(hi));
let center = m.transform_point3((lo + hi) * 0.5);
let half = (hi - lo) * 0.5;
let r = glam::Mat3::from_mat4(m);
let extent =
r.x_axis.abs() * half.x + r.y_axis.abs() * half.y + r.z_axis.abs() * half.z;
let (a, b) = (center - extent, center + extent);
*self.bounds = Some(match *self.bounds {
Some((l, h)) => (l.min(a), h.max(b)),
None => (a, b),
});
}
}
self.index += 1;
for child in &part.children {
self.part(child, m, normals);
}
}
/// The part's vertices: extends the last job when they follow its own.
fn emit(&mut self, stored: StoredPart) {
let first = self.stored.first + stored.first;
let paint = self.paint;
if let Some(job) = self.jobs.last_mut()
&& job.first + job.count == first
&& job.part == self.first_part
&& job.dst + job.count == *self.vertices
{
job.count += stored.count;
} else {
self.jobs.push(SkinJob {
first,
count: stored.count,
part: self.first_part,
dst: *self.vertices,
rect: paint.uv,
scroll: paint.scroll,
color: paint.color,
light: paint.light,
overlay: paint.overlay,
glint: u32::from(paint.overlay == super::render::ARMOR_GLINT),
spare: [0; 3],
});
}
*self.vertices += stored.count;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gfx::entity_skin::expand;
use crate::mobs::{Mob, MobData, MobTypes, render};
/// Every vanilla mob, at a few ages and turns: the GPU path's vertices
/// (its jobs expanded as `entity_skin.wgsl` does) are the CPU path's, to
/// the bit and in the same order, for each mob it takes; the mobs it
/// leaves to the CPU draw exactly as before. Needs the prepared resources.
#[test]
fn posed_on_the_gpu_every_mob_draws_what_the_cpu_draws() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("entity-models.json").exists() {
eprintln!("no prepared resources: skipped");
return;
}
let types = MobTypes::load(&root).unwrap();
let data = MobData {
states: Vec::new(),
placements: Default::default(),
heightmaps: Default::default(),
animations: MobData::load_animations(&root),
biome_tags: Default::default(),
entity_tags: Default::default(),
villager_hats: Default::default(),
cem: Default::default(),
random_textures: Default::default(),
emissive: Default::default(),
blinks: Default::default(),
pois: Default::default(),
species_placements: Default::default(),
};
// Every texture its own rect, so a swapped texture shows.
let atlas = |name: &str| {
let h = name
.bytes()
.fold(7u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
let u = (h % 61) as f32 / 64.0;
let v = (h / 61 % 61) as f32 / 64.0;
Some([u, v, u + 1.0 / 64.0, v + 1.0 / 32.0])
};
let camera = glam::DVec3::new(0.3, 62.0, -4.0);
let mut models = SkinModels::default();
let mut frame = SkinFrame::default();
let mut store: Vec<crate::gfx::entity_skin::SkinVertex> = Vec::new();
let (mut posed, mut kept) = (0, 0);
let mut ids: Vec<_> = types.types.keys().cloned().collect();
ids.sort();
for (n, id) in ids.iter().enumerate() {
let kind = Arc::new(types.types[id].clone());
for (age, yaw) in [(0u32, 0.0f32), (37, 123.0), (412, -71.5)] {
let at = horizon_core::geometry::Vec3::new(1.5 + n as f64 % 5.0, 64.0, 3.25);
let mut m = Mob::new(n as u32 + 1, kind.clone(), at, yaw);
m.previous = horizon_core::geometry::Vec3::new(at.x - 0.1, at.y, at.z + 0.05);
m.age = age;
m.limb_speed = 0.6;
m.limb_position = age as f32 * 0.37;
let mut cpu = Vec::new();
render::render_with(&m, 0.4, camera, &atlas, 0x50a0, &data, &[], &mut cpu);
models.begin(&mut frame, store.len() as u32);
let mut gpu = Vec::new();
let mut skin = Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
render::render_skinned(&m, 0.4, camera, &atlas, 0x50a0, &data, &mut gpu, &mut skin);
let bounds = skin.bounds;
if frame.reset {
store.clear();
}
assert_eq!(store.len() as u32, frame.base);
store.extend_from_slice(&frame.new_model);
frame.handed();
if frame.jobs.is_empty() {
kept += 1;
assert_eq!(
bytemuck::cast_slice::<_, u8>(&gpu),
bytemuck::cast_slice::<_, u8>(&cpu),
"{id} age {age}: the CPU path changed"
);
continue;
}
posed += 1;
assert!(gpu.is_empty(), "{id}: a posed mob left vertices on the CPU");
let expanded = expand(&store, &frame);
assert_eq!(expanded.len(), cpu.len(), "{id} age {age}: vertex count");
for (i, (a, b)) in expanded.iter().zip(&cpu).enumerate() {
assert_eq!(
bytemuck::bytes_of(a),
bytemuck::bytes_of(b),
"{id} age {age} vertex {i}: {a:?} != {b:?}"
);
}
// The culling bounds hold every vertex.
let (lo, hi) = bounds.expect("a posed mob has bounds");
for v in &cpu {
let p = glam::Vec3::from_array(v.pos);
assert!(
p.cmpge(lo - 1e-4).all() && p.cmple(hi + 1e-4).all(),
"{id}: {p} outside {lo}..{hi}"
);
}
}
}
eprintln!("posed on the GPU: {posed} mob draws; kept on the CPU: {kept}");
assert!(posed > 3 * 40, "only {posed} draws took the GPU path");
}
}
+15 -1
View File
@@ -5,17 +5,28 @@
//! Generate the trait and dispatch with `cargo run -p xtask -- mod-api`.
use std::sync::{Arc, OnceLock};
pub use crate::containers::{ModBlockEntity, ModContainer, ModFluidTanks};
pub use crate::biome::BiomeColors;
pub use crate::camera::CameraEntity;
pub use crate::containers::{ModBlockEntity, ModContainer, ModEnergyBuffer, ModFluidTanks};
pub use crate::crafting::{CustomRecipe, Ingredient};
pub use crate::extras::Corner;
pub use crate::extras_hud::Corners;
pub use crate::first_person::Hand;
pub use crate::fog::{FogData, FogType};
pub use crate::game::native_block_entity_draw::{BlockEntityDraw, CubeFace};
pub use crate::game::{
NativeBlockEdit, NativeBlockRun, NativeClock, NativeColumnBlocks, NativeDataMapType,
NativeExperienceOrb, NativeFalling, NativeHeightmap, NativeHeldItem, NativeHit,
NativeMapDecoration, NativeMapInfo, NativeMobRender, NativeModEvent, NativePlayerRender,
NativeShake,
};
pub use crate::gfx::native_render::{
RenderHooks, RenderNeeds, RenderNeedsEvent, WorldDrawn, WorldDrawnEvent, wgpu,
};
pub use crate::hud::HudBatch;
pub use crate::hud_layout::{GuiDraw, HealthInputs};
pub use crate::items::ItemStack;
pub use crate::music::{Music, MusicLayer};
pub use crate::text::Text;
pub use horizon_assets::item_model::DisplayContext;
pub use horizon_core::{
@@ -23,18 +34,21 @@ pub use horizon_core::{
position::BlockPos,
random::{LegacyRandom, RandomSource},
};
pub use horizon_world::consumable::UseAnimation;
pub use horizon_world::{
attachments::{
AttachmentCodec, AttachmentError, AttachmentHost, AttachmentType, Attachments, Holder,
},
fluid::FluidState,
level::{BlockId, Dimension as LevelDimension, PlacementContext},
loot::{LootStack, NativeLootContext},
nbt::{Compound, Tag},
registry::BlockStateId,
};
pub use serde_json;
use serde_json::{Map, Value};
use std::time::Duration;
pub use winit::event::KeyEvent;
/// A dispenser hook's answer: the slot's remainder (None empties it) and the
/// level event to play, `CLICK` (1000), `FAIL` (1001) or 0 for none.
File diff suppressed because it is too large. Load diff
+161
View File
@@ -237,6 +237,167 @@ pub trait Host {
state: BlockStateId,
flags: i32,
) -> bool;
/// For a server mod: the runs made at once, straight into the chunks of the authoritative level `dimension`
/// (the one being ticked, as for `native_set_block`), with what `fill ... strict` does for block entities (a
/// block replaced by another loses its block entity, and a new block gets the one `native_set_block` would
/// give it, a mod block's included); no neighbour is told, and the chunks changed go to the players whole.
/// Runs in chunks not loaded, and the parts of runs outside the column, are left out. The blocks changed, or
/// why the edit was left out, with nothing changed: another dimension, a client of a server, a palette entry
/// or filter that doesn't read or carries NBT (refused), a run naming a palette entry or filter past the end,
/// or more than 65536 filters. API 126.
fn edit_blocks(&mut self, dimension: &str, edit: &NativeBlockEdit) -> Result<u64, String>;
/// For a server mod: each column's height by `heightmap` in the authoritative level `dimension` (the one being
/// ticked), none for a column not loaded. `Err` says why nothing was read: another dimension or a client of a
/// server, or more than 65,536 columns (`MAX_COLUMNS`). API 127.
fn surface_heights(
&self,
dimension: &str,
columns: &[(i32, i32)],
heightmap: NativeHeightmap,
) -> Result<Vec<Option<i32>>, String>;
/// For a server mod: `below` blocks under and `above` blocks from each column's height by `heightmap`, as a
/// palette and indexes, with the biome's grass, foliage and water tints (air outside the column and in columns
/// not loaded). The tints are the biome's at the column's top, not blended over the biome blend radius as the
/// game draws them: near a biome border they can differ from what players see. `Err` says why nothing was
/// read: another dimension or a client of a server, `below + above` of 0 or more than the level's height, or
/// a read of more than 1,048,576 blocks (`MAX_READ`; it runs on the server tick, so read big areas in pieces).
/// API 127.
fn read_columns(
&self,
dimension: &str,
columns: &[(i32, i32)],
heightmap: NativeHeightmap,
range: (u32, u32),
) -> Result<NativeColumnBlocks, String>;
/// Asks `native_biome_colors` again for every biome and, when the colours
/// differ from those in use, rebuilds every loaded section and the far
/// terrain with them (`LevelRenderer.allChanged`, as changing Biome Blend
/// does). Call it when your colours change (a new season, a new day of a
/// slow fade) and once when the player joins, not every frame: a rebuild
/// takes the workers a few seconds. API 107.
fn refresh_biome_colors(&mut self);
/// The strongest redstone signal reaching the block at `pos` from its six
/// neighbours, 0 to 15 (`SignalGetter.getBestNeighborSignal`, what a
/// dispenser or a piston reads), in the level in place, `dimension`. 0 on
/// a client's copy of the world and for another dimension. A mod's
/// explosive, launcher or machine reads it in its block entity's tick to
/// go off or switch on. API 96.
fn neighbor_signal(&mut self, dimension: &str, pos: [i32; 3]) -> i32;
/// The light at `pos` in the world in place, as `(block, sky)`, each 0 to
/// 15 (`Level.getBrightness` for `LightLayer.BLOCK` and `LightLayer.SKY`).
/// A solar panel checks for full sky light above it, a lamp or a mob farm
/// for darkness. `(0, 0)` in a chunk that isn't loaded. Like the other
/// reads here it reads the world this game holds: the level in place on
/// a server, the server's copy on a client. API 97.
fn light(&self, pos: [i32; 3]) -> (u8, u8);
/// The rain and thunder levels of the world in place, each 0.0 to 1.0
/// (`Level.getRainLevel` and `Level.getThunderLevel`): what a solar panel
/// or a wind turbine reads. Both 0 in the Nether and the End, which have
/// no weather (`Level.canHaveWeather`). API 97.
fn weather(&self) -> (f32, f32);
/// The world's time of day in ticks (`Level.getOverworldClockTime`), the
/// same as `native_clock`'s `day_time`; it keeps counting past 24000, so
/// take it modulo 24000 for the hour. API 97.
fn day_time(&self) -> i64;
/// The lowest block y and the height of the world in place
/// (`LevelHeightAccessor.getMinY` and `getHeight`): how deep a quarry or a
/// miner may dig. API 97.
fn world_height(&self) -> (i32, i32);
/// The most of the item `item` one slot holds (`Item.getDefaultMaxStackSize`),
/// or `None` for an unknown item: for a mod's container that fills or
/// merges stacks itself. API 97.
fn max_stack_size(&self, item: &str) -> Option<u32>;
/// Item `item`'s icon drawn with model `model` (`minecraft:paper` as
/// `mymod:wheel_icon`, an `item_model` of a content mod's), as `native_item`
/// draws one; false for an unknown item. API 111.
fn item_model(
&self,
batch: &mut HudBatch,
item: &str,
model: &str,
at: [f32; 2],
size: f32,
) -> bool;
/// A quad of corners `points` (in order round it) in GUI pixels, `argb`:
/// a wedge, a slanted bar, what `native_fill`'s rectangles can't draw.
/// API 111.
fn quad(&self, batch: &mut HudBatch, points: [[f32; 2]; 4], argb: u32);
/// Opens a screen of mod `mod_id`'s own over the world: the screen hooks
/// fire over it as over any other, so the mod draws it in
/// `native_screen_render_post` and takes its clicks, scroll, keys and
/// typing there; the mouse is freed, and Escape closes it. Every mod's own
/// screen has the `NativeScreenView.id` `mod`, so a mod's screen hooks
/// check `native_mod_screen()` is its own before drawing. It doesn't pause
/// a single-player game (as the inventory doesn't). True when it is open:
/// false while another screen is (or another mod's), over the title
/// screen, a book, a sign editor, a dialog or a prompt, while the world
/// loads, and anywhere but the window's own client (the server thread, a
/// dedicated server). A gamepad can close it (B, Start) but not click on
/// it: its clicks don't reach the mouse hooks. API 111.
fn mod_screen_open(&mut self, mod_id: &str) -> bool;
/// Closes mod `mod_id`'s own screen, if it is open. API 111.
fn mod_screen_close(&mut self, mod_id: &str);
/// The mod whose own screen is open, if one is (Escape, or another screen
/// replacing it, closes it). API 111.
fn mod_screen(&self) -> Option<&str>;
/// Writes the experience orb `id`'s `value` (the points a pickup gives), `count`
/// (orbs merged into it, each worth `value`) and `age` (ticks lived; an orb
/// despawns at 6000); a `None` is left as it is. API 128.
/// False, and nothing written, for an orb not in the server level `dimension`, a
/// `value` or `count` of 0, and on a client of a server.
fn set_experience_orb(
&mut self,
dimension: &str,
id: u32,
value: Option<u32>,
count: Option<u32>,
age: Option<u32>,
) -> bool;
/// The experience orbs of the server level `dimension` whose box meets
/// the box `min`..`max`, in the level's order: what a mod reads before it
/// cancels an orb in `native_entity_join_level`, to give its points with
/// `native_give_experience`. Empty for a level not in place and on a
/// client of a server.
fn experience_orbs(
&self,
dimension: &str,
min: [f64; 3],
max: [f64; 3],
) -> Vec<NativeExperienceOrb>;
/// The clock of the world this window shows, for client mods: its game
/// time and time of day as the server last sent them
/// (`ClientboundSetTimePacket`), advanced each tick between packets; none
/// on the title screen. API 92.
fn clock(&self) -> Option<NativeClock>;
/// Throws a falling block of `state` from `pos` (its bottom centre) at
/// `velocity` (blocks a tick) in the level in place, `dimension`: it
/// falls, lands and places itself as sand does, or drops as an item
/// where it can't. The block at `pos` is left as it is: take it away first
/// with `native_set_block` to make a block fall out of the world. False,
/// with nothing thrown, on a client's copy of the world, for another
/// dimension, an air or unknown state, a chunk that isn't loaded, a value
/// that isn't finite, or with 1024 falling blocks in the level already.
/// Each axis of `velocity` is cut to 3.9. API 81.
fn spawn_falling_block(
&mut self,
dimension: &str,
state: BlockStateId,
pos: [f64; 3],
velocity: [f64; 3],
falling: NativeFalling,
) -> bool;
/// Property `key` of block state `state` (a machine's `facing`, from the
/// `state` `native_block_entity_submit` gets), if it has one: no lookup in
/// the world, so cheap each frame. API 91.
fn block_state_property(&self, state: BlockStateId, key: &str) -> Option<&str>;
/// The first block along the view within `distance` (at most 1024), past the loaded chunks over the distant terrain. API 116.
fn raycast(&self, distance: f32) -> Option<NativeHit>;
/// The first block along the view within `RAYCAST_MAX`, and its distance. API 116.
fn view_hit(&self) -> Option<(f32, NativeHit)>;
/// Right-clicks with these items (at most 64 ids) are mod `mod_id`'s; a later call replaces the list. API 116.
fn claim_items(&mut self, mod_id: &str, ids: &[String]);
/// Mod `mod_id`'s events since its last poll (at most 256 wait). API 116.
fn poll(&mut self, mod_id: &str) -> Vec<NativeModEvent>;
/// Sets the stack of item entity `id` in the server level `dimension`, as
/// a hopper's `addItem` leaves what it couldn't take (`ItemEntity.setItem`);
/// an empty stack (count 0) removes the entity (`setItem(EMPTY)`, then
@@ -0,0 +1,48 @@
//! Callbacks into the executable for native trait mods' client-library hooks.
use crate::items::ItemStack;
use serde_json::{Map, Value};
use std::sync::OnceLock;
/// Client-library hooks, installed before any menu, entity or recipe is used.
pub struct NativeClientHooks {
pub menu_slot_rules: fn(&str, usize, &ItemStack, &mut bool, &mut u32),
pub living_breathe: fn(u32, &str, &mut bool, &mut i32, &mut i32),
pub ingredient: fn(&str, &Map<String, Value>, &mut Vec<String>),
}
static HOOKS: OnceLock<NativeClientHooks> = OnceLock::new();
/// Install the process-wide callbacks once; a second installation keeps the first.
pub fn install(hooks: NativeClientHooks) -> Result<(), NativeClientHooks> {
HOOKS.set(hooks)
}
pub(crate) fn menu_slot_rules(
menu: &str,
slot: usize,
stack: &ItemStack,
may_place: &mut bool,
max_stack: &mut u32,
) {
if let Some(hooks) = HOOKS.get() {
(hooks.menu_slot_rules)(menu, slot, stack, may_place, max_stack);
}
}
pub(crate) fn living_breathe(
entity: u32,
kind: &str,
can_breathe: &mut bool,
consume: &mut i32,
refill: &mut i32,
) {
if let Some(hooks) = HOOKS.get() {
(hooks.living_breathe)(entity, kind, can_breathe, consume, refill);
}
}
pub(crate) fn ingredient(kind: &str, data: &Map<String, Value>, items: &mut Vec<String>) {
if let Some(hooks) = HOOKS.get() {
(hooks.ingredient)(kind, data, items);
}
}
@@ -156,7 +156,7 @@ impl Session {
let before = (self.streaming.generated_total, self.world.chunks.len());
self.update();
thread::sleep(Duration::from_millis(5));
let idle = self.streaming.pending() == 0
let idle = !self.streaming.busy()
&& before == (self.streaming.generated_total, self.world.chunks.len());
quiet = if idle { quiet + 1 } else { 0 };
}
@@ -86,7 +86,7 @@ fn columns_loaded_past_the_render_distance_mesh_when_they_come_in_range() {
// and nothing comes back (`pending` empties).
streaming.free(uploads);
thread::sleep(Duration::from_millis(5));
let idle = streaming.pending() == 0 && before == (world.chunks.len(), meshed.len());
let idle = !streaming.busy() && before == (world.chunks.len(), meshed.len());
quiet = if idle { quiet + 1 } else { 0 };
}
};
@@ -181,7 +181,7 @@ fn relit_columns_keep_every_mesh_current() {
while quiet < 40 {
assert!(Instant::now() < deadline, "streaming never settled");
let (uploads, _) = streaming.update(world);
let idle = uploads.is_empty() && streaming.pending() == 0;
let idle = uploads.is_empty() && !streaming.busy();
meshes.extend(uploads);
thread::sleep(Duration::from_millis(5));
quiet = if idle { quiet + 1 } else { 0 };
+1 -1
View File
@@ -90,7 +90,7 @@ fn a_tall_column_streams_lights_and_meshes_to_its_top() {
while quiet < 40 {
assert!(Instant::now() < deadline, "streaming never settled");
let (uploads, _) = streaming.update(world);
let idle = uploads.is_empty() && streaming.pending() == 0;
let idle = uploads.is_empty() && !streaming.busy();
meshes.extend(uploads);
thread::sleep(Duration::from_millis(5));
quiet = if idle { quiet + 1 } else { 0 };
+40
View File
@@ -272,6 +272,23 @@ impl PalettedContainer {
Palette::Global => self.raw(i) as u32,
}
}
/// `get` of the entries `start..start + out.len()`, the palette matched once.
#[inline]
pub fn get_run(&self, start: usize, out: &mut [u32]) {
match &self.palette {
Palette::Single(value) => out.fill(*value),
Palette::List(list) => {
for (k, cell) in out.iter_mut().enumerate() {
*cell = list[self.raw(start + k)];
}
}
Palette::Global => {
for (k, cell) in out.iter_mut().enumerate() {
*cell = self.raw(start + k) as u32;
}
}
}
}
/// The value when every entry is equal, without the palette having to be
/// minimal.
pub fn uniform(&self) -> Option<u32> {
@@ -736,6 +753,29 @@ mod tests {
*state
}
#[test]
fn runs_read_the_stored_values() {
let mut state = 0x9E37_79B9_7F4A_7C15;
// One value, a list palette, and the global palette.
for distinct in [1u64, 5, 3000] {
let values: Vec<u32> = (0..4096)
.map(|_| (rng(&mut state) % distinct) as u32)
.collect();
let container = PalettedContainer::from_values(BLOCKS, &values);
for _ in 0..200 {
let start = (rng(&mut state) % 4096) as usize;
let len = (rng(&mut state) % 20) as usize;
let len = len.min(4096 - start);
let mut run = vec![u32::MAX; len];
container.get_run(start, &mut run);
assert_eq!(
run,
values[start..start + len],
"{distinct} values, {start}+{len}"
);
}
}
}
#[test]
fn containers_stored_alike_are_the_same() {
let values: Vec<u32> = (0..4096).map(|i| (i % 7) as u32).collect();
let a = PalettedContainer::from_values(BLOCKS, &values);
@@ -0,0 +1,205 @@
//! Replays the play packets of a `horizon-bots --capture` file through the client's receive path on
//! one thread, and prints the CPU each stage takes and what each packet type costs to decode:
//!
//! horizon-bots --server 127.0.0.1:25565 --bots 1 --behaviour explore --view-distance 32 \
//! --seconds 60 --capture join.cap
//! cargo run --release -p horizon-protocol --example net_replay -- join.cap
//!
//! The packets go through a connection three times, as they reach the client:
//! - `memory`: single player (an integrated server's pipe: no compression, no encryption);
//! - `tcp`: a server's default compression threshold (256), as offline servers send it;
//! - `tcp+aes`: the same encrypted, as online-mode servers send it.
//!
//! Each pass sends every packet into an in-memory pipe first, then times `ConnReader::recv` for all of
//! them (pipe read, decryption, framing, inflate). `decode` is `play::Clientbound::decode`, and
//! `sections` parses chunk packets' sections (overworld: 24 sections; `--biome-bits` from the server's
//! biome registry, 7 for vanilla's 65 biomes). `--repeat N` keeps each stage's fastest of N runs.
use horizon_protocol::{
Direction, State,
buf::write_varint,
connection::{Connection, Pipe},
ids,
packets::play::Clientbound,
};
use std::{collections::BTreeMap, error::Error, time::Instant};
struct Packet {
id: i32,
body: Vec<u8>,
}
fn load(path: &str) -> Result<Vec<Packet>, Box<dyn Error>> {
let data = std::fs::read(path)?;
let mut packets = Vec::new();
let mut at = 0;
while at + 12 <= data.len() {
let id = i32::from_le_bytes(data[at + 4..at + 8].try_into()?);
let len = u32::from_le_bytes(data[at + 8..at + 12].try_into()?) as usize;
let Some(payload) = data.get(at + 12..at + 12 + len) else {
break;
};
let mut body = Vec::with_capacity(len + 3);
write_varint(&mut body, id);
body.extend_from_slice(payload);
packets.push(Packet { id, body });
at += 12 + len;
}
Ok(packets)
}
/// Seconds to receive every packet through a connection with this threshold and encryption,
/// and the bytes the connection read.
fn receive(packets: &[Packet], threshold: i32, encrypt: bool) -> (f64, i64, Vec<(i32, Vec<u8>)>) {
let (a, b) = Pipe::pair();
let (mut server, mut client) = (Connection::local(b), Connection::local(a));
server.set_compression(threshold);
client.set_compression(threshold);
if encrypt {
let key = [0x5a; 16];
server.enable_encryption(&key);
client.enable_encryption(&key);
}
for p in packets {
server.send(&p.body).expect("the pipe is open");
}
let start = Instant::now();
let received: Vec<_> = (0..packets.len())
.map(|_| client.recv().expect("every packet sent arrives"))
.collect();
let seconds = start.elapsed().as_secs_f64();
let bytes = client
.reader
.counters
.bytes_in
.load(std::sync::atomic::Ordering::Relaxed);
(seconds, bytes, received)
}
#[derive(Default, Clone, Copy)]
struct Cost {
count: u64,
bytes: u64,
nanos: u64,
}
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = std::env::args().skip(1).collect();
let path = args
.first()
.ok_or("usage: net_replay CAPTURE [--repeat N] [--biome-bits B]")?;
let flag = |name: &str, default: usize| {
args.iter()
.position(|a| a == name)
.and_then(|i| args.get(i + 1))
.and_then(|v| v.parse().ok())
.unwrap_or(default)
};
let repeat = flag("--repeat", 3).max(1);
let biome_bits = flag("--biome-bits", 7) as u8;
let packets = load(path)?;
let body_bytes: usize = packets.iter().map(|p| p.body.len()).sum();
let chunk_id = ids::play::clientbound::LEVEL_CHUNK_WITH_LIGHT;
let chunks = packets.iter().filter(|p| p.id == chunk_id).count();
println!(
"{path}: {} packets, {:.1} MB of packet bodies, {chunks} chunk packets",
packets.len(),
body_bytes as f64 / 1e6
);
let mut received = Vec::new();
println!("\nreceive (ConnReader::recv for every packet, fastest of {repeat}):");
println!(
" {:<8} {:>10} {:>10} {:>12} {:>14}",
"pass", "wire MB", "ms", "ns/packet", "ns/wire byte"
);
for (name, threshold, encrypt) in [
("memory", -1, false),
("tcp", 256, false),
("tcp+aes", 256, true),
] {
let mut best = f64::MAX;
let mut wire = 0;
for _ in 0..repeat {
let (seconds, bytes, got) = receive(&packets, threshold, encrypt);
best = best.min(seconds);
wire = bytes;
received = got;
}
println!(
" {name:<8} {:>10.1} {:>10.1} {:>12.0} {:>14.2}",
wire as f64 / 1e6,
best * 1e3,
best * 1e9 / packets.len() as f64,
best * 1e9 / wire.max(1) as f64
);
}
// Decode, per packet type: the fastest of the runs for each type.
let mut costs: BTreeMap<i32, Cost> = BTreeMap::new();
let mut sections = Cost::default();
let mut errors = 0;
for run in 0..repeat {
let mut this: BTreeMap<i32, Cost> = BTreeMap::new();
let mut this_sections = Cost::default();
for (id, payload) in &received {
let start = Instant::now();
let packet = Clientbound::decode(*id, payload);
let nanos = start.elapsed().as_nanos() as u64;
let c = this.entry(*id).or_default();
c.count += 1;
c.bytes += payload.len() as u64;
c.nanos += nanos;
match packet {
Ok(Clientbound::LevelChunkWithLight(p)) => {
let start = Instant::now();
if p.chunk.sections(24, 16, biome_bits).is_err() && run == 0 {
errors += 1;
}
this_sections.count += 1;
this_sections.bytes += p.chunk.buffer.len() as u64;
this_sections.nanos += start.elapsed().as_nanos() as u64;
}
Err(_) if run == 0 => errors += 1,
_ => {}
}
}
for (id, c) in this {
let best = costs.entry(id).or_insert(c);
if c.nanos < best.nanos {
*best = c;
}
}
if run == 0 || this_sections.nanos < sections.nanos {
sections = this_sections;
}
}
let mut rows: Vec<_> = costs.into_iter().collect();
rows.sort_by_key(|(_, c)| std::cmp::Reverse(c.nanos));
let total: u64 = rows.iter().map(|(_, c)| c.nanos).sum();
println!(
"\ndecode (play::Clientbound::decode): {:.1} ms for {} packets, {errors} errors",
total as f64 / 1e6,
packets.len()
);
println!(
" {:<34} {:>8} {:>10} {:>10} {:>10}",
"packet", "count", "MB", "ms", "ns/packet"
);
for (id, c) in rows.iter().take(20) {
let name = ids::name(State::Play, Direction::Clientbound, *id).unwrap_or("?");
println!(
" {name:<34} {:>8} {:>10.2} {:>10.2} {:>10.0}",
c.count,
c.bytes as f64 / 1e6,
c.nanos as f64 / 1e6,
c.nanos as f64 / c.count as f64
);
}
println!(
"\nsections (LevelChunkPacketData.sections): {} chunks, {:.1} ms, {:.0} us a chunk",
sections.count,
sections.nanos as f64 / 1e6,
sections.nanos as f64 / 1e3 / sections.count.max(1) as f64
);
Ok(())
}
+700
View File
@@ -0,0 +1,700 @@
//! One pool of worker threads for the whole process, shared by every source of
//! background work: chunk generation, light and meshing (the client's and each
//! of the server thread's levels), chunk encoding, far terrain.
//!
//! Before it, each of those started its own threads, about one per core each:
//! a single player world ran four or five such pools at once, ~100 threads on a
//! 32-thread CPU, and they fought each other and the render and server threads
//! for cores. Here the threads are counted once, for the machine
//! ([`configure`]), and the work goes to them in the order it matters.
//!
//! A [`Source`] keeps its own queue (it can rank its jobs again when the player
//! moves, or drop the ones that left the view); a worker that is free asks every
//! source for the [`Rank`] of its next job and runs the most urgent one: the
//! [`Class`] first, then the source's key (a chunk's distance from the player).
//! Every free worker takes the next job as soon as it is free, so no core waits
//! while work is queued, whichever source queued it. Jobs are coarse (a chunk, a
//! section, a tile: a millisecond or more), so one lock for the choice costs
//! nothing next to them.
//!
//! A worker asks the sources for their next rank under the pool's read lock
//! of its sources and nothing else, so a source may call [`Pool::notify`]
//! anywhere, its queue's lock held or not; a notify never waits for a worker.
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock, RwLock};
use std::time::Duration;
/// How urgent a job is, before its source's key: every queued job of a lower
/// class runs before any of a higher one.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Class {
/// Work something waits for now: saves, a tick's chunk encoding.
Urgent,
/// The chunks around the player: generation, light, meshing.
Near,
/// Far terrain: runs on what the near work leaves, and never on every
/// worker at once, so near work that arrives finds a worker free.
Far,
}
/// A job's place in the pool's order: lower runs first.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Rank {
pub class: Class,
/// The source's own order within the class (a column's distance).
pub key: i64,
}
impl Rank {
pub const fn new(class: Class, key: i64) -> Self {
Self { class, key }
}
}
/// Work queued for the pool.
pub trait Source: Send + Sync {
/// The rank of the job [`Source::run`] would take next, or None while
/// nothing is queued. Called under the pool's lock: cheap, no blocking.
fn next(&self) -> Option<Rank>;
/// Take the next job and run it on worker `worker` (0..[`Pool::threads`]).
/// False if there was none after all.
fn run(&self, worker: usize) -> bool;
/// Worker `worker` has waited [`IDLE_RELEASE`] with no work: free what it
/// keeps between jobs.
fn idle(&self, _worker: usize) {}
/// Every worker is idle: free what the workers share.
fn all_idle(&self) {}
}
/// How long a worker waits with no job before it frees its buffers.
pub const IDLE_RELEASE: Duration = Duration::from_secs(5);
/// Per-worker state for a source: worker `i` only ever touches slot `i`, so
/// the locks are never contended. The source owns it, so it goes with the source.
pub struct PerWorker<T> {
slots: Box<[Mutex<Option<T>>]>,
}
impl<T> PerWorker<T> {
pub fn new(workers: usize) -> Self {
Self {
slots: (0..workers.max(1)).map(|_| Mutex::new(None)).collect(),
}
}
/// `f` with worker `worker`'s state, made by `init` the first time.
pub fn with<R>(
&self,
worker: usize,
init: impl FnOnce() -> T,
f: impl FnOnce(&mut T) -> R,
) -> R {
let mut slot = lock(&self.slots[worker % self.slots.len()]);
f(slot.get_or_insert_with(init))
}
/// `f` with worker `worker`'s state if it has one.
pub fn with_existing<R>(&self, worker: usize, f: impl FnOnce(&mut T) -> R) -> Option<R> {
lock(&self.slots[worker % self.slots.len()]).as_mut().map(f)
}
/// Drop worker `worker`'s state.
pub fn clear(&self, worker: usize) -> Option<T> {
lock(&self.slots[worker % self.slots.len()]).take()
}
}
struct Entry {
id: u64,
source: Arc<dyn Source>,
/// Workers running one of its jobs.
running: AtomicUsize,
/// Taken out of the pool: the worker that ends its last job tells
/// [`Registration`]'s drop, which waits for it.
retired: AtomicBool,
}
struct Inner {
/// Read by every choice, written only to add or take out a source.
sources: RwLock<Vec<Arc<Entry>>>,
next_id: AtomicU64,
/// Bumped by every [`Pool::notify`]: a worker that found nothing sleeps
/// until it changes, so a job queued while it looked is not missed. A
/// notify is two atomic operations while every worker is busy, and takes
/// `sleep` (held by no source code) only to wake one: the thread queuing
/// work (a frame, a tick) never waits behind a worker's choice.
epoch: AtomicU64,
sleepers: AtomicUsize,
sleep: Mutex<()>,
wake: Condvar,
/// Signalled when a retired source's job ends.
finished: Condvar,
/// Workers running a [`Class::Far`] job.
far: AtomicUsize,
/// Workers that freed their buffers and took no job since.
released: AtomicUsize,
threads: AtomicUsize,
idle_release: Duration,
}
/// The pool: [`pool`] for the process's own, [`Pool::new`] for a test's.
pub struct Pool {
inner: Arc<Inner>,
threads: usize,
}
/// A source's place in the pool; dropping it takes the source out and waits
/// for the jobs of it that are running (so never drop it in one of its own jobs).
pub struct Registration {
inner: Arc<Inner>,
id: u64,
}
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
// A panic in a job is caught; the pool's own state is never left half
// changed, so a poisoned lock is still consistent.
m.lock().unwrap_or_else(|e| e.into_inner())
}
static CONFIGURED: AtomicUsize = AtomicUsize::new(0);
static POOL: OnceLock<Pool> = OnceLock::new();
/// The number of workers the machine gets by default: one thread per core
/// but one, which the render and server threads share with them (they run
/// ahead of the workers: [`crate::favour_current`], [`crate::behind_server`]).
pub fn default_threads() -> usize {
std::thread::available_parallelism()
.map_or(2, |n| n.get())
.saturating_sub(1)
.max(1)
}
/// Size the process's pool before its first use (later calls change
/// nothing): `threads` workers, or `HORIZON_JOB_THREADS` when it is set.
pub fn configure(threads: usize) {
CONFIGURED.store(threads.max(1), Ordering::Relaxed);
}
/// The process's pool, started on first use.
pub fn pool() -> &'static Pool {
POOL.get_or_init(|| {
let threads = std::env::var("HORIZON_JOB_THREADS")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.filter(|&n| n > 0)
.unwrap_or_else(|| match CONFIGURED.load(Ordering::Relaxed) {
0 => default_threads(),
n => n,
});
// The name the crash reports know a worker by (vanilla's `Util` workers).
Pool::new(threads, "horizon-worker")
})
}
impl Pool {
/// A pool of `threads` workers named `name-<i>`. Workers that the system
/// refuses to start leave it smaller ([`Pool::threads`] counts those that started).
pub fn new(threads: usize, name: &str) -> Self {
Self::with_idle_release(threads, name, IDLE_RELEASE)
}
/// [`Pool::new`] whose workers free their buffers after `idle_release`.
pub fn with_idle_release(threads: usize, name: &str, idle_release: Duration) -> Self {
let inner = Arc::new(Inner {
sources: RwLock::new(Vec::new()),
next_id: AtomicU64::new(0),
epoch: AtomicU64::new(0),
sleepers: AtomicUsize::new(0),
sleep: Mutex::new(()),
wake: Condvar::new(),
finished: Condvar::new(),
far: AtomicUsize::new(0),
released: AtomicUsize::new(0),
threads: AtomicUsize::new(threads.max(1)),
idle_release,
});
let mut started = 0;
for i in 0..threads.max(1) {
let worker = inner.clone();
let spawned = std::thread::Builder::new()
.name(format!("{name}-{i}"))
.spawn(move || {
// Ticks and frames come first.
crate::behind_server();
work(&worker, i);
});
if spawned.is_ok() {
started += 1;
}
}
inner.threads.store(started.max(1), Ordering::Relaxed);
Self {
inner,
threads: started.max(1),
}
}
/// The workers: the size of a source's [`PerWorker`] and of the work it
/// keeps queued.
pub fn threads(&self) -> usize {
self.threads
}
/// Add a source; its jobs run until the registration is dropped.
pub fn register(&self, source: Arc<dyn Source>) -> Registration {
let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
self.inner
.sources
.write()
.unwrap_or_else(|e| e.into_inner())
.push(Arc::new(Entry {
id,
source,
running: AtomicUsize::new(0),
retired: AtomicBool::new(false),
}));
self.inner.notify(true);
Registration {
inner: self.inner.clone(),
id,
}
}
/// Wake a worker for a job just queued (after the queue's lock is released).
pub fn notify(&self) {
self.inner.notify(false);
}
/// Wake every worker: many jobs were queued at once.
pub fn notify_all(&self) {
self.inner.notify(true);
}
}
impl Inner {
fn notify(&self, all: bool) {
self.epoch.fetch_add(1, Ordering::SeqCst);
if self.sleepers.load(Ordering::SeqCst) > 0 {
// A worker between counting itself asleep and waiting holds
// `sleep`: taking it here means it is waiting when told.
drop(lock(&self.sleep));
if all {
self.wake.notify_all();
} else {
self.wake.notify_one();
}
}
}
}
impl Registration {
/// Wake a worker for a job just queued: [`Pool::notify`].
pub fn notify(&self) {
self.inner.notify(false);
}
pub fn notify_all(&self) {
self.inner.notify(true);
}
}
impl Drop for Registration {
fn drop(&mut self) {
let entry = {
let mut sources = self
.inner
.sources
.write()
.unwrap_or_else(|e| e.into_inner());
let Some(at) = sources.iter().position(|e| e.id == self.id) else {
return;
};
sources.swap_remove(at)
};
// No worker takes a job of it now (a worker counts itself running
// while it holds the sources' read lock); wait for those that did.
entry.retired.store(true, Ordering::SeqCst);
let mut sleep = lock(&self.inner.sleep);
while entry.running.load(Ordering::SeqCst) > 0 {
sleep = self
.inner
.finished
.wait(sleep)
.unwrap_or_else(|e| e.into_inner());
}
drop(sleep);
// The source's last reference may go here, outside every lock.
drop(entry);
}
}
/// The most urgent queued job's source, counted as running, if any may run now.
fn choose(inner: &Inner) -> Option<(Arc<Entry>, bool)> {
let threads = inner.threads.load(Ordering::Relaxed);
// Far work keeps one worker free for near work (when there are two or more).
let far_cap = threads.saturating_sub(1).max(1);
let sources = inner.sources.read().unwrap_or_else(|e| e.into_inner());
let mut skip_far = false;
loop {
let far_open = !skip_far && inner.far.load(Ordering::SeqCst) < far_cap;
let mut best: Option<(&Arc<Entry>, Rank)> = None;
for entry in sources.iter() {
let Some(rank) = entry.source.next() else {
continue;
};
if rank.class == Class::Far && !far_open {
continue;
}
if best.is_none_or(|(_, b)| rank < b) {
best = Some((entry, rank));
}
}
let (entry, rank) = best?;
let far = rank.class == Class::Far;
if far && !take_far_place(inner, far_cap) {
// Another worker took the last far place meanwhile.
skip_far = true;
continue;
}
entry.running.fetch_add(1, Ordering::SeqCst);
return Some((entry.clone(), far));
}
}
/// One more far job running, if fewer than `cap` are.
fn take_far_place(inner: &Inner, cap: usize) -> bool {
let mut n = inner.far.load(Ordering::SeqCst);
while n < cap {
match inner
.far
.compare_exchange_weak(n, n + 1, Ordering::SeqCst, Ordering::SeqCst)
{
Ok(_) => return true,
Err(now) => n = now,
}
}
false
}
fn work(inner: &Inner, worker: usize) {
let mut released = false;
loop {
let epoch = inner.epoch.load(Ordering::SeqCst);
if let Some((entry, far)) = choose(inner) {
if released {
inner.released.fetch_sub(1, Ordering::Relaxed);
released = false;
}
// A panicking job loses its own work, not the worker: the panic
// hook has already reported it.
let _ = catch_unwind(AssertUnwindSafe(|| entry.source.run(worker)));
if far {
inner.far.fetch_sub(1, Ordering::SeqCst);
}
entry.running.fetch_sub(1, Ordering::SeqCst);
if entry.retired.load(Ordering::SeqCst) {
drop(lock(&inner.sleep));
inner.finished.notify_all();
}
continue;
}
let sleep = lock(&inner.sleep);
inner.sleepers.fetch_add(1, Ordering::SeqCst);
let still = |_: &mut ()| inner.epoch.load(Ordering::SeqCst) == epoch;
let timed_out = if released {
drop(
inner
.wake
.wait_while(sleep, still)
.unwrap_or_else(|e| e.into_inner()),
);
false
} else {
let (sleep, wait) = inner
.wake
.wait_timeout_while(sleep, inner.idle_release, still)
.unwrap_or_else(|e| e.into_inner());
drop(sleep);
wait.timed_out()
};
inner.sleepers.fetch_sub(1, Ordering::SeqCst);
if !timed_out {
continue;
}
// Nothing to do for a while (a server nobody is on, a player standing
// still): free this worker's buffers until the next job needs them.
released = true;
let last = inner.released.fetch_add(1, Ordering::Relaxed) + 1
== inner.threads.load(Ordering::Relaxed);
let sources: Vec<_> = inner
.sources
.read()
.unwrap_or_else(|e| e.into_inner())
.iter()
.map(|e| e.source.clone())
.collect();
for source in &sources {
let _ = catch_unwind(AssertUnwindSafe(|| source.idle(worker)));
}
if last {
// The allocator keeps freed pages until memory runs short: once
// every worker has freed its own, hand them back.
for source in &sources {
let _ = catch_unwind(AssertUnwindSafe(|| source.all_idle()));
}
crate::release_free_memory();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
use std::sync::mpsc;
use std::time::Instant;
/// Jobs with fixed ranks; each records its name when it runs.
struct Fixed {
jobs: Mutex<VecDeque<(Rank, &'static str)>>,
ran: mpsc::Sender<&'static str>,
hold: Option<Arc<(Mutex<bool>, Condvar)>>,
}
impl Source for Fixed {
fn next(&self) -> Option<Rank> {
lock(&self.jobs).iter().map(|(r, _)| *r).min()
}
fn run(&self, _worker: usize) -> bool {
let job = {
let mut jobs = lock(&self.jobs);
let Some(at) = jobs
.iter()
.enumerate()
.min_by_key(|(_, (r, _))| *r)
.map(|(i, _)| i)
else {
return false;
};
jobs.remove(at).unwrap()
};
if let Some(hold) = &self.hold {
let (open, cv) = &**hold;
let mut open = lock(open);
while !*open {
open = cv.wait(open).unwrap();
}
}
let _ = self.ran.send(job.1);
true
}
}
fn fixed(jobs: &[(Rank, &'static str)], ran: mpsc::Sender<&'static str>) -> Arc<Fixed> {
Arc::new(Fixed {
jobs: Mutex::new(jobs.iter().copied().collect()),
ran,
hold: None,
})
}
fn recv_n(rx: &mpsc::Receiver<&'static str>, n: usize) -> Vec<&'static str> {
(0..n)
.map(|_| rx.recv_timeout(Duration::from_secs(10)).unwrap())
.collect()
}
#[test]
fn one_worker_runs_every_source_in_rank_order() {
let pool = Pool::new(1, "test-job");
let (tx, rx) = mpsc::channel();
// Held until both sources are in, so the order is the pool's choice.
let gate = Arc::new((Mutex::new(false), Condvar::new()));
let first = Arc::new(Fixed {
jobs: Mutex::new([(Rank::new(Class::Urgent, i64::MIN), "gate")].into()),
ran: tx.clone(),
hold: Some(gate.clone()),
});
let _g = pool.register(first);
let a = fixed(
&[
(Rank::new(Class::Near, 5), "near5"),
(Rank::new(Class::Far, 0), "far0"),
],
tx.clone(),
);
let b = fixed(
&[
(Rank::new(Class::Near, 1), "near1"),
(Rank::new(Class::Urgent, 9), "urgent9"),
],
tx,
);
let _a = pool.register(a);
let _b = pool.register(b);
*gate.0.lock().unwrap() = true;
gate.1.notify_all();
assert_eq!(
recv_n(&rx, 5),
["gate", "urgent9", "near1", "near5", "far0"]
);
}
#[test]
fn far_work_leaves_a_worker_for_near_work() {
let pool = Pool::new(3, "test-job");
let gate = Arc::new((Mutex::new(false), Condvar::new()));
let (tx, rx) = mpsc::channel();
let far = Arc::new(Fixed {
jobs: Mutex::new((0..6).map(|i| (Rank::new(Class::Far, i), "far")).collect()),
ran: tx.clone(),
hold: Some(gate.clone()),
});
let _far = pool.register(far);
// Two far jobs hold two workers; the third stays free for this one.
std::thread::sleep(Duration::from_millis(100));
let near = fixed(&[(Rank::new(Class::Near, 0), "near")], tx);
let near_reg = pool.register(near);
near_reg.notify();
assert_eq!(rx.recv_timeout(Duration::from_secs(10)).unwrap(), "near");
*gate.0.lock().unwrap() = true;
gate.1.notify_all();
assert_eq!(recv_n(&rx, 6), ["far"; 6]);
}
/// A source that counts its jobs, queued from outside while the workers run.
struct Counter {
left: Mutex<u64>,
done: AtomicUsize,
per_worker: PerWorker<u64>,
idle: AtomicBool,
all_idle: AtomicBool,
}
impl Source for Counter {
fn next(&self) -> Option<Rank> {
(*lock(&self.left) > 0).then_some(Rank::new(Class::Near, 0))
}
fn run(&self, worker: usize) -> bool {
{
let mut left = lock(&self.left);
if *left == 0 {
return false;
}
*left -= 1;
}
self.per_worker.with(worker, || 0, |n| *n += 1);
self.done.fetch_add(1, Ordering::Relaxed);
true
}
fn idle(&self, worker: usize) {
self.per_worker.clear(worker);
self.idle.store(true, Ordering::Relaxed);
}
fn all_idle(&self) {
self.all_idle.store(true, Ordering::Relaxed);
}
}
#[test]
fn no_job_is_missed_when_queued_while_workers_look() {
let pool = Pool::new(4, "test-job");
let counter = Arc::new(Counter {
left: Mutex::new(0),
done: AtomicUsize::new(0),
per_worker: PerWorker::new(pool.threads()),
idle: AtomicBool::new(false),
all_idle: AtomicBool::new(false),
});
let reg = pool.register(counter.clone());
for _ in 0..20_000 {
*lock(&counter.left) += 1;
reg.notify();
}
let deadline = Instant::now() + Duration::from_secs(20);
while counter.done.load(Ordering::Relaxed) < 20_000 {
assert!(Instant::now() < deadline, "jobs left behind");
std::thread::sleep(Duration::from_millis(1));
}
}
#[test]
fn idle_workers_free_their_state() {
let release = Duration::from_millis(300);
let pool = Pool::with_idle_release(2, "test-job", release);
let counter = Arc::new(Counter {
left: Mutex::new(10),
done: AtomicUsize::new(0),
per_worker: PerWorker::new(pool.threads()),
idle: AtomicBool::new(false),
all_idle: AtomicBool::new(false),
});
let reg = pool.register(counter.clone());
reg.notify_all();
let deadline = Instant::now() + release * 20;
while !counter.all_idle.load(Ordering::Relaxed) {
assert!(Instant::now() < deadline, "workers never released");
std::thread::sleep(Duration::from_millis(20));
}
assert!(counter.idle.load(Ordering::Relaxed));
assert_eq!(counter.done.load(Ordering::Relaxed), 10);
assert!(counter.per_worker.slots.iter().all(|s| lock(s).is_none()));
}
#[test]
fn dropping_a_registration_waits_for_its_running_job() {
let pool = Pool::new(2, "test-job");
let gate = Arc::new((Mutex::new(false), Condvar::new()));
let (tx, rx) = mpsc::channel();
let held = Arc::new(Fixed {
jobs: Mutex::new(
[
(Rank::new(Class::Near, 0), "held"),
(Rank::new(Class::Near, 1), "second"),
]
.into(),
),
ran: tx,
hold: Some(gate.clone()),
});
let reg = pool.register(held.clone());
std::thread::sleep(Duration::from_millis(100));
let opener = {
let gate = gate.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(200));
*gate.0.lock().unwrap() = true;
gate.1.notify_all();
})
};
let began = Instant::now();
drop(reg);
// Both jobs may have started (two workers); none runs after the drop.
assert!(began.elapsed() >= Duration::from_millis(150));
opener.join().unwrap();
let ran: Vec<_> = rx.try_iter().collect();
assert!(ran.contains(&"held"));
std::thread::sleep(Duration::from_millis(100));
assert!(rx.try_iter().next().is_none());
// The pool's own references are gone.
assert_eq!(Arc::strong_count(&held), 1);
}
#[test]
fn a_panicking_job_keeps_its_worker() {
struct Boom(AtomicUsize);
impl Source for Boom {
fn next(&self) -> Option<Rank> {
(self.0.load(Ordering::Relaxed) < 2).then_some(Rank::new(Class::Near, 0))
}
fn run(&self, _worker: usize) -> bool {
if self.0.fetch_add(1, Ordering::Relaxed) == 0 {
panic!("first job fails");
}
true
}
}
let pool = Pool::new(1, "test-job");
let boom = Arc::new(Boom(AtomicUsize::new(0)));
let reg = pool.register(boom.clone());
reg.notify();
let deadline = Instant::now() + Duration::from_secs(10);
while boom.0.load(Ordering::Relaxed) < 2 {
assert!(Instant::now() < deadline, "the worker died with the panic");
std::thread::sleep(Duration::from_millis(5));
}
}
}
+1
View File
@@ -4,6 +4,7 @@
/// The game window's boot screen, before wgpu's first frame paints over it.
#[cfg(target_os = "windows")]
pub mod boot_paint;
pub mod jobs;
pub mod memory_limit;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -79,6 +79,48 @@ pub struct BlockCollisionHit {
pub shape: VoxelShape,
}
#[derive(Clone, Copy, Debug)]
enum CellError {
InvalidBounds,
TooLarge,
}
/// The cursor a scan of `bounds` walks: its padded start, the box's extents in
/// cells and the cell count. `new` and [`scan_cells`] share it, so a caller
/// that sizes a budget from a box counts exactly what the scan will visit.
fn cells(bounds: Aabb) -> Result<(BlockPos, [i32; 3], usize), CellError> {
let min = bounds.min_position();
let max = bounds.max_position();
if !min.is_finite() || !max.is_finite() {
return Err(CellError::InvalidBounds);
}
let start = BlockPos::containing(Vec3::new(min.x - 1.0e-7, min.y - 1.0e-7, min.z - 1.0e-7))
.offset(-1, -1, -1);
let end = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
.offset(1, 1, 1);
let sizes = [
end.x.wrapping_sub(start.x).wrapping_add(1),
end.y.wrapping_sub(start.y).wrapping_add(1),
end.z.wrapping_sub(start.z).wrapping_add(1),
];
if sizes.iter().any(|size| *size <= 0) {
return Err(CellError::TooLarge);
}
let count = sizes
.into_iter()
.try_fold(1u64, |n, size| {
n.checked_mul(size as u64).filter(|n| *n <= i32::MAX as u64)
})
.ok_or(CellError::TooLarge)? as usize;
Ok((start, sizes, count))
}
/// The cells a scan of `bounds` visits, or `None` for a box `new` refuses
/// (`InvalidBounds`, `TooLarge`): the most a budget can usefully be.
pub fn scan_cells(bounds: Aabb) -> Option<usize> {
cells(bounds).ok().map(|(_, _, count)| count)
}
pub struct BlockCollisionScan<'w, 'c, W: BlockCollisionWorld> {
world: &'w mut W,
context: CollisionContext<'c>,
@@ -110,29 +152,10 @@ impl<'w, 'c, W: BlockCollisionWorld> BlockCollisionScan<'w, 'c, W> {
only_suffocating: bool,
candidate_budget: usize,
) -> Result<Self, CollisionScanError<W::Error>> {
let min = bounds.min_position();
let max = bounds.max_position();
if !min.is_finite() || !max.is_finite() {
return Err(CollisionScanError::InvalidBounds);
}
let start = BlockPos::containing(Vec3::new(min.x - 1.0e-7, min.y - 1.0e-7, min.z - 1.0e-7))
.offset(-1, -1, -1);
let end = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
.offset(1, 1, 1);
let sizes = [
end.x.wrapping_sub(start.x).wrapping_add(1),
end.y.wrapping_sub(start.y).wrapping_add(1),
end.z.wrapping_sub(start.z).wrapping_add(1),
];
if sizes.iter().any(|size| *size <= 0) {
return Err(CollisionScanError::TooLarge);
}
let count = sizes
.into_iter()
.try_fold(1u64, |n, size| {
n.checked_mul(size as u64).filter(|n| *n <= i32::MAX as u64)
})
.ok_or(CollisionScanError::TooLarge)? as usize;
let (start, sizes, count) = cells(bounds).map_err(|error| match error {
CellError::InvalidBounds => CollisionScanError::InvalidBounds,
CellError::TooLarge => CollisionScanError::TooLarge,
})?;
Ok(Self {
world,
context,
+2 -2
View File
@@ -73,7 +73,7 @@ pub fn native_loot_function(
ctx: &NativeLootContext<'_>,
stack: &mut LootStack,
) {
let _ = (kind, data, ctx, stack);
crate::native_load_hooks::loot_function(kind, data, ctx, stack);
}
/// A loot condition of a mod's own kind: the entry named a `type` in a
@@ -87,7 +87,7 @@ pub fn native_loot_condition(
ctx: &NativeLootContext<'_>,
holds: &mut bool,
) {
let _ = (kind, data, ctx, holds);
crate::native_load_hooks::loot_condition(kind, data, ctx, holds);
}
/// The tool a loot context names: its item id and enchantments.
@@ -1,4 +1,5 @@
//! Callbacks into the executable for data-loading native trait mods.
use crate::loot::{LootStack, NativeLootContext};
use serde_json::{Map, Value};
use std::sync::OnceLock;
@@ -6,6 +7,8 @@ use std::sync::OnceLock;
pub struct NativeLoadHooks {
pub loot_table_load: fn(&str, &mut Value),
pub modify_default_components: fn(&str, &mut Map<String, Value>),
pub loot_function: fn(&str, &Value, &NativeLootContext<'_>, &mut LootStack),
pub loot_condition: fn(&str, &Value, &NativeLootContext<'_>, &mut bool),
}
static HOOKS: OnceLock<NativeLoadHooks> = OnceLock::new();
@@ -26,3 +29,25 @@ pub(crate) fn modify_default_components(item: &str, components: &mut Map<String,
(hooks.modify_default_components)(item, components);
}
}
pub(crate) fn loot_function(
kind: &str,
data: &Value,
ctx: &NativeLootContext<'_>,
stack: &mut LootStack,
) {
if let Some(hooks) = HOOKS.get() {
(hooks.loot_function)(kind, data, ctx, stack);
}
}
pub(crate) fn loot_condition(
kind: &str,
data: &Value,
ctx: &NativeLootContext<'_>,
holds: &mut bool,
) {
if let Some(hooks) = HOOKS.get() {
(hooks.loot_condition)(kind, data, ctx, holds);
}
}
+19 -8
View File
@@ -1,7 +1,9 @@
//! Entity.collide collection order and step resolution over a collision world.
//! Travel, velocity, grounded flags and inside-block effects remain caller work.
use crate::{
block_collision_scan::{BlockCollisionScan, BlockCollisionWorld, CollisionScanError},
block_collision_scan::{
BlockCollisionScan, BlockCollisionWorld, CollisionScanError, scan_cells,
},
collision_context::CollisionContext,
entity_obstruction::EntityIdentity,
};
@@ -56,6 +58,19 @@ where
entity_collision_bounds(request.movement, request.bounds, request.max_step),
)
.map_err(CollisionScanError::World)?;
// Vanilla walks the whole box a movement expands to with no candidate
// counter, so a movement big enough to pass the caller's budget is slow
// there, never fatal (#6124: a legal movement_speed of 400 crossed more
// than a million cells on a diagonal and aborted the game). The budget
// only has to cover the cells both queries of a step can visit: the
// sweep, and the step's own box raised by max_step.
let sweep = request
.bounds
.expand_towards(request.movement)
.expand_towards(Vec3::new(0.0, f64::from(request.max_step.max(0.0)), 0.0));
let candidate_budget = scan_cells(sweep).map_or(request.candidate_budget, |cells| {
cells.max(request.candidate_budget)
});
try_resolve_step_movement(
request.movement,
request.bounds,
@@ -69,13 +84,9 @@ where
{
shapes.push(border);
}
for hit in BlockCollisionScan::new(
world,
request.context,
bounds,
false,
request.candidate_budget,
)? {
for hit in
BlockCollisionScan::new(world, request.context, bounds, false, candidate_budget)?
{
shapes.push(hit?.shape);
}
Ok(shapes)
@@ -29,11 +29,14 @@
//! overlap), so a chunk may be decorated as soon as no lower-ranked chunk
//! within two of it is still waiting: [`DecorationOrder::blocked`]. Chunks
//! further apart still decorate in parallel.
use std::collections::{HashMap, VecDeque};
use horizon_core::fast_hash::FxHashMap;
use std::collections::VecDeque;
#[derive(Default)]
pub struct DecorationOrder {
rank: HashMap<(i32, i32), u64>,
/// Looked up 24 times per chunk `blocked` is asked about, each time the
/// streamer schedules decoration: a fast hasher, not SipHash.
rank: FxHashMap<(i32, i32), u64>,
next: u64,
}
@@ -238,6 +241,7 @@ pub fn forced_order(x0: i32, z0: i32, x1: i32, z1: i32) -> Vec<(i32, i32)> {
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
/// Seed 8675309's geodes in chunks (-26, -1) and (14, -20) run into the
/// trial chambers of (-26, 0) and (14, -19); vanilla decorates the lower
@@ -18,12 +18,16 @@ fn load_hooks_are_noops_before_install_and_use_first_callbacks_after() {
modify_default_components: |item, components| {
components.insert("item".into(), Value::String(item.into()));
},
loot_function: |_, _, _, stack| stack.count += 1,
loot_condition: |kind, _, _, holds| *holds = kind == "mymod:held",
};
assert!(native_load_hooks::install(hooks()).is_ok());
assert!(
native_load_hooks::install(NativeLoadHooks {
loot_table_load: |_, table| *table = json!("second"),
modify_default_components: |_, components| components.clear(),
loot_function: |_, _, _, stack| stack.count = 0,
loot_condition: |_, _, _, holds| *holds = false,
})
.is_err()
);
@@ -31,4 +35,20 @@ fn load_hooks_are_noops_before_install_and_use_first_callbacks_after() {
item_defaults::native_modify_default_components("item", &mut components);
assert_eq!(table, json!(["table"]));
assert_eq!(components["item"], json!("item"));
let ctx = loot::NativeLootContext {
entity: None,
damage: None,
player: None,
tool: None,
block: None,
luck: 0.0,
biome: None,
seed: 0,
place: None,
};
for (kind, expected) in [("mymod:held", true), ("mymod:other", false)] {
let mut holds = !expected;
loot::native_loot_condition(kind, &json!({}), &ctx, &mut holds);
assert_eq!(holds, expected, "{kind}");
}
}
@@ -0,0 +1,131 @@
//! The candidate budget bounds a scan, never a movement: a movement big enough
//! to pass the caller's budget is walked in full, as vanilla's uncounted
//! collision walk does (#6124).
use horizon_core::{
geometry::{Aabb, Vec3, VoxelShape},
position::{BlockPos, ChunkPos},
};
use horizon_world::{
block_collision_scan::{BlockCollisionWorld, CollisionBlock, scan_cells},
collision_context::CollisionContext,
entity_obstruction::EntityIdentity,
registry::BlockStateId,
world_movement::{EntityMovementWorld, MovementRequest, resolve_world_movement},
};
use std::io;
/// Open air: every cell is reachable and has no shape, so a scan visits all of
/// them and the element count is the walk it performed.
struct OpenAir {
cells: usize,
}
impl BlockCollisionWorld for OpenAir {
type Error = io::Error;
type Chunk = ();
fn collision_chunk(&mut self, _: ChunkPos) -> io::Result<Option<()>> {
Ok(Some(()))
}
fn collision_block(&mut self, _: (), _: BlockPos) -> io::Result<CollisionBlock> {
self.cells += 1;
Ok(CollisionBlock {
state: BlockStateId(0),
large_collision_shape: false,
moving_piston: false,
empty_shape: true,
})
}
fn is_suffocating(&mut self, _: (), _: CollisionBlock, _: BlockPos) -> io::Result<bool> {
unreachable!()
}
fn context_shape(
&mut self,
_: CollisionBlock,
_: BlockPos,
_: &CollisionContext<'_>,
) -> io::Result<VoxelShape> {
unreachable!()
}
}
impl EntityMovementWorld for OpenAir {
type Error = io::Error;
fn entity_collisions(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Vec<VoxelShape>> {
Ok(Vec::new())
}
fn movement_border(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Option<VoxelShape>> {
Ok(None)
}
}
fn player() -> Aabb {
Aabb::new(Vec3::new(0.5, 64.0, 0.5), Vec3::new(1.1, 65.8, 1.1))
}
fn resolve(movement: Vec3, candidate_budget: usize) -> (Vec3, OpenAir) {
let mut world = OpenAir { cells: 0 };
let resolved = resolve_world_movement(
&mut world,
MovementRequest {
entity: EntityIdentity(0),
context: CollisionContext::empty(false),
bounds: player(),
movement,
max_step: 0.6,
on_ground: true,
candidate_budget,
},
)
.expect("a movement is never aborted by the candidate budget");
(resolved, world)
}
#[test]
fn a_diagonal_sweep_past_the_budget_is_walked_in_full() {
// 40 blocks each way pads to about 42 x 4 x 42 cells, far past the budget.
let movement = Vec3::new(40.0, 0.0, 40.0);
let sweep = scan_cells(player().expand_towards(movement)).expect("a scannable box");
assert!(sweep > 1_000, "the sweep is {sweep} cells");
let (resolved, world) = resolve(movement, 10);
assert_eq!(resolved, movement);
assert!(
world.cells > 0,
"the whole sweep was walked: {} cells",
world.cells
);
}
#[test]
fn scan_cells_covers_what_the_scan_visits() {
// The count includes the corners the cursor skips, so it is an upper bound
// on the walk that is never short of it by more than the eight corners.
for movement in [
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, -0.5, 2.0),
Vec3::new(40.0, 3.0, 40.0),
] {
let (_, world) = resolve(movement, 0);
let sweep = scan_cells(player().expand_towards(movement)).expect("a scannable box");
if movement.length_squared() == 0.0 {
// A zero movement is never scanned.
assert_eq!(world.cells, 0);
} else {
assert!(world.cells <= sweep, "movement {movement:?}");
assert!(sweep - world.cells <= 8, "movement {movement:?}");
}
}
}
#[test]
fn ordinary_movements_are_unchanged() {
// Resolved movement does not depend on the budget once it covers the sweep.
for movement in [
Vec3::new(0.2, -0.0784, 0.1),
Vec3::new(1.0, 0.0, -3.0),
Vec3::new(0.0, -0.5, 0.0),
] {
assert_eq!(
resolve(movement, 1_000_000).0,
resolve(movement, 1).0,
"movement {movement:?}"
);
}
}
+4
View File
@@ -88,6 +88,10 @@ confidence interval of the mean, Student's t, null when `n` is 1). The unit is t
| `tick.mspt`, `tick.tps` | integrated server tick time and rate (planned) |
| `memory.rss_peak_mb`, `memory.rss_steady_mb` | resident memory: peak over the run, median while flying |
| `memory.gpu_mb`, `memory.<phase>.gpu_mb` | video memory the game allocated (or NVIDIA attributes to it), where readable |
| `memory.<phase>.rss_mb` | resident memory, median over that phase's flight (perf_bench) |
| `cpu.<phase>.p50_ms` | median CPU time of a frame (the frame minus its waits for the GPU, the display and the limiter; Horizon only) |
| `tick.<phase>.client_ms` | mean time of one client tick during the phase (vanilla: `Minecraft.tick`; Horizon: the frames' tick phase over the ticks run) |
| `gpu_pass.<phase>.<pass>_ms` | GPU time of one of the game's render passes (`world`, `world_after_culling`, `occlusion_test`, `hud`...), the median of the last 15 timed frames in the 2 s before the flight, at its start; the flight itself is not timed per pass. Not on Metal, where passes overlap |
| `power.package_w`, `power.battery_drain_pct` | power draw, battery used per hour (laptops; planned) |
| `thermal.cpu_c`, `thermal.throttled_pct` | temperature, share of the run spent throttled (planned) |
| `launcher.start_ms` | launcher process start to its first frame (a session) |
+12
View File
@@ -57,6 +57,18 @@ python3 tools/perf_bench.py --table horizon-mine vanilla-mine mods-mine
Options: `--distances 8,16`, `--sizes 1920x1080`, `--scenes village,ocean`, `--seconds 20`, `--repeats 5`, `--heap 4G` (vanilla), `--offscreen` (Horizon, for locked or headless sessions). Results go to `benchmarks/perf/<label>-rd<N>.json` at 1280x720 and `<label>-1080p-rd<N>.json` at 1920x1080 (other sizes `<label>-<W>x<H>-rd<N>.json`; `--table` takes those labels), one [benchmark report](benchmark-report.md) per window size and render distance (the same format the game's `--benchmark` and the launcher write), with the load average before each game; each game's log, and the vanilla driver's event log, stay in `.local/perf-bench/`. Keep the machine otherwise idle, plugged in, and the window on a screen.
### Many-entity stress scenes
`--scenes stress` (or some of `stress_empty`, `stress_mobs`, `stress_items`, `stress_frames`, `stress_armor_stands`, `stress_chests`, `stress_signs`, `stress_banners`, `stress_particles`) runs the scenes of `tools/stress_scenes.py` instead of flying: 1000 still mobs of 8 kinds, 2000 dropped items, 512 item frames, 500 armoured armor stands, 800 chests and shulker boxes, a wall of 687 written signs, 300 banners, and steady particles from repeating command blocks (flames, smoke, end rods, firework bursts, TNT in the air). Each is built high above the terrain by the same vanilla commands in every game (Horizon through `horizon-ctl command`, vanilla and vanilla-mods through the driver mod), settles 6 seconds and is held still for `--seconds`; `stress_empty` is the same camera and floor without them. Entities are drawn only within a distance that grows with the render distance up to 20 (`Entity.shouldRenderAtSqrDistance`), so run them at 16 or more:
```sh
python3 tools/perf_bench.py --game horizon --scenes stress --distances 16 --label stress-horizon
python3 tools/perf_bench.py --game vanilla --scenes stress --distances 16 --label stress-vanilla
python3 tools/perf_bench.py --table stress-horizon stress-vanilla
```
Besides the FPS table, `--table` then gives each scene's median frame CPU time (Horizon), client tick time and resident memory. Chests stay closed: opening them needs a player, which neither driver has. The stress scenes use 26.3's command syntax, so they are not for `--game neoforge`.
On Windows, run `python tools\perf_bench.py ...` the same way. Horizon's control endpoint there is a localhost TCP port, which the game writes to a `.json` file in the temporary folder. `--build` can point at an installed release (`%APPDATA%\horizon-launcher\versions\vX.Y.Z`, with `--resources` its `.local\horizon-client\26.3`), and the report then gives that release's version.
## Frame pacing
+16 -1
View File
@@ -1802,7 +1802,7 @@ the same mod: for example, `set_block` fires `block_changed`. Mods keep mutable
state with interior mutability so these calls can share the instance safely. Data-load
hooks can run on any loading thread; item default changes must be identical on every side.
The trait has every hook carrying a `mod_api` entry in `native-hooks.json`.
The trait has every hook in `native-hooks.json` and the pending fragments ([Adding a hook](#adding-a-hook)).
Level hooks use access-specific hosts rather than `Host`. `LevelHost` provides
`block_state` through `LevelAccess::block_state`, client-side detection and
`state_with_property`. `LevelRandomHost` provides vanilla's `Level.random` so
@@ -1816,6 +1816,21 @@ renderer list is installed once alongside the mod list. `render_needs` collects
`world_drawn` observes the world pass and can encode drawing before the hand and HUD. Both
run on the render thread while the frame is built, before any deferred GPU submission.
The renderer module re-exports the game's wgpu types.
### Adding a hook
A new hook needs its fragment in `tools/native-hook-fragments/` with its `mod_api` entry (for example
`{"contract": "cancel", "out": "cancel"}`), its `native_*` function with the placeholder body
`let _ = (every, parameter);` (all its parameters, in order), and a run of `cargo run -p xtask -- mod-api`.
The generator adds the hook's trait method and replaces the placeholder with the `dispatch_<hook>` call.
Every hook needs a `mod_api` entry: the check fails naming any hook without one. A body that is neither the
placeholder nor already calls the dispatch is an error, and the generator never changes it. Hooks that are
free functions outside `Game` are wired by hand: those crates can't see the mod list, so each calls a callback
the executable installs once, such as `NativeLoadHooks` in `horizon-world` and `NativeClientHooks` in the
client library (`menu_slot_rules`, `living_breathe`, `ingredient`). `cargo run -p xtask -- mod-api --check`
and the `generated_output_is_current` test report a placeholder still to fill instead of filling it. Mixin
injections go at the head of the body, before the dispatch, so they still run first.
### Server attachments
A mod can declare an `AttachmentType<T>` once and call its `get`, `set` and `remove`
@@ -14,7 +14,10 @@ import java.util.ArrayList;
import java.util.List;
import net.fabricmc.api.ClientModInitializer;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.ConnectScreen;
import net.minecraft.client.gui.screens.TitleScreen;
import net.minecraft.client.multiplayer.ServerData;
import net.minecraft.client.multiplayer.resolver.ServerAddress;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.LevelSettings;
@@ -34,7 +37,10 @@ import net.minecraft.world.phys.Vec3;
*
* Script (system property horizon.bench.script): {"world", "seed", "settle",
* "seconds", "timeout", "scenes": [{"name", "from": [x,y,z], "to": [x,y,z],
* "pitch"}]}. Output: horizon.bench.out.
* "pitch", "settle"?, "setup"?: [command...], "cleanup"?: [command...]}]}: a scene's own settle replaces
* the script's, its setup commands (tools/stress_scenes.py) run once its chunks are loaded and its cleanup
* commands after its flight. "server" ("host:port") joins that server instead of creating a world (the
* player needs op there for /tp and /time). Output: horizon.bench.out.
*/
public final class VanillaBench implements ClientModInitializer {
private static VanillaBench bench;
@@ -52,6 +58,9 @@ public final class VanillaBench implements ClientModInitializer {
private int lastLoaded = -1;
private long lastChange;
private final List<Float> frames = new ArrayList<>();
/** Client tick durations (Minecraft.tick) while flying, in milliseconds. */
private final List<Float> ticks = new ArrayList<>();
private long tickStart;
/** Fabric's entrypoint instance; the benchmark itself is the static one. */
public VanillaBench() {
@@ -78,6 +87,17 @@ public final class VanillaBench implements ClientModInitializer {
bench.event("init");
}
/** Called around every client tick (Minecraft.tick). */
public static void tick(boolean start) {
if (bench == null) return;
long now = System.nanoTime();
if (start) {
bench.tickStart = now;
} else if (bench.phase == Phase.FLY && bench.tickStart != 0) {
bench.ticks.add((now - bench.tickStart) / 1e6f);
}
}
/** Called at the start of every frame (Minecraft.runTick). */
public static void frame(Minecraft mc) {
if (bench != null) bench.step(mc);
@@ -100,6 +120,13 @@ public final class VanillaBench implements ClientModInitializer {
case TITLE -> {
if (mc.gui.screen() instanceof TitleScreen && mc.gui.overlay() == null) {
event("title", renderer(mc));
if (script.has("server")) {
String address = script.get("server").getAsString();
enter(Phase.WORLD, now);
ConnectScreen.startConnecting(mc.gui.screen(), mc, ServerAddress.parseString(address),
new ServerData("Bench", address, ServerData.Type.OTHER), false, null);
return;
}
LevelSettings settings = new LevelSettings(script.get("world").getAsString(), GameType.CREATIVE,
LevelSettings.DifficultySettings.DEFAULT, true, WorldDataConfiguration.DEFAULT);
WorldOptions options = new WorldOptions(script.get("seed").getAsLong(), true, false);
@@ -139,13 +166,19 @@ public final class VanillaBench implements ClientModInitializer {
e.addProperty("load_ms", (lastChange - phaseStart) / 1e6);
e.addProperty("chunks", lastLoaded);
event("loaded", e);
if (s.has("setup")) {
for (var line : s.getAsJsonArray("setup")) mc.player.connection.sendCommand(line.getAsString());
}
enter(Phase.SETTLE, now);
}
}
case SETTLE -> {
place(mc, scenes.get(scene), 0);
if (elapsed >= script.get("settle").getAsDouble()) {
JsonObject s = scenes.get(scene);
place(mc, s, 0);
double settle = (s.has("settle") ? s : script).get("settle").getAsDouble();
if (elapsed >= settle) {
frames.clear();
ticks.clear();
event("fly", named(scenes.get(scene)));
enter(Phase.FLY, now);
}
@@ -159,8 +192,19 @@ public final class VanillaBench implements ClientModInitializer {
JsonArray times = new JsonArray();
frames.forEach(times::add);
e.add("frame_ms", times);
JsonArray tickTimes = new JsonArray();
ticks.forEach(tickTimes::add);
e.add("tick_ms", tickTimes);
JsonObject entities = new JsonObject();
entities.addProperty("all", mc.level.getEntityCount());
e.add("entities", entities);
e.addProperty("chunks", mc.level.getChunkSource().getLoadedChunksCount());
event("flown", e);
if (scenes.get(scene).has("cleanup")) {
for (var line : scenes.get(scene).getAsJsonArray("cleanup")) {
mc.player.connection.sendCommand(line.getAsString());
}
}
next(mc, now);
}
}
@@ -0,0 +1,23 @@
package dev.horizonlab.bench.mixin;
import dev.horizonlab.bench.VanillaBench;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Times the client tick (Minecraft.tick), for the stress scenes' client tick figure. */
@Mixin(Minecraft.class)
public abstract class TickHookMixin {
@Inject(method = "tick", at = @At("HEAD"))
private void horizonBenchTickStart(CallbackInfo callback) {
VanillaBench.tick(true);
}
@Inject(method = "tick", at = @At("RETURN"))
private void horizonBenchTickEnd(CallbackInfo callback) {
VanillaBench.tick(false);
}
}
@@ -3,7 +3,8 @@
"package": "dev.horizonlab.bench.mixin",
"compatibilityLevel": "JAVA_25",
"client": [
"FrameHookMixin"
"FrameHookMixin",
"TickHookMixin"
],
"injectors": {
"defaultRequire": 1
+449 -78
View File
@@ -156,6 +156,102 @@
"name": "set_block",
"target": "horizon_client::game::native_hooks::Game<'_>::native_set_block"
},
{
"name": "edit_blocks",
"target": "horizon_client::game::native_world_edit::Game<'_>::native_edit_blocks"
},
{
"name": "surface_heights",
"target": "horizon_client::game::native_world_columns::Game<'_>::native_surface_heights"
},
{
"name": "read_columns",
"target": "horizon_client::game::native_world_columns::Game<'_>::native_read_columns"
},
{
"name": "refresh_biome_colors",
"target": "horizon_client::game::native_biome_colors::Game<'_>::native_refresh_biome_colors"
},
{
"name": "neighbor_signal",
"target": "horizon_client::game::native_hooks::Game<'_>::native_neighbor_signal"
},
{
"name": "light",
"target": "horizon_client::game::native_hooks::Game<'_>::native_light"
},
{
"name": "weather",
"target": "horizon_client::game::native_hooks::Game<'_>::native_weather"
},
{
"name": "day_time",
"target": "horizon_client::game::native_hooks::Game<'_>::native_day_time"
},
{
"name": "world_height",
"target": "horizon_client::game::native_hooks::Game<'_>::native_world_height"
},
{
"name": "max_stack_size",
"target": "horizon_client::game::native_hooks::Game<'_>::native_max_stack_size"
},
{
"name": "item_model",
"target": "horizon_client::game::native_hooks::Game<'_>::native_item_model"
},
{
"name": "quad",
"target": "horizon_client::game::native_hooks::Game<'_>::native_quad"
},
{
"name": "mod_screen_open",
"target": "horizon_client::game::native_hooks::Game<'_>::native_mod_screen_open"
},
{
"name": "mod_screen_close",
"target": "horizon_client::game::native_hooks::Game<'_>::native_mod_screen_close"
},
{
"name": "mod_screen",
"target": "horizon_client::game::native_hooks::Game<'_>::native_mod_screen"
},
{
"name": "set_experience_orb",
"target": "horizon_client::game::native_server::Game<'_>::native_set_experience_orb"
},
{
"name": "experience_orbs",
"target": "horizon_client::game::native_server::Game<'_>::native_experience_orbs"
},
{
"name": "clock",
"target": "horizon_client::game::native_hooks::Game<'_>::native_clock"
},
{
"name": "spawn_falling_block",
"target": "horizon_client::game::native_falling::Game<'_>::native_spawn_falling_block"
},
{
"name": "block_state_property",
"target": "horizon_client::game::native_block_entity_draw::Game<'_>::native_block_state_property"
},
{
"name": "raycast",
"target": "horizon_client::game::native_claimed_items::Game<'_>::native_raycast"
},
{
"name": "view_hit",
"target": "horizon_client::game::native_claimed_items::Game<'_>::native_view_hit"
},
{
"name": "claim_items",
"target": "horizon_client::game::native_claimed_items::Game<'_>::native_claim_items"
},
{
"name": "poll",
"target": "horizon_client::game::native_claimed_items::Game<'_>::native_poll"
},
{
"name": "set_item_entity",
"target": "horizon_client::game::native_hooks::Game<'_>::native_set_item_entity"
@@ -1247,19 +1343,29 @@
{
"name": "camera_shake",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_camera_shake"
"target": "horizon_client::game::native_hooks::Game<'_>::native_camera_shake",
"mod_api": {
"contract": "listen"
}
},
{
"name": "shader_params",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_shader_params"
"target": "horizon_client::game::native_hooks::Game<'_>::native_shader_params",
"mod_api": {
"contract": "listen"
}
},
{
"name": "block_highlight",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_block_highlight",
"side": "client",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "render_hand",
@@ -1267,55 +1373,85 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_render_hand",
"neoforge": "RenderHandEvent",
"side": "client",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "client_resources_reloaded",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_client_resources_reloaded",
"side": "client"
"side": "client",
"mod_api": {
"contract": "listen"
}
},
{
"name": "item_decorations",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_item_decorations",
"neoforge": "RegisterItemDecorationsEvent",
"side": "client"
"side": "client",
"mod_api": {
"contract": "listen"
}
},
{
"name": "use_duration",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_use_duration"
"target": "horizon_client::game::native_hooks::Game<'_>::native_use_duration",
"mod_api": {
"contract": "listen"
}
},
{
"name": "use_animation",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_use_animation",
"side": "client"
"side": "client",
"mod_api": {
"contract": "listen"
}
},
{
"name": "can_eat",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_can_eat"
"target": "horizon_client::game::native_hooks::Game<'_>::native_can_eat",
"mod_api": {
"contract": "listen"
}
},
{
"name": "living_entity_use_item_start",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_living_entity_use_item_start",
"neoforge": "LivingEntityUseItemEvent.Start",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "living_entity_use_item_finish",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_living_entity_use_item_finish",
"neoforge": "LivingEntityUseItemEvent.Finish",
"side": "server"
"side": "server",
"mod_api": {
"contract": "listen"
}
},
{
"name": "screen_mouse_scrolled_pre",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_screen_mouse_scrolled_pre"
"target": "horizon_client::game::native_hooks::Game<'_>::native_screen_mouse_scrolled_pre",
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "player_command",
@@ -1323,45 +1459,74 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_command",
"neoforge": "CommandEvent",
"side": "server",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "perform_console_command",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_perform_console_command"
"target": "horizon_client::game::native_hooks::Game<'_>::native_perform_console_command",
"mod_api": {
"contract": "cancel",
"out": "ran"
}
},
{
"name": "screen_key_pressed_pre",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_screen_key_pressed_pre"
"target": "horizon_client::game::native_hooks::Game<'_>::native_screen_key_pressed_pre",
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "screen_char_typed_pre",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_screen_char_typed_pre"
"target": "horizon_client::game::native_hooks::Game<'_>::native_screen_char_typed_pre",
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "data_pack_files",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_data_pack_files"
"target": "horizon_client::game::native_hooks::Game<'_>::native_data_pack_files",
"mod_api": {
"contract": "collect",
"out": "files"
}
},
{
"name": "biome_colors",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_biome_colors",
"side": "client"
"side": "client",
"mod_api": {
"contract": "listen"
}
},
{
"name": "server_data_loaded",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_server_data_loaded"
"target": "horizon_client::game::native_hooks::Game<'_>::native_server_data_loaded",
"mod_api": {
"contract": "listen"
}
},
{
"name": "compute_fov",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_compute_fov",
"neoforge": "ViewportEvent.ComputeFov",
"side": "client"
"side": "client",
"mod_api": {
"contract": "listen"
}
},
{
"name": "mob_submit",
@@ -1369,21 +1534,31 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_mob_submit",
"neoforge": "RenderLivingEvent.Pre",
"side": "client",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "drawn"
}
},
{
"name": "camera_distance",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_camera_distance",
"neoforge": "CalculateDetachedCameraDistanceEvent",
"side": "client"
"side": "client",
"mod_api": {
"contract": "listen"
}
},
{
"name": "compute_fog_color",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_compute_fog_color",
"neoforge": "ViewportEvent.ComputeFogColor",
"side": "client"
"side": "client",
"mod_api": {
"contract": "listen"
}
},
{
"name": "mouse_button_pre",
@@ -1391,28 +1566,41 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_mouse_button_pre",
"neoforge": "InputEvent.MouseButton.Pre",
"side": "client",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "mouse_button_post",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_mouse_button_post",
"neoforge": "InputEvent.MouseButton.Post",
"side": "client"
"side": "client",
"mod_api": {
"contract": "listen"
}
},
{
"name": "player_respawned",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_respawned",
"neoforge": "PlayerEvent.PlayerRespawnEvent",
"side": "server"
"side": "server",
"mod_api": {
"contract": "listen"
}
},
{
"name": "render_fog",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_render_fog",
"neoforge": "ViewportEvent.RenderFog",
"side": "client"
"side": "client",
"mod_api": {
"contract": "listen"
}
},
{
"name": "screen_key_released_pre",
@@ -1420,41 +1608,62 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_screen_key_released_pre",
"neoforge": "ScreenEvent.KeyReleased.Pre",
"side": "client",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "music",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_music",
"neoforge": "SelectMusicEvent",
"side": "client"
"side": "client",
"mod_api": {
"contract": "listen"
}
},
{
"name": "music_layers",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_music_layers",
"side": "client"
"side": "client",
"mod_api": {
"contract": "collect",
"out": "layers"
}
},
{
"name": "datapack_sync",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_datapack_sync",
"neoforge": "OnDatapackSyncEvent",
"side": "server"
"side": "server",
"mod_api": {
"contract": "collect",
"out": "recipe_types"
}
},
{
"name": "recipes_received",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_recipes_received",
"neoforge": "RecipesReceivedEvent",
"side": "client"
"side": "client",
"mod_api": {
"contract": "listen"
}
},
{
"name": "input_key",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_input_key",
"neoforge": "InputEvent.Key",
"side": "client"
"side": "client",
"mod_api": {
"contract": "listen"
}
},
{
"name": "mouse_scrolling",
@@ -1462,7 +1671,11 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_mouse_scrolling",
"neoforge": "InputEvent.MouseScrollingEvent",
"side": "client",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "screen_mouse_dragged_pre",
@@ -1470,72 +1683,115 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_screen_mouse_dragged_pre",
"neoforge": "ScreenEvent.MouseDragged.Pre",
"side": "client",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "mod_container_viewers",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_mod_container_viewers",
"side": "server"
"side": "server",
"mod_api": {
"contract": "listen"
}
},
{
"name": "living_knockback",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_living_knockback"
"target": "horizon_client::game::native_hooks::Game<'_>::native_living_knockback",
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "experience_orb_can_merge",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_experience_orb_can_merge",
"side": "server"
"side": "server",
"mod_api": {
"contract": "listen"
}
},
{
"name": "experience_orb_merge",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_experience_orb_merge",
"side": "server",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "menu_slot_rules",
"version": 1,
"target": "horizon_client::menu::native_menu_slot_rules",
"side": "server",
"cancellable": false
"cancellable": false,
"mod_api": {
"contract": "listen"
}
},
{
"name": "living_entity_use_item_tick",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_living_entity_use_item_tick",
"neoforge": "LivingEntityUseItemEvent.Tick",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "on_use_tick",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_on_use_tick"
"target": "horizon_client::game::native_hooks::Game<'_>::native_on_use_tick",
"mod_api": {
"contract": "listen"
}
},
{
"name": "living_entity_use_item_stop",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_living_entity_use_item_stop",
"neoforge": "LivingEntityUseItemEvent.Stop",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "release_using",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_release_using"
"target": "horizon_client::game::native_hooks::Game<'_>::native_release_using",
"mod_api": {
"contract": "listen"
}
},
{
"name": "loot_function",
"version": 1,
"target": "horizon_world::loot::native_loot_function"
"target": "horizon_world::loot::native_loot_function",
"mod_api": {
"contract": "collect",
"out": "stack"
}
},
{
"name": "loot_condition",
"version": 1,
"target": "horizon_world::loot::native_loot_condition"
"target": "horizon_world::loot::native_loot_condition",
"mod_api": {
"contract": "collect",
"out": "holds"
}
},
{
"name": "level_load",
@@ -1543,7 +1799,10 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_level_load",
"neoforge": "LevelEvent.Load",
"side": "server",
"cancellable": false
"cancellable": false,
"mod_api": {
"contract": "listen"
}
},
{
"name": "level_unload",
@@ -1551,7 +1810,10 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_level_unload",
"neoforge": "LevelEvent.Unload",
"side": "server",
"cancellable": false
"cancellable": false,
"mod_api": {
"contract": "listen"
}
},
{
"name": "client_level_load",
@@ -1559,7 +1821,10 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_client_level_load",
"neoforge": "LevelEvent.Load",
"side": "client",
"cancellable": false
"cancellable": false,
"mod_api": {
"contract": "listen"
}
},
{
"name": "client_level_unload",
@@ -1567,13 +1832,20 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_client_level_unload",
"neoforge": "LevelEvent.Unload",
"side": "client",
"cancellable": false
"cancellable": false,
"mod_api": {
"contract": "listen"
}
},
{
"name": "hears_projectile_impacts",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_hears_projectile_impacts",
"side": "server"
"side": "server",
"mod_api": {
"contract": "collect",
"out": "hears"
}
},
{
"name": "projectile_impact",
@@ -1581,7 +1853,11 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_projectile_impact",
"neoforge": "ProjectileImpactEvent",
"side": "server",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "entity_mount",
@@ -1589,7 +1865,11 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_entity_mount",
"neoforge": "EntityMountEvent",
"side": "server",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "interaction_key",
@@ -1597,7 +1877,11 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_interaction_key",
"neoforge": "InputEvent.InteractionKeyMappingTriggered",
"side": "client",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "gui_layer_pre",
@@ -1605,7 +1889,11 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_gui_layer_pre",
"neoforge": "RenderGuiLayerEvent.Pre",
"side": "client",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "gui_layer_post",
@@ -1613,7 +1901,10 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_gui_layer_post",
"neoforge": "RenderGuiLayerEvent.Post",
"side": "client",
"cancellable": false
"cancellable": false,
"mod_api": {
"contract": "listen"
}
},
{
"name": "map_decorations",
@@ -1621,7 +1912,11 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_map_decorations",
"neoforge": "MapDecorationType",
"side": "client",
"cancellable": false
"cancellable": false,
"mod_api": {
"contract": "collect",
"out": "decorations"
}
},
{
"name": "player_start_tracking",
@@ -1629,7 +1924,10 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_start_tracking",
"neoforge": "PlayerEvent.StartTracking",
"side": "server",
"cancellable": false
"cancellable": false,
"mod_api": {
"contract": "listen"
}
},
{
"name": "player_stop_tracking",
@@ -1637,21 +1935,32 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_stop_tracking",
"neoforge": "PlayerEvent.StopTracking",
"side": "server",
"cancellable": false
"cancellable": false,
"mod_api": {
"contract": "listen"
}
},
{
"name": "block_entity_energy",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_block_entity_energy",
"neoforge": "Capabilities.Energy.BLOCK",
"side": "server"
"side": "server",
"mod_api": {
"contract": "replace",
"out": "energy"
}
},
{
"name": "data_map_types",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_data_map_types",
"neoforge": "RegisterDataMapTypesEvent",
"side": "server"
"side": "server",
"mod_api": {
"contract": "collect",
"out": "types"
}
},
{
"name": "living_breathe",
@@ -1659,19 +1968,30 @@
"target": "horizon_client::breathing::native_living_breathe",
"neoforge": "LivingBreatheEvent",
"side": "server",
"cancellable": false
"cancellable": false,
"mod_api": {
"contract": "listen"
}
},
{
"name": "blocks",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_blocks",
"neoforge": "RegisterEvent"
"neoforge": "RegisterEvent",
"mod_api": {
"contract": "collect",
"out": "blocks"
}
},
{
"name": "items",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_items",
"neoforge": "RegisterEvent"
"neoforge": "RegisterEvent",
"mod_api": {
"contract": "collect",
"out": "items"
}
},
{
"name": "entity_tick_pre",
@@ -1679,7 +1999,11 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_entity_tick_pre",
"neoforge": "EntityTickEvent.Pre",
"side": "server",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "entity_tick_post",
@@ -1687,20 +2011,31 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_entity_tick_post",
"neoforge": "EntityTickEvent.Post",
"side": "server",
"cancellable": false
"cancellable": false,
"mod_api": {
"contract": "listen"
}
},
{
"name": "entity_tick_listened",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_entity_tick_listened",
"side": "server",
"cancellable": false
"cancellable": false,
"mod_api": {
"contract": "collect",
"out": "listened"
}
},
{
"name": "item_fluid_tank",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_item_fluid_tank",
"neoforge": "Capabilities.FluidHandler.ITEM"
"neoforge": "Capabilities.FluidHandler.ITEM",
"mod_api": {
"contract": "replace",
"out": "capacity"
}
},
{
"name": "living_equipment_change",
@@ -1708,14 +2043,21 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_living_equipment_change",
"neoforge": "LivingEquipmentChangeEvent",
"side": "server",
"cancellable": false
"cancellable": false,
"mod_api": {
"contract": "listen"
}
},
{
"name": "living_equipment_listened",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_living_equipment_listened",
"side": "server",
"cancellable": false
"cancellable": false,
"mod_api": {
"contract": "collect",
"out": "listened"
}
},
{
"name": "player_submit",
@@ -1723,7 +2065,11 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_submit",
"neoforge": "RenderPlayerEvent.Pre",
"side": "client",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "drawn"
}
},
{
"name": "mob_layer",
@@ -1731,14 +2077,21 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_mob_layer",
"neoforge": "EntityRenderersEvent.AddLayers",
"side": "client",
"cancellable": false
"cancellable": false,
"mod_api": {
"contract": "listen"
}
},
{
"name": "player_render_listened",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_render_listened",
"side": "client",
"cancellable": false
"cancellable": false,
"mod_api": {
"contract": "collect",
"out": "listened"
}
},
{
"name": "sign_changed",
@@ -1746,14 +2099,22 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_sign_changed",
"neoforge": "none (NeoForge has no sign-editing event)",
"side": "server",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
},
{
"name": "standalone_models",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_standalone_models",
"neoforge": "ModelEvent.RegisterStandalone",
"side": "client"
"side": "client",
"mod_api": {
"contract": "collect",
"out": "models"
}
},
{
"name": "player_container_open",
@@ -1761,7 +2122,10 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_container_open",
"neoforge": "PlayerContainerEvent.Open",
"side": "server",
"cancellable": false
"cancellable": false,
"mod_api": {
"contract": "listen"
}
},
{
"name": "player_container_close",
@@ -1769,7 +2133,10 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_container_close",
"neoforge": "PlayerContainerEvent.Close",
"side": "server",
"cancellable": false
"cancellable": false,
"mod_api": {
"contract": "listen"
}
},
{
"name": "player_toggle_flight",
@@ -1777,7 +2144,11 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_toggle_flight",
"neoforge": "none (NeoForge has no flight toggle event; Bukkit's PlayerToggleFlightEvent)",
"side": "server",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
]
}
+67
View File
@@ -236,6 +236,10 @@ class PortabilityTests(unittest.TestCase):
self.assertEqual(r["metrics"]["fps.village.avg_fps"]["mean"], 200.0)
self.assertEqual(pb.report(args, 8, [dict(run, load_before=1.25)], {})["conditions"]["load_avg_1m"], 1.25)
self.assertTrue(r["hardware"]["os"] and r["hardware"]["cpu_threads"])
self.assertNotIn("server", r["conditions"])
# A run on a dedicated server only compares with runs on the same kind of server.
on_server = argparse.Namespace(offscreen=False, settle=2.0, label="x", server="vanilla")
self.assertEqual(pb.report(on_server, 8, [run], {})["conditions"]["server"], "vanilla")
def test_nvidia_memory_not_available(self):
from unittest import mock
@@ -328,3 +332,66 @@ class PortabilityTests(unittest.TestCase):
"-Djava.library.path=${natives_directory}/java", "-cp", "${classpath}"]}}
self.assertEqual(manifest_jvm_flags(manifest), ["-XX:StackShadowPages=32"])
self.assertEqual(manifest_jvm_flags({}), [])
class StressSceneTests(unittest.TestCase):
def test_stress_selects_every_stress_scene_after_the_flights(self):
import argparse
from tools import stress_scenes
from tools.perf_bench import scene_list
names = [s[0] for s in scene_list(argparse.Namespace(scenes="ocean,stress"))]
self.assertEqual(names, ["ocean"] + stress_scenes.NAMES)
self.assertEqual([s[0] for s in scene_list(argparse.Namespace(scenes="stress_mobs"))], ["stress_mobs"])
def test_scenes_have_their_entity_counts(self):
from tools import stress_scenes
summons = lambda name, kind: sum(c.startswith(f"summon {kind} ") for c in stress_scenes.setup(name))
self.assertEqual(sum(summons("stress_mobs", k) for k in stress_scenes.MOBS), 1000)
self.assertEqual(summons("stress_items", "item"), 2000)
self.assertEqual(summons("stress_frames", "item_frame"), 512)
self.assertEqual(summons("stress_armor_stands", "armor_stand"), 500)
self.assertEqual(stress_scenes.setup("stress_empty"), stress_scenes.SETTINGS + stress_scenes.floor(
*stress_scenes.origin("stress_empty")))
# Only the run's first stress scene sets the game rules.
self.assertEqual(stress_scenes.setup("stress_empty", first=False),
stress_scenes.floor(*stress_scenes.origin("stress_empty")))
def test_every_entity_is_near_enough_to_be_drawn_at_rd16(self):
# Entity.shouldRenderAtSqrDistance with viewScale 2: a dropped item (0.25) within 32 blocks.
from tools import stress_scenes
for name in ("stress_mobs", "stress_items", "stress_frames", "stress_armor_stands"):
_, at, _, _ = stress_scenes.scene(name)
eye = (at[0], at[1] + 1.62, at[2])
for line in stress_scenes.setup(name):
if line.startswith("summon "):
x, y, z = map(float, line.split()[2:5])
distance = sum((a - b) ** 2 for a, b in zip((x, y, z), eye)) ** 0.5
self.assertLess(distance, 32.0, (name, line))
self.assertGreater(z, eye[2], (name, line))
def test_commands_are_single_lines_inside_the_world(self):
from tools import stress_scenes
for name in stress_scenes.NAMES:
for line in stress_scenes.setup(name):
self.assertNotIn("\n", line)
self.assertLess(len(line), 1024)
self.assertTrue(all(len(c) < 1024 for c in stress_scenes.cleanup(name)))
def test_cleanup_covers_every_block_a_scene_places(self):
from tools import stress_scenes
slabs = [list(map(int, c.split()[1:7])) for c in stress_scenes.cleanup("stress_mobs") if c.startswith("fill")]
for slab in slabs:
dx, dy, dz = (abs(slab[i + 3] - slab[i]) + 1 for i in range(3))
self.assertLessEqual(dx * dy * dz, 32768) # vanilla's fill limit
covered = lambda x, y, z: any(s[0] <= x <= s[3] and s[1] <= y <= s[4] and s[2] <= z <= s[5] for s in slabs)
for name in stress_scenes.NAMES:
for line in stress_scenes.setup(name):
words = line.split()
if words[0] == "fill":
a, b = list(map(int, words[1:4])), list(map(int, words[4:7]))
for x in (a[0], b[0]):
for y in (a[1], b[1]):
for z in (a[2], b[2]):
self.assertTrue(covered(x, y, z), (name, line))
elif words[0] == "setblock":
self.assertTrue(covered(*map(int, words[1:4])), (name, line))
+9 -1
View File
@@ -32,9 +32,17 @@ impl Potions { fn remote_thrown_motion(&mut self, _id: u32, _v: Vec3) -> bool {
#[derive(Default)]
struct End;
impl End { fn remote_eye_motion(&mut self, _id: u32, _v: [f64; 3]) {} }
// game/mob_index.rs: the same answers as a linear search.
#[derive(Default)]
struct MobIndex;
impl MobIndex {
fn get_mut<'a>(&mut self, mobs: &'a mut [Entity], id: u32) -> Option<&'a mut Entity> {
mobs.iter_mut().find(|m| m.id == id)
}
}
#[derive(Default)]
struct Game {
mobs: Vec<Entity>, dropped: Vec<Entity>, falling: Vec<Entity>, tnts: Vec<Entity>, orbs: Vec<Entity>,
mobs: Vec<Entity>, mob_index: MobIndex, dropped: Vec<Entity>, falling: Vec<Entity>, tnts: Vec<Entity>, orbs: Vec<Entity>,
creatures: Creatures, dragon_fireballs: Vec<Entity>, tridents: Vec<Entity>,
rockets: Vec<Entity>, remote: Option<Remote>,
potions: Potions, end: End,
@@ -4,7 +4,10 @@
"name": "difficulty_changed",
"target": "horizon_client::game::native_hooks::Game<'_>::native_difficulty_changed",
"neoforge": "DifficultyChangeEvent",
"side": "server"
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "the server's difficulty changing (`native_difficulty_changed`).",
@@ -3,18 +3,29 @@
{
"name": "experience_orb_pickup_listened",
"target": "horizon_client::game::native_hooks::Game<'_>::native_experience_orb_pickup_listened",
"side": "server"
"side": "server",
"mod_api": {
"contract": "collect",
"out": "listened"
}
},
{
"name": "experience_orb_pickup",
"target": "horizon_client::game::native_hooks::Game<'_>::native_experience_orb_pickup",
"side": "server",
"cancellable": true
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
},
{
"name": "mending_repair",
"target": "horizon_client::game::native_hooks::Game<'_>::native_mending_repair",
"side": "server"
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "a server player taking an experience orb (`native_experience_orb_pickup`, asked for through\n`native_experience_orb_pickup_listened`: cancel it, or change the orb's value and count) and the Mending repair\nproposed for the item it picked (`native_mending_repair`).",
+10 -2
View File
@@ -2,11 +2,19 @@
"hooks": [
{
"name": "fire_odds",
"target": "horizon_client::game::native_hooks::GameLevel<'_>::native_fire_odds"
"target": "horizon_client::game::native_hooks::GameLevel<'_>::native_fire_odds",
"mod_api": {
"contract": "replace",
"out": "odds"
}
},
{
"name": "smoke_source",
"target": "horizon_client::game::native_hooks::GameLevel<'_>::native_smoke_source"
"target": "horizon_client::game::native_hooks::GameLevel<'_>::native_smoke_source",
"mod_api": {
"contract": "replace",
"out": "smoke"
}
}
],
"rust_summary": "how fire burns a mod block (`native_fire_odds`, `FireBlock.setFlammable`), and a mod block under\na campfire that makes it a signal fire (`native_smoke_source`, `CampfireBlock.isSmokeSource`).",
@@ -5,7 +5,8 @@
"target": "horizon_client::game::native_hooks::Game<'_>::native_framerate_limit",
"neoforge": "none (vanilla FramerateLimitTracker.getFramerateLimit fires no event)",
"side": "client",
"cancellable": false
"cancellable": false,
"mod_api": {"contract": "listen"}
}
],
"rust_summary": "lowering the frame rate limit, as Dynamic FPS does (`native_framerate_limit`).",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"hooks": [{"name": "ingredient", "target": "horizon_client_lib::crafting::native_ingredient"}],
"hooks": [{"name": "ingredient", "target": "horizon_client_lib::crafting::native_ingredient", "mod_api": {"contract": "collect", "out": "items"}}],
"rust_summary": "a recipe ingredient of a mod's own kind\n(`native_ingredient`, which the unknown ingredient type in a namespace of\nown reaches with the kind, its JSON and the item list to fill).",
"doc_summary": "a recipe ingredient of a mod's own kind. The ingredient entry that names a type in a namespace outside minecraft: reaches native_ingredient(kind, data, items) at load time. Fill items with the item ids the ingredient matches (item ids only: components are not checked; a match-time hook follows separately). Duplicates are removed keeping first-seen order. An unclaimed kind leaves the list empty and the recipe fails. The hook may be called more than once per recipe; the answer must be the same. Bundles that use it need \"hooks\": @API:ingredient@.",
"comments": ["crates/horizon-client/src/crafting.rs"]
@@ -4,7 +4,10 @@
"name": "item_crafted",
"target": "horizon_client::game::native_hooks::Game<'_>::native_item_crafted",
"neoforge": "PlayerEvent.ItemCraftedEvent",
"side": "server"
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "a player taking a crafting grid's result (`native_item_crafted`).",
@@ -4,7 +4,11 @@
"name": "item_damaged",
"target": "horizon_client::game::native_hooks::Game<'_>::native_item_damaged",
"neoforge": "none (vanilla: ItemStack.hurtAndBreak; PlayerDestroyItemEvent is only the break)",
"side": "server"
"side": "server",
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
],
"rust_summary": "a player's item losing durability, changeable and cancellable (`native_item_damaged`).",
@@ -4,7 +4,10 @@
"name": "player_changed_dimension",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_changed_dimension",
"neoforge": "PlayerEvent.PlayerChangedDimensionEvent",
"side": "server"
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "a player changing dimension (`native_player_changed_dimension`).",
@@ -4,7 +4,11 @@
"name": "player_fish",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_fish",
"neoforge": "none (NeoForge's ItemFishedEvent fires after the loot roll, in FishingHook.retrieve; Bukkit's PlayerFishEvent)",
"side": "server"
"side": "server",
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
],
"rust_summary": "a player's fishing rod: cast, bite, catch, reel-in, cancellable (`native_player_fish`).",
@@ -2,7 +2,11 @@
"hooks": [
{
"name": "player_game_mode_changed",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_game_mode_changed"
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_game_mode_changed",
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
],
"rust_summary": "a server player's game mode about to change, cancellable (`native_player_game_mode_changed`).",
+236 -19
View File
@@ -30,6 +30,11 @@ Games:
(mods/horizon/src/neoforge-bench) + the jars of --mod-jars and
--deps, the user's own; 26.3 options it does not know are ignored
Many-entity stress scenes (tools/stress_scenes.py: mobs, dropped items, item frames, armor stands,
containers, signs, banners, particles), built by the same vanilla commands in every game and held still:
python3 tools/perf_bench.py --game horizon --scenes stress --distances 16 --label stress-horizon
--port-work loads the mods a horizon-port-pack work folder carried over into
Horizon as dev mods.
@@ -38,6 +43,8 @@ distance (docs/benchmark-report.md), over --repeats games. Games run with no
audio, from a scratch directory under .local/perf-bench.
"""
import argparse
import contextlib
import hashlib
import bisect
import datetime
import json
@@ -53,14 +60,16 @@ import sys
import tempfile
import threading
import time
import uuid
import zipfile
from pathlib import Path
try:
from . import lab, runtime
from . import lab, runtime, stress_scenes
except ImportError:
import lab
import runtime
import stress_scenes
ROOT = lab.ROOT
WORK = ROOT / ".local/perf-bench"
@@ -317,8 +326,36 @@ def size_label(label, width, height):
def scene_list(args):
"""(name, from, to, pitch) of the scenes asked for: the flights, then the stress scenes ("stress" is all of them)."""
picked = args.scenes.split(",") if args.scenes else [s[0] for s in SCENES]
return [s for s in SCENES if s[0] in picked]
if "stress" in picked:
picked += stress_scenes.NAMES
# --server-bots adds the crowd scene over the bots.
return ([s for s in SCENES if s[0] in picked] + list(getattr(args, "extra_scenes", None) or [])
+ [stress_scenes.scene(n) for n in stress_scenes.NAMES if n in picked])
# Seconds a stress scene settles after its setup: the server spawns, the client receives and draws it.
STRESS_SETTLE = 6.0
def first_stress(args):
"""The first stress scene of the run, which sets the game rules for all of them."""
return next((s[0] for s in scene_list(args) if s[0] in stress_scenes.NAMES), None)
def settle_seconds(args, name):
return max(args.settle, STRESS_SETTLE) if name in stress_scenes.NAMES else args.settle
def scene_rss(sampler, windows):
"""Median resident memory (MB) inside each named window: {name: MB}."""
out = {}
for name, (a, b) in windows.items():
during = [rss for t, rss in sampler.samples if a <= t <= b]
if during:
out[name] = statistics.median(during) / 2**20
return out
def yaw(start, end):
@@ -451,6 +488,103 @@ def wait_loaded(control, near, limit, min_chunks=0):
raise TimeoutError(f"not loaded after {limit} s")
BENCH_PLAYER = "Bench"
class LocalServer:
"""--server: a local dedicated server every client of the run joins instead of creating a world. Seed SEED,
creative, offline, view distance = the run's render distance, BENCH_PLAYER an operator (the scenes use /tp
and /time). Its world stays in WORK/server-<kind>-rd<d> between runs; one that is new gets an unmeasured
warm-up join first, so every measured join reads terrain already generated: the clients are compared, not
the server's generation."""
def __init__(self, args, distance):
self.kind, self.dir = args.server, WORK / f"server-{args.server}-rd{distance}"
self.fresh = not (self.dir / "world").exists()
self.dir.mkdir(parents=True, exist_ok=True)
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
self.port = probe.getsockname()[1]
props = {"level-seed": SEED, "online-mode": "false", "server-ip": "127.0.0.1", "server-port": self.port,
"view-distance": distance, "simulation-distance": 12, "gamemode": "creative",
"force-gamemode": "true", "spawn-protection": 0, "allow-flight": "true", "max-players": 300,
"white-list": "false", "level-name": "world"}
(self.dir / "server.properties").write_text("".join(f"{k}={v}\n" for k, v in props.items()))
(self.dir / "eula.txt").write_text("eula=true\n")
# UUIDUtil.createOfflinePlayerUUID: a name-based (version 3) UUID of "OfflinePlayer:<name>".
offline = uuid.UUID(bytes=hashlib.md5(f"OfflinePlayer:{BENCH_PLAYER}".encode()).digest(), version=3)
(self.dir / "ops.json").write_text(json.dumps(
[{"uuid": str(offline), "name": BENCH_PLAYER, "level": 4, "bypassesPlayerLimit": False}]))
if self.kind == "horizon":
binary = Path(args.server_build).resolve() / ("horizon-client.exe" if sys.platform == "win32" else "horizon-client")
self.command = [str(binary), "--resources", str(Path(args.resources).resolve()), "--dedicated", "--nogui"]
else:
java = Path(os.environ["JAVA_HOME"]) / "bin/java" if os.environ.get("JAVA_HOME") else "java"
self.command = [str(java), "-Xmx4G", "-jar", str(VANILLA / "jars/server.jar"), "nogui"]
@property
def address(self):
return f"127.0.0.1:{self.port}"
def __enter__(self):
self.log_path = self.dir / "server.log"
self.log = self.log_path.open("w")
env = lab.game_env(HORIZON_TELEMETRY_URL="off") if self.kind == "horizon" else None
self.proc = subprocess.Popen(self.command, cwd=self.dir, env=env, stdin=subprocess.PIPE, text=True,
stdout=self.log, stderr=subprocess.STDOUT)
deadline = time.time() + 600
while "Done (" not in self.log_path.read_text(errors="replace"):
if self.proc.poll() is not None or time.time() > deadline:
self.__exit__()
raise RuntimeError(f"the {self.kind} server did not start; see {self.log_path}")
time.sleep(0.5)
return self
def crowd(self, args):
"""--server-bots: horizon-bots walking around the spawn for the whole run (they need a few seconds to join
and settle), and the scene that flies over them: (name, from, to, pitch)."""
binary = Path(args.bots_build or args.build).resolve() / ("horizon-bots.exe" if sys.platform == "win32" else "horizon-bots")
self.bots = subprocess.Popen([str(binary), "--server", self.address, "--bots", str(args.server_bots),
"--behaviour", "walk", "--view-distance", "10", "--join-every", "0.1",
"--seconds", "100000", "--seed", str(SEED)],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
deadline = time.time() + 120
for line in self.bots.stdout:
if '"spawn"' in line:
sx, sy, sz = json.loads(line)["spawn"]
break
if time.time() > deadline:
raise RuntimeError("horizon-bots: no spawn position")
else:
raise RuntimeError("horizon-bots ended before a bot spawned")
threading.Thread(target=self.bots.stdout.read, daemon=True).start()
time.sleep(args.server_bots * 0.1 + 10)
# Bots fly 24 blocks above the spawn, 8 to 48 blocks out (horizon-bots' walk).
return ("crowd", (sx - 32.0, sy + 40.0, sz), (sx + 32.0, sy + 40.0, sz), 50.0)
def __exit__(self, *_):
if getattr(self, "bots", None):
stop(self.bots)
try:
self.proc.stdin.write("stop\n")
self.proc.stdin.flush()
self.proc.wait(timeout=120)
except (OSError, subprocess.TimeoutExpired, ValueError):
pass
stop(self.proc)
self.log.close()
def scene_commands(args, control, start, pitch):
"""Time and teleport for a scene: the game's own commands, or the server's when it joined one."""
if args.server:
control("command time set 6000")
control("command tp @s {} {} {} 0 {}".format(*start, pitch))
else:
control("time 6000")
control("tp {} {} {}".format(*start))
def run_horizon(args, distance):
game_dir = WORK / "horizon"
shutil.rmtree(game_dir, ignore_errors=True)
@@ -490,17 +624,23 @@ def run_horizon(args, distance):
sampler.start()
control = Control(sock)
result = {"options_txt": options, "scenes": {}}
windows = []
windows, scene_windows = [], {}
try:
title, at = poll(control, lambda s: s.get("screen") == "title" and s.get("renderer"), 300)
result["renderer"] = title["renderer"]
result["startup"] = {"title_s": at - started}
control(f"open {WORLD} {SEED} creative")
result["startup"]["world_s"] = wait_loaded(control, lambda p: True, args.timeout, view_chunks(distance)) - started
if args.server:
joined = time.time()
control(f"connect {args.server_address} {BENCH_PLAYER}")
else:
control(f"open {WORLD} {SEED} creative")
loaded = wait_loaded(control, lambda p: True, args.timeout, view_chunks(distance))
result["startup"]["world_s"] = loaded - started
if args.server:
result["startup"]["join_s"] = loaded - joined
for name, start, end, pitch in scene_list(args):
control("time 6000")
meshed = control("status")["meshed_sections"]
control("tp {} {} {}".format(*start))
scene_commands(args, control, start, pitch)
tp = time.time()
# A zero-length flight holds the player at the start, flying.
control("fly {0} {1} {2} {0} {1} {2} 0.1 {3}".format(*start, pitch))
@@ -510,13 +650,27 @@ def run_horizon(args, distance):
# Section meshes built while loading and while flying (Horizon only).
loaded_meshes = control("status")["meshed_sections"]
scene["meshes_load"] = loaded_meshes - meshed
time.sleep(args.settle)
loaded_meshes = control("status")["meshed_sections"]
if name in stress_scenes.NAMES:
for line in stress_scenes.setup(name, first=name == first_stress(args)):
control("command " + line)
time.sleep(settle_seconds(args, name))
before = control("status")
loaded_meshes = before["meshed_sections"]
begin = time.time()
summary = control("fly {} {} {} {} {} {} {} {} raw".format(*start, *end, args.seconds, pitch))
windows.append((begin, time.time()))
scene_windows[name] = windows[-1]
scene.update({k: summary.get(k) or [] for k in ("frame_ms", "cpu_ms", "gpu_ms")})
status = control("status")
# The client's own ticks: the frames' tick phase over the ticks the flight ran.
ticks = status.get("ticks", 0) - before.get("ticks", 0)
tick_phase = (summary.get("phases_avg_ms") or {}).get("tick")
if ticks > 0 and tick_phase is not None:
scene["client_tick_ms"] = tick_phase * summary.get("frames", 0) / ticks
scene["entities"] = {"mobs": status.get("mobs"), "block_entities": status.get("block_entities")}
if name in stress_scenes.NAMES:
for line in stress_scenes.cleanup(name):
control("command " + line)
scene["meshes_flight"] = status["meshed_sections"] - loaded_meshes
renderer = status.get("renderer") or {}
scene["gpu_mb"] = nvidia_memory_mb(proc.pid) or (renderer.get("gpu_allocated_bytes") or 0) / 2**20 or None
@@ -531,6 +685,8 @@ def run_horizon(args, distance):
sampler.stop.set()
sock.unlink(missing_ok=True)
result["memory"] = sampler.summary(windows)
for name, mb in scene_rss(sampler, scene_windows).items():
result["scenes"][name]["rss_mb"] = mb
args.release = release_version(WORK / f"horizon-rd{distance}.log")
return result
@@ -649,7 +805,8 @@ def run_vanilla(args, distance):
script = WORK / "script.json"
script.write_text(json.dumps({
"world": WORLD, "seed": SEED, "settle": args.settle, "seconds": args.seconds, "timeout": args.timeout,
"scenes": [{"name": n, "from": a, "to": b, "pitch": p} for n, a, b, p in scene_list(args)],
"scenes": driver_scenes(args),
**({"server": args.server_address} if args.server else {}),
}))
events = WORK / f"{args.game}-rd{distance}.events.jsonl"
events.unlink(missing_ok=True)
@@ -664,7 +821,7 @@ def run_vanilla(args, distance):
"-Dfabric.log.disableAnsi=true",
"-cp", os.pathsep.join(map(str, game_jars + fabric)),
"net.fabricmc.loader.impl.launch.knot.KnotClient",
"--username", "Bench", "--uuid", "00000000000000000000000000000000", "--accessToken", "0",
"--username", BENCH_PLAYER, "--uuid", "00000000000000000000000000000000", "--accessToken", "0",
"--offlineDeveloperMode", "--version", lab.VERSION, "--gameDir", str(game_dir),
"--assetsDir", str(assets), "--assetIndex", manifest["assetIndex"]["id"],
"--width", str(args.width), "--height", str(args.height)]
@@ -676,6 +833,18 @@ def run_vanilla(args, distance):
return driven(args, distance, command, game_dir, env, log, events, options, loaded_mods)
def driver_scenes(args):
"""The scenes as the driver mods read them: stress scenes carry their setup commands and settle time."""
out = []
for n, a, b, p in scene_list(args):
scene = {"name": n, "from": a, "to": b, "pitch": p, "settle": settle_seconds(args, n)}
if n in stress_scenes.NAMES:
scene["setup"] = stress_scenes.setup(n, first=n == first_stress(args))
scene["cleanup"] = stress_scenes.cleanup(n)
out.append(scene)
return out
def driven(args, distance, command, game_dir, env, log, events, options, loaded_mods):
"""Runs a Java client whose driver mod writes `events`, and reads its results."""
started = time.time()
@@ -702,20 +871,29 @@ def driven(args, distance, command, game_dir, env, log, events, options, loaded_
"renderer": {k: title[k] for k in ("gpu", "vendor", "backend", "driver", "size",
"render_distance", "simulation_distance")},
"startup": {"title_s": title["epoch_ms"] / 1000 - started,
"world_s": world["ready_epoch_ms"] / 1000 - started},
"world_s": world["ready_epoch_ms"] / 1000 - started,
# --server: the driver connects as soon as the title screen shows.
**({"join_s": (world["ready_epoch_ms"] - title["epoch_ms"]) / 1000} if args.server else {})},
"scenes": {},
}
windows = []
windows, scene_windows = [], {}
loaded = {e["scene"]: e for e in by("loaded")}
flights = {e["scene"]: e for e in by("fly")}
for flown in by("flown"):
name = flown["scene"]
windows.append((flights[name]["epoch_ms"] / 1000, flown["epoch_ms"] / 1000))
scene_windows[name] = windows[-1]
scene = {"load_s": loaded[name]["load_ms"] / 1000, "frame_ms": flown["frame_ms"],
"chunks": flown["chunks"], "gpu_mb": nvidia}
if flown.get("tick_ms"):
scene["client_tick_ms"] = statistics.mean(flown["tick_ms"])
if "entities" in flown:
scene["entities"] = flown["entities"]
result["scenes"][name] = scene
print_scene(args.game, distance, name, scene)
result["memory"] = sampler.summary(windows)
for name, mb in scene_rss(sampler, scene_windows).items():
result["scenes"][name]["rss_mb"] = mb
return result
@@ -742,7 +920,8 @@ def run_neoforge(args, distance):
script = WORK / "script.json"
script.write_text(json.dumps({
"world": WORLD, "seed": SEED, "settle": args.settle, "seconds": args.seconds, "timeout": args.timeout,
"scenes": [{"name": n, "from": a, "to": b, "pitch": p} for n, a, b, p in scene_list(args)],
"scenes": driver_scenes(args),
**({"server": args.server_address} if args.server else {}),
}))
events = WORK / f"{args.game}-rd{distance}.events.jsonl"
events.unlink(missing_ok=True)
@@ -856,6 +1035,8 @@ def report(args, distance, runs, app):
conditions = {"scenario": "perf_bench:" + ",".join(first["scenes"]), "seed": str(SEED),
"render_distance": distance, "simulation_distance": 12, "vsync": False,
"offscreen": bool(args.offscreen), "repeats": len(runs), "warmup_s": args.settle}
if getattr(args, "server", None):
conditions["server"] = args.server
if first["load_before"] is not None:
conditions["load_avg_1m"] = rnd(first["load_before"])
if size:
@@ -868,6 +1049,7 @@ def report(args, distance, runs, app):
metric("startup.title_ms", [r["startup"]["title_s"] * 1000 for r in runs])
metric("startup.world_ms", [r["startup"]["world_s"] * 1000 for r in runs])
metric("startup.join_ms", [r["startup"]["join_s"] * 1000 for r in runs if "join_s" in r["startup"]])
for name in first["scenes"]:
scenes = [r["scenes"][name] for r in runs if name in r["scenes"]]
flights = [frames(name, i, s["frame_ms"], s.get("cpu_ms", ()), s.get("gpu_ms", ())) for i, s in enumerate(scenes)]
@@ -877,6 +1059,9 @@ def report(args, distance, runs, app):
for key in ("avg_fps", "low_1pct_fps", "low_01pct_fps"):
metric(f"fps.{name}.{key}", [f[key] for f in flights if f])
metric(f"memory.{name}.gpu_mb", [s.get("gpu_mb") for s in scenes])
metric(f"memory.{name}.rss_mb", [s.get("rss_mb") for s in scenes])
metric(f"tick.{name}.client_ms", [s.get("client_tick_ms") for s in scenes])
metric(f"cpu.{name}.p50_ms", [f.get("cpu_p50_ms") for f in flights if f])
metric("memory.rss_peak_mb", [r["memory"].get("rss_peak_mb") for r in runs])
metric("memory.rss_steady_mb", [r["memory"].get("rss_steady_mb") for r in runs])
metric("memory.footprint_peak_mb", [r["memory"].get("footprint_peak_mb") for r in runs])
@@ -923,6 +1108,22 @@ def table(labels):
cells.append(" / ".join(cell(m.get(f"fps.{scene}.{k}")) for k in ("avg_fps", "low_1pct_fps", "low_01pct_fps")))
print(f"| {d} | {scene} | " + " | ".join(cells) + " |")
print()
# Per-scene costs where any run recorded them (stress scenes): frame CPU, client tick, resident memory.
per_scene = ("cpu.{}.p50_ms", "tick.{}.client_ms", "memory.{}.rss_mb")
rows = [(d, scene) for d in distances
for scene in sorted({k.split(".")[1] for by in runs.values() if d in by for k in by[d]["metrics"]
if k.split(".")[0] in ("cpu", "tick") or (k.startswith("memory.") and k.endswith(".rss_mb"))})]
if rows:
print("| RD | scene | " + " | ".join(f"{l}: frame CPU p50 ms / client tick ms / RSS MB" for l in labels) + " |")
print("| --- | --- |" + " --- |" * len(labels))
for d, scene in rows:
cells = []
for l in labels:
m = runs.get(l, {}).get(d, {}).get("metrics", {})
cells.append(" / ".join(cell(m.get(k.format(scene)), 0 if k.startswith("memory") else 2)
for k in per_scene))
print(f"| {d} | {scene} | " + " | ".join(cells) + " |")
print()
print("| RD | " + " | ".join(f"{l}: title / world s, mean scene load s, RSS steady / peak MB, footprint steady / peak MB, load avg" for l in labels) + " |")
print("| --- |" + " --- |" * len(labels))
for d in distances:
@@ -967,6 +1168,13 @@ def main():
help="horizon: measure a later start, after one start to the title screen built the caches")
p.add_argument("--mod-jars", help="neoforge: the mod jars to load (the user's own; never committed)")
p.add_argument("--deps", help="neoforge: their required libraries")
p.add_argument("--server", choices=["horizon", "vanilla"],
help="join a local dedicated server (Horizon's, or your own 26.3 server.jar) instead of creating "
"a world: startup.join_ms is connect to loaded")
p.add_argument("--server-build", help="--server horizon: the server's binary folder (default --build)")
p.add_argument("--server-bots", type=int, default=0,
help="--server: this many horizon-bots walk around the spawn, and a 'crowd' scene flies over them")
p.add_argument("--bots-build", help="--server-bots: horizon-bots' folder (default --build)")
p.add_argument("--table", nargs="+", metavar="LABEL", help="compare saved runs with matching recorded conditions, and exit")
args = p.parse_args()
if args.table:
@@ -983,11 +1191,20 @@ def main():
args.label = size_label(label, args.width, args.height)
for d in [int(x) for x in args.distances.split(",")]:
runs = []
for _ in range(args.repeats):
load_before = load_average()
result = run(args, d)
result["load_before"], result["load_after"] = load_before, load_average()
runs.append(result)
with contextlib.ExitStack() as stack:
if args.server:
args.server_build = args.server_build or args.build
server = stack.enter_context(LocalServer(args, d))
args.server_address = server.address
args.extra_scenes = [server.crowd(args)] if args.server_bots else []
if server.fresh:
print(f"warm-up join of the new {args.server} server world (not measured)")
run(args, d)
for _ in range(args.repeats):
load_before = load_average()
result = run(args, d)
result["load_before"], result["load_after"] = load_before, load_average()
runs.append(result)
out = ROOT / "benchmarks/perf" / f"{args.label}-rd{d}.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report(args, d, runs, app(args)), indent=1) + "\n")
+208
View File
@@ -0,0 +1,208 @@
"""Many-entity stress scenes for tools/perf_bench.py (`--scenes stress` or by name).
Each scene is built by vanilla commands, the same lines for every game (Horizon through horizon-ctl `command`,
vanilla and vanilla-mods through the bench driver mod), on a stone floor at y 289 high above the world spawn of
seed 1234 (loaded already: no terrain to generate), then held still for the flight's seconds, looking along +Z at
the scene. Its cleanup commands then remove every entity and block it made, before the next scene.
Vanilla renders an entity only within 64 * viewScale * its box size (Entity.shouldRenderAtSqrDistance), with
viewScale = clamp(render distance / 8, 1, 2.5): a dropped item (0.25) only within 32 blocks at RD 16. The scenes
keep everything within ~30 blocks of the camera and inside its view, so run them at RD 16 or more.
Entities are still and never despawn (NoAI, NoGravity, PersistenceRequired; items lie on barriers): the same picture every
frame and every run. Particles come from repeating command blocks under the floor, so they keep coming at the
same rate. Chests stay closed: opening one needs a player, which neither driver has.
"""
FLOOR_Y = 289
# The player's feet (the eyes are 1.62 higher).
PLAYER_Y = 298.0
PITCH = 15.0
# The camera stands at the origin, above the spawn of seed 1234 (-519.5, 58.5); the scene lies in front of it, at +Z.
ORIGIN = (-520, 40)
# What a scene may touch: x and z around the origin, y from the command blocks to above the TNT.
BOX = (-24, 24, 2, 40, FLOOR_Y - 9, FLOOR_Y + 26)
SETTINGS = [
# The setup's own commands and the command blocks would fill the chat.
"gamerule send_command_feedback false",
"gamerule command_block_output false",
"gamerule advance_time false",
"gamerule spawn_mobs false",
"time set 6000",
]
MOBS = ["husk", "creeper", "cow", "pig", "sheep", "chicken", "villager", "spider"]
ITEMS = ["stone", "oak_log", "diamond", "apple", "iron_sword", "bread", "redstone", "torch"]
FRAMED = ["diamond_sword", "compass", "clock", "stone", "oak_planks", "apple", "shield", "golden_apple"]
CONTAINERS = ["chest", "trapped_chest", "ender_chest", "white_shulker_box"]
BANNER = ('{patterns:[{pattern:"minecraft:stripe_top",color:"red"},{pattern:"minecraft:creeper",color:"black"},'
'{pattern:"minecraft:border",color:"blue"}]}')
SIGN_TEXT = '{front_text:{messages:["Horizon","stress","sign","wall"]}}'
def num(v):
"""A coordinate as commands write it: no trailing zeros, never an exponent."""
text = f"{v:.3f}".rstrip("0").rstrip(".")
return "0" if text == "-0" else text
def floor(ox, oz):
return [f"fill {ox - 24} {FLOOR_Y} {oz + 2} {ox + 24} {FLOOR_Y} {oz + 40} smooth_stone"]
def wall(ox, oz):
"""A stone wall facing the camera, 17 blocks ahead: the backing of frames and wall signs."""
return [f"fill {ox - 16} {FLOOR_Y - 1} {oz + 17} {ox + 17} {FLOOR_Y + 16} {oz + 17} stone"]
def empty(ox, oz):
return []
def mobs(ox, oz):
"""1000 mobs of 8 common kinds: 20 x 10 x 5 layers, 1.5 / 2 / 2.5 blocks apart."""
out, i = [], 0
for layer in range(5):
for row in range(10):
for col in range(20):
kind = MOBS[i % len(MOBS)]
x, y, z = ox + 0.5 - 14.25 + 1.5 * col, FLOOR_Y + 1 + 2.5 * layer, oz + 8 + 2 * row
out.append(f"summon {kind} {num(x)} {num(y)} {num(z)} "
"{NoAI:1b,NoGravity:1b,PersistenceRequired:1b,Silent:1b}")
i += 1
return out
def items(ox, oz):
"""2000 dropped items of 8 kinds: 25 x 16 on the floor and on four invisible barrier layers above it, 2 blocks
apart, a block apart in a layer (vanilla merges equal stacks within 0.625). They fall the last 0.2 block onto
their layer: no NoGravity, which not every game reads for items."""
out = [f"fill {ox - 12} {FLOOR_Y + 2 * layer} {oz + 8} {ox + 12} {FLOOR_Y + 2 * layer} {oz + 23} barrier"
for layer in range(1, 5)]
i = 0
for layer in range(5):
for row in range(16):
for col in range(25):
item = ITEMS[i % len(ITEMS)]
x, y, z = ox + 0.5 - 12 + col, FLOOR_Y + 1.2 + 2 * layer, oz + 8.5 + row
out.append(f"summon item {num(x)} {num(y)} {num(z)} "
f'{{Item:{{id:"minecraft:{item}",count:1}},Age:-32768s,PickupDelay:32767s}}')
i += 1
return out
def frames(ox, oz):
"""512 item frames holding 8 kinds of item on a wall (32 x 16)."""
out = wall(ox, oz)
i = 0
for y in range(FLOOR_Y, FLOOR_Y + 16):
for x in range(ox - 15, ox + 17):
item = FRAMED[i % len(FRAMED)]
# block_pos too: summoning places the entity, not the block a frame hangs in.
out.append(f"summon item_frame {x} {y} {oz + 16} {{block_pos:[I;{x},{y},{oz + 16}],"
f'Facing:2b,Fixed:1b,Item:{{id:"minecraft:{item}",count:1}}}}')
i += 1
return out
def armor_stands(ox, oz):
"""500 armor stands in full armour holding a sword (25 x 20)."""
gear = ('{ShowArms:1b,NoGravity:1b,equipment:{head:{id:"minecraft:diamond_helmet"},'
'chest:{id:"minecraft:iron_chestplate"},legs:{id:"minecraft:golden_leggings"},'
'feet:{id:"minecraft:leather_boots"},mainhand:{id:"minecraft:diamond_sword"}}}')
return [f"summon armor_stand {num(ox + 0.5 - 12 + col)} {FLOOR_Y + 1} {oz + 8 + row} {gear}"
for row in range(20) for col in range(25)]
def chests(ox, oz):
"""800 containers with block-entity models: four walls 25 wide and 8 high, one kind each."""
return [f"fill {ox - 12} {FLOOR_Y + 1} {oz + 10 + 4 * i} {ox + 12} {FLOOR_Y + 8} {oz + 10 + 4 * i} {kind}"
for i, kind in enumerate(CONTAINERS)]
def signs(ox, oz):
"""A wall of 512 written wall signs (32 x 16) and a field of 175 standing ones in front of it."""
return wall(ox, oz) + [
f"fill {ox - 15} {FLOOR_Y} {oz + 16} {ox + 16} {FLOOR_Y + 15} {oz + 16} oak_wall_sign[facing=north]{SIGN_TEXT}",
f"fill {ox - 12} {FLOOR_Y + 1} {oz + 6} {ox + 12} {FLOOR_Y + 1} {oz + 12} oak_sign[rotation=8]{SIGN_TEXT}",
]
def banners(ox, oz):
"""300 waving banners with three patterns: 12 rows of 25."""
return [f"fill {ox - 12} {FLOOR_Y + 1} {oz + 8 + 2 * row} {ox + 12} {FLOOR_Y + 1} {oz + 8 + 2 * row} "
f"white_banner[rotation=8]{BANNER}" for row in range(12)]
def particles(ox, oz):
"""Steady heavy particles: repeating command blocks under the floor, powered by a redstone block under each,
every tick: flames, smoke, end rods, firework rockets that burst over the floor, and TNT that explodes in the
air (nothing within its reach)."""
def block(dx, dz, command):
command = command.replace('"', '\\"')
at = f"{ox + dx} {FLOOR_Y - 8} {oz + dz}"
return [f'setblock {at} repeating_command_block{{Command:"{command}"}}',
f"setblock {ox + dx} {FLOOR_Y - 9} {oz + dz} redstone_block"]
x, z = ox + 0.5, oz + 20.5
emitters = [
("flame", 80, 0.01), ("flame", 80, 0.01), ("flame", 80, 0.01), ("flame", 80, 0.01),
("campfire_cosy_smoke", 4, 0.0), ("campfire_cosy_smoke", 4, 0.0),
("end_rod", 40, 0.05), ("end_rod", 40, 0.05),
]
out = [line for i, (kind, count, speed) in enumerate(emitters) for line in
block(-8 + 2 * i, 4, f"particle minecraft:{kind} {num(x)} {FLOOR_Y + 5} {num(z)} 8 4 8 {speed} {count} force")]
rocket = ('{LifeTime:18,FireworksItem:{id:"minecraft:firework_rocket",count:1,components:{"minecraft:fireworks":'
'{explosions:[{shape:"large_ball",colors:[I;16711680,65280,255],has_trail:1b}]}}}}')
out += [line for i in range(3) for line in
block(-8 + 2 * i, 6, f"summon firework_rocket {num(x - 6 + 6 * i)} {FLOOR_Y + 2} {num(z)} {rocket}")]
out += block(0, 8, f"summon tnt {num(x)} {FLOOR_Y + 24} {num(z + 6)} {{fuse:0}}")
return out
# name, builder: in the order --scenes stress runs them.
STRESS = [
("stress_empty", empty),
("stress_mobs", mobs),
("stress_items", items),
("stress_frames", frames),
("stress_armor_stands", armor_stands),
("stress_chests", chests),
("stress_signs", signs),
("stress_banners", banners),
("stress_particles", particles),
]
NAMES = [name for name, _ in STRESS]
def origin(name):
return ORIGIN
def scene(name):
"""(name, from, to, pitch) as perf_bench's scenes: a hold at the camera, facing +Z."""
ox, oz = origin(name)
at = (ox + 0.5, PLAYER_Y, oz + 0.5)
return name, at, (at[0], at[1], at[2] + 0.01), PITCH
def setup(name, first=True):
"""The commands that build the scene, after its chunks are loaded; the game rules only for the first scene of
a run (setting a rule again says so in red in the chat, which would stay on screen)."""
ox, oz = origin(name)
return (SETTINGS if first else []) + floor(ox, oz) + dict(STRESS)[name](ox, oz)
def cleanup(name):
"""Removes the scene: its entities first (a frame would drop its item when its wall goes), then every block
in its box, in slabs under vanilla's fill limit, then anything that still dropped. Nothing it runs fails, so
no red line stays in the chat during the next scene: `execute as` kills nothing quietly, and a marker block in
each slab gives every fill something to clear."""
ox, oz = origin(name)
x0, x1, z0, z1, y0, y1 = BOX
kill = "execute as @e[type=!minecraft:player] run kill @s"
out = [kill]
for y in range(y0, y1 + 1, 12):
out.append(f"setblock {ox + x0} {y} {oz + z0} barrier")
out.append(f"fill {ox + x0} {y} {oz + z0} {ox + x1} {min(y + 11, y1)} {oz + z1} air")
return out + [kill]
+566 -44
View File
@@ -425,8 +425,177 @@ fn host_for(is_level: bool, mutable: Option<bool>, ticks: bool) -> Option<(&'sta
})
}
fn catalog_hooks(
root: &Path,
catalog: &serde_json::Value,
) -> Result<Vec<serde_json::Value>, String> {
let mut hooks = catalog["hooks"]
.as_array()
.ok_or("catalog hooks must be an array")?
.clone();
let mut paths = fs::read_dir(root.join("tools/native-hook-fragments"))
.map_err(|e| e.to_string())?
.map(|entry| entry.map(|entry| entry.path()).map_err(|e| e.to_string()))
.collect::<Result<Vec<_>, _>>()?;
paths.sort();
for path in paths
.into_iter()
.filter(|path| path.extension().is_some_and(|ext| ext == "json"))
{
let fragment: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).map_err(|e| e.to_string())?)
.map_err(|e| format!("{}: {e}", path.display()))?;
hooks.extend(
fragment["hooks"]
.as_array()
.ok_or("fragment hooks must be an array")?
.iter()
.cloned(),
);
}
let mut seen = HashSet::new();
for hook in &hooks {
let name = hook["name"].as_str().ok_or("hook name missing")?;
if !seen.insert(name.to_owned()) {
return Err(format!("duplicate hook {name}"));
}
}
Ok(hooks)
}
/// The crate folder and module path of a hook target that is a free function
/// outside `Game`. `horizon-client-lib` builds from `horizon-client/src`
/// (`client_lib.rs`); `menu` and `breathing` are also accepted under their
/// older `horizon_client::` names.
fn free_function_source(target: &str) -> Option<(&'static str, String)> {
let prefixes = [
("horizon_world::", "horizon-world"),
("horizon_client_render::", "horizon-client-render"),
("horizon_client_lib::", "horizon-client"),
];
prefixes
.into_iter()
.find_map(|(prefix, krate)| target.strip_prefix(prefix).map(|p| (krate, p.to_owned())))
.or_else(|| {
["menu", "breathing"].into_iter().find_map(|module| {
target
.strip_prefix(&format!("horizon_client::{module}::"))
.map(|p| ("horizon-client", format!("{module}::{p}")))
})
})
}
/// Whether a hook function needs its dispatch `call` filled in: false when it
/// makes the call; true when it is a `Game` or `GameLevel` method whose body is
/// only the placeholder a new hook starts with, `let _ = (every, parameter);`
/// (`let _ = parameter;` for one). Any other body is an error, left alone.
fn needs_fill(method: &syn::ItemFn, call: &str, in_game: bool) -> Result<bool, String> {
if has_call(&method.block, call) {
return Ok(false);
}
let params: Vec<String> = method
.sig
.inputs
.iter()
.filter_map(|arg| match arg {
FnArg::Typed(arg) => Some(arg.pat.to_token_stream().to_string()),
FnArg::Receiver(_) => None,
})
.collect();
let named = match &method.block.stmts[..] {
[
syn::Stmt::Local(syn::Local {
pat: Pat::Wild(_),
init:
Some(syn::LocalInit {
expr,
diverge: None,
..
}),
..
}),
] => match &**expr {
syn::Expr::Tuple(tuple) => Some(
tuple
.elems
.iter()
.map(|e| e.to_token_stream().to_string())
.collect(),
),
single @ syn::Expr::Path(_) => Some(vec![single.to_token_stream().to_string()]),
_ => None,
},
_ => None,
};
if in_game && named == Some(params) {
return Ok(true);
}
Err(format!(
"missing {call} call in {}: add it, or leave a Game hook's body as the let _ = (…); placeholder for cargo run -p xtask -- mod-api to fill",
method.sig.ident
))
}
/// A hook function whose placeholder body becomes `call`.
struct Fill {
native: String,
call: String,
}
/// `source` with each fill's placeholder statement replaced by its call,
/// formatted as `cargo fmt` would.
fn filled_hooks(source: &str, fills: &[Fill]) -> Result<Vec<u8>, String> {
let mut source = source.to_owned();
for Fill { native, call } in fills {
let start = source
.find(&format!("fn {native}("))
.ok_or_else(|| format!("{native} not found"))?;
let placeholder = start
+ source[start..]
.find("let _ =")
.ok_or_else(|| format!("{native} has no placeholder"))?;
let end = placeholder
+ source[placeholder..]
.find(';')
.ok_or_else(|| format!("{native} has no placeholder"))?
+ 1;
source.replace_range(placeholder..end, call);
}
rustfmt(&source)
}
/// The workspace's edition, which rustfmt formats generated code for.
const EDITION: &str = "2024";
/// `code` as `cargo fmt` formats it.
fn rustfmt(code: &str) -> Result<Vec<u8>, String> {
let mut child = Command::new("rustfmt")
.args(["--emit", "stdout", "--edition", EDITION])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.map_err(|e| e.to_string())?;
use std::io::Write;
child
.stdin
.take()
.ok_or("rustfmt stdin")?
.write_all(code.as_bytes())
.map_err(|e| e.to_string())?;
let formatted = child.wait_with_output().map_err(|e| e.to_string())?;
if !formatted.status.success() {
return Err("rustfmt failed".into());
}
Ok(formatted.stdout)
}
pub fn run(check: bool) -> Result<(), String> {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
generate(&Path::new(env!("CARGO_MANIFEST_DIR")).join(".."), check)
}
/// Writes the mod API generated from the sources under `root`, and fills new
/// hooks' placeholder bodies; with `check`, fails instead if either would change.
fn generate(root: &Path, check: bool) -> Result<(), String> {
let catalog: serde_json::Value = serde_json::from_str(
&fs::read_to_string(root.join("native-hooks.json")).map_err(|e| e.to_string())?,
)
@@ -444,29 +613,18 @@ pub fn run(check: bool) -> Result<(), String> {
let mut dispatch = String::new();
let mut render_traits = String::new();
let mut render_dispatch = String::new();
let mut seen = HashSet::new();
for hook in catalog["hooks"]
.as_array()
.ok_or("catalog hooks must be an array")?
{
let Some(api) = hook.get("mod_api") else {
continue;
};
let hooks = catalog_hooks(root, &catalog)?;
let mut fills = Vec::new();
for hook in &hooks {
let name = hook["name"].as_str().ok_or("hook name missing")?;
if !seen.insert(name.to_owned()) {
return Err(format!("duplicate hook {name}"));
}
let api = hook
.get("mod_api")
.ok_or_else(|| format!("hook {name} has no mod_api entry: add one"))?;
let native = format!("native_{name}");
let target = hook["target"].as_str().ok_or("target missing")?;
let is_render = target.starts_with("horizon_client_render::");
let external = target
.strip_prefix("horizon_world::")
.map(|p| ("horizon-world", p))
.or_else(|| {
target
.strip_prefix("horizon_client_render::")
.map(|p| ("horizon-client-render", p))
});
let external = free_function_source(target);
let in_game = external.is_none();
let (method, call) = if let Some((krate, path)) = external {
let Some((module, function)) = path.rsplit_once("::") else {
return Err(format!("wrong target for {name}"));
@@ -509,9 +667,7 @@ pub fn run(check: bool) -> Result<(), String> {
format!("dispatch_{name}"),
)
};
if !has_call(&method.block, &call) {
return Err(format!("missing {call} call in {native}"));
}
let fill = needs_fill(&method, &call, in_game)?;
let sig = &method.sig;
let mut args = Vec::new();
let mut receiver_mut = None;
@@ -634,6 +790,27 @@ pub fn run(check: bool) -> Result<(), String> {
"replace" => "None",
_ => "",
};
if fill {
// The dispatch's parameters in order: the host, the mods, the
// arguments and the output last.
let host = host.as_ref().map(|_| {
if ticks {
"&mut super::mod_api_host::LevelTicks { level: self, ticks }"
} else {
"self"
}
});
let passed = host
.into_iter()
.chain(["mod_api::mods()"])
.chain(args.iter().chain(output.as_ref()).map(|(n, _)| n.as_str()))
.collect::<Vec<_>>()
.join(", ");
fills.push(Fill {
native: native.clone(),
call: format!("mod_api::{call}({passed});"),
});
}
let invoke = call_args.join(", ");
let trait_params = trait_params.join(", ");
let dispatch_params = dispatch_params.join(", ");
@@ -685,8 +862,8 @@ pub fn run(check: bool) -> Result<(), String> {
"pub fn dispatch_{name}({dispatch_params}) {{ {body} }}\n"
));
}
if seen.is_empty() {
return Err("no mod API contracts".into());
if hooks.is_empty() {
return Err("no native hooks".into());
}
let code = format!(
"// @generated by cargo run -p xtask -- mod-api; do not edit.\nuse super::*;\npub trait Hooks: Send + Sync {{\nfn render(&self) -> Option<&dyn RenderHooks> {{ None }}\n{traits}}}\n{dispatch}"
@@ -764,6 +941,21 @@ pub fn run(check: bool) -> Result<(), String> {
let render_code = format!(
"// @generated by cargo run -p xtask -- mod-api; do not edit.\nuse super::*;\npub trait RenderHooks: Send + Sync {{\n{render_traits}}}\n{render_dispatch}"
);
// A new hook's placeholder body becomes its dispatch call.
let filled = if fills.is_empty() {
None
} else if check {
let natives = fills
.iter()
.map(|fill| fill.native.as_str())
.collect::<Vec<_>>()
.join(", ");
return Err(format!(
"{natives}: the let _ = (…); placeholder needs its dispatch call; run cargo run -p xtask -- mod-api"
));
} else {
Some(filled_hooks(&source, &fills)?)
};
for (relative, code) in [
(
"crates/horizon-client-render/src/gfx/native_render/generated.rs",
@@ -778,34 +970,25 @@ pub fn run(check: bool) -> Result<(), String> {
] {
// Parse before formatting, so invalid catalog names or types fail without writing output.
syn::parse_file(&code).map_err(|e| format!("generated Rust: {e}"))?;
let mut child = Command::new("rustfmt")
.args(["--emit", "stdout", "--edition", "2021"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.map_err(|e| e.to_string())?;
use std::io::Write;
child
.stdin
.take()
.ok_or("rustfmt stdin")?
.write_all(code.as_bytes())
.map_err(|e| e.to_string())?;
let formatted = child.wait_with_output().map_err(|e| e.to_string())?;
if !formatted.status.success() {
return Err("rustfmt failed".into());
}
let formatted = rustfmt(&code)?;
let path = root.join(relative);
if check {
if fs::read(&path).map_err(|e| e.to_string())? != formatted.stdout {
if fs::read(&path).map_err(|e| e.to_string())? != formatted {
return Err("mod API output is stale; run cargo run -p xtask -- mod-api".into());
}
} else {
fs::create_dir_all(path.parent().ok_or("output parent missing")?)
.map_err(|e| e.to_string())?;
fs::write(path, formatted.stdout).map_err(|e| e.to_string())?;
fs::write(path, formatted).map_err(|e| e.to_string())?;
}
}
if let Some(filled) = filled {
fs::write(
root.join("crates/horizon-client/src/game/native_hooks.rs"),
filled,
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
@@ -843,6 +1026,14 @@ mod tests {
pub(super) struct NativeScreenView<'a>(pub &'a ());
pub(super) struct Corners;
pub(super) struct HudBatch;
pub(super) struct BiomeColors;
pub(super) struct UseAnimation;
#[derive(Clone, Copy)]
pub(super) struct Hand;
pub(super) struct NativeMobRender<'a>(pub &'a ());
pub(super) struct NativeShake;
pub(super) struct Music;
pub(super) struct MusicLayer;
pub(super) struct NativeBlockRim;
pub(super) struct NativeColoredLight;
pub(super) struct NativeMinimap;
@@ -852,6 +1043,13 @@ mod tests {
pub(super) struct ModBlockEntity;
pub(super) struct ModContainer;
pub(super) struct ModFluidTanks;
pub(super) struct ModEnergyBuffer;
pub(super) struct LootStack;
pub(super) struct NativeLootContext<'a>(pub &'a ());
pub(super) struct NativeDataMapType;
pub(super) struct NativeMapDecoration;
pub(super) struct NativeMapInfo<'a>(pub &'a ());
pub(super) struct NativePlayerRender<'a>(pub &'a ());
pub(super) struct NativeCommand;
pub(super) struct NativeLifecycleContext;
pub(super) struct NativeServerPlayer;
@@ -873,9 +1071,32 @@ mod tests {
trait RenderHooks {}
struct TestHost;
impl Host for TestHost {}
mod winit {
pub mod event {
pub struct KeyEvent;
}
}
#[allow(dead_code)]
mod generated {
mod horizon_mods {
pub mod content {
pub struct Block;
pub struct Item;
}
}
mod horizon_client {
pub mod camera {
pub struct CameraEntity;
}
pub mod fog {
#[derive(Clone, Copy)]
pub struct FogType;
pub struct FogData;
}
pub mod first_person {
#[derive(Clone, Copy)]
pub struct Hand;
}
pub mod hud_layout {
pub struct HealthInputs;
pub struct GuiDraw;
@@ -897,6 +1118,37 @@ mod tests {
assert_eq!(event.block, "example:machine");
event.draw.0.push(self.id);
}
fn living_entity_use_item_finish(
&self,
_: &mut dyn Host,
event: &mut LivingEntityUseItemFinishEvent<'_>,
) {
*event.result = None;
}
fn mouse_button_pre(&self, _: &mut dyn Host, _: &mut MouseButtonPreEvent) -> Flow {
self.calls.lock().unwrap().push(self.id);
if self.id == 1 {
Flow::Cancel
} else {
Flow::Continue
}
}
fn menu_slot_rules(&self, event: &mut MenuSlotRulesEvent<'_>) {
event.may_place = false;
event.max_stack = self.id;
}
fn living_entity_use_item_tick(
&self,
_: &mut dyn Host,
event: &mut LivingEntityUseItemTickEvent<'_>,
) -> Flow {
event.duration -= 1;
if self.id == 1 {
Flow::Cancel
} else {
Flow::Continue
}
}
fn loot_table_load(&self, event: &mut LootTableLoadEvent<'_>) {
event.table.as_array_mut().unwrap().push(self.id.into());
}
@@ -965,6 +1217,111 @@ mod tests {
assert_eq!(draw.0, [1, 2]);
}
#[test]
fn slot_rules_mutate_and_item_tick_can_cancel() {
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(Mod {
id: 1,
calls: Arc::new(Mutex::new(Vec::new())),
})];
let (mut may_place, mut max_stack) = (true, 64);
dispatch_menu_slot_rules(
&mods,
"test:menu",
0,
&ItemStack,
&mut may_place,
&mut max_stack,
);
assert_eq!((may_place, max_stack), (false, 1));
let (mut duration, mut canceled) = (10, false);
dispatch_living_entity_use_item_tick(
&mut TestHost,
&mods,
&ItemStack,
None,
Hand,
&mut duration,
&mut canceled,
);
assert_eq!((duration, canceled), (9, true));
}
#[test]
fn item_finish_can_clear_vanilla_result() {
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(Mod {
id: 1,
calls: Arc::new(Mutex::new(Vec::new())),
})];
let mut result = Some(ItemStack);
dispatch_living_entity_use_item_finish(
&mut TestHost,
&mods,
&ItemStack,
None,
Hand,
0,
&mut result,
);
assert!(result.is_none());
}
#[test]
fn fragment_cancel_stops_later_mods() {
let calls = Arc::new(Mutex::new(Vec::new()));
let mods: Vec<Box<dyn Hooks>> = vec![
Box::new(Mod {
id: 1,
calls: calls.clone(),
}),
Box::new(Mod {
id: 2,
calls: calls.clone(),
}),
];
let mut canceled = false;
dispatch_mouse_button_pre(&mut TestHost, &mods, 0, true, 0, &mut canceled);
assert!(canceled);
assert_eq!(*calls.lock().unwrap(), [1]);
}
#[test]
fn fragment_hooks_generate_and_duplicates_fail() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let catalog: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(root.join("native-hooks.json")).unwrap())
.unwrap();
let hooks = super::catalog_hooks(&root, &catalog).unwrap();
let hook = hooks
.iter()
.find(|h| h["name"] == "recipes_received")
.unwrap();
assert_eq!(hook["mod_api"]["contract"], "listen");
let generated =
std::fs::read_to_string(root.join("crates/horizon-client/src/mod_api/generated.rs"))
.unwrap();
assert!(generated.contains("fn recipes_received("));
let dir = root
.join("target")
.join(format!("mod-api-fragments-{}", std::process::id()));
let fragments = dir.join("tools/native-hook-fragments");
std::fs::create_dir_all(&fragments).unwrap();
std::fs::write(fragments.join("empty.json"), r#"{"hooks":[]}"#).unwrap();
std::fs::write(
fragments.join("duplicate.json"),
r#"{"hooks":[{"name":"same"}]}"#,
)
.unwrap();
let catalog = serde_json::json!({"hooks":[{"name":"same"}]});
assert_eq!(
super::catalog_hooks(&dir, &catalog).unwrap_err(),
"duplicate hook same"
);
std::fs::remove_file(fragments.join("duplicate.json")).unwrap();
assert_eq!(super::catalog_hooks(&dir, &catalog).unwrap().len(), 1);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn host_mapping_preserves_access_and_docs_and_rejects_unavailable_types() {
let exports = super::exported_types(
@@ -1124,6 +1481,148 @@ mod tests {
);
}
#[test]
fn only_a_game_hooks_placeholder_body_is_filled() {
let fill = |body: &str, in_game: bool| {
let method: syn::ItemFn = syn::parse_str(&format!(
"fn native_h(&mut self, player: u128, cancel: &mut bool) {{ {body} }}"
))
.unwrap();
super::needs_fill(&method, "dispatch_h", in_game)
};
assert_eq!(fill("let _ = (player, cancel);", true), Ok(true));
assert_eq!(
fill(
"horizon_client::mod_api::dispatch_h(self, mods(), player, cancel);",
true
),
Ok(false)
);
for (body, in_game) in [
("let _ = (player, cancel);", false),
("let _ = (cancel, player);", true),
("let _ = player;", true),
("let _ = (player, cancel); *cancel = true;", true),
("", true),
] {
assert!(
fill(body, in_game)
.unwrap_err()
.starts_with("missing dispatch_h call in native_h: "),
"{body}"
);
}
let one: syn::ItemFn =
syn::parse_quote! { fn native_one(&self, from: &str) { let _ = from; } };
assert_eq!(super::needs_fill(&one, "dispatch_one", true), Ok(true));
let source = "impl Game<'_> {\n fn native_other(&self, a: u32) {\n let _ = a;\n }\n fn native_h(&mut self, player: u128, name: &str, cancel: &mut bool) {\n let _ = (player, name, cancel);\n }\n}\n";
let filled = super::filled_hooks(
source,
&[super::Fill {
native: "native_h".into(),
call: "mod_api::dispatch_h(self, mod_api::mods(), player, name, cancel);".into(),
}],
)
.unwrap();
assert_eq!(
String::from_utf8(filled).unwrap(),
"impl Game<'_> {\n fn native_other(&self, a: u32) {\n let _ = a;\n }\n fn native_h(&mut self, player: u128, name: &str, cancel: &mut bool) {\n mod_api::dispatch_h(self, mod_api::mods(), player, name, cancel);\n }\n}\n"
);
}
/// `item_damaged` taken back to how a new hook arrives (its body the
/// placeholder) in a copy of the sources: the check reports it, any other
/// body is left alone, a missing `mod_api` entry fails by name, and the
/// generator brings back the shipped file byte for byte with the generated
/// code unchanged.
#[test]
fn new_hook_placeholder_is_reported_then_filled() {
fn copy(from: &std::path::Path, to: &std::path::Path) {
if from.is_dir() {
for entry in std::fs::read_dir(from).unwrap() {
let entry = entry.unwrap();
copy(&entry.path(), &to.join(entry.file_name()));
}
} else {
std::fs::create_dir_all(to.parent().unwrap()).unwrap();
std::fs::copy(from, to).unwrap();
}
}
let real = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let root = real
.join("target")
.join(format!("mod-api-fill-{}", std::process::id()));
let catalog: Value =
serde_json::from_str(&std::fs::read_to_string(real.join("native-hooks.json")).unwrap())
.unwrap();
let mut inputs: Vec<String> = [
"native-hooks.json",
"tools/native-hook-fragments",
"crates/horizon-client/src/game",
"crates/horizon-client/src/mod_api.rs",
"crates/horizon-client/src/mod_api",
"crates/horizon-client-render/src/gfx/native_render",
]
.map(str::to_owned)
.into();
for hook in super::catalog_hooks(&real, &catalog).unwrap() {
if let Some((krate, path)) =
super::free_function_source(hook["target"].as_str().unwrap())
{
let (module, _) = path.rsplit_once("::").unwrap();
inputs.push(format!(
"crates/{krate}/src/{}.rs",
module.replace("::", "/")
));
}
}
for input in &inputs {
copy(&real.join(input), &root.join(input));
}
let hooks = root.join("crates/horizon-client/src/game/native_hooks.rs");
let shipped = std::fs::read_to_string(&hooks).unwrap();
let call = "mod_api::dispatch_item_damaged(self, mod_api::mods(), player, name, item, amount, cancel);";
assert_eq!(shipped.matches(call).count(), 1);
let unfinished = shipped.replace(call, "let _ = (player, name, item, amount);");
std::fs::write(&hooks, &unfinished).unwrap();
assert!(
super::generate(&root, false)
.unwrap_err()
.starts_with("missing dispatch_item_damaged call in native_item_damaged: ")
);
assert_eq!(std::fs::read_to_string(&hooks).unwrap(), unfinished);
let placeholder = shipped.replace(call, "let _ = (player, name, item, amount, cancel);");
std::fs::write(&hooks, &placeholder).unwrap();
assert_eq!(
super::generate(&root, true).unwrap_err(),
"native_item_damaged: the let _ = (…); placeholder needs its dispatch call; run cargo run -p xtask -- mod-api"
);
assert_eq!(std::fs::read_to_string(&hooks).unwrap(), placeholder);
let fragment = root.join("tools/native-hook-fragments/native_item_damaged.json");
let shipped_fragment = std::fs::read_to_string(&fragment).unwrap();
let mut json: Value = serde_json::from_str(&shipped_fragment).unwrap();
json["hooks"][0].as_object_mut().unwrap().remove("mod_api");
std::fs::write(&fragment, json.to_string()).unwrap();
assert_eq!(
super::generate(&root, false).unwrap_err(),
"hook item_damaged has no mod_api entry: add one"
);
std::fs::write(&fragment, shipped_fragment).unwrap();
super::generate(&root, false).unwrap();
assert_eq!(std::fs::read_to_string(&hooks).unwrap(), shipped);
for generated in [
"crates/horizon-client/src/mod_api/generated.rs",
"crates/horizon-client-render/src/gfx/native_render/generated.rs",
] {
assert_eq!(
std::fs::read(root.join(generated)).unwrap(),
std::fs::read(real.join(generated)).unwrap()
);
}
super::generate(&root, true).unwrap();
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn event_fields_name_their_lifetimes() {
for (ty, field, scalar, a, b) in [
@@ -1171,6 +1670,29 @@ mod tests {
super::run(true).unwrap();
}
#[test]
fn library_crate_targets_resolve_to_their_source() {
for target in [
"horizon_client_lib::menu::native_menu_slot_rules",
"horizon_client::menu::native_menu_slot_rules",
] {
assert_eq!(
super::free_function_source(target),
Some(("horizon-client", "menu::native_menu_slot_rules".to_owned()))
);
}
assert_eq!(
super::free_function_source("horizon_client_lib::crafting::native_ingredient"),
Some(("horizon-client", "crafting::native_ingredient".to_owned()))
);
assert_eq!(
super::free_function_source(
"horizon_client::game::native_hooks::Game<'_>::native_zoom"
),
None
);
}
#[test]
fn contracted_hook_requires_dispatch_call() {
let missing: syn::ImplItemFn = syn::parse_quote! { fn native_zoom(&self) { unrelated(); } };