aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--crates/fparkan-render/src/lib.rs126
-rw-r--r--docs/tomes/05-render.md17
2 files changed, 143 insertions, 0 deletions
diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs
index fe83290..2463087 100644
--- a/crates/fparkan-render/src/lib.rs
+++ b/crates/fparkan-render/src/lib.rs
@@ -43,6 +43,90 @@ impl RawCameraTransform {
pub fn translation(self) -> [f32; 3] {
Self::TRANSLATION_WORD_INDICES.map(|index| f32::from_bits(self.words[index]))
}
+
+ /// Inverts a finite row-major affine transform without assigning it a
+ /// camera-space meaning.
+ ///
+ /// The legacy SIMD dispatch multiplies these blocks as ordinary row-major
+ /// matrices and the confirmed camera samples have translation in the last
+ /// column. `Some` therefore means only that this block has a non-singular
+ /// affine inverse. Callers must still establish whether it is a
+ /// camera-to-world transform before using the result as a view matrix.
+ #[must_use]
+ pub fn try_inverse_affine_row_major(self) -> Option<[f32; 16]> {
+ let matrix = self.words.map(f32::from_bits);
+ if !matrix.iter().all(|value| value.is_finite())
+ || matrix[12].abs() > f32::EPSILON
+ || matrix[13].abs() > f32::EPSILON
+ || matrix[14].abs() > f32::EPSILON
+ || (matrix[15] - 1.0).abs() > f32::EPSILON
+ {
+ return None;
+ }
+
+ let [m00, m01, m02, _, m10, m11, m12, _, m20, m21, m22, _, _, _, _, _] = matrix;
+ let cofactor00 = m11.mul_add(m22, -(m12 * m21));
+ let cofactor01 = m02.mul_add(m21, -(m01 * m22));
+ let cofactor02 = m01.mul_add(m12, -(m02 * m11));
+ let determinant = m00.mul_add(cofactor00, m10.mul_add(cofactor01, m20 * cofactor02));
+ if !determinant.is_finite() || determinant == 0.0 {
+ return None;
+ }
+
+ let inverse_determinant = determinant.recip();
+ let inverse = [
+ cofactor00 * inverse_determinant,
+ m02.mul_add(m21, -(m01 * m22)) * inverse_determinant,
+ cofactor02 * inverse_determinant,
+ 0.0,
+ m12.mul_add(m20, -(m10 * m22)) * inverse_determinant,
+ m00.mul_add(m22, -(m02 * m20)) * inverse_determinant,
+ m02.mul_add(m10, -(m00 * m12)) * inverse_determinant,
+ 0.0,
+ m10.mul_add(m21, -(m11 * m20)) * inverse_determinant,
+ m01.mul_add(m20, -(m00 * m21)) * inverse_determinant,
+ m00.mul_add(m11, -(m01 * m10)) * inverse_determinant,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.0,
+ ];
+ let [translation_x, translation_y, translation_z] = self.translation();
+ let translation = [
+ -(inverse[0].mul_add(
+ translation_x,
+ inverse[1].mul_add(translation_y, inverse[2] * translation_z),
+ )),
+ -(inverse[4].mul_add(
+ translation_x,
+ inverse[5].mul_add(translation_y, inverse[6] * translation_z),
+ )),
+ -(inverse[8].mul_add(
+ translation_x,
+ inverse[9].mul_add(translation_y, inverse[10] * translation_z),
+ )),
+ ];
+
+ Some([
+ inverse[0],
+ inverse[1],
+ inverse[2],
+ translation[0],
+ inverse[4],
+ inverse[5],
+ inverse[6],
+ translation[1],
+ inverse[8],
+ inverse[9],
+ inverse[10],
+ translation[2],
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.0,
+ ])
+ }
}
/// Raw camera state observed through the original Terrain camera interface.
@@ -745,6 +829,28 @@ fn identity_transform() -> [f32; 16] {
mod tests {
use super::*;
+ fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
+ let mut result = [0.0; 16];
+ for row in 0..4 {
+ for column in 0..4 {
+ result[row * 4 + column] = (0..4)
+ .map(|index| left[row * 4 + index] * right[index * 4 + column])
+ .sum();
+ }
+ }
+ result
+ }
+
+ fn assert_matrix_approximately_identity(matrix: [f32; 16]) {
+ for (index, value) in matrix.into_iter().enumerate() {
+ let expected = if index / 4 == index % 4 { 1.0 } else { 0.0 };
+ assert!(
+ (value - expected).abs() < 0.000_02,
+ "matrix element {index}: expected {expected}, got {value}"
+ );
+ }
+ }
+
#[test]
fn raw_camera_pose_preserves_words_and_extracts_confirmed_translation() {
let mut active = [0_u32; 16];
@@ -768,6 +874,26 @@ mod tests {
assert_eq!(CameraSnapshot::default().raw_pose, None);
}
+ #[test]
+ fn raw_camera_transform_inverts_only_non_singular_affine_blocks() {
+ let source = [
+ 0.0, -1.0, 0.0, 433.544_7, 0.948_985, 0.0, 0.315_322, 652.292_5, -0.315_322, 0.0,
+ 0.948_985, 10.673_42, 0.0, 0.0, 0.0, 1.0,
+ ];
+ let transform = RawCameraTransform {
+ words: source.map(f32::to_bits),
+ };
+ let inverse = transform
+ .try_inverse_affine_row_major()
+ .expect("observed affine transform is invertible");
+
+ assert_matrix_approximately_identity(multiply_row_major(source, inverse));
+ assert_matrix_approximately_identity(multiply_row_major(inverse, source));
+
+ let singular = RawCameraTransform { words: [0_u32; 16] };
+ assert_eq!(singular.try_inverse_affine_row_major(), None);
+ }
+
fn snapshot_draw(
id: u64,
phase: RenderPhase,
diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md
index ca812ba..acd9c14 100644
--- a/docs/tomes/05-render.md
+++ b/docs/tomes/05-render.md
@@ -1328,6 +1328,23 @@ to use its existing view/projection matrices: raw pose capture is intentionally
not converted into a view matrix until the original transform/projection
convention has evidence.
+The next live sample strengthens the representation contract but still does
+not name the transform's camera-space direction. For six no-input samples of
+one auto-demo camera object, selectors `0` and `2` were byte-identical,
+finite affine blocks with bottom row `(0, 0, 0, 1)`. Their upper 3x3 basis
+was normalized and their last-column translations changed from approximately
+`(441.038, 687.481, 10.754)` to `(433.545, 652.293, 10.673)`.
+
+This storage/order is not guessed: live `NGI32.dll!g_FastProc[23]` dispatches
+to RVA `0x1D9A0`, whose SSE implementation writes each output row as the sum
+of four scalar-weighted rows. Thus a contiguous 16-float block is multiplied
+as a conventional row-major 4x4 matrix. The render contract now exposes a
+mathematical `try_inverse_affine_row_major` helper for such finite,
+non-singular `[R | t; 0 0 0 1]` blocks. It returns no result for a singular or
+non-affine block and, crucially, does **not** identify that inverse as a view
+matrix. Whether a selector result is camera-to-world or already a renderer
+transform remains an evidence boundary.
+
A fresh no-input launch of the canonical `iron_3d.exe` did create a responsive
window titled `Parkan. Железная Стратегия`. A read-only probe then requested
`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` and attempted to read the known