mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Render baked vanilla models and animated cutout sprites natively
This commit is contained in:
1 parent
8c7d8f6416
commit
6a4add0c67
12 files changed
+676
-101
No files matched your search
Generated
+1
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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}')
|
||||
|
||||
|
||||
Reference in new issue
Block a user