mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 17:19:10 -10:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7bb2259d6 | ||
|
|
a7df9ec019 | ||
|
|
76aa871c1e | ||
|
|
3a264943c7 | ||
|
|
5afa1de5f7 | ||
|
|
40e044da77 | ||
|
|
e7275ddee3 | ||
|
|
f7b73ed5f6 | ||
|
|
1c2ed6b613 | ||
|
|
c2d352e0a3 | ||
|
|
64c0291da2 | ||
|
|
4f1ebf7deb | ||
|
|
f4ee603baf | ||
|
|
daf749bbcd | ||
|
|
27877c639e | ||
|
|
dbb78b8a4c | ||
|
|
c4f9b6fc7f | ||
|
|
03d815532e | ||
|
|
e12185820a | ||
|
|
8f91c1534e | ||
|
|
00e0ecbdaf | ||
|
|
3e642d0acd | ||
|
|
f31198cbf7 | ||
|
|
b82e2a4b35 | ||
|
|
c9434646de | ||
|
|
9784cf63fd | ||
|
|
84ad92068f | ||
|
|
79a60dea88 | ||
|
|
38ce16338c | ||
|
|
2eb2bc1538 | ||
|
|
d5be28d945 | ||
|
|
8a6024f8e1 | ||
|
|
2b874aea1c | ||
|
|
3011a79600 | ||
|
|
50fdee8614 | ||
|
|
9762e4f3db | ||
|
|
21832e9390 | ||
|
|
2b78d2638e | ||
|
|
20e429c3f8 | ||
|
|
c481f9c68a | ||
|
|
912941a8f7 | ||
|
|
b73e5b7c98 | ||
|
|
7ec58998d6 | ||
|
|
7a52fde28f | ||
|
|
af3a264af1 |
No files matched your search
@@ -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, |_| {}));
|
||||
|
||||
@@ -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-"))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -836,7 +836,6 @@ mod tests {
|
||||
sort: super::super::translucency::SortState::new(Vec::new()),
|
||||
sorted_indices: None,
|
||||
flat_id: None,
|
||||
list_id: None,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
@@ -1120,7 +1119,6 @@ mod tests {
|
||||
sort: super::super::translucency::SortState::new(Vec::new()),
|
||||
sorted_indices: (translucent > 0).then_some(if indices { at } else { (7, 7) }),
|
||||
flat_id: None,
|
||||
list_id: None,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,7 +223,6 @@ fn buffer(
|
||||
/// from `shadow[0]` (solid) and `shadow[1]` (translucent) on are a shader
|
||||
/// pack's shadow camera's, with calls of their own. Phase 1's draws before
|
||||
/// `passes[0]` and from `passes[1]` on, the two solid passes' on Metal, too.
|
||||
/// The list starts at index `base` (the GPU-made lists' rooms come first: `gpu_lists`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn plan(
|
||||
draws: &[[u32; 5]],
|
||||
@@ -236,7 +235,6 @@ fn plan(
|
||||
calls: &mut Vec<Call>,
|
||||
ranges: &mut Vec<Range>,
|
||||
segments: &mut Vec<(Source, u64, u64)>,
|
||||
base: u64,
|
||||
) -> Plan {
|
||||
calls.clear();
|
||||
ranges.clear();
|
||||
@@ -244,7 +242,7 @@ fn plan(
|
||||
let (mut phase1_calls, mut solid_calls) = (None, None);
|
||||
let mut shadow_calls = [None; 2];
|
||||
let mut pass_calls = [None; 2];
|
||||
let mut total = base;
|
||||
let mut total = 0u64;
|
||||
let mut by_source: Vec<(Source, Vec<Range>)> = Vec::new();
|
||||
for (i, (d, &(page, sorted))) in draws.iter().zip(pages).enumerate() {
|
||||
if i == phase1_draws {
|
||||
@@ -542,8 +540,6 @@ impl Flat {
|
||||
phase2: Option<&wgpu::Buffer>,
|
||||
origins: impl Iterator<Item = (u32, [f32; 4])>,
|
||||
sorted_page: impl Fn(u32) -> wgpu::Buffer,
|
||||
mut lists: Option<&mut super::gpu_lists::GpuLists>,
|
||||
eye: Option<[f32; 3]>,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> bool {
|
||||
self.phase2_group = None;
|
||||
@@ -581,8 +577,6 @@ impl Flat {
|
||||
bytemuck::cast_slice(&self.origin_data),
|
||||
);
|
||||
}
|
||||
// The GPU-made lists' rooms come first in the index list.
|
||||
let room = lists.as_mut().map_or(0, |l| l.rooms());
|
||||
let Plan {
|
||||
phase1_calls,
|
||||
solid_calls,
|
||||
@@ -600,7 +594,6 @@ impl Flat {
|
||||
&mut self.calls,
|
||||
&mut self.range_data,
|
||||
&mut self.segments,
|
||||
room,
|
||||
);
|
||||
self.phase1_calls = phase1_calls;
|
||||
self.solid_calls = solid_calls;
|
||||
@@ -663,9 +656,6 @@ impl Flat {
|
||||
bytemuck::cast_slice(&self.phase2_call_data),
|
||||
);
|
||||
}
|
||||
if let Some(lists) = &mut lists {
|
||||
lists.prepare(device, encoder, uploads, Some(&self.indices), eye);
|
||||
}
|
||||
let ranges_at = |first: u64, count: u64| {
|
||||
wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &self.ranges,
|
||||
@@ -735,17 +725,13 @@ impl Flat {
|
||||
label: Some("Flatten section draws"),
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
|
||||
});
|
||||
if let Some(lists) = &lists {
|
||||
lists.dispatch(&mut pass);
|
||||
}
|
||||
pass.set_pipeline(&self.flatten);
|
||||
for (group, count) in &groups {
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
drop(pass);
|
||||
let list_pages = lists.iter().flat_map(|l| l.calls().iter().map(|c| c.page));
|
||||
for page in self.calls.iter().map(|c| c.page).chain(list_pages) {
|
||||
for page in self.calls.iter().map(|c| c.page) {
|
||||
let table = self.pages[page as usize]
|
||||
.as_mut()
|
||||
.expect("a drawn page has its block table");
|
||||
@@ -786,27 +772,6 @@ impl Flat {
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
|
||||
/// Draws pass `group`'s calls of `lists` (`gpu_lists::Out::Indices`, prepared with
|
||||
/// this frame's `prepare`) with a flat pipeline set.
|
||||
pub fn draw_lists<'a>(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
lists: &super::gpu_lists::GpuLists,
|
||||
group: u8,
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
) {
|
||||
if !lists.prepared() {
|
||||
return;
|
||||
}
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for (k, call) in lists.calls().iter().enumerate() {
|
||||
if call.group == group {
|
||||
self.bind_page(pass, call.page, &vertices);
|
||||
lists.draw_indexed(pass, k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera's translucent calls.
|
||||
pub fn translucent_calls(&self) -> &[Call] {
|
||||
&self.calls[self.solid_calls..self.shadow_calls[0]]
|
||||
@@ -932,7 +897,6 @@ mod tests {
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
assert_eq!(total, 6 * 13);
|
||||
assert_eq!(phase1_calls, solid_calls);
|
||||
@@ -981,7 +945,6 @@ mod tests {
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
assert_eq!((plan.pass_calls, plan.solid_calls), ([3, 1], 4));
|
||||
let call = |page, first, count| Call { page, first, count };
|
||||
@@ -1017,7 +980,6 @@ mod tests {
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
assert_eq!((plan.solid_calls, plan.shadow_calls), (3, [4, 5]));
|
||||
let call = |page, first, count| Call { page, first, count };
|
||||
@@ -1046,7 +1008,6 @@ mod tests {
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
assert_eq!(
|
||||
plan,
|
||||
@@ -1164,7 +1125,6 @@ mod tests {
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
// Phase 2's draws as the test leaves them: instance counts of 0,
|
||||
// 1 where it saw the section.
|
||||
|
||||
@@ -1,764 +0,0 @@
|
||||
//! Phase 1's solid section draws made on the GPU (gpu_lists.wgsl).
|
||||
//!
|
||||
//! Each frame the render thread walked every section phase 1 draws through
|
||||
//! `mesher::Sides::visible_from` to find the runs of its quads that face the eye,
|
||||
//! and pushed one draw per run. Here it pushes one record per section instead:
|
||||
//! its row (where its quads start and how `Sides` sorted them, uploaded with its
|
||||
//! mesh), the call each of its parts draws in and its camera-relative origin. The
|
||||
//! GPU finds the same runs and writes, per call (one per pass group and arena
|
||||
//! page), either the runs' indices into the flat draws' index list
|
||||
//! (`Out::Indices`, drawn with `draw_indexed_indirect`), or one indirect draw per
|
||||
//! run (`Out::Draws`, drawn with `multi_draw_indexed_indirect_count`). Calls take
|
||||
//! room for the most their sections may write; the GPU counts what they wrote.
|
||||
//!
|
||||
//! Solid draws write depth and are tested against it, so their order within a
|
||||
//! call does not change the picture.
|
||||
use crate::mesher::{Part, Sides};
|
||||
|
||||
/// Words per row: first vertex, `Sides::gpu_row`, padding.
|
||||
const ROW: usize = 24;
|
||||
/// Records each workgroup of `sides_draws` takes.
|
||||
const GROUP: u32 = 64;
|
||||
/// Most workgroups along one dimension of a dispatch.
|
||||
const GROUP_LIMIT: u32 = 65535;
|
||||
|
||||
/// What the GPU writes for a frame's calls.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Out {
|
||||
/// Indices into the flat draws' list (`flat::Flat`), one indirect draw per call.
|
||||
Indices,
|
||||
/// `DrawIndexedIndirectArgs` per run, over the quad index pattern, with the
|
||||
/// section's instance; per call a count for `multi_draw_indexed_indirect_count`.
|
||||
Draws,
|
||||
}
|
||||
|
||||
/// A part of a section in a record: `Part` as gpu_lists.wgsl names it.
|
||||
fn part_code(part: Part) -> u32 {
|
||||
match part {
|
||||
Part::All => 0,
|
||||
Part::Opaque => 1,
|
||||
Part::Cutout => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// One of the frame's calls: what pass `group` draws of arena `page`, from
|
||||
/// `first` (an index, or a draw) with room for `count`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct ListCall {
|
||||
pub group: u8,
|
||||
pub page: u32,
|
||||
pub first: u32,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
pub(super) struct GpuLists {
|
||||
out: Out,
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
/// Per id: its first vertex and `Sides::gpu_row` (`ROW` words).
|
||||
rows: wgpu::Buffer,
|
||||
row_capacity: u64,
|
||||
free_ids: Vec<u32>,
|
||||
next_id: u32,
|
||||
/// The frame's sections: id, the call and part of each of its parts, its
|
||||
/// number of parts plus its instance times 4; then its origin.
|
||||
records: Vec<[u32; 8]>,
|
||||
record_buffer: wgpu::Buffer,
|
||||
record_capacity: u64,
|
||||
calls: Vec<ListCall>,
|
||||
/// `calls`' index by page and group in the band of distance the last
|
||||
/// section listed is in (`u32::MAX`: none yet).
|
||||
call_of: Vec<u32>,
|
||||
band: u32,
|
||||
call_data: Vec<[u32; 5]>,
|
||||
/// Per call `DrawIndexedIndirectArgs` (`Out::Indices`), or its count of
|
||||
/// draws first (`Out::Draws`); the GPU adds what it writes to the first word.
|
||||
call_buffer: wgpu::Buffer,
|
||||
call_capacity: u64,
|
||||
view: wgpu::Buffer,
|
||||
/// `Out::Draws`' draws.
|
||||
draws: wgpu::Buffer,
|
||||
draw_capacity: u64,
|
||||
group: Option<(wgpu::BindGroup, u32)>,
|
||||
/// The room the frame's calls take (indices, or draws), and the most they may.
|
||||
room: u64,
|
||||
cap: u64,
|
||||
}
|
||||
|
||||
fn buffer(
|
||||
device: &wgpu::Device,
|
||||
label: &str,
|
||||
size: u64,
|
||||
usage: wgpu::BufferUsages,
|
||||
) -> wgpu::Buffer {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size,
|
||||
// COPY_SRC: rows move to a larger buffer; tests read calls and draws back.
|
||||
usage: usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn entry(binding: u32, ty: wgpu::BufferBindingType) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl GpuLists {
|
||||
pub fn new(device: &wgpu::Device, out: Out) -> Self {
|
||||
let read = wgpu::BufferBindingType::Storage { read_only: true };
|
||||
let write = wgpu::BufferBindingType::Storage { read_only: false };
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Section lists"),
|
||||
entries: &[
|
||||
entry(0, read),
|
||||
entry(1, read),
|
||||
entry(2, wgpu::BufferBindingType::Uniform),
|
||||
entry(3, write),
|
||||
entry(4, write),
|
||||
],
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Section lists"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("gpu_lists.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Section lists"),
|
||||
layout: Some(
|
||||
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Section lists"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
module: &module,
|
||||
entry_point: Some(match out {
|
||||
Out::Indices => "flatten_sides",
|
||||
Out::Draws => "sides_draws",
|
||||
}),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let storage = wgpu::BufferUsages::STORAGE;
|
||||
let indirect = wgpu::BufferUsages::INDIRECT | storage;
|
||||
Self {
|
||||
out,
|
||||
pipeline,
|
||||
layout,
|
||||
rows: buffer(device, "Section rows", ROW as u64 * 4, storage),
|
||||
row_capacity: 1,
|
||||
free_ids: Vec::new(),
|
||||
next_id: 0,
|
||||
records: Vec::new(),
|
||||
record_buffer: buffer(device, "Sections listed", 32, storage),
|
||||
record_capacity: 1,
|
||||
calls: Vec::new(),
|
||||
call_of: Vec::new(),
|
||||
band: 0,
|
||||
call_data: Vec::new(),
|
||||
call_buffer: buffer(device, "Section list calls", 20, indirect),
|
||||
call_capacity: 1,
|
||||
view: buffer(
|
||||
device,
|
||||
"Section lists' eye",
|
||||
16,
|
||||
wgpu::BufferUsages::UNIFORM,
|
||||
),
|
||||
draws: buffer(device, "Section list draws", 20, indirect),
|
||||
draw_capacity: 1,
|
||||
group: None,
|
||||
room: 0,
|
||||
cap: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A mesh uploaded from vertex `start` of its page, its solid quads sorted as
|
||||
/// `sides` says: its id, whose row is written through `uploads` in `encoder`.
|
||||
pub fn add(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
start: u32,
|
||||
sides: &Sides,
|
||||
) -> u32 {
|
||||
let id = self.free_ids.pop().unwrap_or_else(|| {
|
||||
self.next_id += 1;
|
||||
self.next_id - 1
|
||||
});
|
||||
self.set(device, encoder, uploads, id, start, sides);
|
||||
id
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: u32) {
|
||||
self.free_ids.push(id);
|
||||
}
|
||||
|
||||
/// Mesh `id` now starts at vertex `start` (moved to another page).
|
||||
pub fn set(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
id: u32,
|
||||
start: u32,
|
||||
sides: &Sides,
|
||||
) {
|
||||
let need = u64::from(id) + 1;
|
||||
if need > self.row_capacity {
|
||||
let capacity = need.next_power_of_two().max(1024);
|
||||
let rows = buffer(
|
||||
device,
|
||||
"Section rows",
|
||||
capacity * ROW as u64 * 4,
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
// Earlier rows: uploads record their copies in `encoder`, so this one follows them.
|
||||
encoder.copy_buffer_to_buffer(
|
||||
&self.rows,
|
||||
0,
|
||||
&rows,
|
||||
0,
|
||||
self.row_capacity * ROW as u64 * 4,
|
||||
);
|
||||
self.rows = rows;
|
||||
self.row_capacity = capacity;
|
||||
}
|
||||
let mut row = [0u32; ROW];
|
||||
row[0] = start;
|
||||
row[1..23].copy_from_slice(&sides.gpu_row());
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
&self.rows,
|
||||
u64::from(id) * ROW as u64 * 4,
|
||||
bytemuck::cast_slice(&row),
|
||||
);
|
||||
}
|
||||
|
||||
/// Starts the frame's lists.
|
||||
pub fn clear(&mut self, device: &wgpu::Device) {
|
||||
self.records.clear();
|
||||
self.calls.clear();
|
||||
self.call_of.fill(u32::MAX);
|
||||
self.band = 0;
|
||||
self.room = 0;
|
||||
self.group = None;
|
||||
// A quarter of a binding: indices share theirs with the CPU's draws.
|
||||
let binding = device.limits().max_storage_buffer_binding_size;
|
||||
self.cap = match self.out {
|
||||
Out::Indices => binding / 16,
|
||||
Out::Draws => binding / 20 / 4,
|
||||
};
|
||||
}
|
||||
|
||||
/// Section `id`, in `page`, at `origin` from the camera and `distance`
|
||||
/// (squared, as bits) from it, instance `instance` of the frame's list:
|
||||
/// each of `parts` (pass group, part) draws the quads of the part that face
|
||||
/// the eye. False, listing nothing, when the frame's lists hold all the room
|
||||
/// they may: the caller draws it.
|
||||
///
|
||||
/// Sections come nearest first. Each band of distance gets calls of its own,
|
||||
/// drawn in that order, as the CPU's lists keep them (`page_runs`): within
|
||||
/// a call the GPU writes sections in no set order, and cutout quads, which
|
||||
/// tile GPUs can't hide before shading, then draw mostly front to back.
|
||||
pub fn list(
|
||||
&mut self,
|
||||
id: u32,
|
||||
page: u32,
|
||||
distance: u64,
|
||||
sides: &Sides,
|
||||
parts: &[(u8, Part)],
|
||||
origin: [f32; 3],
|
||||
instance: u32,
|
||||
) -> bool {
|
||||
let parts = &parts[..parts.len().min(2)];
|
||||
let mut counts = [0; 2];
|
||||
for (count, &(_, part)) in counts.iter_mut().zip(parts) {
|
||||
*count = match self.out {
|
||||
Out::Indices => sides.part_quads(part) * 6,
|
||||
Out::Draws => sides.part_runs(part),
|
||||
};
|
||||
}
|
||||
let room = u64::from(counts[0]) + u64::from(counts[1]);
|
||||
if self.room + room > self.cap || instance >= 1 << 30 {
|
||||
return false;
|
||||
}
|
||||
self.room += room;
|
||||
let band = super::page_runs::band(distance) as u32;
|
||||
if band != self.band {
|
||||
self.band = band;
|
||||
self.call_of.fill(u32::MAX);
|
||||
}
|
||||
let mut record = [id, 0, 0, parts.len() as u32 | instance << 2, 0, 0, 0, 0];
|
||||
for (k, &(group, part)) in parts.iter().enumerate() {
|
||||
let key = page as usize * 2 + usize::from(group.min(1));
|
||||
if self.call_of.len() <= key {
|
||||
self.call_of.resize(key + 1, u32::MAX);
|
||||
}
|
||||
if self.call_of[key] == u32::MAX {
|
||||
self.call_of[key] = self.calls.len() as u32;
|
||||
self.calls.push(ListCall {
|
||||
group,
|
||||
page,
|
||||
first: 0,
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
let call = self.call_of[key];
|
||||
self.calls[call as usize].count += counts[k];
|
||||
record[1 + k] = call | part_code(part) << 28;
|
||||
}
|
||||
for (to, o) in record[4..7].iter_mut().zip(origin) {
|
||||
*to = o.to_bits();
|
||||
}
|
||||
self.records.push(record);
|
||||
true
|
||||
}
|
||||
|
||||
/// The sections listed this frame.
|
||||
pub fn listed(&self) -> usize {
|
||||
self.records.len()
|
||||
}
|
||||
|
||||
/// The frame's calls, with their rooms once laid out (`rooms`).
|
||||
pub fn calls(&self) -> &[ListCall] {
|
||||
&self.calls
|
||||
}
|
||||
|
||||
/// Lays the calls' rooms out one after the other from the start: the
|
||||
/// room they take (indices, or draws).
|
||||
pub fn rooms(&mut self) -> u64 {
|
||||
let mut room = 0u64;
|
||||
for call in &mut self.calls {
|
||||
call.first = room as u32;
|
||||
room += u64::from(call.count);
|
||||
}
|
||||
room
|
||||
}
|
||||
|
||||
/// Stages the frame's sections, calls and eye (camera-relative; none: every
|
||||
/// quad faces it), and binds them for `dispatch` writing into `indices`
|
||||
/// (`Out::Indices`) or into the lists' own draws. After `rooms`.
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
indices: Option<&wgpu::Buffer>,
|
||||
eye: Option<[f32; 3]>,
|
||||
) {
|
||||
self.group = None;
|
||||
let records = self.records.len() as u64;
|
||||
if records == 0 {
|
||||
return;
|
||||
}
|
||||
if records > self.record_capacity {
|
||||
self.record_capacity = records.next_power_of_two();
|
||||
self.record_buffer = buffer(
|
||||
device,
|
||||
"Sections listed",
|
||||
self.record_capacity * 32,
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
}
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
&self.record_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.records),
|
||||
);
|
||||
self.call_data.clear();
|
||||
self.call_data
|
||||
.extend(self.calls.iter().map(|c| [0, 1, c.first, 0, 0]));
|
||||
let calls = self.call_data.len() as u64;
|
||||
let indirect = wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::STORAGE;
|
||||
if calls > self.call_capacity {
|
||||
self.call_capacity = calls.next_power_of_two();
|
||||
self.call_buffer = buffer(
|
||||
device,
|
||||
"Section list calls",
|
||||
self.call_capacity * 20,
|
||||
indirect,
|
||||
);
|
||||
}
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
&self.call_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.call_data),
|
||||
);
|
||||
let room = self
|
||||
.calls
|
||||
.last()
|
||||
.map_or(0, |c| u64::from(c.first + c.count));
|
||||
if self.out == Out::Draws && room > self.draw_capacity {
|
||||
self.draw_capacity = room.next_power_of_two();
|
||||
self.draws = buffer(
|
||||
device,
|
||||
"Section list draws",
|
||||
self.draw_capacity * 20,
|
||||
indirect,
|
||||
);
|
||||
}
|
||||
let [x, y, z] = eye.unwrap_or_default();
|
||||
let view = [x, y, z, if eye.is_some() { 1.0 } else { 0.0 }];
|
||||
uploads.buffer(encoder, &self.view, 0, bytemuck::cast_slice(&view));
|
||||
let out = match self.out {
|
||||
Out::Indices => indices.expect("flat draws' index list"),
|
||||
Out::Draws => &self.draws,
|
||||
};
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Section lists"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &self.record_buffer,
|
||||
offset: 0,
|
||||
size: wgpu::BufferSize::new(records * 32),
|
||||
}),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: self.rows.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.view.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: self.call_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: out.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.group = Some((group, records as u32));
|
||||
}
|
||||
|
||||
/// Writes the frame's lists (after `prepare`).
|
||||
pub fn dispatch(&self, pass: &mut wgpu::ComputePass<'_>) {
|
||||
let Some((group, records)) = &self.group else {
|
||||
return;
|
||||
};
|
||||
let groups = match self.out {
|
||||
Out::Indices => *records,
|
||||
Out::Draws => records.div_ceil(GROUP),
|
||||
};
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups(groups.min(GROUP_LIMIT), groups.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
|
||||
pub fn out(&self) -> Out {
|
||||
self.out
|
||||
}
|
||||
|
||||
/// Whether `dispatch` writes this frame's lists.
|
||||
pub fn prepared(&self) -> bool {
|
||||
self.group.is_some()
|
||||
}
|
||||
|
||||
/// Draws call `k` (`Out::Indices`) with a pipeline, its page's vertices and
|
||||
/// the index list bound.
|
||||
pub fn draw_indexed(&self, pass: &mut wgpu::RenderPass<'_>, k: usize) {
|
||||
pass.draw_indexed_indirect(&self.call_buffer, k as u64 * 20);
|
||||
}
|
||||
|
||||
/// Draws call `k` (`Out::Draws`) with a pipeline, its page's vertices, the
|
||||
/// quad indices and the frame's instances bound.
|
||||
pub fn multi_draw(&self, pass: &mut wgpu::RenderPass<'_>, k: usize) {
|
||||
let call = self.calls[k];
|
||||
pass.multi_draw_indexed_indirect_count(
|
||||
&self.draws,
|
||||
u64::from(call.first) * 20,
|
||||
&self.call_buffer,
|
||||
k as u64 * 20,
|
||||
call.count,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mesher::{Part, Sides};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
struct Section {
|
||||
sides: Sides,
|
||||
origin: [f32; 3],
|
||||
start: u32,
|
||||
page: u32,
|
||||
parts: &'static [(u8, Part)],
|
||||
}
|
||||
|
||||
/// Random sorts: some parts empty, grouped or not, split or not, planes in
|
||||
/// and out of a section, each listed in one of the three ways it can be.
|
||||
fn sections(count: usize) -> Vec<Section> {
|
||||
let mut seed = 0x2545_f491_4f6c_dd1du64;
|
||||
let mut next = |n: u32| {
|
||||
seed = seed
|
||||
.wrapping_mul(6_364_136_223_846_793_005)
|
||||
.wrapping_add(1_442_695_040_888_963_407);
|
||||
((seed >> 33) % u64::from(n)) as u32
|
||||
};
|
||||
(0..count)
|
||||
.map(|_| {
|
||||
let mut ends = [0; 14];
|
||||
let mut end = 0;
|
||||
for e in &mut ends {
|
||||
if next(3) > 0 {
|
||||
end += next(40);
|
||||
}
|
||||
*e = end;
|
||||
}
|
||||
let planes = std::array::from_fn(|_| next(17) as f32 + next(16) as f32 / 16.0);
|
||||
Section {
|
||||
sides: Sides::from_parts(ends, next(2) == 0, next(4) > 0, planes),
|
||||
origin: [0, 1, 2].map(|_| next(129) as f32 - 64.0),
|
||||
start: next(10_000) * 4,
|
||||
page: next(3),
|
||||
parts: match next(3) {
|
||||
0 => &[(0, Part::All)],
|
||||
1 => &[(0, Part::Opaque), (1, Part::Cutout)],
|
||||
_ => &[(1, Part::All)],
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The runs the CPU finds for a section's part, as `Gfx` drew them.
|
||||
fn cpu_runs(s: &Section, part: Part, eye: Option<[f32; 3]>) -> Vec<(u32, u32)> {
|
||||
let mut runs = Vec::new();
|
||||
let draw = |first, quads| runs.push((first, quads));
|
||||
match eye {
|
||||
Some(eye) => {
|
||||
let eye = [0, 1, 2].map(|a| eye[a] - s.origin[a]);
|
||||
s.sides.visible_from(eye, eye[1], part, draw);
|
||||
}
|
||||
None => s.sides.every(part, draw),
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
/// Lists `sections` for `out` from each of a few eyes; per eye, the
|
||||
/// lists, the calls' words and the written words.
|
||||
fn run(
|
||||
out: Out,
|
||||
sections: &[Section],
|
||||
) -> Option<Vec<(Option<[f32; 3]>, GpuLists, Vec<u32>, Vec<u32>)>> {
|
||||
let (device, queue) = device()?;
|
||||
let mut results = Vec::new();
|
||||
for eye in [
|
||||
Some([3.5f32, 70.25, -12.0]),
|
||||
Some([-40.0, -3.0, 50.5]),
|
||||
None,
|
||||
] {
|
||||
let mut lists = GpuLists::new(&device, out);
|
||||
lists.clear(&device);
|
||||
let mut rows = vec![0u32; sections.len() * ROW];
|
||||
for (id, s) in sections.iter().enumerate() {
|
||||
rows[id * ROW] = s.start;
|
||||
rows[id * ROW + 1..id * ROW + 23].copy_from_slice(&s.sides.gpu_row());
|
||||
// Nearest first, over several bands.
|
||||
let distance = (id as f64 * 2.0).powi(2).to_bits();
|
||||
let instance = id as u32 + 7;
|
||||
assert!(lists.list(
|
||||
id as u32, s.page, distance, &s.sides, s.parts, s.origin, instance
|
||||
));
|
||||
}
|
||||
let room = lists.rooms();
|
||||
let init = |contents: &[u8], usage| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents,
|
||||
usage,
|
||||
})
|
||||
};
|
||||
let storage = wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC;
|
||||
lists.rows = init(bytemuck::cast_slice(&rows), storage);
|
||||
let words = match out {
|
||||
Out::Indices => room,
|
||||
Out::Draws => room * 5,
|
||||
};
|
||||
let indices = init(&vec![0xffu8; words as usize * 4], storage);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let mut uploads = super::super::upload::Uploads::new(&device);
|
||||
lists.prepare(&device, &mut encoder, &mut uploads, Some(&indices), eye);
|
||||
uploads.finish(&encoder);
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
lists.dispatch(&mut pass);
|
||||
}
|
||||
queue.submit([encoder.finish()]);
|
||||
let written = match out {
|
||||
Out::Indices => &indices,
|
||||
Out::Draws => &lists.draws,
|
||||
};
|
||||
let read_back = |buffer: &wgpu::Buffer, words: u64| -> Vec<u32> {
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: words * 4,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(buffer, 0, &read, 0, words * 4);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec()
|
||||
};
|
||||
let calls = read_back(&lists.call_buffer, lists.calls.len() as u64 * 5);
|
||||
let words = read_back(written, words);
|
||||
results.push((eye, lists, calls, words));
|
||||
}
|
||||
Some(results)
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return None;
|
||||
};
|
||||
let device = pollster::block_on(adapter.request_device(&Default::default())).ok();
|
||||
if device.is_none() {
|
||||
eprintln!("no GPU device: skipped");
|
||||
}
|
||||
device
|
||||
}
|
||||
|
||||
/// The call section `i`'s `k`th part draws in, as listed.
|
||||
fn call_of(lists: &GpuLists, i: usize, k: usize) -> usize {
|
||||
(lists.records[i][1 + k] & 0x0fff_ffff) as usize
|
||||
}
|
||||
|
||||
/// Each band of distance and page gets a pass's calls of its own, in the
|
||||
/// order sections come (nearest first), and sections of one share them.
|
||||
#[test]
|
||||
fn calls_go_by_band_then_page_nearest_first() {
|
||||
let Some((device, _)) = device() else {
|
||||
return;
|
||||
};
|
||||
let mut lists = GpuLists::new(&device, Out::Indices);
|
||||
lists.clear(&device);
|
||||
let sides = Sides::unsorted(4);
|
||||
let parts = &[(0, Part::Opaque), (1, Part::Cutout)];
|
||||
for (page, blocks) in [(1, 10.0), (0, 20.0), (1, 30.0), (1, 100.0), (0, 130.0)] {
|
||||
let distance = f64::powi(blocks, 2).to_bits();
|
||||
assert!(lists.list(0, page, distance, &sides, parts, [0.0; 3], 0));
|
||||
}
|
||||
let calls: Vec<_> = lists
|
||||
.calls()
|
||||
.iter()
|
||||
.map(|c| (c.group, c.page, c.count))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
calls,
|
||||
[
|
||||
(0, 1, 48),
|
||||
(1, 1, 48),
|
||||
(0, 0, 24),
|
||||
(1, 0, 24),
|
||||
(0, 1, 24),
|
||||
(1, 1, 24),
|
||||
(0, 0, 24),
|
||||
(1, 0, 24)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// `flatten_sides` writes, in each call's room, the indices of exactly the
|
||||
/// runs `Sides::visible_from` finds for each listed section's parts (in
|
||||
/// some order: workgroups take room as they come), and counts them.
|
||||
#[test]
|
||||
fn flatten_sides_writes_the_runs_the_cpu_finds() {
|
||||
let sections = sections(200);
|
||||
let Some(results) = run(Out::Indices, §ions) else {
|
||||
return;
|
||||
};
|
||||
for (eye, lists, calls, out) in results {
|
||||
let mut expected = vec![Vec::new(); lists.calls.len()];
|
||||
for (i, s) in sections.iter().enumerate() {
|
||||
for (k, &(_, part)) in s.parts.iter().enumerate() {
|
||||
let call = call_of(&lists, i, k);
|
||||
for (first, quads) in cpu_runs(s, part, eye) {
|
||||
for k in 0..quads * 6 {
|
||||
let corner = [0, 1, 2, 0, 2, 3][(k % 6) as usize];
|
||||
expected[call].push(s.start + first * 4 + (k / 6) * 4 + corner);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (k, call) in lists.calls.iter().enumerate() {
|
||||
let count = calls[k * 5] as usize;
|
||||
assert_eq!(
|
||||
count,
|
||||
expected[k].len(),
|
||||
"call {k}'s index count, eye {eye:?}"
|
||||
);
|
||||
assert_eq!(calls[k * 5 + 2], call.first);
|
||||
let mut got = out[call.first as usize..call.first as usize + count].to_vec();
|
||||
got.sort_unstable();
|
||||
expected[k].sort_unstable();
|
||||
assert_eq!(got, expected[k], "call {k}'s indices, eye {eye:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `sides_draws` writes, in each call's room, one draw per run the CPU finds
|
||||
/// (as the CPU lists them: the run's indices over the quad pattern, from its
|
||||
/// first vertex, with the section's instance), and counts them; the room
|
||||
/// holds them all.
|
||||
#[test]
|
||||
fn sides_draws_writes_a_draw_per_run_the_cpu_finds() {
|
||||
let sections = sections(200);
|
||||
let Some(results) = run(Out::Draws, §ions) else {
|
||||
return;
|
||||
};
|
||||
for (eye, lists, calls, out) in results {
|
||||
let mut expected = vec![Vec::new(); lists.calls.len()];
|
||||
for (i, s) in sections.iter().enumerate() {
|
||||
for (k, &(_, part)) in s.parts.iter().enumerate() {
|
||||
let call = call_of(&lists, i, k);
|
||||
for (first, quads) in cpu_runs(s, part, eye) {
|
||||
expected[call].push([quads * 6, 1, 0, s.start + first * 4, i as u32 + 7]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (k, call) in lists.calls.iter().enumerate() {
|
||||
let count = calls[k * 5] as usize;
|
||||
assert_eq!(
|
||||
count,
|
||||
expected[k].len(),
|
||||
"call {k}'s draw count, eye {eye:?}"
|
||||
);
|
||||
assert!(count as u32 <= call.count, "call {k} wrote past its room");
|
||||
let first = call.first as usize * 5;
|
||||
let mut got: Vec<[u32; 5]> = out[first..first + count * 5]
|
||||
.chunks(5)
|
||||
.map(|d| d.try_into().unwrap())
|
||||
.collect();
|
||||
got.sort_unstable();
|
||||
expected[k].sort_unstable();
|
||||
assert_eq!(got, expected[k], "call {k}'s draws, eye {eye:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
// Phase 1's solid section draws made on the GPU (gfx/gpu_lists.rs): per section
|
||||
// listed, the runs of its quads that face the eye, found as
|
||||
// `mesher::Sides::visible_from` finds them on the CPU, written into the call
|
||||
// each of its parts draws in (one per pass group and arena page) after taking
|
||||
// room there: their indices (`flatten_sides`, for the flat draws' index list,
|
||||
// as `flatten_seen` does for occlusion culling's phase 2), or one indirect draw
|
||||
// per run (`sides_draws`, for `multi_draw_indexed_indirect_count`). Solid draws
|
||||
// write depth and are tested against it, so their order within a call does not
|
||||
// change the picture.
|
||||
struct View {
|
||||
// The eye relative to the camera, and 1.0 when there is one (`sides_on`);
|
||||
// without, every bucket is drawn (`Sides::every`).
|
||||
eye: vec3<f32>,
|
||||
has_eye: f32,
|
||||
}
|
||||
// Per section listed: its row's id; for each of its parts (up to two) the call
|
||||
// it draws in, with the part (`PART_*`) in the top bits; its number of parts
|
||||
// plus 4 times its instance. Then its origin relative to the camera.
|
||||
struct Record {
|
||||
words: vec4<u32>,
|
||||
origin: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<storage, read> records: array<Record>;
|
||||
// Per id, `ROW` words: its first vertex in its page, then `Sides::gpu_row`
|
||||
// (14 ends, 7 planes' bits, flags).
|
||||
@group(0) @binding(1) var<storage, read> rows: array<u32>;
|
||||
@group(0) @binding(2) var<uniform> view: View;
|
||||
// Per call five words: its count (indices, or draws), which starts at 0, then
|
||||
// for `flatten_sides` the rest of its `DrawIndexedIndirectArgs`; word 2 is
|
||||
// where its room starts.
|
||||
@group(0) @binding(3) var<storage, read_write> calls: array<atomic<u32>>;
|
||||
// Indices (`flatten_sides`) or `DrawIndexedIndirectArgs` (`sides_draws`).
|
||||
@group(0) @binding(4) var<storage, read_write> out: array<u32>;
|
||||
|
||||
const ROW: u32 = 24u;
|
||||
const LANES: u32 = 64u;
|
||||
const PART_ALL: u32 = 0u;
|
||||
const PART_OPAQUE: u32 = 1u;
|
||||
const PART_CUTOUT: u32 = 2u;
|
||||
const CALL_MASK: u32 = 0x0fffffffu;
|
||||
// `sides::MARGIN`.
|
||||
const MARGIN: f32 = 0.0625;
|
||||
|
||||
// Index `k` of a solid draw: quad k / 6's vertices 0 1 2 0 2 3.
|
||||
fn quad(k: u32) -> u32 {
|
||||
let c = k % 6u;
|
||||
return (k / 6u) * 4u + select(select(c - 2u, 0u, c == 3u), c, c < 3u);
|
||||
}
|
||||
|
||||
fn plane(row: u32, bucket: u32) -> f32 {
|
||||
return bitcast<f32>(rows[row + 15u + bucket]);
|
||||
}
|
||||
|
||||
// `Sides::visible_from`'s `seen`, buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y.
|
||||
fn seen(row: u32, bucket: u32, eye: vec3<f32>) -> bool {
|
||||
var faces = true;
|
||||
if view.has_eye != 0.0 {
|
||||
switch bucket {
|
||||
case 0u: { faces = eye.x > plane(row, 0u) - MARGIN; }
|
||||
case 1u: { faces = eye.z > plane(row, 1u) - MARGIN; }
|
||||
case 3u: { faces = eye.y > plane(row, 3u) - MARGIN; }
|
||||
case 4u: { faces = eye.z < plane(row, 4u) + MARGIN; }
|
||||
case 5u: { faces = eye.x < plane(row, 5u) + MARGIN; }
|
||||
case 6u: { faces = eye.y < plane(row, 6u) + MARGIN; }
|
||||
default: {}
|
||||
}
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
// A part's runs (first quad, quads): at most 7, as a run ends at a part left
|
||||
// out and there are 14.
|
||||
struct Runs {
|
||||
n: u32,
|
||||
quads: u32,
|
||||
runs: array<vec2<u32>, 7>,
|
||||
}
|
||||
|
||||
// `Sides::runs` for `part` of the mesh at `row`, seen from `eye` (relative to
|
||||
// the section): empty parts do not break a run.
|
||||
fn part_runs(row: u32, part: u32, eye: vec3<f32>) -> Runs {
|
||||
var r: Runs;
|
||||
let grouped = (rows[row + 22u] & 1u) != 0u;
|
||||
let split = (rows[row + 22u] & 2u) != 0u;
|
||||
var start = 0u;
|
||||
var open = false;
|
||||
var first = 0u;
|
||||
for (var n = 0u; n < 14u; n++) {
|
||||
let end = rows[row + 1u + n];
|
||||
if end == start {
|
||||
continue;
|
||||
}
|
||||
var bucket: u32;
|
||||
var cutout: bool;
|
||||
if grouped {
|
||||
bucket = n % 7u;
|
||||
cutout = n >= 7u;
|
||||
} else {
|
||||
bucket = n / 2u;
|
||||
cutout = n % 2u == 1u;
|
||||
}
|
||||
let wanted = part == PART_ALL || !split
|
||||
|| (part == PART_OPAQUE && !cutout) || (part == PART_CUTOUT && cutout);
|
||||
let take = wanted && seen(row, bucket, eye);
|
||||
if take && !open {
|
||||
open = true;
|
||||
first = start;
|
||||
} else if !take && open {
|
||||
r.runs[r.n] = vec2(first, start - first);
|
||||
r.quads += start - first;
|
||||
r.n++;
|
||||
open = false;
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
if open {
|
||||
r.runs[r.n] = vec2(first, start - first);
|
||||
r.quads += start - first;
|
||||
r.n++;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
fn record_index(group: vec3<u32>) -> u32 {
|
||||
return group.y * 65535u + group.x;
|
||||
}
|
||||
|
||||
// The eye relative to `record`'s section.
|
||||
fn eye_of(record: Record) -> vec3<f32> {
|
||||
return view.eye - record.origin.xyz;
|
||||
}
|
||||
|
||||
fn part_word(record: Record, p: u32) -> u32 {
|
||||
return select(record.words.z, record.words.y, p == 0u);
|
||||
}
|
||||
|
||||
// Lane 0's runs per part, and where each part's indices start.
|
||||
var<workgroup> found: array<Runs, 2>;
|
||||
var<workgroup> at: array<u32, 2>;
|
||||
|
||||
// One workgroup per record: lane 0 finds the runs and takes room for their
|
||||
// indices, then every lane writes them.
|
||||
@compute @workgroup_size(64)
|
||||
fn flatten_sides(@builtin(workgroup_id) group: vec3<u32>,
|
||||
@builtin(local_invocation_index) lane: u32) {
|
||||
let r = record_index(group);
|
||||
if r >= arrayLength(&records) {
|
||||
return;
|
||||
}
|
||||
let record = records[r];
|
||||
let row = record.words.x * ROW;
|
||||
let parts = record.words.w & 3u;
|
||||
if lane == 0u {
|
||||
let eye = eye_of(record);
|
||||
for (var p = 0u; p < parts; p++) {
|
||||
let word = part_word(record, p);
|
||||
let runs = part_runs(row, word >> 28u, eye);
|
||||
let call = (word & CALL_MASK) * 5u;
|
||||
var begin = 0u;
|
||||
if runs.quads > 0u {
|
||||
begin = atomicLoad(&calls[call + 2u]) + atomicAdd(&calls[call], runs.quads * 6u);
|
||||
}
|
||||
found[p] = runs;
|
||||
at[p] = begin;
|
||||
}
|
||||
}
|
||||
workgroupBarrier();
|
||||
let base = rows[row];
|
||||
for (var p = 0u; p < parts; p++) {
|
||||
var to = at[p];
|
||||
for (var k = 0u; k < found[p].n; k++) {
|
||||
let run = found[p].runs[k];
|
||||
let first = base + run.x * 4u;
|
||||
let count = run.y * 6u;
|
||||
for (var i = lane; i < count; i += LANES) {
|
||||
out[to + i] = first + quad(i);
|
||||
}
|
||||
to += count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One record per invocation: a draw per run, as the CPU lists them
|
||||
// (`[quads * 6, 1, 0, first vertex, instance]` over the quad index pattern).
|
||||
@compute @workgroup_size(64)
|
||||
fn sides_draws(@builtin(workgroup_id) group: vec3<u32>,
|
||||
@builtin(local_invocation_index) lane: u32) {
|
||||
let r = record_index(group) * LANES + lane;
|
||||
if r >= arrayLength(&records) {
|
||||
return;
|
||||
}
|
||||
let record = records[r];
|
||||
let row = record.words.x * ROW;
|
||||
let parts = record.words.w & 3u;
|
||||
let instance = record.words.w >> 2u;
|
||||
let eye = eye_of(record);
|
||||
let base = rows[row];
|
||||
for (var p = 0u; p < parts; p++) {
|
||||
let word = part_word(record, p);
|
||||
let runs = part_runs(row, word >> 28u, eye);
|
||||
if runs.n == 0u {
|
||||
continue;
|
||||
}
|
||||
let call = (word & CALL_MASK) * 5u;
|
||||
var to = (atomicLoad(&calls[call + 2u]) + atomicAdd(&calls[call], runs.n)) * 5u;
|
||||
for (var k = 0u; k < runs.n; k++) {
|
||||
let run = runs.runs[k];
|
||||
out[to] = run.y * 6u;
|
||||
out[to + 1u] = 1u;
|
||||
out[to + 2u] = 0u;
|
||||
out[to + 3u] = base + run.x * 4u;
|
||||
out[to + 4u] = instance;
|
||||
to += 5u;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -969,6 +969,7 @@ mod initial_history_tests {
|
||||
emissive_layers: &[],
|
||||
shadow_only: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
clouds: None,
|
||||
|
||||
@@ -29,11 +29,6 @@ pub(super) struct Run {
|
||||
pub tag: [u32; 3],
|
||||
}
|
||||
|
||||
/// The band of a section `distance` from the camera (squared, as bits).
|
||||
pub(super) fn band(distance: u64) -> u64 {
|
||||
(f64::from_bits(distance).sqrt() / BAND) as u64
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct PageRuns {
|
||||
runs: Vec<Run>,
|
||||
@@ -63,7 +58,7 @@ impl PageRuns {
|
||||
tag: [u32; 3],
|
||||
) {
|
||||
if len > 0 {
|
||||
let band = band(distance);
|
||||
let band = (f64::from_bits(distance).sqrt() / BAND) as u64;
|
||||
self.runs.push(Run {
|
||||
key: u64::from(group) << GROUP_SHIFT | band << 32 | u64::from(page),
|
||||
start: start as u32,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -10,7 +10,7 @@ mod sides;
|
||||
use crate::connected::{Pick, TileLayer};
|
||||
use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::Biomes,
|
||||
biome::{Biomes, GrassModifier},
|
||||
blocks::{BlockFacts, RenderShape},
|
||||
chunk::{Chunk, Light, MIN_Y},
|
||||
graphics::LeafCullingMode,
|
||||
@@ -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();
|
||||
opaque_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 non-opaque cells by row (`y << 4 | z`, bit x) from the padded
|
||||
/// opacity rows (`MeshScratch::opaque`).
|
||||
fn open_rows(opaque: &[u32]) -> [u16; 256] {
|
||||
std::array::from_fn(|r| {
|
||||
let (y, z) = ((r >> 4) as i32, (r & 15) as i32);
|
||||
!(opaque[((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
|
||||
@@ -509,6 +560,9 @@ pub struct MeshTables {
|
||||
/// 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,
|
||||
@@ -678,6 +732,10 @@ 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")?,
|
||||
@@ -851,68 +909,103 @@ impl Padded {
|
||||
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 and copied row by row;
|
||||
// the border cells of the neighbouring sections are read one by one,
|
||||
// each run of them from a section found once.
|
||||
// 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);
|
||||
}
|
||||
// The x runs of a padded row by the chunk column they fall in.
|
||||
const RUNS: [(usize, i32, i32); 3] = [(0, -B, 0), (1, 0, 16), (2, 16, 16 + B)];
|
||||
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,
|
||||
// `Chunk::get` and `Chunk::light` outside the column's sections.
|
||||
Some(c) => {
|
||||
let s = (wy - c.min_y()) >> 4;
|
||||
match c.sections.get(s as usize).filter(|_| s >= 0) {
|
||||
Some(section) => Source::Section(section),
|
||||
None => Source::Outside(if s < 0 { 0 } else { 0xF0 }),
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
for z in -B..16 + B {
|
||||
let row = pi(-B, y, z);
|
||||
let rz = ((z + 16) >> 4) as usize;
|
||||
for (rx, x0, x1) in RUNS {
|
||||
let (start, end) = (row + (x0 + B) as usize, row + (x1 + B) as usize);
|
||||
let (blocks, light) = (&mut blocks[start..end], &mut light[start..end]);
|
||||
let Some(c) = region[rz][rx] else {
|
||||
match 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.
|
||||
Some(c) => {
|
||||
let ez = z.clamp(0, 15);
|
||||
for (k, x) in (x0..x1).enumerate() {
|
||||
let ex = x.clamp(0, 15);
|
||||
blocks[k] = c.get(ex, wy, ez);
|
||||
light[k] = c.light(ex, wy, ez);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
blocks.fill(0);
|
||||
light.fill(0xF0);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
// `Chunk::get` and `Chunk::light` with the section found once.
|
||||
let s = (wy - c.min_y()) >> 4;
|
||||
let Some(section) = c.sections.get(s as usize).filter(|_| s >= 0) else {
|
||||
blocks.fill(0);
|
||||
light.fill(if s < 0 { 0 } else { 0xF0 });
|
||||
continue;
|
||||
};
|
||||
let at = crate::chunk::index(x0, wy, z);
|
||||
if rx == 1 && rz == 1 && (0..16).contains(&y) {
|
||||
let own_row = (y << 8 | z << 4) as usize;
|
||||
blocks.copy_from_slice(&own[own_row..own_row + 16]);
|
||||
} else {
|
||||
section.blocks.get_run(at, blocks);
|
||||
}
|
||||
match §ion.light {
|
||||
Light::Uniform(v) => light.fill(*v),
|
||||
Light::Full(all) => light.copy_from_slice(&all[at..at + light.len()]),
|
||||
}
|
||||
}
|
||||
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 §ion.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).
|
||||
@@ -920,7 +1013,6 @@ 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`.
|
||||
@@ -949,6 +1041,8 @@ fn hidden(rows: &[u32], x: i32, y: i32, z: i32) -> bool {
|
||||
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
|
||||
@@ -1061,6 +1155,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(
|
||||
@@ -1078,6 +1174,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`
|
||||
@@ -1116,8 +1213,52 @@ 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(|| {
|
||||
@@ -1255,9 +1396,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);
|
||||
opaque_rows(pad, &self.tables.solid, &mut scratch.opaque);
|
||||
let opaque = &scratch.opaque;
|
||||
out.visibility = visibility_rows(&open_rows(opaque));
|
||||
let base_y = center.min_y() + sy * 16;
|
||||
if self.leaf_culling.is_interior() {
|
||||
scratch
|
||||
@@ -1272,6 +1413,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 {
|
||||
@@ -1329,6 +1471,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);
|
||||
|
||||
@@ -181,7 +181,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.opaque.capacity(), 0);
|
||||
}
|
||||
fresh.sort_solid();
|
||||
recycled.sort_solid();
|
||||
|
||||
@@ -103,22 +103,6 @@ impl Sides {
|
||||
}
|
||||
}
|
||||
|
||||
/// A sort with these parts' `ends` and `planes`, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_parts(
|
||||
ends: [u32; 14],
|
||||
grouped: bool,
|
||||
split: bool,
|
||||
planes: [f32; 7],
|
||||
) -> Self {
|
||||
Self {
|
||||
ends,
|
||||
grouped,
|
||||
split,
|
||||
planes,
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of solid quads the buckets hold.
|
||||
pub fn quads(&self) -> u32 {
|
||||
self.ends[13]
|
||||
@@ -144,56 +128,11 @@ impl Sides {
|
||||
self.runs(seen, part, draw);
|
||||
}
|
||||
|
||||
/// How many quads `part` draws facing every way (`every`).
|
||||
pub fn part_quads(&self, part: Part) -> u32 {
|
||||
let mut quads = 0;
|
||||
self.every(part, |_, count| quads += count);
|
||||
quads
|
||||
}
|
||||
|
||||
/// The facts `visible_from` reads, for the GPU to find the same runs
|
||||
/// (gpu_lists.wgsl): the parts' ends, the planes' bits,
|
||||
/// then 1 when grouped plus 2 when split.
|
||||
pub fn gpu_row(&self) -> [u32; 22] {
|
||||
let mut row = [0; 22];
|
||||
row[..14].copy_from_slice(&self.ends);
|
||||
for (to, plane) in row[14..21].iter_mut().zip(self.planes) {
|
||||
*to = plane.to_bits();
|
||||
}
|
||||
row[21] = u32::from(self.grouped) | u32::from(self.split) << 1;
|
||||
row
|
||||
}
|
||||
|
||||
/// The runs of `part`'s quads facing every way.
|
||||
pub fn every(&self, part: Part, draw: impl FnMut(u32, u32)) {
|
||||
self.runs(|_| true, part, draw);
|
||||
}
|
||||
|
||||
/// Whether `part` draws the cutout (or opaque) quads of a bucket.
|
||||
fn wants(&self, part: Part, cutout: bool) -> bool {
|
||||
match part {
|
||||
Part::All => true,
|
||||
_ if !self.split => true,
|
||||
Part::Opaque => !cutout,
|
||||
Part::Cutout => cutout,
|
||||
}
|
||||
}
|
||||
|
||||
/// The most runs `visible_from` finds for `part`, whatever the eye: one per
|
||||
/// part of a bucket it draws that holds quads, and at most 7, as runs are
|
||||
/// apart (gpu_lists.wgsl's room for its draws).
|
||||
pub fn part_runs(&self, part: Part) -> u32 {
|
||||
let mut start = 0;
|
||||
let mut runs = 0;
|
||||
for (n, &end) in self.ends.iter().enumerate() {
|
||||
if end != start && self.wants(part, slot(self.grouped, n).1) {
|
||||
runs += 1;
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
runs.min(7)
|
||||
}
|
||||
|
||||
/// Calls `draw` for each run of `part`'s quads in the buckets `seen` keeps.
|
||||
fn runs(&self, seen: impl Fn(usize) -> bool, part: Part, mut draw: impl FnMut(u32, u32)) {
|
||||
let mut start = 0;
|
||||
@@ -203,7 +142,13 @@ impl Sides {
|
||||
continue;
|
||||
}
|
||||
let (bucket, cutout) = slot(self.grouped, n);
|
||||
match (self.wants(part, cutout) && seen(bucket), run) {
|
||||
let wanted = match part {
|
||||
Part::All => true,
|
||||
_ if !self.split => true,
|
||||
Part::Opaque => !cutout,
|
||||
Part::Cutout => cutout,
|
||||
};
|
||||
match (wanted && seen(bucket), run) {
|
||||
(true, None) => run = Some(start),
|
||||
(false, Some(first)) => {
|
||||
draw(first, start - first);
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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, ®ion[*i]);
|
||||
writes
|
||||
.block_entities
|
||||
.extend(generator.final_block_entities(®ion[*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(®ion), 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(®ion), 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, ®ion[*i]);
|
||||
writes.block_entities.extend(
|
||||
generator.final_block_entities(®ion[*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(®ion), &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(®ion),
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
@@ -2343,6 +2344,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.
|
||||
@@ -2439,6 +2444,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,
|
||||
@@ -15548,6 +15555,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),
|
||||
@@ -15631,6 +15639,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,
|
||||
@@ -15711,6 +15720,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
|
||||
}
|
||||
@@ -18261,6 +18282,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(),
|
||||
@@ -18322,6 +18345,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(),
|
||||
@@ -19371,6 +19395,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
|
||||
@@ -19953,6 +19980,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
entities: &[],
|
||||
entity_motion: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
colored_lights: &[],
|
||||
@@ -20689,9 +20717,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();
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -88,6 +88,9 @@ 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) |
|
||||
| `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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
|
||||
+236
-19
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
Reference in new issue
Block a user