diff options
| author | Valentin Popov <valentin@popov.link> | 2026-10-31 22:13:22 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-10-31 22:13:22 +0300 |
| commit | 853d9b115aa3a7e6790c79c17418e0669d322023 (patch) | |
| tree | 3dc30d6324ad828f3a4fa358b54487a5895daa42 /apps/fparkan-game | |
| parent | 8a2c3e98403083987aa6f3b50cadcbc92281164a (diff) | |
| download | fparkan-853d9b115aa3a7e6790c79c17418e0669d322023.tar.xz fparkan-853d9b115aa3a7e6790c79c17418e0669d322023.zip | |
fix(capture): replay the projection used by world geometry
Diffstat (limited to 'apps/fparkan-game')
| -rw-r--r-- | apps/fparkan-game/src/camera_capture.rs | 251 | ||||
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 166 |
2 files changed, 259 insertions, 158 deletions
diff --git a/apps/fparkan-game/src/camera_capture.rs b/apps/fparkan-game/src/camera_capture.rs new file mode 100644 index 0000000..9862546 --- /dev/null +++ b/apps/fparkan-game/src/camera_capture.rs @@ -0,0 +1,251 @@ +use super::{CapturedViewport, PreviewCameraFrame}; +use fparkan_render::{LegacyD3d7Projection, RawCameraTransform}; +use serde::Deserialize; + +#[derive(Clone, Debug, Deserialize, PartialEq)] +pub(super) struct LegacyCameraCapture { + pub(super) schema: String, + pub(super) render_input_usable: Option<bool>, + pub(super) selector0_words: [u32; 16], + pub(super) parented_camera: Option<bool>, + pub(super) effective_world_matrix_space: Option<String>, + pub(super) effective_world_matrix_words: Option<[u32; 16]>, + pub(super) viewport: [i32; 4], + pub(super) near_plane: f32, + pub(super) far_plane: f32, + pub(super) field_of_view_radians: f32, + pub(super) atmosphere_seconds: Option<f32>, + pub(super) geometry_projection: Option<GeometryProjection>, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +pub(super) struct GeometryProjection { + verified: bool, + viewport: [i32; 4], + near_plane: f32, + far_plane: f32, + field_of_view_radians: f32, +} + +pub(super) fn selected_atmosphere_seconds( + command_line: Option<f32>, + capture: Option<&LegacyCameraCapture>, +) -> Option<f32> { + command_line.or_else(|| capture.and_then(|capture| capture.atmosphere_seconds)) +} + +impl LegacyCameraCapture { + pub(super) fn preview_matrix_words(&self) -> Result<[u32; 16], String> { + match ( + self.effective_world_matrix_space.as_deref(), + self.effective_world_matrix_words, + ) { + (Some("world"), Some(words)) => Ok(words), + (Some(_), _) => Err( + "legacy camera effective_world_matrix_space must be 'world'".to_string(), + ), + (None, Some(_)) => Err( + "legacy camera effective_world_matrix_words requires an explicit world-space marker" + .to_string(), + ), + (None, None) if self.parented_camera == Some(true) => Err( + "parented legacy camera requires an effective world-space matrix".to_string(), + ), + (None, None) => Ok(self.selector0_words), + } + } + + pub(super) fn captured_viewport(&self) -> Result<CapturedViewport, String> { + let width = self.viewport[2] + .checked_sub(self.viewport[0]) + .filter(|width| *width > 0) + .ok_or_else(|| "legacy camera capture has an invalid viewport width".to_string())?; + let height = self.viewport[3] + .checked_sub(self.viewport[1]) + .filter(|height| *height > 0) + .ok_or_else(|| "legacy camera capture has an invalid viewport height".to_string())?; + Ok(CapturedViewport { + rect: self.viewport, + extent: [ + u32::try_from(width) + .map_err(|_| "legacy camera viewport width exceeds u32".to_string())?, + u32::try_from(height) + .map_err(|_| "legacy camera viewport height exceeds u32".to_string())?, + ], + }) + } + + pub(super) fn preview_camera_frame(&self) -> Result<PreviewCameraFrame, String> { + let projection = if let Some(geometry) = &self.geometry_projection { + if !geometry.verified || geometry.viewport != self.viewport { + return Err( + "legacy camera geometry projection is unverified or has a different viewport" + .to_string(), + ); + } + LegacyD3d7Projection { + viewport: geometry.viewport, + near_plane: geometry.near_plane, + far_plane: geometry.far_plane, + field_of_view_radians: geometry.field_of_view_radians, + } + } else { + LegacyD3d7Projection { + viewport: self.viewport, + near_plane: self.near_plane, + far_plane: self.far_plane, + field_of_view_radians: self.field_of_view_radians, + } + }; + let frame = PreviewCameraFrame::from_legacy_d3d7( + RawCameraTransform { + words: self.preview_matrix_words()?, + }, + projection, + ) + .ok_or_else(|| "legacy camera capture contains an invalid D3D7 camera".to_string())?; + self.captured_viewport()?; + Ok(frame) + } +} + +pub(super) fn load_legacy_camera_capture( + path: &std::path::Path, +) -> Result<LegacyCameraCapture, String> { + let bytes = std::fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?; + let capture = + parse_legacy_camera_capture(&bytes).map_err(|err| format!("{}: {err}", path.display()))?; + if capture.geometry_projection.is_none() { + eprintln!("render_coverage captured_projection_unverified=1 (capture has no geometry_projection; using the recorded camera boundary, which can clip nearby surfaces)"); + } + Ok(capture) +} + +pub(super) fn parse_legacy_camera_capture(bytes: &[u8]) -> Result<LegacyCameraCapture, String> { + // Windows PowerShell 5.1 writes redirected text as UTF-16LE with a BOM. + // `capture-original-camera.ps1` intentionally emits plain JSON, so accept + // that normal hand-off format as well as UTF-8 without making the caller + // re-encode a read-only capture file. + let json = decode_legacy_camera_capture_json(bytes)?; + let capture: LegacyCameraCapture = + serde_json::from_str(&json).map_err(|err| format!("invalid legacy camera JSON: {err}"))?; + if capture.schema != "fparkan-legacy-camera-v1" { + return Err("unsupported legacy camera capture schema".to_string()); + } + if capture.effective_world_matrix_space.is_some() + != capture.effective_world_matrix_words.is_some() + { + return Err( + "effective_world_matrix_space and effective_world_matrix_words must be supplied together" + .to_string(), + ); + } + if capture.render_input_usable == Some(false) { + return Err("legacy camera capture is marked render_input_usable=false".to_string()); + } + if capture + .atmosphere_seconds + .is_some_and(|seconds| !seconds.is_finite() || seconds < 0.0) + { + return Err( + "legacy camera capture atmosphere_seconds must be finite and non-negative".to_string(), + ); + } + capture.preview_camera_frame()?; + Ok(capture) +} + +fn decode_legacy_camera_capture_json(bytes: &[u8]) -> Result<String, String> { + let decode_utf16 = |words: Vec<u16>| { + String::from_utf16(&words) + .map_err(|err| format!("invalid UTF-16 legacy camera JSON: {err}")) + }; + match bytes { + [0xff, 0xfe, rest @ ..] => { + let chunks = rest.chunks_exact(2); + if !chunks.remainder().is_empty() { + return Err("invalid UTF-16LE legacy camera JSON length".to_string()); + } + decode_utf16( + chunks + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect(), + ) + } + [0xfe, 0xff, rest @ ..] => { + let chunks = rest.chunks_exact(2); + if !chunks.remainder().is_empty() { + return Err("invalid UTF-16BE legacy camera JSON length".to_string()); + } + decode_utf16( + chunks + .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]])) + .collect(), + ) + } + [0xef, 0xbb, 0xbf, rest @ ..] => std::str::from_utf8(rest) + .map(str::to_owned) + .map_err(|err| format!("invalid UTF-8 legacy camera JSON: {err}")), + _ => std::str::from_utf8(bytes) + .map(str::to_owned) + .map_err(|err| format!("invalid UTF-8 legacy camera JSON: {err}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn json() -> serde_json::Value { + let words = [ + 1.0_f32, 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, + ] + .map(f32::to_bits); + serde_json::json!({ + "schema": "fparkan-legacy-camera-v1", "selector0_words": words, + "viewport": [0, 0, 1280, 1024], "near_plane": 5.0, "far_plane": 700.0, + "field_of_view_radians": 1.3, + "geometry_projection": { + "verified": true, "viewport": [0, 0, 1280, 1024], + "near_plane": 0.5, "far_plane": 700.0, "field_of_view_radians": 1.3, + } + }) + } + + #[test] + fn geometry_projection_preserves_surfaces_closer_than_restored_camera_near() { + let mut json = json(); + let captured = parse_legacy_camera_capture(&serde_json::to_vec(&json).unwrap()).unwrap(); + let camera = captured.preview_camera_frame().unwrap(); + let point = [1.0, 0.0, 0.0, 1.0]; + let depth = |matrix: [f32; 16]| { + (0..4) + .map(|row| point[row] * matrix[row * 4 + 2]) + .sum::<f32>() + }; + assert_eq!(camera.near_plane, 0.5); + assert!(depth(camera.clip_from_world) > 0.0); + json.as_object_mut().unwrap().remove("geometry_projection"); + let old = parse_legacy_camera_capture(&serde_json::to_vec(&json).unwrap()).unwrap(); + let old_camera = old.preview_camera_frame().unwrap(); + assert_eq!(old_camera.near_plane, 5.0); + assert!(depth(old_camera.clip_from_world) < 0.0); + } + + #[test] + fn geometry_projection_rejects_unverified_mismatched_or_invalid_input() { + for (field, value) in [ + ("verified", serde_json::json!(false)), + ("viewport", serde_json::json!([0, 0, 640, 480])), + ("near_plane", serde_json::json!(0.0)), + ("far_plane", serde_json::json!(0.25)), + ] { + let mut json = json(); + json["geometry_projection"][field] = value; + assert!( + parse_legacy_camera_capture(&serde_json::to_vec(&json).unwrap()).is_err(), + "{field}" + ); + } + } +} diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 2c63a29..f3211af 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -2,6 +2,13 @@ //! `FParkan` render-planning composition root. +mod camera_capture; + +#[cfg(test)] +use camera_capture::parse_legacy_camera_capture; +use camera_capture::{ + load_legacy_camera_capture, selected_atmosphere_seconds, LegacyCameraCapture, +}; use fparkan_animation::{AnimationTime, NodePoseBuffer}; use fparkan_assets::{ load_material_archive_name_texture_rgba8_and_phase_with_document_from_root, @@ -79,7 +86,6 @@ use fparkan_terrain::{ FullSurfaceMask, SurfaceQuery, TerrainLod, TerrainMaterialLayers, TerrainWorld, }; use fparkan_vfs::DirectoryVfs; -use serde::Deserialize; use std::collections::{HashMap, HashSet}; use std::io::Write; use std::num::NonZeroUsize; @@ -11097,163 +11103,6 @@ impl Args { } } -#[derive(Clone, Debug, Deserialize, PartialEq)] -struct LegacyCameraCapture { - schema: String, - render_input_usable: Option<bool>, - selector0_words: [u32; 16], - parented_camera: Option<bool>, - effective_world_matrix_space: Option<String>, - effective_world_matrix_words: Option<[u32; 16]>, - viewport: [i32; 4], - near_plane: f32, - far_plane: f32, - field_of_view_radians: f32, - atmosphere_seconds: Option<f32>, -} - -fn selected_atmosphere_seconds( - command_line: Option<f32>, - capture: Option<&LegacyCameraCapture>, -) -> Option<f32> { - command_line.or_else(|| capture.and_then(|capture| capture.atmosphere_seconds)) -} - -impl LegacyCameraCapture { - fn preview_matrix_words(&self) -> Result<[u32; 16], String> { - match ( - self.effective_world_matrix_space.as_deref(), - self.effective_world_matrix_words, - ) { - (Some("world"), Some(words)) => Ok(words), - (Some(_), _) => Err( - "legacy camera effective_world_matrix_space must be 'world'".to_string(), - ), - (None, Some(_)) => Err( - "legacy camera effective_world_matrix_words requires an explicit world-space marker" - .to_string(), - ), - (None, None) if self.parented_camera == Some(true) => Err( - "parented legacy camera requires an effective world-space matrix".to_string(), - ), - (None, None) => Ok(self.selector0_words), - } - } - - fn captured_viewport(&self) -> Result<CapturedViewport, String> { - let width = self.viewport[2] - .checked_sub(self.viewport[0]) - .filter(|width| *width > 0) - .ok_or_else(|| "legacy camera capture has an invalid viewport width".to_string())?; - let height = self.viewport[3] - .checked_sub(self.viewport[1]) - .filter(|height| *height > 0) - .ok_or_else(|| "legacy camera capture has an invalid viewport height".to_string())?; - Ok(CapturedViewport { - rect: self.viewport, - extent: [ - u32::try_from(width) - .map_err(|_| "legacy camera viewport width exceeds u32".to_string())?, - u32::try_from(height) - .map_err(|_| "legacy camera viewport height exceeds u32".to_string())?, - ], - }) - } - - fn preview_camera_frame(&self) -> Result<PreviewCameraFrame, String> { - let frame = PreviewCameraFrame::from_legacy_d3d7( - RawCameraTransform { - words: self.preview_matrix_words()?, - }, - LegacyD3d7Projection { - viewport: self.viewport, - near_plane: self.near_plane, - far_plane: self.far_plane, - field_of_view_radians: self.field_of_view_radians, - }, - ) - .ok_or_else(|| "legacy camera capture contains an invalid D3D7 camera".to_string())?; - self.captured_viewport()?; - Ok(frame) - } -} - -fn load_legacy_camera_capture(path: &std::path::Path) -> Result<LegacyCameraCapture, String> { - let bytes = std::fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?; - parse_legacy_camera_capture(&bytes).map_err(|err| format!("{}: {err}", path.display())) -} - -fn parse_legacy_camera_capture(bytes: &[u8]) -> Result<LegacyCameraCapture, String> { - // Windows PowerShell 5.1 writes redirected text as UTF-16LE with a BOM. - // `capture-original-camera.ps1` intentionally emits plain JSON, so accept - // that normal hand-off format as well as UTF-8 without making the caller - // re-encode a read-only capture file. - let json = decode_legacy_camera_capture_json(bytes)?; - let capture: LegacyCameraCapture = - serde_json::from_str(&json).map_err(|err| format!("invalid legacy camera JSON: {err}"))?; - if capture.schema != "fparkan-legacy-camera-v1" { - return Err("unsupported legacy camera capture schema".to_string()); - } - if capture.effective_world_matrix_space.is_some() - != capture.effective_world_matrix_words.is_some() - { - return Err( - "effective_world_matrix_space and effective_world_matrix_words must be supplied together" - .to_string(), - ); - } - if capture.render_input_usable == Some(false) { - return Err("legacy camera capture is marked render_input_usable=false".to_string()); - } - if capture - .atmosphere_seconds - .is_some_and(|seconds| !seconds.is_finite() || seconds < 0.0) - { - return Err( - "legacy camera capture atmosphere_seconds must be finite and non-negative".to_string(), - ); - } - capture.preview_camera_frame()?; - Ok(capture) -} - -fn decode_legacy_camera_capture_json(bytes: &[u8]) -> Result<String, String> { - let decode_utf16 = |words: Vec<u16>| { - String::from_utf16(&words) - .map_err(|err| format!("invalid UTF-16 legacy camera JSON: {err}")) - }; - match bytes { - [0xff, 0xfe, rest @ ..] => { - let chunks = rest.chunks_exact(2); - if !chunks.remainder().is_empty() { - return Err("invalid UTF-16LE legacy camera JSON length".to_string()); - } - decode_utf16( - chunks - .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) - .collect(), - ) - } - [0xfe, 0xff, rest @ ..] => { - let chunks = rest.chunks_exact(2); - if !chunks.remainder().is_empty() { - return Err("invalid UTF-16BE legacy camera JSON length".to_string()); - } - decode_utf16( - chunks - .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]])) - .collect(), - ) - } - [0xef, 0xbb, 0xbf, rest @ ..] => std::str::from_utf8(rest) - .map(str::to_owned) - .map_err(|err| format!("invalid UTF-8 legacy camera JSON: {err}")), - _ => std::str::from_utf8(bytes) - .map(str::to_owned) - .map_err(|err| format!("invalid UTF-8 legacy camera JSON: {err}")), - } -} - fn usage() -> String { "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--atmosphere-seconds <seconds>] [--control-clock-ms <u32>] [--validation] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>] [--readback-sequence-out <directory>]\nstatic preview uses saved per-component CTLD defaults, with uncontrolled nodes at frame 0; --static-animation-frame overrides all components globally. --control-clock-ms replays one shared MSH/FX clock from zero to the selected time over the requested frames; --readback-sequence-out captures each submitted frame and requires that clock.".to_string() } @@ -15766,6 +15615,7 @@ mod tests { far_plane: 1.0, field_of_view_radians: 1.0, atmosphere_seconds: Some(56.227), + geometry_projection: None, }; assert_eq!( |
