Compare commits

..
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 8307404dc3 Launcher: prebuilt native builds off unless turned on; hash the archive before reading it
Security review of #11941 (#11859):
- `native_prebuilt` unset now means never: players opt in with `settings native-prebuilt on` or
  `native-build --prebuilt auto` until the registry's signing pipeline (horizon-mods#381) has run
  end to end and been checked. Turning it on by default is its own PR.
- The archive is capped at 512 MiB (refused from its Content-Length, capped while streaming),
  hashed from disk in pieces and verified before it is read into memory; the bytes unpacked are
  hashed again so they are the ones the signature covers.
- A failed download also clears native/prebuilt/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:51:39 +01:00
Horizon agentandClaude Opus 5.5 6f19499acf Launcher: download the registry's prebuilt build of an instance's native mods (#11859)
Before compiling an instance's mods, native_mods::build looks for the registry's signed build
of exactly that set (native_prebuilt): every turned-on mod must be a registry file the index
lists by SHA-256 and none revoked; the request (target, the base's commit, each mod's id,
version and SHA-256) names the build in horizon-mods' release native-builds-<commit12>.

The archive is checked before anything is written: signed by a key in native-build-keys.txt
for this key and target, holding only horizon-client, native-mods.json and request.txt, its
request this one, its record naming no other mod. It installs as a native build does
(native-<commit>-<key12>, the base's folder with the program), reused when already there.
No build, or one that fails a check: it compiles as before, the row says so, and the log keeps
the reason.

`native-build --prebuilt auto|never|only` and `settings native-prebuilt on|off`; GitHub or
folder mods, a dev profile, forced pins or a FOLDER always compile. Two new strings in all 86
languages; docs/native-mods.md "Prebuilt builds".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:16:24 +01:00
Horizon agentandClaude Opus 5.5 2325ba52d4 Launcher: native-build --prebuilt-out, the registry CI's side of prebuilt builds (#11859)
`horizon-launcher native-build --prebuilt-out DIR --version <Nightly> FOLDER` builds every
.hnmod in FOLDER through the player's own pipeline (pins, hook points, left-out mods), but
needs no prepared game files and installs nothing: it writes the program, its
native-mods.json and request.txt into DIR. request.txt is the build's request
(horizon_release_sign::native_build): target, the base's commit, each mod's id, version and
file SHA-256, so the registry's workflow and the launcher name a build alike. A set with a
post-build step is refused: the step runs on the player's game files.

native_mods: pipeline takes an export folder; the .hnmod unpacking is shared with
installed_mods; build_target names the Rust target of a build.

Measured on the desktop: zoom + minimap on Nightly 6560641, release profile, -j 4: 8 min,
request key equal to the one horizon-mods' plan computes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:53:51 +01:00
1743 changed files with 41407 additions and 240056 deletions

No files matched your search

+2 -14
View File
@@ -2,8 +2,7 @@
## What and why
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). Over about 500 lines: link the issue with its
approved spec too ("Spec: #123"), CONTRIBUTING.md "Large pull requests". -->
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). -->
## Kind
@@ -17,16 +16,6 @@
- [ ] tooling: tools, CI, tests, harnesses
- [ ] docs
## Layer
<!-- engine, platform or Horizon (docs/layers.md, "Where a new feature goes"). Reviewers check:
- the new code sits in a crate or module of that layer;
- no new dependency its layer may not use (CI's layer check): no engine code calling platform or Horizon code,
and Horizon code using the platform's public API only, never the engine directly;
- Horizon features in the game are behind `horizon-extras`, and platform features that change the vanilla
look are off by default;
- no new built-in server address outside the Horizon layer. -->
## Vanilla reference
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
@@ -36,8 +25,7 @@
<!-- "none", or for each project you read or copied from: its link at the commit, its license (only MIT,
BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense or CC0-1.0), and what you took.
CONTRIBUTING.md "Only original code". Shader packs: also the sections of docs/cleanroom/shader-packs-spec.md,
the documentation pages and the packs you used. -->
CONTRIBUTING.md "Only original code". -->
## Self-check
+1 -1
View File
@@ -13,4 +13,4 @@
87152661 rdbtCVS # added by Ludovic, 2026-10-27
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
169198624 itarqos5 # added by Ludovic, 2026-11-08 (Discord Literal; Horizon Team)
87478350 Qwerner88880 # added by Ludovic, 2026-10-31 (Discord vladislav_hagen; native-mod hooks)
-15
View File
@@ -78,21 +78,6 @@ jobs:
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
rustup default stable
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
# docs/layers.md: a crate depends only on its own layer or a lower one; today's exceptions only shrink.
- name: Crates follow the layers
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: python3 tools/check_layers.py ${BASE_SHA:+--base "$BASE_SHA"}
# docs/layers.md#built-in-addresses: every built-in outside address in the Horizon layer's home; the rest only shrinks.
- name: Built-in addresses stay in their home
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: python3 tools/check_addresses.py ${BASE_SHA:+--base "$BASE_SHA"}
# docs/layers.md#file-size: a Rust file over 3000 lines may not grow (pull requests; main has no base to compare).
- name: Big files don't grow
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: python3 tools/check_file_size.py ${BASE_SHA:+--base "$BASE_SHA"}
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
- name: Install Rust toolchain and the ALSA and udev headers the client links against
-5
View File
@@ -291,11 +291,6 @@ jobs:
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
# Windows x64: Microsoft's DXC, pinned and checksummed, which DirectX 12 uses on integrated GPUs
# (gfx/dx12_compiler.rs), with its licence. ARM64 keeps FXC (THIRD_PARTY_NOTICES.md says why).
if [[ "${{ matrix.name }}" == windows ]]; then
python tools/ci/fetch_dxc.py "dist/${{ matrix.dir }}"
fi
cd dist
rm -f "${{ matrix.asset }}"
case "${{ matrix.asset }}" in
+11 -17
View File
@@ -25,8 +25,6 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
`Co-Authored-By: Claude <model> <[email protected]>`, last line
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- **Layers** ([docs/layers.md](docs/layers.md)): engine < platform < Horizon; a crate depends only on its own layer or a
lower one (`tools/check_layers.py`, crate layers in `tools/layers.json`). New Horizon code goes behind `horizon-extras`.
- 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.
@@ -63,7 +61,6 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
| `horizon-anticheat` | The dedicated server's anticheat, off by default: `[anticheat]` settings, violation levels, the checks' rules; the hub runs them (`lan/anticheat.rs`) ([docs/anticheat.md](docs/anticheat.md)) |
| `horizon-cosmetics` | Cosmetic items Horizon clients draw on players (off by default): the item format, bones and animations ([docs/cosmetics.md](docs/cosmetics.md)) |
| `horizon-addresses` | Every built-in address of an outside service vanilla doesn't contact (Horizon layer; [docs/layers.md](docs/layers.md#built-in-addresses)) |
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
@@ -73,8 +70,6 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
| `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-system` | The machine the game runs on (OS, CPU, memory, drive, power, GPU maker and memory), for the crash report, the renderer's GPU info and telemetry |
| `horizon-platform` | The platform's public API: what the Horizon layer may use of the engine ([docs/layers.md](docs/layers.md)); re-exports `horizon-system` today |
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
@@ -106,7 +101,6 @@ python3 tools/parity.py --changed-files paths.txt --list # which harnesses cov
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
python3 tools/lang_coverage.py --summary # the keys each language still misses, and its English leftovers
```
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
@@ -158,23 +152,23 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| Subsystem | Files |
|---|---|
| Game loop, tick, frame | `game.rs`: `fn run`, `fn tick`, `fn tick_world`, `fn render`; G: `window_handler.rs` (`impl ApplicationHandler for Game`: `window_event`, `about_to_wait`), `launch_options.rs` (the command line); L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.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`, `frame_geometry.rs` (cracks, sun and moon, first-person hand, rain and snow), `item_draw.rs`, `screen_backgrounds.rs` (panorama, menu blur, border wall) |
| 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/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `world_entry.rs`, `postprocess.rs`, `world_update.rs` (`fn update_world`, far terrain) |
| 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`, `spans.rs`; `horizon-client-input/src/window.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/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `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`); G: `block_ticks.rs` (`fn tick_blocks`); game.rs `fn process_level_events` |
| 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: `item_entities.rs` (dropped items, experience orbs), `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
| Player actions | game.rs (`fn tick_mining`, `fn break_block`); G: `use_hand.rs` (`fn use_hand`), `item_actions.rs` (`fn use_item`), `melee.rs` (`fn attack`), `hand.rs`, `combat.rs`, `consume.rs`, `using_item.rs` (an item in use, tick by tick), `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.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`; G: `menu_clicks.rs` (`fn menu_ops`, `fn click_inventory`), `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs`, `inventory_input.rs` (keys and clicks over a container), `inventory_slots.rs` (slot layout and drawing) |
| 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`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.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: crate `horizon-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`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.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`, `settings_shell.rs` (Horizon's settings screens on it: `extras_ui.rs`, `graphics_ui.rs`, `controller_ui.rs`, `shader_ui.rs`; a page list too long for its column collapses into one row), `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `hud_kit/` (Horizon overlays' `Room` and `Plate`, the collapsed page strip `tabs` the shell's page list uses, [docs/ui-kit.md](docs/ui-kit.md)), `pad_glyph.rs` (controller glyphs, by what the button does), `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `resource_loader.rs` (what a start loads, `run_loading`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.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`, `horizon-anticheat` (L: `lan/anticheat.rs`) |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities), `world_session.rs` (opening and closing a world); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pad_craft.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.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`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.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`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `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)) |
@@ -197,7 +191,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
`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, their methods in `game/resource_tables.rs`.
(`pack`), both in game.rs.
## Single player runs on a server thread
+2 -37
View File
@@ -38,16 +38,10 @@ and compares their outputs.
- **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode:
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`.
Say in your pull request which vanilla classes and methods you followed.
- **One version: 26.3.** Parity work targets 26.3 until its parity is good, redstone first. Don't open pull requests
that move Horizon to a newer Minecraft version for now; to join servers on a newer version, put a Via proxy
(ViaVersion, ViaProxy) in front of them.
- **Gameplay changes come with a harness**, new or extended, so the next change can't silently break them.
[The parity workflow](docs/parity-workflow.md) shows how harnesses run; copy a nearby one.
- **Horizon's own features** (the launcher, the new menus) follow [the design system](docs/design-system.md) and are
off by default wherever they would change the vanilla look.
- **Three layers: engine, platform, Horizon.** Vanilla code never depends on the platform (mods, shader packs,
Horizon-only settings) or on Horizon (cosmetics, the shop, official servers, telemetry), and the platform never
depends on Horizon. [docs/layers.md](docs/layers.md) says where a new feature goes; CI checks the dependencies.
- **Everything players run is Rust.** Python and Java are fine for dev tools and harness drivers in `tools/`, never
for player-facing code.
- **No game files in the repo.** Never commit game files, decompiled source or prepared resources (`.local/`):
@@ -67,10 +61,6 @@ another project's source may also be read and used, **only when that project's l
"source available" licenses, all rights reserved (OptiFine), and any project with no license or an unclear one.
Don't read, copy, translate or paraphrase their source, and don't send code an AI tool wrote from it. Being able
to download a mod or a pack does not give you the right to read its files.
- **Java's own library is GPL too.** OpenJDK's source (`java.lang.Math`, `StrictMath`, `FdLibm`, `HashMap`, the
sorts, `Double.toString`, HotSpot's intrinsics...) is GPL-2.0, so it is off limits like the rest. Reproduce Java's
behaviour from its API documentation (the Javadoc) and from what a JVM returns, or from a permissive original such
as Sun's fdlibm C sources (see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)); see #6741.
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one at
your commit counts. In doubt, don't: ask in #contributors first.
@@ -82,18 +72,7 @@ another project's source may also be read and used, **only when that project's l
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever their
license. Shader packs are a clean-room rewrite of the pack format, produced from a written description of it (see
#1561): the [specification and its provenance log](docs/cleanroom/README.md) are public. They carry no foreign
source, and the rules above still apply to anything you read while working on them. A shader-pack pull request
names its sources: the sections of that specification, the documentation pages and the packs it ran.
## Vendor libraries and Java mods
- **Upscalers and latency tools.** AMD FSR (MIT) may go in the repository, like any code under an allowed license.
NVIDIA's DLSS, Reflex and any other library under an NVIDIA license may not: they ship only as an optional native mod
that downloads NVIDIA's files itself, on the player's machine. No NVIDIA binaries, headers or SDK files in the
repository.
- **The JVM bridge** (running Java mods) is only ever a separate, optional download, never installed or on by
default. The game may offer to turn it on when it finds a Java mod; the player decides.
#1561); they carry no foreign source, and the rules above still apply to anything you read while working on them.
## Before asking for review
@@ -131,8 +110,7 @@ it (edited on every push, never repeated): talk to its author in the thread befo
## Pull requests
- **Small.** One change per pull request, under about 500 lines of code where you can; split bigger work into
pieces that each make sense alone. Small pull requests get reviewed and merged the same day. Bigger than that:
[large pull requests](#large-pull-requests).
pieces that each make sense alone. Small pull requests get reviewed and merged the same day.
- **Fill in the template**: what and why, the vanilla reference, the pr_ready block, screenshots for anything you
can see.
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
@@ -155,19 +133,6 @@ generation, the launcher's updates) wait for a local proof first ([merging](docs
Every merge to main is released automatically ([release flow](docs/release-flow.md)), so main always stays playable.
How agents, checks, releases and signed updates keep this safe: [security](docs/security.md).
### Large pull requests
500 lines stays the default. A pull request that can't be split below it starts from a spec:
1. **Open an issue with the spec** before the code: what changes, the files it touches, the behaviour players and
other code will see, and the tests or harnesses that prove it.
2. **A maintainer approves the spec** in the issue. Code written before that may have to change or wait.
3. **The pull request links the approved issue** (`Spec: #123` in the description) and stays within it; a change of
plan goes back to the issue first.
4. **An agent check compares the code with the spec**, run by the maintainers' agents: anything the spec doesn't
cover, or that it promises and the code doesn't do, comes back to you.
5. **Then the normal review**, as for any pull request.
## Parity checks on forks
The `verify` check (formatting, lints, tests) runs on every pull request. The `parity` check runs the affected
Generated
+2 -22
View File
@@ -1374,10 +1374,6 @@ dependencies = [
"digest",
]
[[package]]
name = "horizon-addresses"
version = "0.1.0"
[[package]]
name = "horizon-anticheat"
version = "0.1.0"
@@ -1581,6 +1577,7 @@ dependencies = [
"glam",
"half",
"horizon-assets",
"horizon-bench",
"horizon-client-core",
"horizon-client-input",
"horizon-command",
@@ -1591,11 +1588,10 @@ dependencies = [
"horizon-mod-api-gen",
"horizon-narrator",
"horizon-optifine",
"horizon-polytone",
"horizon-protocol",
"horizon-shader-api",
"horizon-shaders",
"horizon-system",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
"horizon-world",
@@ -1644,7 +1640,6 @@ version = "0.1.0"
dependencies = [
"base64 0.22.1",
"glam",
"horizon-addresses",
"png",
"serde",
"serde_json",
@@ -1667,7 +1662,6 @@ dependencies = [
"chacha20poly1305",
"getrandom 0.3.4",
"hkdf",
"horizon-addresses",
"if-addrs",
"serde",
"serde_json",
@@ -1777,18 +1771,10 @@ dependencies = [
"regex",
]
[[package]]
name = "horizon-platform"
version = "0.1.0"
dependencies = [
"horizon-system",
]
[[package]]
name = "horizon-polytone"
version = "0.1.0"
dependencies = [
"log",
"regex",
"serde_json",
]
@@ -1921,17 +1907,11 @@ dependencies = [
"zip",
]
[[package]]
name = "horizon-system"
version = "0.1.0"
[[package]]
name = "horizon-telemetry"
version = "0.1.0"
dependencies = [
"getrandom 0.3.4",
"horizon-addresses",
"horizon-platform",
"ruzstd",
"serde",
"serde_json",
-3
View File
@@ -1,7 +1,6 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga", "third_party/libmimalloc-sys"]
members = [
"crates/horizon-addresses",
"crates/horizon-core",
"crates/horizon-dualsense",
"crates/horizon-narrator",
@@ -28,8 +27,6 @@ members = [
"crates/horizon-theme",
"crates/horizon-prepare",
"crates/horizon-telemetry",
"crates/horizon-system",
"crates/horizon-platform",
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
+5 -4
View File
@@ -12,6 +12,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-0%20open-F0795A" alt="Player-visible gaps open"></a>
</p>
@@ -137,13 +138,13 @@ It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public
## Project status
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the table below says what the harnesses compare.
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
The table is the project's own audit of what the harnesses compare, subsystem by subsystem: a score says how much of that subsystem the harnesses cover and pass, not that nothing else differs from vanilla. What players still run into: [known issues](docs/known-issues.md).
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs. What players still run into: [known issues](docs/known-issues.md).
### What the harnesses compare
### Java parity: 100%
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (its harnesses cover the subsystem and pass). Last audited 2026-10-13: the harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: the harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
| Subsystem | Score | Main gaps |
| --- | ---: | --- |
+1 -1
View File
@@ -76,7 +76,7 @@ Owner: [levzzz5154](https://github.com/levzzz5154) · Tracking: [#3911](https://
<details><summary>Technical</summary>
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. [docs/native-mods.md](docs/native-mods.md).
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. [docs/native-mods.md](docs/native-mods.md).
More: [#3351](https://github.com/horizon-rust/horizon/issues/3351) · [#3353](https://github.com/horizon-rust/horizon/issues/3353) · [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
+2 -94
View File
@@ -1,36 +1,5 @@
# Third-party notices
## Code removed for its license
Horizon once shipped three pieces of code under MIT that came from projects whose licenses don't allow that. All
three are gone from main. They are listed here for anyone who holds an older commit, tag or release: in those
versions, that code is not covered by Horizon's MIT license. It stays under its authors' copyright and its original
license.
- **Shader packs written from Iris's source.** `crates/horizon-shaderpack` and the render stages that used it were
written from the source of [Iris](https://github.com/IrisShaders/Iris) (LGPL-3.0, copyright the Iris contributors).
They were added from 15 October 2026 (`b22583d057`), shipped in the game releases `v2026.10.16.02` to `v0.2.46`,
and were removed on 24 October 2026 ([#1561](https://github.com/horizon-rust/horizon/pull/1561)) after a developer
of Iris showed us. Shader packs came back in `crates/horizon-shaders`, first written from a description of the
pack format.
- **Math functions translated from OpenJDK.** In `crates/horizon-core/src/jmath.rs`, `fdlibm::{log, log10, cbrt,
hypot}` were translated from OpenJDK's `java/lang/FdLibm.java` and `round_f32` from its `java/lang/Math.java`
(GPL-2.0 with the Classpath Exception), and `aarch64::{sin, cos}` from HotSpot's `macroAssembler_aarch64_trig.cpp`
(GPL-2.0); copyright Oracle and/or its affiliates, and Cavium for that file. The `fdlibm` and `aarch64` functions
were added on 13 October 2026 (`558d140fca`, first released in `v2026.10.13`). `round_f32` was added on 14 October
2026 as `java_round` in `crates/horizon-client/src/mipmap.rs` (`95db0fce9d`) and moved into `jmath.rs` on 3
November ([#7770](https://github.com/horizon-rust/horizon/pull/7770)). All of them were replaced in
[#11944](https://github.com/horizon-rust/horizon/pull/11944) by code written without OpenJDK's sources, from Sun's
fdlibm C sources, the Java SE API documentation and a description of the behaviour, and checked against a JVM
([#6741](https://github.com/horizon-rust/horizon/issues/6741)).
- **Java's `HashMap` translated from OpenJDK.** `crates/horizon-world/src/java_hash_map.rs`, which reproduces the
iteration order of `java.util.HashMap`, was translated from OpenJDK's `java/util/HashMap.java` (GPL-2.0 with the
Classpath Exception; copyright Oracle and/or its affiliates). It was added on 7 October 2026 (`118e93ea51` and
`194db0dbb4`, first released in `v2026.10.12`). It was replaced in
[#12043](https://github.com/horizon-rust/horizon/pull/12043) by code written without OpenJDK's sources, from a
description of the behaviour, the Java SE API documentation and the textbook red-black tree, and checked against a
JVM ([#6741](https://github.com/horizon-rust/horizon/issues/6741)).
## Catnip / Ponder
`crates/horizon-client-render/src/fluid_render.rs` translates the still-fluid face tiling and
@@ -46,11 +15,8 @@ original classes, game shaders, textures or prepared resources are distributed h
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
developers, with upstream fixes applied
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124),
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). Its GL backend also reports
the context's sampled textures and samplers per shader stage (usually 32 on desktop GL,
where WebGL2's minimum 16 was reported before), so a pipeline layout the driver accepts is
not refused. It is licensed under MIT or Apache-2.0; both licenses are in that folder
(`LICENSE.MIT`, `LICENSE.APACHE`).
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). It is licensed under MIT or
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
## naga
@@ -63,32 +29,6 @@ variable's array specifier does not change later variables.
It also reads a struct member outside a constant expression through a pointer to the member,
not out of a loaded copy of the whole struct, so a member an atomic function uses can be read.
## DirectX Shader Compiler
The Windows x64 game folder holds `dxcompiler.dll`, Microsoft's DirectX Shader Compiler
[v1.9.2609](https://github.com/microsoft/DirectXShaderCompiler/releases/tag/v1.9.2609), unmodified: the
`bin/x64/dxcompiler.dll` of `dxc_2026_09_29.zip` (zip SHA-256
`ad31b1fc8443175d204f77a611fdb3ef2ec42759bdc2f1167368de24a4a7e7f1`, DLL SHA-256
`0689e2fee0a1c9df8662a59680469f24e2a3ad7ce6bbbcc9284f7a83172bfc59`, 22,274,872 bytes). The release build downloads
it and checks both checksums (`tools/ci/fetch_dxc.py`); it is not in this repository. DirectX 12 loads it only on
integrated GPUs (`crates/horizon-client-render/src/gfx/dx12_compiler.rs`,
[#8513](https://github.com/horizon-rust/horizon/issues/8513)); every other GPU compiles with FXC, part of Windows.
Copyright (c) Microsoft Corporation. It is not covered by Horizon's MIT license. It is distributed under the
"Microsoft Software License Terms, Microsoft DirectX Shader Compiler" (`LICENSE-MS.txt` of that release). Its
Distributable Code terms are the Windows SDK's: end users get terms that protect the code and Microsoft at least as
much, and the distributor indemnifies Microsoft. The project accepted them on 2026-11-08. Its open-source part is also under the University of Illinois/NCSA license (`LICENSE-LLVM.txt`). Both
files ship beside the DLL as `DXC-LICENSE-MS.txt` and `DXC-LICENSE-LLVM.txt`, with `DXC-NOTICE.txt`
(`packaging/windows/`), which tells players those terms apply and that deleting the DLL brings back FXC.
- Only x64: the Windows SDK's list of distributable files
([REDIST.TXT lists](https://learn.microsoft.com/en-us/legal/windows-sdk/redist), updated 2024-10-21) names
`Redist\D3D\x64\dxcompiler.dll` and `dxil.dll` and their x86 copies, for classic Windows applications (Horizon is
one), and no ARM64 copy. The release zip's own `LICENSE-MS.txt` refers to a list of distributable files that the zip
doesn't contain, so the SDK's list is the one followed. The Windows ARM64 build ships no DXC and compiles with FXC.
- No `dxil.dll`: since DXC 1.8.2505 the compiler signs shaders with its own validator and no longer loads it, and
wgpu never does.
## libmimalloc-sys and mimalloc
`third_party/libmimalloc-sys` is [libmimalloc-sys](https://github.com/purpleprotocol/mimalloc_rust)
@@ -316,35 +256,3 @@ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## fdlibm
`crates/horizon-core/src/jmath/fdlibm.rs` and the `fdlibm_asin`, `fdlibm_atan` and
`fdlibm_atan2` functions in `crates/horizon-core/src/geometry/trig.rs` are Rust ports of
[fdlibm](https://www.netlib.org/fdlibm/) 5.3, Sun's freely distributable math library, whose
results the Java SE API specifies for `StrictMath`. `fdlibm.rs` ports `e_log.c`, `e_log10.c`,
`s_cbrt.c` and `e_hypot.c`, and, with fused multiply-adds, `s_sin.c`, `s_cos.c`, `k_sin.c`,
`k_cos.c` and `e_rem_pio2.c`; `trig.rs` ports `e_asin.c`, `s_atan.c` and `e_atan2.c`. This
notice also heads both files:
```
Copyright (C) 1993 by Sun Microsystems, Inc. All rights reserved.
Developed at SunSoft, a Sun Microsystems, Inc. business.
Permission to use, copy, modify, and distribute this
software is freely granted, provided that this notice
is preserved.
```
## Starling
`crates/horizon-client-render/src/touch/layout.rs` and `crates/horizon-client-render/src/touch/aim.rs`
are adapted from the touch layout and touch helpers of
[Starling](https://github.com/starling-client/starling), an Android port of Horizon, at commit
[150a414b09a1](https://github.com/starling-client/starling/tree/150a414b09a18c900489d9462a7fc035dae744b7)
(`android` branch), written mostly by Vasysik. Starling is licensed under the same MIT license as
Horizon (Copyright (c) 2026 Ludovic Marie and the Horizon contributors).
`crates/horizon-client-render/src/android_gamepad.rs` is adapted from Starling's controller decoder
(`crates/horizon-client-render/src/android_gamepad.rs`) and the input-mode part of its
`android_runtime.rs`, at the same commit.
-10
View File
@@ -1,10 +0,0 @@
[package]
name = "horizon-addresses"
version = "0.1.0"
edition = "2024"
rust-version.workspace = true
license = "MIT"
description = "Every built-in address of an outside service vanilla doesn't contact, in one place (docs/layers.md#built-in-addresses)"
[lints]
workspace = true
-36
View File
@@ -1,36 +0,0 @@
//! Every built-in address of an outside service vanilla doesn't contact, in one place
//! (docs/layers.md#built-in-addresses). `tools/check_addresses.py` keeps new ones from going
//! anywhere else.
//!
//! This is a Horizon-layer crate: the engine and the platform never depend on it. Platform code
//! that talks to a service gets the address passed in from a Horizon crate.
/// Horizon's official survival server (SMP), offered once at the top of the server list by the
/// game and the launcher (`launcher/links.json` may name another). Its SRV record leads to the
/// port.
pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
/// The official server's earlier addresses: the game and the launcher move a server list's
/// entries for them to [`OFFICIAL_SERVER`], once. A move appends the old address here (and to
/// `official_server_previous` in `launcher/links.json`).
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
/// (the telemetry server's status checks).
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
/// The cosmetics service: cosmetics, emotes, "Show me as a Horizon player"
/// (`HORIZON_COSMETICS_API` overrides it).
pub const COSMETICS_API: &str = "https://cosmetics.playhorizon.dev";
/// The telemetry ingest server (`crates/horizon-telemetry-server`): opt-in telemetry, the
/// "I'm playing" ping, benchmark uploads (`HORIZON_TELEMETRY_URL` overrides it).
pub const TELEMETRY: &str = "https://telemetry.playhorizon.dev";
/// Public STUN servers for Play with Friends' NAT discovery: two independent operators, so one
/// outage doesn't break discovery (`HORIZON_STUN_SERVERS` overrides them).
pub const STUN_SERVERS: &[&str] = &["stun.cloudflare.com:3478", "stun.l.google.com:19302"];
/// Play with Friends' troubleshooting page, shown to hosts nobody can reach.
pub const FRIENDS_HELP: &str =
"https://github.com/horizon-rust/horizon/blob/main/docs/play-with-friends.md#troubleshooting";
+14 -55
View File
@@ -917,46 +917,6 @@ impl NumericProperty {
/// (`None` removes a prototype component).
pub type Components = BTreeMap<String, Value>;
/// A stack's components read in place: its patch first, then its item's defaults. Reading one copies
/// nothing, where [`ItemModels::components`] clones the defaults for a stack with a patch (an enchanted
/// sword's are a few dozen JSON values, on every draw of it).
#[derive(Clone, Copy)]
pub struct ComponentsView<'a> {
prototype: &'a Components,
patch: &'a BTreeMap<String, Option<Value>>,
}
impl<'a> ComponentsView<'a> {
pub fn get(&self, key: &str) -> Option<&'a Value> {
match self.patch.get(key) {
Some(patched) => patched.as_ref(),
None => self.prototype.get(key),
}
}
pub fn contains_key(&self, key: &str) -> bool {
self.get(key).is_some()
}
/// Components with no patch: `components` as they are.
pub fn of(components: &'a Components) -> Self {
static NO_PATCH: BTreeMap<String, Option<Value>> = BTreeMap::new();
Self {
prototype: components,
patch: &NO_PATCH,
}
}
/// The merged map, for what needs one whole.
pub fn to_map(&self) -> Components {
let mut out = self.prototype.clone();
for (key, value) in self.patch {
match value {
Some(v) => out.insert(key.clone(), v.clone()),
None => out.remove(key),
};
}
out
}
}
/// An item stack as the resolver reads it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct StackView {
@@ -1127,15 +1087,14 @@ impl ItemModels {
if stack.patch.is_empty() {
return std::borrow::Cow::Borrowed(prototype);
}
std::borrow::Cow::Owned(self.components_view(stack).to_map())
}
/// The stack's components, read in place (see [`ComponentsView`]).
pub fn components_view<'a>(&'a self, stack: &'a StackView) -> ComponentsView<'a> {
static NONE: Components = BTreeMap::new();
ComponentsView {
prototype: self.prototypes.get(&stack.item).unwrap_or(&NONE),
patch: &stack.patch,
let mut out = prototype.clone();
for (key, value) in &stack.patch {
match value {
Some(v) => out.insert(key.clone(), v.clone()),
None => out.remove(key),
};
}
std::borrow::Cow::Owned(out)
}
/// `ItemModelResolver.updateForTopItem`.
pub fn resolve(&self, stack: &StackView, ctx: &Context, env: &dyn ItemEnv) -> ResolvedItem {
@@ -1153,7 +1112,7 @@ impl ItemModels {
ctx: &Context,
env: &dyn ItemEnv,
) {
let components = self.components_view(stack);
let components = self.components(stack);
let Some(Value::String(model_id)) = components.get("minecraft:item_model") else {
return;
};
@@ -1163,7 +1122,7 @@ impl ItemModels {
let mut state = Resolve {
models: self,
stack,
components,
components: &components,
ctx,
env,
};
@@ -1181,7 +1140,7 @@ impl ItemModels {
self.properties(stack).2
}
fn properties(&self, stack: &StackView) -> (bool, bool, f32) {
match self.components_view(stack).get("minecraft:item_model") {
match self.components(stack).get("minecraft:item_model") {
Some(Value::String(id)) => identifier(id)
.ok()
.and_then(|id| self.definitions.get(&id))
@@ -1200,7 +1159,7 @@ impl ItemModels {
struct Resolve<'a> {
models: &'a ItemModels,
stack: &'a StackView,
components: ComponentsView<'a>,
components: &'a Components,
ctx: &'a Context<'a>,
env: &'a dyn ItemEnv,
}
@@ -1496,9 +1455,9 @@ impl Resolve<'_> {
fn numeric(&self, property: &NumericProperty) -> f32 {
let owner = self.ctx.owner;
match property {
NumericProperty::BundleFullness => self
.env
.bundle_fullness(self.stack, &self.components.to_map()),
NumericProperty::BundleFullness => {
self.env.bundle_fullness(self.stack, self.components)
}
NumericProperty::Compass {
wobble,
target,
@@ -63,7 +63,7 @@ pub(super) fn decode(m: &Map<String, Value>) -> Result<Value> {
/// with a matching level. An empty predicate list still needs the component.
pub(super) fn matches(
predicate: &Value,
components: ComponentsView<'_>,
components: &Components,
env: &dyn ItemEnv,
) -> Option<bool> {
let (id, expected) = predicate.as_object()?.iter().next()?;
@@ -143,17 +143,13 @@ mod tests {
json!({"minecraft:density":0}),
)]);
assert_eq!(
matches(
&json!({"minecraft:enchantments":[{}]}),
ComponentsView::of(&components),
&NoEnv
),
matches(&json!({"minecraft:enchantments":[{}]}), &components, &NoEnv),
Some(true)
);
assert_eq!(
matches(
&json!({"minecraft:enchantments":[{"enchantments":"minecraft:density"}]}),
ComponentsView::of(&components),
&components,
&NoEnv
),
Some(false)
@@ -170,21 +166,12 @@ mod tests {
"minecraft:enchantments".into(),
json!({"minecraft:density":3}),
)]);
assert_eq!(
matches(&predicate, ComponentsView::of(&components), &Tags),
Some(true)
);
assert_eq!(
matches(&predicate, ComponentsView::of(&components), &NoEnv),
Some(false)
);
assert_eq!(matches(&predicate, &components, &Tags), Some(true));
assert_eq!(matches(&predicate, &components, &NoEnv), Some(false));
components.insert(
"minecraft:enchantments".into(),
json!({"minecraft:density":2}),
);
assert_eq!(
matches(&predicate, ComponentsView::of(&components), &Tags),
Some(false)
);
assert_eq!(matches(&predicate, &components, &Tags), Some(false));
}
}
@@ -1,5 +1,4 @@
use super::*;
use serde_json::json;
#[test]
fn identifier_adds_default_namespace() {
@@ -112,49 +111,3 @@ fn custom_condition_validates_its_name_and_both_branches() {
assert!(matches!(node("example:charged", empty).unwrap(),
ItemModelNode::Condition { property: ConditionProperty::Custom(name), .. } if name == "example:charged"));
}
#[test]
fn a_patched_stack_reads_its_patch_over_its_items_defaults() {
let mut models = ItemModels::default();
models.prototypes.insert(
"minecraft:diamond_sword".into(),
Components::from_iter([
(
"minecraft:item_model".into(),
json!("minecraft:diamond_sword"),
),
("minecraft:max_damage".into(), json!(1561)),
("minecraft:rarity".into(), json!("common")),
]),
);
let mut stack = StackView::new("minecraft:diamond_sword", 1);
stack.patch.insert(
"minecraft:enchantments".into(),
Some(json!({"minecraft:sharpness": 5})),
);
stack
.patch
.insert("minecraft:rarity".into(), Some(json!("rare")));
stack.patch.insert("minecraft:max_damage".into(), None);
let view = models.components_view(&stack);
assert_eq!(view.get("minecraft:rarity"), Some(&json!("rare")));
assert_eq!(view.get("minecraft:max_damage"), None, "a removed default");
assert_eq!(
view.get("minecraft:item_model"),
Some(&json!("minecraft:diamond_sword"))
);
assert!(view.contains_key("minecraft:enchantments"));
assert!(!view.contains_key("minecraft:custom_name"));
// Every key reads as the merged map has it.
let merged = models.components(&stack);
for key in [
"minecraft:rarity",
"minecraft:max_damage",
"minecraft:item_model",
"minecraft:enchantments",
"minecraft:custom_name",
] {
assert_eq!(view.get(key), merged.get(key), "{key}");
}
assert_eq!(view.to_map(), *merged);
}
+2 -2
View File
@@ -290,12 +290,12 @@ pub fn normalize_quaternion(q: [f32; 4]) -> [f32; 4] {
[x * inv, y * inv, z * inv, w * inv]
}
/// JOML `Math.sin(float)` without fast math.
pub fn joml_sin(rad: f32) -> f32 {
fn joml_sin(rad: f32) -> f32 {
f64::from(rad).sin() as f32
}
/// JOML `Math.cosFromSin(float, float)` without fast math: the magnitude from
/// the sine, the sign from the angle's quadrant (exact zeros at right angles).
pub fn cos_from_sin(sin: f32, angle: f32) -> f32 {
fn cos_from_sin(sin: f32, angle: f32) -> f32 {
let cos = (1.0 - sin * sin).sqrt();
let tau = (std::f64::consts::PI * 2.0) as f32;
let a = angle + std::f32::consts::FRAC_PI_2;
@@ -217,10 +217,9 @@ pub fn stack(tag: &Tag) -> Option<Tag> {
Some(Tag::compound(out))
}
/// `ItemStackTemplate.CODEC`: an item id or `{id, count?, components?}`.
/// Encoding uses the primary record form, including for a bare item id.
fn template(tag: &Tag) -> Option<Tag> {
match tag {
Tag::String(_) => Some(Tag::compound([("id", tag.clone())])),
Tag::String(_) => Some(tag.clone()),
Tag::Compound(_) => {
let mut out = vec![("id", string(&text(tag.get("id"))?))];
if let Some(count) = int(tag.get("count")).filter(|c| *c != 1) {
@@ -1012,69 +1011,3 @@ mod tests {
assert_eq!(items[1].get("count"), Some(&Tag::Int(5)));
}
}
#[cfg(test)]
mod pot_template_tests {
use super::*;
use horizon_world::nbt::parse_snbt;
#[test]
fn decorated_pot_string_template_load_save_uses_item_compound() {
let parse = |s: &str| parse_snbt(s).expect("valid input NBT").0;
let input = |front: &str| {
let mut tag = parse(
r#"{id:"minecraft:decorated_pot",x:4,y:200,z:4,sherds:{back:{id:"minecraft:brick"},right:{id:"minecraft:archer_pottery_sherd"}},item:{id:"minecraft:wheat",count:3,components:{"minecraft:custom_name":"H290 item"}}}"#,
);
let Tag::Compound(root) = &mut tag else {
unreachable!()
};
let Some(Tag::Compound(sides)) = root.get_mut(&"sherds".into()) else {
unreachable!()
};
sides.insert("front".into(), parse(front));
tag
};
let other_fields = |loaded: &Tag, saved: &Tag| {
assert_eq!(saved.get("id"), loaded.get("id"));
for key in ["x", "y", "z", "item"] {
assert_eq!(saved.get(key), loaded.get(key), "{key}");
}
let loaded_sides = loaded.get("sherds").unwrap();
let saved_sides = saved.get("sherds").unwrap();
for side in ["back", "right"] {
assert_eq!(saved_sides.get(side), loaded_sides.get(side), "{side}");
}
assert_eq!(saved_sides.get("left"), None);
};
let plain = r#"{id:"minecraft:angler_pottery_sherd"}"#;
let defaults = r#"{id:"minecraft:angler_pottery_sherd",count:1,components:{}}"#;
let rich = r#"{id:"minecraft:angler_pottery_sherd",count:3,components:{"minecraft:custom_data":{h290:1},"minecraft:custom_name":"H290 sherd"}}"#;
// Actual canonical load/save entry, not the private template helper.
for (front, expected) in [(plain, plain), (defaults, plain), (rich, rich)] {
let loaded = input(front);
let original = loaded.clone();
let saved = canonical(&loaded).expect("actual decorated-pot codec");
other_fields(&loaded, &saved);
assert_eq!(
saved.get("sherds").unwrap().get("front"),
Some(&parse(expected))
);
assert_eq!(canonical(&saved).as_ref(), Some(&saved));
assert_eq!(loaded, original, "read-only canonicalization");
}
println!(
"H290 actual canonical compound/default/count/components, other sides/item/name and idempotency controls admitted"
);
let loaded = input(r#""minecraft:angler_pottery_sherd""#);
let original = loaded.clone();
let saved = canonical(&loaded).expect("actual decorated-pot codec");
other_fields(&loaded, &saved);
assert_eq!(loaded, original, "read-only canonicalization");
assert_eq!(
saved.get("sherds").unwrap().get("front"),
Some(&parse(plain)),
"string template must save the primary item compound",
);
assert_eq!(canonical(&saved).as_ref(), Some(&saved));
}
}
+3 -41
View File
@@ -412,11 +412,10 @@ pub fn to_text_with(
.collect()
})
.unwrap_or_default();
// TranslatableContents.decompose: Language.getOrDefault(key, fallback), so the
// fallback is the template itself, never looked up as another key.
let translated = Text::translate(key, &args);
match o.get("fallback").and_then(Value::as_str) {
Some(fallback) => Text::translate_with_fallback(key, fallback, &args),
None => Text::translate(key, &args),
Some(fallback) if translated.plain() == key => Text::translate(fallback, &args),
_ => translated,
}
} else if let Some(sel) = o.get("selector").and_then(Value::as_str) {
resolve(sel)
@@ -514,43 +513,6 @@ pub fn name_text(tag: &Tag) -> Text {
#[cfg(test)]
mod tests {
use super::*;
use crate::lang::{self, Language, ResourceSource};
struct Mem(std::collections::HashMap<String, Vec<u8>>);
impl ResourceSource for Mem {
fn read(&self, path: &str) -> Option<Vec<u8>> {
self.0.get(path).cloned()
}
}
/// TranslatableContents.decompose: Language.getOrDefault(key, fallback). A missing key
/// shows the fallback as written, even when the fallback is another key the language has.
#[test]
fn a_translatable_fallback_stays_literal() {
let pack = Mem([(
"assets/minecraft/lang/en_us.json".to_owned(),
br#"{"real.key": "Translated", "greet": "Hello %s"}"#.to_vec(),
)]
.into());
let ((), _) = lang::scoped(Language::load(&[&pack], "en_us"), || {
let parse = |v: Value| to_text(&v, &mut |_| Text::empty()).plain();
let missing = serde_json::json!({"translate": "missing.key", "fallback": "real.key"});
assert_eq!(parse(missing), "real.key");
// The fallback is a template: its arguments are filled in.
let args = serde_json::json!({"translate": "missing.key", "fallback": "Hi %s", "with": ["Steve"]});
assert_eq!(parse(args), "Hi Steve");
// A key the language has wins over its fallback.
let found =
serde_json::json!({"translate": "greet", "fallback": "real.key", "with": ["Alex"]});
assert_eq!(parse(found), "Hello Alex");
// A component built with the constructor keeps its meaning through its JSON.
let built = Text::translate_with_fallback("missing.key", "real.key", &[]);
let again = to_text(&built.component(), &mut |_| Text::empty());
assert_eq!(again.plain(), built.plain());
assert_eq!(again.component(), built.component());
});
}
/// SnbtGrammar: a compound or list may end with one comma; two in a row
/// stay an error.
#[test]
+2 -21
View File
@@ -46,21 +46,6 @@ thread_local! {
}
/// The current language's `bidirectional`, read for every drawn string.
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
/// Counts the languages set (`set`): another count is another language, so text looked up
/// before it may read otherwise.
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
thread_local! {
/// Counts the scopes this thread entered and left (`scoped`), which no other thread sees.
static SCOPE_GENERATION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// Which language text looks up in now, on this thread, as a count that changes whenever that
/// can: both counts only grow, so their sum changes when either does.
pub fn generation() -> u64 {
GENERATION.load(Ordering::Relaxed) + SCOPE_GENERATION.get()
}
fn scope_changed() {
SCOPE_GENERATION.set(SCOPE_GENERATION.get() + 1);
}
/// A source of resource files: the prepared root plus packs, highest priority last.
pub trait ResourceSource {
@@ -539,7 +524,6 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
pub fn set(language: Language) {
BIDIRECTIONAL.store(language.bidirectional, Ordering::Relaxed);
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
GENERATION.fetch_add(1, Ordering::Relaxed);
}
pub fn current() -> Option<Arc<Language>> {
if let Some(language) = SCOPE.with(|s| s.borrow().as_ref().map(|s| s.language.clone())) {
@@ -559,7 +543,6 @@ pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>)
impl Drop for Restore {
fn drop(&mut self) {
SCOPE.with(|s| *s.borrow_mut() = self.0.take());
scope_changed();
}
}
let outer = SCOPE.with(|s| {
@@ -569,7 +552,6 @@ pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>)
})
});
let _restore = Restore(outer);
scope_changed();
let result = f();
let missed = SCOPE.with(|s| {
s.borrow_mut()
@@ -652,10 +634,9 @@ mod tests {
assert_eq!(zh.get("%s chunks"), Some("%s 个区块"));
// Not every bundle is complete: the en_us layer is in every map, so a
// missing key falls back to English (`horizon.mods.not_built` is not
// in la_la).
// in ru_ru either).
assert_eq!(zh.get("LOD Distance"), Some("LOD 距离"));
let la = Language::load(&[&empty], "la_la");
assert_eq!(la.get("horizon.mods.not_built"), Some(" (Not built)"));
assert_eq!(ru.get("horizon.mods.not_built"), Some(" (Not built)"));
let pl = Language::load(&[&empty], "pl_pl");
assert_eq!(pl.get("horizon.options.controller"), Some("Kontroler"));
assert_eq!(pl.get("horizon.options.vibration"), Some("Wibracje"));
+20 -1
View File
@@ -1,7 +1,6 @@
//! NBT paths (`NbtPathArgument`): `foo.bar[0]`, `[]`, `list[{id:1}]`,
//! `{root:match}`, with get, set, insert and remove on a tag tree, and the
//! partial NBT comparison (`NbtUtils.compareNbt`) selectors and conditions use.
pub use horizon_world::nbt::compare_nbt;
use horizon_world::nbt::{Compound, JavaString, Tag, parse_snbt};
/// An error carrying the translation key and its arguments.
@@ -48,6 +47,26 @@ fn get_child<'a>(c: &'a Compound, name: &str) -> Option<&'a Tag> {
c.get(&key(name))
}
/// `NbtUtils.compareNbt(expected, actual, true)`.
pub fn compare_nbt(expected: &Tag, actual: Option<&Tag>) -> bool {
let Some(actual) = actual else { return false };
if std::mem::discriminant(expected) != std::mem::discriminant(actual) {
return false;
}
match (expected, actual) {
(Tag::Compound(e), Tag::Compound(a)) => {
a.len() >= e.len() && e.iter().all(|(k, v)| compare_nbt(v, a.get(k)))
}
(Tag::List(e), Tag::List(a)) => {
if e.is_empty() {
return a.is_empty();
}
a.len() >= e.len() && e.iter().all(|x| a.iter().any(|y| compare_nbt(x, Some(y))))
}
_ => expected == actual,
}
}
// ---- list-like tags ----------------------------------------------------------
/// The elements of a list or array tag.
@@ -89,31 +89,6 @@ pub struct Registries {
pub trigger_objectives: Vec<String>,
/// Objectives with the `trigger` criterion -> holder -> whether its score is locked.
pub trigger_scores: BTreeMap<String, BTreeMap<String, bool>>,
/// The `ConfiguredFeature` codec for an inline `minecraft:worldgen/feature`
/// value (`ResourceOrIdArgument`), where the source has the server's live
/// features; None leaves the decode to when the command runs.
pub inline_feature: Option<InlineFeatureCheck>,
}
/// A codec check of an inline value: its error message when it does not decode.
#[derive(Clone)]
pub struct InlineFeatureCheck(
std::sync::Arc<dyn Fn(&horizon_world::nbt::Tag) -> Result<(), String> + Send + Sync>,
);
impl InlineFeatureCheck {
pub fn new(
check: impl Fn(&horizon_world::nbt::Tag) -> Result<(), String> + Send + Sync + 'static,
) -> Self {
Self(std::sync::Arc::new(check))
}
pub fn check(&self, value: &horizon_world::nbt::Tag) -> Result<(), String> {
(self.0)(value)
}
}
impl std::fmt::Debug for InlineFeatureCheck {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("InlineFeatureCheck(..)")
}
}
/// The ids and tag ids of one registry.
#[derive(Clone, Debug, Default)]
-53
View File
@@ -14,8 +14,6 @@ pub enum PistonPart {
Entity,
State,
Properties,
/// Any other block entity's whole compound (`BlockEntity.saveWithFullMetadata`).
BlockEntity,
}
struct Pretty {
@@ -257,20 +255,6 @@ fn piston_hash_order(
entries.sort_by_key(|(k, _)| (hash_bucket(c.len(), k), std::cmp::Reverse(*k)));
}
PistonPart::State => return java_hash_order(c),
PistonPart::BlockEntity => {
// saveAdditional's fields, then `components`, then saveMetadata's
// id and position; the fields keep the default order among them.
const METADATA: [&str; 5] = ["components", "id", "x", "y", "z"];
let mut entries = java_hash_order(c);
entries.sort_by_key(|(k, _)| {
let key = string_of(k);
(
hash_bucket(c.len(), k),
METADATA.iter().position(|m| *m == key).map_or(0, |i| i + 1),
)
});
return entries;
}
}
entries
}
@@ -329,14 +313,6 @@ pub fn java_hash_order(
"NoBasePlate",
"Marker",
"Pose",
// Mannequin.addAdditionalSaveData.
"profile",
"hidden_layers",
"main_hand",
"pose",
"immovable",
"description",
"hide_description",
];
// BlockEntity.saveMetadata writes the position after the entity's data.
const AFTER: &[&str] = &["x", "y", "z"];
@@ -421,21 +397,6 @@ mod tests {
assert_eq!(pretty(&tag).plain(), ordinary);
}
#[test]
fn mannequin_compound_orders_pose_before_immovable_in_shared_bucket() {
let tag = parse_snbt(
r#"{AbsorptionAmount:0.0f,Air:300s,Brain:{memories:{}},DeathTime:0s,FallFlying:0b,Fire:0s,Health:20.0f,HurtTime:0s,Invulnerable:0b,Motion:[0.0d,0.0d,0.0d],OnGround:0b,PortalCooldown:0,Pos:[2.5d,100.0d,2.5d],Rotation:[0.0f,0.0f],UUID:[I;369,0,0,1],attributes:[],current_impulse_context_reset_grace_time:0,description:{text:"Guard"},hide_description:1b,immovable:1b,pose:"crouching",profile:{name:"alex"}}"#,
)
.unwrap()
.0;
let rendered = pretty(&tag).plain();
let pose_idx = rendered.find("pose: \"crouching\"").unwrap();
let immovable_idx = rendered.find("immovable: 1b").unwrap();
assert!(
pose_idx < immovable_idx,
"expected `pose` before `immovable` in bucket 5, got: {rendered}"
);
}
#[test]
fn compact_form_matches_vanilla() {
assert_eq!(
rt("{b:1b,a:\"x\",c:[1,2],d:1.5f,e:2d,f:3L,g:4s}"),
@@ -480,18 +441,4 @@ mod tests {
let long = parse_snbt("{l:[{a:1},{b:2}]}").unwrap().0;
assert_eq!(pretty(&long).plain(), "{l: [{a: 1}, {b: 2}]}");
}
#[test]
fn block_entity_metadata_follows_its_fields() {
// A conduit's `/data get block`, as vanilla 26.3 printed it: `Target`
// and `components` share a bucket, and saveAdditional wrote `Target` first.
let tag = parse_snbt(
r#"{id:"minecraft:conduit",x:4098,y:300,z:4098,components:{},Target:[I;1,2,3,4]}"#,
)
.unwrap()
.0;
assert_eq!(
pretty_piston(&tag, PistonPart::BlockEntity).plain(),
r#"{Target: [I; 1, 2, 3, 4], components: {}, x: 4098, y: 300, z: 4098, id: "minecraft:conduit"}"#,
);
}
}
+55 -108
View File
@@ -82,6 +82,49 @@ impl Button {
fn bit(self) -> u32 {
1 << self as u32
}
/// What is printed on the button for a pad family (`Auto` reads as Xbox).
pub fn label(self, style: GlyphStyle) -> &'static str {
let i = self as usize;
match style {
GlyphStyle::PlayStation => [
"×", "○", "□", "△", "L1", "R1", "L2", "R2", "Create", "Options", "L3", "R3", "↑",
"↓", "←", "→",
][i],
GlyphStyle::Nintendo => [
"B", "A", "Y", "X", "L", "R", "ZL", "ZR", "−", "+", "LS", "RS", "↑", "↓", "←", "→",
][i],
GlyphStyle::Xbox | GlyphStyle::Auto => [
"A", "B", "X", "Y", "LB", "RB", "LT", "RT", "View", "Menu", "LS", "RS", "↑", "↓",
"←", "→",
][i],
}
}
/// The glyph's colour (RGB): face buttons in their family's colours,
/// the rest white.
pub fn color(self, style: GlyphStyle) -> [u8; 3] {
let face = match self {
Button::South => 0,
Button::East => 1,
Button::West => 2,
Button::North => 3,
_ => return [255, 255, 255],
};
match style {
GlyphStyle::PlayStation => [
[124, 178, 232],
[235, 110, 110],
[222, 145, 205],
[92, 205, 170],
][face],
GlyphStyle::Nintendo => [255, 255, 255],
GlyphStyle::Xbox | GlyphStyle::Auto => [
[110, 200, 90],
[235, 90, 80],
[80, 150, 240],
[240, 200, 60],
][face],
}
}
/// The face button that plays this one's part on a Nintendo pad going by its letters
/// (`PadSettings::nintendo_letters`): the A it prints on the right is South, which confirms
/// and jumps as A does on a Switch; B (bottom) is East, X (top) West, Y (left) North. It is
@@ -384,101 +427,6 @@ impl HeldDrop {
}
}
/// A job a menu's fixed buttons do. Menus aren't rebindable: this is the one map from a job to
/// its button, and a glyph for a menu asks for the job (`GlyphOf::Menu`), never the button.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MenuJob {
/// A: press, take, type.
Confirm,
/// B: back, close.
Back,
/// X: the screen's second job (Apply, Options, half a stack, delete a letter).
Alternate,
/// Y: the screen's third job (the exit, a quick move, a space).
Extra,
/// LB: the page or tab before.
PagePrev,
/// RB: the page or tab after.
PageNext,
/// LT: the group before; held, the modifier (shift, direct craft).
GroupPrev,
/// RT: the group after.
GroupNext,
/// View: throw.
View,
/// Menu (Start): done, and cancelling a binding.
Start,
/// The left stick pressed in.
LeftStick,
}
impl MenuJob {
pub const ALL: [MenuJob; 11] = [
MenuJob::Confirm,
MenuJob::Back,
MenuJob::Alternate,
MenuJob::Extra,
MenuJob::PagePrev,
MenuJob::PageNext,
MenuJob::GroupPrev,
MenuJob::GroupNext,
MenuJob::View,
MenuJob::Start,
MenuJob::LeftStick,
];
/// The part that does the job (by place; a pad going by its letters is read so the part
/// still lands: `PadState::by_letter`).
pub const fn button(self) -> Button {
match self {
MenuJob::Confirm => Button::South,
MenuJob::Back => Button::East,
MenuJob::Alternate => Button::West,
MenuJob::Extra => Button::North,
MenuJob::PagePrev => Button::LeftBumper,
MenuJob::PageNext => Button::RightBumper,
MenuJob::GroupPrev => Button::LeftTrigger,
MenuJob::GroupNext => Button::RightTrigger,
MenuJob::View => Button::Select,
MenuJob::Start => Button::Start,
MenuJob::LeftStick => Button::LeftStick,
}
}
/// The job a part does in menus.
pub fn of(button: Button) -> Option<MenuJob> {
Self::ALL.into_iter().find(|job| job.button() == button)
}
}
/// What a glyph stands for: what the button does, never the button. `pad_glyph::Glyph` is built
/// from one, so a rebinding or a pad's letters reach every glyph.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GlyphOf {
/// A gameplay action (`key.jump`...), on the player's binding.
Bound(&'static str),
/// A menu's fixed job.
Menu(MenuJob),
}
impl From<MenuJob> for GlyphOf {
fn from(job: MenuJob) -> Self {
GlyphOf::Menu(job)
}
}
impl GlyphOf {
/// The part that does it under these settings; none while the action is unbound.
pub fn button(self, settings: &PadSettings) -> Option<Button> {
match self {
GlyphOf::Bound(action) => {
// A misspelt action would quietly have no glyph.
debug_assert!(
ACTIONS.iter().any(|a| a.0 == action),
"no pad action {action}"
);
settings.binding(action)
}
GlyphOf::Menu(job) => Some(job.button()),
}
}
}
/// Which button pictures prompts use.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum GlyphStyle {
@@ -635,10 +583,6 @@ pub struct PadSettings {
/// The sneak button flips sneaking (Bedrock's default) instead of
/// holding it, unless vanilla's toggle crouch is on already.
pub toggle_sneak: bool,
/// LT + A on a recipe crafts on every server, not only in single player and on LAN worlds
/// found in the server list. Off by default: a server's rules may count a repeated craft as a
/// macro.
pub craft_on_servers: bool,
pub glyphs: GlyphStyle,
/// A Nintendo pad's face buttons go by their letters (A confirms and jumps, as on the
/// Switch's own menus and keyboard) instead of their places (B, at the bottom, does): on
@@ -670,7 +614,6 @@ impl Default for PadSettings {
look_deadzone: 0.12,
trigger_threshold: 0.4,
toggle_sneak: true,
craft_on_servers: false,
glyphs: GlyphStyle::Auto,
nintendo_letters: cfg!(target_os = "horizon"),
keyboard: TextKeyboard::Auto,
@@ -687,10 +630,6 @@ fn bool_text(v: bool) -> &'static str {
if v { "true" } else { "false" }
}
impl PadSettings {
/// A pad of `family` goes by its letters: only a Nintendo pad has letters to go by.
pub fn by_letter(&self, family: GlyphStyle) -> bool {
self.nintendo_letters && family == GlyphStyle::Nintendo
}
pub fn load(dir: &Path) -> Self {
fs::read_to_string(dir.join(FILE))
.map(|t| Self::parse(&t))
@@ -745,7 +684,6 @@ impl PadSettings {
s.trigger_threshold = unit(s.trigger_threshold).clamp(0.1, 0.9)
}
"toggleSneak" => s.toggle_sneak = flag(s.toggle_sneak),
"craftOnServers" => s.craft_on_servers = flag(s.craft_on_servers),
"glyphs" => s.glyphs = GlyphStyle::parse(value),
"nintendoLetters" => s.nintendo_letters = flag(s.nintendo_letters),
"keyboard" => s.keyboard = TextKeyboard::parse(value),
@@ -807,8 +745,7 @@ impl PadSettings {
self.keyboard.name(),
);
text.push_str(&format!(
"craftOnServers:{}\ngameplayGuide:{}\npointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
bool_text(self.craft_on_servers),
"gameplayGuide:{}\npointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
self.guide_detail.name(),
self.pointer_speed, self.scroll_speed, self.repeat_delay, self.repeat_interval,
));
@@ -1977,6 +1914,11 @@ bind_key.hotbar.2:north
assert!(parts.held(Button::LeftBumper));
assert_eq!(parts.left, [0.5, -0.25]);
assert_eq!(parts.by_letter(), pad);
// A part's glyph is the letter of the button playing it.
assert_eq!(Button::South.by_letter().label(GlyphStyle::Nintendo), "A");
assert_eq!(Button::East.by_letter().label(GlyphStyle::Nintendo), "B");
assert_eq!(Button::West.by_letter().label(GlyphStyle::Nintendo), "X");
assert_eq!(Button::North.by_letter().label(GlyphStyle::Nintendo), "Y");
}
#[test]
@@ -2404,7 +2346,12 @@ bind_key.hotbar.2:north
}
#[test]
fn styles_resolve_and_conflicts() {
fn labels_and_conflicts() {
assert_eq!(Button::South.label(GlyphStyle::Xbox), "A");
assert_eq!(Button::South.label(GlyphStyle::PlayStation), "×");
assert_eq!(Button::South.label(GlyphStyle::Nintendo), "B");
assert_eq!(Button::RightTrigger.label(GlyphStyle::PlayStation), "R2");
assert_eq!(Button::DPadLeft.label(GlyphStyle::Xbox), "←");
assert_eq!(
GlyphStyle::Auto.resolve(GlyphStyle::PlayStation),
GlyphStyle::PlayStation
@@ -2,7 +2,7 @@
//! and of an inventory it drives (Horizon's; vanilla has no gamepad input). The game reads the facts
//! from its own hit results and capabilities (`game/pad/guide.rs`); this turns them into rows, and
//! places the rows around what was drawn.
use crate::gamepad::{Button, GlyphOf, GuideDetail, MenuJob, PadSettings, Rect};
use crate::gamepad::{Button, GuideDetail, PadSettings, Rect};
/// What a use does, by what takes it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -100,27 +100,26 @@ pub struct Facts {
pub off_hand_item: bool,
}
/// One button's line: what the button does (its glyph follows the binding and the pad's letters)
/// and the translation key of the caption.
/// One button's line: the button and the translation key of what it does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Row {
/// Shown before `of`: held with it, or its other half (LB and RB for pages).
pub with: Option<GlyphOf>,
pub of: GlyphOf,
/// Shown before `button`: held with it, or its other half (LB and RB for pages).
pub with: Option<Button>,
pub button: Button,
pub caption: &'static str,
}
impl Row {
fn new(of: impl Into<GlyphOf>, caption: &'static str) -> Self {
fn new(button: Button, caption: &'static str) -> Self {
Row {
with: None,
of: of.into(),
button,
caption,
}
}
fn pair(with: MenuJob, job: MenuJob, caption: &'static str) -> Self {
fn pair(with: Button, button: Button, caption: &'static str) -> Self {
Row {
with: Some(GlyphOf::Menu(with)),
of: GlyphOf::Menu(job),
with: Some(with),
button,
caption,
}
}
@@ -156,11 +155,11 @@ impl Guide {
/// The rows for these facts on these bindings. An unbound action has no row, and neither has one
/// on Start, which is always pause.
pub fn resolve(facts: &Facts, settings: &PadSettings) -> Guide {
let row = |action: &'static str, caption: Option<&'static str>| {
settings
let row = |action: &str, caption: Option<&'static str>| {
let button = settings
.binding(action)
.filter(|button| *button != Button::Start)?;
Some(Row::new(GlyphOf::Bound(action), caption?))
Some(Row::new(button, caption?))
};
let player = !facts.spectator;
let any_item = facts.main_hand_item || facts.off_hand_item;
@@ -309,10 +308,10 @@ pub struct MenuFacts {
/// An inventory's rows, by what they ask of the player: at the bottom right one face button, at the
/// bottom left anything else (a trigger held with a button, the bumpers, View, a stick click). What
/// the crosshair is on comes first in its column, the screen's own rows after, except Close, which
/// stays lowest so the rest don't move it. Menu buttons aren't rebindable, so these name menu jobs.
/// stays lowest so the rest don't move it. Menu buttons aren't rebindable, so these name them.
pub fn resolve_menu(f: &MenuFacts) -> Guide {
use MenuJob::{Alternate, Back, Confirm, Extra, GroupNext, GroupPrev};
use MenuJob::{LeftStick, PageNext, PagePrev, View};
use Button::{East, LeftBumper, LeftStick, LeftTrigger, North, RightBumper, RightTrigger};
use Button::{Select, South, West};
const TAKE: &str = "horizon.controller.guide.take";
const MOVE: &str = "horizon.controller.guide.quick_move";
let under = match f.under {
@@ -322,25 +321,25 @@ pub fn resolve_menu(f: &MenuFacts) -> Guide {
result: true,
same,
..
} if !f.carrying || same => vec![Row::new(Confirm, TAKE), Row::new(Extra, MOVE)],
} if !f.carrying || same => vec![Row::new(South, TAKE), Row::new(North, MOVE)],
Under::Slot { result: true, .. } => Vec::new(),
Under::Slot { accepts: false, .. } if f.carrying => Vec::new(),
Under::Slot { item, same, .. } if f.carrying => vec![
Row::new(
Confirm,
South,
if item && !same {
"horizon.controller.guide.swap_stack"
} else {
"horizon.controller.guide.put"
},
),
Row::new(Alternate, "horizon.controller.guide.put_one"),
Row::new(West, "horizon.controller.guide.put_one"),
],
Under::Slot { item: true, .. } => vec![
Row::new(Confirm, TAKE),
Row::new(Alternate, "horizon.controller.guide.take_half"),
Row::new(Extra, MOVE),
Row::new(View, "horizon.controller.guide.drop"),
Row::new(South, TAKE),
Row::new(West, "horizon.controller.guide.take_half"),
Row::new(North, MOVE),
Row::new(Select, "horizon.controller.guide.drop"),
Row::new(LeftStick, "horizon.controller.guide.drop_stack"),
],
Under::Nothing | Under::Slot { .. } => Vec::new(),
@@ -348,44 +347,43 @@ pub fn resolve_menu(f: &MenuFacts) -> Guide {
direct_craft,
alternatives,
} => [
Some(Row::new(Confirm, "horizon.controller.guide.place_recipe")),
Some(Row::new(South, "horizon.controller.guide.place_recipe")),
direct_craft.then_some(Row::pair(
GroupPrev,
Confirm,
LeftTrigger,
South,
"horizon.controller.direct_craft",
)),
Some(Row::new(Extra, "horizon.controller.guide.place_recipe_all")),
alternatives.then_some(Row::new(Alternate, "horizon.controller.guide.alternatives")),
Some(Row::new(North, "horizon.controller.guide.place_recipe_all")),
alternatives.then_some(Row::new(West, "horizon.controller.guide.alternatives")),
]
.into_iter()
.flatten()
.collect(),
Under::Control => vec![Row::new(Confirm, "horizon.controller.guide.select")],
Under::Control => vec![Row::new(South, "horizon.controller.guide.select")],
};
let screen = [
f.book.then_some(Row::pair(
PagePrev,
PageNext,
LeftBumper,
RightBumper,
"horizon.controller.guide.pages",
)),
f.book.then_some(Row::pair(
GroupPrev,
GroupNext,
LeftTrigger,
RightTrigger,
"horizon.controller.guide.categories",
)),
(f.tabs && !f.book).then_some(Row::pair(
PagePrev,
PageNext,
LeftBumper,
RightBumper,
"horizon.controller.guide.tabs",
)),
];
let close = Row::new(Back, "horizon.controller.guide.close");
let close = Row::new(East, "horizon.controller.guide.close");
let rows = std::iter::once(close)
.chain(under)
.chain(screen.into_iter().flatten());
let (right, left) = rows.partition(|r| {
r.with.is_none() && matches!(r.of, GlyphOf::Menu(Confirm | Back | Alternate | Extra))
});
let (right, left) =
rows.partition(|r| r.with.is_none() && matches!(r.button, South | East | West | North));
Guide { left, right }
}
@@ -497,11 +495,8 @@ mod tests {
..f
}
}
/// Each row's button on these settings, and its caption.
fn rows(rows: &[Row], settings: &PadSettings) -> Vec<(Button, &'static str)> {
rows.iter()
.map(|r| (r.of.button(settings).unwrap(), r.caption))
.collect()
fn rows(rows: &[Row]) -> Vec<(Button, &'static str)> {
rows.iter().map(|r| (r.button, r.caption)).collect()
}
fn captions(rows: &[Row]) -> Vec<&'static str> {
rows.iter().map(|r| r.caption).collect()
@@ -514,10 +509,9 @@ mod tests {
item_use: Some(Verb::Place),
..on_block(walking(), true, None)
};
let settings = PadSettings::default();
let guide = resolve(&facts, &settings);
let guide = resolve(&facts, &PadSettings::default());
assert_eq!(
rows(&guide.right, &settings),
rows(&guide.right),
[
(Button::RightTrigger, "horizon.controller.guide.break"),
(Button::LeftTrigger, "horizon.controller.guide.place"),
@@ -525,7 +519,7 @@ mod tests {
]
);
assert_eq!(
rows(&guide.left, &settings),
rows(&guide.left),
[
(Button::South, "key.jump"),
(Button::RightStick, "key.sneak"),
@@ -549,7 +543,7 @@ mod tests {
};
let guide = resolve(&facts, &settings);
assert_eq!(
rows(&guide.right, &settings),
rows(&guide.right),
[(Button::North, "horizon.controller.guide.break")]
);
// Start only ever pauses.
@@ -851,8 +845,8 @@ mod tests {
["close", "place_recipe", "place_recipe_all", "alternatives"]
);
assert_eq!(recipe.left.len(), 1);
assert_eq!(recipe.left[0].with, Some(GlyphOf::Menu(MenuJob::GroupPrev)));
assert_eq!(recipe.left[0].of, GlyphOf::Menu(MenuJob::Confirm));
assert_eq!(recipe.left[0].with, Some(Button::LeftTrigger));
assert_eq!(recipe.left[0].button, Button::South);
let plain = on(
Under::Recipe {
direct_craft: false,
@@ -898,8 +892,8 @@ mod detail_tests {
fn rows() -> Guide {
Guide {
left: vec![Row::new(MenuJob::Confirm, "left")],
right: vec![Row::new(MenuJob::Back, "right")],
left: vec![Row::new(Button::South, "left")],
right: vec![Row::new(Button::East, "right")],
}
}
@@ -933,7 +927,7 @@ mod detail_tests {
mod slot_tests {
use super::*;
/// The rows for the slot: Confirm or Alternate, whichever column they're in.
/// The rows for the slot: South or West, whichever column they're in.
fn rows(item: bool, same: bool, result: bool, accepts: bool, carrying: bool) -> usize {
let guide = resolve_menu(&MenuFacts {
under: Under::Slot {
@@ -948,7 +942,7 @@ mod slot_tests {
[guide.left, guide.right]
.iter()
.flatten()
.filter(|row| matches!(row.of, GlyphOf::Menu(MenuJob::Confirm | MenuJob::Alternate)))
.filter(|row| matches!(row.button, Button::South | Button::West))
.count()
}
+2 -2
View File
@@ -97,8 +97,8 @@ rustix = { version = "1", default-features = false, features = ["std", "time", "
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or on integrated GPUs with DXC
# (dxcompiler.dll beside the game, gfx/dx12_compiler.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
# on the PATH.
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
+2 -2
View File
@@ -11,6 +11,7 @@ horizon-cosmetics = { path = "../horizon-cosmetics" }
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
horizon-assets = { path = "../horizon-assets" }
horizon-bench = { path = "../horizon-bench" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-command = { path = "../horizon-command" }
@@ -22,11 +23,10 @@ horizon-narrator = { path = "../horizon-narrator" }
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
# (connected.rs) and custom item texture rules (cit.rs).
horizon-optifine = { path = "../horizon-optifine" }
horizon-polytone = { path = "../horizon-polytone" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
horizon-system = { path = "../horizon-system" }
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-theme = { path = "../horizon-theme" }
horizon-threads = { path = "../horizon-threads" }
horizon-world = { path = "../horizon-world" }
@@ -32,11 +32,6 @@ fn pack(properties: &str, shadow: Option<&str>, reads_shadow: bool) -> horizon_s
write("final.fsh", if reads_shadow { SHADOW_READ } else { COLOUR });
write("gbuffers_entities.vsh", VERTEX);
write("gbuffers_entities.fsh", COLOUR);
// Terrain programs keep the load free of "nothing draws it" notes; no terrain is drawn.
for terrain in ["gbuffers_terrain", "gbuffers_water"] {
write(&format!("{terrain}.vsh"), VERTEX);
write(&format!("{terrain}.fsh"), COLOUR);
}
if let Some(shadow) = shadow {
write("shadow.vsh", VERTEX);
write("shadow.fsh", shadow);
@@ -5,7 +5,6 @@
//! `cargo run --profile fast -p horizon-client-render --example pack_load_bench [-- --scale 1]`
//! (`.github/workflows/pack-bench.yml` runs it on a branch and on its base, side by side).
use horizon_client_render::{
gfx::PreparedTextures,
hud::HudAtlas,
model_library::ParsedModels,
packs::{self, Pack},
@@ -404,30 +403,6 @@ fn main() {
);
row("CTM tiles: one by one", sprites(false, false, &tiles, true));
row("CTM tiles: prefetched", sprites(false, true, &tiles, true));
// The mip chains the renderer uploads, from the same pack layers: the HD pack's
// 64 px block sprites fill the array, as they do for a player who has it on.
let mut atlas = Sprites::with_disk_cache(&root);
for name in &blocks {
atlas.insert(&root, name).unwrap();
}
atlas.finish();
let border = vec![255u8; (atlas.size * atlas.size * 4) as usize];
row(
"texture upload: sprite mip chains, both packs",
time(
runs,
|| {},
|| {
std::hint::black_box(PreparedTextures::new(
&atlas.pixels,
atlas.size,
atlas.mips.len() as u32,
&atlas.mips,
(&border, atlas.size),
));
},
),
);
row(
"GUI atlas: decode and stitch, both packs",
time(
@@ -245,7 +245,6 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
settings.fxaa,
settings.taa,
settings.upscale,
0.85,
);
let mut notes = Vec::new();
let mut note = |what: &str, result: horizon_client_render::Result<()>| {
@@ -661,7 +660,6 @@ fn remesh(
blend_radius,
smooth_lighting,
cutout_leaves,
opaque_ice: false,
translucent_layers: &[],
leaf_culling,
};
@@ -50,14 +50,10 @@ fn pack(renderer: &mut Renderer, folder: &Path, valid: bool) {
std::fs::create_dir_all(&shaders).unwrap();
let vertex = "#version 120\nvarying vec2 uv; void main(){gl_Position=ftransform();uv=gl_MultiTexCoord0.xy;}\n";
let fragment = "#version 120\nuniform int entityId; uniform int blockEntityId; uniform vec4 entityColor; void main(){gl_FragData[0]=vec4(float(entityId)/255.0,entityColor.a,float(blockEntityId)/255.0,1.0);}\n";
// The terrain programs keep the load free of "nothing draws it" notes; this
// fixture draws no terrain.
for program in [
"gbuffers_entities",
"gbuffers_block",
"gbuffers_entities_translucent",
"gbuffers_terrain",
"gbuffers_water",
] {
std::fs::write(shaders.join(format!("{program}.vsh")), vertex).unwrap();
std::fs::write(
@@ -1,480 +0,0 @@
//! A world warp (`native_world_warp`, docs/native-mods.md "World warps") through the real
//! `Renderer`, offscreen: a wall of terrain and an entity quad drawn without a warp, with one,
//! with one that is off, with one that cuts the wall, with a pixel stage of its own and with
//! one that doesn't compile. Run with throwaway HORIZON_HOME, HORIZON_LAUNCHER_HOME,
//! XDG_DATA_HOME and XDG_CONFIG_HOME (AGENTS.md):
//! `cargo run -p horizon-client-render --example world_warp_check -- <output-dir>`
//! `--release ... -- <output-dir> --bench`: instead, the time of a frame of rolling land 49 by 49
//! chunks around the camera without a warp, and with one (nothing culled), at 1920 by 1080.
use client::{
gfx::{Frame, Renderer, ScreenshotRequest, native_render},
graphics::GraphicsApi,
hud::HudBatch,
mesher::{SectionMesh, TerrainVertex, Vertex},
};
use horizon_client_render as client;
use std::{
path::Path,
sync::{Arc, Mutex},
};
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
window::{Window, WindowId},
};
/// What the mod below gives the renderer each frame.
static WARP: Mutex<Option<native_render::WorldWarp>> = Mutex::new(None);
struct Mod;
impl native_render::RenderHooks for Mod {
fn world_warp(&self, event: &mut native_render::WorldWarpEvent<'_>) {
if let Some(warp) = WARP.lock().unwrap().as_ref() {
*event.warp = warp.clone();
}
}
}
/// Lifts what is left of x = 8 by `params[0].x` and tints it red.
const LIFT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
let here = pos.x + warp.camera.x + warp.camera_rest.x;
if here < 8.0 { return Warped(pos + vec3(0.0, warp.params[0].x, 0.0), vec3(1.0, 0.0, 0.0)); }
return Warped(pos, vec3(1.0));
}
";
/// Cuts every face.
const CUT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
return Warped(pos, vec3(-1.0));
}
";
/// Paints the terrain green by its pixels and moves nothing.
const PAINT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
return Warped(pos, vec3(1.0));
}
// [fragments]
fn world_fragment(f: Fragment) -> Painted {
if !f.front { return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }
return Painted(vec4(0.0, 1.0, 0.0, 1.0), 0.0);
}
";
/// Moves entities ten blocks right: off the screen's middle.
const SHIFT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
return Warped(pos + vec3(-10.0, 0.0, 0.0), vec3(1.0));
}
";
fn read(path: &Path) -> Vec<[u8; 4]> {
let decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path).unwrap()));
let mut reader = decoder.read_info().unwrap();
let mut bytes = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut bytes).unwrap();
assert_eq!(info.color_type, png::ColorType::Rgba);
bytes.truncate(info.buffer_size());
bytes.as_chunks::<4>().0.to_vec()
}
/// Pixels like `want` (within 40 a channel), of an image.
fn count(pixels: &[[u8; 4]], want: [u8; 3]) -> usize {
pixels
.iter()
.filter(|p| (0..3).all(|i| p[i].abs_diff(want[i]) < 40))
.count()
}
/// A wall across x 0..16, y 0..16 at z = 12 of the section at y 96, both windings.
fn wall() -> SectionMesh {
let mut mesh = SectionMesh::default();
let corners = [
[0., 0., 12.],
[0., 16., 12.],
[16., 16., 12.],
[16., 0., 12.],
];
let uvs = [[0., 1.], [0., 0.], [1., 0.], [1., 1.]];
for order in [[0, 1, 2, 3], [3, 2, 1, 0]] {
for i in order {
mesh.solid
.push(TerrainVertex::new(corners[i], uvs[i], 0, u32::MAX, 0xf0f0));
}
}
mesh.sort_solid();
mesh
}
fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
let out = std::env::args().nth(1).ok_or("output directory required")?;
let out = Path::new(&out);
std::fs::create_dir_all(out)?;
native_render::install(Box::new([&Mod])).map_err(|_| "mod already installed")?;
let white = [255; 16 * 16 * 4];
let zero = [0; 4];
let mut renderer = pollster::block_on(Renderer::new(
window,
true,
&white,
16,
16,
1,
&[],
&[],
(&white, 16, 16),
(&zero, 1, 1),
(&zero, 1, 1),
(&zero, 1),
Arc::new(client::gfx::panorama::Faces::colour([0, 0, 0, 255])),
None,
None,
GraphicsApi::Vulkan,
false,
false,
true,
false,
false,
))?;
renderer.resize(320, 180);
renderer.fullbright = true;
// (keys count sections from the bottom of the world, y -64: 10 is y 96 to 112)
renderer.upload((0, 10, 0), &wall());
// an entity quad in front of the wall, middle of the screen
let entity: Vec<Vertex> = [
[7.5, 103.5, 6.0],
[7.5, 104.5, 6.0],
[8.5, 104.5, 6.0],
[8.5, 103.5, 6.0],
]
.into_iter()
.zip([[0., 1.], [0., 0.], [1., 0.], [1., 1.]])
.map(|(p, uv)| Vertex {
pos: [p[0] - 8.0, p[1] - 104.0, p[2] - 0.5],
uv,
layer: 0,
color: 0xff40_40ff,
light: 0xf0,
normal: [0, 0, 0, 0],
glint_uv: [0.0; 2],
})
.collect();
if std::env::args().any(|a| a == "--bench") {
return bench(&mut renderer, out);
}
let hud = HudBatch::new([320, 180]);
let mut frame = fixture_frame(&hud);
frame.entities = &entity;
let mut shot = |name: &str, warp: Option<native_render::WorldWarp>| {
*WARP.lock().unwrap() = warp;
let path = out.join(format!("{name}.png"));
renderer.screenshot = Some(ScreenshotRequest {
path: path.clone(),
size: None,
scaled: false,
});
// twice: the first frame of a new warp builds it
renderer.render(&frame).unwrap();
renderer.render(&frame).unwrap();
read(&path)
};
let warp = |source: &str, on: bool, lift: f32| {
let mut w = native_render::WorldWarp::default();
w.source = Some(source.into());
w.on = on;
w.params[0] = lift;
Some(w)
};
let plain = shot("plain", None);
let wall_px = count(&plain, [255, 255, 255]);
let entity_px = count(&plain, [255, 64, 64]);
assert!(wall_px > 1000, "the wall shows without a warp: {wall_px}");
assert!(
entity_px > 50,
"the entity shows without a warp: {entity_px}"
);
let off = shot("off", warp(LIFT, false, 4.0));
assert!(off == plain, "a warp that is off draws as no warp");
let lifted = shot("lift", warp(LIFT, true, 4.0));
let red = count(&lifted, [255, 0, 0]);
assert!(red > 500, "the warp tints the wall's left half red: {red}");
assert!(lifted != plain);
let cut = shot("cut", warp(CUT, true, 0.0));
let left = count(&cut, [255, 255, 255]);
assert!(
left < wall_px / 20,
"a cut leaves the wall out: {left} of {wall_px}"
);
let painted = shot("paint", warp(PAINT, true, 0.0));
let green = count(&painted, [0, 255, 0]);
assert!(
green > wall_px / 2,
"the warp's own pixels paint the wall: {green}"
);
let shifted = shot("shift", warp(SHIFT, true, 0.0));
let entity_left = count(&shifted[..], [255, 64, 64]);
let mid = |p: &[[u8; 4]]| -> Vec<[u8; 4]> {
(60..120)
.flat_map(|y| p[y * 320 + 130..y * 320 + 190].to_vec())
.collect()
};
assert!(
count(&mid(&shifted), [255, 64, 64]) < count(&mid(&plain), [255, 64, 64]) / 4,
"the warp moves the entity out of the middle ({entity_left} red-ish pixels left)"
);
let broken = shot(
"broken",
warp("fn world_warp() -> f32 { return 1; }", true, 0.0),
);
assert!(broken == plain, "a warp that doesn't compile is left out");
let back = shot("after", None);
assert!(back == plain, "with the warp gone the frame is as before");
std::fs::write(
out.join("result.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"wall_pixels": wall_px, "entity_pixels": entity_px, "lifted_red_pixels": red,
"cut_wall_pixels_left": left, "painted_green_pixels": green,
"off_equals_plain": true, "broken_equals_plain": true, "after_equals_plain": true,
"graphics_adapter": client::crash::system_details("fixture").into_iter()
.find(|(name, _)| name == "Graphics Adapter").map(|(_, detail)| detail),
}))?,
)?;
println!(
"world warp check passed: {}",
out.join("result.json").display()
);
Ok(())
}
/// Rolling land: a column of a section's top faces and the sides they show, at y 64 to 80.
fn hills(cx: i32, cz: i32) -> SectionMesh {
let height = |x: i32, z: i32| {
let (x, z) = (f64::from(cx * 16 + x), f64::from(cz * 16 + z));
(8.0 + 4.0 * (x * 0.09).sin() + 3.0 * (z * 0.07 + x * 0.02).cos()).floor() as i32
};
let mut mesh = SectionMesh::default();
let mut quad = |c: [[f32; 3]; 4]| {
for (p, uv) in c.into_iter().zip([[0., 1.], [0., 0.], [1., 0.], [1., 1.]]) {
mesh.solid
.push(TerrainVertex::new(p, uv, 0, 0xff60_a060, 0xf0));
}
};
for x in 0..16 {
for z in 0..16 {
let h = height(x, z);
let (fx, fz, fh) = (x as f32, z as f32, h as f32);
quad([
[fx, fh, fz],
[fx, fh, fz + 1.],
[fx + 1., fh, fz + 1.],
[fx + 1., fh, fz],
]);
// the step down to a lower neighbour, toward +x and +z
for (dx, dz) in [(1, 0), (0, 1)] {
let n = height(x + dx, z + dz);
if n < h {
let (lo, hi) = (n as f32, fh);
if dx == 1 {
quad([
[fx + 1., lo, fz],
[fx + 1., hi, fz],
[fx + 1., hi, fz + 1.],
[fx + 1., lo, fz + 1.],
]);
} else {
quad([
[fx, lo, fz + 1.],
[fx + 1., lo, fz + 1.],
[fx + 1., hi, fz + 1.],
[fx, hi, fz + 1.],
]);
}
}
}
}
}
mesh.sort_solid();
mesh
}
/// `--bench`: milliseconds a frame without a warp and with one.
fn bench(renderer: &mut Renderer, out: &Path) -> Result<(), Box<dyn std::error::Error>> {
const R: i32 = 24;
renderer.resize(1920, 1080);
let mut sections = 0;
for cx in -R..=R {
for cz in -R..=R {
// (keys count sections from y -64: 8 is y 64 to 80)
renderer.upload((cx, 8, cz), &hills(cx, cz));
sections += 1;
}
}
let hud = HudBatch::new([1920, 1080]);
let mut frame = fixture_frame(&hud);
frame.camera = glam::DVec3::new(8.0, 90.0, 8.0);
frame.eye = frame.camera;
frame.pitch = 15.0;
frame.eye_rotation = [0.0, 15.0];
frame.distance = 16.0 * R as f32;
frame.detail_distance = frame.distance;
frame.depth_far = 2000.0;
frame.cave_culling = true;
let mut time = |warp: Option<native_render::WorldWarp>| {
*WARP.lock().unwrap() = warp;
for _ in 0..30 {
renderer.render(&frame).unwrap();
}
let started = std::time::Instant::now();
const FRAMES: u32 = 240;
for _ in 0..FRAMES {
renderer.render(&frame).unwrap();
}
// the last frame's picture, so the GPU has caught up
renderer.screenshot = Some(ScreenshotRequest {
path: out.join("bench.png"),
size: None,
scaled: false,
});
renderer.render(&frame).unwrap();
renderer.render(&frame).unwrap();
started.elapsed().as_secs_f64() * 1000.0 / f64::from(FRAMES + 2)
};
let mut wave = native_render::WorldWarp::default();
wave.source = Some(
"fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
let x = pos.x + warp.camera.x + warp.camera_rest.x;
return Warped(pos + vec3(0.0, sin(x * 0.05) * 3.0, 0.0), vec3(1.0));
}\n"
.into(),
);
wave.on = true;
let plain = time(None);
let warped = time(Some(wave));
let again = time(None);
let result = serde_json::json!({
"sections": sections, "frame_ms_without_warp": plain, "frame_ms_with_warp": warped,
"frame_ms_without_warp_after": again,
"graphics_adapter": client::crash::system_details("bench").into_iter()
.find(|(name, _)| name == "Graphics Adapter").map(|(_, detail)| detail),
});
println!("{result}");
std::fs::write(out.join("bench.json"), serde_json::to_vec_pretty(&result)?)?;
Ok(())
}
struct App;
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let window = Arc::new(
event_loop
.create_window(Window::default_attributes().with_visible(false))
.unwrap(),
);
if let Err(error) = fixture(window) {
eprintln!("world warp check failed: {error}");
std::process::exit(1);
}
event_loop.exit();
}
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
}
fn main() {
for key in [
"HORIZON_HOME",
"HORIZON_LAUNCHER_HOME",
"XDG_DATA_HOME",
"XDG_CONFIG_HOME",
] {
assert!(
std::env::var_os(key).is_some(),
"set throwaway {key} (AGENTS.md)"
);
}
EventLoop::new().unwrap().run_app(&mut App).unwrap();
}
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
Frame {
weather: &[],
weather_time: client::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(8.0, 104.0, 0.5),
yaw: 0.0,
pitch: 0.0,
fov_degrees: 85.0,
eye: glam::DVec3::new(8.0, 104.0, 0.5),
eye_rotation: [0.0, 0.0],
first_person: true,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: client::environment::Sky::default(),
shadow_scene_revision: (0, 0),
shadow_cache: false,
distance: 128.0,
detail_distance: 128.0,
depth_far: 1000.0,
underwater: false,
water_plane: None,
cave_culling: false,
title: false,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: Default::default(),
entity_motion: &[],
entity_motion_posed: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
colored_lights: &[],
emissive_layers: &[],
clouds: None,
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
shadow_only: &[],
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
end_portal_sprites: [[0.0; 4]; 2],
crumbling_sprite: [0.0; 4],
ticks: 0,
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
chunk_fade: 0.0,
post_effects: client::post_chain::Request::none(),
menu_blur: None,
}
}
@@ -1,378 +0,0 @@
// Adapted from Starling (https://github.com/starling-client/starling, `android` branch at
// 150a414b09a1, `crates/horizon-client-render/src/android_gamepad.rs` and the input-mode part of
// `android_runtime.rs`), written by Vasysik and the Starling contributors. Copyright (c) 2026
// Ludovic Marie and the Horizon contributors, under the same MIT license as Horizon (LICENSE at
// the repository root).
//! Controllers as Android reports them: its standard key codes and motion axes,
//! the same for USB and Bluetooth pads, decoded into `gamepad::PadState`, and
//! which input device (touch, keyboard and mouse, or a controller) is in charge.
//! Pure logic: the Android event backend feeds it, nothing here talks to the OS.
use crate::gamepad::{Button, PadState};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// The input device the player picked in the options, or the one in use.
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum InputMode {
/// Follow whichever device was used last.
#[default]
Auto,
Touch,
KeyboardMouse,
Controller,
}
impl InputMode {
pub const ALL: [Self; 4] = [
Self::Auto,
Self::Touch,
Self::KeyboardMouse,
Self::Controller,
];
pub fn label(self) -> &'static str {
match self {
Self::Auto => "Automatic",
Self::Touch => "Touch & Android keyboard",
Self::KeyboardMouse => "Keyboard & mouse",
Self::Controller => "Controller",
}
}
}
// Android `MotionEvent` axes (AXIS_*).
const AXIS_X: usize = 0;
const AXIS_Y: usize = 1;
const AXIS_Z: usize = 11;
const AXIS_RZ: usize = 14;
const AXIS_HAT_X: usize = 15;
const AXIS_HAT_Y: usize = 16;
const AXIS_LTRIGGER: usize = 17;
const AXIS_RTRIGGER: usize = 18;
const AXIS_GAS: usize = 22;
const AXIS_BRAKE: usize = 23;
const AXES: usize = 24;
/// The Android `KeyEvent` key code (KEYCODE_*) of a controller button. Back
/// (4) is East, as on Android's own screens; volume and other system keys are
/// never game input.
pub fn button(code: u32) -> Option<Button> {
Some(match code {
96 => Button::South,
97 | 4 => Button::East,
99 => Button::West,
100 => Button::North,
102 => Button::LeftBumper,
103 => Button::RightBumper,
104 => Button::LeftTrigger,
105 => Button::RightTrigger,
106 => Button::LeftStick,
107 => Button::RightStick,
108 => Button::Start,
109 => Button::Select,
19 => Button::DPadUp,
20 => Button::DPadDown,
21 => Button::DPadLeft,
22 => Button::DPadRight,
_ => return None,
})
}
/// One controller: its key states, its last axis values, and presses too short
/// to be seen by a poll (released before the next frame).
#[derive(Default)]
struct Device {
keys: PadState,
axes: [f32; AXES],
pending: PadState,
}
impl Device {
fn state(&self) -> PadState {
let a = &self.axes;
let mut s = self.keys;
// Android's Y axes grow downwards; `PadState`'s grow upwards.
s.left = [a[AXIS_X], -a[AXIS_Y]];
s.right = [a[AXIS_Z], -a[AXIS_RZ]];
// Some pads report triggers as LTRIGGER/RTRIGGER, others as BRAKE/GAS,
// others only as L2/R2 keys: `Edges` reads triggers from `triggers`.
let digital = |b| if s.held(b) { 1.0 } else { 0.0 };
let triggers = [
a[AXIS_LTRIGGER]
.max(a[AXIS_BRAKE])
.max(digital(Button::LeftTrigger)),
a[AXIS_RTRIGGER]
.max(a[AXIS_GAS])
.max(digital(Button::RightTrigger)),
];
s.triggers = triggers;
// A hat D-pad adds to a key D-pad; a centred hat doesn't release keys.
for (button, held) in [
(Button::DPadLeft, a[AXIS_HAT_X] < -0.5),
(Button::DPadRight, a[AXIS_HAT_X] > 0.5),
(Button::DPadUp, a[AXIS_HAT_Y] < -0.5),
(Button::DPadDown, a[AXIS_HAT_Y] > 0.5),
] {
s.set(button, held || s.held(button));
}
s
}
}
/// The connected controllers by Android device id, and the one in use.
#[derive(Default)]
pub struct Devices {
devices: BTreeMap<i32, Device>,
active: Option<i32>,
}
impl Devices {
/// A motion event. Returns true only for a meaningful change, so a resting
/// or drifting stick never takes over from a finger or a mouse.
pub fn axis(&mut self, id: i32, axis: u32, value: f64) -> bool {
let index = axis as usize;
if ![
AXIS_X,
AXIS_Y,
AXIS_Z,
AXIS_RZ,
AXIS_HAT_X,
AXIS_HAT_Y,
AXIS_LTRIGGER,
AXIS_RTRIGGER,
AXIS_GAS,
AXIS_BRAKE,
]
.contains(&index)
{
return false;
}
let value = if value.is_finite() {
value.clamp(-1.0, 1.0) as f32
} else {
0.0
};
let d = self.devices.entry(id).or_default();
let old = std::mem::replace(&mut d.axes[index], value);
if value >= 0.5 && old < 0.5 {
let trigger = match index {
AXIS_LTRIGGER | AXIS_BRAKE => Some(0),
AXIS_RTRIGGER | AXIS_GAS => Some(1),
_ => None,
};
if let Some(i) = trigger {
d.pending.triggers[i] = value;
}
}
let activity = value.abs() > 0.25 && (value - old).abs() > 0.01;
if activity {
self.active = Some(id);
}
activity
}
/// A key event. Returns true for a new press of a controller button (key
/// repeats and releases are not activity).
pub fn key(&mut self, id: i32, code: u32, down: bool) -> bool {
let Some(b) = button(code) else { return false };
let d = self.devices.entry(id).or_default();
let activity = down && !d.keys.held(b);
d.keys.set(b, down);
if activity {
d.pending.set(b, true);
match b {
Button::LeftTrigger => d.pending.triggers[0] = 1.0,
Button::RightTrigger => d.pending.triggers[1] = 1.0,
_ => {}
}
self.active = Some(id);
}
activity
}
/// Forget controllers that are no longer connected.
pub fn retain(&mut self, ids: &[i32]) {
self.devices.retain(|id, _| ids.contains(id));
if self.active.is_some_and(|id| !ids.contains(&id)) {
self.active = None;
}
}
pub fn clear(&mut self) {
*self = Self::default();
}
pub fn active_id(&self) -> Option<i32> {
self.active
}
/// The controller in use, without consuming short presses.
pub fn state(&self) -> PadState {
self.active
.and_then(|id| self.devices.get(&id))
.map(Device::state)
.unwrap_or_default()
}
/// The controller in use, once per frame: a press released since the last
/// poll still shows as held this once.
pub fn poll(&mut self) -> PadState {
self.active
.and_then(|id| self.devices.get_mut(&id))
.map(|d| d.state().merge(&std::mem::take(&mut d.pending)))
.unwrap_or_default()
}
}
/// The picked input mode and the device in charge. In `Auto` the last device
/// used takes over; a picked mode ignores the others.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Selection {
mode: InputMode,
active: InputMode,
}
impl Default for Selection {
fn default() -> Self {
Self {
mode: InputMode::Auto,
active: InputMode::Touch,
}
}
}
impl Selection {
/// The mode picked in the options.
pub fn mode(self) -> InputMode {
self.mode
}
/// The device in charge now; never `Auto`.
pub fn active(self) -> InputMode {
self.active
}
/// Pick a mode. Returns true when it changed: the caller drops held
/// controller and keypad input so nothing stays pressed across the switch.
pub fn configure(&mut self, mode: InputMode) -> bool {
if self.mode == mode {
return false;
}
self.mode = mode;
self.active = if mode == InputMode::Auto {
InputMode::Touch
} else {
mode
};
true
}
/// A device of `kind` was used. Returns whether it may act: false when
/// another mode was picked.
pub fn note(&mut self, kind: InputMode) -> bool {
if kind == InputMode::Auto || (self.mode != InputMode::Auto && self.mode != kind) {
return false;
}
self.active = kind;
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn android_y_is_inverted_and_hats_do_not_erase_key_dpad() {
let mut d = Devices::default();
d.axis(7, 1, -1.0);
d.axis(7, 14, 0.7);
d.key(7, 19, true);
d.axis(7, 16, 0.0);
assert_eq!(d.state().left, [0.0, 1.0]);
assert_eq!(d.state().right, [0.0, -0.7]);
assert!(d.state().held(Button::DPadUp));
d.key(7, 19, false);
assert!(!d.state().held(Button::DPadUp));
d.axis(7, 15, 1.0);
assert!(d.state().held(Button::DPadRight));
}
#[test]
fn a_short_press_survives_until_one_poll_and_disconnect_clears_it() {
let mut d = Devices::default();
d.key(4, 96, true);
assert_eq!(d.active_id(), Some(4));
d.key(4, 96, false);
assert!(d.poll().held(Button::South));
assert!(!d.poll().held(Button::South));
d.key(4, 96, true);
d.retain(&[]);
assert_eq!(d.active_id(), None);
assert_eq!(d.poll(), PadState::default());
}
#[test]
fn triggers_accept_analog_and_digital_reports() {
let mut d = Devices::default();
d.key(3, 104, true);
d.axis(3, 17, 0.0);
assert!(d.state().held(Button::LeftTrigger));
assert_eq!(d.state().triggers[0], 1.0);
d.key(3, 104, false);
assert_eq!(d.state().triggers[0], 0.0);
d.axis(3, 23, 0.8);
assert_eq!(d.state().triggers[0], 0.8);
d.axis(3, 22, 0.6);
assert_eq!(d.state().triggers[1], 0.6);
assert!(!d.key(3, 24, true)); // Volume up is never a game action.
}
#[test]
fn digital_triggers_reach_edges_even_when_tapped_between_polls() {
let mut d = Devices::default();
let mut edges = crate::gamepad::Edges::default();
d.key(1, 105, true);
d.key(1, 105, false);
assert_eq!(edges.update(&d.poll(), 0.4), [(Button::RightTrigger, true)]);
assert_eq!(
edges.update(&d.poll(), 0.4),
[(Button::RightTrigger, false)]
);
}
#[test]
fn drift_neutral_and_repeats_do_not_select_another_controller() {
let mut d = Devices::default();
assert!(!d.axis(1, 0, 0.12));
assert_eq!(d.state(), PadState::default());
assert!(d.key(2, 96, true));
assert!(!d.key(2, 96, true));
d.axis(1, 0, 0.0);
assert!(d.state().held(Button::South));
d.retain(&[1]);
assert_eq!(d.state(), PadState::default());
d.axis(1, 0, f64::NAN);
assert_eq!(d.state(), PadState::default());
assert!(!d.axis(1, 9, 1.0)); // An axis no controller stick uses.
}
#[test]
fn picked_modes_reject_other_devices_and_auto_follows_activity() {
let mut s = Selection::default();
assert_eq!((s.mode(), s.active()), (InputMode::Auto, InputMode::Touch));
assert!(s.configure(InputMode::Touch));
assert!(!s.configure(InputMode::Touch));
assert!(!s.note(InputMode::Controller));
assert_eq!(s.active(), InputMode::Touch);
assert!(s.configure(InputMode::Controller));
assert_eq!(s.active(), InputMode::Controller);
assert!(!s.note(InputMode::Touch));
assert!(s.configure(InputMode::Auto));
assert_eq!(s.active(), InputMode::Touch);
assert!(s.note(InputMode::KeyboardMouse));
assert_eq!(s.active(), InputMode::KeyboardMouse);
assert!(s.note(InputMode::Controller));
assert_eq!(s.active(), InputMode::Controller);
assert!(!s.note(InputMode::Auto));
assert_eq!(s.active(), InputMode::Controller);
}
#[test]
fn input_modes_save_by_name() {
let names: Vec<_> = InputMode::ALL
.iter()
.map(|m| serde_json::to_string(m).unwrap())
.collect();
assert_eq!(
names,
[
"\"auto\"",
"\"touch\"",
"\"keyboard-mouse\"",
"\"controller\""
]
);
assert!(InputMode::ALL.iter().all(|m| !m.label().is_empty()));
}
}
+13 -651
View File
@@ -47,53 +47,6 @@ struct Channel {
world: u32,
/// The sound file it started and when, to tell another player's copy of it.
started: Option<(String, std::time::Instant)>,
/// Its sound event plays clearly through a mod's muffle (`Audio::muffle`).
unmuffled: bool,
}
/// The cutoff of the muffle's low-pass at full strength: the world heard through ringing ears.
const MUFFLED_CUTOFF: f32 = 300.0;
/// The cutoff it sweeps down from as its strength rises.
const OPEN_CUTOFF: f32 = 20_000.0;
/// How long the muffle takes to glide to a new level: a game tick's step (50 ms) would click.
const MUFFLE_GLIDE: f32 = 0.02;
/// A mod's muffle (`Audio::muffle`) in the mixer: the other sounds are mixed apart, low-passed by
/// two one-pole stages and turned down, then added to the sounds it leaves clear. While it is off
/// (never set, or faded out) the sounds mix straight into the output, as without it.
#[derive(Default)]
struct Muffle {
/// `[amount, lowpass]`, each 0 to 1, as the game's tick last set them.
target: [f32; 2],
/// Where the output is now, gliding to `target`.
current: [f32; 2],
/// The low-pass stages' state for the left and right output channels.
stages: [[f32; 2]; 2],
/// The muffled sounds of one buffer, reused.
bus: Vec<f32>,
}
impl Muffle {
fn active(&self) -> bool {
self.target != [0.0; 2] || self.current.iter().any(|v| *v > 1e-4)
}
/// Adds `bus` (the muffled sounds) to `data`, low-passed and turned down.
fn apply(&mut self, bus: &[f32], data: &mut [f32], channels: usize, rate: u32) {
let rate = rate as f32;
let glide = 1.0 - (-1.0 / (MUFFLE_GLIDE * rate)).exp();
for (frame, muffled) in data.chunks_mut(channels).zip(bus.chunks(channels)) {
for (current, target) in self.current.iter_mut().zip(self.target) {
*current += (target - *current) * glide;
}
let [amount, lowpass] = self.current;
let gain = 1.0 - amount;
let cutoff = OPEN_CUTOFF * (MUFFLED_CUTOFF / OPEN_CUTOFF).powf(lowpass);
let a = 1.0 - (-std::f32::consts::TAU * cutoff / rate).exp();
for ((out, s), stage) in frame.iter_mut().zip(muffled).zip(&mut self.stages) {
stage[0] += (s - stage[0]) * a;
stage[1] += (stage[0] - stage[1]) * a;
// Dry at no strength, so a muffle that only turns sounds down leaves them as they were.
*out += (s + (stage[1] - s) * lowpass) * gain;
}
}
}
}
#[derive(Default)]
struct Mixer {
@@ -112,10 +65,6 @@ struct Mixer {
cameras: Vec<([f64; 3], Basis, u32)>,
/// Per-source gain/pan/step cache for one buffer, reused across callbacks.
voices: Vec<(ChannelId, [f32; 3], f64)>,
/// Mods' audio sources (`push_voice`), each with its queue.
mod_audio: Vec<ModAudioSource>,
/// A mod's muffle of the sounds it does not exempt.
muffle: Muffle,
limiter_peak: f32,
/// The output device the stream was opened on (`Library.currentDeviceName`).
#[cfg(feature = "audio")]
@@ -123,158 +72,10 @@ struct Mixer {
/// The stream reported its device gone (`Library.isCurrentDeviceDisconnected`).
#[cfg(feature = "audio")]
device_lost: bool,
/// The stream's recent backend errors, which tell a dead stream from a hiccup.
#[cfg(feature = "audio")]
stream_errors: StreamErrors,
}
/// A stream's backend errors close together (`StreamErrors::dead`).
#[cfg(any(feature = "audio", test))]
#[derive(Default)]
struct StreamErrors {
/// When the current run of errors began, and how many it holds.
since: Option<std::time::Instant>,
count: u32,
}
#[cfg(any(feature = "audio", test))]
impl StreamErrors {
/// Errors this close together...
const WINDOW: std::time::Duration = std::time::Duration::from_secs(2);
/// ...this many of them: the stream is dead. Under vanilla a device that fails unrecoverably
/// reads as disconnected (`ALC_CONNECTED`, the `ALC_EXT_disconnect` extension), and
/// `SoundEngine` opens the output again; cpal reports a dead ALSA stream (`alsa::poll()`
/// returning POLLERR, over and over) as a backend error like any other, so a run of them
/// stands for it. One alone is a hiccup the stream recovers from.
const DEAD_AFTER: u32 = 3;
/// One more backend error at `now`: whether the stream counts as dead.
fn dead(&mut self, now: std::time::Instant) -> bool {
match self.since {
Some(since) if now.duration_since(since) <= Self::WINDOW => self.count += 1,
_ => {
self.since = Some(now);
self.count = 1;
}
}
self.count >= Self::DEAD_AFTER
}
}
/// Opening the output again after it failed: a few more tries, a couple of seconds apart, so a
/// device that is gone for good isn't asked forever.
#[cfg(any(feature = "audio", test))]
#[derive(Default, Debug, PartialEq)]
struct Reopen {
/// The tries left; 0 while the output plays, or after the last try.
tries: u32,
/// Ticks since the last try.
ticks: u32,
}
#[cfg(any(feature = "audio", test))]
impl Reopen {
const TRIES: u32 = 5;
/// Two seconds of client ticks.
const TICKS: u32 = 40;
/// The output failed to open: whether to keep trying.
fn failed(&mut self, retrying: bool) -> bool {
self.ticks = 0;
self.tries = if retrying {
self.tries.saturating_sub(1)
} else {
Self::TRIES
};
self.tries > 0
}
/// A tick while tries are left: whether to try now.
fn due(&mut self) -> bool {
if self.tries == 0 {
return false;
}
self.ticks += 1;
self.ticks >= Self::TICKS
}
}
/// A camera's forward, up and right vectors.
type Basis = ([f64; 3], [f64; 3], [f64; 3]);
/// How much audio one mod source may have waiting, in seconds (`push_voice`): a burst past it
/// is refused rather than delayed, so a stalled sender cannot pile up latency.
const MOD_AUDIO_SECONDS: f64 = 2.0;
/// How many mod audio sources may play at once, across mods.
const MOD_AUDIO_SOURCES: usize = 64;
/// The distance at which positioned mod audio has faded out, in blocks (linear, as a sound's
/// `AL_LINEAR_DISTANCE` fades).
const MOD_AUDIO_RANGE: f32 = 48.0;
/// One of a mod's audio sources (`push_voice`): a speaker, keyed by the mod and its own id
/// for it. Its chunks play one after another, oldest first, so each speaker's audio stays in
/// order while different speakers play at the same time.
struct ModAudioSource {
mod_id: String,
source: u128,
chunks: std::collections::VecDeque<ModAudioChunk>,
/// How far into the head chunk the callback has got, in its own samples.
played: f64,
}
/// A block of mono samples a mod queued.
struct ModAudioChunk {
/// Mono samples, -1 to 1.
samples: Vec<f32>,
/// The rate they were sampled at.
rate: u32,
/// Where they were spoken, for distance and direction; none plays them centred.
at: Option<[f64; 3]>,
}
impl Mixer {
/// Queues `samples` behind what `source` of `mod_id` already has waiting. False, and
/// nothing queued, when they are empty or the rate is 0, when the source would hold more
/// than [`MOD_AUDIO_SECONDS`], or when a new source would pass [`MOD_AUDIO_SOURCES`].
fn push_mod_audio(
&mut self,
mod_id: &str,
source: u128,
samples: &[f32],
rate: u32,
at: Option<[f64; 3]>,
) -> bool {
if samples.is_empty() || rate == 0 {
return false;
}
let seconds = |len: usize, rate: u32| len as f64 / f64::from(rate);
let added = seconds(samples.len(), rate);
let chunk = ModAudioChunk {
samples: samples.to_vec(),
rate,
at,
};
match self
.mod_audio
.iter()
.position(|s| s.source == source && s.mod_id == mod_id)
{
Some(i) => {
let s = &mut self.mod_audio[i];
let waiting: f64 = s
.chunks
.iter()
.map(|c| seconds(c.samples.len(), c.rate))
.sum();
if waiting + added > MOD_AUDIO_SECONDS {
return false;
}
s.chunks.push_back(chunk);
}
None => {
if added > MOD_AUDIO_SECONDS || self.mod_audio.len() >= MOD_AUDIO_SOURCES {
return false;
}
self.mod_audio.push(ModAudioSource {
mod_id: mod_id.to_owned(),
source,
chunks: std::collections::VecDeque::from([chunk]),
played: 0.0,
});
}
}
true
}
/// The id of world `name` (1 and up), given once per name.
fn world_id(&mut self, name: &str) -> u32 {
let i = match self.world_names.iter().position(|w| w == name) {
@@ -299,39 +100,6 @@ pub struct Heard {
pub range: f32,
pub looping: bool,
}
/// A mod's muffle (`Audio::muffle`): its full strength, and the ticks it fades over and has left.
#[derive(Clone, Copy, Debug, PartialEq)]
struct MuffleFade {
amount: f32,
lowpass: f32,
ticks: u32,
left: u32,
}
impl MuffleFade {
/// `[amount, lowpass]` now: full when set, falling in a straight line to none.
fn level(&self) -> [f32; 2] {
let left = self.left as f32 / self.ticks as f32;
[self.amount * left, self.lowpass * left]
}
}
impl MixerBackend {
/// Queues a mod's audio for the output callback (`Host::push_voice`). False when nothing
/// was queued: no output device, or what [`Mixer::push_mod_audio`] refuses.
pub fn push_voice(
&self,
mod_id: &str,
source: u128,
samples: &[f32],
rate: u32,
at: Option<[f64; 3]>,
) -> bool {
self.live
&& self
.mixer
.lock()
.is_ok_and(|mut m| m.push_mod_audio(mod_id, source, samples, rate, at))
}
}
/// The mixer side of the engine (`Backend`).
pub struct MixerBackend {
mixer: Arc<Mutex<Mixer>>,
@@ -454,21 +222,6 @@ pub struct CapturedSound {
pub except_entity: Option<u32>,
}
impl Audio {
/// Plays a mod's audio (`Host::push_voice`): mono samples, -1 to 1, at `rate`, behind what
/// `source` of `mod_id` already has waiting; `at` places them for distance and direction.
pub fn push_voice(
&self,
mod_id: &str,
source: u128,
samples: &[f32],
rate: u32,
at: Option<[f64; 3]>,
) -> bool {
self.backend.push_voice(mod_id, source, samples, rate, at)
}
}
pub struct Audio {
_stream: Option<Stream>,
device_name: String,
@@ -494,8 +247,6 @@ pub struct Audio {
pub stopped: std::collections::BTreeMap<String, u32>,
/// The volume sliders' `SoundPreviewHandler`.
preview: crate::sound_preview::SoundPreview,
/// A mod's muffle fading out (`muffle`), advanced by `tick`.
muffle: Option<MuffleFade>,
/// `SoundEngine.lastSeenDevices`: the system default output last seen.
#[cfg(feature = "audio")]
default_seen: Option<String>,
@@ -510,9 +261,6 @@ pub struct Audio {
/// Ticks since the output devices were last looked at (`DeviceTracker.tick`).
#[cfg(feature = "audio")]
device_ticks: u32,
/// Opening the output again after it failed to (`Reopen`).
#[cfg(feature = "audio")]
reopen: Reopen,
/// The look at the output devices running on its own thread, until a tick
/// takes its answer: listing them can take 100 ms (#11490).
#[cfg(feature = "audio")]
@@ -691,7 +439,6 @@ impl Backend for MixerBackend {
owner: self.owner,
world: self.world,
started: None,
unmuffled: false,
},
);
Some(id)
@@ -719,9 +466,6 @@ impl Backend for MixerBackend {
fn set_relative(&mut self, channel: ChannelId, relative: bool) {
self.with(channel, |c| c.relative = relative);
}
fn set_unmuffled(&mut self, channel: ChannelId, unmuffled: bool) {
self.with(channel, |c| c.unmuffled = unmuffled);
}
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
/// `getStream` (decoded on a thread of its own), then attach and
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
@@ -958,13 +702,6 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, [left, right, gain], step));
}
// A mod's muffle: the sounds it doesn't exempt mix into a bus of their own first.
let muffling = mixer.muffle.active();
let mut bus = std::mem::take(&mut mixer.muffle.bus);
if muffling {
bus.clear();
bus.resize(data.len(), 0.0);
}
for (id, [left, right, gain], step) in voices.iter().copied() {
let Some(c) = mixer.channels.get_mut(&id) else {
continue;
@@ -974,12 +711,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
};
let source_channels = source_channels as usize;
let frames = samples.len() / source_channels;
let out: &mut [f32] = if muffling && !c.unmuffled {
&mut bus
} else {
&mut *data
};
for frame in out.chunks_mut(channels) {
for frame in data.chunks_mut(channels) {
let mut i = c.position as usize;
if i + 1 >= frames {
if c.looping && frames > 1 {
@@ -1014,70 +746,6 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
}
}
mixer.voices = voices;
if muffling {
mixer.muffle.apply(&bus, data, channels, rate);
} else {
// Off: the next muffle starts from silence, not from the last one's filter.
mixer.muffle.current = [0.0; 2];
mixer.muffle.stages = [[0.0; 2]; 2];
}
mixer.muffle.bus = bus;
// A mod's audio (`push_voice`): every source at once, each playing its own chunks in order;
// a chunk that ends mid-buffer hands the rest of the buffer to the next one.
if !mixer.mod_audio.is_empty() {
let (forward, up, right) = basis(mixer.listener_yaw, mixer.listener_pitch);
let listener = mixer.listener;
let frames = data.len() / channels.max(1);
for source in &mut mixer.mod_audio {
let ModAudioSource { chunks, played, .. } = source;
let mut f = 0;
while f < frames {
let Some(chunk) = chunks.front() else {
break;
};
let len = chunk.samples.len();
let [left, right_gain, gain] = match chunk.at {
Some(at) => {
let d = [
at[0] - listener[0],
at[1] - listener[1],
at[2] - listener[2],
];
let gain = distance_gain(Some(MOD_AUDIO_RANGE), dot(d, d).sqrt());
let [l, r] = stereo_gains(dot(d, right), dot(d, up), dot(d, forward));
[l * gain, r * gain, gain]
}
None => [1.0; 3],
};
let step = f64::from(chunk.rate) / f64::from(rate);
while f < frames && (*played as usize) < len {
let i = *played as usize;
let t = (*played - i as f64) as f32;
let next = chunk
.samples
.get(i + 1)
.copied()
.unwrap_or(chunk.samples[i]);
let s = chunk.samples[i] * (1.0 - t) + next * t;
let frame = &mut data[f * channels..(f + 1) * channels];
if channels >= 2 {
frame[0] += s * left;
frame[1] += s * right_gain;
} else {
frame[0] += s * gain;
}
*played += step;
f += 1;
}
if *played as usize >= len {
// The next chunk starts where this one's last step left off.
*played -= len as f64;
chunks.pop_front();
}
}
}
mixer.mod_audio.retain(|s| !s.chunks.is_empty());
}
// Limit the whole buffer together, preserving waveform and stereo balance
// instead of flattening each overloaded sample; release over 200 ms
// (OpenAL Soft's output limiter, `ALC_OUTPUT_LIMITER_SOFT`, releases
@@ -1145,7 +813,6 @@ fn open_device(
if let Ok(mut m) = mixer.lock() {
m.device_name = device.name().unwrap_or_default();
m.device_lost = false;
m.stream_errors = StreamErrors::default();
}
Ok((stream, config))
}
@@ -1295,19 +962,10 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
},
move |e| {
log::warn!("Horizon: audio stream error: {e}");
if let Ok(mut m) = on_error.lock() {
let dead = match e {
cpal::StreamError::DeviceNotAvailable => true,
cpal::StreamError::BackendSpecific { .. } => {
m.stream_errors.dead(std::time::Instant::now())
}
};
if dead && !m.device_lost {
if !matches!(e, cpal::StreamError::DeviceNotAvailable) {
log::warn!("Horizon: the audio stream stopped; opening the output again");
}
m.device_lost = true;
}
if matches!(e, cpal::StreamError::DeviceNotAvailable)
&& let Ok(mut m) = on_error.lock()
{
m.device_lost = true;
}
},
None,
@@ -1413,13 +1071,12 @@ impl Audio {
listener: [0.0; 3],
listener_yaw: 0.0,
master: 1.0,
random: crate::random_source::create(),
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
source_override: None,
capture: None,
heard: Default::default(),
stopped: Default::default(),
preview: Default::default(),
muffle: None,
#[cfg(feature = "audio")]
default_seen: if live { default_output_name() } else { None },
#[cfg(feature = "audio")]
@@ -1429,8 +1086,6 @@ impl Audio {
#[cfg(feature = "audio")]
device_ticks: 0,
#[cfg(feature = "audio")]
reopen: Reopen::default(),
#[cfg(feature = "audio")]
device_probe: None,
}
}
@@ -1577,13 +1232,12 @@ impl Audio {
listener: [0.0; 3],
listener_yaw: 0.0,
master: self.master,
random: crate::random_source::create(),
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
source_override: None,
capture: None,
heard: Default::default(),
stopped: Default::default(),
preview: Default::default(),
muffle: None,
// The device belongs to the first player's output, which looks after it.
#[cfg(feature = "audio")]
default_seen: self.default_seen.clone(),
@@ -1594,8 +1248,6 @@ impl Audio {
#[cfg(feature = "audio")]
device_ticks: 0,
#[cfg(feature = "audio")]
reopen: Reopen::default(),
#[cfg(feature = "audio")]
device_probe: None,
}
}
@@ -1703,27 +1355,6 @@ impl Audio {
}
changed
}
/// Opens the output on the chosen device again; `retrying` after an earlier try failed.
#[cfg(feature = "audio")]
fn reopen_output(&mut self, retrying: bool) {
// The old stream lets go of the device first: a dead one may still hold it.
self._stream = None;
let device = self.device_name.clone();
match self.set_device(&device) {
Ok(()) => {
if retrying {
log::info!("Horizon: the audio output is back");
}
self.reopen = Reopen::default();
}
Err(e) if self.reopen.failed(retrying) => {
log::warn!("Horizon: could not reopen the audio output ({e}); trying again soon");
}
Err(e) => {
log::warn!("Horizon: could not reopen the audio output ({e}); it stays off");
}
}
}
/// `SoundManager.tick(paused)`, once per client tick.
pub fn tick(&mut self, paused: bool, ctx: &TickContext) {
#[cfg(feature = "audio")]
@@ -1731,57 +1362,14 @@ impl Audio {
if self.should_change_device() {
// SoundEngine.reload: stop every sound, open the output again.
self.stop_all();
self.reopen_output(false);
} else if self.backend.owner == 0 && self.reopen.due() {
self.reopen_output(true);
let device = self.device_name.clone();
if let Err(e) = self.set_device(&device) {
log::warn!("Horizon: could not reopen the audio output: {e}");
}
}
}
self.sync_volumes();
self.engine.tick(paused, ctx, &mut self.backend);
// A paused game holds a mod's muffle where it is, as it holds the sounds.
if !paused && let Some(fade) = &mut self.muffle {
fade.left = fade.left.saturating_sub(1);
let level = fade.level();
if fade.left == 0 {
self.muffle = None;
self.engine.set_unmuffled(Vec::new(), &mut self.backend);
}
self.set_muffle_level(level);
}
}
/// A mod's muffle (`native_muffle_sounds`): `amount` (0 to 1) of every sound's volume taken
/// away and `lowpass` (0 to 1) dulling it, from untouched to a 300 Hz low-pass, fading in a
/// straight line to none over `ticks` client ticks; the sound events in `exempt` (namespaced,
/// `mymod:ear.ringing`) play clearly through it, those already playing too. It replaces any
/// muffle in place; `ticks` 0 or no strength ends it. The output's, so in splitscreen every
/// player hears it. False for an amount or strength outside 0 to 1.
pub fn muffle(&mut self, amount: f32, lowpass: f32, ticks: u32, exempt: &[&str]) -> bool {
if !(0.0..=1.0).contains(&amount) || !(0.0..=1.0).contains(&lowpass) {
return false;
}
let on = ticks > 0 && (amount > 0.0 || lowpass > 0.0);
let exempt = if on {
exempt
.iter()
.map(|e| crate::sound_events::normalize(e))
.collect()
} else {
Vec::new()
};
self.engine.set_unmuffled(exempt, &mut self.backend);
self.muffle = on.then_some(MuffleFade {
amount,
lowpass,
ticks,
left: ticks,
});
self.set_muffle_level(self.muffle.map_or([0.0; 2], |fade| fade.level()));
true
}
fn set_muffle_level(&mut self, level: [f32; 2]) {
if let Ok(mut m) = self.backend.mixer.lock() {
m.muffle.target = level;
}
}
/// The `uiSoundReserve` extra: positional sounds leave
/// `sound_engine::HEARD_RESERVE` static channels free (off: vanilla's pool).
@@ -1880,14 +1468,6 @@ impl Audio {
let id = self.engine.add(instance, false);
self.engine.play_delayed(id, ticks);
}
/// `SoundManager.playDelayed` of an instance the caller made: it starts
/// `ticks` sound-engine ticks from now.
pub fn play_instance_after(&mut self, instance: SoundInstance, ticks: i32) {
*self.heard.entry(instance.identifier.clone()).or_default() += 1;
self.sync_volumes();
let id = self.engine.add(instance, false);
self.engine.play_delayed(id, ticks);
}
/// `play` for this client only: a sound a packet's handler plays
/// (`playLocalSound`), which the guests play from that packet themselves.
pub fn play_unshared(&mut self, event: &str, at: Option<[f64; 3]>, volume: f32, pitch: f32) {
@@ -1902,7 +1482,7 @@ impl Audio {
"minecraft:ui.button.click",
1.0,
0.25,
crate::random_source::create(),
SoundEngine::unseeded(),
),
false,
);
@@ -2044,7 +1624,7 @@ impl Audio {
f64::from(pos[2]) + 0.5,
];
let event = crate::sound_events::normalize(event);
let instance = SoundInstance::for_jukebox_song(&event, at, crate::random_source::create());
let instance = SoundInstance::for_jukebox_song(&event, at, SoundEngine::unseeded());
self.play_instance(instance, true).0
}
/// Stop and drop a held instance.
@@ -2134,46 +1714,6 @@ impl Sounds for Audio {
mod tests {
use super::*;
/// A run of backend errors close together is a dead stream; one or two, or errors far apart,
/// are hiccups.
#[test]
fn a_run_of_stream_errors_is_a_dead_stream() {
use std::time::{Duration, Instant};
let start = Instant::now();
let at = |ms: u64| start + Duration::from_millis(ms);
let mut errors = StreamErrors::default();
assert!(!errors.dead(at(0)));
assert!(!errors.dead(at(500)));
assert!(errors.dead(at(900)));
// Spread out, they never add up.
let mut apart = StreamErrors::default();
for k in 0..10 {
assert!(!apart.dead(at(k * 2500)), "error {k}");
}
}
/// A failed reopening is tried again every two seconds, five more times, then given up.
#[test]
fn a_failed_reopening_retries_a_few_times_then_stops() {
let mut reopen = Reopen::default();
assert!(!reopen.due(), "nothing to retry while the output plays");
assert!(reopen.failed(false));
let mut tries = 0;
for _ in 0..1000 {
if reopen.due() {
tries += 1;
if !reopen.failed(true) {
break;
}
}
}
assert_eq!(tries, Reopen::TRIES);
assert_eq!(reopen, Reopen::default());
// The next lost device starts a fresh run.
assert!(reopen.failed(false));
assert_eq!(reopen.tries, Reopen::TRIES);
}
#[test]
fn level_capture_keeps_order_category_entity_and_actor_exclusion() {
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-entity-sound-test"));
@@ -2295,7 +1835,6 @@ mod tests {
owner: 0,
world: 0,
started: None,
unmuffled: false,
}
}
@@ -2716,183 +2255,6 @@ mod tests {
assert_eq!(data, [0.5; 2]);
}
/// One second of a `hz` sine at 48 kHz, amplitude 0.3 (looped, it repeats seamlessly).
fn sine(hz: f32) -> Vec<f32> {
(0..48_000)
.map(|i| 0.3 * (std::f32::consts::TAU * hz * i as f32 / 48_000.0).sin())
.collect()
}
fn rms(data: &[f32]) -> f32 {
(data.iter().map(|s| s * s).sum::<f32>() / data.len() as f32).sqrt()
}
/// `seconds` of the mixer's stereo output, in the 10 ms buffers an output stream asks for.
fn listen(m: &mut Mixer, seconds: f32) -> Vec<f32> {
let mut out = Vec::new();
let mut buffer = [0.0; 960];
for _ in 0..(seconds * 100.0).round() as usize {
mix(m, &mut buffer, 2, 48_000);
out.extend_from_slice(&buffer);
}
out
}
#[test]
fn a_muffle_dulls_and_quietens_every_sound_but_the_exempt_ones() {
// A looping tone heard through a muffle of `[amount, lowpass]`, once it has settled.
let heard = |hz: f32, muffle: [f32; 2], unmuffled: bool| {
let mut m = Mixer::default();
let mut c = channel(sine(hz), None, [0.0, 0.0, 2.0], true);
c.unmuffled = unmuffled;
m.channels.insert(1, c);
m.muffle.target = muffle;
listen(&mut m, 0.2);
listen(&mut m, 0.2)
};
let plain = |hz: f32| rms(&heard(hz, [0.0; 2], false));
// Fully low-passed, a 6 kHz whine all but goes and a 100 Hz hum keeps most of its level.
assert!(rms(&heard(6000.0, [0.0, 1.0], false)) < plain(6000.0) * 0.05);
assert!(rms(&heard(100.0, [0.0, 1.0], false)) > plain(100.0) * 0.8);
// Half the volume taken and no low-pass: half the level, the tone itself untouched.
let ducked = rms(&heard(6000.0, [0.5, 0.0], false)) / plain(6000.0);
assert!((ducked - 0.5).abs() < 1e-3, "{ducked}");
// An exempt sound plays through as if there were no muffle, sample for sample.
assert_eq!(
heard(6000.0, [0.5, 1.0], true),
heard(6000.0, [0.0; 2], false)
);
}
#[test]
fn a_faded_muffle_leaves_the_sound_as_it_was() {
let mixer = || {
let mut m = Mixer::default();
m.channels
.insert(1, channel(sine(3000.0), None, [2.0, 0.0, 1.0], true));
m
};
let (mut muffled, mut plain) = (mixer(), mixer());
muffled.muffle.target = [0.7, 1.0];
assert_ne!(listen(&mut muffled, 0.1), listen(&mut plain, 0.1));
muffled.muffle.target = [0.0; 2];
listen(&mut muffled, 0.5);
listen(&mut plain, 0.5);
assert_eq!(listen(&mut muffled, 0.1), listen(&mut plain, 0.1));
}
#[test]
fn a_mods_muffle_fades_with_the_game_ticks_and_spares_its_exempt_sounds() {
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-test-assets"));
audio.engine.registry.add_file(
"minecraft",
br#"{"ambient.cave":{"sounds":["ambient/cave/cave1"]}}"#,
&|_, _| true,
);
audio.engine.registry.add_file(
"hztest",
br#"{"ear.ringing":{"sounds":["ear/ringing"]}}"#,
&|_, _| true,
);
// The engine makes its channels; with no output, none loads a file.
audio.engine.loaded = true;
let marks = |audio: &Audio| {
let m = audio.backend.mixer.lock().unwrap();
let mut marks: Vec<_> = m
.channels
.iter()
.map(|(&id, c)| (id, c.unmuffled))
.collect();
marks.sort();
marks
};
let level = |audio: &Audio| audio.backend.mixer.lock().unwrap().muffle.target;
let close =
|a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
// Two sounds already playing, and one exempt sound started under the muffle.
audio.play("ambient.cave", None, 1.0, 1.0);
audio.play("hztest:ear.ringing", None, 1.0, 1.0);
assert!(audio.muffle(0.8, 1.0, 4, &["hztest:ear.ringing"]));
audio.play("hztest:ear.ringing", None, 1.0, 1.0);
assert_eq!(marks(&audio), [(1, false), (2, true), (3, true)]);
assert!(close(level(&audio), [0.8, 1.0]));
let ticks = TickContext::default();
audio.tick(false, &ticks);
assert!(close(level(&audio), [0.6, 0.75]), "{:?}", level(&audio));
// A paused game holds it.
audio.tick(true, &ticks);
assert!(close(level(&audio), [0.6, 0.75]), "{:?}", level(&audio));
for _ in 0..3 {
audio.tick(false, &ticks);
}
assert_eq!(level(&audio), [0.0; 2]);
// Over: its exemptions go with it.
assert_eq!(marks(&audio), [(1, false), (2, false), (3, false)]);
// Out of range changes nothing.
assert!(!audio.muffle(1.5, 0.0, 10, &[]));
assert_eq!(level(&audio), [0.0; 2]);
}
#[test]
fn each_mod_audio_source_plays_its_own_chunks_in_order() {
let mut m = Mixer::default();
// Two speakers of one mod, each queuing two chunks, interleaved.
for (source, level) in [(1, 0.1), (2, 0.4), (1, 0.2), (2, 0.3)] {
assert!(m.push_mod_audio("voice", source, &[level; 4], 48_000, None));
}
let mut data = vec![0.0f32; 8 * 2];
mix(&mut m, &mut data, 2, 48_000);
// Each speaker's first chunk, then its second: never both of one speaker at once.
let left: Vec<f32> = data.chunks(2).map(|f| f[0]).collect();
let expected = [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5];
assert!(
left.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6),
"{left:?}"
);
assert!(m.mod_audio.is_empty(), "both sources played out");
// Order within a source: its second chunk follows its first.
assert!(m.push_mod_audio("voice", 1, &[0.25; 2], 48_000, None));
assert!(m.push_mod_audio("voice", 1, &[0.5; 2], 48_000, None));
let mut data = vec![0.0f32; 4 * 2];
mix(&mut m, &mut data, 2, 48_000);
let left: Vec<f32> = data.chunks(2).map(|f| f[0]).collect();
assert_eq!(left, [0.25, 0.25, 0.5, 0.5]);
}
#[test]
fn mod_audio_sources_are_kept_apart_by_mod_and_capped() {
let mut m = Mixer::default();
assert!(m.push_mod_audio("a", 1, &[0.1; 48_000], 48_000, None));
assert!(m.push_mod_audio("b", 1, &[0.1; 48_000], 48_000, None));
assert_eq!(
m.mod_audio.len(),
2,
"the same id in two mods is two sources"
);
// Two seconds wait at most per source; a burst past that is refused, not delayed.
assert!(m.push_mod_audio("a", 1, &[0.1; 48_000], 48_000, None));
assert!(!m.push_mod_audio("a", 1, &[0.1; 1], 48_000, None));
assert!(!m.push_mod_audio("a", 2, &[], 48_000, None));
assert!(!m.push_mod_audio("a", 2, &[0.1], 0, None));
}
#[test]
fn mod_audio_is_panned_and_faded_by_where_it_was_spoken() {
let mut m = Mixer::default();
// Facing south (yaw 0), -X is the listener's right.
assert!(m.push_mod_audio("voice", 1, &[0.5; 16], 48_000, Some([-4.0, 0.0, 0.0])));
let mut data = vec![0.0f32; 16 * 2];
mix(&mut m, &mut data, 2, 48_000);
let left: f32 = data.chunks(2).map(|f| f[0]).sum();
let right: f32 = data.chunks(2).map(|f| f[1]).sum();
assert!(right > left, "right {right} should beat left {left}");
// Past the range it is silent.
assert!(m.push_mod_audio("voice", 1, &[0.5; 16], 48_000, Some([0.0, 0.0, 60.0])));
let mut data = vec![0.0f32; 16 * 2];
mix(&mut m, &mut data, 2, 48_000);
assert!(data.iter().all(|s| *s == 0.0));
}
#[test]
fn output_devices_are_looked_at_again_only_when_the_cards_change() {
// No fingerprint (not Linux, or the cards could not be read): the list
+1 -6
View File
@@ -191,18 +191,13 @@ pub fn discovery_url() -> String {
.unwrap_or_else(|| DEFAULT_DISCOVERY.to_owned())
}
pub(crate) fn agent() -> ureq::Agent {
fn agent() -> ureq::Agent {
ureq::AgentBuilder::new()
.timeout(Duration::from_secs(15))
.user_agent(&format!("Horizon/{}", horizon_core::release::VERSION))
.build()
}
/// Whether `HORIZON_API_DISCOVERY` names another services environment.
pub fn discovery_overridden() -> bool {
discovery_url() != DEFAULT_DISCOVERY
}
/// The discovery document, fetched once per game (a failure is retried next time).
pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
static CACHE: Mutex<Option<Arc<serde_json::Value>>> = Mutex::new(None);
@@ -1,314 +0,0 @@
//! GPU Culling's Auto mode: the setting switched by what limits the frame, measured here.
//!
//! GPU Culling moves the walk over the near sections and the listing of their draws from the
//! render thread to the GPU. That pays where the CPU is the slower of the two and costs where
//! the GPU is, and which a machine is can't be told from its parts: a fast CPU with a modest
//! GPU, a slow one with a strong GPU and every mix between depend on the scene, the render
//! distance and the resolution. So Auto watches the frames' own CPU and GPU times, and only
//! where the CPU is clearly the slower one it tries GPU Culling, measures the frame again, and
//! keeps it if the frame got faster. A trial that didn't help is undone and not repeated for
//! minutes of play, then for longer, then not until the next start, so a machine or a scene it
//! doesn't suit loses a few frames' time a few times, not every half minute.
//!
//! A verdict is the medians of a window of frames (`WINDOW`), which a few slow frames (a chunk
//! load, say) don't move. Nothing here reads the GPU or draws anything: the caller feeds it the
//! frames and applies what it answers.
/// Frames in one verdict.
pub const WINDOW: usize = 240;
/// The CPU's time must be this many times the GPU's for a window to count as CPU-bound.
const CPU_BOUND: f32 = 1.3;
/// Below this CPU time, in milliseconds, a frame is fast enough that neither matters.
const WORTH_TRYING_MS: f32 = 1.5;
/// A trial is kept when the frame's busier side dropped under this share of what it was.
const KEEP_BELOW: f32 = 0.97;
/// Windows of CPU-bound frames in a row before a trial.
const STREAK: u8 = 2;
/// Windows left out after a switch, while its effects settle.
const SETTLE: u8 = 1;
/// How long, in milliseconds of frames watched, after the first and the second trial that didn't
/// help before another; after a third there is none.
const RETRY_AFTER_MS: [f64; 2] = [5.0 * 60_000.0, 20.0 * 60_000.0];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum State {
/// Off, looking for a CPU-bound frame.
Watching,
/// On for a trial: left out `SETTLE` windows, then measured against the frame before.
Trial,
/// The trial made the frame faster: on from here.
Kept,
}
/// What Auto has decided and why, a step at a time.
#[derive(Clone, Debug)]
pub struct GpuCullingAuto {
cpu: Vec<f32>,
gpu: Vec<f32>,
on: bool,
state: State,
/// CPU-bound windows in a row.
streak: u8,
/// The frame's busier side before the trial, in milliseconds.
before: f32,
/// Windows still left out after a switch.
settling: u8,
/// The frames watched so far, in milliseconds of their own time.
clock_ms: f64,
/// When the next trial may start, on that clock.
retry_at_ms: f64,
/// Trials that didn't help.
rejected: usize,
}
impl Default for GpuCullingAuto {
fn default() -> Self {
Self::new()
}
}
impl GpuCullingAuto {
pub fn new() -> Self {
Self {
cpu: Vec::with_capacity(WINDOW),
gpu: Vec::with_capacity(WINDOW),
on: false,
state: State::Watching,
streak: 0,
before: 0.0,
settling: 0,
clock_ms: 0.0,
retry_at_ms: 0.0,
rejected: 0,
}
}
/// Whether Auto has GPU Culling on now.
pub fn on(&self) -> bool {
self.on
}
/// Where Auto is, for the status: `watching`, `trial` or `kept`.
pub fn state(&self) -> &'static str {
match self.state {
State::Watching => "watching",
State::Trial => "trial",
State::Kept => "kept",
}
}
/// Back to the start: off, with nothing measured (the mode changed, or the world did).
pub fn reset(&mut self) {
*self = Self::new();
}
/// One frame's CPU time, a GPU time where its timestamps have arrived, and its whole time,
/// in milliseconds. Answers the new state when this frame completed a window that changed it.
pub fn observe(&mut self, cpu_ms: f32, gpu_ms: Option<f32>, frame_ms: f32) -> Option<bool> {
self.clock_ms += f64::from(frame_ms);
self.cpu.push(cpu_ms);
if let Some(gpu) = gpu_ms {
self.gpu.push(gpu);
}
if self.cpu.len() < WINDOW {
return None;
}
let cpu = median(&mut self.cpu);
// A GPU that can't be timed leaves nothing to compare with: Auto stays as it is.
let gpu = (self.gpu.len() >= WINDOW / 4).then(|| median(&mut self.gpu));
self.cpu.clear();
self.gpu.clear();
self.verdict(cpu, gpu?)
}
fn verdict(&mut self, cpu: f32, gpu: f32) -> Option<bool> {
if self.settling > 0 {
self.settling -= 1;
return None;
}
match self.state {
State::Kept => None,
State::Watching => {
if self.clock_ms < self.retry_at_ms {
return None;
}
let bound = cpu >= WORTH_TRYING_MS && cpu > gpu * CPU_BOUND;
self.streak = if bound { self.streak + 1 } else { 0 };
if self.streak < STREAK {
return None;
}
self.streak = 0;
self.before = cpu.max(gpu);
self.state = State::Trial;
self.settling = SETTLE;
self.on = true;
Some(true)
}
State::Trial => {
if cpu.max(gpu) < self.before * KEEP_BELOW {
self.state = State::Kept;
return None;
}
self.state = State::Watching;
self.retry_at_ms = RETRY_AFTER_MS
.get(self.rejected)
.map_or(f64::INFINITY, |wait| self.clock_ms + wait);
self.rejected += 1;
self.on = false;
Some(false)
}
}
}
}
fn median(values: &mut [f32]) -> f32 {
values.sort_by(f32::total_cmp);
values[values.len() / 2]
}
#[cfg(test)]
mod tests {
use super::*;
/// A window of frames at constant times, each as long as its busier side; the last answer.
fn window(auto: &mut GpuCullingAuto, cpu: f32, gpu: f32) -> Option<bool> {
(0..WINDOW)
.filter_map(|_| auto.observe(cpu, Some(gpu), cpu.max(gpu)))
.last()
}
/// A machine whose CPU limits the frame is tried, and keeps GPU Culling when the frame
/// gets faster.
#[test]
fn a_cpu_bound_frame_tries_it_and_keeps_it_when_it_helps() {
let mut auto = GpuCullingAuto::new();
assert!(!auto.on());
// One CPU-bound window is not yet a verdict.
assert_eq!(window(&mut auto, 5.0, 2.0), None);
assert_eq!(window(&mut auto, 5.0, 2.0), Some(true));
assert!(auto.on());
assert_eq!(auto.state(), "trial");
// The window after the switch is left out, whatever it shows.
assert_eq!(window(&mut auto, 9.0, 9.0), None);
// The frame's busier side went from 5.0 to 3.0 ms.
assert_eq!(window(&mut auto, 3.0, 2.6), None);
assert_eq!(auto.state(), "kept");
assert!(auto.on());
// Kept stays kept, even if a later scene is GPU-bound.
assert_eq!(window(&mut auto, 1.0, 8.0), None);
assert!(auto.on());
}
/// Where it didn't help, it is undone, and not tried again for 5 minutes, then 20, then
/// not at all: a scene it doesn't suit is not tried every half minute.
#[test]
fn a_trial_that_does_not_help_is_undone_and_waits_longer_each_time() {
let mut auto = GpuCullingAuto::new();
// One window is 240 frames of 5 ms: 1.2 s. A trial, and its rejection:
let reject = |auto: &mut GpuCullingAuto| {
window(auto, 5.0, 2.0);
assert_eq!(window(auto, 5.0, 2.0), Some(true));
window(auto, 5.0, 2.0);
// The GPU took over the frame: 5.0 ms before, 5.4 after.
assert_eq!(window(auto, 2.5, 5.4), Some(false));
assert!(!auto.on());
assert_eq!(auto.state(), "watching");
};
reject(&mut auto);
// CPU-bound as ever, and left alone for 5 minutes of frames: 250 windows.
let mut waited = 0;
while window(&mut auto, 5.0, 2.0).is_none() {
waited += 1;
}
assert!((248..=252).contains(&waited), "{waited}");
// That window started the second trial; it is rejected too.
window(&mut auto, 5.0, 2.0);
assert_eq!(window(&mut auto, 2.5, 5.4), Some(false));
// Now 20 minutes: 1000 windows.
let mut waited = 0;
while window(&mut auto, 5.0, 2.0).is_none() {
waited += 1;
}
assert!((998..=1002).contains(&waited), "{waited}");
window(&mut auto, 5.0, 2.0);
assert_eq!(window(&mut auto, 2.5, 5.4), Some(false));
// A third rejection is for good.
for _ in 0..5000 {
assert_eq!(window(&mut auto, 5.0, 2.0), None);
}
assert!(!auto.on());
}
/// A GPU-bound machine is never switched: its frame is the GPU's.
#[test]
fn a_gpu_bound_frame_is_left_alone() {
let mut auto = GpuCullingAuto::new();
for _ in 0..20 {
assert_eq!(window(&mut auto, 0.5, 3.6), None);
}
assert!(!auto.on());
}
/// Within the margin neither side limits the frame, and a single CPU-bound window among
/// others breaks no streak into a trial.
#[test]
fn neither_a_balanced_nor_an_occasional_cpu_bound_frame_is_tried() {
let mut auto = GpuCullingAuto::new();
for _ in 0..10 {
assert_eq!(window(&mut auto, 3.0, 2.6), None);
}
for _ in 0..10 {
assert_eq!(window(&mut auto, 5.0, 2.0), None);
assert_eq!(window(&mut auto, 2.0, 2.0), None);
}
assert!(!auto.on());
}
/// Fast frames aren't worth a trial, bound or not.
#[test]
fn a_frame_under_the_floor_is_not_tried() {
let mut auto = GpuCullingAuto::new();
for _ in 0..10 {
assert_eq!(window(&mut auto, 1.2, 0.2), None);
}
assert!(!auto.on());
}
/// Without GPU times (a GPU that can't be timed) there is nothing to compare.
#[test]
fn frames_without_gpu_times_decide_nothing() {
let mut auto = GpuCullingAuto::new();
for _ in 0..WINDOW * 10 {
assert_eq!(auto.observe(8.0, None, 8.0), None);
}
assert!(!auto.on());
}
/// A spike in a window doesn't move its verdict: the median ignores it.
#[test]
fn a_few_slow_frames_do_not_make_a_window_cpu_bound() {
let mut auto = GpuCullingAuto::new();
for _ in 0..4 {
let mut answer = None;
for i in 0..WINDOW {
// One frame in ten is a 30 ms chunk load, the rest are GPU-bound.
let cpu = if i % 10 == 0 { 30.0 } else { 0.8 };
answer = answer.or(auto.observe(cpu, Some(3.0), 3.0));
}
assert_eq!(answer, None);
}
assert!(!auto.on());
}
#[test]
fn reset_starts_over() {
let mut auto = GpuCullingAuto::new();
window(&mut auto, 5.0, 2.0);
window(&mut auto, 5.0, 2.0);
assert!(auto.on());
auto.reset();
assert!(!auto.on());
assert_eq!(auto.state(), "watching");
assert_eq!(window(&mut auto, 5.0, 2.0), None);
}
}
+2 -36
View File
@@ -17,15 +17,11 @@ pub struct BanDetails {
pub reason_message: Option<String>,
}
/// authlib's `UserProperties`, as far as the title screen and the friends service read it.
/// authlib's `UserProperties`, as far as the title screen reads it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UserProperties {
/// `UserFlag.SERVERS_ALLOWED` (`privileges.multiplayerServer.enabled`).
pub servers_allowed: bool,
/// `UserFlag.FRIENDS_ENABLED` (`friendsPreferences.friends`).
pub friends_enabled: bool,
/// `UserFlag.ACCEPT_FRIEND_INVITES` (`friendsPreferences.acceptInvites`).
pub accept_friend_invites: bool,
/// `bannedScopes.get("MULTIPLAYER")`.
pub multiplayer_ban: Option<BanDetails>,
}
@@ -34,8 +30,6 @@ impl Default for UserProperties {
fn default() -> Self {
Self {
servers_allowed: true,
friends_enabled: false,
accept_friend_invites: false,
multiplayer_ban: None,
}
}
@@ -215,13 +209,6 @@ pub fn parse_properties(body: &str) -> Option<UserProperties> {
.pointer("/privileges/multiplayerServer/enabled")
.and_then(|e| e.as_bool())
.unwrap_or(false);
let toggle = |name: &str| {
v.pointer(&format!("/friendsPreferences/{name}"))
.and_then(|f| f.as_str())
.is_some_and(|f| f.eq_ignore_ascii_case("ENABLED"))
};
let friends_enabled = toggle("friends");
let accept_friend_invites = toggle("acceptInvites");
let multiplayer_ban = v
.pointer("/banStatus/bannedScopes/MULTIPLAYER")
.filter(|b| b.is_object())
@@ -235,8 +222,6 @@ pub fn parse_properties(body: &str) -> Option<UserProperties> {
});
Some(UserProperties {
servers_allowed,
friends_enabled,
accept_friend_invites,
multiplayer_ban,
})
}
@@ -290,24 +275,6 @@ mod tests {
assert!(clean.servers_allowed && clean.multiplayer_ban.is_none());
}
#[test]
fn friends_preferences_read_their_two_flags() {
let body = r#"{"privileges":{"multiplayerServer":{"enabled":true}},
"friendsPreferences":{"friends":"ENABLED","acceptInvites":"DISABLED"}}"#;
let p = parse_properties(body).unwrap();
assert!(p.friends_enabled);
assert!(!p.accept_friend_invites);
let other = parse_properties(
r#"{"friendsPreferences":{"friends":"DISABLED","acceptInvites":"ENABLED"}}"#,
)
.unwrap();
assert!(!other.friends_enabled);
assert!(other.accept_friend_invites);
// The offline fallback has neither flag, as `OFFLINE_PROPERTIES` does.
assert!(!UserProperties::default().friends_enabled);
assert!(!UserProperties::default().accept_friend_invites);
}
#[test]
fn parses_profile_actions() {
let a = parse_profile_actions(
@@ -330,7 +297,6 @@ mod tests {
properties: UserProperties {
servers_allowed: true,
multiplayer_ban: Some(ban.clone()),
..Default::default()
},
actions: ProfileActions {
forced_name_change: true,
@@ -351,7 +317,7 @@ mod tests {
let no_servers = AccountStatus {
properties: UserProperties {
servers_allowed: false,
..Default::default()
multiplayer_ban: None,
},
..Default::default()
};
+1 -87
View File
@@ -1,7 +1,6 @@
//! Biome colours from the pinned biome definitions and colormaps
//! (`Biome.getGrassColor`/`getFoliageColor`/..., `BlockTintSources`).
use crate::Result;
use horizon_client_core::particle_type::ParticleType;
use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom};
use serde_json::Value;
use std::{
@@ -107,31 +106,11 @@ pub struct BiomeInfo {
/// The biome's own fog colour attribute; otherwise the dimension's applies.
pub fog: Option<u32>,
pub water_fog: u32,
/// `minecraft:visual/ambient_particles` entries and their probabilities.
pub ambient_particles: Vec<(ParticleType, f32)>,
/// `BiomeGenerationSettings.features`: the placed feature ids of each
/// `GenerationStep.Decoration` step, in order.
pub features: Vec<Vec<String>>,
/// The biome's environment attributes (`Biome.getAttributes`): its layer in the
/// environment attribute stack.
pub attributes: horizon_world::environment_attributes::AttributeMap,
}
impl BiomeInfo {
fn ambient_particles(v: &Value) -> Vec<(ParticleType, f32)> {
let list = &v["attributes"]["minecraft:visual/ambient_particles"];
let entries = list["argument"].as_array().or_else(|| list.as_array());
entries
.into_iter()
.flatten()
.filter_map(|entry| {
Some((
ParticleType::from_name(entry["particle"]["type"].as_str()?)?,
entry["probability"].as_f64().unwrap_or(0.0) as f32,
))
})
.collect()
}
/// `Biome.getHeightAdjustedTemperature`: the frozen modifier, then cooler
/// with height above `sea_level + 17`.
pub fn temperature_at(&self, pos: BlockPos, sea_level: i32) -> f32 {
@@ -266,7 +245,6 @@ impl BiomeInfo {
sky: color(&attributes["minecraft:visual/sky_color"]),
fog: color(&attributes["minecraft:visual/fog_color"]),
water_fog: color(&attributes["minecraft:visual/water_fog_color"]).unwrap_or(0x050533),
ambient_particles: Self::ambient_particles(v),
features: v["features"]
.as_array()
.into_iter()
@@ -280,11 +258,6 @@ impl BiomeInfo {
.collect()
})
.collect(),
attributes: horizon_world::environment_attributes::AttributeMap::parse(attributes)
.unwrap_or_else(|e| {
log::warn!("biome attributes: {e}");
Default::default()
}),
name,
}
}
@@ -401,17 +374,6 @@ impl Biomes {
pub fn get(&self, id: u16) -> &BiomeInfo {
&self.list[usize::from(id).min(self.list.len() - 1)]
}
/// The name of biome `id`, plains (as for an unknown name) when the id is
/// past this table: a column a server sent under its earlier, longer
/// registry (a proxy's previous server) keeps its ids until the next
/// login replaces it. `get` clamps such an id to the last entry instead,
/// so for that stale column the colours and this name can come from
/// different biomes until the login.
pub fn name(&self, id: u16) -> &str {
self.list
.get(usize::from(id))
.map_or("minecraft:plains", |b| b.name.as_str())
}
pub fn grass(&self, id: u16, x: f64, z: f64) -> u32 {
let b = self.get(id);
match b.modifier {
@@ -601,9 +563,7 @@ mod pack_tests {
#[test]
fn packs_replace_in_place_and_add_after_the_builtin_biomes() {
let base = builtin(&["minecraft:meadow", "minecraft:plains", "minecraft:river"]);
let red = json!({"temperature": 2.0, "effects": {"water_color": "#ff0000"},
"attributes": {"minecraft:visual/ambient_particles": {
"argument": [{"particle": {"type": "minecraft:ash"}, "probability": 0.25}]}}});
let red = json!({"temperature": 2.0, "effects": {"water_color": "#ff0000"}});
let hot = json!({"effects": {"grass_color": 0x123456}});
let files = [
("pack:zeta", &hot),
@@ -616,9 +576,6 @@ mod pack_tests {
assert_eq!(world.id("minecraft:river"), 2);
assert_eq!(world.get(2).water, 0xFF0000);
assert_eq!(world.get(2).temperature, 2.0);
assert_eq!(world.get(2).ambient_particles.len(), 1);
assert_eq!(world.get(2).ambient_particles[0].0.name(), "ash");
assert_eq!(world.get(2).ambient_particles[0].1, 0.25);
// New biomes follow, in registry order.
assert_eq!(world.id("minecraft:molten_peaks"), 3);
assert_eq!(world.id("pack:zeta"), 4);
@@ -643,49 +600,6 @@ mod pack_tests {
assert_eq!(other.get(2).water, 0x3F76E4);
assert!(!other.has_packs());
}
/// A column keeps the ids of the table it was decoded with. When a
/// proxy's next server lacks a biome the last one added, that id is past
/// the next table, and it names plains instead of indexing past the end
/// (#12148: "the len is 67 but the index is 67").
#[test]
fn an_id_past_the_table_names_plains() {
let next = builtin(&["minecraft:meadow", "minecraft:plains", "minecraft:river"]);
let last = next.with_packs([("limbo:void", &json!({}))]);
let stale = last.id("limbo:void");
assert_eq!((stale, last.name(stale)), (3, "limbo:void"));
assert_eq!(next.name(stale), "minecraft:plains");
assert_eq!(next.name(u16::MAX), "minecraft:plains");
assert_eq!(next.name(2), "minecraft:river");
}
}
#[cfg(test)]
impl Biomes {
/// Biomes from their files' JSON, by name, in id order: for tests elsewhere.
pub(crate) fn from_jsons(entries: &[(&str, Value)]) -> Self {
let maps = Colormaps {
grass: Vec::new(),
foliage: Vec::new(),
dry: Vec::new(),
};
let list: Vec<_> = entries
.iter()
.map(|(name, v)| BiomeInfo::from_json((*name).to_owned(), v, &maps))
.collect();
Self {
builtin: Arc::new(list.clone()),
index: list
.iter()
.enumerate()
.map(|(i, b)| (b.name.clone(), i as u16))
.collect(),
list,
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
colormaps: Arc::new(maps),
packs: BTreeMap::new(),
}
}
}
#[cfg(test)]
@@ -77,9 +77,6 @@ pub struct BlockEntities {
/// The fluid tanks of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_fluids: FxHashMap<u32, Arc<ModFluidTanks>>,
/// State-aware native factory ownership. Shared across level snapshots;
/// an empty slice means the declarations have not yet been compiled.
stateful_entities: Arc<[bool]>,
/// The energy buffers of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_energy: FxHashMap<u32, Arc<ModEnergyBuffer>>,
@@ -212,21 +209,6 @@ impl BlockEntities {
pub fn mod_fluids(&self, state: u32) -> Option<&Arc<ModFluidTanks>> {
self.mod_fluids.get(&state)
}
pub fn set_stateful_entities(&mut self, states: Vec<bool>) {
self.stateful_entities = states.into();
}
pub fn has_stateful_entity_table(&self) -> bool {
!self.stateful_entities.is_empty()
}
pub fn stateful_entities(&self) -> Arc<[bool]> {
self.stateful_entities.clone()
}
pub fn stateful_entity(&self, state: u32) -> bool {
self.stateful_entities
.get(state as usize)
.copied()
.unwrap_or(false)
}
pub fn set_mod_energy(&mut self, energy: FxHashMap<u32, Arc<ModEnergyBuffer>>) {
self.mod_energy = energy;
}
@@ -3,22 +3,20 @@
//! decorated pots, conduits, enchanting-table and lectern books and copper
//! golem statues, End portals and End gateways. Models are vanilla's baked `LayerDefinitions` layers
//! (`entity-models.json`); transforms, animation and textures follow each
//! vanilla renderer. Faces carry their tint and world normal: the entity
//! shader (and a shader pack) applies `minecraft_mix_light` from the normal, as
//! vanilla's does.
//! vanilla renderer. Faces are lit like the `entity` shader: the lightmap at
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
use crate::{
Result,
bidi::visual,
block_entity_anim::{self, EnchantingBook},
breaking::entity_geometry,
fast_hash::FxHashMap,
font::{Font, Glyph},
font::Glyph,
gfx::mark_oit,
hud::Rect,
mesher::{CULL_BACK, Vertex},
models::load_visibility,
signs::{self, SignData, SignText},
text::{Style, Text},
world::World,
};
use glam::{DVec3, Mat4, Quat, Vec3};
@@ -130,14 +128,6 @@ fn pack_color(rgb: u32, alpha: u32, brightness: f32) -> u32 {
let c = |shift: u32| (((rgb >> shift) & 255) as f32 * brightness) as u32;
alpha << 24 | c(0) << 16 | c(8) << 8 | c(16)
}
/// A world normal as `Vertex::normal` holds it: RGBA8_SNORM, `w` positive so
/// the entity shader and a shader pack both shade by it. Block-entity models
/// carry no normals of their own (vanilla computes each face's from its
/// polygon, then `minecraft_mix_light` uses it in the shader).
fn pack_normal(normal: Vec3) -> [i8; 4] {
let c = |v: f32| (v.clamp(-1.0, 1.0) * 127.0).round() as i8;
[c(normal.x), c(normal.y), c(normal.z), 127]
}
/// Everything a draw call needs besides the geometry.
#[derive(Clone, Copy)]
@@ -175,7 +165,6 @@ pub fn draw_part(
light,
lights,
glow,
..
} = paint;
for poly in &part.polygons {
let pos = poly.map(|v| m.transform_point3(Vec3::new(v[0], v[1], v[2]) / 16.0));
@@ -188,21 +177,11 @@ pub fn draw_part(
} else {
normal
};
// The colour is the tint alone when the caller shades by the level's
// lights: the entity shader applies `minecraft_mix_light` from the
// normal, as vanilla does, so a shader pack receives the unshaded
// colour Iris gives it (a baked shade would be applied a second time
// by the pack's lighting). Other lights (an item preview's
// `Lighting.Entry`) are not the shader's to know, so those stay baked
// into the colour, as before.
let baked = !(lights == diffuse_lights(false) || lights == diffuse_lights(true));
let brightness = if baked { diffuse(normal, lights) } else { 1.0 };
let color = pack_color(tint, 255, brightness);
let normal = if baked { [0; 4] } else { pack_normal(normal) };
let color = pack_color(tint, 255, diffuse(normal, lights));
for (r, light) in std::iter::once((r, light)).chain(glow.map(|g| (g, FULL_BRIGHT))) {
for (v, position) in poly.iter().zip(pos) {
out.push(Vertex {
normal,
normal: [0; 4],
pos: position.to_array(),
uv: [r.u0 + (r.u1 - r.u0) * v[3], r.v0 + (r.v1 - r.v0) * v[4]],
layer: 0,
@@ -621,26 +600,30 @@ fn frac(x: f32) -> f32 {
x - x.floor()
}
/// `StringSplitter.findLineBreak`: first line, with styled advances.
fn first_line(line: &Text, max_width: f32, advance: &dyn Fn(char, &Style) -> f32) -> Text {
/// `StringSplitter.splitLines(...).get(0)` for plain text: the first line,
/// wrapped at the last space that fits or mid-word when none does.
fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> String {
let mut width = 0.0;
let mut visible = false;
let mut last_space = None;
for (i, (c, style)) in line.chars().enumerate() {
for (i, c) in line.char_indices() {
if c == '\n' {
return line.slice(0, i);
return line[..i].to_owned();
}
if c == ' ' {
last_space = Some(i);
}
let step = advance(c, style);
width += step;
if visible && width > max_width.max(1.0) {
return line.slice(0, last_space.unwrap_or(i));
width += advance(c);
if width > max_width {
let end = match last_space {
Some(s) if s > 0 => s,
_ if i > 0 => i,
// The first char alone is too wide: take that one char.
_ => c.len_utf8(),
};
return line[..end].to_owned();
}
visible |= step != 0.0;
}
line.clone()
line.to_owned()
}
/// `AbstractSignRenderer.isOutlineVisible`: the camera within 16 blocks of
@@ -658,7 +641,7 @@ fn sign_text_near(pos: [i32; 3], camera: DVec3) -> bool {
/// `AbstractSignRenderer.submitSignText`: four centred lines, glowing text
/// full-bright with an 8x outline in the dark colour. The font colours each
/// glyph by its style (`Font.StringRenderOutput`): a line's own colour wins,
/// else the sign's. Keep style boundaries for glyphs and decoration effects.
/// else the sign's. A line is one call per run of one colour.
fn sign_text_calls(
scene: &Scene,
data: &SignData,
@@ -670,43 +653,27 @@ fn sign_text_calls(
let line_height = data.line_height();
let midpoint = (4.0 * line_height / 2.0).floor();
let dark = 0xFF00_0000 | text.dark_color();
let (color, outline, light, tier) = if text.glowing {
let (color, outline, light) = if text.glowing {
let c = text.text_color();
(
0xFF00_0000 | c,
if c == 0 || near { dark } else { 0 },
240 | 240 << 8,
crate::mesher::LOW,
)
} else {
(dark, 0, vertex_light(packed), 0)
};
let advance = |c: char, style: &Style| {
let g = scene
.font
.map_or_else(|| (scene.glyph)(c), |f| f.glyph_styled(c, style));
g.advance + if style.is_bold() { g.offset } else { 0.0 }
(dark, 0, vertex_light(packed))
};
let advance = |c: char| (scene.glyph)(c).advance;
let mut calls: Vec<TextCall> = Vec::with_capacity(text.lines.len());
for i in 0..text.lines.len() {
// The line that fits, in the order it is drawn (getVisualOrder).
let line = text.line_text(i);
let shown = if let Some(font) = scene.font {
font.split_formatted(&line, data.max_line_width())
.into_iter()
.next()
.unwrap_or_default()
.resolved()
.into_owned()
} else {
first_line(&line.resolved(), data.max_line_width(), &advance)
};
let line = line.resolved();
let flat: String = line.chars().map(|(c, _)| c).collect();
let fits = first_line(&flat, data.max_line_width(), &advance);
let shown = line.slice(0, fits.chars().count());
let shown = visual(&shown);
let width = shown
.chars()
.map(|(c, s)| advance(c, s))
.sum::<f32>()
.ceil();
let width = shown.chars().map(|(c, _)| advance(c)).sum::<f32>().ceil();
let x = -((width as i32) / 2) as f32;
let y = i as f32 * line_height - midpoint;
let first = calls.len();
@@ -716,22 +683,19 @@ fn sign_text_calls(
.style
.color
.map_or(color, |rgb| 0xFF00_0000 | (rgb & 0xFF_FFFF));
calls.push(TextCall {
matrix: m,
x: x + pen,
y,
text: span.text.clone(),
style: span.style.clone(),
color: run,
outline,
light,
tier,
});
pen += span
.text
.chars()
.map(|c| advance(c, &span.style))
.sum::<f32>();
match calls[first..].last_mut() {
Some(last) if last.color == run => last.text.push_str(&span.text),
_ => calls.push(TextCall {
matrix: m,
x: x + pen,
y,
text: span.text.clone(),
color: run,
outline,
light,
}),
}
pen += span.text.chars().map(advance).sum::<f32>();
}
if calls.len() == first {
// A line with no text is still a call, of no glyphs.
@@ -740,28 +704,15 @@ fn sign_text_calls(
x,
y,
text: String::new(),
style: Style::default(),
color,
outline,
light,
tier,
});
}
}
calls
}
fn sign_text_animated(data: &SignData) -> bool {
[&data.front, &data.back].into_iter().any(|side| {
(0..side.lines.len()).any(|i| {
side.line_text(i)
.resolved()
.chars()
.any(|(c, s)| c != ' ' && s.obfuscated == Some(true))
})
})
}
/// How far the text of an outlined call sits in front of its outline, in
/// text units (`Font.DisplayMode.POLYGON_OFFSET`, see `draw_text`).
const TEXT_OVER_OUTLINE: f32 = 0.05;
@@ -775,131 +726,72 @@ const TEXT_OVER_OUTLINE: f32 = 0.05;
/// reader instead (a twentieth of a font pixel, invisible) rather than
/// z-fighting with the outline under it.
fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
let glyph = |c| {
let Some(font) = scene.font else {
return (scene.glyph)(c);
};
let g = font.glyph_styled(c, &call.style);
if call.style.obfuscated == Some(true) && c != ' ' {
font.random_glyph(&call.style, g.advance)
} else {
g
}
};
text_vertices(&glyph, scene.font.map(Font::white), call, out);
text_vertices(scene.glyph, call, out);
}
/// `BakedSheetGlyph.renderChar` / `Font.PreparedTextBuilder.accept`: the
/// `draw_text` with the font's glyph cells and advances from `glyph`: the
/// text of `call` as glyph quads in its matrix's plane, 1 unit a font pixel,
/// y down as the font's, with its outline when it has one. A native mod's
/// block entity draws its text this way (`native_draw_text`).
pub fn text_vertices(
glyph: &dyn Fn(char) -> Glyph,
white: Option<Rect>,
call: &TextCall,
out: &mut Vec<Vertex>,
) {
let bold = call.style.is_bold();
let mut quad = |corners: [(f32, f32); 4], cell: Rect, z: f32, argb: u32| {
let uvs = [
[cell.u0, cell.v1],
[cell.u1, cell.v1],
[cell.u1, cell.v0],
[cell.u0, cell.v0],
];
let color = 0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
for ((x, y), uv) in corners.into_iter().zip(uvs) {
out.push(Vertex {
normal: [0; 4],
pos: call.matrix.transform_point3(Vec3::new(x, y, z)).to_array(),
uv,
layer: u32::from(call.tier) << 16,
color,
light: call.light,
glint_uv: [0.0; 2],
});
}
};
let mut emit = |dx: f32, dy: f32, z: f32, argb: u32, outline: bool| {
let mut pen = call.x;
for (i, ch) in call.text.chars().enumerate() {
pub fn text_vertices(glyph: &dyn Fn(char) -> Glyph, call: &TextCall, out: &mut Vec<Vertex>) {
let mut emit = |dx: f32, dy: f32, z: f32, argb: u32| {
let mut x = call.x + dx;
let y0 = call.y + dy;
for ch in call.text.chars() {
let g = glyph(ch);
let x = pen + dx * g.offset;
let y = call.y + dy * g.offset;
let advance = g.advance + if bold { g.offset } else { 0.0 };
if let Some(cell) = g.rect {
let thick = if bold { 0.1 } else { 0.0 };
let shear = |v| {
if call.style.is_italic() {
1.0 - 0.25 * v
} else {
0.0
}
};
for copy in 0..=usize::from(bold) {
let x0 = x + copy as f32 * g.offset + g.left - thick;
let x1 = x + copy as f32 * g.offset + g.right + thick;
let top = y + g.top - thick;
let bottom = y + g.bottom + thick;
quad(
[
(x0 + shear(g.bottom), bottom),
(x1 + shear(g.bottom), bottom),
(x1 + shear(g.top), top),
(x0 + shear(g.top), top),
],
cell,
z + copy as f32 * 0.001,
argb,
);
// BakedSheetGlyph: the glyph's own box from the pen, not a
// fixed 8x8 cell (a Unifont glyph is 0.5 units per texel).
let (x0, x1) = (x + g.left, x + g.right);
let (top, bottom) = (y0 + g.top, y0 + g.bottom);
let corners = [(x0, bottom), (x1, bottom), (x1, top), (x0, top)];
let uvs = [
[cell.u0, cell.v1],
[cell.u1, cell.v1],
[cell.u1, cell.v0],
[cell.u0, cell.v0],
];
let c =
0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
for ((cx, cy), uv) in corners.into_iter().zip(uvs) {
out.push(Vertex {
normal: [0; 4],
pos: call
.matrix
.transform_point3(Vec3::new(cx, cy, z))
.to_array(),
uv,
layer: 0,
color: c,
light: call.light,
glint_uv: [0.0, 0.0],
});
}
}
// `Font.prepare8xTextOutline` discards effects: only the front
// text gets underline/strikethrough, including over spaces.
if !outline && let Some(white) = white {
let x0 = if i == 0 { x - 1.0 } else { x };
for (enabled, top) in [
(call.style.strikethrough, 3.5),
(call.style.underlined, 8.0),
] {
if enabled == Some(true) {
quad(
[
(x0, y + top + 1.0),
(x + advance, y + top + 1.0),
(x + advance, y + top),
(x0, y + top),
],
white,
z + 0.01,
argb,
);
}
}
}
pen += advance;
x += g.advance;
}
};
if call.outline != 0 {
for dx in -1..=1 {
for dy in -1..=1 {
if dx != 0 || dy != 0 {
emit(dx as f32, dy as f32, 0.0, call.outline, true);
}
}
for (dx, dy) in [
(-1.0, -1.0),
(0.0, -1.0),
(1.0, -1.0),
(-1.0, 0.0),
(1.0, 0.0),
(-1.0, 1.0),
(0.0, 1.0),
(1.0, 1.0),
] {
emit(dx, dy, 0.0, call.outline);
}
}
emit(
0.0,
0.0,
if call.outline != 0 {
TEXT_OVER_OUTLINE
} else {
0.0
},
call.color,
false,
);
let z = if call.outline != 0 {
TEXT_OVER_OUTLINE
} else {
0.0
};
emit(0.0, 0.0, z, call.color);
}
/// Block-entity fields the level ticks, at one position (vanilla's own field
@@ -932,16 +824,12 @@ pub struct TextCall {
pub x: f32,
pub y: f32,
pub text: String,
pub style: Style,
/// ARGB.
pub color: u32,
/// ARGB outline colour, 0 for none.
pub outline: u32,
/// Horizon packed light (sky*16 | block*16 << 8).
pub light: u32,
/// HDR emission tier of its glyphs (`mesher::hdr_tier`), 0 for none: glowing sign text
/// shines in HDR output and in shader packs (`horizonTier`), as full-bright text does in SDR.
pub tier: u8,
}
/// Inputs from the game for one frame of block-entity rendering.
@@ -980,8 +868,6 @@ pub struct Scene<'a> {
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
/// Font glyph: atlas cell (none for blank glyphs such as the space), box and advance.
pub glyph: &'a dyn Fn(char) -> Glyph,
/// Font providers, effects and random glyphs for styled sign text.
pub font: Option<&'a Font>,
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
/// text laid out under another value is laid out again.
pub glyphs: u64,
@@ -1143,7 +1029,6 @@ struct SignTextEntry {
/// Game time it was last drawn.
used: i64,
vertices: Vec<Vertex>,
animated: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct StaticKey {
@@ -1459,37 +1344,7 @@ impl BlockEntityRenderer {
registry: &BlockRegistry,
visibility: FaceVisibility,
) -> Result<Self> {
Self::load_with_visibility_inner(root, registry, visibility, None, None)
}
/// Load from what the client startup batch already decoded: its entity-model
/// document (none reads `entity-models.json`) and the item defaults, avoiding a
/// second read and decode of either.
pub fn load_with_visibility_models_and_defaults(
root: &Path,
registry: &BlockRegistry,
visibility: FaceVisibility,
models: Option<&Value>,
defaults: &horizon_world::item_defaults::Defaults,
) -> Result<Self> {
Self::load_with_visibility_inner(root, registry, visibility, models, Some(defaults))
}
fn load_with_visibility_inner(
root: &Path,
registry: &BlockRegistry,
visibility: FaceVisibility,
models: Option<&Value>,
defaults: Option<&horizon_world::item_defaults::Defaults>,
) -> Result<Self> {
let owned_models = if models.is_none() {
Some(serde_json::from_slice(&std::fs::read(
root.join("entity-models.json"),
)?)?)
} else {
None
};
let all = models.or(owned_models.as_ref()).expect("entity models");
let all: Value = serde_json::from_slice(&std::fs::read(root.join("entity-models.json"))?)?;
let models = MODELS
.iter()
.filter_map(|(key, layer)| all.get(*layer).map(|v| (*key, ModelPart::parse(v))))
@@ -1499,17 +1354,12 @@ impl BlockEntityRenderer {
// The packs' custom entity models (`.jem` files), before anything draws.
renderer.set_shapes(renderer.shapes_for(root));
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
let owned_defaults = if defaults.is_none() {
Some(match horizon_world::item_defaults::read(root) {
Ok(items) => items,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
Err(e) => return Err(e.into()),
})
} else {
None
let items = match horizon_world::item_defaults::read(root) {
Ok(items) => items,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
Err(e) => return Err(e.into()),
};
let items = defaults.or(owned_defaults.as_ref()).unwrap();
for (item, components) in items {
for (item, components) in &items {
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str() else {
continue;
};
@@ -2485,11 +2335,9 @@ impl BlockEntityRenderer {
let entry = match signs.entry(pos) {
std::collections::hash_map::Entry::Occupied(mut occupied) => {
let entry = occupied.get_mut();
let changed = entry.data != *data;
if (entry.state, entry.packed, entry.near, entry.glyphs)
!= (state, packed, near, glyphs)
|| changed
|| entry.animated
|| entry.data != *data
{
entry.vertices.clear();
self.render_one(
@@ -2504,10 +2352,7 @@ impl BlockEntityRenderer {
&mut entry.vertices,
);
entry.state = state;
if changed {
entry.animated = sign_text_animated(data);
entry.data.clone_from(data);
}
entry.data.clone_from(data);
entry.packed = packed;
entry.near = near;
entry.glyphs = glyphs;
@@ -2528,7 +2373,6 @@ impl BlockEntityRenderer {
&mut vertices,
);
vacant.insert(SignTextEntry {
animated: sign_text_animated(data),
state,
data: data.clone(),
packed,
@@ -3768,7 +3612,6 @@ mod tests {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
// Compare the cached world path with the original model renderer,
@@ -3931,105 +3774,6 @@ mod tests {
// Straight down: ambient only.
assert!((diffuse(-Vec3::Y, lights) - 0.4).abs() < 1e-6);
}
/// A face's colour carries its tint alone and its normal carries the
/// shading: the entity shader applies `minecraft_mix_light` from it, as a
/// shader pack's lighting does, so a baked shade would be applied twice.
#[test]
fn a_face_keeps_its_tint_and_normal_for_the_shader_to_shade() {
let quad = |y: f32, z0: f32, z1: f32| {
[
[0.0, y, z0, 0.0, 0.0],
[0.0, y, z1, 0.0, 1.0],
[16.0, y, z1, 1.0, 1.0],
[16.0, y, z0, 1.0, 0.0],
]
};
// Opposite windings: the first faces up, the second down.
let part = ModelPart {
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: vec![quad(0.0, 0.0, 16.0), quad(16.0, 16.0, 0.0)],
children: vec![],
};
let mut out = Vec::new();
draw_part(
&part,
"root",
Mat4::IDENTITY,
&|_, _| {},
Paint {
sprite: Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
tint: 0x123456,
light: 0,
lights: diffuse_lights(false),
glow: None,
},
&mut out,
);
assert_eq!(out.len(), 8);
// The same tint on both faces: no directional shade baked in (`pack_color`
// stores the tint in the vertex colour's BGR byte order).
let tint = pack_color(0x12_3456, 255, 1.0);
assert!(out.iter().all(|v| v.color == tint));
let (up, down) = (&out[0], &out[4]);
assert_eq!(up.normal, [0, 127, 0, 127]);
assert_eq!(down.normal, [0, -127, 0, 127]);
// The shader still gets the difference the old baked colours held.
let lights = diffuse_lights(false);
let up_normal = Vec3::new(0.0, f32::from(up.normal[1]) / 127.0, 0.0);
let down_normal = Vec3::new(0.0, f32::from(down.normal[1]) / 127.0, 0.0);
assert!(diffuse(up_normal, lights) > diffuse(down_normal, lights));
}
/// Lights the entity shader does not know (an item preview's
/// `Lighting.Entry`) stay baked into the colour, and `normal.w` 0 tells
/// the shader to keep it: `game/item_draw` passes those for block-entity
/// items in inventories and hands.
#[test]
fn lights_other_than_the_levels_stay_baked() {
let part = ModelPart {
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: vec![[
[0.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 16.0, 0.0, 1.0],
[16.0, 0.0, 16.0, 1.0, 1.0],
[16.0, 0.0, 0.0, 1.0, 0.0],
]],
children: vec![],
};
let lights = (Vec3::X, Vec3::Z);
let mut out = Vec::new();
draw_part(
&part,
"root",
Mat4::IDENTITY,
&|_, _| {},
Paint {
sprite: Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
tint: 0x12_3456,
light: 0,
lights,
glow: None,
},
&mut out,
);
let baked = pack_color(0x12_3456, 255, diffuse(Vec3::Y, lights));
assert!(out.iter().all(|v| v.color == baked));
assert!(out.iter().all(|v| v.normal == [0; 4]));
assert_ne!(baked, pack_color(0x12_3456, 255, 1.0));
}
#[test]
fn part_poses_compose_like_model_part() {
let part = ModelPart {
@@ -4163,7 +3907,6 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
font: None,
dynamic_visible: None,
};
let mut call = TextCall {
@@ -4171,11 +3914,9 @@ mod tests {
x: 0.0,
y: 0.0,
text: "Hi".into(),
style: Style::default(),
color: 0xFFFF_FF00,
outline: 0xFF33_3300,
light: 240 | 240 << 8,
tier: 0,
};
let mut out = Vec::new();
draw_text(&scene, &call, &mut out);
@@ -4239,7 +3980,6 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
font: None,
dynamic_visible: None,
};
let call = TextCall {
@@ -4247,11 +3987,9 @@ mod tests {
x: 10.0,
y: 2.0,
text: "ab".into(),
style: Style::default(),
color: 0xFF00_0000,
outline: 0,
light: 240 | 240 << 8,
tier: 0,
};
let mut out = Vec::new();
draw_text(&scene, &call, &mut out);
@@ -4271,83 +4009,6 @@ mod tests {
assert_eq!(boxes, [[10.0, 14.0, 2.0, 10.0], [15.0, 19.0, 2.0, 10.0]]);
}
#[test]
fn styled_sign_quads_follow_vanilla_glyph_and_effect_bounds() {
let white = Rect {
u0: 0.0,
v0: 0.0,
u1: 0.1,
v1: 0.1,
w: 1,
h: 1,
};
let glyph = |c| Glyph {
rect: (c != ' ').then_some(white),
left: 0.0,
right: 4.0,
top: 0.0,
bottom: 8.0,
advance: 5.0,
offset: 0.5,
};
let mut call = TextCall {
matrix: Mat4::IDENTITY,
x: 0.0,
y: 0.0,
text: "A ".into(),
color: 0xff123456,
outline: 0,
light: 240,
tier: crate::mesher::LOW,
style: Style {
bold: Some(true),
italic: Some(true),
underlined: Some(true),
strikethrough: Some(true),
..Default::default()
},
};
let mut out = Vec::new();
text_vertices(&glyph, Some(white), &call, &mut out);
// BakedSheetGlyph: bold expands by 0.1, italic shears by 1 - y/4,
// the second bold glyph is 0.5 along and 0.001 in front (Unihex).
assert_eq!(out.len(), 24);
for (got, expected) in out[0].pos.iter().zip([-1.1, 8.1, 0.0]) {
assert!((got - expected).abs() < 1e-6);
}
assert!((out[4].pos[0] - out[0].pos[0] - 0.5).abs() < 1e-6);
assert_eq!(out[4].pos[2], 0.001);
assert_eq!(out[8].pos, [-1.0, 4.5, 0.01]);
assert_eq!(out[12].pos, [-1.0, 9.0, 0.01]);
assert_eq!(out[21].pos, [11.0, 9.0, 0.01]); // underline crosses the space
assert!(out.iter().all(|v| v.color == 0xff563412 && v.light == 240));
call.outline = 0xff010203;
out.clear();
text_vertices(&glyph, Some(white), &call, &mut out);
// Eight outlines contain glyphs only; effects appear once, in front.
assert_eq!(out.len(), 8 * 8 + 24);
assert!((out[0].pos[0] - -1.6).abs() < 1e-6);
assert!(out[64..].iter().all(|v| v.pos[2] >= TEXT_OVER_OUTLINE));
// The glow tier reaches bold copies, outlines and decoration quads.
assert!(out.iter().all(|v| v.layer >> 16 == 1));
call.tier = 0;
out.clear();
text_vertices(&glyph, Some(white), &call, &mut out);
assert!(out.iter().all(|v| v.layer >> 16 == 0));
}
#[test]
fn styled_sign_clipping_counts_bold_width_and_preserves_resets() {
let line = Text::legacy("§lAB§r東Z");
let advance = |_: char, s: &Style| 5.0 + if s.is_bold() { 0.5 } else { 0.0 };
let shown = first_line(&line, 16.0, &advance);
assert_eq!(shown.plain(), "AB東");
assert_eq!(
shown.chars().map(|(_, s)| s.is_bold()).collect::<Vec<_>>(),
[true, true, false]
);
}
/// The front text calls of a sign whose every glyph advances 6.
fn front_calls(data: &SignData, near: bool) -> Vec<(String, f32, f32, u32, u32)> {
let world = World::new(BlockStateId(0), 1);
@@ -4376,7 +4037,6 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
font: None,
dynamic_visible: None,
};
sign_text_calls(&scene, data, &data.front, Mat4::IDENTITY, 0, near)
@@ -4516,9 +4176,7 @@ mod tests {
..changed.clone()
};
let wide = std::cell::Cell::new(6.0);
let glyph_reads = std::cell::Cell::new(0);
let glyph = |c: char| {
glyph_reads.set(glyph_reads.get() + 1);
let u = f32::from(c as u8 % 16) / 16.0;
let rect = Rect {
u0: u,
@@ -4561,7 +4219,6 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
font: None,
dynamic_visible: None,
};
let check = |scene: &Scene, pos: [i32; 3], state: u32| {
@@ -4605,23 +4262,6 @@ mod tests {
wide.set(5.0);
scene.glyphs = 1;
check(&scene, pos, 1);
// With the same game time and data, plain text reuses its vertices,
// but obfuscated text must ask the font for fresh glyphs each frame.
let mut vertices = Vec::new();
let reads = glyph_reads.get();
renderer.render(&scene, [(pos, 1)].iter(), &mut vertices);
assert_eq!(glyph_reads.get(), reads);
let animated = SignData {
front: text("§kSecret", false),
..Default::default()
};
let animated_sign = |_| Some(&animated);
scene.sign = &animated_sign;
renderer.render(&scene, [(pos, 0)].iter(), &mut vertices);
let reads = glyph_reads.get();
renderer.render(&scene, [(pos, 0)].iter(), &mut vertices);
assert!(glyph_reads.get() > reads);
scene.sign = &first_sign;
// Signs out of view for a while are dropped.
scene.game_time = 2 * SIGN_TEXT_KEEP;
check(&scene, pos, 0);
@@ -4629,23 +4269,11 @@ mod tests {
}
#[test]
fn first_line_wraps_on_bytes_without_splitting_a_char() {
let advance = |c: char, _: &Style| if c == ' ' { 4.0 } else { 6.0 };
assert_eq!(
first_line(&Text::literal("ab cd ef"), 13.0, &advance).plain(),
"ab"
);
assert_eq!(
first_line(&Text::literal("abcdef"), 13.0, &advance).plain(),
"ab"
);
assert_eq!(
first_line(&Text::literal("\u{6771}"), 3.0, &advance).plain(),
"\u{6771}"
);
assert_eq!(
first_line(&Text::literal("ab\ncd"), 100.0, &advance).plain(),
"ab"
);
let advance = |c: char| if c == ' ' { 4.0 } else { 6.0 };
assert_eq!(first_line("ab cd ef", 13.0, &advance), "ab");
assert_eq!(first_line("abcdef", 13.0, &advance), "ab");
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
}
/// `rendertype_end_portal` culls back faces, and `AbstractEndPortalRenderer`
/// submits the unit cube's two Y faces. Both are wound outward, so of each
@@ -4749,7 +4377,6 @@ mod tests {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let mut verts = Vec::new();
@@ -4836,7 +4463,6 @@ mod tests {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
@@ -358,7 +358,6 @@ fn batched_callbacks_preserve_entity_ranges_and_allow_immediate_culling() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
// Reverse local-position order to check that cache indexing does not
@@ -448,7 +447,6 @@ fn static_cache_qualifies_local_positions_by_section_and_drops_old_entries() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let options = CacheOptions {
@@ -504,7 +502,6 @@ fn gpu_static_cache_uses_section_local_chests_and_keeps_visibility_separate() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let blocks = [([100, 70, -32], 0)];
@@ -692,7 +689,6 @@ fn sign_text_cache_can_be_disabled_and_reenabled_without_changing_output() {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
font: None,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
@@ -747,7 +743,6 @@ fn shulker_cache_tracks_settled_poses_and_does_not_freeze_opening_animation() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
@@ -850,7 +845,6 @@ fn shulker_cache_covers_all_colors_and_facing_directions() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let options = CacheOptions {
@@ -906,7 +900,6 @@ fn cached_bells_keep_shaking_until_the_animation_ends() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
@@ -969,7 +962,6 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
@@ -1001,9 +993,7 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
scene.nether = true;
let nether_closed = render_cached(&scene);
assert_same_vertices(&nether_closed, &render_direct(&renderer, &scene, &blocks));
// The nether is the entity shader's flag now (`entity_light`'s `l1` and
// `globals.time.y`), not a baked colour, so the cached geometry serves both.
assert_same_vertices(&closed_again, &nether_closed);
assert_vertices_differ(&closed_again, &nether_closed);
}
#[test]
@@ -1068,7 +1058,6 @@ fn closed_chest_cache_covers_all_materials_and_atlas_reloads() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let pos = [0, 64, 0];
@@ -1158,7 +1147,6 @@ fn double_chest_cache_bypasses_both_halves_while_either_lid_is_open() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let pair = [([0, 64, 0], 0), ([1, 64, 0], 1)];
@@ -1235,7 +1223,6 @@ fn decorated_pot_cache_tracks_sherds_and_bypasses_wobble_animation() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
@@ -1315,7 +1302,6 @@ fn banner_cache_keeps_the_pole_static_while_the_flag_and_patterns_update() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
@@ -1383,7 +1369,6 @@ fn cached_wall_banner_poles_use_the_wall_model() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let pos = [0, 64, 0];
@@ -1432,7 +1417,6 @@ fn copper_statue_cache_rebuilds_when_its_pose_state_changes() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let options = CacheOptions {
@@ -1539,7 +1523,6 @@ fn copper_statue_cache_covers_weathering_waxing_and_all_poses() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let options = CacheOptions {
@@ -37,37 +37,6 @@ pub fn potion_name_key(item: &str, potion: Option<&str>) -> String {
let suffix = potion.and_then(self::potion).map_or("empty", |p| p.name);
format!("item.minecraft.{prefix}.effect.{suffix}")
}
/// `PotionItem.getName` and `TippedArrowItem.getName`: a potion or a tipped
/// arrow is named by its contents (`PotionContents.getName`: their own
/// `custom_name`, else the potion's name, else `empty`), whatever its
/// `item_name`. None for other items, and for a stack whose contents were
/// taken off (`!minecraft:potion_contents`), which takes the item's name.
pub fn contents_name_key(stack: &ItemStack) -> Option<String> {
use horizon_world::nbt::Tag;
let item = &*stack.item;
let named = matches!(
item,
"minecraft:potion"
| "minecraft:splash_potion"
| "minecraft:lingering_potion"
| "minecraft:tipped_arrow"
);
let data = stack.data();
if !named || data.is_some_and(|d| d.other("!minecraft:potion_contents").is_some()) {
return None;
}
let prefix = item.trim_start_matches("minecraft:");
let own = data
.and_then(|d| d.potion_extra.iter().find(|(k, _)| k == "custom_name"))
.and_then(|(_, name)| match name {
Tag::String(s) => Some(s.to_string_lossy()),
_ => None,
});
Some(match own {
Some(suffix) => format!("item.minecraft.{prefix}.effect.{suffix}"),
None => potion_name_key(item, stack.potion()),
})
}
/// `PotionContents.getAllEffects`: the potion's effects, then the stack's
/// `custom_effects` (id, duration, amplifier). Unknown effect ids are dropped.
pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> {
@@ -500,40 +469,6 @@ mod tests {
potion_name_key("minecraft:splash_potion", Some("minecraft:long_swiftness")),
"item.minecraft.splash_potion.effect.swiftness"
);
// A potion or a tipped arrow is named by its contents, over its
// item_name; one whose contents were taken off is not.
use horizon_world::nbt::Tag;
let mut swift =
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:long_swiftness");
swift
.data_mut()
.set_other("minecraft:item_name", Some(Tag::String("Bottle".into())));
assert_eq!(
contents_name_key(&swift).as_deref(),
Some("item.minecraft.potion.effect.swiftness")
);
assert_eq!(
contents_name_key(&ItemStack::new("minecraft:splash_potion", 1)).as_deref(),
Some("item.minecraft.splash_potion.effect.empty")
);
let mut own = ItemStack::new("minecraft:tipped_arrow", 1).with_potion("minecraft:poison");
own.data_mut()
.potion_extra
.push(("custom_name".into(), Tag::String("luck".into())));
assert_eq!(
contents_name_key(&own).as_deref(),
Some("item.minecraft.tipped_arrow.effect.luck")
);
let mut taken = ItemStack::new("minecraft:potion", 1);
taken.data_mut().set_other(
"!minecraft:potion_contents",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert_eq!(contents_name_key(&taken), None);
assert_eq!(
contents_name_key(&ItemStack::new("minecraft:stone", 1)),
None
);
assert_eq!(format_duration(9600), "08:00");
assert_eq!(format_duration(450), "00:22");
assert_eq!(format_duration(140), "00:07");
+14 -38
View File
@@ -357,24 +357,6 @@ impl Light {
}
/// The smallest form of packed light per block.
pub fn packed(light: Box<[u8; 4096]>) -> Self {
match Self::classify(&light) {
(false, false, first) => Self::Uniform(first),
(true, false, first) => Self::Sky(Self::pack_layer(&light, 4), first & 15),
(false, true, first) => Self::Block(Self::pack_layer(&light, 0), first >> 4),
(true, true, _) => Self::Full(light),
}
}
/// The smallest form of packed light per block, allocating a 4 KiB box only
/// when both sky and block light vary within the section.
pub fn from_slice(light: &[u8; 4096]) -> Self {
match Self::classify(light) {
(false, false, first) => Self::Uniform(first),
(true, false, first) => Self::Sky(Self::pack_layer(light, 4), first & 15),
(false, true, first) => Self::Block(Self::pack_layer(light, 0), first >> 4),
(true, true, _) => Self::Full(Box::new(*light)),
}
}
fn classify(light: &[u8; 4096]) -> (bool, bool, u8) {
let first = light[0];
let (mut sky_varies, mut block_varies) = (false, false);
for &v in light.iter() {
@@ -382,14 +364,19 @@ impl Light {
sky_varies |= d & 0xF0 != 0;
block_varies |= d & 15 != 0;
}
(sky_varies, block_varies, first)
}
fn pack_layer(light: &[u8; 4096], shift: u32) -> Box<[u8; 2048]> {
let mut layer = Box::new([0u8; 2048]);
for (out, pair) in layer.iter_mut().zip(light.as_chunks::<2>().0) {
*out = (pair[0] >> shift & 15) | (pair[1] >> shift & 15) << 4;
let layer = |shift: u32| {
let mut layer = Box::new([0u8; 2048]);
for (out, pair) in layer.iter_mut().zip(light.as_chunks::<2>().0) {
*out = (pair[0] >> shift & 15) | (pair[1] >> shift & 15) << 4;
}
layer
};
match (sky_varies, block_varies) {
(false, false) => Self::Uniform(first),
(true, false) => Self::Sky(layer(4), first & 15),
(false, true) => Self::Block(layer(0), first >> 4),
(true, true) => Self::Full(light),
}
layer
}
/// The packed light of blocks `at..at + out.len()`, into `out`.
#[inline]
@@ -589,7 +576,7 @@ pub struct Chunk {
/// Block entities read from a save, moved into the world on arrival.
pub block_entities: Vec<((i32, i32, i32), block_entity::BlockEntity)>,
/// `CustomName` of the container block entities (shown as the screen title).
pub block_names: Vec<((i32, i32, i32), Tag)>,
pub block_names: Vec<((i32, i32, i32), String)>,
/// Generation postprocessing marks (ProtoChunk.postProcessing) as world
/// positions, applied once by LevelChunk.postProcessGeneration.
pub postprocess: Vec<[i32; 3]>,
@@ -633,14 +620,6 @@ impl Chunk {
entities: Vec::new(),
}
}
/// The original `structures.starts`, including starts with unknown pieces.
pub fn saved_structure_starts(&self) -> Option<&horizon_world::nbt::Compound> {
let structures = self.saved.as_ref()?.root.get(&"structures".into())?;
match structures.get("starts")? {
horizon_world::nbt::Tag::Compound(starts) => Some(starts),
_ => None,
}
}
/// The minimum block Y of the column.
#[inline]
pub fn min_y(&self) -> i32 {
@@ -871,10 +850,7 @@ impl RolledLoot {
/// Puts the loot table back in place of `Items` while they are still the
/// roll; false once the container holds anything else.
pub fn restore(&self, map: &mut Compound) -> bool {
// An empty shulker box saves no `Items`, which matches an empty roll.
let saved = map.get(&"Items".into());
let empty = matches!(&self.items, Tag::List(l) if l.is_empty());
if saved != Some(&self.items) && !(saved.is_none() && empty) {
if map.get(&"Items".into()) != Some(&self.items) {
return false;
}
map.remove(&"Items".into());
@@ -59,9 +59,6 @@ pub enum BlockEntity {
CreakingHeart(Box<CreakingHeart>),
/// `BannerBlockEntity`: the pattern layers.
Banner(Banner),
/// `CopperGolemStatueBlockEntity`: its own components, which hold the
/// custom name of the golem it was (`createStatue`).
CopperGolemStatue(Compound),
/// `CrafterBlockEntity`.
Crafter(Crafter),
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
@@ -80,18 +77,9 @@ pub enum BlockEntity {
Skull(Skull),
/// `TheEndGatewayBlockEntity`: its age and configured destination.
EndGateway(EndGateway),
/// `TheEndPortalBlockEntity`: no data, only identity.
EndPortal,
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
/// `BellBlockEntity`: saved identity only; ringing state is kept separately.
Bell,
/// `ConduitBlockEntity`: the UUID of its `destroyTarget` (`Target`). Its
/// shape and animation are kept separately (`block_entity_anim`).
Conduit(Option<[i32; 4]>),
/// `EnchantingTableBlockEntity`: its custom name is the world's block name.
EnchantingTable,
/// Suspicious sand and gravel: saved loot and the transient brushed face.
Brushable(Box<Brushable>),
/// A shelf holds three displayed item stacks.
@@ -461,9 +449,6 @@ pub struct Jukebox {
pub items: [Option<ItemStack>; 1],
/// `ticks_since_song_started`, while a song plays.
pub ticks: i64,
/// `JukeboxBlockEntity.saveAdditional` writes the clock only when its song
/// reference exists. Keep this separate from legacy playback adoption.
pub song_clock_present: bool,
pub playing: bool,
}
impl Jukebox {
@@ -522,11 +507,6 @@ pub struct Info {
pub pos: [f64; 3],
pub source: Option<Who>,
pub owner: Option<Who>,
/// `uuid`: the source's saved identity (`source`), taken when the event
/// was posted. `source` is the live entity and comes back `None` on load.
pub source_uuid: Option<[i32; 4]>,
/// `projectileOwnerUuid` (`projectile_owner`), likewise beside `owner`.
pub owner_uuid: Option<[i32; 4]>,
}
/// `VibrationSelector`: the closest, then highest-frequency candidate of a tick.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -52,10 +52,6 @@ pub struct ChunkMap {
recent_deltas: VecDeque<(u64, ChunkMembershipDelta)>,
/// The resident chunks generation has not placed its animals in yet.
fresh: FxHashSet<Key>,
/// For each column, how many of the 3×3 columns around it (itself
/// included) are resident; absent when none is. `surrounded` asks one
/// map instead of nine every tick, for every ticking chunk.
near: FxHashMap<Key, u8>,
}
impl Default for ChunkMap {
fn default() -> Self {
@@ -66,7 +62,6 @@ impl Default for ChunkMap {
min_valid_revision: revision,
recent_deltas: VecDeque::new(),
fresh: FxHashSet::default(),
near: FxHashMap::default(),
}
}
}
@@ -129,11 +124,6 @@ impl ChunkMap {
}
let previous = self.columns.insert(key, chunk);
if previous.is_none() {
for z in key.1 - 1..=key.1 + 1 {
for x in key.0 - 1..=key.0 + 1 {
*self.near.entry((x, z)).or_insert(0) += 1;
}
}
self.revision = next_revision();
self.record_delta(ChunkMembershipDelta::Added(key));
}
@@ -143,16 +133,6 @@ impl ChunkMap {
self.fresh.remove(key);
let previous = self.columns.remove(key);
if previous.is_some() {
for z in key.1 - 1..=key.1 + 1 {
for x in key.0 - 1..=key.0 + 1 {
if let Some(n) = self.near.get_mut(&(x, z)) {
*n -= 1;
if *n == 0 {
self.near.remove(&(x, z));
}
}
}
}
self.revision = next_revision();
self.record_delta(ChunkMembershipDelta::Removed(*key));
}
@@ -162,7 +142,6 @@ impl ChunkMap {
if !self.columns.is_empty() {
self.columns.clear();
self.fresh.clear();
self.near.clear();
self.revision = next_revision();
self.recent_deltas.clear();
self.recent_deltas
@@ -170,10 +149,6 @@ impl ChunkMap {
self.min_valid_revision = self.revision;
}
}
/// Whether the column at `key` and its eight neighbours are all resident.
pub fn surrounded(&self, key: Key) -> bool {
self.near.get(&key) == Some(&9)
}
pub fn has_fresh(&self) -> bool {
!self.fresh.is_empty()
}
@@ -407,41 +382,4 @@ mod tests {
assert_eq!(ordered, expected);
assert_eq!(ordered.len(), 3);
}
#[test]
fn surrounded_follows_loads_unloads_and_clears() {
// Brute force: the column and its eight neighbours are all resident.
fn expected(map: &ChunkMap, (x, z): Key) -> bool {
(-1..=1).all(|dz| (-1..=1).all(|dx| map.contains_key(&(x + dx, z + dz))))
}
let mut map = ChunkMap::default();
let mut seed = 0x2545_f491_u32;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed
};
for step in 0..4000 {
let key = ((next() % 9) as i32 - 4, (next() % 9) as i32 - 4);
if next() % 3 == 0 {
map.remove(&key);
} else {
// Replacing a resident column changes nothing.
map.insert(key, Arc::new(Chunk::empty(key.0, key.1)));
}
if step == 2500 {
map.clear();
}
for x in -6..=6 {
for z in -6..=6 {
assert_eq!(
map.surrounded((x, z)),
expected(&map, (x, z)),
"{x},{z} at {step}"
);
}
}
}
}
}
+13 -128
View File
@@ -62,16 +62,6 @@ pub fn radius_cells(cloud_range_chunks: i32) -> i32 {
((cloud_range_chunks * 16) as f32 / 12.0).ceil() as i32
}
/// Cells the mesh's origin snaps to. The mesh is a window on the cloud pattern around the
/// camera's cell, so it used to be rebuilt, and the whole array re-uploaded, on every cell
/// crossed: 6.5 MB, about 11 ms at the default 128-chunk range, the worst frame of a flight.
/// Anchoring the origin to a coarser grid rebuilds it once every `SNAP_CELLS` cells instead,
/// and `Uniform::offset` carries the remaining shift. The ring of cells the shift can leave
/// uncovered at the far side lies past the cloud fog end, where the cloud fade has already
/// taken the alpha to 0 (`entity.wgsl`'s `fs_entity`), so the extra cells cost only the
/// vertices they add.
const SNAP_CELLS: i64 = 4;
#[derive(Clone)]
pub struct CellIter {
radius: i32,
@@ -155,10 +145,6 @@ struct MeshKey {
// The two vertical face tests include both surfaces at the boundaries.
top: bool,
bottom: bool,
// The camera's cell within the snapped origin's, for the 3x3 cells whose inward
// faces are drawn. Only read while the camera is inside the layer.
sub_x: i32,
sub_z: i32,
}
#[repr(C)]
@@ -183,30 +169,16 @@ impl Input<'_> {
let (tw, th) = (f64::from(*w) * 12.0, f64::from(*h) * 12.0);
x -= (x / tw).floor() * tw;
z -= (z / th).floor() * th;
let cell_x = (x / 12.0).floor() as i64 / SNAP_CELLS * SNAP_CELLS;
let cell_z = (z / 12.0).floor() as i64 / SNAP_CELLS * SNAP_CELLS;
let cell_x = (x / 12.0).floor() as i64;
let cell_z = (z / 12.0).floor() as i64;
let bottom = (CLOUD_HEIGHT - self.camera.y) as f32;
// The inward faces of the 3x3 cells around the camera are only seen from inside the
// layer; outside it they are back-facing, so the mesh need not follow the camera there.
let inside_layer = bottom <= 0.0 && bottom + 4.0 >= 0.0;
let (sub_x, sub_z) = if inside_layer {
(
(x / 12.0).floor() as i32 - cell_x as i32,
(z / 12.0).floor() as i32 - cell_z as i32,
)
} else {
(0, 0)
};
let key = MeshKey {
cell_x,
cell_z,
// One more ring than the range, for the cells the snapped origin can uncover.
radius: radius_cells(self.range) + SNAP_CELLS as i32,
radius: radius_cells(self.range),
fast: self.fast,
top: bottom <= 0.0,
bottom: bottom + 4.0 >= 0.0,
sub_x,
sub_z,
};
let colors = [1.0, 0.7, 0.8, 0.9].map(|shade| {
let [r, g, b, a] = self.color;
@@ -228,10 +200,6 @@ impl Input<'_> {
}
}
/// The cloud cells around a snapped origin, rebuilt when the key changes: once every
/// [`SNAP_CELLS`] cells the camera crosses, and every cell while the camera is inside the
/// layer, where the inward faces follow it. The renderer copies the vertices to the GPU on
/// a rebuild, which is why the origin snaps at all.
#[derive(Default)]
pub(crate) struct Mesh {
key: Option<MeshKey>,
@@ -389,8 +357,8 @@ impl Mesh {
}
let mask = face_mask(
dx - key.sub_x,
dz - key.sub_z,
dx,
dz,
bottom,
[
!filled(cx, cz - 1),
@@ -446,16 +414,13 @@ mod tests {
assert_eq!(color[3], 127.0 / 255.0);
}
}
// One block into the second snap cell: every case below moves the snapped origin, or
// another part of the key, so each one rebuilds.
let far_cell = SNAP_CELLS as f64 * 12.0 + 1.0;
for (x, y, z, range, fast) in [
(far_cell, 101.0, 1.0, 2, false),
(far_cell, 101.0, -5.0, 2, false),
(far_cell, 192.33, -5.0, 2, false),
(far_cell, 196.34, -5.0, 2, false),
(far_cell, 196.34, -5.0, 3, false),
(far_cell, 196.34, -5.0, 3, true),
(13.0, 101.0, 1.0, 2, false),
(13.0, 101.0, -5.0, 2, false),
(13.0, 192.33, -5.0, 2, false),
(13.0, 196.34, -5.0, 2, false),
(13.0, 196.34, -5.0, 3, false),
(13.0, 196.34, -5.0, 3, true),
] {
input.camera = glam::DVec3::new(x, y, z);
input.range = range;
@@ -469,14 +434,8 @@ mod tests {
);
assert!(!mesh.prepare(&input).0);
}
// Drift alone crosses the snapped origin, even with a stationary camera.
input.camera = glam::DVec3::new(1.0, 101.0, 1.0);
input.time = 0;
input.partial_tick = 0.0;
assert!(mesh.prepare(&input).0);
assert!(!mesh.prepare(&input).0);
// 400 ticks of drift move the clouds 12 blocks: one whole snap cell.
input.time = SNAP_CELLS * 400;
// Drift alone crosses a cell, even with a stationary camera.
input.time += 400;
assert!(mesh.prepare(&input).0);
// Resource reload constructs a new renderer and cache, including empty packs.
let mut reloaded = Mesh::default();
@@ -487,80 +446,6 @@ mod tests {
assert!(!reloaded.prepare(&input).0);
}
#[test]
fn a_snap_cell_of_motion_keeps_the_mesh_at_the_same_world_cells() {
let texture = (8, 8, (0..64).map(|i| i % 3 != 0).collect());
let mut input = Input {
texture: &texture,
uv: [0.5; 2],
camera: glam::DVec3::new(1.0, 100.0, 1.0),
time: 0,
partial_tick: 0.0,
range: 2,
fast: false,
color: [1.0; 4],
};
// One snap cell, in blocks.
let cell = SNAP_CELLS as f64 * 12.0;
let mut mesh = Mesh::default();
let (rebuilt, uniform) = mesh.prepare(&input);
assert!(rebuilt);
// The shader draws a vertex at `camera + position + offset`, so the world cells the
// cloud covers are `camera` plus this: the same ones anywhere inside a snap cell.
assert_eq!(input.camera.x as f32 + uniform.offset[0], 0.0);
for x in [1.5, cell * 0.15, cell * 0.5, cell * 0.75, cell - 1.5] {
input.camera.x = x;
let (rebuilt, uniform) = mesh.prepare(&input);
assert!(!rebuilt, "rebuilt at {x}");
assert_eq!(input.camera.x as f32 + uniform.offset[0], 0.0);
// The trailing origin still covers the whole range past the camera.
let far = mesh
.vertices
.iter()
.map(|v| v.pos[0] + uniform.offset[0])
.fold(f32::MIN, f32::max);
assert!(far >= 32.0, "clouds end {far} blocks ahead at {x}");
}
// The next snap cell takes the origin with it.
input.camera.x = cell;
let (rebuilt, uniform) = mesh.prepare(&input);
assert!(rebuilt);
assert_eq!(input.camera.x as f32 + uniform.offset[0], cell as f32);
}
#[test]
fn only_inside_the_layer_does_the_mesh_follow_the_camera_between_snap_cells() {
let texture = (8, 8, (0..64).map(|i| i % 3 != 0).collect());
let mut input = Input {
texture: &texture,
uv: [0.5; 2],
camera: glam::DVec3::new(1.0, 101.0, 1.0),
time: 0,
partial_tick: 0.0,
range: 2,
fast: false,
color: [1.0; 4],
};
let mut mesh = Mesh::default();
assert!(mesh.prepare(&input).0);
// Below the layer, cells crossed inside one snap cell change nothing visible.
for (x, z) in [(11.9, 11.9), (24.0, 12.0), (35.5, 23.9)] {
input.camera.x = x;
input.camera.z = z;
assert!(
!mesh.prepare(&input).0,
"rebuilt at {x},{z} below the layer"
);
}
// Inside it, the inward faces around the camera do move with its own cell.
input.camera.y = 194.0;
assert!(mesh.prepare(&input).0);
input.camera.x = 12.0;
assert!(mesh.prepare(&input).0);
input.camera.y = 101.0;
assert!(mesh.prepare(&input).0);
}
#[test]
fn settings_and_packed_alpha_gate_clouds() {
for mode in ["false", "fast", "true"] {
+51 -193
View File
@@ -208,7 +208,7 @@ where
Ok(resolve_world_movement(
self,
MovementRequest {
entity: context.entity().unwrap_or(EntityIdentity(0)),
entity: EntityIdentity(0),
context,
bounds,
movement,
@@ -279,7 +279,7 @@ impl SceneCollisions<'_, '_> {
Ok(resolve_world_movement(
self,
MovementRequest {
entity: context.entity().unwrap_or(EntityIdentity(0)),
entity: EntityIdentity(0),
context,
bounds,
movement,
@@ -300,7 +300,6 @@ impl SceneCollisions<'_, '_> {
CollisionContext::for_entity(
CollisionEntity {
identity: EntityIdentity(0),
kind: horizon_world::collision_context::EntityContextKind::Living,
y,
descending: false,
main_hand: Some(CollisionItem::EMPTY),
@@ -336,58 +335,6 @@ where
{
no_collision_in(world, bounds, true)
}
/// `CollisionGetter.noCollision(entity, bounds)`: retain the real entity snapshot.
pub fn no_collision_with_context<W, E>(
world: &mut W,
bounds: Aabb,
context: CollisionContext<'_>,
) -> bool
where
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
{
no_world_collision(
world,
CollisionCheckRequest {
entity: context.entity().unwrap_or(EntityIdentity(0)),
context,
bounds,
candidate_budget: 1_000_000,
},
)
.unwrap_or(false)
}
/// `Level.loadedAndEntityCanStandOn(pos, projectile)` with the projectile's
/// feet at `entity_y` (`loadedAndEntityCanStandOnFace` with `UP`):
/// `Block.isFaceFull(state.getCollisionShape(level, pos,
/// CollisionContext.of(projectile)), UP)`. A projectile is not living and never
/// falls (it moves by `setPos`), so powder snow and liquids hold nothing up
/// and only scaffolding reads the height. An unloaded or out-of-world block
/// reads as air.
pub fn projectile_can_stand_on(
world: &World,
binding: &BlockCollisionRegistry<'_>,
pos: BlockPos,
entity_y: f64,
) -> bool {
use horizon_core::geometry::{Direction, ShapeOp};
let mut scene = SceneCollisions {
world,
binding,
subject: None,
};
let state = world.block(pos.x, pos.y, pos.z);
let context = CollisionContext::position(entity_y);
binding
.collision_shape_with_context(&mut scene, state, pos, &context)
.is_ok_and(|shape| {
shape.is_block()
|| shape.face_shape(Direction::Up).is_ok_and(|face| {
VoxelShape::block()
.join_is_not_empty(&face, ShapeOp::XOR)
.is_ok_and(|differs| !differs)
})
})
}
fn no_collision_in<W, E>(world: &mut W, bounds: Aabb, fluids: bool) -> bool
where
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
@@ -423,57 +370,49 @@ impl SceneCollisions<'_, '_> {
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
return false;
}
// A column and section at a time. Most boxes lie in a few sections whose palette says that no
// block in them can matter (nothing that collides, nothing that reaches out of its cell), and
// then none of their cells is read: a particle's box at speed spans dozens of cells.
for cz in (lo.z >> 4)..=(hi.z >> 4) {
for cx in (lo.x >> 4)..=(hi.x >> 4) {
// The scan passes the cells of a column that is not resident.
let Some(chunk) = self.world.chunks.get(&(cx, cz)).map(|c| &**c) else {
continue;
};
let (x0, x1) = (lo.x.max(cx << 4), hi.x.min((cx << 4) + 15));
let (z0, z1) = (lo.z.max(cz << 4), hi.z.min((cz << 4) + 15));
for sy in (lo.y >> 4)..=(hi.y >> 4) {
let reaches = self.section_reaches_out(chunk, sy);
if !reaches && self.section_blocks_all_empty(chunk, sy) {
let mut column: Option<((i32, i32), Option<&crate::chunk::Chunk>)> = None;
let mut section: Option<((i32, i32), i32, bool)> = None;
for z in lo.z..=hi.z {
for y in lo.y..=hi.y {
for x in lo.x..=hi.x {
let outer = [
x == lo.x || x == hi.x,
y == lo.y || y == hi.y,
z == lo.z || z == hi.z,
];
let kind = outer.into_iter().filter(|&o| o).count();
if kind == 3 {
continue;
}
let (y0, y1) = (lo.y.max(sy << 4), hi.y.min((sy << 4) + 15));
if !reaches {
// Nothing in the section reaches out of its cell: only the inner cells matter.
for z in z0.max(lo.z + 1)..=z1.min(hi.z - 1) {
for y in y0.max(lo.y + 1)..=y1.min(hi.y - 1) {
for x in x0.max(lo.x + 1)..=x1.min(hi.x - 1) {
if self.binding.may_collide(chunk.block(x, y, z)) {
return false;
}
}
let at = (x >> 4, z >> 4);
let chunk = match column {
Some((c, chunk)) if c == at => chunk,
_ => {
let chunk = self.world.chunks.get(&at).map(|c| &**c);
column = Some((at, chunk));
chunk
}
};
// The scan passes the cells of a column that is not resident.
let Some(chunk) = chunk else {
continue;
};
if kind != 0 {
let reaches = match section {
Some((c, sy, reaches)) if c == at && sy == y >> 4 => reaches,
_ => {
let reaches = self.section_reaches_out(chunk, y >> 4);
section = Some((at, y >> 4, reaches));
reaches
}
};
if reaches {
return false;
}
continue;
}
for z in z0..=z1 {
let outer_z = z == lo.z || z == hi.z;
for y in y0..=y1 {
let outer_y = y == lo.y || y == hi.y;
for x in x0..=x1 {
let outer = [x == lo.x || x == hi.x, outer_y, outer_z];
let kind = outer.into_iter().filter(|&o| o).count();
if kind == 3 {
continue;
}
if kind != 0 {
if reaches {
return false;
}
continue;
}
if self.binding.may_collide(chunk.block(x, y, z)) {
return false;
}
}
}
if self.binding.may_collide(chunk.block(x, y, z)) {
return false;
}
}
}
@@ -481,23 +420,6 @@ impl SceneCollisions<'_, '_> {
true
}
/// Whether every block a section's palette holds has an empty collision shape (air, plants);
/// false for a palette of the whole registry or a section outside the column.
fn section_blocks_all_empty(&self, chunk: &crate::chunk::Chunk, section_y: i32) -> bool {
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
blocks.is_some_and(|blocks| {
!blocks.is_global()
&& !blocks.maybe_has(|v| {
self.binding
.collision_block(BlockStateId(v))
.map_or(true, |block| !block.empty_shape)
})
})
}
/// `may_reach_out`: whether a section's palette holds a block that reaches out of
/// its cell; a global palette, or a section outside the column, may.
fn section_reaches_out(&self, chunk: &crate::chunk::Chunk, section_y: i32) -> bool {
@@ -528,7 +450,18 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
/// From the section's palette: every block it can hold has an empty shape (air, plants).
/// A palette of the whole registry, or a section outside the column, is read cell by cell.
fn section_is_empty(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
Ok(self.section_blocks_all_empty(chunk, section_y))
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
Ok(blocks.is_some_and(|blocks| {
!blocks.is_global()
&& !blocks.maybe_has(|v| {
self.binding
.collision_block(BlockStateId(v))
.map_or(true, |block| !block.empty_shape)
})
}))
}
/// The resident columns do not change while a scan runs, and looking one up changes nothing.
fn missing_chunks_stay_missing(&self) -> bool {
@@ -1013,76 +946,6 @@ mod shulker_collision_tests {
mod section_tests {
use super::*;
impl SceneCollisions<'_, '_> {
/// `cells_without_collision` as it was before it skipped sections: every cell of the box, one at a time.
fn cells_without_collision_by_cell(&self, bounds: Aabb) -> bool {
let (min, max) = (bounds.min_position(), bounds.max_position());
if !min.is_finite() || !max.is_finite() {
return false;
}
// The scan's cells: one past the box on every side (`block_collision_scan::cells`).
let lo =
BlockPos::containing(Vec3::new(min.x - 1.0e-7, min.y - 1.0e-7, min.z - 1.0e-7))
.offset(-1, -1, -1);
let hi =
BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
.offset(1, 1, 1);
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
return false;
}
let mut column: Option<((i32, i32), Option<&crate::chunk::Chunk>)> = None;
let mut section: Option<((i32, i32), i32, bool)> = None;
for z in lo.z..=hi.z {
for y in lo.y..=hi.y {
for x in lo.x..=hi.x {
let outer = [
x == lo.x || x == hi.x,
y == lo.y || y == hi.y,
z == lo.z || z == hi.z,
];
let kind = outer.into_iter().filter(|&o| o).count();
if kind == 3 {
continue;
}
let at = (x >> 4, z >> 4);
let chunk = match column {
Some((c, chunk)) if c == at => chunk,
_ => {
let chunk = self.world.chunks.get(&at).map(|c| &**c);
column = Some((at, chunk));
chunk
}
};
// The scan passes the cells of a column that is not resident.
let Some(chunk) = chunk else {
continue;
};
if kind != 0 {
let reaches = match section {
Some((c, sy, reaches)) if c == at && sy == y >> 4 => reaches,
_ => {
let reaches = self.section_reaches_out(chunk, y >> 4);
section = Some((at, y >> 4, reaches));
reaches
}
};
if reaches {
return false;
}
continue;
}
if self.binding.may_collide(chunk.block(x, y, z)) {
return false;
}
}
}
}
true
}
}
/// Boxes swept through air, past torches (no collision shape), a fence (a shape
/// reaching into the cell above) and the scene's stone: wherever the cells say no
/// block can collide, the full resolution returns the movement, to the bit; the rest
@@ -1140,11 +1003,6 @@ mod section_tests {
(next() - 0.5) * reach,
);
let fast = scene.cells_without_collision(bb.expand_towards(d));
assert_eq!(
fast,
scene.cells_without_collision_by_cell(bb.expand_towards(d)),
"{bb:?} by {d:?}"
);
let mut full = SceneCollisions {
world: &world,
binding: &binding,
+15 -141
View File
@@ -19,75 +19,26 @@ pub struct ModMenuType {
pub slots: usize,
pub data_slots: usize,
pub results: Vec<usize>,
/// its crafting grid, if it has one (`crafting`).
pub crafting: Option<ModCrafting>,
/// its linked slots, if it has any (`links`).
pub links: Option<ModLinks>,
}
/// a mod menu type's linked slots: `count` container slots from `from` on,
/// which show the slots of the containers a native mod links them to
/// (`native_menu_links`) instead of the block entity's.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ModLinks {
pub from: usize,
pub count: usize,
}
impl ModLinks {
/// The linked container slots.
pub fn slots(&self) -> std::ops::Range<usize> {
self.from..self.from + self.count
}
}
/// a mod menu type's crafting grid: `size`x`size` container slots from
/// `grid` on, row by row, and the slot showing what they craft. The block
/// entity holds the grid; the result is the menu's.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ModCrafting {
pub grid: usize,
pub size: usize,
pub result: usize,
}
impl ModCrafting {
/// The grid's container slots.
pub fn grid_slots(&self) -> std::ops::Range<usize> {
self.grid..self.grid + self.size * self.size
}
}
static MOD_MENU_TYPES: std::sync::OnceLock<Vec<ModMenuType>> = std::sync::OnceLock::new();
/// One menu type as [`register_mod_menu_types`] takes it: `(id, slots, data
/// slots, results, crafting, links)`.
pub type ModMenuTypeEntry = (
String,
usize,
usize,
Vec<usize>,
Option<ModCrafting>,
Option<ModLinks>,
);
/// registers the content mods' menu types, a list set once per process
/// (false when it already was).
pub fn register_mod_menu_types(menus: impl IntoIterator<Item = ModMenuTypeEntry>) -> bool {
/// registers the content mods' menu types, `(id, slots, data slots,
/// results)`, a list set once per process (false when it already was).
pub fn register_mod_menu_types(
menus: impl IntoIterator<Item = (String, usize, usize, Vec<usize>)>,
) -> bool {
if MOD_MENU_TYPES.get().is_some() {
return false;
}
let menus =
menus.into_iter().map(
|(id, slots, data_slots, results, crafting, links)| ModMenuType {
id: Box::leak(id.into_boxed_str()),
slots,
data_slots,
results,
crafting,
links,
},
);
let menus = menus
.into_iter()
.map(|(id, slots, data_slots, results)| ModMenuType {
id: Box::leak(id.into_boxed_str()),
slots,
data_slots,
results,
});
MOD_MENU_TYPES.set(menus.collect()).is_ok()
}
@@ -316,10 +267,7 @@ impl SkullSkin {
/// The item slots of the container `name`'s block carries, if any.
fn container_size(name: &str) -> Option<usize> {
match name {
"minecraft:chest"
| "minecraft:trapped_chest"
| "minecraft:barrel"
| "minecraft:ender_chest" => Some(27),
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => Some(27),
n if n.ends_with("copper_chest") => Some(27),
"minecraft:hopper" => Some(HOPPER_SLOTS),
"minecraft:dispenser" | "minecraft:dropper" => Some(9),
@@ -444,29 +392,9 @@ impl BlockEntity {
if name == "minecraft:end_gateway" {
return Some(Self::EndGateway(EndGateway::default()));
}
if name == "minecraft:bell" {
return Some(Self::Bell);
}
if name == "minecraft:conduit" {
return Some(Self::Conduit(None));
}
if name == "minecraft:enchanting_table" {
return Some(Self::EnchantingTable);
}
if name == "minecraft:daylight_detector" {
return Some(Self::DaylightDetector);
}
if name == "minecraft:ender_chest" {
// EnderChestBlock.newBlockEntity: 27 slots, player-specific inventory.
return Some(Self::Chest(vec![None; 27]));
}
if name == "minecraft:end_portal" {
// EndPortalBlock.newBlockEntity: no data, only identity.
return Some(Self::EndPortal);
}
if name.ends_with("copper_golem_statue") {
return Some(Self::CopperGolemStatue(Default::default()));
}
if name == "minecraft:beacon" {
return Some(Self::Beacon(Box::new(Beacon {
last_check_y: i32::MIN / 2,
@@ -532,7 +460,6 @@ impl BlockEntity {
Self::CommandBlock(_) => crate::command_block::Mode::of_block(name).is_some(),
Self::Skull(_) => is_skull(name),
Self::Banner(_) => name.ends_with("_banner"),
Self::CopperGolemStatue(_) => name.ends_with("copper_golem_statue"),
Self::Shelf(_) => name == "minecraft:shelf" || name.ends_with("_shelf"),
Self::Mod(_) => !name.starts_with("minecraft:"),
Self::Beehive(_) => matches!(name, "minecraft:beehive" | "minecraft:bee_nest"),
@@ -556,10 +483,6 @@ impl BlockEntity {
Self::Crafter(_) => name == "minecraft:crafter",
Self::EndGateway(_) => name == "minecraft:end_gateway",
Self::DaylightDetector => name == "minecraft:daylight_detector",
Self::Bell => name == "minecraft:bell",
Self::Conduit(_) => name == "minecraft:conduit",
Self::EndPortal => name == "minecraft:end_portal",
Self::EnchantingTable => name == "minecraft:enchanting_table",
Self::StructureBlock(_) => name == "minecraft:structure_block",
Self::TestBlock(_) => name == "minecraft:test_block",
Self::TestInstanceBlock(_) => name == "minecraft:test_instance_block",
@@ -587,7 +510,6 @@ impl BlockEntity {
Self::Lectern(_) => "minecraft:lectern",
Self::Sculk(s) => s.id(),
Self::Banner(_) => "minecraft:banner",
Self::CopperGolemStatue(_) => "minecraft:copper_golem_statue",
Self::Crafter(_) => "minecraft:crafter",
Self::CreakingHeart(_) => "minecraft:creaking_heart",
Self::Spawner(_) => "minecraft:mob_spawner",
@@ -598,10 +520,6 @@ impl BlockEntity {
Self::Skull(_) => "minecraft:skull",
Self::EndGateway(_) => "minecraft:end_gateway",
Self::DaylightDetector => "minecraft:daylight_detector",
Self::Bell => "minecraft:bell",
Self::Conduit(_) => "minecraft:conduit",
Self::EndPortal => "minecraft:end_portal",
Self::EnchantingTable => "minecraft:enchanting_table",
Self::Brushable(_) => "minecraft:brushable_block",
Self::Campfire(_) => "minecraft:campfire",
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
@@ -636,15 +554,11 @@ impl BlockEntity {
| Self::TestBlock(_)
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Bell
| Self::Conduit(_)
| Self::EndPortal
| Self::EnchantingTable
| Self::Brushable(_) => Vec::new(),
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
Self::Sculk(_) => Vec::new(),
Self::Banner(_) | Self::CopperGolemStatue(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
@@ -661,7 +575,6 @@ impl BlockEntity {
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::CopperGolemStatue(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
@@ -670,10 +583,6 @@ impl BlockEntity {
| Self::TestBlock(_)
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Bell
| Self::Conduit(_)
| Self::EndPortal
| Self::EnchantingTable
| Self::Brushable(_) => &[],
Self::Jukebox(j) => &j.items,
Self::Crafter(c) => &c.items,
@@ -700,7 +609,6 @@ impl BlockEntity {
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::CopperGolemStatue(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
@@ -709,10 +617,6 @@ impl BlockEntity {
| Self::TestBlock(_)
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Bell
| Self::Conduit(_)
| Self::EndPortal
| Self::EnchantingTable
| Self::Brushable(_) => &mut [],
Self::Jukebox(j) => &mut j.items,
Self::Crafter(c) => &mut c.items,
@@ -965,34 +869,4 @@ mod tests {
assert!((250..450).contains(&paid), "{paid}");
assert!((0..100).all(|_| (1..=2).contains(&create_experience(1, 1.7, &mut random))));
}
/// An ender chest placed with /setblock gets its block entity (`EnderChestBlock.newBlockEntity`),
/// so `/data get block` answers with the entity and the chunk saves it.
#[test]
fn an_ender_chest_starts_as_a_block_entity() {
let entity = BlockEntity::for_block("minecraft:ender_chest").expect("an ender chest");
// `Chest` variant with 27 slots; `id()` reports the chest kind, block name comes from NBT.
assert_eq!(entity.id(), "minecraft:chest");
assert_eq!(entity.slots().len(), 27);
assert!(entity.slots()[0].is_none());
assert!(matches!(entity, BlockEntity::Chest(_)));
// `fits`: an ender chest entity fits an ender chest block.
assert!(entity.fits("minecraft:ender_chest"));
// A stone block is not an ender chest.
assert!(BlockEntity::for_block("minecraft:stone").is_none());
}
/// An end portal placed with /setblock gets its block entity (`EndPortalBlock.newBlockEntity`),
/// so `/data get block` answers with the entity and the chunk saves it.
#[test]
fn an_end_portal_starts_as_a_block_entity() {
let entity = BlockEntity::for_block("minecraft:end_portal").expect("an end portal");
assert_eq!(entity.id(), "minecraft:end_portal");
assert_eq!(entity.slots().len(), 0);
assert!(matches!(entity, BlockEntity::EndPortal));
// `fits`: an end portal entity fits an end portal block.
assert!(entity.fits("minecraft:end_portal"));
// A stone block is not an end portal.
assert!(BlockEntity::for_block("minecraft:stone").is_none());
}
}
File diff suppressed because it is too large. Load diff
+14 -26
View File
@@ -10,7 +10,7 @@ use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use chrono::{DateTime, Local};
use horizon_system::{command_output, machine_arch};
use horizon_telemetry::system::machine_arch;
/// What the game is doing, set as it goes: graphics adapter, settings, packs, world.
static DETAILS: Mutex<Vec<(&'static str, String)>> = Mutex::new(Vec::new());
@@ -230,6 +230,15 @@ pub fn release_tag() -> Option<String> {
.then(|| folder.to_owned())
}
fn command_output(program: &str, args: &[&str]) -> Option<String> {
let out = std::process::Command::new(program)
.args(args)
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout).trim().to_owned();
(out.status.success() && !text.is_empty()).then_some(text)
}
fn read(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok()
}
@@ -260,7 +269,7 @@ fn operating_system() -> String {
} else if cfg!(target_os = "macos") {
command_output("sw_vers", &["-productVersion"]).map(|v| format!("macOS {v}"))
} else if cfg!(windows) {
horizon_system::windows_version()
horizon_telemetry::system::windows_version()
} else {
None
};
@@ -275,11 +284,11 @@ fn operating_system() -> String {
pub fn cpu() -> String {
let threads = std::thread::available_parallelism().map_or(0, |n| n.get());
let name = if cfg!(target_os = "linux") {
read("/proc/cpuinfo").and_then(|t| horizon_system::linux_cpu_name(&t))
read("/proc/cpuinfo").and_then(|t| horizon_telemetry::system::linux_cpu_name(&t))
} else if cfg!(target_os = "macos") {
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
} else if cfg!(windows) {
horizon_system::windows_cpu()
horizon_telemetry::system::windows_cpu()
} else {
std::env::var("PROCESSOR_IDENTIFIER").ok()
};
@@ -378,21 +387,6 @@ fn thread_name(side: &str) -> String {
/// promise to launchers (docs/api/launch.md), so it never changes within a line of that API.
pub const SAVED_LINE: &str = "Horizon: crash report saved to ";
/// What runs on each report before it is saved ([`on_report`]).
pub type ReportHook = fn(&Path, &Report);
static REPORT_HOOKS: std::sync::RwLock<Vec<ReportHook>> = std::sync::RwLock::new(Vec::new());
/// Runs `hook` with every crash report, before it is saved: the game folder and the report. An
/// extension point for what lives above the engine (Horizon's opt-in telemetry keeps the
/// report). A hook runs inside the crash handler: it must not panic or block.
pub fn on_report(hook: ReportHook) {
REPORT_HOOKS
.write()
.unwrap_or_else(|e| e.into_inner())
.push(hook);
}
/// Writes the report and tells where it is; the log gets the error too.
fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
if REPORTED.swap(true, Ordering::SeqCst) {
@@ -413,13 +407,7 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
),
);
crate::logging::flush();
for hook in REPORT_HOOKS
.read()
.unwrap_or_else(|e| e.into_inner())
.iter()
{
hook(home, &report);
}
crate::telemetry::keep_crash(home, &report);
// A player of a shared game folder (`--player-dir`) finds the report beside its own log.
match report.save(crate::logging::player_dir_override().unwrap_or(home)) {
Ok(path) => {
+15 -253
View File
@@ -141,8 +141,7 @@ impl DataPack {
pub fn discover(world: &Path) -> Vec<DataPack> {
discover_folder(&world.join("datapacks"))
}
/// `FolderRepositorySource.loadPacks`: packs in Create World's temporary
/// folder or a world's data-pack directory, with required folder metadata.
/// Packs in Create World's temporary pack folder, before a world exists.
pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
@@ -151,7 +150,7 @@ pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
let path = e.path();
if path.is_dir() && folder_metadata_present(&path) {
if path.is_dir() && path.join("pack.mcmeta").exists() {
packs.push(DataPack {
id: format!("file/{name}"),
source: Source::Dir(path),
@@ -169,22 +168,6 @@ pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
packs.sort_by(|a, b| a.id.cmp(&b.id));
packs
}
/// `Pack.readPackMetadata`: even its fallback codec requires a pack section
/// and description. Keep this folder gate separate from format compatibility
/// and the existing interpretation of the description component.
fn folder_metadata_present(path: &Path) -> bool {
let Some(meta) = std::fs::read(path.join("pack.mcmeta"))
.ok()
.and_then(|bytes| crate::packs::parse_meta(&bytes))
else {
return false;
};
meta.as_object()
.and_then(|root| root.get("pack"))
.and_then(serde_json::Value::as_object)
.and_then(|pack| pack.get("description"))
.is_some_and(|description| !description.is_null())
}
/// The resources root's built-in feature packs (`ServerPacksSource`
/// scanning the game's `data/minecraft/datapacks`): every folder with a
/// `pack.mcmeta`, a feature pack (`PackSource.FEATURE`). Off until a world
@@ -278,43 +261,6 @@ pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::Pac
}
infos
}
/// The folder of a game folder whose packs every new world there starts with: a modpack's profile
/// keeps the packs of its data-only jars here (`horizon-port-pack`). Each world gets its own copy,
/// as Create World's packs do, so a world keeps working when the folder changes. Vanilla has no
/// such folder, and without one nothing here happens.
pub const NEW_WORLD_PACKS: &str = "new-world-datapacks";
/// Create World's temporary pack folder holding `game_dir`'s [`NEW_WORLD_PACKS`], and their ids,
/// to show them selected in the Data Packs screen. `None` when there are none.
pub fn seed_create_world(game_dir: &Path) -> Option<(PathBuf, Vec<String>)> {
let from = game_dir.join(NEW_WORLD_PACKS);
let ids: Vec<String> = discover_folder(&from).into_iter().map(|p| p.id).collect();
if ids.is_empty() {
return None;
}
let seeded = temp_folder()
.map_err(|e| e.to_string())
.and_then(|dir| copy(&from, &dir).map(|()| dir));
match seeded {
Ok(dir) => Some((dir, ids)),
Err(e) => {
log::warn!("Horizon: data packs for new worlds: {e}");
None
}
}
}
/// A world about to be created without Create World (`--world`, the control socket) gets
/// `game_dir`'s [`NEW_WORLD_PACKS`]. The game turns on every pack it finds in a world.
pub fn install_new_world_packs(game_dir: &Path, world: &Path) -> Result<(), String> {
let from = game_dir.join(NEW_WORLD_PACKS);
let exists = |name: &str| world.join(name).exists();
if !from.is_dir() || exists("level.dat") || exists("level.dat_old") {
return Ok(());
}
copy(&from, &world.join("datapacks"))
}
/// A new empty folder for Create World's packs
/// (`Files.createTempDirectory("mcworld-")`).
pub fn temp_folder() -> std::io::Result<PathBuf> {
@@ -661,10 +607,8 @@ pub struct DataStore {
pub predicates: BTreeMap<String, serde_json::Value>,
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// `slot_source` definitions, evaluated against a command's live accessor.
pub slot_sources: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/`, `enchantment/` and `tags/…/` files, by
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
@@ -673,7 +617,7 @@ pub struct DataStore {
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/`, `damage_type/`, `data_maps/`, `enchantment/` or `tags/<sub>/`, where the id keeps the sub
/// `villager_trade/`, `dialog/`, `damage_type/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
/// path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
@@ -681,7 +625,7 @@ fn content_id(file: &str) -> Option<String> {
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
| "villager_trade" | "dialog" | "damage_type" | "data_maps" | "enchantment" => kind,
| "villager_trade" | "dialog" | "damage_type" | "data_maps" => kind,
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
// test:sub/mytag`), the whole path after the namespace, so nested
// registries keep their directory (`TagLoader`'s id shape).
@@ -715,14 +659,12 @@ impl DataStore {
"damage_type",
"data_maps",
"dialog",
"enchantment",
"function",
"item_modifier",
"loot_table",
"predicate",
"recipe",
"recipe_filter",
"slot_source",
"tags",
"trade_set",
"villager_trade",
@@ -759,11 +701,6 @@ impl DataStore {
&& let Ok(v) = serde_json::from_slice(&bytes)
{
s.item_modifiers.insert(id, v);
} else if let Some(id) = resource_id(&f, "slot_source", ".json")
&& let Some(bytes) = pack.read(&f)
&& let Ok(v) = serde_json::from_slice(&bytes)
{
s.slot_sources.insert(id, v);
} else if !builtin
&& let Some(id) = content_id(&f)
&& let Some(bytes) = pack.read(&f)
@@ -777,55 +714,15 @@ impl DataStore {
}
s
}
/// `ServerFunctionLibrary.getTag`: only valid function tags expand
/// (nested tags included, each function once).
/// `FunctionManager.getTag`: the functions a tag expands to (nested tags
/// included, each function once).
pub fn tag_functions(&self, id: &str) -> Vec<String> {
let mut out = Vec::new();
let mut seen_fns = std::collections::HashSet::new();
let mut seen_tags = std::collections::HashSet::new();
let invalid = self.invalid_function_tags(id);
self.expand(id, &mut out, &mut seen_fns, &mut seen_tags, &invalid);
self.expand(id, &mut out, &mut seen_fns, &mut seen_tags);
out
}
/// `TagLoader.tryBuildTag`: missing required members invalidate their tag and
/// required parents. Optional invalid children contribute nothing.
/// Resolve against the current accepted function map, without caching a
/// candidate store or changing the existing expansion's cycle handling.
fn invalid_function_tags<'a>(&'a self, id: &'a str) -> std::collections::HashSet<&'a str> {
let mut invalid = std::collections::HashSet::new();
let mut parents: std::collections::HashMap<&str, Vec<&str>> =
std::collections::HashMap::new();
let mut seen = std::collections::HashSet::new();
let mut pending = vec![id];
while let Some(tag) = pending.pop() {
if !seen.insert(tag) {
continue;
}
let Some(entries) = self.function_tags.get(tag) else {
invalid.insert(tag);
continue;
};
for (entry, required) in entries {
if let Some(child) = entry.strip_prefix('#') {
pending.push(child);
if *required {
parents.entry(child).or_default().push(tag);
}
} else if *required && !self.functions.contains_key(entry) {
invalid.insert(tag);
}
}
}
pending.extend(invalid.iter().copied());
while let Some(child) = pending.pop() {
for &parent in parents.get(child).into_iter().flatten() {
if invalid.insert(parent) {
pending.push(parent);
}
}
}
invalid
}
/// Depth-first walk keeping first-seen order. Each tag is expanded at most
/// once, so reference cycles end and nesting depth is not capped.
fn expand<'a>(
@@ -834,19 +731,18 @@ impl DataStore {
out: &mut Vec<String>,
seen_fns: &mut std::collections::HashSet<&'a str>,
seen_tags: &mut std::collections::HashSet<&'a str>,
invalid: &std::collections::HashSet<&'a str>,
) {
if invalid.contains(id) || !seen_tags.insert(id) {
if !seen_tags.insert(id) {
return;
}
let Some(entries) = self.function_tags.get(id) else {
return;
};
for (e, _) in entries {
for (e, required) in entries {
match e.strip_prefix('#') {
Some(inner) => self.expand(inner, out, seen_fns, seen_tags, invalid),
Some(inner) => self.expand(inner, out, seen_fns, seen_tags),
None => {
if self.functions.contains_key(e) && seen_fns.insert(e) {
if (self.functions.contains_key(e) || *required) && seen_fns.insert(e) {
out.push(e.clone());
}
}
@@ -1028,97 +924,6 @@ pub fn worldgen_overrides(
mod tests {
use super::*;
#[test]
fn folder_metadata_is_required_before_contents_are_discovered() {
let root =
std::env::temp_dir().join(format!("horizon-folder-metadata-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
for (name, metadata) in [
("broken", Some("{")),
("root_empty", Some("{}")),
("root_array", Some("[]")),
("pack_empty", Some(r#"{"pack":{}}"#)),
("pack_string", Some(r#"{"pack":"bad"}"#)),
("missing", None),
(
"full",
Some(
r#"{"pack":{"description":"full","pack_format":121,"min_format":[121,0],"max_format":[121,0]}}"#,
),
),
(
"description",
Some(r#"{"pack":{"description":"description only"}}"#),
),
("numeric", Some(r#"{"pack":{"description":42}}"#)),
("null", Some(r#"{"pack":{"description":null}}"#)),
("bom", Some("\u{feff}{\"pack\":{\"description\":\"BOM\"}}")),
(
"comment",
Some("{\"pack\":{\"description\":\"comment\"}}\n// trailing"),
),
] {
let dir = root.join(name);
std::fs::create_dir_all(dir.join("data/h146/function")).unwrap();
std::fs::write(
dir.join(format!("data/h146/function/{name}.mcfunction")),
"scoreboard players set admitted h146 1\n",
)
.unwrap();
if let Some(metadata) = metadata {
std::fs::write(dir.join("pack.mcmeta"), metadata).unwrap();
}
}
let packs = discover_folder(&root);
assert_eq!(
packs.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(),
[
"file/bom",
"file/comment",
"file/description",
"file/full",
"file/numeric"
]
);
let store = DataStore::load(&packs.iter().collect::<Vec<_>>());
assert_eq!(
store
.functions
.keys()
.map(String::as_str)
.collect::<Vec<_>>(),
[
"h146:bom",
"h146:comment",
"h146:description",
"h146:full",
"h146:numeric"
]
);
let numeric = packs.iter().find(|p| p.id == "file/numeric").unwrap();
let raw: serde_json::Value =
serde_json::from_slice(&numeric.read("pack.mcmeta").unwrap()).unwrap();
assert_eq!(raw["pack"]["description"], 42);
// Builtin and feature construction still have their independent paths.
let resources = root.join("resources");
let feature = resources.join("data/minecraft/datapacks/feature");
std::fs::create_dir_all(&feature).unwrap();
std::fs::write(
feature.join("pack.mcmeta"),
r#"{"pack":{"description":"feature"}}"#,
)
.unwrap();
assert_eq!(
feature_packs(&resources)
.iter()
.map(|p| p.id.as_str())
.collect::<Vec<_>>(),
["feature"]
);
assert_eq!(DataPack::builtin(&resources).id, "vanilla");
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn data_map_files_are_content_files_keyed_by_map_namespace_and_registry() {
assert_eq!(
@@ -1146,35 +951,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_game_folder_s_new_world_packs_go_into_new_worlds_only() {
let root = std::env::temp_dir().join(format!("hz-new-world-packs-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let (game, world, old) = (root.join("game"), root.join("new"), root.join("old"));
assert!(seed_create_world(&game).is_none());
assert_eq!(install_new_world_packs(&game, &world), Ok(()));
assert!(!world.join("datapacks").exists());
for pack in ["void", "islands"] {
let dir = game.join(NEW_WORLD_PACKS).join(pack);
std::fs::create_dir_all(dir.join("data/v/function")).unwrap();
std::fs::write(dir.join("pack.mcmeta"), r#"{"pack":{"description":""}}"#).unwrap();
std::fs::write(dir.join("data/v/function/a.mcfunction"), "say a").unwrap();
}
let (temp, ids) = seed_create_world(&game).unwrap();
assert_eq!(ids, ["file/islands", "file/void"]);
assert!(temp.join("void/data/v/function/a.mcfunction").is_file());
let _ = std::fs::remove_dir_all(&temp);
install_new_world_packs(&game, &world).unwrap();
let ids: Vec<String> = discover(&world).into_iter().map(|p| p.id).collect();
assert_eq!(ids, ["file/islands", "file/void"]);
// A world that exists already keeps the packs it has.
std::fs::create_dir_all(&old).unwrap();
std::fs::write(old.join("level.dat"), "").unwrap();
install_new_world_packs(&game, &old).unwrap();
assert!(discover(&old).is_empty());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn enabled_packs_override_worldgen_in_order() {
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
@@ -1185,7 +961,7 @@ mod tests {
std::fs::write(path, t).unwrap();
};
for pack in ["a", "b", "off"] {
write(pack, "pack.mcmeta", r#"{"pack":{"description":"fixture"}}"#);
write(pack, "pack.mcmeta", "{}");
write(pack, "data/minecraft/worldgen/noise/cave.json", pack);
write(pack, "data/p/structure/room/a.nbt", pack);
write(
@@ -1356,20 +1132,6 @@ mod tests {
#[test]
fn tag_resolution_dedupes_fast_ends_cycles_and_nests_deeply() {
let mut store = DataStore::default();
// Every referenced function exists; missing required ones invalidate a tag.
for id in (0..25_000)
.map(|i| format!("t:f{i}"))
.chain(["t:x", "t:y", "t:deep"].map(str::to_owned))
{
store.functions.insert(
id.clone(),
Function {
id,
lines: Vec::new(),
parameters: Vec::new(),
},
);
}
let big: Vec<(String, bool)> = (0..50_000)
.map(|i| (format!("t:f{}", i % 25_000), true))
.collect();
@@ -1454,13 +1216,13 @@ mod tests {
write(
"fns",
"pack.mcmeta",
r#"{"pack":{"description":"fixture","min_format":121,"max_format":121}}"#,
r#"{"pack":{"min_format":121,"max_format":121}}"#,
);
write("fns", "data/x/function/f.mcfunction", "say hi");
write(
"off",
"pack.mcmeta",
r#"{"pack":{"description":"fixture","min_format":121,"max_format":121}}"#,
r#"{"pack":{"min_format":121,"max_format":121}}"#,
);
std::fs::create_dir_all(&dir).unwrap();
let mut zip = zip::ZipWriter::new(std::fs::File::create(dir.join("old.zip")).unwrap());
+6 -51
View File
@@ -33,10 +33,7 @@ struct Globals {
hdr_windows: array<vec4<f32>, 2>,
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
// y what the sky and fog get: the same, but none under water.
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
hdr_light: vec4<f32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -175,11 +172,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
// [world warp entity]
// (a world warp's pipelines put the vertex through the mod's `world_warp`: gfx/world_warp.rs)
let at = position;
// [end world warp entity]
var out = entity_vertex(at, uv, overlay & 0xffffu, color, light, normal);
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
out.glint_uv = glint_uv;
out.tier = (overlay >> 16u) & 0xffu;
return out;
@@ -283,19 +276,6 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
}
// CRUMBLING_UV_END
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return entity_written_color(in, entity_color(in, front));
}
// Takes entity_color's result rather than calling it: FXC (DX12) fails with "argument pulled into
// unrelated predicate" when entity_color's discards sit a function call below the entry point.
fn entity_written_color(in: Out, color: vec4<f32>) -> vec4<f32> {
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 && in.overlay != 11u && in.overlay != 12u {
var mark = hdr_entity_mark(in);
if in.overlay == 4u { mark = hdr_scene_alpha(globals.hdr_light.y); }
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_shares), color.a);
}
return color;
}
@fragment fn fs_entity_unmarked(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return entity_color(in, front);
}
fn entity_color(in: Out, front: bool) -> vec4<f32> {
@@ -378,23 +358,15 @@ fn shadow_color(in: Out) -> vec4<f32> {
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
let texel = textureSample(atlas, atlas_sampler, uv);
if texel.a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, hdr_entity_mark(in));
}
fn hdr_entity_mark(in: Out) -> f32 {
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
let texel = textureSample(atlas, atlas_sampler, uv);
// Additive and procedural colour draws can survive where the legacy mark pass skips.
if texel.a < 0.1 { return 1.0; }
// Fog hides a light source and the light on a surface as it hides everything else.
let fogged = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
if in.tier == 0u {
return hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged));
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged)));
}
// A layer that fades (the warden's) shines by what's left of it.
let y = brightness(texel.rgb * in.color.rgb) * in.color.a * (1.0 - fogged);
return hdr_mark(in.tier, y);
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, y));
}
fn brightness(c: vec3<f32>) -> f32 {
let l = pow(max(c, vec3(0.0)), vec3(2.2));
@@ -432,22 +404,10 @@ fn celestial_color(in: Out) -> vec4<f32> {
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return in.color * (1.0 - fog);
}
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
if in.overlay == 45u {
// A Polytone custom particle drawn additively: the fog over its colour, as vanilla's
// particle.fsh, under the additive blend (no alpha cutout: its faint texels add up).
let f = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
return color;
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
}
return color;
return celestial_color(in);
}
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
@@ -464,12 +424,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
let y = brightness(added);
// Only what's past the tier's window takes the mark: a soft edge keeps the sky's light,
// or it would come out darker than the sky around it.
let full = y >= globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u];
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_shares), color.a);
}
if HDR_WRITE_LIGHT { return vec4(added, color.a); }
if !full { return vec4(added, 0.0); }
if y < globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u] { return vec4(added, 0.0); }
return vec4(added, 1.0 - hdr_mark(tier, y));
}
File diff suppressed because it is too large. Load diff
+19 -83
View File
@@ -5,7 +5,7 @@ use crate::{
lang,
options_ui::{Event, Page},
settings::Settings,
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
settings_shell::{Group, Output, Row, Screen, Sheet, Value},
};
const TITLE: &str = "horizon.pages.extras";
@@ -36,7 +36,7 @@ pub(crate) enum ExtraRow {
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
let e = &s.extras;
let value = match (item.key_name(), item.switch(e)) {
(Some(key), _) => Value::Binding(Binding::key(key, item.value(e))),
(Some(key), _) => Value::Key(key, item.value(e)),
(None, Some(on)) => Value::Switch(on),
(None, None) => Value::Choice(item.value(e)),
};
@@ -85,10 +85,6 @@ impl Screen for ExtrasScreen {
fn note_lines(&self) -> usize {
NOTE_LINES
}
/// The rows read the extras, the mods and whether cosmetics are offered.
fn stamp(&self, s: &Settings) -> u64 {
shell::stamp_of(&(&s.extras, &s.mods, horizon_cosmetics::enabled()))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
let mut groups = vec![Group {
name: MODS,
@@ -107,14 +103,22 @@ impl Screen for ExtrasScreen {
}
}
}
vec![Sheet::new((), lang::get(TITLE), lang::get(ABOUT), groups)]
vec![Sheet {
id: (),
name: lang::get(TITLE),
about: lang::get(ABOUT),
groups,
}]
}
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
match row {
ExtraRow::Item(item) => {
item.cycle(&mut s.extras, back);
vec![Output::Event(Event::Changed)]
}
ExtraRow::Item(item) => match item.key_name() {
Some(key) => vec![Output::BindKey(key)],
None => {
item.cycle(&mut s.extras, back);
vec![Output::Event(Event::Changed)]
}
},
ExtraRow::Zoom(i) => {
s.mods.toggle(i);
vec![Output::Event(Event::Changed)]
@@ -200,21 +204,15 @@ mod tests {
/// The sort key binds instead of cycling; the wheel leaves it be.
#[test]
fn the_key_row_waits_for_a_key() {
use crate::settings_shell::Capture;
let mut s = Settings::default();
s.extras.sort = true;
let mut shell = Shell::<ExtrasScreen>::default();
let mut screen = ExtrasScreen;
let row = ExtraRow::Item(Item::SortKey);
let key = s.extras.sort_key.clone();
let at = shell.point_at(&s, SIZE, row);
assert_eq!(
shell.click(&mut s, at, SIZE, false, false, false),
vec![
Output::Event(Event::ButtonClick),
Output::Bind(Capture::Key(extras::SORT_KEY))
]
screen.press(&mut s, row, false),
vec![Output::BindKey(extras::SORT_KEY)]
);
ExtrasScreen.wheel_step(&mut s, row, true);
screen.wheel_step(&mut s, row, true);
assert_eq!(s.extras.sort_key, key);
}
@@ -230,66 +228,4 @@ mod tests {
assert!(s.extras.sort);
assert_eq!(screen.wheel_step(&mut s, ExtraRow::Zoom(0), true), None);
}
/// Extras has no Undo: a change leaves none to press or reach.
#[test]
fn a_change_on_extras_leaves_no_undo() {
use crate::settings_shell::{Columns, Target};
use winit::keyboard::KeyCode as K;
let mut s = Settings::default();
let mut shell = Shell::<ExtrasScreen>::default();
let at = shell.point_at(&s, SIZE, ExtraRow::Item(Item::Sort));
assert!(
!shell
.click(&mut s, at, SIZE, false, false, false)
.is_empty()
);
assert!(s.extras.sort && !shell.changed());
let cols = Columns::of(SIZE.0, SIZE.1).with_note_lines(NOTE_LINES);
let [x, y, ..] = cols.button(1);
assert!(
shell
.click(&mut s, (x + 2.0, y + 2.0), SIZE, false, false, false)
.is_empty()
);
assert!(s.extras.sort);
let reached = (0..100).any(|_| {
shell.key(&mut s, K::Tab, None, false, SIZE);
shell.focused() == Some(Target::Undo)
});
assert!(!reached);
}
/// A page with no page list has Done under the search field: Down goes to the first row
/// instead, from the keys and, as the frame declares it, from a controller's focus.
#[test]
fn down_from_the_search_field_lands_on_the_first_row() {
use crate::{hud::HudBatch, settings_shell::Columns, ui};
use winit::keyboard::KeyCode as K;
let atlas = crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent")).unwrap();
let mut s = Settings::default();
let mut shell = Shell::<ExtrasScreen>::default();
let mut batch = HudBatch::new([1708, 960]);
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
crate::widgets::end_frame(1);
let size = (batch.width, batch.height);
let cols = Columns::of(size.0, size.1).with_note_lines(NOTE_LINES);
// The Mods group's note can't take the focus: the first item can.
let first = ExtraRow::Item(Item::OwnNameTag);
let at = shell.point_at(&s, size, first);
let [x, y, w, h] = crate::widgets::linked_step(cols.search(), true).unwrap();
assert!(ui::hovered(at, x, y, w, h));
assert_eq!(crate::widgets::linked_step(cols.search(), false), None);
let [x, y, w, h] = cols.search();
shell.click(
&mut s,
(x + w / 2.0, y + h / 2.0),
size,
false,
false,
false,
);
shell.key(&mut s, K::ArrowDown, None, false, size);
assert_eq!(shell.focused_row(), Some(first));
}
}
+23 -45
View File
@@ -9,7 +9,6 @@ use crate::{
environment::Sky,
world::World,
};
use horizon_core::random::{LegacyRandom, RandomSource};
use std::path::Path;
/// `FogType` of the camera position.
@@ -65,7 +64,7 @@ impl FogTables {
}
/// Fog state that evolves over time.
#[derive(Clone, Debug)]
#[derive(Clone, Copy, Debug, Default)]
pub struct FogState {
/// `LocalPlayer.waterVisionTime` (0..600).
pub water_vision_time: i32,
@@ -73,37 +72,32 @@ pub struct FogState {
pub rain_fog_multiplier: f32,
/// `LightmapRenderStateExtractor.blockLightFlicker`.
pub block_light_flicker: f32,
/// `LightmapRenderStateExtractor.random`: `RandomSource.create()`.
flicker_random: LegacyRandom,
}
impl Default for FogState {
fn default() -> Self {
Self {
water_vision_time: 0,
rain_fog_multiplier: 0.0,
block_light_flicker: 0.0,
flicker_random: crate::random_source::create(),
}
}
flicker_seed: u64,
}
impl FogState {
fn next_float(&mut self) -> f32 {
// splitmix64: the vanilla source is unseeded `RandomSource.create()`.
self.flicker_seed = self.flicker_seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.flicker_seed;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
((z ^ (z >> 31)) >> 40) as f32 / (1u32 << 24) as f32
}
/// `LightmapRenderStateExtractor.tick`: the torch light flickers.
pub fn tick_flicker(&mut self) {
let r = &mut self.flicker_random;
let (a, b, c, d) = (
r.next_float(),
r.next_float(),
r.next_float(),
r.next_float(),
self.next_float(),
self.next_float(),
self.next_float(),
self.next_float(),
);
self.block_light_flicker += (a - b) * c * d * 0.1;
self.block_light_flicker *= 0.9;
}
/// `LocalPlayer.aiStep`: water vision ramps up while the eye is in water, ten times faster
/// for a spectator.
pub fn tick(&mut self, eye_in_water: bool, spectator: bool) {
/// `LocalPlayer.aiStep`: water vision ramps up while the eye is in water.
pub fn tick(&mut self, eye_in_water: bool) {
self.water_vision_time = if eye_in_water {
(self.water_vision_time + if spectator { 10 } else { 1 }).min(600)
(self.water_vision_time + 1).min(600)
} else {
(self.water_vision_time - 10).max(0)
};
@@ -708,11 +702,11 @@ mod tests {
fn water_vision_ramps() {
let mut s = FogState::default();
for _ in 0..100 {
s.tick(true, false);
s.tick(true);
}
assert!((s.water_vision(true) - 0.6).abs() < 1e-6);
for _ in 0..500 {
s.tick(true, false);
s.tick(true);
}
assert_eq!(s.water_vision(true), 1.0);
assert_eq!(s.water_vision(false), 0.0);
@@ -727,17 +721,17 @@ mod tests {
input.fog_type = FogType::Water;
// Just in: vanilla's water vision is 0, so the fog ends at a quarter of 96 blocks.
let mut state = FogState::default();
state.tick(true, false);
state.tick(true);
let first = setup_fog(&tables, &state, &input);
assert_eq!(first.environmental_end, 24.0);
for _ in 0..600 {
state.tick(true, false);
state.tick(true);
}
let settled = setup_fog(&tables, &state, &input);
assert_eq!(settled.environmental_end, 96.0);
input.full_water_vision = true;
let mut fresh = FogState::default();
fresh.tick(true, false);
fresh.tick(true);
let clear = setup_fog(&tables, &fresh, &input);
assert_eq!(clear.environmental_end, settled.environmental_end);
assert_eq!(clear.color, settled.color);
@@ -756,7 +750,7 @@ mod tests {
rain_fog_multiplier: 0.1,
..FogState::default()
};
let mut settled = start.clone();
let mut settled = start;
settle_rain_fog(&mut settled, 0.5, 15, true);
assert_eq!(settled.rain_fog_multiplier, 0.5);
let mut eased = start;
@@ -783,20 +777,4 @@ mod tests {
assert_eq!(fog.environmental_start, -160.0);
assert_eq!(fog.environmental_end, 768.0);
}
#[test]
fn a_spectator_sees_clearly_under_water_ten_times_sooner() {
let mut swimmer = FogState::default();
let mut spectator = FogState::default();
for _ in 0..60 {
swimmer.tick(true, false);
spectator.tick(true, true);
}
// LocalPlayer.getWaterVision after 60 ticks: 60 and 600 ticks of vision.
assert!((swimmer.water_vision(true) - 0.36).abs() < 1e-6);
assert_eq!(spectator.water_vision(true), 1.0);
// Out of the water both lose it 10 a tick.
spectator.tick(false, true);
assert_eq!(spectator.water_vision_time, 590);
}
}
+32 -135
View File
@@ -126,16 +126,11 @@ struct Cache {
random: horizon_core::random::LegacyRandom,
}
pub struct Font {
/// The atlas rects of every texture, by resource name: a bitmap provider's
/// `file` may name any texture, not only one under `textures/font`.
/// The atlas rects of the sheets under `textures/font`.
sheets: HashMap<String, Rect>,
missing: Glyph,
white: Rect,
root: PathBuf,
/// The game's own resources, below a pack overlay: a font file the overlay
/// lacks (a pack adds providers over vanilla's, or the overlay left a
/// directory out) is read from here, as vanilla's resource stack does.
fallback: Option<PathBuf>,
/// Atlas size and the reserved dynamic region (y, height).
atlas: (u32, u32),
region: (u32, u32),
@@ -204,13 +199,6 @@ fn parse_hex(text: &[u8], out: &mut HashMap<u32, HexGlyph>) {
out.insert(codepoint, HexGlyph { rows, left, right });
}
}
/// A resource file's bytes, from `root` or, when the overlay lacks it, from
/// the game's own resources below (`defaults`).
fn read_resource(root: &Path, defaults: Option<&Path>, path: &str) -> Option<Vec<u8>> {
std::fs::read(root.join(path))
.ok()
.or_else(|| defaults.and_then(|d| std::fs::read(d.join(path)).ok()))
}
fn read_json(root: &Path, path: &str) -> Option<serde_json::Value> {
serde_json::from_slice(&std::fs::read(root.join(path)).ok()?).ok()
}
@@ -293,7 +281,6 @@ fn hex_advance(h: &HexGlyph) -> f32 {
/// pixels, copied into the atlas when first drawn.
fn load_bitmap(
root: &Path,
defaults: Option<&Path>,
p: &serde_json::Value,
sheets: &HashMap<String, Rect>,
pixels: &[u8],
@@ -303,36 +290,22 @@ fn load_bitmap(
let mut out = HashMap::new();
let file = p["file"].as_str().unwrap_or("");
let (ns, name) = file.split_once(':').unwrap_or(("minecraft", file));
let name = name.trim_end_matches(".png");
// The atlas names the default namespace's textures by their path and every
// other namespace's as `namespace:path` (HudAtlas::build).
let atlas_name = if ns == "minecraft" {
name.to_owned()
} else {
format!("{ns}:{name}")
};
let in_atlas = sheets.get(&atlas_name).copied();
let in_atlas = (ns == "minecraft")
.then(|| sheets.get(name.trim_end_matches(".png")).copied())
.flatten();
// The sheet: its size, and its pixels when they are not in the atlas.
let (sw, sh, sheet) = match in_atlas {
Some(rect) => {
let (w, h) = rect.texels(atlas);
(w, h, None)
}
None => {
let path = resource_path(file, "textures", "");
let sheet = crate::hud::decode(&root.join(&path)).or_else(|first| {
defaults
.and_then(|d| crate::hud::decode(&d.join(&path)).ok())
.ok_or(first)
});
match sheet {
Ok((w, h, rgba)) => (w, h, Some(Arc::new((w, rgba)))),
Err(_) => {
log::warn!("font sheet {file} could not be read");
return out;
}
None => match crate::hud::decode(&root.join(resource_path(file, "textures", ""))) {
Ok((w, h, rgba)) => (w, h, Some(Arc::new((w, rgba)))),
Err(_) => {
log::warn!("font sheet {file} could not be read");
return out;
}
}
},
};
let rows: Vec<Vec<char>> = p["chars"]
.as_array()
@@ -409,13 +382,11 @@ fn load_bitmap(
}
/// `TrueTypeGlyphProviderDefinition.load`: a TrueType file (not CFF) under
/// `font/`, its Unicode characters but the `skip` ones.
fn load_true_type(root: &Path, defaults: Option<&Path>, p: &serde_json::Value) -> Option<TrueType> {
fn load_true_type(root: &Path, p: &serde_json::Value) -> Option<TrueType> {
let file = p["file"].as_str()?;
let path = resource_path(file, "font", "");
let Some(data) = read_resource(root, defaults, &path) else {
log::warn!("font file {file} could not be read");
return None;
};
let data = std::fs::read(root.join(resource_path(file, "font", "")))
.map_err(|e| log::warn!("font file {file}: {e}"))
.ok()?;
// FT_Get_Font_Format must say TrueType: CFF outlines ("OTTO") are refused.
if data.starts_with(b"OTTO") {
log::warn!("font file {file} is not in TTF format");
@@ -515,16 +486,13 @@ impl TrueType {
)
}
}
fn load_hex(root: &Path, defaults: Option<&Path>, p: &serde_json::Value) -> HashMap<u32, HexGlyph> {
fn load_hex(root: &Path, p: &serde_json::Value) -> HashMap<u32, HexGlyph> {
let mut glyphs = HashMap::new();
let Some(file) = p["hex_file"].as_str() else {
return glyphs;
};
let path = resource_path(file, "", "").replace("//", "/");
let Some(zip) = Zip::open(&root.join(&path))
.ok()
.or_else(|| defaults.and_then(|d| Zip::open(&d.join(&path)).ok()))
else {
let path = root.join(resource_path(file, "", "").replace("//", "/"));
let Ok(zip) = Zip::open(&path) else {
return glyphs;
};
let names: Vec<String> = zip
@@ -635,24 +603,7 @@ impl Font {
region: (u32, u32),
white: Rect,
) -> Self {
Self::load_with(root, None, sprites, pixels, atlas, region, white, false)
}
/// [`Font::load`] over a pack overlay: a sheet, TrueType file or Unihex
/// file the overlay lacks is read from `defaults`, the game's own
/// resources, as vanilla's resource stack does. (Definitions stay the
/// overlay's: a pack's filter may hide one from a lower pack.)
#[allow(clippy::too_many_arguments)]
pub fn load_over(
root: &Path,
defaults: &Path,
sprites: &HashMap<String, Rect>,
pixels: &[u8],
atlas: (u32, u32),
region: (u32, u32),
white: Rect,
) -> Self {
let fallback = (root != defaults).then_some(defaults);
Self::load_with(root, fallback, sprites, pixels, atlas, region, white, false)
Self::load_with(root, sprites, pixels, atlas, region, white, false)
}
/// A server measures text but never draws it. Decode bitmap sheets only
/// to obtain advances, then release their pixels; never rasterize glyphs.
@@ -665,21 +616,10 @@ impl Font {
w: 1,
h: 1,
};
Self::load_with(
root,
None,
&HashMap::new(),
&[],
(1, 1),
(0, 0),
white,
true,
)
Self::load_with(root, &HashMap::new(), &[], (1, 1), (0, 0), white, true)
}
#[allow(clippy::too_many_arguments)]
fn load_with(
root: &Path,
fallback: Option<&Path>,
sprites: &HashMap<String, Rect>,
pixels: &[u8],
atlas: (u32, u32),
@@ -687,7 +627,11 @@ impl Font {
white: Rect,
metrics_only: bool,
) -> Self {
let sheets = sprites.clone();
let sheets = sprites
.iter()
.filter(|(k, _)| k.starts_with("font/"))
.map(|(k, v)| (k.clone(), *v))
.collect();
let font = Self {
sheets,
missing: Glyph {
@@ -701,7 +645,6 @@ impl Font {
},
white,
root: root.to_owned(),
fallback: fallback.map(|p| p.to_owned()),
atlas,
region,
metrics_only,
@@ -713,8 +656,11 @@ impl Font {
pending: Vec::new(),
full: false,
},
// `Font.random`: `RandomSource.create()`.
random: crate::random_source::create(),
random: horizon_core::random::LegacyRandom::new(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as i64),
),
}),
generation: std::cell::Cell::new(next_generation()),
uniform: false,
@@ -752,7 +698,6 @@ impl Font {
Filter::of(p),
load_bitmap(
&self.root,
self.fallback.as_deref(),
p,
&self.sheets,
pixels,
@@ -763,7 +708,7 @@ impl Font {
"unihex" => Some(Provider::Unihex(Filter::of(p), p.clone(), None)),
"ttf" => Some(Provider::TrueType(
Filter::of(p),
Box::new(load_true_type(&self.root, self.fallback.as_deref(), p)?),
Box::new(load_true_type(&self.root, p)?),
)),
_ => None,
})
@@ -923,8 +868,7 @@ impl Font {
}
}
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
let glyphs = glyphs
.get_or_insert_with(|| load_hex(&self.root, self.fallback.as_deref(), def));
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
if let Some(h) = glyphs.get(&u32::from(c)).copied()
&& !(non_fishy && fishy_advance(hex_advance(&h)))
{
@@ -1082,8 +1026,7 @@ impl Font {
}
}
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
let glyphs = glyphs
.get_or_insert_with(|| load_hex(&self.root, self.fallback.as_deref(), def));
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
if let Some(h) = glyphs.get(&u32::from(c)) {
return Some(hex_advance(h));
}
@@ -1569,52 +1512,6 @@ mod tests {
assert!(texels.iter().any(|t| t[0] == 255));
}
/// A bitmap provider's `file` can name any texture. When the atlas has that
/// texture (HUD packs keep their glyph sheets outside `textures/font`), the
/// glyphs are read from it instead of being copied into the small run-time
/// region one by one; the region then cannot fill up and turn later glyphs
/// into the missing one, whose advance shifts the rest of a HUD line.
#[test]
fn a_bitmap_sheet_outside_font_is_read_from_the_atlas() {
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
let (_, _, pixels) = crate::hud::decode_bytes(&sheet).unwrap();
let rect = Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 8,
};
for (ns, file, key) in [
("minecraft", "minecraft:hud/sheet.png", "hud/sheet"),
(
"hudengine",
"hudengine:font/sheet.png",
"hudengine:font/sheet",
),
] {
let def = format!(
r#"{{"providers":[{{"type":"bitmap","file":"{file}","ascent":7,"chars":["ab"]}}]}}"#
);
// The image is only in the atlas: the pack's copy is not on disk.
let root = root(
ns,
&[("assets/minecraft/font/default.json", def.as_bytes())],
);
let sprites = HashMap::from([(key.to_string(), rect)]);
let font = Font::load(&root, &sprites, &pixels, (16, 8), (0, 8), white());
let _ = std::fs::remove_dir_all(&root);
assert_eq!(font.glyph('a').advance, 3.0, "{ns}");
assert_eq!(font.glyph('b').advance, 7.0, "{ns}");
assert!(font.glyph('a').rect.is_some(), "{ns}");
assert!(
font.take_patches().is_empty(),
"{ns}: the atlas sheet is not copied into the run-time region"
);
}
}
#[test]
fn styles_pick_their_font_and_unknown_fonts_draw_missing_glyphs() {
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
File diff suppressed because it is too large. Load diff
+2 -115
View File
@@ -5,9 +5,6 @@
//! (`Arena::translucent_indices`): their back-to-front list then stays in one
//! page of each and draws in a few multi-draws.
use std::collections::{BTreeMap, VecDeque};
#[cfg(test)]
#[path = "arena/source_tests.rs"]
mod source_tests;
/// Ranges are rounded up to this many vertices to limit fragmentation, and
/// start at multiples of it (`flat` names sections by these blocks).
@@ -85,8 +82,6 @@ impl FreeList {
/// One vertex buffer of the arena; only the newest page grows.
struct Page {
buffer: wgpu::Buffer,
/// Optional shader-pack source records, indexed by the vertex index / 4.
sources: Option<wgpu::Buffer>,
/// Capacity in vertices.
capacity: u64,
free: FreeList,
@@ -121,7 +116,6 @@ pub struct Arena {
closing: Option<u32>,
/// A page had to go to system memory (logged once).
spilled: bool,
sources_enabled: bool,
/// Tests: GPU-local pages fail as if video memory were full.
#[cfg(test)]
video_memory_full: bool,
@@ -206,7 +200,6 @@ impl Arena {
usage,
closing: None,
spilled: false,
sources_enabled: false,
#[cfg(test)]
video_memory_full: false,
}
@@ -256,11 +249,7 @@ impl Arena {
}
/// Bytes of every page's buffer.
pub fn bytes(&self) -> u64 {
self.pages
.iter()
.flatten()
.map(|p| p.capacity * self.stride + p.sources.as_ref().map_or(0, wgpu::Buffer::size))
.sum()
self.pages.iter().flatten().map(|p| p.capacity).sum::<u64>() * self.stride
}
/// The most vertices a page holds.
pub fn page_vertices(&self) -> u64 {
@@ -292,65 +281,6 @@ impl Arena {
.expect("live arena page")
.buffer
}
pub fn sources(&self, page: u32) -> Option<&wgpu::Buffer> {
self.pages[page as usize].as_ref()?.sources.as_ref()
}
fn source_buffer(device: &wgpu::Device, capacity: u64) -> Option<wgpu::Buffer> {
if capacity / 4 * 8 > device.limits().max_storage_buffer_binding_size {
return None;
}
try_create(
device,
"Terrain quad sources",
capacity / 4 * 8,
wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
)
}
/// Native meshes allocate no sidecar. Once needed, existing pages also get a zeroed
/// sidecar so moves can retain sources. Failure leaves geometry intact and attributes
/// unknown; a later source upload may retry its page.
pub fn write_sources(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
page: u32,
start: u64,
vertices: u64,
sources: &[crate::mesher::TerrainSource],
) {
if !sources.is_empty() && !self.sources_enabled {
self.sources_enabled = true;
for p in self.pages.iter_mut().flatten() {
p.sources = Self::source_buffer(device, p.capacity);
}
}
let p = self.pages[page as usize].as_mut().expect("live arena page");
if p.sources.is_none() && !sources.is_empty() {
p.sources = Self::source_buffer(device, p.capacity);
}
if let Some(buffer) = &p.sources {
let bytes = vertices / 4 * 8;
if bytes > 0 {
// Recycled ranges must never keep another mesh's identity, even for a
// missing or incomplete sidecar. Zero records have no valid bit.
encoder.clear_buffer(buffer, start / 4 * 8, Some(bytes));
if sources.len() as u64 == vertices / 4 {
uploads.buffer(
encoder,
buffer,
start / 4 * 8,
bytemuck::cast_slice(sources),
);
}
}
}
}
/// Bytes of one vertex (or index).
pub fn stride(&self) -> u64 {
self.stride
@@ -406,10 +336,6 @@ impl Arena {
let start = free.take(len).expect("a new page fits one section");
let page = Page {
buffer,
sources: self
.sources_enabled
.then(|| Self::source_buffer(device, capacity))
.flatten(),
capacity,
free,
host,
@@ -525,10 +451,6 @@ impl Arena {
let free = FreeList::new(capacity);
self.pages.push(Some(Page {
buffer,
sources: self
.sources_enabled
.then(|| Self::source_buffer(device, capacity))
.flatten(),
capacity,
free,
host,
@@ -561,21 +483,6 @@ impl Arena {
to.1 * self.stride,
vertices * self.stride,
);
if let Some(to_sources) = self.sources(to.0) {
let bytes = vertices / 4 * 8;
if bytes > 0 {
match self.sources(from.0) {
Some(from_sources) => encoder.copy_buffer_to_buffer(
from_sources,
from.1 / 4 * 8,
to_sources,
to.1 / 4 * 8,
bytes,
),
None => encoder.clear_buffer(to_sources, to.1 / 4 * 8, Some(bytes)),
}
}
}
}
pub fn release(&mut self, page: u32, start: u64, vertices: u64) {
let len = Self::span(vertices).min(self.page_limit);
@@ -633,17 +540,9 @@ impl Arena {
let Some((buffer, host)) = self.create(device, capacity, spill) else {
return false;
};
let sources = self
.sources_enabled
.then(|| Self::source_buffer(device, capacity))
.flatten();
let old = self.pages[page].as_mut().expect("live arena page");
// Copies recorded for the old buffer run before this one.
encoder.copy_buffer_to_buffer(&old.buffer, 0, &buffer, 0, old.capacity * self.stride);
if let (Some(from), Some(to)) = (&old.sources, &sources) {
encoder.copy_buffer_to_buffer(from, 0, to, 0, from.size());
}
old.sources = sources;
let previous = old.capacity;
old.buffer = buffer;
old.host = host;
@@ -980,7 +879,6 @@ mod tests {
solid,
solid_slots: u64::from(solid) * 4,
packed: false,
packed_far: false,
sides: crate::mesher::Sides::unsorted(solid),
translucent,
sort: super::super::translucency::SortState::new(Vec::new()),
@@ -1149,15 +1047,7 @@ mod tests {
hdr: None,
}
};
let mut flat = flat::Flat::new(
&device,
&terrain_layout,
4 * GRAIN,
0,
false,
pipeline,
solid,
);
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline, solid);
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN);
translucent.page_limit = 4 * GRAIN;
@@ -1193,7 +1083,6 @@ mod tests {
solid: 64,
solid_slots: 64 * 4,
packed: false,
packed_far: false,
sides: crate::mesher::Sides::unsorted(64),
translucent: 128,
sort: super::super::translucency::SortState::new(Vec::new()),
@@ -1421,7 +1310,6 @@ mod tests {
solid,
solid_slots: u64::from(solid) * 4,
packed: false,
packed_far: false,
sides: crate::mesher::Sides::unsorted(solid),
translucent,
sort: super::super::translucency::SortState::new(Vec::new()),
@@ -1548,7 +1436,6 @@ mod tests {
let free = FreeList(BTreeMap::from([(used, capacity - used)]));
let buffer = buffer.clone();
arena.pages = vec![Some(Page {
sources: None,
buffer,
capacity,
free,
@@ -1,145 +0,0 @@
//! Original sidecar lifetime controls, executed explicitly on an offscreen adapter.
use super::*;
use crate::mesher::{TerrainSource, TerrainVertex};
fn read(
device: &wgpu::Device,
queue: &wgpu::Queue,
buffer: &wgpu::Buffer,
offset: u64,
size: u64,
) -> Vec<u8> {
let out = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(buffer, offset, &out, 0, size);
queue.submit([encoder.finish()]);
out.slice(..).map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
out.slice(..).get_mapped_range().unwrap().to_vec()
}
#[test]
#[ignore = "offscreen GPU control; run explicitly with an available adapter"]
fn terrain_source_arena_gpu_grow_move_recycle_and_refusal() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
.expect("GPU control requires an adapter");
eprintln!("TERRAIN_SOURCE_ARENA_ADAPTER {:?}", adapter.get_info());
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let mut arena = Arena::new(&device, 24, GRAIN, true);
arena.page_limit = GRAIN * 2;
let mut encoder = device.create_command_encoder(&Default::default());
let mut uploads = super::super::upload::Uploads::new(&device);
let first = arena.alloc(&device, &mut encoder, 4).unwrap();
assert!(
arena.sources(first.0).is_none(),
"default geometry allocates no source buffer"
);
let geometry = [TerrainVertex::new([2.25, 3.75, 4.5], [0.25, 0.75], 7, 0xff123456, 0x8070); 4];
uploads.buffer(
&mut encoder,
arena.buffer(first.0),
first.1 * 24,
bytemuck::cast_slice(&geometry),
);
let source = TerrainSource::new(71, [2, 3, 4], 13, false);
arena.write_sources(
&device,
&mut encoder,
&mut uploads,
first.0,
first.1,
4,
&[source],
);
let second = arena.alloc(&device, &mut encoder, GRAIN).unwrap();
assert_eq!(second, (first.0, GRAIN), "grew the first page");
let third = arena.alloc(&device, &mut encoder, GRAIN).unwrap();
assert_ne!(third.0, first.0, "move crosses pages");
arena.copy(&mut encoder, first, third, 4);
arena.release(first.0, first.1, 4);
let reused = arena.alloc_existing(4).unwrap();
assert_eq!(reused, first);
arena.write_sources(
&device,
&mut encoder,
&mut uploads,
reused.0,
reused.1,
4,
&[],
);
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
assert_eq!(
read(
&device,
&queue,
arena.sources(third.0).unwrap(),
third.1 / 4 * 8,
8
),
bytemuck::bytes_of(&source)
);
assert_eq!(
read(
&device,
&queue,
arena.sources(first.0).unwrap(),
first.1 / 4 * 8,
8
),
[0; 8]
);
assert_eq!(
read(&device, &queue, arena.buffer(third.0), third.1 * 24, 96),
bytemuck::cast_slice::<_, u8>(&geometry)
);
// A real requested binding limit refuses metadata/table allocation. The native
// vertex allocation still fits; this is a controlled refusal, not a real OOM.
let limits = wgpu::Limits {
max_storage_buffer_binding_size: 256,
..Default::default()
};
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: limits,
..Default::default()
}))
.unwrap();
let mut arena = Arena::translucent(&device, 24, GRAIN);
let mut encoder = device.create_command_encoder(&Default::default());
let mut uploads = super::super::upload::Uploads::new(&device);
let at = arena.alloc(&device, &mut encoder, 4).unwrap();
uploads.buffer(
&mut encoder,
arena.buffer(at.0),
at.1 * 24,
bytemuck::cast_slice(&geometry),
);
arena.write_sources(
&device,
&mut encoder,
&mut uploads,
at.0,
at.1,
4,
&[source],
);
assert!(
arena.sources(at.0).is_none(),
"sidecar refused without losing vertices"
);
assert!(super::super::pack_post::block_ids(&device, &queue, &[0; 128], &[0, 1]).is_none());
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
assert_eq!(
read(&device, &queue, arena.buffer(at.0), at.1 * 24, 96),
bytemuck::cast_slice::<_, u8>(&geometry)
);
}
@@ -64,12 +64,9 @@ impl Copies {
fn copy(mesh: &SectionMesh) -> SectionMesh {
SectionMesh {
solid: mesh.solid.clone(),
solid_sources: mesh.solid_sources.clone(),
sides: mesh.sides,
translucent: mesh.translucent.clone(),
translucent_sources: mesh.translucent_sources.clone(),
packed: mesh.packed.clone(),
packed_far: mesh.packed_far,
solid_tints: Vec::new(),
visibility: mesh.visibility,
block_entities: mesh.block_entities.clone(),
@@ -1,436 +0,0 @@
//! A resource pack's `core/sky` and `core/stars` shaders: the overworld's sky disc and stars drawn
//! with the pack's GLSL (`SkyRenderer.renderSkyDisc`, `renderStars`), as a pack that adds a haze
//! to the horizon or tints the night sky expects. Without one the sky draws natively (`fs_sky` in
//! terrain.wgsl, stars.rs).
use glam::Mat4;
use horizon_shaders::{CoreProgram, Glue};
use wgpu::util::DeviceExt;
/// `DefaultVertexFormat.POSITION`: the disc's and the stars' vertices.
pub(crate) const GLUE: Glue = Glue {
inputs: &[("Position", "vec3")],
prelude: "layout(location = 0) in vec3 hz_position;",
setup: "Position = hz_position;",
// 26.3's ProjMat already has wgpu's reversed 0 to 1 depth (Projection: zZeroToOne).
finish: "",
vertex_id: "int(gl_VertexIndex)",
};
/// Each uniform block's offset in the one buffer (`core_shader::BLOCKS` order).
const BLOCK_OFFSETS: [u64; 4] = [0, 256, 512, 768];
const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48];
/// `SkyRenderer.buildSkyDisc(16)`: a fan around the point 16 above the camera, out to 512 every
/// 45 degrees, as a triangle list.
pub(crate) fn disc_triangles() -> Vec<[f32; 3]> {
let rim: Vec<[f32; 3]> = (-180..=180)
.step_by(45)
.map(|i: i32| {
let a = (i as f32).to_radians();
[512.0 * a.cos(), 16.0, 512.0 * a.sin()]
})
.collect();
rim.windows(2)
.flat_map(|edge| [[0.0, 16.0, 0.0], edge[0], edge[1]])
.collect()
}
/// One of the sky's pack pipelines with its vertices and uniforms.
struct Part {
pipeline: wgpu::RenderPipeline,
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
vertices: wgpu::Buffer,
count: u32,
}
impl Part {
fn new(
device: &wgpu::Device,
format: wgpu::TextureFormat,
program: &CoreProgram,
name: &str,
blend: Option<wgpu::BlendState>,
triangles: &[[f32; 3]],
) -> Option<Self> {
// A pack's shader is untrusted: the backend failing to compile it is an internal error.
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let module = |stage, module: &naga::Module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&format!("Resource pack {name} ({stage})")),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module.clone())),
})
};
let vertex = module("vertex", &program.vertex);
let fragment = module("fragment", &program.fragment);
// Vanilla's SKY and STARS pipelines have no samplers: a pack that reads one is not used.
let entries: Vec<wgpu::BindGroupLayoutEntry> = (0..4)
.map(|binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(BLOCK_SIZES[binding as usize]),
},
count: None,
})
.collect();
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some(name),
entries: &entries,
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some(name),
size: 1024,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let resources: Vec<wgpu::BindGroupEntry> = (0..4)
.map(|i| wgpu::BindGroupEntry {
binding: i as u32,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &uniform,
offset: BLOCK_OFFSETS[i],
size: wgpu::BufferSize::new(BLOCK_SIZES[i]),
}),
})
.collect();
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some(name),
layout: &layout,
entries: &resources,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(name),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(name),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vertex,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3],
})],
},
primitive: wgpu::PrimitiveState::default(),
// The sky is drawn first, behind everything: no depth test, no depth write.
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fragment,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let internal = pollster::block_on(internal.pop());
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
log::warn!("the resource pack's {name} shader is not used: {e}");
return None;
}
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(name),
contents: bytemuck::cast_slice(triangles),
usage: wgpu::BufferUsages::VERTEX,
});
Some(Self {
pipeline,
group,
uniform,
vertices,
count: triangles.len() as u32,
})
}
fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.group, &[]);
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw(0..self.count, 0..1);
}
}
/// The packs' sky disc and stars pipelines, each present when a pack replaces that shader.
pub(super) struct CoreSky {
disc: Option<Part>,
stars: Option<Part>,
}
impl CoreSky {
/// `None` when neither shader is replaced or neither builds.
pub(super) fn new(
device: &wgpu::Device,
format: wgpu::TextureFormat,
disc: Option<&CoreProgram>,
stars: Option<&CoreProgram>,
) -> Option<Self> {
let disc = disc.and_then(|program| {
// RenderPipelines.SKY: ColorTargetState.DEFAULT, no blending.
Part::new(device, format, program, "sky", None, &disc_triangles())
});
let stars = stars.and_then(|program| {
// RenderPipelines.STARS: BlendFunction.OVERLAY (src alpha and one; alpha one, zero).
let overlay = wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::Zero,
operation: wgpu::BlendOperation::Add,
},
};
let triangles = super::stars::star_triangles();
Part::new(device, format, program, "stars", Some(overlay), &triangles)
});
(disc.is_some() || stars.is_some()).then_some(Self { disc, stars })
}
pub(super) fn draws_disc(&self) -> bool {
self.disc.is_some()
}
pub(super) fn draws_stars(&self) -> bool {
self.stars.is_some()
}
/// Uploads the disc's and the stars' blocks: `sky` and `stars` from [`uniforms`].
pub(super) fn prepare(
&self,
uploads: &mut super::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
sky: &[u8],
stars: Option<&[u8]>,
) {
if let Some(disc) = &self.disc {
uploads.buffer(encoder, &disc.uniform, 0, sky);
}
if let (Some(part), Some(data)) = (&self.stars, stars) {
uploads.buffer(encoder, &part.uniform, 0, data);
}
}
/// The disc, in place of the native sky colour. The caller sets its own group 0 again after.
pub(super) fn draw_disc(&self, pass: &mut wgpu::RenderPass<'_>) {
if let Some(disc) = &self.disc {
disc.draw(pass);
}
}
/// The stars, in place of the native ones. The caller sets its own group 0 again after.
pub(super) fn draw_stars(&self, pass: &mut wgpu::RenderPass<'_>) {
if let Some(stars) = &self.stars {
stars.draw(pass);
}
}
}
/// The std140 blocks for one sky draw: `Globals`, `Projection` (`projection`, far enough for the
/// disc's 512-block rim), `DynamicTransforms` (`model_view`: the camera's rotation, times the
/// stars' rotation for them; `color`: the sky colour, or the star brightness in every channel)
/// and `Fog` (`fog_color`, `fog`: environmental start and end, render distance start and end,
/// sky end, clouds end).
pub(super) fn uniforms(
globals: [u8; 48],
projection: Mat4,
model_view: Mat4,
color: [f32; 4],
fog_color: [f32; 4],
fog: [f32; 6],
) -> Vec<u8> {
let mut data = vec![0f32; 256];
data[64..80].copy_from_slice(&projection.to_cols_array());
let transforms = 128;
data[transforms..transforms + 16].copy_from_slice(&model_view.to_cols_array());
data[transforms + 16..transforms + 32].copy_from_slice(&Mat4::IDENTITY.to_cols_array());
data[transforms + 32..transforms + 36].copy_from_slice(&color);
data[192..196].copy_from_slice(&fog_color);
data[196..202].copy_from_slice(&fog);
let mut bytes: Vec<u8> = bytemuck::cast_slice(&data).to_vec();
bytes[..48].copy_from_slice(&globals);
bytes
}
#[cfg(test)]
mod tests {
use super::*;
/// `buildSkyDisc(16)`: eight triangles from the point above the camera to the rim, which
/// starts and ends at x -512 (cos 180°) and passes through z -512, x 512 and z 512.
#[test]
fn the_disc_is_vanillas_fan_as_triangles() {
let triangles = disc_triangles();
assert_eq!(triangles.len(), 24);
assert!(triangles.chunks(3).all(|t| t[0] == [0.0, 16.0, 0.0]));
let rim: Vec<[f32; 3]> = triangles.chunks(3).map(|t| t[1]).collect();
let near = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3);
assert!(near(rim[0], [-512.0, 16.0, 0.0]));
assert!(near(rim[2], [0.0, 16.0, -512.0]));
assert!(near(rim[4], [512.0, 16.0, 0.0]));
assert!(near(rim[6], [0.0, 16.0, 512.0]));
assert!(near(triangles[23], [-512.0, 16.0, 0.0]));
}
/// The prepared resources (`HORIZON_RESOURCES`, or `.local/horizon-client/<version>`), where
/// vanilla's sky shader and its includes are.
fn resources() -> std::path::PathBuf {
std::env::var_os("HORIZON_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
})
}
/// Vanilla's `sky.vsh` with a pack's fragment shader that paints `ColorModulator` (the sky
/// colour) where the disc is: looking at the horizon, the top half of the target is the
/// disc, the bottom half stays clear.
#[test]
fn a_pack_sky_shader_paints_the_disc_above_the_horizon() {
let root = resources();
let Ok(vertex) =
std::fs::read_to_string(root.join("assets/minecraft/shaders/core/sky.vsh"))
else {
eprintln!("skipping: no prepared resources at {}", root.display());
return;
};
let include = |id: &str| {
let (namespace, path) = id.split_once(':')?;
std::fs::read_to_string(root.join(format!("assets/{namespace}/{path}"))).ok()
};
let fragment = "#version 330\n#include <minecraft:dynamictransforms.glsl>\n\
layout(location = 0) out vec4 fragColor;\n\
void main() {\n fragColor = ColorModulator;\n}\n";
let program = horizon_shaders::translate_core(&vertex, fragment, &[], &include, &GLUE)
.expect("the sky shaders translate");
let instance = crate::gfx::test_gpu::instance();
let Some((device, queue)) =
pollster::block_on(instance.request_adapter(&Default::default()))
.ok()
.and_then(|adapter| {
pollster::block_on(adapter.request_device(&Default::default())).ok()
})
else {
eprintln!("no GPU: skipping");
return;
};
const SIZE: u32 = 64;
let format = wgpu::TextureFormat::Rgba8Unorm;
let sky = CoreSky::new(&device, format, Some(&program), None).expect("the sky pipeline");
let projection = crate::gfx::world_projection(90.0, 1.0, 2048.0);
let data = uniforms(
[0; 48],
projection,
Mat4::IDENTITY,
[0.25, 0.5, 1.0, 1.0],
[0.0; 4],
[1e6; 6],
);
queue.write_buffer(&sky.disc.as_ref().unwrap().uniform, 0, &data);
let target = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: SIZE,
height: SIZE,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | usage,
view_formats: &[],
})
};
let color = target(format, wgpu::TextureUsages::COPY_SRC);
let depth = target(
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::empty(),
);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(SIZE * SIZE * 4),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let color_view = color.create_view(&Default::default());
let depth_view = depth.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
sky.draw_disc(&mut pass);
}
encoder.copy_texture_to_buffer(
color.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(SIZE * 4),
rows_per_image: Some(SIZE),
},
},
color.size(),
);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixels = read.slice(..).get_mapped_range().unwrap().to_vec();
let at = |x: usize, y: usize| pixels[(y * 64 + x) * 4..(y * 64 + x) * 4 + 4].to_vec();
// Row 0 is the top: the disc, in the sky colour; the bottom row is below the horizon.
let top = at(32, 4);
assert!(
top.iter()
.zip([64u8, 128, 255, 255])
.all(|(&p, w)| p.abs_diff(w) <= 1),
"{top:?}"
);
assert_eq!(at(32, 60), vec![0, 0, 0, 0]);
}
}
+9 -22
View File
@@ -10,8 +10,8 @@
//! second pass, draws the sections whose box was seen; the others' draws have
//! an instance count of 0.
//!
//! The picture stays the one without culling: the boxes around the mesh's
//! geometry (padded by a block) are drawn front faces
//! The picture stays the one without culling: the boxes (grown by a block,
//! the farthest a block model reaches out of its cell) are drawn front faces
//! only, so a fragment is the box's nearest point on its pixel, and the
//! fragment shader runs only where the depth test passes (forced early), so a
//! section is left out only where all of its fragments would fail the depth
@@ -27,13 +27,11 @@
//! 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 reads them from the draws themselves:
//! Vulkan's native multi-draw, DirectX 12's `ExecuteIndirect`, and Metal's,
//! which wgpu replays as one indirect draw per entry. Translucent draws are
//! not culled.
//! 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]]`), nor can DirectX 12
//! (`[earlydepthstencil]`, which wgpu doesn't offer there), so there the boxes
//! 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
@@ -47,15 +45,6 @@ use std::sync::{
const WORDS: u64 = 5 * 4;
/// A tested section's box, around its origin: its cell grown by a block.
pub const SECTION_BOX: [[f32; 3]; 2] = [[-1.0; 3], [17.0; 3]];
/// Mesh bounds already contain `BOUNDS_PAD`; grow them to a block of padding
/// in total, keeping the box's faces off the geometry. Without mesh bounds,
/// keep the section cell grown by a block, as before.
pub fn section_box(bounds: Option<[glam::Vec3; 2]>) -> [[f32; 3]; 2] {
bounds.map_or(SECTION_BOX, |[low, high]| {
let pad = glam::Vec3::splat(1.0 - crate::mesher::SectionMesh::BOUNDS_PAD);
[(low - pad).to_array(), (high + pad).to_array()]
})
}
/// The bit that marks a far tile's slot (`Renderer::tile_cull_slot`): tiles
/// count their slots apart from the sections' resident ones.
pub const TILE: u32 = 1 << 31;
@@ -226,9 +215,9 @@ impl Cull {
adapter.features() & wgpu::Features::SHADER_EARLY_DEPTH_TEST
}
/// Where the device can run the test, if it can: Vulkan with early
/// depth tests, or Metal and DirectX 12 with the test in the shader, all
/// drawing an indirect multi-draw's entries with the instance counts the
/// GPU wrote, with fragment shaders that write storage.
/// 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 | wgpu::Backend::Gl
@@ -238,9 +227,7 @@ impl Cull {
{
DepthTest::Early
}
// DirectX 12 has no forced early depth test in wgpu either: the test runs in the
// shader there too.
wgpu::Backend::Metal | wgpu::Backend::Dx12 => DepthTest::InShader,
wgpu::Backend::Metal => DepthTest::InShader,
_ => return None,
};
(device
@@ -16,8 +16,8 @@ struct Globals {
// `place`): the first pass's first draw, both passes' draw counts (the
// second's in the high 16 bits), the second pass's first draw. No draws:
// phase 1 drew it, and the test only tells whether it is still in sight.
// Then its box's lowest and highest corner around the origin: a section's
// padded mesh bounds, or -1 and 17 (`SECTION_BOX`) without mesh bounds.
// Then its box's lowest and highest corner around the origin (a section's:
// -1 and 17, `SECTION_BOX`).
struct Test {
data: vec4<u32>,
low: vec4<f32>,
@@ -53,8 +53,8 @@ fn corner(origin: vec3<f32>, low: vec3<f32>, high: vec3<f32>, c: u32) -> vec3<f3
);
}
// Each tested section's or far tile's geometry box, padded by a block,
// drawn after phase 1 against its depth
// Each tested section's or far tile's box, grown by a block (block models
// reach one block out of their cell), drawn after phase 1 against its depth
// with back faces culled: a front face fragment that passes the depth test
// is the box's nearest point on that pixel in front of phase 1's depth, so
// the section may show there. A box failing everywhere would have every
@@ -33,9 +33,6 @@ struct Uniform {
brightness: f32,
mode: u32,
pad: [u32; 2],
hdr_settings: [f32; 4],
hdr_shares: [f32; 4],
hdr_light: [f32; 4],
}
#[repr(C)]
@@ -48,8 +45,6 @@ struct Vertex {
pub(super) struct CustomSky {
pipelines: Vec<wgpu::RenderPipeline>,
written_gain: Option<f32>,
written_debug: bool,
layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
uniform: wgpu::Buffer,
@@ -141,10 +136,7 @@ impl CustomSky {
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Custom sky shader"),
source: wgpu::ShaderSource::Wgsl(
(include_str!("../hdr_light.wgsl").to_owned() + include_str!("custom_sky.wgsl"))
.into(),
),
source: wgpu::ShaderSource::Wgsl(include_str!("custom_sky.wgsl").into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Custom sky layout"),
@@ -154,11 +146,7 @@ impl CustomSky {
let attributes = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32x4];
let pipelines = MODES
.iter()
.copied()
.map(|mode| (mode, false, false))
.chain((format == super::LINEAR_FRAME).then_some((Blend::Screen, true, false)))
.chain((format == super::LINEAR_FRAME).then_some((Blend::Add, false, true)))
.map(|(mode, written_screen, debug_add)| {
.map(|&mode| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Custom sky layer"),
layout: Some(&pipeline_layout),
@@ -186,31 +174,14 @@ impl CustomSky {
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(if debug_add {
"fs_custom_sky_screen_light"
} else {
"fs_custom_sky"
}),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(format == super::LINEAR_FRAME)),
)],
..Default::default()
},
entry_point: Some("fs_custom_sky"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
// The frame's alpha is HDR output's mark (hdr.wgsl): the sky
// under the layers keeps its own.
blend: Some(wgpu::BlendState {
color: if written_screen {
wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Constant,
..blend(mode)
}
} else {
blend(mode)
},
color: blend(mode),
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
@@ -244,8 +215,6 @@ impl CustomSky {
});
Self {
pipelines,
written_gain: None,
written_debug: false,
layout,
sampler,
uniform,
@@ -326,20 +295,7 @@ impl CustomSky {
encoder: &mut wgpu::CommandEncoder,
clip: Mat4,
draws: &[Draw],
globals: &super::Globals,
) {
self.written_gain = (globals.hdr_light[3] != 0.0).then(|| {
let mark =
((1.0 - 0.24 * globals.hdr_light[1]).clamp(0.0, 1.0) * 255.0).round() / 255.0;
if globals.hdr_settings[2] > 0.5 && globals.hdr_settings[0] > 1.0 {
(1.0 + (globals.hdr_settings[1] - 1.0) * (1.0 - mark) / 0.24).powf(1.0 / 2.2)
} else {
1.0
}
});
self.written_debug = self.written_gain.is_some()
&& globals.hdr_settings[3] > 0.5
&& globals.hdr_settings[2] > 0.5;
self.frame.clear();
for draw in draws.iter().take(MAX_DRAWS) {
if usize::from(draw.image) >= self.groups.len() {
@@ -351,9 +307,6 @@ impl CustomSky {
brightness: draw.brightness.clamp(0.0, 1.0),
mode: mode as u32,
pad: [0; 2],
hdr_settings: globals.hdr_settings,
hdr_shares: globals.hdr_shares,
hdr_light: globals.hdr_light,
};
let offset = STRIDE * self.frame.len() as u64;
uploads.buffer(encoder, &self.uniform, offset, bytemuck::bytes_of(&uniform));
@@ -370,35 +323,7 @@ impl CustomSky {
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
pass.set_vertex_buffer(0, self.vertices.slice(..));
for (i, &(image, mode)) in self.frame.iter().enumerate() {
let screen_debug = usize::from(mode) == 5 && self.written_debug;
if screen_debug {
// Greyscale debug is not a uniform RGB gain. Attenuate with the original
// source first, then add its converted light, without changing the factor.
pass.set_pipeline(&self.pipelines[4]);
pass.set_bind_group(
0,
&self.groups[usize::from(image)],
&[(STRIDE * i as u64) as u32],
);
pass.draw(0..36, 0..1);
}
let index = if screen_debug {
10
} else if usize::from(mode) == 5 && self.written_gain.is_some() {
9
} else {
usize::from(mode)
};
if let Some(gain) = self.written_gain {
let gain = f64::from(gain);
pass.set_blend_constant(wgpu::Color {
r: gain,
g: gain,
b: gain,
a: 1.0,
});
}
pass.set_pipeline(&self.pipelines[index]);
pass.set_pipeline(&self.pipelines[usize::from(mode)]);
pass.set_bind_group(
0,
&self.groups[usize::from(image)],
@@ -469,10 +394,7 @@ mod tests {
#[test]
fn the_shader_validates() {
let module = naga::front::wgsl::parse_str(
&(include_str!("../hdr_light.wgsl").to_owned() + include_str!("custom_sky.wgsl")),
)
.unwrap();
let module = naga::front::wgsl::parse_str(include_str!("custom_sky.wgsl")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -491,259 +413,4 @@ mod tests {
assert_eq!(v.position.iter().filter(|c| c.abs() == 1.0).count(), 3);
}
}
#[test]
fn written_custom_sky_modes_keep_factors_and_scale_only_screen_source() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let source = include_str!("../hdr_light.wgsl").to_owned()
+ include_str!("custom_sky.wgsl")
+ r#"
@vertex fn test_vertex(@builtin(vertex_index) i: u32) -> Out {
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
return Out(vec4(p * 2.0 - 1.0, 0.0, 1.0), vec2(0.5), vec4(0.0, 0.0, 1.0, 1.0));
}
"#;
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let sky = CustomSky::new(&device, wgpu::TextureFormat::Rgba16Float);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &[Some(&sky.layout)],
..Default::default()
});
let image = device.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&[204, 102, 51, 128],
);
let image = image.create_view(&Default::default());
let sampler = device.create_sampler(&Default::default());
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba16Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&Default::default());
// Independent scalar expectation: a sky mark of 206/255 gives this gamma gain.
let hdr_gain = (1.0f32 + 3.0 * (1.0 - 206.0 / 255.0) / 0.24).powf(1.0 / 2.2);
for (mode_index, mode, debug, written) in MODES
.iter()
.copied()
.enumerate()
.map(|(i, m)| (i, m, false, true))
.chain([(5, Blend::Screen, true, true)])
.chain(
MODES
.iter()
.copied()
.enumerate()
.map(|(i, m)| (i, m, false, false)),
)
{
let gain = if written { hdr_gain } else { 1.0 };
let uniform = Uniform {
clip_from_sky: Mat4::IDENTITY.to_cols_array_2d(),
brightness: 0.7,
mode: mode_index as u32,
pad: [0; 2],
hdr_settings: [5.0, 4.0, 1.0, f32::from(u8::from(debug))],
hdr_shares: [0.2, 0.5, 0.6, 1.0],
hdr_light: [0.8, 0.8, 1.0, f32::from(u8::from(written))],
};
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::bytes_of(&uniform),
usage: wgpu::BufferUsages::UNIFORM,
});
let color = if debug {
blend(Blend::Add)
} else if mode == Blend::Screen {
wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Constant,
..blend(mode)
}
} else {
blend(mode)
};
let pipeline_for = |entry, color| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("test_vertex"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[("HDR_WRITE_LIGHT", 1.0)],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba16Float,
blend: Some(wgpu::BlendState {
color,
alpha: wgpu::BlendComponent::REPLACE,
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
let pipeline = pipeline_for(
if debug {
"fs_custom_sky_screen_light"
} else {
"fs_custom_sky"
},
color,
);
let attenuate = debug.then(|| pipeline_for("fs_custom_sky", blend(Blend::Burn)));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &sky.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&image),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 2.0,
g: 2.0,
b: 2.0,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
let g = f64::from(gain);
pass.set_blend_constant(wgpu::Color {
r: g,
g,
b: g,
a: 1.0,
});
pass.set_bind_group(0, &group, &[0]);
if let Some(attenuate) = &attenuate {
pass.set_pipeline(attenuate);
pass.draw(0..3, 0..1);
}
pass.set_pipeline(&pipeline);
pass.draw(0..3, 0..1);
}
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
target.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
target.size(),
);
queue.submit([encoder.finish()]);
readback.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = readback.get_mapped_range(..).unwrap();
let got = half::f16::from_le_bytes([data[0], data[1]]).to_f32();
let (d, t, k) = (2.0, 0.8, 128.0 / 255.0 * 0.7);
let alpha = half::f16::from_le_bytes([data[6], data[7]]).to_f32();
let coverage = if mode == Blend::Alpha { k } else { 1.0 };
assert!(
(alpha - coverage).abs() < 0.001,
"{mode:?}: alpha {alpha}, expected {coverage}"
);
let expected = if debug {
let grey = (0.2126f32 * 0.8f32.powf(2.2)
+ 0.7152 * 0.4f32.powf(2.2)
+ 0.0722 * 0.2f32.powf(2.2))
.powf(1.0 / 2.2);
gain * grey * k + d * (1.0 - t * k)
} else {
match mode {
Blend::Add => d + gain * t * k,
Blend::Subtract => d - gain * t * k,
Blend::Multiply => d * (1.0 + (t - 1.0) * k),
Blend::Dodge => d + d * t * k,
Blend::Burn => d * (1.0 - t * k),
Blend::Screen => gain * t * k + d * (1.0 - t * k),
Blend::Replace => gain * t,
Blend::Overlay => 2.0 * d * (0.5 + (t - 0.5) * k),
Blend::Alpha => gain * t * k + d * (1.0 - k),
}
};
assert!(
(got - expected).abs() < 0.004,
"{mode:?} debug {debug}: {got} expected {expected}"
);
}
eprintln!(
"HDR custom sky GPU ran all nine modes with and without gain, plus debug Screen, checking RGB and coverage"
);
}
}
@@ -8,10 +8,6 @@ struct Layer {
brightness: f32,
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
mode: u32,
pad: vec2<u32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
hdr_light: vec4<f32>,
}
@group(0) @binding(0) var<uniform> layer: Layer;
@group(0) @binding(1) var sky_texture: texture_2d<f32>;
@@ -46,34 +42,20 @@ const REPLACE: u32 = 6u;
const OVERLAY: u32 = 7u;
const ALPHA: u32 = 8u;
fn custom_sky_color(in: Out) -> vec4<f32> {
@fragment fn fs_custom_sky(in: Out) -> @location(0) vec4<f32> {
let half = 0.5 / vec2<f32>(textureDimensions(sky_texture));
let uv = clamp(in.uv, in.cell.xy + half, in.cell.zw - half);
let t = textureSample(sky_texture, sky_sampler, uv);
let k = t.a * layer.brightness;
var color = t.rgb;
if HDR_WRITE_LIGHT && layer.hdr_light.w != 0.0 &&
(layer.mode == ADD || layer.mode == SUBTRACT || layer.mode == REPLACE || layer.mode == ALPHA) {
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares);
}
switch layer.mode {
// dst * src: white leaves the sky alone.
case MULTIPLY: { return vec4(mix(vec3(1.0), t.rgb, k), 1.0); }
// 2 * dst * src: grey leaves it alone.
case OVERLAY: { return vec4(mix(vec3(0.5), t.rgb, k), 1.0); }
case REPLACE: { return vec4(color, 1.0); }
case REPLACE: { return vec4(t.rgb, 1.0); }
// mix(dst, src, alpha).
case ALPHA: { return vec4(color, k); }
case ALPHA: { return vec4(t.rgb, k); }
// add, subtract, dodge, burn, screen: the layer premultiplied, black leaves it alone.
default: { return vec4(color * k, 1.0); }
default: { return vec4(t.rgb * k, 1.0); }
}
}
@fragment fn fs_custom_sky(in: Out) -> @location(0) vec4<f32> {
return custom_sky_color(in);
}
@fragment fn fs_custom_sky_screen_light(in: Out) -> @location(0) vec4<f32> {
let color = custom_sky_color(in);
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares), color.a);
}
@@ -12,7 +12,7 @@ use crate::mesher::TerrainVertex;
/// The solid quads the grid can draw a frame, and the sections they come from.
const MAX_QUADS: u64 = 1 << 16;
pub(crate) const MAX_SECTIONS: usize = 64;
const MAX_SECTIONS: usize = 64;
/// Bytes of one quad's corners.
const QUAD_BYTES: u64 = 4 * std::mem::size_of::<TerrainVertex>() as u64;
/// How far past the flat face the relief may reach, in blocks, for the view test.
@@ -413,8 +413,6 @@ pub struct Displacement {
pub shadow: Option<Picks>,
/// Group 1's layout for the grid's pipelines.
pub draw_layout: wgpu::BindGroupLayout,
/// CPU-side section table reused between displacement preparation passes.
table: Vec<Section>,
/// Sections the pick ran over this frame (0: nothing to draw).
pub drawn: usize,
}
@@ -600,7 +598,6 @@ impl Displacement {
view,
shadow,
draw_layout,
table: Vec::new(),
drawn: 0,
}
}
@@ -613,8 +610,7 @@ impl Displacement {
near: &[Near<'_>],
view_proj: glam::Mat4,
) {
self.table.clear();
self.table.reserve(near.len().min(MAX_SECTIONS));
let mut table = Vec::with_capacity(near.len().min(MAX_SECTIONS));
let mut first = 0u64;
let mut most = 0u32;
for n in near.iter().take(MAX_SECTIONS) {
@@ -630,7 +626,7 @@ impl Displacement {
first * QUAD_BYTES,
quads * QUAD_BYTES,
);
self.table.push(Section {
table.push(Section {
origin: [
n.origin[0],
n.origin[1],
@@ -644,11 +640,11 @@ impl Displacement {
first += quads;
most = most.max(quads as u32);
}
self.drawn = self.table.len();
if self.table.is_empty() {
self.drawn = table.len();
if table.is_empty() {
return;
}
queue.write_buffer(&self.sections, 0, bytemuck::cast_slice(&self.table));
queue.write_buffer(&self.sections, 0, bytemuck::cast_slice(&table));
let lists = std::iter::once((&self.view, 0)).chain(self.shadow.iter().map(|s| (s, 1)));
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Displacement pick"),
@@ -659,7 +655,7 @@ impl Displacement {
let params = Params {
view_proj: view_proj.to_cols_array_2d(),
distance: self.distance,
sections: self.table.len() as u32,
sections: table.len() as u32,
margin: RELIEF_MARGIN,
capacity: MAX_QUADS as u32,
shadow,
@@ -669,7 +665,7 @@ impl Displacement {
let vertices = grid_vertices(picks.cells, picks.steps);
queue.write_buffer(&picks.draw, 0, bytemuck::cast_slice(&[vertices, 0, 0, 0]));
pass.set_bind_group(0, &picks.pick_group, &[]);
pass.dispatch_workgroups(most.div_ceil(64), self.table.len() as u32, 1);
pass.dispatch_workgroups(most.div_ceil(64), table.len() as u32, 1);
}
}
@@ -1,132 +0,0 @@
//! Which compiler turns DirectX 12's shaders into bytecode: DXC on integrated
//! GPUs, FXC on every other adapter (#8513).
//!
//! Measured on one laptop at render distance 32 (2560x1440, the same world and
//! view): DXC took Intel Iris Xe from 44 to 65 FPS (22.1 to 14.7 ms of GPU
//! time) and an NVIDIA RTX 3050 Ti from 159 down to 150 FPS. An RX 9070 gained
//! about 9 % of GPU time at render distance 32 and lost 0.03 ms at 8. So
//! discrete GPUs keep FXC (`d3dcompiler_47.dll`, part of Windows), and only
//! integrated ones load DXC (`dxcompiler.dll`, which the Windows x64 zip puts
//! beside the game: `tools/ci/fetch_dxc.py`).
//!
//! wgpu chooses the compiler for a whole instance, before any adapter is
//! known. `open_backend` therefore asks for the adapter on an FXC instance and,
//! when that adapter is an integrated GPU on DirectX 12, asks again on an
//! instance with DXC. That happens once, when the device is opened; nothing
//! here runs per frame. Without the DLL, or when it does not load, the game
//! keeps the FXC instance and logs one line. `WGPU_DX12_COMPILER` (`fxc`,
//! `dxc`, `auto`) overrides the choice: wgpu applies it to every instance.
use std::path::PathBuf;
/// The DLL's name, as the Windows zip ships it beside `horizon-client.exe`.
pub(super) const DLL: &str = "dxcompiler.dll";
/// Whether `info`, opened on an FXC instance, should be opened again with DXC:
/// an integrated GPU on DirectX 12, unless `forced` (`WGPU_DX12_COMPILER` is
/// set, so its compiler is already the one every instance uses).
pub(super) fn wants_dxc(info: &wgpu::AdapterInfo, forced: bool) -> bool {
!forced
&& info.backend == wgpu::Backend::Dx12
&& info.device_type == wgpu::DeviceType::IntegratedGpu
}
/// Whether `WGPU_DX12_COMPILER` names the compiler.
pub(super) fn forced() -> bool {
std::env::var_os("WGPU_DX12_COMPILER").is_some()
}
/// `dxcompiler.dll` beside the running executable, if it is there. An absolute
/// path, so a copy elsewhere on `PATH` (an older Windows SDK's) is never the
/// one loaded.
pub(super) fn bundled_dll() -> Option<PathBuf> {
let dll = std::env::current_exe().ok()?.parent()?.join(DLL);
dll.is_file().then_some(dll)
}
/// The compiler setting that loads `dll`, and fails the instance (rather
/// than falling back to FXC on its own) when it doesn't load: `open_backend`
/// then keeps the FXC instance it already has.
pub(super) fn dxc(dll: &std::path::Path) -> wgpu::Dx12Compiler {
wgpu::Dx12Compiler::DynamicDxc {
dxc_path: dll.to_string_lossy().into_owned(),
}
}
/// Whether the DXC instance offered the adapter the FXC instance had: on a
/// laptop with two GPUs, the same request should pick the same one, and the
/// game must not switch GPUs because the compiler changed.
pub(super) fn same_adapter(fxc: &wgpu::AdapterInfo, dxc: &wgpu::AdapterInfo) -> bool {
fxc.backend == dxc.backend
&& fxc.vendor == dxc.vendor
&& fxc.device == dxc.device
&& fxc.device_type == dxc.device_type
&& fxc.device_pci_bus_id == dxc.device_pci_bus_id
}
#[cfg(test)]
mod tests {
use super::*;
fn adapter(backend: wgpu::Backend, device_type: wgpu::DeviceType) -> wgpu::AdapterInfo {
wgpu::AdapterInfo {
name: "Intel(R) Iris(R) Xe Graphics".into(),
vendor: 0x8086,
device: 0x9a49,
device_type,
device_pci_bus_id: "0000:00:02.0".into(),
driver: String::new(),
driver_info: String::new(),
backend,
subgroup_min_size: 0,
subgroup_max_size: 0,
transient_saves_memory: None,
limit_bucket: None,
}
}
#[test]
fn only_integrated_gpus_on_directx_12_get_dxc() {
use wgpu::{Backend, DeviceType};
assert!(wants_dxc(
&adapter(Backend::Dx12, DeviceType::IntegratedGpu),
false
));
for kind in [
DeviceType::DiscreteGpu,
DeviceType::VirtualGpu,
DeviceType::Cpu,
DeviceType::Other,
] {
assert!(!wants_dxc(&adapter(Backend::Dx12, kind), false), "{kind:?}");
}
// Other backends have no shader compiler to choose.
for backend in [Backend::Vulkan, Backend::Gl, Backend::Metal] {
assert!(
!wants_dxc(&adapter(backend, DeviceType::IntegratedGpu), false),
"{backend:?}"
);
}
}
#[test]
fn wgpu_dx12_compiler_overrides_the_choice() {
let igpu = adapter(wgpu::Backend::Dx12, wgpu::DeviceType::IntegratedGpu);
assert!(!wants_dxc(&igpu, true));
}
#[test]
fn the_dxc_instance_must_offer_the_same_gpu() {
let igpu = adapter(wgpu::Backend::Dx12, wgpu::DeviceType::IntegratedGpu);
assert!(same_adapter(&igpu, &igpu.clone()));
let dgpu = wgpu::AdapterInfo {
name: "NVIDIA GeForce RTX 3050 Ti Laptop GPU".into(),
vendor: 0x10de,
device: 0x25a0,
device_type: wgpu::DeviceType::DiscreteGpu,
device_pci_bus_id: "0000:01:00.0".into(),
..igpu.clone()
};
assert!(!same_adapter(&igpu, &dgpu));
}
}
@@ -20,10 +20,6 @@ const MATERIALS: usize = 16;
pub const TERRAIN_MODEL_SOLID_OVERLAY: u32 = 42;
pub const TERRAIN_MODEL_CUTOUT_OVERLAY: u32 = 43;
pub const TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY: u32 = 44;
/// The overlay of a resource pack's Polytone custom particle drawn additively
/// (`additive_translucent`): fog tints its colour as vanilla's particle shader does, which the
/// other additive geometry does not take.
pub const ADDITIVE_PARTICLE_OVERLAY: u32 = 45;
/// Native terrain-array UV/payloads must never enter a shared-atlas shader-pack pass.
pub fn is_native_terrain(layer: u32) -> bool {
matches!(
@@ -36,7 +36,7 @@ pub(super) struct EntityOutline {
impl EntityOutline {
/// `entity` draws the silhouettes with the entity shader's vertex stage and
/// `fs_outline`; `format` is what it draws onto (`Renderer::overlay_format`).
/// `fs_outline`; `format` is the frame's.
pub(super) fn new(
device: &wgpu::Device,
entity: (
+4 -55
View File
@@ -19,7 +19,6 @@
//! start, and the run is drawn indirectly with the count the copy reached.
use super::SolidPipeline;
use super::arena::GRAIN;
use smallvec::SmallVec;
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
/// `QUADS` for a solid draw; phase 2: its entry in phase 2's draws), base
@@ -107,8 +106,6 @@ pub struct Flat {
phase2_calls: wgpu::Buffer,
phase2_call_capacity: u64,
phase2_call_data: Vec<[u32; 5]>,
/// GPU culling's phase 2 uses indirect draws; flat draws without culling do not.
indirect_execution: bool,
/// The copy of phase 2's draws, and its ranges.
phase2_group: Option<(wgpu::BindGroup, u32)>,
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
@@ -175,22 +172,11 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
/// Whether frames without a shader pack's terrain draw flat: where the backend
/// emulates multi-draw (Metal, past `MIN_SECTIONS`), on Intel Gen11/Gen12's Vulkan
/// driver, on Qualcomm's Adreno under DirectX 12, or with `HORIZON_FLAT_DRAWS=1`.
/// Elsewhere per-section draws stay.
/// driver, or with `HORIZON_FLAT_DRAWS=1`. Elsewhere per-section draws stay.
pub fn for_vanilla(info: &wgpu::AdapterInfo) -> bool {
std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
|| info.backend == wgpu::Backend::Metal
|| is_broken_intel_vulkan(info)
|| is_adreno_dx12(info)
}
/// Qualcomm's Adreno under DirectX 12 (Windows on Snapdragon, where Auto picks DirectX 12),
/// where wgpu's indirect multi-draw is `ExecuteIndirect`: drawn flat, an X1-45 took 3-11%
/// less GPU time a frame at render distance 12 and 3-13% less at 32 (perf_bench's four
/// scenes, held still), from 389 sections in view up, the same picture. DXGI names
/// Qualcomm `QCOM` (0x4D4F4351); Vulkan, 0x5143.
pub fn is_adreno_dx12(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Dx12 && matches!(info.vendor, 0x4d4f_4351 | 0x5143)
}
/// Intel Gen11 (Ice Lake) and Gen12 (Tiger Lake, Rocket Lake, Alder Lake,
@@ -379,7 +365,6 @@ impl Flat {
terrain_layout: &wgpu::BindGroupLayout,
page_vertices: u64,
min_sections: usize,
indirect_execution: bool,
pipeline: impl Fn(
&wgpu::PipelineLayout,
&str,
@@ -504,16 +489,10 @@ impl Flat {
device,
"Flat section draw calls, phase 2",
20,
storage_usage
| if indirect_execution {
wgpu::BufferUsages::INDIRECT
} else {
wgpu::BufferUsages::empty()
},
wgpu::BufferUsages::INDIRECT | storage_usage,
),
phase2_call_capacity: 1,
phase2_call_data: Vec::new(),
indirect_execution,
phase2_group: None,
draw_layout,
blocks_per_page: page_vertices.div_ceil(GRAIN),
@@ -666,7 +645,6 @@ impl Flat {
timing: Option<&mut super::pack_timing::PassTiming>,
) -> bool {
self.phase2_group = None;
let phase2 = phase2.filter(|_| self.indirect_execution);
let phase1_draws = if phase2.is_some() {
phase1_draws
} else {
@@ -775,12 +753,7 @@ impl Flat {
device,
"Flat section draw calls, phase 2",
self.phase2_call_capacity * 20,
wgpu::BufferUsages::STORAGE
| if self.indirect_execution {
wgpu::BufferUsages::INDIRECT
} else {
wgpu::BufferUsages::empty()
},
wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::STORAGE,
);
}
if phase2_calls > 0 {
@@ -826,7 +799,7 @@ impl Flat {
});
self.phase2_group = Some((group, count as u32));
}
let groups: SmallVec<[(wgpu::BindGroup, u32); 8]> = self
let groups: Vec<(wgpu::BindGroup, u32)> = self
.segments
.iter()
.filter_map(|&(source, first, count)| {
@@ -1056,30 +1029,6 @@ impl Flat {
mod tests {
use super::*;
#[test]
fn adreno_draws_flat_under_dx12_only() {
let mut info = wgpu::AdapterInfo {
name: "Qualcomm(R) Adreno(TM) X1-45 GPU".into(),
vendor: 0x4d4f_4351,
device: 0,
device_type: wgpu::DeviceType::IntegratedGpu,
device_pci_bus_id: String::new(),
driver: "31.0.128.0".into(),
driver_info: String::new(),
backend: wgpu::Backend::Dx12,
subgroup_min_size: 0,
subgroup_max_size: 0,
transient_saves_memory: None,
limit_bucket: None,
};
assert!(is_adreno_dx12(&info));
info.backend = wgpu::Backend::Vulkan;
assert!(!is_adreno_dx12(&info));
info.backend = wgpu::Backend::Dx12;
info.vendor = 0x10de;
assert!(!is_adreno_dx12(&info));
}
/// Draws as `render` writes them: solid (page, quads at a base vertex)
/// first, then translucent (page, sorted page, count, first sorted index,
/// base vertex).
@@ -2735,9 +2735,8 @@ mod tests {
/// `test` on each device here that draws counts with first instances:
/// Vulkan, and DirectX 12 with each of its compilers. Players' DirectX 12
/// compiles with FXC, part of Windows, and with DXC (`dxcompiler.dll`,
/// beside the game in the Windows x64 zip) on integrated GPUs
/// (`gfx/dx12_compiler.rs`).
/// compiles with FXC, part of Windows: wgpu takes DXC only where
/// `dxcompiler.dll` loads, and the game ships none.
fn on_each_device(test: impl Fn(&wgpu::Device, &Queue)) {
let mut tested = false;
for (backends, compiler, name) in [
File diff suppressed because it is too large. Load diff
@@ -1,65 +0,0 @@
// The light of colour `f` under the world's mark `a` (the alpha the world left, hdr_mark) where
// the world `shows`, else the colour clipped at white; and whether a light source lifted it.
// `eased` for the last pass, which eases it into the peak itself; the layered frame leaves that
// to the tone curve (fs_tonemap).
struct ReferenceLit {
light: vec3<f32>,
lifted: bool,
};
fn reference_lit(f: vec3<f32>, a: f32, shows: bool, eased: bool) -> ReferenceLit {
var lifted = false;
// Light over 1 (a float frame's) shows only where the world does and carries
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
// at white before their lift, as on a unorm frame, so light sources, the sun and
// HDR lighting look as they do there; so do the HUD, menus and HDR Lighting off.
let c = select(min(f, vec3(1.0)), f, shows && a == 1.0);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(c, vec3(2.2));
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
// peak from 60% of it, so bright textures keep their detail. Unmarked light (alpha 1)
// gets no gain and no knee: the clamp to the peak below is all it needs here.
if shows && a >= 0.75 && a < 1.0 {
let top = max(light.r, max(light.g, light.b));
let gain = 1.0 + (params.scene - 1.0) * (1.0 - a) / 0.24;
let v = top * gain;
let knee = max(0.6 * params.peak, 1.0);
var out = v;
if eased && v > knee && params.peak > knee {
out = knee + (params.peak - knee) * (1.0 - exp(-(v - knee) / (params.peak - knee)));
}
light *= select(gain, out / max(top, 1e-4), eased && v > knee);
}
// The world's alpha is below 0.75 on light sources, the sun and the moon
// (terrain.wgsl, entity.wgsl): its band of 0.175 is the tier, the lower the
// brighter, and where in the band how much (hdr_mark).
if shows && a < 0.75 {
let band = u32(clamp(floor((a + 0.005) / 0.175), 0.0, 3.0));
let tier = 3u - band;
let lift = 1.0 - clamp((a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
if lift > 0.0 {
let top = max(light.r, max(light.g, light.b));
var gain = 1.0 + (params.peak - 1.0) * params.share[tier] * lift;
if eased {
gain = min(gain, params.peak / max(top, 1e-4));
}
light *= max(gain, 1.0);
lifted = true;
}
}
// HORIZON_HDR_DEBUG=1: what isn't lifted turns grey, what is keeps its
// colour with a magenta tint, so a lift shows on any colour.
if params.debug > 0.5 && params.world > 0.5 {
if lifted {
light *= vec3(1.0, 0.6, 1.0);
} else {
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
}
}
if eased {
light = min(light, vec3(params.peak));
}
return ReferenceLit(light, lifted);
}
File diff suppressed because it is too large. Load diff
@@ -1,335 +0,0 @@
//! A held world (`WorldHold`, the native hook `native_world_hold`): the terrain goes on being
//! drawn as it stands while what changes under it is meshed, and all of it takes its place on
//! one frame. A section already on screen that is meshed again is uploaded to the arena as ever,
//! over as many frames as its mesh takes to come, but waits beside the mesh drawn (`Hold`);
//! letting go only swaps which of the two is drawn, with nothing left to upload. Sections not on
//! screen yet (terrain streaming in) are not held.
//!
//! Both meshes of a waiting section are in memory, so a hold costs up to what the changed land's
//! new meshes come to. It is bounded, and each bound lets go at once rather than losing anything:
//! `MAX_BYTES` of waiting meshes, `MAX_HOLD` of time, and the arena running out
//! (`Renderer::upload`). It never outlives what asked for it: the mods are asked every frame,
//! and the sections' being cleared (the world left, another dimension) drops what waits. A hold
//! that ended by itself (spent) stays ended until no mod has asked for a frame.
use super::SectionKey;
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::time::{Duration, Instant};
/// The longest a hold lasts.
pub(super) const MAX_HOLD: Duration = Duration::from_secs(30);
/// The most bytes of new meshes that wait.
pub(super) const MAX_BYTES: u64 = 1 << 30;
/// The most circles a hold is kept to (`WorldHold::hold_within`).
pub const MAX_AREAS: usize = 64;
/// What the mods ask of the terrain's picture this frame (`native_world_hold`): nothing, all of
/// it held, or only the sections inside some circles. Several mods add up: one holding all of it
/// holds all of it, and circles join.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct WorldHold {
all: bool,
within: Vec<[f64; 3]>,
}
impl WorldHold {
/// Hold the whole world's terrain as it is drawn, this frame.
pub fn hold(&mut self) {
self.all = true;
}
/// Hold the sections whose middle lies within `radius` blocks of (`x`, `z`), this frame. A
/// circle that isn't finite or has a negative radius is left out, as are those past
/// `MAX_AREAS`.
pub fn hold_within(&mut self, x: f64, z: f64, radius: f64) {
let good = x.is_finite() && z.is_finite() && radius.is_finite() && radius >= 0.0;
if good && self.within.len() < MAX_AREAS {
self.within.push([x, z, radius]);
}
}
/// Whether anything is held.
pub fn is_held(&self) -> bool {
self.all || !self.within.is_empty()
}
/// Whether section `key` is in what is held: all of the world, or a circle its middle lies in.
pub fn covers(&self, key: SectionKey) -> bool {
if self.all {
return true;
}
let (x, z) = (f64::from(key.0) * 16.0 + 8.0, f64::from(key.2) * 16.0 + 8.0);
self.within
.iter()
.any(|&[cx, cz, r]| (x - cx).powi(2) + (z - cz).powi(2) <= r * r)
}
}
/// What a frame's ask means for the sections waiting.
#[derive(Debug, PartialEq, Eq)]
pub(super) enum Release {
/// They go on waiting.
None,
/// Those the hold no longer covers take their place.
Outside,
/// All of them take their place.
All,
}
/// A section waiting behind the hold and the bytes its new mesh takes.
struct Waiting<S> {
section: S,
bytes: u64,
}
/// The renderer's side of a hold: whether it is on, what it covers, and the sections waiting.
pub(super) struct Hold<S> {
ask: WorldHold,
since: Option<Instant>,
spent: bool,
waiting: FxHashMap<SectionKey, Waiting<S>>,
bytes: u64,
}
impl<S> Default for Hold<S> {
fn default() -> Self {
Self {
ask: WorldHold::default(),
since: None,
spent: false,
waiting: FxHashMap::default(),
bytes: 0,
}
}
}
impl<S> Hold<S> {
/// Whether a new mesh of section `key` waits.
pub fn holds(&self, key: SectionKey) -> bool {
self.since.is_some() && !self.spent && self.ask.covers(key)
}
pub fn is_empty(&self) -> bool {
self.waiting.is_empty()
}
pub fn bytes(&self) -> u64 {
self.bytes
}
/// A frame's ask, at `now`.
pub fn ask(&mut self, ask: &WorldHold, now: Instant) -> Release {
if !ask.is_held() {
let was = self.since.is_some();
(self.since, self.spent) = (None, false);
self.ask = WorldHold::default();
return if was && !self.waiting.is_empty() {
Release::All
} else {
Release::None
};
}
if self.spent {
return Release::None;
}
let since = *self.since.get_or_insert(now);
if now.saturating_duration_since(since) >= MAX_HOLD {
log::warn!(
"Horizon: a mod held the world for {} s: let go",
MAX_HOLD.as_secs()
);
self.spend();
return Release::All;
}
if *ask == self.ask {
return Release::None;
}
// (fewer circles or smaller: some of what waits is let go; more: nothing waits that didn't)
self.ask.clone_from(ask);
if self.waiting.is_empty() {
Release::None
} else {
Release::Outside
}
}
/// Ended by itself: nothing waits from now on, until no mod has asked for a frame.
/// What waited is the caller's to place (`take_all`) or drop.
pub fn spend(&mut self) {
self.spent = self.since.is_some();
}
/// Section `key`'s new mesh waits: the one that waited before for it comes back, to free.
/// The bool is whether what waits is now too much (`MAX_BYTES`).
pub fn wait(&mut self, key: SectionKey, section: S, bytes: u64) -> (Option<S>, bool) {
let old = self.take(key);
self.bytes += bytes;
self.waiting.insert(key, Waiting { section, bytes });
(old, self.bytes > MAX_BYTES)
}
/// Section `key`'s waiting mesh, no longer waiting.
pub fn take(&mut self, key: SectionKey) -> Option<S> {
if self.waiting.is_empty() {
return None;
}
let w = self.waiting.remove(&key)?;
self.bytes -= w.bytes;
Some(w.section)
}
/// Everything waiting, no longer waiting.
pub fn take_all(&mut self) -> Vec<(SectionKey, S)> {
self.bytes = 0;
self.waiting.drain().map(|(k, w)| (k, w.section)).collect()
}
/// What waits that the hold no longer covers.
pub fn take_outside(&mut self) -> Vec<(SectionKey, S)> {
let ask = &self.ask;
let all = self.since.is_none() || self.spent;
let out: Vec<_> = self
.waiting
.extract_if(|&k, _| all || !ask.covers(k))
.collect();
self.bytes -= out.iter().map(|(_, w)| w.bytes).sum::<u64>();
out.into_iter().map(|(k, w)| (k, w.section)).collect()
}
/// What waits in these columns, which are going.
pub fn take_columns(&mut self, columns: &FxHashSet<(i32, i32)>) -> Vec<S> {
if self.waiting.is_empty() {
return Vec::new();
}
let out: Vec<_> = self
.waiting
.extract_if(|k, _| columns.contains(&(k.0, k.2)))
.collect();
self.bytes -= out.iter().map(|(_, w)| w.bytes).sum::<u64>();
out.into_iter().map(|(_, w)| w.section).collect()
}
/// The sections are all gone: what waits goes with them and the hold is spent.
pub fn clear(&mut self) {
self.waiting.clear();
self.bytes = 0;
self.spend();
}
pub fn sections(&self) -> impl Iterator<Item = &S> {
self.waiting.values().map(|w| &w.section)
}
pub fn sections_mut(&mut self) -> impl Iterator<Item = &mut S> {
self.waiting.values_mut().map(|w| &mut w.section)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn all() -> WorldHold {
let mut h = WorldHold::default();
h.hold();
h
}
fn within(circles: &[[f64; 3]]) -> WorldHold {
let mut h = WorldHold::default();
for &[x, z, r] in circles {
h.hold_within(x, z, r);
}
h
}
#[test]
fn a_hold_covers_sections_by_their_middle() {
assert!(all().covers((900, 0, -900)));
assert!(!WorldHold::default().covers((0, 0, 0)));
assert!(within(&[[8.0, 8.0, 1.0]]).covers((0, 3, 0)));
assert!(!within(&[[0.0, 0.0, 11.0]]).covers((0, 3, 0)));
assert!(within(&[[0.0, 0.0, 12.0]]).covers((0, 3, 0)));
assert!(within(&[[1000.0, 0.0, 1.0], [24.0, 8.0, 0.5]]).covers((1, 0, 0)));
assert!(within(&[[-8.0, -8.0, 0.0]]).covers((-1, 0, -1)));
}
#[test]
fn bad_circles_are_left_out_and_there_are_at_most_max_areas() {
let h = within(&[
[f64::NAN, 0.0, 5.0],
[0.0, f64::INFINITY, 5.0],
[0.0, 0.0, -1.0],
[0.0, 0.0, f64::INFINITY],
]);
assert!(!h.is_held());
let mut many = WorldHold::default();
for i in 0..MAX_AREAS + 10 {
many.hold_within(i as f64 * 100.0, 0.0, 1.0);
}
assert_eq!(many.within.len(), MAX_AREAS);
// (several mods: all of it from one holds all of it)
many.hold();
assert!(many.covers((-5000, 0, 5000)));
}
#[test]
fn a_hold_starts_and_lets_go_when_no_one_asks() {
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
assert_eq!(g.ask(&WorldHold::default(), t), Release::None);
assert!(!g.holds((0, 0, 0)));
assert_eq!(g.ask(&all(), t), Release::None);
assert!(g.holds((0, 0, 0)));
assert_eq!(g.ask(&all(), t + Duration::from_secs(5)), Release::None);
// (nothing waited: nothing to place)
assert_eq!(g.ask(&WorldHold::default(), t), Release::None);
g.ask(&all(), t);
g.wait((0, 0, 0), 7, 100);
assert_eq!(g.ask(&WorldHold::default(), t), Release::All);
assert_eq!(g.take_all(), vec![((0, 0, 0), 7)]);
assert!(!g.holds((0, 0, 0)));
}
#[test]
fn a_hold_ends_by_itself_and_stays_ended_until_let_go() {
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
g.ask(&all(), t);
assert_eq!(g.ask(&all(), t + MAX_HOLD), Release::All);
assert!(!g.holds((0, 0, 0)));
assert_eq!(g.ask(&all(), t + MAX_HOLD * 2), Release::None);
assert!(!g.holds((0, 0, 0)));
g.ask(&WorldHold::default(), t + MAX_HOLD * 2);
g.ask(&all(), t + MAX_HOLD * 3);
assert!(g.holds((0, 0, 0)));
}
#[test]
fn smaller_circles_let_go_what_they_leave() {
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
g.ask(&within(&[[0.0, 0.0, 100.0]]), t);
assert!(g.holds((0, 0, 0)) && g.holds((5, 0, 0)) && !g.holds((7, 0, 0)));
g.wait((0, 0, 0), 1, 10);
g.wait((5, 0, 0), 2, 20);
assert_eq!(g.ask(&within(&[[0.0, 0.0, 100.0]]), t), Release::None);
assert_eq!(g.ask(&within(&[[0.0, 0.0, 40.0]]), t), Release::Outside);
assert_eq!(g.take_outside(), vec![((5, 0, 0), 2)]);
assert_eq!((g.sections().count(), g.bytes()), (1, 10));
assert!(!g.holds((5, 0, 0)));
// (to all of it again: nothing more is let go, and all of it waits again)
assert_eq!(g.ask(&all(), t), Release::Outside);
assert!(g.take_outside().is_empty());
assert!(g.holds((5, 0, 0)));
}
#[test]
fn what_waits_is_counted_and_capped() {
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
g.ask(&all(), t);
assert_eq!(g.wait((0, 0, 0), 1, MAX_BYTES - 10), (None, false));
// (a newer mesh of the same section replaces it, and the old one comes back to free)
assert_eq!(g.wait((0, 0, 0), 2, MAX_BYTES - 20), (Some(1), false));
assert_eq!(g.bytes(), MAX_BYTES - 20);
assert_eq!(g.wait((1, 0, 0), 3, 20), (None, false));
assert_eq!(g.wait((2, 0, 0), 4, 1), (None, true));
assert_eq!(g.take((1, 0, 0)), Some(3));
assert_eq!(g.take((1, 0, 0)), None);
assert_eq!(g.bytes(), MAX_BYTES - 19);
}
#[test]
fn columns_going_and_the_sections_cleared_drop_what_waits() {
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
g.ask(&all(), t);
g.wait((0, 0, 0), 1, 10);
g.wait((0, 5, 0), 2, 10);
g.wait((3, 0, 0), 3, 10);
let gone: FxHashSet<(i32, i32)> = [(0, 0)].into_iter().collect();
let mut dropped = g.take_columns(&gone);
dropped.sort_unstable();
assert_eq!(dropped, vec![1, 2]);
assert_eq!((g.sections().count(), g.bytes()), (1, 10));
g.clear();
assert!(g.is_empty() && g.bytes() == 0 && !g.holds((3, 0, 0)));
// (cleared while held: spent until let go; with no hold, nothing to end)
assert_eq!(g.ask(&all(), t), Release::None);
assert!(!g.holds((3, 0, 0)));
let mut none = Hold::<u32>::default();
none.clear();
none.ask(&all(), t);
assert!(none.holds((3, 0, 0)));
}
}
@@ -1,471 +0,0 @@
//! HUD coverage beside its unchanged colour and alpha, only on the float HDR lane.
pub(crate) const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm;
/// The HUD's own target on the layered frame (`Hdr::hud`): 8-bit, so each HUD draw clips at
/// white and rounds as on vanilla's frame. The coverage pipelines are the only ones that draw
/// on it.
pub(crate) const HUD_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
pub(crate) fn supported(
attachments: u32,
flags: wgpu::DownlevelFlags,
format: wgpu::TextureFormatFeatures,
) -> bool {
attachments >= 2
&& flags.contains(wgpu::DownlevelFlags::INDEPENDENT_BLEND)
&& format
.allowed_usages
.contains(wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING)
&& format
.flags
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
}
pub(crate) fn attachment(view: &wgpu::TextureView) -> wgpu::RenderPassColorAttachment<'_> {
wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
}
}
pub(crate) fn clear(encoder: &mut wgpu::CommandEncoder, view: &wgpu::TextureView) {
let mut color = attachment(view);
color.ops.load = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("HUD coverage reset"),
color_attachments: &[Some(color)],
..Default::default()
});
}
pub(crate) struct Pipeline {
plain: wgpu::RenderPipeline,
coverage: Option<wgpu::RenderPipeline>,
}
impl Pipeline {
/// `None` when `enabled` and the shader can't be given a coverage output (a resource
/// pack's, which the caller then doesn't use).
pub(crate) fn new(
device: &wgpu::Device,
desc: &wgpu::RenderPipelineDescriptor<'_>,
source: wgpu::ShaderSource<'_>,
enabled: bool,
) -> Option<Self> {
let coverage = if enabled {
Some(pipeline(device, desc, source)?)
} else {
None
};
Some(Self {
plain: device.create_render_pipeline(desc),
coverage,
})
}
pub(crate) fn get(&self, coverage: bool) -> &wgpu::RenderPipeline {
if coverage {
self.coverage.as_ref().expect("HUD coverage pipeline")
} else {
&self.plain
}
}
}
/// Keep the primary output and all its blend factors, including destination alpha, on the
/// HUD's target (`HUD_FORMAT`). Wrap the final shader result, so pack text's alpha, early
/// returns and discard all apply to coverage.
/// `None` for a fragment shader without a colour output at location 0.
pub(super) fn pipeline(
device: &wgpu::Device,
desc: &wgpu::RenderPipelineDescriptor<'_>,
source: wgpu::ShaderSource<'_>,
) -> Option<wgpu::RenderPipeline> {
let mut desc = desc.clone();
let fragment = desc.fragment.as_mut()?;
let mut module = match source {
wgpu::ShaderSource::Wgsl(s) => naga::front::wgsl::parse_str(&s).ok()?,
wgpu::ShaderSource::Naga(m) => m.into_owned(),
_ => return None,
};
let mut primary = fragment.targets.first()?.clone()?;
primary.format = HUD_FORMAT;
let blend = primary.blend.unwrap_or(wgpu::BlendState::REPLACE).color;
let additive = matches!(
(blend.src_factor, blend.dst_factor),
(
wgpu::BlendFactor::One
| wgpu::BlendFactor::Src
| wgpu::BlendFactor::SrcAlpha
| wgpu::BlendFactor::Zero,
wgpu::BlendFactor::One
)
) && blend.operation == wgpu::BlendOperation::Add;
wrap(&mut module, fragment.entry_point?, blend)?;
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: desc.label,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let over = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::OneMinusSrc,
operation: wgpu::BlendOperation::Add,
};
let targets = [
Some(primary),
Some(wgpu::ColorTargetState {
format: FORMAT,
blend: Some(wgpu::BlendState {
color: over,
alpha: over,
}),
write_mask: if additive {
wgpu::ColorWrites::empty()
} else {
wgpu::ColorWrites::RED
},
}),
];
fragment.module = &shader;
fragment.targets = &targets;
Some(device.create_render_pipeline(&desc))
}
fn wrap(module: &mut naga::Module, name: &str, blend: wgpu::BlendComponent) -> Option<()> {
use naga::{Expression as E, Statement as S, TypeInner as T};
use wgpu::BlendFactor as B;
let span = naga::Span::UNDEFINED;
let entry = module.entry_points.iter_mut().find(|e| e.name == name)?;
// Checked before anything is taken apart: a resource pack's shader may have no colour.
let result = entry.function.result.clone()?;
if let T::Struct { members, .. } = &module.types[result.ty].inner {
members
.iter()
.find(|m| matches!(m.binding, Some(naga::Binding::Location { location: 0, .. })))?;
} else if !matches!(
result.binding,
Some(naga::Binding::Location { location: 0, .. })
) {
return None;
}
let mut original = std::mem::take(&mut entry.function);
entry.function.arguments = original.arguments.clone();
for arg in &mut original.arguments {
arg.binding = None;
}
original.result.as_mut().unwrap().binding = None;
original.name = Some("hud_color".into());
let function = module.functions.append(original, span);
let f = &mut entry.function;
let args = (0..f.arguments.len())
.map(|i| f.expressions.append(E::FunctionArgument(i as u32), span))
.collect();
let call = f.expressions.append(E::CallResult(function), span);
f.body.push(
S::Call {
function,
arguments: args,
result: Some(call),
},
span,
);
let one = f
.expressions
.append(E::Literal(naga::Literal::F32(1.0)), span);
let start = f.expressions.len();
let (mut members, mut values, color, offset) = match &module.types[result.ty].inner {
T::Struct {
members,
span: size,
} => {
// HUD pipelines have one target, so a pack's other colour outputs went nowhere;
// left in, one at location 1 would collide with the coverage.
let (members, values): (Vec<_>, Vec<_>) = members
.iter()
.enumerate()
.filter(|(_, m)| {
!matches!(m.binding, Some(naga::Binding::Location { location, .. }) if location != 0)
})
.map(|(i, m)| {
let value = f.expressions.append(
E::AccessIndex {
base: call,
index: i as u32,
},
span,
);
(m.clone(), value)
})
.unzip();
let color = members
.iter()
.position(|m| {
matches!(m.binding, Some(naga::Binding::Location { location: 0, .. }))
})
.unwrap();
let color = values[color];
(members, values, color, *size)
}
_ => (
vec![naga::StructMember {
name: Some("color".into()),
ty: result.ty,
binding: result.binding,
offset: 0,
}],
vec![call],
call,
16,
),
};
let mut component = |index| {
f.expressions
.append(E::AccessIndex { base: color, index }, span)
};
let [r, g, b, a] = [component(0), component(1), component(2), component(3)];
let mut math = |fun, arg, arg1| {
f.expressions.append(
E::Math {
fun,
arg,
arg1: Some(arg1),
arg2: None,
arg3: None,
},
span,
)
};
let low = math(naga::MathFunction::Min, r, g);
let low = math(naga::MathFunction::Min, low, b);
let high = math(naga::MathFunction::Max, r, g);
let high = math(naga::MathFunction::Max, high, b);
// A scalar cannot reproduce RGB-dependent replacement or inversion: surviving pixels
// cover fully, so the later composite cannot leak world light through them.
let c = if blend.operation != wgpu::BlendOperation::Add {
one
} else {
match (blend.src_factor, blend.dst_factor) {
(B::SrcAlpha | B::One, B::OneMinusSrcAlpha) => a,
(B::Zero, B::OneMinusSrc) => high,
// Multiplying retains src.rgb (or 1 - alpha + src.rgb) of the backdrop.
// Use the greatest channel darkening, as for the credits vignette above.
(B::Zero, B::Src)
| (B::Dst, B::Zero)
| (B::Dst, B::OneMinusSrcAlpha)
| (B::Dst, B::Src) => {
let top = if blend.dst_factor == B::OneMinusSrcAlpha {
a
} else {
one
};
let factor = if blend.dst_factor == B::Src && blend.src_factor == B::Dst {
f.expressions.append(
E::Binary {
op: naga::BinaryOperator::Add,
left: low,
right: low,
},
span,
)
} else {
low
};
f.expressions.append(
E::Binary {
op: naga::BinaryOperator::Subtract,
left: top,
right: factor,
},
span,
)
}
_ => one,
}
};
let c = f.expressions.append(
E::Math {
fun: naga::MathFunction::Saturate,
arg: c,
arg1: None,
arg2: None,
arg3: None,
},
span,
);
let scalar = module.types.insert(
naga::Type {
name: None,
inner: T::Scalar(naga::Scalar::F32),
},
span,
);
members.push(naga::StructMember {
name: Some("coverage".into()),
ty: scalar,
binding: Some(naga::Binding::Location {
location: 1,
interpolation: None,
sampling: None,
blend_src: None,
per_primitive: false,
}),
offset,
});
let ty = module.types.insert(
naga::Type {
name: Some("HudCoverage".into()),
inner: T::Struct {
members,
span: (offset + 4).next_multiple_of(16),
},
},
span,
);
values.push(c);
let value = f.expressions.append(
E::Compose {
ty,
components: values,
},
span,
);
f.result = Some(naga::FunctionResult { ty, binding: None });
f.body.push(S::Emit(f.expressions.range_from(start)), span);
f.body.push(S::Return { value: Some(value) }, span);
Some(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coverage_requires_two_attachments_and_independent_r8_blending() {
let flags = wgpu::DownlevelFlags::INDEPENDENT_BLEND;
let format = wgpu::TextureFormatFeatures {
allowed_usages: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
flags: wgpu::TextureFormatFeatureFlags::BLENDABLE,
};
assert!(supported(2, flags, format));
assert!(!supported(1, flags, format));
assert!(!supported(2, wgpu::DownlevelFlags::empty(), format));
assert!(!supported(
2,
flags,
wgpu::TextureFormatFeatures {
flags: wgpu::TextureFormatFeatureFlags::empty(),
..format
}
));
assert!(!supported(
2,
flags,
wgpu::TextureFormatFeatures {
allowed_usages: wgpu::TextureUsages::TEXTURE_BINDING,
..format
}
));
assert!(!supported(
2,
flags,
wgpu::TextureFormatFeatures {
allowed_usages: wgpu::TextureUsages::RENDER_ATTACHMENT,
..format
}
));
}
#[test]
fn disabled_coverage_builds_only_the_original_pipeline() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, _)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@vertex fn vs() -> @builtin(position) vec4<f32> { return vec4(0.0); }".into(),
),
});
let desc = wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: None,
multiview_mask: None,
cache: None,
};
// The coverage source must not even be parsed outside the lane.
let pipeline = Pipeline::new(
&device,
&desc,
wgpu::ShaderSource::Wgsl("invalid".into()),
false,
)
.unwrap();
assert!(pipeline.coverage.is_none());
}
#[test]
fn coverage_wraps_the_debug_crosshair_without_changing_world_entry_points() {
let mut module = naga::front::wgsl::parse_str(&crate::gfx::terrain_shader()).unwrap();
wrap(
&mut module,
"fs_debug_line",
wgpu::BlendState::ALPHA_BLENDING.color,
)
.unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
#[test]
fn coverage_replaces_a_colour_output_the_hud_has_no_target_for() {
let mut module = naga::front::wgsl::parse_str(
"struct Out { @location(0) color: vec4<f32>, @location(1) unused: vec4<f32> }
@fragment fn fs() -> Out { return Out(vec4(0.5), vec4(1.0)); }",
)
.unwrap();
wrap(&mut module, "fs", wgpu::BlendState::ALPHA_BLENDING.color).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
let result = module.entry_points[0].function.result.as_ref().unwrap();
let naga::TypeInner::Struct { members, .. } = &module.types[result.ty].inner else {
panic!("the wrapped entry returns a struct");
};
let names: Vec<_> = members.iter().map(|m| m.name.as_deref().unwrap()).collect();
assert_eq!(names, ["color", "coverage"]);
}
}
@@ -98,24 +98,8 @@ fn interfaces(source: &str) -> Vec<Interface> {
/// The shaders as the renderer assembles them, by the `.wgsl` files each is made of.
fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
let entity = super::entity_shader();
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
vec![
(
"HDR",
vec!["hdr.wgsl", "hdr_light.wgsl"],
super::hdr::shader(),
),
("terrain", vec!["terrain.wgsl"], super::terrain_shader()),
(
"stars",
vec!["stars.wgsl"],
include_str!("../hdr_light.wgsl").to_owned() + include_str!("stars.wgsl"),
),
(
"custom sky",
vec!["custom_sky.wgsl"],
include_str!("../hdr_light.wgsl").to_owned() + include_str!("custom_sky.wgsl"),
),
("entity", vec!["entity.wgsl"], entity.clone()),
(
"GPU culling occlusion test",
@@ -140,29 +124,14 @@ fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
(
"improved transparency world",
vec!["terrain.wgsl", "oit.wgsl", "oit_world.wgsl"],
super::terrain_shader()
include_str!("../terrain.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_world.wgsl"),
),
(
"visibility buffer",
vec!["terrain.wgsl", "visbuffer.wgsl"],
super::terrain_shader() + include_str!("../visbuffer.wgsl"),
),
// A native mod's world warp (gfx/world_warp.rs): its copies with the plainest warp.
(
"world warp terrain",
vec![
"terrain.wgsl",
"world_warp.wgsl",
"world_warp_fragment.wgsl",
],
super::world_warp::terrain_shader(super::world_warp::IDENTITY).unwrap(),
),
(
"world warp entities",
vec!["entity.wgsl", "static_entity.wgsl", "world_warp.wgsl"],
super::world_warp::entity_shader(super::world_warp::IDENTITY).unwrap(),
include_str!("../terrain.wgsl").to_owned() + include_str!("../visbuffer.wgsl"),
),
(
"improved transparency world border",
+15 -21
View File
@@ -50,10 +50,8 @@ impl Pending {
}
}
/// Small, its meshes boxed: `draw_frame` walks every tile each frame (thousands at long far
/// distances), and that walk is bound by the memory it reads.
pub(super) struct Tile {
pub mesh: Box<SectionGpu>,
pub mesh: SectionGpu,
bounds: Bounds,
/// When it began to fade in; none while the rest of its job uploads.
shown: Option<Instant>,
@@ -64,7 +62,7 @@ pub(super) struct Tile {
/// Its mesh before near chunks took some of its columns, drawn in its
/// place until they have faded in (from the job's end, the instant), so
/// no hole shows through them.
previous: Option<Box<(SectionGpu, Option<Instant>)>>,
previous: Option<(SectionGpu, Option<Instant>)>,
/// Its slot in occlusion culling's results (`cull::TILE`), kept while
/// meshes of the same tile replace one another.
pub cull_slot: u32,
@@ -157,12 +155,10 @@ pub(super) struct Visible {
impl Tile {
/// The mesh drawn this frame: the previous one while it stands in.
pub fn drawn(&self) -> &SectionGpu {
self.previous
.as_deref()
.map_or(&self.mesh, |(mesh, _)| mesh)
self.previous.as_ref().map_or(&self.mesh, |(mesh, _)| mesh)
}
pub fn drawn_mut(&mut self) -> &mut SectionGpu {
match self.previous.as_deref_mut() {
match &mut self.previous {
Some((mesh, _)) => mesh,
None => &mut self.mesh,
}
@@ -181,7 +177,7 @@ impl Tile {
}
/// Both meshes it holds in the arena: its own and the stand-in, if any.
pub fn meshes_mut(&mut self) -> impl Iterator<Item = &mut SectionGpu> {
std::iter::once(&mut *self.mesh).chain(self.previous.as_deref_mut().map(|(mesh, _)| mesh))
std::iter::once(&mut self.mesh).chain(self.previous.as_mut().map(|(mesh, _)| mesh))
}
pub fn visible(
&self,
@@ -218,12 +214,12 @@ impl Renderer {
let (shown, previous) = match self.lods.remove(&key) {
Some(old) if old.dying.is_none() => match old.previous {
Some(previous) => {
self.release_mesh(*old.mesh);
self.release_mesh(old.mesh);
(old.shown, Some(previous))
}
None if keep => (old.shown, Some(Box::new((*old.mesh, None)))),
None if keep => (old.shown, Some((old.mesh, None))),
None => {
self.release_mesh(*old.mesh);
self.release_mesh(old.mesh);
(old.shown, None)
}
},
@@ -244,7 +240,7 @@ impl Renderer {
self.lods.insert(
key,
Tile {
mesh: Box::new(mesh),
mesh,
bounds,
shown,
dying: None,
@@ -255,7 +251,7 @@ impl Renderer {
);
}
None => {
if let Some((previous, _)) = previous.map(|p| *p) {
if let Some((previous, _)) = previous {
self.release_mesh(previous);
}
self.lod_cull_free.extend(old_slot);
@@ -264,8 +260,8 @@ impl Renderer {
}
fn release_tile(&mut self, tile: Tile) {
self.release_mesh(*tile.mesh);
if let Some((previous, _)) = tile.previous.map(|p| *p) {
self.release_mesh(tile.mesh);
if let Some((previous, _)) = tile.previous {
self.release_mesh(previous);
}
}
@@ -341,10 +337,8 @@ impl Renderer {
let stood_in: Vec<_> = self
.lods
.values_mut()
.filter_map(|tile| match tile.previous.as_deref() {
Some((_, Some(since))) if start - *since >= FADE => {
tile.previous.take().map(|p| *p)
}
.filter_map(|tile| match tile.previous {
Some((_, Some(since))) if start - since >= FADE => tile.previous.take(),
_ => None,
})
.collect();
@@ -446,7 +440,7 @@ impl Renderer {
if tile.shown.is_none() {
tile.shown = Some(if over_replaced(key) { start } else { whole });
}
if let Some((_, since)) = tile.previous.as_deref_mut() {
if let Some((_, since)) = &mut tile.previous {
since.get_or_insert(start);
}
}
@@ -49,23 +49,9 @@ struct HorizonMaterialOut {
@location(14) @interpolate(flat) normal: vec3<f32>,
}
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec4<f32>) -> HorizonMaterialOut {
let pos = horizon_material_displace(position + origin.xyz, uv, layer, color, light);
var out = horizon_material_out(pos, uv, layer, color, vec2<f32>(light));
// The section's fade-in (`RenderSection.getVisibility`), as how far it is from whole, in the
// high 16 bits of `hdr_code`: no varying of its own, and 0 (whole) where nothing fades. It is
// the same for the whole section, so the flat varying loses nothing.
let instance = horizon_terrain_instance_visibility(origin.w);
out.hdr_code |= u32(round((1.0 - instance) * 65535.0)) << 16u;
return out;
}
// terrain.wgsl's fade-in (terrain.vsh's chunkVisibility): whole within 16 blocks, the section's
// own visibility from 32 out, toward the fog colour, alpha kept. The distance blend runs here on
// the interpolated distance, as vanilla's interpolated `visibility`, not once per triangle.
fn horizon_material_faded(in: HorizonMaterialOut, color: vec4<f32>) -> vec4<f32> {
let instance = 1.0 - f32(in.hdr_code >> 16u) / 65535.0;
let visibility = mix(1.0, instance, clamp((in.spherical - 16.0) / 16.0, 0.0, 1.0));
return vec4(mix(horizon_terrain_globals.fog_color.rgb, color.rgb, visibility), color.a);
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
let pos = horizon_material_displace(position + origin, uv, layer, color, light);
return horizon_material_out(pos, uv, layer, color, vec2<f32>(light));
}
// `light`: sky, block, 16 per level.
fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
@@ -108,7 +94,7 @@ fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
@location(5) origin: vec4<f32>) -> HorizonMaterialOut {
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
color, light, origin);
color, light, origin.xyz);
}
@vertex fn horizon_material_vs_flat(
@builtin(vertex_index) vertex: u32,
@@ -117,7 +103,7 @@ fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f
) -> HorizonMaterialOut {
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[vertex >> 8u]];
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
color, light, origin);
color, light, origin.xyz);
}
// The prelude's `block_normal_map` and `block_specular_map`: the resource packs' LabPBR maps
// in the pack's frame group (3), one array each, a blank 1x1 without them.
@@ -195,11 +181,9 @@ struct HorizonMaterialSolid {
// What `fs_solid` leaves in the frame's alpha for HDR output, from the sprite before the pack's
// material: the pack decides the colour, Horizon still knows which pixels are light sources.
fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
if horizon_terrain_HDR_WRITE_LIGHT { return 1.0; }
let code = in.hdr_code & 15u;
if code > 0u {
let whole = code > 4u;
let tier = select(code, code - 4u, whole);
if in.hdr_code > 0u {
let whole = in.hdr_code > 4u;
let tier = select(in.hdr_code, in.hdr_code - 4u, whole);
let l = in.layer & 0x7fffu;
let t = textureLoad(horizon_terrain_emission_thresholds,
vec2(i32(l % horizon_terrain_SPRITE_SPLIT), i32(l / horizon_terrain_SPRITE_SPLIT)), 0);
@@ -218,7 +202,7 @@ fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
return horizon_material_solid(in);
}
fn horizon_material_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
let color = horizon_material_faded(in, horizon_material_color(in, false));
let color = horizon_material_color(in, false);
let texel = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
@@ -232,7 +216,7 @@ fn horizon_material_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
return out;
}
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_faded(in, horizon_material_color(in, true));
let color = horizon_material_color(in, true);
if color.a < 0.01 { discard; }
return horizon_material_fogged(color, in.spherical, in.cylindrical);
}
@@ -310,8 +294,7 @@ pub struct NativeMaterial {
/// every name it declares prefixed.
fn vanilla_terrain() -> String {
prefixed(
&(include_str!("../hdr_light.wgsl").to_owned()
+ section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)")),
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
PREFIX,
)
}
@@ -433,10 +416,6 @@ impl NativeMaterial {
];
constants.extend(sprite_names.into_iter().zip(shape.sprites));
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
constants.push((
"horizon_terrain_HDR_WRITE_LIGHT",
f64::from(u8::from(shape.format == super::LINEAR_FRAME)),
));
constants.push((
"horizon_displace_distance",
displaced.map_or(0.0, |d| f64::from(d.distance)),
@@ -585,39 +564,6 @@ impl NativeMaterial {
}
}
#[cfg(test)]
pub(super) fn coverage_test_shader(api: horizon_shader_api::Api) -> String {
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
let manifest = format!(
"schema = 1\nid = \"coverage\"\nname = \"Coverage\"\nversion = \"1\"\napi = \"{api}\"\n[material]\nfile = \"material.wgsl\"\n[displacement]\n"
);
let material = "fn material(input: MaterialInput) -> Surface { return Surface(vec4<f32>(1.4, 0.3, 0.1, 0.7)); }\nfn displacement(input: DisplacementInput) -> f32 { return 0.0; }\n";
let files = [(MANIFEST, manifest.as_str()), ("material.wgsl", material)]
.map(|(n, text)| (n.to_owned(), text.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report
.stages
.iter()
.find(|s| s.kind == StageKind::Material)
.unwrap();
shader_source(&stage.source, &[], &[], (false, true), api)
+ r#"
override TEST_TIER: u32 = 0u;
override TEST_OVERLAY: u32 = 0u;
@vertex fn test_vertex(@builtin(vertex_index) i: u32) -> HorizonMaterialOut {
var o: HorizonMaterialOut;
o.clip = vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
o.uv = vec2(0.5);
o.tint = vec4(1.0);
o.lightmap = vec3(1.0);
o.hdr_code = TEST_TIER;
o.base = vec3(1.0);
return o;
}
"#
}
#[cfg(test)]
mod tests {
use super::*;
@@ -636,25 +582,6 @@ mod tests {
report.stages.remove(0)
}
/// The fade-in packed above the HDR code: a section a quarter visible (`section_instance`
/// with step 1: mantissa `1 - visibility` = 0.75) keeps its visibility through the code, and
/// a fragment 24 blocks away, half-way into the 16-block blend, is 1 - (1 - 0.25) / 2 = 0.625
/// visible; the code's low nibble survives.
#[test]
fn the_fade_in_rides_the_hdr_code() {
let w = f32::from_bits(1.0f32.to_bits() | (0.75 * 8_388_608.0) as u32);
// `horizon_material_vertex`.
let instance = 1.0 - (w.to_bits() & 0x7fffff) as f32 / 8_388_608.0;
let code = 7u32 | (((1.0 - instance) * 65535.0).round() as u32) << 16;
// `horizon_material_faded` and `horizon_material_mark`.
let unpacked = 1.0 - (code >> 16) as f32 / 65535.0;
assert!((unpacked - 0.25).abs() < 1.0 / 65535.0);
let blend = ((24.0f32 - 16.0) / 16.0).clamp(0.0, 1.0);
let visibility = 1.0 + (unpacked - 1.0) * blend;
assert!((visibility - 0.625).abs() < 1.0 / 65535.0);
assert_eq!(code & 15, 7);
}
#[test]
fn the_terrain_takes_a_prefix_and_validates_with_a_pack() {
let terrain = vanilla_terrain();
@@ -90,27 +90,6 @@ impl NativePack {
report: Report,
constants: &[(String, f64)],
lights: super::block_light::LightTable,
) -> Result<(Self, Vec<String>), String> {
// A backend may refuse a layout or pipeline the pack asks for (a stage's bindings
// past its sampled-texture limit, say, as OpenGL's 16 was before #10095): the pack
// is refused with that error instead of a validation panic in the renderer.
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let built = Self::build(device, queue, terrain, report, constants, lights);
let refused = pollster::block_on(scope.pop());
match (built, refused) {
(Ok(built), None) => Ok(built),
(Ok(_), Some(e)) => Err(e.to_string()),
(Err(e), _) => Err(e),
}
}
fn build(
device: &wgpu::Device,
queue: &wgpu::Queue,
terrain: &TerrainShape<'_>,
report: Report,
constants: &[(String, f64)],
lights: super::block_light::LightTable,
) -> Result<(Self, Vec<String>), String> {
let (globals, format) = (terrain.globals, terrain.format);
let textures = super::native_post::pack_textures(device, queue, &report);
@@ -299,90 +278,9 @@ impl NativePack {
#[cfg(test)]
mod tests {
use super::{NativePack, TerrainShape, chosen_shadow};
use super::chosen_shadow;
use horizon_shader_api::manifest::{OptionKind, PackOption, Shadow};
/// A device with OpenGL's minimum 16 sampled textures per stage refuses a pack whose
/// sky adds the terrain's 10 to the frame's 9: that is the pack's error to report, not
/// a validation panic (#10095).
#[test]
fn a_pack_the_backend_refuses_is_an_error_not_a_panic() {
use horizon_shader_api::{MANIFEST, Pack, check};
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let mut limits = adapter.limits();
limits.max_sampled_textures_per_shader_stage = 16;
let Ok((device, queue)) =
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: limits,
..Default::default()
}))
else {
return;
};
// The terrain's group 0: its uniform and the 10 sampled textures (_terrain.wgsl_'s
// 8 sprite arrays, the lightmap and the emission thresholds).
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty,
count: None,
};
let mut entries = vec![entry(
0,
wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
)];
entries.extend((1..=10).map(|binding| {
entry(
binding,
wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
)
}));
let globals = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &entries,
});
let empty = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &[],
});
let terrain = TerrainShape {
globals: &globals,
flat: None,
sprites: [1.0, 2048.0],
format: wgpu::TextureFormat::Rgba8Unorm,
sprite_tags: &[],
sprite_materials: &[],
entities: &empty,
weather_groups: [&empty, &empty],
pbr: None,
};
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
[sky]\nfile = \"sky.wgsl\"\n";
let files = [
(MANIFEST, manifest),
(
"sky.wgsl",
"fn sky(input: SkyInput) -> vec4<f32> { return input.color; }\n",
),
]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let built = NativePack::new(&device, &queue, &terrain, report, &[], Default::default());
assert!(built.is_err(), "the pack should be refused");
}
fn option(id: &str, kind: OptionKind) -> PackOption {
PackOption {
id: id.into(),
@@ -164,24 +164,16 @@ fn fullscreen_pass(
}
/// The tonemap stage's entries as the renderer draws them: the check's, with the result
/// encoded into the frame's gamma, where the camera overlays and the HUD draw on it. A pack's
/// `[tonemap]` is held to `frame.headroom`. Horizon's own (`default`) is a clip at white in
/// SDR; in HDR it leaves the light as it is for Horizon's tone curve (`Hdr::tonemap`), the
/// one HDR without a pack uses.
fn tonemap_entries(default: bool) -> String {
let held = if default {
"select(min(horizon_sanitize(out), vec3<f32>(frame.headroom)), horizon_sanitize(out), frame.hdr == 1u)"
} else {
"min(horizon_sanitize(out), vec3<f32>(frame.headroom))"
};
/// encoded into the frame's gamma, where the camera overlays and the HUD draw on it.
fn tonemap_entries() -> String {
StageKind::Tonemap.entries(Api::V0_2).replace(
"return vec4<f32>(min(horizon_sanitize(out), vec3<f32>(frame.headroom)), 1.0);",
&format!("return vec4<f32>(horizon_encode({held}), 1.0);"),
"return vec4<f32>(horizon_encode(min(horizon_sanitize(out), vec3<f32>(frame.headroom))), 1.0);",
)
}
/// The tonemap of a 0.2 pack without `[tonemap]`: the picture as it is, held as
/// `tonemap_entries` holds Horizon's.
/// Horizon's tonemap for a 0.2 pack without `[tonemap]`: SDR's clip at white, which is the
/// entries' hold at `frame.headroom` (1 until 0.2 packs get HDR output).
const DEFAULT_TONEMAP: &str =
"fn tonemap(input: TonemapInput) -> vec3<f32> { return input.color; }\n";
@@ -358,9 +350,6 @@ pub struct NativeFrame {
pub api: Api,
pub decoder: Option<Decoder>,
mirror: Option<wgpu::TextureView>,
/// The HDR display a 0.2 pack's picture goes to this frame (`Hdr::display`: headroom,
/// paper white, peak and black level), set before `write`; `None` is SDR.
pub display: Option<[f32; 4]>,
}
/// A depth texture of `size` the world's depth is copied into.
@@ -570,7 +559,6 @@ impl NativeFrame {
api,
decoder: (api == Api::V0_2).then(|| Decoder::new(device)),
mirror: None,
display: None,
last: None,
eye: [f32::NAN; 4],
history_reset: true,
@@ -811,13 +799,6 @@ impl NativeFrame {
if self.api == Api::V0_2 {
inputs.sky_color = decoded(inputs.sky_color);
inputs.fog_color = decoded(inputs.fog_color);
if let Some([headroom, paper_white, peak, black_level]) = self.display {
inputs.hdr = 1;
inputs.headroom = headroom;
inputs.paper_white = paper_white;
inputs.peak = peak;
inputs.black_level = black_level;
}
}
if !self.reflects {
inputs.has_reflection = 0;
@@ -1073,7 +1054,7 @@ impl NativePost {
let source = tonemap.map_or_else(
|| StageKind::Tonemap.prelude(Api::V0_2) + DEFAULT_TONEMAP,
|s| s.source.clone(),
) + &tonemap_entries(tonemap.is_none());
) + &tonemap_entries();
let file = tonemap.map_or("<tonemap>", |s| s.file.as_str());
let module = naga::front::wgsl::parse_str(&source)
.map_err(|e| format!("{file}: {}", e.emit_to_string(&source)))?;
@@ -1137,11 +1118,6 @@ impl NativePost {
})
}
/// Whether the pack has a `[tonemap]` of its own, which replaces Horizon's tone curve.
pub fn tonemaps(&self) -> bool {
self.display.as_ref().is_some_and(|d| d.label.is_some())
}
/// The passes' labels, in order, then the pack's tonemap's.
pub fn labels(&self) -> Vec<&str> {
let tonemap = self.display.as_ref().and_then(|d| d.label.as_deref());
@@ -1581,8 +1557,8 @@ pub fn frame_inputs(
let k = super::native_shadow::distortion(s.distance, s.resolution);
2.0 * s.distance / s.resolution.max(1) as f32 * (1.0 - k)
}),
// Shader API 0.2's display fields, which 0.1 packs don't declare: SDR's values, which
// `NativeFrame::display` replaces on an HDR display.
// Shader API 0.2's display fields, which 0.1 packs don't declare: SDR's values, also
// with HDR on, until HDR output is served for 0.2 packs.
hdr: 0,
headroom: 1.0,
paper_white: 80.0,
@@ -2508,7 +2484,7 @@ file = \"b.wgsl\"
/// an 8-bit step), at alpha 1.
#[test]
fn a_0_2_world_comes_back_through_decode_tonemap_and_encode() {
assert!(tonemap_entries(false).contains("horizon_encode(min(horizon_sanitize(out)"));
assert!(tonemap_entries().contains("horizon_encode(min(horizon_sanitize(out)"));
let Some((device, queue)) = device() else {
return;
};
@@ -2624,188 +2600,4 @@ file = \"b.wgsl\"
);
}
}
/// A 0.2 pack's `Frame` describes the HDR display its picture goes to (`display`), and SDR
/// without one: `hdr` 0, `headroom` 1, `paper_white` and `peak` 80, `black_level` 0.
#[test]
fn a_0_2_frame_describes_the_display_it_goes_to() {
let Some((device, _)) = device() else {
return;
};
let hud = crate::hud::HudBatch::new([4, 4]);
let frame = crate::gfx::pack_uniforms::initial_history_tests::frame(&hud);
let mut native = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_2,
);
let fields = |i: &FrameInputs| (i.hdr, i.headroom, i.paper_white, i.peak, i.black_level);
assert_eq!(
fields(&native.next_inputs(&frame, [4, 4])),
(0, 1.0, 80.0, 80.0, 0.0)
);
native.display = Some([4.0, 250.0, 1000.0, 0.05]);
assert_eq!(
fields(&native.next_inputs(&frame, [4, 4])),
(1, 4.0, 250.0, 1000.0, 0.05)
);
// Read every frame: a change of display shows on the next one.
native.display = Some([2.0, 300.0, 600.0, 0.0]);
assert_eq!(fields(&native.next_inputs(&frame, [4, 4])).1, 2.0);
native.display = None;
assert_eq!(fields(&native.next_inputs(&frame, [4, 4])).0, 0);
}
/// On the layered HDR frame a 0.2 pack's light is tone mapped once: by Horizon's curve,
/// the one HDR without a pack uses, when the pack has no `[tonemap]`, else by the pack's
/// alone, held to the headroom. In SDR Horizon's is a clip at white.
#[test]
fn a_0_2_pack_is_tone_mapped_once_by_its_own_curve_or_horizons() {
let Some((device, queue)) = device() else {
return;
};
// Light 8 times paper white, as the world's float frame stores it, in gamma.
let light = 8.0f32;
let (paper, peak) = (203.0, 1000.0);
let headroom = peak / paper;
// The picture shown for that light, in linear light: (a pack's [tonemap], HDR).
let shown = |pack_curve: bool, hdr: bool| {
let (stage, file, source) = if pack_curve {
(
"[tonemap]",
"t.wgsl",
"fn tonemap(input: TonemapInput) -> vec3<f32> { return input.color; }\n",
)
} else {
(
"[sky]",
"s.wgsl",
"fn sky(input: SkyInput) -> vec4<f32> { return input.color; }\n",
)
};
let manifest = format!(
"schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\n\
{stage}\nfile = \"{file}\"\n"
);
let files = [(MANIFEST, manifest.as_str()), (file, source)]
.into_iter()
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.collect();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let tonemap = report.stages.iter().find(|s| s.kind == StageKind::Tonemap);
let format = wgpu::TextureFormat::Rgba16Float;
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_2,
);
let inputs = FrameInputs {
hdr: u32::from(hdr),
headroom: if hdr { headroom } else { 1.0 },
..Default::default()
};
queue.write_buffer(&frame.buffer, 0, bytemuck::bytes_of(&inputs));
let mut post = NativePost::new(
&device,
Vec::new(),
&report.manifest,
&[],
format,
&frame,
tonemap,
)
.unwrap();
assert_eq!(post.tonemaps(), pack_curve);
let mut display = super::super::hdr::Hdr::new(
&device,
&queue,
format,
super::super::hdr::Output::Pq,
true,
);
(display.paper_white, display.peak) = (paper, peak);
let screen = display.frame(&device, [4, 4]);
let usage =
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage);
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage);
let [color, depth] = [&color, &depth].map(|t| t.create_view(&Default::default()));
let mut encoder = device.create_command_encoder(&Default::default());
let g = f64::from(light.powf(1.0 / 2.2));
drop(encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: g,
g,
b: g,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
}));
let view = screen.create_view(&Default::default());
let world = (&color, &depth, &frame.surface_view);
post.run(&device, &mut encoder, world, &view, [4, 4], true, None);
// The renderer's rule, then its curve, as it runs them after the pack's chain.
if crate::gfx::horizon_tone_curve(hdr, true, post.tonemaps()) {
display.begin(&queue, true);
display.tonemap(&mut encoder, None);
}
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
screen.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.get_mapped_range(..).unwrap();
half::f16::from_le_bytes([bytes[0], bytes[1]])
.to_f32()
.powf(2.2)
};
let near = |got: f32, want: f32| (got - want).abs() <= 0.01 * want;
// Horizon's curve once: BT.2390's EETF at 203 and 1000 nits puts light 8 at 4.82
// (worked out apart from the shader, as in the HDR tone curve's own test). Clipped at
// the headroom first, or mapped twice, it would land lower.
let horizon = shown(false, true);
assert!(near(horizon, 4.82043), "{horizon}");
// The pack's identity curve alone: the light held to the headroom, not eased again.
let pack = shown(true, true);
assert!(near(pack, headroom), "{pack}");
// SDR: a clip at white, the pack's or Horizon's.
for pack_curve in [false, true] {
let sdr = shown(pack_curve, false);
assert!(near(sdr, 1.0), "{pack_curve}: {sdr}");
}
}
}
@@ -7,16 +7,14 @@ use super::Vertex;
use super::pack_draw::EntitySpan;
use glam::{DVec3, Mat4};
use std::sync::{
Arc, OnceLock,
OnceLock,
atomic::{AtomicBool, Ordering},
};
mod generated {
include!(concat!(env!("OUT_DIR"), "/native_render_generated.rs"));
}
pub use generated::{
HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent, WorldWarpEvent,
};
pub use generated::{HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
pub use wgpu;
static RENDERERS: OnceLock<Box<[&'static dyn RenderHooks]>> = OnceLock::new();
@@ -75,15 +73,10 @@ pub struct WorldDrawn<'a> {
pub entities: &'a [Vertex],
pub spans: &'a [EntitySpan],
/// Which entity each run of `entities` is, recorded every frame (`Frame::entity_motion`):
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player,
/// `(2 << 32) | id` for a dropped item, `(3 << 32) | id` for another entity (boat,
/// minecart, armor stand, item frame, painting, primed TNT, falling block) and
/// `(4 << 32) | id` for an item frame's held item (their entity ids), `(5 << 32) | p` for a
/// block entity (`p`: its position, x and z in 11 bits each, y in 10), `(6 << 32) | p`
/// for a block a piston moves (`p`: the cell it moves to), `7 << 32` for the player's own
/// model, 0 for anything else.
/// A run lasts until the next. Unlike `spans`, whose ids name a type, these follow one
/// entity from frame to frame (API 371).
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player and
/// `(2 << 32) | id` for a dropped item (their entity ids), 0 for anything else. A run lasts
/// until the next. Unlike `spans`, whose ids name a type, these follow one entity from
/// frame to frame (API 371).
pub motion: &'a [(u32, u64)],
/// The shared atlas entity UVs point into.
pub atlas: Option<&'a wgpu::Texture>,
@@ -180,38 +173,6 @@ pub fn render_needs() -> RenderNeeds {
needs
}
/// A world warp ([`native_world_warp`]): WGSL that every terrain vertex (far terrain's tiles too)
/// and entity vertex goes through, so a mod can bend the drawn world itself: its real geometry,
/// light and depth, seen from any side. docs/native-mods.md, "World warps", has the WGSL contract.
#[derive(Clone, Debug, Default, PartialEq)]
#[non_exhaustive]
pub struct WorldWarp {
/// WGSL with `fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped`, and optionally,
/// after a line `// [fragments]`, `fn world_fragment(f: Fragment) -> Painted` for every
/// pixel of the terrain. `None`: no warp.
pub source: Option<Arc<str>>,
/// Whether this frame draws with the warp. A source given while this is false is still
/// built, so a mod can pay for its pipelines before the frame that needs them.
pub on: bool,
/// What the warp reads as `warp.params`: four `vec4<f32>`.
pub params: [f32; 16],
}
/// Asked once a frame, before the world is drawn (not on the title screen's panorama): set
/// [`WorldWarp`] to have the frame drawn through your warp. Mods are asked in order and one warp
/// draws: the last one set. Keep it cheap: compare and reuse your `Arc<str>`.
#[inline(never)]
pub fn native_world_warp(warp: &mut WorldWarp) {
generated::dispatch_world_warp(RENDERERS.get().map_or(&[], |renderers| renderers), warp);
}
/// This frame's world warp ([`native_world_warp`]).
pub fn world_warp() -> WorldWarp {
let mut warp = WorldWarp::default();
native_world_warp(&mut warp);
warp
}
#[cfg(test)]
mod tests {
use super::*;
@@ -412,33 +373,6 @@ mod tests {
assert_eq!(texels, expected);
}
struct Bend(&'static str);
impl RenderHooks for Bend {
fn world_warp(&self, event: &mut WorldWarpEvent<'_>) {
event.warp.source = Some(self.0.into());
event.warp.on = true;
event.warp.params[0] += 1.0;
}
}
#[test]
fn the_last_mod_to_set_a_warp_draws() {
let mut warp = WorldWarp::default();
generated::dispatch_world_warp(&[&Depth, &Bend("a"), &Bend("b")], &mut warp);
assert_eq!(warp.source.as_deref(), Some("b"));
assert!(warp.on);
// each saw what the one before left
assert_eq!(warp.params[0], 2.0);
}
#[test]
fn without_mods_there_is_no_warp() {
let mut warp = WorldWarp::default();
generated::dispatch_world_warp(&[], &mut warp);
assert_eq!(warp, WorldWarp::default());
assert!(warp.source.is_none() && !warp.on);
}
#[test]
fn needs_accumulate_in_mod_order() {
let mut needs = RenderNeeds::default();
@@ -512,8 +512,7 @@ fn shader_source(
format!(
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{}{displace}{ENTRIES}",
prefixed(
&(include_str!("../hdr_light.wgsl").to_owned()
+ section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)")),
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
PREFIX
),
super::native_post::line_helpers(api)
@@ -30,7 +30,6 @@ pub struct NativeSky {
/// terrain.wgsl's sky with every name it declares (and `globals`) prefixed.
fn vanilla_sky() -> String {
let text = [
include_str!("../hdr_light.wgsl"),
section(TERRAIN, "struct Globals {", "@group(0) @binding(1)"),
section(TERRAIN, "fn linear_fog(", "fn apply_fog("),
section(TERRAIN, "struct SkyOut {", "// rendertype_lines"),
+5 -22
View File
@@ -192,7 +192,7 @@ impl Oit {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency"),
source: wgpu::ShaderSource::Wgsl(
(super::terrain_shader()
(include_str!("../terrain.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_world.wgsl"))
.into(),
@@ -253,14 +253,7 @@ impl Oit {
..Default::default()
})
};
let constants = [
("SPRITE_ARRAYS", sprites[0]),
("SPRITE_SPLIT", sprites[1]),
(
"HDR_WRITE_LIGHT",
f64::from(u8::from(format == super::LINEAR_FRAME)),
),
];
let constants = [("SPRITE_ARRAYS", sprites[0]), ("SPRITE_SPLIT", sprites[1])];
let add = wgpu::BlendOperation::Add;
let depth = |write| {
Some(wgpu::DepthStencilState {
@@ -471,10 +464,7 @@ impl Oit {
fragment: Some(wgpu::FragmentState {
module: &cloud_shader,
entry_point: Some(fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants[2..],
..Default::default()
},
compilation_options: Default::default(),
targets: &stage_targets(stage),
}),
multiview_mask: None,
@@ -498,14 +488,7 @@ impl Oit {
Stage::Accumulate,
),
];
let features = Features::new(
device,
&layouts,
features[0],
features[1],
features[2],
format,
);
let features = Features::new(device, &layouts, features[0], features[1], features[2]);
Self {
uniform,
layouts,
@@ -929,7 +912,7 @@ mod tests {
#[test]
fn the_terrain_shader_validates() {
validate(
&(crate::gfx::terrain_shader()
&(include_str!("../terrain.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_world.wgsl")),
&[
@@ -55,7 +55,6 @@ impl Features {
entity: &wgpu::BindGroupLayout,
weather: &wgpu::BindGroupLayout,
border: &wgpu::BindGroupLayout,
format: wgpu::TextureFormat,
) -> Self {
let entity_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency features"),
@@ -114,15 +113,6 @@ impl Features {
bind_group_layouts: &[groups[0], groups[1], Some(&layouts[stage as usize])],
..Default::default()
});
let fragment = if kind.label == "text" && stage == Stage::Accumulate {
"fs_oit_text_accumulate".to_owned()
} else {
format!("fs_oit_{}_{suffix}", kind.name)
};
let constants = [(
"HDR_WRITE_LIGHT",
f64::from(u8::from(format == super::LINEAR_FRAME)),
)];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&format!("OIT {} {suffix}", kind.label)),
layout: Some(&layout),
@@ -146,15 +136,8 @@ impl Features {
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module,
entry_point: Some(&fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants: if kind.name == "border" {
&[]
} else {
&constants
},
..Default::default()
},
entry_point: Some(&format!("fs_oit_{}_{suffix}", kind.name)),
compilation_options: Default::default(),
targets: &stage_targets(stage),
}),
multiview_mask: None,
@@ -630,12 +630,13 @@ impl PackAtlas {
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
layout(set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };\n",
);
// Source metadata follows each quad in its arena page. The immutable table
// holds sprite rectangles and the current pack's ids of source states.
// Of each sprite layer, `mc_Entity` (id, then 1 for fluids or 0) and
// `at_midBlock.w` (its block's light), the game's table; and the arena page's
// vertices (`TerrainVertex`, six words each).
declarations.push_str(
"layout(set = 3, binding = 0) readonly buffer HzQuadSources { uvec2 hz_sources[]; };\n\
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n\
layout(set = 3, binding = 2) readonly buffer HzTerrainTables { int hz_tables[]; };\n",
layout(set = 3, binding = 2) readonly buffer HzSpriteRects { uvec2 hz_sprite_rects[]; };\n",
);
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
} else {
@@ -648,8 +649,7 @@ impl PackAtlas {
let columns = self.columns;
let (cell, side, scale) = if flat {
(
"uvec2 hz_rect = uvec2(0u, 1u);\n \
if (2u * hz_layer + 1u < uint(hz_tables[0])) hz_rect = uvec2(hz_tables[2u + 2u * hz_layer], hz_tables[3u + 2u * hz_layer]);\n \
"uvec2 hz_rect = hz_sprite_rects[hz_layer];\n \
hz_cell = vec2(float(hz_rect.x & 65535u), float(hz_rect.x >> 16u)) / float(hz_rect.y);\n "
.to_owned(),
"float(hz_rect.y)",
@@ -701,27 +701,20 @@ impl PackAtlas {
let mut body = body;
if flat {
body.push_str(FACE);
body.push_str(
" uint hz_quad = uint(gl_VertexIndex) / 4u;\n \
uvec2 hz_source = uvec2(0u);\n \
if (hz_quad < uint(hz_sources.length())) hz_source = hz_sources[hz_quad];\n \
bool hz_known_source = (hz_source.y & 8192u) != 0u;\n \
int hz_material = -1;\n \
if (hz_known_source && hz_source.x < uint(hz_tables[1])) hz_material = hz_tables[2u + uint(hz_tables[0]) + 2u * hz_source.x + ((hz_source.y >> 12u) & 1u)];\n",
);
}
declarations.push_str(
"vec2 hz_cell; vec2 hz_scale; vec2 hz_mid; float hz_uv_scale; vec4 hz_tangent;\n",
);
let entity = if flat {
"vec4(float(hz_material), hz_known_source ? float((hz_source.y >> 12u) & 1u) : 0.0, 0.0, 0.0)"
"vec4(float(hz_block_ids[3u * hz_layer]), float(hz_block_ids[3u * hz_layer + 1u]), 0.0, 0.0)"
} else {
"vec4(-1.0, 0.0, 0.0, 0.0)"
};
// The mesher's owning cell, even for inset, offset, multipart or extended faces;
// w is the source's emitted light, independent of sprite and smooth light.
// The block a face belongs to: half a block behind its centre (the quad's diagonal);
// w, the light it gives (Iris's `BLOCK_EMISSION_ATTRIBUTE`, 0 to 15).
let mid_block = if flat {
"(hz_known_source ? vec4((vec3(float(hz_source.y & 15u), float((hz_source.y >> 4u) & 15u), float((hz_source.y >> 8u) & 15u)) + 0.5 - hz_v_pos) * 64.0, float((hz_source.y >> 14u) & 15u)) : vec4(0.0))"
"vec4((floor((hz_p0 + hz_p2) * 0.5 - 0.5 * hz_Normal) + 0.5 - hz_v_pos) * 64.0, \
float(hz_block_ids[3u * hz_layer + 2u]))"
} else {
"vec4(0.0)"
};
@@ -735,13 +728,6 @@ impl PackAtlas {
),
("mc_Entity", entity),
("at_midBlock", mid_block),
// Horizon's own: the face's HDR tier (`mesher::emitting`'s code in the colour's low
// alpha nibble): 0 for none, 1 to 4 for low to very bright, plus 4 when the whole
// face shines (`mesher::WHOLE`).
(
"horizonTier",
"float(15u - (uint(round(hz_v_color.a * 255.0)) & 15u))",
),
(
"mc_midTexCoord",
"vec4((hz_cell + hz_mid) * hz_scale, 0.0, 1.0)",
@@ -764,9 +750,6 @@ impl PackAtlas {
alpha_test: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
}
}
}
@@ -1163,7 +1146,3 @@ mod tests {
);
}
}
#[cfg(test)]
#[path = "pack_atlas/source_ownership_test.rs"]
mod source_ownership_test;
@@ -49,12 +49,12 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
std::env::temp_dir().join(format!("horizon-terrain-fade-{}", std::process::id())),
);
std::fs::create_dir_all(folder.0.join("shaders")).unwrap();
// The initializer, varying and integer output are independent probe code. The program reads
// `mc_chunkFade` unguarded: Horizon defines no `IRIS_FEATURE_*` (spec OQ-2).
// The guard, initializer, varying and integer output are independent probe code.
let vertex = "#version 330\nout vec4 observation;\n\
float first = mc_chunkFade;\n\
void main() {\n\
#ifdef IRIS_FEATURE_FADE_VARIABLE\nfloat first = mc_chunkFade;\n#endif\n\
void main() {\n#ifdef IRIS_FEATURE_FADE_VARIABLE\n\
observation = vec4(mc_chunkFade, first, gl_Color.a, gl_MultiTexCoord0.x);\n\
#else\nobservation = vec4(1.0, 1.0, gl_Color.a, gl_MultiTexCoord0.x);\n#endif\n\
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n}";
let fragment = "#version 330\nin vec4 observation;\nlayout(location = 0) out uvec4 pixel;\n\
void main() { pixel = floatBitsToUint(observation); }";
@@ -157,10 +157,8 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
flat,
geometry,
origin,
0x80ffffff,
);
// The fade reaches the program whether the pack requested it or not.
let fade = expected;
let fade = if requested { expected } else { 1.0 };
let expected = [
fade,
if geometry { 0.8125 } else { fade },
@@ -184,96 +182,6 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
assert_eq!(count, 98);
}
/// `horizonTier`: the HDR tier the mesher marks a light source's faces with (`mesher::emitting`),
/// read by a pack's terrain program, flat or instanced.
#[test]
fn terrain_tier_reaches_pack_pixels() {
let instance = crate::gfx::test_gpu::instance();
let adapter = match pollster::block_on(instance.request_adapter(&Default::default())) {
Ok(adapter) => adapter,
Err(error) => {
eprintln!("SKIP terrain tier GPU control: no adapter: {error}");
return;
}
};
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let array = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 2,
height: 2,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let mut encoder = device.create_command_encoder(&Default::default());
let atlas = PackAtlas::new(&device, &mut encoder, &[array], 1, 1, 2).unwrap();
queue.submit([encoder.finish()]);
let folder = ProbePack(
std::env::temp_dir().join(format!("horizon-terrain-tier-{}", std::process::id())),
);
std::fs::create_dir_all(folder.0.join("shaders")).unwrap();
std::fs::write(
folder.0.join("shaders/gbuffers_terrain.vsh"),
"#version 330\nin float horizonTier;\nout vec4 observation;\n\
void main() {\nobservation = vec4(horizonTier, 0.0, 0.0, 0.0);\n\
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n}",
)
.unwrap();
std::fs::write(
folder.0.join("shaders/gbuffers_terrain.fsh"),
"#version 330\nin vec4 observation;\nlayout(location = 0) out uvec4 pixel;\n\
void main() { pixel = floatBitsToUint(observation); }",
)
.unwrap();
let pack = horizon_shaders::Pack::open(&folder.0).unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
use crate::mesher::{BRIGHT, LOW, VERY_BRIGHT, WHOLE, emitting};
// No light, low, very bright, and bright all over (4 more).
let cases = [
(0, 0.0),
(LOW, 1.0),
(VERY_BRIGHT, 4.0),
(BRIGHT | WHOLE, 7.0),
];
for flat in [false, true] {
let post = horizon_shaders::load_post(
&pack,
"",
&properties,
&macros,
&horizon_shaders::GameInputs {
terrain: Some(atlas.terrain_inputs(flat)),
..Default::default()
},
);
let program = post
.terrain
.as_ref()
.unwrap_or_else(|| panic!("{:?}", post.notes));
for (tier, expected) in cases {
let origin = section_instance([0.0; 3], 0, 1.0);
let pixel = sample(
&device,
&queue,
&atlas,
program.program.as_ref().unwrap(),
flat,
false,
origin,
emitting(0xffff_ffff, tier),
);
assert_eq!(pixel[0], expected, "tier {tier} flat={flat}");
}
}
}
fn sample(
device: &wgpu::Device,
queue: &wgpu::Queue,
@@ -282,7 +190,6 @@ fn sample(
flat: bool,
geometry: bool,
origin: [f32; 4],
color: u32,
) -> [f32; 4] {
let buffer = |bytes: &[u8], usage| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
@@ -302,18 +209,15 @@ fn sample(
let slots = buffer(bytemuck::bytes_of(&0u32), wgpu::BufferUsages::STORAGE);
let vertices: Vec<_> = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]]
.into_iter()
.map(|pos| TerrainVertex::new(pos, [0.25, 0.75], 0, color, 0))
.map(|pos| TerrainVertex::new(pos, [0.25, 0.75], 0, 0x80ffffff, 0))
.collect();
let data = buffer(
bytemuck::cast_slice(&vertices),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let ids = buffer(bytemuck::bytes_of(&[0u32; 2]), wgpu::BufferUsages::STORAGE);
let ids = buffer(bytemuck::bytes_of(&[0i32; 3]), wgpu::BufferUsages::STORAGE);
let rects = buffer(
bytemuck::cast_slice(&super::super::pack_post::terrain_tables(
&[],
&atlas.rects_table(),
)),
bytemuck::cast_slice(&atlas.rects_table()),
wgpu::BufferUsages::STORAGE,
);
let binding = |n, storage| wgpu::BindGroupLayoutEntry {
@@ -330,30 +234,8 @@ fn sample(
},
count: None,
};
let samplers: Vec<wgpu::BindGroupLayoutEntry> = (program.interface.samplers.iter())
.flat_map(|s| {
[
wgpu::BindGroupLayoutEntry {
binding: s.binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: s.binding + 1,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
count: None,
},
]
})
.collect();
let entries = [
[vec![binding(0, false)], samplers].concat(),
vec![binding(0, false)],
if flat {
vec![binding(0, true), binding(1, true)]
} else {
@@ -389,54 +271,20 @@ fn sample(
vec![]
},
];
// The program's samplers (water's boats' patches, `horizonWaterMask`): a 1x1 stand-in each,
// as the pack binds when there is none, its sampler beside it.
let stand_in = device
.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default());
let nearest = device.create_sampler(&Default::default());
let samplers = &program.interface.samplers;
let groups: Vec<_> = layouts
.iter()
.zip(bindings)
.enumerate()
.map(|(group, (layout, buffers))| {
let mut entries: Vec<_> = buffers
.into_iter()
.map(|(binding, buffer)| wgpu::BindGroupEntry {
binding,
resource: buffer.as_entire_binding(),
})
.collect();
if group == 0 {
for s in samplers {
entries.push(wgpu::BindGroupEntry {
binding: s.binding,
resource: wgpu::BindingResource::TextureView(&stand_in),
});
entries.push(wgpu::BindGroupEntry {
binding: s.binding + 1,
resource: wgpu::BindingResource::Sampler(&nearest),
});
}
}
.map(|(layout, buffers)| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout,
entries: &entries,
entries: &buffers
.into_iter()
.map(|(binding, buffer)| wgpu::BindGroupEntry {
binding,
resource: buffer.as_entire_binding(),
})
.collect::<Vec<_>>(),
})
})
.collect();
@@ -406,16 +406,13 @@ fn sample_probe(
bytemuck::bytes_of(&[0., 0., 0., scale]),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let ids = buffer(bytemuck::bytes_of(&[0u32; 2]), wgpu::BufferUsages::STORAGE);
let ids = buffer(bytemuck::bytes_of(&[0i32; 15]), wgpu::BufferUsages::STORAGE);
let data = buffer(
bytemuck::cast_slice(vertices),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let rects = buffer(
bytemuck::cast_slice(&super::super::pack_post::terrain_tables(
&[],
&atlas.rects_table(),
)),
bytemuck::cast_slice(&atlas.rects_table()),
wgpu::BufferUsages::STORAGE,
);
let bindings = [
@@ -1,424 +0,0 @@
//! Original numeric controls for the production terrain attribute expressions.
//! A compute invocation stands for a terrain vertex; it runs the actual input body.
use super::*;
use wgpu::util::DeviceExt;
fn dispatch(
device: &wgpu::Device,
queue: &wgpu::Queue,
pipeline: &wgpu::ComputePipeline,
groups: &[wgpu::BindGroup],
result: &wgpu::Buffer,
) -> Vec<[f32; 4]> {
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: result.size(),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_compute_pass(&Default::default());
pass.set_pipeline(pipeline);
for (i, g) in groups.iter().enumerate() {
pass.set_bind_group(i as u32, g, &[]);
}
pass.dispatch_workgroups((result.size() / 32) as u32, 1, 1);
}
encoder.copy_buffer_to_buffer(result, 0, &read, 0, result.size());
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec()
}
#[test]
#[ignore = "offscreen GPU control; run explicitly with an available adapter"]
fn terrain_source_ownership_gpu() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
.expect("executed GPU control requires an adapter");
eprintln!("TERRAIN_SOURCE_ADAPTER {:?}", adapter.get_info());
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let mut encoder = device.create_command_encoder(&Default::default());
let atlas = PackAtlas::new(&device, &mut encoder, &[texture], 1, 1, 1).unwrap();
let inputs = atlas.terrain_inputs(true);
// Inset faces of two source states sharing sprite zero, an extended multipart face,
// and the second state's fluid face. Ownership is authored
// fixture data, not inferred from normal, sprite, position or diagonal.
let mut vertices = Vec::new();
for (x, y) in [(2.0, 3.25), (6.0, 3.25), (7.25, 3.75), (6.0, 3.875)] {
for [dx, dz] in [[0.0, 0.0], [0.0, 0.5], [0.5, 0.5], [0.5, 0.0]] {
vertices.push(crate::mesher::TerrainVertex::new(
[x + dx, y, 4.0 + dz],
[0.0; 2],
0,
u32::MAX,
0,
));
}
}
let declarations = inputs
.declarations
.lines()
.filter(|l| !l.starts_with("layout(location"))
.collect::<Vec<_>>()
.join("\n");
let attribute = |name: &str| {
inputs
.attributes
.iter()
.find(|(n, _)| n == name)
.unwrap()
.1
.as_str()
};
let source = format!(
"#version 450\n{declarations}\n\
layout(local_size_x=1) in;\n\
layout(set=0,binding=0) buffer Results {{ vec4 result[]; }};\n\
void main() {{\n\
uint hz_index=gl_GlobalInvocationID.x;\n\
vec3 hz_v_pos=uintBitsToFloat(uvec3(hz_vertices[6u*hz_index],hz_vertices[6u*hz_index+1u],hz_vertices[6u*hz_index+2u]));\n\
uvec2 hz_v_uv=uvec2(0); uint hz_v_layer=0u; vec4 hz_v_color=vec4(1); uvec2 hz_v_light=uvec2(0);\n\
vec4 hz_Vertex, hz_MultiTexCoord0, hz_MultiTexCoord1, hz_MultiTexCoord2, hz_Color; vec3 hz_Normal;\n\
{}\nresult[2u*hz_index]={}; result[2u*hz_index+1u]={};\n}}",
inputs.body.replace("gl_VertexIndex", "hz_index"),
attribute("mc_Entity"),
attribute("at_midBlock")
);
let mut frontend = naga::front::glsl::Frontend::default();
let module = frontend
.parse(
&naga::front::glsl::Options::from(naga::ShaderStage::Compute),
&source,
)
.unwrap();
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Original terrain numeric control"),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let layouts: Vec<_> = [1, 2, 0, 3]
.into_iter()
.map(|count| {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &(0..count)
.map(|binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: if count == 1 {
wgpu::ShaderStages::COMPUTE
} else {
wgpu::ShaderStages::COMPUTE | wgpu::ShaderStages::VERTEX
},
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage {
read_only: binding != 0 || count != 1,
},
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
})
.collect::<Vec<_>>(),
})
})
.collect();
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &layouts.iter().map(Some).collect::<Vec<_>>(),
..Default::default()
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: None,
layout: Some(&layout),
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
let storage = |bytes: &[u8]| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytes,
usage: wgpu::BufferUsages::STORAGE,
})
};
let result = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: vertices.len() as u64 * 32,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let slots = storage(bytemuck::cast_slice(&[0u32]));
let origins = storage(bytemuck::cast_slice(&[[128.0f32, -32.0, -256.0, -1.0]]));
let ids = storage(bytemuck::cast_slice(&[
crate::mesher::TerrainSource::new(0, [2, 3, 4], 0, false),
crate::mesher::TerrainSource::new(1, [6, 3, 4], 13, false),
crate::mesher::TerrainSource::new(1, [6, 3, 4], 13, false),
crate::mesher::TerrainSource::new(1, [6, 3, 4], 13, true),
]));
let vertex_storage = storage(bytemuck::cast_slice(&vertices));
let rects = storage(bytemuck::cast_slice(
&super::super::pack_post::terrain_tables(&[31, -1, 47, 59], &[0, 1]),
));
let group = |set: u32, buffers: &[&wgpu::Buffer]| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(set),
entries: &buffers
.iter()
.enumerate()
.map(|(i, b)| wgpu::BindGroupEntry {
binding: i as u32,
resource: b.as_entire_binding(),
})
.collect::<Vec<_>>(),
})
};
let groups = [
group(0, &[&result]),
group(1, &[&slots, &origins]),
group(2, &[]),
group(3, &[&ids, &vertex_storage, &rects]),
];
queue.submit([encoder.finish()]);
let actual = dispatch(&device, &queue, &pipeline, &groups, &result);
let mut mismatches = Vec::new();
for (i, vertex) in vertices.iter().enumerate() {
let q = i / 4;
let expected_id = [31.0, 47.0, 47.0, 59.0][q];
let expected_light = [0.0, 13.0, 13.0, 13.0][q];
let owner = [
[2.5, 3.5, 4.5],
[6.5, 3.5, 4.5],
[6.5, 3.5, 4.5],
[6.5, 3.5, 4.5],
][q];
let expected_mid = [
(owner[0] - vertex.pos[0]) * 64.0,
(owner[1] - vertex.pos[1]) * 64.0,
(owner[2] - vertex.pos[2]) * 64.0,
expected_light,
];
if actual[i * 2] != [expected_id, if q == 3 { 1.0 } else { 0.0 }, 0.0, 0.0]
|| actual[i * 2 + 1] != expected_mid
{
mismatches.push((
i,
actual[i * 2],
actual[i * 2 + 1],
expected_id,
expected_mid,
));
}
}
assert!(
mismatches.is_empty(),
"terrain source mismatches: {mismatches:?}"
);
// Rasterize the same native vertices and production input body, with a diagnostic
// projection placing one face in each strip. The fragment reports id, emission and
// reconstructed owner Y/X after a nonzero section translation; no texture color
// can hide a wrong ownership value.
let vertex_source = format!(
"#version 450\n{}\nlayout(location=0) flat out vec4 control;\n\
void main(){{vec4 hz_Vertex,hz_MultiTexCoord0,hz_MultiTexCoord1,hz_MultiTexCoord2,hz_Color;vec3 hz_Normal;\n\
{}\nvec4 entity={};vec4 mid={};\n\
control=vec4(entity.x,mid.w,hz_Vertex.y+mid.y/64.0,hz_Vertex.x+mid.x/64.0);\n\
uint corner=uint(gl_VertexIndex)%4u;float q=float(uint(gl_VertexIndex)/4u);\n\
gl_Position=vec4(-1.0+(q+float(corner>=2u))*0.5,(corner==1u||corner==2u)?1.0:-1.0,0.5,1.0);}}",
inputs.declarations,
inputs.body,
attribute("mc_Entity"),
attribute("at_midBlock")
);
let vertex_module = frontend
.parse(
&naga::front::glsl::Options::from(naga::ShaderStage::Vertex),
&vertex_source,
)
.unwrap();
let vertex_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(vertex_module)),
});
let fragment_shader=device.create_shader_module(wgpu::ShaderModuleDescriptor { label:None,source:wgpu::ShaderSource::Wgsl(
"@fragment fn fs(@location(0) @interpolate(flat) control:vec4<f32>)->@location(0) vec4<f32>{return control;}".into()) });
let raster = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &vertex_shader,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: 24,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &super::super::TERRAIN_ATTRIBUTES,
})],
},
fragment: Some(wgpu::FragmentState {
module: &fragment_shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba32Float,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let indices: Vec<u32> = (0..4)
.flat_map(|q| [4 * q, 4 * q + 1, 4 * q + 2, 4 * q, 4 * q + 2, 4 * q + 3])
.collect();
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&indices),
usage: wgpu::BufferUsages::INDEX,
});
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 16,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&Default::default());
let pixels = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&raster);
for (i, g) in groups.iter().enumerate() {
pass.set_bind_group(i as u32, g, &[]);
}
pass.set_vertex_buffer(0, vertex_buffer.slice(..));
pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..24, 0, 0..1);
}
encoder.copy_texture_to_buffer(
target.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &pixels,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: Some(1),
},
},
target.size(),
);
queue.submit([encoder.finish()]);
pixels
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixels = pixels.slice(..).get_mapped_range().unwrap();
let pixels: &[[f32; 4]] = bytemuck::cast_slice(&pixels);
for (i, pixel) in pixels.iter().enumerate() {
assert_eq!(
*pixel,
[
[31.0, 0.0, -28.5, 130.5],
[47.0, 13.0, -28.5, 134.5],
[47.0, 13.0, -28.5, 134.5],
[59.0, 13.0, -28.5, 134.5]
][i / 4],
"diagnostic raster pixel {i}"
);
}
// Reload changes only the immutable mapping; the same raw state/owner records
// resolve to new material ids without baking a previous pack's ids into a mesh.
let changed = storage(bytemuck::cast_slice(
&super::super::pack_post::terrain_tables(&[211, -1, 223, 59], &[0, 1]),
));
let changed_groups = [
groups[0].clone(),
groups[1].clone(),
groups[2].clone(),
group(3, &[&ids, &vertex_storage, &changed]),
];
let reloaded = dispatch(&device, &queue, &pipeline, &changed_groups, &result);
for i in 0..vertices.len() {
assert_eq!(
reloaded[2 * i],
[
if i < 4 {
211.0
} else if i < 12 {
223.0
} else {
59.0
},
if i >= 12 { 1.0 } else { 0.0 },
0.0,
0.0
]
);
assert_eq!(reloaded[2 * i + 1], actual[2 * i + 1]);
}
let missing = storage(&[0; 8]);
let missing_groups = [
groups[0].clone(),
groups[1].clone(),
groups[2].clone(),
group(3, &[&missing, &vertex_storage, &rects]),
];
let neutral = dispatch(&device, &queue, &pipeline, &missing_groups, &result);
for i in 0..vertices.len() {
assert_eq!(neutral[2 * i], [-1.0, 0.0, 0.0, 0.0]);
assert_eq!(neutral[2 * i + 1], [0.0; 4]);
}
}
@@ -30,7 +30,7 @@ pub fn buffers() -> [Option<wgpu::VertexBufferLayout<'static>>; 1] {
/// What `gbuffers_entities` and `gbuffers_block` read from an entity vertex: camera-relative
/// position, the shared atlas's UV, colour, light (block, sky in 16ths) and the world-oriented
/// normal. The quad's corners, read from the same vertices at set 1, give the face normal where
/// the vertex has none (custom geometry) and `at_tangent` along the texture's u, as for terrain.
/// the vertex has none (block entities) and `at_tangent` along the texture's u, as for terrain.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let words = std::mem::size_of::<Vertex>() / 4;
let mut declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
@@ -107,10 +107,6 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
"at_velocity",
"hz_velocity(gbufferModelView, gbufferPreviousModelView)",
),
// Horizon's own, as for terrain: the HDR tier of a light-giving item, particle or glowing
// text (`mesher::hdr_tier`, the overlay's high bits), 0 for none, 1 to 4 for low to very
// bright.
("horizonTier", "float((hz_v_overlay >> 16u) & 255u)"),
];
horizon_shaders::GbuffersInputs {
declarations,
@@ -127,10 +123,6 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
}),
item_id_declared: false,
far_depth: false,
// Entities, items and block entities read the shared atlas: its own maps (paged_maps.rs).
paged_maps: true,
water_mask: false,
water_patches: false,
}
}
@@ -194,9 +186,6 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
alpha_test: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
}
}
@@ -231,9 +220,6 @@ pub(super) struct Draw {
pub slot: u32,
/// End portal or gateway faces: drawn with `end_portal.png` bound as the atlas.
pub portal: bool,
/// A boat's water patch (`WATER_MASK_OVERLAY`): depth only, with the program's pipeline that
/// writes no colour.
pub water_mask: bool,
}
/// Which entity runs a pack's shadow pass draws (2.4, 3.2).
@@ -254,7 +240,7 @@ impl Casters {
pub fn cast(&self, span: &EntitySpan, program: usize) -> bool {
match program {
ENTITY => self.entities && (!self.only_player || span.player),
BLOCK | BLOCK_TRANSLUCENT => self.blocks && (!self.only_lit || span.emissive),
BLOCK => self.blocks && (!self.only_lit || span.emissive),
_ => false,
}
}
@@ -447,7 +433,7 @@ impl PackEntities {
let posed = skin.map_or(0, |s| s.vertices);
if let Some(skin) = skin {
for (span, range, overlay) in skin_runs(skin) {
let program = material_program(&span, overlay, drawn).expect("tagged skin stage");
let program = program_of(&span).expect("tagged skin stage");
let pack = drawn[program] && !span.hidden;
let casts = shadow.cast(&span, program);
if !pack && !casts {
@@ -462,16 +448,14 @@ impl PackEntities {
casts,
overlay,
false,
false,
);
}
}
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
extend_without_portals(rest, &entities[..first.min(entities.len())]);
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
let mut water_patches = Vec::new();
let mut without_fluids = Vec::new();
for (span, range, material) in material_runs(entities, spans, drawn) {
for (span, range) in runs(entities.len(), spans) {
let run_start = range.start as u32;
let whole = &entities[range];
// A native fluid carries sprite-array UVs, not shared-atlas UVs.
@@ -499,27 +483,18 @@ impl PackEntities {
// gateway: its cube and its beam): each goes to its own program.
// Beacon beams come in a run of their own, at stage NONE (game.rs: no stage of 5.4
// is theirs), for gbuffers_beaconbeam.
let split = ((matches!(material, Some(BLOCK | BLOCK_TRANSLUCENT))
|| span.stage == render_stage("NONE"))
let split = (program_of(&span) == Some(BLOCK) || span.stage == render_stage("NONE"))
&& whole
.chunks(4)
.any(|q| matches!(q[0].layer & 0xffff, 6 | 9 | 10)))
// A boat's water patch: depth only (`Part::WaterMask`).
|| whole
.chunks(4)
.any(|q| q[0].layer & 0xffff == super::entity_batch::WATER_MASK_OVERLAY);
let parts: [(&[Vertex], Part); 4] = if split {
.any(|q| matches!(q[0].layer & 0xffff, 6 | 9 | 10));
let parts: [(&[Vertex], Part); 3] = if split {
other.clear();
portal_faces.clear();
beam_faces.clear();
water_patches.clear();
for quad in whole.chunks(4) {
match quad[0].layer & 0xffff {
9 | 10 => portal_faces.extend_from_slice(quad),
6 => beam_faces.extend_from_slice(quad),
super::entity_batch::WATER_MASK_OVERLAY => {
water_patches.extend_from_slice(quad)
}
_ => other.extend_from_slice(quad),
}
}
@@ -527,15 +502,9 @@ impl PackEntities {
(&other, Part::Plain),
(&portal_faces, Part::Portal),
(&beam_faces, Part::Beam),
(&water_patches, Part::WaterMask),
]
} else {
[
(whole, Part::Plain),
(&[], Part::Plain),
(&[], Part::Plain),
(&[], Part::Plain),
]
[(whole, Part::Plain), (&[], Part::Plain), (&[], Part::Plain)]
};
for (run, part) in parts {
if run.is_empty() {
@@ -544,9 +513,7 @@ impl PackEntities {
let program = match part {
Part::Portal => Some(BLOCK),
Part::Beam => Some(BEAM),
// Drawn by the entity program, whose depth-only pipeline it takes.
Part::WaterMask => material.filter(|&p| p == ENTITY),
Part::Plain => material.filter(|&p| {
Part::Plain => program_of(&span).filter(|&p| {
plain(run)
|| (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
}),
@@ -554,8 +521,7 @@ impl PackEntities {
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
// Portal faces cast too (a pack's shadow pass sees block entities whole, as Iris
// draws them); beams do not.
let casts = !matches!(part, Part::Beam | Part::WaterMask)
&& program.is_some_and(|p| shadow.cast(&span, p));
let casts = part != Part::Beam && program.is_some_and(|p| shadow.cast(&span, p));
if !pack && !span.hidden {
extend_without_portals(rest, run);
}
@@ -567,13 +533,8 @@ impl PackEntities {
Part::Beam => beam_segments(run, &mut self.scratch),
_ => self.scratch.extend_from_slice(run),
}
// A run copied whole keeps its vertices' order, for `at_velocity`; so does the
// start of a split one copied as it is (a boat's hull before its water patch).
let in_order = !split
|| (part == Part::Plain
&& bytemuck::cast_slice::<_, u8>(&whole[..run.len().min(whole.len())])
== bytemuck::cast_slice::<_, u8>(run));
if in_order {
// A run copied whole: its vertices keep their order, for `at_velocity`.
if !split {
copied.push((run_start, posed + start, run.len() as u32));
}
if program == DESTROY {
@@ -594,7 +555,6 @@ impl PackEntities {
casts,
overlay,
portal,
part == Part::WaterMask,
);
}
}
@@ -666,10 +626,9 @@ impl PackEntities {
casts: bool,
overlay: u32,
portal: bool,
water_mask: bool,
) {
let (entity_id, block_id) = match program {
BLOCK | BLOCK_TRANSLUCENT => (0, span.id),
BLOCK => (0, span.id),
_ => (span.id, 0),
};
let mut bytes = [0u8; SLOT as usize];
@@ -691,7 +650,6 @@ impl PackEntities {
shadow: casts,
slot: self.draws.len() as u32,
portal,
water_mask,
});
}
}
@@ -726,8 +684,6 @@ enum Part {
Plain,
Portal,
Beam,
/// A boat's water patch: depth only (`Draw::water_mask`).
WaterMask,
}
/// Beam faces (`block_entity_render::beam`: layer 6, v counting the sprite's repeats up the
@@ -819,76 +775,7 @@ pub const PARTICLE: usize = 3;
pub const DESTROY: usize = 4;
/// Beacon and End gateway beams (`gbuffers_beaconbeam`).
pub const BEAM: usize = 5;
pub const BLOCK_TRANSLUCENT: usize = 6;
pub const PARTICLE_TRANSLUCENT: usize = 7;
pub const PROGRAMS: usize = 8;
/// Translucent blocks retain the ordinary block shadow program and blockEntityId.
pub fn shadow_program(program: usize) -> usize {
if program == BLOCK_TRANSLUCENT {
BLOCK
} else {
program
}
}
fn material_program(span: &EntitySpan, layer: u32, drawn: [bool; PROGRAMS]) -> Option<usize> {
Some(match program_of(span)? {
BLOCK
if drawn[BLOCK_TRANSLUCENT]
&& (span.translucent
|| layer & super::OIT_ENTITY != 0
|| matches!(
layer & 0xffff,
super::TRANSLUCENT_ITEM_OVERLAY | super::TRANSLUCENT_GLINT_ITEM_OVERLAY
)) =>
{
BLOCK_TRANSLUCENT
}
PARTICLE
if drawn[PARTICLE_TRANSLUCENT]
&& layer & 0xffff == super::TRANSLUCENT_PARTICLE_OVERLAY =>
{
PARTICLE_TRANSLUCENT
}
p => p,
})
}
/// Split only where an available variant changes a quad's program. Keep contiguous runs,
/// their vertex order and the original single draw when no material variant is usable.
fn material_runs<'a>(
vertices: &'a [Vertex],
spans: &'a [EntitySpan],
drawn: [bool; PROGRAMS],
) -> impl Iterator<Item = (EntitySpan, Range<usize>, Option<usize>)> + 'a {
runs(vertices.len(), spans).flat_map(move |(span, range)| {
let split = match program_of(&span) {
Some(BLOCK) => drawn[BLOCK_TRANSLUCENT],
Some(PARTICLE) => drawn[PARTICLE_TRANSLUCENT],
_ => false,
};
let mut start = range.start;
std::iter::from_fn(move || {
if start >= range.end {
return None;
}
let program = material_program(&span, vertices[start].layer, drawn);
let end = if split {
(start..range.end)
.step_by(4)
.skip(1)
.find(|&i| material_program(&span, vertices[i].layer, drawn) != program)
.unwrap_or(range.end)
} else {
range.end
};
let run = start..end;
start = end;
Some((span, run, program))
})
})
}
pub const PROGRAMS: usize = 6;
/// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has;
/// `None`: Horizon draws it.
@@ -1046,17 +933,11 @@ mod tests {
assert_eq!(drawn_by_pack(&spans[0], all), Some(ENTITY));
assert_eq!(drawn_by_pack(&spans[1], all), Some(PARTICLE));
assert_eq!(
drawn_by_pack(
&spans[1],
[true, true, true, false, true, true, false, false]
),
drawn_by_pack(&spans[1], [true, true, true, false, true, true]),
None
);
assert_eq!(
drawn_by_pack(
&spans[2],
[true, false, true, true, true, true, false, false]
),
drawn_by_pack(&spans[2], [true, false, true, true, true, true]),
None
);
assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK));
@@ -1482,79 +1363,6 @@ mod gpu_span_tests {
);
}
/// A boat's water patch (`RenderTypes.waterMask`) is a draw of its own, depth only, that casts
/// no shadow; with no pack program for entities, Horizon draws it with the boat.
#[test]
fn a_boats_water_patch_draws_depth_only() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let layout = super::super::pack_draw::DrawUniforms::new(&device);
let mut geometry = PackEntities::new(&device, &layout.layout);
let mask = super::super::entity_batch::WATER_MASK_OVERLAY;
// One boat: a hull face, then the water patch.
let cpu: Vec<Vertex> = [0, 0, 0, 0, mask, mask, mask, mask]
.into_iter()
.map(|layer| Vertex {
layer,
..bytemuck::Zeroable::zeroed()
})
.collect();
let spans = [EntitySpan {
start: 0,
id: 7,
stage: render_stage("ENTITIES"),
..Default::default()
}];
let mut rest = Vec::new();
let shadows = Casters {
entities: true,
..Default::default()
};
let mut prepare = |drawn, rest: &mut Vec<Vertex>| {
geometry.prepare(
&device,
&queue,
&layout.layout,
&cpu,
&spans,
drawn,
([0.; 4], [1; 2]),
shadows,
rest,
None,
(&[], &[]),
);
let draws = (geometry.draws.iter())
.map(|d| (d.base, d.program, d.pack, d.shadow, d.water_mask))
.collect::<Vec<_>>();
let heights: Vec<f32> = geometry.scratch.iter().map(|v| v.pos[1]).collect();
(draws, heights)
};
assert_eq!(
prepare([true; PROGRAMS], &mut rest),
(
vec![
(0, ENTITY, true, true, false),
(4, ENTITY, true, false, true)
],
// Where they lie: the patch at the water line, not moved.
vec![0.0; 8]
)
);
assert!(rest.is_empty());
let mut no_entities = [true; PROGRAMS];
no_entities[ENTITY] = false;
let (draws, _) = prepare(no_entities, &mut rest);
assert!(draws.iter().all(|d| !d.2), "{draws:?}");
assert_eq!(rest.len(), 8, "the boat and its patch stay Horizon's");
}
#[test]
fn posed_and_cpu_draws_keep_offsets_uniforms_and_special_fallbacks() {
let instance = crate::gfx::test_gpu::instance();
@@ -1820,47 +1628,3 @@ mod velocity_translate_tests {
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod tier_translate_tests {
/// A pack's entity and hand programs may read `horizonTier`, the overlay's HDR tier.
#[test]
fn a_pack_reading_horizon_tier_translates_for_entities_and_the_hand() {
let dir = std::env::temp_dir().join(format!("hz-entity-tier-{}", std::process::id()));
let shaders = dir.join("shaders");
std::fs::create_dir_all(&shaders).unwrap();
std::fs::write(
shaders.join("gbuffers_entities.vsh"),
"#version 120\nattribute float horizonTier;\nvarying float t;\n\
void main() {\n gl_Position = ftransform();\n t = horizonTier;\n}\n",
)
.unwrap();
std::fs::write(
shaders.join("gbuffers_entities.fsh"),
"#version 120\nvarying float t;\nvoid main() {\n gl_FragData[0] = vec4(t);\n}\n",
)
.unwrap();
let pack = horizon_shaders::Pack::open(&dir).unwrap();
let m = horizon_shaders::Macros::standard();
let vsh = horizon_shaders::preprocess(&pack, "gbuffers_entities.vsh", &m);
let fsh = horizon_shaders::preprocess(&pack, "gbuffers_entities.fsh", &m);
for (who, inputs) in [
("entities", super::inputs()),
("hand", super::super::pack_hand::inputs()),
] {
let attribute = inputs.attributes.iter().find(|(n, _)| n == "horizonTier");
assert!(attribute.is_some(), "{who}");
horizon_shaders::translate_gbuffers(
&vsh,
&fsh,
horizon_shaders::Target::Buffers,
&inputs,
)
.unwrap_or_else(|e| panic!("{who}: {e:?}"));
}
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod material_route_tests;
@@ -1,62 +0,0 @@
use super::*;
#[test]
fn optional_variants_split_contiguous_materials_without_guessing_from_alpha() {
let mut vertices = Vec::new();
for layer in [
0,
super::super::OIT_ENTITY,
0,
super::super::TRANSLUCENT_PARTICLE_OVERLAY,
0,
] {
vertices.extend(
[Vertex {
layer: layer | 3 << 16,
color: 0x40808080,
..bytemuck::Zeroable::zeroed()
}; 4],
);
}
let spans = [
EntitySpan {
stage: render_stage("BLOCK_ENTITIES"),
id: 71,
..Default::default()
},
EntitySpan {
start: 8,
stage: render_stage("PARTICLES"),
..Default::default()
},
];
let collect = |drawn| {
material_runs(&vertices, &spans, drawn)
.map(|(_, range, p)| (range, p))
.collect::<Vec<_>>()
};
let mut legacy = [true; PROGRAMS];
legacy[BLOCK_TRANSLUCENT] = false;
legacy[PARTICLE_TRANSLUCENT] = false;
assert_eq!(
collect(legacy),
[(0..8, Some(BLOCK)), (8..20, Some(PARTICLE))]
);
assert_eq!(
collect([true; PROGRAMS]),
[
(0..4, Some(BLOCK)),
(4..8, Some(BLOCK_TRANSLUCENT)),
(8..12, Some(PARTICLE)),
(12..16, Some(PARTICLE_TRANSLUCENT)),
(16..20, Some(PARTICLE)),
]
);
let casters = Casters {
blocks: true,
..Default::default()
};
assert!(casters.cast(&spans[0], BLOCK_TRANSLUCENT));
assert_eq!(shadow_program(BLOCK_TRANSLUCENT), BLOCK);
assert!(!casters.cast(&spans[1], PARTICLE_TRANSLUCENT));
}
@@ -82,9 +82,6 @@ pub(super) fn format(f: Format, packed_float: bool) -> wgpu::TextureFormat {
_ => rgba,
};
match (f.kind, f.bits) {
(Kind::Unorm, 10) => T::Rgb10a2Unorm,
(Kind::Uint, 10) => T::Rgb10a2Uint,
(Kind::Snorm, 16) => pick(T::R16Snorm, T::Rg16Snorm, T::Rgba16Snorm),
(Kind::Int, 8) => pick(T::R8Sint, T::Rg8Sint, T::Rgba8Sint),
(Kind::Int, 16) => pick(T::R16Sint, T::Rg16Sint, T::Rgba16Sint),
(Kind::Int, _) => pick(T::R32Sint, T::Rg32Sint, T::Rgba32Sint),
@@ -52,9 +52,6 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
alpha_test: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
}
}
@@ -1,209 +0,0 @@
//! Paged maps (`paged_maps.rs`) through the translator's `hz_pagedTexture` and the pass's
//! binding: a pack reads the map's texels at the colour's coordinates.
use super::*;
use wgpu::util::DeviceExt;
/// The atlas the fixture's coordinates are in, and the one mapped texture's place.
const ATLAS: (u32, u32) = (64, 32);
const SPRITE: [u32; 4] = [20, 4, 20, 20];
/// The fixture's normal map texel at texel (x, y) of its texture.
fn texel(x: u32, y: u32) -> [u8; 4] {
[x as u8 * 10, y as u8 * 10, 7, 255]
}
#[test]
#[ignore = "requires a host GPU; missing adapter is a failure"]
fn a_pack_reads_paged_maps_at_the_colours_coordinates() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.unwrap();
let root = std::env::temp_dir().join(format!("horizon-paged-pack-{}", std::process::id()));
std::fs::create_dir_all(root.join("shaders")).unwrap();
std::fs::write(
root.join("shaders/gbuffers_terrain.vsh"),
"#version 120\nvoid main(){gl_Position=gl_Vertex;}",
)
.unwrap();
// Each pixel reads one atlas texel's centre: in the texture (on four pages), beside it,
// on a page without maps, and the specular map, which no texture has.
std::fs::write(
root.join("shaders/gbuffers_terrain.fsh"),
r#"#version 120
uniform sampler2D normals;
uniform sampler2D specular;
vec2 at(float x, float y) { return (vec2(x, y) + 0.5) / vec2(64.0, 32.0); }
void main() {
int x = int(floor(gl_FragCoord.x));
if (x == 0) gl_FragData[0] = texture2D(normals, at(20.0, 4.0));
else if (x == 1) gl_FragData[0] = texture2D(normals, at(39.0, 23.0));
else if (x == 2) gl_FragData[0] = texture2D(normals, at(31.0, 15.0), 2.0);
else if (x == 3) gl_FragData[0] = texture2DLod(normals, at(32.0, 16.0), 3.0);
else if (x == 4) gl_FragData[0] = texture2D(normals, at(19.0, 4.0));
else if (x == 5) gl_FragData[0] = texture2D(normals, at(50.0, 30.0));
else if (x == 6) gl_FragData[0] = texture2D(specular, at(25.0, 10.0));
else gl_FragData[0] = texture2D(normals, at(33.0, 5.0));
}
"#,
)
.unwrap();
let [x0, y0, w, h] = SPRITE;
let normal: Vec<u8> = (0..w * h).flat_map(|i| texel(i % w, i / w)).collect();
let [normals, specular] = crate::paged_maps::build(
ATLAS,
&[crate::paged_maps::SpriteMaps {
rect: SPRITE,
maps: [Some(normal), None],
}],
16384,
);
assert!(specular.is_none());
let normals = normals.unwrap();
let map = device
.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: Some("Fixture paged normal map"),
size: wgpu::Extent3d {
width: normals.width,
height: normals.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,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&normals.pixels,
)
.create_view(&Default::default());
let pack = horizon_shaders::Pack::open(&root).unwrap();
let macros = horizon_shaders::Macros::standard();
let properties = horizon_shaders::Properties::parse("alphaTest.gbuffers_terrain=off", &macros);
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
body: "vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); hz_Vertex=vec4(p*2.0-1.0,0.0,1.0);".into(),
paged_maps: true,
water_mask: false,
water_patches: false,
..Default::default()
}),
..Default::default()
};
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs);
assert!(post.terrain.is_some(), "{:?}", post.notes);
let world = World {
sprite_rects: &[],
atlas: None,
material_maps: None,
lightmap: map.clone(),
terrain: Some(Geometry {
buffers: &[],
table: None,
depth_write: true,
atlas: None,
material_maps: false,
ids: None,
cull: false,
}),
block_ids: &[],
celestial: None,
gui_size: [1, 1],
clouds: None,
weather: false,
hand: false,
};
let mut post = PackPost::new(
&device,
&queue,
&adapter,
post,
wgpu::TextureFormat::Rgba8Unorm,
world,
&Default::default(),
)
.expect("the pack runs");
let mut pass = post
.terrain
.take()
.unwrap_or_else(|| panic!("terrain pipeline must compile: {:?}", post.notes));
// As the entity passes are (`paged`), with the maps the renderer hands over.
pass.paged = true;
post.set_paged_maps([Some(map), None]);
post.copied |= 1;
post.resize(&device, [8, 1]);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let _clear = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Initialize fixture depth"),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &post.targets.as_ref().unwrap().depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
}
let group = post.bind(&device, &queue, &mut encoder, &mut pass, [8, 1]);
{
let mut render = post.begin(&mut encoder, &pass);
render.set_pipeline(pass.render());
render.set_bind_group(0, &group, &[]);
if pass.draw {
post.draws.bind(&mut render, "TERRAIN_SOLID");
}
render.draw(0..3, 0..1);
}
let output = post.targets.as_ref().unwrap().color[0].as_ref().unwrap()[0].texture();
encoder.copy_texture_to_buffer(
output.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
bytes_per_row: Some(256),
..Default::default()
},
},
wgpu::Extent3d {
width: 8,
height: 1,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = read.slice(..).get_mapped_range().unwrap();
let local = |x: u32, y: u32| texel(x - x0, y - y0);
let expected = [
local(20, 4),
local(39, 23),
local(31, 15),
local(32, 16),
crate::sprites::FLAT_NORMAL,
crate::sprites::FLAT_NORMAL,
crate::sprites::NO_SPECULAR,
local(33, 5),
];
for (i, e) in expected.iter().enumerate() {
assert_eq!(&data[i * 4..i * 4 + 4], e, "pixel {i}");
}
std::fs::remove_dir_all(root).unwrap();
}
+58 -314
View File
@@ -19,7 +19,6 @@ use std::collections::HashMap;
use glam::{DMat4, DVec3};
use horizon_shaders::{Format, Group, Kind, Post, SHADOW_COLORS};
use smallvec::SmallVec;
use super::pack_draw::DrawUniforms;
use super::pack_entities::{PROGRAMS, PackEntities};
@@ -62,9 +61,6 @@ mod shadow_faces_tests;
#[cfg(test)]
mod r11f_image_tests;
#[cfg(test)]
mod work_group_fold_tests;
#[cfg(test)]
mod forced_flip_tests;
@@ -126,8 +122,6 @@ enum Source {
ShadowDepth(usize, bool),
/// `shadowcolor0/1` read outside the shadow programs (in them they are `Color`).
ShadowColor(usize),
/// The boats' water patches' depth (`horizon_shaders::WATER_MASK`), for translucent terrain.
WaterMask,
}
#[derive(Clone)]
@@ -163,11 +157,9 @@ impl SamplerKind {
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy if !filtering => {
Self::Nearest
}
Source::Depth
| Source::Depth1
| Source::Depth2
| Source::ShadowDepth(_, false)
| Source::WaterMask => Self::Nearest,
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(_, false) => {
Self::Nearest
}
Source::Noise => Self::Repeat,
Source::Shadow | Source::ShadowDepth(_, true) => Self::Comparison,
// Blocks look as the game draws them: nearest texels. Between levels, Iris 1.11.7
@@ -411,9 +403,6 @@ struct Pass {
/// The same program as vanilla blends the sky's parts (`PostProgram::vanilla_sky_blends`):
/// without blending for the disc and the void, then with `STARS_BLEND` for the stars.
sky_parts: Option<[Slot<wgpu::RenderPipeline>; 2]>,
/// The entity program without colour writes, for the boats' water patches
/// (`RenderTypes.waterMask`: depth only, so no water shows inside a hull).
water_mask: Option<Slot<wgpu::RenderPipeline>>,
layout: wgpu::BindGroupLayout,
ubo: wgpu::Buffer,
block: Vec<u8>,
@@ -441,9 +430,6 @@ struct Pass {
/// What `texture` reads instead of the block atlas.
atlas: Option<wgpu::TextureView>,
material_maps: bool,
/// Draws the shared atlas's geometry: `normals` and `specular` are its paged maps
/// (`PackPost::set_paged_maps`), as its programs were translated to read them.
paged: bool,
/// Reads `mc_Entity`'s table (`PackPost::block_ids`).
ids: bool,
/// Bind groups by the sides read (bits 0–15), the buffers whose mip chain is current (bits
@@ -519,8 +505,6 @@ struct Targets {
depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
/// `depthtex2`, when a program after the hand reads it without the hand.
depth2: Option<(wgpu::Texture, wgpu::TextureView)>,
/// The boats' water patches' depth, when translucent terrain reads it (`Source::WaterMask`).
water_mask: Option<(wgpu::Texture, wgpu::TextureView)>,
/// Both sides' mip chains of the buffers with mipmaps (`color` is their first level).
mips: [Option<[Mips; 2]>; BUFFERS],
/// Where a pass that both samples and stores a buffer samples it from.
@@ -543,7 +527,8 @@ pub(super) struct World<'a> {
pub lightmap: wgpu::TextureView,
/// Terrain's geometry, when the pack draws it.
pub terrain: Option<Geometry<'a>>,
/// Per source block state: its model material id, then its fluid material id.
/// Of each block sprite layer, `mc_Entity` (id, then 1 for fluids or 0) and `at_midBlock.w`
/// (its block's light): three values a layer.
pub block_ids: &'a [i32],
/// The atlas of the sun and moon's sprites.
pub celestial: Option<wgpu::TextureView>,
@@ -658,11 +643,6 @@ pub struct PackPost {
depth1: bool,
/// A program after the hand reads `depthtex2`: it is copied before the hand.
depth2: bool,
/// Translucent terrain reads the boats' water patches (`Targets::water_mask`).
water_mask: bool,
/// The water patches' depth holds patches (or was never cleared): cleared before the next
/// frame's are drawn.
water_mask_dirty: bool,
/// The shadow buffers, when the shadow pass runs (2.5), and the `shadowcomp` programs.
shadow_targets: Option<super::pack_shadow::ShadowTargets>,
/// What draws terrain from the light (`shadow`), and translucent terrain (`shadow_water`).
@@ -681,10 +661,10 @@ pub struct PackPost {
down: HashMap<wgpu::TextureFormat, (wgpu::RenderPipeline, wgpu::BindGroupLayout)>,
/// The per-draw uniforms of gbuffers programs.
draws: DrawUniforms,
/// Set 3 of flat terrain: an unknown-source fallback and immutable state/sprite tables.
/// Each page's bind group tracks both its vertex buffer and optional source sidecar.
/// Set 3 of flat terrain (`mc_Entity`'s table, then the sprites' places) and its bind group
/// for each arena page, with the page buffer it was made for.
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer)>,
page_groups: HashMap<u32, (wgpu::Buffer, Option<wgpu::Buffer>, wgpu::BindGroup)>,
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
gui_size: [u32; 2],
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<super::pack_timing::PassTiming>,
@@ -694,8 +674,6 @@ pub struct PackPost {
custom: horizon_shaders::Custom,
atlas: Option<wgpu::TextureView>,
material_maps: Option<[wgpu::TextureView; 2]>,
/// The shared atlas's normal and specular paged maps (`set_paged_maps`).
paged_maps: [Option<wgpu::TextureView>; 2],
lightmap: wgpu::TextureView,
deferred_pre: u16,
composite_pre: u16,
@@ -922,7 +900,6 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
"lightmap" => Source::Lightmap,
"normals" => Source::Normals,
"specular" => Source::Specular,
horizon_shaders::WATER_MASK => Source::WaterMask,
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
}
}
@@ -1225,44 +1202,30 @@ pub(super) struct ShadowView {
pub depth_planes: bool,
}
/// An arena page's key, native vertices and optional per-quad source records.
pub(super) type TerrainPage = (u32, wgpu::Buffer, Option<wgpu::Buffer>);
/// Header (sprite-rect words, state count), sprite rectangles, then model/fluid ids.
/// Keeping immutable tables together leaves the existing three storage bindings intact.
pub(super) fn terrain_tables(table: &[i32], rects: &[u32]) -> Vec<i32> {
let rects = if rects.is_empty() { &[0, 0][..] } else { rects };
[rects.len() as i32, (table.len() / 2) as i32]
.into_iter()
.chain(rects.iter().map(|&v| v as i32))
.chain(table.iter().copied())
.collect()
}
pub(super) fn block_ids(
/// Set 3 of flat terrain, read by the vertex stage: the sprite layers' table (`mc_Entity`'s id
/// and fluid, `at_midBlock.w`; -1, -1, 0 when empty), per arena page, the page's vertices
/// (a vertex reads its quad's corners for its normal), and each sprite's place in a stitched
/// atlas (`rects`; zeros for a grid, which doesn't read them).
fn block_ids(
device: &wgpu::Device,
queue: &wgpu::Queue,
table: &[i32],
rects: &[u32],
) -> Option<(wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer)> {
) -> (wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer) {
let empty = [-1, -1, 0];
let table = if table.is_empty() { &empty[..] } else { table };
let buffer = |label, contents: &[u8]| {
if contents.len() as u64 > device.limits().max_storage_buffer_binding_size {
return None;
}
let buffer = super::arena::try_create(
wgpu::util::DeviceExt::create_buffer_init(
device,
label,
contents.len() as u64,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
)?;
queue.write_buffer(&buffer, 0, contents);
Some(buffer)
&wgpu::util::BufferInitDescriptor {
label: Some(label),
contents,
usage: wgpu::BufferUsages::STORAGE,
},
)
};
let ids = buffer("Unknown terrain sources", &[0; 8])?;
let rects = buffer(
"Shader pack terrain tables",
bytemuck::cast_slice(&terrain_tables(table, rects)),
)?;
let rects = if rects.is_empty() { &[0, 0][..] } else { rects };
let ids = buffer("Shader pack block ids", bytemuck::cast_slice(table));
let rects = buffer("Shader pack sprite rects", bytemuck::cast_slice(rects));
let storage = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX,
@@ -1277,7 +1240,7 @@ pub(super) fn block_ids(
label: Some("Shader pack terrain"),
entries: &[storage(0), storage(1), storage(2)],
});
Some((layout, ids, rects))
(layout, ids, rects)
}
/// The game's geometry a gbuffers program draws: its vertex buffers and, for flat draws
@@ -1663,11 +1626,9 @@ fn bindings(
Source::ShadowColor(n) => shadow
.and_then(|f| f[n].sample_type(None, Some(features)))
.unwrap_or(float),
Source::Depth
| Source::Depth1
| Source::Depth2
| Source::ShadowDepth(..)
| Source::WaterMask => wgpu::TextureSampleType::Float { filterable: false },
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(..) => {
wgpu::TextureSampleType::Float { filterable: false }
}
// Unfilterable on WebGPU-level devices; the lightmap is sampled at texel centres.
Source::Lightmap => float,
_ => float,
@@ -1951,7 +1912,6 @@ fn build_pass(
custom: &[(String, String)],
draws: &wgpu::BindGroupLayout,
pack: &Resources<'_>,
water_mask: bool,
) -> Option<Pass> {
let features = device.features();
// The frame's uniforms, then the pack's custom ones.
@@ -2166,32 +2126,9 @@ fn build_pass(
)
})
});
// The water patches' pipeline: the same program, depth only.
let water_mask = water_mask.then(|| {
let targets = (job.targets.iter())
.map(|t| {
t.clone().map(|t| wgpu::ColorTargetState {
write_mask: wgpu::ColorWrites::empty(),
..t
})
})
.collect();
pack.render_pipeline(
device,
RenderJob {
targets,
..job.clone()
},
)
});
(
layout,
pack.render_pipeline(device, job),
sky_parts,
water_mask,
)
(layout, pack.render_pipeline(device, job), sky_parts)
});
let (layout, pipeline, sky_parts, water_mask) = match created {
let (layout, pipeline, sky_parts) = match created {
Ok(created) => created,
Err(e) => {
notes.push(format!("{}: skipped: {e}", p.name));
@@ -2231,7 +2168,6 @@ fn build_pass(
group: p.group,
pipeline: Pipeline::Render(pipeline),
sky_parts,
water_mask,
layout,
samplers,
writes,
@@ -2243,7 +2179,6 @@ fn build_pass(
draw,
atlas: geometry.and_then(|g| g.atlas.clone()),
material_maps: geometry.is_some_and(|g| g.material_maps),
paged: false,
ids: geometry.is_some_and(|g| g.ids.is_some()),
groups: HashMap::new(),
portal_groups: HashMap::new(),
@@ -2371,7 +2306,6 @@ fn build_compute(
local,
},
sky_parts: None,
water_mask: None,
layout,
samplers,
writes: Vec::new(),
@@ -2385,7 +2319,6 @@ fn build_compute(
draw: false,
atlas: None,
material_maps: false,
paged: false,
ids: false,
groups: HashMap::new(),
portal_groups: HashMap::new(),
@@ -2424,13 +2357,6 @@ impl Refused {
}
impl PackPost {
/// The shared atlas's normal and specular paged maps (`paged_maps.rs`), which the programs
/// drawing its geometry read as `normals` and `specular`; set before the first frame (a pass's
/// bind groups are kept once made). A missing one reads as the dummy (flat, no specular).
pub fn set_paged_maps(&mut self, maps: [Option<wgpu::TextureView>; 2]) {
self.paged_maps = maps;
}
/// Builds the pipelines of `post` for a screen of `screen` format. An error when the pack
/// can't show anything on the screen: the game turns it off, as a pack that fails to load.
///
@@ -2521,12 +2447,6 @@ impl PackPost {
let textures = pack_textures::upload(device, queue, post.textures, &mut notes);
let shadow_config = post.config.shadow.clone();
let noise_size = post.config.noise_resolution.unwrap_or(256);
let half_lives = post.config.half_lives;
for (name, ticks) in horizon_shaders::HALF_LIVES.iter().zip(half_lives) {
if let Some(t) = ticks {
notes.push(format!("`{name}` is {t} ticks"));
}
}
// shadowcolor2 to 7 only for a pack that names `HIGHER_SHADOWCOLOR`.
let shadow_colors = (1u16 << post.shadow_colors) - 1;
let mut custom = post.custom;
@@ -2640,22 +2560,6 @@ impl PackPost {
.fold(0, |m, n| m | 1 << n),
jobs: defer.then(Default::default),
};
// Flat terrain resolves each quad's source state against the current pack.
let block_ids = world
.terrain
.as_ref()
.filter(|g| g.table.is_some())
.map(|_| {
block_ids(device, queue, world.block_ids, world.sprite_rects).ok_or_else(|| {
Refused {
reason: "terrain source tables could not be allocated".into(),
notes: notes.clone(),
}
})
})
.transpose()?;
// The next pass built also gets a depth-only pipeline for the boats' water patches.
let water_mask_next = std::cell::Cell::new(false);
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
// Not wanted any more: the rest is skipped and the build refused below.
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
@@ -2706,7 +2610,6 @@ impl PackPost {
&custom_names,
&draws.layout,
&pack,
water_mask_next.get(),
)?
};
pass.compute.append(&mut computes);
@@ -2768,6 +2671,12 @@ impl PackPost {
}
Some(pass)
};
// Flat terrain reads `mc_Entity` from a table of the sprite layers.
let block_ids = world
.terrain
.as_ref()
.filter(|g| g.table.is_some())
.map(|_| block_ids(device, world.block_ids, world.sprite_rects));
let terrain_geometry = world.terrain.as_ref().map(|g| Geometry {
buffers: g.buffers,
table: g.table,
@@ -2847,18 +2756,14 @@ impl PackPost {
post.particles,
post.damaged_block,
post.beacon_beam,
post.block_translucent,
post.particles_translucent,
];
let mut late = [
let late = [
false,
false,
post.translucent_late,
post.particles_late,
false,
true,
post.translucent_late,
true,
];
let shadow_only = (post.shadow_only_player, post.shadow_only_lit_blocks);
let shadow_programs = [post.shadow_entities, post.shadow_block];
@@ -2875,18 +2780,7 @@ impl PackPost {
ids: None,
cull: false,
});
// Programs drawing the shared atlas's geometry read its paged maps (`pack_entities::inputs`).
let paged = |pass: Option<Pass>| {
pass.map(|mut pass| {
pass.paged = true;
pass
})
};
let mut kind = 0;
let entity_passes = programs.map(|p| {
// The entity program draws the boats, and their water patches (`Draw::water_mask`).
water_mask_next.set(kind == super::pack_entities::ENTITY);
kind += 1;
p.zip(entity_geometry.as_ref()).and_then(|(p, g)| {
if p.name == "gbuffers_damagedblock" {
let decal = Geometry {
@@ -2898,21 +2792,15 @@ impl PackPost {
ids: g.ids,
cull: false,
};
paged(build(p, Some(&decal)))
build(p, Some(&decal))
} else {
paged(build(p, Some(g)))
build(p, Some(g))
}
})
});
// Translation alone is insufficient: a refused GPU pipeline must retain the
// legacy single particle pass and its timing as well as its geometry grouping.
if entity_passes[super::pack_entities::PARTICLE_TRANSLUCENT].is_some() {
late[super::pack_entities::PARTICLE] = false;
}
water_mask_next.set(false);
let shadow_entities = shadow_programs.map(|p| {
p.zip(entity_geometry.as_ref())
.and_then(|(p, g)| paged(build(p, Some(g))))
.and_then(|(p, g)| build(p, Some(g)))
});
let entities = entity_vertices
.filter(|_| (entity_passes.iter().chain(&shadow_entities)).any(Option::is_some))
@@ -2933,12 +2821,10 @@ impl PackPost {
cull: false,
});
let hides_clouds = post.clouds_off || post.clouds.is_some() && cloud_geometry.is_some();
// Rain and snow test depth but never write it: Iris 1.11.7 left none of either in
// `depthtex0`, whatever `rain.depth` said (probe packs of ours, #10492).
let weather_geometry = world.weather.then(|| Geometry {
buffers: &entity_buffers,
table: None,
depth_write: false,
depth_write: post.rain_depth,
atlas: None,
material_maps: false,
ids: None,
@@ -2957,7 +2843,7 @@ impl PackPost {
ids: None,
cull: false,
};
paged(build(p, Some(&g))).map(|pass| (pass, geometry))
build(p, Some(&g)).map(|pass| (pass, geometry))
});
// Translucent held items, drawn from the hand's own buffer.
let hand_water = post.hand_water.and_then(|p| {
@@ -2971,7 +2857,7 @@ impl PackPost {
ids: None,
cull: false,
};
paged(build(p, Some(&g)))
build(p, Some(&g))
});
let weather = post
.weather
@@ -3137,9 +3023,6 @@ impl PackPost {
let depth1 = (water.is_some() || hand.is_some())
&& (reads_depth(Source::Depth1) || (hand.is_none() && reads_depth2));
let depth2 = hand.is_some() && reads_depth2;
let water_mask = (water.iter())
.flat_map(|p| &p.samplers)
.any(|s| s.source == Source::WaterMask);
let every_pass = || {
(passes
.iter()
@@ -3395,8 +3278,6 @@ impl PackPost {
hand_water,
depth1,
depth2,
water_mask,
water_mask_dirty: true,
shadow_targets,
shadow_terrain,
portal_rect: [0.0; 4],
@@ -3415,7 +3296,6 @@ impl PackPost {
custom,
atlas: world.atlas.as_ref().map(|a| a.0.clone()),
material_maps: world.material_maps,
paged_maps: [None, None],
lightmap: world.lightmap,
deferred_pre: mask(&post.deferred_pre),
composite_pre: mask(&post.composite_pre),
@@ -3538,7 +3418,6 @@ impl PackPost {
let mut u = FrameUniforms::default();
u.atlas = world.atlas.map_or([0; 2], |a| a.1);
u.shadow = shadow_config;
u.half_lives = half_lives;
u
},
shadow_cache: Default::default(),
@@ -3728,7 +3607,6 @@ impl PackPost {
}
}
Source::Depth => &targets.depth,
Source::WaterMask => targets.water_mask.as_ref().map_or(&self.dummy, |m| &m.1),
// Before translucent terrain the two are the same.
Source::Depth1 => match &targets.depth1 {
Some(d)
@@ -3778,12 +3656,6 @@ impl PackPost {
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D1 => {
&self.dummy_1d
}
Source::Normals if pass.paged => {
self.paged_maps[0].as_ref().unwrap_or(&self.dummy_normal)
}
Source::Specular if pass.paged => {
self.paged_maps[1].as_ref().unwrap_or(&self.dummy_black)
}
Source::Normals => self
.material_maps
.as_ref()
@@ -3884,16 +3756,6 @@ impl PackPost {
[n(0), n(1), 1]
});
let (pipeline, groups) = (pipeline.clone(), groups);
// wgpu panics on a dispatch over its limit. The translator reshapes a large
// `workGroups`; a `workGroupsRender` that large is not run.
let most = device.limits().max_compute_workgroups_per_dimension;
if groups.iter().any(|&n| n > most) {
log::warn!(
"shader pack: {}: {groups:?} work groups is over {most} per dimension; not run",
pass.name
);
return;
}
for image in (0..self.images.len()).filter(|k| pass.snapshots & 1 << k != 0) {
pack_images::snapshot(encoder, &self.images[image]);
}
@@ -3922,16 +3784,6 @@ impl PackPost {
&mut self,
encoder: &'e mut wgpu::CommandEncoder,
pass: &Pass,
) -> wgpu::RenderPass<'e> {
self.begin_with(encoder, pass, false)
}
/// `begin`, or with `water_mask` into the water patches' depth instead of the frame's.
fn begin_with<'e>(
&mut self,
encoder: &'e mut wgpu::CommandEncoder,
pass: &Pass,
water_mask: bool,
) -> wgpu::RenderPass<'e> {
let targets = self.targets.as_ref().expect("resized");
let load = wgpu::Operations {
@@ -3945,8 +3797,7 @@ impl PackPost {
_ => (super::pack_shadow::load(), [load; SHADOW_COLORS]),
};
let shadow = self.shadow_targets.as_ref().filter(|_| pass.group.shadow());
let mut attachments: SmallVec<[Option<wgpu::RenderPassColorAttachment>; BUFFERS]> =
SmallVec::new();
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
for &(location, buffer) in &pass.writes {
let l = location as usize;
if attachments.len() <= l {
@@ -3967,10 +3818,7 @@ impl PackPost {
color_attachments: &attachments,
timestamp_writes,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: match &targets.water_mask {
Some((_, view)) if water_mask => view,
_ => shadow.map_or(&targets.depth, |s| &s.depth.1),
},
view: shadow.map_or(&targets.depth, |s| &s.depth.1),
depth_ops: Some(depth_ops),
stencil_ops: None,
}),
@@ -4042,7 +3890,7 @@ impl PackPost {
}
let (groups, ids) = (&self.page_groups, pass.ids);
let page = |rp: &mut wgpu::RenderPass<'_>, page: u32| {
if let Some((_, _, group)) = groups.get(&page).filter(|_| ids) {
if let Some((_, group)) = groups.get(&page).filter(|_| ids) {
rp.set_bind_group(3, group, &[]);
}
};
@@ -4153,11 +4001,8 @@ impl PackPost {
let mut draws = geometry
.draws
.iter()
.filter(|d| d.pack && d.program == program && !d.water_mask)
.filter(|d| d.pack && d.program == program)
.peekable();
let masks: Vec<_> = (geometry.draws.iter())
.filter(|d| d.pack && d.program == program && d.water_mask)
.collect();
if draws.peek().is_none() {
continue;
}
@@ -4199,35 +4044,6 @@ impl PackPost {
rp.draw_indexed(0..d.quads * 6, d.base as i32, 0..1);
}
}
// The boats' water patches, depth only, into their own depth: translucent terrain
// discards what lies behind them, and the frame's depth stays the pack's.
if let Some(mask) = pass.water_mask.as_ref().and_then(|m| m.get())
&& !masks.is_empty()
&& self
.targets
.as_ref()
.is_some_and(|t| t.water_mask.is_some())
{
let mut rp = self.begin_with(encoder, pass, true);
rp.set_pipeline(mask);
rp.set_bind_group(0, &group, &[]);
if pass.buffers
&& let Some(g) = &self.buffers.group
{
rp.set_bind_group(4, g, &[]);
}
rp.set_bind_group(1, &geometry.table_group, &[]);
rp.set_vertex_buffer(0, geometry.vertices());
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
for d in &masks {
if pass.draw {
rp.set_bind_group(2, &geometry.group, &[geometry.offset(d.slot)]);
}
rp.draw_indexed(0..d.quads * 6, d.base as i32, 0..1);
}
drop(rp);
self.water_mask_dirty = true;
}
self.mips_valid &= !pass.written;
}
self.entities = Some(entities);
@@ -4292,7 +4108,7 @@ impl PackPost {
continue;
};
let mut draws = (geometry.draws.iter())
.filter(|d| d.shadow && super::pack_entities::shadow_program(d.program) == program)
.filter(|d| d.shadow && d.program == program)
.peekable();
if draws.peek().is_none() {
continue;
@@ -4304,11 +4120,7 @@ impl PackPost {
encoder,
pass,
size,
(geometry.draws.iter()).any(|d| {
d.shadow
&& super::pack_entities::shadow_program(d.program) == program
&& d.portal
}),
(geometry.draws.iter()).any(|d| d.shadow && d.program == program && d.portal),
);
let mut on_portal = false;
let mut rp = self.begin(encoder, pass);
@@ -5009,16 +4821,6 @@ impl PackPost {
let view = t.create_view(&Default::default());
(t, view)
});
let water_mask = self.water_mask.then(|| {
let t = texture(
"Shader pack water patches",
depth32,
wgpu::TextureUsages::RENDER_ATTACHMENT,
);
let view = t.create_view(&Default::default());
(t, view)
});
self.water_mask_dirty = true;
let scratch = std::array::from_fn(|i| {
(self.copied & 1 << i != 0).then(|| {
// A copy also read as an image before a dispatch.
@@ -5053,7 +4855,6 @@ impl PackPost {
depth_texture,
depth1,
depth2,
water_mask,
mips,
});
self.forget_groups();
@@ -5153,8 +4954,7 @@ impl PackPost {
}
let group = self.bind(device, queue, encoder, pass, size);
let t = self.shadow_targets.as_ref().expect("shadow pass");
let mut attachments: SmallVec<[Option<wgpu::RenderPassColorAttachment>; BUFFERS]> =
SmallVec::new();
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
for &(location, buffer) in &pass.writes {
let l = location as usize;
if attachments.len() <= l {
@@ -5183,7 +4983,6 @@ impl PackPost {
}
rp.draw(0..6, 0..1);
}
drop(attachments);
if let Some(t) = &mut self.shadow_targets {
t.main ^= pass.flip;
}
@@ -5306,28 +5105,10 @@ impl PackPost {
inputs: &PackInputs,
size: [u32; 2],
window: [u32; 2],
pages: &[TerrainPage],
pages: &[(u32, wgpu::Buffer)],
terrain: &mut TerrainDraw<'_>,
) {
self.resize(device, size);
// Last frame's water patches (or a new texture's zeros) out: depth 1 is no patch.
if self.water_mask_dirty
&& let Some((_, view)) = self.targets.as_ref().and_then(|t| t.water_mask.as_ref())
{
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack water patches clear"),
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
self.water_mask_dirty = false;
}
// A resolution scale leaves the picture smaller (or larger) than the window: the pack's
// `final` draws it at the picture's size, and one copy takes it to the window (#9291).
let final_view = if size != window && self.passes.iter().any(|p| p.group == Group::Final) {
@@ -5360,8 +5141,8 @@ impl PackPost {
if let Some((layout, ids, rects)) = &self.block_ids {
// A page that grew has a new buffer.
self.page_groups
.retain(|p, (b, s, _)| pages.iter().any(|(q, c, t)| q == p && c == b && s == t));
for (page, buffer, sources) in pages {
.retain(|p, (b, _)| pages.iter().any(|(q, c)| q == p && c == b));
for (page, buffer) in pages {
self.page_groups.entry(*page).or_insert_with(|| {
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack terrain"),
@@ -5369,7 +5150,7 @@ impl PackPost {
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: sources.as_ref().unwrap_or(ids).as_entire_binding(),
resource: ids.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
@@ -5381,7 +5162,7 @@ impl PackPost {
},
],
});
(buffer.clone(), sources.clone(), group)
(buffer.clone(), group)
});
}
}
@@ -5675,8 +5456,7 @@ impl PackPost {
.as_ref()
.map(|t| &t[usize::from(self.main & 1 << i == 0)])
};
let mut attachments: SmallVec<[Option<wgpu::RenderPassColorAttachment>; BUFFERS]> =
SmallVec::new();
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
// Vertex coverage and fragment discard can leave alternate pixels untouched.
// The unused sink can still skip loading its old contents.
let ops = if !pass.sink || pass.viewport.is_some() {
@@ -6348,7 +6128,6 @@ mod tests {
&[],
&draws.layout,
&pack,
false,
)
.unwrap_or_else(|| panic!("the pass builds: {notes:?}"));
// colortex3 holds (0.75, 0.5, 0.25, 0.5) before the pass.
@@ -6477,34 +6256,6 @@ mod tests {
);
}
#[test]
fn an_image_is_made_in_its_layout_qualifiers_format() {
// `image.x = … RGB10_A2 …` read through `layout(rgb10_a2) image2D x`.
for q in [
"rgba32f",
"rgba16f",
"rg16f",
"r11f_g11f_b10f",
"r32f",
"rgba16",
"rgb10_a2",
"rgba8",
"rg8",
"r16",
"rgba16_snorm",
"rgba8_snorm",
"r8_snorm",
"rgba32i",
"r16i",
"rgb10_a2ui",
"rgba8ui",
"r32ui",
] {
let def = Format::parse(q).unwrap();
assert_eq!(Some(pack_images::format(def, true)), image_format(q), "{q}");
}
}
#[test]
fn samplers_read_custom_textures_of_their_type() {
use wgpu::TextureSampleType as T;
@@ -6550,10 +6301,6 @@ mod destination_tests;
#[path = "pack_material_tests.rs"]
mod material_tests;
#[cfg(test)]
#[path = "pack_paged_maps_tests.rs"]
mod paged_maps_tests;
#[cfg(test)]
mod rgb_alpha_tests;
@@ -6586,6 +6333,3 @@ mod mipmap_box_tests;
#[cfg(test)]
mod colortex_mip_blend_tests;
#[cfg(test)]
mod material_route_tests;
@@ -322,7 +322,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
let inputs=horizon_shaders::GameInputs {
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false,paged_maps:false,water_mask:false,water_patches:false }),
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false }),
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
};
let mut post = gpu.load(
@@ -719,9 +719,6 @@ fn shadow_depth_map_reads(
alpha_test: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
}),
..Default::default()
};
@@ -1,134 +0,0 @@
//! Original end-to-end material routing, timing and blending control; no pack assets.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::gfx::{OIT_ENTITY, TRANSLUCENT_PARTICLE_OVERLAY, Vertex, pack_draw::EntitySpan};
use crate::hud::HudBatch;
use horizon_shaders::render_stage;
use wgpu::TextureFormat as T;
#[test]
#[ignore = "requires an offscreen graphics adapter; run explicitly"]
fn authored_material_programs_render_and_legacy_draws_stay_unsplit() {
let gpu = Gpu::new().expect("an executed adapter is required for this control");
let mut vertices = Vec::new();
for (i, layer) in [0, OIT_ENTITY, 0, TRANSLUCENT_PARTICLE_OVERLAY]
.into_iter()
.enumerate()
{
let x = -1.0 + i as f32 * 0.5;
for pos in [
[x, -1.0, 0.0],
[x + 0.5, -1.0, 0.0],
[x + 0.5, 1.0, 0.0],
[x, 1.0, 0.0],
] {
vertices.push(Vertex {
pos,
layer: layer | 2 << 16,
color: 0xffff_ffff,
..bytemuck::Zeroable::zeroed()
});
}
}
let spans = [
EntitySpan {
stage: render_stage("BLOCK_ENTITIES"),
id: 17,
..Default::default()
},
EntitySpan {
start: 8,
stage: render_stage("PARTICLES"),
..Default::default()
},
];
for variants in [false, true] {
let vertex = "#version 120\nvoid main(){gl_Position=gl_Vertex;}";
let mut files = vec![
("final.vsh".into(), VERTEX.into()),
("final.fsh".into(), "#version 120\nuniform sampler2D colortex0; varying vec2 uv; void main(){gl_FragColor=vec4(texture2D(colortex0,uv).rgb,1.0);}".into()),
("deferred.vsh".into(), VERTEX.into()),
("deferred.fsh".into(), "#version 120\nuniform sampler2D colortex0; varying vec2 uv; void main(){gl_FragData[0]=vec4(texture2D(colortex0,uv).rgb*0.5,1.0);}".into()),
("shaders.properties".into(), "particles.ordering=mixed\nseparateEntityDraws=true\nblend.gbuffers_block=off\nblend.gbuffers_particles=off\nblend.gbuffers_block_translucent=SRC_ALPHA ONE_MINUS_SRC_ALPHA ZERO ZERO\nblend.gbuffers_particles_translucent=SRC_ALPHA ONE_MINUS_SRC_ALPHA ZERO ZERO\n".into()),
];
let programs = [
("gbuffers_block", "vec4(1.0,0.0,0.0,1.0)"),
("gbuffers_particles", "vec4(0.0,0.0,1.0,1.0)"),
("gbuffers_block_translucent", "vec4(0.0,1.0,0.0,0.5)"),
("gbuffers_particles_translucent", "vec4(1.0,1.0,0.0,0.5)"),
];
for (name, color) in programs.into_iter().take(if variants { 4 } else { 2 }) {
let expected_id = if name.starts_with("gbuffers_block") {
17
} else {
0
};
files.push((format!("{name}.vsh"), vertex.into()));
files.push((format!("{name}.fsh"), format!("#version 120\nuniform int blockEntityId; void main(){{gl_FragData[0]={color}; if (blockEntityId != {expected_id}) gl_FragData[0]=vec4(1.0);}}")));
}
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
let mut input = super::super::pack_entities::inputs();
input.body = "hz_Vertex=vec4(hz_v_pos,1.0); hz_Color=vec4(1.0);".into();
let mut post = gpu.load(
files,
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs {
entities: Some(input),
..Default::default()
},
);
let hud = HudBatch::new(SIZE);
for frame in 0..2 {
gpu.clear(&color, &depth, 0.0);
let mut rest = Vec::new();
post.prepare_entities(
&gpu.device,
&gpu.queue,
&vertices,
&spans,
[0.; 4],
[0.; 4],
&mut rest,
None,
(&[], &[]),
);
assert!(rest.is_empty());
let entities = post.entities.as_ref().unwrap();
assert_eq!(entities.geometry.draws.len(), if variants { 4 } else { 2 });
let pixels = gpu.render(
&mut post,
&color,
&depth,
&fixture_frame(&hud),
&mut |_, _, _, _| {},
);
// Early opaque draws are halved by deferred. Late variants blend alpha 1/2
// over world clear 1/16 halved by deferred, giving 1/64 + color/2.
let expected = if variants {
[[128, 0, 0], [4, 132, 4], [0, 0, 128], [132, 132, 4]]
} else {
[[128, 0, 0], [128, 0, 0], [0, 0, 255], [0, 0, 255]]
};
for (i, rgb) in expected.into_iter().enumerate() {
let pixel = pixels[(SIZE[1] / 2 * SIZE[0] + i as u32 * 4 + 2) as usize];
eprintln!(
"MATERIAL_ROUTE variants={variants} frame={frame} strip={i} actual={pixel:?} expected={rgb:?}"
);
assert!(pixel[..3].iter().zip(rgb).all(|(&a, b)| a.abs_diff(b) <= 2));
}
}
}
}
@@ -46,9 +46,6 @@ fn pack() -> Post {
terrain: Some(horizon_shaders::GbuffersInputs {
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
@@ -5,7 +5,7 @@ use wgpu::util::DeviceExt;
#[test]
#[ignore = "requires a host GPU; missing adapter is a failure"]
fn rain_and_snow_leave_depth_alone_whatever_rain_depth_says() {
fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
eprintln!("Weather depth GPU: {:?}", adapter.get_info());
@@ -16,10 +16,14 @@ fn rain_and_snow_leave_depth_alone_whatever_rain_depth_says() {
}))
.unwrap();
let mut depth_failures = Vec::new();
// Iris 1.11.7 left no rain or snow in `depthtex0` with `rain.depth` absent, true or false,
// drawn by `gbuffers_weather` or its fallback (probe packs of ours, #10492).
for program in ["gbuffers_weather", "gbuffers_textured_lit"] {
for property in ["rain.depth=true", "rain.depth=false", ""] {
for (property, writes) in [
("rain.depth=true", true),
("rain.depth=false", false),
// An evidence-limited compatibility choice: preserve accepted main here.
// The permitted packs do not establish the external omitted-value default.
("", true),
] {
let mut post = pack(&device, &queue, &adapter, program, property);
// Retain the pipelines for another frame, then resize their targets.
for size in [[6, 1], [6, 1], [12, 2]] {
@@ -86,7 +90,7 @@ fn rain_and_snow_leave_depth_alone_whatever_rain_depth_says() {
}
let depth =
f32::from_le_bytes(depths[offset..offset + 4].try_into().unwrap());
let expected_depth = 0.5;
let expected_depth = if visible && writes { 0.25 } else { 0.5 };
if depth != expected_depth {
depth_failures.push(format!(
"{program} {property:?} size={size:?} ({x},{y}): depth={depth}, expected={expected_depth}"
Loaded 100 of 1743 files, more files were not shown because too many files have changed in this diff. Show more