Compare commits

..
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 27740e2e8f geometry.rs: GbuffersInputs only in its tests; lockfiles: the pipeline crate takes naga
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:46:06 +01:00
Horizon agentandClaude Opus 5.5 a7d211a1fc Translated programs' types move to horizon-render-pipeline
A translated program as the renderer builds pipelines from it (Program, ComputeProgram,
Interface, Uniform, Sampler, Image, Target, Error, DRAW_UNIFORMS) and the vertex inputs the
renderer offers it (GbuffersInputs, GeometryInputs, AlphaTest, WATER_MASK, WATER_PATCHES) move
unchanged from horizon-shaders' translator, which re-exports them. The renderer's library code
takes them from the engine crate. Layer plan M6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:42:43 +01:00
Ludovic Marie 7ae28b9d3e Merge branch 'split/render-pipeline-stages' into split/render-pipeline-textures 2026-11-09 16:35:58 +01:00
Ludovic Marie 0b7d3f8105 Merge branch 'split/render-pipeline-config' into split/render-pipeline-stages 2026-11-09 16:35:02 +01:00
Horizon agentandClaude Opus 5.5 950c368a3c Custom textures and render stages move to horizon-render-pipeline
The textures a pipeline's programs read (CustomTexture, Texture, Texels, TextureSource, Dim,
EVERY_STAGE) and the render stages they are told they draw in (RENDER_STAGES, render_stage)
move unchanged from horizon-shaders, which still reads them from a pack and re-exports them.
The renderer takes them from the engine crate. Layer plan M6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:07 +01:00
Horizon agentandClaude Opus 5.5 a6093def90 launcher and registry lockfiles: horizon-shader-api depends on horizon-render-pipeline
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:21:43 +01:00
Horizon agentandClaude Opus 5.5 80aa1a5b06 Cargo.lock: horizon-shader-api takes bytemuck through the pipeline crate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:20:23 +01:00
Ludovic Marie 8da02c530e Merge branch 'split/render-pipeline-config' into split/render-pipeline-stages 2026-11-09 16:20:23 +01:00
Horizon agentandClaude Opus 5.5 a23f327048 WGSL stage interface moves to horizon-render-pipeline
The stages Horizon's own packs fill, their preludes, bindings and entry points (stages.rs),
the frame uniform the renderer uploads (inputs.rs) and the API lines (Api) are the renderer's
interface, so they move unchanged into the engine's pipeline crate. horizon-shader-api keeps
reading, composing and checking packs against them and re-exports them under the same paths
(launcher and registry unchanged). The renderer takes them from the engine crate. Layer plan M6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:58:12 +01:00
785 changed files with 15685 additions and 29865 deletions

No files matched your search

+11 -14
View File
@@ -80,24 +80,21 @@ jobs:
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
rustup default stable
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
# On a pull request, the next three checks compare with HEAD^1: the main that GitHub's merge commit puts the pull
# request on. pull_request.base.sha is main at the pull request's last push and falls behind, so main's own
# changes since then would count as the pull request's (#13874).
# 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: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
run: python3 tools/check_layers.py ${BASE:+--base "$BASE"}
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: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
run: python3 tools/check_addresses.py ${BASE:+--base "$BASE"}
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: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
run: python3 tools/check_file_size.py ${BASE:+--base "$BASE"}
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
@@ -220,17 +217,17 @@ jobs:
$clang = Get-Command clang -ErrorAction Stop
Write-Output "Using $($clang.Source)"
& $clang.Source --version
- name: Build the game for native Windows ARM64 (the official game, as game-build.yml ships it)
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-official --bin horizon-official -p horizon-client-lib --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Build the game for native Windows ARM64
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Check the game's PE machine
run: python tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-official.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
run: python tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
- name: Check game DLL imports
run: python tools/check_imports.py target/aarch64-pc-windows-msvc/release/horizon-official.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
run: python tools/check_imports.py target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
- name: Start every game executable
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$client = 'target/aarch64-pc-windows-msvc/release/horizon-official.exe'
$client = 'target/aarch64-pc-windows-msvc/release/horizon-client.exe'
$output = & $client --help 2>&1
if ($LASTEXITCODE -ne 0 -or ($output -join "`n") -notmatch '--resources') { throw 'horizon-client --help failed' }
foreach ($binary in @('horizon-ctl.exe', 'horizon-prepare.exe', 'horizon-server.exe')) {
+2 -7
View File
@@ -168,14 +168,9 @@ jobs:
- name: ALSA and udev headers
if: runner.os == 'Linux' && !matrix.musl
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the official game, horizon-ctl, horizon-prepare and horizon-server
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
if: ${{ !matrix.musl }}
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-official --bin horizon-official -p horizon-client-lib --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: The official game is the game players get, under its name
# horizon-official: horizon-client's game with Horizon's own parts plugged in (docs/layers.md, "How a
# Horizon crate plugs in"). Everything below, the launcher and players know it as horizon-client.
if: ${{ !matrix.musl }}
run: cp -f "$BIN/horizon-official${{ matrix.exe }}" "$BIN/horizon-client${{ matrix.exe }}"
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
if: ${{ !matrix.musl }}
run: |
-3
View File
@@ -201,9 +201,6 @@ jobs:
- name: Verify Windows executable architecture
if: runner.os == 'Windows'
run: python ../tools/check_pe_arch.py "${{ matrix.pe_arch }}" "$BIN/${{ matrix.binary }}"
- name: Unrelated DLLs cannot intercept launcher or setup startup
if: runner.os == 'Windows'
run: python ../tools/test_launcher_dll_search.py "$BIN/${{ matrix.binary }}"
- name: The version is the release's
run: |
out=$("$BIN/${{ matrix.binary }}" --version)
+3 -3
View File
@@ -181,7 +181,7 @@ jobs:
musl=(); if [[ -f dist/horizon-linux-musl-x86_64.tar.gz ]]; then musl=(dist/horizon-linux-musl-x86_64.tar.gz)
else echo "::warning::horizon-linux-musl-x86_64.tar.gz is missing: SHA256SUMS goes out without it"; fi
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
${arm64[@]+"${arm64[@]}"} ${musl[@]+"${musl[@]}"} \
"${arm64[@]}" "${musl[@]}" \
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
dist/horizon-source.tar.gz
- uses: actions/upload-artifact@v7
@@ -267,9 +267,9 @@ jobs:
gh release create nightly --prerelease --latest=false --target "$SHA" \
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
${musl[@]+"${musl[@]}"} \
"${musl[@]}" \
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
${arm64[@]+"${arm64[@]}"} \
"${arm64[@]}" \
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS on Apple Silicon (horizon-macos-arm64.zip)" \
"dist/horizon-macos-x64.zip#Game files the launcher installs (Nightly): macOS on Intel (horizon-macos-x64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
-3
View File
@@ -44,8 +44,6 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- **Machine translations stay unreviewed.** `tools/lang_review/` records only text a person who speaks the language
wrote or checked; never `lang_coverage.py --mark-reviewed` machine text ([docs/translations.md](docs/translations.md)).
- **Controllers reach what registers.** Draw controls with a registering helper (`ui::button`, `settings_shell::foot_button`); `graphics_style::button` and `screen_draw::button` only paint, and registering one part of a screen ends its crosshair, so register all of it.
- **Render passes keep HDR's light.** A pass that reads, filters or redraws the world's picture (upscaling,
anti-aliasing, sharpening, frame generation, post effects, captures) follows [docs/hdr-passes.md](docs/hdr-passes.md).
## Crates
@@ -81,7 +79,6 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `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-hooks`, `horizon-game`, `horizon-official`, `horizon-heartbeat` | The layer seam: the engine's hooks for crates above it, the game an executable starts, the official executable (shipped as `horizon-client`) that registers Horizon's in-game parts, the first of them ([docs/layers.md](docs/layers.md#how-a-horizon-crate-plugs-in)) |
| `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` |
-10
View File
@@ -13,14 +13,6 @@ else (videos, Discord and other community material) lives elsewhere.
suggest something. A pull request for a roadmap item carries its `roadmap:<id>` label; ideas go to the suggestion
queue (the hub, or `/suggest` on Discord) rather than to issues, which are for bugs.
**Mod-related pull requests go to [arismoko/horizon](https://github.com/arismoko/horizon)**, the fork that carries the
mod API and reviews and merges that work. That covers the mod API, native hooks and events for mods, native mods,
content mods (`.hmod`), mixins, wasm mods, `cargo horizon-mod`, `horizon-mods`, mod builds and the registry, and
their docs ([mods](docs/mods.md), [native mods](docs/native-mods.md)). Open the pull request there from your usual
branch. Mod-related pull requests opened here are closed with a pointer to it. Engine extension points that Horizon's
own crates register (the [layer split](docs/layers.md)), resource and shader pack formats, and gameplay fixes stay
here.
## Build it
- **Prerequisites:** Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers
@@ -198,5 +190,3 @@ new pushes, we add it again.
[Discord](https://discord.gg/playhorizon) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
How Horizon is run, and how people join the Team or lead a group: [GOVERNANCE.md](GOVERNANCE.md).
Generated
+19 -47
View File
@@ -1418,7 +1418,6 @@ dependencies = [
name = "horizon-chat-filter"
version = "0.1.0"
dependencies = [
"horizon-server-files",
"serde",
"serde_json",
"toml",
@@ -1451,7 +1450,6 @@ dependencies = [
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-mod-api-gen",
"horizon-mods",
@@ -1502,6 +1500,7 @@ version = "0.1.0"
dependencies = [
"arboard",
"horizon-client-core",
"horizon-dualsense",
"horizon-theme",
"png",
"winit",
@@ -1529,8 +1528,10 @@ dependencies = [
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-mcp",
"horizon-mods",
@@ -1586,12 +1587,13 @@ dependencies = [
"horizon-core",
"horizon-cosmetics",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-mod-api-gen",
"horizon-narrator",
"horizon-optifine",
"horizon-polytone",
"horizon-protocol",
"horizon-render-pipeline",
"horizon-shader-api",
"horizon-shaders",
"horizon-system",
@@ -1667,7 +1669,6 @@ dependencies = [
"getrandom 0.3.4",
"hkdf",
"horizon-addresses",
"horizon-platform",
"if-addrs",
"serde",
"serde_json",
@@ -1676,15 +1677,6 @@ dependencies = [
"spake2",
]
[[package]]
name = "horizon-game"
version = "0.1.0"
dependencies = [
"horizon-client",
"horizon-mods",
"horizon-threads",
]
[[package]]
name = "horizon-hdr"
version = "0.1.0"
@@ -1696,19 +1688,6 @@ dependencies = [
"wgpu",
]
[[package]]
name = "horizon-heartbeat"
version = "0.1.0"
dependencies = [
"horizon-platform",
"horizon-telemetry",
"log",
]
[[package]]
name = "horizon-hooks"
version = "0.1.0"
[[package]]
name = "horizon-items"
version = "0.1.0"
@@ -1789,18 +1768,6 @@ dependencies = [
"windows 0.62.2",
]
[[package]]
name = "horizon-official"
version = "0.1.0"
dependencies = [
"horizon-friends",
"horizon-game",
"horizon-heartbeat",
"horizon-platform",
"log",
"winresource",
]
[[package]]
name = "horizon-optifine"
version = "0.1.0"
@@ -1815,13 +1782,7 @@ dependencies = [
name = "horizon-platform"
version = "0.1.0"
dependencies = [
"horizon-core",
"horizon-hooks",
"horizon-prepare",
"horizon-protocol",
"horizon-server-files",
"horizon-system",
"horizon-world",
]
[[package]]
@@ -1892,6 +1853,14 @@ dependencies = [
"ring",
]
[[package]]
name = "horizon-render-pipeline"
version = "0.1.0"
dependencies = [
"bytemuck",
"naga",
]
[[package]]
name = "horizon-server"
version = "0.1.0"
@@ -1903,9 +1872,11 @@ dependencies = [
"horizon-anticheat",
"horizon-bench",
"horizon-chat-filter",
"horizon-core",
"horizon-mcp",
"horizon-platform",
"horizon-release-sign",
"horizon-server-files",
"horizon-world",
"libc",
"p12-keystore",
"png",
@@ -1938,7 +1909,7 @@ dependencies = [
name = "horizon-shader-api"
version = "0.1.0"
dependencies = [
"bytemuck",
"horizon-render-pipeline",
"naga",
"png",
"serde",
@@ -1954,6 +1925,7 @@ version = "0.1.0"
dependencies = [
"half",
"horizon-core",
"horizon-render-pipeline",
"naga",
"png",
"zip",
+1 -4
View File
@@ -30,10 +30,6 @@ members = [
"crates/horizon-telemetry",
"crates/horizon-system",
"crates/horizon-platform",
"crates/horizon-hooks",
"crates/horizon-game",
"crates/horizon-official",
"crates/horizon-heartbeat",
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
@@ -43,6 +39,7 @@ members = [
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-render-pipeline",
"crates/horizon-optifine",
"crates/horizon-polytone",
"crates/horizon-shader-api",
-483
View File
@@ -1,483 +0,0 @@
# Horizon governance
> Adopted 2026-11-09 by the founder as his last decision as head of the project. It takes effect when he
> announces that he is stepping down. From then on only the council changes it, by a 2/3 poll (section 15).
Horizon has no boss. A **council of equals** decides by polls. **Theme groups** do the work in their areas, each
with a **lead**. The **Horizon Team** reviews and merges code. **Moderators** keep the Discord server and the
official server safe. Every role of trust is earned with work the project can measure, and it ends when that work
stops.
## Contents
1. [Principles](#1-principles)
2. [The levels at a glance](#2-the-levels-at-a-glance)
3. [Each level in detail](#3-each-level-in-detail)
4. [The council and its polls](#4-the-council-and-its-polls)
5. [The selection filter](#5-the-selection-filter)
6. [Theme groups](#6-theme-groups)
7. [Moderation and appeals](#7-moderation-and-appeals)
8. [Conflicts and hard cases](#8-conflicts-and-hard-cases)
9. [The age rule (18+)](#9-the-age-rule-18)
10. [The agents](#10-the-agents)
11. [Accounts and the handover path](#11-accounts-and-the-handover-path)
12. [Money](#12-money)
13. [Transparency](#13-transparency)
14. [Rules that are never voted on](#14-rules-that-are-never-voted-on)
15. [Changing this document](#15-changing-this-document)
## 1. Principles
- **Equals.** Every council member has one vote. Nobody, the founder included, stands above the others.
- **The founder.** Ludovic keeps the title of **Founder**: his role stays at the top of the member list and he is
credited as Horizon's founder everywhere. The title is symbolic. It carries no extra vote or power: on the council
he counts exactly like every other member.
- **Evidence, not popularity.** You get a role by doing the work first. Kira measures that work: merged pull
requests, reviews, triage, support answers and days active. A vote then confirms it.
- **Small, clear powers.** Each role can do a short, written list of things. Anything else goes to the council.
- **Roles are on loan.** Every role has a 30-day probation and an inactivity rule. Stepping down for inactivity is
not a sanction, and you can come back.
- **Public by default.** Polls, results and rules are public. Only matters about a person stay private, and even then
the result is published.
- **18+ for roles of trust** (section 9).
- **Mojang's and Microsoft's rules come first.** They are never put to a vote (section 14).
## 2. The levels at a glance
From the most open level to the most trusted. A person can hold several levels at once: a group lead can also be on
the Horizon Team, for example.
| Level | What it can do | How you get in | How you leave | 18+ |
| --- | --- | --- | --- | --- |
| **Member** | Play, chat, report bugs, suggest ideas, open issues and pull requests, pick group roles | Join the Discord server or the GitHub repo | Leave, or get banned | No |
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; nominate people for group lead (if active in that group) | One merged pull request in a public horizon-rust repo, linked with `/link-github` and `/verify-github`. Ranks come from merged pull requests: 10, 40, 100 and 200 | Contributor status and ranks stay | No |
| **Group member** | Read and write in the group's channel; get the group's pings; take part in the group's advisory poll on its lead | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
| **🤝 Support** | Answer and close posts in #horizon-support | `/apply`; vote of the mods and the Team (as today) | Resign, 30 days inactive, or removed by the Chief of Mods or the council | No |
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | `/apply`, then the selection filter; mods' vote; the council may object within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, removed by a council poll (simple majority) | **Yes** |
| **🛡️ Chief of Mods** | A moderator's powers, plus bans; leads the mods; suspends a mod at once pending the council | Council poll (simple majority), from the mods with at least 30 days | Resign, 30 days inactive, council poll (simple majority) | **Yes** |
| **🧭 Group lead** | Decides inside the group's topic (section 6); manages the group's channel; triage on the group's GitHub issues | Nomination, the filter, a 24 h public discussion with an advisory group poll, then a council poll (simple majority); 30 days probation | Resign, 30 days inactive (automatic), council poll (simple majority); confirmed again every 6 months | **Yes** |
| **🌌 Horizon Team** | Review and merge pull requests (GitHub `horizon-team`, write access); `/nightly` and triage commands (trusted reviewers); #horizon-team | Nomination, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, 60 days without a merge or review (automatic), council poll (2/3, without the person) | **Yes** |
| **🏛️ Council** | Decides everything else by poll (section 4); bans; emergency actions by any 2 members | Nominated by a council member, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, inactivity (section 5.5), council poll (2/3, without the person) | **Yes** |
**The account holder** isn't a level. The founder (Ludovic) still holds the project's accounts (GitHub organisation
owner, Discord server owner, the Microsoft app approved by Mojang, the domains, Railway, money). He carries out the
council's decisions on them and hands them over step by step (section 11). As a council member he has one vote,
like everyone else.
## 3. Each level in detail
### Member
Anyone in the Discord server or on GitHub. Members follow the server rules and Horizon's
[contributing guide](CONTRIBUTING.md). They can propose anything: an idea in #horizon-ideas, a bug in #horizon-bugs,
an issue or a pull request on GitHub, or a request for a council poll (any council member can pick it up).
### Contributor
- **Discord:** the 🛠️ Contributor role writes in #contributors and in the threads of #polls-and-decisions. A rank
role (⛏️ Iron at 10 merged pull requests, 🥇 Gold at 40, 💎 Diamond at 100, 🟪 Netherite at 200, counted over every
public horizon-rust repo) shows how much has been merged. Ranks only go up.
- **GitHub:** no repository role. Anyone can `/claim` and `/unclaim` their own issues.
- **Alone:** their own work.
### Group member
Each theme group (section 6) has a role that anyone can pick and drop. It opens the group's channel and receives the
group's pings. Picking a role grants no power. A group member counts as **active in the group** when Kira has seen at
least one counted action in the group's area (section 5.1) in the last 60 days. Only active group members can
nominate a lead or vote in the group's advisory poll.
### 🤝 Support
Unchanged: Support members help players in #horizon-support. They answer every post there and mark posts solved.
The role carries no permission, so it has no age rule. They join through `/apply`, and the mods and the Team vote.
### 🛡️ Moderators and the Chief of Mods
- **Discord:** time out members, delete messages, manage threads and nicknames, read the audit log, quarantine
through Kira, and see the staff channels. **Moderators don't ban.** Bans are done by council members and the Chief of
Mods. Kira's automatic actions (the honeypot, clear scams, illegal images) stay as they are.
- **Official server:** server operators (ops) are moderators in game. They need the 18+ rule and are confirmed the
same way (section 6, Server and SMP).
- **Alone:** a timeout, a deleted message, a quarantine, with a reason in the mod case.
- **The Chief of Mods** is the first contact for moderation questions. They organise the mods, can ban, and can take a
moderator's role away at once when there is a problem. The council confirms or reverses that within 72 hours.
### 🧭 Group leads
Section 6 says what a lead decides alone and what goes to the council.
- **Discord:** in their group's channel only, delete messages, manage and pin threads, and ping the group role. The
🧭 lead role carries no server-wide permission.
- **GitHub:** triage on the issues and pull requests that carry their group's label (`/label`, `/unlabel`, `/close`,
`/reopen`, `/unclaim`), from a list of group leads that grants no repository role. Leads merge only if they are on
the Horizon Team too.
### 🌌 Horizon Team
The Horizon Team **merges pull requests**. Its rules are in [the release flow](docs/release-flow.md): read the whole
diff, never merge your own pull request, and use `needs-proof` for anything risky.
- **GitHub:** member of the `horizon-team` team (write access to the Horizon repos), on the trusted reviewers list
(`/nightly` and triage commands).
- **Discord:** the 🌌 Horizon Team role, #horizon-team, and writing in #contributors and the read-only channels.
Moderation permissions belong to the moderators and the council: a Team member moderates only if they also hold
one of those roles.
- **During the 30-day probation:** they merge only ordinary pull requests. They don't merge `needs-proof` pull
requests, don't approve files that CODEOWNERS guards, and don't use `/nightly`.
### 🏛️ Council
- **Discord:** the 🏛️ Council role: the moderators' permissions plus ban and mention @everyone; #council (private);
starting polls in #polls-and-decisions.
- **GitHub:** the `council` team. Over time it takes over the founder's CODEOWNERS review and his owner powers in the
release tools (section 11).
- **Alone (any one member):** start a poll; pick up a member's request for a poll; do anything their other roles
allow.
- **Any two members together:** an emergency action (section 4.6).
- **Everything else** is decided by poll.
## 4. The council and its polls
### 4.1 Founding council
kacpherek, levzzz5154, rdbtCVS, Online5277, dezer-x and Ludovic: six members, one vote each. The founding members
don't go through the filter or a probation. They do make the 18+ declaration (section 9). The council has at most
**9** members. It adds a seat only when a 2/3 poll says it needs one.
### 4.2 How a poll works
| Rule | Value |
| --- | --- |
| Who starts one | Any council member, with `/poll` (Kira). Anyone else asks a council member to start it |
| Where | #polls-and-decisions, in public. Polls about a person (removal, appeals, the age rule) run in #council, and only their result is public |
| How long | 24 hours. It closes early only if every council member has voted |
| Ballot | Yes, No or Abstain. You can change it until the poll closes |
| Quorum | At least half of the council votes (Abstain counts). With 6 members, 3 votes |
| Simple majority | More Yes than No |
| Two-thirds | Yes is at least 2/3 of the Yes and No votes, **and at least 3 Yes votes** |
| Tie | Nothing changes |
| No quorum | Nothing changes. Kira reminds the members who haven't voted 12 hours and 2 hours before the end |
| About yourself | You don't vote, and you don't count in the council's size for that poll |
| Asking again | A poll that failed can't be asked again for 7 days, unless two members agree that new facts justify it |
### 4.3 Which threshold applies
| Simple majority | Two-thirds |
| --- | --- |
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members and Horizon Team members |
| Group leads and co-leads: appointing, removing, confirming every 6 months | Removing a council member or a Team member (without the person concerned) |
| Moderators, the Chief of Mods, Support: confirming, removing | Rules: this document, the server rules, the contributing policy, the licence |
| Ending a probation early | Money (section 12) |
| Confirming an emergency action | Account handover steps (section 11) |
| Second-level moderation appeals | Waiving part of the selection filter (section 5.4) |
| Bringing back someone who stepped down for inactivity, within 6 months | Changing the council's size |
**When in doubt, two-thirds.** Any council member can say a poll needs 2/3, and Kira then counts it at 2/3.
### 4.4 Discussion
Polls that add someone to the council, the Team or a lead post start with a **24-hour public discussion** (section
5.3). Other polls are discussed in their own thread while they run. A member who wants more time votes No and says
why. A failed poll can come back after 7 days.
### 4.5 Conflicts of interest
A member doesn't vote on a poll about themselves: their own role, their own removal, an appeal of their own
moderation action, or money paid to them. They say so in the poll's thread and Kira leaves them out of the count.
### 4.6 Emergencies
For **security** (a leaked secret, a malicious pull request or mod, a hijacked account), **Mojang or Microsoft** (a
request from them, a risk to the approved sign-in) or **legal** matters (a takedown, the law), **any two council
members act at once**. They may:
- pause releases and the Nightly;
- take a mod down from the registry;
- remove someone's roles or access for now;
- lock or hide channels and content;
- ask the account holder to rotate a secret or a key.
They record it with `/emergency` in Kira, and both of them confirm it there. Kira opens a council poll at once
(simple majority) to confirm it. If the poll doesn't pass, the action is undone where it can be. An emergency never
adds members, changes rules or spends money.
### 4.7 Removing a council member
A 2/3 poll of the other members, in #council. The person concerned doesn't vote and doesn't count in the quorum.
Only the result is published, never the reasons. Other ways to leave: resigning, inactivity (section 5.5), or a
false age declaration (section 9).
### 4.8 When the council drops below a quorum
- **Fewer than 5 members:** Kira opens nominations for the council at once. The council fills seats within 30 days.
- **Fewer than 3 members, or fewer than 3 who voted in the last 30 days:** an **interim council** forms. It counts the
remaining council members, the Horizon Team and the group leads past probation. Its only task is adding council
members, by its own 2/3 poll with a quorum of half. Any two of its members can take emergency actions. All other
decisions wait. The Team keeps merging and the leads keep leading.
- **Nobody left:** the Team and the group leads form the interim council in the same way.
## 5. The selection filter
Nobody becomes a lead, a Team member or a council member just because they are well liked or talk a lot. Kira
measures the work, and only people above a floor can be nominated. The council then decides among them.
### 5.1 What Kira measures
| Signal | What counts | Source |
| --- | --- | --- |
| **Merged pull requests** | Pull requests merged in a public horizon-rust repo. For Modding, also in the mod API's fork (arismoko/horizon). Counted overall, and per group by label or by files touched | GitHub search, as the ranks are counted today |
| **Reviews** | Others' pull requests you reviewed: approved, asked for changes, or left a review with comments. One per pull request | GitHub search `reviewed-by:` and Kira's GitHub webhook |
| **Triage** | Triage commands run (`/label`, `/close`, duplicates), issues labelled or closed, bug posts routed on Discord | The triage workflow's comments and Kira's webhook |
| **Support answers** | #horizon-support posts you answered that ended solved | Kira's support feature |
| **Active days** | Distinct days with any of the above, or with messages in #contributors or a group's channel. Chat elsewhere doesn't count | Kira |
| **Tenure** | Days since your first counted contribution (since you joined the server, for moderators) | GitHub, Discord |
| **Clean record** | No timeout, quarantine or ban in the last 90 days | Kira's mod cases |
| **Verified GitHub** | `/verify-github` done | Kira |
| **18+ declaration** | Made (section 9) | Kira |
Kira turns these into an **evidence card**: the person's numbers, overall and in the group's area, for the last 60
days and in total. Anyone can see their own card with `/evidence`. A nomination shows the card publicly.
### 5.2 The floors
A nomination is refused below these floors.
| Role | Floor |
| --- | --- |
| **Group lead** | Verified GitHub; tenure of 30 days or more; **in the group's area: 8 or more merged pull requests, or 20 or more reviews** (Translations: 5 or more merged translation pull requests; Server and SMP: 8 pull requests, or 30 days as an official server operator with activity); 15 or more active days in the last 60; clean record; 18+ |
| **Moderator** | In the server for 30 days or more; Kira level 10 or more; 15 or more active days in the last 60; clean record; 18+ |
| **Chief of Mods** | Moderator for 30 days or more, past probation; 18+ |
| **Horizon Team** | 🥇 Gold rank (40 merged pull requests); 20 or more reviews of others' pull requests; tenure of 30 days or more; 20 or more active days in the last 60; clean record; verified GitHub; 18+ |
| **Council** | Horizon Team member, group lead or Chief of Mods for 60 days or more, past probation; 20 or more active days in the last 60; clean record; 18+ |
The numbers are checked again once a year (section 15). They should stay reachable for people who really work on
Horizon and out of reach for people who don't.
### 5.3 The path to a role
1. **Nomination.** Run `/nominate member: role: [group:]` in Kira.
- A **group lead** can be nominated by an active member of that group or by a council member. A self-nomination
needs a second from another active group member or a council member.
- A **Team member** can be nominated by a council or Team member. Someone can also nominate themselves with
`/apply`, if a council or Team member sponsors them.
- A **council member** can only be nominated by a council member.
- **Moderators** apply with `/apply`.
2. **The filter.** Kira checks the floor and the 18+ declaration. Below the floor, Kira refuses and says which line
is short.
3. **Public discussion, 24 hours.** Kira posts the nomination and the evidence card in a thread of
#polls-and-decisions. Contributors and the council can comment. For a lead, Kira also runs an **advisory poll** in
the group's channel for the same 24 hours. Only active group members vote in it, and the council sees the result.
4. **Council poll, 24 hours.** Simple majority for a lead, 2/3 for the Team and the council. For moderators, the mods'
vote replaces this step, and any two council members can object within 24 hours; the council then decides by poll.
5. **Probation, 30 days.** The role is held in full, except for the Team limits in section 3. At day 25, Kira asks the
council in #council whether anything is wrong. Probation ends by itself at day 30, unless a council poll (simple
majority) ended it earlier.
### 5.4 Exceptions
Some valuable people don't show up in the numbers, such as a designer for the website. The council can **waive the
tenure or the area floors** by a 2/3 poll that says why. The age rule and the clean record are never waived.
### 5.5 Inactivity
Kira checks every day. It warns the person in a DM and in #council, then posts the step-down in
#polls-and-decisions with neutral wording ("stepped down: inactive"), and removes the Discord role. The account
holder or an org owner removes GitHub access when the Team role ends.
| Role | What counts as activity | Warning | Steps down |
| --- | --- | --- | --- |
| Group lead | A counted action in the group's area, or messages in the group's channel | Day 21 | Day 30 |
| Horizon Team | A merge or a review | Day 45 | Day 60 |
| Moderator, Chief of Mods | A moderation action, or messages in the staff channels | Day 21 | Day 30 |
| Support | An answer in #horizon-support | Day 21 | Day 30 |
| Council | A vote in a poll. At 30 days with no vote, while at least 2 polls were held | Day 23 | Day 30, or 45 days with no counted activity at all |
- **Away.** Anyone can announce an absence with `/away` for up to 45 days, once every 6 months. Away days don't
count. A lead names someone from the group to cover for them.
- **Coming back.** Within 6 months: a simple-majority poll, with no new probation. After 6 months: the normal path.
### 5.6 Terms
- **Group leads** are confirmed every 6 months by a simple-majority poll. If someone else in the group passes the
floor and is nominated, both go on the same poll.
- **Council and Team** have no fixed term. They stay as long as they are active and keep the council's trust. Removal
is always possible (section 4.7).
## 6. Theme groups
### 6.1 The groups
| Group | Covers | GitHub | Discord |
| --- | --- | --- | --- |
| **Modding** | The mod API, native and content mods, mixins, the mod registry, `cargo horizon-mod` | The mod API lives in Ari's fork **arismoko/horizon**. Its owner runs that repo by their own rules; the council governs only what is Horizon's: horizon-rust/horizon-mods, horizon-mixins, horizon-native-mod-template, the registry. Label `mods` | The 🧩 MODDING category (#modding, #horizon-modding-team); roles 🧩 Modder (picked) and 🧩 Horizon Modding Team (the lead gives it) |
| **Shaders and rendering** | The renderer, shaders, shader packs, OptiFine and Polytone pack formats | Label `rendering`; `crates/horizon-client-render` (gfx), `*.wgsl`, `horizon-shaders`, `horizon-shader-api`, `horizon-optifine`, `horizon-polytone` | #shaders-rendering |
| **Performance** | Frame rate, memory, server tick, benchmarks | Label `perf`; `horizon-bench`, the benchmark tools | #performance |
| **Launcher** | The launcher: installs, updates, instances, its pages | Label `launcher`; `launcher/` | #launcher |
| **Server and SMP** | Dedicated server, hosting, the anticheat, multiplayer, and the official SMP | Labels `server`, `multiplayer`; `horizon-server*`, `horizon-anticheat`, `lan/`, `dedicated*`; horizon-rust/horizon-server | #server-smp (plus #join-server, #server-chat) |
| **Translations** | The game's and the launcher's languages | Label `roadmap:languages`; `launcher/lang/`, `crates/horizon-client/assets/horizon/lang/` | #translations and the 🌍 language channels |
| **Website** | playhorizon.dev | horizon-rust/horizon-site; label `website`. The site's design stays as it is unless the council decides otherwise | #website |
| **Vanilla accuracy** | Parity with vanilla 26.3: harnesses, oracles, the port ledger | Labels `parity`, `parity-regression`, `full-parity`; `tools/test_*_parity.py`, the Java oracles | #vanilla-accuracy |
| **UI** | The game's menus, HUD, settings, controller navigation, the design system | Labels `ui`, `accessibility`; `title/`, `settings_shell.rs`, `hud*`, `ui.rs`, `docs/design-system.md`, `horizon-theme`. The launcher's look is shared with Launcher | #ui |
A pull request can belong to two groups. Both leads can comment, and the Team merges as usual. If the leads disagree,
the council decides.
### 6.2 Leads per group
- **One lead per group.** A group with 8 or more active members may also have **one co-lead**, chosen the same way.
- A person leads **at most two groups**. Council members may lead a group, though a lead from outside the council is
preferred: it spreads the work.
### 6.3 What a lead decides alone
Inside their group's topic, and within this document, the contributing guide, the vanilla-first rule, the layers and
the design system:
- the group's priorities and the order of its roadmap items;
- which issues are `good first issue`, and triage of the group's issues;
- technical and design direction inside the area, when vanilla behaviour and the default look don't change;
- accepting or declining an idea or a proposal in the area. Any two active group members or one council member can
take that decision to the council;
- how the group organises itself (threads, meetings, the channel's rules on top of the server's);
- asking the Team for reviews, adding `needs-proof`, and nominating people.
### 6.4 What goes to the council
- the rules, money, accounts and secrets;
- who is on the council, the Team or the mods;
- anything that changes vanilla behaviour or turns a Horizon-only feature on by default;
- breaking a public promise: the mod API, server compatibility, save and file formats;
- disputes between groups;
- anything involving Mojang, Microsoft or the law;
- speaking for the whole project outside the group's topic.
### 6.5 A group without a lead
The work goes on. The Team reviews and merges, and Kira still routes the group's issues and questions to its
channel. Until a lead is chosen, any council member may take a lead's decision for that group and says so in the
channel. Kira pins a "lead wanted" note with the floor. **No lead is better than a lead who doesn't do the work:**
nobody is appointed just to fill the seat.
## 7. Moderation and appeals
- **The server rules** are in #rules. Moderators enforce them, the Chief of Mods leads the mods, and the council can
change the rules (2/3).
- **Every action has a mod case**: who acted, what they did, and why. Kira keeps the cases.
- **First appeal:** within 14 days, through #appeal (for a quarantine) or mod mail. A moderator who wasn't involved,
or the Chief of Mods, answers within 72 hours.
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a council poll in #council (simple majority).
Members who were involved don't vote. The answer is final for 90 days.
- **Not appealable here:** actions that Discord's rules or the law require. Those are reported where the law says.
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the council decides within 72 hours
(simple majority). The council removes the Chief of Mods the same way.
- **Staff are moderated like everyone.** When a council member is involved, the council decides without them.
## 8. Conflicts and hard cases
| Case | What happens |
| --- | --- |
| Two groups disagree | The leads talk first. If they still disagree, the council decides (simple majority) |
| A lead's decision is contested | Any two active group members or one council member can bring it to the council |
| A Team member merges against the rules | Any council member can revert the merge. The council can remove the member (2/3) |
| A council member breaks the rules | The council decides without them. Removal takes 2/3 (section 4.7) |
| Someone holds a role and is under 18 | Section 9 |
| The council can't reach quorum | Section 4.8 |
| The founder and the council disagree about one of his own accounts | Until a step of section 11 hands it over, the account stays his. He carries out the council's decisions on project matters, or he says publicly that he won't |
## 9. The age rule (18+)
**Who must be 18 or older:** members of the council and the Horizon Team, trusted reviewers, group leads and
co-leads, the Chief of Mods and the moderators, official server operators, and anyone with admin rights anywhere in
the project: Discord (Administrator, Manage Roles, Manage Channels, ban), GitHub (write, admin, organisation),
server panels, Railway, or Kira's admin side. The same goes for anyone who holds a project secret.
**Who doesn't have to be:** members, contributors (with any rank), group members and 🤝 Support. Contributors under
18 are welcome and valued. They just can't hold the roles above.
**How it's checked, without collecting ID:**
- **A declaration.** Before taking such a role, and once at the start for everyone who holds one, the person presses
a button in Kira: "I confirm that I am 18 or older. I understand that a false declaration removes all my roles of
trust." Kira keeps only the Discord account, the date and the wording's version. It never stores a birth date, an
ID, a photo, or any outside age-checking service's result.
- **No declaration, no role.** At the start, current role holders have 7 days to make it. Until they do, their role is
paused.
- **When there's doubt** (the person says they are younger, or there is a credible private report), one council
member asks the person privately. If the doubt remains, any council member pauses the role, and the council decides
within 72 hours in #council.
- **A false declaration** removes every role of trust at once, and the person can't hold one for 12 months.
- **Privacy.** Age is never discussed in public. A role that ends for this reason ends with the same neutral line as
any other step-down.
## 10. The agents
Horizon uses AI agents: the founder's orchestrator, which runs coding agents, and Kira, the Discord bot.
- **They obey council decisions only:** a passed poll recorded by Kira, a recorded emergency action of two council
members, and the standing rules in this document and the project's docs.
- **The only exception is the founder's own things:** his spending, his machines (they run CI and the vanilla tests),
his personal accounts and his time. On those, his word decides. This list should get shorter over time (section 11).
- **Role work isn't a decision.** A lead asking Kira to route issues, or a Team member asking an agent to prove a pull
request, is that person using their role. The agents help, within the role's powers.
- **They check the source.** An agent acts on a decision only when it finds it in Kira's decisions log
(section 13). A message saying "the council decided", or a screenshot, is not enough.
- **They post as agents.** Everything an agent posts says it comes from an agent, and states decisions as the
project's.
## 11. Accounts and the handover path
Today the founder holds every account. The aim is for him to hold fewer, in an order that never leaves the project
unable to act, and never puts an account that is tied to a person into anyone else's hands.
| Account | Today | Can it be shared? | Plan |
| --- | --- | --- | --- |
| **GitHub organisation** (horizon-rust) | One owner (the founder) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
| **Discord server** | Owned by the founder | Partly: a server has exactly one owner; powers can be given through roles | **Step 1:** the 🏛️ Council role gets moderation and ban permissions; Kira applies the outcomes of passed polls, so nobody needs Manage Roles; two-factor authentication becomes required for moderation. Ownership itself stays with the founder until a legal entity exists |
| **Kira** (bot application, Railway service) | The founder's Discord developer account and Railway | The bot: yes (a Discord developer team). Railway: adding members may need a paid plan | **Step 1:** a Discord developer team with two council members. Railway members once the council decides on money |
| **Official server** (hosting panel) | The founder's account | Yes: panel sub-users | **Step 1:** sub-users for the Server and SMP lead and for one council member |
| **Domains** (playhorizon.dev and the others) | The founder's registrar account | Not without moving it | Auto-renewal on, registered for several years ahead. They move only to a legal entity, if one is created |
| **The Microsoft app approved by Mojang** (sign-in) | The founder's Azure app registration; Mojang approved it after his application | **No.** The approval was given on his application. Moving the app or adding owners would change who Mojang deals with, and we have found no documented way to transfer an approval | **Never moves** without Mojang's written agreement. The only change considered: a legal entity, if one is created, and only if Mojang agrees |
| **Signing** (Apple developer certificate, release signing keys) | The founder's | The Apple certificate is in his legal name: no | The Apple certificate never moves. Release keys stay in GitHub's protected environments, and the council team approves their use from step 2 |
| **Money** (sponsorship, donations) | Personal accounts of the founder | Through a fiscal host | Section 12 |
| **The founder's machines, AI accounts and password vault** | His | No | His own things: they never move. The project should depend on them less over time |
**Order:** first the safety nets that remove a single point of failure (a second organisation owner, the bot's
developer team, panel sub-users). Then the day-to-day powers (CODEOWNERS and release tools to the council team). Last,
whatever needs a legal entity: domains, money, and with Mojang's agreement the sign-in app. Each step is a 2/3 poll,
and the founder carries it out.
## 12. Money
*Placeholder: the options (Open Collective and others) are being researched.*
- The council approves every expense by a **2/3 poll**.
- **Every income and every expense is public.**
- Nobody votes on money paid to themselves.
## 13. Transparency
- **#polls-and-decisions:** every poll and every result, posted by Kira, and the 24-hour discussions of nominations.
For polls that aren't about a person, each member's vote is shown once the poll closes.
- **The decisions log:** Kira keeps an append-only record of every poll, emergency action, appointment and
step-down, with its date and result. It is public and machine-readable, so the agents can check a decision there.
- **This document** lives in horizon-rust/horizon as `GOVERNANCE.md`. A short summary is pinned in #rules and in
#polls-and-decisions.
- **Who holds which role:** the people page of playhorizon.dev's hub shows the council, the Team, the group leads,
the Chief of Mods, the moderators and Support.
- **#council** is private for discussion and for polls about people. Everything the council decides is published.
## 14. Rules that are never voted on
These are Horizon's conditions for existing, not choices. No poll can change them:
- Playing needs a **Microsoft account that owns Minecraft: Java Edition**. There is no play without one.
- **No Mojang files** in Horizon's repos or downloads. Players' own copies stay on their own machines.
- **No gambling**, in any form, including with in-game currency.
- **Mojang's and Microsoft's terms and Usage Guidelines** apply to everything Horizon does.
- **The law**, and the terms of service of Discord and GitHub.
## 15. Changing this document
A 2/3 council poll, then a pull request that a Team member merges. The council reviews the floors' numbers
(section 5.2) once a year, or sooner if they turn out wrong.
-2
View File
@@ -135,8 +135,6 @@ python3 tools/pr_ready.py
It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public-tree check and the vanilla harnesses your change affects. Never commit game files, decompiled source or prepared resources.
How Horizon is run (the council, theme groups and their leads, the Horizon Team, moderation): [GOVERNANCE.md](GOVERNANCE.md).
## 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.
+2 -3
View File
@@ -129,9 +129,8 @@ ports to WGSL of the optical flow and frame interpolation shaders of the
[AMD FidelityFX SDK](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK) 1.1.4
(`sdk/include/FidelityFX/gpu/{opticalflow,frameinterpolation}`, at commit
[c6efa6bf7f20](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK/tree/c6efa6bf7f2027b3ec94f28578bb5965eabb9e55)),
and `optical_flow.rs` and `interpolation.rs` follow the SDK's
`sdk/src/components/{opticalflow/ffx_opticalflow.cpp,frameinterpolation/ffx_frameinterpolation.cpp}`
(their passes, their order and their dispatch sizes). Copyright (C) 2024 Advanced Micro Devices,
and `optical_flow.rs` follows the SDK's `sdk/src/components/opticalflow/ffx_opticalflow.cpp`
(its passes, their order and their dispatch sizes). Copyright (C) 2024 Advanced Micro Devices,
Inc., under the MIT license, whose text heads each file. AMD and FidelityFX are AMD's
trademarks; Horizon is not affiliated with AMD.
-1
View File
@@ -90,7 +90,6 @@ impl Report {
/// The kind a file's extension says.
pub fn kind_of(path: &Path) -> Option<Kind> {
match path.extension()?.to_str()? {
"hmod" if horizon_mods::index::native::holds(path) => Some(Kind::NativeMod),
"hmod" => Some(Kind::Mod),
"hnmod" => Some(Kind::NativeMod),
"hpack" => Some(Kind::Modpack),
-7
View File
@@ -377,13 +377,6 @@ mod tests {
zip.finish().unwrap();
let report = check::check(&hnmod, check::kind_of(&hnmod).unwrap(), None, None).unwrap();
assert!(report.text().contains("machine_mod"), "{}", report.text());
let combined = dir.join("machine_mod.hmod");
std::fs::copy(&hnmod, &combined).unwrap();
assert_eq!(
check::kind_of(&combined),
Some(horizon_mods::index::Kind::NativeMod)
);
assert!(check::check(&combined, check::kind_of(&combined).unwrap(), None, None).is_ok());
// A pin that is not a commit is refused; with none, the folder builds on the
// instance's Horizon.
let bad = [
+2 -2
View File
@@ -80,8 +80,8 @@ pub fn main(args: &[String]) -> Result<(), String> {
};
let author = flag(rest, "--author").ok_or(USAGE)?;
let (base, _) = registry::load(Path::new(base)).map_err(lines)?;
let (head, revoked) = registry::load(Path::new(head)).map_err(lines)?;
let review = registry::review_with_revoked(&base, &head, &revoked, author);
let (head, _) = registry::load(Path::new(head)).map_err(lines)?;
let review = registry::review(&base, &head, author);
if rest.iter().any(|a| a == "--json") {
let json = serde_json::json!({"errors": review.errors, "notes": review.notes});
println!("{json}");
+2 -21
View File
@@ -26,23 +26,18 @@ pub struct AnimationMetadata {
pub enum ClipMode {
/// `day_time`: frame 0 at time 0, the last at the cycle's end, then again.
DayTime { cycle: i64 },
/// `screen_time`: the ticks the open screen has been open, over the cycle, held on the last
/// frame once it has run (it doesn't loop, measured on the real mod).
ScreenTime { cycle: i64 },
/// `weather`: frame 0 in clear weather, 1 in rain, 2 in a storm (the rain and thunder levels
/// added up), at most the last.
Weather,
/// A Polytone expression: its value's fraction is how far through the animation it is.
Expression(String),
/// `game_time`, `season`: not driven; the animation runs on ticks.
/// `game_time`, `screen_time`, `season`: not driven yet; the animation runs on ticks.
Unsupported(String),
}
/// The values [`ClipMode`]s read this frame.
pub struct ModeInputs<'a> {
pub day_time: i64,
/// The ticks the open screen has been open (0 without one).
pub screen_time: i64,
/// The rain level plus the thunder level, 0 to 2.
pub weather: f32,
/// An expression's value this frame, by its source.
@@ -121,8 +116,7 @@ impl AnimationMetadata {
match mode.as_str() {
"day_time" => ClipMode::DayTime { cycle },
"weather" => ClipMode::Weather,
"screen_time" => ClipMode::ScreenTime { cycle },
"game_time" | "season" => ClipMode::Unsupported(mode),
"game_time" | "screen_time" | "season" => ClipMode::Unsupported(mode),
_ => ClipMode::Expression(mode),
}
}),
@@ -245,9 +239,6 @@ impl AnimationClip {
pub fn sample_mode(&self, inputs: &ModeInputs) -> Option<AnimationSample> {
let fraction = match self.mode.as_ref()? {
ClipMode::DayTime { cycle } => day_fraction(inputs.day_time, *cycle),
ClipMode::ScreenTime { cycle } => {
(inputs.screen_time.max(0) as f64 / *cycle as f64).min(1.0 - f64::EPSILON)
}
ClipMode::Weather => {
let last = self.sequence.len() - 1;
let index = (inputs.weather.max(0.0) as usize).min(last);
@@ -297,7 +288,6 @@ mod mode_tests {
let expression = move |_: &str| Some(value);
clip.sample_mode(&ModeInputs {
day_time,
screen_time: day_time,
weather,
expression: &expression,
})
@@ -312,13 +302,6 @@ mod mode_tests {
.map(|t| frame(&day, t, 0.0, 0.0))
.into();
assert_eq!(frames, [0, 1, 2, 3, 1]);
// 26.3-6.8.6, four frames over 200 ticks: 40 ticks open show the first, and from ten
// seconds on the last, even after 27 (it doesn't loop).
let screen = clip(r#"{"frametime": 1, "mode": "screen_time", "time_cycle_duration": 200}"#);
let frames: Vec<u32> = [0, 40, 200, 360, 540]
.map(|t| frame(&screen, t, 0.0, 0.0))
.into();
assert_eq!(frames, [0, 0, 3, 3, 3]);
let value = clip(r#"{"frametime": 1, "mode": "0.6"}"#);
assert_eq!(frame(&value, 0, 0.0, 0.6), 2);
assert_eq!(frame(&value, 0, 0.0, 2.0), 0);
@@ -338,7 +321,6 @@ mod mode_tests {
clip(r#"{"frametime": 1}"#)
.sample_mode(&ModeInputs {
day_time: 0,
screen_time: 0,
weather: 0.0,
expression: &|_| None
})
@@ -348,7 +330,6 @@ mod mode_tests {
clip(r#"{"frametime": 1, "mode": "season"}"#)
.sample_mode(&ModeInputs {
day_time: 0,
screen_time: 0,
weather: 0.0,
expression: &|_| None
})
-2
View File
@@ -7,8 +7,6 @@ rust-version.workspace = true
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
[dependencies]
# The moderation durations, shared with `/tempban`.
horizon-server-files = { path = "../horizon-server-files" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1.1"
+55 -3
View File
@@ -10,9 +10,6 @@ use std::path::{Path, PathBuf};
mod normalise;
pub use horizon_server_files::duration::{
DurationUnit, duration_parts, duration_text, now_millis, parse_duration,
};
pub use normalise::{Run, Text};
/// The list built into the game, used when `words_file` is empty.
@@ -477,6 +474,61 @@ pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
writeln!(f, "{line}")
}
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
/// `w` (`10m`, `2h`, `1d`).
pub fn parse_duration(text: &str) -> Option<i64> {
let unit = match text.chars().last()? {
's' => 1_000,
'm' => MINUTE,
'h' => 60 * MINUTE,
'd' => 24 * 60 * MINUTE,
'w' => 7 * 24 * 60 * MINUTE,
_ => return None,
};
let count: i64 = text[..text.len() - 1].parse().ok()?;
(count > 0).then(|| count.checked_mul(unit)).flatten()
}
/// The unit used when presenting a moderation duration to a player.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DurationUnit {
Minute,
Hour,
Day,
}
/// A duration's rounded count and unit, shared by logs and translated replies.
pub fn duration_parts(millis: i64) -> (i64, DurationUnit) {
let (unit, kind) = if millis <= 60 * MINUTE {
(MINUTE, DurationUnit::Minute)
} else if millis <= 48 * 60 * MINUTE {
(60 * MINUTE, DurationUnit::Hour)
} else {
(24 * 60 * MINUTE, DurationUnit::Day)
};
(
millis.div_euclid(unit) + i64::from(millis.rem_euclid(unit) > 0),
kind,
)
}
/// `millis` for logs, rounded up: minutes up to an hour, hours up to two days, then days.
pub fn duration_text(millis: i64) -> String {
let (count, unit) = duration_parts(millis);
let unit = match unit {
DurationUnit::Minute => "minute",
DurationUnit::Hour => "hour",
DurationUnit::Day => "day",
};
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
}
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64)
}
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
pub fn utc(millis: i64) -> String {
let secs = millis.div_euclid(1000);
+3 -1
View File
@@ -13,9 +13,11 @@ winit = "0.30.13"
horizon-theme = { path = "../horizon-theme" }
png = "0.18"
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
# The DualSense's trigger effects (the `controllers` feature).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
[features]
controllers = []
controllers = ["dep:horizon-dualsense"]
[lints]
workspace = true
+109 -12
View File
@@ -1441,16 +1441,6 @@ pub enum Rumble {
},
Totem,
}
impl From<crate::input_source::Feedback> for Rumble {
fn from(event: crate::input_source::Feedback) -> Self {
use crate::input_source::Feedback;
match event {
Feedback::BlockBroken => Rumble::BlockBroken,
Feedback::Explosion { distance, radius } => Rumble::Explosion { distance, radius },
Feedback::Totem => Rumble::Totem,
}
}
}
/// A vibration: the heavy (low-frequency) and light motors, 0..1, and how
/// long it lasts.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -1533,8 +1523,9 @@ impl Rumble {
}
}
/// What a trigger feels like, from what its action is doing (docs/controllers.md "Adaptive
/// triggers"); the controller code turns it into a DualSense trigger effect.
/// What a trigger feels like on a DualSense, from what its action is doing (docs/controllers.md
/// "Adaptive triggers" for the choices). Only the pad's official modes: Nielk1's notes warn the
/// others may leave the firmware.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum TriggerFeel {
Free,
@@ -1553,6 +1544,66 @@ pub enum TriggerFeel {
/// Mining a block of this hardness: a buzz, stronger and slower on harder blocks.
Texture(f32),
}
#[cfg(feature = "controllers")]
impl TriggerFeel {
/// The effect, for a trigger that presses its action at `threshold` (0..1) of its travel.
pub fn effect(self, threshold: f32) -> horizon_dualsense::TriggerEffect {
use horizon_dualsense::trigger;
// The zone (of ten along the travel) where the action presses: a catch gives way there,
// so the click and the shot coincide.
let press = ((threshold.clamp(0.0, 1.0) * 10.0) as u8).clamp(3, 8);
match self {
TriggerFeel::Free => horizon_dualsense::TRIGGER_OFF,
// From the third zone, so the press itself stays light; at most 6 of 8, as the
// draw is held.
TriggerFeel::Draw(charge) => {
trigger::feedback(2, 1 + (5.0 * charge.clamp(0.0, 1.0)).round() as u8)
}
TriggerFeel::Click => trigger::weapon((press - 2).max(2), press, 8),
TriggerFeel::Boost => trigger::weapon((press - 2).max(2), press, 5),
TriggerFeel::Brace => trigger::feedback(2, 3),
TriggerFeel::Locked => trigger::feedback(1, 8),
TriggerFeel::Texture(hardness) if hardness > 0.0 => {
// Dirt 2, stone 3, deepslate 4, obsidian 5 of 8.
let amplitude = (1.0 + 1.6 * hardness.sqrt()).round().clamp(1.0, 5.0);
// Sand and dirt a fine 40 Hz grit; stone 30; wood, deepslate and ores 20;
// obsidian a 10 Hz chisel.
let hertz = match hardness {
h if h < 1.0 => 40,
h if h < 2.0 => 30,
h if h < 5.0 => 20,
_ => 10,
};
trigger::vibration(1, amplitude as u8, hertz)
}
TriggerFeel::Texture(_) => horizon_dualsense::TRIGGER_OFF,
}
}
}
#[cfg(feature = "controllers")]
impl PadSettings {
/// The left and right triggers' effects: each feels the action bound to it, `key.use` or
/// `key.attack`.
pub fn trigger_effects(
&self,
using: TriggerFeel,
attacking: TriggerFeel,
) -> [horizon_dualsense::TriggerEffect; 2] {
[Button::LeftTrigger, Button::RightTrigger].map(|trigger| {
let actions = self.actions_of(trigger);
let feel = if !self.adaptive_triggers {
TriggerFeel::Free
} else if actions.contains(&"key.use") {
using
} else if actions.contains(&"key.attack") {
attacking
} else {
TriggerFeel::Free
};
feel.effect(self.trigger_threshold)
})
}
}
/// What a DualSense's lightbar shows (`lightbar:`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Lightbar {
@@ -2387,6 +2438,52 @@ bind_key.hotbar.2:north
assert_eq!(w.observe(Some(1.0)), Some(1.0));
}
#[cfg(feature = "controllers")]
#[test]
fn triggers_feel_their_action() {
use horizon_dualsense::{TRIGGER_OFF, trigger};
let mut s = PadSettings::default();
// Use is LT, attack RT by default.
let [left, right] = s.trigger_effects(TriggerFeel::Draw(1.0), TriggerFeel::Texture(1.5));
assert_eq!(left, trigger::feedback(2, 6));
assert_eq!(right, trigger::vibration(1, 3, 30));
assert_eq!(
s.trigger_effects(TriggerFeel::Draw(0.0), TriggerFeel::Free),
[trigger::feedback(2, 1), TRIGGER_OFF]
);
// The catch gives way in the zone where the default threshold (0.4) presses: weapon
// 0x25, zones 2 and 4, strength 8 (stored 7) or 5 (stored 4).
let catch = |strength| [0x25, 0x14, 0x00, strength, 0, 0, 0, 0, 0, 0, 0];
assert_eq!(TriggerFeel::Click.effect(0.4), catch(7));
assert_eq!(TriggerFeel::Boost.effect(0.4), catch(4));
// A deeper threshold moves it: zones 5 and 7.
assert_eq!(TriggerFeel::Click.effect(0.7)[1..3], [0xa0, 0x00]);
assert_eq!(TriggerFeel::Click.effect(0.1), trigger::weapon(2, 3, 8));
assert_eq!(TriggerFeel::Brace.effect(0.4), trigger::feedback(2, 3));
assert_eq!(TriggerFeel::Locked.effect(0.4), trigger::feedback(1, 8));
// Harder blocks buzz harder and slower; instant-break blocks don't.
assert_eq!(
TriggerFeel::Texture(0.5).effect(0.4),
trigger::vibration(1, 2, 40)
);
assert_eq!(
TriggerFeel::Texture(3.0).effect(0.4),
trigger::vibration(1, 4, 20)
);
assert_eq!(
TriggerFeel::Texture(50.0).effect(0.4),
trigger::vibration(1, 5, 10)
);
assert_eq!(TriggerFeel::Texture(0.0).effect(0.4), TRIGGER_OFF);
assert_eq!(TriggerFeel::Texture(-1.0).effect(0.4), TRIGGER_OFF);
s.adaptive_triggers = false;
assert_eq!(
s.trigger_effects(TriggerFeel::Click, TriggerFeel::Boost),
[TRIGGER_OFF; 2]
);
assert!(!PadSettings::parse(&s.serialize()).adaptive_triggers);
}
#[test]
fn lightbar_colours() {
let full = Lightbar::Health.colour(1.0, "minecraft:overworld").unwrap();
@@ -1,15 +0,0 @@
//! The engine's side of the input source (docs/layers.md, "Controllers, splitscreen, replays and
//! UI pages"): what gameplay tells a device beyond the keyboard and mouse. Gameplay names these
//! types, never a controller's; the controller code answers.
/// Something that happened to the player that a device may let them feel (a controller's
/// vibration). The device decides how, or whether.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Feedback {
/// The player broke a block.
BlockBroken,
/// An explosion this far away (blocks) with this power.
Explosion { distance: f64, radius: f32 },
/// A totem of undying saved the player.
Totem,
}
-1
View File
@@ -9,7 +9,6 @@ pub mod gamepad;
pub mod gameplay_guide;
pub mod held;
pub mod input;
pub mod input_source;
pub mod interaction_input;
pub mod key_names;
pub mod keybinds;
-33
View File
@@ -670,39 +670,6 @@ pub fn with_horizon_icon(
attributes
}
/// The screen a window is on: its size in physical pixels, its refresh rate where the OS gives
/// it, and its scale. For `horizon-ctl status` and the performance statistics' `system.display`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Display {
pub width: u32,
pub height: u32,
pub refresh_hz: Option<u32>,
pub scale: f64,
}
/// The screen `window` is on, else the primary one, else the only one: right after the window
/// is made, Wayland doesn't say yet which screen it is on.
pub fn monitor(window: &winit::window::Window) -> Option<winit::monitor::MonitorHandle> {
let only = || {
let mut all = window.available_monitors();
all.next().filter(|_| all.next().is_none())
};
window
.current_monitor()
.or_else(|| window.primary_monitor())
.or_else(only)
}
/// [`monitor`]'s size, refresh rate and scale.
pub fn display(window: &winit::window::Window) -> Option<Display> {
monitor(window).map(|m| Display {
width: m.size().width,
height: m.size().height,
refresh_hz: m.refresh_rate_millihertz().map(|r| r.div_ceil(1000)),
scale: m.scale_factor(),
})
}
#[cfg(test)]
mod tests {
use super::*;
+14 -8
View File
@@ -18,10 +18,10 @@ horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
# Emotes pose the player model (`Mob::emote`, `mobs::anim::setup`).
horizon-cosmetics = { path = "../horizon-cosmetics" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# Extension points for the crates above the engine (docs/layers.md, "How a Horizon crate plugs in").
horizon-hooks = { path = "../horizon-hooks" }
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
@@ -42,6 +42,8 @@ horizon-chat-filter = { path = "../horizon-chat-filter" }
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
# Play with Friends: invite codes and their tunnel; the `friends` feature.
horizon-friends = { path = "../horizon-friends", optional = true }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
@@ -71,13 +73,16 @@ mio = { version = "1.2.4", features = ["os-poll", "net"] }
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`), the `heartbeat` feature.
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
crc32fast = "1"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
@@ -109,17 +114,18 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers", "horizon-extras"]
default = ["audio", "controllers", "discord", "friends", "heartbeat"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
# Horizon's own parts (docs/forking.md, docs/layers.md): the features below, and the official
# server in the list (`official_servers`).
horizon-extras = ["discord"]
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
discord = []
# Play with Friends, joining side (`net.rs`): without it no invite code joins.
friends = ["dep:horizon-friends"]
# The anonymous "I'm playing" ping (`heartbeat.rs`, docs/telemetry.md).
heartbeat = ["horizon-telemetry/live"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
+4 -6
View File
@@ -7,8 +7,6 @@ rust-version.workspace = true
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
# The settings stores of the crates above the engine (docs/layers.md, "Platform registries").
horizon-hooks = { path = "../horizon-hooks" }
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"] }
@@ -26,6 +24,7 @@ horizon-narrator = { path = "../horizon-narrator" }
horizon-optifine = { path = "../horizon-optifine" }
horizon-polytone = { path = "../horizon-polytone" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-render-pipeline = { path = "../horizon-render-pipeline" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
horizon-system = { path = "../horizon-system" }
@@ -76,10 +75,9 @@ wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
# horizon-client's `horizon-extras`: asking the device for hardware ray tracing on request
# (`gfx/ray_query.rs`, `HORIZON_RAY_TRACING=1`). Without it the device is requested as without
# the variable.
horizon-extras = []
[build-dependencies]
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
[lints]
workspace = true
+7
View File
@@ -0,0 +1,7 @@
fn main() {
let root =
std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("../..");
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
horizon_mod_api_gen::generate(&root, &out_dir, horizon_mod_api_gen::Target::Render)
.expect("generating native render mod API");
}
@@ -1,158 +0,0 @@
//! Audio occlusion, attenuation and mixing benchmark.
//! Run with: `cargo run --profile fast -p horizon-client-render --example audio_bench`
use horizon_client_render::{
audio::Audio,
sound_engine::{MobSound, SoundInstance, Sounds, TickContext},
};
use std::{path::Path, time::Instant};
fn main() {
println!("=== Horizon Audio Attenuation & Mixing Benchmark ===");
let mut audio = Audio::silent(Path::new("/nonexistent"));
audio.engine.loaded = true;
audio.backend_live_for_bench();
audio.record_heard = false;
// Register a sound event with attenuation distance 16
audio.engine.registry.add_file(
"minecraft",
br#"{"bench.loop":{"attenuation_distance":16,"sounds":[{"name":"bench/loop","stream":false}]}}"#,
&|_, _| true,
);
// Insert 1 second of audio into cache (48000 mono samples)
let samples: Vec<f32> = (0..48_000)
.map(|i| ((i as f32) * 440.0 * 2.0 * std::f32::consts::PI / 48000.0).sin() * 0.5)
.collect();
audio.insert_cached_sound("minecraft:bench/loop", samples, 48_000, 1);
// 1. Benchmark set_listener (called every client render frame)
let listener_iterations = 200_000;
let start = Instant::now();
for i in 0..listener_iterations {
let angle = (i as f32) * 0.01;
audio.set_listener(
[
100.0 + (angle.cos() as f64) * 5.0,
64.0,
200.0 + (angle.sin() as f64) * 5.0,
],
angle,
0.0,
);
}
let listener_elapsed = start.elapsed();
println!(
"1. set_listener moving (render frame): {:?} for {} calls ({:.2} ns/call)",
listener_elapsed,
listener_iterations,
(listener_elapsed.as_nanos() as f64) / (listener_iterations as f64)
);
// 1b. Benchmark set_listener stationary (lock elimination early-out)
let stationary_iterations = 200_000;
let start = Instant::now();
for _ in 0..stationary_iterations {
audio.set_listener([100.0, 64.0, 200.0], 0.0, 0.0);
}
let stationary_elapsed = start.elapsed();
println!(
"1b. set_listener stationary (lock early-out): {:?} for {} calls ({:.2} ns/call)",
stationary_elapsed,
stationary_iterations,
(stationary_elapsed.as_nanos() as f64) / (stationary_iterations as f64)
);
// 2. Play 128 sounds in the world at various distances from listener at [100.0, 64.0, 200.0]
// 64 sounds within attenuation distance (<16 blocks)
// 64 sounds outside attenuation distance (>16 blocks)
for i in 0..128 {
let dist = if i < 64 {
(i as f64) * 0.24 // 0 to 15.1 blocks (audible)
} else {
17.0 + ((i - 64) as f64) * 1.5 // 17 to 111.5 blocks (out of attenuation range)
};
let pos = [100.0 + dist, 64.0, 200.0];
let instance = SoundInstance::new(
"minecraft:bench.loop",
crate_sound_source(i),
1.0,
1.0,
horizon_core::random::LegacyRandom::new(i as i64),
true, // looping
0,
horizon_client_render::sound_engine::Attenuation::Linear,
pos,
false,
);
audio.play_instance(instance, false);
}
// 3. Benchmark mix_buffer (5000 buffers of 1024 stereo frames @ 48kHz = ~106 seconds of audio mixing)
let mut buffer = vec![0.0f32; 1024 * 2];
let mix_iterations = 5_000;
let start = Instant::now();
for _ in 0..mix_iterations {
audio.mix_buffer(&mut buffer, 2, 48_000);
}
let mix_elapsed = start.elapsed();
println!(
"2. mix_buffer (128 active channels, 1024 stereo frames): {:?} for {} buffers ({:.2} µs/buffer)",
mix_elapsed,
mix_iterations,
(mix_elapsed.as_micros() as f64) / (mix_iterations as f64)
);
// 4. Benchmark SoundEngine::tick with ticking mob sounds
let mut mobs = Vec::new();
for i in 0..64 {
mobs.push(MobSound {
id: 2000 + i,
position: [100.0 + (i as f64) * 0.5, 64.0, 200.0 + (i as f64) * 0.5],
speed: 0.25,
baby: false,
angry: i % 2 == 0,
silent: false,
attack_scale: 0.5,
sniffer_digging: false,
});
}
let tick_ctx = TickContext {
player_underwater: false,
player_removed: false,
vehicle: None,
carts: Vec::new(),
mobs: mobs.clone(),
frozen: false,
player_position: [100.0, 64.0, 200.0],
player_speed_sqr: 0.1,
player_fall_flying: false,
ridden: None,
camera_position: [100.0, 64.0, 200.0],
};
let tick_iterations = 10_000;
let start = Instant::now();
for _ in 0..tick_iterations {
audio.tick(false, &tick_ctx);
}
let tick_elapsed = start.elapsed();
println!(
"3. SoundEngine::tick (64 channels + ticking): {:?} for {} ticks ({:.2} µs/tick)",
tick_elapsed,
tick_iterations,
(tick_elapsed.as_micros() as f64) / (tick_iterations as f64)
);
}
fn crate_sound_source(i: u32) -> usize {
match i % 6 {
0 => 0, // master
1 => 1, // music
2 => 2, // records
3 => 3, // weather
4 => 4, // blocks
_ => 5, // hostile
}
}
+2 -2
View File
@@ -382,8 +382,8 @@ impl RenderAssets {
};
// After the connected tiles: a tile identical to a block shares its layer, and the
// overlays skip shared layers.
if draws && let Some(suffix) = crate::pack_hooks::formats().emissive_suffix(root) {
emissive = crate::native_emissive::load_overlays(root, &suffix, &mut sprites);
if draws {
emissive = crate::native_emissive::load_optifine(root, &mut sprites);
}
sprites.save_disk_cache();
if draws {
@@ -233,11 +233,11 @@ pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
}
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
let rel = format!("assets/{namespace}/{directory}/{path}.png");
// No `.`, `..` or empty segment, and no `\` (a separator on Windows, where `..\..` climbs).
let stays = rel
.split('/')
.all(|segment| !matches!(segment, "" | "." | "..") && !segment.contains('\\'));
root.join(if stays { &rel } else { NOWHERE })
root.join(if horizon_optifine::paths::valid(&rel) {
&rel
} else {
NOWHERE
})
}
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
+52 -166
View File
@@ -478,7 +478,6 @@ pub struct Audio {
pub record_heard: bool,
pub listener: [f64; 3],
pub listener_yaw: f32,
pub listener_pitch: f32,
pub master: f32,
/// Seeds of positional plays (the client level random's `nextLong`).
random: LegacyRandom,
@@ -723,29 +722,6 @@ impl Backend for MixerBackend {
fn set_unmuffled(&mut self, channel: ChannelId, unmuffled: bool) {
self.with(channel, |c| c.unmuffled = unmuffled);
}
fn update_channel(&mut self, channel: ChannelId, volume: f32, pitch: f32, position: [f64; 3]) {
if let Ok(mut m) = self.mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
{
c.gain = volume;
c.pitch = pitch;
c.at = [position[0] as f32, position[1] as f32, position[2] as f32];
}
}
fn filter_stopped(&mut self, live: &[(u64, ChannelId)], stopped: &mut Vec<u64>) {
if let Ok(m) = self.mixer.lock() {
for &(h, c) in live {
if m.channels
.get(&c)
.is_none_or(|ch| ch.state == State::Stopped)
{
stopped.push(h);
}
}
} else {
stopped.extend(live.iter().map(|&(h, _)| h));
}
}
/// `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,
@@ -846,24 +822,18 @@ impl Backend for MixerBackend {
/// pinned client's) rendered through `ALC_SOFT_loopback` within 1e-5.
fn stereo_gains(right: f32, up: f32, forward: f32) -> [f32; 2] {
let length = (right * right + up * up + forward * forward).sqrt();
stereo_gains_with_length(right, up, forward, length)
}
#[inline]
fn stereo_gains_with_length(right: f32, up: f32, forward: f32, length: f32) -> [f32; 2] {
if length <= f32::EPSILON {
return stereo_gains_with_length(0.0, 0.0, 1.0, 1.0);
return stereo_gains(0.0, 0.0, 1.0);
}
let u = (up / length).clamp(-1.0, 1.0);
let cos_elev = (1.0 - u * u).max(0.0).sqrt();
let elevation = (up / length).clamp(-1.0, 1.0).asin();
let mut azimuth = right.atan2(forward);
if azimuth.abs() < std::f32::consts::FRAC_PI_2 {
azimuth = (azimuth.abs() * 1.5)
.min(std::f32::consts::FRAC_PI_2)
.copysign(azimuth);
}
let (sin_az, cos_az) = azimuth.sin_cos();
let side = sin_az * cos_elev;
let front = cos_az * cos_elev * 0.095_660_8;
let side = azimuth.sin() * elevation.cos();
let front = azimuth.cos() * elevation.cos() * 0.095_660_8;
[0.5 - 0.5 * side + front, 0.5 + 0.5 * side + front]
}
/// The gain OpenAL applies for distance (`AL_LINEAR_DISTANCE` with reference
@@ -883,10 +853,16 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
// and OpenAL's right = forward x up.
let basis = |yaw: f32, pitch: f32| {
let (yaw, pitch) = (f64::from(yaw.to_radians()), f64::from(pitch.to_radians()));
let (yaw_sin, yaw_cos) = yaw.sin_cos();
let (pitch_sin, pitch_cos) = pitch.sin_cos();
let forward = [-yaw_sin * pitch_cos, -pitch_sin, yaw_cos * pitch_cos];
let up = [-yaw_sin * pitch_sin, pitch_cos, yaw_cos * pitch_sin];
let forward = [
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
];
let up = [
-yaw.sin() * pitch.sin(),
pitch.cos(),
yaw.cos() * pitch.sin(),
];
let right = [
forward[1] * up[2] - forward[2] * up[1],
forward[2] * up[0] - forward[0] * up[2],
@@ -920,7 +896,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
// Gains and pans per source for this buffer.
let mut voices = std::mem::take(&mut mixer.voices);
voices.clear();
for (id, c) in &mut mixer.channels {
for (id, c) in &mixer.channels {
let Some((_, source_rate, source_channels)) = &c.samples else {
continue;
};
@@ -931,7 +907,6 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
if c.state != State::Playing {
continue;
}
let at = [f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])];
let away = |listener: [f64; 3]| {
[
@@ -940,56 +915,45 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
at[2] - listener[2],
]
};
let length_sq = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
let camera = if !shared {
let cam = &mixer.cameras[0];
if c.relative || cam.2 == c.world {
Some((cam.0, cam.1))
} else {
None
}
} else {
mixer
.cameras
.iter()
.filter(|camera| c.relative || camera.2 == c.world)
.min_by(|a, b| length_sq(away(a.0)).total_cmp(&length_sq(away(b.0))))
.map(|camera| (camera.0, camera.1))
};
let length = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
// The nearest camera hears it (one camera but in splitscreen).
let camera = mixer
.cameras
.iter()
.filter(|camera| c.relative || camera.2 == c.world)
.min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0))))
.map(|camera| (camera.0, camera.1));
// Unheard sources still advance: returning to a world must not resume stale sounds.
let (listener, (forward, up, right)) = camera.unwrap_or(([0.0; 3], basis(0.0, 0.0)));
let d = if c.relative { at } else { away(listener) };
let d_sqr = length_sq(d);
let (gain, [left, right]) = if camera.is_none() {
(0.0, [0.0, 0.0])
} else if direct {
let gain = c.gain.clamp(0.0, 1.0);
(gain, [gain, gain])
} else {
let mut zero = false;
if let Some(max) = c.linear {
let max_d = max as f64;
if max <= 0.0 || (!c.relative && d_sqr >= max_d * max_d) {
zero = true;
}
}
if zero {
(0.0, [0.0, 0.0])
} else {
let distance = d_sqr.sqrt() as f32;
let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
let [mut left, mut right] = if c.relative {
stereo_gains_with_length(d[0] as f32, d[1] as f32, -d[2] as f32, distance)
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
let gain = if camera.is_some() {
c.gain.clamp(0.0, 1.0)
* if direct {
1.0
} else {
stereo_gains_with_length(dot(d, right), dot(d, up), dot(d, forward), distance)
};
if shared {
let middle = (left + right) * 0.5;
left = middle + (left - middle) * 0.5;
right = middle + (right - middle) * 0.5;
distance_gain(c.linear, distance)
}
(gain, [left * gain, right * gain])
} else {
0.0
};
let [left, right] = if direct {
[gain, gain]
} else {
// Relative sources are already in OpenAL's listener space (-Z ahead).
let [mut left, mut right] = if c.relative {
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
} else {
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
};
if shared {
// Several players on the speakers: half the stereo width, as left and right are
// nobody's in particular.
let middle = (left + right) / 2.0;
left = middle + (left - middle) / 2.0;
right = middle + (right - middle) / 2.0;
}
[left * gain, right * gain]
};
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, [left, right, gain], step));
@@ -1010,40 +974,6 @@ 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 inaudible = if channels >= 2 {
left == 0.0 && right == 0.0
} else {
gain == 0.0
};
if inaudible {
let buffer_frames = data.len() / channels;
// As the loop below: a sound of one frame or none stops, looping or not.
if !c.looping || frames <= 1 {
// `frames` may be 0: no `frames - 1` in usize. A step of 0 never
// reaches the end, as in the loop below.
let needed = frames as f64 - 1.0 - c.position;
if needed <= 0.0 {
c.state = State::Stopped;
} else if step > 0.0 {
let steps_to_stop = needed / step;
if (buffer_frames as f64) >= steps_to_stop {
c.state = State::Stopped;
} else {
c.position += step * (buffer_frames as f64);
}
}
} else {
for _ in 0..buffer_frames {
let i = c.position as usize;
if i + 1 >= frames {
c.position = 0.0;
}
c.position += step;
}
}
continue;
}
let out: &mut [f32] = if muffling && !c.unmuffled {
&mut bus
} else {
@@ -1062,8 +992,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
}
let t = (c.position - i as f64) as f32;
if source_channels > 1 {
let l = samples[i * 2] + (samples[(i + 1) * 2] - samples[i * 2]) * t;
let r = samples[i * 2 + 1] + (samples[(i + 1) * 2 + 1] - samples[i * 2 + 1]) * t;
let l = samples[i * 2] * (1.0 - t) + samples[(i + 1) * 2] * t;
let r = samples[i * 2 + 1] * (1.0 - t) + samples[(i + 1) * 2 + 1] * t;
if channels >= 2 {
frame[0] += l * left;
frame[1] += r * right;
@@ -1071,7 +1001,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
frame[0] += (l + r) * 0.5 * gain;
}
} else {
let s = samples[i] + (samples[i + 1] - samples[i]) * t;
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
if channels >= 2 {
frame[0] += s * left;
frame[1] += s * right;
@@ -1482,7 +1412,6 @@ impl Audio {
record_heard: false,
listener: [0.0; 3],
listener_yaw: 0.0,
listener_pitch: 0.0,
master: 1.0,
random: crate::random_source::create(),
source_override: None,
@@ -1606,12 +1535,8 @@ impl Audio {
/// `SoundManager.updateSource`: the camera position and rotation
/// (`Listener.setTransform` takes its forward and up vectors).
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32, pitch: f32) {
if self.listener == position && self.listener_yaw == yaw && self.listener_pitch == pitch {
return;
}
self.listener = position;
self.listener_yaw = yaw;
self.listener_pitch = pitch;
if let Ok(mut m) = self.backend.mixer.lock() {
if self.backend.owner == 0 {
m.listener = position;
@@ -1651,7 +1576,6 @@ impl Audio {
record_heard: false,
listener: [0.0; 3],
listener_yaw: 0.0,
listener_pitch: 0.0,
master: self.master,
random: crate::random_source::create(),
source_override: None,
@@ -2185,25 +2109,6 @@ impl Audio {
})
.collect()
}
/// Mixes one buffer of audio into `data` (channels: 1 or 2, rate in Hz).
pub fn mix_buffer(&mut self, data: &mut [f32], channels: usize, rate: u32) {
if let Ok(mut m) = self.backend.mixer.lock() {
mix(&mut m, data, channels, rate);
}
}
#[doc(hidden)]
pub fn insert_cached_sound(&mut self, key: &str, samples: Vec<f32>, rate: u32, channels: u16) {
if let Ok(mut c) = self.backend.cache.lock() {
c.insert(
key.to_owned(),
Some((std::sync::Arc::new(samples), rate, channels)),
);
}
}
#[doc(hidden)]
pub fn backend_live_for_bench(&mut self) {
self.backend.live = true;
}
}
impl Sounds for Audio {
fn engine(&mut self) -> &mut SoundEngine {
@@ -2708,25 +2613,6 @@ mod tests {
assert_eq!(data, [0.0; 4], "returning must not resume the old one-shot");
}
/// A sound of one frame or none, heard or not, looping or not, stops at its first
/// buffer as the mixing loop stops it: none stays playing (and holds its channel).
#[test]
fn sounds_too_short_to_mix_stop_even_unheard() {
for (samples, looping) in [(vec![], false), (vec![1.0], false), (vec![1.0], true)] {
let mut mixer = Mixer::default();
let mut source = channel(samples.clone(), None, [0.0; 3], looping);
source.gain = 0.0;
mixer.channels.insert(1, source);
let mut data = [0.0; 4];
mix(&mut mixer, &mut data, 2, 48_000);
assert_eq!(
mixer.channels[&1].state,
State::Stopped,
"{samples:?} {looping}"
);
}
}
#[test]
fn variants_of_one_sound_are_the_same_sound() {
assert_eq!(
-39
View File
@@ -16,45 +16,6 @@ use crate::{
#[path = "chat_tests.rs"]
mod tests;
/// `FilterMask.applyWithFormatting` over a player message's signed content, as
/// the packet's mask `(type, bit set)`: as it is when passed through, none when
/// fully filtered, and when partly filtered each masked UTF-16 unit becomes a
/// `#` in `FILTERED_STYLE` (dark grey, hover "chat.filtered").
pub fn filtered_message(
content: &str,
(kind, bits): &(i32, Vec<i64>),
) -> Option<serde_json::Value> {
match kind {
1 => return None,
2 => {}
_ => return Some(serde_json::Value::from(content)),
}
let masked = |i: usize| {
bits.get(i / 64)
.is_some_and(|w| (*w as u64 >> (i % 64)) & 1 != 0)
};
let units: Vec<u16> = content.encode_utf16().collect();
let mut parts = Vec::new();
let mut start = 0;
while start < units.len() {
let filtered = masked(start);
let end = (start..units.len())
.find(|&i| masked(i) != filtered)
.unwrap_or(units.len());
parts.push(if filtered {
serde_json::json!({
"text": "#".repeat(end - start),
"color": "dark_gray",
"hover_event": {"action": "show_text", "value": {"translate": "chat.filtered"}},
})
} else {
serde_json::Value::from(String::from_utf16_lossy(&units[start..end]))
});
start = end;
}
Some(serde_json::json!({"text": "", "extra": parts}))
}
/// `CommandHistory.PERSISTED_COMMANDS_FILE_NAME` and its size cap.
const COMMAND_HISTORY_FILE: &str = "command_history.txt";
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
@@ -1026,43 +1026,3 @@ fn a_compact_repeat_keeps_the_scrolled_view_still() {
says(&mut chat, "new");
assert_eq!(chat.scroll, 6);
}
/// `FilterMask.applyWithFormatting`: a pass-through mask keeps the message, a
/// fully filtered one hides it, and a partial one turns each masked UTF-16 unit
/// into a dark grey `#` whose hover reads `chat.filtered`.
#[test]
fn a_server_filter_mask_hides_or_masks_the_message() {
let text = |v: &serde_json::Value| crate::component::to_text(v, &mut |s| Text::literal(s));
let shown = |content: &str, mask: (i32, Vec<i64>)| {
filtered_message(content, &mask).map(|v| text(&v).plain())
};
assert_eq!(shown("hello", (0, vec![])), Some("hello".to_owned()));
assert_eq!(shown("hello", (1, vec![])), None);
assert_eq!(shown("hello", (2, vec![0b0110])), Some("h##lo".to_owned()));
assert_eq!(
shown("hello", (2, vec![0b1_0001])),
Some("#ell#".to_owned())
);
// An emoji is two UTF-16 units, so two bits and two `#`.
assert_eq!(
shown("a\u{1F600}b", (2, vec![0b0110])),
Some("a##b".to_owned())
);
// Bit 65 is in the second word.
let long = "x".repeat(70);
let mut expected = long.clone();
expected.replace_range(65..66, "#");
assert_eq!(shown(&long, (2, vec![0, 0b10])), Some(expected));
let masked = text(&filtered_message("hello", &(2, vec![0b0110])).unwrap());
let hash = masked.spans.iter().find(|s| s.text == "##").unwrap();
assert_eq!(hash.style.color, Some(text::DARK_GRAY));
let hover = hash.style.hover.as_ref().unwrap();
assert_eq!(hover.spans[0].text, "chat.filtered");
assert!(
masked
.spans
.iter()
.filter(|s| s.text != "##")
.all(|s| s.style.color.is_none())
);
}
@@ -121,8 +121,8 @@ impl CommandBlock {
condition_met: flag("conditionMet", false),
}
}
/// `CommandBlockEntity.saveAdditional`. The last output is the component
/// itself (`ComponentSerialization.CODEC`), styles and click events kept.
/// `CommandBlockEntity.saveAdditional`. The last output is written as a
/// plain string component.
pub fn save(&self) -> Vec<(&'static str, Tag)> {
let mut out = vec![
("Command", Tag::String(self.command.as_str().into())),
@@ -135,7 +135,7 @@ impl CommandBlock {
if self.track_output
&& let Some(last) = &self.last_output
{
out.push(("LastOutput", scoreboard::component_tag(&last.component())));
out.push(("LastOutput", Tag::String(last.plain().as_str().into())));
}
out.push((
"UpdateLastExecution",
+1 -6
View File
@@ -43,11 +43,6 @@ pub fn enter_native_mod(call: NativeModCall) -> NativeModGuard {
}
}
/// The native mod call this thread is currently inside, if any.
pub fn current_native_mod() -> Option<NativeModCall> {
IN_NATIVE_MOD.with(Cell::get)
}
/// Puts back the call that `enter_native_mod` found.
#[must_use]
pub struct NativeModGuard {
@@ -249,7 +244,7 @@ fn arch_label(build: &str, machine: &str) -> String {
}
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
pub(crate) fn operating_system() -> String {
fn operating_system() -> String {
let arch = arch_label(std::env::consts::ARCH, machine_arch());
let version = if cfg!(target_os = "linux") {
let name = read("/etc/os-release").and_then(|t| {
+204 -76
View File
@@ -1,34 +1,27 @@
//! Custom sky layers: skyboxes a pack draws over the vanilla sky, in a format vanilla doesn't read
//! (OptiFine's), which the platform's pack formats fill ([`crate::pack_hooks`]). The layers'
//! textures are read once with the pack; each frame the game asks for the layers to draw, their
//! brightness and rotation ([`SkyLayers::draws`]), and `gfx::custom_sky` draws them over the
//! vanilla sky. A pack without layers keeps the vanilla sky.
//! OptiFine custom skies (`horizon_optifine::sky`) as the game draws them: a pack's layers with
//! their decoded textures, read once with the pack, and each frame's choice of layers, their
//! brightness and rotation ([`CustomSky::draws`]), which `gfx::custom_sky` draws over the
//! vanilla sky. A pack without `optifine/sky` gives an empty [`CustomSky`] and the sky stays
//! vanilla.
use std::path::Path;
use std::sync::Arc;
use glam::Mat4;
use glam::{Mat4, Vec3};
use horizon_optifine::sky::{Blend, Conditions, Skies, SkyLayer, Transition};
/// A layer's texture, RGBA8: the skybox's six faces in a 3x2 grid, bottom, top and south in the
/// top row, west, north and east below (`gfx::custom_sky`'s faces).
/// A layer's texture, RGBA8.
pub struct Image {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// How a layer mixes with what is drawn below it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Blend {
Add,
Subtract,
Multiply,
Dodge,
Burn,
Screen,
Replace,
Overlay,
Alpha,
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
/// over all worlds, in `worlds` order).
#[derive(Default)]
pub struct CustomSky {
worlds: Vec<(i32, Vec<SkyLayer>)>,
pub images: Vec<Image>,
}
/// One layer of a frame: which texture, with which blend, how bright and turned how.
@@ -41,44 +34,62 @@ pub struct Draw {
pub rotation: Mat4,
}
/// The weather of a frame: thunder is a storm's rain, so it is only `Thunder`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Weather {
Clear,
Rain,
Thunder,
}
/// What a frame's layers may depend on.
#[derive(Clone, Copy, Debug)]
pub struct Conditions<'a> {
/// Ticks of the day, counting the days.
pub day_time: i64,
pub weather: Weather,
/// The biome at the camera, namespaced.
pub biome: &'a str,
/// The camera's block height.
pub y: i32,
}
/// The eased brightness of each layer, kept by the game between frames.
#[derive(Default)]
pub struct State {
/// The world the layers were last drawn for.
pub world: Option<i32>,
/// Each layer's eased visibility, 0 to 1.
pub eased: Vec<f32>,
world: Option<i32>,
eased: Vec<Transition>,
}
/// A pack's custom sky layers, by world (`0` the Overworld, `1` the End).
pub trait SkyLayers: Send + Sync {
/// Every layer's texture: [`Draw::image`] indexes it.
fn images(&self) -> &[Image];
/// Whether `world` has layers.
fn has(&self, world: i32) -> bool;
/// This frame's layers of `world` into `out` (cleared first), in drawing order, eased by
/// `seconds` since the last frame. `sun_turns` is the sun's angle over 360° (`0` at noon).
fn draws(
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
const TEXEL_BUDGET: u64 = 64 << 20;
impl CustomSky {
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
/// dropped with a warning.
pub fn load(root: &Path) -> Self {
let skies = Skies::scan(root);
let mut out = Self::default();
let mut texels = 0u64;
for (world, sky) in skies.worlds {
let mut layers = Vec::new();
for layer in sky.layers {
match crate::hud::decode(&root.join(&layer.source)) {
Ok((width, height, rgba))
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
{
texels += u64::from(width) * u64::from(height);
out.images.push(Image {
width,
height,
rgba,
});
layers.push(layer);
}
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
Err(e) => log::warn!("{}: {e:#}", layer.source),
}
}
if !layers.is_empty() {
out.worlds.push((world, layers));
}
}
out
}
pub fn is_empty(&self) -> bool {
self.worlds.is_empty()
}
/// Whether `world` has layers (`0` the Overworld, `1` the End).
pub fn has(&self, world: i32) -> bool {
self.worlds.iter().any(|(w, _)| *w == world)
}
/// This frame's layers of `world` into `out` (cleared first), in drawing order: those whose
/// conditions hold, eased over their `transition` by `seconds` since the last frame, at the
/// brightness of their fade times. `sun_turns` is the sun's angle over 360° (`0` at noon).
pub fn draws(
&self,
world: i32,
conditions: &Conditions,
@@ -86,30 +97,147 @@ pub trait SkyLayers: Send + Sync {
seconds: f32,
state: &mut State,
out: &mut Vec<Draw>,
);
}
/// No layers: the vanilla sky.
struct NoLayers;
impl SkyLayers for NoLayers {
fn images(&self) -> &[Image] {
&[]
}
fn has(&self, _: i32) -> bool {
false
}
fn draws(&self, _: i32, _: &Conditions, _: f32, _: f32, s: &mut State, out: &mut Vec<Draw>) {
) {
out.clear();
s.world = None;
let mut first = 0;
let mut found = None;
for (w, layers) in &self.worlds {
if *w == world {
found = Some(layers);
break;
}
first += layers.len();
}
let Some(layers) = found else {
state.world = None;
return;
};
// A new world (or pack) starts each layer where its conditions put it: no fade in.
let snap = state.world != Some(world) || state.eased.len() != layers.len();
if snap {
state.world = Some(world);
state.eased.clear();
state.eased.resize(layers.len(), Transition::default());
}
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
let shown = if snap {
eased.value = target;
target
} else {
eased.step(target, seconds, layer.transition)
};
let brightness = shown * layer.brightness(conditions.day_time);
if brightness <= 0.0 {
continue;
}
let turns = layer.turns(conditions.day_time, sun_turns);
let rotation = if turns == 0.0 {
Mat4::IDENTITY
} else {
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
};
out.push(Draw {
image: (first + i) as u16,
blend: layer.blend,
brightness,
rotation,
});
}
}
}
/// The custom sky layers of the overlay at `root`, from the installed pack formats.
pub fn load(root: &Path) -> Arc<dyn SkyLayers> {
crate::pack_hooks::formats()
.custom_sky(root)
.unwrap_or_else(|| Arc::new(NoLayers))
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::sky::Now;
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
let root = std::env::temp_dir().join(format!(
"horizon-custom-sky-{}-{}",
std::process::id(),
files.len()
));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join("assets/minecraft/optifine/sky/world0");
std::fs::create_dir_all(&dir).unwrap();
let png = {
let mut bytes = Vec::new();
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&[255, 0, 0, 255].repeat(24))
.unwrap();
writer.finish().unwrap();
bytes
};
for (name, text) in files {
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
}
let sky = CustomSky::load(&root);
(root, sky)
}
fn noon(biome: &str) -> Conditions<'_> {
Conditions {
day_time: 6000,
weather: Now::Clear,
biome,
y: 64,
}
}
#[test]
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
let (root, sky) = pack(&[
("sky1", "biomes=desert\ntransition=2"),
("sky2", "blend=alpha\nrotate=false"),
]);
let _ = std::fs::remove_dir_all(root);
assert!(sky.has(0) && !sky.has(1));
assert_eq!(sky.images[0].width, 6);
let (mut state, mut out) = (State::default(), Vec::new());
sky.draws(
0,
&noon("minecraft:desert"),
0.0,
0.05,
&mut state,
&mut out,
);
assert_eq!(
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
[1.0, 1.0]
);
// Out of the desert: the first layer fades over its two seconds.
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
assert_eq!(out[0].brightness, 0.75);
assert_eq!(out[1].blend, Blend::Alpha);
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
assert_eq!(out.len(), 1, "a dark layer is not drawn");
assert_eq!(out[0].image, 1);
// Into the End, which has no layers, and back: no fade.
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert!(out.is_empty());
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert_eq!(out.len(), 2);
}
#[test]
fn a_layer_turns_west_like_the_sun() {
let (root, sky) = pack(&[("sky1", "")]);
let _ = std::fs::remove_dir_all(root);
let (mut state, mut out) = (State::default(), Vec::new());
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
let c = Conditions {
day_time: 12000,
..noon("minecraft:plains")
};
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
let up = out[0].rotation.transform_vector3(Vec3::Y);
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
}
}
@@ -305,9 +305,6 @@ pub(crate) struct Colormap {
/// (`temperature`, `downfall`, `biome_id`) blend: Polytone 6.8.6's water by `biome_id`
/// fades over a biome edge.
pub(crate) biome_blend: bool,
/// Its axes read nothing that changes up a column of one biome (no `o.y`): a section of
/// one biome can sample it once per column.
pub(crate) column: bool,
width: u32,
height: u32,
rgba: Vec<u8>,
@@ -326,18 +323,6 @@ enum Axis {
}
impl Axis {
/// Whether the axis reads only what stays the same up a column of one biome.
fn column(&self) -> bool {
use horizon_polytone::expr::Root;
let flat = |root: Root, name: &str| match root {
Root::Object => matches!(name, "x" | "z" | "biome" | "temperature" | "downfall"),
Root::Random => name == "noise",
_ => false,
};
match self {
Self::Expr(program) | Self::BiomeId(_, program) => program.reads_only(&flat),
}
}
fn value(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<f64> {
match self {
Self::Expr(program) => Some(program.eval(host).ok()?.num()),
@@ -388,23 +373,6 @@ fn biome_id_mapper(pack_root: &Path, json: &Json) -> HashMap<String, f64> {
}
impl Colormap {
/// One colour wherever it samples (a `biome_colors` flat colour, RGB).
pub(crate) fn flat(rgb: u32) -> Self {
let zero = || Axis::Expr(horizon_polytone::expr::Program::compile("0").expect("a number"));
let [_, r, g, b] = rgb.to_be_bytes();
Self {
x: zero(),
y: zero(),
triangular: false,
rounds: true,
biome_blend: false,
column: true,
width: 1,
height: 1,
rgba: vec![r, g, b, 255],
}
}
/// `assets/<namespace>/polytone/colormaps/<path>.png` and its optional `.json` (without
/// one, the axes are the biome's temperature and downfall).
pub(crate) fn load(pack_root: &Path, id: &str) -> Option<Self> {
@@ -456,11 +424,9 @@ impl Colormap {
axis("x_axis", "o.temperature")?,
axis("y_axis", "o.downfall")?,
);
let column = [&x, &y].iter().all(|axis| axis.column());
Some(Self {
x,
y,
column,
biome_blend: json["biome_blend"].as_bool().unwrap_or(by_biome),
triangular: json["triangular"].as_bool() == Some(true),
rounds: json["rounds"].as_bool() != Some(false),
@@ -569,25 +535,6 @@ pub(crate) fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
}
impl horizon_polytone::expr::Host for BlockHost<'_> {
fn field(
&mut self,
root: horizon_polytone::expr::Root,
field: horizon_polytone::expr::Field,
) -> Option<horizon_polytone::expr::Value> {
use horizon_polytone::expr::Field as F;
if root != horizon_polytone::expr::Root::Object {
return None;
}
Some(horizon_polytone::expr::Value::Num(match field {
F::X => f64::from(self.at[0]),
F::Y => f64::from(self.at[1]),
F::Z => f64::from(self.at[2]),
F::Temperature => f64::from(self.biome.temperature),
F::Downfall => f64::from(self.biome.downfall),
_ => return None,
}))
}
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
use horizon_polytone::expr::Root;
horizon_polytone::expr::Value::Object(match root {
+3 -6
View File
@@ -482,16 +482,13 @@ impl Extras {
true
}
/// Written only once something differs from the defaults, so a vanilla
/// setup gains no file. The settings stores of this file follow these lines
/// (`horizon_hooks::settings`).
/// setup gains no file.
pub fn save(&self, dir: &Path) {
let path = dir.join(FILE);
let mut text = self.serialize();
let stores_default = horizon_hooks::settings::engine::append(FILE, &mut text);
if *self == Self::default() && stores_default && !path.exists() {
if *self == Self::default() && !path.exists() {
return;
}
let _ = fs::write(path, text);
let _ = fs::write(path, self.serialize());
}
}
+8 -288
View File
@@ -1,6 +1,5 @@
//! The Horizon Extras page, on the settings shell (`settings_shell`): which rows it has and what
//! pressing them does. Each one's value and what it does stay in `extras::Item`; the sections crates
//! add (`horizon_hooks::pages::Place::Extras`) keep theirs.
//! pressing them does. Each one's value and what it does stay in `extras::Item`.
use crate::{
extras::{self, Item},
lang,
@@ -8,7 +7,6 @@ use crate::{
settings::Settings,
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
};
use horizon_hooks::pages::{self as added, Act, Label};
const TITLE: &str = "horizon.pages.extras";
const ABOUT: &str = "horizon.pages.extras.tooltip";
@@ -33,121 +31,6 @@ pub(crate) enum ExtraRow {
/// It isn't installed: where to get it. A note, not a button: Options > Mods already has the
/// one way into the Mods page (`every_setting_and_page_is_reached_from_one_place`).
ZoomInstall,
/// Row `.1` of the section registered page `.0` adds (`Place::Extras`).
Added(usize, usize),
}
#[cfg(test)]
thread_local! {
/// A test's own pages, instead of the game's registered ones: each test runs on its thread.
static TEST_PAGES: std::cell::Cell<Option<&'static [&'static dyn added::Page]>> =
const { std::cell::Cell::new(None) };
}
/// The pages crates registered, all places.
fn pages() -> &'static [&'static dyn added::Page] {
#[cfg(test)]
if let Some(pages) = TEST_PAGES.get() {
return pages;
}
added::engine::all()
}
/// The sections of this page, by their index among the registered pages.
fn sections() -> impl Iterator<Item = (usize, &'static dyn added::Page)> {
let pages = pages().iter().copied().enumerate();
pages.filter(|(_, page)| page.place() == added::Place::Extras)
}
fn label_text(label: Label) -> String {
match label {
Label::Key(key) => lang::get(key),
Label::Value(text) => text,
}
}
/// The page's group named `name`, made at the end if it has none.
fn group_named<'a>(
groups: &'a mut Vec<Group<ExtraRow>>,
name: &'static str,
) -> &'a mut Group<ExtraRow> {
let at = groups.iter().position(|group| group.name == name);
let at = at.unwrap_or_else(|| {
groups.push(Group {
name,
rows: Vec::new(),
});
groups.len() - 1
});
&mut groups[at]
}
/// The sections' rows, in the groups their titles and headings name.
fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
for (page, section) in sections() {
let mut rows = Vec::new();
section.rows(&mut rows);
let mut name = section.title();
for (r, row) in rows.into_iter().enumerate() {
let id = ExtraRow::Added(page, r);
let row = match row {
added::Row::Heading(key) => {
name = key;
continue;
}
added::Row::Note(text) => Row::new(id, text, Value::Note).live(false),
added::Row::Toggle { label, on } => {
Row::new(id, lang::get(label), Value::Switch(on))
}
added::Row::Cycle { label, value } => {
Row::new(id, lang::get(label), Value::Choice(label_text(value)))
}
added::Row::Slider { label, value, text } => {
let position = value.clamp(0.0, 1.0);
let text = label_text(text);
let value = Value::Slider {
position,
steps: None,
text,
};
Row::new(id, lang::get(label), value)
}
added::Row::Button { label } => Row::new(id, lang::get(label), Value::Press),
added::Row::Key { label, key } => {
let key = crate::keybinds::display_name(&key).plain();
Row::new(id, lang::get(label), Value::Read(key))
}
// A row kind newer than this engine.
_ => continue,
};
let row = match section.tooltip(r) {
Some(about) => row.about(label_text(about)),
None => row,
};
group_named(groups, name).rows.push(row);
}
}
}
/// Row `r` of registered page `page` was acted on: the options screen saves when it closes.
fn act(page: usize, r: usize, act: Act) -> Vec<Output> {
let Some(section) = pages().get(page) else {
return Vec::new();
};
section.act(r, act);
vec![Output::Event(Event::Changed)]
}
/// Whether row `r` of registered page `page` steps (a switch or a cycle).
fn steps(page: usize, r: usize) -> bool {
let mut rows = Vec::new();
if let Some(section) = pages().get(page) {
section.rows(&mut rows);
}
matches!(
rows.get(r),
Some(added::Row::Toggle { .. } | added::Row::Cycle { .. })
)
}
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
@@ -202,14 +85,9 @@ impl Screen for ExtrasScreen {
fn note_lines(&self) -> usize {
NOTE_LINES
}
/// The rows read the extras, the mods, whether cosmetics are offered and the sections' rows.
/// The rows read the extras, the mods and whether cosmetics are offered.
fn stamp(&self, s: &Settings) -> u64 {
let mut added = Vec::new();
for (_, section) in sections() {
section.rows(&mut added);
}
let cosmetics = horizon_cosmetics::enabled();
shell::stamp_of(&(&s.extras, &s.mods, cosmetics, added))
shell::stamp_of(&(&s.extras, &s.mods, horizon_cosmetics::enabled()))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
let mut groups = vec![Group {
@@ -229,7 +107,6 @@ impl Screen for ExtrasScreen {
}
}
}
add_sections(&mut groups);
vec![Sheet::new((), lang::get(TITLE), lang::get(ABOUT), groups)]
}
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
@@ -248,29 +125,16 @@ impl Screen for ExtrasScreen {
Output::Event(Event::NativeSettings(i)),
],
ExtraRow::ZoomInstall => Vec::new(),
ExtraRow::Added(page, r) => {
act(page, r, if back { Act::PressBack } else { Act::Press })
}
}
}
fn slide(&mut self, _s: &mut Settings, row: ExtraRow, position: f32) {
if let ExtraRow::Added(page, r) = row {
act(page, r, Act::Slide(position));
}
}
/// With the wheel extra on, the wheel over an item changes it, without a click; the mod's rows
/// let the list scroll.
fn wheel_step(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Option<Vec<Output>> {
match row {
ExtraRow::Item(item) => {
item.cycle(&mut s.extras, back);
Some(vec![Output::Event(Event::Changed)])
}
ExtraRow::Added(page, r) if steps(page, r) => {
Some(act(page, r, if back { Act::PressBack } else { Act::Press }))
}
_ => None,
}
let ExtraRow::Item(item) = row else {
return None;
};
item.cycle(&mut s.extras, back);
Some(vec![Output::Event(Event::Changed)])
}
}
@@ -428,148 +292,4 @@ mod tests {
shell.key(&mut s, K::ArrowDown, None, false, size);
assert_eq!(shell.focused_row(), Some(first));
}
/// A section with a switch in the page's Multiplayer group, then a heading of its own with a
/// number that steps either way, a note and a volume.
struct Section {
on: std::sync::Mutex<bool>,
step: std::sync::Mutex<i32>,
volume: std::sync::Mutex<f32>,
}
impl added::Page for Section {
fn id(&self) -> &'static str {
"test.section"
}
fn title(&self) -> &'static str {
"horizon.extras.header.multiplayer"
}
fn place(&self) -> added::Place {
added::Place::Extras
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::Toggle {
label: "test.section.switch",
on: *self.on.lock().unwrap(),
});
rows.push(added::Row::Heading("test.section.heading"));
rows.push(added::Row::Cycle {
label: "test.section.step",
value: Label::Value(self.step.lock().unwrap().to_string()),
});
rows.push(added::Row::Note("a note".into()));
rows.push(added::Row::Slider {
label: "test.section.volume",
value: *self.volume.lock().unwrap(),
text: Label::Value(String::new()),
});
}
fn act(&self, row: usize, act: Act) {
match (row, act) {
(0, Act::Press | Act::PressBack) => {
let mut on = self.on.lock().unwrap();
*on = !*on;
}
(2, Act::Press) => *self.step.lock().unwrap() += 1,
(2, Act::PressBack) => *self.step.lock().unwrap() -= 1,
(4, Act::Slide(v)) => *self.volume.lock().unwrap() = v,
_ => {}
}
}
}
/// A page for Horizon Settings: never on Extras.
struct Elsewhere;
impl added::Page for Elsewhere {
fn id(&self) -> &'static str {
"test.elsewhere"
}
fn title(&self) -> &'static str {
"test.elsewhere"
}
fn place(&self) -> added::Place {
added::Place::HorizonSettings
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::Button {
label: "test.elsewhere.button",
});
}
fn act(&self, _: usize, _: Act) {}
}
fn with_section() -> &'static Section {
let section: &'static Section = Box::leak(Box::new(Section {
on: false.into(),
step: 0.into(),
volume: 0.5.into(),
}));
let pages: &'static [&'static dyn added::Page] = Box::leak(Box::new([
&Elsewhere as &dyn added::Page,
section as &dyn added::Page,
]));
TEST_PAGES.set(Some(pages));
section
}
/// The section's first row joins the page's own Multiplayer group, after its rows; its heading
/// starts a group after the page's; a page for elsewhere adds nothing.
#[test]
fn a_section_joins_the_group_its_title_names_and_heads_its_own() {
with_section();
let s = Settings::default();
let sheets = ExtrasScreen.sheets(&s);
let groups = &sheets[0].groups;
let ids = |name: &str| -> Vec<ExtraRow> {
let group = groups.iter().find(|g| g.name == name).unwrap();
group.rows.iter().map(|r| r.id).collect()
};
let multiplayer = ids("horizon.extras.header.multiplayer");
assert_eq!(multiplayer.last(), Some(&ExtraRow::Added(1, 0)));
assert_eq!(groups.last().unwrap().name, "test.section.heading");
let own = [
ExtraRow::Added(1, 2),
ExtraRow::Added(1, 3),
ExtraRow::Added(1, 4),
];
assert_eq!(ids("test.section.heading"), own);
let all = rows_of(&s);
assert!(!all.iter().any(|r| matches!(r.id, ExtraRow::Added(0, _))));
}
/// A click flips the switch, a Shift click steps the number back, the wheel steps it, and the
/// note takes no click; the rows show what the section holds after each.
#[test]
fn a_sections_rows_act_on_it() {
let section = with_section();
let mut s = Settings::default();
let mut shell = Shell::<ExtrasScreen>::default();
let switch = ExtraRow::Added(1, 0);
let at = shell.point_at(&s, SIZE, switch);
assert!(
!shell
.click(&mut s, at, SIZE, false, false, false)
.is_empty()
);
assert!(*section.on.lock().unwrap());
let value = |s: &Settings, id| rows_of(s).into_iter().find(|r| r.id == id).unwrap().value;
assert_eq!(value(&s, switch), Value::Switch(true));
let step = ExtraRow::Added(1, 2);
let mut screen = ExtrasScreen;
screen.press(&mut s, step, true);
assert_eq!(value(&s, step), Value::Choice("-1".into()));
assert!(screen.wheel_step(&mut s, step, false).is_some());
assert_eq!(*section.step.lock().unwrap(), 0);
assert_eq!(
screen.wheel_step(&mut s, ExtraRow::Added(1, 3), false),
None
);
let note = shell.point_at(&s, SIZE, ExtraRow::Added(1, 3));
assert!(
shell
.click(&mut s, note, SIZE, false, false, false)
.is_empty()
);
screen.slide(&mut s, ExtraRow::Added(1, 4), 0.25);
assert_eq!(*section.volume.lock().unwrap(), 0.25);
}
}
+4 -4
View File
@@ -231,8 +231,8 @@ pub struct FogInputs<'a> {
/// `GameRenderer.nightVisionScale` while Night Vision is on without
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
pub night_vision: f32,
/// The packs' `environment` fog settings (Polytone's).
pub environment: crate::pack_palette::Environment,
/// The packs' Polytone `environment` fog settings.
pub environment: horizon_polytone::colors::Environment,
}
/// `FogRenderer.setupFog` + `computeFogColor`.
@@ -581,7 +581,7 @@ mod tests {
let tables = FogTables {
water_fog_end: vec![1.0],
};
let environment = crate::pack_palette::Environment {
let environment = horizon_polytone::colors::Environment {
powder_snow_fog: Some(0xFF0000),
blindness_fog_distance: 40.0,
water_fog_brightening: 0.0,
@@ -613,7 +613,7 @@ mod tests {
}
fn inputs_with<'a>(
input: &FogInputs<'a>,
environment: crate::pack_palette::Environment,
environment: horizon_polytone::colors::Environment,
) -> FogInputs<'a> {
FogInputs {
environment,
+239 -80
View File
@@ -179,7 +179,7 @@ fn packed_wanted() -> bool {
std::env::var("HORIZON_PACKED_QUADS").as_deref() != Ok("0")
}
pub(crate) mod animate;
mod animate;
mod arena;
pub mod capture;
pub mod compositor;
@@ -194,7 +194,6 @@ mod frame_gen;
mod gpu_culling;
mod hdr;
mod hdr_icc;
mod hdr_start;
mod hdr_stats;
pub(crate) mod hud_coverage;
pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
@@ -216,7 +215,6 @@ mod occlusion;
mod oit;
mod oit_features;
pub(crate) mod output;
mod output_apply;
mod pack_atlas;
mod pack_center_depth;
pub mod pack_draw;
@@ -239,7 +237,6 @@ mod reuse;
mod stars;
mod surface_setup;
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
mod off_page;
pub mod submit;
mod sunrise_fan;
mod timing;
@@ -251,7 +248,6 @@ mod wgsl_prefix;
mod world_post;
pub mod world_primitives;
mod world_warp;
use off_page::move_off_page;
/// A section's instance: its camera-relative origin and, in `w`, the step of
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
@@ -728,6 +724,62 @@ impl Part {
}
}
}
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
/// until the next range would pass `budget` bytes (it keeps what is left; the
/// first range moves whatever its size, so none keeps the page closed), the
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
/// section arena, GPU culling's records (`records`) along. False when the other
/// pages have no room left.
fn move_off_page<'a>(
arena: &mut arena::Arena,
encoder: &mut wgpu::CommandEncoder,
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
mut records: Option<&mut gpu_culling::Records>,
meshes: impl Iterator<Item = &'a mut SectionGpu>,
page: u32,
part: Part,
budget: &mut u64,
) -> bool {
debug_assert!(
records.is_none() || matches!(part, Part::Section { .. }),
"records follow the section arena's ranges"
);
let mut moved = false;
for s in meshes {
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
// range of page 0); in the other arenas, one without translucent quads.
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
continue;
};
let bytes = vertices * arena.stride();
if moved && bytes > *budget {
break;
}
let Some(to) = arena.alloc_moved(vertices) else {
return false;
};
arena.copy(encoder, (page, start), to, vertices);
arena.release(page, start, vertices);
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
let key = match part {
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
_ => to.0,
};
flat.place(device, encoder, uploads, id, key, to.1, vertices);
}
part.set(s, to);
// Its solid quads start the range in either layout.
if let (Part::Section { .. }, Some(records), Some(id)) =
(part, records.as_deref_mut(), s.gpu_id)
{
records.relocate(id, to.0, u64::from(s.solid_base()));
}
*budget = budget.saturating_sub(bytes);
moved = true;
}
true
}
/// Starts moving a page of a large arena far larger than its ranges in use
/// (`Arena::oversized`) into a new page of the size they get (`Arena::open_page`),
/// for `Renderer::shrink_translucent`: the page closes and `move_off_page` empties
@@ -1275,7 +1327,7 @@ pub struct Renderer {
celestial_buffer: wgpu::Buffer,
end_sky: end_sky::EndSky,
stars: stars::Stars,
/// Custom sky layers (`crate::custom_sky`), and the frame's from the game.
/// OptiFine custom sky layers (`crate::custom_sky`), and the frame's from the game.
custom_sky: custom_sky::CustomSky,
custom_sky_draws: Vec<crate::custom_sky::Draw>,
/// The glowing entities' outline (`LevelRenderer.entityOutlineTarget`).
@@ -1474,10 +1526,6 @@ pub struct Renderer {
emission_stats: hdr_stats::EmissionStats,
/// What this renderer's destination offers and its device draws.
output_caps: output::OutputCapabilities,
/// An output that failed to apply, not tried again until something it needs changes.
output_rejected: Option<output_apply::Rejected>,
/// Since when Auto keeps HDR on through the display's loss of its report.
auto_off_since: Option<std::time::Instant>,
/// The output applied (`update_output`), with the frame every pipeline that draws it
/// targets: the swapchain's 8-bit format, or `Rgba16Unorm` when the renderer was made with
/// HDR output on and Smooth Gradients and the device draws and blends into it, or
@@ -1490,8 +1538,6 @@ pub struct Renderer {
/// The marker file of an HDR output that has not presented a frame yet
/// (`graphics::HDR_MARKER`).
hdr_marker: Option<std::path::PathBuf>,
/// The window's graphics driver (`hdr_start`); `None` offscreen.
hdr_driver: Option<String>,
/// The HDR Lighting setting, kept for the HDR output made when HDR turns on.
hdr_lighting_setting: bool,
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
@@ -2939,13 +2985,11 @@ impl Renderer {
adapter.get_downlevel_capabilities().flags,
adapter.get_texture_format_features(hud_coverage::FORMAT),
);
let hdr_driver = surface.is_some().then(|| output::window_driver(&info));
let output_caps = output::OutputCapabilities {
hdr: hdr_output,
wide_frame: wide_ok,
float_frame: float_ok,
hud_layer: coverage_ok,
held: output::held(settings, hdr_driver.as_deref()),
};
let frame = output::resolve_frame(settings, request, output_caps, display, config.format);
let (frame_format, hud_coverage, overlay_format) =
@@ -4195,13 +4239,16 @@ impl Renderer {
CRUMBLING_BLEND,
false,
);
// RenderPipelines.ENTITY_SHADOW: translucent, back-face culled, depth test without writes.
// The quads lie exactly on the block tops like vanilla's, facing +Y so looking up from
// below culls them, but terrain and shadows are separate draw paths, so their fp32 depth
// interpolations disagree by a few dozen ulps of the fragment depth and the shadow's rim
// z-fights with the block top (speckle that re-rolls as the camera drifts a subpixel). A
// constant bias of 128 ulps covers that spread at any resolution while staying far below
// the 0.001 a model's feet sit higher, so shadows never bleed onto the feet.
// RenderPipelines.ENTITY_SHADOW: translucent, back-face culled, depth
// test without writes. The quads lie exactly on the block tops like
// vanilla's, facing +Y so looking up from below culls them, but
// terrain and shadows are separate draw paths, so their fp32 depth
// interpolations disagree by a few dozen ulps of the fragment depth
// and the shadow's rim z-fights with the block top (speckle that
// re-rolls as the camera drifts a subpixel). A constant bias of 128
// ulps covers that spread at any resolution while staying far below
// the 0.001 a model's feet sit higher, so shadows never bleed onto
// the feet.
let shadow_blob_pipeline = entity_pipeline_with(
"Entity shadows",
Some(wgpu::Face::Back),
@@ -5299,13 +5346,10 @@ impl Renderer {
hdr_lighting_setting: true,
emission_stats,
output_caps,
output_rejected: None,
auto_off_since: None,
// Nothing is presented as HDR yet: `update_output` applies the settings.
output: output::resolve_output(settings, output_caps, display, frame, false),
frame_kept_logged: false,
hdr_marker: None,
hdr_driver,
pack_post: None,
pack_loading: None,
native: None,
@@ -5463,18 +5507,18 @@ impl Renderer {
))
});
}
/// The pack's custom sky layer textures (`crate::custom_sky::load`).
pub fn set_custom_sky(&mut self, pack: &dyn crate::custom_sky::SkyLayers) {
/// The pack's OptiFine custom sky layer textures (`crate::custom_sky::CustomSky::load`).
pub fn set_custom_sky(&mut self, pack: &crate::custom_sky::CustomSky) {
self.custom_sky.set_pack(&self.device, &self.queue, pack);
}
/// The custom sky layers the next frame draws (`SkyLayers::draws`): the game refills it each
/// The custom sky layers the next frame draws (`CustomSky::draws`): the game refills it each
/// frame, empty when the world's sky has none.
pub fn custom_sky_draws(&mut self) -> &mut Vec<crate::custom_sky::Draw> {
&mut self.custom_sky_draws
}
/// The resource packs' `core/rendertype_lines` (`HudAtlas::lines_shader`) for the block
/// outline, or the built-in one.
pub fn set_lines_shader(&mut self, program: Option<&crate::core_shader::CoreProgram>) {
pub fn set_lines_shader(&mut self, program: Option<&horizon_shaders::CoreProgram>) {
let format = self.output.frame.format;
self.core_lines =
program.and_then(|program| core_lines::CoreLines::new(&self.device, format, program));
@@ -5483,8 +5527,8 @@ impl Renderer {
/// `stars_shader`), or the built-in sky and stars.
pub fn set_sky_shaders(
&mut self,
disc: Option<&crate::core_shader::CoreProgram>,
stars: Option<&crate::core_shader::CoreProgram>,
disc: Option<&horizon_shaders::CoreProgram>,
stars: Option<&horizon_shaders::CoreProgram>,
) {
self.core_sky = core_sky::CoreSky::new(&self.device, self.output.frame.format, disc, stars);
}
@@ -5587,7 +5631,6 @@ impl Renderer {
}
self.surface_unconfigured = configured.is_err();
self.surface = Some(Arc::new(surface));
self.surface_recreated();
Ok(())
}
/// The world's depth, which occlusion culling's test and post effects may read.
@@ -7040,6 +7083,18 @@ impl Renderer {
horizon_hdr::set_metadata(&self.device, surface, hdr.declared_peak(), hdr.black);
}
}
/// The swapchain's configuration: `config` (what every pipeline draws in), or
/// HDR10 or scRGB while HDR output is on.
fn surface_config(&self) -> wgpu::SurfaceConfiguration {
let mut config = self.config.clone();
if let Some(hdr) = &self.hdr {
config.format = hdr.output.format();
config.view_formats = vec![hdr.output.format()];
config.color_space = hdr.output.color_space();
config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
}
config
}
/// This renderer's output as applied, with what its destination offers and reports.
pub fn output(&self) -> &output::EffectiveOutput {
&self.output
@@ -7094,11 +7149,106 @@ impl Renderer {
output::resolve_frame(
settings,
request,
self.caps_for(settings),
output_apply::graced(self.auto_off_since, self.reading, &self.output),
self.output_caps,
self.reading,
self.config.format,
)
}
/// Follows the selected mode even when Auto currently wants SDR. Off never asks
/// the system about the display; selecting On or Auto starts a fresh watch.
fn watch_hdr(&mut self, mode: crate::graphics::HdrMode) {
if mode == crate::graphics::HdrMode::Off {
self.display = None;
self.display_fresh = false;
self.reading.stop_watching();
} else if self.display.is_none() {
let (watch, now) =
DisplayWatch::new(&self.window, self.surface.as_deref(), &self.adapter);
self.display = Some(watch);
self.refresh_hdr_offer();
self.reading = now;
self.display_fresh = true;
}
}
/// What the window's surface offers now: Vulkan's offered formats can change with desktop
/// HDR or the window's output.
fn refresh_hdr_offer(&mut self) {
if let Some(surface) = &self.surface {
self.output_caps.hdr = hdr::output(&surface.get_capabilities(&self.adapter));
}
}
/// HDR output (Horizon setting) as `settings` ask for it on this renderer's frame
/// (`output::resolve_output`): HDR10 or scRGB at paper white, the world's highlights
/// expanded up to the peak. Off where the surface can't present it. Offscreen
/// (`hdr::offscreen_output`) the frame is drawn the same and its HDR screenshot taken,
/// but nothing is presented, and Auto takes its defaults: no display reports.
pub fn update_output(
&mut self,
settings: &crate::graphics::Graphics,
marker: &std::path::Path,
) {
self.watch_hdr(settings.hdr);
let next = output::resolve_output(
settings,
self.output_caps,
self.reading,
self.output.frame,
self.surface.is_some() || self.offscreen.is_some(),
);
let [paper_white, peak, black] = next.nits;
if next.encoding.is_some() != self.hdr.is_some() {
// If the game dies before HDR output shows a frame, the marker is still there at
// the next start, which then comes up with HDR off.
if next.encoding.is_some() {
if self.surface.is_some() {
let _ = std::fs::write(marker, "HDR output was being switched on\n");
self.hdr_marker = Some(marker.to_owned());
}
} else if let Some(marker) = self.hdr_marker.take() {
let _ = std::fs::remove_file(marker);
}
let frame = self.output.frame;
self.hdr = next.encoding.map(|output| {
hdr::Hdr::new(
&self.device,
&self.queue,
frame.format,
output,
frame.hud_layer,
)
});
if let Some(hdr) = &mut self.hdr {
hdr.look = self.hdr_lighting_setting;
}
self.configure_surface();
match next.encoding {
Some(output) => log::info!(
"Horizon renderer: HDR on: {} ({:?}), {paper_white}/{peak} nits",
output.name(),
output.format()
),
None => log::info!("Horizon renderer: HDR off"),
}
self.measure_emission();
}
if let Some(hdr) = &mut self.hdr {
hdr.ui = next.ui_brightness;
}
if let Some(hdr) = &mut self.hdr
&& (hdr.paper_white, hdr.peak, hdr.black) != (paper_white, peak, black)
{
hdr.paper_white = paper_white;
hdr.peak = peak;
hdr.black = black;
self.send_hdr_metadata();
}
// An output of another encoding than the one presented doesn't replace it yet, only
// Off does: the snapshot says what is presented.
self.output = output::EffectiveOutput {
encoding: self.hdr.as_ref().map(|hdr| hdr.output),
..next
};
}
/// Measures which pixels of each block sprite may shine (`hdr_stats`), at the current
/// masks. Only with HDR output on: without it the frame's alpha is never read.
pub fn measure_emission(&mut self) {
@@ -7108,6 +7258,24 @@ impl Renderer {
.measure(&self.device, &self.queue, &views, hdr.masks);
}
}
/// Whether what the system reports for the display changed (another output, the
/// player recalibrating the desktop): the settings' Auto values follow it then, so
/// apply them again.
pub fn display_changed(&mut self) -> bool {
flush_display_log();
if std::mem::take(&mut self.display_fresh) {
return true;
}
let Some(watch) = self.display.as_mut() else {
return false;
};
let Some(now) = watch.changed(self.reading, self.surface.as_deref(), &self.adapter) else {
return false;
};
self.refresh_hdr_offer();
self.reading = now;
true
}
/// The share of real frames frame generation shows a made frame before (1 while it
/// keeps up), `None` while it isn't running.
/// The posed models' store size (`SkinFrame::base`), with entity skinning.
@@ -7493,7 +7661,7 @@ impl Renderer {
) -> std::result::Result<Vec<String>, String> {
let linear = pack
.as_ref()
.is_some_and(|p| p.0.manifest.api == horizon_shader_api::Api::V0_2);
.is_some_and(|p| p.0.manifest.api == horizon_render_pipeline::Api::V0_2);
if (linear || self.output.frame.format == LINEAR_FRAME)
&& linear != (self.output.frame.format == LINEAR_FRAME)
&& !std::mem::replace(&mut self.frame_kept_logged, true)
@@ -7546,7 +7714,7 @@ impl Renderer {
inputs.declarations.push_str(
"layout(set = 5, binding = 0) readonly buffer HzIndices { uint hz_indices[]; };\n",
);
horizon_shaders::GeometryInputs {
horizon_render_pipeline::GeometryInputs {
inputs,
pull: pack_atlas::PULL.to_owned(),
index: "hz_indices[@]".to_owned(),
@@ -7569,7 +7737,7 @@ impl Renderer {
}
}
fn pack_terrain_inputs(&mut self) -> Option<horizon_shaders::GbuffersInputs> {
fn pack_terrain_inputs(&mut self) -> Option<horizon_render_pipeline::GbuffersInputs> {
// Flat draws whenever the device has them (`render` keeps them on with a pack).
let flat = self.flat.is_some() && self.flat_on;
// Flat draws read each sprite's place from a table: the atlas is stitched as the game's.
@@ -8295,12 +8463,8 @@ impl Renderer {
t.begin(&self.device, &self.queue);
}
if self.animated_textures {
self.hud.animate(
&mut self.uploads,
&mut encoder,
frame.ticks,
&self.animations,
);
self.hud
.animate_sprites(&mut self.uploads, &mut encoder, frame.ticks);
}
self.update_lightmap(
&mut encoder,
@@ -9238,11 +9402,13 @@ impl Renderer {
}
// Near water and distant water share one back-to-front draw order.
if self.translucent_draws.len() > section_translucent {
// Distant water goes after the near chunks', and replaced tiles' after all: each is a
// hair behind the one before, so depth leaves it only where that one has none. Back to
// front together, a far tile's water under a near chunk's (one fading in, or brought
// into view by a longer render distance) blended twice and looked opaque. The cost: far
// water just behind near water shows no surface of its own there.
// Distant water goes after the near chunks', and replaced tiles'
// after all: each is a hair behind the one before, so depth leaves
// it only where that one has none. Back to front together, a far
// tile's water under a near chunk's (one fading in, or brought
// into view by a longer render distance) blended twice and looked
// opaque. The cost: far water just behind near water shows no
// surface of its own there.
let tier = |instance: usize| match instance.checked_sub(visible.len()) {
None => 0,
Some(tile) if visible_lods[tile].old => 2,
@@ -10062,11 +10228,7 @@ impl Renderer {
let pack_reads_world = pack_post || native_post;
laps.lap("r_uploads");
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
// Post effects (a resource pack's chains, `use_depth_buffer`) read the world's depth
// after the world pass, as `GameRenderer.render` hands them the main target's.
let post_reads_depth = !frame.title && !frame.post_effects.effects.is_empty();
let keep_depth = pack_reads_world
|| post_reads_depth
|| (taa && self.world_post.taa)
|| oit_on
|| native_render::render_needs().world_depth
@@ -10248,7 +10410,7 @@ impl Renderer {
self.own_timing.as_mut(),
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
pass.set_bind_group(horizon_render_pipeline::stages::GROUP, &shadow.group, &[]);
if flat_frame
&& let Some(flat) = &self.flat
&& let Some(pipeline) = &shadow.flat
@@ -10281,7 +10443,7 @@ impl Renderer {
self.own_timing.as_mut(),
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
pass.set_bind_group(horizon_render_pipeline::stages::GROUP, &shadow.group, &[]);
if flat_frame
&& let Some(flat) = &self.flat
&& let Some(pipeline) = &shadow.flat_translucent
@@ -10313,7 +10475,7 @@ impl Renderer {
.begin_entities(&mut encoder, "Native shader pack entity shadow");
pass.set_pipeline(&shadow.entity);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
pass.set_bind_group(horizon_render_pipeline::stages::GROUP, &shadow.group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
@@ -10534,7 +10696,7 @@ impl Renderer {
let gpu_occluding = gpu_frame && self.gpu_culling.as_ref().is_some_and(|g| g.occluding());
let pack_frame = |pass: &mut wgpu::RenderPass<'_>| {
if let Some((_, group)) = material {
pass.set_bind_group(horizon_shader_api::stages::GROUP, group, &[]);
pass.set_bind_group(horizon_render_pipeline::stages::GROUP, group, &[]);
}
};
// A pack's solid terrain that calls `set_surface` also writes the surface target, in
@@ -11922,7 +12084,7 @@ impl Renderer {
&& self
.native
.as_ref()
.is_none_or(|n| n.frame.api == horizon_shader_api::Api::V0_1);
.is_none_or(|n| n.frame.api == horizon_render_pipeline::Api::V0_1);
match (&self.hdr, &surface_frame) {
(Some(hdr), Some(f)) => hdr.present(
&self.queue,
@@ -13664,7 +13826,6 @@ mod tests {
Some(Reported {
paper_white: 240,
peak: 998,
peak_guessed: true,
})
);
assert_eq!(
@@ -13672,7 +13833,6 @@ mod tests {
Some(Reported {
paper_white: 480,
peak: 480,
peak_guessed: true,
})
);
// White above the calibrated peak (a Samsung QD-OLED's 380, the slider at 456).
@@ -13681,7 +13841,6 @@ mod tests {
Some(Reported {
paper_white: 456,
peak: 456,
peak_guessed: true,
})
);
assert_eq!(os_reported(&info(Some(1000.0), None)), None);
@@ -15240,10 +15399,6 @@ struct DisplayWatch {
}
impl DisplayReading {
/// What wgpu reads from the OS for `surface`'s display; nothing without one.
fn read(surface: Option<&wgpu::Surface>, adapter: &wgpu::Adapter) -> Self {
surface.map_or_else(Self::default, |s| Self::os(&s.display_hdr_info(adapter)))
}
fn os(info: &wgpu::DisplayHdrInfo) -> Self {
#[cfg(target_os = "macos")]
let hdr = macos_hdr(info);
@@ -15284,7 +15439,9 @@ impl DisplayWatch {
return (watch, wayland_reading(now));
}
let _ = window;
let now = DisplayReading::read(surface, adapter);
let now = surface.map_or_else(DisplayReading::default, |surface| {
DisplayReading::os(&surface.display_hdr_info(adapter))
});
log_os_reported(now.luminance);
let watch = Self {
#[cfg(target_os = "linux")]
@@ -15293,28 +15450,30 @@ impl DisplayWatch {
};
(watch, now)
}
/// What the system reports at each poll that is due at `time` (`read`; on Wayland, at each
/// event), whether or not it is `last` again: the surface's capabilities are read with it.
fn poll(
/// What the system reports, if that changed from `last`.
fn changed(
&mut self,
last: DisplayReading,
time: std::time::Instant,
read: impl FnOnce() -> DisplayReading,
surface: Option<&wgpu::Surface>,
adapter: &wgpu::Adapter,
) -> Option<DisplayReading> {
#[cfg(target_os = "linux")]
if let Some(wayland) = &mut self.wayland {
return wayland.changed().map(wayland_reading);
}
let next = self.polled.as_mut()?;
let time = std::time::Instant::now();
if time < *next {
return None;
}
*next = time + DISPLAY_POLL;
let now = read();
if now != last {
let now = surface.map_or_else(DisplayReading::default, |surface| {
DisplayReading::os(&surface.display_hdr_info(adapter))
});
(now != last).then(|| {
log_os_reported(now.luminance);
}
Some(now)
now
})
}
}
@@ -15372,9 +15531,11 @@ fn log_os_reported(reported: Option<crate::graphics::Reported>) {
None => "Horizon renderer: the system doesn't report the display's luminance".into(),
});
}
/// What the OS reports in absolute nits, HDR output's scale (Windows: DXGI's SDR white level,
/// the brightness slider's, as Paper White, and its `MaxLuminance`, mostly the display's EDID
/// guess, as Peak Brightness), kept within the settings' ranges. The player's values win.
/// What the OS reports in absolute nits (Windows: DXGI's `MaxLuminance` and the SDR
/// white level the brightness slider sets), which is HDR output's scale there: SDR
/// white is Paper White, the display's peak is Peak Brightness. Both are kept within
/// the settings' ranges. The OS's figures often come from the display's EDID and may
/// run optimistic; the player's own value always wins.
fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported> {
let luminance = info.luminance?;
let usable = |n: f32| n.is_finite() && n > 0.0;
@@ -15386,7 +15547,6 @@ fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported>
// Windows' SDR brightness slider can set white above the reported peak (480 against
// a calibrated 380): the highlights then stay at white instead of dropping below it.
peak: (peak.round() as u32).clamp(200, 10_000).max(paper_white),
peak_guessed: true,
})
}
@@ -15422,7 +15582,6 @@ fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
crate::graphics::Reported {
paper_white: 203,
peak: peak.clamp(200, 10_000),
peak_guessed: false,
}
}
@@ -15671,11 +15830,11 @@ mod shadow_caster_tests {
#[test]
fn the_shadow_pass_draws_terrain_outside_the_pack_s_planes() {
let kept = |near: Option<f32>, far: Option<f32>, y: f64, min: [f32; 3], depth: bool| {
let shadow = horizon_shaders::Shadow {
let shadow = horizon_render_pipeline::Shadow {
distance: 128.0,
near,
far,
..horizon_shaders::Shadow::default()
..horizon_render_pipeline::Shadow::default()
};
let camera = DVec3::new(13.5, y + 1.62, -13.5);
let view = pack_post::ShadowView {
@@ -64,7 +64,7 @@ enum Writer {
},
}
pub(crate) struct SpriteAnimator {
pub(super) struct SpriteAnimator {
sprites: Vec<Sprite>,
groups: Vec<Group>,
writer: Writer,
@@ -86,9 +86,6 @@ struct AnimationModes {
day_time: i64,
weather: f32,
values: std::collections::HashMap<String, f64>,
/// The open screen (an identity the game picks) and the ticks it has been open.
screen: Option<u64>,
screen_time: i64,
}
impl AnimationModes {
@@ -119,15 +116,8 @@ impl super::Renderer {
day_time: i64,
[rain, thunder]: [f32; 2],
frame: Option<&crate::environment::Frame>,
screen: Option<u64>,
) {
let modes = &mut self.animations.modes;
// `screen_time`: a tick more while the same screen stays open, from 0 on a new one.
modes.screen_time = match (screen, modes.screen) {
(Some(open), Some(was)) if open == was => modes.screen_time + 1,
_ => 0,
};
modes.screen = screen;
modes.day_time = day_time;
modes.weather = rain + thunder;
modes.values.clear();
@@ -143,16 +133,6 @@ impl super::Renderer {
}
impl SpriteAnimator {
/// What the modes read this frame, for the GUI atlas's sprites (their expressions aren't
/// compiled: those run on ticks).
pub(crate) fn mode_inputs(&self) -> horizon_assets::animation::ModeInputs<'static> {
horizon_assets::animation::ModeInputs {
day_time: self.modes.day_time,
screen_time: self.modes.screen_time,
weather: self.modes.weather,
expression: &|_| None,
}
}
/// `textures` are the sprite arrays, `split` layers each, `size` pixels
/// wide; `animations` hold their frames at that size. `compute`: whether
/// the device says it runs compute shaders writing storage textures.
@@ -381,7 +361,6 @@ impl SpriteAnimator {
let expression = |source: &str| values.get(source).copied();
let inputs = horizon_assets::animation::ModeInputs {
day_time: self.modes.day_time,
screen_time: self.modes.screen_time,
weather: self.modes.weather,
expression: &expression,
};
@@ -2,8 +2,8 @@
//! and `secondaryBlockOutline` under a high contrast one): an outline its fragment shader colours
//! or animates. Without one the outline draws natively (`vs_line` in terrain.wgsl).
use crate::core_shader::{CoreProgram, Glue};
use glam::Mat4;
use horizon_shaders::{CoreProgram, Glue};
/// The outline's edges (`pack_lines::layout`: start, end, ARGB colour and width per instance) as
/// vanilla's lines: four vertices an edge, the start twice and the end twice, the side
@@ -425,7 +425,7 @@ mod tests {
eprintln!("no GPU: skipping");
return;
};
let program = crate::core_shader::translate_core(LINES_VSH, RGB_FSH, &[], &include, &GLUE)
let program = horizon_shaders::translate_core(LINES_VSH, RGB_FSH, &[], &include, &GLUE)
.expect("the shaders translate");
assert_pack_coloured(&draw_edge(&device, &queue, &program));
}
@@ -441,7 +441,7 @@ mod tests {
include(id)
.map(|text| text.replace("vec3 ModelOffset;", "vec3 ModelOffset;\n vec4 More;"))
};
let program = crate::core_shader::translate_core(LINES_VSH, RGB_FSH, &[], &bigger, &GLUE)
let program = horizon_shaders::translate_core(LINES_VSH, RGB_FSH, &[], &bigger, &GLUE)
.expect("the shaders translate");
assert!(CoreLines::new(&device, wgpu::TextureFormat::Rgba8Unorm, &program).is_none());
}
@@ -470,11 +470,11 @@ mod tests {
let (namespace, path) = id.split_once(':')?;
std::fs::read_to_string(root.join(format!("assets/{namespace}/{path}"))).ok()
};
let program = crate::core_shader::translate_core(&vertex, RGB_FSH, &[], &include, &GLUE)
let program = horizon_shaders::translate_core(&vertex, RGB_FSH, &[], &include, &GLUE)
.expect("vanilla's vertex shader translates");
// A pack that replaces only the vertex shader runs vanilla's fragment shader.
let fragment = include("minecraft:shaders/core/rendertype_lines.fsh").unwrap();
let vanilla = crate::core_shader::translate_core(&vertex, &fragment, &[], &include, &GLUE)
let vanilla = horizon_shaders::translate_core(&vertex, &fragment, &[], &include, &GLUE)
.expect("vanilla's fragment shader translates");
let Some((device, queue)) = device() else {
eprintln!("no GPU: skipping the draw");
@@ -3,8 +3,8 @@
//! to the horizon or tints the night sky expects. Without one the sky draws natively (`fs_sky` in
//! terrain.wgsl, stars.rs).
use crate::core_shader::{CoreProgram, Glue};
use glam::Mat4;
use horizon_shaders::{CoreProgram, Glue};
use wgpu::util::DeviceExt;
/// `DefaultVertexFormat.POSITION`: the disc's and the stars' vertices.
@@ -325,7 +325,7 @@ mod tests {
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 = crate::core_shader::translate_core(&vertex, fragment, &[], &include, &GLUE)
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)) =
@@ -1,11 +1,12 @@
//! Custom sky layers (`crate::custom_sky`) on the GPU: one texture per layer, kept with the renderer (its pack),
//! OptiFine custom sky layers on the GPU: one texture per layer, kept with the renderer (its pack),
//! a static skybox mesh of the 3x2 grid's six faces, and one pipeline per blend mode. A frame
//! uploads one small uniform per drawn layer ([`crate::custom_sky::Draw`]) and draws 36 vertices
//! each, after the vanilla sky's disc and sunrise and before the sun, moon and stars.
use glam::Mat4;
use horizon_optifine::sky::{Blend, Face};
use wgpu::util::DeviceExt;
use crate::custom_sky::{Blend, Draw, SkyLayers};
use crate::custom_sky::{CustomSky as Pack, Draw};
/// The blend modes in the shader's `mode` order (custom_sky.wgsl).
const MODES: [Blend; 9] = [
@@ -84,76 +85,6 @@ fn blend(mode: Blend) -> wgpu::BlendComponent {
}
}
/// A face of the skybox, as the 3x2 grid of its texture holds them: `Bottom`, `Top`, `South` in
/// the top row, `West`, `North`, `East` below.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Face {
Bottom,
Top,
South,
West,
North,
East,
}
impl Face {
const ALL: [Self; 6] = [
Self::Bottom,
Self::Top,
Self::South,
Self::West,
Self::North,
Self::East,
];
/// The face's cell of the texture, `[u0, v0, u1, v1]` in 0..1.
fn cell(self) -> [f32; 4] {
let (col, row) = match self {
Self::Bottom => (0, 0),
Self::Top => (1, 0),
Self::South => (2, 0),
Self::West => (0, 1),
Self::North => (1, 1),
Self::East => (2, 1),
};
let (u, v) = (col as f32 / 3.0, row as f32 / 2.0);
[u, v, u + 1.0 / 3.0, v + 0.5]
}
/// The face's corners (on the unit cube around the camera; x east, y up, z south) in the
/// order of its cell's `(u0, v0)`, `(u1, v0)`, `(u1, v1)`, `(u0, v1)`. The side faces are
/// seen from inside with up at the top of their cell; the top face has north at the bottom of
/// its cell, the bottom face north at the top (the template's `N` marks).
fn corners(self) -> [[f32; 3]; 4] {
match self {
// Seen looking north: west on the left.
Self::North => [
[-1., 1., -1.],
[1., 1., -1.],
[1., -1., -1.],
[-1., -1., -1.],
],
Self::South => [[1., 1., 1.], [-1., 1., 1.], [-1., -1., 1.], [1., -1., 1.]],
Self::East => [[1., 1., -1.], [1., 1., 1.], [1., -1., 1.], [1., -1., -1.]],
Self::West => [
[-1., 1., 1.],
[-1., 1., -1.],
[-1., -1., -1.],
[-1., -1., 1.],
],
// Looking up with north toward the cell's bottom edge: east on the right.
Self::Top => [[-1., 1., 1.], [1., 1., 1.], [1., 1., -1.], [-1., 1., -1.]],
// Looking down with north toward the cell's top edge: east on the right.
Self::Bottom => [
[-1., -1., -1.],
[1., -1., -1.],
[1., -1., 1.],
[-1., -1., 1.],
],
}
}
}
/// The skybox's 36 vertices: two triangles per face, each corner with its cell's UV.
fn mesh() -> Vec<Vertex> {
let mut out = Vec::with_capacity(36);
@@ -325,15 +256,10 @@ impl CustomSky {
}
/// The pack's layer textures, replacing any before.
pub(super) fn set_pack(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
pack: &dyn SkyLayers,
) {
pub(super) fn set_pack(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pack: &Pack) {
let limit = device.limits().max_texture_dimension_2d;
self.groups = pack
.images()
.images
.iter()
.map(|image| {
// Too large for this GPU: a 1x1 transparent texel, so the layer draws nothing.
@@ -487,38 +413,6 @@ impl CustomSky {
mod tests {
use super::*;
#[test]
fn the_grid_holds_each_face_once_and_faces_point_out() {
let mut cells: Vec<_> = Face::ALL
.iter()
.map(|f| f.cell().map(|x| (x * 6.0) as i32))
.collect();
cells.dedup();
assert_eq!(cells.len(), 6);
assert_eq!(Face::East.cell(), [2.0 / 3.0, 0.5, 1.0, 1.0]);
for face in Face::ALL {
let c = face.corners();
let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
let (a, b) = (sub(c[1], c[0]), sub(c[3], c[0]));
let n = [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
let centre: [f32; 3] =
std::array::from_fn(|i| c.iter().map(|p| p[i]).sum::<f32>() / 4.0);
// Seen from inside, u runs right and v down, so u x v points away from the camera.
let dot: f32 = (0..3).map(|i| n[i] * centre[i]).sum();
assert!(dot > 0.0, "{face:?} is mirrored");
}
// The top cell's bottom edge (v1) is north: its corners 2 and 3 have z = -1.
assert_eq!(Face::Top.corners()[2][2], -1.0);
assert_eq!(Face::Top.corners()[3][2], -1.0);
// The bottom cell's top edge (v0) is north.
assert_eq!(Face::Bottom.corners()[0][2], -1.0);
assert_eq!(Face::Bottom.corners()[1][2], -1.0);
}
/// What a blend leaves a sky texel of `dst` with when the shader outputs `src`
/// (custom_sky.wgsl) for a layer texel `t` at brightness `b`, per channel.
fn mix(mode: Blend, dst: f32, t: f32, a: f32, b: f32) -> f32 {
@@ -6,7 +6,7 @@ struct Layer {
// Projection * view (camera-relative) * the layer's rotation.
clip_from_sky: mat4x4<f32>,
brightness: f32,
// crate::custom_sky::Blend, in gfx/custom_sky.rs's MODES order.
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
mode: u32,
pad: vec2<u32>,
hdr_settings: vec4<f32>,
@@ -1,25 +1,3 @@
// This file is part of the FidelityFX SDK.
//
// Copyright (C) 2024 Advanced Micro Devices, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files(the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and /or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions :
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//! FSR 3.1's frame interpolation (AMD FidelityFX SDK 1.1.4, `ffx_frameinterpolation*`,
//! MIT), ported to WGSL (`interpolation.wgsl`): the frame halfway between the previous
//! real frame and this one, from the camera's motion vectors, the optical flow
+3 -156
View File
@@ -185,7 +185,7 @@ pub struct Hdr {
/// The display's black level, in nits: shadows are lifted onto it (`hdr.wgsl`).
pub black: f32,
pub tiers: [Tier; 4],
/// The HUD and menus' brightness, as a share of paper white: above 1 brighter than it.
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
pub ui: f32,
/// Which pixels of a light source shine (`Masks`).
pub masks: Masks,
@@ -419,26 +419,6 @@ impl Hdr {
}
}
/// The same HDR output presenting `output` instead (`output_apply`): the same frame and
/// HUD layer, and the player's tuning as it is (HDR Lighting, the world's lighting, the UI
/// brightness, the masks and tiers, the debug view, calibration and the luminance). Its
/// targets are made again on the next frame.
pub fn retargeted(&self, device: &wgpu::Device, queue: &wgpu::Queue, output: Output) -> Self {
Self {
debug: self.debug,
paper_white: self.paper_white,
peak: self.peak,
black: self.black,
tiers: self.tiers,
ui: self.ui,
masks: self.masks,
lighting: self.lighting,
look: self.look,
calibration: self.calibration,
..Self::new(device, queue, self.format, output, self.layered.is_some())
}
}
/// The frame every pass draws into this frame, sized like the window.
pub fn frame(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
if self.targets.as_ref().is_none_or(|t| t.size != size) {
@@ -956,7 +936,7 @@ impl Hdr {
f32::from(u8::from(pq)),
]);
params[5] = self.black.clamp(0.0, paper);
params[6] = self.ui;
params[6] = self.ui.clamp(0.25, 1.0);
params[7] = if self.lighting {
scene_gain(self.peak, self.paper_white)
} else {
@@ -1561,93 +1541,6 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
}
/// HDR output switched from HDR10 to scRGB and back (`output_apply`): the player's tuning
/// stays, each swapchain gets the same light in its own encoding, and the HDR10 capture
/// and the HDR10 picture after the switch back are the ones before it.
#[test]
fn a_switched_encoding_keeps_the_tuning_and_shows_the_same_light() {
let Some((device, queue)) = gpu() else {
eprintln!("HDR encoding switch GPU skipped: no adapter");
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
(hdr.paper_white, hdr.peak, hdr.black) = (250.0, 800.0, 0.0);
(hdr.ui, hdr.look, hdr.lighting) = (0.5, false, false);
hdr.masks.parts = 0.4;
hdr.tiers[1].share = 0.7;
hdr.debug = true;
hdr.calibration = Some(super::Calibration {
step: 2,
nits: 700.0,
band: 0.6,
});
let tuning = |h: &Hdr| {
(
[h.paper_white, h.peak, h.black, h.ui],
(h.look, h.lighting, h.debug, h.layered.is_some(), h.format),
(h.masks, h.tiers, h.calibration),
)
};
let switched = hdr.retargeted(&device, &queue, Output::ScRgb);
assert_eq!(switched.output, Output::ScRgb);
assert_eq!(tuning(&switched), tuning(&hdr));
hdr.calibration = None;
hdr.debug = false;
// The swapchain's first texel (`capture`: the HDR10 screenshot's) for light 2 times
// paper white, grey, as the layered frame stores it.
let shot = |hdr: &mut Hdr, capture: bool| {
let frame = hdr.frame(&device, [1, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
let g = 2f64.powf(1.0 / 2.2);
fill(&mut encoder, &frame, [g, g, g, 1.0]);
hdr.begin(&queue, true);
hdr.keep_world(&mut encoder, None);
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
let target = if capture {
hdr.capture(&device, &queue, &mut encoder, true).unwrap()
} else {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: frame.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: hdr.output.format(),
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
};
if !capture {
hdr.present(&queue, &mut encoder, &target, true, None);
}
texel(&device, &queue, encoder, &target)
};
// BT.2100's PQ EOTF: a 10-bit code's nits.
let pq_nits = |code: f32| {
let (m1, m2) = (2610.0 / 16384.0, 2523.0 / 4096.0 * 128.0);
let (c1, c2, c3) = (3424.0 / 4096.0, 2413.0 / 128.0, 2392.0 / 128.0);
let p = (code / 1023.0f32).powf(1.0 / m2);
((p - c1).max(0.0) / (c2 - c3 * p)).powf(1.0 / m1) * 10000.0
};
let pq = shot(&mut hdr, false);
let pq_capture = shot(&mut hdr, true);
let mut scrgb = hdr.retargeted(&device, &queue, Output::ScRgb);
let linear = shot(&mut scrgb, false);
assert_eq!(shot(&mut scrgb, true), pq_capture);
// Two times paper white in both: 500 nits, as HDR10's code and as scRGB's 80-nit units.
let near = |got: f32| (got - 500.0).abs() <= 0.015 * 500.0;
assert!(
near(pq_nits(pq[0])) && pq[..3].iter().all(|&c| c == pq[0]),
"{pq:?}"
);
assert!(near(linear[0] * 80.0), "{linear:?}");
let mut back = scrgb.retargeted(&device, &queue, Output::Pq);
assert_eq!(
(shot(&mut back, false), shot(&mut back, true)),
(pq, pq_capture)
);
}
/// ITU-R BT.2390's black level lift on grey light, in HDR10 and in scRGB, against values
/// worked out apart from the shader, in f64 from SMPTE ST 2084: E3 = E2 + b (1 - E2)^4 on
/// the PQ signal, for a source from 0 to the peak and b the black level's share of it.
@@ -1906,11 +1799,9 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
// Covered: the HUD as drawn, at UI Brightness.
(2.0, 0.3, 1.0, 1.0, 0.3),
(2.0, 0.3, 1.0, 0.5, 0.3 * 0.5f32.powf(1.0 / 2.2)),
// UI Brightness above paper white: brighter than drawn.
(2.0, 0.3, 1.0, 2.0, 0.3 * 2f32.powf(1.0 / 2.2)),
// Added over the world (the text highlight), which it doesn't hide.
(0.6, 0.8, 0.0, 1.0, 0.8),
// At or below white, at paper white: the HUD's picture as drawn.
// At or below white, at 100%: the HUD's picture as drawn.
(0.7, 0.45, 0.5, 1.0, 0.45),
] {
hdr.ui = ui;
@@ -1930,50 +1821,6 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
}
}
/// On the frame without the HUD's layer, the HUD at UI Brightness reaches its nits, and
/// above the peak stops there, as on the layered frame.
#[test]
fn the_hud_at_ui_brightness_stops_at_the_peak() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba8Unorm, Output::ScRgb, false, None);
(hdr.paper_white, hdr.peak) = (200.0, 300.0);
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: Output::ScRgb.format(),
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
// UI Brightness (a share of paper white) and the white HUD's nits.
for (ui, want) in [(0.5, 100.0), (1.25, 250.0), (2.0, 300.0)] {
hdr.ui = ui;
let frame = hdr.frame(&device, [1, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, [0.2, 0.2, 0.2, 1.0]);
hdr.keep_world(&mut encoder, None);
// Opaque white HUD over the world: alpha 1 is full coverage.
fill(&mut encoder, &frame, [1.0; 4]);
hdr.begin(&queue, true);
hdr.present(&queue, &mut encoder, &target, true, None);
let got = texel(&device, &queue, encoder, &target);
for c in &got[..3] {
assert!(
(c * 80.0 - want).abs() < 0.5,
"UI {ui}: {got:?}, not {want} nits"
);
}
}
}
#[test]
fn the_menu_blur_keeps_the_light_it_blurs() {
let Some((device, queue)) = gpu() else {
@@ -1,65 +0,0 @@
//! A window's HDR output start and what a failed one leaves (`graphics::HDR_MARKER`). Turning
//! HDR output on writes a marker naming the window's graphics driver (`output::window_driver`);
//! the first frame presented, turning HDR output off and a clean exit remove it. A start that
//! finds it holds HDR output back on that driver (`output::held`) until a mode is chosen on the
//! HDR page or the driver changes. Offscreen renderers have no driver: they never write the
//! marker or hold HDR output back.
use super::{Renderer, output};
impl Renderer {
/// What this renderer's destination offers for `settings`: HDR output held back on a window
/// whose graphics driver a start failed on (`output::held`).
pub(super) fn caps_for(
&self,
settings: &crate::graphics::Graphics,
) -> output::OutputCapabilities {
output::OutputCapabilities {
held: output::held(settings, self.hdr_driver.as_deref()),
..self.output_caps
}
}
/// Takes the hold `settings` put on this window's driver into the capabilities, and says it
/// once per start.
pub(super) fn hold_hdr_back(&mut self, settings: &crate::graphics::Graphics) {
self.output_caps = self.caps_for(settings);
if self.output_caps.held
&& let Some(driver) = &self.hdr_driver
{
static LOGGED: std::sync::Once = std::sync::Once::new();
LOGGED.call_once(|| {
log::warn!(
"Horizon renderer: HDR output is held back: the last start with this graphics driver ({driver}) ended before HDR output showed a frame. Choose an HDR mode on the HDR page to try again (or remove hdrFailedStart from {})",
crate::graphics::FILE
)
});
}
}
/// Writes the marker at `marker` as a window's HDR output turns on (`on`), and removes this
/// renderer's marker as it turns off.
pub(super) fn mark_hdr_start(&mut self, on: bool, marker: &std::path::Path) {
if !on {
self.drop_hdr_marker();
} else if self.surface.is_some()
&& let Some(driver) = &self.hdr_driver
{
let _ = std::fs::write(marker, crate::graphics::hdr_marker_text(driver));
self.hdr_marker = Some(marker.to_owned());
}
}
/// The graphics driver this window's HDR output starts on (`output::window_driver`);
/// `None` offscreen.
pub fn hdr_driver(&self) -> Option<&str> {
self.hdr_driver.as_deref()
}
/// Removes this renderer's marker of an HDR output that hasn't presented a frame yet, for a
/// clean exit: only a crash may leave it.
pub fn drop_hdr_marker(&mut self) {
if let Some(marker) = self.hdr_marker.take() {
let _ = std::fs::remove_file(marker);
}
}
}
@@ -8,7 +8,8 @@
//! colour. They read the terrain's group 0, the flat draws' group 1 where those run, and the
//! pack's frame at group 3.
use horizon_shader_api::{check::CheckedStage, stages::GROUP};
use horizon_render_pipeline::stages::GROUP;
use horizon_shader_api::check::CheckedStage;
use super::native_post::{NativeFrame, present_constants};
use super::wgsl_prefix::{TERRAIN, prefixed, section};
@@ -369,7 +370,7 @@ fn shader_source(
tags: &[(u32, u32)],
materials: &[(u32, u32)],
(vertex, displaced): (bool, bool),
api: horizon_shader_api::Api,
api: horizon_render_pipeline::Api,
) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{}{ENTRIES}{}{}",
@@ -398,7 +399,7 @@ fn module(
materials: &[(u32, u32)],
vertex: bool,
displaced: bool,
api: horizon_shader_api::Api,
api: horizon_render_pipeline::Api,
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials, (vertex, displaced), api);
naga::front::wgsl::parse_str(&text)
@@ -417,7 +418,7 @@ impl NativeMaterial {
vertex: bool,
displaced: Option<&DisplacedShape<'_>>,
) -> Result<Self, String> {
let writes_surface = horizon_shader_api::stages::calls_set_surface(&stage.source);
let writes_surface = horizon_render_pipeline::stages::calls_set_surface(&stage.source);
let module = module(
stage,
shape.sprite_tags,
@@ -586,7 +587,7 @@ impl NativeMaterial {
}
#[cfg(test)]
pub(super) fn coverage_test_shader(api: horizon_shader_api::Api) -> String {
pub(super) fn coverage_test_shader(api: horizon_render_pipeline::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"
@@ -829,7 +830,7 @@ mod tests {
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let source = report.stages[0].source.clone()
+ horizon_shader_api::stages::PBR_STUB
+ horizon_render_pipeline::stages::PBR_STUB
+ super::super::native_post::line_helpers(api)
+ entry;
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -1,7 +1,8 @@
//! One of Horizon's own shader packs while it runs (docs/shaders.md): the frame its stages
//! read, its sky, material and post stages, and the timer of their passes.
use horizon_shader_api::{Report, StageKind};
use horizon_render_pipeline::StageKind;
use horizon_shader_api::Report;
use super::displace::Displacement;
use super::native_material::{DisplacedShape, NativeMaterial, TerrainShape};
@@ -253,7 +254,7 @@ impl NativePack {
.transpose()?;
// A 0.2 pack always has one: its decode and tonemap run without a chain too.
let tonemap = others.iter().find(|s| s.kind == StageKind::Tonemap);
let post = (!post.is_empty() || api == horizon_shader_api::Api::V0_2)
let post = (!post.is_empty() || api == horizon_render_pipeline::Api::V0_2)
.then(|| {
let manifest = &report.manifest;
NativePost::new(device, post, manifest, constants, format, &frame, tonemap)
@@ -7,9 +7,9 @@
//! (`NativeFrame`).
use glam::{Mat4, Vec3};
use horizon_shader_api::{
Api, Frame as FrameInputs, StageKind, check::CheckedStage, stages::GROUP,
};
use horizon_render_pipeline::stages::{GROUP, POST_TARGET_BINDING};
use horizon_render_pipeline::{Api, Frame as FrameInputs, StageKind};
use horizon_shader_api::check::CheckedStage;
use super::Frame;
@@ -338,7 +338,7 @@ pub struct NativeFrame {
reflection_sampler: wgpu::Sampler,
/// The shadow map the stages read, and the camera that draws it when the pack asks for one.
pub shadow_map: super::native_shadow::ShadowMap,
shadow_camera: Option<horizon_shaders::Shadow>,
shadow_camera: Option<horizon_render_pipeline::Shadow>,
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
/// is drawn and `has_reflection` stays 0.
pub reflects: bool,
@@ -1014,10 +1014,9 @@ impl NativePost {
texture_entry(11, wgpu::TextureSampleType::Uint),
];
entries.extend(pack_texture_entries(frame.textures.len()));
entries
.extend((0..targets.len() as u32).map(|j| {
texture_entry(horizon_shader_api::stages::POST_TARGET_BINDING + j, float)
}));
entries.extend(
(0..targets.len() as u32).map(|j| texture_entry(POST_TARGET_BINDING + j, float)),
);
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack post"),
entries: &entries,
@@ -1346,7 +1345,7 @@ impl NativePost {
view
};
wgpu::BindGroupEntry {
binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32,
binding: POST_TARGET_BINDING + j as u32,
resource: wgpu::BindingResource::TextureView(view),
}
}));
@@ -1497,7 +1496,7 @@ pub fn frame_inputs(
size: [u32; 2],
seconds: f32,
index: u32,
shadow: Option<&horizon_shaders::Shadow>,
shadow: Option<&horizon_render_pipeline::Shadow>,
) -> FrameInputs {
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
@@ -1655,7 +1654,7 @@ pub fn pack_textures(
/// Layout entries of `count` pack textures and their samplers.
pub(super) fn pack_texture_entries(count: usize) -> Vec<wgpu::BindGroupLayoutEntry> {
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
use horizon_render_pipeline::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
(0..count as u32)
.flat_map(|i| {
[
@@ -1684,7 +1683,7 @@ pub(super) fn pack_texture_entries(count: usize) -> Vec<wgpu::BindGroupLayoutEnt
pub(super) fn pack_texture_group_entries(
textures: &[(wgpu::TextureView, wgpu::Sampler)],
) -> Vec<wgpu::BindGroupEntry<'_>> {
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
use horizon_render_pipeline::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
textures
.iter()
.enumerate()
@@ -2677,8 +2676,7 @@ file = \"b.wgsl\"
/// 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. An HDR output switched
/// to scRGB and back to HDR10 (`Hdr::retargeted`) maps it once all the same.
/// 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 {
@@ -2688,9 +2686,8 @@ file = \"b.wgsl\"
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, the
// output switched).
let shown = |pack_curve: bool, hdr: bool, switched: bool| {
// 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]",
@@ -2748,11 +2745,6 @@ file = \"b.wgsl\"
true,
);
(display.paper_white, display.peak) = (paper, peak);
if switched {
use super::super::hdr::Output::{Pq, ScRgb};
display = display.retargeted(&device, &queue, ScRgb);
display = display.retargeted(&device, &queue, Pq);
}
let screen = display.frame(&device, [4, 4]);
let usage =
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
@@ -2820,16 +2812,14 @@ file = \"b.wgsl\"
// 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.
for switched in [false, true] {
let horizon = shown(false, true, switched);
assert!(near(horizon, 4.82043), "{switched}: {horizon}");
// The pack's identity curve alone: the light held to the headroom, not eased again.
let pack = shown(true, true, switched);
assert!(near(pack, headroom), "{switched}: {pack}");
}
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, false);
let sdr = shown(pack_curve, false);
assert!(near(sdr, 1.0), "{pack_curve}: {sdr}");
}
}
@@ -11,9 +11,9 @@ use std::sync::{
atomic::{AtomicBool, Ordering},
};
/// The hooks' trait and dispatch, made from `native-hooks.json` by `cargo run -p xtask -- mod-api`;
/// the game's build checks that the file is current.
mod generated;
mod generated {
include!(concat!(env!("OUT_DIR"), "/native_render_generated.rs"));
}
pub use generated::{
HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent, WorldWarpEvent,
};
@@ -1,71 +0,0 @@
// @generated by cargo run -p xtask -- mod-api; do not edit.
use super::*;
pub trait RenderHooks: Send + Sync {
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
fn world_drawn(&self, event: &mut WorldDrawnEvent<'_, '_>) {
let _ = event;
}
/// Asked as a frame is built and drawn: set what your mod needs.
///
/// Every mod gets `event.needs` in order and may add, change or remove needs.
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
let _ = event;
}
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
/// runs, with no hand too. API 364.
fn hand_drawn(&self, event: &mut HandDrawnEvent<'_, '_>) {
let _ = event;
}
/// 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>`.
///
/// Every mod gets `event.warp` in order and may add, change or remove warp.
fn world_warp(&self, event: &mut WorldWarpEvent<'_>) {
let _ = event;
}
}
/// What [`RenderHooks::world_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct WorldDrawnEvent<'a, 'b> {
pub world: &'a mut WorldDrawn<'b>,
}
pub fn dispatch_world_drawn(mods: &[&dyn RenderHooks], world: &mut WorldDrawn<'_>) {
let mut event = WorldDrawnEvent { world };
for mod_hook in mods {
mod_hook.world_drawn(&mut event);
}
}
/// What [`RenderHooks::render_needs`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct RenderNeedsEvent<'a> {
pub needs: &'a mut RenderNeeds,
}
pub fn dispatch_render_needs(mods: &[&dyn RenderHooks], needs: &mut RenderNeeds) {
let mut event = RenderNeedsEvent { needs };
for mod_hook in mods {
mod_hook.render_needs(&mut event);
}
}
/// What [`RenderHooks::hand_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct HandDrawnEvent<'a, 'b> {
pub hand: &'a mut HandDrawn<'b>,
}
pub fn dispatch_hand_drawn(mods: &[&dyn RenderHooks], hand: &mut HandDrawn<'_>) {
let mut event = HandDrawnEvent { hand };
for mod_hook in mods {
mod_hook.hand_drawn(&mut event);
}
}
/// What [`RenderHooks::world_warp`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct WorldWarpEvent<'a> {
pub warp: &'a mut WorldWarp,
}
pub fn dispatch_world_warp(mods: &[&dyn RenderHooks], warp: &mut WorldWarp) {
let mut event = WorldWarpEvent { warp };
for mod_hook in mods {
mod_hook.world_warp(&mut event);
}
}
@@ -6,8 +6,9 @@
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
//! prelude's `shadow_position` undoes.
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shaders::Shadow;
use horizon_render_pipeline::Shadow;
use horizon_render_pipeline::{Api, Frame as FrameInputs, stages::GROUP};
use horizon_shader_api::manifest::Shadow as Settings;
use super::native_material::TerrainShape;
use super::wgsl_prefix::{TERRAIN, prefixed, section};
@@ -387,7 +388,7 @@ fn entity_source() -> String {
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{ENTITY_ENTRIES}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
horizon_render_pipeline::stages::SHADOW_HELPERS,
)
}
@@ -486,7 +487,7 @@ fn shader_source(
(Some(material), _) => (
format!(
"{material}{}{table}{}{}{GRID_ENTRIES}",
horizon_shader_api::stages::PBR_STUB,
horizon_render_pipeline::stages::PBR_STUB,
super::displace::RELIEF,
super::displace::GRID,
),
@@ -504,7 +505,7 @@ fn shader_source(
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{UNTAKEN}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
horizon_render_pipeline::stages::SHADOW_HELPERS,
),
super::native_material::DISPLACE_VANILLA,
),
@@ -6,7 +6,8 @@
//! names). It reads the renderer's globals (group 0) and the pack's frame (group 3), and
//! draws where vanilla's sky draws; the sun, moon, stars and the dark disc are drawn over it.
use horizon_shader_api::{check::CheckedStage, stages::GROUP};
use horizon_render_pipeline::stages::GROUP;
use horizon_shader_api::check::CheckedStage;
use super::native_post::{NativeFrame, present_constants};
use super::wgsl_prefix::{TERRAIN, prefixed, section};
@@ -40,7 +41,7 @@ fn vanilla_sky() -> String {
}
/// The sky shader for the pack's sky stage `source`.
fn shader_source(source: &str, api: horizon_shader_api::Api) -> String {
fn shader_source(source: &str, api: horizon_render_pipeline::Api) -> String {
format!(
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{}{PACK_ENTRY}",
vanilla_sky(),
@@ -198,7 +199,7 @@ mod tests {
// A pack may use the renderer's names for its own things.
let own = "override gain: f32;\nconst PI: f32 = 3.0;\nfn view_ray() -> f32 { return PI; }\n\
fn sky(input: SkyInput) -> vec4<f32> { return input.color * gain * view_ray(); }\n";
let text = shader_source(&stage(own).source, horizon_shader_api::Api::V0_1);
let text = shader_source(&stage(own).source, horizon_render_pipeline::Api::V0_1);
let module = naga::front::wgsl::parse_str(&text).unwrap_or_else(|e| {
panic!("{}", e.emit_to_string(&text));
});
@@ -261,7 +262,7 @@ mod tests {
Vec::new(),
Default::default(),
None,
horizon_shader_api::Api::V0_1,
horizon_render_pipeline::Api::V0_1,
);
queue.write_buffer(
&frame.buffer,
@@ -1,59 +0,0 @@
//! `move_off_page`, moved out of `gfx.rs` unchanged.
use super::*;
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
/// until the next range would pass `budget` bytes (it keeps what is left; the
/// first range moves whatever its size, so none keeps the page closed), the
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
/// section arena, GPU culling's records (`records`) along. False when the other
/// pages have no room left.
pub(super) fn move_off_page<'a>(
arena: &mut arena::Arena,
encoder: &mut wgpu::CommandEncoder,
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
mut records: Option<&mut gpu_culling::Records>,
meshes: impl Iterator<Item = &'a mut SectionGpu>,
page: u32,
part: Part,
budget: &mut u64,
) -> bool {
debug_assert!(
records.is_none() || matches!(part, Part::Section { .. }),
"records follow the section arena's ranges"
);
let mut moved = false;
for s in meshes {
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
// range of page 0); in the other arenas, one without translucent quads.
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
continue;
};
let bytes = vertices * arena.stride();
if moved && bytes > *budget {
break;
}
let Some(to) = arena.alloc_moved(vertices) else {
return false;
};
arena.copy(encoder, (page, start), to, vertices);
arena.release(page, start, vertices);
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
let key = match part {
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
_ => to.0,
};
flat.place(device, encoder, uploads, id, key, to.1, vertices);
}
part.set(s, to);
// Its solid quads start the range in either layout.
if let (Part::Section { .. }, Some(records), Some(id)) =
(part, records.as_deref_mut(), s.gpu_id)
{
records.relocate(id, to.0, u64::from(s.solid_base()));
}
*budget = budget.saturating_sub(bytes);
moved = true;
}
true
}
+6 -201
View File
@@ -18,43 +18,6 @@ pub(crate) struct OutputCapabilities {
pub float_frame: bool,
/// The device keeps the HUD on a layer of its own (`hud_coverage::supported`).
pub hud_layer: bool,
/// HDR output is held back: the last start on this window's graphics driver ended before
/// HDR output showed a frame ([`held`]). The mode stays; the output is SDR.
pub held: bool,
}
/// Whether HDR output is held back on a renderer whose window's graphics driver is `driver`
/// (`None` offscreen, which never holds it back: only a window writes the marker).
pub(crate) fn held(settings: &Graphics, driver: Option<&str>) -> bool {
driver.is_some_and(|driver| settings.hdr_held(driver))
}
/// The graphics driver this window's HDR output starts on ([`driver_fingerprint`]), with the
/// system's version as crash reports name it (`crash::operating_system`), read once a run.
pub(crate) fn window_driver(info: &wgpu::AdapterInfo) -> String {
static OS: std::sync::OnceLock<String> = std::sync::OnceLock::new();
driver_fingerprint(info, OS.get_or_init(crate::crash::operating_system))
}
/// The graphics driver a window's HDR output starts on, in one line: the backend, the
/// adapter's vendor and device IDs and its name, the driver's name and its version, and the
/// system's version `os`. Another GPU, a driver update or a system update reads differently:
/// Metal names no driver, so on macOS the driver updates with the system.
pub fn driver_fingerprint(info: &wgpu::AdapterInfo, os: &str) -> String {
let adapter = format!(
"{:?} {:04x}:{:04x} {}",
info.backend, info.vendor, info.device, info.name
);
[adapter.as_str(), &info.driver, &info.driver_info, os]
.map(|field| {
field
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect::<String>()
.trim()
.to_owned()
})
.join("; ")
}
/// What the system last reported for a renderer's display: its luminance, and whether it
@@ -134,15 +97,13 @@ pub struct EffectiveOutput {
pub(crate) reported: Option<Reported>,
/// The device draws the 16-bit frame (Smooth Gradients).
pub(crate) wide_frame_supported: bool,
/// HDR output is held back on this window's graphics driver (`OutputCapabilities::held`).
pub(crate) held: bool,
/// The HDR output presented; `None` is SDR.
pub(crate) encoding: Option<Output>,
pub(crate) frame: FrameConfiguration,
/// Paper white, peak and black level in nits, as HDR output's last pass and its
/// metadata take them, while it is on.
pub(crate) nits: [f32; 3],
/// The HUD and menus' brightness, as a share of paper white: their nits over its.
/// The HUD and menus' brightness, as a share of paper white.
pub(crate) ui_brightness: f32,
}
impl Default for EffectiveOutput {
@@ -157,7 +118,6 @@ impl EffectiveOutput {
system_hdr: None,
reported: None,
wide_frame_supported: false,
held: false,
encoding: None,
frame: FrameConfiguration {
format: wgpu::TextureFormat::Bgra8Unorm,
@@ -176,10 +136,9 @@ impl EffectiveOutput {
self.encoding.is_some()
}
/// Whether `mode` asks for HDR here: On always, Auto where the destination offers it and
/// the display reports HDR, neither while HDR output is held back. The output still needs
/// an offer and a frame that can take it.
/// the display reports HDR. The output still needs an offer and a frame that can take it.
pub fn requests_hdr(&self, mode: HdrMode) -> bool {
!self.held && requests_hdr(mode, self.offered.is_some(), self.system_hdr)
requests_hdr(mode, self.offered.is_some(), self.system_hdr)
}
/// What a 0.2 pack's `Frame` describes: the HDR display on the layered frame, whose
/// picture is light; SDR otherwise, where the frame holds SDR's picture, the one HDR
@@ -210,10 +169,6 @@ fn requests_hdr(mode: HdrMode, offered: bool, system: Option<bool>) -> bool {
HdrMode::Auto => offered && system == Some(true),
}
}
/// Whether `settings` ask for HDR on a destination of `caps` whose display reports `reading`.
fn wanted(settings: &Graphics, caps: OutputCapabilities, reading: DisplayReading) -> bool {
!caps.held && requests_hdr(settings.hdr, caps.hdr.is_some(), reading.hdr)
}
/// The frame a renderer made now draws in, for `settings`, the pack's `request`, what the
/// destination and device can do (`caps`) and what the display reports.
@@ -224,7 +179,7 @@ pub(crate) fn resolve_frame(
reading: DisplayReading,
swapchain: wgpu::TextureFormat,
) -> FrameConfiguration {
let wanted = wanted(settings, caps, reading);
let wanted = requests_hdr(settings.hdr, caps.hdr.is_some(), reading.hdr);
let (format, hud_layer) = super::frame_configuration(
wanted && settings.hdr_smooth_gradients,
(
@@ -255,13 +210,12 @@ pub(crate) fn resolve_output(
frame: FrameConfiguration,
presents: bool,
) -> EffectiveOutput {
let wanted = wanted(settings, caps, reading);
let wanted = requests_hdr(settings.hdr, caps.hdr.is_some(), reading.hdr);
EffectiveOutput {
offered: caps.hdr,
system_hdr: reading.hdr,
reported: reading.luminance,
wide_frame_supported: caps.wide_frame,
held: caps.held,
encoding: caps
.hdr
.filter(|_| wanted && presents && frame.overlay_format == frame.format),
@@ -271,8 +225,7 @@ pub(crate) fn resolve_output(
settings.peak_nits(reading.luminance) as f32,
settings.black_level_nits(),
],
ui_brightness: settings.ui_nits(reading.luminance) as f32
/ settings.paper_white_nits(reading.luminance) as f32,
ui_brightness: settings.hdr_ui_brightness as f32 / 100.0,
}
}
@@ -294,13 +247,11 @@ pub(crate) mod tests {
wide_frame: true,
float_frame: true,
hud_layer: true,
held: false,
},
DisplayReading {
luminance: Some(Reported {
paper_white: 400,
peak: 1000,
peak_guessed: false,
}),
hdr: system,
},
@@ -427,118 +378,6 @@ pub(crate) mod tests {
assert_eq!(settings.hdr, HdrMode::On);
}
/// A window on the driver a start failed on stays SDR, on an SDR frame, whatever the
/// mode, and the mode stays as chosen; another driver tries HDR again, and an offscreen
/// renderer (no driver) never holds HDR back.
#[test]
fn a_failed_start_holds_hdr_back_on_its_driver_only() {
let failed = "Vulkan 10de:2684 GPU; driver; 1.0";
for mode in [HdrMode::On, HdrMode::Auto] {
let settings = Graphics {
hdr: mode,
hdr_failed_start: Some(failed.into()),
..Graphics::default()
};
let request = FrameRequest {
linear_pack: false,
hdr_float: true,
};
let on = |driver: Option<&str>| {
let (mut caps, reading) = window(Some(Output::Pq), Some(true));
caps.held = held(&settings, driver);
snapshot(&settings, request, caps, reading)
};
let out = on(Some(failed));
assert_eq!(out.encoding, None, "{mode:?}");
assert_eq!(out.frame.format, Bgra8Unorm, "{mode:?}");
assert!(!out.frame.hud_layer && out.held && !out.requests_hdr(mode));
// Still offered: the page lets the player pick a mode to try again.
assert!(out.hdr_offered());
assert_eq!(settings.hdr, mode);
for driver in [Some("Vulkan 10de:2684 GPU; driver; 1.1"), None] {
let out = on(driver);
assert_eq!(out.encoding, Some(Output::Pq), "{mode:?} {driver:?}");
assert_eq!(out.frame.format, LINEAR_FRAME, "{mode:?} {driver:?}");
assert!(!out.held && out.requests_hdr(mode));
}
}
// A marker that named no driver holds on whichever window comes first.
let legacy = Graphics {
hdr: HdrMode::On,
hdr_failed_start: Some(String::new()),
..Graphics::default()
};
assert!(held(&legacy, Some(failed)));
assert!(!held(&legacy, None));
}
/// The fingerprint tells another GPU, backend, driver version or system version apart, in
/// one line.
#[test]
fn the_driver_fingerprint_is_one_line_that_changes_with_the_driver() {
let os = "windows (x86_64) version Windows 11 (26200.7019)";
let info = wgpu::AdapterInfo {
name: "NVIDIA GeForce RTX 4090".into(),
vendor: 0x10de,
device: 0x2684,
device_type: wgpu::DeviceType::DiscreteGpu,
device_pci_bus_id: "0000:01:00.0".into(),
driver: "NVIDIA".into(),
driver_info: "581.57\n".into(),
backend: wgpu::Backend::Vulkan,
subgroup_min_size: 32,
subgroup_max_size: 32,
transient_saves_memory: None,
limit_bucket: None,
};
let fingerprint = driver_fingerprint(&info, os);
assert_eq!(
fingerprint,
"Vulkan 10de:2684 NVIDIA GeForce RTX 4090; NVIDIA; 581.57; \
windows (x86_64) version Windows 11 (26200.7019)"
);
assert_ne!(
driver_fingerprint(&info, "windows (x86_64) version Windows 11 (26200.7171)"),
fingerprint
);
// Metal names no driver: a macOS update is what changes it.
let metal = wgpu::AdapterInfo {
name: "Apple M3".into(),
vendor: 0,
device: 0,
driver: String::new(),
driver_info: String::new(),
backend: wgpu::Backend::Metal,
..info.clone()
};
let sonoma = driver_fingerprint(&metal, "macos (aarch64) version macOS 14.6");
assert_eq!(
sonoma,
"Metal 0000:0000 Apple M3; ; ; macos (aarch64) version macOS 14.6"
);
assert_ne!(
driver_fingerprint(&metal, "macos (aarch64) version macOS 15.1"),
sonoma
);
let other = [
wgpu::AdapterInfo {
driver_info: "582.02".into(),
..info.clone()
},
wgpu::AdapterInfo {
backend: wgpu::Backend::Dx12,
..info.clone()
},
wgpu::AdapterInfo {
device: 0x2704,
..info.clone()
},
];
for other in other {
assert_ne!(driver_fingerprint(&other, os), fingerprint, "{other:?}");
}
}
/// Auto's paper white and peak are this display's report; the player's own values win.
#[test]
fn auto_levels_follow_this_renderers_report() {
@@ -568,39 +407,6 @@ pub(crate) mod tests {
);
}
/// UI Brightness reaches HDR output as its nits over paper white's: 1 on Auto, whatever
/// paper white is; a value set by hand holds its nits, Paper White on Auto or set by hand.
#[test]
fn ui_brightness_is_its_nits_over_paper_whites() {
let (caps, reading) = window(Some(Output::Pq), Some(true));
let share = |paper_white, ui_nits, reading| {
let settings = Graphics {
hdr: HdrMode::On,
hdr_paper_white: paper_white,
hdr_ui_nits: ui_nits,
..Graphics::default()
};
let frame = resolve_frame(
&settings,
FrameRequest::default(),
caps,
reading,
Bgra8Unorm,
);
resolve_output(&settings, caps, reading, frame, true).ui_brightness
};
// Auto: the report's 400 nits, 203 without one, or the player's 250.
let unreported = DisplayReading::default();
for (paper_white, reading) in [(0, reading), (0, unreported), (250, reading)] {
assert_eq!(share(paper_white, 0, reading), 1.0);
}
assert_eq!(share(0, 100, reading), 0.25);
assert_eq!(share(0, 480, reading), 1.2);
assert_eq!(share(0, 406, unreported), 2.0);
assert_eq!(share(250, 100, reading), 0.4);
assert_eq!(share(250, 480, reading), 1.92);
}
/// Two renderers' states, a window's and an offscreen one's, made and changed in turn:
/// neither's offer or report shows in the other's snapshot, and the offscreen one
/// stopping its watch (HDR Off) leaves the window's report alone.
@@ -651,7 +457,6 @@ pub(crate) mod tests {
window_reading.luminance = Some(Reported {
paper_white: 300,
peak: 800,
peak_guessed: false,
});
assert_eq!(window_output(window_reading).nits[..2], [300.0, 800.0]);
assert_eq!(
@@ -1,945 +0,0 @@
//! Applying the output a renderer resolves (`output::resolve_output`). Each observation of the
//! settings, the destination's capabilities and the display resolves one candidate. One that
//! changes only values (the luminance, the UI brightness, what the display reports) updates
//! them in place. Another encoding or frame is built and the swapchain configured for it before
//! the snapshot says so. One that fails is never published as active, and is not tried again
//! until the request, the capabilities or the surface change. Auto keeps HDR on through a
//! moment's loss of the display's report ([`auto_grace`]).
use super::{DisplayReading, DisplayWatch, Renderer, hdr, output};
use crate::graphics::HdrMode;
use output::{EffectiveOutput, OutputCapabilities};
use std::time::{Duration, Instant};
/// What an output's resources are made for: the encoding presented and the frame.
type Key = (Option<hdr::Output>, output::FrameConfiguration);
fn key(output: &EffectiveOutput) -> Key {
(output.encoding, output.frame)
}
/// An output whose resources or swapchain configuration failed, with the capabilities it
/// failed on.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Rejected {
key: Key,
caps: OutputCapabilities,
}
/// What applying an output changes in a renderer.
trait Present {
/// Builds what `output` presents and configures the swapchain for it, then swaps the new
/// resources in. On an error the resources stay as they were, and the swapchain may be
/// configured for neither output.
fn present(&mut self, output: &EffectiveOutput) -> std::result::Result<(), String>;
/// Presents SDR whatever happens: a swapchain that doesn't configure now is configured
/// again before the next frame (`surface_unconfigured`).
fn present_sdr(&mut self);
}
/// The snapshot to publish for `candidate` after `applied`, on `caps`. The same resources
/// take the candidate's values as they are. Other ones are presented first and published only
/// once that worked. On a failure the output applied stays where it presents again, else
/// SDR, and the failed one is kept in `rejected` until the candidate's resources or the
/// capabilities change.
fn commit(
target: &mut impl Present,
applied: &EffectiveOutput,
candidate: EffectiveOutput,
caps: OutputCapabilities,
rejected: &mut Option<Rejected>,
) -> EffectiveOutput {
let wanted = key(&candidate);
if rejected.is_some_and(|r| r.key != wanted || r.caps != caps) {
*rejected = None;
}
if wanted == key(applied) {
return candidate;
}
let kept = EffectiveOutput {
encoding: applied.encoding,
..candidate
};
if rejected.is_some() {
return kept;
}
let Err(e) = target.present(&candidate) else {
return candidate;
};
log::warn!(
"Horizon renderer: {} could not be presented ({e}); keeping {}",
name(candidate.encoding),
name(applied.encoding)
);
*rejected = Some(Rejected { key: wanted, caps });
if target.present(&kept).is_ok() {
return kept;
}
log::warn!(
"Horizon renderer: {} no longer presents; SDR",
name(kept.encoding)
);
target.present_sdr();
EffectiveOutput {
encoding: None,
..candidate
}
}
fn name(encoding: Option<hdr::Output>) -> &'static str {
encoding.map_or("SDR", hdr::Output::name)
}
/// The swapchain's configuration for `encoding`: `config` (what every pipeline draws in), or
/// HDR10 or scRGB.
pub(super) fn surface_config(
config: &wgpu::SurfaceConfiguration,
encoding: Option<hdr::Output>,
) -> wgpu::SurfaceConfiguration {
let mut config = config.clone();
if let Some(output) = encoding {
config.format = output.format();
config.view_formats = vec![output.format()];
config.color_space = output.color_space();
config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
}
config
}
/// How long Auto keeps HDR on once the display stops reporting it. A fixed window that
/// coalesces the report's churn as a display wakes or the window moves to another one; not a
/// measured sleep/wake time.
const AUTO_OFF_DELAY: Duration = Duration::from_millis(500);
/// The display's report `update_output` resolves Auto with at `now`. While Auto presents HDR
/// and the surface still offers that encoding, a report that turns false or unknown starts
/// the timer `since`, once, and HDR stays on at the values last applied until it has held
/// for [`AUTO_OFF_DELAY`]; a positive report before then clears it. Off and On, a changed
/// offer, and entering HDR take `raw` at once.
fn auto_grace(
now: Instant,
mode: HdrMode,
offered: Option<hdr::Output>,
raw: DisplayReading,
applied: &EffectiveOutput,
since: &mut Option<Instant>,
) -> DisplayReading {
let lost = mode == HdrMode::Auto
&& applied.encoding.is_some()
&& offered == applied.encoding
&& raw.hdr != Some(true);
if !lost {
if since.take().is_some() && raw.hdr == Some(true) {
log::info!("Horizon renderer: the display reports HDR again; HDR stays on");
}
} else if since.is_none() {
log::info!(
"Horizon renderer: the display stopped reporting HDR; Auto keeps it on for {} ms",
AUTO_OFF_DELAY.as_millis()
);
*since = Some(now);
} else if grace_over(*since, now) {
*since = None;
}
graced(*since, raw, applied)
}
fn grace_over(since: Option<Instant>, now: Instant) -> bool {
since.is_some_and(|since| now.saturating_duration_since(since) >= AUTO_OFF_DELAY)
}
/// What the display reports as the output takes it: during Auto's grace (`since`), HDR at the
/// luminance `applied` was resolved with; `raw` otherwise.
pub(super) fn graced(
since: Option<Instant>,
raw: DisplayReading,
applied: &EffectiveOutput,
) -> DisplayReading {
match since {
Some(_) => DisplayReading {
luminance: applied.reported,
hdr: Some(true),
},
None => raw,
}
}
/// Whether the output resolves again at `now`: a due poll (`read`, with the surface's
/// `offer`; on Wayland, an event) changed `reading` or `caps`, or Auto's grace `since` is
/// over. Its end reads the display once more first, however long until the next poll, unless
/// a read since the deadline did (the last read is a `DISPLAY_POLL` before the next).
fn poll_display(
watch: Option<&mut DisplayWatch>,
reading: &mut DisplayReading,
caps: &mut OutputCapabilities,
since: Option<Instant>,
now: Instant,
read: impl FnOnce() -> DisplayReading,
offer: impl FnOnce() -> Option<Option<hdr::Output>>,
) -> bool {
let over = grace_over(since, now);
let Some(watch) = watch else {
return over;
};
if let (Some(since), Some(next)) = (since.filter(|_| over), &mut watch.polled)
&& *next < since + AUTO_OFF_DELAY + super::DISPLAY_POLL
{
*next = now;
}
let Some(read) = watch.poll(*reading, now, read) else {
return over;
};
observe(reading, caps, read, offer()) || over
}
/// What `surface` offers now (`None` offscreen): Vulkan's offered formats can change with
/// desktop HDR or the window's output, and a display can offer the other encoding.
fn offer(surface: Option<&wgpu::Surface>, adapter: &wgpu::Adapter) -> Option<Option<hdr::Output>> {
Some(hdr::output(&surface?.get_capabilities(adapter)))
}
/// Takes what a due poll read into `reading` and `caps`: the display's report `now`, and what
/// the surface offers (`offer`, `None` without a surface). Whether either changed, the offer
/// alone included.
fn observe(
reading: &mut DisplayReading,
caps: &mut OutputCapabilities,
now: DisplayReading,
offer: Option<Option<hdr::Output>>,
) -> bool {
let before = (*reading, caps.hdr);
*reading = now;
if let Some(offer) = offer {
caps.hdr = offer;
}
before != (*reading, caps.hdr)
}
/// A renderer applying an output, with the marker its HDR output's start writes.
struct Applying<'a> {
renderer: &'a mut Renderer,
marker: &'a std::path::Path,
}
impl Present for Applying<'_> {
fn present(&mut self, output: &EffectiveOutput) -> std::result::Result<(), String> {
let r = &mut *self.renderer;
let current = r.hdr.as_ref().map(|hdr| hdr.output);
let switching = output.encoding.is_some_and(|e| current != Some(e));
// If the game dies before HDR output shows a frame, its pipelines' build included, the
// marker is still there at the next start, which then holds HDR output back on this
// driver.
if output.encoding.is_none() || switching {
r.mark_hdr_start(output.encoding.is_some(), self.marker);
}
let built = match output.encoding {
Some(encoding) if switching => Some(r.build_hdr(encoding, output)?),
_ => None,
};
if let Some(surface) = &r.surface {
r.frame_gen.stop();
// The submit thread may still present to the old swapchain.
r.queue.wait();
let hdr = built.as_ref().or(r.hdr.as_ref());
Renderer::configure(
&r.device,
surface,
&surface_config(&r.config, output.encoding),
hdr.filter(|_| output.encoding.is_some()),
&r.device_lost,
)?;
r.surface_unconfigured = false;
}
if output.encoding.is_none() {
r.hdr = None;
} else if built.is_some() {
r.hdr = built;
}
Ok(())
}
fn present_sdr(&mut self) {
let r = &mut *self.renderer;
r.hdr = None;
r.mark_hdr_start(false, self.marker);
if let Some(surface) = &r.surface {
r.frame_gen.stop();
r.queue.wait();
r.surface_unconfigured =
Renderer::configure(&r.device, surface, &r.config, None, &r.device_lost).is_err();
}
}
}
impl Renderer {
/// HDR output presenting `encoding` for `output`, made apart from the one presented: from
/// it where there is one, keeping the player's tuning (`Hdr::retargeted`). An error is
/// the device's, caught here rather than left for the next frame.
fn build_hdr(
&self,
encoding: hdr::Output,
output: &EffectiveOutput,
) -> std::result::Result<hdr::Hdr, String> {
let validation = self.device.push_error_scope(wgpu::ErrorFilter::Validation);
let memory = self.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let mut hdr = match &self.hdr {
Some(hdr) => hdr.retargeted(&self.device, &self.queue, encoding),
None => {
let mut hdr = hdr::Hdr::new(
&self.device,
&self.queue,
output.frame.format,
encoding,
output.frame.hud_layer,
);
hdr.look = self.hdr_lighting_setting;
hdr
}
};
let memory = pollster::block_on(memory.pop());
if let Some(e) = memory.or(pollster::block_on(validation.pop())) {
return Err(e.to_string());
}
// The metadata the swapchain's configuration sends.
[hdr.paper_white, hdr.peak, hdr.black] = output.nits;
hdr.ui = output.ui_brightness;
Ok(hdr)
}
/// HDR output (Horizon setting) as `settings` ask for it on this renderer's frame
/// (`output::resolve_output`): HDR10 or scRGB at paper white, the world's highlights
/// expanded up to the peak. Off where the surface can't present it. Offscreen
/// (`hdr::offscreen_output`) the frame is drawn the same and its HDR screenshot taken,
/// but nothing is presented, and Auto takes its defaults: no display reports.
pub fn update_output(
&mut self,
settings: &crate::graphics::Graphics,
marker: &std::path::Path,
) {
let now = Instant::now();
self.watch_hdr(settings.hdr);
// Auto's grace ends on a fresh read, also where a settings change comes before the
// frame's `display_changed`.
if grace_over(self.auto_off_since, now) {
self.read_display(now);
}
self.hold_hdr_back(settings);
let reading = auto_grace(
now,
settings.hdr,
self.output_caps.hdr,
self.reading,
&self.output,
&mut self.auto_off_since,
);
let candidate = output::resolve_output(
settings,
self.output_caps,
reading,
self.output.frame,
self.surface.is_some() || self.offscreen.is_some(),
);
let (applied, caps) = (self.output, self.output_caps);
let mut rejected = self.output_rejected.take();
let renderer = &mut Applying {
renderer: self,
marker,
};
let next = commit(renderer, &applied, candidate, caps, &mut rejected);
self.output_rejected = rejected;
let [paper_white, peak, black] = next.nits;
if next.encoding != applied.encoding {
match next.encoding {
Some(output) => log::info!(
"Horizon renderer: HDR on: {} ({:?}), {paper_white}/{peak} nits",
output.name(),
output.format()
),
None => log::info!("Horizon renderer: HDR off"),
}
self.measure_emission();
}
if let Some(hdr) = &mut self.hdr {
hdr.ui = next.ui_brightness;
if (hdr.paper_white, hdr.peak, hdr.black) != (paper_white, peak, black) {
(hdr.paper_white, hdr.peak, hdr.black) = (paper_white, peak, black);
self.send_hdr_metadata();
}
}
self.output = next;
}
/// Follows the selected mode even when Auto currently wants SDR. Off never asks
/// the system about the display; selecting On or Auto starts a fresh watch.
fn watch_hdr(&mut self, mode: crate::graphics::HdrMode) {
if mode == crate::graphics::HdrMode::Off {
self.display = None;
self.display_fresh = false;
self.reading.stop_watching();
} else if self.display.is_none() {
let (watch, now) =
DisplayWatch::new(&self.window, self.surface.as_deref(), &self.adapter);
self.display = Some(watch);
let offer = offer(self.surface.as_deref(), &self.adapter);
self.output_caps.hdr = offer.unwrap_or(self.output_caps.hdr);
self.reading = now;
self.display_fresh = true;
}
}
/// The swapchain's configuration: `config` (what every pipeline draws in), or
/// HDR10 or scRGB while HDR output is on.
pub(super) fn surface_config(&self) -> wgpu::SurfaceConfiguration {
surface_config(&self.config, self.hdr.as_ref().map(|hdr| hdr.output))
}
/// After a new window surface: what it offers, and an output that failed on the old one
/// may be tried again. The settings apply again.
pub(super) fn surface_recreated(&mut self) {
let offer = offer(self.surface.as_deref(), &self.adapter);
self.output_caps.hdr = offer.unwrap_or(self.output_caps.hdr);
self.display_fresh = true;
self.output_rejected = None;
}
/// Whether what the system reports for the display, or what the surface offers, changed
/// (another output, the player recalibrating the desktop, desktop HDR offering the other
/// encoding): the settings' Auto values and the output follow it then, so apply them
/// again. Each due poll reads the surface's capabilities as well, even when the display
/// reports the same. So does the end of Auto's grace (`auto_grace`), with or without a
/// poll or an event then.
pub fn display_changed(&mut self) -> bool {
super::flush_display_log();
if std::mem::take(&mut self.display_fresh) {
return true;
}
self.read_display(Instant::now())
}
/// The display read where a poll is due or Auto's grace is over (`poll_display`).
fn read_display(&mut self, now: Instant) -> bool {
let (surface, adapter) = (self.surface.as_deref(), &self.adapter);
poll_display(
self.display.as_mut(),
&mut self.reading,
&mut self.output_caps,
self.auto_off_since,
now,
|| DisplayReading::read(surface, adapter),
|| offer(surface, adapter),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graphics::{Graphics, HdrMode, Reported};
use hdr::Output::{Pq, ScRgb};
use output::FrameRequest;
use output::tests::{snapshot, window};
/// A swapchain that records what it was asked to present, and fails `fails`.
#[derive(Default)]
struct Swapchain {
fails: Vec<Option<hdr::Output>>,
/// Each encoding presented, with the luminance its metadata sends.
asked: Vec<(Option<hdr::Output>, [f32; 3])>,
sdr: usize,
}
impl Present for Swapchain {
fn present(&mut self, output: &EffectiveOutput) -> std::result::Result<(), String> {
self.asked.push((output.encoding, output.nits));
if self.fails.contains(&output.encoding) {
return Err("unsupported".into());
}
Ok(())
}
fn present_sdr(&mut self) {
self.sdr += 1;
}
}
const NO_PACK: FrameRequest = FrameRequest {
linear_pack: false,
hdr_float: true,
};
fn on() -> Graphics {
Graphics {
hdr: HdrMode::On,
..Graphics::default()
}
}
/// The candidate on a renderer whose frame is `applied`'s.
fn candidate(
settings: &Graphics,
caps: OutputCapabilities,
reading: DisplayReading,
applied: &EffectiveOutput,
) -> EffectiveOutput {
output::resolve_output(settings, caps, reading, applied.frame, true)
}
/// HDR stays on while the display offers scRGB instead of HDR10 and then HDR10 again:
/// the swapchain, its metadata and the snapshot follow each, with no Off between.
#[test]
fn the_output_follows_pq_to_scrgb_and_back_while_hdr_stays_on() {
let settings = on();
let (mut caps, reading) = window(Some(Pq), Some(true));
let mut applied = snapshot(&settings, NO_PACK, caps, reading);
assert_eq!(applied.encoding, Some(Pq));
let (mut swapchain, mut rejected) = (Swapchain::default(), None);
for encoding in [ScRgb, Pq] {
caps.hdr = Some(encoding);
let next = candidate(&settings, caps, reading, &applied);
applied = commit(&mut swapchain, &applied, next, caps, &mut rejected);
assert_eq!(
(applied.encoding, applied.offered),
(Some(encoding), Some(encoding))
);
assert_eq!(
applied.frame,
snapshot(&settings, NO_PACK, caps, reading).frame
);
}
let metadata = [400.0, 1000.0, 0.0];
assert_eq!(
swapchain.asked,
[(Some(ScRgb), metadata), (Some(Pq), metadata)]
);
assert_eq!((rejected, swapchain.sdr), (None, 0));
}
/// The display reports the same luminance and HDR, but the surface now offers scRGB: the
/// next due poll reads it, and the output follows with the same nits and report.
#[test]
fn a_change_of_offer_alone_is_seen_at_the_next_poll() {
let settings = on();
let (mut caps, mut reading) = window(Some(Pq), Some(true));
let applied = snapshot(&settings, NO_PACK, caps, reading);
let start = std::time::Instant::now();
let mut watch = DisplayWatch {
#[cfg(target_os = "linux")]
wayland: None,
polled: Some(start + super::super::DISPLAY_POLL),
};
let same = reading;
assert_eq!(watch.poll(reading, start, || unreachable!()), None);
let due = start + super::super::DISPLAY_POLL;
assert_eq!(watch.poll(reading, due, || same), Some(same));
assert_eq!(watch.polled, Some(due + super::super::DISPLAY_POLL));
assert!(observe(&mut reading, &mut caps, same, Some(Some(ScRgb))));
// Nothing changed at the next poll, and offscreen nothing is offered to read.
assert!(!observe(&mut reading, &mut caps, same, Some(Some(ScRgb))));
assert!(!observe(&mut reading, &mut caps, same, None));
let next = candidate(&settings, caps, reading, &applied);
let published = commit(&mut Swapchain::default(), &applied, next, caps, &mut None);
assert_eq!(published.encoding, Some(ScRgb));
assert_eq!(
(published.nits, published.reported, published.system_hdr),
(applied.nits, applied.reported, applied.system_hdr)
);
}
/// The display's luminance or the UI brightness change on the same output: the values
/// follow, and nothing is built or configured again.
#[test]
fn new_nits_on_the_same_output_build_nothing() {
let mut settings = on();
let (caps, mut reading) = window(Some(Pq), Some(true));
let applied = snapshot(&settings, NO_PACK, caps, reading);
reading.luminance = Some(Reported {
paper_white: 250,
peak: 600,
peak_guessed: false,
});
// 150 nits under the reported paper white of 250: 0.6 of it.
settings.hdr_ui_nits = 150;
let mut swapchain = Swapchain::default();
let next = candidate(&settings, caps, reading, &applied);
let published = commit(&mut swapchain, &applied, next, caps, &mut None);
assert_eq!(published, next);
assert_eq!(published.nits, [250.0, 600.0, 0.0]);
assert_eq!(published.ui_brightness, 0.6);
assert!(swapchain.asked.is_empty());
}
/// An encoding that fails is never the snapshot's: the output applied stays where it
/// presents again, else SDR. It is not tried again each frame, nor for new nits, only
/// once the request or the capabilities change.
#[test]
fn an_output_that_fails_is_never_active_nor_tried_every_frame() {
let settings = on();
let (mut caps, mut reading) = window(Some(Pq), Some(true));
let pq = snapshot(&settings, NO_PACK, caps, reading);
caps.hdr = Some(ScRgb);
let mut swapchain = Swapchain {
fails: vec![Some(ScRgb)],
..Default::default()
};
let mut rejected = None;
let next = candidate(&settings, caps, reading, &pq);
let published = commit(&mut swapchain, &pq, next, caps, &mut rejected);
assert_eq!(published.encoding, Some(Pq));
assert_eq!(swapchain.asked.len(), 2, "scRGB, then HDR10 again");
// Frames after, and a new luminance: no new attempt.
for paper_white in [400, 400, 300] {
reading.luminance = Some(Reported {
paper_white,
peak: 1000,
peak_guessed: false,
});
let next = candidate(&settings, caps, reading, &published);
let again = commit(&mut swapchain, &published, next, caps, &mut rejected);
assert_eq!(again.encoding, Some(Pq));
assert_eq!(again.nits[0], paper_white as f32);
}
assert_eq!(swapchain.asked.len(), 2);
// Another request (HDR Off, then On) tries it again.
let off = Graphics::default();
let sdr = commit(
&mut swapchain,
&published,
candidate(&off, caps, reading, &published),
caps,
&mut rejected,
);
assert_eq!((sdr.encoding, rejected), (None, None));
let next = candidate(&settings, caps, reading, &sdr);
assert_eq!(
commit(&mut swapchain, &sdr, next, caps, &mut rejected).encoding,
None
);
assert_eq!(
swapchain.asked.iter().map(|a| a.0).collect::<Vec<_>>(),
[Some(ScRgb), Some(Pq), None, Some(ScRgb), None]
);
// So does another capability (the hold lifted here stands for any).
caps.held = true;
let held = candidate(&settings, caps, reading, &sdr);
assert_eq!(held.encoding, None);
commit(&mut swapchain, &sdr, held, caps, &mut rejected);
caps.held = false;
let asked = swapchain.asked.len();
let next = candidate(&settings, caps, reading, &sdr);
commit(&mut swapchain, &sdr, next, caps, &mut rejected);
assert_eq!(swapchain.asked.len(), asked + 2);
// The output applied failing too: SDR, never the failed one.
let mut broken = Swapchain {
fails: vec![Some(ScRgb), Some(Pq)],
..Default::default()
};
let next = candidate(&settings, caps, reading, &pq);
let published = commit(&mut broken, &pq, next, caps, &mut None);
assert_eq!((published.encoding, broken.sdr), (None, 1));
assert_eq!(settings.hdr, HdrMode::On, "the mode stays");
// A new surface forgets the failure (`surface_recreated`): the same request on the
// same capabilities is tried again.
let mut swapchain = Swapchain {
fails: vec![Some(ScRgb)],
..Default::default()
};
let mut rejected = None;
let next = candidate(&settings, caps, reading, &pq);
for _ in 0..2 {
commit(&mut swapchain, &pq, next, caps, &mut rejected);
}
assert_eq!(swapchain.asked.len(), 2);
rejected = None;
commit(&mut swapchain, &pq, next, caps, &mut rejected);
assert_eq!(swapchain.asked.len(), 4);
}
/// A window in Auto presenting HDR10 at 400/1000 nits on the layered frame, followed on
/// fake time as the game follows a renderer each frame: `display_changed`, then
/// `update_output` where it says so.
struct Auto {
settings: Graphics,
start: Instant,
watch: DisplayWatch,
reading: DisplayReading,
caps: OutputCapabilities,
applied: EffectiveOutput,
since: Option<Instant>,
swapchain: Swapchain,
/// What the system reports when it is read, and when (ms) it was.
report: DisplayReading,
reads: Vec<u64>,
}
impl Auto {
/// `polled`: the system is read every `DISPLAY_POLL` from 0, else it sends events
/// (`event`), as on Wayland.
fn new(polled: bool) -> Self {
let settings = Graphics {
hdr: HdrMode::Auto,
..Graphics::default()
};
let (caps, reading) = window(Some(Pq), Some(true));
let applied = snapshot(&settings, NO_PACK, caps, reading);
assert!(applied.encoding == Some(Pq) && applied.frame.hud_layer);
let start = Instant::now();
Self {
settings,
start,
watch: DisplayWatch {
#[cfg(target_os = "linux")]
wayland: None,
polled: polled.then_some(start),
},
reading,
caps,
applied,
since: None,
swapchain: Swapchain::default(),
report: reading,
reads: Vec::new(),
}
}
/// A frame at `ms`.
fn frame(&mut self, ms: u64) {
if self.read_display(ms) {
self.update(ms);
}
}
/// `Renderer::read_display` at `ms`.
fn read_display(&mut self, ms: u64) -> bool {
let now = self.start + Duration::from_millis(ms);
let (report, offer, reads) = (self.report, Some(self.caps.hdr), &mut self.reads);
let read = || {
reads.push(ms);
report
};
let (reading, caps) = (&mut self.reading, &mut self.caps);
poll_display(
Some(&mut self.watch),
reading,
caps,
self.since,
now,
read,
|| offer,
)
}
/// Frames from `from` to `to` ms, every 10 ms.
fn frames(&mut self, from: u64, to: u64) {
for ms in (from..=to).step_by(10) {
self.frame(ms);
}
}
/// What the system reports from `ms` on: HDR `hdr`, with no luminance unless it is
/// HDR, at 300/800 nits then.
fn reports(&mut self, hdr: Option<bool>, offer: Option<hdr::Output>) {
self.report = DisplayReading {
luminance: (hdr == Some(true)).then_some(Reported {
paper_white: 300,
peak: 800,
peak_guessed: false,
}),
hdr,
};
self.caps.hdr = offer;
}
/// The system's event at `ms`, with what it now `reports`.
fn event(&mut self, ms: u64, hdr: Option<bool>, offer: Option<hdr::Output>) {
self.reports(hdr, offer);
self.reading = self.report;
self.update(ms);
}
/// `update_output` at `ms`, in its order.
fn update(&mut self, ms: u64) {
let now = self.start + Duration::from_millis(ms);
if grace_over(self.since, now) {
self.read_display(ms);
}
let applied = &self.applied;
let reading = auto_grace(
now,
self.settings.hdr,
self.caps.hdr,
self.reading,
applied,
&mut self.since,
);
let next = candidate(&self.settings, self.caps, reading, applied);
self.applied = commit(&mut self.swapchain, applied, next, self.caps, &mut None);
// While HDR stays on, the frame the game asks for (`Renderer::requested_frame`) is
// this one: no new renderer.
let reading = graced(self.since, self.reading, &self.applied);
let frame = output::resolve_frame(
&self.settings,
NO_PACK,
self.caps,
reading,
wgpu::TextureFormat::Bgra8UnormSrgb,
);
assert_eq!(
frame == self.applied.frame,
self.applied.hdr_on(),
"{ms} ms"
);
}
fn presented(&self) -> Vec<Option<hdr::Output>> {
self.swapchain.asked.iter().map(|a| a.0).collect()
}
}
/// The display's report flickers off and back, false or unknown: HDR stays on, nothing
/// is presented again, and the shader `Frame`, what the settings show as active and the
/// nits stay as they were applied, even while the report says nothing of them.
#[test]
fn a_report_that_flickers_off_and_back_swaps_nothing() {
for lost in [Some(false), None] {
let mut auto = Auto::new(false);
let before = auto.applied;
auto.event(0, Some(true), Some(Pq));
auto.frames(0, 90);
let fresh = auto.applied;
assert_eq!(fresh.nits, [300.0, 800.0, 0.0], "a positive report applies");
auto.event(100, lost, Some(Pq));
auto.frames(100, 390);
assert_eq!(auto.applied, fresh, "{lost:?}");
assert!(auto.applied.requests_hdr(HdrMode::Auto) && auto.applied.display().hdr);
auto.event(400, Some(true), Some(Pq));
auto.frames(400, 1500);
assert_eq!((auto.applied, auto.since), (fresh, None), "{lost:?}");
assert!(auto.presented().is_empty(), "{lost:?}");
assert_eq!(auto.applied.frame, before.frame);
}
}
/// A loss held through 600 ms turns HDR off once, at 500 ms, and on an event-driven
/// display with no event then. The same loss reported again doesn't restart the timer.
#[test]
fn a_loss_held_turns_hdr_off_once_at_the_deadline() {
for lost in [Some(false), None] {
let mut auto = Auto::new(false);
auto.event(0, lost, Some(Pq));
auto.frames(0, 190);
auto.event(200, lost, Some(Pq));
auto.frames(200, 490);
assert_eq!(auto.applied.encoding, Some(Pq), "{lost:?}");
auto.frame(500);
assert_eq!(auto.applied.encoding, None, "{lost:?}");
auto.frames(500, 3000);
assert_eq!(auto.presented(), [None], "{lost:?}");
assert_eq!(auto.since, None);
}
}
/// Where the system is polled, a false read by the poll that starts the timer is read
/// again at the deadline, not two seconds after, before it counts: the display came back
/// meanwhile, so HDR stays; where it didn't, HDR turns off then. The two-second cadence
/// stays otherwise.
#[test]
fn a_polled_false_is_read_again_at_the_deadline() {
for back in [true, false] {
let mut auto = Auto::new(true);
auto.frames(0, 1990);
auto.reports(Some(false), Some(Pq));
auto.frame(2000);
assert_eq!(
(auto.applied.encoding, auto.since.is_some()),
(Some(Pq), true)
);
if back {
auto.reports(Some(true), Some(Pq));
}
auto.frames(2010, 2490);
assert_eq!(auto.applied.encoding, Some(Pq), "never before the deadline");
auto.frames(2500, 4990);
assert_eq!(auto.reads, [0, 2000, 2500, 4500], "back {back}");
let after = if back { vec![] } else { vec![None] };
assert_eq!(auto.presented(), after, "back {back}");
}
}
/// A settings change at the deadline, before the frame's poll (a slider, F11), reads the
/// display before the grace ends, and the frame after reads it no more: the display came
/// back meanwhile, so HDR stays; where it didn't, HDR turns off then, once.
#[test]
fn a_settings_change_at_the_deadline_reads_the_display_first() {
for back in [true, false] {
let mut auto = Auto::new(true);
auto.frames(0, 1990);
auto.reports(Some(false), Some(Pq));
auto.frame(2000);
if back {
auto.reports(Some(true), Some(Pq));
}
auto.frames(2010, 2490);
auto.update(2500);
assert_eq!(auto.applied.hdr_on(), back, "back {back}");
auto.frames(2500, 4990);
assert_eq!(auto.reads, [0, 2000, 2500, 4500], "back {back}");
let after = if back { vec![] } else { vec![None] };
assert_eq!(auto.presented(), after, "back {back}");
}
}
/// A positive report clears the timer: a later loss has its own 500 ms.
#[test]
fn a_positive_report_clears_the_timer() {
let mut auto = Auto::new(false);
auto.event(100, Some(false), Some(Pq));
auto.event(300, Some(true), Some(Pq));
auto.event(700, Some(false), Some(Pq));
auto.frames(700, 1190);
assert_eq!(auto.applied.encoding, Some(Pq));
auto.frame(1200);
assert_eq!(auto.presented(), [None]);
}
/// Off acts at once, and On or Off clear the timer: Auto again starts it anew.
#[test]
fn off_and_on_take_no_grace() {
let mut auto = Auto::new(false);
auto.event(100, Some(false), Some(Pq));
auto.settings.hdr = HdrMode::Off;
auto.update(200);
assert_eq!((auto.presented(), auto.since), (vec![None], None));
let mut auto = Auto::new(false);
auto.event(100, Some(false), Some(Pq));
auto.settings.hdr = HdrMode::On;
auto.update(200);
assert_eq!((auto.applied.encoding, auto.since), (Some(Pq), None));
auto.settings.hdr = HdrMode::Auto;
auto.update(300);
auto.frames(300, 790);
assert_eq!(auto.applied.encoding, Some(Pq));
auto.frame(800);
assert_eq!(auto.presented(), [None]);
}
/// The surface no longer offering the encoding presented ends the grace at once: SDR
/// without a report, the other encoding with one.
#[test]
fn an_encoding_no_longer_offered_is_not_kept() {
for offer in [None, Some(ScRgb)] {
let mut auto = Auto::new(false);
auto.event(100, Some(false), Some(Pq));
auto.event(200, Some(false), offer);
assert_eq!((auto.presented(), auto.since), (vec![None], None));
let mut auto = Auto::new(false);
auto.event(100, None, offer);
assert_eq!(auto.presented(), [None], "{offer:?}");
}
let mut auto = Auto::new(false);
auto.event(100, Some(false), Some(Pq));
auto.event(200, Some(true), Some(ScRgb));
assert_eq!(auto.presented(), [Some(ScRgb)]);
}
/// Two windows' timers run apart.
#[test]
fn two_renderers_keep_their_own_timer() {
let (mut a, mut b) = (Auto::new(false), Auto::new(false));
b.start = a.start;
a.event(100, Some(false), Some(Pq));
b.event(300, Some(false), Some(Pq));
a.event(400, Some(true), Some(Pq));
for ms in (300..=900).step_by(10) {
a.frame(ms);
b.frame(ms);
assert_eq!(b.applied.hdr_on(), ms < 800, "{ms} ms");
}
assert_eq!((a.presented(), b.presented()), (vec![], vec![None]));
}
}
@@ -597,9 +597,9 @@ const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
/// quad lands whole in one part. The colour's alpha can't decide it, as with `separateAo` it
/// holds each vertex's own ambient occlusion.
pub fn terrain_parts(
terrain: &horizon_shaders::GbuffersInputs,
terrain: &horizon_render_pipeline::GbuffersInputs,
lava: &[u16],
) -> [horizon_shaders::GbuffersInputs; 2] {
) -> [horizon_render_pipeline::GbuffersInputs; 2] {
let opaque = crate::mesher::OPAQUE_LAYER;
let lava: String = lava
.iter()
@@ -619,7 +619,7 @@ pub fn terrain_parts(
impl PackAtlas {
/// The vertex inputs of a gbuffers program drawing terrain (`TerrainVertex`, terrain.wgsl's
/// `vs_terrain` or, for `flat` draws, `vs_terrain_flat`), in atlas coordinates.
pub fn terrain_inputs(&self, flat: bool) -> horizon_shaders::GbuffersInputs {
pub fn terrain_inputs(&self, flat: bool) -> horizon_render_pipeline::GbuffersInputs {
let mut declarations = String::from(
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
@@ -753,7 +753,7 @@ impl PackAtlas {
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
];
horizon_shaders::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations,
body,
attributes: attributes
@@ -2,7 +2,7 @@
//! translator's `DRAW_UNIFORMS`): one slot per render stage of a uniform buffer, picked with a
//! dynamic offset at each draw.
use horizon_shaders::{RENDER_STAGES, render_stage};
use horizon_render_pipeline::{RENDER_STAGES, render_stage};
/// Where a run of `Frame::entities` begins, and what it is (slice 7): the run lasts until the
/// next span. Recorded only while a shader pack draws entities, so Horizon's own frames carry
@@ -6,7 +6,7 @@
use std::ops::Range;
use horizon_shaders::render_stage;
use horizon_render_pipeline::render_stage;
use super::Vertex;
use super::pack_draw::EntitySpan;
@@ -31,7 +31,7 @@ pub fn buffers() -> [Option<wgpu::VertexBufferLayout<'static>>; 1] {
/// 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.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
pub fn inputs() -> horizon_render_pipeline::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\
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
@@ -112,7 +112,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
// bright.
("horizonTier", "float((hz_v_overlay >> 16u) & 255u)"),
];
horizon_shaders::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations,
body,
attributes: attributes
@@ -121,7 +121,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
.collect(),
after: String::new(),
// Cutout, as vanilla's entity types mostly are (OQ-23): the loader's test at 0.1.
alpha_test: Some(horizon_shaders::AlphaTest {
alpha_test: Some(horizon_render_pipeline::AlphaTest {
op: ">",
reference: 0.1,
}),
@@ -138,7 +138,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
/// inputs of [`inputs`], each vertex read from set 1 (`hz_entity_vertices`), as `Vertex` lays
/// it out. The draw's corners walk its quads two triangles each, as `quad_indices` does: corner
/// `c` of a draw from vertex `first` (a whole quad) is drawn as vertex `first / 4 * 6 + c`.
pub fn geometry_inputs() -> horizon_shaders::GeometryInputs {
pub fn geometry_inputs() -> horizon_render_pipeline::GeometryInputs {
let words = std::mem::size_of::<Vertex>() / 4;
let pull = format!(
" uint hz_pw = hz_vertex_index * {words}u;\n \
@@ -150,7 +150,7 @@ pub fn geometry_inputs() -> horizon_shaders::GeometryInputs {
hz_v_light = uvec4(hz_pl & 255u, (hz_pl >> 8u) & 255u, (hz_pl >> 16u) & 255u, hz_pl >> 24u);\n \
hz_v_normal = unpackSnorm4x8(hz_entity_vertices[hz_pw + 8u]);\n"
);
horizon_shaders::GeometryInputs {
horizon_render_pipeline::GeometryInputs {
inputs: inputs(),
pull,
// Quad `c / 6`, corner 0, 1, 2, 0, 2, 3 of it.
@@ -163,7 +163,7 @@ pub fn geometry_inputs() -> horizon_shaders::GeometryInputs {
/// What `gbuffers_clouds` reads from the clouds' mesh (entity vertices, `vs_cloud`): the
/// position moved by the clouds' offset, the colour of the face's shade from their palette,
/// full light, the face's normal. Set 1 holds the clouds' uniform (`clouds::Uniform`).
pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
pub fn cloud_inputs() -> horizon_render_pipeline::GbuffersInputs {
let declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
layout(location = 4) in uvec4 hz_v_light;\nlayout(location = 5) in vec4 hz_v_normal;\n\
@@ -183,7 +183,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
];
horizon_shaders::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations,
body,
attributes: attributes
@@ -17,7 +17,7 @@ const UNIFORM: u64 = 2 * 64;
const SLOT: u64 = 32;
/// What `gbuffers_hand` reads: the entity inputs, and the hand's projection from set 1.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
let mut inputs = super::pack_entities::inputs();
// No last frame's hand to measure from: the hand's `at_velocity` reads zero (and its set 1
// has no previous vertices).
@@ -270,7 +270,7 @@ fn translucent_quad(quad: &[Vertex]) -> bool {
/// outside block entities, and `currentRenderedItemId`.
fn draw_slot(id: i32, translucent: bool) -> [u8; SLOT as usize] {
let mut bytes = [0u8; SLOT as usize];
let stage = horizon_shaders::render_stage(if translucent {
let stage = horizon_render_pipeline::render_stage(if translucent {
"HAND_TRANSLUCENT"
} else {
"HAND_SOLID"
@@ -4,7 +4,8 @@
use std::collections::BTreeMap;
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
use horizon_render_pipeline::{Format, Kind};
use horizon_shaders::{BufferObject, CustomImage};
pub(super) struct PackImage {
pub(super) def: CustomImage,
@@ -8,7 +8,7 @@
/// width in pixels. The strip's corners 0 and 1 are at the start, 2 and 3 at the end (six
/// vertices per edge: pack pipelines draw triangle lists); the
/// width is applied in screen space after the pack's transform.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
let declarations =
"layout(location = 0) in vec3 hz_l_start;\nlayout(location = 1) in vec3 hz_l_end;\n\
layout(location = 2) in vec4 hz_l_argb;\nlayout(location = 3) in float hz_l_width;\n\
@@ -41,7 +41,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
if ((hz_l_c & 1) != 0) hz_l_o = -hz_l_o;\n \
gl_Position.xy += hz_l_o * gl_Position.w;\n }\n"
.to_owned();
horizon_shaders::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations,
body,
attributes: attributes
+106 -39
View File
@@ -18,7 +18,8 @@
use std::collections::HashMap;
use glam::{DMat4, DVec3};
use horizon_shaders::{BUFFERS, Format, Group, Kind, Post, SHADOW_COLORS};
use horizon_render_pipeline::{BUFFERS, Format, Kind, SHADOW_COLORS};
use horizon_shaders::{Group, Post};
use smallvec::SmallVec;
use super::pack_draw::DrawUniforms;
@@ -54,10 +55,6 @@ mod sky_disc_blend_tests;
#[cfg(test)]
mod undrawn_terrain_tests;
mod image_formats;
use image_formats::{image_format, storage_formats_of};
#[cfg(test)]
mod full_outputs_tests;
#[cfg(test)]
mod shadow_faces_tests;
@@ -128,7 +125,7 @@ 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.
/// The boats' water patches' depth (`horizon_render_pipeline::WATER_MASK`), for translucent terrain.
WaterMask,
}
@@ -886,13 +883,13 @@ fn fits(ty: &str, sample_type: wgpu::TextureSampleType) -> bool {
/// `hz_depth_linear` for a program: 1 for each of shadowtex0, shadowtex1, `shadow` and
/// `watershadow` whose depth filters (`linear`, bit n: shadowtex n). `shadow` is shadowtex1 in a
/// program that also declares `watershadow` (6.3).
fn depth_linear(interface: &horizon_shaders::Interface, linear: u8) -> [f32; 4] {
fn depth_linear(interface: &horizon_render_pipeline::Interface, linear: u8) -> [f32; 4] {
let watershadow = interface.samplers.iter().any(|s| s.name == "watershadow");
[0, 1, usize::from(watershadow), 0].map(|n| f32::from(linear >> n & 1))
}
fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool) -> Source {
let shadow_color = horizon_shaders::shadow_color(name);
let shadow_color = horizon_render_pipeline::shadow_color(name);
let shadow_depth = match name {
"shadowtex0" | "watershadow" | "shadowtex0HW" => Some(0),
"shadowtex1" | "shadowtex1HW" => Some(1),
@@ -911,7 +908,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
};
}
}
if shadow_space && horizon_shaders::buffer_index(name).is_some() {
if shadow_space && horizon_render_pipeline::buffer_index(name).is_some() {
// colortex buffers are not this pass's.
return Source::Dummy;
}
@@ -924,8 +921,8 @@ 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),
horizon_render_pipeline::WATER_MASK => Source::WaterMask,
n => horizon_render_pipeline::buffer_index(n).map_or(Source::Dummy, Source::Color),
}
}
@@ -1506,7 +1503,7 @@ fn owned_buffers(
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn bindings(
features: wgpu::Features,
interface: &horizon_shaders::Interface,
interface: &horizon_render_pipeline::Interface,
name: &str,
group: Group,
buffers: &[u32],
@@ -1577,12 +1574,12 @@ fn bindings(
// pack of ours (#9064) bound it over `gaux2` and `noisetex` but not over names of its
// own, where a raw texture was bound; this rule is a fit to those cases.
let unbound = t.png
&& t.stage != horizon_shaders::EVERY_STAGE
&& t.stage != horizon_render_pipeline::textures::EVERY_STAGE
&& source(&s.name, false, true, false) == Source::Dummy;
!unbound
&& (t.stage != pack_textures::stage(group)
|| released.0 != t.stage
|| horizon_shaders::buffer_index(&t.name)
|| horizon_render_pipeline::buffer_index(&t.name)
.is_none_or(|b| released.1 & 1 << b == 0))
});
let image = pack
@@ -1829,6 +1826,98 @@ fn bindings(
Some((entries, samplers, slots, reads, shadow_reads, images))
}
/// Makes each storage image of `module` in group 0 declare the format its layout entry binds:
/// a `layout(r11f_g11f_b10f)` image whose texture is the `RGBA16F` stand-in (`bindings`).
fn storage_formats_of(module: &mut naga::Module, entries: &[wgpu::BindGroupLayoutEntry]) {
let globals: Vec<_> = module.global_variables.iter().map(|(h, _)| h).collect();
for handle in globals {
let var = &module.global_variables[handle];
let Some(naga::ResourceBinding { group: 0, binding }) = var.binding else {
continue;
};
let naga::TypeInner::Image {
dim,
arrayed,
class: naga::ImageClass::Storage { format, access },
} = module.types[var.ty].inner
else {
continue;
};
let bound = entries.iter().find_map(|e| match e.ty {
wgpu::BindingType::StorageTexture { format, .. } if e.binding == binding => {
Some(format)
}
_ => None,
});
if format == naga::StorageFormat::Rg11b10Ufloat
&& bound == Some(wgpu::TextureFormat::Rgba16Float)
{
let ty = module.types.insert(
naga::Type {
name: module.types[var.ty].name.clone(),
inner: naga::TypeInner::Image {
dim,
arrayed,
class: naga::ImageClass::Storage {
format: naga::StorageFormat::Rgba16Float,
access,
},
},
},
naga::Span::UNDEFINED,
);
module.global_variables[handle].ty = ty;
}
}
}
/// The wgpu format of an image's format qualifier.
fn image_format(qualifier: &str) -> Option<wgpu::TextureFormat> {
use wgpu::TextureFormat as T;
Some(match qualifier {
"rgba32f" => T::Rgba32Float,
"rgba16f" => T::Rgba16Float,
"rg32f" => T::Rg32Float,
"rg16f" => T::Rg16Float,
"r11f_g11f_b10f" => T::Rg11b10Ufloat,
"r32f" => T::R32Float,
"r16f" => T::R16Float,
"rgba16" => T::Rgba16Unorm,
"rgb10_a2" => T::Rgb10a2Unorm,
"rgba8" => T::Rgba8Unorm,
"rg16" => T::Rg16Unorm,
"rg8" => T::Rg8Unorm,
"r16" => T::R16Unorm,
"r8" => T::R8Unorm,
"rgba16_snorm" => T::Rgba16Snorm,
"rgba8_snorm" => T::Rgba8Snorm,
"rg16_snorm" => T::Rg16Snorm,
"rg8_snorm" => T::Rg8Snorm,
"r16_snorm" => T::R16Snorm,
"r8_snorm" => T::R8Snorm,
"rgba32i" => T::Rgba32Sint,
"rgba16i" => T::Rgba16Sint,
"rgba8i" => T::Rgba8Sint,
"rg32i" => T::Rg32Sint,
"rg16i" => T::Rg16Sint,
"rg8i" => T::Rg8Sint,
"r32i" => T::R32Sint,
"r16i" => T::R16Sint,
"r8i" => T::R8Sint,
"rgba32ui" => T::Rgba32Uint,
"rgba16ui" => T::Rgba16Uint,
"rgb10_a2ui" => T::Rgb10a2Uint,
"rgba8ui" => T::Rgba8Uint,
"rg32ui" => T::Rg32Uint,
"rg16ui" => T::Rg16Uint,
"rg8ui" => T::Rg8Uint,
"r32ui" => T::R32Uint,
"r16ui" => T::R16Uint,
"r8ui" => T::R8Uint,
_ => return None,
})
}
/// The value slot of the pack's custom uniform `name` for a program declaring it as GLSL type
/// `ty`: after the frame's uniforms, in the order of `custom` (each with the type
/// `shaders.properties` gives it). A `float`, `int`, `vec2`, `vec3` or `vec4` custom uniform
@@ -2194,7 +2283,7 @@ fn build_compute(
device: &wgpu::Device,
name: String,
group: Group,
c: horizon_shaders::ComputeProgram,
c: horizon_render_pipeline::ComputeProgram,
formats: &[wgpu::TextureFormat; BUFFERS],
shadow: Option<&[wgpu::TextureFormat; SHADOW_COLORS]>,
notes: &mut Vec<String>,
@@ -2432,7 +2521,7 @@ impl PackPost {
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) {
for (name, ticks) in horizon_render_pipeline::HALF_LIVES.iter().zip(half_lives) {
if let Some(t) = ticks {
notes.push(format!("`{name}` is {t} ticks"));
}
@@ -2716,21 +2805,10 @@ impl PackPost {
.shadow
.zip(shadow_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)));
// A GPU that refuses translucent terrain's program with the boats' water patches gets it
// without them (`Post::shadow_water_plain`), rather than no translucent shadows at all.
let mut plain = post.shadow_water_plain;
let shadow_water = post
.shadow_water
.zip(shadow_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)));
let unpatched = shadow_water.is_none().then(|| plain.take()).flatten();
let unpatched_name = unpatched.as_ref().map(|p| p.name.clone());
let shadow_water = shadow_water.or_else(|| {
unpatched
.zip(shadow_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)))
});
let water_unpatched = unpatched_name.filter(|_| shadow_water.is_some());
let (hides_sky, hides_celestial) = (post.sky.is_some(), post.sky_textured.is_some());
let sky = (post.sky.is_some() || post.sky_textured.is_some()).then(|| {
let geometry = PackSky::new(device);
@@ -3287,17 +3365,6 @@ impl PackPost {
let white = [255u8; 4];
let black = [0u8; 4];
let flat_normal = [128u8, 128, 255, 255];
// Said as what it is, not as a program skipped.
if let Some(name) = water_unpatched {
let skipped = format!("{name}: skipped: ");
if let Some(n) = notes.iter_mut().rev().find(|n| n.starts_with(&skipped)) {
*n = format!(
"{name}: translucent terrain without the boats' water patches, as the GPU \
refused it with them ({})",
&n[skipped.len()..]
);
}
}
let jobs = (pack.jobs.as_ref())
.map(std::cell::RefCell::take)
.unwrap_or_default();
@@ -5998,7 +6065,7 @@ mod tests {
texture("composite", "tr", false),
texture("composite", "gaux2", true),
texture("composite", "noisetex", true),
texture(horizon_shaders::EVERY_STAGE, "cp", true),
texture(horizon_render_pipeline::textures::EVERY_STAGE, "cp", true),
];
let names = ["tp", "tr", "gaux2", "noisetex", "cp"];
let interface = horizon_shaders::Interface {
@@ -1,109 +0,0 @@
//! A shadow program writing as many vertex outputs as the GPU takes leaves no room for the boats'
//! water patches (`GbuffersInputs::water_patches`, one output more): translucent terrain gets
//! the program without them, not no shadow program. The programs here are original.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
fn pack(outputs: u32) -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
let declared: String = (0..outputs)
.map(|i| format!("varying vec4 v{i};\n"))
.collect();
let written: String = (0..outputs).map(|i| format!("v{i}=vec4({i}.0);")).collect();
let read: String = (0..outputs).map(|i| format!("+v{i}")).collect();
add(
"shadow.vsh",
&format!(
"#version 120\n{declared}void main() {{ gl_Position=vec4(gl_Vertex.xy,0.5,1.0); {written} }}\n"
),
);
add(
"shadow.fsh",
&format!("#version 120\n{declared}void main() {{ gl_FragData[0]=vec4(0.0){read}; }}\n"),
);
add(
"final.vsh",
"#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n",
);
add(
"final.fsh",
"#version 120\nconst int shadowMapResolution=16;\nuniform sampler2D shadowcolor0;\n\
varying vec2 uv;\nvoid main() { gl_FragColor=texture2D(shadowcolor0,uv); }\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-full-outputs",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let inputs = horizon_shaders::GameInputs {
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);\
hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
.into(),
attributes: vec![],
after: String::new(),
alpha_test: None,
}),
..Default::default()
};
horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs)
}
#[test]
fn a_shadow_program_with_every_output_drops_the_boats_patches_not_its_water() {
let Some(gpu) = Fixture::new() else { return };
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
let layouts = [Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRS,
})];
let geometry = || {
Some(Geometry {
material_maps: false,
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
ids: None,
cull: true,
})
};
let outputs = gpu.device.limits().max_inter_stage_shader_variables;
let post = gpu.build_with_terrain(pack(outputs), geometry());
assert!(post.has_shadow(), "{:?}", post.notes);
assert!(post.shadow_water.is_some(), "{:?}", post.notes);
assert!(
post.notes
.iter()
.any(|n| n.contains("without the boats' water patches")),
"{:?}",
post.notes
);
// With room for them, the patches stay and nothing is said.
let post = gpu.build_with_terrain(pack(outputs - 1), geometry());
assert!(post.shadow_water.is_some(), "{:?}", post.notes);
assert!(
!post.notes.iter().any(|n| n.contains("water patches")),
"{:?}",
post.notes
);
}
@@ -1,97 +0,0 @@
//! Image formats: a storage image's format qualifier as wgpu's, and the stand-in a module's
//! storage images declare.
/// Makes each storage image of `module` in group 0 declare the format its layout entry binds:
/// a `layout(r11f_g11f_b10f)` image whose texture is the `RGBA16F` stand-in (`bindings`).
pub(super) fn storage_formats_of(
module: &mut naga::Module,
entries: &[wgpu::BindGroupLayoutEntry],
) {
let globals: Vec<_> = module.global_variables.iter().map(|(h, _)| h).collect();
for handle in globals {
let var = &module.global_variables[handle];
let Some(naga::ResourceBinding { group: 0, binding }) = var.binding else {
continue;
};
let naga::TypeInner::Image {
dim,
arrayed,
class: naga::ImageClass::Storage { format, access },
} = module.types[var.ty].inner
else {
continue;
};
let bound = entries.iter().find_map(|e| match e.ty {
wgpu::BindingType::StorageTexture { format, .. } if e.binding == binding => {
Some(format)
}
_ => None,
});
if format == naga::StorageFormat::Rg11b10Ufloat
&& bound == Some(wgpu::TextureFormat::Rgba16Float)
{
let ty = module.types.insert(
naga::Type {
name: module.types[var.ty].name.clone(),
inner: naga::TypeInner::Image {
dim,
arrayed,
class: naga::ImageClass::Storage {
format: naga::StorageFormat::Rgba16Float,
access,
},
},
},
naga::Span::UNDEFINED,
);
module.global_variables[handle].ty = ty;
}
}
}
/// The wgpu format of an image's format qualifier.
pub(super) fn image_format(qualifier: &str) -> Option<wgpu::TextureFormat> {
use wgpu::TextureFormat as T;
Some(match qualifier {
"rgba32f" => T::Rgba32Float,
"rgba16f" => T::Rgba16Float,
"rg32f" => T::Rg32Float,
"rg16f" => T::Rg16Float,
"r11f_g11f_b10f" => T::Rg11b10Ufloat,
"r32f" => T::R32Float,
"r16f" => T::R16Float,
"rgba16" => T::Rgba16Unorm,
"rgb10_a2" => T::Rgb10a2Unorm,
"rgba8" => T::Rgba8Unorm,
"rg16" => T::Rg16Unorm,
"rg8" => T::Rg8Unorm,
"r16" => T::R16Unorm,
"r8" => T::R8Unorm,
"rgba16_snorm" => T::Rgba16Snorm,
"rgba8_snorm" => T::Rgba8Snorm,
"rg16_snorm" => T::Rg16Snorm,
"rg8_snorm" => T::Rg8Snorm,
"r16_snorm" => T::R16Snorm,
"r8_snorm" => T::R8Snorm,
"rgba32i" => T::Rgba32Sint,
"rgba16i" => T::Rgba16Sint,
"rgba8i" => T::Rgba8Sint,
"rg32i" => T::Rg32Sint,
"rg16i" => T::Rg16Sint,
"rg8i" => T::Rg8Sint,
"r32i" => T::R32Sint,
"r16i" => T::R16Sint,
"r8i" => T::R8Sint,
"rgba32ui" => T::Rgba32Uint,
"rgba16ui" => T::Rgba16Uint,
"rgb10_a2ui" => T::Rgb10a2Uint,
"rgba8ui" => T::Rgba8Uint,
"rg32ui" => T::Rg32Uint,
"rg16ui" => T::Rg16Uint,
"rg8ui" => T::Rg8Uint,
"r32ui" => T::R32Uint,
"r16ui" => T::R16Uint,
"r8ui" => T::R8Uint,
_ => return None,
})
}
@@ -10,7 +10,7 @@
//! a fit to one driver's values, not a rule Iris states. `RGBA4` and `RGBA2` read back the `RGBA8`
//! bytes in the same probe, as Horizon's 8-bit buffers already do.
use horizon_shaders::{Format, Kind};
use horizon_render_pipeline::{Format, Kind};
use naga::{BinaryOperator, Expression, Literal, MathFunction, Statement, VectorSize};
/// The bits each channel keeps in a buffer of format `f`, for the formats that keep fewer than
@@ -3,7 +3,7 @@
//! with two sides that `shadowcomp` programs flip as composite programs flip colortex (6.1.6).
//! Square, `shadowMapResolution` pixels.
use horizon_shaders::SHADOW_COLORS;
use horizon_render_pipeline::SHADOW_COLORS;
type Ops<T> = wgpu::Operations<T>;
type Color = wgpu::Color;
@@ -97,7 +97,11 @@ pub(super) fn stand_in(device: &wgpu::Device) -> wgpu::TextureView {
}
impl ShadowTargets {
pub fn reset_history(&mut self, device: &wgpu::Device, shadow: &horizon_shaders::Shadow) {
pub fn reset_history(
&mut self,
device: &wgpu::Device,
shadow: &horizon_render_pipeline::Shadow,
) {
let mut colors = 0;
let mut doubled = 0;
let formats = std::array::from_fn(|n| match &self.color[n] {
@@ -125,7 +129,7 @@ impl ShadowTargets {
/// resolution is kept within the device's limit.
pub fn new(
device: &wgpu::Device,
shadow: &horizon_shaders::Shadow,
shadow: &horizon_render_pipeline::Shadow,
formats: [wgpu::TextureFormat; SHADOW_COLORS],
colors: u16,
doubled: u16,
@@ -58,7 +58,7 @@ pub(super) const ATTRIBUTES: [wgpu::VertexAttribute; 2] =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Uint32];
/// What `gbuffers_skybasic` reads from these vertices.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
let declarations =
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uint hz_v_kind;\n\
layout(set = 1, binding = 0) uniform HzSky { mat4 hz_sky_stars; vec4 hz_sky_color; \
@@ -90,7 +90,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Normal = vec3(0.0, 0.0, 1.0);\n"
);
horizon_shaders::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations,
body,
attributes: vec![
@@ -111,8 +111,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
}
/// What `gbuffers_skytextured` reads from the sun and moon's vertices.
pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
horizon_shaders::GbuffersInputs {
pub fn celestial_inputs() -> horizon_render_pipeline::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations:
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
layout(location = 2) in vec4 hz_v_color;\n"
@@ -1,7 +1,11 @@
//! A shader pack's custom textures (`texture.<stage>.<name>`, `texture.noise`) on the GPU, and
//! which of them a program's sampler reads.
use horizon_shaders::{CustomTexture, Group, Kind, Texels, Texture, TextureDim, TextureSource};
use horizon_render_pipeline::Kind;
use horizon_render_pipeline::textures::{
CustomTexture, Dim as TextureDim, EVERY_STAGE, Texels, Texture, TextureSource,
};
use horizon_shaders::Group;
/// What a custom texture is once uploaded: an image of ours, or one the game already has.
pub(super) enum Image {
@@ -118,24 +122,24 @@ pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str)
} else {
wgpu::TextureViewDimension::D2
};
let buffer = horizon_shaders::buffer_index(name);
let buffer = horizon_render_pipeline::buffer_index(name);
let staged = textures.iter().position(|t| {
t.stage == stage(group)
&& t.dimension == dimension
&& (t.name == name
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
|| buffer.is_some() && horizon_render_pipeline::buffer_index(&t.name) == buffer)
});
staged.or_else(|| {
textures.iter().position(|t| {
t.stage == horizon_shaders::EVERY_STAGE && t.dimension == dimension && t.name == name
})
textures
.iter()
.position(|t| t.stage == EVERY_STAGE && t.dimension == dimension && t.name == name)
})
}
/// The `shaders.properties` key a texture came from, for notes.
fn key(stage: &str, name: &str) -> String {
match stage {
horizon_shaders::EVERY_STAGE => format!("customTexture.{name}"),
EVERY_STAGE => format!("customTexture.{name}"),
_ => format!("texture.{stage}.{name}"),
}
}
@@ -350,7 +354,7 @@ mod tests {
#[test]
fn custom_textures_reach_every_program() {
use wgpu::TextureViewDimension as D;
let every = horizon_shaders::EVERY_STAGE;
let every = EVERY_STAGE;
let textures = [
lightmap(every, "texClouds", D::D3),
lightmap("composite", "texClouds", D::D3),
@@ -28,7 +28,7 @@
//! [-w, w], and the depth buffers packs read hold GL window depth.
use glam::{DMat4, DVec3, Mat4, Vec3, Vec4};
use horizon_shaders::Shadow;
use horizon_render_pipeline::Shadow;
use super::Frame;
@@ -440,7 +440,7 @@ pub struct FrameUniforms {
center_depth: Option<f32>,
/// The pack's shadow settings (7.1).
pub shadow: Shadow,
/// The pack's smoothing half-lives in ticks (`horizon_shaders::HALF_LIVES`).
/// The pack's smoothing half-lives in ticks (`horizon_render_pipeline::HALF_LIVES`).
pub half_lives: [Option<f32>; 4],
}
@@ -2717,7 +2717,7 @@ pub(super) mod initial_history_tests {
/// `horizonWaterPatch<k>`'s `k`: patch `k / 2`, its corner and y range (even) or edges (odd).
fn water_patch_index(name: &str) -> Option<usize> {
let k: usize = name.strip_prefix("horizonWaterPatch")?.parse().ok()?;
(k < 2 * horizon_shaders::WATER_PATCHES).then_some(k)
(k < 2 * horizon_render_pipeline::WATER_PATCHES).then_some(k)
}
/// The boats' water patches among the frame's entity quads (`WATER_MASK_OVERLAY`), the first
@@ -2729,7 +2729,7 @@ pub(super) fn water_patches(entities: &[super::Vertex]) -> Vec<[[f32; 4]; 2]> {
let mut points: Vec<[f32; 3]> = Vec::new();
let flush = |points: &mut Vec<[f32; 3]>, out: &mut Vec<[[f32; 4]; 2]>| {
if let Some(b) = patch_box(points)
&& out.len() < horizon_shaders::WATER_PATCHES
&& out.len() < horizon_render_pipeline::WATER_PATCHES
{
out.push(b);
}
@@ -11,7 +11,7 @@ use super::pack_post::Weather;
/// What `gbuffers_weather` reads from a weather vertex: camera-relative position, the UV in
/// repeats of the sprite, colour (with vanilla's fade) and light. Weather has no normal; the
/// pack sees straight up.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
let declarations =
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
@@ -31,7 +31,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
];
horizon_shaders::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations,
body,
attributes: attributes
@@ -40,7 +40,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
.collect(),
after: String::new(),
// Vanilla's weather drops texels below 0.1, as Horizon's own weather shader does.
alpha_test: Some(horizon_shaders::AlphaTest {
alpha_test: Some(horizon_render_pipeline::AlphaTest {
op: ">",
reference: 0.1,
}),
@@ -1,79 +0,0 @@
//! Hardware ray queries, only on request (#13780, after the #1180 spike): `HORIZON_RAY_TRACING=1`
//! at startup asks the device for wgpu's `EXPERIMENTAL_RAY_QUERY` when the GPU has it. Nothing
//! draws with it yet. Without the variable, and in a build without `horizon-extras` (a Horizon
//! extra, docs/layers.md), the device is opened exactly as before.
/// The ray query feature.
const RAY_QUERY: wgpu::Features = wgpu::Features::EXPERIMENTAL_RAY_QUERY;
/// Whether the player asked for ray tracing (the device needs the feature from its creation).
fn requested() -> bool {
asked(std::env::var("HORIZON_RAY_TRACING").ok().as_deref())
}
/// Whether `HORIZON_RAY_TRACING` set to `var` asks for ray tracing: only in a `horizon-extras`
/// build.
fn asked(var: Option<&str>) -> bool {
cfg!(feature = "horizon-extras") && var == Some("1")
}
/// The features to request on `adapter`: ray queries, when asked for and offered.
pub(super) fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
let features = pick(requested(), adapter.features());
if requested() {
let info = adapter.get_info();
if features.is_empty() {
log::warn!(
"Horizon renderer: ray tracing asked for, but {} on {:?} has no hardware ray \
queries; it stays off",
info.name,
info.backend
);
} else {
log::info!(
"Horizon renderer: ray queries on ({} on {:?})",
info.name,
info.backend
);
}
}
features
}
fn pick(requested: bool, offered: wgpu::Features) -> wgpu::Features {
if requested {
offered & RAY_QUERY
} else {
wgpu::Features::empty()
}
}
/// The device's opt-in to `features`' experimental ones.
pub(super) fn experimental(features: wgpu::Features) -> wgpu::ExperimentalFeatures {
if features.contains(RAY_QUERY) {
horizon_hdr::experimental_features()
} else {
wgpu::ExperimentalFeatures::disabled()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_an_extras_build_asks_for_ray_tracing() {
assert_eq!(asked(Some("1")), cfg!(feature = "horizon-extras"));
assert!(!asked(None));
assert!(!asked(Some("0")));
}
#[test]
fn ray_queries_only_when_asked_and_offered() {
let all = wgpu::Features::all();
assert_eq!(pick(true, all), RAY_QUERY);
assert!(pick(false, all).is_empty());
assert!(pick(true, all - RAY_QUERY).is_empty());
assert!(!experimental(wgpu::Features::empty()).is_enabled());
}
}
@@ -5,9 +5,6 @@ use super::{
Renderer, check_layers, cull, describe_adapter, is_software, oit, startup_step, surface_format,
timing,
};
// Ray tracing on request, which the device needs from its creation (gfx.rs may not grow).
#[path = "ray_query.rs"]
mod ray_query;
use std::sync::Arc;
use winit::window::Window;
@@ -137,7 +134,6 @@ pub(super) async fn open_adapter(
let features = timing::GpuTimer::features(adapter)
| cull::Cull::features(adapter)
| oit::features(adapter)
| ray_query::features(adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MULTI_DRAW_INDIRECT_COUNT
@@ -170,7 +166,6 @@ pub(super) async fn open_adapter(
// with the larger blocks a few small buffers kept whole blocks the arena's pages had
// left, and over half the video memory reserved was unused (1,344 MiB for 500).
memory_hints: wgpu::MemoryHints::MemoryUsage,
experimental_features: ray_query::experimental(features),
..Default::default()
};
#[cfg(not(target_os = "macos"))]
@@ -158,7 +158,7 @@ impl Weather {
match native {
Some((pipeline, frame)) => {
pass.set_pipeline(pipeline);
pass.set_bind_group(horizon_shader_api::stages::GROUP, frame, &[]);
pass.set_bind_group(horizon_render_pipeline::stages::GROUP, frame, &[]);
}
None => pass.set_pipeline(&self.pipeline),
}
@@ -225,7 +225,7 @@ struct HorizonWeatherOut {
";
/// The shader of a native pack's weather stage `source`.
fn native_source(source: &str, api: horizon_shader_api::Api) -> String {
fn native_source(source: &str, api: horizon_render_pipeline::Api) -> String {
format!(
"{source}\n// Horizon's weather (entity.wgsl)\n{}{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_"),
@@ -245,7 +245,7 @@ pub(super) fn native_pipeline(
stage: &horizon_shader_api::check::CheckedStage,
constants: &[(String, f64)],
) -> Result<wgpu::RenderPipeline, String> {
use horizon_shader_api::stages::GROUP;
use horizon_render_pipeline::stages::GROUP;
let text = native_source(&stage.source, frame.api);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
@@ -322,7 +322,7 @@ mod tests {
#[test]
fn a_native_weather_stage_validates() {
use horizon_shader_api::{Api, stages::StageKind};
use horizon_render_pipeline::{Api, StageKind};
for api in [Api::V0_1, Api::V0_2] {
let source = StageKind::Weather.prelude(api)
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
@@ -50,98 +50,6 @@ struct TaaResources {
uniform: wgpu::Buffer,
}
/// One frame handed to an upscaler backend: the jittered world at `render_size` and what
/// it needs to rebuild it at `output_size`. The motion is the camera's alone (`reproject`);
/// objects that move on their own carry no motion vectors yet.
// Only TAA reads it so far; a vendor backend reads the rest (#12918).
#[allow(dead_code)]
pub(super) struct UpscaleFrame<'a> {
/// The world's colour, rendered with this frame's jitter.
pub color: &'a wgpu::TextureView,
/// The world's depth, at `render_size`.
pub depth: &'a wgpu::TextureView,
pub render_size: [u32; 2],
pub output_size: [u32; 2],
/// The jitter in clip space, as added to the projection.
pub jitter_clip: [f32; 2],
/// The same jitter in rendered pixels, y down.
pub jitter_pixels: [f32; 2],
/// This frame's unjittered clip space to the previous frame's.
pub reproject: [[f32; 4]; 4],
/// The history is unusable: a cut, a resize, a frame without the upscaler.
pub reset: bool,
/// Frames jittered so far.
pub index: u32,
}
/// Something that turns the jittered low-resolution world into the output picture.
/// TAA (`TaaResources`) is the only backend; `Target` is the per-size state a backend
/// keeps beside the world target (TAA's history textures).
pub(super) trait UpscalerBackend {
type Target;
/// The name its GPU timing is shown under.
fn name(&self) -> &'static str;
/// Write this frame's output and return the bind group that shows it (`draw_onto`).
fn resolve<'t>(
&self,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
frame: &UpscaleFrame<'_>,
target: &'t Self::Target,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> &'t wgpu::BindGroup;
}
impl UpscalerBackend for TaaResources {
type Target = History;
fn name(&self) -> &'static str {
"TAA"
}
fn resolve<'t>(
&self,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
frame: &UpscaleFrame<'_>,
taa: &'t History,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> &'t wgpu::BindGroup {
// The resolve bind group already samples `frame.color` and `frame.depth`.
let i = (frame.index % 2) as usize;
let uniform = TaaUniform {
reproject: frame.reproject,
jitter: [
frame.jitter_clip[0],
frame.jitter_clip[1],
if frame.reset { 1.0 } else { 0.0 },
0.0,
],
};
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&uniform));
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &taa.history[i],
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon TAA"),
timestamp_writes: timing.and_then(|t| t.writes(self.name())),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &taa.resolve[i], &[]);
pass.draw(0..3, 0..1);
drop(pass);
&taa.show[i]
}
}
pub struct Post {
layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
@@ -655,25 +563,33 @@ impl Post {
self.finish_with(encoder, screen, &target.group, timing.as_deref_mut());
return;
};
let size = self.size().unwrap_or([1, 1]);
let frame = UpscaleFrame {
color: &target.color,
depth: &target.depth,
render_size: size,
output_size: taa.size,
jitter_clip: [self.taa_this_frame.jitter[0], self.taa_this_frame.jitter[1]],
jitter_pixels: self.jitter_pixels(),
reproject: self.taa_this_frame.reproject,
reset: self.taa_this_frame.jitter[2] > 0.5,
index: self.taa_frame,
};
let shown = res.resolve(queue, encoder, &frame, taa, timing.as_deref_mut());
let i = (self.taa_frame % 2) as usize;
queue.write_buffer(&res.uniform, 0, bytemuck::bytes_of(&self.taa_this_frame));
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &taa.history[i],
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon TAA"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("TAA")),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&res.pipeline);
pass.set_bind_group(0, &taa.resolve[i], &[]);
pass.draw(0..3, 0..1);
drop(pass);
let pipeline = if self.upscale > 1.0 {
&self.sharpen
} else {
&self.copy
};
self.draw_onto(encoder, screen, shown, pipeline, timing);
self.draw_onto(encoder, screen, &taa.show[i], pipeline, timing);
}
/// Draw what `group` binds (`bind`) onto `screen`.
+37 -306
View File
@@ -5,23 +5,9 @@
use std::{fs, path::Path};
pub const FILE: &str = "horizon-graphics.txt";
/// Written beside it before a window's HDR output is switched on, naming the graphics driver it
/// starts on ([`hdr_marker_text`]), and removed once the first HDR frame is presented. Found at
/// the next start, it means HDR output took the game down there: HDR output is held back on
/// that driver ([`Graphics::hdr_failed_start`]), and the mode stays as the player chose it.
/// Written beside it before HDR output is switched on and removed once the first HDR frame is
/// presented. Found at the next start, it means HDR output took the game down, so HDR starts off.
pub const HDR_MARKER: &str = "horizon-hdr-pending";
/// The marker's text for a window whose HDR output starts on `driver`
/// (`gfx::output::window_driver`).
pub fn hdr_marker_text(driver: &str) -> String {
format!("driver:{driver}\n")
}
/// The driver a marker names; empty for one written before markers named it.
fn marker_driver(text: &str) -> &str {
text.lines()
.find_map(|line| line.strip_prefix("driver:"))
.unwrap_or_default()
.trim()
}
/// What the system reports for the display HDR output goes to, in nits in HDR output's
/// scale: where its SDR white sits (the desktop's white) and the peak. Each renderer keeps its
@@ -30,22 +16,6 @@ fn marker_driver(text: &str) -> &str {
pub struct Reported {
pub paper_white: u32,
pub peak: u32,
/// The peak is the display's own claim (DXGI's `MaxLuminance`, mostly its EDID figure),
/// which often runs high: Auto still uses it, but the HDR page asks to calibrate.
pub peak_guessed: bool,
}
/// Whether HDR is set up for the display, and whether the player kept the system's setup.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HdrSetup {
/// The system says nothing: the player calibrates.
Unknown,
/// The system says, but only guesses the peak: the player calibrates.
Guessed,
/// Peak Brightness and Paper White follow the system.
Automatic,
/// The system said, and the player changed them anyway.
Changed,
}
/// The top of Peak Brightness's scale: `peak_nits_at(PEAK_STEPS)` is PQ's 10 000 nits.
@@ -68,23 +38,6 @@ pub fn peak_step_of(nits: f32) -> f32 {
};
step.clamp(0.0, PEAK_STEPS as f32)
}
/// The steps of Paper White's and UI Brightness's scale: Auto (0), then 10 to 480 nits by 10.
pub const NITS_STEPS: u32 = 48;
/// Paper White's or UI Brightness's setting at slider position `v`, 0 to 1 ([`NITS_STEPS`]).
pub fn nits_at(v: f32) -> u32 {
(v * NITS_STEPS as f32).round() as u32 * 10
}
/// Where a Paper White or UI Brightness setting sits on its slider, 0 to 1.
pub fn nits_position(nits: u32) -> f32 {
nits as f32 / (NITS_STEPS * 10) as f32
}
/// A saved Paper White or UI Brightness: Auto (0), or 10 to 480 nits.
fn nits_setting(nits: u32) -> u32 {
match nits {
0 => 0,
n => n.clamp(10, 480),
}
}
/// Simple-level presets. `Custom` leaves every option as the player set it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
@@ -537,6 +490,8 @@ pub fn reduced_far_detail_suggested() -> bool {
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
}
/// The name the options screen waits on to bind `Graphics::hdr_key`.
pub const HDR_KEY: &str = "horizon.hdrSettings";
/// The Screenshot Quality choices, times the game's resolution.
pub const SCREENSHOT_QUALITIES: [u32; 4] = [1, 2, 4, 8];
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
@@ -846,17 +801,14 @@ pub struct Graphics {
/// HDR black level: the display's darkest black, in hundredths of a nit; 0, the default,
/// lifts nothing. Shadows are lifted onto it, and the HDR metadata says it.
pub hdr_black_level: u32,
/// HDR brightness of the HUD and menus, in nits; 0 is Auto: at paper white
/// ([`Graphics::ui_nits`]). The first-person hand stays at the world's.
pub hdr_ui_nits: u32,
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
/// first-person hand stays at the world's.
pub hdr_ui_brightness: u32,
/// HDR Lighting: Horizon's own HDR look, light sources, the sun and sunlight above paper
/// white. Off, HDR output shows the same picture as SDR, for shader packs or the vanilla look.
pub hdr_lighting: bool,
/// The graphics driver whose last start ended before HDR output showed a frame (the
/// marker found, [`HDR_MARKER`]): a window on it stays SDR whatever the mode, until the
/// mode is changed or the window starts on another driver ([`Graphics::hdr_held`]).
/// Empty for a marker that named no driver: the first one seen holds it.
pub hdr_failed_start: Option<String>,
/// A vanilla key name (`HDR_KEY`): opens HDR's settings from the game. Unbound by default.
pub hdr_key: String,
/// Battery mode: at most 60 frames per second in a world and 30 in menus
/// or behind other windows, and half the worker threads. Off by default;
/// it changes when frames are drawn, never what they show or the ticks.
@@ -929,9 +881,9 @@ impl Default for Graphics {
hdr_paper_white: 0,
hdr_peak: 0,
hdr_black_level: 0,
hdr_ui_nits: 0,
hdr_ui_brightness: 100,
hdr_lighting: true,
hdr_failed_start: None,
hdr_key: "key.keyboard.unknown".into(),
battery: false,
shadow_cache: false,
api: GraphicsApi::Auto,
@@ -957,54 +909,17 @@ impl Graphics {
Vec::new()
}
};
let marker = dir.join(HDR_MARKER);
if let Ok(text) = fs::read_to_string(&marker)
&& fs::remove_file(&marker).is_ok()
&& g.hdr != HdrMode::Off
{
g.hdr_failed_start = Some(marker_driver(&text).to_owned());
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr != HdrMode::Off {
g.hdr = HdrMode::Off;
g.save(dir);
log::warn!(
"the last start ended before HDR output showed a frame; HDR output stays off with that graphics driver until the HDR mode is changed (graphics option hdrFailedStart, in {FILE})"
"the last start ended before HDR output showed a frame; HDR is off now (graphics option hdr, in {FILE})"
);
}
g
}
/// Whether HDR output is held back on a window whose graphics driver is `driver`
/// (`gfx::output::window_driver`): the last start on it ended before HDR output
/// showed a frame ([`Graphics::hdr_failed_start`]).
pub fn hdr_held(&self, driver: &str) -> bool {
self.hdr_failed_start
.as_deref()
.is_some_and(|failed| failed.is_empty() || failed == driver)
}
/// The window's renderer runs on `driver`: a failed start that named no driver is this
/// one's now, and another driver's (a driver update, another GPU) is dropped, so HDR output
/// is tried again. True when it changed.
pub fn settle_failed_start(&mut self, driver: &str) -> bool {
match self.hdr_failed_start.as_deref() {
Some("") => self.hdr_failed_start = Some(driver.to_owned()),
Some(failed) if failed != driver => self.hdr_failed_start = None,
_ => return false,
}
true
}
/// `before`, as Undo brings it back, but never the hold on a driver HDR output failed to
/// start on: that stays as it is now, and goes when Undo changes the mode, as any change of
/// the mode drops it.
pub fn undone_to(&self, before: &Self) -> Self {
Self {
hdr_failed_start: self
.hdr_failed_start
.clone()
.filter(|_| self.hdr == before.hdr),
..before.clone()
}
}
pub fn parse(text: &str) -> Self {
let mut g = Self::default();
// UI Brightness: `hdrUiNits`, else an older file's share of Paper White.
let (mut ui_nits, mut ui_share) = (None, None);
for line in text.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
@@ -1094,9 +1009,12 @@ impl Graphics {
"frameGeneration" => g.frame_generation = FrameGeneration::parse(value),
"hdr" => g.hdr = HdrMode::parse(value, g.hdr),
"hdrLighting" => g.hdr_lighting = flag(g.hdr_lighting),
"hdrPaperWhite" => g.hdr_paper_white = nits_setting(number(g.hdr_paper_white)),
"hdrUiNits" => ui_nits = value.parse().ok().map(nits_setting),
"hdrUiBrightness" => ui_share = value.parse::<u32>().ok(),
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
0 => 0,
n => n.clamp(10, 480),
}
}
"hdrPeak" => {
g.hdr_peak = match number(g.hdr_peak) {
0 => 0,
@@ -1104,7 +1022,10 @@ impl Graphics {
}
}
"hdrBlackLevel" => g.hdr_black_level = number(g.hdr_black_level).min(100),
"hdrFailedStart" => g.hdr_failed_start = Some(value.to_owned()),
"hdrUiBrightness" => {
g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100)
}
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
"batteryMode" => g.battery = flag(g.battery),
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
"batchTranslucent" => g.translucent_batching = flag(g.translucent_batching),
@@ -1121,20 +1042,11 @@ impl Graphics {
_ => {}
}
}
// The share was 25 to 100% of Paper White. 100% is Auto, and so is any share of an
// Auto Paper White, whose nits the file doesn't hold; another share of a Paper White set
// by hand is its nits, to the nearest step.
g.hdr_ui_nits = ui_nits.unwrap_or(match (ui_share, g.hdr_paper_white) {
(Some(share), paper) if share < 100 && paper != 0 => {
((paper * share.max(25) + 500) / 1000).max(1) * 10
}
_ => 0,
});
g
}
pub fn serialize(&self) -> String {
let mut text = format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nopaqueIce:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiNits:{}\nhdrLighting:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nopaqueIce:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -1181,8 +1093,9 @@ impl Graphics {
self.hdr_paper_white,
self.hdr_peak,
self.hdr_black_level,
self.hdr_ui_nits,
self.hdr_ui_brightness,
bool_text(self.hdr_lighting),
self.hdr_key,
bool_text(self.battery),
bool_text(self.shadow_cache),
self.api.name(),
@@ -1191,12 +1104,17 @@ impl Graphics {
bool_text(self.hdr_smooth_gradients),
self.gpu_culling.key(),
);
if let Some(driver) = &self.hdr_failed_start {
text += &format!("hdrFailedStart:{driver}\n");
}
text += &self.blue_map.serialize();
text
}
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
pub fn set_key(&mut self, name: &str, value: String) -> bool {
match name {
HDR_KEY => self.hdr_key = value,
_ => return false,
}
true
}
pub fn save(&self, dir: &Path) {
let _ = fs::write(dir.join(FILE), self.serialize());
}
@@ -1208,13 +1126,6 @@ impl Graphics {
n => n,
}
}
/// The HUD and menus' brightness, in nits: the player's, or on Auto paper white's.
pub fn ui_nits(&self, reported: Option<Reported>) -> u32 {
match self.hdr_ui_nits {
0 => self.paper_white_nits(reported),
n => n,
}
}
/// The peak the world's highlights reach, in nits: the player's, or on Auto
/// what the system `reported`, else 1000.
pub fn peak_nits(&self, reported: Option<Reported>) -> u32 {
@@ -1223,15 +1134,6 @@ impl Graphics {
n => n,
}
}
/// How HDR is set up for the display the system `reported`.
pub fn hdr_setup(&self, reported: Option<Reported>) -> HdrSetup {
match reported {
None => HdrSetup::Unknown,
Some(_) if self.hdr_peak != 0 || self.hdr_paper_white != 0 => HdrSetup::Changed,
Some(r) if r.peak_guessed => HdrSetup::Guessed,
Some(_) => HdrSetup::Automatic,
}
}
/// The black level, in nits. Not the system's: files from before saved 0 for that
/// (Auto), and a reported black would lift every display's shadows by default.
pub fn black_level_nits(&self) -> f32 {
@@ -1304,83 +1206,6 @@ pub fn apply_preset(preset: Preset, s: &mut crate::settings::Settings) {
mod tests {
use super::*;
/// UI Brightness is saved in nits, 0 for Auto. An older file's share of Paper White reads
/// as Auto at 100% or with Paper White on Auto, else as that share of it to the nearest step.
#[test]
fn ui_brightness_round_trips_in_nits_and_reads_older_shares() {
for (file, nits) in [
("", 0),
("hdrUiNits:0\n", 0),
("hdrUiNits:160\n", 160),
("hdrUiNits:5\n", 10),
("hdrUiNits:9000\n", 480),
("hdrUiNits:x\n", 0),
// Older files: 100% is Auto,
("hdrPaperWhite:200\nhdrUiBrightness:100\n", 0),
// any share of Paper White's Auto too,
("hdrUiBrightness:60\n", 0),
("hdrPaperWhite:0\nhdrUiBrightness:25\n", 0),
// and another share of a Paper White set by hand is its nits.
("hdrPaperWhite:200\nhdrUiBrightness:60\n", 120),
("hdrUiBrightness:50\nhdrPaperWhite:203\n", 100),
("hdrPaperWhite:250\nhdrUiBrightness:75\n", 190),
("hdrPaperWhite:10\nhdrUiBrightness:25\n", 10),
("hdrPaperWhite:480\nhdrUiBrightness:10\n", 120),
// The new key wins over the old one.
(
"hdrPaperWhite:200\nhdrUiBrightness:60\nhdrUiNits:300\n",
300,
),
("hdrUiNits:0\nhdrPaperWhite:200\nhdrUiBrightness:60\n", 0),
] {
let g = Graphics::parse(file);
assert_eq!(g.hdr_ui_nits, nits, "{file}");
let saved = g.serialize();
assert!(!saved.contains("hdrUiBrightness"), "{file}");
assert!(saved.contains(&format!("\nhdrUiNits:{nits}\n")), "{file}");
assert_eq!(Graphics::parse(&saved), g, "{file}");
}
let mut g = Graphics::default();
assert_eq!(g.ui_nits(None), 203);
let reported = Some(Reported {
paper_white: 300,
peak: 1000,
peak_guessed: false,
});
assert_eq!(g.ui_nits(reported), 300);
g.hdr_paper_white = 120;
assert_eq!(g.ui_nits(reported), 120);
g.hdr_ui_nits = 400;
assert_eq!((g.ui_nits(None), g.ui_nits(reported)), (400, 400));
}
#[test]
fn hdr_setup_asks_to_calibrate_where_the_peak_is_guessed() {
let wayland = Reported {
paper_white: 203,
peak: 1015,
peak_guessed: false,
};
let windows = Reported {
paper_white: 400,
peak: 1000,
peak_guessed: true,
};
let mut g = Graphics::default();
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Automatic);
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Guessed);
// Auto still takes the guess, over the 1000-nit default.
assert_eq!(g.peak_nits(Some(windows)), 1000);
assert_eq!(g.paper_white_nits(Some(windows)), 400);
for (peak, paper_white) in [(800, 0), (0, 240)] {
(g.hdr_peak, g.hdr_paper_white) = (peak, paper_white);
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Changed);
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Changed);
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
}
}
#[test]
fn hdr_modes_keep_boolean_files_and_round_trip_auto() {
assert_eq!(Graphics::parse("").hdr, HdrMode::Off);
@@ -1418,85 +1243,6 @@ mod tests {
}
}
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"horizon-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
/// A start that finds the marker (HDR output took the last one down) keeps the mode the
/// player chose and saves the driver the marker names; HDR Off saves nothing.
#[test]
fn a_marker_keeps_the_mode_and_holds_the_driver_it_names() {
let driver = "Vulkan 10de:2684 NVIDIA GeForce RTX 4090; NVIDIA; 581.57";
for mode in HdrMode::ALL {
let dir = scratch(&format!("hdr-marker-{mode:?}"));
let saved = Graphics {
hdr: mode,
..Graphics::default()
};
saved.save(&dir);
fs::write(dir.join(HDR_MARKER), hdr_marker_text(driver)).unwrap();
let g = Graphics::load(&dir);
let failed = (mode != HdrMode::Off).then(|| driver.to_owned());
assert_eq!((g.hdr, &g.hdr_failed_start), (mode, &failed), "{mode:?}");
assert!(!dir.join(HDR_MARKER).exists(), "{mode:?}");
// Saved for good: the next start, without a marker, reads the same.
let again = Graphics::load(&dir);
assert_eq!((again.hdr, again.hdr_failed_start), (mode, failed));
let _ = fs::remove_dir_all(&dir);
}
}
/// Today's marker names no driver: it holds whichever driver the window starts on first,
/// and is that driver's from then on.
#[test]
fn a_marker_without_a_driver_holds_the_first_one_seen() {
let dir = scratch("hdr-marker-legacy");
Graphics {
hdr: HdrMode::Auto,
..Graphics::default()
}
.save(&dir);
fs::write(dir.join(HDR_MARKER), "HDR output was being switched on\n").unwrap();
let mut g = Graphics::load(&dir);
assert_eq!(
(g.hdr, g.hdr_failed_start.as_deref()),
(HdrMode::Auto, Some(""))
);
let (first, second) = (
"Vulkan 10de:2684 GPU; NVIDIA; 1",
"Dx12 1002:744c GPU; AMD; 2",
);
assert!(g.hdr_held(first) && g.hdr_held(second));
assert!(g.settle_failed_start(first));
assert!(g.hdr_held(first) && !g.hdr_held(second));
assert!(!g.settle_failed_start(first));
let _ = fs::remove_dir_all(&dir);
}
/// The window starting on another driver (an update, another GPU) drops the hold, so HDR
/// output is tried again; on the same driver it stays.
#[test]
fn another_driver_drops_the_hold() {
let held = "Vulkan 10de:2684 GPU; NVIDIA; 581.57";
let mut g = Graphics {
hdr: HdrMode::On,
hdr_failed_start: Some(held.into()),
..Graphics::default()
};
assert!(!g.settle_failed_start(held));
assert!(g.hdr_held(held));
assert!(g.settle_failed_start("Vulkan 10de:2684 GPU; NVIDIA; 582.02"));
assert_eq!((g.hdr, g.hdr_failed_start), (HdrMode::On, None));
assert!(!Graphics::default().hdr_held(held));
}
#[test]
fn round_trips() {
let g = Graphics {
@@ -1546,11 +1292,9 @@ mod tests {
hdr_paper_white: 240,
hdr_peak: 1400,
hdr_black_level: 12,
hdr_ui_nits: 160,
hdr_ui_brightness: 60,
hdr_lighting: false,
hdr_failed_start: Some(
"Vulkan 10de:2684 NVIDIA GeForce RTX 4090; NVIDIA; 581.57".into(),
),
hdr_key: "key.keyboard.h".into(),
battery: true,
shadow_cache: true,
api: GraphicsApi::Dx12,
@@ -1559,15 +1303,6 @@ mod tests {
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
// A legacy marker's hold, which names no driver, round-trips too; none writes no line.
for failed in [Some(String::new()), None] {
let graphics = Graphics {
hdr_failed_start: failed,
..Graphics::default()
};
assert_eq!(Graphics::parse(&graphics.serialize()), graphics);
}
assert!(!Graphics::default().serialize().contains("hdrFailedStart"));
for mode in EntityCullingMode::ALL {
let graphics = Graphics {
entity_culling: mode,
@@ -1689,10 +1424,6 @@ mod tests {
Graphics::parse("shaderPack:Pack.zip\n"),
Graphics::default()
);
// The HDR page's old shortcut key loads as nothing, and the next save drops it.
let old = Graphics::parse("hdr:true\nhdrKey:key.keyboard.h\nhdrPeak:1000\n");
assert_eq!((old.hdr, old.hdr_peak), (HdrMode::On, 1000));
assert!(!old.serialize().contains("hdrKey"));
assert_eq!(
Graphics::parse(
"workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\nleafCulling:maybe\n"
+84 -52
View File
@@ -8,14 +8,13 @@ use crate::{
gfx::EffectiveOutput,
graphics::{
EntityCullingMode, FarDetail, FrameGeneration, GpuCullingMode, GraphicsApi, HdrMode,
HdrSetup as Setup, LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode,
Preset, Upscaling,
LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode, Preset, Upscaling,
},
graphics_style::Cost,
lang,
options_ui::{Event, Page as OptionsPage},
settings::{Opt, Settings},
settings_shell::{self as shell, Group, Output, Row, Screen, Sheet, Value},
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
text::Text,
};
@@ -107,6 +106,8 @@ pub enum H {
FrameGeneration,
Hdr,
HdrBlackLevel,
/// Binds `Graphics::hdr_key`.
HdrKey,
/// Starts HDR calibration (`crate::hdr_calibration`).
HdrCalibrate,
HdrPeak,
@@ -613,7 +614,7 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
h(
H::HdrUiBrightness,
None,
"How bright the HUD and menus are. Auto: at paper white.",
"How bright the HUD and menus are, against paper white.",
),
h(
H::HdrLighting,
@@ -622,6 +623,14 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
),
],
),
(
"Shortcut",
vec![h(
H::HdrKey,
None,
"A key that opens this screen from the game. Not bound at first.",
)],
),
],
),
];
@@ -676,14 +685,29 @@ const LOD_STEPS: [u32; 24] = [
crate::lod::MAX_CHUNKS,
];
/// Whether the system set HDR up for the display, and whether the player kept it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Setup {
/// The system says nothing: the player calibrates.
Unknown,
/// Peak Brightness and Paper White follow the system.
Automatic,
/// The system said, and the player changed them anyway.
Changed,
}
/// `out`: the output of the renderer the settings are shown for, with what its display reports.
fn setup(s: &Settings, out: &EffectiveOutput) -> Setup {
s.horizon.hdr_setup(out.reported)
setup_of(&s.horizon, out.reported.is_some())
}
fn setup_of(g: &crate::graphics::Graphics, reported: bool) -> Setup {
if !reported {
Setup::Unknown
} else if g.hdr_peak == 0 && g.hdr_paper_white == 0 {
Setup::Automatic
} else {
Setup::Changed
}
}
/// What the HDR page says while HDR output is held back on this window's graphics driver
/// (`EffectiveOutput::held`): picking a mode tries again (`H::cycle`).
const HDR_HELD: &str = "HDR output stopped the game the last time it started with this graphics driver. Choose a mode to try again.";
/// The description beside `item` where it depends on [`setup`]; `text` otherwise.
fn description(
item: Item,
@@ -692,23 +716,16 @@ fn description(
text: &'static str,
) -> &'static str {
match (item, setup(s, out)) {
(Item::Horizon(H::Hdr), _) if out.held => HDR_HELD,
(Item::Horizon(H::HdrCalibrate), Setup::Unknown) => {
"Your display didn't say how bright it gets: calibrate to set it up."
}
(Item::Horizon(H::HdrCalibrate), Setup::Guessed) => {
"Your display's reported peak is often too high: calibrate to set it up."
}
(Item::Horizon(H::HdrCalibrate), Setup::Automatic) => {
"Already set up for your display. Only calibrate if HDR looks wrong."
}
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
"Puts Peak Brightness and Paper White back to your display's setup."
}
// A guessed peak's Auto is what calibration replaced; paper white is the player's own.
(Item::Horizon(h @ (H::HdrPeak | H::HdrPaperWhite)), Setup::Automatic | Setup::Changed)
if h == H::HdrPaperWhite || out.reported.is_some_and(|r| !r.peak_guessed) =>
{
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
"Set up for your display. Leave on Auto unless it looks wrong."
}
(Item::Horizon(H::GpuCulling), _) if s.horizon.gpu_culling == GpuCullingMode::Auto => {
@@ -797,14 +814,14 @@ impl H {
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
H::UpscalingSharpness => Some(g.upscaling_sharpness as f32 / 100.0),
// Auto, then 10..480 by 10.
H::HdrPaperWhite => Some(crate::graphics::nits_position(g.hdr_paper_white)),
H::HdrUiBrightness => Some(crate::graphics::nits_position(g.hdr_ui_nits)),
H::HdrPaperWhite => Some(g.hdr_paper_white as f32 / 480.0),
// Auto, then the peak's scale.
H::HdrPeak => Some(match g.hdr_peak {
0 => 0.0,
n => (1.0 + crate::graphics::peak_step_of(n as f32)) / PEAK_POSITIONS as f32,
}),
H::HdrBlackLevel => Some(g.hdr_black_level as f32 / 100.0),
H::HdrUiBrightness => Some((g.hdr_ui_brightness as f32 - 25.0) / 75.0),
_ => None,
}
}
@@ -816,26 +833,13 @@ impl H {
H::Workers => Some(max_workers()),
H::ResolutionScale => Some(30),
H::UpscalingSharpness => Some(20),
H::HdrPaperWhite | H::HdrUiBrightness => Some(crate::graphics::NITS_STEPS),
H::HdrPaperWhite => Some(48),
H::HdrPeak => Some(PEAK_POSITIONS),
H::HdrBlackLevel => Some(100),
H::HdrUiBrightness => Some(15),
_ => None,
}
}
/// "Paper White: Auto (203)" or "Paper White: 160": a setting in nits, 0 for Auto, which
/// shows the nits Auto gives.
fn nits_label(self, s: &Settings, out: &EffectiveOutput) -> Text {
let (g, r) = (&s.horizon, out.reported);
let (caption, set, auto) = match self {
H::HdrPaperWhite => ("Paper White", g.hdr_paper_white, g.paper_white_nits(r)),
H::HdrPeak => ("Peak Brightness", g.hdr_peak, g.peak_nits(r)),
_ => ("UI Brightness", g.hdr_ui_nits, g.ui_nits(r)),
};
match set {
0 => labelled(caption, lang::tr("Auto (%s)", &[&auto])),
n => labelled(caption, n),
}
}
fn label(self, s: &Settings, out: &EffectiveOutput) -> Text {
let g = &s.horizon;
match self {
@@ -932,13 +936,30 @@ impl H {
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
H::Hdr => labelled("HDR", g.hdr.label()),
H::HdrCalibrate => Text::literal(lang::get(match setup(s, out) {
Setup::Unknown | Setup::Guessed => "Calibrate...",
Setup::Unknown => "Calibrate...",
Setup::Automatic => "Calibrate Anyway...",
Setup::Changed => "Use Automatic Setup",
})),
H::HdrPaperWhite | H::HdrPeak | H::HdrUiBrightness => self.nits_label(s, out),
H::HdrPaperWhite => match g.hdr_paper_white {
0 => labelled(
"Paper White",
lang::tr("Auto (%s)", &[&g.paper_white_nits(out.reported)]),
),
n => labelled("Paper White", n.to_string()),
},
H::HdrPeak => match g.hdr_peak {
0 => labelled(
"Peak Brightness",
lang::tr("Auto (%s)", &[&g.peak_nits(out.reported)]),
),
n => labelled("Peak Brightness", n.to_string()),
},
H::HdrBlackLevel => labelled("Black Level", format!("{:.2}", g.black_level_nits())),
H::HdrUiBrightness => labelled_percent("UI Brightness", g.hdr_ui_brightness),
H::HdrLighting => labelled("HDR Lighting", on_off(g.hdr_lighting)),
H::HdrKey => {
Text::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
}
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
// A press across the row, as Calibrate...: the list it opens shows the pick.
@@ -979,8 +1000,7 @@ impl H {
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
H::UpscalingSharpness => g.upscaling_sharpness = (v * 20.0).round() as u32 * 5,
H::HdrPaperWhite => g.hdr_paper_white = crate::graphics::nits_at(v),
H::HdrUiBrightness => g.hdr_ui_nits = crate::graphics::nits_at(v),
H::HdrPaperWhite => g.hdr_paper_white = (v * 48.0).round() as u32 * 10,
H::HdrPeak => {
g.hdr_peak = match (v * PEAK_POSITIONS as f32).round() as u32 {
0 => 0,
@@ -988,6 +1008,7 @@ impl H {
}
}
H::HdrBlackLevel => g.hdr_black_level = (v * 100.0).round() as u32,
H::HdrUiBrightness => g.hdr_ui_brightness = (25.0 + (v * 15.0).round() * 5.0) as u32,
_ => {}
}
}
@@ -1065,9 +1086,7 @@ impl H {
H::FrameGeneration => {
g.frame_generation = step(&FrameGeneration::ALL, g.frame_generation, back)
}
// Any choice tries HDR output again on a driver a start failed on.
H::Hdr => {
g.hdr_failed_start = None;
g.hdr = step(&HdrMode::ALL, g.hdr, back);
if g.hdr == HdrMode::On && !out.hdr_offered() {
g.hdr = step(&HdrMode::ALL, g.hdr, back);
@@ -1136,11 +1155,7 @@ impl Item {
| H::HdrUiBrightness
| H::HdrLighting
| H::HdrSmoothGradients,
) if !out.requests_hdr(s.horizon.hdr) => Some(if out.held {
HDR_HELD
} else {
"Turn HDR on to use it."
}),
) if !out.requests_hdr(s.horizon.hdr) => Some("Turn HDR on to use it."),
Item::Horizon(H::TranslucentBatching)
if !crate::graphics::translucent_batching_offered() =>
{
@@ -1163,7 +1178,10 @@ impl Item {
fn steps(self, s: &Settings, out: &EffectiveOutput) -> bool {
self.switch(s, out).is_none()
&& self.slider(s).is_none()
&& !matches!(self, Item::Horizon(H::HdrCalibrate | H::ShaderPacks))
&& !matches!(
self,
Item::Horizon(H::HdrKey | H::HdrCalibrate | H::ShaderPacks)
)
}
/// On/off settings and their value.
pub fn switch(self, s: &Settings, out: &EffectiveOutput) -> Option<bool> {
@@ -1318,6 +1336,12 @@ fn shown(item: Item, s: &Settings) -> bool {
}
/// A control's label as its name and its value ("Render Distance" and "12 chunks").
fn parts(item: Item, s: &Settings, out: &EffectiveOutput) -> (String, Option<String>) {
// The shortcut's label is one translated "Shortcut: %s"; the row shows its key apart, as
// KeyBindsScreen does ("> key <" while it waits, `settings_shell::Value::Binding`).
if item == Item::Horizon(H::HdrKey) {
let key = crate::keybinds::display_name(&s.horizon.hdr_key).plain();
return (lang::get("Shortcut"), Some(key));
}
shell::name_and_value(&item.label(s, out).plain())
}
@@ -1326,9 +1350,13 @@ fn parts(item: Item, s: &Settings, out: &EffectiveOutput) -> (String, Option<Str
fn row(setting: Setting, s: &Settings, out: &EffectiveOutput) -> Row<Item> {
let item = setting.item;
let (name, value) = parts(item, s, out);
let value = match (item.switch(s, out), item.slider(s)) {
(Some(on), _) => Value::Switch(on),
(None, Some(position)) => Value::Slider {
let value = match (item, item.switch(s, out), item.slider(s)) {
(Item::Horizon(H::HdrKey), ..) => Value::Binding(Binding::key(
crate::graphics::HDR_KEY,
value.unwrap_or_default(),
)),
(_, Some(on), _) => Value::Switch(on),
(_, None, Some(position)) => Value::Slider {
position,
steps: item.slider_steps(),
text: value.unwrap_or_default(),
@@ -1494,7 +1522,7 @@ impl Screen for GraphicsScreen {
/// one `horizon-ctl options-set` changed meanwhile), nor fullscreen, which F11 and the
/// window manager change.
fn undo(&mut self, s: &mut Settings, b: &Settings) -> Vec<Output> {
s.horizon = s.horizon.undone_to(&b.horizon);
s.horizon = b.horizon.clone();
s.render_distance = b.render_distance;
s.simulation_distance = b.simulation_distance;
s.gamma = b.gamma;
@@ -1544,7 +1572,7 @@ mod tests {
use super::*;
use crate::{
graphics_style as style,
settings_shell::{Columns, Hosted, Shell, Target},
settings_shell::{Capture, Columns, Hosted, Shell, Target},
};
use winit::keyboard::KeyCode as K;
@@ -2010,6 +2038,10 @@ mod tests {
precheck.why.as_deref(),
Some("Only applies to asynchronous entity culling.")
);
let key = find(&s, Item::Horizon(H::HdrKey));
assert_eq!(key.name, "Shortcut");
let waits = Capture::Key(crate::graphics::HDR_KEY);
assert!(matches!(key.value, Value::Binding(ref b) if b.capture == waits));
let calibrate = find(&s, Item::Horizon(H::HdrCalibrate));
assert_eq!(calibrate.value, Value::Press);
}
@@ -2694,7 +2726,7 @@ mod tests {
H::HdrPaperWhite => s.horizon.hdr_paper_white,
H::HdrPeak => s.horizon.hdr_peak,
H::HdrBlackLevel => s.horizon.hdr_black_level,
H::HdrUiBrightness => s.horizon.hdr_ui_nits,
H::HdrUiBrightness => s.horizon.hdr_ui_brightness,
_ => unreachable!(),
};
let fresh = || {
@@ -2,7 +2,7 @@
//! reports.
use super::tests::{CHANGED, CLICK, DRAGGED, FB, NONE, SIZE, Ui, offering, press, row, ui};
use super::*;
use crate::settings_shell::{Columns, Hosted};
use crate::settings_shell::{Capture, Columns, Hosted};
use winit::keyboard::KeyCode as K;
#[test]
@@ -64,12 +64,17 @@ fn hdr_auto_can_be_selected_before_querying_and_brightness_waits_for_output() {
let peak = row(&mut ui, &s, Item::Horizon(H::HdrPeak));
assert_eq!(press(&mut ui, &mut s, (tx + 2.0, peak[1] + 4.0)), [DRAGGED]);
ui.release();
// Calibrate starts the calibration.
// Calibrate starts the calibration; the shortcut row waits for a key.
let calibrate = row(&mut ui, &s, Item::Horizon(H::HdrCalibrate));
assert_eq!(
press(&mut ui, &mut s, at(calibrate)),
[CLICK, Output::Push(OptionsPage::HdrCalibration)]
);
let key = row(&mut ui, &s, Item::Horizon(H::HdrKey));
assert_eq!(
press(&mut ui, &mut s, at(key)),
[CLICK, Output::Bind(Capture::Key(crate::graphics::HDR_KEY))]
);
assert!(ui.changed());
}
@@ -120,188 +125,12 @@ fn auto_levels_and_setup_read_their_own_renderers_report() {
assert_eq!(label(H::HdrCalibrate, &silent), "Calibrate...");
}
/// The setup rows' label and description for each kind of report: none, a compositor's
/// (Wayland), and Windows', whose peak is the display's own guess.
#[test]
fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
let trusted = offering(true, Some(true));
let mut guessed = trusted;
guessed.reported = guessed.reported.map(|r| crate::graphics::Reported {
peak_guessed: true,
..r
});
let rows = |s: &Settings, out: &EffectiveOutput| {
[H::HdrCalibrate, H::HdrPeak, H::HdrPaperWhite].map(|h| {
let item = Item::Horizon(h);
(item.label(s, out).plain(), description(item, s, out, "-"))
})
};
let calibrate = "Calibrate...";
let set_up = "Set up for your display. Leave on Auto unless it looks wrong.";
let mut s = Settings::default();
s.horizon.hdr = HdrMode::On;
let none = (
calibrate.into(),
"Your display didn't say how bright it gets: calibrate to set it up.",
);
assert_eq!(
rows(&s, &NONE),
[
none.clone(),
("Peak Brightness: Auto (1000)".into(), "-"),
("Paper White: Auto (203)".into(), "-"),
]
);
assert_eq!(
rows(&s, &guessed),
[
(
calibrate.into(),
"Your display's reported peak is often too high: calibrate to set it up."
),
("Peak Brightness: Auto (1000)".into(), "-"),
("Paper White: Auto (400)".into(), "-"),
]
);
assert_eq!(
rows(&s, &trusted),
[
(
"Calibrate Anyway...".into(),
"Already set up for your display. Only calibrate if HDR looks wrong."
),
("Peak Brightness: Auto (1000)".into(), set_up),
("Paper White: Auto (400)".into(), set_up),
]
);
// Calibrated or set by hand, either report offers its automatic setup back.
s.horizon.hdr_peak = 800;
// Peak's Auto stays a guess on Windows: only Paper White is said to be set up there.
let changed = |peak| {
[
(
"Use Automatic Setup".into(),
"Puts Peak Brightness and Paper White back to your display's setup.",
),
("Peak Brightness: 800".into(), peak),
("Paper White: Auto (400)".into(), set_up),
]
};
assert_eq!(rows(&s, &guessed), changed("-"));
assert_eq!(rows(&s, &trusted), changed(set_up));
assert_eq!(rows(&s, &NONE)[0], none);
let mut ui = ui();
ui.screen.output = guessed;
let at = |[x, y, ..]: [f32; 4]| (x + 8.0, y + 4.0);
let calibrate = row(&mut ui, &s, Item::Horizon(H::HdrCalibrate));
assert_eq!(press(&mut ui, &mut s, at(calibrate)), [CLICK, CHANGED]);
assert_eq!((s.horizon.hdr_peak, s.horizon.hdr_paper_white), (0, 0));
assert_eq!(
press(&mut ui, &mut s, at(calibrate)),
[CLICK, Output::Push(OptionsPage::HdrCalibration)]
);
}
/// UI Brightness reads as Paper White does, in nits apart from it: Auto shows the paper white
/// in use, and a value set by hand its own nits, whatever Paper White is. Its slider has Paper
/// White's steps: Auto, then 10 to 480 nits by 10.
#[test]
fn ui_brightness_reads_in_nits_on_paper_whites_steps() {
let mut s = Settings::default();
s.horizon.hdr = HdrMode::On;
let reporting = offering(true, Some(true));
let label = |s: &Settings, out| Item::Horizon(H::HdrUiBrightness).label(s, out).plain();
assert_eq!(label(&s, &reporting), "UI Brightness: Auto (400)");
assert_eq!(label(&s, &NONE), "UI Brightness: Auto (203)");
s.horizon.hdr_paper_white = 250;
assert_eq!(label(&s, &reporting), "UI Brightness: Auto (250)");
s.horizon.hdr_ui_nits = 160;
for paper_white in [0, 80, 250] {
s.horizon.hdr_paper_white = paper_white;
assert_eq!(label(&s, &reporting), "UI Brightness: 160");
}
let item = Item::Horizon(H::HdrUiBrightness);
assert_eq!(
H::HdrUiBrightness.slider_steps(),
H::HdrPaperWhite.slider_steps()
);
H::HdrUiBrightness.set_slider(&mut s, 0.0);
let mut reached = vec![s.horizon.hdr_ui_nits];
for _ in 0..H::HdrUiBrightness.slider_steps().unwrap() {
item.step_slider(&mut s, true, 200.0, &reporting);
reached.push(s.horizon.hdr_ui_nits);
}
let paper: Vec<u32> = std::iter::once(0).chain((10..=480).step_by(10)).collect();
assert_eq!(reached, paper);
// Each value sits where Paper White's same value does.
for nits in paper {
s.horizon.hdr_ui_nits = nits;
s.horizon.hdr_paper_white = nits;
assert_eq!(item.slider(&s), Item::Horizon(H::HdrPaperWhite).slider(&s));
}
}
/// While HDR output is held back on this window's driver, the HDR row says so and keeps
/// the mode; any change of the mode drops the hold, and HDR is asked for again.
#[test]
fn a_held_hdr_start_is_said_on_the_page_and_a_mode_change_tries_again() {
use crate::gfx::{HdrOutput, output::tests};
let mut s = Settings::default();
s.horizon.hdr = HdrMode::Auto;
s.horizon.hdr_failed_start = Some("Vulkan 10de:2684 GPU; driver; 1.0".into());
let (mut caps, reading) = tests::window(Some(HdrOutput::Pq), Some(true));
caps.held = true;
let out = tests::snapshot(&s.horizon, Default::default(), caps, reading);
let hdr = Item::Horizon(H::Hdr);
let setting = h(
H::Hdr,
Cost::None,
"Lava, fire and sunlight shine brighter than white.",
);
let held = super::row(setting, &s, &out);
assert_eq!(held.chosen.as_deref(), Some(HDR_HELD));
assert_eq!(held.why, None);
assert_eq!(hdr.label(&s, &out).plain(), "HDR: Auto");
assert_eq!(Item::Horizon(H::HdrPeak).blocked(&s, &out), Some(HDR_HELD));
// Not held, the row says what HDR does.
caps.held = false;
let free = tests::snapshot(&s.horizon, Default::default(), caps, reading);
assert_eq!(super::row(setting, &s, &free).chosen, None);
// Any step of the mode, either way, tries again.
for back in [false, true] {
let mut s = s.clone();
hdr.cycle(&mut s, back, FB, &out);
assert_ne!(s.horizon.hdr, HdrMode::Auto);
assert_eq!(s.horizon.hdr_failed_start, None);
}
}
/// Undo never brings the hold back: held Auto, changed to On (HDR tried again), then
/// undone, is Auto without the hold. With the mode left alone, it keeps the hold as it is.
#[test]
fn undo_never_restores_a_held_hdr_start() {
let mut screen = GraphicsScreen {
framebuffer: FB,
output: NONE,
};
let mut opened = Settings::default();
opened.horizon.hdr = HdrMode::Auto;
opened.horizon.hdr_failed_start = Some("Vulkan 10de:2684 GPU; driver; 1.0; os".into());
let mut s = opened.clone();
let out = offering(true, Some(true));
Item::Horizon(H::Hdr).cycle(&mut s, true, FB, &out);
assert_eq!(
(s.horizon.hdr, &s.horizon.hdr_failed_start),
(HdrMode::On, &None)
);
screen.undo(&mut s, &opened);
assert_eq!(
(s.horizon.hdr, &s.horizon.hdr_failed_start),
(HdrMode::Auto, &None)
);
// Another row changed and undone: the hold stays.
let mut s = opened.clone();
s.horizon.fxaa = !s.horizon.fxaa;
screen.undo(&mut s, &opened);
assert_eq!(s.horizon, opened.horizon);
fn hdr_setup_follows_what_the_system_reported() {
let mut g = crate::graphics::Graphics::default();
assert_eq!(setup_of(&g, false), Setup::Unknown);
assert_eq!(setup_of(&g, true), Setup::Automatic);
g.hdr_paper_white = 80;
assert_eq!(setup_of(&g, true), Setup::Changed);
assert_eq!(setup_of(&g, false), Setup::Unknown);
}
+4 -5
View File
@@ -206,9 +206,8 @@ fn display(light: vec3<f32>) -> vec4<f32> {
let shows = params.world > 0.5 && params.peak > 1.0
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
var light = lit(f.rgb, w.a, shows, true).light;
// The HUD and menus at their own brightness, by how much they cover, no brighter than the
// peak (as `fs_hdr_layered`): UI Brightness can pass paper white.
light = min(light * mix(1.0, params.ui, ui), vec3(params.peak));
// The HUD and menus at their own brightness, by how much they cover.
light *= mix(1.0, params.ui, ui);
return display(light);
}
@@ -276,8 +275,8 @@ fn backdrop(w: vec3<f32>) -> vec3<f32> {
// The HUD over the world in gamma, the space the HUD blends in: its own part, what its backdrop
// B doesn't explain (H = P - (1 - c) B, exact for the layering blends), at UI Brightness, over
// (1 - c) of the world, light above white included. Where the world is at or below white, with
// UI Brightness at paper white, that is the HUD's picture as drawn.
// (1 - c) of the world, light above white included. Where the world is at or below white, at
// 100%, that is the HUD's picture as drawn.
fn layered(at: vec2<i32>) -> vec3<f32> {
let w = max(textureLoad(frame, at, 0).rgb, vec3(0.0));
let p = textureLoad(hud, at, 0).rgb;
@@ -17,7 +17,6 @@
//! Nothing changes until Save, which writes the three into Horizon's settings; Cancel
//! or Escape leaves them as they were.
use crate::{
font::Font,
hud::{HudAtlas, HudBatch, rgba},
lang,
settings::Settings,
@@ -34,10 +33,6 @@ pub enum Step {
}
const STEPS: [Step; 3] = [Step::Black, Step::Peak, Step::PaperWhite];
/// A step's test picture for HDR output: the step, its nits and where the controls start (a
/// share of the height).
pub type Picture = (Step, f32, f32);
/// What the options screen does after an input here.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Outcome {
@@ -48,30 +43,6 @@ pub enum Outcome {
Cancel,
}
/// The window as calibration found it: calibration turns it fullscreen for its test pictures,
/// and gives it back as it was.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WindowState {
Fullscreen,
Maximized,
Windowed,
}
impl WindowState {
pub fn of(fullscreen: bool, maximized: bool) -> Self {
match (fullscreen, maximized) {
(true, _) => Self::Fullscreen,
(false, true) => Self::Maximized,
(false, false) => Self::Windowed,
}
}
/// On leaving calibration: whether the window leaves fullscreen, and whether it is
/// maximized again after (leaving fullscreen gives back its restored size on Windows).
pub fn leaving(self) -> (bool, bool) {
(self != Self::Fullscreen, self == Self::Maximized)
}
}
pub struct Calibration {
step: usize,
/// Nits, in the order of `STEPS`.
@@ -82,11 +53,8 @@ pub struct Calibration {
dragging: bool,
}
/// Height of the band of controls at the bottom with a hint of one line, in GUI pixels.
/// Height of the band of controls at the bottom, in GUI pixels.
const BAND: f32 = 84.0;
/// Each further line of the hint, which wraps within the window less this at each side.
const HINT_LINE: f32 = 10.0;
const HINT_MARGIN: f32 = 10.0;
impl Step {
/// The slider's steps past its first: the settings' own.
@@ -147,24 +115,18 @@ impl Step {
struct Layout {
top: f32,
hint: Vec<Text>,
slider: (f32, f32, f32),
cancel: (f32, f32, f32),
back: (f32, f32, f32),
next: (f32, f32, f32),
}
/// The band grows up by the lines `step`'s hint takes past its first.
fn layout(font: &Font, step: Step, (w, h): (f32, f32)) -> Layout {
let hint = Text::literal(lang::get(step.hint_key()));
let hint = font.split(&hint, w - 2.0 * HINT_MARGIN);
let more = hint.len().saturating_sub(1) as f32 * HINT_LINE;
let top = (h - BAND - more).floor();
fn layout(w: f32, h: f32) -> Layout {
let top = (h - BAND).floor();
let cx = (w / 2.0).floor();
let by = top + more + 58.0;
let by = top + 58.0;
Layout {
top,
hint,
slider: (cx - 100.0, top + more + 32.0, 200.0),
slider: (cx - 100.0, top + 32.0, 200.0),
cancel: (cx - 152.0, by, 98.0),
back: (cx - 49.0, by, 98.0),
next: (cx + 54.0, by, 98.0),
@@ -197,9 +159,6 @@ impl Calibration {
fn current(&self) -> Step {
STEPS[self.step]
}
fn layout(&self, font: &Font, size: (f32, f32)) -> Layout {
layout(font, self.current(), size)
}
/// The slider's position, 0..=1.
fn position(&self) -> f32 {
let step = self.current();
@@ -225,17 +184,18 @@ impl Calibration {
};
Text::literal(format!("{}: {}", step.name(), value))
}
/// The current step's test picture in a GUI of `size`.
pub fn picture(&self, font: &Font, size: (f32, f32)) -> Picture {
/// The step, its nits and where the controls start (a share of the height), for
/// HDR output's test picture.
pub fn picture(&self, h: f32) -> (Step, f32, f32) {
(
self.current(),
self.values[self.step],
self.layout(font, size).top / size.1.max(1.0),
layout(0.0, h).top / h.max(1.0),
)
}
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
let (w, h) = (batch.width, batch.height);
let l = self.layout(&atlas.font, (w, h));
let l = layout(w, h);
// Above the band HDR output draws the test picture over whatever is here.
batch.fill(atlas, 0.0, 0.0, w, h, rgba(0, 0, 0, 255));
batch.fill(atlas, 0.0, l.top, w, h - l.top, rgba(24, 24, 24, 255));
@@ -245,10 +205,16 @@ impl Calibration {
&[&(self.step + 1), &STEPS.len(), &step.name()],
));
ui::centered_text(batch, atlas, &title, 0.0, l.top + 6.0, w, u32::MAX);
for (i, line) in l.hint.iter().enumerate() {
let y = l.top + 18.0 + i as f32 * HINT_LINE;
ui::centered_text(batch, atlas, line, 0.0, y, w, rgba(160, 160, 160, 255));
}
let hint = Text::literal(lang::get(step.hint_key()));
ui::centered_text(
batch,
atlas,
&hint,
0.0,
l.top + 18.0,
w,
rgba(160, 160, 160, 255),
);
let (x, y, sw) = l.slider;
ui::slider(
batch,
@@ -295,14 +261,8 @@ impl Calibration {
}
}
/// A press, or a drag with `drag`, at `cursor`.
pub fn click(
&mut self,
font: &Font,
cursor: (f32, f32),
size: (f32, f32),
drag: bool,
) -> Outcome {
let l = self.layout(font, size);
pub fn click(&mut self, cursor: (f32, f32), (w, h): (f32, f32), drag: bool) -> Outcome {
let l = layout(w, h);
let (x, _, sw) = l.slider;
if drag {
if !self.dragging {
@@ -376,14 +336,6 @@ mod tests {
const NONE: EffectiveOutput = EffectiveOutput::NONE;
fn font() -> Font {
Font::metrics(std::path::Path::new("/nonexistent/horizon-test-assets"))
}
/// At the default GUI scale of the game's first window (854 x 480 at 2).
fn picture(c: &Calibration) -> Picture {
c.picture(&font(), (427.0, 240.0))
}
#[test]
fn calibration_saves_only_on_save() {
let mut s = Settings::default();
@@ -392,14 +344,14 @@ mod tests {
let mut c = Calibration::new(&s, &NONE);
assert_eq!(c.key(KeyCode::ArrowLeft), Outcome::Changed);
assert_eq!(c.key(KeyCode::ArrowLeft), Outcome::Changed);
assert!((picture(&c).1 - 0.03).abs() < 1e-6);
assert!((c.picture(100.0).1 - 0.03).abs() < 1e-6);
assert_eq!(c.key(KeyCode::Escape), Outcome::Cancel);
assert_eq!(s.horizon, before);
// Black, then Peak (one step up), then Paper White, then Save.
assert_eq!(c.key(KeyCode::Enter), Outcome::Click);
assert_eq!(c.key(KeyCode::ArrowRight), Outcome::Changed);
assert_eq!(c.key(KeyCode::Enter), Outcome::Click);
assert_eq!(picture(&c).0, Step::PaperWhite);
assert_eq!(c.picture(100.0).0, Step::PaperWhite);
assert_eq!(c.key(KeyCode::Enter), Outcome::Save);
c.save(&mut s);
assert_eq!(s.horizon.hdr_black_level, 3);
@@ -418,10 +370,10 @@ mod tests {
for (output, peak, paper_white) in [(&reporting, 1000.0, 400.0), (&NONE, 1000.0, 203.0)] {
let mut c = Calibration::new(&s, output);
c.key(KeyCode::Enter);
assert_eq!(picture(&c).0, Step::Peak);
assert_eq!(picture(&c).1, peak);
assert_eq!(c.picture(1.0).0, Step::Peak);
assert_eq!(c.picture(1.0).1, peak);
c.key(KeyCode::Enter);
assert_eq!(picture(&c).1, paper_white);
assert_eq!(c.picture(1.0).1, paper_white);
}
}
@@ -440,14 +392,14 @@ mod tests {
];
let mut c = Calibration::new(&Settings::default(), &NONE);
for (i, expected) in expected.iter().enumerate() {
assert_eq!(picture(&c).0, STEPS[i]);
assert_eq!(c.picture(1.0).0, STEPS[i]);
for _ in 0..200 {
c.key(KeyCode::ArrowLeft);
}
let mut seen = vec![picture(&c).1];
let mut seen = vec![c.picture(1.0).1];
for _ in 0..200 {
c.key(KeyCode::ArrowRight);
let v = picture(&c).1;
let v = c.picture(1.0).1;
if seen.last() != Some(&v) {
seen.push(v);
}
@@ -470,7 +422,7 @@ mod tests {
assert_eq!(s.horizon.hdr_black_level, 2);
// Calibrated back down, 0 is no lift: what the step showed.
let mut c = Calibration::new(&s, &NONE);
assert!((picture(&c).1 - 0.02).abs() < 1e-6);
assert!((c.picture(1.0).1 - 0.02).abs() < 1e-6);
c.key(KeyCode::ArrowLeft);
c.key(KeyCode::ArrowLeft);
c.key(KeyCode::Enter);
@@ -483,89 +435,14 @@ mod tests {
assert_eq!(s.horizon.hdr_peak, 10_000);
let mut c = Calibration::new(&s, &NONE);
c.key(KeyCode::Enter);
assert_eq!(picture(&c).1, 10_000.0);
assert_eq!(c.picture(1.0).1, 10_000.0);
// One step down from the top is 500 nits; from 2000, 50.
c.key(KeyCode::ArrowLeft);
assert_eq!(picture(&c).1, 9500.0);
assert_eq!(c.picture(1.0).1, 9500.0);
s.horizon.hdr_peak = 2000;
let mut c = Calibration::new(&s, &NONE);
c.key(KeyCode::Enter);
c.key(KeyCode::ArrowLeft);
assert_eq!(picture(&c).1, 1950.0);
}
/// A hint wider than the window less its margins wraps, the band grows up by the lines
/// it adds, and the test picture ends where the band drawn starts.
#[test]
fn the_hint_wraps_and_the_band_grows_by_its_lines() {
let font = font();
let mut c = Calibration::new(&Settings::default(), &NONE);
c.key(KeyCode::Enter);
c.key(KeyCode::Enter);
let hint = Text::literal(lang::get(Step::PaperWhite.hint_key()));
let wide = font.width_text(&hint);
let size = (wide + 2.0 * HINT_MARGIN, 240.0);
let one = c.layout(&font, size);
assert_eq!(one.hint.len(), 1);
assert_eq!(one.top, 240.0 - BAND);
let narrow = (wide * 0.6, 240.0);
let wrapped = c.layout(&font, narrow);
assert_eq!(wrapped.hint.len(), 2);
assert!(
wrapped
.hint
.iter()
.all(|l| font.width_text(l) <= narrow.0 - 2.0 * HINT_MARGIN)
);
assert_eq!(wrapped.top, 240.0 - BAND - HINT_LINE);
// Under the hint's lines, the controls keep their place in the band.
assert_eq!(
wrapped.slider.1 - wrapped.top,
one.slider.1 - one.top + HINT_LINE
);
assert_eq!(
wrapped.next.1 - wrapped.top,
one.next.1 - one.top + HINT_LINE
);
for size in [size, narrow] {
let (_, _, band) = c.picture(&font, size);
assert_eq!(band * size.1, c.layout(&font, size).top);
}
}
/// The Peak step's patch (a square of a tenth of the screen, centred above the band,
/// `hdr.wgsl`) stays above the band even on the narrowest GUI with every hint wrapped:
/// its side is no more than the band's top.
#[test]
fn the_test_pictures_stay_above_a_grown_band() {
let font = font();
let mut c = Calibration::new(&Settings::default(), &NONE);
for step in STEPS {
for (w, h) in [(320.0, 240.0), (200.0, 240.0), (427.0, 240.0)] {
let (shown, _, band) = c.picture(&font, (w, h));
assert_eq!(shown, step);
let side = (0.1 * w * h).sqrt();
assert!(
side <= band * h,
"{step:?} at {w}x{h}: {side} > {}",
band * h
);
}
c.key(KeyCode::Enter);
}
}
/// Leaving calibration gives the window back as it found it.
#[test]
fn leaving_gives_the_window_back_as_it_was() {
use WindowState as W;
assert_eq!(W::of(true, false), W::Fullscreen);
assert_eq!(W::of(true, true), W::Fullscreen);
assert_eq!(W::of(false, true), W::Maximized);
assert_eq!(W::of(false, false), W::Windowed);
// (leave fullscreen, maximize again)
assert_eq!(W::Fullscreen.leaving(), (false, false));
assert_eq!(W::Maximized.leaving(), (true, true));
assert_eq!(W::Windowed.leaving(), (true, false));
assert_eq!(c.picture(1.0).1, 1950.0);
}
}
+84 -7
View File
@@ -12,7 +12,6 @@ use std::{
};
use wgpu::util::DeviceExt;
mod animate;
pub mod cit_glint;
mod pack_text;
pub mod pictures;
@@ -101,13 +100,13 @@ pub struct HudAtlas {
pub glint_armor: (Vec<u8>, u32, u32),
/// A resource pack's own `core/text` shader for the GUI's text, translated;
/// none draws the text natively.
pub text_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
pub text_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
/// A resource pack's own `core/rendertype_lines` shader for the block outline, translated;
/// none draws the outline natively.
pub lines_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
pub lines_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
/// The packs' `core/sky` and `core/stars`, when a pack replaces them.
pub sky_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
pub stars_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
pub sky_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
pub stars_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
/// The packs' Polytone screen sprite modifiers.
pub sprite_modifiers: std::sync::Arc<sprite_modifiers::SpriteModifiers>,
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
@@ -1428,7 +1427,32 @@ impl HudAtlas {
}
if full {
// Official Recipes artwork from Just Enough Items, under MIT.
for (name, png) in crate::jei_sprites::SPRITES {
for (name, png) in [
(
"button_enabled_v2",
include_bytes!("jei_recipes/button_enabled_v2.png").as_slice(),
),
(
"button_disabled_v2",
include_bytes!("jei_recipes/button_disabled_v2.png").as_slice(),
),
(
"button_highlight_v2",
include_bytes!("jei_recipes/button_highlight_v2.png").as_slice(),
),
(
"icons/arrow_previous_v2",
include_bytes!("jei_recipes/icons/arrow_previous_v2.png").as_slice(),
),
(
"icons/arrow_next_v2",
include_bytes!("jei_recipes/icons/arrow_next_v2.png").as_slice(),
),
(
"search_background_v2",
include_bytes!("jei_recipes/search_background_v2.png").as_slice(),
),
] {
let (width, height, rgba) = decode_bytes(png)?;
images.push((format!("horizon/jei/{name}"), width, height, rgba));
}
@@ -2871,12 +2895,65 @@ impl HudRenderer {
pub fn set_text_shader(
&mut self,
device: &wgpu::Device,
shader: Option<&crate::core_shader::CoreProgram>,
shader: Option<&horizon_shaders::CoreProgram>,
) {
self.pack_text = shader.and_then(|shader| {
pack_text::PackText::new(device, self.format, &self.atlas_view, shader, self.coverage)
});
}
pub fn animate_sprites(
&mut self,
uploads: &mut crate::gfx::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
tick: u64,
) {
let Some(texture) = &self.atlas_texture else {
return;
};
for (rect, animation, previous) in &mut self.sprite_animations {
// `SpriteContents.AnimationState`: the frame from the mcmeta's
// list, blended into the next one by the sub-frame's thousandths
// when the sprite interpolates.
let blend = animation.blend(tick);
let state = (blend.frame, blend.next, blend.millis);
if state == *previous {
continue;
}
let (w, h) = rect.texels((texture.width(), texture.height()));
let pixels = if blend.millis == 0 {
animation.resized_pixels(blend.frame, w, h)
} else {
let current = animation.resized_pixels(blend.frame, w, h);
let next = animation.resized_pixels(blend.next, w, h);
let millis = blend.millis;
current
.iter()
.zip(next)
.map(|(&a, b)| {
((u32::from(a) * (1000 - millis) + u32::from(b) * millis + 500) / 1000)
as u8
})
.collect()
};
uploads.texture(
encoder,
wgpu::TexelCopyTextureInfo {
texture,
mip_level: 0,
origin: wgpu::Origin3d {
x: (rect.u0 * texture.width() as f32).round() as u32,
y: (rect.v0 * texture.height() as f32).round() as u32,
z: 0,
},
aspect: wgpu::TextureAspect::All,
},
w,
h,
&pixels,
);
*previous = state;
}
}
/// Stage `patches` into the shared atlas texture.
pub fn upload_patches(
&self,
@@ -1,61 +0,0 @@
//! The GUI atlas's animated sprites, frame by frame (vanilla's or a Polytone `mode`'s).
impl super::HudRenderer {
/// Shows each animated sprite's frame: its mcmeta's, or what its Polytone `mode` picks.
pub(crate) fn animate(
&mut self,
uploads: &mut crate::gfx::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
tick: u64,
animator: &crate::gfx::animate::SpriteAnimator,
) {
let inputs = animator.mode_inputs();
let inputs = Some(&inputs);
let Some(texture) = &self.atlas_texture else {
return;
};
for (rect, animation, previous) in &mut self.sprite_animations {
// `SpriteContents.AnimationState`: the frame from the mcmeta's
// list, blended into the next one by the sub-frame's thousandths
// when the sprite interpolates; or what a Polytone `mode` picks.
let blend = animation.blend_with(tick, inputs);
let state = (blend.frame, blend.next, blend.millis);
if state == *previous {
continue;
}
let (w, h) = rect.texels((texture.width(), texture.height()));
let pixels = if blend.millis == 0 {
animation.resized_pixels(blend.frame, w, h)
} else {
let current = animation.resized_pixels(blend.frame, w, h);
let next = animation.resized_pixels(blend.next, w, h);
let millis = blend.millis;
current
.iter()
.zip(next)
.map(|(&a, b)| {
((u32::from(a) * (1000 - millis) + u32::from(b) * millis + 500) / 1000)
as u8
})
.collect()
};
uploads.texture(
encoder,
wgpu::TexelCopyTextureInfo {
texture,
mip_level: 0,
origin: wgpu::Origin3d {
x: (rect.u0 * texture.width() as f32).round() as u32,
y: (rect.v0 * texture.height() as f32).round() as u32,
z: 0,
},
aspect: wgpu::TextureAspect::All,
},
w,
h,
&pixels,
);
*previous = state;
}
}
}
@@ -393,7 +393,7 @@ fn coverage_uses_pack_texts_final_alpha_and_discard() {
return;
};
// A translated-style struct result and early return; no dependency on game resources.
let program = crate::core_shader::CoreProgram {
let program = horizon_shaders::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p.x / 2.0 - 1.0, 1.0 - p.y, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("struct Result { @location(0) color: vec4<f32> } @fragment fn main(@builtin(position) p: vec4<f32>) -> Result { if p.x > 2.0 { discard; } return Result(vec4(0.4, 0.2, 0.1, 0.5)); }").unwrap(),
};
@@ -414,7 +414,7 @@ fn a_pack_text_shader_without_a_colour_output_is_dropped_not_wrapped() {
let Some(mut gpu) = Gpu::new() else {
return;
};
let program = crate::core_shader::CoreProgram {
let program = horizon_shaders::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("@fragment fn main() -> @location(1) vec4<f32> { return vec4(1.0); }").unwrap(),
};
@@ -5,7 +5,7 @@
use std::path::Path;
use std::sync::Arc;
use crate::core_shader::{CoreProgram, Glue};
use horizon_shaders::{CoreProgram, Glue};
use super::{HudBatch, HudVertex};
use crate::gfx::hud_coverage::Pipeline;
@@ -64,7 +64,7 @@ pub(super) fn load_core(
let path = format!("assets/{namespace}/{path}");
read(root, &path).or_else(|| read(defaults, &path))
};
match crate::core_shader::translate_core(&vertex, &fragment, defines, &include, glue) {
match horizon_shaders::translate_core(&vertex, &fragment, defines, &include, glue) {
Ok(program) => {
log::info!("the resource pack's {name} shader is used");
Some(Arc::new(program))
@@ -132,7 +132,7 @@ impl PackText {
count: None,
})
.collect();
for (_, binding) in crate::core_shader::SAMPLERS {
for (_, binding) in horizon_shaders::SAMPLERS {
entries.push(wgpu::BindGroupLayoutEntry {
binding,
visibility,
@@ -175,7 +175,7 @@ impl PackText {
}),
})
.collect();
for (_, binding) in crate::core_shader::SAMPLERS {
for (_, binding) in horizon_shaders::SAMPLERS {
resources.push(wgpu::BindGroupEntry {
binding,
resource: wgpu::BindingResource::TextureView(atlas),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 B

@@ -1,55 +0,0 @@
//! The official Recipes viewer's artwork from Just Enough Items, under MIT (`jei_recipes/LICENSE.txt`,
//! THIRD_PARTY_NOTICES.md): `HudAtlas` loads each as the sprite `horizon/jei/<name>`.
/// Each sprite's name and its PNG.
pub(crate) const SPRITES: &[(&str, &[u8])] = &[
(
"button_enabled_v2",
include_bytes!("jei_recipes/button_enabled_v2.png"),
),
(
"button_disabled_v2",
include_bytes!("jei_recipes/button_disabled_v2.png"),
),
(
"button_highlight_v2",
include_bytes!("jei_recipes/button_highlight_v2.png"),
),
(
"icons/arrow_previous_v2",
include_bytes!("jei_recipes/icons/arrow_previous_v2.png"),
),
(
"icons/arrow_next_v2",
include_bytes!("jei_recipes/icons/arrow_next_v2.png"),
),
(
"search_background_v2",
include_bytes!("jei_recipes/search_background_v2.png"),
),
("slot", include_bytes!("jei_recipes/slot.png")),
(
"icons/list_badge",
include_bytes!("jei_recipes/icons/list_badge.png"),
),
(
"interactive_ingredient_tooltip_background",
include_bytes!("jei_recipes/interactive_ingredient_tooltip_background.png"),
),
(
"recipe_preview_background_v2",
include_bytes!("jei_recipes/recipe_preview_background_v2.png"),
),
(
"ingredient_list_slot_background_v2",
include_bytes!("jei_recipes/ingredient_list_slot_background_v2.png"),
),
(
"scrollbar_background_v2",
include_bytes!("jei_recipes/scrollbar_background_v2.png"),
),
(
"scrollbar_marker_v2",
include_bytes!("jei_recipes/scrollbar_marker_v2.png"),
),
];
+1 -5
View File
@@ -38,7 +38,6 @@ pub mod command_block;
pub mod connected;
pub mod containers;
pub mod controller_ui;
pub mod core_shader;
pub mod crash;
pub mod custom_sky;
pub mod datapack;
@@ -63,11 +62,10 @@ pub mod hud_layout;
pub mod hud_overlays;
pub mod inventory_effects;
pub mod java_text;
mod jei_sprites;
pub mod jigsaw_block;
pub mod keypad;
pub mod terrain_model;
pub use horizon_polytone::creative_tabs as polytone_creative_tabs;
pub use horizon_polytone::colors as polytone_colors;
pub use horizon_world::json_folder;
pub mod level_loading;
pub mod light;
@@ -88,8 +86,6 @@ pub mod options_fixes;
pub mod options_ui;
pub mod overlay;
pub mod pack_browser;
pub mod pack_formats;
pub mod pack_hooks;
pub mod pack_options_ui;
pub mod pack_palette;
pub mod pack_screen;
+2 -2
View File
@@ -2593,7 +2593,8 @@ impl Lod {
self.returned_at = std::time::Instant::now();
let nodes = &self.plan.nodes;
self.mesh_samples.retain(|key| nodes.contains(key));
// Queued tiles the view left are not sampled; a running one is dropped (`finish_sample`).
// Queued tiles the view left are not sampled (a running one is
// dropped when it finishes, `finish_sample`).
drop_queued_samples(&self.shared, &mut self.pending, |key| {
self.plan.nodes.contains(key)
});
@@ -2679,7 +2680,6 @@ impl Lod {
}
if let Some((columns, mask)) = self.kept.take(&key) {
self.ready.insert(key, columns);
self.sync_sample(key);
if let Some(mask) = mask {
self.detail_masks.insert(key, mask);
}
+8 -80
View File
@@ -761,49 +761,6 @@ pub struct MeshTables {
colormaps: Vec<Arc<crate::environment::Colormap>>,
water_colormap: Option<u16>,
}
/// Vanilla's four biome colour functions (`BiomeColors`) with what the packs replace them by:
/// `biome_colors` (a colormap or a flat colour), else the colormap a block modifier gives
/// `grass_block`, `oak_leaves` or `leaf_litter`, or a fluid modifier gives water, which Polytone
/// spreads to every block of that function. Each with the tints that read it.
fn biome_functions(
pack_root: &std::path::Path,
modifiers: &[(bool, horizon_polytone::Targets, String)],
) -> Vec<(&'static [Tint], crate::environment::Colormap)> {
use crate::environment::Colormap;
use horizon_polytone::colors::BiomeColor;
const FUNCTIONS: [(&str, &str, &[Tint]); 4] = [
(
"grass",
"minecraft:grass_block",
&[
Tint::Grass,
Tint::GrassBlock,
Tint::TallGrassUpper,
Tint::SugarCane,
],
),
("foliage", "minecraft:oak_leaves", &[Tint::Foliage]),
("dry_foliage", "minecraft:leaf_litter", &[Tint::DryFoliage]),
("water", "minecraft:water", &[Tint::Water]),
];
let colors = horizon_polytone::colors::PackColors::read(pack_root);
FUNCTIONS
.into_iter()
.filter_map(|(function, block, tints)| {
let colormap = match colors.biome_color(function) {
Some(BiomeColor::Flat(rgb)) => Some(Colormap::flat(rgb)),
Some(BiomeColor::Colormap(id)) => Colormap::load(pack_root, &id),
None => modifiers
.iter()
.find(|(fluid, targets, _)| {
*fluid == (function == "water") && targets.matches(block, |_| false)
})
.and_then(|(_, _, id)| Colormap::load(pack_root, id)),
}?;
Some((tints, colormap))
})
.collect()
}
fn stem_color(age: u8) -> u32 {
let a = u32::from(age);
(a * 32) << 16 | (255 - a * 8) << 8 | (a * 4)
@@ -988,8 +945,7 @@ impl MeshTables {
}
/// These tables with the colormaps of the Polytone block and fluid modifiers of the packs
/// merged at `pack_root` (wiki "Colormaps"): a tinted block they target takes its colour
/// from the colormap, and its own tint where the colormap's pixel is transparent. Vanilla's
/// four biome colour functions take the packs' `biome_colors` first (wiki "Custom Colors").
/// from the colormap, and its own tint where the colormap's pixel is transparent.
#[must_use]
pub fn with_polytone_colormaps(
mut self,
@@ -997,8 +953,7 @@ impl MeshTables {
registry: &horizon_world::registry::BlockRegistry,
) -> Self {
let modifiers = crate::environment::polytone_colormap_modifiers(pack_root);
let biome_functions = biome_functions(pack_root, &modifiers);
if modifiers.is_empty() && biome_functions.is_empty() {
if modifiers.is_empty() {
return self;
}
let mut loaded: std::collections::HashMap<String, Option<u16>> = Default::default();
@@ -1009,24 +964,7 @@ impl MeshTables {
Some((colormaps.len() - 1) as u16)
})
};
for (tints, colormap) in biome_functions {
self.colormaps.push(Arc::new(colormap));
let i = Some((self.colormaps.len() - 1) as u16);
if tints == [Tint::Water] {
self.water_colormap = i;
}
for info in &mut self.info {
let own = &info.tints[..usize::from(info.tint_count).min(2)];
if own.iter().any(|t| tints.contains(t)) {
info.colormap = i;
}
}
}
let water_function = self.water_colormap;
for (fluid, targets, id) in &modifiers {
if *fluid && water_function.is_some() {
continue;
}
if *fluid {
if targets.matches("minecraft:water", |_| false) {
self.water_colormap = index(id, &mut self.colormaps);
@@ -1557,9 +1495,9 @@ struct Tints<'a> {
/// by colormap and section-local block.
colormaps: &'a [Arc<crate::environment::Colormap>],
water_colormap: Option<u16>,
sampled: crate::fast_hash::FxHashMap<(u16, [i32; 3]), Option<u32>>,
sampled: std::collections::HashMap<(u16, [i32; 3]), Option<u32>>,
/// Each colormap's own colour by block, before the blend averages them.
cells: crate::fast_hash::FxHashMap<(u16, [i32; 3]), Option<u32>>,
cells: std::collections::HashMap<(u16, [i32; 3]), Option<u32>>,
}
impl<'a> Tints<'a> {
fn new(
@@ -1580,8 +1518,8 @@ impl<'a> Tints<'a> {
uniform: None,
colormaps: &[],
water_colormap: None,
sampled: Default::default(),
cells: Default::default(),
sampled: std::collections::HashMap::new(),
cells: std::collections::HashMap::new(),
}
}
/// With the packs' colormaps of `tables`.
@@ -1593,16 +1531,10 @@ impl<'a> Tints<'a> {
/// Colormap `index`'s colour at block `(x, y, z)` (section-local), `None` where its pixel
/// is transparent.
fn colormap(&mut self, index: u16, x: i32, y: i32, z: i32) -> Option<u32> {
let colormap = self.colormaps.get(usize::from(index))?.clone();
// A colormap that ignores the height, over one biome: one colour per column.
let y = if colormap.column && self.uniform_biome().is_some() {
0
} else {
y
};
if let Some(&known) = self.sampled.get(&(index, [x, y, z])) {
return known;
}
let colormap = self.colormaps.get(usize::from(index))?.clone();
// ClientLevel.calculateBlockTint: the mean of the (2r+1)^2 colours around the block, each
// channel summed and divided. A transparent pixel in the square leaves the block's own.
let r = if colormap.biome_blend { self.radius } else { 0 };
@@ -1641,11 +1573,7 @@ impl<'a> Tints<'a> {
if let Some(&known) = self.cells.get(&(index, at)) {
return known;
}
// One biome over the section and its blend margin: no zoom per block.
let id = match self.uniform_biome() {
Some(biome) => biome,
None => Self::biome_id(&self.zoom, self.region, self.plains, self.origin, at),
};
let id = Self::biome_id(&self.zoom, self.region, self.plains, self.origin, at);
let [cx, cz, base_y] = self.origin;
let block = [cx * 16 + at[0], base_y + at[1], cz * 16 + at[2]];
let mut host = crate::environment::BlockHost::new(block, self.biomes.get(id));
@@ -251,117 +251,3 @@ fn a_biome_colormap_blends_over_a_biome_edge_as_the_vanilla_water_tint_does() {
}
assert!(edge_differs, "the square must cross the edge somewhere");
}
#[test]
fn biome_colors_and_a_grass_block_colormap_replace_their_whole_biome_function() {
let biomes = Biomes::from_jsons(&[(
"minecraft:plains",
serde_json::json!({"effects": {"water_color": 0x3F76E4}}),
)]);
// biome_colors gives water a flat green; a block modifier colours oak leaves with a map,
// which Polytone spreads to the whole foliage function.
let root = pack(&[
(
"assets/mypack/polytone/colors.json",
br#"{"biome_colors": {"water": "0x00ff00"}}"#.to_vec(),
),
(
"assets/mypack/polytone/colormaps/leaves.png",
png_row(&[[0, 0, 255, 255]]),
),
(
"assets/mypack/polytone/block_modifiers/oak.json",
br#"{"targets": ["minecraft:oak_leaves"], "colormap": "mypack:leaves"}"#.to_vec(),
),
]);
let modifiers = crate::environment::polytone_colormap_modifiers(&root);
let functions = biome_functions(&root, &modifiers);
let tints: Vec<_> = functions.iter().map(|(t, _)| *t).collect();
assert_eq!(tints.len(), 2);
assert!(tints[0] == [Tint::Foliage] && tints[1] == [Tint::Water]);
let colormaps: Vec<_> = functions.into_iter().map(|(_, c)| Arc::new(c)).collect();
let chunks: Vec<Vec<Chunk>> = (0..3)
.map(|rz| (0..3).map(|rx| filled(rx - 1, rz - 1, 0)).collect())
.collect();
let region: [[Option<&Chunk>; 3]; 3] =
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz][rx])));
let mut tints = Tints::new(&region, &biomes, BiomeZoom::new(0), 0, [0, 0, 64]);
tints.colormaps = &colormaps;
tints.water_colormap = Some(1);
assert_eq!(tints.water(3, 4, 5), 0x00_FF00);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn only_a_colormap_that_ignores_the_height_is_shared_up_a_column() {
let root = pack(&[
(
"assets/mypack/polytone/colormaps/flat.png",
png_row(&[[1, 2, 3, 255]]),
),
(
"assets/mypack/polytone/colormaps/flat.json",
br#"{"x_axis": "biome_id", "y_axis": "r.noise(o.x/60, o.z/60)"}"#.to_vec(),
),
(
"assets/mypack/polytone/colormaps/tall.png",
png_row(&[[1, 2, 3, 255]]),
),
(
"assets/mypack/polytone/colormaps/tall.json",
br#"{"y_axis": "y_level"}"#.to_vec(),
),
]);
let load = |id| crate::environment::Colormap::load(&root, id).unwrap();
assert!(load("mypack:flat").column);
assert!(!load("mypack:tall").column);
std::fs::remove_dir_all(root).unwrap();
}
/// Times a section's colormap tints with the freepack's kind of grass colormap (a biome id
/// column and a noise row) over the default Biome Blend: `cargo test --release ... -- --ignored
/// --nocapture colormap_tint_cost`.
#[test]
#[ignore = "timing, not a check"]
fn colormap_tint_cost() {
const RADIUS: i32 = 2;
const LAYERS: i32 = 16;
let biomes = Biomes::from_jsons(&[(
"minecraft:plains",
serde_json::json!({"effects": {"water_color": 0x3F76E4}}),
)]);
let pixels: Vec<[u8; 4]> = (0..16).map(|i| [i * 16, 200, 64, 255]).collect();
let root = pack(&[
("assets/mypack/polytone/colormaps/grass.png", png_row(&pixels)),
(
"assets/mypack/polytone/colormaps/grass.json",
br#"{"x_axis": "biome_id", "y_axis": "0.5+(r.noise(o.x/60,5+o.z/60)*0.55)", "biome_id_mapper": {"minecraft:plains": 1, "texture_size": 16}}"#
.to_vec(),
),
]);
let colormaps = vec![Arc::new(
crate::environment::Colormap::load(&root, "mypack:grass").unwrap(),
)];
let chunks: Vec<Vec<Chunk>> = (0..3)
.map(|rz| (0..3).map(|rx| filled(rx - 1, rz - 1, 0)).collect())
.collect();
let region: [[Option<&Chunk>; 3]; 3] =
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz][rx])));
let rounds = 50;
let start = std::time::Instant::now();
let mut check = 0u64;
for _ in 0..rounds {
let mut tints = Tints::new(&region, &biomes, BiomeZoom::new(0), RADIUS, [0, 0, 64]);
tints.colormaps = &colormaps;
for y in 0..LAYERS {
for z in 0..16 {
for x in 0..16 {
check += u64::from(tints.colormap(0, x, y, z).unwrap_or(0));
}
}
}
}
let per = start.elapsed() / rounds;
println!("{LAYERS} layers of 256 blended colormap tints: {per:?} (checksum {check})");
std::fs::remove_dir_all(root).unwrap();
}
@@ -1,7 +1,6 @@
//! Optional, texture-matched overlays drawn full bright over their base texture in the same
//! terrain fragment: native mods' (`<ns>:emissive/...`) and a pack's emissive block textures
//! (OptiFine's `block/stone_e.png` beside `block/stone.png`, the suffix from the pack formats,
//! [`crate::pack_hooks`]). No extra draw calls or world light changes.
//! terrain fragment: native mods' (`<ns>:emissive/...`) and OptiFine's emissive block textures
//! (`block/stone_e.png` beside `block/stone.png`). No extra draw calls or world light changes.
use crate::sprites::Sprites;
use std::{collections::BTreeMap, path::Path};
@@ -36,12 +35,14 @@ fn tile_file(name: &str) -> Option<&str> {
name.strip_suffix(".png")
}
/// A pack's emissive overlays of the block sprites already loaded (what the terrain draws;
/// items draw from the HUD atlas): for each `<ns>:block/<name>`, or connected-texture tile
/// `assets/<ns>/.../<name>.png`, whose `<name><suffix>.png` sits beside it, the overlay is loaded
/// too and paired with it, blocks first, then tiles, each by name, at most [`MAX`] - [`NATIVE`]
/// (native mods keep their share of the table). A pack without such files adds nothing.
pub fn load_overlays(root: &Path, suffix: &str, sprites: &mut Sprites) -> Vec<(String, String)> {
/// OptiFine's emissive overlays of the block sprites already loaded (what the terrain draws;
/// items draw from the HUD atlas): for each
/// `<ns>:block/<name>`, or connected-texture tile `assets/<ns>/.../<name>.png`, whose
/// `<name><suffix>.png` sits beside it, the overlay is loaded too and paired with it, blocks
/// first, then tiles, each by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
/// the table). A pack without such files adds nothing.
pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)> {
let suffix = horizon_optifine::emissive::suffix(root);
// Blocks with identical pixels share a layer (and the shader pairs by layer): a shared
// base would glow on every block that shares it, so it gets no overlay.
let mut sharing: BTreeMap<u32, usize> = BTreeMap::new();
@@ -55,8 +56,8 @@ pub fn load_overlays(root: &Path, suffix: &str, sprites: &mut Sprites) -> Vec<(S
sharing[layer] == 1
&& (name
.split_once(':')
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(suffix))
|| tile_file(name).is_some_and(|stem| !stem.ends_with(suffix)))
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(&suffix))
|| tile_file(name).is_some_and(|stem| !stem.ends_with(&suffix)))
})
.map(|(name, _)| name.clone())
.collect();
@@ -109,7 +110,7 @@ pub fn load_overlays(root: &Path, suffix: &str, sprites: &mut Sprites) -> Vec<(S
pub const CUTOUT: u32 = 1 << 31;
/// The frame's overlay table: native mods' pairs (at most [`NATIVE`] requested), then the packs'
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_overlays`] loads),
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_optifine`] loads),
/// one per base layer with native mods' first, sorted by base layer for the shader's early exit.
pub fn table<'a>(
layers: &BTreeMap<String, u32>,
@@ -203,7 +204,7 @@ mod tests {
// Items draw from the HUD atlas, not the terrain: no pair.
sprites.insert(&root, "minecraft:item/stick").unwrap();
// glowstone isn't loaded: its overlay pairs with nothing.
let pairs = load_overlays(&root, "_e", &mut sprites);
let pairs = load_optifine(&root, &mut sprites);
assert_eq!(
pairs,
[(
@@ -214,6 +215,9 @@ mod tests {
assert!(sprites.layers.contains_key("minecraft:block/stone_e"));
assert!(!sprites.layers.contains_key("minecraft:block/granite_e"));
// The pack's own suffix.
let props = root.join("assets/minecraft/optifine/emissive.properties");
std::fs::create_dir_all(props.parent().unwrap()).unwrap();
std::fs::write(&props, "suffix.emissive=_glow").unwrap();
// Connected-texture tiles pair with the overlay beside them.
let tiles = root.join("assets/minecraft/optifine/ctm/lamp");
png(&tiles.join("0.png"), 230);
@@ -230,7 +234,7 @@ mod tests {
.insert_file(&root, &format!("assets/minecraft/optifine/{tile}.png"))
.unwrap();
}
let pairs = load_overlays(&root, "_glow", &mut sprites);
let pairs = load_optifine(&root, &mut sprites);
assert_eq!(
pairs,
// Blocks first, then tiles.
+130 -37
View File
@@ -8,7 +8,6 @@ pub use crate::settings::Opt;
use crate::{
gamepad::{self, GlyphStyle},
hdr_calibration::Picture,
hud::{HudAtlas, HudBatch, rgba},
keybinds::{self, CATEGORIES, KeyBinds, all_mappings},
lang::{self, LanguageInfo},
@@ -339,7 +338,6 @@ impl Opt {
Opt::SharePresence => "options.sharePresence",
Opt::DirectionalAudio => "options.directionalAudio",
Opt::MusicToast => "options.musicToast",
Opt::Page(page, row) => pages::slider_caption(page, row),
}
}
pub(crate) fn kind(self, s: &Settings) -> Kind {
@@ -403,7 +401,6 @@ impl Opt {
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
Opt::PadTriggerThreshold => unit(s.controller.trigger_threshold, 0.1, 0.9),
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
Opt::Page(page, row) => pages::slider_kind(page, row),
_ => Kind::Cycle,
}
}
@@ -880,7 +877,6 @@ impl Opt {
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
}),
Opt::Page(page, row) => pages::slider_text(page, row),
_ => Text::empty(),
}
}
@@ -955,7 +951,6 @@ impl Opt {
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
Opt::PadTriggerThreshold => s.controller.trigger_threshold = step(10.0, 90.0) / 100.0,
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
Opt::Page(page, row) => pages::slide(page, row, v),
_ => {}
}
if self.affects_preset() {
@@ -1335,8 +1330,6 @@ pub enum Page {
ModProject,
/// A native mod's settings page: its index in `mods.installed`.
NativeMod(usize),
/// A page a crate registered (`horizon_hooks::pages`): its index in `OptionsUi::pages`.
Added(usize),
/// Horizon's shader packs, either kind (`shader_pack_list`).
ShaderPacks,
/// The picked pack's options: the shader screen (`shader_ui`) at the pack's main screen.
@@ -1396,8 +1389,6 @@ enum Act {
/// A native mod's key (mod, key) waits for a key; a button of its page.
NativeKey(usize, usize),
NativeButton(usize, usize),
/// A registered page's row: (page, row).
PageRow(usize, usize),
OpenModFolder,
/// Browse mods: the page of listing `.0` of `mod_browser`.
ModProject(usize),
@@ -1625,8 +1616,7 @@ pub struct OptionsUi {
/// Key Binds: the mapping waiting for a key ([`NATIVE_KEY`]: a native
/// mod's, `selected_native_key`).
pub selected_key: Option<&'static str>,
/// The native mod's key waiting: (mod, key) in `mods.installed`, or a registered page's
/// (page, row) ([`pages::PAGE_KEY`]).
/// The native mod's key waiting: (mod, key) in `mods.installed`.
selected_native_key: Option<(usize, usize)>,
/// Bindings: the action waiting for a pad button.
pub selected_pad_action: Option<&'static str>,
@@ -1692,8 +1682,8 @@ pub struct OptionsUi {
pub split_profiles: crate::split_profiles::SplitProfiles,
/// HDR calibration while its page is open.
pub calibration: Option<crate::hdr_calibration::Calibration>,
/// The window as calibration found it before making it fullscreen (the game keeps it).
pub calibration_window: Option<crate::hdr_calibration::WindowState>,
/// Whether the window was fullscreen before calibration made it so (the game keeps it).
pub calibration_fullscreen: Option<bool>,
pub split_setup: crate::split_ui::SplitUi,
pub studio: crate::studio_ui::StudioUi,
/// The AI Agent page's status lines (set by the game while it shows).
@@ -1772,7 +1762,7 @@ impl Default for OptionsUi {
pack_configs: Default::default(),
split_profiles: Default::default(),
calibration: None,
calibration_window: None,
calibration_fullscreen: None,
split_setup: Default::default(),
studio: Default::default(),
agent_status: Vec::new(),
@@ -1870,6 +1860,25 @@ fn wide_extras(s: &Settings, width: impl Fn(&str) -> f32) -> Vec<crate::extras::
})
.collect()
}
/// What a button opening one of Horizon's pages says on hover.
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Some(match page {
Page::Horizon => "horizon.pages.settings.tooltip",
Page::Studio => "horizon.pages.studio.tooltip",
Page::Graphics => "horizon.pages.graphics.tooltip",
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
Page::Controller => "horizon.pages.controller.tooltip",
Page::Extras => "horizon.pages.extras.tooltip",
Page::Pvp => "horizon.pages.pvp.tooltip",
Page::Agent => "horizon.pages.agent.tooltip",
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
Page::Mods => "horizon.pages.mods.tooltip",
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
Page::Music => "horizon.pages.music.tooltip",
_ => return None,
})
}
/// Rows of an OptionsList: (big?, options) or a header.
enum Row {
Big(Opt),
@@ -1889,17 +1898,14 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
return None;
};
let key = opt.tooltip_key(s);
let Opt::Page(page, row) = opt else {
return lang::has(&key).then(|| tr(&key));
};
return pages::tooltip(*page, *row);
return lang::has(&key).then(|| tr(&key));
};
let mut tip = None;
if let Act::Extra(item) = act {
tip = Some(Text::literal(item.description_text()));
}
if let Act::Push(page) = act
&& let Some(text) = pages::horizon_page_tooltip(*page)
&& let Some(text) = horizon_page_tooltip(*page)
{
tip = Some(tr(text));
}
@@ -1919,7 +1925,6 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
}
match act {
Act::Cycle(opt) if !opt.available() => Some(tr("horizon.options.unavailable")),
Act::PageRow(page, row) => pages::tooltip(*page, *row),
Act::ToggleMod(i) => match s.mods.installed.get(*i) {
Some(installed) => {
let description = installed
@@ -2361,7 +2366,6 @@ impl OptionsUi {
Page::ModBrowser => "horizon.mods.browse.title",
Page::ModProject => "horizon.mods.browse.project",
Page::NativeMod(_) => "Mod Settings",
Page::Added(i) => pages::title(i),
Page::ShaderPacks => "horizon.pages.shader_packs",
Page::ShaderOptions => "horizon.pages.shader_options",
Page::PackOptions => "horizon.pack.options.title",
@@ -2578,6 +2582,43 @@ impl OptionsUi {
}
rows
}
/// A native mod's page: its name, its keys as Key Binds rows (the
/// player's choice, else the default), then its buttons.
fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
let Some(m) = s.mods.installed.get(i) else {
return Vec::new();
};
let mut rows = vec![Row::Note(m.title.clone())];
let Some(page) = &m.native else {
return rows;
};
let chosen = s.mods.keys.get(&m.id);
for (k, [name, label, default]) in page.keys.iter().enumerate() {
let key = chosen.and_then(|c| c.get(name)).unwrap_or(default);
let mut message = keybinds::display_name(key);
if self.selected_native_key == Some((i, k)) {
message = Text::literal("> ")
.append(message.with_color(text::WHITE))
.append(" <")
.with_color(text::YELLOW);
}
let caption = button(0.0, 0.0, 150.0, Text::literal(label.clone()), Act::None);
let change = button(0.0, 0.0, 150.0, message, Act::NativeKey(i, k));
rows.push(Row::Buttons(inactive(caption), Some(change)));
}
for (b, label) in page.buttons.iter().enumerate() {
let widget = button(
0.0,
0.0,
310.0,
Text::literal(label.clone()),
Act::NativeButton(i, b),
);
rows.push(Row::Buttons(widget, None));
}
rows
}
fn list_rows(&self, s: &Settings) -> Vec<Row> {
match self.page() {
Page::SplitSetup => Vec::new(),
@@ -2642,7 +2683,47 @@ impl OptionsUi {
flush(&mut rows, &mut section);
rows
}
Page::Horizon => self.horizon_rows(s),
Page::Horizon => {
// Two to a row, as vanilla's OptionsList.addSmall lays out
// buttons; each opens one of Horizon's settings pages. Each page has
// one way in: graphics and shader packs are Video Settings' (the graphics
// screen), the controller is Controls', mods are the Options screen's.
let open =
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
vec![
Row::Buttons(
open("horizon.pages.open.extras", Page::Extras),
Some(open("horizon.pages.open.pvp", Page::Pvp)),
),
Row::Buttons(
open(
"horizon.pages.open.split_setup",
if self.split_guest {
Page::SplitProfiles
} else {
Page::SplitSetup
},
),
Some(open("horizon.pages.open.studio", Page::Studio)),
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
self.extra_button(s, crate::extras::Item::DiscordPresence),
Some(if s.extras.no_ai {
self.extra_button(s, crate::extras::Item::NoAi)
} else {
open("horizon.pages.open.agent", Page::Agent)
}),
),
]
.into_iter()
.chain(
(!s.extras.no_ai).then(|| {
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
}),
)
.collect()
}
Page::Agent => {
let mut rows = vec![Row::Buttons(
self.extra_button(s, crate::extras::Item::AgentControl),
@@ -2925,7 +3006,6 @@ impl OptionsUi {
Page::ModBrowser => self.mod_browser_rows(),
Page::ModProject => self.mod_project_rows(s),
Page::NativeMod(i) => self.native_rows(s, i),
Page::Added(i) => self.added_rows(i),
_ => {
let _ = s;
Vec::new()
@@ -3950,7 +4030,7 @@ impl OptionsUi {
if right {
return Vec::new();
}
let outcome = c.click(&atlas.font, (x, y), size, drag);
let outcome = c.click((x, y), size, drag);
return self.calibration_outcome(s, outcome);
}
if self.shell().is_some() {
@@ -4192,9 +4272,11 @@ impl OptionsUi {
self.push(Page::NativeMod(i));
events.push(Event::NativeSettings(i));
}
Act::NativeKey(i, k) => self.wait_native_key(i, k),
Act::NativeKey(i, k) => {
self.selected_key = Some(NATIVE_KEY);
self.selected_native_key = Some((i, k));
}
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
Act::PageRow(p, r) => events.extend(self.page_act(p, r, self.shift)),
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::ModProject(i) => {
if let Some(listing) = self.mod_browser.listings.get(i) {
@@ -4550,12 +4632,13 @@ impl OptionsUi {
}
}
}
/// HDR calibration's test picture, while its page is open, in a GUI of `size`.
pub fn calibration_picture(&self, atlas: &HudAtlas, size: (f32, f32)) -> Option<Picture> {
/// HDR calibration's test picture, while its page is open: the step, its nits and
/// where the controls start (a share of the height `h`).
pub fn calibration_picture(&self, h: f32) -> Option<(crate::hdr_calibration::Step, f32, f32)> {
self.calibration
.as_ref()
.filter(|_| self.page() == Page::HdrCalibration)
.map(|c| c.picture(&atlas.font, size))
.map(|c| c.picture(h))
}
pub fn back(&mut self, s: &mut Settings) -> Vec<Event> {
let mut events = Vec::new();
@@ -4698,10 +4781,19 @@ impl OptionsUi {
let Some(name) = self.selected_key.take() else {
return Vec::new();
};
if name == NATIVE_KEY || name == pages::PAGE_KEY {
return self.bind_native_key(s, name, value);
if name == NATIVE_KEY {
let (i, k) = self.selected_native_key.take().unwrap_or_default();
let Some(m) = s.mods.installed.get(i) else {
return Vec::new();
};
let Some([key, ..]) = m.native.as_ref().and_then(|p| p.keys.get(k)) else {
return Vec::new();
};
let keys = s.mods.keys.entry(m.id.clone()).or_default();
keys.insert(key.clone(), value);
return vec![Event::Changed];
}
if !s.extras.set_key(name, value.clone()) {
if !s.extras.set_key(name, value.clone()) && !s.horizon.set_key(name, value.clone()) {
s.keys.set(name, &value);
}
vec![Event::Changed]
@@ -5081,9 +5173,11 @@ impl OptionsUi {
self.push(Page::NativeMod(i));
events.push(Event::NativeSettings(i));
}
Act::NativeKey(i, k) => self.wait_native_key(i, k),
Act::NativeKey(i, k) => {
self.selected_key = Some(NATIVE_KEY);
self.selected_native_key = Some((i, k));
}
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
Act::PageRow(p, r) => events.extend(self.page_act(p, r, shift)),
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::ModProject(i) => {
if let Some(listing) = self.mod_browser.listings.get(i) {
@@ -7254,7 +7348,7 @@ mod tests {
MouseButton::Back,
MouseButton::Forward,
] {
for name in [SORT_KEY, "key.jump"] {
for name in [SORT_KEY, crate::graphics::HDR_KEY, "key.jump"] {
let mut ui = OptionsUi::default();
let mut s = Settings::default();
ui.selected_key = Some(name);
@@ -7263,6 +7357,7 @@ mod tests {
let value = keybinds::mouse_name(button);
match name {
SORT_KEY => assert_eq!(s.extras.sort_key, value),
crate::graphics::HDR_KEY => assert_eq!(s.horizon.hdr_key, value),
_ => assert_eq!(s.keys.get(name), value),
}
assert_eq!(Extras::parse(&s.extras.serialize()), s.extras);
@@ -8344,5 +8439,3 @@ mod tests {
#[cfg(test)]
#[path = "options_ui_graphics_tests.rs"]
mod graphics_tests;
#[path = "options_ui_pages.rs"]
mod pages;
@@ -143,34 +143,3 @@ fn the_graphics_screens_wheel_scrolls_even_over_a_slider_being_changed() {
assert_eq!(s.horizon.chunk_updates, 16);
assert!(ui.graphics.scroll_amount() > before);
}
/// A window holding HDR output back (a start failed on its driver) shows it on the HDR page
/// as soon as the graphics screen opens, with no setting applied: the row's line, and
/// Calibrate blocked, where the same window not holding it back calibrates.
#[test]
fn a_held_hdr_start_shows_as_the_graphics_screen_opens() {
use crate::gfx::output::tests::{snapshot, window};
let atlas = atlas();
let mut s = Settings::default();
s.horizon.hdr = crate::graphics::HdrMode::Auto;
s.horizon.hdr_failed_start = Some("Vulkan 10de:2684 GPU; driver; 1.0; os".into());
let (mut caps, reading) = window(Some(crate::gfx::HdrOutput::Pq), Some(true));
for held in [true, false] {
caps.held = held;
let mut ui = OptionsUi {
size: (427.0, 240.0),
..Default::default()
};
let output = snapshot(&s.horizon, Default::default(), caps, reading);
ui.draw(
&s,
output,
&mut HudBatch::new([427, 240]),
&atlas,
(-1.0, -1.0),
);
ui.show(Page::Graphics);
assert!(calibrates(&mut ui, &mut s, &atlas) != held, "held {held}");
assert_eq!(ui.graphics.screen.output.held, held);
}
}
Loaded 100 of 785 files, more files were not shown because too many files have changed in this diff. Show more