792 lines
28 KiB
Rust
792 lines
28 KiB
Rust
#![forbid(unsafe_code)]
|
|
//! Camera mathematics and graphics pipeline state.
|
|
|
|
/// A 64-byte transform block returned by the original Terrain camera ABI.
|
|
///
|
|
/// The original uses two selector-dependent transform pointers. Their exact
|
|
/// matrix convention is still under recovery, so words are deliberately kept
|
|
/// losslessly rather than treated as a renderer-ready matrix. The three
|
|
/// translation words have been confirmed at indices 3, 7, and 11.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct RawCameraTransform {
|
|
/// The exact 16 little-endian dwords returned by the legacy camera.
|
|
pub words: [u32; 16],
|
|
}
|
|
|
|
impl RawCameraTransform {
|
|
/// Indices of the confirmed X, Y, and Z translation floats.
|
|
pub const TRANSLATION_WORD_INDICES: [usize; 3] = [3, 7, 11];
|
|
|
|
/// Returns the confirmed legacy world position (X, Y, Z).
|
|
#[must_use]
|
|
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,
|
|
])
|
|
}
|
|
|
|
/// Reproduces Ngi32's `Direct3D7` view-matrix conversion for this transform.
|
|
///
|
|
/// The legacy renderer copies selector-0 into its camera state, then maps
|
|
/// its axes and translation in this exact order before calling
|
|
/// `IDirect3DDevice7::SetTransform(D3DTRANSFORMSTATE_VIEW, ...)`. This is
|
|
/// deliberately distinct from [`Self::try_inverse_affine_row_major`]: it
|
|
/// includes the original renderer's coordinate-system conversion.
|
|
///
|
|
/// The returned matrix is row-major D3D7 data, not yet a Vulkan view
|
|
/// matrix. A later adapter must explicitly account for clip-space and
|
|
/// shader-vector conventions.
|
|
#[must_use]
|
|
pub fn try_direct3d7_view_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, m03, m10, m11, m12, m13, m20, m21, m22, m23, _, _, _, _] = matrix;
|
|
Some([
|
|
-m01,
|
|
m02,
|
|
m00,
|
|
0.0,
|
|
-m11,
|
|
m12,
|
|
m10,
|
|
0.0,
|
|
-m21,
|
|
m22,
|
|
m20,
|
|
0.0,
|
|
m23.mul_add(m21, m13.mul_add(m11, m03 * m01)),
|
|
-(m23.mul_add(m22, m13.mul_add(m12, m03 * m02))),
|
|
-(m00.mul_add(m03, m23.mul_add(m20, m13 * m10))),
|
|
1.0,
|
|
])
|
|
}
|
|
}
|
|
|
|
/// Parameters consumed by Ngi32's `Direct3D7` projection-matrix builder.
|
|
///
|
|
/// These are a separate legacy-renderer boundary from
|
|
/// [`RawCameraProjection`]. The latter preserves Terrain's source ABI, while
|
|
/// this type records the already-resolved Ngi32 values submitted to `Direct3D7`.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct LegacyD3d7Projection {
|
|
/// Viewport rectangle as `(left, top, right, bottom)`.
|
|
pub viewport: [i32; 4],
|
|
/// Positive camera near-plane distance.
|
|
pub near_plane: f32,
|
|
/// Camera far-plane distance, greater than [`Self::near_plane`].
|
|
pub far_plane: f32,
|
|
/// Full field-of-view angle in radians.
|
|
pub field_of_view_radians: f32,
|
|
}
|
|
|
|
impl LegacyD3d7Projection {
|
|
/// Reconstructs the exact row-major matrix passed to D3D7 projection state.
|
|
///
|
|
/// Ngi32 uses the viewport's `width / height`, writes `cos(fov / 2)` to
|
|
/// the diagonal and `sin(fov / 2)` to both the depth scale and
|
|
/// homogeneous-W term. The ratio after D3D's perspective divide is
|
|
/// therefore the expected cotangent scale. This remains legacy D3D7 data
|
|
/// rather than a Vulkan projection.
|
|
#[must_use]
|
|
pub fn try_direct3d7_projection_row_major(self) -> Option<[f32; 16]> {
|
|
let width = self.viewport[2].checked_sub(self.viewport[0])?;
|
|
let height = self.viewport[3].checked_sub(self.viewport[1])?;
|
|
if width <= 0
|
|
|| height <= 0
|
|
|| !self.near_plane.is_finite()
|
|
|| !self.far_plane.is_finite()
|
|
|| !self.field_of_view_radians.is_finite()
|
|
|| self.near_plane <= 0.0
|
|
|| self.far_plane <= self.near_plane
|
|
|| self.field_of_view_radians <= 0.0
|
|
|| self.field_of_view_radians >= std::f32::consts::PI
|
|
{
|
|
return None;
|
|
}
|
|
|
|
// Ngi32 converts these signed viewport dimensions into single-precision
|
|
// arithmetic before building the legacy matrix.
|
|
|
|
let aspect = (width as f32) / (height as f32);
|
|
let half_fov = self.field_of_view_radians * 0.5;
|
|
let cosine = half_fov.cos();
|
|
let sine = half_fov.sin();
|
|
let depth_scale = sine / (1.0 - self.near_plane / self.far_plane);
|
|
[aspect, cosine, sine, depth_scale]
|
|
.iter()
|
|
.all(|value| value.is_finite())
|
|
.then_some([
|
|
cosine,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
aspect * cosine,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
depth_scale,
|
|
sine,
|
|
0.0,
|
|
0.0,
|
|
-(depth_scale * self.near_plane),
|
|
0.0,
|
|
])
|
|
}
|
|
}
|
|
|
|
/// Affine placement inputs consumed by `Iron3D`'s recovered Euler-matrix builder.
|
|
///
|
|
/// This records the exact matrix construction at `iron3d.dll` RVA `0x36610`.
|
|
/// It is intentionally distinct from a mission placement contract: the static
|
|
/// trace has not yet proved that TMA's three raw orientation floats reach this
|
|
/// builder unchanged for every object category.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct LegacyIron3dEulerTransform {
|
|
/// World-space translation passed to the builder.
|
|
pub translation: [f32; 3],
|
|
/// Three angles in the builder's `(x, y, z)` input order, in radians.
|
|
pub orientation_radians: [f32; 3],
|
|
}
|
|
|
|
impl LegacyIron3dEulerTransform {
|
|
/// Reconstructs the builder's finite row-major affine matrix.
|
|
///
|
|
/// The recovered x87 code evaluates `Rz(z) * Ry(y) * Rx(x)` and writes
|
|
/// translation into the last column. This uses portable `f32` trigonometry;
|
|
/// any future x87-compatibility path must be separately capture-validated.
|
|
#[must_use]
|
|
pub fn try_row_major(self) -> Option<[f32; 16]> {
|
|
if !self
|
|
.translation
|
|
.iter()
|
|
.chain(self.orientation_radians.iter())
|
|
.all(|value| value.is_finite())
|
|
{
|
|
return None;
|
|
}
|
|
let [x, y, z] = self.orientation_radians;
|
|
let (sin_x, cos_x) = x.sin_cos();
|
|
let (sin_y, cos_y) = y.sin_cos();
|
|
let (sin_z, cos_z) = z.sin_cos();
|
|
let [translation_x, translation_y, translation_z] = self.translation;
|
|
let matrix = [
|
|
cos_z * cos_y,
|
|
cos_z * sin_y * sin_x - sin_z * cos_x,
|
|
cos_z * sin_y * cos_x + sin_z * sin_x,
|
|
translation_x,
|
|
sin_z * cos_y,
|
|
sin_z * sin_y * sin_x + cos_z * cos_x,
|
|
sin_z * sin_y * cos_x - cos_z * sin_x,
|
|
translation_y,
|
|
-sin_y,
|
|
cos_y * sin_x,
|
|
cos_y * cos_x,
|
|
translation_z,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
1.0,
|
|
];
|
|
matrix
|
|
.iter()
|
|
.all(|value| value.is_finite())
|
|
.then_some(matrix)
|
|
}
|
|
|
|
/// Applies the recovered rotation after a component-wise local scale.
|
|
///
|
|
/// This is the affine `R * (scale * point) + translation` convention used
|
|
/// by the static source-world bridge. The original dynamic transform path
|
|
/// needs its own capture evidence before it may replace this static path.
|
|
#[must_use]
|
|
pub fn try_transform_scaled_point(self, point: [f32; 3], scale: [f32; 3]) -> Option<[f32; 3]> {
|
|
if !point
|
|
.iter()
|
|
.chain(scale.iter())
|
|
.all(|value| value.is_finite())
|
|
{
|
|
return None;
|
|
}
|
|
let matrix = self.try_row_major()?;
|
|
let scaled = [
|
|
point[0] * scale[0],
|
|
point[1] * scale[1],
|
|
point[2] * scale[2],
|
|
];
|
|
let transformed = [
|
|
matrix[0] * scaled[0] + matrix[1] * scaled[1] + matrix[2] * scaled[2] + matrix[3],
|
|
matrix[4] * scaled[0] + matrix[5] * scaled[1] + matrix[6] * scaled[2] + matrix[7],
|
|
matrix[8] * scaled[0] + matrix[9] * scaled[1] + matrix[10] * scaled[2] + matrix[11],
|
|
];
|
|
transformed
|
|
.iter()
|
|
.all(|value| value.is_finite())
|
|
.then_some(transformed)
|
|
}
|
|
}
|
|
|
|
/// Fixed-function blend behaviour represented without a graphics API type.
|
|
///
|
|
/// This is the compatibility contract for the native MAT0 pipeline category.
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
pub enum LegacyBlendMode {
|
|
/// Do not blend the fragment with the existing colour.
|
|
#[default]
|
|
Opaque,
|
|
/// Blend using source alpha.
|
|
SourceAlpha,
|
|
/// Add the source-alpha-scaled fragment to the existing colour.
|
|
Additive,
|
|
/// Replace the source factor with zero and use source colour as the
|
|
/// destination factor.
|
|
ZeroSourceColor,
|
|
/// Multiply the source by destination colour and destination by source
|
|
/// colour, matching the native two-colour blend mode.
|
|
DestColorSourceColor,
|
|
}
|
|
|
|
/// Depth-buffer behaviour represented without a graphics API type.
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
pub enum LegacyDepthMode {
|
|
/// No depth attachment is used.
|
|
#[default]
|
|
Disabled,
|
|
/// Test depth and write passing fragments.
|
|
TestWrite,
|
|
/// Test depth without modifying it.
|
|
TestReadOnly,
|
|
}
|
|
|
|
/// Reflects world Z before a row-vector clip transform, preserving points on the plane.
|
|
#[must_use]
|
|
pub fn reflect_clip_from_world_about_z(matrix: [f32; 16], height: f32) -> Option<[f32; 16]> {
|
|
if !height.is_finite() || !matrix.iter().all(|value| value.is_finite()) {
|
|
return None;
|
|
}
|
|
let mut reflected = matrix;
|
|
for column in 0..4 {
|
|
reflected[8 + column] = -matrix[8 + column];
|
|
reflected[12 + column] = matrix[12 + column] + 2.0 * height * matrix[8 + column];
|
|
}
|
|
reflected
|
|
.iter()
|
|
.all(|value| value.is_finite())
|
|
.then_some(reflected)
|
|
}
|
|
|
|
/// Triangle culling behaviour represented without a graphics API type.
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
pub enum LegacyCullMode {
|
|
/// Keep both front- and back-facing triangles.
|
|
#[default]
|
|
Disabled,
|
|
/// Cull back-facing triangles.
|
|
BackFace,
|
|
/// Cull front-facing triangles.
|
|
FrontFace,
|
|
}
|
|
|
|
/// CPU diffuse alpha for a colourless MSH batch carrying flag `0x0008`.
|
|
///
|
|
/// `packet_distance` is the distance to the packet's first accepted vertex,
|
|
/// after its object transform. Flag `0x0010` takes precedence over `0x0040`.
|
|
/// Returns `None` for invalid distance or projection inputs. Callers must keep
|
|
/// the material alpha for batches with a type-16 colour stream or without `0x0008`.
|
|
#[must_use]
|
|
pub fn legacy_msh_portal_alpha(
|
|
batch_flags: u16,
|
|
packet_distance: f32,
|
|
horizontal_fov: f32,
|
|
) -> Option<f32> {
|
|
if !packet_distance.is_finite()
|
|
|| packet_distance < 0.0
|
|
|| !horizontal_fov.is_finite()
|
|
|| horizontal_fov <= 0.0
|
|
{
|
|
return None;
|
|
}
|
|
let (distance, near, far, slope) = if batch_flags & 0x0010 != 0 {
|
|
(
|
|
packet_distance.sqrt(),
|
|
2.6 / horizontal_fov,
|
|
7.799_999_7 / horizontal_fov,
|
|
(1.0 / (7.799_999_7 - 2.6)) * horizontal_fov,
|
|
)
|
|
} else if batch_flags & 0x0040 != 0 {
|
|
return Some(0.0);
|
|
} else {
|
|
let near = 75.0 * 1.3;
|
|
let far = 95.0 * 1.3;
|
|
(
|
|
packet_distance,
|
|
horizontal_fov.recip() * near,
|
|
horizontal_fov.recip() * far,
|
|
(1.0 / (far - near)) * horizontal_fov,
|
|
)
|
|
};
|
|
Some(if distance < near {
|
|
0.0
|
|
} else if distance > far {
|
|
1.0
|
|
} else {
|
|
((distance - near) * slope).clamp(0.0, 1.0)
|
|
})
|
|
}
|
|
|
|
/// Raster depth adjustment applied to one pipeline variant.
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
pub enum LegacyDepthBias {
|
|
/// Preserve the interpolated depth exactly.
|
|
#[default]
|
|
Disabled,
|
|
/// Move projected decals toward the camera so they pass against their
|
|
/// coplanar receiver under a less-or-equal depth test.
|
|
ProjectedDecal,
|
|
}
|
|
|
|
/// Legacy fixed-function state that changes graphics-pipeline structure.
|
|
///
|
|
/// Alpha reference is deliberately not present: it is dynamic material data,
|
|
/// whereas this state records only whether an alpha-test shader variant is used.
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
pub struct LegacyPipelineState {
|
|
/// Colour blend mode.
|
|
pub blend: LegacyBlendMode,
|
|
/// Depth test/write mode.
|
|
pub depth: LegacyDepthMode,
|
|
/// Face culling mode.
|
|
pub cull: LegacyCullMode,
|
|
/// Whether alpha-test shader logic is enabled.
|
|
pub alpha_test: bool,
|
|
/// Raster depth bias for projected decals.
|
|
pub depth_bias: LegacyDepthBias,
|
|
}
|
|
|
|
impl LegacyPipelineState {
|
|
/// Applies the native MAT0 pipeline category while retaining the caller's
|
|
/// depth and cull state.
|
|
///
|
|
/// Categories 0..4 are the complete table recovered from the native
|
|
/// material path. The native table enables `GREATER_EQUAL` alpha testing
|
|
/// for categories 1..4; the alpha reference remains per draw range.
|
|
#[must_use]
|
|
pub const fn with_material_category(self, category: u8) -> Option<Self> {
|
|
let (blend, alpha_test) = match category {
|
|
0 => (LegacyBlendMode::Opaque, false),
|
|
1 => (LegacyBlendMode::SourceAlpha, true),
|
|
2 => (LegacyBlendMode::Additive, true),
|
|
3 => (LegacyBlendMode::ZeroSourceColor, true),
|
|
4 => (LegacyBlendMode::DestColorSourceColor, true),
|
|
_ => return None,
|
|
};
|
|
Some(Self {
|
|
blend,
|
|
alpha_test,
|
|
..self
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Canonical, backend-neutral key for a graphics-pipeline variant.
|
|
///
|
|
/// The value is explicitly packed rather than hashed, so captures and caches
|
|
/// remain stable across processes and Rust toolchain updates. Bits 0..7 encode
|
|
/// blend, depth, cull and alpha-test state; bit 8 distinguishes projected
|
|
/// decal depth bias.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
|
pub struct PipelineKey(u16);
|
|
|
|
impl PipelineKey {
|
|
/// Returns the canonical packed representation.
|
|
#[must_use]
|
|
pub const fn packed(self) -> u16 {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
impl From<LegacyPipelineState> for PipelineKey {
|
|
fn from(state: LegacyPipelineState) -> Self {
|
|
let blend = match state.blend {
|
|
LegacyBlendMode::Opaque => 0,
|
|
LegacyBlendMode::SourceAlpha => 1,
|
|
LegacyBlendMode::Additive => 2,
|
|
LegacyBlendMode::ZeroSourceColor => 3,
|
|
LegacyBlendMode::DestColorSourceColor => 4,
|
|
};
|
|
let depth = match state.depth {
|
|
LegacyDepthMode::Disabled => 0,
|
|
LegacyDepthMode::TestWrite => 1,
|
|
LegacyDepthMode::TestReadOnly => 2,
|
|
};
|
|
let cull = match state.cull {
|
|
LegacyCullMode::Disabled => 0,
|
|
LegacyCullMode::BackFace => 1,
|
|
LegacyCullMode::FrontFace => 2,
|
|
};
|
|
let depth_bias = match state.depth_bias {
|
|
LegacyDepthBias::Disabled => 0,
|
|
LegacyDepthBias::ProjectedDecal => 1 << 8,
|
|
};
|
|
Self(blend | (depth << 3) | (cull << 5) | (u16::from(state.alpha_test) << 7) | depth_bias)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn portal_alpha_matches_native_packet_distance_vectors() {
|
|
// GOG Terrain+1B0F0 with its real +246A0/+247B0 packet methods,
|
|
// Ngi32 RSQRTSS dispatch, x87 CW=007F, FOV=1.04.
|
|
for (distance, flags, expected) in [
|
|
(0.499_938_96, 0x18, 0.0),
|
|
(4.999_542, 0x18, 0.0),
|
|
(9.999_084, 0x18, 0.132_426_6),
|
|
(25.000_572, 0x18, 0.500_011_44),
|
|
(59.999_084, 0x18, 1.0),
|
|
(85.012_48, 0x08, 0.0),
|
|
(109.994_125, 0x08, 0.649_765_2),
|
|
(25.000_572, 0x58, 0.500_011_44),
|
|
(110.0, 0x48, 0.0),
|
|
] {
|
|
let actual = legacy_msh_portal_alpha(flags, distance, 1.04).unwrap();
|
|
assert!(
|
|
(actual - expected).abs() < 0.000_002,
|
|
"{distance}: {actual}"
|
|
);
|
|
}
|
|
assert_eq!(legacy_msh_portal_alpha(8, 60.0, 1.3), Some(0.0));
|
|
assert!((legacy_msh_portal_alpha(8, 85.0, 1.3).unwrap() - 0.5).abs() < 0.000_001);
|
|
assert_eq!(legacy_msh_portal_alpha(8, 110.0, 1.3), Some(1.0));
|
|
for (distance, fov) in [
|
|
(f32::NAN, 1.3),
|
|
(-1.0, 1.3),
|
|
(1.0, 0.0),
|
|
(1.0, f32::INFINITY),
|
|
] {
|
|
assert_eq!(legacy_msh_portal_alpha(8, distance, fov), None);
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
#[test]
|
|
fn water_reflection_preserves_the_plane_and_reverses_height_before_projection() {
|
|
let matrix = [
|
|
1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0,
|
|
];
|
|
let reflected = reflect_clip_from_world_about_z(matrix, 150.0).unwrap();
|
|
let project = |m: [f32; 16], p: [f32; 4]| -> [f32; 4] {
|
|
std::array::from_fn(|column| (0..4).map(|row| p[row] * m[row * 4 + column]).sum())
|
|
};
|
|
for z in [125.0, 150.0, 185.0] {
|
|
assert_eq!(
|
|
project(reflected, [7.0, 11.0, z, 1.0]),
|
|
project(matrix, [7.0, 11.0, 300.0 - z, 1.0])
|
|
);
|
|
}
|
|
assert_eq!(
|
|
reflect_clip_from_world_about_z(reflected, 150.0),
|
|
Some(matrix)
|
|
);
|
|
assert!(reflect_clip_from_world_about_z(matrix, f32::NAN).is_none());
|
|
assert!(reflect_clip_from_world_about_z([f32::MAX; 16], f32::MAX).is_none());
|
|
}
|
|
|
|
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_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);
|
|
}
|
|
|
|
#[test]
|
|
fn raw_camera_transform_reproduces_direct3d7_view_axis_conversion() {
|
|
let transform = RawCameraTransform {
|
|
words: [
|
|
0.0_f32.to_bits(),
|
|
(-1.0_f32).to_bits(),
|
|
0.0_f32.to_bits(),
|
|
10.0_f32.to_bits(),
|
|
1.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
20.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
1.0_f32.to_bits(),
|
|
30.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
1.0_f32.to_bits(),
|
|
],
|
|
};
|
|
|
|
assert_eq!(
|
|
transform.try_direct3d7_view_row_major(),
|
|
Some([
|
|
1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, -10.0, -30.0, -20.0,
|
|
1.0,
|
|
])
|
|
);
|
|
assert_eq!(
|
|
RawCameraTransform { words: [0_u32; 16] }.try_direct3d7_view_row_major(),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_d3d7_projection_matches_recovered_camera_formula() {
|
|
let projection = LegacyD3d7Projection {
|
|
viewport: [0, 0, 1024, 768],
|
|
near_plane: 0.5,
|
|
far_plane: 700.0,
|
|
field_of_view_radians: 1.3,
|
|
};
|
|
let matrix = projection
|
|
.try_direct3d7_projection_row_major()
|
|
.expect("live Ngi32 projection parameters are valid");
|
|
let half_fov = 0.65_f32;
|
|
let depth_scale = half_fov.sin() * 700.0_f32 / 699.5;
|
|
|
|
assert_eq!(matrix[0], half_fov.cos());
|
|
assert_eq!(matrix[5], (4.0 / 3.0) * half_fov.cos());
|
|
assert_eq!(matrix[10], depth_scale);
|
|
assert_eq!(matrix[11], half_fov.sin());
|
|
assert_eq!(matrix[14], -(depth_scale * 0.5));
|
|
assert_eq!(
|
|
LegacyD3d7Projection {
|
|
viewport: [0, 0, 0, 768],
|
|
..projection
|
|
}
|
|
.try_direct3d7_projection_row_major(),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn iron3d_euler_builder_uses_rz_ry_rx_and_last_column_translation() {
|
|
let matrix = LegacyIron3dEulerTransform {
|
|
translation: [418.103_18, 717.433, 3.040_938_9],
|
|
orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2],
|
|
}
|
|
.try_row_major()
|
|
.expect("finite recovered builder inputs");
|
|
|
|
assert!((matrix[0]).abs() < 0.000_001);
|
|
assert!((matrix[1] + 1.0).abs() < 0.000_001);
|
|
assert!((matrix[4] - 1.0).abs() < 0.000_001);
|
|
assert!((matrix[5]).abs() < 0.000_001);
|
|
assert_eq!(matrix[3], 418.103_18);
|
|
assert_eq!(matrix[7], 717.433);
|
|
assert_eq!(matrix[11], 3.040_938_9);
|
|
assert_eq!(matrix[15], 1.0);
|
|
assert_eq!(
|
|
LegacyIron3dEulerTransform {
|
|
translation: [10.0, 20.0, 30.0],
|
|
orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2],
|
|
}
|
|
.try_transform_scaled_point([2.0, 3.0, 4.0], [2.0, 1.0, 0.5]),
|
|
Some([7.0, 24.0, 32.0])
|
|
);
|
|
assert_eq!(
|
|
LegacyIron3dEulerTransform {
|
|
translation: [0.0, 0.0, 0.0],
|
|
orientation_radians: [f32::NAN, 0.0, 0.0],
|
|
}
|
|
.try_row_major(),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn native_material_categories_preserve_depth_and_cull_state() {
|
|
let base = LegacyPipelineState {
|
|
depth: LegacyDepthMode::TestWrite,
|
|
cull: LegacyCullMode::BackFace,
|
|
..LegacyPipelineState::default()
|
|
};
|
|
let expected = [
|
|
(LegacyBlendMode::Opaque, false),
|
|
(LegacyBlendMode::SourceAlpha, true),
|
|
(LegacyBlendMode::Additive, true),
|
|
(LegacyBlendMode::ZeroSourceColor, true),
|
|
(LegacyBlendMode::DestColorSourceColor, true),
|
|
];
|
|
for (category, (blend, alpha_test)) in expected.into_iter().enumerate() {
|
|
let state = base
|
|
.with_material_category(category as u8)
|
|
.expect("native category is represented");
|
|
assert_eq!(state.blend, blend);
|
|
assert_eq!(state.alpha_test, alpha_test);
|
|
assert_eq!(state.depth, base.depth);
|
|
assert_eq!(state.cull, base.cull);
|
|
}
|
|
assert_eq!(base.with_material_category(5), None);
|
|
}
|
|
|
|
#[test]
|
|
fn projected_decal_bias_is_a_distinct_preserved_pipeline_state() {
|
|
let ordinary = LegacyPipelineState::default();
|
|
let decal = LegacyPipelineState {
|
|
depth_bias: LegacyDepthBias::ProjectedDecal,
|
|
..ordinary
|
|
};
|
|
assert_eq!(ordinary.depth_bias, LegacyDepthBias::Disabled);
|
|
assert_ne!(PipelineKey::from(ordinary), PipelineKey::from(decal));
|
|
assert_eq!(
|
|
decal
|
|
.with_material_category(1)
|
|
.expect("native category is represented")
|
|
.depth_bias,
|
|
LegacyDepthBias::ProjectedDecal
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn native_material_categories_have_distinct_pipeline_keys() {
|
|
let base = LegacyPipelineState {
|
|
depth: LegacyDepthMode::TestWrite,
|
|
..LegacyPipelineState::default()
|
|
};
|
|
let keys: Vec<_> = (0..=4)
|
|
.map(|category| {
|
|
PipelineKey::from(
|
|
base.with_material_category(category)
|
|
.expect("native category is represented"),
|
|
)
|
|
})
|
|
.collect();
|
|
for (index, key) in keys.iter().enumerate() {
|
|
assert!(keys[index + 1..].iter().all(|other| other != key));
|
|
}
|
|
}
|
|
}
|