//! 有界 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; // 单份 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, // 每组三个下标引用 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],纹理采样阶段再钳制。 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()) } /// 生成以原点为中心的盒体顶点和三角形索引;位置和旋转由网格世界矩阵另行描述。 // model/scene3d 的盒体几何构造路径调用;返回可共享的数值网格,不在这里创建 VM 对象。 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, // 共享局部几何;同一 Geometry 可配不同 world 矩阵形成多个实例。 pub geometry: Arc, // 局部到世界的累计仿射矩阵,父子变换已在快照遍历阶段组合。 pub world: Matrix, // 归一化 RGB 材质色,普通网格逐通道乘光照;有纹理面板直接使用纹理颜色。 pub color: [f32; 3], // Some 表示世界 UI 面板,纹理由 panel::texture 生成;None 走普通材质分支。 pub panel: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] /// 面板内普通 UI 的绘制命令及其裁剪/二维变换,逻辑排版不随外部相机改变。 pub struct UiSurface { // panel::compose 把所属 Mount 的命令装入这里;clips/views 按命令索引一一对应。 pub commands: Vec, pub clips: Vec>, pub views: Vec>, } #[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>, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] /// 方向光的原生数值,方向和强度在场景快照阶段校验。 pub struct Light { // 光线传播方向;Lambert 使用其反向和面法线点积,快照校验要求近似单位长度。 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 快照。 // native_visual 创建此 payload,source_paint 解码绘制命令时取出 Arc;渲染线程只使用不可变数值快照。 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 { // 命中的 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=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)); } } // 只有成功增加深度的调用才创建下方 Guard;到达八层时直接失败,不能误减尚未增加的计数。 if !DEPTH.with(|depth| { // 达到上限不修改计数,外层仍负责恢复自己的深度。 if depth.get() >= 8 { false } else { depth.set(depth.get() + 1); true } }) { return false; } // 深度检查通过才建立 Guard,之后任一短路校验失败都会恢复计数;上限覆盖嵌套面板中的 3D 命令。 let _guard = Guard; // 局部闭包统一校验背景、材质和光色,拒绝 NaN/无穷及超出 [0,1] 的通道。 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) }) } /// 按包围盒候选发射射线,再检查三角形;距离相同时保留与绘制顺序一致的实例。 // model 的 pick3D 协议和 panel::Mapping 可见命中调用;这里只返回原生 Hit,不操作托管对象。 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; } // 相机生成单位方向射线,所以三角形返回参数可作为世界距离;basis 用于后面的 near/far 深度筛选。 let ray = self.camera.ray(u, v, aspect)?; let basis = self.camera.basis()?; // 累积目前最近的有效三角形;temporary 只在缓存索引不可用时持有本次重建的 BVH。 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 }; // 候选只有包围盒命中保证,仍需逐面精确求交;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| { 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, }); } } // 返回 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, width: u32, height: u32, aspect: f32, // 最终像素可直接由多个绘制请求共享,缓存淘汰后外部 Arc 仍可继续读取。 pixels: Arc<[u32]>, } #[derive(Default)] /// 限制条目数和总像素数的表面缓存;不承担场景业务所有权。 struct SurfaceCache { entries: std::collections::VecDeque, // 所有缓存条目像素总数,插入和淘汰同步增减;这不是 render_aspect 的递归工作预算。 pixels: usize, } /// 按不可变视图身份、分辨率和投影比例查光栅缓存;未命中时在锁外绘制,支持嵌套世界 UI。 // software_renderer 遇到 DrawCommand::Scene3D 时调用;尺寸可按 LOD 降低,aspect 仍来自原始布局矩形。 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); // 短作用域只做查找和 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 && 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(); } // 下方给缓存克隆一个像素 Arc,再把本地 Arc 返回调用方;外部持有时间独立于队列淘汰。 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,保持新顶点的几何与纹理坐标一致。 // clip 在一条边跨裁剪面时调用;t 按两端带符号距离求得,同步移动位置和纹理参数。 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 边界。 // 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], [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,避免跨近面顶点投影到极大坐标。 // render_aspect 对每个三角形调用,返回可能为空或顶点更多的凸多边形,随后用扇形重新三角化。 fn clip(mut polygon: Vec, camera: Camera, aspect: f32) -> Vec { // 每次只处理一个半空间,再把结果交给下一个平面;六轮结束后所有剩余顶点都在视锥内。 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 } #[cfg(test)] /// 把当前绘制输入转换为输出表面;分配、裁剪和预算由本实现检查。 // 仅测试构建暴露的便利入口,按输出像素宽高推导 aspect;生产合成使用 surface 的显式 aspect。 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> { // 进入共享 RenderScope,让面板递归中的二维/三维分配共用预算;作用域退出由 RAII 恢复嵌套深度,已申请像素额度仍按本帧累计。 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]; // 前面 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| { 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; // 与 pick 一样按网格数量决定是否使用已有索引;内容改变须由快照层重建,数量匹配不等于内容匹配。 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) { // 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; } } // 材质色乘累计光照后转 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 { // 扇形固定第一个顶点,依次连接相邻两点;保留这三个顶点的 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; // 在像素中心求三个屏幕重心权重,负值容忍 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| { // 透视下先对各顶点 UV 除以 z,再乘回当前插值深度;正交下直接按屏幕权重求和。 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.)); // 测试需要修改快照,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( 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(); // 复制 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 { 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() { // 三角形一个顶点在 near 前方外侧,两个顶点在内侧,构造必须裁剪而不能整面丢弃的输入。 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]], }); // 把相机放原点朝 +z 看,令世界 z 直接对应裁剪深度,简化断言解释。 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); // 只收紧远面,令原中心命中退出有效深度范围,验证拾取同样受 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); assert_eq!(right.origin.x - left.origin.x, 2.); } #[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()); assert!(render(&invalid, 64, 64).is_err()); } }