docs: explain 3D rasterization, picking and spatial indexing
This commit is contained in:
@@ -11,19 +11,24 @@ use math::{Camera, Matrix, Projection, Vec3};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
// 单份 Geometry 和全场景累计三角形都使用此上限;顶点另有单几何及全场景两种预算。
|
||||
pub const MAX_TRIANGLES: usize = 65_536;
|
||||
pub const MAX_VERTICES: usize = 32_768;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
/// 不可变顶点、三角形和可选 UV;多个网格可共享同一数值缓冲。
|
||||
pub struct Geometry {
|
||||
// 局部坐标顶点;渲染与拾取都先乘 Mesh.world,再执行相机空间操作。
|
||||
pub vertices: Vec<Vec3>,
|
||||
// 每组三个下标引用 vertices;valid 在边界检查索引,热循环随后直接索引。
|
||||
pub triangles: Vec<[u32; 3]>,
|
||||
#[serde(default)]
|
||||
// 缺省为空;非空时与顶点等长,裁剪交点继承插值 UV,采样时再钳到纹理范围。
|
||||
pub uv: Vec<[f32; 2]>,
|
||||
}
|
||||
impl Geometry {
|
||||
/// 检查顶点与三角数量、索引范围、坐标有效性及逐顶点 UV;允许缺省 UV,不在此限制 UV 到单位区间。
|
||||
// model 的场景快照与 View::valid 调用几何校验;这里不保证三角形有非零面积,退化面在后续计算跳过。
|
||||
pub fn valid(&self) -> bool {
|
||||
// 检查顶点/三角形资源量和所有索引范围;UV 可省略,否则必须逐顶点对应且每个分量有限。
|
||||
// 不在这里强制 UV 位于 [0,1],纹理采样阶段再钳制。
|
||||
@@ -40,6 +45,7 @@ impl Geometry {
|
||||
.all(|&index| (index as usize) < self.vertices.len())
|
||||
}
|
||||
/// 生成以原点为中心的盒体顶点和三角形索引;位置和旋转由网格世界矩阵另行描述。
|
||||
// model/scene3d 的盒体几何构造路径调用;返回可共享的数值网格,不在这里创建 VM 对象。
|
||||
pub fn cuboid(width: f32, height: f32, depth: f32) -> Self {
|
||||
// 八个角点共享位置,每个面拆两个三角形;尺寸有效性由构造调用边界检查,cuboid 本身只生成数值。
|
||||
let (x, y, z) = (width / 2., height / 2., depth / 2.);
|
||||
@@ -77,15 +83,20 @@ impl Geometry {
|
||||
pub struct Mesh {
|
||||
// 纯文本业务关联身份,用于拾取返回;不作为渲染线程可操作的托管引用。
|
||||
pub identity: String,
|
||||
// 共享局部几何;同一 Geometry 可配不同 world 矩阵形成多个实例。
|
||||
pub geometry: Arc<Geometry>,
|
||||
// 局部到世界的累计仿射矩阵,父子变换已在快照遍历阶段组合。
|
||||
pub world: Matrix,
|
||||
// 归一化 RGB 材质色,普通网格逐通道乘光照;有纹理面板直接使用纹理颜色。
|
||||
pub color: [f32; 3],
|
||||
// Some 表示世界 UI 面板,纹理由 panel::texture 生成;None 走普通材质分支。
|
||||
pub panel: Option<Panel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
/// 面板内普通 UI 的绘制命令及其裁剪/二维变换,逻辑排版不随外部相机改变。
|
||||
pub struct UiSurface {
|
||||
// panel::compose 把所属 Mount 的命令装入这里;clips/views 按命令索引一一对应。
|
||||
pub commands: Vec<crate::document::RenderCommand>,
|
||||
pub clips: Vec<Option<[f32; 4]>>,
|
||||
pub views: Vec<Option<crate::viewport::Transform>>,
|
||||
@@ -93,15 +104,19 @@ pub struct UiSurface {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
/// 世界平面的有限 UI 纹理描述,包含像素尺寸、背景与内部显示内容。
|
||||
pub struct Panel {
|
||||
// 一次面板实例的稳定 key,panel::Mapping 用它在视图内找到对应网格。
|
||||
pub key: u64,
|
||||
// 纹理分辨率,单位为 UI 像素;与网格世界宽高分开,View::valid 限制每轴至多 2048。
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub background: [f32; 3],
|
||||
// 装配后的普通 UI 子树;None 时纹理只保留背景,Arc 允许不可变快照共享内容。
|
||||
pub surface: Option<Arc<UiSurface>>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
/// 方向光的原生数值,方向和强度在场景快照阶段校验。
|
||||
pub struct Light {
|
||||
// 光线传播方向;Lambert 使用其反向和面法线点积,快照校验要求近似单位长度。
|
||||
pub direction: Vec3,
|
||||
pub color: [f32; 3],
|
||||
pub intensity: f32,
|
||||
@@ -109,8 +124,10 @@ pub struct Light {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
/// 不含摄像机的世界实例和光照集合;BVH 是可丢弃的派生缓存。
|
||||
pub struct Scene {
|
||||
// 原场景实例顺序也是等深度绘制和拾取的优先顺序,空间查询后仍须保留这个约定。
|
||||
pub meshes: Vec<Mesh>,
|
||||
pub lights: Vec<Light>,
|
||||
// 所有通道共用的环境光底值,方向光在光栅化时逐通道累加。
|
||||
pub ambient: f32,
|
||||
pub background: [f32; 3],
|
||||
#[serde(skip)]
|
||||
@@ -134,6 +151,7 @@ pub struct View {
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// 只读绘制结果使用的名义包装,让源 PaintCommand 可以携带不可变 3D 快照。
|
||||
// native_visual 创建此 payload,source_paint 解码绘制命令时取出 Arc<View>;渲染线程只使用不可变数值快照。
|
||||
pub struct PaintView(pub Arc<View>);
|
||||
impl endor_core::ObjectPayload for PaintView {
|
||||
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
|
||||
@@ -148,18 +166,22 @@ impl endor_core::ObjectPayload for PaintView {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// 射线与实际三角形的精确交点,含实例身份、距离、法线和重心坐标。
|
||||
pub struct Hit {
|
||||
// 命中的 Scene.meshes 下标,用于面板遮挡判断;identity 则供 model 重新关联业务 Mesh。
|
||||
pub instance: usize,
|
||||
pub identity: String,
|
||||
pub point: Vec3,
|
||||
pub normal: Vec3,
|
||||
pub distance: f32,
|
||||
// 原几何中的三角形下标,barycentric 依次对应该面三个顶点的权重。
|
||||
pub triangle: usize,
|
||||
pub barycentric: [f32; 3],
|
||||
}
|
||||
|
||||
impl View {
|
||||
/// 递归验证相机、材质、灯光、网格/面板表面及全场景资源预算;世界 UI 嵌套最多八层。
|
||||
// 绘制命令验证和 render_aspect 都调用此入口,确保直接索引、纹理尺寸和相机计算所需的前提。
|
||||
pub fn valid(&self) -> bool {
|
||||
// DEPTH 按线程累计当前调用链,不是场景内网格个数;面板命令 validate 可能再次调用 View::valid。
|
||||
thread_local! { static DEPTH:std::cell::Cell<usize>=const { std::cell::Cell::new(0) }; }
|
||||
// 验证可能递归进入面板内的 3D;作用域结束时归还深度,错误返回也不泄漏计数。
|
||||
struct Guard;
|
||||
@@ -169,7 +191,9 @@ impl View {
|
||||
DEPTH.with(|depth| depth.set(depth.get() - 1));
|
||||
}
|
||||
}
|
||||
// 只有成功增加深度的调用才创建下方 Guard;到达八层时直接失败,不能误减尚未增加的计数。
|
||||
if !DEPTH.with(|depth| {
|
||||
// 达到上限不修改计数,外层仍负责恢复自己的深度。
|
||||
if depth.get() >= 8 {
|
||||
false
|
||||
} else {
|
||||
@@ -181,6 +205,7 @@ impl View {
|
||||
}
|
||||
// 深度检查通过才建立 Guard,之后任一短路校验失败都会恢复计数;上限覆盖嵌套面板中的 3D 命令。
|
||||
let _guard = Guard;
|
||||
// 局部闭包统一校验背景、材质和光色,拒绝 NaN/无穷及超出 [0,1] 的通道。
|
||||
let color_valid = |color: [f32; 3]| {
|
||||
color
|
||||
.iter()
|
||||
@@ -224,11 +249,13 @@ impl View {
|
||||
&& panel.width <= 2048
|
||||
&& panel.height <= 2048
|
||||
&& color_valid(panel.background)
|
||||
// 无子树时只验证面板本身;有子树时还要检查平行表、二维投影和所有嵌套命令。
|
||||
&& panel.surface.as_ref().is_none_or(|surface| {
|
||||
surface.commands.len() <= 10001
|
||||
&& surface.commands.len() == surface.views.len()
|
||||
&& surface.commands.len() == surface.clips.len()
|
||||
&& surface.views.iter().flatten().all(|view| view.valid())
|
||||
// 裁剪原点允许负数,但所有分量必须有限,宽高不能为负。
|
||||
&& surface.clips.iter().flatten().all(|clip| {
|
||||
clip.iter().all(|value| value.is_finite())
|
||||
&& clip[2] >= 0.
|
||||
@@ -241,6 +268,7 @@ impl View {
|
||||
})
|
||||
})
|
||||
})
|
||||
// 每盏方向光单独验证方向、颜色和强度;前面的灯数上限限制后续逐面光照循环。
|
||||
&& self.scene.lights.iter().all(|light| {
|
||||
color_valid(light.color)
|
||||
&& light.direction.valid()
|
||||
@@ -251,6 +279,7 @@ impl View {
|
||||
})
|
||||
}
|
||||
/// 按包围盒候选发射射线,再检查三角形;距离相同时保留与绘制顺序一致的实例。
|
||||
// model 的 pick3D 协议和 panel::Mapping 可见命中调用;这里只返回原生 Hit,不操作托管对象。
|
||||
pub fn pick(&self, u: f32, v: f32, aspect: f32) -> Option<Hit> {
|
||||
// 拾取只接受有效相机、视口内归一化点及正有限宽高比;不在此完整重验几何,调用方应先验证 View。
|
||||
if !self.camera.valid()
|
||||
@@ -261,8 +290,10 @@ impl View {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// 相机生成单位方向射线,所以三角形返回参数可作为世界距离;basis 用于后面的 near/far 深度筛选。
|
||||
let ray = self.camera.ray(u, v, aspect)?;
|
||||
let basis = self.camera.basis()?;
|
||||
// 累积目前最近的有效三角形;temporary 只在缓存索引不可用时持有本次重建的 BVH。
|
||||
let mut hit: Option<Hit> = None;
|
||||
let temporary;
|
||||
// 无缓存或网格数失配时本次临时重建;数量相同的内容变更须由快照层生成新索引,不靠 matches 检测。
|
||||
@@ -272,21 +303,27 @@ impl View {
|
||||
temporary = spatial::Index::build(&self.scene.meshes);
|
||||
&temporary
|
||||
};
|
||||
// 候选只有包围盒命中保证,仍需逐面精确求交;entry 是进入盒子的距离下界。
|
||||
for (entry, instance) in index.ray(ray) {
|
||||
// BVH 候选按包围盒进入距离排序;后续盒子起点已比最近真实交点远时,可安全停止查询。
|
||||
if hit.as_ref().is_some_and(|hit| entry > hit.distance) {
|
||||
break;
|
||||
}
|
||||
// instance 指向原场景下标,不能把 BVH 返回顺序当成业务身份。
|
||||
let mesh = &self.scene.meshes[instance];
|
||||
// 每个面先变换三个顶点到世界空间,射线与这些真实顶点求交而非局部包围盒。
|
||||
for (triangle, indices) in mesh.geometry.triangles.iter().enumerate() {
|
||||
let [a, b, c] =
|
||||
indices.map(|i| mesh.world.point(mesh.geometry.vertices[i as usize]));
|
||||
// 平行、退化或交点在三角形外时跳过当前面,继续查找其他候选。
|
||||
let Some((distance, barycentric)) = ray.triangle(a, b, c) else {
|
||||
continue;
|
||||
};
|
||||
// 用同一世界射线还原交点,既用于返回位置,也用于计算相机正向深度。
|
||||
let point = ray.origin + ray.direction * distance;
|
||||
// near/far 限制相机前向深度,而不是到相机的欧氏距离;离轴射线同样按视锥范围筛选。
|
||||
let depth = self.camera.view(basis, point).z;
|
||||
// 裁剪面之外或优先级落后于已有命中的面不能覆盖结果;同距时优先较小实例下标。
|
||||
if depth < self.camera.near
|
||||
|| depth > self.camera.far
|
||||
|| hit.as_ref().is_some_and(|hit| {
|
||||
@@ -312,35 +349,42 @@ impl View {
|
||||
});
|
||||
}
|
||||
}
|
||||
// 返回 None 是正常未命中;不把包围盒候选伪装为表面交点。
|
||||
hit
|
||||
}
|
||||
}
|
||||
|
||||
/// 把有限颜色通道转换成最终像素使用的字节。
|
||||
// rgb 的内部转换步骤;调用边界负责颜色合法性,最终输出仍钳制过亮的光照结果。
|
||||
fn channel(value: f32) -> u32 {
|
||||
(value.clamp(0., 1.) * 255.).round() as u32
|
||||
}
|
||||
/// 将三个颜色通道打包为软件像素缓冲使用的 RGB 值。
|
||||
// render_aspect 与 panel::texture 用它初始化背景或打包面颜色,布局为 0xRRGGBB,不携带透明度。
|
||||
pub fn rgb(color: [f32; 3]) -> u32 {
|
||||
channel(color[0]) << 16 | channel(color[1]) << 8 | channel(color[2])
|
||||
}
|
||||
|
||||
/// 某个不可变视图在特定分辨率和宽高比下的光栅结果。
|
||||
struct CachedSurface {
|
||||
// 强持有视图使缓存键的 Arc 身份在条目存活期间稳定;不通过场景内容哈希判等。
|
||||
view: Arc<View>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
aspect: f32,
|
||||
// 最终像素可直接由多个绘制请求共享,缓存淘汰后外部 Arc 仍可继续读取。
|
||||
pixels: Arc<[u32]>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
/// 限制条目数和总像素数的表面缓存;不承担场景业务所有权。
|
||||
struct SurfaceCache {
|
||||
entries: std::collections::VecDeque<CachedSurface>,
|
||||
// 所有缓存条目像素总数,插入和淘汰同步增减;这不是 render_aspect 的递归工作预算。
|
||||
pixels: usize,
|
||||
}
|
||||
|
||||
/// 按不可变视图身份、分辨率和投影比例查光栅缓存;未命中时在锁外绘制,支持嵌套世界 UI。
|
||||
// software_renderer 遇到 DrawCommand::Scene3D 时调用;尺寸可按 LOD 降低,aspect 仍来自原始布局矩形。
|
||||
pub fn surface(
|
||||
view: &Arc<View>,
|
||||
width: u32,
|
||||
@@ -349,9 +393,12 @@ pub fn surface(
|
||||
) -> Result<Arc<[u32]>, &'static str> {
|
||||
// 全局缓存以 View Arc 身份+输出尺寸+aspect 为键;值相等但不同 Arc 的视图不会自动命中。
|
||||
static CACHE: std::sync::OnceLock<std::sync::Mutex<SurfaceCache>> = std::sync::OnceLock::new();
|
||||
// 进程内惰性初始化共享缓存;锁中毒按错误返回,不绕过锁继续使用状态。
|
||||
let cache = CACHE.get_or_init(Default::default);
|
||||
// 短作用域只做查找和 Arc 克隆;离开这个块释放锁,后面的渲染可以递归请求其他 3D 表面。
|
||||
{
|
||||
let cache = cache.lock().map_err(|_| "3D surface cache poisoned")?;
|
||||
// 键必须同时匹配 Arc 身份、像素宽高和投影宽高比;相同像素尺寸不代表相同投影。
|
||||
if let Some(entry) = cache.entries.iter().find(|entry| {
|
||||
Arc::ptr_eq(&entry.view, view)
|
||||
&& entry.width == width
|
||||
@@ -363,15 +410,19 @@ pub fn surface(
|
||||
}
|
||||
// Do not hold a cache lock across rendering: world UI can contain another
|
||||
// 3D viewport and recurse through the compositor.
|
||||
// 未命中时先在锁外完整绘制,失败不会向缓存插入半成品;并发请求允许重复计算。
|
||||
let pixels: Arc<[u32]> = render_aspect(view, width, height, aspect)?.into();
|
||||
// 绘制成功后重新加锁插入;这里不二次查重,因此并发同键结果可各占一个条目。
|
||||
let mut cache = cache.lock().map_err(|_| "3D surface cache poisoned")?;
|
||||
// 按插入顺序从队首淘汰,不在命中时搬到队尾;最多 8 项、累计 8388608 像素,渲染单表面另有限额。
|
||||
while cache.entries.len() >= 8 || cache.pixels + pixels.len() > 8_388_608 {
|
||||
// 移走最早插入条目并归还它的像素计数;空队列时停止淘汰。
|
||||
let Some(entry) = cache.entries.pop_front() else {
|
||||
break;
|
||||
};
|
||||
cache.pixels -= entry.pixels.len();
|
||||
}
|
||||
// 下方给缓存克隆一个像素 Arc,再把本地 Arc 返回调用方;外部持有时间独立于队列淘汰。
|
||||
cache.pixels += pixels.len();
|
||||
cache.entries.push_back(CachedSurface {
|
||||
view: view.clone(),
|
||||
@@ -393,6 +444,7 @@ struct Vertex {
|
||||
}
|
||||
impl Vertex {
|
||||
/// 按裁剪交点比例同时插值位置和 UV,保持新顶点的几何与纹理坐标一致。
|
||||
// clip 在一条边跨裁剪面时调用;t 按两端带符号距离求得,同步移动位置和纹理参数。
|
||||
fn lerp(self, other: Self, t: f32) -> Self {
|
||||
Self {
|
||||
position: self.position + (other.position - self.position) * t,
|
||||
@@ -406,13 +458,16 @@ impl Vertex {
|
||||
/// 生成相机空间六个视锥平面,供三角形裁剪与 BVH 可见性查询共用。
|
||||
fn frustum_planes(camera: Camera, aspect: f32) -> [[f32; 4]; 6] {
|
||||
// 平面内侧满足 ax+by+cz+d>=0。透视侧平面随 z 展开,正交侧平面是固定 x/y 边界。
|
||||
// x/y 表示透视单位深度半宽高或正交固定半宽高,slope 用于在同一平面公式中选择 z 项。
|
||||
let (x, y, slope) = match camera.projection {
|
||||
// 透视侧边经过相机原点,其边界横纵坐标随正向深度线性增长。
|
||||
Projection::Perspective { fov } => {
|
||||
let y = (fov.to_radians() / 2.).tan();
|
||||
(y * aspect, y, 1.)
|
||||
}
|
||||
Projection::Orthographic { height } => (height * aspect / 2., height / 2., 0.),
|
||||
};
|
||||
// 依次返回近、远、左、右、下、上六个内侧平面;不必单位化,因为这里只使用符号和距离比例。
|
||||
[
|
||||
[0., 0., 1., -camera.near],
|
||||
[0., 0., -1., camera.far],
|
||||
@@ -423,31 +478,41 @@ fn frustum_planes(camera: Camera, aspect: f32) -> [[f32; 4]; 6] {
|
||||
]
|
||||
}
|
||||
/// 在透视除法前逐平面裁剪多边形,并同步插值 UV,避免跨近面顶点投影到极大坐标。
|
||||
// render_aspect 对每个三角形调用,返回可能为空或顶点更多的凸多边形,随后用扇形重新三角化。
|
||||
fn clip(mut polygon: Vec<Vertex>, camera: Camera, aspect: f32) -> Vec<Vertex> {
|
||||
// 每次只处理一个半空间,再把结果交给下一个平面;六轮结束后所有剩余顶点都在视锥内。
|
||||
for [a, b, c, d] in frustum_planes(camera, aspect) {
|
||||
// 上一轮已完全裁掉时不再访问 last,也无需继续处理其他平面。
|
||||
if polygon.is_empty() {
|
||||
break;
|
||||
}
|
||||
// 返回平面方程的带符号值,不要求是真实距离;同一边的两端共享系数,比例仍可用于求交。
|
||||
let distance = |vertex: Vertex| {
|
||||
let p = vertex.position;
|
||||
a * p.x + b * p.y + c * p.z + d
|
||||
};
|
||||
// 本轮写入新多边形;遍历消耗旧 polygon,避免边遍历边插入导致重复处理。
|
||||
let mut output = Vec::new();
|
||||
// 从闭多边形最后一点连到第一点开始,逐边做 Sutherland–Hodgman 裁剪,不遗漏首尾边。
|
||||
let mut previous = *polygon.last().unwrap();
|
||||
// 保存上一端点的内外状态;闭合首边的上一点从旧多边形末尾取得。
|
||||
let mut before = distance(previous);
|
||||
// 逐边判断是否跨平面;每条边可输出一个交点,再按需保留当前内侧端点。
|
||||
for current in polygon {
|
||||
let after = distance(current);
|
||||
// 边跨平面时按带符号距离求交点比例,同步插值相机位置和 UV;内侧端点另行保留。
|
||||
if (before >= 0.) != (after >= 0.) {
|
||||
output.push(previous.lerp(current, before / (before - after)));
|
||||
}
|
||||
// 当前点位于内侧或恰在面上时保留;外侧端点只可能贡献前面的边交点。
|
||||
if after >= 0. {
|
||||
output.push(current);
|
||||
}
|
||||
// 前进到下一条边,同时更新端点及其平面值,避免重复求上一端点距离。
|
||||
previous = current;
|
||||
before = after;
|
||||
}
|
||||
// 本轮输出成为下一平面的输入;返回的 UV 已包含所有裁剪交点插值。
|
||||
polygon = output;
|
||||
}
|
||||
polygon
|
||||
@@ -455,6 +520,7 @@ fn clip(mut polygon: Vec<Vertex>, camera: Camera, aspect: f32) -> Vec<Vertex> {
|
||||
|
||||
#[cfg(test)]
|
||||
/// 把当前绘制输入转换为输出表面;分配、裁剪和预算由本实现检查。
|
||||
// 仅测试构建暴露的便利入口,按输出像素宽高推导 aspect;生产合成使用 surface 的显式 aspect。
|
||||
pub fn render(view: &View, width: u32, height: u32) -> Result<Vec<u32>, &'static str> {
|
||||
render_aspect(view, width, height, width as f32 / height as f32)
|
||||
}
|
||||
@@ -465,6 +531,7 @@ fn render_aspect(
|
||||
height: u32,
|
||||
aspect: f32,
|
||||
) -> Result<Vec<u32>, &'static str> {
|
||||
// 进入共享 RenderScope,让面板递归中的二维/三维分配共用预算;作用域退出由 RAII 恢复嵌套深度,已申请像素额度仍按本帧累计。
|
||||
let _scope = crate::software_renderer::RenderScope::enter()?;
|
||||
// 颜色与深度各占一份面积,既有每表面硬上限,也在下方计入递归累计像素预算。
|
||||
let area = width as usize * height as usize;
|
||||
@@ -482,8 +549,10 @@ fn render_aspect(
|
||||
crate::software_renderer::reserve_pixels(area.saturating_mul(2))?;
|
||||
let mut pixels = vec![rgb(view.scene.background); area];
|
||||
let mut depths = vec![f32::INFINITY; area];
|
||||
// 前面 view.valid 已保证相机基可构造,因此这里的 unwrap 依赖该校验前提。
|
||||
let camera = view.camera;
|
||||
let basis = camera.basis().unwrap();
|
||||
// 后续深度与 UV 是否使用倒深度校正只由投影类型决定,不能按网格是否带纹理决定。
|
||||
let perspective = matches!(camera.projection, Projection::Perspective { .. });
|
||||
// 点已经经过视锥裁剪,才做透视除法;屏幕 y 向下,保留相机 z 用于后续深度/UV 校正。
|
||||
let project = |point: Vec3| {
|
||||
@@ -497,8 +566,10 @@ fn render_aspect(
|
||||
point.z,
|
||||
)
|
||||
};
|
||||
// 累计所有三角形屏幕包围矩形的扫描面积,遮挡后未写像素的工作也计入。
|
||||
let mut work = 0usize;
|
||||
let temporary;
|
||||
// 与 pick 一样按网格数量决定是否使用已有索引;内容改变须由快照层重建,数量匹配不等于内容匹配。
|
||||
let index = if view.scene.acceleration.matches(view.scene.meshes.len()) {
|
||||
&*view.scene.acceleration
|
||||
} else {
|
||||
@@ -507,18 +578,23 @@ fn render_aspect(
|
||||
};
|
||||
// 宽阶段只筛选候选:离屏网格不进入后续纹理分配和逐三角形处理。
|
||||
for instance in index.visible(camera, aspect) {
|
||||
// visible 已恢复原场景顺序,等深度时先出现的实例保持优先。
|
||||
let mesh = &view.scene.meshes[instance];
|
||||
// 只为宽阶段可见的面板生成普通 UI 纹理;同一 mesh 的所有三角形复用本次纹理。
|
||||
let texture = mesh.panel.as_ref().map(panel::texture).transpose()?;
|
||||
// 逐个原始面计算世界法线、光照并裁剪;一个原始面裁成多个小三角形后仍共享同一面颜色。
|
||||
for indices in &mesh.geometry.triangles {
|
||||
let [a, b, c] = indices.map(|i| mesh.world.point(mesh.geometry.vertices[i as usize]));
|
||||
// 零面积面不能形成可靠法线,跳过后续光照和光栅循环。
|
||||
let Some(normal) = (b - a).cross(c - a).normalized() else {
|
||||
continue;
|
||||
};
|
||||
// 每三角形使用平面法线做 Lambert 漫反射,环境光为基础,方向光逐 RGB 累加;背光点积钳零。
|
||||
let mut light = [view.scene.ambient; 3];
|
||||
// 方向光的 direction 指传播方向,所以取反才是从表面指向光源的入射方向。
|
||||
for source in &view.scene.lights {
|
||||
let intensity = normal.dot(source.direction * -1.).max(0.) * source.intensity;
|
||||
// 光色逐 RGB 通道调制强度,再加到同一面的环境光底值。
|
||||
for (channel, color) in light.iter_mut().zip(source.color) {
|
||||
*channel += intensity * color;
|
||||
}
|
||||
@@ -545,31 +621,39 @@ fn render_aspect(
|
||||
camera,
|
||||
aspect,
|
||||
);
|
||||
// 完全裁掉或仅剩线/点时没有可填充面积,不能进入扇形三角化。
|
||||
if polygon.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
// 裁剪后可能多于三个顶点,用扇形重新三角化,保持同一平面的纹理参数。
|
||||
for index in 1..polygon.len() - 1 {
|
||||
// 扇形固定第一个顶点,依次连接相邻两点;保留这三个顶点的 UV 供每像素插值。
|
||||
let vertices = [polygon[0], polygon[index], polygon[index + 1]];
|
||||
let [a, b, c] = vertices.map(|vertex| project(vertex.position));
|
||||
// 四个屏幕边界先取整再夹到缓冲尺寸,扫描使用左闭右开范围,避免数组越界。
|
||||
let min_x = a.x.min(b.x).min(c.x).floor().max(0.) as u32;
|
||||
let max_x = a.x.max(b.x).max(c.x).ceil().min(width as f32) as u32;
|
||||
let min_y = a.y.min(b.y).min(c.y).floor().max(0.) as u32;
|
||||
let max_y = a.y.max(b.y).max(c.y).ceil().min(height as f32) as u32;
|
||||
// 按屏幕包围矩形面积计工作,而非仅成功写像素数;大量重叠/细长三角形也不能绕过循环预算。
|
||||
work += max_x.saturating_sub(min_x) as usize * max_y.saturating_sub(min_y) as usize;
|
||||
// 先检查工作预算再进入像素扫描;超额时整张表面返回错误,不发布部分结果。
|
||||
if work > 32_000_000 {
|
||||
return Err("3D raster work budget exceeded");
|
||||
}
|
||||
// 二维边函数给出有向面积,统一用于面积退化判断和像素中心的重心权重。
|
||||
let edge = |a: Vec3, b: Vec3, x: f32, y: f32| {
|
||||
(x - a.x) * (b.y - a.y) - (y - a.y) * (b.x - a.x)
|
||||
};
|
||||
// 有向面积允许任意顶点绕序,除相同 area 得到一致重心权重;近零面积跳过,不做背面剔除。
|
||||
let area = edge(a, b, c.x, c.y);
|
||||
// 投影后几乎共线的面不稳定且没有可见面积;这里的 area 已遮蔽上方缓冲面积变量。
|
||||
if area.abs() < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
// 只扫描当前小三角形的包围矩形;深度缓冲会处理不同三角形之间的遮挡。
|
||||
for y in min_y..max_y {
|
||||
// 以像素中心而非左上角采样,两个坐标均加半个像素。
|
||||
for x in min_x..max_x {
|
||||
let px = x as f32 + 0.5;
|
||||
let py = y as f32 + 0.5;
|
||||
@@ -588,14 +672,18 @@ fn render_aspect(
|
||||
} else {
|
||||
weights[0] * a.z + weights[1] * b.z + weights[2] * c.z
|
||||
};
|
||||
// 按行主序定位颜色和深度数组中的同一像素;与外层三角形扇形下标是不同局部变量。
|
||||
let index = y as usize * width as usize + x as usize;
|
||||
// 只让最近表面写像素;等深度保留先绘制实例,与拾取的排序约定一致。
|
||||
if depth < depths[index] {
|
||||
// 通过严格小于测试后同步更新深度和颜色;没有通过时保留原像素及其深度。
|
||||
depths[index] = depth;
|
||||
pixels[index] =
|
||||
// 只有纹理和面板描述同时存在才采样;普通网格直接使用前面计算的材质光照色。
|
||||
if let (Some(texture), Some(panel)) = (&texture, &mesh.panel) {
|
||||
// UV 也要除以深度再插值,否则倾斜面板会出现纹理拉伸失真。
|
||||
let interpolated = |axis| {
|
||||
// 透视下先对各顶点 UV 除以 z,再乘回当前插值深度;正交下直接按屏幕权重求和。
|
||||
if perspective {
|
||||
depth
|
||||
* (weights[0] * vertices[0].uv[axis] / a.z
|
||||
@@ -633,10 +721,14 @@ mod tests {
|
||||
#[test]
|
||||
/// 构造99个远离视锥的网格,宽阶段只保留正中实例;有/无预存BVH的精确拾取身份与距离必须一致。
|
||||
fn spatial_index_culls_offscreen_meshes_and_preserves_ray_pick() {
|
||||
// 从一个原点盒体开始,随后加入大量远离视锥的实例以验证索引筛选。
|
||||
let mut view = view(Geometry::cuboid(1., 1., 1.));
|
||||
// 测试需要修改快照,Arc::make_mut 保证独占可写场景,不改共享视图的语义。
|
||||
let scene = Arc::make_mut(&mut view.scene);
|
||||
let mesh = scene.meshes[0].clone();
|
||||
// 保留原始实例为下标 0,其余盒体沿世界 x 轴每次平移 100,确保落在侧视锥之外。
|
||||
for index in 1..100 {
|
||||
// 复制几何和材质,只改变业务身份与世界平移,隔离测试中的变量。
|
||||
let mut far = mesh.clone();
|
||||
far.identity = format!("far{index}");
|
||||
far.world = Matrix::trs(
|
||||
@@ -646,20 +738,26 @@ mod tests {
|
||||
);
|
||||
scene.meshes.push(far);
|
||||
}
|
||||
// 显式建立缓存索引,后面还会清空副本索引对比临时重建路径。
|
||||
scene.acceleration = Arc::new(spatial::Index::build(&scene.meshes));
|
||||
// 远处实例应被宽阶段剔除;随后比较带缓存与临时重建索引的精确拾取身份/距离,防止加速改变结果。
|
||||
assert_eq!(scene.acceleration.visible(view.camera, 1.), vec![0]);
|
||||
// 中心射线应只得到原点盒体候选,既检查视锥筛选,也检查射线宽阶段。
|
||||
let ray = view.camera.ray(0.5, 0.5, 1.).unwrap();
|
||||
assert_eq!(scene.acceleration.ray(ray).len(), 1);
|
||||
// 先记录缓存路径的精确结果,避免后面清空副本索引影响比较基准。
|
||||
let indexed = view.pick(0.5, 0.5, 1.).unwrap();
|
||||
// 复制 View 后清空其派生缓存,场景几何和相机保持一致。
|
||||
let mut plain = view.clone();
|
||||
Arc::make_mut(&mut plain.scene).acceleration = Arc::default();
|
||||
// 触发 pick 的临时重建分支;身份与距离断言共同验证加速不改变最终命中。
|
||||
let fallback = plain.pick(0.5, 0.5, 1.).unwrap();
|
||||
assert_eq!(indexed.identity, fallback.identity);
|
||||
assert_eq!(indexed.distance, fallback.distance);
|
||||
}
|
||||
/// 构造测试最小视图:默认相机、原点红网格、纯环境光与白背景,不依赖业务VM或外部光源。
|
||||
fn view(geometry: Geometry) -> View {
|
||||
// 统一返回红色单网格、白背景与满环境光,像素断言可直接识别纯红,无需外部灯光。
|
||||
View {
|
||||
camera: Camera::default(),
|
||||
scene: Arc::new(Scene {
|
||||
@@ -680,6 +778,7 @@ mod tests {
|
||||
#[test]
|
||||
/// 跨越近裁剪面的三角形仍应产生像素,射线命中受同一 near/far 约束;缩短远面后命中消失。
|
||||
fn triangles_crossing_near_plane_are_clipped_and_pick_uses_the_same_range() {
|
||||
// 三角形一个顶点在 near 前方外侧,两个顶点在内侧,构造必须裁剪而不能整面丢弃的输入。
|
||||
let mut view = view(Geometry {
|
||||
uv: vec![],
|
||||
vertices: vec![
|
||||
@@ -689,6 +788,7 @@ mod tests {
|
||||
],
|
||||
triangles: vec![[0, 1, 2]],
|
||||
});
|
||||
// 把相机放原点朝 +z 看,令世界 z 直接对应裁剪深度,简化断言解释。
|
||||
view.camera.position = Vec3::default();
|
||||
view.camera.target = Vec3::new(0., 0., 1.);
|
||||
view.camera.near = 1.;
|
||||
@@ -697,30 +797,37 @@ mod tests {
|
||||
// 三角形跨近面仍应产生可见红像素,拾取也必须尊重相同 near/far;缩短 far 后命中消失。
|
||||
let pixels = render(&view, 64, 64).unwrap();
|
||||
assert!(pixels.contains(&0xFF0000));
|
||||
// 中心射线命中必须落在相机深度范围,不能只证明渲染输出存在。
|
||||
let hit = view.pick(0.5, 0.5, 1.).unwrap();
|
||||
assert!(hit.point.z >= view.camera.near && hit.point.z <= view.camera.far);
|
||||
// 只收紧远面,令原中心命中退出有效深度范围,验证拾取同样受 far 裁剪。
|
||||
view.camera.far = 1.1;
|
||||
assert!(view.pick(0.5, 0.5, 1.).is_none());
|
||||
}
|
||||
#[test]
|
||||
/// 验证局部TRS先于父变换作用于点;正交投影不同水平采样的射线方向相同但原点按可见宽度平移。
|
||||
fn group_transform_order_and_orthographic_rays_are_consistent() {
|
||||
// 局部先绕 y 轴转 90 度再平移 1,父层另外平移 10;输入点 z=1 最终应落在 x=12。
|
||||
let local = Matrix::trs(
|
||||
Vec3::new(1., 0., 0.),
|
||||
Vec3::new(0., 90., 0.),
|
||||
Vec3::new(1., 1., 1.),
|
||||
);
|
||||
// 父变换无旋转和缩放,用于区分 local 与 parent 的应用顺序。
|
||||
let parent = Matrix::trs(
|
||||
Vec3::new(10., 0., 0.),
|
||||
Vec3::default(),
|
||||
Vec3::new(1., 1., 1.),
|
||||
);
|
||||
// compose 先作用 local 再作用 parent;误换顺序会使父平移参与局部旋转而改变断言。
|
||||
let transformed = parent.compose(local).point(Vec3::new(0., 0., 1.));
|
||||
assert!((transformed.x - 12.).abs() < 1e-5 && transformed.z.abs() < 1e-5);
|
||||
// 复用默认观察方向,仅改成高度为 4 的正交投影,检查平行射线与水平起点间距。
|
||||
let camera = Camera {
|
||||
projection: Projection::Orthographic { height: 4. },
|
||||
..Camera::default()
|
||||
};
|
||||
// 归一化横坐标相差 0.5,对应可见宽度 4 的一半;中心纵坐标保持一致。
|
||||
let left = camera.ray(0.25, 0.5, 1.).unwrap();
|
||||
let right = camera.ray(0.75, 0.5, 1.).unwrap();
|
||||
assert_eq!(left.direction, right.direction);
|
||||
@@ -729,15 +836,20 @@ mod tests {
|
||||
#[test]
|
||||
/// 同一不可变视图/尺寸/aspect复用像素Arc;改变aspect重绘,超像素预算或退化相机必须拒绝。
|
||||
fn surface_cache_reuses_immutable_frames_and_rejects_invalid_resources() {
|
||||
// 缓存键使用 View 的 Arc 身份,因此两次调用必须引用同一个 Arc 而不是重新构造等值对象。
|
||||
let view = Arc::new(view(Geometry::cuboid(1., 1., 1.)));
|
||||
// 首次取得表面,第二次相同键应复用像素缓冲;测试不依赖全局缓存先前是否已有其他条目。
|
||||
let first = surface(&view, 64, 64, 1.).unwrap();
|
||||
let second = surface(&view, 64, 64, 1.).unwrap();
|
||||
// 同一不可变视图/尺寸/aspect 返回同像素 Arc;仅改变 aspect 即使像素尺寸不变也必须重绘。
|
||||
assert!(Arc::ptr_eq(&first, &second));
|
||||
// 只改变投影 aspect,输出尺寸仍为 64×64;既检查缓存未复用,也检查投影结果确实改变。
|
||||
let wide = surface(&view, 64, 64, 2.).unwrap();
|
||||
assert!(!Arc::ptr_eq(&first, &wide));
|
||||
assert_ne!(first, wide);
|
||||
// 1600 万像素超过单表面硬上限,应在大缓冲分配前返回错误。
|
||||
assert!(render(&view, 4000, 4000).is_err());
|
||||
// 单独复制视图并令 target 等于 position,验证退化观察基在 valid 和渲染入口都被拒绝。
|
||||
let mut invalid = (*view).clone();
|
||||
invalid.camera.target = invalid.camera.position;
|
||||
assert!(!invalid.valid());
|
||||
|
||||
@@ -9,13 +9,17 @@ use super::{
|
||||
|
||||
#[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
|
||||
@@ -23,6 +27,7 @@ impl Bounds {
|
||||
.map(|point| mesh.world.point(*point));
|
||||
// 空网格没有包围盒,返回 None 后不会进入树;首顶点初始化上下界,避免把原点误算进边界。
|
||||
let first = points.next()?;
|
||||
// 以首顶点为初始界,其余点逐分量扩大范围;fold 的最终值同时覆盖所有变换后顶点。
|
||||
Some(points.fold(
|
||||
Self {
|
||||
min: first,
|
||||
@@ -43,6 +48,7 @@ impl Bounds {
|
||||
))
|
||||
}
|
||||
/// 求两个包围盒的最小共同外包围,供 BVH 父节点使用。
|
||||
// Index::split 用 reduce 合并当前节点全部实例的界,父节点可一次剔除整棵子树。
|
||||
fn union(self, other: Self) -> Self {
|
||||
Self {
|
||||
min: Vec3::new(
|
||||
@@ -58,16 +64,20 @@ impl Bounds {
|
||||
}
|
||||
}
|
||||
/// 取指定轴中心位置,供最长轴排序和分割使用。
|
||||
// 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),
|
||||
@@ -75,29 +85,35 @@ impl Bounds {
|
||||
] {
|
||||
// 近乎平行该轴时不做除法;起点在 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 {
|
||||
@@ -126,11 +142,14 @@ impl Bounds {
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// BVH 叶或分支;叶保存实例索引,分支保存两个子节点索引。
|
||||
// 节点保存在 Index.nodes 连续数组,分支通过下标连接子树,查询不持有递归引用。
|
||||
enum Node {
|
||||
// 少量实例的叶节点,items 是原 Scene.meshes 下标,并非几何中的三角形下标。
|
||||
Leaf {
|
||||
bounds: Bounds,
|
||||
items: Vec<usize>,
|
||||
},
|
||||
// 内部节点合并两个子树的界;命中父界后再把左右子节点加入查询栈。
|
||||
Branch {
|
||||
bounds: Bounds,
|
||||
left: usize,
|
||||
@@ -139,7 +158,9 @@ enum Node {
|
||||
}
|
||||
impl Node {
|
||||
/// 读取叶/分支统一的包围盒,隐藏节点种类差异。
|
||||
// ray/visible 不必分别处理叶和分支的界,先统一测试,再决定是否展开子节点。
|
||||
fn bounds(&self) -> Bounds {
|
||||
// 两种节点共享同名 bounds 字段且 Bounds 可复制,返回值无需借用整棵树。
|
||||
match self {
|
||||
Self::Leaf { bounds, .. } | Self::Branch { bounds, .. } => *bounds,
|
||||
}
|
||||
@@ -149,32 +170,41 @@ impl Node {
|
||||
#[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())
|
||||
@@ -193,7 +223,9 @@ impl Index {
|
||||
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 {
|
||||
@@ -201,6 +233,7 @@ impl Index {
|
||||
} else {
|
||||
2
|
||||
};
|
||||
// 按选定轴的中心排序,total_cmp 提供确定的浮点比较规则;此处不按三角形或材质排序。
|
||||
items.sort_unstable_by(|&a, &b| {
|
||||
self.bounds[a]
|
||||
.unwrap()
|
||||
@@ -209,9 +242,11 @@ impl Index {
|
||||
});
|
||||
// 两半均非空,即使所有中心重合也能递减集合规模,保证递归终止。
|
||||
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,
|
||||
@@ -221,28 +256,36 @@ impl Index {
|
||||
index
|
||||
}
|
||||
/// 检查索引覆盖的网格数量是否与场景相符;缺失缓存时允许重建。
|
||||
// 这里只校验数量,不验证顶点或 world 变换;调用方必须在几何改变后重建索引。
|
||||
// View 在查询前用它选择缓存或临时构建;不验证顶点或 world 变换,几何改变后须由快照层重建。
|
||||
pub fn matches(&self, meshes: usize) -> bool {
|
||||
self.bounds.len() == meshes
|
||||
}
|
||||
/// 用包围盒筛出射线候选;索引查询按进入距离排序,精确三角形求交在调用方完成。
|
||||
// View::pick 消费排序候选,再逐三角形求交与裁剪;本函数不检查相机 near/far。
|
||||
pub fn ray(&self, ray: Ray) -> Vec<(f32, usize)> {
|
||||
// 空树的栈为空;显式栈避免查询递归,先测分支包围盒,未命中时整棵子树跳过。
|
||||
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);
|
||||
}
|
||||
@@ -253,25 +296,32 @@ impl Index {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user