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

859 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 有界 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<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],纹理采样阶段再钳制。
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<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>>,
}
#[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,
}
#[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 快照。
// native_visual 创建此 payload,source_paint 解码绘制命令时取出 Arc<View>;渲染线程只使用不可变数值快照。
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 {
// 命中的 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;
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::<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)
})
}
/// 按包围盒候选发射射线,再检查三角形;距离相同时保留与绘制顺序一致的实例。
// model 的 pick3D 协议和 panel::Mapping 可见命中调用;这里只返回原生 Hit,不操作托管对象。
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;
}
// 相机生成单位方向射线,所以三角形返回参数可作为世界距离;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 检测。
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<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,
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);
// 短作用域只做查找和 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<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
}
#[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)
}
/// 先校验并预留颜色/深度缓冲预算,再剔除、裁剪、投影和光栅化三角形;超预算返回错误。
fn render_aspect(
view: &View,
width: u32,
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;
// 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());
}
}