mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd15699bfa | ||
|
|
e67701b136 | ||
|
|
5399e47ea0 | ||
|
|
3c19f30788 | ||
|
|
6f9d480022 | ||
|
|
02cc9e7c3a | ||
|
|
e7f746782f | ||
|
|
cd97b34460 | ||
|
|
13e441e235 | ||
|
|
c9a8361493 | ||
|
|
e14d6f79b1 |
No files matched your search
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 | ||||