Files
endorphin-tessera-gui/src/render/scene3d/math.rs
T

324 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 3D 数学基础:向量、仿射矩阵、摄像机基向量、投影射线和三角形相交。
//! 矩阵组合顺序决定局部变换如何进入父坐标系;接近退化的逆矩阵、法线和射线使用 Option 拒绝无效计算。
//!
use serde::{Deserialize, Serialize};
use std::ops::{Add, Mul, Sub};
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
/// 三维数学向量;构造不校验分量,需在输入边界显式调用 valid,不是托管 Vec3 对象。
// model/scene3d 用它读取场景变换,原生光栅化、网格拾取和 panel 坐标映射共同复用此类型。
pub struct Vec3 {
pub x: f32,
pub y: f32,
pub z: f32,
}
impl Vec3 {
/// 直接保存三个分量,不隐式归一化或拒绝非有限值。
pub const fn new(x: f32, y: f32, z: f32) -> Self {
Self { x, y, z }
}
/// 计算向量点积,用于方向投影、光照和相交判断。
pub fn dot(self, b: Self) -> f32 {
self.x * b.x + self.y * b.y + self.z * b.z
}
/// 计算右手叉积,用于摄像机基向量和三角形法线。
pub fn cross(self, b: Self) -> Self {
Self::new(
self.y * b.z - self.z * b.y,
self.z * b.x - self.x * b.z,
self.x * b.y - self.y * b.x,
)
}
/// 计算向量长度;归一化前还需检查是否接近零。
pub fn length(self) -> f32 {
self.dot(self).sqrt()
}
/// 仅在长度有效且不退化时返回单位向量,避免除零产生非有限结果。
pub fn normalized(self) -> Option<Self> {
// 先求模长,拒绝非有限值和长度不大于 1e-6 的向量;返回 None 让相机/法线调用方停止退化计算。
let length = self.length();
(length.is_finite() && length > 1e-6).then(|| self * (1.0 / length))
}
/// 要求三维向量各分量有限、绝对值不超过一百万;构造和算术运算不会自动调用此检查。
pub fn valid(self) -> bool {
// 构造函数与算术运算不会自动验证;此入口显式要求每个分量有限且绝对值至多一百万。
[self.x, self.y, self.z]
.iter()
.all(|v| v.is_finite() && v.abs() <= 1_000_000.0)
}
}
impl Add for Vec3 {
type Output = Self;
/// 按分量相加;摄像机射线用它叠加前向与视平面偏移。
fn add(self, b: Self) -> Self {
Self::new(self.x + b.x, self.y + b.y, self.z + b.z)
}
}
impl Sub for Vec3 {
type Output = Self;
/// 按分量相减;观察基与三角形求交用它得到相对位置和边向量。
fn sub(self, b: Self) -> Self {
Self::new(self.x - b.x, self.y - b.y, self.z - b.z)
}
}
impl Mul<f32> for Vec3 {
type Output = Self;
/// 每个分量乘同一个标量;归一化、射线步进和光照计算用它调整向量长度。
fn mul(self, b: f32) -> Self {
Self::new(self.x * b, self.y * b, self.z * b)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
/// 仿射 4×4 变换矩阵,支持局部到世界的组合与求逆。
// model/scene3d 从 TRS 构造并组合世界矩阵;panel 使用其逆矩阵将射线交点还原为局部 UI 位置。
pub struct Matrix(pub [f32; 16]);
impl Default for Matrix {
/// 返回单位矩阵,供场景遍历的根变换使用;与局部矩阵组合时保持原变换。
fn default() -> Self {
Self([
1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1.,
])
}
}
impl Matrix {
/// 求仿射世界矩阵的逆;退化或不可逆时返回空值。
pub fn inverse(self) -> Option<Self> {
// 这里按仿射矩阵处理:最后一行假设为 [0,0,0,1],只逆左上 3×3,不实现一般透视矩阵求逆。
let m = self.0;
let determinant = m[0] * (m[5] * m[10] - m[6] * m[9]) - m[1] * (m[4] * m[10] - m[6] * m[8])
+ m[2] * (m[4] * m[9] - m[5] * m[8]);
// 接近零行列式意味着某方向被压扁或数值不稳定;不能继续除以 determinant。
if !determinant.is_finite() || determinant.abs() < 1e-9 {
return None;
}
// 用伴随矩阵除行列式得到线性部分逆矩阵;先把逆平移留为零。
let d = 1. / determinant;
let mut out = Self([
(m[5] * m[10] - m[6] * m[9]) * d,
(m[2] * m[9] - m[1] * m[10]) * d,
(m[1] * m[6] - m[2] * m[5]) * d,
0.,
(m[6] * m[8] - m[4] * m[10]) * d,
(m[0] * m[10] - m[2] * m[8]) * d,
(m[2] * m[4] - m[0] * m[6]) * d,
0.,
(m[4] * m[9] - m[5] * m[8]) * d,
(m[1] * m[8] - m[0] * m[9]) * d,
(m[0] * m[5] - m[1] * m[4]) * d,
0.,
0.,
0.,
0.,
1.,
]);
// 仿射逆平移为 -A⁻¹t。此时 out 平移为零,所以 point 只应用刚求得的线性逆变换。
let translation = out.point(Vec3::new(m[3], m[7], m[11])) * -1.;
out.0[3] = translation.x;
out.0[7] = translation.y;
out.0[11] = translation.z;
Some(out)
}
/// 组合父子仿射变换,调用顺序决定局部坐标最终落在哪个世界位置。
pub fn compose(self, local: Self) -> Self {
// 行主序存储,点视作列向量;self.compose(local) = self × local,即先应用 local 再应用 self。
let mut out = [0.; 16];
// 逐行逐列计算输出的 16 个元素;k 遍历 self 的行与 local 的列,累加四项点积。
for row in 0..4 {
// row/col 都属于输出矩阵;不能交换两个乘数,否则父子变换顺序会反转。
for col in 0..4 {
out[row * 4 + col] = (0..4)
.map(|k| self.0[row * 4 + k] * local.0[k * 4 + col])
.sum();
}
}
Self(out)
}
/// 对一个位置应用矩阵,包括平移项;不要用它代替纯方向变换。
pub fn point(self, p: Vec3) -> Vec3 {
Vec3::new(
self.0[0] * p.x + self.0[1] * p.y + self.0[2] * p.z + self.0[3],
self.0[4] * p.x + self.0[5] * p.y + self.0[6] * p.z + self.0[7],
self.0[8] * p.x + self.0[9] * p.y + self.0[10] * p.z + self.0[11],
)
}
/// 由平移、角度旋转和缩放构造局部仿射矩阵。
pub fn trs(position: Vec3, rotation: Vec3, scale: Vec3) -> Self {
// 输入欧拉角单位为度,先转换为弧度。组合为 T × Rz × Ry × Rx × S,列分别乘对应缩放轴。
// 分别缓存三个轴的正弦和余弦,后面的矩阵展开式复用这些值。
let (sx, cx) = rotation.x.to_radians().sin_cos();
let (sy, cy) = rotation.y.to_radians().sin_cos();
let (sz, cz) = rotation.z.to_radians().sin_cos();
// 点先缩放,再依次绕局部 X、Y、Z 旋转,最后加平移;这里直接展开组合矩阵以避免临时矩阵。
Self([
cz * cy * scale.x,
(cz * sy * sx - sz * cx) * scale.y,
(cz * sy * cx + sz * sx) * scale.z,
position.x,
sz * cy * scale.x,
(sz * sy * sx + cz * cx) * scale.y,
(sz * sy * cx - cz * sx) * scale.z,
position.y,
-sy * scale.x,
cy * sx * scale.y,
cy * cx * scale.z,
position.z,
0.,
0.,
0.,
1.,
])
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
/// 投影策略:透视视场角或正交可见高度。
pub enum Projection {
// fov 表示垂直视场角(度),水平范围再乘视口宽高比。
Perspective { fov: f32 },
// height 是相机空间可见总高度,与深度无关;宽度为 height × aspect。
Orthographic { height: f32 },
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
/// 原生摄像机描述,包括观察基、近远面和投影参数;宽高比由视口提供。
// 场景发布时由 model 验证参数;View 渲染/拾取与 panel 映射随后使用同一份相机描述。
pub struct Camera {
// 世界空间眼睛位置;透视投影的全部拾取射线从这里出发。
pub position: Vec3,
// 世界空间观察目标,用 target-position 确定前向,而不是直接保存欧拉角。
pub target: Vec3,
// 提供上方向参考,basis 会重新正交化;与前向平行时无法形成观察基。
pub up: Vec3,
// 近远面按相机正向深度裁剪;valid 要求 0 < near < far。
pub near: f32,
pub far: f32,
pub projection: Projection,
}
impl Default for Camera {
/// 返回从世界 z=5 观察原点的透视相机,供缺省场景与原生测试构造使用。
fn default() -> Self {
Self {
position: Vec3::new(0., 0., 5.),
target: Vec3::default(),
up: Vec3::new(0., 1., 0.),
near: 0.1,
far: 1000.,
projection: Projection::Perspective { fov: 50. },
}
}
}
#[derive(Debug, Clone, Copy)]
/// 摄像机的右、上、前单位基向量。
pub struct Basis {
pub right: Vec3,
pub up: Vec3,
pub forward: Vec3,
}
impl Camera {
/// 从观察位置、目标和上方向建立正交相机基;方向退化时拒绝。
pub fn basis(self) -> Option<Basis> {
// forward 指向观察目标;up 输入只提供大致上方向,叉积后重新正交化。
// 位置等于目标、up 为零或与前向平行时,normalized 返回 None。
let forward = (self.target - self.position).normalized()?;
// 前向叉上方向产生右向;再次归一化,消除输入 up 长度对后续投影的影响。
let right = forward.cross(self.up).normalized()?;
// 用已确定的右向和前向重建垂直上向,让三个基向量彼此正交。
let up = right.cross(forward).normalized()?;
Some(Basis { right, up, forward })
}
/// 验证相机向量/观察基、裁剪面次序和投影参数;退化方向、非法视场角或正交高度均返回 false。
pub fn valid(self) -> bool {
// 除了向量范围,还要求有效观察基、0 < near < far,以及合法视场角或正交高度。
self.position.valid()
&& self.target.valid()
&& self.up.valid()
&& self.basis().is_some()
&& self.near.is_finite()
&& self.far.is_finite()
&& self.near > 0.
&& self.far > self.near
&& self.far <= 1_000_000.
// 投影类型决定最后的合法性条件;透视角度受限以避免极端 tan,正交高度必须为正。
&& match self.projection {
Projection::Perspective { fov } => fov.is_finite() && (1.0..=175.0).contains(&fov),
Projection::Orthographic { height } => {
height.is_finite() && height > 0. && height <= 1_000_000.
}
}
}
/// 把世界点投影到相机的右/上/前基向量坐标。
pub fn view(self, basis: Basis, p: Vec3) -> Vec3 {
// 先把世界点相对化,再点乘正交基;这里前方 z 为正,与某些图形 API 的负 z 约定不同。
let p = p - self.position;
Vec3::new(p.dot(basis.right), p.dot(basis.up), p.dot(basis.forward))
}
/// 从视口归一化坐标生成透视或正交射线;宽高比由具体视口提供。
pub fn ray(self, u: f32, v: f32, aspect: f32) -> Option<Ray> {
// View::pick 和 panel::Mapping 的逆投影都从这里生成射线;退化观察基直接向上传播 None。
let basis = self.basis()?;
// u/v 通常是视口内 [0,1],映射到 [-1,1] 并翻转屏幕 y。
// 此函数不钳制输入,捕获拖动可以从视口外继续生成射线;aspect 由调用方保证有效。
let (x, y) = (u * 2. - 1., 1. - v * 2.);
match self.projection {
Projection::Perspective { fov } => {
// 透视射线共用相机原点,方向沿前向加上视平面偏移,最后归一化。
let half = (fov.to_radians() / 2.).tan();
Some(Ray {
origin: self.position,
direction: (basis.forward
+ basis.right * (x * half * aspect)
+ basis.up * (y * half))
.normalized()?,
})
}
// 正交射线方向都为 forward,只改变起点在相机右/上平面中的位置。
Projection::Orthographic { height } => Some(Ray {
origin: self.position
+ basis.right * (x * height * aspect / 2.)
+ basis.up * (y * height / 2.),
direction: basis.forward,
}),
}
}
}
#[derive(Debug, Clone, Copy)]
/// 世界坐标中的射线原点与方向,供网格和面板求交。
pub struct Ray {
pub origin: Vec3,
pub direction: Vec3,
}
impl Ray {
/// 求射线与三角形相交距离和重心坐标,拒绝退化三角形及射线起点后方的交点。
// View::pick 遍历网格三角形时调用,使用返回的射线参数选最近命中,重心权重还可插值纹理坐标。
pub fn triangle(self, a: Vec3, b: Vec3, c: Vec3) -> Option<(f32, [f32; 3])> {
// Möller–Trumbore 求交:使用两条三角形边与叉积解重心系数,不先构造平面方程。
let ab = b - a;
let ac = c - a;
let p = self.direction.cross(ac);
let determinant = ab.dot(p);
// 近零表示射线与面平行或三角形退化;保留正负行列式,因此不做背面剔除。
if determinant.abs() < 1e-7 {
return None;
}
// 复用行列式倒数以解重心系数;t 此处是从顶点 a 指向射线起点的向量。
let inverse = 1.0 / determinant;
let t = self.origin - a;
// u 是顶点 b 的重心权重,必须位于 [0,1];v 是 c 的权重,a 的权重由 1-u-v 得到。
let u = t.dot(p) * inverse;
if !(0.0..=1.0).contains(&u) {
return None;
}
// 第二次叉积用于同时求顶点 c 的权重和射线参数,避免另外求解完整线性方程组。
let q = t.cross(ab);
let v = self.direction.dot(q) * inverse;
// v 为负或 b/c 权重和超过 1 都表示交点落在三角形外,即使它仍位于三角形所在平面。
if v < 0. || u + v > 1. {
return None;
}
// 返回的是射线参数 t,方向为单位向量时才等于世界距离;负值在射线起点后方,被拒绝。
let distance = ac.dot(q) * inverse;
(distance >= 0.).then_some((distance, [1. - u - v, u, v]))
}
}