//! 有界 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, 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, pub world: Matrix, pub color: [f32; 3], pub panel: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] /// 面板内普通 UI 的绘制命令及其裁剪/二维变换,逻辑排版不随外部相机改变。 pub struct UiSurface { pub commands: Vec, pub clips: Vec>, pub views: Vec>, } #[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>, } #[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, pub lights: Vec, pub ambient: f32, pub background: [f32; 3], #[serde(skip)] // 序列化忽略的世界 BVH,反序列化后可按场景网格重建;相机改变不要求重建世界空间索引。 pub acceleration: Arc, } 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, pub camera: Camera, } #[derive(Debug)] /// 只读绘制结果使用的名义包装,让源 PaintCommand 可以携带不可变 3D 快照。 pub struct PaintView(pub Arc); 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=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::() <= 262_144 && self .scene .meshes .iter() .map(|mesh| mesh.geometry.triangles.len()) .sum::() <= 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 { // 拾取只接受有效相机、视口内归一化点及正有限宽高比;不在此完整重验几何,调用方应先验证 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 = 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, width: u32, height: u32, aspect: f32, pixels: Arc<[u32]>, } #[derive(Default)] /// 限制条目数和总像素数的表面缓存;不承担场景业务所有权。 struct SurfaceCache { entries: std::collections::VecDeque, pixels: usize, } /// 按不可变视图身份、分辨率和投影比例查光栅缓存;未命中时在锁外绘制,支持嵌套世界 UI。 pub fn surface( view: &Arc, width: u32, height: u32, aspect: f32, ) -> Result, &'static str> { // 全局缓存以 View Arc 身份+输出尺寸+aspect 为键;值相等但不同 Arc 的视图不会自动命中。 static CACHE: std::sync::OnceLock> = 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, camera: Camera, aspect: f32) -> Vec { 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, &'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, &'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()); } }