338 lines
17 KiB
Rust
338 lines
17 KiB
Rust
//! 不可变世界空间 BVH。用网格变换后的包围盒快速筛选视锥与射线候选。
|
|
//! 空间索引只做宽阶段筛选;最终拾取仍检查真实三角形。相机变化可复用索引,几何或变换变化需要新索引。
|
|
//!
|
|
//! Immutable world-space BVH. Camera changes reuse it; it contains no VM data.
|
|
use super::{
|
|
Mesh,
|
|
math::{Basis, Camera, Ray, Vec3},
|
|
};
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
/// 变换后网格的世界空间 AABB,做保守筛选而不是精确表面描述。
|
|
// Index::build 为每个非空 Mesh 构造,查询时用它拒绝整组不可能命中的实例。
|
|
pub struct Bounds {
|
|
// 世界 x/y/z 三轴最小值;max 为最大值,二者围成与世界轴对齐的保守外包围。
|
|
pub min: Vec3,
|
|
pub max: Vec3,
|
|
}
|
|
impl Bounds {
|
|
/// 变换网格顶点并求世界 AABB,作为空间索引的保守外包围。
|
|
// Index::build 调用;必须从真实变换后顶点取极值,不能只变换局部 min/max 两个角。
|
|
fn mesh(mesh: &Mesh) -> Option<Self> {
|
|
// 惰性遍历局部顶点并应用 world,每个非空网格只扫描一次来建立包围盒。
|
|
let mut points = mesh
|
|
.geometry
|
|
.vertices
|
|
.iter()
|
|
.map(|point| mesh.world.point(*point));
|
|
// 空网格没有包围盒,返回 None 后不会进入树;首顶点初始化上下界,避免把原点误算进边界。
|
|
let first = points.next()?;
|
|
// 以首顶点为初始界,其余点逐分量扩大范围;fold 的最终值同时覆盖所有变换后顶点。
|
|
Some(points.fold(
|
|
Self {
|
|
min: first,
|
|
max: first,
|
|
},
|
|
|bounds, point| Self {
|
|
min: Vec3::new(
|
|
bounds.min.x.min(point.x),
|
|
bounds.min.y.min(point.y),
|
|
bounds.min.z.min(point.z),
|
|
),
|
|
max: Vec3::new(
|
|
bounds.max.x.max(point.x),
|
|
bounds.max.y.max(point.y),
|
|
bounds.max.z.max(point.z),
|
|
),
|
|
},
|
|
))
|
|
}
|
|
/// 求两个包围盒的最小共同外包围,供 BVH 父节点使用。
|
|
// Index::split 用 reduce 合并当前节点全部实例的界,父节点可一次剔除整棵子树。
|
|
fn union(self, other: Self) -> Self {
|
|
Self {
|
|
min: Vec3::new(
|
|
self.min.x.min(other.min.x),
|
|
self.min.y.min(other.min.y),
|
|
self.min.z.min(other.min.z),
|
|
),
|
|
max: Vec3::new(
|
|
self.max.x.max(other.max.x),
|
|
self.max.y.max(other.max.y),
|
|
self.max.z.max(other.max.z),
|
|
),
|
|
}
|
|
}
|
|
/// 取指定轴中心位置,供最长轴排序和分割使用。
|
|
// split 选择排序轴后用它计算中心;axis 只由内部逻辑产生 0/1/2,不接受任意外部下标。
|
|
fn center(self, axis: usize) -> f32 {
|
|
// 临时三分量数组便于按轴索引,避免为 x/y/z 写三套排序表达式。
|
|
let min = [self.min.x, self.min.y, self.min.z];
|
|
let max = [self.max.x, self.max.y, self.max.z];
|
|
(min[axis] + max[axis]) / 2.
|
|
}
|
|
/// 用包围盒筛出射线候选;索引查询按进入距离排序,精确三角形求交在调用方完成。
|
|
// Index::ray 同时用它测试树节点和叶内实例;返回值是射线参数下界,不是精确表面交点。
|
|
fn ray(self, ray: Ray) -> Option<f32> {
|
|
// slab 算法逐轴交区间;near 从零开始,只保留射线前方,起点在盒内时返回零。
|
|
let mut near = 0f32;
|
|
let mut far = f32::INFINITY;
|
|
// 每个轴给出一个可行射线参数区间,逐轴求交后才得到穿过整个盒子的区间。
|
|
for (origin, direction, min, max) in [
|
|
(ray.origin.x, ray.direction.x, self.min.x, self.max.x),
|
|
(ray.origin.y, ray.direction.y, self.min.y, self.max.y),
|
|
(ray.origin.z, ray.direction.z, self.min.z, self.max.z),
|
|
] {
|
|
// 近乎平行该轴时不做除法;起点在 slab 外即可拒绝,在 slab 内则该轴不收窄距离。
|
|
if direction.abs() < 1e-12 {
|
|
// 近乎平行且起点在该轴两面之外时,射线无法进入盒子;其余轴不必继续测试。
|
|
if origin < min || origin > max {
|
|
return None;
|
|
}
|
|
continue;
|
|
}
|
|
// 求该轴两边界的射线参数,方向为负时 a/b 次序会反转,下面统一取 min/max。
|
|
let a = (min - origin) / direction;
|
|
let b = (max - origin) / direction;
|
|
// 方向可为负,先排序该轴进入/退出距离,再与之前各轴区间求交。
|
|
near = near.max(a.min(b));
|
|
far = far.min(a.max(b));
|
|
// 三个轴的参数交集为空时拒绝;far==near 的擦边仍保留,交给精确求交处理。
|
|
if far < near {
|
|
return None;
|
|
}
|
|
}
|
|
// 起点在盒内时 near 保持 0;View::pick 将该下界与已有最近三角形距离比较来提前结束。
|
|
Some(near)
|
|
}
|
|
/// 检查八角是否全落在任一视锥平面外侧;返回保守可见性,不代表真实三角形可见。
|
|
// Index::visible 对节点和叶内实例使用同一保守测试;相机基和六平面由查询入口统一准备。
|
|
fn visible(self, camera: Camera, basis: Basis, planes: &[[f32; 4]; 6]) -> bool {
|
|
// 用三位索引枚举 AABB 八角,再转到相机空间,使角点与视锥平面处于同一坐标系。
|
|
let corners: [Vec3; 8] = std::array::from_fn(|index| {
|
|
camera.view(
|
|
basis,
|
|
Vec3::new(
|
|
// 索引最低位选 x,接着两位选 y/z 的 min 或 max,组合恰好覆盖八个角点。
|
|
if index & 1 == 0 {
|
|
self.min.x
|
|
} else {
|
|
self.max.x
|
|
},
|
|
if index & 2 == 0 {
|
|
self.min.y
|
|
} else {
|
|
self.max.y
|
|
},
|
|
if index & 4 == 0 {
|
|
self.min.z
|
|
} else {
|
|
self.max.z
|
|
},
|
|
),
|
|
)
|
|
});
|
|
// 只有某个平面外侧包含全部八角时才剔除;盒与视锥可能无真实相交也会保守保留。
|
|
!planes.iter().any(|[a, b, c, d]| {
|
|
corners
|
|
.iter()
|
|
.all(|point| a * point.x + b * point.y + c * point.z + d < 0.)
|
|
})
|
|
}
|
|
}
|
|
#[derive(Debug)]
|
|
/// BVH 叶或分支;叶保存实例索引,分支保存两个子节点索引。
|
|
// 节点保存在 Index.nodes 连续数组,分支通过下标连接子树,查询不持有递归引用。
|
|
enum Node {
|
|
// 少量实例的叶节点,items 是原 Scene.meshes 下标,并非几何中的三角形下标。
|
|
Leaf {
|
|
bounds: Bounds,
|
|
items: Vec<usize>,
|
|
},
|
|
// 内部节点合并两个子树的界;命中父界后再把左右子节点加入查询栈。
|
|
Branch {
|
|
bounds: Bounds,
|
|
left: usize,
|
|
right: usize,
|
|
},
|
|
}
|
|
impl Node {
|
|
/// 读取叶/分支统一的包围盒,隐藏节点种类差异。
|
|
// ray/visible 不必分别处理叶和分支的界,先统一测试,再决定是否展开子节点。
|
|
fn bounds(&self) -> Bounds {
|
|
// 两种节点共享同名 bounds 字段且 Bounds 可复制,返回值无需借用整棵树。
|
|
match self {
|
|
Self::Leaf { bounds, .. } | Self::Branch { bounds, .. } => *bounds,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
/// 由不可变场景建立的包围盒树,供相机视锥和射线查询复用。
|
|
pub struct Index {
|
|
// 长度始终等于构建时网格数;空网格对应 None,不入树但保留原场景下标。
|
|
bounds: Vec<Option<Bounds>>,
|
|
// 节点数组先放父占位再递归放子节点,子树返回后补写父分支的左右下标。
|
|
nodes: Vec<Node>,
|
|
// 空输入或全空网格时没有根,查询从空栈开始自然返回空候选。
|
|
root: Option<usize>,
|
|
}
|
|
impl Index {
|
|
/// 用变换后的网格顶点建立世界 AABB 与 BVH;忽略空网格,树内仅保存原生几何下标。
|
|
// model/scene3d 在世界数据变化时建立缓存;View::pick/render_aspect 无匹配缓存时也调用此函数临时构建。
|
|
pub fn build(meshes: &[Mesh]) -> Self {
|
|
// 保留与原网格下标等长的包围盒数组;items 仅收集非空网格,最终候选仍指向原场景下标。
|
|
let bounds: Vec<_> = meshes.iter().map(Bounds::mesh).collect();
|
|
// 收集带有效界的原实例下标供后续排序分割;不复制几何,也不重排外部 Scene.meshes。
|
|
let mut items: Vec<_> = bounds
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(index, bounds)| bounds.map(|_| index))
|
|
.collect();
|
|
// 先保存完整 bounds 表,节点和根从空开始,递归分割只改变索引自身。
|
|
let mut index = Self {
|
|
bounds,
|
|
nodes: Vec::new(),
|
|
root: None,
|
|
};
|
|
// split 需要非空切片才能 reduce/unwrap;空集合直接保留 root=None。
|
|
if !items.is_empty() {
|
|
index.root = Some(index.split(&mut items));
|
|
}
|
|
index
|
|
}
|
|
/// 按包围盒最长轴的中心排序并递归二分,叶节点保留少量网格候选。
|
|
// build 首次调用,随后自身递归;每次切片保证非空,返回节点数组中的稳定下标。
|
|
fn split(&mut self, items: &mut [usize]) -> usize {
|
|
// 把当前切片内所有非空实例界合并为节点界;items 来自过滤后的集合,因此 unwrap 有前提。
|
|
let bounds = items
|
|
.iter()
|
|
.map(|&index| self.bounds[index].unwrap())
|
|
.reduce(Bounds::union)
|
|
.unwrap();
|
|
// 先占位父节点,递归子树会扩展 nodes;返回后按稳定下标替换父占位,不保留可失效的引用。
|
|
let index = self.nodes.len();
|
|
self.nodes.push(Node::Leaf {
|
|
bounds,
|
|
items: Vec::new(),
|
|
});
|
|
// 至多四项成为叶;更大集合沿包围盒最长轴排序后二等分,控制树深而非按三角形拆分。
|
|
if items.len() <= 4 {
|
|
self.nodes[index] = Node::Leaf {
|
|
bounds,
|
|
items: items.to_vec(),
|
|
};
|
|
} else {
|
|
// 较大的集合按合并包围盒的最长轴切分,避免总沿固定轴分割拉长的场景。
|
|
let extent = bounds.max - bounds.min;
|
|
// 相等时优先 x 再 y,选择只影响树形;最终查询排序恢复外部可见顺序。
|
|
let axis = if extent.x >= extent.y && extent.x >= extent.z {
|
|
0
|
|
} else if extent.y >= extent.z {
|
|
1
|
|
} else {
|
|
2
|
|
};
|
|
// 按选定轴的中心排序,total_cmp 提供确定的浮点比较规则;此处不按三角形或材质排序。
|
|
items.sort_unstable_by(|&a, &b| {
|
|
self.bounds[a]
|
|
.unwrap()
|
|
.center(axis)
|
|
.total_cmp(&self.bounds[b].unwrap().center(axis))
|
|
});
|
|
// 两半均非空,即使所有中心重合也能递减集合规模,保证递归终止。
|
|
let mid = items.len() / 2;
|
|
// 把可变切片分成互不重叠两半供递归使用,变量随后被各自节点下标遮蔽。
|
|
let (left, right) = items.split_at_mut(mid);
|
|
let left = self.split(left);
|
|
let right = self.split(right);
|
|
// 两个子树都已存在,再用同一父下标覆盖占位节点,连接左右子树。
|
|
self.nodes[index] = Node::Branch {
|
|
bounds,
|
|
left,
|
|
right,
|
|
};
|
|
}
|
|
index
|
|
}
|
|
/// 检查索引覆盖的网格数量是否与场景相符;缺失缓存时允许重建。
|
|
// View 在查询前用它选择缓存或临时构建;不验证顶点或 world 变换,几何改变后须由快照层重建。
|
|
pub fn matches(&self, meshes: usize) -> bool {
|
|
// 仅为缺失/长度失配提供快速判断,不是网格内容指纹;相同数量下改 world/顶点仍须由不可变快照生产方重建。
|
|
self.bounds.len() == meshes
|
|
}
|
|
/// 用包围盒筛出射线候选;索引查询按进入距离排序,精确三角形求交在调用方完成。
|
|
// View::pick 消费排序候选,再逐三角形求交与裁剪;本函数不检查相机 near/far。
|
|
pub fn ray(&self, ray: Ray) -> Vec<(f32, usize)> {
|
|
// 此处遍历后一次性返回全部盒候选;View::pick 对排序结果做精确求交和距离提前停止,不是让 BVH 直接返回最近表面。
|
|
// 空树的栈为空;显式栈避免查询递归,先测分支包围盒,未命中时整棵子树跳过。
|
|
let mut stack: Vec<_> = self.root.into_iter().collect();
|
|
// 收集 (盒进入参数, 原实例下标),遍历结束后统一排序以支持 pick 的距离提前终止。
|
|
let mut output = Vec::new();
|
|
// 深度优先弹出待查节点;树构建时已保证子节点下标有效,不需要在循环中重新验证。
|
|
while let Some(index) = stack.pop() {
|
|
// 节点整体不与射线相交,叶内实例或分支下的所有后代都可跳过。
|
|
if self.nodes[index].bounds().ray(ray).is_none() {
|
|
continue;
|
|
}
|
|
// 叶逐实例精测包围盒,分支继续展开;节点界命中不等于其中每个实例界都命中。
|
|
match &self.nodes[index] {
|
|
Node::Leaf { items, .. } => {
|
|
// 叶的合并界可能比单实例界大,因此逐项测试以减少最终三角形候选。
|
|
for &item in items {
|
|
// 只把实际穿过的实例盒加入结果,保留原下标用于查回 Scene.meshes。
|
|
if let Some(distance) = self.bounds[item].unwrap().ray(ray) {
|
|
output.push((distance, item));
|
|
}
|
|
}
|
|
}
|
|
Node::Branch { left, right, .. } => {
|
|
// 栈后进先出,右子树会先遍历;下方排序消除遍历顺序对候选次序的影响。
|
|
stack.push(*left);
|
|
stack.push(*right);
|
|
}
|
|
}
|
|
}
|
|
// 距离是盒进入距离而非三角形命中距离;同距按场景下标排序,精确拾取仍由调用方完成。
|
|
output.sort_unstable_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
|
|
output
|
|
}
|
|
/// 用相机六平面保守判断包围盒可见性,返回保持原场景顺序的候选。
|
|
// render_aspect 用于剔除离屏网格,panel 纹理也只对返回候选生成,节约中间表面预算。
|
|
pub fn visible(&self, camera: Camera, aspect: f32) -> Vec<usize> {
|
|
// 相机无法建立有效正交基时没有可靠视锥,返回空候选,不继续使用无效投影。
|
|
let Some(basis) = camera.basis() else {
|
|
return Vec::new();
|
|
};
|
|
// 与 render_aspect 的三角形裁剪共享平面公式,确保宽阶段不会使用另一套投影约定。
|
|
let planes = super::frustum_planes(camera, aspect);
|
|
// 空根转换为空迭代器,非空根变成只有一个元素的初始查询栈。
|
|
let mut stack: Vec<_> = self.root.into_iter().collect();
|
|
// 输出原实例下标,最后升序排列以维持光栅化的同深度先绘制优先规则。
|
|
let mut output = Vec::new();
|
|
// 先测试节点合并界;完全处于任一视锥平面外的子树无需逐实例遍历。
|
|
while let Some(index) = stack.pop() {
|
|
if !self.nodes[index].bounds().visible(camera, basis, &planes) {
|
|
continue;
|
|
}
|
|
match &self.nodes[index] {
|
|
// 叶内继续逐实例做保守可见性筛选,不能把整个叶直接视为可见。
|
|
Node::Leaf { items, .. } => output.extend(
|
|
items
|
|
.iter()
|
|
.copied()
|
|
.filter(|item| self.bounds[*item].unwrap().visible(camera, basis, &planes)),
|
|
),
|
|
// 分支只负责加入后续待测节点,真正可见实例仍在叶中确认。
|
|
Node::Branch { left, right, .. } => {
|
|
stack.push(*left);
|
|
stack.push(*right);
|
|
}
|
|
}
|
|
}
|
|
// BVH 遍历顺序与场景顺序不同;恢复原下标顺序以保持后续绘制及同深度处理稳定。
|
|
output.sort_unstable();
|
|
output
|
|
}
|
|
}
|