From 0e19660eb5122c8c52d5e909927884ad5c50b813 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Thu, 19 Feb 2026 04:46:23 +0400 Subject: Refactor documentation structure and add new specifications - Updated MSH documentation to reflect changes in material, wear, and texture specifications. - Introduced new `render.md` file detailing the render pipeline process. - Removed outdated sections from `runtime-pipeline.md` and redirected to `render.md`. - Added detailed specifications for `Texm` texture format and `WEAR` wear table. - Updated navigation in `mkdocs.yml` to align with new documentation structure. --- crates/render-demo/Cargo.toml | 20 +++ crates/render-demo/README.md | 30 ++++ crates/render-demo/build.rs | 4 + crates/render-demo/src/lib.rs | 113 +++++++++++++ crates/render-demo/src/main.rs | 357 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 524 insertions(+) create mode 100644 crates/render-demo/Cargo.toml create mode 100644 crates/render-demo/README.md create mode 100644 crates/render-demo/build.rs create mode 100644 crates/render-demo/src/lib.rs create mode 100644 crates/render-demo/src/main.rs (limited to 'crates/render-demo') diff --git a/crates/render-demo/Cargo.toml b/crates/render-demo/Cargo.toml new file mode 100644 index 0000000..376a25e --- /dev/null +++ b/crates/render-demo/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "render-demo" +version = "0.1.0" +edition = "2021" + +[features] +default = [] +demo = ["dep:sdl2", "dep:glow"] + +[dependencies] +msh-core = { path = "../msh-core" } +nres = { path = "../nres" } +render-core = { path = "../render-core" } +sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] } +glow = { version = "0.16", optional = true } + +[[bin]] +name = "parkan-render-demo" +path = "src/main.rs" +required-features = ["demo"] diff --git a/crates/render-demo/README.md b/crates/render-demo/README.md new file mode 100644 index 0000000..b33b18c --- /dev/null +++ b/crates/render-demo/README.md @@ -0,0 +1,30 @@ +# render-demo + +Тестовый рендерер Parkan-моделей на Rust (`SDL2 + OpenGL ES 2.0`). + +## Назначение + +- Проверить, что `nres + msh-core + render-core` дают рабочий draw-path на реальных ассетах. +- Служить минимальным reference-приложением. + +## Запуск + +```bash +cargo run -p render-demo --features demo -- \ + --archive "testdata/Parkan - Iron Strategy/animals.rlb" \ + --model "A_L_01.msh" \ + --lod 0 \ + --group 0 +``` + +Параметры: + +- `--archive` (обязательный): NRes-архив с `.msh` entry. +- `--model` (опционально): имя модели; если не задано, берётся первая `.msh`. +- `--lod` (опционально, default `0`). +- `--group` (опционально, default `0`). + +## Ограничения + +- Рендер только геометрии (без материалов/текстур/FX). +- Вывод через `glDrawArrays(GL_TRIANGLES)` из расширенного triangle-list. diff --git a/crates/render-demo/build.rs b/crates/render-demo/build.rs new file mode 100644 index 0000000..126d1d7 --- /dev/null +++ b/crates/render-demo/build.rs @@ -0,0 +1,4 @@ +fn main() { + #[cfg(windows)] + println!("cargo:rustc-link-lib=advapi32"); +} diff --git a/crates/render-demo/src/lib.rs b/crates/render-demo/src/lib.rs new file mode 100644 index 0000000..4c73c09 --- /dev/null +++ b/crates/render-demo/src/lib.rs @@ -0,0 +1,113 @@ +use msh_core::{parse_model_payload, Model}; +use nres::Archive; +use std::path::Path; + +#[derive(Debug)] +pub enum Error { + Nres(nres::error::Error), + Msh(msh_core::error::Error), + NoMshEntries, + ModelNotFound(String), +} + +impl From for Error { + fn from(value: nres::error::Error) -> Self { + Self::Nres(value) + } +} + +impl From for Error { + fn from(value: msh_core::error::Error) -> Self { + Self::Msh(value) + } +} + +pub type Result = core::result::Result; + +pub fn load_model_from_archive(path: &Path, model_name: Option<&str>) -> Result { + let archive = Archive::open_path(path)?; + let mut msh_entries = Vec::new(); + for entry in archive.entries() { + if entry.meta.name.to_ascii_lowercase().ends_with(".msh") { + msh_entries.push((entry.id, entry.meta.name.clone())); + } + } + if msh_entries.is_empty() { + return Err(Error::NoMshEntries); + } + + let target_id = if let Some(name) = model_name { + msh_entries + .iter() + .find(|(_, n)| n.eq_ignore_ascii_case(name)) + .map(|(id, _)| *id) + .ok_or_else(|| Error::ModelNotFound(name.to_string()))? + } else { + msh_entries[0].0 + }; + + let payload = archive.read(target_id)?; + Ok(parse_model_payload(payload.as_slice())?) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::{Path, PathBuf}; + + fn collect_files_recursive(root: &Path, out: &mut Vec) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_files_recursive(&path, out); + } else if path.is_file() { + out.push(path); + } + } + } + + fn archive_with_msh() -> Option { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("testdata"); + let mut files = Vec::new(); + collect_files_recursive(&root, &mut files); + files.sort(); + for path in files { + let Ok(bytes) = fs::read(&path) else { + continue; + }; + if bytes.get(0..4) != Some(b"NRes") { + continue; + } + let Ok(archive) = Archive::open_path(&path) else { + continue; + }; + if archive + .entries() + .any(|entry| entry.meta.name.to_ascii_lowercase().ends_with(".msh")) + { + return Some(path); + } + } + None + } + + #[test] + fn load_model_from_real_archive() { + let Some(path) = archive_with_msh() else { + eprintln!("skipping load_model_from_real_archive: no .msh archives in testdata"); + return; + }; + let model = load_model_from_archive(&path, None) + .unwrap_or_else(|err| panic!("failed to load model from {}: {err:?}", path.display())); + assert!(model.node_count > 0); + assert!(!model.positions.is_empty()); + assert!(!model.indices.is_empty()); + } +} diff --git a/crates/render-demo/src/main.rs b/crates/render-demo/src/main.rs new file mode 100644 index 0000000..c991c80 --- /dev/null +++ b/crates/render-demo/src/main.rs @@ -0,0 +1,357 @@ +use glow::HasContext as _; +use render_core::{build_render_mesh, compute_bounds}; +use render_demo::load_model_from_archive; +use std::path::PathBuf; +use std::time::Instant; + +struct Args { + archive: PathBuf, + model: Option, + lod: usize, + group: usize, +} + +fn parse_args() -> Result { + let mut archive = None; + let mut model = None; + let mut lod = 0usize; + let mut group = 0usize; + + let mut it = std::env::args().skip(1); + while let Some(arg) = it.next() { + match arg.as_str() { + "--archive" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --archive"))?; + archive = Some(PathBuf::from(value)); + } + "--model" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --model"))?; + model = Some(value); + } + "--lod" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --lod"))?; + lod = value + .parse::() + .map_err(|_| String::from("invalid --lod value"))?; + } + "--group" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --group"))?; + group = value + .parse::() + .map_err(|_| String::from("invalid --group value"))?; + } + "--help" | "-h" => { + print_help(); + std::process::exit(0); + } + other => { + return Err(format!("unknown argument: {other}")); + } + } + } + + let archive = archive.ok_or_else(|| String::from("missing required --archive"))?; + Ok(Args { + archive, + model, + lod, + group, + }) +} + +fn print_help() { + eprintln!("parkan-render-demo --archive [--model ] [--lod N] [--group N]"); +} + +fn main() { + let args = match parse_args() { + Ok(v) => v, + Err(err) => { + eprintln!("{err}"); + print_help(); + std::process::exit(2); + } + }; + + let model = match load_model_from_archive(&args.archive, args.model.as_deref()) { + Ok(v) => v, + Err(err) => { + eprintln!("failed to load model: {err:?}"); + std::process::exit(1); + } + }; + + let mesh = build_render_mesh(&model, args.lod, args.group); + if mesh.vertices.is_empty() { + eprintln!( + "model has no renderable triangles for lod={} group={}", + args.lod, args.group + ); + std::process::exit(1); + } + let Some((bounds_min, bounds_max)) = compute_bounds(&mesh.vertices) else { + eprintln!("failed to compute mesh bounds"); + std::process::exit(1); + }; + + let center = [ + 0.5 * (bounds_min[0] + bounds_max[0]), + 0.5 * (bounds_min[1] + bounds_max[1]), + 0.5 * (bounds_min[2] + bounds_max[2]), + ]; + let extent = [ + bounds_max[0] - bounds_min[0], + bounds_max[1] - bounds_min[1], + bounds_max[2] - bounds_min[2], + ]; + let radius = + (extent[0] * extent[0] + extent[1] * extent[1] + extent[2] * extent[2]).sqrt() * 0.5; + let camera_distance = (radius * 2.5).max(2.0); + + let sdl = sdl2::init().expect("failed to init SDL2"); + let video = sdl.video().expect("failed to init SDL2 video"); + + { + let gl_attr = video.gl_attr(); + gl_attr.set_context_profile(sdl2::video::GLProfile::GLES); + gl_attr.set_context_version(2, 0); + gl_attr.set_depth_size(24); + gl_attr.set_double_buffer(true); + } + + let window = video + .window("Parkan Render Demo (SDL2 + OpenGL ES 2.0)", 1280, 720) + .opengl() + .resizable() + .build() + .expect("failed to create window"); + + let gl_ctx = window + .gl_create_context() + .expect("failed to create OpenGL context"); + window + .gl_make_current(&gl_ctx) + .expect("failed to make GL context current"); + let _ = video.gl_set_swap_interval(1); + + let mut vertices_flat = Vec::with_capacity(mesh.vertices.len() * 3); + for pos in &mesh.vertices { + vertices_flat.extend_from_slice(pos); + } + + let gl = unsafe { + glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _) + }; + + let program = unsafe { create_program(&gl).expect("failed to create shader program") }; + let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") }; + let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") }; + let a_pos = a_pos.expect("shader attribute a_pos is missing"); + + let vbo = unsafe { gl.create_buffer().expect("failed to create VBO") }; + unsafe { + gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); + gl.buffer_data_u8_slice( + glow::ARRAY_BUFFER, + cast_slice_u8(&vertices_flat), + glow::STATIC_DRAW, + ); + gl.bind_buffer(glow::ARRAY_BUFFER, None); + } + + let mut events = sdl.event_pump().expect("failed to get SDL event pump"); + let start = Instant::now(); + + 'main_loop: loop { + for event in events.poll_iter() { + match event { + sdl2::event::Event::Quit { .. } => break 'main_loop, + sdl2::event::Event::KeyDown { + keycode: Some(sdl2::keyboard::Keycode::Escape), + .. + } => break 'main_loop, + _ => {} + } + } + + let elapsed = start.elapsed().as_secs_f32(); + let (w, h) = window.size(); + let aspect = (w as f32 / (h.max(1) as f32)).max(0.01); + + let proj = mat4_perspective(60.0_f32.to_radians(), aspect, 0.01, camera_distance * 10.0); + let view = mat4_translation(0.0, 0.0, -camera_distance); + let center_shift = mat4_translation(-center[0], -center[1], -center[2]); + let rot = mat4_rotation_y(elapsed * 0.35); + let model_m = mat4_mul(&rot, ¢er_shift); + let vp = mat4_mul(&view, &model_m); + let mvp = mat4_mul(&proj, &vp); + + unsafe { + gl.viewport(0, 0, w as i32, h as i32); + gl.enable(glow::DEPTH_TEST); + gl.clear_color(0.06, 0.08, 0.12, 1.0); + gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT); + + gl.use_program(Some(program)); + gl.uniform_matrix_4_f32_slice(u_mvp.as_ref(), false, &mvp); + + gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); + gl.enable_vertex_attrib_array(a_pos); + gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 12, 0); + gl.draw_arrays( + glow::TRIANGLES, + 0, + i32::try_from(mesh.vertices.len()).unwrap_or(i32::MAX), + ); + gl.disable_vertex_attrib_array(a_pos); + gl.bind_buffer(glow::ARRAY_BUFFER, None); + gl.use_program(None); + } + + window.gl_swap_window(); + } + + unsafe { + gl.delete_buffer(vbo); + gl.delete_program(program); + } +} + +unsafe fn create_program(gl: &glow::Context) -> Result { + let vs_src = r#" +attribute vec3 a_pos; +uniform mat4 u_mvp; +void main() { + gl_Position = u_mvp * vec4(a_pos, 1.0); +} +"#; + + let fs_src = r#" +precision mediump float; +void main() { + gl_FragColor = vec4(0.85, 0.90, 1.00, 1.0); +} +"#; + + let program = gl.create_program().map_err(|e| e.to_string())?; + let vs = gl + .create_shader(glow::VERTEX_SHADER) + .map_err(|e| e.to_string())?; + let fs = gl + .create_shader(glow::FRAGMENT_SHADER) + .map_err(|e| e.to_string())?; + + gl.shader_source(vs, vs_src); + gl.compile_shader(vs); + if !gl.get_shader_compile_status(vs) { + let log = gl.get_shader_info_log(vs); + gl.delete_shader(vs); + gl.delete_shader(fs); + gl.delete_program(program); + return Err(format!("vertex shader compile failed: {log}")); + } + + gl.shader_source(fs, fs_src); + gl.compile_shader(fs); + if !gl.get_shader_compile_status(fs) { + let log = gl.get_shader_info_log(fs); + gl.delete_shader(vs); + gl.delete_shader(fs); + gl.delete_program(program); + return Err(format!("fragment shader compile failed: {log}")); + } + + gl.attach_shader(program, vs); + gl.attach_shader(program, fs); + gl.link_program(program); + + gl.detach_shader(program, vs); + gl.detach_shader(program, fs); + gl.delete_shader(vs); + gl.delete_shader(fs); + + if !gl.get_program_link_status(program) { + let log = gl.get_program_info_log(program); + gl.delete_program(program); + return Err(format!("program link failed: {log}")); + } + + Ok(program) +} + +fn cast_slice_u8(slice: &[T]) -> &[u8] { + unsafe { std::slice::from_raw_parts(slice.as_ptr() as *const u8, std::mem::size_of_val(slice)) } +} + +fn mat4_identity() -> [f32; 16] { + [ + 1.0, 0.0, 0.0, 0.0, // + 0.0, 1.0, 0.0, 0.0, // + 0.0, 0.0, 1.0, 0.0, // + 0.0, 0.0, 0.0, 1.0, // + ] +} + +fn mat4_translation(x: f32, y: f32, z: f32) -> [f32; 16] { + let mut m = mat4_identity(); + m[12] = x; + m[13] = y; + m[14] = z; + m +} + +fn mat4_rotation_y(rad: f32) -> [f32; 16] { + let c = rad.cos(); + let s = rad.sin(); + [ + c, 0.0, -s, 0.0, // + 0.0, 1.0, 0.0, 0.0, // + s, 0.0, c, 0.0, // + 0.0, 0.0, 0.0, 1.0, // + ] +} + +fn mat4_perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> [f32; 16] { + let f = 1.0 / (0.5 * fovy).tan(); + let nf = 1.0 / (near - far); + [ + f / aspect, + 0.0, + 0.0, + 0.0, + 0.0, + f, + 0.0, + 0.0, + 0.0, + 0.0, + (far + near) * nf, + -1.0, + 0.0, + 0.0, + (2.0 * far * near) * nf, + 0.0, + ] +} + +fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] { + let mut out = [0.0f32; 16]; + for c in 0..4 { + for r in 0..4 { + let mut acc = 0.0f32; + for k in 0..4 { + acc += a[k * 4 + r] * b[c * 4 + k]; + } + out[c * 4 + r] = acc; + } + } + out +} -- cgit v1.2.3