Merge official main into Recipes content scenario
No files matched your search
@@ -158,7 +158,7 @@ jobs:
|
||||
run: |
|
||||
cargo +stable fmt --check
|
||||
cargo +stable clippy --all-targets --locked -- -D warnings
|
||||
cargo +stable test --locked --lib
|
||||
cargo +stable test --locked --lib --bins
|
||||
# Compile and test Windows ARM64 on an actual ARM64 host, and inspect the resulting PE headers. By hand only
|
||||
# (`gh workflow run checks --ref <branch>`): GitHub's runner queue is the bottleneck, and main's runs gate the
|
||||
# releases (tools/release_train.py waits for them), so neither pull requests nor main's pushes start this
|
||||
|
||||
@@ -1400,6 +1400,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"flate2",
|
||||
"fs4",
|
||||
"getrandom 0.3.4",
|
||||
"gilrs",
|
||||
"glam",
|
||||
"horizon-anticheat",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Entity occlusion split measurements — 2026-11-02
|
||||
|
||||
These measurements came from the pre-split integration branch for PR #7054. That build contained the entity-occlusion rewrite and particle light lookup cache; Particle Culling was Off. They are useful comparison evidence, but they are not a benchmark of this isolated entity-only commit. The cache-only run in PR #7391 measured effectively the same CPU time on the ordinary scene, but the combined result still cannot isolate interactions.
|
||||
|
||||
Host: Intel Core i7-3770S, Intel HD Graphics 4000, Vulkan/Mesa, 675×727, render distance 4, VSync off, 260 FPS cap. Two opposite-order passes measured for 8 seconds after warmup. Values are means. CPU is harness average CPU ms per frame; p99 is frame time. The driver supplied no usable GPU timing.
|
||||
|
||||
## Ordinary scene
|
||||
|
||||
The scene had 24 mobs, roughly 9,400–10,100 rain particles and campfire smoke.
|
||||
|
||||
| Build and mode | FPS | CPU ms/frame | Frame p99 ms |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Main, entity culling Off | 88.59 | 1.829 | 24.51 |
|
||||
| Pre-split integration branch, all culling Off | 88.33 | 2.063 | 24.51 |
|
||||
| Main, entity culling Synchronous | 89.34 | 1.965 | 23.99 |
|
||||
| Pre-split integration branch, entity Synchronous | 89.13 | 2.113 | 24.15 |
|
||||
|
||||
This normal-scene run does not show a large FPS change; its CPU differences are small relative to frame time and not attributable to the entity-only change by themselves.
|
||||
|
||||
## Opaque-wall scene
|
||||
|
||||
A 32×32 platform at Y=300, a 7-block wall with a one-block vertical opening, and 192 pigs spawned behind the wall (216 mobs in the captured scene). The player looked toward the opening from the near side. The same world and camera path were used for the main and integration builds.
|
||||
|
||||
| Build and mode | FPS | CPU ms/frame | Frame p99 ms |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Main, Off | 89.08 | 2.423 | 13.52 |
|
||||
| Integration branch, Off | 89.23 | 2.355 | 13.27 |
|
||||
| Main, Synchronous | 92.81 | 2.566 | 14.60 |
|
||||
| Integration branch, Synchronous | 93.21 | 2.550 | 12.84 |
|
||||
| Main, Asynchronous | 92.73 | 2.364 | 13.04 |
|
||||
| Integration branch, Asynchronous | 92.62 | 2.314 | 13.18 |
|
||||
|
||||
The asynchronous mode reduced measured CPU time slightly in this wall scene but did not improve FPS beyond run-to-run spread. This is a synthetic occlusion-heavy scene, not a broad performance claim.
|
||||
|
||||
## Captures
|
||||
|
||||
The HUD performance overlay is visible in each capture. The integration-build screenshots were recorded with the same wall and camera position in Off, Synchronous, and Asynchronous modes.
|
||||
|
||||
- Main, Off: `main-off.png`
|
||||
- Integration branch, Off: `branch-off.png`
|
||||
- Integration branch, Synchronous: `branch-sync.png`
|
||||
- Integration branch, Asynchronous: `branch-async.png`
|
||||
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 399 KiB |
|
After Width: | Height: | Size: 398 KiB |
@@ -0,0 +1,42 @@
|
||||
# Entity occlusion pipeline — release/OpenGL run (2026-11-04)
|
||||
|
||||
This is the final local smoke benchmark for the entity-occlusion pipeline before opening the PR. It was run from the branch after the release build completed, with the OpenGL backend selected explicitly. The same process created each scene, switched the culling mode, flew the camera for two seconds, collected frame/CPU telemetry, and captured the screenshot after the scene settled.
|
||||
|
||||
**Host:** Intel Core i7-3770S, Intel HD Graphics 4000, Mesa OpenGL 4.2
|
||||
**Window:** 1280×720, offscreen, VSync disabled
|
||||
**World:** fresh temporary home, 32×32 platform at Y=300; wall scenes add a solid stone wall and an optional one-block slit
|
||||
**Entities:** 24 pigs in the normal scene, 192 pigs in wall scenes, 512 pigs in stress scenes
|
||||
**Settings:** render distance 8, `entityCullingPrecheck:64`, FPS overlay on, no resource-pack occlusion assumptions
|
||||
**Build:** `cargo build --release -p horizon-client`
|
||||
|
||||
## Results
|
||||
|
||||
The values below are branch-only measurements. They are useful for comparing the three modes under identical geometry and for checking that the worker is doing useful work. A fresh main binary was not built in this short pass, so these numbers do not claim a controlled main-to-branch delta.
|
||||
|
||||
| Scene | Off FPS / CPU p50 | Sync FPS / CPU p50 | Async FPS / CPU p50 | Async entities culled |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| `entity_normal` (24 visible) | 240.5 / 0.586 ms | 233.5 / 0.762 ms | 239.3 / 0.686 ms | 0 |
|
||||
| `entity_wall` (192, solid) | 228.8 / 1.245 ms | 318.6 / 1.342 ms | **324.5 / 0.478 ms** | 124,608 |
|
||||
| `entity_wall_slit` (192, one-block slit) | 231.4 / 1.102 ms | 265.0 / 1.387 ms | **281.2 / 0.716 ms** | 102,826 |
|
||||
| `entity_stress` (512, solid) | 190.3 / 2.467 ms | 299.2 / 2.593 ms | **325.5 / 0.691 ms** | 290,234 |
|
||||
| `entity_stress_slit` (512, one-block slit) | 190.4 / 2.476 ms | 262.2 / 2.573 ms | **262.1 / 1.297 ms** | 227,301 |
|
||||
|
||||
Async telemetry confirmed that the render thread stayed non-blocking (`try_recv`/`try_send`, `worker_blocking_wait_ms: 0`). The latest wall/slit run completed 562 worker jobs, queued 3,982 queries, avoided about 4.0 million entity bytes, and had 379 ns recent p50 worker-job time. The solid 512-pig run culled 15,552 generated vertices; the slit run culled 120,290 generated vertices. The normal scene recorded no pre-model hides and kept the opt-in path close to Off.
|
||||
|
||||
## Captures
|
||||
|
||||
Every image includes the FPS performance overlay. The names are `<mode>-<scene>.png`:
|
||||
|
||||
- [Off — normal](screenshots/off-entity_normal.png), [wall](screenshots/off-entity_wall.png), [wall slit](screenshots/off-entity_wall_slit.png), [stress](screenshots/off-entity_stress.png), [stress slit](screenshots/off-entity_stress_slit.png)
|
||||
- [Sync — normal](screenshots/sync-entity_normal.png), [wall](screenshots/sync-entity_wall.png), [wall slit](screenshots/sync-entity_wall_slit.png), [stress](screenshots/sync-entity_stress.png), [stress slit](screenshots/sync-entity_stress_slit.png)
|
||||
- [Async — normal](screenshots/async-entity_normal.png), [wall](screenshots/async-entity_wall.png), [wall slit](screenshots/async-entity_wall_slit.png), [stress](screenshots/async-entity_stress.png), [stress slit](screenshots/async-entity_stress_slit.png)
|
||||
|
||||
## Reproduction
|
||||
|
||||
From the repository root, with prepared 26.3 resources available:
|
||||
|
||||
```sh
|
||||
python3 tools/entity_occlusion_bench.py
|
||||
```
|
||||
|
||||
The script uses fresh temporary game data, the release binary in `target/release`, OpenGL, the FPS overlay, and writes JSON plus captures under `/tmp/horizon-entity-occlusion-release-quick/`.
|
||||
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 206 KiB |
@@ -30,6 +30,8 @@ horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
# server.properties, eula.txt and the user lists, shared with horizon-server.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
# The control endpoint's token (control.rs), as horizon-server's and the relay's.
|
||||
getrandom = "0.3"
|
||||
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
|
||||
horizon-anticheat = { path = "../horizon-anticheat" }
|
||||
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
|
||||
|
||||
@@ -407,6 +407,16 @@ impl BlockEntities {
|
||||
self.add(pos);
|
||||
}
|
||||
}
|
||||
/// [`Self::retain`] for unloading chunks: removes the block entities at
|
||||
/// the positions `gone` names and changes none it keeps, so those that
|
||||
/// sleep stay asleep (a moving player unloads chunks most ticks).
|
||||
pub fn unload(&mut self, mut gone: impl FnMut(&Pos) -> bool) {
|
||||
let asleep = std::mem::take(&mut self.asleep);
|
||||
self.retain(|p, _| !gone(p));
|
||||
self.asleep = asleep;
|
||||
let map = &self.map;
|
||||
self.asleep.retain(|p| map.contains_key(p));
|
||||
}
|
||||
/// Removes the block entities `keep` refuses, and their slots
|
||||
/// (`clearAllBlockEntities` of unloaded chunks).
|
||||
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
|
||||
@@ -558,6 +568,24 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Unloading a chunk removes its block entities and leaves the others'
|
||||
/// sleep alone; `retain`, which may change what it keeps, wakes them all.
|
||||
#[test]
|
||||
fn unloading_a_chunk_keeps_the_others_asleep() {
|
||||
let mut b = level();
|
||||
let (kept, gone) = ((1, 64, 1), (17, 64, 1));
|
||||
b.insert(kept, hopper());
|
||||
b.insert(gone, hopper());
|
||||
b.sleep(kept);
|
||||
b.sleep(gone);
|
||||
b.unload(|p| p.0 >> 4 == 1);
|
||||
assert!(b.get(&gone).is_none());
|
||||
assert!(!b.is_asleep(&gone));
|
||||
assert!(b.is_asleep(&kept));
|
||||
b.retain(|_, _| true);
|
||||
assert!(!b.is_asleep(&kept));
|
||||
}
|
||||
|
||||
fn near(b: &BlockEntities, x: f64, z: f64, radius: f64) -> Vec<Pos> {
|
||||
let mut found: Vec<Pos> = b.mods_near(x, z, radius).copied().collect();
|
||||
found.sort();
|
||||
|
||||
@@ -271,10 +271,10 @@ pub const DYES: [(&str, u32); 16] = [
|
||||
("black", 0x1D1D21),
|
||||
];
|
||||
fn dye_prefix(name: &str) -> Option<(usize, &str)> {
|
||||
// Longest names first so "light_gray" wins over "gray".
|
||||
let mut order: Vec<usize> = (0..16).collect();
|
||||
order.sort_by_key(|&i| std::cmp::Reverse(DYES[i].0.len()));
|
||||
order.into_iter().find_map(|i| {
|
||||
// Longest names first; this is the same stable order as sorting DYES by
|
||||
// descending prefix length, without a Vec allocation or comparison sort.
|
||||
const ORDER: [usize; 16] = [3, 8, 2, 1, 4, 10, 0, 12, 13, 15, 5, 6, 7, 9, 11, 14];
|
||||
ORDER.into_iter().find_map(|i| {
|
||||
name.strip_prefix(DYES[i].0)
|
||||
.and_then(|rest| rest.strip_prefix('_'))
|
||||
.map(|rest| (i, rest))
|
||||
@@ -841,6 +841,9 @@ pub struct Scene<'a> {
|
||||
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
|
||||
/// text laid out under another value is laid out again.
|
||||
pub glyphs: u64,
|
||||
/// Per-block visibility for dynamic, animated, or uncached models. Bounds
|
||||
/// are world-space and conservatively include model animation extents.
|
||||
pub dynamic_visible: Option<&'a dyn Fn([i32; 3], u32, DVec3, DVec3) -> bool>,
|
||||
}
|
||||
|
||||
/// Empty by default (a dedicated server): no state has a block entity model.
|
||||
@@ -1410,6 +1413,167 @@ impl BlockEntityRenderer {
|
||||
fn kind(&self, state: u32) -> &Kind {
|
||||
self.kinds.get(state as usize).unwrap_or(&Kind::None)
|
||||
}
|
||||
fn dynamic_bounds(
|
||||
&self,
|
||||
scene: &Scene,
|
||||
pos: [i32; 3],
|
||||
kind: &Kind,
|
||||
chest_openness: Option<(f32, Option<[i32; 3]>)>,
|
||||
) -> (DVec3, DVec3) {
|
||||
let [x, y, z] = pos.map(f64::from);
|
||||
let mut lo = DVec3::new(x, y, z);
|
||||
let mut hi = DVec3::new(x + 1.0, y + 1.0, z + 1.0);
|
||||
match kind {
|
||||
Kind::Chest { double_with, .. } => {
|
||||
if let Some(offset) = double_with {
|
||||
lo.x += f64::from(offset[0].min(0));
|
||||
lo.z += f64::from(offset[2].min(0));
|
||||
hi.x += f64::from(offset[0].max(0));
|
||||
hi.z += f64::from(offset[2].max(0));
|
||||
}
|
||||
let is_open = chest_openness.map_or_else(
|
||||
|| {
|
||||
(scene.lid)(pos) != 0.0
|
||||
|| double_with.is_some_and(|offset| {
|
||||
let other = [
|
||||
pos[0].saturating_add(offset[0]),
|
||||
pos[1].saturating_add(offset[1]),
|
||||
pos[2].saturating_add(offset[2]),
|
||||
];
|
||||
(scene.lid)(other) != 0.0
|
||||
})
|
||||
},
|
||||
|(open, _)| open != 0.0,
|
||||
);
|
||||
if is_open {
|
||||
// The lid pivots up from the rear edge. Its longest corner
|
||||
// rises by less than one block in the fully open pose.
|
||||
hi.y += 0.8;
|
||||
}
|
||||
}
|
||||
Kind::Shulker { facing, .. } => {
|
||||
let offset = step(facing);
|
||||
lo.x += f64::from(offset[0].min(0)) * 0.52;
|
||||
lo.y += f64::from(offset[1].min(0)) * 0.52;
|
||||
lo.z += f64::from(offset[2].min(0)) * 0.52;
|
||||
hi.x += f64::from(offset[0].max(0)) * 0.52;
|
||||
hi.y += f64::from(offset[1].max(0)) * 0.52;
|
||||
hi.z += f64::from(offset[2].max(0)) * 0.52;
|
||||
}
|
||||
Kind::Banner { wall: false, .. } => {
|
||||
hi.y += 1.0;
|
||||
lo.x -= 0.05;
|
||||
lo.z -= 0.05;
|
||||
hi.x += 0.05;
|
||||
hi.z += 0.05;
|
||||
}
|
||||
Kind::Banner { wall: true, .. } => {
|
||||
lo.y -= 1.0;
|
||||
lo.x -= 0.05;
|
||||
lo.z -= 0.05;
|
||||
hi.x += 0.05;
|
||||
hi.z += 0.05;
|
||||
}
|
||||
Kind::Sign { hanging: true, .. } => {
|
||||
lo.y -= 1.0;
|
||||
lo.x -= 0.15;
|
||||
lo.z -= 0.15;
|
||||
hi.x += 0.15;
|
||||
hi.z += 0.15;
|
||||
}
|
||||
Kind::Sign { hanging: false, .. } => {
|
||||
hi.y += 0.3;
|
||||
lo.x -= 0.02;
|
||||
lo.z -= 0.02;
|
||||
hi.x += 0.02;
|
||||
hi.z += 0.02;
|
||||
}
|
||||
Kind::EnchantingTable | Kind::Lectern { .. } => {
|
||||
// The book and statue models rise above the block's collision
|
||||
// cube; include their full rendered height and rotated corners.
|
||||
hi.y += 1.0;
|
||||
lo.x -= 0.25;
|
||||
lo.z -= 0.25;
|
||||
hi.x += 0.25;
|
||||
hi.z += 0.25;
|
||||
}
|
||||
Kind::CopperGolem { .. } => {
|
||||
// The statue pose rotates around the root, so some variants
|
||||
// extend below the block as well as above it.
|
||||
lo.y -= 1.0;
|
||||
hi.y += 1.0;
|
||||
lo.x -= 0.25;
|
||||
lo.z -= 0.25;
|
||||
hi.x += 0.25;
|
||||
hi.z += 0.25;
|
||||
}
|
||||
Kind::Conduit => {
|
||||
// Its animated cage is centered above the conduit block and
|
||||
// the wind model spans multiple cells. The caller bypasses
|
||||
// voxel occlusion for envelopes this tall, but retains frustum
|
||||
// culling with this conservative volume.
|
||||
lo.x -= 1.0;
|
||||
lo.y -= 1.0;
|
||||
lo.z -= 1.0;
|
||||
hi.x += 1.0;
|
||||
hi.y += 1.0;
|
||||
hi.z += 1.0;
|
||||
}
|
||||
Kind::Bell => {
|
||||
// The bell's pivoted swing moves its corners slightly outside
|
||||
// the block even though the model starts centered in it.
|
||||
lo.x -= 0.1;
|
||||
lo.y -= 0.1;
|
||||
lo.z -= 0.1;
|
||||
hi.x += 0.1;
|
||||
hi.y += 0.1;
|
||||
hi.z += 0.1;
|
||||
}
|
||||
Kind::Skull { .. } => {
|
||||
// Wall-mounted and dragon heads can protrude from their block.
|
||||
lo.x -= 0.25;
|
||||
lo.y -= 0.25;
|
||||
lo.z -= 0.25;
|
||||
hi.x += 0.25;
|
||||
hi.y += 0.25;
|
||||
hi.z += 0.25;
|
||||
}
|
||||
Kind::EndGateway { .. } => {
|
||||
let anim = (scene.anim)(pos);
|
||||
let beam = gateway_beam(anim.gateway, scene.partial, scene.max_y, scene.game_time);
|
||||
let height = f64::from(beam.height.max(0));
|
||||
lo.y -= height;
|
||||
hi.y += height;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
fn is_dynamic_visible(
|
||||
&self,
|
||||
scene: &Scene,
|
||||
shapes: &Arc<BlockEntityShapes>,
|
||||
pos: [i32; 3],
|
||||
state: u32,
|
||||
kind: &Kind,
|
||||
chest_openness: Option<(f32, Option<[i32; 3]>)>,
|
||||
) -> bool {
|
||||
let Some(visible) = scene.dynamic_visible else {
|
||||
return true;
|
||||
};
|
||||
if matches!(kind, Kind::EndGateway { .. }) {
|
||||
return true;
|
||||
}
|
||||
// A pack model may extend outside the vanilla envelope. Keep that
|
||||
// geometry on the uncullable path unless its model bounds are known.
|
||||
if !Arc::ptr_eq(shapes, &self.vanilla) {
|
||||
return true;
|
||||
}
|
||||
let (lo, hi) = self.dynamic_bounds(scene, pos, kind, chest_openness);
|
||||
visible(pos, state, lo, hi)
|
||||
}
|
||||
|
||||
fn cached_entry_is_current(
|
||||
&self,
|
||||
scene: &Scene,
|
||||
@@ -1419,6 +1583,7 @@ impl BlockEntityRenderer {
|
||||
options: CacheOptions,
|
||||
entry: &StaticEntry,
|
||||
section_revision: StaticSectionRevision,
|
||||
chest_openness: Option<(f32, Option<[i32; 3]>)>,
|
||||
) -> bool {
|
||||
if entry.key.state != state
|
||||
|| entry.key.nether != scene.nether
|
||||
@@ -1430,15 +1595,24 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
match kind {
|
||||
Kind::Chest { .. } => {
|
||||
let (open, other) = self.chest_openness(
|
||||
pos,
|
||||
kind,
|
||||
&|p| scene.world.block(p[0], p[1], p[2]).0,
|
||||
scene.lid,
|
||||
);
|
||||
// Double chests depend on the neighboring half's light too;
|
||||
// let those uncommon models take the full key path.
|
||||
open == 0.0 && other.is_none()
|
||||
let (open, other) = chest_openness.unwrap_or_else(|| {
|
||||
self.chest_openness(
|
||||
pos,
|
||||
kind,
|
||||
&|p| scene.world.block(p[0], p[1], p[2]).0,
|
||||
scene.lid,
|
||||
)
|
||||
});
|
||||
if open != 0.0 {
|
||||
return false;
|
||||
}
|
||||
let mut light = scene.world.light(pos[0], pos[1], pos[2]);
|
||||
if let Some(other) = other {
|
||||
let other_light = scene.world.light(other[0], other[1], other[2]);
|
||||
light = (light >> 4).max(other_light >> 4) << 4
|
||||
| (light & 15).max(other_light & 15);
|
||||
}
|
||||
entry.key.light == light
|
||||
}
|
||||
Kind::Shulker { .. } => {
|
||||
let (progress, old) = (scene.anim)(pos).shulker;
|
||||
@@ -1701,6 +1875,17 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
let local = static_local_index(pos, section_min_y);
|
||||
let current_section_revision = active_section_revision.unwrap_or_default();
|
||||
let chest_openness = (cache.is_some()
|
||||
&& options.chests
|
||||
&& matches!(kind, Kind::Chest { .. }))
|
||||
.then(|| {
|
||||
self.chest_openness(
|
||||
pos,
|
||||
kind,
|
||||
&|p| scene.world.block(p[0], p[1], p[2]).0,
|
||||
scene.lid,
|
||||
)
|
||||
});
|
||||
if cache_only && section_revision.is_some() && active_section_revision.is_none() {
|
||||
break 'draw;
|
||||
}
|
||||
@@ -1716,6 +1901,7 @@ impl BlockEntityRenderer {
|
||||
options,
|
||||
&active_entries[index].1,
|
||||
current_section_revision,
|
||||
chest_openness,
|
||||
)
|
||||
{
|
||||
let entry = &mut active_entries[index].1;
|
||||
@@ -1735,7 +1921,9 @@ impl BlockEntityRenderer {
|
||||
..*vertex
|
||||
}));
|
||||
}
|
||||
if matches!(kind, Kind::Banner { .. }) {
|
||||
if matches!(kind, Kind::Banner { .. })
|
||||
&& self.is_dynamic_visible(scene, &shapes, pos, state, kind, None)
|
||||
{
|
||||
let packed = scene.world.light(pos[0], pos[1], pos[2]);
|
||||
let origin =
|
||||
(DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
|
||||
@@ -1756,11 +1944,25 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
break 'draw;
|
||||
}
|
||||
if cache_only && chest_openness.is_some_and(|(open, _)| open != 0.0) {
|
||||
if let Some(cache) = &mut cache
|
||||
&& let Ok(index) =
|
||||
active_entries.binary_search_by_key(&local, |(index, _)| *index)
|
||||
{
|
||||
active_entries.remove(index);
|
||||
cache.len -= 1;
|
||||
cache.mark_section_dirty(section);
|
||||
}
|
||||
break 'draw;
|
||||
}
|
||||
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
|
||||
- scene.camera)
|
||||
.as_vec3();
|
||||
let mut packed = scene.world.light(pos[0], pos[1], pos[2]);
|
||||
if matches!(kind, Kind::Sign { .. }) && scene.texts.is_none() {
|
||||
if !self.is_dynamic_visible(scene, &shapes, pos, state, kind, None) {
|
||||
break 'draw;
|
||||
}
|
||||
if options.signs {
|
||||
self.sign_text(scene, pos, state, origin, packed, out);
|
||||
} else {
|
||||
@@ -1779,8 +1981,15 @@ impl BlockEntityRenderer {
|
||||
break 'draw;
|
||||
}
|
||||
if let Some(cache) = &mut cache
|
||||
&& let Some(key) =
|
||||
self.static_key(scene, pos, state, kind, options, &mut packed)
|
||||
&& let Some(key) = self.static_key(
|
||||
scene,
|
||||
pos,
|
||||
state,
|
||||
kind,
|
||||
options,
|
||||
&mut packed,
|
||||
chest_openness,
|
||||
)
|
||||
{
|
||||
let slot = active_entries.binary_search_by_key(&local, |(index, _)| *index);
|
||||
let entry = match slot {
|
||||
@@ -1865,7 +2074,9 @@ impl BlockEntityRenderer {
|
||||
..*vertex
|
||||
}));
|
||||
}
|
||||
if matches!(kind, Kind::Banner { .. }) {
|
||||
if matches!(kind, Kind::Banner { .. })
|
||||
&& self.is_dynamic_visible(scene, &shapes, pos, state, kind, None)
|
||||
{
|
||||
self.render_banner(
|
||||
&shapes,
|
||||
scene,
|
||||
@@ -1892,6 +2103,9 @@ impl BlockEntityRenderer {
|
||||
if cache_only {
|
||||
break 'draw;
|
||||
}
|
||||
if !self.is_dynamic_visible(scene, &shapes, pos, state, kind, chest_openness) {
|
||||
break 'draw;
|
||||
}
|
||||
let base = Mat4::from_translation(origin);
|
||||
self.render_one(scene, pos, kind, base, packed, lights, false, true, out);
|
||||
}
|
||||
@@ -1958,6 +2172,7 @@ impl BlockEntityRenderer {
|
||||
kind: &Kind,
|
||||
options: CacheOptions,
|
||||
packed: &mut u8,
|
||||
chest_openness: Option<(f32, Option<[i32; 3]>)>,
|
||||
) -> Option<StaticKey> {
|
||||
let mut sprite_rects = [None; 5];
|
||||
let mut sprite_count = 0;
|
||||
@@ -1975,12 +2190,14 @@ impl BlockEntityRenderer {
|
||||
part,
|
||||
..
|
||||
} if options.chests => {
|
||||
let (open, other) = self.chest_openness(
|
||||
pos,
|
||||
kind,
|
||||
&|p| scene.world.block(p[0], p[1], p[2]).0,
|
||||
scene.lid,
|
||||
);
|
||||
let (open, other) = chest_openness.unwrap_or_else(|| {
|
||||
self.chest_openness(
|
||||
pos,
|
||||
kind,
|
||||
&|p| scene.world.block(p[0], p[1], p[2]).0,
|
||||
scene.lid,
|
||||
)
|
||||
});
|
||||
if open != 0.0 {
|
||||
return None;
|
||||
}
|
||||
@@ -3362,6 +3579,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
// Compare the cached world path with the original model renderer,
|
||||
// including atlas replacement, current light and animated fallback.
|
||||
@@ -3656,6 +3874,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let mut call = TextCall {
|
||||
matrix: Mat4::IDENTITY,
|
||||
@@ -3728,6 +3947,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let call = TextCall {
|
||||
matrix: Mat4::IDENTITY,
|
||||
@@ -3843,6 +4063,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let check = |scene: &Scene, pos: [i32; 3], state: u32| {
|
||||
let mut cached = Vec::new();
|
||||
@@ -3983,6 +4204,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
renderer.render_block(&scene, [0; 3], state.0, Mat4::IDENTITY, 0xff, &mut verts);
|
||||
@@ -4068,6 +4290,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
|
||||
let draw = |renderer: &BlockEntityRenderer, block: &str, texture: &'static str| {
|
||||
|
||||
@@ -358,6 +358,7 @@ fn batched_callbacks_preserve_entity_ranges_and_allow_immediate_culling() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
// Reverse local-position order to check that cache indexing does not
|
||||
// change the order of emitted entities.
|
||||
@@ -446,6 +447,7 @@ fn static_cache_qualifies_local_positions_by_section_and_drops_old_entries() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
shulker_boxes: true,
|
||||
@@ -500,6 +502,7 @@ fn gpu_static_cache_uses_section_local_chests_and_keeps_visibility_separate() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([100, 70, -32], 0)];
|
||||
let other_block = [([116, 70, -32], 0)];
|
||||
@@ -686,6 +689,7 @@ fn sign_text_cache_can_be_disabled_and_reenabled_without_changing_output() {
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
let enabled = CacheOptions {
|
||||
@@ -739,6 +743,7 @@ fn shulker_cache_tracks_settled_poses_and_does_not_freeze_opening_animation() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
let options = CacheOptions {
|
||||
@@ -840,6 +845,7 @@ fn shulker_cache_covers_all_colors_and_facing_directions() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
shulker_boxes: true,
|
||||
@@ -894,6 +900,7 @@ fn cached_bells_keep_shaking_until_the_animation_ends() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
let options = CacheOptions {
|
||||
@@ -955,6 +962,7 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
let options = CacheOptions {
|
||||
@@ -1050,6 +1058,7 @@ fn closed_chest_cache_covers_all_materials_and_atlas_reloads() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let pos = [0, 64, 0];
|
||||
let options = CacheOptions {
|
||||
@@ -1138,6 +1147,7 @@ fn double_chest_cache_bypasses_both_halves_while_either_lid_is_open() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let pair = [([0, 64, 0], 0), ([1, 64, 0], 1)];
|
||||
let options = CacheOptions {
|
||||
@@ -1213,6 +1223,7 @@ fn decorated_pot_cache_tracks_sherds_and_bypasses_wobble_animation() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
let options = CacheOptions {
|
||||
@@ -1291,6 +1302,7 @@ fn banner_cache_keeps_the_pole_static_while_the_flag_and_patterns_update() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
let options = CacheOptions {
|
||||
@@ -1357,6 +1369,7 @@ fn cached_wall_banner_poles_use_the_wall_model() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let pos = [0, 64, 0];
|
||||
let options = CacheOptions {
|
||||
@@ -1404,6 +1417,7 @@ fn copper_statue_cache_rebuilds_when_its_pose_state_changes() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
copper_golem_statues: true,
|
||||
@@ -1509,6 +1523,7 @@ fn copper_statue_cache_covers_weathering_waxing_and_all_poses() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
copper_golem_statues: true,
|
||||
|
||||
@@ -487,6 +487,63 @@ mod tests {
|
||||
assert!(!stand.idle());
|
||||
}
|
||||
|
||||
/// The stand sleeps while `idle`, so its tick must change nothing then: run the real
|
||||
/// tick on idle stands and compare, and check a stand with fuel to take wakes.
|
||||
#[test]
|
||||
fn an_idle_brewing_stand_tick_changes_nothing() {
|
||||
let items = Items::for_tests(std::collections::HashMap::new());
|
||||
let brewing = Brewing {
|
||||
reagents: ["minecraft:nether_wart".to_owned()].into_iter().collect(),
|
||||
inputs: HashSet::new(),
|
||||
recipes: vec![BrewingRecipe {
|
||||
id: "awkward".into(),
|
||||
input: PotionIngredient {
|
||||
items: ["minecraft:potion".to_owned()].into_iter().collect(),
|
||||
potions: Some(["minecraft:water".to_owned()].into_iter().collect()),
|
||||
},
|
||||
reagent: PotionIngredient {
|
||||
items: ["minecraft:nether_wart".to_owned()].into_iter().collect(),
|
||||
potions: None,
|
||||
},
|
||||
output: ItemStack::new("minecraft:potion", 1).with_potion("minecraft:awkward"),
|
||||
}],
|
||||
};
|
||||
let providers = Providers::empty();
|
||||
let water = || Some(ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"));
|
||||
let stand = |bottle: bool, fuel: i32, powder: bool| {
|
||||
let mut s = BrewingStand::new();
|
||||
s.fuel = fuel;
|
||||
if bottle {
|
||||
s.items[1] = water();
|
||||
}
|
||||
if powder {
|
||||
s.items[4] = Some(ItemStack::new("minecraft:blaze_powder", 1));
|
||||
}
|
||||
s.last_bottles = Some([false, bottle, false]);
|
||||
s
|
||||
};
|
||||
for before in [
|
||||
stand(false, 0, false),
|
||||
stand(true, 0, false),
|
||||
stand(true, 5, false),
|
||||
stand(true, 5, true),
|
||||
] {
|
||||
assert!(before.idle(), "{before:?}");
|
||||
let mut after = before.clone();
|
||||
assert_eq!(
|
||||
after.tick(&brewing, &items, &providers),
|
||||
BrewTick::default()
|
||||
);
|
||||
assert_eq!(format!("{after:?}"), format!("{before:?}"));
|
||||
}
|
||||
// A brewable ingredient starts the brew: not idle, and the tick changes it.
|
||||
let mut brewing_now = stand(true, 5, false);
|
||||
brewing_now.items[3] = Some(ItemStack::new("minecraft:nether_wart", 1));
|
||||
assert!(!brewing_now.idle());
|
||||
brewing_now.tick(&brewing, &items, &providers);
|
||||
assert_eq!(brewing_now.brew_time, 400);
|
||||
}
|
||||
|
||||
/// #3312: `PotionContents.getPotionDescription` names the effects with
|
||||
/// translatable components, so a Russian game reads them from ru_ru.json
|
||||
/// (the prepared one; skipped without it), as the tooltip's other lines.
|
||||
|
||||
@@ -749,6 +749,43 @@ mod tests {
|
||||
assert!(c.idle(false));
|
||||
}
|
||||
|
||||
/// The campfire sleeps while `idle`, so the tick its block runs (`cook_tick` lit,
|
||||
/// `cooldown_tick` unlit) must change nothing then, and must change an awake one.
|
||||
#[test]
|
||||
fn an_idle_campfire_tick_changes_nothing() {
|
||||
let tick = |c: &mut Campfire, lit: bool| {
|
||||
if lit {
|
||||
!c.cook_tick().is_empty()
|
||||
} else {
|
||||
c.cooldown_tick()
|
||||
}
|
||||
};
|
||||
let empty = Campfire::default();
|
||||
let mut food = Campfire::default();
|
||||
assert!(food.place_food(&mut ItemStack::new("minecraft:beef", 1), 600));
|
||||
let mut warm = food.clone();
|
||||
warm.cook_tick();
|
||||
let mut cooled = warm.clone();
|
||||
while cooled.cooldown_tick() {}
|
||||
for (before, lit) in [
|
||||
(&empty, true),
|
||||
(&empty, false),
|
||||
(&food, false),
|
||||
(&cooled, false),
|
||||
] {
|
||||
assert!(before.idle(lit), "{before:?} lit {lit}");
|
||||
let mut after = before.clone();
|
||||
assert!(!tick(&mut after, lit));
|
||||
assert_eq!(&after, before, "lit {lit}");
|
||||
}
|
||||
for (before, lit) in [(&food, true), (&warm, false)] {
|
||||
assert!(!before.idle(lit), "{before:?} lit {lit}");
|
||||
let mut after = before.clone();
|
||||
tick(&mut after, lit);
|
||||
assert_ne!(&after, before, "lit {lit}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campfire_slots_take_food_cook_it_and_cool_down() {
|
||||
let mut c = Campfire::default();
|
||||
|
||||
@@ -936,6 +936,10 @@ impl Font {
|
||||
offset: 0.5,
|
||||
})
|
||||
}
|
||||
/// Starts the random `random_glyph` draws on over from `seed`.
|
||||
pub fn reseed(&self, seed: i64) {
|
||||
self.cache.borrow_mut().random = horizon_core::random::LegacyRandom::new(seed);
|
||||
}
|
||||
/// `FontSet.getRandomGlyph`: what an obfuscated character draws, a random
|
||||
/// glyph of the font as wide as `advance` (rounded up), else the missing
|
||||
/// glyph.
|
||||
|
||||
@@ -50,11 +50,13 @@ mod inter_stage_tests;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_gpu;
|
||||
mod weather;
|
||||
mod world_text;
|
||||
use entity_batch::EntityBatch;
|
||||
pub use entity_batch::{
|
||||
DISSOLVE_OVERLAY, DRAGON_RAYS_OVERLAY, ENERGY_SWIRL_OVERLAY, OIT_ENTITY, SHADOW_OVERLAY,
|
||||
TEXT_POLYGON_OFFSET_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, mark_oit,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY,
|
||||
WORLD_TEXT_POLYGON_OFFSET_OVERLAY, mark_oit,
|
||||
};
|
||||
pub use view::{View, ViewSet};
|
||||
|
||||
@@ -1092,10 +1094,11 @@ pub struct Renderer {
|
||||
/// Solid quads facing away from the eye are left out (`mesher::Sides`).
|
||||
sides_on: bool,
|
||||
/// The near solid sections culled and drawn on the GPU (`gpu_culling`): its pass,
|
||||
/// records and fades while it is on, where the device can (`gpu_culling_supported`),
|
||||
/// and whether the last frame drew them so (`gpu_frame`).
|
||||
/// records and fades while it is on, where it is offered (`gpu_culling_offer`, mirrored
|
||||
/// for the settings in `graphics::gpu_culling_offer`), and whether the last frame drew
|
||||
/// them so (`gpu_frame`).
|
||||
gpu_culling: Option<gpu_culling::GpuCulling>,
|
||||
gpu_culling_supported: bool,
|
||||
gpu_culling_offer: crate::graphics::GpuCullingOffer,
|
||||
gpu_frame: bool,
|
||||
/// GPU frames since GPU culling was switched on: the first and every 64th after it
|
||||
/// check its records (`check_gpu_records`).
|
||||
@@ -1167,6 +1170,7 @@ pub struct Renderer {
|
||||
/// Native mods' world primitives (`Frame::world_primitives`).
|
||||
world_primitives: world_primitives::WorldPrimitives,
|
||||
text_polygon_offset_pipeline: wgpu::RenderPipeline,
|
||||
world_text_pipeline: wgpu::RenderPipeline,
|
||||
celestial_pipeline: wgpu::RenderPipeline,
|
||||
celestial_buffer: wgpu::Buffer,
|
||||
end_sky: end_sky::EndSky,
|
||||
@@ -1391,6 +1395,8 @@ pub struct Renderer {
|
||||
pack_terrain_flat: bool,
|
||||
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
|
||||
pub separate_ao: bool,
|
||||
/// Lava's sprite layers, which a pack's solid terrain draws (`pack_atlas::terrain_parts`).
|
||||
pack_lava_layers: Vec<u16>,
|
||||
pub hud: HudRenderer,
|
||||
/// Vanilla's menu background blur, made on first use.
|
||||
menu_blur: Option<crate::menu_blur::MenuBlur>,
|
||||
@@ -1408,11 +1414,17 @@ pub struct Renderer {
|
||||
/// panics when such a surface is acquired from (it never had one that
|
||||
/// worked), so frames configure it again first and skip until it takes.
|
||||
surface_unconfigured: bool,
|
||||
/// The last frame's statistics, all of one frame (`show_stats`): its sections drawn
|
||||
/// (F3's `C:`, the world icon's check), section draws (face ranges and translucent
|
||||
/// sections) and the draw calls the backend issued for them. A CPU frame's are its own;
|
||||
/// in GPU frames (`gpu_culling`) they are the newest GPU frame's whose counts came back,
|
||||
/// a frame or a few earlier (`stats_lag`).
|
||||
pub drawn_sections: usize,
|
||||
/// Last frame's section draws (face ranges and translucent sections),
|
||||
/// and the draw calls the backend issued for them.
|
||||
pub section_draws: usize,
|
||||
pub section_draw_calls: usize,
|
||||
/// `draw_frame`'s frames past its early returns, and the one whose statistics are shown.
|
||||
frame_serial: u64,
|
||||
stats_frame: u64,
|
||||
pub uploaded_bytes: u64,
|
||||
started: Instant,
|
||||
/// The next frame to write to a PNG path (control socket, F2, world icon).
|
||||
@@ -3731,6 +3743,12 @@ impl Renderer {
|
||||
alpha_blend,
|
||||
false,
|
||||
);
|
||||
let world_text_pipeline = world_text::pipeline(
|
||||
&device,
|
||||
Some(&entity_layout),
|
||||
&entity_shader,
|
||||
config.format.remove_srgb_suffix(),
|
||||
);
|
||||
// RenderPipelines.ENTITY_SOLID: a surface of an entity that is coplanar with
|
||||
// terrain wins the tie in vanilla (the boat's floor over the trapdoor on the
|
||||
// ground it is stopped by, #5950). Terrain and entities are separate draw
|
||||
@@ -4374,14 +4392,25 @@ impl Renderer {
|
||||
let batchable = translucent_batchable(&adapter.get_info(), multi_draw);
|
||||
crate::graphics::offer_translucent_batching(batchable);
|
||||
let translucent_batching = batchable && translucent_batching;
|
||||
let gpu_culling_supported = gpu_culling::supported(
|
||||
let gpu_culling_offer = gpu_culling::offer(
|
||||
&adapter.get_info(),
|
||||
device.features(),
|
||||
adapter.get_downlevel_capabilities().flags,
|
||||
&device.limits(),
|
||||
multi_draw,
|
||||
flat.as_ref().map(|f| f.min_sections),
|
||||
gpu_culling::supported(
|
||||
&adapter.get_info(),
|
||||
device.features(),
|
||||
adapter.get_downlevel_capabilities().flags,
|
||||
&device.limits(),
|
||||
multi_draw,
|
||||
flat.as_ref().map(|f| f.min_sections),
|
||||
),
|
||||
flat.is_some(),
|
||||
);
|
||||
// Not again after the device was lost in one of its frames, until Horizon restarts.
|
||||
let gpu_culling_offer = if crate::graphics::gpu_culling_lost() {
|
||||
crate::graphics::GpuCullingOffer::DeviceLost
|
||||
} else {
|
||||
gpu_culling_offer
|
||||
};
|
||||
crate::graphics::offer_gpu_culling(gpu_culling_offer);
|
||||
// A new renderer runs no shader pack: the game loads the picked one on its
|
||||
// first world frame (`shader_pack`: the picked pack draws flat, a `shaders/`
|
||||
// pack or one of Horizon's own with `[shadow]`), and the frames before it, the
|
||||
@@ -4455,7 +4484,7 @@ impl Renderer {
|
||||
"pipelines ready (multi-draw {}, occlusion culling {}, GPU culling {}, GPU timer {})",
|
||||
on_off(multi_draw),
|
||||
on_off(cull.is_some()),
|
||||
if gpu_culling_supported {
|
||||
if gpu_culling_offer == crate::graphics::GpuCullingOffer::Offered {
|
||||
"available"
|
||||
} else {
|
||||
"unavailable"
|
||||
@@ -4501,7 +4530,7 @@ impl Renderer {
|
||||
cull_on: cull.is_some(),
|
||||
sides_on: true,
|
||||
gpu_culling: None,
|
||||
gpu_culling_supported,
|
||||
gpu_culling_offer,
|
||||
gpu_frame: false,
|
||||
#[cfg(debug_assertions)]
|
||||
gpu_checks: 0,
|
||||
@@ -4553,6 +4582,7 @@ impl Renderer {
|
||||
see_through_pipeline,
|
||||
world_primitives,
|
||||
text_polygon_offset_pipeline,
|
||||
world_text_pipeline,
|
||||
celestial_pipeline,
|
||||
celestial_buffer,
|
||||
end_sky: end_sky::EndSky::default(),
|
||||
@@ -4682,6 +4712,7 @@ impl Renderer {
|
||||
pack_material_atlases: None,
|
||||
pack_terrain_flat: false,
|
||||
separate_ao: false,
|
||||
pack_lava_layers: Vec::new(),
|
||||
hud,
|
||||
menu_blur: None,
|
||||
post_effects: None,
|
||||
@@ -4693,6 +4724,8 @@ impl Renderer {
|
||||
drawn_sections: 0,
|
||||
section_draws: 0,
|
||||
section_draw_calls: 0,
|
||||
frame_serial: 0,
|
||||
stats_frame: 0,
|
||||
uploaded_bytes: 0,
|
||||
started: Instant::now(),
|
||||
screenshot: None,
|
||||
@@ -5008,13 +5041,15 @@ impl Renderer {
|
||||
pub fn gpu_fading(&self) -> Option<u32> {
|
||||
self.gpu_culling.as_ref().map(|g| g.records.fading())
|
||||
}
|
||||
/// Switches GPU culling, for A/B checks. Its pass, its records of the
|
||||
/// resident sections and their flags while they fade are made when it is
|
||||
/// switched on (the next world frame decides the flags, `mark_fades`) and
|
||||
/// dropped when it is switched off, with the walk marks the flags gave:
|
||||
/// nothing is kept for it while it is off but the sections' other walk marks
|
||||
/// (`SectionGpu::listed`) and the cave-culling graph's bits
|
||||
/// (`occlusion::Reach`), which are the same either way.
|
||||
/// Switches GPU culling: the GPU Culling setting sets it (`apply_ui_settings`, after
|
||||
/// every settings change and every new renderer), and so does the control word. It
|
||||
/// turns on only where it is offered (`gpu_culling_offer`); unchanged, it does nothing.
|
||||
/// Its pass, its records of the resident sections and their flags while they
|
||||
/// fade are made when it is switched on (the next world frame decides the
|
||||
/// flags, `mark_fades`) and dropped when it is switched off, with the walk
|
||||
/// marks the flags gave: nothing is kept for it while it is off but the
|
||||
/// sections' other walk marks (`SectionGpu::listed`) and the cave-culling
|
||||
/// graph's bits (`occlusion::Reach`), which are the same either way.
|
||||
pub fn set_gpu_culling(&mut self, on: bool) -> Result<()> {
|
||||
if !on {
|
||||
if self.gpu_culling.take().is_some() {
|
||||
@@ -5026,12 +5061,12 @@ impl Renderer {
|
||||
if self.gpu_culling.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
if !self.gpu_culling_supported {
|
||||
return Err("GPU culling is unavailable on this device".into());
|
||||
if let Some(why) = self.gpu_culling_offer.reason() {
|
||||
return Err(why.into());
|
||||
}
|
||||
let Some(mut gpu) = gpu_culling::GpuCulling::new(&self.device) else {
|
||||
// Not offered from now on.
|
||||
self.gpu_culling_supported = false;
|
||||
// Not offered from now on: its switch greys out.
|
||||
self.offer_gpu_culling(crate::graphics::GpuCullingOffer::Unsupported);
|
||||
return Err("GPU culling could not be set up on this device".into());
|
||||
};
|
||||
let sections = self.layout.sections();
|
||||
@@ -5047,6 +5082,15 @@ impl Renderer {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// GPU culling offered or not from now on, here and in the settings' switch.
|
||||
fn offer_gpu_culling(&mut self, offer: crate::graphics::GpuCullingOffer) {
|
||||
self.gpu_culling_offer = offer;
|
||||
crate::graphics::offer_gpu_culling(offer);
|
||||
}
|
||||
/// Whether GPU culling is offered here, and why not (the status's `gpu_culling_offer`).
|
||||
pub fn gpu_culling_offer(&self) -> crate::graphics::GpuCullingOffer {
|
||||
self.gpu_culling_offer
|
||||
}
|
||||
/// Debug builds: GPU culling's records are the sections' as they are now,
|
||||
/// compaction's moves included: each section it keeps a record of
|
||||
/// (`SectionGpu::gpu_recorded`) has its own, no record is left without its
|
||||
@@ -5164,6 +5208,16 @@ impl Renderer {
|
||||
pub fn start_occlusion(&mut self, camera: DVec3, distance: f32) {
|
||||
self.occlusion.update(camera, (distance / 16.0) as i32 + 2);
|
||||
}
|
||||
/// Whether the camera's current section graph knows this section is
|
||||
/// reachable. `None` means the graph cannot safely answer for this camera
|
||||
/// or section, so callers should keep the geometry.
|
||||
pub fn section_occlusion_visible(
|
||||
&self,
|
||||
key: SectionKey,
|
||||
camera_section: SectionKey,
|
||||
) -> Option<bool> {
|
||||
self.occlusion.visible_for_camera(key, camera_section)
|
||||
}
|
||||
pub fn upload(&mut self, key: SectionKey, mesh: &SectionMesh) {
|
||||
if let Some(copies) = &mut self.capture_copies {
|
||||
copies.section(key, mesh);
|
||||
@@ -6395,6 +6449,10 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Lava's sprite layers (still and flowing), whose faces a pack's solid terrain draws.
|
||||
pub fn set_pack_lava_layers(&mut self, layers: Vec<u16>) {
|
||||
self.pack_lava_layers = layers;
|
||||
}
|
||||
/// Runs the sky, material and post stages of one of Horizon's own shader packs from now
|
||||
/// on (`None`: no pack), with its options' values; replaces a `shaders/` pack. Returns the
|
||||
/// stages' labels; on an error the pack that ran keeps running.
|
||||
@@ -6458,6 +6516,9 @@ impl Renderer {
|
||||
}
|
||||
});
|
||||
horizon_shaders::GameInputs {
|
||||
terrain_parts: terrain
|
||||
.as_ref()
|
||||
.map(|t| pack_atlas::terrain_parts(t, &self.pack_lava_layers)),
|
||||
terrain,
|
||||
terrain_geometry,
|
||||
entity_geometry: Some(pack_entities::geometry_inputs()),
|
||||
@@ -7396,7 +7457,8 @@ impl Renderer {
|
||||
});
|
||||
}
|
||||
// A shader pack's shadow pass draws the sections in its camera's box, all of them (the
|
||||
// occlusion graph follows the eye, not the light), within shadowDistanceRenderMul.
|
||||
// occlusion graph follows the eye, not the light), within shadowDistanceRenderMul; for
|
||||
// an Iris-format pack, only those whose shadow can fall into the camera's view.
|
||||
let shadow_view = self
|
||||
.pack_post
|
||||
.as_ref()
|
||||
@@ -7434,15 +7496,21 @@ impl Renderer {
|
||||
mark_fades(sections, &mut gpu.records, &mut gpu.fades, rows, fades);
|
||||
}
|
||||
// The near solid sections culled and drawn on the GPU (`gpu_culling`): never where a
|
||||
// shader pack's terrain, a shadow pass or the water mirror draws the CPU's lists.
|
||||
// Decided before the walk, so that a pass whose buffers no longer fit can still
|
||||
// switch GPU culling off.
|
||||
// shader pack's terrain, a shadow pass or the water mirror draws the CPU's lists, nor
|
||||
// under a native pack's `[displacement]`, whose grid takes its quads from the walk's
|
||||
// `visible`. Decided before the walk, so that a pass whose buffers no longer fit can
|
||||
// still switch GPU culling off.
|
||||
let radius = (frame.detail_distance / 16.0) as i32;
|
||||
let displaced = self
|
||||
.native
|
||||
.as_ref()
|
||||
.is_some_and(|n| n.displacement.is_some());
|
||||
let mut gpu_frame = self.gpu_culling.is_some()
|
||||
&& !frame.title
|
||||
&& !pack_terrain
|
||||
&& shadow_view.is_none()
|
||||
&& mirror_plane.is_none();
|
||||
&& mirror_plane.is_none()
|
||||
&& !displaced;
|
||||
#[cfg(debug_assertions)]
|
||||
if gpu_frame {
|
||||
if self.gpu_checks.is_multiple_of(64) {
|
||||
@@ -7450,6 +7518,14 @@ impl Renderer {
|
||||
}
|
||||
self.gpu_checks = self.gpu_checks.wrapping_add(1);
|
||||
}
|
||||
self.frame_serial += 1;
|
||||
let serial = self.frame_serial;
|
||||
// GPU frames' statistics come back a frame or a few later (`GpuCulling::collect`).
|
||||
if let Some(gpu) = &mut self.gpu_culling
|
||||
&& let Some((frame, stats)) = gpu.collect(&self.device)
|
||||
{
|
||||
self.show_stats(frame, stats);
|
||||
}
|
||||
// The walk reads the graph GPU culling is given (checked after it).
|
||||
#[cfg(debug_assertions)]
|
||||
let graph_given = self.occlusion.generation();
|
||||
@@ -7476,7 +7552,10 @@ impl Renderer {
|
||||
// above or below the level), or whose eye is not a number, is the CPU's.
|
||||
gpu_frame = prepared == gpu_culling::Prepared::Gpu;
|
||||
if prepared == gpu_culling::Prepared::OutOfMemory {
|
||||
log::warn!("Horizon renderer: GPU culling off: its buffers do not fit");
|
||||
log::warn!(
|
||||
"Horizon renderer: GPU culling off until the next start: its buffers do not fit in video memory"
|
||||
);
|
||||
self.offer_gpu_culling(crate::graphics::GpuCullingOffer::OutOfMemory);
|
||||
let _ = self.set_gpu_culling(false);
|
||||
}
|
||||
}
|
||||
@@ -7534,8 +7613,11 @@ impl Renderer {
|
||||
let radius = v
|
||||
.distance
|
||||
.map_or(radius, |d| radius.min((d / 16.0).ceil() as i32));
|
||||
let light = Frustum {
|
||||
planes: frustum(v.cull),
|
||||
let light = ShadowCull {
|
||||
light: Frustum {
|
||||
planes: frustum(v.cull),
|
||||
},
|
||||
casters: v.view_casters.then(|| shadow_casters(cull_proj, v.light)),
|
||||
};
|
||||
crate::spans::time("shadow visibility", || {
|
||||
// Within the unculled distance every section, then those in the light's box.
|
||||
@@ -7605,8 +7687,8 @@ impl Renderer {
|
||||
// the sections GPU culling draws: every layer animates.
|
||||
self.animate(&mut encoder, frame.ticks, !frame.title && !gpu_frame);
|
||||
}
|
||||
self.drawn_sections = visible.len() + visible_lods.len();
|
||||
let instances = self.drawn_sections + shadow_visible.len();
|
||||
let listed = visible.len() + visible_lods.len();
|
||||
let instances = listed + shadow_visible.len();
|
||||
if instances as u64 > self.instance_capacity {
|
||||
self.instance_capacity = (instances as u64).next_power_of_two();
|
||||
self.instances = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
@@ -7737,6 +7819,9 @@ impl Renderer {
|
||||
// (`gpu_fading`), those without a record and those past the distance (far terrain's
|
||||
// cutout, and columns leaving).
|
||||
let from = resident::camera_column(camera);
|
||||
// Listed sections whose solid quads the pass draws: it counts them among the
|
||||
// sections it kept.
|
||||
let mut gpu_solid = 0;
|
||||
for (i, v) in visible.iter().enumerate() {
|
||||
let s = self.sections.get(v.slot);
|
||||
if gpu_frame
|
||||
@@ -7744,6 +7829,7 @@ impl Renderer {
|
||||
from, radius, v.key.0, v.key.2,
|
||||
))
|
||||
{
|
||||
gpu_solid += 1;
|
||||
continue;
|
||||
}
|
||||
let first_phase = cull.as_ref().is_none_or(|c| c.first(v.slot));
|
||||
@@ -7994,7 +8080,7 @@ impl Renderer {
|
||||
}
|
||||
self.translucency_cursor += budget;
|
||||
let translucent_draws = self.draws.len() as u32 - solid_draws;
|
||||
self.section_draws = self.draws.len();
|
||||
let section_draws = self.draws.len();
|
||||
// The shadow camera's sections after the camera's: quads that can face the light (all of
|
||||
// them for `every_face`), then translucent ones (in their last sorted order; depth
|
||||
// decides there).
|
||||
@@ -8002,7 +8088,7 @@ impl Renderer {
|
||||
let mut shadow_translucent = Vec::new();
|
||||
self.shadow_runs.clear();
|
||||
if let Some(view) = &shadow_view {
|
||||
let base = self.drawn_sections;
|
||||
let base = listed;
|
||||
// How far below the nearest section to the light (squared, as bits, as `PageRuns`
|
||||
// takes distances): each band's draws go by page, a call per page and band.
|
||||
let height = |v: &resident::Visible| glam::Vec3::from(v.origin).dot(view.light);
|
||||
@@ -8075,8 +8161,9 @@ impl Renderer {
|
||||
let shadow_translucent = self.draws.len() as u32 - shadow_first - shadow_solid;
|
||||
laps.lap("r_nearby");
|
||||
// Metal replays a multi-draw as one draw call per entry.
|
||||
self.section_draw_calls = if self.multi_draw && self.info.backend != wgpu::Backend::Metal {
|
||||
(0..self.section_draws)
|
||||
let mut section_draw_calls = if self.multi_draw && self.info.backend != wgpu::Backend::Metal
|
||||
{
|
||||
(0..section_draws)
|
||||
.filter(|&i| {
|
||||
i == 0
|
||||
|| i == phase1_draws as usize
|
||||
@@ -8085,12 +8172,12 @@ impl Renderer {
|
||||
})
|
||||
.count()
|
||||
} else {
|
||||
self.section_draws
|
||||
section_draws
|
||||
};
|
||||
// GPU culling's draw calls, one per page. Its draws are not counted: the GPU
|
||||
// decides them, and nothing reads them back.
|
||||
// GPU culling's draw calls, one per page; its draws come back with its statistics
|
||||
// (`GpuCulling::copy_stats`).
|
||||
if gpu_frame && let Some(gpu) = &self.gpu_culling {
|
||||
self.section_draw_calls += gpu.draw_calls();
|
||||
section_draw_calls += gpu.draw_calls();
|
||||
}
|
||||
let debug_depth = frame.debug_lines.len() as u32;
|
||||
let debug_opaque = frame
|
||||
@@ -8242,6 +8329,8 @@ impl Renderer {
|
||||
translucent_particle_quads,
|
||||
translucent_item_quads,
|
||||
oit_entity_quads,
|
||||
world_text_quads,
|
||||
world_text_offset_quads,
|
||||
] = self.entity_batch.update(
|
||||
entities,
|
||||
frame.additive,
|
||||
@@ -8267,11 +8356,14 @@ impl Renderer {
|
||||
+ shadow_quads
|
||||
+ translucent_particle_quads
|
||||
+ translucent_item_quads
|
||||
+ oit_entity_quads;
|
||||
+ oit_entity_quads
|
||||
+ world_text_quads
|
||||
+ world_text_offset_quads;
|
||||
laps.lap("r_draw_lists");
|
||||
// Where the tail of materials the classic and the improved passes
|
||||
// both draw ends: the shadows, particles and items count back from it.
|
||||
let base_quads = world_quads - oit_entity_quads;
|
||||
let base_quads =
|
||||
world_quads - oit_entity_quads - world_text_quads - world_text_offset_quads;
|
||||
let native_clouds = self.native.as_ref().is_none_or(|n| n.vanilla_clouds);
|
||||
let cloud_quads = if let Some(input) = frame.clouds.as_ref().filter(|_| native_clouds) {
|
||||
let (rebuilt, uniform) =
|
||||
@@ -8615,7 +8707,7 @@ impl Renderer {
|
||||
// where vanilla frames draw flat).
|
||||
&& !gpu_frame
|
||||
&& !self.draws.is_empty()
|
||||
&& (self.drawn_sections >= flat.min_sections
|
||||
&& (listed >= flat.min_sections
|
||||
|| pack_terrain
|
||||
|| shadow_view.is_some()) =>
|
||||
{
|
||||
@@ -8671,7 +8763,7 @@ impl Renderer {
|
||||
});
|
||||
}
|
||||
if flat_frame {
|
||||
self.section_draw_calls = self.flat.as_ref().map_or(0, |f| f.shadow_calls[0]);
|
||||
section_draw_calls = self.flat.as_ref().map_or(0, |f| f.shadow_calls[0]);
|
||||
} else if self.multi_draw && !self.draws.is_empty() {
|
||||
if self.draws.len() as u64 > self.indirect_capacity {
|
||||
self.indirect_capacity = (self.draws.len() as u64).next_power_of_two();
|
||||
@@ -8689,12 +8781,6 @@ impl Renderer {
|
||||
bytemuck::cast_slice(&self.draws),
|
||||
);
|
||||
}
|
||||
// A GPU frame's walk leaves out the sections GPU culling draws, and nothing reads back
|
||||
// which it kept: every record counts but the flagged ones, which the walk lists, an
|
||||
// upper bound (the world icon waits for more than a few drawn).
|
||||
if gpu_frame && let Some(gpu) = &self.gpu_culling {
|
||||
self.drawn_sections += gpu.records.live() - gpu.records.fading() as usize;
|
||||
}
|
||||
let view_target = target_texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
format: Some(self.frame_format),
|
||||
..Default::default()
|
||||
@@ -8702,8 +8788,15 @@ impl Renderer {
|
||||
if let Some(timer) = &mut self.gpu_timer {
|
||||
timer.begin(&mut encoder);
|
||||
}
|
||||
if gpu_frame && let Some(gpu) = &self.gpu_culling {
|
||||
// The frame's statistics (`show_stats`): a CPU frame's at once; a GPU frame's when the
|
||||
// pass's counts come back, with the CPU's part of it: the sections the walk listed but
|
||||
// those whose solid quads the pass drew (it counts them), its draws and every call.
|
||||
let cpu = [listed - gpu_solid, section_draws, section_draw_calls];
|
||||
if gpu_frame && let Some(gpu) = &mut self.gpu_culling {
|
||||
gpu.dispatch(&mut encoder, self.own_timing.as_mut());
|
||||
gpu.copy_stats(&mut encoder, serial, cpu);
|
||||
} else {
|
||||
self.show_stats(serial, cpu);
|
||||
}
|
||||
// The world's targets: the screen, or the scaled target of `post`.
|
||||
let pack_post = !frame.title && self.pack_post.as_ref().is_some_and(|p| p.1.is_some());
|
||||
@@ -10007,6 +10100,29 @@ impl Renderer {
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
// Native world text is an ordinary depth-tested overlay, after
|
||||
// particles (also after the OIT composite) and before the hand.
|
||||
// Its own geometry is independent of block-entity lifetime.
|
||||
if world_text_quads + world_text_offset_quads > 0 {
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
let first = base_quads + oit_entity_quads;
|
||||
for (pipeline, count, offset) in [
|
||||
(&self.world_text_pipeline, world_text_quads, first),
|
||||
(
|
||||
&self.text_polygon_offset_pipeline,
|
||||
world_text_offset_quads,
|
||||
first + world_text_quads,
|
||||
),
|
||||
] {
|
||||
if count > 0 {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.draw_indexed(0..count * 6, (offset * 4) as i32, 0..1);
|
||||
}
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
// LevelRenderer.executeClassicTransparency: translucent targets'
|
||||
// outlines follow terrain, so its blending cannot wash them out.
|
||||
if outline_edges > 0 && frame.outline_after_terrain && !pack_draws_outline {
|
||||
@@ -10385,16 +10501,16 @@ impl Renderer {
|
||||
&& (surface_frame.is_none() || self.config.present_mode != wgpu::PresentMode::Fifo)
|
||||
{
|
||||
let offscreen = surface_frame.is_none();
|
||||
// Culling and a shader pack's centre depth read back as soon as the frame is
|
||||
// submitted, as here.
|
||||
let cull = self.cull.as_mut().and_then(|c| c.submitted());
|
||||
// Occlusion culling's results, GPU culling's statistics and a shader pack's centre
|
||||
// depth are read back as soon as the frame is submitted, as here.
|
||||
let culling = self.after_submit();
|
||||
let center = match &mut self.pack_post {
|
||||
Some((_, Some(post))) => post.submitted(),
|
||||
_ => None,
|
||||
};
|
||||
let after = (cull.is_some() || center.is_some()).then(|| {
|
||||
let after = (culling.is_some() || center.is_some()).then(|| {
|
||||
Box::new(move || {
|
||||
if let Some(read) = cull {
|
||||
if let Some(read) = culling {
|
||||
read();
|
||||
}
|
||||
if let Some(read) = center {
|
||||
@@ -10429,9 +10545,7 @@ impl Renderer {
|
||||
self.first_frame = FirstFrame::Drawn;
|
||||
}
|
||||
let submission = crate::spans::time("submit", || self.queue.submit([commands]));
|
||||
if let Some(read) = after {
|
||||
read();
|
||||
} else if let Some(read) = self.cull.as_mut().and_then(|c| c.submitted()) {
|
||||
if let Some(read) = after.or_else(|| self.after_submit()) {
|
||||
read();
|
||||
}
|
||||
if let Some(timer) = &mut self.gpu_timer {
|
||||
@@ -10900,6 +11014,39 @@ pub(crate) fn copy_frame(
|
||||
buffer
|
||||
}
|
||||
impl Renderer {
|
||||
/// The frame's readbacks to start right after its submission (on the submit thread when
|
||||
/// the frame goes there): occlusion culling's results (`cull`) and GPU culling's
|
||||
/// statistics. GPU frames leave occlusion culling off, so a frame has one at most.
|
||||
fn after_submit(&mut self) -> Option<submit::AfterSubmit> {
|
||||
let cull = self.cull.as_mut().and_then(|c| c.submitted());
|
||||
let gpu = self.gpu_culling.as_mut().and_then(|g| g.submitted());
|
||||
match (cull, gpu) {
|
||||
(Some(c), Some(g)) => Some(Box::new(move || {
|
||||
c();
|
||||
g();
|
||||
})),
|
||||
(Some(c), None) => Some(Box::new(c)),
|
||||
(None, Some(g)) => Some(Box::new(g)),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
/// Frame `frame`'s statistics shown, unless a newer frame's are: a CPU frame's as it is
|
||||
/// drawn, a GPU frame's when its counts come back (`GpuCulling::collect`).
|
||||
fn show_stats(&mut self, frame: u64, [sections, draws, calls]: [usize; 3]) {
|
||||
if frame > self.stats_frame {
|
||||
self.stats_frame = frame;
|
||||
(
|
||||
self.drawn_sections,
|
||||
self.section_draws,
|
||||
self.section_draw_calls,
|
||||
) = (sections, draws, calls);
|
||||
}
|
||||
}
|
||||
/// Frames between the last one drawn and the one whose statistics are shown: 0 after a
|
||||
/// CPU frame, one to three in GPU frames, while their counts are on their way back.
|
||||
pub fn stats_lag(&self) -> u64 {
|
||||
self.frame_serial - self.stats_frame
|
||||
}
|
||||
/// Take in the frame the submit thread submitted: start its readbacks and, offscreen,
|
||||
/// keep at most two frames ahead of the GPU, as a frame submitted in place does.
|
||||
fn land_frame(&mut self) {
|
||||
@@ -11115,6 +11262,10 @@ pub struct Frusta(pub Frustum, pub Option<Frustum>);
|
||||
pub trait Cull {
|
||||
fn intersects(&self, min: Vec3, max: Vec3) -> bool;
|
||||
fn contains(&self, min: Vec3, max: Vec3) -> bool;
|
||||
/// Whether a section is tested by the box around its geometry, not its whole 16 blocks.
|
||||
fn tight(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
impl Cull for Frustum {
|
||||
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
|
||||
@@ -11133,6 +11284,31 @@ impl Cull for Frusta {
|
||||
self.0.contains(min, max) || self.1.is_some_and(|f| f.contains(min, max))
|
||||
}
|
||||
}
|
||||
/// A shader pack's shadow camera's box, and with `casters` only what can shade the camera's view
|
||||
/// (`shadow_casters`): a box must meet all of them.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ShadowCull {
|
||||
pub light: Frustum,
|
||||
pub casters: Option<[Frustum; 2]>,
|
||||
}
|
||||
impl Cull for ShadowCull {
|
||||
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
|
||||
self.light.intersects(min, max)
|
||||
&& self
|
||||
.casters
|
||||
.is_none_or(|c| c.iter().all(|f| f.intersects(min, max)))
|
||||
}
|
||||
fn contains(&self, min: Vec3, max: Vec3) -> bool {
|
||||
self.light.contains(min, max)
|
||||
&& self
|
||||
.casters
|
||||
.is_none_or(|c| c.iter().all(|f| f.contains(min, max)))
|
||||
}
|
||||
/// Our probe's Iris 1.11.7 maps fit testing the casters' whole sections (a guess that matched).
|
||||
fn tight(&self) -> bool {
|
||||
self.casters.is_none()
|
||||
}
|
||||
}
|
||||
/// Camera-relative clip planes (`Frustum`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Frustum {
|
||||
@@ -11291,6 +11467,68 @@ fn facing_runs_from(
|
||||
None => sides.every(part, draw),
|
||||
}
|
||||
}
|
||||
/// The camera's view `view_proj` (wgpu depth, reversed or not) stretched without end toward
|
||||
/// `light` (a direction): the boxes whose shadow, cast away from the light, can fall into the
|
||||
/// view. Its planes are the view's faces turned toward the light, and through each edge between
|
||||
/// such a face and one turned away, the plane along the light; unused ones take in everything.
|
||||
fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
|
||||
let faces = frustum(view_proj);
|
||||
let toward = faces.map(|p| p.truncate().dot(light) >= 0.0);
|
||||
let inverse = view_proj.inverse();
|
||||
// Corner (x, y, z) of clip space's box; `frustum`'s faces at x -1 and 1 are 0 and 1, at
|
||||
// y -1 and 1 are 2 and 3, at depth 0 and 1 are 4 and 5.
|
||||
let corner = |c: [usize; 3]| {
|
||||
inverse.project_point3(Vec3::new(
|
||||
c[0] as f32 * 2.0 - 1.0,
|
||||
c[1] as f32 * 2.0 - 1.0,
|
||||
c[2] as f32,
|
||||
))
|
||||
};
|
||||
let corners: Vec<[usize; 3]> = (0..8).map(|i| [i & 1, (i >> 1) & 1, i >> 2]).collect();
|
||||
let centre = corners.iter().map(|&c| corner(c)).sum::<Vec3>() / 8.0;
|
||||
let mut planes = [Vec4::W; 12];
|
||||
let mut n = 0;
|
||||
for (k, &face) in faces.iter().enumerate() {
|
||||
if toward[k] {
|
||||
planes[n] = face;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for a in &corners {
|
||||
for axis in 0..3 {
|
||||
if a[axis] == 1 {
|
||||
continue;
|
||||
}
|
||||
let mut b = *a;
|
||||
b[axis] = 1;
|
||||
// The edge from a to b lies on the faces of the two other axes.
|
||||
let [f, g] = [(axis + 1) % 3, (axis + 2) % 3].map(|o| o * 2 + a[o]);
|
||||
if toward[f] == toward[g] {
|
||||
continue;
|
||||
}
|
||||
let (p, q) = (corner(*a), corner(b));
|
||||
let normal = (q - p).cross(light);
|
||||
if normal.length_squared() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let normal = normal.normalize();
|
||||
let side = if normal.dot(centre - p) < 0.0 {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
// At most six such edges with at most four faces toward the light: 12 planes.
|
||||
if let Some(plane) = planes.get_mut(n) {
|
||||
*plane = (normal * side).extend(-normal.dot(p) * side);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let [a, b] = [0, 6].map(|i| Frustum {
|
||||
planes: planes[i..i + 6].try_into().expect("six planes"),
|
||||
});
|
||||
[a, b]
|
||||
}
|
||||
fn frustum(m: Mat4) -> [Vec4; 6] {
|
||||
let r = |i| m.row(i);
|
||||
let planes = [
|
||||
@@ -11414,6 +11652,26 @@ mod tests {
|
||||
assert!(!offered(&intel, counts, compute, 8, true, None));
|
||||
assert!(offered(&dx12, counts, compute, 8, true, None));
|
||||
assert!(!offered(&dx12, counts, compute, 8, true, Some(usize::MAX)));
|
||||
// The settings offer it there, but not on a software adapter (WARP, lavapipe),
|
||||
// whose pass would run on the CPU, and they say why not on DX12 with flat page groups.
|
||||
use crate::graphics::GpuCullingOffer as O;
|
||||
let offer = super::gpu_culling::offer;
|
||||
assert_eq!(offer(&vulkan, true, true), O::Offered);
|
||||
assert_eq!(
|
||||
offer(&adapter(wgpu::DeviceType::IntegratedGpu), true, false),
|
||||
O::Offered
|
||||
);
|
||||
assert_eq!(
|
||||
offer(&adapter(wgpu::DeviceType::Cpu), true, false),
|
||||
O::Software
|
||||
);
|
||||
assert_eq!(offer(&vulkan, false, false), O::Unsupported);
|
||||
assert_eq!(
|
||||
offer(&adapter(wgpu::DeviceType::Cpu), false, false),
|
||||
O::Unsupported
|
||||
);
|
||||
assert_eq!(offer(&dx12, true, false), O::Offered);
|
||||
assert_eq!(offer(&dx12, false, true), O::Dx12);
|
||||
}
|
||||
|
||||
/// The pack-flat rule: with Translucent Batching translucent quads have an arena of
|
||||
@@ -11718,7 +11976,7 @@ mod tests {
|
||||
let mut batch = super::EntityBatch::default();
|
||||
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8), false);
|
||||
let vertices = &batch.combined;
|
||||
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0]);
|
||||
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0]);
|
||||
assert_eq!(
|
||||
vertices
|
||||
.as_chunks::<4>()
|
||||
@@ -12922,6 +13180,7 @@ mod shadow_face_tests {
|
||||
distance: None,
|
||||
unculled: None,
|
||||
every_face,
|
||||
view_casters: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12936,3 +13195,96 @@ mod shadow_face_tests {
|
||||
assert!(!drawn(&view(false), origin).contains(&3));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shadow_caster_tests {
|
||||
use super::*;
|
||||
|
||||
/// What the shadow pass keeps of a camera-relative section at `min`, the camera at the
|
||||
/// origin looking along -X (yaw 90) with the scenes' 70 degree, 16:9 view, and the light in
|
||||
/// direction `light`; the light's own box takes in everything.
|
||||
fn kept(light: Vec3, min: [f32; 3]) -> bool {
|
||||
kept_looking(Vec3::NEG_X, light, min)
|
||||
}
|
||||
|
||||
/// `kept` with the camera looking along `forward`.
|
||||
fn kept_looking(forward: Vec3, light: Vec3, min: [f32; 3]) -> bool {
|
||||
let camera = world_projection(70.0, 16.0 / 9.0, 192.0)
|
||||
* Mat4::look_to_rh(Vec3::ZERO, forward, Vec3::Y);
|
||||
let cull = ShadowCull {
|
||||
light: Frustum {
|
||||
planes: [Vec4::W; 6],
|
||||
},
|
||||
casters: Some(shadow_casters(camera, light)),
|
||||
};
|
||||
let min = Vec3::from(min);
|
||||
cull.intersects(min, min + 16.0)
|
||||
}
|
||||
|
||||
/// Our probes' Iris 1.11.7 shadow maps fit keeping only the terrain whose shadow can fall
|
||||
/// into the camera's view (#8464).
|
||||
#[test]
|
||||
fn the_shadow_pass_keeps_what_can_shade_the_view() {
|
||||
// A noon sun: a treetop high above the ground ahead shades it, though the camera doesn't
|
||||
// see it; behind the camera, or far off to the side, nothing does.
|
||||
assert!(kept(Vec3::Y, [-48.0, 64.0, -8.0]));
|
||||
assert!(!kept(Vec3::Y, [32.0, 0.0, -8.0]));
|
||||
assert!(!kept(Vec3::Y, [-32.0, 0.0, 96.0]));
|
||||
// Nor does what lies under the ground the camera looks along.
|
||||
assert!(!kept(Vec3::Y, [-48.0, -96.0, -8.0]));
|
||||
// A low sun ahead (west, -X) throws shadows toward the camera: what is behind it can't
|
||||
// reach the view.
|
||||
let west = Vec3::new(-1.0, 0.1, 0.0).normalize();
|
||||
assert!(kept(west, [-48.0, 0.0, -8.0]));
|
||||
assert!(!kept(west, [32.0, 0.0, -8.0]));
|
||||
// The same sun behind the camera throws the terrain behind it forward, into the view.
|
||||
let east = Vec3::new(1.0, 0.1, 0.0).normalize();
|
||||
assert!(kept(east, [32.0, 0.0, -8.0]));
|
||||
assert!(kept(east, [96.0, 0.0, -8.0]));
|
||||
// Looking 20 degrees down under a noon sun, the view's sides lean out below the camera:
|
||||
// a treetop above the ground ahead still shades it, one off to the side doesn't, though
|
||||
// it lies inside each of the view's planes that faces the light.
|
||||
let down = Vec3::new(-20f32.to_radians().cos(), -20f32.to_radians().sin(), 0.0);
|
||||
assert!(kept_looking(down, Vec3::Y, [-48.0, 16.0, -8.0]));
|
||||
assert!(!kept_looking(down, Vec3::Y, [-32.0, 16.0, -96.0]));
|
||||
}
|
||||
|
||||
/// With the camera looking straight up under a noon sun, our probe's Iris 1.11.7 map holds
|
||||
/// 7 columns there, which testing whole sections reproduces (a guess): the sections beside
|
||||
/// the camera whose 16 blocks reach above it are kept, even where their terrain lies below
|
||||
/// the camera.
|
||||
#[test]
|
||||
fn the_shadow_pass_tests_whole_sections() {
|
||||
let camera = DVec3::new(8.0, 6.0, 8.0);
|
||||
let up = world_projection(70.0, 16.0 / 9.0, 192.0)
|
||||
* Mat4::look_to_rh(Vec3::ZERO, Vec3::Y, Vec3::Z);
|
||||
let view = Frustum {
|
||||
planes: frustum(up),
|
||||
};
|
||||
// The section east of the camera's, y 0..16, with ground only in its lowest 2 blocks.
|
||||
let mut sections = resident::Resident::<()>::default();
|
||||
let key = (1, -crate::chunk::MIN_Y / 16, 0);
|
||||
sections.insert_with_bounds(key, (), Some([Vec3::ZERO, Vec3::new(16.0, 2.0, 16.0)]));
|
||||
let drawn = |cull: &dyn Fn(&mut Vec<resident::Visible>)| {
|
||||
let mut out = Vec::new();
|
||||
cull(&mut out);
|
||||
out.iter().map(|v| v.key).collect::<Vec<_>>()
|
||||
};
|
||||
let all = Frustum {
|
||||
planes: [Vec4::W; 6],
|
||||
};
|
||||
let shadow = ShadowCull {
|
||||
light: all,
|
||||
casters: Some(shadow_casters(up, Vec3::Y)),
|
||||
};
|
||||
assert_eq!(
|
||||
drawn(&|out| sections.visible(camera, 4, &shadow, |_| true, |_, _| false, out)),
|
||||
[key]
|
||||
);
|
||||
// The camera's own view goes by the terrain's box, which lies below it.
|
||||
assert!(
|
||||
drawn(&|out| sections.visible(camera, 4, &view, |_| true, |_, _| false, out))
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,8 @@ use super::Vertex;
|
||||
/// material at the end: the render types vanilla gives an `OitPipelineSet`
|
||||
/// (translucent, emissive, eyes and breeze wind entity models). Off, the tag is
|
||||
/// ignored and they stay in the first one, in their order.
|
||||
const MATERIALS: usize = 10;
|
||||
/// Two native world-canvas text materials follow it, NORMAL then POLYGON_OFFSET.
|
||||
const MATERIALS: usize = 12;
|
||||
/// A bit of `Vertex::layer`, above the overlay (low 16 bits) and the HDR tier
|
||||
/// (bits 16 to 23): the quad's render type has an `OitPipelineSet`
|
||||
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
|
||||
@@ -51,6 +52,10 @@ pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
|
||||
/// The overlay of text in `Font.DisplayMode.POLYGON_OFFSET` (text displays):
|
||||
/// drawn like an ordinary entity, pulled toward the camera.
|
||||
pub const TEXT_POLYGON_OFFSET_OVERLAY: u32 = 35;
|
||||
/// A native world canvas, drawn after particles with normal text depth/culling.
|
||||
pub const WORLD_TEXT_OVERLAY: u32 = 39;
|
||||
/// The front of its eight-offset outlined text, using POLYGON_OFFSET.
|
||||
pub const WORLD_TEXT_POLYGON_OFFSET_OVERLAY: u32 = 40;
|
||||
|
||||
/// The material of a quad by its overlay (`MATERIALS` order): the overlay is the layer's low 16
|
||||
/// bits, as entity.wgsl reads it; HDR output's tier rides in the high ones.
|
||||
@@ -59,6 +64,8 @@ fn material(layer: u32, oit: bool) -> usize {
|
||||
return 9;
|
||||
}
|
||||
match layer & 0xffff {
|
||||
WORLD_TEXT_OVERLAY => 10,
|
||||
WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 11,
|
||||
TEXT_POLYGON_OFFSET_OVERLAY => 1,
|
||||
11 => 2,
|
||||
12 => 3,
|
||||
@@ -118,7 +125,7 @@ impl EntityBatch {
|
||||
self.combined.clear();
|
||||
self.combined
|
||||
.reserve(entity_vertices + additive.len() + hand.len() + see_through.len());
|
||||
for runs in &self.runs {
|
||||
for runs in &self.runs[..10] {
|
||||
for &(first, count) in runs {
|
||||
self.combined
|
||||
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
|
||||
@@ -141,6 +148,27 @@ impl EntityBatch {
|
||||
}
|
||||
}
|
||||
}
|
||||
// RenderType.text sorts alpha-blended quads from far to near. Keep
|
||||
// both world-canvas layers after existing materials (including eyes
|
||||
// moved out of additive); front text uses polygon offset.
|
||||
for runs in &self.runs[10..] {
|
||||
let start = self.combined.len();
|
||||
for &(first, count) in runs {
|
||||
self.combined
|
||||
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
|
||||
}
|
||||
self.combined[start..]
|
||||
.as_chunks_mut::<4>()
|
||||
.0
|
||||
.sort_by(|a, b| {
|
||||
let distance = |q: &[Vertex; 4]| {
|
||||
(0..3)
|
||||
.map(|i| ((q[0].pos[i] + q[2].pos[i]) * 0.5).powi(2))
|
||||
.sum::<f32>()
|
||||
};
|
||||
distance(b).total_cmp(&distance(a))
|
||||
});
|
||||
}
|
||||
if self.moved_additive == 0 {
|
||||
self.combined.extend_from_slice(additive);
|
||||
} else {
|
||||
@@ -160,6 +188,41 @@ impl EntityBatch {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{hint::black_box, time::Instant};
|
||||
#[test]
|
||||
fn world_canvas_sorts_after_oit_eyes_before_frame_tails() {
|
||||
let quad = |layer, x| {
|
||||
[Vertex {
|
||||
layer,
|
||||
pos: [x, 0.0, 0.0],
|
||||
..geometry(1, false)[0]
|
||||
}; 4]
|
||||
};
|
||||
let entities: Vec<_> = [
|
||||
(0, 1.0),
|
||||
(35, 2.0),
|
||||
(OIT_ENTITY, 3.0),
|
||||
(WORLD_TEXT_OVERLAY, 4.0),
|
||||
(WORLD_TEXT_OVERLAY, 5.0),
|
||||
(WORLD_TEXT_POLYGON_OFFSET_OVERLAY, 6.0),
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, x)| quad(layer, x))
|
||||
.collect();
|
||||
let additive: Vec<_> = [(OIT_ENTITY | 2, 7.0), (2, 8.0)]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, x)| quad(layer, x))
|
||||
.collect();
|
||||
let mut batch = EntityBatch::default();
|
||||
assert_eq!(
|
||||
batch.update(&entities, &additive, &quad(0, 9.0), &quad(0, 10.0), true),
|
||||
[1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 1]
|
||||
);
|
||||
let positions: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(
|
||||
positions,
|
||||
[1.0, 2.0, 3.0, 7.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0]
|
||||
);
|
||||
}
|
||||
|
||||
// Retain the original implementation as the equivalence/benchmark oracle.
|
||||
fn baseline(
|
||||
@@ -257,12 +320,12 @@ mod tests {
|
||||
let mut batch = EntityBatch::default();
|
||||
// Off: the tag changes nothing, the quads keep their place.
|
||||
let counts = batch.update(&entities, &[], &[], &[], false);
|
||||
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0]);
|
||||
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0]);
|
||||
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(xs, [0.0, 1.0, 2.0, 4.0, 3.0]);
|
||||
// On: the tagged quads come last, in order.
|
||||
let counts = batch.update(&entities, &[], &[], &[], true);
|
||||
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2]);
|
||||
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0]);
|
||||
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(xs, [0.0, 2.0, 3.0, 1.0, 4.0]);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@
|
||||
//! walk's would and a thin shell more, alike on every backend; a frame whose
|
||||
//! sums could leave i32 is drawn by the CPU (`Prepared::Cpu`).
|
||||
//!
|
||||
//! Its statistics, the sections the pass kept and their draws (`counts[0]`,
|
||||
//! `counts[1]`), are copied after the pass into one of `READBACKS` small
|
||||
//! buffers with the CPU's part of the same frame (`copy_stats`), mapped once the
|
||||
//! frame is submitted (`submitted`, from the frame's after-submit hook) and
|
||||
//! taken in a frame or a few later (`collect`); nothing waits for them.
|
||||
//!
|
||||
//! A record also holds where its section's buckets of solid quads by the side
|
||||
//! they face end, and what `mesher::Sides::visible_from` compares the eye with
|
||||
//! for each, its margin in (`Sides::thresholds`); the tables hold the eye less
|
||||
@@ -70,9 +76,17 @@ use super::{
|
||||
submit::Queue,
|
||||
upload::Uploads,
|
||||
};
|
||||
use crate::mesher::Sides;
|
||||
use crate::{graphics::GpuCullingOffer, mesher::Sides};
|
||||
use glam::{DVec3, DVec4, Vec3};
|
||||
use std::{cmp::Reverse, collections::BinaryHeap, time::Instant};
|
||||
use std::{
|
||||
cmp::Reverse,
|
||||
collections::BinaryHeap,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU8, Ordering},
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
/// One section (`gpu_culling.wgsl`'s `Section`).
|
||||
#[repr(C)]
|
||||
@@ -265,6 +279,15 @@ struct Params {
|
||||
/// Counts before the pages' (the shader's `STATS`): the sections `cull` kept,
|
||||
/// and the draws, fewer only where a region was short.
|
||||
const STATS: u64 = 2;
|
||||
/// Statistics on their way back at once, a frame or a few late so the CPU never
|
||||
/// waits (as `cull`'s results).
|
||||
const READBACKS: usize = 3;
|
||||
const FREE: u8 = 0;
|
||||
const PENDING: u8 = 1;
|
||||
const MAPPED: u8 = 2;
|
||||
/// Its mapping failed or the device lost its buffer: never copied into or
|
||||
/// unmapped again, so the statistics stop (as `timing::GpuTimer`'s).
|
||||
const FAILED: u8 = 3;
|
||||
/// In `Params::flags` (the shader's `REACH`): cave culling's graph is in
|
||||
/// `reach`.
|
||||
const REACH: u32 = 1;
|
||||
@@ -475,10 +498,31 @@ pub(super) fn supported(
|
||||
// The pass binds 8, the last cave culling's graph.
|
||||
&& limits.max_storage_buffers_per_shader_stage >= 8
|
||||
&& !super::flat::is_broken_intel_vulkan(info)
|
||||
// DX12's count draws leave each draw's base vertex out of `vertex_index`, by which the
|
||||
// pulled solid pipelines (those of a device with flat page groups) read the page.
|
||||
// shortcut: not offered there, until GPU culling's draws carry their base vertex otherwise.
|
||||
&& !(info.backend == wgpu::Backend::Dx12 && flat_min_sections.is_some())
|
||||
&& !loses_base_vertex(info, flat_min_sections.is_some())
|
||||
}
|
||||
|
||||
/// DX12's count draws leave each draw's base vertex out of `vertex_index`, by which the
|
||||
/// pulled solid pipelines (those of a device with flat page groups) read the page.
|
||||
/// shortcut: not offered there, until GPU culling's draws carry their base vertex otherwise.
|
||||
pub(super) fn loses_base_vertex(info: &wgpu::AdapterInfo, flat_page_groups: bool) -> bool {
|
||||
info.backend == wgpu::Backend::Dx12 && flat_page_groups
|
||||
}
|
||||
|
||||
/// What the settings offer (`graphics::GpuCullingOffer`): where `supported`
|
||||
/// holds, but not on a software adapter (WARP, lavapipe), whose pass would run
|
||||
/// on the CPU beside the frame, as occlusion culling's default has it; where it
|
||||
/// doesn't, DX12 with flat page groups says why (`loses_base_vertex`).
|
||||
pub(super) fn offer(
|
||||
info: &wgpu::AdapterInfo,
|
||||
supported: bool,
|
||||
flat_page_groups: bool,
|
||||
) -> GpuCullingOffer {
|
||||
match (supported, super::is_software(info.device_type)) {
|
||||
(false, _) if loses_base_vertex(info, flat_page_groups) => GpuCullingOffer::Dx12,
|
||||
(false, _) => GpuCullingOffer::Unsupported,
|
||||
(true, true) => GpuCullingOffer::Software,
|
||||
(true, false) => GpuCullingOffer::Offered,
|
||||
}
|
||||
}
|
||||
|
||||
/// A buffer that is recreated larger when it must hold more.
|
||||
@@ -653,6 +697,7 @@ impl Records {
|
||||
self.fading = 0;
|
||||
}
|
||||
/// The sections with a record.
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
pub fn live(&self) -> usize {
|
||||
let free: u32 = self.free.iter().map(|word| word.count_ones()).sum();
|
||||
self.records.len() - free as usize
|
||||
@@ -730,6 +775,18 @@ impl Fades {
|
||||
}
|
||||
}
|
||||
|
||||
/// One GPU frame's statistics on their way back: `counts[..STATS]`, and what
|
||||
/// the CPU drew of the same frame.
|
||||
struct Readback {
|
||||
buffer: wgpu::Buffer,
|
||||
state: Arc<AtomicU8>,
|
||||
/// Its frame (gfx.rs `frame_serial`), and the CPU's part of its statistics:
|
||||
/// the sections it drew that the pass didn't count, its section draws, and
|
||||
/// every draw call.
|
||||
frame: u64,
|
||||
cpu: [usize; 3],
|
||||
}
|
||||
|
||||
/// The pass, its buffers and the records it draws from: made when GPU culling
|
||||
/// is switched on, dropped when it is switched off.
|
||||
pub(super) struct GpuCulling {
|
||||
@@ -769,6 +826,11 @@ pub(super) struct GpuCulling {
|
||||
/// This frame's updates and table words, kept for their room.
|
||||
scratch: Vec<Update>,
|
||||
words: Vec<u32>,
|
||||
/// The statistics on their way back (`Readback`), the next one to copy
|
||||
/// into, and this frame's copy.
|
||||
readbacks: Vec<Readback>,
|
||||
next: usize,
|
||||
current: Option<usize>,
|
||||
}
|
||||
|
||||
impl GpuCulling {
|
||||
@@ -828,6 +890,21 @@ impl GpuCulling {
|
||||
return None;
|
||||
}
|
||||
use wgpu::BufferUsages as U;
|
||||
let readbacks = (0..READBACKS)
|
||||
.map(|_| {
|
||||
Some(Readback {
|
||||
buffer: try_create(
|
||||
device,
|
||||
"GPU culling statistics",
|
||||
STATS * 4,
|
||||
U::MAP_READ | U::COPY_DST,
|
||||
)?,
|
||||
state: Arc::new(AtomicU8::new(FREE)),
|
||||
frame: 0,
|
||||
cpu: [0; 3],
|
||||
})
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some(Self {
|
||||
pipelines,
|
||||
layout,
|
||||
@@ -859,7 +936,8 @@ impl GpuCulling {
|
||||
device,
|
||||
"GPU culling counts",
|
||||
(STATS + 64) * 4,
|
||||
U::STORAGE | U::INDIRECT | U::COPY_DST | TEST_READ,
|
||||
// The counts are copied out for the statistics (`copy_stats`).
|
||||
U::STORAGE | U::INDIRECT | U::COPY_DST | U::COPY_SRC,
|
||||
)?,
|
||||
draws: Grown::new(
|
||||
device,
|
||||
@@ -891,6 +969,9 @@ impl GpuCulling {
|
||||
pass_pages: 1,
|
||||
scratch: Vec::new(),
|
||||
words: Vec::new(),
|
||||
readbacks,
|
||||
next: 0,
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1131,6 +1212,78 @@ impl GpuCulling {
|
||||
pub fn draw_calls(&self) -> usize {
|
||||
self.pages.len()
|
||||
}
|
||||
|
||||
/// After `dispatch`, in the same encoder: the pass's counts copied out with
|
||||
/// the CPU's part of frame `frame` (`Readback::cpu`), into the next readback
|
||||
/// if it is free. While three frames' are on their way, this one's
|
||||
/// statistics are never shown.
|
||||
pub fn copy_stats(&mut self, encoder: &mut wgpu::CommandEncoder, frame: u64, cpu: [usize; 3]) {
|
||||
let r = &mut self.readbacks[self.next];
|
||||
if r.state.load(Ordering::Acquire) != FREE {
|
||||
return;
|
||||
}
|
||||
encoder.copy_buffer_to_buffer(&self.counts.buffer, 0, &r.buffer, 0, STATS * 4);
|
||||
(r.frame, r.cpu) = (frame, cpu);
|
||||
self.current = Some(self.next);
|
||||
}
|
||||
|
||||
/// The frame's statistics to read back, started by calling it right after
|
||||
/// the frame's submission (gfx's after-submit hook, on the submit thread
|
||||
/// when the frame goes there), as `cull::Cull::submitted`.
|
||||
pub fn submitted(&mut self) -> Option<impl FnOnce() + Send + 'static> {
|
||||
let i = self.current.take()?;
|
||||
let r = &self.readbacks[i];
|
||||
let (buffer, state) = (r.buffer.clone(), r.state.clone());
|
||||
state.store(PENDING, Ordering::Release);
|
||||
self.next = (i + 1) % READBACKS;
|
||||
Some(move || {
|
||||
buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
state.store(
|
||||
if result.is_ok() { MAPPED } else { FAILED },
|
||||
Ordering::Release,
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/// The statistics that came back, their readbacks freed: the newest
|
||||
/// frame's, as (frame, [sections drawn, section draws, draw calls]), the
|
||||
/// pass's counts added to the CPU's part. Called as a frame starts, so a
|
||||
/// copy no submission took (a frame that failed on the way) is dropped.
|
||||
pub fn collect(&mut self, device: &wgpu::Device) -> Option<(u64, [usize; 3])> {
|
||||
self.current = None;
|
||||
if device.poll(wgpu::PollType::Poll).is_err() {
|
||||
return None;
|
||||
}
|
||||
let mut newest: Option<(u64, [usize; 3])> = None;
|
||||
for r in &self.readbacks {
|
||||
if r.state.load(Ordering::Acquire) != MAPPED {
|
||||
continue;
|
||||
}
|
||||
{
|
||||
let Ok(data) = r.buffer.slice(..).get_mapped_range() else {
|
||||
// Device loss can destroy a buffer after its mapping
|
||||
// succeeded; unmapping it would panic (`timing::GpuTimer`).
|
||||
r.state.store(FAILED, Ordering::Release);
|
||||
continue;
|
||||
};
|
||||
// The mapping need not be 4-byte aligned: read the words unaligned.
|
||||
let [kept, draws]: [u32; 2] = bytemuck::pod_read_unaligned(&data[..8]);
|
||||
let [sections, cpu_draws, calls] = r.cpu;
|
||||
if newest.is_none_or(|(frame, _)| r.frame > frame) {
|
||||
newest = Some((
|
||||
r.frame,
|
||||
[sections + kept as usize, cpu_draws + draws as usize, calls],
|
||||
));
|
||||
}
|
||||
}
|
||||
r.buffer.unmap();
|
||||
r.state.store(FREE, Ordering::Release);
|
||||
}
|
||||
newest
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2581,8 +2734,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
gpu.dispatch(&mut encoder, None);
|
||||
// Its statistics come back with a CPU part of the case's own.
|
||||
let cpu = [case * 7, case * 3, case];
|
||||
gpu.copy_stats(&mut encoder, case as u64, cpu);
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
gpu.submitted().expect("a readback free")();
|
||||
let pages = u64::from(gpu.pass_pages);
|
||||
let counts = read(device, queue, &gpu.counts.buffer, (STATS + pages) * 4);
|
||||
let draws = read(device, queue, &gpu.draws.buffer, gpu.draws.bytes);
|
||||
@@ -2619,6 +2776,13 @@ mod tests {
|
||||
[kept_sections, draw_count],
|
||||
"case {case}: sections kept, draws"
|
||||
);
|
||||
// The reads above waited for the GPU.
|
||||
let (kept, drawn) = (kept_sections as usize, draw_count as usize);
|
||||
assert_eq!(
|
||||
gpu.collect(device),
|
||||
Some((case as u64, [cpu[0] + kept, cpu[1] + drawn, cpu[2]])),
|
||||
"case {case}: statistics"
|
||||
);
|
||||
compared += kept_sections as usize;
|
||||
}
|
||||
// Not a comparison of empty lists, nor of a test that keeps everything.
|
||||
@@ -2645,6 +2809,73 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The statistics come back with their frame, in place and from the submit
|
||||
/// thread, submitted as gfx.rs submits frames (`submit_later`, or in place
|
||||
/// with its `after` handed back): three frames on their way fill the ring, a
|
||||
/// fourth's are skipped, `collect` takes in the newest; and a readback whose
|
||||
/// buffer the device lost after its mapping goes `FAILED`, never copied into
|
||||
/// or unmapped again (as `timing::GpuTimer`'s), so the statistics stop.
|
||||
#[test]
|
||||
fn the_statistics_come_back_with_their_frame() {
|
||||
use super::super::submit::{AfterSubmit, Job};
|
||||
on_each_device(|device, queue| {
|
||||
for threaded in [false, true] {
|
||||
let queue = Queue::new((**queue).clone(), threaded);
|
||||
let mut gpu = GpuCulling::new(device).expect("the pass");
|
||||
// As if the pass kept 5 sections and drew 7 times.
|
||||
queue.write_buffer(&gpu.counts.buffer, 0, bytemuck::cast_slice(&[5_u32, 7]));
|
||||
// Whether the frame's statistics went out.
|
||||
let frame = |gpu: &mut GpuCulling, f: u64| {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
gpu.copy_stats(&mut encoder, f, [f as usize, 10, 3]);
|
||||
let after = gpu.submitted().map(|g| Box::new(g) as AfterSubmit);
|
||||
let sent = after.is_some();
|
||||
let job = Job {
|
||||
encoder,
|
||||
surface: None,
|
||||
after,
|
||||
};
|
||||
if let Err(back) = queue.submit_later(job) {
|
||||
queue.submit([back.encoder.finish()]);
|
||||
if let Some(read) = back.after {
|
||||
read();
|
||||
}
|
||||
}
|
||||
sent
|
||||
};
|
||||
let landed = |gpu: &mut GpuCulling| {
|
||||
queue.landed();
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
gpu.collect(device)
|
||||
};
|
||||
for f in 1..=3 {
|
||||
assert!(frame(&mut gpu, f), "frame {f}, threaded {threaded}");
|
||||
}
|
||||
assert!(!frame(&mut gpu, 4), "a full ring, threaded {threaded}");
|
||||
assert_eq!(landed(&mut gpu), Some((3, [3 + 5, 10 + 7, 3])));
|
||||
assert_eq!(gpu.collect(device), None);
|
||||
assert!(frame(&mut gpu, 5));
|
||||
assert_eq!(landed(&mut gpu), Some((5, [5 + 5, 10 + 7, 3])));
|
||||
// Frame 6's buffer destroyed after its mapping (#6320).
|
||||
let lost = gpu.next;
|
||||
assert!(frame(&mut gpu, 6));
|
||||
queue.landed();
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
gpu.readbacks[lost].buffer.destroy();
|
||||
assert_eq!(gpu.collect(device), None);
|
||||
for f in 7..=12 {
|
||||
let sent = frame(&mut gpu, f);
|
||||
let back = landed(&mut gpu);
|
||||
let state = gpu.readbacks[lost].state.load(Ordering::Acquire);
|
||||
assert_eq!(state, FAILED, "frame {f}, threaded {threaded}");
|
||||
// The two other readbacks, then none.
|
||||
assert_eq!(sent, f <= 8, "frame {f}, threaded {threaded}");
|
||||
assert_eq!(back.is_some(), f <= 8, "frame {f}, threaded {threaded}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `draw`'s count draws draw what the pass kept, page by page: each kept
|
||||
/// section's quads from its own range, all of them without an eye and those
|
||||
/// of its runs that can face one with it, with its own origin, which the
|
||||
|
||||
@@ -367,6 +367,7 @@ impl NativeFrame {
|
||||
distance: None,
|
||||
unculled: None,
|
||||
every_face: false,
|
||||
view_casters: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -510,15 +511,24 @@ impl NativeFrame {
|
||||
|
||||
/// Writes this frame's inputs, for a world of `size`.
|
||||
pub fn write(&mut self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2]) {
|
||||
let seconds = self.start.elapsed().as_secs_f32() % 3600.0;
|
||||
// Under `HORIZON_STILL` the world's ticks are the clock (`PackInputs::still_ticks`), and
|
||||
// eased values sit at their targets.
|
||||
let still = frame.pack.still_ticks;
|
||||
let seconds = still.map_or_else(
|
||||
|| self.start.elapsed().as_secs_f32() % 3600.0,
|
||||
|ticks| (ticks as f32 / 20.0) % 3600.0,
|
||||
);
|
||||
let now = std::time::Instant::now();
|
||||
let since = self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32());
|
||||
let since = match still {
|
||||
Some(_) => f32::INFINITY,
|
||||
None => self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32()),
|
||||
};
|
||||
self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now));
|
||||
let mut inputs = frame_inputs(
|
||||
frame,
|
||||
size,
|
||||
seconds,
|
||||
self.frames,
|
||||
still.map_or(self.frames, |ticks| ticks as u32),
|
||||
self.shadow_camera.as_ref(),
|
||||
);
|
||||
inputs.wetness = self.wetness.0;
|
||||
@@ -551,7 +561,13 @@ impl NativeFrame {
|
||||
(f32::from(u8::from(inputs.eye_in == 1)), 0.25),
|
||||
(f32::from(u8::from(inputs.eye_in == 2)), 0.25),
|
||||
];
|
||||
let dt = inputs.delta_time;
|
||||
let dt = match still {
|
||||
Some(_) => {
|
||||
inputs.delta_time = 0.05;
|
||||
f32::INFINITY
|
||||
}
|
||||
None => inputs.delta_time,
|
||||
};
|
||||
for (value, (target, half_life)) in self.eye.iter_mut().zip(targets) {
|
||||
*value = eased(*value, target, half_life, dt);
|
||||
}
|
||||
|
||||
@@ -692,6 +692,25 @@ impl Occlusion {
|
||||
pub fn contains(&self, key: SectionKey) -> bool {
|
||||
self.graph.as_ref().is_none_or(|g| g.contains(key))
|
||||
}
|
||||
/// Whether the current graph knows that `key` is visible from
|
||||
/// `camera_section`. Unknown is returned when the graph is absent, was
|
||||
/// searched from another section, or does not cover the queried section.
|
||||
pub fn visible_for_camera(&self, key: SectionKey, camera_section: SectionKey) -> Option<bool> {
|
||||
let graph = self.graph.as_ref()?;
|
||||
if graph.from != camera_section {
|
||||
return None;
|
||||
}
|
||||
let dx = i64::from(key.0) - i64::from(graph.from.0);
|
||||
let dz = i64::from(key.2) - i64::from(graph.from.2);
|
||||
if dx.abs() > i64::from(graph.radius)
|
||||
|| dz.abs() > i64::from(graph.radius)
|
||||
|| key.1 < graph.y0
|
||||
|| key.1 > graph.sections
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(graph.contains(key))
|
||||
}
|
||||
/// Changes whenever `contains` may answer differently: a search landed, a
|
||||
/// partial update reached more sections, or the graph went.
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
@@ -904,6 +923,77 @@ mod tests {
|
||||
assert!(o.contains((2, 1, 0)));
|
||||
}
|
||||
|
||||
/// `visible_for_camera` answers only for the box of the graph it holds, and
|
||||
/// only for the camera that search started from. Everywhere else it says
|
||||
/// unknown, so the caller keeps the geometry.
|
||||
#[test]
|
||||
fn visible_for_camera_is_unknown_outside_its_box_and_its_camera() {
|
||||
// No graph yet: nothing to answer with.
|
||||
assert_eq!(
|
||||
Occlusion::new(0, 4).visible_for_camera((0, 1, 0), (0, 1, 0)),
|
||||
None
|
||||
);
|
||||
let mut o = Occlusion::new(0, 4);
|
||||
o.settle(at((0, 1, 0)), 2);
|
||||
let from = o.columns.section_of(at((0, 1, 0)));
|
||||
assert_eq!(from, (0, 1, 0), "the camera section searched from");
|
||||
// Inside the searched box the answer is `contains`, its edges too.
|
||||
for x in -2..=2 {
|
||||
for z in -2..=2 {
|
||||
for y in -1..=4 {
|
||||
assert_eq!(
|
||||
o.visible_for_camera((x, y, z), from),
|
||||
Some(o.contains((x, y, z))),
|
||||
"{x} {y} {z}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
o.visible_for_camera((2, 1, 0), from),
|
||||
Some(true),
|
||||
"the box's edge"
|
||||
);
|
||||
// Off the box's sides, below it, or above the level: unknown.
|
||||
for key in [
|
||||
(3, 1, 0),
|
||||
(-3, 1, 0),
|
||||
(0, 1, 3),
|
||||
(0, 1, -3),
|
||||
(0, -2, 0),
|
||||
(0, 5, 0),
|
||||
] {
|
||||
assert_eq!(o.visible_for_camera(key, from), None, "{key:?}");
|
||||
}
|
||||
// Another camera section: this graph is not about it.
|
||||
assert_eq!(o.visible_for_camera((0, 1, 0), (1, 1, 0)), None);
|
||||
}
|
||||
|
||||
/// The answer follows the graph: a closed wall hides the section behind it,
|
||||
/// and opening the wall shows it again.
|
||||
#[test]
|
||||
fn visible_for_camera_reports_what_the_graph_hides() {
|
||||
let mut o = Occlusion::new(0, 4);
|
||||
for (dx, dy, dz) in STEPS {
|
||||
o.set((dx, 1 + dy, dz), 0);
|
||||
}
|
||||
o.settle(at((0, 1, 0)), 3);
|
||||
let from = (0, 1, 0);
|
||||
assert_eq!(
|
||||
o.visible_for_camera((1, 1, 0), from),
|
||||
Some(true),
|
||||
"the wall itself"
|
||||
);
|
||||
assert_eq!(
|
||||
o.visible_for_camera((2, 1, 0), from),
|
||||
Some(false),
|
||||
"hidden behind it"
|
||||
);
|
||||
o.set((1, 1, 0), ALL_VISIBLE);
|
||||
o.settle(at((0, 1, 0)), 3);
|
||||
assert_eq!(o.visible_for_camera((2, 1, 0), from), Some(true));
|
||||
}
|
||||
|
||||
/// Faces `a` and `b` of a section see each other, nothing else does.
|
||||
fn connects(a: usize, b: usize) -> u64 {
|
||||
1 << (a * 6 + b) | 1 << (b * 6 + a)
|
||||
|
||||
@@ -519,6 +519,36 @@ const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
|
||||
}\n \
|
||||
}\n";
|
||||
|
||||
/// `terrain` (`PackAtlas::terrain_inputs`) drawing only solid terrain, then only cutout terrain:
|
||||
/// each leaves the other part's quads outside the clip volume. Solid: the quads over opaque
|
||||
/// texels and opaque leaves (`mesher::OPAQUE_LAYER`), as vanilla picks `ChunkSectionLayer.SOLID`
|
||||
/// per quad (`FaceBakery.computeMaterialTransparency`, `SectionCompiler.compile`), and lava's
|
||||
/// faces (sprite layers `lava`, its still and flowing sprites), which Iris 1.11.7 drew with
|
||||
/// `gbuffers_terrain_solid` in our probes, with and without `separateAo`.
|
||||
///
|
||||
/// The test reads only the vertex's sprite layer, which all four vertices of a quad share: a
|
||||
/// quad lands whole in one part. The colour's alpha can't decide it, as with `separateAo` it
|
||||
/// holds each vertex's own ambient occlusion.
|
||||
pub fn terrain_parts(
|
||||
terrain: &horizon_shaders::GbuffersInputs,
|
||||
lava: &[u16],
|
||||
) -> [horizon_shaders::GbuffersInputs; 2] {
|
||||
let opaque = crate::mesher::OPAQUE_LAYER;
|
||||
let lava: String = lava
|
||||
.iter()
|
||||
.map(|l| format!(" || hz_v_layer == {l}u"))
|
||||
.collect();
|
||||
let part = |solid: bool| {
|
||||
let mut inputs = terrain.clone();
|
||||
inputs.after.push_str(&format!(
|
||||
" bool hz_solid = (hz_v_layer & {opaque}u) != 0u{lava};\n \
|
||||
if (hz_solid != {solid}) gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n"
|
||||
));
|
||||
inputs
|
||||
};
|
||||
[part(true), part(false)]
|
||||
}
|
||||
|
||||
impl PackAtlas {
|
||||
/// The vertex inputs of a gbuffers program drawing terrain (`TerrainVertex`, terrain.wgsl's
|
||||
/// `vs_terrain` or, for `flat` draws, `vs_terrain_flat`), in atlas coordinates.
|
||||
|
||||
@@ -43,6 +43,9 @@ mod shadow_faces_tests;
|
||||
#[cfg(test)]
|
||||
mod forced_flip_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod weather_depth_tests;
|
||||
|
||||
/// The texture behind a pack sampler name.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Source {
|
||||
@@ -516,8 +519,14 @@ pub struct PackPost {
|
||||
passes: Vec<Pass>,
|
||||
/// Shadow cache for temporal reprojection.
|
||||
shadow_cache: ShadowCache,
|
||||
/// What draws terrain (`gbuffers_terrain` or its fallback), between `prepare` and `deferred`.
|
||||
/// What draws terrain (`gbuffers_terrain` or its fallback), between `prepare` and `deferred`;
|
||||
/// when the pack draws terrain in two parts, solid terrain only (`gbuffers_terrain_solid` or
|
||||
/// its fallback).
|
||||
terrain: Option<Pass>,
|
||||
/// What draws cutout terrain (`gbuffers_terrain_cutout` or its fallback), after `terrain`,
|
||||
/// when the pack draws terrain in two parts. A pack with no program for one part has only
|
||||
/// the other.
|
||||
terrain_cutout: Option<Pass>,
|
||||
/// What draws translucent terrain (`gbuffers_water` or its fallback), after `deferred`.
|
||||
water: Option<Pass>,
|
||||
/// What draws the sky (`gbuffers_skybasic` or its fallback), before terrain, and its vertices.
|
||||
@@ -1079,6 +1088,11 @@ pub(super) struct ShadowView {
|
||||
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
|
||||
/// a cave's ceiling, which faces away, is often all that shadows the cave.
|
||||
pub every_face: bool,
|
||||
/// Only the sections whose shadow can fall into the camera's view: those the camera's
|
||||
/// frustum, stretched toward the light, takes in (`shadow_casters`). Our probes' Iris 1.11.7
|
||||
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
|
||||
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
|
||||
pub view_casters: bool,
|
||||
}
|
||||
|
||||
/// Set 3 of flat terrain, read by the vertex stage: the sprite layers' table (`mc_Entity`'s id
|
||||
@@ -2163,10 +2177,15 @@ impl PackPost {
|
||||
ids: block_ids.as_ref().map(|i| &i.0),
|
||||
cull: g.cull,
|
||||
});
|
||||
let terrain = post
|
||||
let split = post.terrain.is_some() && post.terrain_cutout.is_some();
|
||||
let mut terrain = post
|
||||
.terrain
|
||||
.zip(terrain_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)));
|
||||
let mut terrain_cutout = post
|
||||
.terrain_cutout
|
||||
.zip(terrain_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)));
|
||||
let water = post
|
||||
.water
|
||||
.zip(terrain_geometry.as_ref())
|
||||
@@ -2295,7 +2314,7 @@ impl PackPost {
|
||||
let weather_geometry = world.weather.then(|| Geometry {
|
||||
buffers: &entity_buffers,
|
||||
table: None,
|
||||
depth_write: true,
|
||||
depth_write: post.rain_depth,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
@@ -2371,8 +2390,31 @@ impl PackPost {
|
||||
.filter_map(|p| build(p, None))
|
||||
.collect();
|
||||
let stand_ins = pack_images::stand_ins(device, stand_ins.take());
|
||||
if let Some(t) = &terrain {
|
||||
notes.push(format!("terrain draws with {}", t.name));
|
||||
// Terrain in two parts draws both or neither: one alone would leave the other part's
|
||||
// terrain undrawn. Without either, the game draws all terrain, as when the terrain
|
||||
// program of a pack that doesn't split can't draw.
|
||||
if split
|
||||
&& terrain.is_some() != terrain_cutout.is_some()
|
||||
&& let Some(p) = terrain.take().or(terrain_cutout.take())
|
||||
{
|
||||
notes.push(format!(
|
||||
"{}: the other part of terrain can't draw; the game draws all terrain",
|
||||
p.name
|
||||
));
|
||||
}
|
||||
match (&terrain, &mut terrain_cutout) {
|
||||
(Some(t), None) => notes.push(format!("terrain draws with {}", t.name)),
|
||||
(t, Some(c)) => {
|
||||
if let Some(t) = t {
|
||||
notes.push(format!("solid terrain draws with {}", t.name));
|
||||
}
|
||||
notes.push(format!("cutout terrain draws with {}", c.name));
|
||||
// Timed (and labelled) apart from the solid part when it runs the same program.
|
||||
if t.as_ref().is_some_and(|t| t.name == c.name) {
|
||||
c.name.push_str(" (cutout)");
|
||||
}
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
for (part, pass) in [
|
||||
("terrain", &shadow_terrain),
|
||||
@@ -2456,6 +2498,7 @@ impl PackPost {
|
||||
.iter()
|
||||
.chain(&shadow_comp)
|
||||
.chain(&terrain)
|
||||
.chain(&terrain_cutout)
|
||||
.chain(&water))
|
||||
.chain(shadow_terrain.iter().chain(&shadow_water))
|
||||
.chain(sky.iter().flat_map(|s| s.basic.iter().chain(&s.textured)))
|
||||
@@ -2513,6 +2556,7 @@ impl PackPost {
|
||||
.iter()
|
||||
.flat_map(|s| s.basic.iter().chain(&s.textured))
|
||||
.chain(&terrain)
|
||||
.chain(&terrain_cutout)
|
||||
.chain(entities.iter().flat_map(|e| e.passes.iter().flatten()))
|
||||
.chain(&clouds)
|
||||
.chain(&lines)
|
||||
@@ -2528,6 +2572,7 @@ impl PackPost {
|
||||
}
|
||||
// Terrain tests against the world's depth (entities...), so the blit copies it then.
|
||||
let blit_depth = terrain.is_some()
|
||||
|| terrain_cutout.is_some()
|
||||
|| water.is_some()
|
||||
|| sky.is_some()
|
||||
|| entities.as_ref().is_some_and(Entities::drawn)
|
||||
@@ -2665,6 +2710,7 @@ impl PackPost {
|
||||
setup_pending: true,
|
||||
passes,
|
||||
terrain,
|
||||
terrain_cutout,
|
||||
water,
|
||||
sky,
|
||||
entities,
|
||||
@@ -3153,7 +3199,8 @@ impl PackPost {
|
||||
}
|
||||
|
||||
/// Terrain (or, `translucent`, translucent terrain) with the pack's program; `draw` issues
|
||||
/// the game's draws.
|
||||
/// the game's draws. Solid terrain in two parts has its cutout part drawn second, from the
|
||||
/// same draws.
|
||||
fn draw_terrain(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
@@ -3163,13 +3210,35 @@ impl PackPost {
|
||||
list: TerrainList,
|
||||
draw: &mut TerrainDraw<'_>,
|
||||
) {
|
||||
let slot = match list {
|
||||
self.draw_terrain_part(device, queue, encoder, size, list, false, draw);
|
||||
if list == TerrainList::Solid {
|
||||
self.draw_terrain_part(device, queue, encoder, size, list, true, draw);
|
||||
}
|
||||
}
|
||||
|
||||
/// The pass that draws `list`, or the cutout part of the solid list.
|
||||
fn terrain_slot(&mut self, list: TerrainList, cutout: bool) -> &mut Option<Pass> {
|
||||
match list {
|
||||
TerrainList::Solid if cutout => &mut self.terrain_cutout,
|
||||
TerrainList::Solid => &mut self.terrain,
|
||||
TerrainList::Translucent => &mut self.water,
|
||||
TerrainList::ShadowSolid => &mut self.shadow_terrain,
|
||||
TerrainList::ShadowTranslucent => &mut self.shadow_water,
|
||||
};
|
||||
let Some(mut pass) = slot.take() else {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_terrain_part(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
size: [u32; 2],
|
||||
list: TerrainList,
|
||||
cutout: bool,
|
||||
draw: &mut TerrainDraw<'_>,
|
||||
) {
|
||||
let Some(mut pass) = self.terrain_slot(list, cutout).take() else {
|
||||
return;
|
||||
};
|
||||
let translucent = list.translucent();
|
||||
@@ -3207,12 +3276,7 @@ impl PackPost {
|
||||
if !pass.group.shadow() {
|
||||
self.mips_valid &= !pass.written;
|
||||
}
|
||||
*match list {
|
||||
TerrainList::Solid => &mut self.terrain,
|
||||
TerrainList::Translucent => &mut self.water,
|
||||
TerrainList::ShadowSolid => &mut self.shadow_terrain,
|
||||
TerrainList::ShadowTranslucent => &mut self.shadow_water,
|
||||
} = Some(pass);
|
||||
*self.terrain_slot(list, cutout) = Some(pass);
|
||||
}
|
||||
|
||||
/// The sky's parts in vanilla's order, each with the program that draws it; one render pass
|
||||
@@ -3846,12 +3910,13 @@ impl PackPost {
|
||||
horizon_shaders::ShadowCulling::Reversed => Some(shadow.voxel_distance),
|
||||
},
|
||||
every_face: true,
|
||||
view_casters: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the pack draws terrain itself.
|
||||
pub fn has_terrain(&self) -> bool {
|
||||
self.terrain.is_some()
|
||||
self.terrain.is_some() || self.terrain_cutout.is_some()
|
||||
}
|
||||
|
||||
/// Whether the pack's shadow pass draws terrain (`shadow_view`).
|
||||
@@ -3902,6 +3967,7 @@ impl PackPost {
|
||||
.passes
|
||||
.iter_mut()
|
||||
.chain(&mut self.terrain)
|
||||
.chain(&mut self.terrain_cutout)
|
||||
.chain(&mut self.water)
|
||||
.chain(sky)
|
||||
.chain(entities)
|
||||
@@ -4561,6 +4627,7 @@ impl PackPost {
|
||||
let mut shown = false;
|
||||
// A water-only pack still needs the opaque phase to populate requested snapshots.
|
||||
let mut terrain_drawn = self.terrain.is_none()
|
||||
&& self.terrain_cutout.is_none()
|
||||
&& self.sky.is_none()
|
||||
&& !self.entities.as_ref().is_some_and(Entities::drawn)
|
||||
&& self.clouds.is_none()
|
||||
@@ -4735,6 +4802,10 @@ mod snorm_tests;
|
||||
#[path = "pack_post/depth_test.rs"]
|
||||
mod depth_test;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pack_post/terrain_parts_tests.rs"]
|
||||
mod terrain_parts_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -3,15 +3,15 @@ use super::*;
|
||||
use crate::{gfx::Frame, hud::HudBatch};
|
||||
use wgpu::{TextureFormat as T, util::DeviceExt};
|
||||
|
||||
const SIZE: [u32; 2] = [16, 16];
|
||||
pub(super) const SIZE: [u32; 2] = [16, 16];
|
||||
|
||||
struct Gpu {
|
||||
pub(super) struct Gpu {
|
||||
adapter: wgpu::Adapter,
|
||||
device: wgpu::Device,
|
||||
pub(super) device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
}
|
||||
impl Gpu {
|
||||
fn new() -> Option<Self> {
|
||||
pub(super) fn new() -> Option<Self> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
@@ -27,7 +27,7 @@ impl Gpu {
|
||||
queue,
|
||||
})
|
||||
}
|
||||
fn texture(&self, format: T) -> wgpu::TextureView {
|
||||
pub(super) fn texture(&self, format: T) -> wgpu::TextureView {
|
||||
self.device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Original depth fixture input"),
|
||||
@@ -47,7 +47,7 @@ impl Gpu {
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
fn load(
|
||||
pub(super) fn load(
|
||||
&self,
|
||||
files: Vec<(String, String)>,
|
||||
world: World<'_>,
|
||||
@@ -122,7 +122,7 @@ impl Gpu {
|
||||
});
|
||||
self.queue.submit([encoder.finish()]);
|
||||
}
|
||||
fn render(
|
||||
pub(super) fn render(
|
||||
&self,
|
||||
post: &mut PackPost,
|
||||
color: &wgpu::TextureView,
|
||||
@@ -213,7 +213,7 @@ enum Scene {
|
||||
WaterOnly,
|
||||
}
|
||||
|
||||
const VERTEX: &str =
|
||||
pub(super) const VERTEX: &str =
|
||||
"#version 120\nvarying vec2 uv;void main(){uv=gl_MultiTexCoord0.xy;gl_Position=ftransform();}";
|
||||
|
||||
fn shaders(sampler: &str, stage: Stage, scene: Scene) -> Vec<(String, String)> {
|
||||
@@ -561,7 +561,7 @@ fn attached_compute_preserves_distinct_hand_snapshots() {
|
||||
}
|
||||
|
||||
// Frame literal adapted from MIT Horizon f700231 pack_uniforms.rs test API setup.
|
||||
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
pub(super) fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
Frame {
|
||||
eye: glam::DVec3::ZERO,
|
||||
eye_rotation: [0.0; 2],
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Solid and cutout terrain through `gbuffers_terrain_solid` and `gbuffers_terrain_cutout`
|
||||
//! (#9137). The expected programs are what Iris 1.11.7 drew with our probe packs (the PR has
|
||||
//! the numbers): with either program the pack draws solid terrain (quads over opaque texels, and
|
||||
//! lava) through `gbuffers_terrain_solid` and cutout terrain through `gbuffers_terrain_cutout`,
|
||||
//! the missing one falling back to `gbuffers_terrain`; solid terrain keeps a fragment written
|
||||
//! with alpha 0.05, cutout terrain loses it. Each quad draws whole with one of them, whatever
|
||||
//! its vertices' alphas (with `separateAo`, their own ambient occlusion). The programs here are
|
||||
//! the probes', cut down.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
use wgpu::{TextureFormat as T, util::DeviceExt};
|
||||
|
||||
const RED: [u8; 3] = [255, 0, 0];
|
||||
const GREEN: [u8; 3] = [0, 255, 0];
|
||||
const BLUE: [u8; 3] = [0, 0, 255];
|
||||
|
||||
/// A program writing `rgb` with `alpha` into colortex0.
|
||||
fn program(name: &str, rgb: [u8; 3], alpha: f32) -> [(String, String); 2] {
|
||||
let [r, g, b] = rgb.map(|c| f32::from(c) / 255.0);
|
||||
[
|
||||
(
|
||||
format!("{name}.vsh"),
|
||||
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}".into(),
|
||||
),
|
||||
(
|
||||
format!("{name}.fsh"),
|
||||
format!(
|
||||
"#version 120\n/* DRAWBUFFERS:0 */\nvoid main(){{gl_FragData[0]=vec4({r:?},{g:?},{b:?},{alpha:?});}}"
|
||||
),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Lava's sprite layer in the fixture.
|
||||
const LAVA: u16 = 5;
|
||||
|
||||
/// The pack (`final` shows colortex0, cleared to black) drawn over three strips of terrain, left
|
||||
/// to right: a quad over opaque texels, a cutout quad, a lava quad, all with opaque vertex
|
||||
/// colours. Each strip's colour, `None` where nothing drew; `None` overall without a GPU.
|
||||
fn strips(programs: &[(&str, [u8; 3], f32)]) -> Option<[Option<[u8; 3]>; 3]> {
|
||||
strips_with_alphas(programs, [255; 4])
|
||||
}
|
||||
|
||||
/// `strips` with the quads' corners' vertex alphas (bottom left, bottom right, top right, top
|
||||
/// left). Fails unless each strip is one colour.
|
||||
fn strips_with_alphas(
|
||||
programs: &[(&str, [u8; 3], f32)],
|
||||
alphas: [u8; 4],
|
||||
) -> Option<[Option<[u8; 3]>; 3]> {
|
||||
let files = programs
|
||||
.iter()
|
||||
.flat_map(|&(name, rgb, alpha)| program(name, rgb, alpha))
|
||||
.collect();
|
||||
let (strips, _) = strips_of(files, alphas)?;
|
||||
Some(strips)
|
||||
}
|
||||
|
||||
/// `strips_with_alphas` with the pack's terrain programs' `files`, and the built pack's notes
|
||||
/// when it draws no terrain itself (`None` when it does).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn strips_of(
|
||||
programs: Vec<(String, String)>,
|
||||
alphas: [u8; 4],
|
||||
) -> Option<([Option<[u8; 3]>; 3], Option<Vec<String>>)> {
|
||||
let Some(gpu) = Gpu::new() else {
|
||||
eprintln!("TERRAIN_PARTS_NO_ADAPTER");
|
||||
return None;
|
||||
};
|
||||
let mut files = vec![
|
||||
("final.vsh".to_owned(), VERTEX.to_owned()),
|
||||
(
|
||||
"final.fsh".to_owned(),
|
||||
"#version 120\nuniform sampler2D colortex0;varying vec2 uv;\
|
||||
const vec4 colortex0ClearColor = vec4(0.0, 0.0, 0.0, 1.0);\
|
||||
void main(){gl_FragColor=vec4(texture2D(colortex0,uv).rgb,1.0);}"
|
||||
.to_owned(),
|
||||
),
|
||||
];
|
||||
files.extend(programs);
|
||||
// The game's terrain inputs as far as the parts read them: the sprite layer and the colour.
|
||||
let terrain = horizon_shaders::GbuffersInputs {
|
||||
declarations: "layout(location=0)in vec3 fixture_position;\n\
|
||||
layout(location=2)in uint hz_v_layer;\n\
|
||||
layout(location=3)in vec4 hz_v_color;\n"
|
||||
.into(),
|
||||
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);\
|
||||
hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
|
||||
.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain_parts: Some(super::super::pack_atlas::terrain_parts(
|
||||
&terrain,
|
||||
&[LAVA, LAVA + 1],
|
||||
)),
|
||||
terrain: Some(terrain),
|
||||
..Default::default()
|
||||
};
|
||||
const ATTRS: [wgpu::VertexAttribute; 3] =
|
||||
wgpu::vertex_attr_array![0=>Float32x3,2=>Uint32,3=>Unorm8x4];
|
||||
let layouts = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 20,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &ATTRS,
|
||||
})];
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: Some(Geometry {
|
||||
buffers: &layouts,
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
}),
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
inputs,
|
||||
);
|
||||
let opaque = u32::from(crate::mesher::OPAQUE_LAYER);
|
||||
let mut vertices: Vec<u32> = Vec::new();
|
||||
for (i, layer) in [opaque | 1, 2, u32::from(LAVA)].into_iter().enumerate() {
|
||||
let (x0, x1) = (
|
||||
-1.0 + i as f32 * 2.0 / 3.0,
|
||||
-1.0 + (i + 1) as f32 * 2.0 / 3.0,
|
||||
);
|
||||
let corners = [[x0, -1.0], [x1, -1.0], [x1, 1.0], [x0, 1.0]];
|
||||
for corner in [0, 1, 2, 0, 2, 3] {
|
||||
let [x, y] = corners[corner];
|
||||
let color = u32::from(alphas[corner]) << 24 | 0x00FF_FFFF;
|
||||
vertices.extend([x.to_bits(), f32::to_bits(y), 0f32.to_bits(), layer, color]);
|
||||
}
|
||||
}
|
||||
let buffer = gpu
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Terrain parts fixture"),
|
||||
contents: bytemuck::cast_slice(&vertices),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let undrawn = (!post.has_terrain()).then(|| post.notes.clone());
|
||||
let hud = HudBatch::new(SIZE);
|
||||
let frame = fixture_frame(&hud);
|
||||
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |rp, list, _, _| {
|
||||
if list == TerrainList::Solid {
|
||||
rp.set_vertex_buffer(0, buffer.slice(..));
|
||||
rp.draw(0..18, 0..1);
|
||||
}
|
||||
});
|
||||
// Every pixel of each strip (columns 0-4, 6-9 and 11-15; 5 and 10 straddle two strips).
|
||||
let colour = |x: usize, y: usize| {
|
||||
let [r, g, b, _] = pixels[y * SIZE[0] as usize + x];
|
||||
Some([r, g, b]).filter(|&c| c != [0, 0, 0])
|
||||
};
|
||||
let strips = [0..5, 6..10, 11..16].map(|columns| {
|
||||
let first = colour(columns.start, 0);
|
||||
for x in columns.clone() {
|
||||
for y in 0..SIZE[1] as usize {
|
||||
assert_eq!(colour(x, y), first, "strip {columns:?} at ({x}, {y})");
|
||||
}
|
||||
}
|
||||
first
|
||||
});
|
||||
Some((strips, undrawn))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_and_cutout_programs_draw_their_own_terrain() {
|
||||
let programs = [
|
||||
("gbuffers_terrain", RED, 1.0),
|
||||
("gbuffers_terrain_solid", GREEN, 1.0),
|
||||
("gbuffers_terrain_cutout", BLUE, 1.0),
|
||||
];
|
||||
let Some(got) = strips(&programs) else { return };
|
||||
assert_eq!(got, [Some(GREEN), Some(BLUE), Some(GREEN)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_cutout_program_cutout_terrain_falls_back_to_gbuffers_terrain() {
|
||||
let programs = [
|
||||
("gbuffers_terrain", RED, 1.0),
|
||||
("gbuffers_terrain_solid", GREEN, 1.0),
|
||||
];
|
||||
let Some(got) = strips(&programs) else { return };
|
||||
assert_eq!(got, [Some(GREEN), Some(RED), Some(GREEN)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_solid_program_solid_terrain_falls_back_to_gbuffers_terrain() {
|
||||
let programs = [
|
||||
("gbuffers_terrain", RED, 1.0),
|
||||
("gbuffers_terrain_cutout", BLUE, 1.0),
|
||||
];
|
||||
let Some(got) = strips(&programs) else { return };
|
||||
assert_eq!(got, [Some(RED), Some(BLUE), Some(RED)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_terrain_keeps_alpha_0_05_and_cutout_loses_it() {
|
||||
let programs = [
|
||||
("gbuffers_terrain", RED, 1.0),
|
||||
("gbuffers_terrain_solid", GREEN, 0.05),
|
||||
("gbuffers_terrain_cutout", BLUE, 0.05),
|
||||
];
|
||||
let Some(got) = strips(&programs) else { return };
|
||||
assert_eq!(got, [Some(GREEN), None, Some(GREEN)]);
|
||||
// The solid part through its fallback, as the probe's `gbuffers_terrain` writing 0.05.
|
||||
let programs = [
|
||||
("gbuffers_terrain", RED, 0.05),
|
||||
("gbuffers_terrain_cutout", BLUE, 1.0),
|
||||
];
|
||||
let Some(got) = strips(&programs) else { return };
|
||||
assert_eq!(got, [Some(RED), Some(BLUE), Some(RED)]);
|
||||
}
|
||||
|
||||
/// With `separateAo` a vertex's alpha is its own ambient occlusion, so the corners of one quad
|
||||
/// differ; the quad still draws whole, with its part's program.
|
||||
#[test]
|
||||
fn a_quad_draws_whole_whatever_its_vertex_alphas() {
|
||||
let programs = [
|
||||
("gbuffers_terrain", RED, 1.0),
|
||||
("gbuffers_terrain_solid", GREEN, 1.0),
|
||||
("gbuffers_terrain_cutout", BLUE, 1.0),
|
||||
];
|
||||
for alphas in [[255, 160, 255, 160], [128, 255, 200, 64], [160; 4]] {
|
||||
let Some(got) = strips_with_alphas(&programs, alphas) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(got, [Some(GREEN), Some(BLUE), Some(GREEN)], "{alphas:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_with_neither_draws_all_terrain_with_gbuffers_terrain() {
|
||||
let Some(got) = strips(&[("gbuffers_terrain", RED, 1.0)]) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(got, [Some(RED); 3]);
|
||||
}
|
||||
|
||||
/// A pack whose only terrain program is `gbuffers_terrain_solid` (or `_cutout`) draws that part,
|
||||
/// and nothing draws the other: Iris 1.11.7 left it undrawn with our probes `tsc-solidonly` and
|
||||
/// `tsc-cutoutonly`.
|
||||
#[test]
|
||||
fn a_lone_split_program_draws_only_its_own_terrain() {
|
||||
let solid = strips(&[("gbuffers_terrain_solid", GREEN, 1.0)]);
|
||||
let Some(solid) = solid else { return };
|
||||
assert_eq!(solid, [Some(GREEN), None, Some(GREEN)]);
|
||||
let cutout = strips(&[("gbuffers_terrain_cutout", BLUE, 1.0)]);
|
||||
assert_eq!(cutout, Some([None, Some(BLUE), None]));
|
||||
}
|
||||
|
||||
/// When one part's pipeline can't be built (here a cutout program writing no buffer, which
|
||||
/// Horizon skips), the other part doesn't draw alone either: the game draws all terrain.
|
||||
#[test]
|
||||
fn a_part_that_fails_to_build_takes_the_other_with_it() {
|
||||
let mut files: Vec<(String, String)> = [
|
||||
program("gbuffers_terrain", RED, 1.0),
|
||||
program("gbuffers_terrain_solid", GREEN, 1.0),
|
||||
]
|
||||
.concat();
|
||||
files.push((
|
||||
"gbuffers_terrain_cutout.vsh".into(),
|
||||
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}".into(),
|
||||
));
|
||||
files.push((
|
||||
"gbuffers_terrain_cutout.fsh".into(),
|
||||
"#version 120\nvoid main(){}".into(),
|
||||
));
|
||||
let Some((got, undrawn)) = strips_of(files, [255; 4]) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(got, [None; 3]);
|
||||
let notes = undrawn.expect("the game draws terrain");
|
||||
for note in [
|
||||
"gbuffers_terrain_cutout: writes no buffer; skipped",
|
||||
"gbuffers_terrain_solid: the other part of terrain can't draw",
|
||||
] {
|
||||
assert!(notes.iter().any(|n| n.starts_with(note)), "{notes:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Original synthetic weather oracle: no pack assets or external shader algorithms.
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; missing adapter is a failure"]
|
||||
fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Weather depth GPU: {:?}", adapter.get_info());
|
||||
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: adapter.limits(),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let mut depth_failures = Vec::new();
|
||||
for program in ["gbuffers_weather", "gbuffers_textured_lit"] {
|
||||
for (property, writes) in [
|
||||
("rain.depth=true", true),
|
||||
("rain.depth=false", false),
|
||||
// An evidence-limited compatibility choice: preserve accepted main here.
|
||||
// The permitted packs do not establish the external omitted-value default.
|
||||
("", true),
|
||||
] {
|
||||
let mut post = pack(&device, &queue, &adapter, program, property);
|
||||
// Retain the pipelines for another frame, then resize their targets.
|
||||
for size in [[6, 1], [6, 1], [12, 2]] {
|
||||
post.resize(&device, size);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let targets = post.targets.as_ref().unwrap();
|
||||
let color = &targets.color[0].as_ref().unwrap()[0];
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[attach(
|
||||
color,
|
||||
wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLUE),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
)],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &targets.depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.5),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
post.set_weather(Some(geometry(&device)));
|
||||
post.draw_weather(&device, &queue, &mut encoder, size);
|
||||
assert!(post.weather_draw.is_none(), "draw consumes this frame only");
|
||||
assert!(post.has_weather(), "pipelines survive the draw");
|
||||
// A dry frame must neither redraw old quads nor discard the pipelines.
|
||||
post.set_weather(None);
|
||||
post.draw_weather(&device, &queue, &mut encoder, size);
|
||||
assert!(post.has_weather());
|
||||
let targets = post.targets.as_ref().unwrap();
|
||||
let color = targets.color[0].as_ref().unwrap()[0].texture();
|
||||
let colors = readback(&device, &mut encoder, color);
|
||||
let depths = readback(&device, &mut encoder, &targets.depth_texture);
|
||||
queue.submit([encoder.finish()]);
|
||||
for buffer in [&colors, &depths] {
|
||||
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
}
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let colors = colors.slice(..).get_mapped_range().unwrap();
|
||||
let depths = depths.slice(..).get_mapped_range().unwrap();
|
||||
for y in 0..size[1] as usize {
|
||||
for x in 0..size[0] as usize {
|
||||
let quad = x / (size[0] as usize / 6);
|
||||
let visible = quad.is_multiple_of(3);
|
||||
// First quad is in front; second is behind scene depth; third discards.
|
||||
let expected_color = if visible && quad < 3 {
|
||||
[128, 0, 127, 255]
|
||||
} else if visible {
|
||||
[0, 128, 127, 255]
|
||||
} else {
|
||||
[0, 0, 255, 255]
|
||||
};
|
||||
let offset = y * 256 + x * 4;
|
||||
let actual_color = &colors[offset..offset + 4];
|
||||
for (actual, expected) in actual_color.iter().zip(expected_color) {
|
||||
assert!(
|
||||
i16::from(*actual).abs_diff(expected) <= 1,
|
||||
"{program} {property:?} size={size:?} ({x},{y}): color {actual_color:?}"
|
||||
);
|
||||
}
|
||||
let depth =
|
||||
f32::from_le_bytes(depths[offset..offset + 4].try_into().unwrap());
|
||||
let expected_depth = if visible && writes { 0.25 } else { 0.5 };
|
||||
if depth != expected_depth {
|
||||
depth_failures.push(format!(
|
||||
"{program} {property:?} size={size:?} ({x},{y}): depth={depth}, expected={expected_depth}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"Weather oracle {program} {property:?} {size:?}: color, occlusion and discard pass; cumulative depth mismatches={}",
|
||||
depth_failures.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(depth_failures.is_empty(), "{}", depth_failures.join("\n"));
|
||||
}
|
||||
|
||||
fn pack(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
adapter: &wgpu::Adapter,
|
||||
program: &str,
|
||||
property: &str,
|
||||
) -> PackPost {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (extension, source) in [
|
||||
(
|
||||
"vsh",
|
||||
"#version 120\nvarying vec4 color; varying float marker; void main(){gl_Position=gl_Vertex;color=gl_Color;marker=gl_MultiTexCoord0.x;}",
|
||||
),
|
||||
(
|
||||
"fsh",
|
||||
"#version 120\nvarying vec4 color; varying float marker; /* DRAWBUFFERS:0 */ void main(){if(marker>1.5) discard;gl_FragData[0]=color;}",
|
||||
),
|
||||
] {
|
||||
zip.start_file(
|
||||
format!("shaders/{program}.{extension}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
}
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-weather-depth",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let macros = horizon_shaders::Macros::standard();
|
||||
let properties = horizon_shaders::Properties::parse(property, ¯os);
|
||||
let post = horizon_shaders::load_post(
|
||||
&pack,
|
||||
"",
|
||||
&properties,
|
||||
¯os,
|
||||
&horizon_shaders::GameInputs {
|
||||
weather: Some(super::super::pack_weather::inputs()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(post.notes.is_empty(), "{:?}", post.notes);
|
||||
assert_eq!(post.weather.as_ref().unwrap().name, program);
|
||||
let lightmap = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
let mut post = PackPost::new(
|
||||
device,
|
||||
queue,
|
||||
adapter,
|
||||
post,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap,
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: [1, 1],
|
||||
clouds: None,
|
||||
weather: true,
|
||||
hand: false,
|
||||
},
|
||||
)
|
||||
.expect("weather pack runs");
|
||||
assert!(
|
||||
post.has_weather(),
|
||||
"production weather pipelines must compile: {:?}",
|
||||
post.notes
|
||||
);
|
||||
post.copied |= 1; // Keep colortex0 readable for the independent color oracle.
|
||||
post
|
||||
}
|
||||
|
||||
fn geometry(device: &wgpu::Device) -> Weather {
|
||||
let mut vertices = Vec::new();
|
||||
let mut indices = Vec::new();
|
||||
for quad in 0..6u32 {
|
||||
let left = quad as f32 / 3.0 - 1.0;
|
||||
let right = (quad + 1) as f32 / 3.0 - 1.0;
|
||||
let z = if quad % 3 == 1 { 0.5 } else { -0.5 };
|
||||
for pos in [
|
||||
[left, -1.0, z],
|
||||
[right, -1.0, z],
|
||||
[right, 1.0, z],
|
||||
[left, 1.0, z],
|
||||
] {
|
||||
vertices.push(super::super::Vertex {
|
||||
pos,
|
||||
uv: [(quad % 3) as f32, 0.0],
|
||||
layer: 0,
|
||||
color: u32::from_le_bytes(if quad < 3 {
|
||||
[255, 0, 0, 128]
|
||||
} else {
|
||||
[0, 255, 0, 128]
|
||||
}),
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
glint_uv: [0.0; 2],
|
||||
});
|
||||
}
|
||||
indices.extend([0, 1, 2, 0, 2, 3].map(|i| quad * 4 + i));
|
||||
}
|
||||
let buffer = |contents: &[u8], usage| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents,
|
||||
usage,
|
||||
})
|
||||
};
|
||||
Weather {
|
||||
vertices: buffer(bytemuck::cast_slice(&vertices), wgpu::BufferUsages::VERTEX),
|
||||
indices: buffer(bytemuck::cast_slice(&indices), wgpu::BufferUsages::INDEX),
|
||||
quads: [0..3, 3..6],
|
||||
sprites: [None, None],
|
||||
generation: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn readback(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
) -> wgpu::Buffer {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(256 * texture.height()),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
aspect: if texture.format().is_depth_stencil_format() {
|
||||
wgpu::TextureAspect::DepthOnly
|
||||
} else {
|
||||
wgpu::TextureAspect::All
|
||||
},
|
||||
..texture.as_image_copy()
|
||||
},
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
buffer
|
||||
}
|
||||
@@ -66,6 +66,10 @@ pub struct PackInputs {
|
||||
/// The player's cave mood, 0..1 (`BiomeAmbientSoundsHandler`'s moodiness):
|
||||
/// `playerMood` and `constantMood`.
|
||||
pub mood: f32,
|
||||
/// Under `HORIZON_STILL`, the world's ticks: the pack's clock (`frameTimeCounter`,
|
||||
/// `frameCounter`) instead of the wall clock and the frames drawn, and smoothed values
|
||||
/// (`wetness`, `eyeBrightnessSmooth`, `centerDepthSmooth`) at their targets.
|
||||
pub still_ticks: Option<u64>,
|
||||
}
|
||||
|
||||
/// The `cloudHeight` uniform. Vanilla reads `visual/cloud_height` only while the cloud color
|
||||
@@ -299,6 +303,8 @@ pub struct FrameUniforms {
|
||||
start: std::time::Instant,
|
||||
last: Option<std::time::Instant>,
|
||||
frame: u32,
|
||||
/// The last frame's `PackInputs::still_ticks`, for `eval_custom`.
|
||||
still_ticks: Option<u64>,
|
||||
previous: Option<(glam::DVec3, Mat4, Mat4)>,
|
||||
wetness: f32,
|
||||
eye_smooth: [f32; 2],
|
||||
@@ -317,6 +323,7 @@ impl Default for FrameUniforms {
|
||||
start: std::time::Instant::now(),
|
||||
last: None,
|
||||
frame: 0,
|
||||
still_ticks: None,
|
||||
previous: None,
|
||||
wetness: 0.0,
|
||||
eye_smooth: [0.0; 2],
|
||||
@@ -422,8 +429,11 @@ impl FrameUniforms {
|
||||
/// This frame's values for a render size of `size` pixels.
|
||||
pub fn update(&mut self, frame: &Frame, inputs: &PackInputs, size: [u32; 2]) {
|
||||
let now = std::time::Instant::now();
|
||||
let dt = self.last.map_or(0.0, |l| (now - l).as_secs_f32());
|
||||
let measured = self.last.map_or(0.0, |l| (now - l).as_secs_f32());
|
||||
self.last = Some(now);
|
||||
self.still_ticks = inputs.still_ticks;
|
||||
// Smoothing over an endless step lands on its target.
|
||||
let dt = inputs.still_ticks.map_or(measured, |_| f32::INFINITY);
|
||||
let aspect = size[0] as f32 / size[1].max(1) as f32;
|
||||
// Packs take gbufferProjection for a plain perspective (they invert only its perspective
|
||||
// terms): the view bob and the nausea spin belong to the model-view, as GL drew them.
|
||||
@@ -502,7 +512,18 @@ impl FrameUniforms {
|
||||
let (shadow_view, shadow_projection) = shadow_camera(light, frame.camera, &self.shadow);
|
||||
let day_time = inputs.day_time.rem_euclid(24000) as i32;
|
||||
let world_day = inputs.day_time.div_euclid(24000);
|
||||
let seconds = (now - self.start).as_secs_f32() % 3600.0;
|
||||
let (seconds, frame_time, frame_counter) = match inputs.still_ticks {
|
||||
Some(ticks) => (
|
||||
(ticks as f32 / 20.0) % 3600.0,
|
||||
0.05,
|
||||
(ticks % 720_720) as i32,
|
||||
),
|
||||
None => (
|
||||
(now - self.start).as_secs_f32() % 3600.0,
|
||||
measured,
|
||||
self.frame as i32,
|
||||
),
|
||||
};
|
||||
|
||||
let value = |name: &str| -> Value {
|
||||
let v3 = |v: Vec3| Value::f(&v.to_array());
|
||||
@@ -538,13 +559,13 @@ impl FrameUniforms {
|
||||
"worldTime" => Value::i(&[day_time]),
|
||||
"worldDay" => Value::i(&[world_day as i32]),
|
||||
"moonPhase" => Value::i(&[world_day.rem_euclid(8) as i32]),
|
||||
"frameCounter" => Value::i(&[self.frame as i32]),
|
||||
"frameCounter" => Value::i(&[frame_counter]),
|
||||
"shadowModelView" => m4(shadow_view),
|
||||
"shadowModelViewInverse" => m4(shadow_view.inverse()),
|
||||
"shadowProjection" => m4(shadow_projection),
|
||||
"shadowProjectionInverse" => m4(shadow_projection.inverse()),
|
||||
"atlasSize" => Value::i(&[self.atlas[0] as i32, self.atlas[1] as i32]),
|
||||
"frameTime" => Value::f(&[dt]),
|
||||
"frameTime" => Value::f(&[frame_time]),
|
||||
"frameTimeCounter" => Value::f(&[seconds]),
|
||||
"viewWidth" => Value::f(&[size[0] as f32]),
|
||||
"viewHeight" => Value::f(&[size[1] as f32]),
|
||||
@@ -614,6 +635,9 @@ impl FrameUniforms {
|
||||
}
|
||||
|
||||
/// `hz_fog_color` and `hz_fog` of the translator's block (the compatibility `gl_Fog`).
|
||||
/// Its density is `fogDensity`'s (0): Iris 1.11.7 gave `gl_Fog.density` 0, not 1, in every
|
||||
/// program kind our probe pack read it in (gbuffers_terrain and deferred, in a cave and in
|
||||
/// the Nether), where `fogDensity` read 0 too.
|
||||
pub fn fog(&self) -> [Vec4; 2] {
|
||||
let get = |name| match index(name).and_then(|i| self.values.get(i)) {
|
||||
Some(Value::F(f)) => *f,
|
||||
@@ -622,7 +646,12 @@ impl FrameUniforms {
|
||||
let (color, start, end) = (get("fogColor"), get("fogStart")[0], get("fogEnd")[0]);
|
||||
[
|
||||
Vec4::new(color[0], color[1], color[2], 1.0),
|
||||
Vec4::new(1.0, start, end, 1.0 / (end - start).max(1e-3)),
|
||||
Vec4::new(
|
||||
get("fogDensity")[0],
|
||||
start,
|
||||
end,
|
||||
1.0 / (end - start).max(1e-3),
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -631,6 +660,9 @@ impl FrameUniforms {
|
||||
/// Evaluates the pack's custom uniforms with this frame's values and puts them after
|
||||
/// `NAMES`' values, in `custom.uniforms()` order.
|
||||
pub fn eval_custom(&mut self, custom: &mut horizon_shaders::Custom) {
|
||||
if let Some(tick) = self.still_ticks {
|
||||
custom.restart_at(tick);
|
||||
}
|
||||
self.values.truncate(NAMES.len());
|
||||
let values = &self.values;
|
||||
let builtin = |name: &str| {
|
||||
@@ -1334,6 +1366,29 @@ mod initial_history_tests {
|
||||
assert_eq!(int(&u, index("fogShape").unwrap()), -1);
|
||||
}
|
||||
|
||||
/// Iris 1.11.7, read back with a probe pack in gbuffers_terrain and in deferred, in a cave
|
||||
/// (fog 0 to 1024) and in the Nether (fog 10 to 96): `gl_Fog` holds the fog's colour, start
|
||||
/// and end, a scale of 1 / (end - start), and a density of 0, not 1.
|
||||
#[test]
|
||||
fn the_compatibility_gl_fog_has_no_density() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let mut f = frame(&hud);
|
||||
f.fog = Some(crate::fog::FogData {
|
||||
color: [0.09, 0.24, 0.23, 1.0],
|
||||
environmental_start: 10.0,
|
||||
environmental_end: 96.0,
|
||||
render_distance_start: 172.8,
|
||||
render_distance_end: 192.0,
|
||||
sky_end: 192.0,
|
||||
cloud_end: 1024.0,
|
||||
});
|
||||
let mut u = FrameUniforms::default();
|
||||
u.update(&f, &PackInputs::default(), [800, 450]);
|
||||
let [color, fog] = u.fog();
|
||||
assert_eq!(color.truncate(), Vec3::new(0.09, 0.24, 0.23));
|
||||
assert_eq!(fog, Vec4::new(0.0, 10.0, 96.0, 1.0 / 86.0));
|
||||
}
|
||||
|
||||
/// Iris 1.11.7 (probe pack): gbuffers programs read fogMode GL_LINEAR (9729) and fogShape 1
|
||||
/// (cylinder) where composite and final programs read 0 and -1.
|
||||
#[test]
|
||||
@@ -1396,6 +1451,42 @@ mod initial_history_tests {
|
||||
assert!((float(&u, "wetness")[0] - 0.25).abs() < 1e-3);
|
||||
}
|
||||
|
||||
/// Under `HORIZON_STILL` the pack's clock is the world's ticks, whenever and however often
|
||||
/// the frame is drawn, and smoothed values are at their targets.
|
||||
#[test]
|
||||
fn a_still_frame_takes_the_world_ticks_as_its_clock() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let f = frame(&hud);
|
||||
let mut u = FrameUniforms::default();
|
||||
let mut input = PackInputs {
|
||||
eye_light: [0, 15],
|
||||
still_ticks: Some(47_811),
|
||||
..Default::default()
|
||||
};
|
||||
u.center_depth_raw = Some(1.0);
|
||||
u.update(&f, &input, [800, 450]);
|
||||
input.eye_light = [0, 0];
|
||||
input.rain = 1.0;
|
||||
u.center_depth_raw = Some(0.0);
|
||||
u.last = Some(std::time::Instant::now() - std::time::Duration::from_secs(3));
|
||||
u.update(&f, &input, [800, 450]);
|
||||
assert_eq!(
|
||||
float(&u, "frameTimeCounter")[0],
|
||||
(47_811.0f32 / 20.0) % 3600.0
|
||||
);
|
||||
assert_eq!(float(&u, "frameTime")[0], 0.05);
|
||||
match u.values[index("frameCounter").unwrap()] {
|
||||
Value::I(v) => assert_eq!(v[0], 47_811),
|
||||
_ => panic!("integer uniform expected"),
|
||||
}
|
||||
match u.values[index("eyeBrightnessSmooth").unwrap()] {
|
||||
Value::I(v) => assert_eq!(v[1], 0),
|
||||
_ => panic!("integer uniform expected"),
|
||||
}
|
||||
assert_eq!(float(&u, "wetness")[0], 1.0);
|
||||
assert_eq!(float(&u, "centerDepthSmooth")[0], 0.0);
|
||||
}
|
||||
|
||||
/// Iris 1.11.7 (probe pack): `ambientLight` is the dimension type's (0.1 in the Nether,
|
||||
/// 0.25 in the End), and `playerMood` and `constantMood` both the cave mood.
|
||||
#[test]
|
||||
|
||||
@@ -475,7 +475,8 @@ impl<T> Resident<T> {
|
||||
}
|
||||
let oy = f64::from(self.min_y + y * 16) - camera.y;
|
||||
let min = Vec3::new(min_x, oy as f32, min_z);
|
||||
let (bmin, bmax) = section_box(min, self.bounds[slot as usize]);
|
||||
let bounds = self.bounds[slot as usize].filter(|_| frustum.tight());
|
||||
let (bmin, bmax) = section_box(min, bounds);
|
||||
if !frustum.intersects(bmin, bmax) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! NORMAL world text: alpha blended, back-face culled, ordinary depth writes.
|
||||
pub(super) fn pipeline(
|
||||
device: &wgpu::Device,
|
||||
layout: Option<&wgpu::PipelineLayout>,
|
||||
shader: &wgpu::ShaderModule,
|
||||
format: wgpu::TextureFormat,
|
||||
) -> wgpu::RenderPipeline {
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Normal world text"),
|
||||
layout,
|
||||
vertex: wgpu::VertexState {
|
||||
module: shader,
|
||||
entry_point: Some("vs_entity"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
entry_point: Some("fs_entity"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wgpu::util::DeviceExt;
|
||||
#[test]
|
||||
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
|
||||
fn world_text_blends_culls_and_obeys_world_depth() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
// Use the production pipeline/vertex layout with a pass-through shader
|
||||
// to isolate its actual depth, winding and blend behavior from lighting.
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
r#"
|
||||
struct Out { @builtin(position) pos:vec4<f32>, @location(0) color:vec4<f32> }
|
||||
@vertex fn vs_entity(@location(0) pos:vec3<f32>, @location(3) color:vec4<f32>)->Out {
|
||||
return Out(vec4(pos,1.0),color);
|
||||
}
|
||||
@fragment fn fs_entity(v:Out)->@location(0) vec4<f32> { return v.color; }
|
||||
"#
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let pipeline = pipeline(&device, None, &shader, wgpu::TextureFormat::Rgba8Unorm);
|
||||
let texture = |format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 4,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let color = texture(
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let depth = texture(
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
);
|
||||
let mut vertices = Vec::new();
|
||||
let mut indices = Vec::new();
|
||||
let mut quad = |pixel: u32, z: f32, color: [u8; 4], back: bool| {
|
||||
let x = -1.0 + pixel as f32 * 0.5;
|
||||
let first = vertices.len() as u32;
|
||||
for pos in [
|
||||
[x, -1.0, z],
|
||||
[x + 0.5, -1.0, z],
|
||||
[x + 0.5, 1.0, z],
|
||||
[x, 1.0, z],
|
||||
] {
|
||||
vertices.push(super::super::Vertex {
|
||||
pos,
|
||||
uv: [0.0; 2],
|
||||
color: u32::from_le_bytes(color),
|
||||
layer: 0,
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
glint_uv: [0.0; 2],
|
||||
});
|
||||
}
|
||||
for i in if back {
|
||||
[0, 2, 1, 0, 3, 2]
|
||||
} else {
|
||||
[0, 1, 2, 0, 2, 3]
|
||||
} {
|
||||
indices.push(first + i);
|
||||
}
|
||||
};
|
||||
quad(0, 0.6, [255, 0, 0, 255], false);
|
||||
// A hair behind: the entity model pipeline's 128-ulp depth bias
|
||||
// would incorrectly put the green quad in front of the red surface.
|
||||
quad(0, 0.599_999, [0, 255, 0, 255], false);
|
||||
quad(1, 0.8, [0, 255, 0, 255], false);
|
||||
quad(1, 0.7, [255, 0, 0, 255], false);
|
||||
quad(2, 0.5, [255, 0, 0, 128], false);
|
||||
quad(3, 0.5, [255, 255, 0, 255], true);
|
||||
let buffer = |data: &[u8], usage| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: data,
|
||||
usage,
|
||||
})
|
||||
};
|
||||
let vertex = buffer(bytemuck::cast_slice(&vertices), wgpu::BufferUsages::VERTEX);
|
||||
let index = buffer(bytemuck::cast_slice(&indices), wgpu::BufferUsages::INDEX);
|
||||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let color = color.create_view(&Default::default());
|
||||
let depth = depth.create_view(&Default::default());
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &color,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLUE),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_vertex_buffer(0, vertex.slice(..));
|
||||
pass.set_index_buffer(index.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..indices.len() as u32, 0, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
color.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &readback,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
color.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
readback.map_async(wgpu::MapMode::Read, .., move |result| {
|
||||
tx.send(result).unwrap()
|
||||
});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
rx.recv().unwrap().unwrap();
|
||||
let pixels = readback.get_mapped_range(..).unwrap();
|
||||
assert_eq!(
|
||||
&pixels[..16],
|
||||
&[
|
||||
255, 0, 0, 255, 0, 255, 0, 255, 128, 0, 127, 255, 0, 0, 255, 255
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -532,6 +532,67 @@ pub fn smooth_gradients_offered() -> bool {
|
||||
SMOOTH_GRADIENTS_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Whether GPU Culling (`Graphics::gpu_culling`) can be switched on here, and why not: set when a
|
||||
/// renderer starts, and again when its pass can't be set up or its buffers stop fitting in video
|
||||
/// memory. Where it isn't offered the settings grey its switch out with the reason, and the saved
|
||||
/// value waits for a renderer that offers it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GpuCullingOffer {
|
||||
/// No count draws the GPU wrote, compute shaders or working first instances (OpenGL, Metal,
|
||||
/// Vulkan without `VK_KHR_draw_indirect_count`, Intel Gen11/Gen12's Vulkan driver), vanilla
|
||||
/// frames drawn flat, or a pass the driver couldn't build.
|
||||
Unsupported = 0,
|
||||
/// A software adapter (WARP, lavapipe): the pass would run on the CPU, beside the frame.
|
||||
Software = 1,
|
||||
/// Its buffers stopped fitting in video memory: off until the next renderer.
|
||||
OutOfMemory = 2,
|
||||
/// DX12 with flat page groups, whose count draws leave each draw's base vertex out of
|
||||
/// what the near sections' packed faces are read by (`gpu_culling::loses_base_vertex`).
|
||||
Dx12 = 3,
|
||||
/// The graphics device was lost while its frames were drawn (`lose_gpu_culling`): off
|
||||
/// for the renderers after it, until Horizon restarts.
|
||||
DeviceLost = 5,
|
||||
Offered = 4,
|
||||
}
|
||||
impl GpuCullingOffer {
|
||||
/// The greyed switch's tooltip; `None` where it is offered.
|
||||
pub fn reason(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Unsupported => Some("Your GPU or graphics API doesn't support it."),
|
||||
Self::Software => Some("Not with software rendering, which runs it on the CPU."),
|
||||
Self::OutOfMemory => Some("Out of video memory: off until the next start."),
|
||||
Self::Dx12 => Some("Not on DirectX 12 yet: try the Vulkan graphics API."),
|
||||
Self::DeviceLost => Some("The graphics device was lost: off until the next start."),
|
||||
Self::Offered => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
static GPU_CULLING_OFFER: std::sync::atomic::AtomicU8 =
|
||||
std::sync::atomic::AtomicU8::new(GpuCullingOffer::Unsupported as u8);
|
||||
pub fn offer_gpu_culling(offer: GpuCullingOffer) {
|
||||
GPU_CULLING_OFFER.store(offer as u8, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
pub fn gpu_culling_offer() -> GpuCullingOffer {
|
||||
match GPU_CULLING_OFFER.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
1 => GpuCullingOffer::Software,
|
||||
2 => GpuCullingOffer::OutOfMemory,
|
||||
3 => GpuCullingOffer::Dx12,
|
||||
4 => GpuCullingOffer::Offered,
|
||||
5 => GpuCullingOffer::DeviceLost,
|
||||
_ => GpuCullingOffer::Unsupported,
|
||||
}
|
||||
}
|
||||
/// Set once the graphics device was lost while GPU culling drew the frames (the game's
|
||||
/// `recover_device`): the renderers made after it don't offer it (`GpuCullingOffer::DeviceLost`),
|
||||
/// so a fault in its frames can't lose the device again after every recovery.
|
||||
static GPU_CULLING_LOST: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
pub fn lose_gpu_culling() {
|
||||
GPU_CULLING_LOST.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
pub fn gpu_culling_lost() -> bool {
|
||||
GPU_CULLING_LOST.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
|
||||
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
|
||||
/// costs them more frame time than it is worth.
|
||||
@@ -608,8 +669,15 @@ pub struct Graphics {
|
||||
pub blue_map: crate::bluemap::Settings,
|
||||
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
|
||||
pub cave_culling: bool,
|
||||
/// Near solid terrain culled, and its draws listed, on the GPU (`gfx::gpu_culling`), where it
|
||||
/// is offered (`gpu_culling_offer`). Off by default (GPU Culling, Performance graphics); it
|
||||
/// applies at once, and no preset sets it. Every save writes it, so a default turned on later
|
||||
/// needs a new key, as `batchTranslucent` did.
|
||||
pub gpu_culling: bool,
|
||||
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
|
||||
pub entity_culling: EntityCullingMode,
|
||||
/// Minimum entity population for coarse AABB culling; zero disables it.
|
||||
pub entity_culling_precheck: u32,
|
||||
/// Skip particles outside the view or hidden behind opaque terrain.
|
||||
pub particle_culling: ParticleCullingMode,
|
||||
/// Terrain leaf faces inside a canopy (see [`LeafCullingMode`]); `Off` draws
|
||||
@@ -716,7 +784,9 @@ impl Default for Graphics {
|
||||
far_detail: FarDetail::Full,
|
||||
blue_map: crate::bluemap::Settings::default(),
|
||||
cave_culling: true,
|
||||
gpu_culling: false,
|
||||
entity_culling: EntityCullingMode::Off,
|
||||
entity_culling_precheck: 64,
|
||||
particle_culling: ParticleCullingMode::Off,
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
cache_shulker_boxes: false,
|
||||
@@ -812,7 +882,11 @@ impl Graphics {
|
||||
}
|
||||
"blueMapAutoDetect" => g.blue_map.auto_detect = flag(g.blue_map.auto_detect),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"gpuCulling" => g.gpu_culling = flag(g.gpu_culling),
|
||||
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
|
||||
"entityCullingPrecheck" => {
|
||||
g.entity_culling_precheck = number(g.entity_culling_precheck).min(512)
|
||||
}
|
||||
"particleCulling" => g.particle_culling = ParticleCullingMode::parse(value),
|
||||
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
|
||||
"blockEntityCache" => {
|
||||
@@ -906,12 +980,13 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
bool_text(self.cave_culling),
|
||||
self.entity_culling.name(),
|
||||
self.entity_culling_precheck,
|
||||
self.particle_culling.name(),
|
||||
self.leaf_culling.name(),
|
||||
bool_text(self.cache_shulker_boxes),
|
||||
@@ -959,6 +1034,7 @@ impl Graphics {
|
||||
self.shader_pack,
|
||||
bool_text(self.translucent_batching),
|
||||
bool_text(self.hdr_smooth_gradients),
|
||||
bool_text(self.gpu_culling),
|
||||
);
|
||||
text += &self.blue_map.serialize();
|
||||
text
|
||||
@@ -1097,7 +1173,9 @@ mod tests {
|
||||
servers: Vec::new(),
|
||||
},
|
||||
cave_culling: false,
|
||||
gpu_culling: true,
|
||||
entity_culling: EntityCullingMode::Synchronous,
|
||||
entity_culling_precheck: 128,
|
||||
particle_culling: ParticleCullingMode::Asynchronous,
|
||||
leaf_culling: LeafCullingMode::SolidAggressive,
|
||||
cache_shulker_boxes: true,
|
||||
@@ -1183,6 +1261,9 @@ mod tests {
|
||||
assert!(Graphics::parse("hdr:true\n").wide_frame());
|
||||
assert!(!Graphics::parse("hdr:true\nhdrSmoothGradients:false\n").wide_frame());
|
||||
assert!(Graphics::parse("translucentBatching:false\n").translucent_batching);
|
||||
// GPU Culling is opt-in: only the file or its switch turns it on.
|
||||
assert!(!Graphics::default().gpu_culling);
|
||||
assert!(Graphics::parse("gpuCulling:true\n").gpu_culling);
|
||||
// 0 is Auto; anything else is a peak the slider reaches.
|
||||
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
|
||||
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
|
||||
|
||||
@@ -65,7 +65,9 @@ pub enum H {
|
||||
BlueMapAutoDetect,
|
||||
BlueMapRadius,
|
||||
CaveCulling,
|
||||
GpuCulling,
|
||||
EntityCulling,
|
||||
EntityCullingPrecheck,
|
||||
ParticleCulling,
|
||||
LeafCulling,
|
||||
CacheShulkerBoxes,
|
||||
@@ -377,6 +379,11 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
Saves,
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.",
|
||||
),
|
||||
h(
|
||||
H::GpuCulling,
|
||||
Saves,
|
||||
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.",
|
||||
),
|
||||
h(
|
||||
H::EntityCulling,
|
||||
None,
|
||||
@@ -384,8 +391,13 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
),
|
||||
h(
|
||||
H::ParticleCulling,
|
||||
None,
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.",
|
||||
Saves,
|
||||
"Skips particles outside the view, below one pixel or behind terrain.",
|
||||
),
|
||||
h(
|
||||
H::EntityCullingPrecheck,
|
||||
Saves,
|
||||
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.",
|
||||
),
|
||||
h(
|
||||
H::LeafCulling,
|
||||
@@ -551,15 +563,15 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
],
|
||||
),
|
||||
];
|
||||
// Only Windows and Linux have more than one graphics API, and translucent batching
|
||||
// (Metal draws flat).
|
||||
// Only Windows and Linux have more than one graphics API, translucent batching (Metal
|
||||
// draws flat) and GPU culling (Metal draws no count the GPU wrote).
|
||||
if cfg!(target_os = "macos") {
|
||||
for (_, groups) in &mut pages {
|
||||
for (_, rows) in groups {
|
||||
rows.retain(|r| {
|
||||
!matches!(
|
||||
r.item,
|
||||
Item::Horizon(H::GraphicsApi | H::TranslucentBatching)
|
||||
Item::Horizon(H::GraphicsApi | H::TranslucentBatching | H::GpuCulling)
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -692,6 +704,7 @@ impl H {
|
||||
let g = &s.horizon;
|
||||
match self {
|
||||
H::ChunkUpdates => Some(g.chunk_updates as f32 / 64.0),
|
||||
H::EntityCullingPrecheck => Some(g.entity_culling_precheck as f32 / 512.0),
|
||||
H::UploadBudget => {
|
||||
let i = UPLOAD_STEPS
|
||||
.iter()
|
||||
@@ -723,6 +736,7 @@ impl H {
|
||||
pub fn slider_steps(self) -> Option<u32> {
|
||||
match self {
|
||||
H::ChunkUpdates => Some(64),
|
||||
H::EntityCullingPrecheck => Some(32),
|
||||
H::UploadBudget => Some((UPLOAD_STEPS.len() - 1) as u32),
|
||||
H::Workers => Some(max_workers()),
|
||||
H::ResolutionScale => Some(30),
|
||||
@@ -748,7 +762,21 @@ impl H {
|
||||
lang::tr("%s chunks", &[&g.blue_map.radius_chunks()]),
|
||||
),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
// Greyed out where it isn't offered (`Item::blocked`): Off, as it is there, while
|
||||
// the saved value waits.
|
||||
H::GpuCulling => labelled(
|
||||
"GPU Culling",
|
||||
on_off(g.gpu_culling && crate::graphics::gpu_culling_offer().reason().is_none()),
|
||||
),
|
||||
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
|
||||
H::EntityCullingPrecheck => labelled(
|
||||
"Entity Culling Precheck",
|
||||
if g.entity_culling_precheck == 0 {
|
||||
"Off".to_string()
|
||||
} else {
|
||||
lang::tr("%s mobs", &[&g.entity_culling_precheck])
|
||||
},
|
||||
),
|
||||
H::ParticleCulling => labelled("Particle Culling", g.particle_culling.label()),
|
||||
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
|
||||
H::CacheShulkerBoxes => labelled("Shulker Boxes", on_off(g.cache_shulker_boxes)),
|
||||
@@ -861,6 +889,7 @@ impl H {
|
||||
let g = &mut s.horizon;
|
||||
match self {
|
||||
H::ChunkUpdates => g.chunk_updates = (v * 64.0).round() as u32,
|
||||
H::EntityCullingPrecheck => g.entity_culling_precheck = (v * 512.0).round() as u32,
|
||||
H::UploadBudget => {
|
||||
g.upload_budget_kib =
|
||||
UPLOAD_STEPS[(v * (UPLOAD_STEPS.len() - 1) as f32).round() as usize]
|
||||
@@ -904,6 +933,7 @@ impl H {
|
||||
g.blue_map.radius = step(&BLUE_MAP_RADII, g.blue_map.radius_chunks(), back)
|
||||
}
|
||||
H::CaveCulling => g.cave_culling = !g.cave_culling,
|
||||
H::GpuCulling => g.gpu_culling = !g.gpu_culling,
|
||||
H::EntityCulling => {
|
||||
g.entity_culling = step(&EntityCullingMode::ALL, g.entity_culling, back)
|
||||
}
|
||||
@@ -993,7 +1023,8 @@ impl Item {
|
||||
/// the saved values wait for an HDR display), and its sliders do something only
|
||||
/// while it is on; Translucent Batching switches only where the device can batch
|
||||
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
|
||||
/// and Smooth Gradients only where it can draw the 16-bit frame, the same way.
|
||||
/// Smooth Gradients only where it can draw the 16-bit frame, the same way, and GPU Culling
|
||||
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't.
|
||||
fn blocked(self, s: &Settings) -> Option<&'static str> {
|
||||
if !self.available() {
|
||||
return Some("This feature is not yet available in Horizon.");
|
||||
@@ -1030,6 +1061,12 @@ impl Item {
|
||||
{
|
||||
Some("Your GPU or graphics API doesn't support it.")
|
||||
}
|
||||
Item::Horizon(H::EntityCullingPrecheck)
|
||||
if s.horizon.entity_culling != EntityCullingMode::Asynchronous =>
|
||||
{
|
||||
Some("Only applies to asynchronous entity culling.")
|
||||
}
|
||||
Item::Horizon(H::GpuCulling) => crate::graphics::gpu_culling_offer().reason(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1045,6 +1082,9 @@ impl Item {
|
||||
Some(match self {
|
||||
Item::Horizon(H::BlueMapAutoDetect) => g.blue_map.auto_detect,
|
||||
Item::Horizon(H::CaveCulling) => g.cave_culling,
|
||||
Item::Horizon(H::GpuCulling) => {
|
||||
g.gpu_culling && crate::graphics::gpu_culling_offer().reason().is_none()
|
||||
}
|
||||
Item::Horizon(H::CacheShulkerBoxes) => g.cache_shulker_boxes,
|
||||
Item::Horizon(H::CacheSigns) => g.cache_signs,
|
||||
Item::Horizon(H::CacheDecoratedPots) => g.cache_decorated_pots,
|
||||
@@ -1096,7 +1136,7 @@ impl Item {
|
||||
}
|
||||
}
|
||||
pub fn set_slider(self, s: &mut Settings, v: f32) {
|
||||
if !self.available() {
|
||||
if !self.available() || self.blocked(s).is_some() {
|
||||
return;
|
||||
}
|
||||
match self {
|
||||
@@ -1134,6 +1174,7 @@ impl Item {
|
||||
| H::GraphicsApi
|
||||
| H::TranslucentBatching
|
||||
| H::HdrSmoothGradients
|
||||
| H::GpuCulling
|
||||
) | Item::Vanilla(
|
||||
Opt::GuiScale
|
||||
| Opt::Fullscreen
|
||||
@@ -2203,7 +2244,8 @@ impl GraphicsUi {
|
||||
content: f32,
|
||||
) {
|
||||
self.focus = Some(target);
|
||||
self.can_change_value = matches!(target, Target::Item(i) if i.slider(s).is_some());
|
||||
self.can_change_value =
|
||||
matches!(target, Target::Item(i) if i.slider(s).is_some() && i.blocked(s).is_none());
|
||||
if let Target::Item(_) = target {
|
||||
let [_, y, _, h] = rect;
|
||||
if y < cols.top {
|
||||
@@ -2272,6 +2314,7 @@ impl GraphicsUi {
|
||||
&& self.can_change_value
|
||||
&& let Some(Target::Item(item)) = self.focus
|
||||
&& item.slider(s).is_some()
|
||||
&& item.blocked(s).is_none()
|
||||
{
|
||||
item.step_slider(s, key == K::ArrowRight, cols.track().1 + 8.0);
|
||||
self.changed = true;
|
||||
@@ -3163,6 +3206,82 @@ mod tests {
|
||||
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_culling_precheck_slider_is_only_live_for_async() {
|
||||
let item = Item::Horizon(H::EntityCullingPrecheck);
|
||||
let mut s = Settings::default();
|
||||
let initial = s.horizon.entity_culling_precheck;
|
||||
|
||||
for mode in [EntityCullingMode::Off, EntityCullingMode::Synchronous] {
|
||||
s.horizon.entity_culling = mode;
|
||||
assert_eq!(
|
||||
item.blocked(&s),
|
||||
Some("Only applies to asynchronous entity culling.")
|
||||
);
|
||||
|
||||
let mut ui = GraphicsUi::default();
|
||||
let row_rect = row(&mut ui, &s, item);
|
||||
assert!(press(&mut ui, &mut s, (row_rect[0] + 4.0, row_rect[1] + 4.0)).is_empty());
|
||||
item.set_slider(&mut s, 1.0);
|
||||
assert_eq!(s.horizon.entity_culling_precheck, initial);
|
||||
|
||||
let (cols, lines, content) = ui.layout(&s, SIZE.0, SIZE.1);
|
||||
assert!(
|
||||
!ui.focusables(&s, &cols, &lines, false)
|
||||
.iter()
|
||||
.any(|(target, _)| *target == Target::Item(item))
|
||||
);
|
||||
ui.focus_on(&s, Target::Item(item), row_rect, &cols, content);
|
||||
assert!(!ui.can_change_value);
|
||||
}
|
||||
|
||||
for (code, expected) in [
|
||||
("en_us", "Only applies to asynchronous entity culling."),
|
||||
("de_de", "Nur für asynchrones Objekt-Culling."),
|
||||
(
|
||||
"es_es",
|
||||
"Solo se aplica a la oclusión asíncrona de entidades.",
|
||||
),
|
||||
(
|
||||
"fr_fr",
|
||||
"S'applique uniquement à l'occlusion asynchrone des entités.",
|
||||
),
|
||||
("pl_pl", "Dotyczy tylko asynchronicznego pomijania bytów."),
|
||||
(
|
||||
"ru_ru",
|
||||
"Применяется только при асинхронном отсечении сущностей.",
|
||||
),
|
||||
("tr_tr", "Yalnızca eşzamansız varlık elemesine uygulanır."),
|
||||
(
|
||||
"uk_ua",
|
||||
"Застосовується лише для асинхронного відсікання сутностей.",
|
||||
),
|
||||
("zh_cn", "仅适用于异步实体剔除。"),
|
||||
] {
|
||||
let (shown, missed) = lang::scoped(lang::Language::load(&[], code), || {
|
||||
lang::get(item.blocked(&s).unwrap())
|
||||
});
|
||||
assert_eq!(shown, expected, "{code}");
|
||||
assert!(missed.is_empty(), "{code}: {missed:?}");
|
||||
}
|
||||
|
||||
s.horizon.entity_culling = EntityCullingMode::Asynchronous;
|
||||
assert_eq!(item.blocked(&s), None);
|
||||
let mut ui = GraphicsUi::default();
|
||||
let row_rect = row(&mut ui, &s, item);
|
||||
let (cols, _, _) = ui.layout(&s, SIZE.0, SIZE.1);
|
||||
let (track_x, track_w) = cols.track();
|
||||
assert_eq!(
|
||||
press(
|
||||
&mut ui,
|
||||
&mut s,
|
||||
(track_x + track_w - 4.0, row_rect[1] + 4.0)
|
||||
),
|
||||
[Action::SliderDrag]
|
||||
);
|
||||
assert_eq!(s.horizon.entity_culling_precheck, 512);
|
||||
}
|
||||
|
||||
/// Translucent Batching: a switch on the Advanced page, on by default, no part of a
|
||||
/// preset, and greyed out, reading Off, where the device can't batch.
|
||||
#[test]
|
||||
@@ -3215,6 +3334,71 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// GPU Culling: a switch right after Cave Culling, off by default, no part of a preset,
|
||||
/// and greyed out with its reason, reading Off, wherever it isn't offered.
|
||||
#[test]
|
||||
fn gpu_culling_is_a_culling_switch_where_it_is_offered() {
|
||||
use crate::graphics::GpuCullingOffer;
|
||||
let item = Item::Horizon(H::GpuCulling);
|
||||
let at = |item| items().into_iter().position(|(_, i)| i == item);
|
||||
if cfg!(target_os = "macos") {
|
||||
// Metal draws no count the GPU wrote: never offered, so not shown.
|
||||
assert_eq!(at(item), None);
|
||||
return;
|
||||
}
|
||||
let i = at(item).unwrap();
|
||||
assert_eq!(items()[i].0, Page::Performance);
|
||||
assert_eq!(at(Item::Horizon(H::CaveCulling)), Some(i - 1));
|
||||
let mut s = Settings::default();
|
||||
assert_eq!(item.switch(&s), Some(false));
|
||||
s.horizon.preset = Preset::Balanced;
|
||||
s.horizon.gpu_culling = true;
|
||||
// Where it isn't offered, the saved On reads Off, and neither a click nor Enter after
|
||||
// it does anything.
|
||||
for offer in [
|
||||
GpuCullingOffer::Unsupported,
|
||||
GpuCullingOffer::Software,
|
||||
GpuCullingOffer::OutOfMemory,
|
||||
GpuCullingOffer::Dx12,
|
||||
GpuCullingOffer::DeviceLost,
|
||||
] {
|
||||
crate::graphics::offer_gpu_culling(offer);
|
||||
assert!(offer.reason().is_some());
|
||||
assert_eq!(item.blocked(&s), offer.reason());
|
||||
assert_eq!(item.label(&s).plain(), "GPU Culling: Off");
|
||||
// The box, the narrator and the widget tree read the switch.
|
||||
assert_eq!(item.switch(&s), Some(false), "{offer:?}");
|
||||
let mut ui = GraphicsUi::default();
|
||||
let r = row(&mut ui, &s, item);
|
||||
assert!(press(&mut ui, &mut s, (r[0] + 4.0, r[1] + 4.0)).is_empty());
|
||||
ui.focus = Some(Target::Item(item));
|
||||
let enter = ui.key_modifiers(&mut s, K::Enter, None, false, SIZE, FB);
|
||||
assert!(enter.0.is_empty());
|
||||
assert!(s.horizon.gpu_culling, "{offer:?}");
|
||||
}
|
||||
// Where it is, a click switches it and keeps the preset.
|
||||
crate::graphics::offer_gpu_culling(GpuCullingOffer::Offered);
|
||||
assert_eq!(item.blocked(&s), None);
|
||||
assert_eq!(item.label(&s).plain(), "GPU Culling: On");
|
||||
assert_eq!(item.switch(&s), Some(true));
|
||||
let mut ui = GraphicsUi::default();
|
||||
let r = row(&mut ui, &s, item);
|
||||
assert_eq!(
|
||||
press(&mut ui, &mut s, (r[0] + 4.0, r[1] + 4.0)),
|
||||
[Action::ButtonClick, Action::Changed]
|
||||
);
|
||||
assert!(!s.horizon.gpu_culling);
|
||||
assert_eq!(s.horizon.preset, Preset::Balanced);
|
||||
// No preset sets it.
|
||||
for on in [false, true] {
|
||||
s.horizon.gpu_culling = on;
|
||||
for preset in Preset::ALL {
|
||||
crate::graphics::apply_preset(preset, &mut s);
|
||||
assert_eq!(s.horizon.gpu_culling, on, "{preset:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_culling_cycles_modes_beside_the_other_culling() {
|
||||
let mut s = Settings::default();
|
||||
@@ -3399,6 +3583,17 @@ mod tests {
|
||||
] {
|
||||
lang::get(text);
|
||||
}
|
||||
// Every reason GPU Culling's switch can give, whatever this device offers.
|
||||
use crate::graphics::GpuCullingOffer as O;
|
||||
for offer in [
|
||||
O::Unsupported,
|
||||
O::Software,
|
||||
O::OutOfMemory,
|
||||
O::Dx12,
|
||||
O::DeviceLost,
|
||||
] {
|
||||
lang::get(offer.reason().unwrap());
|
||||
}
|
||||
});
|
||||
// `labelled` also looks up values that are already words in the language ("An") or
|
||||
// numbers with a unit ("12 chunks", "Auto (8)"); those are not keys, and "FSR 3.1" is
|
||||
|
||||
@@ -256,7 +256,15 @@ pub fn decode_image_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
|
||||
match ImageFormat::sniff(data) {
|
||||
Some(ImageFormat::WebP) => decode_webp(data),
|
||||
Some(ImageFormat::Jpeg) => decode_jpeg(data),
|
||||
Some(ImageFormat::Png) | None => decode_bytes(data),
|
||||
// A PNG's IHDR states the size before the decoder sizes its pixel
|
||||
// buffer, so it is checked there like the WebP and JPEG headers: the
|
||||
// PNG reader alone would trust the header and allocate it.
|
||||
Some(ImageFormat::Png) | None => {
|
||||
if let Some((width, height)) = png_bytes_size(data) {
|
||||
check_fetched_size(u64::from(width), u64::from(height))?;
|
||||
}
|
||||
decode_bytes(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,15 +316,19 @@ fn within_budget(files: &[&PathBuf], budget: u64) -> HashSet<PathBuf> {
|
||||
.map(|p| (*p).clone())
|
||||
.collect()
|
||||
}
|
||||
/// A PNG's width and height from its first 24 bytes (`IHDR`), before any
|
||||
/// pixel buffer is made.
|
||||
fn png_bytes_size(data: &[u8]) -> Option<(u32, u32)> {
|
||||
let header = data.get(..24)?;
|
||||
if header[..8] != *b"\x89PNG\r\n\x1a\n" || header[12..16] != *b"IHDR" {
|
||||
return None;
|
||||
}
|
||||
let field = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
|
||||
Some((field(16), field(20)))
|
||||
}
|
||||
/// A PNG's width and height, from its header alone.
|
||||
fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
let header: [u8; 24] = horizon_core::prepared_files::read_head(path, 24)
|
||||
.ok()?
|
||||
.try_into()
|
||||
.ok()?;
|
||||
let field = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
|
||||
(header[..8] == *b"\x89PNG\r\n\x1a\n" && header[12..16] == *b"IHDR")
|
||||
.then(|| (field(16), field(20)))
|
||||
png_bytes_size(&horizon_core::prepared_files::read_head(path, 24).ok()?)
|
||||
}
|
||||
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
// A prepared root's folder: from its data file, without listing the disk.
|
||||
@@ -1928,7 +1940,7 @@ impl HudBatch {
|
||||
color: u32,
|
||||
shadow: bool,
|
||||
) -> f32 {
|
||||
self.styled_text(atlas, text, x, y, color, shadow, false)
|
||||
self.styled_text(atlas, text, x, y, color, shadow, false, None)
|
||||
}
|
||||
/// `text_styled` for text drawn in the world (`TextRenderable.render`
|
||||
/// with `flat` false): each quad's `depth` is its z in text units, the
|
||||
@@ -1944,7 +1956,42 @@ impl HudBatch {
|
||||
color: u32,
|
||||
shadow: bool,
|
||||
) -> f32 {
|
||||
self.styled_text(atlas, text, x, y, color, shadow, true)
|
||||
self.styled_text(atlas, text, x, y, color, shadow, true, None)
|
||||
}
|
||||
/// `Font.drawInBatch8xOutline`: eight glyph offsets in the outline color,
|
||||
/// then the original text. Returns (advance, first front-text vertex), so
|
||||
/// world rendering can use POLYGON_OFFSET for the front and NORMAL for
|
||||
/// its outline. Offsets use each glyph's own shadow offset (unihex: 0.5).
|
||||
pub fn text_styled_outline_world(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
text: &crate::text::Text,
|
||||
x: f32,
|
||||
y: f32,
|
||||
color: u32,
|
||||
outline: u32,
|
||||
) -> (f32, usize) {
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
if dx != 0 || dy != 0 {
|
||||
self.styled_text(
|
||||
atlas,
|
||||
text,
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
false,
|
||||
true,
|
||||
Some((outline, [dx as f32, dy as f32])),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let front = self.vertices.len();
|
||||
(
|
||||
self.styled_text(atlas, text, x, y, color, false, true, None),
|
||||
front,
|
||||
)
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn styled_text(
|
||||
@@ -1956,6 +2003,7 @@ impl HudBatch {
|
||||
color: u32,
|
||||
shadow: bool,
|
||||
world: bool,
|
||||
outline: Option<(u32, [f32; 2])>,
|
||||
) -> f32 {
|
||||
// StringDecomposer.iterateFormatted inside Font.drawInternal:
|
||||
// `§` codes inside a span restyle what follows and are not drawn.
|
||||
@@ -2023,10 +2071,11 @@ impl HudBatch {
|
||||
}
|
||||
self.patches.extend(font.take_patches());
|
||||
let white = font.white();
|
||||
let has_shadow = shadow
|
||||
|| glyphs
|
||||
.iter()
|
||||
.any(|glyph| glyph.3.is_some_and(|color| color != 0));
|
||||
let has_shadow = outline.is_none()
|
||||
&& (shadow
|
||||
|| glyphs
|
||||
.iter()
|
||||
.any(|glyph| glyph.3.is_some_and(|color| color != 0)));
|
||||
let passes: &[bool] = if has_shadow { &[true, false] } else { &[false] };
|
||||
// ARGB.scaleRGB(color, 0.25): the default shadow colour.
|
||||
let shade = |c: u32| {
|
||||
@@ -2061,8 +2110,9 @@ impl HudBatch {
|
||||
} else {
|
||||
shade(*c)
|
||||
};
|
||||
let has_shadow = (custom_shadow.is_some_and(|argb| argb != 0)
|
||||
|| custom_shadow.is_none() && shadow)
|
||||
let has_shadow = outline.is_none()
|
||||
&& (custom_shadow.is_some_and(|argb| argb != 0)
|
||||
|| custom_shadow.is_none() && shadow)
|
||||
&& !(world && shadow_color == 0);
|
||||
if is_shadow && !has_shadow {
|
||||
continue;
|
||||
@@ -2072,7 +2122,11 @@ impl HudBatch {
|
||||
} else {
|
||||
(0.0, *c)
|
||||
};
|
||||
let (px, py) = (*gx + dx, y + dx);
|
||||
let (px, py, col) = if let Some((color, [dx, dy])) = outline {
|
||||
(*gx + dx * g.offset, y + dy * g.offset, color)
|
||||
} else {
|
||||
(*gx + dx, y + dx, col)
|
||||
};
|
||||
if let Some(object) = object {
|
||||
// `PlainTextRenderable.render`: sprite glyphs use the same
|
||||
// shadow colour and offset as text, with the front at SHADOW_DEPTH.
|
||||
@@ -2916,6 +2970,41 @@ mod tests {
|
||||
assert!(check_fetched_size(16_383, 16_383).is_err());
|
||||
}
|
||||
|
||||
/// A fetched PNG is refused by the size its own header claims, before the
|
||||
/// reader makes the pixel buffer: a few hundred bytes of payload would
|
||||
/// otherwise ask for 64 MiB or much more.
|
||||
#[test]
|
||||
fn a_fetched_png_is_refused_by_the_size_its_header_claims() {
|
||||
let mut encoded = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut encoded, 2, 2);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer.write_image_data(&[7; 16]).unwrap();
|
||||
writer.finish().unwrap();
|
||||
}
|
||||
assert!(matches!(
|
||||
ImageFormat::sniff(&encoded),
|
||||
Some(ImageFormat::Png)
|
||||
));
|
||||
assert_eq!(
|
||||
decode_image_bytes(&encoded).expect("a small PNG decodes").0,
|
||||
2
|
||||
);
|
||||
|
||||
// The same image, its IHDR claiming one pixel row past the limit: the
|
||||
// CRC is ignored on purpose, so this stands in for a hostile header.
|
||||
let ihdr = encoded.windows(4).position(|w| w == b"IHDR").expect("IHDR");
|
||||
encoded[ihdr + 4..ihdr + 8].copy_from_slice(&4097u32.to_be_bytes());
|
||||
encoded[ihdr + 8..ihdr + 12].copy_from_slice(&4096u32.to_be_bytes());
|
||||
let refused = decode_image_bytes(&encoded).expect_err("the size is refused");
|
||||
assert!(
|
||||
refused.to_string().contains("too large to decode"),
|
||||
"{refused}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Only the network path reads WebP and JPEG: a resource pack's texture
|
||||
/// stays PNG-only, as vanilla's `NativeImage.read` is.
|
||||
#[test]
|
||||
@@ -3819,6 +3908,44 @@ texture=wide",
|
||||
assert_eq!(fonts.font.width("abba"), width);
|
||||
}
|
||||
#[test]
|
||||
fn outlined_world_text_keeps_font_metrics_and_overrides_outline_color() {
|
||||
let root = two_glyph_font("world-outline");
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
let text = crate::text::Text::colored("ab", 0xff0000);
|
||||
let outline = rgba(1, 2, 3, 128);
|
||||
let (advance, front) =
|
||||
batch.text_styled_outline_world(&atlas, &text, 10.0, 20.0, u32::MAX, outline);
|
||||
assert_eq!((advance, front, batch.vertices.len()), (10.0, 96, 108));
|
||||
assert!(batch.vertices[..front].iter().all(|v| v.color == outline));
|
||||
assert!(
|
||||
batch.vertices[front..]
|
||||
.iter()
|
||||
.all(|v| v.color == rgba(255, 0, 0, 255))
|
||||
);
|
||||
// Eight independently offset silhouettes, retaining both glyphs and
|
||||
// the bitmap font's advance (a=4, b=6); front text has no shadow.
|
||||
for (i, [dx, dy]) in [
|
||||
[-1.0, -1.0],
|
||||
[-1.0, 0.0],
|
||||
[-1.0, 1.0],
|
||||
[0.0, -1.0],
|
||||
[0.0, 1.0],
|
||||
[1.0, -1.0],
|
||||
[1.0, 0.0],
|
||||
[1.0, 1.0],
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let at = batch.vertices[i * 12].pos;
|
||||
let main = batch.vertices[front].pos;
|
||||
assert_eq!(at, [main[0] + dx, main[1] + dy]);
|
||||
assert_eq!(batch.vertices[i * 12 + 6].pos[0] - at[0], 4.0);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn obfuscated_glyphs_keep_the_width() {
|
||||
let root = two_glyph_font("obfuscated");
|
||||
let fonts = HudAtlas::fonts(&root).unwrap();
|
||||
|
||||
@@ -107,6 +107,7 @@ mod sprite_cache;
|
||||
pub mod sprites;
|
||||
pub mod streaming;
|
||||
pub mod structure_block;
|
||||
pub mod studio_ui;
|
||||
pub mod tab_list;
|
||||
pub mod telemetry;
|
||||
pub mod test_block;
|
||||
|
||||
@@ -354,7 +354,9 @@ fn heightmap(column: &Column, mut fluid: impl FnMut(&Span) -> bool) -> Column {
|
||||
if !fluid(top) {
|
||||
spans.push(ground(top));
|
||||
} else if let Some(under) = below.iter().rev().find(|&span| !fluid(span)) {
|
||||
// Shaded water is a span per light level; the bed is under all of it.
|
||||
// The bed is under all of the water, however many spans the water
|
||||
// takes: a chunk's own columns shade it a span a light level, a
|
||||
// previewed far column one span for the run.
|
||||
spans.push(ground(under));
|
||||
spans.push(Span {
|
||||
min: under.max,
|
||||
|
||||
@@ -1280,6 +1280,8 @@ pub enum Page {
|
||||
/// Horizon Settings: every page Horizon adds, as a mod's settings
|
||||
/// screen hangs off vanilla's Options.
|
||||
Horizon,
|
||||
/// Replay Studio: saved recordings browser and 3D video editor launcher.
|
||||
Studio,
|
||||
/// AI Agent: agent control on or off, whether an agent is connected, the connection details.
|
||||
Agent,
|
||||
/// SkinCustomizationScreen: the skin layers and Main Hand.
|
||||
@@ -1507,6 +1509,8 @@ pub enum Event {
|
||||
ListMusicTracks,
|
||||
/// A Music page row: play this track of this event now.
|
||||
PlayMusicTrack(String, String),
|
||||
/// Open the recordings folder in the system file browser.
|
||||
OpenStudioFolder,
|
||||
}
|
||||
/// A row of the Music page: the track, and the pool it is played from.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -1608,6 +1612,7 @@ pub struct OptionsUi {
|
||||
/// Whether the window was fullscreen before calibration made it so (the game keeps it).
|
||||
pub calibration_fullscreen: Option<bool>,
|
||||
pub split_setup: crate::split_ui::SplitUi,
|
||||
pub studio: crate::studio_ui::StudioUi,
|
||||
/// The AI Agent page's status lines (set by the game while it shows).
|
||||
pub agent_status: Vec<String>,
|
||||
/// A repro recording is running: the AI Agent page's button says stop.
|
||||
@@ -1675,6 +1680,7 @@ impl Default for OptionsUi {
|
||||
calibration: None,
|
||||
calibration_fullscreen: None,
|
||||
split_setup: Default::default(),
|
||||
studio: Default::default(),
|
||||
agent_status: Vec::new(),
|
||||
agent_recording: false,
|
||||
music_groups: Vec::new(),
|
||||
@@ -1740,6 +1746,7 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
|
||||
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Some(match page {
|
||||
Page::Horizon => "horizon.pages.settings.tooltip",
|
||||
Page::Studio => "horizon.pages.studio.tooltip",
|
||||
Page::Graphics => "horizon.pages.graphics.tooltip",
|
||||
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
|
||||
Page::Controller => "horizon.pages.controller.tooltip",
|
||||
@@ -2286,6 +2293,9 @@ impl OptionsUi {
|
||||
if page == Page::SplitProfiles {
|
||||
self.split_profiles.reset();
|
||||
}
|
||||
if page == Page::Studio {
|
||||
self.studio.refresh();
|
||||
}
|
||||
}
|
||||
/// What the graphics screen's actions do to these options.
|
||||
fn graphics_events(
|
||||
@@ -2355,6 +2365,7 @@ impl OptionsUi {
|
||||
Page::Pvp => "horizon.pages.pvp",
|
||||
Page::Credits => "credits_and_attribution.screen.title",
|
||||
Page::Horizon => "horizon.pages.settings",
|
||||
Page::Studio => "horizon.pages.studio",
|
||||
Page::Agent => "horizon.pages.agent",
|
||||
Page::SplitProfiles => "horizon.pages.split_profiles",
|
||||
Page::SplitSetup => "horizon.pages.split_setup",
|
||||
@@ -2550,6 +2561,7 @@ impl OptionsUi {
|
||||
),
|
||||
Some(open("horizon.pages.open.pvp", Page::Pvp)),
|
||||
),
|
||||
Row::Buttons(open("horizon.pages.open.studio", Page::Studio), None),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::DiscordPresence),
|
||||
@@ -3669,6 +3681,10 @@ impl OptionsUi {
|
||||
.draw(batch, atlas, cursor, self.in_world, self.pad_style);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::Studio {
|
||||
self.studio.draw(batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::Packs {
|
||||
self.watch_data_packs(atlas, (w, h));
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
|
||||
@@ -4056,6 +4072,22 @@ impl OptionsUi {
|
||||
}
|
||||
};
|
||||
}
|
||||
if self.page() == Page::Studio {
|
||||
if right || drag {
|
||||
return Vec::new();
|
||||
}
|
||||
use crate::studio_ui::StudioAction;
|
||||
return match self.studio.click((x, y), size) {
|
||||
StudioAction::None => Vec::new(),
|
||||
StudioAction::Refresh => vec![Event::ButtonClick],
|
||||
StudioAction::OpenFolder => vec![Event::ButtonClick, Event::OpenStudioFolder],
|
||||
StudioAction::Back => {
|
||||
let mut events = vec![Event::ButtonClick];
|
||||
events.extend(self.back(s));
|
||||
events
|
||||
}
|
||||
};
|
||||
}
|
||||
if self.page() == Page::HdrCalibration
|
||||
&& let Some(c) = &mut self.calibration
|
||||
{
|
||||
@@ -4556,6 +4588,10 @@ impl OptionsUi {
|
||||
let actions = self.graphics.wheel(s, size, cursor, framebuffer, amount);
|
||||
return self.graphics_events(s, actions);
|
||||
}
|
||||
if self.page() == Page::Studio {
|
||||
self.studio.wheel(amount);
|
||||
return Vec::new();
|
||||
}
|
||||
let plain = self.resource_error.is_none()
|
||||
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
|
||||
// The "Scroll wheel changes settings" extra off: the wheel only scrolls.
|
||||
@@ -7641,6 +7677,7 @@ mod tests {
|
||||
Page::Mods,
|
||||
Page::SplitSetup,
|
||||
Page::Pvp,
|
||||
Page::Studio,
|
||||
Page::Agent,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -709,15 +709,24 @@ pub fn lookup(
|
||||
];
|
||||
for (kind, url) in urls {
|
||||
let Some(url) = url else { continue };
|
||||
// registerTexture computes the hash first; a bad URL fails the lookup.
|
||||
let hash = texture_hash(url)?;
|
||||
// registerTexture computes the hash first. A cape or elytra URL the
|
||||
// hash cannot take is that texture's problem alone: vanilla's lookup
|
||||
// goes on to the next, and only the skin texture decides the default.
|
||||
let Some(hash) = texture_hash(url) else {
|
||||
if kind == Kind::Skin {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if kind == Kind::Skin {
|
||||
skin = Some(skin_location(&hash));
|
||||
}
|
||||
match download(kind, url) {
|
||||
Download::Failed => return None,
|
||||
// A cape or elytra that cannot be fetched is simply not drawn; only
|
||||
// the skin itself falls back to the default one (`SkinManager.get`).
|
||||
Download::Failed if kind == Kind::Skin => return None,
|
||||
Download::Failed | Download::Ready => {}
|
||||
Download::Pending => state = Download::Pending,
|
||||
Download::Ready => {}
|
||||
}
|
||||
}
|
||||
if state != Download::Ready || require_secure && !textures.signed {
|
||||
@@ -809,6 +818,12 @@ pub struct Downloader {
|
||||
resource_root: std::path::PathBuf,
|
||||
/// How each `patch/` texture of the resource packs loaded, by location.
|
||||
resources: std::collections::HashMap<String, Download>,
|
||||
/// When each failed texture download was last tried, so it can be retried:
|
||||
/// a transient network error must not pin a player to the default skin for
|
||||
/// the rest of the session.
|
||||
failed: std::collections::HashMap<(Kind, String), std::time::Instant>,
|
||||
/// How long a failed texture download waits before it is tried again.
|
||||
retry_after: std::time::Duration,
|
||||
}
|
||||
/// `images` is bounded only so a long session on a busy server does not grow
|
||||
/// without end; the disk cache holds everything it drops.
|
||||
@@ -839,6 +854,8 @@ impl Downloader {
|
||||
drawn: Default::default(),
|
||||
resource_root: Default::default(),
|
||||
resources: Default::default(),
|
||||
failed: Default::default(),
|
||||
retry_after: std::time::Duration::from_secs(60),
|
||||
}
|
||||
}
|
||||
/// The resource packs changed (or were first loaded) at `root`: patch
|
||||
@@ -879,7 +896,8 @@ impl Downloader {
|
||||
};
|
||||
self.resources.insert(location.to_owned(), state);
|
||||
}
|
||||
/// How the download of `url` stands, starting it the first time.
|
||||
/// How the download of `url` stands, starting it the first time. A
|
||||
/// failure is permanent for `retry_after`, then tried again.
|
||||
pub fn state(&mut self, kind: Kind, url: &str) -> Download {
|
||||
while let Ok((key, image)) = self.done.try_recv() {
|
||||
let state = match image {
|
||||
@@ -889,9 +907,13 @@ impl Downloader {
|
||||
Kind::Cape => self.queue(cape_location(&key.1), rgba),
|
||||
Kind::Elytra => {}
|
||||
}
|
||||
self.failed.remove(&key);
|
||||
Download::Ready
|
||||
}
|
||||
None => Download::Failed,
|
||||
None => {
|
||||
self.failed.insert(key.clone(), std::time::Instant::now());
|
||||
Download::Failed
|
||||
}
|
||||
};
|
||||
self.states.insert(key, state);
|
||||
}
|
||||
@@ -899,34 +921,57 @@ impl Downloader {
|
||||
return Download::Failed;
|
||||
};
|
||||
let key = (kind, hash);
|
||||
if let Some(state) = self.states.get(&key) {
|
||||
return *state;
|
||||
if let Some(state) = self.states.get(&key).copied() {
|
||||
let due = self
|
||||
.failed
|
||||
.get(&key)
|
||||
.is_none_or(|at| at.elapsed() >= self.retry_after);
|
||||
if state != Download::Failed || !due {
|
||||
return state;
|
||||
}
|
||||
self.states.remove(&key);
|
||||
self.failed.remove(&key);
|
||||
}
|
||||
self.states.insert(key.clone(), Download::Pending);
|
||||
let digest: String = skin_location(&key.1)["skins/".len()..].to_owned();
|
||||
let file = self
|
||||
.root
|
||||
.join(if digest.len() > 2 { &digest[..2] } else { "xx" })
|
||||
.join(&digest);
|
||||
let (url, send) = (url.to_owned(), self.send.clone());
|
||||
let (url, send, root) = (url.to_owned(), self.send.clone(), self.root.clone());
|
||||
std::thread::spawn(move || {
|
||||
let bytes = std::fs::read(&file).ok().or_else(|| {
|
||||
let body = http_get(&url)?;
|
||||
let _ = std::fs::create_dir_all(file.parent()?);
|
||||
let _ = std::fs::write(&file, &body);
|
||||
Some(body)
|
||||
});
|
||||
let image = bytes
|
||||
.and_then(|b| crate::hud::decode_bytes(&b).ok())
|
||||
.and_then(|(w, h, rgba)| match kind {
|
||||
Kind::Skin => process_legacy_skin(w, h, &rgba),
|
||||
Kind::Cape => process_cape(w, h, &rgba),
|
||||
Kind::Elytra => Some(rgba),
|
||||
});
|
||||
let image = Self::load_texture(&root, kind, &digest, &url, http_get);
|
||||
let _ = send.send((key, image));
|
||||
});
|
||||
Download::Pending
|
||||
}
|
||||
/// One texture's pixels: the disk cache's body if it decodes, else a fetch
|
||||
/// (`SkinTextureDownloader.downloadSkin`). Only a body that decodes is
|
||||
/// written to the cache: an error page or a half image must not be what a
|
||||
/// later start reads instead of fetching again.
|
||||
fn load_texture(
|
||||
root: &std::path::Path,
|
||||
kind: Kind,
|
||||
digest: &str,
|
||||
url: &str,
|
||||
fetch: impl FnOnce(&str) -> Option<Vec<u8>>,
|
||||
) -> Option<Vec<u8>> {
|
||||
let file = root
|
||||
.join(if digest.len() > 2 { &digest[..2] } else { "xx" })
|
||||
.join(digest);
|
||||
let decode = |bytes: &[u8]| -> Option<Vec<u8>> {
|
||||
let (w, h, rgba) = crate::hud::decode_bytes(bytes).ok()?;
|
||||
match kind {
|
||||
Kind::Skin => process_legacy_skin(w, h, &rgba),
|
||||
Kind::Cape => process_cape(w, h, &rgba),
|
||||
Kind::Elytra => Some(rgba),
|
||||
}
|
||||
};
|
||||
if let Some(image) = std::fs::read(&file).ok().and_then(|b| decode(&b)) {
|
||||
return Some(image);
|
||||
}
|
||||
let body = fetch(url)?;
|
||||
let image = decode(&body)?;
|
||||
let _ = std::fs::create_dir_all(file.parent()?);
|
||||
let _ = std::fs::write(&file, &body);
|
||||
Some(image)
|
||||
}
|
||||
/// Keeps a decoded skin for `again`, dropping the oldest past the bound.
|
||||
fn remember(&mut self, location: String, rgba: Vec<u8>) {
|
||||
if !self.images.contains_key(&location) {
|
||||
@@ -1516,4 +1561,156 @@ mod tests {
|
||||
"abc"
|
||||
);
|
||||
}
|
||||
|
||||
/// A cape or elytra that cannot be fetched takes only itself out of the
|
||||
/// picture: the player's skin still shows. Only the skin texture's own
|
||||
/// failure is the default skin.
|
||||
#[test]
|
||||
fn a_failed_cape_does_not_hide_the_skin() {
|
||||
let textures = super::Textures {
|
||||
skin: Some("https://textures.minecraft.net/texture/skin1".into()),
|
||||
cape: Some("https://textures.minecraft.net/texture/cape1".into()),
|
||||
elytra: None,
|
||||
signed: true,
|
||||
slim: false,
|
||||
};
|
||||
let skin = super::lookup(&textures, true, |kind, _| match kind {
|
||||
super::Kind::Skin => super::Download::Ready,
|
||||
_ => super::Download::Failed,
|
||||
});
|
||||
assert_eq!(skin, Some(super::skin_location("skin1")));
|
||||
// A cape URL the hash cannot take is the same: the skin shows.
|
||||
let bad_url = super::Textures {
|
||||
cape: Some("no-scheme".into()),
|
||||
..textures.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
super::lookup(&bad_url, true, |_, _| super::Download::Ready),
|
||||
Some(super::skin_location("skin1"))
|
||||
);
|
||||
// The skin itself failing is still the default skin.
|
||||
assert_eq!(
|
||||
super::lookup(&textures, true, |_, _| super::Download::Failed),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// A 1x1 PNG of `rgba`.
|
||||
fn png(rgba: [u8; 4]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut out, 1, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer.write_image_data(&rgba).unwrap();
|
||||
writer.finish().unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// A downloaded body reaches the disk cache only if it decodes; a broken
|
||||
/// cached file is fetched again and replaced. A failed fetch writes nothing.
|
||||
#[test]
|
||||
fn the_texture_cache_holds_only_what_decodes() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-test-skin-cache-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let body = png([7, 8, 9, 255]);
|
||||
let file = root.join("ab").join("abcdef");
|
||||
let load = |fetched: Option<&[u8]>| {
|
||||
let fetched = fetched.map(<[u8]>::to_vec);
|
||||
super::Downloader::load_texture(
|
||||
&root,
|
||||
super::Kind::Elytra,
|
||||
"abcdef",
|
||||
"https://example/texture/abc.png",
|
||||
|_| fetched.clone(),
|
||||
)
|
||||
};
|
||||
assert_eq!(load(Some(&body)), Some(vec![7, 8, 9, 255]));
|
||||
assert_eq!(std::fs::read(&file).unwrap(), body);
|
||||
// From then on the cache answers; no fetch is needed.
|
||||
assert_eq!(load(None), Some(vec![7, 8, 9, 255]));
|
||||
// A broken file is fetched again and replaced, not kept.
|
||||
std::fs::write(&file, b"<html>not a png</html>").unwrap();
|
||||
assert_eq!(load(Some(&body)), Some(vec![7, 8, 9, 255]));
|
||||
assert_eq!(std::fs::read(&file).unwrap(), body);
|
||||
// A body that does not decode is not cached at all.
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert_eq!(load(Some(b"<html>not a png</html>")), None);
|
||||
assert!(!file.exists());
|
||||
}
|
||||
|
||||
fn wait_state(
|
||||
d: &mut super::Downloader,
|
||||
kind: super::Kind,
|
||||
url: &str,
|
||||
want: super::Download,
|
||||
) -> super::Download {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
let state = d.state(kind, url);
|
||||
if state == want || std::time::Instant::now() > deadline {
|
||||
return state;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// A texture download that failed is tried again after the wait, instead of
|
||||
/// pinning the player to the default skin for the rest of the session.
|
||||
#[test]
|
||||
fn a_failed_download_is_retried_after_the_wait() {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let body = png([1, 2, 3, 4]);
|
||||
let served = std::sync::Arc::new(AtomicUsize::new(0));
|
||||
let count = served.clone();
|
||||
std::thread::spawn(move || {
|
||||
for (i, stream) in listener.incoming().take(2).enumerate() {
|
||||
let mut stream = stream.unwrap();
|
||||
let mut line = String::new();
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
reader.read_line(&mut line).unwrap();
|
||||
while reader.read_line(&mut String::new()).unwrap() > 2 {}
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
if i == 0 {
|
||||
stream
|
||||
.write_all(
|
||||
b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
let head = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
stream.write_all(head.as_bytes()).unwrap();
|
||||
stream.write_all(&body).unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-test-skin-retry-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let mut d = super::Downloader::new(root);
|
||||
let url = format!("{base}/texture/retry.png");
|
||||
assert_eq!(d.state(super::Kind::Elytra, &url), super::Download::Pending);
|
||||
assert_eq!(
|
||||
wait_state(&mut d, super::Kind::Elytra, &url, super::Download::Failed),
|
||||
super::Download::Failed
|
||||
);
|
||||
// Within the wait the failure stands: no request goes out.
|
||||
assert_eq!(d.state(super::Kind::Elytra, &url), super::Download::Failed);
|
||||
assert_eq!(served.load(Ordering::SeqCst), 1);
|
||||
// After it, the download is started again and takes the good answer.
|
||||
d.retry_after = std::time::Duration::ZERO;
|
||||
assert_eq!(d.state(super::Kind::Elytra, &url), super::Download::Pending);
|
||||
assert_eq!(
|
||||
wait_state(&mut d, super::Kind::Elytra, &url, super::Download::Ready),
|
||||
super::Download::Ready
|
||||
);
|
||||
assert_eq!(served.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,10 @@ impl SoundEntity {
|
||||
pub enum Ticking {
|
||||
/// Not tickable.
|
||||
No,
|
||||
/// A compiled native owner updates this instance through `instance_mut`.
|
||||
/// The ordinary engine applies gain, pitch and position each unpaused tick;
|
||||
/// no vanilla entity behavior overwrites them. Stop and release when done.
|
||||
Native,
|
||||
/// `EntityBoundSoundInstance`.
|
||||
EntityBound,
|
||||
/// `BiomeAmbientSoundsHandler.LoopSoundInstance`.
|
||||
@@ -611,7 +615,7 @@ impl SoundInstance {
|
||||
/// `TickableSoundInstance.tick`.
|
||||
pub fn tick(&mut self, ctx: &TickContext) {
|
||||
match &mut self.ticking {
|
||||
Ticking::No | Ticking::MusicLayer => {}
|
||||
Ticking::No | Ticking::Native | Ticking::MusicLayer => {}
|
||||
Ticking::EntityBound => {
|
||||
let entity = self
|
||||
.bound_entity
|
||||
@@ -1552,6 +1556,8 @@ pub mod test_backend {
|
||||
next: ChannelId,
|
||||
pub stopped: HashMap<ChannelId, bool>,
|
||||
pub volume: HashMap<ChannelId, f32>,
|
||||
pub pitch: HashMap<ChannelId, f32>,
|
||||
pub position: HashMap<ChannelId, [f64; 3]>,
|
||||
}
|
||||
impl Backend for Null {
|
||||
fn create(&mut self, _streaming: bool) -> Option<ChannelId> {
|
||||
@@ -1562,13 +1568,17 @@ pub mod test_backend {
|
||||
fn destroy(&mut self, c: ChannelId) {
|
||||
self.stopped.remove(&c);
|
||||
}
|
||||
fn set_pitch(&mut self, _: ChannelId, _: f32) {}
|
||||
fn set_pitch(&mut self, c: ChannelId, value: f32) {
|
||||
self.pitch.insert(c, value);
|
||||
}
|
||||
fn set_volume(&mut self, c: ChannelId, v: f32) {
|
||||
self.volume.insert(c, v);
|
||||
}
|
||||
fn set_attenuation(&mut self, _: ChannelId, _: Option<f32>) {}
|
||||
fn set_looping(&mut self, _: ChannelId, _: bool) {}
|
||||
fn set_position(&mut self, _: ChannelId, _: [f64; 3]) {}
|
||||
fn set_position(&mut self, c: ChannelId, value: [f64; 3]) {
|
||||
self.position.insert(c, value);
|
||||
}
|
||||
fn set_relative(&mut self, _: ChannelId, _: bool) {}
|
||||
fn start(&mut self, _: ChannelId, _: &Sound, _: bool) {}
|
||||
fn stop(&mut self, c: ChannelId) {
|
||||
@@ -1586,6 +1596,76 @@ pub mod test_backend {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn native_owned_loop_applies_updates_and_stops_through_the_ordinary_engine() {
|
||||
let mut registry = SoundRegistry::default();
|
||||
registry.add_file(
|
||||
"example",
|
||||
br#"{"loop":{"sounds":["machine/loop"]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
let mut engine = SoundEngine::new(registry);
|
||||
let mut backend = test_backend::Null::default();
|
||||
let mut instance = SoundInstance::new(
|
||||
"example:loop",
|
||||
4,
|
||||
0.1,
|
||||
0.5,
|
||||
LegacyRandom::new(1),
|
||||
true,
|
||||
0,
|
||||
Attenuation::None,
|
||||
[0.0; 3],
|
||||
true,
|
||||
);
|
||||
instance.ticking = Ticking::Native;
|
||||
let (id, result) = engine.play_new(instance, true, &mut backend);
|
||||
assert_eq!(result, PlayResult::Started);
|
||||
let channel = engine.channel(id).unwrap();
|
||||
assert_eq!(backend.volume[&channel], 0.1);
|
||||
let owned = engine.instance_mut(id).unwrap();
|
||||
owned.volume = 0.4;
|
||||
owned.pitch = 1.25;
|
||||
owned.position = [2.0, 3.0, 4.0];
|
||||
engine.tick(true, &TickContext::default(), &mut backend);
|
||||
assert_eq!(
|
||||
backend.volume[&channel], 0.1,
|
||||
"paused world sounds retain channel values"
|
||||
);
|
||||
assert_eq!(backend.pitch[&channel], 0.5);
|
||||
assert_eq!(backend.position[&channel], [0.0; 3]);
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
assert_eq!(backend.volume[&channel], 0.4);
|
||||
assert_eq!(backend.pitch[&channel], 1.25);
|
||||
assert_eq!(backend.position[&channel], [2.0, 3.0, 4.0]);
|
||||
let owned = engine.instance(id).unwrap();
|
||||
assert_eq!((owned.pitch, owned.position), (1.25, [2.0, 3.0, 4.0]));
|
||||
let replacement = owned.clone();
|
||||
assert!(engine.is_active(id));
|
||||
engine.stop(id, &mut backend);
|
||||
assert_eq!(backend.stopped.get(&channel), Some(&true));
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
engine.release(id);
|
||||
for _ in 0..20 {
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
}
|
||||
assert!(engine.instance(id).is_none());
|
||||
assert!(!engine.is_active(id));
|
||||
assert!(!backend.stopped.contains_key(&channel));
|
||||
|
||||
let (id, result) = engine.play_new(replacement, true, &mut backend);
|
||||
assert_eq!(result, PlayResult::Started);
|
||||
engine.stop_all(&mut backend);
|
||||
assert!(!engine.is_active(id));
|
||||
assert!(backend.stopped.is_empty());
|
||||
assert!(engine.instance(id).is_some(), "the owner still retains it");
|
||||
engine.release(id);
|
||||
assert!(engine.instance(id).is_none());
|
||||
assert_eq!(engine.census()["playing"], 0);
|
||||
assert_eq!(engine.census()["ticking"], 0);
|
||||
assert_eq!(engine.census()["handles"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn happy_ghast_riding_sound_follows_the_ghast_speed() {
|
||||
let ghast = RiddenSound {
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
//! Replay Studio Browser & Saved Recordings UI
|
||||
//!
|
||||
//! Provides an in-game graphical browser to inspect saved `.mcpr` recordings,
|
||||
//! view recording metadata (duration, packet count, timestamp, server),
|
||||
//! and open the recordings directory.
|
||||
|
||||
use crate::{
|
||||
graphics_style as style,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
lang,
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// High-level session metadata read from `metaData.json` inside `.mcpr`.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RecordingMetadata {
|
||||
#[serde(default)]
|
||||
pub server_name: String,
|
||||
#[serde(default)]
|
||||
pub custom_server_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub singleplayer: bool,
|
||||
#[serde(default)]
|
||||
pub duration: u32,
|
||||
#[serde(default)]
|
||||
pub date: u64,
|
||||
#[serde(default)]
|
||||
pub mcversion: String,
|
||||
#[serde(default)]
|
||||
pub file_format: String,
|
||||
#[serde(default)]
|
||||
pub file_format_version: u32,
|
||||
#[serde(default)]
|
||||
pub generator: String,
|
||||
#[serde(default)]
|
||||
pub players: Vec<String>,
|
||||
}
|
||||
|
||||
/// A saved replay item displayed in the Studio Browser list.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SavedRecordingItem {
|
||||
pub path: PathBuf,
|
||||
pub filename: String,
|
||||
pub title: String,
|
||||
pub duration_ms: u32,
|
||||
pub date: u64,
|
||||
pub file_size: u64,
|
||||
pub server_name: String,
|
||||
pub singleplayer: bool,
|
||||
}
|
||||
|
||||
impl SavedRecordingItem {
|
||||
/// Formatted duration string `MM:SS` or `HH:MM:SS`.
|
||||
pub fn formatted_duration(&self) -> String {
|
||||
let total_secs = self.duration_ms / 1000;
|
||||
let hours = total_secs / 3600;
|
||||
let mins = (total_secs % 3600) / 60;
|
||||
let secs = total_secs % 60;
|
||||
if hours > 0 {
|
||||
format!("{hours:02}:{mins:02}:{secs:02}")
|
||||
} else {
|
||||
format!("{mins:02}:{secs:02}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Formatted file size string (KB or MB).
|
||||
pub fn formatted_size(&self) -> String {
|
||||
let bytes = self.file_size;
|
||||
if bytes >= 1024 * 1024 {
|
||||
let mb = bytes as f64 / (1024.0 * 1024.0);
|
||||
format!("{mb:.1} MB")
|
||||
} else {
|
||||
let kb = bytes.div_ceil(1024);
|
||||
format!("{kb} KB")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User actions triggered by the Studio Browser UI.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum StudioAction {
|
||||
None,
|
||||
OpenFolder,
|
||||
Refresh,
|
||||
Back,
|
||||
}
|
||||
|
||||
/// The Studio Replay Browser screen state and renderer.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StudioUi {
|
||||
pub recordings: Vec<SavedRecordingItem>,
|
||||
pub selected: Option<usize>,
|
||||
pub scroll: f32,
|
||||
pub search: String,
|
||||
pub record_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for StudioUi {
|
||||
fn default() -> Self {
|
||||
let dir = PathBuf::from("recordings");
|
||||
let mut ui = Self {
|
||||
recordings: Vec::new(),
|
||||
selected: None,
|
||||
scroll: 0.0,
|
||||
search: String::new(),
|
||||
record_dir: dir.clone(),
|
||||
};
|
||||
ui.scan(&dir);
|
||||
ui
|
||||
}
|
||||
}
|
||||
|
||||
impl StudioUi {
|
||||
/// Create a new StudioUi for the given recordings directory.
|
||||
pub fn new(record_dir: impl Into<PathBuf>) -> Self {
|
||||
let dir = record_dir.into();
|
||||
let mut ui = Self {
|
||||
recordings: Vec::new(),
|
||||
selected: None,
|
||||
scroll: 0.0,
|
||||
search: String::new(),
|
||||
record_dir: dir.clone(),
|
||||
};
|
||||
ui.scan(&dir);
|
||||
ui
|
||||
}
|
||||
|
||||
/// Re-scan the recordings folder.
|
||||
pub fn refresh(&mut self) {
|
||||
self.scan(&self.record_dir.clone());
|
||||
}
|
||||
|
||||
/// Read metadata from a `.mcpr` archive without extracting the full packet stream.
|
||||
pub fn read_metadata(path: &Path) -> Option<RecordingMetadata> {
|
||||
let file = File::open(path).ok()?;
|
||||
let mut archive = zip::ZipArchive::new(file).ok()?;
|
||||
let mut meta_file = archive.by_name("metaData.json").ok()?;
|
||||
let mut meta_bytes = Vec::new();
|
||||
meta_file.read_to_end(&mut meta_bytes).ok()?;
|
||||
serde_json::from_slice(&meta_bytes).ok()
|
||||
}
|
||||
|
||||
/// Scan `dir` for `.mcpr` files and parse their metadata.
|
||||
pub fn scan(&mut self, dir: &Path) {
|
||||
self.recordings.clear();
|
||||
self.selected = None;
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("mcpr") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(meta) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
let file_size = meta.len();
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let title = path
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| filename.clone());
|
||||
|
||||
let (duration_ms, date, server_name, singleplayer) =
|
||||
if let Some(parsed) = Self::read_metadata(&path) {
|
||||
let srv = if parsed.singleplayer {
|
||||
lang::get("menu.singleplayer")
|
||||
} else if !parsed.server_name.is_empty() {
|
||||
parsed.server_name
|
||||
} else {
|
||||
lang::get("menu.multiplayer")
|
||||
};
|
||||
(parsed.duration, parsed.date, srv, parsed.singleplayer)
|
||||
} else {
|
||||
let modified = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
(
|
||||
0,
|
||||
modified,
|
||||
lang::get("horizon.studio.unknown_server"),
|
||||
true,
|
||||
)
|
||||
};
|
||||
|
||||
self.recordings.push(SavedRecordingItem {
|
||||
path,
|
||||
filename,
|
||||
title,
|
||||
duration_ms,
|
||||
date,
|
||||
file_size,
|
||||
server_name,
|
||||
singleplayer,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort descending by date (newest recordings first)
|
||||
self.recordings.sort_by_key(|a| std::cmp::Reverse(a.date));
|
||||
if !self.recordings.is_empty() {
|
||||
self.selected = Some(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the Studio Browser screen.
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
in_world: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
|
||||
// Header Title: centered at top
|
||||
style::title(batch, atlas, &Text::tr("horizon.pages.studio", &[]));
|
||||
|
||||
// Subtitle strip: centered info text
|
||||
let subtitle = Text::tr("horizon.studio.saved_recordings", &[]);
|
||||
ui::centered_text(batch, atlas, &subtitle, 0.0, 26.0, w, style::GREY);
|
||||
|
||||
// Main content area layout
|
||||
let panel_w = (w - 40.0).clamp(280.0, 440.0);
|
||||
let panel_x = ((w - panel_w) / 2.0).floor();
|
||||
let panel_y = 42.0;
|
||||
let panel_h = (h - 76.0).max(100.0);
|
||||
|
||||
// Glass panel backdrop for recordings list
|
||||
style::panel(batch, atlas, [panel_x, panel_y, panel_w, panel_h]);
|
||||
|
||||
if self.recordings.is_empty() {
|
||||
// Empty state
|
||||
let empty_text = Text::tr("horizon.studio.empty", &[]);
|
||||
let hint_text = Text::tr("horizon.studio.empty_hint", &[]);
|
||||
ui::centered_text(
|
||||
batch,
|
||||
atlas,
|
||||
&empty_text,
|
||||
panel_x,
|
||||
panel_y + panel_h / 2.0 - 16.0,
|
||||
panel_w,
|
||||
style::WHITE,
|
||||
);
|
||||
ui::centered_text(
|
||||
batch,
|
||||
atlas,
|
||||
&hint_text,
|
||||
panel_x,
|
||||
panel_y + panel_h / 2.0,
|
||||
panel_w,
|
||||
style::GREY,
|
||||
);
|
||||
} else {
|
||||
// Render scrollable cards
|
||||
let card_h = 36.0;
|
||||
let card_gap = 4.0;
|
||||
let row_step = card_h + card_gap;
|
||||
let visible_cards = (panel_h / row_step).floor() as usize;
|
||||
let max_scroll =
|
||||
(self.recordings.len().saturating_sub(visible_cards) as f32 * row_step).max(0.0);
|
||||
self.scroll = self.scroll.clamp(0.0, max_scroll);
|
||||
|
||||
let card_w = panel_w - 12.0;
|
||||
let card_x = panel_x + 6.0;
|
||||
let mut y = panel_y + 6.0 - (self.scroll % row_step);
|
||||
let start_idx = (self.scroll / row_step) as usize;
|
||||
|
||||
for (idx, item) in self.recordings.iter().enumerate().skip(start_idx) {
|
||||
if y + card_h > panel_y + panel_h - 4.0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let rect = [card_x, y, card_w, card_h];
|
||||
let is_hovered = ui::hovered(cursor, card_x, y, card_w, card_h);
|
||||
let is_selected = self.selected == Some(idx);
|
||||
|
||||
// Card background & highlight
|
||||
if is_selected {
|
||||
style::fill(batch, atlas, rect, style::OPEN);
|
||||
} else if is_hovered {
|
||||
style::fill(batch, atlas, rect, style::HOVER);
|
||||
} else {
|
||||
style::well(batch, atlas, rect);
|
||||
}
|
||||
|
||||
let border_color = if is_selected {
|
||||
style::ACCENT
|
||||
} else if is_hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
style::ROW_RULE
|
||||
};
|
||||
style::ring(batch, atlas, rect, border_color);
|
||||
|
||||
// Card Line 1: Recording Title (bold white)
|
||||
let title_text = Text::literal(&item.title);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&title_text,
|
||||
card_x + 8.0,
|
||||
y + 6.0,
|
||||
style::WHITE,
|
||||
true,
|
||||
);
|
||||
|
||||
// Card Line 2: Duration, Size, Server
|
||||
let meta_str = format!(
|
||||
"{} • {} • {}",
|
||||
item.formatted_duration(),
|
||||
item.formatted_size(),
|
||||
item.server_name
|
||||
);
|
||||
let meta_text = Text::literal(&meta_str);
|
||||
batch.text_styled(atlas, &meta_text, card_x + 8.0, y + 20.0, style::GREY, true);
|
||||
|
||||
y += row_step;
|
||||
}
|
||||
|
||||
// Scrollbar track & thumb if scrollable
|
||||
if max_scroll > 0.0 {
|
||||
let track_x = panel_x + panel_w - 4.0;
|
||||
let track_y = panel_y + 2.0;
|
||||
let track_h = panel_h - 4.0;
|
||||
let thumb_h = ((panel_h / (panel_h + max_scroll)) * track_h).max(12.0);
|
||||
let thumb_y = track_y + (self.scroll / max_scroll) * (track_h - thumb_h);
|
||||
style::scroller(
|
||||
batch,
|
||||
atlas,
|
||||
[track_x, track_y, 2.0, track_h],
|
||||
[track_x, thumb_y, 2.0, thumb_h],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom Button Bar
|
||||
let btn_y = h - 28.0;
|
||||
let btn_h = 20.0;
|
||||
|
||||
// Button 1: Open Folder (Left)
|
||||
let f_btn = [panel_x, btn_y, 130.0, btn_h];
|
||||
let f_hover = ui::hovered(cursor, f_btn[0], f_btn[1], f_btn[2], f_btn[3]);
|
||||
style::button(
|
||||
batch,
|
||||
atlas,
|
||||
f_btn,
|
||||
&Text::tr("horizon.studio.open_folder", &[]),
|
||||
(f_hover, true, false),
|
||||
);
|
||||
|
||||
// Button 2: Refresh (Center-Left)
|
||||
let r_btn = [panel_x + 136.0, btn_y, 65.0, btn_h];
|
||||
let r_hover = ui::hovered(cursor, r_btn[0], r_btn[1], r_btn[2], r_btn[3]);
|
||||
style::button(
|
||||
batch,
|
||||
atlas,
|
||||
r_btn,
|
||||
&Text::tr("horizon.studio.refresh", &[]),
|
||||
(r_hover, true, false),
|
||||
);
|
||||
|
||||
// Button 3: Done / Back (Right)
|
||||
let d_btn = [panel_x + panel_w - 64.0, btn_y, 64.0, btn_h];
|
||||
let d_hover = ui::hovered(cursor, d_btn[0], d_btn[1], d_btn[2], d_btn[3]);
|
||||
style::button(
|
||||
batch,
|
||||
atlas,
|
||||
d_btn,
|
||||
&Text::tr("gui.done", &[]),
|
||||
(d_hover, true, false),
|
||||
);
|
||||
}
|
||||
|
||||
/// Process mouse click on the Studio Browser screen.
|
||||
pub fn click(&mut self, cursor: (f32, f32), size: (f32, f32)) -> StudioAction {
|
||||
let (w, h) = size;
|
||||
let panel_w = (w - 40.0).clamp(280.0, 440.0);
|
||||
let panel_x = ((w - panel_w) / 2.0).floor();
|
||||
let panel_y = 42.0;
|
||||
let panel_h = (h - 76.0).max(100.0);
|
||||
let btn_y = h - 28.0;
|
||||
let btn_h = 20.0;
|
||||
|
||||
// Button 1: Open Folder
|
||||
let f_btn = [panel_x, btn_y, 130.0, btn_h];
|
||||
if ui::hovered(cursor, f_btn[0], f_btn[1], f_btn[2], f_btn[3]) {
|
||||
return StudioAction::OpenFolder;
|
||||
}
|
||||
|
||||
// Button 2: Refresh
|
||||
let r_btn = [panel_x + 136.0, btn_y, 65.0, btn_h];
|
||||
if ui::hovered(cursor, r_btn[0], r_btn[1], r_btn[2], r_btn[3]) {
|
||||
self.refresh();
|
||||
return StudioAction::Refresh;
|
||||
}
|
||||
|
||||
// Button 3: Done
|
||||
let d_btn = [panel_x + panel_w - 64.0, btn_y, 64.0, btn_h];
|
||||
if ui::hovered(cursor, d_btn[0], d_btn[1], d_btn[2], d_btn[3]) {
|
||||
return StudioAction::Back;
|
||||
}
|
||||
|
||||
// Card clicks
|
||||
let card_h = 36.0;
|
||||
let card_gap = 4.0;
|
||||
let row_step = card_h + card_gap;
|
||||
let card_w = panel_w - 12.0;
|
||||
let card_x = panel_x + 6.0;
|
||||
let start_idx = (self.scroll / row_step) as usize;
|
||||
let mut y = panel_y + 6.0 - (self.scroll % row_step);
|
||||
|
||||
for (idx, _) in self.recordings.iter().enumerate().skip(start_idx) {
|
||||
if y + card_h > panel_y + panel_h - 4.0 {
|
||||
break;
|
||||
}
|
||||
|
||||
if ui::hovered(cursor, card_x, y, card_w, card_h) {
|
||||
self.selected = Some(idx);
|
||||
return StudioAction::None;
|
||||
}
|
||||
|
||||
y += row_step;
|
||||
}
|
||||
|
||||
StudioAction::None
|
||||
}
|
||||
|
||||
/// Scroll list by mouse wheel delta.
|
||||
pub fn wheel(&mut self, amount: f32) {
|
||||
self.scroll = (self.scroll - amount * 24.0).max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn formatted_duration_and_size() {
|
||||
let item = SavedRecordingItem {
|
||||
path: PathBuf::from("recordings/test.mcpr"),
|
||||
filename: "test.mcpr".to_string(),
|
||||
title: "test".to_string(),
|
||||
duration_ms: 125_000,
|
||||
date: 1_700_000_000,
|
||||
file_size: 4_500_000,
|
||||
server_name: "Local World".to_string(),
|
||||
singleplayer: true,
|
||||
};
|
||||
assert_eq!(item.formatted_duration(), "02:05");
|
||||
assert_eq!(item.formatted_size(), "4.3 MB");
|
||||
|
||||
let short_item = SavedRecordingItem {
|
||||
path: PathBuf::from("recordings/short.mcpr"),
|
||||
filename: "short.mcpr".to_string(),
|
||||
title: "short".to_string(),
|
||||
duration_ms: 45_000,
|
||||
date: 1_700_000_000,
|
||||
file_size: 512 * 1024,
|
||||
server_name: "Hypixel".to_string(),
|
||||
singleplayer: false,
|
||||
};
|
||||
assert_eq!(short_item.formatted_duration(), "00:45");
|
||||
assert_eq!(short_item.formatted_size(), "512 KB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_and_select_recordings() {
|
||||
let temp_dir = std::env::temp_dir().join(format!("studio_test_{}", std::process::id()));
|
||||
let _ = fs::create_dir_all(&temp_dir);
|
||||
|
||||
let rec_path = temp_dir.join("sample.mcpr");
|
||||
{
|
||||
let file = File::create(&rec_path).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
zip.start_file("metaData.json", options).unwrap();
|
||||
let meta = RecordingMetadata {
|
||||
server_name: "Test Server".to_string(),
|
||||
duration: 65_000,
|
||||
date: 1_700_000_500,
|
||||
..Default::default()
|
||||
};
|
||||
let json_bytes = serde_json::to_vec(&meta).unwrap();
|
||||
zip.write_all(&json_bytes).unwrap();
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
|
||||
let mut ui = StudioUi::new(&temp_dir);
|
||||
assert_eq!(ui.recordings.len(), 1);
|
||||
assert_eq!(ui.recordings[0].title, "sample");
|
||||
assert_eq!(ui.recordings[0].duration_ms, 65_000);
|
||||
assert_eq!(ui.recordings[0].server_name, "Test Server");
|
||||
assert_eq!(ui.selected, Some(0));
|
||||
|
||||
// Clicking a card selects it for inspection without opening the editor.
|
||||
let action = ui.click((200.0, 60.0), (427.0, 720.0));
|
||||
assert_eq!(action, StudioAction::None);
|
||||
assert_eq!(ui.selected, Some(0));
|
||||
|
||||
// Clean up
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn studio_wheel_scrolling() {
|
||||
let mut ui = StudioUi::default();
|
||||
ui.wheel(2.0);
|
||||
assert_eq!(ui.scroll, 0.0); // clamped at 0
|
||||
ui.scroll = 100.0;
|
||||
ui.wheel(1.0);
|
||||
assert_eq!(ui.scroll, 76.0);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,12 @@
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Diese Funktion ist in Horizon noch nicht verfügbar.",
|
||||
"horizon.options.search": "Einstellungen durchsuchen …",
|
||||
"horizon.studio.record.usage": "Verwendung: /studio record <start [name]|stop|status>",
|
||||
"horizon.studio.record.started": "Studio-Aufzeichnung gestartet: %s",
|
||||
"horizon.studio.record.saved": "Studio-Aufzeichnung gespeichert: %s",
|
||||
"horizon.studio.record.error": "Studio-Aufzeichnungsfehler: %s",
|
||||
"horizon.studio.record.status.active": "Studio-Aufzeichnung aktiv — %s s, %s Pakete, %s KB (%s)",
|
||||
"horizon.studio.record.status.inactive": "Studio-Aufzeichnung inaktiv",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Shader-Pakete",
|
||||
"horizon.pages.shader_options": "Shader-Optionen",
|
||||
@@ -52,6 +58,7 @@
|
||||
"horizon.pages.open.horizon_graphics": "Horizon-Grafik …",
|
||||
"horizon.pages.open.controller_binds": "Controller-Belegung …",
|
||||
"horizon.pages.open.horizon_settings": "Horizon-Einstellungen …",
|
||||
"horizon.pages.open.studio": "Replay Studio …",
|
||||
"horizon.mods.empty": "Keine Mods: Lege .hmod-Dateien in den Ordner horizon-mods",
|
||||
"horizon.mods.stopped": " (angehalten)",
|
||||
"horizon.mods.not_built": " (nicht gebaut)",
|
||||
@@ -135,6 +142,7 @@
|
||||
"Your display or system doesn't offer HDR output.": "Dein Bildschirm oder System unterstützt keine HDR-Ausgabe.",
|
||||
"Turn HDR on to use it.": "Schalte HDR ein, um es zu nutzen.",
|
||||
"Your GPU or graphics API doesn't support it.": "Deine GPU oder Grafik-API unterstützt das nicht.",
|
||||
"Only applies to asynchronous entity culling.": "Nur für asynchrones Objekt-Culling.",
|
||||
"%s chunks": "%s Chunks",
|
||||
"%s per frame": "%s pro Bild",
|
||||
"%s MiB/frame": "%s MiB/Bild",
|
||||
|
||||
@@ -24,6 +24,12 @@
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "This feature is not yet available in Horizon.",
|
||||
"horizon.options.search": "Search settings...",
|
||||
"horizon.studio.record.usage": "Usage: /studio record <start [name]|stop|status>",
|
||||
"horizon.studio.record.started": "Studio recording started: %s",
|
||||
"horizon.studio.record.saved": "Studio recording saved: %s",
|
||||
"horizon.studio.record.error": "Studio recording error: %s",
|
||||
"horizon.studio.record.status.active": "Studio recording active — %s s, %s packets, %s KB (%s)",
|
||||
"horizon.studio.record.status.inactive": "Studio recording inactive",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Shader Packs",
|
||||
"horizon.pages.shader_options": "Shader Options",
|
||||
@@ -31,6 +37,7 @@
|
||||
"horizon.pages.controller_binds": "Controller Binds",
|
||||
"horizon.pages.extras": "Horizon Extras",
|
||||
"horizon.pages.settings": "Horizon Settings",
|
||||
"horizon.pages.studio": "Replay Studio",
|
||||
"horizon.pages.agent": "AI Agent",
|
||||
"horizon.split.leave": "Leave",
|
||||
"horizon.pages.split_settings": "Splitscreen Settings",
|
||||
@@ -51,6 +58,7 @@
|
||||
"horizon.pages.music.empty": "No music tracks: the game's sounds are not loaded.",
|
||||
"horizon.pages.music.tooltip": "The built-in tracks, by situation: play one now, or turn any off so the game stops picking it.",
|
||||
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
|
||||
"horizon.pages.studio.tooltip": "Browse saved recordings and their metadata.",
|
||||
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
|
||||
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
|
||||
@@ -71,6 +79,13 @@
|
||||
"horizon.pages.open.horizon_graphics": "Horizon Graphics...",
|
||||
"horizon.pages.open.controller_binds": "Controller Binds...",
|
||||
"horizon.pages.open.horizon_settings": "Horizon Settings...",
|
||||
"horizon.pages.open.studio": "Replay Studio...",
|
||||
"horizon.studio.saved_recordings": "Saved Recordings",
|
||||
"horizon.studio.empty": "No recordings found in the recordings folder.",
|
||||
"horizon.studio.empty_hint": "Use /studio record start <name> in a world, or drop .mcpr files there.",
|
||||
"horizon.studio.open_folder": "Open Recordings",
|
||||
"horizon.studio.refresh": "Refresh",
|
||||
"horizon.studio.unknown_server": "Unknown",
|
||||
"horizon.pages.open.music": "Music...",
|
||||
"horizon.mods.empty": "No mods: put .hmod files in the horizon-mods folder",
|
||||
"horizon.mods.stopped": " (Stopped)",
|
||||
@@ -94,6 +109,7 @@
|
||||
"LOD Distance": "LOD Distance",
|
||||
"Far Detail": "Far Detail",
|
||||
"Cave Culling": "Cave Culling",
|
||||
"GPU Culling": "GPU Culling",
|
||||
"Entity Culling": "Entity Culling",
|
||||
"Chunk Updates": "Chunk Updates",
|
||||
"Upload Budget": "Upload Budget",
|
||||
@@ -158,6 +174,10 @@
|
||||
"Your display or system doesn't offer HDR output.": "Your display or system doesn't offer HDR output.",
|
||||
"Turn HDR on to use it.": "Turn HDR on to use it.",
|
||||
"Your GPU or graphics API doesn't support it.": "Your GPU or graphics API doesn't support it.",
|
||||
"Not with software rendering, which runs it on the CPU.": "Not with software rendering, which runs it on the CPU.",
|
||||
"Out of video memory: off until the next start.": "Out of video memory: off until the next start.",
|
||||
"The graphics device was lost: off until the next start.": "The graphics device was lost: off until the next start.",
|
||||
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Not on DirectX 12 yet: try the Vulkan graphics API.",
|
||||
"%s chunks": "%s chunks",
|
||||
"%s per frame": "%s per frame",
|
||||
"%s MiB/frame": "%s MiB/frame",
|
||||
@@ -180,6 +200,7 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "How far mobs, crops and machines keep working. CPU time.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "How far away mobs and items are drawn, as a share of normal.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Skips terrain hidden behind closed sections. Off costs a lot.",
|
||||
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.",
|
||||
"Hides entities behind opaque blocks.": "Hides entities behind opaque blocks.",
|
||||
"No entity occlusion culling.": "No entity occlusion culling.",
|
||||
"CPU occlusion test runs this frame.": "CPU occlusion test runs this frame.",
|
||||
@@ -338,6 +359,7 @@
|
||||
"horizon.friends.stopped": "Stopped sharing the invite code.",
|
||||
"horizon.friends.short_code_changed": "Someone kept trying a wrong short code, so it changed.",
|
||||
"horizon.friends.wrong_code": "Someone tried a wrong code.",
|
||||
"horizon.friends.relay_stopped": "Too many wrong short codes, so the relay is off until you share again. Friends can still join with the full code.",
|
||||
"horizon.friends.code_ready": "Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
|
||||
"horizon.friends.share_failed": "Couldn't share the world: %s",
|
||||
"horizon.friends.finding_route": "Finding how friends can reach you…",
|
||||
@@ -474,6 +496,10 @@
|
||||
"Auto (%s nits)": "Auto (%s nits)",
|
||||
"%.2f nits": "%.2f nits",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Hides models behind opaque blocks. Entities update. Off with custom packs.",
|
||||
"Entity Culling Precheck": "Entity Culling Precheck",
|
||||
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Runs the mob AABB precheck after this many loaded mobs. Zero disables it.",
|
||||
"%s mobs": "%s mobs",
|
||||
"Only applies to asynchronous entity culling.": "Only applies to asynchronous entity culling.",
|
||||
"horizon.extras.repro_keys": "Recording shortcuts",
|
||||
"horizon.extras.repro_keys.tooltip": "F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world.",
|
||||
"horizon.agent.record_session": "Start Recording Session",
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
"Done": "Hecho",
|
||||
"This feature is not yet available in Horizon.": "Esta función aún no está disponible en Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Tu pantalla o sistema no ofrece salida HDR.",
|
||||
"Only applies to asynchronous entity culling.": "Solo se aplica a la oclusión asíncrona de entidades.",
|
||||
"Turn HDR on to use it.": "Activa el HDR para usarlo.",
|
||||
"%s chunks": "%s chunks",
|
||||
"%s per frame": "%s por fotograma",
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"Done": "Terminé",
|
||||
"This feature is not yet available in Horizon.": "Cette fonction n'est pas encore disponible dans Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Votre écran ou votre système ne propose pas de sortie HDR.",
|
||||
"Only applies to asynchronous entity culling.": "S'applique uniquement à l'occlusion asynchrone des entités.",
|
||||
"Turn HDR on to use it.": "Activez le HDR pour l'utiliser.",
|
||||
"Your GPU or graphics API doesn't support it.": "Votre GPU ou votre API graphique ne le prend pas en charge.",
|
||||
"%s per frame": "%s par image",
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
"Done": "Gotowe",
|
||||
"This feature is not yet available in Horizon.": "Ta funkcja nie jest jeszcze dostępna w Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Twój wyświetlacz lub system nie obsługuje wyjścia HDR.",
|
||||
"Only applies to asynchronous entity culling.": "Dotyczy tylko asynchronicznego pomijania bytów.",
|
||||
"Turn HDR on to use it.": "Włącz HDR, aby tego użyć.",
|
||||
"Your GPU or graphics API doesn't support it.": "Twoje GPU lub API graficzne tego nie obsługuje.",
|
||||
"%s chunks": "%s chunków",
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
"Done": "Готово",
|
||||
"This feature is not yet available in Horizon.": "Эта функция в Horizon пока недоступна.",
|
||||
"Your display or system doesn't offer HDR output.": "Ваш экран или система не поддерживает HDR-вывод.",
|
||||
"Only applies to asynchronous entity culling.": "Применяется только при асинхронном отсечении сущностей.",
|
||||
"Turn HDR on to use it.": "Включите HDR, чтобы пользоваться этим.",
|
||||
"%s chunks": "%s чанков",
|
||||
"%s per frame": "%s за кадр",
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
"Done": "Tamam",
|
||||
"This feature is not yet available in Horizon.": "Bu özellik henüz Horizon'da mevcut değil.",
|
||||
"Your display or system doesn't offer HDR output.": "Ekranınız veya sisteminiz HDR çıkışını desteklemiyor.",
|
||||
"Only applies to asynchronous entity culling.": "Yalnızca eşzamansız varlık elemesine uygulanır.",
|
||||
"Turn HDR on to use it.": "Bu özelliği kullanmak için HDR'yi etkinleştirin.",
|
||||
"%s chunks": "%s parça",
|
||||
"%s per frame": "Kare başına %s",
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
"Done": "Готово",
|
||||
"This feature is not yet available in Horizon.": "Ця функція ще недоступна в Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Ваш дисплей або система не підтримує вивід HDR.",
|
||||
"Only applies to asynchronous entity culling.": "Застосовується лише для асинхронного відсікання сутностей.",
|
||||
"Turn HDR on to use it.": "Увімкніть HDR, щоб користуватися цим.",
|
||||
"Your GPU or graphics API doesn't support it.": "Ваш GPU або графічний API цього не підтримує.",
|
||||
"%s chunks": "%s чанків",
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"Done": "完成",
|
||||
"This feature is not yet available in Horizon.": "此功能在 Horizon 中尚不可用。",
|
||||
"Your display or system doesn't offer HDR output.": "你的显示器或系统不支持 HDR 输出。",
|
||||
"Only applies to asynchronous entity culling.": "仅适用于异步实体剔除。",
|
||||
"Turn HDR on to use it.": "开启 HDR 后才能使用。",
|
||||
"Your GPU or graphics API doesn't support it.": "你的 GPU 或图形 API 不支持此功能。",
|
||||
"%s chunks": "%s 个区块",
|
||||
|
||||
@@ -373,6 +373,7 @@ pub fn main() -> Result<()> {
|
||||
skull: &|_| None,
|
||||
glyph: &|c| atlas.glyph_cell(c),
|
||||
glyphs: atlas.font.epoch(),
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
renderer.render_block(&scene, pos, state, Mat4::IDENTITY, packed, &mut verts);
|
||||
|
||||
@@ -1130,6 +1130,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
main_hand: s.hand.as_deref().map(Into::into),
|
||||
off_hand: None,
|
||||
head: None,
|
||||
chest: None,
|
||||
legs: None,
|
||||
feet: None,
|
||||
armor_cover: 0.0,
|
||||
invisible: false,
|
||||
spectator: false,
|
||||
|
||||
@@ -605,6 +605,10 @@ pub trait World {
|
||||
fn light(&self, pos: [i32; 3]) -> i32;
|
||||
fn smokey(&self, pos: [i32; 3]) -> bool;
|
||||
fn in_tag(&self, registry: &str, tag: &str, id: &str) -> bool;
|
||||
/// The block entity's `saveWithFullMetadata` at `pos`, `None` without one.
|
||||
fn block_entity_nbt(&self, _pos: [i32; 3]) -> Option<horizon_world::nbt::Tag> {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// An item stack as predicates see it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -853,6 +857,16 @@ pub fn block(v: &Value, pos: [i32; 3], w: &dyn World) -> bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
// `matchesBlockEntity`: the block entity's data holds the pattern
|
||||
// (`NbtPredicate.matches`, `NbtUtils.compareNbt`); no block entity fails.
|
||||
if let Some(nbt) = v["nbt"].as_str() {
|
||||
let Ok((pattern, _)) = horizon_world::nbt::parse_snbt(nbt) else {
|
||||
return false;
|
||||
};
|
||||
if !crate::nbt_path::compare_nbt(&pattern, w.block_entity_nbt(pos).as_ref()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `LocationPredicate`.
|
||||
@@ -1810,6 +1824,67 @@ mod tests {
|
||||
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
|
||||
}
|
||||
}
|
||||
/// `BlockPredicate.matchesBlockEntity`: an `nbt` test needs a block
|
||||
/// entity whose `saveWithFullMetadata` contains the pattern
|
||||
/// (`NbtPredicate.matches`), so it never matches a plain block.
|
||||
#[test]
|
||||
fn a_block_predicates_nbt_needs_the_block_entitys_data() {
|
||||
let w = Flat;
|
||||
let stone = serde_json::json!({"blocks": "minecraft:stone", "nbt": "{}"});
|
||||
assert!(!block(&stone, [0, 0, 0], &w), "stone has no block entity");
|
||||
assert!(block(
|
||||
&serde_json::json!({"blocks": "minecraft:stone"}),
|
||||
[0, 0, 0],
|
||||
&w
|
||||
));
|
||||
/// An unpowered beacon: its saved data has `Levels:0`.
|
||||
struct Beacon;
|
||||
impl World for Beacon {
|
||||
fn dimension(&self) -> &str {
|
||||
Flat.dimension()
|
||||
}
|
||||
fn weather(&self) -> (bool, bool) {
|
||||
Flat.weather()
|
||||
}
|
||||
fn biome(&self, p: [i32; 3]) -> String {
|
||||
Flat.biome(p)
|
||||
}
|
||||
fn structures(&self, p: [i32; 3]) -> Vec<String> {
|
||||
Flat.structures(p)
|
||||
}
|
||||
fn block(&self, _: [i32; 3]) -> (String, BTreeMap<String, String>) {
|
||||
("minecraft:beacon".into(), BTreeMap::new())
|
||||
}
|
||||
fn fluid(&self, _: [i32; 3]) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn can_see_sky(&self, _: [i32; 3]) -> bool {
|
||||
true
|
||||
}
|
||||
fn light(&self, _: [i32; 3]) -> i32 {
|
||||
15
|
||||
}
|
||||
fn smokey(&self, _: [i32; 3]) -> bool {
|
||||
false
|
||||
}
|
||||
fn in_tag(&self, _: &str, _: &str, _: &str) -> bool {
|
||||
false
|
||||
}
|
||||
fn block_entity_nbt(&self, _: [i32; 3]) -> Option<horizon_world::nbt::Tag> {
|
||||
horizon_world::nbt::parse_snbt(r#"{id:"minecraft:beacon",x:0,y:0,z:0,Levels:0}"#)
|
||||
.ok()
|
||||
.map(|(t, _)| t)
|
||||
}
|
||||
}
|
||||
let beacon = |nbt: &str| serde_json::json!({"blocks": "minecraft:beacon", "nbt": nbt});
|
||||
assert!(block(&beacon("{Levels:0}"), [0, 0, 0], &Beacon));
|
||||
assert!(block(&beacon("{}"), [0, 0, 0], &Beacon));
|
||||
assert!(!block(&beacon("{Levels:4}"), [0, 0, 0], &Beacon));
|
||||
assert!(
|
||||
!block(&beacon("{Levels:0b}"), [0, 0, 0], &Beacon),
|
||||
"an int, not a byte"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn weather_conditions_leave_absent_fields_unconstrained_and_compose() {
|
||||
let ctx = Entities {
|
||||
|
||||
@@ -164,6 +164,10 @@ impl Default for Ambience {
|
||||
}
|
||||
}
|
||||
impl Ambience {
|
||||
/// Starts its random over from `seed`.
|
||||
pub fn reseed(&mut self, seed: i64) {
|
||||
self.random = LegacyRandom::new(seed);
|
||||
}
|
||||
pub fn new(seed: i64) -> Self {
|
||||
Self {
|
||||
random: LegacyRandom::new(seed),
|
||||
|
||||
@@ -59,7 +59,6 @@ pub mod discord_presence;
|
||||
pub mod end_crystal;
|
||||
pub mod end_gateway;
|
||||
pub mod entities;
|
||||
pub mod entity_culling;
|
||||
pub mod entity_nbt;
|
||||
pub mod entity_rules;
|
||||
pub mod extras_hud;
|
||||
@@ -138,6 +137,10 @@ pub mod title;
|
||||
pub mod toasts;
|
||||
pub mod ui_theme;
|
||||
pub mod vanillagen;
|
||||
pub mod voxel_occlusion;
|
||||
// Keep the old path for native callers while giving the implementation a name
|
||||
// that does not collide with the gameplay adapter in `game/entity_culling.rs`.
|
||||
pub use voxel_occlusion as entity_culling;
|
||||
pub mod win_screen;
|
||||
pub mod world_primitives;
|
||||
pub mod world_spawn;
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
//! milliseconds turn into game ticks, partial ticks and real-time deltas, and
|
||||
//! the `/tick` freeze, step and sprint state.
|
||||
|
||||
/// `HORIZON_STILL=1`, read once (`FixedStep::still`).
|
||||
fn still_requested() -> bool {
|
||||
static STILL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*STILL.get_or_init(|| {
|
||||
let on = std::env::var("HORIZON_STILL").is_ok_and(|v| v == "1");
|
||||
if on {
|
||||
log::warn!("HORIZON_STILL=1: worlds start frozen (/tick unfreeze), frames follow the world's ticks");
|
||||
}
|
||||
on
|
||||
})
|
||||
}
|
||||
|
||||
/// `DeltaTracker.Timer`: float arithmetic on integer milliseconds, exactly as
|
||||
/// the client does it.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -311,11 +323,23 @@ pub struct FixedStep {
|
||||
/// (`ServerTickRateManager.updateStepTicks` broadcasts each one).
|
||||
step_changes: u32,
|
||||
step_requested: i32,
|
||||
/// `HORIZON_STILL=1`, a dev switch for screenshots that come out the same every run: the
|
||||
/// world starts frozen, and while it is, the camera's partial tick is held at 1 as the
|
||||
/// level's is. With the frame's other time (`Game::visual_ticks`) following the world's
|
||||
/// ticks, a frozen scene draws the same frame every time, and `/tick step` moves it on by
|
||||
/// whole ticks.
|
||||
still: bool,
|
||||
}
|
||||
|
||||
impl Default for FixedStep {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
Self::new(still_requested())
|
||||
}
|
||||
}
|
||||
|
||||
impl FixedStep {
|
||||
fn new(still: bool) -> Self {
|
||||
let mut clock = Self {
|
||||
timer: Timer::new(20.0, 0),
|
||||
manager: ServerTickRateManager::default(),
|
||||
now: std::time::Duration::ZERO,
|
||||
@@ -328,11 +352,16 @@ impl Default for FixedStep {
|
||||
client: false,
|
||||
step_changes: 0,
|
||||
step_requested: 0,
|
||||
}
|
||||
still,
|
||||
};
|
||||
// Frozen before any tick: how many run while the world loads differs between runs.
|
||||
clock.manager.base.set_frozen(still);
|
||||
clock
|
||||
}
|
||||
/// `HORIZON_STILL` (`still`).
|
||||
pub fn still(&self) -> bool {
|
||||
self.still
|
||||
}
|
||||
}
|
||||
|
||||
impl FixedStep {
|
||||
/// The clock of a client connected to a server: the `ClientLevel`'s
|
||||
/// `TickRateManager`, set by the server's ticking packets.
|
||||
pub fn client() -> Self {
|
||||
@@ -386,8 +415,12 @@ impl FixedStep {
|
||||
}
|
||||
/// The camera entity's partial tick (`Camera.getCameraEntityPartialTicks`
|
||||
/// for a player, who is never frozen): `getGameTimeDeltaPartialTick(true)`,
|
||||
/// so the view and the hand stay smooth in a frozen level.
|
||||
/// so the view and the hand stay smooth in a frozen level. Under `still`, held at 1 there
|
||||
/// like the level's, so a frame doesn't depend on when it's drawn.
|
||||
pub fn camera_fraction(&self) -> f64 {
|
||||
if self.still && !self.runs_normally() {
|
||||
return 1.0;
|
||||
}
|
||||
f64::from(self.timer.game_time_delta_partial_tick(true))
|
||||
}
|
||||
/// After a frame: whether the game is paused by a screen (the residual
|
||||
@@ -555,6 +588,23 @@ mod tests {
|
||||
assert_eq!(clock.camera_fraction(), 0.5, "the player does not");
|
||||
}
|
||||
#[test]
|
||||
fn a_still_clock_starts_frozen_and_holds_the_camera_too() {
|
||||
let mut clock = FixedStep::new(true);
|
||||
clock.world_runs();
|
||||
assert!(!clock.runs_normally(), "frozen before the first tick");
|
||||
clock.advance(std::time::Duration::from_millis(25));
|
||||
clock.end_frame(false);
|
||||
assert_eq!(clock.fraction(), 1.0);
|
||||
assert_eq!(clock.camera_fraction(), 1.0);
|
||||
// `/tick unfreeze`: the world runs as without the switch.
|
||||
clock.set_frozen(false);
|
||||
assert!(clock.world_runs());
|
||||
assert!(
|
||||
FixedStep::new(false).world_runs(),
|
||||
"without the switch, unfrozen"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn only_a_started_or_stopped_step_is_broadcast() {
|
||||
let mut clock = FixedStep::default();
|
||||
assert!(!clock.start_step(5), "not frozen");
|
||||
|
||||
@@ -114,32 +114,17 @@ pub fn connect(path: &Path) -> io::Result<Stream> {
|
||||
Ok(Stream::Tcp(stream))
|
||||
}
|
||||
|
||||
/// 128 random bits as hex, from the standard library's OS-seeded hasher keys.
|
||||
/// 128 random bits from the OS's random generator, as 32 hex digits: the TCP endpoint's token, which
|
||||
/// gates full control of the running game.
|
||||
fn random_token() -> String {
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
(0..2)
|
||||
.map(|i| {
|
||||
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
|
||||
h.write_u64(i);
|
||||
h.write_u128(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos()),
|
||||
);
|
||||
h.write_u32(std::process::id());
|
||||
format!("{:016x}", h.finish())
|
||||
})
|
||||
.collect()
|
||||
let mut bytes = [0u8; 16];
|
||||
getrandom::fill(&mut bytes).expect("OS random generator");
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Write the endpoint file so that only this user can read it.
|
||||
fn write_private(path: &Path, text: &str) -> io::Result<()> {
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
options.open(path)?.write_all(text.as_bytes())
|
||||
}
|
||||
/// Writes the endpoint file so that only this user can read it, even over an older file
|
||||
/// (`horizon_server_files::private_file`).
|
||||
use horizon_server_files::private_file::write_private;
|
||||
|
||||
/// Constant-time comparison of the presented token.
|
||||
fn same_token(a: &str, b: &str) -> bool {
|
||||
@@ -196,8 +181,7 @@ impl Server {
|
||||
tcp_port = Some(port);
|
||||
write_private(
|
||||
path,
|
||||
&serde_json::json!({ "transport": "tcp", "port": port, "token": token })
|
||||
.to_string(),
|
||||
serde_json::json!({ "transport": "tcp", "port": port, "token": token }).to_string(),
|
||||
)?;
|
||||
spawn_listener(
|
||||
move || listener.accept().map(|(s, _)| s),
|
||||
|
||||
@@ -1308,6 +1308,17 @@ pub fn recipe_id(name: &str) -> String {
|
||||
format!("minecraft:{name}")
|
||||
}
|
||||
}
|
||||
/// The sort key of vanilla's recipe registration order, which first-match
|
||||
/// lookups (`RecipeManager.getRecipeFor`'s `findFirst`), the stonecutter's
|
||||
/// list and the recipe book's display numbers all follow:
|
||||
/// `ResourceManagerRegistryLoadTask` sorts the resource files by `Identifier`
|
||||
/// (path, then namespace) and `RecipeMap` keeps that order. The file's path
|
||||
/// ends in `.json`, so `prefix-early` sorts before `prefix`. `name` is a
|
||||
/// table name (`oak_planks`) or a whole id.
|
||||
pub fn registration_key(name: &str) -> (String, String) {
|
||||
let (namespace, path) = name.split_once(':').unwrap_or(("minecraft", name));
|
||||
(format!("{path}.json"), namespace.to_owned())
|
||||
}
|
||||
fn custom_type(v: &Value) -> Option<&str> {
|
||||
v["type"]
|
||||
.as_str()
|
||||
@@ -1316,7 +1327,7 @@ fn custom_type(v: &Value) -> Option<&str> {
|
||||
pub struct Recipes {
|
||||
pub crafting: Vec<(String, Recipe)>,
|
||||
pub cooking: Vec<Cooking>,
|
||||
/// The recipes of types of mods' own, by type, in file order.
|
||||
/// The recipes of types of mods' own, by type, in registration order.
|
||||
pub custom: HashMap<String, Vec<CustomRecipe>>,
|
||||
/// `category` and `group` of each crafting recipe, by file name.
|
||||
pub meta: HashMap<String, (String, String)>,
|
||||
@@ -1515,8 +1526,11 @@ impl Recipes {
|
||||
root: &Path,
|
||||
items: &Items,
|
||||
overlay: &PackOverlay,
|
||||
sources: Vec<(String, Value)>,
|
||||
mut sources: Vec<(String, Value)>,
|
||||
) -> Result<Self> {
|
||||
// Every table in registration order, whatever order the sources come
|
||||
// in (the world's packs' files after the game's own).
|
||||
sources.sort_by_cached_key(|(name, _)| registration_key(name));
|
||||
let mut crafting = Vec::new();
|
||||
let mut cooking = Vec::new();
|
||||
let mut custom: HashMap<String, Vec<CustomRecipe>> = HashMap::new();
|
||||
@@ -2199,7 +2213,7 @@ mod tests {
|
||||
assert_eq!(kind(r#"{"type":7}"#), None);
|
||||
assert_eq!(kind("{}"), None);
|
||||
}
|
||||
/// Recipes of a mod's own types land in `custom` by type, in file order,
|
||||
/// Recipes of a mod's own types land in `custom` by type, in registration order,
|
||||
/// with the id the file has; a data pack overrides by id and adds more; a
|
||||
/// `minecraft` type the game doesn't know is dropped.
|
||||
#[test]
|
||||
@@ -2312,6 +2326,77 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// ResourceManagerRegistryLoadTask registers by resource-file Identifier,
|
||||
/// and RecipeMap keeps that order when taking the first matching recipe,
|
||||
/// for the crafting grid and the furnaces alike.
|
||||
#[test]
|
||||
fn recipe_selection_follows_resource_file_order() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-recipe-order-{}", std::process::id()));
|
||||
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
|
||||
let items = Items::for_tests(HashMap::new());
|
||||
let crafting = |item: &str| {
|
||||
serde_json::json!({
|
||||
"type": "minecraft:crafting_shapeless", "ingredients": ["minecraft:flint"],
|
||||
"result": {"id": item}
|
||||
})
|
||||
};
|
||||
let smelting = |item: &str| {
|
||||
serde_json::json!({
|
||||
"type": "minecraft:smelting", "ingredient": "minecraft:flint",
|
||||
"result": {"id": item}
|
||||
})
|
||||
};
|
||||
let flint = ItemStack::new("minecraft:flint", 1);
|
||||
let cases = [
|
||||
(vec!["minecraft:z_pick", "parity:a_pick"], "parity:a_pick"),
|
||||
(
|
||||
vec!["a_priority:z_pick", "z_priority:a_pick"],
|
||||
"z_priority:a_pick",
|
||||
),
|
||||
(
|
||||
vec!["z_priority:equal", "a_priority:equal"],
|
||||
"a_priority:equal",
|
||||
),
|
||||
// File names include .json: '-' sorts before the parent's '.'.
|
||||
(
|
||||
vec!["parity:prefix", "parity:prefix-early"],
|
||||
"parity:prefix-early",
|
||||
),
|
||||
(vec!["z_pick", "parity:a_pick"], "parity:a_pick"),
|
||||
];
|
||||
let kinds: [(&str, &dyn Fn(&str) -> serde_json::Value); 2] =
|
||||
[("crafting", &crafting), ("smelting", &smelting)];
|
||||
for (ids, expected) in cases {
|
||||
for (kind, recipe) in kinds {
|
||||
for reverse in [false, true] {
|
||||
let mut sources: Vec<_> = ids
|
||||
.iter()
|
||||
.map(|id| (id.to_string(), recipe("minecraft:stick")))
|
||||
.collect();
|
||||
if reverse {
|
||||
sources.reverse();
|
||||
}
|
||||
let recipes =
|
||||
Recipes::from_sources(&root, &items, &PackOverlay::NONE, sources).unwrap();
|
||||
let (id, result) = match kind {
|
||||
"crafting" => recipes.find(&[Some(flint.clone())], 1).unwrap(),
|
||||
_ => {
|
||||
let c = recipes.cook("minecraft:smelting", &flint).unwrap();
|
||||
(c.id.as_str(), c.result.clone())
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
id, expected,
|
||||
"{kind}, input order: {ids:?}, reversed={reverse}"
|
||||
);
|
||||
assert_eq!(&*result.item, "minecraft:stick");
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A recipe whose `neoforge:conditions` hold loads, one whose don't is
|
||||
/// silently absent, from the root's own files and a pack's alike.
|
||||
#[test]
|
||||
|
||||
@@ -1209,6 +1209,7 @@ impl ExperienceOrb {
|
||||
next_float: &mut dyn FnMut() -> f32,
|
||||
border: Option<&BorderGeometry>,
|
||||
fluids: &crate::motion::FluidResources,
|
||||
on_sound: &mut dyn FnMut([f64; 3], f32),
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -1294,7 +1295,8 @@ impl ExperienceOrb {
|
||||
self.velocity.y = 0.0;
|
||||
}
|
||||
// ExperienceOrb.tick applies block effects after moving, before drag.
|
||||
let discarded = server && self.apply_lava_contact_damage(world, fluids, wanted);
|
||||
let discarded =
|
||||
server && self.apply_lava_contact_damage(world, fluids, wanted, next_float, on_sound);
|
||||
// `float friction = getAirDrag()` (0.98F), the ground's block
|
||||
// friction multiplied in as a float, widened once for the scale.
|
||||
let mut friction = 0.98_f32;
|
||||
@@ -1324,14 +1326,20 @@ impl ExperienceOrb {
|
||||
!discarded && self.age < 6000
|
||||
}
|
||||
/// `LavaFluid.entityInside` -> `Entity.lavaHurt`: four damage on the
|
||||
/// server, through the orb's existing hurt/discard policy. This is separate
|
||||
/// from the lava impulse at the start of the tick.
|
||||
fn apply_lava_contact_damage(
|
||||
/// server, through the orb's existing hurt/discard policy, playing the generic
|
||||
/// burn sound (`minecraft:entity.generic.burn`, volume 0.4, pitch 2.0 + random * 0.4)
|
||||
/// on each hurt contact. This is separate from the lava impulse at the start of the tick.
|
||||
pub fn apply_lava_contact_damage(
|
||||
&mut self,
|
||||
world: &World,
|
||||
fluids: &crate::motion::FluidResources,
|
||||
wanted: Vec3,
|
||||
next_float: &mut dyn FnMut() -> f32,
|
||||
on_sound: &mut dyn FnMut([f64; 3], f32),
|
||||
) -> bool {
|
||||
if self.health <= 0 {
|
||||
return true;
|
||||
}
|
||||
let contacts = orb_lava_contacts(self.previous, self.position, wanted, |p| {
|
||||
let fluid = fluids.fluid(world.block(p.x, p.y, p.z));
|
||||
if !fluid
|
||||
@@ -1348,7 +1356,10 @@ impl ExperienceOrb {
|
||||
})
|
||||
});
|
||||
for _ in 0..contacts {
|
||||
if self.hurt(4.0) {
|
||||
let dead = self.hurt(4.0);
|
||||
let pitch = 2.0 + next_float() * 0.4;
|
||||
on_sound([self.position.x, self.position.y, self.position.z], pitch);
|
||||
if dead {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1591,6 +1602,8 @@ pub struct ItemFrame {
|
||||
pub fixed: bool,
|
||||
pub invisible: bool,
|
||||
pub check_interval: u32,
|
||||
/// `Entity.getCustomName` (`CustomName`), copied onto the dropped frame.
|
||||
pub custom_name: Option<horizon_world::nbt::Tag>,
|
||||
}
|
||||
impl ItemFrame {
|
||||
pub fn step(&self) -> [i32; 3] {
|
||||
@@ -1620,6 +1633,16 @@ impl ItemFrame {
|
||||
std::array::from_fn(|i| c[i] + size[i] / 2.0),
|
||||
)
|
||||
}
|
||||
/// `ItemFrame.getFrameItemStackWithData`: `getFrameItemStack` (a glow
|
||||
/// frame's own item), then `DataComponents.CUSTOM_NAME` from
|
||||
/// `getCustomName`.
|
||||
pub fn item_stack_with_data(&self) -> ItemStack {
|
||||
let mut item_stack = ItemStack::new(self.kind(), 1);
|
||||
if let Some(name) = &self.custom_name {
|
||||
item_stack.data_mut().custom_name = Some(name.clone());
|
||||
}
|
||||
item_stack
|
||||
}
|
||||
}
|
||||
/// An armor stand (`ArmorStand`).
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -2763,6 +2786,7 @@ mod world_border_tests {
|
||||
&mut || 0.0,
|
||||
border,
|
||||
&fluids,
|
||||
&mut |_, _| {},
|
||||
);
|
||||
for (what, east) in [
|
||||
("item", item.bounds().max_position().x),
|
||||
@@ -4045,8 +4069,15 @@ crate::impl_joined!(ItemEntity, ExperienceOrb, ArmorStand);
|
||||
mod orb_lava_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_server_orb_takes_lava_damage_after_moving_and_discards_at_zero() {
|
||||
fn with_fixture(
|
||||
mut f: impl FnMut(
|
||||
&mut World,
|
||||
&BlockFacts,
|
||||
&BlockCollisionRegistry<'_>,
|
||||
&crate::motion::FluidResources,
|
||||
&crate::assets::Assets,
|
||||
),
|
||||
) {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
@@ -4060,10 +4091,11 @@ mod orb_lava_tests {
|
||||
let fluids = crate::motion::FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let lava = assets.registry.default_state("minecraft:lava").unwrap();
|
||||
world.set(20, 4, 20, lava).unwrap();
|
||||
let at = Vec3::new(20.5, 4.1, 20.5);
|
||||
let mut orb = ExperienceOrb {
|
||||
f(&mut world, &facts, &collision, &fluids, &assets);
|
||||
}
|
||||
|
||||
fn sample_orb(at: Vec3, health: i32) -> ExperienceOrb {
|
||||
ExperienceOrb {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -4076,44 +4108,294 @@ mod orb_lava_tests {
|
||||
on_ground: false,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
};
|
||||
let mut client = orb.clone();
|
||||
for expected_age in 1..=2 {
|
||||
assert!(client.tick(
|
||||
&world,
|
||||
&facts,
|
||||
&collision,
|
||||
health,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_orb_takes_lava_damage_after_moving_and_discards_at_zero() {
|
||||
with_fixture(|world, facts, collision, fluids, assets| {
|
||||
let lava = assets.registry.default_state("minecraft:lava").unwrap();
|
||||
world.set(20, 4, 20, lava).unwrap();
|
||||
let at = Vec3::new(20.5, 4.1, 20.5);
|
||||
let mut orb = sample_orb(at, 5);
|
||||
let mut client = orb.clone();
|
||||
for expected_age in 1..=2 {
|
||||
assert!(client.tick(
|
||||
world,
|
||||
facts,
|
||||
collision,
|
||||
None,
|
||||
false,
|
||||
&mut || 0.0,
|
||||
None,
|
||||
fluids,
|
||||
&mut |_, _| {}
|
||||
));
|
||||
assert_eq!((client.health, client.age), (5, expected_age));
|
||||
}
|
||||
let mut sounds = Vec::new();
|
||||
assert!(orb.tick(
|
||||
world,
|
||||
facts,
|
||||
collision,
|
||||
None,
|
||||
false,
|
||||
true,
|
||||
&mut || 0.0,
|
||||
None,
|
||||
&fluids
|
||||
fluids,
|
||||
&mut |pos, pitch| sounds.push((pos, pitch)),
|
||||
));
|
||||
assert_eq!((client.health, client.age), (5, expected_age));
|
||||
}
|
||||
assert!(orb.tick(
|
||||
&world,
|
||||
&facts,
|
||||
&collision,
|
||||
None,
|
||||
true,
|
||||
&mut || 0.0,
|
||||
None,
|
||||
&fluids
|
||||
));
|
||||
assert_eq!((orb.health, orb.age), (1, 1));
|
||||
assert!(!orb.tick(
|
||||
&world,
|
||||
&facts,
|
||||
&collision,
|
||||
None,
|
||||
true,
|
||||
&mut || 0.0,
|
||||
None,
|
||||
&fluids
|
||||
));
|
||||
assert_eq!((orb.health, orb.age), (-3, 2));
|
||||
assert_eq!((orb.health, orb.age), (1, 1));
|
||||
assert_eq!(sounds.len(), 1);
|
||||
sounds.clear();
|
||||
assert!(!orb.tick(
|
||||
world,
|
||||
facts,
|
||||
collision,
|
||||
None,
|
||||
true,
|
||||
&mut || 0.0,
|
||||
None,
|
||||
fluids,
|
||||
&mut |pos, pitch| sounds.push((pos, pitch)),
|
||||
));
|
||||
assert_eq!((orb.health, orb.age), (-3, 2));
|
||||
assert_eq!(sounds.len(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_orb_in_air_or_water_or_non_lava_does_not_play_sound_or_consume_random() {
|
||||
with_fixture(|world, facts, collision, fluids, assets| {
|
||||
// Air scenario
|
||||
{
|
||||
let mut orb_air = sample_orb(Vec3::new(20.5, 6.0, 20.5), 5);
|
||||
let mut draws = 0;
|
||||
let mut next_float = || {
|
||||
draws += 1;
|
||||
0.5
|
||||
};
|
||||
let mut sounds = Vec::new();
|
||||
assert!(orb_air.tick(
|
||||
world,
|
||||
facts,
|
||||
collision,
|
||||
None,
|
||||
true,
|
||||
&mut next_float,
|
||||
None,
|
||||
fluids,
|
||||
&mut |pos, pitch| sounds.push((pos, pitch)),
|
||||
));
|
||||
assert!(sounds.is_empty(), "orb in air must not play burn sound");
|
||||
assert_eq!(
|
||||
draws, 0,
|
||||
"orb in air must not consume random float for lava"
|
||||
);
|
||||
assert_eq!(orb_air.health, 5);
|
||||
}
|
||||
|
||||
// Water scenario
|
||||
{
|
||||
let water = assets.registry.default_state("minecraft:water").unwrap();
|
||||
world.set(20, 4, 20, water).unwrap();
|
||||
let mut orb_water = sample_orb(Vec3::new(20.5, 4.1, 20.5), 5);
|
||||
let mut draws = 0;
|
||||
let mut next_float = || {
|
||||
draws += 1;
|
||||
0.5
|
||||
};
|
||||
let mut sounds = Vec::new();
|
||||
assert!(orb_water.tick(
|
||||
world,
|
||||
facts,
|
||||
collision,
|
||||
None,
|
||||
true,
|
||||
&mut next_float,
|
||||
None,
|
||||
fluids,
|
||||
&mut |pos, pitch| sounds.push((pos, pitch)),
|
||||
));
|
||||
assert!(sounds.is_empty(), "orb in water must not play burn sound");
|
||||
assert_eq!(
|
||||
draws, 0,
|
||||
"orb in water must not consume random float for lava"
|
||||
);
|
||||
assert_eq!(orb_water.health, 5);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_case_non_contact_or_client_orb_does_not_play_burn_sound() {
|
||||
with_fixture(|world, facts, collision, fluids, assets| {
|
||||
let lava = assets.registry.default_state("minecraft:lava").unwrap();
|
||||
world.set(20, 4, 20, lava).unwrap();
|
||||
|
||||
// Distant orb (no contact)
|
||||
let mut orb_away = sample_orb(Vec3::new(22.5, 4.1, 22.5), 5);
|
||||
let mut draws = 0;
|
||||
let mut next_float = || {
|
||||
draws += 1;
|
||||
0.5
|
||||
};
|
||||
let mut sounds = Vec::new();
|
||||
assert!(orb_away.tick(
|
||||
world,
|
||||
facts,
|
||||
collision,
|
||||
None,
|
||||
true,
|
||||
&mut next_float,
|
||||
None,
|
||||
fluids,
|
||||
&mut |pos, pitch| sounds.push((pos, pitch)),
|
||||
));
|
||||
assert!(
|
||||
sounds.is_empty(),
|
||||
"non-contact orb must not play burn sound"
|
||||
);
|
||||
assert_eq!(draws, 0, "non-contact orb must not consume random float");
|
||||
assert_eq!(orb_away.health, 5);
|
||||
|
||||
// Client orb in lava (server = false)
|
||||
let mut client_orb = sample_orb(Vec3::new(20.5, 4.1, 20.5), 5);
|
||||
sounds.clear();
|
||||
assert!(client_orb.tick(
|
||||
world,
|
||||
facts,
|
||||
collision,
|
||||
None,
|
||||
false,
|
||||
&mut || 0.5,
|
||||
None,
|
||||
fluids,
|
||||
&mut |pos, pitch| sounds.push((pos, pitch)),
|
||||
));
|
||||
assert!(
|
||||
sounds.is_empty(),
|
||||
"client orb in lava must not play burn sound"
|
||||
);
|
||||
assert_eq!(client_orb.health, 5);
|
||||
|
||||
// Dead orb (health <= 0) takes no contact damage and emits no sound
|
||||
let mut dead_orb = sample_orb(Vec3::new(20.5, 4.1, 20.5), 0);
|
||||
sounds.clear();
|
||||
let mut dead_draws = 0;
|
||||
let discarded = dead_orb.apply_lava_contact_damage(
|
||||
world,
|
||||
fluids,
|
||||
Vec3::ZERO,
|
||||
&mut || {
|
||||
dead_draws += 1;
|
||||
0.5
|
||||
},
|
||||
&mut |pos, pitch| sounds.push((pos, pitch)),
|
||||
);
|
||||
assert!(discarded, "dead orb remains discarded");
|
||||
assert!(sounds.is_empty(), "dead orb must not play burn sound");
|
||||
assert_eq!(dead_draws, 0, "dead orb must not consume random float");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positive_case_server_orb_in_lava_plays_generic_burn_sound() {
|
||||
with_fixture(|world, facts, collision, fluids, assets| {
|
||||
let lava = assets.registry.default_state("minecraft:lava").unwrap();
|
||||
world.set(20, 4, 20, lava).unwrap();
|
||||
|
||||
let at = Vec3::new(20.5, 4.1, 20.5);
|
||||
let mut orb = sample_orb(at, 5);
|
||||
let mut sounds = Vec::new();
|
||||
let float_val = 0.35_f32;
|
||||
let stays = orb.tick(
|
||||
world,
|
||||
facts,
|
||||
collision,
|
||||
None,
|
||||
true,
|
||||
&mut || float_val,
|
||||
None,
|
||||
fluids,
|
||||
&mut |pos, pitch| sounds.push((pos, pitch)),
|
||||
);
|
||||
assert!(stays, "orb survives first contact with 1 HP");
|
||||
assert_eq!(orb.health, 1);
|
||||
assert_eq!(sounds.len(), 1, "exactly one burn sound played");
|
||||
let expected_pitch = 2.0_f32 + float_val * 0.4_f32;
|
||||
assert!((sounds[0].1 - expected_pitch).abs() < 1e-6);
|
||||
assert_eq!(
|
||||
sounds[0].0,
|
||||
[orb.position.x, orb.position.y, orb.position.z]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_case_surviving_and_discarding_and_multi_contact_in_one_tick() {
|
||||
with_fixture(|world, facts, collision, fluids, assets| {
|
||||
let lava = assets.registry.default_state("minecraft:lava").unwrap();
|
||||
world.set(20, 4, 20, lava).unwrap();
|
||||
|
||||
// 1. Surviving at 1 HP vs discarding at <= 0 HP
|
||||
let mut orb = sample_orb(Vec3::new(20.5, 4.1, 20.5), 5);
|
||||
let mut sounds = Vec::new();
|
||||
|
||||
// Tick 1: 5 HP -> 1 HP (survives)
|
||||
assert!(orb.tick(
|
||||
world,
|
||||
facts,
|
||||
collision,
|
||||
None,
|
||||
true,
|
||||
&mut || 0.1,
|
||||
None,
|
||||
fluids,
|
||||
&mut |pos, pitch| sounds.push((pos, pitch)),
|
||||
));
|
||||
assert_eq!(orb.health, 1);
|
||||
assert_eq!(sounds.len(), 1);
|
||||
assert!((sounds[0].1 - (2.0 + 0.1 * 0.4)).abs() < 1e-6);
|
||||
|
||||
// Tick 2: 1 HP -> -3 HP (discarded)
|
||||
sounds.clear();
|
||||
assert!(!orb.tick(
|
||||
world,
|
||||
facts,
|
||||
collision,
|
||||
None,
|
||||
true,
|
||||
&mut || 0.9,
|
||||
None,
|
||||
fluids,
|
||||
&mut |pos, pitch| sounds.push((pos, pitch)),
|
||||
));
|
||||
assert_eq!(orb.health, -3);
|
||||
assert_eq!(sounds.len(), 1);
|
||||
assert!((sounds[0].1 - (2.0 + 0.9 * 0.4)).abs() < 1e-6);
|
||||
|
||||
// 2. Multiple lava contacts in a single tick
|
||||
world.set(21, 4, 20, lava).unwrap();
|
||||
let mut multi_orb = sample_orb(Vec3::new(21.5, 4.1, 20.5), 5);
|
||||
multi_orb.previous = Vec3::new(19.8, 4.1, 20.5);
|
||||
let wanted = Vec3::new(1.7, 0.0, 0.0);
|
||||
let mut float_draws = vec![0.2_f32, 0.8_f32];
|
||||
let mut multi_sounds = Vec::new();
|
||||
let discarded = multi_orb.apply_lava_contact_damage(
|
||||
world,
|
||||
fluids,
|
||||
wanted,
|
||||
&mut || float_draws.remove(0),
|
||||
&mut |pos, pitch| multi_sounds.push((pos, pitch)),
|
||||
);
|
||||
assert!(discarded, "orb is discarded after two contacts");
|
||||
assert_eq!(multi_orb.health, -3);
|
||||
assert_eq!(multi_sounds.len(), 2, "both contacts triggered burn sound");
|
||||
assert!((multi_sounds[0].1 - (2.0 + 0.2 * 0.4)).abs() < 1e-6);
|
||||
assert!((multi_sounds[1].1 - (2.0 + 0.8 * 0.4)).abs() < 1e-6);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -428,6 +428,7 @@ mod tests {
|
||||
&mut || 0.0,
|
||||
None,
|
||||
&fluids,
|
||||
&mut |_, _| {},
|
||||
));
|
||||
assert_eq!(still.position.x, at.x);
|
||||
assert_eq!(still.velocity.x, 0.0);
|
||||
@@ -442,6 +443,7 @@ mod tests {
|
||||
&mut || 0.0,
|
||||
None,
|
||||
&fluids,
|
||||
&mut |_, _| {},
|
||||
));
|
||||
// Current pushes the orb, then underwater and air drag each widen
|
||||
// their float constants before multiplying the double motion.
|
||||
@@ -459,6 +461,7 @@ mod tests {
|
||||
&mut || 0.0,
|
||||
None,
|
||||
&fluids,
|
||||
&mut |_, _| {},
|
||||
);
|
||||
}
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
//! Conservative, budgeted occlusion against full opaque block cross-sections.
|
||||
//!
|
||||
//! A covered projection rectangle proves that an entire bounding box is hidden.
|
||||
//! Testing a few rays would miss openings between those rays.
|
||||
use std::cell::Cell;
|
||||
|
||||
const EPSILON: f64 = 1.0e-5;
|
||||
|
||||
pub struct Culler {
|
||||
pub camera: [f64; 3],
|
||||
remaining: Cell<usize>,
|
||||
}
|
||||
|
||||
impl Culler {
|
||||
pub fn new(camera: [f64; 3], budget: usize) -> Self {
|
||||
Self {
|
||||
camera,
|
||||
remaining: Cell::new(budget),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove only this entity's completed camera-relative mesh when hidden.
|
||||
pub fn cull_vertices(
|
||||
&self,
|
||||
vertices: &mut Vec<crate::mesher::Vertex>,
|
||||
start: usize,
|
||||
opaque: impl FnMut([i32; 3]) -> bool,
|
||||
) -> bool {
|
||||
let hidden = self.hidden(vertices, start, opaque);
|
||||
if hidden {
|
||||
vertices.truncate(start);
|
||||
}
|
||||
hidden
|
||||
}
|
||||
|
||||
/// Whether this entity's completed camera-relative mesh (`vertices[start..]`) is hidden.
|
||||
pub fn hidden(
|
||||
&self,
|
||||
vertices: &[crate::mesher::Vertex],
|
||||
start: usize,
|
||||
opaque: impl FnMut([i32; 3]) -> bool,
|
||||
) -> bool {
|
||||
if self.remaining.get() == 0 {
|
||||
return false;
|
||||
}
|
||||
let Some((min, max)) = Self::bounds(self.camera, vertices, start) else {
|
||||
return false;
|
||||
};
|
||||
self.occluded(min, max, opaque)
|
||||
}
|
||||
|
||||
/// Camera-relative bounds grown to include floating-point and rasterization
|
||||
/// edges. The asynchronous path sends these small queries to its worker.
|
||||
pub fn bounds(
|
||||
camera: [f64; 3],
|
||||
vertices: &[crate::mesher::Vertex],
|
||||
start: usize,
|
||||
) -> Option<([f64; 3], [f64; 3])> {
|
||||
if start >= vertices.len() {
|
||||
return None;
|
||||
}
|
||||
let mut min = [f64::INFINITY; 3];
|
||||
let mut max = [f64::NEG_INFINITY; 3];
|
||||
for vertex in &vertices[start..] {
|
||||
for a in 0..3 {
|
||||
let value = f64::from(vertex.pos[a]) + camera[a];
|
||||
if !value.is_finite() {
|
||||
return None;
|
||||
}
|
||||
min[a] = min[a].min(value);
|
||||
max[a] = max[a].max(value);
|
||||
}
|
||||
}
|
||||
// Cover floating-point and rasterization edges as well as the vertices.
|
||||
Some((min.map(|v| v - 0.05), max.map(|v| v + 0.05)))
|
||||
}
|
||||
|
||||
/// Returns true only when a complete opaque block plane covers the bounds.
|
||||
/// Unknown blocks, invalid bounds and an exhausted lookup budget stay visible.
|
||||
pub fn occluded(
|
||||
&self,
|
||||
min: [f64; 3],
|
||||
max: [f64; 3],
|
||||
mut opaque: impl FnMut([i32; 3]) -> bool,
|
||||
) -> bool {
|
||||
if (0..3).any(|a| {
|
||||
!self.camera[a].is_finite()
|
||||
|| !min[a].is_finite()
|
||||
|| !max[a].is_finite()
|
||||
|| min[a] > max[a]
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
let mut best = (0, 0.0, 0.0);
|
||||
for a in 0..3 {
|
||||
let (gap, sign) = if self.camera[a] < min[a] {
|
||||
(min[a] - self.camera[a], 1.0)
|
||||
} else {
|
||||
(self.camera[a] - max[a], -1.0)
|
||||
};
|
||||
if gap > best.1 {
|
||||
best = (a, gap, sign);
|
||||
}
|
||||
}
|
||||
let (axis, gap, sign) = best;
|
||||
if !gap.is_finite() || gap <= EPSILON {
|
||||
return false;
|
||||
}
|
||||
let across = [(axis + 1) % 3, (axis + 2) % 3];
|
||||
let Some(mut cell) = floor_cell(self.camera[axis]) else {
|
||||
return false;
|
||||
};
|
||||
let step = sign as i32;
|
||||
if (f64::from(cell) + 0.5 - self.camera[axis]) * sign <= EPSILON {
|
||||
let Some(next) = cell.checked_add(step) else {
|
||||
return false;
|
||||
};
|
||||
cell = next;
|
||||
}
|
||||
loop {
|
||||
let depth = (f64::from(cell) + 0.5 - self.camera[axis]) * sign;
|
||||
if depth >= gap - EPSILON || self.remaining.get() == 0 {
|
||||
return false;
|
||||
}
|
||||
let mut lo = [f64::INFINITY; 2];
|
||||
let mut hi = [f64::NEG_INFINITY; 2];
|
||||
for corner in 0..8 {
|
||||
let point: [f64; 3] = std::array::from_fn(|a| {
|
||||
if corner & (1 << a) == 0 {
|
||||
min[a]
|
||||
} else {
|
||||
max[a]
|
||||
}
|
||||
});
|
||||
let t = depth / ((point[axis] - self.camera[axis]) * sign);
|
||||
for (i, &a) in across.iter().enumerate() {
|
||||
let p = self.camera[a] + t * (point[a] - self.camera[a]);
|
||||
if !p.is_finite() {
|
||||
return false;
|
||||
}
|
||||
lo[i] = lo[i].min(p);
|
||||
hi[i] = hi[i].max(p);
|
||||
}
|
||||
}
|
||||
let bounds = [
|
||||
floor_cell(lo[0] - EPSILON),
|
||||
floor_cell(hi[0] + EPSILON),
|
||||
floor_cell(lo[1] - EPSILON),
|
||||
floor_cell(hi[1] + EPSILON),
|
||||
];
|
||||
let [Some(x0), Some(x1), Some(y0), Some(y1)] = bounds else {
|
||||
return false;
|
||||
};
|
||||
let mut pos = [0; 3];
|
||||
pos[axis] = cell;
|
||||
pos[across[0]] = x0 + ((i64::from(x1) - i64::from(x0)) / 2) as i32;
|
||||
pos[across[1]] = y0 + ((i64::from(y1) - i64::from(y0)) / 2) as i32;
|
||||
self.remaining.set(self.remaining.get() - 1);
|
||||
if opaque(pos) {
|
||||
let area = (i64::from(x1) - i64::from(x0) + 1)
|
||||
.checked_mul(i64::from(y1) - i64::from(y0) + 1)
|
||||
.and_then(|n| usize::try_from(n).ok());
|
||||
let Some(area) = area.filter(|&n| n <= self.remaining.get()) else {
|
||||
return false;
|
||||
};
|
||||
// Reserve the worst-case lookups before walking the rectangle.
|
||||
self.remaining.set(self.remaining.get() - area);
|
||||
let covered = (x0..=x1).all(|x| {
|
||||
(y0..=y1).all(|y| {
|
||||
pos[across[0]] = x;
|
||||
pos[across[1]] = y;
|
||||
opaque(pos)
|
||||
})
|
||||
});
|
||||
if covered {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let Some(next) = cell.checked_add(step) else {
|
||||
return false;
|
||||
};
|
||||
cell = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn floor_cell(value: f64) -> Option<i32> {
|
||||
let n = value.floor();
|
||||
(n.is_finite() && n >= f64::from(i32::MIN) && n <= f64::from(i32::MAX)).then_some(n as i32)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn completed_mesh_culling_preserves_prior_geometry_and_protruding_parts() {
|
||||
let vertex = |pos| crate::mesher::Vertex {
|
||||
pos,
|
||||
uv: [0.0; 2],
|
||||
layer: 0,
|
||||
color: u32::MAX,
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
};
|
||||
let prior = vertex([0.0; 3]);
|
||||
let body = [vertex([8.0, -1.0, -1.0]), vertex([10.0, 1.0, 1.0])];
|
||||
let wall = |p: [i32; 3]| p[0] == 4 && (-1..=0).contains(&p[1]);
|
||||
let mut hidden = vec![prior];
|
||||
hidden.extend(body);
|
||||
assert!(Culler::new([0.0; 3], 1_000).cull_vertices(&mut hidden, 1, wall));
|
||||
assert_eq!(hidden.len(), 1);
|
||||
assert_eq!(hidden[0].pos, prior.pos);
|
||||
|
||||
// A held item or animated part beyond the wall keeps the whole model.
|
||||
let mut exposed = vec![prior];
|
||||
exposed.extend(body);
|
||||
exposed.push(vertex([8.0, 5.0, 0.0]));
|
||||
let before = exposed.clone();
|
||||
assert!(!Culler::new([0.0; 3], 1_000).cull_vertices(&mut exposed, 1, wall));
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&exposed),
|
||||
bytemuck::cast_slice::<_, u8>(&before)
|
||||
);
|
||||
assert!(!Culler::new([0.0; 3], 0).cull_vertices(&mut exposed, 1, |_| panic!()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_walls_hide_bounds_in_all_directions() {
|
||||
for axis in 0..3 {
|
||||
for sign in [-1.0, 1.0] {
|
||||
let mut min = [-2.0; 3];
|
||||
let mut max = [2.0; 3];
|
||||
min[axis] = if sign > 0.0 { 8.0 } else { -10.0 };
|
||||
max[axis] = if sign > 0.0 { 10.0 } else { -8.0 };
|
||||
let wall = if sign > 0.0 { 4 } else { -5 };
|
||||
assert!(Culler::new([0.0; 3], 1_000).occluded(min, max, |p| p[axis] == wall));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_slits_and_partial_walls_stay_visible() {
|
||||
let min = [8.0, -4.0, -4.0];
|
||||
let max = [10.0, 4.0, 4.0];
|
||||
for opening in 0..3 {
|
||||
assert!(!Culler::new([0.0; 3], 1_000).occluded(min, max, |p| {
|
||||
p[0] == 4
|
||||
&& match opening {
|
||||
0 => !(p[1] == 1 && p[2] == 1),
|
||||
1 => p[1] != 1,
|
||||
_ => p[1] < 0,
|
||||
}
|
||||
}));
|
||||
}
|
||||
assert!(!Culler::new([0.0; 3], 1_000).occluded(min, max, |_| false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budgets_are_shared_and_exhaustion_fails_open() {
|
||||
let culler = Culler::new([0.0; 3], 3);
|
||||
let calls = Cell::new(0);
|
||||
for _ in 0..2 {
|
||||
assert!(!culler.occluded([8.0; 3], [10.0; 3], |_| {
|
||||
calls.set(calls.get() + 1);
|
||||
false
|
||||
}));
|
||||
}
|
||||
assert_eq!(calls.get(), 3);
|
||||
assert!(!Culler::new([0.0; 3], 0).occluded([8.0; 3], [10.0; 3], |_| panic!()));
|
||||
assert!(!Culler::new([0.0; 3], 1).occluded([8.0; 3], [10.0; 3], |_| true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_inside_invalid_and_target_crossing_wall_stay_visible() {
|
||||
let culler = Culler::new([0.0; 3], 1_000);
|
||||
assert!(!culler.occluded([-1.0; 3], [1.0; 3], |_| panic!()));
|
||||
assert!(!culler.occluded([f64::NAN; 3], [1.0; 3], |_| panic!()));
|
||||
assert!(!culler.occluded([2.0; 3], [1.0; 3], |_| panic!()));
|
||||
assert!(!culler.occluded([4.0, -1.0, -1.0], [8.0, 1.0, 1.0], |p| p[0] == 4));
|
||||
assert!(!Culler::new([f64::INFINITY; 3], 1_000).occluded(
|
||||
[8.0; 3],
|
||||
[10.0; 3],
|
||||
|_| panic!()
|
||||
));
|
||||
assert!(!Culler::new([f64::MAX; 3], 1_000).occluded(
|
||||
[-f64::MAX; 3],
|
||||
[-1.0; 3],
|
||||
|_| panic!()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -536,7 +536,9 @@ pub fn mob_to_nbt(
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
let id = m.kind.id.as_str();
|
||||
// What the mob's class saves: a content mod's living type saves as its
|
||||
// template does (the `id` written above stays its own).
|
||||
let id = m.kind.class_id();
|
||||
// AgeableMob.addAdditionalSaveData, only if canBeABaby (not a frog or a
|
||||
// parrot); a tadpole saves its own age.
|
||||
let species = m.species();
|
||||
@@ -1952,7 +1954,8 @@ pub fn mob_from_nbt(
|
||||
}
|
||||
read_variant(&mut m, tag);
|
||||
m.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
|
||||
m.air = number(tag.get("Air")).unwrap_or(300) as i32;
|
||||
// Entity.load: `input.getIntOr("Air", getMaxAirSupply())`, 4800 for a dolphin.
|
||||
m.air = number(tag.get("Air")).map_or(m.max_air_supply(), |air| air as i32);
|
||||
if m.species() == crate::mobs::Species::Allay {
|
||||
if let Some(Tag::List(inv)) = tag.get("Inventory") {
|
||||
for stack in inv.iter().filter_map(item_from_nbt) {
|
||||
@@ -3186,6 +3189,12 @@ pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Ta
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
// `Entity.saveWithoutId`: `CustomName` before the frame's own fields.
|
||||
if let Some(name) = &f.custom_name {
|
||||
put(&mut tag, "CustomName", name.clone());
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"CustomName".into());
|
||||
}
|
||||
put(&mut tag, "Facing", Tag::Byte(f.facing as i8));
|
||||
put(&mut tag, "block_pos", Tag::IntArray(f.pos.to_vec()));
|
||||
put(&mut tag, "ItemRotation", Tag::Byte(f.rotation as i8));
|
||||
@@ -3217,6 +3226,7 @@ pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option<ItemFrame> {
|
||||
fixed: flag(tag, "Fixed"),
|
||||
invisible: flag(tag, "Invisible"),
|
||||
check_interval: 0,
|
||||
custom_name: tag.get("CustomName").cloned(),
|
||||
})
|
||||
}
|
||||
/// ArmorStand.addAdditionalSaveData with LivingEntity's `equipment`.
|
||||
@@ -6290,6 +6300,41 @@ mod fireball_nbt_tests {
|
||||
assert_eq!(item_frame_from_nbt(&frame, 1).unwrap().pos, [-1, 80, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_frame_drops_with_its_custom_name() {
|
||||
// `ItemFrame.getFrameItemStackWithData`: the frame item, then
|
||||
// `DataComponents.CUSTOM_NAME` from `getCustomName`. Glow frames
|
||||
// share it through `GlowItemFrame.getFrameItemStack`.
|
||||
for id in ["minecraft:item_frame", "minecraft:glow_item_frame"] {
|
||||
let tag = Tag::compound([
|
||||
("id", string(id)),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
("Facing", Tag::Byte(2)),
|
||||
("CustomName", string("Gallery")),
|
||||
]);
|
||||
let frame = item_frame_from_nbt(&tag, 1).unwrap();
|
||||
let stack = frame.item_stack_with_data();
|
||||
assert_eq!(&*stack.item, id);
|
||||
assert_eq!(stack.count, 1);
|
||||
assert_eq!(stack.custom_name().as_deref(), Some("Gallery"));
|
||||
let saved = item_frame_to_nbt(&frame, [1, 2, 3, 4], None);
|
||||
assert_eq!(text(saved.get("CustomName")).as_deref(), Some("Gallery"));
|
||||
}
|
||||
let bare = Tag::compound([
|
||||
("id", string("minecraft:item_frame")),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
("Facing", Tag::Byte(3)),
|
||||
]);
|
||||
let frame = item_frame_from_nbt(&bare, 2).unwrap();
|
||||
assert!(frame.custom_name.is_none());
|
||||
assert!(frame.item_stack_with_data().custom_name().is_none());
|
||||
assert!(
|
||||
item_frame_to_nbt(&frame, [1, 2, 3, 4], Some(&bare))
|
||||
.get("CustomName")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projectile_shot_flag_round_trips_independently_of_age() {
|
||||
for id in [
|
||||
|
||||
@@ -214,15 +214,12 @@ mod tests {
|
||||
assert!(!furnace(stack("minecraft:iron_ore"), None, 0, 5).idle());
|
||||
}
|
||||
|
||||
/// A furnace with `fuel` x `count` burning `test:ore` into `test:ingot`, over a
|
||||
/// data root holding that one recipe and an item table with two fuels: `test:coal`
|
||||
/// (no remainder) and `test:cell` (a content mod's item that leaves `test:husk`).
|
||||
fn burn(fuel: &str, count: u32) -> (Furnace, Vec<ItemStack>) {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-furnace-remainder-{}-{}-{count}",
|
||||
std::process::id(),
|
||||
fuel.replace(':', "-")
|
||||
));
|
||||
/// A data root holding one recipe, `test:ore` into `test:ingot`, and an item table
|
||||
/// with two fuels: `test:coal` (no remainder) and `test:cell` (a content mod's item
|
||||
/// that leaves `test:husk`). The folder is gone when this returns.
|
||||
fn data(name: &str) -> (Items, Recipes, Providers) {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-furnace-{name}-{}", std::process::id(),));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
|
||||
fs::write(
|
||||
@@ -248,6 +245,57 @@ mod tests {
|
||||
let items = Items::catalog(&root, &PackOverlay::NONE).unwrap();
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
|
||||
let providers = Providers::load(&root);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
(items, recipes, providers)
|
||||
}
|
||||
|
||||
/// The furnace sleeps while `idle`, so its tick must change nothing then: run the
|
||||
/// real tick on every idle case and compare, and check a furnace that can smelt
|
||||
/// isn't idle and does change.
|
||||
#[test]
|
||||
fn an_idle_furnace_tick_changes_nothing() {
|
||||
let (items, recipes, providers) = data("idle");
|
||||
let stack = |id: &str| Some(ItemStack::new(id, 1));
|
||||
let furnace = |input, fuel, output| Furnace {
|
||||
kind: FurnaceKind::Furnace,
|
||||
items: [input, fuel, output],
|
||||
lit_remaining: 0,
|
||||
lit_total: 0,
|
||||
cooking_timer: 0,
|
||||
cooking_total: 200,
|
||||
speed: 1.0,
|
||||
recipes_used: HashMap::new(),
|
||||
};
|
||||
let idle = [
|
||||
furnace(None, None, None),
|
||||
furnace(stack("test:ore"), None, None),
|
||||
furnace(None, stack("test:coal"), None),
|
||||
furnace(None, None, stack("test:ingot")),
|
||||
furnace(stack("test:ingot"), None, stack("test:ingot")),
|
||||
];
|
||||
for before in idle {
|
||||
assert!(before.idle(), "{before:?}");
|
||||
let mut after = before.clone();
|
||||
let mut drops = Vec::new();
|
||||
let lit = after.tick(&recipes, &items, &providers, &mut drops);
|
||||
assert_eq!(lit, None, "{before:?}");
|
||||
assert!(drops.is_empty(), "{before:?}");
|
||||
assert_eq!(format!("{after:?}"), format!("{before:?}"));
|
||||
}
|
||||
let mut busy = furnace(stack("test:ore"), stack("test:coal"), None);
|
||||
assert!(!busy.idle());
|
||||
let before = format!("{busy:?}");
|
||||
assert_eq!(
|
||||
busy.tick(&recipes, &items, &providers, &mut Vec::new()),
|
||||
Some(true)
|
||||
);
|
||||
assert_ne!(format!("{busy:?}"), before);
|
||||
}
|
||||
|
||||
/// A furnace with `fuel` x `count` burning `test:ore` into `test:ingot` ([`data`]).
|
||||
fn burn(fuel: &str, count: u32) -> (Furnace, Vec<ItemStack>) {
|
||||
let (items, recipes, providers) =
|
||||
data(&format!("remainder-{}-{count}", fuel.replace(':', "-")));
|
||||
let mut furnace = Furnace {
|
||||
kind: FurnaceKind::Furnace,
|
||||
items: [
|
||||
@@ -264,7 +312,6 @@ mod tests {
|
||||
};
|
||||
let mut drops = Vec::new();
|
||||
let lit = furnace.tick(&recipes, &items, &providers, &mut drops);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
assert_eq!(lit, Some(true), "the fuel lit the furnace");
|
||||
(furnace, drops)
|
||||
}
|
||||
|
||||
@@ -247,6 +247,7 @@ mod native_shaders;
|
||||
mod native_ticks;
|
||||
mod native_visuals;
|
||||
mod native_world_columns;
|
||||
pub mod native_world_draw;
|
||||
mod native_world_edit;
|
||||
mod native_world_read;
|
||||
mod natural_spawning;
|
||||
@@ -254,6 +255,7 @@ mod nautili;
|
||||
mod net_harness;
|
||||
mod nether_portal;
|
||||
mod no_resources;
|
||||
mod occlusion_culling;
|
||||
mod ominous_item_spawner;
|
||||
mod options;
|
||||
mod ordered_tick;
|
||||
@@ -332,6 +334,7 @@ mod storage;
|
||||
mod structure_blocks;
|
||||
mod structure_edit;
|
||||
mod structures;
|
||||
mod studio;
|
||||
mod subtitle_hud;
|
||||
mod sulfur_cubes;
|
||||
mod tempban;
|
||||
@@ -387,6 +390,7 @@ use horizon_client::{
|
||||
first_person::{Hand, SwingAnimation},
|
||||
furnace::FurnaceCooking,
|
||||
gfx::{Frame, Renderer},
|
||||
graphics::EntityCullingMode,
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
hud_layout, hud_overlays,
|
||||
items::{ItemStack, Items},
|
||||
@@ -441,6 +445,7 @@ pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
|
||||
pub use native_reload_listeners::NativeReloadListener;
|
||||
pub use native_server::NativeExperienceOrb;
|
||||
pub use native_world_columns::NativeColumnBlocks;
|
||||
pub use native_world_draw::NativeWorldDraw;
|
||||
pub use native_world_edit::{NativeBlockEdit, NativeBlockRun, NativeHeightmap};
|
||||
pub use native_world_read::NativeBlockReadError;
|
||||
use std::{
|
||||
@@ -527,6 +532,9 @@ struct Options {
|
||||
/// `--dedicated`: run as a dedicated server; vanilla's server arguments
|
||||
/// (`--nogui`, `--port`, `--world`, `--universe`, `--initSettings`) follow.
|
||||
dedicated: Option<Vec<String>>,
|
||||
/// `--transfers SETTINGS`: a dedicated server takes a proxy's hand-over requests
|
||||
/// (`server.toml`'s `[transfer]`, which horizon-server passes; lan/arrivals.rs).
|
||||
transfers: Option<horizon_client::lan::arrivals::Settings>,
|
||||
/// `--benchmark SCENARIO`: run the benchmark, write its report and quit.
|
||||
benchmark: Option<String>,
|
||||
/// `--dev-mod PATH` (repeatable): content mods always on.
|
||||
@@ -662,6 +670,7 @@ impl Options {
|
||||
.ok()
|
||||
.filter(|n| !n.is_empty()),
|
||||
dedicated: None,
|
||||
transfers: None,
|
||||
benchmark: None,
|
||||
dev_mods: Vec::new(),
|
||||
execution_role: execution_role::ExecutionRole::Client,
|
||||
@@ -675,7 +684,7 @@ impl Options {
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --session-stdin The same account as JSON on standard input\n --game-dir DIR Game folder: saves, options.txt, packs, mods (default: the working directory)\n --home DIR Logs and crash reports (default $HORIZON_HOME, else the platform data folder)\n --player-dir DIR This player's own files when several play in one game folder: options.txt, logs, horizon-player.txt, servers.dat, screenshots\n --fullscreen Open the window fullscreen\n --launch-api Print the Launch API version and options as JSON (docs/api/launch.md)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --transfers SETTINGS A dedicated server takes a proxy's hand-over requests (server.toml [transfer], docs/server.md)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --session-stdin The same account as JSON on standard input\n --game-dir DIR Game folder: saves, options.txt, packs, mods (default: the working directory)\n --home DIR Logs and crash reports (default $HORIZON_HOME, else the platform data folder)\n --player-dir DIR This player's own files when several play in one game folder: options.txt, logs, horizon-player.txt, servers.dat, screenshots\n --fullscreen Open the window fullscreen\n --launch-api Print the Launch API version and options as JSON (docs/api/launch.md)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
horizon_core::VANILLA_VERSION
|
||||
);
|
||||
return Ok(None);
|
||||
@@ -778,6 +787,10 @@ impl Options {
|
||||
"--name" => o.player_name = Some(value),
|
||||
"--session-file" => o.session_file = Some(value),
|
||||
"--control-socket" => o.control = Some(value.into()),
|
||||
"--transfers" => {
|
||||
o.transfers =
|
||||
Some(horizon_client::lan::arrivals::Settings::parse(&value)?)
|
||||
}
|
||||
"--time" => o.time = Some(value.parse()?),
|
||||
"--weather" => o.weather = Some(value),
|
||||
"--dimension" => {
|
||||
@@ -1005,9 +1018,11 @@ struct GameLevel<'w> {
|
||||
debug: bool,
|
||||
native_sounds: &'w mut Vec<creatures::NativeSound>,
|
||||
native_particles: &'w mut Vec<lan_particles::QueuedParticle>,
|
||||
/// The tool a mod's machine breaks blocks with (`native_machine_destroy_block`):
|
||||
/// `Block.dropResources` rolls the loot with it instead of with none.
|
||||
drop_tool: Option<ItemStack>,
|
||||
/// The block a mod's machine breaks and the tool it breaks it with
|
||||
/// (`native_machine_destroy_block`): `Block.dropResources` rolls that
|
||||
/// block's loot with the tool instead of with none. Neighbours that pop
|
||||
/// off because of the break drop as with no tool, as in vanilla.
|
||||
drop_tool: Option<((i32, i32, i32), Option<ItemStack>)>,
|
||||
/// Where `Block.dropResources` puts the drops instead of popping them,
|
||||
/// while a mod's machine breaks a block (`native_machine_destroy_block`).
|
||||
drop_into: Option<Vec<ItemStack>>,
|
||||
@@ -1991,6 +2006,15 @@ struct WorldTables {
|
||||
/// The values of the data map types native mods registered
|
||||
/// (`native_data_map_types`).
|
||||
data_maps: native_data_maps::DataMaps,
|
||||
/// The entity type, biome and damage type tags with the packs' over the
|
||||
/// game's own, by registry (`TagLoader.loadTagsForExistingRegistries`):
|
||||
/// what the predicates' `#tag`s of those registries read.
|
||||
pack_tags: HashMap<&'static str, HashMap<String, HashSet<String>>>,
|
||||
}
|
||||
|
||||
impl WorldTables {
|
||||
/// The registries [`WorldTables::pack_tags`] holds.
|
||||
const PACK_TAG_REGISTRIES: [&'static str; 3] = ["entity_type", "worldgen/biome", "damage_type"];
|
||||
}
|
||||
|
||||
impl Resources {
|
||||
@@ -3000,9 +3024,13 @@ struct Game<'a> {
|
||||
pending_uploads: Vec<((i32, i32, i32), horizon_client::mesher::SectionMesh)>,
|
||||
/// This frame's view frustum, for culling entities and block entities.
|
||||
view_frustum: Option<horizon_client::gfx::Frustum>,
|
||||
entity_culler: Option<horizon_client::entity_culling::Culler>,
|
||||
async_entity_culler: Option<entity_culling::AsyncCuller>,
|
||||
async_entity_culling_failed: bool,
|
||||
/// Whether the renderer's section-occlusion graph is active for this view.
|
||||
entity_section_visibility_valid: bool,
|
||||
/// The camera section in the current world layout.
|
||||
entity_section_camera: Option<(i32, i32, i32)>,
|
||||
sync_occlusion_culler: Option<horizon_client::voxel_occlusion::Culler>,
|
||||
async_occlusion_culler: Option<Box<occlusion_culling::AsyncOcclusionCuller>>,
|
||||
async_occlusion_culling_failed: bool,
|
||||
async_particle_culler: RefCell<particle_culling::ParticleAsyncState>,
|
||||
/// `Renderer::pack_shadow_casters` for this frame's entities: (keep what the culler hides,
|
||||
/// build the player's model in first person); (false, false) without a shader pack.
|
||||
@@ -3869,7 +3897,14 @@ impl Game<'_> {
|
||||
/// `HoneyBlock.showSlideParticles` (entity event 53): five honey block
|
||||
/// particles at the entity.
|
||||
fn honey_slide_particles(&mut self, at: [f64; 3]) {
|
||||
self.honey_entity_particles(at, 53);
|
||||
}
|
||||
/// `HoneyBlock.showParticles`: the crumb count of entity event 53 or 54.
|
||||
fn honey_entity_particles(&mut self, at: [f64; 3], event: i8) {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
let Some(count) = horizon_client::landing::honey_particle_count(event) else {
|
||||
return;
|
||||
};
|
||||
let Some(honey) = self
|
||||
.pack
|
||||
.assets
|
||||
@@ -3884,7 +3919,7 @@ impl Game<'_> {
|
||||
at[2].floor() as i32,
|
||||
];
|
||||
if let Some((sprite, tint)) = self.particle_sprite(honey, block) {
|
||||
for _ in 0..5 {
|
||||
for _ in 0..count {
|
||||
self.particles.add(
|
||||
&ParticleOptions::block(ParticleType::Block, sprite.clone(), tint),
|
||||
at,
|
||||
@@ -6218,8 +6253,15 @@ impl Game<'_> {
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
// ServerPlayerGameMode.destroyBlock.
|
||||
// ServerPlayerGameMode.destroyBlock (and MultiPlayerGameMode.destroyBlock
|
||||
// on the client): a GameMasterBlock stays when the player cannot use
|
||||
// game master blocks (Player.canUseGameMasterBlocks).
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !self.can_use_game_master_blocks()
|
||||
&& is_game_master_block(&self.res.facts().get(state).name)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
// Native mods, once the game allowed the break: a canceled one keeps
|
||||
// the block, and a guest's acknowledgement puts it back in its world.
|
||||
let mut canceled = false;
|
||||
@@ -7672,6 +7714,25 @@ impl Game<'_> {
|
||||
};
|
||||
self.with_level(|b, level, ticks| b.notify_written(level, ticks, position, flags));
|
||||
}
|
||||
/// The ticks the frame shows (animated textures, the HUD, a shader pack's clock): the
|
||||
/// client's, or under `HORIZON_STILL` (`FixedStep::still`) the world's game time, which
|
||||
/// moves only while the world runs.
|
||||
pub(super) fn visual_ticks(&self) -> u64 {
|
||||
if self.clock.still() {
|
||||
self.world.game_time.max(0) as u64
|
||||
} else {
|
||||
self.ticks
|
||||
}
|
||||
}
|
||||
/// `Util.getMillis` for what the frame shows (the world border's scroll, the hearts' blink,
|
||||
/// the glint's under the switch): under `HORIZON_STILL`, the world's ticks in milliseconds.
|
||||
pub(super) fn visual_millis(&self) -> i64 {
|
||||
if self.clock.still() {
|
||||
self.visual_ticks() as i64 * 50
|
||||
} else {
|
||||
self.started.elapsed().as_millis() as i64
|
||||
}
|
||||
}
|
||||
fn tick(&mut self) -> Result<()> {
|
||||
if self.agent.hold_server {
|
||||
return Ok(());
|
||||
@@ -7719,8 +7780,9 @@ impl Game<'_> {
|
||||
self.world.weather.tick_timers(&mut self.world.random);
|
||||
}
|
||||
self.world.weather.tick_levels();
|
||||
self.tick_weather_effects();
|
||||
}
|
||||
// ClientLevel.tick: the sky flash fades in a frozen level too.
|
||||
self.tick_weather_effects();
|
||||
let _ = self.finish_player_autosaves(false);
|
||||
// MinecraftServer.tickServer's countdown, then autoSave: three
|
||||
// hundred seconds of ticks at the rate the server runs at now.
|
||||
@@ -8831,6 +8893,7 @@ impl Game<'_> {
|
||||
hud_layout::paint(batch, &g.gui.atlas, &draws);
|
||||
}
|
||||
});
|
||||
let (visual_ticks, visual_millis) = (self.visual_ticks(), self.visual_millis());
|
||||
if !self.creative
|
||||
&& !spectator
|
||||
&& let Some(p) = &mut self.player
|
||||
@@ -8849,8 +8912,8 @@ impl Game<'_> {
|
||||
// the surface, not merely in the water block.
|
||||
let under_water = p.is_eye_in_fluid(horizon_client::motion::WATER);
|
||||
let inputs = hud_layout::HealthInputs {
|
||||
tick: self.ticks as i32,
|
||||
millis: self.started.elapsed().as_millis() as i64,
|
||||
tick: visual_ticks as i32,
|
||||
millis: visual_millis,
|
||||
health: p.health.health(),
|
||||
max_health: p.attribute("minecraft:max_health").unwrap_or(20.0) as f32,
|
||||
absorption: p.health.absorption(),
|
||||
@@ -9376,8 +9439,8 @@ impl Game<'_> {
|
||||
let n = self.craft_size;
|
||||
let grid: Vec<Option<ItemStack>> = (0..n * n).map(|i| self.craft_grid[i].clone()).collect();
|
||||
let scale = |id: i32| self.maps.data.get(&id).map(|m| m.scale);
|
||||
self.res
|
||||
.recipes()
|
||||
// The world's table, the one taking the result (`menu_click`) crafts from.
|
||||
self.recipes()
|
||||
.find_with(&grid, n, &scale)
|
||||
.map(|(name, result)| (name.to_owned(), result))
|
||||
}
|
||||
@@ -11045,7 +11108,8 @@ impl Game<'_> {
|
||||
self.entity_motion.borrow_mut().clear();
|
||||
// LevelExtractor.extract: the transient blocks queued since the last frame.
|
||||
self.drain_transient_block_queue();
|
||||
self.prepare_entity_culling(camera);
|
||||
self.prepare_entity_culling();
|
||||
self.prepare_occlusion_culling(camera);
|
||||
self.prepare_trims();
|
||||
self.begin_skinning();
|
||||
self.mark_entity(out.len(), "minecraft:entity_flame");
|
||||
@@ -11260,32 +11324,6 @@ impl Game<'_> {
|
||||
laps.lap("eg_shadows");
|
||||
// Block entities (BlockEntityRenderers): chests, shulker boxes, banners, skulls...
|
||||
if let Some(r) = &self.renderer {
|
||||
let lid = |p: [i32; 3]| self.chest_lid(p, alpha as f32);
|
||||
let anim = |p: [i32; 3]| self.block_anim(p, alpha as f32);
|
||||
let scene = BlockEntityScene {
|
||||
camera,
|
||||
orientation: self.camera_orientation(),
|
||||
anim: &anim,
|
||||
texts: None,
|
||||
partial: alpha as f32,
|
||||
game_time: self.world.game_time,
|
||||
max_y: self.world.shape.max_y() - 1,
|
||||
world: &self.world,
|
||||
sprites: &self.gui.atlas.sprites,
|
||||
emissive: &self.gui.atlas.emissive,
|
||||
translucent_sprites: &self.gui.atlas.translucent,
|
||||
atlas_revision: self.gui.atlas.revision,
|
||||
nether: self.dim == Dim::Nether,
|
||||
lid: &lid,
|
||||
sign: &|p| match self.world.block_entities.get(&(p[0], p[1], p[2])) {
|
||||
Some(BlockEntity::Sign(sign)) => Some(sign),
|
||||
_ => None,
|
||||
},
|
||||
banner: &|p| self.banner_layers_at(p),
|
||||
skull: &|p| self.skull_skin_at(p),
|
||||
glyph: &|c| self.gui.atlas.glyph_cell(c),
|
||||
glyphs: self.gui.atlas.font.epoch(),
|
||||
};
|
||||
// Block entities outside the view frustum are skipped (with a
|
||||
// block of margin for models larger than their block).
|
||||
let view = self.view_frustum;
|
||||
@@ -11312,6 +11350,62 @@ impl Game<'_> {
|
||||
v.intersects(o - glam::Vec3::ONE, o + glam::Vec3::splat(2.0))
|
||||
})
|
||||
};
|
||||
let dynamic_visible = |_: [i32; 3], state: u32, lo: glam::DVec3, hi: glam::DVec3| {
|
||||
if off_screen(state) {
|
||||
return true;
|
||||
}
|
||||
if let Some(view) = view
|
||||
&& !view.intersects(
|
||||
(lo - camera - glam::DVec3::splat(0.5)).as_vec3(),
|
||||
(hi - camera + glam::DVec3::splat(0.5)).as_vec3(),
|
||||
)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let name = &*self.res.facts().get(BlockStateId(state)).name;
|
||||
if self.settings.horizon.entity_culling == EntityCullingMode::Off
|
||||
|| self.pack_shadow_casters.0
|
||||
|| name == "minecraft:end_portal"
|
||||
|| name == "minecraft:end_gateway"
|
||||
|| hi.y - lo.y > 2.0
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Culler::aabb_bounds adds 0.05 on each side. Keep its final
|
||||
// query just inside the conservative rendered-model envelope.
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(lo.x + 0.06, lo.y + 0.06, lo.z + 0.06),
|
||||
Vec3::new(hi.x - 0.06, hi.y - 0.06, hi.z - 0.06),
|
||||
);
|
||||
!self.block_entity_occluded(bounds)
|
||||
};
|
||||
let lid = |p: [i32; 3]| self.chest_lid(p, alpha as f32);
|
||||
let anim = |p: [i32; 3]| self.block_anim(p, alpha as f32);
|
||||
let scene = BlockEntityScene {
|
||||
camera,
|
||||
orientation: self.camera_orientation(),
|
||||
anim: &anim,
|
||||
texts: None,
|
||||
partial: alpha as f32,
|
||||
game_time: self.world.game_time,
|
||||
max_y: self.world.shape.max_y() - 1,
|
||||
world: &self.world,
|
||||
sprites: &self.gui.atlas.sprites,
|
||||
emissive: &self.gui.atlas.emissive,
|
||||
translucent_sprites: &self.gui.atlas.translucent,
|
||||
atlas_revision: self.gui.atlas.revision,
|
||||
nether: self.dim == Dim::Nether,
|
||||
lid: &lid,
|
||||
sign: &|p| match self.world.block_entities.get(&(p[0], p[1], p[2])) {
|
||||
Some(BlockEntity::Sign(sign)) => Some(sign),
|
||||
_ => None,
|
||||
},
|
||||
banner: &|p| self.banner_layers_at(p),
|
||||
skull: &|p| self.skull_skin_at(p),
|
||||
glyph: &|c| self.gui.atlas.glyph_cell(c),
|
||||
glyphs: self.gui.atlas.font.epoch(),
|
||||
dynamic_visible: Some(&dynamic_visible),
|
||||
};
|
||||
let visible = r
|
||||
.visible_block_entities_in_frustum(
|
||||
self.settings.chunk_fade,
|
||||
@@ -11355,20 +11449,6 @@ impl Game<'_> {
|
||||
|_, state, start| self.mark_block_entity(start, state),
|
||||
|_, _, _, _| {},
|
||||
);
|
||||
} else if (self.entity_culler.is_some() || self.async_entity_culler.is_some())
|
||||
&& !block_entity_cache.any_model_cache()
|
||||
{
|
||||
// Entity Culling on and no model cache: a block entity hidden
|
||||
// behind a wall stays culled, as it was before the caches.
|
||||
for block in visible {
|
||||
let start = out.len();
|
||||
self.res
|
||||
.block_entity_render()
|
||||
.render(&scene, std::iter::once(block), &mut out);
|
||||
if !off_screen(block.1) {
|
||||
self.cull_entity_vertices(&mut out, start);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let cache_candidates = r
|
||||
.visible_block_entities_in_frustum(
|
||||
@@ -11639,8 +11719,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
laps.lap("eg_cracks");
|
||||
self.finish_async_entity_culling();
|
||||
self.finish_async_occlusion_culling();
|
||||
laps.lap("eg_culling_finish");
|
||||
self.native_world_geometry(camera, alpha as f32, &mut out);
|
||||
(
|
||||
out,
|
||||
static_block_entities,
|
||||
@@ -14534,6 +14615,14 @@ impl Game<'_> {
|
||||
.renderer
|
||||
.take()
|
||||
.ok_or("no renderer for graphics recovery")?;
|
||||
// A GPU-culled frame may be what lost it: the renderers after this one don't
|
||||
// offer GPU culling, until Horizon restarts (`graphics::lose_gpu_culling`).
|
||||
if old.gpu_frame() {
|
||||
log::warn!(
|
||||
"Horizon: GPU culling off until the next start: the graphics device was lost in one of its frames"
|
||||
);
|
||||
horizon_client::graphics::lose_gpu_culling();
|
||||
}
|
||||
let screenshot = old.screenshot.take();
|
||||
let screenshot_result = old.screenshot_result.take();
|
||||
drop(old);
|
||||
@@ -15809,6 +15898,10 @@ impl Game<'_> {
|
||||
}
|
||||
// Before any text of this frame: a full glyph region starts over.
|
||||
self.gui.atlas.font.begin_frame();
|
||||
if self.clock.still() {
|
||||
// Obfuscated text picks the same glyphs in a frame of the same world tick.
|
||||
self.gui.atlas.font.reseed(self.visual_ticks() as i64);
|
||||
}
|
||||
self.glint_frame = self.capture_glint_frame();
|
||||
if self.renderer.as_mut().is_some_and(|r| r.display_changed()) {
|
||||
self.apply_ui_settings();
|
||||
@@ -15930,8 +16023,11 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
// What faces the camera (particles, item sprites, block entity text) faces a mod's.
|
||||
// A spectated entity's view is not the local player's yaw and pitch
|
||||
// (`Camera.setup` reads the camera entity), so it is kept too.
|
||||
let spectating = self.camera_mob().is_some();
|
||||
self.posed_rotation = view
|
||||
.filter(|posed| Some(*posed) != vanilla)
|
||||
.filter(|posed| spectating || Some(*posed) != vanilla)
|
||||
.map(|(_, yaw, pitch)| (yaw, pitch));
|
||||
let eye = view.map(|_| {
|
||||
(
|
||||
@@ -16434,7 +16530,7 @@ impl Game<'_> {
|
||||
.get(n)
|
||||
.map_or([0.0; 4], |r| [r.u0, r.v0, r.u1 - r.u0, r.v1 - r.v0])
|
||||
}),
|
||||
ticks: self.ticks,
|
||||
ticks: self.visual_ticks(),
|
||||
darkness: self.darkness_lighting(alpha as f32),
|
||||
night_vision: self.night_vision_intensity(alpha as f32),
|
||||
block_flicker: self.fog.block_light_flicker,
|
||||
@@ -16604,7 +16700,7 @@ impl Game<'_> {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let millis = self.started.elapsed().as_millis() as i64;
|
||||
let millis = self.visual_millis();
|
||||
Some(horizon_client::gfx::BorderDraw {
|
||||
vertices: self.border_renderer.vertices,
|
||||
rebuilt: self.border_renderer.take_rebuilt(),
|
||||
@@ -17195,7 +17291,7 @@ impl Game<'_> {
|
||||
unloaded.iter().map(|c| (c.x, c.z)).collect();
|
||||
self.world
|
||||
.block_entities
|
||||
.retain(|p, _| !gone.contains(&(p.0 >> 4, p.2 >> 4)));
|
||||
.unload(|p| gone.contains(&(p.0 >> 4, p.2 >> 4)));
|
||||
self.world
|
||||
.block_names
|
||||
.retain(|p, _| !gone.contains(&(p.0 >> 4, p.2 >> 4)));
|
||||
@@ -18744,7 +18840,15 @@ impl Game<'_> {
|
||||
let started = Instant::now();
|
||||
if self.title.is_none() && !self.pause {
|
||||
horizon_client::spans::time("tick", || self.tick())?;
|
||||
self.tick_ambience();
|
||||
// Cave mood (a shader pack's playerMood) samples on a random
|
||||
// seeded from the clock: under `HORIZON_STILL` it waits for the
|
||||
// world and draws from its tick.
|
||||
if !self.clock.still() {
|
||||
self.tick_ambience();
|
||||
} else if self.clock.runs_normally() {
|
||||
self.ambience.reseed(self.visual_ticks() as i64);
|
||||
self.tick_ambience();
|
||||
}
|
||||
self.tick_fov();
|
||||
}
|
||||
self.tick_win_screen()?;
|
||||
@@ -19498,9 +19602,11 @@ impl<'a> Game<'a> {
|
||||
repro: Default::default(),
|
||||
agent: agent::Agent::default(),
|
||||
view_frustum: None,
|
||||
entity_culler: None,
|
||||
async_entity_culler: None,
|
||||
async_entity_culling_failed: false,
|
||||
entity_section_visibility_valid: false,
|
||||
entity_section_camera: None,
|
||||
sync_occlusion_culler: None,
|
||||
async_occlusion_culler: None,
|
||||
async_occlusion_culling_failed: false,
|
||||
async_particle_culler: RefCell::new(Default::default()),
|
||||
pack_shadow_casters: (false, false),
|
||||
pending_uploads: Vec::new(),
|
||||
@@ -19947,6 +20053,14 @@ impl Game<'_> {
|
||||
None => self.res.stations(),
|
||||
}
|
||||
}
|
||||
/// The enchantments item names and tooltips read: a server's, with the
|
||||
/// entries its data packs add, else the world's.
|
||||
fn enchantments(&self) -> &horizon_client::enchant::Enchantments {
|
||||
match self.remote.as_ref().and_then(|r| r.enchantments.as_ref()) {
|
||||
Some(e) => e,
|
||||
None => &self.stations().enchantments,
|
||||
}
|
||||
}
|
||||
/// The world's packs' content files, none without packs.
|
||||
/// The biome table the world plays with, `None` before a world opens.
|
||||
fn packed_biomes(&self) -> Option<Arc<Biomes>> {
|
||||
@@ -20026,6 +20140,20 @@ impl Game<'_> {
|
||||
self.res.core.collision.clone(),
|
||||
&causes_suffocation,
|
||||
)?;
|
||||
// A registry whose pack tags don't load keeps the game's own tags
|
||||
// (`in_tag` falls back to them), rather than matching no tag at all.
|
||||
let pack_tags = WorldTables::PACK_TAG_REGISTRIES
|
||||
.into_iter()
|
||||
.filter_map(|registry| {
|
||||
match horizon_client::items::load_tags(root, registry, &overlay) {
|
||||
Ok(tags) => Some((registry, tags)),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: the world's {registry} tags are not applied: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok((
|
||||
Some(WorldTables {
|
||||
suffocation,
|
||||
@@ -20034,6 +20162,7 @@ impl Game<'_> {
|
||||
recipes,
|
||||
stations,
|
||||
data_maps,
|
||||
pack_tags,
|
||||
}),
|
||||
health,
|
||||
))
|
||||
@@ -22355,6 +22484,19 @@ fn debug_crosshair_drawn(
|
||||
enabled && first_person && !hide_gui && !panorama
|
||||
}
|
||||
|
||||
/// Blocks whose class implements `GameMasterBlock`: the command blocks, structure
|
||||
/// block, jigsaw and the test blocks.
|
||||
fn is_game_master_block(name: &str) -> bool {
|
||||
horizon_client::command_block::Mode::of_block(name).is_some()
|
||||
|| matches!(
|
||||
name,
|
||||
"minecraft:structure_block"
|
||||
| "minecraft:jigsaw"
|
||||
| "minecraft:test_block"
|
||||
| "minecraft:test_instance_block"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
||||
@@ -209,8 +209,21 @@ impl adv::World for View<'_, '_> {
|
||||
fn smokey(&self, p: [i32; 3]) -> bool {
|
||||
horizon_client::beehive::smokey(&self.0.mob_world_base(&[]), p)
|
||||
}
|
||||
fn block_entity_nbt(&self, p: [i32; 3]) -> Option<horizon_world::nbt::Tag> {
|
||||
self.0.block_entity_nbt(p)
|
||||
}
|
||||
fn in_tag(&self, registry: &str, tag: &str, id: &str) -> bool {
|
||||
let items = &self.0.res.items();
|
||||
// The world's packs' tags over the game's own, as the server's
|
||||
// registries bind them (`TagLoader.loadTagsForExistingRegistries`).
|
||||
if let Some(tags) = self
|
||||
.0
|
||||
.world_tables
|
||||
.as_ref()
|
||||
.and_then(|t| t.pack_tags.get(registry))
|
||||
{
|
||||
return tags.get(tag).is_some_and(|t| t.contains(id));
|
||||
}
|
||||
let items = self.0.items();
|
||||
match registry {
|
||||
"item" => items.item_in_tag(tag, id),
|
||||
"block" => items.block_in_tag(tag, id),
|
||||
@@ -871,10 +884,7 @@ impl Game<'_> {
|
||||
.recipe_book
|
||||
.add_recipes(&world_stations!(self).recipe_index, &rewards);
|
||||
for table in &a.rewards.loot {
|
||||
let items = self
|
||||
.res
|
||||
.items()
|
||||
.loot_table(table, false, None, &mut self.world.random);
|
||||
let items = world_items!(self).loot_table(table, false, None, &mut self.world.random);
|
||||
for s in items {
|
||||
if let Some(rest) = self.give(s)
|
||||
&& let Some(eye) = self.eye_position()
|
||||
|
||||
@@ -1659,6 +1659,11 @@ impl Game<'_> {
|
||||
if text.is_empty() {
|
||||
return Err("command TEXT".into());
|
||||
}
|
||||
// A native mod's command first, as typed chat does; the
|
||||
// server's console cannot run a client mod's command.
|
||||
if self.client_mod_command(text) {
|
||||
return ok(json!({ "ok": true, "chat": self.ui.chat.recent_lines(5) }));
|
||||
}
|
||||
let command = format!("/{text}");
|
||||
// Single player's console is its server thread's.
|
||||
if let Some(server) = &self.integrated {
|
||||
|
||||
@@ -21,6 +21,48 @@ use horizon_core::{
|
||||
};
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc};
|
||||
|
||||
/// Velocity `AbstractMinecart.push` adds: the cart, and a quarter on the stand.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct CartShove {
|
||||
cart_x: f64,
|
||||
cart_z: f64,
|
||||
stand_x: f64,
|
||||
stand_z: f64,
|
||||
}
|
||||
/// `ArmorStand.pushEntities` for one cart. `None` when the cart is not the
|
||||
/// plain rideable kind, the boxes miss, `distanceToSqr` is past 0.2, the
|
||||
/// stand is a passenger of the cart, or the horizontal gap is under
|
||||
/// `1.0E-4F` (where `AbstractMinecart.push` does nothing).
|
||||
fn armor_stand_cart_shove(
|
||||
stand_pos: Vec3,
|
||||
stand_box: Aabb,
|
||||
cart_pos: Vec3,
|
||||
cart_box: Aabb,
|
||||
kind: super::minecarts::CartKind,
|
||||
cart_carries_stand: bool,
|
||||
) -> Option<CartShove> {
|
||||
if kind != super::minecarts::CartKind::Rideable
|
||||
|| cart_carries_stand
|
||||
|| !stand_box.intersects(cart_box)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let (dx, dy, dz) = (
|
||||
stand_pos.x - cart_pos.x,
|
||||
stand_pos.y - cart_pos.y,
|
||||
stand_pos.z - cart_pos.z,
|
||||
);
|
||||
if dx * dx + dy * dy + dz * dz > 0.2 {
|
||||
return None;
|
||||
}
|
||||
let (xa, za) = super::minecarts::cart_push_from(stand_pos, cart_pos)?;
|
||||
Some(CartShove {
|
||||
cart_x: -xa,
|
||||
cart_z: -za,
|
||||
stand_x: xa / 4.0,
|
||||
stand_z: za / 4.0,
|
||||
})
|
||||
}
|
||||
/// `EquipmentLayerRenderer.getColorForLayer`: a dyeable layer (one with a
|
||||
/// colour when undyed) takes the stack's `minecraft:dyed_color`, else that
|
||||
/// colour; any other layer stays white, dyed or not. The result is in the
|
||||
@@ -879,6 +921,9 @@ impl Game<'_> {
|
||||
true,
|
||||
);
|
||||
}
|
||||
// LivingEntity.aiStep calls pushEntities after travel. The stand's
|
||||
// own doPush is empty; only this shove runs.
|
||||
self.push_rideable_minecarts(i);
|
||||
let a = &mut self.armor_stands[i];
|
||||
if !a.environment_tick(&self.world, self.res.facts(), self.pack.assets.registry) {
|
||||
dead.push(i);
|
||||
@@ -889,6 +934,50 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `ArmorStand.pushEntities`: a plain minecart (`RIDABLE_MINECARTS`,
|
||||
/// `AbstractMinecart.isRideable`) whose box meets the stand's is shoved
|
||||
/// when `distanceToSqr` is at most 0.2. `AbstractMinecart.push` of a
|
||||
/// non-cart adds the push to the cart and a quarter of it to the stand
|
||||
/// (`Entity.push(double)` does not consult `isPushable`). A stand with no
|
||||
/// physics (`Marker` or `NoGravity`, `ArmorStand.hasPhysics` / `noPhysics`)
|
||||
/// or one riding that cart does not shove it.
|
||||
fn push_rideable_minecarts(&mut self, i: usize) {
|
||||
let stand = &self.armor_stands[i];
|
||||
if stand.marker || stand.no_gravity {
|
||||
return;
|
||||
}
|
||||
let stand_pos = stand.position;
|
||||
let stand_box = stand.bounds();
|
||||
let vehicle = stand.vehicle;
|
||||
let mut applied = Vec::new();
|
||||
for (j, cart) in self.carts.iter().enumerate() {
|
||||
let Some(shove) = armor_stand_cart_shove(
|
||||
stand_pos,
|
||||
stand_box,
|
||||
cart.position,
|
||||
cart.bounds(),
|
||||
cart.kind,
|
||||
vehicle == Some(cart.id),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
applied.push((j, shove));
|
||||
}
|
||||
let mut stand_x = 0.0;
|
||||
let mut stand_z = 0.0;
|
||||
for (j, shove) in applied {
|
||||
let cart = &mut self.carts[j];
|
||||
cart.velocity.x += shove.cart_x;
|
||||
cart.velocity.z += shove.cart_z;
|
||||
stand_x += shove.stand_x;
|
||||
stand_z += shove.stand_z;
|
||||
}
|
||||
if stand_x != 0.0 || stand_z != 0.0 {
|
||||
let stand = &mut self.armor_stands[i];
|
||||
stand.velocity.x += stand_x;
|
||||
stand.velocity.z += stand_z;
|
||||
}
|
||||
}
|
||||
/// An armor stand is a `LivingEntity`, so the egg under it
|
||||
/// (`getOnPosLegacy`) cracks like under a mob: `TurtleEggBlock.fallOn` one
|
||||
/// landing in 3 (`Entity.move`'s `checkFallDamage`), then `stepOn` one tick
|
||||
@@ -951,7 +1040,6 @@ impl Game<'_> {
|
||||
// The stands' models posed on the GPU path (`mobs::skin`), when it runs this frame.
|
||||
let skinning = self.skinning();
|
||||
for a in &self.armor_stands {
|
||||
let entity_start = out.len();
|
||||
// ArmorStand.shouldRenderAtSqrDistance: 256 times the box's mean
|
||||
// edge (4 for a marker's empty box), whatever the entity distance.
|
||||
// LivingEntityRenderer grows the culling box by 0.5 for a dragon head.
|
||||
@@ -981,6 +1069,11 @@ impl Game<'_> {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let culling = self.entity_cull_decision(a, alpha);
|
||||
if culling.hides_entity() {
|
||||
continue;
|
||||
}
|
||||
let entity_start = out.len();
|
||||
// EntityRenderer: drawn between last tick's position and this one's.
|
||||
let at = a.render_position(alpha);
|
||||
let p = super::entity_culling::at(at) - camera;
|
||||
@@ -1014,7 +1107,9 @@ impl Game<'_> {
|
||||
self.armor_stand_vertices(a, base, packed, None, out);
|
||||
None
|
||||
};
|
||||
self.cull_skinned(out, entity_start, skinned);
|
||||
if !self.entity_glowing(a.id) {
|
||||
self.cull_skinned(out, entity_start, skinned);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ArmorStandRenderer.submit` for stand `a` under `base` (its
|
||||
@@ -1683,6 +1778,84 @@ mod burn_drop_tests {
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod push_tests {
|
||||
use super::super::minecarts::{CartKind, Minecart};
|
||||
use super::*;
|
||||
|
||||
fn boxes(kind: CartKind, cart_at: Vec3) -> (Aabb, Aabb) {
|
||||
let stand = ArmorStand::new(1, Vec3::ZERO, 0.0);
|
||||
let cart = Minecart::new(2, kind, cart_at);
|
||||
(stand.bounds(), cart.bounds())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rideable_minecart_inside_the_0_2_gate_is_shoved() {
|
||||
// 0.25² is inside the 0.2 gate; 0.5² is not. The function's Some/None is the check.
|
||||
let near = Vec3::new(0.25, 0.0, 0.0);
|
||||
let far = Vec3::new(0.5, 0.0, 0.0);
|
||||
let (stand_box, cart_box) = boxes(CartKind::Rideable, near);
|
||||
let shove = armor_stand_cart_shove(
|
||||
Vec3::ZERO,
|
||||
stand_box,
|
||||
near,
|
||||
cart_box,
|
||||
CartKind::Rideable,
|
||||
false,
|
||||
)
|
||||
.expect("a rideable cart 0.25 away is shoved");
|
||||
// AbstractMinecart.push: gap 0.25, length 0.25, pow capped at 1, then 0.1F * 0.5.
|
||||
// The cart moves east, away from the stand; the stand takes a quarter west.
|
||||
let scale = f64::from(0.1_f32) * 0.5;
|
||||
assert_eq!(shove.cart_x, scale);
|
||||
assert_eq!(shove.cart_z, 0.0);
|
||||
assert_eq!(shove.stand_x, -scale / 4.0);
|
||||
assert_eq!(shove.stand_z, 0.0);
|
||||
|
||||
let (stand_box, cart_box) = boxes(CartKind::Rideable, far);
|
||||
assert!(
|
||||
armor_stand_cart_shove(
|
||||
Vec3::ZERO,
|
||||
stand_box,
|
||||
far,
|
||||
cart_box,
|
||||
CartKind::Rideable,
|
||||
false
|
||||
)
|
||||
.is_none(),
|
||||
"past 0.2 the cart stays"
|
||||
);
|
||||
for kind in [
|
||||
CartKind::Chest,
|
||||
CartKind::Hopper,
|
||||
CartKind::Furnace,
|
||||
CartKind::Tnt,
|
||||
CartKind::CommandBlock,
|
||||
CartKind::Spawner,
|
||||
] {
|
||||
let (stand_box, cart_box) = boxes(kind, near);
|
||||
assert!(
|
||||
armor_stand_cart_shove(Vec3::ZERO, stand_box, near, cart_box, kind, false)
|
||||
.is_none(),
|
||||
"{} is not a rideable minecart",
|
||||
kind.id()
|
||||
);
|
||||
}
|
||||
let (stand_box, cart_box) = boxes(CartKind::Rideable, near);
|
||||
assert!(
|
||||
armor_stand_cart_shove(
|
||||
Vec3::ZERO,
|
||||
stand_box,
|
||||
near,
|
||||
cart_box,
|
||||
CartKind::Rideable,
|
||||
true
|
||||
)
|
||||
.is_none(),
|
||||
"a cart does not shove the stand riding it"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod layer_color_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
|
||||
@@ -1477,7 +1477,6 @@ impl Game<'_> {
|
||||
self.view_scale(),
|
||||
);
|
||||
for b in &self.boats {
|
||||
let entity_start = out.len();
|
||||
// EntityRenderer.shouldRender.
|
||||
let (previous, position) = (at(b.previous), at(b.position));
|
||||
if !self.standing_entity_in_view(
|
||||
@@ -1485,6 +1484,11 @@ impl Game<'_> {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let culling = self.entity_cull_decision(b, alpha);
|
||||
if culling.hides_entity() {
|
||||
continue;
|
||||
}
|
||||
let entity_start = out.len();
|
||||
let models = if b.chest {
|
||||
&self.boat_models.chest_models
|
||||
} else {
|
||||
@@ -1579,7 +1583,7 @@ impl Game<'_> {
|
||||
out,
|
||||
);
|
||||
}
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
self.finish_entity_occlusion(culling, b, out, entity_start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,9 +99,8 @@ impl Game<'_> {
|
||||
};
|
||||
let done = c.cook_tick();
|
||||
for (_, item) in done {
|
||||
let result = self
|
||||
.res
|
||||
.recipes()
|
||||
// The world's table, as placing the item (`self.recipes()`) asked.
|
||||
let result = world_recipes!(self)
|
||||
.cook(KIND, &item)
|
||||
.map_or_else(|| item.clone(), |r| r.result.clone());
|
||||
self.native_block_cook([pos.0, pos.1, pos.2], &item.item, &result.item);
|
||||
|
||||
@@ -63,6 +63,7 @@ impl Game<'_> {
|
||||
) -> horizon_client::command::Dispatcher {
|
||||
let mut names = Vec::new();
|
||||
self.native_commands(&mut names);
|
||||
names.push("studio");
|
||||
let base = match base {
|
||||
Some(tree) => tree,
|
||||
None => horizon_client::command::vanilla::game_dispatcher(),
|
||||
@@ -908,7 +909,7 @@ impl Game<'_> {
|
||||
text,
|
||||
indicator,
|
||||
source,
|
||||
self.ticks,
|
||||
self.visual_ticks(),
|
||||
focused,
|
||||
);
|
||||
}
|
||||
@@ -1073,7 +1074,7 @@ impl Game<'_> {
|
||||
line,
|
||||
None,
|
||||
ChatSource::Player,
|
||||
self.ticks,
|
||||
self.visual_ticks(),
|
||||
focused,
|
||||
);
|
||||
}
|
||||
@@ -1331,7 +1332,7 @@ impl Game<'_> {
|
||||
self.ui.chat.hide_indicator = !self.settings.extras.chat_indicator;
|
||||
// The fractional tick makes the smooth chat extra's slide move every
|
||||
// frame; vanilla's fade reads it too.
|
||||
let now = self.ticks as f32 + self.clock.fraction() as f32;
|
||||
let now = self.visual_ticks() as f32 + self.clock.fraction() as f32;
|
||||
self.ui.chat.draw(batch, &self.gui.atlas, now, focused);
|
||||
}
|
||||
/// `HoverEvent.EntityTooltipInfo.getTooltipLines`: the name when the
|
||||
@@ -1671,7 +1672,7 @@ impl Game<'_> {
|
||||
message,
|
||||
None,
|
||||
ChatSource::Client,
|
||||
self.ticks,
|
||||
self.visual_ticks(),
|
||||
focused,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! system message it produced, as styled spans, with the results reported to
|
||||
//! the player's `CommandResultCallback`; `tick` advances the game; `settle`
|
||||
//! waits until the terrain and entities around the player are loaded;
|
||||
//! `use_item` dispatches the main-hand server packet; `map_state` reads saved map metadata;
|
||||
//! `use_block` uses the main hand at a block top;
|
||||
//! `break` destroys a block as the server player; `portal` sends the player
|
||||
//! through the nether portal it stands in; `falling` counts physical falling
|
||||
@@ -341,6 +342,48 @@ impl Game<'_> {
|
||||
out.push_str(&record.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
"player_loop" => {
|
||||
let ticks = op["n"].as_u64().unwrap_or(1);
|
||||
let count = |g: &mut Self| {
|
||||
Ok(g.stats
|
||||
.values
|
||||
.get("minecraft:custom")
|
||||
.and_then(|m| m.get("minecraft:play_time"))
|
||||
.copied()
|
||||
.unwrap_or(0))
|
||||
};
|
||||
let before = self.harness_owner(count)?;
|
||||
self.chat_capture = Some(Vec::new());
|
||||
for _ in 0..ticks {
|
||||
self.tick_server(false)?;
|
||||
}
|
||||
let messages = self.chat_capture.take().unwrap_or_default();
|
||||
let delta = self.harness_owner(count)? - before;
|
||||
if delta != ticks as i32 {
|
||||
return Err(format!(
|
||||
"player loop did not advance play_time: {delta}/{ticks}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let record = json!({"cmd": format!("#player_loop {}",op["label"].as_str().unwrap_or("")), "messages": messages.iter().map(spans).collect::<Vec<_>>(), "results": [[true,delta]]});
|
||||
out.push_str(&record.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
"player_state" => {
|
||||
let state = self.harness_owner(|g| {
|
||||
let p = g.player.as_ref().ok_or("player state: no actor")?;
|
||||
let mut loaded = Vec::new();
|
||||
let mut done = std::collections::BTreeMap::new();
|
||||
for a in g.adv.tree.list.iter().filter(|a| a.id.starts_with("h311:")) {
|
||||
loaded.push(a.id.clone()); done.insert(a.id.clone(),g.adv.progress.done(a));
|
||||
}
|
||||
loaded.sort();
|
||||
Ok(json!({"health":p.health.health(),"poison_ticks":g.potions.effects.list.iter().find(|e|e.id=="poison").map_or(0,|e|e.duration),"loaded":loaded,"done":done}))
|
||||
})?;
|
||||
let record = json!({"cmd":format!("#player_state {}",op["label"].as_str().unwrap_or("")),"messages":[],"results":[],"state":state});
|
||||
out.push_str(&record.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
"tick" => {
|
||||
self.chat_capture = Some(Vec::new());
|
||||
let paced_start = Instant::now();
|
||||
@@ -391,7 +434,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.harness_owner(|g| g.harness_settle(deadline, op.get("required_chunks")))?;
|
||||
}
|
||||
"use_item" => {
|
||||
"use_item" if op.get("sequence").is_some() => {
|
||||
// Exercise the connected owner's normal server packet route.
|
||||
let sequence = op["sequence"].as_i64().unwrap_or(0) as i32;
|
||||
let yaw = op["yaw"].as_f64().unwrap_or(0.0) as f32;
|
||||
@@ -437,6 +480,76 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
// Java produced the fixture file before this world was opened; map_state loads it.
|
||||
"crafter_max_map_fixture" => {}
|
||||
"use_item" => {
|
||||
let owner = self
|
||||
.serving
|
||||
.as_ref()
|
||||
.ok_or("command harness: no server")?
|
||||
.owner;
|
||||
self.in_owner_level(|g| -> Result<()> {
|
||||
let conn = g
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|guest| guest.uuid == owner && guest.placed)
|
||||
.map(|guest| guest.conn)
|
||||
})
|
||||
.ok_or("command harness: owner is not connected")?;
|
||||
let mut host = g.lan.take().ok_or("command harness: no hub")?;
|
||||
g.lan_guest_packet(
|
||||
&mut host,
|
||||
conn,
|
||||
horizon_protocol::packets::play::Serverbound::UseItem(
|
||||
horizon_protocol::packets::play::UseItem {
|
||||
hand: 0,
|
||||
sequence: 0,
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
},
|
||||
),
|
||||
);
|
||||
g.lan = Some(host);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
"map_state" => {
|
||||
let id = op["id"].as_i64().ok_or("command harness: map id")? as i32;
|
||||
let value = self.harness_owner(|g| {
|
||||
Ok(match g.map(id) {
|
||||
Some(m) => format!(
|
||||
"scale={},locked={},center={},{}",
|
||||
m.scale, m.locked, m.center[0], m.center[1]
|
||||
),
|
||||
None => "missing".to_owned(),
|
||||
})
|
||||
})?;
|
||||
let record = json!({"cmd": format!("#map_state {id}"),
|
||||
"messages": [spans(&Text::literal(value))], "results": []});
|
||||
out.push_str(&record.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
"light" => {
|
||||
let pos = std::array::from_fn::<_, 3, _>(|i| {
|
||||
op["pos"][i].as_i64().unwrap_or(0) as i32
|
||||
});
|
||||
let levels = self.harness_owner(|g| {
|
||||
use horizon_client::mobs::spawn_rules::SpawnLevel;
|
||||
let level = g.natural_level();
|
||||
Ok([
|
||||
level.block_light(pos[0], pos[1], pos[2]),
|
||||
level.sky_light(pos[0], pos[1], pos[2]),
|
||||
])
|
||||
})?;
|
||||
let record = json!({"cmd": format!("#light {}", op["pos"]),
|
||||
"messages": [], "results": levels.map(|v| (true, v))});
|
||||
out.push_str(&record.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
"use_block" => {
|
||||
// Exercise the connected owner's normal server packet route.
|
||||
let pos = std::array::from_fn(|i| op["pos"][i].as_i64().unwrap_or(0) as i32);
|
||||
@@ -478,6 +591,17 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
"crafter_tick" => {
|
||||
let [x, y, z] =
|
||||
std::array::from_fn(|i| op["pos"][i].as_i64().unwrap_or(0) as i32);
|
||||
self.harness_owner(|g| {
|
||||
let state = g.world.block(x, y, z);
|
||||
if &*g.res.facts().get(state).name != "minecraft:crafter" {
|
||||
return Err("command harness: crafter tick without a crafter".into());
|
||||
}
|
||||
g.dispense_from(horizon_core::position::BlockPos::new(x, y, z), state)
|
||||
})?;
|
||||
}
|
||||
"break" => {
|
||||
let pos = std::array::from_fn(|i| op["pos"][i].as_i64().unwrap_or(0) as i32);
|
||||
self.harness_owner(|g| g.break_block(pos))?;
|
||||
|
||||
@@ -697,11 +697,13 @@ impl Game<'_> {
|
||||
let block = Text::translate(&format!("block.{block}"), &[]);
|
||||
Text::translate("entity.minecraft.falling_block_type", &[block])
|
||||
}
|
||||
Target::Mob(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map_or_else(|| Text::translate("entity.", &[]), |m| m.command_name()),
|
||||
Target::Mob(id) => self.mobs.iter().find(|m| m.id == id).map_or_else(
|
||||
|| Text::translate("entity.", &[]),
|
||||
|m| match self.mob_team_of(m) {
|
||||
Some(team) => team.formatted_name(m.command_name()),
|
||||
None => m.command_name(),
|
||||
},
|
||||
),
|
||||
other => {
|
||||
// Entity.getDisplayName: the CustomName component, else the type.
|
||||
let custom = match other {
|
||||
@@ -3342,10 +3344,6 @@ impl Game<'_> {
|
||||
// reopen, so it follows the command.
|
||||
self.level.set_difficulty(i);
|
||||
self.sync_rules();
|
||||
if i == 0 {
|
||||
// Peaceful removes hostile mobs.
|
||||
self.mobs.retain(|m| !m.hostile());
|
||||
}
|
||||
// MinecraftServer.setDifficulty: every player, guests included, is
|
||||
// sent the new difficulty.
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
@@ -3991,7 +3989,7 @@ impl Game<'_> {
|
||||
line,
|
||||
None,
|
||||
horizon_client::chat::ChatSource::Player,
|
||||
self.ticks,
|
||||
self.visual_ticks(),
|
||||
focused,
|
||||
);
|
||||
Ok(1)
|
||||
|
||||
@@ -354,6 +354,10 @@ impl Game<'_> {
|
||||
slot.extra.tame = old.extra.tame;
|
||||
slot.extra.owner = old.extra.owner;
|
||||
}
|
||||
// Entity.load mutates the existing living entity; its damage
|
||||
// cooldown and previous-hit amount are not read from saved NBT.
|
||||
slot.invulnerable = old.invulnerable;
|
||||
slot.last_hurt = old.last_hurt;
|
||||
slot.ai = old.ai;
|
||||
// Mob.readAdditionalSaveData updates persistent home fields,
|
||||
// while retaining the rest of the live AI/runtime state.
|
||||
@@ -625,35 +629,34 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(tag)
|
||||
}
|
||||
DataRef::Block(p) => {
|
||||
if let Some(tag) = self.moving_piston_data(*p) {
|
||||
return Ok(tag);
|
||||
}
|
||||
if let Some(tag) = self.comparator_data(*p).or_else(|| self.pot_data(*p)) {
|
||||
return Ok(tag);
|
||||
}
|
||||
let key = (p[0], p[1], p[2]);
|
||||
let name = self.data_block_name(*p);
|
||||
let e = self
|
||||
.world
|
||||
.block_entities
|
||||
.get(&key)
|
||||
.ok_or_else(|| err("commands.data.block.invalid", &[]))?;
|
||||
// BlockEntity.saveWithFullMetadata: no chunk-only keepPacked,
|
||||
// and the lock and custom name the model does not hold.
|
||||
let mut tag = save::block_entity_data(key, e, &name, self.world.block_lock(key));
|
||||
if let (Some(custom), Tag::Compound(map)) =
|
||||
(self.world.block_names.get(&key), &mut tag)
|
||||
{
|
||||
map.entry("CustomName".into())
|
||||
.or_insert_with(|| Tag::String(custom.as_str().into()));
|
||||
}
|
||||
self.restore_loot_table(key, &mut tag);
|
||||
Ok(tag)
|
||||
}
|
||||
DataRef::Block(p) => self
|
||||
.block_entity_nbt(*p)
|
||||
.ok_or_else(|| err("commands.data.block.invalid", &[])),
|
||||
DataRef::Storage(id) => Ok(self.score.storage.get(id)),
|
||||
}
|
||||
}
|
||||
/// `BlockEntity.saveWithFullMetadata` of the block entity at `p`: no
|
||||
/// chunk-only keepPacked, and the lock and custom name the model does
|
||||
/// not hold. `None` where there is no block entity.
|
||||
pub(in crate::game) fn block_entity_nbt(&self, p: [i32; 3]) -> Option<Tag> {
|
||||
// A live moving piston is the level's block entity there (#9617).
|
||||
if let Some(tag) = self.moving_piston_data(p) {
|
||||
return Some(tag);
|
||||
}
|
||||
if let Some(tag) = self.comparator_data(p).or_else(|| self.pot_data(p)) {
|
||||
return Some(tag);
|
||||
}
|
||||
let key = (p[0], p[1], p[2]);
|
||||
let name = self.data_block_name(p);
|
||||
let e = self.world.block_entities.get(&key)?;
|
||||
let mut tag = save::block_entity_data(key, e, &name, self.world.block_lock(key));
|
||||
if let (Some(custom), Tag::Compound(map)) = (self.world.block_names.get(&key), &mut tag) {
|
||||
map.entry("CustomName".into())
|
||||
.or_insert_with(|| Tag::String(custom.as_str().into()));
|
||||
}
|
||||
self.restore_loot_table(key, &mut tag);
|
||||
Some(tag)
|
||||
}
|
||||
pub(in crate::game) fn set_data_of(&mut self, r: &DataRef, tag: Tag) -> R<()> {
|
||||
match r {
|
||||
DataRef::Entity(t) => self.apply_entity_nbt(*t, tag),
|
||||
|
||||
@@ -244,7 +244,7 @@ impl Game<'_> {
|
||||
/// hand them the next jobs as soon as each one is done, rather than once
|
||||
/// a tick.
|
||||
pub(in crate::game) fn pump_chunks(&mut self, until: Instant, paused: bool) {
|
||||
// Force-loaded chunks too: decorating in vanilla's order, a chunk
|
||||
// Force-loaded chunks too, a mod's held ones as `/forceload`'s: decorating in vanilla's order, a chunk
|
||||
// waits for its neighbours far more often than once a tick allows.
|
||||
// And the players' chunks while they load, as vanilla's server runs
|
||||
// its chunk tasks while it waits for the next tick
|
||||
@@ -255,7 +255,10 @@ impl Game<'_> {
|
||||
// only asked for by the tick's own `update_world`.
|
||||
let players = !paused && self.spawn_wait.is_empty();
|
||||
while self.pregen_runs_here()
|
||||
|| ((players || !self.streaming.forced.is_empty()) && self.streaming.busy())
|
||||
|| ((players
|
||||
|| !self.streaming.forced.is_empty()
|
||||
|| !self.streaming.mod_forced.is_empty())
|
||||
&& self.streaming.busy())
|
||||
{
|
||||
let now = Instant::now();
|
||||
if now >= until {
|
||||
|
||||
@@ -556,7 +556,9 @@ impl Game<'_> {
|
||||
return Ok(0);
|
||||
};
|
||||
let ty = self.target_type(t);
|
||||
let table = format!("minecraft:entities/{}", ty.trim_start_matches("minecraft:"));
|
||||
// `EntityType.getDefaultLootTable`, in the type's namespace.
|
||||
let (namespace, path) = ty.split_once(':').unwrap_or(("minecraft", ty.as_str()));
|
||||
let table = format!("{namespace}:entities/{path}");
|
||||
if t == Target::Player || !world_items!(self).has_loot_table(&table) {
|
||||
return Err(err(
|
||||
"commands.drop.no_loot_table.entity",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `/particle`, `/attribute`, `/ride` and `/spreadplayers`.
|
||||
use super::{Arg, Context, Game, R, Target, err, wrap_degrees};
|
||||
use horizon_client::{
|
||||
mobs::{Damage, DamageKind, Who, finalize},
|
||||
mobs::{Damage, DamageKind, MobEvent, Who, finalize},
|
||||
scoreboard_cmd::{Holder, Tracker},
|
||||
settings::SOUND_SOURCES,
|
||||
text::Text,
|
||||
@@ -12,6 +12,24 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use horizon_world::attribute_map::ValueKind;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// Queue bookkeeping for synchronous command damage, preserving prior requests
|
||||
/// and unrelated events. The existing game policy handles each extracted request.
|
||||
fn take_new_reinforcements(events: &mut Vec<MobEvent>, queued: usize) -> Vec<(u32, Who)> {
|
||||
let mut requests = Vec::new();
|
||||
let mut index = 0;
|
||||
events.retain(|event| {
|
||||
let new = index >= queued;
|
||||
index += 1;
|
||||
if new && let MobEvent::Reinforcements { mob, target } = event {
|
||||
requests.push((*mob, *target));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
requests
|
||||
}
|
||||
|
||||
fn num(n: impl std::fmt::Display) -> Text {
|
||||
Text::literal(n.to_string())
|
||||
}
|
||||
@@ -359,13 +377,18 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
|
||||
/// Retain the mob hit's causing-player connection after the hit is accepted.
|
||||
/// `DamageCommand.damage`: retain the accepted hit's player context and run
|
||||
/// `Zombie.hurtServer`'s reinforcement requests before the command returns.
|
||||
fn command_damage_mob(&mut self, id: u32, amount: f32, damage: Damage) -> bool {
|
||||
let hurt = self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id)
|
||||
.is_some_and(|m| m.damage(amount, damage));
|
||||
let mut reinforcements = Vec::new();
|
||||
let hurt = self.mobs.iter_mut().find(|m| m.id == id).is_some_and(|m| {
|
||||
let queued = m.outbox.len();
|
||||
let hurt = m.damage(amount, damage);
|
||||
if hurt {
|
||||
reinforcements = take_new_reinforcements(&mut m.outbox, queued);
|
||||
}
|
||||
hurt
|
||||
});
|
||||
if hurt {
|
||||
self.resolve_player_responsible(id, damage);
|
||||
if damage.attacker == Some(Who::Player) {
|
||||
@@ -382,6 +405,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (mob, target) in reinforcements {
|
||||
self.call_reinforcements(mob, target);
|
||||
}
|
||||
hurt
|
||||
}
|
||||
|
||||
@@ -1895,7 +1921,44 @@ pub(super) fn look_at(from: [f64; 3], to: [f64; 3]) -> [f32; 2] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{look_at, playsound_placement};
|
||||
use super::{look_at, playsound_placement, take_new_reinforcements};
|
||||
use horizon_client::mobs::{MobEvent, Who};
|
||||
|
||||
#[test]
|
||||
fn command_reinforcements_leave_prior_requests_and_other_events_queued() {
|
||||
let request = |mob| MobEvent::Reinforcements {
|
||||
mob,
|
||||
target: Who::Player,
|
||||
};
|
||||
let mut events = vec![request(1), MobEvent::Sound("before", [0.; 3], 1., 1.)];
|
||||
let queued = events.len();
|
||||
events.extend([
|
||||
request(2),
|
||||
MobEvent::Sound("after", [0.; 3], 1., 1.),
|
||||
request(3),
|
||||
]);
|
||||
assert_eq!(
|
||||
take_new_reinforcements(&mut events, queued),
|
||||
[(2, Who::Player), (3, Who::Player)]
|
||||
);
|
||||
assert!(matches!(
|
||||
events.as_slice(),
|
||||
[
|
||||
MobEvent::Reinforcements { mob: 1, .. },
|
||||
MobEvent::Sound("before", ..),
|
||||
MobEvent::Sound("after", ..)
|
||||
]
|
||||
));
|
||||
assert!(take_new_reinforcements(&mut events, queued).is_empty());
|
||||
assert_eq!(events.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_reinforcements_tolerate_a_damage_handler_clearing_its_outbox() {
|
||||
let mut events = vec![MobEvent::Sound("after", [0.; 3], 1., 1.)];
|
||||
assert!(take_new_reinforcements(&mut events, 4).is_empty());
|
||||
assert_eq!(events.len(), 1);
|
||||
}
|
||||
|
||||
fn assert_angle(got: f32, want: f32) {
|
||||
assert!((got - want).abs() < 1.0e-4, "{got} is not {want}");
|
||||
|
||||
@@ -17,7 +17,7 @@ const PROTOCOL: &str = "0.1.0";
|
||||
/// The command groups the game serves, as `help` chains them: what `hello` reports as its features.
|
||||
const FEATURES: &[&str] = &[
|
||||
"game", "station", "blocks", "lan", "friends", "render", "sculk", "agent", "ui", "options",
|
||||
"events", "saves", "pad",
|
||||
"events", "saves", "pad", "studio",
|
||||
];
|
||||
|
||||
/// `hello`: the protocol the socket speaks, the game's release and the command groups it serves.
|
||||
@@ -118,6 +118,7 @@ impl Game<'_> {
|
||||
&& let Some(flight) = self.flight.take()
|
||||
{
|
||||
let mut summary = self.frame_stats.summary();
|
||||
self.add_async_occlusion_stats(&mut summary);
|
||||
let n = flight.tick_ms.len().max(1) as f32;
|
||||
summary["tick_ms"] = (flight.tick_ms.iter().sum::<f32>() / n).into();
|
||||
summary["tick_ms_max"] = flight.tick_ms.iter().copied().fold(0.0, f32::max).into();
|
||||
@@ -164,8 +165,22 @@ impl Game<'_> {
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
self.add_async_occlusion_stats(&mut summary);
|
||||
summary
|
||||
}
|
||||
|
||||
fn add_async_occlusion_stats(&self, summary: &mut Value) {
|
||||
if let Some(culler) = &self.async_occlusion_culler {
|
||||
summary["async_occlusion_culling"] = culler.stats_value();
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_frame_stats(&mut self) {
|
||||
self.frame_stats.reset();
|
||||
if let Some(culler) = &self.async_occlusion_culler {
|
||||
culler.reset_stats();
|
||||
}
|
||||
}
|
||||
pub(super) fn poll_control(&mut self, event_loop: &dyn EventLoopCtl) {
|
||||
if let Some((deadline, reply)) = self.pending_bench.take() {
|
||||
if Instant::now() >= deadline {
|
||||
@@ -362,6 +377,9 @@ impl Game<'_> {
|
||||
if let Some(value) = self.friends_control(&words)? {
|
||||
return Ok(Some(value));
|
||||
}
|
||||
if let Some(value) = self.studio_control(&words)? {
|
||||
return Ok(Some(value));
|
||||
}
|
||||
match self.agent_command(&words, line, reply, event_loop)? {
|
||||
super::agent::Handled::Reply(value) => return Ok(Some(value)),
|
||||
super::agent::Handled::Deferred => return Ok(None),
|
||||
@@ -384,6 +402,7 @@ impl Game<'_> {
|
||||
.chain(super::pad::HELP)
|
||||
.chain(super::input_trace::HELP)
|
||||
.chain(super::repro::HELP)
|
||||
.chain(super::studio::HELP)
|
||||
.collect::<Vec<_>>() }))),
|
||||
"setblock" | "fill" | "particle" | "signedit" => self.render_command(&words),
|
||||
"section-draws" => {
|
||||
@@ -476,8 +495,13 @@ impl Game<'_> {
|
||||
let r = self.renderer.as_mut().ok_or("no renderer")?;
|
||||
match words.get(1).copied() {
|
||||
None => {}
|
||||
Some("on") => r.set_gpu_culling(true)?,
|
||||
Some("off") => r.set_gpu_culling(false)?,
|
||||
Some(mode @ ("on" | "off")) => {
|
||||
// Refused, with the reason, where it isn't offered.
|
||||
r.set_gpu_culling(mode == "on")?;
|
||||
// The setting, as its switch sets it; not saved here (as
|
||||
// `lod-distance`): settings changes and new renderers keep it.
|
||||
self.settings.horizon.gpu_culling = mode == "on";
|
||||
}
|
||||
Some(other) => return Err(format!("unknown GPU culling mode {other}").into()),
|
||||
}
|
||||
Ok(Some(json!({ "ok": true, "gpu_culling": r.gpu_culling() })))
|
||||
@@ -917,11 +941,10 @@ impl Game<'_> {
|
||||
}
|
||||
"chat" => {
|
||||
let text = line.trim_start().strip_prefix("chat").unwrap_or("").trim();
|
||||
if self.remote.is_some() {
|
||||
self.send_chat(text);
|
||||
return ok();
|
||||
}
|
||||
self.require_world()?;
|
||||
// As if typed: a client mod's command runs before the line
|
||||
// reaches the server, and the Chat option's restrictions and
|
||||
// the recent list apply to a connected server too.
|
||||
self.handle_chat_input(text);
|
||||
Ok(Some(
|
||||
json!({ "ok": true, "chat": self.ui.chat.recent_lines(5) }),
|
||||
@@ -1020,7 +1043,7 @@ impl Game<'_> {
|
||||
summary["gpu_ms"] = self.frame_stats.gpu_millis().into();
|
||||
}
|
||||
if words[1..].contains(&"reset") {
|
||||
self.frame_stats.reset();
|
||||
self.reset_frame_stats();
|
||||
}
|
||||
Ok(Some(summary))
|
||||
}
|
||||
@@ -1029,7 +1052,7 @@ impl Game<'_> {
|
||||
if self.pending_bench.is_some() {
|
||||
return Err("a benchmark is already running".into());
|
||||
}
|
||||
self.frame_stats.reset();
|
||||
self.reset_frame_stats();
|
||||
self.pending_bench = Some((
|
||||
Instant::now() + Duration::from_secs_f64(seconds),
|
||||
reply.clone(),
|
||||
@@ -1043,7 +1066,7 @@ impl Game<'_> {
|
||||
}
|
||||
let n = |i: usize| -> Result<f64> { Ok(arg(i)?.parse()?) };
|
||||
let pitch = words.get(8).map_or(Ok(10.0), |p| p.parse::<f32>())?;
|
||||
self.frame_stats.reset();
|
||||
self.reset_frame_stats();
|
||||
self.flight = Some(Flight {
|
||||
from: Vec3::new(n(1)?, n(2)?, n(3)?),
|
||||
to: Vec3::new(n(4)?, n(5)?, n(6)?),
|
||||
@@ -1328,6 +1351,8 @@ impl Game<'_> {
|
||||
"gpu_culling": r.gpu_culling(),
|
||||
"gpu_frame": r.gpu_frame(),
|
||||
"gpu_fading": r.gpu_fading(),
|
||||
"gpu_culling_offer": format!("{:?}", r.gpu_culling_offer()),
|
||||
"stats_lag": r.stats_lag(),
|
||||
"uploaded_bytes": r.uploaded_bytes,
|
||||
"resident_sections": r.resident_sections(),
|
||||
"arena_bytes": arena_bytes,
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
|
||||
//! breeding, conversions, drops), projectiles, player interactions
|
||||
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
|
||||
mod pufferfish_player;
|
||||
|
||||
use super::{
|
||||
Dim, Game, Hand, InteractTarget, dispenser, native_mob_draw::NativePlayerRender,
|
||||
native_projectiles::Impact, view_vector,
|
||||
Dim, Game, Hand, InteractTarget, dispenser, entity_culling::EntityCullDecision,
|
||||
native_mob_draw::NativePlayerRender, native_projectiles::Impact, view_vector,
|
||||
};
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{
|
||||
@@ -13,6 +15,7 @@ use horizon_client::{
|
||||
blocks::Fluid,
|
||||
collisions::SceneCollisions,
|
||||
crafting::Recipes,
|
||||
graphics::EntityCullingMode,
|
||||
health::{HurtSource, Hurting, Knock, knockback_canceled},
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
@@ -325,6 +328,9 @@ impl Game<'_> {
|
||||
main_hand: self.held().map(|s| s.item.clone()),
|
||||
off_hand: self.offhand.as_ref().map(|s| s.item.clone()),
|
||||
head: self.armor[0].as_ref().map(|s| s.item.clone()),
|
||||
chest: self.armor[1].as_ref().map(|s| s.item.clone()),
|
||||
legs: self.armor[2].as_ref().map(|s| s.item.clone()),
|
||||
feet: self.armor[3].as_ref().map(|s| s.item.clone()),
|
||||
armor_cover: self.armor.iter().filter(|a| a.is_some()).count() as f32 / 4.0,
|
||||
invisible: self.potions.effects.get("invisibility").is_some(),
|
||||
spectator: self.spectator(),
|
||||
@@ -3474,7 +3480,7 @@ impl Game<'_> {
|
||||
// The mix needs all of `self`, so it waits until `child`'s
|
||||
// borrow of the mob list is over.
|
||||
let mixed = dye_mix(
|
||||
self.res.recipes(),
|
||||
world_recipes!(self),
|
||||
&mut self.world.random,
|
||||
p.extra.color,
|
||||
other.extra.color,
|
||||
@@ -3502,7 +3508,7 @@ impl Game<'_> {
|
||||
// The mix needs all of `self`, so it waits until `child`'s
|
||||
// borrow of the mob list is over.
|
||||
let mixed = dye_mix(
|
||||
self.res.recipes(),
|
||||
world_recipes!(self),
|
||||
&mut self.world.random,
|
||||
p.extra.color,
|
||||
other.extra.color,
|
||||
@@ -3553,7 +3559,7 @@ impl Game<'_> {
|
||||
if p.species() == Species::Sheep {
|
||||
let other = q.as_ref().unwrap_or(&p);
|
||||
let mixed = dye_mix(
|
||||
self.res.recipes(),
|
||||
world_recipes!(self),
|
||||
&mut self.world.random,
|
||||
p.extra.color,
|
||||
other.extra.color,
|
||||
@@ -6896,6 +6902,25 @@ impl Game<'_> {
|
||||
skinned: Skinned,
|
||||
) {
|
||||
let corners = skinned.and_then(|(_, bounds)| bounds);
|
||||
if out.len() == entity_start {
|
||||
if let Some((lo, hi)) = corners
|
||||
&& self.cull_entity_camera_bounds(lo, hi)
|
||||
&& let Some((mark, _)) = skinned
|
||||
{
|
||||
if self.pack_shadow_casters.0 {
|
||||
let mut frame = self.skin_frame.borrow_mut();
|
||||
let end = frame.vertices as usize;
|
||||
horizon_client::gfx::pack_draw::EntitySpan::hide(
|
||||
&mut frame.spans,
|
||||
mark.2 as usize,
|
||||
end,
|
||||
);
|
||||
} else {
|
||||
self.skin_frame.borrow_mut().truncate(mark);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some((lo, hi)) = corners {
|
||||
out.extend([lo, hi].map(|pos| Vertex {
|
||||
pos: pos.to_array(),
|
||||
@@ -6935,6 +6960,30 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
// The coarse mob bounds pay off only in dense async scenes; otherwise,
|
||||
// keep the existing exact mesh-culling path.
|
||||
if self.settings.horizon.entity_culling == EntityCullingMode::Asynchronous
|
||||
&& self
|
||||
.async_occlusion_culler
|
||||
.as_ref()
|
||||
.is_some_and(|culler| culler.camera_is_stationary())
|
||||
&& self.settings.horizon.entity_culling_precheck != 0
|
||||
&& self.mobs.len() >= self.settings.horizon.entity_culling_precheck as usize
|
||||
&& !self.pack_shadow_casters.0
|
||||
&& horizon_client::mod_api::mods().is_empty()
|
||||
{
|
||||
self.mob_geometry_inner::<true>(camera, alpha, out);
|
||||
} else {
|
||||
self.mob_geometry_inner::<false>(camera, alpha, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn mob_geometry_inner<const PRECHECK: bool>(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let scale = self.view_scale();
|
||||
@@ -6960,43 +7009,27 @@ impl Game<'_> {
|
||||
..Default::default()
|
||||
});
|
||||
for mob in &self.mobs {
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
|
||||
let outline = self.mob_outline(mob);
|
||||
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
|
||||
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
|
||||
// A player keeps its armour, elytra and held items
|
||||
// (`LivingEntityRenderer.submit` skips only the body).
|
||||
let is_player = &*mob.kind.id == "minecraft:player";
|
||||
let layers_only = mob.invisible && outline.is_none() && is_player;
|
||||
if mob.invisible && outline.is_none() && !layers_only {
|
||||
continue;
|
||||
}
|
||||
// LevelRenderer: the entity the view looks out of is not drawn
|
||||
// in first person.
|
||||
if self.hides_as_camera(mob.id) {
|
||||
continue;
|
||||
}
|
||||
let is_player = &*mob.kind.id == "minecraft:player";
|
||||
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
|
||||
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
|
||||
// mean edge, scaled by the view scale. RemotePlayer's box counts
|
||||
// ten times.
|
||||
let (w, h, _) = mob.dims();
|
||||
let edge = (w + h + w) / 3.0
|
||||
* if &*mob.kind.id == "minecraft:player" {
|
||||
10.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let edge = (w + h + w) / 3.0 * if is_player { 10.0 } else { 1.0 };
|
||||
let range = edge * 64.0 * scale;
|
||||
if d.length_squared() >= range * range {
|
||||
continue;
|
||||
}
|
||||
// EntityRenderer.shouldRender: the box its renderer culls with
|
||||
// (`getBoundingBoxForCulling`, interpolated over the frame), grown
|
||||
// by 0.5 (GuardianRenderer also draws while its beam is out).
|
||||
let beam = horizon_client::mobs::guardian::beam(mob, alpha as f32);
|
||||
// EntityRenderer.shouldRender: the renderer's interpolated culling box,
|
||||
// grown by 0.5 (GuardianRenderer also draws while its beam is out).
|
||||
if let Some(view) = &self.view_frustum
|
||||
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
|
||||
&& beam.is_none()
|
||||
{
|
||||
let (lo, hi) = super::entity_culling::cull_box(mob, alpha);
|
||||
let (lo, hi) = (
|
||||
@@ -7007,6 +7040,40 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let outline = self.mob_outline(mob);
|
||||
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
|
||||
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
|
||||
// A player keeps its armour, elytra and held items
|
||||
// (`LivingEntityRenderer.submit` skips only the body).
|
||||
let layers_only = mob.invisible && outline.is_none() && is_player;
|
||||
if mob.invisible && outline.is_none() && !layers_only {
|
||||
continue;
|
||||
}
|
||||
let culling = if PRECHECK
|
||||
&& outline.is_none()
|
||||
&& !is_player
|
||||
&& mob.kind.id.starts_with("minecraft:")
|
||||
&& [
|
||||
&mob.extra.head,
|
||||
&mob.extra.chest,
|
||||
&mob.extra.legs,
|
||||
&mob.extra.feet,
|
||||
&mob.extra.main_hand,
|
||||
&mob.extra.off_hand,
|
||||
]
|
||||
.iter()
|
||||
.all(|item| item.is_none())
|
||||
&& beam.is_none()
|
||||
{
|
||||
self.entity_cull_decision(mob, alpha)
|
||||
} else {
|
||||
EntityCullDecision::CheckVertices
|
||||
};
|
||||
if culling.hides_entity() {
|
||||
continue;
|
||||
}
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
|
||||
self.mark_entity(entity_start, &mob.kind.id);
|
||||
self.rendered_mobs.set(self.rendered_mobs.get() + 1);
|
||||
let light = self.world.light(
|
||||
@@ -7205,14 +7272,12 @@ impl Game<'_> {
|
||||
}
|
||||
// Glowing outlines and a guardian's beam remain visible independently
|
||||
// of whether the body is hidden behind terrain.
|
||||
if outline.is_none()
|
||||
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
|
||||
{
|
||||
if outline.is_none() && culling.checks_vertices() && beam.is_none() {
|
||||
// What the GPU draws of the mob counts by its bounds' corners.
|
||||
self.cull_skinned(out, entity_start, skinned);
|
||||
}
|
||||
// GuardianRenderer: the laser to the locked target.
|
||||
if let Some((time, scale)) = horizon_client::mobs::guardian::beam(mob, alpha as f32)
|
||||
if let Some((time, scale)) = beam
|
||||
&& let Some(target) = self.beam_target(mob.ai.target, alpha)
|
||||
&& let Some(uv) = lookup("entity/guardian/guardian_beam")
|
||||
{
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Pufferfish.playerTouch in the real player's tick, independent of fish AI.
|
||||
use super::super::{
|
||||
Game,
|
||||
entity::{Entity, with_entity},
|
||||
};
|
||||
use horizon_client::{mobs::Species, scoreboard::teams_allow_damage};
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Pufferfish.playerTouch`: accepted player damage, then poison with this fish as source.
|
||||
pub(in crate::game) fn touch_pufferfish(&mut self) {
|
||||
if self.remote.is_some() || self.spectator() {
|
||||
return;
|
||||
}
|
||||
let Some(player) = self.player.as_ref().filter(|p| !p.health.dead_or_dying()) else {
|
||||
return;
|
||||
};
|
||||
let mut fish = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.species() == Species::Pufferfish && m.alive() && m.extra.size > 0)
|
||||
.peekable();
|
||||
if fish.peek().is_none() {
|
||||
return;
|
||||
}
|
||||
let own = player.bounds();
|
||||
// Player.aiStep's contact query; the existing vehicle bounds are read,
|
||||
// without moving or simulating that vehicle.
|
||||
let vehicle = self.riding.and_then(|id| {
|
||||
self.entity_handles().find_map(
|
||||
|h| with_entity!(self,h,e => (e.id()==id && !e.is_removed()).then(||e.bounds())),
|
||||
)
|
||||
});
|
||||
let query = vehicle.map_or_else(
|
||||
|| own.inflate(Vec3::new(1.0, 0.5, 1.0)),
|
||||
|b| own.union_bounds(b).inflate(Vec3::new(1.0, 0.0, 1.0)),
|
||||
);
|
||||
let touching: Vec<_> = fish
|
||||
.filter(|m| {
|
||||
query.intersects(m.bounds())
|
||||
&& teams_allow_damage(self.mob_team_of(m), self.player_team())
|
||||
})
|
||||
.map(|m| {
|
||||
(
|
||||
m.id,
|
||||
m.extra.size,
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
self.mob_entity(m),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for (id, puff, at, source) in touching {
|
||||
if !self.hurt_player_from(
|
||||
(1 + puff) as f32,
|
||||
Some(at),
|
||||
"minecraft:mob_attack",
|
||||
Some(Some(id)),
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let changes = self.potions.effects.changes;
|
||||
self.give_player_effect("minecraft:poison", 60 * puff, 0);
|
||||
if self.potions.effects.changes != changes {
|
||||
self.trigger_effects_changed_by(Some(source));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
//! A cushion is a `BlockAttachedEntity`, not a block: it has no block state,
|
||||
//! only an entity whose `Pos` is the click point's height. Placement uses
|
||||
//! outline bounds, collision shapes and suffocation predicates.
|
||||
use super::{Game, Hit, InteractTarget};
|
||||
use super::{Game, Hit, InteractTarget, Keys, entity_culling::default_range, ray_box};
|
||||
use horizon_client::entities::{Cushion, dye_index};
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_client::mobs::anim::Poses;
|
||||
@@ -149,7 +149,7 @@ impl Game<'_> {
|
||||
let reach: f64 = self.entity_reach;
|
||||
let mut best: Option<(usize, f64, [f64; 3])> = None;
|
||||
for (i, c) in self.cushions.iter().enumerate() {
|
||||
let Some(d) = super::ray_box(eye, dir, reach, aabb(c.bounds())) else {
|
||||
let Some(d) = ray_box(eye, dir, reach, aabb(c.bounds())) else {
|
||||
continue;
|
||||
};
|
||||
if best.is_none_or(|b| d < b.1) {
|
||||
@@ -525,7 +525,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// `Player.rideTick` on a cushion: hold the seat, and get up on sneak.
|
||||
pub(super) fn tick_cushion_riding(&mut self, keys: super::Keys) -> bool {
|
||||
pub(super) fn tick_cushion_riding(&mut self, keys: Keys) -> bool {
|
||||
let Some(c) = self
|
||||
.cushions
|
||||
.iter()
|
||||
@@ -560,14 +560,13 @@ impl Game<'_> {
|
||||
};
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
for c in &self.cushions {
|
||||
let entity_start = out.len();
|
||||
let a = aabb(c.bounds());
|
||||
let (lo, hi) = (a.min_position(), a.max_position());
|
||||
let (lo, hi) = (
|
||||
glam::DVec3::new(lo.x, lo.y, lo.z),
|
||||
glam::DVec3::new(hi.x, hi.y, hi.z),
|
||||
);
|
||||
let range = super::entity_culling::default_range(hi - lo, self.view_scale());
|
||||
let range = default_range(hi - lo, self.view_scale());
|
||||
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
|
||||
continue;
|
||||
}
|
||||
@@ -577,6 +576,11 @@ impl Game<'_> {
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let culling = self.entity_cull_decision(c, 1.0);
|
||||
if culling.hides_entity() {
|
||||
continue;
|
||||
}
|
||||
let entity_start = out.len();
|
||||
let p = glam::DVec3::new(c.position.x, c.position.y, c.position.z) - camera;
|
||||
let base = glam::Mat4::from_translation(p.as_vec3())
|
||||
* glam::Mat4::from_rotation_y((180.0 - snap_yaw(c.yaw)).to_radians())
|
||||
@@ -599,7 +603,7 @@ impl Game<'_> {
|
||||
0,
|
||||
out,
|
||||
);
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
self.finish_entity_occlusion(culling, c, out, entity_start);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,11 @@ impl Game<'_> {
|
||||
}
|
||||
} else if key == KeyCode::Escape {
|
||||
self.close_gamemode_switcher(false);
|
||||
} else if name == self.settings.keys.get("key.debug.debugOptions") {
|
||||
// The debug options screen interrupts the switcher too
|
||||
// (`canInterruptScreen`): it closes, then F6 opens over the game.
|
||||
self.close_gamemode_switcher(false);
|
||||
self.toggle_debug_options();
|
||||
} else if matches!(key, KeyCode::F2 | KeyCode::F11) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -98,22 +98,48 @@ const BUTTONS: [(Status, &str, u32); 3] = [
|
||||
|
||||
impl Game<'_> {
|
||||
/// `KeyboardHandler`'s F3+F6: closes the screen if it is open, else opens
|
||||
/// it over the game or the pause menu (`canInterruptScreen`).
|
||||
/// it over whatever screen yields (`canInterruptScreen`), closing that
|
||||
/// screen first.
|
||||
pub(super) fn toggle_debug_options(&mut self) {
|
||||
if self.screen == Screen::DebugOptions {
|
||||
self.close_debug_options();
|
||||
return;
|
||||
}
|
||||
if !debug_options_interrupts(self.screen, self.screen_over_playing()) {
|
||||
return;
|
||||
}
|
||||
self.close_screen_behind_debug_options();
|
||||
let mut search = EditBox::new(0.0, 0.0, (ROW_WIDTH / 3.0).floor(), 20.0);
|
||||
search.max_length = 32;
|
||||
self.debug_options = Some(DebugOptionsUi {
|
||||
search,
|
||||
scroll: Scroll::default(),
|
||||
});
|
||||
self.screen = Screen::DebugOptions;
|
||||
self.pause_menu = true;
|
||||
self.capture(false);
|
||||
self.mining = None;
|
||||
}
|
||||
/// The interrupted screen's own close (`Screen.onClose`): the containers
|
||||
/// hand their state back and a mod's hooks hear of the screen going. A
|
||||
/// screen whose close only swaps the screen (stats, links, the pause menu)
|
||||
/// yields by being overwritten instead.
|
||||
fn close_screen_behind_debug_options(&mut self) {
|
||||
match self.screen {
|
||||
Screen::DebugOptions => self.close_debug_options(),
|
||||
Screen::Playing | Screen::Paused if !self.screen_over_playing() => {
|
||||
let mut search = EditBox::new(0.0, 0.0, (ROW_WIDTH / 3.0).floor(), 20.0);
|
||||
search.max_length = 32;
|
||||
self.debug_options = Some(DebugOptionsUi {
|
||||
search,
|
||||
scroll: Scroll::default(),
|
||||
});
|
||||
self.screen = Screen::DebugOptions;
|
||||
Screen::Inventory => {
|
||||
self.close_container();
|
||||
self.pause_focus = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.pause_menu = true;
|
||||
self.capture(false);
|
||||
self.mining = None;
|
||||
self.capture(true);
|
||||
}
|
||||
Screen::Creative => {
|
||||
self.pad_clear_focus();
|
||||
self.carried = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
Screen::Mod => self.close_mod_screen(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -470,6 +496,40 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Minecraft.canInterruptScreen` (Gui): whether F3+F6 opens the debug
|
||||
/// options screen over `screen`, closing it first. `Screen
|
||||
/// .canInterruptWithAnotherScreen` defaults to `shouldCloseOnEsc`, so every
|
||||
/// screen Escape closes yields; the death screen and the level teardown keep
|
||||
/// theirs, and the title never reaches the debug keys. The text-box screens
|
||||
/// (chat, options, sign, book, the search tabs) do not reach it either: a
|
||||
/// focused box takes every key press first. A playing overlay (a dialog, a
|
||||
/// prompt) keeps its own Esc. The credits' close is fallible and this path is
|
||||
/// not, so it keeps its screen too (vanilla interrupts it).
|
||||
fn debug_options_interrupts(screen: Screen, playing_overlay: bool) -> bool {
|
||||
match screen {
|
||||
Screen::Playing | Screen::Paused => !playing_overlay,
|
||||
Screen::Inventory
|
||||
| Screen::Creative
|
||||
| Screen::GameModeSwitcher
|
||||
| Screen::Stats
|
||||
| Screen::Advancements
|
||||
| Screen::Friends
|
||||
| Screen::Social
|
||||
| Screen::ServerLinks
|
||||
| Screen::ConfirmLink
|
||||
| Screen::DialogWarning
|
||||
| Screen::Mod => true,
|
||||
Screen::Chat
|
||||
| Screen::Options
|
||||
| Screen::WorldOptions
|
||||
| Screen::CommandBlock
|
||||
| Screen::BlockEditor
|
||||
| Screen::Dead
|
||||
| Screen::Win
|
||||
| Screen::DebugOptions => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -497,4 +557,48 @@ mod tests {
|
||||
assert_eq!(button_xs(40.0), [206.0, 266.0, 326.0]);
|
||||
assert_eq!(footer(427.0, 240.0), ([2.0, 126.0, 250.0, 364.0], 213.0));
|
||||
}
|
||||
|
||||
/// F3+F6 interrupts every screen whose Esc would close it
|
||||
/// (`Minecraft.canInterruptScreen`), and none whose would not.
|
||||
#[test]
|
||||
fn the_debug_options_screen_interrupts_what_escape_would_close() {
|
||||
assert!(debug_options_interrupts(Screen::Playing, false));
|
||||
assert!(debug_options_interrupts(Screen::Paused, false));
|
||||
assert!(!debug_options_interrupts(Screen::Playing, true));
|
||||
assert!(!debug_options_interrupts(Screen::Paused, true));
|
||||
for screen in [
|
||||
Screen::Inventory,
|
||||
Screen::Creative,
|
||||
Screen::GameModeSwitcher,
|
||||
Screen::Stats,
|
||||
Screen::Advancements,
|
||||
Screen::Friends,
|
||||
Screen::Social,
|
||||
Screen::ServerLinks,
|
||||
Screen::ConfirmLink,
|
||||
Screen::DialogWarning,
|
||||
Screen::Mod,
|
||||
] {
|
||||
assert!(
|
||||
debug_options_interrupts(screen, false),
|
||||
"{} yields",
|
||||
screen.id()
|
||||
);
|
||||
}
|
||||
for screen in [
|
||||
Screen::Dead,
|
||||
Screen::Chat,
|
||||
Screen::Options,
|
||||
Screen::WorldOptions,
|
||||
Screen::CommandBlock,
|
||||
Screen::BlockEditor,
|
||||
Screen::Win,
|
||||
] {
|
||||
assert!(
|
||||
!debug_options_interrupts(screen, false),
|
||||
"{} keeps its screen",
|
||||
screen.id()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ pub(super) mod events;
|
||||
mod management;
|
||||
mod report;
|
||||
mod status;
|
||||
mod transfer;
|
||||
|
||||
/// What the tick loop tells `ServerWatchdog`: when the tick in progress was
|
||||
/// due (`getNextTickTime`), how long a tick is, and whether the server runs.
|
||||
@@ -985,9 +986,6 @@ impl Game<'_> {
|
||||
};
|
||||
self.level.set_difficulty(self.ui.difficulty);
|
||||
self.sync_rules();
|
||||
if self.ui.difficulty == 0 {
|
||||
self.mobs.retain(|m| !m.hostile());
|
||||
}
|
||||
// DedicatedServer.initServer: a legacy `announce-player-achievements`
|
||||
// sets the show_advancement_messages rule once, as the file is read.
|
||||
if let Some(announce) = props.announce_player_achievements {
|
||||
@@ -1025,6 +1023,18 @@ impl Game<'_> {
|
||||
.filter(|b| png_size(b) == Some((64, 64)));
|
||||
// Behind a Velocity proxy: `forwarding.secret` in the server folder, like the proxy's own file.
|
||||
let forwarding_secret = forwarding_secret(online.is_some())?;
|
||||
// `[transfer]` on: a player's only way in must be the proxy, or one who joins this server
|
||||
// directly finds the older copy of itself that a hand-over left here, and items double.
|
||||
let transfers = self
|
||||
.options
|
||||
.transfers
|
||||
.clone()
|
||||
.map(horizon_client::lan::arrivals::Transfers::new);
|
||||
if transfers.is_some() && forwarding_secret.is_none() {
|
||||
return Err("server.toml's [transfer] is on, but players can join this server without the proxy: \
|
||||
turn on Velocity forwarding (forwarding.secret, docs/velocity-forwarding.md) or turn [transfer] off"
|
||||
.into());
|
||||
}
|
||||
let net = NetConfig {
|
||||
bind: (!props.server_ip.is_empty())
|
||||
.then(|| props.server_ip.parse().ok())
|
||||
@@ -1053,6 +1063,7 @@ impl Game<'_> {
|
||||
gate: Some(gate),
|
||||
online,
|
||||
forwarding_secret,
|
||||
arrivals: transfers.as_ref().map(|t| t.expected.clone()),
|
||||
log: Some(NetLog {
|
||||
write: Arc::new(log_from),
|
||||
lang: Arc::new(horizon_client::net::load_language(&self.options.resources)),
|
||||
@@ -1114,6 +1125,22 @@ impl Game<'_> {
|
||||
log("WARN", "Perhaps a server is already running on that port?");
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(transfers) = transfers
|
||||
&& let Some(host) = self.lan.as_mut()
|
||||
{
|
||||
let s = &transfers.settings;
|
||||
log(
|
||||
"INFO",
|
||||
&format!(
|
||||
"Transfers on (server.toml [transfer]): players expected for {} s ({} s at most, {} at once), frozen for {} ticks at most",
|
||||
s.expect.as_secs(),
|
||||
s.max_expect.as_secs(),
|
||||
s.max_expected,
|
||||
s.max_freeze_ticks
|
||||
),
|
||||
);
|
||||
host.hub.transfers = Some(transfers);
|
||||
}
|
||||
self.sync_ops(&lists);
|
||||
let anticheat = load_anticheat();
|
||||
let chat_filter = load_chat_filter();
|
||||
|
||||