mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
176
Commits
No files matched your search
@@ -6,7 +6,6 @@
|
||||
/.github/ @ludovic111
|
||||
/tools/ci/ @ludovic111
|
||||
/tools/release_train.py @ludovic111
|
||||
/tools/release_channels.py @ludovic111
|
||||
/crates/horizon-release-sign/ @ludovic111
|
||||
/crates/horizon-server/src/update.rs @ludovic111
|
||||
/crates/horizon-server/Cargo.toml @ludovic111
|
||||
|
||||
@@ -64,9 +64,13 @@ jobs:
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
|
||||
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: cargo +stable test --workspace
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests
|
||||
cargo +stable test --workspace --doc
|
||||
parity:
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: ./.github/workflows/parity.yml
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
name: promote
|
||||
# Beta game releases become stable once a day (docs/release-flow.md, "Channels"): at 20:00 UTC (21:00 in Paris in
|
||||
# winter time, 22:00 in summer) tools/release_channels.py promotes the newest beta that had been out 6 hours by then,
|
||||
# passed its release smoke test and isn't covered by an issue labelled release-blocker. Launchers on the Alpha
|
||||
# channel install every beta when it is published; Stable gets the day's promotion. The later runs of the evening are
|
||||
# retries for a run GitHub starts late or skips: they promote the same beta, so stable still moves once.
|
||||
# By hand, from GitHub's app too (Actions > promote > Run workflow):
|
||||
# gh workflow run promote -f tag=v0.2.3 # that beta now, without waiting for 20:00
|
||||
# gh workflow run promote -f tag=v0.2.3 -f force=true # even blocked or without its smoke test
|
||||
# gh workflow run promote -f dry_run=true # say what would be promoted
|
||||
# Holding betas back: label an issue release-blocker. While it is open it holds every beta; closed as completed it
|
||||
# still holds the betas published before it closed. The issue's text is never read (its author could edit it).
|
||||
# When the oldest waiting beta is 30 hours old (it missed a promotion it was due for), an issue "Promotion to stable
|
||||
# has stalled" says so; it closes itself after the next promotion.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '7,37 20-21 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: The beta to promote now (empty = the newest beta that is due)
|
||||
type: string
|
||||
default: ''
|
||||
force:
|
||||
description: Promote that tag even if a release-blocker covers it or its smoke test didn't run
|
||||
type: boolean
|
||||
default: false
|
||||
dry_run:
|
||||
description: Say what would be promoted, change nothing
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
concurrency:
|
||||
group: promote
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The notes of a promoted release cover every pull request since the last stable one.
|
||||
fetch-depth: 0
|
||||
# gh uses GH_TOKEN below; nothing needs git credentials on disk.
|
||||
persist-credentials: false
|
||||
- name: Promote
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
FORCE: ${{ inputs.force && 'yes' || '' }}
|
||||
DRY_RUN: ${{ (inputs.dry_run || github.ref != 'refs/heads/main') && 'yes' || '' }}
|
||||
run: python3 tools/release_channels.py promote ${TAG:+--tag "$TAG"} ${FORCE:+--force} ${DRY_RUN:+--dry-run}
|
||||
@@ -3,12 +3,15 @@ name: release
|
||||
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides:
|
||||
# a release when pull requests merged since the last one changed what players run and the newest merged 5 minutes
|
||||
# ago (the oldest 20 minutes ago, if merges never pause), or at once for a pull request labelled release-now.
|
||||
# RELEASES_PAUSED=1 (repository variable) stops them.
|
||||
# RELEASES_PAUSED=1 (repository variable) stops them; so does an open issue labelled release-blocker, forced runs too.
|
||||
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
|
||||
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then
|
||||
# publishes vX.Y.Z on the release line in .github/release-line.txt (docs/versioning.md; without it, the dated
|
||||
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as a beta (a pre-release) that promote.yml makes
|
||||
# stable in its daily promotion. macOS: with the repository variable MACOS_SIGN_IDENTITY set, the programs are signed with
|
||||
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as the release and GitHub's latest: one
|
||||
# channel, which launchers, `horizon-server update` and install.sh follow at once. So it is published only after the
|
||||
# checks that used to gate a promotion pass in this run: the Mac mini's smoke test drew a world, and no release-blocker
|
||||
# is open (tools/release_train.py gate); otherwise nothing is published. macOS: with the repository variable
|
||||
# MACOS_SIGN_IDENTITY set, the programs are signed with
|
||||
# Developer ID and the hardened runtime and the zip is notarized, from the runner's signing keychain
|
||||
# (packaging/macos/README.md); unset (forks), ad hoc.
|
||||
# gh workflow run release --ref <branch> # dry run: builds and notes, publishes nothing
|
||||
@@ -32,6 +35,8 @@ on:
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
# The open release-blocker issues (decide).
|
||||
issues: read
|
||||
concurrency:
|
||||
group: game-release
|
||||
cancel-in-progress: false
|
||||
@@ -203,7 +208,7 @@ jobs:
|
||||
or not report.get("meshed_sections") or not report.get("player"):
|
||||
sys.exit("the smoke run did not draw a world")
|
||||
EOF
|
||||
# Published with the release: a beta becomes stable only with it (tools/release_channels.py).
|
||||
# Published with the release; the publish job checks it again before it publishes anything.
|
||||
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: runner.environment == 'self-hosted'
|
||||
@@ -319,6 +324,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
issues: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -341,6 +347,17 @@ jobs:
|
||||
run: |
|
||||
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
|
||||
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
|
||||
- name: Ready to publish (the smoke test passed, no release-blocker is open)
|
||||
# Right before publishing: an issue labelled while the builds ran still holds this release. A dry run only
|
||||
# reports what a real run would do.
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DRY_RUN: ${{ needs.decide.outputs.dry_run }}
|
||||
run: |
|
||||
if ! python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json --blockers; then
|
||||
[[ "$DRY_RUN" == true ]] || { echo "::error::Nothing published"; exit 1; }
|
||||
echo "::warning::A real run would publish nothing"
|
||||
fi
|
||||
- name: Publish ${{ needs.decide.outputs.tag }}
|
||||
if: needs.decide.outputs.dry_run != 'true'
|
||||
env:
|
||||
@@ -348,17 +365,15 @@ jobs:
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
smoke=(); [[ -f dist/smoke-test.json ]] && smoke=(dist/smoke-test.json)
|
||||
# A beta (pre-release), the Alpha channel's: promote.yml makes it stable and latest in its daily promotion
|
||||
# (docs/release-flow.md).
|
||||
# The release and the latest at once: there is one channel (docs/release-flow.md).
|
||||
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
|
||||
--notes-file notes/body.md --prerelease --latest=false dist/horizon-linux-x86_64.tar.gz \
|
||||
--notes-file notes/body.md --latest dist/horizon-linux-x86_64.tar.gz \
|
||||
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz \
|
||||
dist/SHA256SUMS "${signature[@]}" "${smoke[@]}"
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
- name: Dry run, the release that would be published
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
run: |
|
||||
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }}: $(cat notes/title.txt)"
|
||||
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }} as the release and latest: $(cat notes/title.txt)"
|
||||
ls -l dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# Horizon: a map for agents
|
||||
|
||||
Minecraft: Java Edition 26.3 rewritten in Rust (wgpu). Vanilla is the specification wherever it has the
|
||||
feature. Read [CONTRIBUTING.md](CONTRIBUTING.md) once; this page tells you where things are.
|
||||
Feature → files → harness for the most-changed areas: [docs/architecture.md](docs/architecture.md).
|
||||
|
||||
## Rules that bite
|
||||
|
||||
- **Never commit Mojang's files**: game jars, assets, decompiled source, prepared resources (`.local/`).
|
||||
`tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit.
|
||||
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
|
||||
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
|
||||
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Never write to
|
||||
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
|
||||
- **Mark what an agent posts.** A PR body starts with
|
||||
`> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.` (issues: "Filed by", reviews:
|
||||
"Review by"); every comment starts with `🤖 Agent:`. Never write as Ludovic.
|
||||
- **Attribution.** Commits end with `Co-Authored-By: Claude <model> <[email protected]>`; PR bodies end
|
||||
with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
||||
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
|
||||
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
|
||||
they would change the vanilla look.
|
||||
- Small PRs (< ~500 lines). Labels: `feature` (players get something new), `skip-release` (they see no
|
||||
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Every merge to main ships
|
||||
([docs/release-flow.md](docs/release-flow.md)).
|
||||
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
|
||||
|
||||
## Crates
|
||||
|
||||
Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-server` are separate workspaces.
|
||||
|
||||
| Crate | Owns |
|
||||
|---|---|
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~190 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
|
||||
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
|
||||
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
|
||||
| `horizon-assets` | Resource decoding: block-state and item models, baking, atlases |
|
||||
| `horizon-prepare` | Builds `.local/horizon-client/26.3` from the player's own Minecraft install (runs the Java exporters) |
|
||||
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
|
||||
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-shaderpack` | OptiFine/Iris packs: preprocessor, GLSL → WGSL ([docs/shader-packs.md](docs/shader-packs.md)) |
|
||||
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
|
||||
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `voxel-kernels`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, occupancy kernels, repo tasks |
|
||||
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
|
||||
|
||||
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
|
||||
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
|
||||
`website/` (playhorizon.dev; its paths are relative to `website/`), `docs/`, `.github/workflows/`.
|
||||
|
||||
## Build and test fast
|
||||
|
||||
```sh
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
python3 tools/parity.py mob_ai sculk # named harnesses
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (host in-frame); --server-thread for the host on its server thread
|
||||
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
```
|
||||
|
||||
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
|
||||
`tools/`): see [docs/parity-workflow.md](docs/parity-workflow.md). Without it, pr_ready lists the
|
||||
harnesses for CI.
|
||||
- A harness = `tools/test_<name>_parity.py` (324 of them) + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`)
|
||||
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
|
||||
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
|
||||
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
|
||||
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
|
||||
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
|
||||
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
|
||||
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude horizon-mods-test-guest`.
|
||||
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
|
||||
then `horizon-ctl --socket … state|keys|turn|command|screenshot|quit`
|
||||
([docs/horizon-ctl-commands.md](docs/horizon-ctl-commands.md)).
|
||||
|
||||
## Vanilla reference
|
||||
|
||||
- Decompiled sources: `.local/minecraft/26.3/sources/{client,server}/` (`python3 tools/lab.py decompile`).
|
||||
- Bytecode queries (callers through lambdas, every overload):
|
||||
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`
|
||||
([docs/decompilation-status.md](docs/decompilation-status.md)).
|
||||
- A harness runs the oracle against the real 26.3 jars and the Rust probe on the same inputs, then compares
|
||||
their JSON. Oracle runs are cached by `tools/parity_shim.py`; `--fresh` reruns them.
|
||||
- Mirror vanilla's `Math` calls with `horizon_core::jmath`, and vanilla's RNG with `horizon_core::random`:
|
||||
random draws must happen in vanilla's order.
|
||||
|
||||
## horizon-client: where things live
|
||||
|
||||
`L:` = lib (`crates/horizon-client/src/`), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
|
||||
`Game`; bin code reaches the lib as `horizon_client::<module>`.
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `window.rs`, `spans.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
|
||||
| Commands | parse: L: `command/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`, `nbt_path.rs`; G: `functions.rs`, `command_blocks.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
|
||||
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
|
||||
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
|
||||
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `block_entity_nbt.rs`, `options_file.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Shader packs | L: `shader_pack.rs` (Horizon WGSL packs), `java_pack.rs` + crate `horizon-shaderpack` (Iris/OptiFine), `gfx/pack_stages.rs`, `gfx/java_stages.rs`; G: `shader_packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
|
||||
## How `Game` is organised
|
||||
|
||||
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
|
||||
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
|
||||
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
|
||||
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
|
||||
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
|
||||
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
|
||||
single player on its server thread), `lan` (`LanHost`: this world is open to the network), `integrated`
|
||||
(the window's own server thread), `serving` (this `Game` *is* that server thread), `options.dedicated`.
|
||||
`fn tick` returns `tick_remote()` when `remote` is set; nothing else of the local simulation runs.
|
||||
- A server's players run through the same gameplay code as the local player: `as_guest` swaps the guest's
|
||||
state (position, inventory, mode, mining, item use) into `Game` for one call (`game/lan_actions.rs`), and
|
||||
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
|
||||
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
|
||||
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
|
||||
(`pack`), both in game.rs.
|
||||
|
||||
## The two single-player paths
|
||||
|
||||
1. **In-frame (default since #1428):** the window's `Game` ticks the world itself, one level that follows
|
||||
the player (`fn enter_dimension`). This is what Stable players run and what harnesses test.
|
||||
2. **Server thread (`--server-thread` or `HORIZON_SERVER_THREAD=1`):** `game/integrated.rs` runs a second
|
||||
`Game` (`serving`) on a "Server thread" with one level per dimension; the window joins it as a client
|
||||
through memory (`remote`), exactly as a LAN guest or a dedicated server's player does. Not at parity yet:
|
||||
open `[server-thread]` issues list the gaps.
|
||||
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
|
||||
when you touch player-facing gameplay:
|
||||
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # in-frame
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --server-thread # server thread
|
||||
python3 tools/lan_e2e.py --check <name> # host in-frame, guest is a server client
|
||||
python3 tools/lan_e2e.py --server-thread --check <name> # host on its server thread (portals, levels, distance, lava need it)
|
||||
python3 tools/parity.py dedicated_server server_entities server_player_damage
|
||||
```
|
||||
|
||||
## Docs worth knowing
|
||||
|
||||
[docs/parity-workflow.md](docs/parity-workflow.md) (runner, gate, shards, scopes) ·
|
||||
[docs/native-client.md](docs/native-client.md) (options, controls, horizon-ctl) ·
|
||||
[docs/server-levels.md](docs/server-levels.md) (levels per dimension) ·
|
||||
[docs/rust-parity.md](docs/rust-parity.md) (what is ported) · [docs/known-issues.md](docs/known-issues.md) ·
|
||||
[docs/release-flow.md](docs/release-flow.md) · [docs/README.md](docs/README.md) (index).
|
||||
@@ -30,7 +30,9 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
# Shader packs: pack.toml, and WGSL checked before it reaches wgpu.
|
||||
toml = "1.1"
|
||||
naga = { version = "30.0.1", features = ["wgsl-in"] }
|
||||
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
|
||||
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
# Java (OptiFine/Iris) shader packs, translated to WGSL.
|
||||
horizon-shaderpack = { path = "../horizon-shaderpack" }
|
||||
# Sound (ALSA on Linux); the `audio` feature.
|
||||
@@ -81,7 +83,3 @@ rng-trace = ["horizon-core/rng-trace"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
@@ -121,6 +121,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tick_frozen: false,
|
||||
tick_steps: (0, 0),
|
||||
collision: None,
|
||||
border: &horizon_client::worldborder::WorldBorder::default(),
|
||||
};
|
||||
for event in hub.tick(&mut ctx) {
|
||||
match event {
|
||||
|
||||
@@ -111,6 +111,8 @@ struct World<'a> {
|
||||
slime: f32,
|
||||
starts: Vec<Start>,
|
||||
players: Vec<([f64; 3], String)>,
|
||||
/// Where the non-spectators among them are.
|
||||
nearest: Vec<[f64; 3]>,
|
||||
respawn: Option<[i32; 3]>,
|
||||
unloaded: HashSet<(i32, i32)>,
|
||||
spawn_mobs: bool,
|
||||
@@ -302,19 +304,8 @@ impl NaturalLevel for World<'_> {
|
||||
fn structure_spawns(&self) -> &StructureSpawns {
|
||||
self.s.structures
|
||||
}
|
||||
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
|
||||
let mut best: Option<f64> = None;
|
||||
for (p, mode) in &self.players {
|
||||
if mode == "spectator" {
|
||||
continue;
|
||||
}
|
||||
let d = [p[0] - x, p[1] - y, p[2] - z];
|
||||
let d = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
if best.is_none_or(|b| d < b) {
|
||||
best = Some(d);
|
||||
}
|
||||
}
|
||||
best
|
||||
fn nearest_player_positions(&self) -> &[[f64; 3]] {
|
||||
&self.nearest
|
||||
}
|
||||
fn respawn(&self) -> Option<[i32; 3]> {
|
||||
self.respawn
|
||||
@@ -730,6 +721,11 @@ fn main() -> horizon_client::Result<()> {
|
||||
dimension_id: dimension_id.clone(),
|
||||
slime: get("slime", "0.0").parse()?,
|
||||
starts,
|
||||
nearest: players
|
||||
.iter()
|
||||
.filter(|(_, mode)| mode != "spectator")
|
||||
.map(|(p, _)| *p)
|
||||
.collect(),
|
||||
players,
|
||||
respawn,
|
||||
unloaded,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Random-tick sections companion for tools/test_random_tick_sections_parity.py:
|
||||
//! the game's `RandomTickSections` choosing the sections of a chunk that
|
||||
//! `Behaviours::tick_chunk_random` ticks, after the case's edits (see
|
||||
//! VanillaRandomTickSectionsOracle for the format; the `#resources` line names
|
||||
//! the prepared resources). The level the ticks run against reads as air
|
||||
//! everywhere, so nothing ticks for real: its reads are the drawn positions.
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, Section},
|
||||
random_tick_sections::RandomTickSections,
|
||||
};
|
||||
use horizon_core::{
|
||||
position::{BlockPos, ChunkPos},
|
||||
random::LegacyRandom,
|
||||
};
|
||||
use horizon_world::{
|
||||
fluid::FluidKind,
|
||||
level::{GameRules, LevelAccess, LevelEvent, Ticks, data},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
use std::{cell::RefCell, error::Error, fmt::Write, path::PathBuf, sync::Arc};
|
||||
|
||||
struct Air {
|
||||
reads: RefCell<Vec<BlockPos>>,
|
||||
random: LegacyRandom,
|
||||
speed: i32,
|
||||
}
|
||||
impl LevelAccess for Air {
|
||||
fn block_state(&self, pos: BlockPos) -> BlockStateId {
|
||||
self.reads.borrow_mut().push(pos);
|
||||
BlockStateId(0)
|
||||
}
|
||||
fn set_raw(&mut self, _: BlockPos, _: BlockStateId) -> Option<BlockStateId> {
|
||||
None
|
||||
}
|
||||
fn is_loaded(&self, _: BlockPos) -> bool {
|
||||
true
|
||||
}
|
||||
fn random(&mut self) -> &mut LegacyRandom {
|
||||
&mut self.random
|
||||
}
|
||||
fn game_time(&self) -> i64 {
|
||||
0
|
||||
}
|
||||
fn game_rules(&self) -> GameRules {
|
||||
GameRules {
|
||||
random_tick_speed: self.speed,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn emit(&mut self, _: LevelEvent) {}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = std::fs::read_to_string(&args[1])?;
|
||||
let root = input
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("#resources "))
|
||||
.map(PathBuf::from)
|
||||
.ok_or("no #resources line")?;
|
||||
let registry = data::load_registry(&root)?;
|
||||
let (facts, behaviours) = BlockFacts::load_pair(&root, ®istry)?;
|
||||
let mut output = String::new();
|
||||
for line in input.lines().filter(|l| !l.starts_with('#')) {
|
||||
let mut words = line.split(' ');
|
||||
let mut ticks = Ticks::new(0);
|
||||
ticks.random_tick_value = words.next().ok_or("no randValue")?.parse()?;
|
||||
let mut level = Air {
|
||||
reads: RefCell::default(),
|
||||
random: LegacyRandom::new(0),
|
||||
speed: words.next().ok_or("no speed")?.parse()?,
|
||||
};
|
||||
let mut chunk = Arc::new(Chunk::empty(0, 0));
|
||||
let mut cache = RandomTickSections::default();
|
||||
for word in words {
|
||||
let op: Vec<&str> = word.split(':').collect();
|
||||
let int = |i: usize| -> Result<i32, Box<dyn Error>> { Ok(op[i].parse()?) };
|
||||
match op[0] {
|
||||
"s" => {
|
||||
let (section, index) = (int(1)?, int(2)?);
|
||||
Arc::make_mut(&mut chunk).set(
|
||||
index & 15,
|
||||
MIN_Y + section * 16 + (index >> 8),
|
||||
index >> 4 & 15,
|
||||
int(3)? as u32,
|
||||
);
|
||||
}
|
||||
"f" => {
|
||||
let (a, b, m) = (int(2)? as u32, int(3)? as u32, int(4)? as usize);
|
||||
let values = std::array::from_fn(|i| if i % m == m - 1 { b } else { a });
|
||||
Arc::make_mut(&mut Arc::make_mut(&mut chunk).sections[int(1)? as usize])
|
||||
.blocks = Section::from_blocks(&values);
|
||||
}
|
||||
"t" => {
|
||||
output.push('|');
|
||||
level.reads.borrow_mut().clear();
|
||||
behaviours.tick_chunk_random(
|
||||
&mut level,
|
||||
&mut ticks,
|
||||
ChunkPos::new(0, 0),
|
||||
|_, from| cache.get((0, 0), &chunk, &facts).from(from),
|
||||
);
|
||||
for pos in level.reads.borrow().iter() {
|
||||
let state = chunk.block(pos.x, pos.y, pos.z);
|
||||
let fluid = matches!(
|
||||
behaviours.fluid(state).kind(),
|
||||
FluidKind::Lava | FluidKind::FlowingLava
|
||||
);
|
||||
write!(
|
||||
output,
|
||||
" {},{},{},{}{}",
|
||||
pos.x,
|
||||
pos.y,
|
||||
pos.z,
|
||||
if behaviours.is_randomly_ticking(state) {
|
||||
'B'
|
||||
} else {
|
||||
'-'
|
||||
},
|
||||
if fluid { 'F' } else { '-' },
|
||||
)?;
|
||||
}
|
||||
}
|
||||
other => return Err(format!("unknown op {other}").into()),
|
||||
}
|
||||
}
|
||||
writeln!(output, " | {}", ticks.random_tick_value)?;
|
||||
}
|
||||
std::fs::write(&args[2], output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -78,6 +78,7 @@ fn main() -> Result<()> {
|
||||
let mut selected = collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &entities,
|
||||
border: None,
|
||||
};
|
||||
let movement = world_movement::resolve_world_movement(
|
||||
&mut selected,
|
||||
|
||||
@@ -22,6 +22,11 @@ impl PlayerMotion {
|
||||
pub fn refresh_attributes(&mut self) -> Result<()> {
|
||||
live_updates::refresh(self)
|
||||
}
|
||||
/// `Player.internalSetAbsorptionAmount`: the amount as given, unclamped
|
||||
/// (the saved `AbsorptionAmount`, or what a client's server sent).
|
||||
pub fn internal_set_absorption(&mut self, value: f32) -> Result<()> {
|
||||
self.store_absorption(value)
|
||||
}
|
||||
}
|
||||
impl HealthWorld for PlayerMotion {
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `banner_patterns` layers carried by banner items, shields and placed
|
||||
//! banners, and the loom rules of `LoomMenu` (selectable patterns, the
|
||||
//! six-layer limit and the result stack).
|
||||
use crate::{block_entity_render::DYES, item_data, items::ItemStack, items::Items};
|
||||
use crate::{block_entity_render::DYES, items::ItemStack, items::Items};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path, sync::Arc};
|
||||
@@ -261,12 +261,6 @@ pub fn layers_from_nbt(tag: Option<&Tag>) -> Vec<BannerLayer> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Keeps `item_data` linked: the `StackData` fields the banner code reads.
|
||||
pub fn set_layers(stack: &mut ItemStack, layers: Vec<BannerLayer>) {
|
||||
let data: &mut item_data::StackData = stack.data_mut();
|
||||
data.banner_patterns = layers;
|
||||
stack.tidy();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -144,9 +144,6 @@ impl BlockEntities {
|
||||
pub fn set_states(&mut self, states: Vec<Ticking>) {
|
||||
self.states = states;
|
||||
}
|
||||
pub fn take_states(&mut self) -> Vec<Ticking> {
|
||||
std::mem::take(&mut self.states)
|
||||
}
|
||||
pub fn ticking(&self, state: u32) -> Ticking {
|
||||
self.states.get(state as usize).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -382,10 +382,6 @@ impl Shulker {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `getProgress(a)`: `Mth.lerp(a, progressOld, progress)`.
|
||||
pub fn get_progress(&self, a: f32) -> f32 {
|
||||
self.progress_old + a * (self.progress - self.progress_old)
|
||||
}
|
||||
/// `ShulkerBoxBlock.canOpen`: a closed box only opens when its lid has room
|
||||
/// (`level.noCollision` of the half-block lid box, deflated by 1e-6).
|
||||
pub fn lid_box(pos: [i32; 3], facing: u8) -> [f64; 6] {
|
||||
@@ -668,14 +664,6 @@ impl Skull {
|
||||
self.animating = false;
|
||||
}
|
||||
}
|
||||
/// `getAnimation(a)`.
|
||||
pub fn get_animation(&self, a: f32) -> f32 {
|
||||
if self.animating {
|
||||
self.tick_count as f32 + a
|
||||
} else {
|
||||
self.tick_count as f32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `DecoratedPotBlockEntity.WobbleStyle`.
|
||||
|
||||
@@ -875,13 +875,6 @@ impl BlockEntityRenderer {
|
||||
visibility: None,
|
||||
}
|
||||
}
|
||||
/// The standing banner's pole-and-bar and flag models (`BannerRenderer.submitSpecial`).
|
||||
pub fn banner_models(&self) -> Option<(&ModelPart, &ModelPart)> {
|
||||
Some((
|
||||
self.models.get("standing_banner")?,
|
||||
self.models.get("standing_banner_flag")?,
|
||||
))
|
||||
}
|
||||
fn kind(&self, state: u32) -> &Kind {
|
||||
self.kinds.get(state as usize).unwrap_or(&Kind::None)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ impl BossBar {
|
||||
style: "progress".into(),
|
||||
value: 0,
|
||||
max: 100,
|
||||
visible: false,
|
||||
visible: true,
|
||||
player: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,10 +169,6 @@ impl TickRateManager {
|
||||
self.frozen_ticks_to_run -= 1;
|
||||
}
|
||||
}
|
||||
/// `isEntityFrozen` for an entity that is not (and does not carry) a player.
|
||||
pub fn is_entity_frozen(&self, is_player_or_carries_player: bool) -> bool {
|
||||
!self.runs_normally() && !is_player_or_carries_player
|
||||
}
|
||||
}
|
||||
|
||||
/// What `/tick sprint` reports when it ends.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Entity-dependent rules use the defaults for players and the supported mobs.
|
||||
use crate::{Result, world::World};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3, VoxelShape},
|
||||
geometry::{Aabb, Vec3, VoxelShape, border::BorderGeometry},
|
||||
position::{BlockPos, ChunkPos},
|
||||
};
|
||||
use horizon_world::{
|
||||
@@ -64,6 +64,9 @@ pub struct SceneCollisions<'w, 'r> {
|
||||
pub struct EntityCollisions<'a, W> {
|
||||
pub blocks: W,
|
||||
pub entities: &'a [Aabb],
|
||||
/// The world border and the moving entity's x and z
|
||||
/// (`CollisionGetter.borderCollision`); None: the blocks' answer.
|
||||
pub border: Option<(&'a BorderGeometry, [f64; 2])>,
|
||||
}
|
||||
impl<W: BlockCollisionWorld> BlockCollisionWorld for EntityCollisions<'_, W> {
|
||||
type Error = W::Error;
|
||||
@@ -114,12 +117,20 @@ impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCo
|
||||
}
|
||||
Ok(shapes)
|
||||
}
|
||||
/// `WorldBorder.isInsideCloseToBorder`: an entity inside the border and
|
||||
/// near it collides with its walls.
|
||||
fn movement_border(
|
||||
&mut self,
|
||||
entity: EntityIdentity,
|
||||
bounds: Aabb,
|
||||
) -> io::Result<Option<VoxelShape>> {
|
||||
self.blocks.movement_border(entity, bounds)
|
||||
let Some((border, [x, z])) = self.border else {
|
||||
return self.blocks.movement_border(entity, bounds);
|
||||
};
|
||||
if !border.is_inside_close_to_border(x, z, bounds) {
|
||||
return Ok(None);
|
||||
}
|
||||
border.collision_shape().map(Some).map_err(io::Error::other)
|
||||
}
|
||||
}
|
||||
impl SceneCollisions<'_, '_> {
|
||||
|
||||
@@ -812,14 +812,6 @@ pub fn java_parse_int(s: &str) -> Option<i32> {
|
||||
}
|
||||
s.parse().ok()
|
||||
}
|
||||
/// Long.parseLong.
|
||||
pub fn java_parse_long(s: &str) -> Option<i64> {
|
||||
let digits = s.strip_prefix(['-', '+']).unwrap_or(s);
|
||||
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok()
|
||||
}
|
||||
/// Double.parseDouble over the characters StringReader reads as a number.
|
||||
pub fn java_parse_double(s: &str) -> Option<f64> {
|
||||
let t = s.trim_matches(|c: char| c <= ' ');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! ComponentSerialization.CODEC and Style.Serializer.MAP_CODEC decoding of
|
||||
//! SNBT text components, with the data-fixer-upper error messages
|
||||
//! (ComponentArgument, StyleArgument and component-valued item data).
|
||||
use super::codec::{get, no_key, not_list, not_map, snbt};
|
||||
use super::codec::{get, no_key, not_list, not_map};
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
|
||||
/// A DataResult error: its message and whether it carries a partial result.
|
||||
@@ -362,7 +362,3 @@ pub fn decode_style(t: &Tag) -> Result<(), String> {
|
||||
_ => Err(not_map(t)),
|
||||
}
|
||||
}
|
||||
#[allow(unused)]
|
||||
fn _keep(t: &Tag) -> String {
|
||||
snbt(t)
|
||||
}
|
||||
@@ -632,10 +632,6 @@ pub fn parse_predicate(reader: &mut Reader, reg: &Registries) -> CmdResult<ItemI
|
||||
pub fn suggest_predicate(builder: &mut SuggestionsBuilder, reg: &Registries) {
|
||||
PREDICATE.with(|g| g.parse_for_suggestions_with(builder, Some(reg)));
|
||||
}
|
||||
#[allow(unused)]
|
||||
fn _unused(t: &Tag) -> String {
|
||||
snbt(t)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -183,9 +183,6 @@ impl<'a> Reader<'a> {
|
||||
pub fn read_int(&mut self) -> CmdResult<i32> {
|
||||
self.number("int")
|
||||
}
|
||||
pub fn read_long(&mut self) -> CmdResult<i64> {
|
||||
self.number("long")
|
||||
}
|
||||
pub fn read_double(&mut self) -> CmdResult<f64> {
|
||||
self.number("double")
|
||||
}
|
||||
@@ -222,21 +219,6 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
Err(CommandError::tr_at("parsing.quote.expected.end", &[], self))
|
||||
}
|
||||
pub fn read_quoted_string(&mut self) -> CmdResult<String> {
|
||||
if !self.can_read() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let next = self.peek();
|
||||
if next != '"' && next != '\'' {
|
||||
return Err(CommandError::tr_at(
|
||||
"parsing.quote.expected.start",
|
||||
&[],
|
||||
self,
|
||||
));
|
||||
}
|
||||
self.skip();
|
||||
self.read_string_until(next)
|
||||
}
|
||||
pub fn read_string(&mut self) -> CmdResult<String> {
|
||||
if !self.can_read() {
|
||||
return Ok(String::new());
|
||||
@@ -539,18 +521,6 @@ impl<'a> SuggestionsBuilder<'a> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The same with a tooltip per candidate.
|
||||
pub fn suggest_matching_tooltip<'b>(
|
||||
&mut self,
|
||||
candidates: impl IntoIterator<Item = (&'b str, String)>,
|
||||
) {
|
||||
let lower = self.remaining_lower();
|
||||
for (c, tip) in candidates {
|
||||
if args::matches_sub_str(&lower, &c.to_lowercase()) {
|
||||
self.suggest_tooltip(c, tip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// String.compareToIgnoreCase.
|
||||
pub fn compare_ignore_case(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! SnbtGrammar and TagParser: vanilla's packrat SNBT grammar, with its
|
||||
//! error messages and completion suggestions, producing NBT tags.
|
||||
use super::packrat::{
|
||||
Grammar, Reason, Rule, State, Sugg, Term, Value, alt, ch, chs, look, opt, seq,
|
||||
};
|
||||
use super::packrat::{Grammar, Reason, State, Sugg, Term, Value, alt, ch, chs, look, opt, seq};
|
||||
use super::{CmdResult, CommandError, Reader, SuggestionsBuilder};
|
||||
use horizon_world::nbt::{Compound, JavaString, Tag};
|
||||
use std::rc::Rc;
|
||||
@@ -981,5 +979,3 @@ pub fn rule_in<V: super::packrat::Value>(
|
||||
}
|
||||
}
|
||||
}
|
||||
#[allow(unused)]
|
||||
fn _rule_kind(_: Rule<SV>) {}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! mode keywords are read back from the parsed literals.
|
||||
use super::{
|
||||
Dispatcher,
|
||||
args::{ArgType, GameMode, Resource, integer},
|
||||
args::{ArgType, Resource, integer},
|
||||
};
|
||||
use crate::gamerules::{RULES, Value};
|
||||
|
||||
@@ -509,10 +509,6 @@ fn clone_mode(d: &mut Dispatcher, node: usize) {
|
||||
d.executes(l, "clone");
|
||||
}
|
||||
}
|
||||
/// Game mode names for selector `gamemode=` and the argument.
|
||||
pub fn game_modes() -> [&'static str; 4] {
|
||||
GameMode::ALL.map(GameMode::name)
|
||||
}
|
||||
/// The shared vanilla tree.
|
||||
/// Reorder node children as vanilla registers them (Brigadier keeps
|
||||
/// children in registration order; smart usage lists them that way).
|
||||
@@ -559,5 +555,3 @@ pub fn release_dispatcher() -> &'static Dispatcher {
|
||||
static TREE: std::sync::OnceLock<Dispatcher> = std::sync::OnceLock::new();
|
||||
TREE.get_or_init(|| build_with(false))
|
||||
}
|
||||
#[allow(unused)]
|
||||
const _: (u8, u8) = (ALL, GAMEMASTERS);
|
||||
@@ -167,6 +167,7 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
style.underlined = flag("underlined");
|
||||
style.strikethrough = flag("strikethrough");
|
||||
style.obfuscated = flag("obfuscated");
|
||||
style.font = o.get("font").and_then(crate::text::font_id);
|
||||
style
|
||||
}
|
||||
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
|
||||
@@ -266,4 +267,26 @@ mod tests {
|
||||
let (v, _) = parse_snbt("[\"a\",{\"text\":\"b\"}]").unwrap();
|
||||
assert_eq!(to_text(&v, &mut |_| Text::empty()).plain(), "ab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_is_inherited_and_kept() {
|
||||
let (v, _) =
|
||||
parse_snbt(r#"{text:"a",font:"alt",extra:["b",{text:"c",font:"minecraft:uniform"}]}"#)
|
||||
.unwrap();
|
||||
let t = to_text(&v, &mut |_| Text::empty());
|
||||
let fonts: Vec<_> = t.chars().map(|(_, s)| s.font.clone()).collect();
|
||||
let (alt, uniform) = (
|
||||
Some("minecraft:alt".into()),
|
||||
Some("minecraft:uniform".into()),
|
||||
);
|
||||
assert_eq!(fonts, [alt.clone(), alt, uniform]);
|
||||
// Back to JSON with the field, which a re-read keeps.
|
||||
let again = to_text(&t.component(), &mut |_| Text::empty());
|
||||
let kept: Vec<_> = again.chars().map(|(_, s)| s.font.clone()).collect();
|
||||
assert_eq!(kept, fonts);
|
||||
// Not an identifier: no font.
|
||||
let (v, _) = parse_snbt(r#"{text:"a",font:"Bad Font"}"#).unwrap();
|
||||
let t = to_text(&v, &mut |_| Text::empty());
|
||||
assert_eq!(t.spans[0].style.font, None);
|
||||
}
|
||||
}
|
||||
@@ -252,24 +252,6 @@ impl BlockEntity {
|
||||
};
|
||||
Some(Self::Chest(vec![None; size]))
|
||||
}
|
||||
/// The vanilla block entity type id of a container block.
|
||||
pub fn id_for_block(name: &str) -> Option<&'static str> {
|
||||
Some(match name {
|
||||
"minecraft:chest" => "minecraft:chest",
|
||||
n if n.ends_with("copper_chest") => "minecraft:chest",
|
||||
"minecraft:trapped_chest" => "minecraft:trapped_chest",
|
||||
"minecraft:barrel" => "minecraft:barrel",
|
||||
"minecraft:hopper" => "minecraft:hopper",
|
||||
"minecraft:dispenser" => "minecraft:dispenser",
|
||||
"minecraft:dropper" => "minecraft:dropper",
|
||||
"minecraft:furnace" => "minecraft:furnace",
|
||||
"minecraft:blast_furnace" => "minecraft:blast_furnace",
|
||||
"minecraft:smoker" => "minecraft:smoker",
|
||||
n if n.ends_with("shulker_box") => "minecraft:shulker_box",
|
||||
"minecraft:crafter" => "minecraft:crafter",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Chest(items) => match items.len() {
|
||||
|
||||
@@ -13,7 +13,11 @@ use std::{
|
||||
io::{self, BufRead, BufReader, Read, Write},
|
||||
net::{Ipv4Addr, TcpListener, TcpStream},
|
||||
path::{Path, PathBuf},
|
||||
sync::mpsc,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
thread,
|
||||
};
|
||||
|
||||
@@ -151,6 +155,12 @@ pub struct Server {
|
||||
path: PathBuf,
|
||||
requests: mpsc::Receiver<Request>,
|
||||
sender: mpsc::Sender<Request>,
|
||||
/// Clients connected now (an MCP server keeps one open; horizon-ctl one per command).
|
||||
clients: Arc<AtomicUsize>,
|
||||
/// Set when the server is dropped: the listener stops accepting and exits.
|
||||
closed: Arc<AtomicBool>,
|
||||
/// The TCP port, to wake the listener when closing.
|
||||
port: Option<u16>,
|
||||
}
|
||||
|
||||
/// `$HORIZON_CONTROL`, else `horizon-control.sock` in the user's runtime
|
||||
@@ -174,10 +184,15 @@ impl Server {
|
||||
}
|
||||
let (sender, requests) = mpsc::channel();
|
||||
let local = sender.clone();
|
||||
let clients = Arc::new(AtomicUsize::new(0));
|
||||
let counted = clients.clone();
|
||||
let closed = Arc::new(AtomicBool::new(false));
|
||||
let mut tcp_port = None;
|
||||
if uses_tcp(path) {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
let token = random_token();
|
||||
let port = listener.local_addr()?.port();
|
||||
tcp_port = Some(port);
|
||||
write_private(
|
||||
path,
|
||||
&serde_json::json!({ "transport": "tcp", "port": port, "token": token })
|
||||
@@ -186,6 +201,8 @@ impl Server {
|
||||
spawn_listener(
|
||||
move || listener.accept().map(|(s, _)| s),
|
||||
sender,
|
||||
counted,
|
||||
closed.clone(),
|
||||
move |stream: TcpStream| {
|
||||
let _ = stream.set_nodelay(true);
|
||||
let mut stream = Stream::Tcp(stream);
|
||||
@@ -199,6 +216,8 @@ impl Server {
|
||||
spawn_listener(
|
||||
move || listener.accept().map(|(s, _)| s),
|
||||
sender,
|
||||
counted,
|
||||
closed.clone(),
|
||||
|s| Some(Stream::Unix(s)),
|
||||
)?;
|
||||
}
|
||||
@@ -207,6 +226,9 @@ impl Server {
|
||||
path: path.to_owned(),
|
||||
requests,
|
||||
sender: local,
|
||||
clients,
|
||||
closed,
|
||||
port: tcp_port,
|
||||
})
|
||||
}
|
||||
/// Commands from inside the process only (`--benchmark` without `--control`).
|
||||
@@ -216,8 +238,15 @@ impl Server {
|
||||
path: PathBuf::new(),
|
||||
requests,
|
||||
sender,
|
||||
clients: Arc::default(),
|
||||
closed: Arc::default(),
|
||||
port: None,
|
||||
}
|
||||
}
|
||||
/// How many clients are connected.
|
||||
pub fn clients(&self) -> usize {
|
||||
self.clients.load(Ordering::Relaxed)
|
||||
}
|
||||
/// Sends commands as a socket client would, from another thread.
|
||||
pub fn sender(&self) -> mpsc::Sender<Request> {
|
||||
self.sender.clone()
|
||||
@@ -234,6 +263,8 @@ impl Server {
|
||||
fn spawn_listener<S: Send + 'static>(
|
||||
mut accept_next: impl FnMut() -> io::Result<S> + Send + 'static,
|
||||
sender: mpsc::Sender<Request>,
|
||||
clients: Arc<AtomicUsize>,
|
||||
closed: Arc<AtomicBool>,
|
||||
accept: impl Fn(S) -> Option<Stream> + Send + Sync + 'static,
|
||||
) -> io::Result<()> {
|
||||
let accept = std::sync::Arc::new(accept);
|
||||
@@ -241,17 +272,24 @@ fn spawn_listener<S: Send + 'static>(
|
||||
.name("horizon-control".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
let Ok(stream) = accept_next() else {
|
||||
let next = accept_next();
|
||||
if closed.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let Ok(stream) = next else {
|
||||
thread::sleep(std::time::Duration::from_millis(10));
|
||||
continue;
|
||||
};
|
||||
let sender = sender.clone();
|
||||
let accept = accept.clone();
|
||||
let clients = clients.clone();
|
||||
let _ = thread::Builder::new()
|
||||
.name("horizon-control-client".into())
|
||||
.spawn(move || {
|
||||
if let Some(stream) = accept(stream) {
|
||||
clients.fetch_add(1, Ordering::Relaxed);
|
||||
serve(stream, &sender);
|
||||
clients.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -287,6 +325,18 @@ fn authenticate(stream: &mut Stream, token: &str) -> bool {
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
if self.path.as_os_str().is_empty() {
|
||||
return;
|
||||
}
|
||||
// Wake the listener so it sees `closed` and exits (agent control turned off in-game).
|
||||
self.closed.store(true, Ordering::Relaxed);
|
||||
match self.port {
|
||||
Some(port) => drop(TcpStream::connect((Ipv4Addr::LOCALHOST, port))),
|
||||
#[cfg(unix)]
|
||||
None => drop(UnixStream::connect(&self.path)),
|
||||
#[cfg(not(unix))]
|
||||
None => {}
|
||||
}
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@ use std::{
|
||||
pub const CAPACITY: usize = 240;
|
||||
/// `AbstractDebugChart.CHART_HEIGHT`.
|
||||
pub const CHART_HEIGHT: i32 = 60;
|
||||
/// `TpsDebugDimensions`: a tick sample's values.
|
||||
pub const FULL_TICK: usize = 0;
|
||||
pub const TICK_SERVER_METHOD: usize = 1;
|
||||
pub const SCHEDULED_TASKS: usize = 2;
|
||||
pub const IDLE: usize = 3;
|
||||
@@ -252,10 +250,6 @@ impl Chart {
|
||||
pub fn width(max_width: i32) -> i32 {
|
||||
(CAPACITY as i32 + 2).min(max_width)
|
||||
}
|
||||
/// `getFullHeight`: the box and the min/avg/max line above it.
|
||||
pub fn full_height() -> i32 {
|
||||
CHART_HEIGHT + 9
|
||||
}
|
||||
/// `AbstractDebugChart.extractRenderState`.
|
||||
pub fn draw(
|
||||
self,
|
||||
|
||||
@@ -359,10 +359,6 @@ impl Enchantments {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Whether a stack draws the enchantment glint.
|
||||
pub fn has_glint(&self, stack: &ItemStack) -> bool {
|
||||
stack.has_any_enchantments() || self.props(&stack.item).is_some_and(|p| p.glint)
|
||||
}
|
||||
|
||||
// ---- Enchanting table (EnchantmentHelper) ----
|
||||
|
||||
@@ -514,19 +510,6 @@ impl Enchantments {
|
||||
.is_some_and(|e| e.effects.get(component).is_some())
|
||||
})
|
||||
}
|
||||
/// The highest level among enchantments on `stack` that have `component`.
|
||||
pub fn component_level(&self, stack: &ItemStack, component: &str) -> u32 {
|
||||
stack
|
||||
.enchantments()
|
||||
.iter()
|
||||
.filter(|(id, _)| {
|
||||
self.get(id)
|
||||
.is_some_and(|e| e.effects.get(component).is_some())
|
||||
})
|
||||
.map(|(_, l)| *l)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
/// `minecraft:attributes` contributions of `stack`: (attribute, id, amount, operation).
|
||||
pub fn attributes(&self, stack: &ItemStack) -> Vec<(String, String, f64, String)> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -98,12 +98,73 @@ impl ItemEntity {
|
||||
lava_hurt,
|
||||
}
|
||||
}
|
||||
/// Returns false when the item should be removed.
|
||||
/// `ItemEntity.tick` on a server: an item whose box is inside a block
|
||||
/// (one placed where it lay) has no physics this tick
|
||||
/// (`level.noCollision(box.deflate(1.0E-7))`).
|
||||
pub fn inside_block(&self, world: &World, collision: &BlockCollisionRegistry<'_>) -> bool {
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
let inner = self.bounds().deflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
|
||||
!crate::collisions::no_collision_empty(&mut scene, inner)
|
||||
}
|
||||
/// `Entity.moveTowardsClosestSpace` from the middle of the item's box:
|
||||
/// towards the nearest side of its block (north, south, west, east or
|
||||
/// up) without a full collision cube, at `speed` (`nextFloat() * 0.2 +
|
||||
/// 0.1`).
|
||||
pub fn move_towards_closest_space(&mut self, world: &World, facts: &BlockFacts, speed: f32) {
|
||||
let bounds = self.bounds();
|
||||
let at = Vec3::new(
|
||||
self.position.x,
|
||||
(bounds.min_position().y + bounds.max_position().y) / 2.0,
|
||||
self.position.z,
|
||||
);
|
||||
let pos = [
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
];
|
||||
let delta = [
|
||||
at.x - f64::from(pos[0]),
|
||||
at.y - f64::from(pos[1]),
|
||||
at.z - f64::from(pos[2]),
|
||||
];
|
||||
// (axis, step): NORTH, SOUTH, WEST, EAST, UP.
|
||||
let mut closest = (1, 1);
|
||||
let mut nearest = f64::MAX;
|
||||
for (axis, step) in [(2, -1), (2, 1), (0, -1), (0, 1), (1, 1)] {
|
||||
let mut n = pos;
|
||||
n[axis] += step;
|
||||
if !facts.get(world.block(n[0], n[1], n[2])).full_collision {
|
||||
let d = if step > 0 {
|
||||
1.0 - delta[axis]
|
||||
} else {
|
||||
delta[axis]
|
||||
};
|
||||
if d < nearest {
|
||||
nearest = d;
|
||||
closest = (axis, step);
|
||||
}
|
||||
}
|
||||
}
|
||||
let push = f64::from(closest.1 as f32 * speed);
|
||||
let v = self.velocity.scale(0.75);
|
||||
self.velocity = match closest.0 {
|
||||
0 => Vec3::new(push, v.y, v.z),
|
||||
1 => Vec3::new(v.x, push, v.z),
|
||||
_ => Vec3::new(v.x, v.y, push),
|
||||
};
|
||||
}
|
||||
/// Returns false when the item should be removed. `no_physics`: the item
|
||||
/// moves through blocks this tick ([`ItemEntity::inside_block`]).
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
no_physics: bool,
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -140,15 +201,22 @@ impl ItemEntity {
|
||||
subject: None,
|
||||
};
|
||||
let wanted = self.velocity;
|
||||
let moved = scene
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO);
|
||||
// Entity.move with noPhysics: straight through, the ground as it was.
|
||||
let moved = if no_physics {
|
||||
wanted
|
||||
} else {
|
||||
scene
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
};
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
|
||||
if !no_physics {
|
||||
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
|
||||
}
|
||||
if moved.x != wanted.x {
|
||||
self.velocity.x = 0.0;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,47 @@ impl Base<'_> {
|
||||
}
|
||||
|
||||
/// `LivingEntity` + `Mob` (+ `AgeableMob`, creeper and skeleton specifics).
|
||||
/// The mobs whose class extends vanilla's `Animal` (and so save `InLove`):
|
||||
/// not allays, villagers, golems, dolphins or fish.
|
||||
fn is_animal(id: &str) -> bool {
|
||||
matches!(
|
||||
id.strip_prefix("minecraft:").unwrap_or(id),
|
||||
"pig"
|
||||
| "cow"
|
||||
| "mooshroom"
|
||||
| "sheep"
|
||||
| "chicken"
|
||||
| "rabbit"
|
||||
| "horse"
|
||||
| "donkey"
|
||||
| "mule"
|
||||
| "zombie_horse"
|
||||
| "skeleton_horse"
|
||||
| "llama"
|
||||
| "trader_llama"
|
||||
| "goat"
|
||||
| "panda"
|
||||
| "polar_bear"
|
||||
| "fox"
|
||||
| "wolf"
|
||||
| "cat"
|
||||
| "ocelot"
|
||||
| "parrot"
|
||||
| "bee"
|
||||
| "turtle"
|
||||
| "armadillo"
|
||||
| "frog"
|
||||
| "axolotl"
|
||||
| "hoglin"
|
||||
| "strider"
|
||||
| "camel"
|
||||
| "camel_husk"
|
||||
| "sniffer"
|
||||
| "happy_ghast"
|
||||
| "nautilus"
|
||||
| "zombie_nautilus"
|
||||
)
|
||||
}
|
||||
pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let base = Base {
|
||||
id: &m.kind.id,
|
||||
@@ -175,12 +216,15 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
for (key, value) in [
|
||||
("Age", Tag::Int(0)),
|
||||
("ForcedAge", Tag::Int(0)),
|
||||
("InLove", Tag::Int(0)),
|
||||
("AgeLocked", Tag::Byte(0)),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
|
||||
if is_animal(id) {
|
||||
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
|
||||
}
|
||||
match id {
|
||||
"minecraft:creeper" => {
|
||||
for (key, value) in [("Fuse", Tag::Short(30)), ("ExplosionRadius", Tag::Byte(3))] {
|
||||
@@ -445,10 +489,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
// Sheep.addAdditionalSaveData: its wool as it is now.
|
||||
"minecraft:sheep" => {
|
||||
for (key, value) in [("Color", Tag::Byte(0)), ("Sheared", Tag::Byte(0))] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
map.insert("Color".into(), Tag::Byte((m.extra.color & 15) as i8));
|
||||
map.insert("Sheared".into(), Tag::Byte(i8::from(m.extra.sheared)));
|
||||
}
|
||||
// CopperGolem.addAdditionalSaveData.
|
||||
"minecraft:copper_golem" => {
|
||||
@@ -621,6 +665,9 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
|
||||
m.extra.age_locked = flag(tag, "AgeLocked");
|
||||
}
|
||||
if is_animal(&m.kind.id) {
|
||||
m.extra.in_love = number(tag.get("InLove")).unwrap_or(0) as i32;
|
||||
}
|
||||
m.name_visible = flag(tag, "CustomNameVisible");
|
||||
m.no_ai = flag(tag, "NoAI");
|
||||
m.no_gravity = flag(tag, "NoGravity");
|
||||
@@ -832,6 +879,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
}
|
||||
m.extra.egg_time = number(tag.get("life_ticks")).map_or(-1, |v| v as i32);
|
||||
}
|
||||
// Sheep.readAdditionalSaveData: white and unsheared when absent.
|
||||
if m.species() == crate::mobs::Species::Sheep {
|
||||
m.extra.color = number(tag.get("Color")).map_or(0, |c| (c as u8) & 15);
|
||||
m.extra.sheared = flag(tag, "Sheared");
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Shulker {
|
||||
m.extra.variant3 = number(tag.get("AttachFace")).unwrap_or(0).clamp(0, 5) as u8;
|
||||
m.extra.counter = number(tag.get("Peek")).unwrap_or(0).clamp(0, 100) as i32;
|
||||
@@ -2051,3 +2103,39 @@ mod axolotl_nbt_tests {
|
||||
assert_eq!(mm.temptation_cooldown_ticks.value(), Some(42));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sheep_nbt_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn a_sheep_keeps_its_dyed_and_sheared_wool() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
Arc::new(data.types["minecraft:sheep"].clone()),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
// Summoned white, then dyed red and sheared: the save has it now.
|
||||
let raw = Tag::compound([("Color", Tag::Byte(0)), ("Sheared", Tag::Byte(0))]);
|
||||
(m.extra.color, m.extra.sheared) = (14, true);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw));
|
||||
assert_eq!(
|
||||
(number(tag.get("Color")), number(tag.get("Sheared"))),
|
||||
(Some(14), Some(1))
|
||||
);
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,9 @@ pub struct Extras {
|
||||
pub sort_key: String,
|
||||
/// No AI: every AI feature hidden and off (`horizon_core::no_ai`; the launcher writes it too).
|
||||
pub no_ai: bool,
|
||||
/// Agents may control the game the player starts: it opens its control socket for
|
||||
/// horizon-ctl and MCP clients (off by default; an agent's own launch always opens it).
|
||||
pub agent_control: bool,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -138,6 +141,7 @@ impl Default for Extras {
|
||||
sort: false,
|
||||
sort_key: "key.keyboard.unknown".into(),
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +206,7 @@ impl Extras {
|
||||
"sort" => e.sort = flag(e.sort),
|
||||
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -209,7 +214,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -240,6 +245,7 @@ impl Extras {
|
||||
self.sort_key,
|
||||
horizon_core::no_ai::KEY,
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
|
||||
@@ -457,6 +463,9 @@ pub enum Item {
|
||||
MouseTweaksWheel,
|
||||
Sort,
|
||||
SortKey,
|
||||
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
|
||||
NoAi,
|
||||
AgentControl,
|
||||
}
|
||||
/// The Extras page: headers and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
@@ -535,6 +544,8 @@ impl Item {
|
||||
Item::FpsLows => "Minimum and 1% low",
|
||||
Item::Keystrokes => "Keystrokes",
|
||||
Item::KeystrokesCps => "Clicks per second",
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
}
|
||||
}
|
||||
/// The switch this setting belongs to: it does nothing while that is off.
|
||||
@@ -548,7 +559,7 @@ impl Item {
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort
|
||||
| AutoReconnect => {
|
||||
| AutoReconnect | NoAi | AgentControl => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -592,6 +603,12 @@ impl Item {
|
||||
}
|
||||
Item::Keystrokes => "Movement keys, jump and mouse buttons as you press them.",
|
||||
Item::KeystrokesCps => "Clicks in the last second under each mouse button.",
|
||||
Item::NoAi => {
|
||||
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
|
||||
}
|
||||
Item::AgentControl => {
|
||||
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The frame-time cost the main switch of an extra shows.
|
||||
@@ -622,6 +639,8 @@ impl Item {
|
||||
Item::MouseTweaks => e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort,
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -677,6 +696,8 @@ impl Item {
|
||||
}
|
||||
Item::MinimapShape => e.minimap_round = !e.minimap_round,
|
||||
Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back),
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::ZoomKey | Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,15 +289,6 @@ impl Default for HandHeights {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What `FirstPersonHandsAndItems.tick` decides about a hand's visible item.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct HandChange {
|
||||
/// The visible item is not the hand's item after the instant replacement
|
||||
/// (`mainHandItem != nextMainHand`): the hand lowers.
|
||||
pub changed: bool,
|
||||
/// The visible item becomes the hand's item this tick.
|
||||
pub replace: bool,
|
||||
}
|
||||
impl HandHeights {
|
||||
/// `FirstPersonHandsAndItems.tick`'s heights. `changed` says per hand
|
||||
/// whether the visible item differs from the held one once
|
||||
|
||||
+824
-225
File diff suppressed because it is too large.
Load diff
+291
-127
@@ -3,6 +3,7 @@
|
||||
mod advancements;
|
||||
mod advancements_screen;
|
||||
mod agent;
|
||||
mod agent_settings;
|
||||
mod allays;
|
||||
mod armor_stands;
|
||||
mod banners;
|
||||
@@ -83,6 +84,7 @@ mod level_particles;
|
||||
mod levels;
|
||||
mod lightning;
|
||||
mod llamas;
|
||||
mod locator_bar;
|
||||
mod loom;
|
||||
mod mace;
|
||||
mod machines;
|
||||
@@ -119,6 +121,7 @@ mod pumpkin;
|
||||
mod raids;
|
||||
mod recipe_book;
|
||||
mod remote;
|
||||
mod remote_mob_data;
|
||||
mod remote_objects;
|
||||
mod remote_projectiles;
|
||||
mod remote_stations;
|
||||
@@ -160,6 +163,7 @@ mod ui_control;
|
||||
mod vault;
|
||||
mod villagers;
|
||||
mod wandering_trader;
|
||||
mod win;
|
||||
mod wind;
|
||||
mod wither;
|
||||
mod world_loading;
|
||||
@@ -264,6 +268,9 @@ struct Options {
|
||||
/// Render into a texture instead of the window (`--offscreen`), so a
|
||||
/// GPU the display cannot present from can still be benchmarked.
|
||||
offscreen: bool,
|
||||
/// `--width` and `--height` (vanilla's own arguments): the window's
|
||||
/// starting size, 1280x720 by default.
|
||||
window_size: [u32; 2],
|
||||
/// Multiplayer: `--server HOST[:PORT]` joins at start; `--name` is the offline player name.
|
||||
server: Option<String>,
|
||||
/// `--name`, `HORIZON_PLAYER_NAME` or the signed-in account's name; else
|
||||
@@ -276,9 +283,11 @@ struct Options {
|
||||
benchmark: Option<String>,
|
||||
/// `--dev-mod PATH` (repeatable): mods always on and reloaded when rebuilt.
|
||||
dev_mods: Vec<PathBuf>,
|
||||
/// Single player runs on an integrated server thread (game/integrated.rs),
|
||||
/// as vanilla's does: the default. `--no-server-thread` or
|
||||
/// `HORIZON_SERVER_THREAD=0` runs the world in the frame instead.
|
||||
/// `--server-thread` or `HORIZON_SERVER_THREAD=1`: single player runs on an
|
||||
/// integrated server thread (game/integrated.rs), as vanilla's does. Off by
|
||||
/// default again until a server's players reach parity with the in-frame
|
||||
/// world (signs, books, totems, keep_inventory...); `--no-server-thread`
|
||||
/// keeps it off.
|
||||
server_thread: bool,
|
||||
/// Set on the integrated server's own options: it draws nothing, so it skips meshing.
|
||||
integrated: bool,
|
||||
@@ -323,6 +332,7 @@ impl Options {
|
||||
no_pause: false,
|
||||
control: None,
|
||||
offscreen: false,
|
||||
window_size: [1280, 720],
|
||||
spikes: std::env::var("HORIZON_SPIKES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok()),
|
||||
@@ -335,7 +345,7 @@ impl Options {
|
||||
dev_mods: Vec::new(),
|
||||
// The command harness (HORIZON_COMMAND_SCRIPT) drives the world
|
||||
// itself, without the event loop: it stays in the frame.
|
||||
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0")
|
||||
server_thread: std::env::var("HORIZON_SERVER_THREAD").is_ok_and(|v| v == "1")
|
||||
&& std::env::var_os("HORIZON_COMMAND_SCRIPT").is_none(),
|
||||
integrated: false,
|
||||
};
|
||||
@@ -345,7 +355,7 @@ impl Options {
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --no-server-thread Run single player's world in the frame, not on its server thread\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --server-thread Run single player's world on its server thread (not at parity yet)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
horizon_core::VANILLA_VERSION
|
||||
);
|
||||
return Ok(None);
|
||||
@@ -394,6 +404,8 @@ impl Options {
|
||||
"--timeout-seconds" => o.timeout = Duration::from_secs(value.parse()?),
|
||||
"--smoke-report" => o.report = Some(value.into()),
|
||||
"--screenshot-after" => o.screenshot_after = Some(value.parse()?),
|
||||
"--width" => o.window_size[0] = value.parse::<u32>()?.max(1),
|
||||
"--height" => o.window_size[1] = value.parse::<u32>()?.max(1),
|
||||
"--world" => o.world = Some(value),
|
||||
"--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?),
|
||||
"--benchmark" => {
|
||||
@@ -462,6 +474,8 @@ enum Screen {
|
||||
Friends,
|
||||
/// CommandBlockEditScreen (game/command_blocks.rs).
|
||||
CommandBlock,
|
||||
/// WinScreen: the end poem and the credits (game/win.rs).
|
||||
Win,
|
||||
}
|
||||
struct Mining {
|
||||
pos: [i32; 3],
|
||||
@@ -524,6 +538,7 @@ struct GameLevel<'w> {
|
||||
spill: &'w mut spill::Spill,
|
||||
/// Where the chunk tickets let blocks tick.
|
||||
tickets: horizon_client::ticket_levels::Levels,
|
||||
random_tick_sections: &'w mut horizon_client::random_tick_sections::RandomTickSections,
|
||||
}
|
||||
struct PlayerCollision {
|
||||
y: f64,
|
||||
@@ -1006,9 +1021,11 @@ pub struct UiResources {
|
||||
pub panorama: Arc<horizon_client::gfx::panorama::Faces>,
|
||||
}
|
||||
impl UiResources {
|
||||
pub fn load(root: &std::path::Path) -> Result<Self> {
|
||||
/// From a resource root layering packs over `defaults`, the game's own
|
||||
/// resources (`root` itself when no pack is on).
|
||||
pub fn load(root: &std::path::Path, defaults: &std::path::Path) -> Result<Self> {
|
||||
Ok(Self {
|
||||
atlas: HudAtlas::build(root)?,
|
||||
atlas: HudAtlas::build_over(root, defaults)?,
|
||||
panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(root)),
|
||||
})
|
||||
}
|
||||
@@ -1354,6 +1371,8 @@ struct Game<'a> {
|
||||
respawn_travel: Option<respawn_anchor::RespawnConfig>,
|
||||
/// The next enter_dimension call comes from an End portal.
|
||||
end_portal_travel: bool,
|
||||
/// The open WinScreen (game/win.rs).
|
||||
win: Option<win::Win>,
|
||||
end: end::EndState,
|
||||
block_ticks: Ticks,
|
||||
tick_chunks: horizon_client::tick_chunk_membership::TickChunkMembership,
|
||||
@@ -1373,6 +1392,14 @@ struct Game<'a> {
|
||||
/// Entities of types Horizon does not simulate.
|
||||
others: Vec<commands::OtherEntity>,
|
||||
armor_stand_assets: armor_stands::ArmorStandAssets,
|
||||
/// `WaypointStyleManager`.
|
||||
waypoint_styles: horizon_client::locator_bar::WaypointStyles,
|
||||
/// `ClientWaypointManager`: the locator bar's waypoints.
|
||||
waypoints: horizon_client::locator_bar::ClientWaypoints,
|
||||
/// The camera the last frame drew from (position, yaw, pitch, fov), for the locator bar.
|
||||
locator_camera: Option<([f64; 3], f32, f32, f32)>,
|
||||
/// `LocalPlayer.experienceDisplayStartTick`: (last progress, seen a change, tick).
|
||||
experience_display: (f32, bool, u64),
|
||||
boats: Vec<boats::Boat>,
|
||||
boat_models: boats::BoatModels,
|
||||
/// EndCrystalModel's baked layer.
|
||||
@@ -1395,6 +1422,8 @@ struct Game<'a> {
|
||||
/// (least recently shown first).
|
||||
skin_downloads: RefCell<horizon_client::skins::Downloader>,
|
||||
skin_slots: Vec<(String, usize)>,
|
||||
/// Atlas trim slots in use: (sprite name, slot), least recently drawn first.
|
||||
trim_slots: Vec<(String, usize)>,
|
||||
cart_models: minecarts::CartModels,
|
||||
/// The boat the player rides.
|
||||
riding: Option<u32>,
|
||||
@@ -1427,6 +1456,10 @@ struct Game<'a> {
|
||||
item_columns: Option<machines::ItemColumns>,
|
||||
creative_screen: Creative,
|
||||
control: Option<horizon_client::control::Server>,
|
||||
/// The Agent Control setting the socket was last opened or closed for (`agent_settings`).
|
||||
agent_control_on: bool,
|
||||
/// When an agent's last command came.
|
||||
agent_last: Option<Instant>,
|
||||
frame_stats: horizon_client::control::FrameStats,
|
||||
/// This play session's frame and tick times, for opt-in performance statistics.
|
||||
perf: horizon_client::telemetry::Session,
|
||||
@@ -2084,7 +2117,14 @@ impl Game<'_> {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let alive = item.tick(&self.world, self.res.facts(), self.collision);
|
||||
// ItemEntity.tick's noPhysics (servers only): out of a block
|
||||
// placed on it, towards the closest open side (#1252).
|
||||
let no_physics = merging && item.inside_block(&self.world, self.collision);
|
||||
if no_physics {
|
||||
let speed = self.world.random.next_float() * 0.2 + 0.1;
|
||||
item.move_towards_closest_space(&self.world, self.res.facts(), speed);
|
||||
}
|
||||
let alive = item.tick(&self.world, self.res.facts(), self.collision, no_physics);
|
||||
cells.moved(i, item.previous, item.position);
|
||||
// ItemEntity.tick: merge every 2 ticks while moving between
|
||||
// blocks, every 40 otherwise, before the lifetime check.
|
||||
@@ -2291,6 +2331,7 @@ impl Game<'_> {
|
||||
pending: &mut self.pending,
|
||||
spill: &mut self.spill,
|
||||
tickets: self.tickets.levels(),
|
||||
random_tick_sections: &mut self.random_tick_sections,
|
||||
};
|
||||
let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks);
|
||||
self.flush_spill();
|
||||
@@ -2385,18 +2426,15 @@ impl Game<'_> {
|
||||
self.sync_tick_chunks();
|
||||
self.start_ticking_chunks();
|
||||
let levels = self.tickets.levels();
|
||||
let mut ticking = Vec::new();
|
||||
let facts = self.res.facts();
|
||||
for (&(x, z), chunk) in &self.world.chunks {
|
||||
if !levels.random_ticking(x, z) || !ticking_chunk(&self.world, x, z) {
|
||||
continue;
|
||||
}
|
||||
// Capture eligibility before inside-block and scheduled updates, as
|
||||
// before. Keep the world map's chunk order, including empty chunks
|
||||
// (tick_chunk_random still draws for precipitation in those).
|
||||
let sections = self.random_tick_sections.get((x, z), chunk, facts);
|
||||
ticking.push((x, z, sections));
|
||||
}
|
||||
// Keep the world map's chunk order, including empty chunks
|
||||
// (tick_chunk_random still draws for precipitation in those).
|
||||
let ticking: Vec<_> = self
|
||||
.world
|
||||
.chunks
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|&(x, z)| levels.random_ticking(x, z) && ticking_chunk(&self.world, x, z))
|
||||
.collect();
|
||||
// Entity.checkInsideBlocks for the plates, tripwires and detector rails
|
||||
// under the players (the host and the LAN guests) and the minecarts.
|
||||
let mut inside = Vec::new();
|
||||
@@ -2438,13 +2476,24 @@ impl Game<'_> {
|
||||
self.process_level_events()?;
|
||||
self.tick_scheduled()?;
|
||||
self.with_level(|b, level, ticks| {
|
||||
for (x, z, sections) in ticking {
|
||||
b.tick_chunk_random(
|
||||
level,
|
||||
ticks,
|
||||
horizon_core::position::ChunkPos::new(x, z),
|
||||
sections,
|
||||
);
|
||||
for (x, z) in ticking {
|
||||
// The sections are read again only after a block changed.
|
||||
let mut read = None;
|
||||
let mut sections = Default::default();
|
||||
let pos = horizon_core::position::ChunkPos::new(x, z);
|
||||
b.tick_chunk_random(level, ticks, pos, |level, from| {
|
||||
if read != Some(level.world.revision) {
|
||||
read = Some(level.world.revision);
|
||||
sections = level
|
||||
.world
|
||||
.chunks
|
||||
.get(&(x, z))
|
||||
.map_or_else(Default::default, |chunk| {
|
||||
level.random_tick_sections.get((x, z), chunk, level.facts)
|
||||
});
|
||||
}
|
||||
sections.from(from)
|
||||
});
|
||||
}
|
||||
b.run_block_events(level, ticks);
|
||||
b.tick_block_entities(level, ticks);
|
||||
@@ -2528,6 +2577,17 @@ impl Game<'_> {
|
||||
LevelEvent::LevelEvent { id: 2004, pos, .. } => {
|
||||
self.spawner_burst([pos.x, pos.y, pos.z])
|
||||
}
|
||||
// LevelEventHandler 1505: the growth particles, then the sound.
|
||||
LevelEvent::LevelEvent {
|
||||
id: 1505,
|
||||
pos,
|
||||
data,
|
||||
} => {
|
||||
self.growth_particles(pos, data);
|
||||
let at = [pos.x, pos.y, pos.z].map(|v| f64::from(v) + 0.5);
|
||||
self.audio
|
||||
.play("minecraft:item.bone_meal.use", Some(at), 1.0, 1.0);
|
||||
}
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2015,
|
||||
pos,
|
||||
@@ -2571,7 +2631,6 @@ impl Game<'_> {
|
||||
1030 => Some(("minecraft:block.anvil.use", 1.0)),
|
||||
1029 => Some(("minecraft:block.anvil.destroy", 1.0)),
|
||||
1031 => Some(("minecraft:block.anvil.land", 0.3)),
|
||||
1505 => Some(("minecraft:item.bone_meal.use", 1.0)),
|
||||
1042 => Some(("minecraft:block.grindstone.use", 1.0)),
|
||||
1044 => Some(("minecraft:block.smithing_table.use", 1.0)),
|
||||
// LevelEvent.SOUND_PAGE_TURN (a lectern's page).
|
||||
@@ -3555,8 +3614,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
fn tick_using(&mut self) {
|
||||
// ItemCooldowns.tick for the shield.
|
||||
self.shield_cooldown = self.shield_cooldown.saturating_sub(1);
|
||||
let Some(hand) = self.used_hand() else {
|
||||
return;
|
||||
};
|
||||
@@ -3772,6 +3829,9 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((_, target)) = &self.travelling {
|
||||
if self.win_screen_holds_arrival() {
|
||||
return Ok(());
|
||||
}
|
||||
let (cx, cz) = (
|
||||
(target.x.floor() as i32) >> 4,
|
||||
(target.z.floor() as i32) >> 4,
|
||||
@@ -3899,6 +3959,7 @@ impl Game<'_> {
|
||||
None => 0.0,
|
||||
};
|
||||
self.ticks += 1;
|
||||
self.track_experience_display();
|
||||
// `/tick freeze` stops the world but not the player.
|
||||
let running = self.clock.world_runs();
|
||||
if running {
|
||||
@@ -4257,7 +4318,10 @@ impl Game<'_> {
|
||||
/// world's entities and blocks, and the network hub.
|
||||
fn tick_world(&mut self, running: bool) -> Result<()> {
|
||||
self.update_chunk_tickets();
|
||||
horizon_client::spans::time("tick_effects", || self.tick_effects());
|
||||
horizon_client::spans::time("tick_effects", || self.tick_effects(running));
|
||||
// Player.tick's ItemCooldowns.tick for the window's player (a
|
||||
// server's players count theirs down in `tick_guests`).
|
||||
self.shield_cooldown = self.shield_cooldown.saturating_sub(1);
|
||||
horizon_client::spans::time("tick_using", || self.tick_using());
|
||||
horizon_client::spans::time("tick_hud", || self.tick_hud());
|
||||
// The player's digging comes in with its packets, before the level
|
||||
@@ -4458,7 +4522,8 @@ impl Game<'_> {
|
||||
| Screen::WorldOptions
|
||||
| Screen::Friends
|
||||
| Screen::CommandBlock
|
||||
) || self.book.is_some())
|
||||
) || self.book.is_some()
|
||||
|| self.win_screen_pauses())
|
||||
}
|
||||
/// `MouseHandler.grabMouse` / `releaseMouse`. Both put the pointer at
|
||||
/// the window's centre, so a screen opened from gameplay (inventory,
|
||||
@@ -4673,6 +4738,10 @@ impl Game<'_> {
|
||||
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
let mut batch = HudBatch::new(size);
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
if self.screen == Screen::Win {
|
||||
self.draw_win_screen(&mut batch);
|
||||
return batch;
|
||||
}
|
||||
if self.screen == Screen::Options {
|
||||
self.draw_options(&mut batch);
|
||||
return batch;
|
||||
@@ -4753,12 +4822,41 @@ impl Game<'_> {
|
||||
self.draw_extras(&mut batch);
|
||||
}
|
||||
if show_hud {
|
||||
// The mount's hearts (and the jump bar that replaces the experience bar).
|
||||
let (vehicle_hearts, jump_bar) = if spectator {
|
||||
(0, false)
|
||||
// Hud.nextContextualInfoState: the jump bar, the experience bar or
|
||||
// the locator bar above the hotbar.
|
||||
let has_experience = !self.creative && !spectator;
|
||||
let contextual = horizon_client::locator_bar::ContextualBar::next(
|
||||
self.waypoints.has_waypoints(),
|
||||
!spectator && self.jumpable_mount(),
|
||||
self.mount_jumping(),
|
||||
has_experience,
|
||||
self.experience_recent(),
|
||||
);
|
||||
use horizon_client::locator_bar::ContextualBar;
|
||||
// The mount's hearts (and the jump bar).
|
||||
let vehicle_hearts = if spectator {
|
||||
0
|
||||
} else {
|
||||
self.draw_mount_hud(&mut batch, wi, hi)
|
||||
self.draw_mount_hud(
|
||||
&mut batch,
|
||||
wi,
|
||||
hi,
|
||||
contextual == ContextualBar::JumpableVehicle,
|
||||
)
|
||||
};
|
||||
if contextual == ContextualBar::Locator {
|
||||
let (sprite, [x, y, w, h]) = horizon_client::locator_bar::background(wi, hi);
|
||||
horizon_client::ui::nine_slice(
|
||||
&mut batch,
|
||||
&self.gui.atlas,
|
||||
sprite,
|
||||
x as f32,
|
||||
y as f32,
|
||||
w as f32,
|
||||
h as f32,
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
if !self.creative
|
||||
&& !spectator
|
||||
&& let Some(p) = &mut self.player
|
||||
@@ -4770,7 +4868,7 @@ impl Game<'_> {
|
||||
hi,
|
||||
self.experience.progress,
|
||||
self.experience.level,
|
||||
!jump_bar,
|
||||
contextual == ContextualBar::Experience,
|
||||
&width,
|
||||
);
|
||||
hud_layout::paint(&mut batch, atlas, &draws);
|
||||
@@ -4820,6 +4918,10 @@ impl Game<'_> {
|
||||
let draws = self.health_hud.layout(&inputs, wi, hi);
|
||||
hud_layout::paint(&mut batch, atlas, &draws);
|
||||
}
|
||||
if contextual == ContextualBar::Locator {
|
||||
let draws = self.locator_bar_draws(wi, hi);
|
||||
hud_layout::paint(&mut batch, &self.gui.atlas, &draws);
|
||||
}
|
||||
if !spectator {
|
||||
self.draw_selected_name(&mut batch, !self.creative);
|
||||
}
|
||||
@@ -4874,7 +4976,7 @@ impl Game<'_> {
|
||||
self.draw_creative(&mut batch);
|
||||
}
|
||||
Screen::Chat => self.draw_chat_screen(&mut batch),
|
||||
Screen::Playing | Screen::Options => {}
|
||||
Screen::Playing | Screen::Options | Screen::Win => {}
|
||||
}
|
||||
self.draw_toasts(&mut batch);
|
||||
self.remote_hud(&mut batch);
|
||||
@@ -6461,6 +6563,7 @@ impl Game<'_> {
|
||||
mut out: Vec<Vertex>,
|
||||
) -> Vec<Vertex> {
|
||||
self.prepare_entity_culling(camera);
|
||||
self.prepare_trims();
|
||||
let fire_yaw = if self.camera_type == perspective::CameraType::ThirdPersonFront {
|
||||
self.yaw + 180.0
|
||||
} else {
|
||||
@@ -6946,10 +7049,16 @@ impl Game<'_> {
|
||||
// Item.getAttackDamageBonus (a mace's smash), before the critical hit.
|
||||
amount += self.smash_damage_bonus(target_kind);
|
||||
let player = self.player.as_ref();
|
||||
// Player.canCriticalAttack: falling, off the ground, not sprinting.
|
||||
let falling =
|
||||
player.is_some_and(|p| !p.ground.on_ground && p.fall_distance > 0.0 && !p.sprinting);
|
||||
let crit = scale > 0.9 && falling;
|
||||
// Player.canCriticalAttack: falling, off the ground, not in water,
|
||||
// not blind (isMobilityRestricted), not riding, not sprinting.
|
||||
let falling = player.is_some_and(|p| {
|
||||
!p.ground.on_ground
|
||||
&& p.fall_distance > 0.0
|
||||
&& !p.fluids.was_touching_water
|
||||
&& !p.sprinting
|
||||
});
|
||||
let restricted = self.potions.effects.get("blindness").is_some() || self.riding.is_some();
|
||||
let crit = scale > 0.9 && falling && !restricted;
|
||||
if crit {
|
||||
amount *= 1.5;
|
||||
}
|
||||
@@ -7458,16 +7567,11 @@ impl Game<'_> {
|
||||
self.sleep_timer = 0;
|
||||
self.spawn = None;
|
||||
self.respawn_travel = None;
|
||||
self.respawn_config =
|
||||
self.level
|
||||
.respawn_elsewhere
|
||||
.as_ref()
|
||||
.map(|(dim, pos, yaw, forced)| respawn_anchor::RespawnConfig {
|
||||
dim: Dim::from_id(dim),
|
||||
pos: *pos,
|
||||
yaw: *yaw,
|
||||
forced: *forced,
|
||||
});
|
||||
self.respawn_config = self
|
||||
.level
|
||||
.respawn_elsewhere
|
||||
.as_ref()
|
||||
.map(respawn_anchor::RespawnConfig::from_saved);
|
||||
self.spawn_origin = None;
|
||||
self.spawn_spiral = Default::default();
|
||||
self.player_spawn = None;
|
||||
@@ -7563,6 +7667,8 @@ impl Game<'_> {
|
||||
world.game_time = self.world.game_time;
|
||||
world.day_time = self.world.day_time;
|
||||
world.weather = self.world.weather;
|
||||
// The game rules and the difficulty hold in every dimension.
|
||||
world.health_rules = self.world.health_rules;
|
||||
// An open hub keeps hearing the new level's block changes.
|
||||
world.block_log = self.world.block_log.as_ref().map(|_| Vec::new());
|
||||
world.light_log = self.world.light_log.as_ref().map(|_| Default::default());
|
||||
@@ -7753,6 +7859,7 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
fn close_world(&mut self) -> Result<()> {
|
||||
self.clear_waypoints();
|
||||
self.begin_closing_save(false)
|
||||
}
|
||||
fn finish_close_world(&mut self) -> Result<()> {
|
||||
@@ -8005,13 +8112,15 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// `BlockTransformer.transformBlock` for a held axe, hoe or shovel.
|
||||
fn use_block_transformer(
|
||||
&mut self,
|
||||
/// What `BlockTransformer.transformBlock` would do with a held axe, hoe
|
||||
/// or shovel on the block at `pos`, without doing it: the new state, its
|
||||
/// sound, loot table, the durability it costs and its particle.
|
||||
pub(super) fn block_transform(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
pos: [i32; 3],
|
||||
face: [i32; 3],
|
||||
) -> Result<bool> {
|
||||
) -> Option<(BlockStateId, String, Option<String>, u32, String)> {
|
||||
use horizon_client::block_transform::Around;
|
||||
struct Column<'w> {
|
||||
world: &'w World,
|
||||
@@ -8031,14 +8140,11 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
}
|
||||
let Some(id) = self
|
||||
let id = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.block_transformer.clone())
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
.and_then(|i| i.block_transformer.clone())?;
|
||||
// playerHasBlockingItemUseIntent: a shield in the off hand is raised
|
||||
// instead, unless sneaking.
|
||||
let shield = self
|
||||
@@ -8047,7 +8153,7 @@ impl Game<'_> {
|
||||
.and_then(|s| self.res.items().get(&s.item))
|
||||
.is_some_and(|i| i.blocks_attacks.is_some());
|
||||
if shield && !self.keys.contains(&KeyCode::ShiftLeft) {
|
||||
return Ok(false);
|
||||
return None;
|
||||
}
|
||||
let face_name = match face {
|
||||
[0, -1, 0] => "down",
|
||||
@@ -8058,38 +8164,47 @@ impl Game<'_> {
|
||||
_ => "east",
|
||||
};
|
||||
let registry = self.pack.assets.registry;
|
||||
let (state, sound, loot, damage, particle) = {
|
||||
let around = Column {
|
||||
world: &self.world,
|
||||
registry,
|
||||
pos,
|
||||
};
|
||||
let Some((_, t, state)) =
|
||||
self.res
|
||||
.transformers()
|
||||
.apply(self.res.items(), registry, &id, face_name, &around)
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
(
|
||||
state,
|
||||
t.sound.clone(),
|
||||
t.loot.clone(),
|
||||
t.damage,
|
||||
t.particle.clone(),
|
||||
)
|
||||
let around = Column {
|
||||
world: &self.world,
|
||||
registry,
|
||||
pos,
|
||||
};
|
||||
let Ok(new) = registry.resolve(&state) else {
|
||||
return Ok(false);
|
||||
};
|
||||
self.level_set(pos, new)?;
|
||||
// BlockTransformer.TransformParticle.send.
|
||||
match particle.as_str() {
|
||||
let (_, t, state) =
|
||||
self.res
|
||||
.transformers()
|
||||
.apply(self.res.items(), registry, &id, face_name, &around)?;
|
||||
let new = registry.resolve(&state).ok()?;
|
||||
Some((
|
||||
new,
|
||||
t.sound.clone(),
|
||||
t.loot.clone(),
|
||||
t.damage,
|
||||
t.particle.clone(),
|
||||
))
|
||||
}
|
||||
/// `BlockTransformer.TransformParticle.send`: its level event.
|
||||
pub(super) fn transform_particle(&mut self, particle: &str, pos: [i32; 3]) {
|
||||
match particle {
|
||||
"scrape" => self.level_event_at(3005, pos, 0),
|
||||
"wax_on" => self.level_event_at(3003, pos, 0),
|
||||
"wax_off" => self.level_event_at(3004, pos, 0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `BlockTransformer.transformBlock` for a held axe, hoe or shovel.
|
||||
fn use_block_transformer(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
pos: [i32; 3],
|
||||
face: [i32; 3],
|
||||
) -> Result<bool> {
|
||||
let Some((new, sound, loot, damage, particle)) = self.block_transform(stack, pos, face)
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
self.level_set(pos, new)?;
|
||||
// BlockTransformer.TransformParticle.send.
|
||||
self.transform_particle(&particle, pos);
|
||||
if loot.as_deref() == Some("minecraft:till/rooted_dirt") {
|
||||
self.pop_resource(
|
||||
[pos[0] + face[0], pos[1] + face[1], pos[2] + face[2]],
|
||||
@@ -9068,31 +9183,41 @@ impl Game<'_> {
|
||||
} else {
|
||||
pos
|
||||
};
|
||||
self.spawn = Some(Vec3::new(
|
||||
f64::from(head[0]) + 0.5,
|
||||
f64::from(head[1]) + 0.6,
|
||||
f64::from(head[2]) + 0.5,
|
||||
));
|
||||
// The bed replaces a respawn anchor.
|
||||
self.respawn_config = None;
|
||||
self.level.spawn = Some([
|
||||
f64::from(head[0]),
|
||||
f64::from(head[1]) + 1.0,
|
||||
f64::from(head[2]),
|
||||
]);
|
||||
let dist = ((p.position.x - f64::from(head[0]) - 0.5).powi(2)
|
||||
+ (p.position.z - f64::from(head[2]) - 0.5).powi(2))
|
||||
.sqrt();
|
||||
if !night {
|
||||
if dist > 3.0 {
|
||||
self.message = Some((
|
||||
"You can sleep only at night or during thunderstorms".into(),
|
||||
"You may not rest now; the bed is too far away".into(),
|
||||
Instant::now(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
if dist > 3.0 {
|
||||
// In reach (bedInRange), day or night: setRespawnPosition(
|
||||
// RespawnConfig(the head, the player's yaw), not forced). It replaces
|
||||
// an anchor or a spawnpoint; respawning checks the bed is still there
|
||||
// and finds where to stand up (findRespawnAndUseSpawnBlock).
|
||||
// Straw beds never set it (`straw_bed_rule`).
|
||||
if self.bed_sets_spawn(state) {
|
||||
self.respawn_config = Some(respawn_anchor::RespawnConfig::wrapped(
|
||||
self.dim, head, p.yaw, false,
|
||||
));
|
||||
// The fallback becomes the world spawn: a guest's is none of its
|
||||
// own, so it follows `setworldspawn`.
|
||||
self.spawn = if self.lan_acting.is_some() {
|
||||
None
|
||||
} else {
|
||||
self.level
|
||||
.world_spawn
|
||||
.map(|[x, y, z]| {
|
||||
Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5)
|
||||
})
|
||||
.or(self.spawn)
|
||||
};
|
||||
}
|
||||
if !night {
|
||||
self.message = Some((
|
||||
"You may not rest now; the bed is too far away".into(),
|
||||
"You can sleep only at night or during thunderstorms".into(),
|
||||
Instant::now(),
|
||||
));
|
||||
return;
|
||||
@@ -9444,9 +9569,7 @@ impl Game<'_> {
|
||||
level.weather = self.world.weather;
|
||||
level.day_time = self.world.day_time;
|
||||
level.spawn = self.spawn.map(|s| [s.x, s.y, s.z]);
|
||||
level.respawn_elsewhere = self
|
||||
.respawn_config
|
||||
.map(|c| (c.dim.id().to_owned(), c.pos, c.yaw, c.forced));
|
||||
level.respawn_elsewhere = self.respawn_config.map(|c| c.saved());
|
||||
level.rotation = [self.yaw, self.pitch];
|
||||
level.xp_level = self.experience.level;
|
||||
level.xp_progress = self.experience.progress;
|
||||
@@ -9454,6 +9577,7 @@ impl Game<'_> {
|
||||
if let Some(p) = &self.player {
|
||||
level.position = Some([p.position.x, p.position.y, p.position.z]);
|
||||
level.health = p.health.health();
|
||||
level.absorption = p.health.absorption();
|
||||
let extra = level
|
||||
.player_extra
|
||||
.get_or_insert_with(|| horizon_world::nbt::Tag::Compound(Default::default()));
|
||||
@@ -9466,6 +9590,7 @@ impl Game<'_> {
|
||||
);
|
||||
map.insert("Air".into(), horizon_world::nbt::Tag::Short(p.air as i16));
|
||||
}
|
||||
level.fall_distance = p.fall_distance;
|
||||
|
||||
level.food = p.health.food.food;
|
||||
level.saturation = p.health.food.saturation;
|
||||
@@ -9771,6 +9896,8 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.fov_degrees(camera_alpha as f32, camera.to_array())
|
||||
};
|
||||
self.locator_camera =
|
||||
(!title).then_some((camera.to_array(), view_yaw, view_pitch, fov_degrees));
|
||||
let bob = if title {
|
||||
glam::Mat4::IDENTITY
|
||||
} else {
|
||||
@@ -10034,12 +10161,13 @@ impl Game<'_> {
|
||||
let loading = self.loading_reason() == Some(horizon_client::level_loading::Reason::Other);
|
||||
let blurred = loading
|
||||
|| self.title.as_ref().is_some_and(|t| {
|
||||
self.screen == Screen::Options
|
||||
matches!(self.screen, Screen::Options | Screen::Win)
|
||||
|| !matches!(t.mode, horizon_client::title::Mode::Main)
|
||||
})
|
||||
|| (self.title.is_none()
|
||||
&& self.player.is_some()
|
||||
&& self.screen.blurs_background(self.pause_menu));
|
||||
&& (self.screen.blurs_background(self.pause_menu)
|
||||
|| (self.screen == Screen::Win && self.win_screen_pauses())));
|
||||
let radius = self.settings.menu_blur as f32;
|
||||
(blurred && radius >= 1.0).then_some(radius)
|
||||
}
|
||||
@@ -10123,6 +10251,7 @@ impl Game<'_> {
|
||||
Screen::WorldOptions => self.world_options_escape(),
|
||||
Screen::CommandBlock => self.close_command_block(),
|
||||
Screen::Chat => self.close_chat(),
|
||||
Screen::Win => self.close_win_screen()?,
|
||||
},
|
||||
KeyCode::Digit1
|
||||
| KeyCode::Digit2
|
||||
@@ -10454,6 +10583,10 @@ impl Game<'_> {
|
||||
if let (Some(lod), Some(player)) = (&mut self.lod, &self.player) {
|
||||
let lod_distance = self.settings.horizon.lod_distance_on(&renderer.info);
|
||||
lod.set_near_loading(self.streaming.busy());
|
||||
lod.set_reduced_detail(
|
||||
self.settings.horizon.far_detail
|
||||
== horizon_client::graphics::FarDetail::Reduced,
|
||||
);
|
||||
// The covered columns change only with the complete ones and
|
||||
// the render area: rebuilt then, not every frame.
|
||||
let at = (
|
||||
@@ -10498,6 +10631,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
// The Window constructor: created fullscreen when options.txt says so.
|
||||
let fullscreen = self.settings.fullscreen;
|
||||
let size = self.options.window_size.map(f64::from);
|
||||
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
|
||||
let attributes = Window::default_attributes()
|
||||
.with_title("Horizon")
|
||||
@@ -10509,7 +10643,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
);
|
||||
let window = match event_loop.create_window(
|
||||
attributes
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(1280.0, 720.0))
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1]))
|
||||
.with_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None))),
|
||||
) {
|
||||
Ok(w) => Arc::new(w),
|
||||
@@ -10624,6 +10758,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
WindowEvent::CloseRequested => {
|
||||
self.pending_save.as_mut().unwrap().quit = true;
|
||||
}
|
||||
WindowEvent::DroppedFile(path) => {
|
||||
self.drop_shader_pack(&path);
|
||||
}
|
||||
WindowEvent::Focused(focused) => {
|
||||
self.focused = focused;
|
||||
if !focused {
|
||||
@@ -10718,7 +10855,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
}) * wheel_sensitivity
|
||||
};
|
||||
let d = scaled(raw_y) as f32;
|
||||
if let Some(title) = &mut self.title {
|
||||
if let Some(title) = &mut self.title
|
||||
&& self.screen != Screen::Win
|
||||
{
|
||||
let size = self
|
||||
.window
|
||||
.as_ref()
|
||||
@@ -10776,6 +10915,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.advancements_scroll(scaled(raw_x) as f32, d);
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
|
||||
if self.options_waiting_for_key() && state == ElementState::Pressed {
|
||||
self.options_bind_mouse(button)?;
|
||||
@@ -11032,6 +11172,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
{
|
||||
self.world_options_key(&event)
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::Win => {
|
||||
self.win_key(&event)?
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::CommandBlock => {
|
||||
self.command_block_key(&event)
|
||||
}
|
||||
@@ -11165,6 +11308,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.tick_ambience();
|
||||
self.tick_fov();
|
||||
}
|
||||
self.tick_win_screen()?;
|
||||
self.tick_music();
|
||||
self.tick_sound_engine();
|
||||
self.clock.record(started.elapsed());
|
||||
@@ -11592,6 +11736,7 @@ impl<'a> Game<'a> {
|
||||
respawn_config: None,
|
||||
respawn_travel: None,
|
||||
end_portal_travel: false,
|
||||
win: None,
|
||||
end: Default::default(),
|
||||
block_ticks: Ticks::new(0),
|
||||
tick_chunks: Default::default(),
|
||||
@@ -11609,6 +11754,10 @@ impl<'a> Game<'a> {
|
||||
armor_stands: Vec::new(),
|
||||
others: Vec::new(),
|
||||
armor_stand_assets: prepared.armor_stand_assets,
|
||||
waypoint_styles: prepared.waypoint_styles,
|
||||
waypoints: Default::default(),
|
||||
locator_camera: None,
|
||||
experience_display: Default::default(),
|
||||
boats: Vec::new(),
|
||||
boat_models: prepared.boat_models,
|
||||
crystal_model: prepared.crystal_model,
|
||||
@@ -11629,6 +11778,7 @@ impl<'a> Game<'a> {
|
||||
.join("assets/skins"),
|
||||
)),
|
||||
skin_slots: Vec::new(),
|
||||
trim_slots: Vec::new(),
|
||||
cart_models: prepared.cart_models,
|
||||
riding: None,
|
||||
ender_items: vec![None; 27],
|
||||
@@ -11650,6 +11800,8 @@ impl<'a> Game<'a> {
|
||||
item_columns: None,
|
||||
creative_screen: prepared.creative_screen,
|
||||
control: None,
|
||||
agent_control_on: false,
|
||||
agent_last: None,
|
||||
frame_stats: Default::default(),
|
||||
perf: Default::default(),
|
||||
pending_screenshot: None,
|
||||
@@ -11733,6 +11885,7 @@ struct GameCpuResources {
|
||||
map_frame_model: frames::FrameModel,
|
||||
glow_map_frame_model: frames::FrameModel,
|
||||
armor_stand_assets: armor_stands::ArmorStandAssets,
|
||||
waypoint_styles: horizon_client::locator_bar::WaypointStyles,
|
||||
boat_models: boats::BoatModels,
|
||||
cart_models: minecarts::CartModels,
|
||||
crystal_model: Option<horizon_client::mobs::Part>,
|
||||
@@ -11752,6 +11905,7 @@ impl GameCpuResources {
|
||||
map_frame_model: frames::bake_frame(root, "item_frame_map"),
|
||||
glow_map_frame_model: frames::bake_frame(root, "glow_item_frame_map"),
|
||||
armor_stand_assets: armor_stands::ArmorStandAssets::load(root, document.as_ref()),
|
||||
waypoint_styles: horizon_client::locator_bar::WaypointStyles::load(root),
|
||||
boat_models: boats::BoatModels::load(document.as_ref()),
|
||||
cart_models: minecarts::CartModels::load(document.as_ref()),
|
||||
crystal_model: model("minecraft:end_crystal#main"),
|
||||
@@ -11975,7 +12129,20 @@ fn load_resources(
|
||||
// loads; the synchronous launch instead carries it in `Loaded`.
|
||||
let mut loaded_gui = None;
|
||||
let mut ui_ready = false;
|
||||
if let Ok(ui) = UiResources::load(&root) {
|
||||
let mut pack_error = None;
|
||||
let ui = match UiResources::load(&root, vanilla_root) {
|
||||
Ok(ui) => Some(ui),
|
||||
// Minecraft.rollbackResourcePacks: the packs' textures don't fit, start
|
||||
// with the game's own resources.
|
||||
Err(e) if root != vanilla_root => {
|
||||
log::warn!("Horizon: resource packs failed to load ({e}); using the default resources");
|
||||
pack_error = Some(e.to_string());
|
||||
root = vanilla_root.to_path_buf();
|
||||
None
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
if let Some(ui) = ui {
|
||||
if menu {
|
||||
horizon_client::telemetry::mark_ui_ready();
|
||||
log::info!(
|
||||
@@ -11989,7 +12156,6 @@ fn load_resources(
|
||||
ui_ready = true;
|
||||
}
|
||||
progress.set(0);
|
||||
let mut pack_error = None;
|
||||
let (assets, render) = match Assets::load(&root).and_then(|assets| {
|
||||
let render = assets.reload(&root)?;
|
||||
Ok((assets, render))
|
||||
@@ -12016,7 +12182,7 @@ fn load_resources(
|
||||
let render = Arc::new(render);
|
||||
// The menu bundle must come from the same root as the world bundle.
|
||||
if !ui_ready || root != requested_root {
|
||||
let ui = UiResources::load(&root)?;
|
||||
let ui = UiResources::load(&root, vanilla_root)?;
|
||||
if menu {
|
||||
horizon_client::telemetry::mark_ui_ready();
|
||||
log::info!(
|
||||
@@ -12427,6 +12593,10 @@ fn start_game<'a>(
|
||||
game.rest_loader = rest_loader;
|
||||
game.ui.vanilla_root = vanilla_root;
|
||||
if let Some(error) = pack_error {
|
||||
// Minecraft.clearResourcePacksOnError: the selection is cleared and
|
||||
// saved, so the next launch starts without the packs.
|
||||
game.settings.resource_packs.clear();
|
||||
game.settings.save(&Game::options_path());
|
||||
game.open_options_at(true, horizon_client::options_ui::Page::Packs);
|
||||
game.ui.options.resource_error = Some(error);
|
||||
}
|
||||
@@ -12444,18 +12614,7 @@ fn start_game<'a>(
|
||||
game.mods.dev = game.options.dev_mods.clone();
|
||||
game.start_mods();
|
||||
}
|
||||
if game.options.control.is_some() && game.settings.extras.no_ai {
|
||||
// No AI turns agent control off: the socket is never opened.
|
||||
log::warn!(
|
||||
"Horizon: control socket not opened: {}",
|
||||
horizon_core::no_ai::REFUSAL
|
||||
);
|
||||
} else if let Some(path) = &game.options.control {
|
||||
let server = horizon_client::control::Server::start(path)
|
||||
.map_err(|e| format!("control socket {}: {e}", path.display()))?;
|
||||
log::info!("Horizon: control socket {}", server.path().display());
|
||||
game.control = Some(server);
|
||||
}
|
||||
game.start_agent_control()?;
|
||||
if let Some(scenario) = game.options.benchmark.clone() {
|
||||
let control = game
|
||||
.control
|
||||
@@ -12596,6 +12755,11 @@ impl Loading {
|
||||
}
|
||||
// The Window constructor: created fullscreen when options.txt says so.
|
||||
let fullscreen = self.settings.fullscreen;
|
||||
let size = self
|
||||
.options
|
||||
.as_ref()
|
||||
.map_or([1280, 720], |o| o.window_size)
|
||||
.map(f64::from);
|
||||
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
|
||||
let attributes = Window::default_attributes()
|
||||
.with_title("Horizon")
|
||||
@@ -12607,7 +12771,7 @@ impl Loading {
|
||||
);
|
||||
let window = match event_loop.create_window(
|
||||
attributes
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(1280.0, 720.0))
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1]))
|
||||
.with_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None))),
|
||||
) {
|
||||
Ok(w) => Arc::new(w),
|
||||
|
||||
@@ -405,6 +405,7 @@ impl Game<'_> {
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
}
|
||||
}
|
||||
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
|
||||
@@ -459,8 +460,11 @@ impl Game<'_> {
|
||||
"sneaking": self.keys.contains(&KeyCode::ShiftLeft),
|
||||
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
|
||||
"health": p.health.health(),
|
||||
"absorption": p.health.absorption(),
|
||||
"air": p.air,
|
||||
"food": p.health.food.food,
|
||||
"saturation": p.health.food.saturation,
|
||||
"shield_cooldown": self.shield_cooldown,
|
||||
"xp": { "level": self.experience.level, "progress": self.experience.progress, "total": self.experience.total },
|
||||
"effects": self.potions.effects.list.iter().map(|e| json!({ "id": e.id, "duration_ticks": e.duration, "amplifier": e.amplifier })).collect::<Vec<_>>(),
|
||||
})
|
||||
@@ -478,6 +482,8 @@ impl Game<'_> {
|
||||
"sleeping": self.sleeping,
|
||||
// The item in use: its slot and for how many ticks.
|
||||
"using": self.using.map(|(slot, ticks)| json!({ "slot": slot, "ticks": ticks })),
|
||||
// The block being dug: how far along, after how many ticks.
|
||||
"mining": self.mining.as_ref().map(|m| json!({ "pos": m.pos, "progress": m.progress, "ticks": m.ticks })),
|
||||
// The arm swings started (LivingEntity.swing).
|
||||
"swings": self.swings_started,
|
||||
"time": { "day_time": self.world.day_time, "day": self.world.day_time.div_euclid(24000), "time_of_day": self.world.day_time.rem_euclid(24000) },
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Agent control as a setting (Horizon Settings > AI Agent): when the control socket is open, who
|
||||
//! is connected, and the details an agent needs. The socket opens when the game was started for
|
||||
//! an agent (`--control`, `--control-socket`: horizon-ctl launch, the launcher's MCP `play`) or
|
||||
//! when the player turned Agent Control on, and never under No AI (`horizon_core::no_ai`).
|
||||
//! Turning either switch in the game opens or closes it at once.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, control};
|
||||
use std::path::PathBuf;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The endpoint: the one the command line gave, else the default horizon-ctl looks for.
|
||||
fn agent_socket_path(&self) -> PathBuf {
|
||||
self.options
|
||||
.control
|
||||
.clone()
|
||||
.unwrap_or_else(control::default_path)
|
||||
}
|
||||
|
||||
/// At startup: open the socket if the game was started for an agent or Agent Control is on.
|
||||
pub(super) fn start_agent_control(&mut self) -> Result<()> {
|
||||
self.agent_control_on = self.settings.extras.agent_control;
|
||||
if self.options.benchmark.is_some() && self.options.control.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.settings.extras.no_ai {
|
||||
if self.options.control.is_some() {
|
||||
log::warn!(
|
||||
"Horizon: control socket not opened: {}",
|
||||
horizon_core::no_ai::REFUSAL
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(path) = &self.options.control {
|
||||
// Asked for on the command line: failing to listen is an error.
|
||||
let server = control::Server::start(path)
|
||||
.map_err(|e| format!("control socket {}: {e}", path.display()))?;
|
||||
log::info!("Horizon: control socket {}", server.path().display());
|
||||
self.control = Some(server);
|
||||
} else if self.agent_control_on {
|
||||
self.open_agent_socket();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The setting's socket: a failure (another game holds the default endpoint) is logged and
|
||||
/// shown on the AI Agent page, not fatal.
|
||||
fn open_agent_socket(&mut self) {
|
||||
let path = self.agent_socket_path();
|
||||
match control::Server::start(&path) {
|
||||
Ok(server) => {
|
||||
log::info!("Horizon: control socket {}", server.path().display());
|
||||
self.control = Some(server);
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: control socket {}: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
/// After a settings change: No AI closes the socket; Agent Control turned on or off opens or
|
||||
/// closes it.
|
||||
pub(super) fn sync_agent_control(&mut self) {
|
||||
let wanted = self.settings.extras.agent_control;
|
||||
let changed = wanted != self.agent_control_on;
|
||||
self.agent_control_on = wanted;
|
||||
let open = self
|
||||
.control
|
||||
.as_ref()
|
||||
.is_some_and(|c| !c.path().as_os_str().is_empty());
|
||||
if self.settings.extras.no_ai || (changed && !wanted) {
|
||||
if open {
|
||||
log::info!("Horizon: control socket closed");
|
||||
self.control = None;
|
||||
}
|
||||
} else if changed && wanted && self.control.is_none() {
|
||||
self.open_agent_socket();
|
||||
}
|
||||
}
|
||||
|
||||
/// An agent's command arrived.
|
||||
pub(super) fn agent_command_seen(&mut self) {
|
||||
self.agent_last = Some(std::time::Instant::now());
|
||||
}
|
||||
|
||||
/// The AI Agent page's lines: the state, then where agents connect.
|
||||
pub(super) fn agent_status_lines(&self) -> Vec<String> {
|
||||
let server = self
|
||||
.control
|
||||
.as_ref()
|
||||
.filter(|c| !c.path().as_os_str().is_empty());
|
||||
let mut lines = vec![match server {
|
||||
Some(s) if s.clients() > 0 => {
|
||||
let ago = self
|
||||
.agent_last
|
||||
.map(|t| format!(", last command {} s ago", t.elapsed().as_secs()))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"Connected: {} agent connection{}{ago}",
|
||||
s.clients(),
|
||||
if s.clients() == 1 { "" } else { "s" }
|
||||
)
|
||||
}
|
||||
Some(_) => "Waiting for an agent.".into(),
|
||||
None => "Off: agents can't reach this game.".into(),
|
||||
}];
|
||||
if let Some(s) = server {
|
||||
if self.options.control.is_some() && !self.agent_control_on {
|
||||
lines.push("Opened for this session by the program that started the game.".into());
|
||||
}
|
||||
// The full path is too wide for the page: Copy Connection Details has it.
|
||||
let name = s.path().file_name().unwrap_or_default().to_string_lossy();
|
||||
lines.push(format!(
|
||||
"Socket: {name} (copy the details for its full path)"
|
||||
));
|
||||
}
|
||||
lines.push("Local only: agents on this computer, never the network.".into());
|
||||
lines
|
||||
}
|
||||
|
||||
/// What Copy Connection Details puts on the clipboard: the socket, a horizon-ctl line to try,
|
||||
/// and an MCP client's config for this game.
|
||||
pub(super) fn agent_details(&self) -> String {
|
||||
let socket = self.agent_socket_path();
|
||||
let ctl = std::env::current_exe()
|
||||
.unwrap_or_default()
|
||||
.with_file_name(if cfg!(windows) {
|
||||
"horizon-ctl.exe"
|
||||
} else {
|
||||
"horizon-ctl"
|
||||
});
|
||||
let config = serde_json::json!({ "mcpServers": { "horizon-game": {
|
||||
"command": ctl, "args": ["--socket", socket, "mcp"] } } });
|
||||
format!(
|
||||
"Horizon agent control (this computer only)\nSocket: {}\nTry it: \"{}\" --socket \"{}\" state\nMCP config:\n{}\n",
|
||||
socket.display(),
|
||||
ctl.display(),
|
||||
socket.display(),
|
||||
serde_json::to_string_pretty(&config).unwrap_or_default()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,13 @@ use horizon_client::{
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Mob, Part, anim::Poses},
|
||||
trims::{TrimOverride, TrimRegistry, TrimTexture},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::RandomSource,
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc};
|
||||
|
||||
/// Armor stand models and the humanoid layers of every equipment asset.
|
||||
pub(super) struct ArmorStandAssets {
|
||||
@@ -25,6 +26,11 @@ pub(super) struct ArmorStandAssets {
|
||||
pub player: HashMap<&'static str, Part>,
|
||||
/// asset id -> layer kind (humanoid / humanoid_leggings) -> (texture, undyed colour).
|
||||
pub equipment: HashMap<String, HashMap<String, (String, Option<u32>)>>,
|
||||
/// asset id -> `trim_overrides`.
|
||||
pub trim_overrides: HashMap<String, Vec<TrimOverride>>,
|
||||
pub trims: TrimRegistry,
|
||||
/// The resources the trim textures are made from.
|
||||
pub root: PathBuf,
|
||||
}
|
||||
impl ArmorStandAssets {
|
||||
pub fn load(
|
||||
@@ -48,6 +54,7 @@ impl ArmorStandAssets {
|
||||
player.insert("elytra", part);
|
||||
}
|
||||
let mut equipment = HashMap::new();
|
||||
let mut trim_overrides = HashMap::new();
|
||||
if let Ok(entries) = std::fs::read_dir(root.join("assets/minecraft/equipment")) {
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
@@ -76,6 +83,7 @@ impl ArmorStandAssets {
|
||||
);
|
||||
}
|
||||
}
|
||||
trim_overrides.insert(format!("minecraft:{name}"), TrimOverride::list(&v));
|
||||
equipment.insert(format!("minecraft:{name}"), kinds);
|
||||
}
|
||||
}
|
||||
@@ -85,30 +93,67 @@ impl ArmorStandAssets {
|
||||
layers,
|
||||
player,
|
||||
equipment,
|
||||
trim_overrides,
|
||||
trims: TrimRegistry::load(root),
|
||||
root: root.to_owned(),
|
||||
}
|
||||
}
|
||||
/// Trim data of reloaded resources (the models stay).
|
||||
pub fn reload_trims(&mut self, root: &std::path::Path) {
|
||||
let fresh = Self::load(root, None);
|
||||
self.trim_overrides = fresh.trim_overrides;
|
||||
self.trims = fresh.trims;
|
||||
self.root = fresh.root;
|
||||
}
|
||||
/// `TrimTextureKey`: the trim texture of `stack` on its humanoid
|
||||
/// layer (`humanoid` or `humanoid_leggings`), if it has a trim.
|
||||
fn trim_texture(&self, asset: &str, kind: &str, stack: &ItemStack) -> Option<TrimTexture> {
|
||||
let (material, pattern) = stack.extra.as_ref()?.trim.as_ref()?;
|
||||
let overrides = self
|
||||
.trim_overrides
|
||||
.get(asset)
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
TrimTexture::resolve(&self.trims, overrides, material, pattern, kind)
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `HumanoidArmorLayer` for the player: (model, texture rect, colour) of
|
||||
/// each worn piece, head to feet.
|
||||
pub(super) fn player_armor_layers(&self) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
self.armor_layers(self.armor.each_ref().map(|s| s.as_ref().map(|s| &*s.item)))
|
||||
self.stack_armor_layers(&self.armor)
|
||||
}
|
||||
/// `HumanoidArmorLayer` for worn stacks (head to feet), trims included.
|
||||
pub(super) fn stack_armor_layers(
|
||||
&self,
|
||||
armor: &[Option<ItemStack>; 4],
|
||||
) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
self.armor_layers(
|
||||
armor
|
||||
.each_ref()
|
||||
.map(|s| s.as_ref().map(|s| (&*s.item, Some(s)))),
|
||||
)
|
||||
}
|
||||
/// `HumanoidArmorLayer` for another player's model: the armour it wears
|
||||
/// (`extra.head` .. `extra.feet`).
|
||||
pub(super) fn worn_armor_layers(&self, m: &Mob) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
let e = &m.extra;
|
||||
self.armor_layers([&e.head, &e.chest, &e.legs, &e.feet].map(|s| s.as_deref()))
|
||||
self.armor_layers(
|
||||
[&e.head, &e.chest, &e.legs, &e.feet].map(|s| s.as_deref().map(|i| (i, None))),
|
||||
)
|
||||
}
|
||||
/// The layers of the armour items `worn`, head to feet.
|
||||
fn armor_layers(&self, worn: [Option<&str>; 4]) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
/// The layers of the armour items `worn` (with their stacks when known,
|
||||
/// for trims), head to feet.
|
||||
fn armor_layers(
|
||||
&self,
|
||||
worn: [Option<(&str, Option<&ItemStack>)>; 4],
|
||||
) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
let assets = &self.armor_stand_assets;
|
||||
let mut out = Vec::new();
|
||||
for (item, layer) in worn
|
||||
.into_iter()
|
||||
.zip(["helmet", "chestplate", "leggings", "boots"])
|
||||
{
|
||||
let Some(item) = item else {
|
||||
let Some((item, stack)) = item else {
|
||||
continue;
|
||||
};
|
||||
let Some(asset) = self
|
||||
@@ -138,9 +183,117 @@ impl Game<'_> {
|
||||
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
|
||||
});
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], color));
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
if let Some(r) = stack
|
||||
.and_then(|s| assets.trim_texture(&asset, kind, s))
|
||||
.and_then(|t| self.gui.atlas.sprites.get(&t.sprite()))
|
||||
{
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], 0xFFFF_FFFF));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `PalettedTextureManager.getOrPrepare` for the trims drawn this frame
|
||||
/// (the player's armour and the armour stands'): each generated texture
|
||||
/// goes into an atlas trim slot, a free one, else the least recently
|
||||
/// drawn.
|
||||
pub(super) fn prepare_trims(&mut self) {
|
||||
use horizon_client::hud::TRIM_SLOTS;
|
||||
let assets = &self.armor_stand_assets;
|
||||
let items = self.res.items();
|
||||
let mut wanted: Vec<TrimTexture> = Vec::new();
|
||||
let guests = self.lan.iter().flat_map(|host| host.hub.guests.iter());
|
||||
let stacks = self
|
||||
.armor
|
||||
.iter()
|
||||
.chain(guests.flat_map(|g| g.armor.iter()))
|
||||
.zip(
|
||||
["helmet", "chestplate", "leggings", "boots"]
|
||||
.into_iter()
|
||||
.cycle(),
|
||||
)
|
||||
.chain(self.armor_stands.iter().flat_map(|a| {
|
||||
[5usize, 4, 3, 2]
|
||||
.map(|slot| &a.equipment[slot])
|
||||
.into_iter()
|
||||
.zip(["helmet", "chestplate", "leggings", "boots"])
|
||||
}));
|
||||
for (stack, layer) in stacks {
|
||||
let Some(stack) = stack.as_ref().filter(|s| s.extra.is_some()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(asset) = items
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.equip_asset.as_deref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let kind = if layer == "leggings" {
|
||||
"humanoid_leggings"
|
||||
} else {
|
||||
"humanoid"
|
||||
};
|
||||
if let Some(t) = assets.trim_texture(asset, kind, stack)
|
||||
&& !wanted.contains(&t)
|
||||
{
|
||||
wanted.push(t);
|
||||
}
|
||||
}
|
||||
for t in wanted {
|
||||
let name = t.sprite();
|
||||
// Entries the atlas no longer has (rebuilt by a resource reload) are free.
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
self.trim_slots.retain(|(n, _)| sprites.contains_key(n));
|
||||
if let Some(i) = self.trim_slots.iter().position(|e| e.0 == name) {
|
||||
let e = self.trim_slots.remove(i);
|
||||
self.trim_slots.push(e);
|
||||
continue;
|
||||
}
|
||||
let Some((w, h, rgba)) = t.generate(&self.armor_stand_assets.root) else {
|
||||
continue;
|
||||
};
|
||||
let used: Vec<usize> = self.trim_slots.iter().map(|e| e.1).collect();
|
||||
let slot = match (0..TRIM_SLOTS).find(|i| !used.contains(i)) {
|
||||
Some(free) => free,
|
||||
None => {
|
||||
let (old, slot) = self.trim_slots.remove(0);
|
||||
self.gui.atlas.sprites.remove(&old);
|
||||
slot
|
||||
}
|
||||
};
|
||||
let Some(area) = self
|
||||
.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.get(&format!("horizon/trim{slot}"))
|
||||
.copied()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if w > area.w || h > area.h {
|
||||
continue;
|
||||
}
|
||||
let (aw, ah) = (self.gui.atlas.width as f32, self.gui.atlas.height as f32);
|
||||
let (x, y) = ((area.u0 * aw).round(), (area.v0 * ah).round());
|
||||
self.map_patches.push(horizon_client::font::Patch {
|
||||
x: x as u32,
|
||||
y: y as u32,
|
||||
width: w,
|
||||
height: h,
|
||||
rgba,
|
||||
});
|
||||
let rect = horizon_client::hud::Rect {
|
||||
u0: area.u0,
|
||||
v0: area.v0,
|
||||
u1: (x + w as f32) / aw,
|
||||
v1: (y + h as f32) / ah,
|
||||
w,
|
||||
h,
|
||||
};
|
||||
self.gui.atlas.sprites.insert(name.clone(), rect);
|
||||
self.trim_slots.push((name, slot));
|
||||
}
|
||||
}
|
||||
/// `ArmorStandItem.useOn`.
|
||||
pub(super) fn place_armor_stand(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:armor_stand" || hit.face == [0, -1, 0] {
|
||||
@@ -564,6 +717,22 @@ impl Game<'_> {
|
||||
0,
|
||||
out,
|
||||
);
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
if let Some(trim) = assets
|
||||
.trim_texture(&asset, kind, stack)
|
||||
.and_then(|t| uv_of(&t.sprite()))
|
||||
{
|
||||
horizon_client::mobs::render::draw(
|
||||
part,
|
||||
base,
|
||||
&layer_poses,
|
||||
trim,
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use horizon_client::{
|
||||
chat::{Chat, ChatAction, ChatScreen, Commands, SYSTEM_INDICATOR},
|
||||
command::Registries,
|
||||
gamerules::GameRules,
|
||||
hud::{HudBatch, rgba},
|
||||
hud::HudBatch,
|
||||
screen_draw::Measure,
|
||||
text::{ClickEvent, Style, Text},
|
||||
};
|
||||
@@ -883,10 +883,6 @@ impl Game<'_> {
|
||||
.chat
|
||||
.add(&self.gui.atlas, message, None, self.ticks, focused);
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
fn chat_debug_fill(&self, batch: &mut HudBatch) {
|
||||
batch.fill(&self.gui.atlas, 0.0, 0.0, 1.0, 1.0, rgba(0, 0, 0, 0));
|
||||
}
|
||||
}
|
||||
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, local time
|
||||
/// approximated as UTC).
|
||||
|
||||
@@ -294,6 +294,18 @@ impl Game<'_> {
|
||||
}
|
||||
// ---- entities -------------------------------------------------------
|
||||
|
||||
/// The level `t` is in (`Entity.level`): a server's players have their
|
||||
/// own, which need not be the one in place; everything else is in the
|
||||
/// level in place.
|
||||
pub(super) fn target_level(&self, t: Target) -> Dim {
|
||||
let conn = match t {
|
||||
Target::Player => self.lan_acting,
|
||||
Target::Guest(conn) => Some(conn),
|
||||
_ => None,
|
||||
};
|
||||
conn.and_then(|c| self.command_guest(c))
|
||||
.map_or(self.dim, |g| Dim::from_id(&g.dimension))
|
||||
}
|
||||
pub(super) fn target_position(&self, t: Target) -> Option<[f64; 3]> {
|
||||
match t {
|
||||
Target::Player => self
|
||||
@@ -1191,15 +1203,12 @@ impl Game<'_> {
|
||||
pos: [f64; 3],
|
||||
rotation: Option<[f32; 2]>,
|
||||
) -> bool {
|
||||
let (conn, at) = match t {
|
||||
Target::Player => (self.lan_acting, self.dim),
|
||||
Target::Guest(conn) => (
|
||||
Some(conn),
|
||||
self.command_guest(conn)
|
||||
.map_or(self.dim, |g| super::Dim::from_id(&g.dimension)),
|
||||
),
|
||||
_ => (None, self.dim),
|
||||
let conn = match t {
|
||||
Target::Player => self.lan_acting,
|
||||
Target::Guest(conn) => Some(conn),
|
||||
_ => None,
|
||||
};
|
||||
let at = self.target_level(t);
|
||||
let Some(conn) = conn.filter(|_| at != level && self.options.dedicated.is_some()) else {
|
||||
return false;
|
||||
};
|
||||
@@ -1222,12 +1231,7 @@ impl Game<'_> {
|
||||
let dest = self.entities(ctx, "destination", false)?[0];
|
||||
let pos = self.target_position(dest).unwrap_or(self.source_position());
|
||||
// Into the destination's level.
|
||||
let level = match dest {
|
||||
Target::Guest(conn) => self
|
||||
.command_guest(conn)
|
||||
.map_or(self.dim, |g| super::Dim::from_id(&g.dimension)),
|
||||
_ => self.dim,
|
||||
};
|
||||
let level = self.target_level(dest);
|
||||
// TeleportCommand.teleportToEntity: the destination's rotation too.
|
||||
let rotation = Some(self.target_rotation(dest));
|
||||
for &t in &targets {
|
||||
@@ -2281,12 +2285,23 @@ impl Game<'_> {
|
||||
};
|
||||
let yaw = ((rot[0] + 180.0).rem_euclid(360.0)) - 180.0;
|
||||
let pitch = rot[1].clamp(-90.0, 90.0);
|
||||
self.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
));
|
||||
self.respawn_config = None;
|
||||
// SetSpawnCommand: a forced point in the source's level.
|
||||
if self.dim == Dim::Overworld {
|
||||
self.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
));
|
||||
self.respawn_config = None;
|
||||
} else {
|
||||
self.spawn = None;
|
||||
self.respawn_config = Some(super::respawn_anchor::RespawnConfig {
|
||||
dim: self.dim,
|
||||
pos,
|
||||
yaw,
|
||||
forced: true,
|
||||
});
|
||||
}
|
||||
let dim = Text::literal(self.dim.id());
|
||||
let p = pos.map(|v| Text::literal(v.to_string()));
|
||||
let mut args = vec![
|
||||
@@ -2322,7 +2337,20 @@ impl Game<'_> {
|
||||
Some(Arg::Coords(c)) => c.rotation(self.source_rotation()),
|
||||
_ => [0.0, 0.0],
|
||||
};
|
||||
self.level.spawn = Some([f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])]);
|
||||
// LevelData.setRespawnData: the world's spawn (level.dat `spawn`).
|
||||
let old = self.level.world_spawn.replace(pos);
|
||||
self.level.world_spawn_angle = rot;
|
||||
// A player whose fallback is the world spawn (no spawnpoint of its
|
||||
// own; a bed or an anchor that is gone falls back to it) respawns at
|
||||
// the new one.
|
||||
let floor = |s: Vec3| [s.x.floor() as i32, s.y.floor() as i32, s.z.floor() as i32];
|
||||
if self.spawn.map(floor) == old {
|
||||
self.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
));
|
||||
}
|
||||
let args = [
|
||||
Text::literal(pos[0].to_string()),
|
||||
Text::literal(pos[1].to_string()),
|
||||
|
||||
@@ -155,14 +155,20 @@ impl Game<'_> {
|
||||
_ => s.pos,
|
||||
}
|
||||
}
|
||||
/// Evaluate `f` with `source` as the command source.
|
||||
/// Evaluate `f` with `source` as the command source, in the source's
|
||||
/// level (`CommandSourceStack.getLevel`): a server puts that level in
|
||||
/// place for it.
|
||||
pub(in crate::game) fn with_source<T>(
|
||||
&mut self,
|
||||
source: Source,
|
||||
f: impl FnOnce(&mut Self) -> T,
|
||||
) -> T {
|
||||
let previous = self.command_source.replace(source);
|
||||
let back = self.enter_command_level(source.dimension);
|
||||
let out = f(self);
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
self.command_source = previous;
|
||||
out
|
||||
}
|
||||
@@ -447,11 +453,13 @@ impl Game<'_> {
|
||||
"at" => targets(self)?
|
||||
.into_iter()
|
||||
.filter_map(|t| {
|
||||
// CommandSourceStack.withLevel(entity.level()).
|
||||
let level = self.target_level(t);
|
||||
Some(Source {
|
||||
pos: self.target_position(t)?,
|
||||
rot: self.target_rotation(t),
|
||||
dimension: self.dim,
|
||||
level: None,
|
||||
dimension: level,
|
||||
level: (level != self.dim).then_some(level),
|
||||
..s
|
||||
})
|
||||
})
|
||||
|
||||
@@ -810,7 +810,10 @@ impl Game<'_> {
|
||||
None => break,
|
||||
},
|
||||
};
|
||||
if self.slot_get(slot) != item && self.slot_set(slot, item) {
|
||||
// ItemStack.isEmpty: air (or a count of 0) is an empty slot.
|
||||
// SlotAccess.set counts every slot it sets, the same item too.
|
||||
let item = item.filter(|s| &*s.item != "minecraft:air" && s.count > 0);
|
||||
if self.slot_set(slot, item) {
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,8 +248,32 @@ impl Game<'_> {
|
||||
|
||||
/// Every tick: the size change, the wall the player cannot pass and
|
||||
/// damage outside the border.
|
||||
/// `BorderChangeListener.DelegateBorderChangeListener`'s packets: what
|
||||
/// changed on the border since the guests were last told of it.
|
||||
pub(in crate::game) fn lan_sync_border(&mut self, host: &mut crate::game::lan::LanHost) {
|
||||
let now = horizon_client::lan::border::Sent::of(&self.border);
|
||||
if let Some(sent) = host.border_sent.replace(now) {
|
||||
for packet in sent.changes(&self.border) {
|
||||
host.hub.broadcast(&packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(in crate::game) fn tick_worldborder(&mut self) {
|
||||
self.border.tick();
|
||||
self.player_border();
|
||||
self.border_damage();
|
||||
}
|
||||
/// The player's movement collides with the border as it is now
|
||||
/// (`CollisionGetter.borderCollision`): from inside, its walls stop it.
|
||||
pub(in crate::game) fn player_border(&mut self) {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.border = Some(self.border.geometry().clone());
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.baseTick`: a player outside the border, further than
|
||||
/// its safe zone, is hurt by the blocks beyond it. The game's player, or
|
||||
/// a guest's in their turn (`as_guest`).
|
||||
pub(in crate::game) fn border_damage(&mut self) {
|
||||
let Some((px, pz, half)) = self.player.as_ref().map(|p| {
|
||||
(
|
||||
p.position.x,
|
||||
@@ -269,20 +293,6 @@ impl Game<'_> {
|
||||
let amount = (-dist * self.border.damage_per_block).floor().max(1.0) as f32;
|
||||
self.hurt_player_by("minecraft:outside_border", amount);
|
||||
}
|
||||
// From inside the border the wall stops movement.
|
||||
if self.border.distance(px, pz) >= 0.0 {
|
||||
let [x, z] = self.border.clamp(px, pz, half);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
if x != px {
|
||||
p.velocity.x = 0.0;
|
||||
}
|
||||
if z != pz {
|
||||
p.velocity.z = 0.0;
|
||||
}
|
||||
p.position.x = x;
|
||||
p.position.z = z;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn cmd_worldborder(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let float2 = |v: f64| format!("{v:.2}");
|
||||
|
||||
@@ -42,6 +42,7 @@ const HELP: &[&str] = &[
|
||||
"chat MESSAGE send chat or a /command to the server",
|
||||
"disconnect leave the server for the server list",
|
||||
"respawn respawn after death on a server",
|
||||
"credits [poem] [SCROLL] roll the credits (with the end poem), or hold them SCROLL pixels in",
|
||||
"quit save and exit",
|
||||
];
|
||||
|
||||
@@ -117,6 +118,7 @@ impl Game<'_> {
|
||||
self.answer(request, event_loop);
|
||||
}
|
||||
while let Some(request) = self.control.as_ref().and_then(|c| c.try_recv()) {
|
||||
self.agent_command_seen();
|
||||
self.answer(request, event_loop);
|
||||
if self.pending_save.is_some() {
|
||||
break;
|
||||
@@ -125,6 +127,11 @@ impl Game<'_> {
|
||||
if self.control.is_some() {
|
||||
self.watch_events();
|
||||
}
|
||||
if self.screen == Screen::Options
|
||||
&& self.ui.options.page() == horizon_client::options_ui::Page::Agent
|
||||
{
|
||||
self.ui.options.agent_status = self.agent_status_lines();
|
||||
}
|
||||
}
|
||||
|
||||
fn answer(&mut self, request: horizon_client::control::Request, event_loop: &ActiveEventLoop) {
|
||||
@@ -568,7 +575,9 @@ impl Game<'_> {
|
||||
"graphics" => Page::Graphics,
|
||||
"graphics-advanced" => Page::GraphicsAdvanced,
|
||||
"extras" => Page::Extras,
|
||||
"credits" => Page::Credits,
|
||||
"horizon" => Page::Horizon,
|
||||
"agent" => Page::Agent,
|
||||
"shader-packs" => Page::ShaderPacks,
|
||||
other => return Err(format!("unknown options page {other}").into()),
|
||||
};
|
||||
@@ -645,6 +654,25 @@ impl Game<'_> {
|
||||
self.connect_server(arg(1)?, &name)?;
|
||||
ok()
|
||||
}
|
||||
"credits" => {
|
||||
let poem = words.get(1) == Some(&"poem");
|
||||
let hold = words
|
||||
.get(1 + usize::from(poem))
|
||||
.map(|s| s.parse::<f32>())
|
||||
.transpose()?;
|
||||
if self.screen != Screen::Win {
|
||||
let finish = if self.screen == Screen::Options {
|
||||
super::win::Finish::Options
|
||||
} else {
|
||||
super::win::Finish::Closed
|
||||
};
|
||||
self.open_win_screen(poem, finish);
|
||||
}
|
||||
if let Some(scroll) = hold {
|
||||
self.hold_win_screen(scroll);
|
||||
}
|
||||
ok()
|
||||
}
|
||||
"respawn" => {
|
||||
if self.remote.is_none() || self.screen != Screen::Dead {
|
||||
return Err("respawn needs the death screen on a server".into());
|
||||
@@ -727,6 +755,9 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
fn screen_name(&self) -> &'static str {
|
||||
if self.screen == Screen::Win {
|
||||
return "win";
|
||||
}
|
||||
if self.title.is_some() {
|
||||
return "title";
|
||||
}
|
||||
@@ -744,6 +775,7 @@ impl Game<'_> {
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -802,6 +834,12 @@ impl Game<'_> {
|
||||
"velocity": [p.velocity.x, p.velocity.y, p.velocity.z],
|
||||
"on_ground": p.ground.on_ground,
|
||||
"health": p.health.health(),
|
||||
// The maximum the HUD's hearts last read (its cached value).
|
||||
"max_health": p
|
||||
.attributes
|
||||
.lookup(p.attributes.key("minecraft:max_health").as_ref())
|
||||
.and_then(|h| p.attributes.instance(&h).ok())
|
||||
.map(|i| i.cached_value()),
|
||||
"food": p.health.food.food,
|
||||
})
|
||||
});
|
||||
@@ -854,6 +892,8 @@ impl Game<'_> {
|
||||
"source_attempted": self.lod_source_attempted,
|
||||
"active_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().0),
|
||||
"cached_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().1),
|
||||
"far_detail": self.settings.horizon.far_detail.name(),
|
||||
"resident_quads": self.renderer.as_ref().map_or(0, |r| r.lod_quads()),
|
||||
"pending_observations": self.lod_observe.len(),
|
||||
"refined_samples": self.lod.as_ref().map_or(0, |lod| lod.refined_samples()),
|
||||
});
|
||||
@@ -937,6 +977,12 @@ impl Game<'_> {
|
||||
"flashes": self.lightning_seen[1], "flash": self.flash});
|
||||
// Primed TNT, falling blocks, projectiles, tridents and end crystals.
|
||||
status["projectiles"] = json!(self.projectiles_status());
|
||||
// The particles made so far, by type.
|
||||
status["particles"] = json!(self.particles.made());
|
||||
// The world border this side knows of.
|
||||
status["border"] = json!({ "center": self.border.center(), "size": self.border.size() });
|
||||
// The scoreboard sidebar the HUD shows.
|
||||
status["sidebar"] = self.sidebar_status();
|
||||
status
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,8 +343,12 @@ impl Game<'_> {
|
||||
"on_ground": m.on_ground,
|
||||
"variant": m.extra.variant,
|
||||
"color": m.extra.color,
|
||||
"sheared": m.extra.sheared,
|
||||
"burning": m.fire_ticks > 0,
|
||||
"size": m.extra.size,
|
||||
"leash": m.extra.leash.map(|h| format!("{h:?}")),
|
||||
"tame": m.extra.tame,
|
||||
"sitting": m.extra.sitting,
|
||||
"temper": m.extra.variant3,
|
||||
"chested": m.extra.chested,
|
||||
"head": m.extra.head.as_deref(),
|
||||
|
||||
@@ -291,12 +291,12 @@ impl Game<'_> {
|
||||
used = self.fixture_use_item(origin, op)?;
|
||||
} else if let Some(op) = case.get("respawn_at") {
|
||||
// ServerPlayer.findRespawnAndUseSpawnBlock for a respawn point at the origin.
|
||||
let config = super::respawn_anchor::RespawnConfig {
|
||||
dim: self.dim,
|
||||
pos: [origin.x, origin.y, origin.z],
|
||||
yaw: 0.0,
|
||||
forced: op["forced"].as_bool().unwrap_or(false),
|
||||
};
|
||||
let config = super::respawn_anchor::RespawnConfig::wrapped(
|
||||
self.dim,
|
||||
[origin.x, origin.y, origin.z],
|
||||
op["yaw"].as_f64().map_or(0.0, |v| v as f32),
|
||||
op["forced"].as_bool().unwrap_or(false),
|
||||
);
|
||||
let found =
|
||||
self.find_respawn_and_use(config, op["consume"].as_bool().unwrap_or(true))?;
|
||||
used.push(match found {
|
||||
|
||||
@@ -2697,9 +2697,12 @@ impl Game<'_> {
|
||||
velocity: [d.velocity.x, d.velocity.y, d.velocity.z],
|
||||
data: 0,
|
||||
meta,
|
||||
defaults: Vec::new(),
|
||||
equipment: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
precise: false,
|
||||
needs_sync: false,
|
||||
leash: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,15 @@ use super::Game;
|
||||
use horizon_client::{enchant::EffectContext, items::ItemStack};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
|
||||
#[derive(Clone, Copy)]
|
||||
enum MendSlot {
|
||||
Main,
|
||||
Off,
|
||||
/// An `armor` index (head first).
|
||||
Armor(usize),
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Player.getDestroySpeed` after the tool speed: mining efficiency, haste
|
||||
/// and mining fatigue.
|
||||
@@ -314,36 +323,29 @@ impl Game<'_> {
|
||||
pub(super) fn mending(&mut self, amount: u32) -> u32 {
|
||||
let mut amount = amount;
|
||||
loop {
|
||||
// EnchantmentHelper.getRandomItemWith(REPAIR_WITH_XP, damaged): held and armor.
|
||||
let mut candidates: Vec<(bool, usize)> = Vec::new();
|
||||
if let Some(s) = &self.inventory[self.selected]
|
||||
&& s.damage > 0
|
||||
&& s.enchantment_level("minecraft:mending") > 0
|
||||
{
|
||||
candidates.push((false, self.selected));
|
||||
}
|
||||
for (i, s) in self.armor.iter().enumerate() {
|
||||
if let Some(s) = s
|
||||
&& s.damage > 0
|
||||
&& s.enchantment_level("minecraft:mending") > 0
|
||||
{
|
||||
candidates.push((true, i));
|
||||
}
|
||||
}
|
||||
// EnchantmentHelper.getRandomItemWith(REPAIR_WITH_XP, damaged), in
|
||||
// EquipmentSlot order: the main hand, the off hand, then feet, legs,
|
||||
// chest and head (`armor` holds head first).
|
||||
let mending = |s: &Option<ItemStack>| {
|
||||
s.as_ref()
|
||||
.is_some_and(|s| s.damage > 0 && s.enchantment_level("minecraft:mending") > 0)
|
||||
};
|
||||
let candidates: Vec<MendSlot> = [MendSlot::Main, MendSlot::Off]
|
||||
.into_iter()
|
||||
.chain((0..self.armor.len()).rev().map(MendSlot::Armor))
|
||||
.filter(|&slot| mending(self.mend_slot(slot)))
|
||||
.collect();
|
||||
if candidates.is_empty() {
|
||||
return amount;
|
||||
}
|
||||
let (armor, i) = candidates[self
|
||||
let slot = candidates[self
|
||||
.world
|
||||
.random
|
||||
.next_int_bound(candidates.len() as i32)
|
||||
.unwrap_or(0) as usize];
|
||||
let stack = if armor {
|
||||
self.armor[i].clone()
|
||||
} else {
|
||||
self.inventory[i].clone()
|
||||
let Some(stack) = self.mend_slot(slot).clone() else {
|
||||
return amount;
|
||||
};
|
||||
let Some(stack) = stack else { return amount };
|
||||
let ctx = EffectContext {
|
||||
items: Some(self.res.items()),
|
||||
..Default::default()
|
||||
@@ -356,12 +358,7 @@ impl Game<'_> {
|
||||
amount as f32,
|
||||
) as u32;
|
||||
let repaired = to_repair_xp.min(stack.damage);
|
||||
let slot = if armor {
|
||||
&mut self.armor[i]
|
||||
} else {
|
||||
&mut self.inventory[i]
|
||||
};
|
||||
if let Some(s) = slot.as_mut() {
|
||||
if let Some(s) = self.mend_slot_mut(slot).as_mut() {
|
||||
s.damage -= repaired;
|
||||
}
|
||||
if repaired == 0 || to_repair_xp == 0 {
|
||||
@@ -374,6 +371,20 @@ impl Game<'_> {
|
||||
amount = remaining;
|
||||
}
|
||||
}
|
||||
fn mend_slot(&self, slot: MendSlot) -> &Option<ItemStack> {
|
||||
match slot {
|
||||
MendSlot::Main => &self.inventory[self.selected],
|
||||
MendSlot::Off => &self.offhand,
|
||||
MendSlot::Armor(i) => &self.armor[i],
|
||||
}
|
||||
}
|
||||
fn mend_slot_mut(&mut self, slot: MendSlot) -> &mut Option<ItemStack> {
|
||||
match slot {
|
||||
MendSlot::Main => &mut self.inventory[self.selected],
|
||||
MendSlot::Off => &mut self.offhand,
|
||||
MendSlot::Armor(i) => &mut self.armor[i],
|
||||
}
|
||||
}
|
||||
/// Bow enchantments at release: (extra base damage, infinity keeps the arrow).
|
||||
pub(super) fn bow_enchantments(&mut self) -> (f64, bool) {
|
||||
let Some(bow) = self.inventory[self.selected].clone() else {
|
||||
|
||||
@@ -23,7 +23,7 @@ const GATEWAY_FILE: &str = "horizon-end-gateways.json";
|
||||
|
||||
/// A gateway teleport waiting for its destination chunks.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum GatewayJob {
|
||||
pub(super) enum GatewayJob {
|
||||
/// TheEndGatewayBlockEntity.findOrCreateValidTeleportPos from this gateway.
|
||||
Find { gateway: [i32; 3] },
|
||||
/// A known exit (exact, or findExitPosition around it).
|
||||
@@ -97,8 +97,58 @@ impl EndState {
|
||||
}
|
||||
}
|
||||
|
||||
impl EndState {
|
||||
/// The eyes' entity ids, for a server to number them.
|
||||
pub(super) fn eye_ids(&mut self) -> impl Iterator<Item = &mut u32> {
|
||||
self.eyes.iter_mut().map(|e| &mut e.id)
|
||||
}
|
||||
/// The numbered eyes as a server's clients track them: (id, position, motion).
|
||||
pub(super) fn eyes_tracked(&self) -> impl Iterator<Item = (u32, [f64; 3], [f64; 3])> {
|
||||
self.eyes
|
||||
.iter()
|
||||
.filter(|e| e.id != 0)
|
||||
.map(|e| (e.id, e.position.to_array(), e.velocity.to_array()))
|
||||
}
|
||||
/// `handleAddEntity` for an eye of ender on a server's client: it flies
|
||||
/// where the server says.
|
||||
pub(super) fn add_remote_eye(&mut self, id: u32, at: [f64; 3], velocity: [f64; 3]) {
|
||||
self.eyes.retain(|e| e.id != id);
|
||||
let at = glam::DVec3::from_array(at);
|
||||
self.eyes.push(EyeOfEnder {
|
||||
id,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: glam::DVec3::from_array(velocity),
|
||||
target: at,
|
||||
life: 0,
|
||||
survive: false,
|
||||
});
|
||||
}
|
||||
/// One tick of a server's client: `step` moves each toward where the
|
||||
/// server put it.
|
||||
pub(super) fn step_remote_eyes(&mut self, mut step: impl FnMut(u32, [f64; 3]) -> [f64; 3]) {
|
||||
for e in &mut self.eyes {
|
||||
e.previous = e.position;
|
||||
e.life += 1;
|
||||
e.position = glam::DVec3::from_array(step(e.id, e.position.to_array()));
|
||||
}
|
||||
}
|
||||
pub(super) fn remove_remote_eye(&mut self, id: u32) {
|
||||
self.eyes.retain(|e| e.id != id);
|
||||
}
|
||||
pub(super) fn clear_remote_eyes(&mut self) {
|
||||
self.eyes.clear();
|
||||
}
|
||||
/// Where each eye is, for `status`.
|
||||
pub(super) fn eye_positions(&self) -> impl Iterator<Item = (u32, [f64; 3])> {
|
||||
self.eyes.iter().map(|e| (e.id, e.position.to_array()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The EyeOfEnder entity.
|
||||
struct EyeOfEnder {
|
||||
/// Its entity id once a server's clients track it (0 until then).
|
||||
id: u32,
|
||||
position: glam::DVec3,
|
||||
previous: glam::DVec3,
|
||||
velocity: glam::DVec3,
|
||||
@@ -342,7 +392,10 @@ impl Game<'_> {
|
||||
f64::from(p.dimensions.width),
|
||||
f64::from(p.dimensions.height),
|
||||
);
|
||||
if portal {
|
||||
if portal && self.dim == Dim::End && !self.level.seen_credits {
|
||||
// EndPortalBlock.entityInside: the first exit shows the credits.
|
||||
self.show_end_credits()?;
|
||||
} else if portal {
|
||||
if self.portals.player.cooldown == 0 {
|
||||
let target = if self.dim == Dim::End {
|
||||
Dim::Overworld
|
||||
@@ -413,7 +466,15 @@ impl Game<'_> {
|
||||
f64::from(END_SPAWN[2]) + 0.5,
|
||||
));
|
||||
}
|
||||
// Leaving the End: the respawn position (the world spawn here).
|
||||
// Leaving the End: the respawn position, a bed's found once it has
|
||||
// loaded (findRespawnPositionAndUseSpawnBlock, not using it up).
|
||||
if let Some(c) = self.respawn_config.filter(|c| c.dim == target) {
|
||||
return Some(Vec3::new(
|
||||
f64::from(c.pos[0]) + 0.5,
|
||||
f64::from(c.pos[1]),
|
||||
f64::from(c.pos[2]) + 0.5,
|
||||
));
|
||||
}
|
||||
let s = self.level.spawn.unwrap_or([0.0, 64.0, 0.0]);
|
||||
Some(Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5))
|
||||
}
|
||||
@@ -484,7 +545,16 @@ impl Game<'_> {
|
||||
// Direction.WEST.toYRot().
|
||||
return Ok((target, Some(90.0)));
|
||||
}
|
||||
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: onto the spawn column.
|
||||
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: where the bed or
|
||||
// anchor puts the player, facing it; else onto the spawn column.
|
||||
let mut target = target;
|
||||
if let Some(c) = self.respawn_config.filter(|c| c.dim == self.dim) {
|
||||
if let Some((at, yaw)) = self.find_respawn_and_use(c, false)? {
|
||||
return Ok((at, Some(yaw)));
|
||||
}
|
||||
let s = self.level.spawn.unwrap_or([0.0, 64.0, 0.0]);
|
||||
target = Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5);
|
||||
}
|
||||
let y = self
|
||||
.land_column(target.x.floor() as i32, target.z.floor() as i32)
|
||||
.map_or(target.y, f64::from);
|
||||
@@ -700,6 +770,20 @@ impl Game<'_> {
|
||||
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
|
||||
// TheEndGatewayBlockEntity.triggerCooldown: 40 ticks.
|
||||
self.end.gateway_cooldown = 40;
|
||||
let (job, destination) = self.gateway_job(gateway);
|
||||
let Some(player) = self.player.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
self.end.job = Some(job);
|
||||
self.end_travel = true;
|
||||
self.travelling = Some((player, destination));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where `gateway` leads (TheEndGatewayBlockEntity.getPortalPosition):
|
||||
/// its exit, or the search for one, and the spot to load before either
|
||||
/// is resolved ([`Game::resolve_gateway`]).
|
||||
pub(super) fn gateway_job(&mut self, gateway: [i32; 3]) -> (GatewayJob, Vec3) {
|
||||
let known = self.gateway_exits().get(&gateway).copied();
|
||||
let job = match known {
|
||||
Some((exit, exact)) => GatewayJob::Known { exit, exact },
|
||||
@@ -725,13 +809,7 @@ impl Game<'_> {
|
||||
Vec3::new(d.x, 75.0, d.y)
|
||||
}
|
||||
};
|
||||
let Some(player) = self.player.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
self.end.job = Some(job);
|
||||
self.end_travel = true;
|
||||
self.travelling = Some((player, destination));
|
||||
Ok(())
|
||||
(job, destination)
|
||||
}
|
||||
|
||||
fn chunk_empty(&self, x: f64, z: f64) -> Option<bool> {
|
||||
@@ -768,7 +846,7 @@ impl Game<'_> {
|
||||
|
||||
/// The teleport position of a gateway job, creating the exit gateway (and
|
||||
/// an island when the chunk has no landing spot) for new destinations.
|
||||
fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
|
||||
pub(super) fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
|
||||
let bottom_center = |p: [i32; 3]| {
|
||||
Vec3::new(
|
||||
f64::from(p[0]) + 0.5,
|
||||
@@ -995,6 +1073,7 @@ impl Game<'_> {
|
||||
};
|
||||
let survive = self.world.random.next_int_bound(5).unwrap_or(0) > 0;
|
||||
self.end.eyes.push(EyeOfEnder {
|
||||
id: 0,
|
||||
position: start,
|
||||
previous: start,
|
||||
velocity: glam::DVec3::ZERO,
|
||||
|
||||
@@ -224,7 +224,7 @@ impl Game<'_> {
|
||||
}
|
||||
set.entries().map(|(k, _)| k.0).collect()
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT);
|
||||
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT, minecarts);
|
||||
/// the player takes `damage_type`.
|
||||
/// `ServerExplosion.hurtEntities` for the player: damage scaled by
|
||||
/// distance and exposure, through a raised shield (`hurtServer` blocks
|
||||
@@ -460,6 +460,34 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
self.tnts = tnts;
|
||||
// Minecarts: VehicleEntity.hurtServer (explosions ignite TNT carts),
|
||||
// then the push from their eyes (0.85 of their height).
|
||||
let ids: Vec<u32> = self.carts.iter().map(|c| c.id).collect();
|
||||
for id in ids {
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let bounds = self.carts[i].bounds();
|
||||
let feet = self.carts[i].position;
|
||||
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
self.hurt_cart(i, damage(dist, exposure), false, true);
|
||||
let Some(c) = self.carts.iter_mut().find(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let eye_height = f64::from((hi.y - lo.y) as f32 * 0.85f32);
|
||||
let dir = direction([feet.x, feet.y + eye_height, feet.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
c.velocity = Vec3::new(
|
||||
c.velocity.x + dir[0] * power,
|
||||
c.velocity.y + dir[1] * power,
|
||||
c.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `Level.explode` on the server: positions, entities, blocks, fire; then
|
||||
/// the client's explosion sound.
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
//! the world's options) with [`Integrated::call`], as vanilla's client calls
|
||||
//! into its `IntegratedServer`.
|
||||
//!
|
||||
//! On by default. `--no-server-thread` or `HORIZON_SERVER_THREAD=0` runs
|
||||
//! the world in the frame instead (the escape hatch while the network
|
||||
//! player's side catches up with the in-frame one).
|
||||
//! Opt-in again (`--server-thread` or `HORIZON_SERVER_THREAD=1`) until a
|
||||
//! server's players reach parity with the in-frame world: on the server
|
||||
//! thread, signs open no editor, books don't open, a totem doesn't save its
|
||||
//! holder and death keeps the inventory (#1380, #1366, #1292, #1284 and the
|
||||
//! other `[server-thread]` issues). LAN guests and dedicated servers use the
|
||||
//! same code either way.
|
||||
use super::{
|
||||
Game, Options, PackResources, Resources, UiResources,
|
||||
lan::{LanSettings, Owner},
|
||||
|
||||
@@ -14,7 +14,7 @@ use horizon_client::{
|
||||
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
playerdata,
|
||||
},
|
||||
mobs::Mob,
|
||||
mobs::{Mob, leash::Holder},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
@@ -66,6 +66,8 @@ pub(super) struct LanHost {
|
||||
/// whose respawn replaces their waypoint.
|
||||
pub waypoints: std::collections::HashMap<String, lan::waypoints::Waypoints>,
|
||||
pub waypoint_dead: std::collections::HashSet<u128>,
|
||||
/// The world border as the guests were last told of it (lan/border.rs).
|
||||
pub border_sent: Option<lan::border::Sent>,
|
||||
/// Guests' commands waiting for the hub to be back in `self.lan`, where
|
||||
/// a command can reach every player (game/lan_actions.rs).
|
||||
pub commands: Vec<super::lan_actions::GuestCommand>,
|
||||
@@ -91,7 +93,7 @@ pub(super) fn entity_uuid(id: u32) -> u128 {
|
||||
/// The SynchedEntityData index of the baby flag: AgeableMob.DATA_BABY_ID and
|
||||
/// Zombie.DATA_BABY_ID are both the first entry after Mob's (16); Piglin's
|
||||
/// comes after AbstractPiglin's zombification flag (17).
|
||||
fn baby_index(kind: &str) -> Option<u8> {
|
||||
pub(super) fn baby_index(kind: &str) -> Option<u8> {
|
||||
match kind {
|
||||
"minecraft:piglin" => Some(17),
|
||||
"minecraft:cow"
|
||||
@@ -139,7 +141,13 @@ fn baby_index(kind: &str) -> Option<u8> {
|
||||
/// A mob's synched data as ServerEntity sends it (only the values that differ
|
||||
/// from the defaults the client constructs), with vanilla's entry indices:
|
||||
/// Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses.
|
||||
fn mob_meta(m: &Mob, hub: &Hub, meta: &mut Vec<(u8, i32, Value)>) {
|
||||
/// `defaults` gets the defaults of the entries that can go back to theirs.
|
||||
fn mob_meta(
|
||||
m: &Mob,
|
||||
hub: &Hub,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
let kind = m.kind.id.as_str();
|
||||
if m.fire_ticks > 0 {
|
||||
meta.push((0, serializer::BYTE, Value::Byte(1)));
|
||||
@@ -159,15 +167,18 @@ fn mob_meta(m: &Mob, hub: &Hub, meta: &mut Vec<(u8, i32, Value)>) {
|
||||
if health != 1.0 {
|
||||
meta.push((9, serializer::FLOAT, Value::Float(health)));
|
||||
}
|
||||
defaults.push((9, serializer::FLOAT, Value::Float(1.0)));
|
||||
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2.
|
||||
let flags = i8::from(m.no_ai) | i8::from(m.extra.left_handed) << 1;
|
||||
if flags != 0 {
|
||||
meta.push((15, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
if m.is_baby()
|
||||
&& let Some(index) = baby_index(kind)
|
||||
{
|
||||
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
|
||||
// AgeableMob.DATA_BABY_ID (and Zombie's, Piglin's).
|
||||
if let Some(index) = baby_index(kind) {
|
||||
if m.is_baby() {
|
||||
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((index, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
let e = &m.extra;
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
@@ -178,6 +189,7 @@ fn mob_meta(m: &Mob, hub: &Hub, meta: &mut Vec<(u8, i32, Value)>) {
|
||||
if value != 0 {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(value as i8)));
|
||||
}
|
||||
defaults.push((18, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
"minecraft:cat" | "minecraft:wolf" => {
|
||||
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
|
||||
@@ -200,6 +212,13 @@ fn mob_meta(m: &Mob, hub: &Hub, meta: &mut Vec<(u8, i32, Value)>) {
|
||||
if let Some(v) = id(registry, name) {
|
||||
meta.push((index, serializer, Value::Id(v)));
|
||||
}
|
||||
// Wolf.DATA_COLLAR_COLOR and Cat's, red by default.
|
||||
let collar = if kind == "minecraft:cat" { 23 } else { 21 };
|
||||
let red = i32::from(horizon_client::mobs::tame::RED);
|
||||
if i32::from(e.color & 15) != red {
|
||||
meta.push((collar, serializer::INT, Value::Int(i32::from(e.color & 15))));
|
||||
}
|
||||
defaults.push((collar, serializer::INT, Value::Int(red)));
|
||||
}
|
||||
"minecraft:horse"
|
||||
| "minecraft:donkey"
|
||||
@@ -390,9 +409,14 @@ impl Game<'_> {
|
||||
"placed": g.placed,
|
||||
"loaded": g.loaded,
|
||||
"game_mode": g.game_mode,
|
||||
// ItemCooldowns: the ticks the shield an axe disabled stays down.
|
||||
"shield_cooldown": h.play.get(&g.conn).map_or(0, |p| p.shield_cooldown),
|
||||
"sneaking": g.sneaking,
|
||||
"sleeping": g.bed,
|
||||
"health": g.health,
|
||||
// The server's body: its air supply and golden hearts.
|
||||
"air": g.motion.as_ref().map(|m| m.air),
|
||||
"absorption": g.motion.as_ref().map(|m| m.health.absorption()),
|
||||
// FoodData: food level, saturation and exhaustion.
|
||||
"food": [
|
||||
f64::from(g.food),
|
||||
@@ -539,6 +563,7 @@ impl Game<'_> {
|
||||
owner: None,
|
||||
waypoints: Default::default(),
|
||||
waypoint_dead: Default::default(),
|
||||
border_sent: None,
|
||||
commands: Vec::new(),
|
||||
states: Default::default(),
|
||||
});
|
||||
@@ -752,17 +777,37 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// The entities guests track: mobs, items and the rest (game/lan_entities.rs).
|
||||
fn lan_entities(&self, host_entity: Option<i32>, hub: &mut Hub, out: &mut States) {
|
||||
/// `mob_players` is the guest each mob sees as its player (game/lan_mobs.rs).
|
||||
fn lan_entities(
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
mob_players: &HashMap<u32, u32>,
|
||||
hub: &mut Hub,
|
||||
out: &mut States,
|
||||
) {
|
||||
out.clear();
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
// The lead's holder: the player the mob sees (a guest, else the
|
||||
// host) or another mob. A fence knot isn't an entity here.
|
||||
let leash = m.extra.leash.and_then(|h| match h {
|
||||
Holder::Player => mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id)
|
||||
.or(host_entity),
|
||||
Holder::Mob(id) => Some(id as i32),
|
||||
Holder::Knot(_) => None,
|
||||
});
|
||||
let e = out.add(m.id as i32, entity_uuid(m.id), m.kind.id.as_str());
|
||||
e.position = [m.position.x, m.position.y, m.position.z];
|
||||
(e.yaw, e.pitch, e.head_yaw) = (m.yaw, m.head_pitch, m.head_yaw);
|
||||
e.on_ground = m.on_ground;
|
||||
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
|
||||
mob_meta(m, hub, &mut e.meta);
|
||||
e.needs_sync = m.needs_sync;
|
||||
mob_meta(m, hub, &mut e.meta, &mut e.defaults);
|
||||
mob_equipment(m, hub, &mut e.equipment);
|
||||
self.mob_attributes(m, &mut e.attributes);
|
||||
e.leash = leash;
|
||||
}
|
||||
for d in &self.dropped {
|
||||
let stack = hub.stack(&Some(d.stack.clone()));
|
||||
@@ -824,10 +869,14 @@ impl Game<'_> {
|
||||
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
|
||||
.collect();
|
||||
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
|
||||
self.lan_entities(host_entity, &mut host.hub, &mut states);
|
||||
self.lan_entities(host_entity, &host.mob_players, &mut host.hub, &mut states);
|
||||
} else {
|
||||
states.clear();
|
||||
}
|
||||
// ServerEntity.sendChanges clears needsSync once it has looked.
|
||||
for m in &mut self.mobs {
|
||||
m.needs_sync = false;
|
||||
}
|
||||
let entities = states.as_slice();
|
||||
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
|
||||
let events = {
|
||||
@@ -845,6 +894,7 @@ impl Game<'_> {
|
||||
tick_frozen: self.clock.frozen(),
|
||||
tick_steps: self.clock.step_state(),
|
||||
collision: Some(self.collision),
|
||||
border: &self.border,
|
||||
};
|
||||
host.hub.movement_checks = (
|
||||
self.ui.rules.bool("player_movement_check"),
|
||||
@@ -951,6 +1001,8 @@ impl Game<'_> {
|
||||
self.lan_update_streaming(&host);
|
||||
self.lan_update_models(&mut host);
|
||||
self.lan_update_waypoints(&mut host, state.as_ref());
|
||||
self.lan_sync_scoreboard(&mut host);
|
||||
self.lan_sync_border(&mut host);
|
||||
drop(holding);
|
||||
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
|
||||
self.lan = Some(host);
|
||||
@@ -1008,7 +1060,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
// A dedicated server has no host player.
|
||||
self.lan_entities(None, &mut host.hub, &mut theirs);
|
||||
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs);
|
||||
theirs.as_slice()
|
||||
} else {
|
||||
entities
|
||||
@@ -1027,6 +1079,7 @@ impl Game<'_> {
|
||||
tick_frozen: self.clock.frozen(),
|
||||
tick_steps: self.clock.step_state(),
|
||||
collision: Some(self.collision),
|
||||
border: &self.border,
|
||||
};
|
||||
host.hub.place_new_player(conn, &mut ctx);
|
||||
if elsewhere && let Err(e) = self.enter_level(Some(host), back) {
|
||||
@@ -1041,8 +1094,14 @@ impl Game<'_> {
|
||||
}
|
||||
for e in events {
|
||||
if let horizon_world::level::LevelEvent::LevelEvent { id, pos, data } = e {
|
||||
// The acting guest does not predict the egg's teleport particles.
|
||||
let except = if *id == 2015 { None } else { self.lan_acting };
|
||||
// The acting guest does not predict the egg's teleport
|
||||
// particles, nor bone meal's growth (BoneMealItem.useOn's
|
||||
// levelEvent names no player).
|
||||
let except = if matches!(*id, 2015 | 1505) {
|
||||
None
|
||||
} else {
|
||||
self.lan_acting
|
||||
};
|
||||
self.lan_events
|
||||
.push((*id, [pos.x, pos.y, pos.z], *data, except));
|
||||
}
|
||||
@@ -1243,7 +1302,7 @@ impl Game<'_> {
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
for m in host.models.values() {
|
||||
for (conn, m) in &host.models {
|
||||
let entity_start = out.len();
|
||||
let light = self.world.light(
|
||||
m.position.x.floor() as i32,
|
||||
@@ -1258,7 +1317,10 @@ impl Game<'_> {
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
&self.worn_armor_layers(m),
|
||||
&host.hub.guest(*conn).map_or_else(
|
||||
|| self.worn_armor_layers(m),
|
||||
|g| self.stack_armor_layers(&g.armor),
|
||||
),
|
||||
out,
|
||||
);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! blocks, attacking and the commands all behave exactly as they do for the
|
||||
//! host, and every world change they cause reaches all players through the
|
||||
//! block log, the level events and the entity tracker.
|
||||
use super::{Game, GuestUse, Hit, Mining, Screen, lan::LanHost};
|
||||
use super::{Game, GuestUse, Hit, Mining, Screen, lan::LanHost, respawn_anchor::RespawnConfig};
|
||||
use horizon_client::{
|
||||
command::vanilla,
|
||||
containers::BlockEntity,
|
||||
@@ -34,6 +34,8 @@ pub(super) struct GuestPlay {
|
||||
destroy_delay: u32,
|
||||
using: Option<(usize, u32)>,
|
||||
use_delay: u32,
|
||||
/// The shield's ItemCooldowns entry: an axe disabled it for this many ticks.
|
||||
pub(super) shield_cooldown: u32,
|
||||
attack_ticks: u32,
|
||||
/// The main hand's item at the guest's last tick (`Game::attack_item`).
|
||||
attack_item: Option<std::sync::Arc<str>>,
|
||||
@@ -117,6 +119,7 @@ pub(super) struct GuestSwap {
|
||||
destroy_delay: u32,
|
||||
using: Option<(usize, u32)>,
|
||||
use_delay: u32,
|
||||
pub(super) shield_cooldown: u32,
|
||||
attack_ticks: u32,
|
||||
attack_item: Option<std::sync::Arc<str>>,
|
||||
swing: horizon_client::first_person::SwingState,
|
||||
@@ -217,6 +220,18 @@ impl Game<'_> {
|
||||
host.hub.guests[i].position[2],
|
||||
);
|
||||
host.hub.guests[i].motion = PlayerMotion::new(self.motion, p).ok().map(Box::new);
|
||||
// The first body after joining burns, breathes and falls on as saved.
|
||||
if let Some(saved) = host.hub.guests[i].loaded_body.take()
|
||||
&& let Some(m) = host.hub.guests[i].motion.as_mut()
|
||||
{
|
||||
saved.apply(m);
|
||||
}
|
||||
// The saved golden hearts go on the first body of a player who
|
||||
// has been here before.
|
||||
let saved = host.hub.guests[i].saved_absorption.take();
|
||||
if let (Some(m), Some(amount)) = (host.hub.guests[i].motion.as_mut(), saved) {
|
||||
let _ = m.internal_set_absorption(amount);
|
||||
}
|
||||
let kept = host.hub.guests[i].logs_kept.take();
|
||||
if let (Some(m), Some((hurts, attacks))) = (host.hub.guests[i].motion.as_mut(), kept) {
|
||||
m.hurt_log = hurts;
|
||||
@@ -290,6 +305,7 @@ impl Game<'_> {
|
||||
let destroy_delay = std::mem::replace(&mut self.destroy_delay, play.destroy_delay);
|
||||
let using = std::mem::replace(&mut self.using, play.using.take());
|
||||
let use_delay = std::mem::replace(&mut self.use_delay, play.use_delay);
|
||||
let shield_cooldown = std::mem::replace(&mut self.shield_cooldown, play.shield_cooldown);
|
||||
let attack_ticks = std::mem::replace(&mut self.attack_ticks, play.attack_ticks);
|
||||
let attack_item = std::mem::replace(&mut self.attack_item, play.attack_item.take());
|
||||
let swing = std::mem::replace(&mut self.swing, play.swing);
|
||||
@@ -309,6 +325,7 @@ impl Game<'_> {
|
||||
g.spawn.map(|s| Vec3::new(s[0], s[1], s[2])),
|
||||
);
|
||||
let respawn = (self.respawn_config.take(), self.level.spawn);
|
||||
self.respawn_config = g.respawn_elsewhere.as_ref().map(RespawnConfig::from_saved);
|
||||
let message = self.message.take();
|
||||
// The guest's own ender inventory; one the host has open goes back
|
||||
// into the host's while the guest acts.
|
||||
@@ -369,6 +386,7 @@ impl Game<'_> {
|
||||
destroy_delay,
|
||||
using,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
attack_ticks,
|
||||
attack_item,
|
||||
swing,
|
||||
@@ -421,6 +439,7 @@ impl Game<'_> {
|
||||
destroy_delay,
|
||||
using,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
attack_ticks,
|
||||
attack_item,
|
||||
swing,
|
||||
@@ -470,7 +489,8 @@ impl Game<'_> {
|
||||
let bed = std::mem::replace(&mut self.sleeping, sleeping);
|
||||
let guest_sleep_timer = std::mem::replace(&mut self.sleep_timer, sleep_timer);
|
||||
let guest_spawn = std::mem::replace(&mut self.spawn, spawn).map(|s| [s.x, s.y, s.z]);
|
||||
(self.respawn_config, self.level.spawn) = respawn;
|
||||
let guest_respawn = std::mem::replace(&mut self.respawn_config, respawn.0);
|
||||
self.level.spawn = respawn.1;
|
||||
// ServerPlayer.displayClientMessage(_, true): the guest's action bar.
|
||||
let guest_message = std::mem::replace(&mut self.message, message);
|
||||
let guest_vehicle = std::mem::replace(&mut self.riding, riding);
|
||||
@@ -480,6 +500,7 @@ impl Game<'_> {
|
||||
play.destroy_delay = std::mem::replace(&mut self.destroy_delay, destroy_delay);
|
||||
play.using = std::mem::replace(&mut self.using, using);
|
||||
play.use_delay = std::mem::replace(&mut self.use_delay, use_delay);
|
||||
play.shield_cooldown = std::mem::replace(&mut self.shield_cooldown, shield_cooldown);
|
||||
play.attack_ticks = std::mem::replace(&mut self.attack_ticks, attack_ticks);
|
||||
play.attack_item = std::mem::replace(&mut self.attack_item, attack_item);
|
||||
play.swing = std::mem::replace(&mut self.swing, swing);
|
||||
@@ -507,6 +528,7 @@ impl Game<'_> {
|
||||
g.vehicle = guest_vehicle;
|
||||
let fell_asleep = bed.is_some() && g.bed.is_none();
|
||||
(g.bed, g.sleep_timer, g.spawn) = (bed, guest_sleep_timer, guest_spawn);
|
||||
g.respawn_elsewhere = guest_respawn.map(|c| c.saved());
|
||||
g.sleeping = g.bed.is_some();
|
||||
if let Some((text, _)) = guest_message {
|
||||
g.send(&C::SystemChat(play::SystemChat {
|
||||
@@ -1486,17 +1508,43 @@ impl Game<'_> {
|
||||
if !host.hub.guests[i].dead {
|
||||
return;
|
||||
}
|
||||
// PlayerList.respawn: a server's player comes back in the Overworld,
|
||||
// where the world spawn is (respawn anchors and beds aside).
|
||||
// PlayerList.respawn: a server's player comes back in its respawn
|
||||
// point's level: the Overworld, where the world spawn and beds are,
|
||||
// or another one's anchor or forced point, which its block resolves
|
||||
// once the level has loaded around it.
|
||||
let server = self.options.dedicated.is_some();
|
||||
let own = host.hub.guests[i]
|
||||
.respawn_elsewhere
|
||||
.as_ref()
|
||||
.map(RespawnConfig::from_saved);
|
||||
let elsewhere = own.filter(|_| server);
|
||||
// In the window's world a guest has no level of its own to wait in:
|
||||
// its bed is resolved at once when its chunk is there, else it
|
||||
// comes back on the bed.
|
||||
let in_frame_bed = own.filter(|c| !server && c.dim == super::Dim::Overworld);
|
||||
let spawn = in_frame_bed.map_or(spawn, |c| {
|
||||
[
|
||||
f64::from(c.pos[0]) + 0.5,
|
||||
f64::from(c.pos[1]) + 0.6,
|
||||
f64::from(c.pos[2]) + 0.5,
|
||||
]
|
||||
});
|
||||
let level = elsewhere.map_or(super::Dim::Overworld, |c| c.dim);
|
||||
let spawn = elsewhere.map_or(spawn, |c| {
|
||||
[
|
||||
f64::from(c.pos[0]) + 0.5,
|
||||
f64::from(c.pos[1]),
|
||||
f64::from(c.pos[2]) + 0.5,
|
||||
]
|
||||
});
|
||||
let back = self.dim;
|
||||
let away = self.options.dedicated.is_some()
|
||||
&& host.hub.guests[i].dimension != super::Dim::Overworld.id();
|
||||
if away {
|
||||
let away = server && host.hub.guests[i].dimension != level.id();
|
||||
if server {
|
||||
self.server_trips.retain(|t| !t.is(conn));
|
||||
if let Err(e) = self.enter_level(Some(host), super::Dim::Overworld) {
|
||||
log::error!("could not open the Overworld: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if away && let Err(e) = self.enter_level(Some(host), level) {
|
||||
log::error!("could not open {}: {e}", level.id());
|
||||
return;
|
||||
}
|
||||
let g = &mut host.hub.guests[i];
|
||||
g.dead = false;
|
||||
@@ -1533,8 +1581,31 @@ impl Game<'_> {
|
||||
tick_frozen: self.clock.frozen(),
|
||||
tick_steps: self.clock.step_state(),
|
||||
collision: Some(self.collision),
|
||||
border: &self.border,
|
||||
};
|
||||
host.hub.respawn(i, spawn, &ctx);
|
||||
if let Some(config) = elsewhere {
|
||||
self.respawn_trip(conn, config);
|
||||
}
|
||||
if let Some(c) = in_frame_bed
|
||||
&& self.dim == super::Dim::Overworld
|
||||
&& self.world.loaded(c.pos[0], c.pos[2])
|
||||
{
|
||||
let found = self
|
||||
.as_guest(host, conn, |g| g.resolve_server_respawn(c))
|
||||
.flatten();
|
||||
let world = self
|
||||
.level
|
||||
.world_spawn
|
||||
.map_or([0.5, 80.0, 0.5], |[x, y, z]| {
|
||||
[f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5]
|
||||
});
|
||||
let at = found.map_or(world, |(at, yaw)| {
|
||||
host.hub.guests[i].yaw = yaw;
|
||||
[at.x, at.y, at.z]
|
||||
});
|
||||
host.hub.teleport(i, Some(at));
|
||||
}
|
||||
if away && let Err(e) = self.enter_level(Some(host), back) {
|
||||
log::error!("could not reopen {}: {e}", back.id());
|
||||
}
|
||||
@@ -1562,6 +1633,9 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
// Player.tick's ItemCooldowns.tick, once a tick for each player
|
||||
// (the shield an axe disabled).
|
||||
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
|
||||
let digging = play.digging;
|
||||
let using = play.using.is_some();
|
||||
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
|
||||
@@ -1616,6 +1690,9 @@ impl Game<'_> {
|
||||
self.guest_dig_feedback(host, conn);
|
||||
self.guest_pickups(host, gi);
|
||||
self.guest_orbs(host, gi);
|
||||
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
|
||||
self.guest_forget_digging(host, conn);
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if g.health <= 0.0 && !g.dead {
|
||||
g.dead = true;
|
||||
@@ -1765,6 +1842,7 @@ impl Game<'_> {
|
||||
}
|
||||
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
|
||||
let at = g.position;
|
||||
let sneaking = g.sneaking;
|
||||
let sounds = self.as_guest(host, conn, |game| {
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
@@ -1783,6 +1861,26 @@ impl Game<'_> {
|
||||
let _ = w.fluid_base_tick();
|
||||
w.player.ground.on_ground = from_ground;
|
||||
let travelled: Vec<[f64; 3]> = moves.iter().map(|m| m.0).collect();
|
||||
// Entity.move's movementThisTick: each move, from where the
|
||||
// tick's first one started.
|
||||
let end = w.player.position;
|
||||
let start = travelled
|
||||
.iter()
|
||||
.fold(end, |p, d| p.subtract(Vec3::new(d[0], d[1], d[2])));
|
||||
w.player.history.clear();
|
||||
let mut from = start;
|
||||
for d in &travelled {
|
||||
let delta = Vec3::new(d[0], d[1], d[2]);
|
||||
let to = from.add_vector(delta);
|
||||
w.player
|
||||
.history
|
||||
.add(horizon_world::entity_move::MovementRecord {
|
||||
from,
|
||||
to,
|
||||
original: Some(delta),
|
||||
});
|
||||
from = to;
|
||||
}
|
||||
for (moved, on_ground) in moves {
|
||||
let movement = Vec3::new(moved[0], moved[1], moved[2]);
|
||||
if w.player.ground.on_ground && !on_ground && moved[1] > 0.0 {
|
||||
@@ -1808,6 +1906,10 @@ impl Game<'_> {
|
||||
if w.climbable().unwrap_or(false) {
|
||||
w.player.fall_distance = 0.0;
|
||||
}
|
||||
// Entity.applyEffectsFromBlocks along those moves: magma, cactus,
|
||||
// lit campfires and grown sweet berry bushes hurt (#1305).
|
||||
w.player.shift_key_down = sneaking;
|
||||
let _ = w.server_block_damage(start);
|
||||
let _ = w.tick_environment_damage();
|
||||
let _ = w.apply_fire_contact();
|
||||
let _ = w.server_hazards_tick(in_wall);
|
||||
@@ -1817,7 +1919,8 @@ impl Game<'_> {
|
||||
for d in travelled {
|
||||
game.movement_stats(Vec3::new(d[0], d[1], d[2]));
|
||||
}
|
||||
// LivingEntity.baseTick ends with tickEffects.
|
||||
// LivingEntity.baseTick: outside the world border, then tickEffects.
|
||||
game.border_damage();
|
||||
game.tick_mob_effects();
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return Vec::new();
|
||||
@@ -1968,6 +2071,23 @@ impl Game<'_> {
|
||||
|
||||
/// `level.destroyBlockProgress(id, pos, -1)` for a guest: its crack on `pos` is gone, for the
|
||||
/// host and the other guests.
|
||||
/// A player who dies digs and uses nothing any more: the block it was
|
||||
/// breaking, or had left to break (`hasDelayedDestroy`), stays, and its
|
||||
/// crack goes. Respawning gives it a new ServerPlayerGameMode (#1315).
|
||||
fn guest_forget_digging(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(play) = host.play.get_mut(&conn) else {
|
||||
return;
|
||||
};
|
||||
let cracked = play.digging.take().filter(|_| play.last_progress >= 0);
|
||||
play.mining = None;
|
||||
play.delayed = None;
|
||||
play.destroy_delay = 0;
|
||||
play.using = None;
|
||||
play.last_progress = -1;
|
||||
if let Some((pos, _)) = cracked {
|
||||
self.guest_crack_gone(host, conn, pos);
|
||||
}
|
||||
}
|
||||
fn guest_crack_gone(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
|
||||
let Some(id) = host.hub.guest(conn).map(|g| g.entity_id) else {
|
||||
return;
|
||||
|
||||
@@ -36,9 +36,12 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
|
||||
velocity: [0.0; 3],
|
||||
data: 0,
|
||||
meta: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
equipment: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
precise: false,
|
||||
needs_sync: false,
|
||||
leash: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +277,8 @@ impl Game<'_> {
|
||||
self.rockets.iter_mut().for_each(|r| give(&mut r.id));
|
||||
self.crystals.iter_mut().for_each(|c| give(&mut c.id));
|
||||
self.bolts.iter_mut().for_each(|b| give(&mut b.id));
|
||||
self.potions.thrown_ids().for_each(&mut give);
|
||||
self.end.eye_ids().for_each(&mut give);
|
||||
}
|
||||
|
||||
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
|
||||
@@ -356,6 +361,26 @@ impl Game<'_> {
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
// Thrown potions and bottles: ThrowableItemProjectile.DATA_ITEM_STACK
|
||||
// (the potion's colour), which a plain bottle's type already has.
|
||||
for (id, kind, stack, position, velocity) in self.potions.thrown_tracked() {
|
||||
let mut e = state(id, kind, at(position), 0.0, false);
|
||||
e.velocity = at(velocity);
|
||||
if stack.extra.is_some() || stack.count != 1 {
|
||||
e.meta.push((
|
||||
8,
|
||||
serializer::ITEM_STACK,
|
||||
Value::Item(hub.stack(&Some(stack.clone()))),
|
||||
));
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
// EyeOfEnder: its item is the plain eye (DATA_ITEM_STACK's default).
|
||||
for (id, position, velocity) in self.end.eyes_tracked() {
|
||||
let mut e = state(id, "minecraft:eye_of_ender", position, 0.0, false);
|
||||
e.velocity = velocity;
|
||||
out.push(e);
|
||||
}
|
||||
for t in self.tridents.iter().filter(|t| t.id != 0) {
|
||||
let mut e = state(t.id, "minecraft:trident", at(t.position), t.yaw, false);
|
||||
e.pitch = t.pitch;
|
||||
|
||||
@@ -129,23 +129,6 @@ impl Game<'_> {
|
||||
}
|
||||
/// The screen title: the custom name, else the profession (the client
|
||||
/// adds the level itself).
|
||||
#[allow(dead_code)]
|
||||
fn merchant_title(&self, state: &MerchantState) -> String {
|
||||
if let Some(name) = &state.name {
|
||||
return name.clone();
|
||||
}
|
||||
let e = &self.res.stations().enchantments;
|
||||
let profession = state.session.profession.trim_start_matches("minecraft:");
|
||||
if state.session.villager {
|
||||
e.translate(&format!("entity.minecraft.villager.{profession}"))
|
||||
.unwrap_or(profession)
|
||||
.to_owned()
|
||||
} else {
|
||||
e.translate("entity.minecraft.wandering_trader")
|
||||
.unwrap_or("Wandering Trader")
|
||||
.to_owned()
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.sendMerchantOffers`, when the offers changed.
|
||||
fn lan_send_offers(
|
||||
&mut self,
|
||||
|
||||
@@ -9,6 +9,10 @@ use horizon_client::{
|
||||
},
|
||||
};
|
||||
|
||||
/// The host player's connection id in the waypoint managers: its packets
|
||||
/// go to this client's locator bar rather than to a guest.
|
||||
const HOST_CONN: u32 = u32::MAX;
|
||||
|
||||
/// `waypoint_transmit_range`: hidden by crouching, invisibility or a head worn.
|
||||
fn transmit_range(crouching: bool, invisible: bool, head: Option<&ItemStack>) -> f64 {
|
||||
let hiding_head = head.is_some_and(|s| HIDING_HEADS.contains(&&*s.item));
|
||||
@@ -47,7 +51,7 @@ impl Game<'_> {
|
||||
self.dim.id(),
|
||||
waypoints::Player {
|
||||
uuid: h.uuid,
|
||||
conn: None,
|
||||
conn: Some(HOST_CONN),
|
||||
position: h.position,
|
||||
spectator: self.spectator(),
|
||||
transmit_range: transmit_range(
|
||||
@@ -123,7 +127,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
for (conn, packet) in out {
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
if conn == HOST_CONN {
|
||||
// The host's own connection: its locator bar.
|
||||
if let horizon_protocol::packets::play::Clientbound::TrackedWaypoint(w) = &packet {
|
||||
self.handle_waypoint(w);
|
||||
}
|
||||
} else if let Some(g) = host.hub.guest(conn) {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Level events whose client handler (`LevelEventHandler`) spawns particles
|
||||
//! on a block: dragon egg teleportation (2015), a consumable's teleport
|
||||
//! (2017), electric sparks (3002), waxing (3003), wax off (3004) and
|
||||
//! scraping (3005).
|
||||
//! on a block: bone meal's growth (1505), dragon egg teleportation (2015), a
|
||||
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
|
||||
//! off (3004) and scraping (3005).
|
||||
use super::Game;
|
||||
use horizon_client::particles::{self, ParticleOptions, ParticleType};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
use horizon_world::level::LevelEvent;
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -42,6 +42,65 @@ impl Game<'_> {
|
||||
self.particles.add(&options, p, v);
|
||||
}
|
||||
}
|
||||
/// `BoneMealItem.addGrowthParticles` (event 1505, `count` the data):
|
||||
/// happy villager sparks over a spreading block's neighbourhood (grass,
|
||||
/// nylium, netherrack, moss) or water, or inside a growing block.
|
||||
pub(super) fn growth_particles(&mut self, pos: BlockPos, count: i32) {
|
||||
let p = [pos.x, pos.y, pos.z];
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
let facts = self.res.facts();
|
||||
// ParticleUtils.spawnParticles(level, pos, count, xzSpread, ySpread,
|
||||
// allowFloatingParticles, particle).
|
||||
let (at, count, xz, height, floating) = match self.res.behaviours().bone_meal_spreads(state)
|
||||
{
|
||||
Some(spreads) => {
|
||||
// BonemealableBlock.getParticlePos: below for rooted dirt
|
||||
// (its hanging roots) and mangrove leaves (their propagule).
|
||||
let at = match &*facts.get(state).name {
|
||||
"minecraft:rooted_dirt" | "minecraft:mangrove_leaves" => [p[0], p[1] - 1, p[2]],
|
||||
_ => p,
|
||||
};
|
||||
if spreads {
|
||||
(at, count * 3, 3.0, 1.0, false)
|
||||
} else {
|
||||
// ParticleUtils.spawnParticleInBlock: up to the
|
||||
// block's shape (a full block when air).
|
||||
let inside = self.world.block(at[0], at[1], at[2]);
|
||||
let height = if facts.is_air(inside) {
|
||||
1.0
|
||||
} else {
|
||||
facts
|
||||
.get(inside)
|
||||
.outline
|
||||
.iter()
|
||||
.map(|b| b[4])
|
||||
.fold(0.0, f64::max)
|
||||
};
|
||||
(at, count, 0.5, height, true)
|
||||
}
|
||||
}
|
||||
None if &*facts.get(state).name == "minecraft:water" => (p, count * 3, 3.0, 1.0, false),
|
||||
None => return,
|
||||
};
|
||||
let options = ParticleOptions::simple(ParticleType::HappyVillager);
|
||||
for _ in 0..count.max(0) {
|
||||
let r = self.particles.level_random();
|
||||
let v = [0.0; 3].map(|_: f64| r.next_gaussian() * 0.02);
|
||||
let start = 0.5 - xz;
|
||||
let x = f64::from(at[0]) + start + r.next_double() * xz * 2.0;
|
||||
let y = f64::from(at[1]) + r.next_double() * height;
|
||||
let z = f64::from(at[2]) + start + r.next_double() * xz * 2.0;
|
||||
let below = [x.floor() as i32, y.floor() as i32 - 1, z.floor() as i32];
|
||||
if floating
|
||||
|| !self
|
||||
.res
|
||||
.facts()
|
||||
.is_air(self.world.block(below[0], below[1], below[2]))
|
||||
{
|
||||
self.particles.add(&options, [x, y, z], v);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Queue and play level event `id` at `pos` (`Level.levelEvent`).
|
||||
pub(super) fn level_event_at(&mut self, id: i32, pos: [i32; 3], data: i32) {
|
||||
self.level_events.push(LevelEvent::LevelEvent {
|
||||
|
||||
@@ -232,6 +232,9 @@ impl Game<'_> {
|
||||
p.world.game_time = self.world.game_time;
|
||||
p.world.day_time = self.world.day_time;
|
||||
p.world.weather = self.world.weather;
|
||||
// The game rules and the difficulty are the server's, not a
|
||||
// level's: natural regeneration holds in every dimension.
|
||||
p.world.health_rules = self.world.health_rules;
|
||||
// The sculk tables' watched blocks are built once per game.
|
||||
p.world.watched = std::mem::take(&mut self.world.watched);
|
||||
self.swap_level(&mut p);
|
||||
@@ -253,6 +256,24 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put `dim`'s level in place for a command whose source is there
|
||||
/// (`execute in`, `execute at` a player in another level), opening it if
|
||||
/// nobody is in it; returns the level to put back afterwards. Only a
|
||||
/// server holds other levels.
|
||||
pub(super) fn enter_command_level(&mut self, dim: Dim) -> Option<Dim> {
|
||||
if self.options.dedicated.is_none() || dim == self.dim {
|
||||
return None;
|
||||
}
|
||||
let back = self.dim;
|
||||
match self.enter_level(None, dim) {
|
||||
Ok(()) => Some(back),
|
||||
Err(e) => {
|
||||
log::error!("could not open {}: {e}", dim.id());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One server tick (`MinecraftServer.tickServer`): each level with
|
||||
/// players in turn (`tickChildren`), its chunks streamed and loaded and
|
||||
/// its world ticked. The first pass reads the network and runs the
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! The locator bar on this client (horizon_client::locator_bar): the
|
||||
//! waypoints a server tracks for the player (`handleWaypoint`; on a served
|
||||
//! world the host's own connection in lan/waypoints.rs), the experience
|
||||
//! bar's priority after a change, and the dots drawn from the last frame's
|
||||
//! camera.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
hud_layout::GuiDraw,
|
||||
locator_bar::{ClientWaypoints, View},
|
||||
};
|
||||
use horizon_protocol::packets::play::TrackedWaypoint;
|
||||
|
||||
/// `Player.getEyeHeight` while standing.
|
||||
const PLAYER_EYES: f64 = 1.62;
|
||||
|
||||
fn uuid(ints: [i32; 4]) -> u128 {
|
||||
ints.iter()
|
||||
.fold(0u128, |acc, &i| acc << 32 | u128::from(i as u32))
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ClientPacketListener.handleWaypoint`.
|
||||
pub(super) fn handle_waypoint(&mut self, packet: &TrackedWaypoint) {
|
||||
self.waypoints.apply(packet);
|
||||
}
|
||||
/// A new connection or world starts with no waypoints.
|
||||
pub(super) fn clear_waypoints(&mut self) {
|
||||
self.waypoints = ClientWaypoints::default();
|
||||
}
|
||||
/// `LocalPlayer.setExperienceValues`: a change of progress shows the
|
||||
/// experience bar for 100 ticks, except the first one
|
||||
/// (`experienceDisplayStartTick` starts at `Integer.MIN_VALUE`).
|
||||
pub(super) fn track_experience_display(&mut self) {
|
||||
let progress = self.experience.progress;
|
||||
let (last, seen, _) = &mut self.experience_display;
|
||||
if progress != *last {
|
||||
*last = progress;
|
||||
if *seen {
|
||||
self.experience_display.2 = self.ticks;
|
||||
} else {
|
||||
*seen = true;
|
||||
}
|
||||
}
|
||||
// EnchantmentScreen and AnvilScreen.containerTick keep it up.
|
||||
if self.station.as_ref().is_some_and(|s| {
|
||||
matches!(
|
||||
s.kind,
|
||||
super::stations::Kind::Enchanting | super::stations::Kind::Anvil
|
||||
)
|
||||
}) {
|
||||
self.experience_display.2 = self.ticks;
|
||||
}
|
||||
}
|
||||
/// `Hud.willPrioritizeExperienceInfo`.
|
||||
pub(super) fn experience_recent(&self) -> bool {
|
||||
let (_, _, start) = self.experience_display;
|
||||
start != 0 && start + 100 > self.ticks
|
||||
}
|
||||
/// The local player's UUID, as servers know it.
|
||||
fn own_uuid(&self) -> u128 {
|
||||
match &self.remote {
|
||||
Some(r) => r.profile(),
|
||||
None => self.level.player_uuid.map_or(0, uuid),
|
||||
}
|
||||
}
|
||||
/// An entity's block position and (interpolated) eyes, by UUID.
|
||||
fn waypoint_entity(&self, id: u128, alpha: f64) -> Option<([i32; 3], [f64; 3])> {
|
||||
let at = |p: [f64; 3], q: [f64; 3], eyes: f64| {
|
||||
let pos: [f64; 3] = std::array::from_fn(|i| q[i] + (p[i] - q[i]) * alpha);
|
||||
(p.map(|c| c.floor() as i32), [pos[0], pos[1] + eyes, pos[2]])
|
||||
};
|
||||
if let Some(r) = &self.remote {
|
||||
let entity = r.player_entity(id)?;
|
||||
let m = self.mobs.iter().find(|m| m.id == entity)?;
|
||||
let v = |v: horizon_core::geometry::Vec3| [v.x, v.y, v.z];
|
||||
return Some(at(v(m.position), v(m.previous), m.eye_height()));
|
||||
}
|
||||
let host = self.lan.as_ref()?;
|
||||
let g = host.hub.guests.iter().find(|g| g.uuid == id && g.placed)?;
|
||||
Some(at(g.position, g.previous_position, PLAYER_EYES))
|
||||
}
|
||||
/// `LocatorBar.extractRenderState` from the last frame's camera.
|
||||
pub(super) fn locator_bar_draws(&self, gui_width: i32, gui_height: i32) -> Vec<GuiDraw> {
|
||||
let (Some((camera_pos, yaw, pitch, fov)), Some(p)) = (self.locator_camera, &self.player)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let alpha = self.clock.camera_fraction();
|
||||
// Camera.getViewRotationProjectionMatrix, through view space: the
|
||||
// depth along the look direction and the height on screen.
|
||||
let (sy, cy) = (
|
||||
f64::from(yaw.to_radians()).sin(),
|
||||
f64::from(yaw.to_radians()).cos(),
|
||||
);
|
||||
let (sp, cp) = (
|
||||
f64::from(pitch.to_radians()).sin(),
|
||||
f64::from(pitch.to_radians()).cos(),
|
||||
);
|
||||
let forward = [-sy * cp, -sp, cy * cp];
|
||||
let up = [-sy * sp, cp, cy * sp];
|
||||
let focal = 1.0 / f64::from((fov / 2.0).to_radians()).tan();
|
||||
let project = |o: [f64; 3]| {
|
||||
let dot = |a: [f64; 3]| a[0] * o[0] + a[1] * o[1] + a[2] * o[2];
|
||||
let depth = dot(forward);
|
||||
let y = dot(up) * focal / depth;
|
||||
// Points behind the camera land past the far plane (z > 1).
|
||||
[0.0, y, if depth < 0.0 { 2.0 } else { 0.5 }]
|
||||
};
|
||||
let entity = |id: u128| self.waypoint_entity(id, alpha);
|
||||
let view = View {
|
||||
gui_width,
|
||||
gui_height,
|
||||
camera_uuid: self.own_uuid(),
|
||||
camera_entity_pos: [p.position.x, p.position.y, p.position.z],
|
||||
camera_pos,
|
||||
yaw,
|
||||
pitch,
|
||||
fov,
|
||||
project: &project,
|
||||
entity: &entity,
|
||||
styles: &self.waypoint_styles,
|
||||
tick: self.ticks,
|
||||
};
|
||||
self.waypoints.draw(&view)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use horizon_client::{
|
||||
sound_engine::{CartSound, SoundEngine, SoundInstance, Sounds},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
geometry::{Aabb, Vec3, trig},
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
@@ -637,6 +637,9 @@ impl Game<'_> {
|
||||
let r = self.world.random.next_double();
|
||||
let power = (4.0 + r * 1.5 * speed) as f32;
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
// The cart is the explosion's source: it is gone before the blast
|
||||
// looks for entities, so it can't hit itself.
|
||||
self.carts.retain(|x| x.id != c.id);
|
||||
let _ = self.server_explode(at, power, BlockInteraction::Destroy, false);
|
||||
}
|
||||
/// `MinecartHopper.suckInItems`: items above, into the five slots.
|
||||
@@ -683,8 +686,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AbstractMinecart.push(player)` when the player walks into a cart,
|
||||
/// and carts pushing each other (`pushOtherMinecart`).
|
||||
/// `AbstractMinecart.push(player)` when the player walks into a cart.
|
||||
fn push_carts(&mut self) {
|
||||
let player = self.player.as_ref().map(|p| p.position);
|
||||
let riding = self.riding;
|
||||
@@ -719,55 +721,71 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
for j in i + 1..self.carts.len() {
|
||||
let (a, b) = (&self.carts[i], &self.carts[j]);
|
||||
if !a
|
||||
.bounds()
|
||||
.inflate(Vec3::new(0.2, 0.0, 0.2))
|
||||
.intersects(b.bounds())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let (mut xa, mut za) = (b.position.x - a.position.x, b.position.z - a.position.z);
|
||||
let dd = xa * xa + za * za;
|
||||
if dd < 1.0e-4 {
|
||||
continue;
|
||||
}
|
||||
let d = dd.sqrt();
|
||||
let pow = (1.0 / d).min(1.0);
|
||||
xa = xa / d * pow * f64::from(0.1f32) * 0.5;
|
||||
za = za / d * pow * f64::from(0.1f32) * 0.5;
|
||||
let dir =
|
||||
glam::DVec2::new(b.position.x - a.position.x, b.position.z - a.position.z)
|
||||
.normalize_or_zero();
|
||||
let r = a.yaw.to_radians();
|
||||
let facing =
|
||||
glam::DVec2::new(f64::from(r.cos()), f64::from(r.sin())).normalize_or_zero();
|
||||
if dir.dot(facing).abs() < f64::from(0.8f32) {
|
||||
continue;
|
||||
}
|
||||
let (ma, mb) = (a.velocity, b.velocity);
|
||||
let (fa, fb) = (a.kind == CartKind::Furnace, b.kind == CartKind::Furnace);
|
||||
let (na, nb) = if fb && !fa {
|
||||
(
|
||||
Vec3::new(ma.x * 0.2 + mb.x - xa, ma.y, ma.z * 0.2 + mb.z - za),
|
||||
Vec3::new(mb.x * 0.95, mb.y, mb.z * 0.95),
|
||||
)
|
||||
} else if fa && !fb {
|
||||
(
|
||||
Vec3::new(ma.x * 0.95, ma.y, ma.z * 0.95),
|
||||
Vec3::new(mb.x * 0.2 + ma.x + xa, mb.y, mb.z * 0.2 + ma.z + za),
|
||||
)
|
||||
} else {
|
||||
let (xdd, zdd) = ((mb.x + ma.x) / 2.0, (mb.z + ma.z) / 2.0);
|
||||
(
|
||||
Vec3::new(ma.x * 0.2 + xdd - xa, ma.y, ma.z * 0.2 + zdd - za),
|
||||
Vec3::new(mb.x * 0.2 + xdd + xa, mb.y, mb.z * 0.2 + zdd + za),
|
||||
)
|
||||
};
|
||||
self.carts[i].velocity = na;
|
||||
self.carts[j].velocity = nb;
|
||||
}
|
||||
}
|
||||
/// `OldMinecartBehavior.pushAndPickupEntities` for carts, at the end of
|
||||
/// cart `i`'s own tick: each other cart in its box (0.2 wider on each
|
||||
/// side) gets `push(cart i)`. Running it per cart, after that cart moved
|
||||
/// and before the next one does, is what splits stacked carts apart.
|
||||
fn push_from_cart(&mut self, i: usize) {
|
||||
let reach = f64::from(0.2f32);
|
||||
let hitbox = self.carts[i].bounds().inflate(Vec3::new(reach, 0.0, reach));
|
||||
for j in 0..self.carts.len() {
|
||||
if j == i || !hitbox.intersects(self.carts[j].bounds()) {
|
||||
continue;
|
||||
}
|
||||
// AbstractMinecart.push(entity): `this` is cart j, `entity` cart i.
|
||||
let (this, other) = (&self.carts[j], &self.carts[i]);
|
||||
let (mut xa, mut za) = (
|
||||
other.position.x - this.position.x,
|
||||
other.position.z - this.position.z,
|
||||
);
|
||||
let dd = xa * xa + za * za;
|
||||
if dd < f64::from(1.0e-4f32) {
|
||||
continue;
|
||||
}
|
||||
let d = dd.sqrt();
|
||||
xa /= d;
|
||||
za /= d;
|
||||
let pow = (1.0 / d).min(1.0);
|
||||
xa = xa * pow * f64::from(0.1f32) * 0.5;
|
||||
za = za * pow * f64::from(0.1f32) * 0.5;
|
||||
// pushOtherMinecart (old movement): only along this cart's facing.
|
||||
let dir = glam::DVec2::new(
|
||||
other.position.x - this.position.x,
|
||||
other.position.z - this.position.z,
|
||||
)
|
||||
.normalize_or_zero();
|
||||
let r = f64::from(this.yaw * 0.017453292f32);
|
||||
let facing = glam::DVec2::new(f64::from(trig::cos(r)), f64::from(trig::sin(r)))
|
||||
.normalize_or_zero();
|
||||
if dir.dot(facing).abs() < f64::from(0.8f32) {
|
||||
continue;
|
||||
}
|
||||
let (mt, mo) = (this.velocity, other.velocity);
|
||||
let (ft, fo) = (
|
||||
this.kind == CartKind::Furnace,
|
||||
other.kind == CartKind::Furnace,
|
||||
);
|
||||
let (nt, no) = if fo && !ft {
|
||||
(
|
||||
Vec3::new(mt.x * 0.2 + (mo.x - xa), mt.y, mt.z * 0.2 + (mo.z - za)),
|
||||
Vec3::new(mo.x * 0.95, mo.y, mo.z * 0.95),
|
||||
)
|
||||
} else if !fo && ft {
|
||||
(
|
||||
Vec3::new(mt.x * 0.95, mt.y, mt.z * 0.95),
|
||||
Vec3::new(mo.x * 0.2 + (mt.x + xa), mo.y, mo.z * 0.2 + (mt.z + za)),
|
||||
)
|
||||
} else {
|
||||
let (xdd, zdd) = ((mo.x + mt.x) / 2.0, (mo.z + mt.z) / 2.0);
|
||||
(
|
||||
Vec3::new(mt.x * 0.2 + (xdd - xa), mt.y, mt.z * 0.2 + (zdd - za)),
|
||||
Vec3::new(mo.x * 0.2 + (xdd + xa), mo.y, mo.z * 0.2 + (zdd + za)),
|
||||
)
|
||||
};
|
||||
self.carts[j].velocity = nt;
|
||||
self.carts[i].velocity = no;
|
||||
}
|
||||
}
|
||||
/// Every cart but the ridden one (which ticks with its rider).
|
||||
@@ -777,14 +795,17 @@ impl Game<'_> {
|
||||
let ticking = self.entity_ticking_area();
|
||||
// A server player's minecart moves with its rider (lan_vehicles.rs).
|
||||
let ridden = self.guest_vehicles();
|
||||
let mut i = 0;
|
||||
while i < self.carts.len() {
|
||||
// By id: an exploding cart can destroy or prime others mid-loop.
|
||||
let ids: Vec<u32> = self.carts.iter().map(|c| c.id).collect();
|
||||
for id in ids {
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let p = self.carts[i].position;
|
||||
if Some(self.carts[i].id) == self.riding
|
||||
|| ridden.contains(&self.carts[i].id)
|
||||
if Some(id) == self.riding
|
||||
|| ridden.contains(&id)
|
||||
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
|
||||
{
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut c = self.carts[i].clone();
|
||||
@@ -792,9 +813,12 @@ impl Game<'_> {
|
||||
if c.kind == CartKind::Hopper {
|
||||
self.hopper_cart_pickup(&mut c);
|
||||
}
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
if alive {
|
||||
self.carts[i] = c;
|
||||
i += 1;
|
||||
self.push_from_cart(i);
|
||||
} else {
|
||||
self.carts.remove(i);
|
||||
}
|
||||
@@ -865,8 +889,13 @@ impl Game<'_> {
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
let Some(i) = self.carts.iter().position(|x| x.id == c.id) else {
|
||||
self.riding = None;
|
||||
return true;
|
||||
};
|
||||
if alive {
|
||||
self.carts[i] = c;
|
||||
self.push_from_cart(i);
|
||||
} else {
|
||||
self.carts.remove(i);
|
||||
self.riding = None;
|
||||
@@ -1121,8 +1150,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// `VehicleEntity.hurtServer`: shake, add the damage, break past 40 (at
|
||||
/// once for creative players); TNT minecarts prime instead when moving
|
||||
/// or hurt by fire.
|
||||
fn hurt_cart(&mut self, i: usize, damage: f32, creative: bool, fire: bool) {
|
||||
/// or hurt by fire or an explosion (`damageSourceIgnitesTnt`).
|
||||
pub(super) fn hurt_cart(&mut self, i: usize, damage: f32, creative: bool, fire: bool) {
|
||||
let c = &mut self.carts[i];
|
||||
c.hurt_dir = -c.hurt_dir;
|
||||
c.hurt_time = 10;
|
||||
@@ -1131,7 +1160,7 @@ impl Game<'_> {
|
||||
if destroyed {
|
||||
let mut c = self.carts.remove(i);
|
||||
let speed = c.velocity.x * c.velocity.x + c.velocity.z * c.velocity.z;
|
||||
if c.kind == CartKind::Tnt && (fire || speed >= 0.01) {
|
||||
if c.kind == CartKind::Tnt && (fire || speed >= f64::from(0.01f32)) {
|
||||
// MinecartTNT.destroy: prime instead of breaking.
|
||||
if c.fuse < 0 {
|
||||
self.prime_cart(&mut c);
|
||||
|
||||
@@ -463,25 +463,40 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `Hud.extractVehicleHealth` and the jump bar that replaces the
|
||||
/// experience bar on a saddled jumper. Returns the mount's heart count
|
||||
/// (`getVehicleMaxHearts`) and whether the jump bar shows.
|
||||
pub(super) fn draw_mount_hud(&self, batch: &mut HudBatch, w: i32, h: i32) -> (i32, bool) {
|
||||
/// `Player.jumpableVehicle`: a saddled mount that jumps.
|
||||
pub(super) fn jumpable_mount(&self) -> bool {
|
||||
self.mount_index()
|
||||
.is_some_and(|i| self.mobs[i].extra.saddled)
|
||||
}
|
||||
/// `willPrioritizeJumpInfo`: charging a jump or recharging.
|
||||
pub(super) fn mount_jumping(&self) -> bool {
|
||||
self.mount.jump_scale > 0.0 || self.mount.jump_ticks < 0
|
||||
}
|
||||
/// `Hud.extractVehicleHealth`, and the jump bar when it is the
|
||||
/// contextual bar (`jump_bar`). Returns the mount's heart count
|
||||
/// (`getVehicleMaxHearts`).
|
||||
pub(super) fn draw_mount_hud(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
w: i32,
|
||||
h: i32,
|
||||
jump_bar: bool,
|
||||
) -> i32 {
|
||||
let Some(i) = self.mount_index() else {
|
||||
return (0, false);
|
||||
return 0;
|
||||
};
|
||||
let m = &self.mobs[i];
|
||||
let atlas = &self.gui.atlas;
|
||||
let hearts = horizon_client::hud_layout::vehicle_max_hearts(m.attrs.max_health);
|
||||
let draws = horizon_client::hud_layout::vehicle_health(w, h, hearts, m.health);
|
||||
horizon_client::hud_layout::paint(batch, atlas, &draws);
|
||||
if m.extra.saddled {
|
||||
if jump_bar {
|
||||
// JumpableVehicleBar: the cooldown shows while the jump recharges.
|
||||
let cooldown = i32::from(self.mount.jump_ticks < 0);
|
||||
let draws =
|
||||
horizon_client::hud_overlays::jump_bar(w, h, cooldown, self.mount.jump_scale);
|
||||
horizon_client::hud_layout::paint(batch, atlas, &draws);
|
||||
}
|
||||
(hearts, m.extra.saddled)
|
||||
hearts
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ impl Game<'_> {
|
||||
pub(super) fn tick_music(&mut self) {
|
||||
let (music, volume) = self.situational_music();
|
||||
// LevelLoadingScreen: no new track before the player is in the world.
|
||||
let loading = self.title.is_none() && self.player.is_none();
|
||||
let loading = self.title.is_none() && self.player.is_none() && self.win.is_none();
|
||||
self.music
|
||||
.tick(music.as_ref(), volume, loading, &mut self.audio);
|
||||
}
|
||||
@@ -59,7 +59,11 @@ impl Game<'_> {
|
||||
None => (empty, 1.0),
|
||||
};
|
||||
let situation = Situation {
|
||||
screen_music: None,
|
||||
// WinScreen.getBackgroundMusic.
|
||||
screen_music: self
|
||||
.win
|
||||
.is_some()
|
||||
.then(horizon_client::music::Music::credits),
|
||||
in_world: eye.is_some(),
|
||||
in_end: self.dim == super::Dim::End,
|
||||
boss_music: self.boss_music(),
|
||||
|
||||
@@ -72,6 +72,8 @@ pub(super) struct GameLevel<'g, 'a> {
|
||||
pub(super) w: MobWorld<'g>,
|
||||
/// Every player in the level ([`Game::level_players`]).
|
||||
pub(super) players: Vec<LevelPlayer>,
|
||||
/// Where the non-spectators among them are (`getNearestPlayer`).
|
||||
pub(super) nearest: Vec<[f64; 3]>,
|
||||
/// Boxes of the mobs this pass created, not yet in the game's list.
|
||||
pub(super) pending: std::cell::RefCell<Vec<Aabb>>,
|
||||
/// The types and boxes of what a spawner created this tick.
|
||||
@@ -255,14 +257,8 @@ impl NaturalLevel for GameLevel<'_, '_> {
|
||||
fn structure_spawns(&self) -> &StructureSpawns {
|
||||
StructureSpawns::shared(&self.game.options.resources)
|
||||
}
|
||||
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
|
||||
// The nearest player's distance (`nearest_player`), without finding
|
||||
// which player it is: every spawn attempt asks.
|
||||
self.players
|
||||
.iter()
|
||||
.filter(|p| !p.spectator)
|
||||
.map(|p| p.distance_sqr(x, y, z))
|
||||
.reduce(f64::min)
|
||||
fn nearest_player_positions(&self) -> &[[f64; 3]] {
|
||||
&self.nearest
|
||||
}
|
||||
fn respawn(&self) -> Option<[i32; 3]> {
|
||||
// The world spawn point lives in the overworld.
|
||||
@@ -363,10 +359,17 @@ impl horizon_client::mobs::spawner::SpawnerLevel for GameLevel<'_, '_> {
|
||||
}
|
||||
impl<'a> Game<'a> {
|
||||
pub(super) fn natural_level(&self) -> GameLevel<'_, 'a> {
|
||||
let players = self.level_players();
|
||||
let nearest = players
|
||||
.iter()
|
||||
.filter(|p| !p.spectator)
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z])
|
||||
.collect();
|
||||
GameLevel {
|
||||
game: self,
|
||||
w: self.mob_world_base(&[]),
|
||||
players: self.level_players(),
|
||||
players,
|
||||
nearest,
|
||||
pending: Default::default(),
|
||||
pending_ids: Default::default(),
|
||||
boxes: Default::default(),
|
||||
|
||||
@@ -200,6 +200,13 @@ impl Game<'_> {
|
||||
self.settings.save(&Self::options_path());
|
||||
self.apply_settings();
|
||||
self.sync_mods();
|
||||
self.sync_agent_control();
|
||||
self.ui.options.agent_status = self.agent_status_lines();
|
||||
}
|
||||
Event::CopyAgentDetails => {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
let text = self.agent_details();
|
||||
self.clipboard.set(&text);
|
||||
}
|
||||
Event::Close if self.ui.options.resource_error.is_some() => {}
|
||||
Event::Close => {
|
||||
@@ -235,6 +242,10 @@ impl Game<'_> {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
|
||||
Event::ReloadShaderPack => {
|
||||
self.load_shader_pack();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -133,7 +133,7 @@ impl Game<'_> {
|
||||
}
|
||||
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
|
||||
let render = std::sync::Arc::new(self.pack.assets.reload(root)?);
|
||||
let mut atlas = HudAtlas::build(root)?;
|
||||
let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?;
|
||||
atlas.font.uniform = self.settings.force_unicode;
|
||||
atlas.font.japanese = self.settings.japanese_glyphs;
|
||||
let gui = super::UiResources {
|
||||
@@ -171,6 +171,8 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
// Everything loaded: switch over.
|
||||
self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(root);
|
||||
self.armor_stand_assets.reload_trims(root);
|
||||
self.random_tick_sections.clear();
|
||||
self.portals.sections.clear();
|
||||
self.options.resources = root.to_path_buf();
|
||||
|
||||
@@ -18,6 +18,8 @@ use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
/// A thrown splash or lingering potion in flight.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct ThrownPotion {
|
||||
/// Its entity id once a server's clients track it (0 until then).
|
||||
id: u32,
|
||||
stack: ItemStack,
|
||||
/// The position before the last tick, for drawing between ticks.
|
||||
previous: Vec3,
|
||||
@@ -47,6 +49,7 @@ impl Game<'_> {
|
||||
/// player's throws and dispensers both end up here).
|
||||
pub(super) fn launch_thrown(&mut self, stack: ItemStack, position: Vec3, velocity: Vec3) {
|
||||
self.potions.thrown.push(ThrownPotion {
|
||||
id: 0,
|
||||
stack,
|
||||
previous: position,
|
||||
position,
|
||||
@@ -90,19 +93,78 @@ impl PotionState {
|
||||
pub(super) fn thrown_entities(&self) -> Vec<(&'static str, Vec3, Vec3)> {
|
||||
self.thrown
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let kind = match &*t.stack.item {
|
||||
"minecraft:lingering_potion" => "minecraft:lingering_potion",
|
||||
"minecraft:experience_bottle" => "minecraft:experience_bottle",
|
||||
_ => "minecraft:splash_potion",
|
||||
};
|
||||
(kind, t.position, t.velocity)
|
||||
})
|
||||
.map(|t| (thrown_kind(&t.stack), t.position, t.velocity))
|
||||
.collect()
|
||||
}
|
||||
pub(super) fn clear_thrown(&mut self) {
|
||||
self.thrown.clear();
|
||||
}
|
||||
/// Their entity ids, for a server to number them.
|
||||
pub(super) fn thrown_ids(&mut self) -> impl Iterator<Item = &mut u32> {
|
||||
self.thrown.iter_mut().map(|t| &mut t.id)
|
||||
}
|
||||
/// The numbered ones as a server's clients track them: (id, entity
|
||||
/// type, item, position, motion).
|
||||
pub(super) fn thrown_tracked(
|
||||
&self,
|
||||
) -> impl Iterator<Item = (u32, &'static str, &ItemStack, Vec3, Vec3)> {
|
||||
self.thrown.iter().filter(|t| t.id != 0).map(|t| {
|
||||
(
|
||||
t.id,
|
||||
thrown_kind(&t.stack),
|
||||
&t.stack,
|
||||
t.position,
|
||||
t.velocity,
|
||||
)
|
||||
})
|
||||
}
|
||||
/// `handleAddEntity` for a thrown potion or bottle on a server's client:
|
||||
/// drawn where the server says (its item comes in its entity data).
|
||||
pub(super) fn add_remote_thrown(&mut self, id: u32, kind: &str, at: Vec3, velocity: Vec3) {
|
||||
self.thrown.retain(|t| t.id != id);
|
||||
let item = match kind {
|
||||
"minecraft:lingering_potion" | "minecraft:experience_bottle" => kind,
|
||||
_ => "minecraft:splash_potion",
|
||||
};
|
||||
self.thrown.push(ThrownPotion {
|
||||
id,
|
||||
stack: ItemStack::new(item, 1),
|
||||
previous: at,
|
||||
position: at,
|
||||
velocity,
|
||||
age: 0,
|
||||
});
|
||||
}
|
||||
/// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it shows.
|
||||
pub(super) fn remote_thrown_item(&mut self, id: u32, stack: ItemStack) {
|
||||
if let Some(t) = self.thrown.iter_mut().find(|t| t.id == id) {
|
||||
t.stack = stack;
|
||||
}
|
||||
}
|
||||
/// One tick of a server's client: `step` moves each toward where the
|
||||
/// server put it.
|
||||
pub(super) fn step_remote_thrown(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
|
||||
for t in &mut self.thrown {
|
||||
t.previous = t.position;
|
||||
t.age += 1;
|
||||
t.position = step(t.id, t.position);
|
||||
}
|
||||
}
|
||||
pub(super) fn remove_remote_thrown(&mut self, id: u32) {
|
||||
self.thrown.retain(|t| t.id != id);
|
||||
}
|
||||
pub(super) fn has_thrown(&self, id: u32) -> bool {
|
||||
self.thrown.iter().any(|t| t.id == id)
|
||||
}
|
||||
}
|
||||
|
||||
/// The entity type of a thrown potion or bottle.
|
||||
fn thrown_kind(stack: &ItemStack) -> &'static str {
|
||||
match &*stack.item {
|
||||
"minecraft:lingering_potion" => "minecraft:lingering_potion",
|
||||
"minecraft:experience_bottle" => "minecraft:experience_bottle",
|
||||
_ => "minecraft:splash_potion",
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -297,10 +359,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// The local player's effects (`tick_mob_effects`), then the potions
|
||||
/// in flight, which a server without a local player still has.
|
||||
pub(super) fn tick_effects(&mut self) {
|
||||
/// in flight, which a server without a local player still has. A frozen
|
||||
/// tick (`/tick freeze`) leaves the potions where they are
|
||||
/// (`TickRateManager.isEntityFrozen`); players' effects still run.
|
||||
pub(super) fn tick_effects(&mut self, running: bool) {
|
||||
self.tick_mob_effects();
|
||||
self.tick_thrown_potions();
|
||||
if running {
|
||||
self.tick_thrown_potions();
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.tickEffects` (server side) for `self.player`, the local
|
||||
/// player or a guest acting (`as_guest`), then its attribute modifiers
|
||||
|
||||
@@ -40,7 +40,11 @@ use horizon_protocol::{
|
||||
registry::{Builtin, DimensionType, Dynamic, ceil_log2},
|
||||
text::{Language, format_java, plain},
|
||||
};
|
||||
use horizon_world::{nbt::Tag, registry::BlockStateId};
|
||||
use horizon_world::{
|
||||
nbt::Tag,
|
||||
registry::BlockStateId,
|
||||
stepped_interpolation::{Path, PosRot, Start, SteppedInterpolation},
|
||||
};
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
sync::Arc,
|
||||
@@ -85,6 +89,15 @@ struct Lerp {
|
||||
steps: u32,
|
||||
}
|
||||
|
||||
/// `SteppedInterpolationHandler.doInterpolate`'s `setPos` and `setRot` on a
|
||||
/// mob: its yaw kept continuous (the same turn as vanilla's, without the
|
||||
/// wrap that would spin it in the frame between ticks), its pitch clamped.
|
||||
fn place_interpolated(m: &mut Mob, at: PosRot) {
|
||||
m.position = Vec3::new(at.position[0], at.position[1], at.position[2]);
|
||||
m.yaw += wrap_degrees(at.yaw - m.yaw);
|
||||
m.head_pitch = (at.pitch % 360.0).clamp(-90.0, 90.0);
|
||||
}
|
||||
|
||||
pub(super) struct Remote {
|
||||
pub session: Session,
|
||||
pub brand: String,
|
||||
@@ -123,6 +136,8 @@ pub(super) struct Remote {
|
||||
use_target: Option<[i32; 3]>,
|
||||
entities: HashMap<i32, Tracked>,
|
||||
lerps: HashMap<i32, Lerp>,
|
||||
/// Living entities' `SteppedInterpolationHandler`s.
|
||||
stepped: HashMap<i32, SteppedInterpolation>,
|
||||
/// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it.
|
||||
head_lerps: HashMap<i32, (f32, u32)>,
|
||||
/// Item entities waiting for their item data.
|
||||
@@ -149,6 +164,8 @@ pub(super) struct Remote {
|
||||
level_players: HashMap<i32, u128>,
|
||||
/// The local player's profile UUID (always in the level).
|
||||
profile: u128,
|
||||
/// The local player's profile name: its score holder and team member.
|
||||
pub(super) profile_name: String,
|
||||
destroying: Option<([i32; 3], u8, f32)>,
|
||||
destroy_delay: u32,
|
||||
input: horizon_client::interaction_input::InteractionInput,
|
||||
@@ -351,6 +368,7 @@ impl Game<'_> {
|
||||
r.clear_sections();
|
||||
}
|
||||
self.apply_settings();
|
||||
self.clear_waypoints();
|
||||
self.remote = Some(Remote {
|
||||
session,
|
||||
brand: "unknown".into(),
|
||||
@@ -379,6 +397,7 @@ impl Game<'_> {
|
||||
use_target: None,
|
||||
entities: HashMap::new(),
|
||||
lerps: HashMap::new(),
|
||||
stepped: HashMap::new(),
|
||||
head_lerps: HashMap::new(),
|
||||
pending_items: HashMap::new(),
|
||||
health: (20.0, 20, 5.0),
|
||||
@@ -394,6 +413,7 @@ impl Game<'_> {
|
||||
online_mode: false,
|
||||
level_players: HashMap::new(),
|
||||
profile: 0,
|
||||
profile_name: String::new(),
|
||||
destroying: None,
|
||||
destroy_delay: 0,
|
||||
input: Default::default(),
|
||||
@@ -429,6 +449,7 @@ impl Game<'_> {
|
||||
// Leaving on purpose: nothing for auto-reconnect to retry.
|
||||
self.extras_left();
|
||||
}
|
||||
self.clear_waypoints();
|
||||
if let Some(mut r) = self.remote.take() {
|
||||
r.session.disconnect();
|
||||
}
|
||||
@@ -777,6 +798,7 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.joined = true;
|
||||
r.profile = uuid;
|
||||
r.profile_name = name.clone();
|
||||
r.status = format!("Joined as {name}; loading terrain...");
|
||||
}
|
||||
}
|
||||
@@ -929,6 +951,7 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.entities.clear();
|
||||
r.lerps.clear();
|
||||
r.stepped.clear();
|
||||
r.head_lerps.clear();
|
||||
r.pending_items.clear();
|
||||
// A new ClientLevel: a new chunk storage, centred on 0, 0.
|
||||
@@ -1315,11 +1338,15 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(t) = mob_type {
|
||||
let mut mob = Mob::new(id as u32, t, at, head);
|
||||
super::remote_mob_data::client_defaults(&mut mob);
|
||||
(mob.yaw, mob.previous_yaw) = (yaw, yaw);
|
||||
(mob.head_pitch, mob.previous_head_pitch) = (pitch, pitch);
|
||||
mob.persistent = true;
|
||||
self.mobs.retain(|m| m.id != id as u32);
|
||||
self.mobs.push(mob);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.stepped.remove(&id);
|
||||
}
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
if kind == "minecraft:item" {
|
||||
@@ -1334,23 +1361,77 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// A move packet for entity `id`: to the end of `path` (None: a
|
||||
/// rotation alone), turned to `rotation` if it has one. `far_snaps`:
|
||||
/// an `EntityPositionSync`, which puts an entity more than 64 blocks
|
||||
/// away there at once.
|
||||
fn move_entity(
|
||||
&mut self,
|
||||
id: i32,
|
||||
position: [f64; 3],
|
||||
path: Option<Path>,
|
||||
rotation: Option<(f32, f32)>,
|
||||
on_ground: bool,
|
||||
far_snaps: bool,
|
||||
) {
|
||||
let mob = self.mobs.iter_mut().find(|m| m.id == id as u32);
|
||||
let tracked = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.entities.get(&id))
|
||||
.map(|t| t.position);
|
||||
let position = path
|
||||
.as_ref()
|
||||
.map(Path::end)
|
||||
.or(tracked)
|
||||
.or_else(|| {
|
||||
mob.as_ref()
|
||||
.map(|m| [m.position.x, m.position.y, m.position.z])
|
||||
})
|
||||
.unwrap_or([0.0; 3]);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
if let Some(t) = r.entities.get_mut(&id) {
|
||||
t.position = position;
|
||||
}
|
||||
if let Some(m) = mob {
|
||||
// A living entity: LivingEntity.createInterpolationHandler's
|
||||
// SteppedInterpolationHandler, over its type's update interval.
|
||||
let entity = PosRot {
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
yaw: m.yaw,
|
||||
pitch: m.head_pitch,
|
||||
};
|
||||
let far = far_snaps && {
|
||||
let d = [0, 1, 2].map(|i| position[i] - entity.position[i]);
|
||||
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > 4096.0
|
||||
};
|
||||
let handler = r.stepped.entry(id).or_insert_with(|| {
|
||||
let interval = horizon_client::lan::tracking::settings(&m.kind.id).1;
|
||||
SteppedInterpolation::new(interval as i32)
|
||||
});
|
||||
let snap = if far {
|
||||
handler.cancel();
|
||||
let (yaw, pitch) = rotation.unwrap_or((m.yaw, m.head_pitch));
|
||||
Some(PosRot {
|
||||
position,
|
||||
yaw,
|
||||
pitch,
|
||||
})
|
||||
} else {
|
||||
match handler.interpolate_to(entity, path, rotation) {
|
||||
Start::Snap(at) => Some(at),
|
||||
Start::Interpolating | Start::Unchanged => None,
|
||||
}
|
||||
};
|
||||
if let Some(at) = snap {
|
||||
place_interpolated(m, at);
|
||||
m.previous = m.position;
|
||||
}
|
||||
m.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
// AbstractInterpolationHandler.interpolateTo: without a rotation,
|
||||
// the one it was heading for (or has).
|
||||
let rotation = rotation
|
||||
.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation))
|
||||
.or_else(|| mob.as_ref().map(|m| (m.yaw, m.head_pitch)));
|
||||
// the one it was heading for.
|
||||
let rotation = rotation.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation));
|
||||
r.lerps.insert(
|
||||
id,
|
||||
Lerp {
|
||||
@@ -1363,9 +1444,6 @@ impl Game<'_> {
|
||||
*p = position;
|
||||
}
|
||||
}
|
||||
if let Some(m) = mob {
|
||||
m.on_ground = on_ground;
|
||||
}
|
||||
self.turn_remote_object(id, rotation.map(|(yaw, _)| yaw), on_ground);
|
||||
self.turn_remote_projectile(id, rotation);
|
||||
}
|
||||
@@ -1382,6 +1460,7 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.entities.remove(&id);
|
||||
r.lerps.remove(&id);
|
||||
r.stepped.remove(&id);
|
||||
r.head_lerps.remove(&id);
|
||||
r.pending_items.remove(&id);
|
||||
}
|
||||
@@ -1650,29 +1729,10 @@ impl Game<'_> {
|
||||
self.block_entity_event(p.pos, i32::from(p.b0), i32::from(p.b1));
|
||||
}
|
||||
}
|
||||
C::LevelEvent(p) if p.kind == 2015 => {
|
||||
self.dragon_egg_particles(p.pos, p.data);
|
||||
}
|
||||
C::LevelEvent(p) if p.kind == 2017 => {
|
||||
self.consume_teleport_particles(p.pos, p.data);
|
||||
}
|
||||
C::LevelEvent(p) if p.kind == 2001 => {
|
||||
// LevelEvent.PARTICLES_DESTROY_BLOCK: someone else broke a block.
|
||||
let state = BlockStateId(p.data.max(0) as u32);
|
||||
let s = self.res.facts().get(state).sounds.clone();
|
||||
let at = [
|
||||
f64::from(p.pos[0]) + 0.5,
|
||||
f64::from(p.pos[1]) + 0.5,
|
||||
f64::from(p.pos[2]) + 0.5,
|
||||
];
|
||||
self.audio.play(
|
||||
&s.break_sound,
|
||||
Some(at),
|
||||
(s.volume + 1.0) / 2.0,
|
||||
s.pitch * 0.8,
|
||||
);
|
||||
self.destroy_particles(p.pos, state);
|
||||
}
|
||||
// ClientLevel.levelEvent: LevelEventHandler, as this client's own
|
||||
// level plays them (particles and sounds: a spawner's burst, bone
|
||||
// meal, waxing, a block broken by someone else).
|
||||
C::LevelEvent(p) => self.level_event_at(p.kind, p.pos, p.data),
|
||||
C::Sound(p) => {
|
||||
let name = match &p.sound.1 {
|
||||
Some((location, _)) => Some(location.clone()),
|
||||
@@ -1741,17 +1801,36 @@ impl Game<'_> {
|
||||
.and_then(|r| r.entities.get(&m.id))
|
||||
.map(|t| t.position);
|
||||
let Some(base) = base else { return Ok(()) };
|
||||
let position = m.delta.as_ref().map_or(base, |d| d.end(base));
|
||||
// VecDeltaCodec.decode: each step's position from the last.
|
||||
let path = m.delta.as_ref().map(|d| match d {
|
||||
play::VecDelta::Linear(_) => Path::Linear(d.end(base)),
|
||||
play::VecDelta::Stepped(steps) => {
|
||||
let mut at = base;
|
||||
Path::Stepped(
|
||||
steps
|
||||
.iter()
|
||||
.map(|(delta, ticks)| {
|
||||
at = play::VecDelta::apply(at, *delta);
|
||||
(at, *ticks)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
});
|
||||
let rotation = m.rotation.map(|(y, x)| {
|
||||
(
|
||||
horizon_protocol::buf::unpack_degrees(y),
|
||||
horizon_protocol::buf::unpack_degrees(x),
|
||||
)
|
||||
});
|
||||
self.move_entity(m.id, position, rotation, m.on_ground);
|
||||
self.move_entity(m.id, path, rotation, m.on_ground, false);
|
||||
}
|
||||
C::EntityPositionSync(p) => {
|
||||
self.move_entity(p.id, p.path.end(), Some((p.yaw, p.pitch)), p.on_ground)
|
||||
let path = match p.path {
|
||||
play::PositionPath::Linear(at) => Path::Linear(at),
|
||||
play::PositionPath::Stepped(steps) => Path::Stepped(steps),
|
||||
};
|
||||
self.move_entity(p.id, Some(path), Some((p.yaw, p.pitch)), p.on_ground, true)
|
||||
}
|
||||
C::TeleportEntity(p) => {
|
||||
let base = self
|
||||
@@ -1771,9 +1850,28 @@ impl Game<'_> {
|
||||
rel(play::relative::Y, 1),
|
||||
rel(play::relative::Z, 2),
|
||||
];
|
||||
self.move_entity(p.id, position, Some((p.yaw, p.pitch)), p.on_ground);
|
||||
self.move_entity(
|
||||
p.id,
|
||||
Some(Path::Linear(position)),
|
||||
Some((p.yaw, p.pitch)),
|
||||
p.on_ground,
|
||||
false,
|
||||
);
|
||||
}
|
||||
C::SetPassengers(p) => self.remote_set_passengers(p.vehicle, &p.passengers),
|
||||
// ClientPacketListener.handleEntityLinkPacket: who holds a mob's
|
||||
// lead (this player, another player or a mob), for the rope.
|
||||
C::SetEntityLink(p) => {
|
||||
let me = self.remote.as_ref().map(|r| r.entity_id);
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.source as u32) {
|
||||
use horizon_client::mobs::leash::Holder;
|
||||
m.extra.leash = match p.dest {
|
||||
0 => None,
|
||||
d if Some(d) == me => Some(Holder::Player),
|
||||
d => Some(Holder::Mob(d as u32)),
|
||||
};
|
||||
}
|
||||
}
|
||||
C::MoveVehicle(p) => self.remote_move_vehicle(p.position, p.yaw),
|
||||
// ClientPacketListener.handleRotateMob: lerpHeadTo(yHeadRot, 3).
|
||||
C::RotateHead(p) => {
|
||||
@@ -1810,13 +1908,12 @@ impl Game<'_> {
|
||||
&& m.species() == horizon_client::mobs::Species::Rabbit
|
||||
{
|
||||
for (index, _, value) in &p.values {
|
||||
match (index, value) {
|
||||
(15, Value::Byte(flags)) => m.no_ai = flags & 1 != 0,
|
||||
(16, Value::Bool(baby)) => m.extra.age = if *baby { -1 } else { 0 },
|
||||
_ => {}
|
||||
if let (15, Value::Byte(flags)) = (index, value) {
|
||||
m.no_ai = flags & 1 != 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.remote_mob_data(p.id, &p.values);
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& m.species() == horizon_client::mobs::Species::Creeper
|
||||
{
|
||||
@@ -1865,11 +1962,27 @@ impl Game<'_> {
|
||||
// player lies down in the bed, or gets up.
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
for (index, _, value) in &p.values {
|
||||
if let (14, Value::OptPos(bed)) = (index, value) {
|
||||
if bed.is_some() && self.sleeping.is_none() {
|
||||
self.sleep_timer = 0;
|
||||
match (index, value) {
|
||||
(14, Value::OptPos(bed)) => {
|
||||
if bed.is_some() && self.sleeping.is_none() {
|
||||
self.sleep_timer = 0;
|
||||
}
|
||||
self.sleeping = *bed;
|
||||
}
|
||||
self.sleeping = *bed;
|
||||
// Entity.DATA_AIR_SUPPLY_ID and
|
||||
// Player.DATA_PLAYER_ABSORPTION_ID: the bubbles
|
||||
// and golden hearts the server's player has.
|
||||
(1, Value::Int(air)) => {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.air = *air;
|
||||
}
|
||||
}
|
||||
(17, Value::Float(amount)) => {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
let _ = pl.internal_set_absorption(*amount);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2019,6 +2132,15 @@ impl Game<'_> {
|
||||
m.swing();
|
||||
}
|
||||
}
|
||||
// The player's own instances: its maximum health (the hearts the
|
||||
// HUD draws and the health it may hold), reach, speed and so on.
|
||||
// Then the received health again, which the old maximum clamped.
|
||||
C::UpdateAttributes(p) if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) => {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
horizon_client::lan::attributes::apply(&mut pl.attributes, &p.values);
|
||||
}
|
||||
self.apply_remote_health();
|
||||
}
|
||||
C::UpdateMobEffect(p) => self.remote_effect(&p),
|
||||
C::RemoveMobEffect(p) => {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
|
||||
@@ -2061,6 +2183,7 @@ impl Game<'_> {
|
||||
self.capture(false);
|
||||
}
|
||||
}
|
||||
C::TrackedWaypoint(p) => self.handle_waypoint(&p),
|
||||
C::SetExperience(p) => {
|
||||
self.experience = Experience {
|
||||
level: p.level,
|
||||
@@ -2114,6 +2237,10 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
// WIN_GAME: the poem and credits, then PERFORM_RESPAWN.
|
||||
4 if self.screen != Screen::Win => {
|
||||
self.open_win_screen(true, super::win::Finish::RemoteRespawn);
|
||||
}
|
||||
1 => {
|
||||
self.world.weather.raining = true;
|
||||
self.world.weather.rain_level = (0.0, 0.0);
|
||||
@@ -2884,6 +3011,7 @@ impl Game<'_> {
|
||||
|
||||
fn tick_remote_level(&mut self) -> Result<()> {
|
||||
self.ticks += 1;
|
||||
self.track_experience_display();
|
||||
// Options.broadcastOptions: a changed render distance is sent again.
|
||||
let view = client_view_distance(self.settings.render_distance);
|
||||
if let Some(r) = self.remote.as_mut()
|
||||
@@ -2915,6 +3043,7 @@ impl Game<'_> {
|
||||
self.remote_handle_input();
|
||||
// ClientLevel.tick: the border's size change.
|
||||
self.border.tick();
|
||||
self.player_border();
|
||||
self.tick_remote_entities(running);
|
||||
// ClientLevel.tickBlockEntities: chest lids, shulker boxes, bells.
|
||||
self.tick_block_entity_anims();
|
||||
@@ -2993,6 +3122,13 @@ impl Game<'_> {
|
||||
);
|
||||
let was_flying = player.flying;
|
||||
player.yaw = self.yaw;
|
||||
// As the window's own tick has them: the helmet's Respiration
|
||||
// (decreaseAirSupply) and the dimension's FAST_LAVA, the Nether's
|
||||
// stronger lava current.
|
||||
player.respiration = self.armor[0]
|
||||
.as_ref()
|
||||
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
|
||||
player.fluids.fast_lava = self.dim == Dim::Nether;
|
||||
let dead = self.screen == Screen::Dead;
|
||||
// LivingEntity.isImmobile: a sleeper lies still in the bed.
|
||||
if let Some(head) = self.sleeping {
|
||||
@@ -3145,6 +3281,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let mut lerps = std::mem::take(&mut r.lerps);
|
||||
let stepped = &mut r.stepped;
|
||||
let head_lerps = &mut r.head_lerps;
|
||||
let players = &r.level_players;
|
||||
let mut using = Vec::new();
|
||||
@@ -3175,22 +3312,12 @@ impl Game<'_> {
|
||||
// LivingEntity.updateSwingTime.
|
||||
m.anim.attack_anim_o = m.anim.attack_anim;
|
||||
horizon_client::mobs::ai::tick_swing(m);
|
||||
// Entity.commonTick: LinearInterpolationHandler.interpolate.
|
||||
if let Some(l) = lerps.get_mut(&(m.id as i32))
|
||||
&& l.steps > 0
|
||||
// Entity.tick: the SteppedInterpolationHandler's step.
|
||||
if let Some(at) = stepped
|
||||
.get_mut(&(m.id as i32))
|
||||
.and_then(|h| h.interpolate())
|
||||
{
|
||||
let n = f64::from(l.steps);
|
||||
m.position = Vec3::new(
|
||||
m.position.x + (l.target[0] - m.position.x) / n,
|
||||
m.position.y + (l.target[1] - m.position.y) / n,
|
||||
m.position.z + (l.target[2] - m.position.z) / n,
|
||||
);
|
||||
if let Some((yaw, pitch)) = l.rotation {
|
||||
let alpha = 1.0 / l.steps as f32;
|
||||
m.yaw += wrap_degrees(yaw - m.yaw) * alpha;
|
||||
m.head_pitch += (pitch - m.head_pitch) * alpha;
|
||||
}
|
||||
l.steps -= 1;
|
||||
place_interpolated(m, at);
|
||||
}
|
||||
// LivingEntity.aiStep: the head's own interpolation.
|
||||
if let Some((yaw, steps)) = head_lerps.get_mut(&(m.id as i32))
|
||||
@@ -3465,22 +3592,13 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let facts = self.res.facts().get(state).clone();
|
||||
let held = self.held().cloned();
|
||||
let mut speed = self.res.items().mining_speed(held.as_ref(), &facts.name);
|
||||
if self.player.as_ref().is_some_and(|p| !p.ground.on_ground) {
|
||||
speed /= 5.0;
|
||||
}
|
||||
let harvest = !facts.requires_tool
|
||||
|| self
|
||||
.res
|
||||
.items()
|
||||
.correct_for_drops(held.as_ref(), &facts.name);
|
||||
let progress = if facts.hardness < 0.0 {
|
||||
0.0
|
||||
} else if facts.hardness == 0.0 {
|
||||
// The server's own getDestroyProgress: under water, off the ground,
|
||||
// with haste, fatigue and efficiency, so the client stops digging
|
||||
// when the server is done too (#1254).
|
||||
let progress = if facts.hardness == 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
speed / facts.hardness / if harvest { 30.0 } else { 100.0 }
|
||||
self.destroy_progress(hit.pos)
|
||||
};
|
||||
let same = self
|
||||
.remote
|
||||
@@ -3698,6 +3816,8 @@ impl Game<'_> {
|
||||
}
|
||||
if reacts {
|
||||
self.predict_use(&changes, seq);
|
||||
} else if let Some(stack) = held.as_ref() {
|
||||
self.predict_item_on_block(stack, &hit);
|
||||
}
|
||||
if held_block {
|
||||
if reacts
|
||||
@@ -3716,6 +3836,38 @@ impl Game<'_> {
|
||||
false
|
||||
}
|
||||
|
||||
/// `Item.useOn` on this client's level for the items whose effects are
|
||||
/// the clicker's own (`Level.levelEvent(player, ...)` and
|
||||
/// `playSound(player, ...)`: the server sends them to the others only):
|
||||
/// honeycomb waxing copper, and an axe, hoe or shovel transforming a
|
||||
/// block (stripping, scraping, removing wax, tilling, making a path).
|
||||
fn predict_item_on_block(&mut self, stack: &ItemStack, hit: &super::Hit) {
|
||||
let at = [
|
||||
f64::from(hit.pos[0]) + 0.5,
|
||||
f64::from(hit.pos[1]) + 0.5,
|
||||
f64::from(hit.pos[2]) + 0.5,
|
||||
];
|
||||
if &*stack.item == "minecraft:honeycomb" {
|
||||
// HoneycombItem.useOn.
|
||||
let registry = self.pack.assets.registry;
|
||||
let clicked = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let waxes = registry
|
||||
.state(clicked)
|
||||
.and_then(|old| self.res.transformers().waxed(registry, old))
|
||||
.is_some();
|
||||
if waxes {
|
||||
self.level_event_at(3003, hit.pos, 0);
|
||||
self.audio
|
||||
.play("minecraft:item.honeycomb.wax_on", Some(at), 1.0, 1.0);
|
||||
}
|
||||
} else if let Some((_, sound, _, _, particle)) =
|
||||
self.block_transform(stack, hit.pos, hit.face)
|
||||
{
|
||||
self.transform_particle(&particle, hit.pos);
|
||||
self.audio.play(&sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// MultiPlayerGameMode.useItemOn inside startPrediction: the blocks the
|
||||
/// use changed on this client's level at once, each kept as predicted
|
||||
/// under the use's sequence until the server acknowledges it (a use the
|
||||
@@ -5023,6 +5175,20 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
|
||||
}
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// The local player's profile UUID.
|
||||
pub(super) fn profile(&self) -> u128 {
|
||||
self.profile
|
||||
}
|
||||
/// The entity id of a player in the level, by profile UUID.
|
||||
pub(super) fn player_entity(&self, uuid: u128) -> Option<u32> {
|
||||
self.level_players
|
||||
.iter()
|
||||
.find(|(_, u)| **u == uuid)
|
||||
.map(|(id, _)| *id as u32)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Game;
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! A server's mobs as their synched data has them
|
||||
//! (ClientPacketListener.handleSetEntityData, then each entity's
|
||||
//! `onSyncedDataUpdated`): what a client draws from it rather than from its
|
||||
//! own simulation, which a client of a server doesn't run.
|
||||
use super::Game;
|
||||
use horizon_client::mobs::{Mob, Species, tame};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
|
||||
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
|
||||
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
|
||||
let species = m.species();
|
||||
let baby = super::lan::baby_index(&m.kind.id);
|
||||
for (index, _, value) in values {
|
||||
match (*index, value) {
|
||||
// Entity.DATA_SHARED_FLAGS_ID's FLAG_ONFIRE: Entity.isOnFire on a
|
||||
// client, which draws the flames. A client doesn't count the
|
||||
// fire down: the flag goes off when the server's fire is out.
|
||||
(0, Value::Byte(flags)) => m.fire_ticks = i32::from(flags & 1),
|
||||
// LivingEntity.DATA_HEALTH_ID: the boss bars and the death
|
||||
// animation follow it.
|
||||
(9, Value::Float(health)) => m.health = *health,
|
||||
(i, Value::Bool(b)) if Some(i) == baby => m.extra.age = if *b { -1 } else { 0 },
|
||||
_ => {}
|
||||
}
|
||||
match (species, *index, value) {
|
||||
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 sheared.
|
||||
(Species::Sheep, 18, Value::Byte(wool)) => {
|
||||
m.extra.color = (*wool & 15) as u8;
|
||||
m.extra.sheared = wool & 16 != 0;
|
||||
}
|
||||
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
|
||||
(Species::Wolf | Species::Cat | Species::Parrot, 18, Value::Byte(flags)) => {
|
||||
m.extra.sitting = flags & 1 != 0;
|
||||
m.extra.tame = flags & 4 != 0;
|
||||
}
|
||||
// Wolf.DATA_COLLAR_COLOR and Cat's.
|
||||
(Species::Wolf, 21, Value::Int(c)) | (Species::Cat, 23, Value::Int(c)) => {
|
||||
m.extra.color = (*c & 15) as u8;
|
||||
}
|
||||
// AbstractCubeMob.ID_SIZE: its size and box (refreshDimensions).
|
||||
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
|
||||
m.extra.size = (*size).clamp(1, 127);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// SetEntityData for a mob of the server's.
|
||||
pub(super) fn remote_mob_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
apply(m, values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A mob as a client constructs it before its data arrives: the synched
|
||||
/// defaults the server leaves out (`getNonDefaultValues`) that differ from
|
||||
/// what `Mob::new` gives, such as a wolf's and a cat's red collar.
|
||||
pub(super) fn client_defaults(m: &mut Mob) {
|
||||
if matches!(m.species(), Species::Wolf | Species::Cat) {
|
||||
m.extra.color = tame::RED;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_protocol::entity_data::serializer as s;
|
||||
|
||||
/// A mob of the prepared resources' type `kind`, as a client spawns it
|
||||
/// (None without the resources).
|
||||
fn mob(kind: &str) -> Option<Mob> {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
let types = horizon_client::mobs::MobTypes::load(&root).ok()?;
|
||||
let t = std::sync::Arc::new(types.types.get(kind)?.clone());
|
||||
let mut m = Mob::new(1, t, horizon_core::geometry::Vec3::ZERO, 0.0);
|
||||
client_defaults(&mut m);
|
||||
Some(m)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sheep_takes_its_wool_and_a_calf_its_size() {
|
||||
let Some(mut m) = mob("minecraft:sheep") else {
|
||||
return;
|
||||
};
|
||||
apply(&mut m, &[(18, s::BYTE, Value::Byte(14 | 16))]);
|
||||
assert_eq!((m.extra.color, m.extra.sheared), (14, true));
|
||||
apply(&mut m, &[(18, s::BYTE, Value::Byte(0))]);
|
||||
assert_eq!((m.extra.color, m.extra.sheared), (0, false));
|
||||
let mut calf = mob("minecraft:cow").unwrap();
|
||||
apply(&mut calf, &[(16, s::BOOLEAN, Value::Bool(true))]);
|
||||
assert!(calf.is_baby());
|
||||
apply(&mut calf, &[(16, s::BOOLEAN, Value::Bool(false))]);
|
||||
assert!(!calf.is_baby());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wolf_takes_its_owner_flags_and_collar() {
|
||||
let Some(mut m) = mob("minecraft:wolf") else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(m.extra.color, tame::RED);
|
||||
apply(
|
||||
&mut m,
|
||||
&[
|
||||
(18, s::BYTE, Value::Byte(4 | 1)),
|
||||
(21, s::INT, Value::Int(11)),
|
||||
],
|
||||
);
|
||||
assert!(m.extra.tame && m.extra.sitting);
|
||||
assert_eq!(m.extra.color, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fire_health_and_size_follow_the_server() {
|
||||
let Some(mut m) = mob("minecraft:slime") else {
|
||||
return;
|
||||
};
|
||||
apply(
|
||||
&mut m,
|
||||
&[
|
||||
(0, s::BYTE, Value::Byte(1)),
|
||||
(9, s::FLOAT, Value::Float(12.5)),
|
||||
(18, s::INT, Value::Int(4)),
|
||||
],
|
||||
);
|
||||
assert_eq!((m.fire_ticks, m.health, m.extra.size), (1, 12.5, 4));
|
||||
apply(&mut m, &[(0, s::BYTE, Value::Byte(0))]);
|
||||
assert_eq!(m.fire_ticks, 0);
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,15 @@ impl Game<'_> {
|
||||
self.rockets
|
||||
.push(super::fireworks::Rocket::remote(id, at, velocity));
|
||||
}
|
||||
"minecraft:splash_potion"
|
||||
| "minecraft:lingering_potion"
|
||||
| "minecraft:experience_bottle" => {
|
||||
self.potions.add_remote_thrown(id, kind, at, velocity);
|
||||
}
|
||||
"minecraft:eye_of_ender" => {
|
||||
self.end
|
||||
.add_remote_eye(id, position, [velocity.x, velocity.y, velocity.z])
|
||||
}
|
||||
"minecraft:end_crystal" => self.crystals.push(super::dragon::Crystal {
|
||||
id,
|
||||
position: at,
|
||||
@@ -105,6 +114,17 @@ impl Game<'_> {
|
||||
/// `handleSetEntityData` for these types: what changes how they look.
|
||||
pub(super) fn remote_projectile_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
let id = id as u32;
|
||||
if self.potions.has_thrown(id) {
|
||||
// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it is.
|
||||
for (index, _, value) in values {
|
||||
if let (8, Value::Item(stack)) = (index, value)
|
||||
&& let Some(stack) = self.to_stack(stack)
|
||||
{
|
||||
self.potions.remote_thrown_item(id, stack);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (index, _, value) in values {
|
||||
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id) {
|
||||
// PrimedTnt.DATA_FUSE_ID.
|
||||
@@ -184,6 +204,11 @@ impl Game<'_> {
|
||||
c.time += 1;
|
||||
c.position = step(c.id, c.position);
|
||||
}
|
||||
self.potions.step_remote_thrown(&mut step);
|
||||
self.end.step_remote_eyes(|id, at| {
|
||||
let at = step(id, Vec3::new(at[0], at[1], at[2]));
|
||||
[at.x, at.y, at.z]
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_projectile(&mut self, id: u32) {
|
||||
@@ -193,6 +218,8 @@ impl Game<'_> {
|
||||
self.tridents.retain(|t| t.id != id);
|
||||
self.rockets.retain(|r| r.id != id);
|
||||
self.crystals.retain(|c| c.id != id);
|
||||
self.potions.remove_remote_thrown(id);
|
||||
self.end.remove_remote_eye(id);
|
||||
}
|
||||
|
||||
/// Every one a server sent (a new connection, level or dimension).
|
||||
@@ -203,6 +230,8 @@ impl Game<'_> {
|
||||
self.tridents.clear();
|
||||
self.rockets.clear();
|
||||
self.crystals.clear();
|
||||
self.potions.clear_thrown();
|
||||
self.end.clear_remote_eyes();
|
||||
}
|
||||
|
||||
/// Their kind and place, for `status`.
|
||||
@@ -225,6 +254,12 @@ impl Game<'_> {
|
||||
for p in &self.creatures.projectiles {
|
||||
out.push(json!({"id": p.id, "kind": format!("{:?}", p.kind).split([' ', '{']).next().unwrap_or(""), "position": at(p.position)}));
|
||||
}
|
||||
for (kind, p, _) in self.potions.thrown_entities() {
|
||||
out.push(json!({"kind": kind, "position": at(p)}));
|
||||
}
|
||||
for (id, p) in self.end.eye_positions() {
|
||||
out.push(json!({"id": id, "kind": "minecraft:eye_of_ender", "position": p}));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,7 @@ impl Game<'_> {
|
||||
hud_overlays::camera_overlays(size[0] as i32, size[1] as i32, &overlays, &mut scope);
|
||||
self.screen_fx.build(&ScreenInputs {
|
||||
sprites: &self.gui.atlas.sprites,
|
||||
flags: &self.gui.atlas.flags,
|
||||
aspect: size[0] as f32 / size[1].max(1) as f32,
|
||||
partial,
|
||||
block_overlay,
|
||||
|
||||
@@ -31,6 +31,33 @@ pub(super) struct RespawnConfig {
|
||||
pub forced: bool,
|
||||
}
|
||||
|
||||
/// A respawn point as a player's save keeps it (`respawn`): dimension,
|
||||
/// block, yaw and whether it is forced.
|
||||
pub(super) type SavedRespawn = (String, [i32; 3], f32, bool);
|
||||
|
||||
impl RespawnConfig {
|
||||
/// `RespawnData.of`: the yaw wrapped to [-180, 180).
|
||||
pub(super) fn wrapped(dim: Dim, pos: [i32; 3], yaw: f32, forced: bool) -> Self {
|
||||
Self {
|
||||
dim,
|
||||
pos,
|
||||
yaw: wrap_degrees(f64::from(yaw)) as f32,
|
||||
forced,
|
||||
}
|
||||
}
|
||||
pub(super) fn from_saved((dim, pos, yaw, forced): &SavedRespawn) -> Self {
|
||||
Self {
|
||||
dim: Dim::from_id(dim),
|
||||
pos: *pos,
|
||||
yaw: *yaw,
|
||||
forced: *forced,
|
||||
}
|
||||
}
|
||||
pub(super) fn saved(&self) -> SavedRespawn {
|
||||
(self.dim.id().to_owned(), self.pos, self.yaw, self.forced)
|
||||
}
|
||||
}
|
||||
|
||||
/// `RespawnAnchorBlock.RESPAWN_HORIZONTAL_OFFSETS`, then the same a block
|
||||
/// below, a block above, and straight above.
|
||||
const HORIZONTAL: [[i32; 3]; 8] = [
|
||||
@@ -62,7 +89,7 @@ pub(super) enum AnchorUse {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn is_anchor(&self, state: BlockStateId) -> bool {
|
||||
pub(super) fn is_anchor(&self, state: BlockStateId) -> bool {
|
||||
&*self.res.facts().get(state).name == "minecraft:respawn_anchor"
|
||||
}
|
||||
fn anchor_charges(&self, state: BlockStateId) -> i32 {
|
||||
@@ -415,6 +442,16 @@ impl Game<'_> {
|
||||
let (lz, hz) = (bounds.min(Axis::Z), bounds.max(Axis::Z) - 1.0e-5);
|
||||
(self.border.contains(lo, lz) && self.border.contains(hi, hz)).then_some(position)
|
||||
}
|
||||
/// `Block.isPossibleToRespawnInThis`: neither solid nor liquid; banners,
|
||||
/// signs and pressure plates override it to always.
|
||||
fn possible_to_respawn_in(&self, state: BlockStateId) -> bool {
|
||||
let f = self.res.facts().get(state);
|
||||
let name = &*f.name;
|
||||
name.ends_with("_banner")
|
||||
|| name.ends_with("_sign")
|
||||
|| name.ends_with("_pressure_plate")
|
||||
|| !(f.legacy_solid || matches!(name, "minecraft:water" | "minecraft:lava"))
|
||||
}
|
||||
/// `RespawnAnchorBlock.findStandUpPosition`: a safe spot around the
|
||||
/// anchor, else any spot.
|
||||
pub(super) fn anchor_stand_up(&self, pos: [i32; 3]) -> Option<Vec3> {
|
||||
@@ -428,8 +465,87 @@ impl Game<'_> {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `ServerPlayer.findRespawnAndUseSpawnBlock` for an anchor config in the
|
||||
/// loaded dimension: the position and yaw, using a charge.
|
||||
/// `AbstractBedBlock.findStandUpPosition` for a bed whose head is at
|
||||
/// `pos`, facing `forward`, set from `yaw`: beside the bed on the side
|
||||
/// the player faced away from, around it, then on it; a safe spot
|
||||
/// first, else any spot (a bunk bed: around it, a block lower, then on
|
||||
/// it).
|
||||
pub(super) fn bed_stand_up(&self, pos: [i32; 3], forward: [i32; 3], yaw: f32) -> Option<Vec3> {
|
||||
// Direction.getClockWise.
|
||||
let right = [-forward[2], 0, forward[0]];
|
||||
// Direction.isFacingAngle.
|
||||
let radians = yaw * 0.017_453_292;
|
||||
let (dx, dz) = (
|
||||
-horizon_core::geometry::trig::sin(f64::from(radians)),
|
||||
horizon_core::geometry::trig::cos(f64::from(radians)),
|
||||
);
|
||||
let facing = right[0] as f32 * dx + right[2] as f32 * dz > 0.0;
|
||||
let side = if facing {
|
||||
[-right[0], 0, -right[2]]
|
||||
} else {
|
||||
right
|
||||
};
|
||||
let (f, s) = ([forward[0], forward[2]], [side[0], side[2]]);
|
||||
// bedSurroundStandUpOffsets and bedAboveStandUpOffsets.
|
||||
let surround = [
|
||||
[s[0], s[1]],
|
||||
[s[0] - f[0], s[1] - f[1]],
|
||||
[s[0] - f[0] * 2, s[1] - f[1] * 2],
|
||||
[-f[0] * 2, -f[1] * 2],
|
||||
[-s[0] - f[0] * 2, -s[1] - f[1] * 2],
|
||||
[-s[0] - f[0], -s[1] - f[1]],
|
||||
[-s[0], -s[1]],
|
||||
[-s[0] + f[0], -s[1] + f[1]],
|
||||
[f[0], f[1]],
|
||||
[s[0] + f[0], s[1] + f[1]],
|
||||
];
|
||||
let above = [[0, 0], [-f[0], -f[1]]];
|
||||
let at = |base: [i32; 3], offsets: &[[i32; 2]], check: bool| {
|
||||
offsets.iter().find_map(|o| {
|
||||
self.safe_dismount_location([base[0] + o[0], base[1], base[2] + o[1]], check)
|
||||
})
|
||||
};
|
||||
let below = [pos[0], pos[1] - 1, pos[2]];
|
||||
// isBunkBed: a bed below.
|
||||
if self.is_bed(self.world.block(below[0], below[1], below[2])) {
|
||||
return at(pos, &surround, true)
|
||||
.or_else(|| at(below, &surround, true))
|
||||
.or_else(|| at(pos, &above, true))
|
||||
.or_else(|| at(pos, &surround, false))
|
||||
.or_else(|| at(below, &surround, false))
|
||||
.or_else(|| at(pos, &above, false));
|
||||
}
|
||||
let all: Vec<[i32; 2]> = surround.into_iter().chain(above).collect();
|
||||
at(pos, &all, true).or_else(|| at(pos, &all, false))
|
||||
}
|
||||
pub(super) fn is_bed(&self, state: BlockStateId) -> bool {
|
||||
self.res.facts().get(state).name.ends_with("_bed")
|
||||
}
|
||||
/// The bed's `facing`, as a step.
|
||||
fn bed_forward(&self, state: BlockStateId) -> [i32; 3] {
|
||||
let facing = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("facing").cloned());
|
||||
match facing.as_deref() {
|
||||
Some("south") => [0, 0, 1],
|
||||
Some("west") => [-1, 0, 0],
|
||||
Some("east") => [1, 0, 0],
|
||||
_ => [0, 0, -1],
|
||||
}
|
||||
}
|
||||
/// `BedRule.canSetSpawn` of the bed's rule attribute (`bed_rule`,
|
||||
/// `straw_bed_rule`): only the Overworld's beds set the spawn, straw
|
||||
/// beds never.
|
||||
pub(super) fn bed_sets_spawn(&self, state: BlockStateId) -> bool {
|
||||
self.dim == Dim::Overworld && &*self.res.facts().get(state).name != "minecraft:straw_bed"
|
||||
}
|
||||
/// `ServerPlayer.findRespawnAndUseSpawnBlock` for a config in the loaded
|
||||
/// dimension: the position and yaw, using an anchor's charge; a bed's
|
||||
/// stand-up spot. A forced point (`spawnpoint`) that is no anchor or bed
|
||||
/// holds if a player fits in it; anything else is gone.
|
||||
pub(super) fn find_respawn_and_use(
|
||||
&mut self,
|
||||
c: RespawnConfig,
|
||||
@@ -440,7 +556,19 @@ impl Game<'_> {
|
||||
&& (c.forced || self.anchor_charges(state) > 0)
|
||||
&& self.anchor_works())
|
||||
{
|
||||
return Ok(None);
|
||||
// A bed where its bed rule lets it set the spawn.
|
||||
if self.is_bed(state) && self.bed_sets_spawn(state) {
|
||||
let facing = self.bed_forward(state);
|
||||
let stand = self.bed_stand_up(c.pos, facing, c.yaw);
|
||||
return Ok(stand.map(|p| (p, look_at_yaw(p, c.pos))));
|
||||
}
|
||||
let [x, y, z] = c.pos;
|
||||
let free = c.forced
|
||||
&& !self.is_anchor(state)
|
||||
&& self.possible_to_respawn_in(state)
|
||||
&& self.possible_to_respawn_in(self.world.block(x, y + 1, z));
|
||||
let at = Vec3::new(f64::from(x) + 0.5, f64::from(y) + 0.1, f64::from(z) + 0.5);
|
||||
return Ok(free.then_some((at, c.yaw)));
|
||||
}
|
||||
let stand = self.anchor_stand_up(c.pos);
|
||||
if !c.forced
|
||||
|
||||
@@ -324,6 +324,22 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ServerScoreboard`'s packets: each placed player's client is sent
|
||||
/// what changed on the board since it was last sent it (all of it on
|
||||
/// joining), lan/scoreboard.rs.
|
||||
pub(super) fn lan_sync_scoreboard(&mut self, host: &mut super::lan::LanHost) {
|
||||
if host.hub.guests.iter().all(|g| !g.placed) {
|
||||
return;
|
||||
}
|
||||
self.load_scoreboard();
|
||||
let view = horizon_client::lan::scoreboard::View::of(&self.score.board);
|
||||
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
|
||||
if g.scoreboard != view {
|
||||
g.send_all(&g.scoreboard.changes(&view));
|
||||
g.scoreboard = view.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.doTick` score updates and the statistic criteria.
|
||||
pub(super) fn tick_scoreboard(&mut self) {
|
||||
self.load_scoreboard();
|
||||
@@ -563,19 +579,42 @@ impl Game<'_> {
|
||||
|
||||
// ---- the sidebar -----------------------------------------------------------
|
||||
|
||||
/// `Hud.extractScoreboardSidebar` / `displayScoreboardSidebar`.
|
||||
pub(super) fn draw_scoreboard(&self, batch: &mut HudBatch) {
|
||||
/// The objective in the player's team's sidebar slot, else the sidebar's.
|
||||
fn sidebar_objective(&self) -> Option<&sb::Objective> {
|
||||
let board = &self.score.board;
|
||||
if board.display.is_empty() {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
// A server's client is in its team by its profile name.
|
||||
let name = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map_or(PLAYER_NAME, |r| r.profile_name.as_str());
|
||||
let team_slot = board
|
||||
.players_team(PLAYER_NAME)
|
||||
.players_team(name)
|
||||
.and_then(|t| t.color.as_ref())
|
||||
.map(|c| format!("sidebar.team.{c}"))
|
||||
.and_then(|slot| board.display_objective(&slot));
|
||||
let Some(objective) = team_slot.or_else(|| board.display_objective(sb::SLOT_SIDEBAR))
|
||||
else {
|
||||
team_slot.or_else(|| board.display_objective(sb::SLOT_SIDEBAR))
|
||||
}
|
||||
/// The sidebar the HUD shows: its objective and its lines, for `status`.
|
||||
pub(super) fn sidebar_status(&self) -> serde_json::Value {
|
||||
let board = &self.score.board;
|
||||
let Some(objective) = self.sidebar_objective() else {
|
||||
return serde_json::Value::Null;
|
||||
};
|
||||
let scores: serde_json::Map<String, serde_json::Value> = board
|
||||
.scores
|
||||
.iter()
|
||||
.filter_map(|(holder, s)| Some((holder.clone(), s.get(&objective.name)?.value.into())))
|
||||
.collect();
|
||||
serde_json::json!({ "objective": objective.name, "title": objective.display_text().plain(), "scores": scores })
|
||||
}
|
||||
|
||||
/// `Hud.extractScoreboardSidebar` / `displayScoreboardSidebar`.
|
||||
pub(super) fn draw_scoreboard(&self, batch: &mut HudBatch) {
|
||||
let board = &self.score.board;
|
||||
let Some(objective) = self.sidebar_objective() else {
|
||||
return;
|
||||
};
|
||||
let draws = hud_overlays::sidebar(
|
||||
|
||||
@@ -25,6 +25,9 @@ impl Screen {
|
||||
| Self::Inventory
|
||||
| Self::Dead
|
||||
| Self::CommandBlock => false,
|
||||
// WinScreen.extractBackground: the poem fills the screen with the End portal, the
|
||||
// credits alone blur. Game::menu_blur knows which one rolls.
|
||||
Self::Win => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,6 +362,7 @@ mod tests {
|
||||
Screen::Inventory,
|
||||
Screen::Dead,
|
||||
Screen::CommandBlock,
|
||||
Screen::Win,
|
||||
] {
|
||||
assert!(!screen.blurs_background(true));
|
||||
assert!(!screen.blurs_background(false));
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
//! another level: a Respawn packet keeping all data, then a teleport to the
|
||||
//! exit once the destination has loaded around them.
|
||||
|
||||
use super::end::GatewayJob;
|
||||
use super::nether_portal::{PLAYER_DELAY, Plan};
|
||||
use super::respawn_anchor::RespawnConfig;
|
||||
use super::{Dim, Game, lan::LanHost};
|
||||
use horizon_client::lan::hub::TickCtx;
|
||||
use horizon_client::{lan::hub::TickCtx, text::Text};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
|
||||
/// A trip under way: the player is in the destination's level, whose
|
||||
@@ -22,6 +24,12 @@ pub(super) struct Trip {
|
||||
end: bool,
|
||||
/// The Nether portal plan, whose exit is found or built on arrival.
|
||||
plan: Option<Plan>,
|
||||
/// Through an End gateway, within the level: its exit, found or built
|
||||
/// on arrival.
|
||||
gateway: Option<GatewayJob>,
|
||||
/// A respawn at this point outside the Overworld, resolved through its
|
||||
/// block on arrival.
|
||||
respawn: Option<RespawnConfig>,
|
||||
/// Sent by a command: the player is already where it goes.
|
||||
placed: bool,
|
||||
}
|
||||
@@ -95,7 +103,7 @@ impl Game<'_> {
|
||||
if let Some(pos) = self.nether_portal_inside(at) {
|
||||
self.portals.player.set_inside(pos, PLAYER_DELAY);
|
||||
}
|
||||
let (end_portal, _) = self.inside_end_portals(at, WIDTH, HEIGHT);
|
||||
let (end_portal, gateway) = self.inside_end_portals(at, WIDTH, HEIGHT);
|
||||
if end_portal {
|
||||
if self.portals.player.cooldown == 0 {
|
||||
let target = if self.dim == Dim::End {
|
||||
@@ -109,6 +117,19 @@ impl Game<'_> {
|
||||
self.portals.player.cooldown = 10;
|
||||
return;
|
||||
}
|
||||
// EndGatewayBlock.entityInside: a gateway that is not cooling down
|
||||
// takes the player at once (no transition time) and cools down
|
||||
// (triggerCooldown), even for a player still on its own cooldown.
|
||||
if let Some(g) = gateway
|
||||
&& self.gateway_enter(g)
|
||||
{
|
||||
if self.portals.player.cooldown == 0 {
|
||||
self.start_gateway_trip(host, conn, g);
|
||||
}
|
||||
// setPortalCooldown: after the trip, or again while cooling down.
|
||||
self.portals.player.cooldown = 10;
|
||||
return;
|
||||
}
|
||||
let rule = if invulnerable {
|
||||
"players_nether_portal_creative_delay"
|
||||
} else {
|
||||
@@ -172,11 +193,16 @@ impl Game<'_> {
|
||||
_ => 1.0,
|
||||
};
|
||||
self.end_portal_travel = end;
|
||||
let scaled = self.end_destination(target).unwrap_or(Vec3::new(
|
||||
from.x * scale,
|
||||
from.y,
|
||||
from.z * scale,
|
||||
));
|
||||
// Leaving the End: the player's own respawn point.
|
||||
let own = if end {
|
||||
self.as_guest(host, conn, |g| g.end_destination(target))
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let scaled = own
|
||||
.or_else(|| self.end_destination(target))
|
||||
.unwrap_or(Vec3::new(from.x * scale, from.y, from.z * scale));
|
||||
self.end_portal_travel = false;
|
||||
if let Err(e) = self.enter_level(Some(host), target) {
|
||||
log::error!("could not open {}: {e}", target.id());
|
||||
@@ -197,6 +223,8 @@ impl Game<'_> {
|
||||
target: destination,
|
||||
end,
|
||||
plan,
|
||||
gateway: None,
|
||||
respawn: None,
|
||||
placed: false,
|
||||
});
|
||||
if let Err(e) = self.enter_level(Some(host), source) {
|
||||
@@ -204,6 +232,93 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A respawned player waits at its point outside the Overworld for the
|
||||
/// level to load around it.
|
||||
pub(super) fn respawn_trip(&mut self, conn: u32, config: RespawnConfig) {
|
||||
let [x, y, z] = config.pos;
|
||||
self.server_trips.push(Trip {
|
||||
conn,
|
||||
dim: config.dim,
|
||||
target: Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5),
|
||||
end: false,
|
||||
plan: None,
|
||||
gateway: None,
|
||||
respawn: Some(config),
|
||||
placed: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// `findRespawnAndUseSpawnBlock` for the player in place, at its point
|
||||
/// in the level in place: where it stands up, facing the anchor (with
|
||||
/// the deplete sound) or the bed; else the point is forgotten
|
||||
/// (`block.minecraft.spawn.not_valid`).
|
||||
pub(super) fn resolve_server_respawn(&mut self, config: RespawnConfig) -> Option<(Vec3, f32)> {
|
||||
let [x, y, z] = config.pos;
|
||||
let anchor = self.is_anchor(self.world.block(x, y, z));
|
||||
match self.find_respawn_and_use(config, true) {
|
||||
Ok(Some((at, yaw))) => {
|
||||
if !config.forced && anchor {
|
||||
let [x, y, z] = config.pos.map(f64::from);
|
||||
self.audio.play(
|
||||
"minecraft:block.respawn_anchor.deplete",
|
||||
Some([x, y, z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
Some((at, yaw))
|
||||
}
|
||||
Ok(None) | Err(_) => {
|
||||
self.respawn_config = None;
|
||||
self.system_message(Text::translate("block.minecraft.spawn.not_valid", &[]));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TeleportTransition.missingRespawnBlock: the world spawn, in the
|
||||
/// Overworld.
|
||||
fn respawn_at_world_spawn(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let at = self.level.world_spawn.map_or(
|
||||
self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]),
|
||||
|[x, y, z]| [f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5],
|
||||
);
|
||||
self.level_teleports.push(LevelTeleport {
|
||||
conn,
|
||||
dim: Dim::Overworld,
|
||||
at,
|
||||
rotation: None,
|
||||
});
|
||||
self.run_level_teleports(host);
|
||||
}
|
||||
|
||||
/// `TheEndGatewayBlockEntity.getPortalPosition` and the teleport within
|
||||
/// the End (keeping the player's motion and look): straight to an exact
|
||||
/// exit, else once the destination has loaded around the player, who
|
||||
/// waits there (its exit is found among those blocks, or built).
|
||||
fn start_gateway_trip(&mut self, host: &mut LanHost, conn: u32, gateway: [i32; 3]) {
|
||||
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let (job, destination) = self.gateway_job(gateway);
|
||||
let at = [destination.x, destination.y, destination.z];
|
||||
// Relative.union(DELTA, ROTATION).
|
||||
host.hub.teleport_keeping_motion(i, at);
|
||||
if let GatewayJob::Known { exact: true, .. } = job {
|
||||
return;
|
||||
}
|
||||
self.server_trips.push(Trip {
|
||||
conn,
|
||||
dim: self.dim,
|
||||
target: destination,
|
||||
end: false,
|
||||
plan: None,
|
||||
gateway: Some(job),
|
||||
respawn: None,
|
||||
placed: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// The trips into the level in place whose destination has loaded
|
||||
/// around their target: the exit (the Nether portal found or built, the
|
||||
/// End platform, the Overworld's spawn column) and the teleport there.
|
||||
@@ -232,8 +347,28 @@ impl Game<'_> {
|
||||
}
|
||||
self.resolve_arrivals();
|
||||
let yaw = host.hub.guests[i].yaw;
|
||||
let (position, yaw) = if trip.end {
|
||||
match self.end_arrival(trip.target) {
|
||||
let (position, yaw) = if let Some(config) = trip.respawn {
|
||||
match self.as_guest(host, trip.conn, |g| g.resolve_server_respawn(config)) {
|
||||
Some(Some(found)) => found,
|
||||
_ => {
|
||||
self.respawn_at_world_spawn(host, trip.conn);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else if let Some(job) = trip.gateway {
|
||||
match self.resolve_gateway(job, trip.target) {
|
||||
Ok(at) => (at, yaw),
|
||||
Err(e) => {
|
||||
log::error!("End gateway exit: {e}");
|
||||
(trip.target, yaw)
|
||||
}
|
||||
}
|
||||
} else if trip.end {
|
||||
let arrival = match self.as_guest(host, trip.conn, |g| g.end_arrival(trip.target)) {
|
||||
Some(arrival) => arrival,
|
||||
None => self.end_arrival(trip.target),
|
||||
};
|
||||
match arrival {
|
||||
Ok((at, turned)) => (at, turned.unwrap_or(yaw)),
|
||||
Err(e) => {
|
||||
log::error!("End arrival: {e}");
|
||||
@@ -293,6 +428,8 @@ impl Game<'_> {
|
||||
target: Vec3::new(t.at[0], t.at[1], t.at[2]),
|
||||
end: false,
|
||||
plan: None,
|
||||
gateway: None,
|
||||
respawn: None,
|
||||
placed: true,
|
||||
});
|
||||
if let Err(e) = self.enter_level(Some(host), source) {
|
||||
@@ -318,6 +455,7 @@ impl Game<'_> {
|
||||
tick_frozen: self.clock.frozen(),
|
||||
tick_steps: self.clock.step_state(),
|
||||
collision: Some(self.collision),
|
||||
border: &self.border,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,21 @@ impl Game<'_> {
|
||||
self.load_shader_pack();
|
||||
}
|
||||
}
|
||||
/// A file dropped on the window: a shader pack while the Shader Packs
|
||||
/// page shows (`OptionsUi::drop_shader_pack`).
|
||||
pub(super) fn drop_shader_pack(&mut self, path: &std::path::Path) {
|
||||
match self.ui.options.drop_shader_pack(&mut self.settings, path) {
|
||||
Some(Ok(())) => {
|
||||
self.settings.save(&Self::options_path());
|
||||
self.load_shader_pack();
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::warn!("Horizon: shader pack {}: {e}", path.display());
|
||||
self.system_message(Text::literal(format!("Shader pack: {e}")));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
fn shader_pack_error(&mut self, id: &str, error: &str) {
|
||||
log::warn!("Horizon: shader pack {id}: {error}");
|
||||
// The Shader Packs page says why on the pack's row: the first line,
|
||||
|
||||
@@ -12,6 +12,8 @@ pub(super) struct StartupMenu {
|
||||
title: Option<Title>,
|
||||
options: OptionsUi,
|
||||
options_open: bool,
|
||||
/// The credits from Credits & Attribution, and their last frame.
|
||||
win: Option<(horizon_client::win_screen::WinScreen, Instant)>,
|
||||
root: PathBuf,
|
||||
cursor: (f32, f32),
|
||||
pointer: (f64, f64),
|
||||
@@ -370,7 +372,26 @@ impl Loading {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
packs::open_folder(&dir);
|
||||
}
|
||||
Event::ButtonClick => {}
|
||||
Event::Credits => {
|
||||
if let Some(gui) = &self.gui {
|
||||
let name = self.options.as_ref().map_or_else(
|
||||
|| horizon_client::offline_name::generate(0),
|
||||
Options::player_name,
|
||||
);
|
||||
let screen = horizon_client::win_screen::WinScreen::new(
|
||||
false,
|
||||
&self.menu.root,
|
||||
&name,
|
||||
&gui.atlas.font,
|
||||
);
|
||||
self.menu.win = Some((screen, Instant::now()));
|
||||
}
|
||||
}
|
||||
Event::ReloadShaderPack => {
|
||||
self.save_menu_settings();
|
||||
}
|
||||
// The startup menu runs before the game opens a control socket.
|
||||
Event::ButtonClick | Event::CopyAgentDetails => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -434,6 +455,17 @@ impl Loading {
|
||||
}
|
||||
self.apply_menu_settings();
|
||||
}
|
||||
WindowEvent::DroppedFile(path) => {
|
||||
match self
|
||||
.menu
|
||||
.options
|
||||
.drop_shader_pack(&mut self.settings, &path)
|
||||
{
|
||||
Some(Ok(())) => self.save_menu_settings(),
|
||||
Some(Err(e)) => log::warn!("Horizon: shader pack {}: {e}", path.display()),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
WindowEvent::RedrawRequested => self.draw(),
|
||||
WindowEvent::ModifiersChanged(modifiers) => self.menu.modifiers = modifiers.state(),
|
||||
WindowEvent::Focused(false) => {
|
||||
@@ -456,6 +488,20 @@ impl Loading {
|
||||
}
|
||||
}
|
||||
_ if self.gui.is_none() || self.menu.pending.is_some() => {}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.menu.win.is_some() => {
|
||||
if let PhysicalKey::Code(code) = event.physical_key {
|
||||
let pressed = event.state == ElementState::Pressed;
|
||||
if code == KeyCode::Escape && pressed {
|
||||
self.menu.win = None;
|
||||
} else if let Some(key) = horizon_client::win_screen::Key::from_code(code)
|
||||
&& let Some((win, _)) = &mut self.menu.win
|
||||
{
|
||||
win.key(key, pressed);
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { .. } | WindowEvent::MouseWheel { .. }
|
||||
if self.menu.win.is_some() => {}
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
self.menu.pointer = (position.x, position.y);
|
||||
self.update_menu_cursor();
|
||||
@@ -575,6 +621,15 @@ impl Loading {
|
||||
self.progress.line(),
|
||||
self.progress.fraction(),
|
||||
);
|
||||
} else if let Some((win, frame)) = &mut self.menu.win {
|
||||
let now = Instant::now();
|
||||
// 20 ticks a second: the startup menu runs no tick rate.
|
||||
let delta_ticks = now.duration_since(*frame).as_secs_f32() * 20.0;
|
||||
*frame = now;
|
||||
win.draw(batch, &gui.atlas, delta_ticks, Default::default());
|
||||
if win.finished(batch.height) {
|
||||
self.menu.win = None;
|
||||
}
|
||||
} else if self.menu.options_open {
|
||||
self.menu
|
||||
.options
|
||||
|
||||
@@ -306,9 +306,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `PhantomSpawner.tick`: tired players under an open night sky draw
|
||||
/// phantoms.
|
||||
/// phantoms. A custom spawner ticks only while `spawn_mobs` is on
|
||||
/// (`ServerChunkCache.tickChunks`), and this one only spawns enemies
|
||||
/// (`ServerLevel.isSpawningMonsters`: `spawn_monsters` too). Every
|
||||
/// player of the level counts: the game's own and the hosted ones (a
|
||||
/// server thread's players are all hosted).
|
||||
pub(super) fn spawn_phantoms(&mut self) {
|
||||
if !self.ui.rules.bool("spawn_phantoms") || self.dim != Dim::Overworld {
|
||||
let rules = &self.ui.rules;
|
||||
if !rules.bool("spawn_mobs")
|
||||
|| !rules.bool("spawn_monsters")
|
||||
|| !rules.bool("spawn_phantoms")
|
||||
|| self.dim != Dim::Overworld
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.stats.phantom_tick -= 1;
|
||||
@@ -317,16 +326,42 @@ impl Game<'_> {
|
||||
}
|
||||
let r = &mut self.world.random;
|
||||
self.stats.phantom_tick += (60 + r.next_int_bound(60).unwrap_or(0)) * 20;
|
||||
if self.sky_darken() < 5 || self.spectator() {
|
||||
if self.sky_darken() < 5 {
|
||||
return;
|
||||
}
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let rest = |s: &Stats| s.get("custom", "minecraft:time_since_rest");
|
||||
// `level.players()`: (block position, time since rest) of each player
|
||||
// who isn't a spectator.
|
||||
let mut players = Vec::new();
|
||||
if let Some(p) = self.player.as_ref()
|
||||
&& !self.spectator()
|
||||
{
|
||||
players.push((p.position, rest(&self.stats)));
|
||||
}
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
let level = self.dim.id();
|
||||
for g in host.hub.guests.iter().filter(|g| {
|
||||
g.placed
|
||||
&& g.dimension == level
|
||||
&& g.game_mode != horizon_client::lan::guest::SPECTATOR
|
||||
}) {
|
||||
let since = host.play.get(&g.conn).map_or(0, |p| rest(&p.stats));
|
||||
players.push((
|
||||
Vec3::new(g.position[0], g.position[1], g.position[2]),
|
||||
since,
|
||||
));
|
||||
}
|
||||
}
|
||||
for (position, since) in players {
|
||||
self.spawn_phantoms_near(position, since);
|
||||
}
|
||||
}
|
||||
/// One player's turn of `PhantomSpawner.tick`.
|
||||
fn spawn_phantoms_near(&mut self, position: Vec3, since_rest: i32) {
|
||||
let pos = [
|
||||
p.position.x.floor() as i32,
|
||||
p.position.y.floor() as i32,
|
||||
p.position.z.floor() as i32,
|
||||
position.x.floor() as i32,
|
||||
position.y.floor() as i32,
|
||||
position.z.floor() as i32,
|
||||
];
|
||||
if pos[1] < 63 || !self.can_see_sky(pos) {
|
||||
return;
|
||||
@@ -336,15 +371,15 @@ impl Game<'_> {
|
||||
if r.next_float() * 3.0 >= 2.0 {
|
||||
return;
|
||||
}
|
||||
let rest = self.stats.get("custom", "minecraft:time_since_rest").max(1);
|
||||
if r.next_int_bound(rest).unwrap_or(0) < 72000 {
|
||||
if r.next_int_bound(since_rest.max(1)).unwrap_or(0) < 72000 {
|
||||
return;
|
||||
}
|
||||
let at = [
|
||||
pos[0] - 10 + r.next_int_bound(21).unwrap_or(10),
|
||||
pos[1] + 20 + r.next_int_bound(15).unwrap_or(0),
|
||||
pos[2] - 10 + r.next_int_bound(21).unwrap_or(10),
|
||||
];
|
||||
// `playerPos.above(20 + nextInt(15)).east(-10 + nextInt(21)).south(...)`:
|
||||
// the height is drawn first.
|
||||
let up = 20 + r.next_int_bound(15).unwrap_or(0);
|
||||
let east = -10 + r.next_int_bound(21).unwrap_or(10);
|
||||
let south = -10 + r.next_int_bound(21).unwrap_or(10);
|
||||
let at = [pos[0] + east, pos[1] + up, pos[2] + south];
|
||||
if !self
|
||||
.res
|
||||
.facts()
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! `WinScreen` in the game: the poem and credits after leaving the End for
|
||||
//! the first time (`EndPortalBlock.entityInside` -> `ServerPlayer.showEndCredits`,
|
||||
//! or a server's `WIN_GAME` event), and the credits from Credits &
|
||||
//! Attribution.
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
hud::{EndPortalFill, HudBatch},
|
||||
win_screen::{Key, WinScreen},
|
||||
};
|
||||
use std::time::Instant;
|
||||
use winit::{
|
||||
event::{ElementState, KeyEvent},
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
};
|
||||
|
||||
/// What closing the screen does (`WinScreen.onFinished`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Finish {
|
||||
/// `PERFORM_RESPAWN` in single player: the player, out of the End since
|
||||
/// the credits began, arrives in the Overworld.
|
||||
Respawn,
|
||||
/// `PERFORM_RESPAWN` sent to the server.
|
||||
RemoteRespawn,
|
||||
/// Back to Credits & Attribution.
|
||||
Options,
|
||||
/// Back to the game (`horizon-ctl credits`).
|
||||
Closed,
|
||||
}
|
||||
pub(super) struct Win {
|
||||
screen: WinScreen,
|
||||
finish: Finish,
|
||||
/// The last drawn frame, for the scroll's game-time delta.
|
||||
frame: Instant,
|
||||
}
|
||||
impl Game<'_> {
|
||||
pub(super) fn open_win_screen(&mut self, poem: bool, finish: Finish) {
|
||||
let screen = WinScreen::new(
|
||||
poem,
|
||||
&self.options.resources,
|
||||
&self.options.player_name(),
|
||||
&self.gui.atlas.font,
|
||||
);
|
||||
self.win = Some(Win {
|
||||
screen,
|
||||
finish,
|
||||
frame: Instant::now(),
|
||||
});
|
||||
self.keys.clear();
|
||||
self.screen = Screen::Win;
|
||||
self.capture(false);
|
||||
}
|
||||
/// `ServerPlayer.showEndCredits`: the player leaves the End now (no
|
||||
/// longer in any level until the credits end) and has seen the credits.
|
||||
pub(super) fn show_end_credits(&mut self) -> Result<()> {
|
||||
self.level.seen_credits = true;
|
||||
self.open_win_screen(true, Finish::Respawn);
|
||||
self.end_portal_travel = true;
|
||||
let result = self.enter_dimension(super::Dim::Overworld);
|
||||
self.end_portal_travel = false;
|
||||
result
|
||||
}
|
||||
/// Stop the scroll `scroll` pixels in (`horizon-ctl credits ... SCROLL`).
|
||||
pub(super) fn hold_win_screen(&mut self, scroll: f32) {
|
||||
if let Some(win) = self.win.as_mut() {
|
||||
win.screen.hold(scroll);
|
||||
}
|
||||
}
|
||||
/// `WinScreen.isPauseScreen`: only the credits without the poem pause.
|
||||
pub(super) fn win_screen_pauses(&self) -> bool {
|
||||
self.win.as_ref().is_some_and(|w| !w.screen.poem)
|
||||
}
|
||||
/// The player waits out of the world while the poem rolls.
|
||||
pub(super) fn win_screen_holds_arrival(&self) -> bool {
|
||||
self.win
|
||||
.as_ref()
|
||||
.is_some_and(|w| w.finish == Finish::Respawn)
|
||||
}
|
||||
pub(super) fn draw_win_screen(&mut self, batch: &mut HudBatch) {
|
||||
let Some(win) = self.win.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let now = Instant::now();
|
||||
// DeltaTracker.getGameTimeDeltaTicks: the frame's length in ticks.
|
||||
let delta_ticks =
|
||||
now.duration_since(win.frame).as_secs_f32() / self.clock.tick_duration().as_secs_f32();
|
||||
win.frame = now;
|
||||
let sprite = |n: &str| {
|
||||
self.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.get(n)
|
||||
.map_or([0.0; 4], |r| [r.u0, r.v0, r.u1 - r.u0, r.v1 - r.v0])
|
||||
};
|
||||
let fill = EndPortalFill {
|
||||
sprites: [
|
||||
sprite("environment/end_sky"),
|
||||
sprite("entity/end_portal/end_portal"),
|
||||
],
|
||||
game_time: (self.world.game_time % 24000) as f32 / 24000.0,
|
||||
};
|
||||
win.screen.draw(batch, &self.gui.atlas, delta_ticks, fill);
|
||||
}
|
||||
/// `WinScreen.keyPressed` / `keyReleased`; Escape closes it (`onClose`).
|
||||
pub(super) fn win_key(&mut self, event: &KeyEvent) -> Result<()> {
|
||||
let PhysicalKey::Code(code) = event.physical_key else {
|
||||
return Ok(());
|
||||
};
|
||||
let pressed = event.state == ElementState::Pressed;
|
||||
if code == KeyCode::Escape && pressed {
|
||||
return self.close_win_screen();
|
||||
}
|
||||
let Some(key) = Key::from_code(code) else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(win) = self.win.as_mut() {
|
||||
win.screen.key(key, pressed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `WinScreen.tick`: done once everything has rolled past the top.
|
||||
pub(super) fn tick_win_screen(&mut self) -> Result<()> {
|
||||
let height = self.gui_size().1;
|
||||
if self.win.as_ref().is_some_and(|w| w.screen.finished(height)) {
|
||||
self.close_win_screen()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `WinScreen.respawn` (`onFinished`) and `removed`: the credits music stops.
|
||||
pub(super) fn close_win_screen(&mut self) -> Result<()> {
|
||||
let Some(win) = self.win.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
let credits = horizon_client::music::Music::credits();
|
||||
self.music
|
||||
.stop_playing_music(&credits, None, &mut self.audio);
|
||||
match win.finish {
|
||||
Finish::Options => self.screen = Screen::Options,
|
||||
Finish::Closed if self.title.is_some() => self.screen = Screen::Playing,
|
||||
Finish::Respawn | Finish::RemoteRespawn | Finish::Closed => {
|
||||
if win.finish == Finish::RemoteRespawn {
|
||||
self.remote_respawn();
|
||||
}
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+489
-458
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,590 @@
|
||||
//! Animated block sprites, drawn on the GPU as vanilla 26.3 draws them:
|
||||
//! `SpriteContents.AnimatedTexture` keeps every frame in video memory, and
|
||||
//! each tick `animate_sprite_blit` or `animate_sprite_interpolate` draws the
|
||||
//! frame shown (mixed with the next one for `interpolate`) into the atlas.
|
||||
//!
|
||||
//! Every frame's mip chain is uploaded once, at the sprite's own size. A tick
|
||||
//! that changes sprites writes one small job per changed sprite and runs one
|
||||
//! compute dispatch per mip level (`animate.wgsl`), which repeats the chain
|
||||
//! into the array's larger layers by whole pixels and mixes interpolated
|
||||
//! frames. Blending on the CPU at the array's size and copying every changed
|
||||
//! sprite level by level (a copy command each, a blit each in Mesa) was about
|
||||
//! a fifth of the render thread on an integrated GPU.
|
||||
use super::upload::Uploads;
|
||||
use crate::mipmap::Settings;
|
||||
use crate::sprites::{FrameBlend, SpriteAnimation};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
/// Bytes of `animate.wgsl`'s `Job`.
|
||||
const JOB_BYTES: usize = 20;
|
||||
/// `animate.wgsl`'s workgroup edge.
|
||||
const WORKGROUP: u32 = 8;
|
||||
|
||||
struct Sprite {
|
||||
animation: SpriteAnimation,
|
||||
/// What its layer shows (`None`: the frame the array was created with,
|
||||
/// rewritten on the first tick).
|
||||
shown: Option<FrameBlend>,
|
||||
/// Its layer in its sprite array.
|
||||
layer: u32,
|
||||
/// Each frame's mip chain: its offset in words in the group's frames.
|
||||
frames: Vec<u32>,
|
||||
/// Edge of the chains' first level.
|
||||
edge: u32,
|
||||
group: usize,
|
||||
}
|
||||
|
||||
/// The animated sprites of one sprite array whose frames share one buffer.
|
||||
struct Group {
|
||||
/// Where its jobs start in `SpriteAnimator::jobs`, in bytes.
|
||||
offset: u64,
|
||||
/// One bind group per mip level of the array.
|
||||
levels: Vec<wgpu::BindGroup>,
|
||||
/// This tick's jobs.
|
||||
jobs: Vec<u8>,
|
||||
}
|
||||
|
||||
pub(super) struct SpriteAnimator {
|
||||
sprites: Vec<Sprite>,
|
||||
groups: Vec<Group>,
|
||||
jobs: wgpu::Buffer,
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
/// Edge of the arrays' layers.
|
||||
size: u32,
|
||||
}
|
||||
|
||||
impl SpriteAnimator {
|
||||
/// `textures` are the sprite arrays, `split` layers each, `size` pixels
|
||||
/// wide; `animations` hold their frames at that size.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
animations: &[SpriteAnimation],
|
||||
size: u32,
|
||||
settings: &[Settings],
|
||||
textures: &[wgpu::Texture],
|
||||
split: u32,
|
||||
) -> Self {
|
||||
let limits = device.limits();
|
||||
let limit = limits
|
||||
.max_storage_buffer_binding_size
|
||||
.min(limits.max_buffer_size);
|
||||
let mut frames: Vec<Vec<u8>> = Vec::new();
|
||||
// Each group's sprite array and frames buffer.
|
||||
let mut keys: Vec<(u32, usize)> = Vec::new();
|
||||
let mut counts: Vec<usize> = Vec::new();
|
||||
let mut sprites = Vec::with_capacity(animations.len());
|
||||
for animation in animations {
|
||||
let chains: Vec<_> = (0..animation.frame_count())
|
||||
.map(|frame| source_chain(animation.pixels(frame), size, settings, animation.layer))
|
||||
.collect();
|
||||
let bytes: u64 = chains.iter().map(|(chain, _)| chain.len() as u64).sum();
|
||||
if bytes > limit {
|
||||
log::warn!(
|
||||
"Horizon: layer {}'s animation ({bytes} bytes) does not fit in a storage buffer; it stays still",
|
||||
animation.layer
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if frames
|
||||
.last()
|
||||
.is_none_or(|data| data.len() as u64 + bytes > limit)
|
||||
{
|
||||
frames.push(Vec::new());
|
||||
}
|
||||
let buffer = frames.len() - 1;
|
||||
let data = &mut frames[buffer];
|
||||
let offsets = chains
|
||||
.iter()
|
||||
.map(|(chain, _)| {
|
||||
let offset = (data.len() / 4) as u32;
|
||||
data.extend_from_slice(chain);
|
||||
offset
|
||||
})
|
||||
.collect();
|
||||
let key = (animation.layer / split, buffer);
|
||||
let group = keys.iter().position(|&k| k == key).unwrap_or_else(|| {
|
||||
keys.push(key);
|
||||
counts.push(0);
|
||||
keys.len() - 1
|
||||
});
|
||||
counts[group] += 1;
|
||||
sprites.push(Sprite {
|
||||
animation: animation.clone(),
|
||||
shown: None,
|
||||
layer: animation.layer % split,
|
||||
frames: offsets,
|
||||
edge: chains[0].1,
|
||||
group,
|
||||
});
|
||||
}
|
||||
let frames: Vec<_> = frames
|
||||
.iter()
|
||||
.map(|data| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Animated sprite frames"),
|
||||
contents: data,
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let align = u64::from(limits.min_storage_buffer_offset_alignment);
|
||||
let mut offsets = Vec::with_capacity(counts.len());
|
||||
let mut total = 0;
|
||||
for &count in &counts {
|
||||
offsets.push(total);
|
||||
total = (total + (count * JOB_BYTES) as u64).next_multiple_of(align);
|
||||
}
|
||||
let jobs = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Animated sprite jobs"),
|
||||
size: total.max(align),
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("animate.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("animate"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let layout = pipeline.get_bind_group_layout(0);
|
||||
let groups = keys
|
||||
.iter()
|
||||
.zip(&counts)
|
||||
.zip(offsets)
|
||||
.map(|((&(array, buffer), &count), offset)| {
|
||||
let texture = &textures[array as usize];
|
||||
let levels = (0..texture.mip_level_count())
|
||||
.map(|level| {
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
||||
base_mip_level: level,
|
||||
mip_level_count: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
layout: &layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: frames[buffer].as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &jobs,
|
||||
offset,
|
||||
size: wgpu::BufferSize::new((count * JOB_BYTES) as u64),
|
||||
}),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Group {
|
||||
offset,
|
||||
levels,
|
||||
jobs: Vec::with_capacity(count * JOB_BYTES),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
sprites,
|
||||
groups,
|
||||
jobs,
|
||||
pipeline,
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Show every sprite's frame at `tick`, rewriting only the sprites whose
|
||||
/// frame or mix changed (`SpriteContents.AnimationState.tick`).
|
||||
pub fn animate(
|
||||
&mut self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut Uploads,
|
||||
tick: u64,
|
||||
) {
|
||||
if self.collect(tick) == 0 {
|
||||
return;
|
||||
}
|
||||
for group in &self.groups {
|
||||
uploads.buffer(encoder, &self.jobs, group.offset, &group.jobs);
|
||||
}
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
for group in &mut self.groups {
|
||||
let count = (group.jobs.len() / JOB_BYTES) as u32;
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
for (level, bind) in group.levels.iter().enumerate() {
|
||||
let groups = (self.size >> level).div_ceil(WORKGROUP);
|
||||
pass.set_bind_group(0, bind, &[]);
|
||||
pass.dispatch_workgroups(groups, groups, count);
|
||||
}
|
||||
group.jobs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a job for every sprite whose blend at `tick` differs from what
|
||||
/// its layer shows; the number of jobs.
|
||||
fn collect(&mut self, tick: u64) -> usize {
|
||||
let mut count = 0;
|
||||
for sprite in &mut self.sprites {
|
||||
let blend = sprite.animation.blend(tick);
|
||||
if sprite.shown == Some(blend) {
|
||||
continue;
|
||||
}
|
||||
sprite.shown = Some(blend);
|
||||
let job = [
|
||||
sprite.layer,
|
||||
sprite.frames[blend.frame],
|
||||
sprite.frames[blend.next],
|
||||
blend.millis,
|
||||
sprite.edge,
|
||||
];
|
||||
let jobs = &mut self.groups[sprite.group].jobs;
|
||||
for word in job {
|
||||
jobs.extend_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
/// The mip chain a frame's layer is made of, and its first level's edge.
|
||||
/// `mipmap::generate` makes a sprite scaled up by whole pixels to the
|
||||
/// array's size from its own size's chain, its first level repeated into the
|
||||
/// larger levels: that chain, which the shader repeats. Any other sprite: its
|
||||
/// chain at the array's size.
|
||||
fn source_chain(pixels: &[u8], size: u32, settings: &[Settings], layer: u32) -> (Vec<u8>, u32) {
|
||||
let native = settings.get(layer as usize).map_or(0, |s| s.native_size);
|
||||
let edge = if native.is_power_of_two() && native < size {
|
||||
native
|
||||
} else {
|
||||
size
|
||||
};
|
||||
let frame = crate::sprites::resample(pixels, size, edge);
|
||||
(super::mip_chain(&frame, edge, settings, layer, 1).0, edge)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mipmap::Strategy;
|
||||
|
||||
/// `animate.wgsl` on the CPU: `chain` (first level `edge` wide) as the
|
||||
/// full chain of a `size` layer, mixed `millis` thousandths of the way
|
||||
/// towards `next`.
|
||||
fn reference(chain: &[u8], next: &[u8], millis: u32, edge: u32, size: u32) -> Vec<u8> {
|
||||
let levels = size.trailing_zeros() + 1;
|
||||
let mut out = Vec::new();
|
||||
for level in 0..levels {
|
||||
let width = size >> level;
|
||||
for y in 0..width {
|
||||
for x in 0..width {
|
||||
let texel = if width >= edge {
|
||||
let scale = width / edge;
|
||||
y / scale * edge + x / scale
|
||||
} else {
|
||||
4 * (edge * edge - width * width) / 3 + y * width + x
|
||||
} as usize;
|
||||
for c in 0..4 {
|
||||
let (a, b) = (
|
||||
u32::from(chain[texel * 4 + c]),
|
||||
u32::from(next[texel * 4 + c]),
|
||||
);
|
||||
out.push(((a * (1000 - millis) + b * millis + 500) / 1000) as u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn frame(size: u32, seed: u32) -> Vec<u8> {
|
||||
(0..size * size)
|
||||
.flat_map(|i| {
|
||||
let v = i
|
||||
.wrapping_mul(2654435761)
|
||||
.wrapping_add(seed.wrapping_mul(40503));
|
||||
let alpha = match v >> 29 {
|
||||
0 => 0,
|
||||
1 => 77,
|
||||
_ => 255,
|
||||
};
|
||||
[(v >> 3) as u8, (v >> 11) as u8, (v >> 19) as u8, alpha]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn settings(native: u32, strategy: Strategy) -> Settings {
|
||||
Settings {
|
||||
native_size: native,
|
||||
strategy,
|
||||
alpha_cutoff_bias: -0.1,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_chains_repeat_into_the_array_chain() {
|
||||
// Sprites scaled up by whole pixels, at the array's size, and of a
|
||||
// size that is not a power of two (scaled up to the next one).
|
||||
for (native, size) in [(16, 16), (16, 64), (8, 32), (32, 64), (1, 16), (24, 32)] {
|
||||
for strategy in [
|
||||
Strategy::Auto,
|
||||
Strategy::Mean,
|
||||
Strategy::Cutout,
|
||||
Strategy::StrictCutout,
|
||||
Strategy::DarkCutout,
|
||||
] {
|
||||
for item in [false, true] {
|
||||
let settings = [
|
||||
Settings::default(),
|
||||
Settings {
|
||||
item,
|
||||
..settings(native, strategy)
|
||||
},
|
||||
];
|
||||
let pixels = crate::sprites::resample(&frame(native, 3), native, size);
|
||||
let (expected, _) = super::super::mip_chain(&pixels, size, &settings, 1, 1);
|
||||
let (chain, edge) = source_chain(&pixels, size, &settings, 1);
|
||||
let fits = native.is_power_of_two() && native < size;
|
||||
assert_eq!(edge, if fits { native } else { size });
|
||||
assert_eq!(
|
||||
reference(&chain, &chain, 0, edge, size),
|
||||
expected,
|
||||
"{native} in {size}, {strategy:?}, item {item}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_animation_chains_repeat_into_the_array_chain() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root
|
||||
.join("assets/minecraft/textures/block/water_still.png")
|
||||
.exists()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut sprites = crate::sprites::Sprites::default();
|
||||
for name in [
|
||||
"water_still",
|
||||
"water_flow",
|
||||
"lava_still",
|
||||
"fire_0",
|
||||
"nether_portal",
|
||||
"sea_lantern",
|
||||
"prismarine",
|
||||
"magma",
|
||||
"kelp",
|
||||
"straw_bed",
|
||||
] {
|
||||
sprites
|
||||
.insert(&root, &format!("minecraft:block/{name}"))
|
||||
.unwrap();
|
||||
}
|
||||
sprites.finish();
|
||||
assert_eq!(sprites.size, 64);
|
||||
assert_eq!(sprites.animations.len(), 9);
|
||||
for animation in &sprites.animations {
|
||||
for frame in 0..animation.frame_count() {
|
||||
let pixels = animation.pixels(frame);
|
||||
let (expected, _) =
|
||||
super::super::mip_chain(pixels, 64, &sprites.mips, animation.layer, 1);
|
||||
let (chain, edge) = source_chain(pixels, 64, &sprites.mips, animation.layer);
|
||||
assert!(edge < 64, "layer {}", animation.layer);
|
||||
assert_eq!(reference(&chain, &chain, 0, edge, 64), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounding_matches_f32_mix_but_for_exact_halves() {
|
||||
// The CPU blend this replaced: f32 mix, then rounded. They differ
|
||||
// only where the exact mix is a half, which f32 rounded either way.
|
||||
for millis in 1..1000u32 {
|
||||
let t = millis as f32 / 1000.0;
|
||||
for a in 0..=255u32 {
|
||||
for b in (0..=255u32).step_by(3) {
|
||||
let f = ((a as f32 / 255.0) * (1.0 - t) + (b as f32 / 255.0) * t) * 255.0;
|
||||
let exact = a * (1000 - millis) + b * millis;
|
||||
let ours = (exact + 500) / 1000;
|
||||
if exact % 1000 != 500 {
|
||||
assert_eq!(ours, f.round() as u32, "{a} {b} {millis}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
/// Every layer's full mip chain, layer major.
|
||||
fn read_back(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
texture: &wgpu::Texture,
|
||||
) -> Vec<Vec<u8>> {
|
||||
let layers = texture.depth_or_array_layers();
|
||||
let mut out = vec![Vec::new(); layers as usize];
|
||||
for level in 0..texture.mip_level_count() {
|
||||
let edge = texture.width() >> level;
|
||||
let padded = (edge * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(padded * edge * layers),
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture,
|
||||
mip_level: level,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(padded),
|
||||
rows_per_image: Some(edge),
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: edge,
|
||||
height: edge,
|
||||
depth_or_array_layers: layers,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = buffer.slice(..).get_mapped_range().unwrap();
|
||||
for (layer, image) in data.chunks((padded * edge) as usize).enumerate() {
|
||||
for row in image.chunks(padded as usize) {
|
||||
out[layer].extend_from_slice(&row[..edge as usize * 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_layers_match_the_cpu_chains_every_tick() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let size = 32;
|
||||
// Layers 1 and 3: 8-pixel sprites with and without interpolation;
|
||||
// layer 4: one at the array's size. Two layers per array.
|
||||
let clips = [
|
||||
(
|
||||
1,
|
||||
8,
|
||||
r#"{"frametime":3,"interpolate":true,"frames":[0,2,1]}"#,
|
||||
),
|
||||
(3, 8, r#"{"frametime":2}"#),
|
||||
(4, 32, r#"{"frametime":5,"interpolate":true}"#),
|
||||
];
|
||||
let mut mips = vec![Settings::default(); 6];
|
||||
let animations: Vec<_> = clips
|
||||
.iter()
|
||||
.map(|&(layer, native, metadata)| {
|
||||
mips[layer as usize] = settings(native, Strategy::Cutout);
|
||||
let frames = (0..3)
|
||||
.map(|f| crate::sprites::resample(&frame(native, layer * 7 + f), native, size))
|
||||
.collect();
|
||||
crate::sprites::test_frames(layer, frames, metadata)
|
||||
})
|
||||
.collect();
|
||||
let split = 2;
|
||||
let textures: Vec<_> = (0..3)
|
||||
.map(|_| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: split,
|
||||
},
|
||||
mip_level_count: size.trailing_zeros() + 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut animator = SpriteAnimator::new(&device, &animations, size, &mips, &textures, split);
|
||||
let mut uploads = Uploads::new(&device);
|
||||
let mut writes = Vec::new();
|
||||
for tick in [0, 0, 1, 2, 3, 4, 7, 7, 12, 30] {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let shown: Vec<_> = animator.sprites.iter().map(|s| s.shown).collect();
|
||||
animator.animate(&mut encoder, &mut uploads, tick);
|
||||
writes.push(
|
||||
animator
|
||||
.sprites
|
||||
.iter()
|
||||
.zip(shown)
|
||||
.filter(|(s, shown)| s.shown != *shown)
|
||||
.count(),
|
||||
);
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
for animation in &animations {
|
||||
let blend = animation.blend(tick);
|
||||
let chain = |f| source_chain(animation.pixels(f), size, &mips, animation.layer);
|
||||
let ((current, edge), (next, _)) = (chain(blend.frame), chain(blend.next));
|
||||
let layers = read_back(
|
||||
&device,
|
||||
&queue,
|
||||
&textures[(animation.layer / split) as usize],
|
||||
);
|
||||
assert_eq!(
|
||||
layers[(animation.layer % split) as usize],
|
||||
reference(¤t, &next, blend.millis, edge, size),
|
||||
"layer {} at tick {tick}",
|
||||
animation.layer
|
||||
);
|
||||
}
|
||||
}
|
||||
// Every sprite on the first tick, then only those whose frame or mix
|
||||
// changed since they were written: the interpolated ones every tick
|
||||
// but layer 1 at tick 30 (two 9-tick loops after 12), layer 3 when
|
||||
// its frame turns over (every 2 ticks, but 12 shows 7's frame).
|
||||
assert_eq!(writes, [3, 0, 2, 3, 2, 3, 3, 0, 2, 1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Animated block sprites (gfx/animate.rs), as vanilla's animate_sprite_blit
|
||||
// and animate_sprite_interpolate shaders draw them: each job writes its
|
||||
// frame, or the frame mixed with the next one, into one mip level of its
|
||||
// sprite array layer. One invocation per texel; the job is the z coordinate.
|
||||
struct Job {
|
||||
layer: u32,
|
||||
// Word offsets of the two frames' mip chains in `frames`.
|
||||
current: u32,
|
||||
next: u32,
|
||||
// Thousandths of the way from `current` to `next` (0: `current` alone).
|
||||
millis: u32,
|
||||
// Edge of the chains' first level.
|
||||
edge: u32,
|
||||
}
|
||||
@group(0) @binding(0) var<storage, read> frames: array<u32>;
|
||||
@group(0) @binding(1) var<storage, read> jobs: array<Job>;
|
||||
@group(0) @binding(2) var level: texture_storage_2d_array<rgba8unorm, write>;
|
||||
|
||||
fn channels(rgba: u32) -> vec4<u32> {
|
||||
return vec4(rgba & 0xffu, (rgba >> 8u) & 0xffu, (rgba >> 16u) & 0xffu, rgba >> 24u);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(8, 8)
|
||||
fn animate(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let size = textureDimensions(level).x;
|
||||
if id.x >= size || id.y >= size {
|
||||
return;
|
||||
}
|
||||
let job = jobs[id.z];
|
||||
// mipmap::generate: the levels at least as large as the chain's first
|
||||
// repeat it by whole pixels, the smaller ones are the chain's own.
|
||||
var texel: u32;
|
||||
if size >= job.edge {
|
||||
let scale = size / job.edge;
|
||||
texel = id.y / scale * job.edge + id.x / scale;
|
||||
} else {
|
||||
// Every level above this one, each a quarter of the one before.
|
||||
texel = 4u * (job.edge * job.edge - size * size) / 3u + id.y * size + id.x;
|
||||
}
|
||||
var color = channels(frames[job.current + texel]);
|
||||
if job.millis > 0u {
|
||||
// mix(current, next, millis / 1000), rounded to the nearest 8-bit
|
||||
// value (halves up) in integers: the same on every GPU.
|
||||
let next = channels(frames[job.next + texel]);
|
||||
color = (color * (1000u - job.millis) + next * job.millis + 500u) / 1000u;
|
||||
}
|
||||
textureStore(level, id.xy, job.layer, vec4<f32>(color) / 255.0);
|
||||
}
|
||||
@@ -22,8 +22,16 @@
|
||||
//! Every solid draw is an entry of one of the two lists, so the CPU records
|
||||
//! as many draws as without culling: drivers that unroll an indirect
|
||||
//! multi-draw on the CPU (Mesa's on Intel Gen9) pay per entry. Phase 2's
|
||||
//! counts come from the GPU, which needs a native multi-draw and forced
|
||||
//! early depth tests: Vulkan. Translucent draws are not culled.
|
||||
//! counts come from the GPU, which reads them from the draws themselves:
|
||||
//! Vulkan's native multi-draw, and Metal's, which wgpu replays as one
|
||||
//! indirect draw per entry. Translucent draws are not culled.
|
||||
//!
|
||||
//! Metal cannot force the depth test before a fragment shader that writes
|
||||
//! storage (naga writes no `[[early_fragment_tests]]`), so there the boxes
|
||||
//! are drawn in a pass of their own between the phases, which reads phase
|
||||
//! 1's depth as a texture and makes the same comparison in the shader: the
|
||||
//! fragment's depth against the stored one, greater or equal passing. The
|
||||
//! same fragments pass, so the same sections are seen.
|
||||
use std::num::NonZeroU64;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
@@ -38,6 +46,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;
|
||||
/// On Metal, section draws 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 an M4
|
||||
/// Pro at 1080p and render distance 12 (1,000–1,600 draws) culling added
|
||||
/// about 0.05 ms of GPU time while hiding 15–30% of the solid indices; at
|
||||
/// 32 (drawn flat, over this many draws) it saved 0.25–1.6 ms.
|
||||
pub const TILED_MIN_DRAWS: usize = 4096;
|
||||
/// Boxes over more than this share of the screen count as seen untested.
|
||||
const BIG: f32 = 0.25;
|
||||
/// Results read back at once, some frames late so the CPU never waits.
|
||||
@@ -74,10 +89,40 @@ struct Group {
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
/// Where the boxes' depth test runs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DepthTest {
|
||||
/// Forced before the fragment shader, in phase 1's pass (Vulkan).
|
||||
Early,
|
||||
/// In the fragment shader, against phase 1's depth read as a texture,
|
||||
/// in a pass of its own (Metal).
|
||||
InShader,
|
||||
}
|
||||
|
||||
/// The Vulkan entry point: the depth test forced early, which naga cannot
|
||||
/// write for Metal (so it is not in `cull.wgsl`, which every backend takes).
|
||||
const EARLY_TEST: &str = "
|
||||
@fragment
|
||||
@early_depth_test(force)
|
||||
fn fs_box_early(in: Box) -> @location(0) vec4<f32> {
|
||||
see(in.test);
|
||||
return vec4(0.0);
|
||||
}
|
||||
";
|
||||
|
||||
/// The test's depth texture binding (`InShader`), for the view it was made with.
|
||||
struct DepthGroup {
|
||||
view: wgpu::TextureView,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
pub struct Cull {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
group: Option<Group>,
|
||||
depth_test: DepthTest,
|
||||
depth_layout: Option<wgpu::BindGroupLayout>,
|
||||
depth_group: Option<DepthGroup>,
|
||||
params: wgpu::Buffer,
|
||||
/// Phase 2's draws: the solid draws after phase 1's in the frame's list.
|
||||
pub phase2: wgpu::Buffer,
|
||||
@@ -103,6 +148,8 @@ pub struct Cull {
|
||||
pub hidden: ([usize; 2], f32),
|
||||
/// Cull every frame, whatever the hidden share (A/B checks).
|
||||
pub always: bool,
|
||||
/// Frames with fewer section draws than this do not cull.
|
||||
pub min_draws: usize,
|
||||
}
|
||||
|
||||
fn buffer(
|
||||
@@ -134,20 +181,33 @@ impl Cull {
|
||||
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
|
||||
adapter.features() & wgpu::Features::SHADER_EARLY_DEPTH_TEST
|
||||
}
|
||||
/// Whether the device draws an indirect multi-draw natively and runs
|
||||
/// fragment shaders that write storage only past an early depth test.
|
||||
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
adapter.get_info().backend == wgpu::Backend::Vulkan
|
||||
&& device.features().contains(
|
||||
wgpu::Features::SHADER_EARLY_DEPTH_TEST | wgpu::Features::INDIRECT_FIRST_INSTANCE,
|
||||
)
|
||||
/// Where the device can run the test, if it can: Vulkan with early
|
||||
/// depth tests, or Metal, both drawing an indirect multi-draw's entries
|
||||
/// with the instance counts the GPU wrote, with fragment shaders that
|
||||
/// write storage.
|
||||
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> Option<DepthTest> {
|
||||
let depth_test = match adapter.get_info().backend {
|
||||
wgpu::Backend::Vulkan
|
||||
if device
|
||||
.features()
|
||||
.contains(wgpu::Features::SHADER_EARLY_DEPTH_TEST) =>
|
||||
{
|
||||
DepthTest::Early
|
||||
}
|
||||
wgpu::Backend::Metal => DepthTest::InShader,
|
||||
_ => return None,
|
||||
};
|
||||
(device
|
||||
.features()
|
||||
.contains(wgpu::Features::INDIRECT_FIRST_INSTANCE)
|
||||
&& adapter.get_downlevel_capabilities().flags.contains(
|
||||
wgpu::DownlevelFlags::VERTEX_STORAGE
|
||||
| wgpu::DownlevelFlags::FRAGMENT_WRITABLE_STORAGE,
|
||||
)
|
||||
))
|
||||
.then_some(depth_test)
|
||||
}
|
||||
/// `color` is the world pass's colour format.
|
||||
pub fn new(device: &wgpu::Device, color: wgpu::TextureFormat) -> Self {
|
||||
pub fn new(device: &wgpu::Device, color: wgpu::TextureFormat, depth_test: DepthTest) -> Self {
|
||||
let entry = |binding, visibility, ty| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility,
|
||||
@@ -180,13 +240,41 @@ impl Cull {
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Section culling"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("cull.wgsl").into()),
|
||||
source: wgpu::ShaderSource::Wgsl(source(depth_test).into()),
|
||||
});
|
||||
let depth_layout = (depth_test == DepthTest::InShader).then(|| {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Section culling depth"),
|
||||
entries: &[entry(
|
||||
0,
|
||||
fragment,
|
||||
wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
)],
|
||||
})
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Section culling"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
bind_group_layouts: &[Some(&layout), depth_layout.as_ref()],
|
||||
..Default::default()
|
||||
});
|
||||
// In the shader: no colour target, and phase 1's depth attached read
|
||||
// only (a pass needs an attachment), its test left to the shader.
|
||||
let (fragment_entry, targets, depth_compare) = match depth_test {
|
||||
DepthTest::Early => (
|
||||
"fs_box_early",
|
||||
&[Some(wgpu::ColorTargetState {
|
||||
format: color,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})][..],
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
),
|
||||
DepthTest::InShader => ("fs_box", &[][..], wgpu::CompareFunction::Always),
|
||||
};
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Section culling"),
|
||||
layout: Some(&pipeline_layout),
|
||||
@@ -204,20 +292,16 @@ impl Cull {
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
depth_compare: Some(depth_compare),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs_box"),
|
||||
entry_point: Some(fragment_entry),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: color,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})],
|
||||
targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
@@ -226,6 +310,9 @@ impl Cull {
|
||||
pipeline,
|
||||
layout,
|
||||
group: None,
|
||||
depth_test,
|
||||
depth_layout,
|
||||
depth_group: None,
|
||||
params: buffer(
|
||||
device,
|
||||
"Section culling parameters",
|
||||
@@ -259,6 +346,7 @@ impl Cull {
|
||||
stamp: 0,
|
||||
hidden: ([0; 2], 1.0),
|
||||
always: false,
|
||||
min_draws: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,17 +487,69 @@ impl Cull {
|
||||
}
|
||||
}
|
||||
|
||||
/// In the world pass after phase 1: draw the tested boxes against its
|
||||
/// depth. Leaves the pass's pipeline and group 0 changed.
|
||||
pub fn depth_test(&self) -> DepthTest {
|
||||
self.depth_test
|
||||
}
|
||||
|
||||
/// In the world pass after phase 1 (`Early`): draw the tested boxes
|
||||
/// against its depth. Leaves the pass's pipeline and group 0 changed.
|
||||
pub fn test(&self, pass: &mut wgpu::RenderPass<'_>) {
|
||||
let Some(Group { group, .. }) = &self.group else {
|
||||
return;
|
||||
};
|
||||
if self.depth_test != DepthTest::Early || self.test_data.is_empty() {
|
||||
return;
|
||||
}
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.draw(0..36, 0..self.test_data.len() as u32);
|
||||
}
|
||||
|
||||
/// Before the world pass (`InShader`): the binding of the depth the
|
||||
/// world is drawn with this frame.
|
||||
pub fn bind_depth(&mut self, device: &wgpu::Device, depth: &wgpu::TextureView) {
|
||||
let Some(layout) = &self.depth_layout else {
|
||||
return;
|
||||
};
|
||||
if self.depth_group.as_ref().is_none_or(|g| g.view != *depth) {
|
||||
self.depth_group = Some(DepthGroup {
|
||||
view: depth.clone(),
|
||||
group: device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Section culling depth"),
|
||||
layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(depth),
|
||||
}],
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Between the phases (`InShader`): draw the tested boxes in a pass of
|
||||
/// their own that reads phase 1's depth, bound by `bind_depth`.
|
||||
pub fn test_pass(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let (Some(Group { group, .. }), Some(DepthGroup { view, group: depth })) =
|
||||
(&self.group, &self.depth_group)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.test_data.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Section culling"),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view,
|
||||
depth_ops: None,
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.set_bind_group(1, depth, &[]);
|
||||
pass.draw(0..36, 0..self.test_data.len() as u32);
|
||||
}
|
||||
|
||||
@@ -498,16 +638,33 @@ impl Cull {
|
||||
}
|
||||
}
|
||||
|
||||
/// The test's shader for `depth_test`.
|
||||
fn source(depth_test: DepthTest) -> String {
|
||||
let common = include_str!("cull.wgsl");
|
||||
match depth_test {
|
||||
DepthTest::Early => format!("{common}{EARLY_TEST}"),
|
||||
DepthTest::InShader => common.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shader_validates() {
|
||||
let module = naga::front::wgsl::parse_str(include_str!("cull.wgsl")).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
fn shaders_validate() {
|
||||
for (depth_test, capabilities) in [
|
||||
(DepthTest::Early, naga::valid::Capabilities::all()),
|
||||
// What wgpu's Metal backend allows: no early depth test.
|
||||
(
|
||||
DepthTest::InShader,
|
||||
naga::valid::Capabilities::all() - naga::valid::Capabilities::EARLY_DEPTH_TEST,
|
||||
),
|
||||
] {
|
||||
let module = naga::front::wgsl::parse_str(&source(depth_test)).unwrap();
|
||||
naga::valid::Validator::new(naga::valid::ValidationFlags::all(), capabilities)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{depth_test:?}: {e:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,16 +86,28 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
|
||||
return out;
|
||||
}
|
||||
|
||||
// Runs only for fragments that pass the depth test (forced early).
|
||||
@fragment
|
||||
@early_depth_test(force)
|
||||
fn fs_box(in: Box) -> @location(0) vec4<f32> {
|
||||
if results[in.test] != params.stamp {
|
||||
results[in.test] = params.stamp;
|
||||
let test = tests[in.test];
|
||||
// The box is seen at this fragment: store the stamp, and give its phase 2
|
||||
// draws their instance.
|
||||
fn see(t: u32) {
|
||||
if results[t] != params.stamp {
|
||||
results[t] = params.stamp;
|
||||
let test = tests[t];
|
||||
for (var k = 0u; k < test.z; k++) {
|
||||
phase2[(test.y + k) * 5u + 1u] = 1u;
|
||||
}
|
||||
}
|
||||
return vec4(0.0);
|
||||
}
|
||||
|
||||
// Phase 1's depth, where the test runs in the shader (gfx/cull.rs).
|
||||
@group(1) @binding(0) var depth: texture_depth_2d;
|
||||
|
||||
// The world pass's reversed depth test, greater or equal passing, made here:
|
||||
// where it cannot be forced before a shader that writes storage, a fragment
|
||||
// that fails it would still write. Where it can, `fs_box_early` (cull.rs)
|
||||
// runs only for the fragments that pass it.
|
||||
@fragment
|
||||
fn fs_box(in: Box) {
|
||||
if in.position.z >= textureLoad(depth, vec2<u32>(in.position.xy), 0) {
|
||||
see(in.test);
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,37 @@
|
||||
//! so each block names one section: its id here, which indexes the frame's
|
||||
//! camera-relative origins (computed on the CPU exactly as for the instance
|
||||
//! attribute).
|
||||
//!
|
||||
//! With occlusion culling (`cull`), phase 1's draws and the translucent ones
|
||||
//! are copied before the world pass, as without it. Phase 2's draws are
|
||||
//! copied after the test, which tells which ones it saw: each run of a page
|
||||
//! reserves room for all of its draws, the copy packs the seen ones at its
|
||||
//! start, and the run is drawn indirectly with the count the copy reached.
|
||||
use super::arena::GRAIN;
|
||||
|
||||
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
|
||||
/// `QUADS` for a solid draw), base vertex, first index in the list.
|
||||
/// `QUADS` for a solid draw; phase 2: its entry in phase 2's draws), base
|
||||
/// vertex, first index in the list (phase 2: its draw call in phase 2).
|
||||
type Range = [u32; 4];
|
||||
|
||||
/// Where a group of ranges takes its indices.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Source {
|
||||
/// Solid draws: quads.
|
||||
Quads,
|
||||
/// Occlusion culling's phase 2: quads, of the sections its test saw.
|
||||
Seen,
|
||||
/// Translucent draws: this sorted index page.
|
||||
Sorted(u32),
|
||||
}
|
||||
|
||||
/// The frame's draw calls, as `plan` lays them out.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Plan {
|
||||
phase1_calls: usize,
|
||||
solid_calls: usize,
|
||||
total: u64,
|
||||
}
|
||||
const QUADS: u32 = u32::MAX;
|
||||
const GROUP_LIMIT: u32 = 65535;
|
||||
|
||||
@@ -38,6 +64,15 @@ pub struct Flat {
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
flatten: wgpu::ComputePipeline,
|
||||
compute_layout: wgpu::BindGroupLayout,
|
||||
flatten_seen: wgpu::ComputePipeline,
|
||||
seen_layout: wgpu::BindGroupLayout,
|
||||
/// Phase 2's draw calls (`DrawIndexedIndirectArgs`), whose index counts
|
||||
/// `flatten_seen` makes.
|
||||
phase2_calls: wgpu::Buffer,
|
||||
phase2_call_capacity: u64,
|
||||
phase2_call_data: Vec<[u32; 5]>,
|
||||
/// The copy of phase 2's draws, and its ranges.
|
||||
phase2_group: Option<(wgpu::BindGroup, u32)>,
|
||||
draw_layout: wgpu::BindGroupLayout,
|
||||
blocks_per_page: u64,
|
||||
pages: Vec<Option<PageTable>>,
|
||||
@@ -52,12 +87,14 @@ pub struct Flat {
|
||||
ranges: wgpu::Buffer,
|
||||
range_capacity: u64,
|
||||
range_data: Vec<Range>,
|
||||
/// Segments of `range_data` by index source: (sorted index page or
|
||||
/// `None` for solid draws, first range, ranges).
|
||||
segments: Vec<(Option<u32>, u64, u64)>,
|
||||
/// Segments of `range_data` by index source: (source, first range,
|
||||
/// ranges).
|
||||
segments: Vec<(Source, u64, u64)>,
|
||||
empty: wgpu::Buffer,
|
||||
/// This frame's draw calls: solid ones, then translucent ones.
|
||||
/// This frame's draw calls: solid ones (phase 1's, then phase 2's when
|
||||
/// culling), then translucent ones.
|
||||
pub calls: Vec<Call>,
|
||||
pub phase1_calls: usize,
|
||||
pub solid_calls: usize,
|
||||
/// Frames with fewer section draws keep the per-section draws.
|
||||
pub min_draws: usize,
|
||||
@@ -121,42 +158,60 @@ fn buffer(
|
||||
/// Lays out the frame's index list: each draw's range (destinations in draw
|
||||
/// order) grouped by index source into `ranges`, every group padded to
|
||||
/// `align` entries for binding, with `segments` naming each group; and the
|
||||
/// draw calls, one per run of draws in the same vertex page, solid and
|
||||
/// translucent apart. Returns the first translucent call and the list's
|
||||
/// length.
|
||||
/// draw calls, one per run of draws in the same vertex page, phase 1's solid
|
||||
/// ones, phase 2's (draws `phase1_draws..solid_draws`) and translucent ones
|
||||
/// apart. A phase 2 range names its draw in phase 2 and its call in phase 2
|
||||
/// instead of a destination: the call's room holds all of its draws.
|
||||
fn plan(
|
||||
draws: &[[u32; 5]],
|
||||
pages: &[(u32, Option<u32>)],
|
||||
phase1_draws: usize,
|
||||
solid_draws: usize,
|
||||
align: u64,
|
||||
calls: &mut Vec<Call>,
|
||||
ranges: &mut Vec<Range>,
|
||||
segments: &mut Vec<(Option<u32>, u64, u64)>,
|
||||
) -> (usize, u64) {
|
||||
segments: &mut Vec<(Source, u64, u64)>,
|
||||
) -> Plan {
|
||||
calls.clear();
|
||||
ranges.clear();
|
||||
segments.clear();
|
||||
let mut solid_calls = None;
|
||||
let (mut phase1_calls, mut solid_calls) = (None, None);
|
||||
let mut total = 0u64;
|
||||
let mut by_source: Vec<(Option<u32>, Vec<Range>)> = Vec::new();
|
||||
let mut by_source: Vec<(Source, Vec<Range>)> = Vec::new();
|
||||
for (i, (d, &(page, sorted))) in draws.iter().zip(pages).enumerate() {
|
||||
let first = if sorted.is_some() { d[2] } else { QUADS };
|
||||
let range = [d[0], first, d[3], total as u32];
|
||||
match by_source.iter_mut().find(|(s, _)| *s == sorted) {
|
||||
Some((_, list)) => list.push(range),
|
||||
None => by_source.push((sorted, vec![range])),
|
||||
if i == phase1_draws {
|
||||
phase1_calls.get_or_insert(calls.len());
|
||||
}
|
||||
if i == solid_draws {
|
||||
solid_calls = Some(calls.len());
|
||||
}
|
||||
match calls.last_mut() {
|
||||
Some(last) if last.page == page && i != solid_draws => last.count += d[0],
|
||||
Some(last) if last.page == page && i != phase1_draws && i != solid_draws => {
|
||||
last.count += d[0]
|
||||
}
|
||||
_ => calls.push(Call {
|
||||
page,
|
||||
first: total as u32,
|
||||
count: d[0],
|
||||
}),
|
||||
}
|
||||
let (source, range) = match sorted {
|
||||
Some(p) => (Source::Sorted(p), [d[0], d[2], d[3], total as u32]),
|
||||
None if (phase1_draws..solid_draws).contains(&i) => (
|
||||
Source::Seen,
|
||||
[
|
||||
d[0],
|
||||
(i - phase1_draws) as u32,
|
||||
d[3],
|
||||
(calls.len() - 1 - phase1_calls.unwrap_or(0)) as u32,
|
||||
],
|
||||
),
|
||||
None => (Source::Quads, [d[0], QUADS, d[3], total as u32]),
|
||||
};
|
||||
match by_source.iter_mut().find(|(s, _)| *s == source) {
|
||||
Some((_, list)) => list.push(range),
|
||||
None => by_source.push((source, vec![range])),
|
||||
}
|
||||
total += u64::from(d[0]);
|
||||
}
|
||||
for (source, list) in by_source {
|
||||
@@ -165,7 +220,12 @@ fn plan(
|
||||
let padded = (ranges.len() as u64).next_multiple_of(align);
|
||||
ranges.resize(padded as usize, [0; 4]);
|
||||
}
|
||||
(solid_calls.unwrap_or(calls.len()), total)
|
||||
let solid_calls = solid_calls.unwrap_or(calls.len());
|
||||
Plan {
|
||||
phase1_calls: phase1_calls.unwrap_or(calls.len()).min(solid_calls),
|
||||
solid_calls,
|
||||
total,
|
||||
}
|
||||
}
|
||||
|
||||
impl Flat {
|
||||
@@ -201,24 +261,37 @@ impl Flat {
|
||||
storage(2, compute, false),
|
||||
],
|
||||
});
|
||||
let seen_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Flatten phase 2 section draws"),
|
||||
entries: &[
|
||||
storage(0, compute, true),
|
||||
storage(2, compute, false),
|
||||
storage(3, compute, true),
|
||||
storage(4, compute, false),
|
||||
],
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("flatten.wgsl").into()),
|
||||
});
|
||||
let flatten = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
layout: Some(
|
||||
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
bind_group_layouts: &[Some(&compute_layout)],
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
module: &module,
|
||||
entry_point: Some("flatten"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let compute_pipeline = |layout: &wgpu::BindGroupLayout, entry| {
|
||||
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
layout: Some(
|
||||
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
bind_group_layouts: &[Some(layout)],
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
module: &module,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let flatten = compute_pipeline(&compute_layout, "flatten");
|
||||
let flatten_seen = compute_pipeline(&seen_layout, "flatten_seen");
|
||||
let storage_usage = wgpu::BufferUsages::STORAGE;
|
||||
Self {
|
||||
solid: pipeline(&layout, "Terrain solid (flat)", "fs_solid", None),
|
||||
@@ -230,6 +303,17 @@ impl Flat {
|
||||
),
|
||||
flatten,
|
||||
compute_layout,
|
||||
flatten_seen,
|
||||
seen_layout,
|
||||
phase2_calls: buffer(
|
||||
device,
|
||||
"Flat section draw calls, phase 2",
|
||||
20,
|
||||
wgpu::BufferUsages::INDIRECT | storage_usage,
|
||||
),
|
||||
phase2_call_capacity: 1,
|
||||
phase2_call_data: Vec::new(),
|
||||
phase2_group: None,
|
||||
draw_layout,
|
||||
blocks_per_page: page_vertices.div_ceil(GRAIN),
|
||||
pages: Vec::new(),
|
||||
@@ -251,6 +335,7 @@ impl Flat {
|
||||
segments: Vec::new(),
|
||||
empty: buffer(device, "No sorted indices", 16, storage_usage),
|
||||
calls: Vec::new(),
|
||||
phase1_calls: 0,
|
||||
solid_calls: 0,
|
||||
min_draws: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
|
||||
0
|
||||
@@ -321,6 +406,9 @@ impl Flat {
|
||||
/// the origins of the sections they draw by id. Records the copy into
|
||||
/// `encoder`; `calls` then lists the frame's draw calls. False when the
|
||||
/// frame cannot be drawn flat (the per-section draws then apply).
|
||||
///
|
||||
/// When culling, the solid draws from `phase1_draws` are phase 2's, which
|
||||
/// `phase2` holds as the test leaves them: `flatten_phase2` copies them.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
@@ -329,10 +417,18 @@ impl Flat {
|
||||
uploads: &mut super::upload::Uploads,
|
||||
draws: &[[u32; 5]],
|
||||
pages: &[(u32, Option<u32>)],
|
||||
phase1_draws: usize,
|
||||
solid_draws: usize,
|
||||
phase2: Option<&wgpu::Buffer>,
|
||||
origins: impl Iterator<Item = (u32, [f32; 4])>,
|
||||
sorted_page: impl Fn(u32) -> wgpu::Buffer,
|
||||
) -> bool {
|
||||
self.phase2_group = None;
|
||||
let phase1_draws = if phase2.is_some() {
|
||||
phase1_draws
|
||||
} else {
|
||||
solid_draws
|
||||
};
|
||||
// Origins by section id.
|
||||
self.origin_data.clear();
|
||||
for (id, o) in origins {
|
||||
@@ -362,18 +458,25 @@ impl Flat {
|
||||
bytemuck::cast_slice(&self.origin_data),
|
||||
);
|
||||
}
|
||||
let (solid_calls, total) = plan(
|
||||
let Plan {
|
||||
phase1_calls,
|
||||
solid_calls,
|
||||
total,
|
||||
} = plan(
|
||||
draws,
|
||||
pages,
|
||||
phase1_draws,
|
||||
solid_draws,
|
||||
self.range_align,
|
||||
&mut self.calls,
|
||||
&mut self.range_data,
|
||||
&mut self.segments,
|
||||
);
|
||||
self.phase1_calls = phase1_calls;
|
||||
self.solid_calls = solid_calls;
|
||||
if total == 0 || total * 4 > device.limits().max_storage_buffer_binding_size {
|
||||
self.calls.clear();
|
||||
self.phase1_calls = 0;
|
||||
self.solid_calls = 0;
|
||||
return false;
|
||||
}
|
||||
@@ -401,22 +504,82 @@ impl Flat {
|
||||
0,
|
||||
bytemuck::cast_slice(&self.range_data),
|
||||
);
|
||||
// Phase 2's calls start empty, each at the start of its room.
|
||||
self.phase2_call_data.clear();
|
||||
self.phase2_call_data.extend(
|
||||
self.calls[phase1_calls..solid_calls]
|
||||
.iter()
|
||||
.map(|c| [0, 1, c.first, 0, 0]),
|
||||
);
|
||||
let phase2_calls = self.phase2_call_data.len() as u64;
|
||||
if phase2_calls > self.phase2_call_capacity {
|
||||
self.phase2_call_capacity = phase2_calls.next_power_of_two();
|
||||
self.phase2_calls = buffer(
|
||||
device,
|
||||
"Flat section draw calls, phase 2",
|
||||
self.phase2_call_capacity * 20,
|
||||
wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
}
|
||||
if phase2_calls > 0 {
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
&self.phase2_calls,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.phase2_call_data),
|
||||
);
|
||||
}
|
||||
let ranges_at = |first: u64, count: u64| {
|
||||
wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &self.ranges,
|
||||
offset: first * 16,
|
||||
size: wgpu::BufferSize::new(count * 16),
|
||||
})
|
||||
};
|
||||
if let (Some(phase2), Some(&(_, first, count))) = (
|
||||
phase2,
|
||||
self.segments.iter().find(|(s, ..)| *s == Source::Seen),
|
||||
) {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Flatten phase 2 section draws"),
|
||||
layout: &self.seen_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: ranges_at(first, count),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.indices.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: phase2.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: self.phase2_calls.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.phase2_group = Some((group, count as u32));
|
||||
}
|
||||
let groups: Vec<(wgpu::BindGroup, u32)> = self
|
||||
.segments
|
||||
.iter()
|
||||
.map(|&(source, first, count)| {
|
||||
let sorted = source.map(&sorted_page);
|
||||
.filter_map(|&(source, first, count)| {
|
||||
let sorted = match source {
|
||||
Source::Quads => None,
|
||||
Source::Seen => return None,
|
||||
Source::Sorted(page) => Some(sorted_page(page)),
|
||||
};
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
layout: &self.compute_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &self.ranges,
|
||||
offset: first * 16,
|
||||
size: wgpu::BufferSize::new(count * 16),
|
||||
}),
|
||||
resource: ranges_at(first, count),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
@@ -428,7 +591,7 @@ impl Flat {
|
||||
},
|
||||
],
|
||||
});
|
||||
(group, count as u32)
|
||||
Some((group, count as u32))
|
||||
})
|
||||
.collect();
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
@@ -464,6 +627,19 @@ impl Flat {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// After occlusion culling's test: copies the phase 2 draws it saw.
|
||||
pub fn flatten_phase2(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some((group, count)) = &self.phase2_group else {
|
||||
return;
|
||||
};
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Flatten phase 2 section draws"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&self.flatten_seen);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
/// Draws `calls` with a flat pipeline set and `vertices` giving each
|
||||
/// page's vertex buffer.
|
||||
pub fn draw<'a>(
|
||||
@@ -474,15 +650,38 @@ impl Flat {
|
||||
) {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for call in calls {
|
||||
let table = self.pages[call.page as usize]
|
||||
.as_ref()
|
||||
.and_then(|t| t.group.as_ref())
|
||||
.expect("prepared page");
|
||||
pass.set_vertex_buffer(0, vertices(call.page).slice(..));
|
||||
pass.set_bind_group(1, table, &[]);
|
||||
self.bind_page(pass, call.page, &vertices);
|
||||
pass.draw_indexed(call.first..call.first + call.count, 0, 0..1);
|
||||
}
|
||||
}
|
||||
/// Draws phase 2's calls, as `flatten_phase2` left them.
|
||||
pub fn draw_phase2<'a>(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
) {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for (k, call) in self.calls[self.phase1_calls..self.solid_calls]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
self.bind_page(pass, call.page, &vertices);
|
||||
pass.draw_indexed_indirect(&self.phase2_calls, k as u64 * 20);
|
||||
}
|
||||
}
|
||||
fn bind_page<'a>(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
page: u32,
|
||||
vertices: &impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
) {
|
||||
let table = self.pages[page as usize]
|
||||
.as_ref()
|
||||
.and_then(|t| t.group.as_ref())
|
||||
.expect("prepared page");
|
||||
pass.set_vertex_buffer(0, vertices(page).slice(..));
|
||||
pass.set_bind_group(1, table, &[]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -516,16 +715,22 @@ mod tests {
|
||||
fn plan_draws_runs_of_a_page_in_order() {
|
||||
let (draws, pages, solid) = sample();
|
||||
let (mut calls, mut ranges, mut segments) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let (solid_calls, total) = plan(
|
||||
let Plan {
|
||||
phase1_calls,
|
||||
solid_calls,
|
||||
total,
|
||||
} = plan(
|
||||
&draws,
|
||||
&pages,
|
||||
solid,
|
||||
solid,
|
||||
4,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
);
|
||||
assert_eq!(total, 6 * 13);
|
||||
assert_eq!(phase1_calls, solid_calls);
|
||||
let call = |page, first, count| Call { page, first, count };
|
||||
// Pages 0, 0 | 1 | 0, then translucent 0, 0 (never merged with solid).
|
||||
assert_eq!(
|
||||
@@ -539,16 +744,65 @@ mod tests {
|
||||
);
|
||||
assert_eq!(solid_calls, 3);
|
||||
// Solid ranges, then each sorted page's, every group padded to 4.
|
||||
assert_eq!(segments, [(None, 0, 4), (Some(0), 4, 1), (Some(1), 8, 1)]);
|
||||
assert_eq!(
|
||||
segments,
|
||||
[
|
||||
(Source::Quads, 0, 4),
|
||||
(Source::Sorted(0), 4, 1),
|
||||
(Source::Sorted(1), 8, 1)
|
||||
]
|
||||
);
|
||||
assert_eq!(ranges[3], [24, QUADS, 1024, 36]);
|
||||
assert_eq!(ranges[4], [12, 6, 768, 60]);
|
||||
assert_eq!(ranges[8], [6, 0, 40, 72]);
|
||||
assert_eq!(ranges.len(), 12);
|
||||
}
|
||||
|
||||
/// Phase 2's draws (here the third and fourth) get calls of their own,
|
||||
/// each with room for all of its draws, and ranges naming their draw
|
||||
/// and call in phase 2.
|
||||
#[test]
|
||||
fn plan_gives_phase_2_calls_of_its_own() {
|
||||
let (draws, pages, solid) = sample();
|
||||
let (mut calls, mut ranges, mut segments) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let plan = plan(
|
||||
&draws,
|
||||
&pages,
|
||||
2,
|
||||
solid,
|
||||
4,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
);
|
||||
assert_eq!(
|
||||
plan,
|
||||
Plan {
|
||||
phase1_calls: 1,
|
||||
solid_calls: 3,
|
||||
total: 6 * 13
|
||||
}
|
||||
);
|
||||
let call = |page, first, count| Call { page, first, count };
|
||||
// Phase 1: page 0 | phase 2: page 1, page 0 | translucent: page 0.
|
||||
assert_eq!(
|
||||
calls,
|
||||
[
|
||||
call(0, 0, 30),
|
||||
call(1, 30, 6),
|
||||
call(0, 36, 24),
|
||||
call(0, 60, 18)
|
||||
]
|
||||
);
|
||||
assert_eq!(segments[1], (Source::Seen, 4, 2));
|
||||
assert_eq!(ranges[4], [6, 0, 256, 0]);
|
||||
assert_eq!(ranges[5], [24, 1, 1024, 1]);
|
||||
}
|
||||
|
||||
/// The compute pass writes exactly the indices the per-section draws
|
||||
/// read: quads 0 1 2 0 2 3 for solid draws, sorted indices otherwise,
|
||||
/// plus the base vertex.
|
||||
/// plus the base vertex. With culling, phase 2's runs hold the indices
|
||||
/// of the draws the test saw, in order, and their calls count them.
|
||||
#[test]
|
||||
fn flatten_writes_the_indices_of_the_per_section_draws() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
@@ -564,101 +818,170 @@ mod tests {
|
||||
use wgpu::util::DeviceExt;
|
||||
let (draws, pages, solid) = sample();
|
||||
let align = (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16).max(1);
|
||||
let (mut calls, mut ranges, mut segments) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let (_, total) = plan(
|
||||
&draws,
|
||||
&pages,
|
||||
solid,
|
||||
align,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
);
|
||||
let sorted: [Vec<u32>; 2] = [
|
||||
(0..32).map(|i| (i * 7) % 32).collect(),
|
||||
(0..8).rev().collect(),
|
||||
];
|
||||
let mut expected = Vec::new();
|
||||
for (d, (_, page)) in draws.iter().zip(&pages) {
|
||||
for k in 0..d[0] {
|
||||
let i = match page {
|
||||
None => (k / 6) * 4 + [0, 1, 2, 0, 2, 3][(k % 6) as usize],
|
||||
Some(p) => sorted[*p as usize][(d[2] + k) as usize],
|
||||
};
|
||||
expected.push(d[3] + i);
|
||||
}
|
||||
}
|
||||
let storage = |entries: &[u32]| {
|
||||
let indices = |d: &[u32; 5], sorted_page: Option<u32>| -> Vec<u32> {
|
||||
(0..d[0])
|
||||
.map(|k| {
|
||||
d[3] + match sorted_page {
|
||||
None => (k / 6) * 4 + [0, 1, 2, 0, 2, 3][(k % 6) as usize],
|
||||
Some(p) => sorted[p as usize][(d[2] + k) as usize],
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let storage = |entries: &[u32], usage| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(entries),
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
usage: wgpu::BufferUsages::STORAGE | usage,
|
||||
})
|
||||
};
|
||||
let range_buffer = storage(bytemuck::cast_slice(&ranges));
|
||||
let sorted_buffers = [storage(&sorted[0]), storage(&sorted[1]), storage(&[0; 4])];
|
||||
let out = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: total * 4,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: total * 4,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let none = wgpu::BufferUsages::empty();
|
||||
let sorted_buffers = [
|
||||
storage(&sorted[0], none),
|
||||
storage(&sorted[1], none),
|
||||
storage(&[0; 4], none),
|
||||
];
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("flatten.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("flatten"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
pass.set_pipeline(&pipeline);
|
||||
for &(source, first, count) in &segments {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &range_buffer,
|
||||
offset: first * 16,
|
||||
size: wgpu::BufferSize::new(count * 16),
|
||||
}),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: sorted_buffers[source.map_or(2, |p| p as usize)]
|
||||
.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: out.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.dispatch_workgroups(count as u32, 1, 1);
|
||||
let pipeline = |entry| {
|
||||
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let (flatten, flatten_seen) = (pipeline("flatten"), pipeline("flatten_seen"));
|
||||
let read_back = |buffer: &wgpu::Buffer, words: u64| -> Vec<u32> {
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: words * 4,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(buffer, 0, &read, 0, words * 4);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec()
|
||||
};
|
||||
// Without culling, then with the third and fourth draws in phase 2,
|
||||
// of which the test saw only the fourth.
|
||||
for phase1 in [solid, 2] {
|
||||
let (mut calls, mut ranges, mut segments) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let plan = plan(
|
||||
&draws,
|
||||
&pages,
|
||||
phase1,
|
||||
solid,
|
||||
align,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
);
|
||||
// Phase 2's draws as the test leaves them: instance counts of 0,
|
||||
// 1 where it saw the section.
|
||||
let mut phase2: Vec<[u32; 5]> = draws[phase1..solid]
|
||||
.iter()
|
||||
.map(|&[count, _, first, base, instance]| [count, 0, first, base, instance])
|
||||
.collect();
|
||||
if let Some(last) = phase2.last_mut() {
|
||||
last[1] = 1;
|
||||
}
|
||||
let phase2_calls: Vec<[u32; 5]> = calls[plan.phase1_calls..plan.solid_calls]
|
||||
.iter()
|
||||
.map(|c| [0, 1, c.first, 0, 0])
|
||||
.collect();
|
||||
let range_buffer = storage(bytemuck::cast_slice(&ranges), none);
|
||||
let phase2_buffer = storage(
|
||||
bytemuck::cast_slice(&[phase2.as_slice(), &[[0; 5]]].concat()),
|
||||
none,
|
||||
);
|
||||
let calls_buffer = storage(
|
||||
bytemuck::cast_slice(&[phase2_calls.as_slice(), &[[0; 5]]].concat()),
|
||||
wgpu::BufferUsages::COPY_SRC,
|
||||
);
|
||||
let out = storage(&vec![0; plan.total as usize], wgpu::BufferUsages::COPY_SRC);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
for &(source, first, count) in &segments {
|
||||
let ranges = wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &range_buffer,
|
||||
offset: first * 16,
|
||||
size: wgpu::BufferSize::new(count * 16),
|
||||
});
|
||||
let entry = |binding, resource| wgpu::BindGroupEntry { binding, resource };
|
||||
let (pipeline, entries) = match source {
|
||||
Source::Seen => (
|
||||
&flatten_seen,
|
||||
vec![
|
||||
entry(0, ranges),
|
||||
entry(2, out.as_entire_binding()),
|
||||
entry(3, phase2_buffer.as_entire_binding()),
|
||||
entry(4, calls_buffer.as_entire_binding()),
|
||||
],
|
||||
),
|
||||
Source::Quads | Source::Sorted(_) => {
|
||||
let page = match source {
|
||||
Source::Sorted(p) => p as usize,
|
||||
_ => 2,
|
||||
};
|
||||
(
|
||||
&flatten,
|
||||
vec![
|
||||
entry(0, ranges),
|
||||
entry(1, sorted_buffers[page].as_entire_binding()),
|
||||
entry(2, out.as_entire_binding()),
|
||||
],
|
||||
)
|
||||
}
|
||||
};
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &entries,
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.dispatch_workgroups(count as u32, 1, 1);
|
||||
}
|
||||
}
|
||||
queue.submit([encoder.finish()]);
|
||||
let got = read_back(&out, plan.total);
|
||||
// Phase 1's and the translucent calls: each of their draws, in order.
|
||||
let expected: Vec<u32> = (0..draws.len())
|
||||
.filter(|i| !(phase1..solid).contains(i))
|
||||
.flat_map(|i| indices(&draws[i], pages[i].1))
|
||||
.collect();
|
||||
let drawn: Vec<u32> = calls
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(c, _)| !(plan.phase1_calls..plan.solid_calls).contains(c))
|
||||
.flat_map(|(_, c)| got[c.first as usize..(c.first + c.count) as usize].to_vec())
|
||||
.collect();
|
||||
assert_eq!(drawn, expected);
|
||||
// Phase 2: page 1's run drew nothing; page 0's holds the fourth
|
||||
// draw's indices.
|
||||
if phase1 < solid {
|
||||
let counts = read_back(&calls_buffer, phase2_calls.len() as u64 * 5);
|
||||
assert_eq!(counts[0], 0);
|
||||
assert_eq!(counts[5], draws[3][0]);
|
||||
let run = &calls[plan.phase1_calls + 1];
|
||||
assert_eq!(
|
||||
got[run.first as usize..(run.first + draws[3][0]) as usize],
|
||||
indices(&draws[3], None)
|
||||
);
|
||||
}
|
||||
}
|
||||
encoder.copy_buffer_to_buffer(&out, 0, &read, 0, total * 4);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let got: Vec<u32> =
|
||||
bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec();
|
||||
assert_eq!(got, expected);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@
|
||||
// translucent ones read their sorted indices.
|
||||
struct Range {
|
||||
count: u32,
|
||||
// First sorted index, or QUADS for a solid draw.
|
||||
// First sorted index, or QUADS for a solid draw (`flatten_seen`: its
|
||||
// entry in occlusion culling's phase 2 draws).
|
||||
first: u32,
|
||||
base: u32,
|
||||
// First index in the list (`flatten_seen`: its draw call).
|
||||
dst: u32,
|
||||
}
|
||||
const QUADS: u32 = 0xffffffffu;
|
||||
@@ -14,6 +16,13 @@ const GROUP: u32 = 64u;
|
||||
@group(0) @binding(0) var<storage, read> ranges: array<Range>;
|
||||
@group(0) @binding(1) var<storage, read> sorted: array<u32>;
|
||||
@group(0) @binding(2) var<storage, read_write> out: array<u32>;
|
||||
|
||||
// Index `k` of a solid draw: quad k / 6's vertices 0 1 2 0 2 3.
|
||||
fn quad(k: u32) -> u32 {
|
||||
let c = k % 6u;
|
||||
return (k / 6u) * 4u + select(select(c - 2u, 0u, c == 3u), c, c < 3u);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn flatten(@builtin(workgroup_id) group: vec3<u32>,
|
||||
@builtin(local_invocation_index) lane: u32) {
|
||||
@@ -25,11 +34,43 @@ fn flatten(@builtin(workgroup_id) group: vec3<u32>,
|
||||
for (var k = lane; k < d.count; k += GROUP) {
|
||||
var i: u32;
|
||||
if d.first == QUADS {
|
||||
let c = k % 6u;
|
||||
i = (k / 6u) * 4u + select(select(c - 2u, 0u, c == 3u), c, c < 3u);
|
||||
i = quad(k);
|
||||
} else {
|
||||
i = sorted[d.first + k];
|
||||
}
|
||||
out[d.dst + k] = d.base + i;
|
||||
}
|
||||
}
|
||||
|
||||
// Occlusion culling's phase 2 (gfx/cull.rs), after its test: the draws as
|
||||
// the test left them (`DrawIndexedIndirectArgs`, the instance count 1 where
|
||||
// it saw the section), and phase 2's draw calls, whose index counts start
|
||||
// at 0. A draw the test saw takes the next indices of its call's run.
|
||||
@group(0) @binding(3) var<storage, read> draws: array<u32>;
|
||||
@group(0) @binding(4) var<storage, read_write> calls: array<atomic<u32>>;
|
||||
const UNSEEN: u32 = 0xffffffffu;
|
||||
var<workgroup> start: u32;
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn flatten_seen(@builtin(workgroup_id) group: vec3<u32>,
|
||||
@builtin(local_invocation_index) lane: u32) {
|
||||
let r = group.y * 65535u + group.x;
|
||||
if r >= arrayLength(&ranges) {
|
||||
return;
|
||||
}
|
||||
let d = ranges[r];
|
||||
if lane == 0u {
|
||||
var s = UNSEEN;
|
||||
if draws[d.first * 5u + 1u] != 0u {
|
||||
s = atomicLoad(&calls[d.dst * 5u + 2u]) + atomicAdd(&calls[d.dst * 5u], d.count);
|
||||
}
|
||||
start = s;
|
||||
}
|
||||
let s = workgroupUniformLoad(&start);
|
||||
if s == UNSEEN {
|
||||
return;
|
||||
}
|
||||
for (var k = lane; k < d.count; k += GROUP) {
|
||||
out[s + k] = d.base + quad(k);
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,21 @@
|
||||
//! Iris's colour targets (`colortex0..`, each with a main and an alternate
|
||||
//! side) and the pack's `begin`, `prepare`, `deferred`, `composite` and
|
||||
//! `final` programs as full-screen passes over them, as Iris's
|
||||
//! `CompositeRenderer` runs them. Horizon's world enters `colortex0` and
|
||||
//! `depthtex0..2` after `prepare`, where Iris's gbuffers programs would have
|
||||
//! drawn, and the last picture goes to `world_post`. The targets keep GL's
|
||||
//! layout (row 0 at the bottom, which the translated vertex programs
|
||||
//! produce), so the world is flipped going in and the picture coming out.
|
||||
use super::pack_stages::FrameUniform;
|
||||
use crate::java_pack::{JavaFolder, JavaPasses};
|
||||
//! `CompositeRenderer` runs them. Horizon's world enters the target the
|
||||
//! pack's terrain program draws to (`colortex0` for most packs) and
|
||||
//! `depthtex0` after `prepare`, where Iris's gbuffers programs would have
|
||||
//! drawn (`depthtex1`/`depthtex2`: its depth before translucent geometry),
|
||||
//! and the last picture goes to `world_post`. The
|
||||
//! targets keep GL's layout (row 0 at the bottom, which the translated vertex
|
||||
//! programs produce), so the world is flipped going in and the picture coming
|
||||
//! out.
|
||||
use super::pack_stages::{FrameUniform, noise, rgba_view};
|
||||
use crate::java_pack::{JavaFolder, JavaPasses, NOISE_KEY, texture_key};
|
||||
use crate::shader_pack::Rgba;
|
||||
use glam::{Mat3, Mat4, Vec3, Vec4};
|
||||
use horizon_shaderpack::pipeline::TargetSettings;
|
||||
use horizon_shaderpack::pipeline::{Directives, Program, target_index};
|
||||
use horizon_shaderpack::properties::Blend;
|
||||
use horizon_shaderpack::uniforms::{CustomUniforms, SmoothFloat, UniformState};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
@@ -27,14 +32,13 @@ pub struct JavaFrame {
|
||||
pub moon_angle: f32,
|
||||
}
|
||||
|
||||
/// Iris's `noisetex` size (`noiseTextureResolution`'s default).
|
||||
const NOISE_SIZE: u32 = 256;
|
||||
/// The vertex buffers a pass may read (wgpu's default limit).
|
||||
const MAX_ATTRIBUTES: usize = 8;
|
||||
|
||||
const BLIT: &str = r#"
|
||||
@group(0) @binding(0) var color: texture_2d<f32>;
|
||||
@group(0) @binding(1) var depth: texture_depth_2d;
|
||||
@group(0) @binding(2) var opaque_depth: texture_depth_2d;
|
||||
|
||||
@vertex
|
||||
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
|
||||
@@ -45,15 +49,20 @@ fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
|
||||
struct World {
|
||||
@location(0) color: vec4f,
|
||||
@location(1) depth: vec4f,
|
||||
@location(2) opaque_depth: vec4f,
|
||||
}
|
||||
|
||||
// The world's colour and depth, upside down into GL's layout; Horizon's
|
||||
// reversed depth (near 1) as GL's (near 0).
|
||||
// The world's colour, depth and pre-translucent depth, upside down into GL's
|
||||
// layout; Horizon's reversed depth (near 1) as GL's (near 0).
|
||||
@fragment
|
||||
fn world_in(@builtin(position) p: vec4f) -> World {
|
||||
let size = textureDimensions(depth);
|
||||
let xy = vec2u(u32(p.x), size.y - 1u - u32(p.y));
|
||||
return World(textureLoad(color, xy, 0), vec4f(1.0 - textureLoad(depth, xy, 0)));
|
||||
return World(
|
||||
textureLoad(color, xy, 0),
|
||||
vec4f(1.0 - textureLoad(depth, xy, 0)),
|
||||
vec4f(1.0 - textureLoad(opaque_depth, xy, 0)),
|
||||
);
|
||||
}
|
||||
|
||||
// A target, upside down back into wgpu's layout.
|
||||
@@ -64,6 +73,42 @@ fn present(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Convert a GL `Blend` override from `shaders.properties` into a wgpu `BlendState`.
|
||||
fn blend_state(blend: &Blend) -> Option<wgpu::BlendState> {
|
||||
let Blend::Factors(factors) = blend else {
|
||||
return None;
|
||||
};
|
||||
let map_factor = |name: &str| -> wgpu::BlendFactor {
|
||||
use wgpu::BlendFactor as B;
|
||||
match name {
|
||||
"ZERO" => B::Zero,
|
||||
"ONE" => B::One,
|
||||
"SRC_COLOR" => B::Src,
|
||||
"ONE_MINUS_SRC_COLOR" => B::OneMinusSrc,
|
||||
"DST_COLOR" => B::Dst,
|
||||
"ONE_MINUS_DST_COLOR" => B::OneMinusDst,
|
||||
"SRC_ALPHA" => B::SrcAlpha,
|
||||
"ONE_MINUS_SRC_ALPHA" => B::OneMinusSrcAlpha,
|
||||
"DST_ALPHA" => B::DstAlpha,
|
||||
"ONE_MINUS_DST_ALPHA" => B::OneMinusDstAlpha,
|
||||
"SRC_ALPHA_SATURATE" => B::SrcAlphaSaturated,
|
||||
_ => B::One,
|
||||
}
|
||||
};
|
||||
Some(wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: map_factor(&factors[0]),
|
||||
dst_factor: map_factor(&factors[1]),
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: map_factor(&factors[2]),
|
||||
dst_factor: map_factor(&factors[3]),
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// A wgpu format for each of Iris's (`InternalTextureFormat`), renderable
|
||||
/// everywhere: 8-bit unsigned normalized formats stay, signed normalized,
|
||||
/// 16-bit and packed float ones become half floats, integer ones 32-bit.
|
||||
@@ -123,8 +168,10 @@ struct Target {
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Source {
|
||||
Target(u32),
|
||||
/// `depthtex0..2`: the world's depth.
|
||||
/// `depthtex0` / `gdepthtex`: the world's depth (including translucent water).
|
||||
Depth,
|
||||
/// `depthtex1` / `depthtex2`: the opaque world's depth (before translucent water).
|
||||
OpaqueDepth,
|
||||
Noise,
|
||||
/// `shadowtex*`: no shadow map yet, so a far depth (nothing in shadow);
|
||||
/// also Distant Horizons' and Voxy's depth, as without those mods.
|
||||
@@ -135,7 +182,8 @@ enum Source {
|
||||
|
||||
fn source(name: &str) -> Source {
|
||||
match name {
|
||||
"depthtex0" | "depthtex1" | "depthtex2" | "gdepthtex" => Source::Depth,
|
||||
"depthtex0" | "gdepthtex" => Source::Depth,
|
||||
"depthtex1" | "depthtex2" => Source::OpaqueDepth,
|
||||
"noisetex" => Source::Noise,
|
||||
"shadowtex0" | "shadowtex1" | "shadow" | "watershadow" => Source::Shadow,
|
||||
n if n.starts_with("dhDepthTex") || n.starts_with("vxDepthTex") => Source::Shadow,
|
||||
@@ -146,6 +194,7 @@ fn source(name: &str) -> Source {
|
||||
|
||||
/// A sampler of a pass: its texture and sampler bindings.
|
||||
struct SamplerBinding {
|
||||
name: String,
|
||||
source: Source,
|
||||
dimension: wgpu::TextureViewDimension,
|
||||
/// `isampler` (true) or `usampler` (false).
|
||||
@@ -183,6 +232,7 @@ struct Common {
|
||||
repeat: wgpu::Sampler,
|
||||
compare: wgpu::Sampler,
|
||||
noise: wgpu::TextureView,
|
||||
custom_textures: BTreeMap<String, wgpu::TextureView>,
|
||||
/// Full-screen triangle data per attribute kind (see `PassGpu::attributes`).
|
||||
vertices: [wgpu::Buffer; 4],
|
||||
world_layout: wgpu::BindGroupLayout,
|
||||
@@ -194,12 +244,16 @@ struct Common {
|
||||
struct Bound {
|
||||
folder: usize,
|
||||
size: [u32; 2],
|
||||
/// `world_post`'s targets it binds (`Post::generation`).
|
||||
post_generation: u64,
|
||||
targets: BTreeMap<u32, Target>,
|
||||
depth: wgpu::TextureView,
|
||||
opaque_depth: wgpu::TextureView,
|
||||
/// Per pass, then `final`.
|
||||
groups: Vec<wgpu::BindGroup>,
|
||||
world_in: (wgpu::RenderPipeline, wgpu::BindGroup),
|
||||
/// The world goes to colortex0's alternate side.
|
||||
/// The world's target, and whether the world goes to its alternate side.
|
||||
world_target: u32,
|
||||
world_alt: bool,
|
||||
final_target: Option<wgpu::TextureView>,
|
||||
present: (wgpu::RenderPipeline, wgpu::BindGroup),
|
||||
@@ -212,6 +266,7 @@ struct Bound {
|
||||
struct Folder {
|
||||
targets: Vec<TargetSettings>,
|
||||
world_at: usize,
|
||||
world_target: u32,
|
||||
flipped_after_prepare: BTreeSet<u32>,
|
||||
flipped_at_end: BTreeSet<u32>,
|
||||
passes: Vec<PassGpu>,
|
||||
@@ -221,6 +276,9 @@ struct Folder {
|
||||
|
||||
pub struct JavaStages {
|
||||
pub id: String,
|
||||
/// A pass samples `depthtex1`/`depthtex2`: the world's depth is kept
|
||||
/// before translucent geometry draws.
|
||||
pub reads_opaque_depth: bool,
|
||||
format: wgpu::TextureFormat,
|
||||
common: Common,
|
||||
folders: Vec<Folder>,
|
||||
@@ -234,6 +292,9 @@ pub struct JavaStages {
|
||||
uniform_state: UniformState,
|
||||
/// `eyeBrightnessSmooth`, block then sky.
|
||||
eye_smooth: [SmoothFloat; 2],
|
||||
/// `wetness`: `rainStrength` smoothed with `wetnessHalflife` going up
|
||||
/// and `drynessHalflife` going down.
|
||||
wetness_smooth: SmoothFloat,
|
||||
}
|
||||
|
||||
impl JavaStages {
|
||||
@@ -245,7 +306,7 @@ impl JavaStages {
|
||||
id: &str,
|
||||
pack: &JavaPasses,
|
||||
) -> Result<Self, String> {
|
||||
let common = Common::new(device, queue);
|
||||
let common = Common::new(device, queue, &pack.custom_textures);
|
||||
let folder_for = [-1, 0, 1].map(|d| pack.folder_for(d));
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let folders = pack
|
||||
@@ -270,6 +331,7 @@ impl JavaStages {
|
||||
Ok::<_, String>(Folder {
|
||||
targets: plan.targets.clone(),
|
||||
world_at: plan.world_at,
|
||||
world_target: plan.world_target,
|
||||
flipped_after_prepare: plan.flipped_after_prepare.clone(),
|
||||
flipped_at_end: plan.flipped_at_end.clone(),
|
||||
passes,
|
||||
@@ -281,8 +343,14 @@ impl JavaStages {
|
||||
if let Some(error) = pollster::block_on(scope.pop()) {
|
||||
return Err(error.to_string());
|
||||
}
|
||||
let reads_opaque_depth = folders
|
||||
.iter()
|
||||
.flat_map(|f| f.passes.iter().chain(&f.final_pass))
|
||||
.flat_map(|p| &p.samplers)
|
||||
.any(|s| s.source == Source::OpaqueDepth);
|
||||
Ok(Self {
|
||||
id: id.to_owned(),
|
||||
reads_opaque_depth,
|
||||
format,
|
||||
common,
|
||||
folders,
|
||||
@@ -295,6 +363,7 @@ impl JavaStages {
|
||||
uniforms: pack.uniforms.clone(),
|
||||
uniform_state: UniformState::default(),
|
||||
eye_smooth: [SmoothFloat::default(); 2],
|
||||
wetness_smooth: SmoothFloat::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -315,11 +384,9 @@ impl JavaStages {
|
||||
) -> &wgpu::BindGroup {
|
||||
let dimension = (frame.dimension[0] as i32).clamp(-1, 1);
|
||||
let folder = self.folder_for[(dimension + 1) as usize].unwrap_or(0);
|
||||
if self
|
||||
.bound
|
||||
.as_ref()
|
||||
.is_none_or(|b| b.folder != folder || b.size != size)
|
||||
{
|
||||
if self.bound.as_ref().is_none_or(|b| {
|
||||
b.folder != folder || b.size != size || b.post_generation != post.generation()
|
||||
}) {
|
||||
self.bound = Some(self.bind(device, queue, folder, color, depth, size, post));
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
@@ -339,6 +406,12 @@ impl JavaStages {
|
||||
for (i, smooth) in self.eye_smooth.iter_mut().enumerate() {
|
||||
inputs.eye_smooth[i] = smooth.update(inputs.eye[i], half_life, half_life, frame_time);
|
||||
}
|
||||
inputs.wetness = self.wetness_smooth.update(
|
||||
frame.weather[0],
|
||||
directives.wetness_half_life,
|
||||
directives.dryness_half_life,
|
||||
frame_time,
|
||||
);
|
||||
self.uniforms
|
||||
.update(&mut self.uniform_state, frame_time, &|name| {
|
||||
inputs.builtin(name)
|
||||
@@ -417,8 +490,9 @@ impl JavaStages {
|
||||
let folder = &self.folders[folder_index];
|
||||
let plan = folder;
|
||||
let all = folder.passes.iter().chain(&folder.final_pass);
|
||||
// Every target a pass reads or writes, and colortex0 (the world).
|
||||
let mut used: BTreeSet<u32> = [0].into();
|
||||
// Every target a pass reads or writes, colortex0 (shown without
|
||||
// `final`) and the world's.
|
||||
let mut used: BTreeSet<u32> = [0, folder.world_target].into();
|
||||
for pass in all.clone() {
|
||||
used.extend(pass.draw_buffers.iter().copied());
|
||||
used.extend(pass.samplers.iter().filter_map(|s| match s.source {
|
||||
@@ -496,20 +570,41 @@ impl JavaStages {
|
||||
size,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let opaque_depth = texture(
|
||||
"Java shader pack opaque depth",
|
||||
wgpu::TextureFormat::R32Float,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
size,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let mut blank = Vec::new();
|
||||
let groups = all
|
||||
.clone()
|
||||
.map(|pass| pass.bind(device, queue, &targets, &depth, &self.common, &mut blank))
|
||||
.map(|pass| {
|
||||
pass.bind(
|
||||
device,
|
||||
queue,
|
||||
&targets,
|
||||
&depth,
|
||||
&opaque_depth,
|
||||
&self.common,
|
||||
&mut blank,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// The world goes into colortex0's side the gbuffers would write.
|
||||
let colortex0 = &targets[&0];
|
||||
// The world goes into its target's side the gbuffers would write.
|
||||
let world_target = &targets[&folder.world_target];
|
||||
let world_pipeline = blit_pipeline(
|
||||
device,
|
||||
&self.common,
|
||||
&self.common.world_layout,
|
||||
"world_in",
|
||||
&[colortex0.format, wgpu::TextureFormat::R32Float],
|
||||
&[
|
||||
world_target.format,
|
||||
wgpu::TextureFormat::R32Float,
|
||||
wgpu::TextureFormat::R32Float,
|
||||
],
|
||||
);
|
||||
let world_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Java shader pack world"),
|
||||
@@ -523,6 +618,10 @@ impl JavaStages {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(world_depth),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(post.opaque_depth()),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -536,6 +635,7 @@ impl JavaStages {
|
||||
.create_view(&Default::default())
|
||||
});
|
||||
// Without `final`, Iris shows colortex0 as the last pass left it.
|
||||
let colortex0 = &targets[&0];
|
||||
let shown = final_target.clone().unwrap_or_else(|| {
|
||||
if plan.flipped_at_end.contains(&0) {
|
||||
colortex0.alt_view.clone()
|
||||
@@ -569,11 +669,14 @@ impl JavaStages {
|
||||
Bound {
|
||||
folder: folder_index,
|
||||
size,
|
||||
post_generation: post.generation(),
|
||||
targets,
|
||||
depth,
|
||||
opaque_depth,
|
||||
groups,
|
||||
world_in: (world_pipeline, world_group),
|
||||
world_alt: plan.flipped_after_prepare.contains(&0),
|
||||
world_target: folder.world_target,
|
||||
world_alt: plan.flipped_after_prepare.contains(&folder.world_target),
|
||||
final_target,
|
||||
present: (present_pipeline, present_group),
|
||||
output_view,
|
||||
@@ -584,15 +687,16 @@ impl JavaStages {
|
||||
}
|
||||
|
||||
fn world_in(encoder: &mut wgpu::CommandEncoder, bound: &Bound) {
|
||||
let colortex0 = &bound.targets[&0];
|
||||
let target = &bound.targets[&bound.world_target];
|
||||
let side = if bound.world_alt {
|
||||
&colortex0.alt_view
|
||||
&target.alt_view
|
||||
} else {
|
||||
&colortex0.main_view
|
||||
&target.main_view
|
||||
};
|
||||
let attachments = [
|
||||
Some(color_attachment(side, None)),
|
||||
Some(color_attachment(&bound.depth, None)),
|
||||
Some(color_attachment(&bound.opaque_depth, None)),
|
||||
];
|
||||
let mut render = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Java shader pack world"),
|
||||
@@ -666,7 +770,8 @@ fn blit_pipeline(
|
||||
}
|
||||
|
||||
impl Common {
|
||||
fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
|
||||
/// `custom` holds the pack's `texture.*` images (`JavaPasses::custom_textures`).
|
||||
fn new(device: &wgpu::Device, queue: &wgpu::Queue, custom: &BTreeMap<String, Rgba>) -> Self {
|
||||
let sampler = |filter, address, compare| {
|
||||
device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Java shader pack"),
|
||||
@@ -680,40 +785,15 @@ impl Common {
|
||||
})
|
||||
};
|
||||
use wgpu::{AddressMode::*, FilterMode::*};
|
||||
// Iris's noise: random RGB, opaque.
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
let noise_bytes: Vec<u8> = (0..NOISE_SIZE * NOISE_SIZE * 4)
|
||||
.map(|i| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
if i % 4 == 3 { 255 } else { (seed >> 24) as u8 }
|
||||
})
|
||||
let noise = match custom.get(NOISE_KEY) {
|
||||
Some(image) => rgba_view(device, queue, "noisetex", image),
|
||||
None => rgba_view(device, queue, "noisetex", &noise()),
|
||||
};
|
||||
let custom_textures = custom
|
||||
.iter()
|
||||
.filter(|(key, _)| *key != NOISE_KEY)
|
||||
.map(|(key, image)| (key.clone(), rgba_view(device, queue, key, image)))
|
||||
.collect();
|
||||
let noise = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("noisetex"),
|
||||
size: wgpu::Extent3d {
|
||||
width: NOISE_SIZE,
|
||||
height: NOISE_SIZE,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
noise.as_image_copy(),
|
||||
&noise_bytes,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(NOISE_SIZE * 4),
|
||||
rows_per_image: None,
|
||||
},
|
||||
noise.size(),
|
||||
);
|
||||
// The full-screen triangle (0, 0), (2, 0), (0, 2): the pack's
|
||||
// projection maps 0..1 to the screen. Texture coordinates are the
|
||||
// position; colours white; normals up.
|
||||
@@ -753,13 +833,15 @@ impl Common {
|
||||
nearest: sampler(Nearest, ClampToEdge, None),
|
||||
repeat: sampler(Linear, Repeat, None),
|
||||
compare: sampler(Linear, ClampToEdge, Some(wgpu::CompareFunction::LessEqual)),
|
||||
noise: noise.create_view(&Default::default()),
|
||||
noise,
|
||||
custom_textures,
|
||||
vertices,
|
||||
world_layout: device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Java shader pack world"),
|
||||
entries: &[
|
||||
texture_entry(0, unfilterable),
|
||||
texture_entry(1, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(2, wgpu::TextureSampleType::Depth),
|
||||
],
|
||||
}),
|
||||
present_layout: device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -841,6 +923,7 @@ impl PassGpu {
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("{sampler}_s"));
|
||||
samplers.push(SamplerBinding {
|
||||
name: sampler.clone(),
|
||||
source: source(sampler),
|
||||
dimension,
|
||||
signed,
|
||||
@@ -885,7 +968,7 @@ impl PassGpu {
|
||||
)),
|
||||
)
|
||||
}
|
||||
(false, Source::Depth | Source::Shadow) => (
|
||||
(false, Source::Depth | Source::OpaqueDepth | Source::Shadow) => (
|
||||
wgpu::TextureSampleType::Float { filterable: false },
|
||||
Some(false),
|
||||
),
|
||||
@@ -979,10 +1062,11 @@ impl PassGpu {
|
||||
|
||||
// Output location `l` writes target `draw_buffers[l]` (or the screen).
|
||||
let outputs = &program.fragment.interface.outputs;
|
||||
let pass_blend = program.pass.blend.as_ref().and_then(blend_state);
|
||||
let target_states: Vec<Option<wgpu::ColorTargetState>> = match screen {
|
||||
Some(format) => vec![Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
blend: pass_blend,
|
||||
write_mask: if outputs.contains_key(&0) {
|
||||
wgpu::ColorWrites::ALL
|
||||
} else {
|
||||
@@ -994,12 +1078,25 @@ impl PassGpu {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(l, &i)| {
|
||||
let format = formats
|
||||
.get(i as usize)
|
||||
.copied()
|
||||
.unwrap_or(wgpu::TextureFormat::Rgba8Unorm);
|
||||
// Only filterable float/unorm formats support blending.
|
||||
let can_blend = matches!(
|
||||
sample_type(format),
|
||||
wgpu::TextureSampleType::Float { filterable: true }
|
||||
);
|
||||
let buf_blend = program
|
||||
.pass
|
||||
.buffer_blends
|
||||
.iter()
|
||||
.find(|(idx, _)| *idx == i)
|
||||
.map(|(_, b)| blend_state(b))
|
||||
.unwrap_or(pass_blend);
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: formats
|
||||
.get(i as usize)
|
||||
.copied()
|
||||
.unwrap_or(wgpu::TextureFormat::Rgba8Unorm),
|
||||
blend: None,
|
||||
format,
|
||||
blend: if can_blend { buf_blend } else { None },
|
||||
write_mask: if outputs.contains_key(&(l as u32)) {
|
||||
wgpu::ColorWrites::ALL
|
||||
} else {
|
||||
@@ -1068,37 +1165,55 @@ impl PassGpu {
|
||||
}
|
||||
|
||||
/// The pass's bind group over `targets`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn bind(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
targets: &BTreeMap<u32, Target>,
|
||||
depth: &wgpu::TextureView,
|
||||
opaque_depth: &wgpu::TextureView,
|
||||
common: &Common,
|
||||
blank: &mut Vec<wgpu::Texture>,
|
||||
) -> wgpu::BindGroup {
|
||||
let mut views = Vec::new();
|
||||
for s in &self.samplers {
|
||||
let view = match (s.compare, s.source) {
|
||||
(true, _) => blank_view(device, queue, blank, s, BlankKind::FarDepth),
|
||||
(false, Source::Target(i)) if s.dimension == wgpu::TextureViewDimension::D2 => {
|
||||
match targets.get(&i) {
|
||||
Some(t) if self.reads_alt.contains(&i) => t.alt_view.clone(),
|
||||
Some(t) => t.main_view.clone(),
|
||||
None => blank_view(device, queue, blank, s, BlankKind::Color(false)),
|
||||
let custom = (!s.compare && s.dimension == wgpu::TextureViewDimension::D2)
|
||||
.then(|| {
|
||||
common
|
||||
.custom_textures
|
||||
.get(&texture_key(&self.program, &s.name))
|
||||
})
|
||||
.flatten();
|
||||
let view = if let Some(custom) = custom {
|
||||
custom.clone()
|
||||
} else {
|
||||
match (s.compare, s.source) {
|
||||
(true, _) => blank_view(device, queue, blank, s, BlankKind::FarDepth),
|
||||
(false, Source::Target(i)) if s.dimension == wgpu::TextureViewDimension::D2 => {
|
||||
match targets.get(&i) {
|
||||
Some(t) if self.reads_alt.contains(&i) => t.alt_view.clone(),
|
||||
Some(t) => t.main_view.clone(),
|
||||
None => blank_view(device, queue, blank, s, BlankKind::Color(false)),
|
||||
}
|
||||
}
|
||||
(false, Source::Depth) if s.dimension == wgpu::TextureViewDimension::D2 => {
|
||||
depth.clone()
|
||||
}
|
||||
(false, Source::OpaqueDepth)
|
||||
if s.dimension == wgpu::TextureViewDimension::D2 =>
|
||||
{
|
||||
opaque_depth.clone()
|
||||
}
|
||||
(false, Source::Shadow) => blank_view(device, queue, blank, s, BlankKind::Far),
|
||||
(false, Source::Noise) if s.dimension == wgpu::TextureViewDimension::D2 => {
|
||||
common.noise.clone()
|
||||
}
|
||||
(false, Source::Blank(white)) => {
|
||||
blank_view(device, queue, blank, s, BlankKind::Color(white))
|
||||
}
|
||||
_ => blank_view(device, queue, blank, s, BlankKind::Color(false)),
|
||||
}
|
||||
(false, Source::Depth) if s.dimension == wgpu::TextureViewDimension::D2 => {
|
||||
depth.clone()
|
||||
}
|
||||
(false, Source::Shadow) => blank_view(device, queue, blank, s, BlankKind::Far),
|
||||
(false, Source::Noise) if s.dimension == wgpu::TextureViewDimension::D2 => {
|
||||
common.noise.clone()
|
||||
}
|
||||
(false, Source::Blank(white)) => {
|
||||
blank_view(device, queue, blank, s, BlankKind::Color(white))
|
||||
}
|
||||
_ => blank_view(device, queue, blank, s, BlankKind::Color(false)),
|
||||
};
|
||||
let sampler = match (s.compare, s.source) {
|
||||
_ if s.shared.is_some() => match s.shared.as_deref() {
|
||||
@@ -1110,7 +1225,7 @@ impl PassGpu {
|
||||
(true, _) => &common.compare,
|
||||
_ if s.signed.is_some() => &common.nearest,
|
||||
(false, Source::Noise) => &common.repeat,
|
||||
(false, Source::Depth | Source::Shadow) => &common.nearest,
|
||||
(false, Source::Depth | Source::OpaqueDepth | Source::Shadow) => &common.nearest,
|
||||
(false, Source::Target(i)) => {
|
||||
match targets.get(&i).map(|t| sample_type(t.format)) {
|
||||
Some(wgpu::TextureSampleType::Float { filterable: true }) | None => {
|
||||
@@ -1442,6 +1557,8 @@ struct Inputs {
|
||||
/// `eyeBrightness` and `eyeBrightnessSmooth`: block, sky light × 16.
|
||||
eye: [f32; 2],
|
||||
eye_smooth: [f32; 2],
|
||||
/// `wetness` (`JavaStages::wetness_smooth`).
|
||||
wetness: f32,
|
||||
/// `CelestialUniforms`, in view space.
|
||||
sun_position: Vec3,
|
||||
moon_position: Vec3,
|
||||
@@ -1499,6 +1616,7 @@ impl Inputs {
|
||||
frame: *frame,
|
||||
eye: [0.0; 2],
|
||||
eye_smooth: [0.0; 2],
|
||||
wetness: frame.weather[0],
|
||||
sun_position: Vec3::ZERO,
|
||||
moon_position: Vec3::ZERO,
|
||||
up_position: Vec3::ZERO,
|
||||
@@ -1591,7 +1709,8 @@ impl Inputs {
|
||||
}),
|
||||
"upPosition" => vec3(self.up_position),
|
||||
"moonPhase" => vec![f.moon[3]],
|
||||
"rainStrength" | "wetness" => vec![f.weather[0]],
|
||||
"rainStrength" => vec![f.weather[0]],
|
||||
"wetness" => vec![self.wetness],
|
||||
"thunderStrength" => vec![f.weather[1]],
|
||||
"fogColor" => f.fog_color[..3].to_vec(),
|
||||
"skyColor" => f.sky_color[..3].to_vec(),
|
||||
@@ -1651,6 +1770,7 @@ mod tests {
|
||||
assert_eq!(source("colortex4"), Source::Target(4));
|
||||
assert_eq!(source("gaux1"), Source::Target(4));
|
||||
assert_eq!(source("gdepthtex"), Source::Depth);
|
||||
assert_eq!(source("depthtex1"), Source::OpaqueDepth);
|
||||
assert_eq!(source("shadowtex1"), Source::Shadow);
|
||||
assert_eq!(source("dhDepthTex0"), Source::Shadow);
|
||||
assert_eq!(source("shadowcolor0"), Source::Blank(true));
|
||||
|
||||
@@ -119,10 +119,6 @@ impl Tile {
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
pub fn resident_lods(&self) -> usize {
|
||||
self.lods.len()
|
||||
}
|
||||
|
||||
pub fn upload_lod(&mut self, key: TileKey, mesh: &SectionMesh) {
|
||||
self.remove_lod(key);
|
||||
if let Some(bounds) = Bounds::mesh(mesh)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! A Horizon shader pack on the GPU (docs/shader-packs.md): each `composite`
|
||||
//! stage is one full-screen pass that reads the world's colour and depth and
|
||||
//! the previous stage's output and writes its own target; the last target
|
||||
//! goes to the screen through `world_post` (copy or FXAA) under the hand and
|
||||
//! HUD. Only exists while a pack is selected.
|
||||
use crate::shader_pack::{ShaderPack, VERTEX_ENTRY};
|
||||
//! A Horizon shader pack on the GPU (docs/shader-packs.md): each stage
|
||||
//! (`shadow`, `gbuffer`, `deferred`, `composite`, `final`) is a full-screen
|
||||
//! pass that reads the world's colour, depth, opaque depth, G-buffer normals
|
||||
//! and materials, shadow map, noise texture and the previous stage's output,
|
||||
//! and writes its own target; the last target goes to the screen through
|
||||
//! `world_post` (copy or FXAA) under the hand and HUD. Only exists while a
|
||||
//! pack is selected.
|
||||
use crate::shader_pack::{Rgba, ShaderPack, VERTEX_ENTRY};
|
||||
use horizon_shaderpack::uniforms::SmoothFloat;
|
||||
|
||||
/// The `Frame` uniform of the prelude (`shader_pack_prelude.wgsl`).
|
||||
#[repr(C)]
|
||||
@@ -27,10 +30,81 @@ pub struct FrameUniform {
|
||||
pub dimension: [f32; 4],
|
||||
}
|
||||
|
||||
/// The API 2 `FrameExt` uniform of the prelude (`shader_pack_prelude.wgsl`).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct FrameExtUniform {
|
||||
pub previous_view: [[f32; 4]; 4],
|
||||
pub previous_projection: [[f32; 4]; 4],
|
||||
pub shadow_view: [[f32; 4]; 4],
|
||||
pub shadow_projection: [[f32; 4]; 4],
|
||||
pub shadow_inverse_view: [[f32; 4]; 4],
|
||||
pub shadow_inverse_projection: [[f32; 4]; 4],
|
||||
pub previous_camera: [f32; 4],
|
||||
pub shadow_params: [f32; 4],
|
||||
pub environment: [f32; 4],
|
||||
}
|
||||
|
||||
/// Iris's `noisetex` without a `texture.noise`: 256² random RGB, opaque.
|
||||
/// Horizon packs get the same one unless `[textures] noise` replaces it.
|
||||
pub(super) fn noise() -> Rgba {
|
||||
const SIZE: u32 = 256;
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
let pixels = (0..SIZE * SIZE * 4)
|
||||
.map(|i| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
if i % 4 == 3 { 255 } else { (seed >> 24) as u8 }
|
||||
})
|
||||
.collect();
|
||||
Rgba {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
pixels,
|
||||
}
|
||||
}
|
||||
|
||||
/// `image` as a sampled RGBA8 texture.
|
||||
pub(super) fn rgba_view(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
label: &str,
|
||||
image: &Rgba,
|
||||
) -> wgpu::TextureView {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
texture.as_image_copy(),
|
||||
&image.pixels,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(image.width * 4),
|
||||
rows_per_image: None,
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
texture.create_view(&Default::default())
|
||||
}
|
||||
|
||||
struct Pass {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
scale: f32,
|
||||
target_format: wgpu::TextureFormat,
|
||||
uniform: wgpu::Buffer,
|
||||
uniform_ext: wgpu::Buffer,
|
||||
}
|
||||
/// A stage's output and the bindings that read the world into it.
|
||||
struct Target {
|
||||
@@ -41,24 +115,47 @@ struct Target {
|
||||
|
||||
pub struct PackStages {
|
||||
pub id: String,
|
||||
pub needs_shadow: bool,
|
||||
/// The stages read the G-buffer (`build_gbuffer`) or the depth before
|
||||
/// translucent geometry (`capture_opaque_depth`).
|
||||
pub needs_gbuffer: bool,
|
||||
pub needs_opaque_depth: bool,
|
||||
pub shadow_res: u32,
|
||||
pub shadow_dist: f32,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
linear: wgpu::Sampler,
|
||||
nearest: wgpu::Sampler,
|
||||
repeat: wgpu::Sampler,
|
||||
shadow_sampler: wgpu::Sampler,
|
||||
noise_view: wgpu::TextureView,
|
||||
passes: Vec<Pass>,
|
||||
format: wgpu::TextureFormat,
|
||||
/// Built for this world target size.
|
||||
/// Built for this world target size and `world_post` generation.
|
||||
targets: Vec<Target>,
|
||||
/// `world_post`'s bindings of the last stage's output.
|
||||
output: Option<wgpu::BindGroup>,
|
||||
world_size: [u32; 2],
|
||||
post_generation: u64,
|
||||
started: std::time::Instant,
|
||||
last: Option<std::time::Instant>,
|
||||
frames: u32,
|
||||
previous: Option<([[f32; 4]; 4], [[f32; 4]; 4], [f32; 3])>,
|
||||
wetness: SmoothFloat,
|
||||
}
|
||||
|
||||
fn stage_format(name: Option<&str>, default: wgpu::TextureFormat) -> wgpu::TextureFormat {
|
||||
match name.map(str::to_lowercase).as_deref() {
|
||||
Some("rgba16float" | "rgba16f") => wgpu::TextureFormat::Rgba16Float,
|
||||
Some("rgba8unorm" | "rgba8" | "rgba") => wgpu::TextureFormat::Rgba8Unorm,
|
||||
Some("rgb10a2unorm" | "rgb10_a2") => wgpu::TextureFormat::Rgb10a2Unorm,
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
|
||||
impl PackStages {
|
||||
/// The pipelines of a loaded (already validated) pack, or wgpu's error.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
format: wgpu::TextureFormat,
|
||||
pack: &ShaderPack,
|
||||
) -> Result<Self, String> {
|
||||
@@ -79,6 +176,16 @@ impl PackStages {
|
||||
ty: wgpu::BindingType::Sampler(ty),
|
||||
count: None,
|
||||
};
|
||||
let uniform_entry = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Shader pack stage"),
|
||||
entries: &[
|
||||
@@ -87,16 +194,15 @@ impl PackStages {
|
||||
texture(2, float),
|
||||
sampler(3, wgpu::SamplerBindingType::Filtering),
|
||||
sampler(4, wgpu::SamplerBindingType::NonFiltering),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 5,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
uniform_entry(5),
|
||||
uniform_entry(6),
|
||||
texture(7, float),
|
||||
texture(8, float),
|
||||
texture(9, wgpu::TextureSampleType::Depth),
|
||||
texture(10, wgpu::TextureSampleType::Depth),
|
||||
sampler(11, wgpu::SamplerBindingType::Comparison),
|
||||
texture(12, float),
|
||||
sampler(13, wgpu::SamplerBindingType::Filtering),
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
@@ -106,10 +212,18 @@ impl PackStages {
|
||||
});
|
||||
let constants = pack.constants();
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let stage_count = pack.stages.len();
|
||||
let passes = pack
|
||||
.stages
|
||||
.iter()
|
||||
.map(|stage| {
|
||||
.enumerate()
|
||||
.map(|(idx, stage)| {
|
||||
let is_last = idx + 1 == stage_count;
|
||||
let target_format = if is_last {
|
||||
format
|
||||
} else {
|
||||
stage_format(stage.format.as_deref(), format)
|
||||
};
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&stage.file),
|
||||
source: wgpu::ShaderSource::Wgsl(stage.source.as_str().into()),
|
||||
@@ -140,7 +254,7 @@ impl PackStages {
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
format: target_format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
@@ -151,38 +265,77 @@ impl PackStages {
|
||||
Pass {
|
||||
pipeline,
|
||||
scale: stage.scale,
|
||||
target_format,
|
||||
uniform: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack frame"),
|
||||
size: std::mem::size_of::<FrameUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
uniform_ext: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack frame ext"),
|
||||
size: std::mem::size_of::<FrameExtUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if let Some(error) = pollster::block_on(scope.pop()) {
|
||||
return Err(error.to_string());
|
||||
}
|
||||
let sampler = |filter| {
|
||||
let make_sampler = |filter, address, compare| {
|
||||
device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Shader pack"),
|
||||
address_mode_u: address,
|
||||
address_mode_v: address,
|
||||
address_mode_w: address,
|
||||
mag_filter: filter,
|
||||
min_filter: filter,
|
||||
compare,
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let noise_view = match &pack.custom_noise {
|
||||
Some(image) => rgba_view(device, queue, "Shader pack noise", image),
|
||||
None => rgba_view(device, queue, "Shader pack noise", &noise()),
|
||||
};
|
||||
let (shadow_res, shadow_dist) = pack.shadow_config();
|
||||
Ok(Self {
|
||||
id: pack.id.clone(),
|
||||
needs_shadow: pack.needs_shadow(),
|
||||
needs_gbuffer: pack.needs_gbuffer(),
|
||||
needs_opaque_depth: pack.needs_opaque_depth(),
|
||||
shadow_res,
|
||||
shadow_dist,
|
||||
layout,
|
||||
linear: sampler(wgpu::FilterMode::Linear),
|
||||
nearest: sampler(wgpu::FilterMode::Nearest),
|
||||
linear: make_sampler(
|
||||
wgpu::FilterMode::Linear,
|
||||
wgpu::AddressMode::ClampToEdge,
|
||||
None,
|
||||
),
|
||||
nearest: make_sampler(
|
||||
wgpu::FilterMode::Nearest,
|
||||
wgpu::AddressMode::ClampToEdge,
|
||||
None,
|
||||
),
|
||||
repeat: make_sampler(wgpu::FilterMode::Linear, wgpu::AddressMode::Repeat, None),
|
||||
shadow_sampler: make_sampler(
|
||||
wgpu::FilterMode::Linear,
|
||||
wgpu::AddressMode::ClampToEdge,
|
||||
Some(wgpu::CompareFunction::GreaterEqual),
|
||||
),
|
||||
noise_view,
|
||||
passes,
|
||||
format,
|
||||
targets: Vec::new(),
|
||||
output: None,
|
||||
world_size: [0, 0],
|
||||
post_generation: u64::MAX,
|
||||
started: std::time::Instant::now(),
|
||||
last: None,
|
||||
frames: 0,
|
||||
previous: None,
|
||||
wetness: SmoothFloat::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -199,9 +352,14 @@ impl PackStages {
|
||||
size: [u32; 2],
|
||||
post: &super::world_post::Post,
|
||||
mut uniform: FrameUniform,
|
||||
eye_light: [u8; 2],
|
||||
) -> &wgpu::BindGroup {
|
||||
if self.world_size != size || self.targets.len() != self.passes.len() {
|
||||
if self.world_size != size
|
||||
|| self.post_generation != post.generation()
|
||||
|| self.targets.len() != self.passes.len()
|
||||
{
|
||||
self.world_size = size;
|
||||
self.post_generation = post.generation();
|
||||
self.targets.clear();
|
||||
for pass in &self.passes {
|
||||
let target_size = size.map(|v| ((v as f32 * pass.scale).round() as u32).max(1));
|
||||
@@ -216,7 +374,7 @@ impl PackStages {
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: self.format,
|
||||
format: pass.target_format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
@@ -251,6 +409,38 @@ impl PackStages {
|
||||
binding: 5,
|
||||
resource: pass.uniform.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: pass.uniform_ext.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(post.normal()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: wgpu::BindingResource::TextureView(post.material()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 9,
|
||||
resource: wgpu::BindingResource::TextureView(post.opaque_depth()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 10,
|
||||
resource: wgpu::BindingResource::TextureView(post.shadow_depth()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 11,
|
||||
resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 12,
|
||||
resource: wgpu::BindingResource::TextureView(&self.noise_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 13,
|
||||
resource: wgpu::BindingResource::Sampler(&self.repeat),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.targets.push(Target {
|
||||
@@ -262,6 +452,42 @@ impl PackStages {
|
||||
let last = &self.targets.last().expect("a pack has a stage").view;
|
||||
self.output = Some(post.bind(device, last));
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
let dt = self.last.map_or(0.0, |t| (now - t).as_secs_f32());
|
||||
self.last = Some(now);
|
||||
let wet = self.wetness.update(uniform.weather[0], 60.0, 120.0, dt);
|
||||
let cam = [
|
||||
uniform.camera_position[0],
|
||||
uniform.camera_position[1],
|
||||
uniform.camera_position[2],
|
||||
];
|
||||
let (prev_view, prev_proj, prev_cam) =
|
||||
self.previous
|
||||
.unwrap_or((uniform.view, uniform.projection, cam));
|
||||
self.previous = Some((uniform.view, uniform.projection, cam));
|
||||
let shadow_view = post.shadow_view;
|
||||
let shadow_proj = post.shadow_proj;
|
||||
let ext = FrameExtUniform {
|
||||
previous_view: prev_view,
|
||||
previous_projection: prev_proj,
|
||||
shadow_view: shadow_view.to_cols_array_2d(),
|
||||
shadow_projection: shadow_proj.to_cols_array_2d(),
|
||||
shadow_inverse_view: shadow_view.inverse().to_cols_array_2d(),
|
||||
shadow_inverse_projection: shadow_proj.inverse().to_cols_array_2d(),
|
||||
previous_camera: [prev_cam[0], prev_cam[1], prev_cam[2], dt],
|
||||
shadow_params: [
|
||||
post.shadow_resolution as f32,
|
||||
post.shadow_distance,
|
||||
0.0,
|
||||
if post.shadow_active { 1.0 } else { 0.0 },
|
||||
],
|
||||
environment: [
|
||||
f32::from(eye_light[0]) * 16.0,
|
||||
f32::from(eye_light[1]) * 16.0,
|
||||
wet,
|
||||
0.0,
|
||||
],
|
||||
};
|
||||
uniform.time[0] = self.started.elapsed().as_secs_f32();
|
||||
uniform.time[1] = self.frames as f32;
|
||||
self.frames = self.frames.wrapping_add(1);
|
||||
@@ -269,6 +495,7 @@ impl PackStages {
|
||||
let [w, h] = target.size.map(|v| v as f32);
|
||||
uniform.resolution = [w, h, 1.0 / w, 1.0 / h];
|
||||
queue.write_buffer(&pass.uniform, 0, bytemuck::bytes_of(&uniform));
|
||||
queue.write_buffer(&pass.uniform_ext, 0, bytemuck::bytes_of(&ext));
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target.view,
|
||||
resolve_target: None,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Solid draws grouped by vertex arena page within distance bands.
|
||||
//!
|
||||
//! Sections sit in arena pages of 256 MiB, so render distance 32 spreads
|
||||
//! them over half a dozen pages, and a list sorted only by distance switches
|
||||
//! page every few sections: about a thousand indirect multi-draws of two or
|
||||
//! three entries a frame. Mesa's Intel driver (and wgpu-core, for each
|
||||
//! vertex buffer change) pays per call, and below four entries Mesa unrolls
|
||||
//! the call on the CPU entry by entry. Grouping the sections of one band of
|
||||
//! distance by page keeps the list front to back to within the band (what
|
||||
//! the depth test needs to reject hidden fragments early) with one call per
|
||||
//! page and band.
|
||||
//!
|
||||
//! The picture does not change: solid draws write depth and are tested
|
||||
//! against it, so their order only decides which of two fragments at the
|
||||
//! same depth stays, and sections do not share faces.
|
||||
|
||||
/// The width of a band, in blocks.
|
||||
const BAND: f64 = 64.0;
|
||||
|
||||
/// One section's draws: `start..start + len` of the list.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct Run {
|
||||
key: u64,
|
||||
pub start: u32,
|
||||
pub len: u32,
|
||||
/// The caller's data, carried along (occlusion culling's test inputs).
|
||||
pub tag: [u32; 3],
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct PageRuns {
|
||||
runs: Vec<Run>,
|
||||
scratch: Vec<([u32; 5], (u32, Option<u32>))>,
|
||||
}
|
||||
|
||||
impl PageRuns {
|
||||
pub fn clear(&mut self) {
|
||||
self.runs.clear();
|
||||
}
|
||||
|
||||
/// The draws `start..start + len` of a section `distance` from the
|
||||
/// camera (squared, as bits) in arena `page`; runs come nearest first.
|
||||
pub fn push(&mut self, distance: u64, page: u32, start: usize, len: usize, tag: [u32; 3]) {
|
||||
if len > 0 {
|
||||
let band = (f64::from_bits(distance).sqrt() / BAND) as u64;
|
||||
self.runs.push(Run {
|
||||
key: band << 32 | u64::from(page),
|
||||
start: start as u32,
|
||||
len: len as u32,
|
||||
tag,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Reorders `draws[base..]` and their `pages`, which the pushed runs
|
||||
/// cover in order, so that each band's runs in one page are adjacent
|
||||
/// (in distance order); returns the runs at their new places.
|
||||
pub fn regroup(
|
||||
&mut self,
|
||||
draws: &mut [[u32; 5]],
|
||||
pages: &mut [(u32, Option<u32>)],
|
||||
base: usize,
|
||||
) -> &[Run] {
|
||||
let page = |r: &Run| r.key as u32;
|
||||
if self.runs.windows(2).all(|w| page(&w[0]) == page(&w[1])) {
|
||||
return &self.runs;
|
||||
}
|
||||
// Stable: within a band and page the runs stay nearest first.
|
||||
self.runs.sort_by_key(|r| r.key);
|
||||
self.scratch.clear();
|
||||
for r in &mut self.runs {
|
||||
let (s, e) = (r.start as usize, (r.start + r.len) as usize);
|
||||
r.start = (base + self.scratch.len()) as u32;
|
||||
self.scratch
|
||||
.extend(draws[s..e].iter().copied().zip(pages[s..e].iter().copied()));
|
||||
}
|
||||
for (i, &(draw, p)) in self.scratch.iter().enumerate() {
|
||||
draws[base + i] = draw;
|
||||
pages[base + i] = p;
|
||||
}
|
||||
&self.runs
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bits(blocks: f64) -> u64 {
|
||||
(blocks * blocks).to_bits()
|
||||
}
|
||||
|
||||
/// Runs of (distance in blocks, page, draws), as the list builder pushes them.
|
||||
fn list(runs: &[(f64, u32, u32)]) -> (PageRuns, Vec<[u32; 5]>, Vec<(u32, Option<u32>)>) {
|
||||
let mut grouped = PageRuns::default();
|
||||
let (mut draws, mut pages) = (Vec::new(), Vec::new());
|
||||
for (n, &(distance, page, count)) in runs.iter().enumerate() {
|
||||
let start = draws.len();
|
||||
for k in 0..count {
|
||||
draws.push([n as u32, k, 0, 0, 0]);
|
||||
pages.push((page, None));
|
||||
}
|
||||
grouped.push(
|
||||
bits(distance),
|
||||
page,
|
||||
start,
|
||||
count as usize,
|
||||
[n as u32, 0, 0],
|
||||
);
|
||||
}
|
||||
(grouped, draws, pages)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_band_draws_each_page_once_keeping_the_order_inside_it() {
|
||||
let (mut grouped, mut draws, mut pages) = list(&[
|
||||
(10.0, 1, 2),
|
||||
(20.0, 0, 1),
|
||||
(30.0, 1, 3),
|
||||
(40.0, 0, 2),
|
||||
(70.0, 1, 1),
|
||||
]);
|
||||
let runs: Vec<_> = grouped
|
||||
.regroup(&mut draws, &mut pages, 0)
|
||||
.iter()
|
||||
.map(|r| (r.tag[0], r.start, r.len))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
runs,
|
||||
[(1, 0, 1), (3, 1, 2), (0, 3, 2), (2, 5, 3), (4, 8, 1)]
|
||||
);
|
||||
let order: Vec<_> = draws.iter().map(|d| (d[0], d[1])).collect();
|
||||
assert_eq!(
|
||||
order,
|
||||
[
|
||||
(1, 0),
|
||||
(3, 0),
|
||||
(3, 1),
|
||||
(0, 0),
|
||||
(0, 1),
|
||||
(2, 0),
|
||||
(2, 1),
|
||||
(2, 2),
|
||||
(4, 0)
|
||||
]
|
||||
);
|
||||
let pages: Vec<_> = pages.iter().map(|p| p.0).collect();
|
||||
assert_eq!(pages, [0, 0, 0, 1, 1, 1, 1, 1, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bands_stay_in_distance_order() {
|
||||
let (mut grouped, mut draws, mut pages) =
|
||||
list(&[(10.0, 1, 1), (100.0, 0, 1), (200.0, 0, 1)]);
|
||||
grouped.regroup(&mut draws, &mut pages, 0);
|
||||
let order: Vec<_> = draws.iter().map(|d| d[0]).collect();
|
||||
assert_eq!(order, [0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_page_and_an_offset_list() {
|
||||
let (mut grouped, mut draws, mut pages) = list(&[(10.0, 3, 2), (90.0, 3, 1)]);
|
||||
let before = draws.clone();
|
||||
assert_eq!(grouped.regroup(&mut draws, &mut pages, 0)[1].start, 2);
|
||||
assert_eq!(draws, before);
|
||||
// A list after other draws: starts are absolute.
|
||||
let mut grouped = PageRuns::default();
|
||||
let mut draws = vec![[9; 5], [7, 0, 0, 0, 0], [8, 0, 0, 0, 0]];
|
||||
let mut pages = vec![(5, None), (1, None), (0, None)];
|
||||
grouped.push(bits(5.0), 1, 1, 1, [0; 3]);
|
||||
grouped.push(bits(6.0), 0, 2, 1, [1, 0, 0]);
|
||||
let runs = grouped.regroup(&mut draws, &mut pages, 1).to_vec();
|
||||
assert_eq!(draws, [[9; 5], [8, 0, 0, 0, 0], [7, 0, 0, 0, 0]]);
|
||||
assert_eq!(pages, [(5, None), (0, None), (1, None)]);
|
||||
assert_eq!((runs[0].tag[0], runs[0].start), (1, 1));
|
||||
// Empty runs are left out.
|
||||
grouped.clear();
|
||||
grouped.push(bits(1.0), 0, 0, 0, [0; 3]);
|
||||
assert!(grouped.regroup(&mut [], &mut [], 0).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -169,14 +169,20 @@ fn in_view(key: SectionKey, camera: DVec3, radius: i32, frustum: &Frustum) -> Op
|
||||
if !in_view_distance(camera_column(camera), radius, key.0, key.2) {
|
||||
return None;
|
||||
}
|
||||
in_frustum(key, camera, frustum)
|
||||
in_frustum(key, camera, frustum, false)
|
||||
}
|
||||
|
||||
/// `in_view` without the distance test.
|
||||
fn in_frustum(key: SectionKey, camera: DVec3, frustum: &Frustum) -> Option<(f64, Vec3)> {
|
||||
/// `in_view` without the distance test; `inside`: the section's column is
|
||||
/// entirely inside the frustum, so the section is too.
|
||||
fn in_frustum(
|
||||
key: SectionKey,
|
||||
camera: DVec3,
|
||||
frustum: &Frustum,
|
||||
inside: bool,
|
||||
) -> Option<(f64, Vec3)> {
|
||||
let origin = origin(key, camera);
|
||||
let min = origin.as_vec3();
|
||||
if !frustum.intersects(min, min + Vec3::splat(16.0)) {
|
||||
if !inside && !frustum.intersects(min, min + Vec3::splat(16.0)) {
|
||||
return None;
|
||||
}
|
||||
Some(((origin + DVec3::splat(8.0)).length_squared(), min))
|
||||
@@ -192,8 +198,9 @@ impl<T> Resident<T> {
|
||||
/// Columns are tested first, with the same distance test (it depends
|
||||
/// only on x and z) and a box spanning their sections: every section box
|
||||
/// lies inside it and the frustum test rounds monotonically, so a column
|
||||
/// the frustum rejects holds no section it would accept. `radius` is the
|
||||
/// render distance in chunks.
|
||||
/// the frustum rejects holds no section it would accept, and one
|
||||
/// entirely inside holds none it would reject (its sections skip the
|
||||
/// test). `radius` is the render distance in chunks.
|
||||
pub fn visible(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
@@ -211,7 +218,9 @@ impl<T> Resident<T> {
|
||||
}
|
||||
let min = origin((column.x, column.low, column.z), camera).as_vec3();
|
||||
let top = origin((column.x, column.high, column.z), camera).as_vec3();
|
||||
if !frustum.intersects(min, top + Vec3::splat(16.0)) {
|
||||
let max = top + Vec3::splat(16.0);
|
||||
let inside = frustum.contains(min, max);
|
||||
if !inside && !frustum.intersects(min, max) {
|
||||
continue;
|
||||
}
|
||||
for &(y, slot) in &column.sections {
|
||||
@@ -219,7 +228,7 @@ impl<T> Resident<T> {
|
||||
if near && !reachable(key) {
|
||||
continue;
|
||||
}
|
||||
if let Some((d, o)) = in_frustum(key, camera, frustum) {
|
||||
if let Some((d, o)) = in_frustum(key, camera, frustum, inside) {
|
||||
out.push(Visible {
|
||||
distance: d.to_bits(),
|
||||
key,
|
||||
@@ -277,7 +286,7 @@ mod tests {
|
||||
if near {
|
||||
in_view(k, camera, radius, &frustum).filter(|_| reachable(k))
|
||||
} else if beyond(k.0, k.2) {
|
||||
in_frustum(k, camera, &frustum)
|
||||
in_frustum(k, camera, &frustum, false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -298,6 +307,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_entirely_inside_skip_no_section() {
|
||||
// Short columns ahead of the camera: most lie entirely inside.
|
||||
let mut r = Resident::default();
|
||||
for x in -12i32..=12 {
|
||||
for z in 1..=12 {
|
||||
for y in 3..6 {
|
||||
if (x + y + z).rem_euclid(3) != 0 {
|
||||
r.insert((x, y, z), ());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let camera = DVec3::new(0.5, -8.0, 0.5);
|
||||
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
|
||||
let frustum = Frustum {
|
||||
planes: super::super::frustum(projection * crate::camera::view_matrix(0.0, 5.0)),
|
||||
};
|
||||
let inside = r
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
let min = origin((c.x, c.low, c.z), camera).as_vec3();
|
||||
let top = origin((c.x, c.high, c.z), camera).as_vec3();
|
||||
frustum.contains(min, top + Vec3::splat(16.0))
|
||||
})
|
||||
.count();
|
||||
assert!(inside > 50 && inside < r.columns.len(), "{inside}");
|
||||
let mut fast = Vec::new();
|
||||
r.visible(camera, 12, &frustum, |_| true, |_, _| false, &mut fast);
|
||||
let mut slow: Vec<_> = r
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|&(k, _)| in_view(k, camera, 12, &frustum).map(|(d, _)| (d, k)))
|
||||
.collect();
|
||||
slow.sort_unstable_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||
let fast: Vec<_> = fast
|
||||
.iter()
|
||||
.map(|v| (f64::from_bits(v.distance), v.key))
|
||||
.collect();
|
||||
assert_eq!(fast, slow);
|
||||
}
|
||||
|
||||
/// Every key reaches its value through its slot and column, and every
|
||||
/// column's bounds and sections match the keys.
|
||||
fn check(r: &Resident<u32>, model: &HashMap<SectionKey, u32>) {
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
//! GPU frame time from timestamp queries, read back a few frames late so the
|
||||
//! CPU never waits for it. Frames whose readback slot is still busy go untimed.
|
||||
//!
|
||||
//! Where the GPU can only time pass boundaries (Apple GPUs on Metal), the
|
||||
//! frame is bracketed by two one-thread compute passes that write the
|
||||
//! timestamps (Metal leaves an empty pass's timestamps at zero).
|
||||
//! Where the GPU can only time pass boundaries (Apple GPUs on Metal), a
|
||||
//! one-thread compute pass stamps the frame's start, and a render pass over
|
||||
//! the frame's target that shades one pixel with every write masked stamps
|
||||
//! its end (Metal leaves the timestamps of a pass that does nothing at zero).
|
||||
//! A pass that loads the target runs after every pass that draws into it; a
|
||||
//! compute pass, which depends on nothing, ran alongside them. Metal also
|
||||
//! resolves timestamps without waiting for the passes that write them, so
|
||||
//! they are resolved in a later frame, once the GPU has finished this one.
|
||||
//! Resolved in the frame itself, the end was mostly the one an earlier frame
|
||||
//! left in the query, and frames read about 0.01 ms.
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU8, Ordering},
|
||||
};
|
||||
|
||||
const SLOTS: usize = 4;
|
||||
/// Frames in flight at once; on Metal a slot is busy for about five frames.
|
||||
const SLOTS: usize = 8;
|
||||
/// Each slot's timestamps have their own queries and resolve range.
|
||||
const RESOLVE_STRIDE: u64 = wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT;
|
||||
/// The frame's target and its format.
|
||||
pub type Target<'a> = (&'a wgpu::TextureView, wgpu::TextureFormat);
|
||||
const FREE: u8 = 0;
|
||||
const PENDING: u8 = 1;
|
||||
const MAPPED: u8 = 2;
|
||||
/// Its frame is submitted; with markers, until the GPU has finished it.
|
||||
const RUNNING: u8 = 1;
|
||||
/// Its frame is finished: its timestamps can be resolved.
|
||||
const DONE: u8 = 2;
|
||||
/// Resolved and on its way back.
|
||||
const PENDING: u8 = 3;
|
||||
const MAPPED: u8 = 4;
|
||||
|
||||
struct Slot {
|
||||
buffer: wgpu::Buffer,
|
||||
@@ -27,10 +44,29 @@ pub struct GpuTimer {
|
||||
current: Option<usize>,
|
||||
next: usize,
|
||||
period_ns: f32,
|
||||
/// Timestamps between commands, or else this pass's boundaries.
|
||||
marker: Option<wgpu::ComputePipeline>,
|
||||
/// Timestamps between commands, or else these passes' boundaries.
|
||||
markers: Option<Markers>,
|
||||
/// Slots resolved in this frame's commands, read back once it is submitted.
|
||||
resolved: Vec<usize>,
|
||||
}
|
||||
|
||||
/// The marker passes: the start's compute pipeline, and the end's shader and
|
||||
/// pipeline, for the target format it was made for.
|
||||
struct Markers {
|
||||
module: wgpu::ShaderModule,
|
||||
start: wgpu::ComputePipeline,
|
||||
end: Option<(wgpu::TextureFormat, wgpu::RenderPipeline)>,
|
||||
}
|
||||
|
||||
/// The start's one thread; the end's triangle over the whole target (the
|
||||
/// pass's scissor keeps one pixel), shading nothing it writes.
|
||||
const MARKER: &str =
|
||||
"@vertex fn cover(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
return vec4<f32>(f32(i & 1u) * 4.0 - 1.0, f32(i >> 1u) * 4.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
@fragment fn nothing() -> @location(0) vec4<f32> { return vec4<f32>(0.0); }
|
||||
@compute @workgroup_size(1) fn mark() {}";
|
||||
|
||||
impl GpuTimer {
|
||||
/// The features timing uses that the adapter has (none: no timing).
|
||||
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
|
||||
@@ -44,11 +80,11 @@ impl GpuTimer {
|
||||
let queries = device.create_query_set(&wgpu::QuerySetDescriptor {
|
||||
label: Some("Frame timestamps"),
|
||||
ty: wgpu::QueryType::Timestamp,
|
||||
count: 2,
|
||||
count: 2 * SLOTS as u32,
|
||||
});
|
||||
let resolve = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Frame timestamp resolve"),
|
||||
size: 16,
|
||||
size: RESOLVE_STRIDE * SLOTS as u64,
|
||||
usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
@@ -70,42 +106,43 @@ impl GpuTimer {
|
||||
current: None,
|
||||
next: 0,
|
||||
period_ns: queue.get_timestamp_period(),
|
||||
marker: (!device
|
||||
markers: (!device
|
||||
.features()
|
||||
.contains(wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS))
|
||||
.then(|| {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Frame timestamp"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
"@compute @workgroup_size(1) fn mark() {}".into(),
|
||||
),
|
||||
source: wgpu::ShaderSource::Wgsl(MARKER.into()),
|
||||
});
|
||||
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Frame timestamp"),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("mark"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
})
|
||||
Markers {
|
||||
start: device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Frame timestamp"),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("mark"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
}),
|
||||
module,
|
||||
end: None,
|
||||
}
|
||||
}),
|
||||
resolved: Vec::new(),
|
||||
})
|
||||
}
|
||||
pub fn begin(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
if self.markers.is_some() {
|
||||
for slot in 0..SLOTS {
|
||||
if self.slots[slot].state.load(Ordering::Acquire) == DONE {
|
||||
self.resolve(encoder, slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
let slot = self.next;
|
||||
self.current = (self.slots[slot].state.load(Ordering::Acquire) == FREE).then_some(slot);
|
||||
if self.current.is_some() {
|
||||
self.stamp(encoder, 0);
|
||||
}
|
||||
}
|
||||
pub fn end(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(slot) = self.current else { return };
|
||||
self.stamp(encoder, 1);
|
||||
encoder.resolve_query_set(&self.queries, 0..2, &self.resolve, 0);
|
||||
encoder.copy_buffer_to_buffer(&self.resolve, 0, &self.slots[slot].buffer, 0, 16);
|
||||
}
|
||||
fn stamp(&self, encoder: &mut wgpu::CommandEncoder, index: u32) {
|
||||
let Some(marker) = &self.marker else {
|
||||
let index = 2 * slot as u32;
|
||||
let Some(markers) = &self.markers else {
|
||||
encoder.write_timestamp(&self.queries, index);
|
||||
return;
|
||||
};
|
||||
@@ -113,29 +150,85 @@ impl GpuTimer {
|
||||
label: Some("Frame timestamp"),
|
||||
timestamp_writes: Some(wgpu::ComputePassTimestampWrites {
|
||||
query_set: &self.queries,
|
||||
beginning_of_pass_write_index: (index == 0).then_some(0),
|
||||
end_of_pass_write_index: (index == 1).then_some(1),
|
||||
beginning_of_pass_write_index: Some(index),
|
||||
end_of_pass_write_index: None,
|
||||
}),
|
||||
});
|
||||
pass.set_pipeline(marker);
|
||||
pass.set_pipeline(&markers.start);
|
||||
pass.dispatch_workgroups(1, 1, 1);
|
||||
}
|
||||
/// After the frame's submission: start reading its timestamps back.
|
||||
pub fn submitted(&mut self) {
|
||||
/// After the frame's last pass into `target` (the frame's own, of `format`).
|
||||
pub fn end(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
(target, format): Target,
|
||||
) {
|
||||
let Some(slot) = self.current else { return };
|
||||
let index = 2 * slot as u32 + 1;
|
||||
let Some(markers) = &mut self.markers else {
|
||||
encoder.write_timestamp(&self.queries, index);
|
||||
self.resolve(encoder, slot);
|
||||
return;
|
||||
};
|
||||
if markers.end.as_ref().is_none_or(|(f, _)| *f != format) {
|
||||
markers.end = Some((format, end_pipeline(device, &markers.module, format)));
|
||||
}
|
||||
let (_, pipeline) = markers.end.as_ref().expect("made above");
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Frame timestamp"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
timestamp_writes: Some(wgpu::RenderPassTimestampWrites {
|
||||
query_set: &self.queries,
|
||||
beginning_of_pass_write_index: None,
|
||||
end_of_pass_write_index: Some(index),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_scissor_rect(0, 0, 1, 1);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
/// Copy `slot`'s timestamps to its readback buffer.
|
||||
fn resolve(&mut self, encoder: &mut wgpu::CommandEncoder, slot: usize) {
|
||||
let (first, at) = (2 * slot as u32, RESOLVE_STRIDE * slot as u64);
|
||||
encoder.resolve_query_set(&self.queries, first..first + 2, &self.resolve, at);
|
||||
encoder.copy_buffer_to_buffer(&self.resolve, at, &self.slots[slot].buffer, 0, 16);
|
||||
self.slots[slot].state.store(RUNNING, Ordering::Release);
|
||||
self.resolved.push(slot);
|
||||
}
|
||||
/// After the frame's submission: start reading back what it resolved,
|
||||
/// and with markers, wait for the GPU to finish the frame it timed.
|
||||
pub fn submitted(&mut self, queue: &wgpu::Queue) {
|
||||
for slot in self.resolved.drain(..) {
|
||||
let state = self.slots[slot].state.clone();
|
||||
state.store(PENDING, Ordering::Release);
|
||||
self.slots[slot]
|
||||
.buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
state.store(
|
||||
if result.is_ok() { MAPPED } else { FREE },
|
||||
Ordering::Release,
|
||||
);
|
||||
});
|
||||
}
|
||||
let Some(slot) = self.current.take() else {
|
||||
return;
|
||||
};
|
||||
let state = self.slots[slot].state.clone();
|
||||
state.store(PENDING, Ordering::Release);
|
||||
self.slots[slot]
|
||||
.buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
state.store(
|
||||
if result.is_ok() { MAPPED } else { FREE },
|
||||
Ordering::Release,
|
||||
);
|
||||
});
|
||||
if self.markers.is_some() {
|
||||
let state = self.slots[slot].state.clone();
|
||||
state.store(RUNNING, Ordering::Release);
|
||||
queue.on_submitted_work_done(move || state.store(DONE, Ordering::Release));
|
||||
}
|
||||
self.next = (slot + 1) % SLOTS;
|
||||
}
|
||||
/// GPU milliseconds of frames whose timestamps have arrived, oldest first.
|
||||
@@ -149,11 +242,46 @@ impl GpuTimer {
|
||||
if let Ok(data) = slot.buffer.slice(..).get_mapped_range() {
|
||||
// Read unaligned: the mapping isn't guaranteed to be 8-byte aligned.
|
||||
let stamps: [u64; 2] = bytemuck::pod_read_unaligned(&data[..16]);
|
||||
let ticks = stamps[1].saturating_sub(stamps[0]);
|
||||
out.push((ticks as f64 * f64::from(self.period_ns) / 1e6) as f32);
|
||||
// A start marker that ran late (rare on Metal) leaves the frame untimed.
|
||||
if let Some(ticks) = stamps[1].checked_sub(stamps[0]) {
|
||||
out.push((ticks as f64 * f64::from(self.period_ns) / 1e6) as f32);
|
||||
}
|
||||
}
|
||||
slot.buffer.unmap();
|
||||
slot.state.store(FREE, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The end marker's pipeline for a `format` target, every write masked.
|
||||
fn end_pipeline(
|
||||
device: &wgpu::Device,
|
||||
module: &wgpu::ShaderModule,
|
||||
format: wgpu::TextureFormat,
|
||||
) -> wgpu::RenderPipeline {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Frame timestamp"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module,
|
||||
entry_point: Some("cover"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module,
|
||||
entry_point: Some("nothing"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
@@ -1,26 +1,163 @@
|
||||
//! Resolution scale and FXAA (Horizon settings): the world renders into its
|
||||
//! own target at the scaled size, then one full-screen pass copies or
|
||||
//! anti-aliases it onto the screen, under the hand and HUD. A shader pack
|
||||
//! also needs the target (its stages read the world's colour and depth).
|
||||
//! Resolution scale, FXAA, and shader-pack G-buffer/shadow targets (Horizon
|
||||
//! settings): the world renders into its own target at the scaled size, then
|
||||
//! one full-screen pass copies or anti-aliases it onto the screen, under the
|
||||
//! hand and HUD. For a shader pack that reads them, `Post` also keeps:
|
||||
//! - `opaque_depth`: the depth before translucent terrain (water, stained
|
||||
//! glass, ice) draws, a Java pack's `depthtex1`/`depthtex2`;
|
||||
//! - `normal` and `material`, an api 2 pack's `scene_normal` and
|
||||
//! `scene_material`: surface normals and material properties
|
||||
//! reconstructed from depth and scene colour;
|
||||
//! - `shadow_depth`, an api 2 pack's: the sun/moon shadow map and its
|
||||
//! light-space `shadow_view` and `shadow_proj` matrices.
|
||||
//!
|
||||
//! With none of them on, the world renders straight to the screen as before.
|
||||
|
||||
use super::pack_stages::FrameUniform;
|
||||
use glam::{Mat4, Vec3};
|
||||
|
||||
const GBUFFER_WGSL: &str = r#"
|
||||
struct GbufferParams {
|
||||
inverse_view: mat4x4<f32>,
|
||||
inverse_projection: mat4x4<f32>,
|
||||
resolution: vec4<f32>,
|
||||
// x: eye block light (0..1), y: eye sky light (0..1), z: wetness (0..1), w: sun elevation
|
||||
env: vec4<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var world_color: texture_2d<f32>;
|
||||
@group(0) @binding(1) var world_depth: texture_depth_2d;
|
||||
@group(0) @binding(2) var opaque_depth: texture_depth_2d;
|
||||
@group(0) @binding(3) var nearest_sampler: sampler;
|
||||
@group(0) @binding(4) var<uniform> params: GbufferParams;
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_gbuffer(@builtin(vertex_index) i: u32) -> VsOut {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
var out: VsOut;
|
||||
out.clip = vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
out.uv = vec2<f32>(p.x, 1.0 - p.y);
|
||||
return out;
|
||||
}
|
||||
|
||||
struct GbufferOut {
|
||||
@location(0) normal: vec4<f32>,
|
||||
@location(1) material: vec4<f32>,
|
||||
}
|
||||
|
||||
fn world_pos(uv: vec2<f32>, d: f32) -> vec3<f32> {
|
||||
let v = params.inverse_projection * vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, d, 1.0);
|
||||
let view = v.xyz / max(v.w, 1e-6);
|
||||
return (params.inverse_view * vec4<f32>(view, 1.0)).xyz;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_gbuffer(in: VsOut) -> GbufferOut {
|
||||
let d_all = textureSampleLevel(world_depth, nearest_sampler, in.uv, 0);
|
||||
let d_opq = textureSampleLevel(opaque_depth, nearest_sampler, in.uv, 0);
|
||||
let color = textureSampleLevel(world_color, nearest_sampler, in.uv, 0.0);
|
||||
var out: GbufferOut;
|
||||
if d_all <= 0.0 && d_opq <= 0.0 {
|
||||
out.normal = vec4<f32>(0.5, 1.0, 0.5, 0.0);
|
||||
out.material = vec4<f32>(0.0, params.env.y, 0.0, 1.0);
|
||||
return out;
|
||||
}
|
||||
let d = select(d_all, d_opq, d_opq > 0.0);
|
||||
let step_uv = params.resolution.zw;
|
||||
let p0 = world_pos(in.uv, d);
|
||||
let uv_r = clamp(in.uv + vec2<f32>(step_uv.x, 0.0), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
let uv_l = clamp(in.uv - vec2<f32>(step_uv.x, 0.0), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
let uv_d = clamp(in.uv + vec2<f32>(0.0, step_uv.y), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
let uv_u = clamp(in.uv - vec2<f32>(0.0, step_uv.y), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
let dr = textureSampleLevel(opaque_depth, nearest_sampler, uv_r, 0);
|
||||
let dl = textureSampleLevel(opaque_depth, nearest_sampler, uv_l, 0);
|
||||
let dd = textureSampleLevel(opaque_depth, nearest_sampler, uv_d, 0);
|
||||
let du = textureSampleLevel(opaque_depth, nearest_sampler, uv_u, 0);
|
||||
let dx = select(world_pos(uv_r, dr) - p0, p0 - world_pos(uv_l, dl), abs(dl - d) < abs(dr - d));
|
||||
let dy = select(world_pos(uv_d, dd) - p0, p0 - world_pos(uv_u, du), abs(du - d) < abs(dd - d));
|
||||
let raw_n = cross(dy, dx);
|
||||
let n_len = length(raw_n);
|
||||
let n = select(vec3<f32>(0.0, 1.0, 0.0), raw_n / max(n_len, 1e-6), n_len > 1e-6);
|
||||
// Reversed depth: closer translucent geometry has larger depth than opaque geometry.
|
||||
let is_translucent = select(0.0, 1.0, d_all > d_opq + 1e-5);
|
||||
let luma = dot(color.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
let block_light = clamp(max(params.env.x, smoothstep(0.65, 0.98, luma)), 0.0, 1.0);
|
||||
let roughness = select(clamp(0.85 - params.env.z * 0.45, 0.1, 1.0), 0.05, is_translucent > 0.5);
|
||||
out.normal = vec4<f32>(n * 0.5 + 0.5, 1.0);
|
||||
out.material = vec4<f32>(block_light, params.env.y, is_translucent, roughness);
|
||||
return out;
|
||||
}
|
||||
"#;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct GbufferUniform {
|
||||
inverse_view: [[f32; 4]; 4],
|
||||
inverse_projection: [[f32; 4]; 4],
|
||||
resolution: [f32; 4],
|
||||
env: [f32; 4],
|
||||
}
|
||||
|
||||
/// What the frame's shader pack reads beyond the world's colour and depth:
|
||||
/// `Post` keeps these targets only while one does.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Extras {
|
||||
pub opaque_depth: bool,
|
||||
pub gbuffer: bool,
|
||||
}
|
||||
|
||||
struct WorldTarget {
|
||||
size: [u32; 2],
|
||||
extras: Extras,
|
||||
color: wgpu::TextureView,
|
||||
depth_tex: wgpu::Texture,
|
||||
depth: wgpu::TextureView,
|
||||
/// With `extras.opaque_depth` or `extras.gbuffer` (which reads it).
|
||||
opaque_depth: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
gbuffer: Option<Gbuffer>,
|
||||
group: wgpu::BindGroup,
|
||||
opaque_captured: bool,
|
||||
}
|
||||
|
||||
struct Gbuffer {
|
||||
normal: wgpu::TextureView,
|
||||
material: wgpu::TextureView,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
struct ShadowTarget {
|
||||
resolution: u32,
|
||||
depth: wgpu::TextureView,
|
||||
}
|
||||
|
||||
pub struct Post {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
nearest: wgpu::Sampler,
|
||||
copy: wgpu::RenderPipeline,
|
||||
fxaa: wgpu::RenderPipeline,
|
||||
gbuffer_layout: wgpu::BindGroupLayout,
|
||||
gbuffer_pipeline: wgpu::RenderPipeline,
|
||||
gbuffer_uniform: wgpu::Buffer,
|
||||
format: wgpu::TextureFormat,
|
||||
/// World resolution relative to the window (0.5..=2.0).
|
||||
pub scale: f32,
|
||||
pub antialias: bool,
|
||||
target: Option<WorldTarget>,
|
||||
shadow: Option<ShadowTarget>,
|
||||
/// Bumped whenever a target is made: bindings of the old ones are stale.
|
||||
generation: u64,
|
||||
fallback_shadow: wgpu::TextureView,
|
||||
fallback_color: wgpu::TextureView,
|
||||
pub shadow_view: Mat4,
|
||||
pub shadow_proj: Mat4,
|
||||
pub shadow_distance: f32,
|
||||
pub shadow_resolution: u32,
|
||||
pub shadow_active: bool,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
@@ -88,76 +225,482 @@ impl Post {
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
let nearest = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Horizon post nearest"),
|
||||
mag_filter: wgpu::FilterMode::Nearest,
|
||||
min_filter: wgpu::FilterMode::Nearest,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let gbuffer_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
source: wgpu::ShaderSource::Wgsl(GBUFFER_WGSL.into()),
|
||||
});
|
||||
let gbuffer_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon gbuffer layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 3,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let gbuffer_pipe_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
bind_group_layouts: &[Some(&gbuffer_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let gbuffer_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
layout: Some(&gbuffer_pipe_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &gbuffer_shader,
|
||||
entry_point: Some("vs_gbuffer"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &gbuffer_shader,
|
||||
entry_point: Some("fs_gbuffer"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let gbuffer_uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon gbuffer uniform"),
|
||||
size: std::mem::size_of::<GbufferUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let fallback_shadow = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon fallback shadow"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
let fallback_color = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon fallback gbuffer"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
Self {
|
||||
copy: pipeline("fs_copy"),
|
||||
fxaa: pipeline("fs_fxaa"),
|
||||
layout,
|
||||
sampler,
|
||||
nearest,
|
||||
gbuffer_layout,
|
||||
gbuffer_pipeline,
|
||||
gbuffer_uniform,
|
||||
format,
|
||||
scale: 1.0,
|
||||
antialias: false,
|
||||
target: None,
|
||||
shadow: None,
|
||||
generation: 0,
|
||||
fallback_shadow,
|
||||
fallback_color,
|
||||
shadow_view: Mat4::IDENTITY,
|
||||
shadow_proj: Mat4::IDENTITY,
|
||||
shadow_distance: 128.0,
|
||||
shadow_resolution: 2048,
|
||||
shadow_active: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The world's colour and depth targets this frame, or `None` to draw
|
||||
/// it straight to the screen. `pack`: a shader pack runs this frame.
|
||||
/// it straight to the screen. `pack`: a shader pack runs this frame,
|
||||
/// reading `extras`.
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
window: [u32; 2],
|
||||
pack: bool,
|
||||
extras: Extras,
|
||||
) -> Option<(wgpu::TextureView, wgpu::TextureView)> {
|
||||
if self.scale == 1.0 && !self.antialias && !pack {
|
||||
self.target = None;
|
||||
self.shadow_active = false;
|
||||
return None;
|
||||
}
|
||||
let size = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
if self.target.as_ref().is_none_or(|t| t.size != size) {
|
||||
if self
|
||||
.target
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size != size || t.extras != extras)
|
||||
{
|
||||
self.generation += 1;
|
||||
let extent = wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let texture = |label, format, usage| {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: extent,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
let make_tex = |label, format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: extent,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let color = texture(
|
||||
let color = make_tex(
|
||||
"Scaled world",
|
||||
self.format,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
);
|
||||
let depth = texture(
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let depth_tex = make_tex(
|
||||
"Scaled world depth",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let depth = depth_tex.create_view(&Default::default());
|
||||
let opaque_depth = (extras.opaque_depth || extras.gbuffer).then(|| {
|
||||
let tex = make_tex(
|
||||
"Opaque world depth",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
);
|
||||
let view = tex.create_view(&Default::default());
|
||||
(tex, view)
|
||||
});
|
||||
let gbuffer = opaque_depth
|
||||
.as_ref()
|
||||
.filter(|_| extras.gbuffer)
|
||||
.map(|(_, opaque)| {
|
||||
let color_target = |label| {
|
||||
make_tex(
|
||||
label,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
)
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
layout: &self.gbuffer_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&color),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&depth),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(opaque),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::Sampler(&self.nearest),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: self.gbuffer_uniform.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
Gbuffer {
|
||||
normal: color_target("World G-buffer normal"),
|
||||
material: color_target("World G-buffer material"),
|
||||
group,
|
||||
}
|
||||
});
|
||||
let group = self.bind(device, &color);
|
||||
self.target = Some(WorldTarget {
|
||||
size,
|
||||
extras,
|
||||
color,
|
||||
depth_tex,
|
||||
depth,
|
||||
opaque_depth,
|
||||
gbuffer,
|
||||
group,
|
||||
opaque_captured: false,
|
||||
});
|
||||
}
|
||||
if let Some(t) = &mut self.target {
|
||||
t.opaque_captured = false;
|
||||
}
|
||||
self.target
|
||||
.as_ref()
|
||||
.map(|t| (t.color.clone(), t.depth.clone()))
|
||||
}
|
||||
|
||||
/// Prepare the directional sun/moon shadow map target and compute its
|
||||
/// camera-relative orthographic `shadow_view` and `shadow_proj` matrices.
|
||||
pub fn prepare_shadow(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
resolution: u32,
|
||||
distance: f32,
|
||||
sun_dir: Vec3,
|
||||
moon_dir: Vec3,
|
||||
) -> &wgpu::TextureView {
|
||||
let resolution = resolution.clamp(256, 4096);
|
||||
let distance = distance.clamp(16.0, 512.0);
|
||||
if self
|
||||
.shadow
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.resolution != resolution)
|
||||
{
|
||||
let depth = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon shadow map"),
|
||||
size: wgpu::Extent3d {
|
||||
width: resolution,
|
||||
height: resolution,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
self.shadow = Some(ShadowTarget { resolution, depth });
|
||||
self.generation += 1;
|
||||
}
|
||||
let light_dir = if sun_dir.y >= -0.05 {
|
||||
sun_dir.normalize_or_zero()
|
||||
} else {
|
||||
moon_dir.normalize_or_zero()
|
||||
};
|
||||
let dir = if light_dir.length_squared() > 1e-4 {
|
||||
light_dir
|
||||
} else {
|
||||
Vec3::Y
|
||||
};
|
||||
let up = if dir.y.abs() > 0.99 { Vec3::Z } else { Vec3::Y };
|
||||
self.shadow_view = Mat4::look_at_rh(dir * distance, Vec3::ZERO, up);
|
||||
// Reversed-depth orthographic projection for the world's GreaterEqual
|
||||
// pipelines (near maps to 1.0, far maps to 0.0), and standard 0..1
|
||||
// projection for shader sampling.
|
||||
self.shadow_proj = Mat4::orthographic_rh(
|
||||
-distance,
|
||||
distance,
|
||||
-distance,
|
||||
distance,
|
||||
distance * 2.5,
|
||||
0.05,
|
||||
);
|
||||
self.shadow_distance = distance;
|
||||
self.shadow_resolution = resolution;
|
||||
self.shadow_active = true;
|
||||
&self.shadow.as_ref().expect("created above").depth
|
||||
}
|
||||
|
||||
/// Snapshot `world_depth` into `opaque_depth` (`depthtex1`/`depthtex2`)
|
||||
/// right before translucent water and weather draw.
|
||||
pub fn capture_opaque_depth(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(t) = &mut self.target else { return };
|
||||
let Some((opaque, _)) = &t.opaque_depth else {
|
||||
return;
|
||||
};
|
||||
encoder.copy_texture_to_texture(
|
||||
t.depth_tex.as_image_copy(),
|
||||
opaque.as_image_copy(),
|
||||
t.depth_tex.size(),
|
||||
);
|
||||
t.opaque_captured = true;
|
||||
}
|
||||
|
||||
/// Reconstruct the G-buffer `normal` and `material` textures after the
|
||||
/// world pass finishes (with `Extras::gbuffer`).
|
||||
pub fn build_gbuffer(
|
||||
&mut self,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &FrameUniform,
|
||||
eye_light: [u8; 2],
|
||||
wetness: f32,
|
||||
) {
|
||||
if !self.target.as_ref().is_some_and(|t| t.opaque_captured) {
|
||||
self.capture_opaque_depth(encoder);
|
||||
}
|
||||
let Some(t) = &self.target else { return };
|
||||
let Some(gbuffer) = &t.gbuffer else { return };
|
||||
let [w, h] = t.size.map(|v| v.max(1) as f32);
|
||||
let uniform = GbufferUniform {
|
||||
inverse_view: frame.inverse_view,
|
||||
inverse_projection: frame.inverse_projection,
|
||||
resolution: [w, h, 1.0 / w, 1.0 / h],
|
||||
env: [
|
||||
f32::from(eye_light[0]) / 15.0,
|
||||
f32::from(eye_light[1]) / 15.0,
|
||||
wetness,
|
||||
frame.sun[1],
|
||||
],
|
||||
};
|
||||
queue.write_buffer(&self.gbuffer_uniform, 0, bytemuck::bytes_of(&uniform));
|
||||
let attachments = [
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view: &gbuffer.normal,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
}),
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view: &gbuffer.material,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
}),
|
||||
];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.gbuffer_pipeline);
|
||||
pass.set_bind_group(0, &gbuffer.group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// The world target's size, once `prepare` made one.
|
||||
pub fn size(&self) -> Option<[u32; 2]> {
|
||||
self.target.as_ref().map(|t| t.size)
|
||||
}
|
||||
|
||||
/// Changes whenever `prepare` or `prepare_shadow` makes new targets:
|
||||
/// bindings made from the views below must be made again.
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
/// The depth before translucent geometry (`capture_opaque_depth`), or
|
||||
/// the world's depth when nothing reads it.
|
||||
pub fn opaque_depth(&self) -> &wgpu::TextureView {
|
||||
match &self.target {
|
||||
Some(WorldTarget {
|
||||
opaque_depth: Some((_, view)),
|
||||
..
|
||||
}) => view,
|
||||
Some(t) => &t.depth,
|
||||
None => &self.fallback_shadow,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstructed world-space normals (`scene_normal`).
|
||||
pub fn normal(&self) -> &wgpu::TextureView {
|
||||
self.gbuffer().map_or(&self.fallback_color, |g| &g.normal)
|
||||
}
|
||||
|
||||
/// Reconstructed material and light (`scene_material`).
|
||||
pub fn material(&self) -> &wgpu::TextureView {
|
||||
self.gbuffer().map_or(&self.fallback_color, |g| &g.material)
|
||||
}
|
||||
|
||||
fn gbuffer(&self) -> Option<&Gbuffer> {
|
||||
self.target.as_ref()?.gbuffer.as_ref()
|
||||
}
|
||||
|
||||
/// The sun/moon shadow map (`shadow_depth` of api 2 packs).
|
||||
pub fn shadow_depth(&self) -> &wgpu::TextureView {
|
||||
self.shadow
|
||||
.as_ref()
|
||||
.map_or(&self.fallback_shadow, |s| &s.depth)
|
||||
}
|
||||
|
||||
/// Bindings that copy (or anti-alias) `view` onto the screen in `finish_with`.
|
||||
pub fn bind(&self, device: &wgpu::Device, view: &wgpu::TextureView) -> wgpu::BindGroup {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
@@ -175,11 +718,13 @@ impl Post {
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// Draw the scaled world onto `screen`.
|
||||
pub fn finish(&self, encoder: &mut wgpu::CommandEncoder, screen: &wgpu::TextureView) {
|
||||
let Some(target) = &self.target else { return };
|
||||
self.finish_with(encoder, screen, &target.group);
|
||||
}
|
||||
|
||||
/// Draw what `group` binds (`bind`) onto `screen`.
|
||||
pub fn finish_with(
|
||||
&self,
|
||||
|
||||
@@ -7,7 +7,6 @@ use std::collections::BTreeMap;
|
||||
|
||||
/// `ReputationEventType` amounts.
|
||||
pub const REPUTATION_CHANGE_PER_EVENT: i32 = 25;
|
||||
pub const REPUTATION_CHANGE_PER_EVERLASTING_MEMORY: i32 = 20;
|
||||
pub const REPUTATION_CHANGE_PER_TRADE: i32 = 2;
|
||||
/// `GossipContainer.DISCARD_THRESHOLD`: values below this are forgotten.
|
||||
const DISCARD_THRESHOLD: i32 = 2;
|
||||
|
||||
@@ -156,6 +156,48 @@ pub enum Overlay {
|
||||
Graph,
|
||||
}
|
||||
|
||||
/// How finely far terrain is drawn (`FarDetail` in the LOD options).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum FarDetail {
|
||||
/// Each tile level starts at eight times its size from the camera.
|
||||
#[default]
|
||||
Full,
|
||||
/// Coarser levels start at half that distance: about a quarter of the
|
||||
/// far quads, for GPUs that far terrain slows down. Changes the picture.
|
||||
Reduced,
|
||||
}
|
||||
impl FarDetail {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
FarDetail::Full => "full",
|
||||
FarDetail::Reduced => "reduced",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FarDetail::Full => "Full",
|
||||
FarDetail::Reduced => "Reduced",
|
||||
}
|
||||
}
|
||||
fn parse(s: &str) -> Self {
|
||||
if s == "reduced" {
|
||||
FarDetail::Reduced
|
||||
} else {
|
||||
FarDetail::Full
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static REDUCED_SUGGESTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the settings suggest Reduced far detail: set when the renderer
|
||||
/// starts, on the GPUs `lod_capped` names (far terrain slows them most).
|
||||
pub fn suggest_reduced_far_detail(on: bool) {
|
||||
REDUCED_SUGGESTED.store(on, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
pub fn reduced_far_detail_suggested() -> bool {
|
||||
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
|
||||
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
|
||||
/// costs them more frame time than it is worth.
|
||||
@@ -181,6 +223,8 @@ pub struct Graphics {
|
||||
pub preset: Preset,
|
||||
/// Distant surface terrain range, in chunks; 0 disables LODs.
|
||||
pub lod_distance: u32,
|
||||
/// How finely far terrain is drawn; Full unless the player picks Reduced.
|
||||
pub far_detail: FarDetail,
|
||||
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
|
||||
pub cave_culling: bool,
|
||||
/// Hide shared leaf and mangrove-root faces, reducing foliage density.
|
||||
@@ -219,6 +263,7 @@ impl Default for Graphics {
|
||||
Self {
|
||||
preset: Preset::Custom,
|
||||
lod_distance: 0,
|
||||
far_detail: FarDetail::Full,
|
||||
cave_culling: true,
|
||||
cull_leaves: false,
|
||||
entity_culling: false,
|
||||
@@ -265,6 +310,7 @@ impl Graphics {
|
||||
match key.trim() {
|
||||
"preset" => g.preset = Preset::parse(value),
|
||||
"lodDistance" => g.lod_distance = number(g.lod_distance).min(1024),
|
||||
"farDetail" => g.far_detail = FarDetail::parse(value),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"cullLeaves" => g.cull_leaves = flag(g.cull_leaves),
|
||||
"entityCulling" => g.entity_culling = flag(g.entity_culling),
|
||||
@@ -302,9 +348,10 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\ncaveCulling:{}\ncullLeaves:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\nshaderPack:{}\ngraphicsApi:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\ncullLeaves:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\nshaderPack:{}\ngraphicsApi:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
bool_text(self.cave_culling),
|
||||
bool_text(self.cull_leaves),
|
||||
bool_text(self.entity_culling),
|
||||
@@ -405,6 +452,7 @@ mod tests {
|
||||
let g = Graphics {
|
||||
preset: Preset::Balanced,
|
||||
lod_distance: 256,
|
||||
far_detail: FarDetail::Reduced,
|
||||
cave_culling: false,
|
||||
cull_leaves: true,
|
||||
entity_culling: true,
|
||||
@@ -487,6 +535,24 @@ mod tests {
|
||||
assert_eq!(Graphics::parse("lodDistance:invalid\n").lod_distance, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn far_detail_is_full_unless_reduced_is_picked() {
|
||||
assert_eq!(Graphics::default().far_detail, FarDetail::Full);
|
||||
assert_eq!(
|
||||
Graphics::parse("farDetail:reduced\n").far_detail,
|
||||
FarDetail::Reduced
|
||||
);
|
||||
for text in ["farDetail:full\n", "farDetail:other\n", ""] {
|
||||
assert_eq!(Graphics::parse(text).far_detail, FarDetail::Full);
|
||||
}
|
||||
// No preset changes it.
|
||||
for preset in Preset::ALL {
|
||||
let mut s = crate::settings::Settings::default();
|
||||
apply_preset(preset, &mut s);
|
||||
assert_eq!(s.horizon.far_detail, FarDetail::Full, "{preset:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_workers_leave_cores_for_the_render_and_server_threads() {
|
||||
let g = Graphics::default();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! they show, in what groups, with what description and cost, and their
|
||||
//! input. How they look comes from `graphics_style`.
|
||||
use crate::{
|
||||
graphics::{GraphicsApi, Overlay, Pacing, PresentMode, Preset},
|
||||
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset},
|
||||
graphics_style::{Cost, STYLE},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
options_ui::Opt,
|
||||
@@ -22,6 +22,7 @@ pub enum Level {
|
||||
pub enum H {
|
||||
Preset,
|
||||
LodDistance,
|
||||
FarDetail,
|
||||
CaveCulling,
|
||||
CullLeaves,
|
||||
EntityCulling,
|
||||
@@ -85,6 +86,16 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
Medium,
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.",
|
||||
);
|
||||
// Suggested, never switched on, where far terrain costs the GPU most.
|
||||
let far = h(
|
||||
H::FarDetail,
|
||||
Medium,
|
||||
if crate::graphics::reduced_far_detail_suggested() {
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU."
|
||||
} else {
|
||||
"Reduced draws far terrain coarser, for GPUs it slows down."
|
||||
},
|
||||
);
|
||||
let fov = v(
|
||||
Opt::Fov,
|
||||
Low,
|
||||
@@ -101,17 +112,23 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
"Waits for the display's refresh: no tearing, a little latency.",
|
||||
);
|
||||
let gui = v(Opt::GuiScale, None, "Size of menus and the HUD.");
|
||||
let shader_pack = h(
|
||||
H::ShaderPack,
|
||||
High,
|
||||
"Redraws the world with a pack from the shaderpacks folder. Off is vanilla.",
|
||||
);
|
||||
let preset = h(
|
||||
H::Preset,
|
||||
None,
|
||||
"Sets the options below together. Changing one makes it Custom.",
|
||||
);
|
||||
let mut rows = match level {
|
||||
Level::Simple => vec![preset, render, lod, fov, fps, vsync, gui],
|
||||
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui, shader_pack],
|
||||
Level::Advanced => vec![
|
||||
Row::Header("Rendering"),
|
||||
render,
|
||||
lod,
|
||||
far,
|
||||
v(
|
||||
Opt::SimulationDistance,
|
||||
Medium,
|
||||
@@ -234,11 +251,7 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
None,
|
||||
"Lowers the frame rate when minimised, or idle too.",
|
||||
),
|
||||
h(
|
||||
H::ShaderPack,
|
||||
High,
|
||||
"Redraws the world with a pack from the shaderpacks folder. Off is vanilla.",
|
||||
),
|
||||
shader_pack,
|
||||
Row::Header("Preferences"),
|
||||
v(Opt::ViewBobbing, None, "Sways the view while walking."),
|
||||
v(Opt::ChunkFade, None, "Fades newly loaded chunks in."),
|
||||
@@ -335,6 +348,7 @@ impl H {
|
||||
0 => labelled("LOD Distance", "Off"),
|
||||
n => labelled("LOD Distance", format!("{n} chunks")),
|
||||
},
|
||||
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
H::CullLeaves => labelled("Cull Leaves", on_off(g.cull_leaves)),
|
||||
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
|
||||
@@ -411,6 +425,9 @@ impl H {
|
||||
H::LodDistance => {
|
||||
g.lod_distance = step(&[0, 64, 128, 256, 512, 1024], g.lod_distance, back)
|
||||
}
|
||||
H::FarDetail => {
|
||||
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
|
||||
}
|
||||
H::CaveCulling => g.cave_culling = !g.cave_culling,
|
||||
H::CullLeaves => g.cull_leaves = !g.cull_leaves,
|
||||
H::EntityCulling => g.entity_culling = !g.entity_culling,
|
||||
@@ -469,14 +486,6 @@ impl Item {
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// Caption and value text, split from the label ("Caption: Value").
|
||||
pub fn caption_value(self, s: &Settings) -> (String, String) {
|
||||
let label = self.label(s).plain();
|
||||
match label.split_once(": ") {
|
||||
Some((c, v)) => (c.to_owned(), v.to_owned()),
|
||||
None => (label, String::new()),
|
||||
}
|
||||
}
|
||||
pub fn slider(self, s: &Settings) -> Option<f32> {
|
||||
match self {
|
||||
Item::Vanilla(o) => o.slider_position(s),
|
||||
|
||||
@@ -9,6 +9,11 @@ use std::{
|
||||
};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
/// A sprite's atlas region, and its size in GUI pixels: the size of the
|
||||
/// default resources' texture of that name, whatever the resolution of the
|
||||
/// pack that replaced it (vanilla's GUI code draws in those units: `blit`
|
||||
/// divides by the texture size it is given, sprites by their `gui.scaling`
|
||||
/// size). Textures the default resources lack keep their own size.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Rect {
|
||||
pub u0: f32,
|
||||
@@ -18,11 +23,44 @@ pub struct Rect {
|
||||
pub w: u32,
|
||||
pub h: u32,
|
||||
}
|
||||
impl Rect {
|
||||
/// The region's size in texels of an atlas this large.
|
||||
pub fn texels(&self, (width, height): (u32, u32)) -> (u32, u32) {
|
||||
(
|
||||
((self.u1 - self.u0) * width as f32).round() as u32,
|
||||
((self.v1 - self.v0) * height as f32).round() as u32,
|
||||
)
|
||||
}
|
||||
}
|
||||
/// `TextureMetadataSection`'s `blur` and `clamp`: how vanilla samples a
|
||||
/// standalone texture (`ReloadableTexture.setSampler`: linear rather than
|
||||
/// nearest filtering, clamp-to-edge rather than repeat addressing).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct TextureFlags {
|
||||
pub blur: bool,
|
||||
pub clamp: bool,
|
||||
}
|
||||
impl TextureFlags {
|
||||
/// The `texture` section of a `.png.mcmeta` file, if it sets either flag.
|
||||
pub fn from_mcmeta(json: &str) -> Option<Self> {
|
||||
let meta: serde_json::Value = serde_json::from_str(json).ok()?;
|
||||
let section = meta.get("texture")?;
|
||||
let flag = |key| section.get(key).and_then(serde_json::Value::as_bool) == Some(true);
|
||||
let flags = Self {
|
||||
blur: flag("blur"),
|
||||
clamp: flag("clamp"),
|
||||
};
|
||||
(flags != Self::default()).then_some(flags)
|
||||
}
|
||||
}
|
||||
pub struct HudAtlas {
|
||||
pub pixels: Vec<u8>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub sprites: HashMap<String, Rect>,
|
||||
/// Sprites whose `.png.mcmeta` sets `blur` or `clamp`; the others
|
||||
/// sample as vanilla's defaults (nearest, repeat).
|
||||
pub flags: HashMap<String, TextureFlags>,
|
||||
pub fire_animations: Vec<(Rect, crate::sprites::SpriteAnimation)>,
|
||||
/// The default font (bitmap sheets in this atlas, unifont rasterized on demand).
|
||||
pub font: crate::font::Font,
|
||||
@@ -30,6 +68,9 @@ pub struct HudAtlas {
|
||||
}
|
||||
/// Atlas rows reserved for glyphs rasterized at run time.
|
||||
pub const GLYPH_ROWS: u32 = 512;
|
||||
/// The largest atlas side: the 2D texture limit of the GPUs Horizon runs on
|
||||
/// (Apple silicon, RADV and Intel allow 16384; NVIDIA 32768).
|
||||
pub const MAX_ATLAS_SIZE: u32 = 16384;
|
||||
/// 128x128 atlas slots for map textures. A lobby paints its signs as walls of
|
||||
/// framed maps, so the pool holds a wall; too few slots re-upload every map
|
||||
/// every frame. 26.3's `MapTextureManager` keeps a texture per map instead.
|
||||
@@ -40,6 +81,9 @@ pub const MINIMAP_SPRITE: &str = "horizon/minimap";
|
||||
pub const SKIN_SLOTS: usize = 32;
|
||||
/// Reused by the visible rows of the classic world selector.
|
||||
pub const WORLD_ICON_SLOTS: usize = 32;
|
||||
/// Atlas slots for armour trim textures made at run time (`trims`), each the
|
||||
/// size of the largest trim texture.
|
||||
pub const TRIM_SLOTS: usize = 32;
|
||||
pub fn decode(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
decode_bytes(&fs::read(path)?)
|
||||
}
|
||||
@@ -62,6 +106,15 @@ pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
|
||||
}
|
||||
Ok((info.width, info.height, rgba))
|
||||
}
|
||||
/// A PNG's width and height, from its header alone.
|
||||
fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
use std::io::Read;
|
||||
let mut header = [0u8; 24];
|
||||
fs::File::open(path).ok()?.read_exact(&mut header).ok()?;
|
||||
let field = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
|
||||
(header[..8] == *b"\x89PNG\r\n\x1a\n" && header[12..16] == *b"IHDR")
|
||||
.then(|| (field(16), field(20)))
|
||||
}
|
||||
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
@@ -77,6 +130,20 @@ fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
}
|
||||
/// A packed atlas image: sprite name, size and RGBA pixels.
|
||||
type PackedImage = (String, u32, u32, Vec<u8>);
|
||||
/// `image` inside a one-texel border copied from its opposite edges, the
|
||||
/// texels repeat addressing reads past each edge.
|
||||
fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
|
||||
let (bw, bh) = (w + 2, h + 2);
|
||||
let mut out = Vec::with_capacity((bw * bh * 4) as usize);
|
||||
for y in 0..bh {
|
||||
let row = (y + h - 1) % h;
|
||||
for x in 0..bw {
|
||||
let at = ((row * w + (x + w - 1) % w) * 4) as usize;
|
||||
out.extend_from_slice(&data[at..at + 4]);
|
||||
}
|
||||
}
|
||||
(name.clone(), bw, bh, out)
|
||||
}
|
||||
/// Sprites the atlas definitions (`atlases/*.json`) add beyond the texture
|
||||
/// files already packed: generated ones (`paletted_permutations` such as the
|
||||
/// armour trim sprites, `unstitch` regions) and files listed under another id.
|
||||
@@ -160,8 +227,14 @@ impl HudAtlas {
|
||||
(g.rect, g.advance)
|
||||
}
|
||||
pub fn build(root: &Path) -> Result<Self> {
|
||||
Self::build_over(root, root)
|
||||
}
|
||||
/// The atlas of a resource root that layers packs over `defaults` (the
|
||||
/// game's own resources), whose textures give the sprites' GUI sizes.
|
||||
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
|
||||
Self::pack(
|
||||
root,
|
||||
defaults,
|
||||
&[
|
||||
"gui",
|
||||
"item",
|
||||
@@ -180,7 +253,7 @@ impl HudAtlas {
|
||||
}
|
||||
/// Empty slots written at run time: map textures (game/maps.rs), the
|
||||
/// minimap, downloaded skins, world icons and the on-screen keyboard's text.
|
||||
fn slots(images: &mut Vec<PackedImage>) {
|
||||
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32)) {
|
||||
for i in 0..MAP_SLOTS {
|
||||
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
|
||||
}
|
||||
@@ -195,6 +268,14 @@ impl HudAtlas {
|
||||
for i in 0..SKIN_SLOTS {
|
||||
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
|
||||
}
|
||||
for i in 0..TRIM_SLOTS {
|
||||
images.push((
|
||||
format!("horizon/trim{i}"),
|
||||
trim.0,
|
||||
trim.1,
|
||||
vec![0; (trim.0 * trim.1 * 4) as usize],
|
||||
));
|
||||
}
|
||||
for i in 0..WORLD_ICON_SLOTS {
|
||||
images.push((
|
||||
format!("horizon/world-icon{i}"),
|
||||
@@ -216,14 +297,53 @@ impl HudAtlas {
|
||||
/// holding its textures. The pixels are dropped once the glyph widths are
|
||||
/// read, since nothing uploads them.
|
||||
pub fn fonts(root: &Path) -> Result<Self> {
|
||||
let mut atlas = Self::pack(root, &["font"], false)?;
|
||||
let mut atlas = Self::pack(root, root, &["font"], false)?;
|
||||
atlas.pixels = Vec::new();
|
||||
Ok(atlas)
|
||||
}
|
||||
/// Shelf placements of `images` (tallest first) in the narrowest atlas
|
||||
/// width from 4096 up that keeps the atlas, glyph rows included, within
|
||||
/// `max` texels a side; and the first row free for glyphs. High-resolution
|
||||
/// packs fill a 4096-wide atlas past what GPUs allow (Faithful 64x needs
|
||||
/// 32768 rows); vanilla's stitcher fails the reload when its atlases don't
|
||||
/// fit, and the packs are then turned off (`Minecraft.clearResourcePacksOnError`).
|
||||
fn layout(images: &[PackedImage], max: u32) -> Result<(u32, Vec<(u32, u32)>, u32)> {
|
||||
let mut width = 4096.min(max);
|
||||
loop {
|
||||
let mut placements = Vec::with_capacity(images.len());
|
||||
let (mut x, mut y, mut row) = (0u32, 0u32, 0u32);
|
||||
for (_, w, h, _) in images {
|
||||
if x + w + 1 > width {
|
||||
x = 0;
|
||||
y += row + 1;
|
||||
row = 0;
|
||||
}
|
||||
placements.push((x, y));
|
||||
x += w + 1;
|
||||
row = row.max(*h);
|
||||
}
|
||||
let glyph_region = y + row + 1;
|
||||
let wide_enough = images.iter().all(|i| i.1 < width);
|
||||
if wide_enough && glyph_region + GLYPH_ROWS <= max {
|
||||
return Ok((width, placements, glyph_region));
|
||||
}
|
||||
if width >= max {
|
||||
let texels: u64 = images.iter().map(|i| u64::from(i.1) * u64::from(i.2)).sum();
|
||||
return Err(format!(
|
||||
"the textures ({} images, {texels} texels) do not fit a {max}x{max} atlas",
|
||||
images.len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
width = (width * 2).min(max);
|
||||
}
|
||||
}
|
||||
/// Pack the textures under these folders of `textures/`, and for the
|
||||
/// `full` atlas the generated sprites and the run-time slots too.
|
||||
fn pack(root: &Path, folders: &[&str], full: bool) -> Result<Self> {
|
||||
fn pack(root: &Path, defaults: &Path, folders: &[&str], full: bool) -> Result<Self> {
|
||||
let textures = root.join("assets/minecraft/textures");
|
||||
let default_textures =
|
||||
(defaults != root).then(|| defaults.join("assets/minecraft/textures"));
|
||||
let mut files = Vec::new();
|
||||
for folder in folders {
|
||||
walk(&textures.join(folder), &mut files);
|
||||
@@ -232,7 +352,8 @@ impl HudAtlas {
|
||||
let s = p.to_string_lossy();
|
||||
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
|
||||
});
|
||||
let decode_one = |path: &PathBuf| -> Option<(String, u32, u32, Vec<u8>)> {
|
||||
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>);
|
||||
let decode_one = |path: &PathBuf| -> Option<Decoded> {
|
||||
let name = path
|
||||
.strip_prefix(&textures)
|
||||
.ok()?
|
||||
@@ -240,18 +361,33 @@ impl HudAtlas {
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let (w, mut h, pixels) = decode(path).ok()?;
|
||||
let is_frame_strip = (name.starts_with("block/") || name.starts_with("item/")) && h > w;
|
||||
let pixels = if is_frame_strip {
|
||||
// Animated strips keep their first frame.
|
||||
let frames = name.starts_with("block/") || name.starts_with("item/");
|
||||
let pixels = if frames && h > w {
|
||||
h = w;
|
||||
pixels[..(w * w * 4) as usize].to_vec()
|
||||
} else {
|
||||
pixels
|
||||
};
|
||||
(w <= 1024 && (h <= 512 || name.starts_with("font/"))).then_some((name, w, h, pixels))
|
||||
// The default texture's size, when a pack's differs from it.
|
||||
let gui_size = default_textures
|
||||
.as_ref()
|
||||
.and_then(|d| png_size(&d.join(format!("{name}.png"))))
|
||||
.map(|(dw, dh)| (dw, if frames { dh.min(dw) } else { dh }))
|
||||
.filter(|&size| size != (w, h));
|
||||
let flags = fs::read_to_string(path.with_extension("png.mcmeta"))
|
||||
.ok()
|
||||
.and_then(|json| TextureFlags::from_mcmeta(&json));
|
||||
let (gw, gh) = gui_size.unwrap_or((w, h));
|
||||
(gw <= 1024 && (gh <= 512 || name.starts_with("font/"))).then_some((
|
||||
(name, w, h, pixels),
|
||||
flags,
|
||||
gui_size,
|
||||
))
|
||||
};
|
||||
let threads = std::thread::available_parallelism().map_or(2, |n| n.get());
|
||||
let chunk = files.len().div_ceil(threads).max(1);
|
||||
let mut images: Vec<_> = std::thread::scope(|scope| {
|
||||
let decoded: Vec<_> = std::thread::scope(|scope| {
|
||||
let handles: Vec<_> = files
|
||||
.chunks(chunk)
|
||||
.map(|part| {
|
||||
@@ -263,6 +399,18 @@ impl HudAtlas {
|
||||
.flat_map(|h| h.join().unwrap_or_default())
|
||||
.collect()
|
||||
});
|
||||
let mut flags = HashMap::new();
|
||||
let mut gui_sizes = HashMap::new();
|
||||
let mut images: Vec<PackedImage> = Vec::with_capacity(decoded.len());
|
||||
for (image, image_flags, gui_size) in decoded {
|
||||
if let Some(f) = image_flags {
|
||||
flags.insert(image.0.clone(), f);
|
||||
}
|
||||
if let Some(size) = gui_size {
|
||||
gui_sizes.insert(image.0.clone(), size);
|
||||
}
|
||||
images.push(image);
|
||||
}
|
||||
if full {
|
||||
images.extend(atlas_sprites(root, &images));
|
||||
}
|
||||
@@ -277,24 +425,23 @@ impl HudAtlas {
|
||||
}
|
||||
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
|
||||
if full {
|
||||
Self::slots(&mut images);
|
||||
Self::slots(&mut images, crate::trims::slot_size(root));
|
||||
}
|
||||
// Blurred sprites are sampled linearly in the atlas (screen.wgsl): a
|
||||
// border wrapped from their own edges keeps the filter off their
|
||||
// neighbours, as repeat addressing of their own texture would.
|
||||
let mut bordered = std::collections::HashSet::new();
|
||||
for image in &mut images {
|
||||
if flags.get(&image.0).is_some_and(|f: &TextureFlags| f.blur) {
|
||||
*image = wrap_border(image);
|
||||
bordered.insert(image.0.clone());
|
||||
}
|
||||
}
|
||||
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
|
||||
let width = 4096u32;
|
||||
let mut placements = Vec::with_capacity(images.len());
|
||||
let (mut x, mut y, mut row) = (0u32, 0u32, 0u32);
|
||||
for (_, w, h, _) in &images {
|
||||
if x + w + 1 > width {
|
||||
x = 0;
|
||||
y += row + 1;
|
||||
row = 0;
|
||||
}
|
||||
placements.push((x, y));
|
||||
x += w + 1;
|
||||
row = row.max(*h);
|
||||
}
|
||||
let glyph_region = y + row + 1;
|
||||
let height = (glyph_region + GLYPH_ROWS).next_power_of_two();
|
||||
let (width, placements, glyph_region) = Self::layout(&images, MAX_ATLAS_SIZE)?;
|
||||
// A multiple of 1024 rather than a power of two: the default resources
|
||||
// need 4096x5120 (8192 rows before), Faithful 32x 4096x12288 (16384).
|
||||
let height = (glyph_region + GLYPH_ROWS).next_multiple_of(1024);
|
||||
let mut pixels = vec![0u8; (width * height * 4) as usize];
|
||||
let mut sprites = HashMap::new();
|
||||
for ((name, w, h, data), (px, py)) in images.iter().zip(placements) {
|
||||
@@ -303,6 +450,9 @@ impl HudAtlas {
|
||||
let dst = (((py + row) * width + px) * 4) as usize;
|
||||
pixels[dst..dst + (w * 4) as usize].copy_from_slice(src);
|
||||
}
|
||||
let b = u32::from(bordered.contains(name));
|
||||
let (px, py, w, h) = (px + b, py + b, w - 2 * b, h - 2 * b);
|
||||
let (gw, gh) = gui_sizes.get(name).copied().unwrap_or((w, h));
|
||||
sprites.insert(
|
||||
name.clone(),
|
||||
Rect {
|
||||
@@ -310,8 +460,8 @@ impl HudAtlas {
|
||||
v0: py as f32 / height as f32,
|
||||
u1: (px + w) as f32 / width as f32,
|
||||
v1: (py + h) as f32 / height as f32,
|
||||
w: *w,
|
||||
h: *h,
|
||||
w: gw,
|
||||
h: gh,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -331,14 +481,15 @@ impl HudAtlas {
|
||||
.find(|a| a.layer == layer)
|
||||
.cloned()
|
||||
{
|
||||
let image = animation.resized_pixels(animation.frame(0), rect.w, rect.h);
|
||||
let (tw, th) = rect.texels((width, height));
|
||||
let image = animation.resized_pixels(animation.frame(0), tw, th);
|
||||
let x = (rect.u0 * width as f32).round() as usize;
|
||||
let y = (rect.v0 * height as f32).round() as usize;
|
||||
for row in 0..rect.h as usize {
|
||||
let row_bytes = tw as usize * 4;
|
||||
for row in 0..th as usize {
|
||||
let dst = ((y + row) * width as usize + x) * 4;
|
||||
let src = row * rect.w as usize * 4;
|
||||
pixels[dst..dst + rect.w as usize * 4]
|
||||
.copy_from_slice(&image[src..src + rect.w as usize * 4]);
|
||||
let src = row * row_bytes;
|
||||
pixels[dst..dst + row_bytes].copy_from_slice(&image[src..src + row_bytes]);
|
||||
}
|
||||
fire_animations.push((rect, animation));
|
||||
}
|
||||
@@ -358,6 +509,7 @@ impl HudAtlas {
|
||||
width,
|
||||
height,
|
||||
sprites,
|
||||
flags,
|
||||
fire_animations,
|
||||
font,
|
||||
white,
|
||||
@@ -384,6 +536,8 @@ pub struct HudBatch {
|
||||
pub patches: Vec<crate::font::Patch>,
|
||||
/// Vertex ranges with another blend or depth/scissor state, in order.
|
||||
pub blends: Vec<(std::ops::Range<u32>, Blend)>,
|
||||
/// The textures and time of the `Blend::EndPortal` runs.
|
||||
pub end_portal: EndPortalFill,
|
||||
/// Screen's first vertex: the preceding HUD is part of its blurred background.
|
||||
pub blur_at: u32,
|
||||
}
|
||||
@@ -398,6 +552,19 @@ pub enum Blend {
|
||||
Additive,
|
||||
/// Alpha blending with an isolated depth buffer and a pixel scissor.
|
||||
Depth([u32; 4]),
|
||||
/// `RenderPipelines.VIGNETTE`: the destination darkened by the texture
|
||||
/// (`ZERO, ONE_MINUS_SRC_COLOR`).
|
||||
Vignette,
|
||||
/// `RenderPipelines.END_PORTAL` as a GUI fill: the End portal's layers
|
||||
/// projected from the screen (`HudBatch::end_portal` sets the textures).
|
||||
EndPortal,
|
||||
}
|
||||
/// What a `Blend::EndPortal` fill samples: the atlas rects (u0, v0, width,
|
||||
/// height) of `end_sky.png` and `end_portal.png`, and the `GameTime` uniform.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct EndPortalFill {
|
||||
pub sprites: [[f32; 4]; 2],
|
||||
pub game_time: f32,
|
||||
}
|
||||
pub fn rgba(r: u8, g: u8, b: u8, a: u8) -> u32 {
|
||||
u32::from(a) << 24 | u32::from(b) << 16 | u32::from(g) << 8 | u32::from(r)
|
||||
@@ -421,6 +588,7 @@ impl HudBatch {
|
||||
height: (window[1] as f32 / scale as f32).ceil(),
|
||||
patches: Vec::new(),
|
||||
blends: Vec::new(),
|
||||
end_portal: EndPortalFill::default(),
|
||||
blur_at: 0,
|
||||
}
|
||||
}
|
||||
@@ -693,24 +861,6 @@ impl HudBatch {
|
||||
};
|
||||
self.text_styled(atlas, &t, x, y, color, shadow)
|
||||
}
|
||||
/// `text_styled` under a pose: GUI positions scaled by `scale`, then
|
||||
/// moved to `origin` (Matrix3x2fStack translate + scale).
|
||||
pub fn text_posed(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
text: &crate::text::Text,
|
||||
at: [f32; 2],
|
||||
color: u32,
|
||||
scale: f32,
|
||||
origin: [f32; 2],
|
||||
) -> f32 {
|
||||
let start = self.vertices.len();
|
||||
let advance = self.text_styled(atlas, text, at[0], at[1], color, true);
|
||||
for v in &mut self.vertices[start..] {
|
||||
v.pos = [origin[0] + v.pos[0] * scale, origin[1] + v.pos[1] * scale];
|
||||
}
|
||||
advance
|
||||
}
|
||||
/// Font.drawInternal: styled glyphs, shadows first, with bold, italic,
|
||||
/// underline and strikethrough. `color` is the default colour and alpha.
|
||||
pub fn text_styled(
|
||||
@@ -727,7 +877,13 @@ impl HudBatch {
|
||||
let mut glyphs = Vec::new();
|
||||
let mut cursor = x;
|
||||
for (i, (ch, style)) in text.chars().enumerate() {
|
||||
let g = font.glyph(ch);
|
||||
// The style's font (`Style.getFont`), the default one unless set.
|
||||
let mut g = font.glyph_styled(ch, style);
|
||||
// Font.StringRenderOutput.accept: obfuscated characters draw a
|
||||
// random glyph of the same width each frame.
|
||||
if style.obfuscated == Some(true) && ch != ' ' {
|
||||
g = font.random_glyph(style, g.advance);
|
||||
}
|
||||
let c = style.color.map_or(color, |rgb| {
|
||||
rgba((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, alpha)
|
||||
});
|
||||
@@ -821,6 +977,20 @@ impl HudBatch {
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct HudUniform {
|
||||
size: [f32; 4],
|
||||
/// `EndPortalFill`: the two sprites, then the game time in x.
|
||||
end_portal: [[f32; 4]; 3],
|
||||
}
|
||||
impl HudUniform {
|
||||
fn new(size: [f32; 4], fill: EndPortalFill) -> Self {
|
||||
Self {
|
||||
size,
|
||||
end_portal: [
|
||||
fill.sprites[0],
|
||||
fill.sprites[1],
|
||||
[fill.game_time, 0.0, 0.0, 0.0],
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
pub struct HudRenderer {
|
||||
pub fire_animations: Vec<(Rect, crate::sprites::SpriteAnimation, usize)>,
|
||||
@@ -831,6 +1001,8 @@ pub struct HudRenderer {
|
||||
invert_pipeline: wgpu::RenderPipeline,
|
||||
additive_pipeline: wgpu::RenderPipeline,
|
||||
depth_pipeline: wgpu::RenderPipeline,
|
||||
vignette_pipeline: wgpu::RenderPipeline,
|
||||
end_portal_pipeline: wgpu::RenderPipeline,
|
||||
group: wgpu::BindGroup,
|
||||
uniform: wgpu::Buffer,
|
||||
buffer: wgpu::Buffer,
|
||||
@@ -853,7 +1025,7 @@ impl HudRenderer {
|
||||
});
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("HUD size"),
|
||||
contents: bytemuck::bytes_of(&HudUniform { size: [1.0; 4] }),
|
||||
contents: bytemuck::bytes_of(&HudUniform::new([1.0; 4], EndPortalFill::default())),
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -861,7 +1033,7 @@ impl HudRenderer {
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
@@ -989,6 +1161,31 @@ impl HudRenderer {
|
||||
"fs_main",
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.VIGNETTE: ZERO, ONE_MINUS_SRC_COLOR, ZERO, ONE.
|
||||
let vignette_pipeline = create(
|
||||
"HUD vignette",
|
||||
wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::OneMinusSrc,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
},
|
||||
"fs_main",
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.END_PORTAL: opaque.
|
||||
let end_portal_pipeline = create(
|
||||
"HUD end portal",
|
||||
wgpu::BlendState::REPLACE,
|
||||
"fs_end_portal",
|
||||
false,
|
||||
);
|
||||
let capacity = 1 << 16;
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("HUD vertices"),
|
||||
@@ -1003,6 +1200,8 @@ impl HudRenderer {
|
||||
invert_pipeline,
|
||||
additive_pipeline,
|
||||
depth_pipeline,
|
||||
vignette_pipeline,
|
||||
end_portal_pipeline,
|
||||
group,
|
||||
uniform,
|
||||
buffer,
|
||||
@@ -1023,7 +1222,8 @@ impl HudRenderer {
|
||||
if frame == *previous {
|
||||
continue;
|
||||
}
|
||||
let pixels = animation.resized_pixels(frame, rect.w, rect.h);
|
||||
let (w, h) = rect.texels((texture.width(), texture.height()));
|
||||
let pixels = animation.resized_pixels(frame, w, h);
|
||||
uploads.texture(
|
||||
encoder,
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
@@ -1036,8 +1236,8 @@ impl HudRenderer {
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
rect.w,
|
||||
rect.h,
|
||||
w,
|
||||
h,
|
||||
&pixels,
|
||||
);
|
||||
*previous = frame;
|
||||
@@ -1096,9 +1296,10 @@ impl HudRenderer {
|
||||
encoder,
|
||||
&self.uniform,
|
||||
0,
|
||||
bytemuck::bytes_of(&HudUniform {
|
||||
size: [size[0] as f32, size[1] as f32, batch.scale, 0.0],
|
||||
}),
|
||||
bytemuck::bytes_of(&HudUniform::new(
|
||||
[size[0] as f32, size[1] as f32, batch.scale, 0.0],
|
||||
batch.end_portal,
|
||||
)),
|
||||
);
|
||||
}
|
||||
/// Draw the batch `prepare` staged.
|
||||
@@ -1186,6 +1387,8 @@ impl HudRenderer {
|
||||
Blend::Invert => &self.invert_pipeline,
|
||||
Blend::Additive => &self.additive_pipeline,
|
||||
Blend::Depth(_) => &self.depth_pipeline,
|
||||
Blend::Vignette => &self.vignette_pipeline,
|
||||
Blend::EndPortal => &self.end_portal_pipeline,
|
||||
});
|
||||
pass.draw(range.clone(), 0..1);
|
||||
}
|
||||
@@ -1198,10 +1401,115 @@ impl HudRenderer {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `count` images of `w x h`; layout reads only their sizes.
|
||||
fn sized(count: usize, w: u32, h: u32) -> Vec<PackedImage> {
|
||||
(0..count)
|
||||
.map(|i| (format!("i{i}"), w, h, Vec::new()))
|
||||
.collect()
|
||||
}
|
||||
fn assert_inside(images: &[PackedImage], width: u32, placements: &[(u32, u32)], free: u32) {
|
||||
for ((_, w, h, _), (x, y)) in images.iter().zip(placements) {
|
||||
assert!(
|
||||
x + w <= width && y + h < free,
|
||||
"{x},{y} {w}x{h} outside {width}x{free}"
|
||||
);
|
||||
}
|
||||
}
|
||||
fn write_png(root: &Path, name: &str, w: u32, h: u32) {
|
||||
let path = root.join(format!("assets/minecraft/textures/{name}.png"));
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), w, h);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let pixels = vec![255; (w * h * 4) as usize];
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn the_fonts_only_atlas_measures_text_like_the_full_one() {
|
||||
// A synthetic default font: two 8x8 glyphs, 3 and 5 pixels wide.
|
||||
let root = std::env::temp_dir().join(format!("horizon-fonts-atlas-{}", std::process::id()));
|
||||
fn a_pack_texture_keeps_the_default_textures_gui_size() {
|
||||
let temp = std::env::temp_dir().join(format!("horizon-gui-size-{}", std::process::id()));
|
||||
let (defaults, pack) = (temp.join("defaults"), temp.join("pack"));
|
||||
write_png(&defaults, "gui/container/inventory", 256, 256);
|
||||
write_png(&defaults, "gui/sprites/widget/button", 200, 20);
|
||||
write_png(&defaults, "block/stone", 16, 16);
|
||||
write_png(&defaults, "item/compass", 16, 512);
|
||||
// Faithful 64x's inventory (past the 512 rows a texture may have),
|
||||
// 32x's button and stone, an animated strip, and a texture of its own.
|
||||
write_png(&pack, "gui/container/inventory", 1024, 1024);
|
||||
write_png(&pack, "gui/sprites/widget/button", 400, 40);
|
||||
write_png(&pack, "block/stone", 32, 32);
|
||||
write_png(&pack, "item/compass", 32, 1024);
|
||||
write_png(&pack, "gui/sprites/pack_only", 30, 10);
|
||||
let atlas = HudAtlas::build_over(&pack, &defaults).unwrap();
|
||||
let _ = fs::remove_dir_all(&temp);
|
||||
let size = (atlas.width, atlas.height);
|
||||
for (name, gui, texels) in [
|
||||
("gui/container/inventory", (256, 256), (1024, 1024)),
|
||||
("gui/sprites/widget/button", (200, 20), (400, 40)),
|
||||
("block/stone", (16, 16), (32, 32)),
|
||||
("item/compass", (16, 16), (32, 32)),
|
||||
("gui/sprites/pack_only", (30, 10), (30, 10)),
|
||||
] {
|
||||
let r = atlas.sprites[name];
|
||||
assert_eq!(((r.w, r.h), r.texels(size)), (gui, texels), "{name}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn the_default_textures_keep_a_4096_wide_atlas() {
|
||||
let images = sized(400, 64, 64);
|
||||
let (width, placements, free) = HudAtlas::layout(&images, MAX_ATLAS_SIZE).unwrap();
|
||||
assert_eq!(width, 4096);
|
||||
assert_inside(&images, width, &placements, free);
|
||||
}
|
||||
#[test]
|
||||
fn a_high_resolution_pack_widens_the_atlas_to_fit_the_gpu() {
|
||||
// 34 rows of three 1024x512 GUI textures: 17442 rows at 4096 wide,
|
||||
// past the 16384 GPUs allow (Faithful 64x needed 32768).
|
||||
let images = sized(100, 1024, 512);
|
||||
let (width, placements, free) = HudAtlas::layout(&images, MAX_ATLAS_SIZE).unwrap();
|
||||
assert_eq!(width, 8192);
|
||||
assert!(free + GLYPH_ROWS <= MAX_ATLAS_SIZE);
|
||||
assert_inside(&images, width, &placements, free);
|
||||
}
|
||||
#[test]
|
||||
fn textures_that_cannot_fit_are_an_error_not_a_gpu_panic() {
|
||||
let error = HudAtlas::layout(&sized(300, 1024, 1024), MAX_ATLAS_SIZE).unwrap_err();
|
||||
assert!(error.to_string().contains("do not fit a 16384x16384 atlas"));
|
||||
// One image wider than the largest atlas.
|
||||
assert!(HudAtlas::layout(&sized(1, 2048, 16), 2048).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrapped_border_repeats_the_opposite_edges() {
|
||||
// 2x2 texels a b / c d, one byte each in red.
|
||||
let image = (
|
||||
"x".to_owned(),
|
||||
2,
|
||||
2,
|
||||
[1u8, 2, 3, 4]
|
||||
.iter()
|
||||
.flat_map(|&v| [v, 0, 0, 255])
|
||||
.collect(),
|
||||
);
|
||||
let (_, w, h, data) = wrap_border(&image);
|
||||
assert_eq!((w, h), (4, 4));
|
||||
let red: Vec<u8> = data.chunks(4).map(|t| t[0]).collect();
|
||||
#[rustfmt::skip]
|
||||
assert_eq!(red, [
|
||||
4, 3, 4, 3,
|
||||
2, 1, 2, 1,
|
||||
4, 3, 4, 3,
|
||||
2, 1, 2, 1,
|
||||
]);
|
||||
}
|
||||
|
||||
/// A synthetic default font: two 8x8 glyphs, 3 and 5 pixels wide.
|
||||
fn two_glyph_font(name: &str) -> std::path::PathBuf {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-fonts-{name}-{}", std::process::id()));
|
||||
let font = root.join("assets/minecraft/font");
|
||||
let textures = root.join("assets/minecraft/textures/font");
|
||||
fs::create_dir_all(&font).unwrap();
|
||||
@@ -1226,6 +1534,11 @@ mod tests {
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
root
|
||||
}
|
||||
#[test]
|
||||
fn the_fonts_only_atlas_measures_text_like_the_full_one() {
|
||||
let root = two_glyph_font("atlas");
|
||||
let full = HudAtlas::build(&root).unwrap();
|
||||
let fonts = HudAtlas::fonts(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
@@ -1234,6 +1547,21 @@ mod tests {
|
||||
assert!(width > 0.0);
|
||||
assert_eq!(fonts.font.width("abba"), width);
|
||||
}
|
||||
#[test]
|
||||
fn obfuscated_glyphs_keep_the_width() {
|
||||
let root = two_glyph_font("obfuscated");
|
||||
let fonts = HudAtlas::fonts(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let (a, b) = (fonts.font.glyph('a').advance, fonts.font.glyph('b').advance);
|
||||
assert_ne!(a, b);
|
||||
for _ in 0..20 {
|
||||
assert_eq!(fonts.font.random_glyph(&Default::default(), a).advance, a);
|
||||
assert_eq!(fonts.font.random_glyph(&Default::default(), b).advance, b);
|
||||
}
|
||||
// No glyph that wide: FontSet's missing glyph.
|
||||
let missing = fonts.font.random_glyph(&Default::default(), 30.0);
|
||||
assert!(missing.rect.is_none() && missing.right == 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_icons_are_painted_from_the_mob_effect_directory() {
|
||||
@@ -1418,6 +1746,7 @@ mod tests {
|
||||
height: height as f32,
|
||||
patches: Vec::new(),
|
||||
blends: Vec::new(),
|
||||
end_portal: EndPortalFill::default(),
|
||||
blur_at: 0,
|
||||
};
|
||||
for (image_width, image_height) in [(195, 136), (176, 166), (176, 222)] {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
struct Hud { size: vec4<f32> }
|
||||
struct Hud {
|
||||
size: vec4<f32>,
|
||||
// Blend::EndPortal: atlas rects (u0, v0, width, height) of end_sky.png and
|
||||
// end_portal.png, then vanilla's GameTime in x.
|
||||
end_portal: array<vec4<f32>, 3>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> hud: Hud;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@group(0) @binding(2) var atlas_sampler: sampler;
|
||||
@@ -6,6 +11,8 @@ struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
// projection_from_position(gl_Position): the End portal's texProj0.
|
||||
@location(2) proj: vec4<f32>,
|
||||
}
|
||||
@vertex fn vs_main(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>, @location(2) color: vec4<f32>, @location(3) depth: f32) -> Out {
|
||||
let pixels = pos * hud.size.z;
|
||||
@@ -13,6 +20,7 @@ struct Out {
|
||||
out.clip = vec4(pixels.x / hud.size.x * 2.0 - 1.0, 1.0 - pixels.y / hud.size.y * 2.0, depth, 1.0);
|
||||
out.uv = uv;
|
||||
out.color = color;
|
||||
out.proj = vec4(out.clip.xy * 0.5 + 0.5, out.clip.zw);
|
||||
return out;
|
||||
}
|
||||
@fragment fn fs_main(in: Out) -> @location(0) vec4<f32> {
|
||||
@@ -31,3 +39,44 @@ struct Out {
|
||||
if c.a == 0.0 { discard; }
|
||||
return c;
|
||||
}
|
||||
// rendertype_end_portal.fsh (entity.wgsl has the same for portal blocks).
|
||||
const PORTAL_COLORS = array<vec3<f32>, 16>(
|
||||
vec3(0.022087, 0.098399, 0.110818),
|
||||
vec3(0.011892, 0.095924, 0.089485),
|
||||
vec3(0.027636, 0.101689, 0.100326),
|
||||
vec3(0.046564, 0.109883, 0.114838),
|
||||
vec3(0.064901, 0.117696, 0.097189),
|
||||
vec3(0.063761, 0.086895, 0.123646),
|
||||
vec3(0.084817, 0.111994, 0.166380),
|
||||
vec3(0.097489, 0.154120, 0.091064),
|
||||
vec3(0.106152, 0.131144, 0.195191),
|
||||
vec3(0.097721, 0.110188, 0.187229),
|
||||
vec3(0.133516, 0.138278, 0.148582),
|
||||
vec3(0.070006, 0.243332, 0.235792),
|
||||
vec3(0.196766, 0.142899, 0.214696),
|
||||
vec3(0.047281, 0.315338, 0.321970),
|
||||
vec3(0.204675, 0.390010, 0.302066),
|
||||
vec3(0.080955, 0.314821, 0.661491),
|
||||
);
|
||||
fn portal_sample(rect: vec4<f32>, p: vec4<f32>) -> vec3<f32> {
|
||||
let st = fract(p.xy / p.w);
|
||||
return textureSampleLevel(atlas, atlas_sampler, rect.xy + st * rect.zw, 0.0).rgb;
|
||||
}
|
||||
fn portal_layer(p: vec4<f32>, layer: f32) -> vec4<f32> {
|
||||
let a = radians((layer * layer * 4321.0 + layer * 9.0) * 2.0);
|
||||
let c = cos(a);
|
||||
let s = sin(a);
|
||||
let k = (4.5 - layer / 4.0) * 2.0;
|
||||
var v = vec4(k * (c * p.x - s * p.y), k * (s * p.x + c * p.y), p.z, p.w);
|
||||
v = vec4(v.x + 17.0 / layer * v.w, v.y + (2.0 + layer / 1.5) * (hud.end_portal[2].x * 1.5) * v.w, v.z, v.w);
|
||||
return vec4(0.5 * v.x + 0.25 * v.w, 0.5 * v.y + 0.25 * v.w, v.z, v.w);
|
||||
}
|
||||
// RenderPipelines.END_PORTAL (WinScreen's poem background): end_sky and 15
|
||||
// layers of end_portal.png.
|
||||
@fragment fn fs_end_portal(in: Out) -> @location(0) vec4<f32> {
|
||||
var c = portal_sample(hud.end_portal[0], in.proj) * PORTAL_COLORS[0];
|
||||
for (var i = 0; i < 15; i++) {
|
||||
c += portal_sample(hud.end_portal[1], portal_layer(in.proj, f32(i + 1))) * PORTAL_COLORS[i];
|
||||
}
|
||||
return vec4(c, 1.0);
|
||||
}
|
||||
@@ -124,17 +124,6 @@ impl ItemRenderState {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.layers.is_empty()
|
||||
}
|
||||
/// `pickParticleMaterial`: a random layer's particle sprite.
|
||||
pub fn pick_particle(
|
||||
&self,
|
||||
random: &mut impl horizon_core::random::RandomSource,
|
||||
) -> Option<String> {
|
||||
if self.layers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let i = random.next_int_bound(self.layers.len() as i32).unwrap_or(0) as usize;
|
||||
self.layers.get(i).map(|l| l.particle.clone())
|
||||
}
|
||||
/// `usesBlockLight` of the first layer (GUI lighting choice).
|
||||
pub fn uses_block_light(&self) -> bool {
|
||||
self.layers.first().is_some_and(|l| l.uses_block_light)
|
||||
|
||||
@@ -23,17 +23,36 @@ pub struct JavaFolder {
|
||||
/// `begin*`, `prepare*`, `deferred*`, `composite*`, in order.
|
||||
pub passes: Vec<Program>,
|
||||
/// How many of `passes` run before the world (`begin*`, `prepare*`): the
|
||||
/// world's colour and depth enter `colortex0` and `depthtex*` after them.
|
||||
/// world's colour and depth enter `world_target` and `depthtex*` after them.
|
||||
pub world_at: usize,
|
||||
/// The colour target the world goes into, where the pack's terrain
|
||||
/// program draws (MakeUp: colortex1).
|
||||
pub world_target: u32,
|
||||
/// The targets whose alternate side the world is written to.
|
||||
pub flipped_after_prepare: BTreeSet<u32>,
|
||||
pub final_pass: Option<Program>,
|
||||
/// The targets whose alternate side holds the frame's last write.
|
||||
pub flipped_at_end: BTreeSet<u32>,
|
||||
/// `sunPathRotation` and `eyeBrightnessHalflife`.
|
||||
/// `sunPathRotation`, `eyeBrightnessHalflife`, `wetnessHalflife` and
|
||||
/// `drynessHalflife`.
|
||||
pub directives: Directives,
|
||||
}
|
||||
|
||||
/// The key of `texture.noise` (`noisetex`) in [`JavaPasses::custom_textures`].
|
||||
pub const NOISE_KEY: &str = "noise";
|
||||
|
||||
/// The key of `texture.<stage>.<sampler>` in [`JavaPasses::custom_textures`]
|
||||
/// for a pass of `program` (`world0/composite2`): Iris's texture stages, where
|
||||
/// `final` reads the `composite` ones.
|
||||
pub fn texture_key(program: &str, sampler: &str) -> String {
|
||||
let name = program.rsplit('/').next().unwrap_or(program);
|
||||
let stage = match name.trim_end_matches(|c: char| c.is_ascii_digit()) {
|
||||
"final" => "composite",
|
||||
stage => stage,
|
||||
};
|
||||
format!("{stage}.{sampler}")
|
||||
}
|
||||
|
||||
/// A loaded Java pack.
|
||||
pub struct JavaPasses {
|
||||
pub folders: Vec<JavaFolder>,
|
||||
@@ -41,6 +60,9 @@ pub struct JavaPasses {
|
||||
pub uniforms: CustomUniforms,
|
||||
/// What the pack keeps of the vanilla sky (`clouds`, `sun`, `moon`).
|
||||
pub sky: SkyDirectives,
|
||||
/// `texture.<stage>.<sampler>` and `texture.noise` images, by
|
||||
/// `"<stage>.<sampler>"` and [`NOISE_KEY`].
|
||||
pub custom_textures: BTreeMap<String, crate::shader_pack::Rgba>,
|
||||
}
|
||||
|
||||
impl JavaPasses {
|
||||
@@ -126,6 +148,7 @@ pub fn load(
|
||||
folder: name.to_owned(),
|
||||
targets: plan.targets,
|
||||
world_at: early.count(),
|
||||
world_target: plan.world_target,
|
||||
passes,
|
||||
flipped_after_prepare: plan.flipped_after_prepare,
|
||||
final_pass,
|
||||
@@ -136,6 +159,27 @@ pub fn load(
|
||||
if folders.is_empty() {
|
||||
return Err("no deferred, composite or final programs".into());
|
||||
}
|
||||
let mut custom_textures = BTreeMap::new();
|
||||
for tex in properties.custom_textures() {
|
||||
let key = match tex.stage {
|
||||
Some(stage) => format!("{stage}.{}", tex.sampler),
|
||||
None if tex.sampler == NOISE_KEY => NOISE_KEY.to_owned(),
|
||||
None => continue,
|
||||
};
|
||||
// Only PNGs for now: resource locations and raw `.dat` textures are
|
||||
// skipped and the sampler reads what it would without them.
|
||||
let image = pack
|
||||
.files
|
||||
.read_bytes(&tex.path)
|
||||
.ok_or_else(|| "not in the pack".to_owned())
|
||||
.and_then(|bytes| crate::shader_pack::decode_png(&bytes));
|
||||
match image {
|
||||
Ok(image) => {
|
||||
custom_textures.insert(key, image);
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: shader pack texture {}: {e}", tex.path),
|
||||
}
|
||||
}
|
||||
let uniforms = properties.custom_uniforms();
|
||||
for problem in &uniforms.problems {
|
||||
log::warn!("Horizon: shader pack custom uniform {problem}");
|
||||
@@ -144,6 +188,7 @@ pub fn load(
|
||||
folders,
|
||||
uniforms,
|
||||
sky: properties.sky(),
|
||||
custom_textures,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -151,6 +196,17 @@ pub fn load(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn custom_textures_belong_to_iris_s_stages() {
|
||||
assert_eq!(texture_key("world0/composite2", "gaux1"), "composite.gaux1");
|
||||
assert_eq!(texture_key("final", "colortex4"), "composite.colortex4");
|
||||
assert_eq!(
|
||||
texture_key("world-1/deferred10", "noisetex"),
|
||||
"deferred.noisetex"
|
||||
);
|
||||
assert_eq!(texture_key("prepare", "depthtex2"), "prepare.depthtex2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_the_full_screen_passes_of_a_java_pack() {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -180,6 +236,24 @@ mod tests {
|
||||
assert_eq!(vendor_name(0, "Apple M4 Pro"), "Apple");
|
||||
}
|
||||
|
||||
/// wgpu is given the WGSL text: it must parse again.
|
||||
fn reparse_error(pack: &JavaPasses) -> Option<String> {
|
||||
let passes = pack
|
||||
.folders
|
||||
.iter()
|
||||
.flat_map(|f| f.passes.iter().chain(&f.final_pass));
|
||||
passes
|
||||
.flat_map(|p| [(p, &p.vertex, "vsh"), (p, &p.fragment, "fsh")])
|
||||
.find_map(|(p, t, ext)| {
|
||||
let e = naga::front::wgsl::parse_str(&t.wgsl).err()?;
|
||||
Some(format!(
|
||||
"{}.{ext}: {}",
|
||||
p.pass.program,
|
||||
e.emit_to_string(&t.wgsl)
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The packs in `HORIZON_TEST_SHADERPACKS` (folders or zips) load as the
|
||||
/// game loads them, with their default options: the compatibility table
|
||||
/// of docs/shader-packs.md. Skipped when the variable isn't set.
|
||||
@@ -199,6 +273,10 @@ mod tests {
|
||||
for path in entries {
|
||||
let name = path.file_name().unwrap().to_string_lossy().into_owned();
|
||||
match load(&path, &BTreeMap::new(), "Apple", "Apple M4 Pro") {
|
||||
Ok(pack) if let Some(e) = reparse_error(&pack) => {
|
||||
println!("{name}: {e}");
|
||||
failures.push(name);
|
||||
}
|
||||
Ok(pack) => {
|
||||
let folders: Vec<String> = pack
|
||||
.folders
|
||||
|
||||
Loaded 100 of 283 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user