aboutsummaryrefslogtreecommitdiff
path: root/crates/render-demo
diff options
context:
space:
mode:
Diffstat (limited to 'crates/render-demo')
-rw-r--r--crates/render-demo/Cargo.toml3
-rw-r--r--crates/render-demo/README.md19
-rw-r--r--crates/render-demo/src/main.rs371
3 files changed, 330 insertions, 63 deletions
diff --git a/crates/render-demo/Cargo.toml b/crates/render-demo/Cargo.toml
index 376a25e..aab041d 100644
--- a/crates/render-demo/Cargo.toml
+++ b/crates/render-demo/Cargo.toml
@@ -5,7 +5,7 @@ edition = "2021"
[features]
default = []
-demo = ["dep:sdl2", "dep:glow"]
+demo = ["dep:sdl2", "dep:glow", "dep:image"]
[dependencies]
msh-core = { path = "../msh-core" }
@@ -13,6 +13,7 @@ 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 }
+image = { version = "0.25", optional = true, default-features = false, features = ["png"] }
[[bin]]
name = "parkan-render-demo"
diff --git a/crates/render-demo/README.md b/crates/render-demo/README.md
index b33b18c..0a1fb45 100644
--- a/crates/render-demo/README.md
+++ b/crates/render-demo/README.md
@@ -23,6 +23,25 @@ cargo run -p render-demo --features demo -- \
- `--model` (опционально): имя модели; если не задано, берётся первая `.msh`.
- `--lod` (опционально, default `0`).
- `--group` (опционально, default `0`).
+- `--width`, `--height` (опционально, default `1280x720`).
+- `--angle` (опционально): фиксированный угол поворота вокруг Y (в радианах).
+- `--spin-rate` (опционально, default `0.35`): скорость вращения в интерактивном режиме.
+
+## Детерминированный снимок кадра
+
+Для parity-проверок используется headless-сценарий с фиксированными параметрами:
+
+```bash
+cargo run -p render-demo --features demo -- \
+ --archive "testdata/Parkan - Iron Strategy/animals.rlb" \
+ --model "A_L_01.msh" \
+ --lod 0 \
+ --group 0 \
+ --width 1280 \
+ --height 720 \
+ --angle 0.0 \
+ --capture "target/render-parity/current/animals_a_l_01.png"
+```
## Ограничения
diff --git a/crates/render-demo/src/main.rs b/crates/render-demo/src/main.rs
index c991c80..5bb0a58 100644
--- a/crates/render-demo/src/main.rs
+++ b/crates/render-demo/src/main.rs
@@ -1,7 +1,7 @@
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::path::{Path, PathBuf};
use std::time::Instant;
struct Args {
@@ -9,6 +9,11 @@ struct Args {
model: Option<String>,
lod: usize,
group: usize,
+ width: u32,
+ height: u32,
+ capture: Option<PathBuf>,
+ angle: Option<f32>,
+ spin_rate: f32,
}
fn parse_args() -> Result<Args, String> {
@@ -16,6 +21,11 @@ fn parse_args() -> Result<Args, String> {
let mut model = None;
let mut lod = 0usize;
let mut group = 0usize;
+ let mut width = 1280u32;
+ let mut height = 720u32;
+ let mut capture = None;
+ let mut angle = None;
+ let mut spin_rate = 0.35f32;
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
@@ -48,6 +58,52 @@ fn parse_args() -> Result<Args, String> {
.parse::<usize>()
.map_err(|_| String::from("invalid --group value"))?;
}
+ "--width" => {
+ let value = it
+ .next()
+ .ok_or_else(|| String::from("missing value for --width"))?;
+ width = value
+ .parse::<u32>()
+ .map_err(|_| String::from("invalid --width value"))?;
+ if width == 0 {
+ return Err(String::from("--width must be > 0"));
+ }
+ }
+ "--height" => {
+ let value = it
+ .next()
+ .ok_or_else(|| String::from("missing value for --height"))?;
+ height = value
+ .parse::<u32>()
+ .map_err(|_| String::from("invalid --height value"))?;
+ if height == 0 {
+ return Err(String::from("--height must be > 0"));
+ }
+ }
+ "--capture" => {
+ let value = it
+ .next()
+ .ok_or_else(|| String::from("missing value for --capture"))?;
+ capture = Some(PathBuf::from(value));
+ }
+ "--angle" => {
+ let value = it
+ .next()
+ .ok_or_else(|| String::from("missing value for --angle"))?;
+ angle = Some(
+ value
+ .parse::<f32>()
+ .map_err(|_| String::from("invalid --angle value"))?,
+ );
+ }
+ "--spin-rate" => {
+ let value = it
+ .next()
+ .ok_or_else(|| String::from("missing value for --spin-rate"))?;
+ spin_rate = value
+ .parse::<f32>()
+ .map_err(|_| String::from("invalid --spin-rate value"))?;
+ }
"--help" | "-h" => {
print_help();
std::process::exit(0);
@@ -64,11 +120,19 @@ fn parse_args() -> Result<Args, String> {
model,
lod,
group,
+ width,
+ height,
+ capture,
+ angle,
+ spin_rate,
})
}
fn print_help() {
- eprintln!("parkan-render-demo --archive <path> [--model <name.msh>] [--lod N] [--group N]");
+ eprintln!(
+ "parkan-render-demo --archive <path> [--model <name.msh>] [--lod N] [--group N] [--width W] [--height H]"
+ );
+ eprintln!(" [--capture <out.png>] [--angle RAD] [--spin-rate RAD_PER_SEC]");
}
fn main() {
@@ -81,25 +145,29 @@ fn main() {
}
};
- 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);
- }
- };
+ if let Err(err) = run(args) {
+ eprintln!("{err}");
+ std::process::exit(1);
+ }
+}
+
+fn run(args: Args) -> Result<(), String> {
+ let model = load_model_from_archive(&args.archive, args.model.as_deref()).map_err(|err| {
+ format!(
+ "failed to load model from archive {}: {err:?}",
+ args.archive.display()
+ )
+ })?;
let mesh = build_render_mesh(&model, args.lod, args.group);
if mesh.vertices.is_empty() {
- eprintln!(
+ return Err(format!(
"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);
+ return Err(String::from("failed to compute mesh bounds"));
};
let center = [
@@ -116,8 +184,10 @@ fn main() {
(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 sdl = sdl2::init().map_err(|err| format!("failed to init SDL2: {err}"))?;
+ let video = sdl
+ .video()
+ .map_err(|err| format!("failed to init SDL2 video: {err}"))?;
{
let gl_attr = video.gl_attr();
@@ -127,20 +197,32 @@ fn main() {
gl_attr.set_double_buffer(true);
}
- let window = video
- .window("Parkan Render Demo (SDL2 + OpenGL ES 2.0)", 1280, 720)
- .opengl()
- .resizable()
+ let mut window_builder = video.window(
+ "Parkan Render Demo (SDL2 + OpenGL ES 2.0)",
+ args.width,
+ args.height,
+ );
+ window_builder.opengl();
+ if args.capture.is_some() {
+ window_builder.hidden();
+ } else {
+ window_builder.resizable();
+ }
+ let window = window_builder
.build()
- .expect("failed to create window");
+ .map_err(|err| format!("failed to create window: {err}"))?;
let gl_ctx = window
.gl_create_context()
- .expect("failed to create OpenGL context");
+ .map_err(|err| format!("failed to create OpenGL context: {err}"))?;
window
.gl_make_current(&gl_ctx)
- .expect("failed to make GL context current");
- let _ = video.gl_set_swap_interval(1);
+ .map_err(|err| format!("failed to make GL context current: {err}"))?;
+ let _ = if args.capture.is_some() {
+ video.gl_set_swap_interval(0)
+ } else {
+ video.gl_set_swap_interval(1)
+ };
let mut vertices_flat = Vec::with_capacity(mesh.vertices.len() * 3);
for pos in &mesh.vertices {
@@ -151,12 +233,12 @@ fn main() {
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 program = unsafe { create_program(&gl)? };
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 a_pos = unsafe { gl.get_attrib_location(program, "a_pos") }
+ .ok_or_else(|| String::from("shader attribute a_pos is missing"))?;
- let vbo = unsafe { gl.create_buffer().expect("failed to create VBO") };
+ let vbo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? };
unsafe {
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
gl.buffer_data_u8_slice(
@@ -167,7 +249,95 @@ fn main() {
gl.bind_buffer(glow::ARRAY_BUFFER, None);
}
- let mut events = sdl.event_pump().expect("failed to get SDL event pump");
+ let result = if let Some(capture_path) = args.capture.as_ref() {
+ run_capture(
+ &gl,
+ program,
+ u_mvp.as_ref(),
+ a_pos,
+ vbo,
+ mesh.vertices.len(),
+ &args,
+ center,
+ camera_distance,
+ capture_path,
+ )
+ } else {
+ run_interactive(
+ &sdl,
+ &window,
+ &gl,
+ program,
+ u_mvp.as_ref(),
+ a_pos,
+ vbo,
+ mesh.vertices.len(),
+ &args,
+ center,
+ camera_distance,
+ )
+ };
+
+ unsafe {
+ gl.delete_buffer(vbo);
+ gl.delete_program(program);
+ }
+
+ result
+}
+
+#[allow(clippy::too_many_arguments)]
+fn run_capture(
+ gl: &glow::Context,
+ program: glow::NativeProgram,
+ u_mvp: Option<&glow::NativeUniformLocation>,
+ a_pos: u32,
+ vbo: glow::NativeBuffer,
+ vertex_count: usize,
+ args: &Args,
+ center: [f32; 3],
+ camera_distance: f32,
+ capture_path: &Path,
+) -> Result<(), String> {
+ let angle = args.angle.unwrap_or(0.0);
+ let mvp = compute_mvp(args.width, args.height, center, camera_distance, angle);
+ unsafe {
+ draw_frame(
+ gl,
+ program,
+ u_mvp,
+ a_pos,
+ vbo,
+ vertex_count,
+ args.width,
+ args.height,
+ &mvp,
+ );
+ }
+ let mut rgba = unsafe { read_pixels_rgba(gl, args.width, args.height)? };
+ flip_image_y_rgba(&mut rgba, args.width as usize, args.height as usize);
+ save_png(capture_path, args.width, args.height, rgba)?;
+ println!("captured frame to {}", capture_path.display());
+ Ok(())
+}
+
+#[allow(clippy::too_many_arguments)]
+fn run_interactive(
+ sdl: &sdl2::Sdl,
+ window: &sdl2::video::Window,
+ gl: &glow::Context,
+ program: glow::NativeProgram,
+ u_mvp: Option<&glow::NativeUniformLocation>,
+ a_pos: u32,
+ vbo: glow::NativeBuffer,
+ vertex_count: usize,
+ args: &Args,
+ center: [f32; 3],
+ camera_distance: f32,
+) -> Result<(), String> {
+ let mut events = sdl
+ .event_pump()
+ .map_err(|err| format!("failed to get SDL event pump: {err}"))?;
let start = Instant::now();
'main_loop: loop {
@@ -182,47 +352,124 @@ fn main() {
}
}
- 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);
+ let angle = args
+ .angle
+ .unwrap_or(start.elapsed().as_secs_f32() * args.spin_rate);
+ let mvp = compute_mvp(w, h, center, camera_distance, angle);
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);
+ draw_frame(gl, program, u_mvp, a_pos, vbo, vertex_count, w, h, &mvp);
}
-
window.gl_swap_window();
}
- unsafe {
- gl.delete_buffer(vbo);
- gl.delete_program(program);
+ Ok(())
+}
+
+fn compute_mvp(
+ width: u32,
+ height: u32,
+ center: [f32; 3],
+ camera_distance: f32,
+ angle_rad: f32,
+) -> [f32; 16] {
+ let aspect = (width as f32 / (height.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(angle_rad);
+ let model_m = mat4_mul(&rot, &center_shift);
+ let vp = mat4_mul(&view, &model_m);
+ mat4_mul(&proj, &vp)
+}
+
+#[allow(clippy::too_many_arguments)]
+unsafe fn draw_frame(
+ gl: &glow::Context,
+ program: glow::NativeProgram,
+ u_mvp: Option<&glow::NativeUniformLocation>,
+ a_pos: u32,
+ vbo: glow::NativeBuffer,
+ vertex_count: usize,
+ width: u32,
+ height: u32,
+ mvp: &[f32; 16],
+) {
+ gl.viewport(
+ 0,
+ 0,
+ width.min(i32::MAX as u32) as i32,
+ height.min(i32::MAX as u32) 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, 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,
+ vertex_count.min(i32::MAX as usize) as i32,
+ );
+ gl.disable_vertex_attrib_array(a_pos);
+ gl.bind_buffer(glow::ARRAY_BUFFER, None);
+ gl.use_program(None);
+}
+
+unsafe fn read_pixels_rgba(gl: &glow::Context, width: u32, height: u32) -> Result<Vec<u8>, String> {
+ let pixel_count = usize::try_from(width)
+ .ok()
+ .and_then(|w| usize::try_from(height).ok().map(|h| w.saturating_mul(h)))
+ .ok_or_else(|| String::from("frame dimensions are too large"))?;
+ let mut pixels = vec![0u8; pixel_count.saturating_mul(4)];
+ gl.read_pixels(
+ 0,
+ 0,
+ width.min(i32::MAX as u32) as i32,
+ height.min(i32::MAX as u32) as i32,
+ glow::RGBA,
+ glow::UNSIGNED_BYTE,
+ glow::PixelPackData::Slice(Some(pixels.as_mut_slice())),
+ );
+ Ok(pixels)
+}
+
+fn flip_image_y_rgba(rgba: &mut [u8], width: usize, height: usize) {
+ let stride = width.saturating_mul(4);
+ if stride == 0 {
+ return;
+ }
+ for y in 0..(height / 2) {
+ let top = y * stride;
+ let bottom = (height - 1 - y) * stride;
+ for i in 0..stride {
+ rgba.swap(top + i, bottom + i);
+ }
+ }
+}
+
+fn save_png(path: &Path, width: u32, height: u32, rgba: Vec<u8>) -> Result<(), String> {
+ if let Some(parent) = path.parent() {
+ if !parent.as_os_str().is_empty() {
+ std::fs::create_dir_all(parent).map_err(|err| {
+ format!(
+ "failed to create output directory {}: {err}",
+ parent.display()
+ )
+ })?;
+ }
}
+ let image = image::RgbaImage::from_raw(width, height, rgba)
+ .ok_or_else(|| String::from("failed to build image from framebuffer bytes"))?;
+ image
+ .save(path)
+ .map_err(|err| format!("failed to save PNG {}: {err}", path.display()))
}
unsafe fn create_program(gl: &glow::Context) -> Result<glow::NativeProgram, String> {