330 lines
18 KiB
Rust
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)
|
|
}
|