mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge main into fix/item-tnt-block-speed
The soul sand/honey TNT test gets main's new PrimedTnt fields (rotation, block_state). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
ef4e5c049d
609 files changed
+29855
-4926
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:?}"
|
||||
@@ -378,6 +381,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
|
||||
@@ -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(),
|
||||
@@ -257,19 +275,34 @@ pub fn temp_folder() -> std::io::Result<PathBuf> {
|
||||
/// `PackSelectionScreen.onFilesDrop`'s `PackDetector`: a zip is a pack; a
|
||||
/// folder is one when it holds a `pack.mcmeta`, or exactly one folder that
|
||||
/// does (a folder zipped by the system, which adds a wrapper). Copied into
|
||||
/// `dir`; false when `path` is no pack.
|
||||
/// `dir`; false when `path` is no pack, or when part of it could not be copied.
|
||||
pub fn copy_pack(path: &Path, dir: &Path) -> bool {
|
||||
fn copy_dir(from: &Path, at: &Path) {
|
||||
let _ = std::fs::create_dir_all(at);
|
||||
fn copy_dir(from: &Path, at: &Path) -> bool {
|
||||
let mut whole = true;
|
||||
if let Err(e) = std::fs::create_dir_all(at) {
|
||||
log::warn!("Horizon: {} is not made: {e}", at.display());
|
||||
whole = false;
|
||||
}
|
||||
for entry in std::fs::read_dir(from).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
let to = at.join(entry.file_name());
|
||||
if path.is_dir() {
|
||||
copy_dir(&path, &to);
|
||||
let copied = if path.is_dir() {
|
||||
copy_dir(&path, &to)
|
||||
} else {
|
||||
let _ = std::fs::copy(&path, &to);
|
||||
}
|
||||
match std::fs::copy(&path, &to) {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Horizon: {} is not copied into the pack: {e}",
|
||||
path.display()
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
whole &= copied;
|
||||
}
|
||||
whole
|
||||
}
|
||||
let name = || {
|
||||
path.file_name()
|
||||
@@ -289,8 +322,7 @@ pub fn copy_pack(path: &Path, dir: &Path) -> bool {
|
||||
return false;
|
||||
}
|
||||
if path.join("pack.mcmeta").is_file() {
|
||||
copy_dir(path, &dir.join(name()));
|
||||
return true;
|
||||
return copy_dir(path, &dir.join(name()));
|
||||
}
|
||||
let own: Vec<PathBuf> = std::fs::read_dir(path)
|
||||
.into_iter()
|
||||
@@ -300,10 +332,7 @@ pub fn copy_pack(path: &Path, dir: &Path) -> bool {
|
||||
.filter(|p| p.is_dir() && p.join("pack.mcmeta").is_file())
|
||||
.collect();
|
||||
match own.as_slice() {
|
||||
[one] => {
|
||||
copy_dir(one, &dir.join(one.file_name().unwrap_or_default()));
|
||||
true
|
||||
}
|
||||
[one] => copy_dir(one, &dir.join(one.file_name().unwrap_or_default())),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -896,6 +925,24 @@ mod tests {
|
||||
assert_eq!(content_id("data/mymod/data_maps/item/fuel.txt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_that_cannot_be_copied_is_reported() {
|
||||
let root = std::env::temp_dir().join(format!("hz-datapack-copy-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let pack = root.join("pack");
|
||||
std::fs::create_dir_all(pack.join("data")).unwrap();
|
||||
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
|
||||
std::fs::write(pack.join("data/functions.mcfunction"), "").unwrap();
|
||||
// A file where the pack's folder goes: every copy under it fails.
|
||||
let blocked = root.join("blocked");
|
||||
std::fs::write(&blocked, "").unwrap();
|
||||
assert!(!copy_pack(&pack, &blocked));
|
||||
let dir = root.join("packs");
|
||||
assert!(copy_pack(&pack, &dir));
|
||||
assert!(dir.join("pack/pack.mcmeta").is_file());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_packs_override_worldgen_in_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -485,6 +485,7 @@ impl Scene {
|
||||
let f = &self.frame;
|
||||
Frame {
|
||||
weather: &[],
|
||||
hand_items: &[],
|
||||
weather_time: crate::weather::Time::new(0, 0.0),
|
||||
camera: DVec3::from_array(f.camera),
|
||||
yaw: f.yaw,
|
||||
|
||||
@@ -60,6 +60,7 @@ struct Pipelines {
|
||||
luma_pyramid: wgpu::ComputePipeline,
|
||||
scd_histogram: wgpu::ComputePipeline,
|
||||
scd_divergence: wgpu::ComputePipeline,
|
||||
scd_finalize: wgpu::ComputePipeline,
|
||||
search: wgpu::ComputePipeline,
|
||||
filter: wgpu::ComputePipeline,
|
||||
scale: wgpu::ComputePipeline,
|
||||
@@ -84,6 +85,7 @@ pub struct OpticalFlow {
|
||||
frames: [Frame; 2],
|
||||
filter: [wgpu::BindGroup; LEVELS],
|
||||
scd_divergence: wgpu::BindGroup,
|
||||
scd_finalize: wgpu::BindGroup,
|
||||
scd_histogram: wgpu::Buffer,
|
||||
scd_previous_histogram: wgpu::Buffer,
|
||||
scd_temp: wgpu::Buffer,
|
||||
@@ -183,6 +185,7 @@ impl OpticalFlow {
|
||||
luma_pyramid: pipeline("luma_pyramid"),
|
||||
scd_histogram: pipeline("scd_histogram_pass"),
|
||||
scd_divergence: pipeline("scd_divergence_pass"),
|
||||
scd_finalize: pipeline("scd_finalize_pass"),
|
||||
search: pipeline("search_pass"),
|
||||
filter: pipeline("filter_flow"),
|
||||
scale: pipeline("scale_pass"),
|
||||
@@ -235,6 +238,7 @@ impl OpticalFlow {
|
||||
HISTOGRAM_WIDTH * 4,
|
||||
);
|
||||
let scd_temp = buffer(device, "Scene change temp", 12);
|
||||
let scd_next_histogram = buffer(device, "Next scene change histogram", HISTOGRAM_WIDTH * 4);
|
||||
let scd_output = buffer(device, "Scene change detection", 12);
|
||||
let frame = |current: usize| {
|
||||
let (cur, prev) = (&luma[current], &luma[1 - current]);
|
||||
@@ -298,11 +302,22 @@ impl OpticalFlow {
|
||||
let scd_divergence = bind(
|
||||
device,
|
||||
&pipelines.scd_divergence,
|
||||
&[
|
||||
(7, whole(&scd_histogram)),
|
||||
(8, whole(&scd_previous_histogram)),
|
||||
(9, whole(&scd_temp)),
|
||||
(11, whole(&scd_next_histogram)),
|
||||
],
|
||||
);
|
||||
let scd_finalize = bind(
|
||||
device,
|
||||
&pipelines.scd_finalize,
|
||||
&[
|
||||
(7, whole(&scd_histogram)),
|
||||
(8, whole(&scd_previous_histogram)),
|
||||
(9, whole(&scd_temp)),
|
||||
(10, whole(&scd_output)),
|
||||
(11, whole(&scd_next_histogram)),
|
||||
],
|
||||
);
|
||||
Self {
|
||||
@@ -312,6 +327,7 @@ impl OpticalFlow {
|
||||
frames,
|
||||
filter,
|
||||
scd_divergence,
|
||||
scd_finalize,
|
||||
scd_histogram,
|
||||
scd_previous_histogram,
|
||||
scd_temp,
|
||||
@@ -406,6 +422,7 @@ impl OpticalFlow {
|
||||
9,
|
||||
);
|
||||
run(&p.scd_divergence, &self.scd_divergence, 9, 3, 1);
|
||||
run(&p.scd_finalize, &self.scd_finalize, 9, 1, 1);
|
||||
for level in (0..LEVELS).rev() {
|
||||
let luma = self.display.map(|v| (v >> level).max(1));
|
||||
run(
|
||||
@@ -597,6 +614,22 @@ pub(super) mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn scd(device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow) -> u32 {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 12,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
|
||||
u32::from_le_bytes(bytes[4..8].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// A frame moved right by 16 and down by 8: the blocks inside report where they were
|
||||
/// (-16, -8), and nothing reads as a scene change. A much darker frame then does.
|
||||
#[test]
|
||||
@@ -630,24 +663,40 @@ pub(super) mod tests {
|
||||
inside.len(),
|
||||
&inside[..8]
|
||||
);
|
||||
let scd = |device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow| {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 12,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
|
||||
u32::from_le_bytes(bytes[4..8].try_into().unwrap())
|
||||
};
|
||||
assert_eq!(scd(&device, &queue, &flow) & 0xf, 0);
|
||||
run(&device, &queue, &mut flow, &other, false);
|
||||
assert_eq!(scd(&device, &queue, &flow) & 1, 1);
|
||||
assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_cuts_and_stable_frames_update_the_history() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let size = [384, 216];
|
||||
let bright = upload(
|
||||
&device,
|
||||
&queue,
|
||||
size,
|
||||
&vec![255; (size[0] * size[1] * 4) as usize],
|
||||
);
|
||||
let dark_pixels: Vec<u8> = (0..size[0] * size[1])
|
||||
.flat_map(|_| [0, 0, 0, 255])
|
||||
.collect();
|
||||
let dark = upload(&device, &queue, size, &dark_pixels);
|
||||
let mut flow = OpticalFlow::new(&device, size);
|
||||
run(&device, &queue, &mut flow, &bright, true);
|
||||
for _ in 0..6 {
|
||||
run(&device, &queue, &mut flow, &bright, false);
|
||||
}
|
||||
for frame in 0..16 {
|
||||
let color = if frame % 2 == 0 { &dark } else { &bright };
|
||||
run(&device, &queue, &mut flow, color, false);
|
||||
assert_eq!(scd(&device, &queue, &flow) & 1, 1, "cut {frame}");
|
||||
assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0]));
|
||||
run(&device, &queue, &mut flow, color, false);
|
||||
assert_eq!(scd(&device, &queue, &flow) & 1, 0, "stable frame {frame}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,10 +53,10 @@ struct Params {
|
||||
@group(0) @binding(8) var<storage, read_write> scd_previous_histogram: array<f32>;
|
||||
@group(0) @binding(9) var<storage, read_write> scd_temp: array<atomic<u32>, 3>;
|
||||
@group(0) @binding(10) var<storage, read_write> scd_output: array<atomic<u32>, 3>;
|
||||
@group(0) @binding(11) var<storage, read_write> scd_next_histogram: array<f32>;
|
||||
|
||||
const SCD_OUTPUT_SCENE_CHANGE_SLOT: u32 = 0u;
|
||||
const SCD_OUTPUT_HISTORY_BITS_SLOT: u32 = 1u;
|
||||
const SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT: u32 = 2u;
|
||||
|
||||
fn load_luma(t: texture_2d<f32>, p: vec2<i32>) -> u32 {
|
||||
let size = vec2<i32>(textureDimensions(t));
|
||||
@@ -266,7 +266,6 @@ fn scd_divergence_pass(
|
||||
@builtin(local_invocation_index) local_index: u32,
|
||||
) {
|
||||
let factor = 1000000.0;
|
||||
let where_to_stop = 3u * 9u - 1u;
|
||||
let histogram_count = 9.0;
|
||||
let kernel = array(0.0088122291, 0.027143577, 0.065114059, 0.12164907, 0.17699835, 0.20056541);
|
||||
let li = i32(local_index);
|
||||
@@ -326,27 +325,32 @@ fn scd_divergence_pass(
|
||||
let res_float = 1.0 - exp(-(abs(sum.x) + abs(sum.y)));
|
||||
let res_uint = u32((res_float / histogram_count) * factor);
|
||||
atomicAdd(&scd_temp[gid.y], res_uint);
|
||||
let finished = atomicAdd(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 1u);
|
||||
if finished == where_to_stop {
|
||||
let res0 = atomicLoad(&scd_temp[0]);
|
||||
let res1 = atomicLoad(&scd_temp[1]);
|
||||
let res2 = atomicLoad(&scd_temp[2]);
|
||||
let scene_change = f32(min(res0, min(res1, res2))) / factor;
|
||||
var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u;
|
||||
if scene_change > 0.45 {
|
||||
history |= 1u;
|
||||
}
|
||||
atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast<u32>(scene_change));
|
||||
atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history);
|
||||
atomicStore(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 0u);
|
||||
atomicStore(&scd_temp[0], 0u);
|
||||
atomicStore(&scd_temp[1], 0u);
|
||||
atomicStore(&scd_temp[2], 0u);
|
||||
}
|
||||
}
|
||||
if gid.y == 1u {
|
||||
scd_previous_histogram[gid.x] = current;
|
||||
atomicStore(&scd_histogram[gid.x], 0u);
|
||||
scd_next_histogram[gid.x] = current;
|
||||
}
|
||||
}
|
||||
|
||||
// All divergence workgroups must finish reading both histograms before either is
|
||||
// replaced. A separate dispatch also makes every partial sum visible to the reducer.
|
||||
@compute @workgroup_size(256)
|
||||
fn scd_finalize_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
scd_previous_histogram[gid.x] = scd_next_histogram[gid.x];
|
||||
atomicStore(&scd_histogram[gid.x], 0u);
|
||||
if gid.x == 0u {
|
||||
let res0 = atomicLoad(&scd_temp[0]);
|
||||
let res1 = atomicLoad(&scd_temp[1]);
|
||||
let res2 = atomicLoad(&scd_temp[2]);
|
||||
let scene_change = f32(min(res0, min(res1, res2))) / 1000000.0;
|
||||
var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u;
|
||||
if scene_change > 0.45 {
|
||||
history |= 1u;
|
||||
}
|
||||
atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast<u32>(scene_change));
|
||||
atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history);
|
||||
atomicStore(&scd_temp[0], 0u);
|
||||
atomicStore(&scd_temp[1], 0u);
|
||||
atomicStore(&scd_temp[2], 0u);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
//! each in one draw (the pipelines cull the faces turned away), with the origin
|
||||
//! the walk would give it: `resident::origin`'s f32 bits, copied, never
|
||||
//! computed on the GPU. Within the render distance the CPU's walk then lists
|
||||
//! only the sections whose translucent quads it draws, or that have no record
|
||||
//! (`Resident::walk`); past it, it lists every section as before.
|
||||
//! Unlike the walk, the pass draws sections fading in or out whole.
|
||||
//! only the sections whose translucent quads it draws, that have no record, or
|
||||
//! that fade (`Resident::walk`); past it, it lists every section as before. A
|
||||
//! section whose instance carries a fade, fading in or out, has its record
|
||||
//! flagged (`FADING`, gfx.rs `mark_fades`) until it is whole: the pass leaves
|
||||
//! it out, and the CPU draws it with its fade.
|
||||
//!
|
||||
//! The graph is the one the walk reads that frame (`occlusion::Reach`). Its
|
||||
//! search keeps the cells it reached as bits too, and `Occlusion` a generation
|
||||
@@ -59,6 +61,7 @@ use super::{
|
||||
upload::Uploads,
|
||||
};
|
||||
use glam::{DVec3, DVec4, Vec3};
|
||||
use std::{cmp::Reverse, collections::BinaryHeap, time::Instant};
|
||||
|
||||
/// One section (`gpu_culling.wgsl`'s `Section`).
|
||||
#[repr(C)]
|
||||
@@ -68,10 +71,14 @@ pub(super) struct Record {
|
||||
key: [i32; 4],
|
||||
/// The first vertex of its solid quads (a draw's base vertex, an i32: a
|
||||
/// page holds fewer vertices), its solid quads, its box (`rounded_box`),
|
||||
/// then a word kept at 0.
|
||||
/// then its flags (`FADING`).
|
||||
a: [u32; 4],
|
||||
}
|
||||
|
||||
/// In `Record::a[3]`: the CPU draws the section's solid quads while it fades
|
||||
/// (gfx.rs `SectionGpu::gpu_fading`), and the pass leaves it out.
|
||||
const FADING: u32 = 1;
|
||||
|
||||
impl Record {
|
||||
const FREE: Self = Self {
|
||||
key: [0, 0, 0, -1],
|
||||
@@ -86,9 +93,19 @@ impl Record {
|
||||
a: [start as u32, quads, boxed, 0],
|
||||
}
|
||||
}
|
||||
/// The record, `FADING` when `on`.
|
||||
pub fn fading(mut self, on: bool) -> Self {
|
||||
if on {
|
||||
self.a[3] |= FADING;
|
||||
}
|
||||
self
|
||||
}
|
||||
fn page(&self) -> Option<u32> {
|
||||
u32::try_from(self.key[3]).ok()
|
||||
}
|
||||
fn is_fading(&self) -> bool {
|
||||
(self.a[3] & FADING) != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// `rounded_box` for bounds past what a box's codes hold, or not finite: no
|
||||
@@ -359,6 +376,10 @@ pub(super) fn supported(
|
||||
// The pass binds 8, the last cave culling's graph.
|
||||
&& limits.max_storage_buffers_per_shader_stage >= 8
|
||||
&& !super::flat::is_broken_intel_vulkan(info)
|
||||
// DX12's count draws leave each draw's base vertex out of `vertex_index`, by which the
|
||||
// pulled solid pipelines (those of a device with flat page groups) read the page.
|
||||
// shortcut: not offered there, until GPU culling's draws carry their base vertex otherwise.
|
||||
&& !(info.backend == wgpu::Backend::Dx12 && flat_min_sections.is_some())
|
||||
}
|
||||
|
||||
/// A buffer that is recreated larger when it must hold more.
|
||||
@@ -419,6 +440,8 @@ pub(super) struct Records {
|
||||
all_dirty: bool,
|
||||
/// Per arena page, the records on it.
|
||||
page_sections: Vec<u32>,
|
||||
/// The records flagged `FADING`.
|
||||
fading: u32,
|
||||
}
|
||||
|
||||
impl Records {
|
||||
@@ -435,6 +458,7 @@ impl Records {
|
||||
};
|
||||
self.records[id as usize] = record;
|
||||
self.add_to(record.key[3] as u32);
|
||||
self.fading += u32::from(record.is_fading());
|
||||
self.mark(id);
|
||||
id
|
||||
}
|
||||
@@ -455,6 +479,7 @@ impl Records {
|
||||
continue;
|
||||
};
|
||||
self.page_sections[page as usize] -= 1;
|
||||
self.fading -= u32::from(self.records[id as usize].is_fading());
|
||||
self.records[id as usize] = Record::FREE;
|
||||
// shortcut: a teleport frees more than `DIRTY_LIMIT`, so `prepare` writes the
|
||||
// whole table (0.6-2.6 ms at render distance 64); upgrade to a list of freed ids
|
||||
@@ -502,6 +527,22 @@ impl Records {
|
||||
self.add_to(page);
|
||||
self.mark(id);
|
||||
}
|
||||
/// The section with `id` began to draw faded, or stopped (gfx.rs
|
||||
/// `SectionGpu::gpu_fading`): its record is `FADING` when `on`.
|
||||
pub fn set_fading(&mut self, id: u32, on: bool) {
|
||||
let page = self.records.get(id as usize).and_then(Record::page);
|
||||
debug_assert!(page.is_some(), "no record {id} to flag");
|
||||
if page.is_none() || self.records[id as usize].is_fading() == on {
|
||||
return;
|
||||
}
|
||||
self.records[id as usize].a[3] ^= FADING;
|
||||
if on {
|
||||
self.fading += 1;
|
||||
} else {
|
||||
self.fading -= 1;
|
||||
}
|
||||
self.mark(id);
|
||||
}
|
||||
/// Every section went.
|
||||
pub fn clear(&mut self) {
|
||||
self.records.clear();
|
||||
@@ -510,12 +551,17 @@ impl Records {
|
||||
self.dirty = Vec::new();
|
||||
self.all_dirty = false;
|
||||
self.page_sections.clear();
|
||||
self.fading = 0;
|
||||
}
|
||||
/// The sections with a record.
|
||||
pub fn live(&self) -> usize {
|
||||
let free: u32 = self.free.iter().map(|word| word.count_ones()).sum();
|
||||
self.records.len() - free as usize
|
||||
}
|
||||
/// The sections whose record is `FADING`, which the CPU draws.
|
||||
pub fn fading(&self) -> u32 {
|
||||
self.fading
|
||||
}
|
||||
/// The record of the section with `id`; `None` for a free id.
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
pub fn at(&self, id: u32) -> Option<&Record> {
|
||||
@@ -549,6 +595,42 @@ impl Records {
|
||||
}
|
||||
}
|
||||
|
||||
/// The flagged sections by when their fade is over at the latest (gfx.rs
|
||||
/// `SectionGpu::fade_end`), for gfx.rs `mark_fades` to look at them again
|
||||
/// then, and the chunk fade-in time their flags were decided with: `None`
|
||||
/// until a frame has decided them, just after GPU culling was switched on.
|
||||
/// The ends of sections that went or were handed back are skipped as they come.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Fades {
|
||||
due: BinaryHeap<Reverse<(Instant, SectionKey)>>,
|
||||
pub fade_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl Fades {
|
||||
/// The section at `key` is looked at again at `end`.
|
||||
pub fn push(&mut self, end: Instant, key: SectionKey) {
|
||||
self.due.push(Reverse((end, key)));
|
||||
}
|
||||
/// The section of the earliest end at or before `now`, taken out.
|
||||
pub fn pop_due(&mut self, now: Instant) -> Option<SectionKey> {
|
||||
let &Reverse((end, key)) = self.due.peek()?;
|
||||
if end > now {
|
||||
return None;
|
||||
}
|
||||
self.due.pop();
|
||||
Some(key)
|
||||
}
|
||||
/// Every end goes; `fade_ms` stays.
|
||||
pub fn clear(&mut self) {
|
||||
self.due.clear();
|
||||
}
|
||||
/// The sections an end is waited for.
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn pending(&self) -> impl Iterator<Item = SectionKey> {
|
||||
self.due.iter().map(|r| r.0.1)
|
||||
}
|
||||
}
|
||||
|
||||
/// The pass, its buffers and the records it draws from: made when GPU culling
|
||||
/// is switched on, dropped when it is switched off.
|
||||
pub(super) struct GpuCulling {
|
||||
@@ -574,6 +656,9 @@ pub(super) struct GpuCulling {
|
||||
/// that the arena's compaction moves records while it borrows the
|
||||
/// renderer's other fields.
|
||||
pub records: Records,
|
||||
/// When the flagged records' fades end, a field of its own as `records`
|
||||
/// is: gfx.rs `mark_fades` takes both beside the renderer's sections.
|
||||
pub fades: Fades,
|
||||
/// This frame's pages with records: (page, first draw of its region, the
|
||||
/// region's size).
|
||||
pages: Vec<(u32, u32, u32)>,
|
||||
@@ -700,6 +785,7 @@ impl GpuCulling {
|
||||
reach_generation: None,
|
||||
group: None,
|
||||
records: Records::default(),
|
||||
fades: Fades::default(),
|
||||
pages: Vec::new(),
|
||||
ids: 0,
|
||||
n_updates: 0,
|
||||
@@ -975,8 +1061,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Where a section's solid quads are: key, arena page, first vertex, quads,
|
||||
/// and its box.
|
||||
type Placed = (SectionKey, u32, u64, u32, u32);
|
||||
/// its box, and whether it fades (`FADING`).
|
||||
type Placed = (SectionKey, u32, u64, u32, u32, bool);
|
||||
|
||||
/// `gpu_culling.wgsl`'s `near`, on the CPU.
|
||||
fn near(params: &Params, x: i32, z: i32) -> bool {
|
||||
@@ -1025,7 +1111,7 @@ mod tests {
|
||||
/// (and the pages `prepare` gives them).
|
||||
fn keeps(params: &Params, words: &[u32], record: &Record) -> bool {
|
||||
let (key, a) = (record.key, record.a);
|
||||
if key[3] < 0 || a[1] == 0 || !near(params, key[0], key[2]) {
|
||||
if key[3] < 0 || a[1] == 0 || (a[3] & FADING) != 0 || !near(params, key[0], key[2]) {
|
||||
return false;
|
||||
}
|
||||
let c: [u32; 3] = std::array::from_fn(|i| key[i].wrapping_sub(params.first[i]) as u32);
|
||||
@@ -1099,10 +1185,13 @@ mod tests {
|
||||
Some([min, max])
|
||||
}
|
||||
|
||||
/// Ids, page counts and changes against a plain list of what each id holds:
|
||||
/// the lowest free id comes back, free ids at the end leave, counts follow
|
||||
/// moves (to pages past the last too), a move keeps the box, a free is a
|
||||
/// change, and more changes than updates take mark the whole table.
|
||||
/// Ids, page counts, flags and changes against a plain list of what each id
|
||||
/// holds: the lowest free id comes back, flagged only if inserted so, free
|
||||
/// ids at the end leave, counts follow moves (to pages past the last too), a
|
||||
/// move keeps the box and the flag, a free is a change, a flag set changes
|
||||
/// its record only when it differs, the flagged ones are counted through
|
||||
/// inserts, flags, removals and clears, and more changes than updates take
|
||||
/// mark the whole table.
|
||||
#[test]
|
||||
fn records_keep_ids_page_counts_and_moves() {
|
||||
let mut records = Records::default();
|
||||
@@ -1140,7 +1229,7 @@ mod tests {
|
||||
};
|
||||
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
|
||||
// What each id holds; `None`: a free id.
|
||||
let mut model: Vec<Option<Placed>> = vec![Some(((2, 0, 0), 1, 0, 3, 11))];
|
||||
let mut model: Vec<Option<Placed>> = vec![Some(((2, 0, 0), 1, 0, 3, 11, false))];
|
||||
for step in 0..4_000 {
|
||||
let live: Vec<u32> = (0..model.len() as u32)
|
||||
.filter(|&id| model[id as usize].is_some())
|
||||
@@ -1149,20 +1238,22 @@ mod tests {
|
||||
rng.below(4 + step / 500),
|
||||
u64::from(rng.below(1 << 20)) * 256,
|
||||
);
|
||||
match rng.below(5) {
|
||||
let dirty = records.dirty.len();
|
||||
match rng.below(6) {
|
||||
op if op < 2 || live.is_empty() => {
|
||||
let key = (rng.range(-9, 9), rng.range(0, 23), rng.range(-9, 9));
|
||||
let (quads, boxed) = (1 + rng.below(500), rng.below(1 << 30));
|
||||
let fading = rng.below(4) == 0;
|
||||
let lowest = model
|
||||
.iter()
|
||||
.position(Option::is_none)
|
||||
.unwrap_or(model.len());
|
||||
let record = Record::new(key, page, start, quads, boxed);
|
||||
let record = Record::new(key, page, start, quads, boxed).fading(fading);
|
||||
assert_eq!(records.insert(record) as usize, lowest);
|
||||
if lowest == model.len() {
|
||||
model.push(None);
|
||||
}
|
||||
model[lowest] = Some((key, page, start, quads, boxed));
|
||||
model[lowest] = Some((key, page, start, quads, boxed, fading));
|
||||
}
|
||||
op => {
|
||||
let id = live[rng.below(live.len() as u32) as usize];
|
||||
@@ -1170,6 +1261,12 @@ mod tests {
|
||||
if op < 4 {
|
||||
records.relocate(id, page, start);
|
||||
(held.1, held.2) = (page, start);
|
||||
} else if op == 4 {
|
||||
let on = rng.below(2) == 0;
|
||||
records.set_fading(id, on);
|
||||
let changed = usize::from(held.5 != on);
|
||||
assert_eq!(records.dirty.len(), dirty + changed, "step {step}");
|
||||
held.5 = on;
|
||||
} else {
|
||||
records.remove(id);
|
||||
// Marked: the GPU must have it free before the pass culls.
|
||||
@@ -1183,11 +1280,13 @@ mod tests {
|
||||
}
|
||||
assert_eq!(records.ids() as usize, model.len(), "step {step}");
|
||||
assert_eq!(records.live(), model.iter().flatten().count());
|
||||
let flagged = model.iter().flatten().filter(|held| held.5).count();
|
||||
assert_eq!(records.fading() as usize, flagged, "step {step}");
|
||||
let mut pages = vec![0; records.page_sections().len()];
|
||||
for (id, held) in model.iter().enumerate() {
|
||||
let record = held.map(|(key, page, start, quads, boxed)| {
|
||||
let record = held.map(|(key, page, start, quads, boxed, fading)| {
|
||||
pages[page as usize] += 1;
|
||||
Record::new(key, page, start, quads, boxed)
|
||||
Record::new(key, page, start, quads, boxed).fading(fading)
|
||||
});
|
||||
assert_eq!(
|
||||
records.at(id as u32),
|
||||
@@ -1204,16 +1303,17 @@ mod tests {
|
||||
let (mut each, mut all) = (Records::default(), Records::default());
|
||||
for n in 0..1_000 {
|
||||
let (key, page, start) = ((n % 31, n % 24, n / 31), n as u32 % 3, n as u64 * 256);
|
||||
let record = Record::new(key, page, start, 1, 0);
|
||||
let record = Record::new(key, page, start, 1, 0).fading(n % 7 == 0);
|
||||
assert_eq!(each.insert(record), all.insert(record));
|
||||
}
|
||||
let gone: Vec<u32> = (0..1_000).filter(|id| id % 3 != 1 || *id >= 900).collect();
|
||||
let counts = |r: &Records| (r.ids(), r.live(), r.fading());
|
||||
for ids in [&[1u32, 4, 898][..], &gone[..]] {
|
||||
for &id in ids {
|
||||
each.remove(id);
|
||||
}
|
||||
all.remove_all(ids.iter().copied());
|
||||
assert_eq!((all.ids(), all.live()), (each.ids(), each.live()));
|
||||
assert_eq!(counts(&all), counts(&each));
|
||||
assert_eq!(all.page_sections(), each.page_sections());
|
||||
assert_eq!(free_ids(&all), free_ids(&each));
|
||||
assert_eq!(all.dirty, each.dirty);
|
||||
@@ -1228,8 +1328,9 @@ mod tests {
|
||||
assert_eq!((all.ids(), all.live(), all.free.len()), (0, 0, 0));
|
||||
assert!(all.page_sections().iter().all(|&n| n == 0));
|
||||
|
||||
assert!(records.fading() > 0);
|
||||
records.clear();
|
||||
assert_eq!((records.ids(), records.live()), (0, 0));
|
||||
assert_eq!((records.ids(), records.live(), records.fading()), (0, 0, 0));
|
||||
assert!(records.page_sections().is_empty() && records.dirty.is_empty());
|
||||
for x in 0..DIRTY_LIMIT as i32 {
|
||||
records.insert(Record::new((x, 0, 0), 0, 0, 1, 0));
|
||||
@@ -1360,6 +1461,7 @@ mod tests {
|
||||
("UNBOUNDED", u64::from(UNBOUNDED)),
|
||||
("REACH", u64::from(REACH)),
|
||||
("SKY", u64::from(SKY)),
|
||||
("FADING", u64::from(FADING)),
|
||||
] {
|
||||
assert_eq!(constant(name), value, "{name}");
|
||||
}
|
||||
@@ -1784,7 +1886,9 @@ mod tests {
|
||||
/// reached or not: the pass reads the copy it holds when a frame brings the
|
||||
/// same graph (once through a bind group made anew), sends a new one after
|
||||
/// a frame the CPU draws, and makes its copy anew for one too large for its
|
||||
/// room.
|
||||
/// room. A fifth of the sections fade, flagged anew before each case
|
||||
/// (`FADING`, sent as updates or with the whole table): the pass leaves
|
||||
/// them to the CPU.
|
||||
#[test]
|
||||
fn the_gpu_draws_what_the_integer_test_keeps() {
|
||||
on_each_device(draws_match);
|
||||
@@ -1792,11 +1896,14 @@ mod tests {
|
||||
|
||||
fn draws_match(device: &wgpu::Device, queue: &Queue) {
|
||||
let mut rng = Rng(0x2545_F491_4F6C_DD1D);
|
||||
// The flags have their own sequence: the cases are otherwise as before.
|
||||
let mut flags = Rng(0x9E37_79B9_7F4A_7C15);
|
||||
let (min_y, height) = (-64, 24);
|
||||
let mut gpu = GpuCulling::new(device).expect("the pass");
|
||||
let mut uploads = Uploads::new(device);
|
||||
let mut held: BTreeMap<u32, Placed> = BTreeMap::new();
|
||||
let (mut camera, mut compared, mut culled) = (DVec3::ZERO, 0, 0);
|
||||
// Fading sections the frustum and the graph keep, left to the CPU.
|
||||
let (mut camera, mut compared, mut culled, mut faded) = (DVec3::ZERO, 0, 0, 0);
|
||||
let mut words = Vec::new();
|
||||
// Cave culling's graph while a case has one (its generation, from x and
|
||||
// z, radius, height and whether its search reached the sky) and its bits.
|
||||
@@ -1897,7 +2004,8 @@ mod tests {
|
||||
.records
|
||||
.insert(Record::new(key, page, start, quads, boxed));
|
||||
assert!(
|
||||
held.insert(id, (key, page, start, quads, boxed)).is_none(),
|
||||
held.insert(id, (key, page, start, quads, boxed, false))
|
||||
.is_none(),
|
||||
"id {id} twice"
|
||||
);
|
||||
};
|
||||
@@ -1912,6 +2020,13 @@ mod tests {
|
||||
place(&mut gpu, &mut held, &mut rng);
|
||||
}
|
||||
}
|
||||
// A fifth of the sections fade: the flags that change go up as
|
||||
// updates, or with the whole table (cases 4 and 18), and through
|
||||
// case 7's frame the CPU draws.
|
||||
for (&id, placed) in held.iter_mut() {
|
||||
placed.5 = flags.below(5) == 0;
|
||||
gpu.records.set_fading(id, placed.5);
|
||||
}
|
||||
let whole = gpu.records.all_dirty;
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let input = FrameInputs {
|
||||
@@ -1947,10 +2062,12 @@ mod tests {
|
||||
let mut params = tables(&input, &mut words).expect("the planes fit");
|
||||
params.pages = gpu.pass_pages;
|
||||
let mut expected = Vec::new();
|
||||
for &(key, page, start, quads, boxed) in held.values() {
|
||||
for &(key, page, start, quads, boxed, fading) in held.values() {
|
||||
let record = Record::new(key, page, start, quads, boxed);
|
||||
let kept = keeps(¶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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -366,6 +366,7 @@ impl NativeFrame {
|
||||
// the sun's azimuth while the sun is low.
|
||||
distance: None,
|
||||
unculled: None,
|
||||
every_face: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -76,14 +76,14 @@ pub struct WorldDrawn<'a> {
|
||||
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player and
|
||||
/// `(2 << 32) | id` for a dropped item (their entity ids), 0 for anything else. A run lasts
|
||||
/// until the next. Unlike `spans`, whose ids name a type, these follow one entity from
|
||||
/// frame to frame (API @API:world_drawn_motion@).
|
||||
/// frame to frame (API 371).
|
||||
pub motion: &'a [(u32, u64)],
|
||||
/// The shared atlas entity UVs point into.
|
||||
pub atlas: Option<&'a wgpu::Texture>,
|
||||
/// Quad indices (0 1 2 0 2 3 for each 4 vertices), for as many quads as `entities` holds.
|
||||
pub quad_indices: &'a wgpu::Buffer,
|
||||
/// The block textures the terrain samples, for drawing blocks with the player's own
|
||||
/// textures (`native_block_sprites` says where a block's are). API @API:native_block_sprites@.
|
||||
/// textures (`native_block_sprites` says where a block's are). API 380.
|
||||
pub block_sprites: BlockSpriteArrays<'a>,
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
|
||||
}
|
||||
|
||||
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
|
||||
/// runs, with no hand too. API @API:hand_drawn@.
|
||||
/// runs, with no hand too. API 364.
|
||||
#[inline(never)]
|
||||
pub fn native_hand_drawn(hand: &mut HandDrawn<'_>) {
|
||||
generated::dispatch_hand_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), hand);
|
||||
|
||||
@@ -806,12 +806,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn what_the_camera_stands_on_is_inside_the_shadow_cameras_depth() {
|
||||
let settings = Settings {
|
||||
resolution: 2048,
|
||||
distance: 128.0,
|
||||
solid_materials: Vec::new(),
|
||||
};
|
||||
let shadow = camera(&settings);
|
||||
let shadow = camera(&Settings::default());
|
||||
let (view, projection) = super::super::pack_uniforms::shadow_camera(
|
||||
glam::Vec3::new(0.3, 0.9, 0.0).normalize(),
|
||||
glam::DVec3::new(-2064.5, 246.0, -4304.5),
|
||||
@@ -827,11 +822,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_centre_stays_near_the_player_on_both_sides_of_zero() {
|
||||
let shadow = camera(&Settings {
|
||||
resolution: 2048,
|
||||
distance: 128.0,
|
||||
solid_materials: Vec::new(),
|
||||
});
|
||||
let shadow = camera(&Settings::default());
|
||||
let light = glam::Vec3::Y;
|
||||
for x in [5.5, -5.5, -2064.5, 2064.5] {
|
||||
let camera = glam::DVec3::new(x, 64.0, x);
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
//! The block atlas of shader packs (spec 5.1, `atlasSize` in 5.2): packs sample one 2D atlas
|
||||
//! with atlas coordinates, Horizon keeps block sprites in texture arrays. With a pack, every
|
||||
//! layer is copied into a grid, row by row from the top left, and an animated layer again
|
||||
//! whenever its frame changes.
|
||||
//! layer is copied into the atlas, and an animated layer again whenever its frame changes.
|
||||
//!
|
||||
//! Flat draws, which read a table per sprite layer, get the game's own layout
|
||||
//! ([`PackAtlas::stitched`]): every sprite at its own size, placed by vanilla's `Stitcher` with its
|
||||
//! padding; this gives the `atlasSize` and spans our probes read in Iris 1.11.7 (the padding stays
|
||||
//! empty for now). Other draws get a grid of array-sized cells ([`PackAtlas::new`]).
|
||||
|
||||
#[path = "pack_atlas/stitch.rs"]
|
||||
mod stitch;
|
||||
|
||||
pub(super) struct PackAtlas {
|
||||
texture: wgpu::Texture,
|
||||
@@ -14,6 +21,40 @@ pub(super) struct PackAtlas {
|
||||
split: u32,
|
||||
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
|
||||
pub separate_ao: bool,
|
||||
/// The stitched layout, when the atlas has one.
|
||||
stitched: Option<Stitched>,
|
||||
}
|
||||
|
||||
/// A stitched atlas: where each layer's sprite is.
|
||||
struct Stitched {
|
||||
/// Each layer's sprite at the first level: its top left corner and its side.
|
||||
rects: Vec<[u32; 3]>,
|
||||
}
|
||||
|
||||
/// The side a layer of `sprite`-wide arrays has in a stitched atlas: its own, when the arrays
|
||||
/// hold it scaled up by a power of two (else the array's).
|
||||
fn atlas_side(native: u32, sprite: u32) -> u32 {
|
||||
if native == 0
|
||||
|| native > sprite
|
||||
|| !sprite.is_multiple_of(native)
|
||||
|| !(sprite / native).is_power_of_two()
|
||||
{
|
||||
sprite
|
||||
} else {
|
||||
native
|
||||
}
|
||||
}
|
||||
|
||||
/// `SpriteLoader.stitch`'s mip level: the option, lowered to fit the smallest sprite.
|
||||
fn stitch_mip_level(sides: &[u32], option: u32) -> u32 {
|
||||
let mut lowest = 1u32 << option.min(16);
|
||||
let mut smallest = u32::MAX;
|
||||
for &side in sides {
|
||||
smallest = smallest.min(side);
|
||||
lowest = lowest.min(side & side.wrapping_neg());
|
||||
}
|
||||
let level = smallest.min(lowest).max(1).ilog2();
|
||||
level.min(option)
|
||||
}
|
||||
|
||||
impl PackAtlas {
|
||||
@@ -26,6 +67,37 @@ impl PackAtlas {
|
||||
split: u32,
|
||||
layers: u32,
|
||||
sprite: u32,
|
||||
) -> Option<Self> {
|
||||
Self::with_levels(
|
||||
device,
|
||||
encoder,
|
||||
arrays,
|
||||
split,
|
||||
layers,
|
||||
sprite,
|
||||
arrays[0].mip_level_count(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Encoded material channels must select an existing texel, including at distance.
|
||||
pub fn material(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
split: u32,
|
||||
layers: u32,
|
||||
) -> Option<Self> {
|
||||
Self::with_levels(device, encoder, arrays, split, layers, arrays[0].width(), 1)
|
||||
}
|
||||
|
||||
fn with_levels(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
split: u32,
|
||||
layers: u32,
|
||||
sprite: u32,
|
||||
levels: u32,
|
||||
) -> Option<Self> {
|
||||
let layers = layers.max(1);
|
||||
let columns = (f64::from(layers).sqrt().ceil() as u32).max(1);
|
||||
@@ -35,7 +107,7 @@ impl PackAtlas {
|
||||
return None;
|
||||
}
|
||||
// Levels past a sprite's single pixel would mix neighbours: stop there.
|
||||
let levels = arrays[0].mip_level_count().min(sprite.ilog2() + 1);
|
||||
let levels = levels.min(sprite.ilog2() + 1);
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack block atlas"),
|
||||
size: wgpu::Extent3d {
|
||||
@@ -60,11 +132,102 @@ impl PackAtlas {
|
||||
sprite,
|
||||
split,
|
||||
separate_ao: false,
|
||||
stitched: None,
|
||||
};
|
||||
atlas.copy(encoder, arrays, 0..layers);
|
||||
Some(atlas)
|
||||
}
|
||||
|
||||
/// Every layer of `arrays` at its own size (`native`, its side before the arrays scaled it up
|
||||
/// to `sprite`), placed as vanilla's `Stitcher` places them with the padding of the Mipmap
|
||||
/// Levels option and the anisotropy bit; `None` when they don't fit the device.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn stitched(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
split: u32,
|
||||
sprite: u32,
|
||||
native: &[u32],
|
||||
mip_option: u32,
|
||||
anisotropy_bit: u32,
|
||||
) -> Option<Self> {
|
||||
let sides: Vec<u32> = if native.is_empty() {
|
||||
vec![sprite]
|
||||
} else {
|
||||
native.iter().map(|&n| atlas_side(n, sprite)).collect()
|
||||
};
|
||||
// A level of the atlas comes from the arrays' level as many halvings further down.
|
||||
let array_levels = arrays[0].mip_level_count();
|
||||
let deepest = sides
|
||||
.iter()
|
||||
.map(|&s| (sprite / s).ilog2())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let mip =
|
||||
stitch_mip_level(&sides, mip_option).min(array_levels.saturating_sub(deepest + 1));
|
||||
let max = device.limits().max_texture_dimension_2d;
|
||||
let layout = stitch::stitch(&sides, mip, anisotropy_bit, max)?;
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack block atlas"),
|
||||
size: wgpu::Extent3d {
|
||||
width: layout.width.max(1),
|
||||
height: layout.height.max(1),
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: mip + 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_DST
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
let rects = layout
|
||||
.holders
|
||||
.iter()
|
||||
.zip(&sides)
|
||||
.map(|(&[x, y], &side)| [x + layout.padding, y + layout.padding, side])
|
||||
.collect::<Vec<_>>();
|
||||
let layers = rects.len() as u32;
|
||||
let atlas = Self {
|
||||
texture,
|
||||
view,
|
||||
columns: 1,
|
||||
sprite,
|
||||
split,
|
||||
separate_ao: false,
|
||||
stitched: Some(Stitched { rects }),
|
||||
};
|
||||
atlas.copy(encoder, arrays, 0..layers);
|
||||
Some(atlas)
|
||||
}
|
||||
|
||||
/// Whether the sprites keep their own sizes ([`PackAtlas::stitched`]).
|
||||
pub fn is_stitched(&self) -> bool {
|
||||
self.stitched.is_some()
|
||||
}
|
||||
|
||||
/// The table flat terrain reads (`hz_sprite_rects`): per layer, its sprite's top left corner
|
||||
/// (x | y << 16) and side, in atlas pixels.
|
||||
pub fn rects_table(&self) -> Vec<u32> {
|
||||
if let Some(stitched) = &self.stitched {
|
||||
return stitched
|
||||
.rects
|
||||
.iter()
|
||||
.flat_map(|&[x, y, side]| [x | (y << 16), side])
|
||||
.collect();
|
||||
}
|
||||
(0..self.columns * self.texture.height() / self.sprite)
|
||||
.flat_map(|layer| {
|
||||
let (x, y) = (layer % self.columns, layer / self.columns);
|
||||
[(x * self.sprite) | ((y * self.sprite) << 16), self.sprite]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The atlas's size in pixels (`atlasSize`).
|
||||
pub fn size(&self) -> [u32; 2] {
|
||||
[self.texture.width(), self.texture.height()]
|
||||
@@ -81,13 +244,26 @@ impl PackAtlas {
|
||||
let Some(array) = arrays.get((layer / self.split) as usize) else {
|
||||
continue;
|
||||
};
|
||||
let (column, row) = (layer % self.columns, layer / self.columns);
|
||||
// Where the sprite goes, and from which array level: a stitched sprite keeps its own
|
||||
// side, which the arrays hold that many halvings down.
|
||||
let [x, y, side] = match &self.stitched {
|
||||
Some(s) => match s.rects.get(layer as usize) {
|
||||
Some(&rect) => rect,
|
||||
None => continue,
|
||||
},
|
||||
None => [
|
||||
layer % self.columns * self.sprite,
|
||||
layer / self.columns * self.sprite,
|
||||
self.sprite,
|
||||
],
|
||||
};
|
||||
let from = (self.sprite / side).ilog2();
|
||||
for level in 0..self.texture.mip_level_count() {
|
||||
let edge = self.sprite >> level;
|
||||
let edge = side >> level;
|
||||
encoder.copy_texture_to_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: array,
|
||||
mip_level: level,
|
||||
mip_level: from + level,
|
||||
origin: wgpu::Origin3d {
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -99,8 +275,8 @@ impl PackAtlas {
|
||||
texture: &self.texture,
|
||||
mip_level: level,
|
||||
origin: wgpu::Origin3d {
|
||||
x: column * edge,
|
||||
y: row * edge,
|
||||
x: x >> level,
|
||||
y: y >> level,
|
||||
z: 0,
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
@@ -173,7 +349,8 @@ impl PackAtlas {
|
||||
// vertices (`TerrainVertex`, six words each).
|
||||
declarations.push_str(
|
||||
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
|
||||
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n",
|
||||
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n\
|
||||
layout(set = 3, binding = 2) readonly buffer HzSpriteRects { uvec2 hz_sprite_rects[]; };\n",
|
||||
);
|
||||
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
|
||||
} else {
|
||||
@@ -181,11 +358,29 @@ impl PackAtlas {
|
||||
"hz_v_origin"
|
||||
};
|
||||
let [width, height] = self.size();
|
||||
let scale = [
|
||||
self.sprite as f32 / width as f32,
|
||||
self.sprite as f32 / height as f32,
|
||||
];
|
||||
// A sprite's cell in its own sides, and the share of the atlas a side is: from the table
|
||||
// in flat draws (`rects_table`), from the grid in others.
|
||||
let columns = self.columns;
|
||||
let (cell, side, scale) = if flat {
|
||||
(
|
||||
"uvec2 hz_rect = hz_sprite_rects[hz_layer];\n \
|
||||
hz_cell = vec2(float(hz_rect.x & 65535u), float(hz_rect.x >> 16u)) / float(hz_rect.y);\n "
|
||||
.to_owned(),
|
||||
"float(hz_rect.y)",
|
||||
[1.0 / width as f32, 1.0 / height as f32],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!(
|
||||
"hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n "
|
||||
),
|
||||
"1.0",
|
||||
[
|
||||
self.sprite as f32 / width as f32,
|
||||
self.sprite as f32 / height as f32,
|
||||
],
|
||||
)
|
||||
};
|
||||
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
|
||||
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1 unless separate_ao.
|
||||
let color_a = if self.separate_ao {
|
||||
@@ -196,8 +391,8 @@ impl PackAtlas {
|
||||
let body = format!(
|
||||
" vec4 hz_origin = {origin};\n \
|
||||
uint hz_layer = hz_v_layer & 32767u;\n \
|
||||
hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n \
|
||||
hz_scale = vec2({:?}, {:?});\n \
|
||||
{cell}\
|
||||
hz_scale = vec2({:?}, {:?}) * {side};\n \
|
||||
hz_Vertex = vec4(hz_v_pos + hz_origin.xyz, 1.0);\n \
|
||||
hz_uv_scale = hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w));\n \
|
||||
hz_mid = vec2(0.5);\n \
|
||||
@@ -252,6 +447,7 @@ impl PackAtlas {
|
||||
.collect(),
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,6 +533,119 @@ mod tests {
|
||||
assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]);
|
||||
}
|
||||
|
||||
/// An array of `sprite`-pixel layers (`sprite` the largest of `native`), as the game builds
|
||||
/// them: each layer scaled up by whole pixels at level 0, its own pixels from the level where
|
||||
/// it has its own size. Every pixel of a layer's own image differs: (layer, x, y, 255).
|
||||
pub(super) fn upscaled_array(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
native: &[u32],
|
||||
) -> wgpu::Texture {
|
||||
let sprite = native.iter().copied().max().unwrap();
|
||||
let levels = sprite.ilog2() + 1;
|
||||
let mut data = Vec::new();
|
||||
for (layer, &side) in native.iter().enumerate() {
|
||||
for level in 0..levels {
|
||||
let edge = sprite >> level;
|
||||
// Above its own size a level repeats each pixel; below, it shrinks to the first.
|
||||
let scale = (edge / side).max(1);
|
||||
for y in 0..edge {
|
||||
for x in 0..edge {
|
||||
let (x, y) = if edge >= side {
|
||||
(x / scale, y / scale)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
data.extend([layer as u8 * 50, x as u8 * 16, y as u8 * 16, 255]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
wgpu::util::DeviceExt::create_texture_with_data(
|
||||
device,
|
||||
queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: sprite,
|
||||
height: sprite,
|
||||
depth_or_array_layers: native.len() as u32,
|
||||
},
|
||||
mip_level_count: levels,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&data,
|
||||
)
|
||||
}
|
||||
|
||||
/// Sprites of 2, 4 and 1 px in a 4 px array, no mipmaps (one pixel of padding). Worked through
|
||||
/// vanilla's `Stitcher` by hand: holders of 6, 4 and 3 px at (0, 0), (8, 0) and (8, 4) in a 16
|
||||
/// by 8 atlas, each sprite with its own pixels.
|
||||
#[test]
|
||||
fn stitched_sprites_keep_their_own_size() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let array = upscaled_array(&device, &queue, &[2, 4, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let atlas =
|
||||
PackAtlas::stitched(&device, &mut encoder, &[array], 3, 4, &[2, 4, 1], 0, 0).unwrap();
|
||||
assert_eq!(atlas.size(), [16, 8]);
|
||||
assert_eq!(
|
||||
atlas.rects_table(),
|
||||
[9 | 1 << 16, 2, 1 | 1 << 16, 4, 9 | 5 << 16, 1]
|
||||
);
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256 * 8,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
atlas.texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: 16,
|
||||
height: 8,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = read.slice(..).get_mapped_range().unwrap();
|
||||
let at = |x: usize, y: usize| {
|
||||
let i = y * 256 + x * 4;
|
||||
[data[i], data[i + 1], data[i + 2], data[i + 3]]
|
||||
};
|
||||
// Layer 0's own 2 px: (layer 0, x, y); its padding is empty.
|
||||
assert_eq!(
|
||||
[at(9, 1), at(10, 1), at(10, 2)],
|
||||
[[0, 0, 0, 255], [0, 16, 0, 255], [0, 16, 16, 255]]
|
||||
);
|
||||
assert_eq!([at(8, 0), at(11, 3), at(11, 1)], [[0; 4]; 3]);
|
||||
// Layer 1 fills its 4 px; layer 2 is one pixel.
|
||||
assert_eq!(
|
||||
[at(1, 1), at(4, 4), at(9, 5), at(10, 6)],
|
||||
[[50, 0, 0, 255], [50, 48, 48, 255], [100, 0, 0, 255], [0; 4]]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terrain_inputs_respects_separate_ao() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
@@ -366,6 +675,7 @@ mod tests {
|
||||
sprite: 16,
|
||||
split: 1,
|
||||
separate_ao: false,
|
||||
stitched: None,
|
||||
};
|
||||
let inputs_default = atlas.terrain_inputs(false);
|
||||
assert!(
|
||||
|
||||
@@ -123,6 +123,54 @@ fn mid_tex_coord_follows_each_quads_mapped_corners() {
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// A stitched atlas (the one `stitched_sprites_keep_their_own_size` works out: 16 by
|
||||
/// 8, sprites of 2, 4 and 1 px at (9, 1), (1, 1) and (9, 5)): flat draws map a quad's UVs onto its
|
||||
/// sprite's own pixels, so a corner is half the sprite's own side from `mc_midTexCoord`.
|
||||
#[test]
|
||||
fn flat_draws_map_uvs_onto_a_stitched_sprite() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let array = super::tests::upscaled_array(&device, &queue, &[2, 4, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let atlas =
|
||||
PackAtlas::stitched(&device, &mut encoder, &[array], 3, 4, &[2, 4, 1], 0, 0).unwrap();
|
||||
queue.submit([encoder.finish()]);
|
||||
let whole = [[0., 0.], [1., 0.], [1., 1.], [0., 1.]];
|
||||
let positions = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]];
|
||||
let vertices: Vec<_> = (0..3)
|
||||
.flat_map(|layer| {
|
||||
(0..4).map(move |k| {
|
||||
TerrainVertex::new(positions[k], whole[k], layer as u16 | 0x8000, u32::MAX, 0)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Corner and side of each layer's sprite, in atlas pixels.
|
||||
let sprites = [([9., 1.], 2.), ([1., 1.], 4.), ([9., 5.], 1.)];
|
||||
let origin = f32::from_bits((1. / UV_STEPS).to_bits());
|
||||
let pixels = sample(&device, &queue, &atlas, &vertices, origin, [true, false]);
|
||||
let mut failures = Vec::new();
|
||||
for (layer, ([x, y], side)) in sprites.into_iter().enumerate() {
|
||||
let mid = [(x + side / 2.) / 16., (y + side / 2.) / 8.];
|
||||
for (corner, [u, v]) in whole.iter().enumerate() {
|
||||
let expected = [mid[0], mid[1], (x + u * side) / 16., (y + v * side) / 8.];
|
||||
let actual = pixels[4 * layer + corner];
|
||||
if actual
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.any(|(a, b)| (a - b).abs() > 1e-6)
|
||||
{
|
||||
failures.push(format!(
|
||||
"layer {layer} corner {corner}: {actual:?} != {expected:?}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
fn sample(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
@@ -183,7 +231,7 @@ fn sample(
|
||||
}],
|
||||
vec![storage(0), storage(1)],
|
||||
vec![],
|
||||
vec![storage(0), storage(1)],
|
||||
vec![storage(0), storage(1), storage(2)],
|
||||
];
|
||||
let layouts: Vec<_> = entries
|
||||
.iter()
|
||||
@@ -268,11 +316,15 @@ fn sample(
|
||||
bytemuck::cast_slice(vertices),
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
|
||||
);
|
||||
let rects = buffer(
|
||||
bytemuck::cast_slice(&atlas.rects_table()),
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
let bindings = [
|
||||
vec![(0, &uniform)],
|
||||
vec![(0, &slots), (1, &origin)],
|
||||
vec![],
|
||||
vec![(0, &ids), (1, &data)],
|
||||
vec![(0, &ids), (1, &data), (2, &rects)],
|
||||
];
|
||||
let groups: Vec<_> = bindings
|
||||
.iter()
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Where a shader pack's block atlas puts each sprite: vanilla's `Stitcher`, so every sprite keeps
|
||||
//! its own size and the atlas comes out as large as the game's (`atlasSize`). Holders are sorted
|
||||
//! by height, then width, then (where vanilla compares names) sprite layer.
|
||||
|
||||
/// The atlas a set of sprites stitches into.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(super) struct Stitched {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Each sprite's padded holder: its top left corner. The sprite itself starts `padding` in.
|
||||
pub holders: Vec<[u32; 2]>,
|
||||
pub padding: u32,
|
||||
}
|
||||
|
||||
/// `Stitcher.smallestFittingMinTexel`.
|
||||
fn fit(input: u32, mip_level: u32) -> u32 {
|
||||
let step = 1 << mip_level;
|
||||
input.div_ceil(step) * step
|
||||
}
|
||||
|
||||
/// `Mth.smallestEncompassingPowerOfTwo` (0 stays 0).
|
||||
fn power_of_two(input: u32) -> u32 {
|
||||
if input == 0 {
|
||||
0
|
||||
} else {
|
||||
input.next_power_of_two()
|
||||
}
|
||||
}
|
||||
|
||||
/// `Stitcher.Region`.
|
||||
struct Region {
|
||||
x: u32,
|
||||
y: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
holder: bool,
|
||||
sub: Vec<Region>,
|
||||
}
|
||||
|
||||
impl Region {
|
||||
fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
holder: false,
|
||||
sub: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `Region.add`: the holder's top left corner, when it fits.
|
||||
fn add(&mut self, [w, h]: [u32; 2]) -> Option<[u32; 2]> {
|
||||
if self.holder || w > self.width || h > self.height {
|
||||
return None;
|
||||
}
|
||||
if w == self.width && h == self.height {
|
||||
self.holder = true;
|
||||
return Some([self.x, self.y]);
|
||||
}
|
||||
if self.sub.is_empty() {
|
||||
let (x, y) = (self.x, self.y);
|
||||
self.sub.push(Region::new(x, y, w, h));
|
||||
let (spare_w, spare_h) = (self.width - w, self.height - h);
|
||||
if spare_h > 0 && spare_w > 0 {
|
||||
let right = self.height.max(spare_w);
|
||||
let bottom = self.width.max(spare_h);
|
||||
if right >= bottom {
|
||||
self.sub.push(Region::new(x, y + h, w, spare_h));
|
||||
self.sub.push(Region::new(x + w, y, spare_w, self.height));
|
||||
} else {
|
||||
self.sub.push(Region::new(x + w, y, spare_w, h));
|
||||
self.sub.push(Region::new(x, y + h, self.width, spare_h));
|
||||
}
|
||||
} else if spare_w == 0 {
|
||||
self.sub.push(Region::new(x, y + h, w, spare_h));
|
||||
} else if spare_h == 0 {
|
||||
self.sub.push(Region::new(x + w, y, spare_w, h));
|
||||
}
|
||||
}
|
||||
self.sub.iter_mut().find_map(|r| r.add([w, h]))
|
||||
}
|
||||
}
|
||||
|
||||
/// `Stitcher` over square sprites of `sides` pixels, with vanilla's padding for `mip_level` and
|
||||
/// `anisotropy_bit`; `None` when they don't fit in `max` by `max`.
|
||||
pub(super) fn stitch(
|
||||
sides: &[u32],
|
||||
mip_level: u32,
|
||||
anisotropy_bit: u32,
|
||||
max: u32,
|
||||
) -> Option<Stitched> {
|
||||
let padding = (1 << mip_level) << anisotropy_bit.saturating_sub(1).min(4);
|
||||
let mut order: Vec<usize> = (0..sides.len()).collect();
|
||||
let holder = |i: usize| fit(sides[i] + padding * 2, mip_level);
|
||||
order.sort_by_key(|&i| (std::cmp::Reverse(holder(i)), i));
|
||||
let (mut storage_x, mut storage_y) = (0u32, 0u32);
|
||||
let mut storage: Vec<Region> = Vec::new();
|
||||
let mut holders = vec![[0, 0]; sides.len()];
|
||||
for i in order {
|
||||
let size = [holder(i); 2];
|
||||
if let Some(at) = storage.iter_mut().find_map(|r| r.add(size)) {
|
||||
holders[i] = at;
|
||||
continue;
|
||||
}
|
||||
// `Stitcher.expand`.
|
||||
let (x_now, y_now) = (power_of_two(storage_x), power_of_two(storage_y));
|
||||
let (x_new, y_new) = (
|
||||
power_of_two(storage_x + size[0]),
|
||||
power_of_two(storage_y + size[1]),
|
||||
);
|
||||
let (x_can, y_can) = (x_new <= max, y_new <= max);
|
||||
if !x_can && !y_can {
|
||||
return None;
|
||||
}
|
||||
let (x_will, y_will) = (x_can && x_now != x_new, y_can && y_now != y_new);
|
||||
let grow_x = if x_will ^ y_will {
|
||||
x_will
|
||||
} else {
|
||||
x_can && x_now <= y_now
|
||||
};
|
||||
let mut slot = if grow_x {
|
||||
if storage_y == 0 {
|
||||
storage_y = y_new;
|
||||
}
|
||||
let slot = Region::new(storage_x, 0, x_new - storage_x, storage_y);
|
||||
storage_x = x_new;
|
||||
slot
|
||||
} else {
|
||||
let slot = Region::new(0, storage_y, storage_x, y_new - storage_y);
|
||||
storage_y = y_new;
|
||||
slot
|
||||
};
|
||||
holders[i] = slot.add(size)?;
|
||||
storage.push(slot);
|
||||
}
|
||||
Some(Stitched {
|
||||
width: storage_x,
|
||||
height: storage_y,
|
||||
holders,
|
||||
padding,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Five 16 px sprites with one pixel of padding (no mipmaps), worked through vanilla's
|
||||
/// `Stitcher` by hand: 18 px holders in a 64 by 64 atlas.
|
||||
#[test]
|
||||
fn equal_sprites_follow_vanillas_regions() {
|
||||
let stitched = stitch(&[16; 5], 0, 0, 1 << 14).unwrap();
|
||||
assert_eq!(
|
||||
(stitched.width, stitched.height, stitched.padding),
|
||||
(64, 64, 1)
|
||||
);
|
||||
assert_eq!(
|
||||
stitched.holders,
|
||||
[[0, 0], [32, 0], [0, 32], [18, 32], [36, 32]]
|
||||
);
|
||||
}
|
||||
|
||||
/// The largest sprite goes first; padding and holders round up to the mip step.
|
||||
#[test]
|
||||
fn larger_sprites_go_first_and_holders_round_to_the_mip_step() {
|
||||
// Mip level 2: 4 px padding, holders 24 (16 + 8) and 40 (32 + 8) px.
|
||||
let stitched = stitch(&[16, 32], 2, 0, 1 << 14).unwrap();
|
||||
assert_eq!(stitched.padding, 4);
|
||||
assert_eq!(stitched.holders[1], [0, 0]);
|
||||
// 40 px grows the atlas to 64 by 64; the 24 px holder fits below the first.
|
||||
assert_eq!((stitched.width, stitched.height), (64, 64));
|
||||
assert_eq!(stitched.holders[0], [0, 40]);
|
||||
// Anisotropy 16x (bit 4) multiplies the padding by 8.
|
||||
assert_eq!(stitch(&[16], 4, 4, 1 << 14).unwrap().padding, 128);
|
||||
assert_eq!(stitch(&[16; 2], 0, 0, 16), None);
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,8 @@ impl EntitySpan {
|
||||
}
|
||||
}
|
||||
|
||||
/// `HzDraw`'s size: `renderStage`, `entityId`, `blockEntityId`, a pad, `entityColor`.
|
||||
/// `HzDraw`'s size: `renderStage`, `entityId`, `blockEntityId`, `currentRenderedItemId`,
|
||||
/// `entityColor`.
|
||||
const SIZE: u32 = 32;
|
||||
|
||||
pub(super) struct DrawUniforms {
|
||||
@@ -73,8 +74,10 @@ impl DrawUniforms {
|
||||
let mut contents = vec![0u8; stride as usize * RENDER_STAGES.len()];
|
||||
for (i, slot) in contents.chunks_mut(stride as usize).enumerate() {
|
||||
slot[..4].copy_from_slice(&(i as i32).to_le_bytes());
|
||||
// `blockEntityId` outside block entities, as Iris has it.
|
||||
// `blockEntityId` outside block entities, as Iris has it; `currentRenderedItemId`
|
||||
// outside items, as Iris 1.11.7 gives it to terrain, water and the sky (observed).
|
||||
slot[8..12].copy_from_slice(&(-1i32).to_le_bytes());
|
||||
slot[12..16].copy_from_slice(&(-1i32).to_le_bytes());
|
||||
}
|
||||
let buffer = wgpu::util::DeviceExt::create_buffer_init(
|
||||
device,
|
||||
|
||||
@@ -101,6 +101,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
op: ">",
|
||||
reference: 0.1,
|
||||
}),
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +163,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
.collect(),
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,7 +428,9 @@ impl PackEntities {
|
||||
}),
|
||||
};
|
||||
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
|
||||
let casts = part == Part::Plain && program.is_some_and(|p| shadow.cast(&span, p));
|
||||
// Portal faces cast too (a pack's shadow pass sees block entities whole, as Iris
|
||||
// draws them); beams do not.
|
||||
let casts = part != Part::Beam && program.is_some_and(|p| shadow.cast(&span, p));
|
||||
if !pack && !span.hidden {
|
||||
extend_without_portals(rest, run);
|
||||
}
|
||||
@@ -1032,6 +1036,57 @@ mod gpu_span_tests {
|
||||
assert_eq!(skin.spans.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_portal_faces_reach_the_shadow_pass_with_their_block_entity() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let layout = super::super::pack_draw::DrawUniforms::new(&device);
|
||||
let mut geometry = PackEntities::new(&device, &layout.layout);
|
||||
// One block entity: a plain face, then an End portal face (overlay layer 9).
|
||||
let cpu: Vec<Vertex> = [0, 0, 0, 0, 9, 9, 9, 9]
|
||||
.into_iter()
|
||||
.map(|layer| Vertex {
|
||||
layer,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
})
|
||||
.collect();
|
||||
let spans = [EntitySpan {
|
||||
start: 0,
|
||||
id: 99,
|
||||
stage: render_stage("BLOCK_ENTITIES"),
|
||||
..Default::default()
|
||||
}];
|
||||
let mut rest = Vec::new();
|
||||
geometry.prepare(
|
||||
&device,
|
||||
&queue,
|
||||
&layout.layout,
|
||||
&cpu,
|
||||
&spans,
|
||||
[true; PROGRAMS],
|
||||
([0.; 4], [1; 2]),
|
||||
Casters {
|
||||
blocks: true,
|
||||
..Default::default()
|
||||
},
|
||||
&mut rest,
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
(geometry.draws.iter())
|
||||
.map(|d| (d.portal, d.shadow))
|
||||
.collect::<Vec<_>>(),
|
||||
[(false, true), (true, true)],
|
||||
"the portal face casts as its block entity does"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posed_and_cpu_draws_keep_offsets_uniforms_and_special_fallbacks() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
|
||||
@@ -13,9 +13,14 @@ const HAND_DEPTH: f32 = 0.125;
|
||||
/// `HzHand`: the hand's projection and its inverse.
|
||||
const UNIFORM: u64 = 2 * 64;
|
||||
|
||||
/// `HzDraw`'s size (pack_draw.rs).
|
||||
const SLOT: u64 = 32;
|
||||
|
||||
/// What `gbuffers_hand` reads: the entity inputs, and the hand's projection from set 1.
|
||||
pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
let mut inputs = super::pack_entities::inputs();
|
||||
// Iris 1.11.7 declares `currentRenderedItemId` in the hand's programs (observed).
|
||||
inputs.item_id_declared = true;
|
||||
inputs.declarations.push_str(
|
||||
"layout(set = 1, binding = 1) uniform HzHand { mat4 hz_hand_projection; mat4 hz_hand_projection_inverse; };\n",
|
||||
);
|
||||
@@ -45,10 +50,17 @@ pub struct PackHand {
|
||||
capacity: u32,
|
||||
/// Quads uploaded this frame.
|
||||
pub quads: u32,
|
||||
/// `HzDraw` (set 2) of each run: the arm or one held item, and its `currentRenderedItemId`.
|
||||
slots: wgpu::Buffer,
|
||||
pub slot_group: wgpu::BindGroup,
|
||||
stride: u64,
|
||||
/// This frame's runs: first quad, quads, and the dynamic offset of their slot.
|
||||
pub runs: Vec<(u32, u32, u32)>,
|
||||
}
|
||||
|
||||
impl PackHand {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
/// `draws`: the layout of `HzDraw` (pack_draw.rs).
|
||||
pub fn new(device: &wgpu::Device, draws: &wgpu::BindGroupLayout) -> Self {
|
||||
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
@@ -74,6 +86,28 @@ impl PackHand {
|
||||
});
|
||||
let capacity = 256;
|
||||
let (vertices, group) = Self::vertex_buffer(device, &layout, &uniform, capacity);
|
||||
let stride = SLOT.max(u64::from(
|
||||
device.limits().min_uniform_buffer_offset_alignment,
|
||||
));
|
||||
// The arm and the two hands' items: a few runs a frame.
|
||||
let slots = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack hand draws"),
|
||||
size: SLOTS * stride,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let slot_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack hand draws"),
|
||||
layout: draws,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &slots,
|
||||
offset: 0,
|
||||
size: wgpu::BufferSize::new(SLOT),
|
||||
}),
|
||||
}],
|
||||
});
|
||||
Self {
|
||||
indices: super::pack_entities::quad_indices(device, capacity),
|
||||
layout,
|
||||
@@ -82,6 +116,10 @@ impl PackHand {
|
||||
group,
|
||||
capacity,
|
||||
quads: 0,
|
||||
slots,
|
||||
slot_group,
|
||||
stride,
|
||||
runs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,18 +154,31 @@ impl PackHand {
|
||||
(buffer, group)
|
||||
}
|
||||
|
||||
/// Uploads this frame's hand and its projection.
|
||||
/// Uploads this frame's hand, its projection, and the `HzDraw` of each run `items` starts
|
||||
/// (first vertex, `currentRenderedItemId`; the slots past [`SLOTS`] share the last).
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
hand: &[Vertex],
|
||||
items: &[(u32, i32)],
|
||||
projection: Mat4,
|
||||
) {
|
||||
self.quads = (hand.len() / 4) as u32;
|
||||
self.runs.clear();
|
||||
if self.quads == 0 {
|
||||
return;
|
||||
}
|
||||
let mut contents = Vec::new();
|
||||
for (start, quads, id) in runs(self.quads, items) {
|
||||
let slot = (self.runs.len() as u64).min(SLOTS - 1);
|
||||
if slot as usize == self.runs.len() {
|
||||
contents.extend_from_slice(&draw_slot(id));
|
||||
contents.resize(((slot + 1) * self.stride) as usize, 0);
|
||||
}
|
||||
self.runs.push((start, quads, (slot * self.stride) as u32));
|
||||
}
|
||||
queue.write_buffer(&self.slots, 0, &contents);
|
||||
if self.quads > self.capacity {
|
||||
self.capacity = self.quads.next_power_of_two();
|
||||
(self.vertices, self.group) =
|
||||
@@ -144,10 +195,54 @@ impl PackHand {
|
||||
}
|
||||
}
|
||||
|
||||
/// The hand's runs a frame keeps slots for.
|
||||
const SLOTS: u64 = 8;
|
||||
|
||||
/// `HzDraw` of a hand run: `renderStage` the solid hand's, `blockEntityId` -1 as outside block
|
||||
/// entities, and `currentRenderedItemId`.
|
||||
fn draw_slot(id: i32) -> [u8; SLOT as usize] {
|
||||
let mut bytes = [0u8; SLOT as usize];
|
||||
let stage = horizon_shaders::render_stage("HAND_SOLID") as i32;
|
||||
bytes[0..4].copy_from_slice(&stage.to_le_bytes());
|
||||
bytes[8..12].copy_from_slice(&(-1i32).to_le_bytes());
|
||||
bytes[12..16].copy_from_slice(&id.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// The runs of `quads` quads that `items` starts (first vertex, id), as first quad, quads and
|
||||
/// id; geometry before the first start, or with no starts at all, reads 0, as the arm does.
|
||||
fn runs(quads: u32, items: &[(u32, i32)]) -> Vec<(u32, u32, i32)> {
|
||||
let mut starts: Vec<(u32, i32)> = vec![(0, 0)];
|
||||
for &(start, id) in items {
|
||||
let q = (start / 4).min(quads);
|
||||
match starts.last_mut() {
|
||||
Some(last) if last.0 == q => last.1 = id,
|
||||
_ => starts.push((q, id)),
|
||||
}
|
||||
}
|
||||
(0..starts.len())
|
||||
.map(|i| {
|
||||
let end = starts.get(i + 1).map_or(quads, |s| s.0);
|
||||
(starts[i].0, end - starts[i].0, starts[i].1)
|
||||
})
|
||||
.filter(|r| r.1 > 0)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Each item's quads draw with its id; geometry before the first item is the arm's (0).
|
||||
#[test]
|
||||
fn runs_split_the_hand_by_item() {
|
||||
assert_eq!(runs(3, &[]), [(0, 3, 0)]);
|
||||
assert_eq!(runs(5, &[(0, 11), (8, 65535)]), [(0, 2, 11), (2, 3, 65535)]);
|
||||
assert_eq!(runs(4, &[(4, 21)]), [(0, 1, 0), (1, 3, 21)]);
|
||||
// An item that drew nothing gives way to the next.
|
||||
assert_eq!(runs(2, &[(0, 11), (0, 22)]), [(0, 2, 22)]);
|
||||
}
|
||||
|
||||
/// The hand's window depth lies within `MC_HAND_DEPTH` of the middle (packs tell the hand
|
||||
/// apart by it), and x, y match the plain projection.
|
||||
#[test]
|
||||
|
||||
@@ -50,6 +50,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
.collect(),
|
||||
after,
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Original synthetic material transport oracle; no external shader algorithms or assets.
|
||||
use super::*;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; missing adapter is a failure"]
|
||||
fn terrain_material_samplers_preserve_surface_and_encoded_bytes() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Material transport GPU: {:?}", adapter.get_info());
|
||||
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: adapter.limits(),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let root = std::env::temp_dir().join(format!("horizon-material-pack-{}", std::process::id()));
|
||||
std::fs::create_dir_all(root.join("shaders")).unwrap();
|
||||
std::fs::write(
|
||||
root.join("shaders/gbuffers_terrain.vsh"),
|
||||
"#version 120\nvoid main(){gl_Position=gl_Vertex;}",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join("shaders/gbuffers_terrain.fsh"),
|
||||
r#"#version 120
|
||||
uniform sampler2D gtexture;
|
||||
uniform sampler2D normals;
|
||||
uniform sampler2D specular;
|
||||
void main() {
|
||||
int x = int(floor(gl_FragCoord.x));
|
||||
int layer = x - (x / 5) * 5;
|
||||
// Source coordinate (1.25,1.25): inside a texel, away from both its centre and boundary.
|
||||
vec2 uv = (vec2(float(layer - (layer / 3) * 3), float(layer / 3)) + 0.3125) / vec2(3.0, 2.0);
|
||||
if (x < 5) gl_FragData[0] = texture2DLod(specular, uv, 12.0);
|
||||
else if (x < 10) gl_FragData[0] = texture2DLod(normals, uv, 12.0);
|
||||
else gl_FragData[0] = texture2DLod(gtexture, uv, 0.0);
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let (split, layers) = (2u32, 5u32);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let atlases: Vec<_> = (0..3)
|
||||
.map(|kind| {
|
||||
let side = if kind == 0 { 4 } else { 2 };
|
||||
let levels = if kind == 0 { 3 } else { 1 };
|
||||
let arrays: Vec<_> = (0..layers.div_ceil(split))
|
||||
.map(|array| {
|
||||
let count = (layers - array * split).min(split);
|
||||
let pixels: Vec<u8> = (0..count)
|
||||
.flat_map(|l| {
|
||||
let layer = (array * split + l) as u8;
|
||||
(0..levels).flat_map(move |level| {
|
||||
let edge = side >> level;
|
||||
(0..edge * edge).flat_map(move |pixel| {
|
||||
if level != 0 {
|
||||
// Sampling unused source mip levels must fail the oracle.
|
||||
[211, 212, 213, 214]
|
||||
} else {
|
||||
match kind {
|
||||
0 if layer == 3 => crate::sprites::NO_SPECULAR,
|
||||
0 => [
|
||||
17 + layer,
|
||||
if pixel % 2 == 0 { 0 } else { 240 },
|
||||
65,
|
||||
if pixel % 2 == 0 { 0 } else { 255 },
|
||||
],
|
||||
1 if layer == 2 => crate::sprites::FLAT_NORMAL,
|
||||
1 => [128 + layer, 126, 255, 19 + layer],
|
||||
_ => [40 + layer, 50, 60, 255],
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
device.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("Original fixture companion array"),
|
||||
size: wgpu::Extent3d {
|
||||
width: side,
|
||||
height: side,
|
||||
depth_or_array_layers: count,
|
||||
},
|
||||
mip_level_count: levels,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&pixels,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
super::super::pack_atlas::PackAtlas::material(
|
||||
&device,
|
||||
&mut encoder,
|
||||
&arrays,
|
||||
split,
|
||||
layers,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
queue.submit([encoder.finish()]);
|
||||
let pack = horizon_shaders::Pack::open(&root).unwrap();
|
||||
let macros = horizon_shaders::Macros::standard();
|
||||
// Encoded zero alpha is data here and must remain observable in readback.
|
||||
let properties = horizon_shaders::Properties::parse("alphaTest.gbuffers_terrain=off", ¯os);
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
body: "vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); hz_Vertex=vec4(p*2.0-1.0,0.0,1.0);".into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯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(),
|
||||
terrain: Some(Geometry {
|
||||
buffers: &[],
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
material_maps: true,
|
||||
ids: None,
|
||||
cull: false,
|
||||
}),
|
||||
block_ids: &[],
|
||||
celestial: None,
|
||||
gui_size: [1, 1],
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
};
|
||||
let mut post = PackPost::new(
|
||||
&device,
|
||||
&queue,
|
||||
&adapter,
|
||||
post,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
world,
|
||||
)
|
||||
.expect("the pack runs");
|
||||
let mut pass = post
|
||||
.terrain
|
||||
.take()
|
||||
.unwrap_or_else(|| panic!("terrain pipeline must compile: {:?}", post.notes));
|
||||
// Request the existing copy usage for the fixture's colour readback.
|
||||
post.copied |= 1;
|
||||
// First draw preserves production Geometry -> Pass scope, so a routing regression fails.
|
||||
for case in 0..3 {
|
||||
if case == 1 {
|
||||
pass.material_maps = false;
|
||||
}
|
||||
if case == 2 {
|
||||
pass.material_maps = true;
|
||||
post.material_maps = None;
|
||||
}
|
||||
let maps_expected = case == 0;
|
||||
pass.groups.clear();
|
||||
post.resize(&device, [15, 1]);
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let _clear = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Initialize fixture depth"),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &post.targets.as_ref().unwrap().depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(1.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let group = post.bind(&device, &queue, &mut encoder, &mut pass, [15, 1]);
|
||||
{
|
||||
let mut render = post.begin(&mut encoder, &pass);
|
||||
render.set_pipeline(pass.render());
|
||||
render.set_bind_group(0, &group, &[]);
|
||||
if pass.draw {
|
||||
post.draws.bind(&mut render, "TERRAIN_SOLID");
|
||||
}
|
||||
render.draw(0..3, 0..1);
|
||||
}
|
||||
let output = post.targets.as_ref().unwrap().color[0].as_ref().unwrap()[0].texture();
|
||||
encoder.copy_texture_to_buffer(
|
||||
output.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
bytes_per_row: Some(256),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: 15,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = read.slice(..).get_mapped_range().unwrap();
|
||||
for i in 0..15usize {
|
||||
let layer = (i % 5) as u8;
|
||||
let expected = if i >= 10 {
|
||||
[40 + layer, 50, 60, 255]
|
||||
} else if i >= 5 && (!maps_expected || layer == 2) {
|
||||
crate::sprites::FLAT_NORMAL
|
||||
} else if i >= 5 {
|
||||
[128 + layer, 126, 255, 19 + layer]
|
||||
} else if !maps_expected || layer == 3 {
|
||||
crate::sprites::NO_SPECULAR
|
||||
} else {
|
||||
[17 + layer, 240, 65, 255]
|
||||
};
|
||||
assert_eq!(&data[i * 4..i * 4 + 4], &expected, "case={case}, pixel={i}");
|
||||
}
|
||||
eprintln!("Material oracle case={case}: 15 exact RGBA pixels pass");
|
||||
}
|
||||
// Fault injection: the same production binding must refuse a filtering sampler.
|
||||
// If a regression changed both the binding and sampler, the fractional positive
|
||||
// probes above would instead mix 0.75*240=180 and 0.75*255=191.25, not 240/255.
|
||||
pass.material_maps = true;
|
||||
post.material_maps = Some([atlases[1].view.clone(), atlases[0].view.clone()]);
|
||||
post.nearest_repeat = sampler(&device, wgpu::FilterMode::Linear, wgpu::AddressMode::Repeat);
|
||||
pass.groups.clear();
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let _invalid_group = post.bind(&device, &queue, &mut encoder, &mut pass, [15, 1]);
|
||||
let error = pollster::block_on(validation.pop())
|
||||
.expect("production material binding must reject a filtering sampler");
|
||||
assert!(
|
||||
error.to_string().to_ascii_lowercase().contains("filtering"),
|
||||
"{error}"
|
||||
);
|
||||
eprintln!("Material oracle negative filtering control: rejected by production binding");
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,111 @@
|
||||
//! A buffer that isn't cleared, in passes that leave pixels alone or blend: what is under them.
|
||||
//! The expected values are Iris 1.11.7's, measured with probe packs of our own (#9009).
|
||||
use super::*;
|
||||
|
||||
/// colortex6 is written three times a frame, the first write leaving the right half alone:
|
||||
/// there it reads the previous frame's last write, 0.125 (Iris 1.11.7), not the 0.25 written into
|
||||
/// that texture before. colortex8 is written twice a frame the same way: its right half was never
|
||||
/// written and reads 0 (Iris 1.11.7). colortex10 is only blended onto (`ONE ONE`), 0.125 a frame:
|
||||
/// it grows by 0.125 every frame (Iris 1.11.7 adds once per frame).
|
||||
#[test]
|
||||
fn a_buffer_that_is_not_cleared_starts_the_frame_with_its_last_write() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
let fill = |v: f32| format!("vec4({v},{v},{v},1)");
|
||||
let programs = [
|
||||
(
|
||||
"composite",
|
||||
format!(
|
||||
"#version 120\nvarying vec2 uv;\n/* RENDERTARGETS:6 */\n\
|
||||
/*\nconst int colortex6Format=RGBA16F;\nconst int colortex7Format=RGBA16F;\n\
|
||||
const int colortex8Format=RGBA16F;\nconst int colortex9Format=RGBA16F;\n\
|
||||
const int colortex10Format=RGBA16F;\nconst bool colortex6Clear=false;\n\
|
||||
const bool colortex8Clear=false;\nconst bool colortex10Clear=false;\n*/\n\
|
||||
void main() {{ if(uv.x>0.5) discard; gl_FragData[0]={}; }}\n",
|
||||
fill(0.5)
|
||||
),
|
||||
),
|
||||
(
|
||||
"composite1",
|
||||
format!(
|
||||
"#version 120\nuniform sampler2D colortex6;\nvarying vec2 uv;\n/* RENDERTARGETS:7,6 */\n\
|
||||
void main() {{ gl_FragData[0]=texture2D(colortex6,uv); gl_FragData[1]={}; }}\n",
|
||||
fill(0.25)
|
||||
),
|
||||
),
|
||||
(
|
||||
"composite2",
|
||||
format!(
|
||||
"#version 120\n/* RENDERTARGETS:6 */\nvoid main() {{ gl_FragData[0]={}; }}\n",
|
||||
fill(0.125)
|
||||
),
|
||||
),
|
||||
(
|
||||
"composite3",
|
||||
format!(
|
||||
"#version 120\nvarying vec2 uv;\n/* RENDERTARGETS:8 */\n\
|
||||
void main() {{ if(uv.x>0.5) discard; gl_FragData[0]={}; }}\n",
|
||||
fill(0.5)
|
||||
),
|
||||
),
|
||||
(
|
||||
"composite4",
|
||||
format!(
|
||||
"#version 120\nuniform sampler2D colortex8;\nvarying vec2 uv;\n/* RENDERTARGETS:9,8 */\n\
|
||||
void main() {{ gl_FragData[0]=texture2D(colortex8,uv); gl_FragData[1]={}; }}\n",
|
||||
fill(0.125)
|
||||
),
|
||||
),
|
||||
(
|
||||
"composite5",
|
||||
"#version 120\n/* RENDERTARGETS:10 */\nvoid main() { gl_FragData[0]=vec4(0.125,0.125,0.125,0); }\n"
|
||||
.to_owned(),
|
||||
),
|
||||
(
|
||||
"final",
|
||||
"#version 120\nuniform sampler2D colortex7;\nuniform sampler2D colortex9;\n\
|
||||
uniform sampler2D colortex10;\nvarying vec2 uv;\n\
|
||||
void main() { gl_FragColor=vec4(texture2D(colortex7,uv).r,texture2D(colortex9,uv).r,texture2D(colortex10,uv).r,1); }\n"
|
||||
.to_owned(),
|
||||
),
|
||||
];
|
||||
for (program, fragment) in &programs {
|
||||
add(&format!("{program}.vsh"), VERTEX);
|
||||
add(&format!("{program}.fsh"), fragment);
|
||||
}
|
||||
add("shaders.properties", "blend.composite5=ONE ONE ONE ONE\n");
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"clear-false-history",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
let mut post = gpu.build(post);
|
||||
for frame in 0..4u8 {
|
||||
let actual = gpu.render(&mut post, [16, 16]);
|
||||
let sum = 32 * (frame + 1);
|
||||
let wrong: Vec<_> = (actual.iter().enumerate())
|
||||
.filter(|(n, p)| {
|
||||
let expected = match (n % 16 < 8, frame) {
|
||||
(true, _) => [128, 128, sum, 255],
|
||||
(false, 0) => [0, 0, sum, 255],
|
||||
(false, _) => [32, 0, sum, 255],
|
||||
};
|
||||
p.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1)
|
||||
})
|
||||
.map(|(n, p)| (n, *p))
|
||||
.take(4)
|
||||
.collect();
|
||||
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! A texel offset given by a `const` local reads the texel that far away, as a literal offset
|
||||
//! does (GLSL 4.60, 4.3.3 and 8.9.2, as #8462 quotes them). #8462's probe.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
fn pack() -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
add("composite.vsh", VERTEX);
|
||||
// Each texel of colortex1 holds its own column and row, in eighths.
|
||||
add(
|
||||
"composite.fsh",
|
||||
"#version 120\n/* RENDERTARGETS: 1 */\n\
|
||||
void main() { gl_FragData[0] = vec4(floor(gl_FragCoord.xy) / 8.0, 0.0, 1.0); }\n",
|
||||
);
|
||||
add("final.vsh", VERTEX);
|
||||
// Red and green: what the texel two columns right and one row down holds; blue: this row.
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 330 compatibility\nuniform sampler2D colortex1;\nin vec2 uv;\n\
|
||||
vec4 f(vec2 p) { const ivec2 offset = ivec2(2, -1); return textureLodOffset(colortex1, p, 0.0, offset); }\n\
|
||||
void main() { gl_FragColor = vec4(f(uv).rg, floor(gl_FragCoord.y) / 8.0, 1.0); }\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-const-offset",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
assert_eq!(post.programs.len(), 2, "{:?}", post.notes);
|
||||
post
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_const_local_offset_reads_the_offset_texel() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut post = gpu.build(pack());
|
||||
let pixels = gpu.render(&mut post, [8, 8]);
|
||||
let eighth = |n: u8| (f32::from(n) * 255.0 / 8.0).round() as u8;
|
||||
let mut checked = 0;
|
||||
for (n, p) in pixels.iter().enumerate() {
|
||||
let x = (n % 8) as u8;
|
||||
// The bottom row (GL's row 0) and the two right columns read past the edge.
|
||||
if x >= 6 || p[2] == 0 {
|
||||
continue;
|
||||
}
|
||||
let row = (0..8)
|
||||
.find(|&r| eighth(r).abs_diff(p[2]) <= 1)
|
||||
.expect("row in blue");
|
||||
let want = [eighth(x + 2), eighth(row - 1)];
|
||||
assert!(
|
||||
p[0].abs_diff(want[0]) <= 1 && p[1].abs_diff(want[1]) <= 1,
|
||||
"pixel {n} (column {x}, GL row {row}): {p:?}, expected red and green {want:?}"
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
assert_eq!(checked, 6 * 7);
|
||||
}
|
||||
@@ -83,7 +83,8 @@ impl Gpu {
|
||||
post,
|
||||
T::Rgba8Unorm,
|
||||
world,
|
||||
);
|
||||
)
|
||||
.expect("the pack runs");
|
||||
assert!(
|
||||
built.passes.iter().any(|p| p.group == Group::Final),
|
||||
"{:?}",
|
||||
@@ -315,23 +316,26 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
|
||||
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
|
||||
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
|
||||
let inputs=horizon_shaders::GameInputs {
|
||||
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None }),
|
||||
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false }),
|
||||
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
|
||||
};
|
||||
let mut post = gpu.load(
|
||||
shaders(sampler, stage, scene),
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: terrain.then_some(Geometry {
|
||||
buffers: &layouts,
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
}),
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
@@ -617,6 +621,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
@@ -635,3 +640,144 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
post_effects: crate::post_chain::Request::none(),
|
||||
}
|
||||
}
|
||||
|
||||
/// shadowtex0 and shadowtex1 read through a plain sampler a quarter texel past a texel's centre,
|
||||
/// over a shadow map whose depth rises a sixteenth a texel: red and green are how far each
|
||||
/// moved toward the next texel, times four (0 nearest, 255 linear). `nearest` is a line for
|
||||
/// both stage files.
|
||||
fn shadow_depth_filtering(nearest: &str) -> Option<Vec<[u8; 4]>> {
|
||||
let gpu = Gpu::new()?;
|
||||
if !gpu
|
||||
.device
|
||||
.features()
|
||||
.contains(wgpu::Features::FLOAT32_FILTERABLE)
|
||||
{
|
||||
eprintln!("DEPTH_FIXTURE_NO_FLOAT32_FILTERABLE");
|
||||
return None;
|
||||
}
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
|
||||
let layouts = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 12,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &ATTRS,
|
||||
})];
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
|
||||
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
|
||||
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
|
||||
hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
|
||||
.into(),
|
||||
attributes: vec![],
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let settings = format!("const int shadowMapResolution = 16;\n{nearest}\n");
|
||||
let files = vec![
|
||||
("final.vsh".to_string(), VERTEX.to_string()),
|
||||
(
|
||||
"final.fsh".into(),
|
||||
format!(
|
||||
"#version 330 compatibility\n{settings}\
|
||||
uniform sampler2D shadowtex0;\nuniform sampler2D shadowtex1;\n\
|
||||
float moved(sampler2D s, ivec2 t) {{\n\
|
||||
float a = texelFetch(s, t, 0).r;\n\
|
||||
return (texture(s, (vec2(t) + vec2(0.75, 0.5)) / 16.0).r - a) * 16.0;\n\
|
||||
}}\n\
|
||||
void main() {{\n\
|
||||
ivec2 t = ivec2(int(gl_FragCoord.x) % 15, 8);\n\
|
||||
gl_FragColor = vec4(moved(shadowtex0, t) * 4.0, moved(shadowtex1, t) * 4.0, 0.0, 1.0);\n\
|
||||
}}\n"
|
||||
),
|
||||
),
|
||||
// The shadow map's depth runs from 0 at its left edge to 1 at its right.
|
||||
(
|
||||
"shadow.vsh".into(),
|
||||
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,gl_Vertex.x,1.0);}".into(),
|
||||
),
|
||||
(
|
||||
"shadow.fsh".into(),
|
||||
format!("#version 120\n{settings}void main(){{gl_FragData[0]=vec4(1.0);}}"),
|
||||
),
|
||||
];
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
sprite_rects: &[],
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: Some(Geometry {
|
||||
buffers: &layouts,
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
ids: None,
|
||||
cull: false,
|
||||
material_maps: false,
|
||||
}),
|
||||
block_ids: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
inputs,
|
||||
);
|
||||
assert!(post.has_shadow(), "{:?}", post.notes);
|
||||
let positions: [[f32; 3]; 6] = [
|
||||
[-1., -1., 0.],
|
||||
[1., -1., 0.],
|
||||
[1., 1., 0.],
|
||||
[-1., -1., 0.],
|
||||
[1., 1., 0.],
|
||||
[-1., 1., 0.],
|
||||
];
|
||||
let vertices = gpu
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Original shadow depth ramp"),
|
||||
contents: bytemuck::cast_slice(&positions),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let hud = HudBatch::new(SIZE);
|
||||
let frame = fixture_frame(&hud);
|
||||
Some(
|
||||
gpu.render(&mut post, &color, &depth, &frame, &mut |rp, list, _, _| {
|
||||
if list == TerrainList::ShadowSolid {
|
||||
rp.set_vertex_buffer(0, vertices.slice(..));
|
||||
rp.draw(0..6, 0..1);
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_depth_is_read_linearly_through_a_plain_sampler() {
|
||||
// Iris 1.11.7 filters both (probe packs of our own; the PR has the readings).
|
||||
if let Some(pixels) = shadow_depth_filtering("") {
|
||||
all_pixels(&pixels, [255, 255, 0, 255], "shadowtex linear");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadowtex_nearest_settings_read_the_nearest_texel() {
|
||||
for (line, expected) in [
|
||||
("const bool shadowtex0Nearest = true;", [0, 255, 0, 255]),
|
||||
("const bool shadowtexNearest = true;", [0, 255, 0, 255]),
|
||||
("const bool shadow0MinMagNearest = true;", [0, 255, 0, 255]),
|
||||
("const bool shadowtex1Nearest = true;", [255, 0, 0, 255]),
|
||||
("const bool shadow1MinMagNearest = true;", [255, 0, 0, 255]),
|
||||
] {
|
||||
if let Some(pixels) = shadow_depth_filtering(line) {
|
||||
all_pixels(&pixels, expected, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,25 +199,7 @@ impl Fixture {
|
||||
}
|
||||
|
||||
pub(super) fn build(&self, post: Post) -> PackPost {
|
||||
let lightmap = self.texture([1, 1], T::Rgba8Unorm);
|
||||
let post = PackPost::new(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&self.adapter,
|
||||
post,
|
||||
T::Rgba8Unorm,
|
||||
World {
|
||||
atlas: None,
|
||||
lightmap,
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
celestial: None,
|
||||
gui_size: [16, 16],
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
);
|
||||
let post = self.try_build(post).expect("the pack runs");
|
||||
assert!(
|
||||
post.notes.iter().all(|n| n.starts_with("shadow map ")),
|
||||
"{:?}",
|
||||
@@ -226,7 +208,56 @@ impl Fixture {
|
||||
post
|
||||
}
|
||||
|
||||
/// [`Self::build`] for a pack that may be refused.
|
||||
pub(super) fn try_build(&self, post: Post) -> Result<PackPost, Refused> {
|
||||
self.try_build_with_terrain(post, None)
|
||||
}
|
||||
|
||||
/// `build`, with the game's terrain geometry handed to the pack.
|
||||
pub(super) fn build_with_terrain(&self, post: Post, terrain: Option<Geometry<'_>>) -> PackPost {
|
||||
self.try_build_with_terrain(post, terrain)
|
||||
.expect("the pack runs")
|
||||
}
|
||||
|
||||
fn try_build_with_terrain(
|
||||
&self,
|
||||
post: Post,
|
||||
terrain: Option<Geometry<'_>>,
|
||||
) -> Result<PackPost, Refused> {
|
||||
let lightmap = self.texture([1, 1], T::Rgba8Unorm);
|
||||
PackPost::new(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&self.adapter,
|
||||
post,
|
||||
T::Rgba8Unorm,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap,
|
||||
terrain,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: [16, 16],
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn render(&self, post: &mut PackPost, size: [u32; 2]) -> Vec<[u8; 4]> {
|
||||
self.render_with_terrain(post, size, &mut |_, _, _, _| {})
|
||||
}
|
||||
|
||||
/// `render`, with `terrain` drawing the game's terrain lists.
|
||||
pub(super) fn render_with_terrain(
|
||||
&self,
|
||||
post: &mut PackPost,
|
||||
size: [u32; 2],
|
||||
terrain: &mut TerrainDraw<'_>,
|
||||
) -> Vec<[u8; 4]> {
|
||||
let world = self.texture(size, T::Rgba8Unorm);
|
||||
let depth = self.texture(size, T::Depth32Float);
|
||||
let screen = self.texture(size, T::Rgba8Unorm);
|
||||
@@ -251,7 +282,7 @@ impl Fixture {
|
||||
size,
|
||||
size,
|
||||
&[],
|
||||
&mut |_, _, _, _| {},
|
||||
terrain,
|
||||
);
|
||||
encoder.copy_texture_to_buffer(
|
||||
screen.texture().as_image_copy(),
|
||||
@@ -346,8 +377,8 @@ fn frame_clear_and_retained_history_apply_before_partial_post_writes() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
for clear in [true, false] {
|
||||
let mut post = gpu.build(pack(Coverage::Full, false, false, Some(clear), false));
|
||||
// Every frame changes which physical image is main. The other image is fully
|
||||
// seeded red before the partial write, independent of its initial clearing.
|
||||
// The other image is fully seeded red before the partial write, independent of its
|
||||
// initial clearing.
|
||||
for frame in 0..3 {
|
||||
let actual = gpu.render(&mut post, [16, 16]);
|
||||
assert!(
|
||||
@@ -433,6 +464,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
@@ -521,3 +553,6 @@ fn a_cleared_buffer_starts_every_frame_cleared_on_both_textures() {
|
||||
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[path = "clear_false_tests.rs"]
|
||||
mod clear_false_tests;
|
||||
@@ -0,0 +1,73 @@
|
||||
//! `flip.<program>.<buffer> = true` on a buffer the program writes (#9010). The expected values
|
||||
//! are Iris 1.11.7's, measured with probe packs of our own.
|
||||
use super::destination_tests::Fixture;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
/// `composite` writes 0.25 to colortex2 and colortex4 and forces both to flip: the forced flip
|
||||
/// cancels the write's own, so `composite1` samples colortex2's cleared side (Iris 1.11.7: 0),
|
||||
/// and `composite2`, blended `ONE ONE`, adds 0.5 onto colortex4's 0.25 (Iris 1.11.7: 0.749).
|
||||
#[test]
|
||||
fn a_forced_flip_of_a_written_buffer_leaves_it_unflipped() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
for (program, fragment) in [
|
||||
(
|
||||
"composite",
|
||||
"#version 120\n/* RENDERTARGETS:2,4 */\n\
|
||||
void main() { gl_FragData[0]=vec4(0.25,0.25,0.25,1); gl_FragData[1]=vec4(0.25,0.25,0.25,1); }\n",
|
||||
),
|
||||
(
|
||||
"composite1",
|
||||
"#version 120\nuniform sampler2D colortex2;\nvarying vec2 uv;\n/* RENDERTARGETS:3 */\n\
|
||||
void main() { gl_FragData[0]=texture2D(colortex2,uv); }\n",
|
||||
),
|
||||
(
|
||||
"composite2",
|
||||
"#version 120\n/* RENDERTARGETS:4 */\nvoid main() { gl_FragData[0]=vec4(0.5,0.5,0.5,0); }\n",
|
||||
),
|
||||
(
|
||||
"final",
|
||||
"#version 120\nuniform sampler2D colortex3;\nuniform sampler2D colortex4;\nvarying vec2 uv;\n\
|
||||
void main() { gl_FragColor=vec4(texture2D(colortex4,uv).r,texture2D(colortex3,uv).r,0,1); }\n",
|
||||
),
|
||||
] {
|
||||
add(&format!("{program}.vsh"), VERTEX);
|
||||
add(&format!("{program}.fsh"), fragment);
|
||||
}
|
||||
add(
|
||||
"shaders.properties",
|
||||
"flip.composite.colortex2=true\nflip.composite.colortex4=true\nblend.composite2=ONE ONE ONE ONE\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"forced-flip-of-a-written-buffer",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
let mut post = gpu.build(post);
|
||||
for frame in 0..2 {
|
||||
let actual = gpu.render(&mut post, [16, 16]);
|
||||
let wrong: Vec<_> = (actual.iter().enumerate())
|
||||
.filter(|(_, p)| {
|
||||
p.iter()
|
||||
.zip([191, 0, 0, 255])
|
||||
.any(|(a, b)| a.abs_diff(b) > 1)
|
||||
})
|
||||
.map(|(n, p)| (n, *p))
|
||||
.take(4)
|
||||
.collect();
|
||||
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! A pack may give colortex0 an integer format (OptiFine's `shaders.txt`, "Texture Formats",
|
||||
//! lists `RGBA32UI`, as #8459 quotes it): the pack still loads, and reads back what it wrote
|
||||
//! there. #8459's probe; #8459 reports Iris 1.11.7 drawing (0, 200, 0) at every point
|
||||
//! sampled from it. Without a `final` that draws it, the pack is refused (#9165).
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
/// A `final` that shows the integer colortex0.
|
||||
const FINAL: &str = "#version 330 compatibility\nuniform usampler2D colortex0;\n\
|
||||
void main() { gl_FragColor = vec4(texelFetch(colortex0, ivec2(gl_FragCoord.xy), 0)) / 255.0; }\n";
|
||||
|
||||
/// A `final` that doesn't compile (it calls a function nobody declares), so it is skipped.
|
||||
const BROKEN_FINAL: &str =
|
||||
"#version 330 compatibility\nvoid main() { gl_FragColor = undeclared_function(); }\n";
|
||||
|
||||
/// `depth`: the composite reads depthtex0, so the blit copies the world's depth; `last`: the
|
||||
/// pack's `final`, if it has one.
|
||||
fn pack(depth: bool, last: Option<&str>) -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
add("composite.vsh", VERTEX);
|
||||
// With depthtex0: green where it holds the empty world's far plane (1), red elsewhere.
|
||||
let value = if depth {
|
||||
"texelFetch(depthtex0, ivec2(gl_FragCoord.xy), 0).r == 1.0 ? uvec4(0u, 200u, 0u, 255u) : uvec4(200u, 0u, 0u, 255u)"
|
||||
} else {
|
||||
"uvec4(0u, 200u, 0u, 255u)"
|
||||
};
|
||||
add(
|
||||
"composite.fsh",
|
||||
&format!(
|
||||
"#version 330 compatibility\n/* RENDERTARGETS: 0 */\n/*\nconst int colortex0Format = RGBA32UI;\n*/\n\
|
||||
uniform sampler2D depthtex0;\nlayout(location = 0) out uvec4 o;\nvoid main() {{ o = {value}; }}\n"
|
||||
),
|
||||
);
|
||||
if let Some(source) = last {
|
||||
add("final.vsh", VERTEX);
|
||||
add("final.fsh", source);
|
||||
}
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-integer-colortex0",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
horizon_shaders::load_post(&pack, "", &properties, ¯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, Some(FINAL)));
|
||||
// Twice: the second frame's world blit runs over a colortex0 the first frame wrote.
|
||||
for frame in 0..2 {
|
||||
let pixels = gpu.render(&mut post, [8, 8]);
|
||||
let wrong: Vec<_> = pixels.iter().filter(|p| **p != [0, 200, 0, 255]).collect();
|
||||
assert!(wrong.is_empty(), "depth {depth}, frame {frame}: {wrong:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a `final` left to draw it (none, or one that is skipped), an integer colortex0 has
|
||||
/// no colours the screen can show: the pack is refused, so the game turns it off and draws
|
||||
/// without it instead of a black screen (#9165). The refusal keeps why `final` was skipped.
|
||||
#[test]
|
||||
fn an_integer_colortex0_without_a_working_final_is_refused() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
for last in [None, Some(BROKEN_FINAL)] {
|
||||
let Err(refused) = gpu.try_build(pack(false, last)) else {
|
||||
panic!("{last:?}: a pack that can't show colortex0 runs");
|
||||
};
|
||||
assert_eq!(
|
||||
refused.notes.iter().any(|n| n.starts_with("final")),
|
||||
last.is_some(),
|
||||
"{refused:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! The small packed colortex formats `RGB5_A1`, `RGB565` and `R3_G3_B2`, read back as in Iris
|
||||
//! 1.11.7. wgpu has no such formats, so these buffers are 8-bit (`texture_format`); what a pass
|
||||
//! writes into one and its clear colour go through [`quantise`] first.
|
||||
//!
|
||||
//! Probe pack p8106c of our own wrote 600 values to each format and read them back with
|
||||
//! `texelFetch` in Iris 1.11.7 (NVIDIA 615.78.08). Our probe's Iris values fit this, with no
|
||||
//! exception: each channel becomes the byte an `RGBA8` buffer stores for it, keeps that byte's
|
||||
//! top bits (5, 5, 5 and 1 for `RGB5_A1`; 5, 6 and 5 for `RGB565` and also for `R3_G3_B2`), and
|
||||
//! reads back those bits repeated to fill 8 (`0.3337` → byte 85 → 10 of 31 → 82 / 255). This is
|
||||
//! a fit to one driver's values, not a rule Iris states. `RGBA4` and `RGBA2` read back the `RGBA8`
|
||||
//! bytes in the same probe, as Horizon's 8-bit buffers already do.
|
||||
|
||||
use horizon_shaders::{Format, Kind};
|
||||
use naga::{BinaryOperator, Expression, Literal, MathFunction, Statement, VectorSize};
|
||||
|
||||
/// The bits each channel keeps in a buffer of format `f`, for the formats that keep fewer than
|
||||
/// 8; a three-channel format's alpha keeps 8 (it reads 1 anyway).
|
||||
pub(super) fn bits(f: Format) -> Option<[u8; 4]> {
|
||||
match (f.kind, f.channels, f.bits) {
|
||||
(Kind::Unorm, 4, 5) => Some([5, 5, 5, 1]),
|
||||
// `RGB565`, and `R3_G3_B2` (3 × 3 bits in `config.rs`), which our probe's Iris values
|
||||
// put in 5, 6 and 5 bits too.
|
||||
(Kind::Unorm, 3, 6 | 3) => Some([5, 6, 5, 8]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The value a buffer keeping `bits` per channel reads back after `v` is written or cleared.
|
||||
pub(super) fn quantise(v: [f32; 4], bits: [u8; 4]) -> [f32; 4] {
|
||||
std::array::from_fn(|i| {
|
||||
let byte = (v[i].clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u32;
|
||||
let n = u32::from(bits[i]);
|
||||
let kept = byte >> (8 - n);
|
||||
// The kept bits, repeated until there are 8.
|
||||
let mut wide = 0;
|
||||
let mut have = 0;
|
||||
while have < 8 {
|
||||
wide = wide << n | kept;
|
||||
have += n;
|
||||
}
|
||||
(wide >> (have - 8)) as f32 / 255.0
|
||||
})
|
||||
}
|
||||
|
||||
/// The factors of the shader's [`quantise`] for a channel keeping `n` bits: the kept value is
|
||||
/// `floor(byte / step)`, and it reads back `kept × step + floor(kept × repeat)`.
|
||||
fn factors(n: u8) -> (f32, f32) {
|
||||
let step = f32::from(1u16 << (8 - n));
|
||||
// `kept × repeat` is the kept bits shifted down to fill what `kept × step` leaves: one bit
|
||||
// fills the other 7 (× 127); 5 or 6 bits fill 3 or 2 with their top bits.
|
||||
let repeat = match n {
|
||||
1 => 127.0,
|
||||
_ => 2f32.powi(8 - 2 * i32::from(n)),
|
||||
};
|
||||
(step, repeat)
|
||||
}
|
||||
|
||||
/// Makes the fragment entry point of `module` write each output location of `outputs` as
|
||||
/// [`quantise`] gives it for its bits. The translator's entry point ends in one `return` of its
|
||||
/// outputs; a module without one is left as it is.
|
||||
pub(super) fn quantise_outputs(module: &mut naga::Module, outputs: &[(u32, [u8; 4])]) {
|
||||
let Some(ep) =
|
||||
(module.entry_points.iter_mut()).find(|e| e.stage == naga::ShaderStage::Fragment)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(result) = &ep.function.result else {
|
||||
return;
|
||||
};
|
||||
let naga::TypeInner::Struct { members, .. } = &module.types[result.ty].inner else {
|
||||
return;
|
||||
};
|
||||
let vec4 = |ty: naga::Handle<naga::Type>| {
|
||||
matches!(
|
||||
module.types[ty].inner,
|
||||
naga::TypeInner::Vector {
|
||||
size: VectorSize::Quad,
|
||||
scalar: naga::Scalar::F32
|
||||
}
|
||||
)
|
||||
};
|
||||
// (member, bits, the member's type) of each output to change.
|
||||
let changed: Vec<(usize, [u8; 4], naga::Handle<naga::Type>)> = (members.iter().enumerate())
|
||||
.filter_map(|(i, m)| {
|
||||
let Some(naga::Binding::Location { location, .. }) = m.binding else {
|
||||
return None;
|
||||
};
|
||||
let bits = outputs.iter().find(|o| o.0 == location)?.1;
|
||||
vec4(m.ty).then_some((i, bits, m.ty))
|
||||
})
|
||||
.collect();
|
||||
if changed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let struct_ty = result.ty;
|
||||
let f = &mut ep.function;
|
||||
let body = std::mem::take(&mut f.body);
|
||||
let mut out = naga::Block::with_capacity(body.len() + 1);
|
||||
for (statement, span) in body.span_into_iter() {
|
||||
let Statement::Return { value: Some(value) } = statement else {
|
||||
out.push(statement, span);
|
||||
continue;
|
||||
};
|
||||
let Expression::Compose { components, .. } = &f.expressions[value] else {
|
||||
out.push(statement, span);
|
||||
continue;
|
||||
};
|
||||
let mut components = components.clone();
|
||||
let e = &mut f.expressions;
|
||||
let lit = |e: &mut naga::Arena<Expression>, v: f32| {
|
||||
e.append(Expression::Literal(Literal::F32(v)), span)
|
||||
};
|
||||
// Literals need no emitting: they come before the range that is emitted.
|
||||
let scalars: Vec<_> = (changed.iter())
|
||||
.map(|&(_, bits, _)| {
|
||||
let per = bits.map(factors);
|
||||
(
|
||||
per.map(|(step, _)| lit(e, step)),
|
||||
per.map(|(step, _)| lit(e, 1.0 / step)),
|
||||
per.map(|(_, repeat)| lit(e, repeat)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let (zero, one, max, half) = (lit(e, 0.0), lit(e, 1.0), lit(e, 255.0), lit(e, 0.5));
|
||||
let mut add = |x: Expression| e.append(x, span);
|
||||
let splat = |add: &mut dyn FnMut(Expression) -> _, value| {
|
||||
add(Expression::Splat {
|
||||
size: VectorSize::Quad,
|
||||
value,
|
||||
})
|
||||
};
|
||||
let (zero, one, max, half) = (
|
||||
splat(&mut add, zero),
|
||||
splat(&mut add, one),
|
||||
splat(&mut add, max),
|
||||
splat(&mut add, half),
|
||||
);
|
||||
let binary = |add: &mut dyn FnMut(Expression) -> _, op, left, right| {
|
||||
add(Expression::Binary { op, left, right })
|
||||
};
|
||||
let math = |add: &mut dyn FnMut(Expression) -> _, fun, arg, arg1, arg2| {
|
||||
add(Expression::Math {
|
||||
fun,
|
||||
arg,
|
||||
arg1,
|
||||
arg2,
|
||||
arg3: None,
|
||||
})
|
||||
};
|
||||
for (&(member, _, ty), (steps, inverse, repeats)) in changed.iter().zip(scalars) {
|
||||
let mut vector = |components: [_; 4]| {
|
||||
add(Expression::Compose {
|
||||
ty,
|
||||
components: components.to_vec(),
|
||||
})
|
||||
};
|
||||
let (step, inverse, repeat) = (vector(steps), vector(inverse), vector(repeats));
|
||||
let x = components[member];
|
||||
let clamped = math(&mut add, MathFunction::Clamp, x, Some(zero), Some(one));
|
||||
let scaled = binary(&mut add, BinaryOperator::Multiply, clamped, max);
|
||||
let rounded = binary(&mut add, BinaryOperator::Add, scaled, half);
|
||||
let byte = math(&mut add, MathFunction::Floor, rounded, None, None);
|
||||
let shifted = binary(&mut add, BinaryOperator::Multiply, byte, inverse);
|
||||
let kept = math(&mut add, MathFunction::Floor, shifted, None, None);
|
||||
let high = binary(&mut add, BinaryOperator::Multiply, kept, step);
|
||||
let low = binary(&mut add, BinaryOperator::Multiply, kept, repeat);
|
||||
let low = math(&mut add, MathFunction::Floor, low, None, None);
|
||||
let wide = binary(&mut add, BinaryOperator::Add, high, low);
|
||||
components[member] = binary(&mut add, BinaryOperator::Divide, wide, max);
|
||||
}
|
||||
let value = add(Expression::Compose {
|
||||
ty: struct_ty,
|
||||
components,
|
||||
});
|
||||
// Everything from the first splat on is new and not a literal.
|
||||
let range = naga::Range::new_from_bounds(zero, value);
|
||||
out.push(Statement::Emit(range), span);
|
||||
out.push(Statement::Return { value: Some(value) }, span);
|
||||
}
|
||||
f.body = out;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clear_colours_keep_the_same_bits() {
|
||||
// The CPU side, for clear colours. Iris 1.11.7 read p8106d's clear colour back as below in
|
||||
// RGB5_A1 and RGB565, and p8106c's 0.3337 as 82/255 in the 5-bit channels and 85/255 in
|
||||
// the 6-bit one.
|
||||
let to_255 = |v: [f32; 4]| v.map(|c| (c * 255.0 * 1000.0).round() / 1000.0);
|
||||
let rgb5_a1 = bits(Format::parse("RGB5_A1").unwrap()).unwrap();
|
||||
let rgb565 = bits(Format::parse("RGB565").unwrap()).unwrap();
|
||||
let r3_g3_b2 = bits(Format::parse("R3_G3_B2").unwrap()).unwrap();
|
||||
let clear = [0.3337, 0.1, 0.6, 0.3];
|
||||
assert_eq!(to_255(quantise(clear, rgb5_a1)), [82.0, 24.0, 156.0, 0.0]);
|
||||
assert_eq!(to_255(quantise(clear, rgb565))[..3], [82.0, 24.0, 156.0]);
|
||||
assert_eq!(
|
||||
to_255(quantise([0.3337; 4], r3_g3_b2))[..3],
|
||||
[82.0, 85.0, 82.0]
|
||||
);
|
||||
assert!(bits(Format::parse("RGBA4").unwrap()).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! `RGB5_A1`, `RGB565` and `R3_G3_B2` buffers read back as in Iris 1.11.7. Expected values are
|
||||
//! those probe packs p8106c (written values) and p8106d (clear colours) read back in Iris (the PR
|
||||
//! has them); the programs here are the probes', cut down. Written values are ones whose byte
|
||||
//! (× 255) is not near a half: GPUs may round those either way.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
fn pack() -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
add("composite.vsh", VERTEX);
|
||||
add(
|
||||
"composite.fsh",
|
||||
"#version 120\n/* RENDERTARGETS: 1,2,3 */\n\
|
||||
/*\nconst int colortex1Format = RGB5_A1;\nconst int colortex2Format = RGB565;\n\
|
||||
const int colortex3Format = R3_G3_B2;\nconst int colortex4Format = RGB5_A1;\n\
|
||||
const int colortex5Format = RGB565;\n*/\n\
|
||||
const vec4 colortex4ClearColor = vec4(0.3337, 0.1, 0.6, 0.3);\n\
|
||||
const vec4 colortex5ClearColor = vec4(0.3337, 0.1, 0.6, 0.3);\n\
|
||||
void main() {\n\
|
||||
gl_FragData[0] = vec4(175.0, 176.0, 766.5, 256.0) / 511.0;\n\
|
||||
gl_FragData[1] = vec4(400.0, 175.0, 44.0, 0.0) / 511.0;\n\
|
||||
gl_FragData[2] = vec4(-255.5, 176.0, 300.0, 0.0) / 511.0;\n}\n",
|
||||
);
|
||||
add("final.vsh", VERTEX);
|
||||
// Pixel column x shows component x % 4 of buffer 1 + x / 4, as the bytes of its bits.
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 330 compatibility\nuniform sampler2D colortex1;\nuniform sampler2D colortex2;\n\
|
||||
uniform sampler2D colortex3;\nuniform sampler2D colortex4;\nuniform sampler2D colortex5;\n\
|
||||
void main() {\n ivec2 p = ivec2(gl_FragCoord.xy);\n vec4 c = vec4(0.0);\n\
|
||||
switch (p.x / 4) {\n\
|
||||
case 0: c = texelFetch(colortex1, p, 0); break;\n\
|
||||
case 1: c = texelFetch(colortex2, p, 0); break;\n\
|
||||
case 2: c = texelFetch(colortex3, p, 0); break;\n\
|
||||
case 3: c = texelFetch(colortex4, p, 0); break;\n\
|
||||
case 4: c = texelFetch(colortex5, p, 0); break;\n\
|
||||
}\n uint u = floatBitsToUint(c[p.x % 4]);\n\
|
||||
gl_FragColor = vec4(uvec4(u >> 24u, u >> 16u, u >> 8u, u) & 255u) / 255.0;\n}\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-packed",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯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<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! The shadow pass draws terrain faces from both sides, as Iris 1.11.7 does (#9033: a probe pack
|
||||
//! of ours writing `gl_FrontFacing` into `shadowcolor0` found back faces in Iris's shadow map and
|
||||
//! none in Horizon's). The programs here are original.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
const FINAL_VSH: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
fn pack() -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
// The terrain's positions are already in clip space.
|
||||
add(
|
||||
"shadow.vsh",
|
||||
"#version 120\nvoid main() { gl_Position=vec4(gl_Vertex.xy,0.5,1.0); }\n",
|
||||
);
|
||||
// Red where a front face was drawn, blue where a back face was.
|
||||
add(
|
||||
"shadow.fsh",
|
||||
"#version 120\nvoid main() {\n\
|
||||
gl_FragData[0]=gl_FrontFacing ? vec4(1.0,0.0,0.0,1.0) : vec4(0.0,0.0,1.0,1.0);\n}\n",
|
||||
);
|
||||
add("final.vsh", FINAL_VSH);
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 120\nconst int shadowMapResolution=16;\nuniform sampler2D shadowcolor0;\n\
|
||||
varying vec2 uv;\nvoid main() { gl_FragColor=texture2D(shadowcolor0,uv); }\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-shadow-faces",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
item_id_declared: false,
|
||||
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
|
||||
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
|
||||
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
|
||||
hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
|
||||
.into(),
|
||||
attributes: vec![],
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
horizon_shaders::load_post(&pack, "", &properties, ¯os, &inputs)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_pass_draws_terrain_back_faces() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
|
||||
let layouts = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 12,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &ATTRS,
|
||||
})];
|
||||
// Culled as the game's own terrain is (`Renderer` hands packs `cull: true`).
|
||||
let mut post = gpu.build_with_terrain(
|
||||
pack(),
|
||||
Some(Geometry {
|
||||
material_maps: false,
|
||||
buffers: &layouts,
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
ids: None,
|
||||
cull: true,
|
||||
}),
|
||||
);
|
||||
assert!(post.has_shadow(), "{:?}", post.notes);
|
||||
// GL clip space: the left half counter-clockwise (a front face), the right half
|
||||
// clockwise (a back face).
|
||||
let positions: [[f32; 3]; 12] = [
|
||||
[-1., -1., 0.],
|
||||
[0., -1., 0.],
|
||||
[0., 1., 0.],
|
||||
[-1., -1., 0.],
|
||||
[0., 1., 0.],
|
||||
[-1., 1., 0.],
|
||||
[0., -1., 0.],
|
||||
[1., 1., 0.],
|
||||
[1., -1., 0.],
|
||||
[0., -1., 0.],
|
||||
[0., 1., 0.],
|
||||
[1., 1., 0.],
|
||||
];
|
||||
let vertices = gpu
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Original shadow faces geometry"),
|
||||
contents: bytemuck::cast_slice(&positions),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let size = [8, 8];
|
||||
let pixels = gpu.render_with_terrain(&mut post, size, &mut |rp, list, _, _| {
|
||||
if list == TerrainList::ShadowSolid {
|
||||
rp.set_vertex_buffer(0, vertices.slice(..));
|
||||
rp.draw(0..12, 0..1);
|
||||
}
|
||||
});
|
||||
let near = |got: [u8; 4], want: [u8; 4]| got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1);
|
||||
for (i, got) in pixels.iter().enumerate() {
|
||||
let x = i as u32 % size[0];
|
||||
let want = if x < size[0] / 2 {
|
||||
[255, 0, 0, 255]
|
||||
} else {
|
||||
[0, 0, 255, 255]
|
||||
};
|
||||
assert!(
|
||||
near(*got, want),
|
||||
"pixel ({x}, {}): {got:?}, expected {want:?} (red: front face, blue: back face)",
|
||||
i as u32 / size[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! `min3`, `max3` and `mid3` of GL_AMD_shader_trinary_minmax: the per-component minimum, maximum
|
||||
//! and median of three values, for float, int and uint vectors (the extension's additions to
|
||||
//! GLSL 4.30 Section 8.3). An original probe; the expected values are worked out by hand.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
fn pack() -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
add("final.vsh", VERTEX);
|
||||
// Pixel column x shows case x % 8 in red, green and blue.
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 330 compatibility\n#extension GL_AMD_shader_trinary_minmax : require\n\
|
||||
void main() {\n int x = int(gl_FragCoord.x) % 8;\n\
|
||||
vec3 a = vec3(0.1, 0.9, 0.4), b = vec3(0.6, 0.2, 0.8), c = vec3(0.3, 0.5, 0.0);\n\
|
||||
ivec3 i = ivec3(-3, 7, 2), j = ivec3(5, -1, 9), k = ivec3(0, 4, -6);\n\
|
||||
uvec3 u = uvec3(1u, 8u, 3u), v = uvec3(6u, 2u, 7u), w = uvec3(4u, 5u, 0u);\n\
|
||||
vec3 o = vec3(0.0);\n\
|
||||
switch (x) {\n\
|
||||
case 0: o = min3(a, b, c); break;\n\
|
||||
case 1: o = max3(a, b, c); break;\n\
|
||||
case 2: o = mid3(a, b, c); break;\n\
|
||||
case 3: o = vec3(mid3(i, j, k)) / 10.0; break;\n\
|
||||
case 4: o = vec3(max3(u, v, w)) / 10.0; break;\n\
|
||||
case 5: o = vec3(min3(i, j, k) + 6) / 10.0; break;\n\
|
||||
case 6: o = vec3(mid3(u.x, v.x, w.x), mid3(u.y, v.y, w.y), mid3(u.z, v.z, w.z)) / 10.0; break;\n\
|
||||
case 7: o = vec3(max3(a.x, b.x, c.x), min3(a.yz, b.yz, c.yz)); break;\n\
|
||||
}\n gl_FragColor = vec4(o, 1.0);\n}\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-trinary",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
assert_eq!(post.programs.len(), 1, "{:?}", post.notes);
|
||||
post
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trinary_min_max_and_median_per_component() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut post = gpu.build(pack());
|
||||
let pixels = gpu.render(&mut post, [8, 8]);
|
||||
// Tenths, by hand: a = (1, 9, 4), b = (6, 2, 8), c = (3, 5, 0); i = (-3, 7, 2),
|
||||
// j = (5, -1, 9), k = (0, 4, -6); u = (1, 8, 3), v = (6, 2, 7), w = (4, 5, 0).
|
||||
let tenths: [[u8; 3]; 8] = [
|
||||
[1, 2, 0],
|
||||
[6, 9, 8],
|
||||
[3, 5, 4],
|
||||
[0, 4, 2],
|
||||
[6, 8, 7],
|
||||
[3, 5, 0],
|
||||
[4, 5, 3],
|
||||
[6, 2, 0],
|
||||
];
|
||||
let row: Vec<[u8; 3]> = pixels[..8].iter().map(|p| [p[0], p[1], p[2]]).collect();
|
||||
for (case, (got, want)) in row.iter().zip(tenths).enumerate() {
|
||||
let want = want.map(|t| (f32::from(t) * 25.5).round() as u8);
|
||||
assert!(
|
||||
got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1),
|
||||
"case {case}: {got:?}, expected {want:?}; row {row:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -215,16 +215,19 @@ fn pack(
|
||||
T::Rgba8Unorm,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: lightmap.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: [1, 1],
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
);
|
||||
)
|
||||
.expect("the pack runs");
|
||||
assert!(post.notes.is_empty(), "{:?}", post.notes);
|
||||
post
|
||||
}
|
||||
@@ -351,6 +354,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
|
||||
@@ -35,6 +35,13 @@ pub(super) struct ShadowTargets {
|
||||
pub depth: (wgpu::Texture, wgpu::TextureView),
|
||||
/// `shadowtex1`: a copy of `shadowtex0` taken before translucent terrain, when read.
|
||||
pub depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
/// `shadowtex0`/`1` as a plain sampler filters them: Iris 1.11.7 reads them linearly unless
|
||||
/// a pack asks for the nearest texel (measured). wgpu filters a depth format only for a
|
||||
/// comparison, so these are `R32Float` copies of the final depths, when read that way.
|
||||
pub linear: [Option<wgpu::TextureView>; 2],
|
||||
/// The rows a depth copy goes through to `linear` (a depth format copies only to a buffer),
|
||||
/// and their stride in bytes.
|
||||
staging: Option<(wgpu::Buffer, u32)>,
|
||||
/// Both sides of each shadowcolor buffer used.
|
||||
pub color: [Option<[wgpu::TextureView; 2]>; SHADOW_COLORS],
|
||||
/// The clear colour of each buffer cleared every frame.
|
||||
@@ -77,13 +84,15 @@ fn target(
|
||||
|
||||
impl ShadowTargets {
|
||||
/// The buffers of `shadow` the pack uses: `colors` (bit n: shadowcolor n), `depth1` when
|
||||
/// some program reads `shadowtex1`. The resolution is kept within the device's limit.
|
||||
/// some program reads `shadowtex1`, `linear` (bit n: shadowtex n read through a filtering
|
||||
/// sampler). The resolution is kept within the device's limit.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
shadow: &horizon_shaders::Shadow,
|
||||
formats: [wgpu::TextureFormat; SHADOW_COLORS],
|
||||
colors: u16,
|
||||
depth1: bool,
|
||||
linear: u8,
|
||||
) -> Self {
|
||||
let size = shadow
|
||||
.resolution
|
||||
@@ -105,10 +114,43 @@ impl ShadowTargets {
|
||||
})
|
||||
})
|
||||
});
|
||||
let linear = [0, 1].map(|n| {
|
||||
(linear & 1 << n != 0).then(|| {
|
||||
let label = format!("Shader pack shadowtex{n} (filtered)");
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(&label),
|
||||
size: wgpu::Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::R32Float,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
})
|
||||
});
|
||||
let staging = linear.iter().any(Option::is_some).then(|| {
|
||||
let stride = (size * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack shadow depth rows"),
|
||||
size: u64::from(stride) * u64::from(size),
|
||||
usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
(buffer, stride)
|
||||
});
|
||||
Self {
|
||||
size,
|
||||
depth: target(device, "Shader pack shadowtex0", size, depth, copy),
|
||||
depth1: depth1.then(|| target(device, "Shader pack shadowtex1", size, depth, copy)),
|
||||
linear,
|
||||
staging,
|
||||
color,
|
||||
clears: std::array::from_fn(|n| {
|
||||
let b = &shadow.colors[n];
|
||||
@@ -214,4 +256,38 @@ impl ShadowTargets {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `linear`: the filtered copies of the depths the shadow pass left (`shadowtex1` from its
|
||||
/// copy before translucent geometry).
|
||||
pub fn copy_linear(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some((buffer, stride)) = &self.staging else {
|
||||
return;
|
||||
};
|
||||
for (n, linear) in self.linear.iter().enumerate() {
|
||||
let Some(linear) = linear else {
|
||||
continue;
|
||||
};
|
||||
let depth = match (n, &self.depth1) {
|
||||
(1, Some(d)) => &d.0,
|
||||
_ => &self.depth.0,
|
||||
};
|
||||
let rows = wgpu::TexelCopyBufferInfo {
|
||||
buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(*stride),
|
||||
rows_per_image: None,
|
||||
},
|
||||
};
|
||||
encoder.copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
aspect: wgpu::TextureAspect::DepthOnly,
|
||||
..depth.as_image_copy()
|
||||
},
|
||||
rows,
|
||||
depth.size(),
|
||||
);
|
||||
encoder.copy_buffer_to_texture(rows, linear.texture().as_image_copy(), depth.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
// At the far plane: the sky shows only where nothing else drew, and it writes no depth.
|
||||
after: " gl_Position.z = gl_Position.w;\n".into(),
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +123,7 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
],
|
||||
after: " gl_Position.z = gl_Position.w;\n".into(),
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ pub(super) struct PackTexture {
|
||||
pub(super) sample_type: wgpu::TextureSampleType,
|
||||
pub(super) blur: bool,
|
||||
pub(super) clamp: bool,
|
||||
/// Decoded from a PNG, the pack's or the game's (not a raw dump, not a generated texture).
|
||||
pub(super) png: bool,
|
||||
pub(super) image: Image,
|
||||
}
|
||||
|
||||
@@ -30,7 +32,7 @@ pub(super) fn upload(
|
||||
let features = device.features();
|
||||
let mut out = Vec::new();
|
||||
for t in textures {
|
||||
let (image, dimension, sample_type, blur, clamp) = match t.source {
|
||||
let (image, dimension, sample_type, blur, clamp, png) = match t.source {
|
||||
TextureSource::Image(texture) => {
|
||||
let (view, sample_type) = match create(device, queue, &texture, features) {
|
||||
Ok(created) => created,
|
||||
@@ -50,6 +52,7 @@ pub(super) fn upload(
|
||||
sample_type,
|
||||
texture.blur,
|
||||
texture.clamp,
|
||||
!texture.raw,
|
||||
)
|
||||
}
|
||||
TextureSource::Resource(location) => {
|
||||
@@ -66,7 +69,14 @@ pub(super) fn upload(
|
||||
continue;
|
||||
};
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
(image, wgpu::TextureViewDimension::D2, float, true, true)
|
||||
(
|
||||
image,
|
||||
wgpu::TextureViewDimension::D2,
|
||||
float,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
}
|
||||
};
|
||||
out.push(PackTexture {
|
||||
@@ -76,6 +86,7 @@ pub(super) fn upload(
|
||||
sample_type,
|
||||
blur,
|
||||
clamp,
|
||||
png,
|
||||
image,
|
||||
});
|
||||
}
|
||||
@@ -285,6 +296,7 @@ mod tests {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
blur: false,
|
||||
clamp: false,
|
||||
png: false,
|
||||
image: Image::Lightmap,
|
||||
}
|
||||
}
|
||||
@@ -297,6 +309,7 @@ mod tests {
|
||||
texels,
|
||||
blur: false,
|
||||
clamp: false,
|
||||
raw: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -718,6 +718,13 @@ fn components(name: &str) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// What `name` gives custom uniforms before the first frame: zeros, at its size, so their
|
||||
/// shapes check out as on a frame (#9086).
|
||||
pub fn zero(name: &str) -> Option<horizon_shaders::Val> {
|
||||
index(name)?;
|
||||
Some(horizon_shaders::Val::vector(&[0.0; 16][..components(name)]))
|
||||
}
|
||||
|
||||
/// The position of `name` in [`NAMES`].
|
||||
pub fn index(name: &str) -> Option<usize> {
|
||||
NAMES.iter().position(|n| *n == name)
|
||||
@@ -752,6 +759,22 @@ mod tests {
|
||||
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
|
||||
}
|
||||
|
||||
/// The load-time run of custom uniforms sees each built-in at its size: Iris 1.11.7 reads 2
|
||||
/// for the first line of our probes K and X (#9086) and 0 for the others.
|
||||
#[test]
|
||||
fn custom_uniforms_check_the_shapes_of_built_ins_before_the_first_frame() {
|
||||
let properties = horizon_shaders::Properties::parse(
|
||||
"uniform.vec3.a = skyColor * vec3(0.0, 0.0, 0.0) + vec3(2.0, 2.0, 2.0)\n\
|
||||
uniform.float.b = skyColor\n\
|
||||
uniform.float.c = gbufferModelView * 0.0 + 2.0\n",
|
||||
&horizon_shaders::Macros::standard(),
|
||||
);
|
||||
let mut custom = horizon_shaders::Custom::parse(&properties);
|
||||
custom.eval(0.0, &zero);
|
||||
let broken: Vec<&str> = custom.notes.iter().map(|n| &n[1..2]).collect();
|
||||
assert_eq!(broken, ["b", "c"], "{:?}", custom.notes);
|
||||
}
|
||||
|
||||
/// Iris's status uniforms as its documentation defines them (shaders.properties, "Status").
|
||||
#[test]
|
||||
fn status_uniforms_follow_iris() {
|
||||
@@ -1006,11 +1029,6 @@ mod initial_history_tests {
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn core_matrix_inputs_render_provider_values() {
|
||||
use horizon_shaders::{
|
||||
GbuffersInputs, Macros, Pack, Target, preprocess, translate_gbuffers,
|
||||
};
|
||||
use std::io::Write;
|
||||
use wgpu::util::DeviceExt;
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Core matrix GPU: {:?}", adapter.get_info());
|
||||
@@ -1070,137 +1088,7 @@ mod initial_history_tests {
|
||||
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n\
|
||||
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}}"
|
||||
);
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (path, source) in [
|
||||
("shaders/gbuffers_line.vsh", vertex.as_str()),
|
||||
(
|
||||
"shaders/gbuffers_line.fsh",
|
||||
"#version 330\nin vec3 result; out vec4 color; void main() { color = vec4(result, 1.0); }",
|
||||
),
|
||||
] {
|
||||
zip.start_file(path, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
}
|
||||
let pack = Pack::from_zip_bytes("matrix-probe.zip", zip.finish().unwrap().into_inner())
|
||||
.unwrap();
|
||||
let macros = Macros::standard();
|
||||
let program = translate_gbuffers(
|
||||
&preprocess(&pack, "gbuffers_line.vsh", ¯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],
|
||||
@@ -1388,6 +1473,7 @@ mod initial_history_tests {
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
|
||||
@@ -44,6 +44,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
op: ">",
|
||||
reference: 0.1,
|
||||
}),
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,17 @@ impl Readback {
|
||||
let Some(texture) = self.texture.clone() else {
|
||||
return;
|
||||
};
|
||||
self.draw_onto(device, encoder, &texture, surface);
|
||||
}
|
||||
|
||||
/// Draws `frame` onto the swapchain image (or offscreen target) `surface`.
|
||||
pub fn draw_onto(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &wgpu::Texture,
|
||||
surface: &wgpu::Texture,
|
||||
) {
|
||||
// The frame's passes write the swapchain through its non-sRGB view
|
||||
// (`view_formats`), so the copy goes there unconverted too.
|
||||
let format = surface.format().remove_srgb_suffix();
|
||||
@@ -74,7 +85,7 @@ impl Readback {
|
||||
format: Some(format),
|
||||
..Default::default()
|
||||
});
|
||||
self.blit(device, encoder, &texture, &target, format);
|
||||
self.blit(device, encoder, frame, &target, format);
|
||||
}
|
||||
|
||||
/// `frame` (a 16-bit frame) in an 8-bit texture of `format`, clamped to 0..1
|
||||
|
||||
@@ -92,7 +92,7 @@ impl<T> Resident<T> {
|
||||
self.items.iter_mut().map(|(_, v)| v)
|
||||
}
|
||||
/// Every section's key, value and bounds.
|
||||
#[cfg(debug_assertions)]
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
pub fn iter(&self) -> impl Iterator<Item = (SectionKey, &T, Option<[Vec3; 2]>)> {
|
||||
let items = self.items.iter().zip(&self.bounds);
|
||||
items.map(|((key, value), bounds)| (*key, value, *bounds))
|
||||
@@ -194,6 +194,12 @@ impl<T> Resident<T> {
|
||||
column.low = column.low.min(key.1);
|
||||
column.high = column.high.max(key.1);
|
||||
}
|
||||
/// Marks or unmarks the section at `key`, if it is resident.
|
||||
pub fn set_marked(&mut self, key: SectionKey, marked: bool) {
|
||||
if let Some(&slot) = self.slots.get(&key) {
|
||||
self.mark_slot(slot, marked);
|
||||
}
|
||||
}
|
||||
/// Marks or unmarks the section in `slot`, in its column's list too.
|
||||
fn mark_slot(&mut self, slot: u32, marked: bool) {
|
||||
let was = std::mem::replace(&mut self.marked[slot as usize], marked);
|
||||
@@ -657,7 +663,8 @@ mod tests {
|
||||
(!value.is_multiple_of(3)).then(|| [Vec3::splat((value % 5) as f32), Vec3::splat(16.0)])
|
||||
};
|
||||
// A deterministic mix of inserts, replacements and removals, marked at
|
||||
// random: a replacement can mark or unmark its section.
|
||||
// random: a replacement can mark or unmark its section, and so can a
|
||||
// mark set alone (`set_marked`), which leaves a key not resident alone.
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
for step in 0..4000u32 {
|
||||
seed ^= seed << 13;
|
||||
@@ -694,6 +701,12 @@ mod tests {
|
||||
assert_eq!(removed, expected);
|
||||
} else if (seed >> 12).is_multiple_of(3) {
|
||||
assert_eq!(r.remove(key), model.remove(&key).map(|(value, _)| value));
|
||||
} else if (seed >> 12).is_multiple_of(5) {
|
||||
let marked = (seed >> 20).is_multiple_of(2);
|
||||
r.set_marked(key, marked);
|
||||
if let Some(held) = model.get_mut(&key) {
|
||||
held.1 = marked;
|
||||
}
|
||||
} else {
|
||||
let marked = (seed >> 20).is_multiple_of(2);
|
||||
r.insert_marked(key, step, bounds(step), marked);
|
||||
|
||||
@@ -1050,7 +1050,9 @@ impl Item {
|
||||
Item::Horizon(H::Hdr) => g.hdr,
|
||||
Item::Horizon(H::BatteryMode) => g.battery,
|
||||
Item::Horizon(H::ShadowCache) => g.shadow_cache,
|
||||
Item::Horizon(H::TranslucentBatching) => g.translucent_batching,
|
||||
Item::Horizon(H::TranslucentBatching) => {
|
||||
g.translucent_batching && crate::graphics::translucent_batching_offered()
|
||||
}
|
||||
Item::Horizon(H::HdrSmoothGradients) => {
|
||||
g.hdr_smooth_gradients && crate::graphics::smooth_gradients_offered()
|
||||
}
|
||||
@@ -3164,13 +3166,13 @@ mod tests {
|
||||
assert_eq!(page, Some(Page::Advanced));
|
||||
let mut s = Settings::default();
|
||||
s.horizon.preset = Preset::Balanced;
|
||||
assert_eq!(item.switch(&s), Some(true));
|
||||
let mut ui = GraphicsUi::default();
|
||||
// Where the device can't, the saved On reads Off, and neither a click nor Enter
|
||||
// after it does anything: no sound, no change, no save.
|
||||
crate::graphics::offer_translucent_batching(false);
|
||||
assert!(item.blocked(&s).is_some());
|
||||
assert_eq!(item.label(&s).plain(), "Translucent Batching: Off");
|
||||
assert_eq!(item.switch(&s), Some(false));
|
||||
let r = row(&mut ui, &s, item);
|
||||
let point = (r[0] + 4.0, r[1] + 4.0);
|
||||
assert!(press(&mut ui, &mut s, point).is_empty());
|
||||
@@ -3181,6 +3183,7 @@ mod tests {
|
||||
// Where it can, a click switches it and keeps the preset.
|
||||
crate::graphics::offer_translucent_batching(true);
|
||||
assert_eq!(item.label(&s).plain(), "Translucent Batching: On");
|
||||
assert_eq!(item.switch(&s), Some(true));
|
||||
assert_eq!(
|
||||
press(&mut ui, &mut s, point),
|
||||
[Action::ButtonClick, Action::Changed]
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -27,6 +27,8 @@ use horizon_world::{
|
||||
fluid::{FluidKind, FluidState},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use sides::sort_tinted;
|
||||
pub use sides::{Part, Sides, group_parts};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -1405,7 +1407,8 @@ fn pack_coords(coords: u32) -> u16 {
|
||||
pub struct Mesher<'a> {
|
||||
/// CardinalLighting.NETHER: brighter up/down faces.
|
||||
pub nether_shading: bool,
|
||||
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours.
|
||||
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours,
|
||||
/// only the dimension's up-face brightness (`lighting::pack_flat`).
|
||||
pub shader_pack_lighting: bool,
|
||||
/// OptiFine/Iris separate ambient occlusion: vertex RGB does not include AO, and AO factor is passed in vertex alpha.
|
||||
pub separate_ao: bool,
|
||||
@@ -1660,8 +1663,10 @@ impl Mesher<'_> {
|
||||
None => ao,
|
||||
};
|
||||
let shade_face = q.shade_direction_override.unwrap_or(q.facing);
|
||||
let directional = if self.shader_pack_lighting || !q.shade {
|
||||
let directional = if !q.shade {
|
||||
1.0
|
||||
} else if self.shader_pack_lighting {
|
||||
lighting::pack_flat(self.nether_shading)
|
||||
} else {
|
||||
lighting::cardinal(self.nether_shading, shade_face)
|
||||
};
|
||||
|
||||
@@ -189,8 +189,9 @@ impl FluidFlowWorld for FlowView<'_> {
|
||||
}
|
||||
}
|
||||
/// Emits one fluid cell at section-relative `[x, y, z]`.
|
||||
/// `(nether, shader_pack_lighting, separate_ao)`: the Nether's face shading, none when a shader pack
|
||||
/// lights the faces (`Mesher::shader_pack_lighting`), and whether to keep pure alpha for separate AO.
|
||||
/// `(nether, shader_pack_lighting, separate_ao)`: the Nether's face shading, only the up-face value
|
||||
/// when a shader pack lights the faces (`Mesher::shader_pack_lighting`), and whether to keep pure
|
||||
/// alpha for separate AO.
|
||||
pub(super) fn tesselate(
|
||||
tables: &MeshTables,
|
||||
(nether, shader_pack_lighting, separate_ao): (bool, bool, bool),
|
||||
@@ -202,7 +203,7 @@ pub(super) fn tesselate(
|
||||
use horizon_assets::model::Direction as D;
|
||||
let cardinal = |d: D| {
|
||||
if shader_pack_lighting {
|
||||
1.0
|
||||
lighting::pack_flat(nether)
|
||||
} else {
|
||||
lighting::cardinal(nether, d)
|
||||
}
|
||||
@@ -447,7 +448,10 @@ pub(super) fn tesselate(
|
||||
Some(layer) if face_state.overlay => (layer, true),
|
||||
_ => (flow, false),
|
||||
};
|
||||
let side = if matches!(d, Direction::North | Direction::South) {
|
||||
// Vanilla's side is the up value times the side's; a shader pack's has no side factor.
|
||||
let side = if shader_pack_lighting {
|
||||
1.0
|
||||
} else if matches!(d, Direction::North | Direction::South) {
|
||||
cardinal(D::North)
|
||||
} else {
|
||||
cardinal(D::West)
|
||||
|
||||
@@ -19,6 +19,12 @@ pub(super) fn cardinal(nether: bool, face: D) -> f32 {
|
||||
D::West | D::East => 0.6,
|
||||
}
|
||||
}
|
||||
/// A face's brightness when a shader pack lights the faces (`Mesher::shader_pack_lighting`): no
|
||||
/// directional shading, every face at the dimension's up-face value (1.0, the Nether's 0.9).
|
||||
/// Our probe pack's Iris 1.11.7 values fit this: 0.9 on every Nether face, 1.0 in the overworld.
|
||||
pub(super) fn pack_flat(nether: bool) -> f32 {
|
||||
cardinal(nether, D::Up)
|
||||
}
|
||||
fn normal(face: D) -> [i32; 3] {
|
||||
match face {
|
||||
D::Down => [0, -1, 0],
|
||||
|
||||
@@ -159,6 +159,60 @@ fn a_shader_pack_lights_the_faces_itself() {
|
||||
assert_eq!(colours(true).len(), 1);
|
||||
}
|
||||
|
||||
/// Run explicitly with prepared assets. With a shader pack lighting the faces, every face of a
|
||||
/// block and of a lava source takes the dimension's up-face brightness, as Iris 1.11.7 gives our
|
||||
/// probe pack: 0.9 in the Nether (`CardinalLighting.NETHER`'s up, 229 of 255), 1.0 elsewhere.
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn a_shader_pack_lights_every_nether_face_like_an_up_face() {
|
||||
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3")
|
||||
});
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
|
||||
let state = |name| assets.registry.default_state(name).unwrap().0;
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
chunk.set(4, MIN_Y + 8, 4, state("minecraft:netherrack"));
|
||||
chunk.set(11, MIN_Y + 8, 11, state("minecraft:lava"));
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(&chunk);
|
||||
let colours = |nether_shading| {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading,
|
||||
shader_pack_lighting: true,
|
||||
separate_ao: false,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
biomes: &biomes,
|
||||
visibility: visibility.clone(),
|
||||
zoom: BiomeZoom::new(0),
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves: true,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
let mesh = mesher.mesh(®ion, 0);
|
||||
// Both blocks' six faces: 2 × 6 quads of 4 vertices.
|
||||
let all: Vec<_> = mesh.solid.iter().chain(&mesh.translucent).collect();
|
||||
assert!(all.len() >= 48, "{} vertices", all.len());
|
||||
let mut colours: Vec<u32> = all.iter().map(|v| v.color & 0xFF_FFFF).collect();
|
||||
colours.sort_unstable();
|
||||
colours.dedup();
|
||||
colours
|
||||
};
|
||||
assert_eq!(colours(true), [0xE5_E5E5]);
|
||||
assert_eq!(colours(false), [0xFF_FFFF]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn separate_ao_passes_ao_in_alpha_without_darkening_rgb() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -170,8 +170,17 @@ fn last_pre_minor(current: Format) -> i64 {
|
||||
/// `PackFormat.fullCodec(default_minor)`: an int, or a list of 1 to 256
|
||||
/// non-negative ints whose second is the minor. None: it does not parse.
|
||||
fn format_codec(v: &serde_json::Value, default_minor: i64) -> Option<Format> {
|
||||
let non_negative =
|
||||
|v: &serde_json::Value| v.as_i64().filter(|n| *n >= 0 && *n <= i64::from(i32::MAX));
|
||||
// `Codec.INT` reads any JSON number through `Number.intValue`: a decimal such as `97.1`
|
||||
// counts as its whole part (97), as it does for the game.
|
||||
let non_negative = |v: &serde_json::Value| {
|
||||
v.as_i64()
|
||||
.or_else(|| {
|
||||
v.as_f64()
|
||||
.filter(|f| f.is_finite())
|
||||
.map(|f| f.trunc() as i64)
|
||||
})
|
||||
.filter(|n| *n >= 0 && *n <= i64::from(i32::MAX))
|
||||
};
|
||||
match v {
|
||||
serde_json::Value::Array(a) if (1..=256).contains(&a.len()) => {
|
||||
let list: Option<Vec<i64>> = a.iter().map(non_negative).collect();
|
||||
@@ -636,7 +645,7 @@ fn build_overlay_uncached(
|
||||
}
|
||||
// FallbackResourceManager: a file's `.mcmeta` counts only from the pack that
|
||||
// supplies the file or one above it; vanilla's own sits below every pack.
|
||||
let hidden: BTreeSet<String> = files
|
||||
let mut hidden: BTreeSet<String> = files
|
||||
.iter()
|
||||
.filter(|(name, _)| !name.ends_with(".mcmeta"))
|
||||
.filter_map(|(name, (owner, _))| {
|
||||
@@ -647,6 +656,25 @@ fn build_overlay_uncached(
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// A companion from below the winning colour would describe another surface.
|
||||
// A map-only pack above it is intentional and remains valid.
|
||||
for (name, (owner, _)) in &files {
|
||||
let Some(stem) = name.strip_suffix(".png") else {
|
||||
continue;
|
||||
};
|
||||
if stem.ends_with("_n") || stem.ends_with("_s") {
|
||||
continue;
|
||||
}
|
||||
for suffix in ["_n.png", "_s.png"] {
|
||||
let map = format!("{stem}{suffix}");
|
||||
if files
|
||||
.get(&map)
|
||||
.is_none_or(|(map_owner, _)| map_owner < owner)
|
||||
{
|
||||
hidden.insert(map);
|
||||
}
|
||||
}
|
||||
}
|
||||
for name in &hidden {
|
||||
files.remove(name);
|
||||
}
|
||||
@@ -1257,6 +1285,16 @@ mod tests {
|
||||
.unwrap_err()
|
||||
.contains("deprecated")
|
||||
);
|
||||
// A decimal reads as its whole part, in a list or alone: `[97.1]` is format 97 (a pack
|
||||
// that meant `[97, 1]` still loads and counts as compatible).
|
||||
assert_eq!(
|
||||
check(r#"{"min_format":[97.1],"max_format":[97.1]}"#),
|
||||
Ok(((97, 0), (97, i64::from(i32::MAX))))
|
||||
);
|
||||
assert_eq!(
|
||||
check(r#"{"min_format":97.9,"max_format":98.5}"#),
|
||||
Ok(((97, 0), (98, i64::from(i32::MAX))))
|
||||
);
|
||||
assert_eq!(
|
||||
check(r#"{"min_format":97,"max_format":98}"#),
|
||||
Ok(((97, 0), (98, i64::from(i32::MAX))))
|
||||
@@ -1469,6 +1507,61 @@ mod tests {
|
||||
assert_eq!(merge_kind("assets/minecraft/models/block/stone.json"), None);
|
||||
}
|
||||
#[test]
|
||||
fn companion_maps_follow_the_winning_colour_or_a_higher_map_only_pack() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-material-owner-{}", std::process::id()));
|
||||
let vanilla = root.join("vanilla");
|
||||
let relative = "assets/minecraft/textures/block";
|
||||
fs::create_dir_all(vanilla.join(relative)).unwrap();
|
||||
let names = ["stone.png", "stone_n.png", "stone_s.png"];
|
||||
for name in names {
|
||||
fs::write(vanilla.join(relative).join(name), b"base").unwrap();
|
||||
}
|
||||
for (pack, files) in [
|
||||
("low", vec!["stone_n.png", "stone_s.png"]),
|
||||
("colour", vec!["stone.png"]),
|
||||
("maps", vec!["stone_n.png"]),
|
||||
] {
|
||||
let dir = root.join("packs").join(pack);
|
||||
fs::create_dir_all(dir.join(relative)).unwrap();
|
||||
fs::write(
|
||||
dir.join("pack.mcmeta"),
|
||||
br#"{"pack":{"description":"fixture","pack_format":97}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
for name in files {
|
||||
fs::write(dir.join(relative).join(name), pack.as_bytes()).unwrap();
|
||||
}
|
||||
}
|
||||
let all = discover(&vanilla, &root.join("packs"));
|
||||
let order = |names: &[&str]| {
|
||||
names
|
||||
.iter()
|
||||
.map(|name| {
|
||||
all.iter()
|
||||
.find(|p| p.id == format!("file/{name}"))
|
||||
.unwrap()
|
||||
.clone()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let out = root.join("out");
|
||||
build_overlay(&vanilla, &order(&["low", "colour"]), &out).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(out.join(relative).join("stone.png")).unwrap(),
|
||||
b"colour"
|
||||
);
|
||||
assert!(!out.join(relative).join("stone_n.png").exists());
|
||||
assert!(!out.join(relative).join("stone_s.png").exists());
|
||||
build_overlay(&vanilla, &order(&["low", "colour", "maps"]), &out).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(out.join(relative).join("stone_n.png")).unwrap(),
|
||||
b"maps"
|
||||
);
|
||||
assert!(!out.join(relative).join("stone_s.png").exists());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn overlay_layers_packs_over_vanilla() {
|
||||
// `&` would split an unquoted `cmd /C mklink` line.
|
||||
let base = std::env::temp_dir().join(format!("horizon-packs-&{}", std::process::id()));
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
|
||||
// Version 5 read every vanilla file to stamp it on Windows.
|
||||
// Version 6 stamps vanilla's files by their metadata on Windows too.
|
||||
// Version 7 stacks blockstate files (every pack's definition, bottom first).
|
||||
const VERSION: u32 = 7;
|
||||
const VERSION: u32 = 8;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub(super) struct Dependencies {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,67 @@
|
||||
//! The controller's menu cursor in inventories: a crosshair whose centre is
|
||||
//! the point it clicks. Horizon's own art (pad_cursor.png).
|
||||
|
||||
/// The atlas sprite.
|
||||
pub const SPRITE: &str = "horizon/pad_cursor";
|
||||
/// The texture's size in texels, and the size it's drawn at in GUI pixels
|
||||
/// (an item's).
|
||||
pub const SIZE: u32 = 32;
|
||||
pub const DRAWN: f32 = 16.0;
|
||||
|
||||
const PNG: &[u8] = include_bytes!("pad_cursor.png");
|
||||
|
||||
/// The texture as `SIZE` square RGBA texels.
|
||||
pub fn pixels() -> Vec<u8> {
|
||||
decode(PNG).expect("pad_cursor.png is a 32x32 PNG")
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
let info = reader.info();
|
||||
if (info.width, info.height) != (SIZE, SIZE) {
|
||||
return None;
|
||||
}
|
||||
let mut buf = vec![0; reader.output_buffer_size()?];
|
||||
let frame = reader.next_frame(&mut buf).ok()?;
|
||||
let channels = frame.color_type.samples();
|
||||
Some(
|
||||
buf.chunks(channels)
|
||||
.take((SIZE * SIZE) as usize)
|
||||
.flat_map(|p| match channels {
|
||||
4 => [p[0], p[1], p[2], p[3]],
|
||||
3 => [p[0], p[1], p[2], 255],
|
||||
2 => [p[0], p[0], p[0], p[1]],
|
||||
_ => [p[0], p[0], p[0], 255],
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Where to draw the cursor (top-left, GUI pixels) so its centre lands on
|
||||
/// the GUI point `at`.
|
||||
pub fn origin(at: (f32, f32)) -> (f32, f32) {
|
||||
(at.0 - DRAWN / 2.0, at.1 - DRAWN / 2.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_cursor_is_a_crosshair_centred_on_its_point() {
|
||||
let px = pixels();
|
||||
assert_eq!(px.len(), (SIZE * SIZE * 4) as usize);
|
||||
let alpha = |x: u32, y: u32| px[((y * SIZE + x) * 4 + 3) as usize];
|
||||
let middle = (SIZE / 2 - 1)..=(SIZE / 2);
|
||||
assert!(
|
||||
middle
|
||||
.clone()
|
||||
.any(|x| middle.clone().any(|y| alpha(x, y) > 0)),
|
||||
"the clicked point sits on the crosshair"
|
||||
);
|
||||
assert_eq!(alpha(0, 0), 0, "corners are clear: a plus, not a square");
|
||||
assert_eq!(origin((100.0, 50.0)), (92.0, 42.0));
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ use std::{
|
||||
|
||||
pub use horizon_client_core::particle_type::ParticleType;
|
||||
pub use horizon_items::firework::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape};
|
||||
mod native;
|
||||
pub use native::{ParticleController, ParticleUpdate};
|
||||
|
||||
/// `ParticleOptions` with the parameters of the option classes used here.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -212,6 +214,9 @@ enum Light {
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Behaviour {
|
||||
Base,
|
||||
Controlled {
|
||||
translucent: bool,
|
||||
},
|
||||
Mod {
|
||||
size: [f32; 2],
|
||||
alpha: [f32; 2],
|
||||
@@ -505,6 +510,7 @@ pub struct Particle {
|
||||
pre_tick: bool,
|
||||
/// `Particle.random`: the particle's own generator (`RandomSource.create()`).
|
||||
random: LegacyRandom,
|
||||
controller: Option<Box<dyn native::Controller>>,
|
||||
}
|
||||
fn aabb_at(p: [f64; 3], w: f32, h: f32) -> Aabb {
|
||||
let w = f64::from(w) / 2.0;
|
||||
@@ -653,6 +659,30 @@ pub fn around_self(
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.handleEntityEvent(46)`: 128 `ParticleTypes.PORTAL` samples
|
||||
/// from the entity's old position (`xo`, `yo`, `zo`) to its current one,
|
||||
/// spread across the bounding box (`getBbWidth() * 2` on x and z, `getBbHeight`
|
||||
/// on y). The speeds are `(nextFloat() - 0.5) * 0.2`.
|
||||
pub fn living_teleport_particles(
|
||||
random: &mut impl RandomSource,
|
||||
from: [f64; 3],
|
||||
to: [f64; 3],
|
||||
width: f64,
|
||||
height: f64,
|
||||
mut emit: impl FnMut([f64; 3], [f64; 3]),
|
||||
) {
|
||||
for i in 0..128 {
|
||||
let d = i as f64 / 127.0;
|
||||
let velocity = [0; 3].map(|_| f64::from((random.next_float() - 0.5) * 0.2));
|
||||
let position = [
|
||||
from[0] + d * (to[0] - from[0]) + (random.next_double() - 0.5) * width * 2.0,
|
||||
from[1] + d * (to[1] - from[1]) + random.next_double() * height,
|
||||
from[2] + d * (to[2] - from[2]) + (random.next_double() - 0.5) * width * 2.0,
|
||||
];
|
||||
emit(position, velocity);
|
||||
}
|
||||
}
|
||||
|
||||
/// ClientLevel.addDestroyBlockEffect placement and pre-constructor velocities.
|
||||
pub fn destroy_positions(
|
||||
pos: [i32; 3],
|
||||
@@ -841,8 +871,37 @@ impl Particle {
|
||||
pre_tick: false,
|
||||
frame_ticks: 0,
|
||||
random: LegacyRandom::new(0),
|
||||
controller: None,
|
||||
}
|
||||
}
|
||||
/// The normal age/removal test and previous pose, without vanilla motion.
|
||||
/// Returns false when this tick expires the particle.
|
||||
pub fn advance_age(&mut self) -> bool {
|
||||
self.previous = self.position;
|
||||
self.o_roll = self.roll;
|
||||
let alive = self.age < self.lifetime;
|
||||
self.age = self.age.saturating_add(1);
|
||||
self.removed |= !alive;
|
||||
alive
|
||||
}
|
||||
pub fn remove(&mut self) {
|
||||
self.removed = true;
|
||||
}
|
||||
/// Hide geometry while retaining the particle's lifetime and controller.
|
||||
pub fn set_visible(&mut self, visible: bool) {
|
||||
self.no_render = !visible;
|
||||
}
|
||||
/// The particle's own random, distinct from the engine and level generators.
|
||||
pub fn random(&mut self) -> &mut LegacyRandom {
|
||||
&mut self.random
|
||||
}
|
||||
pub fn set_full_bright(&mut self, full_bright: bool) {
|
||||
self.light = if full_bright {
|
||||
Light::FullBright
|
||||
} else {
|
||||
Light::World
|
||||
};
|
||||
}
|
||||
/// `Particle(level, x, y, z, xa, ya, za)`: jittered, normalized initial velocity.
|
||||
fn moving(random: &mut LegacyRandom, ty: ParticleType, p: [f64; 3], v: [f64; 3]) -> Self {
|
||||
let mut particle = Self::base_moving(random, ty, p, v);
|
||||
@@ -1420,6 +1479,10 @@ impl ParticleEngine {
|
||||
atlas: &HashMap<String, Rect>,
|
||||
translucent: &HashSet<String>,
|
||||
) {
|
||||
// Controllers' numeric sprite-set handles belong to this resource
|
||||
// generation. Discard them rather than selecting another set after reload.
|
||||
self.particles.retain(|p| p.controller.is_none());
|
||||
self.pending.retain(|p| p.controller.is_none());
|
||||
self.atlas = atlas
|
||||
.iter()
|
||||
.map(|(name, &rect)| (name.clone(), (rect, translucent.contains(name))))
|
||||
@@ -1584,6 +1647,45 @@ impl ParticleEngine {
|
||||
pub fn push(&mut self, particle: Particle) {
|
||||
self.pending.push(particle);
|
||||
}
|
||||
/// Create a named billboard with custom compiled behavior and no vanilla
|
||||
/// motion. Like `add`, respects camera distance and the Particles setting.
|
||||
/// Configure its public fields, then `push` it to the ordinary engine queue.
|
||||
/// Missing sprite sets return None. The constructor has the ordinary
|
||||
/// stationary SingleQuadParticle random size/lifetime and no velocity draws.
|
||||
pub fn controlled<C: ParticleController>(
|
||||
&mut self,
|
||||
name: &str,
|
||||
position: [f64; 3],
|
||||
translucent: bool,
|
||||
controller: C,
|
||||
) -> Option<Particle> {
|
||||
let minimal = self.calculate_particle_level(false) == 2;
|
||||
let distance: f64 = position
|
||||
.iter()
|
||||
.zip(self.camera)
|
||||
.map(|(p, c)| (p - c).powi(2))
|
||||
.sum();
|
||||
if minimal || !distance.is_finite() || distance > 1024.0 {
|
||||
return None;
|
||||
}
|
||||
let (set, _) = *self.mod_sets.get(name)?;
|
||||
let mut random = LegacyRandom::new(self.seeds.next_long());
|
||||
let mut particle = Particle::at(&mut random, ParticleType::Smoke, position);
|
||||
particle.random = random;
|
||||
particle.set = Some(set);
|
||||
particle.sprite = Sprite::Index(*self.set_sprites.get(set as usize)?.first()?);
|
||||
particle.behaviour = Behaviour::Controlled { translucent };
|
||||
particle.controller = Some(Box::new(controller));
|
||||
Some(particle)
|
||||
}
|
||||
/// Choose a frame from this particle's declared sprite set.
|
||||
pub fn set_sprite_frame(&self, particle: &mut Particle, frame: usize) {
|
||||
if let Some(sprites) = particle.set.and_then(|s| self.set_sprites.get(s as usize))
|
||||
&& !sprites.is_empty()
|
||||
{
|
||||
particle.sprite = Sprite::Index(sprites[frame.min(sprites.len() - 1)]);
|
||||
}
|
||||
}
|
||||
/// Random generator shared by particle constructors and spawners.
|
||||
pub fn random(&mut self) -> &mut LegacyRandom {
|
||||
&mut self.random
|
||||
@@ -3710,6 +3812,20 @@ impl ParticleEngine {
|
||||
fn tick_particle(&mut self, p: &mut Particle, level: &ParticleLevel) {
|
||||
match p.behaviour {
|
||||
Behaviour::Base => p.base_tick(level),
|
||||
Behaviour::Controlled { .. } => {
|
||||
if let Some(mut controller) = p.controller.take() {
|
||||
let update = controller.tick(p, level);
|
||||
p.controller = Some(controller);
|
||||
if let Some(frame) = update.frame {
|
||||
self.set_sprite_frame(p, frame);
|
||||
}
|
||||
for (options, pos, velocity) in update.spawned {
|
||||
self.add(&options, pos, velocity);
|
||||
}
|
||||
} else {
|
||||
p.remove();
|
||||
}
|
||||
}
|
||||
Behaviour::Mod { size, alpha } => {
|
||||
p.base_tick(level);
|
||||
p.size = mod_curve(size, p.age, p.lifetime);
|
||||
@@ -4627,6 +4743,7 @@ impl ParticleEngine {
|
||||
Sprite::Index(i) => (
|
||||
self.rects.get(*i as usize).copied().flatten(),
|
||||
p.ty.translucent_layer()
|
||||
|| matches!(p.behaviour, Behaviour::Controlled { translucent: true })
|
||||
|| matches!(p.behaviour, Behaviour::Mod { alpha, .. } if alpha != [1.0; 2]),
|
||||
),
|
||||
Sprite::Named(name) => match self.atlas.get(name) {
|
||||
@@ -5318,6 +5435,47 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// `LivingEntity.handleEntityEvent(46)`: 128 samples, the first on the old
|
||||
/// box and the last on the new one, with speeds inside ±0.1.
|
||||
#[test]
|
||||
fn living_teleport_particles_span_the_old_and_new_boxes() {
|
||||
let mut random = LegacyRandom::new(1);
|
||||
let mut samples = Vec::new();
|
||||
living_teleport_particles(
|
||||
&mut random,
|
||||
[0.0, 1.0, 0.0],
|
||||
[8.0, 1.0, 0.0],
|
||||
0.6,
|
||||
1.8,
|
||||
|p, v| samples.push((p, v)),
|
||||
);
|
||||
assert_eq!(samples.len(), 128);
|
||||
let first = samples[0].0;
|
||||
let last = samples[127].0;
|
||||
assert!(first[0].abs() <= 0.6, "{first:?}");
|
||||
assert!((1.0..2.8).contains(&first[1]), "{first:?}");
|
||||
assert!(first[2].abs() <= 0.6, "{first:?}");
|
||||
assert!((7.4..8.6).contains(&last[0]), "{last:?}");
|
||||
assert!((1.0..2.8).contains(&last[1]), "{last:?}");
|
||||
assert!(last[2].abs() <= 0.6, "{last:?}");
|
||||
// `(nextDouble - 0.5) * width * 2` reaches ±width. A spread of only
|
||||
// `width` (the factor of 2 dropped) stays inside 0.3 of the line.
|
||||
let edge = samples
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (p, _))| {
|
||||
let along = (i as f64 / 127.0) * 8.0;
|
||||
(p[0] - along).abs()
|
||||
})
|
||||
.fold(0.0_f64, f64::max);
|
||||
assert!(edge > 0.45, "box spread reached only {edge}");
|
||||
for (_, v) in &samples {
|
||||
for c in v {
|
||||
assert!((-0.1..0.1).contains(c), "{v:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn egg_teleport_unpacks_offsets_and_preserves_seeded_draw_order() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Opt-in, compiled particle behavior. The ordinary engine still owns the
|
||||
//! particle, its sprite set, interpolation, rendering and total count limits.
|
||||
use super::{Particle, ParticleLevel, ParticleOptions};
|
||||
use std::fmt::Debug;
|
||||
|
||||
/// Requests made by one particle tick, submitted through normal spawn limits.
|
||||
#[derive(Default)]
|
||||
pub struct ParticleUpdate {
|
||||
/// An index in this particle's declared sprite set. Out-of-range is clamped.
|
||||
pub frame: Option<usize>,
|
||||
pub spawned: Vec<(ParticleOptions, [f64; 3], [f64; 3])>,
|
||||
}
|
||||
|
||||
/// Owned behavior for a compiled native particle. Cloning a particle clones its
|
||||
/// controller state. Do not retain world references; inspect `level` each tick.
|
||||
pub trait ParticleController: Clone + Debug + Send + Sync + 'static {
|
||||
/// Call `particle.advance_age()` for ordinary lifetime/interpolation, or
|
||||
/// explicitly maintain those fields for an overridden source tick.
|
||||
fn tick(&mut self, particle: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate;
|
||||
}
|
||||
|
||||
pub(super) trait Controller: Debug + Send + Sync {
|
||||
fn tick(&mut self, particle: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate;
|
||||
fn clone_box(&self) -> Box<dyn Controller>;
|
||||
}
|
||||
impl<T: ParticleController> Controller for T {
|
||||
fn tick(&mut self, particle: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate {
|
||||
ParticleController::tick(self, particle, level)
|
||||
}
|
||||
fn clone_box(&self) -> Box<dyn Controller> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
impl Clone for Box<dyn Controller> {
|
||||
fn clone(&self) -> Self {
|
||||
self.clone_box()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::*;
|
||||
use horizon_world::{
|
||||
DATA_VERSION,
|
||||
collision::CachedCollisionRegistry,
|
||||
nbt::Tag,
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
#[derive(Clone, Debug)]
|
||||
struct Moving(u32);
|
||||
impl ParticleController for Moving {
|
||||
fn tick(&mut self, p: &mut Particle, _: &ParticleLevel<'_>) -> ParticleUpdate {
|
||||
if p.advance_age() {
|
||||
self.0 += 1;
|
||||
p.position[0] += f64::from(self.0);
|
||||
}
|
||||
ParticleUpdate {
|
||||
frame: Some(self.0 as usize),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
fn engine() -> ParticleEngine {
|
||||
let mut e = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
e.mod_sets.insert("example:orbit".into(), (0, None));
|
||||
e.set_sprites.push(vec![0, 1]);
|
||||
e.rects = vec![
|
||||
Some(Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16
|
||||
});
|
||||
2
|
||||
];
|
||||
e
|
||||
}
|
||||
#[test]
|
||||
fn controlled_particle_keeps_owned_motion_sprite_interpolation_and_expiry() {
|
||||
let registry = BlockRegistry::from_nbt(&Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
(
|
||||
"states",
|
||||
Tag::List(vec![Tag::String("minecraft:air".into())]),
|
||||
),
|
||||
("defaults", Tag::compound([("minecraft:air", Tag::Int(0))])),
|
||||
]))
|
||||
.unwrap();
|
||||
let cached = CachedCollisionRegistry::from_nbt(
|
||||
&Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
(
|
||||
"states",
|
||||
Tag::List(vec![Tag::String("minecraft:air".into())]),
|
||||
),
|
||||
("shapes", Tag::List(vec![])),
|
||||
("cached_shapes", Tag::IntArray(vec![-1])),
|
||||
]),
|
||||
®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]
|
||||
|
||||
@@ -8,6 +8,7 @@ pub struct SpriteAnimation {
|
||||
pub layer: u32,
|
||||
frames: Arc<Vec<Vec<u8>>>,
|
||||
clip: Arc<AnimationClip>,
|
||||
materials: [Option<Arc<Vec<Vec<u8>>>>; 2],
|
||||
}
|
||||
/// What an animated sprite shows: `frame`, mixed `millis` thousandths of the
|
||||
/// way towards `next` (0 for a single frame).
|
||||
@@ -18,6 +19,14 @@ pub struct FrameBlend {
|
||||
pub millis: u32,
|
||||
}
|
||||
impl SpriteAnimation {
|
||||
pub(crate) fn has_materials(&self) -> bool {
|
||||
self.materials.iter().any(Option::is_some)
|
||||
}
|
||||
/// Material categories follow the selected colour frame, without colour interpolation.
|
||||
pub(crate) fn material_pixels(&self, kind: usize, tick: u64) -> Option<&[u8]> {
|
||||
let frames = self.materials[kind].as_ref()?;
|
||||
Some(&frames[self.frame(tick) % frames.len()])
|
||||
}
|
||||
pub fn frame(&self, tick: u64) -> usize {
|
||||
self.clip.sample(tick).frame as usize
|
||||
}
|
||||
@@ -88,9 +97,9 @@ pub struct Sprites {
|
||||
/// Each layer's frames at the texture's own resolution (square), and the
|
||||
/// frame shown first.
|
||||
native: Vec<(u32, Arc<Vec<Vec<u8>>>, usize)>,
|
||||
/// Each layer's LabPBR maps (`_n` normals, `_s` specular) beside its texture: their first
|
||||
/// frame at their own resolution (square).
|
||||
pbr: Vec<[Option<(u32, Vec<u8>)>; 2]>,
|
||||
/// Each layer's LabPBR maps (`_n` normals, `_s` specular) beside its texture: their
|
||||
/// frames at their own resolution (square).
|
||||
pbr: Vec<[Option<(u32, Vec<Vec<u8>>)>; 2]>,
|
||||
/// The layers' normal and specular maps at `pbr_size`, layer after layer, set by
|
||||
/// [`Sprites::finish`]: empty when no texture has either.
|
||||
pub normals: Vec<u8>,
|
||||
@@ -126,7 +135,7 @@ pub fn set_texture_budget(mib: u32) {
|
||||
/// Whether resource loads read the resource packs' LabPBR maps (`set_labpbr_maps`).
|
||||
static LABPBR_MAPS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether resource loads from now on read the resource packs' LabPBR maps beside their block
|
||||
/// textures. Only a native shader pack's material stage reads them, so without one they cost
|
||||
/// textures. Native material stages and terrain shader packs read them; without a pack they cost
|
||||
/// neither loading time nor video memory.
|
||||
pub fn set_labpbr_maps(on: bool) {
|
||||
LABPBR_MAPS.store(on, std::sync::atomic::Ordering::Relaxed);
|
||||
@@ -135,7 +144,7 @@ pub fn set_labpbr_maps(on: bool) {
|
||||
pub fn labpbr_maps() -> bool {
|
||||
LABPBR_MAPS.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
/// Largest array of each LabPBR map, which only a native shader pack reads.
|
||||
/// Budget for either complete normal or specular image set.
|
||||
const MAX_MAP_BYTES: u64 = DEFAULT_TEXTURE_BUDGET / 4;
|
||||
/// Fewer layers than any game has (vanilla's are about 1300): what a map is kept at while
|
||||
/// loading assumes at least these many share the array.
|
||||
@@ -153,30 +162,12 @@ fn side_within(start: u32, layers: u64, bytes: u64) -> u32 {
|
||||
}
|
||||
size
|
||||
}
|
||||
/// A LabPBR map's square `frame` of `side` averaged down by whole powers of two to at most
|
||||
/// `max`, channel by channel: they are data, not colour.
|
||||
/// Reduce data maps by selecting whole texels: material IDs and sentinel bytes cannot be averaged.
|
||||
fn shrink_map(frame: Vec<u8>, side: u32, max: u32) -> (u32, Vec<u8>) {
|
||||
if side <= max || !side.is_power_of_two() || !max.is_power_of_two() {
|
||||
return (side, frame);
|
||||
}
|
||||
let (out_side, f, side) = (max as usize, (side / max) as usize, side as usize);
|
||||
let mut out = Vec::with_capacity(out_side * out_side * 4);
|
||||
for y in 0..out_side {
|
||||
for x in 0..out_side {
|
||||
let mut sum = [0u32; 4];
|
||||
for dy in 0..f {
|
||||
let row = ((y * f + dy) * side + x * f) * 4;
|
||||
for texel in frame[row..row + f * 4].as_chunks::<4>().0 {
|
||||
for (s, &v) in sum.iter_mut().zip(texel) {
|
||||
*s += u32::from(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = (f * f) as u32;
|
||||
out.extend(sum.map(|s| ((s + n / 2) / n) as u8));
|
||||
}
|
||||
}
|
||||
(max, out)
|
||||
(max, resample(&frame, side, max))
|
||||
}
|
||||
/// Nearest-resample a square RGBA frame to another square size.
|
||||
pub(crate) fn resample(frame: &[u8], from: u32, to: u32) -> Vec<u8> {
|
||||
@@ -289,14 +280,28 @@ impl Sprites {
|
||||
if largest_map.is_some() {
|
||||
let pbr_size = self.pbr_size;
|
||||
log::info!("Horizon: LabPBR maps at {pbr_size} px, {layers} layers");
|
||||
for maps in std::mem::take(&mut self.pbr) {
|
||||
for animation in &mut self.animations {
|
||||
animation.materials = self.pbr[animation.layer as usize].each_ref().map(|map| {
|
||||
map.as_ref().map(|(from, frames)| {
|
||||
Arc::new(
|
||||
frames
|
||||
.iter()
|
||||
.map(|frame| resample(frame, *from, pbr_size))
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
for (layer, maps) in std::mem::take(&mut self.pbr).into_iter().enumerate() {
|
||||
for (map, (out, none)) in maps.into_iter().zip([
|
||||
(&mut self.normals, FLAT_NORMAL),
|
||||
(&mut self.specular, NO_SPECULAR),
|
||||
]) {
|
||||
match map {
|
||||
Some((from, pixels)) => {
|
||||
let (from, pixels) = shrink_map(pixels, from, pbr_size);
|
||||
Some((from, frames)) => {
|
||||
let initial = self.native[layer].2 % frames.len();
|
||||
let (from, pixels) =
|
||||
shrink_map(frames[initial].clone(), from, pbr_size);
|
||||
out.extend(resample(&pixels, from, pbr_size));
|
||||
}
|
||||
None => out.extend(none.repeat((pbr_size * pbr_size) as usize)),
|
||||
@@ -638,8 +643,10 @@ impl Sprites {
|
||||
let pbr = match name.split_once(':') {
|
||||
Some(_) if labpbr_maps() => {
|
||||
let path = crate::atlas_sources::texture_path(root, name, "textures");
|
||||
["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count))
|
||||
["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count, animation.as_ref()))
|
||||
}
|
||||
None if labpbr_maps() && !name.starts_with("fusion/") => ["_n", "_s"]
|
||||
.map(|suffix| pbr_map(&root.join(name), suffix, count, animation.as_ref())),
|
||||
_ => [None, None],
|
||||
};
|
||||
let settings = crate::mipmap::Settings {
|
||||
@@ -691,6 +698,7 @@ impl Sprites {
|
||||
layer,
|
||||
frames: frames.clone(),
|
||||
clip: Arc::new(clip),
|
||||
materials: [None, None],
|
||||
});
|
||||
}
|
||||
self.mips.push(settings);
|
||||
@@ -734,32 +742,64 @@ fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
Ok((info.width, info.height, pixels))
|
||||
}
|
||||
|
||||
/// The LabPBR map with `suffix` beside the texture at `path`, whose frames lie in a column of
|
||||
/// `count`: its first frame, square, no larger than the maps' array allows any game's
|
||||
/// textures (a 512x pack's maps would otherwise fill the memory while loading).
|
||||
fn pbr_map(path: &Path, suffix: &str, count: u32) -> Option<(u32, Vec<u8>)> {
|
||||
/// Companion frames use the colour image's grid; a single-frame map stays static.
|
||||
fn pbr_map(
|
||||
path: &Path,
|
||||
suffix: &str,
|
||||
count: u32,
|
||||
animation: Option<&AnimationMetadata>,
|
||||
) -> Option<(u32, Vec<Vec<u8>>)> {
|
||||
let stem = path.file_stem()?.to_str()?;
|
||||
let map = path.with_file_name(format!("{stem}{suffix}.png"));
|
||||
let (map_width, map_height, pixels) = read_rgba(&map).ok()?;
|
||||
let (w, h) = (map_width, map_height / count.max(1));
|
||||
let (width, height, _) = read_rgba(path).ok()?;
|
||||
let [frame_width, frame_height] = animation.map_or([width as i32, height as i32], |a| {
|
||||
a.frame_size(width as i32, height as i32)
|
||||
});
|
||||
let (frame_width, frame_height) = (
|
||||
u32::try_from(frame_width).ok()?,
|
||||
u32::try_from(frame_height).ok()?,
|
||||
);
|
||||
if frame_width == 0 || frame_height == 0 {
|
||||
return None;
|
||||
}
|
||||
let (columns, rows) = if count <= 1 || (map_width == frame_width && map_height == frame_height)
|
||||
{
|
||||
(1, 1)
|
||||
} else if u64::from(map_width) * u64::from(height) == u64::from(map_height) * u64::from(width) {
|
||||
(width / frame_width, height / frame_height)
|
||||
} else if u64::from(map_width) * u64::from(frame_height)
|
||||
== u64::from(map_height) * u64::from(frame_width)
|
||||
{
|
||||
(1, 1)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
if columns == 0 || rows == 0 || map_width % columns != 0 || map_height % rows != 0 {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (map_width / columns, map_height / rows);
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
let side = w.max(h);
|
||||
let mut frame = Vec::with_capacity((side * side * 4) as usize);
|
||||
for row in 0..side {
|
||||
for column in 0..side {
|
||||
let offset = (((row * h / side) * map_width + column * w / side) * 4) as usize;
|
||||
frame.extend_from_slice(&pixels[offset..offset + 4]);
|
||||
}
|
||||
}
|
||||
Some(shrink_map(
|
||||
frame,
|
||||
side,
|
||||
side_within(side, LAYERS_AT_LEAST, MAX_MAP_BYTES),
|
||||
))
|
||||
let output_side = side_within(side, LAYERS_AT_LEAST, MAX_MAP_BYTES).min(side);
|
||||
let frames = (0..columns * rows)
|
||||
.map(|index| {
|
||||
let (x, y) = (index % columns * w, index / columns * h);
|
||||
let mut frame = Vec::with_capacity((side * side * 4) as usize);
|
||||
for row in 0..side {
|
||||
for column in 0..side {
|
||||
let offset =
|
||||
(((y + row * h / side) * map_width + x + column * w / side) * 4) as usize;
|
||||
frame.extend_from_slice(&pixels[offset..offset + 4]);
|
||||
}
|
||||
}
|
||||
resample(&frame, side, output_side)
|
||||
})
|
||||
.collect();
|
||||
Some((output_side, frames))
|
||||
}
|
||||
|
||||
/// SpriteContents: the `.mcmeta` `texture` section (TextureMetadataSection's
|
||||
/// mipmap strategy and alpha cutoff bias).
|
||||
fn texture_section(
|
||||
@@ -790,6 +830,7 @@ pub(crate) fn test_frames(layer: u32, frames: Vec<Vec<u8>>, metadata: &str) -> S
|
||||
layer,
|
||||
clip: Arc::new(metadata.clip(frames.len() as u32).unwrap().unwrap()),
|
||||
frames: Arc::new(frames),
|
||||
materials: [None, None],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -815,8 +856,8 @@ mod tests {
|
||||
assert_eq!(side_within(512, 1300, 1), SPRITE_SIZE);
|
||||
}
|
||||
#[test]
|
||||
fn a_labpbr_map_shrinks_by_plain_means_and_a_layer_without_one_gets_the_default() {
|
||||
// Alpha is data here: it does not weigh the other channels.
|
||||
fn a_labpbr_map_selects_whole_texels_and_missing_layers_get_defaults() {
|
||||
// Selecting a texel preserves encoded channels, including categorical values.
|
||||
let frame = [
|
||||
[200, 100, 0, 255],
|
||||
[0, 0, 0, 0],
|
||||
@@ -824,14 +865,14 @@ mod tests {
|
||||
[0, 0, 0, 0],
|
||||
]
|
||||
.concat();
|
||||
assert_eq!(shrink_map(frame, 2, 1), (1, vec![100, 50, 0, 128]));
|
||||
assert_eq!(shrink_map(frame, 2, 1), (1, vec![200, 100, 0, 255]));
|
||||
let mut sprites = Sprites {
|
||||
native: vec![
|
||||
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
|
||||
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
|
||||
],
|
||||
pbr: vec![
|
||||
[Some((16, [9, 8, 7, 6].repeat(16 * 16))), None],
|
||||
[Some((16, vec![[9, 8, 7, 6].repeat(16 * 16)])), None],
|
||||
[None, None],
|
||||
],
|
||||
..Default::default()
|
||||
@@ -844,6 +885,50 @@ mod tests {
|
||||
assert_eq!(sprites.specular.len(), 2 * 16 * 16 * 4);
|
||||
}
|
||||
#[test]
|
||||
fn companion_grid_and_static_map_follow_colour_frame_selection() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-companion-grid-{}", std::process::id()));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let write = |name: &str, width, height, pixels: &[u8]| {
|
||||
let mut encoder =
|
||||
png::Encoder::new(File::create(root.join(name)).unwrap(), width, height);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(pixels)
|
||||
.unwrap();
|
||||
};
|
||||
write("grid.png", 4, 4, &[200, 200, 200, 255].repeat(16));
|
||||
let maps: Vec<u8> = (0..16)
|
||||
.flat_map(|i| {
|
||||
let cell = (i / 8) * 2 + (i % 4) / 2;
|
||||
[10 + cell as u8, 230, 65, 255]
|
||||
})
|
||||
.collect();
|
||||
write("grid_s.png", 4, 4, &maps);
|
||||
write("grid_n.png", 2, 2, &[128, 128, 255, 255].repeat(4));
|
||||
let metadata =
|
||||
r#"{"width":2,"height":2,"frametime":2,"interpolate":true,"frames":[3,1,2,0]}"#;
|
||||
let animation = AnimationMetadata::from_json(metadata).unwrap();
|
||||
let (side, frames) = pbr_map(&root.join("grid.png"), "_s", 4, Some(&animation)).unwrap();
|
||||
assert_eq!(side, 2);
|
||||
assert_eq!(
|
||||
frames.iter().map(|f| f[0]).collect::<Vec<_>>(),
|
||||
[10, 11, 12, 13]
|
||||
);
|
||||
let (_, normals) = pbr_map(&root.join("grid.png"), "_n", 4, Some(&animation)).unwrap();
|
||||
assert_eq!(normals.len(), 1);
|
||||
let mut sprite = test_frames(0, vec![vec![0; 16]; 4], metadata);
|
||||
sprite.materials = [Some(Arc::new(normals)), Some(Arc::new(frames))];
|
||||
for (tick, expected) in [(0, 13), (1, 13), (2, 11), (4, 12), (6, 10)] {
|
||||
assert_eq!(sprite.material_pixels(1, tick).unwrap()[0], expected);
|
||||
assert_eq!(&sprite.material_pixels(0, tick).unwrap()[..4], &FLAT_NORMAL);
|
||||
}
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn textures_without_labpbr_maps_keep_none() {
|
||||
let mut sprites = Sprites {
|
||||
native: vec![(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0)],
|
||||
@@ -1284,6 +1369,7 @@ mod tests {
|
||||
fn animation_boundaries_and_repeated_frames_do_not_depend_on_render_rate() {
|
||||
let animation = SpriteAnimation {
|
||||
layer: 0,
|
||||
materials: [None, None],
|
||||
frames: Arc::new(vec![vec![], vec![], vec![]]),
|
||||
clip: Arc::new(AnimationMetadata::from_json(r#"{"frames":[{"index":2,"time":8},{"index":1,"time":3},{"index":1,"time":2},0]}"#).unwrap().clip(3).unwrap().unwrap()),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! An ordinary read of a struct or buffer-block member that an atomic function also uses gets
|
||||
//! the member's current value (#9081), on a host GPU through the translated program.
|
||||
|
||||
use horizon_shaders::{Macros, Pack, preprocess, translate_compute};
|
||||
|
||||
/// Runs compute program `source` once (one work group), with `probe_buffer` (set 4, binding 2)
|
||||
/// starting at `start`, and returns `probe_result.result` (set 4, binding 1).
|
||||
fn run(device: &wgpu::Device, queue: &wgpu::Queue, source: &str, start: [u32; 2]) -> u32 {
|
||||
use std::io::Write;
|
||||
use wgpu::util::DeviceExt;
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
zip.start_file(
|
||||
"shaders/deferred.csh",
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
let pack =
|
||||
Pack::from_zip_bytes("atomic-values.zip", zip.finish().unwrap().into_inner()).unwrap();
|
||||
let program = translate_compute(&preprocess(&pack, "deferred.csh", &Macros::standard()))
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(program.module)),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let storage = wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC;
|
||||
let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(program.interface.uniform_size.max(48)),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let result = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &[0; 4],
|
||||
usage: storage,
|
||||
});
|
||||
let probe: Vec<u8> = start.iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
let probe = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &probe,
|
||||
usage: storage,
|
||||
});
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 4,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let groups: Vec<wgpu::BindGroup> = (0..5)
|
||||
.map(|set| {
|
||||
let entries: Vec<wgpu::BindGroupEntry> = match set {
|
||||
0 => vec![wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniforms.as_entire_binding(),
|
||||
}],
|
||||
4 if source.contains("probe_buffer") => vec![
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: result.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: probe.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
4 => vec![wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: result.as_entire_binding(),
|
||||
}],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(set),
|
||||
entries: &entries,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
pass.set_pipeline(&pipeline);
|
||||
for (set, group) in groups.iter().enumerate() {
|
||||
pass.set_bind_group(set as u32, group, &[]);
|
||||
}
|
||||
pass.dispatch_workgroups(1, 1, 1);
|
||||
}
|
||||
encoder.copy_buffer_to_buffer(&result, 0, &read, 0, 4);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = read.slice(..).get_mapped_range().unwrap();
|
||||
u32::from_le_bytes(bytes[..4].try_into().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn reads_of_atomic_members_see_the_atomic_updates() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Atomic member GPU: {}", adapter.get_info().name);
|
||||
// Pack buffers sit in bind group 4, as in the game.
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: wgpu::Limits {
|
||||
max_bind_groups: 5,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let result = "layout(std430, binding = 1) buffer probe_result { uint result; };\n";
|
||||
// Eight invocations each add 1 to a zeroed shared member, then read it: 8.
|
||||
for (host, member) in [
|
||||
("shared struct { uint other; uint count; } sh;", "sh.count"),
|
||||
(
|
||||
"struct Counter { uint count; uint other; };\nshared Counter counters[2];",
|
||||
"counters[1].count",
|
||||
),
|
||||
] {
|
||||
for (read, expected) in [
|
||||
("result = M * 10u + 1u;", 81),
|
||||
("uint r = M; result = r;", 8),
|
||||
("result = uint(float(M) * 0.5);", 4),
|
||||
("result = min(M, 5u);", 5),
|
||||
] {
|
||||
let source = format!(
|
||||
"#version 460 core\nlayout(local_size_x = 8) in;\n{host}\n{result}\
|
||||
void main() {{\n {member} = 0u;\n barrier();\n \
|
||||
atomicAdd({member}, 1u);\n barrier();\n {}\n}}\n",
|
||||
read.replace('M', member)
|
||||
);
|
||||
assert_eq!(
|
||||
run(&device, &queue, &source, [0, 0]),
|
||||
expected,
|
||||
"{member}: {read}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// One invocation adds 2 to a buffer member that starts at 5, then reads it: 7.
|
||||
for (read, expected) in [
|
||||
("result = pc.count * 10u + 1u;", 71),
|
||||
("uint r = pc.count; result = r;", 7),
|
||||
("result = uint(int(pc.count) - 10 + 100);", 97),
|
||||
] {
|
||||
let source = format!(
|
||||
"#version 460 core\nlayout(local_size_x = 1) in;\n\
|
||||
layout(std430, binding = 2) buffer probe_buffer {{ uint count; uint other; }} pc;\n\
|
||||
{result}void main() {{\n atomicAdd(pc.count, 2u);\n {read}\n}}\n"
|
||||
);
|
||||
assert_eq!(run(&device, &queue, &source, [5, 0]), expected, "{read}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Synthetic rendering checks (two output shapes mirror RenderPearl v2.7.2 @86de79c, MIT; see the
|
||||
//! PR): a fragment output's `component` layout qualifier (GLSL 4.60, 4.4.2) picks the channels of
|
||||
//! its location that it writes.
|
||||
use horizon_shaders::{Origin, Preprocessed, Target, translate_composite};
|
||||
|
||||
fn source(code: &str) -> Preprocessed {
|
||||
Preprocessed {
|
||||
code: code.into(),
|
||||
files: vec!["components.glsl".into()],
|
||||
lines: code
|
||||
.lines()
|
||||
.enumerate()
|
||||
.map(|(i, _)| Origin {
|
||||
file: 0,
|
||||
line: i as u32 + 1,
|
||||
})
|
||||
.collect(),
|
||||
version: Some((440, None)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws `fragment` over a 1×1 target of `format` and reads back its 16 bytes.
|
||||
fn draw(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
fragment: &str,
|
||||
format: wgpu::TextureFormat,
|
||||
) -> [u8; 16] {
|
||||
let program = translate_composite(
|
||||
&source("void main() { gl_Position = ftransform(); }"),
|
||||
&source(fragment),
|
||||
Target::Buffers,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{fragment}: {e:?}"));
|
||||
let module = |module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
})
|
||||
};
|
||||
let (vertex, fragment_module) = (module(program.vertex), module(program.fragment));
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vertex,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleStrip,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fragment_module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let extent = wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: extent,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let view = target.create_view(&Default::default());
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
// The composite program's uniform block, if it kept one, reads zero.
|
||||
if program.interface.uniform_size > 0 {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(program.interface.uniform_size).max(16),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
}
|
||||
pass.draw(0..4, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
target.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &readback,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: Some(1),
|
||||
},
|
||||
},
|
||||
extent,
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
readback
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = readback.slice(..).get_mapped_range().unwrap()[..16]
|
||||
.try_into()
|
||||
.unwrap();
|
||||
readback.unmap();
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn component_qualified_outputs_write_their_own_channels() {
|
||||
let instance =
|
||||
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
|
||||
.expect("GPU adapter required; missing adapter is not a pass");
|
||||
eprintln!("Output components GPU: {:?}", adapter.get_info());
|
||||
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
// The channels no output writes keep the fill of a narrow output: 0, and 1 in alpha.
|
||||
for (name, fragment, expected) in [
|
||||
(
|
||||
"uvec3 at component 1",
|
||||
"layout(location = 0, component = 1) out uvec3 words;\n\
|
||||
void main() { words = uvec3(7u, 8u, 9u); }",
|
||||
[0u32, 7, 8, 9],
|
||||
),
|
||||
(
|
||||
"uint at component 2",
|
||||
"layout(location = 0, component = 2) out uint word;\n\
|
||||
void main() { word = 5u; }",
|
||||
[0, 0, 5, 1],
|
||||
),
|
||||
(
|
||||
"two outputs share location 0",
|
||||
"layout(location = 0, component = 0) out uint low;\n\
|
||||
layout(location = 0, component = 1) out uvec3 high;\n\
|
||||
void main() { low = 3u; high = uvec3(4u, 5u, 6u); }",
|
||||
[3, 4, 5, 6],
|
||||
),
|
||||
(
|
||||
"the component 1 output declared first",
|
||||
"layout(location = 0, component = 1) out uvec2 middle;\n\
|
||||
layout(location = 0) out uint low;\n\
|
||||
void main() { low = 11u; middle = uvec2(12u, 13u); }",
|
||||
[11, 12, 13, 1],
|
||||
),
|
||||
] {
|
||||
let bytes = draw(&device, &queue, fragment, wgpu::TextureFormat::Rgba32Uint);
|
||||
let actual: [u32; 4] = bytemuck::cast(bytes);
|
||||
eprintln!("{name}: {actual:?}");
|
||||
assert_eq!(actual, expected, "{name}");
|
||||
}
|
||||
let bytes = draw(
|
||||
&device,
|
||||
&queue,
|
||||
"layout(location = 0, component = 0) out float red;\n\
|
||||
layout(location = 0, component = 1) out vec3 rest;\n\
|
||||
void main() { red = 0.0; rest = vec3(1.0, 0.0, 1.0); }",
|
||||
wgpu::TextureFormat::Rgba32Float,
|
||||
);
|
||||
let actual: [f32; 4] = bytemuck::cast(bytes);
|
||||
eprintln!("float outputs share location 0: {actual:?}");
|
||||
assert_eq!(actual, [0.0, 1.0, 0.0, 1.0]);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Original synthetic rendering controls for ordinary uniform initial values.
|
||||
use horizon_client_render::gfx::pack_uniforms::Value;
|
||||
use horizon_shaders::{Origin, Preprocessed, Target, translate_composite};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
fn source(code: &str) -> Preprocessed {
|
||||
Preprocessed {
|
||||
code: code.into(),
|
||||
files: vec!["original-render.glsl".into()],
|
||||
lines: code
|
||||
.lines()
|
||||
.enumerate()
|
||||
.map(|(i, _)| Origin {
|
||||
file: 0,
|
||||
line: i as u32 + 1,
|
||||
})
|
||||
.collect(),
|
||||
version: Some((430, None)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn authored_defaults_render_and_runtime_values_override_including_zero() {
|
||||
let program = translate_composite(
|
||||
&source("void main() { gl_Position = ftransform(); }"),
|
||||
&source("uniform float gain = 0.375; uniform mat3 basis = mat3(2.0); uniform mat4 transform = mat4(1.0); uniform vec3 tint = vec3(0.125,0.25,0.5); uniform float points[2] = float[](0.0625,0.125); uniform int mode = 3; uniform bool enabled = true; out vec4 color; void main() { color = vec4(gain * basis[0][0] * transform[2][2], tint.g + points[1], float(mode), enabled ? 1.0 : 0.0); }"),
|
||||
Target::Buffers,
|
||||
).unwrap();
|
||||
let instance =
|
||||
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
|
||||
.expect("GPU adapter required; missing adapter is not a pass");
|
||||
eprintln!("Uniform defaults GPU: {:?}", adapter.get_info());
|
||||
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let vertex = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(program.vertex)),
|
||||
});
|
||||
let fragment = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(program.fragment)),
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vertex,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleStrip,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fragment,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba32Float,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
for (name, zero_buffer, overrides, expected) in [
|
||||
("authored defaults", false, vec![], [0.75, 0.375, 3.0, 1.0]),
|
||||
(
|
||||
"runtime precedence",
|
||||
false,
|
||||
vec![
|
||||
("gain", Value::F([0.125; 16])),
|
||||
("transform", Value::F([0.5; 16])),
|
||||
("mode", Value::I([7, 0, 0])),
|
||||
("enabled", Value::I([0; 3])),
|
||||
],
|
||||
[0.125, 0.375, 7.0, 0.0],
|
||||
),
|
||||
(
|
||||
"runtime explicit zero",
|
||||
false,
|
||||
vec![("gain", Value::F([0.; 16])), ("mode", Value::I([0; 3]))],
|
||||
[0.0, 0.375, 0.0, 1.0],
|
||||
),
|
||||
("zero upload negative control", true, vec![], [0.; 4]),
|
||||
] {
|
||||
let mut bytes = if zero_buffer {
|
||||
vec![0; program.interface.uniform_size as usize]
|
||||
} else {
|
||||
program.interface.initial_values.clone()
|
||||
};
|
||||
for (name, value) in overrides {
|
||||
let uniform = program
|
||||
.interface
|
||||
.uniforms
|
||||
.iter()
|
||||
.find(|u| u.name == name)
|
||||
.unwrap();
|
||||
value.write(&uniform.ty, &mut bytes, uniform.offset as usize);
|
||||
}
|
||||
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(name),
|
||||
contents: &bytes,
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let view = target.create_view(&Default::default());
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..4, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
target.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &readback,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: Some(1),
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
readback
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
{
|
||||
let mapped = readback.slice(..).get_mapped_range().unwrap();
|
||||
let actual: &[f32] = bytemuck::cast_slice(&mapped[..16]);
|
||||
eprintln!("{name}: {actual:?}");
|
||||
assert_eq!(actual, expected, "{name}");
|
||||
}
|
||||
readback.unmap();
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Bewegingsdooiesone",
|
||||
"horizon.options.look_deadzone": "Kykdooiesone",
|
||||
"horizon.options.button_glyphs": "Knoppie Glyphs",
|
||||
"horizon.options.menu_navigation": "Kieslysnavigasie",
|
||||
"horizon.options.on_screen_keyboard": "Sleutelbord op die skerm",
|
||||
"horizon.options.chat_delay": "Chat Vertraging",
|
||||
"horizon.options.lightbar.health": "Gesondheid",
|
||||
"horizon.options.lightbar.dimension": "Dimensie",
|
||||
"horizon.options.glyphs.auto": "Outo",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.menu_pointer": "Wyser",
|
||||
"horizon.options.keyboard.auto": "Outo",
|
||||
"horizon.options.unavailable": "Hierdie kenmerk is nog nie in Horizon beskikbaar nie.",
|
||||
"horizon.options.search": "Soek instellings...",
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"horizon.options.move_deadzone": "المنطقة الميتة للحركة",
|
||||
"horizon.options.look_deadzone": "المنطقة الميتة للنظر",
|
||||
"horizon.options.button_glyphs": "الحروف الرسومية للأزرار",
|
||||
"horizon.options.menu_navigation": "التنقل في القائمة",
|
||||
"horizon.options.on_screen_keyboard": "لوحة المفاتيح على الشاشة",
|
||||
"horizon.options.chat_delay": "تأخير الدردشة",
|
||||
"horizon.options.lightbar.health": "الصحة",
|
||||
@@ -19,8 +18,6 @@
|
||||
"horizon.options.glyphs.xbox": "اكس بوكس",
|
||||
"horizon.options.glyphs.playstation": "بلاي ستيشن",
|
||||
"horizon.options.glyphs.nintendo": "نينتندو",
|
||||
"horizon.options.menu_focus": "التركيز",
|
||||
"horizon.options.menu_pointer": "المؤشر",
|
||||
"horizon.options.keyboard.auto": "تلقائي",
|
||||
"horizon.options.keyboard.steam": "البخار",
|
||||
"horizon.options.unavailable": "هذه الميزة غير متوفرة بعد في Horizon.",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Hərəkət ölü zonası",
|
||||
"horizon.options.look_deadzone": "Baxış ölü zonası",
|
||||
"horizon.options.button_glyphs": "Düymə Qlifləri",
|
||||
"horizon.options.menu_navigation": "Menyu Naviqasiyası",
|
||||
"horizon.options.on_screen_keyboard": "Ekran Klaviaturası",
|
||||
"horizon.options.chat_delay": "Söhbət Gecikməsi",
|
||||
"horizon.options.lightbar.health": "Sağlamlıq",
|
||||
"horizon.options.lightbar.dimension": "Ölçü",
|
||||
"horizon.options.glyphs.auto": "Avtomatik",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.menu_pointer": "Göstərici",
|
||||
"horizon.options.keyboard.auto": "Avtomatik",
|
||||
"horizon.options.unavailable": "Bu funksiya hələ Horizon ərazisində mövcud deyil.",
|
||||
"horizon.options.search": "Axtarış parametrləri...",
|
||||
|
||||
@@ -11,14 +11,11 @@
|
||||
"horizon.options.move_deadzone": "Хәрәкәт үле зонаһы",
|
||||
"horizon.options.look_deadzone": "Ҡараш үле зонаһы",
|
||||
"horizon.options.button_glyphs": "Төймә глифтары",
|
||||
"horizon.options.menu_navigation": "Навигация меню",
|
||||
"horizon.options.on_screen_keyboard": "Экрандағы клавиатура",
|
||||
"horizon.options.chat_delay": "Чат тотҡарланыуы",
|
||||
"horizon.options.lightbar.health": "Һаулыҡ",
|
||||
"horizon.options.lightbar.dimension": "Үлсәм",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Күрһәткес",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.unavailable": "Был функция Horizon-ла әлегә юҡ.",
|
||||
"horizon.options.search": "Эҙләү параметрҙары...",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Мёртвая зона руху",
|
||||
"horizon.options.look_deadzone": "Мёртвая зона агляду",
|
||||
"horizon.options.button_glyphs": "Гліфы кнопак",
|
||||
"horizon.options.menu_navigation": "Навігацыя па меню",
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавіятура",
|
||||
"horizon.options.chat_delay": "Затрымка чата",
|
||||
"horizon.options.lightbar.health": "здароўе",
|
||||
"horizon.options.lightbar.dimension": "Вымярэнне",
|
||||
"horizon.options.glyphs.auto": "Аўто",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Паказальнік",
|
||||
"horizon.options.keyboard.auto": "Аўто",
|
||||
"horizon.options.unavailable": "Гэтая функцыя яшчэ недаступная ў Horizon.",
|
||||
"horizon.options.search": "Налады пошуку...",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Мъртва зона на движението",
|
||||
"horizon.options.look_deadzone": "Мъртва зона на погледа",
|
||||
"horizon.options.button_glyphs": "Глифове на бутони",
|
||||
"horizon.options.menu_navigation": "Навигация в менюто",
|
||||
"horizon.options.on_screen_keyboard": "Екранна клавиатура",
|
||||
"horizon.options.chat_delay": "Забавяне на чата",
|
||||
"horizon.options.lightbar.health": "здраве",
|
||||
"horizon.options.lightbar.dimension": "Измерение",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "показалец",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.keyboard.steam": "Пара",
|
||||
"horizon.options.unavailable": "Тази функция все още не е налична в Horizon.",
|
||||
|
||||
@@ -11,14 +11,11 @@
|
||||
"horizon.options.move_deadzone": "Takad marv ar fiñval",
|
||||
"horizon.options.look_deadzone": "Takad marv ar sell",
|
||||
"horizon.options.button_glyphs": "Glifoù bouton",
|
||||
"horizon.options.menu_navigation": "Merdeiñ dre ar meni",
|
||||
"horizon.options.on_screen_keyboard": "Klavier war ar skramm",
|
||||
"horizon.options.chat_delay": "Dale flapañ",
|
||||
"horizon.options.lightbar.health": "Yec'hed",
|
||||
"horizon.options.lightbar.dimension": "Ment",
|
||||
"horizon.options.glyphs.auto": "Emgefreek",
|
||||
"horizon.options.menu_focus": "Lakaat ar gaoz",
|
||||
"horizon.options.menu_pointer": "Poent",
|
||||
"horizon.options.keyboard.auto": "Emgefreek",
|
||||
"horizon.options.unavailable": "N'eo ket hegerz an elfenn-mañ c'hoazh e Horizon.",
|
||||
"horizon.options.search": "Arventennoù klask...",
|
||||
|
||||
@@ -10,13 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Zona morta del moviment",
|
||||
"horizon.options.look_deadzone": "Zona morta de la vista",
|
||||
"horizon.options.button_glyphs": "Glifos de botons",
|
||||
"horizon.options.menu_navigation": "Navegació del menú",
|
||||
"horizon.options.on_screen_keyboard": "Teclat en pantalla",
|
||||
"horizon.options.chat_delay": "Retard de xat",
|
||||
"horizon.options.lightbar.health": "Salut",
|
||||
"horizon.options.lightbar.dimension": "Dimensió",
|
||||
"horizon.options.glyphs.auto": "Automàtic",
|
||||
"horizon.options.menu_pointer": "Apuntador",
|
||||
"horizon.options.keyboard.auto": "Automàtic",
|
||||
"horizon.options.unavailable": "Aquesta funció encara no està disponible a Horizon.",
|
||||
"horizon.options.search": "Configuració de cerca...",
|
||||
|
||||
@@ -10,13 +10,10 @@
|
||||
"horizon.options.move_deadzone": "Mrtvá zóna pohybu",
|
||||
"horizon.options.look_deadzone": "Mrtvá zóna pohledu",
|
||||
"horizon.options.button_glyphs": "Glyfy tlačítek",
|
||||
"horizon.options.menu_navigation": "Navigace v menu",
|
||||
"horizon.options.on_screen_keyboard": "Klávesnice na obrazovce",
|
||||
"horizon.options.chat_delay": "Zpoždění chatu",
|
||||
"horizon.options.lightbar.health": "Zdraví",
|
||||
"horizon.options.lightbar.dimension": "Dimenze",
|
||||
"horizon.options.menu_focus": "Zaměřte se",
|
||||
"horizon.options.menu_pointer": "Ukazatel",
|
||||
"horizon.options.keyboard.horizon": "Horizont",
|
||||
"horizon.options.keyboard.steam": "Pára",
|
||||
"horizon.options.unavailable": "Tato funkce zatím není v Horizon k dispozici.",
|
||||
|
||||
@@ -11,14 +11,11 @@
|
||||
"horizon.options.move_deadzone": "Куҫӑмӑн вилӗ зони",
|
||||
"horizon.options.look_deadzone": "Пӑхӑвӑн вилӗ зони",
|
||||
"horizon.options.button_glyphs": "Кнопка глифӗсем",
|
||||
"horizon.options.menu_navigation": "Меню навигацийӗ",
|
||||
"horizon.options.on_screen_keyboard": "Экран ҫинчи клавиатура",
|
||||
"horizon.options.chat_delay": "Чат кая юлни",
|
||||
"horizon.options.lightbar.health": "Сывлӑх",
|
||||
"horizon.options.lightbar.dimension": "Виҫе",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Указатель",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.unavailable": "Ку функци Horizon-ра хальлӗхе ҫук-ха.",
|
||||
"horizon.options.search": "Шырав настройкисем...",
|
||||
|
||||
@@ -10,13 +10,10 @@
|
||||
"horizon.options.move_deadzone": "Parth marw symud",
|
||||
"horizon.options.look_deadzone": "Parth marw edrych",
|
||||
"horizon.options.button_glyphs": "Glyffau Botwm",
|
||||
"horizon.options.menu_navigation": "Llywio Dewislen",
|
||||
"horizon.options.on_screen_keyboard": "Bysellfwrdd Ar-Sgrin",
|
||||
"horizon.options.chat_delay": "Oedi Sgwrsio",
|
||||
"horizon.options.lightbar.health": "Iechyd",
|
||||
"horizon.options.lightbar.dimension": "Dimensiwn",
|
||||
"horizon.options.menu_focus": "Ffocws",
|
||||
"horizon.options.menu_pointer": "Pwyntiwr",
|
||||
"horizon.options.unavailable": "Nid yw'r nodwedd hon ar gael eto yn Horizon.",
|
||||
"horizon.options.search": "Gosodiadau chwilio...",
|
||||
"horizon.pages.shader_packs": "Pecynnau Shader",
|
||||
|
||||
@@ -8,11 +8,9 @@
|
||||
"horizon.options.move_deadzone": "Dødzone for bevægelse",
|
||||
"horizon.options.look_deadzone": "Dødzone for kig",
|
||||
"horizon.options.button_glyphs": "Knap Glyfer",
|
||||
"horizon.options.menu_navigation": "Menu navigation",
|
||||
"horizon.options.on_screen_keyboard": "Skærmtastatur",
|
||||
"horizon.options.chat_delay": "Chatforsinkelse",
|
||||
"horizon.options.lightbar.health": "Sundhed",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.keyboard.steam": "Damp",
|
||||
"horizon.options.unavailable": "Denne funktion er endnu ikke tilgængelig i Horizon.",
|
||||
"horizon.options.search": "Søgeindstillinger...",
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"horizon.options.move_deadzone": "Totzone der Bewegung",
|
||||
"horizon.options.look_deadzone": "Totzone des Blicks",
|
||||
"horizon.options.button_glyphs": "Tastensymbole",
|
||||
"horizon.options.menu_navigation": "Menünavigation",
|
||||
"horizon.options.on_screen_keyboard": "Bildschirmtastatur",
|
||||
"horizon.options.chat_delay": "Chatverzögerung",
|
||||
"horizon.options.lightbar.health": "Gesundheit",
|
||||
@@ -20,8 +19,6 @@
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.menu_pointer": "Zeiger",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
|
||||
@@ -9,14 +9,11 @@
|
||||
"horizon.options.move_deadzone": "Νεκρή ζώνη κίνησης",
|
||||
"horizon.options.look_deadzone": "Νεκρή ζώνη κάμερας",
|
||||
"horizon.options.button_glyphs": "Γλυφικά κουμπιών",
|
||||
"horizon.options.menu_navigation": "Πλοήγηση μενού",
|
||||
"horizon.options.on_screen_keyboard": "Πληκτρολόγιο οθόνης",
|
||||
"horizon.options.chat_delay": "Καθυστέρηση συνομιλίας",
|
||||
"horizon.options.lightbar.health": "Υγεία",
|
||||
"horizon.options.lightbar.dimension": "Διάσταση",
|
||||
"horizon.options.glyphs.auto": "Αυτόματο",
|
||||
"horizon.options.menu_focus": "Εστίαση",
|
||||
"horizon.options.menu_pointer": "Δείκτης",
|
||||
"horizon.options.keyboard.auto": "Αυτόματο",
|
||||
"horizon.options.unavailable": "Αυτή η δυνατότητα δεν είναι ακόμη διαθέσιμη στο Horizon.",
|
||||
"horizon.options.search": "Ρυθμίσεις αναζήτησης...",
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"horizon.options.move_deadzone": "Move Deadzone",
|
||||
"horizon.options.look_deadzone": "Look Deadzone",
|
||||
"horizon.options.button_glyphs": "Button Glyphs",
|
||||
"horizon.options.menu_navigation": "Menu Navigation",
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
|
||||
"horizon.options.chat_delay": "Chat Delay",
|
||||
"horizon.options.lightbar.health": "Health",
|
||||
@@ -20,8 +19,6 @@
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Focus",
|
||||
"horizon.options.menu_pointer": "Pointer",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
@@ -522,5 +519,10 @@
|
||||
"horizon.extras.cosmetics_auras.tooltip": "Auras and trails other players wear. Off by default.",
|
||||
"horizon.extras.cosmetics.shown": "Shown",
|
||||
"horizon.extras.cosmetics.hidden": "Hidden",
|
||||
"horizon.extras.cosmetics.name_tags": "Name tags only"
|
||||
"horizon.extras.cosmetics.name_tags": "Name tags only",
|
||||
"horizon.options.pointer_speed": "Menu Pointer Speed",
|
||||
"horizon.options.scroll_speed": "Menu Scroll Speed",
|
||||
"horizon.options.repeat_delay": "Navigation Repeat Delay",
|
||||
"horizon.options.repeat_interval": "Navigation Repeat Interval",
|
||||
"horizon.options.milliseconds": "%s ms"
|
||||
}
|
||||
@@ -11,14 +11,11 @@
|
||||
"horizon.options.move_deadzone": "Mova mortzono",
|
||||
"horizon.options.look_deadzone": "Rigarda mortzono",
|
||||
"horizon.options.button_glyphs": "Butonaj Glifoj",
|
||||
"horizon.options.menu_navigation": "Menuo Navigado",
|
||||
"horizon.options.on_screen_keyboard": "Surekrana Klavaro",
|
||||
"horizon.options.chat_delay": "Babilado Prokrasto",
|
||||
"horizon.options.lightbar.health": "Sano",
|
||||
"horizon.options.lightbar.dimension": "Dimensio",
|
||||
"horizon.options.glyphs.auto": "Aŭtomate",
|
||||
"horizon.options.menu_focus": "Fokuso",
|
||||
"horizon.options.menu_pointer": "Montrilo",
|
||||
"horizon.options.keyboard.auto": "Aŭtomate",
|
||||
"horizon.options.unavailable": "Ĉi tiu funkcio ankoraŭ ne disponeblas en Horizon.",
|
||||
"horizon.options.search": "Serĉaj agordoj...",
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"horizon.options.move_deadzone": "Zona muerta de movimiento",
|
||||
"horizon.options.look_deadzone": "Zona muerta de vista",
|
||||
"horizon.options.button_glyphs": "Símbolos de botones",
|
||||
"horizon.options.menu_navigation": "Navegación de menús",
|
||||
"horizon.options.on_screen_keyboard": "Teclado en pantalla",
|
||||
"horizon.options.chat_delay": "Retardo del chat",
|
||||
"horizon.options.lightbar.health": "Salud",
|
||||
@@ -19,8 +18,6 @@
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Foco",
|
||||
"horizon.options.menu_pointer": "Puntero",
|
||||
"horizon.options.keyboard.auto": "Automático",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Liikumise surnud tsoon",
|
||||
"horizon.options.look_deadzone": "Vaate surnud tsoon",
|
||||
"horizon.options.button_glyphs": "Nuppude glüüfid",
|
||||
"horizon.options.menu_navigation": "Menüüs navigeerimine",
|
||||
"horizon.options.on_screen_keyboard": "Ekraaniklaviatuur",
|
||||
"horizon.options.chat_delay": "Vestluse viivitus",
|
||||
"horizon.options.lightbar.health": "Tervis",
|
||||
"horizon.options.lightbar.dimension": "Mõõtmed",
|
||||
"horizon.options.glyphs.auto": "Automaatne",
|
||||
"horizon.options.menu_focus": "Keskendu",
|
||||
"horizon.options.menu_pointer": "Osuti",
|
||||
"horizon.options.keyboard.auto": "Automaatne",
|
||||
"horizon.options.unavailable": "See funktsioon pole Horizonis veel saadaval.",
|
||||
"horizon.options.search": "Otsinguseaded...",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Mugimenduaren zona hila",
|
||||
"horizon.options.look_deadzone": "Begiradaren zona hila",
|
||||
"horizon.options.button_glyphs": "Botoien glifoak",
|
||||
"horizon.options.menu_navigation": "Menuen nabigazioa",
|
||||
"horizon.options.on_screen_keyboard": "Pantailako teklatua",
|
||||
"horizon.options.chat_delay": "Txat-atzerapena",
|
||||
"horizon.options.lightbar.health": "Osasuna",
|
||||
"horizon.options.lightbar.dimension": "Dimentsioa",
|
||||
"horizon.options.glyphs.auto": "Autoa",
|
||||
"horizon.options.menu_focus": "Fokua",
|
||||
"horizon.options.menu_pointer": "Erakuslea",
|
||||
"horizon.options.keyboard.auto": "Autoa",
|
||||
"horizon.options.unavailable": "Eginbide hau oraindik ez dago erabilgarri Horizon-n.",
|
||||
"horizon.options.search": "Bilaketa-ezarpenak...",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "ناحیه مرده حرکت",
|
||||
"horizon.options.look_deadzone": "ناحیه مرده نگاه",
|
||||
"horizon.options.button_glyphs": "گلیف دکمه",
|
||||
"horizon.options.menu_navigation": "پیمایش منو",
|
||||
"horizon.options.on_screen_keyboard": "صفحه کلید روی صفحه",
|
||||
"horizon.options.chat_delay": "تاخیر چت",
|
||||
"horizon.options.lightbar.health": "سلامتی",
|
||||
"horizon.options.lightbar.dimension": "بعد",
|
||||
"horizon.options.glyphs.auto": "خودکار",
|
||||
"horizon.options.menu_focus": "تمرکز کنید",
|
||||
"horizon.options.menu_pointer": "اشاره گر",
|
||||
"horizon.options.keyboard.auto": "خودکار",
|
||||
"horizon.options.keyboard.steam": "بخار",
|
||||
"horizon.options.unavailable": "این ویژگی هنوز در Horizon موجود نیست.",
|
||||
|
||||
@@ -9,13 +9,10 @@
|
||||
"horizon.options.move_deadzone": "Liikkeen kuollut alue",
|
||||
"horizon.options.look_deadzone": "Katseen kuollut alue",
|
||||
"horizon.options.button_glyphs": "Painikkeiden kuviot",
|
||||
"horizon.options.menu_navigation": "Valikkonavigointi",
|
||||
"horizon.options.on_screen_keyboard": "Näyttönäppäimistö",
|
||||
"horizon.options.chat_delay": "Chatin viive",
|
||||
"horizon.options.lightbar.health": "Terveys",
|
||||
"horizon.options.lightbar.dimension": "Mitat",
|
||||
"horizon.options.menu_focus": "Keskity",
|
||||
"horizon.options.menu_pointer": "Osoitin",
|
||||
"horizon.options.unavailable": "Tämä ominaisuus ei ole vielä saatavilla Horizonissa.",
|
||||
"horizon.options.search": "Hakuasetukset...",
|
||||
"horizon.pages.mods": "Modit",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"horizon.options.move_deadzone": "Deadzone ng Paggalaw",
|
||||
"horizon.options.look_deadzone": "Deadzone ng Tingin",
|
||||
"horizon.options.button_glyphs": "Mga Glyph ng Button",
|
||||
"horizon.options.menu_navigation": "Navigation ng Menu",
|
||||
"horizon.options.on_screen_keyboard": "On-Screen na Keyboard",
|
||||
"horizon.options.chat_delay": "Pagkaantala sa Chat",
|
||||
"horizon.options.lightbar.health": "Kalusugan",
|
||||
|
||||
@@ -11,13 +11,10 @@
|
||||
"horizon.options.move_deadzone": "Deyðaøki fyri flyting",
|
||||
"horizon.options.look_deadzone": "Deyðaøki fyri hyggjan",
|
||||
"horizon.options.button_glyphs": "Knappaglyfur",
|
||||
"horizon.options.menu_navigation": "Valmyndanavigatión",
|
||||
"horizon.options.on_screen_keyboard": "Lyklaborð á skjánum",
|
||||
"horizon.options.chat_delay": "Kjakseinking",
|
||||
"horizon.options.lightbar.health": "Heilsa",
|
||||
"horizon.options.lightbar.dimension": "Mát",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.menu_pointer": "Peikari",
|
||||
"horizon.options.unavailable": "Hesin hentleikin er ikki tøkur enn í Horizon.",
|
||||
"horizon.options.search": "Leiti innstillingar...",
|
||||
"horizon.pages.shader_packs": "Skuggapakkar",
|
||||
|
||||
Loaded 100 of 609 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user