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

330 lines
18 KiB
Rust

//! 连接 3D 平面与普通 UI 的坐标和绘制桥梁。
//! Mapping 沿嵌套面板逐层把页面指针变成纹理坐标;可见命中还须检查最近射线交点,防止点穿遮挡物。
//! compose 把各 Mount 的绘制命令按所属面板分组并递归装配;texture 再调用普通软件渲染器绘制面板内容。
//!
//! Perspective UI mapping. The same world plane supplies texture coordinates
//! during rendering and inverse pointer coordinates during event dispatch.
use super::{
View,
math::{Projection, Vec3},
};
use crate::{
geometry::{Point, Rect},
viewport::Transform,
};
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
/// 一次面板出现的正逆投影信息,可以链接父面板形成嵌套映射。
pub struct Mapping {
// 包含本面板的 3D 视口 Mount ID,与 key 一起区分同一业务场景的多次出现。
pub owner: u64,
// 面板在场景中的稳定标识,不能仅凭 Block 身份选择某个具体面板出现。
pub key: u64,
// 保存这次出现所使用的不可变场景和相机;命中必须与生成画面的视图一致。
pub view: Arc<View>,
// [x,y,width,height] 是所属二维空间中的视口布局矩形,宽高也决定射线的 aspect。
pub rect: [f32; 4],
// 外层二维相机变换;逆映射先处理它,再进入 3D 摄像机射线计算。
pub outer: Transform,
// 当前层的二维裁剪,在逆 outer 之前检查;None 表示这一层没有额外矩形裁剪。
pub clip: Option<[f32; 4]>,
// 嵌套面板的外层链;反投影从父到子,正投影从子到父。
pub parent: Option<Arc<Mapping>>,
}
impl Mapping {
/// 沿父面板逆投影到当前平面的 UI 像素;要求可见时使用最近射线命中拒绝被遮挡的目标。
// RenderDocument 的面板坐标转换调用这里服务命中与事件派发;clipped=false 用于捕获后的连续拖动。
pub fn texture_point(&self, point: Point, clipped: bool) -> Option<Point> {
// 先把最外层窗口坐标逐层反投影为父面板像素,再检查当前面板所属空间的裁剪。
let point = if let Some(parent) = &self.parent {
parent.texture_point(point, clipped)?
} else {
point
};
if clipped
&& self
.clip
.is_some_and(|clip| !Rect::new(clip[0], clip[1], clip[2], clip[3]).contains(point))
{
return None;
}
// 先撤销当前层二维平移/缩放,后面的 rect 才能按未变换布局坐标归一化。
let point = self.outer.inverse(point);
let [x, y, width, height] = self.rect;
// 零或负视口没有可用面积,不能计算归一化位置或宽高比。
if width <= 0. || height <= 0. {
return None;
}
// rect 是 3D 视口在当前二维空间的位置,归一化坐标传给该视口相机;不是面板纹理坐标。
let (u, v) = ((point.x - x) / width, (point.y - y) / height);
// key 标识当前出现的面板网格;找不到时不退回其他面板,以免把事件发给错误对象。
let mesh = self.view.scene.meshes.iter().find(|mesh| {
mesh.panel
.as_ref()
.is_some_and(|panel| panel.key == self.key)
})?;
// panel 提供实际纹理像素宽高;它与世界几何尺寸分开,缩放网格不会改变 UI 的像素坐标。
let panel = mesh.panel.as_ref()?;
// 普通命中要求当前可见;捕获手势跳过这一步,使指针离开面板或被遮挡后仍能继续拖动。
if clipped {
// 指针落在平面投影内还不够:最近三角形必须属于本面板,不能点穿前方网格。
let hit = self.view.pick(u, v, width / height)?;
if self.view.scene.meshes[hit.instance]
.panel
.as_ref()
.is_none_or(|panel| panel.key != self.key)
{
return None;
}
}
// 捕获拖动可以不要求指针仍位于面板内,但仍使用原面板平面的连续逆映射。
let ray = self.view.camera.ray(u, v, width / height)?;
// 平面中心与两条世界轴共同确定相交平面;不直接把局部 z 轴当作非均匀缩放后的法线。
let center = mesh.world.point(Vec3::default());
let normal = (mesh.world.point(Vec3::new(1., 0., 0.)) - center)
.cross(mesh.world.point(Vec3::new(0., 1., 0.)) - center);
// 用世界变换后的局部 x/y 轴叉积求平面法线,支持旋转和非均匀缩放;平行射线无交点。
let denominator = normal.dot(ray.direction);
if denominator.abs() < 1e-7 {
return None;
}
// 解射线与无限平面交点,拒绝非有限及相机后方结果;clipped=false 时允许点落在面板边界外。
let distance = (center - ray.origin).dot(normal) / denominator;
if !distance.is_finite() || distance <= 0. {
return None;
}
// 把世界交点乘逆 world 回到面板局部坐标,逆矩阵退化时拒绝。
let local = mesh
.world
.inverse()?
.point(ray.origin + ray.direction * distance);
// 面板网格约定以局部原点为中心;x 向右、y 向上,转换为纹理左上原点、y 向下。
let world_width = mesh.geometry.vertices[1].x - mesh.geometry.vertices[0].x;
let world_height = mesh.geometry.vertices[0].y - mesh.geometry.vertices[2].y;
let point = Point::new(
(local.x / world_width + 0.5) * panel.width as f32,
(0.5 - local.y / world_height) * panel.height as f32,
);
(point.x.is_finite() && point.y.is_finite()).then_some(point)
}
/// 把当前面板的 UI 像素正向投影到外层页面空间,供坐标查询和交互测试使用。
// model/mount 的坐标查询使用此正投影,将面板内布局位置还原到外层页面供调用者定位。
pub fn project_point(&self, point: Point) -> Option<Point> {
// Mount.toView 只请求几何位置,不能用这个成功结果推断目标当前可点击;遮挡和 clip 要由 texture_point(clipped=true) 另检。
let mesh = self.view.scene.meshes.iter().find(|mesh| {
mesh.panel
.as_ref()
.is_some_and(|panel| panel.key == self.key)
})?;
let panel = mesh.panel.as_ref()?;
// 面板几何由矩形网格构造路径保证顶点顺序;这里读取局部宽高,再与纹理分辨率换算。
let width = mesh.geometry.vertices[1].x - mesh.geometry.vertices[0].x;
let height = mesh.geometry.vertices[0].y - mesh.geometry.vertices[2].y;
// 正向路径与 texture_point 对称:纹理像素先转局部平面点,再进入世界空间。
let world = mesh.world.point(Vec3::new(
(point.x / panel.width as f32 - 0.5) * width,
(0.5 - point.y / panel.height as f32) * height,
0.,
));
// 世界点先转换成相机的右/上/前坐标,后续用正向深度决定裁剪和透视除数。
let camera = self.view.camera;
let local = camera.view(camera.basis()?, world);
// 点在近远面之外时无可用投影;此方法仅算位置,不检查前方遮挡或二维 clip。
if local.z <= camera.near || local.z >= camera.far {
return None;
}
let [x, y, w, h] = self.rect;
// 透视半高随相机深度增长,正交半高恒定;横向除数还包含视口宽高比。
let half = match camera.projection {
Projection::Perspective { fov } => local.z * (fov.to_radians() / 2.).tan(),
Projection::Orthographic { height } => height / 2.,
};
// 归一化投影恢复为视口逻辑坐标,最后施加所属二维层的 outer 变换。
let point = self.outer.point(Point::new(
x + (local.x / (half * w / h) + 1.) * w / 2.,
y + (1. - local.y / half) * h / 2.,
));
// 当前投影得到的是父面板纹理空间;继续向外层正投影,直到窗口页面坐标。
if let Some(parent) = &self.parent {
parent.project_point(point)
} else {
Some(point)
}
}
}
#[derive(Clone)]
/// 归属明确的一条内部 UI 绘制命令,装配时还保留裁剪、变换和 owner。
struct Entry {
// 本出现生成的完整逻辑命令;递归 assemble 可仅替换其中 Scene3D 的不可变视图。
command: crate::document::RenderCommand,
// 所属二维/纹理空间裁剪,与 command 的位置投影分开保存。
clip: Option<[f32; 4]>,
// 命令的累计二维视图;装入 UiSurface 后仍与原命令保持同索引。
view: Option<Transform>,
// 原绘制出现的 Mount ID,用于分组、嵌套环检查,不是场景 mesh 下标。
owner: u64,
}
/// Collect a panel's ordinary paint subtree into its texture. Nested 3D
/// commands are assembled recursively before their containing texture.
/// 按面板所属视口和稳定 key 分组绘制命令,递归将内部 UI 装配进 3D 面板表面。
/// source_paint 在生成各 Mount 命令后调用;输入的 command_clips、command_views、command_mounts 必须与 commands 对齐,归属 ID 必须能查到 Mount。
pub fn compose(frame: &mut crate::document::Frame) -> anyhow::Result<()> {
// 输入由 source_paint::finish 建立四张平行表并校验归属;本函数直接索引,不把任意未经检查的外部 Frame 当作可靠输入。
use crate::document::RenderCommand;
use std::collections::{HashMap, HashSet};
// 按 Mount ID 建立查找表,让每条命令直接取到所属面板映射,避免逐条扫描完整 Mount 列表。
let mounts: HashMap<_, _> = frame.mounts.iter().map(|mount| (mount.id, mount)).collect();
// 分组键包含视口 owner 和面板 key,防止同一场景在两个视口出现时互相混入绘制命令。
let mut groups = HashMap::<(u64, u64), Vec<Entry>>::new();
let mut roots = Vec::new();
// 每条命令都有出现归属;据此分流为窗口根命令或某个世界面板的内部命令。
for (index, command) in frame.commands.iter().enumerate() {
// owner 是该条绘制命令的 Mount ID;面板分组使用 mount.panel 内的外层视口 owner。
let owner = frame.command_mounts[index];
let mount = mounts[&owner];
// 克隆命令并携带同索引裁剪、二维变换,移动到纹理后仍保持原始绘制语义。
let entry = Entry {
command: command.clone(),
clip: frame.command_clips[index],
view: frame.command_views[index],
owner,
};
// 面板后代收进对应纹理分组;不属于面板的命令保留为根层,二者都维持输入相对顺序。
if let Some(panel) = &mount.panel {
groups
.entry((panel.owner, panel.key))
.or_default()
.push(entry);
} else {
roots.push(entry);
}
}
// 面板内焦点框也必须作为纹理内容绘制,不能留在窗口根层,否则会脱离世界面板位置。
if let Some(mount) = frame.focus.and_then(|id| mounts.get(&id))
&& let Some(panel) = &mount.panel
{
groups
.entry((panel.owner, panel.key))
.or_default()
.push(Entry {
command: RenderCommand::StrokeRoundedRect {
x: mount.x,
y: mount.y,
width: mount.width,
height: mount.height,
radius: 7.,
stroke_width: 2.,
color: "#4F6FDB".into(),
},
clip: mount.clip,
view: mount.view,
owner: mount.id,
});
}
/// 递归装配一个绘制条目包含的世界 UI,检查嵌套环与深度,再生成独立不可变视图。
fn assemble(
entry: Entry,
groups: &HashMap<(u64, u64), Vec<Entry>>,
path: &mut HashSet<u64>,
depth: usize,
) -> anyhow::Result<Entry> {
// 仅本次条目可变;没有面板的普通命令可原样返回,不需要复制整个场景。
let mut entry = entry;
if let RenderCommand::Scene3D { view, .. } = &mut entry.command
&& view.scene.meshes.iter().any(|mesh| mesh.panel.is_some())
{
// 只有含面板的 Scene3D 命令才递归装配;path 跟踪当前祖先出现,限制深度小于 8 并拒绝环。
anyhow::ensure!(
depth < 8 && path.insert(entry.owner),
"3D UI paint nesting cycle or depth budget exceeded"
);
// 只构造新的原生快照,不写回源 Scene3D;同一业务场景仍可被其他视口共享。
let mut scene = (*view.scene).clone();
// 每个面板取得自己分组的 UI 命令;普通网格没有纹理子树,直接跳过。
for mesh in &mut scene.meshes {
if let Some(panel) = &mut mesh.panel {
// 一张表面保存三组平行数据;每个子条目装配成功后同时追加,保持索引对应。
let mut surface = super::UiSurface {
commands: Vec::new(),
clips: Vec::new(),
views: Vec::new(),
};
// 按照原命令顺序递归装配面板内容;嵌套 3D 子命令先完成,父面板才取得完整表面。
for child in groups.get(&(entry.owner, panel.key)).into_iter().flatten() {
let child = assemble(child.clone(), groups, path, depth + 1)?;
surface.commands.push(child.command);
surface.clips.push(child.clip);
surface.views.push(child.view);
}
// 即使分组为空也发布空表面,避免继承源快照中与本次出现无关的旧绘制内容。
panel.surface = Some(Arc::new(surface));
}
}
// 新场景替换本条目引用,相机保持不变;其他命令持有的 Arc<View> 不受影响。
*view = Arc::new(View {
scene: Arc::new(scene),
camera: view.camera,
});
// 退出当前分支时撤销祖先标记,允许同一身份在其他独立分支中出现而不误判成环。
path.remove(&entry.owner);
}
Ok(entry)
}
// 所有根条目共用临时祖先集合,每条递归返回时删除标记;depth 从根的 0 开始计算。
let mut path = HashSet::new();
// 面板内部命令已经移入 UiSurface,根帧只保留外层命令;四张平行元数据表同步重建。
frame.commands.clear();
frame.command_clips.clear();
frame.command_views.clear();
frame.command_mounts.clear();
// 递归错误会在清空之后返回,此函数本身不保证出错时恢复原帧;调用方需处理整帧失败。
for entry in roots {
let entry = assemble(entry, &groups, &mut path, 0)?;
frame.commands.push(entry.command);
frame.command_clips.push(entry.clip);
frame.command_views.push(entry.view);
frame.command_mounts.push(entry.owner);
}
Ok(())
}
/// 为一个面板分配有限像素表面,并调用普通 UI 软件渲染;当前实现不会按面板内容独立缓存纹理。
/// 软件光栅化的面板纹理路径调用;本函数每次产生新像素数组,复用策略由外层调用方决定。
pub fn texture(panel: &super::Panel) -> Result<Vec<u32>, &'static str> {
// render_aspect 已通过 View.valid 验证面板分辨率和内部命令;这里复用普通软件合成,输出是不透明背景上的 RGB 纹理。
// 先向统一像素预算申请有限纹理面积,再分配背景缓冲;嵌套面板同样受该预算约束。
crate::software_renderer::reserve_pixels(panel.width as usize * panel.height as usize)?;
// u32 缓冲按纹理宽高铺满背景;没有 UiSurface 时直接返回这张纯背景纹理。
let mut pixels =
vec![super::rgb(panel.background); panel.width as usize * panel.height as usize];
// 有 UI 子树才生成显示列表并绘制;解析或渲染错误向上返回,不把半成品作为成功纹理。
if let Some(surface) = &panel.surface {
// 将普通 UI 表面包装为临时文档复用现有显示列表和软件渲染路径;比例 1 对应纹理像素。
let document = crate::document::RenderDocument {
frame: crate::document::Frame {
background: format!("#{:06X}", super::rgb(panel.background)),
commands: surface.commands.clone(),
command_views: surface.views.clone(),
command_clips: surface.clips.clone(),
..Default::default()
},
..Default::default()
};
// 比例为 1,保持子 UI 坐标和纹理像素一一对应;无效命令转换成纹理路径的简短错误。
let list = document
.display_list(1.)
.map_err(|_| "invalid 3D UI texture commands")?;
crate::software_renderer::SoftwareRenderer::render(
&list,
&mut pixels,
panel.width,
panel.height,
)?;
}
Ok(pixels)
}