aboutsummaryrefslogtreecommitdiff
path: root/crates/render-demo
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-02-19 03:46:23 +0300
committerValentin Popov <valentin@popov.link>2026-02-19 03:46:23 +0300
commit0e19660eb5122c8c52d5e909927884ad5c50b813 (patch)
tree6a53c24544ca828f08c2b6872d568b1edc1a4cef /crates/render-demo
parent8a69872576eed41a918643be52a80fe74a054974 (diff)
downloadfparkan-0e19660eb5122c8c52d5e909927884ad5c50b813.tar.xz
fparkan-0e19660eb5122c8c52d5e909927884ad5c50b813.zip
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.
Diffstat (limited to 'crates/render-demo')
-rw-r--r--crates/render-demo/Cargo.toml20
-rw-r--r--crates/render-demo/README.md30
-rw-r--r--crates/render-demo/build.rs4
-rw-r--r--crates/render-demo/src/lib.rs113
-rw-r--r--crates/render-demo/src/main.rs357
5 files changed, 524 insertions, 0 deletions
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<nres::error::Error> for Error {
+ fn from(value: nres::error::Error) -> Self {
+ Self::Nres(value)
+ }
+}
+
+impl From<msh_core::error::Error> for Error {
+ fn from(value: msh_core::error::Error) -> Self {
+ Self::Msh(value)
+ }
+}
+
+pub type Result<T> = core::result::Result<T, Error>;
+
+pub fn load_model_from_archive(path: &Path, model_name: Option<&str>) -> Result<Model> {
+ 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<PathBuf>) {
+ 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<PathBuf> {
+ 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<String>,
+ lod: usize,
+ group: usize,
+}
+
+fn parse_args() -> Result<Args, String> {
+ 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::<usize>()
+ .map_err(|_| String::from("invalid --lod value"))?;
+ }
+ "--group" => {
+ let value = it
+ .next()
+ .ok_or_else(|| String::from("missing value for --group"))?;
+ group = value
+ .parse::<usize>()
+ .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 <path> [--model <name.msh>] [--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, &center_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<glow::NativeProgram, String> {
+ 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<T>(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
+}