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

747 lines
34 KiB
Rust

//! 有界 CPU 3D 渲染器。使用相同摄像机与世界变换进行视锥裁剪、深度测试、纹理采样和射线拾取。
//! 不可变 View 可以缓存光栅结果;相机变化会产生新视图。当前不是 GPU 后端,像素和光栅工作预算用于限制分配与循环工作量。
//! 面板 UI 先渲染为有限纹理,再以透视校正 UV 映射进世界。与相机相关的投影宽高比由具体视口提供。
//!
//! Software 3D surfaces share the application's existing CPU compositor. Mesh
//! visibility and picking use the same camera and transformed world triangles.
pub mod math;
pub mod panel;
pub mod spatial;
use math::{Camera, Matrix, Projection, Vec3};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
pub const MAX_TRIANGLES: usize = 65_536;
pub const MAX_VERTICES: usize = 32_768;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 不可变顶点、三角形和可选 UV;多个网格可共享同一数值缓冲。
pub struct Geometry {
pub vertices: Vec<Vec3>,
pub triangles: Vec<[u32; 3]>,
#[serde(default)]
pub uv: Vec<[f32; 2]>,
}
impl Geometry {
/// 检查顶点与三角数量、索引范围、坐标有效性及逐顶点 UV;允许缺省 UV,不在此限制 UV 到单位区间。
pub fn valid(&self) -> bool {
// 检查顶点/三角形资源量和所有索引范围;UV 可省略,否则必须逐顶点对应且每个分量有限。
// 不在这里强制 UV 位于 [0,1],纹理采样阶段再钳制。
self.vertices.len() <= MAX_VERTICES
&& (self.uv.is_empty()
|| self.uv.len() == self.vertices.len()
&& self.uv.iter().flatten().all(|value| value.is_finite()))
&& self.triangles.len() <= MAX_TRIANGLES
&& self.vertices.iter().all(|point| point.valid())
&& self
.triangles
.iter()
.flatten()
.all(|&index| (index as usize) < self.vertices.len())
}
/// 生成以原点为中心的盒体顶点和三角形索引;位置和旋转由网格世界矩阵另行描述。
pub fn cuboid(width: f32, height: f32, depth: f32) -> Self {
// 八个角点共享位置,每个面拆两个三角形;尺寸有效性由构造调用边界检查,cuboid 本身只生成数值。
let (x, y, z) = (width / 2., height / 2., depth / 2.);
Self {
uv: vec![],
vertices: vec![
Vec3::new(-x, -y, -z),
Vec3::new(x, -y, -z),
Vec3::new(x, y, -z),
Vec3::new(-x, y, -z),
Vec3::new(-x, -y, z),
Vec3::new(x, -y, z),
Vec3::new(x, y, z),
Vec3::new(-x, y, z),
],
triangles: vec![
[0, 2, 1],
[0, 3, 2],
[4, 5, 6],
[4, 6, 7],
[0, 1, 5],
[0, 5, 4],
[3, 7, 6],
[3, 6, 2],
[0, 4, 7],
[0, 7, 3],
[1, 2, 6],
[1, 6, 5],
],
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 一次场景实例:几何、世界矩阵、颜色与可选 UI 面板,身份用于拾取回关联。
pub struct Mesh {
// 纯文本业务关联身份,用于拾取返回;不作为渲染线程可操作的托管引用。
pub identity: String,
pub geometry: Arc<Geometry>,
pub world: Matrix,
pub color: [f32; 3],
pub panel: Option<Panel>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 面板内普通 UI 的绘制命令及其裁剪/二维变换,逻辑排版不随外部相机改变。
pub struct UiSurface {
pub commands: Vec<crate::document::RenderCommand>,
pub clips: Vec<Option<[f32; 4]>>,
pub views: Vec<Option<crate::viewport::Transform>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 世界平面的有限 UI 纹理描述,包含像素尺寸、背景与内部显示内容。
pub struct Panel {
pub key: u64,
pub width: u32,
pub height: u32,
pub background: [f32; 3],
pub surface: Option<Arc<UiSurface>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 方向光的原生数值,方向和强度在场景快照阶段校验。
pub struct Light {
pub direction: Vec3,
pub color: [f32; 3],
pub intensity: f32,
}
#[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)]
// 序列化忽略的世界 BVH,反序列化后可按场景网格重建;相机改变不要求重建世界空间索引。
pub acceleration: Arc<spatial::Index>,
}
impl PartialEq for Scene {
/// 比较会影响场景结果的数据;加速索引属于派生缓存,不参与场景语义相等。
fn eq(&self, other: &Self) -> bool {
self.meshes == other.meshes
&& self.lights == other.lights
&& self.ambient == other.ambient
&& self.background == other.background
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 显式相机与共享世界的组合,一个 Scene 可由多个独立 View 观察。
pub struct View {
pub scene: Arc<Scene>,
pub camera: Camera,
}
#[derive(Debug)]
/// 只读绘制结果使用的名义包装,让源 PaintCommand 可以携带不可变 3D 快照。
pub struct PaintView(pub Arc<View>);
impl endor_core::ObjectPayload for PaintView {
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"immutable-3d-paint"
}
}
#[derive(Debug, Clone, PartialEq)]
/// 射线与实际三角形的精确交点,含实例身份、距离、法线和重心坐标。
pub struct Hit {
pub instance: usize,
pub identity: String,
pub point: Vec3,
pub normal: Vec3,
pub distance: f32,
pub triangle: usize,
pub barycentric: [f32; 3],
}
impl View {
/// 递归验证相机、材质、灯光、网格/面板表面及全场景资源预算;世界 UI 嵌套最多八层。
pub fn valid(&self) -> bool {
thread_local! { static DEPTH:std::cell::Cell<usize>=const { std::cell::Cell::new(0) }; }
// 验证可能递归进入面板内的 3D;作用域结束时归还深度,错误返回也不泄漏计数。
struct Guard;
impl Drop for Guard {
/// 退出当前嵌套校验层时递减线程局部深度;短路失败与正常返回都恢复同一计数。
fn drop(&mut self) {
DEPTH.with(|depth| depth.set(depth.get() - 1));
}
}
if !DEPTH.with(|depth| {
if depth.get() >= 8 {
false
} else {
depth.set(depth.get() + 1);
true
}
}) {
return false;
}
// 深度检查通过才建立 Guard,之后任一短路校验失败都会恢复计数;上限覆盖嵌套面板中的 3D 命令。
let _guard = Guard;
let color_valid = |color: [f32; 3]| {
color
.iter()
.all(|value| value.is_finite() && (0.0..=1.0).contains(value))
};
// 同时限制实例数、灯光数、累计顶点数和三角形数,单个 Geometry 合法不代表整个 Scene 未超预算。
self.camera.valid()
&& self.scene.ambient.is_finite()
&& (0.0..=1.0).contains(&self.scene.ambient)
&& color_valid(self.scene.background)
&& self.scene.meshes.len() <= 4096
&& self.scene.lights.len() <= 32
&& self
.scene
.meshes
.iter()
.map(|mesh| mesh.geometry.vertices.len())
.sum::<usize>()
<= 262_144
&& self
.scene
.meshes
.iter()
.map(|mesh| mesh.geometry.triangles.len())
.sum::<usize>()
<= MAX_TRIANGLES
&& self.scene.meshes.iter().all(|mesh| {
// 矩阵系数有限之外还验证每个变换后顶点范围,避免局部几何合法但累计世界变换产生无限坐标。
mesh.geometry.valid()
&& mesh.world.0.iter().all(|v| v.is_finite())
&& color_valid(mesh.color)
&& mesh
.geometry
.vertices
.iter()
.all(|point| mesh.world.point(*point).valid())
// 面板纹理限制在 1..2048 每轴,内部命令/视图/裁剪表等长并递归校验绘制;多一个命令额度留给装配焦点框。
&& mesh.panel.as_ref().is_none_or(|panel| {
panel.width > 0
&& panel.height > 0
&& 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.
&& clip[3] >= 0.
})
&& surface
.commands
.iter()
.all(|command| command.validate().is_ok())
})
})
})
&& self.scene.lights.iter().all(|light| {
color_valid(light.color)
&& light.direction.valid()
// 方向光必须近似单位向量,确保点积仅反映夹角而非把方向模长重复乘入光照强度。
&& (light.direction.length() - 1.).abs() < 0.0001
&& light.intensity.is_finite()
&& (0.0..=32.0).contains(&light.intensity)
})
}
/// 按包围盒候选发射射线,再检查三角形;距离相同时保留与绘制顺序一致的实例。
pub fn pick(&self, u: f32, v: f32, aspect: f32) -> Option<Hit> {
// 拾取只接受有效相机、视口内归一化点及正有限宽高比;不在此完整重验几何,调用方应先验证 View。
if !self.camera.valid()
|| !(0.0..=1.0).contains(&u)
|| !(0.0..=1.0).contains(&v)
|| !aspect.is_finite()
|| aspect <= 0.
{
return None;
}
let ray = self.camera.ray(u, v, aspect)?;
let basis = self.camera.basis()?;
let mut hit: Option<Hit> = None;
let temporary;
// 无缓存或网格数失配时本次临时重建;数量相同的内容变更须由快照层生成新索引,不靠 matches 检测。
let index = if self.scene.acceleration.matches(self.scene.meshes.len()) {
&*self.scene.acceleration
} else {
temporary = spatial::Index::build(&self.scene.meshes);
&temporary
};
for (entry, instance) in index.ray(ray) {
// BVH 候选按包围盒进入距离排序;后续盒子起点已比最近真实交点远时,可安全停止查询。
if hit.as_ref().is_some_and(|hit| entry > hit.distance) {
break;
}
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| {
hit.distance < distance
|| hit.distance == distance && hit.instance <= instance
})
{
continue;
}
// 使用变换后的世界顶点计算面法线,支持非均匀缩放;退化三角形没有可靠法线,不作为命中。
let Some(normal) = (b - a).cross(c - a).normalized() else {
continue;
};
// 同距离时保留较早实例,与绘制严格小于深度比较一致;同实例后续同距三角形也不会覆盖前者。
hit = Some(Hit {
instance,
identity: mesh.identity.clone(),
point,
normal,
distance,
triangle,
barycentric,
});
}
}
hit
}
}
/// 把有限颜色通道转换成最终像素使用的字节。
fn channel(value: f32) -> u32 {
(value.clamp(0., 1.) * 255.).round() as u32
}
/// 将三个颜色通道打包为软件像素缓冲使用的 RGB 值。
pub fn rgb(color: [f32; 3]) -> u32 {
channel(color[0]) << 16 | channel(color[1]) << 8 | channel(color[2])
}
/// 某个不可变视图在特定分辨率和宽高比下的光栅结果。
struct CachedSurface {
view: Arc<View>,
width: u32,
height: u32,
aspect: f32,
pixels: Arc<[u32]>,
}
#[derive(Default)]
/// 限制条目数和总像素数的表面缓存;不承担场景业务所有权。
struct SurfaceCache {
entries: std::collections::VecDeque<CachedSurface>,
pixels: usize,
}
/// 按不可变视图身份、分辨率和投影比例查光栅缓存;未命中时在锁外绘制,支持嵌套世界 UI。
pub fn surface(
view: &Arc<View>,
width: u32,
height: u32,
aspect: f32,
) -> 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);
{
let cache = cache.lock().map_err(|_| "3D surface cache poisoned")?;
if let Some(entry) = cache.entries.iter().find(|entry| {
Arc::ptr_eq(&entry.view, view)
&& entry.width == width
&& entry.height == height
&& entry.aspect == aspect
}) {
return Ok(entry.pixels.clone());
}
}
// 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();
}
cache.pixels += pixels.len();
cache.entries.push_back(CachedSurface {
view: view.clone(),
width,
height,
aspect,
pixels: pixels.clone(),
});
Ok(pixels)
}
/// Homogeneous frustum planes expressed in view space. Clipping before division
/// handles triangles crossing the near plane without huge screen coordinates.
#[derive(Clone, Copy)]
/// 裁剪过程中的相机空间顶点,位置与 UV 必须共同插值。
struct Vertex {
position: Vec3,
uv: [f32; 2],
}
impl Vertex {
/// 按裁剪交点比例同时插值位置和 UV,保持新顶点的几何与纹理坐标一致。
fn lerp(self, other: Self, t: f32) -> Self {
Self {
position: self.position + (other.position - self.position) * t,
uv: [
self.uv[0] + (other.uv[0] - self.uv[0]) * t,
self.uv[1] + (other.uv[1] - self.uv[1]) * t,
],
}
}
}
/// 生成相机空间六个视锥平面,供三角形裁剪与 BVH 可见性查询共用。
fn frustum_planes(camera: Camera, aspect: f32) -> [[f32; 4]; 6] {
// 平面内侧满足 ax+by+cz+d>=0。透视侧平面随 z 展开,正交侧平面是固定 x/y 边界。
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],
[1., 0., x * slope, x * (1. - slope)],
[-1., 0., x * slope, x * (1. - slope)],
[0., 1., y * slope, y * (1. - slope)],
[0., -1., y * slope, y * (1. - slope)],
]
}
/// 在透视除法前逐平面裁剪多边形,并同步插值 UV,避免跨近面顶点投影到极大坐标。
fn clip(mut polygon: Vec<Vertex>, camera: Camera, aspect: f32) -> Vec<Vertex> {
for [a, b, c, d] in frustum_planes(camera, aspect) {
if polygon.is_empty() {
break;
}
let distance = |vertex: Vertex| {
let p = vertex.position;
a * p.x + b * p.y + c * p.z + d
};
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;
}
polygon = output;
}
polygon
}
#[cfg(test)]
/// 把当前绘制输入转换为输出表面;分配、裁剪和预算由本实现检查。
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)
}
/// 先校验并预留颜色/深度缓冲预算,再剔除、裁剪、投影和光栅化三角形;超预算返回错误。
fn render_aspect(
view: &View,
width: u32,
height: u32,
aspect: f32,
) -> Result<Vec<u32>, &'static str> {
let _scope = crate::software_renderer::RenderScope::enter()?;
// 颜色与深度各占一份面积,既有每表面硬上限,也在下方计入递归累计像素预算。
let area = width as usize * height as usize;
// Offscreen targets have a hard per-surface resource bound.
if area > 4_194_304
|| width == 0
|| height == 0
|| !aspect.is_finite()
|| aspect <= 0.
|| !view.valid()
{
return Err("invalid 3D surface or resource budget exceeded");
}
// 先申请两份工作量再分配缓冲,深度初始化正无穷,首个有效面可写入。
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];
let camera = view.camera;
let basis = camera.basis().unwrap();
let perspective = matches!(camera.projection, Projection::Perspective { .. });
// 点已经经过视锥裁剪,才做透视除法;屏幕 y 向下,保留相机 z 用于后续深度/UV 校正。
let project = |point: Vec3| {
let half = match camera.projection {
Projection::Perspective { fov } => point.z * (fov.to_radians() / 2.).tan(),
Projection::Orthographic { height } => height / 2.,
};
Vec3::new(
(point.x / (half * aspect) + 1.) * width as f32 / 2.,
(1. - point.y / half) * height as f32 / 2.,
point.z,
)
};
let mut work = 0usize;
let temporary;
let index = if view.scene.acceleration.matches(view.scene.meshes.len()) {
&*view.scene.acceleration
} else {
temporary = spatial::Index::build(&view.scene.meshes);
&temporary
};
// 宽阶段只筛选候选:离屏网格不进入后续纹理分配和逐三角形处理。
for instance in index.visible(camera, aspect) {
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];
for source in &view.scene.lights {
let intensity = normal.dot(source.direction * -1.).max(0.) * source.intensity;
for (channel, color) in light.iter_mut().zip(source.color) {
*channel += intensity * color;
}
}
// 材质色乘累计光照后转 RGB,通道最终钳制;UI 面板纹理分支直接采纹理,不再乘这份网格光照。
let color = rgb(std::array::from_fn(|i| mesh.color[i] * light[i]));
// 在透视除法之前裁剪,并插入具有正确 UV 的交点;不能先投影再裁剪近面。
let polygon = clip(
indices
.map(|index| Vertex {
position: camera.view(
basis,
mesh.world.point(mesh.geometry.vertices[index as usize]),
),
// 没有 UV 的顶点用 [0,0];普通无纹理网格不依赖它,带纹理面板由构造器提供四角 UV。
uv: mesh
.geometry
.uv
.get(index as usize)
.copied()
.unwrap_or([0., 0.]),
})
.to_vec(),
camera,
aspect,
);
if polygon.len() < 3 {
continue;
}
// 裁剪后可能多于三个顶点,用扇形重新三角化,保持同一平面的纹理参数。
for index in 1..polygon.len() - 1 {
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);
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;
// 在像素中心求三个屏幕重心权重,负值容忍 1e-5 舍入误差,明显在外则不做深度和纹理计算。
let weights = [
edge(b, c, px, py) / area,
edge(c, a, px, py) / area,
edge(a, b, px, py) / area,
];
if weights.iter().any(|weight| *weight < -1e-5) {
continue;
}
// 透视投影下使用倒深度插值,正交投影下可直接线性插值。
let depth = if perspective {
1. / (weights[0] / a.z + weights[1] / b.z + weights[2] / c.z)
} 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| {
if perspective {
depth
* (weights[0] * vertices[0].uv[axis] / a.z
+ weights[1] * vertices[1].uv[axis] / b.z
+ weights[2] * vertices[2].uv[axis] / c.z)
} else {
weights[0] * vertices[0].uv[axis]
+ weights[1] * vertices[1].uv[axis]
+ weights[2] * vertices[2].uv[axis]
}
};
// 透视校正 UV 后做最近邻采样,钳到 [0,1] 并限制到最后一个像素,避免 UV=1 恰好越界。
let u = (interpolated(0).clamp(0., 1.) * panel.width as f32)
.min((panel.width - 1) as f32)
as usize;
let v = (interpolated(1).clamp(0., 1.) * panel.height as f32)
.min((panel.height - 1) as f32)
as usize;
texture[v * panel.width as usize + u]
} else {
color
};
}
}
}
}
}
}
Ok(pixels)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// 构造99个远离视锥的网格,宽阶段只保留正中实例;有/无预存BVH的精确拾取身份与距离必须一致。
fn spatial_index_culls_offscreen_meshes_and_preserves_ray_pick() {
let mut view = view(Geometry::cuboid(1., 1., 1.));
let scene = Arc::make_mut(&mut view.scene);
let mesh = scene.meshes[0].clone();
for index in 1..100 {
let mut far = mesh.clone();
far.identity = format!("far{index}");
far.world = Matrix::trs(
Vec3::new(index as f32 * 100., 0., 0.),
Vec3::default(),
Vec3::new(1., 1., 1.),
);
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();
let mut plain = view.clone();
Arc::make_mut(&mut plain.scene).acceleration = Arc::default();
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 {
meshes: vec![Mesh {
panel: None,
identity: "mesh".into(),
geometry: Arc::new(geometry),
world: Matrix::default(),
color: [1., 0., 0.],
}],
lights: vec![],
ambient: 1.,
background: [1., 1., 1.],
acceleration: Arc::default(),
}),
}
}
#[test]
/// 跨越近裁剪面的三角形仍应产生像素,射线命中受同一 near/far 约束;缩短远面后命中消失。
fn triangles_crossing_near_plane_are_clipped_and_pick_uses_the_same_range() {
let mut view = view(Geometry {
uv: vec![],
vertices: vec![
Vec3::new(-1., -1., 0.5),
Vec3::new(1., -1., 2.),
Vec3::new(0., 1., 2.),
],
triangles: vec![[0, 1, 2]],
});
view.camera.position = Vec3::default();
view.camera.target = Vec3::new(0., 0., 1.);
view.camera.near = 1.;
view.camera.far = 10.;
view.camera.projection = Projection::Perspective { fov: 90. };
// 三角形跨近面仍应产生可见红像素,拾取也必须尊重相同 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);
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() {
let local = Matrix::trs(
Vec3::new(1., 0., 0.),
Vec3::new(0., 90., 0.),
Vec3::new(1., 1., 1.),
);
let parent = Matrix::trs(
Vec3::new(10., 0., 0.),
Vec3::default(),
Vec3::new(1., 1., 1.),
);
let transformed = parent.compose(local).point(Vec3::new(0., 0., 1.));
assert!((transformed.x - 12.).abs() < 1e-5 && transformed.z.abs() < 1e-5);
let camera = Camera {
projection: Projection::Orthographic { height: 4. },
..Camera::default()
};
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);
assert_eq!(right.origin.x - left.origin.x, 2.);
}
#[test]
/// 同一不可变视图/尺寸/aspect复用像素Arc;改变aspect重绘,超像素预算或退化相机必须拒绝。
fn surface_cache_reuses_immutable_frames_and_rejects_invalid_resources() {
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));
let wide = surface(&view, 64, 64, 2.).unwrap();
assert!(!Arc::ptr_eq(&first, &wide));
assert_ne!(first, wide);
assert!(render(&view, 4000, 4000).is_err());
let mut invalid = (*view).clone();
invalid.camera.target = invalid.camera.position;
assert!(!invalid.valid());
assert!(render(&invalid, 64, 64).is_err());
}
}