333 lines
16 KiB
Rust
333 lines
16 KiB
Rust
//! 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,不实现一般透视矩阵求逆。
|
||
// panel::Mapping 用它把世界射线交点还原到面板局部平面;零缩放等退化情况返回 None,由调用方停止映射。
|
||
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,
|
||
// 透视角度或正交高度;相机本身不保存视口宽高比,render/pick 每次按具体出现传入 aspect。
|
||
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 {
|
||
// 观察平面的横向单位轴,来自 forward×up,不直接使用世界 X 轴。
|
||
pub right: Vec3,
|
||
// 与 right/forward 正交的上轴,可能不同于源相机未归一化的 up 参考向量。
|
||
pub up: Vec3,
|
||
// 从相机指向 target 的单位前向,view 以此方向为正深度。
|
||
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 {
|
||
// 世界空间起点;透视使用相机 position,正交则按视口点在相机平面上平移起点。
|
||
pub origin: Vec3,
|
||
// Camera::ray 返回单位方向;手工构造 Ray 不会自动归一化,triangle 返回的参数此时不保证等于欧氏距离。
|
||
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]))
|
||
}
|
||
}
|