Refresh honey dispatch with current main

Preserve both honey and pufferfish fixture options. Upstream stand changes are renderer-only and entity changes concern XP-orb sound; honey travel code is unchanged. Refreshed all-target Clippy, formatting and Python syntax pass. Runtime pair stays qualified to185156c0e.

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
Florian ERNSTandCodex committed 2026-11-05 08:48:14 +01:00
commit 4efa91b049
290 files changed
+15187 -1857

No files matched your search

+15 -9
View File
@@ -1,10 +1,16 @@
# Trusted reviewers: one list for two things (docs/release-flow.md "Trusted reviewers").
# Trusted reviewers: one list for three things (docs/release-flow.md "Trusted reviewers").
# - `/nightly 2640 2642` picks the pull requests the Nightly holds (tools/ci/nightly_set.py);
# - `/close`, `/reopen`, `/label`, `/unlabel` triage issues and pull requests (tools/ci/triage_command.py).
# Ludovic is always allowed and isn't listed. One GitHub login per line, with who added it and when. Removing a
# line revokes that login at once. A line with only `paused` stops both for every reviewer (Ludovic's still work).
kacpherek # added by Ludovic, 2026-10-27
rdbtCVS # added by Ludovic, 2026-10-27
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
Qwerner88880 # added by Ludovic, 2026-10-31 (Discord vladislav_hagen; native-mod hooks)
# - `/close`, `/reopen`, `/label`, `/unlabel` triage issues and pull requests (tools/ci/triage_command.py);
# - `/unclaim @login` releases someone else's claim, and listed reviewers have no claim limit (tools/ci/claim_command.py).
# One reviewer per line: `<numeric GitHub ID> <login>`, with who added it and when. The ID decides; the login is only
# for people reading this file and for messages. A login is a name anyone can claim once its owner renames or deletes
# the account; an ID is never reused. Get it with `gh api users/<login> --jq .id`, or run
# `python3 tools/ci/resolve_reviewer_ids.py` to fill in the IDs and `--check` to catch renames.
# A line in any other form (a bare login included) grants nothing. Ludovic is always allowed (OWNER_ID in
# tools/ci/nightly_set.py) and isn't listed. Removing a line revokes that account at once. A line with only `paused`
# stops all three for every reviewer (Ludovic's still work).
54669067 kacpherek # added by Ludovic, 2026-10-27
87152661 rdbtCVS # added by Ludovic, 2026-10-27
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
87478350 Qwerner88880 # added by Ludovic, 2026-10-31 (Discord vladislav_hagen; native-mod hooks)
+2
View File
@@ -50,6 +50,8 @@ jobs:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
# Who may act is decided by this numeric ID: a login can be claimed by someone else after a rename.
AUTHOR_ID: ${{ github.event.comment.user.id }}
ISSUE: ${{ github.event.issue.number }}
COMMENT_ID: ${{ github.event.comment.id }}
REPO: ${{ github.repository }}
+2
View File
@@ -41,6 +41,8 @@ jobs:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
# Who may act is decided by this numeric ID: a login can be claimed by someone else after a rename.
AUTHOR_ID: ${{ github.event.comment.user.id }}
ISSUE: ${{ github.event.issue.number }}
COMMENT_URL: ${{ github.event.comment.html_url }}
run: python3 tools/ci/nightly_set.py command
+2
View File
@@ -43,6 +43,8 @@ jobs:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
# Who may act is decided by this numeric ID: a login can be claimed by someone else after a rename.
AUTHOR_ID: ${{ github.event.comment.user.id }}
ISSUE: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
COMMENT_URL: ${{ github.event.comment.html_url }}
+36
View File
@@ -170,3 +170,39 @@ BlueMap is licensed under the MIT license:
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
> THE SOFTWARE.
## JOML
`crates/horizon-client-render/src/camera.rs` (`joml::Mat4::rotation`, `invert_orthonormal`) and
`crates/horizon-client-render/src/gfx/pack_uniforms.rs` (`invert_generic`, the formula of
`pack_projection`) adapt the float arithmetic of `Matrix4f.rotation(Quaternionfc)`,
`invertOrthonormal`, `invertGenericNonThis` and `setPerspective` from
[JOML](https://github.com/JOML-CI/JOML/tree/faf9eef2511242122beb6055fe3da92daed214c5) 1.10.9, the
library vanilla 26.3 uses, so that shader packs get the same matrices to the last bit. Changes:
ported to Rust; `invert_generic` and `pack_projection` drop JOML's property flags, and
`pack_projection` works the formula in doubles. The copyright line and license notice also head
both files.
```
The MIT License
Copyright (c) 2015-2026 Richard Greenlees
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
@@ -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`
Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

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/`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

@@ -964,6 +964,10 @@ impl Scoreboard {
pub fn tracked(&self) -> Vec<String> {
self.holder_order.stream().map(str::to_owned).collect()
}
/// Every holder on a team (`teamsByPlayer.keySet()`).
pub fn team_members(&self) -> impl Iterator<Item = &str> {
self.team_of.keys().map(String::as_str)
}
// teams
pub fn team(&self, name: &str) -> Option<&Team> {
+375 -7
View File
@@ -815,6 +815,98 @@ pub fn stick_direction(stick: [f32; 2], threshold: f32) -> Option<Button> {
})
}
/// How far a stick already stepping must fall back to let go: between this and
/// the step threshold it holds its direction, so a stick resting on the
/// threshold doesn't step again.
pub const STICK_RELEASE: f32 = 0.35;
/// How much more a stick must lean along another axis to change direction, so
/// a stick resting on a diagonal doesn't flip between the two.
const STICK_TURN: f32 = 1.5;
/// [`stick_direction`] for a stick that may already be stepping `held`: it stays
/// that way until the stick falls back past [`STICK_RELEASE`] or clearly turns.
pub fn stick_direction_held(
stick: [f32; 2],
threshold: f32,
held: Option<Button>,
) -> Option<Button> {
let fresh = stick_direction(stick, threshold);
let Some((axis, positive)) = held.and_then(direction) else {
return fresh;
};
if length(stick) < STICK_RELEASE {
return None;
}
// `direction`'s y runs down the screen, a stick's up.
let toward = stick[axis] * if positive == (axis == 0) { 1.0 } else { -1.0 };
if toward > 0.0 && stick[1 - axis].abs() <= toward * STICK_TURN {
held
} else {
fresh
}
}
/// A list drawn with a scrollbar (`ui::scrollbar`), how far it is scrolled (a list
/// moving a whole row a notch draws the same rectangles, so they can't tell) and
/// the ways it can still scroll.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScrollArea {
pub rect: Rect,
pub amount: f32,
pub up: bool,
pub down: bool,
}
impl ScrollArea {
/// A row counts while any of it is in view: the stick can leave the focus on one nearly gone.
pub fn holds(&self, r: &Rect) -> bool {
let x = r[0] + r[2] / 2.0;
let [ax, ay, aw, ah] = self.rect;
(ax..ax + aw).contains(&x) && r[1] < ay + ah && r[1] + r[3] > ay
}
/// All of a row is in view.
fn shows(&self, r: &Rect) -> bool {
let [_, ay, _, ah] = self.rect;
self.holds(r) && r[1] + 0.5 >= ay && r[1] + r[3] <= ay + ah + 0.5
}
}
/// A vertical focus step, with lists: the strict step, unless it would land
/// outside the view of the list `from` is in, or only partly in it, while that
/// list can still scroll that way, when there is none (`None`) and the list
/// scrolls instead: its hidden rows come first and whole, as vanilla's arrow
/// keys walk a selection list's entries (`ensureVisible`).
pub fn list_step(
from: Rect,
button: Button,
targets: &[Rect],
areas: &[ScrollArea],
) -> Option<usize> {
let step = focus_step_strict(from, button, targets)?;
let can_scroll = |a: &ScrollArea| {
if button == Button::DPadUp {
a.up
} else {
a.down
}
};
let leaves = areas
.iter()
.find(|a| a.holds(&from))
.is_some_and(|a| can_scroll(a) && !a.shows(&targets[step]));
(!leaves).then_some(step)
}
/// The farthest centres of two neighbouring slots: 18 apart, 22 over the hotbar's gap.
const SLOT_NEIGHBOUR: f32 = 24.0;
/// A held direction's repeat stops at the edge of the slots it is walking: it
/// goes on only from a slot to a slot next to it (the hotbar's small gap
/// included). A fresh press may go anywhere.
pub fn repeat_crosses_slots(from: (f32, f32), to: (f32, f32), to_slot: bool) -> bool {
!to_slot || (to.0 - from.0).abs().max((to.1 - from.1).abs()) > SLOT_NEIGHBOUR
}
/// Arrow-key focus navigation as vanilla does it
/// (`ContainerEventHandler.nextFocusPathInDirection`), over every target of
/// the screen at once: from a strip one pixel past the focused edge, the
@@ -910,12 +1002,25 @@ pub fn focus_step_vague(from: Rect, button: Button, targets: &[Rect]) -> Option<
/// How far a list scrolled between two frames' targets: the vertical shift
/// most targets of the same position and size agree on (targets that didn't
/// move, outside the list, don't count). `None` when nothing moved.
pub fn scroll_shift(before: &[Rect], after: &[Rect]) -> Option<f32> {
/// move, outside the list, don't count), for a list scrolled towards its end
/// (`down`: the rows moved up) or its start. `None` when nothing moved that way.
/// It takes the direction because half a row's scroll votes the same both ways.
pub fn scroll_shift(before: &[Rect], after: &[Rect], down: bool) -> Option<f32> {
let same = |a: &Rect, b: &Rect| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.5);
if before.len() == after.len() && before.iter().zip(after).all(|(a, b)| same(a, b)) {
return None;
}
// Most votes; among equals the smallest move (the next row, not a far one).
shift_votes(before, after)
.into_iter()
.filter(|v| (v.0 < 0.0) == down)
.max_by(|a, b| a.1.cmp(&b.1).then(b.0.abs().total_cmp(&a.0.abs())))
.map(|v| v.0)
}
/// Every vertical shift that would carry a `before` target onto an `after`
/// target of the same position and size, with how many it would.
fn shift_votes(before: &[Rect], after: &[Rect]) -> Vec<(f32, usize)> {
let mut votes: Vec<(f32, usize)> = Vec::new();
for b in before {
for a in after {
@@ -932,11 +1037,94 @@ pub fn scroll_shift(before: &[Rect], after: &[Rect]) -> Option<f32> {
}
}
}
// Most votes; among equals the smallest move (the next row, not a far one).
votes
}
/// Where the focus goes when the right stick scrolled a list (`down`: towards
/// its end, so the rows moved up): the rectangle moved by the list's shift;
/// the same rectangle, for a widget outside the list; or, its row out of view,
/// the nearest target of its kind (`after`'s flag), the list's rows before
/// anything fixed. `None` when nothing moved that way, or none of its kind is
/// left. It takes the direction because half a row's scroll votes the same
/// both ways once a new row comes into view.
pub fn follow_scroll(
focus: Rect,
down: bool,
before: &[Rect],
after: &[(Rect, bool)],
) -> Option<usize> {
let rects: Vec<Rect> = after.iter().map(|t| t.0).collect();
// A column that stayed put isn't the list, even spaced like its shift.
let column = |of: &[Rect], r: &Rect| -> Vec<f32> {
let same = |o: &&Rect| o[0] == r[0] && o[2] == r[2] && o[3] == r[3];
of.iter().filter(same).map(|o| o[1]).collect()
};
let moving = |r: &&Rect| column(before, r) != column(&rects, r);
let was: Vec<Rect> = before.iter().filter(moving).copied().collect();
let now: Vec<Rect> = rects.iter().filter(moving).copied().collect();
let dy = shift_votes(&was, &now)
.into_iter()
.max_by(|a, b| a.1.cmp(&b.1).then(b.0.abs().total_cmp(&a.0.abs())))
.map(|v| v.0)
.filter(|v| (v.0 < 0.0) == down)
.max_by(|a, b| a.1.cmp(&b.1).then(b.0.abs().total_cmp(&a.0.abs())))?
.0;
let same = |a: &Rect, b: &Rect| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.5);
let moved = [focus[0], focus[1] + dy, focus[2], focus[3]];
let found = |at: &Rect| rects.iter().position(|r| same(r, at));
if !moving(&&focus) {
return found(&focus);
}
if let Some(i) = found(&moved).or_else(|| found(&focus)) {
return Some(i);
}
let in_list = |r: &Rect| {
let was = [r[0], r[1] - dy, r[2], r[3]];
before.iter().any(|b| same(b, &was))
};
let centre = (moved[0] + moved[2] / 2.0, moved[1] + moved[3] / 2.0);
let distance = |r: &Rect| (r[0] + r[2] / 2.0 - centre.0).hypot(r[1] + r[3] / 2.0 - centre.1);
after
.iter()
.enumerate()
.filter(|(_, t)| t.1)
.min_by(|(_, a), (_, b)| {
in_list(&b.0)
.cmp(&in_list(&a.0))
.then(distance(&a.0).total_cmp(&distance(&b.0)))
})
.map(|(i, _)| i)
}
/// The target a focus rectangle stands for once the screen's size changed:
/// the same widget, by its place in the draw order when the screen drew as
/// many; else the target nearest where its centre falls at the new size.
pub fn refind_resized(
focus: Rect,
before: &[Rect],
after: &[Rect],
old: (f32, f32),
new: (f32, f32),
) -> Option<usize> {
let same = |r: &Rect| r.iter().zip(&focus).all(|(a, b)| (a - b).abs() < 0.5);
if before.len() == after.len()
&& let Some(i) = before.iter().position(same)
{
return Some(i);
}
let at = rescaled(
(focus[0] + focus[2] / 2.0, focus[1] + focus[3] / 2.0),
old,
new,
);
let centres: Vec<(f32, f32)> = after
.iter()
.map(|r| (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0))
.collect();
nearest(at, &centres, f32::INFINITY)
}
/// A point at the same place, proportionally, on a screen of another size.
pub fn rescaled(at: (f32, f32), old: (f32, f32), new: (f32, f32)) -> (f32, f32) {
(at.0 * new.0 / old.0.max(1.0), at.1 * new.1 / old.1.max(1.0))
}
/// The target a remembered focus rectangle stands for in a new frame: the
@@ -1632,6 +1820,63 @@ mod tests {
assert_eq!(focus_step_rect(grid[0], Button::South, &grid), None);
}
#[test]
fn follow_scroll_keeps_rows_and_fixed_widgets() {
// Rows 36 tall scrolled down 18: a fifth row comes into view, so +18
// carries as many rows as the real -18. Back, below, stays.
let back = [296.0, 212.0, 71.0, 20.0];
let rows = |ys: &[f32]| -> Vec<(Rect, bool)> {
let rows = ys.iter().map(|y| ([78.0, *y, 270.0, 36.0], true));
rows.chain([(back, false)]).collect()
};
let rects = |t: &[(Rect, bool)]| -> Vec<Rect> { t.iter().map(|t| t.0).collect() };
let before = rects(&rows(&[33.0, 69.0, 105.0, 141.0]));
let after = rows(&[15.0, 51.0, 87.0, 123.0, 159.0]);
assert_eq!(follow_scroll(before[1], true, &before, &after), Some(1));
assert_eq!(follow_scroll(back, true, &before, &after), Some(5));
// And back up.
let up = rows(&[33.0, 69.0, 105.0, 141.0]);
let was = rects(&after);
assert_eq!(follow_scroll(was[2], false, &was, &up), Some(2));
// Nothing moved, as on a screen with no list.
let still = rows(&[33.0, 69.0, 105.0, 141.0]);
assert_eq!(follow_scroll(before[1], true, &before, &still), None);
// A sidebar's tabs spaced exactly like the list's shift stay put.
let tab = |y: f32| [8.0, y, 112.0, 16.0];
let with_tabs = |t: &[(Rect, bool)]| -> Vec<(Rect, bool)> {
t.iter()
.copied()
.chain([(tab(100.0), true), (tab(118.0), true)])
.collect()
};
let was = rects(&with_tabs(&rows(&[33.0, 69.0, 105.0, 141.0])));
let now = with_tabs(&after);
assert_eq!(follow_scroll(tab(118.0), true, &was, &now), Some(7));
}
#[test]
fn follow_scroll_lands_on_the_nearest_row_when_its_row_leaves() {
// The row at 33 left the top; the next one is at 51. Nothing fixed.
let row = |y: f32| [78.0, y, 270.0, 36.0];
let before = [row(33.0), row(69.0), row(105.0)];
let after = [(row(51.0), true), (row(87.0), true)];
assert_eq!(follow_scroll(row(33.0), true, &before, &after), Some(0));
// The last row left the bottom: the list's new last row, not a fixed
// widget of its kind (at 200) nearer where the row went.
let fixed = [78.0, 200.0, 270.0, 36.0];
let before = [row(15.0), row(51.0), row(87.0), row(159.0), fixed];
let after = [
(row(33.0), true),
(row(69.0), true),
(row(105.0), true),
(fixed, true),
];
assert_eq!(follow_scroll(row(159.0), false, &before, &after), Some(2));
// Nothing of its kind left.
let other = after.map(|t| (t.0, false));
assert_eq!(follow_scroll(row(159.0), false, &before, &other), None);
}
#[test]
fn refind_tabs_sliders_and_stick_steps() {
let rects: Vec<Rect> = vec![[0.0, 0.0, 10.0, 10.0], [0.0, 20.0, 10.0, 10.0]];
@@ -1648,8 +1893,10 @@ mod tests {
.map(|i| [10.0, 16.0 + 24.0 * i as f32, 100.0, 20.0])
.chain([[10.0, 200.0, 50.0, 20.0]])
.collect();
assert_eq!(scroll_shift(&before, &after), Some(-24.0));
assert_eq!(scroll_shift(&before, &before), None);
assert_eq!(scroll_shift(&before, &after, true), Some(-24.0));
// Rows this far apart line up the other way too; the direction is what tells them apart.
assert_eq!(scroll_shift(&before, &after, false), Some(24.0));
assert_eq!(scroll_shift(&before, &before, true), None);
let tabs = [
([60.0, 0.0, 26.0, 32.0], false),
([0.0, 0.0, 26.0, 32.0], true),
@@ -1675,6 +1922,127 @@ mod tests {
assert!(steam_keyboard(TextKeyboard::Steam) && !steam_keyboard(TextKeyboard::Horizon));
}
#[test]
fn a_list_step_scrolls_rather_than_leave_a_list_that_can() {
let rows = |n: usize| -> Vec<Rect> {
(0..n)
.map(|i| [100.0, 40.0 + i as f32 * 36.0, 270.0, 36.0])
.collect()
};
let back: Rect = [150.0, 220.0, 150.0, 20.0];
let search: Rect = [150.0, 10.0, 150.0, 20.0];
let mut targets = rows(4);
targets.extend([back, search]);
let area = |up, down| ScrollArea {
rect: [0.0, 40.0, 380.0, 144.0],
amount: 0.0,
up,
down,
};
let (down, up) = (Button::DPadDown, Button::DPadUp);
let step = |from, button, area: &[ScrollArea]| list_step(from, button, &targets, area);
// A row below in view comes first.
assert_eq!(step(targets[1], down, &[area(true, true)]), Some(2));
// The last row in view: more below scrolls; none leaves.
assert_eq!(step(targets[3], down, &[area(true, true)]), None);
assert_eq!(step(targets[3], down, &[area(true, false)]), Some(4));
// The first row in view, the same upwards.
assert_eq!(step(targets[0], up, &[area(true, false)]), None);
assert_eq!(step(targets[0], up, &[area(false, true)]), Some(5));
// From outside the list it enters it, and no list means a plain step.
assert_eq!(step(back, up, &[area(true, true)]), Some(3));
assert_eq!(step(targets[3], down, &[]), Some(4));
}
#[test]
fn a_list_step_shows_its_row_whole_and_starts_from_one_nearly_gone() {
let area = |up, down| ScrollArea {
rect: [0.0, 40.0, 380.0, 100.0],
amount: 50.0,
up,
down,
};
let (down, up) = (Button::DPadDown, Button::DPadUp);
// The next row is cut by the view's bottom: it scrolls first, unless that's the end.
let targets: Vec<Rect> = vec![[100.0, 100.0, 270.0, 25.0], [100.0, 125.0, 270.0, 25.0]];
assert_eq!(
list_step(targets[0], down, &targets, &[area(true, true)]),
None
);
assert_eq!(
list_step(targets[0], down, &targets, &[area(true, false)]),
Some(1)
);
// The same at its top.
let targets: Vec<Rect> = vec![[100.0, 30.0, 270.0, 25.0], [100.0, 55.0, 270.0, 25.0]];
assert_eq!(
list_step(targets[1], up, &targets, &[area(true, true)]),
None
);
// A focused row with its last pixels in view is still in the list: going up scrolls,
// even with something above the list to go to.
let row: Rect = [100.0, 18.0, 270.0, 25.0];
let field: Rect = [150.0, 0.0, 150.0, 15.0];
assert!(area(true, true).holds(&row));
assert_eq!(list_step(row, up, &[row, field], &[area(true, true)]), None);
// Above the view altogether, it isn't.
assert!(!area(true, true).holds(&[100.0, 10.0, 270.0, 25.0]));
}
#[test]
fn a_stick_holds_its_direction_until_it_lets_go_or_turns() {
let step = 0.55;
let (up, right) = (Some(Button::DPadUp), Some(Button::DPadRight));
// Only a push past the threshold starts a step.
assert_eq!(stick_direction_held([0.0, 0.5], step, None), None);
assert_eq!(stick_direction_held([0.0, 0.6], step, None), up);
// Wobbling on the threshold keeps it; falling back past the release lets go.
assert_eq!(stick_direction_held([0.0, 0.5], step, up), up);
assert_eq!(stick_direction_held([0.0, 0.3], step, up), None);
// A diagonal doesn't flip it, but a clear turn does.
assert_eq!(stick_direction_held([0.6, 0.55], step, up), up);
assert_eq!(stick_direction_held([0.55, 0.6], step, right), right);
assert_eq!(stick_direction_held([0.9, 0.3], step, up), right);
// Straight across to the other side.
assert_eq!(
stick_direction_held([0.0, -0.9], step, up),
Some(Button::DPadDown)
);
// Past the release on the far side, but not far enough for a step.
assert_eq!(stick_direction_held([0.0, -0.45], step, up), None);
}
#[test]
fn a_repeat_stops_where_the_slots_end() {
let at = (100.0, 100.0);
assert!(!repeat_crosses_slots(at, (118.0, 100.0), true));
// The hotbar, over its small gap, is the same run of slots.
assert!(!repeat_crosses_slots(at, (100.0, 122.0), true));
// Another block of slots, or a button.
assert!(repeat_crosses_slots(at, (100.0, 132.0), true));
assert!(repeat_crosses_slots(at, (118.0, 100.0), false));
}
#[test]
fn a_resize_keeps_the_same_widget() {
let before: Vec<Rect> = vec![[10.0, 10.0, 20.0, 20.0], [100.0, 10.0, 20.0, 20.0]];
let after: Vec<Rect> = vec![[300.0, 90.0, 20.0, 20.0], [400.0, 90.0, 20.0, 20.0]];
let (old, new) = ((200.0, 100.0), (800.0, 400.0));
// The same count: the same place in the draw order, however far it moved.
assert_eq!(
refind_resized(before[0], &before, &after, old, new),
Some(0)
);
// A list showing more rows: the nearest to where it would be.
let more: Vec<Rect> = vec![
[30.0, 30.0, 20.0, 20.0],
[395.0, 35.0, 20.0, 20.0],
[700.0, 300.0, 20.0, 20.0],
];
assert_eq!(refind_resized(before[1], &before, &more, old, new), Some(1));
assert_eq!(refind_resized(before[0], &before, &[], old, new), None);
}
#[test]
fn labels_and_conflicts() {
assert_eq!(Button::South.label(GlyphStyle::Xbox), "A");
+50
View File
@@ -91,6 +91,36 @@ pub fn settle(start: Point, end: Point, targets: &[Point], tuning: &Tuning) -> O
}
}
/// Where a cursor left on a target goes once the targets moved: with most of
/// them if they moved together (a recipe book opened), else to the nearest
/// one (a creative tab with other slots), so it never rests between targets.
/// `None` when it wasn't on a target, or one is still there.
pub fn follow(at: Point, before: &[Point], after: &[Point]) -> Option<usize> {
const SAME: f32 = 0.5;
let on = before.iter().find(|t| distance(at, **t) <= SAME)?;
if after.iter().any(|t| distance(at, *t) <= SAME) {
return None;
}
let carried = |by: Point| {
before
.iter()
.filter(|b| {
let moved = (b.0 + by.0, b.1 + by.1);
after.iter().any(|a| distance(moved, *a) <= SAME)
})
.count()
};
let together = after
.iter()
.enumerate()
.map(|(i, a)| (i, carried((a.0 - on.0, a.1 - on.1))))
.max_by_key(|&(_, count)| count)
.filter(|&(_, count)| count * 2 > before.len());
together
.map(|(i, _)| i)
.or_else(|| nearest(at, after, f32::INFINITY))
}
/// The cursor `seconds` into a glide from `from` to `to`: eased out, so it
/// slows as it lands, and exactly `to` once the glide is over.
pub fn glide(from: Point, to: Point, seconds: f32, tuning: &Tuning) -> Point {
@@ -109,6 +139,26 @@ mod tests {
/// A row of inventory slots, 18 pixels apart, and the slot below the first.
const SLOTS: [Point; 4] = [(8.0, 8.0), (26.0, 8.0), (44.0, 8.0), (8.0, 26.0)];
#[test]
fn follow_goes_with_targets_that_moved_together() {
let moved = SLOTS.map(|s| (s.0 + 77.0, s.1));
assert_eq!(follow(SLOTS[1], &SLOTS, &moved), Some(1));
}
#[test]
fn follow_takes_the_nearest_of_other_targets() {
let other = [(30.0, 50.0), (200.0, 200.0)];
assert_eq!(follow(SLOTS[1], &SLOTS, &other), Some(0));
}
#[test]
fn follow_leaves_a_free_cursor_and_a_target_still_there() {
let other = [(30.0, 50.0)];
assert_eq!(follow((100.0, 100.0), &SLOTS, &other), None);
assert_eq!(follow(SLOTS[0], &SLOTS, &SLOTS), None);
assert_eq!(follow(SLOTS[0], &SLOTS, &[]), None);
}
#[test]
fn a_short_flick_moves_to_the_neighbour_it_heads_for() {
// 4 pixels right of slot 0: still nearest slot 0, but heading for slot 1.
@@ -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,
+113
View File
@@ -1,3 +1,26 @@
// `joml::Mat4::rotation` and `invert_orthonormal` in this file are adapted from JOML 1.10.9's
// `Matrix4f` (https://github.com/JOML-CI/JOML, MIT; see THIRD_PARTY_NOTICES.md).
//
// Copyright (c) 2015-2026 Richard Greenlees
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//! The camera (`Camera.tick`, `tickFov`, `calculateFov`, `alignWithEntity`,
//! `getMaxZoom`, `setRotation`, `move`), view and hurt bobbing
//! (`GameRenderer.bobView`, `bobHurt`), the walk and bob history they read
@@ -142,8 +165,10 @@ pub mod joml {
}
const PERSPECTIVE: u8 = 1;
const AFFINE: u8 = 2;
const IDENTITY: u8 = 4;
const TRANSLATION: u8 = 8;
const ORTHONORMAL: u8 = 16;
/// A JOML `Matrix4f` with its property flags, which select the code
/// paths (and so the rounding) of later operations. `m[column * 4 + row]`.
@@ -284,10 +309,98 @@ pub mod joml {
// already taken that branch.
self.rotate_axis(2, ang);
}
/// `Matrix4f.rotation(Quaternionfc)`.
pub fn rotation(&mut self, q: Quat) {
let (w2, x2, y2, z2) = (q.w * q.w, q.x * q.x, q.y * q.y, q.z * q.z);
let zw = q.z * q.w;
let dzw = zw + zw;
let xy = q.x * q.y;
let dxy = xy + xy;
let xz = q.x * q.z;
let dxz = xz + xz;
let yw = q.y * q.w;
let dyw = yw + yw;
let yz = q.y * q.z;
let dyz = yz + yz;
let xw = q.x * q.w;
let dxw = xw + xw;
self.reset_unless_identity();
self.set(0, 0, w2 + x2 - z2 - y2);
self.set(0, 1, dxy + dzw);
self.set(0, 2, dxz - dyw);
self.set(1, 0, -dzw + dxy);
self.set(1, 1, y2 - z2 + w2 - x2);
self.set(1, 2, dyz + dxw);
self.set(2, 0, dyw + dxz);
self.set(2, 1, dyz - dxw);
self.set(2, 2, z2 - y2 - x2 + w2);
self.properties = AFFINE | ORTHONORMAL;
}
/// `Matrix4f.invert()` of a rotation and translation (`invertOrthonormal`; with the
/// identity and pure-translation shortcuts). `None` for any other matrix, which JOML
/// inverts by other code paths.
pub fn invert_orthonormal(&self) -> Option<Self> {
if self.properties & IDENTITY != 0 {
return Some(Self::IDENTITY);
}
let mut out = *self;
if self.properties & TRANSLATION != 0 {
for row in 0..3 {
out.set(3, row, -self.get(3, row));
}
out.properties = AFFINE | TRANSLATION | ORTHONORMAL;
return Some(out);
}
if self.properties & ORTHONORMAL == 0 {
return None;
}
let g = |c, r| self.get(c, r);
let t = |c| -(g(c, 0) * g(3, 0) + g(c, 1) * g(3, 1) + g(c, 2) * g(3, 2));
out.m = [
g(0, 0),
g(1, 0),
g(2, 0),
0.0,
g(0, 1),
g(1, 1),
g(2, 1),
0.0,
g(0, 2),
g(1, 2),
g(2, 2),
0.0,
t(0),
t(1),
t(2),
1.0,
];
out.properties = AFFINE | ORTHONORMAL;
Some(out)
}
pub fn to_glam(self) -> glam::Mat4 {
glam::Mat4::from_cols_array(&self.m)
}
}
}
use joml::{Mat4, Quat};
/// `Camera.getViewRotationMatrix` to the last bit: `Matrix4f.rotation` of the conjugate of
/// `setRotation`'s quaternion, and its JOML `invert()`. `view_matrix` turns the same quaternion
/// into a matrix by glam's formula, which differs in the last bits (1.0 for 0.99999994, 6e-8 for
/// 0).
pub fn view_rotation_matrix(yaw: f32, pitch: f32) -> (glam::Mat4, glam::Mat4) {
let q = Quat::rotation_yxz(PI - yaw * DEG_TO_RAD, -pitch * DEG_TO_RAD, 0.0);
let mut m = Mat4::IDENTITY;
m.rotation(Quat {
x: -q.x,
y: -q.y,
z: -q.z,
w: q.w,
});
let inverse = m.invert_orthonormal().expect("a rotation is orthonormal");
(m.to_glam(), inverse.to_glam())
}
/// Camera-relative world view used by terrain, sky and the first-person hand.
pub fn view_matrix(yaw: f32, pitch: f32) -> glam::Mat4 {
// Vanilla uses the conjugate of Camera.rotation, not a world-up look-at.
+334 -24
View File
@@ -4404,6 +4404,12 @@ impl Renderer {
),
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/`
@@ -5016,9 +5022,14 @@ impl Renderer {
/// it did not see and how many it drew, and the share of solid indices
/// it hid.
pub fn culled_sections(&self) -> Option<([usize; 2], f32)> {
if self.gpu_frame {
// GPU culling's own test, on Vulkan.
let gpu = self.gpu_culling.as_ref().filter(|_| self.cull_on);
return gpu.and_then(|g| g.hidden());
}
self.cull
.as_ref()
.filter(|_| self.cull_on && !self.gpu_frame)
.filter(|_| self.cull_on)
.map(|c| c.hidden)
}
/// Whether the near solid sections are culled and drawn on the GPU where
@@ -5026,6 +5037,10 @@ impl Renderer {
pub fn gpu_culling(&self) -> bool {
self.gpu_culling.is_some()
}
/// Whether the last frame GPU culling drew ran its occlusion test.
pub fn gpu_occluding(&self) -> bool {
self.gpu_frame && self.gpu_culling.as_ref().is_some_and(|g| g.occluding())
}
/// Whether the last frame drew them so.
pub fn gpu_frame(&self) -> bool {
self.gpu_frame
@@ -5058,7 +5073,12 @@ impl Renderer {
if let Some(why) = self.gpu_culling_offer.reason() {
return Err(why.into());
}
let Some(mut gpu) = gpu_culling::GpuCulling::new(&self.device) else {
let test = self
.cull
.as_ref()
.filter(|c| c.early())
.map(|_| self.frame_format);
let Some(mut gpu) = gpu_culling::GpuCulling::new(&self.device, test) else {
// 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());
@@ -5202,6 +5222,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);
@@ -7441,7 +7471,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()
@@ -7521,6 +7552,8 @@ impl Renderer {
frustum: &view,
reach: self.occlusion.reach().filter(|_| culling),
eye,
view_proj: jittered,
occlude: self.cull_on,
};
let prepared = crate::spans::time("gpu culling", || {
gpu.prepare(
@@ -7596,8 +7629,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.
@@ -8774,7 +8810,6 @@ impl Renderer {
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);
}
@@ -9236,6 +9271,8 @@ impl Renderer {
// A material stage's translucent pass reads a copy of the depth the solid terrain
// left: it is stored across the pass that ends before the copy.
let keep_depth = keep_depth || material.is_some();
// GPU culling's occlusion test splits the world pass as cull.rs's does.
let gpu_occluding = gpu_frame && self.gpu_culling.as_ref().is_some_and(|g| g.occluding());
let pack_frame = |pass: &mut wgpu::RenderPass<'_>| {
if let Some((_, group)) = material {
pass.set_bind_group(horizon_shader_api::stages::GROUP, group, &[]);
@@ -9292,8 +9329,9 @@ impl Renderer {
view: world_depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
// The visibility buffer's passes draw on after it.
store: if cull.is_some() || keep_depth || vis_frame {
// The visibility buffer's passes, and those after an occlusion test,
// draw on after it.
store: if cull.is_some() || gpu_occluding || keep_depth || vis_frame {
wgpu::StoreOp::Store
} else {
wgpu::StoreOp::Discard
@@ -9576,27 +9614,74 @@ impl Renderer {
);
}
}
// Without culling, phase 1 is all the solid draws.
// Without culling, phase 1 is all the solid draws; GPU culling's
// own test (`GpuCulling::occluding`) leaves the CPU's to phase 2.
None => {
let gpu = self.gpu_culling.as_ref().filter(|_| gpu_frame);
let occluding = gpu.is_some_and(|g| g.occluding());
let page_group = |page: u32| {
pulled
.then(|| self.flat.as_ref()?.page_group(page))
.flatten()
};
for (pipeline, draws, _) in passes {
pass.set_pipeline(pipeline);
// GPU culling's draws first, all within the render distance.
if gpu_frame && let Some(gpu) = &self.gpu_culling {
gpu.draw(&mut pass, &self.arena, |page| {
pulled
.then(|| self.flat.as_ref()?.page_group(page))
.flatten()
});
if let Some(gpu) = gpu {
gpu.draw(&mut pass, &self.arena, page_group, 0);
pass.set_vertex_buffer(1, self.instances.slice(..));
}
self.draw_sections_from(
&mut pass,
&self.indirect,
0,
draws.start,
draws.len() as u32,
pulled,
if !occluding {
self.draw_sections_from(
&mut pass,
&self.indirect,
0,
draws.start,
draws.len() as u32,
pulled,
);
}
}
// As cull.rs's arm: the boxes against phase 1's depth, then
// phase 2 and the CPU's draws in a pass that loads both targets.
if let Some(gpu) = gpu.filter(|_| occluding) {
if surface.is_some() {
drop(pass);
pass = plain_pass(&mut encoder);
}
gpu.test(&mut pass);
drop(pass);
gpu.compact(&mut encoder, self.own_timing.as_mut());
pass = match &surface {
Some(surface) => terrain_pass(&mut encoder, surface, false),
None => world_pass_after_culling(
&mut encoder,
world_view,
world_depth,
keep_depth,
self.own_timing.as_mut(),
"World after culling",
),
};
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
pass.set_index_buffer(
self.indices.slice(..),
wgpu::IndexFormat::Uint32,
);
for (pipeline, draws, _) in passes {
pass.set_pipeline(pipeline);
gpu.draw(&mut pass, &self.arena, page_group, 1);
pass.set_vertex_buffer(1, self.instances.slice(..));
self.draw_sections_from(
&mut pass,
&self.indirect,
0,
draws.start,
draws.len() as u32,
pulled,
);
}
}
}
}
@@ -10408,6 +10493,10 @@ impl Renderer {
if culling && let Some(cull) = &mut self.cull {
cull.end(&self.device, &mut encoder);
}
// GPU culling's counts once its world passes are done (phase 2's too).
if gpu_frame && let Some(gpu) = &mut self.gpu_culling {
gpu.copy_stats(&mut encoder, serial, cpu);
}
if let Some(timer) = &mut self.gpu_timer {
timer.end(
&self.device,
@@ -11242,6 +11331,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 {
@@ -11260,6 +11353,39 @@ 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]>,
}
/// How far past a section's 16 blocks the shadow pass looks for what can shade the view: our
/// probes' Iris 1.11.7 maps keep the sections whose box, grown by this much on every side, meets
/// `shadow_casters`' shape. The 31 edge columns of 8 noon scenes allow only a growth of 1.12 to
/// 1.19 blocks (a fit, not Iris's code); with 1.15, 19 of our 20 scenes hold exactly Iris's
/// columns. Without it the edges of the wedge lose a column of terrain.
const SHADOW_CASTER_MARGIN: f32 = 1.15;
impl Cull for ShadowCull {
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.light.intersects(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.intersects(grown_min, grown_max)))
}
fn contains(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.light.contains(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.contains(grown_min, grown_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 {
@@ -11418,6 +11544,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 = [
@@ -12008,8 +12196,9 @@ mod tests {
for path in files {
let text = std::fs::read_to_string(&path).unwrap();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name == "oit.wgsl" {
// The shared maths: it needs the file it is appended to.
if name == "oit.wgsl" || name == "gpu_occlusion.wgsl" {
// The shared maths: it needs the file it is appended to. GPU culling's
// occlusion test is appended to gpu_culling.wgsl, on Vulkan alone.
continue;
} else if name == "oit_world.wgsl" {
let base = include_str!("terrain.wgsl").to_owned() + include_str!("oit.wgsl");
@@ -13069,6 +13258,7 @@ mod shadow_face_tests {
distance: None,
unculled: None,
every_face,
view_casters: true,
}
}
@@ -13083,3 +13273,123 @@ 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]));
}
/// Our probes' Iris 1.11.7 maps keep the sections whose box, grown by a margin of 1.12 to
/// 1.19 blocks, meets the shape (#8464): a camera at (13.5, 69.62, -13.5) under a noon sun with the scenes' flying
/// field of view (77°).
#[test]
fn the_shadow_pass_keeps_a_margin_around_the_view() {
let kept = |yaw: f32, column: [f32; 2]| {
let camera =
world_projection(77.0, 16.0 / 9.0, 1024.0) * crate::camera::view_matrix(yaw, 0.0);
let cull = ShadowCull {
light: Frustum {
planes: [Vec4::W; 6],
},
casters: Some(shadow_casters(camera, Vec3::Y)),
};
let min = Vec3::new(
column[0] * 16.0 - 13.5,
64.0 - 69.62,
column[1] * 16.0 + 13.5,
);
cull.intersects(min, min + 16.0)
};
// Looking west, the column at chunk (-2, -6) needs a margin of 0.9 blocks: Iris keeps it.
assert!(kept(90.0, [-2.0, -6.0]));
// Looking south (yaw 7.5), the one at (3, 0) needs a margin of 1.3 blocks: Iris drops it.
assert!(!kept(7.5, [3.0, 0.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()
);
}
}
+27 -2
View File
@@ -55,6 +55,13 @@ const RETEST: u32 = 8;
/// more than they save. Else one frame in `PROBE` tests every section.
const MIN_SHARE: f32 = 0.1;
const PROBE: u32 = 64;
/// The gate, for frame `stamp`, with `share` the newest result's hidden share:
/// whether the frame culls, and whether it probes (tests every section). GPU
/// culling's frames take it too.
pub(super) fn gate(share: f32, stamp: u32) -> (bool, bool) {
let probe = share < MIN_SHARE;
(!probe || stamp.is_multiple_of(PROBE), probe)
}
/// On Metal, sections in view from which a frame culls. Apple's GPUs store
/// and reload the whole frame at each of the passes the test adds, a fixed
/// cost that the sections it hides repay only at high render distances: on
@@ -232,6 +239,11 @@ impl Cull {
))
.then_some(depth_test)
}
/// Its test runs in phase 1's pass, the depth test forced early (Vulkan):
/// GPU culling's frames can run it too.
pub fn early(&self) -> bool {
self.depth_test == DepthTest::Early
}
/// `color` is the world pass's colour format.
pub fn new(device: &wgpu::Device, color: wgpu::TextureFormat, depth_test: DepthTest) -> Self {
let entry = |binding, visibility, ty| wgpu::BindGroupLayoutEntry {
@@ -422,8 +434,12 @@ impl Cull {
self.test_slots.clear();
self.test_indices.clear();
self.total = 0;
self.probe = !self.always && self.hidden.1 < MIN_SHARE;
!self.probe || self.stamp.is_multiple_of(PROBE)
let (culls, probe) = match self.always {
true => (true, false),
false => gate(self.hidden.1, self.stamp),
};
self.probe = probe;
culls
}
/// Whether phase 1 draws the section in resident `slot` (or the far tile
@@ -718,6 +734,15 @@ fn source(depth_test: DepthTest) -> String {
mod tests {
use super::*;
/// Culling while the newest result hid enough; else one frame in `PROBE`
/// tests every section. GPU culling's frames take the same gate.
#[test]
fn the_gate_culls_while_enough_is_hidden_and_probes_else() {
assert_eq!(gate(MIN_SHARE, 3), (true, false));
assert_eq!(gate(MIN_SHARE / 2.0, 3), (false, true));
assert_eq!(gate(MIN_SHARE / 2.0, PROBE), (true, true));
}
#[test]
fn shaders_validate() {
for (depth_test, capabilities) in [
@@ -77,7 +77,7 @@ use super::{
upload::Uploads,
};
use crate::{graphics::GpuCullingOffer, mesher::Sides};
use glam::{DVec3, DVec4, Vec3};
use glam::{DVec3, DVec4, Mat4, Vec3};
use std::{
cmp::Reverse,
collections::BinaryHeap,
@@ -190,9 +190,9 @@ fn threshold_key(t: f32, positive: bool) -> u32 {
/// The most draws a section has: the runs its buckets that can face the eye
/// lie in. Two runs are apart by a bucket left out, so 7 buckets hold at most 4.
const RUNS: u32 = 4;
/// Words per id in `runs` (the shader's `RUN_WORDS`): its page and draws, then
/// each draw's first quad and quads.
const RUN_WORDS: u64 = 1 + 2 * RUNS as u64;
/// Words per id in `runs` (the shader's `RUN_WORDS`): its page, flags and draws,
/// then each draw's first quad and quads, then its two occlusion stamps.
const RUN_WORDS: u64 = 1 + 2 * RUNS as u64 + 2;
/// `rounded_box` for bounds past what a box's codes hold, or not finite: no
/// frustum plane rejects it, so it is drawn wherever its column is within the
@@ -270,15 +270,27 @@ struct Params {
/// Its height in sections, and the level's (the sky band's top section).
reach_height: i32,
reach_sections: i32,
/// `REACH`, `SKY`, `EYE`.
/// `REACH`, `SKY`, `EYE`, `OCCLUDE`, `PROBE`.
flags: u32,
/// With `EYE`, where the eye's keys start in `tables`, in words.
eyes: u32,
/// With `OCCLUDE`: the frame's stamp (never 0), where the boxes to test
/// start in `counts`, in words, 0, 0.
occl: [u32; 4],
/// The world pass's view projection, which the test draws its boxes with.
view_proj: [[f32; 4]; 4],
}
/// 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;
/// the draws (fewer only where a region was short), then occlusion's: the
/// quads of the sections with runs and of the tested boxes not seen (the
/// gate's share), the box draw (its instances: the boxes tested), and the
/// boxes not seen.
const STATS: u64 = 12;
const QUADS: usize = 2;
const HIDDEN_QUADS: usize = 3;
const BOX: u64 = 4;
const HIDDEN: usize = 9;
/// 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;
@@ -297,6 +309,10 @@ const SKY: u32 = 2;
/// In `Params::flags` (the shader's `EYE`): solid quads facing away from the
/// eye are left out, its keys in `tables` from `Params::eyes`.
const EYE: u32 = 4;
/// In `Params::flags` (the shader's `OCCLUDE` and `PROBE`): the frame occludes,
/// and it tests every box (cull.rs's probe frame).
const OCCLUDE: u32 = 8;
const PROBE: u32 = 16;
/// Ids per block (the shader's `BLOCK`), and the workgroup of the passes over ids.
const WORKGROUP: u32 = 64;
/// Workgroups along x when there are more (the shader's `ROW`).
@@ -337,6 +353,11 @@ pub(super) struct FrameInputs<'a> {
/// from it are left out (`sides_on`); `None`: every solid quad is drawn. A
/// GPU frame has no water mirror, whose eye below would see more of them.
pub eye: Option<Vec3>,
/// The world pass's view projection (gfx.rs `globals.view_proj`), and
/// whether occlusion culling is on: frames that can run its test then
/// do, by its gate (`GpuCulling::occluding`).
pub view_proj: Mat4,
pub occlude: bool,
}
/// What `GpuCulling::prepare` made of a frame.
@@ -567,6 +588,20 @@ impl Grown {
self.bytes = size;
Some(true)
}
/// `reserve`, keeping what the buffer held (`runs`' stamps).
fn reserve_keeping(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
bytes: u64,
) -> Option<bool> {
let (old, old_bytes) = (self.buffer.clone(), self.bytes);
let made = self.reserve(device, bytes)?;
if made {
encoder.copy_buffer_to_buffer(&old, 0, &self.buffer, 0, old_bytes);
}
Some(made)
}
}
/// The section table as the CPU keeps it, by id, and what changed since
@@ -785,6 +820,147 @@ struct Readback {
/// every draw call.
frame: u64,
cpu: [usize; 3],
/// Its frame occluded: its counts carry the test's.
occluded: bool,
}
/// The occlusion test (`gpu_occlusion.wgsl`): its boxes' pipeline and bind
/// group, and phase 2's passes (count2, scan2, scatter2).
struct BoxTest {
pipeline: wgpu::RenderPipeline,
layout: wgpu::BindGroupLayout,
group: Option<wgpu::BindGroup>,
phase2: [wgpu::ComputePipeline; 3],
}
impl BoxTest {
/// `color`: the world pass's colour format. `None` when the device fails to
/// make the pipelines: GPU culling then runs without the test.
fn new(
device: &wgpu::Device,
color: wgpu::TextureFormat,
compute: &wgpu::BindGroupLayout,
) -> Option<Self> {
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let entry = |binding, visibility, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility,
ty: wgpu::BindingType::Buffer {
ty,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
let storage = |read_only| wgpu::BufferBindingType::Storage { read_only };
use wgpu::ShaderStages as S;
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("GPU culling occlusion test"),
entries: &[
entry(0, S::VERTEX_FRAGMENT, wgpu::BufferBindingType::Uniform),
entry(9, S::VERTEX, storage(true)),
entry(10, S::VERTEX, storage(true)),
entry(11, S::FRAGMENT, storage(false)),
entry(12, S::VERTEX, storage(true)),
],
});
let source = [
include_str!("gpu_culling.wgsl"),
include_str!("gpu_occlusion.wgsl"),
]
.concat();
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("GPU culling occlusion test"),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let layouts = [Some(&layout)];
let box_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("GPU culling occlusion test"),
bind_group_layouts: &layouts,
..Default::default()
});
// cull.rs's `Early` pipeline: front faces against the world pass's
// reversed depth, without writes.
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("GPU culling occlusion test"),
layout: Some(&box_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs_box"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_box"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: color,
blend: None,
write_mask: wgpu::ColorWrites::empty(),
})],
}),
multiview_mask: None,
cache: None,
});
let compute_layouts = [Some(compute)];
let compute_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("GPU culling phase 2"),
bind_group_layouts: &compute_layouts,
..Default::default()
});
let phase2 = ["count2", "scan2", "scatter2"].map(|entry| {
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("GPU culling phase 2"),
layout: Some(&compute_layout),
module: &module,
entry_point: Some(entry),
compilation_options: Default::default(),
cache: None,
})
});
let internal = pollster::block_on(internal.pop());
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
log::warn!("Horizon renderer: GPU culling's occlusion test unavailable ({e})");
return None;
}
Some(Self {
pipeline,
layout,
group: None,
phase2,
})
}
/// Its bind group: the parameters, then the test list (`counts`), the
/// origins, the stamps (`runs`) and the records.
fn bind(&self, device: &wgpu::Device, buffers: [&wgpu::Buffer; 5]) -> wgpu::BindGroup {
let entries = [0, 9, 10, 11, 12]
.into_iter()
.zip(buffers)
.map(|(binding, buffer)| wgpu::BindGroupEntry {
binding,
resource: buffer.as_entire_binding(),
});
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("GPU culling occlusion test"),
layout: &self.layout,
entries: &entries.collect::<Vec<_>>(),
})
}
}
/// The pass, its buffers and the records it draws from: made when GPU culling
@@ -831,12 +1007,23 @@ pub(super) struct GpuCulling {
readbacks: Vec<Readback>,
next: usize,
current: Option<usize>,
/// The occlusion test, where the device can run it (Vulkan, with cull.rs's
/// test forced early: `Cull::early`).
test: Option<BoxTest>,
/// Advanced by every frame that may occlude (never 0); the newest share of
/// quads the test hid, with its frame; whether this frame occludes; and the
/// newest result's boxes not seen and tested.
stamp: u32,
share: (u64, f32),
occluding: bool,
hidden: Option<[usize; 2]>,
}
impl GpuCulling {
/// `None` when the device fails to make the pipelines or the memory for the
/// buffers is missing (caught, not raised: raised errors are fatal).
pub fn new(device: &wgpu::Device) -> Option<Self> {
/// buffers is missing (caught, not raised: raised errors are fatal). `test`:
/// the world pass's colour format, where frames can run the occlusion test.
pub fn new(device: &wgpu::Device, test: Option<wgpu::TextureFormat>) -> Option<Self> {
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
@@ -889,6 +1076,7 @@ impl GpuCulling {
log::warn!("Horizon renderer: GPU culling unavailable ({e})");
return None;
}
let test = test.and_then(|color| BoxTest::new(device, color, &layout));
use wgpu::BufferUsages as U;
let readbacks = (0..READBACKS)
.map(|_| {
@@ -902,6 +1090,7 @@ impl GpuCulling {
state: Arc::new(AtomicU8::new(FREE)),
frame: 0,
cpu: [0; 3],
occluded: false,
})
})
.collect::<Option<Vec<_>>>()?;
@@ -951,7 +1140,13 @@ impl GpuCulling {
1024 * 16,
U::VERTEX | U::STORAGE | TEST_READ,
)?,
runs: Grown::new(device, "GPU culling runs", 1024 * RUN_WORDS * 4, U::STORAGE)?,
// Kept when it grows (`reserve_keeping`): it holds the stamps.
runs: Grown::new(
device,
"GPU culling runs",
1024 * RUN_WORDS * 4,
U::STORAGE | U::COPY_SRC | U::COPY_DST,
)?,
// Room for render distance 64's graph, 57.5 KB.
reach: Grown::new(
device,
@@ -972,6 +1167,12 @@ impl GpuCulling {
readbacks,
next: 0,
current: None,
test,
stamp: 0,
// cull.rs's start: culling until a result says otherwise.
share: (0, 1.0),
occluding: false,
hidden: None,
})
}
@@ -993,6 +1194,7 @@ impl GpuCulling {
self.pages.clear();
self.ids = 0;
self.n_updates = 0;
self.occluding = false;
self.prepare_buffers(device, queue, uploads, encoder, input)
.unwrap_or(Prepared::OutOfMemory)
}
@@ -1036,12 +1238,18 @@ impl GpuCulling {
let pass_pages = self.records.page_sections.len().max(1) as u32;
// A region has room for every draw of every record on its page.
let total = RUNS * self.records.page_sections.iter().sum::<u32>();
// The statistics, each page's count, then each block's per page.
let count_words = STATS + u64::from(pass_pages) * (1 + u64::from(ids.div_ceil(WORKGROUP)));
// The statistics, each page's count in both phases, each block's per
// page in both phases, then where the test exists the boxes to test.
let blocks = u64::from(ids.div_ceil(WORKGROUP));
let phase1_end = STATS + u64::from(pass_pages) * (2 + blocks);
let tests_at = phase1_end + u64::from(pass_pages) * blocks;
let count_words = match self.test {
Some(_) => tests_at + u64::from(ids),
None => phase1_end,
};
for (buffer, bytes) in [
(&mut self.updates, self.scratch.len() as u64 * Update::BYTES),
(&mut self.origins, u64::from(ids) * 16),
(&mut self.runs, u64::from(ids) * RUN_WORDS * 4),
(&mut self.tables, u64::from(regions + 2 * pass_pages) * 4),
(&mut self.counts, count_words * 4),
(&mut self.draws, u64::from(total.max(1)) * 20),
@@ -1050,6 +1258,12 @@ impl GpuCulling {
self.group = None;
}
}
if self
.runs
.reserve_keeping(device, encoder, u64::from(ids) * RUN_WORDS * 4)?
{
self.group = None;
}
// A copy of the graph made anew holds none.
if let Some(r) = &input.reach
&& self.reach.reserve(device, r.bits.len() as u64 * 4)?
@@ -1063,6 +1277,16 @@ impl GpuCulling {
let Some(params) = planes else {
return Some(Prepared::Cpu);
};
// Occlusion, by cull.rs's gate over the share the newest test hid.
let mut occl = 0;
if self.test.is_some() && input.occlude {
self.stamp = self.stamp.wrapping_add(1).max(1);
let (occludes, probe) = super::cull::gate(self.share.1, self.stamp);
if occludes && ids > 0 {
self.occluding = true;
occl = OCCLUDE | if probe { PROBE } else { 0 };
}
}
if self.records.all_dirty {
// The whole table, after it grew or more records changed than
// updates take (GPU culling switched on in a loaded world, a
@@ -1106,8 +1330,11 @@ impl GpuCulling {
updates: self.scratch.len() as u32,
pages: pass_pages,
regions,
occl: [self.stamp, tests_at as u32, 0, 0],
view_proj: input.view_proj.to_cols_array_2d(),
..params
};
params.flags |= occl;
if let Some(r) = &input.reach {
if self.reach_generation != Some(r.generation) {
// Through the belt: a search lands every few frames while the
@@ -1121,9 +1348,24 @@ impl GpuCulling {
params.flags |= REACH | if r.sky_reached { SKY } else { 0 };
}
uploads.buffer(encoder, &self.params, 0, bytemuck::bytes_of(&params));
encoder.clear_buffer(&self.counts.buffer, 0, Some(count_words * 4));
// A frame that occludes reads phase 2's blocks too; the boxes to test
// are written before they are read.
let cleared = if self.occluding { tests_at } else { phase1_end };
encoder.clear_buffer(&self.counts.buffer, 0, Some(cleared * 4));
if self.group.is_none() {
self.group = Some(self.bind(device));
if let Some(test) = &mut self.test {
test.group = Some(test.bind(
device,
[
&self.params,
&self.counts.buffer,
&self.origins.buffer,
&self.runs.buffer,
&self.sections.buffer,
],
));
}
}
self.ids = ids;
self.n_updates = params.updates;
@@ -1183,11 +1425,13 @@ impl GpuCulling {
/// The kept sections' solid draws, a count draw per page, with the quad
/// indices bound. Leaves the origins at vertex slot 1. `group`: the page's group 1 for
/// pipelines that read the page themselves (`Flat::page_group`), else none.
/// `phase`: 0, or 1 after `compact` in a frame that occludes.
pub fn draw<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
arena: &Arena,
group: impl Fn(u32) -> Option<&'a wgpu::BindGroup>,
phase: u64,
) {
if self.pages.is_empty() {
return;
@@ -1202,7 +1446,7 @@ impl GpuCulling {
&self.draws.buffer,
u64::from(first) * 20,
&self.counts.buffer,
(STATS + u64::from(page)) * 4,
(STATS + phase * u64::from(self.pass_pages) + u64::from(page)) * 4,
size,
);
}
@@ -1213,7 +1457,60 @@ impl GpuCulling {
self.pages.len()
}
/// After `dispatch`, in the same encoder: the pass's counts copied out with
/// Whether the frame occludes (`prepare`): phase 1's `draw`, then `test` in
/// its pass, `compact` after it, and phase 2's `draw` in the next pass.
pub fn occluding(&self) -> bool {
self.occluding
}
/// The boxes the newest test read back did not see and those it tested,
/// and the share of quads it hid (cull.rs `Cull::hidden`).
pub fn hidden(&self) -> Option<([usize; 2], f32)> {
self.hidden.map(|h| (h, self.share.1))
}
/// In the world pass after phase 1's draws: the listed boxes against its
/// depth. Leaves the pass's pipeline and group 0 changed.
pub fn test(&self, pass: &mut wgpu::RenderPass<'_>) {
if let Some(BoxTest {
pipeline,
group: Some(group),
..
}) = &self.test
&& self.occluding
{
pass.set_pipeline(pipeline);
pass.set_bind_group(0, group, &[]);
pass.draw_indirect(&self.counts.buffer, BOX * 4);
}
}
/// Between the world passes: phase 2's draws, of the sections the test saw,
/// into the regions phase 1 drew from.
pub fn compact(&self, encoder: &mut wgpu::CommandEncoder, timing: Option<&mut PassTiming>) {
let (Some(test), Some(group)) = (&self.test, &self.group) else {
return;
};
if !self.occluding {
return;
}
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("GPU culling phase 2"),
timestamp_writes: timing.and_then(|t| t.compute_writes("GPU culling phase 2")),
});
pass.set_bind_group(0, group, &[]);
let [count2, scan2, scatter2] = &test.phase2;
let blocks = self.ids.div_ceil(WORKGROUP);
pass.set_pipeline(count2);
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
pass.set_pipeline(scan2);
pass.dispatch_workgroups(self.pass_pages, 1, 1);
pass.set_pipeline(scatter2);
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
}
/// After the frame's world passes (phase 2's counts too), 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.
@@ -1223,7 +1520,7 @@ impl GpuCulling {
return;
}
encoder.copy_buffer_to_buffer(&self.counts.buffer, 0, &r.buffer, 0, STATS * 4);
(r.frame, r.cpu) = (frame, cpu);
(r.frame, r.cpu, r.occluded) = (frame, cpu, self.occluding);
self.current = Some(self.next);
}
@@ -1270,7 +1567,15 @@ impl GpuCulling {
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 words: [u32; STATS as usize] =
bytemuck::pod_read_unaligned(&data[..STATS as usize * 4]);
let [kept, draws] = [words[0], words[1]];
// The newest test's share, for the gate (`prepare`).
if r.occluded && r.frame > self.share.0 {
let quads = words[QUADS].max(1);
self.share = (r.frame, words[HIDDEN_QUADS] as f32 / quads as f32);
self.hidden = Some([words[HIDDEN] as usize, words[BOX as usize + 1] as usize]);
}
let [sections, cpu_draws, calls] = r.cpu;
if newest.is_none_or(|(frame, _)| r.frame > frame) {
newest = Some((
@@ -1384,6 +1689,8 @@ mod tests {
frustum,
reach: None,
eye: None,
view_proj: Mat4::IDENTITY,
occlude: false,
}
}
@@ -1546,6 +1853,21 @@ mod tests {
runs
}
/// The pass's module validates without the early depth test, and the
/// occlusion test's (the pass's with `gpu_occlusion.wgsl`) with it.
#[test]
fn the_shaders_validate() {
use naga::valid::Capabilities as C;
let pass = include_str!("gpu_culling.wgsl");
let test = [pass, include_str!("gpu_occlusion.wgsl")].concat();
for (source, capabilities) in [(pass, C::all() - C::EARLY_DEPTH_TEST), (&test, C::all())] {
let module = naga::front::wgsl::parse_str(source).unwrap();
naga::valid::Validator::new(naga::valid::ValidationFlags::all(), capabilities)
.validate(&module)
.unwrap();
}
}
/// Ids, page counts, flags and changes against a plain list of what each id
/// holds: the lowest free id comes back, flagged only if inserted so, free
/// ids at the end leave, counts follow moves (to pages past the last too), a
@@ -1784,6 +2106,8 @@ mod tests {
("reach_sections", offset_of!(Params, reach_sections)),
("flags", offset_of!(Params, flags)),
("eyes", offset_of!(Params, eyes)),
("occl", offset_of!(Params, occl)),
("view_proj", offset_of!(Params, view_proj)),
] {
assert!(members.contains(&(name.into(), offset)), "Params.{name}");
}
@@ -1804,7 +2128,7 @@ mod tests {
];
assert_eq!(layout("Update"), (size_of::<Update>(), named(&id)));
assert_eq!((size_of::<Record>(), size_of::<Update>()), (80, 96));
assert_eq!(size_of::<Params>(), 128);
assert_eq!(size_of::<Params>(), 208);
let constant = |name: &str| {
let (_, c) = module
.constants
@@ -2492,7 +2816,7 @@ mod tests {
// So have the sections' buckets and whether a frame has an eye.
let mut shapes = Rng(0x3C6E_F372_FE94_F82B);
let (min_y, height) = (-64, 24);
let mut gpu = GpuCulling::new(device).expect("the pass");
let mut gpu = GpuCulling::new(device, None).expect("the pass");
let mut uploads = Uploads::new(device);
let mut held: BTreeMap<u32, (Placed, Sides)> = BTreeMap::new();
// Fading sections the frustum and the graph keep, left to the CPU.
@@ -2665,6 +2989,8 @@ mod tests {
frustum: &frustum,
reach,
eye,
view_proj: Mat4::IDENTITY,
occlude: false,
};
if case == 7 {
// Planes past the integers: the CPU draws that frame, nothing is
@@ -2821,7 +3147,7 @@ mod tests {
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");
let mut gpu = GpuCulling::new(device, None).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.
@@ -2918,7 +3244,7 @@ struct Quad {
for _ in 0..4 {
assert!(arena.open_page(device, u64::from(SECTIONS / 3) * grain));
}
let mut gpu = GpuCulling::new(device).expect("the pass");
let mut gpu = GpuCulling::new(device, None).expect("the pass");
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
// The sections' buckets by facing side have their own sequence.
let mut shapes = Rng(0xA54F_F53A_5F1D_36F1);
@@ -3047,6 +3373,8 @@ struct Quad {
frustum,
reach: None,
eye,
view_proj: Mat4::IDENTITY,
occlude: false,
};
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &input);
assert_eq!(prepared, Prepared::Gpu, "frame {frame}");
@@ -3091,7 +3419,7 @@ struct Quad {
});
pass.set_pipeline(&pipeline);
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
gpu.draw(&mut pass, &arena, |_| None);
gpu.draw(&mut pass, &arena, |_| None, 0);
drop(pass);
encoder.copy_texture_to_buffer(
target.as_image_copy(),
@@ -13,6 +13,13 @@
// integers the CPU computed, and facing compares keys that order as the floats
// the CPU compares. `cull` leaves sections fading in or out to the CPU
// (`FADING`).
// Frames that occlude (`OCCLUDE`, gpu_occlusion.wgsl) draw in two phases, each
// in id order: `cull` leaves to phase 2 the sections whose box the last test
// did not see and lists the boxes to test; after the test, `count2`, `scan2`
// and `scatter2` write the draws of those it saw into the same regions. Which
// boxes count as seen untested (`near_or_big`) is float arithmetic of the
// shader's own: it only sorts sections between the phases, and doubt counts as
// seen.
struct Params {
// Ids to test, records `apply` writes, the arena pages the counts and
@@ -34,13 +41,19 @@ struct Params {
// Each frustum plane's w term, its margin in, then 0, 0.
w: array<vec4<i32>, 2>,
// Cave culling's graph (occlusion.rs `Reach`): from x, from z, radius, lowest
// section y; its height in sections and the level's; REACH, SKY and EYE;
// where the eye's keys start in `tables`.
// section y; its height in sections and the level's; REACH, SKY, EYE,
// OCCLUDE and PROBE; where the eye's keys start in `tables`.
reach: vec4<i32>,
reach_height: i32,
reach_sections: i32,
flags: u32,
eyes: u32,
// Occlusion (OCCLUDE): this frame's stamp (never 0), where the boxes to
// test start in `counts`, 0, 0.
occl: vec4<u32>,
// The world pass's view projection, TAA's jitter included (gfx.rs
// `globals.view_proj`).
view_proj: mat4x4<f32>,
}
// gpu_culling.rs `Record`.
@@ -75,15 +88,18 @@ struct Update {
// and its size).
@group(0) @binding(2) var<storage, read> tables: array<u32>;
@group(0) @binding(3) var<storage, read> updates: array<Update>;
// Sections kept, draws, each page's draws, then per block of ids and page its
// draws (`cull`), turned into where they start (`scan`).
// The statistics (`STATS`), each page's draws in phase 1 and in phase 2, then
// per block of ids and page its draws in each phase (`cull`, `count2`), turned
// into where they start (`scan`, `scan2`), then the boxes to test (`cull`).
@group(0) @binding(4) var<storage, read_write> counts: array<atomic<u32>>;
// DrawIndexedIndirectArgs, five words each.
@group(0) @binding(5) var<storage, read_write> draws: array<u32>;
// Per id the instance it is drawn with: origin and UV step, as f32 bits.
@group(0) @binding(6) var<storage, read_write> origins: array<vec4<u32>>;
// Per id `RUN_WORDS`: its page << PAGE_SHIFT | its draws when it is drawn this
// frame, else 0; then each draw's first quad and quads.
// Per id `RUN_WORDS`: its page << PAGE_SHIFT | TESTED | PHASE2 | its draws when
// it is drawn this frame, else 0; then each draw's first quad and quads; then
// the stamps of the frames whose test last saw its box and last tested it,
// kept from frame to frame.
@group(0) @binding(7) var<storage, read_write> runs: array<u32>;
// occlusion.rs `Reach::bits`: whether the search reached each cell of its box,
// a bit each (32 a word, lowest first).
@@ -95,8 +111,16 @@ const STEP_BITS: u32 = 0x38000000u;
const ROW: u32 = 65535u;
// Ids per block.
const BLOCK: u32 = 64u;
// Counts before the pages'.
const STATS: u32 = 2u;
// Counts before the pages': sections kept and draws, then occlusion's: the
// quads of the sections with runs and those of the tested boxes not seen, the
// box draw (four words and a spare), those boxes, phase 2's draws, a spare.
const STATS: u32 = 12u;
const DRAWS: u32 = 1u;
const QUADS: u32 = 2u;
const HIDDEN_QUADS: u32 = 3u;
const BOX: u32 = 4u;
const HIDDEN: u32 = 9u;
const DRAWN2: u32 = 10u;
// gpu_culling.rs `rounded_box` for a box past what its codes hold: no plane
// rejects it.
const UNBOUNDED: u32 = 0x80000000u;
@@ -109,14 +133,56 @@ const FADING: u32 = 1u;
// In `params.flags`: solid quads facing away from the eye are left out, its
// keys in `tables` from `params.eyes`.
const EYE: u32 = 4u;
// Words per id in `runs`, and where its page starts in the first, its draws
// (at most 4: gpu_culling.rs `RUNS`) below.
const RUN_WORDS: u32 = 9u;
const PAGE_SHIFT: u32 = 3u;
// Words per id in `runs`, and where its page starts in the first, its flags and
// draws (at most 4: gpu_culling.rs `RUNS`) below.
const RUN_WORDS: u32 = 11u;
const PAGE_SHIFT: u32 = 5u;
// In its first word: its box is tested this frame; it waits for phase 2.
const TESTED: u32 = 16u;
const PHASE2: u32 = 8u;
// Its stamps: the frames whose test last saw its box, and last tested it.
const SEEN_WORD: u32 = 9u;
const TESTED_WORD: u32 = 10u;
// In `params.flags`: the frame occludes, and tests every box (cull.rs's probe).
const OCCLUDE: u32 = 8u;
const PROBE: u32 = 16u;
// cull.rs `RETEST` and `BIG`.
const RETEST: u32 = 8u;
const BIG: f32 = 0.25;
// gpu_culling.rs `CODE_OFFSET`: the rounded box's codes hold -8..=23.
const CODE_OFFSET: i32 = 8i;
// Past the box, for rounding: far past what f32 moves a camera-relative corner.
const EPS: f32 = 0.015625;
// Clip z / w at about twice the near plane's distance: reversed depth is 1 at
// the near plane and grows toward the eye; behind it, z > 0 >= w.
const NEAR_DEPTH: f32 = 0.5;
// The count of `block`'s draws in `page` (`cull`), then where they start (`scan`).
fn block_count(block: u32, page: u32) -> u32 {
return STATS + params.pages + block * params.pages + page;
// `page`'s count draw in `phase` (0: the first world pass; 1: after the test).
fn page_count(page: u32, phase: u32) -> u32 {
return STATS + phase * params.pages + page;
}
// The count of `block`'s draws in `page` and `phase` (`cull`, `count2`), then
// where they start (`scan`): phase 1's slots, then phase 2's.
fn block_count(block: u32, page: u32, phase: u32) -> u32 {
let blocks = (params.ids + BLOCK - 1u) / BLOCK;
return STATS + 2u * params.pages + (phase * blocks + block) * params.pages + page;
}
// The id's first `runs` word, its flags cleared, if it draws in `phase` (in
// phase 2, only once this frame's test saw its box); else 0.
fn drawn_in(id: u32, phase: u32) -> u32 {
if id >= params.ids {
return 0u;
}
let w = runs[id * RUN_WORDS];
if (w & 7u) == 0u || ((w & PHASE2) != 0u) != (phase == 1u) {
return 0u;
}
if (w & PHASE2) != 0u && runs[id * RUN_WORDS + SEEN_WORD] != params.occl.x {
return 0u;
}
return w & ~(PHASE2 | TESTED);
}
// `page`'s region of `draws`: its first draw and its size.
@@ -218,6 +284,59 @@ fn seen(id: u32, b: u32, eye: vec3<u32>) -> bool {
}
}
// Corner `c` (bit 0: x, bit 1: y, bit 2: z) of the box tested for the section
// at `o` whose rounded box is `b` (never UNBOUNDED here): cull.wgsl's box, the
// cell grown by a block, grown further to the section's own bounds where they
// reach past it, then by EPS.
fn box_corner(o: vec3<f32>, b: u32, c: u32) -> vec3<f32> {
let low = vec3<i32>(vec3<u32>(b, b >> 5u, b >> 10u) & vec3(31u)) - vec3(CODE_OFFSET);
let high = vec3<i32>(vec3<u32>(b >> 15u, b >> 20u, b >> 25u) & vec3(31u)) - vec3(CODE_OFFSET);
let lo = min(vec3<f32>(low), vec3(-1.0)) - vec3(EPS);
let hi = max(vec3<f32>(high), vec3(17.0)) + vec3(EPS);
return o + select(lo, hi, (vec3(c) & vec3(1u, 2u, 4u)) != vec3(0u));
}
// Boxes that count as seen untested: one with a corner within about twice the
// near plane's distance, behind the eye, or NaN, whose front faces near
// clipping could cut away; or, as in cull.wgsl, one over BIG of the screen.
fn near_or_big(o: vec3<f32>, b: u32) -> bool {
var lo = vec2(1e30);
var hi = vec2(-1e30);
for (var c = 0u; c < 8u; c++) {
let p = params.view_proj * vec4(box_corner(o, b, c), 1.0);
if !(p.z < NEAR_DEPTH * p.w) {
return true;
}
let q = p.xy / p.w;
lo = min(lo, q);
hi = max(hi, q);
}
let size = max(min(hi, vec2(1.0)) - max(lo, vec2(-1.0)), vec2(0.0));
return size.x * size.y > BIG * 4.0;
}
// In frames that occlude, a kept section with runs: its flags (0: phase 1,
// untested), its box listed when it is tested. Its last test saw it: phase 1,
// retested every RETEST frames (every frame with PROBE). A box that counts as
// seen: phase 1. Else phase 2, drawn if this frame's test sees its box.
fn occlusion(id: u32, at: u32, o: vec3<f32>, b: u32) -> u32 {
let stamp = params.occl.x;
let seen = runs[at + SEEN_WORD];
let passed = seen != 0u && seen == runs[at + TESTED_WORD];
if passed && (params.flags & PROBE) == 0u && (id + stamp) % RETEST != 0u {
return 0u;
}
if (b & UNBOUNDED) != 0u || near_or_big(o, b) {
runs[at + SEEN_WORD] = stamp;
runs[at + TESTED_WORD] = stamp;
return 0u;
}
runs[at + TESTED_WORD] = stamp;
let t = atomicAdd(&counts[BOX + 1u], 1u);
atomicStore(&counts[params.occl.y + t], id);
return TESTED | select(PHASE2, 0u, passed);
}
// One invocation per changed record; the CPU lists each id once.
@compute @workgroup_size(64)
fn apply(@builtin(global_invocation_id) gid: vec3<u32>) {
@@ -230,6 +349,10 @@ fn apply(@builtin(global_invocation_id) gid: vec3<u32>) {
@compute @workgroup_size(64)
fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
let id = gid.y * (ROW * BLOCK) + gid.x;
if id == 0u && (params.flags & OCCLUDE) != 0u {
// The box draw's vertices: the cube's corners (gpu_occlusion.wgsl).
atomicStore(&counts[BOX], 36u);
}
// Past the table's end the records are stale: the CPU never writes them free.
if id >= params.ids {
return;
@@ -252,7 +375,8 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
atomicAdd(&counts[0], 1u);
let nx = params.cells.x;
let ny = params.cells.y;
origins[id] = vec4<u32>(tables[c.x], tables[nx + c.y], tables[nx + ny + c.z], STEP_BITS);
let origin = vec4<u32>(tables[c.x], tables[nx + c.y], tables[nx + ny + c.z], STEP_BITS);
origins[id] = origin;
// gfx.rs `facing_runs`: the runs of its buckets that can face the eye, an
// empty bucket breaking none, a draw each; without an eye, its solid quads.
var n = 0u;
@@ -292,24 +416,29 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
if n == 0u {
return;
}
runs[at] = (page << PAGE_SHIFT) | n;
atomicAdd(&counts[block_count(id / BLOCK, page)], n);
var flags = 0u;
if (params.flags & OCCLUDE) != 0u {
flags = occlusion(id, at, bitcast<vec3<f32>>(origin.xyz), s.a.z);
}
runs[at] = (page << PAGE_SHIFT) | flags | n;
if (flags & PHASE2) == 0u {
atomicAdd(&counts[block_count(id / BLOCK, page, 0u)], n);
}
}
var<workgroup> partial: array<u32, 256>;
// One workgroup per page: each lane sums a stretch of blocks, the lanes'
// sums are scanned, and each block's sum becomes where its draws start.
@compute @workgroup_size(256)
fn scan(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
let page = group.x;
// sums are scanned, and each block's sum becomes where its draws start, in
// `phase` (`scan`, gpu_occlusion.wgsl `scan2`).
fn scan_page(page: u32, lane: u32, phase: u32) {
let blocks = (params.ids + BLOCK - 1u) / BLOCK;
let stretch = (blocks + 255u) / 256u;
let first = min(lane * stretch, blocks);
let last = min(first + stretch, blocks);
var sum = 0u;
for (var b = first; b < last; b++) {
sum += atomicLoad(&counts[block_count(b, page)]);
sum += atomicLoad(&counts[block_count(b, page, phase)]);
}
partial[lane] = sum;
workgroupBarrier();
@@ -327,31 +456,35 @@ fn scan(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index
start = partial[lane - 1u];
}
for (var b = first; b < last; b++) {
let count = atomicLoad(&counts[block_count(b, page)]);
atomicStore(&counts[block_count(b, page)], start);
let count = atomicLoad(&counts[block_count(b, page, phase)]);
atomicStore(&counts[block_count(b, page, phase)], start);
start += count;
}
if lane == 255u {
// A region has room for every draw of its page's records; were one
// short, its draws past it go.
let total = min(partial[255], region(page).y);
atomicStore(&counts[STATS + page], total);
atomicAdd(&counts[1], total);
atomicStore(&counts[page_count(page, phase)], total);
atomicAdd(&counts[DRAWS], total);
if phase == 1u {
atomicAdd(&counts[DRAWN2], total);
}
}
}
@compute @workgroup_size(256)
fn scan(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
scan_page(group.x, lane, 0u);
}
var<workgroup> held: array<u32, 64>;
// One workgroup per block of ids: each section's draws go after those of the
// block's earlier sections in its page, in its runs' order.
@compute @workgroup_size(64)
fn scatter(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
let block = group.y * ROW + group.x;
// block's earlier sections in its page, in its runs' order, in `phase`
// (`scatter`, gpu_occlusion.wgsl `scatter2`).
fn scatter_block(block: u32, lane: u32, phase: u32) {
let id = block * BLOCK + lane;
var mine = 0u;
if id < params.ids {
mine = runs[id * RUN_WORDS];
}
let mine = drawn_in(id, phase);
held[lane] = mine;
workgroupBarrier();
// Its draws below PAGE_SHIFT, its page above.
@@ -360,7 +493,7 @@ fn scatter(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_in
return;
}
let page = mine >> PAGE_SHIFT;
var slot = atomicLoad(&counts[block_count(block, page)]);
var slot = atomicLoad(&counts[block_count(block, page, phase)]);
for (var other = 0u; other < lane; other++) {
// A section not drawn holds 0: no draws.
if (held[other] >> PAGE_SHIFT) == page {
@@ -383,3 +516,8 @@ fn scatter(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_in
draws[d + 4u] = id;
}
}
@compute @workgroup_size(64)
fn scatter(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
scatter_block(group.y * ROW + group.x, lane, 0u);
}
@@ -0,0 +1,95 @@
// GPU culling's occlusion test (gpu_culling.rs `BoxTest`), after
// gpu_culling.wgsl in one module, made only where the depth test can be forced
// before the fragment shader (Vulkan, cull.rs `DepthTest::Early`): one instance
// per box `cull` listed, its section's box drawn front faces only against phase
// 1's depth; then `count2`, `scan2` and `scatter2` write phase 2's draws.
@group(0) @binding(9) var<storage, read> box_list: array<u32>;
@group(0) @binding(10) var<storage, read> box_origins: array<vec4<u32>>;
@group(0) @binding(11) var<storage, read_write> box_runs: array<u32>;
@group(0) @binding(12) var<storage, read> box_sections: array<Section>;
// The corners (bit 0: x, bit 1: y, bit 2: z) of the box's 12 triangles,
// counter-clockwise seen from outside (cull.wgsl's).
const CORNERS = array<u32, 36>(
0u, 6u, 2u, 0u, 4u, 6u, 1u, 3u, 7u, 1u, 7u, 5u, 0u, 1u, 5u, 0u, 5u, 4u,
2u, 7u, 3u, 2u, 6u, 7u, 0u, 3u, 1u, 0u, 2u, 3u, 4u, 5u, 7u, 4u, 7u, 6u,
);
struct BoxOut {
@builtin(position) position: vec4<f32>,
@location(0) @interpolate(flat) id: u32,
}
@vertex
fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> BoxOut {
let id = box_list[params.occl.y + t];
let o = bitcast<vec3<f32>>(box_origins[id].xyz);
var out: BoxOut;
out.id = id;
out.position = params.view_proj * vec4(box_corner(o, box_sections[id].a.z, CORNERS[v]), 1.0);
return out;
}
// Seen at this fragment, which runs only where the depth test passes (forced
// before it: a fragment that failed would still write). Every writer stores
// the same stamp.
@fragment
@early_depth_test(force)
fn fs_box(in: BoxOut) -> @location(0) vec4<f32> {
let at = in.id * RUN_WORDS + SEEN_WORD;
if box_runs[at] != params.occl.x {
box_runs[at] = params.occl.x;
}
return vec4(0.0);
}
var<workgroup> tally: array<atomic<u32>, 3>;
// After the test, one workgroup per block of ids: the quads of the sections
// with runs, the tested boxes not seen and their quads, and per page each
// block's draws that phase 2 makes (`drawn_in`'s condition).
@compute @workgroup_size(64)
fn count2(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
if lane < 3u {
atomicStore(&tally[lane], 0u);
}
workgroupBarrier();
let block = group.y * ROW + group.x;
let id = block * BLOCK + lane;
if id < params.ids {
let at = id * RUN_WORDS;
let w = runs[at];
let n = w & 7u;
if n > 0u {
var quads = 0u;
for (var k = 0u; k < n; k++) {
quads += runs[at + 2u + 2u * k];
}
atomicAdd(&tally[0], quads);
let saw = runs[at + SEEN_WORD] == params.occl.x;
if (w & TESTED) != 0u && !saw {
atomicAdd(&tally[1], 1u);
atomicAdd(&tally[2], quads);
}
if (w & PHASE2) != 0u && saw {
atomicAdd(&counts[block_count(block, w >> PAGE_SHIFT, 1u)], n);
}
}
}
workgroupBarrier();
if lane == 0u {
atomicAdd(&counts[QUADS], atomicLoad(&tally[0]));
atomicAdd(&counts[HIDDEN], atomicLoad(&tally[1]));
atomicAdd(&counts[HIDDEN_QUADS], atomicLoad(&tally[2]));
}
}
@compute @workgroup_size(256)
fn scan2(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
scan_page(group.x, lane, 1u);
}
@compute @workgroup_size(64)
fn scatter2(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
scatter_block(group.y * ROW + group.x, lane, 1u);
}
@@ -101,6 +101,11 @@ fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
vec![
("entity", vec!["entity.wgsl"], entity.clone()),
(
"GPU culling occlusion test",
vec!["gpu_culling.wgsl", "gpu_occlusion.wgsl"],
include_str!("gpu_culling.wgsl").to_owned() + include_str!("gpu_occlusion.wgsl"),
),
(
"static entity",
vec!["entity.wgsl", "static_entity.wgsl"],
@@ -367,6 +367,7 @@ impl NativeFrame {
distance: None,
unculled: None,
every_face: false,
view_casters: false,
})
}
@@ -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)
@@ -46,6 +46,9 @@ mod forced_flip_tests;
#[cfg(test)]
mod weather_depth_tests;
#[cfg(test)]
mod custom_type_tests;
/// The texture behind a pack sampler name.
#[derive(Clone, Copy, PartialEq)]
enum Source {
@@ -1088,6 +1091,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
@@ -1512,6 +1520,22 @@ fn image_format(qualifier: &str) -> Option<wgpu::TextureFormat> {
})
}
/// The value slot of the pack's custom uniform `name` for a program declaring it as GLSL type
/// `ty`: after the frame's uniforms, in the order of `custom` (each with the type
/// `shaders.properties` gives it). A `float`, `int`, `vec2`, `vec3` or `vec4` custom uniform
/// declared as another of these types gets none, and reads 0: Iris 1.11.7 logs "Wrong uniform
/// type ... Disabling that uniform" and our probes read 0 there (float as int, int as float,
/// vec3 as vec2, vec2 as vec3; the vec4 cases were not probed). Other pairs (a `bool` on either
/// side) keep the conversion `Value::write` makes: our probe read a float of 2.5 declared
/// `bool` as true in both games.
fn custom_index(custom: &[(String, String)], name: &str, ty: &str) -> Option<usize> {
const STRICT: [&str; 5] = ["float", "int", "vec2", "vec3", "vec4"];
let k = custom.iter().position(|(c, _)| c == name)?;
let declared = custom[k].1.as_str();
let disabled = declared != ty && STRICT.contains(&declared) && STRICT.contains(&ty);
(!disabled).then_some(pack_uniforms::NAMES.len() + k)
}
/// The pass of program `p`: a quad, or `geometry` drawn by the game. `None` (with a note) when
/// wgpu refuses it or it writes nothing. `formats` are its buffers' (for a program of the shadow
/// buffers, buffer n is shadowcolor n); `shadow` the shadowcolor formats when the shadow pass
@@ -1525,17 +1549,14 @@ fn build_pass(
screen: wgpu::TextureFormat,
notes: &mut Vec<String>,
geometry: Option<&Geometry<'_>>,
custom: &[String],
custom: &[(String, String)],
draws: &wgpu::BindGroupLayout,
pack: &Resources<'_>,
) -> Option<Pass> {
let features = device.features();
// The frame's uniforms, then the pack's custom ones.
let index = |name: &str| {
pack_uniforms::index_in(name, geometry.is_some()).or_else(|| {
let k = custom.iter().position(|c| c == name)?;
Some(pack_uniforms::NAMES.len() + k)
})
let index = |name: &str, ty: &str| {
pack_uniforms::index_in(name, geometry.is_some()).or_else(|| custom_index(custom, name, ty))
};
let mask = |flips: &[(u32, bool)]| flips.iter().filter(|f| f.1).fold(0u16, |m, f| m | 1 << f.0);
let mut p = p;
@@ -1727,7 +1748,7 @@ fn build_pass(
let missing: Vec<&str> = interface
.uniforms
.iter()
.filter(|u| !u.name.starts_with("hz_") && index(&u.name).is_none())
.filter(|u| !u.name.starts_with("hz_") && index(&u.name, &u.ty).is_none())
.map(|u| u.name.as_str())
.collect();
if !missing.is_empty() {
@@ -1749,7 +1770,7 @@ fn build_pass(
uniforms: interface
.uniforms
.iter()
.map(|u| (index(&u.name), u.ty.clone(), u.offset))
.map(|u| (index(&u.name, &u.ty), u.ty.clone(), u.offset))
.collect(),
name: p.name,
group: p.group,
@@ -1802,14 +1823,11 @@ fn build_compute(
formats: &[wgpu::TextureFormat; BUFFERS],
shadow: Option<&[wgpu::TextureFormat; SHADOW_COLORS]>,
notes: &mut Vec<String>,
custom: &[String],
custom: &[(String, String)],
pack: &Resources<'_>,
) -> Option<Pass> {
let index = |name: &str| {
pack_uniforms::index(name).or_else(|| {
let k = custom.iter().position(|c| c == name)?;
Some(pack_uniforms::NAMES.len() + k)
})
let index = |name: &str, ty: &str| {
pack_uniforms::index(name).or_else(|| custom_index(custom, name, ty))
};
let interface = &c.interface;
let (entries, samplers, slots, reads, shadow_reads, images) = bindings(
@@ -1878,7 +1896,7 @@ fn build_compute(
uniforms: interface
.uniforms
.iter()
.map(|u| (index(&u.name), u.ty.clone(), u.offset))
.map(|u| (index(&u.name, &u.ty), u.ty.clone(), u.offset))
.collect(),
name,
group,
@@ -1974,7 +1992,9 @@ impl PackPost {
// shadowcolor2 to 7 only for a pack that names `HIGHER_SHADOWCOLOR`.
let shadow_colors = (1u16 << post.shadow_colors) - 1;
let mut custom = post.custom;
let custom_names: Vec<String> = custom.uniforms().map(|u| u.0.to_owned()).collect();
let custom_names: Vec<(String, String)> = (custom.uniforms())
.map(|(name, ty)| (name.to_owned(), ty.to_owned()))
.collect();
// A first run finds the names neither the frame nor the pack defines.
custom.eval(0.0, &pack_uniforms::zero);
// Its zeros are no start for `smooth`: the first frame's values are, as in Iris.
@@ -3905,6 +3925,7 @@ impl PackPost {
horizon_shaders::ShadowCulling::Reversed => Some(shadow.voxel_distance),
},
every_face: true,
view_casters: true,
})
}
@@ -0,0 +1,76 @@
//! A custom uniform of `shaders.properties` that the GLSL declares with another type: an original
//! probe (#9307). In Iris 1.11.7 our probe packs read 0 for a float custom uniform declared `int`,
//! an int declared `float`, a vec3 declared `vec2` and a vec2 declared `vec3`, and the uniform's
//! own value where the types agree. A float of 2.5 declared `bool` read true (Iris logged it as
//! disabled, but it read true in both games).
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add(
"shaders.properties",
"uniform.float.floatAsInt = 7.0\n\
uniform.int.intAsFloat = 7\n\
uniform.vec3.vec3AsVec2 = vec3(1.0, 2.0, 3.0)\n\
uniform.vec2.vec2AsVec3 = vec2(4.0, 5.0)\n\
uniform.float.floatAsFloat = 0.5\n\
uniform.int.intAsInt = 3\n\
uniform.float.floatAsBool = 2.5\n",
);
add("final.vsh", VERTEX);
// Pixel column x shows case x % 7 in red.
add(
"final.fsh",
"#version 330 compatibility\n\
uniform int floatAsInt;\nuniform float intAsFloat;\nuniform vec2 vec3AsVec2;\n\
uniform vec3 vec2AsVec3;\nuniform float floatAsFloat;\nuniform int intAsInt;\n\
uniform bool floatAsBool;\n\
void main() {\n int x = int(gl_FragCoord.x) % 7;\n float o = 0.0;\n\
if (x == 0) o = float(floatAsInt) / 10.0;\n\
else if (x == 1) o = intAsFloat / 10.0;\n\
else if (x == 2) o = (vec3AsVec2.x + vec3AsVec2.y) / 10.0;\n\
else if (x == 3) o = (vec2AsVec3.x + vec2AsVec3.y) / 10.0;\n\
else if (x == 4) o = floatAsFloat;\n\
else if (x == 5) o = float(intAsInt) / 10.0;\n\
else o = floatAsBool ? 1.0 : 0.0;\n\
gl_FragColor = vec4(o, 0.0, 0.0, 1.0);\n}\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-custom-types",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert_eq!(post.programs.len(), 1, "{:?}", post.notes);
post
}
#[test]
fn a_custom_uniform_declared_with_another_type_reads_zero() {
let Some(gpu) = Fixture::new() else { return };
let Ok(mut post) = gpu.try_build(pack()) else {
panic!("the pack runs")
};
let pixels = gpu.render(&mut post, [7, 1]);
let red: Vec<u8> = pixels[..7].iter().map(|p| p[0]).collect();
// 0 for the four mismatches, 0.5 and 0.3 where the types agree, then true.
let want = [0.0, 0.0, 0.0, 0.0, 0.5, 0.3, 1.0].map(|v: f32| (v * 255.0).round() as u8);
assert!(
red.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1),
"{red:?}, expected {want:?}"
);
}
@@ -293,3 +293,56 @@ fn a_part_that_fails_to_build_takes_the_other_with_it() {
assert!(notes.iter().any(|n| n.starts_with(note)), "{notes:?}");
}
}
/// Iris 1.11.7 with our probe packs pe4u (only `gbuffers_terrain`) and pe4s (only
/// `gbuffers_terrain_solid` and `gbuffers_terrain_cutout`), each writing one alpha per screen
/// strip (#9307): solid terrain kept every fragment, alpha 0.0 included, and cutout terrain
/// kept those above 0.5 (0.51 kept; 0.5, 0.49 and lower discarded), whichever program drew it.
#[test]
fn cutout_terrain_keeps_alphas_above_0_5_and_solid_terrain_keeps_all() {
for (alpha, cutout) in [(0.0, false), (0.49, false), (0.5, false), (0.51, true)] {
let Some(got) = strips(&[("gbuffers_terrain", RED, alpha)]) else {
return;
};
let want = [Some(RED), cutout.then_some(RED), Some(RED)];
assert_eq!(got, want, "gbuffers_terrain writing alpha {alpha}");
let programs = [
("gbuffers_terrain_solid", GREEN, alpha),
("gbuffers_terrain_cutout", BLUE, alpha),
];
let Some(got) = strips(&programs) else { return };
let want = [Some(GREEN), cutout.then_some(BLUE), Some(GREEN)];
assert_eq!(got, want, "split programs writing alpha {alpha}");
}
}
/// `alphaTestRef` in the same probes: 0 for solid terrain and 0.5 for cutout terrain, through
/// `gbuffers_terrain` alone or through the split programs.
#[test]
fn alpha_test_ref_is_0_for_solid_terrain_and_0_5_for_cutout() {
let program = |name: &str| {
[
(
format!("{name}.vsh"),
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}".to_owned(),
),
(
format!("{name}.fsh"),
"#version 120\nuniform float alphaTestRef;\n/* DRAWBUFFERS:0 */\n\
void main(){gl_FragData[0]=vec4(1.0,alphaTestRef*2.0,0.0,1.0);}"
.to_owned(),
),
]
};
const YELLOW: [u8; 3] = [255, 255, 0];
for names in [
&["gbuffers_terrain"][..],
&["gbuffers_terrain_solid", "gbuffers_terrain_cutout"],
] {
let files = names.iter().flat_map(|n| program(n)).collect();
let Some((got, _)) = strips_of(files, [255; 4]) else {
return;
};
assert_eq!(got, [Some(RED), Some(YELLOW), Some(RED)], "{names:?}");
}
}
@@ -1,3 +1,26 @@
// `invert_generic` and the formula of `pack_projection` in this file are adapted from JOML
// 1.10.9's `Matrix4f` (https://github.com/JOML-CI/JOML, MIT; see THIRD_PARTY_NOTICES.md).
//
// Copyright (c) 2015-2026 Richard Greenlees
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//! The uniforms a shader pack's post-processing programs read once per frame (spec 5.2, class
//! F), from the renderer's `Frame` and the few values only the game knows (`PackInputs`).
//! Matrices follow GL's conventions (5.5): `gbufferProjection` maps view depth to clip z in
@@ -360,6 +383,116 @@ pub fn shadow_light(frame: &Frame, shadow: &Shadow) -> Vec3 {
body(angle, shadow.sun_path_rotation).normalize()
}
/// A sky angle in degrees as the fraction of a day Iris 1.11.7 gives packs (`sunAngle`,
/// `shadowAngle`): 0 at sunrise, 0.25 at noon. Our probes' readings fit `((angle + 90) % 360)
/// / 360` in floats to the last bit, by day and by night.
fn day_fraction(degrees: f32) -> f32 {
(degrees + 90.0) % 360.0 / 360.0
}
/// The sky angle in degrees of the body that casts the shadows: the sun by day, the moon at
/// night (taken below 360).
fn shadow_light_degrees(frame: &Frame) -> f32 {
if day_fraction(frame.sky.sun_angle) < 0.5 {
frame.sky.sun_angle
} else {
frame.sky.moon_angle % 360.0
}
}
/// `shadowModelView` and its inverse to the last bit, as Iris 1.11.7 gives them: our probes'
/// readings (20 scenes: times of day and night, `sunPathRotation` 0 and 30, cameras off the
/// block grid) fit these JOML float operations, in this order: `rotateX(90°)`, `rotateZ(-angle)`
/// with the light body's sky angle, `rotateX(sunPathRotation)`, then `translate` by minus the
/// origin `shadow_camera` describes, from the camera's position as a float; and JOML's
/// `invert()`. `shadow_camera`'s view, built in doubles, differs in the last bits (6e-17 for
/// 0), and on a texel boundary of the map that is enough to read the other texel (#8464).
pub fn shadow_model_view(frame: &Frame, shadow: &Shadow) -> (Mat4, Mat4) {
use horizon_core::geometry::trig::DEG_TO_RAD;
let mut m = crate::camera::joml::Mat4::IDENTITY;
m.rotate_x(90.0 * DEG_TO_RAD);
m.rotate_z(-(shadow_light_degrees(frame) * DEG_TO_RAD));
m.rotate_x(shadow.sun_path_rotation * DEG_TO_RAD);
let step = shadow.interval;
if step > 0.0 {
let camera = frame.camera.as_vec3();
let remainder = if shadow.euclidean_snap {
camera.rem_euclid(Vec3::splat(step))
} else {
camera % step
};
let origin = Vec3::splat(step / 2.0) - remainder;
m.translate(-origin.x, -origin.y, -origin.z);
}
let inverse = m
.invert_orthonormal()
.expect("rotations and a translation are orthonormal");
(m.to_glam(), inverse.to_glam())
}
/// `gbufferProjection` and its inverse to the last bit, as Iris 1.11.7 gives them: our probes'
/// readings fit JOML's `setPerspective` formula worked in doubles from the float field of view
/// in radians, the float aspect ratio and the near plane 0.05 as a float, narrowed to floats;
/// and JOML's general `invert()` of that, in floats. glam's perspective differs in the last
/// bits.
pub fn pack_projection(fov_degrees: f32, aspect: f32, far: f32) -> (Mat4, Mat4) {
use horizon_core::geometry::trig::DEG_TO_RAD;
let fov = f64::from(fov_degrees * DEG_TO_RAD);
let (aspect, near, far) = (f64::from(aspect), f64::from(0.05f32), f64::from(far));
let h = (fov * 0.5).tan();
let m = glam::DMat4::from_cols(
glam::DVec4::new(1.0 / (h * aspect), 0.0, 0.0, 0.0),
glam::DVec4::new(0.0, 1.0 / h, 0.0, 0.0),
glam::DVec4::new(0.0, 0.0, (far + near) / (near - far), -1.0),
glam::DVec4::new(0.0, 0.0, (far + far) * near / (near - far), 0.0),
)
.as_mat4();
(m, invert_generic(m))
}
/// JOML's `Matrix4f.invert()` of a matrix with no known shape (`invertGeneric`), operation by
/// operation (JOML's `Math.fma` without `joml.useMathFma` is a product and a sum).
fn invert_generic(m: Mat4) -> Mat4 {
let g = |c: usize, r: usize| m.col(c)[r];
let (m00, m01, m02, m03) = (g(0, 0), g(0, 1), g(0, 2), g(0, 3));
let (m10, m11, m12, m13) = (g(1, 0), g(1, 1), g(1, 2), g(1, 3));
let (m20, m21, m22, m23) = (g(2, 0), g(2, 1), g(2, 2), g(2, 3));
let (m30, m31, m32, m33) = (g(3, 0), g(3, 1), g(3, 2), g(3, 3));
let a = m00 * m11 - m01 * m10;
let b = m00 * m12 - m02 * m10;
let c = m00 * m13 - m03 * m10;
let d = m01 * m12 - m02 * m11;
let e = m01 * m13 - m03 * m11;
let f = m02 * m13 - m03 * m12;
let gg = m20 * m31 - m21 * m30;
let h = m20 * m32 - m22 * m30;
let i = m20 * m33 - m23 * m30;
let j = m21 * m32 - m22 * m31;
let k = m21 * m33 - m23 * m31;
let l = m22 * m33 - m23 * m32;
let det = 1.0 / (a * l - b * k + c * j + d * i - e * h + f * gg);
// `Math.fma(x, y, z)` unfused.
let fma = |x: f32, y: f32, z: f32| x * y + z;
Mat4::from_cols_array(&[
fma(m11, l, fma(-m12, k, m13 * j)) * det,
fma(-m01, l, fma(m02, k, -m03 * j)) * det,
fma(m31, f, fma(-m32, e, m33 * d)) * det,
fma(-m21, f, fma(m22, e, -m23 * d)) * det,
fma(-m10, l, fma(m12, i, -m13 * h)) * det,
fma(m00, l, fma(-m02, i, m03 * h)) * det,
fma(-m30, f, fma(m32, c, -m33 * b)) * det,
fma(m20, f, fma(-m22, c, m23 * b)) * det,
fma(m10, k, fma(-m11, i, m13 * gg)) * det,
fma(-m00, k, fma(m01, i, -m03 * gg)) * det,
fma(m30, e, fma(-m31, c, m33 * a)) * det,
fma(-m20, e, fma(m21, c, -m23 * a)) * det,
fma(-m10, j, fma(m11, h, -m12 * gg)) * det,
fma(m00, j, fma(-m01, h, m02 * gg)) * det,
fma(-m30, d, fma(m31, b, -m32 * a)) * det,
fma(m20, d, fma(-m21, b, m22 * a)) * det,
])
}
/// `shadow_camera`'s view and projection in wgpu's clip space (depth 0..1), for culling.
pub fn shadow_culling(frame: &Frame, shadow: &Shadow) -> Mat4 {
let (view, gl) = shadow_camera(shadow_light(frame, shadow), frame.camera, shadow);
@@ -437,13 +570,19 @@ impl FrameUniforms {
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.
let view = frame.bob * frame.spin * crate::camera::view_matrix(frame.yaw, frame.pitch);
let projection = Mat4::perspective_rh_gl(
frame.fov_degrees.to_radians(),
aspect,
0.05,
frame.depth_far,
);
// Vanilla's view rotation to the last bit (Iris 1.11.7's gbufferModelView and its inverse
// match it bit for bit in our probes): fragments on a texel boundary of the shadow map
// then read the same texel as in Iris (#8464).
let (rotation, rotation_inverse) =
crate::camera::view_rotation_matrix(frame.yaw, frame.pitch);
let (view, view_inverse) = if frame.bob == Mat4::IDENTITY && frame.spin == Mat4::IDENTITY {
(rotation, rotation_inverse)
} else {
let view = frame.bob * frame.spin * rotation;
(view, view.inverse())
};
let (projection, projection_inverse) =
pack_projection(frame.fov_degrees, aspect, frame.depth_far);
let camera = frame.camera.as_vec3();
let first_frame = self.previous.is_none();
let (previous_exact, previous_view, previous_projection) =
@@ -464,7 +603,7 @@ impl FrameUniforms {
);
let to_view = |v: Vec3| view.transform_vector3(v);
let sun_angle = (frame.sky.sun_angle / 360.0 + 0.25).rem_euclid(1.0);
let sun_angle = day_fraction(frame.sky.sun_angle);
let day = sun_angle < 0.5;
let tilt = self.shadow.sun_path_rotation;
let sun = to_view(body(frame.sky.sun_angle, tilt));
@@ -509,7 +648,8 @@ impl FrameUniforms {
None => (frame.sky.fog_color, frame.distance * 0.9, frame.distance),
};
let light = shadow_light(frame, &self.shadow);
let (shadow_view, shadow_projection) = shadow_camera(light, frame.camera, &self.shadow);
let (_, shadow_projection) = shadow_camera(light, frame.camera, &self.shadow);
let (shadow_view, shadow_view_inverse) = shadow_model_view(frame, &self.shadow);
let day_time = inputs.day_time.rem_euclid(24000) as i32;
let world_day = inputs.day_time.div_euclid(24000);
let (seconds, frame_time, frame_counter) = match inputs.still_ticks {
@@ -555,13 +695,17 @@ impl FrameUniforms {
"moonPosition" => v3(moon),
"shadowLightPosition" => v3(if day { sun } else { moon }),
"sunAngle" => Value::f(&[sun_angle]),
"shadowAngle" => Value::f(&[if day { sun_angle } else { sun_angle - 0.5 }]),
"shadowAngle" => Value::f(&[if day {
sun_angle
} else {
day_fraction(frame.sky.moon_angle % 360.0)
}]),
"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(&[frame_counter]),
"shadowModelView" => m4(shadow_view),
"shadowModelViewInverse" => m4(shadow_view.inverse()),
"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]),
@@ -610,9 +754,9 @@ impl FrameUniforms {
// Rounded down, as Iris 1.11.7 gives it (probe pack).
"eyeBrightnessSmooth" => Value::i(&self.eye_smooth.map(|v| v.floor() as i32)),
"gbufferModelView" => m4(view),
"gbufferModelViewInverse" => m4(view.inverse()),
"gbufferModelViewInverse" => m4(view_inverse),
"gbufferProjection" => m4(projection),
"gbufferProjectionInverse" => m4(projection.inverse()),
"gbufferProjectionInverse" => m4(projection_inverse),
"gbufferPreviousModelView" => m4(previous_view),
"gbufferPreviousProjection" => m4(previous_projection),
"heldItemId" => Value::i(&[inputs.held_items[0]]),
@@ -635,6 +779,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,
@@ -643,7 +790,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),
),
]
}
}
@@ -971,6 +1123,131 @@ mod initial_history_tests {
_ => panic!("float uniform expected"),
}
}
/// The bits of a matrix uniform.
fn bits(u: &FrameUniforms, name: &str) -> [u32; 16] {
float(u, name).map(f32::to_bits)
}
/// Iris 1.11.7's camera and shadow matrices to the last bit, read back from its uniforms by
/// a probe pack of ours: on a texel boundary of the shadow map the last bit decides which
/// texel a fragment reads (#8464). Noon at 6000 ticks, looking west (yaw 90) with vanilla's
/// flying field of view (76.99999°) at 1280×720, the camera at (13.5, 69.62, -13.5).
#[test]
fn the_matrices_are_iris_to_the_last_bit() {
let hud = HudBatch::new([1280, 720]);
let mut f = frame(&hud);
f.camera = DVec3::new(13.5, 68.0 + f64::from(1.62f32), -13.5);
f.yaw = 90.0;
f.pitch = 0.0;
f.fov_degrees = f32::from_bits(0x4299ffff);
f.depth_far = 1024.0;
f.sky.sun_angle = 0.0;
f.sky.moon_angle = 180.0;
let mut u = FrameUniforms::default();
u.update(&f, &PackInputs::default(), [1280, 720]);
let (o, one, minus) = (0, 0x3f800000, 0xbf800000);
let (z, almost) = (0x80000000, 0x3f7fffff);
assert_eq!(
bits(&u, "gbufferModelView"),
[
o, o, almost, o, o, almost, o, o, 0xbf7fffff, o, o, o, o, o, o, one
]
);
assert_eq!(
bits(&u, "gbufferModelViewInverse"),
[
o, o, 0xbf7fffff, o, o, almost, o, o, almost, o, o, o, z, z, z, one
]
);
assert_eq!(
bits(&u, "gbufferProjection"),
[
0x3f350867, o, o, o, o, 0x3fa0eb07, o, o, o, o, 0xbf800333, minus, o, o,
0xbdcccf5c, o
]
);
assert_eq!(
bits(&u, "gbufferProjectionInverse"),
[
0x3fb5017f, z, z, z, z, 0x3f4ba1af, o, z, z, z, z, 0xc11ffe00, z, z, minus,
0x41200200
]
);
// The origin's height is the camera's as a float, 69.620003, in steps of 2.
assert_eq!(
bits(&u, "shadowModelView"),
[
one, o, o, o, o, o, one, o, o, minus, z, o, 0x3f000000, 0x40200000, 0x3f1eb880, one
]
);
assert_eq!(
bits(&u, "shadowModelViewInverse"),
[
one, o, o, o, o, o, minus, o, o, one, z, o, 0xbf000000, 0xbf1eb880, 0x40200000, one
]
);
assert_eq!(float(&u, "sunAngle")[0].to_bits(), 0x3e800000);
// 9000 ticks (the sun 34.598564° past noon), yaw 200, pitch -30.
f.yaw = 200.0;
f.pitch = -30.0;
f.sky.sun_angle = f32::from_bits(0x420a64ee);
f.sky.moon_angle = f.sky.sun_angle + 180.0;
u.update(&f, &PackInputs::default(), [1280, 720]);
assert_eq!(
bits(&u, "gbufferModelView"),
[
0x3f708fb1, 0xbe2f1d41, 0xbe97a745, o, o, 0x3f5db3d6, 0xbefffffe, o, 0x3eaf1d41,
0x3ef08fb0, 0x3f50550b, o, o, o, o, one
]
);
assert_eq!(
bits(&u, "shadowModelView"),
[
0x3f52b9ff, o, 0xbf115cdb, o, 0x3f115cdb, o, 0x3f52b9ff, o, o, minus, z, o,
0x3f437d12, 0x40200000, 0x3e67e0f2, one
]
);
assert_eq!(float(&u, "sunAngle")[0].to_bits(), 0x3eb134f4);
}
/// At night the moon casts the shadows, here with `sunPathRotation = 30`: Iris 1.11.7 at
/// 16000 ticks (the moon at 324.4897°), the camera at (-556.5, 76.62, 212.5), whose
/// negative x keeps its sign in the remainder.
#[test]
fn the_moon_s_shadow_matrices_are_iris_to_the_last_bit() {
let hud = HudBatch::new([1280, 720]);
let mut f = frame(&hud);
f.camera = DVec3::new(-556.5, 75.0 + f64::from(1.62f32), 212.5);
f.sky.sun_angle = f32::from_bits(0x43107d5b);
f.sky.moon_angle = f32::from_bits(0x43a23eae);
let mut u = FrameUniforms {
shadow: Shadow {
sun_path_rotation: 30.0,
..Shadow::default()
},
..FrameUniforms::default()
};
u.update(&f, &PackInputs::default(), [1280, 720]);
let (o, one) = (0, 0x3f800000);
assert_eq!(
bits(&u, "shadowModelView"),
[
0x3f506307, o, 0x3f14b28c, o, 0xbf00c699, 0xbf000000, 0x3f3477e1, o, 0x3e94b28c,
0xbf5db3d7, 0xbed06307, o, 0xbf9668fb, 0x3f1f7dac, 0xbf6f86e4, one
]
);
assert_eq!(
bits(&u, "shadowModelViewInverse"),
[
0x3f506307, 0xbf00c699, 0x3e94b28c, o, o, 0xbf000000, 0xbf5db3d7, o, 0x3f14b28c,
0x3f3477e1, 0xbed06307, o, 0x3fc00000, 0x3ec28f00, 0x3effffff, one
]
);
assert_eq!(float(&u, "sunAngle")[0].to_bits(), 0x3f26bf8b);
assert_eq!(float(&u, "shadowAngle")[0].to_bits(), 0x3e1afe2e);
}
#[test]
fn first_history_is_current_then_tracks_the_actual_previous_frame() {
let hud = HudBatch::new([800, 450]);
@@ -1358,6 +1635,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]
@@ -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;
}
+24 -1
View File
@@ -549,6 +549,9 @@ pub enum GpuCullingOffer {
/// 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 {
@@ -559,6 +562,7 @@ impl GpuCullingOffer {
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,
}
}
@@ -574,9 +578,20 @@ pub fn gpu_culling_offer() -> GpuCullingOffer {
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
@@ -661,6 +676,8 @@ pub struct Graphics {
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
@@ -769,6 +786,7 @@ impl Default for Graphics {
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,
@@ -866,6 +884,9 @@ impl Graphics {
"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" => {
@@ -959,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:{}\ngpuCulling:{}\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),
@@ -1153,6 +1175,7 @@ mod tests {
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,
+124 -6
View File
@@ -67,6 +67,7 @@ pub enum H {
CaveCulling,
GpuCulling,
EntityCulling,
EntityCullingPrecheck,
ParticleCulling,
LeafCulling,
CacheShulkerBoxes,
@@ -390,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,
@@ -698,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()
@@ -729,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),
@@ -761,6 +769,14 @@ impl H {
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)),
@@ -873,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]
@@ -1044,6 +1061,11 @@ 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,
}
@@ -1114,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 {
@@ -1924,7 +1946,18 @@ impl GraphicsUi {
return;
};
let x = cols.bar_x();
let track = [x, cols.top, style::SCROLLER_W, cols.bottom - cols.top];
let height = cols.bottom - cols.top;
crate::widgets::scroll_area(
[
cols.list_x,
cols.top,
x + style::SCROLLER_W - cols.list_x,
height,
],
self.scroll.amount,
content - height,
);
let track = [x, cols.top, style::SCROLLER_W, height];
style::scroller(batch, atlas, track, [x, y, style::SCROLLER_W, bar]);
}
/// The strip under the plate: the hovered or focused setting's name and
@@ -2222,7 +2255,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 {
@@ -2291,6 +2325,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;
@@ -3182,6 +3217,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]
@@ -3260,6 +3371,7 @@ mod tests {
GpuCullingOffer::Software,
GpuCullingOffer::OutOfMemory,
GpuCullingOffer::Dx12,
GpuCullingOffer::DeviceLost,
] {
crate::graphics::offer_gpu_culling(offer);
assert!(offer.reason().is_some());
@@ -3484,7 +3596,13 @@ mod tests {
}
// 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] {
for offer in [
O::Unsupported,
O::Software,
O::OutOfMemory,
O::Dx12,
O::DeviceLost,
] {
lang::get(offer.reason().unwrap());
}
});
+1
View File
@@ -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;
@@ -498,7 +498,8 @@ impl MultiLineEdit {
self.x + self.width - 1.0,
self.y + self.height - 1.0,
);
ui::scrollbar(
// Its text scrolls with its caret: not a list for the focus to step through.
ui::scrollbar_sprites(
batch,
atlas,
self.x + self.width,
@@ -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,
]
);
@@ -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);
}
}
+22 -1
View File
@@ -917,7 +917,8 @@ impl Scroll {
}
}
/// Vertical scroll bar (AbstractScrollArea): background and scroller sprites.
/// Vertical scroll bar of a list (AbstractScrollArea): the sprites, and the
/// list is registered for the controller's focus (`widgets::scroll_area`).
pub fn scrollbar(
batch: &mut HudBatch,
atlas: &HudAtlas,
@@ -926,6 +927,26 @@ pub fn scrollbar(
bottom: f32,
content: f32,
scroll: f32,
) {
if content > bottom - top {
crate::widgets::scroll_area(
[0.0, top, x + 6.0, bottom - top],
scroll,
content - (bottom - top),
);
}
scrollbar_sprites(batch, atlas, x, top, bottom, content, scroll);
}
/// The scroll bar's background and scroller sprites.
pub fn scrollbar_sprites(
batch: &mut HudBatch,
atlas: &HudAtlas,
x: f32,
top: f32,
bottom: f32,
content: f32,
scroll: f32,
) {
let Some((y, bar)) = scrollbar_thumb(top, bottom, content, scroll) else {
return;
@@ -3,6 +3,7 @@
//! click-button Respawn`) or any widget by its id in [`tree`] (`horizon-ctl
//! click done`), whatever screen drew it. Also the mouse cursor the widget
//! under the mouse asks for.
use crate::gamepad::ScrollArea;
use std::cell::{Cell, RefCell};
/// The mouse cursor a frame's widgets asked for (`CursorTypes`; the others
@@ -102,6 +103,8 @@ thread_local! {
static ACTION_GLYPHS: Cell<bool> = const { Cell::new(false) };
/// The screen the buttons in `ACTIONS` were declared on, the same two frames.
static ACTION_SCREENS: Cell<(u64, u64)> = const { Cell::new((0, 0)) };
/// The lists drawn with a scrollbar since `end_frame`, and the last frame's.
static SCROLL_AREAS: RefCell<(Vec<ScrollArea>, Vec<ScrollArea>)> = const { RefCell::new((Vec::new(), Vec::new())) };
}
/// Splitscreen: one player's buttons and focus targets, kept aside while another player's game
@@ -113,6 +116,7 @@ pub struct Saved(
(Vec<DeclaredAction>, Vec<DeclaredAction>),
bool,
(u64, u64),
(Vec<ScrollArea>, Vec<ScrollArea>),
);
/// This thread's buttons and targets, leaving none.
@@ -123,6 +127,7 @@ pub fn take() -> Saved {
ACTIONS.take(),
ACTION_GLYPHS.take(),
ACTION_SCREENS.take(),
SCROLL_AREAS.take(),
)
}
@@ -130,6 +135,7 @@ pub fn take() -> Saved {
pub fn put(saved: Saved) {
WIDGETS.set(saved.0);
TARGETS.set(saved.1);
SCROLL_AREAS.set(saved.5);
ACTIONS.set(saved.2);
ACTION_GLYPHS.set(saved.3);
ACTION_SCREENS.set(saved.4);
@@ -238,6 +244,37 @@ pub fn clip_targets(mark: usize, y0: f32, y1: f32) {
});
}
/// This frame drew a list with something to scroll, in `rect`, scrolled `amount`
/// of `max`: a focus step goes through its hidden rows before it leaves
/// (`gamepad::list_step`).
pub fn scroll_area(rect: [f32; 4], amount: f32, max: f32) {
let area = ScrollArea {
rect,
amount,
up: amount > 0.5,
down: amount < max - 0.5,
};
SCROLL_AREAS.with_borrow_mut(|(current, _)| current.push(area));
}
/// The lists the last drawn frame could scroll.
pub fn last_scroll_areas() -> Vec<ScrollArea> {
SCROLL_AREAS.with_borrow(|(_, last)| last.clone())
}
/// A point the open screen's list scrolls under: `at` when it is over one, else
/// the same column at the list's middle. For the right stick, which scrolls the
/// list wherever the hidden pointer is.
pub fn over_scroll_area(at: (f32, f32)) -> (f32, f32) {
SCROLL_AREAS.with_borrow(|(_, last)| {
let over = |a: &ScrollArea| (a.rect[1]..a.rect[1] + a.rect[3]).contains(&at.1);
match last.first() {
Some(area) if !last.iter().any(over) => (at.0, area.rect[1] + area.rect[3] / 2.0),
_ => at,
}
})
}
/// The focus targets of the last drawn frame.
pub fn last_targets() -> Vec<Target> {
TARGETS.with_borrow(|(_, last, _)| last.clone())
@@ -312,6 +349,9 @@ pub fn end_frame(frames: u64) {
*last = std::mem::take(current);
});
ACTION_SCREENS.set((ACTION_SCREENS.get().0, ACTION_SCREENS.get().0));
SCROLL_AREAS.with_borrow_mut(|(current, last)| {
*last = std::mem::take(current);
});
NARRATION.with_borrow_mut(|(current, last)| {
*last = std::mem::replace(current, Narration::EMPTY);
});
@@ -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)",
@@ -161,6 +176,7 @@
"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",
@@ -480,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);
+4 -1
View File
@@ -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;
+53 -28
View File
@@ -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();
@@ -1717,14 +1731,6 @@ impl Recipes {
crafting.push((name, recipe));
}
}
// ResourceManagerRegistryLoadTask registers resource-file Identifiers by
// path, then namespace; RecipeMap preserves that first-match order.
// Include .json: the resource file, rather than the bare recipe id, is sorted.
// Other recipe types retain their existing table order.
crafting.sort_by_cached_key(|(name, _)| {
let (namespace, path) = name.split_once(':').unwrap_or(("minecraft", name.as_str()));
(format!("{path}.json"), namespace.to_owned())
});
let facts = items
.items
.iter()
@@ -2207,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]
@@ -2321,19 +2327,27 @@ mod tests {
}
/// ResourceManagerRegistryLoadTask registers by resource-file Identifier,
/// and RecipeMap keeps that order when taking the first matching recipe.
/// and RecipeMap keeps that order when taking the first matching recipe,
/// for the crafting grid and the furnaces alike.
#[test]
fn crafting_selection_follows_resource_file_order() {
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 recipe = |item: &str| {
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"),
(
@@ -2351,22 +2365,33 @@ mod tests {
),
(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 reverse in [false, true] {
let mut sources: Vec<_> = ids
.iter()
.map(|id| (id.to_string(), recipe("minecraft:stick")))
.collect();
if reverse {
sources.reverse();
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 recipes =
Recipes::from_sources(&root, &items, &PackOverlay::NONE, sources).unwrap();
let (id, result) = recipes
.find(&[Some(ItemStack::new("minecraft:flint", 1))], 1)
.unwrap();
assert_eq!(id, expected, "input order: {ids:?}, reversed={reverse}");
assert_eq!(&*result.item, "minecraft:stick");
}
}
let _ = fs::remove_dir_all(&root);
+35
View File
@@ -1278,6 +1278,41 @@ mod tests {
assert_eq!(c.search.value, "");
}
#[test]
fn stepping_through_tabs_turns_their_pages() {
let stack = |id: &str| format!("{{count:1,id:\"minecraft:{id}\"}}");
let tab = |id: &str, column: u32, page: u32| {
serde_json::json!({"id": format!("minecraft:{id}"), "type": "CATEGORY", "row": "TOP",
"column": column, "page": page, "title": id, "icon": stack("stone"),
"visible": true, "items": [stack("stone")]})
};
let raw = serde_json::json!({"schema": 3, "tabs": [
tab("first", 0, 0), tab("second", 1, 0), tab("modded", 0, 1), tab("more", 1, 2),
tab("everywhere", 6, 0)
]});
let mut c = Creative::from_json(&raw).unwrap();
assert_eq!(c.pages(), 3);
let count = c.tabs.len();
let mut pages = Vec::new();
for step in 1..=count {
let tab = (c.selected + 1) % count;
c.select(tab);
assert_eq!(c.selected, tab, "step {step}");
assert!(
c.on_page(&c.tabs[tab]),
"tab {tab} selected on another page"
);
pages.push(c.page);
}
// A tab every page shows keeps the page it was reached from; then it wraps.
assert_eq!(pages, [0, 1, 2, 2, 0]);
// Backwards off the first tab, across pages, the same way.
c.select((c.selected + count - 1) % count);
assert_eq!(c.page, 0);
c.select((c.selected + count - 1) % count);
assert_eq!(c.page, 2);
}
/// An export built with permissions: the operator tab's stacks, and their
/// place at the end of the search tab, only with them.
fn with_operator_tab() -> Creative {
+315 -45
View File
@@ -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;
}
}
@@ -2819,6 +2830,7 @@ mod world_border_tests {
&mut || 0.0,
border,
&fluids,
&mut |_, _| {},
);
for (what, east) in [
("item", item.bounds().max_position().x),
@@ -4162,8 +4174,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);
@@ -4177,10 +4196,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,
@@ -4193,44 +4213,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);
-294
View File
@@ -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!()
));
}
}
+3 -1
View File
@@ -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();
+3
View File
@@ -155,6 +155,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
living_jump: |entity, kind| {
crate::mod_api::dispatch_living_jump(crate::mod_api::mods(), entity, kind);
},
register_goals: |mob| {
crate::mod_api::dispatch_register_goals(crate::mod_api::mods(), mob);
},
ingredient: |kind, data, items| {
crate::mod_api::dispatch_ingredient(crate::mod_api::mods(), kind, data, items);
},
+114 -53
View File
@@ -255,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;
@@ -333,6 +334,7 @@ mod storage;
mod structure_blocks;
mod structure_edit;
mod structures;
mod studio;
mod subtitle_hud;
mod sulfur_cubes;
mod tempban;
@@ -388,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},
@@ -2606,6 +2609,9 @@ struct Game<'a> {
fps_frames: u64,
fps_at: Instant,
ticks: u64,
/// The sprite animation clock (`TextureManager.tick`): counts only the ticks the level
/// runs normally, so `/tick freeze` holds it and `/tick step` moves it on.
texture_ticks: u64,
health_hud: hud_layout::HealthHud,
/// What ends the game with an error; kept as it is, so main can tell a debug crash.
error: Option<Box<dyn std::error::Error>>,
@@ -3021,9 +3027,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.
@@ -7717,6 +7727,15 @@ impl Game<'_> {
self.ticks
}
}
/// The clock of the animated textures: `Minecraft.runTick` ticks the `TextureManager`
/// only while the level runs normally. Under `HORIZON_STILL` it is the world's ticks.
pub(super) fn texture_clock(&self) -> u64 {
if self.clock.still() {
self.visual_ticks()
} else {
self.texture_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 {
@@ -7825,6 +7844,7 @@ impl Game<'_> {
self.share_spawn_conditions();
horizon_client::mobs::sulfur_cube::set_tnt_explodes(self.ui.rules.bool("tnt_explodes"));
self.update_chunk_tickets();
self.score_destroyed_entities();
horizon_client::spans::time("tick_effects", || self.tick_effects(running));
self.tick_sign_editor();
// Player.tick's ItemCooldowns.tick for the window's player (a
@@ -7971,6 +7991,7 @@ impl Game<'_> {
horizon_client::spans::time("tick_particles", || self.tick_particles());
self.tick_client_ambient();
}
self.tick_frame_visuals(running);
if running {
// MinecraftServer.tickServer: GameTestTicker.SINGLETON.tick().
self.tick_game_tests();
@@ -9432,8 +9453,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))
}
@@ -11101,7 +11122,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");
@@ -11316,32 +11338,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;
@@ -11368,6 +11364,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,
@@ -11411,20 +11463,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(
@@ -11695,7 +11733,7 @@ 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);
(
@@ -14591,6 +14629,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);
@@ -15991,8 +16037,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(|_| {
(
@@ -16495,7 +16544,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.visual_ticks(),
ticks: self.texture_clock(),
darkness: self.darkness_lighting(alpha as f32),
night_vision: self.night_vision_intensity(alpha as f32),
block_flicker: self.fog.block_light_flicker,
@@ -18086,6 +18135,8 @@ impl Game<'_> {
return Ok(());
}
}
let stick_scroll = self.pad_stick_scroll();
let step_scroll = self.pad_step_scroll();
if self.dialog_open() {
self.dialog_scroll(d);
} else if self.code_of_conduct.is_some() {
@@ -18099,7 +18150,14 @@ impl Game<'_> {
.map(|w| w.inner_size())
.unwrap_or_default();
let batch = HudBatch::new([size.width, size.height]);
title.scroll_worlds(d, self.cursor, (batch.width, batch.height));
let gui = (batch.width, batch.height);
if step_scroll {
title.step_scroll(d, self.cursor, gui);
} else if stick_scroll {
title.stick_scroll(d, self.cursor, gui);
} else {
title.scroll_worlds(d, self.cursor, gui);
}
}
// MouseHandler.onScroll: the hotbar (or the spectator
// menu) takes the wheel only when no screen is open; a
@@ -19329,6 +19387,7 @@ impl<'a> Game<'a> {
fps_frames: 0,
fps_at: now,
ticks: 0,
texture_ticks: 0,
error: None,
message: None,
save,
@@ -19567,9 +19626,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(),
@@ -480,6 +480,10 @@ impl Game<'_> {
let Some((ty, i)) = tab_type(n) else { continue };
let (x, y) = tab_pos(ty, i);
let sprite = tab_sprite(ty, i, ty.3, Some(t) == tab);
horizon_client::widgets::target_as(
[left + x, top + y, ty.1, ty.2],
horizon_client::widgets::Kind::Tab(Some(t) == tab),
);
batch.sprite_scaled(
&self.gui.atlas,
&sprite,
@@ -1041,7 +1041,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.
@@ -1071,6 +1070,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;
@@ -1104,7 +1108,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
+6 -2
View File
@@ -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);
}
}
}
+2 -3
View File
@@ -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);
+1
View File
@@ -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(),
@@ -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
@@ -148,6 +149,10 @@ impl Game<'_> {
fn harness_settle(&mut self, deadline: Instant, required: Option<&Value>) -> Result<()> {
loop {
self.update_world();
// The oracle's settle takes its tickets at once (getChunk runs the
// DistanceManager updates); a tick would refresh these too, but a
// settle runs none, and `/execute if loaded` reads them.
self.update_chunk_tickets();
if let Some(p) = &self.player {
let (cx, cz) = (
(p.position.x.floor() as i32) >> 4,
@@ -433,7 +438,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;
@@ -479,6 +484,59 @@ 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
@@ -1131,14 +1131,12 @@ impl Game<'_> {
}
if found.is_empty() {
return Err(match &sources[..] {
[Owner::Block(p)]
if !self.world.block_entities.contains_key(&(p[0], p[1], p[2])) =>
{
err(
"commands.item.source.not_a_container",
&[&p[0], &p[1], &p[2]],
)
}
// ItemCommands.getContainer: a block entity that is no Container
// (a sign, a lectern, a beacon) is no source either.
[Owner::Block(p)] if self.block_container_size(*p).is_none() => err(
"commands.item.source.not_a_container",
&[&p[0], &p[1], &p[2]],
),
_ if source_slots.starts_with('{') || source_slots.starts_with('[') => {
err("commands.item.source.no_such_slot.unnamed", &[])
}
@@ -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",
@@ -380,10 +380,16 @@ impl Game<'_> {
/// `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 player_uuid = (damage.attacker == Some(Who::Player))
.then(|| self.owner_damage_uuid())
.flatten();
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);
let hurt = match player_uuid {
Some(uuid) => m.damage_with_player_uuid(amount, damage, uuid, None),
None => m.damage(amount, damage),
};
if hurt {
reinforcements = take_new_reinforcements(&mut m.outbox, queued);
}
@@ -1919,6 +1925,95 @@ pub(super) fn look_at(from: [f64; 3], to: [f64; 3]) -> [f32; 2] {
[wrap_degrees(yaw), pitch]
}
#[cfg(test)]
mod allay_owner_damage_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::mobs::{Damage, Mob};
use horizon_core::geometry::Vec3;
#[test]
fn allay_owner_adapters_compare_the_actual_player_uuid() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("entity-types.json").exists() {
eprintln!("Prepared resources unavailable; Allay Game damage coverage did not run");
return;
}
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
let actual = [101, 102, 103, 104];
let other = [10, 20, 30, 40];
game.level.player_uuid = Some(actual);
game.lan_acting_uuid = Some(horizon_client::lan::playerdata::ints_uuid(actual));
assert_eq!(game.owner_damage_uuid(), Some(actual));
game.lan_acting_uuid = Some(horizon_client::lan::playerdata::ints_uuid(other));
assert_eq!(
game.owner_damage_uuid(),
None,
"a guest is not the owner route"
);
game.lan_acting_uuid = None;
game.level.player_uuid = None;
assert_eq!(
game.owner_damage_uuid(),
None,
"no invented UUID for an unknown source"
);
game.level.player_uuid = Some(actual);
game.lan_acting_uuid = Some(horizon_client::lan::playerdata::ints_uuid(actual));
let kind = game.res.mob_types().get("minecraft:allay").unwrap().clone();
let before = game.world.game_time;
for melee in [true, false] {
for liked in [Some(actual), None, Some(other)] {
game.mobs.clear();
let mut mob = Mob::new(1, kind.clone(), Vec3::new(22.5, 101.0, 22.5), 0.0);
mob.no_ai = true;
mob.extra.tame = liked.is_some();
mob.extra.owner = liked;
assert_eq!((mob.health, mob.hurt_time), (20.0, 0));
game.mobs.push(mob);
let source = Damage::player([20.5, 102.0, 20.5]);
let accepted = if melee {
game.hurt_by_player(0, 2.0, source)
} else {
game.command_damage_mob(1, 2.0, source)
};
let expected = liked != Some(actual);
assert_eq!(accepted, expected, "melee={melee}, liked={liked:?}");
assert_eq!(game.mobs[0].health, if expected { 18.0 } else { 20.0 });
assert_eq!(game.mobs[0].extra.owner, liked);
assert_eq!(game.world.game_time, before);
}
}
}
}
#[cfg(test)]
mod tests {
use super::{look_at, playsound_placement, take_new_reinforcements};
+24 -4
View File
@@ -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" => {
@@ -1024,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))
}
@@ -1033,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(),
@@ -1047,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)?),
@@ -1333,6 +1352,7 @@ impl Game<'_> {
"gpu_frame": r.gpu_frame(),
"gpu_fading": r.gpu_fading(),
"gpu_culling_offer": format!("{:?}", r.gpu_culling_offer()),
"gpu_occluding": r.gpu_occluding(),
"stats_lag": r.stats_lag(),
"uploaded_bytes": r.uploaded_bytes,
"resident_sections": r.resident_sections(),
+94 -60
View File
@@ -5,8 +5,8 @@
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::{
@@ -15,6 +15,7 @@ use horizon_client::{
blocks::Fluid,
collisions::SceneCollisions,
crafting::Recipes,
graphics::EntityCullingMode,
health::{HurtSource, Hurting, Knock, knockback_canceled},
items::ItemStack,
mesher::Vertex,
@@ -2395,34 +2396,6 @@ impl Game<'_> {
}
}
}
MobEvent::PufferfishSting {
mob,
target,
amount,
source,
poison_ticks,
} => {
// Pufferfish.touch: the poison and
// `playSound(PUFFER_FISH_STING, 1, 1)` sit inside the
// `hurtServer` branch, so only a hit that landed poisons
// and stings, at the fish's own position as
// `Entity.playSound` puts it, and not when it is silent.
let landed = self.mob_hits_mob(target, amount, source);
if landed && let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
t.add_effect("minecraft:poison", poison_ticks, 0);
}
if landed && let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
let at = [m.position.x, m.position.y, m.position.z];
if !m.silent {
self.handle_mob_events(vec![MobEvent::Sound(
"entity.puffer_fish.sting",
at,
1.0,
1.0,
)])?;
}
}
}
MobEvent::SonicBoom {
target,
attacker,
@@ -3000,7 +2973,9 @@ impl Game<'_> {
}
}
// Applied after the fish's tick (`apply_brain_events`).
MobEvent::StartFollowing { .. } | MobEvent::SchoolSize { .. } => {}
MobEvent::StartFollowing { .. }
| MobEvent::SchoolSize { .. }
| MobEvent::PufferfishSting { .. } => {}
MobEvent::LeashBreak { mob, drop, sound } => self.break_leash(mob, drop, sound),
MobEvent::NotifyLeashHolder { holder } => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == holder) {
@@ -3479,7 +3454,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,
@@ -3507,7 +3482,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,
@@ -3558,7 +3533,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,
@@ -6901,6 +6876,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(),
@@ -6940,6 +6934,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();
@@ -6965,43 +6983,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) = (
@@ -7012,6 +7014,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(
@@ -7210,14 +7246,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")
{
+10 -6
View File
@@ -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;
}
+116 -12
View File
@@ -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()
);
}
}
}
+8 -2
View File
@@ -903,6 +903,9 @@ impl Game<'_> {
return;
};
let cursor = self.cursor;
let stick_scroll = self.pad_stick_scroll();
// A focus step past the body's edge scrolls the body, not a box it's leaving.
let step_scroll = self.pad_step_scroll();
if let Some(open) = self.dialogs.open.as_mut() {
let [_, ay, _, ah] = layout.area;
if delta != 0.0 && cursor.1 >= ay && cursor.1 < ay + ah {
@@ -916,7 +919,7 @@ impl Game<'_> {
} if ui::hovered(cursor, x, y, w, h) => Some(input),
_ => None,
});
if let Some(input) = hovered_cycle {
if let Some(input) = hovered_cycle.filter(|_| !stick_scroll) {
// CycleButton.mouseScrolled: up goes back.
cycle(open, input, if delta > 0.0 { -1 } else { 1 });
return;
@@ -941,7 +944,10 @@ impl Game<'_> {
},
_ => None,
});
if let Some(Some(Field::Multi(edit))) = text.and_then(|i| open.fields.get_mut(i)) {
if let Some(Some(Field::Multi(edit))) = text
.filter(|_| !step_scroll)
.and_then(|i| open.fields.get_mut(i))
{
edit.wheel(delta);
return;
}
+269 -1
View File
@@ -13,7 +13,9 @@ use super::{FallingBlock, Game};
use super::{
boats::Boat, fireworks::Rocket, minecarts::Minecart, tnt::PrimedTnt, trident::ThrownTrident,
};
use horizon_client::entities::{ArmorStand, ExperienceOrb, ItemEntity};
use horizon_client::entities::{
ArmorStand, Cushion, ExperienceOrb, ItemEntity, ItemFrame, Painting,
};
use horizon_client::entity_rules::EntityRules;
use horizon_client::join_seq::JoinSeq;
use horizon_client::join_seq::Joined as _;
@@ -278,6 +280,272 @@ pub(super) trait Entity: EntityRules {
}
}
/// Bounds and identity needed by the render occlusion culler. Moving renderers
/// use interpolated entity bounds; attached entities use their own world bounds.
pub(super) trait RenderEntityBounds {
fn render_id(&self) -> u32;
fn render_bounds(&self, alpha: f64) -> Aabb;
fn render_glowing(&self, game: &Game<'_>) -> bool {
game.entity_glowing(self.render_id())
}
}
impl RenderEntityBounds for Mob {
fn render_id(&self) -> u32 {
self.id
}
fn render_bounds(&self, alpha: f64) -> Aabb {
// Keep models with large independent appendages on the old envelope:
// their tentacles and wings can extend several blocks past the entity
// dimensions. Equipment and custom rendering bypass this pre-check at
// the call site.
let (width, height, _) = self.awake_dims();
let at = self.previous.lerp(self.position, f64::from(alpha as f32));
if matches!(
self.species(),
Species::Guardian | Species::ElderGuardian | Species::Phantom
) {
let radius = width.max(height) * 2.5 + 3.0;
return Aabb::of_size(at, Vec3::new(radius * 2.0, radius * 2.0, radius * 2.0));
}
// Vanilla's ordinary models can rotate their head, limbs and death
// pose beyond the collision box. This envelope covers those poses while
// remaining close to the rendered mesh for the common upright mobs.
// A few quadruped death and riding poses reach several blocks past
// their standing width. Keep a bounded fallback radius for ordinary
// models while still shrinking the common pig/zombie envelope below
// the old five-block radius.
let horizontal = (width * 2.0).max(height * 1.5).max(3.5);
let vertical = height * 3.0 + 0.5;
Aabb::new(
at.subtract(Vec3::new(horizontal, vertical, horizontal)),
at.add_vector(Vec3::new(horizontal, vertical, horizontal)),
)
}
fn render_glowing(&self, _game: &Game<'_>) -> bool {
self.is_currently_glowing()
}
}
impl RenderEntityBounds for ArmorStand {
fn render_id(&self) -> u32 {
self.id
}
fn render_bounds(&self, alpha: f64) -> Aabb {
let at = self.previous.lerp(self.position, alpha);
// ArmorStandRenderer poses the limbs, then attaches hand items, head
// items, armour and folded elytra. Those can extend well beyond the
// 0.5 x 1.975 collision box (especially raised arms and head banners).
// This axis-aligned envelope contains every vanilla pose while staying
// independent of the stand's yaw and partial-tick rotation.
let scale = if self.small { 0.5 } else { 1.0 };
let equipped = self.equipment.iter().any(Option::is_some);
let horizontal = if equipped { 2.0 } else { 1.5 * scale };
let lower = if equipped { -1.0 } else { -0.5 * scale };
let upper = 3.5 * scale
+ if self.equipment[5].is_some() {
1.5
} else {
0.0
};
Aabb::new(
at.subtract(Vec3::new(horizontal, -lower, horizontal)),
at.add_vector(Vec3::new(horizontal, upper, horizontal)),
)
}
}
impl RenderEntityBounds for ItemEntity {
fn render_id(&self) -> u32 {
self.id
}
fn render_bounds(&self, alpha: f64) -> Aabb {
let at = self.previous.lerp(self.position, alpha);
// DroppedItemRenderer bobs, spins, and fans out up to five item copies.
// The draw path uses the resolved model's exact posed extents; this
// fallback envelope also covers the largest vanilla ground item.
Aabb::new(
at.subtract(Vec3::new(0.75, 0.1, 0.75)),
at.add_vector(Vec3::new(0.75, 1.0, 0.75)),
)
}
}
impl RenderEntityBounds for Boat {
fn render_id(&self) -> u32 {
self.id
}
fn render_bounds(&self, alpha: f64) -> Aabb {
let at = self.previous.lerp(self.position, alpha);
// The boat model turns with yaw and its animated paddles sweep outside
// the sized collision box. Hurt and bubble-column rotations are covered
// by this orientation-independent model envelope.
Aabb::new(
at.subtract(Vec3::new(1.75, 0.75, 1.75)),
at.add_vector(Vec3::new(1.75, 1.5, 1.75)),
)
}
}
impl RenderEntityBounds for Minecart {
fn render_id(&self) -> u32 {
self.id
}
fn render_bounds(&self, alpha: f64) -> Aabb {
let at = self.previous.lerp(self.position, alpha);
let offset = self.custom_offset.map_or_else(
|| {
if self.kind == super::minecarts::CartKind::Hopper {
1.0
} else {
6.0
}
},
f64::from,
);
let display_shift = 0.75 * (offset - 8.0) / 16.0;
let display_low = at.y + 0.375 - 0.4 + display_shift;
let display_high = at.y + 0.375 + 0.4 + display_shift + 0.9;
Aabb::new(
Vec3::new(at.x - 1.25, (at.y - 0.5).min(display_low), at.z - 1.25),
Vec3::new(at.x + 1.25, (at.y + 1.0).max(display_high), at.z + 1.25),
)
}
}
fn aabb_from_extents(bounds: ([f64; 3], [f64; 3])) -> Aabb {
Aabb::new(
Vec3::new(bounds.0[0], bounds.0[1], bounds.0[2]),
Vec3::new(bounds.1[0], bounds.1[1], bounds.1[2]),
)
}
impl RenderEntityBounds for Painting {
fn render_id(&self) -> u32 {
self.id
}
fn render_bounds(&self, _alpha: f64) -> Aabb {
aabb_from_extents(self.bounds())
}
}
impl RenderEntityBounds for ItemFrame {
fn render_id(&self) -> u32 {
self.id
}
fn render_bounds(&self, _alpha: f64) -> Aabb {
let (lo, hi) = self.bounds();
let step = self.step().map(f64::from);
let center: [f64; 3] = std::array::from_fn(|axis| (lo[axis] + hi[axis]) * 0.5);
// Maps fill a full block, while the held item projects toward the
// camera from the frame plane. Include both in the occlusion envelope.
let half: [f64; 3] = std::array::from_fn(|axis| if step[axis] != 0.0 { 0.75 } else { 0.5 });
aabb_from_extents((
std::array::from_fn(|axis| center[axis] - half[axis]),
std::array::from_fn(|axis| center[axis] + half[axis]),
))
}
}
impl RenderEntityBounds for Cushion {
fn render_id(&self) -> u32 {
self.id
}
fn render_bounds(&self, _alpha: f64) -> Aabb {
aabb_from_extents(self.bounds())
}
}
#[cfg(test)]
mod render_bounds_tests {
use super::*;
#[test]
fn armor_stand_bounds_include_raised_equipment_space() {
let mut stand = ArmorStand::new(1, Vec3::new(4.0, 64.0, -2.0), 90.0);
stand.show_arms = true;
stand.pose[2] = [180.0, 0.0, 45.0];
stand.equipment[0] = Some(horizon_client::items::ItemStack {
item: "minecraft:iron_sword".into(),
count: 1,
damage: 0,
extra: None,
});
stand.equipment[5] = Some(horizon_client::items::ItemStack {
item: "minecraft:banner".into(),
count: 1,
damage: 0,
extra: None,
});
let bounds = stand.render_bounds(0.5);
assert!(bounds.min_position().x <= 2.0);
assert!(bounds.max_position().x >= 6.0);
assert!(bounds.min_position().y <= 63.0);
assert!(bounds.max_position().y >= 69.0);
assert!(bounds.min_position().z <= -4.0);
assert!(bounds.max_position().z >= 0.0);
}
#[test]
fn boat_bounds_cover_the_rotated_paddle_sweep() {
let boat = Boat::new(2, "oak".into(), Vec3::new(0.0, 10.0, 0.0), 90.0);
let bounds = boat.render_bounds(0.5);
assert!(bounds.min_position().x <= -1.75);
assert!(bounds.max_position().x >= 1.75);
assert!(bounds.min_position().y <= 9.25);
assert!(bounds.max_position().y >= 11.5);
assert!(bounds.min_position().z <= -1.75);
assert!(bounds.max_position().z >= 1.75);
}
#[test]
fn item_frame_bounds_include_the_full_map_and_held_item_depth() {
let frame = ItemFrame {
id: 3,
pos: [0, 0, 0],
facing: 5,
glow: false,
item: None,
rotation: 0,
drop_chance: 1.0,
fixed: false,
invisible: false,
check_interval: 0,
custom_name: None,
};
let bounds = frame.render_bounds(1.0);
assert!(bounds.min_position().x <= -0.71875);
assert!(bounds.max_position().x >= 0.78125);
assert!(bounds.min_position().y <= 0.0);
assert!(bounds.max_position().y >= 1.0);
assert!(bounds.min_position().z <= 0.0);
assert!(bounds.max_position().z >= 1.0);
}
#[test]
fn minecart_bounds_follow_custom_display_offset() {
let mut cart = Minecart::new(
4,
super::super::minecarts::CartKind::Chest,
Vec3::new(0.0, 10.0, 0.0),
);
cart.custom_offset = Some(32);
let bounds = cart.render_bounds(1.0);
assert!(bounds.min_position().y <= 9.5);
assert!(bounds.max_position().y >= 12.8);
}
}
/// The fields every kind stores under the same names.
macro_rules! stored_motion {
() => {
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,98 @@
use super::*;
use horizon_client::voxel_occlusion::Culler;
#[allow(dead_code)]
fn vertex(pos: [f32; 3]) -> Vertex {
Vertex {
pos,
uv: [0.0; 2],
layer: 0,
color: u32::MAX,
light: 0,
normal: [0; 4],
glint_uv: [0.0; 2],
}
}
/// `Entity.setViewScale`: the entity distance option times the render
/// distance's boost — 1 at eight chunks, 1.5 at twelve, 2.5 at twenty.
#[test]
fn view_scale_follows_the_render_distance() {
assert_eq!(entity_view_scale(4, 1.0), 1.0);
assert_eq!(entity_view_scale(8, 1.0), 1.0);
assert_eq!(entity_view_scale(12, 1.0), 1.5);
assert_eq!(entity_view_scale(20, 1.0), 2.5);
assert_eq!(entity_view_scale(32, 1.0), 2.5);
assert_eq!(entity_view_scale(12, 0.5), 0.75);
}
/// `Entity.shouldRenderAtSqrDistance`: a chicken's box (0.4 by 0.7 by
/// 0.4) is drawn up to 48 blocks away at the vanilla defaults.
#[test]
fn small_mobs_use_the_mean_edge_for_render_distance() {
let range = default_range(glam::DVec3::new(0.4, 0.7, 0.4), entity_view_scale(12, 1.0));
assert_eq!(range, 48.0);
}
/// Dropped items keep their 0.25-block box, so their default range scales
/// with the render distance but never exceeds the vanilla clamp.
#[test]
fn dropped_items_use_their_small_render_box() {
let item = glam::DVec3::splat(0.25);
assert_eq!(default_range(item, entity_view_scale(12, 1.0)), 24.0);
assert_eq!(default_range(item, entity_view_scale(16, 1.0)), 32.0);
assert_eq!(default_range(item, entity_view_scale(32, 1.0)), 40.0);
}
#[test]
fn invalid_render_sizes_fail_open_to_one_block() {
let range = default_range(
glam::DVec3::new(f64::NAN, 0.7, 0.4),
entity_view_scale(12, 1.0),
);
assert_eq!(range, 96.0);
}
#[test]
fn culling_decisions_only_hide_after_a_confirmed_occlusion() {
assert!(!EntityCullDecision::Bypass.hides_entity());
assert!(!EntityCullDecision::CheckVertices.hides_entity());
assert!(!EntityCullDecision::Pending.hides_entity());
assert!(EntityCullDecision::Hidden.hides_entity());
}
#[test]
fn coarse_aabb_and_mesh_paths_agree_for_a_complete_wall() {
let camera = [0.0; 3];
let bounds = Aabb::new(Vec3::new(8.0, -1.0, -1.0), Vec3::new(10.0, 1.0, 1.0));
let wall = |pos: [i32; 3]| pos[0] == 4;
assert!(Culler::new(camera, 1_000).hidden_bounds(bounds, wall));
let mesh = [vertex([8.0, -1.0, -1.0]), vertex([10.0, 1.0, 1.0])];
assert!(Culler::new(camera, 1_000).hidden(&mesh, 0, wall));
}
#[test]
fn partial_wall_and_budget_exhaustion_fail_open() {
let bounds = [8.0, -4.0, -4.0];
let max = [10.0, 4.0, 4.0];
let culler = Culler::new([0.0; 3], 1_000);
assert!(!culler.occluded(bounds, max, |pos| {
pos[0] == 4 && !(pos[1] == 1 && pos[2] == 1)
}));
let exhausted = Culler::new([0.0; 3], 1);
assert!(!exhausted.occluded(bounds, max, |_| true));
}
#[test]
fn mesh_culling_keeps_geometry_before_the_entity_prefix() {
let prefix = vertex([0.0; 3]);
let mesh = [vertex([8.0, -1.0, -1.0]), vertex([10.0, 1.0, 1.0])];
let mut vertices = vec![prefix];
vertices.extend(mesh);
let hidden = Culler::new([0.0; 3], 1_000).cull_vertices(&mut vertices, 1, |pos| pos[0] == 4);
assert!(hidden);
assert_eq!(vertices.len(), 1);
assert_eq!(vertices[0].pos, prefix.pos);
}
+1 -2
View File
@@ -261,6 +261,7 @@ impl Game<'_> {
let (yaw, pitch) = (self.yaw, self.pitch);
let (y_sin, y_cos, dir_y) = cast_direction(yaw, pitch);
let eye_y = p.position.y + f64::from(p.dimensions.eye_height);
// snapTo(x, getEyeY(), z): the hook's feet start at the owner's eye.
let start = Vec3::new(
p.position.x - f64::from(y_sin) * 0.3,
eye_y,
@@ -274,8 +275,6 @@ impl Game<'_> {
v.y * (0.6 / len + triangle(r, 0.5, 0.0103365)),
v.z * (0.6 / len + triangle(r, 0.5, 0.0103365)),
);
// A spawned hook's start position is its feet: centre the box on the eye.
let start = Vec3::new(start.x, start.y - SIZE / 2.0, start.z);
self.next_entity += 1;
self.hook = Some(Hook {
id: self.next_entity,
+26 -9
View File
@@ -488,8 +488,8 @@ impl Game<'_> {
let sprites = &self.gui.atlas.sprites;
// Each with its frame's type: a pack tells a glow frame and what it holds apart by its
// `entityId` (`glow_item_frame` in entity.properties), the item drawn within its frame.
let mut maps: Vec<(i32, glam::Mat4, u32, &'static str)> = Vec::new();
let mut items: Vec<(ItemStack, u32, glam::Mat4, u8, &'static str)> = Vec::new();
let mut maps: Vec<(i32, glam::Mat4, u32, bool, &'static str)> = Vec::new();
let mut items: Vec<(ItemStack, u32, glam::Mat4, u8, bool, &'static str)> = Vec::new();
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at a view scale
// of 1.
let range = 16.0 * 64.0 * self.view_scale();
@@ -513,6 +513,11 @@ impl Game<'_> {
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
continue;
}
let culling = self.entity_cull_decision(f, 1.0);
if culling.hides_entity() {
continue;
}
let entity_start = out.len();
let s = f.step();
// Pose: to the block centre, then X/Y rotation by facing.
let (x_rot, y_rot) = if s[1] == 0 {
@@ -599,7 +604,8 @@ impl Game<'_> {
v.layer |= u32::from(horizon_client::mesher::INCANDESCENT) << 16;
}
}
self.cull_entity_vertices(out, entity_start);
self.finish_entity_occlusion(culling, f, out, entity_start);
let cull_contents = culling.checks_vertices();
if let Some(stack) = &f.item {
let mut canceled = false;
self.native_render_item_in_frame(f.id, f.pos, stack, &mut canceled);
@@ -621,7 +627,7 @@ impl Game<'_> {
* glam::DMat4::from_rotation_z(std::f64::consts::PI)
* glam::DMat4::from_scale(glam::DVec3::splat(0.0078125))
* glam::DMat4::from_translation(glam::DVec3::new(-64.0, -64.0, -1.0));
maps.push((id, m.as_mat4(), item_light, f.kind()));
maps.push((id, m.as_mat4(), item_light, cull_contents, f.kind()));
continue;
}
let Some(stack) = f.item.clone() else {
@@ -634,13 +640,20 @@ impl Game<'_> {
* glam::DMat4::from_translation(glam::DVec3::new(0.0, 0.0, depth))
* glam::DMat4::from_rotation_z((f64::from(f.rotation) * 45.0).to_radians())
* glam::DMat4::from_scale(glam::DVec3::splat(0.5));
items.push((stack, f.id, m.as_mat4(), item_light, f.kind()));
items.push((
stack,
f.id,
m.as_mat4(),
item_light,
cull_contents,
f.kind(),
));
}
// `extractRenderState`: FIXED, seeded by the frame entity.
let skinning = self.skinning();
let lights =
horizon_client::block_entity_render::diffuse_lights(self.dim == super::Dim::Nether);
for (stack, id, m, light, kind) in items {
for (stack, id, m, light, cull, kind) in items {
let entity_start = out.len();
self.mark_entity(entity_start, kind);
let owner = super::item_draw::owner_at([0.0; 3], 0.0, "minecraft:item_frame");
@@ -658,13 +671,17 @@ impl Game<'_> {
if skinned.is_none() {
self.submit_item_world(&state, m, light, out);
}
self.cull_skinned(out, entity_start, skinned);
if cull {
self.cull_skinned(out, entity_start, skinned);
}
}
for (id, m, light, kind) in maps {
for (id, m, light, cull, kind) in maps {
let entity_start = out.len();
self.mark_entity(entity_start, kind);
self.map_vertices(id, true, m, light, out);
self.cull_entity_vertices(out, entity_start);
if cull {
self.cull_entity_vertices(out, entity_start);
}
}
}
}
@@ -15,6 +15,7 @@ use horizon_client::{
items::ItemStack,
mesher::Vertex,
};
use horizon_core::geometry::{Aabb, Vec3};
use serde_json::Value;
use std::{cell::RefCell, collections::HashMap};
@@ -632,6 +633,34 @@ impl Game<'_> {
item_render::rendered_amount(item.stack.count),
seed,
);
// Use the same animated, spun, clustered transforms as the draw.
// ItemEntity's collision box is only 0.25 blocks and cannot prove
// that this larger rendered model stays behind an occluder.
let culling = entity.then(|| {
state
.posed_bounds(&copies)
.map(|(lo, hi)| {
self.entity_cull_decision_in_bounds(
item,
Aabb::new(
Vec3::new(
camera.x + f64::from(lo.x),
camera.y + f64::from(lo.y),
camera.z + f64::from(lo.z),
),
Vec3::new(
camera.x + f64::from(hi.x),
camera.y + f64::from(hi.y),
camera.z + f64::from(hi.z),
),
),
)
})
.unwrap_or_else(|| self.entity_cull_decision(item, f64::from(partial)))
});
if culling.is_some_and(|decision| decision.hides_entity()) {
continue;
}
let outline = self.thing_outline(item.id, "minecraft:item");
// Posed on the GPU when every layer can be, its silhouette too when it glows.
let skinned = skinning
@@ -648,6 +677,8 @@ impl Game<'_> {
let color = horizon_client::mobs::render::abgr(rgb);
self.skin_frame.borrow_mut().outline_since(first, color);
}
} else if let Some(culling) = culling {
self.finish_entity_occlusion(culling, item, out, entity_start);
} else {
self.cull_skinned(out, entity_start, skinned);
}
+55 -19
View File
@@ -497,7 +497,9 @@ fn mob_meta(
meta: &mut Vec<(u8, i32, Value)>,
defaults: &mut Vec<(u8, i32, Value)>,
) {
let kind = m.kind.id.as_str();
// The entries are the class's: a content mod's living type has its
// template's.
let kind = m.kind.class_id();
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, sprinting 0x08 (a dashing
// cat or ocelot), swimming 0x10 (a drowned under water,
// `Drowned.setSwimming`), invisible 0x20 (the effect's flag) and glowing
@@ -3644,14 +3646,63 @@ mod tests {
/// the raider celebrates.
#[test]
fn a_celebrating_raider_is_sent_by_mob_meta() {
use horizon_client::lan::{Lan, NetConfig, chunks::ChunkEncoder, data, hub};
let Some(mut pillager) = mob_of("minecraft:pillager") else {
return;
};
let hub = meta_hub("RaiderMetaTest");
let meta_of = |m: &Mob| {
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
super::mob_meta(m, &hub, None, 0, &mut meta, &mut defaults);
(meta, defaults)
};
let (meta, defaults) = meta_of(&pillager);
assert!(defaults.contains(&(16, serializer::BOOLEAN, Value::Bool(false))));
assert!(meta.iter().all(|e| e.0 != 16), "{meta:?}");
horizon_client::mobs::raider::state_mut(&mut pillager).celebrating = true;
let (meta, _) = meta_of(&pillager);
assert!(
meta.contains(&(16, serializer::BOOLEAN, Value::Bool(true))),
"{meta:?}"
);
}
/// A content mod's living type sends the entity data of its template's
/// class (`Zombie.DATA_BABY_ID`, the villager's unhappy counter), entry
/// for entry what its template sends.
#[test]
fn a_mod_living_type_sends_its_templates_entity_data() {
let types = super::super::mods::living_mod_types();
let hub = meta_hub("ModMobMetaTest");
let meta_of = |id: &str| {
let at = horizon_core::geometry::Vec3::new(0.5, 64.0, 0.5);
let mut m = Mob::new(1, types[id].clone(), at, 0.0);
m.extra.age = -24000;
// The same health: the ghoul's own maximum is 40, the zombie's 20.
m.health = 10.0;
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
super::mob_meta(&m, &hub, None, 0, &mut meta, &mut defaults);
(meta, defaults)
};
for (template, id) in [
("minecraft:zombie", "ruby:ghoul"),
("minecraft:villager", "ruby:settler"),
] {
assert_eq!(
meta_of(id),
meta_of(template),
"{id} sends what {template} sends"
);
}
}
/// A hub with no players and empty registries, for `mob_meta`.
fn meta_hub(name: &str) -> horizon_client::lan::hub::Hub {
use horizon_client::lan::{Lan, NetConfig, chunks::ChunkEncoder, data, hub};
let sync = std::sync::Arc::new(data::SyncData::empty());
let config = NetConfig {
announce: false,
..NetConfig::lan(0, "RaiderMetaTest".into(), 2)
..NetConfig::lan(0, name.into(), 2)
};
let lan = Lan::start_with(sync.clone(), config).unwrap();
let encoder = ChunkEncoder::new(
@@ -3674,22 +3725,7 @@ mod tests {
integrated: false,
levels: Default::default(),
};
let hub = hub::Hub::new(lan, sync, encoder, hub_config);
let meta_of = |m: &Mob| {
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
super::mob_meta(m, &hub, None, 0, &mut meta, &mut defaults);
(meta, defaults)
};
let (meta, defaults) = meta_of(&pillager);
assert!(defaults.contains(&(16, serializer::BOOLEAN, Value::Bool(false))));
assert!(meta.iter().all(|e| e.0 != 16), "{meta:?}");
horizon_client::mobs::raider::state_mut(&mut pillager).celebrating = true;
let (meta, _) = meta_of(&pillager);
assert!(
meta.contains(&(16, serializer::BOOLEAN, Value::Bool(true))),
"{meta:?}"
);
hub::Hub::new(lan, sync, encoder, hub_config)
}
/// `LivingEntity.startUsingItem` sets flag 1 for the use and flag 2 for
Loaded 100 of 290 files, more files were not shown because too many files have changed in this diff. Show more