Bake vanilla cuboid geometry with native float-bit validation
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"minecraft": "26.3",
|
||||
"manifest_sha1": "4fe1aa1ef8da1cb95c5bad1fb98890ca56dd8ca3",
|
||||
"platform": "Linux-7.0.0-31-generic-x86_64-with-glibc2.43",
|
||||
"java": "openjdk 25.0.4.1 2026-08-18",
|
||||
"rustc": "rustc 1.99.0 (b940084d7 2026-09-28)",
|
||||
"resource_models": 2883,
|
||||
"graph_fixtures": 440,
|
||||
"rejected_roots": 1039,
|
||||
"sources_sha256": {
|
||||
"mods/horizon/src/test/java/dev/horizonlab/parity/VanillaModelBakingOracle.java": "fca267347a21149791a3260826be98df55705208c30396277ef6c3feba875748",
|
||||
"mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCuboidModelOracle.java": "7526f20513c549483122ab0d5ad24b9e87f38dc2f6aae55addd701a91ac85e43",
|
||||
"tools/test_model_baking_parity.py": "392ffec44918fb91740ca4a1316d684480d39f14bd34d46175f7f44946d15bd7",
|
||||
"tools/test_cuboid_model_parity.py": "3b7c018570b0f609aae43cef60ff3139c543d5d629be804a5aa257e3ab2dd67c",
|
||||
"tools/test_model_resolution_parity.py": "d9595f2f1442f1f590b72d2a46600657e79ae103bec3d4c45db98242b964bde0",
|
||||
"tools/lab.py": "d00bf107fa01b6c579734a082c3ca4ade3018f0d6851d46844acf17574f66e64",
|
||||
"tools/test_rust_parity.py": "29d3db08005873528894b45ca10dcd6278753650473b3eb574e53e06ede40ec6",
|
||||
"Cargo.toml": "3f93319c1eb70dbd7ba25cd74ef343fbf35c52c7c4752f955605c58699cbff9d",
|
||||
"Cargo.lock": "7b4ab0ffa35af7ea8b6e38525f18ff6c3e94768198915f5ffe560d84263423f8",
|
||||
"crates/horizon-assets/Cargo.toml": "fadb78cf819e026500606c45d27138ed67fcbbc967ae8cee6c1e7c82c9046b36",
|
||||
"crates/horizon-assets/examples/cuboid_model_fixture.rs": "97609d90e3f6ce340f3590296db2dc778113bb2b33c7d1b54080a1d6f3023f40",
|
||||
"crates/horizon-assets/examples/model_baking_fixture.rs": "59caca09dd5afd0b70a2e8fdb8e949deb9322f00adc617918b33d1e7e5b0b522",
|
||||
"crates/horizon-assets/examples/model_resolution_fixture.rs": "c071067140299e49345a6ebd94e15b3b6d853cb9ae11d8de46e3cbdfb8b01f0e",
|
||||
"crates/horizon-assets/examples/support/mod.rs": "bcf5a6d8a54845982191e037911c24139237771d6afa4b01b1e83c011489c3f5",
|
||||
"crates/horizon-assets/src/bake.rs": "0bc519e93ad8200d89543ceb5c811dd82bbeb0e9003b143385c8a03e758dfe65",
|
||||
"crates/horizon-assets/src/json.rs": "e7b89bf4852f561a0e5bf1a984857c61e755961dfd86fcc326b6d55558615f39",
|
||||
"crates/horizon-assets/src/lib.rs": "5fe567bdf1e63230cee2b960719c5cb6166176a674772955329a9d04cf22b9db",
|
||||
"crates/horizon-assets/src/model.rs": "57ddb3d65e88e7a8aa22c62bc298a2dae7f4e994326a873a31f06c0f36ed051b",
|
||||
"crates/horizon-assets/src/resolve.rs": "3927ac926cdcbd7d334901cc32d0ecc1e498b1bec98f76a46bca6d4964c13541"
|
||||
},
|
||||
"fixtures_sha256": {
|
||||
".local/rust-model-baking-parity/inputs.jsonl": "aba24d38f4fc1af060502f983923c2f279c016d0fd412d40f6d50093fac1d811"
|
||||
},
|
||||
"rust_output_sha256": "9216be3bbae9e59bb89b6ed780ccedc5608cd4583c8e7527e50b165a0d8d30da",
|
||||
"references": [
|
||||
{
|
||||
"side": "client",
|
||||
"jar_sha256": "4508d006323f24fa02876310c192d739af56516eb259000ac50f0909a68c9a2d",
|
||||
"output_sha256": "9216be3bbae9e59bb89b6ed780ccedc5608cd4583c8e7527e50b165a0d8d30da",
|
||||
"matched_output_lines": 7100
|
||||
}
|
||||
],
|
||||
"scope": "Actual client FaceBakery geometry overload with a real unit-atlas sprite and default JOML math options. Identity blockstate transform, all bundled cuboid geometry and synthetic rotations, rescaling, default/explicit UVs, winding, degenerate and reversed bounds, inherited models and missing materials. Positions, UVs and element matrix coefficients compare by float bits. This does not cover blockstate transforms/UV lock, atlas packing, transparency classification, lighting, GPU rendering or gameplay."
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use horizon_assets::{
|
||||
bake::{ElementMatrix, bake_model},
|
||||
model::*,
|
||||
resolve::ModelLibrary,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
fn bits(value: f32) -> u32 {
|
||||
if value.is_nan() {
|
||||
0x7fc0_0000
|
||||
} else {
|
||||
value.to_bits()
|
||||
}
|
||||
}
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let input = BufReader::new(File::open(&args[1])?);
|
||||
let mut output = BufWriter::new(File::create(&args[2])?);
|
||||
for (index, line) in input.lines().enumerate() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
let models = row["models"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(name, raw)| Ok((name.clone(), CuboidModel::from_json(raw.as_str().unwrap())?)))
|
||||
.collect::<Result<_, horizon_assets::DecodeError>>()?;
|
||||
let library = ModelLibrary::new(models)?;
|
||||
for root in row["roots"].as_array().unwrap() {
|
||||
let name = root.as_str().unwrap();
|
||||
let baked = library.resolve(name).and_then(|model| {
|
||||
let quads = bake_model(&model)?.into_iter().map(|q| json!({
|
||||
"positions":q.positions.map(|v| v.map(bits)),"uv":q.uv.map(|v| v.map(bits)),
|
||||
"facing":q.facing.name(),"cull":q.cull.map(Direction::name),"tint":q.tint_index,
|
||||
"shade":q.shade_direction_override.map(Direction::name),"light":q.light_emission,
|
||||
"material":{"sprite":q.material.sprite,"translucent":q.material.force_translucent}
|
||||
})).collect::<Vec<_>>();
|
||||
let matrices: Vec<_> = model.elements().iter().map(|e| e.as_ref().unwrap().rotation.as_ref().map(|r| ElementMatrix::from_rotation(r).rows.map(|row| row.map(bits)))).collect();
|
||||
Ok(json!({"quads":quads,"matrices":matrices}))
|
||||
});
|
||||
let value = match baked {
|
||||
Ok(baked) => json!({"case":index,"name":name,"baked":baked}),
|
||||
Err(_) => json!({"case":index,"name":name,"error":true}),
|
||||
};
|
||||
writeln!(output, "{value}")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Cuboid geometry in block units, before blockstate rotation and UV locking.
|
||||
use crate::{json::*, model::*, resolve::ResolvedCuboidModel};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ModelQuad {
|
||||
pub positions: [[f32; 3]; 4],
|
||||
/// Normalized unit-atlas coordinates, before placement in a larger atlas.
|
||||
pub uv: [[f32; 2]; 4],
|
||||
pub facing: Direction,
|
||||
pub cull: Option<Direction>,
|
||||
pub material: Material,
|
||||
pub tint_index: i32,
|
||||
pub shade_direction_override: Option<Direction>,
|
||||
pub light_emission: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ElementMatrix {
|
||||
pub rows: [[f32; 3]; 3],
|
||||
identity: bool,
|
||||
}
|
||||
impl ElementMatrix {
|
||||
pub fn from_rotation(rotation: &ElementRotation) -> Self {
|
||||
const DEG: f32 = std::f32::consts::PI / 180.0;
|
||||
let mut matrix = match rotation.value {
|
||||
RotationValue::SingleAxis { angle: 0.0, .. } => Self {
|
||||
rows: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
||||
identity: true,
|
||||
},
|
||||
RotationValue::SingleAxis { axis, angle } => {
|
||||
let (s, c) = sin_cos(angle * DEG);
|
||||
let rows = match axis {
|
||||
Axis::X => [[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]],
|
||||
Axis::Y => [[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]],
|
||||
Axis::Z => [[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]],
|
||||
};
|
||||
Self {
|
||||
rows,
|
||||
identity: false,
|
||||
}
|
||||
}
|
||||
RotationValue::EulerXyz([x, y, z]) => {
|
||||
let (sx, cx) = sin_cos(x * DEG);
|
||||
let (sy, cy) = sin_cos(y * DEG);
|
||||
let (sz, cz) = sin_cos(z * DEG);
|
||||
let czsy = cz * sy;
|
||||
let szsy = sz * sy;
|
||||
Self {
|
||||
rows: [
|
||||
[cz * cy, -sz * cx + czsy * sx, -sz * -sx + czsy * cx],
|
||||
[sz * cy, cz * cx + szsy * sx, cz * -sx + szsy * cx],
|
||||
[-sy, cy * sx, cy * cx],
|
||||
],
|
||||
identity: false,
|
||||
}
|
||||
}
|
||||
};
|
||||
if rotation.rescale && !matrix.identity {
|
||||
let scales: [f32; 3] = std::array::from_fn(|axis| {
|
||||
let basis = std::array::from_fn(|i| if i == axis { 1.0 } else { 0.0 });
|
||||
let column = matrix.direction(basis);
|
||||
let maximum = java_max(java_max(column[0].abs(), column[1].abs()), column[2].abs());
|
||||
1.0 / maximum
|
||||
});
|
||||
for (axis, scale) in scales.into_iter().enumerate() {
|
||||
for row in &mut matrix.rows {
|
||||
row[axis] *= scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
matrix
|
||||
}
|
||||
fn direction(self, value: [f32; 3]) -> [f32; 3] {
|
||||
self.rows
|
||||
.map(|r| r[0] * value[0] + (r[1] * value[1] + r[2] * value[2]))
|
||||
}
|
||||
fn position(self, value: [f32; 3]) -> [f32; 3] {
|
||||
if self.identity {
|
||||
value
|
||||
} else {
|
||||
self.rows
|
||||
.map(|r| r[0] * value[0] + (r[1] * value[1] + (r[2] * value[2] + 0.0)))
|
||||
}
|
||||
}
|
||||
}
|
||||
fn java_max(a: f32, b: f32) -> f32 {
|
||||
if a.is_nan() || b.is_nan() {
|
||||
f32::NAN
|
||||
} else {
|
||||
a.max(b)
|
||||
}
|
||||
}
|
||||
/// JOML's default float path uses a double sine rounded to float, and obtains
|
||||
/// cosine's magnitude from that rounded sine. Keep all subsequent steps f32.
|
||||
fn sin_cos(angle: f32) -> (f32, f32) {
|
||||
let sin = f64::from(angle).sin() as f32;
|
||||
let cos = (1.0 - sin * sin).sqrt();
|
||||
let shifted = angle + std::f32::consts::FRAC_PI_2;
|
||||
let mut period =
|
||||
shifted - (shifted / std::f32::consts::TAU) as i32 as f32 * std::f32::consts::TAU;
|
||||
if period < 0.0 {
|
||||
period += std::f32::consts::TAU;
|
||||
}
|
||||
(
|
||||
sin,
|
||||
if period >= std::f32::consts::PI {
|
||||
-cos
|
||||
} else {
|
||||
cos
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn default_uv(from: [f32; 3], to: [f32; 3], direction: Direction) -> [f32; 4] {
|
||||
match direction {
|
||||
Direction::Down => [from[0], 16.0 - to[2], to[0], 16.0 - from[2]],
|
||||
Direction::Up => [from[0], from[2], to[0], to[2]],
|
||||
Direction::North => [16.0 - to[0], 16.0 - to[1], 16.0 - from[0], 16.0 - from[1]],
|
||||
Direction::South => [from[0], 16.0 - to[1], to[0], 16.0 - from[1]],
|
||||
Direction::West => [from[2], 16.0 - to[1], to[2], 16.0 - from[1]],
|
||||
Direction::East => [16.0 - to[2], 16.0 - to[1], 16.0 - from[2], 16.0 - from[1]],
|
||||
}
|
||||
}
|
||||
fn corners(from: [f32; 3], to: [f32; 3], direction: Direction) -> [[f32; 3]; 4] {
|
||||
let selectors = match direction {
|
||||
Direction::Down => [[0, 0, 1], [0, 0, 0], [1, 0, 0], [1, 0, 1]],
|
||||
Direction::Up => [[0, 1, 0], [0, 1, 1], [1, 1, 1], [1, 1, 0]],
|
||||
Direction::North => [[1, 1, 0], [1, 0, 0], [0, 0, 0], [0, 1, 0]],
|
||||
Direction::South => [[0, 1, 1], [0, 0, 1], [1, 0, 1], [1, 1, 1]],
|
||||
Direction::West => [[0, 1, 0], [0, 0, 0], [0, 0, 1], [0, 1, 1]],
|
||||
Direction::East => [[1, 1, 1], [1, 0, 1], [1, 0, 0], [1, 1, 0]],
|
||||
};
|
||||
selectors
|
||||
.map(|selector| std::array::from_fn(|i| if selector[i] == 0 { from[i] } else { to[i] }))
|
||||
}
|
||||
fn facing(vertices: &[[f32; 3]; 4]) -> Option<Direction> {
|
||||
let a: [f32; 3] = std::array::from_fn(|i| vertices[1][i] - vertices[0][i]);
|
||||
let b: [f32; 3] = std::array::from_fn(|i| vertices[2][i] - vertices[0][i]);
|
||||
let n = [
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
];
|
||||
let inverse_length = 1.0 / (n[0] * n[0] + (n[1] * n[1] + n[2] * n[2])).sqrt();
|
||||
let n = n.map(|v| v * inverse_length);
|
||||
if n.iter().any(|v| !v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let mut best = 0.0;
|
||||
let mut result = None;
|
||||
for (direction, dot) in Direction::ALL
|
||||
.into_iter()
|
||||
.zip([-n[1], n[1], -n[2], n[2], -n[0], n[0]])
|
||||
{
|
||||
if dot >= 0.0 && dot > best {
|
||||
best = dot;
|
||||
result = Some(direction);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn bake_model(model: &ResolvedCuboidModel) -> Result<Vec<ModelQuad>> {
|
||||
let mut output = Vec::new();
|
||||
for element in model.elements() {
|
||||
let element = element
|
||||
.as_ref()
|
||||
.ok_or_else(|| error("null model element"))?;
|
||||
let matrix = element.rotation.as_ref().map(ElementMatrix::from_rotation);
|
||||
for (direction, face) in &element.faces {
|
||||
let face = face.as_ref().ok_or_else(|| error("null model face"))?;
|
||||
let material = model.material(&face.texture)?.cloned().unwrap_or(Material {
|
||||
sprite: "minecraft:missingno".to_owned(),
|
||||
force_translucent: false,
|
||||
});
|
||||
output.push(bake_face(element, face, *direction, matrix, material)?);
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
fn bake_face(
|
||||
element: &Element,
|
||||
face: &Face,
|
||||
direction: Direction,
|
||||
matrix: Option<ElementMatrix>,
|
||||
material: Material,
|
||||
) -> Result<ModelQuad> {
|
||||
let mut positions = corners(element.from, element.to, direction).map(|v| v.map(|c| c / 16.0));
|
||||
if let (Some(rotation), Some(matrix)) = (&element.rotation, matrix) {
|
||||
for vertex in &mut positions {
|
||||
let relative = std::array::from_fn(|i| vertex[i] - rotation.origin[i]);
|
||||
let rotated = matrix.position(relative);
|
||||
*vertex = std::array::from_fn(|i| rotated[i] + rotation.origin[i]);
|
||||
}
|
||||
}
|
||||
let bounds = face
|
||||
.uv
|
||||
.unwrap_or_else(|| default_uv(element.from, element.to, direction));
|
||||
let mut uv = std::array::from_fn(|i| {
|
||||
let corner = (i + usize::from(face.rotation)) % 4;
|
||||
[
|
||||
bounds[if corner < 2 { 0 } else { 2 }] / 16.0,
|
||||
bounds[if corner == 0 || corner == 3 { 1 } else { 3 }] / 16.0,
|
||||
]
|
||||
// Native TextureAtlasSprite applies origin + extent * coordinate;
|
||||
// even the unit atlas changes negative zero to positive zero.
|
||||
.map(|value| 0.0 + value)
|
||||
});
|
||||
let normal = facing(&positions);
|
||||
if let (None, Some(facing)) = (&element.rotation, normal) {
|
||||
let mut min = [999.0; 3];
|
||||
let mut max = [-999.0; 3];
|
||||
for vertex in &positions {
|
||||
for axis in 0..3 {
|
||||
if vertex[axis] < min[axis] {
|
||||
min[axis] = vertex[axis];
|
||||
}
|
||||
if vertex[axis] > max[axis] {
|
||||
max[axis] = vertex[axis];
|
||||
}
|
||||
}
|
||||
}
|
||||
for (index, expected) in corners(min, max, facing).into_iter().enumerate() {
|
||||
let found = (index..4)
|
||||
.find(|i| positions[*i] == expected)
|
||||
.ok_or_else(|| error("cannot reorder model face"))?;
|
||||
positions.swap(index, found);
|
||||
uv.swap(index, found);
|
||||
}
|
||||
}
|
||||
Ok(ModelQuad {
|
||||
positions,
|
||||
uv,
|
||||
facing: normal.unwrap_or(Direction::Up),
|
||||
cull: face.cull,
|
||||
material,
|
||||
tint_index: face.tint_index,
|
||||
shade_direction_override: element.shade_direction_override,
|
||||
light_emission: element.light_emission,
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Resource decoding for the pinned Java Edition client.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod bake;
|
||||
mod json;
|
||||
pub mod model;
|
||||
pub mod resolve;
|
||||
|
||||
+22
-2
@@ -69,8 +69,8 @@ python3 tools/test_cuboid_model_parity.py
|
||||
The [archived decoder result](../benchmarks/rust-cuboid-model-decoding-2026-10-06/scoped-parity.json)
|
||||
records source, fixture and reference hashes. The oracle uses actual client
|
||||
classes; those rendering APIs are absent from the server. This decoder is not
|
||||
yet connected to the development renderer. Rotation matrices, quad baking,
|
||||
blockstate selection and rendered appearance need their own checks.
|
||||
yet connected to the development renderer. Blockstate selection, transformed
|
||||
models, UV locking and rendered appearance still need their own checks.
|
||||
|
||||
`ModelLibrary` resolves parent geometry, ambient occlusion, GUI lighting and
|
||||
each display context. It shares inherited elements rather than cloning them.
|
||||
@@ -88,6 +88,26 @@ python3 tools/test_model_resolution_parity.py
|
||||
cargo +stable test --locked -p horizon-assets
|
||||
```
|
||||
|
||||
Cuboid baking now produces positions in block units, normalized unit-atlas UVs,
|
||||
face winding and facing, material references, tint indices, shade overrides and
|
||||
light-emission metadata. It supports single-axis and Euler element rotations
|
||||
with the pinned rescaling rule. The
|
||||
[baking comparison](../benchmarks/rust-model-baking-2026-10-06/scoped-parity.json)
|
||||
matches **7,100 roots across 440 graphs**, using native `FaceBakery` with a real
|
||||
sprite and the default JOML math options. All bundled block models bake without
|
||||
errors. Synthetic coverage includes reversed/degenerate bounds, signed zero,
|
||||
arbitrary rotations, rescaling, null geometry children and parent cycles.
|
||||
Position, UV and matrix fields compare by exact float bits for these fixtures.
|
||||
|
||||
```sh
|
||||
python3 tools/test_model_baking_parity.py
|
||||
```
|
||||
|
||||
This comparison uses the identity blockstate transform. Atlas packing,
|
||||
transparency classification, actual tint/light evaluation and GPU rendering
|
||||
remain separate from the geometry result. Other operating systems still need
|
||||
their native validation runs.
|
||||
|
||||
```sh
|
||||
cargo +stable test -p horizon-client
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
+3
-2
@@ -16,8 +16,9 @@ record the exact tested scope and remaining gaps.
|
||||
The `horizon-assets` crate now decodes pinned cuboid models, with all 2,883 bundled
|
||||
block-model JSON files and 2,337 synthetic cases compared against the actual
|
||||
client parser. [Decoder scope and validation](native-client.md#validation)
|
||||
also include parent/texture resolution over 5,648 roots. Quad baking,
|
||||
blockstate selection and integration with the renderer remain unfinished.
|
||||
also include parent/texture resolution over 5,648 roots and cuboid baking over
|
||||
7,100 roots with identity blockstate transforms. Blockstate selection, transformed
|
||||
models, UV locking and integration with the renderer remain unfinished.
|
||||
The playable Java performance packs remain available during development.
|
||||
|
||||
## Completion criteria and playable builds
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.mojang.blaze3d.platform.NativeImage;
|
||||
import java.io.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import net.minecraft.client.resources.model.*;
|
||||
import net.minecraft.client.resources.model.cuboid.*;
|
||||
import net.minecraft.client.resources.model.geometry.*;
|
||||
import net.minecraft.client.resources.model.sprite.Material;
|
||||
import net.minecraft.client.renderer.block.dispatch.ModelState;
|
||||
import net.minecraft.client.renderer.texture.*;
|
||||
import net.minecraft.client.resources.metadata.animation.FrameSize;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import org.joml.*;
|
||||
import static dev.horizonlab.parity.VanillaCuboidModelOracle.map;
|
||||
|
||||
/** Geometry-only native baker probe with a real 16x16 sprite in a unit atlas. */
|
||||
public final class VanillaModelBakingOracle {
|
||||
private static final Gson JSON = new GsonBuilder().serializeNulls().create();
|
||||
private static final class Sprite extends TextureAtlasSprite {
|
||||
Sprite(SpriteContents contents) { super(Identifier.parse("horizon:atlas"),contents,16,16,0,0,0); }
|
||||
}
|
||||
private static final ModelBaker.Interner INTERNER = new ModelBaker.Interner() {
|
||||
public Vector3fc vector(Vector3fc vector) { return vector; }
|
||||
public BakedQuad.MaterialInfo materialInfo(BakedQuad.MaterialInfo value) { return value; }
|
||||
};
|
||||
private static long bits(float value) { return Integer.toUnsignedLong(Float.floatToIntBits(value)); }
|
||||
private static Object matrix(CuboidRotation rotation) {
|
||||
if(rotation==null)return null;
|
||||
var m=rotation.transform();
|
||||
return new long[][]{{bits(m.m00()),bits(m.m10()),bits(m.m20())},
|
||||
{bits(m.m01()),bits(m.m11()),bits(m.m21())},{bits(m.m02()),bits(m.m12()),bits(m.m22())}};
|
||||
}
|
||||
private static Object bake(ResolvedModel model, Sprite sprite) throws Exception {
|
||||
var geometry=model.getTopGeometry();
|
||||
var quads=new ArrayList<Object>(); var matrices=new ArrayList<Object>();
|
||||
if(geometry==UnbakedGeometry.EMPTY)return map("quads",quads,"matrices",matrices);
|
||||
var textures=model.getTopTextureSlots();
|
||||
var defaultUv=FaceBakery.class.getDeclaredMethod("defaultFaceUV",Vector3fc.class,Vector3fc.class,Direction.class);defaultUv.setAccessible(true);
|
||||
for(var element:((UnbakedCuboidGeometry)geometry).elements()) {
|
||||
matrices.add(matrix(element.rotation()));
|
||||
for(var entry:element.faces().entrySet()) {
|
||||
var face=entry.getValue();
|
||||
var material=textures.getMaterial(face.texture());
|
||||
if(material==null)material=new Material(Identifier.parse("minecraft:missingno"));
|
||||
var uv=face.uvs();
|
||||
if(uv==null)uv=(CuboidFace.UVs)defaultUv.invoke(null,element.from(),element.to(),entry.getKey());
|
||||
var info=new BakedQuad.MaterialInfo(sprite,null,null,null,null,face.tintIndex(),element.shadeDirectionOverride(),element.lightEmission());
|
||||
var quad=FaceBakery.bakeQuad(INTERNER,element.from(),element.to(),uv,face.rotation(),info,entry.getKey(),new ModelState(){},element.rotation());
|
||||
var positions=new long[4][3];var uvs=new long[4][2];
|
||||
for(int i=0;i<4;i++) {
|
||||
var p=quad.position(i);positions[i]=new long[]{bits(p.x()),bits(p.y()),bits(p.z())};
|
||||
long packed=quad.packedUV(i);uvs[i]=new long[]{packed >>> 32,packed & 0xffffffffL};
|
||||
}
|
||||
quads.add(map("positions",positions,"uv",uvs,"facing",quad.direction().getName(),
|
||||
"cull",face.cullForDirection()==null?null:face.cullForDirection().getName(),"tint",face.tintIndex(),
|
||||
"shade",element.shadeDirectionOverride()==null?null:element.shadeDirectionOverride().getName(),
|
||||
"light",element.lightEmission(),"material",map("sprite",material.sprite().toString(),"translucent",material.forceTranslucent())));
|
||||
}
|
||||
}
|
||||
return map("quads",quads,"matrices",matrices);
|
||||
}
|
||||
public static void main(String[] args) throws Exception {
|
||||
net.minecraft.SharedConstants.tryDetectVersion();
|
||||
if(org.joml.Options.FASTMATH || org.joml.Options.USE_MATH_FMA)throw new AssertionError("Expected default JOML math options");
|
||||
var image=new NativeImage(16,16,true);
|
||||
var contents=new SpriteContents(Identifier.parse("horizon:probe"),new FrameSize(16,16),image);
|
||||
var sprite=new Sprite(contents);
|
||||
try (contents; var input=Files.newBufferedReader(Path.of(args[0])); var output=Files.newBufferedWriter(Path.of(args[1]))) {
|
||||
int index=0;
|
||||
for(String line;(line=input.readLine())!=null;index++) {
|
||||
var row=JsonParser.parseString(line).getAsJsonObject();var models=new HashMap<Identifier,UnbakedModel>();
|
||||
for(var entry:row.getAsJsonObject("models").entrySet())models.put(Identifier.parse(entry.getKey()),CuboidModel.fromStream(new StringReader(entry.getValue().getAsString())));
|
||||
var discovery=new ModelDiscovery(models,MissingCuboidModel.missingModel());
|
||||
discovery.addRoot(resolver->{for(var root:row.getAsJsonArray("roots"))resolver.markDependency(Identifier.parse(root.getAsString()));});
|
||||
var resolved=discovery.resolve();
|
||||
for(var root:row.getAsJsonArray("roots")) {
|
||||
String name=root.getAsString();Object value;
|
||||
try {value=bake(Objects.requireNonNull(resolved.get(Identifier.parse(name))),sprite);}
|
||||
catch(RuntimeException error) {output.write(JSON.toJson(map("case",index,"name",name,"error",true)));output.newLine();continue;}
|
||||
output.write(JSON.toJson(map("case",index,"name",name,"baked",value)));output.newLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare cuboid quad positions, UVs and element matrices with the pinned client."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import random
|
||||
import subprocess
|
||||
import zipfile
|
||||
try:
|
||||
from . import lab, test_rust_parity as base, test_cuboid_model_parity as decoding, test_model_resolution_parity as resolution
|
||||
except ImportError:
|
||||
import lab, test_rust_parity as base, test_cuboid_model_parity as decoding, test_model_resolution_parity as resolution
|
||||
|
||||
|
||||
def corpus():
|
||||
yield from resolution.corpus()
|
||||
directions = ['down', 'up', 'north', 'south', 'west', 'east']
|
||||
models = {}
|
||||
def add(rotation, start=None, end=None, explicit=False):
|
||||
index = len(models)
|
||||
faces = {d: {'texture': '#all', 'cullface': d, 'rotation': 90 * (index % 4),
|
||||
'tintindex': index % 3 - 1} for d in directions}
|
||||
if explicit:
|
||||
for face in faces.values(): face['uv'] = [-0.0, 3.25, 15.5, -1.0]
|
||||
element = {'from': start or [0, 0, 0], 'to': end or [16, 16, 16], 'faces': faces,
|
||||
'light_emission': index % 16}
|
||||
if rotation is not None: element['rotation'] = rotation
|
||||
if index % 3 == 0: element['shade_direction_override'] = directions[index % 6]
|
||||
models[f'horizon:geometry_{index}'] = {'textures': {'all': 'block/stone'}, 'elements': [element]}
|
||||
for bounds in [([0,0,0],[16,16,16]), ([16,16,16],[0,0,0]), ([0,8,0],[16,8,16]),
|
||||
([0,0,0],[0,0,0]), ([-16,-16,-16],[32,32,32]), ([-0.0,0,0],[16,16,16])]:
|
||||
for explicit in [False, True]: add(None, *bounds, explicit)
|
||||
for angle in [-720,-360,-180,-90,-63,-45,-22.5,-0.0,0,22.5,45,63,90,180,270,360,540,720]:
|
||||
for axis in ['x','y','z']:
|
||||
for rescale in [False,True]:
|
||||
add({'origin':[8,8,8], 'axis':axis, 'angle':angle, 'rescale':rescale}, explicit=True)
|
||||
for x in [0,22.5,90,180]:
|
||||
for y in [0,45,90,270]:
|
||||
for z in [-90,0,45,180]:
|
||||
for rescale in [False,True]:
|
||||
add({'origin':[8,3,-4], 'x':x, 'y':y, 'z':z, 'rescale':rescale})
|
||||
rng=random.Random(0x42414b494e47)
|
||||
for _ in range(1200):
|
||||
rotation={'origin':[rng.uniform(-64,64) for _ in range(3)], 'rescale':bool(rng.randrange(2))}
|
||||
if rng.randrange(2): rotation.update(axis=rng.choice(['x','y','z']), angle=rng.uniform(-3600,3600))
|
||||
else: rotation.update(zip(['x','y','z'],[rng.uniform(-3600,3600) for _ in range(3)]))
|
||||
add(rotation, [rng.uniform(-16,32) for _ in range(3)], [rng.uniform(-16,32) for _ in range(3)], bool(rng.randrange(2)))
|
||||
row=resolution.bundle(models)
|
||||
for token in ['1e100','-1e100','1e-100','-1e-100']:
|
||||
name='horizon:extreme_'+str(len(row['models']))
|
||||
raw=json.dumps({'elements':[{'from':[0,0,0],'to':[16,16,16],'faces':{'up':{'texture':'#all'}},'rotation':{'origin':[8,8,8],'axis':'x','angle':'TOKEN','rescale':True}}]})
|
||||
row['models'][name]=raw.replace('"TOKEN"',token); row['roots'].append(name)
|
||||
yield row
|
||||
|
||||
|
||||
def client_libraries():
|
||||
_, directory=lab.config()
|
||||
manifest=json.loads((directory/'manifest.json').read_text())
|
||||
libraries=[]
|
||||
for library in manifest['libraries']:
|
||||
artifact=library.get('downloads',{}).get('artifact')
|
||||
if artifact:
|
||||
relative=Path(artifact['path'])
|
||||
if relative.is_absolute() or '..' in relative.parts: raise ValueError('Unsafe client library path')
|
||||
path=directory/'libraries/client'/relative
|
||||
if lab.digest(path,'sha1')!=artifact['sha1']: raise ValueError('Client dependency checksum changed: '+str(relative))
|
||||
libraries.append(path)
|
||||
return libraries
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__); parser.add_argument('--prepare', action='store_true'); args = parser.parse_args()
|
||||
if args.prepare: lab.prepare()
|
||||
lock, jars, libraries = base.verify_artifacts()
|
||||
build = lab.LOCAL / 'rust-model-baking-parity'; build.mkdir(parents=True, exist_ok=True)
|
||||
report_path = build / 'report.json'; report_path.unlink(missing_ok=True)
|
||||
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaModelBakingOracle.java'
|
||||
helper = oracle.with_name('VanillaCuboidModelOracle.java')
|
||||
crate = lab.ROOT / 'crates/horizon-assets'
|
||||
sources = [oracle, helper, Path(__file__), lab.ROOT / 'tools/test_cuboid_model_parity.py', lab.ROOT / 'tools/test_model_resolution_parity.py',
|
||||
lab.ROOT / 'tools/lab.py', lab.ROOT / 'tools/test_rust_parity.py', lab.ROOT / 'Cargo.toml',
|
||||
lab.ROOT / 'Cargo.lock', crate / 'Cargo.toml', *sorted(crate.rglob('*.rs'))]
|
||||
tested_sources = base.source_hashes(sources)
|
||||
with zipfile.ZipFile(jars['client']) as archive:
|
||||
names = sorted(name for name in archive.namelist() if name.startswith('assets/minecraft/models/block/') and name.endswith('.json'))
|
||||
models = {'minecraft:' + name.removeprefix('assets/minecraft/models/').removesuffix('.json'): archive.read(name).decode() for name in names}
|
||||
rows = [{'models': models, 'roots': list(models) + ['minecraft:builtin/missing', 'horizon:missing'],
|
||||
'queries': ['particle', '#particle', 'all', '#all', 'missingno', 'unknown', '', '#']}, *corpus()]
|
||||
inputs = build / 'inputs.jsonl'; inputs.write_text(''.join(json.dumps(row) + '\n' for row in rows)); fixtures = base.source_hashes([inputs])
|
||||
classes = build / 'classes'; classes.mkdir(exist_ok=True)
|
||||
def run(command, name):
|
||||
with (build / (name + '.log')).open('w') as log:
|
||||
subprocess.run(list(map(str, command)), cwd=lab.ROOT, check=True, timeout=3600, stdout=log, stderr=subprocess.STDOUT)
|
||||
dependencies = os.pathsep.join(map(str, [jars['client'], *client_libraries()]))
|
||||
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-cp', dependencies, '-d', classes, oracle, helper], 'compile-java')
|
||||
run(['cargo', 'build', '--locked', '-p', 'horizon-assets', '--example', 'model_baking_fixture'], 'compile-rust')
|
||||
metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--locked', '--no-deps', '--format-version', '1'], cwd=lab.ROOT))
|
||||
binary = Path(metadata['target_directory']) / 'debug/examples' / ('model_baking_fixture.exe' if os.name == 'nt' else 'model_baking_fixture')
|
||||
run([binary, inputs, build / 'rust.jsonl'], 'rust')
|
||||
run(['java', '-Xmx512M', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaModelBakingOracle', inputs, build / 'client.jsonl'], 'client')
|
||||
rejected = decoding.canonicalize(build / 'client.jsonl'); decoding.canonicalize(build / 'rust.jsonl')
|
||||
matched = base.compare(build / 'client.jsonl', build / 'rust.jsonl')
|
||||
base.ensure_sources_unchanged(sources, tested_sources); base.ensure_sources_unchanged([inputs], fixtures)
|
||||
report = {'schema': 1, 'minecraft': lock['minecraft']['version'], 'manifest_sha1': lock['minecraft']['manifest_sha1'],
|
||||
'platform': platform.platform(), 'java': subprocess.check_output(['java', '--version'], text=True).splitlines()[0],
|
||||
'rustc': subprocess.check_output(['rustc', '--version'], text=True).strip(),
|
||||
'resource_models': len(names), 'graph_fixtures': len(rows), 'rejected_roots': rejected,
|
||||
'sources_sha256': tested_sources, 'fixtures_sha256': fixtures, 'rust_output_sha256': lab.digest(build / 'rust.jsonl', 'sha256'),
|
||||
'references': [{'side': 'client', 'jar_sha256': lab.digest(jars['client'], 'sha256'),
|
||||
'output_sha256': lab.digest(build / 'client.jsonl', 'sha256'), 'matched_output_lines': matched}],
|
||||
'scope': 'Actual client FaceBakery geometry overload with a real unit-atlas sprite and default JOML math options. Identity blockstate transform, all bundled cuboid geometry and synthetic rotations, rescaling, default/explicit UVs, winding, degenerate and reversed bounds, inherited models and missing materials. Positions, UVs and element matrix coefficients compare by float bits. This does not cover blockstate transforms/UV lock, atlas packing, transparency classification, lighting, GPU rendering or gameplay.'}
|
||||
lab.write_json(report_path, report); print(f'Client cuboid baking: {matched:,} roots match ({rejected:,} rejected); {report_path}', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__': main()
|
||||
Reference in New Issue
Block a user