aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitea/workflows/test.yml28
-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
-rw-r--r--crates/render-parity/Cargo.toml9
-rw-r--r--crates/render-parity/README.md16
-rw-r--r--crates/render-parity/src/lib.rs212
-rw-r--r--crates/render-parity/src/main.rs405
-rw-r--r--docs/specs/render-parity.md77
-rw-r--r--mkdocs.yml1
-rw-r--r--parity/README.md20
-rw-r--r--parity/cases.toml27
-rw-r--r--parity/reference/.gitkeep0
13 files changed, 1125 insertions, 63 deletions
diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml
index cf314cb..e9cfc2e 100644
--- a/.gitea/workflows/test.yml
+++ b/.gitea/workflows/test.yml
@@ -25,3 +25,31 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- name: Cargo test
run: cargo test --workspace --all-features -- --nocapture
+
+ render-parity:
+ name: Render parity
+ runs-on: ubuntu-latest
+ needs: test
+ steps:
+ - uses: actions/checkout@v6
+ - uses: dtolnay/rust-toolchain@stable
+ - name: Install headless GL runtime
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y xvfb libgl1-mesa-dri libgles2-mesa-dev mesa-utils
+ - name: Build render-demo binary
+ run: cargo build -p render-demo --features demo
+ - name: Run frame parity suite
+ run: |
+ xvfb-run -s "-screen 0 1280x720x24" cargo run -p render-parity -- \
+ --manifest parity/cases.toml \
+ --output-dir target/render-parity/current \
+ --demo-bin target/debug/parkan-render-demo \
+ --keep-going
+ - name: Upload parity artifacts
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: render-parity-artifacts
+ path: target/render-parity/current
+ if-no-files-found: ignore
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> {
diff --git a/crates/render-parity/Cargo.toml b/crates/render-parity/Cargo.toml
new file mode 100644
index 0000000..70c7ac3
--- /dev/null
+++ b/crates/render-parity/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "render-parity"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+image = { version = "0.25", default-features = false, features = ["png"] }
+serde = { version = "1", features = ["derive"] }
+toml = "0.8"
diff --git a/crates/render-parity/README.md b/crates/render-parity/README.md
new file mode 100644
index 0000000..a94520e
--- /dev/null
+++ b/crates/render-parity/README.md
@@ -0,0 +1,16 @@
+# render-parity
+
+Deterministic frame-diff runner for `parkan-render-demo`.
+
+Usage:
+
+```bash
+cargo run -p render-parity -- \
+ --manifest parity/cases.toml \
+ --output-dir target/render-parity/current
+```
+
+Options:
+
+- `--demo-bin <path>`: use prebuilt `parkan-render-demo` binary instead of `cargo run`.
+- `--keep-going`: continue all cases even after failures.
diff --git a/crates/render-parity/src/lib.rs b/crates/render-parity/src/lib.rs
new file mode 100644
index 0000000..cb412e9
--- /dev/null
+++ b/crates/render-parity/src/lib.rs
@@ -0,0 +1,212 @@
+use image::{ImageBuffer, Rgba, RgbaImage};
+use serde::Deserialize;
+
+#[derive(Debug, Clone, Deserialize, Default)]
+pub struct ManifestMeta {
+ pub width: Option<u32>,
+ pub height: Option<u32>,
+ pub lod: Option<usize>,
+ pub group: Option<usize>,
+ pub angle: Option<f32>,
+ pub diff_threshold: Option<u8>,
+ pub max_mean_abs: Option<f32>,
+ pub max_changed_ratio: Option<f32>,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct CaseSpec {
+ pub id: String,
+ pub archive: String,
+ pub model: Option<String>,
+ pub reference: String,
+ pub width: Option<u32>,
+ pub height: Option<u32>,
+ pub lod: Option<usize>,
+ pub group: Option<usize>,
+ pub angle: Option<f32>,
+ pub diff_threshold: Option<u8>,
+ pub max_mean_abs: Option<f32>,
+ pub max_changed_ratio: Option<f32>,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct ParityManifest {
+ #[serde(default)]
+ pub meta: ManifestMeta,
+ #[serde(rename = "case", default)]
+ pub cases: Vec<CaseSpec>,
+}
+
+#[derive(Debug, Clone)]
+pub struct DiffMetrics {
+ pub width: u32,
+ pub height: u32,
+ pub mean_abs: f32,
+ pub max_abs: u8,
+ pub changed_pixels: u64,
+ pub changed_ratio: f32,
+}
+
+pub fn compare_images(
+ reference: &RgbaImage,
+ actual: &RgbaImage,
+ diff_threshold: u8,
+) -> Result<DiffMetrics, String> {
+ let (rw, rh) = reference.dimensions();
+ let (aw, ah) = actual.dimensions();
+ if rw != aw || rh != ah {
+ return Err(format!(
+ "image size mismatch: reference={}x{}, actual={}x{}",
+ rw, rh, aw, ah
+ ));
+ }
+
+ let mut diff_sum = 0u64;
+ let mut max_abs = 0u8;
+ let mut changed_pixels = 0u64;
+ let pixel_count = u64::from(rw).saturating_mul(u64::from(rh));
+
+ for (ref_px, act_px) in reference.pixels().zip(actual.pixels()) {
+ let mut pixel_changed = false;
+ for chan in 0..3 {
+ let a = i16::from(ref_px[chan]);
+ let b = i16::from(act_px[chan]);
+ let diff = (a - b).unsigned_abs() as u8;
+ diff_sum = diff_sum.saturating_add(u64::from(diff));
+ if diff > max_abs {
+ max_abs = diff;
+ }
+ if diff > diff_threshold {
+ pixel_changed = true;
+ }
+ }
+ if pixel_changed {
+ changed_pixels = changed_pixels.saturating_add(1);
+ }
+ }
+
+ let channels = pixel_count.saturating_mul(3);
+ let mean_abs = if channels == 0 {
+ 0.0
+ } else {
+ diff_sum as f32 / channels as f32
+ };
+ let changed_ratio = if pixel_count == 0 {
+ 0.0
+ } else {
+ changed_pixels as f32 / pixel_count as f32
+ };
+
+ Ok(DiffMetrics {
+ width: rw,
+ height: rh,
+ mean_abs,
+ max_abs,
+ changed_pixels,
+ changed_ratio,
+ })
+}
+
+pub fn build_diff_image(reference: &RgbaImage, actual: &RgbaImage) -> Result<RgbaImage, String> {
+ let (rw, rh) = reference.dimensions();
+ let (aw, ah) = actual.dimensions();
+ if rw != aw || rh != ah {
+ return Err(format!(
+ "image size mismatch: reference={}x{}, actual={}x{}",
+ rw, rh, aw, ah
+ ));
+ }
+
+ let mut out: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(rw, rh);
+ for (dst, (ref_px, act_px)) in out
+ .pixels_mut()
+ .zip(reference.pixels().zip(actual.pixels()))
+ {
+ let dr = (i16::from(ref_px[0]) - i16::from(act_px[0])).unsigned_abs() as u8;
+ let dg = (i16::from(ref_px[1]) - i16::from(act_px[1])).unsigned_abs() as u8;
+ let db = (i16::from(ref_px[2]) - i16::from(act_px[2])).unsigned_abs() as u8;
+ *dst = Rgba([dr, dg, db, 255]);
+ }
+ Ok(out)
+}
+
+pub fn evaluate_metrics(
+ metrics: &DiffMetrics,
+ max_mean_abs: f32,
+ max_changed_ratio: f32,
+) -> Vec<String> {
+ let mut violations = Vec::new();
+ if metrics.mean_abs > max_mean_abs {
+ violations.push(format!(
+ "mean_abs {:.4} > allowed {:.4}",
+ metrics.mean_abs, max_mean_abs
+ ));
+ }
+ if metrics.changed_ratio > max_changed_ratio {
+ violations.push(format!(
+ "changed_ratio {:.4}% > allowed {:.4}%",
+ metrics.changed_ratio * 100.0,
+ max_changed_ratio * 100.0
+ ));
+ }
+ violations
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn solid(w: u32, h: u32, r: u8, g: u8, b: u8) -> RgbaImage {
+ let mut img = RgbaImage::new(w, h);
+ for px in img.pixels_mut() {
+ *px = Rgba([r, g, b, 255]);
+ }
+ img
+ }
+
+ #[test]
+ fn compare_identical_images() {
+ let ref_img = solid(4, 3, 10, 20, 30);
+ let act_img = solid(4, 3, 10, 20, 30);
+ let metrics = compare_images(&ref_img, &act_img, 2).expect("comparison must succeed");
+ assert_eq!(metrics.width, 4);
+ assert_eq!(metrics.height, 3);
+ assert_eq!(metrics.max_abs, 0);
+ assert_eq!(metrics.changed_pixels, 0);
+ assert_eq!(metrics.mean_abs, 0.0);
+ assert_eq!(metrics.changed_ratio, 0.0);
+ }
+
+ #[test]
+ fn compare_detects_changes_and_thresholds() {
+ let mut ref_img = solid(2, 2, 100, 100, 100);
+ let mut act_img = solid(2, 2, 100, 100, 100);
+ ref_img.put_pixel(1, 1, Rgba([120, 100, 100, 255]));
+ act_img.put_pixel(1, 1, Rgba([100, 100, 100, 255]));
+
+ let metrics = compare_images(&ref_img, &act_img, 5).expect("comparison must succeed");
+ assert_eq!(metrics.max_abs, 20);
+ assert_eq!(metrics.changed_pixels, 1);
+ assert!((metrics.changed_ratio - 0.25).abs() < 1e-6);
+ assert!(metrics.mean_abs > 0.0);
+
+ let violations = evaluate_metrics(&metrics, 2.0, 0.20);
+ assert_eq!(violations.len(), 1);
+ assert!(violations[0].contains("changed_ratio"));
+ }
+
+ #[test]
+ fn build_diff_image_returns_per_channel_abs_diff() {
+ let mut ref_img = solid(1, 1, 100, 150, 200);
+ let mut act_img = solid(1, 1, 90, 180, 170);
+ ref_img.put_pixel(0, 0, Rgba([100, 150, 200, 255]));
+ act_img.put_pixel(0, 0, Rgba([90, 180, 170, 255]));
+
+ let diff = build_diff_image(&ref_img, &act_img).expect("diff image must build");
+ let px = diff.get_pixel(0, 0);
+ assert_eq!(px[0], 10);
+ assert_eq!(px[1], 30);
+ assert_eq!(px[2], 30);
+ assert_eq!(px[3], 255);
+ }
+}
diff --git a/crates/render-parity/src/main.rs b/crates/render-parity/src/main.rs
new file mode 100644
index 0000000..22795bc
--- /dev/null
+++ b/crates/render-parity/src/main.rs
@@ -0,0 +1,405 @@
+use image::RgbaImage;
+use render_parity::{
+ build_diff_image, compare_images, evaluate_metrics, CaseSpec, ManifestMeta, ParityManifest,
+};
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+const DEFAULT_MANIFEST: &str = "parity/cases.toml";
+const DEFAULT_OUTPUT_DIR: &str = "target/render-parity/current";
+const DEFAULT_WIDTH: u32 = 1280;
+const DEFAULT_HEIGHT: u32 = 720;
+const DEFAULT_LOD: usize = 0;
+const DEFAULT_GROUP: usize = 0;
+const DEFAULT_ANGLE: f32 = 0.0;
+const DEFAULT_DIFF_THRESHOLD: u8 = 8;
+const DEFAULT_MAX_MEAN_ABS: f32 = 2.0;
+const DEFAULT_MAX_CHANGED_RATIO: f32 = 0.01;
+
+struct Args {
+ manifest: PathBuf,
+ output_dir: PathBuf,
+ demo_bin: Option<PathBuf>,
+ keep_going: bool,
+}
+
+#[derive(Debug, Clone)]
+struct EffectiveCase {
+ id: String,
+ archive: PathBuf,
+ model: Option<String>,
+ reference: PathBuf,
+ width: u32,
+ height: u32,
+ lod: usize,
+ group: usize,
+ angle: f32,
+ diff_threshold: u8,
+ max_mean_abs: f32,
+ max_changed_ratio: f32,
+}
+
+fn main() {
+ let args = match parse_args() {
+ Ok(v) => v,
+ Err(err) => {
+ eprintln!("{err}");
+ print_help();
+ std::process::exit(2);
+ }
+ };
+
+ if let Err(err) = run(args) {
+ eprintln!("{err}");
+ std::process::exit(1);
+ }
+}
+
+fn parse_args() -> Result<Args, String> {
+ let mut manifest = PathBuf::from(DEFAULT_MANIFEST);
+ let mut output_dir = PathBuf::from(DEFAULT_OUTPUT_DIR);
+ let mut demo_bin = None;
+ let mut keep_going = false;
+
+ let mut it = std::env::args().skip(1);
+ while let Some(arg) = it.next() {
+ match arg.as_str() {
+ "--manifest" => {
+ let value = it
+ .next()
+ .ok_or_else(|| String::from("missing value for --manifest"))?;
+ manifest = PathBuf::from(value);
+ }
+ "--output-dir" => {
+ let value = it
+ .next()
+ .ok_or_else(|| String::from("missing value for --output-dir"))?;
+ output_dir = PathBuf::from(value);
+ }
+ "--demo-bin" => {
+ let value = it
+ .next()
+ .ok_or_else(|| String::from("missing value for --demo-bin"))?;
+ demo_bin = Some(PathBuf::from(value));
+ }
+ "--keep-going" => {
+ keep_going = true;
+ }
+ "--help" | "-h" => {
+ print_help();
+ std::process::exit(0);
+ }
+ other => {
+ return Err(format!("unknown argument: {other}"));
+ }
+ }
+ }
+
+ Ok(Args {
+ manifest,
+ output_dir,
+ demo_bin,
+ keep_going,
+ })
+}
+
+fn print_help() {
+ eprintln!(
+ "render-parity [--manifest <cases.toml>] [--output-dir <dir>] [--demo-bin <path>] [--keep-going]"
+ );
+ eprintln!(" --manifest path to parity manifest (default: {DEFAULT_MANIFEST})");
+ eprintln!(" --output-dir where current renders and diff images are written");
+ eprintln!(" --demo-bin prebuilt parkan-render-demo binary path");
+ eprintln!(" --keep-going continue all cases even after failures");
+}
+
+fn run(args: Args) -> Result<(), String> {
+ let workspace = workspace_root()?;
+ let manifest_path = resolve_path(&workspace, &args.manifest);
+ let output_dir = resolve_path(&workspace, &args.output_dir);
+ let demo_bin = args
+ .demo_bin
+ .as_ref()
+ .map(|path| resolve_path(&workspace, path));
+
+ let manifest_raw = fs::read_to_string(&manifest_path)
+ .map_err(|err| format!("failed to read manifest {}: {err}", manifest_path.display()))?;
+ let manifest: ParityManifest = toml::from_str(&manifest_raw).map_err(|err| {
+ format!(
+ "failed to parse manifest {}: {err}",
+ manifest_path.display()
+ )
+ })?;
+
+ if manifest.cases.is_empty() {
+ println!(
+ "render-parity: no cases in {} (nothing to validate)",
+ manifest_path.display()
+ );
+ return Ok(());
+ }
+
+ fs::create_dir_all(&output_dir).map_err(|err| {
+ format!(
+ "failed to create output directory {}: {err}",
+ output_dir.display()
+ )
+ })?;
+
+ let manifest_dir = manifest_path
+ .parent()
+ .map(Path::to_path_buf)
+ .unwrap_or_else(|| workspace.clone());
+
+ let mut failed_cases = 0usize;
+ for case in &manifest.cases {
+ let effective = make_effective_case(&manifest.meta, case, &manifest_dir)?;
+ let case_file = output_dir.join(format!("{}.png", sanitize_case_id(&effective.id)));
+ let diff_file = output_dir
+ .join("diff")
+ .join(format!("{}.png", sanitize_case_id(&effective.id)));
+
+ let run_res = run_single_case(
+ &workspace, // ensure `cargo run` executes from workspace root
+ demo_bin.as_deref(),
+ &effective,
+ &case_file,
+ &diff_file,
+ );
+
+ match run_res {
+ Ok(()) => {}
+ Err(err) => {
+ failed_cases = failed_cases.saturating_add(1);
+ eprintln!("[FAIL] {}: {}", effective.id, err);
+ if !args.keep_going {
+ break;
+ }
+ }
+ }
+ }
+
+ if failed_cases > 0 {
+ return Err(format!(
+ "render-parity failed: {} case(s) did not match reference frames",
+ failed_cases
+ ));
+ }
+
+ println!("render-parity: all cases passed");
+ Ok(())
+}
+
+fn run_single_case(
+ workspace: &Path,
+ demo_bin: Option<&Path>,
+ case: &EffectiveCase,
+ case_file: &Path,
+ diff_file: &Path,
+) -> Result<(), String> {
+ run_render_capture(workspace, demo_bin, case, case_file)?;
+
+ let reference = load_rgba(&case.reference)?;
+ let actual = load_rgba(case_file)?;
+ let metrics = compare_images(&reference, &actual, case.diff_threshold)?;
+ let violations = evaluate_metrics(&metrics, case.max_mean_abs, case.max_changed_ratio);
+
+ if violations.is_empty() {
+ println!(
+ "[OK] {} mean_abs={:.4} changed={:.4}% max_abs={} ({}x{})",
+ case.id,
+ metrics.mean_abs,
+ metrics.changed_ratio * 100.0,
+ metrics.max_abs,
+ metrics.width,
+ metrics.height
+ );
+ return Ok(());
+ }
+
+ if let Some(parent) = diff_file.parent() {
+ fs::create_dir_all(parent).map_err(|err| {
+ format!(
+ "failed to create diff output directory {}: {err}",
+ parent.display()
+ )
+ })?;
+ }
+ let diff = build_diff_image(&reference, &actual)?;
+ diff.save(diff_file)
+ .map_err(|err| format!("failed to save diff image {}: {err}", diff_file.display()))?;
+
+ let mut details = String::new();
+ for item in violations {
+ if !details.is_empty() {
+ details.push_str("; ");
+ }
+ details.push_str(&item);
+ }
+ Err(format!(
+ "{} | diff={} | mean_abs={:.4}, changed={:.4}% ({} px), max_abs={}",
+ details,
+ diff_file.display(),
+ metrics.mean_abs,
+ metrics.changed_ratio * 100.0,
+ metrics.changed_pixels,
+ metrics.max_abs
+ ))
+}
+
+fn run_render_capture(
+ workspace: &Path,
+ demo_bin: Option<&Path>,
+ case: &EffectiveCase,
+ out_path: &Path,
+) -> Result<(), String> {
+ if let Some(parent) = out_path.parent() {
+ fs::create_dir_all(parent).map_err(|err| {
+ format!(
+ "failed to create capture directory {}: {err}",
+ parent.display()
+ )
+ })?;
+ }
+
+ let mut cmd = if let Some(bin) = demo_bin {
+ Command::new(bin)
+ } else {
+ let mut command = Command::new("cargo");
+ command.args(["run", "-p", "render-demo", "--features", "demo", "--"]);
+ command
+ };
+
+ cmd.current_dir(workspace)
+ .arg("--archive")
+ .arg(&case.archive)
+ .arg("--lod")
+ .arg(case.lod.to_string())
+ .arg("--group")
+ .arg(case.group.to_string())
+ .arg("--width")
+ .arg(case.width.to_string())
+ .arg("--height")
+ .arg(case.height.to_string())
+ .arg("--angle")
+ .arg(case.angle.to_string())
+ .arg("--capture")
+ .arg(out_path);
+
+ if let Some(model) = case.model.as_deref() {
+ cmd.arg("--model").arg(model);
+ }
+
+ let output = cmd.output().map_err(|err| {
+ let mode = if demo_bin.is_some() {
+ "parkan-render-demo"
+ } else {
+ "cargo run -p render-demo"
+ };
+ format!("failed to execute {} for case {}: {err}", mode, case.id)
+ })?;
+ if !output.status.success() {
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(format!(
+ "render command exited with status {:?}\nstdout:\n{}\nstderr:\n{}",
+ output.status.code(),
+ stdout,
+ stderr
+ ));
+ }
+
+ Ok(())
+}
+
+fn load_rgba(path: &Path) -> Result<RgbaImage, String> {
+ image::open(path)
+ .map_err(|err| format!("failed to load image {}: {err}", path.display()))
+ .map(|img| img.to_rgba8())
+}
+
+fn make_effective_case(
+ meta: &ManifestMeta,
+ case: &CaseSpec,
+ manifest_dir: &Path,
+) -> Result<EffectiveCase, String> {
+ let width = case.width.or(meta.width).unwrap_or(DEFAULT_WIDTH);
+ let height = case.height.or(meta.height).unwrap_or(DEFAULT_HEIGHT);
+ if width == 0 || height == 0 {
+ return Err(format!(
+ "case '{}' has invalid dimensions {}x{}",
+ case.id, width, height
+ ));
+ }
+
+ let archive = resolve_path(manifest_dir, Path::new(&case.archive));
+ let reference = resolve_path(manifest_dir, Path::new(&case.reference));
+ if !archive.is_file() {
+ return Err(format!(
+ "case '{}' archive not found: {}",
+ case.id,
+ archive.display()
+ ));
+ }
+ if !reference.is_file() {
+ return Err(format!(
+ "case '{}' reference frame not found: {}",
+ case.id,
+ reference.display()
+ ));
+ }
+
+ Ok(EffectiveCase {
+ id: case.id.clone(),
+ archive,
+ model: case.model.clone(),
+ reference,
+ width,
+ height,
+ lod: case.lod.or(meta.lod).unwrap_or(DEFAULT_LOD),
+ group: case.group.or(meta.group).unwrap_or(DEFAULT_GROUP),
+ angle: case.angle.or(meta.angle).unwrap_or(DEFAULT_ANGLE),
+ diff_threshold: case
+ .diff_threshold
+ .or(meta.diff_threshold)
+ .unwrap_or(DEFAULT_DIFF_THRESHOLD),
+ max_mean_abs: case
+ .max_mean_abs
+ .or(meta.max_mean_abs)
+ .unwrap_or(DEFAULT_MAX_MEAN_ABS),
+ max_changed_ratio: case
+ .max_changed_ratio
+ .or(meta.max_changed_ratio)
+ .unwrap_or(DEFAULT_MAX_CHANGED_RATIO),
+ })
+}
+
+fn sanitize_case_id(id: &str) -> String {
+ id.chars()
+ .map(|c| {
+ if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
+ c
+ } else {
+ '_'
+ }
+ })
+ .collect()
+}
+
+fn workspace_root() -> Result<PathBuf, String> {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("..")
+ .join("..")
+ .canonicalize()
+ .map_err(|err| format!("failed to resolve workspace root: {err}"))?;
+ Ok(root)
+}
+
+fn resolve_path(base: &Path, path: &Path) -> PathBuf {
+ if path.is_absolute() {
+ path.to_path_buf()
+ } else {
+ base.join(path)
+ }
+}
diff --git a/docs/specs/render-parity.md b/docs/specs/render-parity.md
new file mode 100644
index 0000000..5c63c13
--- /dev/null
+++ b/docs/specs/render-parity.md
@@ -0,0 +1,77 @@
+# Рендер-паритет (кадровый diff)
+
+Документ описывает процесс проверки соответствия рендера:
+`оригинальный движок -> эталонный кадр -> render-demo -> diff-метрики`.
+
+## Цель
+
+- Зафиксировать объективный критерий "паритет достигнут / не достигнут".
+- Убрать субъективную визуальную оценку "похоже/не похоже".
+- Дать CI-проверку, которая ловит регрессии сразу после коммита.
+
+## Единица проверки
+
+Один тест-кейс = один объект (одна модель) + фиксированная конфигурация:
+
+- архив ресурса;
+- имя модели;
+- `lod`;
+- `group`;
+- размер кадра (`width`, `height`);
+- угол камеры (`angle`);
+- PNG-эталон из оригинального рендера.
+
+## Инварианты детерминизма
+
+Для корректного сравнения кадры должны быть сняты в одинаковых условиях:
+
+- одинаковый FOV и расстояние камеры до объекта;
+- одинаковый clear-color/фон;
+- одинаковые `lod/group`;
+- фиксированный угол (`angle`), без анимации;
+- фиксированное разрешение.
+
+## Метрики сравнения
+
+Сравнение выполняется по RGB-каналам:
+
+- `mean_abs`: средняя абсолютная разница канала (0..255);
+- `max_abs`: максимальная разница канала;
+- `changed_ratio`: доля пикселей, где хотя бы один канал превышает `diff_threshold`.
+
+Кейс считается пройденным, если:
+
+- `mean_abs <= max_mean_abs`;
+- `changed_ratio <= max_changed_ratio`.
+
+## Конфигурация кейсов
+
+Файл: `parity/cases.toml`.
+
+- секция `[meta]`: глобальные дефолты;
+- `[[case]]`: параметры конкретной модели и путь к эталонному PNG.
+
+Эталонные кадры хранятся в `parity/reference/`.
+
+## Локальный запуск
+
+```bash
+cargo run -p render-parity -- \
+ --manifest parity/cases.toml \
+ --output-dir target/render-parity/current
+```
+
+При расхождении утилита пишет diff-изображение в:
+
+- `target/render-parity/current/diff/<case>.png`
+
+## CI-модель
+
+CI запускает `render-parity` на каждом push/PR:
+
+1. собирает `parkan-render-demo`;
+2. прогоняет кейсы из `cases.toml`;
+3. при падении публикует текущие кадры и diff как артефакт.
+
+Важно: оригинальный движок в CI обычно не запускается.
+Эталонные PNG снимаются офлайн и версионируются в репозитории.
diff --git a/mkdocs.yml b/mkdocs.yml
index cf0907b..c7bf965 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -39,6 +39,7 @@ nav:
- Network system: specs/network.md
- NRes / RsLi: specs/nres.md
- Render pipeline: specs/render.md
+ - Render parity: specs/render-parity.md
- Runtime pointer: specs/runtime-pipeline.md
- Sound system: specs/sound.md
- Terrain + map loading: specs/terrain-map-loading.md
diff --git a/parity/README.md b/parity/README.md
new file mode 100644
index 0000000..dd338bc
--- /dev/null
+++ b/parity/README.md
@@ -0,0 +1,20 @@
+# Render Parity Dataset
+
+This folder stores parity-test input for `crates/render-parity`.
+
+- `cases.toml`: list of deterministic render cases.
+- `reference/*.png`: baseline frames captured from the original renderer.
+
+Expected workflow:
+
+1. Capture baseline PNG frames from original game/editor for each case.
+2. Add entries to `cases.toml`.
+3. Run:
+
+```bash
+cargo run -p render-parity -- \
+ --manifest parity/cases.toml \
+ --output-dir target/render-parity/current
+```
+
+On failure, diff images are saved to `target/render-parity/current/diff`.
diff --git a/parity/cases.toml b/parity/cases.toml
new file mode 100644
index 0000000..62bb0e3
--- /dev/null
+++ b/parity/cases.toml
@@ -0,0 +1,27 @@
+[meta]
+# Global defaults for all cases.
+width = 1280
+height = 720
+lod = 0
+group = 0
+angle = 0.0
+
+# Per-pixel change threshold for the "changed pixel ratio" metric.
+diff_threshold = 8
+
+# Allowed thresholds (case fails if any limit is exceeded).
+max_mean_abs = 2.0
+max_changed_ratio = 0.010
+
+# Add one block per model.
+#
+# [[case]]
+# id = "animals_a_l_01"
+# archive = "../testdata/Parkan - Iron Strategy/animals.rlb"
+# model = "A_L_01.msh"
+# reference = "reference/animals_a_l_01.png"
+# lod = 0
+# group = 0
+# angle = 0.0
+# max_mean_abs = 2.0
+# max_changed_ratio = 0.010
diff --git a/parity/reference/.gitkeep b/parity/reference/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/parity/reference/.gitkeep