mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
perf(client): share parsed block and item models
This commit is contained in:
1 parent
89b58991bb
commit
7b8189d19c
12 files changed
+563
-75
No files matched your search
@@ -50,29 +50,47 @@ impl ModelLibrary {
|
||||
/// A library that also knows `builtin/generated`, the parent of every
|
||||
/// flat item model; the native client registers it as a special model.
|
||||
pub fn with_item_generator(mut models: BTreeMap<String, CuboidModel>) -> Result<Self> {
|
||||
models.insert(
|
||||
GENERATED_MODEL.to_owned(),
|
||||
CuboidModel {
|
||||
parent: None,
|
||||
ambient_occlusion: None,
|
||||
gui_light: Some(GuiLight::Front),
|
||||
textures: BTreeMap::from([(
|
||||
"particle".to_owned(),
|
||||
TextureSlot::Reference("layer0".to_owned()),
|
||||
)]),
|
||||
elements: None,
|
||||
display: None,
|
||||
},
|
||||
);
|
||||
models.insert(GENERATED_MODEL.to_owned(), Self::item_generator_model());
|
||||
Self::new(models)
|
||||
}
|
||||
/// Register the item built-in while reusing already decoded resources.
|
||||
pub fn with_shared_item_generator(
|
||||
mut models: BTreeMap<String, Arc<CuboidModel>>,
|
||||
) -> Result<Self> {
|
||||
models.insert(
|
||||
GENERATED_MODEL.to_owned(),
|
||||
Arc::new(Self::item_generator_model()),
|
||||
);
|
||||
Self::from_shared(models)
|
||||
}
|
||||
fn item_generator_model() -> CuboidModel {
|
||||
CuboidModel {
|
||||
parent: None,
|
||||
ambient_occlusion: None,
|
||||
gui_light: Some(GuiLight::Front),
|
||||
textures: BTreeMap::from([(
|
||||
"particle".to_owned(),
|
||||
TextureSlot::Reference("layer0".to_owned()),
|
||||
)]),
|
||||
elements: None,
|
||||
display: None,
|
||||
}
|
||||
}
|
||||
pub fn new(models: BTreeMap<String, CuboidModel>) -> Result<Self> {
|
||||
Self::from_models(
|
||||
models
|
||||
.into_iter()
|
||||
.map(|(name, model)| (name, Arc::new(model))),
|
||||
)
|
||||
}
|
||||
/// Build an immutable resolution view without copying model geometry.
|
||||
pub fn from_shared(models: BTreeMap<String, Arc<CuboidModel>>) -> Result<Self> {
|
||||
Self::from_models(models)
|
||||
}
|
||||
fn from_models(models: impl IntoIterator<Item = (String, Arc<CuboidModel>)>) -> Result<Self> {
|
||||
let mut decoded = BTreeMap::new();
|
||||
for (name, model) in models {
|
||||
if decoded
|
||||
.insert(identifier(&name)?, Arc::new(model))
|
||||
.is_some()
|
||||
{
|
||||
if decoded.insert(identifier(&name)?, model).is_some() {
|
||||
return Err(error("duplicate normalized model identifier"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Full block/assets + item startup. Compile with HORIZON_BENCH_COUNT_ALLOCATIONS=1
|
||||
//! for live requested heap bytes; leave it unset for latency measurements.
|
||||
//! cargo run --profile fast -p horizon-client --example model_library_bench -- ROOT [--parity]
|
||||
//! Run fresh processes, warm up twice, then compare multiple alternating baseline/candidate runs.
|
||||
use horizon_client::{assets::Assets, item_render::ItemRenderer};
|
||||
use std::{
|
||||
alloc::{GlobalAlloc, Layout, System},
|
||||
hash::{Hash, Hasher},
|
||||
path::Path,
|
||||
sync::atomic::{AtomicUsize, Ordering::Relaxed},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
struct CountingAllocator;
|
||||
const TRACK: bool = option_env!("HORIZON_BENCH_COUNT_ALLOCATIONS").is_some();
|
||||
static LIVE: AtomicUsize = AtomicUsize::new(0);
|
||||
// The allocator delegates every operation and layout unchanged to System.
|
||||
unsafe impl GlobalAlloc for CountingAllocator {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
let ptr = unsafe { System.alloc(layout) };
|
||||
if TRACK && !ptr.is_null() {
|
||||
LIVE.fetch_add(layout.size(), Relaxed);
|
||||
}
|
||||
ptr
|
||||
}
|
||||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||||
let ptr = unsafe { System.alloc_zeroed(layout) };
|
||||
if TRACK && !ptr.is_null() {
|
||||
LIVE.fetch_add(layout.size(), Relaxed);
|
||||
}
|
||||
ptr
|
||||
}
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
if TRACK {
|
||||
LIVE.fetch_sub(layout.size(), Relaxed);
|
||||
}
|
||||
unsafe { System.dealloc(ptr, layout) };
|
||||
}
|
||||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 {
|
||||
let out = unsafe { System.realloc(ptr, layout, size) };
|
||||
if TRACK && !out.is_null() {
|
||||
LIVE.fetch_sub(layout.size(), Relaxed);
|
||||
LIVE.fetch_add(size, Relaxed);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
#[global_allocator]
|
||||
static ALLOCATOR: CountingAllocator = CountingAllocator;
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let root = std::env::args()
|
||||
.nth(1)
|
||||
.ok_or("usage: model_library_bench ROOT [--parity]")?;
|
||||
let root = Path::new(&root);
|
||||
let initial = LIVE.load(Relaxed);
|
||||
let start = Instant::now();
|
||||
let assets = Assets::load(root)?;
|
||||
let block_ms = start.elapsed().as_secs_f64() * 1000.;
|
||||
let block_retained = LIVE.load(Relaxed) - initial;
|
||||
let start_items = Instant::now();
|
||||
let items = ItemRenderer::load_with_library(root, assets.model_library.clone())?;
|
||||
let item_ms = start_items.elapsed().as_secs_f64() * 1000.;
|
||||
let total_ms = start.elapsed().as_secs_f64() * 1000.;
|
||||
let retained = LIVE.load(Relaxed) - initial;
|
||||
println!(
|
||||
"block_ms={block_ms:.3} item_ms={item_ms:.3} total_ms={total_ms:.3} retained_bytes={retained} block_retained_bytes={block_retained}"
|
||||
);
|
||||
if std::env::args().any(|a| a == "--parity") {
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
for id in 0..assets.registry.len() {
|
||||
let id = horizon_world::registry::BlockStateId(id as u32);
|
||||
for pos in [
|
||||
horizon_core::position::BlockPos::new(0, 0, 0),
|
||||
horizon_core::position::BlockPos::new(-17, 65, 31),
|
||||
] {
|
||||
if let Ok(selected) = assets.model_catalog.collect(id, pos) {
|
||||
for (variant, model) in selected {
|
||||
format!("{variant:?}").hash(&mut hash);
|
||||
model.ambient_occlusion.hash(&mut hash);
|
||||
for face in &model.faces {
|
||||
format!("{:?}", face.quad).hash(&mut hash);
|
||||
face.layer.hash(&mut hash);
|
||||
face.opaque.hash(&mut hash);
|
||||
face.translucent.hash(&mut hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut names = Vec::new();
|
||||
for ns in std::fs::read_dir(root.join("assets"))? {
|
||||
let ns = ns?;
|
||||
let base = ns.path().join("models");
|
||||
let mut stack = vec![base.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries {
|
||||
let path = entry?.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if path.extension().is_some_and(|e| e == "json") {
|
||||
names.push(format!(
|
||||
"{}:{}",
|
||||
ns.file_name().to_string_lossy(),
|
||||
path.strip_prefix(&base)?
|
||||
.with_extension("")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
for name in &names {
|
||||
format!("{:?}", items.bake(name)).hash(&mut hash);
|
||||
}
|
||||
println!(
|
||||
"parity_models={} fingerprint={:016x}",
|
||||
names.len(),
|
||||
hash.finish()
|
||||
);
|
||||
}
|
||||
std::hint::black_box((&assets, &items));
|
||||
Ok(())
|
||||
}
|
||||
@@ -59,6 +59,8 @@ pub struct Assets {
|
||||
pub pixels: Vec<u8>,
|
||||
pub texture_layers: u32,
|
||||
pub model_catalog: crate::models::StateModels,
|
||||
/// Shared parsed resources; item-only validation errors surface at item preparation.
|
||||
pub model_library: std::result::Result<Arc<horizon_assets::resolve::ModelLibrary>, String>,
|
||||
pub model_placements: Vec<(horizon_core::position::BlockPos, BlockStateId)>,
|
||||
pub model_gallery: Vec<Value>,
|
||||
pub animations: Vec<crate::sprites::SpriteAnimation>,
|
||||
@@ -219,7 +221,9 @@ impl Assets {
|
||||
for name in MATERIALS {
|
||||
sprites.insert(root, &format!("minecraft:block/{name}"))?;
|
||||
}
|
||||
let models = crate::models::prepare(root, registry, &mut sprites)?;
|
||||
let parsed = crate::model_library::ParsedModels::load(root);
|
||||
let models = crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?;
|
||||
let model_library = parsed.into_item_library();
|
||||
// Fluids are drawn by the fluid renderer, not block models; breaking
|
||||
// progress uses the destroy-stage overlays.
|
||||
for name in [
|
||||
@@ -244,6 +248,7 @@ impl Assets {
|
||||
texture_layers: u32::try_from(sprites.layers.len())?,
|
||||
pixels: sprites.pixels,
|
||||
model_catalog: models.catalog,
|
||||
model_library,
|
||||
model_placements: models.placements,
|
||||
model_gallery: models.states,
|
||||
animations: sprites.animations,
|
||||
|
||||
@@ -9143,6 +9143,12 @@ pub fn run() -> Result<()> {
|
||||
let animate =
|
||||
horizon_client::animate_tick::AnimateTables::load(&root, registry_owner.registry, &biomes)?;
|
||||
let fog = horizon_client::fog::FogRenderer::load(&root, &biomes);
|
||||
let item_renderer = Arc::new(
|
||||
horizon_client::item_render::ItemRenderer::load_with_library(
|
||||
&root,
|
||||
assets.model_library.clone(),
|
||||
)?,
|
||||
);
|
||||
let mut resources = Resources {
|
||||
stations,
|
||||
assets,
|
||||
@@ -9154,7 +9160,7 @@ pub fn run() -> Result<()> {
|
||||
environment,
|
||||
atlas,
|
||||
panorama,
|
||||
item_renderer: Arc::new(horizon_client::item_render::ItemRenderer::load(&root)?),
|
||||
item_renderer,
|
||||
layer_names,
|
||||
providers: Providers::load(&root),
|
||||
block_entity_render: horizon_client::block_entity_render::BlockEntityRenderer::load(
|
||||
|
||||
@@ -115,7 +115,10 @@ impl Game<'_> {
|
||||
let panorama = horizon_client::gfx::panorama::Faces::load(root);
|
||||
atlas.font.uniform = self.settings.force_unicode;
|
||||
atlas.font.japanese = self.settings.japanese_glyphs;
|
||||
let item_renderer = horizon_client::item_render::ItemRenderer::load(root)?;
|
||||
let item_renderer = horizon_client::item_render::ItemRenderer::load_with_library(
|
||||
root,
|
||||
assets.model_library.clone(),
|
||||
)?;
|
||||
let visibility = horizon_client::models::load_visibility(root, assets.registry)?;
|
||||
let tables = std::sync::Arc::new(horizon_client::mesher::MeshTables::new(
|
||||
&assets,
|
||||
|
||||
@@ -22,7 +22,7 @@ use horizon_assets::{
|
||||
ClientItem, Components, Context, DisplayContext, FoilType, ItemEnv, ItemModels,
|
||||
LayerContent, LevelState, OwnerState, SpecialModel, StackView,
|
||||
},
|
||||
model::{CuboidModel, Direction},
|
||||
model::Direction,
|
||||
palette::PalettedPermutations,
|
||||
resolve::{MISSING_MODEL, ModelLibrary},
|
||||
};
|
||||
@@ -370,7 +370,7 @@ impl FileSprites {
|
||||
/// The baked item models and everything resolution needs.
|
||||
pub struct ItemRenderer {
|
||||
pub models: ItemModels,
|
||||
library: ModelLibrary,
|
||||
library: Arc<ModelLibrary>,
|
||||
sprites: FileSprites,
|
||||
baked: Mutex<HashMap<String, Arc<BakedItemModel>>>,
|
||||
extents: Mutex<HashMap<String, Arc<Vec<Vec3>>>>,
|
||||
@@ -382,37 +382,6 @@ pub struct ItemRenderer {
|
||||
item_ids: HashMap<String, i32>,
|
||||
}
|
||||
|
||||
/// Every `models/**.json` of a namespace directory, by identifier.
|
||||
fn load_models(root: &Path) -> BTreeMap<String, CuboidModel> {
|
||||
let mut out = BTreeMap::new();
|
||||
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
|
||||
return out;
|
||||
};
|
||||
for ns in namespaces.flatten() {
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
let base = ns.path().join("models");
|
||||
let mut stack = vec![base.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if path.extension().is_some_and(|x| x == "json")
|
||||
&& let Ok(rel) = path.strip_prefix(&base)
|
||||
&& let Ok(text) = fs::read_to_string(&path)
|
||||
&& let Ok(model) = CuboidModel::from_json(&text)
|
||||
{
|
||||
let rel = rel.with_extension("").to_string_lossy().replace('\\', "/");
|
||||
out.insert(format!("{namespace}:{rel}"), model);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Every `items/*.json` client item definition, by identifier.
|
||||
fn load_definitions(root: &Path) -> BTreeMap<String, ClientItem> {
|
||||
let mut out = BTreeMap::new();
|
||||
@@ -445,14 +414,24 @@ impl ItemRenderer {
|
||||
/// Load item definitions, item prototypes (`items.json`), every model,
|
||||
/// the special renderers' entity models and the grass colormap.
|
||||
pub fn load(root: &Path) -> crate::Result<Self> {
|
||||
Self::load_with_library(
|
||||
root,
|
||||
crate::model_library::ParsedModels::load(root).into_item_library(),
|
||||
)
|
||||
}
|
||||
/// Reuse the immutable models prepared with the block resources at `root`.
|
||||
/// Defer item-only validation failures until after item definitions are loaded.
|
||||
pub fn load_with_library(
|
||||
root: &Path,
|
||||
library: std::result::Result<Arc<ModelLibrary>, String>,
|
||||
) -> crate::Result<Self> {
|
||||
let prototypes: BTreeMap<String, Components> =
|
||||
serde_json::from_slice(&fs::read(root.join("items.json"))?)?;
|
||||
let models = ItemModels {
|
||||
definitions: load_definitions(root),
|
||||
prototypes,
|
||||
};
|
||||
let library = ModelLibrary::with_item_generator(load_models(root))
|
||||
.map_err(|e| format!("item models: {e:?}"))?;
|
||||
let library = library?;
|
||||
let permutations = PalettedPermutations::from_atlas(
|
||||
&fs::read_to_string(root.join("assets/minecraft/atlases/items.json"))
|
||||
.unwrap_or_default(),
|
||||
|
||||
@@ -93,6 +93,7 @@ pub mod mesher;
|
||||
pub mod minimap;
|
||||
pub mod mipmap;
|
||||
pub mod mobs;
|
||||
mod model_library;
|
||||
pub mod models;
|
||||
pub mod motion;
|
||||
pub mod mouse;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Parsed resource models for one startup or resource-pack reload.
|
||||
use crate::Result;
|
||||
use horizon_assets::{model::CuboidModel, resolve::ModelLibrary};
|
||||
use std::{collections::BTreeMap, fs, path::Path, sync::Arc};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ParsedModels {
|
||||
models: BTreeMap<String, Arc<CuboidModel>>,
|
||||
}
|
||||
impl ParsedModels {
|
||||
/// Every `models/**.json` of a namespace directory, by identifier.
|
||||
pub(crate) fn load(root: &Path) -> Self {
|
||||
let mut out = BTreeMap::new();
|
||||
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
|
||||
return Self { models: out };
|
||||
};
|
||||
for ns in namespaces.flatten() {
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
let base = ns.path().join("models");
|
||||
let mut stack = vec![base.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if path.extension().is_some_and(|x| x == "json")
|
||||
&& let Ok(rel) = path.strip_prefix(&base)
|
||||
&& let Ok(text) = fs::read_to_string(&path)
|
||||
&& let Ok(model) = CuboidModel::from_json(&text)
|
||||
{
|
||||
let rel = rel.with_extension("").to_string_lossy().replace('\\', "/");
|
||||
out.insert(format!("{namespace}:{rel}"), Arc::new(model));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { models: out }
|
||||
}
|
||||
|
||||
/// Blocks require every referenced resource, including parents. Items instead
|
||||
/// skip broken resources and use the resolver's missing-model fallback.
|
||||
pub(crate) fn block_library(
|
||||
&self,
|
||||
root: &Path,
|
||||
mut pending: Vec<String>,
|
||||
) -> Result<ModelLibrary> {
|
||||
let mut models = BTreeMap::new();
|
||||
while let Some(name) = pending.pop() {
|
||||
if models.contains_key(&name) {
|
||||
continue;
|
||||
}
|
||||
let model = match self.models.get(&name) {
|
||||
Some(model) => Arc::clone(model),
|
||||
None => {
|
||||
let (namespace, path) =
|
||||
name.split_once(':').ok_or("invalid model identifier")?;
|
||||
let text = fs::read_to_string(
|
||||
root.join(format!("assets/{namespace}/models/{path}.json")),
|
||||
)?;
|
||||
Arc::new(CuboidModel::from_json(&text)?)
|
||||
}
|
||||
};
|
||||
if let Some(parent) = &model.parent {
|
||||
pending.push(parent.clone());
|
||||
}
|
||||
models.insert(name, model);
|
||||
}
|
||||
Ok(ModelLibrary::from_shared(models)?)
|
||||
}
|
||||
pub(crate) fn into_item_library(self) -> std::result::Result<Arc<ModelLibrary>, String> {
|
||||
ModelLibrary::with_shared_item_generator(self.models)
|
||||
.map(Arc::new)
|
||||
.map_err(|e| format!("item models: {e:?}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_assets::resolve::{GENERATED_MODEL, MISSING_MODEL};
|
||||
|
||||
struct Fixture(std::path::PathBuf);
|
||||
impl Fixture {
|
||||
fn write(&self, name: &str, json: &str) {
|
||||
let (namespace, path) = name.split_once(':').unwrap();
|
||||
let path = self
|
||||
.0
|
||||
.join(format!("assets/{namespace}/models/{path}.json"));
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, json).unwrap();
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_views_preserve_resolution_errors_builtins_and_reload() -> Result<()> {
|
||||
let fixture = Fixture(
|
||||
std::env::temp_dir().join(format!("horizon-shared-models-{}", std::process::id())),
|
||||
);
|
||||
fixture.write("horizon:parent", r##"{"textures":{"side":"#all","all":"block/stone"},"elements":[{"from":[0,0,0],"to":[16,8,16],"faces":{"up":{"texture":"#side"}}}]}"##);
|
||||
fixture.write("horizon:nested/child", r#"{"parent":"horizon:parent","textures":{"all":"block/glass"},"display":{"gui":{"rotation":[30,45,0]}}}"#);
|
||||
fixture.write("horizon:broken", "{");
|
||||
fixture.write("horizon:dangling", r#"{"parent":"horizon:absent"}"#);
|
||||
fixture.write("horizon:a", r#"{"parent":"horizon:b"}"#);
|
||||
fixture.write("horizon:b", r#"{"parent":"horizon:a"}"#);
|
||||
fixture.write(
|
||||
GENERATED_MODEL,
|
||||
r#"{"elements":[{"from":[0,0,0],"to":[1,1,1],"faces":{"up":{"texture":"particle"}}}],"gui_light":"side"}"#,
|
||||
);
|
||||
fixture.write(MISSING_MODEL, r#"{"elements":[]}"#);
|
||||
let parsed = ParsedModels::load(&fixture.0);
|
||||
let direct = ParsedModels::default();
|
||||
let names = vec![
|
||||
"horizon:nested/child".into(),
|
||||
GENERATED_MODEL.into(),
|
||||
MISSING_MODEL.into(),
|
||||
"horizon:a".into(),
|
||||
];
|
||||
let old = direct.block_library(&fixture.0, names.clone())?;
|
||||
let shared = parsed.block_library(&fixture.0, names)?;
|
||||
for name in ["horizon:nested/child", GENERATED_MODEL, MISSING_MODEL] {
|
||||
assert_eq!(
|
||||
format!("{:?}", old.resolve(name)?),
|
||||
format!("{:?}", shared.resolve(name)?)
|
||||
);
|
||||
}
|
||||
assert!(shared.resolve("horizon:a").is_err());
|
||||
for name in ["horizon:broken", "horizon:absent", "horizon:dangling"] {
|
||||
let load = |models: &ParsedModels| {
|
||||
models
|
||||
.block_library(&fixture.0, vec![name.into()])
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
};
|
||||
assert_eq!(load(&direct), load(&parsed));
|
||||
}
|
||||
let items = parsed.into_item_library()?;
|
||||
let block = shared.resolve("horizon:nested/child")?;
|
||||
let item = items.resolve("horizon:nested/child")?;
|
||||
assert!(std::ptr::eq(
|
||||
block.elements().as_ptr(),
|
||||
item.elements().as_ptr()
|
||||
));
|
||||
assert_eq!(
|
||||
item.material("side")?.unwrap().sprite,
|
||||
"minecraft:block/glass"
|
||||
);
|
||||
assert_eq!(shared.resolve(GENERATED_MODEL)?.elements().len(), 1);
|
||||
assert!(items.resolve(GENERATED_MODEL)?.elements().is_empty());
|
||||
assert!(items.resolve(GENERATED_MODEL)?.uses_item_generator());
|
||||
for name in [
|
||||
"horizon:broken",
|
||||
"horizon:absent",
|
||||
"horizon:dangling",
|
||||
MISSING_MODEL,
|
||||
] {
|
||||
assert_eq!(items.resolve(name)?.elements().len(), 1);
|
||||
}
|
||||
assert!(items.resolve("horizon:a").is_err());
|
||||
fixture.write("horizon:parent", r#"{"elements":[]}"#);
|
||||
let reloaded = ParsedModels::load(&fixture.0).into_item_library()?;
|
||||
assert!(
|
||||
reloaded
|
||||
.resolve("horizon:nested/child")?
|
||||
.elements()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(items.resolve("horizon:nested/child")?.elements().len(), 1);
|
||||
// An unrelated bad item identifier must not make block preparation fail
|
||||
// (which would trigger resource-pack rollback at a different stage).
|
||||
fixture.write("horizon:INVALID", "{}");
|
||||
let parsed = ParsedModels::load(&fixture.0);
|
||||
assert!(
|
||||
parsed
|
||||
.block_library(&fixture.0, vec!["horizon:nested/child".into()])
|
||||
.is_ok()
|
||||
);
|
||||
let library = parsed.into_item_library();
|
||||
assert!(library.is_err());
|
||||
fs::write(fixture.0.join("items.json"), "{")?;
|
||||
let load = || {
|
||||
crate::item_render::ItemRenderer::load_with_library(&fixture.0, library.clone())
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
};
|
||||
assert!(!load().starts_with("item models:"));
|
||||
fs::write(fixture.0.join("items.json"), "{}")?;
|
||||
assert!(load().starts_with("item models:"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,19 @@ pub fn prepare(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
sprites: &mut Sprites,
|
||||
) -> Result<PreparedModels> {
|
||||
prepare_with_models(
|
||||
root,
|
||||
registry,
|
||||
sprites,
|
||||
&crate::model_library::ParsedModels::default(),
|
||||
)
|
||||
}
|
||||
pub(crate) fn prepare_with_models(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
sprites: &mut Sprites,
|
||||
models: &crate::model_library::ParsedModels,
|
||||
) -> Result<PreparedModels> {
|
||||
let facts: serde_json::Map<String, Value> =
|
||||
serde_json::from_slice(&std::fs::read(root.join("model-states.json"))?)?;
|
||||
@@ -62,7 +75,7 @@ pub fn prepare(
|
||||
.filter(|name| registry.default_state(name).is_some())
|
||||
.filter_map(|name| name.strip_prefix("minecraft:"))
|
||||
.collect();
|
||||
let catalog = StateModels::load(root, registry, &blocks, sprites)?;
|
||||
let catalog = StateModels::load_with_models(root, registry, &blocks, sprites, models)?;
|
||||
let mut placements = Vec::new();
|
||||
let mut states = Vec::new();
|
||||
for (index, (name, properties)) in GALLERY.iter().enumerate() {
|
||||
|
||||
@@ -9,8 +9,6 @@ use horizon_assets::{
|
||||
bind::{Properties, PropertySchema, PropertyValues, SelectedModels},
|
||||
choice::CompiledChoices,
|
||||
},
|
||||
model::CuboidModel,
|
||||
resolve::ModelLibrary,
|
||||
};
|
||||
use horizon_core::{
|
||||
position::BlockPos,
|
||||
@@ -54,6 +52,21 @@ impl StateModels {
|
||||
registry: &BlockRegistry,
|
||||
blocks: &[&str],
|
||||
sprites: &mut Sprites,
|
||||
) -> Result<Self> {
|
||||
Self::load_with_models(
|
||||
root,
|
||||
registry,
|
||||
blocks,
|
||||
sprites,
|
||||
&crate::model_library::ParsedModels::default(),
|
||||
)
|
||||
}
|
||||
pub(crate) fn load_with_models(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
blocks: &[&str],
|
||||
sprites: &mut Sprites,
|
||||
models: &crate::model_library::ParsedModels,
|
||||
) -> Result<Self> {
|
||||
let facts: BTreeMap<String, Value> =
|
||||
serde_json::from_slice(&fs::read(root.join("model-states.json"))?)?;
|
||||
@@ -156,22 +169,10 @@ impl StateModels {
|
||||
}
|
||||
choices.push(CompiledChoices::new(&definition)?);
|
||||
}
|
||||
let mut models = BTreeMap::new();
|
||||
let mut pending: Vec<_> = variants.iter().map(|(name, _, _)| name.clone()).collect();
|
||||
while let Some(name) = pending.pop() {
|
||||
if models.contains_key(&name) {
|
||||
continue;
|
||||
}
|
||||
let (namespace, path) = name.split_once(':').ok_or("invalid model identifier")?;
|
||||
let text =
|
||||
fs::read_to_string(root.join(format!("assets/{namespace}/models/{path}.json")))?;
|
||||
let model = CuboidModel::from_json(&text)?;
|
||||
if let Some(parent) = &model.parent {
|
||||
pending.push(parent.clone());
|
||||
}
|
||||
models.insert(name, model);
|
||||
}
|
||||
let library = ModelLibrary::new(models)?;
|
||||
let library = models.block_library(
|
||||
root,
|
||||
variants.iter().map(|(name, _, _)| name.clone()).collect(),
|
||||
)?;
|
||||
let mut baked = BTreeMap::new();
|
||||
for (name, turns, lock) in variants {
|
||||
let model = library.resolve(&name)?;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Shared model library benchmark
|
||||
|
||||
`model_library_bench` measures `Assets::load` followed by item preparation using
|
||||
that asset snapshot. It includes filesystem traversal, JSON decoding, blockstate
|
||||
binding, block model resolution/baking, sprite preparation, item definitions,
|
||||
entity parts and item registry setup. Timing stops before the optional parity
|
||||
probe. The Python driver's `wait4` resource accounting reports process peak RSS
|
||||
on Linux. For latency runs, allocation tracking is compiled out. For separate
|
||||
retained-memory runs, compile with `HORIZON_BENCH_COUNT_ALLOCATIONS=1`; the counting
|
||||
system allocator reports live requested bytes after both loads. A zero
|
||||
`retained_bytes` with tracking disabled is not a heap measurement.
|
||||
|
||||
Use real prepared resources; no game files belong in Git. Build the baseline
|
||||
at `89b58991bbc02eb79b6965fc4ca56357fe2297d9` with the benchmark example copied in
|
||||
and `ItemRenderer::load(root)` in its item stage. Save that binary, then build
|
||||
this branch with `ItemRenderer::load_with_library(root, assets.model_library.clone())`.
|
||||
Both builds use `cargo build --locked --profile fast -p horizon-client --example
|
||||
model_library_bench` with `HORIZON_BENCH_COUNT_ALLOCATIONS` unset (`opt-level=3`, LTO disabled, 16 codegen units). Set
|
||||
`CARGO_BUILD_JOBS=2`. On the optimization machine, hold
|
||||
`/tmp/horizon-optimizations-20261015-heavy.lock` for each entire build or paired
|
||||
measurement session. Reuse a shared Cargo target only under that lock.
|
||||
|
||||
```sh
|
||||
flock /tmp/horizon-optimizations-20261015-heavy.lock \
|
||||
python3 tools/model_library_bench.py BASELINE_BINARY CANDIDATE_BINARY RESOURCE_ROOT
|
||||
BASELINE_BINARY RESOURCE_ROOT --parity
|
||||
CANDIDATE_BINARY RESOURCE_ROOT --parity
|
||||
```
|
||||
|
||||
Run the parity probes under the same lock. The driver runs two warmups for each
|
||||
binary, followed by ten samples per binary in alternating order. Every sample
|
||||
uses a fresh process, including setup and allocation costs. Filesystem caches
|
||||
are warm; these measurements do not represent cold disk, GPU initialization or
|
||||
entire time to title. The library is built during asset preparation, so compare
|
||||
the combined time rather than either stage alone. Block-only callers of
|
||||
`Assets::load` now prepare and retain item models too; standalone
|
||||
`ItemRenderer::load` remains available for callers without an asset snapshot.
|
||||
|
||||
The optional parity probe hashes every bound registry state's selected variants
|
||||
and baked block faces at two fixed positions, then every resource model's baked
|
||||
item geometry, lighting, transforms and particle material. It complements the
|
||||
synthetic test for malformed/missing parents, cycles, child overrides, built-in
|
||||
models, shared geometry and reload isolation. It is a baseline comparison, not
|
||||
a replacement for vanilla differential harnesses.
|
||||
|
||||
## Results
|
||||
|
||||
Prepared 26.3 resources contain 4,192 model JSON files. Both binaries use the
|
||||
optimized `fast` profile and the same resource root on this Linux machine
|
||||
(12 logical CPUs, 31 GiB RAM; rustc 1.99.0, b940084d7). Tracking is compiled out
|
||||
for these ten samples.
|
||||
|
||||
| Median | Baseline | Shared |
|
||||
| --- | ---: | ---: |
|
||||
| Assets (ms) | 450.1310 | 477.4105 |
|
||||
| Items (ms) | 83.1180 | 36.2980 |
|
||||
| Combined (ms) | 532.6055 | 514.1075 |
|
||||
| Peak RSS (KiB) | 275440.0000 | 277018.0000 |
|
||||
|
||||
Combined startup preparation improves by 18.4980 ms (3.47%). The
|
||||
candidate is faster in all ten pairs. Stage medians do not necessarily add to
|
||||
the median combined time. Peak RSS increases by 1578 KiB (0.57%).
|
||||
|
||||
Combined sample times in milliseconds, in paired order:
|
||||
|
||||
```text
|
||||
baseline: 531.090, 528.995, 523.807, 534.564, 538.876, 529.148, 531.804, 533.407, 541.323, 539.934
|
||||
candidate: 514.767, 513.659, 514.896, 506.912, 516.291, 514.556, 510.443, 513.191, 510.017, 519.038
|
||||
```
|
||||
|
||||
Separate builds with allocation counting enabled report the same retained
|
||||
requested heap in every sample: 129,921,272 versus 129,921,320 bytes (+48 bytes)
|
||||
after both loaders. After assets alone, the values are
|
||||
115,873,670 versus 122,249,411 bytes (+6.08 MiB). The shared models
|
||||
are prepared earlier, so block-only asset consumers pay this retained-memory
|
||||
and loading-time cost even when no item renderer follows.
|
||||
|
||||
The baseline and current candidate resource probes both produce
|
||||
`86a447cfd3f535c2` over all 4,192 item models and the block selections/faces at
|
||||
two fixed positions. The focused test additionally preserves item-only error
|
||||
timing, so invalid item identifiers do not change resource-pack rollback.
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare saved model_library_bench baseline/candidate binaries on prepared resources.
|
||||
|
||||
Build both with the same profile, then run under the shared heavy-work lock:
|
||||
python3 tools/model_library_bench.py BASELINE CANDIDATE RESOURCE_ROOT
|
||||
Fresh processes include all asset/item setup. Two warmups per binary precede
|
||||
10 alternating samples. Page caches are warm; this does not simulate cold disk.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import statistics
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("baseline", type=Path)
|
||||
parser.add_argument("candidate", type=Path)
|
||||
parser.add_argument("resources", type=Path)
|
||||
args = parser.parse_args()
|
||||
binaries = {name: str(getattr(args, name).resolve()) for name in ("baseline", "candidate")}
|
||||
samples = {name: [] for name in binaries}
|
||||
for iteration in range(12):
|
||||
order = list(binaries) if iteration % 2 == 0 else list(reversed(binaries))
|
||||
for label in order:
|
||||
with tempfile.TemporaryFile(mode="w+t") as output:
|
||||
command = [binaries[label], str(args.resources)]
|
||||
with subprocess.Popen(command, stdout=output, stderr=subprocess.STDOUT) as process:
|
||||
_, status, usage = os.wait4(process.pid, 0)
|
||||
process.returncode = os.waitstatus_to_exitcode(status)
|
||||
output.seek(0)
|
||||
text = output.read()
|
||||
if process.returncode:
|
||||
raise subprocess.CalledProcessError(process.returncode, command, text)
|
||||
row = {key: float(value) for key, value in re.findall(r"(\w+)=([0-9.]+)", text)}
|
||||
row["peak_rss_kib"] = usage.ru_maxrss
|
||||
print(json.dumps({"iteration": iteration, "label": label, **row}), flush=True)
|
||||
if iteration >= 2:
|
||||
samples[label].append(row)
|
||||
for label, rows in samples.items():
|
||||
print(label, json.dumps({key: {
|
||||
"median": statistics.median(row[key] for row in rows),
|
||||
"samples": [row[key] for row in rows],
|
||||
} for key in rows[0]}), flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user