Render baked vanilla models and animated cutout sprites natively

This commit is contained in:
Ludovic Marie committed 2026-10-06 20:47:09 +02:00
1 parent 8c7d8f6416
commit 6a4add0c67
12 files changed
+676 -101

No files matched your search

Generated
+1
View File
@@ -587,6 +587,7 @@ version = "0.1.0"
dependencies = [
"bytemuck",
"glam",
"horizon-assets",
"horizon-core",
"horizon-world",
"png",
@@ -0,0 +1,96 @@
{
"adapter": {
"backend": "Vulkan",
"device": 0,
"device_type": "Cpu",
"name": "llvmpipe (LLVM 21.1.8, 256 bits)",
"vendor": 65541
},
"animated_sprites": 1,
"animation_uploads": 1,
"arch": "x86_64",
"camera_pitch": -0.3839724354387525,
"camera_position": [
25.0,
18.0,
25.0
],
"camera_yaw": -2.356194490192345,
"elapsed_seconds": 0.691954259,
"error": null,
"merged_quads": 166,
"model_gallery": [
"oak_stairs",
"stone_slab",
"anvil",
"torch",
"lantern",
"oak_fence_post",
"oak_fence_gate",
"iron_bars_post",
"cobweb",
"grindstone",
"ladder",
"amethyst_cluster"
],
"model_quads": 145,
"os": "linux",
"presented_frames": 30,
"project": "Horizon",
"resident_chunks": 9,
"schema": 1,
"scope": "Native cube and cuboid-model renderer with binary cutouts, stepped sprite animation and free camera; no gameplay simulation",
"sprite_layers": 18,
"window_size": [
1280,
720
],
"resource_files": {
"files": 5579,
"sha256": "0bc95c8e4b10ef7401f4b948e9a151ea2f9d9112e8dadb9f6807a51e143fcf6f"
},
"executable_sha256": "da79260dbc9ac235d48837dd7a2ea7369c03adffba40f7cb6a3e30f86c42daa4",
"resource_manifest_sha256": "eda44ebee6904eab880c15458dbd1212dc2799d320a4419fc810e2ceb0e5d9d6",
"sources": {
"Cargo.toml": "3f93319c1eb70dbd7ba25cd74ef343fbf35c52c7c4752f955605c58699cbff9d",
"Cargo.lock": "fed441cad861a44e9919087307cdb9b3d8165fe36ae05ab03f72dde23758cbd5",
"tools/test_native_client.py": "e47a866cffd20fcd6fdab62fca573227ca9837488e72384f08f8c33fd4b6228c",
"crates/horizon-client/Cargo.toml": "a6eaf78388d88f1e9628474734ee6f1678f1b12c63ee25e86d7d95c8a9c155a8",
"crates/horizon-assets/Cargo.toml": "fadb78cf819e026500606c45d27138ed67fcbbc967ae8cee6c1e7c82c9046b36",
"crates/horizon-client/src/assets.rs": "c953a42820cb8d19d8964d4807df401614d4d6621bb47d8ba591c8db38f3e214",
"crates/horizon-client/src/camera.rs": "f9821c8a51a004dd4ad9c0952e746eb07926f6040cf8660bc26da5a0728ae572",
"crates/horizon-client/src/main.rs": "7fb00e89b963a77f124c8e7ad59c5506a4a2f3e6c69720944dec00677ed15239",
"crates/horizon-client/src/mesh.rs": "e2fa00435ec8239ba8a3c6539022bb50a10ff9c3850c6faab72f86aabea896ef",
"crates/horizon-client/src/models.rs": "70b0273100e83afbfc5c013c79afae1e6b13565845e067ad3303244c3f0bbe68",
"crates/horizon-client/src/render.rs": "0f8929d9061b20adbc5ddd0adcbe9f90ce76227b7bb9bafe3f4474961ee2984c",
"crates/horizon-client/src/sprites.rs": "0c4d6acc07b947e5d99aeed8f3788d15639d5af1071eaa0852e9e2fd8c51e318",
"crates/horizon-client/src/world.rs": "901aa59c23ea084e926eec014589f4d511c41691a8dc1520534e039c28cfc76c",
"crates/horizon-client/src/world.wgsl": "82873c131f46a31aef35106f89a53589f179048362d0f8877eca6d20aa6274a4",
"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"
},
"platform": "Linux-7.0.0-31-generic-x86_64-with-glibc2.43",
"manual_validation": {
"scope": "Interactive close inspection of the twelve gallery models, followed by stationary screenshot differences confined to the lantern. This preceded the final Arc allocation/lint cleanup; the bounded startup test above used the final source fingerprints.",
"interactive_frames": 6226,
"animation_uploads": 269,
"lantern_difference_bounds": [
411,
307,
425,
318
],
"screenshot_sha256": {
"model-gallery-first.png": "2ac637607e34ec05f552b3496c721ae9c09fa9e2a4a1d5e64073a49c39c5b36c",
"model-gallery-close.png": "188d35e8a9ecd0c68ee08545b503402450e2675f2ccf28fbb32d26751e634a7e",
"model-gallery-animation-0.png": "188d35e8a9ecd0c68ee08545b503402450e2675f2ccf28fbb32d26751e634a7e",
"model-gallery-animation-1.png": "1e26f942411d91554c4e509803d7e5eafa8e6d5467c455051d0b5c77497a2ec2",
"model-gallery-animation-2.png": "188d35e8a9ecd0c68ee08545b503402450e2675f2ccf28fbb32d26751e634a7e",
"model-gallery-animation-3.png": "1e26f942411d91554c4e509803d7e5eafa8e6d5467c455051d0b5c77497a2ec2"
},
"limitations": "Linux software Vulkan adapter only; no native Mac or Windows execution, gameplay or full pixel-parity claim."
}
}
+1
View File
@@ -7,6 +7,7 @@ rust-version = "1.90"
description = "Horizon native development client"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-core = { path = "../horizon-core" }
horizon-world = { path = "../horizon-world" }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "dx12", "wgsl"] }
+12 -38
View File
@@ -56,6 +56,9 @@ pub struct Assets {
pub layers: Vec<i32>,
pub sprite_size: u32,
pub pixels: Vec<u8>,
pub texture_layers: u32,
pub model_quads: Vec<crate::models::ModelInstance>,
pub animations: Vec<crate::sprites::SpriteAnimation>,
}
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
@@ -169,55 +172,26 @@ impl Assets {
.map(|state| if state.air { -1 } else { -2 })
.collect();
let mut material_states = [BlockStateId(0); 6];
let mut pixels = Vec::new();
let mut sprite_size = 0;
let mut sprites = crate::sprites::Sprites::default();
for (layer, name) in MATERIALS.iter().enumerate() {
let state = registry
.default_state(&format!("minecraft:{name}"))
.ok_or_else(|| invalid("missing test-scene material"))?;
material_states[layer] = state;
layers[state.0 as usize] = layer as i32;
let path = root.join(format!("assets/minecraft/textures/block/{name}.png"));
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
decoder
.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![
0;
reader
.output_buffer_size()
.ok_or_else(|| invalid("sprite exceeds decoder limits"))?
];
let info = reader.next_frame(&mut bytes)?;
if info.width != info.height
|| info.width == 0
|| (sprite_size != 0 && sprite_size != info.width)
{
return Err(invalid(
"test-client sprites must be square and use matching dimensions",
)
.into());
}
sprite_size = info.width;
let channels = info.color_type.samples();
for pixel in bytes[..info.buffer_size()].chunks_exact(channels) {
let rgba = match info.color_type {
png::ColorType::Rgba => [pixel[0], pixel[1], pixel[2], pixel[3]],
png::ColorType::Rgb => [pixel[0], pixel[1], pixel[2], 255],
png::ColorType::Grayscale => [pixel[0], pixel[0], pixel[0], 255],
png::ColorType::GrayscaleAlpha => [pixel[0], pixel[0], pixel[0], pixel[1]],
_ => return Err(invalid("unsupported expanded sprite format").into()),
};
pixels.extend(rgba);
}
let sprite = sprites.insert(root, &format!("minecraft:block/{name}"))?;
layers[state.0 as usize] = sprite as i32;
}
let model_quads = crate::models::gallery(root, &mut sprites)?;
Ok(Self {
registry,
properties,
material_states,
layers,
sprite_size,
pixels,
sprite_size: sprites.size,
texture_layers: u32::try_from(sprites.layers.len())?,
pixels: sprites.pixels,
model_quads,
animations: sprites.animations,
})
}
}
+12 -4
View File
@@ -4,7 +4,9 @@
mod assets;
mod camera;
mod mesh;
mod models;
mod render;
mod sprites;
mod world;
use assets::Assets;
use camera::Camera;
@@ -60,7 +62,7 @@ impl Options {
}
if argument == "--help" || argument == "-h" {
println!(
"Horizon native development client\n\n --resources PATH Prepared pinned resources\n --radius CHUNKS Controlled scene radius, 1..16 (default 4)\n --frames COUNT Exit after this many presented frames\n --smoke-report PATH Write a runtime report on exit\n --timeout-seconds N Deadline for a frame-limited run (default 90)\n --trace-input Log received keyboard, mouse-button and focus events\n\nControls: click to capture mouse; WASD move; Space/Shift rise/descend;\nCtrl boosts speed; arrow keys rotate; Esc releases mouse; Ctrl+Q quits; F3 shows diagnostics.\n\nThis is a controlled cube scene with a free inspection camera, not complete gameplay."
"Horizon native development client\n\n --resources PATH Prepared pinned resources\n --radius CHUNKS Controlled scene radius, 1..16 (default 4)\n --frames COUNT Exit after this many presented frames\n --smoke-report PATH Write a runtime report on exit\n --timeout-seconds N Deadline for a frame-limited run (default 90)\n --trace-input Log received keyboard, mouse-button and focus events\n\nControls: click to capture mouse; WASD move; Space/Shift rise/descend;\nCtrl boosts speed; arrow keys rotate; Esc releases mouse; Ctrl+Q quits; F3 shows diagnostics.\n\nThis is a controlled block/model scene with a free inspection camera, not complete gameplay."
);
return Ok(None);
}
@@ -157,7 +159,7 @@ impl App<'_> {
let report = serde_json::json!({
"schema": 1,
"project": "Horizon",
"scope": "Native opaque-cube renderer and free inspection camera; no gameplay simulation",
"scope": "Native cube and cuboid-model renderer with binary cutouts, stepped sprite animation and free camera; no gameplay simulation",
"os": std::env::consts::OS,
"arch": std::env::consts::ARCH,
"adapter": adapter,
@@ -166,6 +168,11 @@ impl App<'_> {
"elapsed_seconds": self.started.elapsed().as_secs_f64(),
"resident_chunks": self.world.chunks.len(),
"merged_quads": self.mesh.len(),
"model_quads": self.assets.model_quads.len(),
"model_gallery": models::GALLERY,
"sprite_layers": self.assets.texture_layers,
"animated_sprites": self.assets.animations.len(),
"animation_uploads": self.renderer.as_ref().map_or(0, |r| r.animation_uploads),
"camera_position": self.camera.position.to_array(),
"camera_yaw": self.camera.yaw,
"camera_pitch": self.camera.pitch,
@@ -348,9 +355,10 @@ fn run() -> Result<()> {
let world = TestWorld::new(&assets, options.radius)?;
let mesh = mesh::build(&world)?;
eprintln!(
"Horizon development scene: {} resident chunks, {} merged cube faces.\nClick to capture mouse; WASD/Space/Shift move; arrows rotate; Esc releases; Ctrl+Q quits.",
"Horizon development scene: {} resident chunks, {} merged cube faces, {} baked model faces.\nClick to capture mouse; WASD/Space/Shift move; arrows rotate; Esc releases; Ctrl+Q quits.",
world.chunks.len(),
mesh.len()
mesh.len(),
assets.model_quads.len()
);
let now = Instant::now();
let mut app = App {
+84
View File
@@ -0,0 +1,84 @@
//! A resource-model inspection gallery. Blockstate dispatch is still separate.
use crate::{Result, sprites::Sprites};
use horizon_assets::{
bake::bake_model,
model::{CuboidModel, Direction},
resolve::ModelLibrary,
};
use std::{collections::BTreeMap, fs, path::Path};
pub const GALLERY: [&str; 12] = [
"oak_stairs",
"stone_slab",
"anvil",
"torch",
"lantern",
"oak_fence_post",
"oak_fence_gate",
"iron_bars_post",
"cobweb",
"grindstone",
"ladder",
"amethyst_cluster",
];
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct ModelInstance {
pub positions: [[f32; 3]; 4],
pub uv: [[f32; 2]; 4],
pub layer: u32,
pub shade: f32,
}
pub fn gallery(root: &Path, sprites: &mut Sprites) -> Result<Vec<ModelInstance>> {
let mut models = BTreeMap::new();
let mut pending: Vec<_> = GALLERY
.iter()
.map(|name| format!("minecraft:block/{name}"))
.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 mut instances = Vec::new();
for (index, name) in GALLERY.iter().enumerate() {
let model = library.resolve(&format!("minecraft:block/{name}"))?;
let origin = [
(index % 4) as f32 * 4.0 - 7.0,
4.0,
(index / 4) as f32 * 4.0 + 14.0,
];
for quad in bake_model(&model)? {
if quad.material.force_translucent || quad.tint_index != -1 {
return Err(
"inspection gallery requires untinted opaque or binary-cutout materials".into(),
);
}
let layer = sprites.insert(root, &quad.material.sprite)?;
let shade = match quad.shade_direction_override.unwrap_or(quad.facing) {
Direction::Down => 0.5,
Direction::Up => 1.0,
Direction::North | Direction::South => 0.8,
Direction::West | Direction::East => 0.6,
};
instances.push(ModelInstance {
positions: quad
.positions
.map(|v| std::array::from_fn(|i| v[i] + origin[i])),
uv: quad.uv,
layer,
shade,
});
}
}
Ok(instances)
}
+160 -43
View File
@@ -1,5 +1,5 @@
use crate::{Result, assets::Assets, camera::Camera, mesh::Quad};
use std::sync::Arc;
use crate::{Result, assets::Assets, camera::Camera, mesh::Quad, models::ModelInstance};
use std::{sync::Arc, time::Instant};
use wgpu::util::DeviceExt;
use winit::window::Window;
#[repr(C)]
@@ -17,6 +17,14 @@ pub struct Renderer {
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
pipeline: wgpu::RenderPipeline,
model_pipeline: wgpu::RenderPipeline,
model_quads: wgpu::Buffer,
model_quad_count: u32,
sprite_texture: wgpu::Texture,
sprite_size: u32,
animations: Vec<(crate::sprites::SpriteAnimation, usize)>,
animation_started: Instant,
pub animation_uploads: u64,
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
quads: wgpu::Buffer,
@@ -72,45 +80,101 @@ impl Renderer {
label: Some("Horizon cube renderer"),
source: wgpu::ShaderSource::Wgsl(include_str!("world.wgsl").into()),
});
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Uint32];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon opaque cubes"),
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Quad>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Less),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
let bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon world bindings"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon world layout"),
bind_group_layouts: &[Some(&bindings)],
..Default::default()
});
let create_pipeline =
|label: &str, vertex: &str, fragment: &str, buffer: wgpu::VertexBufferLayout<'_>| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some(vertex),
compilation_options: Default::default(),
buffers: &[Some(buffer)],
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Less),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(fragment),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Uint32];
let pipeline = create_pipeline(
"Horizon opaque cubes",
"vs_main",
"fs_main",
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Quad>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &attributes,
},
);
let model_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x3,2=>Float32x3,3=>Float32x3,4=>Float32x2,5=>Float32x2,6=>Float32x2,7=>Float32x2,8=>Uint32,9=>Float32];
let model_pipeline = create_pipeline(
"Horizon model geometry",
"vs_model",
"fs_model",
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<ModelInstance>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &model_attributes,
},
);
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon camera"),
size: std::mem::size_of::<Uniforms>() as u64,
@@ -120,11 +184,11 @@ impl Renderer {
let texture = device.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: Some("Pinned cube sprites"),
label: Some("Pinned block sprites"),
size: wgpu::Extent3d {
width: assets.sprite_size,
height: assets.sprite_size,
depth_or_array_layers: assets.material_states.len() as u32,
depth_or_array_layers: assets.texture_layers,
},
mip_level_count: 1,
sample_count: 1,
@@ -150,7 +214,7 @@ impl Renderer {
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon world resources"),
layout: &pipeline.get_bind_group_layout(0),
layout: &bindings,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
@@ -172,6 +236,12 @@ impl Renderer {
contents: bytemuck::cast_slice(quads),
usage: wgpu::BufferUsages::VERTEX,
});
let model_quad_count = u32::try_from(assets.model_quads.len())?;
let model_quads = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Baked vanilla model faces"),
contents: bytemuck::cast_slice(&assets.model_quads),
usage: wgpu::BufferUsages::VERTEX,
});
let depth = Self::depth(&device, &config);
Ok(Self {
instance,
@@ -181,6 +251,18 @@ impl Renderer {
queue,
config,
pipeline,
model_pipeline,
model_quads,
model_quad_count,
sprite_texture: texture,
sprite_size: assets.sprite_size,
animations: assets
.animations
.iter()
.map(|a| (a.clone(), a.frame(0)))
.collect(),
animation_started: Instant::now(),
animation_uploads: 0,
group,
uniform,
quads,
@@ -242,6 +324,38 @@ impl Renderer {
return Err("surface acquisition failed validation".into());
}
};
// Inspection textures advance at 20 ticks/s independently of presentation.
let tick = self.animation_started.elapsed().as_millis() / 50;
for (animation, previous) in &mut self.animations {
let frame = animation.frame(tick as u64);
if frame != *previous {
self.queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &self.sprite_texture,
mip_level: 0,
origin: wgpu::Origin3d {
x: 0,
y: 0,
z: animation.layer,
},
aspect: wgpu::TextureAspect::All,
},
animation.pixels(frame),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(self.sprite_size * 4),
rows_per_image: Some(self.sprite_size),
},
wgpu::Extent3d {
width: self.sprite_size,
height: self.sprite_size,
depth_or_array_layers: 1,
},
);
*previous = frame;
self.animation_uploads += 1;
}
}
let fog = [0.35, 0.55, 0.80, 1.0];
let p = camera.position.as_vec3();
let uniforms = Uniforms {
@@ -291,6 +405,9 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
pass.set_vertex_buffer(0, self.quads.slice(..));
pass.draw(0..6, 0..self.quad_count);
pass.set_pipeline(&self.model_pipeline);
pass.set_vertex_buffer(0, self.model_quads.slice(..));
pass.draw(0..6, 0..self.model_quad_count);
}
self.queue.submit([encoder.finish()]);
self.window.pre_present_notify();
+217
View File
@@ -0,0 +1,217 @@
//! Shared nearest-sampled sprite array for the controlled scene.
use crate::Result;
use std::{
collections::BTreeMap,
fs::{self, File},
io::BufReader,
path::Path,
sync::Arc,
};
#[derive(Clone)]
pub struct SpriteAnimation {
pub layer: u32,
frames: Arc<Vec<Vec<u8>>>,
sequence: Arc<Vec<(usize, u32)>>,
duration: u64,
}
impl SpriteAnimation {
pub fn frame(&self, tick: u64) -> usize {
let mut time = tick % self.duration;
for &(index, duration) in self.sequence.iter() {
if time < u64::from(duration) {
return index;
}
time -= u64::from(duration);
}
unreachable!("validated animation duration")
}
pub fn pixels(&self, frame: usize) -> &[u8] {
&self.frames[frame]
}
}
#[derive(Default)]
pub struct Sprites {
pub size: u32,
pub pixels: Vec<u8>,
pub layers: BTreeMap<String, u32>,
pub animations: Vec<SpriteAnimation>,
}
impl Sprites {
pub fn insert(&mut self, root: &Path, name: &str) -> Result<u32> {
if let Some(layer) = self.layers.get(name) {
return Ok(*layer);
}
let (namespace, path) = name.split_once(':').ok_or("invalid sprite identifier")?;
let path = root.join(format!("assets/{namespace}/textures/{path}.png"));
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
Ok(text) => Some(serde_json::from_str::<serde_json::Value>(&text)?),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
let animation = metadata.as_ref().and_then(|v| v.get("animation"));
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![
0;
reader
.output_buffer_size()
.ok_or("sprite exceeds decoder limits")?
];
let info = reader.next_frame(&mut bytes)?;
let positive = |value: &serde_json::Value| -> Result<u32> {
let value = value
.as_u64()
.filter(|v| *v > 0 && *v <= i32::MAX as u64)
.ok_or("invalid positive animation integer")?;
Ok(value as u32)
};
let (width, height) = if let Some(animation) = animation {
if !animation.is_object() {
return Err("invalid animation metadata".into());
}
if animation
.get("interpolate")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
return Err(
"interpolated animation is not yet supported by the inspection renderer".into(),
);
}
match (
animation.get("width").map(positive).transpose()?,
animation.get("height").map(positive).transpose()?,
) {
(Some(w), Some(h)) => (w, h),
(Some(w), None) => (w, info.height),
(None, Some(h)) => (info.width, h),
(None, None) => {
let size = info.width.min(info.height);
(size, size)
}
}
} else {
(info.width, info.height)
};
if width != height
|| width == 0
|| info.width % width != 0
|| info.height % height != 0
|| (self.size != 0 && self.size != width)
{
return Err(format!(
"inspection sprite {name} must be square and match the sprite array dimensions"
)
.into());
}
let channels = info.color_type.samples();
let mut pixels = Vec::with_capacity(info.width as usize * info.height as usize * 4);
for pixel in bytes[..info.buffer_size()].chunks_exact(channels) {
let rgba = match info.color_type {
png::ColorType::Rgba => [pixel[0], pixel[1], pixel[2], pixel[3]],
png::ColorType::Rgb => [pixel[0], pixel[1], pixel[2], 255],
png::ColorType::Grayscale => [pixel[0], pixel[0], pixel[0], 255],
png::ColorType::GrayscaleAlpha => [pixel[0], pixel[0], pixel[0], pixel[1]],
_ => return Err("unsupported expanded sprite format".into()),
};
if rgba[3] != 0 && rgba[3] != 255 {
return Err(format!(
"inspection sprite {name} requires fractional-alpha rendering"
)
.into());
}
pixels.extend(rgba);
}
let layer = u32::try_from(self.layers.len())?;
let columns = info.width / width;
let count = columns * (info.height / height);
let mut frames = Vec::new();
for index in 0..count {
let x = index % columns * width;
let y = index / columns * height;
let mut frame = Vec::with_capacity((width * height * 4) as usize);
for row in y..y + height {
let offset = ((row * info.width + x) * 4) as usize;
frame.extend_from_slice(&pixels[offset..offset + (width * 4) as usize]);
}
frames.push(frame);
}
let frames = Arc::new(frames);
let mut initial = 0;
if let Some(metadata) = animation {
let default_time = metadata
.get("frametime")
.map(positive)
.transpose()?
.unwrap_or(1);
let sequence = if let Some(values) = metadata.get("frames") {
let mut sequence = Vec::new();
for value in values.as_array().ok_or("invalid animation frame list")? {
let (index, time) = if value.is_object() {
(
value.get("index").and_then(serde_json::Value::as_u64),
value
.get("time")
.map(positive)
.transpose()?
.unwrap_or(default_time),
)
} else {
(value.as_u64(), default_time)
};
if let Some(index) = index.filter(|i| *i < u64::from(count)) {
sequence.push((index as usize, time));
}
}
sequence
} else {
(0..count).map(|i| (i as usize, default_time)).collect()
};
if sequence.len() > 1 {
initial = sequence[0].0;
let duration = sequence.iter().map(|(_, time)| u64::from(*time)).sum();
self.animations.push(SpriteAnimation {
layer,
frames: frames.clone(),
sequence: Arc::new(sequence),
duration,
});
}
}
self.layers.insert(name.to_owned(), layer);
self.size = width;
self.pixels.extend_from_slice(&frames[initial]);
Ok(layer)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn animation_boundaries_and_repeated_frames_do_not_depend_on_render_rate() {
let animation = SpriteAnimation {
layer: 0,
frames: Arc::new(vec![vec![], vec![], vec![]]),
sequence: Arc::new(vec![(2, 8), (1, 3), (1, 2), (0, 1)]),
duration: 14,
};
for (tick, frame) in [
(0, 2),
(7, 2),
(8, 1),
(10, 1),
(11, 1),
(12, 1),
(13, 0),
(14, 2),
(29, 2),
(u64::MAX, 2),
] {
assert_eq!(animation.frame(tick), frame);
}
}
}
+27
View File
@@ -40,3 +40,30 @@ struct VertexOut {
let fog_amount=smoothstep(camera.position.w*0.65,camera.position.w,distance);
return vec4(mix(color.rgb*input.shade,camera.fog.rgb,fog_amount),1.0);
}
@vertex fn vs_model(@builtin(vertex_index) vertex: u32,
@location(0) p0: vec3<f32>, @location(1) p1: vec3<f32>,
@location(2) p2: vec3<f32>, @location(3) p3: vec3<f32>,
@location(4) uv0: vec2<f32>, @location(5) uv1: vec2<f32>,
@location(6) uv2: vec2<f32>, @location(7) uv3: vec2<f32>,
@location(8) layer: u32, @location(9) shade: f32) -> VertexOut {
let order=array<u32,6>(0u,1u,2u,0u,2u,3u);
let positions=array<vec3<f32>,4>(p0,p1,p2,p3);
let uvs=array<vec2<f32>,4>(uv0,uv1,uv2,uv3);
let corner=order[vertex];
var output: VertexOut;
output.clip=camera.view_projection*vec4(positions[corner],1.0);
output.uv=uvs[corner];
output.layer=layer;
output.shade=shade;
output.world_position=positions[corner];
return output;
}
@fragment fn fs_model(input:VertexOut)->@location(0) vec4<f32> {
let color=textureSample(sprites,sprite_sampler,input.uv,i32(input.layer));
// Inspection sprites are validated to contain only zero or full alpha.
if color.a<0.5 { discard; }
let distance=length(input.world_position-camera.position.xyz);
let fog_amount=smoothstep(camera.position.w*0.65,camera.position.w,distance);
return vec4(mix(color.rgb*input.shade,camera.fog.rgb,fog_amount),1.0);
}
+30 -9
View File
@@ -1,16 +1,29 @@
# Horizon native development client
`horizon-client` opens a native Rust window and renders a controlled block scene
stored in Horizon's resident chunks. It loads six opaque cube textures and the
block registry from the pinned Java Edition 26.3 resources. A padded chunk mesh
stored in Horizon's resident chunks, alongside twelve real resource models. It
loads the block registry, models and sprites from pinned Java Edition 26.3
resources. A padded chunk mesh
culls hidden faces across chunk boundaries and merges adjacent matching faces;
the GPU draws the rectangles as instances. The camera is a free inspection tool.
the GPU draws the rectangles as instances. A separate instanced draw renders
baked cuboid faces without expanding them into cube approximations. The camera
is a free inspection tool.
This checkpoint has no player simulation, vanilla world generation, block
interaction, inventory, sound, networking or world save/load. It does not meet
the [playable-build acceptance criteria](rust-parity.md#completion-criteria-and-playable-builds).
Full block models, weighted variants, texture orientation, transparency, tinting,
light, resource packs, chunk streaming and visual parity remain unfinished.
Blockstate dispatch, weighted variants, model transforms/UV locking, fractional
transparency, tinting, lighting, resource packs, chunk streaming and full visual
parity remain unfinished.
The resource gallery sits beside the pyramid and includes oak stairs, a slab,
an anvil, a torch, a lantern, fence parts, iron bars, a cobweb, a grindstone, a
ladder and an amethyst cluster. These are explicit model selections for geometry
inspection, not simulated block placements. Binary-alpha cutouts are rendered;
the lantern's three frames advance every eight inspection ticks at 20 ticks/s.
General animation metadata compatibility, interpolation and synchronization with
the eventual game loop still need validation. Sprites currently require matching
square frame dimensions.
## Run from a checkout
@@ -68,8 +81,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. Blockstate selection, transformed
classes; those rendering APIs are absent from the server. The decoder, parent
resolver and baker now feed the development gallery. Blockstate selection, transformed
models, UV locking and rendered appearance still need their own checks.
`ModelLibrary` resolves parent geometry, ambient occlusion, GUI lighting and
@@ -119,11 +132,14 @@ python3 tools/test_native_client.py --vscreen
The mesh tests compare expanded rectangles against independently enumerated
exposed voxel faces, including solid neighbors, cavities, material boundaries,
negative chunk origins and random surroundings. The startup runner launches a
nine-chunk scene, requires 30 presented frames and a clean exit, and writes GPU,
nine-chunk scene, requires 30 presented frames and a clean exit, verifies 145
baked gallery faces across 18 sprite layers, and writes GPU,
platform, binary and source information to ignored
`.local/horizon-client/startup-smoke.json`. It has an external timeout as well as
the application's frame deadline. These checks do not establish gameplay or
pixel parity with Java.
pixel parity with Java. Source and resource fingerprints must remain unchanged
during the run. An animation test covers duration boundaries, wrapping and
repeated physical frames independently of render frequency.
Initial local validation uses Linux x86_64, a headless Wayland compositor and
Vulkan llvmpipe software rendering. Textured geometry and resizing were visually
@@ -132,3 +148,8 @@ physical key codes. Captured relative mouse movement and Escape release were
exercised through a virtual pointer. Native macOS/Metal and
Windows/Direct3D 12 runtime tests remain pending. Software-renderer frame rates
are not evidence of a hardware speedup.
The model gallery was also inspected at close range on Linux/Vulkan llvmpipe.
With the camera stationary, screenshot differences were confined to the lantern
as its frames changed. The [model-client startup report](../benchmarks/rust-native-model-client-2026-10-06/startup-smoke.json)
records the bounded presentation check and the scope of this visual inspection.
+5 -2
View File
@@ -3,7 +3,8 @@
The behavior target is the repository's pinned **Minecraft Java Edition 26.3**.
One-to-one vanilla gameplay parity is the acceptance criterion. It has **not**
been reached. Horizon now has a [native development client](native-client.md)
that renders a controlled block scene with a free camera. It does not run a
that renders a controlled block scene and twelve baked resource models with a
free camera. It does not run a
complete game server or gameplay simulation.
The Rust workspace implements core arithmetic/random streams, world data and
resident sections, scheduled tick ordering, geometry, contained fluid states,
@@ -18,7 +19,9 @@ 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 and cuboid baking over
7,100 roots with identity blockstate transforms. Blockstate selection, transformed
models, UV locking and integration with the renderer remain unfinished.
models and UV locking remain unfinished. The native renderer now draws an
inspection gallery from the decoded/resolved/baked models, including binary
cutouts and stepped lantern animation; these are not yet gameplay blocks.
The playable Java performance packs remain available during development.
## Completion criteria and playable builds
+31 -5
View File
@@ -5,6 +5,7 @@ Requires prepared private resources and a desktop display (or --vscreen on
headless Linux). This is a renderer startup check, not a gameplay parity gate.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
@@ -17,6 +18,17 @@ except ImportError:
import lab
def resource_fingerprint(root):
files = [root / 'facts.json', root / 'block-tags.json', root / 'horizon-resources.json',
*sorted(path for path in (root / 'assets').rglob('*') if path.is_file())]
digest = hashlib.sha256()
for path in files:
data = path.read_bytes()
digest.update(path.relative_to(root).as_posix().encode() + b'\0')
digest.update(len(data).to_bytes(8, 'big')); digest.update(data)
return {'files': len(files), 'sha256': digest.hexdigest()}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--vscreen', action='store_true')
@@ -29,6 +41,16 @@ def main():
root.mkdir(parents=True, exist_ok=True)
report = root / 'startup-smoke.json'
report.unlink(missing_ok=True)
resources = resource_fingerprint(root / '26.3')
sources = [lab.ROOT / 'Cargo.toml', lab.ROOT / 'Cargo.lock', Path(__file__).resolve(),
lab.ROOT / 'crates/horizon-client/Cargo.toml', lab.ROOT / 'crates/horizon-assets/Cargo.toml',
*sorted((lab.ROOT / 'crates/horizon-client/src').glob('*')),
*sorted((lab.ROOT / 'crates/horizon-assets/src').glob('*.rs'))]
tested_sources = {
str(path.relative_to(lab.ROOT)): lab.digest(path, 'sha256')
for path in sources
if path.is_file()
}
environment = dict(os.environ, RUSTUP_TOOLCHAIN='stable')
subprocess.run(['cargo', 'build', '--locked', '-p', 'horizon-client'],
cwd=lab.ROOT, env=environment, check=True, timeout=3600)
@@ -46,16 +68,20 @@ def main():
result = json.loads(report.read_text())
if (result.get('error') is not None or result.get('presented_frames') != 30
or result.get('resident_chunks') != 9 or result.get('merged_quads', 0) <= 0
or result.get('model_quads') != 145 or len(result.get('model_gallery', [])) != 12
or result.get('sprite_layers') != 18 or result.get('animated_sprites') != 1
or not result.get('adapter') or min(result.get('window_size', [0])) <= 0):
raise RuntimeError('Incomplete native client presentation: ' + str(report))
if resource_fingerprint(root / '26.3') != resources:
raise RuntimeError('Resources changed during native client verification')
result['resource_files'] = resources
result['executable_sha256'] = lab.digest(executable, 'sha256')
result['resource_manifest_sha256'] = lab.digest(
root / '26.3/horizon-resources.json', 'sha256')
result['sources'] = {
str(path.relative_to(lab.ROOT)): lab.digest(path, 'sha256')
for path in sorted((lab.ROOT / 'crates/horizon-client/src').glob('*'))
if path.is_file()
}
actual_sources = {str(path.relative_to(lab.ROOT)): lab.digest(path, 'sha256') for path in sources if path.is_file()}
if actual_sources != tested_sources:
raise RuntimeError('Sources changed during native client verification')
result['sources'] = tested_sources
lab.write_json(report, result)
print(f'Native renderer startup passed: {report}')