859 lines
46 KiB
Rust
859 lines
46 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;
|
||
|
||
// 单份 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());
|
||
}
|
||
}
|