fix(capture): replay the projection used by world geometry
Docs Deploy / Build and Deploy MkDocs (push) Successful in 39s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 39s
This commit is contained in:
7 files changed
+409
-170
No files matched your search
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
Reference in new issue
Block a user