mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge origin/main into shaderpacks/shadow-pass-back-faces
Fixture: build/try_build (#9196) and build_with_terrain share one try_build_with_terrain. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
9fac526fef
507 files changed
+28390
-3258
No files matched your search
+2
-2
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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, ¯os, &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:?}"
|
||||
|
||||
@@ -84,7 +84,9 @@ pub struct Assets {
|
||||
pub struct RenderAssets {
|
||||
pub sprite_size: u32,
|
||||
pub sprite_minimum_size: u32,
|
||||
pub pixels: Vec<u8>,
|
||||
/// The block sprites' RGBA, layer by layer: as built until a renderer has
|
||||
/// them, then kept compressed ([`SpritePixels`]).
|
||||
pub pixels: SpritePixels,
|
||||
pub texture_layers: u32,
|
||||
pub model_catalog: crate::models::StateModels,
|
||||
/// Shared parsed resources; item-only validation errors surface at item preparation.
|
||||
@@ -111,6 +113,76 @@ pub struct RenderAssets {
|
||||
/// sprites are when it changes (`native_block_sprites_version`).
|
||||
pub generation: u64,
|
||||
}
|
||||
/// The block sprites' pixels for a renderer to upload. Once a renderer has
|
||||
/// them ([`Self::pack`]) they are kept lz4-compressed: the GPU holds the
|
||||
/// texture array, and the copy only serves a renderer made again (another
|
||||
/// window, a lost device, another graphics API), which waits for
|
||||
/// [`Self::get`] to uncompress them. Shared by the window and the workers'
|
||||
/// `RenderAssets`, so the compressed form replaces the raw one in place.
|
||||
pub struct SpritePixels(Arc<PixelStore>);
|
||||
struct PixelStore {
|
||||
len: usize,
|
||||
/// Set by the first `pack`: later calls cost one atomic read.
|
||||
packing: std::sync::atomic::AtomicBool,
|
||||
/// As built; dropped once `packed` holds them.
|
||||
raw: std::sync::Mutex<Option<Arc<Vec<u8>>>>,
|
||||
packed: std::sync::OnceLock<Vec<u8>>,
|
||||
}
|
||||
impl SpritePixels {
|
||||
pub fn new(pixels: Vec<u8>) -> Self {
|
||||
Self(Arc::new(PixelStore {
|
||||
len: pixels.len(),
|
||||
packing: std::sync::atomic::AtomicBool::new(false),
|
||||
raw: std::sync::Mutex::new(Some(Arc::new(pixels))),
|
||||
packed: std::sync::OnceLock::new(),
|
||||
}))
|
||||
}
|
||||
/// Their size in bytes, uncompressed.
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.len == 0
|
||||
}
|
||||
/// The pixels as built, uncompressed again once packed.
|
||||
pub fn get(&self) -> Arc<Vec<u8>> {
|
||||
let raw = self.0.raw.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
raw.unwrap_or_else(|| {
|
||||
let packed = self.0.packed.get().map(Vec::as_slice).unwrap_or_default();
|
||||
Arc::new(
|
||||
lz4_flex::decompress_size_prepended(packed)
|
||||
.ok()
|
||||
.filter(|p| p.len() == self.0.len)
|
||||
.unwrap_or_else(|| vec![0; self.0.len]),
|
||||
)
|
||||
})
|
||||
}
|
||||
/// Keep them compressed from now on, once a renderer has uploaded them:
|
||||
/// compressed on a thread of their own, the raw copy dropped after.
|
||||
pub fn pack(&self) {
|
||||
use std::sync::atomic::Ordering;
|
||||
if self.0.len == 0 || self.0.packing.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let store = self.0.clone();
|
||||
let job = move || {
|
||||
let raw = store.raw.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
if let Some(raw) = raw {
|
||||
let _ = store.packed.set(lz4_flex::compress_prepend_size(&raw));
|
||||
// `get` reads the compressed copy from here on.
|
||||
*store.raw.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
};
|
||||
let fallback = job.clone();
|
||||
if std::thread::Builder::new()
|
||||
.name("Sprite packing".into())
|
||||
.spawn(job)
|
||||
.is_err()
|
||||
{
|
||||
fallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The last `RenderAssets::generation` handed out.
|
||||
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
@@ -333,7 +405,7 @@ impl RenderAssets {
|
||||
sprite_size: sprites.size,
|
||||
sprite_minimum_size: sprites.minimum_size,
|
||||
texture_layers: u32::try_from(sprites.count())?,
|
||||
pixels: sprites.pixels,
|
||||
pixels: SpritePixels::new(sprites.pixels),
|
||||
model_catalog: models.catalog,
|
||||
model_library,
|
||||
model_placements: models.placements,
|
||||
@@ -431,4 +503,29 @@ mod tests {
|
||||
[ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_pixels_come_back_the_same_once_packed() {
|
||||
// Two layers of 16x16 RGBA with some structure, as sprites have.
|
||||
let pixels: Vec<u8> = (0..2 * 16 * 16 * 4).map(|i| (i * 7 % 251) as u8).collect();
|
||||
let stored = super::SpritePixels::new(pixels.clone());
|
||||
assert_eq!(*stored.get(), pixels);
|
||||
stored.pack();
|
||||
stored.pack();
|
||||
// The packing thread drops the raw copy once the compressed one is set.
|
||||
let started = std::time::Instant::now();
|
||||
while stored.0.raw.lock().unwrap().is_some() {
|
||||
assert!(started.elapsed().as_secs() < 10, "packing never finished");
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert!(
|
||||
stored
|
||||
.0
|
||||
.packed
|
||||
.get()
|
||||
.is_some_and(|p| p.len() < pixels.len())
|
||||
);
|
||||
assert_eq!(*stored.get(), pixels);
|
||||
assert_eq!(stored.len(), pixels.len());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use crate::{
|
||||
block_entity_anim::{self, EnchantingBook},
|
||||
breaking::entity_geometry,
|
||||
fast_hash::FxHashMap,
|
||||
font::Glyph,
|
||||
gfx::mark_oit,
|
||||
hud::Rect,
|
||||
mesher::{CULL_BACK, Vertex},
|
||||
@@ -660,7 +661,7 @@ fn sign_text_calls(
|
||||
} else {
|
||||
(dark, 0, vertex_light(packed))
|
||||
};
|
||||
let advance = |c: char| (scene.glyph)(c).1;
|
||||
let advance = |c: char| (scene.glyph)(c).advance;
|
||||
let width = |s: &str| s.chars().map(advance).sum::<f32>().ceil();
|
||||
text.lines
|
||||
.iter()
|
||||
@@ -699,10 +700,19 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
let mut x = call.x + dx;
|
||||
let y0 = call.y + dy;
|
||||
for ch in call.text.chars() {
|
||||
let (cell, advance) = (scene.glyph)(ch);
|
||||
if let Some(r) = cell {
|
||||
let corners = [(x, y0 + 8.0), (x + 8.0, y0 + 8.0), (x + 8.0, y0), (x, y0)];
|
||||
let uvs = [[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]];
|
||||
let g = (scene.glyph)(ch);
|
||||
if let Some(cell) = g.rect {
|
||||
// BakedSheetGlyph: the glyph's own box from the pen, not a
|
||||
// fixed 8x8 cell (a Unifont glyph is 0.5 units per texel).
|
||||
let (x0, x1) = (x + g.left, x + g.right);
|
||||
let (top, bottom) = (y0 + g.top, y0 + g.bottom);
|
||||
let corners = [(x0, bottom), (x1, bottom), (x1, top), (x0, top)];
|
||||
let uvs = [
|
||||
[cell.u0, cell.v1],
|
||||
[cell.u1, cell.v1],
|
||||
[cell.u1, cell.v0],
|
||||
[cell.u0, cell.v0],
|
||||
];
|
||||
let c =
|
||||
0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
|
||||
for ((cx, cy), uv) in corners.into_iter().zip(uvs) {
|
||||
@@ -721,7 +731,7 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
});
|
||||
}
|
||||
}
|
||||
x += advance;
|
||||
x += g.advance;
|
||||
}
|
||||
};
|
||||
if call.outline != 0 {
|
||||
@@ -818,8 +828,8 @@ pub struct Scene<'a> {
|
||||
/// profile → skin): a downloaded `skins/<sha1>` slot or the profile's
|
||||
/// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
|
||||
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
|
||||
/// Font glyph cell (none for blank glyphs such as the space) and advance.
|
||||
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
|
||||
/// Font glyph: atlas cell (none for blank glyphs such as the space), box and advance.
|
||||
pub glyph: &'a dyn Fn(char) -> Glyph,
|
||||
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
|
||||
/// text laid out under another value is laid out again.
|
||||
pub glyphs: u64,
|
||||
@@ -3342,7 +3352,7 @@ mod tests {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
// Compare the cached world path with the original model renderer,
|
||||
@@ -3610,7 +3620,13 @@ mod tests {
|
||||
w: 8,
|
||||
h: 8,
|
||||
};
|
||||
(Some(rect), 6.0)
|
||||
Glyph {
|
||||
rect: Some(rect),
|
||||
right: 8.0,
|
||||
bottom: 8.0,
|
||||
advance: 6.0,
|
||||
..Glyph::default()
|
||||
}
|
||||
};
|
||||
let scene = Scene {
|
||||
camera: DVec3::ZERO,
|
||||
@@ -3660,6 +3676,78 @@ mod tests {
|
||||
assert!(out.iter().all(|v| v.pos[2] == 0.0));
|
||||
}
|
||||
|
||||
/// BakedSheetGlyph draws a glyph over its own box from the pen
|
||||
/// (x + left .. x + right, y + up .. y + down), not a fixed 8x8 cell. With
|
||||
/// Force Unicode Font a Latin letter is an 8x16 Unifont glyph at half
|
||||
/// scale: 4 units wide and 8 tall, advancing 5 (#8699).
|
||||
#[test]
|
||||
fn sign_text_draws_each_glyph_over_its_own_box() {
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
let sprites = HashMap::new();
|
||||
let glyph = |_: char| Glyph {
|
||||
rect: Some(Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 0.0625,
|
||||
v1: 0.125,
|
||||
w: 8,
|
||||
h: 16,
|
||||
}),
|
||||
left: 0.0,
|
||||
top: 0.0,
|
||||
right: 4.0,
|
||||
bottom: 8.0,
|
||||
advance: 5.0,
|
||||
offset: 0.5,
|
||||
};
|
||||
let scene = Scene {
|
||||
camera: DVec3::ZERO,
|
||||
orientation: Quat::IDENTITY,
|
||||
anim: &|_| BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: 0.0,
|
||||
game_time: 0,
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
emissive: &Default::default(),
|
||||
translucent_sprites: &HashSet::new(),
|
||||
atlas_revision: 1,
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
};
|
||||
let call = TextCall {
|
||||
matrix: Mat4::IDENTITY,
|
||||
x: 10.0,
|
||||
y: 2.0,
|
||||
text: "ab".into(),
|
||||
color: 0xFF00_0000,
|
||||
outline: 0,
|
||||
light: 240 | 240 << 8,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
draw_text(&scene, &call, &mut out);
|
||||
let boxes: Vec<_> = out
|
||||
.chunks(4)
|
||||
.map(|q| {
|
||||
let xs = q.iter().map(|v| v.pos[0]);
|
||||
let ys = q.iter().map(|v| v.pos[1]);
|
||||
[
|
||||
xs.clone().fold(f32::MAX, f32::min),
|
||||
xs.fold(f32::MIN, f32::max),
|
||||
ys.clone().fold(f32::MAX, f32::min),
|
||||
ys.fold(f32::MIN, f32::max),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(boxes, [[10.0, 14.0, 2.0, 10.0], [15.0, 19.0, 2.0, 10.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_sign_text_matches_and_follows_changes() {
|
||||
let renderer = BlockEntityRenderer {
|
||||
@@ -3714,7 +3802,13 @@ mod tests {
|
||||
w: 8,
|
||||
h: 8,
|
||||
};
|
||||
((c != ' ').then_some(rect), wide.get())
|
||||
Glyph {
|
||||
rect: (c != ' ').then_some(rect),
|
||||
right: 8.0,
|
||||
bottom: 8.0,
|
||||
advance: wide.get(),
|
||||
..Glyph::default()
|
||||
}
|
||||
};
|
||||
let (first_sign, changed_sign, glowing_sign) = (
|
||||
|_| Some(&first_text),
|
||||
@@ -3879,7 +3973,7 @@ mod tests {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
@@ -3964,7 +4058,7 @@ mod tests {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::{
|
||||
ModelPart, SIGN_TEXT_KEEP, Scene, StaticSectionRevision, classify,
|
||||
},
|
||||
chunk::Chunk,
|
||||
font::Glyph,
|
||||
hud::Rect,
|
||||
mesher::Vertex,
|
||||
signs::{SignData, SignText},
|
||||
@@ -355,7 +356,7 @@ fn batched_callbacks_preserve_entity_ranges_and_allow_immediate_culling() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
// Reverse local-position order to check that cache indexing does not
|
||||
@@ -443,7 +444,7 @@ fn static_cache_qualifies_local_positions_by_section_and_drops_old_entries() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
@@ -497,7 +498,7 @@ fn gpu_static_cache_uses_section_local_chests_and_keeps_visibility_separate() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let blocks = [([100, 70, -32], 0)];
|
||||
@@ -651,18 +652,19 @@ fn sign_text_cache_can_be_disabled_and_reenabled_without_changing_output() {
|
||||
};
|
||||
let sign_data = &data;
|
||||
let sign = move |_: [i32; 3]| Some(sign_data);
|
||||
let glyph = |_: char| {
|
||||
(
|
||||
Some(Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 0.0625,
|
||||
v1: 0.0625,
|
||||
w: 8,
|
||||
h: 8,
|
||||
}),
|
||||
6.0,
|
||||
)
|
||||
let glyph = |_: char| Glyph {
|
||||
rect: Some(Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 0.0625,
|
||||
v1: 0.0625,
|
||||
w: 8,
|
||||
h: 8,
|
||||
}),
|
||||
right: 8.0,
|
||||
bottom: 8.0,
|
||||
advance: 6.0,
|
||||
..Glyph::default()
|
||||
};
|
||||
let scene = Scene {
|
||||
camera: DVec3::new(2.25, 64.5, -3.75),
|
||||
@@ -735,7 +737,7 @@ fn shulker_cache_tracks_settled_poses_and_does_not_freeze_opening_animation() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -836,7 +838,7 @@ fn shulker_cache_covers_all_colors_and_facing_directions() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
@@ -890,7 +892,7 @@ fn cached_bells_keep_shaking_until_the_animation_ends() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -951,7 +953,7 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -1046,7 +1048,7 @@ fn closed_chest_cache_covers_all_materials_and_atlas_reloads() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let pos = [0, 64, 0];
|
||||
@@ -1134,7 +1136,7 @@ fn double_chest_cache_bypasses_both_halves_while_either_lid_is_open() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let pair = [([0, 64, 0], 0), ([1, 64, 0], 1)];
|
||||
@@ -1209,7 +1211,7 @@ fn decorated_pot_cache_tracks_sherds_and_bypasses_wobble_animation() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -1287,7 +1289,7 @@ fn banner_cache_keeps_the_pole_static_while_the_flag_and_patterns_update() {
|
||||
sign: &|_| None,
|
||||
banner: &banner_patterns,
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -1353,7 +1355,7 @@ fn cached_wall_banner_poles_use_the_wall_model() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let pos = [0, 64, 0];
|
||||
@@ -1400,7 +1402,7 @@ fn copper_statue_cache_rebuilds_when_its_pose_state_changes() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
@@ -1505,7 +1507,7 @@ fn copper_statue_cache_covers_weathering_waxing_and_all_poses() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
|
||||
@@ -58,6 +58,24 @@ impl DataPack {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The files of `data/<namespace>/<folder>/` in every namespace, as
|
||||
/// `list` names them; a folder pack walks only those folders.
|
||||
pub fn list_folder(&self, folder: &str) -> Vec<String> {
|
||||
let files = self.list_kinds(&[folder]);
|
||||
let Source::Zip(_) = &self.source else {
|
||||
return files;
|
||||
};
|
||||
files
|
||||
.into_iter()
|
||||
.filter(|f| {
|
||||
f.strip_prefix("data/")
|
||||
.and_then(|r| r.split_once('/'))
|
||||
.is_some_and(|(_, r)| {
|
||||
r.strip_prefix(folder).is_some_and(|r| r.starts_with('/'))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
match &self.source {
|
||||
Source::Zip(z) => z.read(path).ok(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -24,7 +24,7 @@ pub const DEFAULT: &str = "minecraft:default";
|
||||
|
||||
/// A glyph ready to draw: atlas UVs and its box relative to the pen
|
||||
/// position (GlyphBitmap left/top/right/bottom), in GUI pixels.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Glyph {
|
||||
pub rect: Option<Rect>,
|
||||
pub left: f32,
|
||||
@@ -134,6 +134,7 @@ pub struct Font {
|
||||
/// Atlas size and the reserved dynamic region (y, height).
|
||||
atlas: (u32, u32),
|
||||
region: (u32, u32),
|
||||
metrics_only: bool,
|
||||
cache: RefCell<Cache>,
|
||||
/// Set anew for each font and each `reset_cache` (see `epoch`).
|
||||
generation: std::cell::Cell<u64>,
|
||||
@@ -284,6 +285,7 @@ fn load_bitmap(
|
||||
sheets: &HashMap<String, Rect>,
|
||||
pixels: &[u8],
|
||||
atlas: (u32, u32),
|
||||
metrics_only: bool,
|
||||
) -> HashMap<char, BitmapGlyph> {
|
||||
let mut out = HashMap::new();
|
||||
let file = p["file"].as_str().unwrap_or("");
|
||||
@@ -349,6 +351,10 @@ fn load_bitmap(
|
||||
advance: (0.5 + used as f32 * scale).floor() + 1.0,
|
||||
offset: 1.0,
|
||||
};
|
||||
if metrics_only {
|
||||
out.insert(*ch, BitmapGlyph::Atlas(glyph));
|
||||
continue;
|
||||
}
|
||||
let glyph = match &sheet {
|
||||
None => BitmapGlyph::Atlas(Glyph {
|
||||
rect: Some(glyph_rect(
|
||||
@@ -596,6 +602,30 @@ impl Font {
|
||||
atlas: (u32, u32),
|
||||
region: (u32, u32),
|
||||
white: Rect,
|
||||
) -> Self {
|
||||
Self::load_with(root, sprites, pixels, atlas, region, white, false)
|
||||
}
|
||||
/// A server measures text but never draws it. Decode bitmap sheets only
|
||||
/// to obtain advances, then release their pixels; never rasterize glyphs.
|
||||
pub fn metrics(root: &Path) -> Self {
|
||||
let white = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
};
|
||||
Self::load_with(root, &HashMap::new(), &[], (1, 1), (0, 0), white, true)
|
||||
}
|
||||
fn load_with(
|
||||
root: &Path,
|
||||
sprites: &HashMap<String, Rect>,
|
||||
pixels: &[u8],
|
||||
atlas: (u32, u32),
|
||||
region: (u32, u32),
|
||||
white: Rect,
|
||||
metrics_only: bool,
|
||||
) -> Self {
|
||||
let sheets = sprites
|
||||
.iter()
|
||||
@@ -617,6 +647,7 @@ impl Font {
|
||||
root: root.to_owned(),
|
||||
atlas,
|
||||
region,
|
||||
metrics_only,
|
||||
cache: RefCell::new(Cache {
|
||||
sets: Vec::new(),
|
||||
region: Region {
|
||||
@@ -665,7 +696,14 @@ impl Font {
|
||||
)),
|
||||
"bitmap" => Some(Provider::Bitmap(
|
||||
Filter::of(p),
|
||||
load_bitmap(&self.root, p, &self.sheets, pixels, self.atlas),
|
||||
load_bitmap(
|
||||
&self.root,
|
||||
p,
|
||||
&self.sheets,
|
||||
pixels,
|
||||
self.atlas,
|
||||
self.metrics_only,
|
||||
),
|
||||
)),
|
||||
"unihex" => Some(Provider::Unihex(Filter::of(p), p.clone(), None)),
|
||||
"ttf" => Some(Provider::TrueType(
|
||||
@@ -841,6 +879,14 @@ impl Font {
|
||||
if let Some(id) = ttf.ids.get(&c)
|
||||
&& !(non_fishy && fishy_advance(ttf.advance(*id)))
|
||||
{
|
||||
if self.metrics_only {
|
||||
return Some(Glyph {
|
||||
rect: None,
|
||||
advance: ttf.advance(*id),
|
||||
offset: 1.0,
|
||||
..self.missing
|
||||
});
|
||||
}
|
||||
let (glyph, bitmap) = ttf.glyph(*id);
|
||||
return match bitmap {
|
||||
Some((w, h, rgba)) => {
|
||||
@@ -861,6 +907,17 @@ impl Font {
|
||||
}
|
||||
fn rasterize_hex(&self, region: &mut Region, h: HexGlyph) -> Option<Glyph> {
|
||||
let width = h.right.saturating_sub(h.left) + 1;
|
||||
if self.metrics_only {
|
||||
return Some(Glyph {
|
||||
rect: None,
|
||||
left: 0.0,
|
||||
top: 0.0,
|
||||
right: width as f32 / 2.0,
|
||||
bottom: 8.0,
|
||||
advance: hex_advance(&h),
|
||||
offset: 0.5,
|
||||
});
|
||||
}
|
||||
let mut rgba = Vec::with_capacity((width * 16 * 4) as usize);
|
||||
for row in h.rows {
|
||||
for bit in h.left..=h.right {
|
||||
@@ -1361,6 +1418,55 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_metrics_preserve_bitmap_true_type_and_unihex_advances() {
|
||||
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
|
||||
let def = br#"{"providers":[{"type":"space","advances":{" ":4}},{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["ab"]}]}"#;
|
||||
let ttf = br#"{"providers":[{"type":"ttf","file":"minecraft:mono.ttf","size":11,"oversample":2}]}"#;
|
||||
let root = root(
|
||||
"metrics",
|
||||
&[
|
||||
("assets/minecraft/font/default.json", def),
|
||||
("assets/minecraft/font/alt.json", ttf),
|
||||
(
|
||||
"assets/minecraft/font/mono.ttf",
|
||||
horizon_theme::fonts::GEIST_MONO,
|
||||
),
|
||||
("assets/minecraft/textures/font/a.png", &sheet),
|
||||
],
|
||||
);
|
||||
let rendered = load(&root);
|
||||
let metrics = Font::metrics(&root);
|
||||
assert_eq!(metrics.width("a b"), 14.0);
|
||||
assert_eq!(metrics.width("§lab"), 12.0);
|
||||
let text = Text::literal("ab ab");
|
||||
assert_eq!(
|
||||
metrics.split_formatted(&text, 10.0),
|
||||
rendered.split_formatted(&text, 10.0)
|
||||
);
|
||||
let alt = Style {
|
||||
font: Some("minecraft:alt".into()),
|
||||
bold: Some(true),
|
||||
..Style::default()
|
||||
};
|
||||
for c in ['A', 'i', ' ', '\u{fffd}'] {
|
||||
assert_eq!(metrics.advance(c, &alt), rendered.advance(c, &alt));
|
||||
assert!(metrics.glyph_styled(c, &alt).rect.is_none());
|
||||
}
|
||||
let mut hex = HashMap::new();
|
||||
parse_hex(b"0041:0000000018242442427E424242420000\n", &mut hex);
|
||||
let h = hex[&0x41];
|
||||
let glyph = metrics
|
||||
.rasterize_hex(&mut metrics.cache.borrow_mut().region, h)
|
||||
.unwrap();
|
||||
assert_eq!(glyph.advance, 4.0);
|
||||
assert_eq!(glyph.offset, 0.5);
|
||||
assert!(glyph.rect.is_none());
|
||||
assert!(metrics.take_patches().is_empty());
|
||||
assert!(!rendered.take_patches().is_empty());
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn true_type_glyphs_are_rasterized_at_the_provider_size() {
|
||||
let def = br#"{"providers":[{"type":"ttf","file":"minecraft:mono.ttf","size":11,"oversample":2,"shift":[0.5,1],"skip":["B"]}]}"#;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -885,6 +885,7 @@ mod tests {
|
||||
sorted_indices: None,
|
||||
flat_id: None,
|
||||
gpu_id: None,
|
||||
gpu_fading: false,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
@@ -1081,6 +1082,7 @@ mod tests {
|
||||
sorted_indices: None,
|
||||
flat_id: Some(id),
|
||||
gpu_id: None,
|
||||
gpu_fading: false,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
@@ -1307,6 +1309,7 @@ mod tests {
|
||||
sorted_indices: (translucent > 0).then_some(if indices { at } else { (7, 7) }),
|
||||
flat_id: None,
|
||||
gpu_id: None,
|
||||
gpu_fading: false,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
//! each in one draw (the pipelines cull the faces turned away), with the origin
|
||||
//! the walk would give it: `resident::origin`'s f32 bits, copied, never
|
||||
//! computed on the GPU. Within the render distance the CPU's walk then lists
|
||||
//! only the sections whose translucent quads it draws, or that have no record
|
||||
//! (`Resident::walk`); past it, it lists every section as before.
|
||||
//! Unlike the walk, the pass draws sections fading in or out whole.
|
||||
//! only the sections whose translucent quads it draws, that have no record, or
|
||||
//! that fade (`Resident::walk`); past it, it lists every section as before. A
|
||||
//! section whose instance carries a fade, fading in or out, has its record
|
||||
//! flagged (`FADING`, gfx.rs `mark_fades`) until it is whole: the pass leaves
|
||||
//! it out, and the CPU draws it with its fade.
|
||||
//!
|
||||
//! The graph is the one the walk reads that frame (`occlusion::Reach`). Its
|
||||
//! search keeps the cells it reached as bits too, and `Occlusion` a generation
|
||||
@@ -59,6 +61,7 @@ use super::{
|
||||
upload::Uploads,
|
||||
};
|
||||
use glam::{DVec3, DVec4, Vec3};
|
||||
use std::{cmp::Reverse, collections::BinaryHeap, time::Instant};
|
||||
|
||||
/// One section (`gpu_culling.wgsl`'s `Section`).
|
||||
#[repr(C)]
|
||||
@@ -68,10 +71,14 @@ pub(super) struct Record {
|
||||
key: [i32; 4],
|
||||
/// The first vertex of its solid quads (a draw's base vertex, an i32: a
|
||||
/// page holds fewer vertices), its solid quads, its box (`rounded_box`),
|
||||
/// then a word kept at 0.
|
||||
/// then its flags (`FADING`).
|
||||
a: [u32; 4],
|
||||
}
|
||||
|
||||
/// In `Record::a[3]`: the CPU draws the section's solid quads while it fades
|
||||
/// (gfx.rs `SectionGpu::gpu_fading`), and the pass leaves it out.
|
||||
const FADING: u32 = 1;
|
||||
|
||||
impl Record {
|
||||
const FREE: Self = Self {
|
||||
key: [0, 0, 0, -1],
|
||||
@@ -86,9 +93,19 @@ impl Record {
|
||||
a: [start as u32, quads, boxed, 0],
|
||||
}
|
||||
}
|
||||
/// The record, `FADING` when `on`.
|
||||
pub fn fading(mut self, on: bool) -> Self {
|
||||
if on {
|
||||
self.a[3] |= FADING;
|
||||
}
|
||||
self
|
||||
}
|
||||
fn page(&self) -> Option<u32> {
|
||||
u32::try_from(self.key[3]).ok()
|
||||
}
|
||||
fn is_fading(&self) -> bool {
|
||||
(self.a[3] & FADING) != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// `rounded_box` for bounds past what a box's codes hold, or not finite: no
|
||||
@@ -359,6 +376,10 @@ pub(super) fn supported(
|
||||
// The pass binds 8, the last cave culling's graph.
|
||||
&& limits.max_storage_buffers_per_shader_stage >= 8
|
||||
&& !super::flat::is_broken_intel_vulkan(info)
|
||||
// DX12's count draws leave each draw's base vertex out of `vertex_index`, by which the
|
||||
// pulled solid pipelines (those of a device with flat page groups) read the page.
|
||||
// shortcut: not offered there, until GPU culling's draws carry their base vertex otherwise.
|
||||
&& !(info.backend == wgpu::Backend::Dx12 && flat_min_sections.is_some())
|
||||
}
|
||||
|
||||
/// A buffer that is recreated larger when it must hold more.
|
||||
@@ -419,6 +440,8 @@ pub(super) struct Records {
|
||||
all_dirty: bool,
|
||||
/// Per arena page, the records on it.
|
||||
page_sections: Vec<u32>,
|
||||
/// The records flagged `FADING`.
|
||||
fading: u32,
|
||||
}
|
||||
|
||||
impl Records {
|
||||
@@ -435,6 +458,7 @@ impl Records {
|
||||
};
|
||||
self.records[id as usize] = record;
|
||||
self.add_to(record.key[3] as u32);
|
||||
self.fading += u32::from(record.is_fading());
|
||||
self.mark(id);
|
||||
id
|
||||
}
|
||||
@@ -455,6 +479,7 @@ impl Records {
|
||||
continue;
|
||||
};
|
||||
self.page_sections[page as usize] -= 1;
|
||||
self.fading -= u32::from(self.records[id as usize].is_fading());
|
||||
self.records[id as usize] = Record::FREE;
|
||||
// shortcut: a teleport frees more than `DIRTY_LIMIT`, so `prepare` writes the
|
||||
// whole table (0.6-2.6 ms at render distance 64); upgrade to a list of freed ids
|
||||
@@ -502,6 +527,22 @@ impl Records {
|
||||
self.add_to(page);
|
||||
self.mark(id);
|
||||
}
|
||||
/// The section with `id` began to draw faded, or stopped (gfx.rs
|
||||
/// `SectionGpu::gpu_fading`): its record is `FADING` when `on`.
|
||||
pub fn set_fading(&mut self, id: u32, on: bool) {
|
||||
let page = self.records.get(id as usize).and_then(Record::page);
|
||||
debug_assert!(page.is_some(), "no record {id} to flag");
|
||||
if page.is_none() || self.records[id as usize].is_fading() == on {
|
||||
return;
|
||||
}
|
||||
self.records[id as usize].a[3] ^= FADING;
|
||||
if on {
|
||||
self.fading += 1;
|
||||
} else {
|
||||
self.fading -= 1;
|
||||
}
|
||||
self.mark(id);
|
||||
}
|
||||
/// Every section went.
|
||||
pub fn clear(&mut self) {
|
||||
self.records.clear();
|
||||
@@ -510,12 +551,17 @@ impl Records {
|
||||
self.dirty = Vec::new();
|
||||
self.all_dirty = false;
|
||||
self.page_sections.clear();
|
||||
self.fading = 0;
|
||||
}
|
||||
/// The sections with a record.
|
||||
pub fn live(&self) -> usize {
|
||||
let free: u32 = self.free.iter().map(|word| word.count_ones()).sum();
|
||||
self.records.len() - free as usize
|
||||
}
|
||||
/// The sections whose record is `FADING`, which the CPU draws.
|
||||
pub fn fading(&self) -> u32 {
|
||||
self.fading
|
||||
}
|
||||
/// The record of the section with `id`; `None` for a free id.
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
pub fn at(&self, id: u32) -> Option<&Record> {
|
||||
@@ -549,6 +595,42 @@ impl Records {
|
||||
}
|
||||
}
|
||||
|
||||
/// The flagged sections by when their fade is over at the latest (gfx.rs
|
||||
/// `SectionGpu::fade_end`), for gfx.rs `mark_fades` to look at them again
|
||||
/// then, and the chunk fade-in time their flags were decided with: `None`
|
||||
/// until a frame has decided them, just after GPU culling was switched on.
|
||||
/// The ends of sections that went or were handed back are skipped as they come.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Fades {
|
||||
due: BinaryHeap<Reverse<(Instant, SectionKey)>>,
|
||||
pub fade_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl Fades {
|
||||
/// The section at `key` is looked at again at `end`.
|
||||
pub fn push(&mut self, end: Instant, key: SectionKey) {
|
||||
self.due.push(Reverse((end, key)));
|
||||
}
|
||||
/// The section of the earliest end at or before `now`, taken out.
|
||||
pub fn pop_due(&mut self, now: Instant) -> Option<SectionKey> {
|
||||
let &Reverse((end, key)) = self.due.peek()?;
|
||||
if end > now {
|
||||
return None;
|
||||
}
|
||||
self.due.pop();
|
||||
Some(key)
|
||||
}
|
||||
/// Every end goes; `fade_ms` stays.
|
||||
pub fn clear(&mut self) {
|
||||
self.due.clear();
|
||||
}
|
||||
/// The sections an end is waited for.
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn pending(&self) -> impl Iterator<Item = SectionKey> {
|
||||
self.due.iter().map(|r| r.0.1)
|
||||
}
|
||||
}
|
||||
|
||||
/// The pass, its buffers and the records it draws from: made when GPU culling
|
||||
/// is switched on, dropped when it is switched off.
|
||||
pub(super) struct GpuCulling {
|
||||
@@ -574,6 +656,9 @@ pub(super) struct GpuCulling {
|
||||
/// that the arena's compaction moves records while it borrows the
|
||||
/// renderer's other fields.
|
||||
pub records: Records,
|
||||
/// When the flagged records' fades end, a field of its own as `records`
|
||||
/// is: gfx.rs `mark_fades` takes both beside the renderer's sections.
|
||||
pub fades: Fades,
|
||||
/// This frame's pages with records: (page, first draw of its region, the
|
||||
/// region's size).
|
||||
pages: Vec<(u32, u32, u32)>,
|
||||
@@ -700,6 +785,7 @@ impl GpuCulling {
|
||||
reach_generation: None,
|
||||
group: None,
|
||||
records: Records::default(),
|
||||
fades: Fades::default(),
|
||||
pages: Vec::new(),
|
||||
ids: 0,
|
||||
n_updates: 0,
|
||||
@@ -975,8 +1061,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Where a section's solid quads are: key, arena page, first vertex, quads,
|
||||
/// and its box.
|
||||
type Placed = (SectionKey, u32, u64, u32, u32);
|
||||
/// its box, and whether it fades (`FADING`).
|
||||
type Placed = (SectionKey, u32, u64, u32, u32, bool);
|
||||
|
||||
/// `gpu_culling.wgsl`'s `near`, on the CPU.
|
||||
fn near(params: &Params, x: i32, z: i32) -> bool {
|
||||
@@ -1025,7 +1111,7 @@ mod tests {
|
||||
/// (and the pages `prepare` gives them).
|
||||
fn keeps(params: &Params, words: &[u32], record: &Record) -> bool {
|
||||
let (key, a) = (record.key, record.a);
|
||||
if key[3] < 0 || a[1] == 0 || !near(params, key[0], key[2]) {
|
||||
if key[3] < 0 || a[1] == 0 || (a[3] & FADING) != 0 || !near(params, key[0], key[2]) {
|
||||
return false;
|
||||
}
|
||||
let c: [u32; 3] = std::array::from_fn(|i| key[i].wrapping_sub(params.first[i]) as u32);
|
||||
@@ -1099,10 +1185,13 @@ mod tests {
|
||||
Some([min, max])
|
||||
}
|
||||
|
||||
/// Ids, page counts and changes against a plain list of what each id holds:
|
||||
/// the lowest free id comes back, free ids at the end leave, counts follow
|
||||
/// moves (to pages past the last too), a move keeps the box, a free is a
|
||||
/// change, and more changes than updates take mark the whole table.
|
||||
/// Ids, page counts, flags and changes against a plain list of what each id
|
||||
/// holds: the lowest free id comes back, flagged only if inserted so, free
|
||||
/// ids at the end leave, counts follow moves (to pages past the last too), a
|
||||
/// move keeps the box and the flag, a free is a change, a flag set changes
|
||||
/// its record only when it differs, the flagged ones are counted through
|
||||
/// inserts, flags, removals and clears, and more changes than updates take
|
||||
/// mark the whole table.
|
||||
#[test]
|
||||
fn records_keep_ids_page_counts_and_moves() {
|
||||
let mut records = Records::default();
|
||||
@@ -1140,7 +1229,7 @@ mod tests {
|
||||
};
|
||||
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
|
||||
// What each id holds; `None`: a free id.
|
||||
let mut model: Vec<Option<Placed>> = vec![Some(((2, 0, 0), 1, 0, 3, 11))];
|
||||
let mut model: Vec<Option<Placed>> = vec![Some(((2, 0, 0), 1, 0, 3, 11, false))];
|
||||
for step in 0..4_000 {
|
||||
let live: Vec<u32> = (0..model.len() as u32)
|
||||
.filter(|&id| model[id as usize].is_some())
|
||||
@@ -1149,20 +1238,22 @@ mod tests {
|
||||
rng.below(4 + step / 500),
|
||||
u64::from(rng.below(1 << 20)) * 256,
|
||||
);
|
||||
match rng.below(5) {
|
||||
let dirty = records.dirty.len();
|
||||
match rng.below(6) {
|
||||
op if op < 2 || live.is_empty() => {
|
||||
let key = (rng.range(-9, 9), rng.range(0, 23), rng.range(-9, 9));
|
||||
let (quads, boxed) = (1 + rng.below(500), rng.below(1 << 30));
|
||||
let fading = rng.below(4) == 0;
|
||||
let lowest = model
|
||||
.iter()
|
||||
.position(Option::is_none)
|
||||
.unwrap_or(model.len());
|
||||
let record = Record::new(key, page, start, quads, boxed);
|
||||
let record = Record::new(key, page, start, quads, boxed).fading(fading);
|
||||
assert_eq!(records.insert(record) as usize, lowest);
|
||||
if lowest == model.len() {
|
||||
model.push(None);
|
||||
}
|
||||
model[lowest] = Some((key, page, start, quads, boxed));
|
||||
model[lowest] = Some((key, page, start, quads, boxed, fading));
|
||||
}
|
||||
op => {
|
||||
let id = live[rng.below(live.len() as u32) as usize];
|
||||
@@ -1170,6 +1261,12 @@ mod tests {
|
||||
if op < 4 {
|
||||
records.relocate(id, page, start);
|
||||
(held.1, held.2) = (page, start);
|
||||
} else if op == 4 {
|
||||
let on = rng.below(2) == 0;
|
||||
records.set_fading(id, on);
|
||||
let changed = usize::from(held.5 != on);
|
||||
assert_eq!(records.dirty.len(), dirty + changed, "step {step}");
|
||||
held.5 = on;
|
||||
} else {
|
||||
records.remove(id);
|
||||
// Marked: the GPU must have it free before the pass culls.
|
||||
@@ -1183,11 +1280,13 @@ mod tests {
|
||||
}
|
||||
assert_eq!(records.ids() as usize, model.len(), "step {step}");
|
||||
assert_eq!(records.live(), model.iter().flatten().count());
|
||||
let flagged = model.iter().flatten().filter(|held| held.5).count();
|
||||
assert_eq!(records.fading() as usize, flagged, "step {step}");
|
||||
let mut pages = vec![0; records.page_sections().len()];
|
||||
for (id, held) in model.iter().enumerate() {
|
||||
let record = held.map(|(key, page, start, quads, boxed)| {
|
||||
let record = held.map(|(key, page, start, quads, boxed, fading)| {
|
||||
pages[page as usize] += 1;
|
||||
Record::new(key, page, start, quads, boxed)
|
||||
Record::new(key, page, start, quads, boxed).fading(fading)
|
||||
});
|
||||
assert_eq!(
|
||||
records.at(id as u32),
|
||||
@@ -1204,16 +1303,17 @@ mod tests {
|
||||
let (mut each, mut all) = (Records::default(), Records::default());
|
||||
for n in 0..1_000 {
|
||||
let (key, page, start) = ((n % 31, n % 24, n / 31), n as u32 % 3, n as u64 * 256);
|
||||
let record = Record::new(key, page, start, 1, 0);
|
||||
let record = Record::new(key, page, start, 1, 0).fading(n % 7 == 0);
|
||||
assert_eq!(each.insert(record), all.insert(record));
|
||||
}
|
||||
let gone: Vec<u32> = (0..1_000).filter(|id| id % 3 != 1 || *id >= 900).collect();
|
||||
let counts = |r: &Records| (r.ids(), r.live(), r.fading());
|
||||
for ids in [&[1u32, 4, 898][..], &gone[..]] {
|
||||
for &id in ids {
|
||||
each.remove(id);
|
||||
}
|
||||
all.remove_all(ids.iter().copied());
|
||||
assert_eq!((all.ids(), all.live()), (each.ids(), each.live()));
|
||||
assert_eq!(counts(&all), counts(&each));
|
||||
assert_eq!(all.page_sections(), each.page_sections());
|
||||
assert_eq!(free_ids(&all), free_ids(&each));
|
||||
assert_eq!(all.dirty, each.dirty);
|
||||
@@ -1228,8 +1328,9 @@ mod tests {
|
||||
assert_eq!((all.ids(), all.live(), all.free.len()), (0, 0, 0));
|
||||
assert!(all.page_sections().iter().all(|&n| n == 0));
|
||||
|
||||
assert!(records.fading() > 0);
|
||||
records.clear();
|
||||
assert_eq!((records.ids(), records.live()), (0, 0));
|
||||
assert_eq!((records.ids(), records.live(), records.fading()), (0, 0, 0));
|
||||
assert!(records.page_sections().is_empty() && records.dirty.is_empty());
|
||||
for x in 0..DIRTY_LIMIT as i32 {
|
||||
records.insert(Record::new((x, 0, 0), 0, 0, 1, 0));
|
||||
@@ -1360,6 +1461,7 @@ mod tests {
|
||||
("UNBOUNDED", u64::from(UNBOUNDED)),
|
||||
("REACH", u64::from(REACH)),
|
||||
("SKY", u64::from(SKY)),
|
||||
("FADING", u64::from(FADING)),
|
||||
] {
|
||||
assert_eq!(constant(name), value, "{name}");
|
||||
}
|
||||
@@ -1784,7 +1886,9 @@ mod tests {
|
||||
/// reached or not: the pass reads the copy it holds when a frame brings the
|
||||
/// same graph (once through a bind group made anew), sends a new one after
|
||||
/// a frame the CPU draws, and makes its copy anew for one too large for its
|
||||
/// room.
|
||||
/// room. A fifth of the sections fade, flagged anew before each case
|
||||
/// (`FADING`, sent as updates or with the whole table): the pass leaves
|
||||
/// them to the CPU.
|
||||
#[test]
|
||||
fn the_gpu_draws_what_the_integer_test_keeps() {
|
||||
on_each_device(draws_match);
|
||||
@@ -1792,11 +1896,14 @@ mod tests {
|
||||
|
||||
fn draws_match(device: &wgpu::Device, queue: &Queue) {
|
||||
let mut rng = Rng(0x2545_F491_4F6C_DD1D);
|
||||
// The flags have their own sequence: the cases are otherwise as before.
|
||||
let mut flags = Rng(0x9E37_79B9_7F4A_7C15);
|
||||
let (min_y, height) = (-64, 24);
|
||||
let mut gpu = GpuCulling::new(device).expect("the pass");
|
||||
let mut uploads = Uploads::new(device);
|
||||
let mut held: BTreeMap<u32, Placed> = BTreeMap::new();
|
||||
let (mut camera, mut compared, mut culled) = (DVec3::ZERO, 0, 0);
|
||||
// Fading sections the frustum and the graph keep, left to the CPU.
|
||||
let (mut camera, mut compared, mut culled, mut faded) = (DVec3::ZERO, 0, 0, 0);
|
||||
let mut words = Vec::new();
|
||||
// Cave culling's graph while a case has one (its generation, from x and
|
||||
// z, radius, height and whether its search reached the sky) and its bits.
|
||||
@@ -1897,7 +2004,8 @@ mod tests {
|
||||
.records
|
||||
.insert(Record::new(key, page, start, quads, boxed));
|
||||
assert!(
|
||||
held.insert(id, (key, page, start, quads, boxed)).is_none(),
|
||||
held.insert(id, (key, page, start, quads, boxed, false))
|
||||
.is_none(),
|
||||
"id {id} twice"
|
||||
);
|
||||
};
|
||||
@@ -1912,6 +2020,13 @@ mod tests {
|
||||
place(&mut gpu, &mut held, &mut rng);
|
||||
}
|
||||
}
|
||||
// A fifth of the sections fade: the flags that change go up as
|
||||
// updates, or with the whole table (cases 4 and 18), and through
|
||||
// case 7's frame the CPU draws.
|
||||
for (&id, placed) in held.iter_mut() {
|
||||
placed.5 = flags.below(5) == 0;
|
||||
gpu.records.set_fading(id, placed.5);
|
||||
}
|
||||
let whole = gpu.records.all_dirty;
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let input = FrameInputs {
|
||||
@@ -1947,10 +2062,12 @@ mod tests {
|
||||
let mut params = tables(&input, &mut words).expect("the planes fit");
|
||||
params.pages = gpu.pass_pages;
|
||||
let mut expected = Vec::new();
|
||||
for &(key, page, start, quads, boxed) in held.values() {
|
||||
for &(key, page, start, quads, boxed, fading) in held.values() {
|
||||
let record = Record::new(key, page, start, quads, boxed);
|
||||
let kept = keeps(¶ms, &words, &record);
|
||||
let kept = keeps(¶ms, &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(¶ms, &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);
|
||||
|
||||
@@ -42,18 +42,32 @@ impl Output {
|
||||
}
|
||||
}
|
||||
|
||||
/// `HORIZON_HDR_OUTPUT`'s names, in order of preference.
|
||||
const NAMES: [(Output, &str); 2] = [(Output::Pq, "pq"), (Output::ScRgb, "scrgb")];
|
||||
|
||||
/// The HDR output `caps` can present: HDR10 where offered, else scRGB.
|
||||
/// `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only that one.
|
||||
pub fn output(caps: &wgpu::SurfaceCapabilities) -> Option<Output> {
|
||||
pick(caps, std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref())
|
||||
}
|
||||
fn pick(caps: &wgpu::SurfaceCapabilities, only: Option<&str>) -> Option<Output> {
|
||||
[(Output::Pq, "pq"), (Output::ScRgb, "scrgb")]
|
||||
NAMES
|
||||
.into_iter()
|
||||
.filter(|(_, name)| only.is_none_or(|o| o == *name))
|
||||
.map(|(output, _)| output)
|
||||
.find(|o| caps.color_spaces(o.format()).contains(o.space()))
|
||||
}
|
||||
/// The HDR output an offscreen renderer (`--offscreen`) offers: it has no display to ask, so
|
||||
/// only the one `HORIZON_HDR_OUTPUT` names, for HDR screenshots without an HDR display.
|
||||
pub fn offscreen_output() -> Option<Output> {
|
||||
named(std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref())
|
||||
}
|
||||
fn named(name: Option<&str>) -> Option<Output> {
|
||||
NAMES
|
||||
.into_iter()
|
||||
.find(|(_, n)| name == Some(*n))
|
||||
.map(|(output, _)| output)
|
||||
}
|
||||
|
||||
struct Targets {
|
||||
size: [u32; 2],
|
||||
@@ -637,7 +651,7 @@ fn scene_gain(peak: f32, paper_white: f32) -> f32 {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, pick};
|
||||
use super::{Output, named, pick};
|
||||
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
|
||||
|
||||
#[test]
|
||||
@@ -687,6 +701,14 @@ mod tests {
|
||||
assert_eq!(pick(&scrgb, Some("pq")), None);
|
||||
assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offscreen_offers_only_the_named_output() {
|
||||
assert_eq!(named(Some("pq")), Some(Output::Pq));
|
||||
assert_eq!(named(Some("scrgb")), Some(Output::ScRgb));
|
||||
assert_eq!(named(Some("hdr10")), None);
|
||||
assert_eq!(named(None), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -59,6 +59,25 @@ fn chosen_cells(
|
||||
chosen.unwrap_or(d.cells)
|
||||
}
|
||||
|
||||
/// `[shadow]` with the option values `constants`: none while its `option` is off, else the
|
||||
/// resolution and distance its `resolution_option` and `distance_option` pick.
|
||||
fn chosen_shadow(
|
||||
s: &horizon_shader_api::manifest::Shadow,
|
||||
options: &[horizon_shader_api::manifest::PackOption],
|
||||
constants: &[(String, f64)],
|
||||
) -> Option<horizon_shader_api::manifest::Shadow> {
|
||||
let number = |id: Option<&String>| {
|
||||
let option = options.iter().find(|o| Some(&o.id) == id)?;
|
||||
let (_, value) = constants.iter().find(|(k, _)| *k == option.id)?;
|
||||
option.kind.number(*value)
|
||||
};
|
||||
option_on(constants, s.option.as_ref()).then(|| horizon_shader_api::manifest::Shadow {
|
||||
resolution: number(s.resolution_option.as_ref()).map_or(s.resolution, |r| r as u32),
|
||||
distance: number(s.distance_option.as_ref()).map_or(s.distance, |d| d as f32),
|
||||
..s.clone()
|
||||
})
|
||||
}
|
||||
|
||||
impl NativePack {
|
||||
/// The pack of the checked stages `report` with the option values `constants`, drawing
|
||||
/// onto the world's targets as `terrain` describes; the sky reads the terrain's group 0.
|
||||
@@ -74,8 +93,12 @@ impl NativePack {
|
||||
) -> Result<(Self, Vec<String>), String> {
|
||||
let (globals, format) = (terrain.globals, terrain.format);
|
||||
let textures = super::native_post::pack_textures(device, queue, &report);
|
||||
let settings = report
|
||||
// `[shadow]`, unless its option is off, at the size its options pick.
|
||||
let chosen = report
|
||||
.shadow
|
||||
.as_ref()
|
||||
.and_then(|s| chosen_shadow(s, &report.manifest.options, constants));
|
||||
let settings = chosen
|
||||
.as_ref()
|
||||
.map(|settings| super::native_shadow::fitted(device, settings));
|
||||
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
|
||||
@@ -103,8 +126,7 @@ impl NativePack {
|
||||
report.shadow.is_some(),
|
||||
)
|
||||
});
|
||||
let shadow = report
|
||||
.shadow
|
||||
let shadow = chosen
|
||||
.as_ref()
|
||||
.map(|s| {
|
||||
let vertex = report.stages.iter().find(|s| s.kind == StageKind::Vertex);
|
||||
@@ -240,3 +262,61 @@ impl NativePack {
|
||||
|| self.post.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::chosen_shadow;
|
||||
use horizon_shader_api::manifest::{OptionKind, PackOption, Shadow};
|
||||
|
||||
fn option(id: &str, kind: OptionKind) -> PackOption {
|
||||
PackOption {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
description: String::new(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shadow_map_follows_its_options() {
|
||||
let options = [
|
||||
option("shadows", OptionKind::Bool { default: true }),
|
||||
option(
|
||||
"quality",
|
||||
OptionKind::Choice {
|
||||
values: vec!["1024".into(), "4096".into()],
|
||||
default: 0,
|
||||
},
|
||||
),
|
||||
option(
|
||||
"reach",
|
||||
OptionKind::Int {
|
||||
default: 96,
|
||||
min: 32,
|
||||
max: 512,
|
||||
},
|
||||
),
|
||||
];
|
||||
let shadow = Shadow {
|
||||
option: Some("shadows".into()),
|
||||
resolution_option: Some("quality".into()),
|
||||
distance_option: Some("reach".into()),
|
||||
..Shadow::default()
|
||||
};
|
||||
let values = |on: f64, quality: f64, reach: f64| {
|
||||
[("shadows", on), ("quality", quality), ("reach", reach)].map(|(k, v)| (k.into(), v))
|
||||
};
|
||||
let size = |values: &[(String, f64)]| {
|
||||
chosen_shadow(&shadow, &options, values).map(|s| (s.resolution, s.distance))
|
||||
};
|
||||
assert_eq!(size(&values(1.0, 1.0, 200.0)), Some((4096, 200.0)));
|
||||
assert_eq!(size(&values(1.0, 0.0, 64.0)), Some((1024, 64.0)));
|
||||
assert_eq!(size(&values(0.0, 1.0, 200.0)), None);
|
||||
// Without the options' keys: the manifest's own numbers, always drawn.
|
||||
assert_eq!(
|
||||
chosen_shadow(&Shadow::default(), &options, &values(0.0, 1.0, 200.0))
|
||||
.map(|s| (s.resolution, s.distance)),
|
||||
Some((2048, 128.0))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -76,14 +76,14 @@ pub struct WorldDrawn<'a> {
|
||||
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player and
|
||||
/// `(2 << 32) | id` for a dropped item (their entity ids), 0 for anything else. A run lasts
|
||||
/// until the next. Unlike `spans`, whose ids name a type, these follow one entity from
|
||||
/// frame to frame (API @API:world_drawn_motion@).
|
||||
/// frame to frame (API 371).
|
||||
pub motion: &'a [(u32, u64)],
|
||||
/// The shared atlas entity UVs point into.
|
||||
pub atlas: Option<&'a wgpu::Texture>,
|
||||
/// Quad indices (0 1 2 0 2 3 for each 4 vertices), for as many quads as `entities` holds.
|
||||
pub quad_indices: &'a wgpu::Buffer,
|
||||
/// The block textures the terrain samples, for drawing blocks with the player's own
|
||||
/// textures (`native_block_sprites` says where a block's are). API @API:native_block_sprites@.
|
||||
/// textures (`native_block_sprites` says where a block's are). API 380.
|
||||
pub block_sprites: BlockSpriteArrays<'a>,
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
|
||||
}
|
||||
|
||||
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
|
||||
/// runs, with no hand too. API @API:hand_drawn@.
|
||||
/// runs, with no hand too. API 364.
|
||||
#[inline(never)]
|
||||
pub fn native_hand_drawn(hand: &mut HandDrawn<'_>) {
|
||||
generated::dispatch_hand_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), hand);
|
||||
|
||||
@@ -806,12 +806,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn what_the_camera_stands_on_is_inside_the_shadow_cameras_depth() {
|
||||
let settings = Settings {
|
||||
resolution: 2048,
|
||||
distance: 128.0,
|
||||
solid_materials: Vec::new(),
|
||||
};
|
||||
let shadow = camera(&settings);
|
||||
let shadow = camera(&Settings::default());
|
||||
let (view, projection) = super::super::pack_uniforms::shadow_camera(
|
||||
glam::Vec3::new(0.3, 0.9, 0.0).normalize(),
|
||||
glam::DVec3::new(-2064.5, 246.0, -4304.5),
|
||||
@@ -827,11 +822,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_centre_stays_near_the_player_on_both_sides_of_zero() {
|
||||
let shadow = camera(&Settings {
|
||||
resolution: 2048,
|
||||
distance: 128.0,
|
||||
solid_materials: Vec::new(),
|
||||
});
|
||||
let shadow = camera(&Settings::default());
|
||||
let light = glam::Vec3::Y;
|
||||
for x in [5.5, -5.5, -2064.5, 2064.5] {
|
||||
let camera = glam::DVec3::new(x, 64.0, x);
|
||||
|
||||
@@ -96,6 +96,17 @@ impl PackAtlas {
|
||||
Some(atlas)
|
||||
}
|
||||
|
||||
/// The table flat terrain reads (`hz_sprite_rects`): per layer, its sprite's top left corner
|
||||
/// (x | y << 16) and side, in atlas pixels.
|
||||
pub fn rects_table(&self) -> Vec<u32> {
|
||||
(0..self.columns * self.texture.height() / self.sprite)
|
||||
.flat_map(|layer| {
|
||||
let (x, y) = (layer % self.columns, layer / self.columns);
|
||||
[(x * self.sprite) | ((y * self.sprite) << 16), self.sprite]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The atlas's size in pixels (`atlasSize`).
|
||||
pub fn size(&self) -> [u32; 2] {
|
||||
[self.texture.width(), self.texture.height()]
|
||||
@@ -204,7 +215,8 @@ impl PackAtlas {
|
||||
// vertices (`TerrainVertex`, six words each).
|
||||
declarations.push_str(
|
||||
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
|
||||
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n",
|
||||
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n\
|
||||
layout(set = 3, binding = 2) readonly buffer HzSpriteRects { uvec2 hz_sprite_rects[]; };\n",
|
||||
);
|
||||
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
|
||||
} else {
|
||||
@@ -212,11 +224,29 @@ impl PackAtlas {
|
||||
"hz_v_origin"
|
||||
};
|
||||
let [width, height] = self.size();
|
||||
let scale = [
|
||||
self.sprite as f32 / width as f32,
|
||||
self.sprite as f32 / height as f32,
|
||||
];
|
||||
// A sprite's cell in its own sides, and the share of the atlas a side is: from the table
|
||||
// in flat draws (`rects_table`), from the grid in others.
|
||||
let columns = self.columns;
|
||||
let (cell, side, scale) = if flat {
|
||||
(
|
||||
"uvec2 hz_rect = hz_sprite_rects[hz_layer];\n \
|
||||
hz_cell = vec2(float(hz_rect.x & 65535u), float(hz_rect.x >> 16u)) / float(hz_rect.y);\n "
|
||||
.to_owned(),
|
||||
"float(hz_rect.y)",
|
||||
[1.0 / width as f32, 1.0 / height as f32],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!(
|
||||
"hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n "
|
||||
),
|
||||
"1.0",
|
||||
[
|
||||
self.sprite as f32 / width as f32,
|
||||
self.sprite as f32 / height as f32,
|
||||
],
|
||||
)
|
||||
};
|
||||
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
|
||||
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1 unless separate_ao.
|
||||
let color_a = if self.separate_ao {
|
||||
@@ -227,8 +257,8 @@ impl PackAtlas {
|
||||
let body = format!(
|
||||
" vec4 hz_origin = {origin};\n \
|
||||
uint hz_layer = hz_v_layer & 32767u;\n \
|
||||
hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n \
|
||||
hz_scale = vec2({:?}, {:?});\n \
|
||||
{cell}\
|
||||
hz_scale = vec2({:?}, {:?}) * {side};\n \
|
||||
hz_Vertex = vec4(hz_v_pos + hz_origin.xyz, 1.0);\n \
|
||||
hz_uv_scale = hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w));\n \
|
||||
hz_mid = vec2(0.5);\n \
|
||||
|
||||
@@ -183,7 +183,7 @@ fn sample(
|
||||
}],
|
||||
vec![storage(0), storage(1)],
|
||||
vec![],
|
||||
vec![storage(0), storage(1)],
|
||||
vec![storage(0), storage(1), storage(2)],
|
||||
];
|
||||
let layouts: Vec<_> = entries
|
||||
.iter()
|
||||
@@ -268,11 +268,15 @@ fn sample(
|
||||
bytemuck::cast_slice(vertices),
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
|
||||
);
|
||||
let rects = buffer(
|
||||
bytemuck::cast_slice(&atlas.rects_table()),
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
let bindings = [
|
||||
vec![(0, &uniform)],
|
||||
vec![(0, &slots), (1, &origin)],
|
||||
vec![],
|
||||
vec![(0, &ids), (1, &data)],
|
||||
vec![(0, &ids), (1, &data), (2, &rects)],
|
||||
];
|
||||
let groups: Vec<_> = bindings
|
||||
.iter()
|
||||
|
||||
@@ -122,6 +122,7 @@ void main() {
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &inputs);
|
||||
assert!(post.terrain.is_some(), "{:?}", post.notes);
|
||||
let world = World {
|
||||
sprite_rects: &[],
|
||||
atlas: Some((atlases[2].view.clone(), atlases[2].size())),
|
||||
material_maps: Some([atlases[1].view.clone(), atlases[0].view.clone()]),
|
||||
lightmap: atlases[2].view.clone(),
|
||||
@@ -148,7 +149,8 @@ void main() {
|
||||
post,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
world,
|
||||
);
|
||||
)
|
||||
.expect("the pack runs");
|
||||
let mut pass = post
|
||||
.terrain
|
||||
.take()
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use glam::{DMat4, DVec3};
|
||||
use horizon_shaders::{Format, Group, Kind, Post, SHADOW_COLORS, Val};
|
||||
use horizon_shaders::{Format, Group, Kind, Post, SHADOW_COLORS};
|
||||
|
||||
use super::pack_draw::DrawUniforms;
|
||||
use super::pack_entities::{PROGRAMS, PackEntities};
|
||||
@@ -29,6 +29,8 @@ use super::pack_uniforms::{self, FrameUniforms, PackInputs};
|
||||
|
||||
const BUFFERS: usize = horizon_shaders::BUFFERS;
|
||||
|
||||
mod packed;
|
||||
|
||||
#[cfg(test)]
|
||||
mod world_target_tests;
|
||||
|
||||
@@ -439,6 +441,9 @@ struct Targets {
|
||||
pub(super) struct World<'a> {
|
||||
/// The block atlas and its size.
|
||||
pub atlas: Option<(wgpu::TextureView, [u32; 2])>,
|
||||
/// Of a stitched block atlas, each sprite layer's place (`PackAtlas::rects_table`); empty
|
||||
/// for a grid.
|
||||
pub sprite_rects: &'a [u32],
|
||||
pub material_maps: Option<[wgpu::TextureView; 2]>,
|
||||
pub lightmap: wgpu::TextureView,
|
||||
/// Terrain's geometry, when the pack draws it.
|
||||
@@ -565,9 +570,9 @@ pub struct PackPost {
|
||||
down: HashMap<wgpu::TextureFormat, (wgpu::RenderPipeline, wgpu::BindGroupLayout)>,
|
||||
/// The per-draw uniforms of gbuffers programs.
|
||||
draws: DrawUniforms,
|
||||
/// Set 3 of flat terrain (`mc_Entity`'s table) and its bind group for each arena page,
|
||||
/// with the page buffer it was made for.
|
||||
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer)>,
|
||||
/// Set 3 of flat terrain (`mc_Entity`'s table, then the sprites' places) and its bind group
|
||||
/// for each arena page, with the page buffer it was made for.
|
||||
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer)>,
|
||||
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
|
||||
gui_size: [u32; 2],
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
@@ -1040,19 +1045,29 @@ pub(super) struct ShadowView {
|
||||
}
|
||||
|
||||
/// Set 3 of flat terrain, read by the vertex stage: the sprite layers' table (`mc_Entity`'s id
|
||||
/// and fluid, `at_midBlock.w`; -1, -1, 0 when empty) and, per arena page, the page's vertices
|
||||
/// (a vertex reads its quad's corners for its normal).
|
||||
fn block_ids(device: &wgpu::Device, table: &[i32]) -> (wgpu::BindGroupLayout, wgpu::Buffer) {
|
||||
/// and fluid, `at_midBlock.w`; -1, -1, 0 when empty), per arena page, the page's vertices
|
||||
/// (a vertex reads its quad's corners for its normal), and each sprite's place in a stitched
|
||||
/// atlas (`rects`; zeros for a grid, which doesn't read them).
|
||||
fn block_ids(
|
||||
device: &wgpu::Device,
|
||||
table: &[i32],
|
||||
rects: &[u32],
|
||||
) -> (wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer) {
|
||||
let empty = [-1, -1, 0];
|
||||
let table = if table.is_empty() { &empty[..] } else { table };
|
||||
let buffer = wgpu::util::DeviceExt::create_buffer_init(
|
||||
device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Shader pack block ids"),
|
||||
contents: bytemuck::cast_slice(table),
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
},
|
||||
);
|
||||
let buffer = |label, contents: &[u8]| {
|
||||
wgpu::util::DeviceExt::create_buffer_init(
|
||||
device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(label),
|
||||
contents,
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
},
|
||||
)
|
||||
};
|
||||
let rects = if rects.is_empty() { &[0, 0][..] } else { rects };
|
||||
let ids = buffer("Shader pack block ids", bytemuck::cast_slice(table));
|
||||
let rects = buffer("Shader pack sprite rects", bytemuck::cast_slice(rects));
|
||||
let storage = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
@@ -1065,9 +1080,9 @@ fn block_ids(device: &wgpu::Device, table: &[i32]) -> (wgpu::BindGroupLayout, wg
|
||||
};
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Shader pack terrain"),
|
||||
entries: &[storage(0), storage(1)],
|
||||
entries: &[storage(0), storage(1), storage(2)],
|
||||
});
|
||||
(layout, buffer)
|
||||
(layout, ids, rects)
|
||||
}
|
||||
|
||||
/// The game's geometry a gbuffers program draws: its vertex buffers and, for flat draws
|
||||
@@ -1101,6 +1116,8 @@ struct Resources<'a> {
|
||||
storable: u16,
|
||||
/// The buffers of a three-channel format (`rgb`): they have no alpha to write, and read 1.
|
||||
rgb: u16,
|
||||
/// The bits per channel a buffer of a small packed format keeps ([`packed::bits`]).
|
||||
packed: [Option<[u8; 4]>; BUFFERS],
|
||||
/// The stage (`texture.<stage>`) of the programs built so far, and the buffers one of them
|
||||
/// flipped: a `texture.<stage>.colortexN` no longer stands in for buffer N in the stage's
|
||||
/// later programs, which read what was written, as in Iris. `final` continues
|
||||
@@ -1185,9 +1202,18 @@ fn bindings(
|
||||
let released = pack.released.get();
|
||||
let custom = pack_textures::find(textures, group, &s.ty, &s.name).filter(|&i| {
|
||||
let t = &textures[i];
|
||||
t.stage != pack_textures::stage(group)
|
||||
|| released.0 != t.stage
|
||||
|| horizon_shaders::buffer_index(&t.name).is_none_or(|b| released.1 & 1 << b == 0)
|
||||
// A PNG under `texture.<stage>.<name>` whose name is no sampler of the game's
|
||||
// isn't bound: the sampler reads as if it had no texture. Iris 1.11.7 with a probe
|
||||
// pack of ours (#9064) bound it over `gaux2` and `noisetex` but not over names of its
|
||||
// own, where a raw texture was bound; this rule is a fit to those cases.
|
||||
let unbound = t.png
|
||||
&& t.stage != horizon_shaders::EVERY_STAGE
|
||||
&& source(&s.name, false, true, false) == Source::Dummy;
|
||||
!unbound
|
||||
&& (t.stage != pack_textures::stage(group)
|
||||
|| released.0 != t.stage
|
||||
|| horizon_shaders::buffer_index(&t.name)
|
||||
.is_none_or(|b| released.1 & 1 << b == 0))
|
||||
});
|
||||
let image = pack
|
||||
.images
|
||||
@@ -1497,6 +1523,8 @@ fn build_pass(
|
||||
let final_pass = p.group == Group::Final;
|
||||
let mut targets = Vec::new();
|
||||
let mut writes = Vec::new();
|
||||
// Outputs into a buffer of a small packed format, and the bits it keeps.
|
||||
let mut packed_outputs = Vec::new();
|
||||
for &location in &interface.outputs {
|
||||
let l = location as usize;
|
||||
if targets.len() <= l {
|
||||
@@ -1528,6 +1556,11 @@ fn build_pass(
|
||||
{
|
||||
t.blend = Some(blend_state(b));
|
||||
}
|
||||
if !p.group.shadow()
|
||||
&& let Some(bits) = pack.packed[buffer as usize]
|
||||
{
|
||||
packed_outputs.push((location, bits));
|
||||
}
|
||||
targets[l] = state;
|
||||
writes.push((location, buffer as usize));
|
||||
}
|
||||
@@ -1587,7 +1620,9 @@ fn build_pass(
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(m)),
|
||||
})
|
||||
};
|
||||
let (vs, fs) = (module(program.vertex), module(program.fragment));
|
||||
let mut fragment = program.fragment;
|
||||
packed::quantise_outputs(&mut fragment, &packed_outputs);
|
||||
let (vs, fs) = (module(program.vertex), module(fragment));
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&p.name),
|
||||
layout: Some(&pipeline_layout),
|
||||
@@ -1833,8 +1868,16 @@ fn build_compute(
|
||||
})
|
||||
}
|
||||
|
||||
/// Why a pack can't run, and the notes of loading it until then (why programs were skipped).
|
||||
#[derive(Debug)]
|
||||
pub struct Refused {
|
||||
pub reason: String,
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl PackPost {
|
||||
/// Builds the pipelines of `post` for a screen of `screen` format.
|
||||
/// Builds the pipelines of `post` for a screen of `screen` format. An error when the pack
|
||||
/// can't show anything on the screen: the game turns it off, as a pack that fails to load.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
@@ -1842,7 +1885,7 @@ impl PackPost {
|
||||
mut post: Post,
|
||||
screen: wgpu::TextureFormat,
|
||||
world: World<'_>,
|
||||
) -> Self {
|
||||
) -> Result<Self, Refused> {
|
||||
// What the pack's shaders need of its storage buffers, before its parts move out.
|
||||
let minimums = post.buffer_minimums();
|
||||
let features = device.features();
|
||||
@@ -1858,6 +1901,10 @@ impl PackPost {
|
||||
let rgb = (0..BUFFERS)
|
||||
.filter(|&i| post.config.buffers[i].format.channels == 3)
|
||||
.fold(0u16, |m, i| m | 1 << i);
|
||||
// A small packed format's buffer reads back fewer bits, as in Iris: its clears and
|
||||
// what its passes write keep only those.
|
||||
let packed: [Option<[u8; 4]>; BUFFERS] =
|
||||
std::array::from_fn(|i| packed::bits(post.config.buffers[i].format));
|
||||
let clears = std::array::from_fn(|i| {
|
||||
let b = &post.config.buffers[i];
|
||||
b.clear.then(|| {
|
||||
@@ -1865,7 +1912,7 @@ impl PackPost {
|
||||
if rgb & 1 << i != 0 {
|
||||
c[3] = 1.0;
|
||||
}
|
||||
c
|
||||
packed[i].map_or(c, |bits| packed::quantise(c, bits))
|
||||
})
|
||||
});
|
||||
let mut notes = post.notes;
|
||||
@@ -1882,7 +1929,7 @@ impl PackPost {
|
||||
let mut custom = post.custom;
|
||||
let custom_names: Vec<String> = custom.uniforms().map(|u| u.0.to_owned()).collect();
|
||||
// A first run finds the names neither the frame nor the pack defines.
|
||||
custom.eval(0.0, &|n| pack_uniforms::index(n).map(|_| Val::scalar(0.0)));
|
||||
custom.eval(0.0, &pack_uniforms::zero);
|
||||
// Its zeros are no start for `smooth`: the first frame's values are, as in Iris.
|
||||
custom.restart_smoothing();
|
||||
if !custom.notes.is_empty() {
|
||||
@@ -1970,6 +2017,7 @@ impl PackPost {
|
||||
gpu,
|
||||
storable,
|
||||
rgb,
|
||||
packed,
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: if features.contains(wgpu::Features::FLOAT32_FILTERABLE) {
|
||||
@@ -2065,7 +2113,7 @@ impl PackPost {
|
||||
.terrain
|
||||
.as_ref()
|
||||
.filter(|g| g.table.is_some())
|
||||
.map(|_| block_ids(device, world.block_ids));
|
||||
.map(|_| block_ids(device, world.block_ids, world.sprite_rects));
|
||||
let terrain_geometry = world.terrain.as_ref().map(|g| Geometry {
|
||||
buffers: g.buffers,
|
||||
table: g.table,
|
||||
@@ -2260,6 +2308,22 @@ impl PackPost {
|
||||
.filter(|p| !skip.split(',').any(|s| s == p.name))
|
||||
.filter_map(|p| build(p, None))
|
||||
.collect();
|
||||
// The screen gets colortex0 through `final`, or else through Horizon's copy, which reads
|
||||
// floats: an integer colortex0 (`RGBA32UI`...) with no `final` left to draw can't reach
|
||||
// it (#9165).
|
||||
let shows = passes
|
||||
.iter()
|
||||
.any(|p| p.group == Group::Final && matches!(p.pipeline, Pipeline::Render(_)));
|
||||
if !shows && !holds_floats(formats[0]) {
|
||||
return Err(Refused {
|
||||
reason: format!(
|
||||
"colortex0 has an integer format ({:?}) and no `final` program draws it to \
|
||||
the screen",
|
||||
formats[0]
|
||||
),
|
||||
notes,
|
||||
});
|
||||
}
|
||||
let shadow_comp: Vec<Pass> = post
|
||||
.shadow_comp
|
||||
.into_iter()
|
||||
@@ -2556,7 +2620,7 @@ impl PackPost {
|
||||
let white = [255u8; 4];
|
||||
let black = [0u8; 4];
|
||||
let flat_normal = [128u8, 128, 255, 255];
|
||||
Self {
|
||||
Ok(Self {
|
||||
notes,
|
||||
setup_pending: true,
|
||||
passes,
|
||||
@@ -2719,7 +2783,7 @@ impl PackPost {
|
||||
u
|
||||
},
|
||||
shadow_cache: Default::default(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes `pass`'s uniforms for a target of `out` pixels; its bind group for the buffers'
|
||||
@@ -4214,7 +4278,7 @@ impl PackPost {
|
||||
terrain: &mut TerrainDraw<'_>,
|
||||
) {
|
||||
self.resize(device, size);
|
||||
if let Some((layout, ids)) = &self.block_ids {
|
||||
if let Some((layout, ids, rects)) = &self.block_ids {
|
||||
// A page that grew has a new buffer.
|
||||
self.page_groups
|
||||
.retain(|p, (b, _)| pages.iter().any(|(q, c)| q == p && c == b));
|
||||
@@ -4232,6 +4296,10 @@ impl PackPost {
|
||||
binding: 1,
|
||||
resource: buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: rects.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
(buffer.clone(), group)
|
||||
@@ -4590,14 +4658,8 @@ impl PackPost {
|
||||
.as_ref()
|
||||
.map(|t| &t[main(m, i) ^ usize::from(alt)])
|
||||
};
|
||||
// An integer colortex0 has no colour to show: the screen is only cleared.
|
||||
if !shown && !holds_floats(self.formats[0]) {
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack colortex0"),
|
||||
color_attachments: &[attach(screen, overwrite)],
|
||||
..Default::default()
|
||||
});
|
||||
} else if !shown {
|
||||
// Without `final`, colortex0 holds floats (`new` refuses an integer one).
|
||||
if !shown {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack colortex0"),
|
||||
layout: &self.copy.1,
|
||||
@@ -4745,6 +4807,7 @@ mod tests {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
blur: false,
|
||||
clamp: false,
|
||||
png: false,
|
||||
image: pack_textures::Image::Atlas,
|
||||
}];
|
||||
let interface = horizon_shaders::Interface {
|
||||
@@ -4777,6 +4840,7 @@ mod tests {
|
||||
gpu: Gpu::of(&adapter, device.features()),
|
||||
storable: 0,
|
||||
rgb: 0,
|
||||
packed: [None; BUFFERS],
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
@@ -4805,6 +4869,102 @@ mod tests {
|
||||
assert!(reads(Group::Final) == Source::Color(0));
|
||||
}
|
||||
|
||||
/// Iris 1.11.7 (a probe pack of ours, #9064) didn't bind a PNG under
|
||||
/// `texture.<stage>.<name>` when `name` was a name of the pack's own, no sampler of the
|
||||
/// game's; a raw texture there, a PNG over `gaux2` or `noisetex`, and a `customTexture` PNG
|
||||
/// were bound.
|
||||
#[test]
|
||||
fn a_png_under_a_name_of_its_own_is_not_bound() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let texture = |stage: &str, name: &str, png: bool| pack_textures::PackTexture {
|
||||
stage: stage.into(),
|
||||
name: name.into(),
|
||||
dimension: wgpu::TextureViewDimension::D2,
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
blur: !png,
|
||||
clamp: !png,
|
||||
png,
|
||||
image: pack_textures::Image::Atlas,
|
||||
};
|
||||
let textures = [
|
||||
texture("composite", "tp", true),
|
||||
texture("composite", "tr", false),
|
||||
texture("composite", "gaux2", true),
|
||||
texture("composite", "noisetex", true),
|
||||
texture(horizon_shaders::EVERY_STAGE, "cp", true),
|
||||
];
|
||||
let names = ["tp", "tr", "gaux2", "noisetex", "cp"];
|
||||
let interface = horizon_shaders::Interface {
|
||||
uniform_size: 48,
|
||||
uniforms: Vec::new(),
|
||||
samplers: names
|
||||
.iter()
|
||||
.zip(1..)
|
||||
.map(|(name, k)| horizon_shaders::Sampler {
|
||||
name: (*name).into(),
|
||||
ty: "sampler2D".into(),
|
||||
binding: 2 * k - 1,
|
||||
})
|
||||
.collect(),
|
||||
outputs: vec![0],
|
||||
draw: false,
|
||||
images: Vec::new(),
|
||||
buffers: Vec::new(),
|
||||
initial_values: vec![0; 48],
|
||||
};
|
||||
let buffers = pack_images::Buffers::new(
|
||||
&device,
|
||||
Default::default(),
|
||||
Default::default(),
|
||||
wgpu::ShaderStages::FRAGMENT,
|
||||
);
|
||||
let stand_ins = std::cell::RefCell::new(Vec::new());
|
||||
let pack = Resources {
|
||||
textures: &textures,
|
||||
images: &[],
|
||||
left_out: &[],
|
||||
buffers: &buffers,
|
||||
indices: None,
|
||||
gpu: Gpu::of(&adapter, device.features()),
|
||||
storable: 0,
|
||||
rgb: 0,
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
packed: [None; BUFFERS],
|
||||
};
|
||||
let (_, samplers, ..) = bindings(
|
||||
device.features(),
|
||||
&interface,
|
||||
"final",
|
||||
Group::Final,
|
||||
&[0],
|
||||
None,
|
||||
&[T::Rgba8Unorm; BUFFERS],
|
||||
None,
|
||||
&mut Vec::new(),
|
||||
&pack,
|
||||
)
|
||||
.expect("the pass is kept");
|
||||
let read: Vec<Source> = samplers.iter().map(|b| b.source).collect();
|
||||
assert!(
|
||||
read == [
|
||||
Source::Dummy,
|
||||
Source::Atlas,
|
||||
Source::Atlas,
|
||||
Source::Atlas,
|
||||
Source::Atlas
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Iris 1.11.7 runs a program whose image no `image.*` gives, and its loads read zero
|
||||
/// (observed with a probe pack). Its pass is kept, the image bound to a stand-in of its
|
||||
/// declared format, zero again before each pass even after one stored into it.
|
||||
@@ -4857,6 +5017,7 @@ mod tests {
|
||||
gpu: Gpu::of(&adapter, device.features()),
|
||||
storable: 0,
|
||||
rgb: 0,
|
||||
packed: [None; BUFFERS],
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
@@ -4998,6 +5159,7 @@ mod tests {
|
||||
gpu: Gpu::of(&adapter, device.features()),
|
||||
storable: 0,
|
||||
rgb: 0,
|
||||
packed: [None; BUFFERS],
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
@@ -5170,6 +5332,9 @@ mod material_tests;
|
||||
#[cfg(test)]
|
||||
mod rgb_alpha_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod packed_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod trinary_tests;
|
||||
|
||||
|
||||
@@ -83,7 +83,8 @@ impl Gpu {
|
||||
post,
|
||||
T::Rgba8Unorm,
|
||||
world,
|
||||
);
|
||||
)
|
||||
.expect("the pack runs");
|
||||
assert!(
|
||||
built.passes.iter().any(|p| p.group == Group::Final),
|
||||
"{:?}",
|
||||
@@ -334,6 +335,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
|
||||
cull: false,
|
||||
}),
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
@@ -707,6 +709,7 @@ fn shadow_depth_filtering(nearest: &str) -> Option<Vec<[u8; 4]>> {
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
sprite_rects: &[],
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
|
||||
@@ -199,7 +199,7 @@ impl Fixture {
|
||||
}
|
||||
|
||||
pub(super) fn build(&self, post: Post) -> PackPost {
|
||||
let post = self.build_with_terrain(post, None);
|
||||
let post = self.try_build(post).expect("the pack runs");
|
||||
assert!(
|
||||
post.notes.iter().all(|n| n.starts_with("shadow map ")),
|
||||
"{:?}",
|
||||
@@ -208,8 +208,22 @@ impl Fixture {
|
||||
post
|
||||
}
|
||||
|
||||
/// [`Self::build`] for a pack that may be refused.
|
||||
pub(super) fn try_build(&self, post: Post) -> Result<PackPost, Refused> {
|
||||
self.try_build_with_terrain(post, None)
|
||||
}
|
||||
|
||||
/// `build`, with the game's terrain geometry handed to the pack.
|
||||
pub(super) fn build_with_terrain(&self, post: Post, terrain: Option<Geometry<'_>>) -> PackPost {
|
||||
self.try_build_with_terrain(post, terrain)
|
||||
.expect("the pack runs")
|
||||
}
|
||||
|
||||
fn try_build_with_terrain(
|
||||
&self,
|
||||
post: Post,
|
||||
terrain: Option<Geometry<'_>>,
|
||||
) -> Result<PackPost, Refused> {
|
||||
let lightmap = self.texture([1, 1], T::Rgba8Unorm);
|
||||
PackPost::new(
|
||||
&self.device,
|
||||
@@ -218,11 +232,12 @@ impl Fixture {
|
||||
post,
|
||||
T::Rgba8Unorm,
|
||||
World {
|
||||
material_maps: None,
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap,
|
||||
terrain,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: [16, 16],
|
||||
clouds: None,
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
//! A pack may give colortex0 an integer format (OptiFine's `shaders.txt`, "Texture Formats",
|
||||
//! lists `RGBA32UI`, as #8459 quotes it): the pack still loads, and reads back what it wrote
|
||||
//! there. #8459's probe; #8459 reports Iris 1.11.7 drawing (0, 200, 0) at every point
|
||||
//! sampled from it.
|
||||
//! sampled from it. Without a `final` that draws it, the pack is refused (#9165).
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
/// A `final` that shows the integer colortex0.
|
||||
const FINAL: &str = "#version 330 compatibility\nuniform usampler2D colortex0;\n\
|
||||
void main() { gl_FragColor = vec4(texelFetch(colortex0, ivec2(gl_FragCoord.xy), 0)) / 255.0; }\n";
|
||||
|
||||
/// A `final` that doesn't compile (it calls a function nobody declares), so it is skipped.
|
||||
const BROKEN_FINAL: &str =
|
||||
"#version 330 compatibility\nvoid main() { gl_FragColor = undeclared_function(); }\n";
|
||||
|
||||
/// `depth`: the composite reads depthtex0, so the blit copies the world's depth; `last`: the
|
||||
/// pack has a `final` that shows colortex0 (without, Horizon shows colortex0 itself).
|
||||
fn pack(depth: bool, last: bool) -> Post {
|
||||
/// pack's `final`, if it has one.
|
||||
fn pack(depth: bool, last: Option<&str>) -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
@@ -34,13 +42,9 @@ fn pack(depth: bool, last: bool) -> Post {
|
||||
uniform sampler2D depthtex0;\nlayout(location = 0) out uvec4 o;\nvoid main() {{ o = {value}; }}\n"
|
||||
),
|
||||
);
|
||||
if last {
|
||||
if let Some(source) = last {
|
||||
add("final.vsh", VERTEX);
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 330 compatibility\nuniform usampler2D colortex0;\n\
|
||||
void main() { gl_FragColor = vec4(texelFetch(colortex0, ivec2(gl_FragCoord.xy), 0)) / 255.0; }\n",
|
||||
);
|
||||
add("final.fsh", source);
|
||||
}
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-integer-colortex0",
|
||||
@@ -49,21 +53,14 @@ fn pack(depth: bool, last: bool) -> Post {
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
assert_eq!(
|
||||
post.programs.len(),
|
||||
1 + usize::from(last),
|
||||
"{:?}",
|
||||
post.notes
|
||||
);
|
||||
post
|
||||
horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_integer_colortex0_keeps_what_the_pack_wrote() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
for depth in [false, true] {
|
||||
let mut post = gpu.build(pack(depth, true));
|
||||
let mut post = gpu.build(pack(depth, Some(FINAL)));
|
||||
// Twice: the second frame's world blit runs over a colortex0 the first frame wrote.
|
||||
for frame in 0..2 {
|
||||
let pixels = gpu.render(&mut post, [8, 8]);
|
||||
@@ -73,13 +70,20 @@ fn an_integer_colortex0_keeps_what_the_pack_wrote() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a `final`, there are no colours to show from an integer colortex0; what Iris shows
|
||||
/// then was not measured. The frame must still run.
|
||||
/// Without a `final` left to draw it (none, or one that is skipped), an integer colortex0 has
|
||||
/// no colours the screen can show: the pack is refused, so the game turns it off and draws
|
||||
/// without it instead of a black screen (#9165). The refusal keeps why `final` was skipped.
|
||||
#[test]
|
||||
fn an_integer_colortex0_without_final_still_runs() {
|
||||
fn an_integer_colortex0_without_a_working_final_is_refused() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut post = gpu.build(pack(false, false));
|
||||
for _ in 0..2 {
|
||||
gpu.render(&mut post, [8, 8]);
|
||||
for last in [None, Some(BROKEN_FINAL)] {
|
||||
let Err(refused) = gpu.try_build(pack(false, last)) else {
|
||||
panic!("{last:?}: a pack that can't show colortex0 runs");
|
||||
};
|
||||
assert_eq!(
|
||||
refused.notes.iter().any(|n| n.starts_with("final")),
|
||||
last.is_some(),
|
||||
"{refused:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! The small packed colortex formats `RGB5_A1`, `RGB565` and `R3_G3_B2`, read back as in Iris
|
||||
//! 1.11.7. wgpu has no such formats, so these buffers are 8-bit (`texture_format`); what a pass
|
||||
//! writes into one and its clear colour go through [`quantise`] first.
|
||||
//!
|
||||
//! Probe pack p8106c of our own wrote 600 values to each format and read them back with
|
||||
//! `texelFetch` in Iris 1.11.7 (NVIDIA 615.78.08). Our probe's Iris values fit this, with no
|
||||
//! exception: each channel becomes the byte an `RGBA8` buffer stores for it, keeps that byte's
|
||||
//! top bits (5, 5, 5 and 1 for `RGB5_A1`; 5, 6 and 5 for `RGB565` and also for `R3_G3_B2`), and
|
||||
//! reads back those bits repeated to fill 8 (`0.3337` → byte 85 → 10 of 31 → 82 / 255). This is
|
||||
//! a fit to one driver's values, not a rule Iris states. `RGBA4` and `RGBA2` read back the `RGBA8`
|
||||
//! bytes in the same probe, as Horizon's 8-bit buffers already do.
|
||||
|
||||
use horizon_shaders::{Format, Kind};
|
||||
use naga::{BinaryOperator, Expression, Literal, MathFunction, Statement, VectorSize};
|
||||
|
||||
/// The bits each channel keeps in a buffer of format `f`, for the formats that keep fewer than
|
||||
/// 8; a three-channel format's alpha keeps 8 (it reads 1 anyway).
|
||||
pub(super) fn bits(f: Format) -> Option<[u8; 4]> {
|
||||
match (f.kind, f.channels, f.bits) {
|
||||
(Kind::Unorm, 4, 5) => Some([5, 5, 5, 1]),
|
||||
// `RGB565`, and `R3_G3_B2` (3 × 3 bits in `config.rs`), which our probe's Iris values
|
||||
// put in 5, 6 and 5 bits too.
|
||||
(Kind::Unorm, 3, 6 | 3) => Some([5, 6, 5, 8]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The value a buffer keeping `bits` per channel reads back after `v` is written or cleared.
|
||||
pub(super) fn quantise(v: [f32; 4], bits: [u8; 4]) -> [f32; 4] {
|
||||
std::array::from_fn(|i| {
|
||||
let byte = (v[i].clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u32;
|
||||
let n = u32::from(bits[i]);
|
||||
let kept = byte >> (8 - n);
|
||||
// The kept bits, repeated until there are 8.
|
||||
let mut wide = 0;
|
||||
let mut have = 0;
|
||||
while have < 8 {
|
||||
wide = wide << n | kept;
|
||||
have += n;
|
||||
}
|
||||
(wide >> (have - 8)) as f32 / 255.0
|
||||
})
|
||||
}
|
||||
|
||||
/// The factors of the shader's [`quantise`] for a channel keeping `n` bits: the kept value is
|
||||
/// `floor(byte / step)`, and it reads back `kept × step + floor(kept × repeat)`.
|
||||
fn factors(n: u8) -> (f32, f32) {
|
||||
let step = f32::from(1u16 << (8 - n));
|
||||
// `kept × repeat` is the kept bits shifted down to fill what `kept × step` leaves: one bit
|
||||
// fills the other 7 (× 127); 5 or 6 bits fill 3 or 2 with their top bits.
|
||||
let repeat = match n {
|
||||
1 => 127.0,
|
||||
_ => 2f32.powi(8 - 2 * i32::from(n)),
|
||||
};
|
||||
(step, repeat)
|
||||
}
|
||||
|
||||
/// Makes the fragment entry point of `module` write each output location of `outputs` as
|
||||
/// [`quantise`] gives it for its bits. The translator's entry point ends in one `return` of its
|
||||
/// outputs; a module without one is left as it is.
|
||||
pub(super) fn quantise_outputs(module: &mut naga::Module, outputs: &[(u32, [u8; 4])]) {
|
||||
let Some(ep) =
|
||||
(module.entry_points.iter_mut()).find(|e| e.stage == naga::ShaderStage::Fragment)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(result) = &ep.function.result else {
|
||||
return;
|
||||
};
|
||||
let naga::TypeInner::Struct { members, .. } = &module.types[result.ty].inner else {
|
||||
return;
|
||||
};
|
||||
let vec4 = |ty: naga::Handle<naga::Type>| {
|
||||
matches!(
|
||||
module.types[ty].inner,
|
||||
naga::TypeInner::Vector {
|
||||
size: VectorSize::Quad,
|
||||
scalar: naga::Scalar::F32
|
||||
}
|
||||
)
|
||||
};
|
||||
// (member, bits, the member's type) of each output to change.
|
||||
let changed: Vec<(usize, [u8; 4], naga::Handle<naga::Type>)> = (members.iter().enumerate())
|
||||
.filter_map(|(i, m)| {
|
||||
let Some(naga::Binding::Location { location, .. }) = m.binding else {
|
||||
return None;
|
||||
};
|
||||
let bits = outputs.iter().find(|o| o.0 == location)?.1;
|
||||
vec4(m.ty).then_some((i, bits, m.ty))
|
||||
})
|
||||
.collect();
|
||||
if changed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let struct_ty = result.ty;
|
||||
let f = &mut ep.function;
|
||||
let body = std::mem::take(&mut f.body);
|
||||
let mut out = naga::Block::with_capacity(body.len() + 1);
|
||||
for (statement, span) in body.span_into_iter() {
|
||||
let Statement::Return { value: Some(value) } = statement else {
|
||||
out.push(statement, span);
|
||||
continue;
|
||||
};
|
||||
let Expression::Compose { components, .. } = &f.expressions[value] else {
|
||||
out.push(statement, span);
|
||||
continue;
|
||||
};
|
||||
let mut components = components.clone();
|
||||
let e = &mut f.expressions;
|
||||
let lit = |e: &mut naga::Arena<Expression>, v: f32| {
|
||||
e.append(Expression::Literal(Literal::F32(v)), span)
|
||||
};
|
||||
// Literals need no emitting: they come before the range that is emitted.
|
||||
let scalars: Vec<_> = (changed.iter())
|
||||
.map(|&(_, bits, _)| {
|
||||
let per = bits.map(factors);
|
||||
(
|
||||
per.map(|(step, _)| lit(e, step)),
|
||||
per.map(|(step, _)| lit(e, 1.0 / step)),
|
||||
per.map(|(_, repeat)| lit(e, repeat)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let (zero, one, max, half) = (lit(e, 0.0), lit(e, 1.0), lit(e, 255.0), lit(e, 0.5));
|
||||
let mut add = |x: Expression| e.append(x, span);
|
||||
let splat = |add: &mut dyn FnMut(Expression) -> _, value| {
|
||||
add(Expression::Splat {
|
||||
size: VectorSize::Quad,
|
||||
value,
|
||||
})
|
||||
};
|
||||
let (zero, one, max, half) = (
|
||||
splat(&mut add, zero),
|
||||
splat(&mut add, one),
|
||||
splat(&mut add, max),
|
||||
splat(&mut add, half),
|
||||
);
|
||||
let binary = |add: &mut dyn FnMut(Expression) -> _, op, left, right| {
|
||||
add(Expression::Binary { op, left, right })
|
||||
};
|
||||
let math = |add: &mut dyn FnMut(Expression) -> _, fun, arg, arg1, arg2| {
|
||||
add(Expression::Math {
|
||||
fun,
|
||||
arg,
|
||||
arg1,
|
||||
arg2,
|
||||
arg3: None,
|
||||
})
|
||||
};
|
||||
for (&(member, _, ty), (steps, inverse, repeats)) in changed.iter().zip(scalars) {
|
||||
let mut vector = |components: [_; 4]| {
|
||||
add(Expression::Compose {
|
||||
ty,
|
||||
components: components.to_vec(),
|
||||
})
|
||||
};
|
||||
let (step, inverse, repeat) = (vector(steps), vector(inverse), vector(repeats));
|
||||
let x = components[member];
|
||||
let clamped = math(&mut add, MathFunction::Clamp, x, Some(zero), Some(one));
|
||||
let scaled = binary(&mut add, BinaryOperator::Multiply, clamped, max);
|
||||
let rounded = binary(&mut add, BinaryOperator::Add, scaled, half);
|
||||
let byte = math(&mut add, MathFunction::Floor, rounded, None, None);
|
||||
let shifted = binary(&mut add, BinaryOperator::Multiply, byte, inverse);
|
||||
let kept = math(&mut add, MathFunction::Floor, shifted, None, None);
|
||||
let high = binary(&mut add, BinaryOperator::Multiply, kept, step);
|
||||
let low = binary(&mut add, BinaryOperator::Multiply, kept, repeat);
|
||||
let low = math(&mut add, MathFunction::Floor, low, None, None);
|
||||
let wide = binary(&mut add, BinaryOperator::Add, high, low);
|
||||
components[member] = binary(&mut add, BinaryOperator::Divide, wide, max);
|
||||
}
|
||||
let value = add(Expression::Compose {
|
||||
ty: struct_ty,
|
||||
components,
|
||||
});
|
||||
// Everything from the first splat on is new and not a literal.
|
||||
let range = naga::Range::new_from_bounds(zero, value);
|
||||
out.push(Statement::Emit(range), span);
|
||||
out.push(Statement::Return { value: Some(value) }, span);
|
||||
}
|
||||
f.body = out;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clear_colours_keep_the_same_bits() {
|
||||
// The CPU side, for clear colours. Iris 1.11.7 read p8106d's clear colour back as below in
|
||||
// RGB5_A1 and RGB565, and p8106c's 0.3337 as 82/255 in the 5-bit channels and 85/255 in
|
||||
// the 6-bit one.
|
||||
let to_255 = |v: [f32; 4]| v.map(|c| (c * 255.0 * 1000.0).round() / 1000.0);
|
||||
let rgb5_a1 = bits(Format::parse("RGB5_A1").unwrap()).unwrap();
|
||||
let rgb565 = bits(Format::parse("RGB565").unwrap()).unwrap();
|
||||
let r3_g3_b2 = bits(Format::parse("R3_G3_B2").unwrap()).unwrap();
|
||||
let clear = [0.3337, 0.1, 0.6, 0.3];
|
||||
assert_eq!(to_255(quantise(clear, rgb5_a1)), [82.0, 24.0, 156.0, 0.0]);
|
||||
assert_eq!(to_255(quantise(clear, rgb565))[..3], [82.0, 24.0, 156.0]);
|
||||
assert_eq!(
|
||||
to_255(quantise([0.3337; 4], r3_g3_b2))[..3],
|
||||
[82.0, 85.0, 82.0]
|
||||
);
|
||||
assert!(bits(Format::parse("RGBA4").unwrap()).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! `RGB5_A1`, `RGB565` and `R3_G3_B2` buffers read back as in Iris 1.11.7. Expected values are
|
||||
//! those probe packs p8106c (written values) and p8106d (clear colours) read back in Iris (the PR
|
||||
//! has them); the programs here are the probes', cut down. Written values are ones whose byte
|
||||
//! (× 255) is not near a half: GPUs may round those either way.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
fn pack() -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
add("composite.vsh", VERTEX);
|
||||
add(
|
||||
"composite.fsh",
|
||||
"#version 120\n/* RENDERTARGETS: 1,2,3 */\n\
|
||||
/*\nconst int colortex1Format = RGB5_A1;\nconst int colortex2Format = RGB565;\n\
|
||||
const int colortex3Format = R3_G3_B2;\nconst int colortex4Format = RGB5_A1;\n\
|
||||
const int colortex5Format = RGB565;\n*/\n\
|
||||
const vec4 colortex4ClearColor = vec4(0.3337, 0.1, 0.6, 0.3);\n\
|
||||
const vec4 colortex5ClearColor = vec4(0.3337, 0.1, 0.6, 0.3);\n\
|
||||
void main() {\n\
|
||||
gl_FragData[0] = vec4(175.0, 176.0, 766.5, 256.0) / 511.0;\n\
|
||||
gl_FragData[1] = vec4(400.0, 175.0, 44.0, 0.0) / 511.0;\n\
|
||||
gl_FragData[2] = vec4(-255.5, 176.0, 300.0, 0.0) / 511.0;\n}\n",
|
||||
);
|
||||
add("final.vsh", VERTEX);
|
||||
// Pixel column x shows component x % 4 of buffer 1 + x / 4, as the bytes of its bits.
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 330 compatibility\nuniform sampler2D colortex1;\nuniform sampler2D colortex2;\n\
|
||||
uniform sampler2D colortex3;\nuniform sampler2D colortex4;\nuniform sampler2D colortex5;\n\
|
||||
void main() {\n ivec2 p = ivec2(gl_FragCoord.xy);\n vec4 c = vec4(0.0);\n\
|
||||
switch (p.x / 4) {\n\
|
||||
case 0: c = texelFetch(colortex1, p, 0); break;\n\
|
||||
case 1: c = texelFetch(colortex2, p, 0); break;\n\
|
||||
case 2: c = texelFetch(colortex3, p, 0); break;\n\
|
||||
case 3: c = texelFetch(colortex4, p, 0); break;\n\
|
||||
case 4: c = texelFetch(colortex5, p, 0); break;\n\
|
||||
}\n uint u = floatBitsToUint(c[p.x % 4]);\n\
|
||||
gl_FragColor = vec4(uvec4(u >> 24u, u >> 16u, u >> 8u, u) & 255u) / 255.0;\n}\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-packed",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
assert_eq!(post.programs.len(), 2, "{:?}", post.notes);
|
||||
post
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_packed_buffers_keep_their_bits() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut post = gpu.build(pack());
|
||||
// Twice: the second frame writes the buffers' other sides.
|
||||
for frame in 0..2 {
|
||||
let pixels = gpu.render(&mut post, [20, 1]);
|
||||
let read: Vec<f32> = pixels
|
||||
.iter()
|
||||
.map(|p| f32::from_bits(u32::from_be_bytes(*p)))
|
||||
.collect();
|
||||
// Iris 1.11.7, in 255ths: RGB5_A1 written (175, 176, 766.5, 256) / 511; RGB565 written
|
||||
// (400, 175, 44, 0) / 511; R3_G3_B2 written (-255.5, 176, 300, 0) / 511; RGB5_A1 and
|
||||
// RGB565 cleared to (0.3337, 0.1, 0.6, 0.3). Three-channel formats read alpha 1.
|
||||
let expected: [f32; 20] = [
|
||||
82.0, 90.0, 255.0, 255.0, //
|
||||
206.0, 85.0, 16.0, 255.0, //
|
||||
0.0, 89.0, 148.0, 255.0, //
|
||||
82.0, 24.0, 156.0, 0.0, //
|
||||
82.0, 24.0, 156.0, 255.0,
|
||||
];
|
||||
for (n, (got, want)) in read.iter().zip(expected).enumerate() {
|
||||
assert!(
|
||||
(got - want / 255.0).abs() <= 1e-6,
|
||||
"frame {frame}, buffer {} component {}: {}/255, expected {want}/255; all {:?}",
|
||||
1 + n / 4,
|
||||
n % 4,
|
||||
got * 255.0,
|
||||
read.iter().map(|v| v * 255.0).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,13 +219,15 @@ fn pack(
|
||||
lightmap: lightmap.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: [1, 1],
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
);
|
||||
)
|
||||
.expect("the pack runs");
|
||||
assert!(post.notes.is_empty(), "{:?}", post.notes);
|
||||
post
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ pub(super) struct PackTexture {
|
||||
pub(super) sample_type: wgpu::TextureSampleType,
|
||||
pub(super) blur: bool,
|
||||
pub(super) clamp: bool,
|
||||
/// Decoded from a PNG, the pack's or the game's (not a raw dump, not a generated texture).
|
||||
pub(super) png: bool,
|
||||
pub(super) image: Image,
|
||||
}
|
||||
|
||||
@@ -30,7 +32,7 @@ pub(super) fn upload(
|
||||
let features = device.features();
|
||||
let mut out = Vec::new();
|
||||
for t in textures {
|
||||
let (image, dimension, sample_type, blur, clamp) = match t.source {
|
||||
let (image, dimension, sample_type, blur, clamp, png) = match t.source {
|
||||
TextureSource::Image(texture) => {
|
||||
let (view, sample_type) = match create(device, queue, &texture, features) {
|
||||
Ok(created) => created,
|
||||
@@ -50,6 +52,7 @@ pub(super) fn upload(
|
||||
sample_type,
|
||||
texture.blur,
|
||||
texture.clamp,
|
||||
!texture.raw,
|
||||
)
|
||||
}
|
||||
TextureSource::Resource(location) => {
|
||||
@@ -66,7 +69,14 @@ pub(super) fn upload(
|
||||
continue;
|
||||
};
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
(image, wgpu::TextureViewDimension::D2, float, true, true)
|
||||
(
|
||||
image,
|
||||
wgpu::TextureViewDimension::D2,
|
||||
float,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
}
|
||||
};
|
||||
out.push(PackTexture {
|
||||
@@ -76,6 +86,7 @@ pub(super) fn upload(
|
||||
sample_type,
|
||||
blur,
|
||||
clamp,
|
||||
png,
|
||||
image,
|
||||
});
|
||||
}
|
||||
@@ -285,6 +296,7 @@ mod tests {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
blur: false,
|
||||
clamp: false,
|
||||
png: false,
|
||||
image: Image::Lightmap,
|
||||
}
|
||||
}
|
||||
@@ -297,6 +309,7 @@ mod tests {
|
||||
texels,
|
||||
blur: false,
|
||||
clamp: false,
|
||||
raw: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -718,6 +718,13 @@ fn components(name: &str) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// What `name` gives custom uniforms before the first frame: zeros, at its size, so their
|
||||
/// shapes check out as on a frame (#9086).
|
||||
pub fn zero(name: &str) -> Option<horizon_shaders::Val> {
|
||||
index(name)?;
|
||||
Some(horizon_shaders::Val::vector(&[0.0; 16][..components(name)]))
|
||||
}
|
||||
|
||||
/// The position of `name` in [`NAMES`].
|
||||
pub fn index(name: &str) -> Option<usize> {
|
||||
NAMES.iter().position(|n| *n == name)
|
||||
@@ -752,6 +759,22 @@ mod tests {
|
||||
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
|
||||
}
|
||||
|
||||
/// The load-time run of custom uniforms sees each built-in at its size: Iris 1.11.7 reads 2
|
||||
/// for the first line of our probes K and X (#9086) and 0 for the others.
|
||||
#[test]
|
||||
fn custom_uniforms_check_the_shapes_of_built_ins_before_the_first_frame() {
|
||||
let properties = horizon_shaders::Properties::parse(
|
||||
"uniform.vec3.a = skyColor * vec3(0.0, 0.0, 0.0) + vec3(2.0, 2.0, 2.0)\n\
|
||||
uniform.float.b = skyColor\n\
|
||||
uniform.float.c = gbufferModelView * 0.0 + 2.0\n",
|
||||
&horizon_shaders::Macros::standard(),
|
||||
);
|
||||
let mut custom = horizon_shaders::Custom::parse(&properties);
|
||||
custom.eval(0.0, &zero);
|
||||
let broken: Vec<&str> = custom.notes.iter().map(|n| &n[1..2]).collect();
|
||||
assert_eq!(broken, ["b", "c"], "{:?}", custom.notes);
|
||||
}
|
||||
|
||||
/// Iris's status uniforms as its documentation defines them (shaders.properties, "Status").
|
||||
#[test]
|
||||
fn status_uniforms_follow_iris() {
|
||||
@@ -1006,11 +1029,6 @@ mod initial_history_tests {
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn core_matrix_inputs_render_provider_values() {
|
||||
use horizon_shaders::{
|
||||
GbuffersInputs, Macros, Pack, Target, preprocess, translate_gbuffers,
|
||||
};
|
||||
use std::io::Write;
|
||||
use wgpu::util::DeviceExt;
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Core matrix GPU: {:?}", adapter.get_info());
|
||||
@@ -1070,137 +1088,7 @@ mod initial_history_tests {
|
||||
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n\
|
||||
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}}"
|
||||
);
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (path, source) in [
|
||||
("shaders/gbuffers_line.vsh", vertex.as_str()),
|
||||
(
|
||||
"shaders/gbuffers_line.fsh",
|
||||
"#version 330\nin vec3 result; out vec4 color; void main() { color = vec4(result, 1.0); }",
|
||||
),
|
||||
] {
|
||||
zip.start_file(path, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
}
|
||||
let pack = Pack::from_zip_bytes("matrix-probe.zip", zip.finish().unwrap().into_inner())
|
||||
.unwrap();
|
||||
let macros = Macros::standard();
|
||||
let program = translate_gbuffers(
|
||||
&preprocess(&pack, "gbuffers_line.vsh", ¯os),
|
||||
&preprocess(&pack, "gbuffers_line.fsh", ¯os),
|
||||
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", ¯os),
|
||||
&preprocess(&pack, "gbuffers_line.fsh", ¯os),
|
||||
Target::Buffers,
|
||||
&GbuffersInputs::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut bytes = vec![0; program.interface.uniform_size as usize];
|
||||
for uniform in &program.interface.uniforms {
|
||||
if let Some(i) = index(&uniform.name) {
|
||||
values.values[i].write(&uniform.ty, &mut bytes, uniform.offset as usize);
|
||||
}
|
||||
}
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &bytes,
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let module = |module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
})
|
||||
};
|
||||
let vs = module(program.vertex);
|
||||
let fs = module(program.fragment);
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniform.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |result| result.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let mapped = read.slice(..).get_mapped_range().unwrap();
|
||||
let pixel = mapped[..4].try_into().unwrap();
|
||||
drop(mapped);
|
||||
read.unmap();
|
||||
pixel
|
||||
}
|
||||
|
||||
fn int(u: &FrameUniforms, at: usize) -> i32 {
|
||||
match u.values[at] {
|
||||
Value::I(v) => v[0],
|
||||
|
||||
@@ -67,6 +67,17 @@ impl Readback {
|
||||
let Some(texture) = self.texture.clone() else {
|
||||
return;
|
||||
};
|
||||
self.draw_onto(device, encoder, &texture, surface);
|
||||
}
|
||||
|
||||
/// Draws `frame` onto the swapchain image (or offscreen target) `surface`.
|
||||
pub fn draw_onto(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &wgpu::Texture,
|
||||
surface: &wgpu::Texture,
|
||||
) {
|
||||
// The frame's passes write the swapchain through its non-sRGB view
|
||||
// (`view_formats`), so the copy goes there unconverted too.
|
||||
let format = surface.format().remove_srgb_suffix();
|
||||
@@ -74,7 +85,7 @@ impl Readback {
|
||||
format: Some(format),
|
||||
..Default::default()
|
||||
});
|
||||
self.blit(device, encoder, &texture, &target, format);
|
||||
self.blit(device, encoder, frame, &target, format);
|
||||
}
|
||||
|
||||
/// `frame` (a 16-bit frame) in an 8-bit texture of `format`, clamped to 0..1
|
||||
|
||||
@@ -92,7 +92,7 @@ impl<T> Resident<T> {
|
||||
self.items.iter_mut().map(|(_, v)| v)
|
||||
}
|
||||
/// Every section's key, value and bounds.
|
||||
#[cfg(debug_assertions)]
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
pub fn iter(&self) -> impl Iterator<Item = (SectionKey, &T, Option<[Vec3; 2]>)> {
|
||||
let items = self.items.iter().zip(&self.bounds);
|
||||
items.map(|((key, value), bounds)| (*key, value, *bounds))
|
||||
@@ -194,6 +194,12 @@ impl<T> Resident<T> {
|
||||
column.low = column.low.min(key.1);
|
||||
column.high = column.high.max(key.1);
|
||||
}
|
||||
/// Marks or unmarks the section at `key`, if it is resident.
|
||||
pub fn set_marked(&mut self, key: SectionKey, marked: bool) {
|
||||
if let Some(&slot) = self.slots.get(&key) {
|
||||
self.mark_slot(slot, marked);
|
||||
}
|
||||
}
|
||||
/// Marks or unmarks the section in `slot`, in its column's list too.
|
||||
fn mark_slot(&mut self, slot: u32, marked: bool) {
|
||||
let was = std::mem::replace(&mut self.marked[slot as usize], marked);
|
||||
@@ -657,7 +663,8 @@ mod tests {
|
||||
(!value.is_multiple_of(3)).then(|| [Vec3::splat((value % 5) as f32), Vec3::splat(16.0)])
|
||||
};
|
||||
// A deterministic mix of inserts, replacements and removals, marked at
|
||||
// random: a replacement can mark or unmark its section.
|
||||
// random: a replacement can mark or unmark its section, and so can a
|
||||
// mark set alone (`set_marked`), which leaves a key not resident alone.
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
for step in 0..4000u32 {
|
||||
seed ^= seed << 13;
|
||||
@@ -694,6 +701,12 @@ mod tests {
|
||||
assert_eq!(removed, expected);
|
||||
} else if (seed >> 12).is_multiple_of(3) {
|
||||
assert_eq!(r.remove(key), model.remove(&key).map(|(value, _)| value));
|
||||
} else if (seed >> 12).is_multiple_of(5) {
|
||||
let marked = (seed >> 20).is_multiple_of(2);
|
||||
r.set_marked(key, marked);
|
||||
if let Some(held) = model.get_mut(&key) {
|
||||
held.1 = marked;
|
||||
}
|
||||
} else {
|
||||
let marked = (seed >> 20).is_multiple_of(2);
|
||||
r.insert_marked(key, step, bounds(step), marked);
|
||||
|
||||
@@ -1050,7 +1050,9 @@ impl Item {
|
||||
Item::Horizon(H::Hdr) => g.hdr,
|
||||
Item::Horizon(H::BatteryMode) => g.battery,
|
||||
Item::Horizon(H::ShadowCache) => g.shadow_cache,
|
||||
Item::Horizon(H::TranslucentBatching) => g.translucent_batching,
|
||||
Item::Horizon(H::TranslucentBatching) => {
|
||||
g.translucent_batching && crate::graphics::translucent_batching_offered()
|
||||
}
|
||||
Item::Horizon(H::HdrSmoothGradients) => {
|
||||
g.hdr_smooth_gradients && crate::graphics::smooth_gradients_offered()
|
||||
}
|
||||
@@ -3164,13 +3166,13 @@ mod tests {
|
||||
assert_eq!(page, Some(Page::Advanced));
|
||||
let mut s = Settings::default();
|
||||
s.horizon.preset = Preset::Balanced;
|
||||
assert_eq!(item.switch(&s), Some(true));
|
||||
let mut ui = GraphicsUi::default();
|
||||
// Where the device can't, the saved On reads Off, and neither a click nor Enter
|
||||
// after it does anything: no sound, no change, no save.
|
||||
crate::graphics::offer_translucent_batching(false);
|
||||
assert!(item.blocked(&s).is_some());
|
||||
assert_eq!(item.label(&s).plain(), "Translucent Batching: Off");
|
||||
assert_eq!(item.switch(&s), Some(false));
|
||||
let r = row(&mut ui, &s, item);
|
||||
let point = (r[0] + 4.0, r[1] + 4.0);
|
||||
assert!(press(&mut ui, &mut s, point).is_empty());
|
||||
@@ -3181,6 +3183,7 @@ mod tests {
|
||||
// Where it can, a click switches it and keeps the preset.
|
||||
crate::graphics::offer_translucent_batching(true);
|
||||
assert_eq!(item.label(&s).plain(), "Translucent Batching: On");
|
||||
assert_eq!(item.switch(&s), Some(true));
|
||||
assert_eq!(
|
||||
press(&mut ui, &mut s, point),
|
||||
[Action::ButtonClick, Action::Changed]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -272,13 +272,16 @@ impl Opt {
|
||||
Opt::PadAdaptiveTriggers => "horizon.options.adaptive_triggers",
|
||||
Opt::PadLightbar => "horizon.options.lightbar",
|
||||
Opt::PadLookSensitivity => "horizon.options.look_sensitivity",
|
||||
Opt::PadPointerSpeed => "horizon.options.pointer_speed",
|
||||
Opt::PadScrollSpeed => "horizon.options.scroll_speed",
|
||||
Opt::PadRepeatDelay => "horizon.options.repeat_delay",
|
||||
Opt::PadRepeatInterval => "horizon.options.repeat_interval",
|
||||
Opt::PadInvertX => "horizon.options.invert_x",
|
||||
Opt::PadInvertY => "horizon.options.invert_y",
|
||||
Opt::PadToggleSneak => "horizon.options.sneak_button",
|
||||
Opt::PadMoveDeadzone => "horizon.options.move_deadzone",
|
||||
Opt::PadLookDeadzone => "horizon.options.look_deadzone",
|
||||
Opt::PadGlyphs => "horizon.options.button_glyphs",
|
||||
Opt::PadMenuNav => "horizon.options.menu_navigation",
|
||||
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
|
||||
Opt::ModelPart(i) => [
|
||||
"options.modelPart.cape",
|
||||
@@ -352,6 +355,10 @@ impl Opt {
|
||||
Opt::ChatHeightUnfocused => Kind::Slider(s.chat_height_unfocused),
|
||||
Opt::ChatLineSpacing => Kind::Slider(s.chat_line_spacing),
|
||||
Opt::PadLookSensitivity => Kind::Slider(s.controller.look_sensitivity),
|
||||
Opt::PadPointerSpeed => unit(s.controller.pointer_speed, 0.25, 3.0),
|
||||
Opt::PadScrollSpeed => unit(s.controller.scroll_speed, 1.0, 36.0),
|
||||
Opt::PadRepeatDelay => unit(s.controller.repeat_delay, 0.1, 1.0),
|
||||
Opt::PadRepeatInterval => unit(s.controller.repeat_interval, 0.03, 0.3),
|
||||
Opt::PadMoveDeadzone => unit(s.controller.move_deadzone, 0.0, 0.5),
|
||||
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
|
||||
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
|
||||
@@ -610,6 +617,23 @@ impl Opt {
|
||||
),
|
||||
),
|
||||
Opt::PadLookSensitivity => percent(c, 2.0 * s.controller.look_sensitivity),
|
||||
Opt::PadPointerSpeed => percent(c, s.controller.pointer_speed),
|
||||
Opt::PadScrollSpeed => percent(c, s.controller.scroll_speed / 12.0),
|
||||
Opt::PadRepeatDelay | Opt::PadRepeatInterval => generic(
|
||||
c,
|
||||
Text::tr(
|
||||
"horizon.options.milliseconds",
|
||||
&[&format!(
|
||||
"{:.0}",
|
||||
1000.0
|
||||
* if self == Opt::PadRepeatDelay {
|
||||
s.controller.repeat_delay
|
||||
} else {
|
||||
s.controller.repeat_interval
|
||||
}
|
||||
)],
|
||||
),
|
||||
),
|
||||
Opt::PadMoveDeadzone | Opt::PadLookDeadzone => {
|
||||
let v = if self == Opt::PadMoveDeadzone {
|
||||
s.controller.move_deadzone
|
||||
@@ -796,10 +820,6 @@ impl Opt {
|
||||
GlyphStyle::PlayStation => "horizon.options.glyphs.playstation",
|
||||
GlyphStyle::Nintendo => "horizon.options.glyphs.nintendo",
|
||||
}),
|
||||
Opt::PadMenuNav => tr(match s.controller.menu_nav {
|
||||
gamepad::MenuNav::Focus => "horizon.options.menu_focus",
|
||||
gamepad::MenuNav::Pointer => "horizon.options.menu_pointer",
|
||||
}),
|
||||
Opt::PadKeyboard => tr(match s.controller.keyboard {
|
||||
gamepad::TextKeyboard::Auto => "horizon.options.keyboard.auto",
|
||||
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
|
||||
@@ -871,6 +891,10 @@ impl Opt {
|
||||
Opt::ChatHeightUnfocused => s.chat_height_unfocused = v,
|
||||
Opt::ChatLineSpacing => s.chat_line_spacing = v,
|
||||
Opt::PadLookSensitivity => s.controller.look_sensitivity = v,
|
||||
Opt::PadPointerSpeed => s.controller.pointer_speed = step(25.0, 300.0) / 100.0,
|
||||
Opt::PadScrollSpeed => s.controller.scroll_speed = step(1.0, 36.0),
|
||||
Opt::PadRepeatDelay => s.controller.repeat_delay = step(100.0, 1000.0) / 1000.0,
|
||||
Opt::PadRepeatInterval => s.controller.repeat_interval = step(30.0, 300.0) / 1000.0,
|
||||
Opt::PadMoveDeadzone => s.controller.move_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
|
||||
@@ -1094,12 +1118,6 @@ impl Opt {
|
||||
.unwrap_or(0) as u8;
|
||||
s.controller.glyphs = GlyphStyle::ALL[usize::from(next(i, 4))];
|
||||
}
|
||||
Opt::PadMenuNav => {
|
||||
s.controller.menu_nav = match s.controller.menu_nav {
|
||||
gamepad::MenuNav::Focus => gamepad::MenuNav::Pointer,
|
||||
gamepad::MenuNav::Pointer => gamepad::MenuNav::Focus,
|
||||
}
|
||||
}
|
||||
Opt::PadKeyboard => {
|
||||
let all = gamepad::TextKeyboard::ALL;
|
||||
let i = all
|
||||
@@ -1344,6 +1362,14 @@ enum WKind {
|
||||
value: f32,
|
||||
name: String,
|
||||
},
|
||||
/// A float or int option `.option` of the Horizon pack `s.shader_packs[.pack]`: where its
|
||||
/// value sits between its minimum and maximum.
|
||||
PackSlider {
|
||||
label: Text,
|
||||
value: f32,
|
||||
pack: usize,
|
||||
option: usize,
|
||||
},
|
||||
Text {
|
||||
text: Text,
|
||||
color: u32,
|
||||
@@ -1372,6 +1398,8 @@ struct Widget {
|
||||
enum FocusId {
|
||||
Search,
|
||||
Slider(Opt),
|
||||
/// A Horizon pack's option slider: (pack, option).
|
||||
PackSlider(usize, usize),
|
||||
Cycle(Opt),
|
||||
Act(Act),
|
||||
Lang(String),
|
||||
@@ -1381,6 +1409,7 @@ enum FocusId {
|
||||
fn focus_id(i: usize, wd: &Widget) -> FocusId {
|
||||
match &wd.kind {
|
||||
WKind::Slider { opt, .. } => FocusId::Slider(*opt),
|
||||
WKind::PackSlider { pack, option, .. } => FocusId::PackSlider(*pack, *option),
|
||||
WKind::Button {
|
||||
act: Act::Cycle(opt),
|
||||
..
|
||||
@@ -1394,6 +1423,9 @@ fn focus_id(i: usize, wd: &Widget) -> FocusId {
|
||||
fn matches_focus(id: &FocusId, i: usize, wd: &Widget) -> bool {
|
||||
match (id, &wd.kind) {
|
||||
(FocusId::Slider(a), WKind::Slider { opt, .. }) => a == opt,
|
||||
(FocusId::PackSlider(p, o), WKind::PackSlider { pack, option, .. }) => {
|
||||
(p, o) == (pack, option)
|
||||
}
|
||||
(
|
||||
FocusId::Cycle(a),
|
||||
WKind::Button {
|
||||
@@ -1512,6 +1544,9 @@ pub struct OptionsUi {
|
||||
/// The shader pack option whose slider is held, and the handle's own position: it follows
|
||||
/// the pointer while the option takes the nearest of its values.
|
||||
shader_dragging: Option<(String, f32)>,
|
||||
/// The same for a Horizon pack's option: (pack, option, the handle's position, whether its
|
||||
/// value changed since the press). The pack saves its values, and reloads, on release.
|
||||
pack_dragging: Option<(usize, usize, f32, bool)>,
|
||||
/// Shift is held during the click (it resets a shader pack option to its default).
|
||||
pub shift: bool,
|
||||
/// The pressed slider's own position (`AbstractSliderButton.value`) and
|
||||
@@ -1602,6 +1637,7 @@ impl Default for OptionsUi {
|
||||
scroll: 0.0,
|
||||
dragging: None,
|
||||
shader_dragging: None,
|
||||
pack_dragging: None,
|
||||
shift: false,
|
||||
slider_value: None,
|
||||
scrollbar_drag_y: None,
|
||||
@@ -1746,7 +1782,23 @@ fn native_pack_option_rows(s: &Settings, i: usize, at: u8) -> Vec<Row> {
|
||||
let value = crate::shader_pack_list::show_value(&option.kind, pack.values[o]);
|
||||
let label =
|
||||
Text::option_name_value(Text::literal(option.name.clone()), Text::literal(value));
|
||||
button(0.0, 0.0, width, label, Act::NativePackOption(i, o))
|
||||
match pack.slider_position(o) {
|
||||
Some(value) => Widget {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
w: width,
|
||||
h: 20.0,
|
||||
kind: WKind::PackSlider {
|
||||
label,
|
||||
value,
|
||||
pack: i,
|
||||
option: o,
|
||||
},
|
||||
list: false,
|
||||
row: 0,
|
||||
},
|
||||
None => button(0.0, 0.0, width, label, Act::NativePackOption(i, o)),
|
||||
}
|
||||
}
|
||||
PageItem::Page(p) if p < pack.pages.len() => {
|
||||
let label = Text::literal(format!("{}...", pack.pages[p].name));
|
||||
@@ -1845,7 +1897,10 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
|
||||
WKind::Button {
|
||||
act: Act::NativePackOption(pack, option),
|
||||
..
|
||||
} => Some(&s.shader_packs.get(*pack)?.options.get(*option)?.description),
|
||||
}
|
||||
| WKind::PackSlider { pack, option, .. } => {
|
||||
Some(&s.shader_packs.get(*pack)?.options.get(*option)?.description)
|
||||
}
|
||||
WKind::Button {
|
||||
act: Act::Push(Page::NativePackOptions(pack, at)),
|
||||
..
|
||||
@@ -2043,6 +2098,13 @@ fn pairs(opts: &[Opt]) -> Vec<Row> {
|
||||
.collect()
|
||||
}
|
||||
impl OptionsUi {
|
||||
/// No widget keeps the highlight a click gave it; a focus the keyboard
|
||||
/// moved stays.
|
||||
pub fn clear_click_focus(&mut self) {
|
||||
if !self.focus_from_keyboard {
|
||||
self.focus = None;
|
||||
}
|
||||
}
|
||||
/// A switch of `horizon-extras.txt` as the Extras page draws it: "Caption: On".
|
||||
fn extra_button(&self, s: &Settings, item: crate::extras::Item) -> Widget {
|
||||
let label = Text::option_name_value(
|
||||
@@ -2163,6 +2225,18 @@ impl OptionsUi {
|
||||
}
|
||||
events
|
||||
}
|
||||
/// Steps a Horizon pack's option slider one step forward or backward, and saves it.
|
||||
fn step_pack_slider(s: &mut Settings, pack: usize, option: usize, forward: bool) -> Vec<Event> {
|
||||
let stepped = s
|
||||
.shader_packs
|
||||
.get_mut(pack)
|
||||
.is_some_and(|entry| entry.nudge_option(option, !forward));
|
||||
if stepped {
|
||||
vec![Event::NativePackOptions(pack)]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
/// Open a page whose Done/Escape returns directly to the calling screen.
|
||||
pub fn open_at(&mut self, in_world: bool, page: Page) {
|
||||
self.stack.clear();
|
||||
@@ -2689,8 +2763,11 @@ impl OptionsUi {
|
||||
Opt::PadMoveDeadzone,
|
||||
Opt::PadLookDeadzone,
|
||||
Opt::PadGlyphs,
|
||||
Opt::PadMenuNav,
|
||||
Opt::PadKeyboard,
|
||||
Opt::PadPointerSpeed,
|
||||
Opt::PadScrollSpeed,
|
||||
Opt::PadRepeatDelay,
|
||||
Opt::PadRepeatInterval,
|
||||
]));
|
||||
rows
|
||||
}
|
||||
@@ -3716,6 +3793,34 @@ impl OptionsUi {
|
||||
dragging,
|
||||
);
|
||||
}
|
||||
WKind::PackSlider {
|
||||
label,
|
||||
value,
|
||||
pack,
|
||||
option,
|
||||
} => {
|
||||
let held = self
|
||||
.pack_dragging
|
||||
.filter(|&(p, o, ..)| (p, o) == (*pack, *option));
|
||||
let dragging = held.is_some();
|
||||
ui::slider_ext(
|
||||
batch,
|
||||
atlas,
|
||||
wd.x,
|
||||
wd.y,
|
||||
wd.w,
|
||||
label,
|
||||
held.map_or(*value, |h| h.2),
|
||||
hover || dragging,
|
||||
is_focused,
|
||||
self.can_change_value && is_focused,
|
||||
true,
|
||||
dragging,
|
||||
);
|
||||
crate::widgets::last_narratable(|n| {
|
||||
narrate_widget(n, s, wd, hover, list_key, framebuffer);
|
||||
});
|
||||
}
|
||||
WKind::Text {
|
||||
text,
|
||||
color,
|
||||
@@ -4015,6 +4120,16 @@ impl OptionsUi {
|
||||
*at = ui::slider_value(wd.x, wd.w, x);
|
||||
options.set_position(name, *at);
|
||||
}
|
||||
if let Some((pack, option, at, changed)) = &mut self.pack_dragging
|
||||
&& let Some(wd) = widgets.iter().find(|wd| {
|
||||
matches!(wd.kind, WKind::PackSlider { pack: p, option: o, .. }
|
||||
if (p, o) == (*pack, *option))
|
||||
})
|
||||
&& let Some(entry) = s.shader_packs.get_mut(*pack)
|
||||
{
|
||||
*at = ui::slider_value(wd.x, wd.w, x);
|
||||
*changed |= entry.set_slider_position(*option, *at);
|
||||
}
|
||||
if let Some(opt) = self.dragging
|
||||
&& let Some(wd) = widgets
|
||||
.iter()
|
||||
@@ -4251,6 +4366,21 @@ impl OptionsUi {
|
||||
}
|
||||
self.shader_dragging = Some((name, at));
|
||||
}
|
||||
WKind::PackSlider { pack, option, .. } => {
|
||||
self.can_change_value = true;
|
||||
let Some(entry) = s.shader_packs.get_mut(pack) else {
|
||||
break;
|
||||
};
|
||||
if self.shift {
|
||||
// Its default, as a shift-click on a pack's button does; no drag follows.
|
||||
entry.step_option(option, false, true);
|
||||
events.extend([Event::ButtonClick, Event::NativePackOptions(pack)]);
|
||||
break;
|
||||
}
|
||||
let at = ui::slider_value(wd.x, wd.w, x);
|
||||
let changed = entry.set_slider_position(option, at);
|
||||
self.pack_dragging = Some((pack, option, at, changed));
|
||||
}
|
||||
WKind::Slider { opt, .. } => {
|
||||
self.can_change_value = true;
|
||||
self.dragging = Some(opt);
|
||||
@@ -4287,12 +4417,19 @@ impl OptionsUi {
|
||||
}
|
||||
// AbstractSliderButton suppresses the press sound and plays once on
|
||||
// release, even if the pointer has left the widget or its value stayed.
|
||||
let slider = self.dragging.take().is_some() | self.shader_dragging.take().is_some();
|
||||
let pack = self.pack_dragging.take();
|
||||
let slider =
|
||||
self.dragging.take().is_some() | self.shader_dragging.take().is_some() | pack.is_some();
|
||||
let graphics_slider = self.graphics.release();
|
||||
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
|
||||
let split_drop = self.split_setup.release();
|
||||
if slider || graphics_slider || calibration_slider {
|
||||
vec![Event::ButtonClick, Event::Changed]
|
||||
let mut events = vec![Event::ButtonClick, Event::Changed];
|
||||
// A pack's option is saved, and the pack rebuilt, once per drag.
|
||||
if let Some((pack, _, _, true)) = pack {
|
||||
events.push(Event::NativePackOptions(pack));
|
||||
}
|
||||
events
|
||||
} else if split_drop {
|
||||
vec![Event::ButtonClick]
|
||||
} else {
|
||||
@@ -4390,6 +4527,10 @@ impl OptionsUi {
|
||||
.map_or(150.0, |wd| wd.w);
|
||||
return self.step_slider_option(s, opt, amount > 0.0, width);
|
||||
}
|
||||
Some(FocusId::PackSlider(pack, option)) => {
|
||||
let (pack, option) = (*pack, *option);
|
||||
return Self::step_pack_slider(s, pack, option, amount > 0.0);
|
||||
}
|
||||
Some(FocusId::Cycle(opt)) => {
|
||||
let opt = *opt;
|
||||
let mut events = vec![Event::ButtonClick];
|
||||
@@ -4855,6 +4996,7 @@ impl OptionsUi {
|
||||
match wd.kind {
|
||||
WKind::Button { active: true, .. }
|
||||
| WKind::Slider { .. }
|
||||
| WKind::PackSlider { .. }
|
||||
| WKind::LanguageRow { .. } => {
|
||||
focusables.push((focus_id(i, wd), [wd.x, wd.y, wd.w, wd.h], wd.list));
|
||||
}
|
||||
@@ -4890,7 +5032,7 @@ impl OptionsUi {
|
||||
let (next_id, rect, in_list) = &focusables[next_idx];
|
||||
self.focus = Some(next_id.clone());
|
||||
self.focus_from_keyboard = true;
|
||||
self.can_change_value = matches!(next_id, FocusId::Slider(_));
|
||||
self.can_change_value = matches!(next_id, FocusId::Slider(_) | FocusId::PackSlider(..));
|
||||
if *in_list {
|
||||
if rect[1] < top {
|
||||
self.scroll -= (top - rect[1]) + 4.0;
|
||||
@@ -4915,6 +5057,11 @@ impl OptionsUi {
|
||||
.map_or(150.0, |wd| wd.w);
|
||||
return self.step_slider_option(s, opt, forward, width);
|
||||
}
|
||||
if let Some(FocusId::PackSlider(pack, option)) = self.focus
|
||||
&& self.can_change_value
|
||||
{
|
||||
return Self::step_pack_slider(s, pack, option, forward);
|
||||
}
|
||||
// Otherwise, navigate horizontally using 2D focus stepping
|
||||
if let Some(fid) = &self.focus {
|
||||
if let Some(cur_pos) = focusables.iter().position(|(id, ..)| id == fid) {
|
||||
@@ -4992,7 +5139,7 @@ impl OptionsUi {
|
||||
// 4. Enter / Space selection keys
|
||||
if matches!(key, K::Enter | K::NumpadEnter | K::Space) {
|
||||
match &self.focus {
|
||||
Some(FocusId::Slider(_)) => {
|
||||
Some(FocusId::Slider(_) | FocusId::PackSlider(..)) => {
|
||||
self.can_change_value = !self.can_change_value;
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -5505,11 +5652,9 @@ mod tests {
|
||||
id: "a".into(),
|
||||
name: "A".into(),
|
||||
description: String::new(),
|
||||
kind: horizon_shader_api::OptionKind::Float {
|
||||
default: 1.0,
|
||||
min: 0.0,
|
||||
max: 2.0,
|
||||
step: 0.5,
|
||||
kind: horizon_shader_api::OptionKind::Choice {
|
||||
values: vec!["x".into(), "y".into(), "z".into()],
|
||||
default: 1,
|
||||
},
|
||||
}],
|
||||
values: vec![1.0],
|
||||
@@ -5550,10 +5695,10 @@ mod tests {
|
||||
let (x, y) = find(&mut ui, &s, &Act::NativePackOption(0, 0));
|
||||
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
assert!(events.contains(&Event::NativePackOptions(0)));
|
||||
assert_eq!(s.shader_packs[0].values, [1.5]);
|
||||
assert_eq!(s.shader_packs[0].values, [2.0]);
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
|
||||
assert_eq!(s.shader_packs[0].values, [0.5]);
|
||||
assert_eq!(s.shader_packs[0].values, [0.0]);
|
||||
// Shift-click: the option's default.
|
||||
ui.shift = true;
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
@@ -5567,6 +5712,116 @@ mod tests {
|
||||
assert_eq!(ui.page(), Page::NativePackOptions(0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_horizon_packs_float_and_int_options_are_sliders_saved_on_release() {
|
||||
use horizon_shader_api::{OptionKind, PackOption};
|
||||
use winit::keyboard::KeyCode;
|
||||
let atlas = atlas();
|
||||
let size = (960.0, 540.0);
|
||||
let option = |id: &str, kind| PackOption {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
description: String::new(),
|
||||
kind,
|
||||
};
|
||||
let pack = crate::shader_pack_list::Entry {
|
||||
file: "p".into(),
|
||||
title: "P".into(),
|
||||
kind: crate::shader_pack_list::Kind::Horizon,
|
||||
options: vec![
|
||||
option(
|
||||
"a",
|
||||
OptionKind::Float {
|
||||
default: 1.0,
|
||||
min: 0.0,
|
||||
max: 2.0,
|
||||
step: 0.5,
|
||||
},
|
||||
),
|
||||
option(
|
||||
"b",
|
||||
OptionKind::Int {
|
||||
default: 2,
|
||||
min: 0,
|
||||
max: 4,
|
||||
},
|
||||
),
|
||||
option("c", OptionKind::Bool { default: true }),
|
||||
],
|
||||
values: vec![1.0, 2.0, 1.0],
|
||||
pages: Vec::new(),
|
||||
};
|
||||
let mut s = Settings {
|
||||
shader_packs: vec![pack],
|
||||
..Settings::default()
|
||||
};
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::NativePackOptions(0, 0));
|
||||
// A slider's track, from its left end to its right: (x of 0, x of 1, y).
|
||||
let track = |ui: &mut OptionsUi, s: &Settings, want: usize| {
|
||||
let (widgets, ..) = ui.layout(s, size.0, size.1);
|
||||
widgets
|
||||
.into_iter()
|
||||
.find(|w| matches!(w.kind, WKind::PackSlider { option, .. } if option == want))
|
||||
.map(|w| (w.x + 4.0, w.x + w.w - 4.0, w.y + w.h / 2.0))
|
||||
};
|
||||
assert!(track(&mut ui, &s, 2).is_none(), "a bool stays a button");
|
||||
let (left, right, y) = track(&mut ui, &s, 0).expect("the float's slider");
|
||||
let at = |f: f32| left + f * (right - left);
|
||||
// Pressed a fifth of the way: 0.4 of 2, the nearest step 0.5. Nothing is saved yet.
|
||||
let events = ui.click(&mut s, &atlas, at(0.2), y, size, [854, 480], false, false);
|
||||
assert!(!events.contains(&Event::NativePackOptions(0)));
|
||||
assert_eq!(s.shader_packs[0].values[0], 0.5);
|
||||
// Dragged past the right end: the maximum.
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
right + 30.0,
|
||||
y,
|
||||
size,
|
||||
[854, 480],
|
||||
true,
|
||||
false,
|
||||
);
|
||||
assert_eq!(s.shader_packs[0].values[0], 2.0);
|
||||
// Released: saved once, which reloads the pack.
|
||||
let events = ui.release();
|
||||
let saves = events.iter().filter(|e| **e == Event::NativePackOptions(0));
|
||||
assert_eq!(saves.count(), 1);
|
||||
// A press and release that leave the value as it was save nothing.
|
||||
ui.click(&mut s, &atlas, right, y, size, [854, 480], false, false);
|
||||
assert!(!ui.release().contains(&Event::NativePackOptions(0)));
|
||||
// Shift-click: the default at once.
|
||||
ui.shift = true;
|
||||
let events = ui.click(&mut s, &atlas, at(0.9), y, size, [854, 480], false, false);
|
||||
ui.shift = false;
|
||||
assert!(events.contains(&Event::NativePackOptions(0)));
|
||||
assert_eq!(s.shader_packs[0].values[0], 1.0);
|
||||
assert!(!ui.release().contains(&Event::NativePackOptions(0)));
|
||||
|
||||
// The int takes whole numbers: 0.6 of 0..4 is 2.4, so 2, unchanged; 0.7 is 3.
|
||||
let (left, right, y) = track(&mut ui, &s, 1).expect("the int's slider");
|
||||
let at = |f: f32| left + f * (right - left);
|
||||
ui.click(&mut s, &atlas, at(0.6), y, size, [854, 480], false, false);
|
||||
assert_eq!(s.shader_packs[0].values[1], 2.0);
|
||||
assert!(!ui.release().contains(&Event::NativePackOptions(0)));
|
||||
ui.click(&mut s, &atlas, at(0.7), y, size, [854, 480], false, false);
|
||||
assert_eq!(s.shader_packs[0].values[1], 3.0);
|
||||
assert!(ui.release().contains(&Event::NativePackOptions(0)));
|
||||
|
||||
// From the keyboard: the arrows step a focused slider, saving each step, up to its end.
|
||||
ui.focus = Some(FocusId::PackSlider(0, 1));
|
||||
ui.focus_from_keyboard = true;
|
||||
ui.can_change_value = true;
|
||||
let events = ui.key(&mut s, &atlas, KeyCode::ArrowRight, None);
|
||||
assert_eq!(events, [Event::NativePackOptions(0)]);
|
||||
assert_eq!(s.shader_packs[0].values[1], 4.0);
|
||||
assert!(ui.key(&mut s, &atlas, KeyCode::ArrowRight, None).is_empty());
|
||||
assert_eq!(s.shader_packs[0].values[1], 4.0);
|
||||
ui.key(&mut s, &atlas, KeyCode::ArrowLeft, None);
|
||||
assert_eq!(s.shader_packs[0].values[1], 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_reload_error_blocks_options_until_dismissed() {
|
||||
use winit::keyboard::KeyCode;
|
||||
@@ -8039,7 +8294,7 @@ mod tests {
|
||||
|
||||
// Also verify Act::Extra on Page::Extras
|
||||
ui.show(Page::Extras);
|
||||
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
|
||||
let (widgets, top, _, _) = ui.layout(&s, size.0, size.1);
|
||||
if let Some(extra_btn) = widgets.iter().find(|wd| {
|
||||
matches!(
|
||||
wd.kind,
|
||||
@@ -8050,6 +8305,22 @@ mod tests {
|
||||
}
|
||||
)
|
||||
}) {
|
||||
// Extras has enough rows to scroll: click the cycle button inside
|
||||
// the list's viewport, as a player would after scrolling to it.
|
||||
ui.scroll = (extra_btn.y - top).max(0.0);
|
||||
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
|
||||
let extra_btn = widgets
|
||||
.iter()
|
||||
.find(|wd| {
|
||||
matches!(
|
||||
wd.kind,
|
||||
WKind::Button {
|
||||
act: Act::Extra(crate::extras::Item::CosmeticsOthers),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let (ex, ey) = (extra_btn.x + 1.0, extra_btn.y + 1.0);
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
assert_eq!(s.extras.cosmetics_others, Others::Shown);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,67 @@
|
||||
//! The controller's menu cursor in inventories: a crosshair whose centre is
|
||||
//! the point it clicks. Horizon's own art (pad_cursor.png).
|
||||
|
||||
/// The atlas sprite.
|
||||
pub const SPRITE: &str = "horizon/pad_cursor";
|
||||
/// The texture's size in texels, and the size it's drawn at in GUI pixels
|
||||
/// (an item's).
|
||||
pub const SIZE: u32 = 32;
|
||||
pub const DRAWN: f32 = 16.0;
|
||||
|
||||
const PNG: &[u8] = include_bytes!("pad_cursor.png");
|
||||
|
||||
/// The texture as `SIZE` square RGBA texels.
|
||||
pub fn pixels() -> Vec<u8> {
|
||||
decode(PNG).expect("pad_cursor.png is a 32x32 PNG")
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
let info = reader.info();
|
||||
if (info.width, info.height) != (SIZE, SIZE) {
|
||||
return None;
|
||||
}
|
||||
let mut buf = vec![0; reader.output_buffer_size()?];
|
||||
let frame = reader.next_frame(&mut buf).ok()?;
|
||||
let channels = frame.color_type.samples();
|
||||
Some(
|
||||
buf.chunks(channels)
|
||||
.take((SIZE * SIZE) as usize)
|
||||
.flat_map(|p| match channels {
|
||||
4 => [p[0], p[1], p[2], p[3]],
|
||||
3 => [p[0], p[1], p[2], 255],
|
||||
2 => [p[0], p[0], p[0], p[1]],
|
||||
_ => [p[0], p[0], p[0], 255],
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Where to draw the cursor (top-left, GUI pixels) so its centre lands on
|
||||
/// the GUI point `at`.
|
||||
pub fn origin(at: (f32, f32)) -> (f32, f32) {
|
||||
(at.0 - DRAWN / 2.0, at.1 - DRAWN / 2.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_cursor_is_a_crosshair_centred_on_its_point() {
|
||||
let px = pixels();
|
||||
assert_eq!(px.len(), (SIZE * SIZE * 4) as usize);
|
||||
let alpha = |x: u32, y: u32| px[((y * SIZE + x) * 4 + 3) as usize];
|
||||
let middle = (SIZE / 2 - 1)..=(SIZE / 2);
|
||||
assert!(
|
||||
middle
|
||||
.clone()
|
||||
.any(|x| middle.clone().any(|y| alpha(x, y) > 0)),
|
||||
"the clicked point sits on the crosshair"
|
||||
);
|
||||
assert_eq!(alpha(0, 0), 0, "corners are clear: a plus, not a square");
|
||||
assert_eq!(origin((100.0, 50.0)), (92.0, 42.0));
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ use std::{
|
||||
|
||||
pub use horizon_client_core::particle_type::ParticleType;
|
||||
pub use horizon_items::firework::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape};
|
||||
mod native;
|
||||
pub use native::{ParticleController, ParticleUpdate};
|
||||
|
||||
/// `ParticleOptions` with the parameters of the option classes used here.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -212,6 +214,9 @@ enum Light {
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Behaviour {
|
||||
Base,
|
||||
Controlled {
|
||||
translucent: bool,
|
||||
},
|
||||
Mod {
|
||||
size: [f32; 2],
|
||||
alpha: [f32; 2],
|
||||
@@ -505,6 +510,7 @@ pub struct Particle {
|
||||
pre_tick: bool,
|
||||
/// `Particle.random`: the particle's own generator (`RandomSource.create()`).
|
||||
random: LegacyRandom,
|
||||
controller: Option<Box<dyn native::Controller>>,
|
||||
}
|
||||
fn aabb_at(p: [f64; 3], w: f32, h: f32) -> Aabb {
|
||||
let w = f64::from(w) / 2.0;
|
||||
@@ -841,8 +847,37 @@ impl Particle {
|
||||
pre_tick: false,
|
||||
frame_ticks: 0,
|
||||
random: LegacyRandom::new(0),
|
||||
controller: None,
|
||||
}
|
||||
}
|
||||
/// The normal age/removal test and previous pose, without vanilla motion.
|
||||
/// Returns false when this tick expires the particle.
|
||||
pub fn advance_age(&mut self) -> bool {
|
||||
self.previous = self.position;
|
||||
self.o_roll = self.roll;
|
||||
let alive = self.age < self.lifetime;
|
||||
self.age = self.age.saturating_add(1);
|
||||
self.removed |= !alive;
|
||||
alive
|
||||
}
|
||||
pub fn remove(&mut self) {
|
||||
self.removed = true;
|
||||
}
|
||||
/// Hide geometry while retaining the particle's lifetime and controller.
|
||||
pub fn set_visible(&mut self, visible: bool) {
|
||||
self.no_render = !visible;
|
||||
}
|
||||
/// The particle's own random, distinct from the engine and level generators.
|
||||
pub fn random(&mut self) -> &mut LegacyRandom {
|
||||
&mut self.random
|
||||
}
|
||||
pub fn set_full_bright(&mut self, full_bright: bool) {
|
||||
self.light = if full_bright {
|
||||
Light::FullBright
|
||||
} else {
|
||||
Light::World
|
||||
};
|
||||
}
|
||||
/// `Particle(level, x, y, z, xa, ya, za)`: jittered, normalized initial velocity.
|
||||
fn moving(random: &mut LegacyRandom, ty: ParticleType, p: [f64; 3], v: [f64; 3]) -> Self {
|
||||
let mut particle = Self::base_moving(random, ty, p, v);
|
||||
@@ -1420,6 +1455,10 @@ impl ParticleEngine {
|
||||
atlas: &HashMap<String, Rect>,
|
||||
translucent: &HashSet<String>,
|
||||
) {
|
||||
// Controllers' numeric sprite-set handles belong to this resource
|
||||
// generation. Discard them rather than selecting another set after reload.
|
||||
self.particles.retain(|p| p.controller.is_none());
|
||||
self.pending.retain(|p| p.controller.is_none());
|
||||
self.atlas = atlas
|
||||
.iter()
|
||||
.map(|(name, &rect)| (name.clone(), (rect, translucent.contains(name))))
|
||||
@@ -1584,6 +1623,45 @@ impl ParticleEngine {
|
||||
pub fn push(&mut self, particle: Particle) {
|
||||
self.pending.push(particle);
|
||||
}
|
||||
/// Create a named billboard with custom compiled behavior and no vanilla
|
||||
/// motion. Like `add`, respects camera distance and the Particles setting.
|
||||
/// Configure its public fields, then `push` it to the ordinary engine queue.
|
||||
/// Missing sprite sets return None. The constructor has the ordinary
|
||||
/// stationary SingleQuadParticle random size/lifetime and no velocity draws.
|
||||
pub fn controlled<C: ParticleController>(
|
||||
&mut self,
|
||||
name: &str,
|
||||
position: [f64; 3],
|
||||
translucent: bool,
|
||||
controller: C,
|
||||
) -> Option<Particle> {
|
||||
let minimal = self.calculate_particle_level(false) == 2;
|
||||
let distance: f64 = position
|
||||
.iter()
|
||||
.zip(self.camera)
|
||||
.map(|(p, c)| (p - c).powi(2))
|
||||
.sum();
|
||||
if minimal || !distance.is_finite() || distance > 1024.0 {
|
||||
return None;
|
||||
}
|
||||
let (set, _) = *self.mod_sets.get(name)?;
|
||||
let mut random = LegacyRandom::new(self.seeds.next_long());
|
||||
let mut particle = Particle::at(&mut random, ParticleType::Smoke, position);
|
||||
particle.random = random;
|
||||
particle.set = Some(set);
|
||||
particle.sprite = Sprite::Index(*self.set_sprites.get(set as usize)?.first()?);
|
||||
particle.behaviour = Behaviour::Controlled { translucent };
|
||||
particle.controller = Some(Box::new(controller));
|
||||
Some(particle)
|
||||
}
|
||||
/// Choose a frame from this particle's declared sprite set.
|
||||
pub fn set_sprite_frame(&self, particle: &mut Particle, frame: usize) {
|
||||
if let Some(sprites) = particle.set.and_then(|s| self.set_sprites.get(s as usize))
|
||||
&& !sprites.is_empty()
|
||||
{
|
||||
particle.sprite = Sprite::Index(sprites[frame.min(sprites.len() - 1)]);
|
||||
}
|
||||
}
|
||||
/// Random generator shared by particle constructors and spawners.
|
||||
pub fn random(&mut self) -> &mut LegacyRandom {
|
||||
&mut self.random
|
||||
@@ -3710,6 +3788,20 @@ impl ParticleEngine {
|
||||
fn tick_particle(&mut self, p: &mut Particle, level: &ParticleLevel) {
|
||||
match p.behaviour {
|
||||
Behaviour::Base => p.base_tick(level),
|
||||
Behaviour::Controlled { .. } => {
|
||||
if let Some(mut controller) = p.controller.take() {
|
||||
let update = controller.tick(p, level);
|
||||
p.controller = Some(controller);
|
||||
if let Some(frame) = update.frame {
|
||||
self.set_sprite_frame(p, frame);
|
||||
}
|
||||
for (options, pos, velocity) in update.spawned {
|
||||
self.add(&options, pos, velocity);
|
||||
}
|
||||
} else {
|
||||
p.remove();
|
||||
}
|
||||
}
|
||||
Behaviour::Mod { size, alpha } => {
|
||||
p.base_tick(level);
|
||||
p.size = mod_curve(size, p.age, p.lifetime);
|
||||
@@ -4627,6 +4719,7 @@ impl ParticleEngine {
|
||||
Sprite::Index(i) => (
|
||||
self.rects.get(*i as usize).copied().flatten(),
|
||||
p.ty.translucent_layer()
|
||||
|| matches!(p.behaviour, Behaviour::Controlled { translucent: true })
|
||||
|| matches!(p.behaviour, Behaviour::Mod { alpha, .. } if alpha != [1.0; 2]),
|
||||
),
|
||||
Sprite::Named(name) => match self.atlas.get(name) {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Opt-in, compiled particle behavior. The ordinary engine still owns the
|
||||
//! particle, its sprite set, interpolation, rendering and total count limits.
|
||||
use super::{Particle, ParticleLevel, ParticleOptions};
|
||||
use std::fmt::Debug;
|
||||
|
||||
/// Requests made by one particle tick, submitted through normal spawn limits.
|
||||
#[derive(Default)]
|
||||
pub struct ParticleUpdate {
|
||||
/// An index in this particle's declared sprite set. Out-of-range is clamped.
|
||||
pub frame: Option<usize>,
|
||||
pub spawned: Vec<(ParticleOptions, [f64; 3], [f64; 3])>,
|
||||
}
|
||||
|
||||
/// Owned behavior for a compiled native particle. Cloning a particle clones its
|
||||
/// controller state. Do not retain world references; inspect `level` each tick.
|
||||
pub trait ParticleController: Clone + Debug + Send + Sync + 'static {
|
||||
/// Call `particle.advance_age()` for ordinary lifetime/interpolation, or
|
||||
/// explicitly maintain those fields for an overridden source tick.
|
||||
fn tick(&mut self, particle: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate;
|
||||
}
|
||||
|
||||
pub(super) trait Controller: Debug + Send + Sync {
|
||||
fn tick(&mut self, particle: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate;
|
||||
fn clone_box(&self) -> Box<dyn Controller>;
|
||||
}
|
||||
impl<T: ParticleController> Controller for T {
|
||||
fn tick(&mut self, particle: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate {
|
||||
ParticleController::tick(self, particle, level)
|
||||
}
|
||||
fn clone_box(&self) -> Box<dyn Controller> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
impl Clone for Box<dyn Controller> {
|
||||
fn clone(&self) -> Self {
|
||||
self.clone_box()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::*;
|
||||
use horizon_world::{
|
||||
DATA_VERSION,
|
||||
collision::CachedCollisionRegistry,
|
||||
nbt::Tag,
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
#[derive(Clone, Debug)]
|
||||
struct Moving(u32);
|
||||
impl ParticleController for Moving {
|
||||
fn tick(&mut self, p: &mut Particle, _: &ParticleLevel<'_>) -> ParticleUpdate {
|
||||
if p.advance_age() {
|
||||
self.0 += 1;
|
||||
p.position[0] += f64::from(self.0);
|
||||
}
|
||||
ParticleUpdate {
|
||||
frame: Some(self.0 as usize),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
fn engine() -> ParticleEngine {
|
||||
let mut e = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
e.mod_sets.insert("example:orbit".into(), (0, None));
|
||||
e.set_sprites.push(vec![0, 1]);
|
||||
e.rects = vec![
|
||||
Some(Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16
|
||||
});
|
||||
2
|
||||
];
|
||||
e
|
||||
}
|
||||
#[test]
|
||||
fn controlled_particle_keeps_owned_motion_sprite_interpolation_and_expiry() {
|
||||
let registry = BlockRegistry::from_nbt(&Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
(
|
||||
"states",
|
||||
Tag::List(vec![Tag::String("minecraft:air".into())]),
|
||||
),
|
||||
("defaults", Tag::compound([("minecraft:air", Tag::Int(0))])),
|
||||
]))
|
||||
.unwrap();
|
||||
let cached = CachedCollisionRegistry::from_nbt(
|
||||
&Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
(
|
||||
"states",
|
||||
Tag::List(vec![Tag::String("minecraft:air".into())]),
|
||||
),
|
||||
("shapes", Tag::List(vec![])),
|
||||
("cached_shapes", Tag::IntArray(vec![-1])),
|
||||
]),
|
||||
®istry,
|
||||
)
|
||||
.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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -68,27 +68,48 @@ fn cpu_if_enabled() -> Option<Duration> {
|
||||
/// Run `f`, recording its duration under `name` when spans are enabled.
|
||||
#[inline]
|
||||
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
let _span = guard(name);
|
||||
f()
|
||||
}
|
||||
/// A span that ends where it is dropped: [`time`] for a step with several ways out (early
|
||||
/// returns). Nothing is read while spans are off.
|
||||
#[must_use = "the span ends when the guard is dropped"]
|
||||
pub struct Guard {
|
||||
open: Option<(&'static str, Instant, Option<Duration>, u8)>,
|
||||
_heap: Option<horizon_threads::heap_census::Tag>,
|
||||
}
|
||||
/// Start a span named `name` that ends when the returned [`Guard`] is dropped.
|
||||
#[inline]
|
||||
pub fn guard(name: &'static str) -> Guard {
|
||||
let _heap = horizon_threads::heap_tag(name);
|
||||
if !recording_here() {
|
||||
return f();
|
||||
return Guard { open: None, _heap };
|
||||
}
|
||||
let depth = DEPTH.with(|d| d.replace(d.get().saturating_add(1)));
|
||||
let (started, cpu) = (Instant::now(), cpu_if_enabled());
|
||||
let out = f();
|
||||
let ms = started.elapsed().as_secs_f32() * 1000.0;
|
||||
let cpu_ms = cpu
|
||||
.and_then(|start| Some((thread_cpu()?, start)))
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
DEPTH.with(|d| d.set(depth));
|
||||
SPANS.with(|s| {
|
||||
s.borrow_mut().push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
})
|
||||
});
|
||||
out
|
||||
Guard {
|
||||
open: Some((name, Instant::now(), cpu_if_enabled(), depth)),
|
||||
_heap,
|
||||
}
|
||||
}
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) {
|
||||
let Some((name, started, cpu, depth)) = self.open.take() else {
|
||||
return;
|
||||
};
|
||||
let ms = started.elapsed().as_secs_f32() * 1000.0;
|
||||
let cpu_ms = cpu
|
||||
.and_then(|start| Some((thread_cpu()?, start)))
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
DEPTH.with(|d| d.set(depth));
|
||||
SPANS.with(|s| {
|
||||
s.borrow_mut().push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Notes that one step inside a span took `ms` milliseconds doing `what` (one player's
|
||||
/// packets, say), when this thread records spans and the step took at least [`NOTE_MS`]: a
|
||||
@@ -551,6 +572,42 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A guarded span ends on every way out of its step, an early return's too, and holds the
|
||||
/// spans that ran inside it until then; the next span outside it is back at its depth.
|
||||
#[test]
|
||||
fn a_guarded_span_ends_where_its_step_returns() {
|
||||
fn step(early: bool) -> u32 {
|
||||
let _span = super::guard("use_block");
|
||||
super::time("open_loot", || ());
|
||||
if early {
|
||||
return 1;
|
||||
}
|
||||
super::time("open_maps", || ());
|
||||
2
|
||||
}
|
||||
std::thread::spawn(|| {
|
||||
super::record_here(true);
|
||||
assert_eq!(step(true), 1);
|
||||
assert_eq!(step(false), 2);
|
||||
super::time("use_item", || ());
|
||||
let raw = super::take_raw();
|
||||
let seen: Vec<_> = raw.iter().map(|s| (s.name, s.depth)).collect();
|
||||
assert_eq!(
|
||||
seen,
|
||||
[
|
||||
("open_loot", 1),
|
||||
("use_block", 0),
|
||||
("open_loot", 1),
|
||||
("open_maps", 1),
|
||||
("use_block", 0),
|
||||
("use_item", 0)
|
||||
]
|
||||
);
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A slow frame is put down to the slowest outer span, then to the span inside it that took
|
||||
/// at least half of it; repeated spans (several ticks a frame) add up.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! An ordinary read of a struct or buffer-block member that an atomic function also uses gets
|
||||
//! the member's current value (#9081), on a host GPU through the translated program.
|
||||
|
||||
use horizon_shaders::{Macros, Pack, preprocess, translate_compute};
|
||||
|
||||
/// Runs compute program `source` once (one work group), with `probe_buffer` (set 4, binding 2)
|
||||
/// starting at `start`, and returns `probe_result.result` (set 4, binding 1).
|
||||
fn run(device: &wgpu::Device, queue: &wgpu::Queue, source: &str, start: [u32; 2]) -> u32 {
|
||||
use std::io::Write;
|
||||
use wgpu::util::DeviceExt;
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
zip.start_file(
|
||||
"shaders/deferred.csh",
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
let pack =
|
||||
Pack::from_zip_bytes("atomic-values.zip", zip.finish().unwrap().into_inner()).unwrap();
|
||||
let program = translate_compute(&preprocess(&pack, "deferred.csh", &Macros::standard()))
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(program.module)),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let storage = wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC;
|
||||
let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(program.interface.uniform_size.max(48)),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let result = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &[0; 4],
|
||||
usage: storage,
|
||||
});
|
||||
let probe: Vec<u8> = start.iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
let probe = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &probe,
|
||||
usage: storage,
|
||||
});
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 4,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let groups: Vec<wgpu::BindGroup> = (0..5)
|
||||
.map(|set| {
|
||||
let entries: Vec<wgpu::BindGroupEntry> = match set {
|
||||
0 => vec![wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniforms.as_entire_binding(),
|
||||
}],
|
||||
4 if source.contains("probe_buffer") => vec![
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: result.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: probe.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
4 => vec![wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: result.as_entire_binding(),
|
||||
}],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(set),
|
||||
entries: &entries,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
pass.set_pipeline(&pipeline);
|
||||
for (set, group) in groups.iter().enumerate() {
|
||||
pass.set_bind_group(set as u32, group, &[]);
|
||||
}
|
||||
pass.dispatch_workgroups(1, 1, 1);
|
||||
}
|
||||
encoder.copy_buffer_to_buffer(&result, 0, &read, 0, 4);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = read.slice(..).get_mapped_range().unwrap();
|
||||
u32::from_le_bytes(bytes[..4].try_into().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn reads_of_atomic_members_see_the_atomic_updates() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Atomic member GPU: {}", adapter.get_info().name);
|
||||
// Pack buffers sit in bind group 4, as in the game.
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: wgpu::Limits {
|
||||
max_bind_groups: 5,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let result = "layout(std430, binding = 1) buffer probe_result { uint result; };\n";
|
||||
// Eight invocations each add 1 to a zeroed shared member, then read it: 8.
|
||||
for (host, member) in [
|
||||
("shared struct { uint other; uint count; } sh;", "sh.count"),
|
||||
(
|
||||
"struct Counter { uint count; uint other; };\nshared Counter counters[2];",
|
||||
"counters[1].count",
|
||||
),
|
||||
] {
|
||||
for (read, expected) in [
|
||||
("result = M * 10u + 1u;", 81),
|
||||
("uint r = M; result = r;", 8),
|
||||
("result = uint(float(M) * 0.5);", 4),
|
||||
("result = min(M, 5u);", 5),
|
||||
] {
|
||||
let source = format!(
|
||||
"#version 460 core\nlayout(local_size_x = 8) in;\n{host}\n{result}\
|
||||
void main() {{\n {member} = 0u;\n barrier();\n \
|
||||
atomicAdd({member}, 1u);\n barrier();\n {}\n}}\n",
|
||||
read.replace('M', member)
|
||||
);
|
||||
assert_eq!(
|
||||
run(&device, &queue, &source, [0, 0]),
|
||||
expected,
|
||||
"{member}: {read}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// One invocation adds 2 to a buffer member that starts at 5, then reads it: 7.
|
||||
for (read, expected) in [
|
||||
("result = pc.count * 10u + 1u;", 71),
|
||||
("uint r = pc.count; result = r;", 7),
|
||||
("result = uint(int(pc.count) - 10 + 100);", 97),
|
||||
] {
|
||||
let source = format!(
|
||||
"#version 460 core\nlayout(local_size_x = 1) in;\n\
|
||||
layout(std430, binding = 2) buffer probe_buffer {{ uint count; uint other; }} pc;\n\
|
||||
{result}void main() {{\n atomicAdd(pc.count, 2u);\n {read}\n}}\n"
|
||||
);
|
||||
assert_eq!(run(&device, &queue, &source, [5, 0]), expected, "{read}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Synthetic rendering checks (two output shapes mirror RenderPearl v2.7.2 @86de79c, MIT; see the
|
||||
//! PR): a fragment output's `component` layout qualifier (GLSL 4.60, 4.4.2) picks the channels of
|
||||
//! its location that it writes.
|
||||
use horizon_shaders::{Origin, Preprocessed, Target, translate_composite};
|
||||
|
||||
fn source(code: &str) -> Preprocessed {
|
||||
Preprocessed {
|
||||
code: code.into(),
|
||||
files: vec!["components.glsl".into()],
|
||||
lines: code
|
||||
.lines()
|
||||
.enumerate()
|
||||
.map(|(i, _)| Origin {
|
||||
file: 0,
|
||||
line: i as u32 + 1,
|
||||
})
|
||||
.collect(),
|
||||
version: Some((440, None)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws `fragment` over a 1×1 target of `format` and reads back its 16 bytes.
|
||||
fn draw(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
fragment: &str,
|
||||
format: wgpu::TextureFormat,
|
||||
) -> [u8; 16] {
|
||||
let program = translate_composite(
|
||||
&source("void main() { gl_Position = ftransform(); }"),
|
||||
&source(fragment),
|
||||
Target::Buffers,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{fragment}: {e:?}"));
|
||||
let module = |module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
})
|
||||
};
|
||||
let (vertex, fragment_module) = (module(program.vertex), module(program.fragment));
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vertex,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleStrip,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fragment_module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let extent = wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: extent,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let view = target.create_view(&Default::default());
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
// The composite program's uniform block, if it kept one, reads zero.
|
||||
if program.interface.uniform_size > 0 {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(program.interface.uniform_size).max(16),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
}
|
||||
pass.draw(0..4, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
target.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &readback,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: Some(1),
|
||||
},
|
||||
},
|
||||
extent,
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
readback
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = readback.slice(..).get_mapped_range().unwrap()[..16]
|
||||
.try_into()
|
||||
.unwrap();
|
||||
readback.unmap();
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn component_qualified_outputs_write_their_own_channels() {
|
||||
let instance =
|
||||
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
|
||||
.expect("GPU adapter required; missing adapter is not a pass");
|
||||
eprintln!("Output components GPU: {:?}", adapter.get_info());
|
||||
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
// The channels no output writes keep the fill of a narrow output: 0, and 1 in alpha.
|
||||
for (name, fragment, expected) in [
|
||||
(
|
||||
"uvec3 at component 1",
|
||||
"layout(location = 0, component = 1) out uvec3 words;\n\
|
||||
void main() { words = uvec3(7u, 8u, 9u); }",
|
||||
[0u32, 7, 8, 9],
|
||||
),
|
||||
(
|
||||
"uint at component 2",
|
||||
"layout(location = 0, component = 2) out uint word;\n\
|
||||
void main() { word = 5u; }",
|
||||
[0, 0, 5, 1],
|
||||
),
|
||||
(
|
||||
"two outputs share location 0",
|
||||
"layout(location = 0, component = 0) out uint low;\n\
|
||||
layout(location = 0, component = 1) out uvec3 high;\n\
|
||||
void main() { low = 3u; high = uvec3(4u, 5u, 6u); }",
|
||||
[3, 4, 5, 6],
|
||||
),
|
||||
(
|
||||
"the component 1 output declared first",
|
||||
"layout(location = 0, component = 1) out uvec2 middle;\n\
|
||||
layout(location = 0) out uint low;\n\
|
||||
void main() { low = 11u; middle = uvec2(12u, 13u); }",
|
||||
[11, 12, 13, 1],
|
||||
),
|
||||
] {
|
||||
let bytes = draw(&device, &queue, fragment, wgpu::TextureFormat::Rgba32Uint);
|
||||
let actual: [u32; 4] = bytemuck::cast(bytes);
|
||||
eprintln!("{name}: {actual:?}");
|
||||
assert_eq!(actual, expected, "{name}");
|
||||
}
|
||||
let bytes = draw(
|
||||
&device,
|
||||
&queue,
|
||||
"layout(location = 0, component = 0) out float red;\n\
|
||||
layout(location = 0, component = 1) out vec3 rest;\n\
|
||||
void main() { red = 0.0; rest = vec3(1.0, 0.0, 1.0); }",
|
||||
wgpu::TextureFormat::Rgba32Float,
|
||||
);
|
||||
let actual: [f32; 4] = bytemuck::cast(bytes);
|
||||
eprintln!("float outputs share location 0: {actual:?}");
|
||||
assert_eq!(actual, [0.0, 1.0, 0.0, 1.0]);
|
||||
}
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Bewegingsdooiesone",
|
||||
"horizon.options.look_deadzone": "Kykdooiesone",
|
||||
"horizon.options.button_glyphs": "Knoppie Glyphs",
|
||||
"horizon.options.menu_navigation": "Kieslysnavigasie",
|
||||
"horizon.options.on_screen_keyboard": "Sleutelbord op die skerm",
|
||||
"horizon.options.chat_delay": "Chat Vertraging",
|
||||
"horizon.options.lightbar.health": "Gesondheid",
|
||||
"horizon.options.lightbar.dimension": "Dimensie",
|
||||
"horizon.options.glyphs.auto": "Outo",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.menu_pointer": "Wyser",
|
||||
"horizon.options.keyboard.auto": "Outo",
|
||||
"horizon.options.unavailable": "Hierdie kenmerk is nog nie in Horizon beskikbaar nie.",
|
||||
"horizon.options.search": "Soek instellings...",
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"horizon.options.move_deadzone": "المنطقة الميتة للحركة",
|
||||
"horizon.options.look_deadzone": "المنطقة الميتة للنظر",
|
||||
"horizon.options.button_glyphs": "الحروف الرسومية للأزرار",
|
||||
"horizon.options.menu_navigation": "التنقل في القائمة",
|
||||
"horizon.options.on_screen_keyboard": "لوحة المفاتيح على الشاشة",
|
||||
"horizon.options.chat_delay": "تأخير الدردشة",
|
||||
"horizon.options.lightbar.health": "الصحة",
|
||||
@@ -19,8 +18,6 @@
|
||||
"horizon.options.glyphs.xbox": "اكس بوكس",
|
||||
"horizon.options.glyphs.playstation": "بلاي ستيشن",
|
||||
"horizon.options.glyphs.nintendo": "نينتندو",
|
||||
"horizon.options.menu_focus": "التركيز",
|
||||
"horizon.options.menu_pointer": "المؤشر",
|
||||
"horizon.options.keyboard.auto": "تلقائي",
|
||||
"horizon.options.keyboard.steam": "البخار",
|
||||
"horizon.options.unavailable": "هذه الميزة غير متوفرة بعد في Horizon.",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Hərəkət ölü zonası",
|
||||
"horizon.options.look_deadzone": "Baxış ölü zonası",
|
||||
"horizon.options.button_glyphs": "Düymə Qlifləri",
|
||||
"horizon.options.menu_navigation": "Menyu Naviqasiyası",
|
||||
"horizon.options.on_screen_keyboard": "Ekran Klaviaturası",
|
||||
"horizon.options.chat_delay": "Söhbət Gecikməsi",
|
||||
"horizon.options.lightbar.health": "Sağlamlıq",
|
||||
"horizon.options.lightbar.dimension": "Ölçü",
|
||||
"horizon.options.glyphs.auto": "Avtomatik",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.menu_pointer": "Göstərici",
|
||||
"horizon.options.keyboard.auto": "Avtomatik",
|
||||
"horizon.options.unavailable": "Bu funksiya hələ Horizon ərazisində mövcud deyil.",
|
||||
"horizon.options.search": "Axtarış parametrləri...",
|
||||
|
||||
@@ -11,14 +11,11 @@
|
||||
"horizon.options.move_deadzone": "Хәрәкәт үле зонаһы",
|
||||
"horizon.options.look_deadzone": "Ҡараш үле зонаһы",
|
||||
"horizon.options.button_glyphs": "Төймә глифтары",
|
||||
"horizon.options.menu_navigation": "Навигация меню",
|
||||
"horizon.options.on_screen_keyboard": "Экрандағы клавиатура",
|
||||
"horizon.options.chat_delay": "Чат тотҡарланыуы",
|
||||
"horizon.options.lightbar.health": "Һаулыҡ",
|
||||
"horizon.options.lightbar.dimension": "Үлсәм",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Күрһәткес",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.unavailable": "Был функция Horizon-ла әлегә юҡ.",
|
||||
"horizon.options.search": "Эҙләү параметрҙары...",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Мёртвая зона руху",
|
||||
"horizon.options.look_deadzone": "Мёртвая зона агляду",
|
||||
"horizon.options.button_glyphs": "Гліфы кнопак",
|
||||
"horizon.options.menu_navigation": "Навігацыя па меню",
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавіятура",
|
||||
"horizon.options.chat_delay": "Затрымка чата",
|
||||
"horizon.options.lightbar.health": "здароўе",
|
||||
"horizon.options.lightbar.dimension": "Вымярэнне",
|
||||
"horizon.options.glyphs.auto": "Аўто",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Паказальнік",
|
||||
"horizon.options.keyboard.auto": "Аўто",
|
||||
"horizon.options.unavailable": "Гэтая функцыя яшчэ недаступная ў Horizon.",
|
||||
"horizon.options.search": "Налады пошуку...",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Мъртва зона на движението",
|
||||
"horizon.options.look_deadzone": "Мъртва зона на погледа",
|
||||
"horizon.options.button_glyphs": "Глифове на бутони",
|
||||
"horizon.options.menu_navigation": "Навигация в менюто",
|
||||
"horizon.options.on_screen_keyboard": "Екранна клавиатура",
|
||||
"horizon.options.chat_delay": "Забавяне на чата",
|
||||
"horizon.options.lightbar.health": "здраве",
|
||||
"horizon.options.lightbar.dimension": "Измерение",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "показалец",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.keyboard.steam": "Пара",
|
||||
"horizon.options.unavailable": "Тази функция все още не е налична в Horizon.",
|
||||
|
||||
@@ -11,14 +11,11 @@
|
||||
"horizon.options.move_deadzone": "Takad marv ar fiñval",
|
||||
"horizon.options.look_deadzone": "Takad marv ar sell",
|
||||
"horizon.options.button_glyphs": "Glifoù bouton",
|
||||
"horizon.options.menu_navigation": "Merdeiñ dre ar meni",
|
||||
"horizon.options.on_screen_keyboard": "Klavier war ar skramm",
|
||||
"horizon.options.chat_delay": "Dale flapañ",
|
||||
"horizon.options.lightbar.health": "Yec'hed",
|
||||
"horizon.options.lightbar.dimension": "Ment",
|
||||
"horizon.options.glyphs.auto": "Emgefreek",
|
||||
"horizon.options.menu_focus": "Lakaat ar gaoz",
|
||||
"horizon.options.menu_pointer": "Poent",
|
||||
"horizon.options.keyboard.auto": "Emgefreek",
|
||||
"horizon.options.unavailable": "N'eo ket hegerz an elfenn-mañ c'hoazh e Horizon.",
|
||||
"horizon.options.search": "Arventennoù klask...",
|
||||
|
||||
@@ -10,13 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Zona morta del moviment",
|
||||
"horizon.options.look_deadzone": "Zona morta de la vista",
|
||||
"horizon.options.button_glyphs": "Glifos de botons",
|
||||
"horizon.options.menu_navigation": "Navegació del menú",
|
||||
"horizon.options.on_screen_keyboard": "Teclat en pantalla",
|
||||
"horizon.options.chat_delay": "Retard de xat",
|
||||
"horizon.options.lightbar.health": "Salut",
|
||||
"horizon.options.lightbar.dimension": "Dimensió",
|
||||
"horizon.options.glyphs.auto": "Automàtic",
|
||||
"horizon.options.menu_pointer": "Apuntador",
|
||||
"horizon.options.keyboard.auto": "Automàtic",
|
||||
"horizon.options.unavailable": "Aquesta funció encara no està disponible a Horizon.",
|
||||
"horizon.options.search": "Configuració de cerca...",
|
||||
|
||||
@@ -10,13 +10,10 @@
|
||||
"horizon.options.move_deadzone": "Mrtvá zóna pohybu",
|
||||
"horizon.options.look_deadzone": "Mrtvá zóna pohledu",
|
||||
"horizon.options.button_glyphs": "Glyfy tlačítek",
|
||||
"horizon.options.menu_navigation": "Navigace v menu",
|
||||
"horizon.options.on_screen_keyboard": "Klávesnice na obrazovce",
|
||||
"horizon.options.chat_delay": "Zpoždění chatu",
|
||||
"horizon.options.lightbar.health": "Zdraví",
|
||||
"horizon.options.lightbar.dimension": "Dimenze",
|
||||
"horizon.options.menu_focus": "Zaměřte se",
|
||||
"horizon.options.menu_pointer": "Ukazatel",
|
||||
"horizon.options.keyboard.horizon": "Horizont",
|
||||
"horizon.options.keyboard.steam": "Pára",
|
||||
"horizon.options.unavailable": "Tato funkce zatím není v Horizon k dispozici.",
|
||||
|
||||
@@ -11,14 +11,11 @@
|
||||
"horizon.options.move_deadzone": "Куҫӑмӑн вилӗ зони",
|
||||
"horizon.options.look_deadzone": "Пӑхӑвӑн вилӗ зони",
|
||||
"horizon.options.button_glyphs": "Кнопка глифӗсем",
|
||||
"horizon.options.menu_navigation": "Меню навигацийӗ",
|
||||
"horizon.options.on_screen_keyboard": "Экран ҫинчи клавиатура",
|
||||
"horizon.options.chat_delay": "Чат кая юлни",
|
||||
"horizon.options.lightbar.health": "Сывлӑх",
|
||||
"horizon.options.lightbar.dimension": "Виҫе",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Указатель",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.unavailable": "Ку функци Horizon-ра хальлӗхе ҫук-ха.",
|
||||
"horizon.options.search": "Шырав настройкисем...",
|
||||
|
||||
@@ -10,13 +10,10 @@
|
||||
"horizon.options.move_deadzone": "Parth marw symud",
|
||||
"horizon.options.look_deadzone": "Parth marw edrych",
|
||||
"horizon.options.button_glyphs": "Glyffau Botwm",
|
||||
"horizon.options.menu_navigation": "Llywio Dewislen",
|
||||
"horizon.options.on_screen_keyboard": "Bysellfwrdd Ar-Sgrin",
|
||||
"horizon.options.chat_delay": "Oedi Sgwrsio",
|
||||
"horizon.options.lightbar.health": "Iechyd",
|
||||
"horizon.options.lightbar.dimension": "Dimensiwn",
|
||||
"horizon.options.menu_focus": "Ffocws",
|
||||
"horizon.options.menu_pointer": "Pwyntiwr",
|
||||
"horizon.options.unavailable": "Nid yw'r nodwedd hon ar gael eto yn Horizon.",
|
||||
"horizon.options.search": "Gosodiadau chwilio...",
|
||||
"horizon.pages.shader_packs": "Pecynnau Shader",
|
||||
|
||||
@@ -8,11 +8,9 @@
|
||||
"horizon.options.move_deadzone": "Dødzone for bevægelse",
|
||||
"horizon.options.look_deadzone": "Dødzone for kig",
|
||||
"horizon.options.button_glyphs": "Knap Glyfer",
|
||||
"horizon.options.menu_navigation": "Menu navigation",
|
||||
"horizon.options.on_screen_keyboard": "Skærmtastatur",
|
||||
"horizon.options.chat_delay": "Chatforsinkelse",
|
||||
"horizon.options.lightbar.health": "Sundhed",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.keyboard.steam": "Damp",
|
||||
"horizon.options.unavailable": "Denne funktion er endnu ikke tilgængelig i Horizon.",
|
||||
"horizon.options.search": "Søgeindstillinger...",
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"horizon.options.move_deadzone": "Totzone der Bewegung",
|
||||
"horizon.options.look_deadzone": "Totzone des Blicks",
|
||||
"horizon.options.button_glyphs": "Tastensymbole",
|
||||
"horizon.options.menu_navigation": "Menünavigation",
|
||||
"horizon.options.on_screen_keyboard": "Bildschirmtastatur",
|
||||
"horizon.options.chat_delay": "Chatverzögerung",
|
||||
"horizon.options.lightbar.health": "Gesundheit",
|
||||
@@ -20,8 +19,6 @@
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.menu_pointer": "Zeiger",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
|
||||
@@ -9,14 +9,11 @@
|
||||
"horizon.options.move_deadzone": "Νεκρή ζώνη κίνησης",
|
||||
"horizon.options.look_deadzone": "Νεκρή ζώνη κάμερας",
|
||||
"horizon.options.button_glyphs": "Γλυφικά κουμπιών",
|
||||
"horizon.options.menu_navigation": "Πλοήγηση μενού",
|
||||
"horizon.options.on_screen_keyboard": "Πληκτρολόγιο οθόνης",
|
||||
"horizon.options.chat_delay": "Καθυστέρηση συνομιλίας",
|
||||
"horizon.options.lightbar.health": "Υγεία",
|
||||
"horizon.options.lightbar.dimension": "Διάσταση",
|
||||
"horizon.options.glyphs.auto": "Αυτόματο",
|
||||
"horizon.options.menu_focus": "Εστίαση",
|
||||
"horizon.options.menu_pointer": "Δείκτης",
|
||||
"horizon.options.keyboard.auto": "Αυτόματο",
|
||||
"horizon.options.unavailable": "Αυτή η δυνατότητα δεν είναι ακόμη διαθέσιμη στο Horizon.",
|
||||
"horizon.options.search": "Ρυθμίσεις αναζήτησης...",
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"horizon.options.move_deadzone": "Move Deadzone",
|
||||
"horizon.options.look_deadzone": "Look Deadzone",
|
||||
"horizon.options.button_glyphs": "Button Glyphs",
|
||||
"horizon.options.menu_navigation": "Menu Navigation",
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
|
||||
"horizon.options.chat_delay": "Chat Delay",
|
||||
"horizon.options.lightbar.health": "Health",
|
||||
@@ -20,8 +19,6 @@
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Focus",
|
||||
"horizon.options.menu_pointer": "Pointer",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
@@ -522,5 +519,10 @@
|
||||
"horizon.extras.cosmetics_auras.tooltip": "Auras and trails other players wear. Off by default.",
|
||||
"horizon.extras.cosmetics.shown": "Shown",
|
||||
"horizon.extras.cosmetics.hidden": "Hidden",
|
||||
"horizon.extras.cosmetics.name_tags": "Name tags only"
|
||||
"horizon.extras.cosmetics.name_tags": "Name tags only",
|
||||
"horizon.options.pointer_speed": "Menu Pointer Speed",
|
||||
"horizon.options.scroll_speed": "Menu Scroll Speed",
|
||||
"horizon.options.repeat_delay": "Navigation Repeat Delay",
|
||||
"horizon.options.repeat_interval": "Navigation Repeat Interval",
|
||||
"horizon.options.milliseconds": "%s ms"
|
||||
}
|
||||
@@ -11,14 +11,11 @@
|
||||
"horizon.options.move_deadzone": "Mova mortzono",
|
||||
"horizon.options.look_deadzone": "Rigarda mortzono",
|
||||
"horizon.options.button_glyphs": "Butonaj Glifoj",
|
||||
"horizon.options.menu_navigation": "Menuo Navigado",
|
||||
"horizon.options.on_screen_keyboard": "Surekrana Klavaro",
|
||||
"horizon.options.chat_delay": "Babilado Prokrasto",
|
||||
"horizon.options.lightbar.health": "Sano",
|
||||
"horizon.options.lightbar.dimension": "Dimensio",
|
||||
"horizon.options.glyphs.auto": "Aŭtomate",
|
||||
"horizon.options.menu_focus": "Fokuso",
|
||||
"horizon.options.menu_pointer": "Montrilo",
|
||||
"horizon.options.keyboard.auto": "Aŭtomate",
|
||||
"horizon.options.unavailable": "Ĉi tiu funkcio ankoraŭ ne disponeblas en Horizon.",
|
||||
"horizon.options.search": "Serĉaj agordoj...",
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"horizon.options.move_deadzone": "Zona muerta de movimiento",
|
||||
"horizon.options.look_deadzone": "Zona muerta de vista",
|
||||
"horizon.options.button_glyphs": "Símbolos de botones",
|
||||
"horizon.options.menu_navigation": "Navegación de menús",
|
||||
"horizon.options.on_screen_keyboard": "Teclado en pantalla",
|
||||
"horizon.options.chat_delay": "Retardo del chat",
|
||||
"horizon.options.lightbar.health": "Salud",
|
||||
@@ -19,8 +18,6 @@
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Foco",
|
||||
"horizon.options.menu_pointer": "Puntero",
|
||||
"horizon.options.keyboard.auto": "Automático",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Liikumise surnud tsoon",
|
||||
"horizon.options.look_deadzone": "Vaate surnud tsoon",
|
||||
"horizon.options.button_glyphs": "Nuppude glüüfid",
|
||||
"horizon.options.menu_navigation": "Menüüs navigeerimine",
|
||||
"horizon.options.on_screen_keyboard": "Ekraaniklaviatuur",
|
||||
"horizon.options.chat_delay": "Vestluse viivitus",
|
||||
"horizon.options.lightbar.health": "Tervis",
|
||||
"horizon.options.lightbar.dimension": "Mõõtmed",
|
||||
"horizon.options.glyphs.auto": "Automaatne",
|
||||
"horizon.options.menu_focus": "Keskendu",
|
||||
"horizon.options.menu_pointer": "Osuti",
|
||||
"horizon.options.keyboard.auto": "Automaatne",
|
||||
"horizon.options.unavailable": "See funktsioon pole Horizonis veel saadaval.",
|
||||
"horizon.options.search": "Otsinguseaded...",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Mugimenduaren zona hila",
|
||||
"horizon.options.look_deadzone": "Begiradaren zona hila",
|
||||
"horizon.options.button_glyphs": "Botoien glifoak",
|
||||
"horizon.options.menu_navigation": "Menuen nabigazioa",
|
||||
"horizon.options.on_screen_keyboard": "Pantailako teklatua",
|
||||
"horizon.options.chat_delay": "Txat-atzerapena",
|
||||
"horizon.options.lightbar.health": "Osasuna",
|
||||
"horizon.options.lightbar.dimension": "Dimentsioa",
|
||||
"horizon.options.glyphs.auto": "Autoa",
|
||||
"horizon.options.menu_focus": "Fokua",
|
||||
"horizon.options.menu_pointer": "Erakuslea",
|
||||
"horizon.options.keyboard.auto": "Autoa",
|
||||
"horizon.options.unavailable": "Eginbide hau oraindik ez dago erabilgarri Horizon-n.",
|
||||
"horizon.options.search": "Bilaketa-ezarpenak...",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "ناحیه مرده حرکت",
|
||||
"horizon.options.look_deadzone": "ناحیه مرده نگاه",
|
||||
"horizon.options.button_glyphs": "گلیف دکمه",
|
||||
"horizon.options.menu_navigation": "پیمایش منو",
|
||||
"horizon.options.on_screen_keyboard": "صفحه کلید روی صفحه",
|
||||
"horizon.options.chat_delay": "تاخیر چت",
|
||||
"horizon.options.lightbar.health": "سلامتی",
|
||||
"horizon.options.lightbar.dimension": "بعد",
|
||||
"horizon.options.glyphs.auto": "خودکار",
|
||||
"horizon.options.menu_focus": "تمرکز کنید",
|
||||
"horizon.options.menu_pointer": "اشاره گر",
|
||||
"horizon.options.keyboard.auto": "خودکار",
|
||||
"horizon.options.keyboard.steam": "بخار",
|
||||
"horizon.options.unavailable": "این ویژگی هنوز در Horizon موجود نیست.",
|
||||
|
||||
@@ -9,13 +9,10 @@
|
||||
"horizon.options.move_deadzone": "Liikkeen kuollut alue",
|
||||
"horizon.options.look_deadzone": "Katseen kuollut alue",
|
||||
"horizon.options.button_glyphs": "Painikkeiden kuviot",
|
||||
"horizon.options.menu_navigation": "Valikkonavigointi",
|
||||
"horizon.options.on_screen_keyboard": "Näyttönäppäimistö",
|
||||
"horizon.options.chat_delay": "Chatin viive",
|
||||
"horizon.options.lightbar.health": "Terveys",
|
||||
"horizon.options.lightbar.dimension": "Mitat",
|
||||
"horizon.options.menu_focus": "Keskity",
|
||||
"horizon.options.menu_pointer": "Osoitin",
|
||||
"horizon.options.unavailable": "Tämä ominaisuus ei ole vielä saatavilla Horizonissa.",
|
||||
"horizon.options.search": "Hakuasetukset...",
|
||||
"horizon.pages.mods": "Modit",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"horizon.options.move_deadzone": "Deadzone ng Paggalaw",
|
||||
"horizon.options.look_deadzone": "Deadzone ng Tingin",
|
||||
"horizon.options.button_glyphs": "Mga Glyph ng Button",
|
||||
"horizon.options.menu_navigation": "Navigation ng Menu",
|
||||
"horizon.options.on_screen_keyboard": "On-Screen na Keyboard",
|
||||
"horizon.options.chat_delay": "Pagkaantala sa Chat",
|
||||
"horizon.options.lightbar.health": "Kalusugan",
|
||||
|
||||
@@ -11,13 +11,10 @@
|
||||
"horizon.options.move_deadzone": "Deyðaøki fyri flyting",
|
||||
"horizon.options.look_deadzone": "Deyðaøki fyri hyggjan",
|
||||
"horizon.options.button_glyphs": "Knappaglyfur",
|
||||
"horizon.options.menu_navigation": "Valmyndanavigatión",
|
||||
"horizon.options.on_screen_keyboard": "Lyklaborð á skjánum",
|
||||
"horizon.options.chat_delay": "Kjakseinking",
|
||||
"horizon.options.lightbar.health": "Heilsa",
|
||||
"horizon.options.lightbar.dimension": "Mát",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.menu_pointer": "Peikari",
|
||||
"horizon.options.unavailable": "Hesin hentleikin er ikki tøkur enn í Horizon.",
|
||||
"horizon.options.search": "Leiti innstillingar...",
|
||||
"horizon.pages.shader_packs": "Skuggapakkar",
|
||||
|
||||
@@ -10,11 +10,9 @@
|
||||
"horizon.options.move_deadzone": "Zone morte du déplacement",
|
||||
"horizon.options.look_deadzone": "Zone morte de la vue",
|
||||
"horizon.options.button_glyphs": "Symboles des boutons",
|
||||
"horizon.options.menu_navigation": "Navigation dans les menus",
|
||||
"horizon.options.on_screen_keyboard": "Clavier à l'écran",
|
||||
"horizon.options.chat_delay": "Délai du tchat",
|
||||
"horizon.options.lightbar.health": "Santé",
|
||||
"horizon.options.menu_pointer": "Pointeur",
|
||||
"horizon.options.unavailable": "Cette fonction n'est pas encore disponible dans Horizon.",
|
||||
"horizon.options.search": "Rechercher une option...",
|
||||
"horizon.pages.shader_packs": "Packs de shaders",
|
||||
@@ -443,5 +441,10 @@
|
||||
"horizon.repro.bundle": "Archive de reproduction : %s",
|
||||
"horizon.repro.error": "Reproduction : %s",
|
||||
"horizon.repro.no_recording": "Il n'y a pas encore d'enregistrement à exporter.",
|
||||
"horizon.repro.replay_started": "Lecture démarrée : %s"
|
||||
"horizon.repro.replay_started": "Lecture démarrée : %s",
|
||||
"horizon.options.pointer_speed": "Vitesse du pointeur des menus",
|
||||
"horizon.options.scroll_speed": "Vitesse de défilement des menus",
|
||||
"horizon.options.repeat_delay": "Délai de répétition de navigation",
|
||||
"horizon.options.repeat_interval": "Intervalle de répétition de navigation",
|
||||
"horizon.options.milliseconds": "%s ms"
|
||||
}
|
||||
@@ -11,12 +11,9 @@
|
||||
"horizon.options.move_deadzone": "Zone muarte dal moviment",
|
||||
"horizon.options.look_deadzone": "Zone muarte dal sguard",
|
||||
"horizon.options.button_glyphs": "Glifs dai botôns",
|
||||
"horizon.options.menu_navigation": "Navigazion dal menût",
|
||||
"horizon.options.on_screen_keyboard": "Tastier sul schermi",
|
||||
"horizon.options.chat_delay": "Ritart de conversazion",
|
||||
"horizon.options.lightbar.health": "Salût",
|
||||
"horizon.options.menu_focus": "Concentrâsi",
|
||||
"horizon.options.menu_pointer": "Puntôr",
|
||||
"horizon.options.unavailable": "Cheste funzion no je ancjemò disponibile in Horizon.",
|
||||
"horizon.options.search": "Impostazions di ricercje...",
|
||||
"horizon.pages.shader_packs": "Pachetis ombrenadôrs",
|
||||
|
||||
@@ -8,10 +8,8 @@
|
||||
"horizon.options.move_deadzone": "Deadzone beweging",
|
||||
"horizon.options.look_deadzone": "Deadzone kamera",
|
||||
"horizon.options.button_glyphs": "Knop Glyphs",
|
||||
"horizon.options.menu_navigation": "Menu Navigaasje",
|
||||
"horizon.options.chat_delay": "Petear fertraging",
|
||||
"horizon.options.lightbar.dimension": "Diminsje",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.unavailable": "Dizze funksje is noch net beskikber yn Horizon.",
|
||||
"horizon.options.search": "Ynstellings sykje...",
|
||||
"horizon.pages.shader_options": "Shader Opsjes",
|
||||
|
||||
@@ -10,13 +10,10 @@
|
||||
"horizon.options.move_deadzone": "Crios marbh gluaiseachta",
|
||||
"horizon.options.look_deadzone": "Crios marbh amhairc",
|
||||
"horizon.options.button_glyphs": "Glyphs Cnaipe",
|
||||
"horizon.options.menu_navigation": "Nascleanúint Roghchláir",
|
||||
"horizon.options.on_screen_keyboard": "Méarchlár ar an Scáileán",
|
||||
"horizon.options.chat_delay": "Moill Comhrá",
|
||||
"horizon.options.lightbar.health": "Sláinte",
|
||||
"horizon.options.lightbar.dimension": "Toise",
|
||||
"horizon.options.menu_focus": "Fócas",
|
||||
"horizon.options.menu_pointer": "Pointeoir",
|
||||
"horizon.options.unavailable": "Níl an ghné seo ar fáil fós i Horizon.",
|
||||
"horizon.options.search": "Socruithe cuardaigh...",
|
||||
"horizon.pages.shader_packs": "Pacáistí Shader",
|
||||
|
||||
@@ -11,13 +11,10 @@
|
||||
"horizon.options.move_deadzone": "Raon marbh a' ghluasaid",
|
||||
"horizon.options.look_deadzone": "Raon marbh a' chamara",
|
||||
"horizon.options.button_glyphs": "Glyphs putan",
|
||||
"horizon.options.menu_navigation": "Seòladh clàr-taice",
|
||||
"horizon.options.on_screen_keyboard": "Meur-chlàr air-sgrion",
|
||||
"horizon.options.chat_delay": "Dàil Chat",
|
||||
"horizon.options.lightbar.health": "Slàinte",
|
||||
"horizon.options.lightbar.dimension": "Tomhas",
|
||||
"horizon.options.menu_focus": "Fòcas",
|
||||
"horizon.options.menu_pointer": "Puingeadair",
|
||||
"horizon.options.unavailable": "Chan eil am feart seo ri fhaighinn fhathast ann an Horizon.",
|
||||
"horizon.options.search": "Rannsaich roghainnean...",
|
||||
"horizon.pages.mods": "Mòdan",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Zona morta do movemento",
|
||||
"horizon.options.look_deadzone": "Zona morta da mirada",
|
||||
"horizon.options.button_glyphs": "Glifos de botón",
|
||||
"horizon.options.menu_navigation": "Navegación por menús",
|
||||
"horizon.options.on_screen_keyboard": "Teclado en pantalla",
|
||||
"horizon.options.chat_delay": "Atraso do chat",
|
||||
"horizon.options.lightbar.health": "Saúde",
|
||||
"horizon.options.lightbar.dimension": "Dimensión",
|
||||
"horizon.options.glyphs.auto": "Automático",
|
||||
"horizon.options.menu_focus": "Foco",
|
||||
"horizon.options.menu_pointer": "Punteiro",
|
||||
"horizon.options.keyboard.auto": "Automático",
|
||||
"horizon.options.unavailable": "Esta función aínda non está dispoñible en Horizon.",
|
||||
"horizon.options.search": "Configuración de busca...",
|
||||
|
||||
@@ -11,13 +11,10 @@
|
||||
"horizon.options.move_deadzone": "Deadzone o ka neʻe",
|
||||
"horizon.options.look_deadzone": "Deadzone o ka nānā",
|
||||
"horizon.options.button_glyphs": "Pihi Glyphs",
|
||||
"horizon.options.menu_navigation": "Papa Hoʻokele",
|
||||
"horizon.options.on_screen_keyboard": "Pāpāpā-pāpāho",
|
||||
"horizon.options.chat_delay": "Hoʻopaneʻe kamaʻilio",
|
||||
"horizon.options.lightbar.health": "Ola kino",
|
||||
"horizon.options.lightbar.dimension": "Anana",
|
||||
"horizon.options.menu_focus": "Nānā",
|
||||
"horizon.options.menu_pointer": "Kuhikuhi",
|
||||
"horizon.options.unavailable": "ʻAʻole loaʻa kēia hiʻohiʻona ma Horizon.",
|
||||
"horizon.options.search": "Nā koho hulina...",
|
||||
"horizon.pages.shader_packs": "Nā Pākuʻi Shader",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "אזור מת של התנועה",
|
||||
"horizon.options.look_deadzone": "אזור מת של המבט",
|
||||
"horizon.options.button_glyphs": "גליפים של כפתורים",
|
||||
"horizon.options.menu_navigation": "ניווט בתפריט",
|
||||
"horizon.options.on_screen_keyboard": "מקלדת על המסך",
|
||||
"horizon.options.chat_delay": "עיכוב בצ'אט",
|
||||
"horizon.options.lightbar.health": "בריאות",
|
||||
"horizon.options.lightbar.dimension": "מימד",
|
||||
"horizon.options.glyphs.auto": "אוטומטי",
|
||||
"horizon.options.menu_focus": "פוקוס",
|
||||
"horizon.options.menu_pointer": "מצביע",
|
||||
"horizon.options.keyboard.auto": "אוטומטי",
|
||||
"horizon.options.keyboard.steam": "קיטור",
|
||||
"horizon.options.unavailable": "תכונה זו עדיין לא זמינה ב-Horizon.",
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"horizon.options.move_deadzone": "मूव डेडज़ोन",
|
||||
"horizon.options.look_deadzone": "लुक डेडज़ोन",
|
||||
"horizon.options.button_glyphs": "बटन ग्लाइफ्स",
|
||||
"horizon.options.menu_navigation": "मेनू नेविगेशन",
|
||||
"horizon.options.on_screen_keyboard": "ऑन-स्क्रीन कीबोर्ड",
|
||||
"horizon.options.chat_delay": "चैट विलंब",
|
||||
"horizon.options.lightbar.health": "स्वास्थ्य",
|
||||
@@ -19,8 +18,6 @@
|
||||
"horizon.options.glyphs.xbox": "एक्सबॉक्स",
|
||||
"horizon.options.glyphs.playstation": "प्लेस्टेशन",
|
||||
"horizon.options.glyphs.nintendo": "निनटेंडो",
|
||||
"horizon.options.menu_focus": "फोकस",
|
||||
"horizon.options.menu_pointer": "पॉइंटर",
|
||||
"horizon.options.keyboard.auto": "ऑटो",
|
||||
"horizon.options.keyboard.horizon": "क्षितिज",
|
||||
"horizon.options.keyboard.steam": "स्टीम",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Mrtva zona kretanja",
|
||||
"horizon.options.look_deadzone": "Mrtva zona pogleda",
|
||||
"horizon.options.button_glyphs": "Simboli gumba",
|
||||
"horizon.options.menu_navigation": "Navigacija izbornikom",
|
||||
"horizon.options.on_screen_keyboard": "Zaslonska tipkovnica",
|
||||
"horizon.options.chat_delay": "Odgoda chata",
|
||||
"horizon.options.lightbar.health": "Zdravlje",
|
||||
"horizon.options.lightbar.dimension": "Dimenzija",
|
||||
"horizon.options.glyphs.auto": "Automatski",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.menu_pointer": "Pokazivač",
|
||||
"horizon.options.keyboard.auto": "Automatski",
|
||||
"horizon.options.unavailable": "Ova značajka još nije dostupna u Horizonu.",
|
||||
"horizon.options.search": "Pretraži postavke...",
|
||||
|
||||
@@ -9,13 +9,10 @@
|
||||
"horizon.options.move_deadzone": "Mozgás holtzónája",
|
||||
"horizon.options.look_deadzone": "Nézet holtzónája",
|
||||
"horizon.options.button_glyphs": "Gomb jelek",
|
||||
"horizon.options.menu_navigation": "Menü navigáció",
|
||||
"horizon.options.on_screen_keyboard": "Képernyő-billentyűzet",
|
||||
"horizon.options.chat_delay": "Chat késleltetés",
|
||||
"horizon.options.lightbar.health": "Egészség",
|
||||
"horizon.options.lightbar.dimension": "Méret",
|
||||
"horizon.options.menu_focus": "Fókusz",
|
||||
"horizon.options.menu_pointer": "Mutató",
|
||||
"horizon.options.keyboard.horizon": "Horizont",
|
||||
"horizon.options.unavailable": "Ez a funkció még nem érhető el a Horizonban.",
|
||||
"horizon.options.search": "Keresési beállítások...",
|
||||
|
||||
@@ -9,14 +9,11 @@
|
||||
"horizon.options.move_deadzone": "Շարժման մեռյալ գոտի",
|
||||
"horizon.options.look_deadzone": "Նայելու մեռյալ գոտի",
|
||||
"horizon.options.button_glyphs": "Կոճակի գլիֆներ",
|
||||
"horizon.options.menu_navigation": "Մենյու նավարկություն",
|
||||
"horizon.options.on_screen_keyboard": "Էկրանի ստեղնաշար",
|
||||
"horizon.options.chat_delay": "Զրույցի հետաձգում",
|
||||
"horizon.options.lightbar.health": "Առողջություն",
|
||||
"horizon.options.lightbar.dimension": "Չափս",
|
||||
"horizon.options.glyphs.auto": "Ավտոմատ",
|
||||
"horizon.options.menu_focus": "Կենտրոնանալ",
|
||||
"horizon.options.menu_pointer": "Ցուցիչ",
|
||||
"horizon.options.keyboard.auto": "Ավտոմատ",
|
||||
"horizon.options.unavailable": "Այս հատկությունը դեռ հասանելի չէ Horizon-ում:",
|
||||
"horizon.options.search": "Որոնման կարգավորումներ...",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Zona Mati Gerakan",
|
||||
"horizon.options.look_deadzone": "Zona Mati Pandangan",
|
||||
"horizon.options.button_glyphs": "Simbol Tombol",
|
||||
"horizon.options.menu_navigation": "Navigasi Menu",
|
||||
"horizon.options.on_screen_keyboard": "Papan Ketik Layar",
|
||||
"horizon.options.chat_delay": "Penundaan Obrolan",
|
||||
"horizon.options.lightbar.health": "Nyawa",
|
||||
"horizon.options.lightbar.dimension": "Dimensi",
|
||||
"horizon.options.glyphs.auto": "Otomatis",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.menu_pointer": "Penunjuk",
|
||||
"horizon.options.keyboard.auto": "Otomatis",
|
||||
"horizon.options.unavailable": "Fitur ini belum tersedia di Horizon.",
|
||||
"horizon.options.search": "Cari pengaturan...",
|
||||
|
||||
@@ -11,14 +11,11 @@
|
||||
"horizon.options.move_deadzone": "Deadzone mmegharị",
|
||||
"horizon.options.look_deadzone": "Deadzone ilegharị anya",
|
||||
"horizon.options.button_glyphs": "Bọtịnụ Glyphs",
|
||||
"horizon.options.menu_navigation": "Ntugharị menu",
|
||||
"horizon.options.on_screen_keyboard": "Ahụigodo na ihuenyo",
|
||||
"horizon.options.chat_delay": "Nkata nkata",
|
||||
"horizon.options.lightbar.health": "Ahụike",
|
||||
"horizon.options.lightbar.dimension": "Akụkụ",
|
||||
"horizon.options.glyphs.auto": "Akpaaka",
|
||||
"horizon.options.menu_focus": "Lekwasị anya",
|
||||
"horizon.options.menu_pointer": "Ntuziaka",
|
||||
"horizon.options.keyboard.auto": "Akpaaka",
|
||||
"horizon.options.unavailable": "Njirimara a adịbeghị na Horizon.",
|
||||
"horizon.options.search": "Chọọ ntọala...",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Dauðasvæði hreyfingar",
|
||||
"horizon.options.look_deadzone": "Dauðasvæði sjónarhorns",
|
||||
"horizon.options.button_glyphs": "Hnappamerki",
|
||||
"horizon.options.menu_navigation": "Valmyndarleiðsögn",
|
||||
"horizon.options.on_screen_keyboard": "Skjályklaborð",
|
||||
"horizon.options.chat_delay": "Töf á spjalli",
|
||||
"horizon.options.lightbar.health": "Heilsa",
|
||||
"horizon.options.lightbar.dimension": "Stærð",
|
||||
"horizon.options.glyphs.auto": "Sjálfvirk",
|
||||
"horizon.options.menu_focus": "Einbeittu þér",
|
||||
"horizon.options.menu_pointer": "Bendill",
|
||||
"horizon.options.keyboard.auto": "Sjálfvirk",
|
||||
"horizon.options.unavailable": "Þessi eiginleiki er ekki enn í boði í Horizon.",
|
||||
"horizon.options.search": "Leitarstillingar...",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Zona morta del movimento",
|
||||
"horizon.options.look_deadzone": "Zona morta della visuale",
|
||||
"horizon.options.button_glyphs": "Glifi dei pulsanti",
|
||||
"horizon.options.menu_navigation": "Navigazione nel menu",
|
||||
"horizon.options.on_screen_keyboard": "Tastiera su schermo",
|
||||
"horizon.options.chat_delay": "Ritardo nella chat",
|
||||
"horizon.options.lightbar.health": "Salute",
|
||||
"horizon.options.lightbar.dimension": "Dimensione",
|
||||
"horizon.options.glyphs.auto": "Automatico",
|
||||
"horizon.options.menu_focus": "Concentrarsi",
|
||||
"horizon.options.menu_pointer": "Puntatore",
|
||||
"horizon.options.keyboard.auto": "Automatico",
|
||||
"horizon.options.keyboard.steam": "Vapore",
|
||||
"horizon.options.unavailable": "Questa funzionalità non è ancora disponibile in Horizon.",
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"horizon.options.move_deadzone": "移動のデッドゾーン",
|
||||
"horizon.options.look_deadzone": "視点のデッドゾーン",
|
||||
"horizon.options.button_glyphs": "ボタンのグリフ",
|
||||
"horizon.options.menu_navigation": "メニューナビゲーション",
|
||||
"horizon.options.on_screen_keyboard": "オンスクリーンキーボード",
|
||||
"horizon.options.chat_delay": "チャットの遅延",
|
||||
"horizon.options.lightbar.health": "健康",
|
||||
@@ -18,8 +17,6 @@
|
||||
"horizon.options.glyphs.auto": "自動",
|
||||
"horizon.options.glyphs.playstation": "プレイステーション",
|
||||
"horizon.options.glyphs.nintendo": "任天堂",
|
||||
"horizon.options.menu_focus": "フォーカス",
|
||||
"horizon.options.menu_pointer": "ポインタ",
|
||||
"horizon.options.keyboard.auto": "自動",
|
||||
"horizon.options.keyboard.steam": "蒸気",
|
||||
"horizon.options.unavailable": "この機能は Horizon ではまだ利用できません。",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "მოძრაობის მკვდარი ზონა",
|
||||
"horizon.options.look_deadzone": "ხედვის მკვდარი ზონა",
|
||||
"horizon.options.button_glyphs": "ღილაკების გლიფები",
|
||||
"horizon.options.menu_navigation": "მენიუს ნავიგაცია",
|
||||
"horizon.options.on_screen_keyboard": "ეკრანის კლავიატურა",
|
||||
"horizon.options.chat_delay": "ჩატის დაგვიანება",
|
||||
"horizon.options.lightbar.health": "ჯანმრთელობა",
|
||||
"horizon.options.lightbar.dimension": "განზომილება",
|
||||
"horizon.options.glyphs.auto": "ავტო",
|
||||
"horizon.options.menu_focus": "ფოკუსირება",
|
||||
"horizon.options.menu_pointer": "პოინტერი",
|
||||
"horizon.options.keyboard.auto": "ავტო",
|
||||
"horizon.options.unavailable": "ეს ფუნქცია ჯერ არ არის ხელმისაწვდომი Horizon-ში.",
|
||||
"horizon.options.search": "ძებნის პარამეტრები...",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Қозғалыс өлі аймағы",
|
||||
"horizon.options.look_deadzone": "Шолу өлі аймағы",
|
||||
"horizon.options.button_glyphs": "Түйме глифтері",
|
||||
"horizon.options.menu_navigation": "Мәзірді шарлау",
|
||||
"horizon.options.on_screen_keyboard": "Экрандық пернетақта",
|
||||
"horizon.options.chat_delay": "Чаттың кешігуі",
|
||||
"horizon.options.lightbar.health": "Денсаулық",
|
||||
"horizon.options.lightbar.dimension": "Өлшем",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Көрсеткіш",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.unavailable": "Бұл мүмкіндік Horizon ішінде әлі қолжетімді емес.",
|
||||
"horizon.options.search": "Параметрлерді іздеу...",
|
||||
|
||||
@@ -11,14 +11,11 @@
|
||||
"horizon.options.move_deadzone": "ಚಲನೆಯ ಡೆಡ್ಜೋನ್",
|
||||
"horizon.options.look_deadzone": "ಕ್ಯಾಮೆರಾ ಡೆಡ್ಜೋನ್",
|
||||
"horizon.options.button_glyphs": "ಬಟನ್ ಗ್ಲಿಫ್ಸ್",
|
||||
"horizon.options.menu_navigation": "ಮೆನು ನ್ಯಾವಿಗೇಷನ್",
|
||||
"horizon.options.on_screen_keyboard": "ಆನ್-ಸ್ಕ್ರೀನ್ ಕೀಬೋರ್ಡ್",
|
||||
"horizon.options.chat_delay": "ಚಾಟ್ ವಿಳಂಬ",
|
||||
"horizon.options.lightbar.health": "ಆರೋಗ್ಯ",
|
||||
"horizon.options.lightbar.dimension": "ಆಯಾಮ",
|
||||
"horizon.options.glyphs.auto": "ಆಟೋ",
|
||||
"horizon.options.menu_focus": "ಗಮನ",
|
||||
"horizon.options.menu_pointer": "ಪಾಯಿಂಟರ್",
|
||||
"horizon.options.keyboard.auto": "ಆಟೋ",
|
||||
"horizon.options.unavailable": "ಈ ವೈಶಿಷ್ಟ್ಯವು ಇನ್ನೂ Horizon ನಲ್ಲಿ ಲಭ್ಯವಿಲ್ಲ.",
|
||||
"horizon.options.search": "ಹುಡುಕಾಟ ಸೆಟ್ಟಿಂಗ್ಗಳು...",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "이동 데드존",
|
||||
"horizon.options.look_deadzone": "시점 데드존",
|
||||
"horizon.options.button_glyphs": "버튼 아이콘",
|
||||
"horizon.options.menu_navigation": "메뉴 탐색",
|
||||
"horizon.options.on_screen_keyboard": "화면 키보드",
|
||||
"horizon.options.chat_delay": "채팅 지연",
|
||||
"horizon.options.lightbar.health": "체력",
|
||||
"horizon.options.lightbar.dimension": "차원",
|
||||
"horizon.options.glyphs.auto": "자동",
|
||||
"horizon.options.menu_focus": "포커스",
|
||||
"horizon.options.menu_pointer": "포인터",
|
||||
"horizon.options.keyboard.auto": "자동",
|
||||
"horizon.options.unavailable": "이 기능은 아직 Horizon에서 사용할 수 없습니다.",
|
||||
"horizon.options.search": "설정 검색...",
|
||||
|
||||
@@ -9,14 +9,11 @@
|
||||
"horizon.options.move_deadzone": "Кыймылдын өлүк аймагы",
|
||||
"horizon.options.look_deadzone": "Камеранын өлүк аймагы",
|
||||
"horizon.options.button_glyphs": "Баскыч Glyphs",
|
||||
"horizon.options.menu_navigation": "Меню навигациясы",
|
||||
"horizon.options.on_screen_keyboard": "Экрандагы клавиатура",
|
||||
"horizon.options.chat_delay": "Маектешүү кечигүү",
|
||||
"horizon.options.lightbar.health": "Ден соолук",
|
||||
"horizon.options.lightbar.dimension": "Өлчөм",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Көрсөткүч",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.unavailable": "Бул функция азырынча Horizon ичинде жеткиликтүү эмес.",
|
||||
"horizon.options.search": "Издөө жөндөөлөрү...",
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"horizon.options.on_screen_keyboard": "De-Screen Cantiones",
|
||||
"horizon.options.chat_delay": "Curabitur Mora",
|
||||
"horizon.options.lightbar.health": "Salus",
|
||||
"horizon.options.menu_pointer": "Indicium",
|
||||
"horizon.options.unavailable": "Haec factura nondum est in promptuHorizon.",
|
||||
"horizon.options.search": "Quaerere occasus...",
|
||||
"horizon.pages.controller": "Controller Optiones",
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"horizon.options.move_deadzone": "Deadzone Beweegung",
|
||||
"horizon.options.look_deadzone": "Deadzone Kamera",
|
||||
"horizon.options.button_glyphs": "Knäppchen Glyphen",
|
||||
"horizon.options.menu_navigation": "Menu Navigatioun",
|
||||
"horizon.options.chat_delay": "Chat Verzögerung",
|
||||
"horizon.options.lightbar.health": "Gesondheet",
|
||||
"horizon.options.lightbar.dimension": "Dimensioun",
|
||||
|
||||
@@ -10,12 +10,10 @@
|
||||
"horizon.options.move_deadzone": "Deadzone beweging",
|
||||
"horizon.options.look_deadzone": "Deadzone kieke",
|
||||
"horizon.options.button_glyphs": "Knopglyphe",
|
||||
"horizon.options.menu_navigation": "Menunavigatie",
|
||||
"horizon.options.on_screen_keyboard": "Toetsenbord op scherm",
|
||||
"horizon.options.chat_delay": "Chatvertraging",
|
||||
"horizon.options.lightbar.health": "Gezondheid",
|
||||
"horizon.options.lightbar.dimension": "Dimensie",
|
||||
"horizon.options.menu_pointer": "Aanwizer",
|
||||
"horizon.options.unavailable": "Deze functie is nog neet besjikbaar in Horizon.",
|
||||
"horizon.options.search": "Zoek insjtellinge...",
|
||||
"horizon.pages.shader_options": "Shader-opties",
|
||||
|
||||
Loaded 100 of 507 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user