Merge main into #5693: the two uniforms after main's isRightHanded … hasCeiling

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-01 15:47:07 +01:00
commit 67ebb0257e
178 files changed
+11228 -636

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
name: mojang republish
# Hourly: does Mojang's version manifest list a pinned-version JSON that research-lock.json does not know?
# If so, one issue "Mojang republished <version>: <sha8>" with the hashes and a lock snippet
# (tools/ci/mojang_republish.py). Reads only Mojang's public manifest.
# gh workflow run "mojang republish" -f dry_run=true
on:
schedule:
- cron: '23 * * * *' # hourly: a republished JSON breaks horizon-prepare for every new player until accepted
workflow_dispatch:
inputs:
dry_run:
description: Only print what it would do with issues
type: boolean
default: false
permissions:
contents: read
issues: write
concurrency:
group: mojang-republish
cancel-in-progress: false
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Check the manifest
env:
GH_TOKEN: ${{ github.token }}
run: python3 tools/ci/mojang_republish.py ${{ inputs.dry_run && '--dry-run' || '' }}
+1
View File
@@ -56,6 +56,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `horizon-optifine` | OptiFine-format pack files below every feature that reads them: `.properties`, the name/range/block matchers, paths and `optifine/` folders, and custom entity models (`cem/`: `.jem`/`.jpm` parsing and baking) |
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
Generated
+3
View File
@@ -1323,6 +1323,7 @@ dependencies = [
"horizon-items",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
@@ -1478,6 +1479,7 @@ dependencies = [
"horizon-client-core",
"horizon-core",
"horizon-world",
"log",
"serde",
"serde_json",
]
@@ -1513,6 +1515,7 @@ dependencies = [
name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"regex",
]
@@ -6,5 +6,5 @@ authors = []
# A content mod: blocks, items and mob effects from data/ and assets/, no code (API 0.2).
api = "0.2"
side = "both"
# registries: "May add blocks, items and mob effects to your worlds".
# registries: "May add blocks, items, mob effects and entity types to your worlds".
capabilities = ["registries"]
@@ -1179,6 +1179,15 @@ impl Audio {
self.sync_volumes();
self.engine.tick(paused, ctx, &mut self.backend);
}
/// The `uiSoundReserve` extra: positional sounds leave
/// `sound_engine::HEARD_RESERVE` static channels free (off: vanilla's pool).
pub fn set_listener_reserve(&mut self, on: bool) {
self.engine.listener_reserve = if on {
crate::sound_engine::HEARD_RESERVE
} else {
0
};
}
/// `SoundManager.pauseAllExcept(MUSIC, UI)`.
pub fn pause(&mut self) {
self.engine.pause_all_except(
+121
View File
@@ -387,6 +387,52 @@ impl Biomes {
}
}
/// A biome's colours as a mod may change them (`native_biome_colors`), each
/// 0xRRGGBB: what tints grass (before the dark forest's modifier; the swamp's
/// noise colours replace it), leaves and vines, dry foliage (leaf litter) and
/// water.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BiomeColors {
pub grass: u32,
pub foliage: u32,
pub dry_foliage: u32,
pub water: u32,
}
impl Biomes {
/// This table with each biome's colours passed through `f`, given the
/// biome's name (`minecraft:plains`) and its colours; None when `f`
/// changed none. Bits past 0xFFFFFF are dropped.
pub fn with_colors(&self, mut f: impl FnMut(&str, &mut BiomeColors)) -> Option<Self> {
let mut out = self.clone();
let mut changed = false;
for b in &mut out.list {
let before = BiomeColors {
grass: b.grass,
foliage: b.foliage,
dry_foliage: b.dry_foliage,
water: b.water,
};
let mut colors = before;
f(&b.name, &mut colors);
let after = BiomeColors {
grass: colors.grass & 0xFF_FFFF,
foliage: colors.foliage & 0xFF_FFFF,
dry_foliage: colors.dry_foliage & 0xFF_FFFF,
water: colors.water & 0xFF_FFFF,
};
if after != before {
changed = true;
b.grass = after.grass;
b.foliage = after.foliage;
b.dry_foliage = after.dry_foliage;
b.water = after.water;
}
}
changed.then_some(out)
}
}
/// A 24-bit colour as sRGB components in 0..1.
pub fn rgb(c: u32) -> [f32; 3] {
[16, 8, 0].map(|shift| (c >> shift & 255) as f32 / 255.0)
@@ -534,6 +580,81 @@ mod pack_tests {
}
}
#[cfg(test)]
mod color_tests {
use super::*;
use serde_json::json;
fn biomes() -> Biomes {
let maps = Colormaps {
grass: Vec::new(),
foliage: Vec::new(),
dry: Vec::new(),
};
let effects = |grass: u32, modifier: &str| {
json!({"effects": {"grass_color": grass, "foliage_color": 0x112233,
"dry_foliage_color": 0x445566, "water_color": 0x3F76E4,
"grass_color_modifier": modifier}})
};
let list = vec![
BiomeInfo::from_json("minecraft:plains".into(), &effects(0x91BD59, "none"), &maps),
BiomeInfo::from_json(
"minecraft:dark_forest".into(),
&effects(0x91BD59, "dark_forest"),
&maps,
),
];
Biomes {
builtin: Arc::new(list.clone()),
index: list
.iter()
.enumerate()
.map(|(i, b)| (b.name.clone(), i as u16))
.collect(),
list,
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
colormaps: Arc::new(maps),
packs: BTreeMap::new(),
}
}
#[test]
fn a_change_reaches_the_tints_the_mesher_reads() {
let autumn = biomes()
.with_colors(|name, c| {
c.foliage = 0xD2691E;
if name == "minecraft:plains" {
c.grass = 0xBFA055;
}
})
.expect("changed");
let plains = autumn.id("minecraft:plains");
assert_eq!(autumn.grass(plains, 0.0, 0.0), 0xBFA055);
assert_eq!(autumn.get(plains).foliage, 0xD2691E);
assert_eq!(autumn.get(plains).dry_foliage, 0x445566);
assert_eq!(autumn.get(plains).water, 0x3F76E4);
// The dark forest's modifier still darkens its (unchanged) grass:
// ((0x91BD59 & 0xFEFEFE) + 0x28340A) >> 1.
let dark = autumn.id("minecraft:dark_forest");
assert_eq!(autumn.grass(dark, 0.0, 0.0), 0x5C7831);
assert_eq!(autumn.get(dark).foliage, 0xD2691E);
}
#[test]
fn no_change_gives_none_and_alpha_bits_are_dropped() {
assert!(biomes().with_colors(|_, _| {}).is_none());
assert!(
biomes()
.with_colors(|_, c| c.water |= 0xFF00_0000)
.is_none()
);
let blue = biomes()
.with_colors(|_, c| c.water = 0xFF00_00FF)
.expect("changed");
assert_eq!(blue.get(0).water, 0x0000FF);
}
}
#[cfg(test)]
mod blend_tests {
use super::*;
+3 -1
View File
@@ -205,7 +205,9 @@ impl Brewing {
}
// The gate answers the merged file, as the crafting loader's does:
// a false-conditioned pack file leaves the lower one gone too.
sources.retain(|(_, v)| conditions_hold(v));
// The data's recipe filters, over the final set.
let filters = horizon_items::recipe_filters::RecipeFilters::load(root, overlay);
sources.retain_mut(|(id, v)| conditions_hold(v) && filters.apply(id, v));
sources.sort_by(|a, b| a.0.cmp(&b.0));
let mut recipes = Vec::new();
for (id, v) in sources {
+115 -4
View File
@@ -501,6 +501,19 @@ impl Camera {
entity: &CameraEntity,
partial: f32,
camera_type: CameraType,
clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
) {
self.align_with_entity_distance(entity, partial, camera_type, |distance| distance, clip);
}
/// Adjust the detached distance after entity/mount scaling, before collision clipping.
/// Only finite overrides in 0..=1024 blocks are accepted (within the game's ray budget).
/// First-person views do not call `adjust_distance`.
pub fn align_with_entity_distance(
&mut self,
entity: &CameraEntity,
partial: f32,
camera_type: CameraType,
adjust_distance: impl FnOnce(f32) -> f32,
mut clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
) {
self.set_rotation(entity.view_y_rot(partial), entity.view_x_rot(partial));
@@ -528,10 +541,14 @@ impl Camera {
mount_scale = s;
mount_distance = d as f32;
}
let zoom = self.max_zoom(
java_max(scale * distance, mount_scale * mount_distance),
&mut clip,
);
let vanilla = java_max(scale * distance, mount_scale * mount_distance);
let adjusted = adjust_distance(vanilla);
let distance = if adjusted.is_finite() && (0.0..=1024.0).contains(&adjusted) {
adjusted
} else {
vanilla
};
let zoom = self.max_zoom(distance, &mut clip);
self.move_by(-zoom, 0.0, 0.0);
} else if let Some(LivingCamera {
sleeping: Some(bed),
@@ -969,6 +986,100 @@ mod tests {
assert!(camera.position[2] > -1.1 && camera.position[2] < -0.9);
}
#[test]
fn detached_distance_override_keeps_collision_clipping() {
let entity = CameraEntity {
position: [0.0; 3],
previous: [0.0; 3],
x_rot: 0.0,
y_rot: 0.0,
x_rot_o: 0.0,
y_rot_o: 0.0,
eye_height: 1.62,
living: Some(LivingCamera {
scale: 2.0,
camera_distance: 3.0,
sleeping: None,
dead: None,
}),
mount: Some((3.0, 4.0)),
};
for kind in [CameraType::ThirdPersonBack, CameraType::ThirdPersonFront] {
let mut camera = Camera::default();
let mut calls = 0;
let mut rays = 0;
let z = if kind.is_mirrored() { 1.0 } else { -1.0 };
camera.align_with_entity_distance(
&entity,
1.0,
kind,
|distance| {
assert_eq!(distance, 12.0);
8.0
},
|_, _| None,
);
assert!((camera.position[2] - z * 8.0).abs() < 1e-5);
camera.align_with_entity_distance(
&entity,
1.0,
kind,
|distance| {
calls += 1;
assert_eq!(distance, 12.0);
8.0
},
|_, _| {
rays += 1;
Some([0.0, 0.0, z])
},
);
assert_eq!((calls, rays), (1, 8));
assert!((camera.position[2] - z).abs() < 0.2);
}
}
#[test]
fn detached_distance_rejects_invalid_values_and_accepts_zero() {
let entity = CameraEntity {
position: [0.0; 3],
previous: [0.0; 3],
x_rot: 0.0,
y_rot: 0.0,
x_rot_o: 0.0,
y_rot_o: 0.0,
eye_height: 1.62,
living: None,
mount: None,
};
for distance in [f32::NAN, f32::INFINITY, -1.0, 1025.0] {
let mut camera = Camera::default();
camera.align_with_entity_distance(
&entity,
1.0,
CameraType::ThirdPersonBack,
|_| distance,
|_, _| None,
);
assert!((camera.position[2] + 4.0).abs() < 1e-6);
}
let mut camera = Camera::default();
camera.align_with_entity_distance(
&entity,
1.0,
CameraType::ThirdPersonBack,
|_| 0.0,
|_, _| None,
);
assert_eq!(camera.position, camera.eye);
camera.align_with_entity_distance(
&entity,
1.0,
CameraType::FirstPerson,
|_| panic!("first person has no detached distance"),
|_, _| None,
);
assert_eq!(camera.position, camera.eye);
}
#[test]
fn fov_modifier_combines_speed_flight_and_bow() {
let mut p = FovPlayer {
flying: false,
+36
View File
@@ -195,6 +195,14 @@ fn read(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok()
}
fn windows_processor_name(query: &str) -> Option<String> {
query.lines().find_map(|line| {
let (_, value) = line.split_once("REG_SZ")?;
let value = value.trim();
(!value.is_empty()).then(|| value.to_owned())
})
}
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
fn operating_system() -> String {
let arch = std::env::consts::ARCH;
@@ -235,6 +243,18 @@ pub fn cpu() -> String {
})
} else if cfg!(target_os = "macos") {
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
} else if cfg!(windows) {
command_output(
"reg",
&[
"query",
r"HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0",
"/v",
"ProcessorNameString",
],
)
.and_then(|query| windows_processor_name(&query))
.or_else(|| std::env::var("PROCESSOR_IDENTIFIER").ok())
} else {
std::env::var("PROCESSOR_IDENTIFIER").ok()
};
@@ -636,6 +656,22 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
mod tests {
use super::*;
#[test]
fn windows_registry_cpu_name_is_read_without_the_registry_columns() {
let query = r#"
HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0
ProcessorNameString REG_SZ 13th Gen Intel(R) Core(TM) i7-13700F
"#;
assert_eq!(
windows_processor_name(query).as_deref(),
Some("13th Gen Intel(R) Core(TM) i7-13700F")
);
assert_eq!(
windows_processor_name("ProcessorNameString REG_SZ "),
None
);
}
#[test]
fn a_release_reports_its_built_in_version() {
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
+5 -3
View File
@@ -575,7 +575,7 @@ pub struct DataStore {
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
@@ -583,7 +583,7 @@ pub struct DataStore {
pub errors: Vec<(String, String)>,
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/` or `tags/<sub>/`, where the id keeps the sub
/// path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
@@ -591,7 +591,8 @@ fn content_id(file: &str) -> Option<String> {
let (ns, rest) = rest.split_once('/')?;
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "loot_table" | "advancement" | "trade_set" | "villager_trade" | "dialog" => kind,
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
| "villager_trade" | "dialog" => kind,
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
// test:sub/mytag`), the whole path after the namespace, so nested
// registries keep their directory (`TagLoader`'s id shape).
@@ -628,6 +629,7 @@ impl DataStore {
"loot_table",
"predicate",
"recipe",
"recipe_filter",
"tags",
"trade_set",
"villager_trade",
@@ -320,6 +320,13 @@ fn shadow_color(in: Out) -> vec4<f32> {
}
fn celestial_color(in: Out) -> vec4<f32> {
// RenderPipelines.DRAGON_RAYS / rendertype_lightning.fsh: untextured
// vertex colour, with fog fading both RGB and alpha before additive blending.
if in.overlay == 37u {
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return in.color * (1.0 - fog);
}
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
+41 -6
View File
@@ -96,6 +96,9 @@ pub struct Extras {
pub minimap_rotate: bool,
/// Retry lost multiplayer connections (`reconnect`).
pub auto_reconnect: bool,
/// Positional sounds leave a few static sound channels free for UI sounds
/// and others played to the listener (`sound_engine::HEARD_RESERVE`).
pub ui_sound_reserve: bool,
/// The player's own name tag over its model in third person.
pub own_name_tag: bool,
/// Mouse Tweaks-style drags in the inventory and storage containers.
@@ -113,6 +116,8 @@ pub struct Extras {
pub agent_control: bool,
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
pub discord_presence: bool,
/// Another controller can join P1's world (`splitscreen`, off by default).
pub splitscreen: bool,
}
impl Default for Extras {
fn default() -> Self {
@@ -138,6 +143,7 @@ impl Default for Extras {
minimap_zoom: 1.0,
minimap_rotate: false,
auto_reconnect: false,
ui_sound_reserve: false,
own_name_tag: false,
mouse_tweaks: false,
mouse_tweaks_wheel: true,
@@ -146,6 +152,7 @@ impl Default for Extras {
no_ai: false,
agent_control: false,
discord_presence: true,
splitscreen: false,
}
}
}
@@ -213,6 +220,7 @@ impl Extras {
}
"minimapRotate" => e.minimap_rotate = flag(e.minimap_rotate),
"autoReconnect" => e.auto_reconnect = flag(e.auto_reconnect),
"uiSoundReserve" => e.ui_sound_reserve = flag(e.ui_sound_reserve),
"ownNameTag" => e.own_name_tag = flag(e.own_name_tag),
"mouseTweaks" => e.mouse_tweaks = flag(e.mouse_tweaks),
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
@@ -221,6 +229,7 @@ impl Extras {
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
"splitscreen" => e.splitscreen = flag(e.splitscreen),
_ => {}
}
}
@@ -228,7 +237,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -254,6 +263,7 @@ impl Extras {
self.minimap_zoom,
bool_text(self.minimap_rotate),
bool_text(self.auto_reconnect),
bool_text(self.ui_sound_reserve),
bool_text(self.own_name_tag),
bool_text(self.mouse_tweaks),
bool_text(self.mouse_tweaks_wheel),
@@ -263,6 +273,7 @@ impl Extras {
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.discord_presence),
bool_text(self.splitscreen),
)
}
/// Binds the extra key `name` (`SORT_KEY`); false for
@@ -384,6 +395,7 @@ pub enum Item {
DynamicLights,
OwnNameTag,
AutoReconnect,
UiSoundReserve,
MouseTweaks,
MouseTweaksWheel,
Sort,
@@ -393,6 +405,8 @@ pub enum Item {
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
/// On the Splitscreen settings page.
Splitscreen,
}
/// The Extras page: headers and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
@@ -410,6 +424,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(SortKey),
Err("Multiplayer"),
Ok(AutoReconnect),
Err("Sound"),
Ok(UiSoundReserve),
]
}
impl Item {
@@ -419,6 +435,7 @@ impl Item {
Item::DynamicLights => "Dynamic lights",
Item::OwnNameTag => "Own name tag",
Item::AutoReconnect => "Auto-reconnect",
Item::UiSoundReserve => "Keep channels for UI sounds",
Item::MouseTweaks => "Mouse Tweaks controls",
Item::MouseTweaksWheel => "Scroll wheel moves items",
Item::Sort => "Sort buttons",
@@ -426,16 +443,18 @@ impl Item {
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
}
}
/// The switch this setting belongs to: it does nothing while that is off.
pub fn parent(self) -> Option<Item> {
use Item::*;
Some(match self {
UiSoundReserve => return None,
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
Fullbright | DynamicLights | OwnNameTag | MouseTweaks | Sort | AutoReconnect | NoAi
| AgentControl | DiscordPresence => {
| AgentControl | DiscordPresence | Splitscreen => {
return None;
}
})
@@ -453,6 +472,9 @@ impl Item {
Item::AutoReconnect => {
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
}
Item::UiSoundReserve => {
"Sounds in the world leave 16 of the 247 sound channels free, so a flood of them (a big explosion) can't silence menu clicks."
}
Item::MouseTweaks => {
"Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot."
}
@@ -471,6 +493,9 @@ impl Item {
Item::DiscordPresence => {
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
}
Item::Splitscreen => {
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
}
Item::AgentControl => {
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
}
@@ -481,10 +506,13 @@ impl Item {
use crate::graphics_style::Cost;
match self {
Item::Fullbright => Some(Cost::None),
Item::UiSoundReserve => Some(Cost::None),
Item::DynamicLights => Some(Cost::Low),
Item::OwnNameTag | Item::AutoReconnect | Item::MouseTweaks | Item::Sort => {
Some(Cost::None)
}
Item::OwnNameTag
| Item::AutoReconnect
| Item::MouseTweaks
| Item::Sort
| Item::Splitscreen => Some(Cost::None),
_ => None,
}
}
@@ -495,12 +523,14 @@ impl Item {
Item::DynamicLights => e.dynamic_lights,
Item::OwnNameTag => e.own_name_tag,
Item::AutoReconnect => e.auto_reconnect,
Item::UiSoundReserve => e.ui_sound_reserve,
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
Item::Sort => e.sort,
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
_ => return None,
})
}
@@ -522,12 +552,14 @@ impl Item {
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
Item::OwnNameTag => e.own_name_tag = !e.own_name_tag,
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
Item::UiSoundReserve => e.ui_sound_reserve = !e.ui_sound_reserve,
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
Item::Sort => e.sort = !e.sort,
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::Splitscreen => e.splitscreen = !e.splitscreen,
Item::SortKey => {}
}
}
@@ -549,7 +581,7 @@ mod tests {
let d = Extras::default();
assert!(!d.fullbright && !d.dynamic_lights);
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag);
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
assert!(d.discord_presence);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
@@ -580,6 +612,9 @@ mod tests {
e.sort = true;
e.sort_key = "key.keyboard.r".into();
e.discord_presence = false;
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
assert_eq!(Extras::parse(&e.serialize()), e);
}
+77 -1
View File
@@ -112,6 +112,8 @@ struct Region {
cursor: (u32, u32),
row: u32,
pending: Vec<Patch>,
/// A glyph did not fit since the last `Font::begin_frame`.
full: bool,
}
struct Cache {
/// Every font by id, the default one first (the common lookup).
@@ -546,6 +548,7 @@ impl Region {
self.row = 0;
}
if y + h > region.1 {
self.full = true;
return None;
}
let (ax, ay) = (x, region.0 + y);
@@ -603,6 +606,7 @@ impl Font {
cursor: (0, 0),
row: 0,
pending: Vec::new(),
full: false,
},
random: horizon_core::random::LegacyRandom::new(
std::time::SystemTime::now()
@@ -669,8 +673,19 @@ impl Font {
}
cache.region.cursor = (0, 0);
cache.region.row = 0;
cache.region.full = false;
self.generation.set(next_generation());
}
/// Call once per frame before any text is laid out. When the dynamic
/// region ran out of room (vanilla's glyph textures grow instead), the
/// glyphs are forgotten so they are rasterized again from the start; it
/// waits for the frame boundary because quads already queued hold the old
/// rects.
pub fn begin_frame(&self) {
if self.cache.borrow().region.full {
self.reset_cache();
}
}
/// Changes whenever a glyph may come out different: another font, a
/// cache reset (glyphs move in the atlas) or other font options. Text laid
/// out under one epoch can be kept until it changes.
@@ -706,7 +721,16 @@ impl Font {
if let Some(g) = set.glyphs.get(&c) {
return *g;
}
let glyph = self.resolve(set, region, c).unwrap_or(self.missing);
let was_full = std::mem::replace(&mut region.full, false);
let found = self.resolve(set, region, c);
// No room: the missing glyph for now, not remembered, so the
// character is tried again once `begin_frame` cleared the region.
let overflowed = std::mem::replace(&mut region.full, was_full);
region.full |= overflowed;
if overflowed {
return self.missing;
}
let glyph = found.unwrap_or(self.missing);
set.glyphs.insert(c, glyph);
glyph
}
@@ -1418,4 +1442,56 @@ mod tests {
font.reset_cache();
assert_eq!(font.glyph('a').advance, 2.0);
}
#[test]
fn a_full_glyph_region_recovers_at_the_next_frame() {
// Two 8x8 glyphs from a sheet outside the atlas; the region holds one row.
let sheet = png(16, 8, |x, _| x % 8 < 4);
let default = br#"{"providers":[{"type":"bitmap","file":"pack:font/s.png","ascent":7,"chars":["ab"]}]}"#;
let root = root(
"full",
&[
("assets/minecraft/font/default.json", default),
("assets/pack/textures/font/s.png", &sheet),
],
);
// A 10 wide atlas fits one 8 pixel glyph per row; the region two rows.
let font = Font::load(&root, &HashMap::new(), &[], (10, 64), (0, 8), white());
let _ = std::fs::remove_dir_all(&root);
let epoch = font.epoch();
let a = font.glyph('a');
assert!(a.rect.is_some());
// No room for 'b': missing for now, and asked again next time.
assert!(font.glyph('b').rect.is_none());
assert!(font.glyph('b').rect.is_none());
assert_eq!(font.epoch(), epoch);
font.begin_frame();
assert_ne!(font.epoch(), epoch);
let b = font.glyph('b');
assert!(b.rect.is_some());
assert_eq!(b.advance, a.advance);
// Nothing overflowed since: the next frame keeps the glyphs.
let epoch = font.epoch();
font.begin_frame();
assert_eq!(font.epoch(), epoch);
}
#[test]
fn a_character_without_a_provider_does_not_reset_the_region() {
let sheet = png(8, 8, |x, _| x < 4);
let default = br#"{"providers":[{"type":"bitmap","file":"pack:font/s.png","ascent":7,"chars":["a"]}]}"#;
let root = root(
"nofill",
&[
("assets/minecraft/font/default.json", default),
("assets/pack/textures/font/s.png", &sheet),
],
);
let font = Font::load(&root, &HashMap::new(), &[], (10, 64), (0, 8), white());
let _ = std::fs::remove_dir_all(&root);
let epoch = font.epoch();
assert!(font.glyph('z').rect.is_none());
font.begin_frame();
assert_eq!(font.epoch(), epoch);
}
}
+442 -83
View File
@@ -40,9 +40,9 @@ mod entity_outline;
mod weather;
use entity_batch::EntityBatch;
pub use entity_batch::{
ENERGY_SWIRL_OVERLAY, OIT_ENTITY, SHADOW_OVERLAY, TEXT_POLYGON_OFFSET_OVERLAY,
TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY,
WATER_MASK_OVERLAY, mark_oit,
DRAGON_RAYS_OVERLAY, ENERGY_SWIRL_OVERLAY, OIT_ENTITY, SHADOW_OVERLAY,
TEXT_POLYGON_OFFSET_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, mark_oit,
};
pub use view::{View, ViewSet};
@@ -78,6 +78,31 @@ fn gpu_culling_requested(info: &wgpu::AdapterInfo, env: Option<&str>) -> bool {
env != Some("0") && (env.is_some() || info.device_type != wgpu::DeviceType::Cpu)
}
/// Whether Translucent Batching is offered (`graphics::offer_translucent_batching`): where
/// indirect multi-draws (`multi_draw`) take its batches, but not OpenGL's, which loop over
/// their entries on the CPU (and without them every section is a call of its own anyway);
/// nor where vanilla frames draw flat (`flat`'s `min_sections` below `usize::MAX`: Metal,
/// Intel Gen11/Gen12's Vulkan driver, `HORIZON_FLAT_DRAWS=1`), as flat draws read
/// translucent quads in the section arena.
fn translucent_batchable(
info: &wgpu::AdapterInfo,
multi_draw: bool,
flat_min_sections: Option<usize>,
) -> bool {
multi_draw
&& info.backend != wgpu::Backend::Gl
&& flat_min_sections.is_none_or(|min| min == usize::MAX)
}
/// Whether translucent quads have an arena of their own: with Translucent Batching
/// (`batching`), unless a shader pack's frames can draw terrain flat (as for `flat_frame`),
/// which reads them in the section arena through its tables: where flat draws exist and
/// are on (`flat_draws`), for a `shaders/` pack's terrain programs (`terrain`) or a shadow
/// pass (`shadow`: a `shaders/` pack's, or the `[shadow]` of one of Horizon's own).
fn translucent_split(batching: bool, flat_draws: bool, terrain: bool, shadow: bool) -> bool {
batching && !(flat_draws && (terrain || shadow))
}
mod animate;
mod arena;
pub mod compositor;
@@ -109,7 +134,7 @@ mod pack_post;
mod pack_shadow;
mod pack_sky;
mod pack_textures;
mod pack_timing;
pub(crate) mod pack_timing;
pub mod pack_uniforms;
mod pack_weather;
mod page_runs;
@@ -144,9 +169,12 @@ fn world_pass_after_culling<'e>(
view: &wgpu::TextureView,
depth: &wgpu::TextureView,
keep_depth: bool,
timing: Option<&mut pack_timing::PassTiming>,
name: &str,
) -> wgpu::RenderPass<'e> {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon world, after culling"),
timestamp_writes: timing.and_then(|t| t.writes(name)),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
@@ -258,9 +286,15 @@ struct MirrorTarget {
depth: wgpu::TextureView,
}
struct SectionGpu {
/// Arena page and first vertex in it: solid quads, then translucent ones.
/// Arena page and first vertex of its solid quads, and after them its
/// translucent quads unless those have an arena of their own; (0, 0),
/// unused, when that leaves it no quads in `arena`.
page: u32,
start: u64,
/// The page and first vertex of its translucent quads: in
/// `translucent_arena` when there is one, else after the solid quads.
/// Unused without translucent quads.
translucent_at: (u32, u64),
solid: u32,
/// Where the solid quads' buckets by facing side end (`mesher::Sides`).
sides: Sides,
@@ -306,6 +340,61 @@ impl SectionGpu {
.unwrap_or(0),
}
}
/// Its vertices in `arena`, and in `translucent_arena` when `split` (there
/// is one): (page, first vertex, vertices) of each.
fn vertex_ranges(&self, split: bool) -> (Option<(u32, u64, u64)>, Option<(u32, u64, u64)>) {
if !split {
let vertices = u64::from(self.solid + self.translucent) * 4;
return (Some((self.page, self.start, vertices)), None);
}
let (page, start) = self.translucent_at;
(
(self.solid > 0).then(|| (self.page, self.start, u64::from(self.solid) * 4)),
(self.translucent > 0).then(|| (page, start, u64::from(self.translucent) * 4)),
)
}
}
/// `Renderer::compact_arena`'s moves: the meshes' ranges on `page` of `arena`
/// go to its other pages in video memory, `budget` bytes of them at most, the
/// copies into `encoder` and the ranges' flat draw ids (`flat`) along. A mesh
/// moves what it holds in `arena`: split (`SectionGpu::vertex_ranges`), its
/// solid quads alone. 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)>,
meshes: impl Iterator<Item = &'a mut SectionGpu>,
page: u32,
split: bool,
mut budget: u64,
) -> bool {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
for s in meshes {
// Split, a mesh without solid quads has nothing here: its (0, 0) is no
// range of page 0.
let Some((_, start, vertices)) = s.vertex_ranges(split).0.filter(|r| r.0 == page) else {
continue;
};
if vertices * stride > 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) {
flat.remove(id);
s.flat_id = Some(flat.add(device, encoder, uploads, to.0, to.1, vertices));
}
(s.page, s.start) = to;
if !split {
// Its translucent quads came along, after the solid ones.
s.translucent_at = (to.0, to.1 + u64::from(s.solid) * 4);
}
budget -= vertices * stride;
}
true
}
/// Line segments the hit outline can hold.
const MAX_OUTLINE_EDGES: u64 = 256;
@@ -394,6 +483,9 @@ pub struct Frame<'a> {
pub nether_lighting: bool,
/// Camera-relative entity geometry textured from the shared atlas.
pub entities: &'a [Vertex],
/// Which entity `entities`' vertices belong to: (first vertex, entity id), each run up
/// to the next; id 0 for none. Frame generation follows these entities' own motion.
pub entity_motion: &'a [(u32, u64)],
/// The glowing entities' silhouettes in their team colours (`OutlineBufferSource`),
/// outlined through walls after the hand.
pub glowing: &'a [Vertex],
@@ -579,6 +671,7 @@ pub struct Renderer {
cloud_uniform: wgpu::Buffer,
cloud_group: wgpu::BindGroup,
additive_pipeline: wgpu::RenderPipeline,
dragon_rays_pipeline: wgpu::RenderPipeline,
/// `RenderPipelines.ENERGY_SWIRL`: additive, unlit, the sprite repeated within itself.
energy_swirl_pipeline: wgpu::RenderPipeline,
lightning_pipeline: wgpu::RenderPipeline,
@@ -671,6 +764,17 @@ pub struct Renderer {
/// Bumped whenever a column becomes or stops being complete.
detail_revision: u64,
arena: arena::Arena,
/// Translucent quads' own arena, with large pages, while the layout is split
/// (`translucent_split`): the back-to-front list then stays in one page (and
/// one sorted-index page) and draws in a few multi-draws rather than one per
/// few sections.
translucent_arena: Option<arena::Arena>,
/// Translucent Batching (`Graphics::translucent_batching`, read when a renderer
/// is made), where it is offered (`graphics::offer_translucent_batching`).
translucent_batching: bool,
/// Every section and far tile was dropped to change the layout since the game
/// last asked (`take_dropped_sections`).
sections_dropped: bool,
sorted_indices: arena::Arena,
translucent_sorter: translucency::Sorter,
translucency_cursor: usize,
@@ -689,7 +793,9 @@ pub struct Renderer {
/// `DrawIndexedIndirectArgs` words: index count, instance count, first
/// index, base vertex, first instance.
draws: Vec<[u32; 5]>,
/// Vertex arena page and optional sorted index arena page of each draw.
/// Vertex arena page and optional sorted index arena page of each draw:
/// with sorted indices (a translucent draw), a page of `translucent_arena`
/// when there is one.
draw_pages: Vec<(u32, Option<u32>)>,
/// Solid draws left to occlusion culling's phase 2, while the list is
/// built, and their pages.
@@ -733,6 +839,8 @@ pub struct Renderer {
native: Option<native_pack::NativePack>,
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
pass_timing: bool,
/// Times Horizon's own passes on the GPU, as `pass_timing` does (`own_pass_times`).
own_timing: Option<pack_timing::PassTiming>,
/// The entity vertices Horizon draws itself while a shader pack draws the others.
pack_entity_rest: Vec<Vertex>,
/// Set 1 of the cloud pipeline, for a shader pack's `gbuffers_clouds`.
@@ -1516,7 +1624,7 @@ impl Renderer {
}
levels
}
/// Bytes of the section vertex arena's buffers, and of the vertices
/// Bytes of the section vertex arenas' buffers, and of the vertices
/// resident sections hold in them.
pub fn section_vertex_bytes(&self) -> [u64; 2] {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
@@ -1526,7 +1634,9 @@ impl Renderer {
.chain(self.lods.values().map(|tile| &tile.mesh))
.map(|s| u64::from(s.solid + s.translucent) * 4)
.sum();
[self.arena.bytes(), vertices * stride]
let translucent = self.translucent_arena.as_ref();
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
[arenas, vertices * stride]
}
}
impl Renderer {
@@ -1547,6 +1657,8 @@ impl Renderer {
prepared: Option<PreparedTextures>,
previous: Option<&Self>,
api: GraphicsApi,
translucent_batching: bool,
shader_pack: bool,
) -> Result<Self> {
let Opened {
instance,
@@ -2720,6 +2832,20 @@ impl Renderer {
cache: None,
};
let additive_pipeline = device.create_render_pipeline(&additive_descriptor);
// `RenderPipelines.DRAGON_RAYS` uses DepthStencilState.DEFAULT:
// unlike eyes, a ray hides the faces and rays behind it.
let dragon_rays_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Dragon death rays"),
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..additive_descriptor.primitive
},
depth_stencil: Some(wgpu::DepthStencilState {
depth_write_enabled: Some(true),
..additive_descriptor.depth_stencil.clone().unwrap()
}),
..additive_descriptor.clone()
});
// HDR output's mark on lightning bolts: fs_hdr_mark over the bolts' own vertex layout,
// as hdr_mark_pipeline marks entities and particles (only alpha written).
let mark_targets = [Some(wgpu::ColorTargetState {
@@ -3106,7 +3232,6 @@ impl Renderer {
},
)
});
let sorted_indices = arena::Arena::indices(&device, 1 << 16, flat.is_some());
let multi_draw = device
.features()
.contains(wgpu::Features::INDIRECT_FIRST_INSTANCE)
@@ -3114,6 +3239,26 @@ impl Renderer {
.get_downlevel_capabilities()
.flags
.contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION);
let batchable = translucent_batchable(
&adapter.get_info(),
multi_draw,
flat.as_ref().map(|f| f.min_sections),
);
crate::graphics::offer_translucent_batching(batchable);
let translucent_batching = batchable && translucent_batching;
// A new renderer runs no shader pack: the game loads the picked one on its
// first world frame (`shader_pack`: the picked pack draws flat, a `shaders/`
// pack or one of Horizon's own with `[shadow]`), and the frames before it, the
// level loading screen's among them, keep the layout the sections upload in
// (`draw_frame`). It starts as that pack's, a `shaders/` pack taken to draw
// terrain: the pack loading keeps it, and only one whose terrain and shadow
// pass draw none meshes the sections again.
let (sorted_indices, translucent_arena) = arena::Arena::translucent_arenas(
&device,
std::mem::size_of::<TerrainVertex>() as u64,
flat.is_some(),
translucent_split(translucent_batching, flat.is_some(), shader_pack, false),
);
// Without indirect draws (OpenGL before 4.0 without indirect execution, or
// before 4.2 + GL_ARB_shader_draw_parameters without indirect first instance)
// even creating an indirect buffer fails; one draw per section reads
@@ -3233,6 +3378,7 @@ impl Renderer {
cloud_uniform,
cloud_group,
additive_pipeline,
dragon_rays_pipeline,
energy_swirl_pipeline,
lightning_pipeline,
lightning: lightning::Renderer::new(),
@@ -3297,6 +3443,9 @@ impl Renderer {
detail_columns: Default::default(),
detail_revision: 0,
arena,
translucent_arena,
translucent_batching,
sections_dropped: false,
sorted_indices,
translucent_sorter: translucency::Sorter::default(),
translucency_cursor: 0,
@@ -3330,6 +3479,7 @@ impl Renderer {
pack_post: None,
native: None,
pass_timing: pack_timing::PassTiming::logging(),
own_timing: None,
pack_entity_rest: Vec::new(),
cloud_bindings,
pack_weather: None,
@@ -3649,47 +3799,85 @@ impl Renderer {
self.index_quads = max.next_power_of_two();
self.indices = Self::quad_indices(&self.device, self.index_quads);
}
let vertices = (mesh.solid.len() + mesh.translucent.len()) as u64;
let encoder = self.section_copies.get_or_insert_with(|| {
self.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Section uploads"),
})
});
// With video and system memory both full, the section is not drawn.
let (page, start) = self.arena.alloc(&self.device, encoder, vertices)?;
// The solid quads, and the translucent ones after them unless they have
// an arena of their own. With video and system memory both full, the
// section is not drawn.
let split = self.translucent_arena.is_some();
let main = if split {
mesh.solid.len()
} else {
mesh.solid.len() + mesh.translucent.len()
} as u64;
let (page, start) = if main > 0 {
self.arena.alloc(&self.device, encoder, main)?
} else {
(0, 0)
};
let release_main = |arena: &mut arena::Arena| {
if main > 0 {
arena.release(page, start, main);
}
};
let translucent_vertices = mesh.translucent.len() as u64;
let translucent_at = match &mut self.translucent_arena {
Some(arena) if translucent > 0 => {
match arena.alloc(&self.device, encoder, translucent_vertices) {
Some(at) => at,
None => {
release_main(&mut self.arena);
return None;
}
}
}
_ => (page, start + u64::from(solid) * 4),
};
let sorted_indices = if translucent > 0 {
let indices =
self.sorted_indices
.alloc(&self.device, encoder, u64::from(translucent) * 6);
if indices.is_none() {
self.arena.release(page, start, vertices);
release_main(&mut self.arena);
if let Some(arena) = &mut self.translucent_arena {
arena.release(translucent_at.0, translucent_at.1, translucent_vertices);
}
return None;
}
indices
} else {
None
};
let flat_id = self.flat.as_mut().map(|f| {
// Flat draws name the blocks of its range in `arena`: split, its solid
// quads alone (no frame draws flat then: `translucent_split`).
let flat_id = self.flat.as_mut().filter(|_| main > 0).map(|f| {
f.add(
&self.device,
encoder,
&mut self.section_uploads,
page,
start,
vertices,
main,
)
});
let stride = std::mem::size_of::<TerrainVertex>() as u64;
for (offset, part) in [(0, &mesh.solid), (mesh.solid.len(), &mesh.translucent)] {
let translucent_arena = self.translucent_arena.as_ref().unwrap_or(&self.arena);
for (part, arena, (part_page, first)) in [
(&mesh.solid, &self.arena, (page, start)),
(&mesh.translucent, translucent_arena, translucent_at),
] {
if !part.is_empty() {
let bytes: &[u8] = bytemuck::cast_slice(part);
self.uploaded_bytes += bytes.len() as u64;
self.section_copy_bytes += bytes.len() as u64;
self.section_uploads.buffer(
encoder,
self.arena.buffer(page),
(start + offset as u64) * stride,
arena.buffer(part_page),
first * stride,
bytes,
);
}
@@ -3704,6 +3892,7 @@ impl Renderer {
Some(SectionGpu {
page,
start,
translucent_at,
solid,
sides: if mesh.sides.quads() == solid {
mesh.sides
@@ -3733,7 +3922,7 @@ impl Renderer {
self.section_uploads.finish(&encoder);
Some(encoder.finish())
}
/// Draws `count` section draws from `first` with the arena and section
/// Draws `count` section draws from `first` with the arenas and section
/// origins bound: one indirect multi-draw, or a loop without it.
/// Consecutive draws in one arena page share a vertex buffer binding,
/// keeping the list's order (translucent sections go back to front).
@@ -3755,7 +3944,12 @@ impl Renderer {
.iter()
.take_while(|&&p| p == (page, index_page))
.count();
pass.set_vertex_buffer(0, self.arena.buffer(page).slice(..));
// Translucent draws, and only they, have sorted indices.
let vertices = match &self.translucent_arena {
Some(arena) if index_page.is_some() => arena.buffer(page),
_ => self.arena.buffer(page),
};
pass.set_vertex_buffer(0, vertices.slice(..));
let indices = index_page.map_or(&self.indices, |p| self.sorted_indices.buffer(p));
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
if self.multi_draw {
@@ -3788,15 +3982,24 @@ impl Renderer {
self.sorted_indices
.release(page, start, u64::from(s.translucent) * 6);
}
self.arena
.release(s.page, s.start, u64::from(s.solid + s.translucent) * 4);
let (main, translucent) = s.vertex_ranges(self.translucent_arena.is_some());
if let Some((page, start, vertices)) = main {
self.arena.release(page, start, vertices);
}
if let (Some(arena), Some((page, start, vertices))) =
(&mut self.translucent_arena, translucent)
{
arena.release(page, start, vertices);
}
}
/// Video memory the arena keeps because fragmentation holds its pages
/// partly used (a render distance that went down, far tiles that were
/// replaced): moves the meshes off the sparsest page, a few MiB a frame,
/// and `Arena::trim` returns the page once it is empty. Frames with the
/// arena in system memory (video memory full) are slow, so this is what
/// keeps the terrain in video memory.
/// keeps the terrain in video memory. The section arena's alone: the
/// translucent arena and the sorted indices keep their pages
/// (`Arena::translucent`).
fn compact_arena(&mut self) {
// Copied per frame: a page empties over a few frames, with no hitch.
const BUDGET: u64 = 32 << 20;
@@ -3808,8 +4011,6 @@ impl Renderer {
let Some(page) = self.arena.closing() else {
return;
};
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let mut budget = BUDGET;
let encoder = self.section_copies.get_or_insert_with(|| {
self.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
@@ -3820,31 +4021,14 @@ impl Renderer {
.sections
.values_mut()
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
for s in meshes.filter(|s| s.page == page) {
let vertices = u64::from(s.solid + s.translucent) * 4;
if vertices * stride > budget {
break;
}
let Some(to) = self.arena.alloc_moved(vertices) else {
// The other pages have no room after all.
self.arena.reopen();
return;
};
self.arena.copy(encoder, (s.page, s.start), to, vertices);
self.arena.release(s.page, s.start, vertices);
if let (Some(flat), Some(id)) = (&mut self.flat, s.flat_id) {
flat.remove(id);
s.flat_id = Some(flat.add(
&self.device,
encoder,
&mut self.section_uploads,
to.0,
to.1,
vertices,
));
}
(s.page, s.start) = to;
budget -= vertices * stride;
let flat = self
.flat
.as_mut()
.map(|f| (f, &self.device, &mut self.section_uploads));
let split = self.translucent_arena.is_some();
if !move_off_page(&mut self.arena, encoder, flat, meshes, page, split, BUDGET) {
// The other pages have no room after all.
self.arena.reopen();
}
// With every mesh moved off, the page stays closed so this frame's
// uploads can't refill it: `trim` returns it and clears `closing`.
@@ -3893,6 +4077,9 @@ impl Renderer {
self.detail_columns.clear();
self.detail_revision += 1;
self.arena.clear();
if let Some(arena) = &mut self.translucent_arena {
arena.clear();
}
if let Some(flat) = &mut self.flat {
flat.clear();
}
@@ -3906,6 +4093,39 @@ impl Renderer {
self.sections.set_min_y(layout.min_y());
}
}
/// Lays the sections out as `translucent_split` says for the shader pack that
/// runs, before a world frame draws them. A change drops every section and far
/// tile, as a new renderer has none, and changes the sorted indices' arena with
/// them (large pages beside the translucent arena, else pages flat draws bind):
/// the game meshes them again (`take_dropped_sections`).
fn follow_translucent_layout(&mut self) {
let pack = self.pack_post.as_ref().and_then(|p| p.1.as_ref());
let split = translucent_split(
self.translucent_batching,
self.flat.is_some() && self.flat_on,
pack.is_some_and(pack_post::PackPost::has_terrain),
// One of Horizon's own packs with `[shadow]` draws from the same lists (`shadow_view`).
pack.is_some_and(pack_post::PackPost::has_shadow) || self.native_shadow(),
);
if split == self.translucent_arena.is_some() {
return;
}
self.sections_dropped |=
self.sections.len() > 0 || !self.lods.is_empty() || self.lod_pending.is_some();
self.clear_sections(self.layout);
(self.sorted_indices, self.translucent_arena) = arena::Arena::translucent_arenas(
&self.device,
std::mem::size_of::<TerrainVertex>() as u64,
self.flat.is_some(),
split,
);
}
/// Whether every section and far tile was dropped since the last call, to be
/// laid out again (`follow_translucent_layout`): the game meshes them again,
/// as for a new renderer.
pub fn take_dropped_sections(&mut self) -> bool {
std::mem::take(&mut self.sections_dropped)
}
/// A column whose every section has its mesh (`lod_detail_columns`).
fn complete_column(&self) -> SectionMask {
SectionMask::below(self.layout.sections())
@@ -4021,11 +4241,12 @@ impl Renderer {
let (page, start) = s.sorted_indices?;
Some((page, start, u64::from(s.translucent) * 6))
}));
self.arena.release_all(
released
.iter()
.map(|s| (s.page, s.start, u64::from(s.solid + s.translucent) * 4)),
);
let split = self.translucent_arena.is_some();
self.arena
.release_all(released.iter().filter_map(|s| s.vertex_ranges(split).0));
if let Some(arena) = &mut self.translucent_arena {
arena.release_all(released.iter().filter_map(|s| s.vertex_ranges(true).1));
}
}
/// Horizon's resolution scale (world pixels per window pixel) and FXAA.
/// Configures `surface` and, while HDR output is on, sends its HDR metadata
@@ -4413,6 +4634,14 @@ impl Renderer {
.map(pack_timing::PassTiming::medians)
.unwrap_or_default()
}
/// Each of Horizon's own passes with its median GPU time in milliseconds, while they are
/// timed (`set_pass_timing`).
pub fn own_pass_times(&self) -> Vec<(String, f32)> {
self.own_timing
.as_ref()
.map(pack_timing::PassTiming::medians)
.unwrap_or_default()
}
/// The macros of the GL extensions this device gives shader packs: subgroups, when it
/// has them.
pub fn define_pack_macros(&self, macros: &mut horizon_shaders::Macros) {
@@ -4583,8 +4812,13 @@ impl Renderer {
}
fn animate(&mut self, encoder: &mut wgpu::CommandEncoder, tick: u64, cull_inactive: bool) {
let active = cull_inactive.then_some(&self.active_animated_layers[..]);
self.animations
.animate(encoder, &mut self.uploads, tick, active);
self.animations.animate(
encoder,
&mut self.uploads,
tick,
active,
self.own_timing.as_mut(),
);
if let Some(atlas) = &self.pack_atlas {
atlas.copy(
encoder,
@@ -4689,8 +4923,18 @@ impl Renderer {
}
/// `checks`: the error scopes of a scaled screenshot's frame, checked before its PNG is written.
fn draw_frame(&mut self, frame: &Frame, checks: Option<GpuChecks>) -> Result<bool> {
// World frames alone follow the shader pack: the game loads the picked one
// before it draws one. Title frames draw no terrain and keep the layout
// through a pack dropped (Shader Options) or not loaded yet (`Renderer::new`),
// so the next world's sections, which the level loading screen uploads, stay.
if !frame.title {
self.follow_translucent_layout();
}
self.arena.trim();
self.compact_arena();
if let Some(arena) = &mut self.translucent_arena {
arena.trim();
}
self.sorted_indices.trim();
if let Some(flat) = &mut self.flat {
flat.trim(|page| self.arena.has_page(page));
@@ -4785,6 +5029,16 @@ impl Renderer {
label: Some("Horizon frame"),
})
});
// Horizon's own passes are timed like a shader pack's, from the frame's first pass.
if self.own_timing.is_some() != self.pass_timing {
self.own_timing = self
.pass_timing
.then(|| pack_timing::PassTiming::new(&self.device, &self.queue))
.flatten();
}
if let Some(t) = &mut self.own_timing {
t.begin(&self.device, &self.queue);
}
if self.animated_textures {
self.hud
.animate_sprites(&mut self.uploads, &mut encoder, frame.ticks);
@@ -5309,7 +5563,7 @@ impl Renderer {
bytemuck::cast_slice(indices),
);
}
let first = s.start + u64::from(s.solid) * 4;
let (page, first) = s.translucent_at;
self.translucent_draws.push((
v.distance,
i,
@@ -5320,7 +5574,7 @@ impl Renderer {
first as u32,
i as u32,
],
(s.page, Some(index_page)),
(page, Some(index_page)),
));
}
}
@@ -5347,7 +5601,7 @@ impl Renderer {
bytemuck::cast_slice(indices),
);
}
let first = s.start + u64::from(s.solid) * 4;
let (page, first) = s.translucent_at;
self.translucent_draws.push((
v.distance,
instance,
@@ -5358,7 +5612,7 @@ impl Renderer {
first as u32,
instance as u32,
],
(s.page, Some(index_page)),
(page, Some(index_page)),
));
}
}
@@ -5445,7 +5699,7 @@ impl Renderer {
let len = self.draws.len() - before;
self.shadow_runs.push(distance, s.page, before, len, [0; 3]);
if let Some((index_page, index_start)) = s.sorted_indices {
let start = s.start + u64::from(s.solid) * 4;
let (page, start) = s.translucent_at;
shadow_translucent.push((
distance,
[
@@ -5455,7 +5709,7 @@ impl Renderer {
start as u32,
instance,
],
(s.page, Some(index_page)),
(page, Some(index_page)),
));
}
}
@@ -5781,6 +6035,8 @@ impl Renderer {
|| pack_terrain
|| shadow_view.is_some()) =>
{
// They read translucent quads in the section arena (`translucent_split`).
debug_assert!(self.translucent_arena.is_none(), "flat draws, split");
let (sections, retiring) = (&self.sections, &self.lod_retiring);
let lods = &self.lods;
let sorted = &self.sorted_indices;
@@ -5812,6 +6068,7 @@ impl Renderer {
Some((id, lod::instance(v.origin, v.fade)))
})),
|page| sorted.buffer(page).clone(),
self.own_timing.as_mut(),
)
})
}
@@ -6013,10 +6270,11 @@ impl Renderer {
&& let Some(native) = &self.native
&& let Some(shadow) = &native.shadow
{
let mut pass = native
.frame
.shadow_map
.begin(&mut encoder, "Native shader pack shadow");
let mut pass = native.frame.shadow_map.begin(
&mut encoder,
"Native shader pack shadow",
self.own_timing.as_mut(),
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
if flat_frame
@@ -6035,10 +6293,11 @@ impl Renderer {
self.draw_sections(&mut pass, &self.indirect, 0, shadow_first, shadow_solid);
}
drop(pass);
let mut pass = native
.frame
.shadow_map
.begin_translucent(&mut encoder, "Native shader pack translucent shadow");
let mut pass = native.frame.shadow_map.begin_translucent(
&mut encoder,
"Native shader pack translucent shadow",
self.own_timing.as_mut(),
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
if flat_frame
@@ -6179,6 +6438,10 @@ impl Renderer {
);
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Water mirror"),
timestamp_writes: self
.own_timing
.as_mut()
.and_then(|t| t.writes("Water mirror")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &mirror.color,
resolve_target: None,
@@ -6269,6 +6532,7 @@ impl Renderer {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon world"),
timestamp_writes: self.own_timing.as_mut().and_then(|t| t.writes("World")),
color_attachments: &attachments,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: world_depth,
@@ -6355,13 +6619,15 @@ impl Renderer {
flat.draw(&mut pass, &flat.calls[..flat.phase1_calls], vertices);
cull.test(&mut pass);
drop(pass);
cull.test_pass(&mut encoder);
flat.flatten_phase2(&mut encoder);
cull.test_pass(&mut encoder, self.own_timing.as_mut());
flat.flatten_phase2(&mut encoder, self.own_timing.as_mut());
pass = world_pass_after_culling(
&mut encoder,
world_view,
world_depth,
keep_depth,
self.own_timing.as_mut(),
"World after culling",
);
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
@@ -6386,12 +6652,14 @@ impl Renderer {
self.draw_sections(&mut pass, &self.indirect, 0, 0, phase1_draws);
cull.test(&mut pass);
drop(pass);
cull.test_pass(&mut encoder);
cull.test_pass(&mut encoder, self.own_timing.as_mut());
pass = world_pass_after_culling(
&mut encoder,
world_view,
world_depth,
keep_depth,
self.own_timing.as_mut(),
"World after culling",
);
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
@@ -6509,10 +6777,23 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
if additive_quads > 0 && !oit_on {
pass.set_pipeline(&self.additive_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..additive_quads * 6, (world_quads * 4) as i32, 0..1);
let mut first = 0;
for run in frame.additive.as_chunks::<4>().0.chunk_by(|a, b| {
(a[0].layer & 0xffff == DRAGON_RAYS_OVERLAY)
== (b[0].layer & 0xffff == DRAGON_RAYS_OVERLAY)
}) {
let rays = run[0][0].layer & 0xffff == DRAGON_RAYS_OVERLAY;
pass.set_pipeline(if rays {
&self.dragon_rays_pipeline
} else {
&self.additive_pipeline
});
let end = first + run.len() as u32;
pass.draw_indexed(first * 6..end * 6, (world_quads * 4) as i32, 0..1);
first = end;
}
if self.hdr.is_some() {
// The dragon's eyes and death rays, marked as entities are.
pass.set_pipeline(&self.hdr_mark_pipeline);
@@ -6554,7 +6835,14 @@ impl Renderer {
color.size(),
);
}
pass = world_pass_after_culling(&mut encoder, world_view, world_depth, keep_depth);
pass = world_pass_after_culling(
&mut encoder,
world_view,
world_depth,
keep_depth,
self.own_timing.as_mut(),
"World after copy",
);
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
}
@@ -6682,6 +6970,8 @@ impl Renderer {
clouds: (cloud_quads > 0).then_some(&clouds as _),
features: Some(&features),
};
// The draw closure below borrows all of `self`, so the timer is taken out.
let mut own_timing = self.own_timing.take();
oit.run(
&mut encoder,
world_view,
@@ -6709,8 +6999,17 @@ impl Renderer {
);
},
&extras,
own_timing.as_mut(),
);
self.own_timing = own_timing;
pass = world_pass_after_culling(
&mut encoder,
world_view,
world_depth,
true,
self.own_timing.as_mut(),
"World after OIT",
);
pass = world_pass_after_culling(&mut encoder, world_view, world_depth, true);
pass.set_bind_group(0, &self.group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
// HDR output's mark on what shines in the stages, as the classic
@@ -6971,8 +7270,12 @@ impl Renderer {
native.timing.as_mut(),
);
} else if scaled.is_some() {
self.world_post
.finish(&self.queue, &mut encoder, &view_target);
self.world_post.finish(
&self.queue,
&mut encoder,
&view_target,
self.own_timing.as_mut(),
);
}
if !frame.title {
let (depth, depth_size) = match &scaled {
@@ -6999,7 +7302,7 @@ impl Renderer {
});
}
if let Some(hdr) = &self.hdr {
hdr.keep_world(&mut encoder);
hdr.keep_world(&mut encoder, self.own_timing.as_mut());
}
if hand_quads > 0 && !pack_draws_hand {
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -7013,6 +7316,7 @@ impl Renderer {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("First-person hand"),
timestamp_writes: self.own_timing.as_mut().and_then(|t| t.writes("Hand")),
color_attachments: &attachments,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth,
@@ -7035,7 +7339,7 @@ impl Renderer {
);
drop(pass);
if let Some(hdr) = &self.hdr {
hdr.mark_hand(&mut encoder);
hdr.mark_hand(&mut encoder, self.own_timing.as_mut());
}
}
// GameRenderer.render: LevelRenderer.blitEntityOutline after the level and the hand.
@@ -7044,6 +7348,7 @@ impl Renderer {
&view_target,
&self.entity_group,
&self.indices,
self.own_timing.as_mut(),
);
// The world icon grabs here: after the level and the hand, before
// the effects and the HUD draw over them (`tryTakeScreenshotIfNeeded`
@@ -7072,6 +7377,7 @@ impl Renderer {
// The scaled world pass drew its own depth buffer.
scaled.is_none().then_some(&self.depth),
&frame.post_effects,
self.own_timing.as_mut(),
);
}
if !frame.screen.is_empty() {
@@ -7086,6 +7392,10 @@ impl Renderer {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Screen effects"),
timestamp_writes: self
.own_timing
.as_mut()
.and_then(|t| t.writes("Screen effects")),
color_attachments: &attachments,
..Default::default()
});
@@ -7115,6 +7425,7 @@ impl Renderer {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("3d crosshair"),
timestamp_writes: self.own_timing.as_mut().and_then(|t| t.writes("Crosshair")),
color_attachments: &attachments,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth,
@@ -7143,6 +7454,7 @@ impl Renderer {
frame.hud,
[self.config.width, self.config.height],
0..blur_at,
self.own_timing.as_mut(),
);
if let Some(radius) = menu_blur {
let format = self.config.format;
@@ -7157,6 +7469,7 @@ impl Renderer {
&view_target,
[self.config.width, self.config.height],
radius,
self.own_timing.as_mut(),
);
}
self.hud.draw_range(
@@ -7166,6 +7479,7 @@ impl Renderer {
frame.hud,
[self.config.width, self.config.height],
blur_at..frame.hud.vertices.len() as u32,
self.own_timing.as_mut(),
);
if culling && let Some(cull) = &mut self.cull {
cull.end(&self.device, &mut encoder);
@@ -7192,7 +7506,13 @@ impl Renderer {
.present(&self.device, &mut encoder, &f.texture);
}
if let (Some(hdr), Some(f)) = (&self.hdr, &surface_frame) {
hdr.present(&self.queue, &mut encoder, &f.texture, !frame.title);
hdr.present(
&self.queue,
&mut encoder,
&f.texture,
!frame.title,
self.own_timing.as_mut(),
);
}
// With HDR on, a full-frame screenshot gets an HDR10 copy beside it.
let hdr_readback = readback
@@ -7221,6 +7541,9 @@ impl Renderer {
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
if let Some(timer) = &mut self.own_timing {
timer.submitted(&self.queue);
}
let failed = checks.and_then(GpuChecks::pop);
if let Some((request, buffer)) = readback {
// Out of memory on the way: the PNG would be black; taken again at half the size.
@@ -7707,6 +8030,42 @@ mod tests {
assert!(!super::gpu_culling_requested(&discrete, Some("0")));
}
/// Translucent Batching is offered with indirect multi-draws that aren't OpenGL's,
/// and only where frames without a shader pack never draw flat.
#[test]
fn translucent_batching_is_offered_where_vanilla_frames_never_draw_flat() {
let vulkan = adapter(wgpu::DeviceType::DiscreteGpu);
let mut gl = adapter(wgpu::DeviceType::DiscreteGpu);
gl.backend = wgpu::Backend::Gl;
let batchable = super::translucent_batchable;
// No flat draws, or flat draws for a shader pack's terrain alone.
assert!(batchable(&vulkan, true, None));
assert!(batchable(&vulkan, true, Some(usize::MAX)));
// Vanilla frames drawn flat past MIN_SECTIONS (Metal), or all of them.
assert!(!batchable(&vulkan, true, Some(super::flat::MIN_SECTIONS)));
assert!(!batchable(&vulkan, true, Some(0)));
assert!(!batchable(&vulkan, false, None));
assert!(!batchable(&gl, true, None));
assert!(!batchable(&gl, true, Some(usize::MAX)));
}
/// The pack-flat rule: with Translucent Batching translucent quads have an arena of
/// their own, unless a shader pack's terrain programs or its shadow pass alone can
/// draw flat (a pack picked and not loaded yet counts as drawing terrain).
#[test]
fn translucent_quads_stay_in_the_section_arena_while_a_pack_can_draw_flat() {
// (batching, flat draws, the pack's terrain, its shadow pass)
let split = super::translucent_split;
assert!(split(true, true, false, false));
assert!(!split(true, true, true, false));
assert!(!split(true, true, false, true));
assert!(!split(true, true, true, true));
// Without flat draws, or with them off, the pack draws per section, from either.
assert!(split(true, false, true, true));
assert!(!split(false, true, false, false));
assert!(!split(false, false, false, false));
}
#[test]
fn the_mirror_sees_each_point_where_its_reflection_is() {
use glam::{Mat4, Vec3, Vec4};
@@ -299,6 +299,7 @@ impl SpriteAnimator {
uploads: &mut Uploads,
tick: u64,
active: Option<&[u64]>,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
if self.collect(tick, active) == 0 {
return;
@@ -341,7 +342,7 @@ impl SpriteAnimator {
}
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Animated sprites"),
timestamp_writes: None,
timestamp_writes: timing.and_then(|t| t.compute_writes("Animated sprites")),
});
pass.set_pipeline(pipeline);
for group in &mut self.groups {
@@ -859,7 +860,7 @@ mod tests {
for tick in [0, 0, 1, 2, 3, 4, 7, 7, 12, 30] {
let mut encoder = device.create_command_encoder(&Default::default());
let shown: Vec<_> = animator.sprites.iter().map(|s| s.shown).collect();
animator.animate(&mut encoder, &mut uploads, tick, None);
animator.animate(&mut encoder, &mut uploads, tick, None, None);
writes.push(
animator
.sprites
+306 -18
View File
@@ -1,12 +1,21 @@
//! One vertex buffer for every terrain section: sections own ranges of it, so
//! a frame binds it once and draws every section with indirect multi-draw.
//! Translucent quads can have an arena of their own (`Arena::translucent`),
//! with pages up to `LARGE_PAGE_BYTES`, and their sorted indices too
//! (`Arena::translucent_indices`): their back-to-front list then stays in one
//! page of each and draws in a few multi-draws.
use std::collections::BTreeMap;
/// Ranges are rounded up to this many vertices to limit fragmentation, and
/// start at multiples of it (`flat` names sections by these blocks).
pub const GRAIN: u64 = 256;
/// The largest page, in bytes.
/// The largest page of the section arena, and of arenas shaders bind whole
/// as storage, in bytes.
pub const PAGE_BYTES: u64 = 256 << 20;
/// The largest page of an arena no shader binds as storage: under 2 GiB, where
/// DirectX 12 and OpenGL cap buffers (`i32::MAX`), with room for the bytes
/// wgpu-core adds to vertex buffers; the device's own limit still applies.
pub const LARGE_PAGE_BYTES: u64 = (1 << 31) - (1 << 20);
/// Free vertex ranges: start -> length, never adjacent.
#[derive(Debug, PartialEq)]
@@ -118,7 +127,7 @@ impl Arena {
} else {
wgpu::BufferUsages::VERTEX
};
Self::with_usage(device, stride, capacity, usage)
Self::with_usage(device, stride, capacity, usage, PAGE_BYTES)
}
/// Sorted translucent indices; `storage` when compute passes read them.
pub fn indices(device: &wgpu::Device, capacity: u64, storage: bool) -> Self {
@@ -127,16 +136,54 @@ impl Arena {
} else {
wgpu::BufferUsages::INDEX
};
Self::with_usage(device, 4, capacity, usage)
Self::with_usage(device, 4, capacity, usage, PAGE_BYTES)
}
/// Translucent quads' vertices, which no shader reads as storage: pages
/// up to `LARGE_PAGE_BYTES`. The section arena's compaction never runs on
/// it (`Renderer::compact_arena`): a page this large has no others to
/// empty into, and the compaction moves sections' `page` ranges, not their
/// `translucent_at`.
pub fn translucent(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
let usage = wgpu::BufferUsages::VERTEX;
Self::with_usage(device, stride, capacity, usage, LARGE_PAGE_BYTES)
}
/// Sorted indices of the translucent arena's quads: pages up to
/// `LARGE_PAGE_BYTES` as its own, so its draws keep to one index page too.
pub fn translucent_indices(device: &wgpu::Device, capacity: u64) -> Self {
let usage = wgpu::BufferUsages::INDEX;
Self::with_usage(device, 4, capacity, usage, LARGE_PAGE_BYTES)
}
/// The sorted-index arena and, when `split`, the translucent arena
/// (vertices of `stride`), both with large pages, which no shader binds;
/// otherwise translucent quads stay in the section arena, and the indices
/// in pages flat draws (`flat`) bind as storage.
pub fn translucent_arenas(
device: &wgpu::Device,
stride: u64,
flat: bool,
split: bool,
) -> (Self, Option<Self>) {
if split {
let indices = Self::translucent_indices(device, 1 << 16);
(indices, Some(Self::translucent(device, stride, 1 << 18)))
} else {
(Self::indices(device, 1 << 16, flat), None)
}
}
fn with_usage(
device: &wgpu::Device,
stride: u64,
capacity: u64,
usage: wgpu::BufferUsages,
page_bytes: u64,
) -> Self {
// 256 MiB pages, or less where the device's buffers are smaller.
let bytes = device.limits().max_buffer_size.min(PAGE_BYTES);
// Pages of `page_bytes`, or less where the device's buffers (or the
// storage bindings that read them whole) are smaller.
let limits = device.limits();
let mut bytes = limits.max_buffer_size.min(page_bytes);
if usage.contains(wgpu::BufferUsages::STORAGE) {
bytes = bytes.min(limits.max_storage_buffer_binding_size);
}
let page_limit = (bytes / stride) / GRAIN * GRAIN;
let capacity = capacity.next_multiple_of(GRAIN).min(page_limit);
Self {
@@ -151,11 +198,16 @@ impl Arena {
video_memory_full: false,
}
}
/// A buffer of `capacity` vertices in video memory or, when that is full
/// and the device allows it, in system memory: Vulkan drivers fail an
/// allocation rather than page like OpenGL ones, and wgpu's default error
/// handler panics. `None` when neither has room.
fn create(&mut self, device: &wgpu::Device, capacity: u64) -> Option<(wgpu::Buffer, bool)> {
/// A buffer of `capacity` vertices in video memory or, when that is full,
/// `spill` allows it and so does the device, in system memory: Vulkan
/// drivers fail an allocation rather than page like OpenGL ones, and
/// wgpu's default error handler panics. `None` when neither has room.
fn create(
&mut self,
device: &wgpu::Device,
capacity: u64,
spill: bool,
) -> Option<(wgpu::Buffer, bool)> {
let usage = self.usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC;
#[cfg(test)]
let local = (!self.video_memory_full)
@@ -167,9 +219,10 @@ impl Arena {
local.or_else(|| {
// Mappable buffers are placed in host-visible memory, which the
// allocator takes from system memory once video memory is full.
if !device
.features()
.contains(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS)
if !spill
|| !device
.features()
.contains(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS)
{
return None;
}
@@ -234,7 +287,7 @@ impl Arena {
&& self.closing != Some(last as u32)
{
let current = page.capacity;
let grown = (current * 2).max(current + len).min(self.page_limit);
let grown = self.grown(current, len);
if (grown - current >= len || grown == self.page_limit)
&& self.grow(device, encoder, last, grown)
&& let Some(start) = self.pages[last].as_mut().unwrap().free.take(len)
@@ -250,9 +303,9 @@ impl Arena {
.max(len)
.min(self.page_limit);
// A page just large enough when memory is short.
let ((buffer, host), capacity) = match self.create(device, capacity) {
let ((buffer, host), capacity) = match self.create(device, capacity, true) {
Some(buffer) => (buffer, capacity),
None if capacity > len => (self.create(device, len)?, len),
None if capacity > len => (self.create(device, len, true)?, len),
None => return None,
};
let mut free = FreeList::new(capacity);
@@ -392,8 +445,17 @@ impl Arena {
}
}
}
/// The capacity the newest page grows to for a range of `len`: double, but
/// by at most `PAGE_BYTES` past that size, so a large page is never left
/// half empty (nor held twice during the copy) by hundreds of MiB.
fn grown(&self, current: u64, len: u64) -> u64 {
let step = current.min(PAGE_BYTES / self.stride);
(current + step).max(current + len).min(self.page_limit)
}
/// Moves a page into a larger buffer; false (the page unchanged) when
/// there is no memory for it.
/// there is no memory for it. A page past `PAGE_BYTES` grows in video
/// memory only: moving all of it to system memory would slow every frame
/// that draws it, so a new page takes the range instead (and may spill).
fn grow(
&mut self,
device: &wgpu::Device,
@@ -402,7 +464,8 @@ impl Arena {
capacity: u64,
) -> bool {
let capacity = capacity.next_multiple_of(GRAIN).min(self.page_limit);
let Some((buffer, host)) = self.create(device, capacity) else {
let spill = capacity * self.stride <= PAGE_BYTES;
let Some((buffer, host)) = self.create(device, capacity, spill) else {
return false;
};
let old = self.pages[page].as_mut().expect("live arena page");
@@ -474,6 +537,26 @@ mod tests {
.ok()
}
/// A device with the adapter's own limits, as the renderer opens it: page
/// caps past the default 256 MiB buffer limit show there.
fn device_at_limits() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.ok()
}
/// `device` and `device_at_limits`: the default limits bind 128 MiB of a storage
/// buffer, half their 256 MiB buffers, so storage pages shrink to the binding there;
/// the adapter's own let large pages past 256 MiB.
fn devices() -> Vec<(wgpu::Device, wgpu::Queue)> {
let devices = [device(), device_at_limits()];
devices.into_iter().flatten().collect()
}
/// `Arena::alloc`, with the copies of a grow submitted at once.
fn alloc(
arena: &mut Arena,
@@ -588,6 +671,119 @@ mod tests {
}
}
/// The renderer's half of that compaction (`move_off_page`) moves what each
/// section holds in the section arena, data and all. Split, that is its
/// solid quads alone, and nothing of a section without any, whose (0, 0)
/// names no range of page 0; else solid and translucent quads together,
/// `translucent_at` along.
#[test]
fn compaction_moves_what_sections_hold_in_the_section_arena() {
use super::super::{SectionGpu, move_off_page};
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
let section = |(page, start), translucent_at, solid, translucent| SectionGpu {
page,
start,
translucent_at,
solid,
sides: crate::mesher::Sides::unsorted(solid),
translucent,
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: None,
flat_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
};
for split in [true, false] {
let mut arena = Arena::new(&device, stride, 4 * GRAIN, false);
arena.page_limit = 4 * GRAIN;
// Page 0: two sections' grains and two free ones; page 1: one taken.
let (_, a) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
let (_, b) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
let (_, rest) = alloc(&mut arena, &device, &queue, 2 * GRAIN).unwrap();
assert_eq!(alloc(&mut arena, &device, &queue, GRAIN), Some((1, 0)));
arena.release(0, rest, 2 * GRAIN);
// Split, a grain of solid quads each (the first's translucent ones, and
// all of a water section's, in the translucent arena); else a grain of
// solid and translucent quads, and one of translucent quads alone.
let mut sections = if split {
vec![
section((0, a), (0, 0), 64, 64),
section((0, 0), (0, GRAIN), 0, 64),
section((0, b), (0, 0), 64, 0),
]
} else {
vec![
section((0, a), (0, a + 128), 32, 32),
section((0, b), (0, b), 0, 64),
]
};
// A range's first and last vertex hold its section's number, and the
// first translucent one of a mixed range that plus 100.
let write = |(page, at): (u32, u64), value: usize| {
let bytes = (value as u32).to_le_bytes();
queue.write_buffer(arena.buffer(page), at * stride, &bytes);
};
for (i, s) in sections.iter().enumerate() {
if let Some((page, start, len)) = s.vertex_ranges(split).0 {
write((page, start), i);
write((page, start + len - 1), i);
if !split && s.solid > 0 && s.translucent > 0 {
write(s.translucent_at, 100 + i);
}
}
}
let before: Vec<_> = sections
.iter()
.map(|s| (s.page, s.start, s.translucent_at))
.collect();
arena.close(0);
let mut encoder = device.create_command_encoder(&Default::default());
let meshes = sections.iter_mut();
assert!(move_off_page(
&mut arena,
&mut encoder,
None,
meshes,
0,
split,
u64::MAX
));
queue.submit([encoder.finish()]);
let word =
|(page, at): (u32, u64)| read(&device, &queue, arena.buffer(page), at * stride);
for (i, s) in sections.iter().enumerate() {
let Some((page, start, len)) = s.vertex_ranges(split).0 else {
assert_eq!(
(s.page, s.start, s.translucent_at),
before[i],
"split {split}"
);
continue;
};
assert_eq!(page, 1, "split {split}");
assert_eq!(word((page, start)), i as u32);
assert_eq!(word((page, start + len - 1)), i as u32);
if !split {
assert_eq!(s.translucent_at, (page, start + u64::from(s.solid) * 4));
if s.solid > 0 {
assert_eq!(word(s.translucent_at), 100 + i as u32);
}
}
}
// Page 0 empties without a range given twice, and page 1 took a grain
// per section moved: one is left.
arena.trim();
assert!(!arena.has_page(0), "split {split}");
assert!(arena.alloc_existing(GRAIN).is_some(), "split {split}");
assert_eq!(arena.alloc_existing(GRAIN), None, "split {split}");
}
}
#[test]
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
let Some((device, queue)) = device() else {
@@ -630,6 +826,96 @@ mod tests {
assert!(arena.pages.is_empty());
}
/// Every arena's pages stay within its cap and the device's limits: the
/// whole-page storage bindings of flat draws and shader packs included.
#[test]
fn pages_stay_within_their_caps_and_the_device_limits() {
let devices = devices();
if devices.is_empty() {
eprintln!("no GPU adapter: skipped");
return;
}
let bytes = |a: &Arena| a.page_vertices() * a.stride;
for (device, _) in &devices {
let limits = device.limits();
for storage in [false, true] {
for arena in [
Arena::new(device, 24, GRAIN, storage),
Arena::indices(device, GRAIN, storage),
] {
assert!(bytes(&arena) <= PAGE_BYTES.min(limits.max_buffer_size));
let binding = limits.max_storage_buffer_binding_size;
assert!(!storage || bytes(&arena) <= binding);
}
}
for large in [
Arena::translucent(device, 24, GRAIN),
Arena::translucent_indices(device, GRAIN),
] {
assert!(bytes(&large) <= LARGE_PAGE_BYTES.min(limits.max_buffer_size));
assert!(!large.usage.contains(wgpu::BufferUsages::STORAGE));
}
}
}
/// The translucent arena comes with the split layout alone, and only then
/// are the index pages large. Otherwise, as while a shader pack's terrain
/// draws flat (gfx's `translucent_split`), translucent quads keep to the
/// section arena, and flat draws (`flat`) bind the index pages as storage.
#[test]
fn translucent_arenas_follow_the_layout() {
let devices = devices();
if devices.is_empty() {
eprintln!("no GPU adapter: skipped");
return;
}
let mut exercised = false;
for (device, _) in &devices {
let limits = device.limits();
let large = limits.max_buffer_size > PAGE_BYTES;
exercised |= large;
for flat in [false, true] {
for split in [false, true] {
let case = format!("large {large}, flat {flat}, split {split}");
let (indices, translucent) = Arena::translucent_arenas(device, 24, flat, split);
assert_eq!(translucent.is_some(), split, "{case}");
let bytes = indices.page_vertices() * 4;
let storage = indices.usage.contains(wgpu::BufferUsages::STORAGE);
assert_eq!(storage, flat && !split, "{case}");
assert!(!storage || bytes <= limits.max_storage_buffer_binding_size);
assert_eq!(bytes > PAGE_BYTES, split && large, "{case}");
if let Some(t) = translucent {
assert_eq!(t.page_vertices() * 24 > PAGE_BYTES, large, "{case}");
}
}
}
}
if !exercised {
eprintln!("buffers end at PAGE_BYTES here: large pages not exercised");
}
}
/// Pages double up to `PAGE_BYTES`, then grow by `PAGE_BYTES` at most.
#[test]
fn large_pages_grow_by_bounded_steps() {
let Some((device, _)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::translucent(&device, 24, GRAIN);
// Exercise the policy past the test device's own buffer limit.
arena.page_limit = LARGE_PAGE_BYTES / 24 / GRAIN * GRAIN;
let step = PAGE_BYTES / 24;
assert_eq!(arena.grown(1 << 18, GRAIN), 1 << 19);
assert_eq!(arena.grown(step, GRAIN), 2 * step);
assert_eq!(arena.grown(3 * step, GRAIN), 4 * step);
assert_eq!(arena.grown(3 * step, step + GRAIN), 4 * step + GRAIN);
assert_eq!(
arena.grown(arena.page_limit - GRAIN, GRAIN),
arena.page_limit
);
}
#[test]
fn clearing_an_arena_releases_buffers_and_can_allocate_again() {
let Some((device, queue)) = device() else {
@@ -638,7 +924,9 @@ mod tests {
};
for mut arena in [
Arena::new(&device, 28, GRAIN, false),
Arena::translucent(&device, 24, GRAIN),
Arena::indices(&device, GRAIN, false),
Arena::translucent_indices(&device, GRAIN),
] {
alloc(&mut arena, &device, &queue, GRAIN).unwrap();
assert_eq!(arena.bytes(), arena.stride * GRAIN);
+6 -1
View File
@@ -528,7 +528,11 @@ impl Cull {
/// Between the phases (`InShader`): draw the tested boxes in a pass of
/// their own that reads phase 1's depth, bound by `bind_depth`.
pub fn test_pass(&self, encoder: &mut wgpu::CommandEncoder) {
pub fn test_pass(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let (Some(Group { group, .. }), Some(DepthGroup { view, group: depth })) =
(&self.group, &self.depth_group)
else {
@@ -539,6 +543,7 @@ impl Cull {
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Section culling"),
timestamp_writes: timing.and_then(|t| t.writes("Occlusion test")),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view,
@@ -20,6 +20,8 @@ const MATERIALS: usize = 10;
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
/// `breezeWind`). Only the improved transparency path reads it.
pub const OIT_ENTITY: u32 = 1 << 24;
/// `RenderPipelines.DRAGON_RAYS`: untextured colour, additive with depth writes.
pub const DRAGON_RAYS_OVERLAY: u32 = 37;
/// Tags quads as drawn by a render type vanilla gives an `OitPipelineSet`
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
pub fn mark_oit(quads: &mut [Vertex]) {
@@ -258,13 +258,20 @@ impl EntityOutline {
frame: &wgpu::TextureView,
entity_group: &wgpu::BindGroup,
indices: &wgpu::Buffer,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = self.targets.as_ref().filter(|_| self.quads > 0) else {
return;
};
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
{
let mut pass = begin(encoder, "Entity outline", &t.outline, clear);
let mut pass = begin(
encoder,
"Entity outline",
&t.outline,
clear,
timing.as_deref_mut(),
);
pass.set_pipeline(&self.silhouette);
pass.set_bind_group(0, entity_group, &[]);
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -280,7 +287,7 @@ impl EntityOutline {
("Entity outline blit", frame, wgpu::LoadOp::Load),
];
for (i, (label, view, load)) in steps.into_iter().enumerate() {
let mut pass = begin(encoder, label, view, load);
let mut pass = begin(encoder, label, view, load, timing.as_deref_mut());
pass.set_pipeline(&self.passes[i]);
pass.set_bind_group(0, &t.groups[i], &[]);
pass.draw(0..3, 0..1);
@@ -293,6 +300,7 @@ fn begin<'e>(
label: &str,
view: &wgpu::TextureView,
load: wgpu::LoadOp<wgpu::Color>,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> wgpu::RenderPass<'e> {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view,
@@ -305,6 +313,7 @@ fn begin<'e>(
})];
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes(label)),
color_attachments: &attachments,
..Default::default()
})
+8 -3
View File
@@ -490,6 +490,7 @@ impl Flat {
phase2: Option<&wgpu::Buffer>,
origins: impl Iterator<Item = (u32, [f32; 4])>,
sorted_page: impl Fn(u32) -> wgpu::Buffer,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> bool {
self.phase2_group = None;
let phase1_draws = if phase2.is_some() {
@@ -668,7 +669,7 @@ impl Flat {
.collect();
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Flatten section draws"),
timestamp_writes: None,
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
});
pass.set_pipeline(&self.flatten);
for (group, count) in &groups {
@@ -700,13 +701,17 @@ impl Flat {
true
}
/// After occlusion culling's test: copies the phase 2 draws it saw.
pub fn flatten_phase2(&self, encoder: &mut wgpu::CommandEncoder) {
pub fn flatten_phase2(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some((group, count)) = &self.phase2_group else {
return;
};
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Flatten phase 2 section draws"),
timestamp_writes: None,
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten phase 2")),
});
pass.set_pipeline(&self.flatten_seen);
pass.set_bind_group(0, group, &[]);
@@ -1,9 +1,11 @@
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
//! two real ones. So far this holds its optical flow and interpolation; the setting that
//! runs them comes next.
//! two real ones. So far this holds its optical flow, its interpolation and the motion
//! vectors of what moves on its own; the setting that runs them comes next.
// Unused until the renderer runs them.
#[allow(dead_code)]
mod interpolation;
#[allow(dead_code)]
mod motion;
#[allow(dead_code)]
mod optical_flow;
@@ -10,8 +10,8 @@
//! priorities and vectors) and fill the game field's holes from a pyramid; mask what each
//! frame can't see; blend the two frames along both fields; fill what is left from a
//! pyramid of the result. What differs from AMD's, all forced by WGSL or by Horizon:
//! the game's motion vectors are computed from the depth and `Inputs::reproject` (the
//! camera's, so what moves on its own relies on the optical flow); textures AMD updates
//! the game's motion vectors are the camera's, from the depth and `Inputs::reproject`,
//! except where `Inputs::object_motion` has an entity's own; textures AMD updates
//! with image atomics are storage buffers; SPD's single-pass pyramids are a pass per
//! level; and there is no HUD-less pass, as Horizon draws its HUD over the made frame.
@@ -33,6 +33,9 @@ pub struct Inputs<'a> {
/// `world_post::reprojection`): the motion vectors are the camera's, from this and
/// the depth.
pub reproject: Mat4,
/// What moved on its own: `motion::FORMAT` at `render_size`, xy its motion vector
/// (FSR's convention, in UV) where z is 1, used over the camera's.
pub object_motion: &'a wgpu::TextureView,
pub near: f32,
pub far: f32,
/// Vertical field of view, radians.
@@ -442,6 +445,7 @@ impl Interpolation {
&[
(0, params()),
(2, view(inputs.depth)),
(34, view(inputs.object_motion)),
(5, view(&r.dilated_depth)),
(6, view(&r.dilated_mv)),
(9, whole(&r.rec_prev_depth)),
@@ -644,10 +648,12 @@ mod tests {
/// Runs `frames` (pattern shifts) through the optical flow and the interpolation with
/// a camera that moves by `reproject` each frame, and returns the mean error of the
/// last made frame's interior against `expected`, and the last two real frames'.
/// `object`: every pixel's own motion vector (UV), as if all of it moved on its own.
fn interpolate(
seed: u32,
shifts: &[[i32; 2]],
reproject: Mat4,
object: Option<[f32; 2]>,
expected: [i32; 2],
) -> Option<[f32; 3]> {
let (device, queue) = of::device()?;
@@ -682,6 +688,35 @@ mod tests {
..Default::default()
});
queue.submit([encoder.finish()]);
let object_motion = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: super::super::motion::FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
if let Some(mv) = object {
let texel = [mv[0], mv[1], 1.0, 0.0].map(|v| half::f16::from_f32(v).to_bits());
let texels: Vec<u16> = (0..size[0] * size[1]).flat_map(|_| texel).collect();
queue.write_texture(
object_motion.as_image_copy(),
bytemuck::cast_slice(&texels),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(size[0] * 8),
rows_per_image: None,
},
object_motion.size(),
);
}
let object_motion = object_motion.create_view(&Default::default());
let mut flow = OpticalFlow::new(&device, size);
let mut interpolation = Interpolation::new(&device, size);
let frames: Vec<_> = shifts
@@ -731,6 +766,7 @@ mod tests {
render_size: size,
jitter: [0.0; 2],
reproject: if i == 0 { Mat4::IDENTITY } else { reproject },
object_motion: &object_motion,
near: 0.05,
far: 512.0,
fov_y: 1.2,
@@ -776,7 +812,8 @@ mod tests {
fn moves_what_the_optical_flow_follows_halfway() {
let mut shifts = vec![[0, 0]; 20];
shifts.push([16, 8]);
let Some([made, previous, current]) = interpolate(100, &shifts, Mat4::IDENTITY, [8, 4])
let Some([made, previous, current]) =
interpolate(100, &shifts, Mat4::IDENTITY, None, [8, 4])
else {
return;
};
@@ -795,7 +832,26 @@ mod tests {
// What is at a pixel now was 16 pixels to its right last frame.
let reproject = Mat4::from_translation(glam::Vec3::new(32.0 / size[0], 0.0, 0.0));
let shifts = [[0, 0], [-16, 0], [-32, 0], [-48, 0]];
let Some([made, previous, current]) = interpolate(1, &shifts, reproject, [-40, 0]) else {
let Some([made, previous, current]) = interpolate(1, &shifts, reproject, None, [-40, 0])
else {
return;
};
assert!(
made * 4.0 < previous.min(current),
"{made} against {previous} and {current}"
);
}
/// Something moving on its own with its own motion vectors (the camera still): the
/// made frame is halfway, as with the camera's.
#[test]
fn moves_what_has_its_own_motion_vectors_halfway() {
let size = [512.0, 288.0];
let shifts = [[0, 0], [16, 8], [32, 16], [48, 24]];
let mv = [-16.0 / size[0], -8.0 / size[1]];
let Some([made, previous, current]) =
interpolate(1, &shifts, Mat4::IDENTITY, Some(mv), [40, 20])
else {
return;
};
assert!(
@@ -24,7 +24,8 @@
// for Horizon, for an SDR back buffer, inverted finite depth, the whole screen as the
// interpolation rect and no distortion field. What differs from AMD's (interpolation.rs
// lists why):
// - the motion vectors come from the depth and the camera (`prepare`), already unjittered;
// - the motion vectors are the camera's, from the depth (`prepare`), already unjittered,
// or an entity's own where `object_motion` has one;
// - the vector fields and reconstructed depths are storage buffers of atomics;
// - both inpainting pyramids are a pass per level, and textures of their own;
// - the interpolation writes one texture and the inpainting another (rgba16float can't
@@ -85,6 +86,8 @@ struct Params {
@group(0) @binding(31) var interpolated_out: texture_storage_2d<rgba16float, write>;
@group(0) @binding(32) var interpolated: texture_2d<f32>;
@group(0) @binding(33) var output: texture_storage_2d<rgba16float, write>;
// What moved on its own (motion.wgsl): xy its motion vector where z is 1.
@group(0) @binding(34) var object_motion: texture_2d<f32>;
const EPSILON: f32 = 1e-03;
const RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD: f32 = EPSILON;
@@ -355,7 +358,11 @@ fn prepare_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
}
}
}
let mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
let object = textureLoad(object_motion, nearest_coord, 0);
var mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
if object.z > 0.5 {
mv = object.xy;
}
textureStore(dilated_depth_out, p, vec4(nearest, 0.0, 0.0, 0.0));
textureStore(dilated_mv_out, p, vec4(mv, 0.0, 0.0));
// ReconstructPrevDepth
@@ -0,0 +1,306 @@
//! Motion vectors for what moves on its own, for frame generation: the camera's motion
//! vectors (from the depth) see mobs and dropped items as still. The game tags each
//! entity's vertices with its id (`Frame::entity_motion`); an entity whose vertices have
//! the same shape as last frame's (the same count, UVs and layers) gets each vertex's
//! own motion, limbs and spin included, drawn into a texture at the render size
//! (`motion.wgsl`) that the interpolation reads before the camera's.
use crate::mesher::Vertex;
use glam::{DVec3, Mat4, Vec3};
use std::collections::HashMap;
/// `motion.wgsl`'s `Params`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
jittered: [[f32; 4]; 4],
current: [[f32; 4]; 4],
previous: [[f32; 4]; 4],
}
/// A vertex this frame and where it was last frame, both relative to this frame's camera.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct MotionVertex {
position: [f32; 3],
previous: [f32; 3],
uv: [f32; 2],
/// 1 for a translucent item, whose texture says where it shows.
translucent: u32,
}
pub struct ObjectMotion {
pipeline: wgpu::RenderPipeline,
atlas: wgpu::TextureView,
sampler: wgpu::Sampler,
params: wgpu::Buffer,
vertices: wgpu::Buffer,
/// Triangles of the entities matched this frame.
staged: Vec<MotionVertex>,
/// Last frame's vertices by entity, and its camera.
tracked: HashMap<u64, Vec<Vertex>>,
camera: DVec3,
target: Option<([u32; 2], wgpu::TextureView)>,
}
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
impl ObjectMotion {
/// `atlas`: the entities' texture atlas.
pub fn new(device: &wgpu::Device, atlas: wgpu::TextureView) -> Self {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Frame generation motion"),
source: wgpu::ShaderSource::Wgsl(include_str!("motion.wgsl").into()),
});
let attributes =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2, 3 => Uint32];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Frame generation motion"),
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs_motion"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<MotionVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_motion"),
compilation_options: Default::default(),
targets: &[Some(FORMAT.into())],
}),
multiview_mask: None,
cache: None,
});
Self {
pipeline,
atlas,
sampler: device.create_sampler(&Default::default()),
params: device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Frame generation motion"),
size: std::mem::size_of::<Params>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}),
vertices: vertex_buffer(device, 1 << 12),
staged: Vec::new(),
tracked: HashMap::new(),
camera: DVec3::ZERO,
target: None,
}
}
/// Forget last frame's entities: the next frame has no motion of its own.
pub fn reset(&mut self) {
self.tracked.clear();
}
/// Match this frame's `entities` (relative to `camera`) to last frame's by the ids
/// `spans` gives their vertices (start, id; 0 for none, each up to the next start), and
/// keep them for the next frame.
pub fn track(&mut self, entities: &[Vertex], spans: &[(u32, u64)], camera: DVec3) {
self.staged.clear();
let shift = (self.camera - camera).as_vec3();
let mut tracked = HashMap::with_capacity(self.tracked.len());
for (i, &(start, id)) in spans.iter().enumerate() {
let end = spans.get(i + 1).map_or(entities.len(), |s| s.0 as usize);
let Some(now) = entities
.get(start as usize..end)
.filter(|v| id != 0 && !v.is_empty())
else {
continue;
};
if let Some(then) = self.tracked.remove(&id)
&& then.len() == now.len()
&& then
.iter()
.zip(now)
.all(|(a, b)| a.uv == b.uv && a.layer == b.layer)
{
let pair = |i: usize| MotionVertex {
position: now[i].pos,
previous: (Vec3::from(then[i].pos) + shift).into(),
uv: now[i].uv,
translucent: u32::from(matches!(
now[i].layer,
super::super::entity_batch::TRANSLUCENT_ITEM_OVERLAY
| super::super::entity_batch::TRANSLUCENT_GLINT_ITEM_OVERLAY
)),
};
for q in (0..now.len() / 4).map(|q| q * 4) {
self.staged
.extend([q, q + 1, q + 2, q, q + 2, q + 3].map(pair));
}
}
tracked.insert(id, now.to_vec());
}
self.tracked = tracked;
self.camera = camera;
}
/// Draw the matched entities' motion vectors at `size` (the world's render size),
/// where `depth` (the world's) shows them; the rest of the texture is 0.
#[allow(clippy::too_many_arguments)]
pub fn draw(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
depth: &wgpu::TextureView,
size: [u32; 2],
jittered: Mat4,
current: Mat4,
previous: Mat4,
) -> wgpu::TextureView {
if self.target.as_ref().is_none_or(|t| t.0 != size) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Frame generation motion"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
self.target = Some((size, texture.create_view(&Default::default())));
}
let target = self.target.as_ref().expect("made above").1.clone();
let bytes: &[u8] = bytemuck::cast_slice(&self.staged);
if bytes.len() as u64 > self.vertices.size() {
self.vertices = vertex_buffer(device, (bytes.len() as u64).next_power_of_two());
}
if !bytes.is_empty() {
queue.write_buffer(&self.vertices, 0, bytes);
}
let params = Params {
jittered: jittered.to_cols_array_2d(),
current: current.to_cols_array_2d(),
previous: previous.to_cols_array_2d(),
};
queue.write_buffer(&self.params, 0, bytemuck::bytes_of(&params));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Frame generation motion"),
layout: &self.pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(depth),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&self.atlas),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Frame generation motion"),
color_attachments: &attachments,
..Default::default()
});
if !self.staged.is_empty() {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.set_vertex_buffer(0, self.vertices.slice(..bytes.len() as u64));
pass.draw(0..self.staged.len() as u32, 0..1);
}
drop(pass);
target
}
}
fn vertex_buffer(device: &wgpu::Device, size: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Frame generation motion vertices"),
size,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("motion.wgsl")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::empty(),
)
.validate(&module)
.unwrap();
}
fn quad(x: f32, uv: f32) -> Vec<Vertex> {
(0..4)
.map(|i| Vertex {
pos: [x + i as f32, 0.0, -5.0],
uv: [uv, 0.0],
..bytemuck::Zeroable::zeroed()
})
.collect()
}
/// An entity seen last frame gets its vertices' own motion, in this frame's camera
/// space; a new one, one whose shape changed and untagged geometry get none.
#[test]
fn tracks_each_entity_by_id() {
let Some((device, queue)) = super::super::optical_flow::tests::device() else {
return;
};
let atlas = super::super::optical_flow::tests::upload(&device, &queue, [1, 1], &[0; 4]);
let mut motion = ObjectMotion::new(&device, atlas);
let mut entities = quad(0.0, 0.0);
entities.extend(quad(10.0, 0.5));
motion.track(&entities, &[(0, 7), (4, 8)], DVec3::ZERO);
assert!(motion.staged.is_empty());
// Entity 7 moved 1 block right, the camera 2 blocks right; entity 8 became
// another item; 9 is new; then untagged geometry.
let mut entities = quad(-1.0, 0.0);
entities.extend(quad(10.0, 0.25));
entities.extend(quad(20.0, 0.0));
entities.extend(quad(30.0, 0.0));
motion.track(
&entities,
&[(0, 7), (4, 8), (8, 9), (12, 0)],
DVec3::new(2.0, 0.0, 0.0),
);
assert_eq!(motion.staged.len(), 6);
let v = motion.staged[0];
assert_eq!(v.position, [-1.0, 0.0, -5.0]);
assert_eq!(v.previous, [-2.0, 0.0, -5.0]);
assert_eq!(motion.tracked.len(), 3);
}
}
@@ -0,0 +1,58 @@
// Frame generation's motion vectors for what moves on its own (mobs, dropped items): each
// vertex drawn where the world pass drew it, with where it was last frame; only the pixels
// the world shows it at keep its vector: where the depth is its own, or, for translucent
// items (which don't write depth), where its texture shows and nothing is in front.
struct Params {
// The world's projection this frame (TAA's jitter in it), the same without the jitter,
// and last frame's projection from this frame's camera.
jittered: mat4x4<f32>,
current: mat4x4<f32>,
previous: mat4x4<f32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var depth: texture_depth_2d;
@group(0) @binding(2) var atlas: texture_2d<f32>;
@group(0) @binding(3) var atlas_sampler: sampler;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) current: vec4<f32>,
@location(1) previous: vec4<f32>,
@location(2) uv: vec2<f32>,
// 1: a translucent item.
@location(3) @interpolate(flat) translucent: u32,
};
@vertex fn vs_motion(@location(0) position: vec3<f32>, @location(1) previous: vec3<f32>,
@location(2) uv: vec2<f32>, @location(3) translucent: u32) -> Out {
var out: Out;
out.uv = uv;
out.translucent = translucent;
out.clip = params.jittered * vec4(position, 1.0);
out.current = params.current * vec4(position, 1.0);
out.previous = params.previous * vec4(previous, 1.0);
return out;
}
// xy: the motion vector in UV (to where the pixel was last frame, FSR's convention), z: 1.
@fragment fn fs_motion(in: Out) -> @location(0) vec4<f32> {
let stored = textureLoad(depth, vec2<i32>(in.clip.xy), 0);
let tolerance = stored * 1e-3 + 1e-7;
// Not what the world shows here: a transparent texel, or behind something.
if in.translucent != 0u {
let alpha = textureSampleLevel(atlas, atlas_sampler, in.uv, 0.0).a;
if alpha < 0.1 || in.clip.z < stored - tolerance {
discard;
}
} else if abs(in.clip.z - stored) > tolerance {
discard;
}
if in.previous.w <= 0.0 {
discard;
}
let now = in.current.xy / in.current.w;
let then = in.previous.xy / in.previous.w;
return vec4((then - now) * vec2(0.5, -0.5), 1.0, 0.0);
}
+17 -4
View File
@@ -366,7 +366,11 @@ impl Hdr {
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
/// on raises the alpha where it covers the world.
pub fn keep_world(&self, encoder: &mut wgpu::CommandEncoder) {
pub fn keep_world(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else {
return;
};
@@ -387,6 +391,7 @@ impl Hdr {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR UI mask"),
timestamp_writes: timing.and_then(|t| t.writes("HDR UI mask")),
color_attachments: &attachments,
..Default::default()
});
@@ -397,7 +402,11 @@ impl Hdr {
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
/// from the UI (UI Brightness).
pub fn mark_hand(&self, encoder: &mut wgpu::CommandEncoder) {
pub fn mark_hand(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else {
return;
};
@@ -413,6 +422,7 @@ impl Hdr {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR hand mark"),
timestamp_writes: timing.and_then(|t| t.writes("HDR hand mark")),
color_attachments: &attachments,
..Default::default()
});
@@ -434,6 +444,7 @@ impl Hdr {
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::Texture,
world: bool,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else {
return;
@@ -443,7 +454,7 @@ impl Hdr {
0,
bytemuck::cast_slice(&self.params(world, self.output == Output::Pq)),
);
Self::draw(encoder, &self.pipeline, &t.group, target);
Self::draw(encoder, &self.pipeline, &t.group, target, timing);
}
/// The frame `present` writes, as HDR10 (BT.2020, PQ, 10 bits a channel) whatever
@@ -494,7 +505,7 @@ impl Hdr {
0,
bytemuck::cast_slice(&self.params(world, true)),
);
Self::draw(encoder, &self.capture_pipeline, &group, &texture);
Self::draw(encoder, &self.capture_pipeline, &group, &texture, None);
Some(texture)
}
@@ -528,6 +539,7 @@ impl Hdr {
pipeline: &wgpu::RenderPipeline,
group: &wgpu::BindGroup,
target: &wgpu::Texture,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let view = target.create_view(&Default::default());
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -541,6 +553,7 @@ impl Hdr {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR"),
timestamp_writes: timing.and_then(|t| t.writes("HDR output")),
color_attachments: &attachments,
..Default::default()
});
+1 -1
View File
@@ -1,4 +1,4 @@
//! Far meshes share the terrain arena and passes, with their actual bounds
//! Far meshes share the terrain arenas and passes, with their actual bounds
//! rather than the 16³ boxes used by the near-section residency walk.
use super::{Frustum, Renderer, SectionGpu};
use crate::{
@@ -225,8 +225,9 @@ impl ShadowMap {
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> wgpu::RenderPass<'e> {
self.depth_pass(encoder, label, wgpu::LoadOp::Clear(1.0))
self.depth_pass(encoder, label, wgpu::LoadOp::Clear(1.0), timing)
}
/// A depth pass that adds to the map. Entities draw here, after the translucent map was
@@ -236,7 +237,7 @@ impl ShadowMap {
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
) -> wgpu::RenderPass<'e> {
self.depth_pass(encoder, label, wgpu::LoadOp::Load)
self.depth_pass(encoder, label, wgpu::LoadOp::Load, None)
}
fn depth_pass<'e>(
@@ -244,9 +245,11 @@ impl ShadowMap {
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
load: wgpu::LoadOp<f32>,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> wgpu::RenderPass<'e> {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes(label)),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.view,
@@ -266,6 +269,7 @@ impl ShadowMap {
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> wgpu::RenderPass<'e> {
encoder.copy_texture_to_texture(
self.depth.as_image_copy(),
@@ -274,6 +278,7 @@ impl ShadowMap {
);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes(label)),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.color_view,
resolve_target: None,
@@ -298,8 +303,8 @@ impl ShadowMap {
/// Clears the 1×1 map of a pack without `[shadow]` once.
pub fn clear_blank(&mut self, encoder: &mut wgpu::CommandEncoder) {
if std::mem::take(&mut self.blank_uncleared) {
drop(self.begin(encoder, "Native shader pack shadow map clear"));
drop(self.begin_translucent(encoder, "Native shader pack shadow colour clear"));
drop(self.begin(encoder, "Native shader pack shadow map clear", None));
drop(self.begin_translucent(encoder, "Native shader pack shadow colour clear", None));
}
}
}
+15 -3
View File
@@ -620,6 +620,7 @@ impl Oit {
depth: &wgpu::TextureView,
draw: &dyn Fn(&mut wgpu::RenderPass<'_>, Stage),
extras: &Extras<'_>,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else { return };
let attachment = |view, load| {
@@ -645,7 +646,7 @@ impl Oit {
let water_mask = extras.water_mask.zip(t.terrain_depth.as_ref());
let cloud_depth = extras.clouds.zip(t.cloud_depth.as_ref());
if let Some((mask, _)) = water_mask {
self.execute_water_mask(encoder, t, color, depth, mask);
self.execute_water_mask(encoder, t, color, depth, mask, timing.as_deref_mut());
}
let terrain_depth = water_mask.map_or(depth, |(_, d)| &d.view);
let [c0, c1] = &t.views.coefficients;
@@ -680,6 +681,7 @@ impl Oit {
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(label)),
color_attachments: &first,
depth_stencil_attachment: read_only(terrain_depth),
..Default::default()
@@ -692,6 +694,7 @@ impl Oit {
// the terrain's: the masks only hide water.
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("OIT features")),
color_attachments: &[attachment(
match stage {
Stage::DepthBounds => &t.views.bounds,
@@ -710,7 +713,7 @@ impl Oit {
features(&mut pass, stage);
}
if stage == Stage::DepthBounds {
self.execute_depth_bounds_cull(encoder, t, depth);
self.execute_depth_bounds_cull(encoder, t, depth, timing.as_deref_mut());
}
if let Some((clouds, cloud_depth)) = cloud_depth {
self.render_clouds(
@@ -720,16 +723,18 @@ impl Oit {
depth,
(label, group, &then),
(clouds, cloud_depth),
timing.as_deref_mut(),
);
}
if stage == Stage::DepthBounds
&& let Some((mask, _)) = water_mask
{
self.execute_water_mask(encoder, t, color, depth, mask);
self.execute_water_mask(encoder, t, color, depth, mask, timing.as_deref_mut());
}
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT composite"),
timestamp_writes: timing.and_then(|t| t.writes("OIT composite")),
color_attachments: &[attachment(color, wgpu::LoadOp::Load)],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
@@ -749,6 +754,7 @@ impl Oit {
/// `CloudRenderer.renderOit` for one stage. The bounds stage first copies
/// the world's depth (after the cull) and draws into it, so the later
/// stages, which only read it, keep each pixel's nearest cloud face.
#[allow(clippy::too_many_arguments)]
fn render_clouds(
&self,
encoder: &mut wgpu::CommandEncoder,
@@ -764,6 +770,7 @@ impl Oit {
&dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline),
&Depth,
),
timing: Option<&mut super::pack_timing::PassTiming>,
) {
if stage == Stage::DepthBounds {
encoder.copy_texture_to_texture(
@@ -774,6 +781,7 @@ impl Oit {
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes("OIT clouds")),
color_attachments: targets,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &cloud_depth.view,
@@ -798,6 +806,7 @@ impl Oit {
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
mask: &dyn Fn(&mut wgpu::RenderPass<'_>),
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(terrain) = &t.terrain_depth else {
return;
@@ -810,6 +819,7 @@ impl Oit {
// The mask pipeline writes depth only but has a colour target.
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT water mask"),
timestamp_writes: timing.and_then(|t| t.writes("OIT water mask")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: color,
resolve_target: None,
@@ -840,6 +850,7 @@ impl Oit {
encoder: &mut wgpu::CommandEncoder,
t: &Targets,
depth: &wgpu::TextureView,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
encoder.copy_texture_to_texture(
t.bounds.as_image_copy(),
@@ -852,6 +863,7 @@ impl Oit {
);
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT cull depth bounds"),
timestamp_writes: timing.and_then(|t| t.writes("OIT cull depth bounds")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &t.views.culled,
resolve_target: None,
@@ -217,7 +217,12 @@ impl Features {
&entity_module,
entities,
&entity_buffers,
kind("additive entities", "lightning", None, PLAIN),
kind(
"additive entities",
"lightning",
Some(wgpu::Face::Back),
PLAIN,
),
),
weather: set(
&entity_module,
@@ -3117,8 +3117,7 @@ impl PackPost {
/// What the shadow camera draws of the terrain this frame, when the pack draws it.
pub fn shadow_view(&self, frame: &super::Frame) -> Option<ShadowView> {
self.shadow_targets.as_ref()?;
if self.shadow_terrain.is_none() && self.shadow_water.is_none() {
if !self.has_shadow() {
return None;
}
let shadow = &self.uniforms.shadow;
@@ -3139,6 +3138,12 @@ impl PackPost {
self.terrain.is_some()
}
/// Whether the pack's shadow pass draws terrain (`shadow_view`).
pub fn has_shadow(&self) -> bool {
self.shadow_targets.is_some()
&& (self.shadow_terrain.is_some() || self.shadow_water.is_some())
}
/// Whether the pack draws the sky (disc, sunset, stars, void) itself.
/// What the world's background is when the pack draws the sky: colortex0's clear colour
/// (`None`: the fog colour, its default).
@@ -1,5 +1,6 @@
//! GPU time of each pass of a shader pack, of either kind: shown in the F3 overlay while it is
//! open, and logged every `REPORT` timed frames with `HORIZON_SHADER_PACK_TIMING=1`. One frame
//! GPU time of each pass of a shader pack, of either kind, and of Horizon's own passes: shown in
//! the F3 overlay while it is open, and logged every `REPORT` timed frames with
//! `HORIZON_SHADER_PACK_TIMING=1`. One frame
//! at a time is timed, and the CPU never waits for it: its timestamps are resolved in a later
//! frame, once the GPU has finished it (Metal resolves them without waiting for the passes that
//! write them), then read back when mapped. The next frame after that is timed again.
@@ -139,6 +140,12 @@ impl PassTiming {
Some(stamps(&self.queries, i))
}
/// Timestamp writes for the compute pass `name`, in a timed frame.
pub fn compute_writes(&mut self, name: &str) -> Option<wgpu::ComputePassTimestampWrites<'_>> {
let i = self.push(name)?;
Some(compute_stamps(&self.queries, i))
}
/// The index of the timed frame's next pass, `name`.
fn push(&mut self, name: &str) -> Option<u32> {
let i = self.names.len() as u32;
@@ -274,6 +281,15 @@ fn stamps(queries: &wgpu::QuerySet, i: u32) -> wgpu::RenderPassTimestampWrites<'
}
}
/// The compute pass's twin of `stamps`.
fn compute_stamps(queries: &wgpu::QuerySet, i: u32) -> wgpu::ComputePassTimestampWrites<'_> {
wgpu::ComputePassTimestampWrites {
query_set: queries,
beginning_of_pass_write_index: Some(2 * i),
end_of_pass_write_index: Some(2 * i + 1),
}
}
/// Each pass's time in milliseconds from its (beginning, end) timestamps, `None` for a pass
/// the GPU left unstamped. Passes overlap on tiled GPUs (a pass begins before the one before it
/// ends): a pass costs the time from the later of its beginning and the previous pass's end.
@@ -35,6 +35,16 @@ pub struct PackInputs {
/// `heldItemId`, `heldItemId2` (item.properties, 5.3) and `heldBlockLightValue(2)`.
pub held_items: [i32; 2],
pub held_light: [i32; 2],
/// `isRightHanded` (the Main Hand option) and `is_on_ground`.
pub right_handed: bool,
pub on_ground: bool,
/// The dimension type's lowest block (`bedrockLevel`), build height (`heightLimit`),
/// logical height, sky light and ceiling.
pub min_y: i32,
pub height: i32,
pub logical_height: i32,
pub has_skylight: bool,
pub has_ceiling: bool,
/// `isElytraFlying`: the player is fall flying.
pub elytra_flying: bool,
/// `cloudHeight`, see [`cloud_height`].
@@ -188,6 +198,13 @@ pub const NAMES: &[&str] = &[
"playerLookVector",
"playerBodyVector",
"firstPersonCamera",
"isRightHanded",
"is_on_ground",
"bedrockLevel",
"heightLimit",
"logicalHeightLimit",
"hasSkylight",
"hasCeiling",
"isElytraFlying",
"cloudHeight",
];
@@ -397,6 +414,13 @@ impl FrameUniforms {
"relativeEyePosition" => v3((frame.camera - frame.eye).as_vec3()),
"playerLookVector" | "playerBodyVector" => v3(look),
"firstPersonCamera" => Value::i(&[i32::from(frame.first_person)]),
"isRightHanded" => Value::i(&[i32::from(inputs.right_handed)]),
"is_on_ground" => Value::i(&[i32::from(inputs.on_ground)]),
"bedrockLevel" => Value::i(&[inputs.min_y]),
"heightLimit" => Value::i(&[inputs.height]),
"logicalHeightLimit" => Value::i(&[inputs.logical_height]),
"hasSkylight" => Value::i(&[i32::from(inputs.has_skylight)]),
"hasCeiling" => Value::i(&[i32::from(inputs.has_ceiling)]),
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
"sunPosition" => v3(sun),
@@ -437,14 +437,15 @@ impl Post {
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
screen: &wgpu::TextureView,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(target) = &self.target else { return };
let Some(taa) = target.taa.as_ref().filter(|_| self.taa_previous.is_some()) else {
self.finish_with(encoder, screen, &target.group);
self.finish_with(encoder, screen, &target.group, timing.as_deref_mut());
return;
};
let Some(res) = &self.taa_resources else {
self.finish_with(encoder, screen, &target.group);
self.finish_with(encoder, screen, &target.group, timing.as_deref_mut());
return;
};
let i = (self.taa_frame % 2) as usize;
@@ -460,6 +461,7 @@ impl Post {
})];
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()
});
@@ -467,7 +469,7 @@ impl Post {
pass.set_bind_group(0, &taa.resolve[i], &[]);
pass.draw(0..3, 0..1);
drop(pass);
self.finish_with(encoder, screen, &taa.show[i]);
self.finish_with(encoder, screen, &taa.show[i], timing);
}
/// Draw what `group` binds (`bind`) onto `screen`.
@@ -476,6 +478,7 @@ impl Post {
encoder: &mut wgpu::CommandEncoder,
screen: &wgpu::TextureView,
group: &wgpu::BindGroup,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: screen,
@@ -488,6 +491,7 @@ impl Post {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon post"),
timestamp_writes: timing.and_then(|t| t.writes("Post")),
color_attachments: &attachments,
..Default::default()
});
+25 -1
View File
@@ -325,6 +325,19 @@ pub fn hdr_offered() -> bool {
HDR_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
static TRANSLUCENT_BATCHING_OFFERED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// Whether the device can batch translucent quads (`Graphics::translucent_batching`): set when
/// the renderer starts; not without indirect multi-draw or on OpenGL, which loops over a
/// multi-draw's entries on the CPU, nor where vanilla frames draw flat (they read the section
/// arena's translucent quads).
pub fn offer_translucent_batching(on: bool) {
TRANSLUCENT_BATCHING_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
}
pub fn translucent_batching_offered() -> bool {
TRANSLUCENT_BATCHING_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
/// costs them more frame time than it is worth.
@@ -415,6 +428,11 @@ pub struct Graphics {
pub shadow_cache: bool,
/// The graphics API (Windows only; see `GraphicsApi`).
pub api: GraphicsApi,
/// Translucent quads in vertex buffers of their own (`gfx`'s translucent arena), so their
/// back-to-front draws go out in a few calls, for a little more video memory. Off by
/// default for now; read when a renderer is made (start, resource-pack reload), where it is
/// offered (`translucent_batching_offered`).
pub translucent_batching: bool,
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
/// ignored.
@@ -454,6 +472,7 @@ impl Default for Graphics {
battery: false,
shadow_cache: false,
api: GraphicsApi::Auto,
translucent_batching: false,
shader_pack: String::new(),
}
}
@@ -544,6 +563,7 @@ impl Graphics {
"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),
"translucentBatching" => g.translucent_batching = flag(g.translucent_batching),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
"overlay" => {
@@ -560,7 +580,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -600,6 +620,7 @@ impl Graphics {
bool_text(self.shadow_cache),
self.api.name(),
self.shader_pack,
bool_text(self.translucent_batching),
)
}
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
@@ -747,10 +768,13 @@ mod tests {
battery: true,
shadow_cache: true,
api: GraphicsApi::Dx12,
translucent_batching: true,
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
assert_eq!(Graphics::parse(""), Graphics::default());
// Translucent Batching is opt-in for now: only the file turns it on.
assert!(!Graphics::default().translucent_batching);
// 0 is Auto; anything else is a peak the slider reaches.
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
+4
View File
@@ -2017,9 +2017,11 @@ impl HudRenderer {
batch,
size,
0..batch.vertices.len() as u32,
None,
);
}
/// Draw part of the prepared batch, preserving its blend/depth runs.
#[allow(clippy::too_many_arguments)]
pub fn draw_range(
&self,
encoder: &mut wgpu::CommandEncoder,
@@ -2028,6 +2030,7 @@ impl HudRenderer {
batch: &HudBatch,
size: [u32; 2],
vertices: std::ops::Range<u32>,
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
if vertices.is_empty() || size.contains(&0) {
return;
@@ -2058,6 +2061,7 @@ impl HudRenderer {
};
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("HUD"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("HUD")),
color_attachments: &attachments,
depth_stencil_attachment: clip.map(|_| wgpu::RenderPassDepthStencilAttachment {
view: depth,
+240 -23
View File
@@ -494,7 +494,20 @@ struct Plan {
/// selection is rebuilt, without sorting the whole set.
by_level: [Vec<TileKey>; MAX_LEVEL as usize + 1],
elevation: i32,
camera: [i32; 2],
/// The squared distance within which no coarser tile stands in for
/// missing detail (`stands_in`): the seam with the near chunks waits
/// for its own tiles instead of showing broad boxes beside them.
near: i64,
/// The squared distance within which planned tiles load before the
/// coarse backdrop (`wanted_tiles`).
first: i64,
}
/// Chunks past the render distance where only planned tiles are drawn (two
/// past the last near chunk, `streaming::in_view_distance`), and where they
/// load first.
const SEAM_CHUNKS: i32 = 3;
const FIRST_CHUNKS: i32 = 4;
impl Plan {
#[cfg(test)]
fn new(camera: [i32; 2], detail: i32, distance: u32) -> Self {
@@ -518,6 +531,9 @@ impl Plan {
let size = 16 << level;
let mut result = Self {
elevation,
camera,
near: i64::from((detail + SEAM_CHUNKS) * 16).pow(2),
first: i64::from((detail + FIRST_CHUNKS) * 16).pow(2),
..Self::default()
};
for z in (camera[1] - range).div_euclid(size)..=(camera[1] + range).div_euclid(size) {
@@ -589,7 +605,7 @@ impl Plan {
if complete {
return true;
}
if ready.contains_key(&key) {
if ready.contains_key(&key) && self.stands_in(key) {
// Its mesh is clipped around every selected finer tile.
out.push(key);
return true;
@@ -602,6 +618,26 @@ impl Plan {
false
}
}
/// Whether a split tile may draw for its missing children: only away
/// from the near chunks, where its cells are small on screen.
fn stands_in(&self, key: TileKey) -> bool {
key.reach(self.camera) >= self.near
}
/// The coarse tile sampled to cover `leaf` while it loads: two levels
/// up (16 times fewer tiles, each as cheap as a leaf to sample), one
/// where that would reach the seam, none beside it.
fn backdrop(&self, leaf: TileKey) -> Option<TileKey> {
let mut backdrop = None;
let mut key = leaf;
for _ in 0..2 {
key = key.parent();
if !self.nodes.contains(&key) || !self.stands_in(key) {
break;
}
backdrop = Some(key);
}
backdrop
}
/// Whether chunk column (x, z) is drawn by the tile planned there (ready;
/// at level 0, also `filled`) rather than by a coarser ancestor standing
/// in for it, or is past the far terrain.
@@ -625,6 +661,47 @@ impl Plan {
self.split.contains(&key) || (ready.contains_key(&key) && (key.level > 0 || filled(key)))
}
}
/// The tiles to sample, `Wanted`'s order best first. First the planned
/// tiles at the seam with the near chunks, nearest first: coarse tiles
/// there cost a far tile's work for broad boxes that fine tiles replace
/// moments later. Then a coarse backdrop (`Plan::backdrop`) out to the
/// horizon, then every other planned tile nearest first, each replacing its
/// part of the backdrop: the horizon fills in early and sharpens from the
/// player outward, whichever way they look.
fn wanted_tiles(
plan: &Plan,
ready: &FxHashMap<TileKey, impl Sized>,
camera: [i32; 2],
) -> impl Iterator<Item = Wanted> {
let wanted = |class, key: TileKey| Wanted {
class,
distance: i64::from(key.distance(camera)),
x: key.x,
z: key.z,
level: key.level,
};
let mut backdrop = FxHashSet::default();
let mut tiles: Vec<_> = plan
.nodes
.iter()
.filter(|key| !plan.split.contains(key) && !ready.contains_key(key))
.map(|&key| {
if let Some(coarse) = plan.backdrop(key)
&& !ready.contains_key(&coarse)
{
backdrop.insert(coarse);
}
let first = key.reach(camera) < plan.first;
wanted(if first { SEAM } else { REST }, key)
})
.collect();
tiles.extend(backdrop.into_iter().map(|key| wanted(BACKDROP, key)));
tiles.into_iter()
}
/// `Wanted::class`es, best first.
const SEAM: u8 = 0;
const BACKDROP: u8 = 1;
const REST: u8 = 2;
/// Visited chunks' summaries (`Lod::saved`) and their reductions.
type Saved = FxHashMap<(i32, i32), Arc<[Column; 256]>>;
type Reduced = FxHashMap<(i32, i32), Arc<ReducedColumns>>;
@@ -791,9 +868,11 @@ impl Ord for MeshQueued {
.then(other.order.cmp(&self.order))
}
}
/// A tile the sampler should produce next, nearest first.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
/// A tile the sampler should produce next: by `class`
/// (`wanted_tiles`), then nearest first.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct Wanted {
class: u8,
distance: i64,
x: i32,
z: i32,
@@ -1411,14 +1490,15 @@ impl Lod {
let present = self.ready.contains_key(&key);
// Covered level-0 stand-ins complete their parent but are never drawn.
let ready = present && !(key.level == 0 && self.covered.contains(&(key.x, key.z)));
// Mirrors `Plan::select`: a ready split tile is a complete
// stand-in even while its children are still incomplete, so no
// second fallback is drawn above it.
// Mirrors `Plan::select`: a ready split tile away from the seam is a
// complete stand-in even while its children are still incomplete,
// so no second fallback is drawn above it.
let (complete, active) = if self.plan.split.contains(&key) {
let children = key.children().into_iter().all(|child| {
!self.plan.nodes.contains(&child)
|| self.complete.get(&child).copied().unwrap_or(false)
});
let ready = ready && self.plan.stands_in(key);
(children || ready, ready && !children)
} else {
(present, ready)
@@ -2183,11 +2263,10 @@ impl Lod {
z: wanted.z,
level: wanted.level,
};
// Final tiles only: a coarse tile under the near area is work
// that finer tiles replace moments later.
// Final tiles and the backdrop only (`wanted_tiles`).
if self.pending.contains(&key)
|| self.ready.contains_key(&key)
|| self.plan.split.contains(&key)
|| (self.plan.split.contains(&key) && wanted.class != BACKDROP)
|| !self.plan.nodes.contains(&key)
{
continue;
@@ -2203,17 +2282,8 @@ impl Lod {
}
fn rebuild_wanted(&mut self, camera: [i32; 2]) {
self.wanted.clear();
for &key in &self.plan.nodes {
if self.plan.split.contains(&key) || self.ready.contains_key(&key) {
continue;
}
self.wanted.push(std::cmp::Reverse(Wanted {
distance: i64::from(key.distance(camera)),
x: key.x,
z: key.z,
level: key.level,
}));
}
self.wanted
.extend(wanted_tiles(&self.plan, &self.ready, camera).map(std::cmp::Reverse));
}
#[cfg(test)]
@@ -2607,14 +2677,14 @@ mod tests {
(ox..ox + key.size()).contains(&(x * 16)) && (oz..oz + key.size()).contains(&(z * 16))
};
// 24 chunks out, now past the render distance: its planned tile was
// split before, so never sampled, and a root stands in for it.
// split before, so never sampled. The ready root reaches the seam, so
// it stands in for nothing: the column stays until its tile is in.
let column = (24, 0);
let mut selected = Vec::new();
for &root in &after.roots {
after.select(root, &ready, &mut selected);
}
let drawn = selected.iter().filter(|key| contains(key, column));
assert_eq!(drawn.map(|key| key.level).min(), Some(6));
assert!(!selected.iter().any(|key| contains(key, column)));
assert!(!after.replaces(column, &ready, |_| true));
let planned = TileKey {
x: 12,
@@ -3258,6 +3328,153 @@ mod tests {
Ok(())
}
#[test]
fn far_cells_past_render_distance_12_start_one_block_wide() {
// Leaves' cells: 1 block to 256 blocks out, 2 to 512 (Full); Reduced
// keeps 1 to the near terrain's edge and 2 to 256.
for (reduced, [one, two]) in [(false, [256, 512]), (true, [208, 256])] {
let plan = Plan::with_previous([8, 8], 12, 128, &Plan::default(), 0, reduced);
for key in plan.nodes.iter().filter(|key| !plan.split.contains(key)) {
let distance = key.distance([8, 8]);
if distance < one {
assert_eq!(key.size() / 16, 1, "{key:?} reduced {reduced}");
} else if distance < two {
assert!(key.size() / 16 <= 2, "{key:?} reduced {reduced}");
}
}
}
}
/// Loads a render distance 12 view one sample job at a time, in
/// `wanted_tiles`' order. Counts the jobs, and their density lattice
/// points (each job's cost), before every far column within two chunks
/// of the near terrain is drawn as finely as the plan wants it (one
/// block wide in Full): until then nothing is drawn there. And before
/// every far column is drawn, by its tile or a coarse stand-in: until
/// then the far terrain ends short of the horizon. Checks that no coarse
/// tile is ever drawn at the seam.
fn jobs_until_fine_seam(camera: [i32; 2], reduced: bool) -> ([(usize, i32); 2], usize) {
let (detail, distance) = (12, 128);
let center = (camera[0].div_euclid(16), camera[1].div_euclid(16));
let mut covered = FxHashSet::default();
for z in center.1 - detail..=center.1 + detail {
for x in center.0 - detail..=center.0 + detail {
if crate::streaming::in_view_distance(center, detail, x, z) {
covered.insert((x, z));
}
}
}
let plan = Plan::with_previous(camera, detail, distance, &Plan::default(), 0, reduced);
let leaf = |x: i32, z: i32| {
(0..=MAX_LEVEL)
.map(|level| TileKey {
x: x.div_euclid(16 << level),
z: z.div_euclid(16 << level),
level,
})
.find(|key| plan.nodes.contains(key) && !plan.split.contains(key))
};
let mut seam = Vec::new();
for z in (camera[1] - 256..camera[1] + 256).step_by(2) {
for x in (camera[0] - 256..camera[0] + 256).step_by(2) {
let chunk = (x.div_euclid(16), z.div_euclid(16));
let near = (-2..=2)
.any(|dz| (-2..=2).any(|dx| covered.contains(&(chunk.0 + dx, chunk.1 + dz))));
if !covered.contains(&chunk) && near {
seam.push((x, z, leaf(x, z).unwrap()));
}
}
}
let mut horizon = Vec::new();
for z in (camera[1] - 2048..camera[1] + 2048).step_by(64) {
for x in (camera[0] - 2048..camera[0] + 2048).step_by(64) {
if !covered.contains(&(x.div_euclid(16), z.div_euclid(16))) && leaf(x, z).is_some()
{
horizon.push((x, z));
}
}
}
let mut ready: FxHashMap<TileKey, ()> = FxHashMap::default();
for key in &plan.nodes {
if key.level == 0 && covered.contains(&(key.x, key.z)) {
ready.insert(*key, ());
}
}
let leaves = plan
.nodes
.iter()
.filter(|k| !plan.split.contains(k))
.count();
let (mut cost, mut fine, mut reached) = (0, None, None);
for jobs in 0.. {
let mut selected = Vec::new();
for &root in &plan.roots {
plan.select(root, &ready, &mut selected);
}
let active: FxHashSet<_> = selected.into_iter().collect();
let drawn = |x: i32, z: i32, levels: u8| {
(0..=levels).any(|level| {
active.contains(&TileKey {
x: x.div_euclid(16 << level),
z: z.div_euclid(16 << level),
level,
})
})
};
for &(x, z, leaf) in &seam {
assert!(drawn(x, z, leaf.level) || !drawn(x, z, MAX_LEVEL));
}
if fine.is_none() && seam.iter().all(|&(x, z, leaf)| drawn(x, z, leaf.level)) {
fine = Some((jobs, cost));
}
if reached.is_none() && horizon.iter().all(|&(x, z)| drawn(x, z, MAX_LEVEL)) {
reached = Some((jobs, cost));
}
if let (Some(fine), Some(reached)) = (fine, reached) {
return ([fine, reached], leaves);
}
let next = wanted_tiles(&plan, &ready, camera).min().unwrap();
let next = TileKey {
x: next.x,
z: next.z,
level: next.level,
};
// `lod_grid`'s density lattice: one point per max(stride, 4) blocks.
let stride = next.size() / 16;
let lattice = 16 * stride / stride.max(4) + 1;
cost += lattice * lattice;
ready.insert(next, ());
}
unreachable!()
}
#[test]
fn the_far_terrain_next_to_the_near_chunks_loads_first() {
// Measured: 270 to 301 jobs, all of them fine tiles (7500 to 8500
// lattice points). A coarse tile first costs up to 289 points; the
// order that filled a ring ahead first and refined broad tiles every
// other job took 54000 to 119000 for the seam in view alone.
for camera in [[8, 8], [40, -9], [-300, 517]] {
for reduced in [false, true] {
let ([(jobs, cost), _], _) = jobs_until_fine_seam(camera, reduced);
assert!(jobs < 320, "{jobs} jobs");
assert!(cost < 9000, "{cost} lattice points");
}
}
}
#[test]
fn a_coarse_backdrop_reaches_the_horizon_early() {
// Sampling only planned tiles, nearest first, reached the horizon
// with the last of ~3000 jobs in Full (500000 lattice points), ~1050
// in Reduced (200000): for minutes on a slow machine the far terrain
// ended in a hard edge. The backdrop adds ~230 coarse tiles (~80 in
// Reduced), about a tenth more work in all.
for camera in [[8, 8], [40, -9], [-300, 517]] {
for reduced in [false, true] {
let ([_, (jobs, cost)], leaves) = jobs_until_fine_seam(camera, reduced);
assert!(jobs < 1000 && jobs < leaves, "{jobs} jobs of {leaves}");
assert!(cost < 100_000, "{cost} lattice points");
}
}
}
#[test]
fn extra_sampling_threads_only_on_large_machines() {
assert_eq!([1, 2, 4, 8, 11].map(sampling_threads), [1, 1, 1, 4, 5]);
@@ -172,6 +172,7 @@ impl MenuBlur {
target: &wgpu::TextureView,
size: [u32; 2],
radius: f32,
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
self.ensure(device, size);
encoder.copy_texture_to_texture(
@@ -224,6 +225,7 @@ impl MenuBlur {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu blur pass"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Menu blur")),
color_attachments: &attachments,
..Default::default()
});
@@ -1264,6 +1264,8 @@ pub enum Page {
Online,
/// Splitscreen: the profile another player joins as (`split_profiles`).
SplitProfiles,
/// Splitscreen: enable joining on this computer.
SplitSetup,
}
#[derive(Clone, Debug, PartialEq)]
enum Act {
@@ -1526,6 +1528,8 @@ pub struct OptionsUi {
/// The list's rows as last laid out: each row's top and bottom (GUI
/// pixels), for the narration's row under the mouse.
row_bands: Vec<[f32; 2]>,
/// Other local players are joined: leave before turning splitscreen off.
pub split_joined: bool,
}
impl Default for OptionsUi {
fn default() -> Self {
@@ -1571,6 +1575,7 @@ impl Default for OptionsUi {
framebuffer: [1280, 720],
split_guest: false,
row_bands: Vec::new(),
split_joined: false,
}
}
}
@@ -1634,6 +1639,7 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
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",
_ => return None,
})
}
@@ -2166,6 +2172,7 @@ impl OptionsUi {
Page::Horizon => "horizon.pages.settings",
Page::Agent => "horizon.pages.agent",
Page::SplitProfiles => "horizon.pages.split_profiles",
Page::SplitSetup => "horizon.pages.split_setup",
}
}
fn scrollbar_x(&self, width: f32) -> f32 {
@@ -2255,6 +2262,14 @@ impl OptionsUi {
fn list_rows(&self, s: &Settings) -> Vec<Row> {
match self.page() {
Page::SplitSetup => vec![Row::Buttons(
if self.split_guest || self.split_joined {
inactive(self.extra_button(s, crate::extras::Item::Splitscreen))
} else {
self.extra_button(s, crate::extras::Item::Splitscreen)
},
None,
)],
Page::Extras => {
// Each section's switches two to a row, as vanilla's
// OptionsList.addSmall lays out an options screen.
@@ -2321,6 +2336,17 @@ impl OptionsUi {
open("horizon.pages.open.extras", Page::Extras),
Some(open("horizon.pages.open.mods", Page::Mods)),
),
Row::Buttons(
open(
"horizon.pages.open.split_setup",
if self.split_guest {
Page::SplitProfiles
} else {
Page::SplitSetup
},
),
None,
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
self.extra_button(s, crate::extras::Item::DiscordPresence),
@@ -6788,6 +6814,7 @@ mod tests {
Page::Controller,
Page::Extras,
Page::Mods,
Page::SplitSetup,
Page::Agent,
]
);
@@ -1024,6 +1024,7 @@ impl PostEffects {
size: [u32; 2],
depth: Option<&wgpu::TextureView>,
req: &Request,
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) -> bool {
self.use_root(req.root);
let mut applied = Vec::new();
@@ -1099,6 +1100,7 @@ impl PostEffects {
(&work.1, size),
0.0,
format,
timing.as_deref_mut(),
);
self.staging = Some(staging);
for id in &applied {
@@ -1113,6 +1115,7 @@ impl PostEffects {
depth,
req.menu_blur_radius,
format,
timing.as_deref_mut(),
);
self.chains.insert(id.clone(), Some(chain));
}
@@ -1126,6 +1129,7 @@ impl PostEffects {
(target, size),
0.0,
format,
timing,
);
self.blit = Some(blit);
self.work = Some(work);
@@ -1144,6 +1148,7 @@ impl PostEffects {
main_depth: Option<&wgpu::TextureView>,
menu_radius: f32,
format: wgpu::TextureFormat,
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
for pass in &mut chain.passes {
if let Some(translated) = &mut pass.translated {
@@ -1203,6 +1208,7 @@ impl PostEffects {
{
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Post effect depth clear"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Post clear")),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view,
@@ -1221,6 +1227,7 @@ impl PostEffects {
let c = t.clear.map(f64::from);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Post effect target clear"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Post clear")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &chain.textures[i - 1].1,
resolve_target: None,
@@ -1274,6 +1281,7 @@ impl PostEffects {
&inputs,
(view(pass.output), sizes[pass.output]),
format,
timing.as_deref_mut(),
);
continue;
}
@@ -1287,6 +1295,7 @@ impl PostEffects {
(view(pass.output), sizes[pass.output]),
menu_radius,
format,
timing.as_deref_mut(),
);
}
}
@@ -1303,6 +1312,7 @@ impl PostEffects {
inputs: &[(&wgpu::TextureView, [u32; 2])],
output: (&wgpu::TextureView, [u32; 2]),
format: wgpu::TextureFormat,
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
let Some(pipeline) = translated.pipelines.get(&format) else {
return;
@@ -1353,6 +1363,7 @@ impl PostEffects {
});
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Post effect pass"),
timestamp_writes: timing.and_then(|t| t.writes("Post effect")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: output.0,
resolve_target: None,
@@ -1381,6 +1392,7 @@ impl PostEffects {
output: (&wgpu::TextureView, [u32; 2]),
menu_radius: f32,
format: wgpu::TextureFormat,
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
// SamplerInfo: OutSize, then each input's size.
let info = [
@@ -1441,6 +1453,7 @@ impl PostEffects {
});
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Post effect pass"),
timestamp_writes: timing.and_then(|t| t.writes("Post effect")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: output.0,
resolve_target: None,
@@ -1715,6 +1728,7 @@ mod tests {
menu_blur_radius: 5.0,
globals: Globals::default(),
},
None,
);
let buffer = crate::gfx::copy_frame(device, &mut encoder, &frame, [8, 4]);
queue.submit([encoder.finish()]);
@@ -1845,6 +1859,7 @@ mod tests {
menu_blur_radius: 0.0,
globals: Globals::default(),
},
None,
));
let buffer = crate::gfx::copy_frame(device, &mut encoder, &frame, [8, 4]);
queue.submit([encoder.finish()]);
@@ -25,6 +25,9 @@ pub const UI: usize = 10;
/// sources: `clamp((int) sqrt(255), 2, 8)` streaming, the rest static.
pub const STATIC_CHANNELS: usize = 247;
pub const STREAMING_CHANNELS: usize = 8;
/// Static channels only sounds played to the listener (no position) may take
/// while the `uiSoundReserve` extra is on.
pub const HEARD_RESERVE: usize = 16;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PlayResult {
@@ -792,6 +795,9 @@ pub struct SoundEngine {
pub source_volumes: [f32; 11],
/// A `SoundEventListener` is registered.
pub listening: bool,
/// Static channels positional sounds leave free for the ones played to the
/// listener: 0 as vanilla, `HEARD_RESERVE` with the `uiSoundReserve` extra.
pub listener_reserve: usize,
pub notifications: Vec<Notification>,
}
fn handle_channel(h: &Handle) -> Option<ChannelId> {
@@ -819,6 +825,7 @@ impl SoundEngine {
limits: [STATIC_CHANNELS, STREAMING_CHANNELS],
source_volumes: [1.0; 11],
listening: false,
listener_reserve: 0,
notifications: Vec::new(),
}
}
@@ -1100,7 +1107,15 @@ impl SoundEngine {
let looping = instance.looping && instance.delay <= 0;
let streaming = sound.stream;
let pool = usize::from(streaming);
let channel = if self.used[pool] >= self.limits[pool] {
// With the `uiSoundReserve` extra, sounds out in the world leave a few static channels free, so a flood of
// them (a crater's worth of blocks breaking) can't stop the ones played to the listener.
let positional = !instance.relative && instance.attenuation != Attenuation::None;
let reserved = if positional && !streaming {
self.listener_reserve
} else {
0
};
let channel = if self.used[pool] + reserved >= self.limits[pool] {
None
} else {
backend.create(streaming)
@@ -1549,4 +1564,47 @@ mod tests {
assert_eq!(s.volume, 0.0);
assert!(!s.stopped);
}
#[test]
fn world_sounds_leave_channels_for_the_listener_only_with_the_extra() {
use test_backend::Null;
let mut registry = SoundRegistry::default();
registry.add_file(
"minecraft",
br#"{"block.stone.break": {"sounds": ["dig/stone1"]},
"ui.button.click": {"sounds": ["random/click"]}}"#,
&|_, _| true,
);
let random = || LegacyRandom::new(0);
let block = |i: usize| {
let at = [i as f64, 64.0, 0.0];
let source = crate::settings::sound_source("minecraft:block.stone.break");
SoundInstance::at(
"minecraft:block.stone.break",
source,
1.0,
1.0,
random(),
at,
)
};
let click = || SoundInstance::for_ui("minecraft:ui.button.click", 1.0, 0.25, random());
for reserve in [0, HEARD_RESERVE] {
let mut engine = SoundEngine::new(registry.clone());
engine.listener_reserve = reserve;
let mut backend = Null::default();
// Positional sounds take the static channels the reserve leaves; then their plays fail.
let started = (0..STATIC_CHANNELS)
.filter(|&i| {
engine.play_new(block(i), false, &mut backend).1 == PlayResult::Started
})
.count();
assert_eq!(started, STATIC_CHANNELS - reserve);
let heard = engine.play_new(click(), false, &mut backend).1;
assert_eq!(
heard == PlayResult::Started,
reserve > 0,
"reserve {reserve}"
);
}
}
}
+19 -6
View File
@@ -92,19 +92,29 @@ fn sprite_level(uv: vec2<f32>, layer: u32, lod: f32) -> vec4<f32> {
}
// texture_sampling.glsl's sampleNearest: the sprite's texels stay square pixels, blending only
// across the one screen pixel where two meet, so a linear (and anisotropic) sampler keeps them crisp.
// `tiled`: a distant tile's face takes its sprite again per block, so its gradients span many
// sprites while `uv` stays inside one (see `tile_uv` below).
fn sample_nearest(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: vec2<f32>,
texel_screen: vec2<f32>) -> vec4<f32> {
texel_screen: vec2<f32>, tiled: bool) -> vec4<f32> {
let texel_coords = uv / pixel;
let center = round(texel_coords) - 0.5;
var offset = texel_coords - center;
offset = (offset - 0.5) * pixel / max(texel_screen, vec2(1e-12)) + 0.5;
offset = clamp(offset, vec2(0.0), vec2(1.0));
return sprite_grad((center + offset) * pixel, layer, du, dv);
let at = (center + offset) * pixel;
if tiled {
// An anisotropic sampler lays its taps along those gradients, past the sprite's edge,
// where the clamped sampler reads that edge texel again: long streaks on the far tiles.
// Asking for the level the gradients give takes the one tap an anisotropy of 1 would.
let level = max(length(du), length(dv)) / min(pixel.x, pixel.y);
return sprite_level(at, layer, max(0.0, log2(level)));
}
return sprite_grad(at, layer, du, dv);
}
// sampleRGSS: four rotated-grid samples of the two nearest mip levels, faded in as the texels
// shrink below two screen pixels, over sample_nearest.
fn sample_rgss(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: vec2<f32>,
texel_screen: vec2<f32>) -> vec4<f32> {
texel_screen: vec2<f32>, tiled: bool) -> vec4<f32> {
let max_texel = max(texel_screen.x, texel_screen.y);
let min_pixel = min(pixel.x, pixel.y);
let blend = smoothstep(min_pixel, min_pixel * 2.0, max_texel);
@@ -124,7 +134,7 @@ fn sample_rgss(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: v
sum_high += sprite_level(p, layer, low + 1.0);
}
let rgss = mix(sum_low * 0.25, sum_high * 0.25, mip_blend);
return mix(sample_nearest(uv, layer, pixel, du, dv, texel_screen), rgss, blend);
return mix(sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled), rgss, blend);
}
// Distant terrain tiles (lod/mesh.rs) count their UVs in blocks past 1: each block takes the
// sprite again, as separate faces would under the clamped sampler. Sprite UVs in [0, 1] stay.
@@ -137,12 +147,15 @@ fn sprite(tiled: vec2<f32>, layer: u32) -> vec4<f32> {
let du = dpdx(tiled);
let dv = dpdy(tiled);
let uv = tile_uv(tiled);
// The wrap shifts a face's UVs by whole blocks, so half of one tells a tile's face from a
// block's (whose UVs stay inside its sprite).
let tiled_face = any(tiled - uv >= vec2(0.5));
let pixel = vec2(1.0 / globals.filtering.y);
let texel_screen = sqrt(du * du + dv * dv);
if u32(globals.filtering.x) == 1u {
return sample_rgss(uv, layer, pixel, du, dv, texel_screen);
return sample_rgss(uv, layer, pixel, du, dv, texel_screen, tiled_face);
}
return sample_nearest(uv, layer, pixel, du, dv, texel_screen);
return sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled_face);
}
struct TerrainOut {
+1
View File
@@ -16,6 +16,7 @@ horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
@@ -34,6 +34,8 @@
"horizon.pages.extras": "Horizon Extras",
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.agent": "AI Agent",
"horizon.pages.split_settings": "Splitscreen Settings",
"horizon.pages.split_setup": "Splitscreen",
"horizon.pages.split_profiles": "Who's playing?",
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
@@ -41,6 +43,7 @@
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
"horizon.pages.split_setup.tooltip": "Players on this computer: controllers can join your world.",
"horizon.pages.split_profiles.tooltip": "Who plays: a profile kept in this game folder.",
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
@@ -48,6 +51,7 @@
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
"horizon.pages.open.shader_packs": "Shader Packs...",
"horizon.pages.open.controller": "Controller Settings...",
"horizon.pages.open.split_setup": "Splitscreen...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "AI Agent...",
+1 -1
View File
@@ -478,7 +478,7 @@ pub fn blocks_inside(from: Vec3, to: Vec3) -> Vec<BlockPos> {
inside
}
/// `Projectile.lerpRotation`.
fn lerp_rotation(mut old: f32, new: f32) -> f32 {
pub fn lerp_rotation(mut old: f32, new: f32) -> f32 {
while new - old < -180.0 {
old -= 360.0;
}
@@ -445,6 +445,7 @@ pub const COMMANDS: &[Cmd] = &[
["horizon-ctl mcp"], &[], mcp: false, local: true),
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again")], mcp: true, local: false),
cmd!("gpu-passes", Scripting, "gpu-passes [on|off]", "time Horizon's own render passes on the GPU, shown in F3 and in stats and bench", "", ["horizon-ctl gpu-passes on"], &[opt("mode", Ty::Enum(&["on", "off"]), "on or off; without it, only reports")], mcp: false, local: false),
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
"The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). With raw, the reply also lists every frame time in milliseconds (frame_ms).",
["horizon-ctl fly 0 100 0 500 100 0 20"], &[], mcp: false, local: false),
@@ -6,7 +6,19 @@ use crate::{Session, commands};
use serde_json::{Value, json};
use std::io::{BufRead, Write};
const PROTOCOL: &str = "2025-06-18";
/// Revisions this server implements. Older ones are not claimed: only the revision actually
/// implemented is advertised (no JSON-RPC batches, no other capability than `tools`).
const SUPPORTED: &[&str] = &["2025-06-18"];
/// MCP lifecycle: answer with the client's version if supported, otherwise one we support.
fn negotiate_version(params: &Value) -> &'static str {
let wanted = params.get("protocolVersion").and_then(Value::as_str);
SUPPORTED
.iter()
.copied()
.find(|v| Some(*v) == wanted)
.unwrap_or(SUPPORTED[0])
}
fn tools() -> Vec<Value> {
commands::COMMANDS
@@ -134,7 +146,7 @@ impl Session {
fn handle(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
match method {
"initialize" => Ok(json!({
"protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL),
"protocolVersion": negotiate_version(params),
"capabilities": { "tools": {} },
"serverInfo": { "name": "horizon-ctl", "version": horizon_core::release::VERSION },
"instructions": "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).",
@@ -194,7 +206,17 @@ pub fn serve(session: &mut Session) -> Result<(), String> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_negotiation() {
let v = |p: Value| negotiate_version(&p);
assert_eq!(v(json!({ "protocolVersion": "2025-06-18" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": "2026-07-28" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": "1999-01-01" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": 7 })), "2025-06-18");
assert_eq!(v(json!({})), "2025-06-18");
}
#[test]
fn base64_matches_rfc_4648() {
@@ -234,9 +256,9 @@ mod tests {
fn protocol_basics() {
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-mcp-test.sock"), None);
let init = s
.handle("initialize", &json!({ "protocolVersion": "2025-03-26" }))
.handle("initialize", &json!({ "protocolVersion": "2025-06-18" }))
.unwrap();
assert_eq!(init["protocolVersion"], "2025-03-26");
assert_eq!(init["protocolVersion"], "2025-06-18");
assert!(s.handle("nope", &json!({})).is_err());
let r = s
.handle(
+48 -1
View File
@@ -1356,10 +1356,11 @@ impl Recipes {
None => sources.push((name, bytes)),
}
}
let filters = horizon_items::recipe_filters::RecipeFilters::load(root, overlay);
for (name, bytes) in sources {
// Vanilla's RecipeManager logs a file that does not parse and
// goes on ("Couldn't parse data file").
let v: Value = match serde_json::from_slice(&bytes) {
let mut v: Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(e) => {
log::warn!("Horizon: recipe {name} not loaded: {e}");
@@ -1373,6 +1374,14 @@ impl Recipes {
}) {
continue;
}
// The data's recipe filters, over the final set.
let full_id = match name.contains(':') {
true => name.clone(),
false => format!("minecraft:{name}"),
};
if !filters.apply(&full_id, &mut v) {
continue;
}
if let Some(kind) = custom_type(&v) {
let id = if name.contains(':') {
name
@@ -2163,6 +2172,44 @@ mod tests {
assert!(ids.iter().any(|r| r.id == "minecraft:pack_yes"));
let _ = fs::remove_dir_all(&root);
}
/// A pack's recipe filter removes a recipe and swaps a mod recipe's input
/// in the loaded tables, after the packs are merged.
#[test]
fn recipe_filters_remove_and_swap() {
let root = std::env::temp_dir().join(format!("horizon-filters-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
let crusher = |input: &str| {
format!(
r#"{{"type":"mymod:crusher","input":"{input}","result":{{"id":"mymod:dust"}}}}"#
)
.into_bytes()
};
fs::write(
root.join("data/minecraft/recipe/iron.json"),
crusher("minecraft:iron_ingot"),
)
.unwrap();
fs::write(
root.join("data/minecraft/recipe/gold.json"),
crusher("minecraft:gold_ingot"),
)
.unwrap();
let filter = br##"{"filters":[
{"id":"minecraft:gold","remove":true},
{"type":"mymod:*","replace_input":{"from":"minecraft:iron_ingot","to":"#c:ingots/iron"}}
]}"##;
let packs = [("recipe_filter/pack:f".to_owned(), vec![filter.to_vec()])];
let items = Items::for_tests(HashMap::new());
let recipes = Recipes::load(&root, &items, &PackOverlay { files: &packs }).unwrap();
let crushed = &recipes.custom["mymod:crusher"];
assert_eq!(
crushed.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
["minecraft:iron"]
);
assert_eq!(crushed[0].json["input"], "#c:ingots/iron");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn cook_finds_the_first_recipe_the_scan_finds() {
let recipes = cooking_book();
+6 -4
View File
@@ -86,15 +86,17 @@ impl Displayer {
/// Horizon's own lines: the shader pack's GPU time, then its costliest passes (`passes`: each
/// pass's median milliseconds, in the order they run).
pub fn shader_pass_lines(passes: &[(String, f32)]) -> Vec<String> {
labelled_pass_lines("Shader pack GPU", passes)
}
/// `label` and the passes' total, then the costliest passes by GPU time.
pub fn labelled_pass_lines(label: &str, passes: &[(String, f32)]) -> Vec<String> {
const SHOWN: usize = 8;
if passes.is_empty() {
return Vec::new();
}
let total: f32 = passes.iter().map(|p| p.1).sum();
let mut lines = vec![format!(
"Shader pack GPU: {total:.2} ms in {} passes",
passes.len()
)];
let mut lines = vec![format!("{label}: {total:.2} ms in {} passes", passes.len())];
let mut costliest: Vec<&(String, f32)> = passes.iter().collect();
costliest.sort_by(|a, b| b.1.total_cmp(&a.1));
lines.extend(
+118 -11
View File
@@ -187,6 +187,7 @@ mod moving_pistons;
mod music;
mod names;
mod narrator;
mod native_biome_colors;
mod native_block_entity_draw;
mod native_components;
mod native_criteria;
@@ -196,6 +197,7 @@ mod native_falling;
mod native_hooks;
mod native_lifecycle;
mod native_menus;
mod native_mob_draw;
mod native_mobs;
mod native_motion;
mod native_net;
@@ -765,6 +767,9 @@ enum Screen {
Win,
/// DebugOptionsScreen, F3+F6 (game/debug_options.rs).
DebugOptions,
/// A screen of a native mod's own (`native_mod_screen_open`): it draws and
/// takes its input through the screen hooks.
Mod,
}
impl Screen {
/// The id native mods see for this screen (`NativeScreenView.id`, the
@@ -791,6 +796,7 @@ impl Screen {
Screen::DialogWarning => "dialog_warning",
Screen::Win => "win",
Screen::DebugOptions => "debug_options",
Screen::Mod => "mod",
}
}
}
@@ -872,6 +878,13 @@ struct GameLevel<'w> {
dropped: &'w [ItemEntity],
orbs: &'w [ExperienceOrb],
projectiles: &'w [horizon_client::projectiles::Projectile],
/// The tridents, firework rockets and fishing hooks here. None ignores
/// block triggers (`Entity.isIgnoringBlockTriggers`), so each counts on a
/// plate or tripwire (`getEntitiesOfClass`). The flag says whether it also
/// reaches `entityInside` through this list (`applyEffectsFromBlocks`):
/// not a rocket boosting a player or a hook on an entity, whose ticks
/// skip that step, nor a trident, which `tick_blocks` sweeps itself.
trigger_projectiles: Vec<(Aabb, bool)>,
biomes: &'w Biomes,
seed: i64,
behaviours: &'w Behaviours,
@@ -1549,6 +1562,7 @@ impl LevelAccess for GameLevel<'_> {
};
let others = || {
self.other_trigger_boxes()
.chain(self.trigger_projectiles.iter().map(|&(bounds, _)| bounds))
.filter(|b| area.intersects(*b))
.count()
};
@@ -1741,8 +1755,8 @@ struct WorldRest {
/// recipes, loot tables and tags from the world's pack stack, over the
/// resource root's own. Worlds without packs use `Resources.rest` directly.
struct WorldTables {
/// The pack files the tables were built from, for the advancement tree
/// reload and the loot data.
/// The pack files the tables were built from (a native mod's among
/// them), for the advancement tree reload and the loot data.
files: Vec<(String, Vec<Vec<u8>>)>,
items: Items,
recipes: Recipes,
@@ -2219,6 +2233,8 @@ struct Game<'a> {
/// Minecraft.pause: a single-player screen that pauses the game is open.
pause: bool,
debug: bool,
/// `horizon-ctl gpu-passes`: time Horizon's own passes on the GPU with F3 shut.
gpu_passes: bool,
screen: Screen,
window: Option<Arc<Window>>,
renderer: Option<Renderer>,
@@ -2235,6 +2251,8 @@ struct Game<'a> {
/// Runs of `entity_vertices` by entity, for a shader pack's per-entity draws; recorded only
/// while `pack_ids` is set (game/shader_packs.rs).
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
/// Which entity this frame's entity vertices belong to (`Frame::entity_motion`).
entity_motion: std::cell::RefCell<Vec<(u32, u64)>>,
/// The glowing entities' silhouettes this frame (game/glowing.rs).
glowing_vertices: std::cell::RefCell<Vec<Vertex>>,
/// The shader pack's entity and block entity ids, while one draws entities.
@@ -2623,6 +2641,8 @@ struct Game<'a> {
native_lifecycle: native_lifecycle::NativeLifecycle,
/// Native mods' messages waiting (`game/native_net.rs`).
native_net: native_net::NativeNet,
/// The mod whose own screen is open (`Screen::Mod`).
native_mod_screen: Option<String>,
/// Meshed sections waiting for an upload budget (Horizon settings).
pending_uploads: Vec<((i32, i32, i32), horizon_client::mesher::SectionMesh)>,
/// This frame's view frustum, for culling entities and block entities.
@@ -2815,6 +2835,8 @@ struct Game<'a> {
border: horizon_client::worldborder::WorldBorder,
/// `WorldBorderRenderer` and its render state.
border_renderer: horizon_client::border_render::BorderRenderer,
/// The camera's 16-block section when the wall was last invalidated.
border_section: Option<[i32; 3]>,
border_state: horizon_client::border_render::BorderRenderState,
/// `Hud.tabList`: the player list's header, footer and heart states.
tab_list: horizon_client::tab_list::TabList,
@@ -3902,6 +3924,18 @@ impl Game<'_> {
.map(|g| g.position)
}))
.collect();
let mut trigger_projectiles: Vec<(Aabb, bool)> = self
.tridents
.iter()
.map(|t| (horizon_client::arrows::arrow_bounds(t.position), false))
.chain(self.rockets.iter().map(|r| (r.bounds(), !r.attached)))
.chain(
self.hook
.iter()
.map(|h| (h.bounds(), h.applies_block_effects())),
)
.collect();
self.lan_hook_triggers(&mut trigger_projectiles);
let blockers = Blockers {
mobs: &self.mobs,
armor_stands: &self.armor_stands,
@@ -3919,6 +3953,7 @@ impl Game<'_> {
dropped: &self.dropped,
orbs: &self.orbs,
projectiles: &self.creatures.projectiles,
trigger_projectiles,
world: &mut self.world,
facts: self.res.facts(),
collision: self.collision,
@@ -4290,7 +4325,16 @@ impl Game<'_> {
}
// The mobs and the other entities (items, orbs, projectiles...) too.
let mut touched: Vec<BlockPos> = Vec::new();
for bounds in level.mob_trigger_boxes().chain(level.other_trigger_boxes()) {
let projectiles = level
.trigger_projectiles
.iter()
.filter(|(_, applies)| *applies)
.map(|&(bounds, _)| bounds);
for bounds in level
.mob_trigger_boxes()
.chain(level.other_trigger_boxes())
.chain(projectiles)
{
let (lo, hi) = (bounds.min_position(), bounds.max_position());
for x in (lo.x + 1e-7).floor() as i32..=(hi.x - 1e-7).floor() as i32 {
for y in (lo.y + 1e-7).floor() as i32..=(hi.y - 1e-7).floor() as i32 {
@@ -4772,7 +4816,7 @@ impl Game<'_> {
}
PlayerEffect::InteractLoot(_) => {}
},
LevelEvent::PrimeTnt { pos, rot } => self.add_primed_tnt(pos, rot, 80),
LevelEvent::PrimeTnt { pos, rot } => self.add_primed_tnt(pos, rot, 80, None, None),
LevelEvent::BlockEvent {
pos,
state,
@@ -8118,6 +8162,7 @@ impl Game<'_> {
Screen::Dead => self.draw_death_screen(&mut batch),
Screen::Stats => self.draw_stats(&mut batch),
Screen::Advancements => self.draw_advancements(&mut batch),
Screen::Mod => {}
Screen::WorldOptions => self.draw_world_options(&mut batch),
Screen::Friends => self.draw_friends_screen(&mut batch),
Screen::ServerLinks => self.draw_server_links(&mut batch),
@@ -8157,11 +8202,12 @@ impl Game<'_> {
/// `native_screen_render_post` (`ScreenEvent.Render.Post`) for the open
/// screen, once a frame; the Win and Options screens call this on their
/// own path. Reads `self.screen`, the one `hud_layers` drew. Not while
/// playing with no screen. Client only, read-only: what a mod draws is
/// its own.
/// playing with no screen, nor while the server pack prompt hides the
/// open screen (`hud_layers` draws `Playing` then). Client only,
/// read-only: what a mod draws is its own.
fn fire_screen_render_post(&self, batch: &mut HudBatch) {
let screen = self.screen;
if screen == Screen::Playing {
if screen == Screen::Playing || self.server_packs.prompt.is_some() {
return;
}
// slot_count mirrors slots() without allocating; keep them in step.
@@ -10111,6 +10157,11 @@ impl Game<'_> {
pub(super) fn camera_orientation(&self) -> glam::Quat {
camera_rotation(self.camera_type, self.yaw, self.pitch)
}
/// The entity whose geometry starts at vertex `start` (0: none), for frame
/// generation to follow its own motion. Each run lasts until the next mark.
pub(super) fn mark_motion(&self, start: usize, id: u64) {
self.entity_motion.borrow_mut().push((start as u32, id));
}
/// ItemEntityRenderer: items bob and spin; blocks render as small cubes.
fn entity_geometry(
&mut self,
@@ -10119,6 +10170,7 @@ impl Game<'_> {
mut out: Vec<Vertex>,
) -> Vec<Vertex> {
self.entity_spans.borrow_mut().clear();
self.entity_motion.borrow_mut().clear();
// LevelExtractor.extract: the transient blocks queued since the last frame.
self.drain_transient_block_queue();
self.prepare_entity_culling(camera);
@@ -10223,6 +10275,7 @@ impl Game<'_> {
// `ItemEntityRenderer`.
self.mark_entity(out.len(), "minecraft:item");
self.dropped_item_geometry(camera, alpha, &mut out);
self.mark_motion(out.len(), 0);
// `VaultRenderer`.
self.mark_entity(out.len(), "");
self.vault_geometry(camera, alpha, &mut out);
@@ -10235,6 +10288,7 @@ impl Game<'_> {
self.ominous_item_spawner_geometry(camera, alpha, &mut out);
// Mobs and their projectiles (a span each).
self.mob_geometry(camera, alpha, &mut out);
self.mark_motion(out.len(), 0);
// In first person only a shader pack's shadow pass draws the player.
self.mark_player(out.len(), !self.own_body_in_view());
let player_start = out.len();
@@ -11394,6 +11448,9 @@ impl Game<'_> {
&self.options.resources,
&pack_files(&self.world_tables),
);
// TagsUpdatedEvent (SERVER_DATA_LOAD): the world's data is in, before
// its chunks and players are.
self.native_server_data_loaded(false);
self.bosses = Default::default();
reset_world_progress(&mut self.adv, &mut self.stats);
self.prefetch_advancement_tree();
@@ -13260,6 +13317,8 @@ impl Game<'_> {
self.renderer.as_ref(),
None,
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
shader_packs::picked_pack_draws_flat(&self.settings),
)
}
fn apply_settings(&mut self) {
@@ -14341,6 +14400,8 @@ impl Game<'_> {
});
}
fn render(&mut self) -> Result<bool> {
// Before any text of this frame: a full glyph region starts over.
self.gui.atlas.font.begin_frame();
self.glint_frame = self.capture_glint_frame();
if self.renderer.as_mut().is_some_and(|r| r.display_changed()) {
self.apply_ui_settings();
@@ -14734,7 +14795,9 @@ impl Game<'_> {
self.bolt_instances(camera)
};
let mut additive = Vec::new();
if !title && self.dim == Dim::End {
// The dragon is drawn in every dimension, as any entity: a dragon
// summoned outside the End (the server's AddEntity) is shown too.
if !title {
let mut dragon = Vec::new();
self.dragon_geometry(camera, alpha, &mut dragon, &mut additive);
self.dragon_fireball_geometry(camera, alpha, &mut dragon);
@@ -14802,6 +14865,7 @@ impl Game<'_> {
// map_patches any earlier leaves them a frame behind.
hud.patches.append(&mut self.map_patches);
let entity_spans = std::mem::take(self.entity_spans.get_mut());
let entity_motion = std::mem::take(self.entity_motion.get_mut());
let glowing = std::mem::take(self.glowing_vertices.get_mut());
let shadow_only = std::mem::take(self.shadow_only_vertices.get_mut());
let frame = Frame {
@@ -14885,6 +14949,7 @@ impl Game<'_> {
color: sky.cloud_color,
}),
entities: &entities,
entity_motion: &entity_motion,
entity_spans: &entity_spans,
glowing: &glowing,
dynamic_lights: &self.dynamic_lights,
@@ -14955,7 +15020,7 @@ impl Game<'_> {
};
let result = match self.renderer.as_mut() {
Some(renderer) => {
renderer.set_pass_timing(self.debug);
renderer.set_pass_timing(self.debug || self.gpu_passes);
horizon_client::spans::time("renderer", || renderer.render(&frame))
}
None => Err("renderer".into()),
@@ -14963,6 +15028,7 @@ impl Game<'_> {
// Restore storage even when rendering fails (for example, a lost surface).
self.entity_vertices = entities;
*self.entity_spans.get_mut() = entity_spans;
*self.entity_motion.get_mut() = entity_motion;
*self.glowing_vertices.get_mut() = glowing;
*self.shadow_only_vertices.get_mut() = shadow_only;
self.see_through_vertices = see_through;
@@ -15090,6 +15156,13 @@ impl Game<'_> {
distance,
&mut self.border_state,
);
// `LevelRenderer.repositionCamera`: the wall is rebuilt when the camera
// enters another `SectionPos` (it only holds the stretch in range).
let section = camera.map(|c| (c.floor() as i32) >> 4);
if self.border_section != Some(section) {
self.border_renderer.invalidate();
self.border_section = Some(section);
}
// Camera.depthFar, as the renderer's far plane.
let depth_far =
horizon_client::camera::depth_far(self.terrain_distance(), self.settings.cloud_range);
@@ -15169,6 +15242,7 @@ impl Game<'_> {
Screen::Chat => self.close_chat(chat::ChatExit::Intentional),
Screen::Win => self.close_win_screen()?,
Screen::DebugOptions => self.close_debug_options(),
Screen::Mod => self.close_mod_screen(),
},
KeyCode::Digit1
| KeyCode::Digit2
@@ -15452,6 +15526,16 @@ impl Game<'_> {
if let Err(e) = self.poll_remote() {
log::warn!("Horizon: {e}");
}
// The renderer laid translucent quads out again (a shader pack's flat terrain came
// or went) and dropped every section and far tile: meshed again, as for a new one.
if self
.renderer
.as_mut()
.is_some_and(Renderer::take_dropped_sections)
{
self.clear_lod();
self.all_sections_changed();
}
self.prepare_lod();
if self.lod.is_some() {
for &((x, _, z), _, _) in &self.world.light_edits {
@@ -17295,6 +17379,7 @@ impl<'a> Game<'a> {
was_playing: false,
pause: false,
debug: false,
gpu_passes: false,
screen: Screen::Playing,
window: None,
renderer: None,
@@ -17304,6 +17389,7 @@ impl<'a> Game<'a> {
see_through_vertices: Vec::new(),
shadow_only_vertices: Default::default(),
entity_spans: Default::default(),
entity_motion: Default::default(),
glowing_vertices: Default::default(),
pack_ids: None,
health_hud: hud_layout::HealthHud::default(),
@@ -17548,6 +17634,7 @@ impl<'a> Game<'a> {
native_shaders: Default::default(),
native_lifecycle: Default::default(),
native_net: Default::default(),
native_mod_screen: None,
sky_darken_memo: Default::default(),
water_plane_memo: Default::default(),
water_plane_scan: Default::default(),
@@ -17635,6 +17722,7 @@ impl<'a> Game<'a> {
brushing: Default::default(),
border: Default::default(),
border_renderer: Default::default(),
border_section: None,
border_state: Default::default(),
tab_list: Default::default(),
subtitles: Default::default(),
@@ -17957,11 +18045,16 @@ impl Game<'_> {
/// packs, and `Resources.rest` stays in use). This re-reads the tables,
/// so it runs once per world open and on `/reload`.
fn build_world_tables(&self) -> Option<WorldTables> {
let files = functions::content_overlay(
// The ask of this load, both here and on `/reload`: a native mod
// recomputes the pack files it contributes every time.
let mut contributed = Vec::new();
self.native_data_pack_files(&mut contributed);
let files = functions::overlay_files(
&self.world_root,
&self.options.resources,
self.cmds.vanilla_disabled,
&self.level.enabled_features,
contributed,
);
if files.is_empty() {
return None;
@@ -18023,6 +18116,7 @@ impl Game<'_> {
&overlay,
));
self.adv.reset_tree();
self.native_server_data_loaded(true);
}
fn join_world_rest(&mut self) -> Result<()> {
@@ -18579,7 +18673,10 @@ fn load_world_rest(
.into_iter()
.map(|(k, v)| (k, Arc::new(v)))
.collect();
let mob_data = horizon_client::mobs::MobData::load(root, facts);
let mut mob_data = horizon_client::mobs::MobData::load(root, facts);
mob_data.cem = std::sync::RwLock::new(Arc::new(
horizon_client::mobs::cem::CemModels::load(root, &mob_types),
));
horizon_client::mobs::trial_spawner::load_registry(root);
Ok((mob_types, mob_data))
};
@@ -18689,6 +18786,8 @@ fn build_renderer(
previous: Option<&Renderer>,
prepared: Option<horizon_client::gfx::PreparedTextures>,
api: horizon_client::graphics::GraphicsApi,
translucent_batching: bool,
shader_pack: bool,
) -> Result<Renderer> {
let assets = &pack.render;
let atlas = &gui.atlas;
@@ -18723,6 +18822,8 @@ fn build_renderer(
prepared,
previous,
api,
translucent_batching,
shader_pack,
))?;
renderer.hud.sprite_animations = atlas
.sprite_animations
@@ -18784,6 +18885,9 @@ fn loading_renderer(
None,
previous,
api,
// It draws no terrain.
false,
false,
))?;
if let Some(atlas) = atlas {
renderer.hud.sprite_animations = atlas
@@ -18852,6 +18956,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
entity_motion: &[],
entity_spans: &[],
glowing: &[],
dynamic_lights: &[],
@@ -19325,6 +19430,8 @@ impl Loading {
self.renderer.as_ref(),
loaded.renderer_cpu,
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
shader_packs::picked_pack_draws_flat(&self.settings),
)?;
log::info!(
"Horizon: world renderer ready after {} ms",
+36 -13
View File
@@ -4,7 +4,7 @@
use super::{Dim, Game};
use horizon_client::{
advancements::{self as adv, EntityView, Frame, ItemView, Progress, Tree},
hud::HudBatch,
hud::{HudBatch, rgba},
items::ItemStack,
text::{self, Style, Text},
toasts::Slide,
@@ -326,13 +326,19 @@ impl Game<'_> {
return;
}
let data = self.options.resources.join("data");
// The world's packs' advancements go over the game's own.
let files = super::functions::content_overlay(
&self.world_root,
&self.options.resources,
self.cmds.vanilla_disabled,
&self.level.enabled_features,
);
// The world's packs' advancements go over the game's own, and a
// native mod's contributed files are already among them: the tables
// were built for this open (`build_world_tables`), so the hook is
// asked once, not again here.
let files = match &self.world_tables {
Some(tables) => tables.files.clone(),
None => super::functions::content_overlay(
&self.world_root,
&self.options.resources,
self.cmds.vanilla_disabled,
&self.level.enabled_features,
),
};
self.adv.pending_tree = std::thread::Builder::new()
.name("horizon-advancements".into())
.spawn(move || Tree::shared(&data, &horizon_world::loot::PackOverlay { files: &files }))
@@ -1821,11 +1827,7 @@ impl Game<'_> {
}
let atlas = &self.gui.atlas;
batch.sprite(atlas, "gui/sprites/toast/advancement", x, y);
let (key, color) = match frame {
Frame::Task => ("advancements.toast.task", 0xFFFF_FF00),
Frame::Goal => ("advancements.toast.goal", 0xFFFF_FF00),
Frame::Challenge => ("advancements.toast.challenge", 0xFFFF_88FF),
};
let (key, color) = toast_label(frame);
// AdvancementToast.extractRenderState: a title of one line sits under
// the frame's label; a longer one is wrapped at 125, the label fading
// out first (to 1.5 s) and the lines fading in after it over 0.3 s.
@@ -1862,6 +1864,15 @@ impl Game<'_> {
self.draw_item(batch, &ItemStack::new(&icon, 1), x + 8.0, y + 8.0);
}
}
/// The toast's frame label and its colour (`AdvancementToast`: -30465, pink,
/// for a challenge, else -256, yellow), packed as `hud::rgba` for the batch.
fn toast_label(frame: Frame) -> (&'static str, u32) {
match frame {
Frame::Task => ("advancements.toast.task", rgba(255, 255, 0, 255)),
Frame::Goal => ("advancements.toast.goal", rgba(255, 255, 0, 255)),
Frame::Challenge => ("advancements.toast.challenge", rgba(255, 136, 255, 255)),
}
}
/// `Advancement.name`: the bracketed title in the frame's colour, with the
/// description on hover; the id when there is no display.
pub(super) fn adv_name(a: &adv::Advancement) -> Text {
@@ -2059,6 +2070,18 @@ pub(super) fn text_of(v: &Value) -> Text {
mod tests {
use super::*;
#[test]
fn toast_labels_are_yellow_or_pink() {
// The batch takes colours as `rgba`: 0xFFFF_FF00 there is cyan (#5850).
for frame in [Frame::Task, Frame::Goal] {
assert_eq!(toast_label(frame).1.to_le_bytes(), [255, 255, 0, 255]);
}
assert_eq!(
toast_label(Frame::Challenge).1.to_le_bytes(),
[255, 136, 255, 255]
);
}
fn recipes(n: usize) -> Toast {
Toast::recipe(
(0..n)
+27 -2
View File
@@ -424,6 +424,7 @@ impl Game<'_> {
Screen::BlockEditor => "block-editor",
Screen::Win => "win",
Screen::DebugOptions => "debug-options",
Screen::Mod => "mod",
}
}
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
@@ -498,7 +499,7 @@ impl Game<'_> {
json!({
"dialog": self.dialog_status(),
"screen": self.screen_label(),
"menu": self.title.as_ref().map(|_| self.title_value()),
"menu": (self.title.is_some() || self.split_profiles_open()).then(|| self.title_value()),
"world": (self.title.is_none()).then(|| match &self.integrated {
Some(server) => server.world_name.clone(),
None => self.level.name.clone(),
@@ -528,8 +529,16 @@ impl Game<'_> {
})
}
/// Splitscreen's profile page, a joining player's (`split_profiles`).
fn split_profiles_open(&self) -> bool {
self.screen == Screen::Options
&& self.ui.options.page() == horizon_client::options_ui::Page::SplitProfiles
}
fn title_value(&self) -> Value {
use horizon_client::title::Mode;
if self.split_profiles_open() {
return self.ui.options.split_profiles.state();
}
let Some(title) = &self.title else {
return Value::Null;
};
@@ -727,7 +736,7 @@ impl Game<'_> {
v["chat_input"] = json!(chat.input.value);
v["chat_suggestions"] = json!(chat.suggestions_visible());
}
if self.title.is_some() {
if self.title.is_some() || self.split_profiles_open() {
v["menu"] = self.title_value();
}
if self.screen == Screen::Inventory && self.title.is_none() {
@@ -1591,6 +1600,22 @@ impl Game<'_> {
self.synth_click(event_loop, x, y, button)?;
ok(json!({ "ok": true, "screen": self.screen_label() }))
}
// Splitscreen's profile page, over a joining player's title screen.
"type" if self.split_profiles_open() => {
let text = line
.trim_start()
.strip_prefix("type")
.unwrap_or("")
.strip_prefix(' ')
.unwrap_or("");
let page = &mut self.ui.options.split_profiles;
if text == "\\b" {
page.key(winit::keyboard::KeyCode::Backspace, None);
} else {
page.type_text(text);
}
ok(json!({ "ok": true, "menu": self.title_value() }))
}
"type" if self.title.is_some() => {
// type [--field name|seed] [--replace] TEXT on the title screens.
let mut text = line
+6
View File
@@ -94,6 +94,12 @@ pub(super) struct CombatTracker {
/// known (`AbstractArrow.onHitEntity` makes an ownerless arrow its own
/// cause).
pub(super) projectile_direct: Option<Text>,
/// The player whose primed TNT is going off now (`PrimedTnt.getOwner`):
/// its entity id and display name, the blast's causing entity.
pub(super) blast_owner: Option<(u32, Text)>,
/// The guest behind `blast_owner`, when that player is a LAN guest; the
/// host's own blast leaves it None.
pub(super) blast_guest: Option<u32>,
pub(super) projectile_attacker: Option<(u32, Text)>,
/// The projectile hitting the player now is another player's that
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
+20 -3
View File
@@ -2459,6 +2459,11 @@ impl Game<'_> {
ctx.has("keep"),
);
let mut count = 0;
// Whether the filter takes a state, worked out once per state: a fill visits up to 32768 blocks of a handful
// of states, and matching a tag is a name lookup.
let mut matched: std::collections::HashMap<BlockStateId, bool> =
std::collections::HashMap::new();
let plain = !(destroy || hollow || outline);
for y in min[1]..=max[1] {
for z in min[2]..=max[2] {
for x in min[0]..=max[0] {
@@ -2466,11 +2471,23 @@ impl Game<'_> {
if keep && current != air {
continue;
}
if let Some(f) = &filter
&& !self.block_matches(f, current)
{
// Already the block it would become: nothing to place (place_state would do nothing either).
if plain && current == state {
continue;
}
if let Some(f) = &filter {
let ok = match matched.get(&current) {
Some(&ok) => ok,
None => {
let ok = self.block_matches(f, current);
matched.insert(current, ok);
ok
}
};
if !ok {
continue;
}
}
let edge = x == min[0]
|| x == max[0]
|| y == min[1]
+30 -2
View File
@@ -109,10 +109,28 @@ impl Game<'_> {
let _ = flight.reply.send(summary.to_string());
}
}
/// The frame summary, with Horizon's own passes' median GPU times while they are timed.
fn summary_with_passes(&self) -> Value {
let mut summary = self.frame_stats.summary();
let passes = self
.renderer
.as_ref()
.map(|r| r.own_pass_times())
.unwrap_or_default();
if !passes.is_empty() {
summary["gpu_passes"] = Value::Object(
passes
.into_iter()
.map(|(name, ms)| (name, json!(ms)))
.collect(),
);
}
summary
}
pub(super) fn poll_control(&mut self, event_loop: &dyn EventLoopCtl) {
if let Some((deadline, reply)) = self.pending_bench.take() {
if Instant::now() >= deadline {
let _ = reply.send(self.frame_stats.summary().to_string());
let _ = reply.send(self.summary_with_passes().to_string());
} else {
self.pending_bench = Some((deadline, reply));
}
@@ -312,6 +330,15 @@ impl Game<'_> {
}
Ok(Some(json!({ "ok": true, "cull": r.section_cull() })))
}
"gpu-passes" => {
match words.get(1).copied() {
None => {}
Some("on") => self.gpu_passes = true,
Some("off") => self.gpu_passes = false,
Some(other) => return Err(format!("unknown gpu pass mode {other}").into()),
}
Ok(Some(json!({ "ok": true, "gpu_passes": self.gpu_passes })))
}
"section-sides" => {
let r = self.renderer.as_mut().ok_or("no renderer")?;
match words.get(1).copied() {
@@ -820,7 +847,7 @@ impl Game<'_> {
})))
}
"stats" => {
let summary = self.frame_stats.summary();
let summary = self.summary_with_passes();
if words.get(1) == Some(&"reset") {
self.frame_stats.reset();
}
@@ -1032,6 +1059,7 @@ impl Game<'_> {
Screen::BlockEditor => "block-editor",
Screen::Win => "win",
Screen::DebugOptions => "debug-options",
Screen::Mod => "mod",
}
}
+33 -1
View File
@@ -2382,6 +2382,16 @@ impl Game<'_> {
self.particles.add(&options, at, velocity);
}
}
MobEvent::ParticleColored {
at,
color: [r, g, b],
} => {
let options = horizon_client::particles::ParticleOptions::colored(
horizon_client::particles::ParticleType::EntityEffect,
[r, g, b, 1.0],
);
self.particles.add(&options, at, [0.0; 3]);
}
MobEvent::Experience { at, amount } => self.spawn_breeding_orb(at, amount),
MobEvent::AxolotlSupportPlayer { mob } => {
let previous = self
@@ -3788,7 +3798,7 @@ impl Game<'_> {
}
ArrowEvent::Block { pos, reaction } => match reaction {
BlockReaction::PrimeTnt => {
self.spawn_primed_tnt(pos, false);
self.spawn_primed_tnt(pos, false, None, None);
self.with_level(|b, level, ticks| b.remove_block(level, ticks, pos, false));
let _ = self.process_level_events();
}
@@ -5901,6 +5911,7 @@ impl Game<'_> {
self.rendered_mobs.set(0);
for mob in &self.mobs {
let entity_start = out.len();
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
let outline = self.mob_outline(mob);
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
@@ -5974,6 +5985,25 @@ impl Game<'_> {
let block = horizon_client::mobs::aquatic::glow_squid_block_light(mob, lit);
packed = (packed & 0xFF) | ((block * 16) << 8);
}
// A native mod's own model in place of the vanilla one
// (`native_mob_submit`), before any of the vanilla model's parts
// (a sulfur cube's swallowed block) are drawn.
if !is_player
&& !layers_only
&& self.mod_mob_geometry(mob, alpha as f32, camera, packed, out)
{
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
if outline.is_none() {
self.cull_entity_vertices(out, entity_start);
}
continue;
}
if mob.species() == Species::SulfurCube
&& !horizon_client::mobs::trader::is_invisible(mob)
{
@@ -6075,6 +6105,8 @@ impl Game<'_> {
);
}
}
// What follows isn't a tracked mob: close the last one's span.
self.mark_motion(out.len(), 0);
self.elder_apparition_geometry(alpha, out);
self.spawner_geometry(camera, alpha, out);
// Projectiles: the baked vanilla arrow model, the rest as item billboards.
@@ -162,6 +162,13 @@ impl Game<'_> {
if self.debug && !pass_lines.is_empty() {
d.group("shader pack", pass_lines);
}
// Horizon's own passes, in a group of their own.
let own_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
overlay::labelled_pass_lines("Horizon GPU", &r.own_pass_times())
});
if self.debug && !own_lines.is_empty() {
d.group("Horizon passes", own_lines);
}
}
"tps" => self.debug_tps_line(d),
// DebugEntryChunkGeneration, where the world is generated
@@ -6,6 +6,7 @@
use super::{ServerState, log};
use crate::game::Game;
use horizon_client::{
command::vanilla::DIFFICULTIES,
server::{
profiles,
user_lists::{BanEntry, NameAndId, OpEntry, WhiteEntry},
@@ -48,6 +49,27 @@ impl Game<'_> {
let entries = body["entries"].as_array().cloned().unwrap_or_default();
let lists = state.lists.clone();
match body["method"].as_str().unwrap_or_default() {
"serversettings/difficulty" => {
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
}
"serversettings/difficulty/set" => {
let Some(value) = body["value"].as_str() else {
return json!({ "error": "difficulty must be a string" });
};
let Some(index) = DIFFICULTIES.iter().position(|name| *name == value) else {
return json!({ "error": format!("unknown difficulty {value:?}") });
};
rpc(format!(
"Update difficulty from '{}' to '{}'",
DIFFICULTIES[self.level.difficulty_id() as usize],
value
));
// DedicatedServer.setDifficulty stores the property and then forces the world
// difficulty, including hardcore's always-hard rule and the player broadcast.
self.store_property("difficulty", value.to_owned());
self.set_difficulty_forced(if self.level.hardcore { 3 } else { index as u8 });
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
}
"allowlist/add" => {
for user in entries.iter().filter_map(|e| self.management_user(e)) {
rpc(format!("Add player '{}' to allowlist", shown(&user)));
+2 -2
View File
@@ -1377,7 +1377,7 @@ impl Game<'_> {
);
} else if &*name == "minecraft:tnt" {
if self.ui.rules.bool("tnt_explodes") {
self.spawn_primed_tnt(target_pos, false);
self.spawn_primed_tnt(target_pos, false, None, None);
self.with_level(|b, level, ticks| b.remove_block(level, ticks, target_pos, false));
self.process_level_events()?;
} else {
@@ -1441,7 +1441,7 @@ impl Game<'_> {
// new PrimedTnt(level, x + 0.5, y, z + 0.5, null), its sound and ENTITY_PLACE.
let target = Self::pos_of(front);
let rot = self.world.random.next_double() * f64::from(std::f32::consts::TAU);
self.add_primed_tnt(target, rot, 80);
self.add_primed_tnt(target, rot, 80, None, None);
self.audio.play(
"minecraft:entity.tnt.primed",
Some([
+53 -2
View File
@@ -438,8 +438,10 @@ impl Dragon {
fn sample(&self, delay: usize) -> (f64, f32) {
self.history[(self.head + 64 - (delay & 63)) & 63]
}
/// DragonFlightHistory.get(delay, partial).
/// `DragonFlightHistory.get(delay, partial)`, with
/// `EnderDragonRenderer.extractRenderState`'s frozen history on death.
fn sample_lerp(&self, delay: usize, partial: f32) -> (f64, f32) {
let partial = if self.alive() { partial } else { 0.0 };
let a = self.sample(delay);
let b = self.sample(delay + 1);
let yaw = b.1 + wrap(a.1 - b.1) * partial;
@@ -3874,7 +3876,7 @@ fn dragon_rays(base: glam::Mat4, t: f32, white: horizon_client::hud::Rect, out:
normal: [0; 4],
pos: m.transform_point3(p).to_array(),
uv,
layer: 2,
layer: horizon_client::gfx::DRAGON_RAYS_OVERLAY,
color: c,
light: 0,
@@ -3911,6 +3913,55 @@ fn push_seen_with_hurt(
mod tests {
use super::*;
#[test]
fn dying_dragons_keep_their_flight_pose_between_frames() {
let mut dragon = Dragon::new(Vec3::new(0.0, 80.0, 0.0), 0.0, 432);
dragon.history.fill((70.0, 170.0));
dragon.position.y = 90.0;
dragon.yaw = -170.0;
dragon.record();
assert_eq!(dragon.sample_lerp(0, 0.5), (80.0, 180.0));
dragon.health = 0.0;
for partial in [0.0, 0.25, 0.5, 0.75, 1.0] {
assert_eq!(dragon.sample_lerp(0, partial), (70.0, 170.0));
}
}
/// Driven by `tools/test_dragon_render_parity.py`; compares the actual
/// ray vertices with EnderDragonRenderer's captured VertexConsumer output.
#[test]
#[ignore]
fn dragon_render_parity_fixture() {
let path = std::env::var("HORIZON_DRAGON_RENDER_OUTPUT").expect("harness output path");
let mut cases = Vec::new();
for t in [0.0, 0.05, 0.25, 0.5, 0.8, 0.9, 0.95, 1.0] {
let mut vertices = Vec::new();
dragon_rays(
glam::Mat4::IDENTITY,
t,
horizon_client::hud::Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
},
&mut vertices,
);
// The fourth quad vertex only pads a triangle for our index buffer.
let triangles: Vec<_> = vertices
.as_chunks::<4>()
.0
.iter()
.flat_map(|q| &q[..3])
.map(|v| serde_json::json!([v.pos[0], v.pos[1], v.pos[2], v.color]))
.collect();
cases.push(triangles);
}
std::fs::write(path, serde_json::to_vec(&cases).unwrap()).unwrap();
}
#[test]
fn death_rays_sample_inside_the_white_sprite() {
// A four-texel white sprite surrounded by transparent atlas pixels.
+25 -8
View File
@@ -4,7 +4,7 @@
//! merged drops and TNT chain priming, and optional fire.
use super::Game;
use horizon_client::{
Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack, text::Text,
Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack, mobs::Who, text::Text,
};
use horizon_core::{
geometry::{Vec3, VoxelShape},
@@ -175,12 +175,17 @@ pub(super) fn client_float() -> f32 {
impl Game<'_> {
/// `blast_cause` for the blast going off now.
pub(super) fn blast_attacker(&self) -> Option<(u32, Text)> {
blast_cause(
self.combat.explosion_owner,
self.combat.explosion_source,
|id| self.mob_display_name(id),
&self.combat.exploded,
)
// `Explosion.getIndirectSourceEntity`: a primed TNT's owner names the
// blast before any mob does, which makes the damage type
// `minecraft:player_explosion` (`server_explode` asks this).
self.combat.blast_owner.clone().or_else(|| {
blast_cause(
self.combat.explosion_owner,
self.combat.explosion_source,
|id| self.mob_display_name(id),
&self.combat.exploded,
)
})
}
/// `ServerExplosion.getSeenPercent` for an entity box.
pub(super) fn seen_percent(&self, center: [f64; 3], min: [f64; 3], max: [f64; 3]) -> f32 {
@@ -976,7 +981,19 @@ impl Game<'_> {
let r = &mut self.world.random;
let rot = r.next_double() * f64::from(std::f32::consts::TAU);
let fuse = r.next_int_bound(20).unwrap_or(0) + 10;
self.add_primed_tnt(BlockPos::new(pos[0], pos[1], pos[2]), rot, fuse);
// TntBlock.wasExploded hands the new TNT the blast's indirect
// source, so a chain reaction keeps the player who started it.
let (owner, guest) = match &self.combat.blast_owner {
Some(_) => (Some(Who::Player), self.combat.blast_guest),
None => (None, None),
};
self.add_primed_tnt(
BlockPos::new(pos[0], pos[1], pos[2]),
rot,
fuse,
owner,
guest,
);
}
Ok(())
}
+19 -2
View File
@@ -123,6 +123,18 @@ impl Rocket {
)
}
}
/// `EntityType.FIREWORK_ROCKET`: 0.25 x 0.25.
pub(super) fn bounds(&self) -> Aabb {
let h = 0.125;
Aabb::new(
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
Vec3::new(
self.position.x + h,
self.position.y + 2.0 * h,
self.position.z + h,
),
)
}
/// `FireworkRocketEntity.tick` for a rocket attached to no entity: speed
/// up (an angled one keeps its motion) and rise, then move.
fn free_flight(&mut self, scene: &mut SceneCollisions) {
@@ -479,8 +491,13 @@ impl Game<'_> {
}
/// The owner as `rocket_targets` names it: a guest by their entity id.
fn rocket_owner(&self, r: &Rocket) -> Option<Who> {
let owner = r.owner?;
let Some(conn) = r.guest.filter(|_| owner == Who::Player) else {
self.shooter_who(r.owner, r.guest)
}
/// `Projectile.getOwner` as a target's `Who`: a guest's `Who::Player`
/// (kept with the connection that shot it) is their entity, not the host.
pub(super) fn shooter_who(&self, owner: Option<Who>, guest: Option<u32>) -> Option<Who> {
let owner = owner?;
let Some(conn) = guest.filter(|_| owner == Who::Player) else {
return Some(owner);
};
let host = self.lan.as_ref()?;
+10
View File
@@ -64,6 +64,16 @@ fn bounds(p: Vec3) -> Aabb {
Vec3::new(p.x + h, p.y + SIZE, p.z + h),
)
}
impl Hook {
pub(super) fn bounds(&self) -> Aabb {
bounds(self.position)
}
/// `FishingHook.tick` returns before `applyEffectsFromBlocks` while it
/// holds an entity.
pub(super) fn applies_block_effects(&self) -> bool {
!matches!(self.state, State::Hooked(_))
}
}
/// RandomSource.triangle(mode, deviation).
fn triangle(r: &mut impl RandomSource, mode: f64, deviation: f64) -> f64 {
mode + deviation * (f64::from(r.next_float()) - f64::from(r.next_float()))
+8 -1
View File
@@ -124,7 +124,14 @@ impl Game<'_> {
self.camera_fluid(camera),
false,
);
self.zoomed_fov_degrees(fov)
let vanilla = self.zoomed_fov_degrees(fov);
let mut degrees = vanilla;
self.native_compute_fov(partial, camera, &mut degrees);
if degrees.is_finite() && (1.0..=179.0).contains(&degrees) {
degrees
} else {
vanilla
}
}
/// `Camera.calculateHudFov`: the first-person hand's field of view.
pub(super) fn hud_fov_degrees(&self, partial: f32, camera: [f64; 3]) -> f32 {
+125
View File
@@ -96,6 +96,48 @@ pub(super) fn content_overlay(
DataStore::load(&packs).files.into_iter().collect()
}
/// The files the content tables load from: the world's enabled packs'
/// (`content_overlay`) with what native mods contribute for this load
/// (`native_data_pack_files`) below them. `PackOverlay::top` is a path's last
/// file, so a pack's file still overrides a mod's for the same path, and a
/// later contribution overrides an earlier one, as packs do. `Game` builds
/// its tables through here on a world's first open and on `/reload`, so the
/// mods are asked once per load.
pub(super) fn overlay_files(
world_root: &std::path::Path,
resources: &std::path::Path,
vanilla_disabled: bool,
enabled_features: &[String],
contributed: Vec<(String, Vec<u8>)>,
) -> Vec<(String, Vec<Vec<u8>>)> {
let mut files = content_overlay(world_root, resources, vanilla_disabled, enabled_features);
if contributed.is_empty() {
return files;
}
// A path's mod files in contribution order, under the pack files.
let mut mods: std::collections::BTreeMap<String, Vec<Vec<u8>>> = Default::default();
for (path, bytes) in contributed {
// One bad file would fail its whole table and drop the world's pack
// content with it, so it is skipped alone: every kind is JSON.
if let Err(e) = serde_json::from_slice::<serde_json::Value>(&bytes) {
log::warn!("Horizon: a native mod's data pack file {path} is not JSON, skipped: {e}");
continue;
}
mods.entry(path).or_default().push(bytes);
}
for (path, versions) in files.iter_mut() {
let Some(under) = mods.remove(path.as_str()) else {
continue;
};
let mut merged = under;
merged.append(versions);
*versions = merged;
}
// The paths only a mod has.
files.extend(mods);
files
}
impl FnState {
/// The level function files compile at (`ServerFunctionLibrary`'s
/// `functionCompilationPermissions`): `function-permission-level` on a
@@ -948,4 +990,87 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
}
/// What a native mod contributes (`native_data_pack_files`) joins the load
/// the tables are built from, a world's first open and `/reload` alike
/// (`Game::build_world_tables` is both), and sits below the world's own
/// packs. Each load asks again, so it is the current contribution that
/// counts.
#[test]
fn contributed_files_join_the_load_below_the_worlds_packs() {
let dir = std::env::temp_dir().join(format!("horizon-contributed-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let pack = dir.join("datapacks").join("pack");
std::fs::create_dir_all(pack.join("data/pkg/tags/item")).unwrap();
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
std::fs::write(
pack.join("data/pkg/tags/item/both.json"),
r#"{"replace": true, "values": ["pkg:from_pack"]}"#,
)
.unwrap();
let settings = serde_json::json!({"enabled": ["file/pack"], "disabled": []});
std::fs::write(dir.join("datapacks.json"), settings.to_string()).unwrap();
let tag = |entry: &str| format!(r#"{{"values": ["{entry}"]}}"#).into_bytes();
let files = overlay_files(
&dir,
std::path::Path::new(""),
true,
&[],
vec![
("tags/pkg:item/both".to_owned(), tag("pkg:from_mod")),
("tags/pkg:item/mod_only".to_owned(), tag("pkg:only_mod")),
],
);
// The pack's file is the path's last (`PackOverlay::top`), and its
// `replace` drops what the mod put below it.
let (_, versions) = files
.iter()
.find(|(id, _)| id == "tags/pkg:item/both")
.unwrap();
assert!(horizon_world::loot::PackOverlay::top(versions).starts_with(b"{\"replace\""));
let overlay = horizon_world::loot::PackOverlay { files: &files };
let tags = horizon_items::items::load_tags(&dir, "item", &overlay).unwrap();
assert!(tags["pkg:both"].contains("pkg:from_pack"));
assert!(!tags["pkg:both"].contains("pkg:from_mod"));
// A path only the mod has reaches the tables too.
assert_eq!(
tags["pkg:mod_only"].iter().collect::<Vec<_>>(),
["pkg:only_mod"]
);
// The next load asks again: the new contribution, not the first's.
let files = overlay_files(
&dir,
std::path::Path::new(""),
true,
&[],
vec![("tags/pkg:item/mod_only".to_owned(), tag("pkg:second_load"))],
);
let overlay = horizon_world::loot::PackOverlay { files: &files };
let tags = horizon_items::items::load_tags(&dir, "item", &overlay).unwrap();
assert_eq!(
tags["pkg:mod_only"].iter().collect::<Vec<_>>(),
["pkg:second_load"]
);
// A file that isn't JSON is skipped alone, not the whole load.
let files = overlay_files(
&dir,
std::path::Path::new(""),
true,
&[],
vec![
("tags/pkg:item/mod_only".to_owned(), b"{not json".to_vec()),
("tags/pkg:item/good".to_owned(), tag("pkg:good")),
],
);
let overlay = horizon_world::loot::PackOverlay { files: &files };
let tags = horizon_items::items::load_tags(&dir, "item", &overlay).unwrap();
assert!(!tags.contains_key("pkg:mod_only"));
assert!(tags["pkg:good"].contains("pkg:good"));
// With nothing contributed the overlay is the packs' own.
assert_eq!(
overlay_files(&dir, std::path::Path::new(""), true, &[], Vec::new()),
content_overlay(&dir, std::path::Path::new(""), true, &[])
);
let _ = std::fs::remove_dir_all(&dir);
}
}
@@ -421,6 +421,7 @@ impl Game<'_> {
});
for (item, at, partial, entity) in live.chain(taken) {
let entity_start = out.len();
self.mark_motion(entity_start, (2 << 32) | u64::from(item.id));
let q = item.position;
let pos = glam::DVec3::from_array(at) - camera;
if pos.length_squared() > 64.0 * 64.0 {
@@ -128,7 +128,11 @@ impl Game<'_> {
e.velocity = [b.velocity.x, b.velocity.y, b.velocity.z];
e.meta = vehicle_meta(b.hurt_time, b.hurt_dir, b.damage);
// AbstractBoat.DATA_ID_PADDLE_LEFT and DATA_ID_PADDLE_RIGHT.
// Omitted false values must reset clients that saw a true value,
// including when the controlling passenger dismounts mid-stroke.
for (k, paddling) in b.paddling.iter().enumerate() {
e.defaults
.push((11 + k as u8, serializer::BOOLEAN, Value::Bool(false)));
if *paddling {
e.meta
.push((11 + k as u8, serializer::BOOLEAN, Value::Bool(true)));
@@ -226,6 +226,20 @@ impl Game<'_> {
})
.collect()
}
/// The same guests' cast fishing hooks, for `GameLevel::trigger_projectiles`.
pub(super) fn lan_hook_triggers(&self, out: &mut Vec<(Aabb, bool)>) {
let Some(host) = self.lan.as_ref() else {
return;
};
out.extend(
host.hub
.guests
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
.filter_map(|g| host.play.get(&g.conn)?.hook.as_ref())
.map(|hook| (hook.bounds(), hook.applies_block_effects())),
);
}
/// The same guests' feet and whether they stand on the ground, for the
/// blocks that also ask that (`BigDripleafBlock.canEntityTilt`).
pub(super) fn lan_guest_feet(&self) -> Vec<([f64; 3], bool)> {
+1 -1
View File
@@ -297,7 +297,7 @@ impl Game<'_> {
result.unwrap_or(Ok(()))
}
fn player_attacker(&self, host: &LanHost, conn: Option<u32>) -> Option<(u32, Text)> {
pub(super) fn player_attacker(&self, host: &LanHost, conn: Option<u32>) -> Option<(u32, Text)> {
match conn {
Some(conn) => {
let g = host.hub.guest(conn)?;
+2
View File
@@ -164,6 +164,8 @@ impl Game<'_> {
player_fall_flying,
ridden,
};
self.audio
.set_listener_reserve(self.settings.extras.ui_sound_reserve);
self.audio.tick(self.pause, &ctx);
}
/// `Minecraft.setLevel` -> `updateLevelInEngines`: `SoundManager.stop()`.
@@ -0,0 +1,39 @@
//! Biome colours a client mod changes (`native_biome_colors`), for seasons:
//! the grass, foliage, dry foliage and water tints the mesher blends, as a
//! resource pack's colormaps and biome effects set them, changed while the
//! game runs.
use super::Game;
use horizon_client::streaming::WorkerContext;
use std::sync::Arc;
// Native mods call it; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
/// Asks `native_biome_colors` again for every biome and, when the colours
/// differ from those in use, rebuilds every loaded section and the far
/// terrain with them (`LevelRenderer.allChanged`, as changing Biome Blend
/// does). Call it when your colours change (a new season, a new day of a
/// slow fade) and once when the player joins, not every frame: a rebuild
/// takes the workers a few seconds. API @API:biome_colors@.
pub(super) fn native_refresh_biome_colors(&mut self) {
let base = self.res.biomes().clone();
let biomes = base
.with_colors(|biome, colors| self.native_biome_colors(biome, colors))
.map_or(base, Arc::new);
if Arc::ptr_eq(&biomes, &self.pack.context.biomes) {
return;
}
let old = &self.pack.context;
let context = Arc::new(WorkerContext {
assets: old.assets.clone(),
render: old.render.clone(),
tables: old.tables.clone(),
biomes,
light: old.light.clone(),
});
self.pack.context = context.clone();
self.clear_lod();
self.streaming.reload_resources(&mut self.world, context);
}
}
@@ -63,6 +63,33 @@ pub struct BlockEntityDraw {
#[allow(dead_code)]
impl BlockEntityDraw {
/// A frame drawn from `pose` (relative to the camera), lit by `light` and
/// `lights`, adding to `vertices`; `block` is where `native_draw_item`'s
/// owner stands. A mob's frame (`native_mob_submit`) is made this way.
pub(super) fn posed(
partial: f32,
game_time: i64,
light: u32,
block: [i32; 3],
pose: Mat4,
lights: (Vec3, Vec3),
vertices: Vec<Vertex>,
) -> Self {
Self {
partial,
game_time,
light,
block,
poses: vec![pose],
lights,
vertices,
kept: false,
}
}
/// The vertices drawn, with those it was made with first.
pub(super) fn into_vertices(self) -> Vec<Vertex> {
self.vertices
}
/// `PoseStack.pushPose`: `pop` comes back to the pose as it is now.
pub fn push(&mut self) {
let pose = self.pose();
+228 -3
View File
@@ -277,11 +277,13 @@ use super::{
Game, GameLevel, LevelDimension, Screen,
execution_role::ExecutionRole,
native_block_entity_draw::BlockEntityDraw,
native_mob_draw::NativeMobRender,
native_panel_recipes::RecipePage,
native_server::{NativeCommand, NativeServerPlayer},
};
use horizon_client::{
atlas_sources::qualified,
biome::BiomeColors,
block_entities::Pos,
chat::ChatSource,
containers::{BlockEntity, MOD_MENUS, ModBlockEntity, ModContainer, ModFluidTanks},
@@ -1183,6 +1185,35 @@ impl Game<'_> {
) {
let _ = (context, phase, error_text);
}
/// The server's data tables are built (NeoForge's `TagsUpdatedEvent`
/// with `SERVER_DATA_LOAD`): the recipes, tags, loot and advancements of
/// the world's data packs, and the files mods added through
/// `native_data_pack_files`, are what the helpers read from now on.
/// `reload` is false on a world's first open, before its chunks and
/// players are loaded, and true after `/reload` rebuilt them, once the
/// players were sent the new data. The place to
/// drop caches derived from the data and to rebind what depends on it.
/// Server-side (the single player's integrated server included); a client
/// connected to a server never fires it. API @API:server_data_loaded@.
pub(super) fn native_server_data_loaded(&mut self, reload: bool) {
let _ = reload;
}
/// The server is assembling its data tables from the world's data packs
/// (`ReloadableServerResources`): a world is opening, or `/reload` was
/// run, and the recipes, tags, loot and advancements are built after
/// this. Push `(path, bytes)` pack files and they join this load. `path`
/// is the overlay's key, `<kind>/<namespace>:<path>` without `.json`, over
/// the kinds the tables read: `recipe/mymod:thing`,
/// `tags/mymod:block/thing`, `loot_table/…`, `advancement/…`,
/// `trade_set/…`, `villager_trade/…`, `dialog/…`. A file that isn't JSON
/// is skipped with a warning. Asked once per load on both paths, so this
/// is where a mod recomputes them.
/// Contributed files sit below the world's own data packs: a pack's file
/// for the same path still overrides a mod's. Server-side (the single
/// player's integrated server included). API @API:data_pack_files@.
pub(super) fn native_data_pack_files(&self, files: &mut Vec<(String, Vec<u8>)>) {
let _ = files;
}
/// The server is about to open its worlds: nothing of the world is
/// loaded yet (NeoForge's `ServerAboutToStartEvent`, "called before the
/// server begins loading anything"). Vanilla's `MinecraftServer.loadLevel`
@@ -1309,6 +1340,12 @@ impl Game<'_> {
pub(super) fn native_commands(&mut self, commands: &mut Vec<&'static str>) {
let _ = commands;
}
/// World field of view, after normal calculation and zoom and before projection/Nausea.
/// Called for world rendering and near-plane overlays; hand/HUD FOV uses its own path.
/// Only finite values in 1..=179 degrees replace the computed FOV.
pub(super) fn native_compute_fov(&self, partial: f32, camera: [f64; 3], degrees: &mut f32) {
let _ = (partial, camera, degrees);
}
/// The zoom a native mod asks for, applied while playing (the spyglass's
/// own view wins): set `zoom` when it is none yet.
pub(super) fn native_zoom(&self, zoom: &mut Option<NativeZoom>) {
@@ -1339,6 +1376,18 @@ impl Game<'_> {
let _ = (hand, stack, partial, swing, equip, canceled);
}
/// Final scaled third-person camera distance, before visual-shape collision clipping.
/// `entity` includes its living scale/distance and optional mount scale/distance.
/// Only finite requests in 0..=1024 blocks replace the vanilla distance.
pub(super) fn native_camera_distance(
&self,
partial: f32,
mirrored: bool,
entity: &horizon_client::camera::CameraEntity,
distance: &mut f32,
) {
let _ = (partial, mirrored, entity, distance);
}
/// The values native mods hand their shader packs in this frame,
/// `partial` ticks past the last tick (0 to 1): a pack's WGSL reads
/// them as `frame.mods` (eight `vec4<f32>`). Write the slots your own
@@ -1346,6 +1395,17 @@ impl Game<'_> {
/// one that pack is for. Asked once a frame while a pack is on; a value
/// that isn't finite reads as 0 (`native_shader_params_now`). API
/// @API:native_shader_params@.
/// Change the colours of `biome` (`minecraft:plains`) on this client: the
/// grass, foliage (leaves, vines), dry foliage (leaf litter) and water
/// tints, 0xRRGGBB, as its biome file and the colormaps set them. Asked
/// for every biome when a mod calls `native_refresh_biome_colors`, which
/// rebuilds the terrain with them; a season mod answers from its season
/// and calls that when the season turns. The dark forest's modifier
/// still applies to the grass set here; the swamp's noise colours
/// replace it. API @API:biome_colors@.
pub(super) fn native_biome_colors(&self, biome: &str, colors: &mut BiomeColors) {
let _ = (biome, colors);
}
pub(super) fn native_shader_params(&self, partial: f32, params: &mut [[f32; 4]; 8]) {
let _ = (partial, params);
}
@@ -1519,6 +1579,29 @@ impl Game<'_> {
) {
let _ = (pos, block, state, entity, bounds);
}
/// Draw the mob `mob` (its id, kind, custom name and the frame's body
/// and head turns and walk cycle, `LivingEntityRenderState`) on this
/// client with a model of your own, once a frame while it is in view
/// (vanilla's `EntityRenderer.submit`), and set `drawn` to leave out its
/// vanilla model, armour and held items (NeoForge's `RenderLivingEvent.Pre`,
/// cancelled). Add geometry with `native_draw_cube`, `native_draw_quad`,
/// `native_draw_block` and `native_draw_item` under `draw`'s pose stack,
/// which starts at the mob's feet turned with its body: +z is the way it
/// faces, +x its left, +y up, in blocks. `draw` also has the partial tick,
/// the game time and the mob's light. Its name tag, shadow, flames and
/// glowing outline draw as for any mob. Asked for every mob in view but
/// players; a mob your server half spawned (`native_spawn_mob`) is told
/// apart by its id, sent with mod messages, or by its custom name.
/// A mob is drawn afresh each frame: `draw.keep()` does nothing here.
/// API @API:mob_submit@.
pub(super) fn native_mob_submit(
&self,
mob: &NativeMobRender<'_>,
draw: &mut BlockEntityDraw,
drawn: &mut bool,
) {
let _ = (mob, draw, drawn);
}
/// A menu opens on this client for block `block`, a mod's: set `screen`
/// when the block is yours, to lay its screen out (`native_screen_draw`
/// draws it). The server asks it too: a slot it hides is not in the menu
@@ -1553,8 +1636,10 @@ impl Game<'_> {
/// It does not fire on the title screen, which returns before its point,
/// nor for the screens drawn over gameplay after it (a server dialog, the
/// server pack, link and code-of-conduct prompts, a book, a sign editor):
/// those keep `Screen::Playing`, which it skips, and draw over what it
/// drew, while vanilla's `ScreenEvent.Render.Post` fires for them.
/// those keep `Screen::Playing`, which it skips (the server pack prompt
/// hides an open screen too, and that screen does not fire under it),
/// and draw over what it drew, while vanilla's `ScreenEvent.Render.Post`
/// fires for them.
/// API 78.
pub(super) fn native_screen_render_post(
&self,
@@ -3281,6 +3366,94 @@ impl Game<'_> {
batch.scale_since(start, at[0], at[1], size / 16.0, size / 16.0);
true
}
/// Item `item`'s icon drawn with model `model` (`minecraft:paper` as
/// `mymod:wheel_icon`, an `item_model` of a content mod's), as `native_item`
/// draws one; false for an unknown item. API @API:native_mod_screen@.
pub(super) fn native_item_model(
&self,
batch: &mut HudBatch,
item: &str,
model: &str,
at: [f32; 2],
size: f32,
) -> bool {
if self.res.items().get(item).is_none() {
return false;
}
let mut stack = ItemStack::new(item, 1);
let mut data = horizon_client::item_data::StackData::default();
data.set_other(
"minecraft:item_model",
Some(horizon_world::nbt::Tag::String(model.into())),
);
stack.extra = Some(Box::new(data));
let start = batch.vertices.len();
self.draw_item_model(batch, &stack, at[0], at[1]);
batch.scale_since(start, at[0], at[1], size / 16.0, size / 16.0);
true
}
/// A quad of corners `points` (in order round it) in GUI pixels, `argb`:
/// a wedge, a slanted bar, what `native_fill`'s rectangles can't draw.
/// API @API:native_mod_screen@.
pub(super) fn native_quad(&self, batch: &mut HudBatch, points: [[f32; 2]; 4], argb: u32) {
let [a, r, g, b] = argb.to_be_bytes();
batch.fill_quad(&self.gui.atlas, points, rgba(r, g, b, a));
}
/// Opens a screen of mod `mod_id`'s own over the world: the screen hooks
/// fire over it as over any other, so the mod draws it in
/// `native_screen_render_post` and takes its clicks, scroll, keys and
/// typing there; the mouse is freed, and Escape closes it. Every mod's own
/// screen has the `NativeScreenView.id` `mod`, so a mod's screen hooks
/// check `native_mod_screen()` is its own before drawing. It doesn't pause
/// a single-player game (as the inventory doesn't). True when it is open:
/// false while another screen is (or another mod's), over the title
/// screen, a book, a sign editor, a dialog or a prompt, while the world
/// loads, and anywhere but the window's own client (the server thread, a
/// dedicated server). A gamepad can close it (B, Start) but not click on
/// it: its clicks don't reach the mouse hooks. API @API:native_mod_screen@.
pub(super) fn native_mod_screen_open(&mut self, mod_id: &str) -> bool {
// What `screen_hooks_open` would refuse over the world: the mod's
// screen would be up with no hook to draw or click it.
let blocked = self.title.is_some()
|| self.renderer.is_none()
|| self.lan_acting.is_some()
|| self.player.is_none()
|| self.screen_over_playing()
|| self.remote_loading();
let owner = self.native_mod_screen();
if let Some(open) = mod_screen_opens(self.screen, blocked, owner, mod_id) {
return open;
}
self.native_mod_screen = Some(mod_id.to_owned());
self.screen = Screen::Mod;
self.keys.clear();
self.left = false;
self.right = false;
self.mining = None;
self.capture(false);
true
}
/// Closes mod `mod_id`'s own screen, if it is open. API @API:native_mod_screen@.
pub(super) fn native_mod_screen_close(&mut self, mod_id: &str) {
if self.native_mod_screen().is_some_and(|id| id == mod_id) {
self.close_mod_screen();
}
}
/// The mod whose own screen is open, if one is (Escape, or another screen
/// replacing it, closes it). API @API:native_mod_screen@.
pub(super) fn native_mod_screen(&self) -> Option<&str> {
self.native_mod_screen
.as_deref()
.filter(|_| self.screen == Screen::Mod)
}
/// A mod's own screen closes (Escape, or `native_mod_screen_close`).
pub(super) fn close_mod_screen(&mut self) {
self.native_mod_screen = None;
if self.screen == Screen::Mod {
self.screen = Screen::Playing;
self.capture(true);
}
}
/// The open menu's title (the block's name, or the one it was given),
/// as plain text. API 20.
pub(super) fn native_screen_title(&self) -> Option<String> {
@@ -4492,9 +4665,57 @@ fn box_meets(b: &horizon_core::geometry::Aabb, min: [f64; 3], max: [f64; 3]) ->
&& hi.z > min[2]
}
/// What `native_mod_screen_open(mod_id)` does over `screen` (`blocked`: an
/// overlay, the title screen, no window or no player; `owner`'s own screen
/// open): `None` opens it, `Some` answers without changing anything (true:
/// `mod_id`'s is already open).
fn mod_screen_opens(
screen: Screen,
blocked: bool,
owner: Option<&str>,
mod_id: &str,
) -> Option<bool> {
if screen == Screen::Mod {
return Some(owner == Some(mod_id));
}
if screen != Screen::Playing || blocked {
return Some(false);
}
None
}
#[cfg(test)]
mod tests {
/// A mod's own screen opens only over the world, never over another
/// screen, an overlay (the title screen, a book, loading, no window) or
/// another mod's; opening it again is a no-op.
#[test]
fn a_mod_screen_opens_only_over_the_world() {
use super::{Screen, mod_screen_opens};
assert_eq!(mod_screen_opens(Screen::Playing, false, None, "a"), None);
assert_eq!(
mod_screen_opens(Screen::Playing, true, None, "a"),
Some(false)
);
for other in [
Screen::Paused,
Screen::Inventory,
Screen::Chat,
Screen::Options,
] {
assert_eq!(mod_screen_opens(other, false, None, "a"), Some(false));
}
assert_eq!(
mod_screen_opens(Screen::Mod, false, Some("a"), "a"),
Some(true)
);
assert_eq!(
mod_screen_opens(Screen::Mod, false, Some("b"), "a"),
Some(false)
);
}
/// The characters a `ScreenEvent.CharacterTyped.Pre` sees are the
/// printable ones of a text: a cancelled one leaves that text and a
/// control character (a key, not text) is never offered a hook.
@@ -4749,7 +4970,11 @@ mod tests {
// From its doc comment's last line, so an attribute above it counts.
let hook = source.find("pub fn native_loot_table_load(").unwrap();
let start = source[..hook].rfind("///").unwrap();
let body = &source[start..start + source[start..].find("\n}\n").unwrap()];
let end = source[start..]
.find("\n}\n")
.or_else(|| source[start..].find("\r\n}\r\n"))
.unwrap();
let body = &source[start..start + end];
for line in body.lines() {
assert!(
!line.contains("cfg"),
@@ -0,0 +1,155 @@
//! Mobs drawn by native mods on the client: `native_mob_submit` (vanilla's
//! `EntityRenderer.submit` for a mob, NeoForge's `RenderLivingEvent.Pre` when
//! it cancels) gets the mob's render state and a [`BlockEntityDraw`] posed at
//! its feet and turned with its body, so a mod draws a model of its own with
//! the helpers block entities draw with (`native_draw_cube`,
//! `native_draw_quad`, `native_draw_block`, `native_draw_item`).
use super::{Dim, Game, native_block_entity_draw::BlockEntityDraw};
use glam::{DVec3, Mat4};
use horizon_client::{
block_entity_render::diffuse_lights,
mesher::Vertex,
mobs::{Mob, anim, living::wrap},
};
/// One mob's frame, as `EntityRenderer.extractRenderState` fills a
/// `LivingEntityRenderState`: what a model reads to pose and animate itself.
/// Angles are in degrees, interpolated to the frame's partial tick.
// Native mods read it; nothing in Horizon does.
#[allow(dead_code)]
#[derive(Clone, Debug)]
pub struct NativeMobRender<'a> {
/// The mob's network id, as the mob helpers and mod messages name it.
pub id: u32,
/// Its entity type (`minecraft:villager`).
pub kind: &'a str,
/// Its `CustomName` as plain text, when it has one.
pub custom_name: Option<&'a str>,
/// `isBaby`.
pub baby: bool,
/// The frame's partial tick, 0 to 1.
pub partial: f32,
/// `ageInTicks`: ticks lived plus the partial tick, for idle motion.
pub age: f32,
/// `bodyRot`: the way the body faces (0 is south, 90 west).
pub body_yaw: f32,
/// `yRot`: the head's turn from the body, -180 to 180.
pub head_yaw: f32,
/// `xRot`: the head's pitch, positive looking down.
pub pitch: f32,
/// `walkAnimationPos`: how far the legs have swung, for a walk cycle
/// (a vanilla quadruped's legs swing `cos(pos * 0.6662) * 1.4 * speed` radians).
pub walk_position: f32,
/// `walkAnimationSpeed`: how fast it walks, 0 standing to about 1.
pub walk_speed: f32,
/// Ticks left of the red hurt flash (10 when hit).
pub hurt_time: i32,
/// Ticks since it died, 0 while alive (vanilla tips the body over 20).
pub death_time: i32,
pub on_ground: bool,
}
impl<'a> NativeMobRender<'a> {
/// The render state of `m` at `partial` (`LivingEntityRenderer.extractRenderState`).
pub(super) fn of(m: &'a Mob, partial: f32) -> Self {
let body = m.previous_body_yaw + wrap(m.body_yaw - m.previous_body_yaw) * partial;
// The walk cycle, head turn and age as the vanilla models read them:
// no walking while riding or dead, a baby's legs three times as far
// (`WalkAnimationState.position(partial)`, `positionScale`).
let pose = anim::state(m, partial);
Self {
id: m.id,
kind: &m.kind.id,
custom_name: m.custom_name.as_deref(),
baby: m.is_baby(),
partial,
age: pose.age,
body_yaw: body,
head_yaw: pose.y_rot,
pitch: pose.x_rot,
walk_position: pose.pos,
walk_speed: pose.speed,
hurt_time: m.hurt_time,
death_time: m.death_time,
on_ground: m.on_ground,
}
}
}
/// The pose a mod's mob model starts from: at the feet, `at` (relative to the
/// camera), turned with the body so +z is the way it faces, +x its left and +y
/// up, in blocks.
pub(super) fn mob_pose(at: DVec3, body_yaw: f32) -> Mat4 {
Mat4::from_translation(at.as_vec3()) * Mat4::from_rotation_y((-body_yaw).to_radians())
}
impl Game<'_> {
/// `native_mob_submit` for `mob`, lit by `light` (packed as the entity
/// pass packs it): true when a mod drew it, and its vanilla model, armour
/// and held items are left out. The name tag, shadow, fire and a glowing
/// outline still draw as for any mob.
pub(super) fn mod_mob_geometry(
&self,
mob: &Mob,
partial: f32,
camera: DVec3,
light: u32,
out: &mut Vec<Vertex>,
) -> bool {
let render = NativeMobRender::of(mob, partial);
let a = f64::from(partial);
let feet = mob.previous.lerp(mob.position, a);
let feet = DVec3::new(feet.x, feet.y, feet.z);
let start = out.len();
let mut draw = BlockEntityDraw::posed(
partial,
self.world.game_time,
light,
[feet.x, feet.y, feet.z].map(|c| c.floor() as i32),
mob_pose(feet - camera, render.body_yaw),
diffuse_lights(self.dim == Dim::Nether),
std::mem::take(out),
);
let mut drawn = false;
self.native_mob_submit(&render, &mut draw, &mut drawn);
*out = draw.into_vertices();
if !drawn {
out.truncate(start);
}
drawn
}
}
#[cfg(test)]
mod tests {
use super::*;
use glam::Vec3;
fn close(a: Vec3, b: Vec3) -> bool {
(a - b).length() < 1e-5
}
#[test]
fn the_pose_faces_where_the_body_faces() {
// Entity yaw 0 faces south (+z), 90 west (-x), 180 north, 270 east.
for (yaw, ahead, left) in [
(0.0, Vec3::Z, Vec3::X),
(90.0, Vec3::NEG_X, Vec3::Z),
(180.0, Vec3::NEG_Z, Vec3::NEG_X),
(270.0, Vec3::X, Vec3::NEG_Z),
] {
let pose = mob_pose(DVec3::ZERO, yaw);
assert!(close(pose.transform_vector3(Vec3::Z), ahead), "{yaw}");
assert!(close(pose.transform_vector3(Vec3::X), left), "{yaw}");
assert!(close(pose.transform_vector3(Vec3::Y), Vec3::Y), "{yaw}");
}
}
#[test]
fn the_pose_starts_at_the_feet() {
let at = DVec3::new(3.0, -1.5, 0.25);
let pose = mob_pose(at, 37.0);
assert!(close(pose.transform_point3(Vec3::ZERO), at.as_vec3()));
}
}
+16
View File
@@ -281,6 +281,7 @@ impl Game<'_> {
gui,
pack,
textures,
cem,
} = staged;
// Keep the working GPU resources, and hand its window surface over.
if let Some(renderer) = &mut self.renderer {
@@ -298,6 +299,8 @@ impl Game<'_> {
self.renderer.as_ref(),
Some(textures),
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
super::shader_packs::picked_pack_draws_flat(&self.settings),
)
})
.transpose()
@@ -346,6 +349,12 @@ impl Game<'_> {
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
horizon_client::mobs::villager_hats::VillagerHats::load(&root);
*self
.res
.mob_data()
.cem
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = cem;
horizon_client::ui::load_metadata(&root);
lang::set(lang::Language::load(&[&root], &lang::code()));
self.refresh_remote_language();
@@ -423,12 +432,17 @@ pub(super) fn stage_pack(
context.light.clone(),
)?;
let textures = prepare_renderer_textures(&pack);
let cem = Arc::new(horizon_client::mobs::cem::CemModels::load(
&root,
res.mob_types(),
));
progress.set(3);
Ok(StagedPack {
root,
gui,
pack,
textures,
cem,
})
}
@@ -439,6 +453,8 @@ pub(super) struct StagedPack {
pub gui: UiResources,
pub pack: PackResources,
pub textures: PreparedTextures,
/// The packs' custom entity models (OptiFine CEM).
pub cem: Arc<horizon_client::mobs::cem::CemModels>,
}
/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`.
+68 -9
View File
@@ -455,6 +455,14 @@ impl Game<'_> {
Button::South | Button::West => {
self.pad_take_pointer(event_loop);
self.pad_pointer_button(event_loop, button == Button::West, true);
// A on a text field in pointer mode opens the keyboard, as
// in focus mode (not the right click).
if button == Button::South
&& !self.pad_focusing()
&& field_at(&self.pad_targets(), self.cursor)
{
self.pad_open_keyboard();
}
}
// Y in an inventory: a shift-click (quick move).
Button::North if matches!(self.screen, Screen::Inventory | Screen::Creative) => {
@@ -1311,17 +1319,17 @@ impl Game<'_> {
self.pad.menu.osk = Some(Keyboard::default());
}
/// Chat or a sign opened by the pad in focus mode: the keyboard opens
/// with it (once per screen; B closes it for good).
/// Chat or a sign opened by the pad (either menu mode): the keyboard
/// opens with it (once per screen; B closes it for good).
fn pad_auto_keyboard(&mut self) {
let menu = &self.pad.menu;
if menu.osk.is_none()
&& !menu.osk_dismissed
&& self.pad.in_use
&& menu.open_polls >= 3
&& self.pad_focus_mode()
&& (self.screen == Screen::Chat || self.sign_edit.is_some())
{
if osk_auto_opens(
menu.osk.is_some(),
menu.osk_dismissed,
self.pad.in_use,
menu.open_polls,
self.screen == Screen::Chat || self.sign_edit.is_some(),
) {
self.pad.menu.osk_dismissed = true;
self.pad_open_keyboard();
}
@@ -1468,3 +1476,54 @@ impl Game<'_> {
key_focus::draw_outline(&self.gui.atlas, batch, focus);
}
}
/// Whether the keyboard opens by itself over chat or a sign: the pad is in
/// use and the screen has been open for a few polls, and it is neither
/// open nor dismissed already. Independent of the menu mode.
fn osk_auto_opens(
osk_open: bool,
dismissed: bool,
pad_in_use: bool,
polls: u32,
typing_screen: bool,
) -> bool {
!osk_open && !dismissed && pad_in_use && polls >= 3 && typing_screen
}
/// Whether a text field in `targets` contains the point `at` (GUI pixels).
fn field_at(targets: &[Target], at: (f32, f32)) -> bool {
targets.iter().any(|t| {
let [x, y, w, h] = t.rect;
t.kind == Kind::Field && at.0 >= x && at.0 < x + w && at.1 >= y && at.1 < y + h
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keyboard_auto_opens_for_pad_on_typing_screen() {
assert!(osk_auto_opens(false, false, true, 3, true));
assert!(!osk_auto_opens(false, false, true, 2, true));
assert!(!osk_auto_opens(false, false, false, 5, true));
assert!(!osk_auto_opens(false, true, true, 5, true));
assert!(!osk_auto_opens(true, false, true, 5, true));
assert!(!osk_auto_opens(false, false, true, 5, false));
}
#[test]
fn field_hit_test() {
let t = |rect, kind| Target { rect, kind };
let targets = [
t([0.0, 0.0, 50.0, 20.0], Kind::Button),
t([10.0, 30.0, 100.0, 20.0], Kind::Field),
];
assert!(field_at(&targets, (10.0, 30.0)));
assert!(field_at(&targets, (109.0, 49.0)));
assert!(!field_at(&targets, (110.0, 40.0)));
assert!(!field_at(&targets, (50.0, 29.0)));
assert!(!field_at(&targets, (5.0, 5.0)), "a button is not a field");
assert!(!field_at(&[], (5.0, 5.0)));
}
}
+89 -8
View File
@@ -79,6 +79,20 @@ impl PaintingVariants {
fn overlaps(a: ([f64; 3], [f64; 3]), b: ([f64; 3], [f64; 3])) -> bool {
(0..3).all(|i| a.0[i] < b.1[i] && b.0[i] < a.1[i])
}
/// `HangingEntityItem.mayPlace` for a painting: not a floor or ceiling.
/// Direction.get2DDataValue: south 0, west 1, north 2, east 3.
fn painting_facing(face: [i32; 3]) -> Option<u8> {
if face[1] != 0 {
return None;
}
Some(match face {
[0, 0, 1] => 0,
[-1, 0, 0] => 1,
[0, 0, -1] => 2,
_ => 3,
})
}
impl Game<'_> {
/// `HangingEntity.survives`: no block collision in the box, solid blocks
/// (or diodes) behind it and no other painting overlapping it.
@@ -117,16 +131,58 @@ impl Game<'_> {
.iter()
.any(|o| o.id != p.id && overlaps(o.bounds(), (lo, hi)))
}
/// `HangingEntityItem.useOn` for a painting against `hit`'s face.
pub(super) fn place_painting(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:painting" || hit.face[1] != 0 {
/// `HangingEntityItem.useOn` on a client level: SUCCESS once
/// `Painting.create` finds a surviving variant (and the stack's
/// `painting/variant`, if any, also survives). The random pick and
/// spawn are the server's.
pub(super) fn painting_fits(&self, hit: &Hit, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:painting" {
return false;
}
let facing = match hit.face {
[0, 0, 1] => 0,
[-1, 0, 0] => 1,
[0, 0, -1] => 2,
_ => 3,
let Some(facing) = painting_facing(hit.face) else {
return false;
};
let pos = [
hit.pos[0] + hit.face[0],
hit.pos[1],
hit.pos[2] + hit.face[2],
];
let mut candidate = Painting {
id: self.next_entity.wrapping_add(1),
pos,
facing,
variant: Arc::from(""),
width: 1,
height: 1,
check_interval: 0,
};
let mut any = false;
for id in &self.painting_variants.placeable {
let Some(v) = self.painting_variants.get(id) else {
continue;
};
(candidate.width, candidate.height) = (v.width, v.height);
if self.painting_survives(&candidate) {
any = true;
break;
}
}
if !any {
return false;
}
if let Some(v) = self.painting_variants.of_stack(stack).cloned() {
(candidate.variant, candidate.width, candidate.height) = (v.id, v.width, v.height);
return self.painting_survives(&candidate);
}
true
}
/// `HangingEntityItem.useOn` for a painting against `hit`'s face.
pub(super) fn place_painting(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:painting" {
return false;
}
let Some(facing) = painting_facing(hit.face) else {
return false;
};
let pos = [
hit.pos[0] + hit.face[0],
@@ -187,6 +243,8 @@ impl Game<'_> {
if !self.creative {
self.consume_held();
}
// HangingEntityItem.useOn returns SUCCESS: the hand swings.
self.swing_arm();
true
}
/// The painting under the crosshair within reach and its distance.
@@ -408,3 +466,26 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `HangingEntityItem.useOn` returns SUCCESS (the hand swings) when a
/// painting is created on a wall. Floor, ceiling, or a failed create is
/// CONSUME: no swing.
fn painting_use_on_swings(face: [i32; 3], created: bool) -> bool {
painting_facing(face).is_some() && created
}
#[test]
fn placing_a_painting_on_a_wall_swings() {
assert!(painting_use_on_swings([0, 0, 1], true));
assert!(painting_use_on_swings([-1, 0, 0], true));
assert!(painting_use_on_swings([0, 0, -1], true));
assert!(painting_use_on_swings([1, 0, 0], true));
assert!(!painting_use_on_swings([0, 1, 0], true));
assert!(!painting_use_on_swings([0, -1, 0], true));
assert!(!painting_use_on_swings([0, 0, 1], false));
}
}
@@ -445,6 +445,8 @@ impl Game<'_> {
velocity: entity_nbt::motion(&tag),
fuse,
on_ground: matches!(tag.get("OnGround"), Some(Tag::Byte(1))),
owner: None,
guest: None,
});
}
}
+23 -12
View File
@@ -275,18 +275,28 @@ impl Game<'_> {
facts: self.res.facts(),
context,
};
view.align_with_entity(&entity, partial, kind, |from, to| {
let hit = clip_world(
&mut rays,
Vec3::new(from[0], from[1], from[2]),
Vec3::new(to[0], to[1], to[2]),
RayBlockMode::Visual,
RayFluidMode::None,
4096,
)
.ok()?;
(!hit.miss).then_some([hit.hit.location.x, hit.hit.location.y, hit.hit.location.z])
});
view.align_with_entity_distance(
&entity,
partial,
kind,
|distance| {
let mut distance = distance;
self.native_camera_distance(partial, kind.is_mirrored(), &entity, &mut distance);
distance
},
|from, to| {
let hit = clip_world(
&mut rays,
Vec3::new(from[0], from[1], from[2]),
Vec3::new(to[0], to[1], to[2]),
RayBlockMode::Visual,
RayFluidMode::None,
4096,
)
.ok()?;
(!hit.miss).then_some([hit.hit.location.x, hit.hit.location.y, hit.hit.location.z])
},
);
self.view = view;
Some((
glam::DVec3::from_array(view.position),
@@ -1405,6 +1415,7 @@ mod preview_tests {
biome_tags: Default::default(),
entity_tags: Default::default(),
villager_hats: Default::default(),
cem: Default::default(),
pois: Default::default(),
species_placements: Default::default(),
};
+4 -1
View File
@@ -1,4 +1,4 @@
//! `ItemPickupParticle`: a taken item entity, experience orb or arrow flies
//! `ItemPickupParticle`: a taken item entity, experience orb, arrow or trident flies
//! into whoever took it over three ticks (`ClientPacketListener.handleTakeItemEntity`
//! adds one for every `ClientboundTakeItemEntityPacket`), drawn with the render
//! state it had when it was taken (`extractEntity(from, 1.0F)`).
@@ -16,6 +16,7 @@ pub(super) enum Taken {
Item(Box<ItemEntity>),
Orb(Box<ExperienceOrb>),
Arrow(Box<Projectile>),
Trident(Box<super::trident::ThrownTrident>),
}
impl Taken {
/// The render state's position: the entity's at partial tick 1.
@@ -24,6 +25,7 @@ impl Taken {
Taken::Item(i) => i.position,
Taken::Orb(o) => o.position,
Taken::Arrow(a) => a.position,
Taken::Trident(t) => t.position,
};
[p.x, p.y, p.z]
}
@@ -122,6 +124,7 @@ impl Game<'_> {
Taken::Item(_) => "item",
Taken::Orb(_) => "orb",
Taken::Arrow(_) => "arrow",
Taken::Trident(_) => "trident",
};
*self.pickups.made.entry(kind).or_default() += 1;
self.pickups.added.push(Pickup {
+185 -9
View File
@@ -24,7 +24,7 @@ use horizon_client::{
text::Text,
};
use horizon_core::{
geometry::{Aabb, Direction, Vec3},
geometry::{Aabb, Axis, Direction, Vec3},
random::{LegacyRandom, RandomSource},
};
@@ -42,6 +42,44 @@ pub(super) struct ThrownPotion {
/// `Projectile.getOwner`'s display name: the player who threw it (none
/// for a dispenser's).
owner: Option<Text>,
/// `Projectile.getOwner` as a target: the thrower (`Who::Player`, a
/// guest's with `guest` set), none for a dispenser's or a server's.
thrower: Option<Who>,
/// On a server, the guest (by connection) who threw it.
guest: Option<u32>,
/// `Projectile.leftOwner`: until then it can't hit its thrower.
left_owner: bool,
}
/// The player (not one of `skip`, the thrower and who rides with them before
/// it has left them) whose box, grown by `margin` (`ProjectileUtil.computeMargin`),
/// holds `p` (`Projectile.canHitEntity`).
fn thrown_player_at<'a>(
players: &'a [(Who, Aabb)],
skip: &[Who],
p: Vec3,
margin: f64,
) -> Option<&'a (Who, Aabb)> {
players.iter().find(|(who, b)| {
!skip.contains(who) && b.inflate(Vec3::new(margin, margin, margin)).contains(p)
})
}
/// `ProjectileUtil.computeMargin`: how far the pick box is grown at `age`.
fn thrown_margin(age: u32) -> f64 {
((f64::from(age) - 2.0) / 20.0).clamp(0.0, 0.3)
}
/// `Projectile.isOutsideOwnerCollisionRange` (`checkLeftOwner`): none of the
/// boxes of the thrower's vehicle tree (`owner.getRootVehicle().getSelfAndPassengers()`,
/// here `ridden`) is within a block of the potion's (0.25 wide) grown by its
/// motion; true too when there is no thrower (`ridden` empty).
fn thrown_left_owner(ridden: &[Aabb], position: Vec3, velocity: Vec3) -> bool {
let h = 0.125;
let reach = Aabb::new(
Vec3::new(position.x - h, position.y, position.z - h),
Vec3::new(position.x + h, position.y + 2.0 * h, position.z + h),
)
.expand_towards(velocity)
.inflate(Vec3::new(1.0, 1.0, 1.0));
!ridden.iter().any(|b| b.intersects(reach))
}
/// `AreaEffectCloud` wait time set by `ThrownLingeringPotion.onHitAsPotion`.
pub(super) const LINGERING_WAIT: i32 = 10;
@@ -296,7 +334,10 @@ impl Game<'_> {
position,
velocity,
age: 0,
thrower: owner.is_some().then_some(Who::Player),
guest: self.lan_acting.filter(|_| owner.is_some()),
owner,
left_owner: false,
});
}
}
@@ -378,6 +419,9 @@ impl PotionState {
velocity,
age: 0,
owner: None,
thrower: None,
guest: None,
left_owner: false,
});
}
/// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it shows.
@@ -959,14 +1003,86 @@ impl Game<'_> {
p.status = status;
}
}
/// The players a thrown potion can hit (`Projectile.canHitEntity`): the
/// host and the guests, but not a spectator or a dead one.
fn thrown_player_targets(&self) -> Vec<(Who, Aabb)> {
let mut targets = Vec::new();
if let Some(p) = self.player_view()
&& p.alive
&& !self.spectator()
{
targets.push((Who::Player, p.bounds));
}
targets.extend(self.lan_projectile_targets());
targets
}
/// The thrower's vehicle tree as `Projectile` sees it
/// (`owner.getRootVehicle().getSelfAndPassengers()`): the players in it (the
/// thrower and who shares their vehicle), the mob that is the vehicle, and
/// the boxes of all of them. Until the potion has left it, none can be hit
/// (`canHitEntity`: `isPassengerOfSameVehicle`) and any one near keeps it
/// from leaving (`isOutsideOwnerCollisionRange`).
fn thrown_ridden(
&self,
owner: Option<Who>,
players: &[(Who, Aabb)],
) -> (Vec<Who>, Option<u32>, Vec<Aabb>) {
let Some(owner) = owner else {
return (Vec::new(), None, Vec::new());
};
let vehicle_of = |who: Who| match who {
Who::Player => self.riding,
Who::Mob(id) => self
.lan
.as_ref()
.and_then(|h| h.hub.guests.iter().find(|g| g.entity_id as u32 == id))
.and_then(|g| g.vehicle),
};
let vehicle = vehicle_of(owner);
let mut who = vec![owner];
let mut boxes = Vec::new();
for (w, b) in players {
if *w == owner || (vehicle.is_some() && vehicle_of(*w) == vehicle) {
if *w != owner {
who.push(*w);
}
boxes.push(*b);
}
}
if let Some(v) = vehicle {
if let Some(m) = self.mobs.iter().find(|m| m.id == v) {
boxes.push(m.bounds());
}
if let Some(b) = self.boats.iter().find(|b| b.id == v) {
boxes.push(b.bounds());
}
}
(who, vehicle, boxes)
}
/// `ThrowableProjectile.tick` for the potions in flight: the motion, then
/// the first block or mob the step meets (`ProjectileUtil.getHitResultOnMoveVector`),
/// where the potion splashes (`Projectile.onHit`).
/// the first block, mob or player the step meets
/// (`ProjectileUtil.getHitResultOnMoveVector`), where the potion splashes
/// (`Projectile.onHit`). Its thrower is not met until it has left them
/// (`Projectile.leftOwner`, checked before the move like `Projectile.tick`),
/// nor anything else in their vehicle tree.
fn tick_thrown_potions(&mut self) {
let mut thrown = std::mem::take(&mut self.potions.thrown);
let players = if thrown.is_empty() {
Vec::new()
} else {
self.thrown_player_targets()
};
let mut landed = Vec::new();
thrown.retain_mut(|t| {
t.age += 1;
// Projectile.tick: whether it has left its thrower's vehicle tree,
// before it moves.
let owner = self.shooter_who(t.thrower, t.guest);
let (ridden, vehicle, boxes) = self.thrown_ridden(owner, &players);
if !t.left_owner {
t.left_owner = thrown_left_owner(&boxes, t.position, t.velocity);
}
let skip: &[Who] = if t.left_owner { &[] } else { &ridden };
t.previous = t.position;
self.thrown_potion_motion(t);
let next = Vec3::new(
@@ -974,7 +1090,9 @@ impl Game<'_> {
t.position.y + t.velocity.y,
t.position.z + t.velocity.z,
);
// Hit a block along the step (sampled), a mob, or fell out of the world.
// Hit a block along the step (sampled), a mob, a player, or fell out
// of the world.
let margin = thrown_margin(t.age);
let steps = 4;
let mut hit = None;
for i in 1..=steps {
@@ -989,18 +1107,27 @@ impl Game<'_> {
.block(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
let mob = self.mobs.iter().find(|m| {
m.alive()
&& (t.left_owner || Some(m.id) != vehicle)
&& (m.position.x - p.x).abs() < m.kind.width / 2.0 + 0.125
&& (m.position.z - p.z).abs() < m.kind.width / 2.0 + 0.125
&& p.y >= m.position.y
&& p.y <= m.position.y + m.kind.height
});
if self.res.facts().get(state).full_collision || mob.is_some() {
// A player (`canHitEntity`: not the thrower before it has left
// them), the box grown by the 0.3 pick margin.
let player = thrown_player_at(&players, skip, p, margin);
if self.res.facts().get(state).full_collision || mob.is_some() || player.is_some() {
// A block hit has the face it came in by
// (`onHitBlock`); a mob hit has none.
let block = mob
.is_none()
// (`onHitBlock`); an entity hit has none.
let block = (mob.is_none() && player.is_none())
.then(|| thrown_block_hit(t.position, p, t.velocity));
hit = Some((p, mob.map(|m| m.position), block));
let at = mob.map(|m| m.position).or_else(|| {
player.map(|(_, b)| {
let c = b.center();
Vec3::new(c.x, b.min(Axis::Y), c.z)
})
});
hit = Some((p, at, block));
break;
}
}
@@ -1615,6 +1742,55 @@ mod tests {
use super::*;
use horizon_client::screen_effects::nausea_blend_added;
/// #5876: a potion thrown straight up from inside its thrower's box does
/// not hit them while it climbs out, then hits them on the way down;
/// another player in its path is hit at once.
#[test]
fn a_thrown_potion_hits_its_thrower_only_after_leaving_them() {
// A player's box: 0.6 wide, 1.8 high, feet at the origin.
let body = |x: f64| Aabb::new(Vec3::new(x - 0.3, 0.0, -0.3), Vec3::new(x + 0.3, 1.8, 0.3));
let thrower = Who::Mob(1);
let players = [(thrower, body(0.0)), (Who::Mob(2), body(5.0))];
let ridden = [body(0.0)];
let eye = Vec3::new(0.0, 1.52, 0.0);
let up = Vec3::new(0.0, 0.5, 0.0);
let m = thrown_margin(10);
assert!((m - 0.3).abs() < 1e-12 && thrown_margin(1) == 0.0);
// Thrown from the eye: still within a block of its thrower.
assert!(!thrown_left_owner(&ridden, eye, up));
assert!(thrown_player_at(&players, &[thrower], eye, m).is_none());
// Without the gate the thrower's box does hold it.
assert_eq!(
thrown_player_at(&players, &[], eye, m).map(|t| t.0),
Some(thrower)
);
// Four blocks up and rising: clear of the box grown by a block.
let high = Vec3::new(0.0, 5.0, 0.0);
assert!(thrown_left_owner(&ridden, high, up));
// Falling back (left_owner now set), the thrower is hit at their head.
let head = Vec3::new(0.0, 2.0, 0.0);
assert_eq!(
thrown_player_at(&players, &[], head, m).map(|t| t.0),
Some(thrower)
);
// Another player is hit though the thrower has not left; a miss is none.
let beside = Vec3::new(5.2, 1.0, 0.0);
assert_eq!(
thrown_player_at(&players, &[thrower], beside, m).map(|t| t.0),
Some(Who::Mob(2))
);
assert!(thrown_player_at(&players, &[thrower], Vec3::new(2.5, 1.0, 0.0), m).is_none());
// A passenger sharing the thrower's vehicle is skipped like the thrower.
assert!(thrown_player_at(&players, &[thrower, Who::Mob(2)], beside, m).is_none());
// The vehicle's box keeps it from leaving: a mount 2 blocks wide.
let mount = Aabb::new(Vec3::new(-1.0, -1.0, -1.0), Vec3::new(1.0, 1.0, 1.0));
let beside_mount = Vec3::new(1.9, 0.0, 0.0);
assert!(thrown_left_owner(&[body(0.0)], beside_mount, up));
assert!(!thrown_left_owner(&[body(0.0), mount], beside_mount, up));
// No thrower (a dispenser's potion): it has left them already.
assert!(thrown_left_owner(&[], eye, up));
}
/// A server's client (multiplayer and the default single player) fades
/// the darkness effect in over its 22 blend ticks, holds it, and fades
/// it out over the last 22 (#3393).
+27 -4
View File
@@ -3587,9 +3587,15 @@ impl Game<'_> {
.projectiles
.iter()
.find(|s| s.id == p.item as u32);
let shot = shot.map(|s| s.position).or_else(|| {
self.tridents
.iter()
.find(|t| t.id == p.item as u32)
.map(|t| t.position)
});
let at = match (item, shot) {
(Some(d), _) => Some([d.position.x, d.position.y, d.position.z]),
(None, Some(s)) => Some([s.position.x, s.position.y, s.position.z]),
(None, Some(s)) => Some([s.x, s.y, s.z]),
(None, None)
if self
.remote
@@ -3630,8 +3636,12 @@ impl Game<'_> {
.find(|s| s.id == p.item as u32)
.cloned()
{
// An arrow or trident flies to its collector too, then goes.
// An arrow flies to its collector too, then goes.
self.add_pickup(super::pickups::Taken::Arrow(Box::new(shot)), collector);
} else if let Some(i) = self.tridents.iter().position(|t| t.id == p.item as u32) {
// So does a trident: a server's client keeps them apart.
let trident = self.tridents.remove(i);
self.add_pickup(super::pickups::Taken::Trident(Box::new(trident)), collector);
}
// An orb stays: the server may still hold merged ones in it.
if !orb {
@@ -5526,6 +5536,10 @@ impl Game<'_> {
} else {
Keys::default()
};
// The held lead's end eases from the look at the start of the tick
// (`yRotO`/`xRotO`), as the local tick does; `tick` returns before it
// reaches its own write when this window is a server client.
self.rope_rot_old = (self.yaw, self.pitch);
// A passenger moves with its vehicle (Entity.rideTick), not by itself.
let riding = self.screen != Screen::Dead && self.remote_ride(keys);
// The server sends no passenger's position: they sit where their
@@ -6076,13 +6090,17 @@ impl Game<'_> {
// The particles these mobs' own ticks draw on the client side.
{
use horizon_client::mobs::{
Species as S, cube, illusioner, nautilus, tame, wither,
Species as S, cube, illager, illusioner, nautilus, tame, wither,
};
match m.species() {
S::Wolf => tame::client_tick(m, rain, &mut mob_fx),
S::Slime | S::MagmaCube | S::SulfurCube => cube::client_tick(m, &mut mob_fx),
S::Nautilus | S::ZombieNautilus => nautilus::client_tick(m, &mut mob_fx),
S::Illusioner => illusioner::client_tick(m, &mut mob_fx),
S::Illusioner => {
illusioner::client_tick(m, &mut mob_fx);
illager::cast_particles(m, &mut mob_fx);
}
S::Evoker => illager::cast_particles(m, &mut mob_fx),
S::Wither => wither::client_smoke(m, &mut mob_fx),
_ => {}
}
@@ -7172,6 +7190,11 @@ impl Game<'_> {
// CushionItem.useOn on a client level: SUCCESS once
// `canBePlacedAt` holds, before the server's own checks.
self.cushion_spawn(hit).is_some()
} else if &*stack.item == "minecraft:painting" {
// HangingEntityItem.useOn on a client level: SUCCESS once
// `Painting.create` finds a surviving variant, before the
// server spawns it.
self.painting_fits(hit, stack)
} else {
false
}
@@ -127,6 +127,12 @@ fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
if water {
p.velocity = p.velocity.scale(0.6);
}
let (movement_x, movement_y, movement_z) = (p.velocity.x, p.velocity.y, p.velocity.z);
let sd = (movement_x * movement_x + movement_z * movement_z).sqrt();
let y_rot = horizon_core::geometry::trig::atan2_degrees(movement_x, movement_z);
let x_rot = horizon_core::geometry::trig::atan2_degrees(movement_y, sd);
p.pitch = horizon_client::arrows::lerp_rotation(p.pitch, x_rot);
p.yaw = horizon_client::arrows::lerp_rotation(p.yaw, y_rot);
let to = p.position.add_vector(p.velocity);
if let Some((at, _, _)) = w.clip(p.position, to) {
p.position = at;
@@ -286,6 +292,8 @@ impl Game<'_> {
velocity,
fuse: 80,
on_ground: false,
owner: None,
guest: None,
}),
// FallingBlockEntity.recreateFromPacket: the block state is the data.
"minecraft:falling_block" => self.falling.push(FallingBlock {
+99 -44
View File
@@ -35,7 +35,8 @@ impl Screen {
| Self::Inventory
| Self::Dead
| Self::CommandBlock
| Self::BlockEditor => false,
| Self::BlockEditor
| Self::Mod => false,
// WinScreen.extractBackground: the poem fills the screen with the End portal, the
// credits alone blur. Game::menu_blur knows which one rolls.
Self::Win => false,
@@ -44,11 +45,10 @@ impl Screen {
}
/// The pause menu grid's origin: GridLayout of two 98px columns (padding 4),
/// aligned at (0.5, 0.25). `row3`: the full-width row at y + 122 that holds
/// the `pause_screen_additions` button, or the `minecraft:server_links` one
/// when the tag is empty (`PauseScreen.getCustomAdditions`).
fn pause_grid(w: f32, h: f32, row3: bool) -> (f32, f32) {
let (grid_w, grid_h) = (212.0, 166.0 + if row3 { 24.0 } else { 0.0 });
/// aligned at (0.5, 0.25). Extra rows hold a server dialog or links
/// (`PauseScreen.getCustomAdditions`), followed by the guest profile settings.
fn pause_grid(w: f32, h: f32, extra_rows: u8) -> (f32, f32) {
let (grid_w, grid_h) = (212.0, 166.0 + 24.0 * f32::from(extra_rows));
(((w - grid_w) * 0.5).floor(), ((h - grid_h) * 0.25).floor())
}
@@ -67,10 +67,11 @@ fn pause_buttons(
remote: bool,
links: bool,
additions: bool,
split: bool,
) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
let row3 = links || additions;
let (x0, y0) = pause_grid(w, h, row3);
let down = if row3 { 24.0 } else { 0.0 };
let (x0, y0) = pause_grid(w, h, u8::from(row3) + u8::from(split));
let down = 24.0 * f32::from(u8::from(row3) + u8::from(split));
let mut v = vec![
(x0 + 4.0, y0 + 50.0, 204.0, "menu.returnToGame", 0, true),
(x0 + 4.0, y0 + 74.0, 98.0, "gui.advancements", 4, true),
@@ -84,6 +85,10 @@ fn pause_buttons(
// CommonDialogData.computeExternalTitle of Dialogs.SERVER_LINKS.
v.push((x0 + 4.0, y0 + 122.0, 204.0, "menu.server_links", 11, true));
}
if split {
let y = y0 + 122.0 + if row3 { 24.0 } else { 0.0 };
v.push((x0 + 4.0, y, 204.0, SPLIT_SETTINGS, 30, true));
}
v.extend([
(x0 + 4.0, y0 + 122.0 + down, 98.0, "menu.options", 1, true),
(
@@ -149,8 +154,15 @@ fn pause_icon_focus_ids(friends: bool) -> Vec<u8> {
.map(|i| if i == FRIENDS_ICON { 10 } else { 12 + i as u8 })
.collect()
}
fn pause_icons(w: f32, h: f32, links: bool, additions: bool) -> [(f32, f32, &'static str); 4] {
let (x0, y0) = pause_grid(w, h, links || additions);
const SPLIT_SETTINGS: &str = "horizon.pages.split_settings";
fn pause_icons(
w: f32,
h: f32,
links: bool,
additions: bool,
split: bool,
) -> [(f32, f32, &'static str); 4] {
let (x0, y0) = pause_grid(w, h, u8::from(links || additions) + u8::from(split));
let (x0, y) = (x0 + 60.0, y0 + 98.0);
[
(x0, y, "gui/sprites/pause_menu/bug"),
@@ -240,9 +252,14 @@ impl Game<'_> {
let links = !self.server_links().is_empty();
let additions = self.pause_screen_additions();
let mut tooltip = None;
for (x, y, bw, key, id, active) in
pause_buttons(w, h, self.external_server(), links, additions.is_some())
{
for (x, y, bw, key, id, active) in pause_buttons(
w,
h,
self.external_server(),
links,
additions.is_some(),
self.options.split_guest,
) {
let is_focused = self.pause_focus == Some(id);
let hover = active && (ui::hovered(self.cursor, x, y, bw, 20.0) || is_focused);
let label = match (id, &additions) {
@@ -255,9 +272,10 @@ impl Game<'_> {
tooltip = Some(Text::translate("menu.custom_options.tooltip", &[]));
}
}
for (i, (x, y, sprite)) in pause_icons(w, h, links, additions.is_some())
.into_iter()
.enumerate()
for (i, (x, y, sprite)) in
pause_icons(w, h, links, additions.is_some(), self.options.split_guest)
.into_iter()
.enumerate()
{
let active = icon_active(i, friends);
let is_focused = (self.pause_focus == Some(10) && i == FRIENDS_ICON)
@@ -344,6 +362,7 @@ impl Game<'_> {
// ConfirmLinkScreen.confirmLink(this, CommonLinks...) on the pause screen.
12 => self.confirm_trusted_link(BUGS_LINK, Screen::Paused),
13 => self.confirm_trusted_link(feedback_link(), Screen::Paused),
30 => self.open_split_settings(),
// addCustomDialogButtons: DialogConnectionAccess.openDialog of the
// additions dialog, from the pause menu.
14 => {
@@ -370,23 +389,30 @@ impl Game<'_> {
};
let links = !self.server_links().is_empty();
let additions = self.pause_screen_additions();
let icons = pause_icons(w, h, links, additions.is_some());
let icons = pause_icons(w, h, links, additions.is_some(), self.options.split_guest);
let friends = icons[FRIENDS_ICON];
let hit = pause_buttons(w, h, self.external_server(), links, additions.is_some())
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4)
.or_else(|| {
// The report bugs and feedback icons (ids 12 and 13).
(0..2)
.find(|&i| ui::hovered(self.cursor, icons[i].0, icons[i].1, 20.0, 20.0))
.map(|i| 12 + i as u8)
})
.or_else(|| {
(self.friends_view().shown
&& ui::hovered(self.cursor, friends.0, friends.1, 20.0, 20.0))
.then_some(10)
});
let hit = pause_buttons(
w,
h,
self.external_server(),
links,
additions.is_some(),
self.options.split_guest,
)
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4)
.or_else(|| {
// The report bugs and feedback icons (ids 12 and 13).
(0..2)
.find(|&i| ui::hovered(self.cursor, icons[i].0, icons[i].1, 20.0, 20.0))
.map(|i| 12 + i as u8)
})
.or_else(|| {
(self.friends_view().shown
&& ui::hovered(self.cursor, friends.0, friends.1, 20.0, 20.0))
.then_some(10)
});
if let Some(id) = hit {
self.pause_focus = Some(id);
self.audio.play_button_click();
@@ -410,6 +436,7 @@ impl Game<'_> {
self.external_server(),
links,
self.pause_screen_additions().is_some(),
self.options.split_guest,
);
let mut active_ids: Vec<u8> = buttons.into_iter().filter(|b| b.5).map(|b| b.4).collect();
let friends = self.friends_view().shown;
@@ -457,6 +484,7 @@ impl Game<'_> {
self.external_server(),
links,
self.pause_screen_additions().is_some(),
self.options.split_guest,
);
let mut rects: Vec<(u8, f32, f32, f32)> = buttons
.into_iter()
@@ -464,10 +492,15 @@ impl Game<'_> {
.map(|b| (b.4, b.0, b.1, b.2))
.collect();
let friends = self.friends_view().shown;
for (i, (x, y, _)) in
pause_icons(w, h, links, self.pause_screen_additions().is_some())
.into_iter()
.enumerate()
for (i, (x, y, _)) in pause_icons(
w,
h,
links,
self.pause_screen_additions().is_some(),
self.options.split_guest,
)
.into_iter()
.enumerate()
{
if icon_active(i, friends) {
let id = if i == FRIENDS_ICON { 10 } else { 12 + i as u8 };
@@ -871,7 +904,7 @@ mod tests {
#[test]
fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() {
for (remote, label) in [(false, "menu.returnToMenu"), (true, "menu.disconnect")] {
let buttons = pause_buttons(427.0, 240.0, remote, false, false);
let buttons = pause_buttons(427.0, 240.0, remote, false, false, false);
let quit = buttons.iter().find(|b| b.4 == 2).unwrap();
assert_eq!(quit.3, label);
assert!(quit.5);
@@ -897,14 +930,14 @@ mod tests {
#[test]
fn server_links_adds_a_full_width_row_above_options() {
let with = pause_buttons(427.0, 240.0, true, true, false);
let without = pause_buttons(427.0, 240.0, true, false, false);
let with = pause_buttons(427.0, 240.0, true, true, false, false);
let without = pause_buttons(427.0, 240.0, true, false, false, false);
let links = with.iter().find(|b| b.4 == 11).unwrap();
assert_eq!(links.2, 204.0);
assert_eq!(links.3, "menu.server_links");
assert!(links.5);
// The icon row sits above it; Options and Disconnect move down 24.
let icons = pause_icons(427.0, 240.0, true, false);
let icons = pause_icons(427.0, 240.0, true, false, false);
let y0_icons = ((240.0f32 - 190.0) * 0.25).floor() + 98.0;
assert_eq!(icons[0].1, y0_icons);
assert!(links.1 > icons[0].1);
@@ -920,8 +953,8 @@ mod tests {
fn pause_screen_additions_takes_the_server_links_row() {
// getCustomAdditions: one additions button in row 3, never next to the
// links button, so the grid keeps one extra row either way.
let with = pause_buttons(427.0, 240.0, true, true, true);
let links = pause_buttons(427.0, 240.0, true, true, false);
let with = pause_buttons(427.0, 240.0, true, true, true, false);
let links = pause_buttons(427.0, 240.0, true, true, false, false);
assert!(with.iter().all(|b| b.4 != 11));
let button = with.iter().find(|b| b.4 == 14).unwrap();
assert_eq!(
@@ -934,8 +967,30 @@ mod tests {
assert_eq!(add.1, link.1);
}
assert_eq!(
pause_icons(427.0, 240.0, true, true)[0].1,
pause_icons(427.0, 240.0, true, false)[0].1
pause_icons(427.0, 240.0, true, true, false)[0].1,
pause_icons(427.0, 240.0, true, false, false)[0].1
);
}
#[test]
fn split_settings_preserves_dialog_rows_and_icon_actions() {
for (links, additions) in [(false, false), (true, false), (false, true), (true, true)] {
let buttons = pause_buttons(427.0, 300.0, true, links, additions, true);
let split = buttons.iter().find(|b| b.4 == 30).unwrap();
let options = buttons.iter().find(|b| b.4 == 1).unwrap();
assert_eq!(options.1, split.1 + 24.0);
if links || additions {
let custom = buttons
.iter()
.find(|b| b.4 == if additions { 14 } else { 11 })
.unwrap();
assert_eq!(split.1, custom.1 + 24.0);
}
assert!(buttons.iter().all(|b| b.4 != 12 && b.4 != 13));
assert!(
!pause_buttons(427.0, 300.0, true, links, additions, false)
.iter()
.any(|b| b.4 == 30)
);
}
}
}
@@ -99,6 +99,16 @@ impl Rideable for minecarts::Minecart {
/// The vehicle `id` leaves the level it is in, at the point its rider
/// arrives. It keeps its entity id, so the ride is never broken.
/// A guest leaving for another level leaves its fishing hook behind in the
/// old one, where vanilla's hook stays until its distance check ends it. A
/// guest's hook lives on the guest here, so it ends now: it must not act on
/// the blocks of the level the guest arrives in.
fn drop_hook(host: &mut LanHost, conn: u32) {
if let Some(play) = host.play.get_mut(&conn) {
play.hook = None;
}
}
fn take_vehicle<T: Rideable>(list: &mut Vec<T>, id: u32, at: Vec3, yaw: f32) -> Option<T> {
let i = list.iter().position(|v| v.id() == id)?;
let mut v = list.remove(i);
@@ -337,6 +347,9 @@ impl Game<'_> {
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
if target != self.dim {
drop_hook(host, conn);
}
let g = &host.hub.guests[i];
let from = Vec3::new(g.position[0], g.position[1], g.position[2]);
let yaw = g.yaw;
@@ -685,6 +698,9 @@ impl Game<'_> {
continue;
};
let source = self.dim;
if t.dim != source {
drop_hook(host, t.conn);
}
if let Err(e) = self.enter_level(Some(host), t.dim) {
log::error!("could not open {}: {e}", t.dim.id());
continue;
+60 -7
View File
@@ -68,6 +68,33 @@ pub(super) fn write_shader_options(options: &ShaderOptions) {
}
}
/// The pack that runs over the world ("": none): `HORIZON_SHADER_PACK`, else the pick
/// (`active_pack`).
fn picked_pack(settings: &horizon_client::settings::Settings) -> &str {
static DEV_PACK: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
let dev = DEV_PACK.get_or_init(|| std::env::var("HORIZON_SHADER_PACK").ok());
active_pack(
dev.as_deref().unwrap_or(&settings.horizon.shader_pack),
&settings.mods,
)
}
/// Whether the pack picked (`picked_pack`) draws flat where the device can (`pack_draws_flat`):
/// a renderer made now lays the sections out for it before the first world frame loads it
/// (`Renderer::new`).
pub(super) fn picked_pack_draws_flat(settings: &horizon_client::settings::Settings) -> bool {
let picked = picked_pack(settings);
!picked.is_empty() && pack_draws_flat(&Game::shader_packs_dir().join(picked))
}
/// Whether the pack at `path` has passes that draw the terrain flat where the device can,
/// reading translucent quads beside their section's solid ones: a `shaders/` pack, taken to
/// draw terrain, or one of Horizon's own with a `[shadow]` pass.
fn pack_draws_flat(path: &std::path::Path) -> bool {
!horizon_shader_api::package::is_pack(path)
|| horizon_shader_api::Pack::read(path).is_ok_and(|p| p.manifest.shadow.is_some())
}
// A native mod can bundle a pack as `native-<mod-id>`; its Mods toggle controls it too.
fn active_pack<'a>(picked: &'a str, mods: &horizon_client::mod_list::ModList) -> &'a str {
if picked.strip_prefix("native-").is_some_and(|id| {
@@ -129,12 +156,7 @@ impl Game<'_> {
self.streaming.shader_pack_lighting = pack_lighting;
self.all_sections_changed();
}
static DEV_PACK: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
let dev = DEV_PACK.get_or_init(|| std::env::var("HORIZON_SHADER_PACK").ok());
let picked = active_pack(
dev.as_deref().unwrap_or(&self.settings.horizon.shader_pack),
&self.settings.mods,
);
let picked = picked_pack(&self.settings);
if self.native_shaders.picked != picked {
// Another pack, or none: drop what ran.
self.native_shaders = super::native_shaders::NativeShaders::picking(picked);
@@ -290,6 +312,13 @@ impl Game<'_> {
rainfall: info.map_or(0.0, |b| b.downfall),
held_items,
held_light,
right_handed: !self.settings.main_hand_left,
on_ground: self.player.as_ref().is_some_and(|p| p.ground.on_ground),
min_y: self.world.shape.min_y,
height: self.world.shape.height,
logical_height: self.world.shape.logical_height,
has_skylight: self.world.shape.sky_light,
has_ceiling: self.dim == Dim::Nether,
elytra_flying: self.player.as_ref().is_some_and(|p| p.fall_flying),
cloud_height: horizon_client::gfx::pack_uniforms::cloud_height(
sky.cloud_color[3],
@@ -572,9 +601,33 @@ fn block_id_table(pack: &super::PackResources, ids: &BlockIds) -> Vec<i32> {
#[cfg(test)]
mod native_pack_tests {
use super::active_pack;
use super::{active_pack, pack_draws_flat};
use horizon_client::mod_list::{Installed, ModList, NativePage};
#[test]
fn shaders_packs_and_packs_with_a_shadow_pass_draw_flat() {
let root = std::env::temp_dir().join(format!("horizon-flat-packs-{}", std::process::id()));
// A folder without a manifest is a `shaders/` pack; with one, a pack of Horizon's own.
let pack = |name: &str, manifest: Option<&str>| {
let dir = root.join(name);
std::fs::create_dir_all(&dir).unwrap();
if let Some(extra) = manifest {
std::fs::write(dir.join("material.wgsl"), "").unwrap();
let text = format!(
"schema = 1\nid = \"{name}\"\nname = \"{name}\"\nversion = \"1\"\napi = \"{}\"\n\
[material]\nfile = \"material.wgsl\"\n{extra}",
horizon_shader_api::API
);
std::fs::write(dir.join(horizon_shader_api::MANIFEST), text).unwrap();
}
dir
};
assert!(pack_draws_flat(&pack("shaders_pack", None)));
assert!(!pack_draws_flat(&pack("unshadowed", Some(""))));
assert!(pack_draws_flat(&pack("shadowed", Some("[shadow]\n"))));
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn native_shader_follows_its_mod_without_affecting_other_packs() {
let mut mods = ModList::default();
+80 -16
View File
@@ -21,7 +21,8 @@
//! disconnects leaves its player waiting, its view dimmed, until it comes back or another takes
//! over (`pads`).
//!
//! `HORIZON_SPLIT=N` (testing, the only way in so far): splitscreen watches the controllers, and
//! Horizon Settings > Splitscreen... enables joining (off by default).
//! `HORIZON_SPLIT=N` (testing) enables it too, and
//! N-1 players without one join once P1 is in its world, as P2, P3... (`HORIZON_SPLIT=1`: only
//! the controllers). The player in seat N takes `horizon-ctl` on P1's socket path + "N".
@@ -30,6 +31,7 @@ use super::{
pad::{PadId, PadRouter},
start_game,
};
use horizon_client::gamepad::Button;
use horizon_client::split_layout::{self as layout, MAX_PLAYERS, Placement, View};
use horizon_client::{Result, gfx::compositor::Compositor, widgets};
use std::{
@@ -77,10 +79,21 @@ impl Game<'_> {
}
}
const WINDOW_1: &str = "Window 1";
const NEW_WINDOW: &str = "New window";
/// A place offered while picking a profile.
#[derive(Clone, PartialEq)]
enum Spot {
Window(u8),
New,
Fill(String),
}
struct Guest<'a> {
game: Box<Game<'a>>,
/// The name it plays as.
name: String,
/// The profile it plays as; `None` while it picks one.
name: Option<String>,
/// Its seat (2 to `MAX_PLAYERS`): its number (P2...), its control socket, its sounds.
seat: usize,
options: PathBuf,
@@ -90,15 +103,19 @@ struct Guest<'a> {
window: u8,
/// Its buttons and focus targets while another player's game runs.
widgets: widgets::Saved,
/// Its profile got into the world: turned away after that (a kick), it leaves; before, it
/// picks again.
entered: bool,
bumpers: [bool; 2],
}
pub(super) struct Split<'a> {
pub(super) host: Game<'a>,
guests: Vec<Guest<'a>>,
/// `HORIZON_SPLIT`: splitscreen watches the controllers for players joining.
watching: bool,
/// `HORIZON_SPLIT=N`: players still to add once P1 is in a world.
test_players: usize,
/// `HORIZON_SPLIT` is set: splitscreen is on for this run, without touching the saved switch.
forced: bool,
/// While another player is in, the windows players play in, each with its compositor: the
/// game's first, then the others (else P1 draws on its window).
surfaces: Vec<Surface>,
@@ -106,6 +123,8 @@ pub(super) struct Split<'a> {
/// monitor, and P1's.
windows: Vec<layout::Window>,
p1_window: u8,
/// Seats in the order their views fill a shared window.
order: Vec<usize>,
/// Whether Window 1 is shown (hidden while nobody plays in it): set once per change, since
/// Wayland can't say whether a window is visible.
window1_shown: bool,
@@ -150,14 +169,15 @@ impl<'a> Split<'a> {
Self {
host,
guests: Vec::new(),
watching: split.is_some(),
test_players,
forced: split.is_some(),
surfaces: Vec::new(),
windows: vec![layout::Window {
number: 1,
placement: Placement::Free(None),
}],
p1_window: 1,
order: Vec::new(),
window1_shown: true,
window_rects: Default::default(),
screens: Vec::new(),
@@ -304,7 +324,10 @@ impl<'a> Split<'a> {
fn name(&self, i: usize) -> String {
match i {
0 => "P1".into(),
_ => self.guests[i - 1].name.clone(),
_ => self.guests[i - 1]
.name
.clone()
.unwrap_or_else(|| "New player".into()),
}
}
@@ -352,6 +375,8 @@ impl<'a> Split<'a> {
let mut old = self.host.renderer.take().ok_or("no renderer")?;
old.release_surface();
let api = self.host.settings.horizon.api;
let batching = self.host.settings.horizon.translucent_batching;
let shader_pack = super::shader_packs::picked_pack_draws_flat(&self.host.settings);
let built = build_renderer(
window.clone(),
true,
@@ -360,6 +385,8 @@ impl<'a> Split<'a> {
Some(&old),
None,
api,
batching,
shader_pack,
);
let renderer = match built {
Ok(r) => r,
@@ -399,13 +426,17 @@ impl<'a> Split<'a> {
Ok(())
}
/// P1 alone again: it draws on its window again.
/// Splitscreen off or P1 alone again: it draws on its window again.
#[inline(never)]
fn disable(&mut self, event_loop: &ActiveEventLoop) -> Result<()> {
fn disable(&mut self, event_loop: &ActiveEventLoop, watching: bool) -> Result<()> {
for k in (0..self.guests.len()).rev() {
self.remove_guest(k);
}
self.applied.clear();
self.order.clear();
if !watching {
self.stop_routing();
}
self.p1_window = 1;
self.windows.truncate(1);
self.surfaces.truncate(1);
@@ -419,6 +450,8 @@ impl<'a> Split<'a> {
let old = self.host.renderer.take().ok_or("no renderer")?;
old.take_back(main.compositor);
let api = self.host.settings.horizon.api;
let batching = self.host.settings.horizon.translucent_batching;
let shader_pack = super::shader_packs::picked_pack_draws_flat(&self.host.settings);
let built = build_renderer(
window.clone(),
false,
@@ -427,6 +460,8 @@ impl<'a> Split<'a> {
Some(&old),
None,
api,
batching,
shader_pack,
);
let renderer = match built {
Ok(r) => r,
@@ -466,13 +501,17 @@ impl<'a> Split<'a> {
fn admit(&mut self, _event_loop: &ActiveEventLoop, pad_joins: Vec<PadId>) -> bool {
let mut changed = false;
for pad in pad_joins {
match self.join(Some(pad)) {
match self.join(Some(pad), None) {
Ok(()) => changed = true,
Err(e) => log::warn!("Horizon: splitscreen: a player could not join: {e}"),
}
}
if self.in_world() && self.test_players > 0 {
match self.join(None) {
// HORIZON_SPLIT_PICK: they pick a profile like a controller's player, else P2...
let name = std::env::var_os("HORIZON_SPLIT_PICK")
.is_none()
.then(|| format!("P{}", self.free_seat().unwrap_or(0)));
match self.join(None, name) {
Ok(()) => {
self.test_players -= 1;
changed = true;
@@ -652,16 +691,19 @@ impl ApplicationHandler for Split<'_> {
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
// The last other player gone: P1 draws on its window again.
self.refresh_monitors();
let on = self.host.settings.extras.splitscreen || self.forced;
self.host.ui.options.split_joined = self.guests.iter().any(|g| g.name.is_some());
// Off, or the last other player gone: P1 draws on its window again.
if !self.surfaces.is_empty()
&& self.guests.is_empty()
&& let Err(e) = self.disable(event_loop)
&& (!on || self.guests.is_empty())
&& let Err(e) = self.disable(event_loop, on)
{
log::warn!("Horizon: splitscreen could not switch back: {e}");
}
if self.surfaces.is_empty() {
if !self.watching {
if !on {
self.stop_routing();
return self.host.about_to_wait(event_loop);
}
// Alone, the controllers watched for someone joining; P1's own drives P1, its
@@ -684,6 +726,8 @@ impl ApplicationHandler for Split<'_> {
self.turn(0);
self.host.about_to_wait(event_loop);
let mut changed = self.drop_out() | self.admit(event_loop, pad_joins);
self.sync_profiles();
changed |= self.pick_profiles();
changed |= self.sync_surfaces(event_loop);
if changed || self.views() != self.applied {
self.relayout(event_loop);
@@ -691,7 +735,7 @@ impl ApplicationHandler for Split<'_> {
// Each player's badge: who it is, shown a moment when something changes for it.
self.host.split_hud.badge = Some("P1".into());
for g in &mut self.guests {
g.game.split_hud.badge = Some(g.name.clone());
g.game.split_hud.badge = Some(g.name.clone().unwrap_or_else(|| "New player".into()));
}
let master = self.host.audio.master;
for i in 0..self.guests.len() {
@@ -703,6 +747,26 @@ impl ApplicationHandler for Split<'_> {
if let Some(e) = &self.guests[i].game.error {
log::error!("Horizon: splitscreen: P{}: {e}", self.guests[i].seat);
}
// Choosing its profile, the bumpers move its view (its pad as its game read it: the
// controller, or `horizon-ctl pad`).
let place = self.stop_label(i + 1);
let game = &mut self.guests[i].game;
if game.ui.options.page() == horizon_client::options_ui::Page::SplitProfiles
&& game.screen == super::Screen::Options
{
game.ui.options.split_profiles.place = place;
let state = game.pad.state;
let held = [
state.held(Button::LeftBumper),
state.held(Button::RightBumper),
];
let before = std::mem::replace(&mut self.guests[i].bumpers, held);
if held[0] && !before[0] {
self.move_with_bumpers(i + 1, true);
} else if held[1] && !before[1] {
self.move_with_bumpers(i + 1, false);
}
}
}
self.turn(0);
self.present();
+12 -25
View File
@@ -77,6 +77,11 @@ pub(super) fn lost_prompt(name: &str) -> Prompt {
}
impl Game<'_> {
/// Another player's pause menu opens its profiles to switch identity.
pub(in crate::game) fn open_split_settings(&mut self) {
self.open_options_at(false, horizon_client::options_ui::Page::SplitProfiles);
}
/// Splitscreen over this player's view, above the other toasts (`ToastManager`'s slots).
pub(in crate::game) fn draw_split_hud(&mut self, batch: &mut HudBatch, hidden: bool) {
let atlas = &self.gui.atlas;
@@ -151,7 +156,7 @@ impl Game<'_> {
return;
}
let max = (w - 24.0).min(260.0);
let lines = wrap(&notice.body, max, width);
let lines = horizon_client::split_profiles::wrap(&notice.body, max, width);
let inner = lines
.iter()
.map(|l| width(l))
@@ -176,8 +181,12 @@ impl Game<'_> {
impl<'a> Split<'a> {
/// The join question on every player's view in the world (a strip at the top), or gone.
pub(super) fn prompt_join(&mut self, prompt: Option<Prompt>) {
let views = std::iter::once(&mut self.host.split_hud)
.chain(self.guests.iter_mut().map(|g| &mut g.game.split_hud));
let views = std::iter::once(&mut self.host.split_hud).chain(
self.guests
.iter_mut()
.filter(|g| g.name.is_some())
.map(|g| &mut g.game.split_hud),
);
for hud in views {
match &prompt {
Some(p) => {
@@ -201,25 +210,3 @@ impl<'a> Split<'a> {
}
}
}
/// `text` in lines at most `max` wide (by `width`), broken between words.
fn wrap(text: &str, max: f32, width: impl Fn(&str) -> f32) -> Vec<String> {
let mut lines = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
let longer = if line.is_empty() {
word.to_owned()
} else {
format!("{line} {word}")
};
if width(&longer) > max && !line.is_empty() {
lines.push(std::mem::replace(&mut line, word.to_owned()));
} else {
line = longer;
}
}
if !line.is_empty() {
lines.push(line);
}
lines
}
+19 -1
View File
@@ -64,6 +64,19 @@ pub(super) fn claim<Id>(
}
impl Split<'_> {
/// Give P1 its normal controller reader again and discard pending joins and handoffs.
pub(super) fn stop_routing(&mut self) {
if self.host.pad.feed.take().is_some() {
self.host.pad.release_hardware();
self.host.split_hud = Default::default();
self.offers.clear();
self.fresh.clear();
self.lost.clear();
self.p1_pad = None;
self.pads = PadRouter::default();
}
}
/// The pad's state for its player: nothing while the press that gave it is still held.
fn pad_state(&mut self, pad: Option<PadId>) -> PadState {
let Some(id) = pad else {
@@ -212,12 +225,17 @@ impl Split<'_> {
return;
};
g.pad = None;
let Some(name) = g.name.clone() else {
// Still picking a profile: as if it pressed Cancel (`pick_profiles`).
g.game.screen = crate::game::Screen::Playing;
return;
};
self.lost.push(Lost {
seat: g.seat,
id,
uuid,
});
g.game.split_hud.prompt = Some(lost_prompt(&g.name));
g.game.split_hud.prompt = Some(lost_prompt(&name));
}
/// Player in `seat` plays with controller `id` (back, or another): its question goes.
+273 -51
View File
@@ -1,6 +1,22 @@
//! Players coming and going: joining P1's world through memory, dropping out.
//! Players coming and going: joining P1's world through memory as a profile it picks
//! (`split_profiles`), dropping out.
use super::*;
use horizon_client::gamepad::PadState;
use horizon_client::{gamepad::PadState, options_ui::Page};
/// Whether `game` shows its profile page now.
fn on_profiles(game: &Game<'_>) -> bool {
game.screen == crate::game::Screen::Options && game.ui.options.page() == Page::SplitProfiles
}
/// Why a player's game left its server, when the server said (its Disconnected or error screen).
fn turned_away(game: &Game<'_>) -> Option<String> {
use horizon_client::{multiplayer::Screen, title::Mode};
match &game.title.as_ref()?.mode {
Mode::Multiplayer(Screen::Disconnected { reason, .. }) => Some(reason.clone()),
Mode::Error { message, .. } => Some(message.clone()),
_ => None,
}
}
/// The `Game` `start` makes (~67 KB, by value) into a box, in a frame of its own that is gone
/// once it is: the copies stay out of the caller's frame, deep in the event loop.
@@ -15,29 +31,134 @@ fn boxed(start: impl FnOnce() -> Result<Option<Game<'static>>>) -> Result<Box<Ga
impl Split<'_> {
/// The first seat free (2 to `MAX_PLAYERS`).
fn free_seat(&self) -> Option<usize> {
pub(super) fn free_seat(&self) -> Option<usize> {
(2..=MAX_PLAYERS).find(|n| self.guests.iter().all(|g| g.seat != *n))
}
/// A player joins P1's world as `P<seat>`, with its own options in the game folder's
/// `splitscreen/profiles/P<seat>/` (P1's, the first time).
pub(super) fn join(&mut self, pad: Option<PadId>) -> Result<()> {
/// The splitscreen folder in the game folder: `profiles/<name>/` holds each profile.
fn folder() -> Result<PathBuf> {
Ok(std::env::current_dir()?.join("splitscreen"))
}
/// Add a player driven by `pad`: it picks a profile first, unless `name` is given (tests).
pub(super) fn join(&mut self, pad: Option<PadId>, name: Option<String>) -> Result<()> {
let seat = self.free_seat().ok_or("every seat is taken")?;
let name = format!("P{seat}");
let profile = std::env::current_dir()?.join(format!("splitscreen/profiles/{name}"));
// The first other player: P1 moves to the compositor now (back with nobody joined, if
// this one can't: `about_to_wait`).
if self.surfaces.is_empty() {
self.enable()?;
}
// Until it has a profile, a scratch options file: never P1's.
let options = Self::folder()?.join(".joining/options.txt");
OPTIONS.set(Some(options.clone()));
let game = self.start_guest(seat, &options, self.host.settings.clone());
OPTIONS.set(None);
let mut game = game?;
if let Some(pad) = pad {
// The press that joined it doesn't act in its game.
self.fresh.push(pad);
}
// Over its title screen: Cancel leaves (`pick_profiles`).
game.open_options_at(true, Page::SplitProfiles);
let i = self.guests.len() + 1;
self.guests.push(Guest {
game,
name: None,
seat,
options,
pad,
window: 1,
widgets: Default::default(),
entered: false,
bumpers: [false; 2],
});
self.planned_window(i, seat);
if let Some(name) = name {
self.enter(i - 1, name)?;
}
Ok(())
}
/// The names playing now: P1's and the joined players'.
fn taken(&self) -> Vec<String> {
std::iter::once(self.host.options.player_name())
.chain(self.guests.iter().filter_map(|g| g.name.clone()))
.collect()
}
/// The profile pages just opened, filled: the list and who plays now.
pub(super) fn sync_profiles(&mut self) {
let taken = self.taken();
for g in self.guests.iter_mut().filter(|g| on_profiles(&g.game)) {
g.game.ui.options.split_profiles.taken = taken.clone();
}
let open = |g: &Guest<'_>| on_profiles(&g.game) && !g.game.ui.options.split_profiles.filled;
if !self.guests.iter().any(open) {
return;
}
let names = Self::folder()
.and_then(|f| {
Ok(horizon_client::split_profiles::store::names(
&f.join("profiles"),
)?)
})
.map(|mut names| {
names.sort_by_key(|n| n.to_lowercase());
names
})
.unwrap_or_else(|e| {
log::warn!("Horizon: splitscreen: the profiles: {e}");
Vec::new()
});
for g in self.guests.iter_mut().filter(|g| open(g)) {
let page = &mut g.game.ui.options.split_profiles;
page.filled = true;
page.names = names.clone();
if let Some(me) = g.name.as_ref().filter(|me| !names.contains(me)) {
page.names.insert(0, me.clone());
}
page.taken = taken.clone();
page.current = g.name.clone();
}
}
/// Guest `i`, playing, switches to profile `name` where it plays: out of the world as its
/// old profile, in as the new one.
fn switch(&mut self, i: usize, name: String) -> Result<()> {
self.turn(i + 1);
let left = self.guests[i].game.leave_server(None);
self.turn(0);
let old = self.guests[i].name.take();
if let (Some(server), Some(old)) = (&self.host.integrated, &old) {
server.leave_split(old);
}
left?;
self.enter(i, name)
}
/// Guest `i` plays as profile `name`: its options, then into P1's world.
pub(super) fn enter(&mut self, i: usize, name: String) -> Result<()> {
if self.taken().iter().any(|n| n.eq_ignore_ascii_case(&name)) {
return Err(format!("{name} is already playing.").into());
}
// The page's names are player names; a test seat's (P2) isn't one, but no name leaves
// the profiles folder.
if !folder_name(&name) {
return Err(format!("{name:?} can't be a profile.").into());
}
let profiles = Self::folder()?.join("profiles");
if horizon_client::split_profiles::valid_name(&name) {
horizon_client::split_profiles::store::restore(&profiles, &name)?;
}
let profile = profiles.join(&name);
let options = profile.join("options.txt");
let settings = if options.exists() {
let mut settings = if options.exists() {
horizon_client::settings::Settings::load(&options)
} else {
std::fs::create_dir_all(&profile)?;
self.host.settings.save(&options);
self.host.settings.clone()
};
// The first other player: P1 moves to the compositor now (back with nobody joined, if
// this one can't: `about_to_wait`).
if self.surfaces.is_empty() {
self.enable()?;
}
let pipe = self
.host
.integrated
@@ -45,41 +166,99 @@ impl Split<'_> {
.ok_or("P1 is not in its world")?
.join_split(&name)
.ok_or("P1's world does not let players in")?;
OPTIONS.set(Some(options.clone()));
let game = self.start_guest(seat, &options, settings);
OPTIONS.set(None);
let joined = game.and_then(|mut game| {
game.connect_local(pipe, &name)?;
Ok(game)
});
let game = match joined {
Ok(game) => game,
Err(e) => {
// Let in for nothing: the world pauses again, the name is free.
if let Some(server) = &self.host.integrated {
server.leave_split(&name);
}
return Err(e);
self.guests[i].options = options;
self.turn(i + 1);
let guest = &mut self.guests[i];
// Its profile page closes as it goes in.
guest.game.screen = crate::game::Screen::Playing;
settings.extras.agent_control &= guest.game.options.control.is_some();
guest.game.settings = settings;
guest.game.options.player_name = Some(name.clone());
guest.game.apply_settings();
let joined = guest.game.connect_local(pipe, &name);
self.turn(0);
if let Err(e) = joined {
// Let in for nothing: the world pauses again, the name is free.
if let Some(server) = &self.host.integrated {
server.leave_split(&name);
}
};
if let Some(pad) = pad {
// The press that joined it doesn't act in its game.
self.fresh.push(pad);
return Err(e);
}
let i = self.guests.len() + 1;
self.guests.push(Guest {
game,
name,
seat,
options,
pad,
window: 1,
widgets: Default::default(),
});
self.planned_window(i, seat);
self.guests[i].name = Some(name);
self.guests[i].entered = false;
Ok(())
}
/// Players' profile pages: a pick goes in, one that can't says why there; a forgotten
/// profile is hidden; a joining player who cancels (or closes the page) leaves.
pub(super) fn pick_profiles(&mut self) -> bool {
let mut changed = false;
let mut i = 0;
while i < self.guests.len() {
if let Some(name) = self.guests[i]
.game
.ui
.options
.split_profiles
.take_forgotten()
{
let hidden = if self.taken().iter().any(|n| n.eq_ignore_ascii_case(&name)) {
Err("This profile cannot be forgotten while it is playing.".into())
} else {
Self::folder().and_then(|f| {
Ok(horizon_client::split_profiles::store::hide(
&f.join("profiles"),
&name,
)?)
})
};
match hidden {
Ok(()) => {
for guest in &mut self.guests {
guest.game.ui.options.split_profiles.filled = false;
}
}
Err(e) => self.refuse(i, e.to_string()),
}
}
let joining = self.guests[i].name.is_none();
match self.guests[i].game.ui.options.split_profiles.take_choice() {
Some(Some(name)) => match (|| {
if self.taken().iter().any(|n| n.eq_ignore_ascii_case(&name)) {
return Err(format!("{name} is already playing.").into());
}
if joining {
self.enter(i, name)
} else {
self.switch(i, name)
}
})() {
Ok(()) => changed = true,
Err(why) => self.refuse(i, why.to_string()),
},
_ if joining && !on_profiles(&self.guests[i].game) => {
self.remove_guest(i);
changed = true;
continue;
}
_ => {}
}
i += 1;
}
changed
}
/// Guest `i`'s pick can't play: its profile page says why (opened again if it had left the
/// world for it).
fn refuse(&mut self, i: usize, why: String) {
log::warn!("Horizon: splitscreen: P{}: {why}", self.guests[i].seat);
let game = &mut self.guests[i].game;
if !on_profiles(game) {
game.open_options_at(true, Page::SplitProfiles);
}
game.ui.options.split_profiles.refused = Some(why);
}
/// A new player's game in its seat. Boxed as it comes out of `start_game`, and never
/// inlined: a `Game` is ~67 KB, and copies of it in the event loop's frames overflowed
/// Windows' 1 MB main stack.
@@ -106,7 +285,6 @@ impl Split<'_> {
options.split_guest = true;
options.offscreen = true;
options.paced_by_split = true;
options.player_name = Some(format!("P{seat}"));
// P1's socket (`--control` or Agent Control) + the seat: every game in this process
// would otherwise take the one default endpoint, P1's.
options.control = host.control.as_ref().map(|c| {
@@ -145,6 +323,9 @@ impl Split<'_> {
host.renderer.as_ref(),
None,
host.settings.horizon.api,
host.settings.horizon.translucent_batching,
// The pack its own game loads, from its profile.
crate::game::shader_packs::picked_pack_draws_flat(&guest.settings),
)?);
guest.window = Some(window);
guest.world_renderer_ready = true;
@@ -152,17 +333,40 @@ impl Split<'_> {
guest.apply_settings();
// Its pad comes from the split (`route_pads`): it never opens the controllers itself.
guest.pad.feed = Some(PadState::default());
guest.ui.options.split_guest = true;
Ok(guest)
}
/// Players who left their world (Disconnect) or failed drop out; all of them once P1 has
/// left its world. Whether any did.
/// Players who disconnected drop out; all of them once P1 has left its world. One the
/// server turned away as it came in picks again, told why; one it sent away later (a kick)
/// leaves, and P1 hears why. Whether any left.
pub(super) fn drop_out(&mut self) -> bool {
let p1_left = self.host.title.is_some() || self.host.integrated.is_none();
let before = self.guests.len();
if p1_left {
self.host.split_hud.notice = None;
}
for k in (0..self.guests.len()).rev() {
let game = &self.guests[k].game;
if p1_left || game.remote.is_none() || game.error.is_some() {
let g = &mut self.guests[k];
g.entered |= g.game.remote.is_some() && g.game.player.is_some();
let g = &self.guests[k];
// A player picking a profile has no connection yet.
let playing = g.name.is_none() || g.game.remote.is_some();
let why = (!p1_left && g.game.error.is_none() && !playing)
.then(|| turned_away(&g.game))
.flatten();
if let (true, Some(name), Some(why)) = (g.entered, &g.name, &why) {
self.host.split_hud.notice =
Some(Notice::new(format!("{name} could not stay"), why.clone()));
} else if let Some(why) = why {
let old = self.guests[k].name.take();
if let (Some(server), Some(old)) = (&self.host.integrated, &old) {
server.leave_split(old);
}
self.refuse(k, why);
continue;
}
if p1_left || !playing || g.game.error.is_some() {
self.remove_guest(k);
}
}
@@ -182,8 +386,26 @@ impl Split<'_> {
if self.focus > k + 1 {
self.focus -= 1;
}
if let Some(server) = &self.host.integrated {
server.leave_split(&guest.name);
if let (Some(server), Some(name)) = (&self.host.integrated, &guest.name) {
server.leave_split(name);
}
}
}
/// A name that stays one folder inside `profiles`.
fn folder_name(name: &str) -> bool {
!name.is_empty() && name != "." && !name.contains(['/', '\\']) && !name.contains("..")
}
#[cfg(test)]
mod tests {
use super::folder_name;
#[test]
fn a_profile_name_never_leaves_the_profiles_folder() {
assert!(folder_name("P2") && folder_name("Steve"));
for bad in ["", ".", "..", "../x", "a/b", "a\\b"] {
assert!(!folder_name(bad), "{bad:?}");
}
}
}
+227 -1
View File
@@ -147,6 +147,23 @@ impl<'a> Split<'a> {
self.main_screen.as_ref()
}
/// How a monitor is called: numbered left to right, with its size (the system's names are
/// ids on some).
pub(super) fn monitor_label(&self, name: &str) -> Option<String> {
let (n, (_, m)) = self
.monitors()
.iter()
.enumerate()
.find(|(_, (n, _))| n == name)?;
let size = m.size();
Some(format!(
"Monitor {} ({}x{})",
n + 1,
size.width,
size.height
))
}
/// The window player `i` plays in now: Window 1 for a window that fills a monitor that's
/// gone, or the one Window 1 is on.
pub(super) fn window_of(&self, i: usize) -> u8 {
@@ -161,6 +178,94 @@ impl<'a> Split<'a> {
if shown { number } else { 1 }
}
/// Where player `i` can play, as the screens say it: Window 1 (the game's), the other windows
/// open, a new window, and each monitor no window fills but Window 1's. The page's button
/// steps forward through them (buttons take the left click only), a joining player's bumpers
/// both ways. A player alone in its window is at "New window" or its monitor's choice (its
/// window is that), so the list stays the same as it steps.
pub(super) fn choices(&self, i: usize) -> Vec<(String, Spot)> {
let alone = self.alone(i);
let mine = self.window_of(i);
let mut open: Vec<u8> = (0..self.players())
.map(|j| self.window_of(j))
.filter(|&n| !(alone && n == mine))
.collect();
open.sort_unstable();
open.dedup();
let filled: Vec<&String> = open
.iter()
.filter_map(|&n| match self.placement(n) {
Some(Placement::Fill(name)) => Some(name),
_ => None,
})
.collect();
let main = self.main_monitor();
let monitors = self.monitors().iter().filter_map(|(name, _)| {
if Some(name) == main || filled.contains(&name) {
return None;
}
Some((self.monitor_label(name)?, Spot::Fill(name.clone())))
});
std::iter::once((WINDOW_1.to_owned(), Spot::Window(1)))
.chain(
open.into_iter()
.filter(|&n| n != 1)
.map(|n| (self.window_label(n), Spot::Window(n))),
)
.chain(std::iter::once((NEW_WINDOW.to_owned(), Spot::New)))
.chain(monitors)
.collect()
}
/// Player `i` plays alone in a window of its own (not Window 1).
pub(super) fn alone(&self, i: usize) -> bool {
let mine = self.window_of(i);
mine != 1 && (0..self.players()).all(|j| j == i || self.window_of(j) != mine)
}
/// Player `i`'s place among its choices: its window, or alone, what its window is.
pub(super) fn current(&self, i: usize) -> Spot {
let mine = self.window_of(i);
match self.placement(mine) {
Some(Placement::Free(_)) if self.alone(i) => Spot::New,
Some(Placement::Fill(name)) if self.alone(i) => Spot::Fill(name.clone()),
_ => Spot::Window(mine),
}
}
/// How window `number` is shown: "Window 2", and the monitor it fills.
pub(super) fn window_label(&self, number: u8) -> String {
match self.placement(number) {
Some(Placement::Fill(name)) if number != 1 => match self.monitor_label(name) {
Some(monitor) => format!("Window {number}, {monitor}"),
None => format!("Window {number}"),
},
_ => format!("Window {number}"),
}
}
/// Where player `i` plays, as the screens say it.
pub(super) fn label(&self, i: usize) -> String {
self.window_label(self.window_of(i))
}
/// Player `i` into a window: one there is, or a new one with the first number free.
pub(super) fn choose(&mut self, i: usize, choice: Spot) {
let placement = match choice {
Spot::Window(n) => return self.set_window(i, n),
Spot::New => Placement::Free(None),
Spot::Fill(name) => Placement::Fill(name),
};
// Out of its window first: alone there, its number is free again.
self.set_window(i, 1);
self.forget_empty_windows();
let number = (2..=u8::MAX)
.find(|n| self.windows.iter().all(|w| w.number != *n))
.unwrap_or(u8::MAX);
self.windows.push(layout::Window { number, placement });
self.set_window(i, number);
}
/// Windows nobody plays in close (Window 1 stays).
pub(super) fn forget_empty_windows(&mut self) {
let used: Vec<u8> = (0..self.players()).map(|i| self.window_number(i)).collect();
@@ -180,7 +285,21 @@ impl<'a> Split<'a> {
})
.collect();
let sizes: Vec<_> = self.surfaces.iter().map(|s| s.size).collect();
layout::place(&windows, &sizes, 0, 0)
layout::place_ordered(&windows, &self.player_order(), &sizes, 0, 0)
}
/// The players (indices) in the order they fill a window they share (`order`, by seat;
/// players it doesn't name last).
pub(super) fn player_order(&self) -> Vec<usize> {
let seats: Vec<usize> = std::iter::once(1)
.chain(self.guests.iter().map(|g| g.seat))
.collect();
let named: Vec<usize> = self
.order
.iter()
.filter_map(|seat| seats.iter().position(|s| s == seat))
.collect();
layout::ordered(&named, seats.len())
}
/// Every player into its view: its window, then its size. Keyboard and mouse follow their
@@ -212,6 +331,113 @@ impl<'a> Split<'a> {
self.turn(0);
}
/// The places a joining player steps through with its bumpers: each window it could play in,
/// at each place among the players there ("Window 1 (1 of 2)": first is left or on top),
/// then a new window and each monitor (`choices`).
pub(super) fn joining_stops(&self, i: usize) -> Vec<(String, Spot, usize)> {
let mut stops = Vec::new();
for (label, spot) in self.choices(i) {
let others = match spot {
Spot::Window(n) => self.sharing(i, n).len(),
_ => 0,
};
if others == 0 {
stops.push((label, spot, 0));
continue;
}
for k in 0..=others {
let label = format!("{label} ({} of {})", k + 1, others + 1);
stops.push((label, spot.clone(), k));
}
}
stops
}
/// The players but `i` in window `number`, in their order.
fn sharing(&self, i: usize, number: u8) -> Vec<usize> {
self.player_order()
.into_iter()
.filter(|&j| j != i && self.window_of(j) == number)
.collect()
}
pub(super) fn seat_of(&self, i: usize) -> usize {
if i == 0 { 1 } else { self.guests[i - 1].seat }
}
/// Player `i` to place `k` among the others in its window (0: before them all).
pub(super) fn put_at(&mut self, i: usize, k: usize) {
let others: Vec<usize> = self
.sharing(i, self.window_of(i))
.into_iter()
.map(|j| self.seat_of(j))
.collect();
let me = self.seat_of(i);
let mut order: Vec<usize> = self
.player_order()
.into_iter()
.map(|j| self.seat_of(j))
.filter(|&s| s != me)
.collect();
let at = match others.get(k) {
Some(seat) => order.iter().position(|s| s == seat).unwrap_or(order.len()),
// After the last of them.
None => others
.last()
.and_then(|seat| order.iter().position(|s| s == seat))
.map_or(order.len(), |p| p + 1),
};
order.insert(at, me);
self.order = order;
}
/// A bumper on the profile page (joining or switching): player `i`'s view moves to the next
/// place it could play (`back`: the one before), at once, so it sees where it will be.
pub(super) fn move_with_bumpers(&mut self, i: usize, back: bool) {
let stops = self.joining_stops(i);
let n = stops.len();
if n == 0 {
return;
}
let (spot, before) = (self.current(i), self.sharing_before(i));
let at = stops
.iter()
.position(|(_, s, k)| *s == spot && *k == before)
.unwrap_or(0);
let (shown, next, k) = stops[if back { (at + n - 1) % n } else { (at + 1) % n }].clone();
self.choose(i, next);
self.put_at(i, k);
if let Some(game) = self.game_mut(i) {
game.split_hud.place = Some((
format!("LB < {shown} > RB"),
Instant::now() + Duration::from_secs(3),
));
}
}
/// Where player `i` plays now, as its bumpers' stops name it ("Window 1 (2 of 2)"); its own
/// window by its name ("Window 2").
pub(super) fn stop_label(&self, i: usize) -> Option<String> {
let (spot, before) = (self.current(i), self.sharing_before(i));
if spot != Spot::Window(self.window_of(i)) {
return Some(self.label(i));
}
self.joining_stops(i)
.into_iter()
.find(|(_, s, k)| *s == spot && *k == before)
.map(|(label, ..)| label)
}
/// How many players come before `i` in its window.
fn sharing_before(&self, i: usize) -> usize {
let mine = self.window_of(i);
self.player_order()
.into_iter()
.take_while(|&j| j != i)
.filter(|&j| self.window_of(j) == mine)
.count()
}
/// What a window's title says: its name and who plays in it.
pub(super) fn title(&self, k: usize) -> String {
let views = &self.applied;
+42 -5
View File
@@ -2,7 +2,9 @@
//! power 4, `TntRenderer` swell and white flash, and `TntBlock.useItemOn`
//! (flint and steel / fire charge).
use super::Game;
use horizon_client::{Result, collisions::SceneCollisions, items::ItemStack, mesher::Vertex};
use horizon_client::{
Result, collisions::SceneCollisions, items::ItemStack, mesher::Vertex, mobs::Who,
};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
@@ -19,6 +21,8 @@ pub(super) struct PrimedTnt {
pub velocity: Vec3,
pub fuse: i32,
pub on_ground: bool,
pub owner: Option<Who>,
pub guest: Option<u32>,
}
impl PrimedTnt {
const SIZE: f64 = 0.98;
@@ -110,7 +114,13 @@ impl PrimedTnt {
impl Game<'_> {
/// `new PrimedTnt(level, x + 0.5, y, z + 0.5, owner)`; `short` applies
/// `wasExploded`'s random short fuse. Plays `entity.tnt.primed` for primes.
pub(super) fn spawn_primed_tnt(&mut self, pos: BlockPos, short: bool) {
pub(super) fn spawn_primed_tnt(
&mut self,
pos: BlockPos,
short: bool,
owner: Option<Who>,
guest: Option<u32>,
) {
// PrimedTnt: the fuse is lit.
let who = self.sculk_actor();
self.post_block_game_event("minecraft:prime_fuse", [pos.x, pos.y, pos.z], who, None);
@@ -132,10 +142,17 @@ impl Game<'_> {
);
80
};
self.add_primed_tnt(pos, rot, fuse);
self.add_primed_tnt(pos, rot, fuse, owner, guest);
}
/// Adds primed TNT at the block's bottom centre, pushed along `rot`.
pub(super) fn add_primed_tnt(&mut self, pos: BlockPos, rot: f64, fuse: i32) {
pub(super) fn add_primed_tnt(
&mut self,
pos: BlockPos,
rot: f64,
fuse: i32,
owner: Option<Who>,
guest: Option<u32>,
) {
let position = Vec3::new(
f64::from(pos.x) + 0.5,
f64::from(pos.y),
@@ -148,6 +165,8 @@ impl Game<'_> {
velocity: Vec3::new(-rot.sin() * 0.02, f64::from(0.2f32), -rot.cos() * 0.02),
fuse,
on_ground: false,
owner,
guest,
});
}
/// `PrimedTnt.tick` for every primed TNT, in order; an expired fuse
@@ -177,15 +196,28 @@ impl Game<'_> {
t.position.z,
];
let bounds = t.bounds();
let (owner, guest) = (t.owner, t.guest);
let in_water = self.bounds_in_water(bounds);
self.tnts.remove(i);
count -= 1;
// ExplosionInteraction.TNT: the tnt_explosion_drop_decay rule.
let interaction = self.destroy_type("tnt_explosion_drop_decay");
self.combat.explosion_in_water = in_water;
// `Explosion.getIndirectSourceEntity`: a player-lit TNT names
// its igniter, which `blast_attacker` and the damage type read.
self.combat.blast_guest = guest;
self.combat.blast_owner = match owner {
Some(Who::Player) => self
.lan
.as_ref()
.and_then(|host| self.player_attacker(host, guest)),
Some(Who::Mob(_)) | None => None,
};
let result = self.with_hive_emptying_blast(|g| {
g.server_explode(center, 4.0, interaction, false)
});
self.combat.blast_owner = None;
self.combat.blast_guest = None;
self.combat.explosion_in_water = false;
result?;
} else {
@@ -208,7 +240,8 @@ impl Game<'_> {
return Ok(false);
}
let pos = BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]);
self.spawn_primed_tnt(pos, false);
let guest = self.lan_acting;
self.spawn_primed_tnt(pos, false, Some(Who::Player), guest);
let air = self.world.air;
self.with_level(|b, level, ticks| {
b.set_block(level, ticks, pos, air, level_flags::UPDATE_ALL_IMMEDIATE)
@@ -507,6 +540,8 @@ mod tests {
velocity: Vec3::ZERO,
fuse: 80,
on_ground: true,
owner: None,
guest: None,
};
tnt.velocity = bounce_velocity(Direction::Up, tnt.velocity);
tnt.on_ground = false;
@@ -547,6 +582,8 @@ mod tests {
velocity: Vec3::ZERO,
fuse: 80,
on_ground: false,
owner: None,
guest: None,
};
tnt.tick(&world, &collision, &assets, &facts).unwrap();
assert_eq!(
+40 -24
View File
@@ -483,11 +483,6 @@ impl Game<'_> {
return false;
};
let rest = self.as_guest(&mut host, conn, |g| g.give(t.stack.clone()));
// Player.take: the guest's client plays the pickup sound.
if rest.is_some() {
let at = [t.position.x, t.position.y, t.position.z];
host.hub.take_item(t.id as i32, by.id, 1, at);
}
self.lan = Some(host);
let Some(rest) = rest else {
return false;
@@ -499,6 +494,14 @@ impl Game<'_> {
self.spawn_item(rest, [by.feet.x, by.feet.y, by.feet.z], [0.0; 3], 0);
}
}
// Player.take, for a creative thrower's trident too: the clients near
// play the pickup sound and fly the trident to its collector.
if by.guest.is_some()
&& let Some(host) = self.lan.as_ref()
{
let at = [t.position.x, t.position.y, t.position.z];
host.hub.take_item(t.id as i32, by.id, 1, at);
}
if by.guest.is_none() {
let pitch = ((self.world.random.next_float() - self.world.random.next_float()) * 0.7
+ 1.0)
@@ -723,25 +726,38 @@ impl Game<'_> {
glam::DVec3::splat(2.0 * HALF),
self.view_scale(),
);
for t in &self.tridents {
// EntityRenderer.shouldRender; ThrownTridentRenderer grows the box by 1.5.
if !self.standing_entity_in_view(
camera,
super::entity_culling::at(t.previous),
super::entity_culling::at(t.position),
alpha,
2.0 * HALF,
2.0 * HALF,
range,
1.5,
) {
continue;
}
let p = glam::DVec3::new(
t.previous.x + (t.position.x - t.previous.x) * alpha,
t.previous.y + (t.position.y - t.previous.y) * alpha,
t.previous.z + (t.position.z - t.previous.z) * alpha,
) - camera;
let live = self
.tridents
.iter()
.filter(|t| {
// EntityRenderer.shouldRender; ThrownTridentRenderer grows the box by 1.5.
self.standing_entity_in_view(
camera,
super::entity_culling::at(t.previous),
super::entity_culling::at(t.position),
alpha,
2.0 * HALF,
2.0 * HALF,
range,
1.5,
)
})
.map(|t| {
let at = [
t.previous.x + (t.position.x - t.previous.x) * alpha,
t.previous.y + (t.position.y - t.previous.y) * alpha,
t.previous.z + (t.position.z - t.previous.z) * alpha,
];
(t, at)
});
// ItemPickupParticle: a picked-up trident flying to its collector,
// drawn with the render state extracted at partial tick 1.
let taken = self.pickups.live.iter().filter_map(|p| match &p.taken {
super::pickups::Taken::Trident(t) => Some((&**t, super::pickups::position(p, alpha))),
_ => None,
});
for (t, at) in live.chain(taken) {
let p = glam::DVec3::from_array(at) - camera;
let base = glam::Mat4::from_translation(p.as_vec3())
* glam::Mat4::from_rotation_y((t.yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z((t.pitch + 90.0).to_radians());
+3
View File
@@ -89,6 +89,9 @@ pub fn update_recipes(
let mut all = BTreeMap::new();
recipes(&resources.join("data/minecraft/recipe"), "", &mut all);
overlay_recipes(overlay, &mut all);
// The data's recipe filters, as the server's own tables apply them.
let filters = horizon_items::recipe_filters::RecipeFilters::load(resources, overlay);
all.retain(|id, v| filters.apply(id, v));
let tags: BTreeMap<&str, &[i32]> = sync
.tags
.registries
+7
View File
@@ -20,6 +20,7 @@ pub mod brain;
pub mod breeze;
pub mod camel;
pub mod caravan;
pub mod cem;
pub mod combat;
pub mod control;
pub mod conversion;
@@ -1633,6 +1634,12 @@ pub enum MobEvent {
at: [f64; 3],
velocity: [f64; 3],
},
/// One `minecraft:entity_effect` particle at `at` in the colour `color`
/// (`ColorParticleOption.create(ENTITY_EFFECT, r, g, b)`), standing still.
ParticleColored {
at: [f64; 3],
color: [f32; 3],
},
Experience {
at: [f64; 3],
amount: u32,
+3
View File
@@ -990,6 +990,9 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if m.species() == Species::Illusioner {
super::illusioner::tick(m, events);
}
if matches!(m.species(), Species::Evoker | Species::Illusioner) {
super::illager::cast_particles(m, events);
}
if m.hurt_time > 0 {
m.hurt_time -= 1;
}
+510
View File
@@ -0,0 +1,510 @@
//! OptiFine custom entity models over Horizon's mob model layers: `optifine/cem/<name>.jem` (or
//! `cem/<name>/<name>.jem`, and Entity Model Features' `emf/cem/`, which wins) maps to one layer
//! (`zombie.jem` to `zombie#main`, `sheep_wool.jem` to `sheep#wool`), its parts to the layer's
//! vanilla parts, and its boxes join or replace theirs. A baby layer without its own model
//! takes the adult's (OptiFine's fallbacks, and Entity Model Features' for horses too).
//!
//! Built when the packs load; a mob's draw only swaps the layer's part tree. Without CEM files
//! nothing is built and every model stays vanilla's.
//!
//! The seam with random entity textures (ETF): a `.jem`'s own `texture` replaces the layer's
//! vanilla texture name, and the texture selection then works on that name as on vanilla's.
use super::{MobType, Part, Polygon};
use horizon_optifine::cem::model::{Bone, EntityModel, MAX_BYTES, parse_jem};
use std::{collections::HashMap, fs, path::Path, sync::Arc};
/// The CEM models of the enabled packs, by model layer (`zombie#main`).
#[derive(Default)]
pub struct CemModels {
layers: HashMap<String, CemLayer>,
}
/// A model layer as a `.jem` reshapes it.
pub struct CemLayer {
pub model: Arc<Part>,
/// The atlas name of the `.jem`'s texture (`entity/zombie/zombie_green`), drawn instead of
/// the layer's vanilla texture.
pub texture: Option<String>,
}
impl CemModels {
/// The `.jem` files under `root` applied to the layers of `types`.
pub fn load(root: &Path, types: &HashMap<String, Arc<MobType>>) -> Self {
let vanilla: HashMap<&str, &Arc<Part>> = types
.values()
.flat_map(|t| t.layers.iter().map(|(k, v)| (k.as_str(), v)))
.collect();
Self::over(root, &vanilla)
}
/// The `.jem` files under `root` applied to the `vanilla` layers.
fn over(root: &Path, vanilla: &HashMap<&str, &Arc<Part>>) -> Self {
let files = jem_files(root);
if files.is_empty() {
return Self::default();
}
let mut read = |path: &str| read_small(&root.join(path));
let mut layers = HashMap::new();
// A baby layer without its own file takes the adult's: those go after every own file.
let mut fallbacks = Vec::new();
for (name, path) in &files {
let Some(text) = read_small(&root.join(path)) else {
log::warn!(
"Horizon: custom entity model {path}: unreadable or over {MAX_BYTES} bytes"
);
continue;
};
let jem = match parse_jem(path, &text, &mut read) {
Ok(jem) => jem,
Err(e) => {
log::warn!("Horizon: custom entity model {e}");
continue;
}
};
for warning in &jem.warnings {
log::warn!("Horizon: custom entity model {warning}");
}
let jem = Arc::new(jem);
let key = layer_key(name);
if let Some(part) = vanilla.get(key.as_str()) {
layers.insert(key, CemLayer::build(part, &jem, name));
}
if !name.contains("_baby") {
fallbacks.extend(baby_names(name).map(|baby| (baby, name, jem.clone())));
}
}
for (baby, adult, jem) in fallbacks {
if files.contains_key(&baby) {
continue;
}
let key = layer_key(&baby);
if let Some(part) = vanilla.get(key.as_str()) {
layers
.entry(key)
.or_insert_with(|| CemLayer::build(part, &jem, adult));
}
}
Self { layers }
}
pub fn layer(&self, key: &str) -> Option<&CemLayer> {
self.layers.get(key)
}
}
/// A pack file's text, None when it's missing or larger than a model may read: its size is
/// checked before anything is read.
fn read_small(path: &Path) -> Option<String> {
let len = fs::metadata(path).ok()?.len();
if len > MAX_BYTES as u64 {
return None;
}
fs::read_to_string(path).ok()
}
/// Every `.jem` of the CEM folders by model name, `emf/cem` over `optifine/cem`.
fn jem_files(root: &Path) -> HashMap<String, String> {
let mut out = HashMap::new();
for base in ["assets/minecraft/optifine/cem", "assets/minecraft/emf/cem"] {
let Ok(entries) = fs::read_dir(root.join(base)) else {
continue;
};
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if let Some(model) = name.strip_suffix(".jem") {
out.insert(model.to_owned(), format!("{base}/{name}"));
} else if root.join(format!("{base}/{name}/{name}.jem")).is_file() {
out.entry(name.clone())
.or_insert_with(|| format!("{base}/{name}/{name}.jem"));
}
}
}
out
}
/// The model layer a CEM model name reshapes.
fn layer_key(name: &str) -> String {
// `<model>_baby[_<layer>]`: the adult's layer on the baby model.
if let Some((base, rest)) = name.split_once("_baby") {
let adult = layer_key(&format!("{base}{rest}"));
let (model, layer) = adult.split_once('#').unwrap_or((&adult, "main"));
return format!("{model}_baby#{layer}");
}
match name {
"sheep_wool" => "sheep#wool",
"creeper_charge" => "creeper#armor",
"slime_outer" => "slime#outer",
"drowned_outer" => "drowned#outer",
"stray_outer" => "stray#outer",
"bogged_outer" => "bogged#outer",
"wither_armor" => "wither#armor",
"breeze_wind" => "breeze#wind",
"llama_decor" | "trader_llama_decor" => "llama#decor",
"cat_collar" => "cat#collar",
"pig_saddle" => "pig#saddle",
"strider_saddle" => "strider#saddle",
"nautilus_saddle" => "nautilus#saddle",
"tropical_fish_a" => "tropical_fish_small#main",
"tropical_fish_b" => "tropical_fish_large#main",
"tropical_fish_pattern_a" => "tropical_fish_small#pattern",
"tropical_fish_pattern_b" => "tropical_fish_large#pattern",
"puffer_fish_big" => "pufferfish_big#main",
"puffer_fish_medium" => "pufferfish_medium#main",
"puffer_fish_small" => "pufferfish_small#main",
"zombie_horse_armor" | "skeleton_horse_armor" => "undead_horse_armor#main",
_ => return format!("{name}#main"),
}
.to_owned()
}
/// The baby model names that fall back to the adult `name`: `_baby` before any of its
/// `_` words or at its end (`sheep_wool` gives `sheep_baby_wool`).
fn baby_names(name: &str) -> impl Iterator<Item = String> + '_ {
let cuts = name.match_indices('_').map(|(i, _)| i).chain([name.len()]);
cuts.map(move |i| format!("{}_baby{}", &name[..i], &name[i..]))
}
/// The vanilla part an OptiFine part name of model `model` means. Names OptiFine kept from
/// the game's own code map to themselves.
fn vanilla_part(model: &str, part: &str) -> String {
// OptiFine numbers from 1, the game from 0.
let n = |prefix: &str| -> Option<usize> {
part.strip_prefix(prefix)?.parse().ok().filter(|i| *i >= 1)
};
let quadruped = matches!(
model,
"cow"
| "cold_cow"
| "warm_cow"
| "mooshroom"
| "pig"
| "cold_pig"
| "warm_pig"
| "pig_saddle"
| "sheep"
| "sheep_wool"
| "polar_bear"
| "panda"
| "turtle"
| "llama"
| "llama_decor"
| "trader_llama"
| "trader_llama_decor"
| "goat"
| "fox"
| "wolf"
| "wolf_armor"
| "wolf_collar"
| "ravager"
| "creeper"
| "creeper_charge"
| "axolotl"
);
const LEGS: [&str; 4] = [
"right_hind_leg",
"left_hind_leg",
"right_front_leg",
"left_front_leg",
];
const SPIDER_LEGS: [&str; 8] = [
"right_hind_leg",
"left_hind_leg",
"right_middle_hind_leg",
"left_middle_hind_leg",
"right_middle_front_leg",
"left_middle_front_leg",
"right_front_leg",
"left_front_leg",
];
let spider = matches!(model, "spider" | "cave_spider");
let renamed: Option<String> = match (model, part) {
(_, "headwear") => Some("hat".into()),
(_, "back_left_leg") => Some("left_hind_leg".into()),
(_, "back_right_leg") => Some("right_hind_leg".into()),
(_, "front_left_leg") => Some("left_front_leg".into()),
(_, "front_right_leg") => Some("right_front_leg".into()),
(_, _) if quadruped && n("leg").is_some_and(|i| (1..=4).contains(&i)) => {
n("leg").map(|i| LEGS[i - 1].into())
}
(_, _) if spider && n("leg").is_some_and(|i| (1..=8).contains(&i)) => {
n("leg").map(|i| SPIDER_LEGS[i - 1].into())
}
(_, "neck") if spider => Some("body0".into()),
(_, "body") if spider => Some("body1".into()),
("villager" | "wandering_trader" | "witch", "headwear2") => Some("hat_rim".into()),
("villager" | "wandering_trader" | "witch", "bodywear") => Some("jacket".into()),
("chicken" | "cold_chicken" | "warm_chicken", "bill") => Some("beak".into()),
("chicken" | "cold_chicken" | "warm_chicken", "chin") => Some("red_thing".into()),
("llama" | "llama_decor" | "trader_llama" | "trader_llama_decor", "chest_right") => {
Some("right_chest".into())
}
("llama" | "llama_decor" | "trader_llama" | "trader_llama_decor", "chest_left") => {
Some("left_chest".into())
}
("turtle", "body2") => Some("egg_belly".into()),
("ravager", "jaw") => Some("mouth".into()),
("bee" | "warden", "body") => Some("bone".into()),
("bee" | "warden", "torso") => Some("body".into()),
("snow_golem", "body") => Some("upper_body".into()),
("snow_golem", "body_bottom") => Some("lower_body".into()),
("snow_golem", "right_hand") => Some("right_arm".into()),
("snow_golem", "left_hand") => Some("left_arm".into()),
("slime" | "slime_outer", "body") => Some("cube".into()),
("magma_cube", "core") => Some("inside_cube".into()),
("magma_cube", _) => n("segment").map(|i| format!("cube{}", i - 1)),
("blaze", _) => n("stick").map(|i| format!("part{}", i - 1)),
("ghast" | "happy_ghast" | "happy_ghast_ropes" | "squid" | "glow_squid", _) => {
n("tentacle").map(|i| format!("tentacle{}", i - 1))
}
("silverfish", _) => n("body")
.map(|i| format!("segment{}", i - 1))
.or_else(|| n("wing").map(|i| format!("layer{}", i - 1))),
("endermite", _) => n("body").map(|i| format!("segment{}", i - 1)),
("guardian" | "elder_guardian", _) => n("spine")
.map(|i| format!("spike{}", i - 1))
.or_else(|| n("tail").map(|i| format!("tail{}", i - 1))),
("wither" | "wither_armor", _) => n("body")
.and_then(|i| ["shoulders", "ribcage", "tail"].get(i - 1).copied())
.or_else(|| {
n("head").and_then(|i| {
["center_head", "right_head", "left_head"]
.get(i - 1)
.copied()
})
})
.map(str::to_owned),
("phantom", "left_wing") => Some("left_wing_base".into()),
("phantom", "right_wing") => Some("right_wing_base".into()),
("phantom", "tail") => Some("tail_base".into()),
("phantom", "tail2") => Some("tail_tip".into()),
("cat" | "ocelot" | "cat_collar", "tail") => Some("tail1".into()),
("bat", "outer_right_wing") => Some("right_wing_tip".into()),
("bat", "outer_left_wing") => Some("left_wing_tip".into()),
("cod" | "tropical_fish_a" | "tropical_fish_b", "fin_back" | "fin_top") => {
Some("top_fin".into())
}
(_, "fin_right") if model.starts_with("puffer") => Some("right_blue_fin".into()),
(_, "fin_left") if model.starts_with("puffer") => Some("left_blue_fin".into()),
(_, "fin_right") => Some("right_fin".into()),
(_, "fin_left") => Some("left_fin".into()),
(_, "fin_bottom") => Some("bottom_fin".into()),
("cod", "tail") => Some("tail_fin".into()),
("sniffer", _) => part
.strip_prefix("middle_")
.and_then(|side| side.strip_suffix("_leg"))
.map(|side| format!("{side}_mid_leg")),
// `spikes_<front>_<top>` and `hair_<right>_<bottom>`.
(_, _) if part.starts_with("spikes_") => part["spikes_".len()..]
.split_once('_')
.map(|(along, side)| format!("{side}_{along}_fin")),
("strider" | "strider_saddle", _) => part
.strip_prefix("hair_")
.map(|rest| format!("{rest}_bristle")),
("horse" | "donkey" | "mule" | "skeleton_horse" | "zombie_horse", "neck") => {
Some("head_parts".into())
}
("horse" | "donkey" | "mule" | "skeleton_horse" | "zombie_horse", "mouth") => {
Some("upper_mouth".into())
}
_ => None,
};
renamed.unwrap_or_else(|| part.to_owned())
}
impl CemLayer {
/// `vanilla` reshaped by `jem`, the model `name`'s file.
fn build(vanilla: &Part, jem: &EntityModel, name: &str) -> Self {
let model = name.replace("_baby", "");
let mut out = vanilla.clone();
let mut count = 0;
for p in &jem.parts {
let target = vanilla_part(&model, &p.part);
let target = if find(&out, &target) {
target
} else {
p.part.clone()
};
let mut bone = part(&p.bone, &mut count);
bone.pose[6..9].fill(p.scale);
let Some(node) = find_mut(&mut out, &target) else {
log::warn!("Horizon: custom entity model {name}: no part {}", p.part);
continue;
};
if !p.attach {
node.polygons.clear();
}
node.children.push(bone);
}
Self {
model: Arc::new(out),
texture: jem.texture.as_deref().and_then(atlas_name),
}
}
}
/// The atlas name of a texture resource path: `assets/minecraft/textures/entity/x.png` is
/// `entity/x`. Textures outside a `textures/` folder are not in the atlas.
fn atlas_name(path: &str) -> Option<String> {
let rest = path.strip_prefix("assets/minecraft/textures/")?;
Some(rest.strip_suffix(".png").unwrap_or(rest).to_owned())
}
fn find(part: &Part, name: &str) -> bool {
part.name == name || part.children.iter().any(|c| find(c, name))
}
/// The first part named `name`, depth first (`ModelPart.getChild` through the tree).
fn find_mut<'a>(part: &'a mut Part, name: &str) -> Option<&'a mut Part> {
if part.name == name {
return Some(part);
}
part.children.iter_mut().find_map(|c| find_mut(c, name))
}
/// A baked bone as a model part. Its name, `cem#<n>`, is unique in the model, so its pose is
/// its own whatever the files call their ids.
fn part(bone: &Bone, count: &mut usize) -> Part {
let name = format!("cem#{count}");
*count += 1;
let [x, y, z] = bone.pivot;
let [rx, ry, rz] = bone.rotation;
Part {
name,
pose: [x, y, z, rx, ry, rz, 1.0, 1.0, 1.0],
polygons: bone
.quads
.iter()
.map(|q| Polygon {
normal: q.normal,
vertices: q.vertices,
})
.collect(),
children: bone.children.iter().map(|c| part(c, count)).collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn plane(name: &str, children: Vec<Part>) -> Part {
Part {
name: name.into(),
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: vec![Polygon {
normal: [0.0, 0.0, -1.0],
vertices: [[0.0; 5]; 4],
}],
children,
}
}
fn get<'a>(part: &'a Part, name: &str) -> &'a Part {
fn walk<'a>(p: &'a Part, name: &str) -> Option<&'a Part> {
(p.name == name)
.then_some(p)
.or_else(|| p.children.iter().find_map(|c| walk(c, name)))
}
walk(part, name).unwrap()
}
#[test]
fn layer_names() {
for (name, key) in [
("zombie", "zombie#main"),
("zombie_baby", "zombie_baby#main"),
("sheep_wool", "sheep#wool"),
("sheep_baby_wool", "sheep_baby#wool"),
("drowned_baby_outer", "drowned_baby#outer"),
("cold_chicken", "cold_chicken#main"),
("puffer_fish_big", "pufferfish_big#main"),
("tropical_fish_pattern_b", "tropical_fish_large#pattern"),
] {
assert_eq!(layer_key(name), key, "{name}");
}
assert_eq!(
baby_names("sheep_wool").collect::<Vec<_>>(),
["sheep_baby_wool", "sheep_wool_baby"]
);
}
#[test]
fn part_names() {
for (model, part, vanilla) in [
("zombie", "headwear", "hat"),
("zombie", "left_arm", "left_arm"),
("cow", "leg1", "right_hind_leg"),
("cow", "leg4", "left_front_leg"),
("spider", "leg5", "right_middle_front_leg"),
("spider", "neck", "body0"),
("ghast", "tentacle1", "tentacle0"),
("blaze", "stick12", "part11"),
("camel", "back_left_leg", "left_hind_leg"),
("villager", "headwear2", "hat_rim"),
("wither", "head2", "right_head"),
("strider", "hair_left_top", "left_top_bristle"),
("puffer_fish_big", "spikes_front_top", "top_front_fin"),
("squid", "tentacle0", "tentacle0"),
] {
assert_eq!(vanilla_part(model, part), vanilla, "{model} {part}");
}
}
#[test]
fn files_reshape_their_layers_and_babies_fall_back() {
let root = std::env::temp_dir().join(format!("horizon-cem-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let cem = root.join("assets/minecraft/optifine/cem");
fs::create_dir_all(cem.join("zombie")).unwrap();
let box_ = r#""boxes": [{"coordinates": [0, 0, 0, 1, 1, 1], "textureOffset": [0, 0]}]"#;
fs::write(
cem.join("zombie/zombie.jem"),
format!(
r#"{{"texture": "textures/entity/zombie/green.png", "models": [
{{"part": "head", {box_}}},
{{"part": "headwear", "attach": true, "scale": 2, {box_}}},
{{"part": "tail", {box_}}}]}}"#
),
)
.unwrap();
// Picked over optifine/cem's, though nothing reads EMF's here but its folder.
fs::create_dir_all(root.join("assets/minecraft/emf/cem")).unwrap();
fs::write(
root.join("assets/minecraft/emf/cem/cow.jem"),
format!(r#"{{"models": [{{"part": "leg1", {box_}}}]}}"#),
)
.unwrap();
fs::write(cem.join("cow.jem"), "{").unwrap();
let zombie = Arc::new(plane(
"root",
vec![plane("head", vec![plane("hat", vec![])])],
));
let cow = Arc::new(plane("root", vec![plane("right_hind_leg", vec![])]));
let vanilla: HashMap<&str, &Arc<Part>> = [
("zombie#main", &zombie),
("zombie_baby#main", &zombie),
("cow#main", &cow),
]
.into_iter()
.collect();
let models = CemModels::over(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let adult = models.layer("zombie#main").unwrap();
assert_eq!(adult.texture.as_deref(), Some("entity/zombie/green"));
let head = get(&adult.model, "head");
// Replaced: the head's own face goes, its hat stays, the custom box joins as a child.
assert_eq!(head.polygons.len(), 0);
assert_eq!(get(&adult.model, "cem#0").polygons.len(), 6);
// Attached: the hat keeps its face beside the scaled custom box.
let hat = get(&adult.model, "hat");
assert_eq!(hat.polygons.len(), 1);
assert_eq!(get(hat, "cem#1").pose[6..], [2.0, 2.0, 2.0]);
// The baby has no file of its own: it takes the adult's.
let baby = models.layer("zombie_baby#main").unwrap();
assert_eq!(get(&baby.model, "head").polygons.len(), 0);
let leg = get(&models.layer("cow#main").unwrap().model, "right_hind_leg");
assert_eq!((leg.polygons.len(), leg.children.len()), (0, 1));
assert!(models.layer("sheep#main").is_none());
}
}
+4
View File
@@ -48,6 +48,8 @@ pub struct MobData {
pub animations: HashMap<String, Animation>,
/// Replaced when the resource packs change (the rest of `MobData` never is).
pub villager_hats: std::sync::RwLock<super::villager_hats::VillagerHats>,
/// The packs' custom entity models (OptiFine CEM); replaced when the resource packs change.
pub cem: std::sync::RwLock<std::sync::Arc<super::cem::CemModels>>,
/// Biome tags (spawns_warm_variant_farm_animals, is_ocean...) by tag id.
pub biome_tags: HashMap<String, std::collections::HashSet<String>>,
/// Entity type tags (`data/*/tags/entity_type`).
@@ -145,6 +147,7 @@ impl MobData {
heightmaps,
animations,
villager_hats: Default::default(),
cem: Default::default(),
biome_tags: HashMap::new(),
entity_tags: HashMap::new(),
pois: Default::default(),
@@ -202,6 +205,7 @@ impl MobData {
heightmaps: HashMap::new(),
animations: HashMap::new(),
villager_hats: Default::default(),
cem: Default::default(),
biome_tags: HashMap::new(),
entity_tags: HashMap::new(),
pois: Default::default(),
+64 -2
View File
@@ -71,7 +71,8 @@ pub fn stared_at(m: &Mob, w: &MobWorld) -> bool {
Vec3::new(m.position.x, m.eye_y(), m.position.z),
)
}
/// Enderman.teleport(x, y, z): drop to standing ground, require free space without liquid.
/// Enderman.teleport(x, y, z): never while riding something; drop to standing
/// ground, require free space without liquid.
pub fn teleport_to(
m: &mut Mob,
w: &MobWorld,
@@ -80,6 +81,9 @@ pub fn teleport_to(
z: f64,
events: &mut Vec<MobEvent>,
) -> bool {
if m.extra.passenger {
return false;
}
let (bx, bz) = (x.floor() as i32, z.floor() as i32);
if !w.world.loaded(bx, bz) {
return false;
@@ -219,6 +223,13 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if m.extra.counter2 > 0 {
let tries = m.extra.counter2;
m.extra.counter2 = 0;
// repeatedlyTryToTeleport (64 tries) returns before any draw while riding;
// a single `teleport()` still draws its target first.
let tries = if tries > 1 && m.extra.passenger {
0
} else {
tries
};
for _ in 0..tries {
if teleport_random(m, w, events) {
break;
@@ -335,7 +346,7 @@ impl Goal for LookForPlayer {
1.0,
));
}
} else if m.ai.target == Some(Who::Player) {
} else if m.ai.target == Some(Who::Player) && !m.extra.passenger {
if stared_at(m, cx.w) {
if m.distance_sqr(p.position) < 16.0 {
teleport_random(m, cx.w, cx.events);
@@ -521,6 +532,57 @@ mod tests {
}
}
/// Enderman.teleport(x, y, z) fails for a passenger (#4865: one in a boat
/// played the teleport sound every tick and snapped back to its seat).
/// `repeatedlyTryToTeleport` returns before drawing; `teleport()` still
/// draws its three values first.
#[test]
fn a_seated_enderman_never_teleports() {
let build = |world: &mut crate::world::World, registry: &BlockRegistry| {
let stone = registry.default_state("minecraft:stone").unwrap();
for cx in -2..2 {
for cz in -2..2 {
world
.chunks
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
}
}
for x in -32..32 {
for z in -32..32 {
world.set(x, -1, z, stone).unwrap();
}
}
};
crate::mobs::world::with_built_test_world(&[], build, |w, _| {
let mut events = Vec::new();
let mut free = enderman();
assert!((0..64).any(|_| teleport_random(&mut free, w, &mut events)));
assert!(!events.is_empty());
let mut seated = enderman();
seated.extra.passenger = true;
events.clear();
assert!(!(0..64).any(|_| teleport_random(&mut seated, w, &mut events)));
assert!(events.is_empty());
assert_eq!(seated.position, Vec3::ZERO);
for tries in [64, 1] {
let mut seated = enderman();
seated.extra.passenger = true;
let mut expected = enderman().random;
if tries == 1 {
expected.next_double();
expected.next_int_bound(64).unwrap();
expected.next_double();
}
seated.extra.counter2 = tries;
ai_step(&mut seated, w, &mut events);
assert!(events.is_empty(), "{tries}");
assert_eq!(seated.random.next_int(), expected.next_int(), "{tries}");
}
});
}
/// Enderman.dropCustomDeathLoot (#3513): a killed enderman drops the
/// block it carries, mined with a silk touch diamond axe, so a grass
/// block comes back as a grass block (not dirt) and a nylium as itself.
+84 -1
View File
@@ -10,7 +10,7 @@ use super::{
};
use crate::items::ItemStack;
use crate::projectiles::{Projectile, ProjectileKind};
use horizon_core::geometry::trig::Mth;
use horizon_core::geometry::trig::{DEG_TO_RAD, Mth};
use horizon_core::{geometry::Vec3, random::RandomSource};
use std::f32::consts::PI;
@@ -111,6 +111,42 @@ enum Spell {
Fangs = 2,
Wololo = 3,
}
/// `IllagerSpell.spellColor` as `(float)` red, green and blue, by spell id
/// (1 summon vex, 2 fangs, 3 wololo, 4 disappear, 5 blindness).
fn spell_color(id: u8) -> [f32; 3] {
match id {
1 => [0.7, 0.7, 0.8],
2 => [0.4, 0.3, 0.35],
3 => [0.7, 0.5, 0.2],
4 => [0.3, 0.3, 0.8],
5 => [0.1, 0.1, 0.2],
_ => [0.0; 3],
}
}
/// The client half of `SpellcasterIllager.tick`: while the casting-spell data
/// is above 0, two `entity_effect` particles in the spell's colour, one at each
/// hand, which swing with the body and a wobble of the age.
pub fn cast_particles(m: &Mob, events: &mut Vec<MobEvent>) {
if m.extra.variant2 == 0 {
return;
}
let [r, g, b] = spell_color(m.extra.variant2);
let angle = m.body_yaw * DEG_TO_RAD + (m.age as f32 * 0.6662).mcos() * 0.25;
let (cos, sin) = (f64::from(angle.mcos()), f64::from(angle.msin()));
let scale = super::riders::scale(m);
let (reach, height) = (0.6 * scale, 1.8 * scale);
let p = m.position;
for side in [1.0, -1.0] {
events.push(MobEvent::ParticleColored {
at: [
p.x + side * cos * reach,
p.y + height,
p.z + side * sin * reach,
],
color: [r, g, b],
});
}
}
/// `SpellcasterCastingSpellGoal`: stand still facing the target while a
/// spell is cast.
#[derive(Clone, Debug)]
@@ -802,6 +838,53 @@ mod tests {
use crate::mobs::sensor_scan_tests::kind;
use std::sync::Arc;
fn evoker(spell: u8) -> Mob {
let mut kind = kind();
let k = Arc::make_mut(&mut kind);
k.id = "minecraft:evoker".into();
k.species = Species::Evoker;
let mut m = Mob::new(1, kind, Vec3::new(10.0, 64.0, -5.0), 0.0);
m.extra.variant2 = spell;
m.body_yaw = 90.0;
m.age = 7;
m
}
/// `SpellcasterIllager.tick`: two coloured particles at the hands while a
/// spell is cast, none otherwise; the colours are `IllagerSpell.spellColor`.
#[test]
fn a_casting_evoker_has_a_particle_in_each_hand() {
let mut events = Vec::new();
cast_particles(&evoker(0), &mut events);
assert!(events.is_empty());
for (id, want) in [
(1, [0.7, 0.7, 0.8]),
(2, [0.4, 0.3, 0.35]),
(3, [0.7, 0.5, 0.2]),
(4, [0.3, 0.3, 0.8]),
(5, [0.1, 0.1, 0.2]),
] {
events.clear();
cast_particles(&evoker(id), &mut events);
assert_eq!(events.len(), 2);
let hands: Vec<([f64; 3], [f32; 3])> = events
.iter()
.map(|e| match e {
MobEvent::ParticleColored { at, color } => (*at, *color),
_ => panic!("not a coloured particle"),
})
.collect();
assert_eq!(hands[0].1, want);
assert_eq!(hands[1].1, want);
// Both at y + 1.8, mirrored about the mob, 0.6 away.
assert!((hands[0].0[1] - 65.8).abs() < 1e-9);
assert!((hands[0].0[0] + hands[1].0[0] - 20.0).abs() < 1e-9);
assert!((hands[0].0[2] + hands[1].0[2] + 10.0).abs() < 1e-9);
let (dx, dz) = (hands[0].0[0] - 10.0, hands[0].0[2] + 5.0);
assert!((dx.hypot(dz) - 0.6).abs() < 1e-6);
}
}
fn pillager() -> Mob {
let mut kind = kind();
let k = Arc::make_mut(&mut kind);
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff. Show more