Merge main into fix/item-tnt-block-speed

The soul sand/honey TNT test gets main's new PrimedTnt fields (rotation, block_state).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-04 17:03:30 +01:00
commit ef4e5c049d
609 files changed
+29855 -4926

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:?}"
@@ -378,6 +381,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
+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 {
+60 -13
View File
@@ -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(),
@@ -257,19 +275,34 @@ pub fn temp_folder() -> std::io::Result<PathBuf> {
/// `PackSelectionScreen.onFilesDrop`'s `PackDetector`: a zip is a pack; a
/// folder is one when it holds a `pack.mcmeta`, or exactly one folder that
/// does (a folder zipped by the system, which adds a wrapper). Copied into
/// `dir`; false when `path` is no pack.
/// `dir`; false when `path` is no pack, or when part of it could not be copied.
pub fn copy_pack(path: &Path, dir: &Path) -> bool {
fn copy_dir(from: &Path, at: &Path) {
let _ = std::fs::create_dir_all(at);
fn copy_dir(from: &Path, at: &Path) -> bool {
let mut whole = true;
if let Err(e) = std::fs::create_dir_all(at) {
log::warn!("Horizon: {} is not made: {e}", at.display());
whole = false;
}
for entry in std::fs::read_dir(from).into_iter().flatten().flatten() {
let path = entry.path();
let to = at.join(entry.file_name());
if path.is_dir() {
copy_dir(&path, &to);
let copied = if path.is_dir() {
copy_dir(&path, &to)
} else {
let _ = std::fs::copy(&path, &to);
}
match std::fs::copy(&path, &to) {
Ok(_) => true,
Err(e) => {
log::warn!(
"Horizon: {} is not copied into the pack: {e}",
path.display()
);
false
}
}
};
whole &= copied;
}
whole
}
let name = || {
path.file_name()
@@ -289,8 +322,7 @@ pub fn copy_pack(path: &Path, dir: &Path) -> bool {
return false;
}
if path.join("pack.mcmeta").is_file() {
copy_dir(path, &dir.join(name()));
return true;
return copy_dir(path, &dir.join(name()));
}
let own: Vec<PathBuf> = std::fs::read_dir(path)
.into_iter()
@@ -300,10 +332,7 @@ pub fn copy_pack(path: &Path, dir: &Path) -> bool {
.filter(|p| p.is_dir() && p.join("pack.mcmeta").is_file())
.collect();
match own.as_slice() {
[one] => {
copy_dir(one, &dir.join(one.file_name().unwrap_or_default()));
true
}
[one] => copy_dir(one, &dir.join(one.file_name().unwrap_or_default())),
_ => false,
}
}
@@ -896,6 +925,24 @@ mod tests {
assert_eq!(content_id("data/mymod/data_maps/item/fuel.txt"), None);
}
#[test]
fn a_pack_that_cannot_be_copied_is_reported() {
let root = std::env::temp_dir().join(format!("hz-datapack-copy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let pack = root.join("pack");
std::fs::create_dir_all(pack.join("data")).unwrap();
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
std::fs::write(pack.join("data/functions.mcfunction"), "").unwrap();
// A file where the pack's folder goes: every copy under it fails.
let blocked = root.join("blocked");
std::fs::write(&blocked, "").unwrap();
assert!(!copy_pack(&pack, &blocked));
let dir = root.join("packs");
assert!(copy_pack(&pack, &dir));
assert!(dir.join("pack/pack.mcmeta").is_file());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn enabled_packs_override_worldgen_in_order() {
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
+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([]),
@@ -485,6 +485,7 @@ impl Scene {
let f = &self.frame;
Frame {
weather: &[],
hand_items: &[],
weather_time: crate::weather::Time::new(0, 0.0),
camera: DVec3::from_array(f.camera),
yaw: f.yaw,
@@ -60,6 +60,7 @@ struct Pipelines {
luma_pyramid: wgpu::ComputePipeline,
scd_histogram: wgpu::ComputePipeline,
scd_divergence: wgpu::ComputePipeline,
scd_finalize: wgpu::ComputePipeline,
search: wgpu::ComputePipeline,
filter: wgpu::ComputePipeline,
scale: wgpu::ComputePipeline,
@@ -84,6 +85,7 @@ pub struct OpticalFlow {
frames: [Frame; 2],
filter: [wgpu::BindGroup; LEVELS],
scd_divergence: wgpu::BindGroup,
scd_finalize: wgpu::BindGroup,
scd_histogram: wgpu::Buffer,
scd_previous_histogram: wgpu::Buffer,
scd_temp: wgpu::Buffer,
@@ -183,6 +185,7 @@ impl OpticalFlow {
luma_pyramid: pipeline("luma_pyramid"),
scd_histogram: pipeline("scd_histogram_pass"),
scd_divergence: pipeline("scd_divergence_pass"),
scd_finalize: pipeline("scd_finalize_pass"),
search: pipeline("search_pass"),
filter: pipeline("filter_flow"),
scale: pipeline("scale_pass"),
@@ -235,6 +238,7 @@ impl OpticalFlow {
HISTOGRAM_WIDTH * 4,
);
let scd_temp = buffer(device, "Scene change temp", 12);
let scd_next_histogram = buffer(device, "Next scene change histogram", HISTOGRAM_WIDTH * 4);
let scd_output = buffer(device, "Scene change detection", 12);
let frame = |current: usize| {
let (cur, prev) = (&luma[current], &luma[1 - current]);
@@ -298,11 +302,22 @@ impl OpticalFlow {
let scd_divergence = bind(
device,
&pipelines.scd_divergence,
&[
(7, whole(&scd_histogram)),
(8, whole(&scd_previous_histogram)),
(9, whole(&scd_temp)),
(11, whole(&scd_next_histogram)),
],
);
let scd_finalize = bind(
device,
&pipelines.scd_finalize,
&[
(7, whole(&scd_histogram)),
(8, whole(&scd_previous_histogram)),
(9, whole(&scd_temp)),
(10, whole(&scd_output)),
(11, whole(&scd_next_histogram)),
],
);
Self {
@@ -312,6 +327,7 @@ impl OpticalFlow {
frames,
filter,
scd_divergence,
scd_finalize,
scd_histogram,
scd_previous_histogram,
scd_temp,
@@ -406,6 +422,7 @@ impl OpticalFlow {
9,
);
run(&p.scd_divergence, &self.scd_divergence, 9, 3, 1);
run(&p.scd_finalize, &self.scd_finalize, 9, 1, 1);
for level in (0..LEVELS).rev() {
let luma = self.display.map(|v| (v >> level).max(1));
run(
@@ -597,6 +614,22 @@ pub(super) mod tests {
.collect()
}
fn scd(device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow) -> u32 {
let mut encoder = device.create_command_encoder(&Default::default());
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 12,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12);
queue.submit([encoder.finish()]);
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
u32::from_le_bytes(bytes[4..8].try_into().unwrap())
}
/// A frame moved right by 16 and down by 8: the blocks inside report where they were
/// (-16, -8), and nothing reads as a scene change. A much darker frame then does.
#[test]
@@ -630,24 +663,40 @@ pub(super) mod tests {
inside.len(),
&inside[..8]
);
let scd = |device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow| {
let mut encoder = device.create_command_encoder(&Default::default());
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 12,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12);
queue.submit([encoder.finish()]);
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
u32::from_le_bytes(bytes[4..8].try_into().unwrap())
};
assert_eq!(scd(&device, &queue, &flow) & 0xf, 0);
run(&device, &queue, &mut flow, &other, false);
assert_eq!(scd(&device, &queue, &flow) & 1, 1);
assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0]));
}
#[test]
fn repeated_cuts_and_stable_frames_update_the_history() {
let Some((device, queue)) = device() else {
return;
};
let size = [384, 216];
let bright = upload(
&device,
&queue,
size,
&vec![255; (size[0] * size[1] * 4) as usize],
);
let dark_pixels: Vec<u8> = (0..size[0] * size[1])
.flat_map(|_| [0, 0, 0, 255])
.collect();
let dark = upload(&device, &queue, size, &dark_pixels);
let mut flow = OpticalFlow::new(&device, size);
run(&device, &queue, &mut flow, &bright, true);
for _ in 0..6 {
run(&device, &queue, &mut flow, &bright, false);
}
for frame in 0..16 {
let color = if frame % 2 == 0 { &dark } else { &bright };
run(&device, &queue, &mut flow, color, false);
assert_eq!(scd(&device, &queue, &flow) & 1, 1, "cut {frame}");
assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0]));
run(&device, &queue, &mut flow, color, false);
assert_eq!(scd(&device, &queue, &flow) & 1, 0, "stable frame {frame}");
}
}
}
@@ -53,10 +53,10 @@ struct Params {
@group(0) @binding(8) var<storage, read_write> scd_previous_histogram: array<f32>;
@group(0) @binding(9) var<storage, read_write> scd_temp: array<atomic<u32>, 3>;
@group(0) @binding(10) var<storage, read_write> scd_output: array<atomic<u32>, 3>;
@group(0) @binding(11) var<storage, read_write> scd_next_histogram: array<f32>;
const SCD_OUTPUT_SCENE_CHANGE_SLOT: u32 = 0u;
const SCD_OUTPUT_HISTORY_BITS_SLOT: u32 = 1u;
const SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT: u32 = 2u;
fn load_luma(t: texture_2d<f32>, p: vec2<i32>) -> u32 {
let size = vec2<i32>(textureDimensions(t));
@@ -266,7 +266,6 @@ fn scd_divergence_pass(
@builtin(local_invocation_index) local_index: u32,
) {
let factor = 1000000.0;
let where_to_stop = 3u * 9u - 1u;
let histogram_count = 9.0;
let kernel = array(0.0088122291, 0.027143577, 0.065114059, 0.12164907, 0.17699835, 0.20056541);
let li = i32(local_index);
@@ -326,27 +325,32 @@ fn scd_divergence_pass(
let res_float = 1.0 - exp(-(abs(sum.x) + abs(sum.y)));
let res_uint = u32((res_float / histogram_count) * factor);
atomicAdd(&scd_temp[gid.y], res_uint);
let finished = atomicAdd(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 1u);
if finished == where_to_stop {
let res0 = atomicLoad(&scd_temp[0]);
let res1 = atomicLoad(&scd_temp[1]);
let res2 = atomicLoad(&scd_temp[2]);
let scene_change = f32(min(res0, min(res1, res2))) / factor;
var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u;
if scene_change > 0.45 {
history |= 1u;
}
atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast<u32>(scene_change));
atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history);
atomicStore(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 0u);
atomicStore(&scd_temp[0], 0u);
atomicStore(&scd_temp[1], 0u);
atomicStore(&scd_temp[2], 0u);
}
}
if gid.y == 1u {
scd_previous_histogram[gid.x] = current;
atomicStore(&scd_histogram[gid.x], 0u);
scd_next_histogram[gid.x] = current;
}
}
// All divergence workgroups must finish reading both histograms before either is
// replaced. A separate dispatch also makes every partial sum visible to the reducer.
@compute @workgroup_size(256)
fn scd_finalize_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
scd_previous_histogram[gid.x] = scd_next_histogram[gid.x];
atomicStore(&scd_histogram[gid.x], 0u);
if gid.x == 0u {
let res0 = atomicLoad(&scd_temp[0]);
let res1 = atomicLoad(&scd_temp[1]);
let res2 = atomicLoad(&scd_temp[2]);
let scene_change = f32(min(res0, min(res1, res2))) / 1000000.0;
var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u;
if scene_change > 0.45 {
history |= 1u;
}
atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast<u32>(scene_change));
atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history);
atomicStore(&scd_temp[0], 0u);
atomicStore(&scd_temp[1], 0u);
atomicStore(&scd_temp[2], 0u);
}
}
@@ -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))
);
}
}
@@ -366,6 +366,7 @@ impl NativeFrame {
// the sun's azimuth while the sun is low.
distance: None,
unculled: None,
every_face: false,
})
}
@@ -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);
@@ -1,7 +1,14 @@
//! The block atlas of shader packs (spec 5.1, `atlasSize` in 5.2): packs sample one 2D atlas
//! with atlas coordinates, Horizon keeps block sprites in texture arrays. With a pack, every
//! layer is copied into a grid, row by row from the top left, and an animated layer again
//! whenever its frame changes.
//! layer is copied into the atlas, and an animated layer again whenever its frame changes.
//!
//! Flat draws, which read a table per sprite layer, get the game's own layout
//! ([`PackAtlas::stitched`]): every sprite at its own size, placed by vanilla's `Stitcher` with its
//! padding; this gives the `atlasSize` and spans our probes read in Iris 1.11.7 (the padding stays
//! empty for now). Other draws get a grid of array-sized cells ([`PackAtlas::new`]).
#[path = "pack_atlas/stitch.rs"]
mod stitch;
pub(super) struct PackAtlas {
texture: wgpu::Texture,
@@ -14,6 +21,40 @@ pub(super) struct PackAtlas {
split: u32,
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
pub separate_ao: bool,
/// The stitched layout, when the atlas has one.
stitched: Option<Stitched>,
}
/// A stitched atlas: where each layer's sprite is.
struct Stitched {
/// Each layer's sprite at the first level: its top left corner and its side.
rects: Vec<[u32; 3]>,
}
/// The side a layer of `sprite`-wide arrays has in a stitched atlas: its own, when the arrays
/// hold it scaled up by a power of two (else the array's).
fn atlas_side(native: u32, sprite: u32) -> u32 {
if native == 0
|| native > sprite
|| !sprite.is_multiple_of(native)
|| !(sprite / native).is_power_of_two()
{
sprite
} else {
native
}
}
/// `SpriteLoader.stitch`'s mip level: the option, lowered to fit the smallest sprite.
fn stitch_mip_level(sides: &[u32], option: u32) -> u32 {
let mut lowest = 1u32 << option.min(16);
let mut smallest = u32::MAX;
for &side in sides {
smallest = smallest.min(side);
lowest = lowest.min(side & side.wrapping_neg());
}
let level = smallest.min(lowest).max(1).ilog2();
level.min(option)
}
impl PackAtlas {
@@ -26,6 +67,37 @@ impl PackAtlas {
split: u32,
layers: u32,
sprite: u32,
) -> Option<Self> {
Self::with_levels(
device,
encoder,
arrays,
split,
layers,
sprite,
arrays[0].mip_level_count(),
)
}
/// Encoded material channels must select an existing texel, including at distance.
pub fn material(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
split: u32,
layers: u32,
) -> Option<Self> {
Self::with_levels(device, encoder, arrays, split, layers, arrays[0].width(), 1)
}
fn with_levels(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
split: u32,
layers: u32,
sprite: u32,
levels: u32,
) -> Option<Self> {
let layers = layers.max(1);
let columns = (f64::from(layers).sqrt().ceil() as u32).max(1);
@@ -35,7 +107,7 @@ impl PackAtlas {
return None;
}
// Levels past a sprite's single pixel would mix neighbours: stop there.
let levels = arrays[0].mip_level_count().min(sprite.ilog2() + 1);
let levels = levels.min(sprite.ilog2() + 1);
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack block atlas"),
size: wgpu::Extent3d {
@@ -60,11 +132,102 @@ impl PackAtlas {
sprite,
split,
separate_ao: false,
stitched: None,
};
atlas.copy(encoder, arrays, 0..layers);
Some(atlas)
}
/// Every layer of `arrays` at its own size (`native`, its side before the arrays scaled it up
/// to `sprite`), placed as vanilla's `Stitcher` places them with the padding of the Mipmap
/// Levels option and the anisotropy bit; `None` when they don't fit the device.
#[allow(clippy::too_many_arguments)]
pub fn stitched(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
split: u32,
sprite: u32,
native: &[u32],
mip_option: u32,
anisotropy_bit: u32,
) -> Option<Self> {
let sides: Vec<u32> = if native.is_empty() {
vec![sprite]
} else {
native.iter().map(|&n| atlas_side(n, sprite)).collect()
};
// A level of the atlas comes from the arrays' level as many halvings further down.
let array_levels = arrays[0].mip_level_count();
let deepest = sides
.iter()
.map(|&s| (sprite / s).ilog2())
.max()
.unwrap_or(0);
let mip =
stitch_mip_level(&sides, mip_option).min(array_levels.saturating_sub(deepest + 1));
let max = device.limits().max_texture_dimension_2d;
let layout = stitch::stitch(&sides, mip, anisotropy_bit, max)?;
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack block atlas"),
size: wgpu::Extent3d {
width: layout.width.max(1),
height: layout.height.max(1),
depth_or_array_layers: 1,
},
mip_level_count: mip + 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let rects = layout
.holders
.iter()
.zip(&sides)
.map(|(&[x, y], &side)| [x + layout.padding, y + layout.padding, side])
.collect::<Vec<_>>();
let layers = rects.len() as u32;
let atlas = Self {
texture,
view,
columns: 1,
sprite,
split,
separate_ao: false,
stitched: Some(Stitched { rects }),
};
atlas.copy(encoder, arrays, 0..layers);
Some(atlas)
}
/// Whether the sprites keep their own sizes ([`PackAtlas::stitched`]).
pub fn is_stitched(&self) -> bool {
self.stitched.is_some()
}
/// 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> {
if let Some(stitched) = &self.stitched {
return stitched
.rects
.iter()
.flat_map(|&[x, y, side]| [x | (y << 16), side])
.collect();
}
(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()]
@@ -81,13 +244,26 @@ impl PackAtlas {
let Some(array) = arrays.get((layer / self.split) as usize) else {
continue;
};
let (column, row) = (layer % self.columns, layer / self.columns);
// Where the sprite goes, and from which array level: a stitched sprite keeps its own
// side, which the arrays hold that many halvings down.
let [x, y, side] = match &self.stitched {
Some(s) => match s.rects.get(layer as usize) {
Some(&rect) => rect,
None => continue,
},
None => [
layer % self.columns * self.sprite,
layer / self.columns * self.sprite,
self.sprite,
],
};
let from = (self.sprite / side).ilog2();
for level in 0..self.texture.mip_level_count() {
let edge = self.sprite >> level;
let edge = side >> level;
encoder.copy_texture_to_texture(
wgpu::TexelCopyTextureInfo {
texture: array,
mip_level: level,
mip_level: from + level,
origin: wgpu::Origin3d {
x: 0,
y: 0,
@@ -99,8 +275,8 @@ impl PackAtlas {
texture: &self.texture,
mip_level: level,
origin: wgpu::Origin3d {
x: column * edge,
y: row * edge,
x: x >> level,
y: y >> level,
z: 0,
},
aspect: wgpu::TextureAspect::All,
@@ -173,7 +349,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 {
@@ -181,11 +358,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 {
@@ -196,8 +391,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 \
@@ -252,6 +447,7 @@ impl PackAtlas {
.collect(),
after: String::new(),
alpha_test: None,
item_id_declared: false,
}
}
}
@@ -337,6 +533,119 @@ mod tests {
assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]);
}
/// An array of `sprite`-pixel layers (`sprite` the largest of `native`), as the game builds
/// them: each layer scaled up by whole pixels at level 0, its own pixels from the level where
/// it has its own size. Every pixel of a layer's own image differs: (layer, x, y, 255).
pub(super) fn upscaled_array(
device: &wgpu::Device,
queue: &wgpu::Queue,
native: &[u32],
) -> wgpu::Texture {
let sprite = native.iter().copied().max().unwrap();
let levels = sprite.ilog2() + 1;
let mut data = Vec::new();
for (layer, &side) in native.iter().enumerate() {
for level in 0..levels {
let edge = sprite >> level;
// Above its own size a level repeats each pixel; below, it shrinks to the first.
let scale = (edge / side).max(1);
for y in 0..edge {
for x in 0..edge {
let (x, y) = if edge >= side {
(x / scale, y / scale)
} else {
(0, 0)
};
data.extend([layer as u8 * 50, x as u8 * 16, y as u8 * 16, 255]);
}
}
}
}
wgpu::util::DeviceExt::create_texture_with_data(
device,
queue,
&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: sprite,
height: sprite,
depth_or_array_layers: native.len() as u32,
},
mip_level_count: levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&data,
)
}
/// Sprites of 2, 4 and 1 px in a 4 px array, no mipmaps (one pixel of padding). Worked through
/// vanilla's `Stitcher` by hand: holders of 6, 4 and 3 px at (0, 0), (8, 0) and (8, 4) in a 16
/// by 8 atlas, each sprite with its own pixels.
#[test]
fn stitched_sprites_keep_their_own_size() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let array = upscaled_array(&device, &queue, &[2, 4, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
let atlas =
PackAtlas::stitched(&device, &mut encoder, &[array], 3, 4, &[2, 4, 1], 0, 0).unwrap();
assert_eq!(atlas.size(), [16, 8]);
assert_eq!(
atlas.rects_table(),
[9 | 1 << 16, 2, 1 | 1 << 16, 4, 9 | 5 << 16, 1]
);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256 * 8,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
atlas.texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
wgpu::Extent3d {
width: 16,
height: 8,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = read.slice(..).get_mapped_range().unwrap();
let at = |x: usize, y: usize| {
let i = y * 256 + x * 4;
[data[i], data[i + 1], data[i + 2], data[i + 3]]
};
// Layer 0's own 2 px: (layer 0, x, y); its padding is empty.
assert_eq!(
[at(9, 1), at(10, 1), at(10, 2)],
[[0, 0, 0, 255], [0, 16, 0, 255], [0, 16, 16, 255]]
);
assert_eq!([at(8, 0), at(11, 3), at(11, 1)], [[0; 4]; 3]);
// Layer 1 fills its 4 px; layer 2 is one pixel.
assert_eq!(
[at(1, 1), at(4, 4), at(9, 5), at(10, 6)],
[[50, 0, 0, 255], [50, 48, 48, 255], [100, 0, 0, 255], [0; 4]]
);
}
#[test]
fn terrain_inputs_respects_separate_ao() {
let instance = crate::gfx::test_gpu::instance();
@@ -366,6 +675,7 @@ mod tests {
sprite: 16,
split: 1,
separate_ao: false,
stitched: None,
};
let inputs_default = atlas.terrain_inputs(false);
assert!(
@@ -123,6 +123,54 @@ fn mid_tex_coord_follows_each_quads_mapped_corners() {
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// A stitched atlas (the one `stitched_sprites_keep_their_own_size` works out: 16 by
/// 8, sprites of 2, 4 and 1 px at (9, 1), (1, 1) and (9, 5)): flat draws map a quad's UVs onto its
/// sprite's own pixels, so a corner is half the sprite's own side from `mc_midTexCoord`.
#[test]
fn flat_draws_map_uvs_onto_a_stitched_sprite() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let array = super::tests::upscaled_array(&device, &queue, &[2, 4, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
let atlas =
PackAtlas::stitched(&device, &mut encoder, &[array], 3, 4, &[2, 4, 1], 0, 0).unwrap();
queue.submit([encoder.finish()]);
let whole = [[0., 0.], [1., 0.], [1., 1.], [0., 1.]];
let positions = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]];
let vertices: Vec<_> = (0..3)
.flat_map(|layer| {
(0..4).map(move |k| {
TerrainVertex::new(positions[k], whole[k], layer as u16 | 0x8000, u32::MAX, 0)
})
})
.collect();
// Corner and side of each layer's sprite, in atlas pixels.
let sprites = [([9., 1.], 2.), ([1., 1.], 4.), ([9., 5.], 1.)];
let origin = f32::from_bits((1. / UV_STEPS).to_bits());
let pixels = sample(&device, &queue, &atlas, &vertices, origin, [true, false]);
let mut failures = Vec::new();
for (layer, ([x, y], side)) in sprites.into_iter().enumerate() {
let mid = [(x + side / 2.) / 16., (y + side / 2.) / 8.];
for (corner, [u, v]) in whole.iter().enumerate() {
let expected = [mid[0], mid[1], (x + u * side) / 16., (y + v * side) / 8.];
let actual = pixels[4 * layer + corner];
if actual
.iter()
.zip(expected)
.any(|(a, b)| (a - b).abs() > 1e-6)
{
failures.push(format!(
"layer {layer} corner {corner}: {actual:?} != {expected:?}"
));
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
fn sample(
device: &wgpu::Device,
queue: &wgpu::Queue,
@@ -183,7 +231,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 +316,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()
@@ -0,0 +1,178 @@
//! Where a shader pack's block atlas puts each sprite: vanilla's `Stitcher`, so every sprite keeps
//! its own size and the atlas comes out as large as the game's (`atlasSize`). Holders are sorted
//! by height, then width, then (where vanilla compares names) sprite layer.
/// The atlas a set of sprites stitches into.
#[derive(Debug, PartialEq)]
pub(super) struct Stitched {
pub width: u32,
pub height: u32,
/// Each sprite's padded holder: its top left corner. The sprite itself starts `padding` in.
pub holders: Vec<[u32; 2]>,
pub padding: u32,
}
/// `Stitcher.smallestFittingMinTexel`.
fn fit(input: u32, mip_level: u32) -> u32 {
let step = 1 << mip_level;
input.div_ceil(step) * step
}
/// `Mth.smallestEncompassingPowerOfTwo` (0 stays 0).
fn power_of_two(input: u32) -> u32 {
if input == 0 {
0
} else {
input.next_power_of_two()
}
}
/// `Stitcher.Region`.
struct Region {
x: u32,
y: u32,
width: u32,
height: u32,
holder: bool,
sub: Vec<Region>,
}
impl Region {
fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
Self {
x,
y,
width,
height,
holder: false,
sub: Vec::new(),
}
}
/// `Region.add`: the holder's top left corner, when it fits.
fn add(&mut self, [w, h]: [u32; 2]) -> Option<[u32; 2]> {
if self.holder || w > self.width || h > self.height {
return None;
}
if w == self.width && h == self.height {
self.holder = true;
return Some([self.x, self.y]);
}
if self.sub.is_empty() {
let (x, y) = (self.x, self.y);
self.sub.push(Region::new(x, y, w, h));
let (spare_w, spare_h) = (self.width - w, self.height - h);
if spare_h > 0 && spare_w > 0 {
let right = self.height.max(spare_w);
let bottom = self.width.max(spare_h);
if right >= bottom {
self.sub.push(Region::new(x, y + h, w, spare_h));
self.sub.push(Region::new(x + w, y, spare_w, self.height));
} else {
self.sub.push(Region::new(x + w, y, spare_w, h));
self.sub.push(Region::new(x, y + h, self.width, spare_h));
}
} else if spare_w == 0 {
self.sub.push(Region::new(x, y + h, w, spare_h));
} else if spare_h == 0 {
self.sub.push(Region::new(x + w, y, spare_w, h));
}
}
self.sub.iter_mut().find_map(|r| r.add([w, h]))
}
}
/// `Stitcher` over square sprites of `sides` pixels, with vanilla's padding for `mip_level` and
/// `anisotropy_bit`; `None` when they don't fit in `max` by `max`.
pub(super) fn stitch(
sides: &[u32],
mip_level: u32,
anisotropy_bit: u32,
max: u32,
) -> Option<Stitched> {
let padding = (1 << mip_level) << anisotropy_bit.saturating_sub(1).min(4);
let mut order: Vec<usize> = (0..sides.len()).collect();
let holder = |i: usize| fit(sides[i] + padding * 2, mip_level);
order.sort_by_key(|&i| (std::cmp::Reverse(holder(i)), i));
let (mut storage_x, mut storage_y) = (0u32, 0u32);
let mut storage: Vec<Region> = Vec::new();
let mut holders = vec![[0, 0]; sides.len()];
for i in order {
let size = [holder(i); 2];
if let Some(at) = storage.iter_mut().find_map(|r| r.add(size)) {
holders[i] = at;
continue;
}
// `Stitcher.expand`.
let (x_now, y_now) = (power_of_two(storage_x), power_of_two(storage_y));
let (x_new, y_new) = (
power_of_two(storage_x + size[0]),
power_of_two(storage_y + size[1]),
);
let (x_can, y_can) = (x_new <= max, y_new <= max);
if !x_can && !y_can {
return None;
}
let (x_will, y_will) = (x_can && x_now != x_new, y_can && y_now != y_new);
let grow_x = if x_will ^ y_will {
x_will
} else {
x_can && x_now <= y_now
};
let mut slot = if grow_x {
if storage_y == 0 {
storage_y = y_new;
}
let slot = Region::new(storage_x, 0, x_new - storage_x, storage_y);
storage_x = x_new;
slot
} else {
let slot = Region::new(0, storage_y, storage_x, y_new - storage_y);
storage_y = y_new;
slot
};
holders[i] = slot.add(size)?;
storage.push(slot);
}
Some(Stitched {
width: storage_x,
height: storage_y,
holders,
padding,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Five 16 px sprites with one pixel of padding (no mipmaps), worked through vanilla's
/// `Stitcher` by hand: 18 px holders in a 64 by 64 atlas.
#[test]
fn equal_sprites_follow_vanillas_regions() {
let stitched = stitch(&[16; 5], 0, 0, 1 << 14).unwrap();
assert_eq!(
(stitched.width, stitched.height, stitched.padding),
(64, 64, 1)
);
assert_eq!(
stitched.holders,
[[0, 0], [32, 0], [0, 32], [18, 32], [36, 32]]
);
}
/// The largest sprite goes first; padding and holders round up to the mip step.
#[test]
fn larger_sprites_go_first_and_holders_round_to_the_mip_step() {
// Mip level 2: 4 px padding, holders 24 (16 + 8) and 40 (32 + 8) px.
let stitched = stitch(&[16, 32], 2, 0, 1 << 14).unwrap();
assert_eq!(stitched.padding, 4);
assert_eq!(stitched.holders[1], [0, 0]);
// 40 px grows the atlas to 64 by 64; the 24 px holder fits below the first.
assert_eq!((stitched.width, stitched.height), (64, 64));
assert_eq!(stitched.holders[0], [0, 40]);
// Anisotropy 16x (bit 4) multiplies the padding by 8.
assert_eq!(stitch(&[16], 4, 4, 1 << 14).unwrap().padding, 128);
assert_eq!(stitch(&[16; 2], 0, 0, 16), None);
}
}
@@ -58,7 +58,8 @@ impl EntitySpan {
}
}
/// `HzDraw`'s size: `renderStage`, `entityId`, `blockEntityId`, a pad, `entityColor`.
/// `HzDraw`'s size: `renderStage`, `entityId`, `blockEntityId`, `currentRenderedItemId`,
/// `entityColor`.
const SIZE: u32 = 32;
pub(super) struct DrawUniforms {
@@ -73,8 +74,10 @@ impl DrawUniforms {
let mut contents = vec![0u8; stride as usize * RENDER_STAGES.len()];
for (i, slot) in contents.chunks_mut(stride as usize).enumerate() {
slot[..4].copy_from_slice(&(i as i32).to_le_bytes());
// `blockEntityId` outside block entities, as Iris has it.
// `blockEntityId` outside block entities, as Iris has it; `currentRenderedItemId`
// outside items, as Iris 1.11.7 gives it to terrain, water and the sky (observed).
slot[8..12].copy_from_slice(&(-1i32).to_le_bytes());
slot[12..16].copy_from_slice(&(-1i32).to_le_bytes());
}
let buffer = wgpu::util::DeviceExt::create_buffer_init(
device,
@@ -101,6 +101,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
op: ">",
reference: 0.1,
}),
item_id_declared: false,
}
}
@@ -162,6 +163,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
.collect(),
after: String::new(),
alpha_test: None,
item_id_declared: false,
}
}
@@ -426,7 +428,9 @@ impl PackEntities {
}),
};
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
let casts = part == Part::Plain && program.is_some_and(|p| shadow.cast(&span, p));
// Portal faces cast too (a pack's shadow pass sees block entities whole, as Iris
// draws them); beams do not.
let casts = part != Part::Beam && program.is_some_and(|p| shadow.cast(&span, p));
if !pack && !span.hidden {
extend_without_portals(rest, run);
}
@@ -1032,6 +1036,57 @@ mod gpu_span_tests {
assert_eq!(skin.spans.len(), 1);
}
#[test]
fn end_portal_faces_reach_the_shadow_pass_with_their_block_entity() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let layout = super::super::pack_draw::DrawUniforms::new(&device);
let mut geometry = PackEntities::new(&device, &layout.layout);
// One block entity: a plain face, then an End portal face (overlay layer 9).
let cpu: Vec<Vertex> = [0, 0, 0, 0, 9, 9, 9, 9]
.into_iter()
.map(|layer| Vertex {
layer,
..bytemuck::Zeroable::zeroed()
})
.collect();
let spans = [EntitySpan {
start: 0,
id: 99,
stage: render_stage("BLOCK_ENTITIES"),
..Default::default()
}];
let mut rest = Vec::new();
geometry.prepare(
&device,
&queue,
&layout.layout,
&cpu,
&spans,
[true; PROGRAMS],
([0.; 4], [1; 2]),
Casters {
blocks: true,
..Default::default()
},
&mut rest,
None,
);
assert_eq!(
(geometry.draws.iter())
.map(|d| (d.portal, d.shadow))
.collect::<Vec<_>>(),
[(false, true), (true, true)],
"the portal face casts as its block entity does"
);
}
#[test]
fn posed_and_cpu_draws_keep_offsets_uniforms_and_special_fallbacks() {
let instance = crate::gfx::test_gpu::instance();
@@ -13,9 +13,14 @@ const HAND_DEPTH: f32 = 0.125;
/// `HzHand`: the hand's projection and its inverse.
const UNIFORM: u64 = 2 * 64;
/// `HzDraw`'s size (pack_draw.rs).
const SLOT: u64 = 32;
/// What `gbuffers_hand` reads: the entity inputs, and the hand's projection from set 1.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let mut inputs = super::pack_entities::inputs();
// Iris 1.11.7 declares `currentRenderedItemId` in the hand's programs (observed).
inputs.item_id_declared = true;
inputs.declarations.push_str(
"layout(set = 1, binding = 1) uniform HzHand { mat4 hz_hand_projection; mat4 hz_hand_projection_inverse; };\n",
);
@@ -45,10 +50,17 @@ pub struct PackHand {
capacity: u32,
/// Quads uploaded this frame.
pub quads: u32,
/// `HzDraw` (set 2) of each run: the arm or one held item, and its `currentRenderedItemId`.
slots: wgpu::Buffer,
pub slot_group: wgpu::BindGroup,
stride: u64,
/// This frame's runs: first quad, quads, and the dynamic offset of their slot.
pub runs: Vec<(u32, u32, u32)>,
}
impl PackHand {
pub fn new(device: &wgpu::Device) -> Self {
/// `draws`: the layout of `HzDraw` (pack_draw.rs).
pub fn new(device: &wgpu::Device, draws: &wgpu::BindGroupLayout) -> Self {
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX,
@@ -74,6 +86,28 @@ impl PackHand {
});
let capacity = 256;
let (vertices, group) = Self::vertex_buffer(device, &layout, &uniform, capacity);
let stride = SLOT.max(u64::from(
device.limits().min_uniform_buffer_offset_alignment,
));
// The arm and the two hands' items: a few runs a frame.
let slots = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack hand draws"),
size: SLOTS * stride,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let slot_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack hand draws"),
layout: draws,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &slots,
offset: 0,
size: wgpu::BufferSize::new(SLOT),
}),
}],
});
Self {
indices: super::pack_entities::quad_indices(device, capacity),
layout,
@@ -82,6 +116,10 @@ impl PackHand {
group,
capacity,
quads: 0,
slots,
slot_group,
stride,
runs: Vec::new(),
}
}
@@ -116,18 +154,31 @@ impl PackHand {
(buffer, group)
}
/// Uploads this frame's hand and its projection.
/// Uploads this frame's hand, its projection, and the `HzDraw` of each run `items` starts
/// (first vertex, `currentRenderedItemId`; the slots past [`SLOTS`] share the last).
pub fn prepare(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
hand: &[Vertex],
items: &[(u32, i32)],
projection: Mat4,
) {
self.quads = (hand.len() / 4) as u32;
self.runs.clear();
if self.quads == 0 {
return;
}
let mut contents = Vec::new();
for (start, quads, id) in runs(self.quads, items) {
let slot = (self.runs.len() as u64).min(SLOTS - 1);
if slot as usize == self.runs.len() {
contents.extend_from_slice(&draw_slot(id));
contents.resize(((slot + 1) * self.stride) as usize, 0);
}
self.runs.push((start, quads, (slot * self.stride) as u32));
}
queue.write_buffer(&self.slots, 0, &contents);
if self.quads > self.capacity {
self.capacity = self.quads.next_power_of_two();
(self.vertices, self.group) =
@@ -144,10 +195,54 @@ impl PackHand {
}
}
/// The hand's runs a frame keeps slots for.
const SLOTS: u64 = 8;
/// `HzDraw` of a hand run: `renderStage` the solid hand's, `blockEntityId` -1 as outside block
/// entities, and `currentRenderedItemId`.
fn draw_slot(id: i32) -> [u8; SLOT as usize] {
let mut bytes = [0u8; SLOT as usize];
let stage = horizon_shaders::render_stage("HAND_SOLID") as i32;
bytes[0..4].copy_from_slice(&stage.to_le_bytes());
bytes[8..12].copy_from_slice(&(-1i32).to_le_bytes());
bytes[12..16].copy_from_slice(&id.to_le_bytes());
bytes
}
/// The runs of `quads` quads that `items` starts (first vertex, id), as first quad, quads and
/// id; geometry before the first start, or with no starts at all, reads 0, as the arm does.
fn runs(quads: u32, items: &[(u32, i32)]) -> Vec<(u32, u32, i32)> {
let mut starts: Vec<(u32, i32)> = vec![(0, 0)];
for &(start, id) in items {
let q = (start / 4).min(quads);
match starts.last_mut() {
Some(last) if last.0 == q => last.1 = id,
_ => starts.push((q, id)),
}
}
(0..starts.len())
.map(|i| {
let end = starts.get(i + 1).map_or(quads, |s| s.0);
(starts[i].0, end - starts[i].0, starts[i].1)
})
.filter(|r| r.1 > 0)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// Each item's quads draw with its id; geometry before the first item is the arm's (0).
#[test]
fn runs_split_the_hand_by_item() {
assert_eq!(runs(3, &[]), [(0, 3, 0)]);
assert_eq!(runs(5, &[(0, 11), (8, 65535)]), [(0, 2, 11), (2, 3, 65535)]);
assert_eq!(runs(4, &[(4, 21)]), [(0, 1, 0), (1, 3, 21)]);
// An item that drew nothing gives way to the next.
assert_eq!(runs(2, &[(0, 11), (0, 22)]), [(0, 2, 22)]);
}
/// The hand's window depth lies within `MC_HAND_DEPTH` of the middle (packs tell the hand
/// apart by it), and x, y match the plain projection.
#[test]
@@ -50,6 +50,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
.collect(),
after,
alpha_test: None,
item_id_declared: false,
}
}
@@ -0,0 +1,259 @@
//! Original synthetic material transport oracle; no external shader algorithms or assets.
use super::*;
use wgpu::util::DeviceExt;
#[test]
#[ignore = "requires a host GPU; missing adapter is a failure"]
fn terrain_material_samplers_preserve_surface_and_encoded_bytes() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
eprintln!("Material transport GPU: {:?}", adapter.get_info());
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.unwrap();
let root = std::env::temp_dir().join(format!("horizon-material-pack-{}", std::process::id()));
std::fs::create_dir_all(root.join("shaders")).unwrap();
std::fs::write(
root.join("shaders/gbuffers_terrain.vsh"),
"#version 120\nvoid main(){gl_Position=gl_Vertex;}",
)
.unwrap();
std::fs::write(
root.join("shaders/gbuffers_terrain.fsh"),
r#"#version 120
uniform sampler2D gtexture;
uniform sampler2D normals;
uniform sampler2D specular;
void main() {
int x = int(floor(gl_FragCoord.x));
int layer = x - (x / 5) * 5;
// Source coordinate (1.25,1.25): inside a texel, away from both its centre and boundary.
vec2 uv = (vec2(float(layer - (layer / 3) * 3), float(layer / 3)) + 0.3125) / vec2(3.0, 2.0);
if (x < 5) gl_FragData[0] = texture2DLod(specular, uv, 12.0);
else if (x < 10) gl_FragData[0] = texture2DLod(normals, uv, 12.0);
else gl_FragData[0] = texture2DLod(gtexture, uv, 0.0);
}
"#,
)
.unwrap();
let (split, layers) = (2u32, 5u32);
let mut encoder = device.create_command_encoder(&Default::default());
let atlases: Vec<_> = (0..3)
.map(|kind| {
let side = if kind == 0 { 4 } else { 2 };
let levels = if kind == 0 { 3 } else { 1 };
let arrays: Vec<_> = (0..layers.div_ceil(split))
.map(|array| {
let count = (layers - array * split).min(split);
let pixels: Vec<u8> = (0..count)
.flat_map(|l| {
let layer = (array * split + l) as u8;
(0..levels).flat_map(move |level| {
let edge = side >> level;
(0..edge * edge).flat_map(move |pixel| {
if level != 0 {
// Sampling unused source mip levels must fail the oracle.
[211, 212, 213, 214]
} else {
match kind {
0 if layer == 3 => crate::sprites::NO_SPECULAR,
0 => [
17 + layer,
if pixel % 2 == 0 { 0 } else { 240 },
65,
if pixel % 2 == 0 { 0 } else { 255 },
],
1 if layer == 2 => crate::sprites::FLAT_NORMAL,
1 => [128 + layer, 126, 255, 19 + layer],
_ => [40 + layer, 50, 60, 255],
}
}
})
})
})
.collect();
device.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: Some("Original fixture companion array"),
size: wgpu::Extent3d {
width: side,
height: side,
depth_or_array_layers: count,
},
mip_level_count: levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&pixels,
)
})
.collect();
super::super::pack_atlas::PackAtlas::material(
&device,
&mut encoder,
&arrays,
split,
layers,
)
.unwrap()
})
.collect();
queue.submit([encoder.finish()]);
let pack = horizon_shaders::Pack::open(&root).unwrap();
let macros = horizon_shaders::Macros::standard();
// Encoded zero alpha is data here and must remain observable in readback.
let properties = horizon_shaders::Properties::parse("alphaTest.gbuffers_terrain=off", &macros);
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
body: "vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); hz_Vertex=vec4(p*2.0-1.0,0.0,1.0);".into(),
..Default::default()
}),
..Default::default()
};
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs);
assert!(post.terrain.is_some(), "{:?}", post.notes);
let world = World {
sprite_rects: &[],
atlas: Some((atlases[2].view.clone(), atlases[2].size())),
material_maps: Some([atlases[1].view.clone(), atlases[0].view.clone()]),
lightmap: atlases[2].view.clone(),
terrain: Some(Geometry {
buffers: &[],
table: None,
depth_write: true,
atlas: None,
material_maps: true,
ids: None,
cull: false,
}),
block_ids: &[],
celestial: None,
gui_size: [1, 1],
clouds: None,
weather: false,
hand: false,
};
let mut post = PackPost::new(
&device,
&queue,
&adapter,
post,
wgpu::TextureFormat::Rgba8Unorm,
world,
)
.expect("the pack runs");
let mut pass = post
.terrain
.take()
.unwrap_or_else(|| panic!("terrain pipeline must compile: {:?}", post.notes));
// Request the existing copy usage for the fixture's colour readback.
post.copied |= 1;
// First draw preserves production Geometry -> Pass scope, so a routing regression fails.
for case in 0..3 {
if case == 1 {
pass.material_maps = false;
}
if case == 2 {
pass.material_maps = true;
post.material_maps = None;
}
let maps_expected = case == 0;
pass.groups.clear();
post.resize(&device, [15, 1]);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let _clear = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Initialize fixture depth"),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &post.targets.as_ref().unwrap().depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
}
let group = post.bind(&device, &queue, &mut encoder, &mut pass, [15, 1]);
{
let mut render = post.begin(&mut encoder, &pass);
render.set_pipeline(pass.render());
render.set_bind_group(0, &group, &[]);
if pass.draw {
post.draws.bind(&mut render, "TERRAIN_SOLID");
}
render.draw(0..3, 0..1);
}
let output = post.targets.as_ref().unwrap().color[0].as_ref().unwrap()[0].texture();
encoder.copy_texture_to_buffer(
output.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
bytes_per_row: Some(256),
..Default::default()
},
},
wgpu::Extent3d {
width: 15,
height: 1,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = read.slice(..).get_mapped_range().unwrap();
for i in 0..15usize {
let layer = (i % 5) as u8;
let expected = if i >= 10 {
[40 + layer, 50, 60, 255]
} else if i >= 5 && (!maps_expected || layer == 2) {
crate::sprites::FLAT_NORMAL
} else if i >= 5 {
[128 + layer, 126, 255, 19 + layer]
} else if !maps_expected || layer == 3 {
crate::sprites::NO_SPECULAR
} else {
[17 + layer, 240, 65, 255]
};
assert_eq!(&data[i * 4..i * 4 + 4], &expected, "case={case}, pixel={i}");
}
eprintln!("Material oracle case={case}: 15 exact RGBA pixels pass");
}
// Fault injection: the same production binding must refuse a filtering sampler.
// If a regression changed both the binding and sampler, the fractional positive
// probes above would instead mix 0.75*240=180 and 0.75*255=191.25, not 240/255.
pass.material_maps = true;
post.material_maps = Some([atlases[1].view.clone(), atlases[0].view.clone()]);
post.nearest_repeat = sampler(&device, wgpu::FilterMode::Linear, wgpu::AddressMode::Repeat);
pass.groups.clear();
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut encoder = device.create_command_encoder(&Default::default());
let _invalid_group = post.bind(&device, &queue, &mut encoder, &mut pass, [15, 1]);
let error = pollster::block_on(validation.pop())
.expect("production material binding must reject a filtering sampler");
assert!(
error.to_string().to_ascii_lowercase().contains("filtering"),
"{error}"
);
eprintln!("Material oracle negative filtering control: rejected by production binding");
std::fs::remove_dir_all(root).unwrap();
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,111 @@
//! A buffer that isn't cleared, in passes that leave pixels alone or blend: what is under them.
//! The expected values are Iris 1.11.7's, measured with probe packs of our own (#9009).
use super::*;
/// colortex6 is written three times a frame, the first write leaving the right half alone:
/// there it reads the previous frame's last write, 0.125 (Iris 1.11.7), not the 0.25 written into
/// that texture before. colortex8 is written twice a frame the same way: its right half was never
/// written and reads 0 (Iris 1.11.7). colortex10 is only blended onto (`ONE ONE`), 0.125 a frame:
/// it grows by 0.125 every frame (Iris 1.11.7 adds once per frame).
#[test]
fn a_buffer_that_is_not_cleared_starts_the_frame_with_its_last_write() {
let Some(gpu) = Fixture::new() else { return };
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
let fill = |v: f32| format!("vec4({v},{v},{v},1)");
let programs = [
(
"composite",
format!(
"#version 120\nvarying vec2 uv;\n/* RENDERTARGETS:6 */\n\
/*\nconst int colortex6Format=RGBA16F;\nconst int colortex7Format=RGBA16F;\n\
const int colortex8Format=RGBA16F;\nconst int colortex9Format=RGBA16F;\n\
const int colortex10Format=RGBA16F;\nconst bool colortex6Clear=false;\n\
const bool colortex8Clear=false;\nconst bool colortex10Clear=false;\n*/\n\
void main() {{ if(uv.x>0.5) discard; gl_FragData[0]={}; }}\n",
fill(0.5)
),
),
(
"composite1",
format!(
"#version 120\nuniform sampler2D colortex6;\nvarying vec2 uv;\n/* RENDERTARGETS:7,6 */\n\
void main() {{ gl_FragData[0]=texture2D(colortex6,uv); gl_FragData[1]={}; }}\n",
fill(0.25)
),
),
(
"composite2",
format!(
"#version 120\n/* RENDERTARGETS:6 */\nvoid main() {{ gl_FragData[0]={}; }}\n",
fill(0.125)
),
),
(
"composite3",
format!(
"#version 120\nvarying vec2 uv;\n/* RENDERTARGETS:8 */\n\
void main() {{ if(uv.x>0.5) discard; gl_FragData[0]={}; }}\n",
fill(0.5)
),
),
(
"composite4",
format!(
"#version 120\nuniform sampler2D colortex8;\nvarying vec2 uv;\n/* RENDERTARGETS:9,8 */\n\
void main() {{ gl_FragData[0]=texture2D(colortex8,uv); gl_FragData[1]={}; }}\n",
fill(0.125)
),
),
(
"composite5",
"#version 120\n/* RENDERTARGETS:10 */\nvoid main() { gl_FragData[0]=vec4(0.125,0.125,0.125,0); }\n"
.to_owned(),
),
(
"final",
"#version 120\nuniform sampler2D colortex7;\nuniform sampler2D colortex9;\n\
uniform sampler2D colortex10;\nvarying vec2 uv;\n\
void main() { gl_FragColor=vec4(texture2D(colortex7,uv).r,texture2D(colortex9,uv).r,texture2D(colortex10,uv).r,1); }\n"
.to_owned(),
),
];
for (program, fragment) in &programs {
add(&format!("{program}.vsh"), VERTEX);
add(&format!("{program}.fsh"), fragment);
}
add("shaders.properties", "blend.composite5=ONE ONE ONE ONE\n");
let pack = horizon_shaders::Pack::from_zip_bytes(
"clear-false-history",
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());
let mut post = gpu.build(post);
for frame in 0..4u8 {
let actual = gpu.render(&mut post, [16, 16]);
let sum = 32 * (frame + 1);
let wrong: Vec<_> = (actual.iter().enumerate())
.filter(|(n, p)| {
let expected = match (n % 16 < 8, frame) {
(true, _) => [128, 128, sum, 255],
(false, 0) => [0, 0, sum, 255],
(false, _) => [32, 0, sum, 255],
};
p.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1)
})
.map(|(n, p)| (n, *p))
.take(4)
.collect();
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
}
}
@@ -0,0 +1,70 @@
//! A texel offset given by a `const` local reads the texel that far away, as a literal offset
//! does (GLSL 4.60, 4.3.3 and 8.9.2, as #8462 quotes them). #8462's probe.
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);
// Each texel of colortex1 holds its own column and row, in eighths.
add(
"composite.fsh",
"#version 120\n/* RENDERTARGETS: 1 */\n\
void main() { gl_FragData[0] = vec4(floor(gl_FragCoord.xy) / 8.0, 0.0, 1.0); }\n",
);
add("final.vsh", VERTEX);
// Red and green: what the texel two columns right and one row down holds; blue: this row.
add(
"final.fsh",
"#version 330 compatibility\nuniform sampler2D colortex1;\nin vec2 uv;\n\
vec4 f(vec2 p) { const ivec2 offset = ivec2(2, -1); return textureLodOffset(colortex1, p, 0.0, offset); }\n\
void main() { gl_FragColor = vec4(f(uv).rg, floor(gl_FragCoord.y) / 8.0, 1.0); }\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-const-offset",
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 a_const_local_offset_reads_the_offset_texel() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
let pixels = gpu.render(&mut post, [8, 8]);
let eighth = |n: u8| (f32::from(n) * 255.0 / 8.0).round() as u8;
let mut checked = 0;
for (n, p) in pixels.iter().enumerate() {
let x = (n % 8) as u8;
// The bottom row (GL's row 0) and the two right columns read past the edge.
if x >= 6 || p[2] == 0 {
continue;
}
let row = (0..8)
.find(|&r| eighth(r).abs_diff(p[2]) <= 1)
.expect("row in blue");
let want = [eighth(x + 2), eighth(row - 1)];
assert!(
p[0].abs_diff(want[0]) <= 1 && p[1].abs_diff(want[1]) <= 1,
"pixel {n} (column {x}, GL row {row}): {p:?}, expected red and green {want:?}"
);
checked += 1;
}
assert_eq!(checked, 6 * 7);
}
@@ -83,7 +83,8 @@ impl Gpu {
post,
T::Rgba8Unorm,
world,
);
)
.expect("the pack runs");
assert!(
built.passes.iter().any(|p| p.group == Group::Final),
"{:?}",
@@ -315,23 +316,26 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
let inputs=horizon_shaders::GameInputs {
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None }),
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false }),
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
};
let mut post = gpu.load(
shaders(sampler, stage, scene),
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: terrain.then_some(Geometry {
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
material_maps: false,
ids: None,
cull: false,
}),
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
@@ -617,6 +621,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
@@ -635,3 +640,144 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
post_effects: crate::post_chain::Request::none(),
}
}
/// shadowtex0 and shadowtex1 read through a plain sampler a quarter texel past a texel's centre,
/// over a shadow map whose depth rises a sixteenth a texel: red and green are how far each
/// moved toward the next texel, times four (0 nearest, 255 linear). `nearest` is a line for
/// both stage files.
fn shadow_depth_filtering(nearest: &str) -> Option<Vec<[u8; 4]>> {
let gpu = Gpu::new()?;
if !gpu
.device
.features()
.contains(wgpu::Features::FLOAT32_FILTERABLE)
{
eprintln!("DEPTH_FIXTURE_NO_FLOAT32_FILTERABLE");
return None;
}
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
gpu.clear(&color, &depth, 0.0);
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
let layouts = [Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRS,
})];
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
.into(),
attributes: vec![],
after: String::new(),
alpha_test: None,
item_id_declared: false,
}),
..Default::default()
};
let settings = format!("const int shadowMapResolution = 16;\n{nearest}\n");
let files = vec![
("final.vsh".to_string(), VERTEX.to_string()),
(
"final.fsh".into(),
format!(
"#version 330 compatibility\n{settings}\
uniform sampler2D shadowtex0;\nuniform sampler2D shadowtex1;\n\
float moved(sampler2D s, ivec2 t) {{\n\
float a = texelFetch(s, t, 0).r;\n\
return (texture(s, (vec2(t) + vec2(0.75, 0.5)) / 16.0).r - a) * 16.0;\n\
}}\n\
void main() {{\n\
ivec2 t = ivec2(int(gl_FragCoord.x) % 15, 8);\n\
gl_FragColor = vec4(moved(shadowtex0, t) * 4.0, moved(shadowtex1, t) * 4.0, 0.0, 1.0);\n\
}}\n"
),
),
// The shadow map's depth runs from 0 at its left edge to 1 at its right.
(
"shadow.vsh".into(),
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,gl_Vertex.x,1.0);}".into(),
),
(
"shadow.fsh".into(),
format!("#version 120\n{settings}void main(){{gl_FragData[0]=vec4(1.0);}}"),
),
];
let mut post = gpu.load(
files,
World {
sprite_rects: &[],
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: Some(Geometry {
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
ids: None,
cull: false,
material_maps: false,
}),
block_ids: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
inputs,
);
assert!(post.has_shadow(), "{:?}", post.notes);
let positions: [[f32; 3]; 6] = [
[-1., -1., 0.],
[1., -1., 0.],
[1., 1., 0.],
[-1., -1., 0.],
[1., 1., 0.],
[-1., 1., 0.],
];
let vertices = gpu
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Original shadow depth ramp"),
contents: bytemuck::cast_slice(&positions),
usage: wgpu::BufferUsages::VERTEX,
});
let hud = HudBatch::new(SIZE);
let frame = fixture_frame(&hud);
Some(
gpu.render(&mut post, &color, &depth, &frame, &mut |rp, list, _, _| {
if list == TerrainList::ShadowSolid {
rp.set_vertex_buffer(0, vertices.slice(..));
rp.draw(0..6, 0..1);
}
}),
)
}
#[test]
fn shadow_depth_is_read_linearly_through_a_plain_sampler() {
// Iris 1.11.7 filters both (probe packs of our own; the PR has the readings).
if let Some(pixels) = shadow_depth_filtering("") {
all_pixels(&pixels, [255, 255, 0, 255], "shadowtex linear");
}
}
#[test]
fn shadowtex_nearest_settings_read_the_nearest_texel() {
for (line, expected) in [
("const bool shadowtex0Nearest = true;", [0, 255, 0, 255]),
("const bool shadowtexNearest = true;", [0, 255, 0, 255]),
("const bool shadow0MinMagNearest = true;", [0, 255, 0, 255]),
("const bool shadowtex1Nearest = true;", [255, 0, 0, 255]),
("const bool shadow1MinMagNearest = true;", [255, 0, 0, 255]),
] {
if let Some(pixels) = shadow_depth_filtering(line) {
all_pixels(&pixels, expected, line);
}
}
}
@@ -199,25 +199,7 @@ impl Fixture {
}
pub(super) fn build(&self, post: Post) -> PackPost {
let lightmap = self.texture([1, 1], T::Rgba8Unorm);
let post = PackPost::new(
&self.device,
&self.queue,
&self.adapter,
post,
T::Rgba8Unorm,
World {
atlas: None,
lightmap,
terrain: None,
block_ids: &[],
celestial: None,
gui_size: [16, 16],
clouds: None,
weather: false,
hand: false,
},
);
let post = self.try_build(post).expect("the pack runs");
assert!(
post.notes.iter().all(|n| n.starts_with("shadow map ")),
"{:?}",
@@ -226,7 +208,56 @@ 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,
&self.queue,
&self.adapter,
post,
T::Rgba8Unorm,
World {
atlas: None,
material_maps: None,
lightmap,
terrain,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: [16, 16],
clouds: None,
weather: false,
hand: false,
},
)
}
pub(super) fn render(&self, post: &mut PackPost, size: [u32; 2]) -> Vec<[u8; 4]> {
self.render_with_terrain(post, size, &mut |_, _, _, _| {})
}
/// `render`, with `terrain` drawing the game's terrain lists.
pub(super) fn render_with_terrain(
&self,
post: &mut PackPost,
size: [u32; 2],
terrain: &mut TerrainDraw<'_>,
) -> Vec<[u8; 4]> {
let world = self.texture(size, T::Rgba8Unorm);
let depth = self.texture(size, T::Depth32Float);
let screen = self.texture(size, T::Rgba8Unorm);
@@ -251,7 +282,7 @@ impl Fixture {
size,
size,
&[],
&mut |_, _, _, _| {},
terrain,
);
encoder.copy_texture_to_buffer(
screen.texture().as_image_copy(),
@@ -346,8 +377,8 @@ fn frame_clear_and_retained_history_apply_before_partial_post_writes() {
let Some(gpu) = Fixture::new() else { return };
for clear in [true, false] {
let mut post = gpu.build(pack(Coverage::Full, false, false, Some(clear), false));
// Every frame changes which physical image is main. The other image is fully
// seeded red before the partial write, independent of its initial clearing.
// The other image is fully seeded red before the partial write, independent of its
// initial clearing.
for frame in 0..3 {
let actual = gpu.render(&mut post, [16, 16]);
assert!(
@@ -433,6 +464,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
@@ -521,3 +553,6 @@ fn a_cleared_buffer_starts_every_frame_cleared_on_both_textures() {
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
}
}
#[path = "clear_false_tests.rs"]
mod clear_false_tests;
@@ -0,0 +1,73 @@
//! `flip.<program>.<buffer> = true` on a buffer the program writes (#9010). The expected values
//! are Iris 1.11.7's, measured with probe packs of our own.
use super::destination_tests::Fixture;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
/// `composite` writes 0.25 to colortex2 and colortex4 and forces both to flip: the forced flip
/// cancels the write's own, so `composite1` samples colortex2's cleared side (Iris 1.11.7: 0),
/// and `composite2`, blended `ONE ONE`, adds 0.5 onto colortex4's 0.25 (Iris 1.11.7: 0.749).
#[test]
fn a_forced_flip_of_a_written_buffer_leaves_it_unflipped() {
let Some(gpu) = Fixture::new() else { return };
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();
};
for (program, fragment) in [
(
"composite",
"#version 120\n/* RENDERTARGETS:2,4 */\n\
void main() { gl_FragData[0]=vec4(0.25,0.25,0.25,1); gl_FragData[1]=vec4(0.25,0.25,0.25,1); }\n",
),
(
"composite1",
"#version 120\nuniform sampler2D colortex2;\nvarying vec2 uv;\n/* RENDERTARGETS:3 */\n\
void main() { gl_FragData[0]=texture2D(colortex2,uv); }\n",
),
(
"composite2",
"#version 120\n/* RENDERTARGETS:4 */\nvoid main() { gl_FragData[0]=vec4(0.5,0.5,0.5,0); }\n",
),
(
"final",
"#version 120\nuniform sampler2D colortex3;\nuniform sampler2D colortex4;\nvarying vec2 uv;\n\
void main() { gl_FragColor=vec4(texture2D(colortex4,uv).r,texture2D(colortex3,uv).r,0,1); }\n",
),
] {
add(&format!("{program}.vsh"), VERTEX);
add(&format!("{program}.fsh"), fragment);
}
add(
"shaders.properties",
"flip.composite.colortex2=true\nflip.composite.colortex4=true\nblend.composite2=ONE ONE ONE ONE\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"forced-flip-of-a-written-buffer",
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());
let mut post = gpu.build(post);
for frame in 0..2 {
let actual = gpu.render(&mut post, [16, 16]);
let wrong: Vec<_> = (actual.iter().enumerate())
.filter(|(_, p)| {
p.iter()
.zip([191, 0, 0, 255])
.any(|(a, b)| a.abs_diff(b) > 1)
})
.map(|(n, p)| (n, *p))
.take(4)
.collect();
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
}
}
@@ -0,0 +1,89 @@
//! 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. 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'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(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add("composite.vsh", VERTEX);
// With depthtex0: green where it holds the empty world's far plane (1), red elsewhere.
let value = if depth {
"texelFetch(depthtex0, ivec2(gl_FragCoord.xy), 0).r == 1.0 ? uvec4(0u, 200u, 0u, 255u) : uvec4(200u, 0u, 0u, 255u)"
} else {
"uvec4(0u, 200u, 0u, 255u)"
};
add(
"composite.fsh",
&format!(
"#version 330 compatibility\n/* RENDERTARGETS: 0 */\n/*\nconst int colortex0Format = RGBA32UI;\n*/\n\
uniform sampler2D depthtex0;\nlayout(location = 0) out uvec4 o;\nvoid main() {{ o = {value}; }}\n"
),
);
if let Some(source) = last {
add("final.vsh", VERTEX);
add("final.fsh", source);
}
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-integer-colortex0",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
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, 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]);
let wrong: Vec<_> = pixels.iter().filter(|p| **p != [0, 200, 0, 255]).collect();
assert!(wrong.is_empty(), "depth {depth}, frame {frame}: {wrong:?}");
}
}
}
/// 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_a_working_final_is_refused() {
let Some(gpu) = Fixture::new() else { return };
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<_>>()
);
}
}
}
@@ -0,0 +1,129 @@
//! The shadow pass draws terrain faces from both sides, as Iris 1.11.7 does (#9033: a probe pack
//! of ours writing `gl_FrontFacing` into `shadowcolor0` found back faces in Iris's shadow map and
//! none in Horizon's). The programs here are original.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
use wgpu::util::DeviceExt;
const FINAL_VSH: &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();
};
// The terrain's positions are already in clip space.
add(
"shadow.vsh",
"#version 120\nvoid main() { gl_Position=vec4(gl_Vertex.xy,0.5,1.0); }\n",
);
// Red where a front face was drawn, blue where a back face was.
add(
"shadow.fsh",
"#version 120\nvoid main() {\n\
gl_FragData[0]=gl_FrontFacing ? vec4(1.0,0.0,0.0,1.0) : vec4(0.0,0.0,1.0,1.0);\n}\n",
);
add("final.vsh", FINAL_VSH);
add(
"final.fsh",
"#version 120\nconst int shadowMapResolution=16;\nuniform sampler2D shadowcolor0;\n\
varying vec2 uv;\nvoid main() { gl_FragColor=texture2D(shadowcolor0,uv); }\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-shadow-faces",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
item_id_declared: false,
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
.into(),
attributes: vec![],
after: String::new(),
alpha_test: None,
}),
..Default::default()
};
horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs)
}
#[test]
fn shadow_pass_draws_terrain_back_faces() {
let Some(gpu) = Fixture::new() else { return };
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
let layouts = [Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRS,
})];
// Culled as the game's own terrain is (`Renderer` hands packs `cull: true`).
let mut post = gpu.build_with_terrain(
pack(),
Some(Geometry {
material_maps: false,
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
ids: None,
cull: true,
}),
);
assert!(post.has_shadow(), "{:?}", post.notes);
// GL clip space: the left half counter-clockwise (a front face), the right half
// clockwise (a back face).
let positions: [[f32; 3]; 12] = [
[-1., -1., 0.],
[0., -1., 0.],
[0., 1., 0.],
[-1., -1., 0.],
[0., 1., 0.],
[-1., 1., 0.],
[0., -1., 0.],
[1., 1., 0.],
[1., -1., 0.],
[0., -1., 0.],
[0., 1., 0.],
[1., 1., 0.],
];
let vertices = gpu
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Original shadow faces geometry"),
contents: bytemuck::cast_slice(&positions),
usage: wgpu::BufferUsages::VERTEX,
});
let size = [8, 8];
let pixels = gpu.render_with_terrain(&mut post, size, &mut |rp, list, _, _| {
if list == TerrainList::ShadowSolid {
rp.set_vertex_buffer(0, vertices.slice(..));
rp.draw(0..12, 0..1);
}
});
let near = |got: [u8; 4], want: [u8; 4]| got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1);
for (i, got) in pixels.iter().enumerate() {
let x = i as u32 % size[0];
let want = if x < size[0] / 2 {
[255, 0, 0, 255]
} else {
[0, 0, 255, 255]
};
assert!(
near(*got, want),
"pixel ({x}, {}): {got:?}, expected {want:?} (red: front face, blue: back face)",
i as u32 / size[0]
);
}
}
@@ -0,0 +1,78 @@
//! `min3`, `max3` and `mid3` of GL_AMD_shader_trinary_minmax: the per-component minimum, maximum
//! and median of three values, for float, int and uint vectors (the extension's additions to
//! GLSL 4.30 Section 8.3). An original probe; the expected values are worked out by hand.
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("final.vsh", VERTEX);
// Pixel column x shows case x % 8 in red, green and blue.
add(
"final.fsh",
"#version 330 compatibility\n#extension GL_AMD_shader_trinary_minmax : require\n\
void main() {\n int x = int(gl_FragCoord.x) % 8;\n\
vec3 a = vec3(0.1, 0.9, 0.4), b = vec3(0.6, 0.2, 0.8), c = vec3(0.3, 0.5, 0.0);\n\
ivec3 i = ivec3(-3, 7, 2), j = ivec3(5, -1, 9), k = ivec3(0, 4, -6);\n\
uvec3 u = uvec3(1u, 8u, 3u), v = uvec3(6u, 2u, 7u), w = uvec3(4u, 5u, 0u);\n\
vec3 o = vec3(0.0);\n\
switch (x) {\n\
case 0: o = min3(a, b, c); break;\n\
case 1: o = max3(a, b, c); break;\n\
case 2: o = mid3(a, b, c); break;\n\
case 3: o = vec3(mid3(i, j, k)) / 10.0; break;\n\
case 4: o = vec3(max3(u, v, w)) / 10.0; break;\n\
case 5: o = vec3(min3(i, j, k) + 6) / 10.0; break;\n\
case 6: o = vec3(mid3(u.x, v.x, w.x), mid3(u.y, v.y, w.y), mid3(u.z, v.z, w.z)) / 10.0; break;\n\
case 7: o = vec3(max3(a.x, b.x, c.x), min3(a.yz, b.yz, c.yz)); break;\n\
}\n gl_FragColor = vec4(o, 1.0);\n}\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-trinary",
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(), 1, "{:?}", post.notes);
post
}
#[test]
fn trinary_min_max_and_median_per_component() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
let pixels = gpu.render(&mut post, [8, 8]);
// Tenths, by hand: a = (1, 9, 4), b = (6, 2, 8), c = (3, 5, 0); i = (-3, 7, 2),
// j = (5, -1, 9), k = (0, 4, -6); u = (1, 8, 3), v = (6, 2, 7), w = (4, 5, 0).
let tenths: [[u8; 3]; 8] = [
[1, 2, 0],
[6, 9, 8],
[3, 5, 4],
[0, 4, 2],
[6, 8, 7],
[3, 5, 0],
[4, 5, 3],
[6, 2, 0],
];
let row: Vec<[u8; 3]> = pixels[..8].iter().map(|p| [p[0], p[1], p[2]]).collect();
for (case, (got, want)) in row.iter().zip(tenths).enumerate() {
let want = want.map(|t| (f32::from(t) * 25.5).round() as u8);
assert!(
got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1),
"case {case}: {got:?}, expected {want:?}; row {row:?}"
);
}
}
@@ -215,16 +215,19 @@ fn pack(
T::Rgba8Unorm,
World {
atlas: None,
material_maps: None,
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
}
@@ -351,6 +354,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
@@ -35,6 +35,13 @@ pub(super) struct ShadowTargets {
pub depth: (wgpu::Texture, wgpu::TextureView),
/// `shadowtex1`: a copy of `shadowtex0` taken before translucent terrain, when read.
pub depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
/// `shadowtex0`/`1` as a plain sampler filters them: Iris 1.11.7 reads them linearly unless
/// a pack asks for the nearest texel (measured). wgpu filters a depth format only for a
/// comparison, so these are `R32Float` copies of the final depths, when read that way.
pub linear: [Option<wgpu::TextureView>; 2],
/// The rows a depth copy goes through to `linear` (a depth format copies only to a buffer),
/// and their stride in bytes.
staging: Option<(wgpu::Buffer, u32)>,
/// Both sides of each shadowcolor buffer used.
pub color: [Option<[wgpu::TextureView; 2]>; SHADOW_COLORS],
/// The clear colour of each buffer cleared every frame.
@@ -77,13 +84,15 @@ fn target(
impl ShadowTargets {
/// The buffers of `shadow` the pack uses: `colors` (bit n: shadowcolor n), `depth1` when
/// some program reads `shadowtex1`. The resolution is kept within the device's limit.
/// some program reads `shadowtex1`, `linear` (bit n: shadowtex n read through a filtering
/// sampler). The resolution is kept within the device's limit.
pub fn new(
device: &wgpu::Device,
shadow: &horizon_shaders::Shadow,
formats: [wgpu::TextureFormat; SHADOW_COLORS],
colors: u16,
depth1: bool,
linear: u8,
) -> Self {
let size = shadow
.resolution
@@ -105,10 +114,43 @@ impl ShadowTargets {
})
})
});
let linear = [0, 1].map(|n| {
(linear & 1 << n != 0).then(|| {
let label = format!("Shader pack shadowtex{n} (filtered)");
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(&label),
size: wgpu::Extent3d {
width: size,
height: size,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
})
.create_view(&Default::default())
})
});
let staging = linear.iter().any(Option::is_some).then(|| {
let stride = (size * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack shadow depth rows"),
size: u64::from(stride) * u64::from(size),
usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
(buffer, stride)
});
Self {
size,
depth: target(device, "Shader pack shadowtex0", size, depth, copy),
depth1: depth1.then(|| target(device, "Shader pack shadowtex1", size, depth, copy)),
linear,
staging,
color,
clears: std::array::from_fn(|n| {
let b = &shadow.colors[n];
@@ -214,4 +256,38 @@ impl ShadowTargets {
);
}
}
/// `linear`: the filtered copies of the depths the shadow pass left (`shadowtex1` from its
/// copy before translucent geometry).
pub fn copy_linear(&self, encoder: &mut wgpu::CommandEncoder) {
let Some((buffer, stride)) = &self.staging else {
return;
};
for (n, linear) in self.linear.iter().enumerate() {
let Some(linear) = linear else {
continue;
};
let depth = match (n, &self.depth1) {
(1, Some(d)) => &d.0,
_ => &self.depth.0,
};
let rows = wgpu::TexelCopyBufferInfo {
buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(*stride),
rows_per_image: None,
},
};
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
aspect: wgpu::TextureAspect::DepthOnly,
..depth.as_image_copy()
},
rows,
depth.size(),
);
encoder.copy_buffer_to_texture(rows, linear.texture().as_image_copy(), depth.size());
}
}
}
@@ -97,6 +97,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
// At the far plane: the sky shows only where nothing else drew, and it writes no depth.
after: " gl_Position.z = gl_Position.w;\n".into(),
alpha_test: None,
item_id_declared: false,
}
}
@@ -122,6 +123,7 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
],
after: " gl_Position.z = gl_Position.w;\n".into(),
alpha_test: None,
item_id_declared: false,
}
}
@@ -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],
@@ -1388,6 +1473,7 @@ mod initial_history_tests {
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
@@ -44,6 +44,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
op: ">",
reference: 0.1,
}),
item_id_declared: false,
}
}
@@ -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| {
+7 -2
View File
@@ -27,6 +27,8 @@ use horizon_world::{
fluid::{FluidKind, FluidState},
registry::BlockStateId,
};
#[cfg(test)]
pub(crate) use sides::sort_tinted;
pub use sides::{Part, Sides, group_parts};
use std::sync::Arc;
@@ -1405,7 +1407,8 @@ fn pack_coords(coords: u32) -> u16 {
pub struct Mesher<'a> {
/// CardinalLighting.NETHER: brighter up/down faces.
pub nether_shading: bool,
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours.
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours,
/// only the dimension's up-face brightness (`lighting::pack_flat`).
pub shader_pack_lighting: bool,
/// OptiFine/Iris separate ambient occlusion: vertex RGB does not include AO, and AO factor is passed in vertex alpha.
pub separate_ao: bool,
@@ -1660,8 +1663,10 @@ impl Mesher<'_> {
None => ao,
};
let shade_face = q.shade_direction_override.unwrap_or(q.facing);
let directional = if self.shader_pack_lighting || !q.shade {
let directional = if !q.shade {
1.0
} else if self.shader_pack_lighting {
lighting::pack_flat(self.nether_shading)
} else {
lighting::cardinal(self.nether_shading, shade_face)
};
@@ -189,8 +189,9 @@ impl FluidFlowWorld for FlowView<'_> {
}
}
/// Emits one fluid cell at section-relative `[x, y, z]`.
/// `(nether, shader_pack_lighting, separate_ao)`: the Nether's face shading, none when a shader pack
/// lights the faces (`Mesher::shader_pack_lighting`), and whether to keep pure alpha for separate AO.
/// `(nether, shader_pack_lighting, separate_ao)`: the Nether's face shading, only the up-face value
/// when a shader pack lights the faces (`Mesher::shader_pack_lighting`), and whether to keep pure
/// alpha for separate AO.
pub(super) fn tesselate(
tables: &MeshTables,
(nether, shader_pack_lighting, separate_ao): (bool, bool, bool),
@@ -202,7 +203,7 @@ pub(super) fn tesselate(
use horizon_assets::model::Direction as D;
let cardinal = |d: D| {
if shader_pack_lighting {
1.0
lighting::pack_flat(nether)
} else {
lighting::cardinal(nether, d)
}
@@ -447,7 +448,10 @@ pub(super) fn tesselate(
Some(layer) if face_state.overlay => (layer, true),
_ => (flow, false),
};
let side = if matches!(d, Direction::North | Direction::South) {
// Vanilla's side is the up value times the side's; a shader pack's has no side factor.
let side = if shader_pack_lighting {
1.0
} else if matches!(d, Direction::North | Direction::South) {
cardinal(D::North)
} else {
cardinal(D::West)
@@ -19,6 +19,12 @@ pub(super) fn cardinal(nether: bool, face: D) -> f32 {
D::West | D::East => 0.6,
}
}
/// A face's brightness when a shader pack lights the faces (`Mesher::shader_pack_lighting`): no
/// directional shading, every face at the dimension's up-face value (1.0, the Nether's 0.9).
/// Our probe pack's Iris 1.11.7 values fit this: 0.9 on every Nether face, 1.0 in the overworld.
pub(super) fn pack_flat(nether: bool) -> f32 {
cardinal(nether, D::Up)
}
fn normal(face: D) -> [i32; 3] {
match face {
D::Down => [0, -1, 0],
@@ -159,6 +159,60 @@ fn a_shader_pack_lights_the_faces_itself() {
assert_eq!(colours(true).len(), 1);
}
/// Run explicitly with prepared assets. With a shader pack lighting the faces, every face of a
/// block and of a lava source takes the dimension's up-face brightness, as Iris 1.11.7 gives our
/// probe pack: 0.9 in the Nether (`CardinalLighting.NETHER`'s up, 229 of 255), 1.0 elsewhere.
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn a_shader_pack_lights_every_nether_face_like_an_up_face() {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
let assets = Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let biomes = Biomes::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
let state = |name| assets.registry.default_state(name).unwrap().0;
let mut chunk = Chunk::empty(0, 0);
chunk.set(4, MIN_Y + 8, 4, state("minecraft:netherrack"));
chunk.set(11, MIN_Y + 8, 11, state("minecraft:lava"));
let mut region = [[None; 3]; 3];
region[1][1] = Some(&chunk);
let colours = |nether_shading| {
let mut mesher = Mesher {
nether_shading,
shader_pack_lighting: true,
separate_ao: false,
assets: &assets,
render: &render,
tables: &tables,
biomes: &biomes,
visibility: visibility.clone(),
zoom: BiomeZoom::new(0),
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
let mesh = mesher.mesh(&region, 0);
// Both blocks' six faces: 2 × 6 quads of 4 vertices.
let all: Vec<_> = mesh.solid.iter().chain(&mesh.translucent).collect();
assert!(all.len() >= 48, "{} vertices", all.len());
let mut colours: Vec<u32> = all.iter().map(|v| v.color & 0xFF_FFFF).collect();
colours.sort_unstable();
colours.dedup();
colours
};
assert_eq!(colours(true), [0xE5_E5E5]);
assert_eq!(colours(false), [0xFF_FFFF]);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn separate_ao_passes_ao_in_alpha_without_darkening_rgb() {
+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);
+96 -3
View File
@@ -170,8 +170,17 @@ fn last_pre_minor(current: Format) -> i64 {
/// `PackFormat.fullCodec(default_minor)`: an int, or a list of 1 to 256
/// non-negative ints whose second is the minor. None: it does not parse.
fn format_codec(v: &serde_json::Value, default_minor: i64) -> Option<Format> {
let non_negative =
|v: &serde_json::Value| v.as_i64().filter(|n| *n >= 0 && *n <= i64::from(i32::MAX));
// `Codec.INT` reads any JSON number through `Number.intValue`: a decimal such as `97.1`
// counts as its whole part (97), as it does for the game.
let non_negative = |v: &serde_json::Value| {
v.as_i64()
.or_else(|| {
v.as_f64()
.filter(|f| f.is_finite())
.map(|f| f.trunc() as i64)
})
.filter(|n| *n >= 0 && *n <= i64::from(i32::MAX))
};
match v {
serde_json::Value::Array(a) if (1..=256).contains(&a.len()) => {
let list: Option<Vec<i64>> = a.iter().map(non_negative).collect();
@@ -636,7 +645,7 @@ fn build_overlay_uncached(
}
// FallbackResourceManager: a file's `.mcmeta` counts only from the pack that
// supplies the file or one above it; vanilla's own sits below every pack.
let hidden: BTreeSet<String> = files
let mut hidden: BTreeSet<String> = files
.iter()
.filter(|(name, _)| !name.ends_with(".mcmeta"))
.filter_map(|(name, (owner, _))| {
@@ -647,6 +656,25 @@ fn build_overlay_uncached(
}
})
.collect();
// A companion from below the winning colour would describe another surface.
// A map-only pack above it is intentional and remains valid.
for (name, (owner, _)) in &files {
let Some(stem) = name.strip_suffix(".png") else {
continue;
};
if stem.ends_with("_n") || stem.ends_with("_s") {
continue;
}
for suffix in ["_n.png", "_s.png"] {
let map = format!("{stem}{suffix}");
if files
.get(&map)
.is_none_or(|(map_owner, _)| map_owner < owner)
{
hidden.insert(map);
}
}
}
for name in &hidden {
files.remove(name);
}
@@ -1257,6 +1285,16 @@ mod tests {
.unwrap_err()
.contains("deprecated")
);
// A decimal reads as its whole part, in a list or alone: `[97.1]` is format 97 (a pack
// that meant `[97, 1]` still loads and counts as compatible).
assert_eq!(
check(r#"{"min_format":[97.1],"max_format":[97.1]}"#),
Ok(((97, 0), (97, i64::from(i32::MAX))))
);
assert_eq!(
check(r#"{"min_format":97.9,"max_format":98.5}"#),
Ok(((97, 0), (98, i64::from(i32::MAX))))
);
assert_eq!(
check(r#"{"min_format":97,"max_format":98}"#),
Ok(((97, 0), (98, i64::from(i32::MAX))))
@@ -1469,6 +1507,61 @@ mod tests {
assert_eq!(merge_kind("assets/minecraft/models/block/stone.json"), None);
}
#[test]
fn companion_maps_follow_the_winning_colour_or_a_higher_map_only_pack() {
let root =
std::env::temp_dir().join(format!("horizon-material-owner-{}", std::process::id()));
let vanilla = root.join("vanilla");
let relative = "assets/minecraft/textures/block";
fs::create_dir_all(vanilla.join(relative)).unwrap();
let names = ["stone.png", "stone_n.png", "stone_s.png"];
for name in names {
fs::write(vanilla.join(relative).join(name), b"base").unwrap();
}
for (pack, files) in [
("low", vec!["stone_n.png", "stone_s.png"]),
("colour", vec!["stone.png"]),
("maps", vec!["stone_n.png"]),
] {
let dir = root.join("packs").join(pack);
fs::create_dir_all(dir.join(relative)).unwrap();
fs::write(
dir.join("pack.mcmeta"),
br#"{"pack":{"description":"fixture","pack_format":97}}"#,
)
.unwrap();
for name in files {
fs::write(dir.join(relative).join(name), pack.as_bytes()).unwrap();
}
}
let all = discover(&vanilla, &root.join("packs"));
let order = |names: &[&str]| {
names
.iter()
.map(|name| {
all.iter()
.find(|p| p.id == format!("file/{name}"))
.unwrap()
.clone()
})
.collect::<Vec<_>>()
};
let out = root.join("out");
build_overlay(&vanilla, &order(&["low", "colour"]), &out).unwrap();
assert_eq!(
fs::read(out.join(relative).join("stone.png")).unwrap(),
b"colour"
);
assert!(!out.join(relative).join("stone_n.png").exists());
assert!(!out.join(relative).join("stone_s.png").exists());
build_overlay(&vanilla, &order(&["low", "colour", "maps"]), &out).unwrap();
assert_eq!(
fs::read(out.join(relative).join("stone_n.png")).unwrap(),
b"maps"
);
assert!(!out.join(relative).join("stone_s.png").exists());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn overlay_layers_packs_over_vanilla() {
// `&` would split an unquoted `cmd /C mklink` line.
let base = std::env::temp_dir().join(format!("horizon-packs-&{}", std::process::id()));
@@ -13,7 +13,7 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
// Version 5 read every vanilla file to stamp it on Windows.
// Version 6 stamps vanilla's files by their metadata on Windows too.
// Version 7 stacks blockstate files (every pack's definition, bottom first).
const VERSION: u32 = 7;
const VERSION: u32 = 8;
#[derive(Default, Serialize, Deserialize)]
pub(super) struct Dependencies {
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;
@@ -653,6 +659,30 @@ pub fn around_self(
}
}
/// `LivingEntity.handleEntityEvent(46)`: 128 `ParticleTypes.PORTAL` samples
/// from the entity's old position (`xo`, `yo`, `zo`) to its current one,
/// spread across the bounding box (`getBbWidth() * 2` on x and z, `getBbHeight`
/// on y). The speeds are `(nextFloat() - 0.5) * 0.2`.
pub fn living_teleport_particles(
random: &mut impl RandomSource,
from: [f64; 3],
to: [f64; 3],
width: f64,
height: f64,
mut emit: impl FnMut([f64; 3], [f64; 3]),
) {
for i in 0..128 {
let d = i as f64 / 127.0;
let velocity = [0; 3].map(|_| f64::from((random.next_float() - 0.5) * 0.2));
let position = [
from[0] + d * (to[0] - from[0]) + (random.next_double() - 0.5) * width * 2.0,
from[1] + d * (to[1] - from[1]) + random.next_double() * height,
from[2] + d * (to[2] - from[2]) + (random.next_double() - 0.5) * width * 2.0,
];
emit(position, velocity);
}
}
/// ClientLevel.addDestroyBlockEffect placement and pre-constructor velocities.
pub fn destroy_positions(
pos: [i32; 3],
@@ -841,8 +871,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 +1479,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 +1647,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 +3812,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 +4743,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) {
@@ -5318,6 +5435,47 @@ mod tests {
));
}
/// `LivingEntity.handleEntityEvent(46)`: 128 samples, the first on the old
/// box and the last on the new one, with speeds inside ±0.1.
#[test]
fn living_teleport_particles_span_the_old_and_new_boxes() {
let mut random = LegacyRandom::new(1);
let mut samples = Vec::new();
living_teleport_particles(
&mut random,
[0.0, 1.0, 0.0],
[8.0, 1.0, 0.0],
0.6,
1.8,
|p, v| samples.push((p, v)),
);
assert_eq!(samples.len(), 128);
let first = samples[0].0;
let last = samples[127].0;
assert!(first[0].abs() <= 0.6, "{first:?}");
assert!((1.0..2.8).contains(&first[1]), "{first:?}");
assert!(first[2].abs() <= 0.6, "{first:?}");
assert!((7.4..8.6).contains(&last[0]), "{last:?}");
assert!((1.0..2.8).contains(&last[1]), "{last:?}");
assert!(last[2].abs() <= 0.6, "{last:?}");
// `(nextDouble - 0.5) * width * 2` reaches ±width. A spread of only
// `width` (the factor of 2 dropped) stays inside 0.3 of the line.
let edge = samples
.iter()
.enumerate()
.map(|(i, (p, _))| {
let along = (i as f64 / 127.0) * 8.0;
(p[0] - along).abs()
})
.fold(0.0_f64, f64::max);
assert!(edge > 0.45, "box spread reached only {edge}");
for (_, v) in &samples {
for c in v {
assert!((-0.1..0.1).contains(c), "{v:?}");
}
}
}
#[test]
fn egg_teleport_unpacks_offsets_and_preserves_seeded_draw_order() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
@@ -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]
+137 -51
View File
@@ -8,6 +8,7 @@ pub struct SpriteAnimation {
pub layer: u32,
frames: Arc<Vec<Vec<u8>>>,
clip: Arc<AnimationClip>,
materials: [Option<Arc<Vec<Vec<u8>>>>; 2],
}
/// What an animated sprite shows: `frame`, mixed `millis` thousandths of the
/// way towards `next` (0 for a single frame).
@@ -18,6 +19,14 @@ pub struct FrameBlend {
pub millis: u32,
}
impl SpriteAnimation {
pub(crate) fn has_materials(&self) -> bool {
self.materials.iter().any(Option::is_some)
}
/// Material categories follow the selected colour frame, without colour interpolation.
pub(crate) fn material_pixels(&self, kind: usize, tick: u64) -> Option<&[u8]> {
let frames = self.materials[kind].as_ref()?;
Some(&frames[self.frame(tick) % frames.len()])
}
pub fn frame(&self, tick: u64) -> usize {
self.clip.sample(tick).frame as usize
}
@@ -88,9 +97,9 @@ pub struct Sprites {
/// Each layer's frames at the texture's own resolution (square), and the
/// frame shown first.
native: Vec<(u32, Arc<Vec<Vec<u8>>>, usize)>,
/// Each layer's LabPBR maps (`_n` normals, `_s` specular) beside its texture: their first
/// frame at their own resolution (square).
pbr: Vec<[Option<(u32, Vec<u8>)>; 2]>,
/// Each layer's LabPBR maps (`_n` normals, `_s` specular) beside its texture: their
/// frames at their own resolution (square).
pbr: Vec<[Option<(u32, Vec<Vec<u8>>)>; 2]>,
/// The layers' normal and specular maps at `pbr_size`, layer after layer, set by
/// [`Sprites::finish`]: empty when no texture has either.
pub normals: Vec<u8>,
@@ -126,7 +135,7 @@ pub fn set_texture_budget(mib: u32) {
/// Whether resource loads read the resource packs' LabPBR maps (`set_labpbr_maps`).
static LABPBR_MAPS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether resource loads from now on read the resource packs' LabPBR maps beside their block
/// textures. Only a native shader pack's material stage reads them, so without one they cost
/// textures. Native material stages and terrain shader packs read them; without a pack they cost
/// neither loading time nor video memory.
pub fn set_labpbr_maps(on: bool) {
LABPBR_MAPS.store(on, std::sync::atomic::Ordering::Relaxed);
@@ -135,7 +144,7 @@ pub fn set_labpbr_maps(on: bool) {
pub fn labpbr_maps() -> bool {
LABPBR_MAPS.load(std::sync::atomic::Ordering::Relaxed)
}
/// Largest array of each LabPBR map, which only a native shader pack reads.
/// Budget for either complete normal or specular image set.
const MAX_MAP_BYTES: u64 = DEFAULT_TEXTURE_BUDGET / 4;
/// Fewer layers than any game has (vanilla's are about 1300): what a map is kept at while
/// loading assumes at least these many share the array.
@@ -153,30 +162,12 @@ fn side_within(start: u32, layers: u64, bytes: u64) -> u32 {
}
size
}
/// A LabPBR map's square `frame` of `side` averaged down by whole powers of two to at most
/// `max`, channel by channel: they are data, not colour.
/// Reduce data maps by selecting whole texels: material IDs and sentinel bytes cannot be averaged.
fn shrink_map(frame: Vec<u8>, side: u32, max: u32) -> (u32, Vec<u8>) {
if side <= max || !side.is_power_of_two() || !max.is_power_of_two() {
return (side, frame);
}
let (out_side, f, side) = (max as usize, (side / max) as usize, side as usize);
let mut out = Vec::with_capacity(out_side * out_side * 4);
for y in 0..out_side {
for x in 0..out_side {
let mut sum = [0u32; 4];
for dy in 0..f {
let row = ((y * f + dy) * side + x * f) * 4;
for texel in frame[row..row + f * 4].as_chunks::<4>().0 {
for (s, &v) in sum.iter_mut().zip(texel) {
*s += u32::from(v);
}
}
}
let n = (f * f) as u32;
out.extend(sum.map(|s| ((s + n / 2) / n) as u8));
}
}
(max, out)
(max, resample(&frame, side, max))
}
/// Nearest-resample a square RGBA frame to another square size.
pub(crate) fn resample(frame: &[u8], from: u32, to: u32) -> Vec<u8> {
@@ -289,14 +280,28 @@ impl Sprites {
if largest_map.is_some() {
let pbr_size = self.pbr_size;
log::info!("Horizon: LabPBR maps at {pbr_size} px, {layers} layers");
for maps in std::mem::take(&mut self.pbr) {
for animation in &mut self.animations {
animation.materials = self.pbr[animation.layer as usize].each_ref().map(|map| {
map.as_ref().map(|(from, frames)| {
Arc::new(
frames
.iter()
.map(|frame| resample(frame, *from, pbr_size))
.collect(),
)
})
});
}
for (layer, maps) in std::mem::take(&mut self.pbr).into_iter().enumerate() {
for (map, (out, none)) in maps.into_iter().zip([
(&mut self.normals, FLAT_NORMAL),
(&mut self.specular, NO_SPECULAR),
]) {
match map {
Some((from, pixels)) => {
let (from, pixels) = shrink_map(pixels, from, pbr_size);
Some((from, frames)) => {
let initial = self.native[layer].2 % frames.len();
let (from, pixels) =
shrink_map(frames[initial].clone(), from, pbr_size);
out.extend(resample(&pixels, from, pbr_size));
}
None => out.extend(none.repeat((pbr_size * pbr_size) as usize)),
@@ -638,8 +643,10 @@ impl Sprites {
let pbr = match name.split_once(':') {
Some(_) if labpbr_maps() => {
let path = crate::atlas_sources::texture_path(root, name, "textures");
["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count))
["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count, animation.as_ref()))
}
None if labpbr_maps() && !name.starts_with("fusion/") => ["_n", "_s"]
.map(|suffix| pbr_map(&root.join(name), suffix, count, animation.as_ref())),
_ => [None, None],
};
let settings = crate::mipmap::Settings {
@@ -691,6 +698,7 @@ impl Sprites {
layer,
frames: frames.clone(),
clip: Arc::new(clip),
materials: [None, None],
});
}
self.mips.push(settings);
@@ -734,32 +742,64 @@ fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
Ok((info.width, info.height, pixels))
}
/// The LabPBR map with `suffix` beside the texture at `path`, whose frames lie in a column of
/// `count`: its first frame, square, no larger than the maps' array allows any game's
/// textures (a 512x pack's maps would otherwise fill the memory while loading).
fn pbr_map(path: &Path, suffix: &str, count: u32) -> Option<(u32, Vec<u8>)> {
/// Companion frames use the colour image's grid; a single-frame map stays static.
fn pbr_map(
path: &Path,
suffix: &str,
count: u32,
animation: Option<&AnimationMetadata>,
) -> Option<(u32, Vec<Vec<u8>>)> {
let stem = path.file_stem()?.to_str()?;
let map = path.with_file_name(format!("{stem}{suffix}.png"));
let (map_width, map_height, pixels) = read_rgba(&map).ok()?;
let (w, h) = (map_width, map_height / count.max(1));
let (width, height, _) = read_rgba(path).ok()?;
let [frame_width, frame_height] = animation.map_or([width as i32, height as i32], |a| {
a.frame_size(width as i32, height as i32)
});
let (frame_width, frame_height) = (
u32::try_from(frame_width).ok()?,
u32::try_from(frame_height).ok()?,
);
if frame_width == 0 || frame_height == 0 {
return None;
}
let (columns, rows) = if count <= 1 || (map_width == frame_width && map_height == frame_height)
{
(1, 1)
} else if u64::from(map_width) * u64::from(height) == u64::from(map_height) * u64::from(width) {
(width / frame_width, height / frame_height)
} else if u64::from(map_width) * u64::from(frame_height)
== u64::from(map_height) * u64::from(frame_width)
{
(1, 1)
} else {
return None;
};
if columns == 0 || rows == 0 || map_width % columns != 0 || map_height % rows != 0 {
return None;
}
let (w, h) = (map_width / columns, map_height / rows);
if w == 0 || h == 0 {
return None;
}
let side = w.max(h);
let mut frame = Vec::with_capacity((side * side * 4) as usize);
for row in 0..side {
for column in 0..side {
let offset = (((row * h / side) * map_width + column * w / side) * 4) as usize;
frame.extend_from_slice(&pixels[offset..offset + 4]);
}
}
Some(shrink_map(
frame,
side,
side_within(side, LAYERS_AT_LEAST, MAX_MAP_BYTES),
))
let output_side = side_within(side, LAYERS_AT_LEAST, MAX_MAP_BYTES).min(side);
let frames = (0..columns * rows)
.map(|index| {
let (x, y) = (index % columns * w, index / columns * h);
let mut frame = Vec::with_capacity((side * side * 4) as usize);
for row in 0..side {
for column in 0..side {
let offset =
(((y + row * h / side) * map_width + x + column * w / side) * 4) as usize;
frame.extend_from_slice(&pixels[offset..offset + 4]);
}
}
resample(&frame, side, output_side)
})
.collect();
Some((output_side, frames))
}
/// SpriteContents: the `.mcmeta` `texture` section (TextureMetadataSection's
/// mipmap strategy and alpha cutoff bias).
fn texture_section(
@@ -790,6 +830,7 @@ pub(crate) fn test_frames(layer: u32, frames: Vec<Vec<u8>>, metadata: &str) -> S
layer,
clip: Arc::new(metadata.clip(frames.len() as u32).unwrap().unwrap()),
frames: Arc::new(frames),
materials: [None, None],
}
}
@@ -815,8 +856,8 @@ mod tests {
assert_eq!(side_within(512, 1300, 1), SPRITE_SIZE);
}
#[test]
fn a_labpbr_map_shrinks_by_plain_means_and_a_layer_without_one_gets_the_default() {
// Alpha is data here: it does not weigh the other channels.
fn a_labpbr_map_selects_whole_texels_and_missing_layers_get_defaults() {
// Selecting a texel preserves encoded channels, including categorical values.
let frame = [
[200, 100, 0, 255],
[0, 0, 0, 0],
@@ -824,14 +865,14 @@ mod tests {
[0, 0, 0, 0],
]
.concat();
assert_eq!(shrink_map(frame, 2, 1), (1, vec![100, 50, 0, 128]));
assert_eq!(shrink_map(frame, 2, 1), (1, vec![200, 100, 0, 255]));
let mut sprites = Sprites {
native: vec![
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
],
pbr: vec![
[Some((16, [9, 8, 7, 6].repeat(16 * 16))), None],
[Some((16, vec![[9, 8, 7, 6].repeat(16 * 16)])), None],
[None, None],
],
..Default::default()
@@ -844,6 +885,50 @@ mod tests {
assert_eq!(sprites.specular.len(), 2 * 16 * 16 * 4);
}
#[test]
fn companion_grid_and_static_map_follow_colour_frame_selection() {
let root =
std::env::temp_dir().join(format!("horizon-companion-grid-{}", std::process::id()));
fs::create_dir_all(&root).unwrap();
let write = |name: &str, width, height, pixels: &[u8]| {
let mut encoder =
png::Encoder::new(File::create(root.join(name)).unwrap(), width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(pixels)
.unwrap();
};
write("grid.png", 4, 4, &[200, 200, 200, 255].repeat(16));
let maps: Vec<u8> = (0..16)
.flat_map(|i| {
let cell = (i / 8) * 2 + (i % 4) / 2;
[10 + cell as u8, 230, 65, 255]
})
.collect();
write("grid_s.png", 4, 4, &maps);
write("grid_n.png", 2, 2, &[128, 128, 255, 255].repeat(4));
let metadata =
r#"{"width":2,"height":2,"frametime":2,"interpolate":true,"frames":[3,1,2,0]}"#;
let animation = AnimationMetadata::from_json(metadata).unwrap();
let (side, frames) = pbr_map(&root.join("grid.png"), "_s", 4, Some(&animation)).unwrap();
assert_eq!(side, 2);
assert_eq!(
frames.iter().map(|f| f[0]).collect::<Vec<_>>(),
[10, 11, 12, 13]
);
let (_, normals) = pbr_map(&root.join("grid.png"), "_n", 4, Some(&animation)).unwrap();
assert_eq!(normals.len(), 1);
let mut sprite = test_frames(0, vec![vec![0; 16]; 4], metadata);
sprite.materials = [Some(Arc::new(normals)), Some(Arc::new(frames))];
for (tick, expected) in [(0, 13), (1, 13), (2, 11), (4, 12), (6, 10)] {
assert_eq!(sprite.material_pixels(1, tick).unwrap()[0], expected);
assert_eq!(&sprite.material_pixels(0, tick).unwrap()[..4], &FLAT_NORMAL);
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn textures_without_labpbr_maps_keep_none() {
let mut sprites = Sprites {
native: vec![(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0)],
@@ -1284,6 +1369,7 @@ mod tests {
fn animation_boundaries_and_repeated_frames_do_not_depend_on_render_rate() {
let animation = SpriteAnimation {
layer: 0,
materials: [None, None],
frames: Arc::new(vec![vec![], vec![], vec![]]),
clip: Arc::new(AnimationMetadata::from_json(r#"{"frames":[{"index":2,"time":8},{"index":1,"time":3},{"index":1,"time":2},0]}"#).unwrap().clip(3).unwrap().unwrap()),
};
@@ -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]);
}
@@ -0,0 +1,191 @@
//! Original synthetic rendering controls for ordinary uniform initial values.
use horizon_client_render::gfx::pack_uniforms::Value;
use horizon_shaders::{Origin, Preprocessed, Target, translate_composite};
use wgpu::util::DeviceExt;
fn source(code: &str) -> Preprocessed {
Preprocessed {
code: code.into(),
files: vec!["original-render.glsl".into()],
lines: code
.lines()
.enumerate()
.map(|(i, _)| Origin {
file: 0,
line: i as u32 + 1,
})
.collect(),
version: Some((430, None)),
..Default::default()
}
}
#[test]
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
fn authored_defaults_render_and_runtime_values_override_including_zero() {
let program = translate_composite(
&source("void main() { gl_Position = ftransform(); }"),
&source("uniform float gain = 0.375; uniform mat3 basis = mat3(2.0); uniform mat4 transform = mat4(1.0); uniform vec3 tint = vec3(0.125,0.25,0.5); uniform float points[2] = float[](0.0625,0.125); uniform int mode = 3; uniform bool enabled = true; out vec4 color; void main() { color = vec4(gain * basis[0][0] * transform[2][2], tint.g + points[1], float(mode), enabled ? 1.0 : 0.0); }"),
Target::Buffers,
).unwrap();
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!("Uniform defaults 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();
let vertex = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(program.vertex)),
});
let fragment = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(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,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba32Float,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba32Float,
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,
});
for (name, zero_buffer, overrides, expected) in [
("authored defaults", false, vec![], [0.75, 0.375, 3.0, 1.0]),
(
"runtime precedence",
false,
vec![
("gain", Value::F([0.125; 16])),
("transform", Value::F([0.5; 16])),
("mode", Value::I([7, 0, 0])),
("enabled", Value::I([0; 3])),
],
[0.125, 0.375, 7.0, 0.0],
),
(
"runtime explicit zero",
false,
vec![("gain", Value::F([0.; 16])), ("mode", Value::I([0; 3]))],
[0.0, 0.375, 0.0, 1.0],
),
("zero upload negative control", true, vec![], [0.; 4]),
] {
let mut bytes = if zero_buffer {
vec![0; program.interface.uniform_size as usize]
} else {
program.interface.initial_values.clone()
};
for (name, value) in overrides {
let uniform = program
.interface
.uniforms
.iter()
.find(|u| u.name == name)
.unwrap();
value.write(&uniform.ty, &mut bytes, uniform.offset as usize);
}
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(name),
contents: &bytes,
usage: wgpu::BufferUsages::UNIFORM,
});
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(),
}],
});
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);
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),
},
},
wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
readback
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
{
let mapped = readback.slice(..).get_mapped_range().unwrap();
let actual: &[f32] = bytemuck::cast_slice(&mapped[..16]);
eprintln!("{name}: {actual:?}");
assert_eq!(actual, expected, "{name}");
}
readback.unmap();
}
}
@@ -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",
Loaded 100 of 609 files, more files were not shown because too many files have changed in this diff. Show more