Compare commits

..
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 a7bb2259d6 Renderer: fall back when an adapter can't present to the window (#13493)
On a hybrid laptop under Wayland, request_adapter(HighPerformance) returns
the NVIDIA GPU, whose Surface::configure then fails with "Invalid surface";
wgpu's default handler panicked outside the device search.

Surface setup is now part of open_backend: an adapter whose surface offers
no format is skipped before its device opens, and configure runs in error
scopes (Renderer::configure). On either failure the backend's other
adapters are enumerated and the best one that presents is tried
(pick_adapter: HighPerformance order, never one already tried, software
only on the last try); with none, the search goes on to the next backend.
One warning line names the skipped adapter and why. The normal path opens
one device and configures once, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:10:36 +01:00
Ludovic Marie a7df9ec019 Merge pull request #6808 from horizon-rust/perf/entities-stress
🤖 Agent: stress scenes for perf_bench (Horizon and vanilla); Horizon beats vanilla x2.2 (armor stands) to x10.8 (signs) on the M4 Pro; Python tests 607 ok, the bench mod compiles against 26.3.
2026-11-02 07:15:49 +01:00
Ludovic Marie 76aa871c1e Merge remote-tracking branch 'origin/main' into perf/entities-stress
# Conflicts:
#	mods/horizon/src/bench/java/dev/horizonlab/bench/VanillaBench.java
#	tools/perf_bench.py
2026-11-02 07:10:57 +01:00
Ludovic Marie 3a264943c7 Merge pull request #6672 from horizon-rust/perf/mesh-tint-uniform
Mesher: one-biome sections skip the tint blend
2026-11-02 06:43:12 +01:00
Ludovic Marie 5afa1de5f7 Merge pull request #6744 from horizon-rust/perf/gen-coordinator
🤖 Agent: perf-gen-light verified this head: pr_ready --quick green, chunk_generation/decoration/server_world_gen/worldgen pass on playhorizon, generation order unchanged (differential test); coordinator CPU -30% at RD 32.
2026-11-02 06:21:15 +01:00
Ludovic Marie 40e044da77 Merge pull request #6757 from horizon-rust/perf/entity-gpu-skin
🤖 Agent: mobs posed on the GPU, byte-identical to the CPU path for every mob type; stress_mobs frame CPU 4.52 → 3.26 ms; verify passed.
2026-11-02 06:19:04 +01:00
Ludovic Marie e7275ddee3 Merge pull request #6756 from horizon-rust/perf/entity-skin-pass
🤖 Agent: renderer half of GPU posing, unused until #6757; shader output byte-identical to the CPU reference on a real adapter; verify passed.
2026-11-02 06:09:56 +01:00
Ludovic Marie f7b73ed5f6 Merge pull request #6755 from horizon-rust/perf/remote-mob-index
🤖 Agent: mob lookups by id go through a validated id → slot map; 3000 mobs: 5.5 ms → 0.0125 ms per tick of searching; verify passed.
2026-11-02 06:09:52 +01:00
Ludovic Marie 1c2ed6b613 Merge pull request #6535 from horizon-rust/perf/mesh-faces
Mesher: 15% less CPU per section again (face shortcut, bit-row VisGraph), identical meshes
2026-11-02 06:02:47 +01:00
Ludovic Marie c2d352e0a3 Merge pull request #6619 from horizon-rust/perf/bench-server-join
🤖 Agent: verified by perf-net-client: main merged in, pr_ready --quick on playhorizon all green, CI verify green, and the Mac runs in the body used this branch end to end (Horizon, vanilla and vanilla-mods clients on a local Horizon server and on a vanilla 26.3 server.jar, with and without 100 bots). Tooling only (skip-release). Merging.
2026-11-02 05:48:50 +01:00
Ludovic Marie 64c0291da2 Merge pull request #6436 from horizon-rust/perf/net-replay-bench
🤖 Agent: verified by perf-net-client: main merged in, pr_ready --quick on playhorizon all green (horizon-bots + horizon-protocol 173 tests), CI verify and parity green. Tooling only (skip-release). Merging.
2026-11-02 05:46:49 +01:00
Ludovic Marie 4f1ebf7deb Merge pull request #6754 from horizon-rust/fix/item-entity-render-distance
🤖 Agent: dropped items now use vanilla's 64 × 0.25-box × view-scale range (32 blocks at RD 16), not 64 blocks; verify passed.
2026-11-02 05:46:46 +01:00
Ludovic Marie f4ee603baf Merge remote-tracking branch 'origin/main' into perf/entities-stress 2026-11-02 05:46:29 +01:00
Ludovic Marie daf749bbcd Merge branch 'perf/entity-skin-pass' into perf/entity-gpu-skin 2026-11-02 05:45:12 +01:00
Ludovic MarieandClaude Opus 5.5 27877c639e Renderer: the pack uniforms test frame sends no posed entities
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:44:55 +01:00
Ludovic Marie dbb78b8a4c Merge remote-tracking branch 'origin/main' into perf/entity-skin-pass 2026-11-02 05:44:40 +01:00
Ludovic MarieandClaude Opus 5.5 c4f9b6fc7f tests: the remote motion fixture looks mobs up through MobIndex
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:44:22 +01:00
Ludovic MarieandClaude Opus 5.5 03d815532e Keep mobs.rs's modules in order
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:26:30 +01:00
Ludovic Marie e12185820a Merge pull request #6613 from horizon-rust/perf/mob-poses-no-alloc
🤖 Agent: poses keyed by Fx hash, vertex hash identical; fmt, clippy and mobs:: (339) pass on the main-merged head.
2026-11-02 05:14:28 +01:00
Ludovic MarieandClaude Opus 5.5 8f91c1534e Format the item render range
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:12:04 +01:00
Ludovic MarieandClaude Opus 5.5 00e0ecbdaf Pose mob models on the GPU
Every frame the CPU posed each mob's model parts and pushed 44-byte vertices
for every face (mobs::render::draw_normals): with 1000 mobs in view that was
most of the frame's CPU time. mobs/skin.rs now flattens each model once for
the renderer's store (gfx/entity_skin.rs, the previous commit) and, per
frame, walks the poses like draw_normals but sends one matrix pair per drawn
part and one job per contiguous run; the compute pass writes the vertices.
Culling uses the posed parts' bounds; a culled mob's jobs are taken back.

The vertices are bit for bit the CPU path's: a test poses every mob type at
several ages and yaws both ways and compares them. A mob takes the GPU path
only when all its draws can, so the draw order stays: no custom (CEM)
models, translucency, full-bright layers, energy swirl, armour extras,
outlines, and not players. HORIZON_NO_ENTITY_SKIN=1 turns it off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:56:02 +01:00
Ludovic MarieandClaude Opus 5.5 3e642d0acd Renderer: a compute pass that poses entity models
The first half of posing mobs on the GPU (the game side follows): the
renderer keeps a store of model-space vertices (position / 16, UV, axis
normal, part index) that grows as models are handed over, and each frame
runs gfx/entity_skin.wgsl over the frame's jobs (one run of a model's
vertices with its parts' matrices, texture rect, scroll, colour, light and
overlay). The shader writes mesher::Vertex words into the entity buffer
ahead of the CPU-built entities, so every pass that reads the buffer (OIT,
shadows, HDR marks) sees the same vertices as before.

`expand` is the CPU reference: glam's transform order, axis normals taken as
exact normal-matrix columns and `as i8` normal packing, so the shader's
output is bit for bit the CPU path's (test on a real device). The pass needs
compute and four storage buffers, and stays off with a shader pack, a native
pack, frame generation or HORIZON_NO_ENTITY_SKIN=1. Nothing sends frames yet
(`Frame::skin` is None).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:55:54 +01:00
Ludovic Marie f31198cbf7 Merge pull request #6721 from horizon-rust/perf-jobs/streaming
Streaming: run on the shared worker pool
2026-11-02 04:55:16 +01:00
Ludovic Marie b82e2a4b35 Merge pull request #6719 from horizon-rust/perf-jobs/pool
🤖 Agent: verified: verify green; pr_ready --quick green (horizon-threads 16 tests, 602 Python tests); the pool has no user yet (skip-release). First of the job-system stack (#6721, #6722).
2026-11-02 04:42:43 +01:00
Ludovic Marie c9434646de Merge pull request #6725 from horizon-rust/perf/low-end-menu
🤖 Agent: verified: pr_ready green (990 render tests, clippy, rustfmt), new tests pass on llvmpipe, blurred menus 2.6 → 7.5 FPS on llvmpipe, GPUs unchanged. Merging.
2026-11-02 04:41:06 +01:00
Ludovic Marie 9784cf63fd Merge remote-tracking branch 'origin/main' into perf/net-replay-bench 2026-11-02 04:35:39 +01:00
Ludovic Marie 84ad92068f Merge remote-tracking branch 'origin/main' into perf/bench-server-join 2026-11-02 04:35:22 +01:00
Ludovic Marie 79a60dea88 Merge remote-tracking branch 'origin/main' into perf/remote-mob-index 2026-11-02 04:33:50 +01:00
Ludovic Marie 38ce16338c Merge remote-tracking branch 'origin/main' into fix/item-entity-render-distance 2026-11-02 04:33:30 +01:00
Ludovic Marie 2eb2bc1538 Merge remote-tracking branch 'origin/main' into perf/mob-poses-no-alloc 2026-11-02 04:33:12 +01:00
Ludovic MarieandClaude Opus 5.5 d5be28d945 Streaming: run on the shared worker pool
Every streamer (the client's, and one per level on the server thread)
started N-1 worker threads of its own: a single player world ran two to
four such pools at once. They now queue their jobs on the process's
pool (horizon_threads::jobs), sized from the workers setting before any
world opens. Saves and freed meshes go first, then jobs by column
distance, as before; per-worker generator state, face cache and mesh
scratch move into the streamer's per-worker slots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:12:25 +01:00
Ludovic MarieandClaude Opus 5.5 8a6024f8e1 horizon-threads: one shared worker pool for the process (jobs)
A pool of worker threads sized once for the machine, which every source
of background work can share instead of starting its own threads: each
source keeps its queue (so it can rank its jobs again and drop the ones
that left the view) and a free worker runs the most urgent queued job
of any source, by class (urgent, near, far) then the source's key. Far
work never holds every worker. A notify is two atomic operations while
the workers are busy, so the thread queuing work (a frame, a tick) never
waits behind a worker's choice. Per-worker scratch belongs to the
source; dropping a registration waits for its running jobs; idle
workers free their buffers. No user yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:12:25 +01:00
Ludovic MarieandClaude Opus 5.5 2b874aea1c Menu blur: half size on a software adapter
On WARP or llvmpipe the menu blur took ~250 ms of CPU per frame at 1440x900:
every blurred menu (options, world list, first-launch screen) ran at 3 FPS.
On a software adapter it now runs its six passes at half the width and height
with half the radius, the same blur over a quarter of the pixels. GPUs blur as
before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:12:08 +01:00
Ludovic MarieandClaude Opus 5.5 3011a79600 Renderer: a chosen graphics API with no GPU tries the others before software
An Intel HD 2500 on Windows has DirectX 12 only through WARP and its GPU through
OpenGL. With DirectX 12 chosen (or set by the launcher), Horizon rendered the
title and every menu on the CPU: 90% CPU and minutes to load (#5185). Auto
already tried every backend on a GPU first (#3985); a chosen API now does too
before it falls back to a software adapter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:12:08 +01:00
Ludovic MarieandClaude Opus 5.5 50fdee8614 perf_bench report: runs without --server (tests' Namespace) have no server condition
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:48:06 +01:00
Ludovic MarieandClaude Opus 5.5 9762e4f3db Mesher: one-biome sections skip the tint blend
When every quart cell the section's blend squares can zoom to holds one
biome, each block's blended tint is that biome's colour (the mean of equal
colours), so Tints::get returns it without the per-block zoom and the
(2r+1)^2 sum. Swamp grass, whose colour follows noise, keeps the blend.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:08:48 +01:00
Ludovic MarieandClaude Opus 5.5 21832e9390 Mesher: face visibility shortcut, call-free rounding, layer-wise fill, bit-row VisGraph
- A face whose neighbour shows a full occlusion face toward it is hidden:
  should_render_face's first test, read from a per-state bit table without
  the visibility lookups.
- round_to_u16 and gray compute f32::round/floor's results without the
  roundf/floorf calls x86-64's baseline makes of them (tested against them).
- Padded::fill finds each chunk column's section once per layer and copies
  fixed-length runs.
- VisGraph's flood fill works on bit rows: each row filled along its open
  runs at once (an occluded fill), regions grown by sweeps over the rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:08:23 +01:00
Ludovic MarieandClaude Opus 5.5 2b78d2638e perf_bench --server-bots N: a crowd of horizon-bots and a scene that flies over them
horizon-bots prints the first bot's spawn position, so the bench knows where
the crowd walks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:00:45 +01:00
Ludovic MarieandClaude Opus 5.5 20e429c3f8 Find a multiplayer client's mobs by id through an index
A client looked a mob up by a linear search of its mob list for every
movement, data, motion, attribute, equipment and event packet. A Mob is
~3.3 KB, so every compared id is its own cache line: in a 3000-mob farm,
the ~1000 movement packets of one tick took 5.5 ms of searching, 0.11 ms
with 1000 mobs. MobIndex keeps id -> position in a map and checks each hit
against the list; a miss falls back to the linear search and rebuilds the
map, so the list keeps changing freely in its ~35 places and the answer is
always the linear search's (ids are unique: spawn_entity drops the old mob
first).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:56:10 +01:00
Ludovic MarieandClaude Opus 5.5 c481f9c68a Pose mob models without allocating part names
anim::Poses keyed every part by an owned String, so each mob drew a new
String per model part for its body and for every layer (wool, clothes,
armour, eyes) every frame: tens of thousands of small allocations a frame
in a mob farm. It now keys parts by the Fx hash of their name (Fx maps
and sets), with the same lookups and first-name-wins rule; a test checks
that no two part names of any vanilla model share a key.

An ignored benchmark poses the stress_mobs farm (1000 mobs of 8 kinds)
through render_with and hashes the vertices: the same hash before and
after, 6.93 -> 5.73 ms of thread CPU per frame, the body's poses
1.28 -> 0.65 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:44:18 +01:00
Ludovic MarieandClaude Opus 5.5 912941a8f7 Streaming coordinator: decoration and generation scheduling cost less
While a world loads, Streaming::update ran on one thread and at render
distance 32 took most of it: DecorationOrder::blocked asked waits_for
(36 lookups) for 24 neighbours of every wanted chunk on every update, in a
SipHash map, and schedule_generation scanned every column of the area each
time a worker finished.
- Decoration ranks use the Fx hasher; waits_for answers are kept for the
  pass (nothing it reads changes in it but decorated, which updates them).
- GenerationScan keeps the sorted candidates of a full scan and takes the
  next batches from them while the ranges stay the same and no column left;
  a removal, a dropped proto chunk or a range change scans again.
The decoration and generation order are unchanged (differential test
against the full scan through jobs finishing, columns and protos leaving
and the centre moving).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:27:23 +01:00
Ludovic MarieandClaude Opus 5.5 b73e5b7c98 perf_bench --server horizon|vanilla: clients join a local dedicated server
Every client of the run (Horizon, vanilla, vanilla with the performance
mods) joins the same fresh local server instead of creating a world:
seed 1234, creative, offline, the bench player an operator so the scenes'
/tp and /time work. A new server world gets an unmeasured warm-up join, so
measured joins compare the clients, not the server's generation.
startup.join_ms is connect to loaded. The vanilla driver connects with
ConnectScreen.startConnecting when its script names a server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:05:03 +01:00
Ludovic MarieandClaude Opus 5.5 7ec58998d6 Draw dropped items only as far as vanilla does
ItemEntity keeps Entity.shouldRenderAtSqrDistance: 64 times its 0.25 box,
times the view scale, so 24 blocks at render distance 12, 32 at 16 and 40
from 20 on, measured from its current position. Horizon drew them up to 64
blocks, which posed and uploaded every item of a big storage or farm area
that vanilla leaves out. Item pickup particles keep their 64 blocks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:52:53 +01:00
Ludovic MarieandClaude Opus 5.5 7a52fde28f perf_bench: many-entity stress scenes, per-scene client tick and memory
tools/stress_scenes.py builds nine scenes above the spawn with vanilla
commands (1000 mobs, 2000 dropped items, 512 item frames, 500 armor
stands, 800 containers, 687 signs, 300 banners, steady particles from
redstone-powered command blocks, and an empty baseline) and removes each
afterwards. The same lines run in Horizon (horizon-ctl command) and in
vanilla (the driver mod's new per-scene setup and cleanup).
perf_bench --scenes stress holds each scene still and reports frame CPU,
client tick time (vanilla: Minecraft.tick through a new mixin) and
resident memory per scene.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:48:34 +01:00
Ludovic MarieandClaude Opus 5.5 af3a264af1 Net replay bench: horizon-bots --capture, net_replay example
horizon-bots --capture FILE records the first bot's play packets; the
horizon-protocol example net_replay sends them through an in-memory
connection as single player, an offline server and an online-mode server
send them, and prints the CPU of ConnReader::recv, of decoding per packet
type and of parsing chunk sections.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:46:52 +01:00
48 changed files with 4679 additions and 1944 deletions

No files matched your search

+60 -2
View File
@@ -14,7 +14,8 @@
//!
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
//!
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
//! It prints one JSON line a second (bots online, chunks received), one with the first bot's spawn position, and
//! writes a summary with `--report`.
//! A bot moves only while it holds the chunk it stands in: vanilla's client stops ticking its player in a chunk
//! it hasn't received, so a player can't outrun the server's chunk sending, on any server.
//! Each bot also keeps the chunks it holds, as a client's chunk cache does, so the report tells chunks that
@@ -102,6 +103,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
to test what a server takes
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
--report FILE a JSON summary at the end (- prints it after the per-second lines)
--capture FILE record the first bot's play packets (milliseconds since it joined, id, body)
for crates/horizon-protocol/examples/net_replay.rs
";
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -177,6 +180,8 @@ struct Options {
/// The `minecraft:brand` to send, if any.
brand: Option<String>,
report: Option<String>,
/// --capture: where the first bot's play packets go.
capture: Option<String>,
/// How many times as fast as vanilla's 20 Hz the bots tick (a timer cheat above 1).
timer: f64,
}
@@ -207,6 +212,7 @@ fn options() -> Result<Options, String> {
click: None,
name: "Bot".into(),
report: None,
capture: None,
brand: None,
timer: 1.0,
};
@@ -256,6 +262,7 @@ fn options() -> Result<Options, String> {
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--capture" => o.capture = Some(value),
"--brand" => o.brand = Some(value),
"--give" => {
for part in value.split(',') {
@@ -819,12 +826,53 @@ impl BatchSize {
}
}
/// `--capture`: one record per play packet, little-endian: u32 milliseconds since the
/// bot joined, i32 packet id, u32 body length, then the body (without the id).
struct Capture {
out: std::io::BufWriter<std::fs::File>,
start: Instant,
/// The second last flushed: the process may end before the reader thread does.
flushed: u32,
}
impl Capture {
fn create(path: &str) -> Option<Self> {
match std::fs::File::create(path) {
Ok(file) => Some(Self {
out: std::io::BufWriter::new(file),
start: Instant::now(),
flushed: 0,
}),
Err(e) => {
eprintln!("--capture {path}: {e}");
None
}
}
}
fn record(&mut self, id: i32, body: &[u8]) {
use std::io::Write;
let ms = self.start.elapsed().as_millis() as u32;
let mut head = [0u8; 12];
head[..4].copy_from_slice(&ms.to_le_bytes());
head[4..8].copy_from_slice(&id.to_le_bytes());
head[8..].copy_from_slice(&(body.len() as u32).to_le_bytes());
let _ = self
.out
.write_all(&head)
.and_then(|()| self.out.write_all(body));
if ms / 1000 != self.flushed {
self.flushed = ms / 1000;
let _ = self.out.flush();
}
}
}
/// Reads the play state: answers what a client must answer, counts chunks, records teleports.
fn read(
mut reader: horizon_protocol::connection::ConnReader,
writer: Arc<Mutex<ConnWriter>>,
bot: Arc<Bot>,
track: bool,
mut capture: Option<Capture>,
) {
use ids::play::clientbound as cb;
use ids::play::serverbound as sb;
@@ -848,6 +896,9 @@ fn read(
return;
}
};
if let Some(c) = &mut capture {
c.record(id, &body);
}
let mut r = Reader::new(&body);
let sent = match id {
cb::KEEP_ALIVE => match r.i64() {
@@ -1007,7 +1058,10 @@ fn run_bot(
{
let (writer, bot) = (writer.clone(), bot.clone());
let track = behaviour == Behaviour::Fight;
thread::spawn(move || read(reader, writer, bot, track));
let capture = (index == 0)
.then(|| o.capture.as_deref().and_then(Capture::create))
.flatten();
thread::spawn(move || read(reader, writer, bot, track, capture));
}
use ids::play::serverbound as sb;
// Wait for the spawn position.
@@ -1025,6 +1079,10 @@ fn run_bot(
};
bot.joined_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
if index == 0 {
// Where the bots gather, for a client that comes to watch them (tools/perf_bench.py --server-bots).
println!("{}", json!({ "spawn": spawn }));
}
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
if !o.no_loaded {
send(raw(sb::PLAYER_LOADED, |_| {}));
+2 -1
View File
@@ -518,7 +518,8 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
}
}
/// Whether `name` is one of the streaming pool's threads, vanilla's `Util` worker threads.
/// Whether `name` is one of the shared worker pool's threads (`horizon_threads::jobs`), vanilla's
/// `Util` worker threads.
fn is_worker_thread(name: Option<&str>) -> bool {
name.is_some_and(|n| n.starts_with("horizon-worker-"))
}
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;
}
}
}
+2 -42
View File
@@ -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, &sections) 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, &sections) 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,
+157 -47
View File
@@ -4,6 +4,11 @@
//! framebuffer pixels wide, before the screen is drawn over it. Works in the
//! frame's own (gamma) values, as vanilla's main target does, and needs a
//! swapchain that can be copied from.
//!
//! On a software adapter (WARP, llvmpipe) the rounds run at half the frame's
//! width and height with half the radius: the same blur, a quarter of the
//! pixels. At full size they took 250 ms of a CPU per frame at 1440x900, and
//! every blurred menu ran at 3 FPS (#5185).
/// `blur.json`: the passes' directions, ping-ponging between the copied
/// frame and a swap texture; the last one writes the frame.
@@ -27,15 +32,20 @@ pub struct MenuBlur {
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
sampler: wgpu::Sampler,
/// One per pass, then the half-size copy's.
uniforms: Vec<wgpu::Buffer>,
/// The copied frame (`minecraft:main`) and `swap`.
/// The copied frame (`minecraft:main`) and `swap`; at half size, then the
/// frame copied at full size.
targets: Vec<(wgpu::Texture, wgpu::TextureView)>,
size: [u32; 2],
format: wgpu::TextureFormat,
/// Blur at half size (`new`'s `software`).
half: bool,
}
impl MenuBlur {
/// `format` is the swapchain's; the blur works in its non-sRGB view.
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
/// `format` is the swapchain's; the blur works in its non-sRGB view. `software`:
/// the adapter renders on the CPU, so the blur runs at half size.
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, software: bool) -> Self {
let format = format.remove_srgb_suffix();
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Menu blur"),
@@ -111,8 +121,7 @@ impl MenuBlur {
address_mode_v: wgpu::AddressMode::ClampToEdge,
..Default::default()
});
let uniforms = PASSES
.iter()
let uniforms = (0..=PASSES.len())
.map(|_| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Menu blur pass"),
@@ -130,6 +139,15 @@ impl MenuBlur {
targets: Vec::new(),
size: [0, 0],
format,
half: software,
}
}
/// The size the blur's rounds run at for a frame of `size`.
fn work_size(&self, size: [u32; 2]) -> [u32; 2] {
if self.half {
size.map(|s| s.div_ceil(2))
} else {
size
}
}
fn ensure(&mut self, device: &wgpu::Device, size: [u32; 2]) {
@@ -137,8 +155,15 @@ impl MenuBlur {
return;
}
self.size = size;
self.targets = (0..2)
.map(|_| {
let work = self.work_size(size);
let sizes = if self.half {
vec![work, work, size]
} else {
vec![work, work]
};
self.targets = sizes
.into_iter()
.map(|size| {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Menu blur target"),
size: wgpu::Extent3d {
@@ -180,65 +205,112 @@ impl MenuBlur {
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
self.ensure(device, size);
let work = self.work_size(size);
let copy = if self.half { 2 } else { 0 };
encoder.copy_texture_to_texture(
frame.as_image_copy(),
self.targets[0].0.as_image_copy(),
self.targets[copy].0.as_image_copy(),
wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
);
let radius = if self.half { radius / 2.0 } else { radius };
if self.half {
// A radius of 0 samples each half-size pixel's 2x2 block at its shared
// corner: their mean.
let data = Uniform {
size_dir: [size[0] as f32, size[1] as f32, 0.0, 0.0],
radius: [0.0; 4],
};
let (source, output) = (&self.targets[2].1, &self.targets[0].1);
self.pass(
device,
queue,
encoder,
PASSES.len(),
data,
source,
output,
None,
);
}
for (i, dir) in PASSES.iter().enumerate() {
let data = Uniform {
size_dir: [size[0] as f32, size[1] as f32, dir[0], dir[1]],
size_dir: [work[0] as f32, work[1] as f32, dir[0], dir[1]],
radius: [radius, 0.0, 0.0, 0.0],
};
queue.write_buffer(&self.uniforms[i], 0, bytemuck::bytes_of(&data));
let source = &self.targets[i % 2].1;
// The last pass writes the frame; from half size it samples up as it blurs.
let output = if i + 1 == PASSES.len() {
target
} else {
&self.targets[(i + 1) % 2].1
};
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Menu blur pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.uniforms[i].as_entire_binding(),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: output,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu blur pass"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Menu blur")),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
self.pass(
device,
queue,
encoder,
i,
data,
source,
output,
timing.as_deref_mut(),
);
}
}
/// One `box_blur` pass from `source` into `output` with uniform buffer `slot`.
#[allow(clippy::too_many_arguments)]
fn pass(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
slot: usize,
data: Uniform,
source: &wgpu::TextureView,
output: &wgpu::TextureView,
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
queue.write_buffer(&self.uniforms[slot], 0, bytemuck::bytes_of(&data));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Menu blur pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.uniforms[slot].as_entire_binding(),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: output,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu blur pass"),
timestamp_writes: timing.and_then(|t| t.writes("Menu blur")),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
}
#[cfg(test)]
@@ -318,7 +390,7 @@ mod tests {
else {
return;
};
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb);
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, false);
let size = [64, 8];
let first = blurred(&device, &queue, &mut blur, size);
// The stripes are blurred into greys.
@@ -327,6 +399,44 @@ mod tests {
assert_eq!(blurred(&device, &queue, &mut blur, size), first);
}
/// On a software adapter the blur runs at half size and still blurs the
/// frame much as at full size: stripes two pixels wide turn to an even grey.
#[test]
fn the_half_size_blur_blurs_like_the_full_size_one() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
let size = [64, 8];
let full = blurred(
&device,
&queue,
&mut MenuBlur::new(&device, format, false),
size,
);
let half = blurred(
&device,
&queue,
&mut MenuBlur::new(&device, format, true),
size,
);
// Away from the edges, both are within a few levels of the stripes' mean grey.
for row in 0..size[1] as usize {
for x in 8..56 {
let at = (row * size[0] as usize + x) * 4;
for (name, frame) in [("full", &full), ("half", &half)] {
let v = frame[at];
assert!((120..=136).contains(&v), "{name} size: {v} at ({x}, {row})");
}
}
}
}
#[test]
fn box_blur_shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("menu_blur.wgsl")).unwrap();
+274 -127
View File
@@ -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 &section.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 &section.light {
Light::Uniform(v) => light.fill(*v),
Light::Full(all) => light.copy_from_slice(&all[at..at + light.len()]),
}
}
Source::Outside(v) => {
blocks.fill(0);
light.fill(*v);
}
// A neighbour not loaded continues this chunk's edge column. As lit
// air, the outermost chunks' water drew a wall toward it (fluids show
// both sides), a light sheet in the water where far terrain carries
// on. Vanilla draws no chunk without its neighbours.
Source::Missing => match center {
Some(c) => {
let ez = z.clamp(0, 15);
for (k, (block, light)) in blocks.iter_mut().zip(light.iter_mut()).enumerate() {
let ex = (x0 + k as i32).clamp(0, 15);
*block = c.get(ex, wy, ez);
*light = c.light(ex, wy, ez);
}
}
None => {
blocks.fill(0);
light.fill(0xF0);
}
},
}
}
/// Per-worker temporary arrays. They never hold resource-pack references or
/// geometry still owned by the renderer, and total about 150 KiB per worker
/// (the leaf mask adds 8,000 cells, depths and a queue).
@@ -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)];
+358 -221
View File
@@ -6,21 +6,19 @@ use crate::fast_hash::{FxHashMap, FxHashSet};
use crate::section_mask::SectionMask;
use crate::{
chunk::Chunk,
chunk_map::ChunkMembershipDelta,
graphics::LeafCullingMode,
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, borrow},
world::World,
};
use horizon_core::biome::BiomeZoom;
use horizon_threads::jobs::{Class, PerWorker, Rank, Registration, Source};
use horizon_world::block_render::FaceVisibility;
use horizon_world::worldgen::decoration_order::DecorationOrder;
use std::{
collections::{BinaryHeap, HashSet, VecDeque},
sync::{
Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
thread,
sync::{Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError},
};
/// How long a worker waits with nothing to do before it gives back its
@@ -33,7 +31,8 @@ pub type Proto = Box<dyn std::any::Any + Send>;
/// (biomes, noise, surface, carvers), decoration of a 3x3 neighbourhood
/// (FEATURES, which may write into neighbours), then conversion of a chunk
/// whose eight neighbours are decorated into a final column. Implementations
/// must be thread-safe; each worker thread owns one `Worker`.
/// must be thread-safe; each worker of the shared pool
/// (`horizon_threads::jobs`) keeps one `Worker` per streamer.
pub trait ChunkGenerator: Send + Sync + 'static {
type Worker: Send + 'static;
fn worker(&self) -> Self::Worker;
@@ -276,8 +275,6 @@ impl Ord for Queued {
}
struct Shared {
queue: Mutex<BinaryHeap<Queued>>,
ready: Condvar,
stop: AtomicBool,
/// What meshing reads (block models and sprites), swapped when resource
/// packs reload; each mesh job takes the current one.
context: RwLock<Arc<WorkerContext>>,
@@ -285,11 +282,6 @@ struct Shared {
/// Only workers use this pool; frames never wait for its lock. Retaining
/// cleared buffers avoids racing the renderer for the process heap.
meshes: Mutex<MeshPool>,
/// Workers that have freed their buffers ([`IDLE_RELEASE`]) and taken no
/// job since: the last of them gives the freed memory back to the OS.
released: AtomicUsize,
/// The workers that started.
workers: AtomicUsize,
}
#[derive(Default)]
struct MeshPool {
@@ -364,7 +356,11 @@ pub struct Streaming {
/// next arrive, though read from the save (`World::loaded_chunks`).
dropped_new: FxHashSet<(i32, i32)>,
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
threads: Vec<thread::JoinHandle<()>>,
/// This streamer's place in the shared pool (`horizon_threads::jobs`);
/// dropping it waits for its running jobs.
jobs: Registration,
/// The workers its in-flight work is sized for.
workers: usize,
order: u64,
/// Ticks since the columns and protos that fell out of range were swept.
scan_age: u8,
@@ -476,6 +472,14 @@ pub struct Streaming {
/// Snapshot only after a scan found no missing candidates. Public range fields
/// and `World::chunks` can change without going through Streaming, so compare
/// their values and structural membership token rather than a frame timeout.
///
/// A scan also keeps every candidate it found, nearest first (`plan`): while
/// the ranges stay the same and no column leaves (`World::chunks` removals, or
/// `invalidate` for proto chunks dropped), no column becomes wanted, so the
/// next batches are the plan's entries still missing. A full scan looks at
/// every column of the areas (thousands at render distance 32), and one ran
/// whenever a worker finished: it took a quarter of the thread that runs
/// `Streaming::update` while a world loaded.
#[derive(Default)]
struct GenerationScan {
exhausted: bool,
@@ -485,17 +489,52 @@ struct GenerationScan {
forced: HashSet<(i32, i32)>,
band: Option<Band>,
chunk_revision: u64,
plan: Vec<(i64, i32, i32)>,
/// Entries before it are no longer missing.
next: usize,
/// What `plan` was made for: the ranges, the loading screen and
/// decoration; the chunk map's revision is `chunk_revision`.
plan_basis: Option<(bool, bool)>,
}
impl GenerationScan {
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
self.exhausted
&& self.center == ranges.center
fn same_ranges(&self, ranges: &Ranges<'_>) -> bool {
self.center == ranges.center
&& self.radius == ranges.radius
&& self.extra == ranges.extra
&& self.forced == *ranges.forced
&& self.band.as_ref() == ranges.band
}
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
self.exhausted
&& self.same_ranges(ranges)
&& self.chunk_revision == world.chunks.membership_revision()
}
/// A column may be wanted again (a proto chunk was dropped): the next
/// call scans the areas again.
fn invalidate(&mut self) {
self.exhausted = false;
self.plan_basis = None;
}
/// Whether `plan` still holds every candidate: same ranges, and no
/// column left the chunk map since it was made.
fn plan_holds(&mut self, input: &GenerationInputs<'_>) -> bool {
if self.plan_basis != Some((input.loading, input.decorates))
|| !self.same_ranges(&input.ranges)
{
return false;
}
let revision = input.world.chunks.membership_revision();
if revision != self.chunk_revision {
let Some(mut changes) = input.world.chunks.deltas_since(self.chunk_revision) else {
return false;
};
if !changes.all(|(_, change)| matches!(change, ChunkMembershipDelta::Added(_))) {
return false;
}
self.chunk_revision = revision;
}
true
}
fn remember(&mut self, ranges: Ranges<'_>, world: &World) {
self.exhausted = true;
self.center = ranges.center;
@@ -533,6 +572,30 @@ impl GenerationScan {
{
return None;
}
let missing = |&(_, x, z): &(i64, i32, i32)| {
!input.world.chunks.contains_key(&(x, z))
&& !input.generating.contains(&(x, z))
&& !input.protos.contains_key(&(x, z))
&& !input.finishing.contains(&(x, z))
};
if self.plan_holds(&input) {
while self.next < self.plan.len() && !missing(&self.plan[self.next]) {
self.next += 1;
}
let room = capacity - input.generating.len();
let batch: Vec<_> = self.plan[self.next..]
.iter()
.filter(|c| missing(c))
.take(room)
.copied()
.collect();
// Past the plan's end, the full scan below decides whether
// anything is left (and remembers when nothing is).
if !batch.is_empty() {
self.exhausted = false;
return Some(batch);
}
}
{
let mut wanted = Vec::new();
// Each area's square of columns, as one run per row. Players'
@@ -592,28 +655,196 @@ impl GenerationScan {
}
}
}
let ranges = Ranges {
center: input.ranges.center,
radius: input.ranges.radius,
extra: input.ranges.extra,
forced: input.ranges.forced,
cover: input.ranges.cover,
band: input.ranges.band,
};
self.remember(ranges, input.world);
if wanted.is_empty() {
let ranges = Ranges {
center: input.ranges.center,
radius: input.ranges.radius,
extra: input.ranges.extra,
forced: input.ranges.forced,
cover: input.ranges.cover,
band: input.ranges.band,
};
self.remember(ranges, input.world);
self.plan.clear();
self.plan_basis = None;
} else {
// A bounded batch may leave candidates behind. Keep scanning
// as capacity opens, even if no range or membership changed.
// A bounded batch may leave candidates behind: the next calls
// take them from the plan as capacity opens.
self.exhausted = false;
self.plan_basis = Some((input.loading, input.decorates));
}
wanted.sort_unstable();
wanted.dedup();
self.plan.clear();
self.plan.extend_from_slice(&wanted);
self.next = 0;
wanted.truncate(capacity - input.generating.len());
Some(wanted)
}
}
}
/// What a pool worker keeps for one streamer between its jobs: the
/// generator's worker, and the face visibility table and its occlusion
/// cache (like vanilla's per-thread cache: cloning it per job copied every
/// state and face shape) and the mesher's scratch.
struct WorkerLocal<W> {
worker: W,
faces: Option<(Arc<MeshTables>, FaceVisibility)>,
mesh_scratch: MeshScratch,
}
/// A streamer's jobs, run by the process's shared pool (`horizon_threads::jobs`).
struct Jobs<G: ChunkGenerator> {
shared: Arc<Shared>,
generator: Arc<G>,
light: Arc<LightTables>,
locals: PerWorker<WorkerLocal<G::Worker>>,
}
impl<G: ChunkGenerator> Source for Jobs<G> {
fn next(&self) -> Option<Rank> {
let queue = self.shared.queue.lock().unwrap();
queue.peek().map(|q| {
// Jobs without a column (saves, meshes to free) go first.
let class = if q.at.is_none() {
Class::Urgent
} else {
Class::Near
};
Rank::new(class, q.priority)
})
}
fn run(&self, worker: usize) -> bool {
let Some(job) = self.shared.queue.lock().unwrap().pop() else {
return false;
};
let done = self.locals.with(
worker,
|| WorkerLocal {
worker: self.generator.worker(),
faces: None,
mesh_scratch: MeshScratch::default(),
},
|local| self.run_job(job.job, local),
);
if let Some(done) = done {
self.shared.done.send(done);
}
true
}
fn idle(&self, worker: usize) {
// A server nobody is on, a client standing still: free the
// generator's buffers (and the mesher's face cache and scratch)
// until the next job needs them.
self.locals.with_existing(worker, |local| {
self.generator.idle(&mut local.worker);
local.faces = None;
local.mesh_scratch = MeshScratch::default();
});
}
fn all_idle(&self) {
// No worker needs retained mesh capacity now. Drop it outside the
// pool lock; the pool then asks the allocator to return freed pages.
let meshes = std::mem::take(&mut *self.shared.meshes.lock().unwrap());
drop(meshes);
}
}
impl<G: ChunkGenerator> Jobs<G> {
fn run_job(&self, job: Job, local: &mut WorkerLocal<G::Worker>) -> Option<Done> {
let (generator, shared, light) = (&self.generator, &self.shared, &self.light);
let WorkerLocal {
worker,
faces,
mesh_scratch,
} = local;
let done = match job {
Job::Generate(x, z) => match generator.load(x, z) {
Some(chunk) => {
generator.prepare_loaded(&mut *worker, &chunk);
Done::Generated(chunk)
}
None => {
let proto = generator.proto(&mut *worker, x, z);
let decorated = generator.is_decorated(&proto);
Done::Proto(x, z, proto, decorated)
}
},
Job::Decorate(x, z, mut region, finals) => {
// The final chunks' proto chunks, made here: a
// generated proto chunk holding their blocks.
for (i, snapshot) in &finals {
if let Some(proto) = generator.proto_beside_final(&mut *worker, snapshot) {
region[*i] = proto;
}
}
generator.decorate(&mut *worker, &mut region);
let mut writes = FinalWrites::default();
for (i, snapshot) in &finals {
let blocks = generator.final_writes(snapshot, &region[*i]);
writes
.block_entities
.extend(generator.final_block_entities(&region[*i], &blocks));
writes.blocks.extend(blocks);
region[*i] = Box::new(());
}
let finals = finals.into_iter().map(|(i, _)| i).collect();
Done::Decorated(x, z, region, finals, writes)
}
Job::Finish(proto) => {
let mut chunk = generator.finish(proto);
chunk.generated = true;
Done::Generated(chunk)
}
Job::Save(protos) => {
let refs: Vec<&Proto> = protos.iter().map(|(_, p)| p).collect();
generator.save_protos(&refs);
Done::Saved(protos)
}
Job::LightView(x, z, proto) => {
let view = generator.light_view(&proto);
Done::LightView(x, z, proto, view)
}
Job::Free(meshes) => {
for (_, mesh) in meshes {
let rejected = shared.meshes.lock().unwrap().put(mesh);
drop(rejected);
}
return None;
}
Job::Light(x, z, region, seq, sky) => {
Done::Lit(x, z, light_chunk(&borrow(&region), light, sky), seq)
}
Job::Mesh(x, sy, z, region, seq, settings) => {
let context = shared.context.read().unwrap().clone();
let visibility = match faces.take() {
Some((tables, visibility)) if Arc::ptr_eq(&tables, &context.tables) => {
visibility
}
_ => context.tables.visibility.clone(),
};
let mut mesher = Mesher {
nether_shading: settings.nether,
shader_pack_lighting: settings.shader_pack_lighting,
zoom: settings.zoom,
blend_radius: settings.blend_radius,
smooth_lighting: settings.smooth_lighting,
cutout_leaves: settings.cutout_leaves,
translucent_layers: &settings.translucent_layers,
leaf_culling: settings.leaf_culling,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
biomes: &context.biomes,
visibility,
};
let recycled = shared.meshes.lock().unwrap().take();
let mut mesh = mesher.mesh_reusing(&borrow(&region), sy, recycled, mesh_scratch);
mesh.sort_solid();
*faces = Some((context.tables.clone(), mesher.visibility));
Done::Meshed(x, sy, z, mesh, seq)
}
};
Some(done)
}
}
pub struct WorkerContext {
pub assets: Arc<crate::assets::Assets>,
pub render: Arc<crate::assets::RenderAssets>,
@@ -629,190 +860,23 @@ impl Streaming {
) -> Self {
let shared = Arc::new(Shared {
queue: Mutex::new(BinaryHeap::new()),
ready: Condvar::new(),
stop: AtomicBool::new(false),
context: RwLock::new(context.clone()),
done: Mailbox::default(),
meshes: Mutex::new(MeshPool::default()),
released: AtomicUsize::new(0),
workers: AtomicUsize::new(0),
});
let light_tables = context.light.clone();
let saves: Arc<dyn Saves> = generator.clone();
let threads = (0..threads.max(1))
.map(|i| {
let shared = shared.clone();
let generator = generator.clone();
let light = context.light.clone();
thread::Builder::new()
.name(format!("horizon-worker-{i}"))
.spawn(move || {
// Ticks and frames come first (horizon_threads).
horizon_threads::behind_server();
let mut worker = generator.worker();
// The face visibility table and its occlusion cache stay with
// the worker across mesh jobs, like vanilla's per-thread cache:
// cloning it per job copied every state and face shape.
let mut faces: Option<(Arc<MeshTables>, FaceVisibility)> = None;
let mut mesh_scratch = MeshScratch::default();
loop {
let job = {
let mut queue = shared.queue.lock().unwrap();
let mut released = false;
loop {
if shared.stop.load(Ordering::Relaxed) {
return;
}
if let Some(job) = queue.pop() {
if released {
shared.released.fetch_sub(1, Ordering::Relaxed);
}
break job.job;
}
if released {
queue = shared.ready.wait(queue).unwrap();
continue;
}
let (woken, wait) =
shared.ready.wait_timeout(queue, IDLE_RELEASE).unwrap();
queue = woken;
if wait.timed_out() && queue.is_empty() {
// A server nobody is on, a client standing still:
// free the generator's buffers (and the mesher's
// face cache and scratch) until the next job
// needs them.
drop(queue);
generator.idle(&mut worker);
faces = None;
mesh_scratch = MeshScratch::default();
released = true;
// The allocator keeps freed pages until memory
// runs short: once every worker has freed its own,
// hand them back.
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
== shared.workers.load(Ordering::Relaxed)
{
// No worker needs retained mesh capacity now.
// Drop it outside the pool lock before asking
// the allocator to return freed pages.
let meshes =
std::mem::take(&mut *shared.meshes.lock().unwrap());
drop(meshes);
horizon_threads::release_free_memory();
}
queue = shared.queue.lock().unwrap();
}
}
};
let done = match job {
Job::Generate(x, z) => match generator.load(x, z) {
Some(chunk) => {
generator.prepare_loaded(&mut worker, &chunk);
Done::Generated(chunk)
}
None => {
let proto = generator.proto(&mut worker, x, z);
let decorated = generator.is_decorated(&proto);
Done::Proto(x, z, proto, decorated)
}
},
Job::Decorate(x, z, mut region, finals) => {
// The final chunks' proto chunks, made here: a
// generated proto chunk holding their blocks.
for (i, snapshot) in &finals {
if let Some(proto) =
generator.proto_beside_final(&mut worker, snapshot)
{
region[*i] = proto;
}
}
generator.decorate(&mut worker, &mut region);
let mut writes = FinalWrites::default();
for (i, snapshot) in &finals {
let blocks = generator.final_writes(snapshot, &region[*i]);
writes.block_entities.extend(
generator.final_block_entities(&region[*i], &blocks),
);
writes.blocks.extend(blocks);
region[*i] = Box::new(());
}
let finals = finals.into_iter().map(|(i, _)| i).collect();
Done::Decorated(x, z, region, finals, writes)
}
Job::Finish(proto) => {
let mut chunk = generator.finish(proto);
chunk.generated = true;
Done::Generated(chunk)
}
Job::Save(protos) => {
let refs: Vec<&Proto> = protos.iter().map(|(_, p)| p).collect();
generator.save_protos(&refs);
Done::Saved(protos)
}
Job::LightView(x, z, proto) => {
let view = generator.light_view(&proto);
Done::LightView(x, z, proto, view)
}
Job::Free(meshes) => {
for (_, mesh) in meshes {
let rejected = shared.meshes.lock().unwrap().put(mesh);
drop(rejected);
}
continue;
}
Job::Light(x, z, region, seq, sky) => {
Done::Lit(x, z, light_chunk(&borrow(&region), &light, sky), seq)
}
Job::Mesh(x, sy, z, region, seq, settings) => {
let context = shared.context.read().unwrap().clone();
let visibility = match faces.take() {
Some((tables, visibility))
if Arc::ptr_eq(&tables, &context.tables) =>
{
visibility
}
_ => context.tables.visibility.clone(),
};
let mut mesher = Mesher {
nether_shading: settings.nether,
shader_pack_lighting: settings.shader_pack_lighting,
zoom: settings.zoom,
blend_radius: settings.blend_radius,
smooth_lighting: settings.smooth_lighting,
cutout_leaves: settings.cutout_leaves,
translucent_layers: &settings.translucent_layers,
leaf_culling: settings.leaf_culling,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
biomes: &context.biomes,
visibility,
};
let recycled = shared.meshes.lock().unwrap().take();
let mut mesh = mesher.mesh_reusing(
&borrow(&region),
sy,
recycled,
&mut mesh_scratch,
);
mesh.sort_solid();
faces = Some((context.tables.clone(), mesher.visibility));
Done::Meshed(x, sy, z, mesh, seq)
}
};
shared.done.send(done);
}
})
})
// A thread the system refuses (a container's process limit reached) leaves the pool a
// worker short: a server that panicked here went down with its players' moves unsaved.
.filter_map(|spawned| {
spawned
.inspect_err(|e| log::error!("Horizon: chunk worker not started: {e}"))
.ok()
})
.collect::<Vec<_>>();
shared.workers.store(threads.len(), Ordering::Relaxed);
let pool = horizon_threads::jobs::pool();
let source = Arc::new(Jobs {
shared: shared.clone(),
generator: generator.clone(),
light: context.light.clone(),
locals: horizon_threads::jobs::PerWorker::new(pool.threads()),
});
let jobs = pool.register(source);
// The work kept in flight is sized for the workers this streamer may
// use: the pool's, or fewer when asked (tests, a placeholder).
let workers = threads.clamp(1, pool.threads());
let non_air_sections = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.non_air_sections(p))
@@ -820,7 +884,8 @@ impl Streaming {
Self {
shared,
non_air_sections,
threads,
jobs,
workers,
order: 0,
scan_age: 0,
scan_area: ((i32::MIN, i32::MIN), i32::MIN),
@@ -1019,7 +1084,7 @@ impl Streaming {
order: self.order,
job,
});
self.shared.ready.notify_one();
self.jobs.notify();
}
/// Rank the queued jobs again when what their priorities were computed
/// from changed: the centre, the extra areas, the band, or the loading
@@ -1489,7 +1554,7 @@ impl Streaming {
.map(|(k, _)| *k)
.collect();
if !far.is_empty() {
self.generation_scan.exhausted = false;
self.generation_scan.invalidate();
}
// Those decoration wrote to are saved first (see
// `ChunkGenerator::save_protos`), and dropped once back.
@@ -1538,7 +1603,7 @@ impl Streaming {
// Decorate chunks whose 3x3 proto neighbourhood is available, in
// vanilla's order: decoration is order dependent where write regions
// overlap (see `DecorationOrder`).
let workers = self.threads.len();
let workers = self.workers;
if self.decorates && self.order_area != Some((self.center, self.radius)) {
let (decorated, chunks) = (&self.decorated, &world.chunks);
// The render distance is the server view distance in singleplayer.
@@ -1600,14 +1665,24 @@ impl Streaming {
w.0 = self.decoration_order.rank_or_append(w.2, w.3);
}
wanted.sort_unstable();
// Most wanted chunks are blocked, and the same neighbours are
// asked about for each of them: what `waits_for` says is kept for
// the pass (nothing it reads changes in it but `decorated`, which
// updates the entry). Without it, scheduling at render distance 32
// took most of the thread that runs it while the world loaded.
let waiting: std::cell::RefCell<FxHashMap<(i32, i32), bool>> = Default::default();
for (_, _, x, z) in wanted {
if self.decorating >= workers * 2 {
break;
}
if self
.decoration_order
.blocked(x, z, |nx, nz| waits_for(self, nx, nz))
{
if self.decoration_order.blocked(x, z, |nx, nz| {
if let Some(&known) = waiting.borrow().get(&(nx, nz)) {
return known;
}
let known = waits_for(self, nx, nz);
waiting.borrow_mut().insert((nx, nz), known);
known
}) {
continue;
}
let keys: Vec<(i32, i32)> =
@@ -1627,6 +1702,7 @@ impl Streaming {
.collect();
if !finals.is_empty() && !self.saves_beside_final {
self.decorated.insert((x, z));
waiting.borrow_mut().insert((x, z), false);
continue;
}
if !keys.iter().enumerate().all(|(i, k)| {
@@ -1680,7 +1756,7 @@ impl Streaming {
for key in gone {
world.dirty_light.remove(&key);
}
let room = (self.threads.len() * 4).saturating_sub(self.lighting.len());
let room = (self.workers * 4).saturating_sub(self.lighting.len());
nearest_first(&mut light, room);
for (_, x, z) in light {
let Some(region) = self.light_region(world, x, z) else {
@@ -1766,7 +1842,7 @@ impl Streaming {
// jobs went stale long before a worker reached them (#3629). What
// stays dirty is asked for again, from the current centre, as
// capacity frees; `requeue` then has a small queue to keep close.
let room = (self.threads.len() * 32).saturating_sub(self.meshing.len());
let room = (self.workers * 32).saturating_sub(self.meshing.len());
nearest_first(&mut candidates, room);
for (_, x, z, sections) in candidates {
let Some(region) = Self::region(world, x, z) else {
@@ -2438,11 +2514,8 @@ impl Drop for Streaming {
None => break,
}
}
self.shared.stop.store(true, Ordering::Relaxed);
self.shared.ready.notify_all();
for t in self.threads.drain(..) {
let _ = t.join();
}
// `jobs` leaves the pool after this, once its running jobs are
// done; the jobs still queued go with the queue.
}
}
/// The queued jobs' priorities computed again from each one's column
@@ -3143,6 +3216,70 @@ mod tests {
}
}
/// Batches taken from the plan are the full scan's, as jobs finish,
/// columns and proto chunks leave and the centre moves.
#[test]
fn generation_plan_matches_full_scans_as_columns_arrive() {
for decorates in [false, true] {
let mut f = GenerationFixture::new(6, decorates);
let mut gate = GenerationScan::default();
let chunk = Arc::new(Chunk::empty(0, 0));
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let mut rand = |n: u64| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed % n
};
let mut scheduled = 0;
for step in 0..600 {
let capacity = 3 + rand(6) as usize;
let expected = baseline_candidates(f.input(), capacity);
let batch = gate.candidates(f.input(), capacity).unwrap_or_default();
assert_eq!(batch, expected, "step {step}");
for (_, x, z) in batch {
f.generating.insert((x, z));
scheduled += 1;
}
// Some jobs finish: as a proto chunk, or a final column.
let mut done: Vec<_> = f.generating.iter().copied().collect();
done.sort_unstable();
for key in done {
if rand(3) != 0 {
continue;
}
f.generating.remove(&key);
if rand(2) == 0 {
f.protos.insert(key, None);
} else {
f.world.chunks.insert(key, chunk.clone());
}
gate.exhausted = false;
}
// Now and then a column or a proto chunk leaves.
if rand(20) == 0 {
let mut keys: Vec<_> = f.world.chunks.keys().copied().collect();
keys.sort_unstable();
if let Some(key) = keys.get(rand(keys.len().max(1) as u64) as usize) {
f.world.chunks.remove(key);
}
}
if rand(20) == 0 {
let mut keys: Vec<_> = f.protos.keys().copied().collect();
keys.sort_unstable();
if let Some(key) = keys.get(rand(keys.len().max(1) as u64) as usize) {
f.protos.remove(key);
gate.invalidate();
}
}
if step == 300 {
f.center = (3, -2);
}
}
assert!(scheduled > 200);
}
}
#[test]
fn generation_scan_looks_at_overlapping_areas_once() {
// A server's players: many areas around one spot, some apart, one
@@ -89,8 +89,9 @@ fn main() -> Result<(), Error> {
});
let terrain = VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed)?;
// Generation and light, on the game's worker pool. Meshing is left out
// here and measured on its own below.
// Generation and light, on the game's worker pool (`threads` workers).
// Meshing is left out here and measured on its own below.
horizon_threads::jobs::configure(threads);
let mut streaming = Streaming::new(Arc::new(terrain), context.clone(), threads);
streaming.mesh = false;
streaming.center = center;
+30 -3
View File
@@ -183,6 +183,7 @@ mod maps;
mod minecarts;
mod mob_blocks;
mod mob_gear;
mod mob_index;
mod mob_looting;
mod mob_sounds;
mod mod_api_host;
@@ -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();
}
+77 -14
View File
@@ -6098,6 +6098,19 @@ impl Game<'_> {
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = self.view_scale();
self.rendered_mobs.set(0);
// Mob models posed on the GPU (mobs::skin), when the renderer takes them this frame.
let skinning = self
.renderer
.as_ref()
.filter(|r| r.skins_entities())
.and_then(|r| r.skinned_models());
match skinning {
Some(stored) => self
.skin_models
.borrow_mut()
.begin(&mut self.skin_frame.borrow_mut(), stored),
None => self.skin_frame.borrow_mut().clear_draws(),
}
// What custom entity model animations read of the frame.
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
time: self.world.game_time as f64 + alpha,
@@ -6240,21 +6253,55 @@ impl Game<'_> {
Vec::new()
};
let body = out.len();
let render = if layers_only {
horizon_client::mobs::render::render_layers
// The GPU path: an ordinary mob, no outline, no layers the CPU path changes after
// drawing them (a player's, the HDR tiers below, the sulfur cube's shell).
let posable = skinning.is_some()
&& !is_player
&& !layers_only
&& outline.is_none()
&& armor.is_empty()
&& !matches!(
mob.species(),
Species::Blaze | Species::MagmaCube | Species::GlowSquid | Species::SulfurCube
);
let mut skinned = None;
if posable {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mark = frame.mark();
let mut skin = horizon_client::mobs::skin::Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
horizon_client::mobs::render::render_skinned(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
out,
&mut skin,
);
skinned = Some((mark, skin.bounds));
} else {
horizon_client::mobs::render::render_with
};
render(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
&armor,
out,
);
let render = if layers_only {
horizon_client::mobs::render::render_layers
} else {
horizon_client::mobs::render::render_with
};
render(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
&armor,
out,
);
}
// HDR output: mobs that are light shine by their kind, as blocks do (entity.wgsl
// fs_hdr_mark); a tier's window lifts only their bright texels.
if let Some(tier) = match mob.species() {
@@ -6302,7 +6349,23 @@ impl Game<'_> {
if outline.is_none()
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
{
// What the GPU draws of the mob counts by its bounds' corners.
let corners = skinned.and_then(|(_, bounds)| bounds);
if let Some((lo, hi)) = corners {
out.extend([lo, hi].map(|pos| Vertex {
pos: pos.to_array(),
..bytemuck::Zeroable::zeroed()
}));
}
let len = out.len();
self.cull_entity_vertices(out, entity_start);
if out.len() < len {
if let Some((mark, _)) = skinned {
self.skin_frame.borrow_mut().truncate(mark);
}
} else if corners.is_some() {
out.truncate(len - 2);
}
}
// GuardianRenderer: the laser to the locked target.
if let Some((time, scale)) = horizon_client::mobs::guardian::beam(mob, alpha as f32)
@@ -712,6 +712,17 @@ mod tests {
assert_eq!(range, 48.0);
}
/// `Entity.shouldRenderAtSqrDistance` for a dropped item (`ItemEntity`
/// keeps the default and its box is 0.25 on each side): 24 blocks at
/// render distance 12, 32 at 16, 40 from 20 on; never 64.
#[test]
fn a_dropped_item_is_drawn_sixteen_blocks_times_the_view_scale() {
let item = glam::DVec3::splat(0.25);
assert_eq!(default_range(item, entity_view_scale(12, 1.0)), 24.0);
assert_eq!(default_range(item, entity_view_scale(16, 1.0)), 32.0);
assert_eq!(default_range(item, entity_view_scale(32, 1.0)), 40.0);
}
/// `Entity.shouldRenderAtSqrDistance` on a broken box: the size falls
/// back to one block.
#[test]
+11 -1
View File
@@ -483,12 +483,22 @@ impl Game<'_> {
}
_ => None,
});
// Entity.shouldRenderAtSqrDistance for the item's 0.25 box: 16 blocks
// times the view scale (32 at render distance 16), from its current
// position. A pickup particle is not an entity and keeps 64 blocks.
let range =
super::entity_culling::default_range(glam::DVec3::splat(0.25), self.view_scale());
for (item, at, partial, entity) in live.chain(taken) {
let entity_start = out.len();
self.mark_motion(entity_start, (2 << 32) | u64::from(item.id));
let q = item.position;
let pos = glam::DVec3::from_array(at) - camera;
if pos.length_squared() > 64.0 * 64.0 {
let far = if entity {
(glam::DVec3::new(q.x, q.y, q.z) - camera).length_squared() >= range * range
} else {
pos.length_squared() > 64.0 * 64.0
};
if far {
continue;
}
// EntityRenderer.shouldRender: the item's 0.25 box, grown by 0.5,
@@ -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);
}
}
+23 -24
View File
@@ -2230,7 +2230,7 @@ impl Game<'_> {
)
})
} else {
self.mobs.iter().find(|m| m.id == id as u32).map(|m| {
self.mob_index.get(&self.mobs, id as u32).map(|m| {
(
[m.position.x, m.position.y, m.position.z],
m.kind.width,
@@ -2448,9 +2448,8 @@ impl Game<'_> {
let dragon = self.dragon.as_ref().is_some_and(|d| d.id as i32 == id);
let driven = self.driven_mount().is_some_and(|m| m.id as i32 == id);
let mob = self
.mobs
.iter_mut()
.find(|m| m.id == id as u32)
.mob_index
.get_mut(&mut self.mobs, id as u32)
.filter(|_| !dragon);
let tracked = self
.remote
@@ -2852,7 +2851,7 @@ impl Game<'_> {
value("minecraft:movement_speed"),
value("minecraft:jump_strength"),
);
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) else {
let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) else {
return;
};
// A horse its rider's client moves goes at its own speed and jumps
@@ -3284,7 +3283,7 @@ impl Game<'_> {
None => Some(Holder::Mob(d as u32)),
},
};
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.source as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.source as u32) {
m.extra.leash = holder;
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id == p.source as u32) {
// A leashed boat or raft (AbstractBoat is Leashable).
@@ -3304,7 +3303,7 @@ impl Game<'_> {
// Entity.DATA_SHARED_FLAGS_ID, DATA_CUSTOM_NAME and
// DATA_CUSTOM_NAME_VISIBLE: server NPCs and holograms rely on
// these instead of saved NBT.
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
for (index, _, value) in &p.values {
match (index, value) {
(0, Value::Byte(flags)) => {
@@ -3337,7 +3336,7 @@ impl Game<'_> {
}
}
self.remote_mob_data(p.id, &p.values);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
&& m.species() == horizon_client::mobs::Species::Creeper
{
for (index, _, value) in &p.values {
@@ -3350,7 +3349,7 @@ impl Game<'_> {
}
}
}
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
for (index, _, value) in &p.values {
match (index, value) {
// Entity.DATA_TICKS_FROZEN: isFullyFrozen shakes it.
@@ -3382,7 +3381,7 @@ impl Game<'_> {
for (index, _, value) in &p.values {
if let (14, Value::OptPos(bed)) = (index, value) {
let code = bed.map_or(0, |b| self.bed_facing(b));
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
m.extra.ints[1] = code;
if let Some(head) = *bed {
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): setPosToBed.
@@ -3401,14 +3400,14 @@ impl Game<'_> {
// Avatar.DATA_PLAYER_MAIN_HAND (HumanoidArm: 0 left, 1
// right): the arm a swing of the main hand moves.
if let (15, Value::Id(arm)) = (index, value)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
m.extra.main_hand_left = *arm == 0;
}
// Avatar.DATA_PLAYER_MODE_CUSTOMISATION: the skin layers
// the player shows (PlayerModelPart bits).
if let (16, Value::Byte(bits)) = (index, value)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
m.extra.hidden_model_parts = !(*bits as u8) & 0x7F;
}
@@ -3518,7 +3517,7 @@ impl Game<'_> {
.remote
.as_ref()
.is_some_and(|r| r.level_players.contains_key(&p.id)))
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
for (index, _, value) in &p.values {
if let (0, Value::Byte(flags)) = (index, value) {
@@ -3571,7 +3570,7 @@ impl Game<'_> {
)
})
});
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
if let Some(charged) = charged
&& &*m.kind.id == "minecraft:player"
{
@@ -3753,7 +3752,7 @@ impl Game<'_> {
pitch,
);
}
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
} else if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
m.limb_speed = 1.5;
m.hurt_time = 10;
} else {
@@ -3766,7 +3765,7 @@ impl Game<'_> {
if let Some(pl) = self.player.as_mut() {
pl.health.animate_hurt_dir(p.yaw);
}
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
} else if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
m.hurt_time = 10;
} else {
self.remote_dragon_hurt(p.id);
@@ -3810,20 +3809,20 @@ impl Game<'_> {
self.fox_eat_particles(p.id as u32);
}
if p.event == 1
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
&& m.species() == horizon_client::mobs::Species::Rabbit
{
horizon_client::mobs::rabbit::hop_event(m);
}
if p.event == 19
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
horizon_client::mobs::aquatic::squid_cycle_event(m);
}
// LivingEntity.handleEntityEvent 55: another player swapped
// its main and off hand (`swapHandItems`).
if p.event == 55
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
std::mem::swap(&mut m.extra.main_hand, &mut m.extra.off_hand);
}
@@ -3835,7 +3834,7 @@ impl Game<'_> {
}
// EntityEvent.DEATH: start the death animation.
if p.event == 3
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
m.health = 0.0;
}
@@ -3859,12 +3858,12 @@ impl Game<'_> {
);
}
// The events that start a mob's animation (the warden's).
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
horizon_client::mobs::sync::entity_event(m, p.event as u8);
}
// LivingEntity.handleEntityEvent 60: the poof as the dead body goes.
if p.event == 60
&& let Some(m) = self.mobs.iter().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get(&self.mobs, p.id as u32)
{
let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height()));
self.death_poof([at.x, at.y, at.z], w, h);
@@ -3941,7 +3940,7 @@ impl Game<'_> {
};
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
self.swing_hand(hand, animation);
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
} else if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
m.swing_hand_modified(hand == Hand::Off, animation);
}
}
@@ -5219,7 +5218,7 @@ impl Game<'_> {
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
self.remote_dragon_motion(id, velocity);
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) {
// Squid.aiStep derives its body pitch and roll from network motion.
m.velocity = v;
} else if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
@@ -487,7 +487,7 @@ impl Game<'_> {
(14, _, Value::OptPos(bed)) => Some(bed.map_or(0, |b| self.bed_facing(b))),
_ => None,
});
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) {
apply(m, values, &r.builtin, r.entity_id);
apply_registry_data(m, values, &r.registries, self.world.game_time);
if let Some(code) = bed
@@ -170,7 +170,7 @@ impl Game<'_> {
} else if self.riding == Some(vehicle_id) {
// The server takes the player out, then teleports it.
self.riding = None;
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == vehicle_id) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, vehicle_id) {
m.rider = None;
}
// HappyGhast.removePassenger: a still timeout of 10 (the server's
@@ -286,7 +286,7 @@ impl Game<'_> {
pub(super) fn remote_move_vehicle(&mut self, position: [f64; 3], yaw: f32) {
let at = Vec3::new(position[0], position[1], position[2]);
if let Some(id) = self.driven_mount().map(|m| m.id)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, id)
{
m.previous = at;
m.position = at;
+1
View File
@@ -73,6 +73,7 @@ pub mod render;
pub mod riders;
pub mod seen_grid;
pub mod shulker;
pub mod skin;
pub mod sniffer;
pub mod spawn_rules;
pub mod spawn_selection;
+39 -26
View File
@@ -2,6 +2,7 @@
//! (quadrupeds, humanoids, creepers, spiders, chickens...) applied to a map of
//! part poses before the model is drawn.
use super::{Mob, Part, keyframes::AnimationState, living::wrap};
use crate::fast_hash::{FxHashMap, FxHashSet};
use horizon_core::geometry::trig::Mth;
use std::collections::HashMap;
use std::f32::consts::{FRAC_PI_6, PI};
@@ -101,15 +102,27 @@ impl AnimState {
}
pub fn end_tick(&mut self, _swinging: bool) {}
}
/// A part name as `Poses` keys it: its Fx hash, so filling a pose map allocates
/// nothing (a mob farm resets one per layer per mob each frame). Names are short
/// and few per model; `no_two_part_names_share_a_key` checks every vanilla model.
#[inline]
pub(crate) fn key(name: &str) -> u64 {
use std::hash::Hasher;
let mut h = crate::fast_hash::FxHasher::default();
h.write(name.as_bytes());
// The length too, so "a" and "a\0" differ (str's own Hash adds a terminator).
h.write_usize(name.len());
h.finish()
}
/// Part poses for one draw, keyed by part name.
#[derive(Clone, Default)]
pub struct Poses {
map: HashMap<String, [f32; 9]>,
map: FxHashMap<u64, [f32; 9]>,
dummy: [f32; 9],
/// Parts with `skipDraw` set: their own cubes are not drawn, children are.
skipped: std::collections::HashSet<String>,
skipped: FxHashSet<u64>,
/// Parts with `visible` cleared: neither they nor their children draw.
hidden: std::collections::HashSet<String>,
hidden: FxHashSet<u64>,
}
impl Poses {
pub fn new(model: &Part) -> Self {
@@ -120,9 +133,9 @@ impl Poses {
/// Refills this for another model, keeping the map and the sets' capacity:
/// a mob draws its body and every equipment layer each frame.
pub fn reset(&mut self, model: &Part) {
fn walk(p: &Part, map: &mut HashMap<String, [f32; 9]>) {
fn walk(p: &Part, map: &mut FxHashMap<u64, [f32; 9]>) {
// ModelPart.createPartLookup fills its map with putIfAbsent: the first part keeps a name.
map.entry(p.name.clone()).or_insert(p.pose);
map.entry(key(&p.name)).or_insert(p.pose);
for c in &p.children {
walk(c, map);
}
@@ -134,26 +147,26 @@ impl Poses {
walk(model, &mut self.map);
}
pub fn get(&mut self, name: &str) -> &mut [f32; 9] {
match self.map.get_mut(name) {
match self.map.get_mut(&key(name)) {
Some(p) => p,
None => &mut self.dummy,
}
}
pub fn pose(&self, part: &Part) -> [f32; 9] {
self.map.get(&part.name).copied().unwrap_or(part.pose)
self.map.get(&key(&part.name)).copied().unwrap_or(part.pose)
}
pub fn skip_draw(&mut self, name: &str) {
self.skipped.insert(name.to_owned());
self.skipped.insert(key(name));
}
pub fn skips(&self, part: &Part) -> bool {
self.skips_named(&part.name)
}
/// `skips` by the part's name.
pub fn skips_named(&self, name: &str) -> bool {
self.skipped.contains(name)
!self.skipped.is_empty() && self.skipped.contains(&key(name))
}
pub fn has(&self, name: &str) -> bool {
self.map.contains_key(name)
self.map.contains_key(&key(name))
}
pub fn xrot(&mut self, n: &str, v: f32) {
self.get(n)[3] = v;
@@ -167,9 +180,9 @@ impl Poses {
/// `ModelPart.visible = v`; a hidden part and its children do not draw.
pub fn visible(&mut self, n: &str, v: bool) {
if v {
self.hidden.remove(n);
} else if self.map.contains_key(n) {
self.hidden.insert(n.to_owned());
self.hidden.remove(&key(n));
} else if self.map.contains_key(&key(n)) {
self.hidden.insert(key(n));
}
}
pub fn is_hidden(&self, part: &Part) -> bool {
@@ -177,7 +190,7 @@ impl Poses {
}
/// `is_hidden` by the part's name.
pub fn is_hidden_named(&self, name: &str) -> bool {
self.hidden.contains(name)
!self.hidden.is_empty() && self.hidden.contains(&key(name))
}
}
/// The LivingEntityRenderState values setupAnim reads.
@@ -1003,7 +1016,7 @@ mod flight_pose_tests {
"left_leg",
]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1011,13 +1024,13 @@ mod flight_pose_tests {
};
player_model_visibility(&mut p, 0, true);
for part in ["body", "right_arm", "left_arm", "right_leg", "left_leg"] {
assert!(p.hidden.contains(part), "{part}");
assert!(p.hidden.contains(&key(part)), "{part}");
}
assert!(!p.hidden.contains("head") && !p.hidden.contains("hat"));
assert!(!p.hidden.contains(&key("head")) && !p.hidden.contains(&key("hat")));
// Back in another mode they all show, but for the layers the player hid.
player_model_visibility(&mut p, 1 << 6, false);
assert!(!p.hidden.contains("body") && !p.hidden.contains("left_leg"));
assert!(p.hidden.contains("hat"));
assert!(!p.hidden.contains(&key("body")) && !p.hidden.contains(&key("left_leg")));
assert!(p.hidden.contains(&key("hat")));
}
#[test]
fn blocking_arm_follows_the_head_with_limits_in_both_hands() {
@@ -1030,7 +1043,7 @@ mod flight_pose_tests {
let mut p = Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1087,7 +1100,7 @@ mod flight_pose_tests {
"left_leg",
]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1142,7 +1155,7 @@ mod flight_pose_tests {
"body",
]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1183,7 +1196,7 @@ mod flight_pose_tests {
let make = || Poses {
map: ["head", "right_arm", "left_arm", "right_leg", "left_leg"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1289,7 +1302,7 @@ mod flight_pose_tests {
let mut p = Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1310,7 +1323,7 @@ mod flight_pose_tests {
let mut p = Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1338,7 +1351,7 @@ mod flight_pose_tests {
let make = || Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
+326 -65
View File
@@ -1278,15 +1278,7 @@ fn draw_normals(
out: &mut Vec<Vertex>,
) {
let p = poses.pose(part);
let m = base
* glam::Mat4::from_translation(glam::Vec3::new(p[0], p[1], p[2]) / 16.0)
* glam::Mat4::from_quat(glam::Quat::from_euler(
glam::EulerRot::ZYX,
p[5],
p[4],
p[3],
))
* glam::Mat4::from_scale(glam::Vec3::new(p[6], p[7], p[8]));
let m = posed(base, p);
if poses.is_hidden(part) || p[6] == 0.0 && p[7] == 0.0 && p[8] == 0.0 {
return;
}
@@ -1324,6 +1316,46 @@ fn draw_normals(
draw_normals(c, m, normals, poses, uv, scroll, color, light, overlay, out);
}
}
/// A part's matrix under `base` for its pose (`ModelPart.translateAndRotate`
/// and its scale), as `draw` and the GPU path (`super::skin`) both use it.
pub(super) fn posed(base: glam::Mat4, p: [f32; 9]) -> glam::Mat4 {
base * glam::Mat4::from_translation(glam::Vec3::new(p[0], p[1], p[2]) / 16.0)
* glam::Mat4::from_quat(glam::Quat::from_euler(
glam::EulerRot::ZYX,
p[5],
p[4],
p[3],
))
* glam::Mat4::from_scale(glam::Vec3::new(p[6], p[7], p[8]))
}
/// `draw_scrolled` through the GPU path when the mob takes it (`skin`).
#[allow(clippy::too_many_arguments)]
fn put(
skin: &mut Option<&mut super::skin::Skin>,
part: &std::sync::Arc<Part>,
base: glam::Mat4,
poses: &Poses,
uv: [f32; 4],
scroll: f32,
color: u32,
light: u32,
overlay: u32,
out: &mut Vec<Vertex>,
) {
let paint = super::skin::Paint {
uv,
scroll,
color,
light,
overlay,
};
if let Some(skin) = skin
&& skin.draw(part, base, poses, paint)
{
return;
}
draw_scrolled(part, base, poses, uv, scroll, color, light, overlay, out);
}
/// Render a mob with its variant model, texture and layers.
pub fn render(
m: &Mob,
@@ -1360,7 +1392,7 @@ pub fn render_with(
let shifted =
camera - glam::DVec3::new(off[0] + w[0], off[1] + w[1], off[2] + w[2]);
let base = base_transform(m, alpha, shifted);
render_visible(m, alpha, base, atlas, light, data, extra, true, out);
render_visible(m, alpha, base, atlas, light, data, extra, true, out, None);
}
}
return;
@@ -1375,6 +1407,37 @@ pub fn render_with(
extra,
true,
out,
None,
);
}
/// `render` with the model's ordinary draws posed on the GPU when the mob
/// allows it (`super::skin`): the same picture, no vertices on the CPU.
#[allow(clippy::too_many_arguments)]
pub fn render_skinned(
m: &Mob,
alpha: f32,
camera: glam::DVec3,
atlas: Atlas,
light: u32,
data: &MobData,
out: &mut Vec<Vertex>,
skin: &mut super::skin::Skin,
) {
if super::trader::is_invisible(m) {
render_with(m, alpha, camera, atlas, light, data, &[], out);
return;
}
render_visible(
m,
alpha,
base_transform(m, alpha, camera),
atlas,
light,
data,
&[],
true,
out,
Some(skin),
);
}
/// `CapeLayer.submit` with `PlayerCapeModel`: the player model's body posed
@@ -1632,7 +1695,7 @@ pub fn render_layers(
out: &mut Vec<Vertex>,
) {
let base = base_transform(m, alpha, camera);
render_visible(m, alpha, base, atlas, light, data, extra, false, out);
render_visible(m, alpha, base, atlas, light, data, extra, false, out, None);
}
/// A mob drawn under `origin` instead of at its position (the spinning mob
/// of `SpawnerRenderer.submitEntityInSpawner`).
@@ -1646,7 +1709,7 @@ pub fn render_posed(
out: &mut Vec<Vertex>,
) {
let base = base_transform_at(m, alpha, origin);
render_visible(m, alpha, base, atlas, light, data, &[], true, out);
render_visible(m, alpha, base, atlas, light, data, &[], true, out, None);
}
/// The custom model's key of a body model: `main` is the wide player model, `kind.model`.
fn cem_key(model_key: &str) -> &str {
@@ -1730,6 +1793,7 @@ fn render_visible(
extra: &[(&Part, [f32; 4], u32, u32, bool)],
body_visible: bool,
out: &mut Vec<Vertex>,
skin: Option<&mut super::skin::Skin>,
) {
let overlay = u32::from(red_overlay(m));
let s = anim::state(m, alpha);
@@ -1799,56 +1863,6 @@ fn render_visible(
if let Some(animation) = body_animation {
animation.run(m, &s, &mut poses);
}
let draw_body = |out: &mut Vec<Vertex>| {
// A player or mannequin whose skin has no atlas sprite (an evicted or
// never stored download) draws with its default skin, not invisibly.
let drawn = sprite(texture).or_else(|| Some((texture, atlas(texture)?)));
let uv = drawn.map(|(_, uv)| uv).or_else(|| {
if avatar(m) {
atlas(&m.kind.texture)
} else {
None
}
});
if body_visible && let Some(uv) = uv {
let start = out.len();
draw(
&model,
base,
&poses,
uv,
body_color(m, alpha),
light,
body_overlay(m, alpha),
out,
);
if let Some(glow) = drawn.and_then(|(name, _)| emissive_uv(name)) {
draw(
&model,
base,
&poses,
glow,
body_color(m, alpha),
FULL_BRIGHT,
0,
out,
);
}
if translucent_body(m) {
mark_oit(&mut out[start..]);
}
}
};
// The translucent shell writes depth: draw the opaque core before it.
let shell_last = m.species() == Species::SulfurCube;
if !shell_last {
draw_body(out);
}
// The worn armour's layers (and their emissive overlays, full bright).
for (part, uv, color, layer_overlay, glows) in extra {
let light = if *glows { FULL_BRIGHT } else { light };
draw(part, base, &poses, *uv, *color, light, *layer_overlay, out);
}
let mut all_layers = layers(m, s.age);
// WardenRenderer: the translucent emissive layers fade with the animations.
if m.species() == Species::Warden {
@@ -1864,6 +1878,87 @@ fn render_visible(
}
}
}
// The GPU path takes the whole mob or none of it, so its draws keep their
// order: only plain opaque and cutout draws of models it can pose (no
// custom models, translucency, eyes or energy swirl, which the CPU path
// changes after drawing).
let mut skin = skin;
let posable = match skin.as_deref_mut() {
Some(skin) => {
custom_body.is_none()
&& extra.is_empty()
&& m.species() != Species::SulfurCube
&& !translucent_body(m)
&& energy_swirl(m, s.age).is_none()
&& skin.supports(&model)
&& all_layers.iter().all(|layer| {
!layer.full_bright
&& !translucent_layer(m, layer)
&& cem.layer(&layer.model).is_none()
&& m.kind
.layer(&layer.model)
.is_none_or(|part| skin.supports(part))
})
}
None => false,
};
if !posable {
skin = None;
}
let draw_body = |out: &mut Vec<Vertex>, skin: &mut Option<&mut super::skin::Skin>| {
// A player or mannequin whose skin has no atlas sprite (an evicted or
// never stored download) draws with its default skin, not invisibly.
let drawn = sprite(texture).or_else(|| Some((texture, atlas(texture)?)));
let uv = drawn.map(|(_, uv)| uv).or_else(|| {
if avatar(m) {
atlas(&m.kind.texture)
} else {
None
}
});
if body_visible && let Some(uv) = uv {
let start = out.len();
put(
skin,
&model,
base,
&poses,
uv,
0.0,
body_color(m, alpha),
light,
body_overlay(m, alpha),
out,
);
if let Some(glow) = drawn.and_then(|(name, _)| emissive_uv(name)) {
put(
skin,
&model,
base,
&poses,
glow,
0.0,
body_color(m, alpha),
FULL_BRIGHT,
0,
out,
);
}
if translucent_body(m) {
mark_oit(&mut out[start..]);
}
}
};
// The translucent shell writes depth: draw the opaque core before it.
let shell_last = m.species() == Species::SulfurCube;
if !shell_last {
draw_body(out, &mut skin);
}
// The worn armour's layers (and their emissive overlays, full bright).
for (part, uv, color, layer_overlay, glows) in extra {
let light = if *glows { FULL_BRIGHT } else { light };
draw(part, base, &poses, *uv, *color, light, *layer_overlay, out);
}
// One Poses refilled for every layer (armour draws up to seven): it keeps
// its maps' capacity.
let mut layer_poses = Poses::default();
@@ -1917,7 +2012,8 @@ fn render_visible(
None => overlay,
};
let start = out.len();
draw_scrolled(
put(
&mut skin,
&part,
base,
&layer_poses,
@@ -1929,7 +2025,8 @@ fn render_visible(
out,
);
if let Some(glow) = emissive_uv(drawn) {
draw_scrolled(
put(
&mut skin,
&part,
base,
&layer_poses,
@@ -1979,7 +2076,7 @@ fn render_visible(
}
}
if shell_last {
draw_body(out);
draw_body(out, &mut skin);
}
}
/// `EnergySwirlLayer`'s model, texture and `xOffset(t)` for a charged creeper
@@ -3084,6 +3181,170 @@ mod scroll_tests {
]
);
}
/// `anim::Poses` keys parts by a hash of their name: within any vanilla model, two
/// different names never share one (they would share a pose).
#[test]
fn no_two_part_names_share_a_key() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("entity-models.json").exists() {
return;
}
fn names<'a>(p: &'a Part, out: &mut Vec<&'a str>) {
out.push(&p.name);
for c in &p.children {
names(c, out);
}
}
let types = super::super::MobTypes::load(&root).unwrap();
let mut models = 0;
for kind in types.types.values() {
for model in std::iter::once(&kind.model).chain(kind.layers.values()) {
let mut all = Vec::new();
names(model, &mut all);
let mut keys = std::collections::HashMap::new();
for name in all {
let other = *keys.entry(super::super::anim::key(name)).or_insert(name);
assert_eq!(other, name, "{} shares a key", kind.id);
}
models += 1;
}
}
assert!(models > 100);
}
/// The CPU cost of posing a mob farm (tools/stress_scenes.py `stress_mobs`): 1000 mobs of
/// 8 kinds through `render_with`, as `mob_geometry` draws them each frame. Prints the median
/// thread CPU time per frame (busy machines stretch wall time), the vertices and a hash of them (the same hash before and after a speed-up
/// means the same picture). `cargo test --profile fast -p horizon-client --lib
/// benchmark_mob_farm_geometry -- --ignored --nocapture`.
#[test]
#[ignore = "manual benchmark; needs the prepared resources"]
fn benchmark_mob_farm_geometry() {
use std::{hash::Hasher, hint::black_box, time::Instant};
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("entity-models.json").exists() {
return;
}
let types = super::super::MobTypes::load(&root).unwrap();
let kinds = [
"husk", "creeper", "cow", "pig", "sheep", "chicken", "villager", "spider",
]
.map(|k| std::sync::Arc::new(types.types[&format!("minecraft:{k}")].clone()));
let mobs: Vec<Mob> = (0..1000u32)
.map(|i| {
let (layer, row, col) = (i / 200, i / 20 % 10, i % 20);
let at = horizon_core::geometry::Vec3::new(
-14.25 + 1.5 * f64::from(col),
1.0 + 2.5 * f64::from(layer),
8.0 + 2.0 * f64::from(row),
);
let mut m = Mob::new(i + 1, kinds[i as usize % 8].clone(), at, 180.0);
m.previous = at;
m.age = i;
m
})
.collect();
let data = MobData {
states: Vec::new(),
placements: Default::default(),
heightmaps: Default::default(),
animations: Default::default(),
biome_tags: Default::default(),
entity_tags: Default::default(),
villager_hats: Default::default(),
cem: Default::default(),
random_textures: Default::default(),
emissive: Default::default(),
blinks: Default::default(),
pois: Default::default(),
species_placements: Default::default(),
};
let atlas = |name: &str| {
let h = name.len() as f32 / 64.0;
Some([h, h, h + 0.25, h + 0.25])
};
let camera = glam::DVec3::new(0.5, 9.6, 0.5);
let mut out = Vec::new();
let frame = |out: &mut Vec<Vertex>| {
out.clear();
for m in &mobs {
render_with(m, 0.5, camera, &atlas, 0xf0f0, &data, &[], out);
}
};
frame(&mut out);
let mut hash = std::collections::hash_map::DefaultHasher::new();
hash.write(bytemuck::cast_slice(&out));
// The thread's own CPU time where the platform gives it, else wall time.
let timed = |f: &mut dyn FnMut()| {
let (wall, cpu) = (Instant::now(), horizon_client_render::spans::thread_cpu());
f();
match (horizon_client_render::spans::thread_cpu(), cpu) {
(Some(end), Some(start)) => (end - start).as_secs_f64() * 1000.0,
_ => wall.elapsed().as_secs_f64() * 1000.0,
}
};
let mut times = Vec::new();
for _ in 0..30 {
times.push(timed(&mut || frame(black_box(&mut out))));
}
times.sort_by(f64::total_cmp);
println!(
"mob farm: 1000 mobs, {} vertices, {:.3} ms/frame (median of 30, min {:.3}), hash {:016x}",
out.len(),
times[15],
times[0],
hash.finish()
);
// Where it goes: the body's poses alone (state, pose map, setupAnim), then the
// body's vertices alone from those poses.
let median = |mut f: Box<dyn FnMut() + '_>| {
let mut t: Vec<f64> = (0..30).map(|_| timed(&mut f)).collect();
t.sort_by(f64::total_cmp);
t[15]
};
let posed: Vec<(std::sync::Arc<Part>, Poses)> = mobs
.iter()
.map(|m| {
let model = m.kind.model.clone();
let mut poses = Poses::new(&model);
anim::setup(m, &anim::state(m, 0.5), &mut poses, &data.animations);
(model, poses)
})
.collect();
let poses_ms = median(Box::new(|| {
for m in &mobs {
let model = m.kind.model.clone();
let mut poses = Poses::new(&model);
anim::setup(m, &anim::state(m, 0.5), &mut poses, &data.animations);
black_box(&poses);
}
}));
let mut body = Vec::new();
let draw_ms = median(Box::new(|| {
body.clear();
for (m, (model, poses)) in mobs.iter().zip(&posed) {
let base = base_transform(m, 0.5, camera);
draw(
model,
base,
poses,
[0.0, 0.0, 0.25, 0.25],
u32::MAX,
0xf0f0,
0,
&mut body,
);
}
black_box(&body);
}));
println!(
" body poses only {poses_ms:.3} ms; body vertices only {draw_ms:.3} ms ({} vertices)",
body.len()
);
}
}
#[cfg(test)]
+422
View File
@@ -0,0 +1,422 @@
//! Mob models posed on the GPU (`gfx::entity_skin`): the store of models
//! uploaded once, and the walk that turns one `render::draw` into the parts'
//! matrices and jobs the shader expands into the very vertices `draw` would
//! push, in its order.
//!
//! A model takes this path only when every polygon faces an axis: the
//! normal matrix times an axis is one of its columns, exactly, so the shader's
//! packed normals are the CPU's to the bit. Positions and UVs are the same
//! sums in the same order (`Mat4::transform_point3`).
use super::{ModelNormals, Part, anim::Poses};
use crate::fast_hash::FxHashMap;
use crate::gfx::entity_skin::{SkinFrame, SkinJob, SkinPart, SkinVertex};
use std::sync::Arc;
/// A stored model: where its vertices start, and per part (pre-order) its
/// own vertices and the end of its subtree, for the walk.
struct Stored {
first: u32,
parts: Vec<StoredPart>,
/// The model's bounds in its parts' own space, per part: min and max of
/// its own vertices (none: no polygons).
bounds: Vec<Option<([f32; 3], [f32; 3])>>,
}
#[derive(Clone, Copy)]
struct StoredPart {
/// The part's first vertex, from the model's.
first: u32,
count: u32,
/// One past the last part of its subtree.
end: u32,
}
/// The models on the GPU, by the `Arc` they are drawn from. An entry holds
/// its `Arc`, so the address is never reused while stored; models no one
/// else holds any more are dropped by `begin`.
#[derive(Default)]
pub struct SkinModels {
stored: FxHashMap<usize, (Arc<Part>, Option<Stored>)>,
vertices: u32,
}
/// Models whose store would grow past this restart it (a pack reload's
/// models pile up otherwise).
const STORE_LIMIT: u32 = 4 << 20;
impl SkinModels {
/// Starts a frame: drops the models nothing else holds (their vertices
/// stay in the store until it restarts), restarts the store when it grew
/// too large or the renderer's (`stored`, its size) is out of step.
pub fn begin(&mut self, frame: &mut SkinFrame, stored: u32) {
if self.vertices > STORE_LIMIT || stored + frame.new_model.len() as u32 != self.vertices {
self.restart(frame);
}
self.stored
.retain(|_, (model, _)| Arc::strong_count(model) > 1);
frame.clear_draws();
}
fn restart(&mut self, frame: &mut SkinFrame) {
self.stored.clear();
self.vertices = 0;
frame.new_model.clear();
frame.base = 0;
frame.reset = true;
}
fn entry(&mut self, model: &Arc<Part>, frame: &mut SkinFrame) -> Option<&Stored> {
let key = Arc::as_ptr(model) as usize;
let vertices = &mut self.vertices;
let (_, stored) = self.stored.entry(key).or_insert_with(|| {
// The renderer appends `new_model` to its store in order, so a
// model's first vertex is the store's size when it is added.
let before = frame.new_model.len();
let stored = flatten(model, *vertices, &mut frame.new_model);
*vertices += (frame.new_model.len() - before) as u32;
(model.clone(), stored)
});
stored.as_ref()
}
}
/// The model's vertices in pre-order (a part's polygons, then its children),
/// appended to `out`; `None` when a polygon does not face an axis.
fn flatten(model: &Part, first: u32, out: &mut Vec<SkinVertex>) -> Option<Stored> {
let start = out.len();
let mut parts = Vec::new();
let mut bounds = Vec::new();
fn walk(
part: &Part,
start: usize,
out: &mut Vec<SkinVertex>,
parts: &mut Vec<StoredPart>,
bounds: &mut Vec<Option<([f32; 3], [f32; 3])>>,
) -> bool {
let index = parts.len();
let own = (out.len() - start) as u32;
parts.push(StoredPart {
first: own,
count: 0,
end: 0,
});
bounds.push(None);
let mut lo = [f32::INFINITY; 3];
let mut hi = [f32::NEG_INFINITY; 3];
for poly in &part.polygons {
let Some(axis) = axis(poly.normal) else {
return false;
};
for v in poly.vertices {
let pos = (glam::Vec3::new(v[0], v[1], v[2]) / 16.0).to_array();
for a in 0..3 {
lo[a] = lo[a].min(pos[a]);
hi[a] = hi[a].max(pos[a]);
}
out.push(SkinVertex {
pos,
uv: [v[3], v[4]],
axis,
part: index as u32,
});
}
}
parts[index].count = (out.len() - start) as u32 - own;
if !part.polygons.is_empty() {
bounds[index] = Some((lo, hi));
}
for child in &part.children {
if !walk(child, start, out, parts, bounds) {
return false;
}
}
parts[index].end = parts.len() as u32;
true
}
if !walk(model, start, out, &mut parts, &mut bounds) {
out.truncate(start);
return None;
}
Some(Stored {
first,
parts,
bounds,
})
}
/// 0..2 for +x, +y, +z, 3..5 for -x, -y, -z; `None` for any other normal.
fn axis(n: [f32; 3]) -> Option<u32> {
let mut found = None;
for (a, &c) in n.iter().enumerate() {
if c == 1.0 || c == -1.0 {
if found.is_some() {
return None;
}
found = Some(a as u32 + if c < 0.0 { 3 } else { 0 });
} else if c != 0.0 {
return None;
}
}
found
}
/// One draw's parameters (`render::draw_scrolled`'s).
#[derive(Clone, Copy)]
pub struct Paint {
pub uv: [f32; 4],
pub scroll: f32,
pub color: u32,
pub light: u32,
pub overlay: u32,
}
/// Where a frame's posed draws go, and the bounds of the current mob's.
pub struct Skin<'a> {
pub models: &'a mut SkinModels,
pub frame: &'a mut SkinFrame,
/// Camera-relative bounds of what this mob drew here (for culling).
pub bounds: Option<(glam::Vec3, glam::Vec3)>,
}
impl Skin<'_> {
/// Whether this model can be posed here (stored on first sight).
pub fn supports(&mut self, model: &Arc<Part>) -> bool {
self.models.entry(model, self.frame).is_some()
}
/// `render::draw_scrolled` on the GPU: false (nothing done) when the
/// model cannot take this path.
pub fn draw(
&mut self,
model: &Arc<Part>,
base: glam::Mat4,
poses: &Poses,
paint: Paint,
) -> bool {
let Some(stored) = self.models.entry(model, self.frame) else {
return false;
};
let first_part = self.frame.parts.len() as u32;
self.frame.parts.resize(
self.frame.parts.len() + stored.parts.len(),
bytemuck::Zeroable::zeroed(),
);
let mut walk = Walk {
stored,
poses,
parts: &mut self.frame.parts[first_part as usize..],
jobs: &mut self.frame.jobs,
vertices: &mut self.frame.vertices,
bounds: &mut self.bounds,
first_part,
paint,
index: 0,
};
walk.part(model, base, ModelNormals::new(base));
true
}
}
struct Walk<'a> {
stored: &'a Stored,
poses: &'a Poses,
parts: &'a mut [SkinPart],
jobs: &'a mut Vec<SkinJob>,
vertices: &'a mut u32,
bounds: &'a mut Option<(glam::Vec3, glam::Vec3)>,
first_part: u32,
paint: Paint,
/// The pre-order index of the part being walked.
index: u32,
}
impl Walk<'_> {
/// `render::draw_normals` without the vertices: the part's matrices, and
/// its polygons as (part of) a job.
fn part(&mut self, part: &Part, base: glam::Mat4, normals: ModelNormals) {
let index = self.index;
let stored = self.stored.parts[index as usize];
let p = self.poses.pose(part);
let m = super::render::posed(base, p);
if self.poses.is_hidden(part) || p[6] == 0.0 && p[7] == 0.0 && p[8] == 0.0 {
// The whole subtree is left out.
self.index = stored.end;
return;
}
let normals = normals.part(p);
let n = if normals.trusted {
normals.matrix
} else {
glam::Mat3::from_cols(
normals.matrix.x_axis.normalize_or_zero(),
normals.matrix.y_axis.normalize_or_zero(),
normals.matrix.z_axis.normalize_or_zero(),
)
};
self.parts[index as usize] = SkinPart {
matrix: m.to_cols_array(),
normal: n.to_cols_array(),
};
if !self.poses.skips(part) && stored.count > 0 {
self.emit(stored);
if let Some((lo, hi)) = self.stored.bounds[index as usize] {
let (lo, hi) = (glam::Vec3::from_array(lo), glam::Vec3::from_array(hi));
let center = m.transform_point3((lo + hi) * 0.5);
let half = (hi - lo) * 0.5;
let r = glam::Mat3::from_mat4(m);
let extent =
r.x_axis.abs() * half.x + r.y_axis.abs() * half.y + r.z_axis.abs() * half.z;
let (a, b) = (center - extent, center + extent);
*self.bounds = Some(match *self.bounds {
Some((l, h)) => (l.min(a), h.max(b)),
None => (a, b),
});
}
}
self.index += 1;
for child in &part.children {
self.part(child, m, normals);
}
}
/// The part's vertices: extends the last job when they follow its own.
fn emit(&mut self, stored: StoredPart) {
let first = self.stored.first + stored.first;
let paint = self.paint;
if let Some(job) = self.jobs.last_mut()
&& job.first + job.count == first
&& job.part == self.first_part
&& job.dst + job.count == *self.vertices
{
job.count += stored.count;
} else {
self.jobs.push(SkinJob {
first,
count: stored.count,
part: self.first_part,
dst: *self.vertices,
rect: paint.uv,
scroll: paint.scroll,
color: paint.color,
light: paint.light,
overlay: paint.overlay,
glint: u32::from(paint.overlay == super::render::ARMOR_GLINT),
spare: [0; 3],
});
}
*self.vertices += stored.count;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gfx::entity_skin::expand;
use crate::mobs::{Mob, MobData, MobTypes, render};
/// Every vanilla mob, at a few ages and turns: the GPU path's vertices
/// (its jobs expanded as `entity_skin.wgsl` does) are the CPU path's, to
/// the bit and in the same order, for each mob it takes; the mobs it
/// leaves to the CPU draw exactly as before. Needs the prepared resources.
#[test]
fn posed_on_the_gpu_every_mob_draws_what_the_cpu_draws() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("entity-models.json").exists() {
eprintln!("no prepared resources: skipped");
return;
}
let types = MobTypes::load(&root).unwrap();
let data = MobData {
states: Vec::new(),
placements: Default::default(),
heightmaps: Default::default(),
animations: MobData::load_animations(&root),
biome_tags: Default::default(),
entity_tags: Default::default(),
villager_hats: Default::default(),
cem: Default::default(),
random_textures: Default::default(),
emissive: Default::default(),
blinks: Default::default(),
pois: Default::default(),
species_placements: Default::default(),
};
// Every texture its own rect, so a swapped texture shows.
let atlas = |name: &str| {
let h = name
.bytes()
.fold(7u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
let u = (h % 61) as f32 / 64.0;
let v = (h / 61 % 61) as f32 / 64.0;
Some([u, v, u + 1.0 / 64.0, v + 1.0 / 32.0])
};
let camera = glam::DVec3::new(0.3, 62.0, -4.0);
let mut models = SkinModels::default();
let mut frame = SkinFrame::default();
let mut store: Vec<crate::gfx::entity_skin::SkinVertex> = Vec::new();
let (mut posed, mut kept) = (0, 0);
let mut ids: Vec<_> = types.types.keys().cloned().collect();
ids.sort();
for (n, id) in ids.iter().enumerate() {
let kind = Arc::new(types.types[id].clone());
for (age, yaw) in [(0u32, 0.0f32), (37, 123.0), (412, -71.5)] {
let at = horizon_core::geometry::Vec3::new(1.5 + n as f64 % 5.0, 64.0, 3.25);
let mut m = Mob::new(n as u32 + 1, kind.clone(), at, yaw);
m.previous = horizon_core::geometry::Vec3::new(at.x - 0.1, at.y, at.z + 0.05);
m.age = age;
m.limb_speed = 0.6;
m.limb_position = age as f32 * 0.37;
let mut cpu = Vec::new();
render::render_with(&m, 0.4, camera, &atlas, 0x50a0, &data, &[], &mut cpu);
models.begin(&mut frame, store.len() as u32);
let mut gpu = Vec::new();
let mut skin = Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
render::render_skinned(&m, 0.4, camera, &atlas, 0x50a0, &data, &mut gpu, &mut skin);
let bounds = skin.bounds;
if frame.reset {
store.clear();
}
assert_eq!(store.len() as u32, frame.base);
store.extend_from_slice(&frame.new_model);
frame.handed();
if frame.jobs.is_empty() {
kept += 1;
assert_eq!(
bytemuck::cast_slice::<_, u8>(&gpu),
bytemuck::cast_slice::<_, u8>(&cpu),
"{id} age {age}: the CPU path changed"
);
continue;
}
posed += 1;
assert!(gpu.is_empty(), "{id}: a posed mob left vertices on the CPU");
let expanded = expand(&store, &frame);
assert_eq!(expanded.len(), cpu.len(), "{id} age {age}: vertex count");
for (i, (a, b)) in expanded.iter().zip(&cpu).enumerate() {
assert_eq!(
bytemuck::bytes_of(a),
bytemuck::bytes_of(b),
"{id} age {age} vertex {i}: {a:?} != {b:?}"
);
}
// The culling bounds hold every vertex.
let (lo, hi) = bounds.expect("a posed mob has bounds");
for v in &cpu {
let p = glam::Vec3::from_array(v.pos);
assert!(
p.cmpge(lo - 1e-4).all() && p.cmple(hi + 1e-4).all(),
"{id}: {p} outside {lo}..{hi}"
);
}
}
}
eprintln!("posed on the GPU: {posed} mob draws; kept on the CPU: {kept}");
assert!(posed > 3 * 40, "only {posed} draws took the GPU path");
}
}
@@ -156,7 +156,7 @@ impl Session {
let before = (self.streaming.generated_total, self.world.chunks.len());
self.update();
thread::sleep(Duration::from_millis(5));
let idle = self.streaming.pending() == 0
let idle = !self.streaming.busy()
&& before == (self.streaming.generated_total, self.world.chunks.len());
quiet = if idle { quiet + 1 } else { 0 };
}
@@ -86,7 +86,7 @@ fn columns_loaded_past_the_render_distance_mesh_when_they_come_in_range() {
// and nothing comes back (`pending` empties).
streaming.free(uploads);
thread::sleep(Duration::from_millis(5));
let idle = streaming.pending() == 0 && before == (world.chunks.len(), meshed.len());
let idle = !streaming.busy() && before == (world.chunks.len(), meshed.len());
quiet = if idle { quiet + 1 } else { 0 };
}
};
@@ -181,7 +181,7 @@ fn relit_columns_keep_every_mesh_current() {
while quiet < 40 {
assert!(Instant::now() < deadline, "streaming never settled");
let (uploads, _) = streaming.update(world);
let idle = uploads.is_empty() && streaming.pending() == 0;
let idle = uploads.is_empty() && !streaming.busy();
meshes.extend(uploads);
thread::sleep(Duration::from_millis(5));
quiet = if idle { quiet + 1 } else { 0 };
+1 -1
View File
@@ -90,7 +90,7 @@ fn a_tall_column_streams_lights_and_meshes_to_its_top() {
while quiet < 40 {
assert!(Instant::now() < deadline, "streaming never settled");
let (uploads, _) = streaming.update(world);
let idle = uploads.is_empty() && streaming.pending() == 0;
let idle = uploads.is_empty() && !streaming.busy();
meshes.extend(uploads);
thread::sleep(Duration::from_millis(5));
quiet = if idle { quiet + 1 } else { 0 };
@@ -0,0 +1,205 @@
//! Replays the play packets of a `horizon-bots --capture` file through the client's receive path on
//! one thread, and prints the CPU each stage takes and what each packet type costs to decode:
//!
//! horizon-bots --server 127.0.0.1:25565 --bots 1 --behaviour explore --view-distance 32 \
//! --seconds 60 --capture join.cap
//! cargo run --release -p horizon-protocol --example net_replay -- join.cap
//!
//! The packets go through a connection three times, as they reach the client:
//! - `memory`: single player (an integrated server's pipe: no compression, no encryption);
//! - `tcp`: a server's default compression threshold (256), as offline servers send it;
//! - `tcp+aes`: the same encrypted, as online-mode servers send it.
//!
//! Each pass sends every packet into an in-memory pipe first, then times `ConnReader::recv` for all of
//! them (pipe read, decryption, framing, inflate). `decode` is `play::Clientbound::decode`, and
//! `sections` parses chunk packets' sections (overworld: 24 sections; `--biome-bits` from the server's
//! biome registry, 7 for vanilla's 65 biomes). `--repeat N` keeps each stage's fastest of N runs.
use horizon_protocol::{
Direction, State,
buf::write_varint,
connection::{Connection, Pipe},
ids,
packets::play::Clientbound,
};
use std::{collections::BTreeMap, error::Error, time::Instant};
struct Packet {
id: i32,
body: Vec<u8>,
}
fn load(path: &str) -> Result<Vec<Packet>, Box<dyn Error>> {
let data = std::fs::read(path)?;
let mut packets = Vec::new();
let mut at = 0;
while at + 12 <= data.len() {
let id = i32::from_le_bytes(data[at + 4..at + 8].try_into()?);
let len = u32::from_le_bytes(data[at + 8..at + 12].try_into()?) as usize;
let Some(payload) = data.get(at + 12..at + 12 + len) else {
break;
};
let mut body = Vec::with_capacity(len + 3);
write_varint(&mut body, id);
body.extend_from_slice(payload);
packets.push(Packet { id, body });
at += 12 + len;
}
Ok(packets)
}
/// Seconds to receive every packet through a connection with this threshold and encryption,
/// and the bytes the connection read.
fn receive(packets: &[Packet], threshold: i32, encrypt: bool) -> (f64, i64, Vec<(i32, Vec<u8>)>) {
let (a, b) = Pipe::pair();
let (mut server, mut client) = (Connection::local(b), Connection::local(a));
server.set_compression(threshold);
client.set_compression(threshold);
if encrypt {
let key = [0x5a; 16];
server.enable_encryption(&key);
client.enable_encryption(&key);
}
for p in packets {
server.send(&p.body).expect("the pipe is open");
}
let start = Instant::now();
let received: Vec<_> = (0..packets.len())
.map(|_| client.recv().expect("every packet sent arrives"))
.collect();
let seconds = start.elapsed().as_secs_f64();
let bytes = client
.reader
.counters
.bytes_in
.load(std::sync::atomic::Ordering::Relaxed);
(seconds, bytes, received)
}
#[derive(Default, Clone, Copy)]
struct Cost {
count: u64,
bytes: u64,
nanos: u64,
}
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = std::env::args().skip(1).collect();
let path = args
.first()
.ok_or("usage: net_replay CAPTURE [--repeat N] [--biome-bits B]")?;
let flag = |name: &str, default: usize| {
args.iter()
.position(|a| a == name)
.and_then(|i| args.get(i + 1))
.and_then(|v| v.parse().ok())
.unwrap_or(default)
};
let repeat = flag("--repeat", 3).max(1);
let biome_bits = flag("--biome-bits", 7) as u8;
let packets = load(path)?;
let body_bytes: usize = packets.iter().map(|p| p.body.len()).sum();
let chunk_id = ids::play::clientbound::LEVEL_CHUNK_WITH_LIGHT;
let chunks = packets.iter().filter(|p| p.id == chunk_id).count();
println!(
"{path}: {} packets, {:.1} MB of packet bodies, {chunks} chunk packets",
packets.len(),
body_bytes as f64 / 1e6
);
let mut received = Vec::new();
println!("\nreceive (ConnReader::recv for every packet, fastest of {repeat}):");
println!(
" {:<8} {:>10} {:>10} {:>12} {:>14}",
"pass", "wire MB", "ms", "ns/packet", "ns/wire byte"
);
for (name, threshold, encrypt) in [
("memory", -1, false),
("tcp", 256, false),
("tcp+aes", 256, true),
] {
let mut best = f64::MAX;
let mut wire = 0;
for _ in 0..repeat {
let (seconds, bytes, got) = receive(&packets, threshold, encrypt);
best = best.min(seconds);
wire = bytes;
received = got;
}
println!(
" {name:<8} {:>10.1} {:>10.1} {:>12.0} {:>14.2}",
wire as f64 / 1e6,
best * 1e3,
best * 1e9 / packets.len() as f64,
best * 1e9 / wire.max(1) as f64
);
}
// Decode, per packet type: the fastest of the runs for each type.
let mut costs: BTreeMap<i32, Cost> = BTreeMap::new();
let mut sections = Cost::default();
let mut errors = 0;
for run in 0..repeat {
let mut this: BTreeMap<i32, Cost> = BTreeMap::new();
let mut this_sections = Cost::default();
for (id, payload) in &received {
let start = Instant::now();
let packet = Clientbound::decode(*id, payload);
let nanos = start.elapsed().as_nanos() as u64;
let c = this.entry(*id).or_default();
c.count += 1;
c.bytes += payload.len() as u64;
c.nanos += nanos;
match packet {
Ok(Clientbound::LevelChunkWithLight(p)) => {
let start = Instant::now();
if p.chunk.sections(24, 16, biome_bits).is_err() && run == 0 {
errors += 1;
}
this_sections.count += 1;
this_sections.bytes += p.chunk.buffer.len() as u64;
this_sections.nanos += start.elapsed().as_nanos() as u64;
}
Err(_) if run == 0 => errors += 1,
_ => {}
}
}
for (id, c) in this {
let best = costs.entry(id).or_insert(c);
if c.nanos < best.nanos {
*best = c;
}
}
if run == 0 || this_sections.nanos < sections.nanos {
sections = this_sections;
}
}
let mut rows: Vec<_> = costs.into_iter().collect();
rows.sort_by_key(|(_, c)| std::cmp::Reverse(c.nanos));
let total: u64 = rows.iter().map(|(_, c)| c.nanos).sum();
println!(
"\ndecode (play::Clientbound::decode): {:.1} ms for {} packets, {errors} errors",
total as f64 / 1e6,
packets.len()
);
println!(
" {:<34} {:>8} {:>10} {:>10} {:>10}",
"packet", "count", "MB", "ms", "ns/packet"
);
for (id, c) in rows.iter().take(20) {
let name = ids::name(State::Play, Direction::Clientbound, *id).unwrap_or("?");
println!(
" {name:<34} {:>8} {:>10.2} {:>10.2} {:>10.0}",
c.count,
c.bytes as f64 / 1e6,
c.nanos as f64 / 1e6,
c.nanos as f64 / c.count as f64
);
}
println!(
"\nsections (LevelChunkPacketData.sections): {} chunks, {:.1} ms, {:.0} us a chunk",
sections.count,
sections.nanos as f64 / 1e6,
sections.nanos as f64 / 1e3 / sections.count.max(1) as f64
);
Ok(())
}
+700
View File
@@ -0,0 +1,700 @@
//! One pool of worker threads for the whole process, shared by every source of
//! background work: chunk generation, light and meshing (the client's and each
//! of the server thread's levels), chunk encoding, far terrain.
//!
//! Before it, each of those started its own threads, about one per core each:
//! a single player world ran four or five such pools at once, ~100 threads on a
//! 32-thread CPU, and they fought each other and the render and server threads
//! for cores. Here the threads are counted once, for the machine
//! ([`configure`]), and the work goes to them in the order it matters.
//!
//! A [`Source`] keeps its own queue (it can rank its jobs again when the player
//! moves, or drop the ones that left the view); a worker that is free asks every
//! source for the [`Rank`] of its next job and runs the most urgent one: the
//! [`Class`] first, then the source's key (a chunk's distance from the player).
//! Every free worker takes the next job as soon as it is free, so no core waits
//! while work is queued, whichever source queued it. Jobs are coarse (a chunk, a
//! section, a tile: a millisecond or more), so one lock for the choice costs
//! nothing next to them.
//!
//! A worker asks the sources for their next rank under the pool's read lock
//! of its sources and nothing else, so a source may call [`Pool::notify`]
//! anywhere, its queue's lock held or not; a notify never waits for a worker.
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock, RwLock};
use std::time::Duration;
/// How urgent a job is, before its source's key: every queued job of a lower
/// class runs before any of a higher one.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Class {
/// Work something waits for now: saves, a tick's chunk encoding.
Urgent,
/// The chunks around the player: generation, light, meshing.
Near,
/// Far terrain: runs on what the near work leaves, and never on every
/// worker at once, so near work that arrives finds a worker free.
Far,
}
/// A job's place in the pool's order: lower runs first.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Rank {
pub class: Class,
/// The source's own order within the class (a column's distance).
pub key: i64,
}
impl Rank {
pub const fn new(class: Class, key: i64) -> Self {
Self { class, key }
}
}
/// Work queued for the pool.
pub trait Source: Send + Sync {
/// The rank of the job [`Source::run`] would take next, or None while
/// nothing is queued. Called under the pool's lock: cheap, no blocking.
fn next(&self) -> Option<Rank>;
/// Take the next job and run it on worker `worker` (0..[`Pool::threads`]).
/// False if there was none after all.
fn run(&self, worker: usize) -> bool;
/// Worker `worker` has waited [`IDLE_RELEASE`] with no work: free what it
/// keeps between jobs.
fn idle(&self, _worker: usize) {}
/// Every worker is idle: free what the workers share.
fn all_idle(&self) {}
}
/// How long a worker waits with no job before it frees its buffers.
pub const IDLE_RELEASE: Duration = Duration::from_secs(5);
/// Per-worker state for a source: worker `i` only ever touches slot `i`, so
/// the locks are never contended. The source owns it, so it goes with the source.
pub struct PerWorker<T> {
slots: Box<[Mutex<Option<T>>]>,
}
impl<T> PerWorker<T> {
pub fn new(workers: usize) -> Self {
Self {
slots: (0..workers.max(1)).map(|_| Mutex::new(None)).collect(),
}
}
/// `f` with worker `worker`'s state, made by `init` the first time.
pub fn with<R>(
&self,
worker: usize,
init: impl FnOnce() -> T,
f: impl FnOnce(&mut T) -> R,
) -> R {
let mut slot = lock(&self.slots[worker % self.slots.len()]);
f(slot.get_or_insert_with(init))
}
/// `f` with worker `worker`'s state if it has one.
pub fn with_existing<R>(&self, worker: usize, f: impl FnOnce(&mut T) -> R) -> Option<R> {
lock(&self.slots[worker % self.slots.len()]).as_mut().map(f)
}
/// Drop worker `worker`'s state.
pub fn clear(&self, worker: usize) -> Option<T> {
lock(&self.slots[worker % self.slots.len()]).take()
}
}
struct Entry {
id: u64,
source: Arc<dyn Source>,
/// Workers running one of its jobs.
running: AtomicUsize,
/// Taken out of the pool: the worker that ends its last job tells
/// [`Registration`]'s drop, which waits for it.
retired: AtomicBool,
}
struct Inner {
/// Read by every choice, written only to add or take out a source.
sources: RwLock<Vec<Arc<Entry>>>,
next_id: AtomicU64,
/// Bumped by every [`Pool::notify`]: a worker that found nothing sleeps
/// until it changes, so a job queued while it looked is not missed. A
/// notify is two atomic operations while every worker is busy, and takes
/// `sleep` (held by no source code) only to wake one: the thread queuing
/// work (a frame, a tick) never waits behind a worker's choice.
epoch: AtomicU64,
sleepers: AtomicUsize,
sleep: Mutex<()>,
wake: Condvar,
/// Signalled when a retired source's job ends.
finished: Condvar,
/// Workers running a [`Class::Far`] job.
far: AtomicUsize,
/// Workers that freed their buffers and took no job since.
released: AtomicUsize,
threads: AtomicUsize,
idle_release: Duration,
}
/// The pool: [`pool`] for the process's own, [`Pool::new`] for a test's.
pub struct Pool {
inner: Arc<Inner>,
threads: usize,
}
/// A source's place in the pool; dropping it takes the source out and waits
/// for the jobs of it that are running (so never drop it in one of its own jobs).
pub struct Registration {
inner: Arc<Inner>,
id: u64,
}
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
// A panic in a job is caught; the pool's own state is never left half
// changed, so a poisoned lock is still consistent.
m.lock().unwrap_or_else(|e| e.into_inner())
}
static CONFIGURED: AtomicUsize = AtomicUsize::new(0);
static POOL: OnceLock<Pool> = OnceLock::new();
/// The number of workers the machine gets by default: one thread per core
/// but one, which the render and server threads share with them (they run
/// ahead of the workers: [`crate::favour_current`], [`crate::behind_server`]).
pub fn default_threads() -> usize {
std::thread::available_parallelism()
.map_or(2, |n| n.get())
.saturating_sub(1)
.max(1)
}
/// Size the process's pool before its first use (later calls change
/// nothing): `threads` workers, or `HORIZON_JOB_THREADS` when it is set.
pub fn configure(threads: usize) {
CONFIGURED.store(threads.max(1), Ordering::Relaxed);
}
/// The process's pool, started on first use.
pub fn pool() -> &'static Pool {
POOL.get_or_init(|| {
let threads = std::env::var("HORIZON_JOB_THREADS")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.filter(|&n| n > 0)
.unwrap_or_else(|| match CONFIGURED.load(Ordering::Relaxed) {
0 => default_threads(),
n => n,
});
// The name the crash reports know a worker by (vanilla's `Util` workers).
Pool::new(threads, "horizon-worker")
})
}
impl Pool {
/// A pool of `threads` workers named `name-<i>`. Workers that the system
/// refuses to start leave it smaller ([`Pool::threads`] counts those that started).
pub fn new(threads: usize, name: &str) -> Self {
Self::with_idle_release(threads, name, IDLE_RELEASE)
}
/// [`Pool::new`] whose workers free their buffers after `idle_release`.
pub fn with_idle_release(threads: usize, name: &str, idle_release: Duration) -> Self {
let inner = Arc::new(Inner {
sources: RwLock::new(Vec::new()),
next_id: AtomicU64::new(0),
epoch: AtomicU64::new(0),
sleepers: AtomicUsize::new(0),
sleep: Mutex::new(()),
wake: Condvar::new(),
finished: Condvar::new(),
far: AtomicUsize::new(0),
released: AtomicUsize::new(0),
threads: AtomicUsize::new(threads.max(1)),
idle_release,
});
let mut started = 0;
for i in 0..threads.max(1) {
let worker = inner.clone();
let spawned = std::thread::Builder::new()
.name(format!("{name}-{i}"))
.spawn(move || {
// Ticks and frames come first.
crate::behind_server();
work(&worker, i);
});
if spawned.is_ok() {
started += 1;
}
}
inner.threads.store(started.max(1), Ordering::Relaxed);
Self {
inner,
threads: started.max(1),
}
}
/// The workers: the size of a source's [`PerWorker`] and of the work it
/// keeps queued.
pub fn threads(&self) -> usize {
self.threads
}
/// Add a source; its jobs run until the registration is dropped.
pub fn register(&self, source: Arc<dyn Source>) -> Registration {
let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
self.inner
.sources
.write()
.unwrap_or_else(|e| e.into_inner())
.push(Arc::new(Entry {
id,
source,
running: AtomicUsize::new(0),
retired: AtomicBool::new(false),
}));
self.inner.notify(true);
Registration {
inner: self.inner.clone(),
id,
}
}
/// Wake a worker for a job just queued (after the queue's lock is released).
pub fn notify(&self) {
self.inner.notify(false);
}
/// Wake every worker: many jobs were queued at once.
pub fn notify_all(&self) {
self.inner.notify(true);
}
}
impl Inner {
fn notify(&self, all: bool) {
self.epoch.fetch_add(1, Ordering::SeqCst);
if self.sleepers.load(Ordering::SeqCst) > 0 {
// A worker between counting itself asleep and waiting holds
// `sleep`: taking it here means it is waiting when told.
drop(lock(&self.sleep));
if all {
self.wake.notify_all();
} else {
self.wake.notify_one();
}
}
}
}
impl Registration {
/// Wake a worker for a job just queued: [`Pool::notify`].
pub fn notify(&self) {
self.inner.notify(false);
}
pub fn notify_all(&self) {
self.inner.notify(true);
}
}
impl Drop for Registration {
fn drop(&mut self) {
let entry = {
let mut sources = self
.inner
.sources
.write()
.unwrap_or_else(|e| e.into_inner());
let Some(at) = sources.iter().position(|e| e.id == self.id) else {
return;
};
sources.swap_remove(at)
};
// No worker takes a job of it now (a worker counts itself running
// while it holds the sources' read lock); wait for those that did.
entry.retired.store(true, Ordering::SeqCst);
let mut sleep = lock(&self.inner.sleep);
while entry.running.load(Ordering::SeqCst) > 0 {
sleep = self
.inner
.finished
.wait(sleep)
.unwrap_or_else(|e| e.into_inner());
}
drop(sleep);
// The source's last reference may go here, outside every lock.
drop(entry);
}
}
/// The most urgent queued job's source, counted as running, if any may run now.
fn choose(inner: &Inner) -> Option<(Arc<Entry>, bool)> {
let threads = inner.threads.load(Ordering::Relaxed);
// Far work keeps one worker free for near work (when there are two or more).
let far_cap = threads.saturating_sub(1).max(1);
let sources = inner.sources.read().unwrap_or_else(|e| e.into_inner());
let mut skip_far = false;
loop {
let far_open = !skip_far && inner.far.load(Ordering::SeqCst) < far_cap;
let mut best: Option<(&Arc<Entry>, Rank)> = None;
for entry in sources.iter() {
let Some(rank) = entry.source.next() else {
continue;
};
if rank.class == Class::Far && !far_open {
continue;
}
if best.is_none_or(|(_, b)| rank < b) {
best = Some((entry, rank));
}
}
let (entry, rank) = best?;
let far = rank.class == Class::Far;
if far && !take_far_place(inner, far_cap) {
// Another worker took the last far place meanwhile.
skip_far = true;
continue;
}
entry.running.fetch_add(1, Ordering::SeqCst);
return Some((entry.clone(), far));
}
}
/// One more far job running, if fewer than `cap` are.
fn take_far_place(inner: &Inner, cap: usize) -> bool {
let mut n = inner.far.load(Ordering::SeqCst);
while n < cap {
match inner
.far
.compare_exchange_weak(n, n + 1, Ordering::SeqCst, Ordering::SeqCst)
{
Ok(_) => return true,
Err(now) => n = now,
}
}
false
}
fn work(inner: &Inner, worker: usize) {
let mut released = false;
loop {
let epoch = inner.epoch.load(Ordering::SeqCst);
if let Some((entry, far)) = choose(inner) {
if released {
inner.released.fetch_sub(1, Ordering::Relaxed);
released = false;
}
// A panicking job loses its own work, not the worker: the panic
// hook has already reported it.
let _ = catch_unwind(AssertUnwindSafe(|| entry.source.run(worker)));
if far {
inner.far.fetch_sub(1, Ordering::SeqCst);
}
entry.running.fetch_sub(1, Ordering::SeqCst);
if entry.retired.load(Ordering::SeqCst) {
drop(lock(&inner.sleep));
inner.finished.notify_all();
}
continue;
}
let sleep = lock(&inner.sleep);
inner.sleepers.fetch_add(1, Ordering::SeqCst);
let still = |_: &mut ()| inner.epoch.load(Ordering::SeqCst) == epoch;
let timed_out = if released {
drop(
inner
.wake
.wait_while(sleep, still)
.unwrap_or_else(|e| e.into_inner()),
);
false
} else {
let (sleep, wait) = inner
.wake
.wait_timeout_while(sleep, inner.idle_release, still)
.unwrap_or_else(|e| e.into_inner());
drop(sleep);
wait.timed_out()
};
inner.sleepers.fetch_sub(1, Ordering::SeqCst);
if !timed_out {
continue;
}
// Nothing to do for a while (a server nobody is on, a player standing
// still): free this worker's buffers until the next job needs them.
released = true;
let last = inner.released.fetch_add(1, Ordering::Relaxed) + 1
== inner.threads.load(Ordering::Relaxed);
let sources: Vec<_> = inner
.sources
.read()
.unwrap_or_else(|e| e.into_inner())
.iter()
.map(|e| e.source.clone())
.collect();
for source in &sources {
let _ = catch_unwind(AssertUnwindSafe(|| source.idle(worker)));
}
if last {
// The allocator keeps freed pages until memory runs short: once
// every worker has freed its own, hand them back.
for source in &sources {
let _ = catch_unwind(AssertUnwindSafe(|| source.all_idle()));
}
crate::release_free_memory();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
use std::sync::mpsc;
use std::time::Instant;
/// Jobs with fixed ranks; each records its name when it runs.
struct Fixed {
jobs: Mutex<VecDeque<(Rank, &'static str)>>,
ran: mpsc::Sender<&'static str>,
hold: Option<Arc<(Mutex<bool>, Condvar)>>,
}
impl Source for Fixed {
fn next(&self) -> Option<Rank> {
lock(&self.jobs).iter().map(|(r, _)| *r).min()
}
fn run(&self, _worker: usize) -> bool {
let job = {
let mut jobs = lock(&self.jobs);
let Some(at) = jobs
.iter()
.enumerate()
.min_by_key(|(_, (r, _))| *r)
.map(|(i, _)| i)
else {
return false;
};
jobs.remove(at).unwrap()
};
if let Some(hold) = &self.hold {
let (open, cv) = &**hold;
let mut open = lock(open);
while !*open {
open = cv.wait(open).unwrap();
}
}
let _ = self.ran.send(job.1);
true
}
}
fn fixed(jobs: &[(Rank, &'static str)], ran: mpsc::Sender<&'static str>) -> Arc<Fixed> {
Arc::new(Fixed {
jobs: Mutex::new(jobs.iter().copied().collect()),
ran,
hold: None,
})
}
fn recv_n(rx: &mpsc::Receiver<&'static str>, n: usize) -> Vec<&'static str> {
(0..n)
.map(|_| rx.recv_timeout(Duration::from_secs(10)).unwrap())
.collect()
}
#[test]
fn one_worker_runs_every_source_in_rank_order() {
let pool = Pool::new(1, "test-job");
let (tx, rx) = mpsc::channel();
// Held until both sources are in, so the order is the pool's choice.
let gate = Arc::new((Mutex::new(false), Condvar::new()));
let first = Arc::new(Fixed {
jobs: Mutex::new([(Rank::new(Class::Urgent, i64::MIN), "gate")].into()),
ran: tx.clone(),
hold: Some(gate.clone()),
});
let _g = pool.register(first);
let a = fixed(
&[
(Rank::new(Class::Near, 5), "near5"),
(Rank::new(Class::Far, 0), "far0"),
],
tx.clone(),
);
let b = fixed(
&[
(Rank::new(Class::Near, 1), "near1"),
(Rank::new(Class::Urgent, 9), "urgent9"),
],
tx,
);
let _a = pool.register(a);
let _b = pool.register(b);
*gate.0.lock().unwrap() = true;
gate.1.notify_all();
assert_eq!(
recv_n(&rx, 5),
["gate", "urgent9", "near1", "near5", "far0"]
);
}
#[test]
fn far_work_leaves_a_worker_for_near_work() {
let pool = Pool::new(3, "test-job");
let gate = Arc::new((Mutex::new(false), Condvar::new()));
let (tx, rx) = mpsc::channel();
let far = Arc::new(Fixed {
jobs: Mutex::new((0..6).map(|i| (Rank::new(Class::Far, i), "far")).collect()),
ran: tx.clone(),
hold: Some(gate.clone()),
});
let _far = pool.register(far);
// Two far jobs hold two workers; the third stays free for this one.
std::thread::sleep(Duration::from_millis(100));
let near = fixed(&[(Rank::new(Class::Near, 0), "near")], tx);
let near_reg = pool.register(near);
near_reg.notify();
assert_eq!(rx.recv_timeout(Duration::from_secs(10)).unwrap(), "near");
*gate.0.lock().unwrap() = true;
gate.1.notify_all();
assert_eq!(recv_n(&rx, 6), ["far"; 6]);
}
/// A source that counts its jobs, queued from outside while the workers run.
struct Counter {
left: Mutex<u64>,
done: AtomicUsize,
per_worker: PerWorker<u64>,
idle: AtomicBool,
all_idle: AtomicBool,
}
impl Source for Counter {
fn next(&self) -> Option<Rank> {
(*lock(&self.left) > 0).then_some(Rank::new(Class::Near, 0))
}
fn run(&self, worker: usize) -> bool {
{
let mut left = lock(&self.left);
if *left == 0 {
return false;
}
*left -= 1;
}
self.per_worker.with(worker, || 0, |n| *n += 1);
self.done.fetch_add(1, Ordering::Relaxed);
true
}
fn idle(&self, worker: usize) {
self.per_worker.clear(worker);
self.idle.store(true, Ordering::Relaxed);
}
fn all_idle(&self) {
self.all_idle.store(true, Ordering::Relaxed);
}
}
#[test]
fn no_job_is_missed_when_queued_while_workers_look() {
let pool = Pool::new(4, "test-job");
let counter = Arc::new(Counter {
left: Mutex::new(0),
done: AtomicUsize::new(0),
per_worker: PerWorker::new(pool.threads()),
idle: AtomicBool::new(false),
all_idle: AtomicBool::new(false),
});
let reg = pool.register(counter.clone());
for _ in 0..20_000 {
*lock(&counter.left) += 1;
reg.notify();
}
let deadline = Instant::now() + Duration::from_secs(20);
while counter.done.load(Ordering::Relaxed) < 20_000 {
assert!(Instant::now() < deadline, "jobs left behind");
std::thread::sleep(Duration::from_millis(1));
}
}
#[test]
fn idle_workers_free_their_state() {
let release = Duration::from_millis(300);
let pool = Pool::with_idle_release(2, "test-job", release);
let counter = Arc::new(Counter {
left: Mutex::new(10),
done: AtomicUsize::new(0),
per_worker: PerWorker::new(pool.threads()),
idle: AtomicBool::new(false),
all_idle: AtomicBool::new(false),
});
let reg = pool.register(counter.clone());
reg.notify_all();
let deadline = Instant::now() + release * 20;
while !counter.all_idle.load(Ordering::Relaxed) {
assert!(Instant::now() < deadline, "workers never released");
std::thread::sleep(Duration::from_millis(20));
}
assert!(counter.idle.load(Ordering::Relaxed));
assert_eq!(counter.done.load(Ordering::Relaxed), 10);
assert!(counter.per_worker.slots.iter().all(|s| lock(s).is_none()));
}
#[test]
fn dropping_a_registration_waits_for_its_running_job() {
let pool = Pool::new(2, "test-job");
let gate = Arc::new((Mutex::new(false), Condvar::new()));
let (tx, rx) = mpsc::channel();
let held = Arc::new(Fixed {
jobs: Mutex::new(
[
(Rank::new(Class::Near, 0), "held"),
(Rank::new(Class::Near, 1), "second"),
]
.into(),
),
ran: tx,
hold: Some(gate.clone()),
});
let reg = pool.register(held.clone());
std::thread::sleep(Duration::from_millis(100));
let opener = {
let gate = gate.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(200));
*gate.0.lock().unwrap() = true;
gate.1.notify_all();
})
};
let began = Instant::now();
drop(reg);
// Both jobs may have started (two workers); none runs after the drop.
assert!(began.elapsed() >= Duration::from_millis(150));
opener.join().unwrap();
let ran: Vec<_> = rx.try_iter().collect();
assert!(ran.contains(&"held"));
std::thread::sleep(Duration::from_millis(100));
assert!(rx.try_iter().next().is_none());
// The pool's own references are gone.
assert_eq!(Arc::strong_count(&held), 1);
}
#[test]
fn a_panicking_job_keeps_its_worker() {
struct Boom(AtomicUsize);
impl Source for Boom {
fn next(&self) -> Option<Rank> {
(self.0.load(Ordering::Relaxed) < 2).then_some(Rank::new(Class::Near, 0))
}
fn run(&self, _worker: usize) -> bool {
if self.0.fetch_add(1, Ordering::Relaxed) == 0 {
panic!("first job fails");
}
true
}
}
let pool = Pool::new(1, "test-job");
let boom = Arc::new(Boom(AtomicUsize::new(0)));
let reg = pool.register(boom.clone());
reg.notify();
let deadline = Instant::now() + Duration::from_secs(10);
while boom.0.load(Ordering::Relaxed) < 2 {
assert!(Instant::now() < deadline, "the worker died with the panic");
std::thread::sleep(Duration::from_millis(5));
}
}
}
+1
View File
@@ -4,6 +4,7 @@
/// The game window's boot screen, before wgpu's first frame paints over it.
#[cfg(target_os = "windows")]
pub mod boot_paint;
pub mod jobs;
pub mod memory_limit;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -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
+3
View File
@@ -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) |
+12
View File
@@ -57,6 +57,18 @@ python3 tools/perf_bench.py --table horizon-mine vanilla-mine mods-mine
Options: `--distances 8,16`, `--sizes 1920x1080`, `--scenes village,ocean`, `--seconds 20`, `--repeats 5`, `--heap 4G` (vanilla), `--offscreen` (Horizon, for locked or headless sessions). Results go to `benchmarks/perf/<label>-rd<N>.json` at 1280x720 and `<label>-1080p-rd<N>.json` at 1920x1080 (other sizes `<label>-<W>x<H>-rd<N>.json`; `--table` takes those labels), one [benchmark report](benchmark-report.md) per window size and render distance (the same format the game's `--benchmark` and the launcher write), with the load average before each game; each game's log, and the vanilla driver's event log, stay in `.local/perf-bench/`. Keep the machine otherwise idle, plugged in, and the window on a screen.
### Many-entity stress scenes
`--scenes stress` (or some of `stress_empty`, `stress_mobs`, `stress_items`, `stress_frames`, `stress_armor_stands`, `stress_chests`, `stress_signs`, `stress_banners`, `stress_particles`) runs the scenes of `tools/stress_scenes.py` instead of flying: 1000 still mobs of 8 kinds, 2000 dropped items, 512 item frames, 500 armoured armor stands, 800 chests and shulker boxes, a wall of 687 written signs, 300 banners, and steady particles from repeating command blocks (flames, smoke, end rods, firework bursts, TNT in the air). Each is built high above the terrain by the same vanilla commands in every game (Horizon through `horizon-ctl command`, vanilla and vanilla-mods through the driver mod), settles 6 seconds and is held still for `--seconds`; `stress_empty` is the same camera and floor without them. Entities are drawn only within a distance that grows with the render distance up to 20 (`Entity.shouldRenderAtSqrDistance`), so run them at 16 or more:
```sh
python3 tools/perf_bench.py --game horizon --scenes stress --distances 16 --label stress-horizon
python3 tools/perf_bench.py --game vanilla --scenes stress --distances 16 --label stress-vanilla
python3 tools/perf_bench.py --table stress-horizon stress-vanilla
```
Besides the FPS table, `--table` then gives each scene's median frame CPU time (Horizon), client tick time and resident memory. Chests stay closed: opening them needs a player, which neither driver has. The stress scenes use 26.3's command syntax, so they are not for `--game neoforge`.
On Windows, run `python tools\perf_bench.py ...` the same way. Horizon's control endpoint there is a localhost TCP port, which the game writes to a `.json` file in the temporary folder. `--build` can point at an installed release (`%APPDATA%\horizon-launcher\versions\vX.Y.Z`, with `--resources` its `.local\horizon-client\26.3`), and the report then gives that release's version.
## Frame pacing
@@ -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
+67
View File
@@ -236,6 +236,10 @@ class PortabilityTests(unittest.TestCase):
self.assertEqual(r["metrics"]["fps.village.avg_fps"]["mean"], 200.0)
self.assertEqual(pb.report(args, 8, [dict(run, load_before=1.25)], {})["conditions"]["load_avg_1m"], 1.25)
self.assertTrue(r["hardware"]["os"] and r["hardware"]["cpu_threads"])
self.assertNotIn("server", r["conditions"])
# A run on a dedicated server only compares with runs on the same kind of server.
on_server = argparse.Namespace(offscreen=False, settle=2.0, label="x", server="vanilla")
self.assertEqual(pb.report(on_server, 8, [run], {})["conditions"]["server"], "vanilla")
def test_nvidia_memory_not_available(self):
from unittest import mock
@@ -328,3 +332,66 @@ class PortabilityTests(unittest.TestCase):
"-Djava.library.path=${natives_directory}/java", "-cp", "${classpath}"]}}
self.assertEqual(manifest_jvm_flags(manifest), ["-XX:StackShadowPages=32"])
self.assertEqual(manifest_jvm_flags({}), [])
class StressSceneTests(unittest.TestCase):
def test_stress_selects_every_stress_scene_after_the_flights(self):
import argparse
from tools import stress_scenes
from tools.perf_bench import scene_list
names = [s[0] for s in scene_list(argparse.Namespace(scenes="ocean,stress"))]
self.assertEqual(names, ["ocean"] + stress_scenes.NAMES)
self.assertEqual([s[0] for s in scene_list(argparse.Namespace(scenes="stress_mobs"))], ["stress_mobs"])
def test_scenes_have_their_entity_counts(self):
from tools import stress_scenes
summons = lambda name, kind: sum(c.startswith(f"summon {kind} ") for c in stress_scenes.setup(name))
self.assertEqual(sum(summons("stress_mobs", k) for k in stress_scenes.MOBS), 1000)
self.assertEqual(summons("stress_items", "item"), 2000)
self.assertEqual(summons("stress_frames", "item_frame"), 512)
self.assertEqual(summons("stress_armor_stands", "armor_stand"), 500)
self.assertEqual(stress_scenes.setup("stress_empty"), stress_scenes.SETTINGS + stress_scenes.floor(
*stress_scenes.origin("stress_empty")))
# Only the run's first stress scene sets the game rules.
self.assertEqual(stress_scenes.setup("stress_empty", first=False),
stress_scenes.floor(*stress_scenes.origin("stress_empty")))
def test_every_entity_is_near_enough_to_be_drawn_at_rd16(self):
# Entity.shouldRenderAtSqrDistance with viewScale 2: a dropped item (0.25) within 32 blocks.
from tools import stress_scenes
for name in ("stress_mobs", "stress_items", "stress_frames", "stress_armor_stands"):
_, at, _, _ = stress_scenes.scene(name)
eye = (at[0], at[1] + 1.62, at[2])
for line in stress_scenes.setup(name):
if line.startswith("summon "):
x, y, z = map(float, line.split()[2:5])
distance = sum((a - b) ** 2 for a, b in zip((x, y, z), eye)) ** 0.5
self.assertLess(distance, 32.0, (name, line))
self.assertGreater(z, eye[2], (name, line))
def test_commands_are_single_lines_inside_the_world(self):
from tools import stress_scenes
for name in stress_scenes.NAMES:
for line in stress_scenes.setup(name):
self.assertNotIn("\n", line)
self.assertLess(len(line), 1024)
self.assertTrue(all(len(c) < 1024 for c in stress_scenes.cleanup(name)))
def test_cleanup_covers_every_block_a_scene_places(self):
from tools import stress_scenes
slabs = [list(map(int, c.split()[1:7])) for c in stress_scenes.cleanup("stress_mobs") if c.startswith("fill")]
for slab in slabs:
dx, dy, dz = (abs(slab[i + 3] - slab[i]) + 1 for i in range(3))
self.assertLessEqual(dx * dy * dz, 32768) # vanilla's fill limit
covered = lambda x, y, z: any(s[0] <= x <= s[3] and s[1] <= y <= s[4] and s[2] <= z <= s[5] for s in slabs)
for name in stress_scenes.NAMES:
for line in stress_scenes.setup(name):
words = line.split()
if words[0] == "fill":
a, b = list(map(int, words[1:4])), list(map(int, words[4:7]))
for x in (a[0], b[0]):
for y in (a[1], b[1]):
for z in (a[2], b[2]):
self.assertTrue(covered(x, y, z), (name, line))
elif words[0] == "setblock":
self.assertTrue(covered(*map(int, words[1:4])), (name, line))
+9 -1
View File
@@ -32,9 +32,17 @@ impl Potions { fn remote_thrown_motion(&mut self, _id: u32, _v: Vec3) -> bool {
#[derive(Default)]
struct End;
impl End { fn remote_eye_motion(&mut self, _id: u32, _v: [f64; 3]) {} }
// game/mob_index.rs: the same answers as a linear search.
#[derive(Default)]
struct MobIndex;
impl MobIndex {
fn get_mut<'a>(&mut self, mobs: &'a mut [Entity], id: u32) -> Option<&'a mut Entity> {
mobs.iter_mut().find(|m| m.id == id)
}
}
#[derive(Default)]
struct Game {
mobs: Vec<Entity>, dropped: Vec<Entity>, falling: Vec<Entity>, tnts: Vec<Entity>, orbs: Vec<Entity>,
mobs: Vec<Entity>, mob_index: MobIndex, dropped: Vec<Entity>, falling: Vec<Entity>, tnts: Vec<Entity>, orbs: Vec<Entity>,
creatures: Creatures, dragon_fireballs: Vec<Entity>, tridents: Vec<Entity>,
rockets: Vec<Entity>, remote: Option<Remote>,
potions: Potions, end: End,
+236 -19
View File
@@ -30,6 +30,11 @@ Games:
(mods/horizon/src/neoforge-bench) + the jars of --mod-jars and
--deps, the user's own; 26.3 options it does not know are ignored
Many-entity stress scenes (tools/stress_scenes.py: mobs, dropped items, item frames, armor stands,
containers, signs, banners, particles), built by the same vanilla commands in every game and held still:
python3 tools/perf_bench.py --game horizon --scenes stress --distances 16 --label stress-horizon
--port-work loads the mods a horizon-port-pack work folder carried over into
Horizon as dev mods.
@@ -38,6 +43,8 @@ distance (docs/benchmark-report.md), over --repeats games. Games run with no
audio, from a scratch directory under .local/perf-bench.
"""
import argparse
import contextlib
import hashlib
import bisect
import datetime
import json
@@ -53,14 +60,16 @@ import sys
import tempfile
import threading
import time
import uuid
import zipfile
from pathlib import Path
try:
from . import lab, runtime
from . import lab, runtime, stress_scenes
except ImportError:
import lab
import runtime
import stress_scenes
ROOT = lab.ROOT
WORK = ROOT / ".local/perf-bench"
@@ -317,8 +326,36 @@ def size_label(label, width, height):
def scene_list(args):
"""(name, from, to, pitch) of the scenes asked for: the flights, then the stress scenes ("stress" is all of them)."""
picked = args.scenes.split(",") if args.scenes else [s[0] for s in SCENES]
return [s for s in SCENES if s[0] in picked]
if "stress" in picked:
picked += stress_scenes.NAMES
# --server-bots adds the crowd scene over the bots.
return ([s for s in SCENES if s[0] in picked] + list(getattr(args, "extra_scenes", None) or [])
+ [stress_scenes.scene(n) for n in stress_scenes.NAMES if n in picked])
# Seconds a stress scene settles after its setup: the server spawns, the client receives and draws it.
STRESS_SETTLE = 6.0
def first_stress(args):
"""The first stress scene of the run, which sets the game rules for all of them."""
return next((s[0] for s in scene_list(args) if s[0] in stress_scenes.NAMES), None)
def settle_seconds(args, name):
return max(args.settle, STRESS_SETTLE) if name in stress_scenes.NAMES else args.settle
def scene_rss(sampler, windows):
"""Median resident memory (MB) inside each named window: {name: MB}."""
out = {}
for name, (a, b) in windows.items():
during = [rss for t, rss in sampler.samples if a <= t <= b]
if during:
out[name] = statistics.median(during) / 2**20
return out
def yaw(start, end):
@@ -451,6 +488,103 @@ def wait_loaded(control, near, limit, min_chunks=0):
raise TimeoutError(f"not loaded after {limit} s")
BENCH_PLAYER = "Bench"
class LocalServer:
"""--server: a local dedicated server every client of the run joins instead of creating a world. Seed SEED,
creative, offline, view distance = the run's render distance, BENCH_PLAYER an operator (the scenes use /tp
and /time). Its world stays in WORK/server-<kind>-rd<d> between runs; one that is new gets an unmeasured
warm-up join first, so every measured join reads terrain already generated: the clients are compared, not
the server's generation."""
def __init__(self, args, distance):
self.kind, self.dir = args.server, WORK / f"server-{args.server}-rd{distance}"
self.fresh = not (self.dir / "world").exists()
self.dir.mkdir(parents=True, exist_ok=True)
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
self.port = probe.getsockname()[1]
props = {"level-seed": SEED, "online-mode": "false", "server-ip": "127.0.0.1", "server-port": self.port,
"view-distance": distance, "simulation-distance": 12, "gamemode": "creative",
"force-gamemode": "true", "spawn-protection": 0, "allow-flight": "true", "max-players": 300,
"white-list": "false", "level-name": "world"}
(self.dir / "server.properties").write_text("".join(f"{k}={v}\n" for k, v in props.items()))
(self.dir / "eula.txt").write_text("eula=true\n")
# UUIDUtil.createOfflinePlayerUUID: a name-based (version 3) UUID of "OfflinePlayer:<name>".
offline = uuid.UUID(bytes=hashlib.md5(f"OfflinePlayer:{BENCH_PLAYER}".encode()).digest(), version=3)
(self.dir / "ops.json").write_text(json.dumps(
[{"uuid": str(offline), "name": BENCH_PLAYER, "level": 4, "bypassesPlayerLimit": False}]))
if self.kind == "horizon":
binary = Path(args.server_build).resolve() / ("horizon-client.exe" if sys.platform == "win32" else "horizon-client")
self.command = [str(binary), "--resources", str(Path(args.resources).resolve()), "--dedicated", "--nogui"]
else:
java = Path(os.environ["JAVA_HOME"]) / "bin/java" if os.environ.get("JAVA_HOME") else "java"
self.command = [str(java), "-Xmx4G", "-jar", str(VANILLA / "jars/server.jar"), "nogui"]
@property
def address(self):
return f"127.0.0.1:{self.port}"
def __enter__(self):
self.log_path = self.dir / "server.log"
self.log = self.log_path.open("w")
env = lab.game_env(HORIZON_TELEMETRY_URL="off") if self.kind == "horizon" else None
self.proc = subprocess.Popen(self.command, cwd=self.dir, env=env, stdin=subprocess.PIPE, text=True,
stdout=self.log, stderr=subprocess.STDOUT)
deadline = time.time() + 600
while "Done (" not in self.log_path.read_text(errors="replace"):
if self.proc.poll() is not None or time.time() > deadline:
self.__exit__()
raise RuntimeError(f"the {self.kind} server did not start; see {self.log_path}")
time.sleep(0.5)
return self
def crowd(self, args):
"""--server-bots: horizon-bots walking around the spawn for the whole run (they need a few seconds to join
and settle), and the scene that flies over them: (name, from, to, pitch)."""
binary = Path(args.bots_build or args.build).resolve() / ("horizon-bots.exe" if sys.platform == "win32" else "horizon-bots")
self.bots = subprocess.Popen([str(binary), "--server", self.address, "--bots", str(args.server_bots),
"--behaviour", "walk", "--view-distance", "10", "--join-every", "0.1",
"--seconds", "100000", "--seed", str(SEED)],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
deadline = time.time() + 120
for line in self.bots.stdout:
if '"spawn"' in line:
sx, sy, sz = json.loads(line)["spawn"]
break
if time.time() > deadline:
raise RuntimeError("horizon-bots: no spawn position")
else:
raise RuntimeError("horizon-bots ended before a bot spawned")
threading.Thread(target=self.bots.stdout.read, daemon=True).start()
time.sleep(args.server_bots * 0.1 + 10)
# Bots fly 24 blocks above the spawn, 8 to 48 blocks out (horizon-bots' walk).
return ("crowd", (sx - 32.0, sy + 40.0, sz), (sx + 32.0, sy + 40.0, sz), 50.0)
def __exit__(self, *_):
if getattr(self, "bots", None):
stop(self.bots)
try:
self.proc.stdin.write("stop\n")
self.proc.stdin.flush()
self.proc.wait(timeout=120)
except (OSError, subprocess.TimeoutExpired, ValueError):
pass
stop(self.proc)
self.log.close()
def scene_commands(args, control, start, pitch):
"""Time and teleport for a scene: the game's own commands, or the server's when it joined one."""
if args.server:
control("command time set 6000")
control("command tp @s {} {} {} 0 {}".format(*start, pitch))
else:
control("time 6000")
control("tp {} {} {}".format(*start))
def run_horizon(args, distance):
game_dir = WORK / "horizon"
shutil.rmtree(game_dir, ignore_errors=True)
@@ -490,17 +624,23 @@ def run_horizon(args, distance):
sampler.start()
control = Control(sock)
result = {"options_txt": options, "scenes": {}}
windows = []
windows, scene_windows = [], {}
try:
title, at = poll(control, lambda s: s.get("screen") == "title" and s.get("renderer"), 300)
result["renderer"] = title["renderer"]
result["startup"] = {"title_s": at - started}
control(f"open {WORLD} {SEED} creative")
result["startup"]["world_s"] = wait_loaded(control, lambda p: True, args.timeout, view_chunks(distance)) - started
if args.server:
joined = time.time()
control(f"connect {args.server_address} {BENCH_PLAYER}")
else:
control(f"open {WORLD} {SEED} creative")
loaded = wait_loaded(control, lambda p: True, args.timeout, view_chunks(distance))
result["startup"]["world_s"] = loaded - started
if args.server:
result["startup"]["join_s"] = loaded - joined
for name, start, end, pitch in scene_list(args):
control("time 6000")
meshed = control("status")["meshed_sections"]
control("tp {} {} {}".format(*start))
scene_commands(args, control, start, pitch)
tp = time.time()
# A zero-length flight holds the player at the start, flying.
control("fly {0} {1} {2} {0} {1} {2} 0.1 {3}".format(*start, pitch))
@@ -510,13 +650,27 @@ def run_horizon(args, distance):
# Section meshes built while loading and while flying (Horizon only).
loaded_meshes = control("status")["meshed_sections"]
scene["meshes_load"] = loaded_meshes - meshed
time.sleep(args.settle)
loaded_meshes = control("status")["meshed_sections"]
if name in stress_scenes.NAMES:
for line in stress_scenes.setup(name, first=name == first_stress(args)):
control("command " + line)
time.sleep(settle_seconds(args, name))
before = control("status")
loaded_meshes = before["meshed_sections"]
begin = time.time()
summary = control("fly {} {} {} {} {} {} {} {} raw".format(*start, *end, args.seconds, pitch))
windows.append((begin, time.time()))
scene_windows[name] = windows[-1]
scene.update({k: summary.get(k) or [] for k in ("frame_ms", "cpu_ms", "gpu_ms")})
status = control("status")
# The client's own ticks: the frames' tick phase over the ticks the flight ran.
ticks = status.get("ticks", 0) - before.get("ticks", 0)
tick_phase = (summary.get("phases_avg_ms") or {}).get("tick")
if ticks > 0 and tick_phase is not None:
scene["client_tick_ms"] = tick_phase * summary.get("frames", 0) / ticks
scene["entities"] = {"mobs": status.get("mobs"), "block_entities": status.get("block_entities")}
if name in stress_scenes.NAMES:
for line in stress_scenes.cleanup(name):
control("command " + line)
scene["meshes_flight"] = status["meshed_sections"] - loaded_meshes
renderer = status.get("renderer") or {}
scene["gpu_mb"] = nvidia_memory_mb(proc.pid) or (renderer.get("gpu_allocated_bytes") or 0) / 2**20 or None
@@ -531,6 +685,8 @@ def run_horizon(args, distance):
sampler.stop.set()
sock.unlink(missing_ok=True)
result["memory"] = sampler.summary(windows)
for name, mb in scene_rss(sampler, scene_windows).items():
result["scenes"][name]["rss_mb"] = mb
args.release = release_version(WORK / f"horizon-rd{distance}.log")
return result
@@ -649,7 +805,8 @@ def run_vanilla(args, distance):
script = WORK / "script.json"
script.write_text(json.dumps({
"world": WORLD, "seed": SEED, "settle": args.settle, "seconds": args.seconds, "timeout": args.timeout,
"scenes": [{"name": n, "from": a, "to": b, "pitch": p} for n, a, b, p in scene_list(args)],
"scenes": driver_scenes(args),
**({"server": args.server_address} if args.server else {}),
}))
events = WORK / f"{args.game}-rd{distance}.events.jsonl"
events.unlink(missing_ok=True)
@@ -664,7 +821,7 @@ def run_vanilla(args, distance):
"-Dfabric.log.disableAnsi=true",
"-cp", os.pathsep.join(map(str, game_jars + fabric)),
"net.fabricmc.loader.impl.launch.knot.KnotClient",
"--username", "Bench", "--uuid", "00000000000000000000000000000000", "--accessToken", "0",
"--username", BENCH_PLAYER, "--uuid", "00000000000000000000000000000000", "--accessToken", "0",
"--offlineDeveloperMode", "--version", lab.VERSION, "--gameDir", str(game_dir),
"--assetsDir", str(assets), "--assetIndex", manifest["assetIndex"]["id"],
"--width", str(args.width), "--height", str(args.height)]
@@ -676,6 +833,18 @@ def run_vanilla(args, distance):
return driven(args, distance, command, game_dir, env, log, events, options, loaded_mods)
def driver_scenes(args):
"""The scenes as the driver mods read them: stress scenes carry their setup commands and settle time."""
out = []
for n, a, b, p in scene_list(args):
scene = {"name": n, "from": a, "to": b, "pitch": p, "settle": settle_seconds(args, n)}
if n in stress_scenes.NAMES:
scene["setup"] = stress_scenes.setup(n, first=n == first_stress(args))
scene["cleanup"] = stress_scenes.cleanup(n)
out.append(scene)
return out
def driven(args, distance, command, game_dir, env, log, events, options, loaded_mods):
"""Runs a Java client whose driver mod writes `events`, and reads its results."""
started = time.time()
@@ -702,20 +871,29 @@ def driven(args, distance, command, game_dir, env, log, events, options, loaded_
"renderer": {k: title[k] for k in ("gpu", "vendor", "backend", "driver", "size",
"render_distance", "simulation_distance")},
"startup": {"title_s": title["epoch_ms"] / 1000 - started,
"world_s": world["ready_epoch_ms"] / 1000 - started},
"world_s": world["ready_epoch_ms"] / 1000 - started,
# --server: the driver connects as soon as the title screen shows.
**({"join_s": (world["ready_epoch_ms"] - title["epoch_ms"]) / 1000} if args.server else {})},
"scenes": {},
}
windows = []
windows, scene_windows = [], {}
loaded = {e["scene"]: e for e in by("loaded")}
flights = {e["scene"]: e for e in by("fly")}
for flown in by("flown"):
name = flown["scene"]
windows.append((flights[name]["epoch_ms"] / 1000, flown["epoch_ms"] / 1000))
scene_windows[name] = windows[-1]
scene = {"load_s": loaded[name]["load_ms"] / 1000, "frame_ms": flown["frame_ms"],
"chunks": flown["chunks"], "gpu_mb": nvidia}
if flown.get("tick_ms"):
scene["client_tick_ms"] = statistics.mean(flown["tick_ms"])
if "entities" in flown:
scene["entities"] = flown["entities"]
result["scenes"][name] = scene
print_scene(args.game, distance, name, scene)
result["memory"] = sampler.summary(windows)
for name, mb in scene_rss(sampler, scene_windows).items():
result["scenes"][name]["rss_mb"] = mb
return result
@@ -742,7 +920,8 @@ def run_neoforge(args, distance):
script = WORK / "script.json"
script.write_text(json.dumps({
"world": WORLD, "seed": SEED, "settle": args.settle, "seconds": args.seconds, "timeout": args.timeout,
"scenes": [{"name": n, "from": a, "to": b, "pitch": p} for n, a, b, p in scene_list(args)],
"scenes": driver_scenes(args),
**({"server": args.server_address} if args.server else {}),
}))
events = WORK / f"{args.game}-rd{distance}.events.jsonl"
events.unlink(missing_ok=True)
@@ -856,6 +1035,8 @@ def report(args, distance, runs, app):
conditions = {"scenario": "perf_bench:" + ",".join(first["scenes"]), "seed": str(SEED),
"render_distance": distance, "simulation_distance": 12, "vsync": False,
"offscreen": bool(args.offscreen), "repeats": len(runs), "warmup_s": args.settle}
if getattr(args, "server", None):
conditions["server"] = args.server
if first["load_before"] is not None:
conditions["load_avg_1m"] = rnd(first["load_before"])
if size:
@@ -868,6 +1049,7 @@ def report(args, distance, runs, app):
metric("startup.title_ms", [r["startup"]["title_s"] * 1000 for r in runs])
metric("startup.world_ms", [r["startup"]["world_s"] * 1000 for r in runs])
metric("startup.join_ms", [r["startup"]["join_s"] * 1000 for r in runs if "join_s" in r["startup"]])
for name in first["scenes"]:
scenes = [r["scenes"][name] for r in runs if name in r["scenes"]]
flights = [frames(name, i, s["frame_ms"], s.get("cpu_ms", ()), s.get("gpu_ms", ())) for i, s in enumerate(scenes)]
@@ -877,6 +1059,9 @@ def report(args, distance, runs, app):
for key in ("avg_fps", "low_1pct_fps", "low_01pct_fps"):
metric(f"fps.{name}.{key}", [f[key] for f in flights if f])
metric(f"memory.{name}.gpu_mb", [s.get("gpu_mb") for s in scenes])
metric(f"memory.{name}.rss_mb", [s.get("rss_mb") for s in scenes])
metric(f"tick.{name}.client_ms", [s.get("client_tick_ms") for s in scenes])
metric(f"cpu.{name}.p50_ms", [f.get("cpu_p50_ms") for f in flights if f])
metric("memory.rss_peak_mb", [r["memory"].get("rss_peak_mb") for r in runs])
metric("memory.rss_steady_mb", [r["memory"].get("rss_steady_mb") for r in runs])
metric("memory.footprint_peak_mb", [r["memory"].get("footprint_peak_mb") for r in runs])
@@ -923,6 +1108,22 @@ def table(labels):
cells.append(" / ".join(cell(m.get(f"fps.{scene}.{k}")) for k in ("avg_fps", "low_1pct_fps", "low_01pct_fps")))
print(f"| {d} | {scene} | " + " | ".join(cells) + " |")
print()
# Per-scene costs where any run recorded them (stress scenes): frame CPU, client tick, resident memory.
per_scene = ("cpu.{}.p50_ms", "tick.{}.client_ms", "memory.{}.rss_mb")
rows = [(d, scene) for d in distances
for scene in sorted({k.split(".")[1] for by in runs.values() if d in by for k in by[d]["metrics"]
if k.split(".")[0] in ("cpu", "tick") or (k.startswith("memory.") and k.endswith(".rss_mb"))})]
if rows:
print("| RD | scene | " + " | ".join(f"{l}: frame CPU p50 ms / client tick ms / RSS MB" for l in labels) + " |")
print("| --- | --- |" + " --- |" * len(labels))
for d, scene in rows:
cells = []
for l in labels:
m = runs.get(l, {}).get(d, {}).get("metrics", {})
cells.append(" / ".join(cell(m.get(k.format(scene)), 0 if k.startswith("memory") else 2)
for k in per_scene))
print(f"| {d} | {scene} | " + " | ".join(cells) + " |")
print()
print("| RD | " + " | ".join(f"{l}: title / world s, mean scene load s, RSS steady / peak MB, footprint steady / peak MB, load avg" for l in labels) + " |")
print("| --- |" + " --- |" * len(labels))
for d in distances:
@@ -967,6 +1168,13 @@ def main():
help="horizon: measure a later start, after one start to the title screen built the caches")
p.add_argument("--mod-jars", help="neoforge: the mod jars to load (the user's own; never committed)")
p.add_argument("--deps", help="neoforge: their required libraries")
p.add_argument("--server", choices=["horizon", "vanilla"],
help="join a local dedicated server (Horizon's, or your own 26.3 server.jar) instead of creating "
"a world: startup.join_ms is connect to loaded")
p.add_argument("--server-build", help="--server horizon: the server's binary folder (default --build)")
p.add_argument("--server-bots", type=int, default=0,
help="--server: this many horizon-bots walk around the spawn, and a 'crowd' scene flies over them")
p.add_argument("--bots-build", help="--server-bots: horizon-bots' folder (default --build)")
p.add_argument("--table", nargs="+", metavar="LABEL", help="compare saved runs with matching recorded conditions, and exit")
args = p.parse_args()
if args.table:
@@ -983,11 +1191,20 @@ def main():
args.label = size_label(label, args.width, args.height)
for d in [int(x) for x in args.distances.split(",")]:
runs = []
for _ in range(args.repeats):
load_before = load_average()
result = run(args, d)
result["load_before"], result["load_after"] = load_before, load_average()
runs.append(result)
with contextlib.ExitStack() as stack:
if args.server:
args.server_build = args.server_build or args.build
server = stack.enter_context(LocalServer(args, d))
args.server_address = server.address
args.extra_scenes = [server.crowd(args)] if args.server_bots else []
if server.fresh:
print(f"warm-up join of the new {args.server} server world (not measured)")
run(args, d)
for _ in range(args.repeats):
load_before = load_average()
result = run(args, d)
result["load_before"], result["load_after"] = load_before, load_average()
runs.append(result)
out = ROOT / "benchmarks/perf" / f"{args.label}-rd{d}.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report(args, d, runs, app(args)), indent=1) + "\n")
+208
View File
@@ -0,0 +1,208 @@
"""Many-entity stress scenes for tools/perf_bench.py (`--scenes stress` or by name).
Each scene is built by vanilla commands, the same lines for every game (Horizon through horizon-ctl `command`,
vanilla and vanilla-mods through the bench driver mod), on a stone floor at y 289 high above the world spawn of
seed 1234 (loaded already: no terrain to generate), then held still for the flight's seconds, looking along +Z at
the scene. Its cleanup commands then remove every entity and block it made, before the next scene.
Vanilla renders an entity only within 64 * viewScale * its box size (Entity.shouldRenderAtSqrDistance), with
viewScale = clamp(render distance / 8, 1, 2.5): a dropped item (0.25) only within 32 blocks at RD 16. The scenes
keep everything within ~30 blocks of the camera and inside its view, so run them at RD 16 or more.
Entities are still and never despawn (NoAI, NoGravity, PersistenceRequired; items lie on barriers): the same picture every
frame and every run. Particles come from repeating command blocks under the floor, so they keep coming at the
same rate. Chests stay closed: opening one needs a player, which neither driver has.
"""
FLOOR_Y = 289
# The player's feet (the eyes are 1.62 higher).
PLAYER_Y = 298.0
PITCH = 15.0
# The camera stands at the origin, above the spawn of seed 1234 (-519.5, 58.5); the scene lies in front of it, at +Z.
ORIGIN = (-520, 40)
# What a scene may touch: x and z around the origin, y from the command blocks to above the TNT.
BOX = (-24, 24, 2, 40, FLOOR_Y - 9, FLOOR_Y + 26)
SETTINGS = [
# The setup's own commands and the command blocks would fill the chat.
"gamerule send_command_feedback false",
"gamerule command_block_output false",
"gamerule advance_time false",
"gamerule spawn_mobs false",
"time set 6000",
]
MOBS = ["husk", "creeper", "cow", "pig", "sheep", "chicken", "villager", "spider"]
ITEMS = ["stone", "oak_log", "diamond", "apple", "iron_sword", "bread", "redstone", "torch"]
FRAMED = ["diamond_sword", "compass", "clock", "stone", "oak_planks", "apple", "shield", "golden_apple"]
CONTAINERS = ["chest", "trapped_chest", "ender_chest", "white_shulker_box"]
BANNER = ('{patterns:[{pattern:"minecraft:stripe_top",color:"red"},{pattern:"minecraft:creeper",color:"black"},'
'{pattern:"minecraft:border",color:"blue"}]}')
SIGN_TEXT = '{front_text:{messages:["Horizon","stress","sign","wall"]}}'
def num(v):
"""A coordinate as commands write it: no trailing zeros, never an exponent."""
text = f"{v:.3f}".rstrip("0").rstrip(".")
return "0" if text == "-0" else text
def floor(ox, oz):
return [f"fill {ox - 24} {FLOOR_Y} {oz + 2} {ox + 24} {FLOOR_Y} {oz + 40} smooth_stone"]
def wall(ox, oz):
"""A stone wall facing the camera, 17 blocks ahead: the backing of frames and wall signs."""
return [f"fill {ox - 16} {FLOOR_Y - 1} {oz + 17} {ox + 17} {FLOOR_Y + 16} {oz + 17} stone"]
def empty(ox, oz):
return []
def mobs(ox, oz):
"""1000 mobs of 8 common kinds: 20 x 10 x 5 layers, 1.5 / 2 / 2.5 blocks apart."""
out, i = [], 0
for layer in range(5):
for row in range(10):
for col in range(20):
kind = MOBS[i % len(MOBS)]
x, y, z = ox + 0.5 - 14.25 + 1.5 * col, FLOOR_Y + 1 + 2.5 * layer, oz + 8 + 2 * row
out.append(f"summon {kind} {num(x)} {num(y)} {num(z)} "
"{NoAI:1b,NoGravity:1b,PersistenceRequired:1b,Silent:1b}")
i += 1
return out
def items(ox, oz):
"""2000 dropped items of 8 kinds: 25 x 16 on the floor and on four invisible barrier layers above it, 2 blocks
apart, a block apart in a layer (vanilla merges equal stacks within 0.625). They fall the last 0.2 block onto
their layer: no NoGravity, which not every game reads for items."""
out = [f"fill {ox - 12} {FLOOR_Y + 2 * layer} {oz + 8} {ox + 12} {FLOOR_Y + 2 * layer} {oz + 23} barrier"
for layer in range(1, 5)]
i = 0
for layer in range(5):
for row in range(16):
for col in range(25):
item = ITEMS[i % len(ITEMS)]
x, y, z = ox + 0.5 - 12 + col, FLOOR_Y + 1.2 + 2 * layer, oz + 8.5 + row
out.append(f"summon item {num(x)} {num(y)} {num(z)} "
f'{{Item:{{id:"minecraft:{item}",count:1}},Age:-32768s,PickupDelay:32767s}}')
i += 1
return out
def frames(ox, oz):
"""512 item frames holding 8 kinds of item on a wall (32 x 16)."""
out = wall(ox, oz)
i = 0
for y in range(FLOOR_Y, FLOOR_Y + 16):
for x in range(ox - 15, ox + 17):
item = FRAMED[i % len(FRAMED)]
# block_pos too: summoning places the entity, not the block a frame hangs in.
out.append(f"summon item_frame {x} {y} {oz + 16} {{block_pos:[I;{x},{y},{oz + 16}],"
f'Facing:2b,Fixed:1b,Item:{{id:"minecraft:{item}",count:1}}}}')
i += 1
return out
def armor_stands(ox, oz):
"""500 armor stands in full armour holding a sword (25 x 20)."""
gear = ('{ShowArms:1b,NoGravity:1b,equipment:{head:{id:"minecraft:diamond_helmet"},'
'chest:{id:"minecraft:iron_chestplate"},legs:{id:"minecraft:golden_leggings"},'
'feet:{id:"minecraft:leather_boots"},mainhand:{id:"minecraft:diamond_sword"}}}')
return [f"summon armor_stand {num(ox + 0.5 - 12 + col)} {FLOOR_Y + 1} {oz + 8 + row} {gear}"
for row in range(20) for col in range(25)]
def chests(ox, oz):
"""800 containers with block-entity models: four walls 25 wide and 8 high, one kind each."""
return [f"fill {ox - 12} {FLOOR_Y + 1} {oz + 10 + 4 * i} {ox + 12} {FLOOR_Y + 8} {oz + 10 + 4 * i} {kind}"
for i, kind in enumerate(CONTAINERS)]
def signs(ox, oz):
"""A wall of 512 written wall signs (32 x 16) and a field of 175 standing ones in front of it."""
return wall(ox, oz) + [
f"fill {ox - 15} {FLOOR_Y} {oz + 16} {ox + 16} {FLOOR_Y + 15} {oz + 16} oak_wall_sign[facing=north]{SIGN_TEXT}",
f"fill {ox - 12} {FLOOR_Y + 1} {oz + 6} {ox + 12} {FLOOR_Y + 1} {oz + 12} oak_sign[rotation=8]{SIGN_TEXT}",
]
def banners(ox, oz):
"""300 waving banners with three patterns: 12 rows of 25."""
return [f"fill {ox - 12} {FLOOR_Y + 1} {oz + 8 + 2 * row} {ox + 12} {FLOOR_Y + 1} {oz + 8 + 2 * row} "
f"white_banner[rotation=8]{BANNER}" for row in range(12)]
def particles(ox, oz):
"""Steady heavy particles: repeating command blocks under the floor, powered by a redstone block under each,
every tick: flames, smoke, end rods, firework rockets that burst over the floor, and TNT that explodes in the
air (nothing within its reach)."""
def block(dx, dz, command):
command = command.replace('"', '\\"')
at = f"{ox + dx} {FLOOR_Y - 8} {oz + dz}"
return [f'setblock {at} repeating_command_block{{Command:"{command}"}}',
f"setblock {ox + dx} {FLOOR_Y - 9} {oz + dz} redstone_block"]
x, z = ox + 0.5, oz + 20.5
emitters = [
("flame", 80, 0.01), ("flame", 80, 0.01), ("flame", 80, 0.01), ("flame", 80, 0.01),
("campfire_cosy_smoke", 4, 0.0), ("campfire_cosy_smoke", 4, 0.0),
("end_rod", 40, 0.05), ("end_rod", 40, 0.05),
]
out = [line for i, (kind, count, speed) in enumerate(emitters) for line in
block(-8 + 2 * i, 4, f"particle minecraft:{kind} {num(x)} {FLOOR_Y + 5} {num(z)} 8 4 8 {speed} {count} force")]
rocket = ('{LifeTime:18,FireworksItem:{id:"minecraft:firework_rocket",count:1,components:{"minecraft:fireworks":'
'{explosions:[{shape:"large_ball",colors:[I;16711680,65280,255],has_trail:1b}]}}}}')
out += [line for i in range(3) for line in
block(-8 + 2 * i, 6, f"summon firework_rocket {num(x - 6 + 6 * i)} {FLOOR_Y + 2} {num(z)} {rocket}")]
out += block(0, 8, f"summon tnt {num(x)} {FLOOR_Y + 24} {num(z + 6)} {{fuse:0}}")
return out
# name, builder: in the order --scenes stress runs them.
STRESS = [
("stress_empty", empty),
("stress_mobs", mobs),
("stress_items", items),
("stress_frames", frames),
("stress_armor_stands", armor_stands),
("stress_chests", chests),
("stress_signs", signs),
("stress_banners", banners),
("stress_particles", particles),
]
NAMES = [name for name, _ in STRESS]
def origin(name):
return ORIGIN
def scene(name):
"""(name, from, to, pitch) as perf_bench's scenes: a hold at the camera, facing +Z."""
ox, oz = origin(name)
at = (ox + 0.5, PLAYER_Y, oz + 0.5)
return name, at, (at[0], at[1], at[2] + 0.01), PITCH
def setup(name, first=True):
"""The commands that build the scene, after its chunks are loaded; the game rules only for the first scene of
a run (setting a rule again says so in red in the chat, which would stay on screen)."""
ox, oz = origin(name)
return (SETTINGS if first else []) + floor(ox, oz) + dict(STRESS)[name](ox, oz)
def cleanup(name):
"""Removes the scene: its entities first (a frame would drop its item when its wall goes), then every block
in its box, in slabs under vanilla's fill limit, then anything that still dropped. Nothing it runs fails, so
no red line stays in the chat during the next scene: `execute as` kills nothing quietly, and a marker block in
each slab gives every fill something to clear."""
ox, oz = origin(name)
x0, x1, z0, z1, y0, y1 = BOX
kill = "execute as @e[type=!minecraft:player] run kill @s"
out = [kill]
for y in range(y0, y1 + 1, 12):
out.append(f"setblock {ox + x0} {y} {oz + z0} barrier")
out.append(f"fill {ox + x0} {y} {oz + z0} {ox + x1} {min(y + 11, y1)} {oz + z1} air")
return out + [kill]