Compare commits

...
Author SHA1 Message Date
Ludovic Marie dd15699bfa Merge commit '5399e47ea' into HEAD 2026-10-28 23:46:52 +01:00
Ludovic Marie e67701b136 Merge commit 'refs/cs2/pr/3409' into HEAD
# Conflicts:
#	crates/horizon-client/src/lib.rs
2026-10-28 23:46:47 +01:00
Ludovic Marie 5399e47ea0 Merge commit 'refs/cs2/pr/3131' into HEAD 2026-10-28 23:46:47 +01:00
Ludovic Marie 3c19f30788 Merge commit 'refs/cs2/pr/3167' into HEAD
# Conflicts:
#	crates/horizon-client-render/src/gfx/sunrise_fan.rs
2026-10-28 23:46:37 +01:00
Ludovic Marie 6f9d480022 Merge commit 'refs/cs2/pr/3089' into HEAD
# Conflicts:
#	crates/horizon-client-render/src/gfx/view.rs
2026-10-28 23:46:37 +01:00
Ludovic Marie 02cc9e7c3a Merge commit 'refs/cs2/pr/3015' into HEAD
# Conflicts:
#	crates/horizon-client/src/lib.rs
2026-10-28 23:46:05 +01:00
degepieandClaude Opus 5.5 e7f746782f Totem of undying: the item activation animation on screen
ClientPacketListener.handleEntityEvent 35 now calls
LocalPlayer.displayItemActivation(findTotem(player)) for the player's own
event. ItemActivation (40 ticks, two random offsets) ticks in
GameRenderer.tick and resets in GameRenderer.resetData.
ScreenEffectRenderer.renderItemActivationAnimation draws it in the hand
pass with a FIXED item at full brightness, ITEMS_3D lighting and no
camera transform or bob. Only F1 hides it.

Fixes #3142.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 02:46:24 +05:00
Stepan Bogomazov cd97b34460 Build the sunset fan once a frame instead of in every sky pixel
While the sunrise/sunset glow shows (about 11500-14500 and 22500-500),
sunrise_weight rebuilt the fan's 16 triangles for every pixel of the
fullscreen sky pass, with four sin/cos per triangle. The triangles depend
only on the glow's alpha and the sun's horizon, so they are now built on
the CPU and passed in Globals as normals; the shader's Moller-Trumbore
reduces to dot products, and only triangles whose rim normals change sign
along the ray are tested in full. The weights are unchanged (a test checks
them against the old per-pixel code).

Sky pass at 1920x1080, dawn: Intel UHD 730 10.2 ms -> 1.8 ms (0.6 ms by
day), RTX 3060 0.47 ms -> 0.27 ms.

Fixes #3143
2026-10-28 14:27:17 +03:00
Mik00000 13e441e235 Fix invisible item frames and custom head skins 2026-10-28 12:00:31 +02:00
hashtheboardandClaude Opus 5.5 c9a8361493 render: draw from a ViewSet of per-eye views (VR groundwork)
The renderer now builds its camera from `View` (rotation, lens, aspect,
tan(fov/2)) gathered in a `ViewSet`: one view on a flat screen, and the
view culling and back-face removal use. A headset can later supply one
view per eye without touching draw_frame's consumers.

No visible change: the products keep their order (projection * pose *
rotation), and a test pins the screen view to the old formula bit for bit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 07:05:51 +01:00
DanielGodriNeto e14d6f79b1 perf(assets): keep decoded block sprites on disk between launches 2026-10-27 20:38:51 -03:00
24 changed files with 1239 additions and 105 deletions

No files matched your search

+2 -1
View File
@@ -196,7 +196,7 @@ impl Assets {
impl RenderAssets {
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
let registry = assets.registry;
let mut sprites = crate::sprites::Sprites::default();
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
for name in MATERIALS {
sprites.insert(root, &format!("minecraft:block/{name}"))?;
}
@@ -217,6 +217,7 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
sprites.save_disk_cache();
sprites.finish();
// `finish` grows the buffer by doubling: keep only the bytes in use.
sprites.pixels.shrink_to_fit();
@@ -187,7 +187,14 @@ pub fn list_textures(root: &Path) -> Vec<String> {
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
// `file_type` comes with the listing; `Path::is_dir` is a stat per
// entry, kept for links (which it follows).
let dir = match e.file_type() {
Ok(t) if t.is_symlink() => p.is_dir(),
Ok(t) => t.is_dir(),
Err(_) => p.is_dir(),
};
if dir {
stack.push(p);
} else if p.extension().is_some_and(|x| x == "png")
&& let Ok(rel) = p.strip_prefix(&base)
@@ -117,8 +117,15 @@ impl SkullSkin {
let fallback = crate::skins::profile_uuid_nbt(profile)
.or_else(|| {
crate::skins::nbt_text(profile.get("name"))
.or_else(|| crate::skins::nbt_text(profile.get("Name")))
.map(|name| horizon_protocol::crypto::offline_uuid(&name))
})
.or_else(|| match profile {
horizon_world::nbt::Tag::String(s) => {
Some(horizon_protocol::crypto::offline_uuid(&s.to_string_lossy()))
}
_ => None,
})
.map_or("entity/player/slim/steve", crate::tab_list::default_skin);
Self {
download,
@@ -22,6 +22,7 @@ struct Globals {
outline_params: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
sunrise_fan: array<vec4<f32>, 32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
+23 -14
View File
@@ -42,6 +42,7 @@ pub use entity_batch::{
ENERGY_SWIRL_OVERLAY, SHADOW_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY,
};
pub use view::{View, ViewSet};
/// Window.getAppropriateLineWidth, in framebuffer pixels.
pub fn outline_width(width: u32) -> f32 {
@@ -93,9 +94,11 @@ mod page_runs;
pub mod panorama;
mod resident;
mod stars;
mod sunrise_fan;
mod timing;
pub mod translucency;
pub(crate) mod upload;
mod view;
mod wgsl_prefix;
mod world_post;
@@ -188,6 +191,8 @@ struct Globals {
outline_params: [f32; 4],
/// Horizon Extras' dynamic lights: camera-relative position and level (`dynamic_light`).
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
/// The sunrise fan's triangles as the sky shader tests them (`sunrise_fan::normals`).
sunrise_fan: [[f32; 4]; 2 * sunrise_fan::TRIANGLES],
}
pub type SectionKey = (i32, i32, i32);
struct SectionGpu {
@@ -3429,14 +3434,14 @@ impl Renderer {
&self.lightmap_sampler,
);
}
pub fn view_projection(&self, frame: &Frame) -> Mat4 {
self.projection(
/// The views `render` draws `frame` from.
pub fn views(&self, frame: &Frame) -> ViewSet {
ViewSet::single(self.screen_view(
frame.fov_degrees,
frame.yaw,
frame.pitch,
frame.bob * frame.spin,
frame.depth_far,
)
))
}
/// The view frustum `render` will cull with, for culling geometry built
/// before the frame (entities, block entities) the same way.
@@ -3448,13 +3453,14 @@ impl Renderer {
bob: Mat4,
depth_far: f32,
) -> Frustum {
let views = ViewSet::single(self.screen_view(fov_degrees, yaw, pitch, depth_far));
Frustum {
planes: frustum(self.projection(fov_degrees, yaw, pitch, bob, depth_far)),
planes: frustum(views.cull().view_proj(bob)),
}
}
fn projection(&self, fov_degrees: f32, yaw: f32, pitch: f32, bob: Mat4, far: f32) -> Mat4 {
fn screen_view(&self, fov_degrees: f32, yaw: f32, pitch: f32, far: f32) -> View {
let aspect = self.config.width as f32 / self.config.height.max(1) as f32;
world_projection(fov_degrees, aspect, far) * bob * crate::camera::view_matrix(yaw, pitch)
View::screen(yaw, pitch, fov_degrees, aspect, far)
}
fn update_lightmap(
&mut self,
@@ -3614,7 +3620,9 @@ impl Renderer {
frame.block_flicker,
frame.boss_darken,
);
let view_proj = self.view_projection(frame);
let views = self.views(frame);
let pose = frame.bob * frame.spin;
let view_proj = views.primary().view_proj(pose);
if frame.title {
self.panorama.prepare(
&self.queue,
@@ -3624,8 +3632,7 @@ impl Renderer {
frame.pitch,
);
}
let aspect = self.config.width as f32 / self.config.height.max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
let view = views.primary().view;
let sun_angle = frame.sky.sun_angle.to_radians();
// Celestial bodies rotate around the X axis; angle 0 = noon.
let sun = Vec3::new(0.0, sun_angle.cos(), -sun_angle.sin());
@@ -3678,8 +3685,8 @@ impl Renderer {
misc: [
1.0 - frame.sky.sky_light_factor,
frame.game_time,
aspect,
(frame.fov_degrees.to_radians() / 2.0).tan(),
views.primary().aspect,
views.primary().tan_half_fov,
],
inverse_view: view.inverse().to_cols_array_2d(),
sunrise: frame.sky.sunrise_color,
@@ -3706,6 +3713,7 @@ impl Renderer {
}
lights
},
sunrise_fan: sunrise_fan::normals(frame.sky.sunrise_color[3], sun),
};
self.uploads
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
@@ -3741,14 +3749,15 @@ impl Renderer {
bytemuck::bytes_of(&crosshair_globals),
);
// Frustum culling and camera-relative origins.
let cull_proj = views.cull().view_proj(pose);
let view = Frustum {
planes: frustum(view_proj),
planes: frustum(cull_proj),
};
let camera = frame.camera;
let camera_section = camera_section(camera);
// Solid quads facing away from the eye are left out: the bob moves
// the eye off `camera`, so it is found from the projection.
let eye = projection_center(view_proj).filter(|_| self.sides_on);
let eye = projection_center(cull_proj).filter(|_| self.sides_on);
let culling = !frame.title && frame.cave_culling;
if culling {
crate::spans::time("occlusion", || {
@@ -0,0 +1,131 @@
//! The sunrise/sunset fan (`SkyRenderer.renderSunriseAndSunset`) as the sky
//! shader tests it. The fan's 16 triangles depend only on the sunrise alpha
//! and the horizon the sun is on, so their vertices are built here once a
//! frame instead of in every sky pixel (#3143: with the per-pixel build, four
//! `sin`/`cos` per triangle, the sky pass took 10.2 ms at dawn at 1080p on an
//! Intel UHD 730 against 0.6 ms by day; now 1.8 ms).
//!
//! Möller-Trumbore from the camera reduces to triple products: with apex A
//! and rim vertices r0, r1, a ray d hits at barycentrics proportional to
//! (`[d, r0, r1]`, `[A, d, r1]`, `[A, r0, d]`), each a dot of d with a
//! constant normal, and their sum S is the negated determinant. A hit needs
//! S on the side of `[A, r0, r1]`, so only the triangles whose rim normals'
//! dots change sign there are tested in full.
use glam::Vec3;
pub const TRIANGLES: usize = 16;
/// Per triangle: xyz `r0 × r1` (its dot with a ray is the apex's weight
/// times S), w `[A, r0, r1]` (its sign is S's where the hit lies ahead).
/// Then per rim vertex: xyz `r × A`.
pub fn normals(alpha: f32, sun: Vec3) -> [[f32; 4]; 2 * TRIANGLES] {
let (apex, rim) = vertices(alpha, sun);
let mut out = [[0.0; 4]; 2 * TRIANGLES];
for i in 0..TRIANGLES {
let (r0, r1) = (rim[i], rim[(i + 1) % TRIANGLES]);
out[i] = r0.cross(r1).extend(apex.dot(r0.cross(r1))).to_array();
out[TRIANGLES + i] = r0.cross(apex).extend(0.0).to_array();
}
out
}
/// The fan's apex and rim in camera space, as vanilla builds them: the
/// fan turned to the horizon the sun sets on (or rises from), its rim
/// tilted by the alpha.
fn vertices(alpha: f32, sun: Vec3) -> (Vec3, [Vec3; TRIANGLES]) {
// sin(sunAngle) < 0 flips the fan to the other horizon; sun.z = -sin(sunAngle).
let flip = if -sun.z < 0.0 { 180f32 } else { 0.0 };
let (s, c) = (flip + 90.0).to_radians().sin_cos();
let rotate_x90 = |v: Vec3| Vec3::new(v.x, -v.z, v.y);
let apex = rotate_x90(Vec3::new(-100.0 * s, 100.0 * c, 0.0));
let rim = std::array::from_fn(|i| {
let a = i as f32 * std::f32::consts::TAU / TRIANGLES as f32;
let l = Vec3::new(a.sin() * 120.0, a.cos() * 120.0, -a.cos() * 40.0 * alpha);
rotate_x90(Vec3::new(l.x * c - l.y * s, l.x * s + l.y * c, l.z))
});
(apex, rim)
}
#[cfg(test)]
mod tests {
use super::*;
/// terrain.wgsl's `sunrise_weight` over the normals.
fn weight(normals: &[[f32; 4]; 2 * TRIANGLES], d: Vec3) -> f32 {
let n = |i: usize| Vec3::from_slice(&normals[i][..3]);
let mut best = 0.0f32;
let mut k0 = d.dot(n(TRIANGLES));
for i in 0..TRIANGLES {
let k1 = d.dot(n(TRIANGLES + (i + 1) % TRIANGLES));
let w = normals[i][3];
if k0 * w <= 0.0 && k1 * w >= 0.0 {
let apex = d.dot(n(i));
let s = apex + k1 - k0;
if s.abs() >= 1e-6
&& s * w > 0.0
&& apex * s >= 0.0
&& k1 * s >= 0.0
&& k0 * s <= 0.0
{
best = best.max(apex / s);
}
}
k0 = k1;
}
best
}
/// The shader's former per-pixel Möller-Trumbore over the same vertices.
fn reference(alpha: f32, sun: Vec3, d: Vec3) -> f32 {
let (apex, rim) = vertices(alpha, sun);
let mut best = 0.0f32;
for i in 0..TRIANGLES {
let e1 = rim[i] - apex;
let e2 = rim[(i + 1) % TRIANGLES] - apex;
let pv = d.cross(e2);
let det = e1.dot(pv);
if det.abs() < 1e-6 {
continue;
}
let tv = -apex;
let u = tv.dot(pv) / det;
let qv = tv.cross(e1);
let v = d.dot(qv) / det;
let t = e2.dot(qv) / det;
if u >= 0.0 && v >= 0.0 && u + v <= 1.0 && t > 0.0 {
best = best.max(1.0 - u - v);
}
}
best
}
#[test]
fn normals_weigh_every_ray_as_the_per_pixel_fan_did() {
let mut hits = 0;
for (alpha, sun_z) in [(1.0f32, -0.2f32), (0.6, 0.3), (0.05, -0.9), (0.9, 0.05)] {
let sun = Vec3::new(0.0, (1.0 - sun_z * sun_z).sqrt(), sun_z);
let normals = normals(alpha, sun);
for yaw in 0..180 {
for pitch in -45..=45 {
let (yaw, pitch) = (
(yaw as f32 * 2.0).to_radians(),
(pitch as f32 * 2.0).to_radians(),
);
let d = Vec3::new(
yaw.cos() * pitch.cos(),
pitch.sin(),
yaw.sin() * pitch.cos(),
);
let (got, want) = (weight(&normals, d), reference(alpha, sun, d));
assert!(
(got - want).abs() < 1e-4,
"{alpha} {sun_z} {d}: {got} vs {want}"
);
hits += usize::from(want > 0.0);
}
}
}
// The fan covers part of the sky, not none of it.
assert!(hits > 1000, "{hits}");
}
}
@@ -0,0 +1,133 @@
//! The views a frame is drawn from. A flat screen has one; a headset has
//! one per eye. Every view shares the frame's camera position, so the
//! camera-relative world (section origins, entity offsets) is built once and
//! only each view's rotation and lens differ.
use glam::Mat4;
/// The most views one frame draws.
const MAX_VIEWS: usize = 2;
/// One eye's view of the camera-relative world.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct View {
/// Camera-relative world to eye: a rotation, plus the eye's offset from
/// the camera on a headset.
pub view: Mat4,
/// Reversed-Z perspective: the near plane at depth 1, far at 0.
pub projection: Mat4,
/// Width over height of the image this view fills.
pub aspect: f32,
/// `tan(fov_y / 2)`, for shaders rebuilding view rays from NDC.
pub tan_half_fov: f32,
}
impl View {
/// The flat-screen view: vanilla's camera rotation and a symmetric
/// vertical field of view.
pub fn screen(yaw: f32, pitch: f32, fov_degrees: f32, aspect: f32, far: f32) -> Self {
Self {
view: crate::camera::view_matrix(yaw, pitch),
projection: super::world_projection(fov_degrees, aspect, far),
aspect,
tan_half_fov: (fov_degrees.to_radians() / 2.0).tan(),
}
}
/// Clip space from the camera-relative world, with `pose` (view bobbing,
/// nausea spin) between the lens and the rotation.
pub fn view_proj(&self, pose: Mat4) -> Mat4 {
self.projection * pose * self.view
}
}
/// Every view of a frame, and the one geometry is culled with.
#[derive(Clone, Copy, Debug)]
pub struct ViewSet {
views: [View; MAX_VIEWS],
len: usize,
cull: View,
}
impl ViewSet {
/// A frame drawn from `view` alone, culled with it.
pub fn single(view: View) -> Self {
Self {
views: [view; MAX_VIEWS],
len: 1,
cull: view,
}
}
/// The view the HUD, hand and screen-space effects follow.
pub fn primary(&self) -> &View {
&self.views[0]
}
pub fn views(&self) -> &[View] {
&self.views[..self.len]
}
/// The view whose frustum covers every other view's.
pub fn cull(&self) -> &View {
&self.cull
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The formulas `Renderer` used before views existed, kept to pin the
/// screen view to them bit for bit.
fn legacy_view_proj(fov: f32, yaw: f32, pitch: f32, pose: Mat4, aspect: f32, far: f32) -> Mat4 {
super::super::world_projection(fov, aspect, far)
* pose
* crate::camera::view_matrix(yaw, pitch)
}
fn poses() -> [Mat4; 3] {
[
Mat4::IDENTITY,
Mat4::from_translation(glam::Vec3::new(0.013, -0.027, 0.0))
* Mat4::from_rotation_z(0.021),
Mat4::from_rotation_y(0.4) * Mat4::from_scale(glam::Vec3::new(1.1, 0.9, 1.0)),
]
}
#[test]
fn the_screen_view_matches_the_legacy_projection_bit_for_bit() {
for (fov, yaw, pitch, aspect, far) in [
(70.0, 0.0, 0.0, 16.0 / 9.0, 512.0),
(110.0, -137.25, 61.5, 21.0 / 9.0, 8192.0),
(30.0, 359.9, -90.0, 4.0 / 3.0, 96.0),
(82.333, 12.5, 7.75, 1.0, 1024.0),
] {
let view = View::screen(yaw, pitch, fov, aspect, far);
for pose in poses() {
let old = legacy_view_proj(fov, yaw, pitch, pose, aspect, far);
let new = view.view_proj(pose);
assert_eq!(
old.to_cols_array().map(f32::to_bits),
new.to_cols_array().map(f32::to_bits),
"fov {fov} yaw {yaw} pitch {pitch}"
);
}
assert_eq!(view.view, crate::camera::view_matrix(yaw, pitch));
assert_eq!(
view.tan_half_fov.to_bits(),
(fov.to_radians() / 2.0).tan().to_bits()
);
assert_eq!(view.aspect.to_bits(), aspect.to_bits());
}
}
#[test]
fn a_single_view_set_draws_and_culls_with_its_view() {
let view = View::screen(45.0, 10.0, 70.0, 16.0 / 9.0, 512.0);
let set = ViewSet::single(view);
assert_eq!(set.views(), &[view]);
assert_eq!(*set.primary(), view);
assert_eq!(*set.cull(), view);
}
}
+1
View File
@@ -67,6 +67,7 @@ pub mod signs;
pub mod skins;
pub mod sound_events;
pub mod spans;
mod sprite_cache;
pub mod sprites;
pub mod streaming;
pub mod tab_list;
+117 -37
View File
@@ -132,16 +132,31 @@ pub fn profile_skin_url(profile: &serde_json::Value) -> Option<String> {
/// the same `properties` list, held as a compound. A name-only profile (a
/// string tag) has no properties and no skin URL.
pub fn profile_skin_url_nbt(profile: &horizon_world::nbt::Tag) -> Option<String> {
let properties = match profile.get("properties")? {
horizon_world::nbt::Tag::List(list) => list,
_ => return None,
};
let value = properties.iter().find_map(|p| {
let name = nbt_text(p.get("name"))?;
(name == "textures")
.then(|| nbt_text(p.get("value")))
.flatten()
})?;
let props = profile
.get("properties")
.or_else(|| profile.get("Properties"))?;
let value = match props {
horizon_world::nbt::Tag::List(list) => list.iter().find_map(|p| {
let name = nbt_text(p.get("name")).or_else(|| nbt_text(p.get("Name")))?;
if name == "textures" {
nbt_text(p.get("value")).or_else(|| nbt_text(p.get("Value")))
} else {
None
}
}),
horizon_world::nbt::Tag::Compound(map) => {
let textures = map
.get(&"textures".into())
.or_else(|| map.get(&"Textures".into()))?;
match textures {
horizon_world::nbt::Tag::List(list) => list
.iter()
.find_map(|p| nbt_text(p.get("Value")).or_else(|| nbt_text(p.get("value")))),
_ => None,
}
}
_ => None,
}?;
unpack(&value, Signature::Unsigned, &TEXTURE_URIS).skin
}
@@ -149,7 +164,8 @@ pub fn profile_skin_url_nbt(profile: &horizon_world::nbt::Tag) -> Option<String>
/// uuid string (older and component forms). Used for `DefaultPlayerSkin.get`
/// when a head's skin is not downloaded yet.
pub fn profile_uuid_nbt(profile: &horizon_world::nbt::Tag) -> Option<u128> {
match profile.get("id")? {
let id_tag = profile.get("id").or_else(|| profile.get("Id"))?;
match id_tag {
horizon_world::nbt::Tag::IntArray(v) if v.len() == 4 => Some(
v.iter()
.fold(0u128, |a, &w| (a << 32) | u128::from(w as u32)),
@@ -391,32 +407,70 @@ pub fn parse_profile(v: &serde_json::Value) -> ProfileInfo {
dynamic: valid_player_name(name).then(|| NameOrId::Name(name.clone())),
};
}
let name = v.get("name").and_then(|n| n.as_str()).map(str::to_owned);
let id = v.get("id").and_then(profile_id);
let properties = v.get("properties").and_then(|p| p.as_array());
let textures = properties
.into_iter()
.flatten()
.find(|p| p.get("name").and_then(|n| n.as_str()) == Some("textures"))
.and_then(|p| {
Some((
p.get("value")?.as_str()?.to_owned(),
if p.get("signature").is_some() {
Signature::Signed
} else {
Signature::Unsigned
},
))
let name = v
.get("name")
.or_else(|| v.get("Name"))
.and_then(|n| n.as_str())
.map(str::to_owned);
let id = v.get("id").or_else(|| v.get("Id")).and_then(profile_id);
let properties = v.get("properties").or_else(|| v.get("Properties"));
let textures = match properties {
Some(serde_json::Value::Array(arr)) => arr
.iter()
.find(|p| {
p.get("name")
.or_else(|| p.get("Name"))
.and_then(|n| n.as_str())
== Some("textures")
})
.and_then(|p| {
Some((
p.get("value")
.or_else(|| p.get("Value"))?
.as_str()?
.to_owned(),
if p.get("signature").or_else(|| p.get("Signature")).is_some() {
Signature::Signed
} else {
Signature::Unsigned
},
))
}),
Some(serde_json::Value::Object(obj)) => {
let textures_prop = obj.get("textures").or_else(|| obj.get("Textures"));
match textures_prop {
Some(serde_json::Value::Array(arr)) => arr.iter().find_map(|p| {
let val = p
.get("Value")
.or_else(|| p.get("value"))?
.as_str()?
.to_owned();
let sig = if p.get("Signature").or_else(|| p.get("signature")).is_some() {
Signature::Signed
} else {
Signature::Unsigned
};
Some((val, sig))
}),
_ => None,
}
}
_ => None,
};
let has_properties = textures.is_some()
|| properties.is_some_and(|p| match p {
serde_json::Value::Array(a) => !a.is_empty(),
serde_json::Value::Object(o) => !o.is_empty(),
_ => false,
});
// `ResolvableProfile.create`: a Partial is Dynamic with no properties
// and exactly one of a name or an id.
let dynamic =
(properties.is_none_or(|p| p.is_empty()) && name.is_some() != id.is_some()).then(|| {
name.as_deref().map_or_else(
|| NameOrId::Id(id.unwrap()),
|n| NameOrId::Name(n.to_owned()),
)
});
let dynamic = (!has_properties && name.is_some() != id.is_some()).then(|| {
name.as_deref().map_or_else(
|| NameOrId::Id(id.unwrap()),
|n| NameOrId::Name(n.to_owned()),
)
});
ProfileInfo {
// `createPartialProfile`: the id, else the name's offline UUID, else nil.
id: id
@@ -435,11 +489,18 @@ pub fn dynamic_nbt(profile: &horizon_world::nbt::Tag) -> Option<NameOrId> {
let name = s.to_string_lossy();
return valid_player_name(&name).then_some(NameOrId::Name(name));
}
if matches!(profile.get("properties"), Some(horizon_world::nbt::Tag::List(l)) if !l.is_empty())
if let Some(props) = profile
.get("properties")
.or_else(|| profile.get("Properties"))
{
return None;
match props {
horizon_world::nbt::Tag::List(l) if !l.is_empty() => return None,
horizon_world::nbt::Tag::Compound(m) if !m.is_empty() => return None,
_ => {}
}
}
match (nbt_text(profile.get("name")), profile_uuid_nbt(profile)) {
let name = nbt_text(profile.get("name")).or_else(|| nbt_text(profile.get("Name")));
match (name, profile_uuid_nbt(profile)) {
(Some(name), None) => Some(NameOrId::Name(name)),
(None, Some(id)) => Some(NameOrId::Id(id)),
_ => None,
@@ -1041,6 +1102,25 @@ mod tests {
super::profile_uuid_nbt(&text_id),
Some(u128::from_str_radix("e843a1f4000000010000000200000003", 16).unwrap())
);
// Legacy SkullOwner format with Properties compound and uppercase Id.
let legacy = horizon_world::nbt::parse_snbt(&format!(
r#"{{Name:"T",Id:[I;-158327916,1572358581,-2125825222,-487948715],Properties:{{textures:[{{Value:"{value}"}}]}}}}"#
))
.unwrap()
.0;
assert_eq!(
super::profile_skin_url_nbt(&legacy).as_deref(),
Some("https://textures.minecraft.net/texture/abc123")
);
assert_eq!(
super::profile_uuid_nbt(&legacy),
Some(
[-158327916, 1572358581, -2125825222, -487948715]
.iter()
.fold(0u128, |a, &w: &i32| (a << 32) | u128::from(w as u32))
)
);
assert_eq!(super::dynamic_nbt(&legacy), None);
}
#[test]
@@ -0,0 +1,345 @@
//! Decoded block sprites kept on disk between launches (M-11): reading and
//! decoding ~1300 small PNGs is most of `Sprites::load`. One file per resource
//! root, valid only while the `textures` trees still have the same paths,
//! sizes and modification times and the format version is the same; anything
//! else (missing, corrupt, stale, unwritable) means the normal decoding path.
use crate::mipmap::Strategy;
use std::{
collections::HashMap,
fs,
hash::{DefaultHasher, Hash, Hasher},
path::{Path, PathBuf},
};
const MAGIC: &[u8; 4] = b"HSPR";
/// Bump when `Decoded` or the decoding in `Sprites::decode` changes.
const VERSION: u32 = 1;
const FILE: &str = "horizon-sprites.cache";
/// What decoding one texture gives, before it takes a layer.
#[derive(Clone)]
pub(crate) struct Decoded {
pub side: u32,
pub frames: Vec<Vec<u8>>,
/// The `.mcmeta` `animation` value, as text.
pub animation: Option<String>,
pub strategy: Strategy,
pub alpha_cutoff_bias: f32,
pub has_transparent: bool,
pub opaque: bool,
pub translucent: bool,
}
pub(crate) struct SpriteCache {
path: PathBuf,
fingerprint: u64,
entries: HashMap<String, Decoded>,
dirty: bool,
}
/// Paths, sizes and modification times of every file under `assets/*/textures`,
/// summed so the directory order does not matter.
fn fingerprint(root: &Path) -> Option<u64> {
fn walk(dir: &Path, base: &Path, sum: &mut u64) -> Option<()> {
for entry in fs::read_dir(dir).ok()? {
let entry = entry.ok()?;
let meta = entry.metadata().ok()?;
if meta.is_dir() {
walk(&entry.path(), base, sum)?;
continue;
}
let mut hasher = DefaultHasher::new();
entry.path().strip_prefix(base).ok()?.hash(&mut hasher);
meta.len().hash(&mut hasher);
meta.modified().ok()?.hash(&mut hasher);
*sum = sum.wrapping_add(hasher.finish());
}
Some(())
}
let mut sum = 0;
for namespace in fs::read_dir(root.join("assets")).ok()? {
let textures = namespace.ok()?.path().join("textures");
if textures.is_dir() {
walk(&textures, root, &mut sum)?;
}
}
Some(sum)
}
impl SpriteCache {
/// The cache of `root`: its stored entries when still valid, else empty.
/// `None` when the textures cannot be fingerprinted (no caching then).
pub fn open(root: &Path) -> Option<Self> {
let fingerprint = fingerprint(root)?;
let path = root.join(FILE);
let entries = fs::read(&path)
.ok()
.and_then(|bytes| parse(&bytes, fingerprint))
.unwrap_or_default();
Some(Self {
path,
fingerprint,
entries,
dirty: false,
})
}
pub fn get(&self, name: &str) -> Option<&Decoded> {
self.entries.get(name)
}
pub fn insert(&mut self, name: &str, decoded: Decoded) {
self.entries.insert(name.to_owned(), decoded);
self.dirty = true;
}
/// Write new entries; a failure only costs the next launch the decoding.
pub fn save(&mut self) {
if !self.dirty {
return;
}
self.dirty = false;
let temporary = self
.path
.with_extension(format!("{}.tmp", std::process::id()));
if fs::write(&temporary, serialize(self.fingerprint, &self.entries)).is_ok()
&& fs::rename(&temporary, &self.path).is_err()
{
let _ = fs::remove_file(&temporary);
}
}
}
fn serialize(fingerprint: u64, entries: &HashMap<String, Decoded>) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(MAGIC);
out.extend_from_slice(&VERSION.to_le_bytes());
out.extend_from_slice(&fingerprint.to_le_bytes());
out.extend_from_slice(&(entries.len() as u32).to_le_bytes());
for (name, d) in entries {
put_bytes(&mut out, name.as_bytes());
out.extend_from_slice(&d.side.to_le_bytes());
out.extend_from_slice(&(d.frames.len() as u32).to_le_bytes());
for frame in &d.frames {
put_bytes(&mut out, frame);
}
put_bytes(&mut out, d.animation.as_deref().unwrap_or("").as_bytes());
out.push(u8::from(d.animation.is_some()));
out.push(d.strategy as u8);
out.extend_from_slice(&d.alpha_cutoff_bias.to_bits().to_le_bytes());
out.push(
u8::from(d.has_transparent) | u8::from(d.opaque) << 1 | u8::from(d.translucent) << 2,
);
}
out
}
fn put_bytes(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
out.extend_from_slice(bytes);
}
struct Reader<'a>(&'a [u8]);
impl<'a> Reader<'a> {
fn take(&mut self, n: usize) -> Option<&'a [u8]> {
let (head, tail) = self.0.split_at_checked(n)?;
self.0 = tail;
Some(head)
}
fn u32(&mut self) -> Option<u32> {
Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?))
}
fn bytes(&mut self) -> Option<&'a [u8]> {
let n = self.u32()? as usize;
self.take(n)
}
}
fn parse(bytes: &[u8], fingerprint: u64) -> Option<HashMap<String, Decoded>> {
let mut r = Reader(bytes);
if r.take(4)? != MAGIC || r.u32()? != VERSION {
return None;
}
if u64::from_le_bytes(r.take(8)?.try_into().ok()?) != fingerprint {
return None;
}
let count = r.u32()?;
let mut entries = HashMap::new();
for _ in 0..count {
let name = String::from_utf8(r.bytes()?.to_vec()).ok()?;
let side = r.u32()?;
let frame_count = r.u32()?;
let mut frames = Vec::new();
for _ in 0..frame_count {
frames.push(r.bytes()?.to_vec());
}
let animation = String::from_utf8(r.bytes()?.to_vec()).ok()?;
let has_animation = r.take(1)?[0] != 0;
let strategy = match r.take(1)?[0] {
0 => Strategy::Auto,
1 => Strategy::Mean,
2 => Strategy::Cutout,
3 => Strategy::StrictCutout,
4 => Strategy::DarkCutout,
_ => return None,
};
let alpha_cutoff_bias = f32::from_bits(r.u32()?);
let flags = r.take(1)?[0];
let frame_len = (side as usize).checked_mul(side as usize)?.checked_mul(4)?;
if frames.is_empty() || frames.iter().any(|f| f.len() != frame_len) {
return None;
}
entries.insert(
name,
Decoded {
side,
frames,
animation: has_animation.then_some(animation),
strategy,
alpha_cutoff_bias,
has_transparent: flags & 1 != 0,
opaque: flags & 2 != 0,
translucent: flags & 4 != 0,
},
);
}
r.0.is_empty().then_some(entries)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(blue: u8, animation: Option<&str>) -> Decoded {
Decoded {
side: 2,
frames: vec![vec![blue; 16], vec![blue + 1; 16]],
animation: animation.map(str::to_owned),
strategy: Strategy::Cutout,
alpha_cutoff_bias: 0.25,
has_transparent: true,
opaque: false,
translucent: true,
}
}
fn scratch(tag: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("horizon-sprite-cache-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("assets/minecraft/textures/block")).unwrap();
dir
}
#[test]
fn round_trips() {
let mut entries = HashMap::new();
entries.insert("a:b".to_owned(), sample(1, Some("{\"frametime\":2}")));
entries.insert("a:c".to_owned(), sample(9, None));
let back = parse(&serialize(7, &entries), 7).unwrap();
for (name, d) in &entries {
let e = &back[name];
assert_eq!(
(d.side, &d.frames, &d.animation),
(e.side, &e.frames, &e.animation)
);
assert_eq!(d.alpha_cutoff_bias, e.alpha_cutoff_bias);
assert_eq!(
(d.has_transparent, d.opaque, d.translucent, d.strategy as u8),
(e.has_transparent, e.opaque, e.translucent, e.strategy as u8)
);
}
}
#[test]
fn wrong_fingerprint_truncation_and_garbage_are_misses() {
let mut entries = HashMap::new();
entries.insert("a:b".to_owned(), sample(1, None));
let bytes = serialize(7, &entries);
assert!(parse(&bytes, 8).is_none());
for cut in 0..bytes.len() {
assert!(parse(&bytes[..cut], 7).is_none(), "cut {cut}");
}
assert!(parse(b"garbage", 7).is_none());
let mut extra = bytes.clone();
extra.push(0);
assert!(parse(&extra, 7).is_none());
}
#[test]
fn changed_texture_invalidates_and_corrupt_file_is_ignored() {
let root = scratch("invalidate");
let png = root.join("assets/minecraft/textures/block/x.png");
fs::write(&png, b"one").unwrap();
let mut cache = SpriteCache::open(&root).unwrap();
cache.insert("minecraft:block/x", sample(1, None));
cache.save();
assert!(
SpriteCache::open(&root)
.unwrap()
.get("minecraft:block/x")
.is_some()
);
// Same length, new content and time: still stale.
std::thread::sleep(std::time::Duration::from_millis(30));
fs::write(&png, b"two").unwrap();
assert!(
SpriteCache::open(&root)
.unwrap()
.get("minecraft:block/x")
.is_none()
);
// A new file also invalidates.
let mut cache = SpriteCache::open(&root).unwrap();
cache.insert("minecraft:block/x", sample(1, None));
cache.save();
fs::write(root.join("assets/minecraft/textures/block/y.png"), b"y").unwrap();
assert!(
SpriteCache::open(&root)
.unwrap()
.get("minecraft:block/x")
.is_none()
);
// Corrupt cache file: empty, no error.
fs::write(root.join(FILE), b"HSPR junk").unwrap();
assert!(
SpriteCache::open(&root)
.unwrap()
.get("minecraft:block/x")
.is_none()
);
let _ = fs::remove_dir_all(&root);
}
fn write_png(path: &Path, rgba: [u8; 4]) {
let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), 16, 16);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let pixels: Vec<u8> = rgba.iter().copied().cycle().take(16 * 16 * 4).collect();
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
}
fn sprite_pixels(root: &Path) -> Vec<u8> {
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
sprites.insert(root, "minecraft:block/x").unwrap();
sprites.save_disk_cache();
sprites.finish();
sprites.pixels
}
#[test]
fn altered_png_is_not_served_stale_and_cache_matches_decoding() {
let root = scratch("stale");
let png = root.join("assets/minecraft/textures/block/x.png");
write_png(&png, [255, 0, 0, 255]);
let decoded = sprite_pixels(&root);
assert!(root.join(FILE).exists());
assert_eq!(
sprite_pixels(&root),
decoded,
"cache hit differs from decoding"
);
std::thread::sleep(std::time::Duration::from_millis(30));
write_png(&png, [0, 255, 0, 255]);
let fresh = sprite_pixels(&root);
assert_eq!(&fresh[..4], &[0, 255, 0, 255]);
// Corrupt and deleted caches fall back to decoding.
fs::write(root.join(FILE), b"HSPR junk").unwrap();
assert_eq!(sprite_pixels(&root), fresh);
fs::remove_file(root.join(FILE)).unwrap();
assert_eq!(sprite_pixels(&root), fresh);
let _ = fs::remove_dir_all(&root);
}
}
+74 -13
View File
@@ -89,6 +89,8 @@ pub struct Sprites {
/// textures under their own names, and one layer serves them all (the
/// GPU's array may hold as few as 2048).
still: std::collections::HashMap<(Vec<u8>, u32, [u32; 2], [bool; 4]), u32>,
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
disk: Option<crate::sprite_cache::SpriteCache>,
}
/// Edge length of vanilla's block textures: the smallest array layer.
pub const SPRITE_SIZE: u32 = 16;
@@ -111,6 +113,20 @@ pub(crate) fn resample(frame: &[u8], from: u32, to: u32) -> Vec<u8> {
out
}
impl Sprites {
/// Sprites that reuse textures decoded by an earlier launch, kept in a
/// cache file in `root`.
pub fn with_disk_cache(root: &Path) -> Self {
Self {
disk: crate::sprite_cache::SpriteCache::open(root),
..Self::default()
}
}
/// Write what this run decoded to the cache file.
pub fn save_disk_cache(&mut self) {
if let Some(disk) = &mut self.disk {
disk.save();
}
}
/// How many array layers the textures take.
pub fn count(&self) -> usize {
self.native.len()
@@ -201,6 +217,21 @@ impl Sprites {
}
}
fn load(&mut self, root: &Path, name: &str) -> Result<u32> {
let cached = self.disk.as_ref().and_then(|d| d.get(name)).cloned();
let decoded = match cached {
Some(decoded) => decoded,
None => {
let decoded = Self::decode(root, name)?;
if let Some(disk) = &mut self.disk {
disk.insert(name, decoded.clone());
}
decoded
}
};
self.register(name, decoded)
}
/// Read and decode one texture and its `.mcmeta`.
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
let (namespace, path) = name.split_once(':').ok_or("invalid sprite identifier")?;
let path = root.join(format!("assets/{namespace}/textures/{path}.png"));
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
@@ -210,10 +241,13 @@ impl Sprites {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
let animation = metadata
let animation_text = metadata
.as_ref()
.and_then(|v| v.get("animation"))
.map(|v| AnimationMetadata::from_json(v.get()))
.map(|v| v.get().to_owned());
let animation = animation_text
.as_deref()
.map(AnimationMetadata::from_json)
.transpose()?;
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
@@ -247,7 +281,6 @@ impl Sprites {
};
pixels.extend(rgba);
}
let layer = u32::try_from(self.native.len())?;
let columns = info.width / width;
let count = columns * (info.height / height);
// Layers are square; a frame of another shape is nearest-resampled to
@@ -268,28 +301,56 @@ impl Sprites {
}
frames.push(frame);
}
let settings = crate::mipmap::Settings {
native_size: side,
strategy: texture_section(metadata.as_ref())
let texture = texture_section(metadata.as_ref());
Ok(crate::sprite_cache::Decoded {
side,
frames,
animation: animation_text,
strategy: texture
.as_ref()
.and_then(|t| t["mipmap_strategy"].as_str())
.and_then(crate::mipmap::Strategy::parse)
.unwrap_or_default(),
alpha_cutoff_bias: texture_section(metadata.as_ref())
alpha_cutoff_bias: texture
.as_ref()
.and_then(|t| t["alpha_cutoff_bias"].as_f64())
.unwrap_or(0.0) as f32,
has_transparent: pixels.as_chunks::<4>().0.iter().any(|p| p[3] == 0),
opaque: pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 255),
translucent: pixels
.as_chunks::<4>()
.0
.iter()
.any(|p| p[3] != 0 && p[3] != 255),
})
}
/// Give a decoded texture its layer (or the one an identical still has).
fn register(&mut self, name: &str, decoded: crate::sprite_cache::Decoded) -> Result<u32> {
let crate::sprite_cache::Decoded {
side,
frames,
animation: animation_text,
strategy,
alpha_cutoff_bias,
has_transparent,
opaque,
translucent,
} = decoded;
let animation = animation_text
.as_deref()
.map(AnimationMetadata::from_json)
.transpose()?;
let count = frames.len() as u32;
let layer = u32::try_from(self.native.len())?;
let settings = crate::mipmap::Settings {
native_size: side,
strategy,
alpha_cutoff_bias,
has_transparent,
item: name
.split_once(':')
.is_some_and(|(_, p)| p.starts_with("item/")),
};
let opaque = pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 255);
let translucent = pixels
.as_chunks::<4>()
.0
.iter()
.any(|p| p[3] != 0 && p[3] != 255);
let still = (animation.is_none() && frames.len() == 1).then(|| {
(
frames[0].clone(),
+19 -29
View File
@@ -24,6 +24,9 @@ struct Globals {
outline_params: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
// The sunrise fan's 16 triangles (gfx/sunrise_fan.rs): xyz the apex weight's normal, w the
// triangle's volume with the camera; then each rim vertex's normal against the apex.
sunrise_fan: array<vec4<f32>, 32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
@@ -197,39 +200,26 @@ fn sky_fog(color: vec3<f32>, dist: vec2<f32>) -> vec3<f32> {
let f = max(linear_fog(dist.x, 0.0, end), linear_fog(dist.y, end, end));
return mix(color, globals.fog_color.rgb, f * globals.fog_color.a);
}
fn rotate_x90(v: vec3<f32>) -> vec3<f32> { return vec3(v.x, -v.z, v.y); }
// Weight of the fan centre where the ray crosses the sunrise fan (0 when missed).
// Weight of the fan centre where the ray crosses the sunrise fan (0 when missed):
// Moller-Trumbore from the origin along d, over normals built once a frame
// (gfx/sunrise_fan.rs). The barycentrics are apex/s, k1/s and -k0/s; s is -det.
fn sunrise_weight(d: vec3<f32>) -> f32 {
let alpha = globals.sunrise.a;
if alpha <= 0.001 { return 0.0; }
// sin(sunAngle) < 0 flips the fan to the other horizon; sun.z = -sin(sunAngle).
let flip = select(0.0, 180.0, -globals.sun.z < 0.0);
let theta = radians(flip + 90.0);
let c = cos(theta);
let s = sin(theta);
if globals.sunrise.a <= 0.001 { return 0.0; }
var best = 0.0;
let apex = rotate_x90(vec3(-100.0 * s, 100.0 * c, 0.0));
var k0 = dot(d, globals.sunrise_fan[16].xyz);
for (var i = 0; i < 16; i++) {
let a0 = f32(i) * 2.0 * PI / 16.0;
let a1 = f32(i + 1) * 2.0 * PI / 16.0;
let l0 = vec3(sin(a0) * 120.0, cos(a0) * 120.0, -cos(a0) * 40.0 * alpha);
let l1 = vec3(sin(a1) * 120.0, cos(a1) * 120.0, -cos(a1) * 40.0 * alpha);
let r0 = rotate_x90(vec3(l0.x * c - l0.y * s, l0.x * s + l0.y * c, l0.z));
let r1 = rotate_x90(vec3(l1.x * c - l1.y * s, l1.x * s + l1.y * c, l1.z));
// Moller-Trumbore from the origin along d.
let e1 = r0 - apex;
let e2 = r1 - apex;
let pv = cross(d, e2);
let det = dot(e1, pv);
if abs(det) < 1e-6 { continue; }
let tv = -apex;
let u = dot(tv, pv) / det;
let qv = cross(tv, e1);
let v = dot(d, qv) / det;
let t = dot(e2, qv) / det;
if u >= 0.0 && v >= 0.0 && u + v <= 1.0 && t > 0.0 {
best = max(best, 1.0 - u - v);
let triangle = globals.sunrise_fan[i];
let k1 = dot(d, globals.sunrise_fan[16 + (i + 1) % 16].xyz);
// A hit needs s on the side of w, so k0 and k1 on either side of 0.
if k0 * triangle.w <= 0.0 && k1 * triangle.w >= 0.0 {
let apex = dot(d, triangle.xyz);
let s = apex + k1 - k0;
if abs(s) >= 1e-6 && s * triangle.w > 0.0 && apex * s >= 0.0 && k1 * s >= 0.0
&& k0 * s <= 0.0 {
best = max(best, apex / s);
}
}
k0 = k1;
}
return best;
}
@@ -0,0 +1,58 @@
//! M-11 gate: block assets loaded through the sprite disk cache are byte-identical
//! to the ones decoded from the PNGs. Deletes the cache file in ROOT, loads (decodes
//! and writes it), loads again (reads it) and compares the sprite arrays.
//! cargo run --profile fast -p horizon-client --example sprite_cache_parity -- ROOT
use horizon_client::assets::{Assets, RenderAssets};
use std::{
hash::{DefaultHasher, Hash, Hasher},
path::Path,
time::Instant,
};
fn digest(render: &RenderAssets) -> u64 {
let mut h = DefaultHasher::new();
render.pixels.hash(&mut h);
(
render.sprite_size,
render.sprite_minimum_size,
render.texture_layers,
)
.hash(&mut h);
render.sprite_layers.hash(&mut h);
render.translucent_layers.hash(&mut h);
format!("{:?}", render.sprite_mips).hash(&mut h);
for animation in &render.animations {
animation.layer.hash(&mut h);
for frame in 0..animation.frame_count() {
animation.pixels(frame).hash(&mut h);
}
for tick in 0..400 {
format!("{:?}", animation.blend(tick)).hash(&mut h);
}
}
h.finish()
}
fn main() -> horizon_client::Result<()> {
let root = std::env::args()
.nth(1)
.ok_or("usage: sprite_cache_parity ROOT")?;
let root = Path::new(&root);
let assets = Assets::load(root)?;
let _ = std::fs::remove_file(root.join("horizon-sprites.cache"));
let start = Instant::now();
let cold = digest(&assets.reload(root)?);
let cold_ms = start.elapsed().as_secs_f64() * 1000.;
let size = std::fs::metadata(root.join("horizon-sprites.cache"))?.len();
let start = Instant::now();
let warm = digest(&assets.reload(root)?);
let warm_ms = start.elapsed().as_secs_f64() * 1000.;
println!(
"cold={cold:016x} ({cold_ms:.1} ms) warm={warm:016x} ({warm_ms:.1} ms) cache_bytes={size}"
);
if cold != warm {
return Err("cached sprites differ from decoded sprites".into());
}
println!("PARITY OK");
Ok(())
}
+29 -3
View File
@@ -1818,7 +1818,14 @@ pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Ta
pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option<ItemFrame> {
let pos = match tag.get("block_pos") {
Some(Tag::IntArray(v)) if v.len() == 3 => [v[0], v[1], v[2]],
_ => return None,
_ => match (
number(tag.get("TileX")),
number(tag.get("TileY")),
number(tag.get("TileZ")),
) {
(Some(x), Some(y), Some(z)) => [x as i32, y as i32, z as i32],
_ => return None,
},
};
Some(ItemFrame {
id,
@@ -1828,8 +1835,8 @@ pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option<ItemFrame> {
item: tag.get("Item").and_then(item_from_nbt),
rotation: (number(tag.get("ItemRotation")).unwrap_or(0) & 7) as u8,
drop_chance: float(tag.get("ItemDropChance")).map_or(1.0, |v| v as f32),
fixed: flag(tag, "Fixed"),
invisible: flag(tag, "Invisible"),
fixed: flag(tag, "Fixed") || flag(tag, "fixed"),
invisible: flag(tag, "Invisible") || flag(tag, "invisible"),
check_interval: 0,
})
}
@@ -2979,4 +2986,23 @@ mod fireball_nbt_tests {
(ProjectileKind::LargeFireball { power: 1 }, 0.1)
);
}
#[test]
fn item_frame_reads_legacy_tile_pos_and_invisibility() {
let tag = Tag::compound([
("id", Tag::String("minecraft:item_frame".into())),
("TileX", Tag::Int(5)),
("TileY", Tag::Int(64)),
("TileZ", Tag::Int(-12)),
("Facing", Tag::Byte(3)),
("Invisible", Tag::Byte(1)),
("Fixed", Tag::Byte(1)),
]);
let frame = item_frame_from_nbt(&tag, 100).expect("frame");
assert_eq!(frame.id, 100);
assert_eq!(frame.pos, [5, 64, -12]);
assert_eq!(frame.facing, 3);
assert!(frame.invisible);
assert!(frame.fixed);
}
}
+16 -2
View File
@@ -1421,6 +1421,8 @@ struct Game<'a> {
/// `FogRenderer`'s evolving state.
fog: horizon_client::fog::FogState,
screen_fx: horizon_client::screen_effects::ScreenEffects,
/// `LocalPlayer.itemActivation`: the totem flying up to the screen.
item_activation: horizon_client::item_activation::ItemActivation,
weather_parameters: horizon_client::weather::ParameterCache,
panorama_rotation: horizon_client::gfx::panorama::Rotation,
world: World,
@@ -5131,8 +5133,10 @@ impl Game<'_> {
self.track_experience_display();
// `/tick freeze` stops the world but not the player.
let running = self.clock.world_runs();
// GameRenderer.tick: a wither's bar darkens the world, then fades.
// GameRenderer.tick: a wither's bar darkens the world, then fades;
// the activated item counts down.
self.boss_overlay.tick_darken(running);
self.tick_item_activation();
if running {
self.world.game_time += 1;
if self.ui.rules.bool("advance_weather") {
@@ -9494,6 +9498,8 @@ impl Game<'_> {
horizon_client::crash::clear_detail("World");
self.debug_charts.reset();
self.boss_overlay.reset();
// GameRenderer.resetData (Minecraft.clearClientLevel).
self.item_activation.reset();
self.bosses = Default::default();
self.streaming.paused = true;
self.world = World::new(self.res.facts().air, 0);
@@ -11898,11 +11904,18 @@ impl Game<'_> {
} else {
(Vec::new(), None)
};
let hand = if title || self.scoping() {
let mut hand = if title || self.scoping() {
Vec::new()
} else {
self.hand_geometry(camera_alpha)
};
// GameRenderer.render3dHud: the screen effects after the hand, in
// its pass (the hand pass draws with `bob * view`).
if !title {
let view = bob * horizon_client::camera::view_matrix(yaw, pitch);
let aspect = size[0] as f32 / size[1].max(1) as f32;
self.item_activation_geometry(camera_alpha as f32, view, aspect, &mut hand);
}
let screen = if title {
Vec::new()
} else {
@@ -13816,6 +13829,7 @@ impl<'a> Game<'a> {
gui,
fog: Default::default(),
screen_fx: Default::default(),
item_activation: Default::default(),
weather_parameters: Default::default(),
panorama_rotation: Default::default(),
world,
+11 -1
View File
@@ -132,6 +132,16 @@ impl Game<'_> {
"minecraft:glow_item_frame" => true,
_ => return false,
};
let invisible = stack.extra.as_ref().is_some_and(|extra| {
extra.other.iter().any(|(k, v)| {
(k == "minecraft:entity_data" || k == "entity_data" || k == "EntityTag")
&& horizon_client::save::number(
v.get("Invisible").or_else(|| v.get("invisible")),
)
.unwrap_or(0)
!= 0
})
});
let pos = std::array::from_fn(|i| hit.pos[i] + hit.face[i]);
let frame = ItemFrame {
id: self.next_entity + 1,
@@ -142,7 +152,7 @@ impl Game<'_> {
rotation: 0,
drop_chance: 1.0,
fixed: false,
invisible: false,
invisible,
check_interval: 0,
};
if !self.frame_survives(&frame) {
+14 -1
View File
@@ -344,11 +344,24 @@ impl Game<'_> {
pose: glam::Mat4,
light: u32,
out: &mut Vec<Vertex>,
) {
let lights = diffuse_lights(self.dim == Dim::Nether);
self.submit_item_lit(state, pose, light, lights, out);
}
/// [`Self::submit_item_world`] shaded by other diffuse lights (a
/// `Lighting.Entry` other than the level's), in the pose's output space.
pub(super) fn submit_item_lit(
&self,
state: &ItemRenderState,
pose: glam::Mat4,
light: u32,
lights: (glam::Vec3, glam::Vec3),
out: &mut Vec<Vertex>,
) {
let mut sink = WorldSink {
out,
light,
lights: Some(diffuse_lights(self.dim == Dim::Nether)),
lights: Some(lights),
glint: self.glint(),
};
state.submit(
+11 -1
View File
@@ -153,8 +153,15 @@ impl Game<'_> {
);
e.head_yaw = a.yaw;
e.velocity = [a.velocity.x, a.velocity.y, a.velocity.z];
let mut flags = 0i8;
if a.fire_ticks > 0 {
e.meta.push((0, serializer::BYTE, Value::Byte(1)));
flags |= 1;
}
if a.invisible {
flags |= 0x20;
}
if flags != 0 {
e.meta.push((0, serializer::BYTE, Value::Byte(flags)));
}
// LivingEntity.DATA_HEALTH_ID: 1 until the stand's max health is set.
e.meta.push((9, serializer::FLOAT, Value::Float(a.health)));
@@ -203,6 +210,9 @@ impl Game<'_> {
_ => 0.0,
};
e.data = i32::from(f.facing);
if f.invisible {
e.meta.push((0, serializer::BYTE, Value::Byte(0x20)));
}
// HangingEntity.DATA_DIRECTION, south by default.
if f.facing != 3 {
e.meta
+3
View File
@@ -548,6 +548,8 @@ impl Game<'_> {
}
self.clear_waypoints();
self.boss_overlay.reset();
// GameRenderer.resetData (Minecraft.disconnect).
self.item_activation.reset();
if let Some(mut r) = self.remote.take() {
r.session.disconnect();
}
@@ -3646,6 +3648,7 @@ impl Game<'_> {
// the time only while the level runs normally.
let running = self.clock.world_runs();
self.boss_overlay.tick_darken(running);
self.tick_item_activation();
if running {
self.world.game_time += 1;
self.remote_clock_tick();
@@ -135,6 +135,7 @@ impl Game<'_> {
continue;
};
match (index, value) {
(0, Value::Byte(flags)) => f.invisible = (*flags as u8 & 0x20) != 0,
(8, Value::Id(d)) => f.facing = (*d).clamp(0, 5) as u8,
(10, Value::Int(r)) => f.rotation = r.rem_euclid(8) as u8,
_ => {}
+63
View File
@@ -1,8 +1,12 @@
//! The totem of undying (`LivingEntity.checkTotemDeathProtection` with the
//! totem's `death_protection` component, entity event 35 on the client).
use super::Game;
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
block_entity_render::vertex_light,
effects::EffectInstance,
items::ItemStack,
mesher::Vertex,
particles::{ParticleOptions, ParticleType},
};
@@ -120,8 +124,67 @@ impl Game<'_> {
.play_unshared("minecraft:item.totem.use", Some(body.at), 1.0, 1.0);
if own {
self.pad_rumble(horizon_client::gamepad::Rumble::Totem);
// findTotem, then LocalPlayer.displayItemActivation. The offsets
// come from the player's own random in vanilla, not the level's:
// the client-only particle random stands in for it.
let totem = self.find_totem();
self.item_activation
.activate(totem, self.particles.random());
}
}
/// `ClientPacketListener.findTotem`: the first hand's item with
/// `death_protection`, else a new totem (a used single totem is gone by
/// the time the event arrives).
fn find_totem(&self) -> ItemStack {
let models = &self.pack.item_renderer.models;
[self.held(), self.offhand.as_ref()]
.into_iter()
.flatten()
.find(|s| {
models
.components(&horizon_client::item_render::stack_view(s))
.get("minecraft:death_protection")
.is_some()
})
.cloned()
.unwrap_or_else(|| ItemStack::new(TOTEM, 1))
}
/// `GameRenderer.tick`: the activated item counts down.
pub(super) fn tick_item_activation(&mut self) {
if self.player.is_some() {
self.item_activation.tick();
}
}
/// `ScreenEffectRenderer.renderItemActivationAnimation`, for the hand
/// pass: the item in view space with the hand's projection but none of
/// the camera's turn or bob (a fresh `PoseStack`), so those are undone
/// here. `FIXED` display, no owner, seed 0 (`LevelExtractor`), full
/// brightness and `Lighting.Entry.ITEMS_3D`. Only F1 hides it.
pub(super) fn item_activation_geometry(
&self,
partial: f32,
view: glam::Mat4,
aspect: f32,
out: &mut Vec<Vertex>,
) {
if self.ui.hide_gui {
return;
}
let (Some(stack), Some(pose)) = (
self.item_activation.active(),
self.item_activation.pose(partial, aspect),
) else {
return;
};
let state = self.resolve_item(stack, DisplayContext::Fixed, None, 0);
let to_world = view.inverse();
let (a, b) = horizon_client::item_render::gui::items_3d_lights();
let lights = (
to_world.transform_vector3(a).normalize(),
to_world.transform_vector3(b).normalize(),
);
self.submit_item_lit(&state, to_world * pose, vertex_light(0xFF), lights, out);
}
}
/// Where an entity event's entity stands and its size.
@@ -0,0 +1,144 @@
//! The item that spins up to the screen when it activates (the totem of
//! undying): `net.minecraft.client.player.ItemActivation`, set by
//! `LocalPlayer.displayItemActivation`, ticked by `GameRenderer.tick`, and
//! posed by `ScreenEffectRenderer.renderItemActivationAnimation`.
use crate::items::ItemStack;
use glam::{Mat4, Vec3};
use horizon_core::{geometry::trig::Mth, random::RandomSource};
/// `ItemActivation.ANIMATION_LENGTH`, in ticks.
pub const ANIMATION_LENGTH: i32 = 40;
#[derive(Clone, Debug, Default)]
pub struct ItemActivation {
item: Option<ItemStack>,
ticks: i32,
off_x: f32,
off_y: f32,
}
impl ItemActivation {
/// `ItemActivation.tick`: the item goes when the count reaches 0.
pub fn tick(&mut self) {
if self.ticks > 0 {
self.ticks -= 1;
if self.ticks == 0 {
self.item = None;
}
}
}
/// `ItemActivation.reset` (`GameRenderer.resetData`).
pub fn reset(&mut self) {
self.item = None;
self.ticks = 0;
}
/// `ItemActivation.activate`: a copy of the stack, the full length, and
/// two random offsets in [-1, 1) for where on the screen it flies.
pub fn activate(&mut self, item: ItemStack, random: &mut impl RandomSource) {
self.item = Some(item);
self.ticks = ANIMATION_LENGTH;
self.off_x = random.next_float() * 2.0 - 1.0;
self.off_y = random.next_float() * 2.0 - 1.0;
}
/// `ItemActivation.isActive`: the item to draw, while it shows.
pub fn active(&self) -> Option<&ItemStack> {
self.item.as_ref().filter(|_| self.ticks > 0)
}
/// The item's view-space pose this frame, before its `FIXED` display
/// transform; `aspect` is the window's width over its height.
pub fn pose(&self, partial: f32, aspect: f32) -> Option<Mat4> {
self.active()?;
Some(animation_pose(
self.ticks, partial, self.off_x, self.off_y, aspect,
))
}
}
/// `ScreenEffectRenderer.renderItemActivationAnimation`: a quintic over the
/// 40 ticks brings the item from 10 blocks out to 1 and back while it turns
/// 900 degrees about Y and rocks 6 degrees about X and Z, drawn with the
/// hand's projection and no camera transform.
pub fn animation_pose(ticks: i32, partial: f32, off_x: f32, off_y: f32, aspect: f32) -> Mat4 {
let tick = ANIMATION_LENGTH - ticks;
let t = (tick as f32 + partial) / 40.0;
let t2 = t * t;
let t3 = t * t2;
let curve = 10.25 * t3 * t2 - 24.95 * t2 * t2 + 25.5 * t3 - 13.8 * t2 + 4.0 * t;
let angle = curve * std::f32::consts::PI;
let x = off_x * 0.3 * aspect;
let y = off_y * 0.3;
let swing = (angle * 2.0).msin().abs();
Mat4::from_translation(Vec3::new(x * swing, y * swing, -10.0 + 9.0 * angle.msin()))
* Mat4::from_scale(Vec3::splat(0.8))
* Mat4::from_rotation_y((900.0 * angle.msin().abs()).to_radians())
* Mat4::from_rotation_x((6.0 * (t * 8.0).mcos()).to_radians())
* Mat4::from_rotation_z((6.0 * (t * 8.0).mcos()).to_radians())
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_core::random::LegacyRandom;
fn totem() -> ItemStack {
ItemStack::new("minecraft:totem_of_undying", 1)
}
#[test]
fn it_shows_for_forty_ticks() {
let mut a = ItemActivation::default();
assert!(a.active().is_none());
a.activate(totem(), &mut LegacyRandom::new(0));
for _ in 0..39 {
a.tick();
assert!(a.active().is_some());
}
a.tick();
assert!(a.active().is_none());
assert!(a.pose(0.0, 1.0).is_none());
}
#[test]
fn the_offsets_are_two_floats_from_the_players_random() {
// java.util.Random(0): nextFloat() 0.73096776, then 0.831441.
let mut a = ItemActivation::default();
a.activate(totem(), &mut LegacyRandom::new(0));
assert!((a.off_x - 0.461_935_5).abs() < 1e-6, "{}", a.off_x);
assert!((a.off_y - 0.662_882).abs() < 1e-6, "{}", a.off_y);
}
#[test]
fn it_comes_from_ten_blocks_out_to_one_and_back() {
let at = |ticks, partial| {
animation_pose(ticks, partial, 0.5, -0.5, 16.0 / 9.0)
.w_axis
.truncate()
};
// First tick: straight ahead, 10 out.
assert_eq!(at(40, 0.0), Vec3::new(0.0, 0.0, -10.0));
// Halfway the curve is 0.4984375: almost PI/2, so about 1 out and
// the sideways swing (|sin 2a|) nearly gone.
let mid = at(20, 0.0);
assert!((mid.z - -1.0).abs() < 1e-3, "{mid}");
let a = 0.498_437_5 * std::f32::consts::PI;
let swing = (a * 2.0).msin().abs();
assert!((mid.x - 0.5 * 0.3 * 16.0 / 9.0 * swing).abs() < 1e-6);
assert!((mid.y - -0.5 * 0.3 * swing).abs() < 1e-6);
// A quarter in (curve 0.4484863): already near, swung to the side.
let quarter = at(30, 0.0);
let a = 0.448_486_33 * std::f32::consts::PI;
assert!((quarter.z - (-10.0 + 9.0 * a.msin())).abs() < 1e-5);
let swing = (a * 2.0).msin().abs();
assert!((quarter.x - 0.5 * 0.3 * 16.0 / 9.0 * swing).abs() < 1e-6);
// Partial ticks move it smoothly between ticks.
let between = at(39, 0.5).z;
assert!(between > at(39, 0.0).z && between < at(38, 0.0).z);
}
#[test]
fn it_is_scaled_to_four_fifths() {
let m = animation_pose(25, 0.3, 0.0, 0.0, 1.0);
let (scale, _, _) = m.to_scale_rotation_translation();
assert!((scale - Vec3::splat(0.8)).abs().max_element() < 1e-5);
}
}
+1
View File
@@ -64,6 +64,7 @@ pub mod heartbeat;
pub mod hotbar;
pub mod hunger;
pub mod inv_sort;
pub mod item_activation;
pub mod item_render;
pub mod item_sync;
pub mod item_tooltip;
+27 -2
View File
@@ -647,11 +647,16 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
return Some((pos, BlockEntity::Beacon(Box::new(beacon))));
}
// A skull's block entity id is not its block's name (every head block).
if id == "minecraft:skull" {
if id == "minecraft:skull" || id == "Skull" {
let profile = tag
.get("profile")
.or_else(|| tag.get("SkullOwner"))
.or_else(|| tag.get("ExtraType"))
.cloned();
return Some((
pos,
BlockEntity::Skull(crate::containers::Skull::new(
tag.get("profile").cloned(),
profile,
text(tag.get("note_block_sound")),
tag.get("custom_name")
.and_then(crate::block_entity_nbt::component),
@@ -4693,6 +4698,26 @@ mod tests {
assert!(BlockEntity::for_block("minecraft:piston_head").is_none());
}
#[test]
fn skull_block_entity_decodes_legacy_skull_owner() {
let legacy = horizon_world::nbt::parse_snbt(
r#"{id:"minecraft:skull",x:5,y:65,z:-20,SkullOwner:{Name:"CustomStatue",Id:[I;9,8,7,6],Properties:{textures:[{Value:"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvMTIzIn19fQ=="}]}}}"#,
)
.unwrap()
.0;
let (pos, back) = decode_block_entity(&legacy).expect("skull from SkullOwner");
assert_eq!(pos, (5, 65, -20));
let crate::containers::BlockEntity::Skull(skull) = &back else {
panic!("not a skull");
};
assert!(skull.profile.is_some());
let skin = skull.skin.as_ref().expect("skull skin");
assert_eq!(
skin.download.as_ref().map(|(u, _)| u.as_str()),
Some("http://textures.minecraft.net/texture/123")
);
}
#[test]
fn uuid_text_round_trips() {
let u = [-158327916, 1572358581, -2125825222, -487948715];