aboutsummaryrefslogtreecommitdiff
path: root/crates/render-demo/src
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-02-19 12:46:23 +0300
committerValentin Popov <valentin@popov.link>2026-02-19 12:46:23 +0300
commitefab61a45c8837d3c2aaec464d8f6243fecb7a38 (patch)
treeb511f1cab917f5f2931d6bc2ae2676b553bb8ef9 /crates/render-demo/src
parent0d7ae6a017b8b2bf26c5c14c39cb62b599e8262d (diff)
downloadfparkan-efab61a45c8837d3c2aaec464d8f6243fecb7a38.tar.xz
fparkan-efab61a45c8837d3c2aaec464d8f6243fecb7a38.zip
feat(render-core): add default UV scale and refactor UV mapping logic
- Introduced a constant `DEFAULT_UV_SCALE` for UV scaling. - Refactored UV mapping in `build_render_mesh` to use the new constant. - Simplified `compute_bounds` functions by extracting common logic into `compute_bounds_impl`. test(render-core): add tests for rendering with empty and multi-node models - Added tests to verify behavior when building render meshes from models with no slots and multiple nodes. - Ensured UV scaling is correctly applied in tests. feat(render-demo): add FOV argument and improve error handling - Added a `--fov` command-line argument to set the field of view. - Enhanced error messages for texture resolution failures. - Updated MVP computation to use the new FOV parameter. fix(rsli): improve error handling in LZH decompression - Added checks to prevent out-of-bounds access in LZH decoding logic. refactor(texm): streamline texture parsing and decoding tests - Created a helper function `build_texm_payload` for constructing test payloads. - Added tests for various texture formats including RGB565, RGB556, ARGB4444, and Luminance Alpha. - Improved error handling for invalid TEXM headers and mip bounds.
Diffstat (limited to 'crates/render-demo/src')
-rw-r--r--crates/render-demo/src/lib.rs85
-rw-r--r--crates/render-demo/src/main.rs53
2 files changed, 118 insertions, 20 deletions
diff --git a/crates/render-demo/src/lib.rs b/crates/render-demo/src/lib.rs
index c5c72b5..c82e055 100644
--- a/crates/render-demo/src/lib.rs
+++ b/crates/render-demo/src/lib.rs
@@ -1,5 +1,7 @@
+use encoding_rs::WINDOWS_1251;
use msh_core::{parse_model_payload, Model};
use nres::{Archive, EntryRef};
+use std::fmt;
use std::path::{Path, PathBuf};
use texm::{decode_mip_rgba8, parse_texm};
@@ -22,6 +24,37 @@ pub enum Error {
InvalidMaterial(String),
}
+impl fmt::Display for Error {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Nres(err) => write!(f, "{err}"),
+ Self::Msh(err) => write!(f, "{err}"),
+ Self::Texm(err) => write!(f, "{err}"),
+ Self::Io(err) => write!(f, "{err}"),
+ Self::NoMshEntries => write!(f, "archive does not contain .msh entries"),
+ Self::ModelNotFound(name) => write!(f, "model not found: {name}"),
+ Self::NoTexmEntries => write!(f, "archive does not contain Texm entries"),
+ Self::TextureNotFound(name) => write!(f, "texture not found: {name}"),
+ Self::MaterialNotFound(name) => write!(f, "material not found: {name}"),
+ Self::WearNotFound(name) => write!(f, "wear entry not found: {name}"),
+ Self::InvalidWear(reason) => write!(f, "invalid WEAR payload: {reason}"),
+ Self::InvalidMaterial(reason) => write!(f, "invalid MAT0 payload: {reason}"),
+ }
+ }
+}
+
+impl std::error::Error for Error {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Nres(err) => Some(err),
+ Self::Msh(err) => Some(err),
+ Self::Texm(err) => Some(err),
+ Self::Io(err) => Some(err),
+ _ => None,
+ }
+ }
+}
+
impl From<nres::error::Error> for Error {
fn from(value: nres::error::Error) -> Self {
Self::Nres(value)
@@ -280,7 +313,7 @@ fn find_material_entry_with_fallback<'a>(
}
fn parse_wear_material_names(payload: &[u8]) -> Result<Vec<String>> {
- let text = String::from_utf8_lossy(payload).replace('\r', "");
+ let text = decode_cp1251(payload).replace('\r', "");
let mut lines = text.lines();
let Some(first) = lines.next() else {
return Err(Error::InvalidWear(String::from("WEAR payload is empty")));
@@ -360,9 +393,7 @@ fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result<Op
.iter()
.position(|&b| b == 0)
.unwrap_or(name_raw.len());
- let name = String::from_utf8_lossy(&name_raw[..name_end])
- .trim()
- .to_string();
+ let name = decode_cp1251(&name_raw[..name_end]).trim().to_string();
if !name.is_empty() {
return Ok(Some(name));
}
@@ -371,6 +402,11 @@ fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result<Op
Ok(None)
}
+fn decode_cp1251(bytes: &[u8]) -> String {
+ let (decoded, _, _) = WINDOWS_1251.decode(bytes);
+ decoded.into_owned()
+}
+
fn load_texture_from_archive_by_name(archive: &Archive, name: &str) -> Result<LoadedTexture> {
let Some(id) = archive.find(name) else {
return Err(Error::TextureNotFound(name.to_string()));
@@ -524,4 +560,45 @@ mod tests {
assert!(texture.width > 0 && texture.height > 0);
assert!(!texture.rgba8.is_empty());
}
+
+ #[test]
+ fn parse_wear_material_names_parses_counted_lines() {
+ let payload = b"2\r\n0 MAT_A\r\n1 MAT_B\r\n";
+ let materials =
+ parse_wear_material_names(payload).expect("failed to parse valid WEAR payload");
+ assert_eq!(materials, vec!["MAT_A".to_string(), "MAT_B".to_string()]);
+ }
+
+ #[test]
+ fn parse_wear_material_names_rejects_invalid_payload() {
+ let payload = b"2\n0 ONLY_ONE\n";
+ assert!(matches!(
+ parse_wear_material_names(payload),
+ Err(Error::InvalidWear(_))
+ ));
+ }
+
+ #[test]
+ fn parse_primary_texture_name_from_mat0_respects_attr2_layout() {
+ let mut payload = vec![0u8; 4 + 10 + 34];
+ payload[0..2].copy_from_slice(&1u16.to_le_bytes()); // phase_count
+ // attr2=4 adds 10 bytes before phase table
+ let name = b"TEX_MAIN";
+ payload[4 + 10 + 18..4 + 10 + 18 + name.len()].copy_from_slice(name);
+
+ let parsed = parse_primary_texture_name_from_mat0(&payload, 4)
+ .expect("failed to parse MAT0 payload with attr2=4");
+ assert_eq!(parsed, Some("TEX_MAIN".to_string()));
+ }
+
+ #[test]
+ fn parse_primary_texture_name_from_mat0_decodes_cp1251_bytes() {
+ let mut payload = vec![0u8; 4 + 34];
+ payload[0..2].copy_from_slice(&1u16.to_le_bytes()); // phase_count
+ payload[4 + 18] = 0xC0; // 'А' in CP1251
+
+ let parsed =
+ parse_primary_texture_name_from_mat0(&payload, 0).expect("failed to parse MAT0");
+ assert_eq!(parsed, Some("А".to_string()));
+ }
}
diff --git a/crates/render-demo/src/main.rs b/crates/render-demo/src/main.rs
index bb826d5..8d309d1 100644
--- a/crates/render-demo/src/main.rs
+++ b/crates/render-demo/src/main.rs
@@ -11,6 +11,7 @@ struct Args {
group: usize,
width: u32,
height: u32,
+ fov_deg: f32,
capture: Option<PathBuf>,
angle: Option<f32>,
spin_rate: f32,
@@ -32,6 +33,7 @@ fn parse_args() -> Result<Args, String> {
let mut group = 0usize;
let mut width = 1280u32;
let mut height = 720u32;
+ let mut fov_deg = 60.0f32;
let mut capture = None;
let mut angle = None;
let mut spin_rate = 0.35f32;
@@ -94,6 +96,17 @@ fn parse_args() -> Result<Args, String> {
return Err(String::from("--height must be > 0"));
}
}
+ "--fov" => {
+ let value = it
+ .next()
+ .ok_or_else(|| String::from("missing value for --fov"))?;
+ fov_deg = value
+ .parse::<f32>()
+ .map_err(|_| String::from("invalid --fov value"))?;
+ if !(1.0..=179.0).contains(&fov_deg) {
+ return Err(String::from("--fov must be in range [1, 179]"));
+ }
+ }
"--capture" => {
let value = it
.next()
@@ -163,6 +176,7 @@ fn parse_args() -> Result<Args, String> {
group,
width,
height,
+ fov_deg,
capture,
angle,
spin_rate,
@@ -176,7 +190,7 @@ fn parse_args() -> Result<Args, String> {
fn print_help() {
eprintln!(
- "parkan-render-demo --archive <path> [--model <name.msh>] [--lod N] [--group N] [--width W] [--height H]"
+ "parkan-render-demo --archive <path> [--model <name.msh>] [--lod N] [--group N] [--width W] [--height H] [--fov DEG]"
);
eprintln!(" [--capture <out.png>] [--angle RAD] [--spin-rate RAD_PER_SEC]");
eprintln!(" [--texture <name>] [--texture-archive <path>] [--material-archive <path>] [--wear <name.wea>] [--no-texture]");
@@ -202,7 +216,7 @@ fn run(args: Args) -> Result<(), String> {
let loaded_model = load_model_with_name_from_archive(&args.archive, args.model.as_deref())
.map_err(|err| {
format!(
- "failed to load model from archive {}: {err:?}",
+ "failed to load model from archive {}: {err}",
args.archive.display()
)
})?;
@@ -289,6 +303,7 @@ fn run(args: Args) -> Result<(), String> {
vertex_data.push(vertex.uv0[0]);
vertex_data.push(vertex.uv0[1]);
}
+ let vertex_bytes = f32_slice_to_ne_bytes(&vertex_data);
let gl = unsafe {
glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _)
@@ -306,11 +321,7 @@ fn run(args: Args) -> Result<(), String> {
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(
- glow::ARRAY_BUFFER,
- cast_slice_u8(&vertex_data),
- glow::STATIC_DRAW,
- );
+ gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, &vertex_bytes, glow::STATIC_DRAW);
gl.bind_buffer(glow::ARRAY_BUFFER, None);
}
@@ -388,11 +399,9 @@ fn resolve_texture(args: &Args, model_name: &str) -> Result<Option<LoadedTexture
|| args.material_archive.is_some()
|| args.wear.is_some()
{
- Err(format!("failed to resolve texture: {err:?}"))
+ Err(format!("failed to resolve texture: {err}"))
} else {
- eprintln!(
- "warning: auto texture resolve failed ({err:?}), fallback to solid color"
- );
+ eprintln!("warning: auto texture resolve failed ({err}), fallback to solid color");
Ok(None)
}
}
@@ -451,7 +460,14 @@ fn run_capture(
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);
+ let mvp = compute_mvp(
+ args.width,
+ args.height,
+ args.fov_deg,
+ center,
+ camera_distance,
+ angle,
+ );
unsafe {
draw_frame(
gl,
@@ -515,7 +531,7 @@ fn run_interactive(
let angle = args
.angle
.unwrap_or(start.elapsed().as_secs_f32() * args.spin_rate);
- let mvp = compute_mvp(w, h, center, camera_distance, angle);
+ let mvp = compute_mvp(w, h, args.fov_deg, center, camera_distance, angle);
unsafe {
draw_frame(
@@ -543,12 +559,13 @@ fn run_interactive(
fn compute_mvp(
width: u32,
height: u32,
+ fov_deg: f32,
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 proj = mat4_perspective(fov_deg.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);
@@ -733,8 +750,12 @@ void main() {
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 f32_slice_to_ne_bytes(slice: &[f32]) -> Vec<u8> {
+ let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<f32>()));
+ for &value in slice {
+ out.extend_from_slice(&value.to_ne_bytes());
+ }
+ out
}
fn mat4_identity() -> [f32; 16] {