Merge origin/main into shaderpacks/shadow-pass-back-faces

Fixture: build/try_build (#9196) and build_with_terrain share one
try_build_with_terrain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-04 16:40:10 +01:00
commit 9fac526fef
507 files changed
+28390 -3258

No files matched your search

+2 -2
View File
@@ -868,7 +868,7 @@ Banners in End cities show as plain magenta instead of with their pattern.
<details><summary>Technical</summary>
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526).
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526). The templates' block entities now keep their patterns ([#1928](https://github.com/horizon-rust/horizon/issues/1928)).
</details>
@@ -878,7 +878,7 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
<details><summary>Technical</summary>
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660).
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660). The loot's enchantment functions now reach every table that rolls ([#1911](https://github.com/horizon-rust/horizon/issues/1911), [#2037](https://github.com/horizon-rust/horizon/issues/2037)).
</details>
+2
View File
@@ -16,6 +16,8 @@ are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
The GLSL frontend also resets the common type for each comma-separated declarator so one
variable's array specifier does not change later variables.
It also reads a struct member outside a constant expression through a pointer to the member,
not out of a loaded copy of the whole struct, so a member an atomic function uses can be read.
## libmimalloc-sys and mimalloc
+182 -34
View File
@@ -79,28 +79,65 @@ fn elements(tag: &Tag) -> Option<Vec<Tag>> {
_ => return None,
})
}
fn number(t: &Tag) -> Option<i64> {
match t {
Tag::Byte(v) => Some(i64::from(*v)),
Tag::Short(v) => Some(i64::from(*v)),
Tag::Int(v) => Some(i64::from(*v)),
Tag::Long(v) => Some(*v),
_ => None,
/// `DoubleTag.intValue` / `FloatTag.intValue` floor before the i32 conversion;
/// integral tags use their intValue narrowing. Byte arrays then keep the low byte.
fn int_value(t: &Tag) -> Option<i32> {
Some(match t {
Tag::Byte(v) => i32::from(*v),
Tag::Short(v) => i32::from(*v),
Tag::Int(v) => *v,
Tag::Long(v) => *v as i32,
Tag::Float(v) => f32::from_bits(*v).floor() as i32,
Tag::Double(v) => f64::from_bits(*v).floor() as i32,
_ => return None,
})
}
/// `FloatTag.longValue` truncates; `DoubleTag.longValue` floors before narrowing.
fn long_value(t: &Tag) -> Option<i64> {
Some(match t {
Tag::Byte(v) => i64::from(*v),
Tag::Short(v) => i64::from(*v),
Tag::Int(v) => i64::from(*v),
Tag::Long(v) => *v,
// FloatTag.longValue truncates; DoubleTag.longValue floors first.
Tag::Float(v) => f32::from_bits(*v) as i64,
Tag::Double(v) => f64::from_bits(*v).floor() as i64,
_ => return None,
})
}
#[derive(Clone, Copy)]
enum ElementKind {
List,
NumericArray,
}
impl ElementKind {
// Admission only: arrays require NumericTag; lists accept every tag type.
fn accepts(self, value: &Tag) -> bool {
matches!(self, Self::List)
|| matches!(
value,
Tag::Byte(_)
| Tag::Short(_)
| Tag::Int(_)
| Tag::Long(_)
| Tag::Float(_)
| Tag::Double(_)
)
}
}
/// Run `f` over the elements of a list/array, writing array results back.
/// Rust view adapter: run `f` over the elements, then write arrays using their numeric accessor.
/// None when `tag` is not list-like.
fn with_elements<R>(tag: &mut Tag, f: impl FnOnce(&mut Vec<Tag>) -> R) -> Option<R> {
fn with_elements<R>(tag: &mut Tag, f: impl FnOnce(&mut Vec<Tag>, ElementKind) -> R) -> Option<R> {
match tag {
Tag::List(l) => Some(f(l)),
Tag::List(l) => Some(f(l, ElementKind::List)),
Tag::ByteArray(_) | Tag::IntArray(_) | Tag::LongArray(_) => {
let mut v = elements(tag)?;
let r = f(&mut v);
let nums: Vec<i64> = v.iter().filter_map(number).collect();
let r = f(&mut v, ElementKind::NumericArray);
match tag {
Tag::ByteArray(a) => *a = nums.iter().map(|n| *n as u8).collect(),
Tag::IntArray(a) => *a = nums.iter().map(|n| *n as i32).collect(),
Tag::LongArray(a) => *a = nums,
// Float/double byteValue uses intValue's floor, then narrows.
Tag::ByteArray(a) => *a = v.iter().filter_map(int_value).map(|n| n as u8).collect(),
Tag::IntArray(a) => *a = v.iter().filter_map(int_value).collect(),
Tag::LongArray(a) => *a = v.iter().filter_map(long_value).collect(),
_ => {}
}
Some(r)
@@ -349,7 +386,7 @@ impl NbtPath {
});
removed
}
/// `insert` (also append and prepend): into every list the path reaches.
/// `NbtPathArgument.NbtPath.insert`: insert accepted tags into every reached collection.
pub fn insert(
&self,
index: i32,
@@ -372,19 +409,23 @@ impl NbtPath {
return;
}
let shown = crate::snbt::to_snbt(target);
let result = with_elements(target, |list| {
let result = with_elements(target, |list, kind| {
let start = if index < 0 {
list.len() as i64 + i64::from(index) + 1
} else {
i64::from(index)
};
let mut any = false;
for (n, v) in values.iter().enumerate() {
let at = start + n as i64;
let mut at = start;
for v in values {
if !kind.accepts(v) {
continue;
}
if at < 0 || at as usize > list.len() {
return Err(CollectionError::InvalidIndex(at as i32));
}
list.insert(at as usize, v.clone());
at += 1;
any = true;
}
Ok(any)
@@ -428,7 +469,8 @@ fn preferred_parent(node: &Node) -> Tag {
_ => Tag::Compound(Compound::new()),
}
}
/// `getOrCreateTag` (or `getTag` without `create`): the children of `parent`.
/// Children of `parent`; the array creation arm follows
/// `NbtPathArgument.AllElementsNode.getOrCreateTag` admission.
fn step_mut(
node: &Node,
parent: &mut Tag,
@@ -468,11 +510,14 @@ fn step_mut(
}
Node::AllElements => {
let make = create;
let _ = with_elements(parent, |list| {
let _ = with_elements(parent, |list, kind| {
if list.is_empty() {
if let Some(make) = make {
list.push(make());
out(&mut list[0]);
let value = make();
if kind.accepts(&value) {
list.push(value);
out(&mut list[0]);
}
}
} else {
for t in list.iter_mut() {
@@ -482,7 +527,7 @@ fn step_mut(
});
}
Node::Index(i) => {
let _ = with_elements(parent, |list| {
let _ = with_elements(parent, |list, _kind| {
let n = list.len() as i64;
let idx = if *i < 0 {
n + i64::from(*i)
@@ -518,7 +563,8 @@ fn step_mut(
}
}
}
/// `Node.setTag`: replace the child(ren) of `parent` with `value`.
/// Element setters follow `NbtPathArgument.AllElementsNode.setTag` and
/// `NbtPathArgument.IndexedElementNode.setTag`; object nodes replace their children.
fn set_tag(node: &Node, parent: &mut Tag, value: &Tag) -> usize {
match node {
Node::Child(name) => {
@@ -538,29 +584,36 @@ fn set_tag(node: &Node, parent: &mut Tag, value: &Tag) -> usize {
}
0
}
Node::AllElements => with_elements(parent, |list| {
if list.is_empty() {
list.push(value.clone());
Node::AllElements => with_elements(parent, |list, kind| {
let len = list.len();
if len == 0 {
if kind.accepts(value) {
list.push(value.clone());
}
// AllElementsNode returns 1 here even when addTag rejects.
return 1;
}
// Compare the original tags, before numeric conversion.
let changed = list.iter().filter(|t| *t != value).count();
if changed == 0 {
return 0;
}
for t in list.iter_mut() {
*t = value.clone();
list.clear();
if !kind.accepts(value) {
return 0;
}
list.resize(len, value.clone());
changed
})
.unwrap_or(0),
Node::Index(i) => with_elements(parent, |list| {
Node::Index(i) => with_elements(parent, |list, kind| {
let n = list.len() as i64;
let idx = if *i < 0 {
n + i64::from(*i)
} else {
i64::from(*i)
};
if (0..n).contains(&idx) && list[idx as usize] != *value {
if (0..n).contains(&idx) && list[idx as usize] != *value && kind.accepts(value) {
list[idx as usize] = value.clone();
1
} else {
@@ -586,6 +639,7 @@ fn set_tag(node: &Node, parent: &mut Tag, value: &Tag) -> usize {
Node::MatchRoot(_) => 0,
}
}
/// Removal arms, including `NbtPathArgument.AllElementsNode.removeTag`.
fn remove_tag(node: &Node, parent: &mut Tag) -> usize {
match node {
Node::Child(name) => match parent {
@@ -603,13 +657,13 @@ fn remove_tag(node: &Node, parent: &mut Tag) -> usize {
}
_ => 0,
},
Node::AllElements => with_elements(parent, |l| {
Node::AllElements => with_elements(parent, |l, _kind| {
let n = l.len();
l.clear();
n
})
.unwrap_or(0),
Node::Index(i) => with_elements(parent, |l| {
Node::Index(i) => with_elements(parent, |l, _kind| {
let n = l.len() as i64;
let idx = if *i < 0 {
n + i64::from(*i)
@@ -887,6 +941,100 @@ mod tests {
));
}
#[test]
fn numeric_array_edits_use_the_destination_numeric_accessor() {
for (initial, value, inserted, indexed, all) in [
(
"{v:[B;1b,2b]}",
"-3.5f",
"{v:[B;1b,-4b,2b]}",
"{v:[B;-4b,2b]}",
"{v:[B;-4b,-4b]}",
),
(
"{v:[B;1b,2b]}",
"-3.5d",
"{v:[B;1b,-4b,2b]}",
"{v:[B;-4b,2b]}",
"{v:[B;-4b,-4b]}",
),
(
"{v:[I;1,2]}",
"-3.5f",
"{v:[I;1,-4,2]}",
"{v:[I;-4,2]}",
"{v:[I;-4,-4]}",
),
(
"{v:[I;1,2]}",
"-3.5d",
"{v:[I;1,-4,2]}",
"{v:[I;-4,2]}",
"{v:[I;-4,-4]}",
),
(
"{v:[L;1L,2L]}",
"-3.5f",
"{v:[L;1L,-3L,2L]}",
"{v:[L;-3L,2L]}",
"{v:[L;-3L,-3L]}",
),
(
"{v:[L;1L,2L]}",
"-3.5d",
"{v:[L;1L,-4L,2L]}",
"{v:[L;-4L,2L]}",
"{v:[L;-4L,-4L]}",
),
] {
let value = tag(value);
let mut root = tag(initial);
assert_eq!(
path("v")
.insert(1, &mut root, std::slice::from_ref(&value))
.unwrap(),
1
);
assert_eq!(root, tag(inserted));
let mut root = tag(initial);
assert_eq!(path("v[0]").set(&mut root, &value).unwrap(), 1);
assert_eq!(root, tag(indexed));
let mut root = tag(initial);
assert_eq!(path("v[]").set(&mut root, &value).unwrap(), 2);
assert_eq!(root, tag(all));
}
}
#[test]
fn numeric_arrays_reject_nonnumeric_insert_and_indexed_set() {
for initial in ["{v:[B;1b,2b]}", "{v:[I;1,2]}", "{v:[L;1L,2L]}"] {
let original = tag(initial);
let value = tag("\"bad\"");
for index in [0, 99, -99] {
let mut root = original.clone();
assert_eq!(
path("v")
.insert(index, &mut root, std::slice::from_ref(&value))
.unwrap(),
0
);
assert_eq!(root, original);
}
let mut root = original.clone();
assert_eq!(path("v[0]").set(&mut root, &value).unwrap(), 0);
assert_eq!(root, original);
}
}
#[test]
fn empty_array_wildcard_set_preserves_vanillas_count_on_rejection() {
for initial in ["{v:[B;]}", "{v:[I;]}", "{v:[L;]}"] {
let mut root = tag(initial);
assert_eq!(path("v[]").set(&mut root, &tag("\"bad\"")).unwrap(), 1);
assert_eq!(root, tag(initial));
}
}
#[test]
fn partial_comparison() {
let actual = tag("{a:1,b:{c:[1,2,3],d:\"x\"},e:2}");
+237 -39
View File
@@ -179,12 +179,13 @@ impl PadState {
#[derive(Clone, Debug, Default)]
pub struct Edges {
held: u32,
suppressed: u32,
}
impl Edges {
pub fn update(&mut self, state: &PadState, threshold: f32) -> Vec<(Button, bool)> {
let mut now = state.buttons;
for (trigger, button) in [(0, Button::LeftTrigger), (1, Button::RightTrigger)] {
let was = self.held & button.bit() != 0;
let was = (self.held | self.suppressed) & button.bit() != 0;
let value = state.triggers[trigger];
let on = if was {
value >= (threshold - 0.1).max(0.05)
@@ -197,6 +198,8 @@ impl Edges {
now &= !button.bit();
}
}
self.suppressed &= now;
now &= !self.suppressed;
let changed = now ^ self.held;
self.held = now;
Button::ALL
@@ -217,6 +220,50 @@ impl Edges {
pub fn is_held(&self, button: Button) -> bool {
self.held & button.bit() != 0
}
/// Cancel a context without turning its still-held buttons into new presses.
pub fn cancel(&mut self) -> Vec<(Button, bool)> {
self.suppressed |= self.held;
self.release_all()
}
}
/// Intentional activity may take input ownership; an unchanged hold may not.
#[derive(Default)]
pub struct Activity {
buttons: u32,
sticks: [[f32; 2]; 2],
}
impl Activity {
pub fn observe(&mut self, state: &PadState, settings: &PadSettings) -> bool {
let mut buttons = state.buttons;
for (i, button) in [(0, Button::LeftTrigger), (1, Button::RightTrigger)] {
let threshold = if self.buttons & button.bit() != 0 {
(settings.trigger_threshold - 0.1).max(0.05)
} else {
settings.trigger_threshold
};
if state.triggers[i] >= threshold {
buttons |= button.bit();
}
}
let mut activity = buttons & !self.buttons != 0;
self.buttons = buttons;
for (i, (stick, deadzone)) in [
(state.left, settings.move_deadzone),
(state.right, settings.look_deadzone),
]
.into_iter()
.enumerate()
{
if length(stick) <= deadzone {
self.sticks[i] = [0.0; 2];
} else if length([stick[0] - self.sticks[i][0], stick[1] - self.sticks[i][1]]) > 0.08 {
activity = true;
self.sticks[i] = stick;
}
}
activity
}
}
/// Horizon's pad-only actions (no vanilla key mapping).
@@ -248,6 +295,39 @@ pub const ACTIONS: &[(&str, Option<Button>)] = &[
("key.smoothCamera", None),
];
/// How a bindable action reaches the game: one of its own handlers, shared
/// with the keyboard where the keyboard has one, or else the vanilla default
/// key or button of its mapping, as a rebound keyboard key would.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Route {
HotbarStep {
next: bool,
},
/// Held, or flipped when the pad's toggle sneak is on.
Sneak,
Screenshot,
SmoothCamera,
Key(winit::keyboard::KeyCode),
Mouse(winit::event::MouseButton),
}
/// The route of a bindable action; `None` for one the pad can't reach.
pub fn route(action: &str) -> Option<Route> {
match action {
HOTBAR_PREVIOUS => Some(Route::HotbarStep { next: false }),
HOTBAR_NEXT => Some(Route::HotbarStep { next: true }),
"key.sneak" => Some(Route::Sneak),
"key.screenshot" => Some(Route::Screenshot),
"key.smoothCamera" => Some(Route::SmoothCamera),
_ => {
let default = crate::keybinds::KeyBinds::default_of(action);
crate::keybinds::mouse_button(default)
.map(Route::Mouse)
.or_else(|| crate::keybinds::key_code(default).map(Route::Key))
}
}
}
/// An action's name in the binds list: vanilla's translation key, or
/// Horizon's own English for the pad-only actions.
pub fn action_caption(action: &str) -> &str {
@@ -313,31 +393,6 @@ impl GlyphStyle {
}
}
/// How a controller drives menus (docs/controllers.md, "Menus and
/// inventories").
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum MenuNav {
/// A console-style highlight moved between widgets and slots.
#[default]
Focus,
/// A virtual mouse pointer.
Pointer,
}
impl MenuNav {
pub fn name(self) -> &'static str {
match self {
MenuNav::Focus => "focus",
MenuNav::Pointer => "pointer",
}
}
pub fn parse(name: &str) -> Self {
match name {
"pointer" => MenuNav::Pointer,
_ => MenuNav::Focus,
}
}
}
/// The on-screen keyboard A opens on a text field in focus mode.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum TextKeyboard {
@@ -399,9 +454,12 @@ pub struct PadSettings {
/// holding it, unless vanilla's toggle crouch is on already.
pub toggle_sneak: bool,
pub glyphs: GlyphStyle,
/// How the pad drives menus: a focus highlight or a virtual pointer.
pub menu_nav: MenuNav,
pub keyboard: TextKeyboard,
/// Menu pointer multiplier, scroll lines/second, and repeat times in seconds.
pub pointer_speed: f32,
pub scroll_speed: f32,
pub repeat_delay: f32,
pub repeat_interval: f32,
/// Action name -> button, `None` for unbound; missing means the default.
bindings: BTreeMap<&'static str, Option<Button>>,
}
@@ -420,8 +478,11 @@ impl Default for PadSettings {
trigger_threshold: 0.4,
toggle_sneak: true,
glyphs: GlyphStyle::Auto,
menu_nav: MenuNav::Focus,
keyboard: TextKeyboard::Auto,
pointer_speed: 1.0,
scroll_speed: 12.0,
repeat_delay: 0.35,
repeat_interval: 0.09,
bindings: BTreeMap::new(),
}
}
@@ -462,6 +523,13 @@ impl PadSettings {
.filter(|v| v.is_finite())
.map_or(d, |v| v.clamp(0.0, 1.0))
};
let bounded = |default: f32, min: f32, max: f32| {
value
.parse::<f32>()
.ok()
.filter(|v| v.is_finite())
.map_or(default, |v| v.clamp(min, max))
};
match key.trim() {
"enabled" => s.enabled = flag(s.enabled),
"vibration" => s.vibration = flag(s.vibration),
@@ -477,8 +545,11 @@ impl PadSettings {
}
"toggleSneak" => s.toggle_sneak = flag(s.toggle_sneak),
"glyphs" => s.glyphs = GlyphStyle::parse(value),
"menuNavigation" => s.menu_nav = MenuNav::parse(value),
"keyboard" => s.keyboard = TextKeyboard::parse(value),
"pointerSpeed" => s.pointer_speed = bounded(s.pointer_speed, 0.25, 3.0),
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
"repeatDelay" => s.repeat_delay = bounded(s.repeat_delay, 0.1, 1.0),
"repeatInterval" => s.repeat_interval = bounded(s.repeat_interval, 0.03, 0.3),
other => {
if let Some(action) = other.strip_prefix("bind_") {
let button = if value == "none" {
@@ -497,7 +568,7 @@ impl PadSettings {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"enabled:{}\nvibration:{}\nadaptiveTriggers:{}\nlightbar:{}\nlookSensitivity:{}\ninvertX:{}\ninvertY:{}\nmoveDeadzone:{}\nlookDeadzone:{}\ntriggerThreshold:{}\ntoggleSneak:{}\nglyphs:{}\nmenuNavigation:{}\nkeyboard:{}\n",
"enabled:{}\nvibration:{}\nadaptiveTriggers:{}\nlightbar:{}\nlookSensitivity:{}\ninvertX:{}\ninvertY:{}\nmoveDeadzone:{}\nlookDeadzone:{}\ntriggerThreshold:{}\ntoggleSneak:{}\nglyphs:{}\nkeyboard:{}\n",
bool_text(self.enabled),
bool_text(self.vibration),
bool_text(self.adaptive_triggers),
@@ -510,9 +581,12 @@ impl PadSettings {
self.trigger_threshold,
bool_text(self.toggle_sneak),
self.glyphs.name(),
self.menu_nav.name(),
self.keyboard.name(),
);
text.push_str(&format!(
"pointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
self.pointer_speed, self.scroll_speed, self.repeat_delay, self.repeat_interval,
));
for (action, button) in &self.bindings {
text.push_str(&format!(
"bind_{action}:{}\n",
@@ -1163,6 +1237,131 @@ pub fn bow_power(ticks: u32) -> f32 {
mod tests {
use super::*;
#[test]
fn every_bindable_action_reaches_the_game() {
for (action, _) in ACTIONS {
assert!(route(action).is_some(), "{action} has nowhere to go");
}
}
#[test]
fn navigation_preferences_migrate_and_stay_with_each_player() {
let dir = std::env::temp_dir().join(format!(
"horizon-pad-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let p2 = dir.join("P2");
fs::create_dir_all(&p2).unwrap();
let fresh = PadSettings::load(&dir);
assert_eq!(fresh, PadSettings::default());
// An older file, with the removed menuNavigation key: bindings kept.
let legacy =
PadSettings::parse("menuNavigation:pointer\nbind_key.jump:east\nlookSensitivity:0.7\n");
assert_eq!(legacy.binding("key.jump"), Some(Button::East));
assert_eq!(legacy.look_sensitivity, 0.7);
fresh.save(&dir);
assert!(!dir.join(FILE).exists(), "defaults write no file");
let mut other = legacy;
other.pointer_speed = 2.5;
other.scroll_speed = 24.0;
other.repeat_delay = 0.6;
other.repeat_interval = 0.12;
other.save(&p2);
assert_eq!(PadSettings::load(&p2), other);
assert_eq!(PadSettings::load(&dir), fresh, "P2 did not write P1");
let invalid = PadSettings::parse(
"pointerSpeed:NaN\nscrollSpeed:inf\nrepeatDelay:-10\nrepeatInterval:20\n",
);
assert_eq!((invalid.pointer_speed, invalid.scroll_speed), (1.0, 12.0));
assert_eq!((invalid.repeat_delay, invalid.repeat_interval), (0.1, 0.3));
let bounded = PadSettings::parse(
"pointerSpeed:10\nscrollSpeed:-1\nrepeatDelay:NaN\nrepeatInterval:inf\n",
);
assert_eq!((bounded.pointer_speed, bounded.scroll_speed), (3.0, 1.0));
assert_eq!(
(bounded.repeat_delay, bounded.repeat_interval),
(0.35, 0.09)
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn ownership_requires_new_activity_and_ignores_drift() {
let settings = PadSettings::default();
let mut p1 = Activity::default();
let mut p2 = Activity::default();
let mut state = PadState {
left: [0.04, 0.03],
..PadState::default()
};
assert!(!p1.observe(&state, &settings));
state.set(Button::South, true);
assert!(p1.observe(&state, &settings));
assert!(
!p1.observe(&state, &settings),
"held pad cannot reclaim mouse ownership"
);
assert!(
p2.observe(&state, &settings),
"another player's tracker is independent"
);
state.set(Button::South, false);
assert!(!p1.observe(&state, &settings));
state.left = [0.5, 0.0];
assert!(p1.observe(&state, &settings));
state.left = [0.52, 0.0];
assert!(!p1.observe(&state, &settings));
for x in [0.54, 0.56] {
state.left = [x, 0.0];
assert!(!p1.observe(&state, &settings));
}
state.left = [0.6, 0.0];
assert!(
p1.observe(&state, &settings),
"slow intentional movement accumulates"
);
state.left = [0.0; 2];
state.triggers[0] = 0.5;
assert!(p1.observe(&state, &settings));
state.triggers[0] = 0.35;
assert!(!p1.observe(&state, &settings));
state.triggers[0] = 0.45;
assert!(
!p1.observe(&state, &settings),
"trigger threshold noise is not new activity"
);
}
#[test]
fn cancelled_holds_stay_released_until_neutral_then_press_again() {
let mut edges = Edges::default();
let mut state = PadState::default();
state.set(Button::South, true);
state.triggers[1] = 0.8;
assert_eq!(
edges.update(&state, 0.4),
vec![(Button::South, true), (Button::RightTrigger, true)]
);
assert_eq!(
edges.cancel(),
vec![(Button::South, false), (Button::RightTrigger, false)]
);
assert!(edges.update(&state, 0.4).is_empty());
state.triggers[1] = 0.35;
assert!(edges.update(&state, 0.4).is_empty());
assert!(edges.update(&PadState::default(), 0.4).is_empty());
assert_eq!(edges.update(&state, 0.4), vec![(Button::South, true)]);
state.triggers[1] = 0.7;
assert_eq!(
edges.update(&state, 0.4),
vec![(Button::RightTrigger, true)]
);
}
#[test]
fn defaults_follow_bedrock() {
let s = PadSettings::default();
@@ -1414,14 +1613,13 @@ mod tests {
assert_eq!(stick_direction([0.9, 0.2], 0.5), Some(Button::DPadRight));
assert_eq!(stick_direction([-0.2, -0.8], 0.5), Some(Button::DPadDown));
assert_eq!(stick_direction([0.3, 0.3], 0.5), None);
let mut s = PadSettings::default();
assert_eq!(s.menu_nav, MenuNav::Focus);
s.menu_nav = MenuNav::Pointer;
s.keyboard = TextKeyboard::Horizon;
let back = PadSettings::parse(&s.serialize());
let s = PadSettings {
keyboard: TextKeyboard::Horizon,
..PadSettings::default()
};
assert_eq!(
(back.menu_nav, back.keyboard),
(MenuNav::Pointer, TextKeyboard::Horizon)
PadSettings::parse(&s.serialize()).keyboard,
TextKeyboard::Horizon
);
assert!(steam_keyboard(TextKeyboard::Steam) && !steam_keyboard(TextKeyboard::Horizon));
}
+155
View File
@@ -0,0 +1,155 @@
//! The keys a player's mappings hold, as their vanilla default keys, and which
//! devices hold each one. A key stays held while any device holds it, so one
//! device letting go (splitscreen moving the keyboard to another player, say)
//! can't release what another still presses.
use std::collections::HashMap;
use winit::keyboard::KeyCode;
/// Where a press came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Source {
/// The keyboard and mouse, and the scripting socket acting as them.
Keyboard,
Pad,
}
impl Source {
fn bit(self) -> u8 {
match self {
Source::Keyboard => 1,
Source::Pad => 2,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct HeldKeys {
held: HashMap<KeyCode, u8>,
}
impl HeldKeys {
pub fn contains(&self, key: &KeyCode) -> bool {
self.held.contains_key(key)
}
/// `source` holds `key`. True when nothing held it before.
pub fn press(&mut self, key: KeyCode, source: Source) -> bool {
let sources = self.held.entry(key).or_insert(0);
let new = *sources == 0;
*sources |= source.bit();
new
}
/// `source` lets go of `key`. True when nothing holds it any more.
pub fn release(&mut self, key: &KeyCode, source: Source) -> bool {
let Some(sources) = self.held.get_mut(key) else {
return true;
};
*sources &= !source.bit();
if *sources == 0 {
self.held.remove(key);
true
} else {
false
}
}
pub fn held_by(&self, key: &KeyCode, source: Source) -> bool {
self.held
.get(key)
.is_some_and(|sources| sources & source.bit() != 0)
}
/// `source` lets go of everything: the keys nothing holds any more.
pub fn release_source(&mut self, source: Source) -> Vec<KeyCode> {
let keys: Vec<KeyCode> = self
.held
.iter()
.filter(|(_, sources)| *sources & source.bit() != 0)
.map(|(key, _)| *key)
.collect();
keys.into_iter()
.filter(|key| self.release(key, source))
.collect()
}
/// Held, by the keyboard unless a device already holds it: for callers
/// that don't say where the press came from.
pub fn insert(&mut self, key: KeyCode) -> bool {
if self.contains(&key) {
return false;
}
self.press(key, Source::Keyboard)
}
/// Let go of `key` whoever holds it.
pub fn remove(&mut self, key: &KeyCode) -> bool {
self.held.remove(key).is_some()
}
pub fn clear(&mut self) {
self.held.clear();
}
pub fn is_empty(&self) -> bool {
self.held.is_empty()
}
pub fn len(&self) -> usize {
self.held.len()
}
pub fn iter(&self) -> impl Iterator<Item = &KeyCode> {
self.held.keys()
}
}
impl IntoIterator for HeldKeys {
type Item = KeyCode;
type IntoIter = std::collections::hash_map::IntoKeys<KeyCode, u8>;
fn into_iter(self) -> Self::IntoIter {
self.held.into_keys()
}
}
impl<'a> IntoIterator for &'a HeldKeys {
type Item = &'a KeyCode;
type IntoIter = std::collections::hash_map::Keys<'a, KeyCode, u8>;
fn into_iter(self) -> Self::IntoIter {
self.held.keys()
}
}
impl FromIterator<KeyCode> for HeldKeys {
fn from_iter<I: IntoIterator<Item = KeyCode>>(keys: I) -> Self {
let mut held = HeldKeys::default();
for key in keys {
held.insert(key);
}
held
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_key_stays_held_while_any_device_holds_it() {
let mut keys = HeldKeys::default();
assert!(keys.press(KeyCode::Space, Source::Pad));
assert!(!keys.press(KeyCode::Space, Source::Keyboard));
assert!(keys.press(KeyCode::KeyW, Source::Keyboard));
assert_eq!(keys.release_source(Source::Keyboard), vec![KeyCode::KeyW]);
assert!(keys.contains(&KeyCode::Space) && !keys.contains(&KeyCode::KeyW));
assert!(keys.held_by(&KeyCode::Space, Source::Pad));
assert!(!keys.held_by(&KeyCode::Space, Source::Keyboard));
assert!(keys.release(&KeyCode::Space, Source::Pad));
assert!(keys.is_empty());
}
#[test]
fn untagged_inserts_and_removes_act_as_before() {
let mut keys = HeldKeys::default();
keys.press(KeyCode::ShiftLeft, Source::Pad);
assert!(!keys.insert(KeyCode::ShiftLeft), "already held");
assert!(!keys.held_by(&KeyCode::ShiftLeft, Source::Keyboard));
assert!(
keys.remove(&KeyCode::ShiftLeft),
"remove lets go for everyone"
);
assert!(!keys.contains(&KeyCode::ShiftLeft));
assert!(keys.release(&KeyCode::KeyA, Source::Keyboard), "never held");
}
}
+2
View File
@@ -5,6 +5,7 @@
#![forbid(unsafe_code)]
pub mod clipboard;
pub mod gamepad;
pub mod held;
pub mod input;
pub mod interaction_input;
pub mod key_names;
@@ -12,4 +13,5 @@ pub mod keybinds;
pub mod keymapping;
pub mod modifiers;
pub mod mouse;
pub mod snap;
pub mod window;
+152
View File
@@ -0,0 +1,152 @@
//! Where the controller's menu cursor settles once the stick is let go, and
//! how it gets there. Every rule's number lives in [`Tuning`], so the feel can
//! be adjusted in one place.
/// The snapping engine's knobs, in GUI pixels and seconds.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tuning {
/// A let-go cursor settles on a target this close to it.
pub radius: f32,
/// How long the glide onto a target takes.
pub glide_seconds: f32,
/// Movement shorter than this is a wobble: the cursor stays where it was.
pub min_travel: f32,
/// A flick that ends still nearest its starting target commits to the
/// neighbour it was heading for once it covered this fraction of the way.
pub commit: f32,
/// How far off the flick's direction a neighbour may lie (cosine of the
/// angle: 0.7 is about 45 degrees).
pub cone: f32,
/// The pointer's speed over a slot or control, as a fraction of its
/// speed elsewhere (1 leaves it unchanged).
pub over_target_speed: f32,
}
pub const TUNING: Tuning = Tuning {
radius: 16.0,
glide_seconds: 0.025,
min_travel: 1.5,
commit: 0.15,
cone: 0.7,
over_target_speed: 1.0,
};
type Point = (f32, f32);
fn distance(a: Point, b: Point) -> f32 {
((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
}
/// The target nearest `at`, if one lies within `radius`.
pub fn nearest(at: Point, targets: &[Point], radius: f32) -> Option<usize> {
targets
.iter()
.enumerate()
.map(|(i, t)| (i, distance(at, *t)))
.filter(|(_, d)| *d <= radius)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(i, _)| i)
}
/// Where a stick movement from `start` to `end` settles among `targets`
/// (their centres), or `None` to leave the cursor where it is.
pub fn settle(start: Point, end: Point, targets: &[Point], tuning: &Tuning) -> Option<usize> {
let near = nearest(end, targets, tuning.radius);
let travel = (end.0 - start.0, end.1 - start.1);
let length = distance(start, end);
if length < tuning.min_travel {
return near;
}
let origin = nearest(start, targets, tuning.radius);
if near != origin {
return near;
}
// Still nearest where it started: a quick flick toward a neighbour.
let Some(o) = origin else {
return near;
};
let from = targets[o];
let heading = (travel.0 / length, travel.1 / length);
let next = targets
.iter()
.enumerate()
.filter(|(i, _)| *i != o)
.filter_map(|(i, t)| {
let to = (t.0 - from.0, t.1 - from.1);
let gap = distance(from, *t);
let aligned = (to.0 * heading.0 + to.1 * heading.1) / gap;
(gap > 0.0 && aligned >= tuning.cone).then_some((i, gap, to))
})
.min_by(|a, b| a.1.total_cmp(&b.1));
match next {
Some((i, gap, to)) => {
let progress = ((end.0 - from.0) * to.0 + (end.1 - from.1) * to.1) / (gap * gap);
if progress >= tuning.commit {
Some(i)
} else {
near
}
}
None => near,
}
}
/// The cursor `seconds` into a glide from `from` to `to`: eased out, so it
/// slows as it lands, and exactly `to` once the glide is over.
pub fn glide(from: Point, to: Point, seconds: f32, tuning: &Tuning) -> Point {
let t = (seconds / tuning.glide_seconds).clamp(0.0, 1.0);
let eased = 1.0 - (1.0 - t).powi(3);
(
from.0 + (to.0 - from.0) * eased,
from.1 + (to.1 - from.1) * eased,
)
}
#[cfg(test)]
mod tests {
use super::*;
/// A row of inventory slots, 18 pixels apart, and the slot below the first.
const SLOTS: [Point; 4] = [(8.0, 8.0), (26.0, 8.0), (44.0, 8.0), (8.0, 26.0)];
#[test]
fn a_short_flick_moves_to_the_neighbour_it_heads_for() {
// 4 pixels right of slot 0: still nearest slot 0, but heading for slot 1.
assert_eq!(settle((8.0, 8.0), (12.0, 8.0), &SLOTS, &TUNING), Some(1));
assert_eq!(settle((8.0, 8.0), (8.0, 12.0), &SLOTS, &TUNING), Some(3));
assert_eq!(settle((26.0, 8.0), (22.0, 8.0), &SLOTS, &TUNING), Some(0));
}
#[test]
fn a_wobble_stays_put() {
assert_eq!(settle((8.0, 8.0), (8.5, 8.4), &SLOTS, &TUNING), Some(0));
// Barely started toward the neighbour: not committed.
assert_eq!(settle((8.0, 8.0), (9.6, 8.0), &SLOTS, &TUNING), Some(0));
}
#[test]
fn a_long_move_settles_on_the_nearest_target() {
assert_eq!(settle((8.0, 8.0), (42.0, 9.0), &SLOTS, &TUNING), Some(2));
// Far from everything: left where it is.
assert_eq!(settle((8.0, 8.0), (200.0, 200.0), &SLOTS, &TUNING), None);
}
#[test]
fn a_flick_off_any_row_does_not_jump_sideways() {
// Up from slot 0: nothing lies that way, so it stays on slot 0.
assert_eq!(settle((8.0, 8.0), (8.0, 4.0), &SLOTS, &TUNING), Some(0));
}
#[test]
fn the_glide_eases_onto_its_target_and_stops_there() {
let (from, to) = ((0.0, 0.0), (20.0, -10.0));
assert_eq!(glide(from, to, 0.0, &TUNING), from);
let half = glide(from, to, TUNING.glide_seconds / 2.0, &TUNING);
assert!(
half.0 > 10.0 && half.0 < 20.0,
"eased out: past halfway at half time"
);
assert_eq!(glide(from, to, TUNING.glide_seconds, &TUNING), to);
assert_eq!(glide(from, to, 1.0, &TUNING), to);
}
}
@@ -68,7 +68,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
true,
))?;
assert!(!renderer.pack_entity_shadows(), "no selected pack");
renderer.set_shader_pack(0, None, &[]);
renderer.set_shader_pack(0, None, &[]).unwrap();
assert!(
!renderer.pack_entity_shadows(),
"selected pack without a compiled post"
@@ -165,6 +165,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
}
let (_, notes) = renderer
.set_shader_pack(dimension, Some(post), &[])
.unwrap()
.unwrap();
if break_pipeline {
assert!(
@@ -190,8 +190,9 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
}
let one = [255u8; 4];
let zero = [0u8; 4];
let pixels = render.pixels.get();
let prepared = PreparedTextures::new(
&render.pixels,
&pixels,
render.sprite_size,
render.texture_layers,
&render.sprite_mips,
@@ -206,7 +207,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
let mut renderer = pollster::block_on(Renderer::new(
window,
true,
&render.pixels,
&pixels,
render.sprite_size,
render.sprite_minimum_size,
render.texture_layers,
@@ -224,6 +225,8 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
false,
false,
))?;
drop(pixels);
render.pixels.pack();
let state = &scene.renderer;
let [width, height] = o.size.unwrap_or(state.size);
renderer.resize(width, height);
@@ -79,7 +79,10 @@ fn pack(renderer: &mut Renderer, folder: &Path, valid: bool) {
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &renderer.pack_inputs());
assert_eq!(post.notes.is_empty(), valid, "{:?}", post.notes);
let (_, notes) = renderer.set_shader_pack(0, Some(post), &[]).unwrap();
let (_, notes) = renderer
.set_shader_pack(0, Some(post), &[])
.unwrap()
.unwrap();
assert!(
!valid || notes.iter().all(|n| !n.contains("skipped")),
"{notes:?}"
+99 -2
View File
@@ -84,7 +84,9 @@ pub struct Assets {
pub struct RenderAssets {
pub sprite_size: u32,
pub sprite_minimum_size: u32,
pub pixels: Vec<u8>,
/// The block sprites' RGBA, layer by layer: as built until a renderer has
/// them, then kept compressed ([`SpritePixels`]).
pub pixels: SpritePixels,
pub texture_layers: u32,
pub model_catalog: crate::models::StateModels,
/// Shared parsed resources; item-only validation errors surface at item preparation.
@@ -111,6 +113,76 @@ pub struct RenderAssets {
/// sprites are when it changes (`native_block_sprites_version`).
pub generation: u64,
}
/// The block sprites' pixels for a renderer to upload. Once a renderer has
/// them ([`Self::pack`]) they are kept lz4-compressed: the GPU holds the
/// texture array, and the copy only serves a renderer made again (another
/// window, a lost device, another graphics API), which waits for
/// [`Self::get`] to uncompress them. Shared by the window and the workers'
/// `RenderAssets`, so the compressed form replaces the raw one in place.
pub struct SpritePixels(Arc<PixelStore>);
struct PixelStore {
len: usize,
/// Set by the first `pack`: later calls cost one atomic read.
packing: std::sync::atomic::AtomicBool,
/// As built; dropped once `packed` holds them.
raw: std::sync::Mutex<Option<Arc<Vec<u8>>>>,
packed: std::sync::OnceLock<Vec<u8>>,
}
impl SpritePixels {
pub fn new(pixels: Vec<u8>) -> Self {
Self(Arc::new(PixelStore {
len: pixels.len(),
packing: std::sync::atomic::AtomicBool::new(false),
raw: std::sync::Mutex::new(Some(Arc::new(pixels))),
packed: std::sync::OnceLock::new(),
}))
}
/// Their size in bytes, uncompressed.
pub fn len(&self) -> usize {
self.0.len
}
pub fn is_empty(&self) -> bool {
self.0.len == 0
}
/// The pixels as built, uncompressed again once packed.
pub fn get(&self) -> Arc<Vec<u8>> {
let raw = self.0.raw.lock().unwrap_or_else(|e| e.into_inner()).clone();
raw.unwrap_or_else(|| {
let packed = self.0.packed.get().map(Vec::as_slice).unwrap_or_default();
Arc::new(
lz4_flex::decompress_size_prepended(packed)
.ok()
.filter(|p| p.len() == self.0.len)
.unwrap_or_else(|| vec![0; self.0.len]),
)
})
}
/// Keep them compressed from now on, once a renderer has uploaded them:
/// compressed on a thread of their own, the raw copy dropped after.
pub fn pack(&self) {
use std::sync::atomic::Ordering;
if self.0.len == 0 || self.0.packing.swap(true, Ordering::Relaxed) {
return;
}
let store = self.0.clone();
let job = move || {
let raw = store.raw.lock().unwrap_or_else(|e| e.into_inner()).clone();
if let Some(raw) = raw {
let _ = store.packed.set(lz4_flex::compress_prepend_size(&raw));
// `get` reads the compressed copy from here on.
*store.raw.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
};
let fallback = job.clone();
if std::thread::Builder::new()
.name("Sprite packing".into())
.spawn(job)
.is_err()
{
fallback();
}
}
}
/// The last `RenderAssets::generation` handed out.
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn invalid(message: &str) -> io::Error {
@@ -333,7 +405,7 @@ impl RenderAssets {
sprite_size: sprites.size,
sprite_minimum_size: sprites.minimum_size,
texture_layers: u32::try_from(sprites.count())?,
pixels: sprites.pixels,
pixels: SpritePixels::new(sprites.pixels),
model_catalog: models.catalog,
model_library,
model_placements: models.placements,
@@ -431,4 +503,29 @@ mod tests {
[ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3
));
}
#[test]
fn sprite_pixels_come_back_the_same_once_packed() {
// Two layers of 16x16 RGBA with some structure, as sprites have.
let pixels: Vec<u8> = (0..2 * 16 * 16 * 4).map(|i| (i * 7 % 251) as u8).collect();
let stored = super::SpritePixels::new(pixels.clone());
assert_eq!(*stored.get(), pixels);
stored.pack();
stored.pack();
// The packing thread drops the raw copy once the compressed one is set.
let started = std::time::Instant::now();
while stored.0.raw.lock().unwrap().is_some() {
assert!(started.elapsed().as_secs() < 10, "packing never finished");
std::thread::yield_now();
}
assert!(
stored
.0
.packed
.get()
.is_some_and(|p| p.len() < pixels.len())
);
assert_eq!(*stored.get(), pixels);
assert_eq!(stored.len(), pixels.len());
}
}
@@ -11,6 +11,7 @@ use crate::{
block_entity_anim::{self, EnchantingBook},
breaking::entity_geometry,
fast_hash::FxHashMap,
font::Glyph,
gfx::mark_oit,
hud::Rect,
mesher::{CULL_BACK, Vertex},
@@ -660,7 +661,7 @@ fn sign_text_calls(
} else {
(dark, 0, vertex_light(packed))
};
let advance = |c: char| (scene.glyph)(c).1;
let advance = |c: char| (scene.glyph)(c).advance;
let width = |s: &str| s.chars().map(advance).sum::<f32>().ceil();
text.lines
.iter()
@@ -699,10 +700,19 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
let mut x = call.x + dx;
let y0 = call.y + dy;
for ch in call.text.chars() {
let (cell, advance) = (scene.glyph)(ch);
if let Some(r) = cell {
let corners = [(x, y0 + 8.0), (x + 8.0, y0 + 8.0), (x + 8.0, y0), (x, y0)];
let uvs = [[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]];
let g = (scene.glyph)(ch);
if let Some(cell) = g.rect {
// BakedSheetGlyph: the glyph's own box from the pen, not a
// fixed 8x8 cell (a Unifont glyph is 0.5 units per texel).
let (x0, x1) = (x + g.left, x + g.right);
let (top, bottom) = (y0 + g.top, y0 + g.bottom);
let corners = [(x0, bottom), (x1, bottom), (x1, top), (x0, top)];
let uvs = [
[cell.u0, cell.v1],
[cell.u1, cell.v1],
[cell.u1, cell.v0],
[cell.u0, cell.v0],
];
let c =
0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
for ((cx, cy), uv) in corners.into_iter().zip(uvs) {
@@ -721,7 +731,7 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
});
}
}
x += advance;
x += g.advance;
}
};
if call.outline != 0 {
@@ -818,8 +828,8 @@ pub struct Scene<'a> {
/// profile → skin): a downloaded `skins/<sha1>` slot or the profile's
/// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
/// Font glyph cell (none for blank glyphs such as the space) and advance.
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
/// Font glyph: atlas cell (none for blank glyphs such as the space), box and advance.
pub glyph: &'a dyn Fn(char) -> Glyph,
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
/// text laid out under another value is laid out again.
pub glyphs: u64,
@@ -3342,7 +3352,7 @@ mod tests {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
// Compare the cached world path with the original model renderer,
@@ -3610,7 +3620,13 @@ mod tests {
w: 8,
h: 8,
};
(Some(rect), 6.0)
Glyph {
rect: Some(rect),
right: 8.0,
bottom: 8.0,
advance: 6.0,
..Glyph::default()
}
};
let scene = Scene {
camera: DVec3::ZERO,
@@ -3660,6 +3676,78 @@ mod tests {
assert!(out.iter().all(|v| v.pos[2] == 0.0));
}
/// BakedSheetGlyph draws a glyph over its own box from the pen
/// (x + left .. x + right, y + up .. y + down), not a fixed 8x8 cell. With
/// Force Unicode Font a Latin letter is an 8x16 Unifont glyph at half
/// scale: 4 units wide and 8 tall, advancing 5 (#8699).
#[test]
fn sign_text_draws_each_glyph_over_its_own_box() {
let world = World::new(BlockStateId(0), 1);
let sprites = HashMap::new();
let glyph = |_: char| Glyph {
rect: Some(Rect {
u0: 0.0,
v0: 0.0,
u1: 0.0625,
v1: 0.125,
w: 8,
h: 16,
}),
left: 0.0,
top: 0.0,
right: 4.0,
bottom: 8.0,
advance: 5.0,
offset: 0.5,
};
let scene = Scene {
camera: DVec3::ZERO,
orientation: Quat::IDENTITY,
anim: &|_| BlockAnim::default(),
texts: None,
partial: 0.0,
game_time: 0,
max_y: 320,
world: &world,
sprites: &sprites,
emissive: &Default::default(),
translucent_sprites: &HashSet::new(),
atlas_revision: 1,
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
};
let call = TextCall {
matrix: Mat4::IDENTITY,
x: 10.0,
y: 2.0,
text: "ab".into(),
color: 0xFF00_0000,
outline: 0,
light: 240 | 240 << 8,
};
let mut out = Vec::new();
draw_text(&scene, &call, &mut out);
let boxes: Vec<_> = out
.chunks(4)
.map(|q| {
let xs = q.iter().map(|v| v.pos[0]);
let ys = q.iter().map(|v| v.pos[1]);
[
xs.clone().fold(f32::MAX, f32::min),
xs.fold(f32::MIN, f32::max),
ys.clone().fold(f32::MAX, f32::min),
ys.fold(f32::MIN, f32::max),
]
})
.collect();
assert_eq!(boxes, [[10.0, 14.0, 2.0, 10.0], [15.0, 19.0, 2.0, 10.0]]);
}
#[test]
fn cached_sign_text_matches_and_follows_changes() {
let renderer = BlockEntityRenderer {
@@ -3714,7 +3802,13 @@ mod tests {
w: 8,
h: 8,
};
((c != ' ').then_some(rect), wide.get())
Glyph {
rect: (c != ' ').then_some(rect),
right: 8.0,
bottom: 8.0,
advance: wide.get(),
..Glyph::default()
}
};
let (first_sign, changed_sign, glowing_sign) = (
|_| Some(&first_text),
@@ -3879,7 +3973,7 @@ mod tests {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let mut verts = Vec::new();
@@ -3964,7 +4058,7 @@ mod tests {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
@@ -4,6 +4,7 @@ use crate::{
ModelPart, SIGN_TEXT_KEEP, Scene, StaticSectionRevision, classify,
},
chunk::Chunk,
font::Glyph,
hud::Rect,
mesher::Vertex,
signs::{SignData, SignText},
@@ -355,7 +356,7 @@ fn batched_callbacks_preserve_entity_ranges_and_allow_immediate_culling() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
// Reverse local-position order to check that cache indexing does not
@@ -443,7 +444,7 @@ fn static_cache_qualifies_local_positions_by_section_and_drops_old_entries() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let options = CacheOptions {
@@ -497,7 +498,7 @@ fn gpu_static_cache_uses_section_local_chests_and_keeps_visibility_separate() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([100, 70, -32], 0)];
@@ -651,18 +652,19 @@ fn sign_text_cache_can_be_disabled_and_reenabled_without_changing_output() {
};
let sign_data = &data;
let sign = move |_: [i32; 3]| Some(sign_data);
let glyph = |_: char| {
(
Some(Rect {
u0: 0.0,
v0: 0.0,
u1: 0.0625,
v1: 0.0625,
w: 8,
h: 8,
}),
6.0,
)
let glyph = |_: char| Glyph {
rect: Some(Rect {
u0: 0.0,
v0: 0.0,
u1: 0.0625,
v1: 0.0625,
w: 8,
h: 8,
}),
right: 8.0,
bottom: 8.0,
advance: 6.0,
..Glyph::default()
};
let scene = Scene {
camera: DVec3::new(2.25, 64.5, -3.75),
@@ -735,7 +737,7 @@ fn shulker_cache_tracks_settled_poses_and_does_not_freeze_opening_animation() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([0, 64, 0], 0)];
@@ -836,7 +838,7 @@ fn shulker_cache_covers_all_colors_and_facing_directions() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let options = CacheOptions {
@@ -890,7 +892,7 @@ fn cached_bells_keep_shaking_until_the_animation_ends() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([0, 64, 0], 0)];
@@ -951,7 +953,7 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([0, 64, 0], 0)];
@@ -1046,7 +1048,7 @@ fn closed_chest_cache_covers_all_materials_and_atlas_reloads() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let pos = [0, 64, 0];
@@ -1134,7 +1136,7 @@ fn double_chest_cache_bypasses_both_halves_while_either_lid_is_open() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let pair = [([0, 64, 0], 0), ([1, 64, 0], 1)];
@@ -1209,7 +1211,7 @@ fn decorated_pot_cache_tracks_sherds_and_bypasses_wobble_animation() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([0, 64, 0], 0)];
@@ -1287,7 +1289,7 @@ fn banner_cache_keeps_the_pole_static_while_the_flag_and_patterns_update() {
sign: &|_| None,
banner: &banner_patterns,
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([0, 64, 0], 0)];
@@ -1353,7 +1355,7 @@ fn cached_wall_banner_poles_use_the_wall_model() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let pos = [0, 64, 0];
@@ -1400,7 +1402,7 @@ fn copper_statue_cache_rebuilds_when_its_pose_state_changes() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let options = CacheOptions {
@@ -1505,7 +1507,7 @@ fn copper_statue_cache_covers_weathering_waxing_and_all_poses() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let options = CacheOptions {
@@ -58,6 +58,24 @@ impl DataPack {
}
out
}
/// The files of `data/<namespace>/<folder>/` in every namespace, as
/// `list` names them; a folder pack walks only those folders.
pub fn list_folder(&self, folder: &str) -> Vec<String> {
let files = self.list_kinds(&[folder]);
let Source::Zip(_) = &self.source else {
return files;
};
files
.into_iter()
.filter(|f| {
f.strip_prefix("data/")
.and_then(|r| r.split_once('/'))
.is_some_and(|(_, r)| {
r.strip_prefix(folder).is_some_and(|r| r.starts_with('/'))
})
})
.collect()
}
pub fn read(&self, path: &str) -> Option<Vec<u8>> {
match &self.source {
Source::Zip(z) => z.read(path).ok(),
+11 -1
View File
@@ -78,6 +78,13 @@ pub struct Extras {
pub zoom_smooth: bool,
/// Divide mouse turning by the zoom.
pub zoom_sensitivity: bool,
/// Far terrain a zoom magnifies is planned finer, so a spyglass does not
/// show the coarse cells it was given for an unzoomed screen
/// (`lod::Aim`). Costs far tiles while the zoom is held and gives them
/// back after, and does nothing at all unless far terrain is on
/// (`lodDistance`) and the view is actually zoomed. Off by default until
/// a measurement shows the extra tiles land while the zoom is still held.
pub lod_zoom_detail: bool,
/// The FPS counter and keystrokes extras are official mods now: their
/// old settings stay here, kept as they were.
pub fps: bool,
@@ -175,6 +182,7 @@ impl Default for Extras {
zoom_level: 4,
zoom_smooth: true,
zoom_sensitivity: true,
lod_zoom_detail: false,
fps: false,
fps_corner: Corner::TopLeft,
fps_lows: false,
@@ -250,6 +258,7 @@ impl Extras {
}
"zoomSmooth" => e.zoom_smooth = flag(e.zoom_smooth),
"zoomSensitivity" => e.zoom_sensitivity = flag(e.zoom_sensitivity),
"lodZoomDetail" => e.lod_zoom_detail = flag(e.lod_zoom_detail),
"fps" => e.fps = flag(e.fps),
"fpsCorner" => e.fps_corner = Corner::parse(value, e.fps_corner),
"fpsLows" => e.fps_lows = flag(e.fps_lows),
@@ -322,7 +331,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\ncosmeticsOthers:{}\ncosmeticsAuras:{}\n{}:{}\n",
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\ncosmeticsOthers:{}\ncosmeticsAuras:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
@@ -331,6 +340,7 @@ impl Extras {
self.zoom_level,
bool_text(self.zoom_smooth),
bool_text(self.zoom_sensitivity),
bool_text(self.lod_zoom_detail),
bool_text(self.fps),
self.fps_corner.name(),
bool_text(self.fps_lows),
+108 -2
View File
@@ -24,7 +24,7 @@ pub const DEFAULT: &str = "minecraft:default";
/// A glyph ready to draw: atlas UVs and its box relative to the pen
/// position (GlyphBitmap left/top/right/bottom), in GUI pixels.
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Copy, Debug, Default)]
pub struct Glyph {
pub rect: Option<Rect>,
pub left: f32,
@@ -134,6 +134,7 @@ pub struct Font {
/// Atlas size and the reserved dynamic region (y, height).
atlas: (u32, u32),
region: (u32, u32),
metrics_only: bool,
cache: RefCell<Cache>,
/// Set anew for each font and each `reset_cache` (see `epoch`).
generation: std::cell::Cell<u64>,
@@ -284,6 +285,7 @@ fn load_bitmap(
sheets: &HashMap<String, Rect>,
pixels: &[u8],
atlas: (u32, u32),
metrics_only: bool,
) -> HashMap<char, BitmapGlyph> {
let mut out = HashMap::new();
let file = p["file"].as_str().unwrap_or("");
@@ -349,6 +351,10 @@ fn load_bitmap(
advance: (0.5 + used as f32 * scale).floor() + 1.0,
offset: 1.0,
};
if metrics_only {
out.insert(*ch, BitmapGlyph::Atlas(glyph));
continue;
}
let glyph = match &sheet {
None => BitmapGlyph::Atlas(Glyph {
rect: Some(glyph_rect(
@@ -596,6 +602,30 @@ impl Font {
atlas: (u32, u32),
region: (u32, u32),
white: Rect,
) -> Self {
Self::load_with(root, sprites, pixels, atlas, region, white, false)
}
/// A server measures text but never draws it. Decode bitmap sheets only
/// to obtain advances, then release their pixels; never rasterize glyphs.
pub fn metrics(root: &Path) -> Self {
let white = Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
};
Self::load_with(root, &HashMap::new(), &[], (1, 1), (0, 0), white, true)
}
fn load_with(
root: &Path,
sprites: &HashMap<String, Rect>,
pixels: &[u8],
atlas: (u32, u32),
region: (u32, u32),
white: Rect,
metrics_only: bool,
) -> Self {
let sheets = sprites
.iter()
@@ -617,6 +647,7 @@ impl Font {
root: root.to_owned(),
atlas,
region,
metrics_only,
cache: RefCell::new(Cache {
sets: Vec::new(),
region: Region {
@@ -665,7 +696,14 @@ impl Font {
)),
"bitmap" => Some(Provider::Bitmap(
Filter::of(p),
load_bitmap(&self.root, p, &self.sheets, pixels, self.atlas),
load_bitmap(
&self.root,
p,
&self.sheets,
pixels,
self.atlas,
self.metrics_only,
),
)),
"unihex" => Some(Provider::Unihex(Filter::of(p), p.clone(), None)),
"ttf" => Some(Provider::TrueType(
@@ -841,6 +879,14 @@ impl Font {
if let Some(id) = ttf.ids.get(&c)
&& !(non_fishy && fishy_advance(ttf.advance(*id)))
{
if self.metrics_only {
return Some(Glyph {
rect: None,
advance: ttf.advance(*id),
offset: 1.0,
..self.missing
});
}
let (glyph, bitmap) = ttf.glyph(*id);
return match bitmap {
Some((w, h, rgba)) => {
@@ -861,6 +907,17 @@ impl Font {
}
fn rasterize_hex(&self, region: &mut Region, h: HexGlyph) -> Option<Glyph> {
let width = h.right.saturating_sub(h.left) + 1;
if self.metrics_only {
return Some(Glyph {
rect: None,
left: 0.0,
top: 0.0,
right: width as f32 / 2.0,
bottom: 8.0,
advance: hex_advance(&h),
offset: 0.5,
});
}
let mut rgba = Vec::with_capacity((width * 16 * 4) as usize);
for row in h.rows {
for bit in h.left..=h.right {
@@ -1361,6 +1418,55 @@ mod tests {
out
}
#[test]
fn server_metrics_preserve_bitmap_true_type_and_unihex_advances() {
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
let def = br#"{"providers":[{"type":"space","advances":{" ":4}},{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["ab"]}]}"#;
let ttf = br#"{"providers":[{"type":"ttf","file":"minecraft:mono.ttf","size":11,"oversample":2}]}"#;
let root = root(
"metrics",
&[
("assets/minecraft/font/default.json", def),
("assets/minecraft/font/alt.json", ttf),
(
"assets/minecraft/font/mono.ttf",
horizon_theme::fonts::GEIST_MONO,
),
("assets/minecraft/textures/font/a.png", &sheet),
],
);
let rendered = load(&root);
let metrics = Font::metrics(&root);
assert_eq!(metrics.width("a b"), 14.0);
assert_eq!(metrics.width("§lab"), 12.0);
let text = Text::literal("ab ab");
assert_eq!(
metrics.split_formatted(&text, 10.0),
rendered.split_formatted(&text, 10.0)
);
let alt = Style {
font: Some("minecraft:alt".into()),
bold: Some(true),
..Style::default()
};
for c in ['A', 'i', ' ', '\u{fffd}'] {
assert_eq!(metrics.advance(c, &alt), rendered.advance(c, &alt));
assert!(metrics.glyph_styled(c, &alt).rect.is_none());
}
let mut hex = HashMap::new();
parse_hex(b"0041:0000000018242442427E424242420000\n", &mut hex);
let h = hex[&0x41];
let glyph = metrics
.rasterize_hex(&mut metrics.cache.borrow_mut().region, h)
.unwrap();
assert_eq!(glyph.advance, 4.0);
assert_eq!(glyph.offset, 0.5);
assert!(glyph.rect.is_none());
assert!(metrics.take_patches().is_empty());
assert!(!rendered.take_patches().is_empty());
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn true_type_glyphs_are_rasterized_at_the_provider_size() {
let def = br#"{"providers":[{"type":"ttf","file":"minecraft:mono.ttf","size":11,"oversample":2,"shift":[0.5,1],"skip":["B"]}]}"#;
File diff suppressed because it is too large. Load diff
@@ -885,6 +885,7 @@ mod tests {
sorted_indices: None,
flat_id: None,
gpu_id: None,
gpu_fading: false,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -1081,6 +1082,7 @@ mod tests {
sorted_indices: None,
flat_id: Some(id),
gpu_id: None,
gpu_fading: false,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -1307,6 +1309,7 @@ mod tests {
sorted_indices: (translucent > 0).then_some(if indices { at } else { (7, 7) }),
flat_id: None,
gpu_id: None,
gpu_fading: false,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -14,9 +14,11 @@
//! each in one draw (the pipelines cull the faces turned away), with the origin
//! the walk would give it: `resident::origin`'s f32 bits, copied, never
//! computed on the GPU. Within the render distance the CPU's walk then lists
//! only the sections whose translucent quads it draws, or that have no record
//! (`Resident::walk`); past it, it lists every section as before.
//! Unlike the walk, the pass draws sections fading in or out whole.
//! only the sections whose translucent quads it draws, that have no record, or
//! that fade (`Resident::walk`); past it, it lists every section as before. A
//! section whose instance carries a fade, fading in or out, has its record
//! flagged (`FADING`, gfx.rs `mark_fades`) until it is whole: the pass leaves
//! it out, and the CPU draws it with its fade.
//!
//! The graph is the one the walk reads that frame (`occlusion::Reach`). Its
//! search keeps the cells it reached as bits too, and `Occlusion` a generation
@@ -59,6 +61,7 @@ use super::{
upload::Uploads,
};
use glam::{DVec3, DVec4, Vec3};
use std::{cmp::Reverse, collections::BinaryHeap, time::Instant};
/// One section (`gpu_culling.wgsl`'s `Section`).
#[repr(C)]
@@ -68,10 +71,14 @@ pub(super) struct Record {
key: [i32; 4],
/// The first vertex of its solid quads (a draw's base vertex, an i32: a
/// page holds fewer vertices), its solid quads, its box (`rounded_box`),
/// then a word kept at 0.
/// then its flags (`FADING`).
a: [u32; 4],
}
/// In `Record::a[3]`: the CPU draws the section's solid quads while it fades
/// (gfx.rs `SectionGpu::gpu_fading`), and the pass leaves it out.
const FADING: u32 = 1;
impl Record {
const FREE: Self = Self {
key: [0, 0, 0, -1],
@@ -86,9 +93,19 @@ impl Record {
a: [start as u32, quads, boxed, 0],
}
}
/// The record, `FADING` when `on`.
pub fn fading(mut self, on: bool) -> Self {
if on {
self.a[3] |= FADING;
}
self
}
fn page(&self) -> Option<u32> {
u32::try_from(self.key[3]).ok()
}
fn is_fading(&self) -> bool {
(self.a[3] & FADING) != 0
}
}
/// `rounded_box` for bounds past what a box's codes hold, or not finite: no
@@ -359,6 +376,10 @@ pub(super) fn supported(
// The pass binds 8, the last cave culling's graph.
&& limits.max_storage_buffers_per_shader_stage >= 8
&& !super::flat::is_broken_intel_vulkan(info)
// DX12's count draws leave each draw's base vertex out of `vertex_index`, by which the
// pulled solid pipelines (those of a device with flat page groups) read the page.
// shortcut: not offered there, until GPU culling's draws carry their base vertex otherwise.
&& !(info.backend == wgpu::Backend::Dx12 && flat_min_sections.is_some())
}
/// A buffer that is recreated larger when it must hold more.
@@ -419,6 +440,8 @@ pub(super) struct Records {
all_dirty: bool,
/// Per arena page, the records on it.
page_sections: Vec<u32>,
/// The records flagged `FADING`.
fading: u32,
}
impl Records {
@@ -435,6 +458,7 @@ impl Records {
};
self.records[id as usize] = record;
self.add_to(record.key[3] as u32);
self.fading += u32::from(record.is_fading());
self.mark(id);
id
}
@@ -455,6 +479,7 @@ impl Records {
continue;
};
self.page_sections[page as usize] -= 1;
self.fading -= u32::from(self.records[id as usize].is_fading());
self.records[id as usize] = Record::FREE;
// shortcut: a teleport frees more than `DIRTY_LIMIT`, so `prepare` writes the
// whole table (0.6-2.6 ms at render distance 64); upgrade to a list of freed ids
@@ -502,6 +527,22 @@ impl Records {
self.add_to(page);
self.mark(id);
}
/// The section with `id` began to draw faded, or stopped (gfx.rs
/// `SectionGpu::gpu_fading`): its record is `FADING` when `on`.
pub fn set_fading(&mut self, id: u32, on: bool) {
let page = self.records.get(id as usize).and_then(Record::page);
debug_assert!(page.is_some(), "no record {id} to flag");
if page.is_none() || self.records[id as usize].is_fading() == on {
return;
}
self.records[id as usize].a[3] ^= FADING;
if on {
self.fading += 1;
} else {
self.fading -= 1;
}
self.mark(id);
}
/// Every section went.
pub fn clear(&mut self) {
self.records.clear();
@@ -510,12 +551,17 @@ impl Records {
self.dirty = Vec::new();
self.all_dirty = false;
self.page_sections.clear();
self.fading = 0;
}
/// The sections with a record.
pub fn live(&self) -> usize {
let free: u32 = self.free.iter().map(|word| word.count_ones()).sum();
self.records.len() - free as usize
}
/// The sections whose record is `FADING`, which the CPU draws.
pub fn fading(&self) -> u32 {
self.fading
}
/// The record of the section with `id`; `None` for a free id.
#[cfg(any(test, debug_assertions))]
pub fn at(&self, id: u32) -> Option<&Record> {
@@ -549,6 +595,42 @@ impl Records {
}
}
/// The flagged sections by when their fade is over at the latest (gfx.rs
/// `SectionGpu::fade_end`), for gfx.rs `mark_fades` to look at them again
/// then, and the chunk fade-in time their flags were decided with: `None`
/// until a frame has decided them, just after GPU culling was switched on.
/// The ends of sections that went or were handed back are skipped as they come.
#[derive(Default)]
pub(super) struct Fades {
due: BinaryHeap<Reverse<(Instant, SectionKey)>>,
pub fade_ms: Option<u64>,
}
impl Fades {
/// The section at `key` is looked at again at `end`.
pub fn push(&mut self, end: Instant, key: SectionKey) {
self.due.push(Reverse((end, key)));
}
/// The section of the earliest end at or before `now`, taken out.
pub fn pop_due(&mut self, now: Instant) -> Option<SectionKey> {
let &Reverse((end, key)) = self.due.peek()?;
if end > now {
return None;
}
self.due.pop();
Some(key)
}
/// Every end goes; `fade_ms` stays.
pub fn clear(&mut self) {
self.due.clear();
}
/// The sections an end is waited for.
#[cfg(debug_assertions)]
pub fn pending(&self) -> impl Iterator<Item = SectionKey> {
self.due.iter().map(|r| r.0.1)
}
}
/// The pass, its buffers and the records it draws from: made when GPU culling
/// is switched on, dropped when it is switched off.
pub(super) struct GpuCulling {
@@ -574,6 +656,9 @@ pub(super) struct GpuCulling {
/// that the arena's compaction moves records while it borrows the
/// renderer's other fields.
pub records: Records,
/// When the flagged records' fades end, a field of its own as `records`
/// is: gfx.rs `mark_fades` takes both beside the renderer's sections.
pub fades: Fades,
/// This frame's pages with records: (page, first draw of its region, the
/// region's size).
pages: Vec<(u32, u32, u32)>,
@@ -700,6 +785,7 @@ impl GpuCulling {
reach_generation: None,
group: None,
records: Records::default(),
fades: Fades::default(),
pages: Vec::new(),
ids: 0,
n_updates: 0,
@@ -975,8 +1061,8 @@ mod tests {
}
/// Where a section's solid quads are: key, arena page, first vertex, quads,
/// and its box.
type Placed = (SectionKey, u32, u64, u32, u32);
/// its box, and whether it fades (`FADING`).
type Placed = (SectionKey, u32, u64, u32, u32, bool);
/// `gpu_culling.wgsl`'s `near`, on the CPU.
fn near(params: &Params, x: i32, z: i32) -> bool {
@@ -1025,7 +1111,7 @@ mod tests {
/// (and the pages `prepare` gives them).
fn keeps(params: &Params, words: &[u32], record: &Record) -> bool {
let (key, a) = (record.key, record.a);
if key[3] < 0 || a[1] == 0 || !near(params, key[0], key[2]) {
if key[3] < 0 || a[1] == 0 || (a[3] & FADING) != 0 || !near(params, key[0], key[2]) {
return false;
}
let c: [u32; 3] = std::array::from_fn(|i| key[i].wrapping_sub(params.first[i]) as u32);
@@ -1099,10 +1185,13 @@ mod tests {
Some([min, max])
}
/// Ids, page counts and changes against a plain list of what each id holds:
/// the lowest free id comes back, free ids at the end leave, counts follow
/// moves (to pages past the last too), a move keeps the box, a free is a
/// change, and more changes than updates take mark the whole table.
/// Ids, page counts, flags and changes against a plain list of what each id
/// holds: the lowest free id comes back, flagged only if inserted so, free
/// ids at the end leave, counts follow moves (to pages past the last too), a
/// move keeps the box and the flag, a free is a change, a flag set changes
/// its record only when it differs, the flagged ones are counted through
/// inserts, flags, removals and clears, and more changes than updates take
/// mark the whole table.
#[test]
fn records_keep_ids_page_counts_and_moves() {
let mut records = Records::default();
@@ -1140,7 +1229,7 @@ mod tests {
};
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
// What each id holds; `None`: a free id.
let mut model: Vec<Option<Placed>> = vec![Some(((2, 0, 0), 1, 0, 3, 11))];
let mut model: Vec<Option<Placed>> = vec![Some(((2, 0, 0), 1, 0, 3, 11, false))];
for step in 0..4_000 {
let live: Vec<u32> = (0..model.len() as u32)
.filter(|&id| model[id as usize].is_some())
@@ -1149,20 +1238,22 @@ mod tests {
rng.below(4 + step / 500),
u64::from(rng.below(1 << 20)) * 256,
);
match rng.below(5) {
let dirty = records.dirty.len();
match rng.below(6) {
op if op < 2 || live.is_empty() => {
let key = (rng.range(-9, 9), rng.range(0, 23), rng.range(-9, 9));
let (quads, boxed) = (1 + rng.below(500), rng.below(1 << 30));
let fading = rng.below(4) == 0;
let lowest = model
.iter()
.position(Option::is_none)
.unwrap_or(model.len());
let record = Record::new(key, page, start, quads, boxed);
let record = Record::new(key, page, start, quads, boxed).fading(fading);
assert_eq!(records.insert(record) as usize, lowest);
if lowest == model.len() {
model.push(None);
}
model[lowest] = Some((key, page, start, quads, boxed));
model[lowest] = Some((key, page, start, quads, boxed, fading));
}
op => {
let id = live[rng.below(live.len() as u32) as usize];
@@ -1170,6 +1261,12 @@ mod tests {
if op < 4 {
records.relocate(id, page, start);
(held.1, held.2) = (page, start);
} else if op == 4 {
let on = rng.below(2) == 0;
records.set_fading(id, on);
let changed = usize::from(held.5 != on);
assert_eq!(records.dirty.len(), dirty + changed, "step {step}");
held.5 = on;
} else {
records.remove(id);
// Marked: the GPU must have it free before the pass culls.
@@ -1183,11 +1280,13 @@ mod tests {
}
assert_eq!(records.ids() as usize, model.len(), "step {step}");
assert_eq!(records.live(), model.iter().flatten().count());
let flagged = model.iter().flatten().filter(|held| held.5).count();
assert_eq!(records.fading() as usize, flagged, "step {step}");
let mut pages = vec![0; records.page_sections().len()];
for (id, held) in model.iter().enumerate() {
let record = held.map(|(key, page, start, quads, boxed)| {
let record = held.map(|(key, page, start, quads, boxed, fading)| {
pages[page as usize] += 1;
Record::new(key, page, start, quads, boxed)
Record::new(key, page, start, quads, boxed).fading(fading)
});
assert_eq!(
records.at(id as u32),
@@ -1204,16 +1303,17 @@ mod tests {
let (mut each, mut all) = (Records::default(), Records::default());
for n in 0..1_000 {
let (key, page, start) = ((n % 31, n % 24, n / 31), n as u32 % 3, n as u64 * 256);
let record = Record::new(key, page, start, 1, 0);
let record = Record::new(key, page, start, 1, 0).fading(n % 7 == 0);
assert_eq!(each.insert(record), all.insert(record));
}
let gone: Vec<u32> = (0..1_000).filter(|id| id % 3 != 1 || *id >= 900).collect();
let counts = |r: &Records| (r.ids(), r.live(), r.fading());
for ids in [&[1u32, 4, 898][..], &gone[..]] {
for &id in ids {
each.remove(id);
}
all.remove_all(ids.iter().copied());
assert_eq!((all.ids(), all.live()), (each.ids(), each.live()));
assert_eq!(counts(&all), counts(&each));
assert_eq!(all.page_sections(), each.page_sections());
assert_eq!(free_ids(&all), free_ids(&each));
assert_eq!(all.dirty, each.dirty);
@@ -1228,8 +1328,9 @@ mod tests {
assert_eq!((all.ids(), all.live(), all.free.len()), (0, 0, 0));
assert!(all.page_sections().iter().all(|&n| n == 0));
assert!(records.fading() > 0);
records.clear();
assert_eq!((records.ids(), records.live()), (0, 0));
assert_eq!((records.ids(), records.live(), records.fading()), (0, 0, 0));
assert!(records.page_sections().is_empty() && records.dirty.is_empty());
for x in 0..DIRTY_LIMIT as i32 {
records.insert(Record::new((x, 0, 0), 0, 0, 1, 0));
@@ -1360,6 +1461,7 @@ mod tests {
("UNBOUNDED", u64::from(UNBOUNDED)),
("REACH", u64::from(REACH)),
("SKY", u64::from(SKY)),
("FADING", u64::from(FADING)),
] {
assert_eq!(constant(name), value, "{name}");
}
@@ -1784,7 +1886,9 @@ mod tests {
/// reached or not: the pass reads the copy it holds when a frame brings the
/// same graph (once through a bind group made anew), sends a new one after
/// a frame the CPU draws, and makes its copy anew for one too large for its
/// room.
/// room. A fifth of the sections fade, flagged anew before each case
/// (`FADING`, sent as updates or with the whole table): the pass leaves
/// them to the CPU.
#[test]
fn the_gpu_draws_what_the_integer_test_keeps() {
on_each_device(draws_match);
@@ -1792,11 +1896,14 @@ mod tests {
fn draws_match(device: &wgpu::Device, queue: &Queue) {
let mut rng = Rng(0x2545_F491_4F6C_DD1D);
// The flags have their own sequence: the cases are otherwise as before.
let mut flags = Rng(0x9E37_79B9_7F4A_7C15);
let (min_y, height) = (-64, 24);
let mut gpu = GpuCulling::new(device).expect("the pass");
let mut uploads = Uploads::new(device);
let mut held: BTreeMap<u32, Placed> = BTreeMap::new();
let (mut camera, mut compared, mut culled) = (DVec3::ZERO, 0, 0);
// Fading sections the frustum and the graph keep, left to the CPU.
let (mut camera, mut compared, mut culled, mut faded) = (DVec3::ZERO, 0, 0, 0);
let mut words = Vec::new();
// Cave culling's graph while a case has one (its generation, from x and
// z, radius, height and whether its search reached the sky) and its bits.
@@ -1897,7 +2004,8 @@ mod tests {
.records
.insert(Record::new(key, page, start, quads, boxed));
assert!(
held.insert(id, (key, page, start, quads, boxed)).is_none(),
held.insert(id, (key, page, start, quads, boxed, false))
.is_none(),
"id {id} twice"
);
};
@@ -1912,6 +2020,13 @@ mod tests {
place(&mut gpu, &mut held, &mut rng);
}
}
// A fifth of the sections fade: the flags that change go up as
// updates, or with the whole table (cases 4 and 18), and through
// case 7's frame the CPU draws.
for (&id, placed) in held.iter_mut() {
placed.5 = flags.below(5) == 0;
gpu.records.set_fading(id, placed.5);
}
let whole = gpu.records.all_dirty;
let mut encoder = device.create_command_encoder(&Default::default());
let input = FrameInputs {
@@ -1947,10 +2062,12 @@ mod tests {
let mut params = tables(&input, &mut words).expect("the planes fit");
params.pages = gpu.pass_pages;
let mut expected = Vec::new();
for &(key, page, start, quads, boxed) in held.values() {
for &(key, page, start, quads, boxed, fading) in held.values() {
let record = Record::new(key, page, start, quads, boxed);
let kept = keeps(&params, &words, &record);
let kept = keeps(&params, &words, &record.fading(fading));
let reached = reach.is_none_or(|r| r.contains(key));
// In view, but fading: the CPU's.
faded += usize::from(fading && reached && keeps(&params, &words, &record));
if kept && let Some(r) = reach {
graphed += 1;
unreached += usize::from(!reached);
@@ -1965,7 +2082,7 @@ mod tests {
let origin = resident::origin(key, min_y, camera).as_vec3();
let instance = super::super::section_instance(origin.to_array(), 0, 1.0);
expected.push((page, start as u32, quads * 6, instance.map(f32::to_bits)));
} else if in_view_distance((cx, cz), radius, key.0, key.2) {
} else if !fading && in_view_distance((cx, cz), radius, key.0, key.2) {
culled += 1;
}
}
@@ -2012,8 +2129,10 @@ mod tests {
// Not a comparison of empty lists, nor of a test that keeps everything.
// The graphs leave out a real share of what the frustum keeps, and their
// sky band keeps sections above a box in some cases and not in others.
// The flags leave out about a fifth of what they would keep.
assert!(compared > 2_000, "{compared} draws");
assert!(culled > 2_000, "{culled} within the distance left out");
assert!(faded * 10 > compared, "{faded} fading of {compared} drawn");
assert!(
unreached * 10 > graphed && unreached < graphed,
"{unreached} of {graphed} in view unreached"
@@ -9,7 +9,7 @@
// them as the GPU schedules, and where faces of two sections meet at the same
// depth the later draw keeps the pixel). Nothing here computes with floats: the
// origins are the CPU's bits, copied, and the frustum test adds integers the
// CPU computed.
// CPU computed. `cull` leaves sections fading in or out to the CPU (`FADING`).
struct Params {
// Ids to test, records `apply` writes, the arena pages the counts and
@@ -45,7 +45,7 @@ struct Section {
// free id).
key: vec4<i32>,
// The first vertex of its solid quads, its solid quads, its box
// (gpu_culling.rs `rounded_box`), then a word unused here.
// (gpu_culling.rs `rounded_box`), then its flags (`FADING`).
a: vec4<u32>,
}
@@ -91,6 +91,8 @@ const UNBOUNDED: u32 = 0x80000000u;
// reached the box's top layer (the sky band).
const REACH: u32 = 1u;
const SKY: u32 = 2u;
// In a section's `a.w`: it fades in or out, and the CPU draws it with its fade.
const FADING: u32 = 1u;
// The count of `block`'s draws in `page` (`cull`), then where they start (`scan`).
fn block_count(block: u32, page: u32) -> u32 {
@@ -186,7 +188,8 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
}
runs[id] = 0u;
let s = sections[id];
if s.key.w < 0 || s.a.y == 0u || !near(s.key.x, s.key.z) || !reached(s.key.xyz) {
if s.key.w < 0 || s.a.y == 0u || (s.a.w & FADING) != 0u || !near(s.key.x, s.key.z)
|| !reached(s.key.xyz) {
return;
}
let page = u32(s.key.w);
+24 -2
View File
@@ -42,18 +42,32 @@ impl Output {
}
}
/// `HORIZON_HDR_OUTPUT`'s names, in order of preference.
const NAMES: [(Output, &str); 2] = [(Output::Pq, "pq"), (Output::ScRgb, "scrgb")];
/// The HDR output `caps` can present: HDR10 where offered, else scRGB.
/// `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only that one.
pub fn output(caps: &wgpu::SurfaceCapabilities) -> Option<Output> {
pick(caps, std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref())
}
fn pick(caps: &wgpu::SurfaceCapabilities, only: Option<&str>) -> Option<Output> {
[(Output::Pq, "pq"), (Output::ScRgb, "scrgb")]
NAMES
.into_iter()
.filter(|(_, name)| only.is_none_or(|o| o == *name))
.map(|(output, _)| output)
.find(|o| caps.color_spaces(o.format()).contains(o.space()))
}
/// The HDR output an offscreen renderer (`--offscreen`) offers: it has no display to ask, so
/// only the one `HORIZON_HDR_OUTPUT` names, for HDR screenshots without an HDR display.
pub fn offscreen_output() -> Option<Output> {
named(std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref())
}
fn named(name: Option<&str>) -> Option<Output> {
NAMES
.into_iter()
.find(|(_, n)| name == Some(*n))
.map(|(output, _)| output)
}
struct Targets {
size: [u32; 2],
@@ -637,7 +651,7 @@ fn scene_gain(peak: f32, paper_white: f32) -> f32 {
#[cfg(test)]
mod tests {
use super::{Output, pick};
use super::{Output, named, pick};
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
#[test]
@@ -687,6 +701,14 @@ mod tests {
assert_eq!(pick(&scrgb, Some("pq")), None);
assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None);
}
#[test]
fn offscreen_offers_only_the_named_output() {
assert_eq!(named(Some("pq")), Some(Output::Pq));
assert_eq!(named(Some("scrgb")), Some(Output::ScRgb));
assert_eq!(named(Some("hdr10")), None);
assert_eq!(named(None), None);
}
}
#[cfg(test)]
@@ -59,6 +59,25 @@ fn chosen_cells(
chosen.unwrap_or(d.cells)
}
/// `[shadow]` with the option values `constants`: none while its `option` is off, else the
/// resolution and distance its `resolution_option` and `distance_option` pick.
fn chosen_shadow(
s: &horizon_shader_api::manifest::Shadow,
options: &[horizon_shader_api::manifest::PackOption],
constants: &[(String, f64)],
) -> Option<horizon_shader_api::manifest::Shadow> {
let number = |id: Option<&String>| {
let option = options.iter().find(|o| Some(&o.id) == id)?;
let (_, value) = constants.iter().find(|(k, _)| *k == option.id)?;
option.kind.number(*value)
};
option_on(constants, s.option.as_ref()).then(|| horizon_shader_api::manifest::Shadow {
resolution: number(s.resolution_option.as_ref()).map_or(s.resolution, |r| r as u32),
distance: number(s.distance_option.as_ref()).map_or(s.distance, |d| d as f32),
..s.clone()
})
}
impl NativePack {
/// The pack of the checked stages `report` with the option values `constants`, drawing
/// onto the world's targets as `terrain` describes; the sky reads the terrain's group 0.
@@ -74,8 +93,12 @@ impl NativePack {
) -> Result<(Self, Vec<String>), String> {
let (globals, format) = (terrain.globals, terrain.format);
let textures = super::native_post::pack_textures(device, queue, &report);
let settings = report
// `[shadow]`, unless its option is off, at the size its options pick.
let chosen = report
.shadow
.as_ref()
.and_then(|s| chosen_shadow(s, &report.manifest.options, constants));
let settings = chosen
.as_ref()
.map(|settings| super::native_shadow::fitted(device, settings));
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
@@ -103,8 +126,7 @@ impl NativePack {
report.shadow.is_some(),
)
});
let shadow = report
.shadow
let shadow = chosen
.as_ref()
.map(|s| {
let vertex = report.stages.iter().find(|s| s.kind == StageKind::Vertex);
@@ -240,3 +262,61 @@ impl NativePack {
|| self.post.is_some()
}
}
#[cfg(test)]
mod tests {
use super::chosen_shadow;
use horizon_shader_api::manifest::{OptionKind, PackOption, Shadow};
fn option(id: &str, kind: OptionKind) -> PackOption {
PackOption {
id: id.into(),
name: id.into(),
description: String::new(),
kind,
}
}
#[test]
fn the_shadow_map_follows_its_options() {
let options = [
option("shadows", OptionKind::Bool { default: true }),
option(
"quality",
OptionKind::Choice {
values: vec!["1024".into(), "4096".into()],
default: 0,
},
),
option(
"reach",
OptionKind::Int {
default: 96,
min: 32,
max: 512,
},
),
];
let shadow = Shadow {
option: Some("shadows".into()),
resolution_option: Some("quality".into()),
distance_option: Some("reach".into()),
..Shadow::default()
};
let values = |on: f64, quality: f64, reach: f64| {
[("shadows", on), ("quality", quality), ("reach", reach)].map(|(k, v)| (k.into(), v))
};
let size = |values: &[(String, f64)]| {
chosen_shadow(&shadow, &options, values).map(|s| (s.resolution, s.distance))
};
assert_eq!(size(&values(1.0, 1.0, 200.0)), Some((4096, 200.0)));
assert_eq!(size(&values(1.0, 0.0, 64.0)), Some((1024, 64.0)));
assert_eq!(size(&values(0.0, 1.0, 200.0)), None);
// Without the options' keys: the manifest's own numbers, always drawn.
assert_eq!(
chosen_shadow(&Shadow::default(), &options, &values(0.0, 1.0, 200.0))
.map(|s| (s.resolution, s.distance)),
Some((2048, 128.0))
);
}
}
@@ -76,14 +76,14 @@ pub struct WorldDrawn<'a> {
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player and
/// `(2 << 32) | id` for a dropped item (their entity ids), 0 for anything else. A run lasts
/// until the next. Unlike `spans`, whose ids name a type, these follow one entity from
/// frame to frame (API @API:world_drawn_motion@).
/// frame to frame (API 371).
pub motion: &'a [(u32, u64)],
/// The shared atlas entity UVs point into.
pub atlas: Option<&'a wgpu::Texture>,
/// Quad indices (0 1 2 0 2 3 for each 4 vertices), for as many quads as `entities` holds.
pub quad_indices: &'a wgpu::Buffer,
/// The block textures the terrain samples, for drawing blocks with the player's own
/// textures (`native_block_sprites` says where a block's are). API @API:native_block_sprites@.
/// textures (`native_block_sprites` says where a block's are). API 380.
pub block_sprites: BlockSpriteArrays<'a>,
}
@@ -154,7 +154,7 @@ pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
}
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
/// runs, with no hand too. API @API:hand_drawn@.
/// runs, with no hand too. API 364.
#[inline(never)]
pub fn native_hand_drawn(hand: &mut HandDrawn<'_>) {
generated::dispatch_hand_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), hand);
@@ -806,12 +806,7 @@ mod tests {
#[test]
fn what_the_camera_stands_on_is_inside_the_shadow_cameras_depth() {
let settings = Settings {
resolution: 2048,
distance: 128.0,
solid_materials: Vec::new(),
};
let shadow = camera(&settings);
let shadow = camera(&Settings::default());
let (view, projection) = super::super::pack_uniforms::shadow_camera(
glam::Vec3::new(0.3, 0.9, 0.0).normalize(),
glam::DVec3::new(-2064.5, 246.0, -4304.5),
@@ -827,11 +822,7 @@ mod tests {
#[test]
fn the_centre_stays_near_the_player_on_both_sides_of_zero() {
let shadow = camera(&Settings {
resolution: 2048,
distance: 128.0,
solid_materials: Vec::new(),
});
let shadow = camera(&Settings::default());
let light = glam::Vec3::Y;
for x in [5.5, -5.5, -2064.5, 2064.5] {
let camera = glam::DVec3::new(x, 64.0, x);
@@ -96,6 +96,17 @@ impl PackAtlas {
Some(atlas)
}
/// The table flat terrain reads (`hz_sprite_rects`): per layer, its sprite's top left corner
/// (x | y << 16) and side, in atlas pixels.
pub fn rects_table(&self) -> Vec<u32> {
(0..self.columns * self.texture.height() / self.sprite)
.flat_map(|layer| {
let (x, y) = (layer % self.columns, layer / self.columns);
[(x * self.sprite) | ((y * self.sprite) << 16), self.sprite]
})
.collect()
}
/// The atlas's size in pixels (`atlasSize`).
pub fn size(&self) -> [u32; 2] {
[self.texture.width(), self.texture.height()]
@@ -204,7 +215,8 @@ impl PackAtlas {
// vertices (`TerrainVertex`, six words each).
declarations.push_str(
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n",
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n\
layout(set = 3, binding = 2) readonly buffer HzSpriteRects { uvec2 hz_sprite_rects[]; };\n",
);
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
} else {
@@ -212,11 +224,29 @@ impl PackAtlas {
"hz_v_origin"
};
let [width, height] = self.size();
let scale = [
self.sprite as f32 / width as f32,
self.sprite as f32 / height as f32,
];
// A sprite's cell in its own sides, and the share of the atlas a side is: from the table
// in flat draws (`rects_table`), from the grid in others.
let columns = self.columns;
let (cell, side, scale) = if flat {
(
"uvec2 hz_rect = hz_sprite_rects[hz_layer];\n \
hz_cell = vec2(float(hz_rect.x & 65535u), float(hz_rect.x >> 16u)) / float(hz_rect.y);\n "
.to_owned(),
"float(hz_rect.y)",
[1.0 / width as f32, 1.0 / height as f32],
)
} else {
(
format!(
"hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n "
),
"1.0",
[
self.sprite as f32 / width as f32,
self.sprite as f32 / height as f32,
],
)
};
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1 unless separate_ao.
let color_a = if self.separate_ao {
@@ -227,8 +257,8 @@ impl PackAtlas {
let body = format!(
" vec4 hz_origin = {origin};\n \
uint hz_layer = hz_v_layer & 32767u;\n \
hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n \
hz_scale = vec2({:?}, {:?});\n \
{cell}\
hz_scale = vec2({:?}, {:?}) * {side};\n \
hz_Vertex = vec4(hz_v_pos + hz_origin.xyz, 1.0);\n \
hz_uv_scale = hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w));\n \
hz_mid = vec2(0.5);\n \
@@ -183,7 +183,7 @@ fn sample(
}],
vec![storage(0), storage(1)],
vec![],
vec![storage(0), storage(1)],
vec![storage(0), storage(1), storage(2)],
];
let layouts: Vec<_> = entries
.iter()
@@ -268,11 +268,15 @@ fn sample(
bytemuck::cast_slice(vertices),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let rects = buffer(
bytemuck::cast_slice(&atlas.rects_table()),
wgpu::BufferUsages::STORAGE,
);
let bindings = [
vec![(0, &uniform)],
vec![(0, &slots), (1, &origin)],
vec![],
vec![(0, &ids), (1, &data)],
vec![(0, &ids), (1, &data), (2, &rects)],
];
let groups: Vec<_> = bindings
.iter()
@@ -122,6 +122,7 @@ void main() {
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs);
assert!(post.terrain.is_some(), "{:?}", post.notes);
let world = World {
sprite_rects: &[],
atlas: Some((atlases[2].view.clone(), atlases[2].size())),
material_maps: Some([atlases[1].view.clone(), atlases[0].view.clone()]),
lightmap: atlases[2].view.clone(),
@@ -148,7 +149,8 @@ void main() {
post,
wgpu::TextureFormat::Rgba8Unorm,
world,
);
)
.expect("the pack runs");
let mut pass = post
.terrain
.take()
+202 -37
View File
@@ -18,7 +18,7 @@
use std::collections::HashMap;
use glam::{DMat4, DVec3};
use horizon_shaders::{Format, Group, Kind, Post, SHADOW_COLORS, Val};
use horizon_shaders::{Format, Group, Kind, Post, SHADOW_COLORS};
use super::pack_draw::DrawUniforms;
use super::pack_entities::{PROGRAMS, PackEntities};
@@ -29,6 +29,8 @@ use super::pack_uniforms::{self, FrameUniforms, PackInputs};
const BUFFERS: usize = horizon_shaders::BUFFERS;
mod packed;
#[cfg(test)]
mod world_target_tests;
@@ -439,6 +441,9 @@ struct Targets {
pub(super) struct World<'a> {
/// The block atlas and its size.
pub atlas: Option<(wgpu::TextureView, [u32; 2])>,
/// Of a stitched block atlas, each sprite layer's place (`PackAtlas::rects_table`); empty
/// for a grid.
pub sprite_rects: &'a [u32],
pub material_maps: Option<[wgpu::TextureView; 2]>,
pub lightmap: wgpu::TextureView,
/// Terrain's geometry, when the pack draws it.
@@ -565,9 +570,9 @@ pub struct PackPost {
down: HashMap<wgpu::TextureFormat, (wgpu::RenderPipeline, wgpu::BindGroupLayout)>,
/// The per-draw uniforms of gbuffers programs.
draws: DrawUniforms,
/// Set 3 of flat terrain (`mc_Entity`'s table) and its bind group for each arena page,
/// with the page buffer it was made for.
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer)>,
/// Set 3 of flat terrain (`mc_Entity`'s table, then the sprites' places) and its bind group
/// for each arena page, with the page buffer it was made for.
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer)>,
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
gui_size: [u32; 2],
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
@@ -1040,19 +1045,29 @@ pub(super) struct ShadowView {
}
/// Set 3 of flat terrain, read by the vertex stage: the sprite layers' table (`mc_Entity`'s id
/// and fluid, `at_midBlock.w`; -1, -1, 0 when empty) and, per arena page, the page's vertices
/// (a vertex reads its quad's corners for its normal).
fn block_ids(device: &wgpu::Device, table: &[i32]) -> (wgpu::BindGroupLayout, wgpu::Buffer) {
/// and fluid, `at_midBlock.w`; -1, -1, 0 when empty), per arena page, the page's vertices
/// (a vertex reads its quad's corners for its normal), and each sprite's place in a stitched
/// atlas (`rects`; zeros for a grid, which doesn't read them).
fn block_ids(
device: &wgpu::Device,
table: &[i32],
rects: &[u32],
) -> (wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer) {
let empty = [-1, -1, 0];
let table = if table.is_empty() { &empty[..] } else { table };
let buffer = wgpu::util::DeviceExt::create_buffer_init(
device,
&wgpu::util::BufferInitDescriptor {
label: Some("Shader pack block ids"),
contents: bytemuck::cast_slice(table),
usage: wgpu::BufferUsages::STORAGE,
},
);
let buffer = |label, contents: &[u8]| {
wgpu::util::DeviceExt::create_buffer_init(
device,
&wgpu::util::BufferInitDescriptor {
label: Some(label),
contents,
usage: wgpu::BufferUsages::STORAGE,
},
)
};
let rects = if rects.is_empty() { &[0, 0][..] } else { rects };
let ids = buffer("Shader pack block ids", bytemuck::cast_slice(table));
let rects = buffer("Shader pack sprite rects", bytemuck::cast_slice(rects));
let storage = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX,
@@ -1065,9 +1080,9 @@ fn block_ids(device: &wgpu::Device, table: &[i32]) -> (wgpu::BindGroupLayout, wg
};
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack terrain"),
entries: &[storage(0), storage(1)],
entries: &[storage(0), storage(1), storage(2)],
});
(layout, buffer)
(layout, ids, rects)
}
/// The game's geometry a gbuffers program draws: its vertex buffers and, for flat draws
@@ -1101,6 +1116,8 @@ struct Resources<'a> {
storable: u16,
/// The buffers of a three-channel format (`rgb`): they have no alpha to write, and read 1.
rgb: u16,
/// The bits per channel a buffer of a small packed format keeps ([`packed::bits`]).
packed: [Option<[u8; 4]>; BUFFERS],
/// The stage (`texture.<stage>`) of the programs built so far, and the buffers one of them
/// flipped: a `texture.<stage>.colortexN` no longer stands in for buffer N in the stage's
/// later programs, which read what was written, as in Iris. `final` continues
@@ -1185,9 +1202,18 @@ fn bindings(
let released = pack.released.get();
let custom = pack_textures::find(textures, group, &s.ty, &s.name).filter(|&i| {
let t = &textures[i];
t.stage != pack_textures::stage(group)
|| released.0 != t.stage
|| horizon_shaders::buffer_index(&t.name).is_none_or(|b| released.1 & 1 << b == 0)
// A PNG under `texture.<stage>.<name>` whose name is no sampler of the game's
// isn't bound: the sampler reads as if it had no texture. Iris 1.11.7 with a probe
// pack of ours (#9064) bound it over `gaux2` and `noisetex` but not over names of its
// own, where a raw texture was bound; this rule is a fit to those cases.
let unbound = t.png
&& t.stage != horizon_shaders::EVERY_STAGE
&& source(&s.name, false, true, false) == Source::Dummy;
!unbound
&& (t.stage != pack_textures::stage(group)
|| released.0 != t.stage
|| horizon_shaders::buffer_index(&t.name)
.is_none_or(|b| released.1 & 1 << b == 0))
});
let image = pack
.images
@@ -1497,6 +1523,8 @@ fn build_pass(
let final_pass = p.group == Group::Final;
let mut targets = Vec::new();
let mut writes = Vec::new();
// Outputs into a buffer of a small packed format, and the bits it keeps.
let mut packed_outputs = Vec::new();
for &location in &interface.outputs {
let l = location as usize;
if targets.len() <= l {
@@ -1528,6 +1556,11 @@ fn build_pass(
{
t.blend = Some(blend_state(b));
}
if !p.group.shadow()
&& let Some(bits) = pack.packed[buffer as usize]
{
packed_outputs.push((location, bits));
}
targets[l] = state;
writes.push((location, buffer as usize));
}
@@ -1587,7 +1620,9 @@ fn build_pass(
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(m)),
})
};
let (vs, fs) = (module(program.vertex), module(program.fragment));
let mut fragment = program.fragment;
packed::quantise_outputs(&mut fragment, &packed_outputs);
let (vs, fs) = (module(program.vertex), module(fragment));
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&p.name),
layout: Some(&pipeline_layout),
@@ -1833,8 +1868,16 @@ fn build_compute(
})
}
/// Why a pack can't run, and the notes of loading it until then (why programs were skipped).
#[derive(Debug)]
pub struct Refused {
pub reason: String,
pub notes: Vec<String>,
}
impl PackPost {
/// Builds the pipelines of `post` for a screen of `screen` format.
/// Builds the pipelines of `post` for a screen of `screen` format. An error when the pack
/// can't show anything on the screen: the game turns it off, as a pack that fails to load.
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
@@ -1842,7 +1885,7 @@ impl PackPost {
mut post: Post,
screen: wgpu::TextureFormat,
world: World<'_>,
) -> Self {
) -> Result<Self, Refused> {
// What the pack's shaders need of its storage buffers, before its parts move out.
let minimums = post.buffer_minimums();
let features = device.features();
@@ -1858,6 +1901,10 @@ impl PackPost {
let rgb = (0..BUFFERS)
.filter(|&i| post.config.buffers[i].format.channels == 3)
.fold(0u16, |m, i| m | 1 << i);
// A small packed format's buffer reads back fewer bits, as in Iris: its clears and
// what its passes write keep only those.
let packed: [Option<[u8; 4]>; BUFFERS] =
std::array::from_fn(|i| packed::bits(post.config.buffers[i].format));
let clears = std::array::from_fn(|i| {
let b = &post.config.buffers[i];
b.clear.then(|| {
@@ -1865,7 +1912,7 @@ impl PackPost {
if rgb & 1 << i != 0 {
c[3] = 1.0;
}
c
packed[i].map_or(c, |bits| packed::quantise(c, bits))
})
});
let mut notes = post.notes;
@@ -1882,7 +1929,7 @@ impl PackPost {
let mut custom = post.custom;
let custom_names: Vec<String> = custom.uniforms().map(|u| u.0.to_owned()).collect();
// A first run finds the names neither the frame nor the pack defines.
custom.eval(0.0, &|n| pack_uniforms::index(n).map(|_| Val::scalar(0.0)));
custom.eval(0.0, &pack_uniforms::zero);
// Its zeros are no start for `smooth`: the first frame's values are, as in Iris.
custom.restart_smoothing();
if !custom.notes.is_empty() {
@@ -1970,6 +2017,7 @@ impl PackPost {
gpu,
storable,
rgb,
packed,
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: if features.contains(wgpu::Features::FLOAT32_FILTERABLE) {
@@ -2065,7 +2113,7 @@ impl PackPost {
.terrain
.as_ref()
.filter(|g| g.table.is_some())
.map(|_| block_ids(device, world.block_ids));
.map(|_| block_ids(device, world.block_ids, world.sprite_rects));
let terrain_geometry = world.terrain.as_ref().map(|g| Geometry {
buffers: g.buffers,
table: g.table,
@@ -2260,6 +2308,22 @@ impl PackPost {
.filter(|p| !skip.split(',').any(|s| s == p.name))
.filter_map(|p| build(p, None))
.collect();
// The screen gets colortex0 through `final`, or else through Horizon's copy, which reads
// floats: an integer colortex0 (`RGBA32UI`...) with no `final` left to draw can't reach
// it (#9165).
let shows = passes
.iter()
.any(|p| p.group == Group::Final && matches!(p.pipeline, Pipeline::Render(_)));
if !shows && !holds_floats(formats[0]) {
return Err(Refused {
reason: format!(
"colortex0 has an integer format ({:?}) and no `final` program draws it to \
the screen",
formats[0]
),
notes,
});
}
let shadow_comp: Vec<Pass> = post
.shadow_comp
.into_iter()
@@ -2556,7 +2620,7 @@ impl PackPost {
let white = [255u8; 4];
let black = [0u8; 4];
let flat_normal = [128u8, 128, 255, 255];
Self {
Ok(Self {
notes,
setup_pending: true,
passes,
@@ -2719,7 +2783,7 @@ impl PackPost {
u
},
shadow_cache: Default::default(),
}
})
}
/// Writes `pass`'s uniforms for a target of `out` pixels; its bind group for the buffers'
@@ -4214,7 +4278,7 @@ impl PackPost {
terrain: &mut TerrainDraw<'_>,
) {
self.resize(device, size);
if let Some((layout, ids)) = &self.block_ids {
if let Some((layout, ids, rects)) = &self.block_ids {
// A page that grew has a new buffer.
self.page_groups
.retain(|p, (b, _)| pages.iter().any(|(q, c)| q == p && c == b));
@@ -4232,6 +4296,10 @@ impl PackPost {
binding: 1,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: rects.as_entire_binding(),
},
],
});
(buffer.clone(), group)
@@ -4590,14 +4658,8 @@ impl PackPost {
.as_ref()
.map(|t| &t[main(m, i) ^ usize::from(alt)])
};
// An integer colortex0 has no colour to show: the screen is only cleared.
if !shown && !holds_floats(self.formats[0]) {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack colortex0"),
color_attachments: &[attach(screen, overwrite)],
..Default::default()
});
} else if !shown {
// Without `final`, colortex0 holds floats (`new` refuses an integer one).
if !shown {
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack colortex0"),
layout: &self.copy.1,
@@ -4745,6 +4807,7 @@ mod tests {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
blur: false,
clamp: false,
png: false,
image: pack_textures::Image::Atlas,
}];
let interface = horizon_shaders::Interface {
@@ -4777,6 +4840,7 @@ mod tests {
gpu: Gpu::of(&adapter, device.features()),
storable: 0,
rgb: 0,
packed: [None; BUFFERS],
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
@@ -4805,6 +4869,102 @@ mod tests {
assert!(reads(Group::Final) == Source::Color(0));
}
/// Iris 1.11.7 (a probe pack of ours, #9064) didn't bind a PNG under
/// `texture.<stage>.<name>` when `name` was a name of the pack's own, no sampler of the
/// game's; a raw texture there, a PNG over `gaux2` or `noisetex`, and a `customTexture` PNG
/// were bound.
#[test]
fn a_png_under_a_name_of_its_own_is_not_bound() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let texture = |stage: &str, name: &str, png: bool| pack_textures::PackTexture {
stage: stage.into(),
name: name.into(),
dimension: wgpu::TextureViewDimension::D2,
sample_type: wgpu::TextureSampleType::Float { filterable: true },
blur: !png,
clamp: !png,
png,
image: pack_textures::Image::Atlas,
};
let textures = [
texture("composite", "tp", true),
texture("composite", "tr", false),
texture("composite", "gaux2", true),
texture("composite", "noisetex", true),
texture(horizon_shaders::EVERY_STAGE, "cp", true),
];
let names = ["tp", "tr", "gaux2", "noisetex", "cp"];
let interface = horizon_shaders::Interface {
uniform_size: 48,
uniforms: Vec::new(),
samplers: names
.iter()
.zip(1..)
.map(|(name, k)| horizon_shaders::Sampler {
name: (*name).into(),
ty: "sampler2D".into(),
binding: 2 * k - 1,
})
.collect(),
outputs: vec![0],
draw: false,
images: Vec::new(),
buffers: Vec::new(),
initial_values: vec![0; 48],
};
let buffers = pack_images::Buffers::new(
&device,
Default::default(),
Default::default(),
wgpu::ShaderStages::FRAGMENT,
);
let stand_ins = std::cell::RefCell::new(Vec::new());
let pack = Resources {
textures: &textures,
images: &[],
left_out: &[],
buffers: &buffers,
indices: None,
gpu: Gpu::of(&adapter, device.features()),
storable: 0,
rgb: 0,
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
packed: [None; BUFFERS],
};
let (_, samplers, ..) = bindings(
device.features(),
&interface,
"final",
Group::Final,
&[0],
None,
&[T::Rgba8Unorm; BUFFERS],
None,
&mut Vec::new(),
&pack,
)
.expect("the pass is kept");
let read: Vec<Source> = samplers.iter().map(|b| b.source).collect();
assert!(
read == [
Source::Dummy,
Source::Atlas,
Source::Atlas,
Source::Atlas,
Source::Atlas
]
);
}
/// Iris 1.11.7 runs a program whose image no `image.*` gives, and its loads read zero
/// (observed with a probe pack). Its pass is kept, the image bound to a stand-in of its
/// declared format, zero again before each pass even after one stored into it.
@@ -4857,6 +5017,7 @@ mod tests {
gpu: Gpu::of(&adapter, device.features()),
storable: 0,
rgb: 0,
packed: [None; BUFFERS],
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
@@ -4998,6 +5159,7 @@ mod tests {
gpu: Gpu::of(&adapter, device.features()),
storable: 0,
rgb: 0,
packed: [None; BUFFERS],
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
@@ -5170,6 +5332,9 @@ mod material_tests;
#[cfg(test)]
mod rgb_alpha_tests;
#[cfg(test)]
mod packed_tests;
#[cfg(test)]
mod trinary_tests;
@@ -83,7 +83,8 @@ impl Gpu {
post,
T::Rgba8Unorm,
world,
);
)
.expect("the pack runs");
assert!(
built.passes.iter().any(|p| p.group == Group::Final),
"{:?}",
@@ -334,6 +335,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
cull: false,
}),
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
@@ -707,6 +709,7 @@ fn shadow_depth_filtering(nearest: &str) -> Option<Vec<[u8; 4]>> {
let mut post = gpu.load(
files,
World {
sprite_rects: &[],
atlas: None,
material_maps: None,
lightmap: color.clone(),
@@ -199,7 +199,7 @@ impl Fixture {
}
pub(super) fn build(&self, post: Post) -> PackPost {
let post = self.build_with_terrain(post, None);
let post = self.try_build(post).expect("the pack runs");
assert!(
post.notes.iter().all(|n| n.starts_with("shadow map ")),
"{:?}",
@@ -208,8 +208,22 @@ impl Fixture {
post
}
/// [`Self::build`] for a pack that may be refused.
pub(super) fn try_build(&self, post: Post) -> Result<PackPost, Refused> {
self.try_build_with_terrain(post, None)
}
/// `build`, with the game's terrain geometry handed to the pack.
pub(super) fn build_with_terrain(&self, post: Post, terrain: Option<Geometry<'_>>) -> PackPost {
self.try_build_with_terrain(post, terrain)
.expect("the pack runs")
}
fn try_build_with_terrain(
&self,
post: Post,
terrain: Option<Geometry<'_>>,
) -> Result<PackPost, Refused> {
let lightmap = self.texture([1, 1], T::Rgba8Unorm);
PackPost::new(
&self.device,
@@ -218,11 +232,12 @@ impl Fixture {
post,
T::Rgba8Unorm,
World {
material_maps: None,
atlas: None,
material_maps: None,
lightmap,
terrain,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: [16, 16],
clouds: None,
@@ -1,16 +1,24 @@
//! A pack may give colortex0 an integer format (OptiFine's `shaders.txt`, "Texture Formats",
//! lists `RGBA32UI`, as #8459 quotes it): the pack still loads, and reads back what it wrote
//! there. #8459's probe; #8459 reports Iris 1.11.7 drawing (0, 200, 0) at every point
//! sampled from it.
//! sampled from it. Without a `final` that draws it, the pack is refused (#9165).
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
/// A `final` that shows the integer colortex0.
const FINAL: &str = "#version 330 compatibility\nuniform usampler2D colortex0;\n\
void main() { gl_FragColor = vec4(texelFetch(colortex0, ivec2(gl_FragCoord.xy), 0)) / 255.0; }\n";
/// A `final` that doesn't compile (it calls a function nobody declares), so it is skipped.
const BROKEN_FINAL: &str =
"#version 330 compatibility\nvoid main() { gl_FragColor = undeclared_function(); }\n";
/// `depth`: the composite reads depthtex0, so the blit copies the world's depth; `last`: the
/// pack has a `final` that shows colortex0 (without, Horizon shows colortex0 itself).
fn pack(depth: bool, last: bool) -> Post {
/// pack's `final`, if it has one.
fn pack(depth: bool, last: Option<&str>) -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
@@ -34,13 +42,9 @@ fn pack(depth: bool, last: bool) -> Post {
uniform sampler2D depthtex0;\nlayout(location = 0) out uvec4 o;\nvoid main() {{ o = {value}; }}\n"
),
);
if last {
if let Some(source) = last {
add("final.vsh", VERTEX);
add(
"final.fsh",
"#version 330 compatibility\nuniform usampler2D colortex0;\n\
void main() { gl_FragColor = vec4(texelFetch(colortex0, ivec2(gl_FragCoord.xy), 0)) / 255.0; }\n",
);
add("final.fsh", source);
}
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-integer-colortex0",
@@ -49,21 +53,14 @@ fn pack(depth: bool, last: bool) -> Post {
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert_eq!(
post.programs.len(),
1 + usize::from(last),
"{:?}",
post.notes
);
post
horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default())
}
#[test]
fn an_integer_colortex0_keeps_what_the_pack_wrote() {
let Some(gpu) = Fixture::new() else { return };
for depth in [false, true] {
let mut post = gpu.build(pack(depth, true));
let mut post = gpu.build(pack(depth, Some(FINAL)));
// Twice: the second frame's world blit runs over a colortex0 the first frame wrote.
for frame in 0..2 {
let pixels = gpu.render(&mut post, [8, 8]);
@@ -73,13 +70,20 @@ fn an_integer_colortex0_keeps_what_the_pack_wrote() {
}
}
/// Without a `final`, there are no colours to show from an integer colortex0; what Iris shows
/// then was not measured. The frame must still run.
/// Without a `final` left to draw it (none, or one that is skipped), an integer colortex0 has
/// no colours the screen can show: the pack is refused, so the game turns it off and draws
/// without it instead of a black screen (#9165). The refusal keeps why `final` was skipped.
#[test]
fn an_integer_colortex0_without_final_still_runs() {
fn an_integer_colortex0_without_a_working_final_is_refused() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack(false, false));
for _ in 0..2 {
gpu.render(&mut post, [8, 8]);
for last in [None, Some(BROKEN_FINAL)] {
let Err(refused) = gpu.try_build(pack(false, last)) else {
panic!("{last:?}: a pack that can't show colortex0 runs");
};
assert_eq!(
refused.notes.iter().any(|n| n.starts_with("final")),
last.is_some(),
"{refused:?}"
);
}
}
@@ -0,0 +1,205 @@
//! The small packed colortex formats `RGB5_A1`, `RGB565` and `R3_G3_B2`, read back as in Iris
//! 1.11.7. wgpu has no such formats, so these buffers are 8-bit (`texture_format`); what a pass
//! writes into one and its clear colour go through [`quantise`] first.
//!
//! Probe pack p8106c of our own wrote 600 values to each format and read them back with
//! `texelFetch` in Iris 1.11.7 (NVIDIA 615.78.08). Our probe's Iris values fit this, with no
//! exception: each channel becomes the byte an `RGBA8` buffer stores for it, keeps that byte's
//! top bits (5, 5, 5 and 1 for `RGB5_A1`; 5, 6 and 5 for `RGB565` and also for `R3_G3_B2`), and
//! reads back those bits repeated to fill 8 (`0.3337` → byte 85 → 10 of 31 → 82 / 255). This is
//! a fit to one driver's values, not a rule Iris states. `RGBA4` and `RGBA2` read back the `RGBA8`
//! bytes in the same probe, as Horizon's 8-bit buffers already do.
use horizon_shaders::{Format, Kind};
use naga::{BinaryOperator, Expression, Literal, MathFunction, Statement, VectorSize};
/// The bits each channel keeps in a buffer of format `f`, for the formats that keep fewer than
/// 8; a three-channel format's alpha keeps 8 (it reads 1 anyway).
pub(super) fn bits(f: Format) -> Option<[u8; 4]> {
match (f.kind, f.channels, f.bits) {
(Kind::Unorm, 4, 5) => Some([5, 5, 5, 1]),
// `RGB565`, and `R3_G3_B2` (3 × 3 bits in `config.rs`), which our probe's Iris values
// put in 5, 6 and 5 bits too.
(Kind::Unorm, 3, 6 | 3) => Some([5, 6, 5, 8]),
_ => None,
}
}
/// The value a buffer keeping `bits` per channel reads back after `v` is written or cleared.
pub(super) fn quantise(v: [f32; 4], bits: [u8; 4]) -> [f32; 4] {
std::array::from_fn(|i| {
let byte = (v[i].clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u32;
let n = u32::from(bits[i]);
let kept = byte >> (8 - n);
// The kept bits, repeated until there are 8.
let mut wide = 0;
let mut have = 0;
while have < 8 {
wide = wide << n | kept;
have += n;
}
(wide >> (have - 8)) as f32 / 255.0
})
}
/// The factors of the shader's [`quantise`] for a channel keeping `n` bits: the kept value is
/// `floor(byte / step)`, and it reads back `kept × step + floor(kept × repeat)`.
fn factors(n: u8) -> (f32, f32) {
let step = f32::from(1u16 << (8 - n));
// `kept × repeat` is the kept bits shifted down to fill what `kept × step` leaves: one bit
// fills the other 7 (× 127); 5 or 6 bits fill 3 or 2 with their top bits.
let repeat = match n {
1 => 127.0,
_ => 2f32.powi(8 - 2 * i32::from(n)),
};
(step, repeat)
}
/// Makes the fragment entry point of `module` write each output location of `outputs` as
/// [`quantise`] gives it for its bits. The translator's entry point ends in one `return` of its
/// outputs; a module without one is left as it is.
pub(super) fn quantise_outputs(module: &mut naga::Module, outputs: &[(u32, [u8; 4])]) {
let Some(ep) =
(module.entry_points.iter_mut()).find(|e| e.stage == naga::ShaderStage::Fragment)
else {
return;
};
let Some(result) = &ep.function.result else {
return;
};
let naga::TypeInner::Struct { members, .. } = &module.types[result.ty].inner else {
return;
};
let vec4 = |ty: naga::Handle<naga::Type>| {
matches!(
module.types[ty].inner,
naga::TypeInner::Vector {
size: VectorSize::Quad,
scalar: naga::Scalar::F32
}
)
};
// (member, bits, the member's type) of each output to change.
let changed: Vec<(usize, [u8; 4], naga::Handle<naga::Type>)> = (members.iter().enumerate())
.filter_map(|(i, m)| {
let Some(naga::Binding::Location { location, .. }) = m.binding else {
return None;
};
let bits = outputs.iter().find(|o| o.0 == location)?.1;
vec4(m.ty).then_some((i, bits, m.ty))
})
.collect();
if changed.is_empty() {
return;
}
let struct_ty = result.ty;
let f = &mut ep.function;
let body = std::mem::take(&mut f.body);
let mut out = naga::Block::with_capacity(body.len() + 1);
for (statement, span) in body.span_into_iter() {
let Statement::Return { value: Some(value) } = statement else {
out.push(statement, span);
continue;
};
let Expression::Compose { components, .. } = &f.expressions[value] else {
out.push(statement, span);
continue;
};
let mut components = components.clone();
let e = &mut f.expressions;
let lit = |e: &mut naga::Arena<Expression>, v: f32| {
e.append(Expression::Literal(Literal::F32(v)), span)
};
// Literals need no emitting: they come before the range that is emitted.
let scalars: Vec<_> = (changed.iter())
.map(|&(_, bits, _)| {
let per = bits.map(factors);
(
per.map(|(step, _)| lit(e, step)),
per.map(|(step, _)| lit(e, 1.0 / step)),
per.map(|(_, repeat)| lit(e, repeat)),
)
})
.collect();
let (zero, one, max, half) = (lit(e, 0.0), lit(e, 1.0), lit(e, 255.0), lit(e, 0.5));
let mut add = |x: Expression| e.append(x, span);
let splat = |add: &mut dyn FnMut(Expression) -> _, value| {
add(Expression::Splat {
size: VectorSize::Quad,
value,
})
};
let (zero, one, max, half) = (
splat(&mut add, zero),
splat(&mut add, one),
splat(&mut add, max),
splat(&mut add, half),
);
let binary = |add: &mut dyn FnMut(Expression) -> _, op, left, right| {
add(Expression::Binary { op, left, right })
};
let math = |add: &mut dyn FnMut(Expression) -> _, fun, arg, arg1, arg2| {
add(Expression::Math {
fun,
arg,
arg1,
arg2,
arg3: None,
})
};
for (&(member, _, ty), (steps, inverse, repeats)) in changed.iter().zip(scalars) {
let mut vector = |components: [_; 4]| {
add(Expression::Compose {
ty,
components: components.to_vec(),
})
};
let (step, inverse, repeat) = (vector(steps), vector(inverse), vector(repeats));
let x = components[member];
let clamped = math(&mut add, MathFunction::Clamp, x, Some(zero), Some(one));
let scaled = binary(&mut add, BinaryOperator::Multiply, clamped, max);
let rounded = binary(&mut add, BinaryOperator::Add, scaled, half);
let byte = math(&mut add, MathFunction::Floor, rounded, None, None);
let shifted = binary(&mut add, BinaryOperator::Multiply, byte, inverse);
let kept = math(&mut add, MathFunction::Floor, shifted, None, None);
let high = binary(&mut add, BinaryOperator::Multiply, kept, step);
let low = binary(&mut add, BinaryOperator::Multiply, kept, repeat);
let low = math(&mut add, MathFunction::Floor, low, None, None);
let wide = binary(&mut add, BinaryOperator::Add, high, low);
components[member] = binary(&mut add, BinaryOperator::Divide, wide, max);
}
let value = add(Expression::Compose {
ty: struct_ty,
components,
});
// Everything from the first splat on is new and not a literal.
let range = naga::Range::new_from_bounds(zero, value);
out.push(Statement::Emit(range), span);
out.push(Statement::Return { value: Some(value) }, span);
}
f.body = out;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clear_colours_keep_the_same_bits() {
// The CPU side, for clear colours. Iris 1.11.7 read p8106d's clear colour back as below in
// RGB5_A1 and RGB565, and p8106c's 0.3337 as 82/255 in the 5-bit channels and 85/255 in
// the 6-bit one.
let to_255 = |v: [f32; 4]| v.map(|c| (c * 255.0 * 1000.0).round() / 1000.0);
let rgb5_a1 = bits(Format::parse("RGB5_A1").unwrap()).unwrap();
let rgb565 = bits(Format::parse("RGB565").unwrap()).unwrap();
let r3_g3_b2 = bits(Format::parse("R3_G3_B2").unwrap()).unwrap();
let clear = [0.3337, 0.1, 0.6, 0.3];
assert_eq!(to_255(quantise(clear, rgb5_a1)), [82.0, 24.0, 156.0, 0.0]);
assert_eq!(to_255(quantise(clear, rgb565))[..3], [82.0, 24.0, 156.0]);
assert_eq!(
to_255(quantise([0.3337; 4], r3_g3_b2))[..3],
[82.0, 85.0, 82.0]
);
assert!(bits(Format::parse("RGBA4").unwrap()).is_none());
}
}
@@ -0,0 +1,95 @@
//! `RGB5_A1`, `RGB565` and `R3_G3_B2` buffers read back as in Iris 1.11.7. Expected values are
//! those probe packs p8106c (written values) and p8106d (clear colours) read back in Iris (the PR
//! has them); the programs here are the probes', cut down. Written values are ones whose byte
//! (× 255) is not near a half: GPUs may round those either way.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add("composite.vsh", VERTEX);
add(
"composite.fsh",
"#version 120\n/* RENDERTARGETS: 1,2,3 */\n\
/*\nconst int colortex1Format = RGB5_A1;\nconst int colortex2Format = RGB565;\n\
const int colortex3Format = R3_G3_B2;\nconst int colortex4Format = RGB5_A1;\n\
const int colortex5Format = RGB565;\n*/\n\
const vec4 colortex4ClearColor = vec4(0.3337, 0.1, 0.6, 0.3);\n\
const vec4 colortex5ClearColor = vec4(0.3337, 0.1, 0.6, 0.3);\n\
void main() {\n\
gl_FragData[0] = vec4(175.0, 176.0, 766.5, 256.0) / 511.0;\n\
gl_FragData[1] = vec4(400.0, 175.0, 44.0, 0.0) / 511.0;\n\
gl_FragData[2] = vec4(-255.5, 176.0, 300.0, 0.0) / 511.0;\n}\n",
);
add("final.vsh", VERTEX);
// Pixel column x shows component x % 4 of buffer 1 + x / 4, as the bytes of its bits.
add(
"final.fsh",
"#version 330 compatibility\nuniform sampler2D colortex1;\nuniform sampler2D colortex2;\n\
uniform sampler2D colortex3;\nuniform sampler2D colortex4;\nuniform sampler2D colortex5;\n\
void main() {\n ivec2 p = ivec2(gl_FragCoord.xy);\n vec4 c = vec4(0.0);\n\
switch (p.x / 4) {\n\
case 0: c = texelFetch(colortex1, p, 0); break;\n\
case 1: c = texelFetch(colortex2, p, 0); break;\n\
case 2: c = texelFetch(colortex3, p, 0); break;\n\
case 3: c = texelFetch(colortex4, p, 0); break;\n\
case 4: c = texelFetch(colortex5, p, 0); break;\n\
}\n uint u = floatBitsToUint(c[p.x % 4]);\n\
gl_FragColor = vec4(uvec4(u >> 24u, u >> 16u, u >> 8u, u) & 255u) / 255.0;\n}\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-packed",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert_eq!(post.programs.len(), 2, "{:?}", post.notes);
post
}
#[test]
fn small_packed_buffers_keep_their_bits() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
// Twice: the second frame writes the buffers' other sides.
for frame in 0..2 {
let pixels = gpu.render(&mut post, [20, 1]);
let read: Vec<f32> = pixels
.iter()
.map(|p| f32::from_bits(u32::from_be_bytes(*p)))
.collect();
// Iris 1.11.7, in 255ths: RGB5_A1 written (175, 176, 766.5, 256) / 511; RGB565 written
// (400, 175, 44, 0) / 511; R3_G3_B2 written (-255.5, 176, 300, 0) / 511; RGB5_A1 and
// RGB565 cleared to (0.3337, 0.1, 0.6, 0.3). Three-channel formats read alpha 1.
let expected: [f32; 20] = [
82.0, 90.0, 255.0, 255.0, //
206.0, 85.0, 16.0, 255.0, //
0.0, 89.0, 148.0, 255.0, //
82.0, 24.0, 156.0, 0.0, //
82.0, 24.0, 156.0, 255.0,
];
for (n, (got, want)) in read.iter().zip(expected).enumerate() {
assert!(
(got - want / 255.0).abs() <= 1e-6,
"frame {frame}, buffer {} component {}: {}/255, expected {want}/255; all {:?}",
1 + n / 4,
n % 4,
got * 255.0,
read.iter().map(|v| v * 255.0).collect::<Vec<_>>()
);
}
}
}
@@ -219,13 +219,15 @@ fn pack(
lightmap: lightmap.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: [1, 1],
clouds: None,
weather: false,
hand: false,
},
);
)
.expect("the pack runs");
assert!(post.notes.is_empty(), "{:?}", post.notes);
post
}
@@ -17,6 +17,8 @@ pub(super) struct PackTexture {
pub(super) sample_type: wgpu::TextureSampleType,
pub(super) blur: bool,
pub(super) clamp: bool,
/// Decoded from a PNG, the pack's or the game's (not a raw dump, not a generated texture).
pub(super) png: bool,
pub(super) image: Image,
}
@@ -30,7 +32,7 @@ pub(super) fn upload(
let features = device.features();
let mut out = Vec::new();
for t in textures {
let (image, dimension, sample_type, blur, clamp) = match t.source {
let (image, dimension, sample_type, blur, clamp, png) = match t.source {
TextureSource::Image(texture) => {
let (view, sample_type) = match create(device, queue, &texture, features) {
Ok(created) => created,
@@ -50,6 +52,7 @@ pub(super) fn upload(
sample_type,
texture.blur,
texture.clamp,
!texture.raw,
)
}
TextureSource::Resource(location) => {
@@ -66,7 +69,14 @@ pub(super) fn upload(
continue;
};
let float = wgpu::TextureSampleType::Float { filterable: true };
(image, wgpu::TextureViewDimension::D2, float, true, true)
(
image,
wgpu::TextureViewDimension::D2,
float,
true,
true,
false,
)
}
};
out.push(PackTexture {
@@ -76,6 +86,7 @@ pub(super) fn upload(
sample_type,
blur,
clamp,
png,
image,
});
}
@@ -285,6 +296,7 @@ mod tests {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
blur: false,
clamp: false,
png: false,
image: Image::Lightmap,
}
}
@@ -297,6 +309,7 @@ mod tests {
texels,
blur: false,
clamp: false,
raw: true,
}
}
@@ -718,6 +718,13 @@ fn components(name: &str) -> usize {
}
}
/// What `name` gives custom uniforms before the first frame: zeros, at its size, so their
/// shapes check out as on a frame (#9086).
pub fn zero(name: &str) -> Option<horizon_shaders::Val> {
index(name)?;
Some(horizon_shaders::Val::vector(&[0.0; 16][..components(name)]))
}
/// The position of `name` in [`NAMES`].
pub fn index(name: &str) -> Option<usize> {
NAMES.iter().position(|n| *n == name)
@@ -752,6 +759,22 @@ mod tests {
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
}
/// The load-time run of custom uniforms sees each built-in at its size: Iris 1.11.7 reads 2
/// for the first line of our probes K and X (#9086) and 0 for the others.
#[test]
fn custom_uniforms_check_the_shapes_of_built_ins_before_the_first_frame() {
let properties = horizon_shaders::Properties::parse(
"uniform.vec3.a = skyColor * vec3(0.0, 0.0, 0.0) + vec3(2.0, 2.0, 2.0)\n\
uniform.float.b = skyColor\n\
uniform.float.c = gbufferModelView * 0.0 + 2.0\n",
&horizon_shaders::Macros::standard(),
);
let mut custom = horizon_shaders::Custom::parse(&properties);
custom.eval(0.0, &zero);
let broken: Vec<&str> = custom.notes.iter().map(|n| &n[1..2]).collect();
assert_eq!(broken, ["b", "c"], "{:?}", custom.notes);
}
/// Iris's status uniforms as its documentation defines them (shaders.properties, "Status").
#[test]
fn status_uniforms_follow_iris() {
@@ -1006,11 +1029,6 @@ mod initial_history_tests {
#[test]
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
fn core_matrix_inputs_render_provider_values() {
use horizon_shaders::{
GbuffersInputs, Macros, Pack, Target, preprocess, translate_gbuffers,
};
use std::io::Write;
use wgpu::util::DeviceExt;
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
eprintln!("Core matrix GPU: {:?}", adapter.get_info());
@@ -1070,137 +1088,7 @@ mod initial_history_tests {
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n\
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}}"
);
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (path, source) in [
("shaders/gbuffers_line.vsh", vertex.as_str()),
(
"shaders/gbuffers_line.fsh",
"#version 330\nin vec3 result; out vec4 color; void main() { color = vec4(result, 1.0); }",
),
] {
zip.start_file(path, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
}
let pack = Pack::from_zip_bytes("matrix-probe.zip", zip.finish().unwrap().into_inner())
.unwrap();
let macros = Macros::standard();
let program = translate_gbuffers(
&preprocess(&pack, "gbuffers_line.vsh", &macros),
&preprocess(&pack, "gbuffers_line.fsh", &macros),
Target::Buffers,
&GbuffersInputs::default(),
)
.unwrap();
let mut bytes = vec![0; program.interface.uniform_size as usize];
for uniform in &program.interface.uniforms {
if let Some(i) = index(&uniform.name) {
values.values[i].write(&uniform.ty, &mut bytes, uniform.offset as usize);
}
}
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: &bytes,
usage: wgpu::BufferUsages::UNIFORM,
});
let module = |module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
})
};
let vs = module(program.vertex);
let fs = module(program.fragment);
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
}],
});
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
texture.size(),
);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |result| result.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let mapped = read.slice(..).get_mapped_range().unwrap();
let mapped = draw_line_program(&device, &queue, &values, &vertex);
// Independent result: translated point (1, .5, -2), perspective (aspect 2,
// vertical FOV 90): clip (.5, .5, z, 2), hence (.25, .25, .5, 1).
for (actual, expected) in mapped[..4].iter().zip(expected) {
@@ -1213,6 +1101,203 @@ mod initial_history_tests {
}
}
/// `normalMatrix` in a core-profile gbuffers program is the view's rotation; in the
/// compatibility profile it stays zero (the issue's Iris 1.11.7 probes, #9136).
#[test]
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
fn core_normal_matrix_renders_the_view_rotation() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
eprintln!("Normal matrix GPU: {:?}", adapter.get_info());
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let hud = HudBatch::new([800, 400]);
let mut f = frame(&hud);
// Facing west (-X), level: view space looks down -Z with +X to the right (north).
f.yaw = 90.0;
f.pitch = 0.0;
let mut values = FrameUniforms::default();
values.update(&f, &PackInputs::default(), [800, 400]);
for (profile, world, expected) in [
// East (behind the camera) is view +Z.
("330 core", "vec3(1.0, 0.0, 0.0)", [128u8, 128, 255, 255]),
// South (to the left) is view -X; up stays up.
("330 core", "vec3(0.0, 0.0, 1.0)", [0, 128, 128, 255]),
("330 core", "vec3(0.0, 1.0, 0.0)", [128, 255, 128, 255]),
(
"330 compatibility",
"vec3(1.0, 0.0, 0.0)",
[128, 128, 128, 255],
),
] {
let vertex = format!(
"#version {profile}\nuniform mat3 normalMatrix;\nout vec3 result;\n\
void main() {{\n\
result = normalMatrix * {world} * 0.5 + 0.5;\n\
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n\
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}}"
);
let pixel = draw_line_program(&device, &queue, &values, &vertex);
eprintln!("{profile} {world}: {pixel:?}");
for (actual, expected) in pixel.iter().zip(expected) {
assert!(
actual.abs_diff(expected) <= 1,
"{profile} {world}: {pixel:?}"
);
}
}
}
/// Draws one fragment through a translated `gbuffers_line` program with `vertex` and the
/// provider's `values`, and reads it back.
fn draw_line_program(
device: &wgpu::Device,
queue: &wgpu::Queue,
values: &FrameUniforms,
vertex: &str,
) -> [u8; 4] {
use horizon_shaders::{
GbuffersInputs, Macros, Pack, Target, preprocess, translate_gbuffers,
};
use std::io::Write;
use wgpu::util::DeviceExt;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (path, source) in [
("shaders/gbuffers_line.vsh", vertex),
(
"shaders/gbuffers_line.fsh",
"#version 330\nin vec3 result; out vec4 color; void main() { color = vec4(result, 1.0); }",
),
] {
zip.start_file(path, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
}
let pack =
Pack::from_zip_bytes("matrix-probe.zip", zip.finish().unwrap().into_inner()).unwrap();
let macros = Macros::standard();
let program = translate_gbuffers(
&preprocess(&pack, "gbuffers_line.vsh", &macros),
&preprocess(&pack, "gbuffers_line.fsh", &macros),
Target::Buffers,
&GbuffersInputs::default(),
)
.unwrap();
let mut bytes = vec![0; program.interface.uniform_size as usize];
for uniform in &program.interface.uniforms {
if let Some(i) = index(&uniform.name) {
values.values[i].write(&uniform.ty, &mut bytes, uniform.offset as usize);
}
}
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: &bytes,
usage: wgpu::BufferUsages::UNIFORM,
});
let module = |module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
})
};
let vs = module(program.vertex);
let fs = module(program.fragment);
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
}],
});
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
texture.size(),
);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |result| result.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let mapped = read.slice(..).get_mapped_range().unwrap();
let pixel = mapped[..4].try_into().unwrap();
drop(mapped);
read.unmap();
pixel
}
fn int(u: &FrameUniforms, at: usize) -> i32 {
match u.values[at] {
Value::I(v) => v[0],
@@ -67,6 +67,17 @@ impl Readback {
let Some(texture) = self.texture.clone() else {
return;
};
self.draw_onto(device, encoder, &texture, surface);
}
/// Draws `frame` onto the swapchain image (or offscreen target) `surface`.
pub fn draw_onto(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
frame: &wgpu::Texture,
surface: &wgpu::Texture,
) {
// The frame's passes write the swapchain through its non-sRGB view
// (`view_formats`), so the copy goes there unconverted too.
let format = surface.format().remove_srgb_suffix();
@@ -74,7 +85,7 @@ impl Readback {
format: Some(format),
..Default::default()
});
self.blit(device, encoder, &texture, &target, format);
self.blit(device, encoder, frame, &target, format);
}
/// `frame` (a 16-bit frame) in an 8-bit texture of `format`, clamped to 0..1
@@ -92,7 +92,7 @@ impl<T> Resident<T> {
self.items.iter_mut().map(|(_, v)| v)
}
/// Every section's key, value and bounds.
#[cfg(debug_assertions)]
#[cfg(any(test, debug_assertions))]
pub fn iter(&self) -> impl Iterator<Item = (SectionKey, &T, Option<[Vec3; 2]>)> {
let items = self.items.iter().zip(&self.bounds);
items.map(|((key, value), bounds)| (*key, value, *bounds))
@@ -194,6 +194,12 @@ impl<T> Resident<T> {
column.low = column.low.min(key.1);
column.high = column.high.max(key.1);
}
/// Marks or unmarks the section at `key`, if it is resident.
pub fn set_marked(&mut self, key: SectionKey, marked: bool) {
if let Some(&slot) = self.slots.get(&key) {
self.mark_slot(slot, marked);
}
}
/// Marks or unmarks the section in `slot`, in its column's list too.
fn mark_slot(&mut self, slot: u32, marked: bool) {
let was = std::mem::replace(&mut self.marked[slot as usize], marked);
@@ -657,7 +663,8 @@ mod tests {
(!value.is_multiple_of(3)).then(|| [Vec3::splat((value % 5) as f32), Vec3::splat(16.0)])
};
// A deterministic mix of inserts, replacements and removals, marked at
// random: a replacement can mark or unmark its section.
// random: a replacement can mark or unmark its section, and so can a
// mark set alone (`set_marked`), which leaves a key not resident alone.
let mut seed = 0x2545_f491_u32;
for step in 0..4000u32 {
seed ^= seed << 13;
@@ -694,6 +701,12 @@ mod tests {
assert_eq!(removed, expected);
} else if (seed >> 12).is_multiple_of(3) {
assert_eq!(r.remove(key), model.remove(&key).map(|(value, _)| value));
} else if (seed >> 12).is_multiple_of(5) {
let marked = (seed >> 20).is_multiple_of(2);
r.set_marked(key, marked);
if let Some(held) = model.get_mut(&key) {
held.1 = marked;
}
} else {
let marked = (seed >> 20).is_multiple_of(2);
r.insert_marked(key, step, bounds(step), marked);
@@ -1050,7 +1050,9 @@ impl Item {
Item::Horizon(H::Hdr) => g.hdr,
Item::Horizon(H::BatteryMode) => g.battery,
Item::Horizon(H::ShadowCache) => g.shadow_cache,
Item::Horizon(H::TranslucentBatching) => g.translucent_batching,
Item::Horizon(H::TranslucentBatching) => {
g.translucent_batching && crate::graphics::translucent_batching_offered()
}
Item::Horizon(H::HdrSmoothGradients) => {
g.hdr_smooth_gradients && crate::graphics::smooth_gradients_offered()
}
@@ -3164,13 +3166,13 @@ mod tests {
assert_eq!(page, Some(Page::Advanced));
let mut s = Settings::default();
s.horizon.preset = Preset::Balanced;
assert_eq!(item.switch(&s), Some(true));
let mut ui = GraphicsUi::default();
// Where the device can't, the saved On reads Off, and neither a click nor Enter
// after it does anything: no sound, no change, no save.
crate::graphics::offer_translucent_batching(false);
assert!(item.blocked(&s).is_some());
assert_eq!(item.label(&s).plain(), "Translucent Batching: Off");
assert_eq!(item.switch(&s), Some(false));
let r = row(&mut ui, &s, item);
let point = (r[0] + 4.0, r[1] + 4.0);
assert!(press(&mut ui, &mut s, point).is_empty());
@@ -3181,6 +3183,7 @@ mod tests {
// Where it can, a click switches it and keeps the preset.
crate::graphics::offer_translucent_batching(true);
assert_eq!(item.label(&s).plain(), "Translucent Batching: On");
assert_eq!(item.switch(&s), Some(true));
assert_eq!(
press(&mut ui, &mut s, point),
[Action::ButtonClick, Action::Changed]
+42 -10
View File
@@ -796,10 +796,10 @@ impl HudAtlas {
let g = self.font.glyph(ch);
g.rect.map(|rect| (rect, g.advance))
}
/// A font glyph's atlas cell (none for blank glyphs such as the space) and advance.
pub fn glyph_cell(&self, ch: char) -> (Option<Rect>, f32) {
let g = self.font.glyph(ch);
(g.rect, g.advance)
/// A font glyph in the default font: its atlas cell (none for blank
/// glyphs such as the space), its box and advance.
pub fn glyph_cell(&self, ch: char) -> crate::font::Glyph {
self.font.glyph(ch)
}
pub fn build(root: &Path) -> Result<Self> {
Self::build_over(root, root)
@@ -887,13 +887,39 @@ impl HudAtlas {
));
}
/// Only the font's metrics, for a game that draws nothing (the
/// integrated server): text measures as with the full atlas, without
/// holding its textures. The pixels are dropped once the glyph widths are
/// read, since nothing uploads them.
/// integrated or dedicated server): text measures as with the full atlas,
/// without packing textures or rasterizing glyphs.
pub fn fonts(root: &Path) -> Result<Self> {
let mut atlas = Self::pack(root, root, &["font"], false, false)?;
atlas.pixels = Vec::new();
Ok(atlas)
let white = Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
};
Ok(Self {
revision: Self::next_revision(),
pixels: Vec::new(),
packed: None,
galactic_widths: [0; 128],
width: 1,
height: 1,
sprites: HashMap::new(),
object_skins: HashMap::new(),
translucent: HashSet::new(),
flags: HashMap::new(),
sprite_animations: Vec::new(),
font: crate::font::Font::metrics(root),
glint: (Vec::new(), 1, 1),
glint_armor: (Vec::new(), 1, 1),
text_shader: None,
lines_shader: None,
random_textures: Default::default(),
emissive: Arc::default(),
blinks: Default::default(),
white,
})
}
/// The boot screen's atlas, before any pack loads: the theme's run-time
/// glyphs (`horizon/ui-text`), a white sprite and the glyph rows over
@@ -1274,6 +1300,12 @@ impl HudAtlas {
edition.3 = pixels;
}
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
images.push((
crate::pad_cursor::SPRITE.into(),
crate::pad_cursor::SIZE,
crate::pad_cursor::SIZE,
crate::pad_cursor::pixels(),
));
if full {
Self::slots(&mut images, crate::trims::slot_size(root), mod_image);
// MissingTextureAtlasSprite.generateMissingImage: 16x16, magenta
+1
View File
@@ -74,6 +74,7 @@ pub mod overlay;
pub mod pack_browser;
pub mod pack_screen;
pub mod packs;
pub mod pad_cursor;
pub mod par;
pub mod particles;
pub mod poi_sections;
+280 -35
View File
@@ -77,6 +77,110 @@ const HELD_SAMPLE_STEP: std::time::Duration = std::time::Duration::from_millis(5
/// How long a tile whose sampling failed (`LodSampler::grid` gave no
/// columns: a server refused or lost it) waits before it is asked again.
const RETRY: std::time::Duration = std::time::Duration::from_secs(2);
/// The most a tile a zoomed view magnifies may split past the plan it would
/// have had ([`ZoomAim`]): cells three times smaller, so nine times the tiles
/// where it applies. A spyglass covers about a sixteenth of the circle, so at
/// the 4078 tiles and ~1500 quads a tile measured at `lodDistance` 256 this
/// asks for roughly 45% more far tiles while the zoom is held, and gives them
/// back when it is released. Four would be about 85%: raise it only with a
/// measurement, because the cost grows with the square.
const MAX_ZOOM_BOOST: i32 = 3;
/// Yaw is rounded to this many degrees before it reaches the plan, so turning
/// the mouse does not rebuild the quadtree every frame. The cone is widened by
/// as much, so rounding never swings it off a tile being looked at.
const ZOOM_YAW_STEP: f32 = 5.0;
/// Where a zoomed view asks for finer far tiles (the `lodZoomDetail` extra).
///
/// `Plan::visit`'s falloff keeps a tile's cells roughly the same size on
/// screen, which silently assumes the field of view never changes. A spyglass
/// (`SpyglassItem`, a tenth of it) or the zoom mod breaks that: the same tile
/// covers many times more pixels, so its cells look as coarse as a tile ten
/// times nearer would. This asks the tiles the zoom actually magnifies to
/// split further.
///
/// Only those: lifting the whole circle would ask for about a hundred times
/// the tiles, which at the quads measured above is gigabytes of vertices. The
/// cone is the narrowed view's, and `MAX_ZOOM_BOOST` caps how far it goes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ZoomAim {
/// The view direction in x/z, in thousandths of a unit vector.
dir: [i32; 2],
/// The cosine of the cone's half-angle, in thousandths.
cos_half: i32,
/// How many times further than the unzoomed plan a tile inside the cone
/// may split. Two or more; [`ZoomAim::new`] gives `None` for less.
boost: i32,
}
impl ZoomAim {
/// `fov_degrees` is the zoomed field of view and `magnification` how many
/// times the view is magnified (1 when it is not). `None` when the zoom is
/// too slight to be worth replanning for.
pub fn new(yaw: f32, fov_degrees: f32, magnification: f32) -> Option<Self> {
if !magnification.is_finite() || !fov_degrees.is_finite() || fov_degrees <= 0.0 {
return None;
}
let boost = (magnification.round() as i64).clamp(1, i64::from(MAX_ZOOM_BOOST)) as i32;
if boost < 2 {
return None;
}
// Half the field of view spans the screen's width; three quarters of
// it reaches the corners, and the rounding step is added on top.
let half = (fov_degrees * 0.75 + ZOOM_YAW_STEP).clamp(5.0, 90.0);
let radians = ((yaw / ZOOM_YAW_STEP).round() * ZOOM_YAW_STEP).to_radians();
// `view_vector`'s x and z: yaw 0 looks toward +z.
let thousandths = |v: f32| (v * 1000.0).round() as i32;
Some(Self {
dir: [thousandths(-radians.sin()), thousandths(radians.cos())],
cos_half: thousandths(half.to_radians().cos()),
boost,
})
}
/// Whether the cone reaches `key`, so the zoom magnifies it.
///
/// The tile is taken as the circle around its centre that covers it, and
/// that circle's own angular radius widens the cone for the test, so a
/// tile straddling the edge counts in rather than out. A tile whose circle
/// reaches the camera is always in: there is no direction to compare, and
/// it fills the view whichever way the player faces.
fn covers(&self, key: TileKey, camera: [i32; 2]) -> bool {
let [ox, _, oz] = key.origin();
let size = key.size();
// The camera stands in the tile: it is in view whichever way the
// player faces, and there is no direction to compare it with. Tested
// on the box rather than on the circle below, whose radius a camera
// on a corner matches exactly -- a float coin-toss.
// In i64: a level 22 tile is 67 million blocks wide, so the sum
// at the far edge of the world overflows an i32.
let (ox64, oz64, size64) = (i64::from(ox), i64::from(oz), i64::from(size));
if (ox64..ox64 + size64).contains(&i64::from(camera[0]))
&& (oz64..oz64 + size64).contains(&i64::from(camera[1]))
{
return true;
}
let half = f64::from(size) / 2.0;
let cx = f64::from(ox) + half - f64::from(camera[0]);
let cz = f64::from(oz) + half - f64::from(camera[1]);
let distance = (cx * cx + cz * cz).sqrt();
let radius = half * std::f64::consts::SQRT_2;
if distance <= radius {
return true;
}
let dir = [
f64::from(self.dir[0]) / 1000.0,
f64::from(self.dir[1]) / 1000.0,
];
let cos = (cx * dir[0] + cz * dir[1]) / distance;
let sin = (1.0 - cos * cos).max(0.0).sqrt();
// cos(angle - tile radius), expanded so no inverse trigonometry runs
// per tile: the quadtree asks this of every node it visits.
let radius_sin = (radius / distance).min(1.0);
let radius_cos = (1.0 - radius_sin * radius_sin).max(0.0).sqrt();
cos * radius_cos + sin * radius_sin >= f64::from(self.cos_half) / 1000.0
}
}
/// The coarsest tile level: `Plan` roots cover at most `MAX_CHUNKS` chunks
/// around (a level-22 tile is 67 million blocks wide: the whole world).
pub(crate) const MAX_LEVEL: u8 = 22;
@@ -598,6 +702,8 @@ struct Plan {
/// The squared distance within which planned tiles load before the
/// coarse backdrop (`wanted_tiles`).
first: i64,
/// Where a zoomed view asks for finer tiles, if it does ([`ZoomAim`]).
aim: Option<ZoomAim>,
}
/// Chunks past the render distance where only planned tiles are drawn (two
/// past the last near chunk, `streaming::in_view_distance`), and where they
@@ -607,7 +713,7 @@ const FIRST_CHUNKS: i32 = 4;
impl Plan {
#[cfg(test)]
fn new(camera: [i32; 2], detail: i32, distance: u32) -> Self {
Self::with_previous(camera, detail, distance, &Self::default(), 0, false)
Self::with_previous(camera, detail, distance, &Self::default(), 0, false, None)
}
/// `reduced`: Reduced far detail (`graphics::FarDetail`), each level from
/// half the distance.
@@ -618,6 +724,7 @@ impl Plan {
previous: &Self,
elevation: i32,
reduced: bool,
aim: Option<ZoomAim>,
) -> Self {
let range = distance.min(MAX_CHUNKS) as i32 * 16;
let mut level = 0;
@@ -630,6 +737,7 @@ impl Plan {
camera,
near: i64::from((detail + SEAM_CHUNKS) * 16).pow(2),
first: i64::from((detail + FIRST_CHUNKS) * 16).pow(2),
aim,
..Self::default()
};
for z in (camera[1] - range).div_euclid(size)..=(camera[1] + range).div_euclid(size) {
@@ -665,6 +773,14 @@ impl Plan {
// Beyond the old 1024-chunk limit the cells are as large as `reduced`'s:
// the whole world is 22 levels, not all of them worth full cells.
let world = key.distance(camera) >= WORLD_START;
// A zoomed view magnifies the tiles its cone covers, so those split
// further; every other tile keeps the plan it would have had
// ([`ZoomAim`]). The world-wide threshold is boosted too: the horizon is
// where a spyglass shows the coarsest cells of all.
let boost = match self.aim {
Some(aim) if aim.covers(key, camera) => i64::from(aim.boost),
_ => 1,
};
let threshold = i64::from(key.size())
* i64::from(if world {
WORLD_SPLIT
@@ -672,7 +788,8 @@ impl Plan {
split / 2
} else {
split
});
})
* boost;
// The 8 to 10 band above is wide already. The ring of full detail
// around the camera and the world-wide threshold have none: a tile
// split last plan stays split until it is 5% past them, or it
@@ -1059,7 +1176,7 @@ pub struct Lod {
context: Option<Arc<WorkerContext>>,
/// A finished mesh job may have changed the selection of its ancestors.
reselect: bool,
area: Option<([i32; 2], i32, u32, i32, bool)>,
area: Option<([i32; 2], i32, u32, i32, bool, Option<ZoomAim>)>,
plan: Plan,
/// Reduced far detail (`set_reduced_detail`).
reduced_detail: bool,
@@ -2220,29 +2337,35 @@ impl Lod {
self.shared.wake.notify_all();
}
}
/// `aim` asks the tiles a zoomed view magnifies for finer detail
/// ([`ZoomAim`]); `None` plans exactly as before.
pub fn update(
&mut self,
camera: [f64; 3],
detail: i32,
distance: u32,
covered: Option<FxHashSet<(i32, i32)>>,
aim: Option<ZoomAim>,
) -> Vec<(TileKey, SectionMesh)> {
let elevation =
(((camera[1] - f64::from(MIN_Y + crate::chunk::HEIGHT)).clamp(0., 16384.)) as i32 / 32)
* 32;
let camera = [camera[0].floor() as i32, camera[2].floor() as i32];
// Replan at chunk boundaries, not on every camera movement.
// `ZoomAim` rounds its yaw, so a zoom held still keeps one area and the
// quadtree is not rebuilt as the player nudges the mouse.
let area = (
[camera[0].div_euclid(16), camera[1].div_euclid(16)],
detail,
distance,
elevation,
self.reduced_detail,
aim,
);
if self.area != Some(area) {
crate::spans::time("lod_plan", || {
self.area = Some(area);
self.replan(camera, detail, distance, elevation);
self.replan(camera, detail, distance, elevation, aim);
});
}
let mut meshes = Vec::new();
@@ -2265,7 +2388,14 @@ impl Lod {
}
/// Rebuilds the quadtree and the selection it implies. Only runs when the
/// planned area changes (a chunk boundary), never per finished sample.
fn replan(&mut self, camera: [i32; 2], detail: i32, distance: u32, elevation: i32) {
fn replan(
&mut self,
camera: [i32; 2],
detail: i32,
distance: u32,
elevation: i32,
aim: Option<ZoomAim>,
) {
let plan = Plan::with_previous(
camera,
detail,
@@ -2273,6 +2403,7 @@ impl Lod {
&self.plan,
elevation,
self.reduced_detail,
aim,
);
// A tile that leaves the range before it was drawn stops being
// unknown to its neighbours (`Snapshot::planned_leaf`): they remesh
@@ -2962,6 +3093,104 @@ fn retiring_detail<'a>(
}
#[cfg(test)]
mod tests {
/// A spyglass or the zoom mod magnifies only what the narrowed view
/// covers, so `ZoomAim` asks for finer tiles there and nowhere else. Without
/// that cone the same request would reach the whole circle, which at the
/// measured quads a tile is gigabytes of vertices.
#[test]
fn a_zoomed_view_asks_for_finer_tiles_only_where_it_looks() {
// Yaw 0 looks toward +z (`view_vector`), ten times magnified.
let aim = ZoomAim::new(0.0, 7.0, 10.0).expect("a tenth of the field of view zooms");
// Capped, however far the zoom goes.
assert_eq!(aim.boost, MAX_ZOOM_BOOST);
let camera = [0, 0];
let tile = |x, z, level| TileKey { x, z, level };
// Straight ahead, far enough that the direction decides it.
assert!(aim.covers(tile(0, 8, 4), camera), "ahead");
// Behind, beside, and the opposite diagonal: the zoom does not
// magnify these, so they keep the plan they would have had.
assert!(!aim.covers(tile(0, -9, 4), camera), "behind");
assert!(!aim.covers(tile(8, 0, 4), camera), "beside");
assert!(!aim.covers(tile(-9, -9, 4), camera), "diagonally behind");
// A tile the camera stands in is in view whichever way it faces.
assert!(aim.covers(tile(0, 0, 4), camera), "underfoot");
}
/// A zoom too slight to change which level a tile wants is not worth
/// rebuilding the quadtree for, and an unzoomed view must plan exactly as
/// it did before this feature.
#[test]
fn a_slight_zoom_does_not_replan() {
assert_eq!(ZoomAim::new(0.0, 70.0, 1.0), None, "not zoomed");
assert_eq!(
ZoomAim::new(0.0, 60.0, 1.2),
None,
"a fifth is under one level"
);
assert!(ZoomAim::new(0.0, 35.0, 2.0).is_some(), "twice is worth it");
// Nothing finite comes of a broken field of view.
assert_eq!(ZoomAim::new(0.0, f32::NAN, 4.0), None);
assert_eq!(ZoomAim::new(0.0, 0.0, 4.0), None);
assert_eq!(ZoomAim::new(0.0, 70.0, f32::INFINITY), None);
}
/// The cone turns with the player, and its yaw is rounded so that a
/// mouse-twitch does not rebuild the quadtree: two aims within the
/// rounding step compare equal, and `Lod::update` replans on that.
#[test]
fn the_cone_turns_and_rounds_with_the_view() {
let camera = [0, 0];
let east = ZoomAim::new(-90.0, 7.0, 10.0).expect("zoomed");
let tile = |x, z| TileKey { x, z, level: 4 };
// Yaw -90 looks toward +x.
assert!(east.covers(tile(8, 0), camera), "ahead when facing +x");
assert!(!east.covers(tile(0, 8), camera), "+z is no longer ahead");
// Within the rounding step: the same aim, so no replan.
assert_eq!(ZoomAim::new(0.0, 7.0, 10.0), ZoomAim::new(1.0, 7.0, 10.0));
// Past it: a different aim, so the cone follows.
assert_ne!(ZoomAim::new(0.0, 7.0, 10.0), ZoomAim::new(20.0, 7.0, 10.0));
}
/// Inside the cone a tile splits further than it would unzoomed, and
/// outside it the plan is unchanged, tile for tile.
#[test]
fn the_plan_is_finer_ahead_and_untouched_behind() {
let camera = [0, 0];
let plain = Plan::with_previous(camera, 12, 256, &Plan::default(), 0, false, None);
let zoomed = Plan::with_previous(
camera,
12,
256,
&Plan::default(),
0,
false,
ZoomAim::new(0.0, 7.0, 10.0),
);
// Finer somewhere: the zoomed plan holds tiles the plain one split
// no further.
assert!(
zoomed.nodes.len() > plain.nodes.len(),
"zoomed {} tiles, plain {}",
zoomed.nodes.len(),
plain.nodes.len()
);
// Behind the camera the two agree. Tiles there are the same level in
// both, so every plain node well behind is still a node when zoomed.
let behind = |key: &&TileKey| {
let [_, _, oz] = key.origin();
oz + key.size() < -1024
};
let plain_behind: std::collections::BTreeSet<_> =
plain.nodes.iter().filter(behind).copied().collect();
let zoomed_behind: std::collections::BTreeSet<_> =
zoomed.nodes.iter().filter(behind).copied().collect();
assert!(!plain_behind.is_empty(), "nothing behind to compare");
assert_eq!(
plain_behind, zoomed_behind,
"the zoom changed the plan behind the player"
);
}
use super::*;
use crate::mesher::TerrainVertex;
#[test]
@@ -3033,7 +3262,8 @@ mod tests {
for camera in [[0, 0], [100, -40]] {
for distance in [64, 128, 512, 1024] {
let full = Plan::new(camera, 12, distance);
let reduced = Plan::with_previous(camera, 12, distance, &Plan::default(), 0, true);
let reduced =
Plan::with_previous(camera, 12, distance, &Plan::default(), 0, true, None);
// Never finer than Full anywhere, and as fine next to the
// full-detail area, whose edge the finest tiles match.
assert!(reduced.split.is_subset(&full.split));
@@ -3093,10 +3323,24 @@ mod tests {
([115, 135], 49_000_000),
([-30_000_000, 29_999_000], 90),
] {
let plan =
Plan::with_previous(camera, 12, MAX_CHUNKS, &Plan::default(), elevation, false);
let reduced =
Plan::with_previous(camera, 12, MAX_CHUNKS, &Plan::default(), elevation, true);
let plan = Plan::with_previous(
camera,
12,
MAX_CHUNKS,
&Plan::default(),
elevation,
false,
None,
);
let reduced = Plan::with_previous(
camera,
12,
MAX_CHUNKS,
&Plan::default(),
elevation,
true,
None,
);
let top = plan.nodes.iter().map(|k| k.level).max().unwrap();
assert_eq!(top, MAX_LEVEL, "{camera:?} y={elevation}: roots stop short");
assert!(
@@ -3123,7 +3367,7 @@ mod tests {
#[test]
fn aerial_distance_avoids_unseen_full_detail_outside_the_near_area() {
let ground = Plan::new([0, 0], 12, 1024);
let high = Plan::with_previous([0, 0], 12, 1024, &Plan::default(), 1024, false);
let high = Plan::with_previous([0, 0], 12, 1024, &Plan::default(), 1024, false, None);
assert!(high.nodes.len() < ground.nodes.len());
assert!(
high.nodes.contains(&TileKey {
@@ -3178,7 +3422,7 @@ mod tests {
// stand-in for a covered column, and only the roots are sampled.
let camera = [8, 8];
let before = Plan::new(camera, 64, 64);
let after = Plan::with_previous(camera, 8, 64, &before, 0, false);
let after = Plan::with_previous(camera, 8, 64, &before, 0, false, None);
let mut ready: FxHashMap<TileKey, ()> = before
.nodes
.iter()
@@ -3217,7 +3461,7 @@ mod tests {
#[test]
fn detail_survives_movement_across_a_split_threshold() {
let before = Plan::new([0, 0], 2, 256);
let after = Plan::with_previous([16, 0], 2, 256, &before, 0, false);
let after = Plan::with_previous([16, 0], 2, 256, &before, 0, false, None);
let mut retained = 0;
let range = i64::from(256 * 16);
for key in &before.split {
@@ -3242,12 +3486,12 @@ mod tests {
let before = Plan::new([0, 0], 40, 256);
assert!(before.split.contains(&tile));
// 670 blocks: past the ring, inside 5% of it.
let held = Plan::with_previous([-30, 0], 40, 256, &before, 0, false);
let held = Plan::with_previous([-30, 0], 40, 256, &before, 0, false, None);
assert!(held.split.contains(&tile));
// Not held without having been split: it was never in the ring.
assert!(!Plan::new([-30, 0], 40, 256).split.contains(&tile));
// 700 blocks: past the margin.
let gone = Plan::with_previous([-60, 0], 40, 256, &before, 0, false);
let gone = Plan::with_previous([-60, 0], 40, 256, &before, 0, false, None);
assert!(!gone.split.contains(&tile));
}
#[test]
@@ -3749,7 +3993,7 @@ mod tests {
// Exercise the actual worker/update seam: a parent stays displayed
// until all four child meshes can replace it in the same update.
let camera = [-256.0, 100.0, -256.0];
lod.area = Some(([-16, -16], 2, 64, 0, false));
lod.area = Some(([-16, -16], 2, 64, 0, false, None));
lod.plan.roots = vec![key];
lod.plan.nodes.insert(key);
// As a replan does (`update` only selects after a change).
@@ -3757,7 +4001,7 @@ mod tests {
let await_meshes = |lod: &mut Lod| {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
let meshes = lod.update(camera, 2, 64, None);
let meshes = lod.update(camera, 2, 64, None, None);
if !meshes.is_empty() {
return meshes;
}
@@ -3775,7 +4019,7 @@ mod tests {
lod.ready.insert(child, lod.ready[&key].clone());
}
lod.reselect = true;
assert!(lod.update(camera, 2, 64, None).is_empty());
assert!(lod.update(camera, 2, 64, None, None).is_empty());
assert!(
lod.active.contains(&key),
"parent retired before child meshes"
@@ -3804,7 +4048,7 @@ mod tests {
lod.reselect = true;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
lod.update(camera, 2, 64, None);
lod.update(camera, 2, 64, None, None);
if lod.outstanding_meshes == 0 && lod.active.is_empty() {
break;
}
@@ -3844,7 +4088,7 @@ mod tests {
.collect();
lod.detail_request_pending = true;
lod.wanted_stale = true;
lod.update(camera, 2, 64, Some(FxHashSet::default()));
lod.update(camera, 2, 64, Some(FxHashSet::default()), None);
let Some(Job::Sample(nearby)) = lod.shared.jobs.lock().unwrap().pop_front() else {
panic!("nearby detail was not scheduled");
};
@@ -3855,7 +4099,7 @@ mod tests {
"the nearest tiles load first"
);
lod.finish_sample(nearby, vec![Column::default(); 256]);
lod.update(camera, 2, 64, Some(FxHashSet::default()));
lod.update(camera, 2, 64, Some(FxHashSet::default()), None);
let Some(Job::Sample(next)) = lod.shared.jobs.lock().unwrap().pop_front() else {
panic!("the next ring was not scheduled");
};
@@ -3894,7 +4138,7 @@ mod tests {
level: 0,
})
.collect();
lod.update(camera, 2, 64, Some(FxHashSet::default()));
lod.update(camera, 2, 64, Some(FxHashSet::default()), None);
let Some(Job::Sample(first)) = lod.shared.jobs.lock().unwrap().pop_front() else {
panic!("no final tile scheduled")
};
@@ -3938,7 +4182,7 @@ mod tests {
lod.shared.jobs.lock().unwrap().clear();
lod.pending.clear();
lod.wanted_stale = true;
lod.update([-256., 64., -256.], 2, 64, Some(FxHashSet::default()));
lod.update([-256., 64., -256.], 2, 64, Some(FxHashSet::default()), None);
let jobs = lod.shared.jobs.lock().unwrap();
jobs.iter()
.filter(|job| matches!(job, Job::Sample(_)))
@@ -3954,7 +4198,7 @@ mod tests {
// 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);
let plan = Plan::with_previous([8, 8], 12, 128, &Plan::default(), 0, reduced, None);
for key in plan.nodes.iter().filter(|key| !plan.split.contains(key)) {
let distance = key.distance([8, 8]);
if distance < one {
@@ -3984,7 +4228,8 @@ mod tests {
}
}
}
let plan = Plan::with_previous(camera, detail, distance, &Plan::default(), 0, reduced);
let plan =
Plan::with_previous(camera, detail, distance, &Plan::default(), 0, reduced, None);
let leaf = |x: i32, z: i32| {
(0..=MAX_LEVEL)
.map(|level| TileKey {
@@ -4250,7 +4495,7 @@ mod tests {
lod.finish_sample(a, vec![Column::default(); CELLS * CELLS]);
lod.dirty.clear();
// A range of one chunk around (-10, 0) keeps `a` and drops `b`.
lod.replan([-10, 0], 0, 1, 0);
lod.replan([-10, 0], 0, 1, 0, None);
assert!(lod.plan.nodes.contains(&a) && !lod.plan.nodes.contains(&b));
assert!(lod.dirty.contains_key(&a), "its east wall is drawn now");
}
@@ -4305,7 +4550,7 @@ mod tests {
keys
};
let none = || Some(FxHashSet::default());
lod.update([8., 64., 8.], 2, 64, none());
lod.update([8., 64., 8.], 2, 64, none(), None);
let before = queued(&lod);
assert_eq!(before.len(), 16, "a full queue once the near terrain is in");
lod.shared
@@ -4315,7 +4560,7 @@ mod tests {
.push_back(Job::Observe((5, 5)));
// A chunk's move: the tiles still planned keep their place in line.
lod.update([24., 64., 8.], 2, 64, none());
lod.update([24., 64., 8.], 2, 64, none(), None);
let moved = queued(&lod);
let kept: Vec<_> = before
.iter()
@@ -4328,7 +4573,7 @@ mod tests {
assert!(observing(&lod), "only sample jobs are dropped");
// A teleport: none of the old view's tiles is sampled any more.
lod.update([100_000., 64., 100_000.], 2, 64, none());
lod.update([100_000., 64., 100_000.], 2, 64, none(), None);
let after = queued(&lod);
assert_eq!(after.len(), 16);
assert!(after.iter().all(|key| !moved.contains(key)));
@@ -4346,7 +4591,7 @@ mod tests {
assert!(lod.pending.is_empty());
assert!(observing(&lod));
lod.set_near_loading(false);
lod.update([100_000., 64., 100_000.], 2, 64, none());
lod.update([100_000., 64., 100_000.], 2, 64, none(), None);
assert_eq!(queued(&lod), after);
// Larger machines keep the first `SAMPLE_JOBS` of the queue.
@@ -4754,7 +4999,7 @@ mod tests {
};
for _ in 0..2 {
for _ in 0..4000 {
lod.update(camera, 2, 64, Some(FxHashSet::default()));
lod.update(camera, 2, 64, Some(FxHashSet::default()), None);
check(&lod);
if !finish_queued(&mut lod) {
break;
@@ -4765,11 +5010,11 @@ mod tests {
check(&lod);
// Move a chunk: retained samples stay selected, new ones join.
camera[0] += 16.0;
lod.update(camera, 2, 64, Some(FxHashSet::default()));
lod.update(camera, 2, 64, Some(FxHashSet::default()), None);
check(&lod);
}
// A covered level-0 stand-in completes its parent but is not drawn.
lod.update(camera, 2, 64, Some([(0, 0)].into_iter().collect()));
lod.update(camera, 2, 64, Some([(0, 0)].into_iter().collect()), None);
check(&lod);
assert!(lod.ready.contains_key(&TileKey {
x: 0,
@@ -4782,7 +5027,7 @@ mod tests {
level: 0
}));
// Shrinking the distance retires tiles and reports them once.
lod.update(camera, 0, 32, Some(FxHashSet::default()));
lod.update(camera, 0, 32, Some(FxHashSet::default()), None);
check(&lod);
let _ = lod.removed();
}
@@ -4866,7 +5111,7 @@ mod tests {
let mut covered = Some(covered.clone());
let mut quiet = 0;
while quiet < if settle { 50 } else { 3 } {
lod.update(camera, detail, distance, covered.take());
lod.update(camera, detail, distance, covered.take(), None);
lod.removed();
// Every tile sampled, but those waiting for their data.
let unsampled = lod.plan.nodes.iter().any(|key| {
+297 -26
View File
@@ -272,13 +272,16 @@ impl Opt {
Opt::PadAdaptiveTriggers => "horizon.options.adaptive_triggers",
Opt::PadLightbar => "horizon.options.lightbar",
Opt::PadLookSensitivity => "horizon.options.look_sensitivity",
Opt::PadPointerSpeed => "horizon.options.pointer_speed",
Opt::PadScrollSpeed => "horizon.options.scroll_speed",
Opt::PadRepeatDelay => "horizon.options.repeat_delay",
Opt::PadRepeatInterval => "horizon.options.repeat_interval",
Opt::PadInvertX => "horizon.options.invert_x",
Opt::PadInvertY => "horizon.options.invert_y",
Opt::PadToggleSneak => "horizon.options.sneak_button",
Opt::PadMoveDeadzone => "horizon.options.move_deadzone",
Opt::PadLookDeadzone => "horizon.options.look_deadzone",
Opt::PadGlyphs => "horizon.options.button_glyphs",
Opt::PadMenuNav => "horizon.options.menu_navigation",
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
Opt::ModelPart(i) => [
"options.modelPart.cape",
@@ -352,6 +355,10 @@ impl Opt {
Opt::ChatHeightUnfocused => Kind::Slider(s.chat_height_unfocused),
Opt::ChatLineSpacing => Kind::Slider(s.chat_line_spacing),
Opt::PadLookSensitivity => Kind::Slider(s.controller.look_sensitivity),
Opt::PadPointerSpeed => unit(s.controller.pointer_speed, 0.25, 3.0),
Opt::PadScrollSpeed => unit(s.controller.scroll_speed, 1.0, 36.0),
Opt::PadRepeatDelay => unit(s.controller.repeat_delay, 0.1, 1.0),
Opt::PadRepeatInterval => unit(s.controller.repeat_interval, 0.03, 0.3),
Opt::PadMoveDeadzone => unit(s.controller.move_deadzone, 0.0, 0.5),
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
@@ -610,6 +617,23 @@ impl Opt {
),
),
Opt::PadLookSensitivity => percent(c, 2.0 * s.controller.look_sensitivity),
Opt::PadPointerSpeed => percent(c, s.controller.pointer_speed),
Opt::PadScrollSpeed => percent(c, s.controller.scroll_speed / 12.0),
Opt::PadRepeatDelay | Opt::PadRepeatInterval => generic(
c,
Text::tr(
"horizon.options.milliseconds",
&[&format!(
"{:.0}",
1000.0
* if self == Opt::PadRepeatDelay {
s.controller.repeat_delay
} else {
s.controller.repeat_interval
}
)],
),
),
Opt::PadMoveDeadzone | Opt::PadLookDeadzone => {
let v = if self == Opt::PadMoveDeadzone {
s.controller.move_deadzone
@@ -796,10 +820,6 @@ impl Opt {
GlyphStyle::PlayStation => "horizon.options.glyphs.playstation",
GlyphStyle::Nintendo => "horizon.options.glyphs.nintendo",
}),
Opt::PadMenuNav => tr(match s.controller.menu_nav {
gamepad::MenuNav::Focus => "horizon.options.menu_focus",
gamepad::MenuNav::Pointer => "horizon.options.menu_pointer",
}),
Opt::PadKeyboard => tr(match s.controller.keyboard {
gamepad::TextKeyboard::Auto => "horizon.options.keyboard.auto",
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
@@ -871,6 +891,10 @@ impl Opt {
Opt::ChatHeightUnfocused => s.chat_height_unfocused = v,
Opt::ChatLineSpacing => s.chat_line_spacing = v,
Opt::PadLookSensitivity => s.controller.look_sensitivity = v,
Opt::PadPointerSpeed => s.controller.pointer_speed = step(25.0, 300.0) / 100.0,
Opt::PadScrollSpeed => s.controller.scroll_speed = step(1.0, 36.0),
Opt::PadRepeatDelay => s.controller.repeat_delay = step(100.0, 1000.0) / 1000.0,
Opt::PadRepeatInterval => s.controller.repeat_interval = step(30.0, 300.0) / 1000.0,
Opt::PadMoveDeadzone => s.controller.move_deadzone = step(0.0, 50.0) / 100.0,
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
@@ -1094,12 +1118,6 @@ impl Opt {
.unwrap_or(0) as u8;
s.controller.glyphs = GlyphStyle::ALL[usize::from(next(i, 4))];
}
Opt::PadMenuNav => {
s.controller.menu_nav = match s.controller.menu_nav {
gamepad::MenuNav::Focus => gamepad::MenuNav::Pointer,
gamepad::MenuNav::Pointer => gamepad::MenuNav::Focus,
}
}
Opt::PadKeyboard => {
let all = gamepad::TextKeyboard::ALL;
let i = all
@@ -1344,6 +1362,14 @@ enum WKind {
value: f32,
name: String,
},
/// A float or int option `.option` of the Horizon pack `s.shader_packs[.pack]`: where its
/// value sits between its minimum and maximum.
PackSlider {
label: Text,
value: f32,
pack: usize,
option: usize,
},
Text {
text: Text,
color: u32,
@@ -1372,6 +1398,8 @@ struct Widget {
enum FocusId {
Search,
Slider(Opt),
/// A Horizon pack's option slider: (pack, option).
PackSlider(usize, usize),
Cycle(Opt),
Act(Act),
Lang(String),
@@ -1381,6 +1409,7 @@ enum FocusId {
fn focus_id(i: usize, wd: &Widget) -> FocusId {
match &wd.kind {
WKind::Slider { opt, .. } => FocusId::Slider(*opt),
WKind::PackSlider { pack, option, .. } => FocusId::PackSlider(*pack, *option),
WKind::Button {
act: Act::Cycle(opt),
..
@@ -1394,6 +1423,9 @@ fn focus_id(i: usize, wd: &Widget) -> FocusId {
fn matches_focus(id: &FocusId, i: usize, wd: &Widget) -> bool {
match (id, &wd.kind) {
(FocusId::Slider(a), WKind::Slider { opt, .. }) => a == opt,
(FocusId::PackSlider(p, o), WKind::PackSlider { pack, option, .. }) => {
(p, o) == (pack, option)
}
(
FocusId::Cycle(a),
WKind::Button {
@@ -1512,6 +1544,9 @@ pub struct OptionsUi {
/// The shader pack option whose slider is held, and the handle's own position: it follows
/// the pointer while the option takes the nearest of its values.
shader_dragging: Option<(String, f32)>,
/// The same for a Horizon pack's option: (pack, option, the handle's position, whether its
/// value changed since the press). The pack saves its values, and reloads, on release.
pack_dragging: Option<(usize, usize, f32, bool)>,
/// Shift is held during the click (it resets a shader pack option to its default).
pub shift: bool,
/// The pressed slider's own position (`AbstractSliderButton.value`) and
@@ -1602,6 +1637,7 @@ impl Default for OptionsUi {
scroll: 0.0,
dragging: None,
shader_dragging: None,
pack_dragging: None,
shift: false,
slider_value: None,
scrollbar_drag_y: None,
@@ -1746,7 +1782,23 @@ fn native_pack_option_rows(s: &Settings, i: usize, at: u8) -> Vec<Row> {
let value = crate::shader_pack_list::show_value(&option.kind, pack.values[o]);
let label =
Text::option_name_value(Text::literal(option.name.clone()), Text::literal(value));
button(0.0, 0.0, width, label, Act::NativePackOption(i, o))
match pack.slider_position(o) {
Some(value) => Widget {
x: 0.0,
y: 0.0,
w: width,
h: 20.0,
kind: WKind::PackSlider {
label,
value,
pack: i,
option: o,
},
list: false,
row: 0,
},
None => button(0.0, 0.0, width, label, Act::NativePackOption(i, o)),
}
}
PageItem::Page(p) if p < pack.pages.len() => {
let label = Text::literal(format!("{}...", pack.pages[p].name));
@@ -1845,7 +1897,10 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
WKind::Button {
act: Act::NativePackOption(pack, option),
..
} => Some(&s.shader_packs.get(*pack)?.options.get(*option)?.description),
}
| WKind::PackSlider { pack, option, .. } => {
Some(&s.shader_packs.get(*pack)?.options.get(*option)?.description)
}
WKind::Button {
act: Act::Push(Page::NativePackOptions(pack, at)),
..
@@ -2043,6 +2098,13 @@ fn pairs(opts: &[Opt]) -> Vec<Row> {
.collect()
}
impl OptionsUi {
/// No widget keeps the highlight a click gave it; a focus the keyboard
/// moved stays.
pub fn clear_click_focus(&mut self) {
if !self.focus_from_keyboard {
self.focus = None;
}
}
/// A switch of `horizon-extras.txt` as the Extras page draws it: "Caption: On".
fn extra_button(&self, s: &Settings, item: crate::extras::Item) -> Widget {
let label = Text::option_name_value(
@@ -2163,6 +2225,18 @@ impl OptionsUi {
}
events
}
/// Steps a Horizon pack's option slider one step forward or backward, and saves it.
fn step_pack_slider(s: &mut Settings, pack: usize, option: usize, forward: bool) -> Vec<Event> {
let stepped = s
.shader_packs
.get_mut(pack)
.is_some_and(|entry| entry.nudge_option(option, !forward));
if stepped {
vec![Event::NativePackOptions(pack)]
} else {
Vec::new()
}
}
/// Open a page whose Done/Escape returns directly to the calling screen.
pub fn open_at(&mut self, in_world: bool, page: Page) {
self.stack.clear();
@@ -2689,8 +2763,11 @@ impl OptionsUi {
Opt::PadMoveDeadzone,
Opt::PadLookDeadzone,
Opt::PadGlyphs,
Opt::PadMenuNav,
Opt::PadKeyboard,
Opt::PadPointerSpeed,
Opt::PadScrollSpeed,
Opt::PadRepeatDelay,
Opt::PadRepeatInterval,
]));
rows
}
@@ -3716,6 +3793,34 @@ impl OptionsUi {
dragging,
);
}
WKind::PackSlider {
label,
value,
pack,
option,
} => {
let held = self
.pack_dragging
.filter(|&(p, o, ..)| (p, o) == (*pack, *option));
let dragging = held.is_some();
ui::slider_ext(
batch,
atlas,
wd.x,
wd.y,
wd.w,
label,
held.map_or(*value, |h| h.2),
hover || dragging,
is_focused,
self.can_change_value && is_focused,
true,
dragging,
);
crate::widgets::last_narratable(|n| {
narrate_widget(n, s, wd, hover, list_key, framebuffer);
});
}
WKind::Text {
text,
color,
@@ -4015,6 +4120,16 @@ impl OptionsUi {
*at = ui::slider_value(wd.x, wd.w, x);
options.set_position(name, *at);
}
if let Some((pack, option, at, changed)) = &mut self.pack_dragging
&& let Some(wd) = widgets.iter().find(|wd| {
matches!(wd.kind, WKind::PackSlider { pack: p, option: o, .. }
if (p, o) == (*pack, *option))
})
&& let Some(entry) = s.shader_packs.get_mut(*pack)
{
*at = ui::slider_value(wd.x, wd.w, x);
*changed |= entry.set_slider_position(*option, *at);
}
if let Some(opt) = self.dragging
&& let Some(wd) = widgets
.iter()
@@ -4251,6 +4366,21 @@ impl OptionsUi {
}
self.shader_dragging = Some((name, at));
}
WKind::PackSlider { pack, option, .. } => {
self.can_change_value = true;
let Some(entry) = s.shader_packs.get_mut(pack) else {
break;
};
if self.shift {
// Its default, as a shift-click on a pack's button does; no drag follows.
entry.step_option(option, false, true);
events.extend([Event::ButtonClick, Event::NativePackOptions(pack)]);
break;
}
let at = ui::slider_value(wd.x, wd.w, x);
let changed = entry.set_slider_position(option, at);
self.pack_dragging = Some((pack, option, at, changed));
}
WKind::Slider { opt, .. } => {
self.can_change_value = true;
self.dragging = Some(opt);
@@ -4287,12 +4417,19 @@ impl OptionsUi {
}
// AbstractSliderButton suppresses the press sound and plays once on
// release, even if the pointer has left the widget or its value stayed.
let slider = self.dragging.take().is_some() | self.shader_dragging.take().is_some();
let pack = self.pack_dragging.take();
let slider =
self.dragging.take().is_some() | self.shader_dragging.take().is_some() | pack.is_some();
let graphics_slider = self.graphics.release();
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
let split_drop = self.split_setup.release();
if slider || graphics_slider || calibration_slider {
vec![Event::ButtonClick, Event::Changed]
let mut events = vec![Event::ButtonClick, Event::Changed];
// A pack's option is saved, and the pack rebuilt, once per drag.
if let Some((pack, _, _, true)) = pack {
events.push(Event::NativePackOptions(pack));
}
events
} else if split_drop {
vec![Event::ButtonClick]
} else {
@@ -4390,6 +4527,10 @@ impl OptionsUi {
.map_or(150.0, |wd| wd.w);
return self.step_slider_option(s, opt, amount > 0.0, width);
}
Some(FocusId::PackSlider(pack, option)) => {
let (pack, option) = (*pack, *option);
return Self::step_pack_slider(s, pack, option, amount > 0.0);
}
Some(FocusId::Cycle(opt)) => {
let opt = *opt;
let mut events = vec![Event::ButtonClick];
@@ -4855,6 +4996,7 @@ impl OptionsUi {
match wd.kind {
WKind::Button { active: true, .. }
| WKind::Slider { .. }
| WKind::PackSlider { .. }
| WKind::LanguageRow { .. } => {
focusables.push((focus_id(i, wd), [wd.x, wd.y, wd.w, wd.h], wd.list));
}
@@ -4890,7 +5032,7 @@ impl OptionsUi {
let (next_id, rect, in_list) = &focusables[next_idx];
self.focus = Some(next_id.clone());
self.focus_from_keyboard = true;
self.can_change_value = matches!(next_id, FocusId::Slider(_));
self.can_change_value = matches!(next_id, FocusId::Slider(_) | FocusId::PackSlider(..));
if *in_list {
if rect[1] < top {
self.scroll -= (top - rect[1]) + 4.0;
@@ -4915,6 +5057,11 @@ impl OptionsUi {
.map_or(150.0, |wd| wd.w);
return self.step_slider_option(s, opt, forward, width);
}
if let Some(FocusId::PackSlider(pack, option)) = self.focus
&& self.can_change_value
{
return Self::step_pack_slider(s, pack, option, forward);
}
// Otherwise, navigate horizontally using 2D focus stepping
if let Some(fid) = &self.focus {
if let Some(cur_pos) = focusables.iter().position(|(id, ..)| id == fid) {
@@ -4992,7 +5139,7 @@ impl OptionsUi {
// 4. Enter / Space selection keys
if matches!(key, K::Enter | K::NumpadEnter | K::Space) {
match &self.focus {
Some(FocusId::Slider(_)) => {
Some(FocusId::Slider(_) | FocusId::PackSlider(..)) => {
self.can_change_value = !self.can_change_value;
return Vec::new();
}
@@ -5505,11 +5652,9 @@ mod tests {
id: "a".into(),
name: "A".into(),
description: String::new(),
kind: horizon_shader_api::OptionKind::Float {
default: 1.0,
min: 0.0,
max: 2.0,
step: 0.5,
kind: horizon_shader_api::OptionKind::Choice {
values: vec!["x".into(), "y".into(), "z".into()],
default: 1,
},
}],
values: vec![1.0],
@@ -5550,10 +5695,10 @@ mod tests {
let (x, y) = find(&mut ui, &s, &Act::NativePackOption(0, 0));
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert!(events.contains(&Event::NativePackOptions(0)));
assert_eq!(s.shader_packs[0].values, [1.5]);
assert_eq!(s.shader_packs[0].values, [2.0]);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
assert_eq!(s.shader_packs[0].values, [0.5]);
assert_eq!(s.shader_packs[0].values, [0.0]);
// Shift-click: the option's default.
ui.shift = true;
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
@@ -5567,6 +5712,116 @@ mod tests {
assert_eq!(ui.page(), Page::NativePackOptions(0, 0));
}
#[test]
fn a_horizon_packs_float_and_int_options_are_sliders_saved_on_release() {
use horizon_shader_api::{OptionKind, PackOption};
use winit::keyboard::KeyCode;
let atlas = atlas();
let size = (960.0, 540.0);
let option = |id: &str, kind| PackOption {
id: id.into(),
name: id.into(),
description: String::new(),
kind,
};
let pack = crate::shader_pack_list::Entry {
file: "p".into(),
title: "P".into(),
kind: crate::shader_pack_list::Kind::Horizon,
options: vec![
option(
"a",
OptionKind::Float {
default: 1.0,
min: 0.0,
max: 2.0,
step: 0.5,
},
),
option(
"b",
OptionKind::Int {
default: 2,
min: 0,
max: 4,
},
),
option("c", OptionKind::Bool { default: true }),
],
values: vec![1.0, 2.0, 1.0],
pages: Vec::new(),
};
let mut s = Settings {
shader_packs: vec![pack],
..Settings::default()
};
let mut ui = OptionsUi::default();
ui.show(Page::NativePackOptions(0, 0));
// A slider's track, from its left end to its right: (x of 0, x of 1, y).
let track = |ui: &mut OptionsUi, s: &Settings, want: usize| {
let (widgets, ..) = ui.layout(s, size.0, size.1);
widgets
.into_iter()
.find(|w| matches!(w.kind, WKind::PackSlider { option, .. } if option == want))
.map(|w| (w.x + 4.0, w.x + w.w - 4.0, w.y + w.h / 2.0))
};
assert!(track(&mut ui, &s, 2).is_none(), "a bool stays a button");
let (left, right, y) = track(&mut ui, &s, 0).expect("the float's slider");
let at = |f: f32| left + f * (right - left);
// Pressed a fifth of the way: 0.4 of 2, the nearest step 0.5. Nothing is saved yet.
let events = ui.click(&mut s, &atlas, at(0.2), y, size, [854, 480], false, false);
assert!(!events.contains(&Event::NativePackOptions(0)));
assert_eq!(s.shader_packs[0].values[0], 0.5);
// Dragged past the right end: the maximum.
ui.click(
&mut s,
&atlas,
right + 30.0,
y,
size,
[854, 480],
true,
false,
);
assert_eq!(s.shader_packs[0].values[0], 2.0);
// Released: saved once, which reloads the pack.
let events = ui.release();
let saves = events.iter().filter(|e| **e == Event::NativePackOptions(0));
assert_eq!(saves.count(), 1);
// A press and release that leave the value as it was save nothing.
ui.click(&mut s, &atlas, right, y, size, [854, 480], false, false);
assert!(!ui.release().contains(&Event::NativePackOptions(0)));
// Shift-click: the default at once.
ui.shift = true;
let events = ui.click(&mut s, &atlas, at(0.9), y, size, [854, 480], false, false);
ui.shift = false;
assert!(events.contains(&Event::NativePackOptions(0)));
assert_eq!(s.shader_packs[0].values[0], 1.0);
assert!(!ui.release().contains(&Event::NativePackOptions(0)));
// The int takes whole numbers: 0.6 of 0..4 is 2.4, so 2, unchanged; 0.7 is 3.
let (left, right, y) = track(&mut ui, &s, 1).expect("the int's slider");
let at = |f: f32| left + f * (right - left);
ui.click(&mut s, &atlas, at(0.6), y, size, [854, 480], false, false);
assert_eq!(s.shader_packs[0].values[1], 2.0);
assert!(!ui.release().contains(&Event::NativePackOptions(0)));
ui.click(&mut s, &atlas, at(0.7), y, size, [854, 480], false, false);
assert_eq!(s.shader_packs[0].values[1], 3.0);
assert!(ui.release().contains(&Event::NativePackOptions(0)));
// From the keyboard: the arrows step a focused slider, saving each step, up to its end.
ui.focus = Some(FocusId::PackSlider(0, 1));
ui.focus_from_keyboard = true;
ui.can_change_value = true;
let events = ui.key(&mut s, &atlas, KeyCode::ArrowRight, None);
assert_eq!(events, [Event::NativePackOptions(0)]);
assert_eq!(s.shader_packs[0].values[1], 4.0);
assert!(ui.key(&mut s, &atlas, KeyCode::ArrowRight, None).is_empty());
assert_eq!(s.shader_packs[0].values[1], 4.0);
ui.key(&mut s, &atlas, KeyCode::ArrowLeft, None);
assert_eq!(s.shader_packs[0].values[1], 3.0);
}
#[test]
fn resource_reload_error_blocks_options_until_dismissed() {
use winit::keyboard::KeyCode;
@@ -8039,7 +8294,7 @@ mod tests {
// Also verify Act::Extra on Page::Extras
ui.show(Page::Extras);
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
let (widgets, top, _, _) = ui.layout(&s, size.0, size.1);
if let Some(extra_btn) = widgets.iter().find(|wd| {
matches!(
wd.kind,
@@ -8050,6 +8305,22 @@ mod tests {
}
)
}) {
// Extras has enough rows to scroll: click the cycle button inside
// the list's viewport, as a player would after scrolling to it.
ui.scroll = (extra_btn.y - top).max(0.0);
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
let extra_btn = widgets
.iter()
.find(|wd| {
matches!(
wd.kind,
WKind::Button {
act: Act::Extra(crate::extras::Item::CosmeticsOthers),
..
}
)
})
.unwrap();
let (ex, ey) = (extra_btn.x + 1.0, extra_btn.y + 1.0);
use horizon_cosmetics::prefs::Others;
assert_eq!(s.extras.cosmetics_others, Others::Shown);
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,67 @@
//! The controller's menu cursor in inventories: a crosshair whose centre is
//! the point it clicks. Horizon's own art (pad_cursor.png).
/// The atlas sprite.
pub const SPRITE: &str = "horizon/pad_cursor";
/// The texture's size in texels, and the size it's drawn at in GUI pixels
/// (an item's).
pub const SIZE: u32 = 32;
pub const DRAWN: f32 = 16.0;
const PNG: &[u8] = include_bytes!("pad_cursor.png");
/// The texture as `SIZE` square RGBA texels.
pub fn pixels() -> Vec<u8> {
decode(PNG).expect("pad_cursor.png is a 32x32 PNG")
}
fn decode(bytes: &[u8]) -> Option<Vec<u8>> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().ok()?;
let info = reader.info();
if (info.width, info.height) != (SIZE, SIZE) {
return None;
}
let mut buf = vec![0; reader.output_buffer_size()?];
let frame = reader.next_frame(&mut buf).ok()?;
let channels = frame.color_type.samples();
Some(
buf.chunks(channels)
.take((SIZE * SIZE) as usize)
.flat_map(|p| match channels {
4 => [p[0], p[1], p[2], p[3]],
3 => [p[0], p[1], p[2], 255],
2 => [p[0], p[0], p[0], p[1]],
_ => [p[0], p[0], p[0], 255],
})
.collect(),
)
}
/// Where to draw the cursor (top-left, GUI pixels) so its centre lands on
/// the GUI point `at`.
pub fn origin(at: (f32, f32)) -> (f32, f32) {
(at.0 - DRAWN / 2.0, at.1 - DRAWN / 2.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_cursor_is_a_crosshair_centred_on_its_point() {
let px = pixels();
assert_eq!(px.len(), (SIZE * SIZE * 4) as usize);
let alpha = |x: u32, y: u32| px[((y * SIZE + x) * 4 + 3) as usize];
let middle = (SIZE / 2 - 1)..=(SIZE / 2);
assert!(
middle
.clone()
.any(|x| middle.clone().any(|y| alpha(x, y) > 0)),
"the clicked point sits on the crosshair"
);
assert_eq!(alpha(0, 0), 0, "corners are clear: a plus, not a square");
assert_eq!(origin((100.0, 50.0)), (92.0, 42.0));
}
}
@@ -26,6 +26,8 @@ use std::{
pub use horizon_client_core::particle_type::ParticleType;
pub use horizon_items::firework::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape};
mod native;
pub use native::{ParticleController, ParticleUpdate};
/// `ParticleOptions` with the parameters of the option classes used here.
#[derive(Clone, Debug)]
@@ -212,6 +214,9 @@ enum Light {
#[derive(Clone, Copy, Debug, PartialEq)]
enum Behaviour {
Base,
Controlled {
translucent: bool,
},
Mod {
size: [f32; 2],
alpha: [f32; 2],
@@ -505,6 +510,7 @@ pub struct Particle {
pre_tick: bool,
/// `Particle.random`: the particle's own generator (`RandomSource.create()`).
random: LegacyRandom,
controller: Option<Box<dyn native::Controller>>,
}
fn aabb_at(p: [f64; 3], w: f32, h: f32) -> Aabb {
let w = f64::from(w) / 2.0;
@@ -841,8 +847,37 @@ impl Particle {
pre_tick: false,
frame_ticks: 0,
random: LegacyRandom::new(0),
controller: None,
}
}
/// The normal age/removal test and previous pose, without vanilla motion.
/// Returns false when this tick expires the particle.
pub fn advance_age(&mut self) -> bool {
self.previous = self.position;
self.o_roll = self.roll;
let alive = self.age < self.lifetime;
self.age = self.age.saturating_add(1);
self.removed |= !alive;
alive
}
pub fn remove(&mut self) {
self.removed = true;
}
/// Hide geometry while retaining the particle's lifetime and controller.
pub fn set_visible(&mut self, visible: bool) {
self.no_render = !visible;
}
/// The particle's own random, distinct from the engine and level generators.
pub fn random(&mut self) -> &mut LegacyRandom {
&mut self.random
}
pub fn set_full_bright(&mut self, full_bright: bool) {
self.light = if full_bright {
Light::FullBright
} else {
Light::World
};
}
/// `Particle(level, x, y, z, xa, ya, za)`: jittered, normalized initial velocity.
fn moving(random: &mut LegacyRandom, ty: ParticleType, p: [f64; 3], v: [f64; 3]) -> Self {
let mut particle = Self::base_moving(random, ty, p, v);
@@ -1420,6 +1455,10 @@ impl ParticleEngine {
atlas: &HashMap<String, Rect>,
translucent: &HashSet<String>,
) {
// Controllers' numeric sprite-set handles belong to this resource
// generation. Discard them rather than selecting another set after reload.
self.particles.retain(|p| p.controller.is_none());
self.pending.retain(|p| p.controller.is_none());
self.atlas = atlas
.iter()
.map(|(name, &rect)| (name.clone(), (rect, translucent.contains(name))))
@@ -1584,6 +1623,45 @@ impl ParticleEngine {
pub fn push(&mut self, particle: Particle) {
self.pending.push(particle);
}
/// Create a named billboard with custom compiled behavior and no vanilla
/// motion. Like `add`, respects camera distance and the Particles setting.
/// Configure its public fields, then `push` it to the ordinary engine queue.
/// Missing sprite sets return None. The constructor has the ordinary
/// stationary SingleQuadParticle random size/lifetime and no velocity draws.
pub fn controlled<C: ParticleController>(
&mut self,
name: &str,
position: [f64; 3],
translucent: bool,
controller: C,
) -> Option<Particle> {
let minimal = self.calculate_particle_level(false) == 2;
let distance: f64 = position
.iter()
.zip(self.camera)
.map(|(p, c)| (p - c).powi(2))
.sum();
if minimal || !distance.is_finite() || distance > 1024.0 {
return None;
}
let (set, _) = *self.mod_sets.get(name)?;
let mut random = LegacyRandom::new(self.seeds.next_long());
let mut particle = Particle::at(&mut random, ParticleType::Smoke, position);
particle.random = random;
particle.set = Some(set);
particle.sprite = Sprite::Index(*self.set_sprites.get(set as usize)?.first()?);
particle.behaviour = Behaviour::Controlled { translucent };
particle.controller = Some(Box::new(controller));
Some(particle)
}
/// Choose a frame from this particle's declared sprite set.
pub fn set_sprite_frame(&self, particle: &mut Particle, frame: usize) {
if let Some(sprites) = particle.set.and_then(|s| self.set_sprites.get(s as usize))
&& !sprites.is_empty()
{
particle.sprite = Sprite::Index(sprites[frame.min(sprites.len() - 1)]);
}
}
/// Random generator shared by particle constructors and spawners.
pub fn random(&mut self) -> &mut LegacyRandom {
&mut self.random
@@ -3710,6 +3788,20 @@ impl ParticleEngine {
fn tick_particle(&mut self, p: &mut Particle, level: &ParticleLevel) {
match p.behaviour {
Behaviour::Base => p.base_tick(level),
Behaviour::Controlled { .. } => {
if let Some(mut controller) = p.controller.take() {
let update = controller.tick(p, level);
p.controller = Some(controller);
if let Some(frame) = update.frame {
self.set_sprite_frame(p, frame);
}
for (options, pos, velocity) in update.spawned {
self.add(&options, pos, velocity);
}
} else {
p.remove();
}
}
Behaviour::Mod { size, alpha } => {
p.base_tick(level);
p.size = mod_curve(size, p.age, p.lifetime);
@@ -4627,6 +4719,7 @@ impl ParticleEngine {
Sprite::Index(i) => (
self.rects.get(*i as usize).copied().flatten(),
p.ty.translucent_layer()
|| matches!(p.behaviour, Behaviour::Controlled { translucent: true })
|| matches!(p.behaviour, Behaviour::Mod { alpha, .. } if alpha != [1.0; 2]),
),
Sprite::Named(name) => match self.atlas.get(name) {
@@ -0,0 +1,179 @@
//! Opt-in, compiled particle behavior. The ordinary engine still owns the
//! particle, its sprite set, interpolation, rendering and total count limits.
use super::{Particle, ParticleLevel, ParticleOptions};
use std::fmt::Debug;
/// Requests made by one particle tick, submitted through normal spawn limits.
#[derive(Default)]
pub struct ParticleUpdate {
/// An index in this particle's declared sprite set. Out-of-range is clamped.
pub frame: Option<usize>,
pub spawned: Vec<(ParticleOptions, [f64; 3], [f64; 3])>,
}
/// Owned behavior for a compiled native particle. Cloning a particle clones its
/// controller state. Do not retain world references; inspect `level` each tick.
pub trait ParticleController: Clone + Debug + Send + Sync + 'static {
/// Call `particle.advance_age()` for ordinary lifetime/interpolation, or
/// explicitly maintain those fields for an overridden source tick.
fn tick(&mut self, particle: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate;
}
pub(super) trait Controller: Debug + Send + Sync {
fn tick(&mut self, particle: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate;
fn clone_box(&self) -> Box<dyn Controller>;
}
impl<T: ParticleController> Controller for T {
fn tick(&mut self, particle: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate {
ParticleController::tick(self, particle, level)
}
fn clone_box(&self) -> Box<dyn Controller> {
Box::new(self.clone())
}
}
impl Clone for Box<dyn Controller> {
fn clone(&self) -> Self {
self.clone_box()
}
}
#[cfg(test)]
mod tests {
use super::super::*;
use horizon_world::{
DATA_VERSION,
collision::CachedCollisionRegistry,
nbt::Tag,
registry::{BlockRegistry, BlockStateId},
};
#[derive(Clone, Debug)]
struct Moving(u32);
impl ParticleController for Moving {
fn tick(&mut self, p: &mut Particle, _: &ParticleLevel<'_>) -> ParticleUpdate {
if p.advance_age() {
self.0 += 1;
p.position[0] += f64::from(self.0);
}
ParticleUpdate {
frame: Some(self.0 as usize),
..Default::default()
}
}
}
fn engine() -> ParticleEngine {
let mut e = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
e.mod_sets.insert("example:orbit".into(), (0, None));
e.set_sprites.push(vec![0, 1]);
e.rects = vec![
Some(Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16
});
2
];
e
}
#[test]
fn controlled_particle_keeps_owned_motion_sprite_interpolation_and_expiry() {
let registry = BlockRegistry::from_nbt(&Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
(
"states",
Tag::List(vec![Tag::String("minecraft:air".into())]),
),
("defaults", Tag::compound([("minecraft:air", Tag::Int(0))])),
]))
.unwrap();
let cached = CachedCollisionRegistry::from_nbt(
&Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
(
"states",
Tag::List(vec![Tag::String("minecraft:air".into())]),
),
("shapes", Tag::List(vec![])),
("cached_shapes", Tag::IntArray(vec![-1])),
]),
&registry,
)
.unwrap();
let collision = BlockCollisionRegistry::new(std::sync::Arc::new(cached)).unwrap();
let facts = BlockFacts {
states: vec![],
air: BlockStateId(0),
woods: HashMap::new(),
};
let world = World::new(BlockStateId(0), 0);
let level = ParticleLevel {
world: &world,
facts: &facts,
collision: &collision,
player: None,
};
let mut e = engine();
let mut p = e
.controlled("example:orbit", [0.0; 3], true, Moving(0))
.unwrap();
p.lifetime = 2;
p.rgba[3] = 0.5;
p.velocity = [99.0; 3]; // no vanilla drift may leak into the custom tick
let mut copy = p.clone();
e.tick_particle(&mut p, &level);
e.tick_particle(&mut p, &level);
e.tick_particle(&mut copy, &level);
assert_eq!((p.position, p.previous), ([3.0, 0.0, 0.0], [1.0, 0.0, 0.0]));
assert_eq!(copy.position, [1.0, 0.0, 0.0]); // controller state cloned independently
assert!(matches!(p.sprite, Sprite::Index(1))); // clamped frame
p.size = 0.1;
e.particles.push(p.clone());
let mut vertices = Vec::new();
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
assert_eq!(vertices.len(), 4);
assert!(vertices.iter().all(
|v| v.color >> 24 == 127 && v.layer & crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY != 0
));
e.particles[0].set_visible(false);
vertices.clear();
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
assert!(vertices.is_empty());
assert_eq!(e.particles[0].age, 2);
e.pending.push(copy);
e.reload(Path::new(""), &HashMap::new(), &HashSet::new());
assert!(e.pending.is_empty());
vertices.clear();
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
assert!(vertices.is_empty());
e.tick_particle(&mut p, &level);
assert!(p.removed);
assert_eq!(p.position, [3.0, 0.0, 0.0]);
}
#[test]
fn controlled_spawn_respects_distance_settings_and_declared_sprite_sets() {
let mut e = engine();
assert!(
e.controlled("example:missing", [0.0; 3], true, Moving(0))
.is_none()
);
assert!(
e.controlled("example:orbit", [33.0, 0.0, 0.0], true, Moving(0))
.is_none()
);
assert!(
e.controlled("example:orbit", [f64::NAN, 0.0, 0.0], true, Moving(0))
.is_none()
);
assert!(
e.controlled("example:orbit", [32.0, 0.0, 0.0], true, Moving(0))
.is_some()
);
e.particle_level = 2;
assert!(
e.controlled("example:orbit", [0.0; 3], true, Moving(0))
.is_none()
);
}
}
+7 -2
View File
@@ -651,7 +651,9 @@ impl Settings {
}
pub fn save(&self, path: &Path) {
self.describe_for_crashes();
let _ = fs::write(path, self.serialize());
if let Err(e) = fs::write(path, self.serialize()) {
log::warn!("Horizon: options.txt is not saved: {e}");
}
if let Some(dir) = path.parent() {
self.horizon.save(dir);
self.controller.save(dir);
@@ -803,13 +805,16 @@ pub enum Opt {
PadAdaptiveTriggers,
PadLightbar,
PadLookSensitivity,
PadPointerSpeed,
PadScrollSpeed,
PadRepeatDelay,
PadRepeatInterval,
PadInvertX,
PadInvertY,
PadToggleSneak,
PadMoveDeadzone,
PadLookDeadzone,
PadGlyphs,
PadMenuNav,
PadKeyboard,
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
ModelPart(usize),
@@ -53,6 +53,47 @@ impl Entry {
true
}
/// Where option `i` sits on its slider, 0 to 1: float and int options are sliders.
pub fn slider_position(&self, i: usize) -> Option<f32> {
slider_position(&self.options.get(i)?.kind, *self.values.get(i)?)
}
/// Option `i` at the value nearest the slider position `at`. Whether its value changed.
pub fn set_slider_position(&mut self, i: usize, at: f32) -> bool {
let (Some(o), Some(v)) = (self.options.get(i), self.values.get_mut(i)) else {
return false;
};
let Some(new) = slider_value(&o.kind, at) else {
return false;
};
let changed = new != *v;
*v = new;
changed
}
/// Option `i`'s slider one step on (`back`: one before), stopping at its ends. Whether its
/// value changed.
pub fn nudge_option(&mut self, i: usize, back: bool) -> bool {
let (Some(o), Some(v)) = (self.options.get(i), self.values.get_mut(i)) else {
return false;
};
let dir = if back { -1.0 } else { 1.0 };
let new = match &o.kind {
OptionKind::Float { min, max, step, .. } => {
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
let n = ((*v - min) / step).round() + dir;
on_f32((min + n * step).clamp(min, max))
}
OptionKind::Int { min, max, .. } => {
(v.round() + dir).clamp(f64::from(*min), f64::from(*max))
}
_ => return false,
};
let changed = new != *v;
*v = new;
changed
}
/// Every option back to its default.
pub fn reset_options(&mut self) {
let defaults = self.options.iter().map(|o| o.kind.default_constant());
@@ -209,6 +250,42 @@ fn step_value(kind: &OptionKind, v: f64, back: bool) -> f64 {
}
}
/// Where `v` sits between the option's minimum and maximum, 0 to 1; `None` for the options
/// that are buttons.
fn slider_position(kind: &OptionKind, v: f64) -> Option<f32> {
let (min, max) = match kind {
OptionKind::Float { min, max, .. } => (f64::from(*min), f64::from(*max)),
OptionKind::Int { min, max, .. } => (f64::from(*min), f64::from(*max)),
_ => return None,
};
Some(((v - min) / (max - min)).clamp(0.0, 1.0) as f32)
}
/// The option's value nearest the slider position `at`: a float on its steps from the
/// minimum (the last one the maximum, when the range isn't a whole number of steps), an int
/// whole.
fn slider_value(kind: &OptionKind, at: f32) -> Option<f64> {
let at = f64::from(at.clamp(0.0, 1.0));
match kind {
OptionKind::Float { min, max, step, .. } => {
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
let steps = ((max - min) / step - 1e-6).ceil();
Some(on_f32((min + (at * steps).round() * step).min(max)))
}
OptionKind::Int { min, max, .. } => {
let (min, max) = (f64::from(*min), f64::from(*max));
Some((min + at * (max - min)).round())
}
_ => None,
}
}
/// `v` as the `f32` the pack's `override` gets: a step of 0.05 is no f32, and 20 of them would
/// otherwise miss a default of 1 by a hair.
fn on_f32(v: f64) -> f64 {
f64::from(v as f32)
}
/// The value as the settings page shows it.
pub fn show_value(kind: &OptionKind, v: f64) -> String {
match kind {
@@ -324,4 +401,65 @@ mod tests {
assert_eq!(load_values(&pack, &options), [1.0, 0.0]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn sliders_take_the_nearest_step_and_stop_at_the_ends() {
let float = |default, max, step| OptionKind::Float {
default,
min: 0.0,
max,
step,
};
let int = OptionKind::Int {
default: 2,
min: 0,
max: 10,
};
let mut entry = Entry {
file: "p".into(),
title: "P".into(),
kind: Kind::Horizon,
options: vec![
option("a", float(1.0, 2.0, 0.05)),
option("b", int),
option("c", OptionKind::Bool { default: true }),
option("d", float(0.0, 1.0, 0.3)),
],
values: vec![1.0, 2.0, 1.0, 0.0],
pages: Vec::new(),
};
assert_eq!(entry.slider_position(0), Some(0.5));
assert_eq!(entry.slider_position(1), Some(0.2));
// A bool (and a choice) stays a button.
assert_eq!(entry.slider_position(2), None);
assert!(!entry.set_slider_position(2, 0.0));
assert_eq!(entry.values[2], 1.0);
// 0.26 of 0..2 is 0.52: the step below.
assert!(entry.set_slider_position(0, 0.26));
assert_eq!(show_value(&entry.options[0].kind, entry.values[0]), "0.50");
// Twenty steps of 0.05 are the default of 1 exactly, so it isn't saved.
assert!(entry.set_slider_position(0, 0.5));
assert_eq!(entry.values[0], 1.0);
assert!(!entry.set_slider_position(0, 0.505));
// 5.7 of 0..10 is 6.
assert!(entry.set_slider_position(1, 0.57));
assert_eq!(entry.values[1], 6.0);
// A range that isn't a whole number of steps still reaches its maximum.
entry.set_slider_position(3, 1.0);
assert_eq!(entry.values[3], 1.0);
entry.set_slider_position(3, 0.6);
assert_eq!(entry.values[3], f64::from(0.6_f32));
// The arrow keys: a step at a time, not round past the ends.
entry.values[0] = 2.0;
assert!(!entry.nudge_option(0, false));
assert_eq!(entry.values[0], 2.0);
assert!(entry.nudge_option(0, true));
assert_eq!(entry.values[0], f64::from(1.95_f32));
entry.values[1] = 0.0;
assert!(!entry.nudge_option(1, true));
assert!(entry.nudge_option(1, false));
assert_eq!(entry.values[1], 1.0);
assert!(!entry.nudge_option(2, false));
}
}
+74 -17
View File
@@ -68,27 +68,48 @@ fn cpu_if_enabled() -> Option<Duration> {
/// Run `f`, recording its duration under `name` when spans are enabled.
#[inline]
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
let _span = guard(name);
f()
}
/// A span that ends where it is dropped: [`time`] for a step with several ways out (early
/// returns). Nothing is read while spans are off.
#[must_use = "the span ends when the guard is dropped"]
pub struct Guard {
open: Option<(&'static str, Instant, Option<Duration>, u8)>,
_heap: Option<horizon_threads::heap_census::Tag>,
}
/// Start a span named `name` that ends when the returned [`Guard`] is dropped.
#[inline]
pub fn guard(name: &'static str) -> Guard {
let _heap = horizon_threads::heap_tag(name);
if !recording_here() {
return f();
return Guard { open: None, _heap };
}
let depth = DEPTH.with(|d| d.replace(d.get().saturating_add(1)));
let (started, cpu) = (Instant::now(), cpu_if_enabled());
let out = f();
let ms = started.elapsed().as_secs_f32() * 1000.0;
let cpu_ms = cpu
.and_then(|start| Some((thread_cpu()?, start)))
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
DEPTH.with(|d| d.set(depth));
SPANS.with(|s| {
s.borrow_mut().push(Span {
name,
ms,
cpu_ms,
depth,
})
});
out
Guard {
open: Some((name, Instant::now(), cpu_if_enabled(), depth)),
_heap,
}
}
impl Drop for Guard {
fn drop(&mut self) {
let Some((name, started, cpu, depth)) = self.open.take() else {
return;
};
let ms = started.elapsed().as_secs_f32() * 1000.0;
let cpu_ms = cpu
.and_then(|start| Some((thread_cpu()?, start)))
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
DEPTH.with(|d| d.set(depth));
SPANS.with(|s| {
s.borrow_mut().push(Span {
name,
ms,
cpu_ms,
depth,
})
});
}
}
/// Notes that one step inside a span took `ms` milliseconds doing `what` (one player's
/// packets, say), when this thread records spans and the step took at least [`NOTE_MS`]: a
@@ -551,6 +572,42 @@ mod tests {
.unwrap();
}
/// A guarded span ends on every way out of its step, an early return's too, and holds the
/// spans that ran inside it until then; the next span outside it is back at its depth.
#[test]
fn a_guarded_span_ends_where_its_step_returns() {
fn step(early: bool) -> u32 {
let _span = super::guard("use_block");
super::time("open_loot", || ());
if early {
return 1;
}
super::time("open_maps", || ());
2
}
std::thread::spawn(|| {
super::record_here(true);
assert_eq!(step(true), 1);
assert_eq!(step(false), 2);
super::time("use_item", || ());
let raw = super::take_raw();
let seen: Vec<_> = raw.iter().map(|s| (s.name, s.depth)).collect();
assert_eq!(
seen,
[
("open_loot", 1),
("use_block", 0),
("open_loot", 1),
("open_maps", 1),
("use_block", 0),
("use_item", 0)
]
);
})
.join()
.unwrap();
}
/// A slow frame is put down to the slowest outer span, then to the span inside it that took
/// at least half of it; repeated spans (several ticks a frame) add up.
#[test]
@@ -0,0 +1,162 @@
//! An ordinary read of a struct or buffer-block member that an atomic function also uses gets
//! the member's current value (#9081), on a host GPU through the translated program.
use horizon_shaders::{Macros, Pack, preprocess, translate_compute};
/// Runs compute program `source` once (one work group), with `probe_buffer` (set 4, binding 2)
/// starting at `start`, and returns `probe_result.result` (set 4, binding 1).
fn run(device: &wgpu::Device, queue: &wgpu::Queue, source: &str, start: [u32; 2]) -> u32 {
use std::io::Write;
use wgpu::util::DeviceExt;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
zip.start_file(
"shaders/deferred.csh",
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
let pack =
Pack::from_zip_bytes("atomic-values.zip", zip.finish().unwrap().into_inner()).unwrap();
let program = translate_compute(&preprocess(&pack, "deferred.csh", &Macros::standard()))
.unwrap_or_else(|e| panic!("{e:?}"));
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(program.module)),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: None,
layout: None,
module: &module,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
let storage = wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC;
let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(program.interface.uniform_size.max(48)),
usage: wgpu::BufferUsages::UNIFORM,
mapped_at_creation: false,
});
let result = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: &[0; 4],
usage: storage,
});
let probe: Vec<u8> = start.iter().flat_map(|v| v.to_le_bytes()).collect();
let probe = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: &probe,
usage: storage,
});
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 4,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let groups: Vec<wgpu::BindGroup> = (0..5)
.map(|set| {
let entries: Vec<wgpu::BindGroupEntry> = match set {
0 => vec![wgpu::BindGroupEntry {
binding: 0,
resource: uniforms.as_entire_binding(),
}],
4 if source.contains("probe_buffer") => vec![
wgpu::BindGroupEntry {
binding: 1,
resource: result.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: probe.as_entire_binding(),
},
],
4 => vec![wgpu::BindGroupEntry {
binding: 1,
resource: result.as_entire_binding(),
}],
_ => Vec::new(),
};
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(set),
entries: &entries,
})
})
.collect();
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_compute_pass(&Default::default());
pass.set_pipeline(&pipeline);
for (set, group) in groups.iter().enumerate() {
pass.set_bind_group(set as u32, group, &[]);
}
pass.dispatch_workgroups(1, 1, 1);
}
encoder.copy_buffer_to_buffer(&result, 0, &read, 0, 4);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = read.slice(..).get_mapped_range().unwrap();
u32::from_le_bytes(bytes[..4].try_into().unwrap())
}
#[test]
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
fn reads_of_atomic_members_see_the_atomic_updates() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
eprintln!("Atomic member GPU: {}", adapter.get_info().name);
// Pack buffers sit in bind group 4, as in the game.
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: wgpu::Limits {
max_bind_groups: 5,
..Default::default()
},
..Default::default()
}))
.unwrap();
let result = "layout(std430, binding = 1) buffer probe_result { uint result; };\n";
// Eight invocations each add 1 to a zeroed shared member, then read it: 8.
for (host, member) in [
("shared struct { uint other; uint count; } sh;", "sh.count"),
(
"struct Counter { uint count; uint other; };\nshared Counter counters[2];",
"counters[1].count",
),
] {
for (read, expected) in [
("result = M * 10u + 1u;", 81),
("uint r = M; result = r;", 8),
("result = uint(float(M) * 0.5);", 4),
("result = min(M, 5u);", 5),
] {
let source = format!(
"#version 460 core\nlayout(local_size_x = 8) in;\n{host}\n{result}\
void main() {{\n {member} = 0u;\n barrier();\n \
atomicAdd({member}, 1u);\n barrier();\n {}\n}}\n",
read.replace('M', member)
);
assert_eq!(
run(&device, &queue, &source, [0, 0]),
expected,
"{member}: {read}"
);
}
}
// One invocation adds 2 to a buffer member that starts at 5, then reads it: 7.
for (read, expected) in [
("result = pc.count * 10u + 1u;", 71),
("uint r = pc.count; result = r;", 7),
("result = uint(int(pc.count) - 10 + 100);", 97),
] {
let source = format!(
"#version 460 core\nlayout(local_size_x = 1) in;\n\
layout(std430, binding = 2) buffer probe_buffer {{ uint count; uint other; }} pc;\n\
{result}void main() {{\n atomicAdd(pc.count, 2u);\n {read}\n}}\n"
);
assert_eq!(run(&device, &queue, &source, [5, 0]), expected, "{read}");
}
}
@@ -0,0 +1,208 @@
//! Synthetic rendering checks (two output shapes mirror RenderPearl v2.7.2 @86de79c, MIT; see the
//! PR): a fragment output's `component` layout qualifier (GLSL 4.60, 4.4.2) picks the channels of
//! its location that it writes.
use horizon_shaders::{Origin, Preprocessed, Target, translate_composite};
fn source(code: &str) -> Preprocessed {
Preprocessed {
code: code.into(),
files: vec!["components.glsl".into()],
lines: code
.lines()
.enumerate()
.map(|(i, _)| Origin {
file: 0,
line: i as u32 + 1,
})
.collect(),
version: Some((440, None)),
..Default::default()
}
}
/// Draws `fragment` over a 1×1 target of `format` and reads back its 16 bytes.
fn draw(
device: &wgpu::Device,
queue: &wgpu::Queue,
fragment: &str,
format: wgpu::TextureFormat,
) -> [u8; 16] {
let program = translate_composite(
&source("void main() { gl_Position = ftransform(); }"),
&source(fragment),
Target::Buffers,
)
.unwrap_or_else(|e| panic!("{fragment}: {e:?}"));
let module = |module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
})
};
let (vertex, fragment_module) = (module(program.vertex), module(program.fragment));
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &vertex,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleStrip,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fragment_module,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let extent = wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
};
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: extent,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let view = target.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
// The composite program's uniform block, if it kept one, reads zero.
if program.interface.uniform_size > 0 {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(program.interface.uniform_size).max(16),
usage: wgpu::BufferUsages::UNIFORM,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
pass.set_bind_group(0, &group, &[]);
}
pass.draw(0..4, 0..1);
}
encoder.copy_texture_to_buffer(
target.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: Some(1),
},
},
extent,
);
queue.submit([encoder.finish()]);
readback
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = readback.slice(..).get_mapped_range().unwrap()[..16]
.try_into()
.unwrap();
readback.unmap();
bytes
}
#[test]
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
fn component_qualified_outputs_write_their_own_channels() {
let instance =
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
.expect("GPU adapter required; missing adapter is not a pass");
eprintln!("Output components GPU: {:?}", adapter.get_info());
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
// The channels no output writes keep the fill of a narrow output: 0, and 1 in alpha.
for (name, fragment, expected) in [
(
"uvec3 at component 1",
"layout(location = 0, component = 1) out uvec3 words;\n\
void main() { words = uvec3(7u, 8u, 9u); }",
[0u32, 7, 8, 9],
),
(
"uint at component 2",
"layout(location = 0, component = 2) out uint word;\n\
void main() { word = 5u; }",
[0, 0, 5, 1],
),
(
"two outputs share location 0",
"layout(location = 0, component = 0) out uint low;\n\
layout(location = 0, component = 1) out uvec3 high;\n\
void main() { low = 3u; high = uvec3(4u, 5u, 6u); }",
[3, 4, 5, 6],
),
(
"the component 1 output declared first",
"layout(location = 0, component = 1) out uvec2 middle;\n\
layout(location = 0) out uint low;\n\
void main() { low = 11u; middle = uvec2(12u, 13u); }",
[11, 12, 13, 1],
),
] {
let bytes = draw(&device, &queue, fragment, wgpu::TextureFormat::Rgba32Uint);
let actual: [u32; 4] = bytemuck::cast(bytes);
eprintln!("{name}: {actual:?}");
assert_eq!(actual, expected, "{name}");
}
let bytes = draw(
&device,
&queue,
"layout(location = 0, component = 0) out float red;\n\
layout(location = 0, component = 1) out vec3 rest;\n\
void main() { red = 0.0; rest = vec3(1.0, 0.0, 1.0); }",
wgpu::TextureFormat::Rgba32Float,
);
let actual: [f32; 4] = bytemuck::cast(bytes);
eprintln!("float outputs share location 0: {actual:?}");
assert_eq!(actual, [0.0, 1.0, 0.0, 1.0]);
}
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "Bewegingsdooiesone",
"horizon.options.look_deadzone": "Kykdooiesone",
"horizon.options.button_glyphs": "Knoppie Glyphs",
"horizon.options.menu_navigation": "Kieslysnavigasie",
"horizon.options.on_screen_keyboard": "Sleutelbord op die skerm",
"horizon.options.chat_delay": "Chat Vertraging",
"horizon.options.lightbar.health": "Gesondheid",
"horizon.options.lightbar.dimension": "Dimensie",
"horizon.options.glyphs.auto": "Outo",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Wyser",
"horizon.options.keyboard.auto": "Outo",
"horizon.options.unavailable": "Hierdie kenmerk is nog nie in Horizon beskikbaar nie.",
"horizon.options.search": "Soek instellings...",
@@ -10,7 +10,6 @@
"horizon.options.move_deadzone": "المنطقة الميتة للحركة",
"horizon.options.look_deadzone": "المنطقة الميتة للنظر",
"horizon.options.button_glyphs": "الحروف الرسومية للأزرار",
"horizon.options.menu_navigation": "التنقل في القائمة",
"horizon.options.on_screen_keyboard": "لوحة المفاتيح على الشاشة",
"horizon.options.chat_delay": "تأخير الدردشة",
"horizon.options.lightbar.health": "الصحة",
@@ -19,8 +18,6 @@
"horizon.options.glyphs.xbox": "اكس بوكس",
"horizon.options.glyphs.playstation": "بلاي ستيشن",
"horizon.options.glyphs.nintendo": "نينتندو",
"horizon.options.menu_focus": "التركيز",
"horizon.options.menu_pointer": "المؤشر",
"horizon.options.keyboard.auto": "تلقائي",
"horizon.options.keyboard.steam": "البخار",
"horizon.options.unavailable": "هذه الميزة غير متوفرة بعد في Horizon.",
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "Hərəkət ölü zonası",
"horizon.options.look_deadzone": "Baxış ölü zonası",
"horizon.options.button_glyphs": "Düymə Qlifləri",
"horizon.options.menu_navigation": "Menyu Naviqasiyası",
"horizon.options.on_screen_keyboard": "Ekran Klaviaturası",
"horizon.options.chat_delay": "Söhbət Gecikməsi",
"horizon.options.lightbar.health": "Sağlamlıq",
"horizon.options.lightbar.dimension": "Ölçü",
"horizon.options.glyphs.auto": "Avtomatik",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Göstərici",
"horizon.options.keyboard.auto": "Avtomatik",
"horizon.options.unavailable": "Bu funksiya hələ Horizon ərazisində mövcud deyil.",
"horizon.options.search": "Axtarış parametrləri...",
@@ -11,14 +11,11 @@
"horizon.options.move_deadzone": "Хәрәкәт үле зонаһы",
"horizon.options.look_deadzone": "Ҡараш үле зонаһы",
"horizon.options.button_glyphs": "Төймә глифтары",
"horizon.options.menu_navigation": "Навигация меню",
"horizon.options.on_screen_keyboard": "Экрандағы клавиатура",
"horizon.options.chat_delay": "Чат тотҡарланыуы",
"horizon.options.lightbar.health": "Һаулыҡ",
"horizon.options.lightbar.dimension": "Үлсәм",
"horizon.options.glyphs.auto": "Авто",
"horizon.options.menu_focus": "Фокус",
"horizon.options.menu_pointer": "Күрһәткес",
"horizon.options.keyboard.auto": "Авто",
"horizon.options.unavailable": "Был функция Horizon-ла әлегә юҡ.",
"horizon.options.search": "Эҙләү параметрҙары...",
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "Мёртвая зона руху",
"horizon.options.look_deadzone": "Мёртвая зона агляду",
"horizon.options.button_glyphs": "Гліфы кнопак",
"horizon.options.menu_navigation": "Навігацыя па меню",
"horizon.options.on_screen_keyboard": "Экранная клавіятура",
"horizon.options.chat_delay": "Затрымка чата",
"horizon.options.lightbar.health": "здароўе",
"horizon.options.lightbar.dimension": "Вымярэнне",
"horizon.options.glyphs.auto": "Аўто",
"horizon.options.menu_focus": "Фокус",
"horizon.options.menu_pointer": "Паказальнік",
"horizon.options.keyboard.auto": "Аўто",
"horizon.options.unavailable": "Гэтая функцыя яшчэ недаступная ў Horizon.",
"horizon.options.search": "Налады пошуку...",
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "Мъртва зона на движението",
"horizon.options.look_deadzone": "Мъртва зона на погледа",
"horizon.options.button_glyphs": "Глифове на бутони",
"horizon.options.menu_navigation": "Навигация в менюто",
"horizon.options.on_screen_keyboard": "Екранна клавиатура",
"horizon.options.chat_delay": "Забавяне на чата",
"horizon.options.lightbar.health": "здраве",
"horizon.options.lightbar.dimension": "Измерение",
"horizon.options.glyphs.auto": "Авто",
"horizon.options.menu_focus": "Фокус",
"horizon.options.menu_pointer": "показалец",
"horizon.options.keyboard.auto": "Авто",
"horizon.options.keyboard.steam": "Пара",
"horizon.options.unavailable": "Тази функция все още не е налична в Horizon.",
@@ -11,14 +11,11 @@
"horizon.options.move_deadzone": "Takad marv ar fiñval",
"horizon.options.look_deadzone": "Takad marv ar sell",
"horizon.options.button_glyphs": "Glifoù bouton",
"horizon.options.menu_navigation": "Merdeiñ dre ar meni",
"horizon.options.on_screen_keyboard": "Klavier war ar skramm",
"horizon.options.chat_delay": "Dale flapañ",
"horizon.options.lightbar.health": "Yec'hed",
"horizon.options.lightbar.dimension": "Ment",
"horizon.options.glyphs.auto": "Emgefreek",
"horizon.options.menu_focus": "Lakaat ar gaoz",
"horizon.options.menu_pointer": "Poent",
"horizon.options.keyboard.auto": "Emgefreek",
"horizon.options.unavailable": "N'eo ket hegerz an elfenn-mañ c'hoazh e Horizon.",
"horizon.options.search": "Arventennoù klask...",
@@ -10,13 +10,11 @@
"horizon.options.move_deadzone": "Zona morta del moviment",
"horizon.options.look_deadzone": "Zona morta de la vista",
"horizon.options.button_glyphs": "Glifos de botons",
"horizon.options.menu_navigation": "Navegació del menú",
"horizon.options.on_screen_keyboard": "Teclat en pantalla",
"horizon.options.chat_delay": "Retard de xat",
"horizon.options.lightbar.health": "Salut",
"horizon.options.lightbar.dimension": "Dimensió",
"horizon.options.glyphs.auto": "Automàtic",
"horizon.options.menu_pointer": "Apuntador",
"horizon.options.keyboard.auto": "Automàtic",
"horizon.options.unavailable": "Aquesta funció encara no està disponible a Horizon.",
"horizon.options.search": "Configuració de cerca...",
@@ -10,13 +10,10 @@
"horizon.options.move_deadzone": "Mrtvá zóna pohybu",
"horizon.options.look_deadzone": "Mrtvá zóna pohledu",
"horizon.options.button_glyphs": "Glyfy tlačítek",
"horizon.options.menu_navigation": "Navigace v menu",
"horizon.options.on_screen_keyboard": "Klávesnice na obrazovce",
"horizon.options.chat_delay": "Zpoždění chatu",
"horizon.options.lightbar.health": "Zdraví",
"horizon.options.lightbar.dimension": "Dimenze",
"horizon.options.menu_focus": "Zaměřte se",
"horizon.options.menu_pointer": "Ukazatel",
"horizon.options.keyboard.horizon": "Horizont",
"horizon.options.keyboard.steam": "Pára",
"horizon.options.unavailable": "Tato funkce zatím není v Horizon k dispozici.",
@@ -11,14 +11,11 @@
"horizon.options.move_deadzone": "Куҫӑмӑн вилӗ зони",
"horizon.options.look_deadzone": "Пӑхӑвӑн вилӗ зони",
"horizon.options.button_glyphs": "Кнопка глифӗсем",
"horizon.options.menu_navigation": "Меню навигацийӗ",
"horizon.options.on_screen_keyboard": "Экран ҫинчи клавиатура",
"horizon.options.chat_delay": "Чат кая юлни",
"horizon.options.lightbar.health": "Сывлӑх",
"horizon.options.lightbar.dimension": "Виҫе",
"horizon.options.glyphs.auto": "Авто",
"horizon.options.menu_focus": "Фокус",
"horizon.options.menu_pointer": "Указатель",
"horizon.options.keyboard.auto": "Авто",
"horizon.options.unavailable": "Ку функци Horizon-ра хальлӗхе ҫук-ха.",
"horizon.options.search": "Шырав настройкисем...",
@@ -10,13 +10,10 @@
"horizon.options.move_deadzone": "Parth marw symud",
"horizon.options.look_deadzone": "Parth marw edrych",
"horizon.options.button_glyphs": "Glyffau Botwm",
"horizon.options.menu_navigation": "Llywio Dewislen",
"horizon.options.on_screen_keyboard": "Bysellfwrdd Ar-Sgrin",
"horizon.options.chat_delay": "Oedi Sgwrsio",
"horizon.options.lightbar.health": "Iechyd",
"horizon.options.lightbar.dimension": "Dimensiwn",
"horizon.options.menu_focus": "Ffocws",
"horizon.options.menu_pointer": "Pwyntiwr",
"horizon.options.unavailable": "Nid yw'r nodwedd hon ar gael eto yn Horizon.",
"horizon.options.search": "Gosodiadau chwilio...",
"horizon.pages.shader_packs": "Pecynnau Shader",
@@ -8,11 +8,9 @@
"horizon.options.move_deadzone": "Dødzone for bevægelse",
"horizon.options.look_deadzone": "Dødzone for kig",
"horizon.options.button_glyphs": "Knap Glyfer",
"horizon.options.menu_navigation": "Menu navigation",
"horizon.options.on_screen_keyboard": "Skærmtastatur",
"horizon.options.chat_delay": "Chatforsinkelse",
"horizon.options.lightbar.health": "Sundhed",
"horizon.options.menu_focus": "Fokus",
"horizon.options.keyboard.steam": "Damp",
"horizon.options.unavailable": "Denne funktion er endnu ikke tilgængelig i Horizon.",
"horizon.options.search": "Søgeindstillinger...",
@@ -11,7 +11,6 @@
"horizon.options.move_deadzone": "Totzone der Bewegung",
"horizon.options.look_deadzone": "Totzone des Blicks",
"horizon.options.button_glyphs": "Tastensymbole",
"horizon.options.menu_navigation": "Menünavigation",
"horizon.options.on_screen_keyboard": "Bildschirmtastatur",
"horizon.options.chat_delay": "Chatverzögerung",
"horizon.options.lightbar.health": "Gesundheit",
@@ -20,8 +19,6 @@
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Zeiger",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
@@ -9,14 +9,11 @@
"horizon.options.move_deadzone": "Νεκρή ζώνη κίνησης",
"horizon.options.look_deadzone": "Νεκρή ζώνη κάμερας",
"horizon.options.button_glyphs": "Γλυφικά κουμπιών",
"horizon.options.menu_navigation": "Πλοήγηση μενού",
"horizon.options.on_screen_keyboard": "Πληκτρολόγιο οθόνης",
"horizon.options.chat_delay": "Καθυστέρηση συνομιλίας",
"horizon.options.lightbar.health": "Υγεία",
"horizon.options.lightbar.dimension": "Διάσταση",
"horizon.options.glyphs.auto": "Αυτόματο",
"horizon.options.menu_focus": "Εστίαση",
"horizon.options.menu_pointer": "Δείκτης",
"horizon.options.keyboard.auto": "Αυτόματο",
"horizon.options.unavailable": "Αυτή η δυνατότητα δεν είναι ακόμη διαθέσιμη στο Horizon.",
"horizon.options.search": "Ρυθμίσεις αναζήτησης...",
@@ -11,7 +11,6 @@
"horizon.options.move_deadzone": "Move Deadzone",
"horizon.options.look_deadzone": "Look Deadzone",
"horizon.options.button_glyphs": "Button Glyphs",
"horizon.options.menu_navigation": "Menu Navigation",
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
"horizon.options.chat_delay": "Chat Delay",
"horizon.options.lightbar.health": "Health",
@@ -20,8 +19,6 @@
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Focus",
"horizon.options.menu_pointer": "Pointer",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
@@ -522,5 +519,10 @@
"horizon.extras.cosmetics_auras.tooltip": "Auras and trails other players wear. Off by default.",
"horizon.extras.cosmetics.shown": "Shown",
"horizon.extras.cosmetics.hidden": "Hidden",
"horizon.extras.cosmetics.name_tags": "Name tags only"
"horizon.extras.cosmetics.name_tags": "Name tags only",
"horizon.options.pointer_speed": "Menu Pointer Speed",
"horizon.options.scroll_speed": "Menu Scroll Speed",
"horizon.options.repeat_delay": "Navigation Repeat Delay",
"horizon.options.repeat_interval": "Navigation Repeat Interval",
"horizon.options.milliseconds": "%s ms"
}
@@ -11,14 +11,11 @@
"horizon.options.move_deadzone": "Mova mortzono",
"horizon.options.look_deadzone": "Rigarda mortzono",
"horizon.options.button_glyphs": "Butonaj Glifoj",
"horizon.options.menu_navigation": "Menuo Navigado",
"horizon.options.on_screen_keyboard": "Surekrana Klavaro",
"horizon.options.chat_delay": "Babilado Prokrasto",
"horizon.options.lightbar.health": "Sano",
"horizon.options.lightbar.dimension": "Dimensio",
"horizon.options.glyphs.auto": "Aŭtomate",
"horizon.options.menu_focus": "Fokuso",
"horizon.options.menu_pointer": "Montrilo",
"horizon.options.keyboard.auto": "Aŭtomate",
"horizon.options.unavailable": "Ĉi tiu funkcio ankoraŭ ne disponeblas en Horizon.",
"horizon.options.search": "Serĉaj agordoj...",
@@ -10,7 +10,6 @@
"horizon.options.move_deadzone": "Zona muerta de movimiento",
"horizon.options.look_deadzone": "Zona muerta de vista",
"horizon.options.button_glyphs": "Símbolos de botones",
"horizon.options.menu_navigation": "Navegación de menús",
"horizon.options.on_screen_keyboard": "Teclado en pantalla",
"horizon.options.chat_delay": "Retardo del chat",
"horizon.options.lightbar.health": "Salud",
@@ -19,8 +18,6 @@
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Foco",
"horizon.options.menu_pointer": "Puntero",
"horizon.options.keyboard.auto": "Automático",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "Liikumise surnud tsoon",
"horizon.options.look_deadzone": "Vaate surnud tsoon",
"horizon.options.button_glyphs": "Nuppude glüüfid",
"horizon.options.menu_navigation": "Menüüs navigeerimine",
"horizon.options.on_screen_keyboard": "Ekraaniklaviatuur",
"horizon.options.chat_delay": "Vestluse viivitus",
"horizon.options.lightbar.health": "Tervis",
"horizon.options.lightbar.dimension": "Mõõtmed",
"horizon.options.glyphs.auto": "Automaatne",
"horizon.options.menu_focus": "Keskendu",
"horizon.options.menu_pointer": "Osuti",
"horizon.options.keyboard.auto": "Automaatne",
"horizon.options.unavailable": "See funktsioon pole Horizonis veel saadaval.",
"horizon.options.search": "Otsinguseaded...",
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "Mugimenduaren zona hila",
"horizon.options.look_deadzone": "Begiradaren zona hila",
"horizon.options.button_glyphs": "Botoien glifoak",
"horizon.options.menu_navigation": "Menuen nabigazioa",
"horizon.options.on_screen_keyboard": "Pantailako teklatua",
"horizon.options.chat_delay": "Txat-atzerapena",
"horizon.options.lightbar.health": "Osasuna",
"horizon.options.lightbar.dimension": "Dimentsioa",
"horizon.options.glyphs.auto": "Autoa",
"horizon.options.menu_focus": "Fokua",
"horizon.options.menu_pointer": "Erakuslea",
"horizon.options.keyboard.auto": "Autoa",
"horizon.options.unavailable": "Eginbide hau oraindik ez dago erabilgarri Horizon-n.",
"horizon.options.search": "Bilaketa-ezarpenak...",
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "ناحیه مرده حرکت",
"horizon.options.look_deadzone": "ناحیه مرده نگاه",
"horizon.options.button_glyphs": "گلیف دکمه",
"horizon.options.menu_navigation": "پیمایش منو",
"horizon.options.on_screen_keyboard": "صفحه کلید روی صفحه",
"horizon.options.chat_delay": "تاخیر چت",
"horizon.options.lightbar.health": "سلامتی",
"horizon.options.lightbar.dimension": "بعد",
"horizon.options.glyphs.auto": "خودکار",
"horizon.options.menu_focus": "تمرکز کنید",
"horizon.options.menu_pointer": "اشاره گر",
"horizon.options.keyboard.auto": "خودکار",
"horizon.options.keyboard.steam": "بخار",
"horizon.options.unavailable": "این ویژگی هنوز در Horizon موجود نیست.",
@@ -9,13 +9,10 @@
"horizon.options.move_deadzone": "Liikkeen kuollut alue",
"horizon.options.look_deadzone": "Katseen kuollut alue",
"horizon.options.button_glyphs": "Painikkeiden kuviot",
"horizon.options.menu_navigation": "Valikkonavigointi",
"horizon.options.on_screen_keyboard": "Näyttönäppäimistö",
"horizon.options.chat_delay": "Chatin viive",
"horizon.options.lightbar.health": "Terveys",
"horizon.options.lightbar.dimension": "Mitat",
"horizon.options.menu_focus": "Keskity",
"horizon.options.menu_pointer": "Osoitin",
"horizon.options.unavailable": "Tämä ominaisuus ei ole vielä saatavilla Horizonissa.",
"horizon.options.search": "Hakuasetukset...",
"horizon.pages.mods": "Modit",
@@ -8,7 +8,6 @@
"horizon.options.move_deadzone": "Deadzone ng Paggalaw",
"horizon.options.look_deadzone": "Deadzone ng Tingin",
"horizon.options.button_glyphs": "Mga Glyph ng Button",
"horizon.options.menu_navigation": "Navigation ng Menu",
"horizon.options.on_screen_keyboard": "On-Screen na Keyboard",
"horizon.options.chat_delay": "Pagkaantala sa Chat",
"horizon.options.lightbar.health": "Kalusugan",
@@ -11,13 +11,10 @@
"horizon.options.move_deadzone": "Deyðaøki fyri flyting",
"horizon.options.look_deadzone": "Deyðaøki fyri hyggjan",
"horizon.options.button_glyphs": "Knappaglyfur",
"horizon.options.menu_navigation": "Valmyndanavigatión",
"horizon.options.on_screen_keyboard": "Lyklaborð á skjánum",
"horizon.options.chat_delay": "Kjakseinking",
"horizon.options.lightbar.health": "Heilsa",
"horizon.options.lightbar.dimension": "Mát",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Peikari",
"horizon.options.unavailable": "Hesin hentleikin er ikki tøkur enn í Horizon.",
"horizon.options.search": "Leiti innstillingar...",
"horizon.pages.shader_packs": "Skuggapakkar",
@@ -10,11 +10,9 @@
"horizon.options.move_deadzone": "Zone morte du déplacement",
"horizon.options.look_deadzone": "Zone morte de la vue",
"horizon.options.button_glyphs": "Symboles des boutons",
"horizon.options.menu_navigation": "Navigation dans les menus",
"horizon.options.on_screen_keyboard": "Clavier à l'écran",
"horizon.options.chat_delay": "Délai du tchat",
"horizon.options.lightbar.health": "Santé",
"horizon.options.menu_pointer": "Pointeur",
"horizon.options.unavailable": "Cette fonction n'est pas encore disponible dans Horizon.",
"horizon.options.search": "Rechercher une option...",
"horizon.pages.shader_packs": "Packs de shaders",
@@ -443,5 +441,10 @@
"horizon.repro.bundle": "Archive de reproduction : %s",
"horizon.repro.error": "Reproduction : %s",
"horizon.repro.no_recording": "Il n'y a pas encore d'enregistrement à exporter.",
"horizon.repro.replay_started": "Lecture démarrée : %s"
"horizon.repro.replay_started": "Lecture démarrée : %s",
"horizon.options.pointer_speed": "Vitesse du pointeur des menus",
"horizon.options.scroll_speed": "Vitesse de défilement des menus",
"horizon.options.repeat_delay": "Délai de répétition de navigation",
"horizon.options.repeat_interval": "Intervalle de répétition de navigation",
"horizon.options.milliseconds": "%s ms"
}
@@ -11,12 +11,9 @@
"horizon.options.move_deadzone": "Zone muarte dal moviment",
"horizon.options.look_deadzone": "Zone muarte dal sguard",
"horizon.options.button_glyphs": "Glifs dai botôns",
"horizon.options.menu_navigation": "Navigazion dal menût",
"horizon.options.on_screen_keyboard": "Tastier sul schermi",
"horizon.options.chat_delay": "Ritart de conversazion",
"horizon.options.lightbar.health": "Salût",
"horizon.options.menu_focus": "Concentrâsi",
"horizon.options.menu_pointer": "Puntôr",
"horizon.options.unavailable": "Cheste funzion no je ancjemò disponibile in Horizon.",
"horizon.options.search": "Impostazions di ricercje...",
"horizon.pages.shader_packs": "Pachetis ombrenadôrs",
@@ -8,10 +8,8 @@
"horizon.options.move_deadzone": "Deadzone beweging",
"horizon.options.look_deadzone": "Deadzone kamera",
"horizon.options.button_glyphs": "Knop Glyphs",
"horizon.options.menu_navigation": "Menu Navigaasje",
"horizon.options.chat_delay": "Petear fertraging",
"horizon.options.lightbar.dimension": "Diminsje",
"horizon.options.menu_focus": "Fokus",
"horizon.options.unavailable": "Dizze funksje is noch net beskikber yn Horizon.",
"horizon.options.search": "Ynstellings sykje...",
"horizon.pages.shader_options": "Shader Opsjes",
@@ -10,13 +10,10 @@
"horizon.options.move_deadzone": "Crios marbh gluaiseachta",
"horizon.options.look_deadzone": "Crios marbh amhairc",
"horizon.options.button_glyphs": "Glyphs Cnaipe",
"horizon.options.menu_navigation": "Nascleanúint Roghchláir",
"horizon.options.on_screen_keyboard": "Méarchlár ar an Scáileán",
"horizon.options.chat_delay": "Moill Comhrá",
"horizon.options.lightbar.health": "Sláinte",
"horizon.options.lightbar.dimension": "Toise",
"horizon.options.menu_focus": "Fócas",
"horizon.options.menu_pointer": "Pointeoir",
"horizon.options.unavailable": "Níl an ghné seo ar fáil fós i Horizon.",
"horizon.options.search": "Socruithe cuardaigh...",
"horizon.pages.shader_packs": "Pacáistí Shader",
@@ -11,13 +11,10 @@
"horizon.options.move_deadzone": "Raon marbh a' ghluasaid",
"horizon.options.look_deadzone": "Raon marbh a' chamara",
"horizon.options.button_glyphs": "Glyphs putan",
"horizon.options.menu_navigation": "Seòladh clàr-taice",
"horizon.options.on_screen_keyboard": "Meur-chlàr air-sgrion",
"horizon.options.chat_delay": "Dàil Chat",
"horizon.options.lightbar.health": "Slàinte",
"horizon.options.lightbar.dimension": "Tomhas",
"horizon.options.menu_focus": "Fòcas",
"horizon.options.menu_pointer": "Puingeadair",
"horizon.options.unavailable": "Chan eil am feart seo ri fhaighinn fhathast ann an Horizon.",
"horizon.options.search": "Rannsaich roghainnean...",
"horizon.pages.mods": "Mòdan",
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "Zona morta do movemento",
"horizon.options.look_deadzone": "Zona morta da mirada",
"horizon.options.button_glyphs": "Glifos de botón",
"horizon.options.menu_navigation": "Navegación por menús",
"horizon.options.on_screen_keyboard": "Teclado en pantalla",
"horizon.options.chat_delay": "Atraso do chat",
"horizon.options.lightbar.health": "Saúde",
"horizon.options.lightbar.dimension": "Dimensión",
"horizon.options.glyphs.auto": "Automático",
"horizon.options.menu_focus": "Foco",
"horizon.options.menu_pointer": "Punteiro",
"horizon.options.keyboard.auto": "Automático",
"horizon.options.unavailable": "Esta función aínda non está dispoñible en Horizon.",
"horizon.options.search": "Configuración de busca...",
@@ -11,13 +11,10 @@
"horizon.options.move_deadzone": "Deadzone o ka neʻe",
"horizon.options.look_deadzone": "Deadzone o ka nānā",
"horizon.options.button_glyphs": "Pihi Glyphs",
"horizon.options.menu_navigation": "Papa Hoʻokele",
"horizon.options.on_screen_keyboard": "Pāpāpā-pāpāho",
"horizon.options.chat_delay": "Hoʻopaneʻe kamaʻilio",
"horizon.options.lightbar.health": "Ola kino",
"horizon.options.lightbar.dimension": "Anana",
"horizon.options.menu_focus": "Nānā",
"horizon.options.menu_pointer": "Kuhikuhi",
"horizon.options.unavailable": "ʻAʻole loaʻa kēia hiʻohiʻona ma Horizon.",
"horizon.options.search": "Nā koho hulina...",
"horizon.pages.shader_packs": "Nā Pākuʻi Shader",
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "אזור מת של התנועה",
"horizon.options.look_deadzone": "אזור מת של המבט",
"horizon.options.button_glyphs": "גליפים של כפתורים",
"horizon.options.menu_navigation": "ניווט בתפריט",
"horizon.options.on_screen_keyboard": "מקלדת על המסך",
"horizon.options.chat_delay": "עיכוב בצ'אט",
"horizon.options.lightbar.health": "בריאות",
"horizon.options.lightbar.dimension": "מימד",
"horizon.options.glyphs.auto": "אוטומטי",
"horizon.options.menu_focus": "פוקוס",
"horizon.options.menu_pointer": "מצביע",
"horizon.options.keyboard.auto": "אוטומטי",
"horizon.options.keyboard.steam": "קיטור",
"horizon.options.unavailable": "תכונה זו עדיין לא זמינה ב-Horizon.",
@@ -10,7 +10,6 @@
"horizon.options.move_deadzone": "मूव डेडज़ोन",
"horizon.options.look_deadzone": "लुक डेडज़ोन",
"horizon.options.button_glyphs": "बटन ग्लाइफ्स",
"horizon.options.menu_navigation": "मेनू नेविगेशन",
"horizon.options.on_screen_keyboard": "ऑन-स्क्रीन कीबोर्ड",
"horizon.options.chat_delay": "चैट विलंब",
"horizon.options.lightbar.health": "स्वास्थ्य",
@@ -19,8 +18,6 @@
"horizon.options.glyphs.xbox": "एक्सबॉक्स",
"horizon.options.glyphs.playstation": "प्लेस्टेशन",
"horizon.options.glyphs.nintendo": "निनटेंडो",
"horizon.options.menu_focus": "फोकस",
"horizon.options.menu_pointer": "पॉइंटर",
"horizon.options.keyboard.auto": "ऑटो",
"horizon.options.keyboard.horizon": "क्षितिज",
"horizon.options.keyboard.steam": "स्टीम",
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "Mrtva zona kretanja",
"horizon.options.look_deadzone": "Mrtva zona pogleda",
"horizon.options.button_glyphs": "Simboli gumba",
"horizon.options.menu_navigation": "Navigacija izbornikom",
"horizon.options.on_screen_keyboard": "Zaslonska tipkovnica",
"horizon.options.chat_delay": "Odgoda chata",
"horizon.options.lightbar.health": "Zdravlje",
"horizon.options.lightbar.dimension": "Dimenzija",
"horizon.options.glyphs.auto": "Automatski",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Pokazivač",
"horizon.options.keyboard.auto": "Automatski",
"horizon.options.unavailable": "Ova značajka još nije dostupna u Horizonu.",
"horizon.options.search": "Pretraži postavke...",
@@ -9,13 +9,10 @@
"horizon.options.move_deadzone": "Mozgás holtzónája",
"horizon.options.look_deadzone": "Nézet holtzónája",
"horizon.options.button_glyphs": "Gomb jelek",
"horizon.options.menu_navigation": "Menü navigáció",
"horizon.options.on_screen_keyboard": "Képernyő-billentyűzet",
"horizon.options.chat_delay": "Chat késleltetés",
"horizon.options.lightbar.health": "Egészség",
"horizon.options.lightbar.dimension": "Méret",
"horizon.options.menu_focus": "Fókusz",
"horizon.options.menu_pointer": "Mutató",
"horizon.options.keyboard.horizon": "Horizont",
"horizon.options.unavailable": "Ez a funkció még nem érhető el a Horizonban.",
"horizon.options.search": "Keresési beállítások...",
@@ -9,14 +9,11 @@
"horizon.options.move_deadzone": "Շարժման մեռյալ գոտի",
"horizon.options.look_deadzone": "Նայելու մեռյալ գոտի",
"horizon.options.button_glyphs": "Կոճակի գլիֆներ",
"horizon.options.menu_navigation": "Մենյու նավարկություն",
"horizon.options.on_screen_keyboard": "Էկրանի ստեղնաշար",
"horizon.options.chat_delay": "Զրույցի հետաձգում",
"horizon.options.lightbar.health": "Առողջություն",
"horizon.options.lightbar.dimension": "Չափս",
"horizon.options.glyphs.auto": "Ավտոմատ",
"horizon.options.menu_focus": "Կենտրոնանալ",
"horizon.options.menu_pointer": "Ցուցիչ",
"horizon.options.keyboard.auto": "Ավտոմատ",
"horizon.options.unavailable": "Այս հատկությունը դեռ հասանելի չէ Horizon-ում:",
"horizon.options.search": "Որոնման կարգավորումներ...",
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "Zona Mati Gerakan",
"horizon.options.look_deadzone": "Zona Mati Pandangan",
"horizon.options.button_glyphs": "Simbol Tombol",
"horizon.options.menu_navigation": "Navigasi Menu",
"horizon.options.on_screen_keyboard": "Papan Ketik Layar",
"horizon.options.chat_delay": "Penundaan Obrolan",
"horizon.options.lightbar.health": "Nyawa",
"horizon.options.lightbar.dimension": "Dimensi",
"horizon.options.glyphs.auto": "Otomatis",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Penunjuk",
"horizon.options.keyboard.auto": "Otomatis",
"horizon.options.unavailable": "Fitur ini belum tersedia di Horizon.",
"horizon.options.search": "Cari pengaturan...",
@@ -11,14 +11,11 @@
"horizon.options.move_deadzone": "Deadzone mmegharị",
"horizon.options.look_deadzone": "Deadzone ilegharị anya",
"horizon.options.button_glyphs": "Bọtịnụ Glyphs",
"horizon.options.menu_navigation": "Ntugharị menu",
"horizon.options.on_screen_keyboard": "Ahụigodo na ihuenyo",
"horizon.options.chat_delay": "Nkata nkata",
"horizon.options.lightbar.health": "Ahụike",
"horizon.options.lightbar.dimension": "Akụkụ",
"horizon.options.glyphs.auto": "Akpaaka",
"horizon.options.menu_focus": "Lekwasị anya",
"horizon.options.menu_pointer": "Ntuziaka",
"horizon.options.keyboard.auto": "Akpaaka",
"horizon.options.unavailable": "Njirimara a adịbeghị na Horizon.",
"horizon.options.search": "Chọọ ntọala...",
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "Dauðasvæði hreyfingar",
"horizon.options.look_deadzone": "Dauðasvæði sjónarhorns",
"horizon.options.button_glyphs": "Hnappamerki",
"horizon.options.menu_navigation": "Valmyndarleiðsögn",
"horizon.options.on_screen_keyboard": "Skjályklaborð",
"horizon.options.chat_delay": "Töf á spjalli",
"horizon.options.lightbar.health": "Heilsa",
"horizon.options.lightbar.dimension": "Stærð",
"horizon.options.glyphs.auto": "Sjálfvirk",
"horizon.options.menu_focus": "Einbeittu þér",
"horizon.options.menu_pointer": "Bendill",
"horizon.options.keyboard.auto": "Sjálfvirk",
"horizon.options.unavailable": "Þessi eiginleiki er ekki enn í boði í Horizon.",
"horizon.options.search": "Leitarstillingar...",
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "Zona morta del movimento",
"horizon.options.look_deadzone": "Zona morta della visuale",
"horizon.options.button_glyphs": "Glifi dei pulsanti",
"horizon.options.menu_navigation": "Navigazione nel menu",
"horizon.options.on_screen_keyboard": "Tastiera su schermo",
"horizon.options.chat_delay": "Ritardo nella chat",
"horizon.options.lightbar.health": "Salute",
"horizon.options.lightbar.dimension": "Dimensione",
"horizon.options.glyphs.auto": "Automatico",
"horizon.options.menu_focus": "Concentrarsi",
"horizon.options.menu_pointer": "Puntatore",
"horizon.options.keyboard.auto": "Automatico",
"horizon.options.keyboard.steam": "Vapore",
"horizon.options.unavailable": "Questa funzionalità non è ancora disponibile in Horizon.",
@@ -10,7 +10,6 @@
"horizon.options.move_deadzone": "移動のデッドゾーン",
"horizon.options.look_deadzone": "視点のデッドゾーン",
"horizon.options.button_glyphs": "ボタンのグリフ",
"horizon.options.menu_navigation": "メニューナビゲーション",
"horizon.options.on_screen_keyboard": "オンスクリーンキーボード",
"horizon.options.chat_delay": "チャットの遅延",
"horizon.options.lightbar.health": "健康",
@@ -18,8 +17,6 @@
"horizon.options.glyphs.auto": "自動",
"horizon.options.glyphs.playstation": "プレイステーション",
"horizon.options.glyphs.nintendo": "任天堂",
"horizon.options.menu_focus": "フォーカス",
"horizon.options.menu_pointer": "ポインタ",
"horizon.options.keyboard.auto": "自動",
"horizon.options.keyboard.steam": "蒸気",
"horizon.options.unavailable": "この機能は Horizon ではまだ利用できません。",
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "მოძრაობის მკვდარი ზონა",
"horizon.options.look_deadzone": "ხედვის მკვდარი ზონა",
"horizon.options.button_glyphs": "ღილაკების გლიფები",
"horizon.options.menu_navigation": "მენიუს ნავიგაცია",
"horizon.options.on_screen_keyboard": "ეკრანის კლავიატურა",
"horizon.options.chat_delay": "ჩატის დაგვიანება",
"horizon.options.lightbar.health": "ჯანმრთელობა",
"horizon.options.lightbar.dimension": "განზომილება",
"horizon.options.glyphs.auto": "ავტო",
"horizon.options.menu_focus": "ფოკუსირება",
"horizon.options.menu_pointer": "პოინტერი",
"horizon.options.keyboard.auto": "ავტო",
"horizon.options.unavailable": "ეს ფუნქცია ჯერ არ არის ხელმისაწვდომი Horizon-ში.",
"horizon.options.search": "ძებნის პარამეტრები...",
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "Қозғалыс өлі аймағы",
"horizon.options.look_deadzone": "Шолу өлі аймағы",
"horizon.options.button_glyphs": "Түйме глифтері",
"horizon.options.menu_navigation": "Мәзірді шарлау",
"horizon.options.on_screen_keyboard": "Экрандық пернетақта",
"horizon.options.chat_delay": "Чаттың кешігуі",
"horizon.options.lightbar.health": "Денсаулық",
"horizon.options.lightbar.dimension": "Өлшем",
"horizon.options.glyphs.auto": "Авто",
"horizon.options.menu_focus": "Фокус",
"horizon.options.menu_pointer": "Көрсеткіш",
"horizon.options.keyboard.auto": "Авто",
"horizon.options.unavailable": "Бұл мүмкіндік Horizon ішінде әлі қолжетімді емес.",
"horizon.options.search": "Параметрлерді іздеу...",
@@ -11,14 +11,11 @@
"horizon.options.move_deadzone": "ಚಲನೆಯ ಡೆಡ್‌ಜೋನ್",
"horizon.options.look_deadzone": "ಕ್ಯಾಮೆರಾ ಡೆಡ್‌ಜೋನ್",
"horizon.options.button_glyphs": "ಬಟನ್ ಗ್ಲಿಫ್ಸ್",
"horizon.options.menu_navigation": "ಮೆನು ನ್ಯಾವಿಗೇಷನ್",
"horizon.options.on_screen_keyboard": "ಆನ್-ಸ್ಕ್ರೀನ್ ಕೀಬೋರ್ಡ್",
"horizon.options.chat_delay": "ಚಾಟ್ ವಿಳಂಬ",
"horizon.options.lightbar.health": "ಆರೋಗ್ಯ",
"horizon.options.lightbar.dimension": "ಆಯಾಮ",
"horizon.options.glyphs.auto": "ಆಟೋ",
"horizon.options.menu_focus": "ಗಮನ",
"horizon.options.menu_pointer": "ಪಾಯಿಂಟರ್",
"horizon.options.keyboard.auto": "ಆಟೋ",
"horizon.options.unavailable": "ಈ ವೈಶಿಷ್ಟ್ಯವು ಇನ್ನೂ Horizon ನಲ್ಲಿ ಲಭ್ಯವಿಲ್ಲ.",
"horizon.options.search": "ಹುಡುಕಾಟ ಸೆಟ್ಟಿಂಗ್‌ಗಳು...",
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "이동 데드존",
"horizon.options.look_deadzone": "시점 데드존",
"horizon.options.button_glyphs": "버튼 아이콘",
"horizon.options.menu_navigation": "메뉴 탐색",
"horizon.options.on_screen_keyboard": "화면 키보드",
"horizon.options.chat_delay": "채팅 지연",
"horizon.options.lightbar.health": "체력",
"horizon.options.lightbar.dimension": "차원",
"horizon.options.glyphs.auto": "자동",
"horizon.options.menu_focus": "포커스",
"horizon.options.menu_pointer": "포인터",
"horizon.options.keyboard.auto": "자동",
"horizon.options.unavailable": "이 기능은 아직 Horizon에서 사용할 수 없습니다.",
"horizon.options.search": "설정 검색...",
@@ -9,14 +9,11 @@
"horizon.options.move_deadzone": "Кыймылдын өлүк аймагы",
"horizon.options.look_deadzone": "Камеранын өлүк аймагы",
"horizon.options.button_glyphs": "Баскыч Glyphs",
"horizon.options.menu_navigation": "Меню навигациясы",
"horizon.options.on_screen_keyboard": "Экрандагы клавиатура",
"horizon.options.chat_delay": "Маектешүү кечигүү",
"horizon.options.lightbar.health": "Ден соолук",
"horizon.options.lightbar.dimension": "Өлчөм",
"horizon.options.glyphs.auto": "Авто",
"horizon.options.menu_focus": "Фокус",
"horizon.options.menu_pointer": "Көрсөткүч",
"horizon.options.keyboard.auto": "Авто",
"horizon.options.unavailable": "Бул функция азырынча Horizon ичинде жеткиликтүү эмес.",
"horizon.options.search": "Издөө жөндөөлөрү...",
@@ -11,7 +11,6 @@
"horizon.options.on_screen_keyboard": "De-Screen Cantiones",
"horizon.options.chat_delay": "Curabitur Mora",
"horizon.options.lightbar.health": "Salus",
"horizon.options.menu_pointer": "Indicium",
"horizon.options.unavailable": "Haec factura nondum est in promptuHorizon.",
"horizon.options.search": "Quaerere occasus...",
"horizon.pages.controller": "Controller Optiones",
@@ -10,7 +10,6 @@
"horizon.options.move_deadzone": "Deadzone Beweegung",
"horizon.options.look_deadzone": "Deadzone Kamera",
"horizon.options.button_glyphs": "Knäppchen Glyphen",
"horizon.options.menu_navigation": "Menu Navigatioun",
"horizon.options.chat_delay": "Chat Verzögerung",
"horizon.options.lightbar.health": "Gesondheet",
"horizon.options.lightbar.dimension": "Dimensioun",
@@ -10,12 +10,10 @@
"horizon.options.move_deadzone": "Deadzone beweging",
"horizon.options.look_deadzone": "Deadzone kieke",
"horizon.options.button_glyphs": "Knopglyphe",
"horizon.options.menu_navigation": "Menunavigatie",
"horizon.options.on_screen_keyboard": "Toetsenbord op scherm",
"horizon.options.chat_delay": "Chatvertraging",
"horizon.options.lightbar.health": "Gezondheid",
"horizon.options.lightbar.dimension": "Dimensie",
"horizon.options.menu_pointer": "Aanwizer",
"horizon.options.unavailable": "Deze functie is nog neet besjikbaar in Horizon.",
"horizon.options.search": "Zoek insjtellinge...",
"horizon.pages.shader_options": "Shader-opties",
Loaded 100 of 507 files, more files were not shown because too many files have changed in this diff. Show more