一、从矩阵到像素
上一篇我们算出了每个零件的位姿矩阵。现在的问题是:
一堆 4×4 矩阵和三角面片,怎么变成屏幕上那个能转能点的 3D 手?
答案是 VTK(Visualization Toolkit)。它是科学可视化的老前辈,自带 STL 加载、光照渲染、拾取、相机交互------省了我至少 3000 行 OpenGL 胶水代码。
二、LinkActor:一个零件 = 一个 Actor
渲染层的核心封装:
cpp
// render/link_actor.hpp
class LinkActor {
public:
void setMesh(const TriangleMesh& mesh); // 上传三角网格
void setPose(const Transform& T); // 设置位姿
void setScale(double s); // 均匀缩放
void setColor(double r, double g, double b);
bool triangleNormal(int cellId, Vec3& out) const; // 拾取用:面法线
Vec3 centroid() const; // 网格质心
};
关键实现------位姿设置:
cpp
void LinkActor::setPose(const Transform& T) {
vtkNew<vtkMatrix4x4> m;
const auto& e = T.matrix(); // Eigen 4x4
for (int r = 0; r < 4; ++r)
for (int c = 0; c < 4; ++c)
m->SetElement(r, c, e(r, c));
actor_->SetUserMatrix(m); // VTK 拿去渲染
}
Eigen 的 Isometry3d 矩阵和 VTK 的 vtkMatrix4x4 逐元素复制------没有魔法,只有搬运。
三、SceneRenderer:场景管理
cpp
// render/scene_renderer.hpp
class SceneRenderer {
vtkRenderer* renderer_;
vtkActor* grid_; // 白色虚线网格地板(±10m)
vtkAxesActor* axes_; // 红绿蓝世界坐标轴
};
我加了一个 Gazebo/RViz 风格的黑色背景 + 白色虚线网格
比之前的灰色地板专业多了,看模型也更清楚。

关于裁切面(一个折磨了我两天的坑):
近裁切面太远 → 模型近处被切掉(仰视底部缺角);太近 → 深度缓冲精度不够、闪烁。最终方案是按可见模型包围盒逐帧动态计算:
cpp
// renderNow() 里每帧调用
double b[6];
actor->GetBounds(b); // 世界包围盒
// near = 相机到包围盒最近点距离的一半
// far = 到最远角点的 2 倍
cam->SetClippingRange(nearP, farP);
模型永远不被切开,10 米网格完整覆盖。
四、3D 交互三件套
4.1 点选零件
VTK 的 vtkCellPicker 发射一条射线,命中哪个三角面就选中哪个零件:

cpp
vtkNew<vtkCellPicker> picker;
picker->PickFromListOn();
picker->AddPickList(actors_[idx]->actor());
const bool hit = picker->Pick(x, y, 0, ren);
拾取后可以拿到命中面的法线 和世界坐标------这在后面插轴时至关重要。
4.2 Gizmo 拖拽
选中零件后出现三轴 Gizmo(红绿蓝箭头),拖拽直接改位姿:
cpp
模式:Select(点选)/ Translate(移动)/ Rotate(旋转)
取向:Global(Gizmo 恒与世界对齐)/ Local(跟随零件旋转)
拖拽计算的核心:把鼠标屏幕位移反投影到 3D,算出位移/旋转量,发信号给主窗口写入树。
4.3 插入旋转轴
在详细视图里点一下圆柱面,系统自动识别轴向并插入旋转关节。这个功能看起来简单,背后有三层判定:
cpp
优先级 1:圆柱特征拟合(点在销/孔壁上)
优先级 2:零件局部主轴吸附(X/Y/Z 轴)
优先级 3:垂直屏幕(兜底)
圆柱特征拟合的数学原理:
柱面上所有三角面的法线都垂直于柱轴。把邻近面片的法线堆成协方差矩阵 M = Σnnᵀ,最小特征向量就是轴向(法线都垂直于它 → 零空间)。
cpp
Eigen::SelfAdjointEigenSolver<Eigen::Matrix3d> es(M);
const double l0 = es.eigenvalues()(0); // 最小特征值
const double l1 = es.eigenvalues()(1);
if (l1 < 1e-12 || l0 > 0.15 * l1)
return false; // 法线不共面 → 非柱面,拒绝
axisLocal = es.eigenvectors().col(0); // 零空间 = 柱轴
轴心位置用 Kasa 代数圆拟合:在垂直于轴的平面上,把面片质心投影下来拟合圆,圆心即轴线通过的点。
拟合邻域半径自适应------用模型包围盒对角线的 2%/5%,而不是硬编码 5mm(因为模型可能是毫米建模的)。
五、取景与相机:让模型"好看"
5.1 离群剔除
用户在底部放了一堆散件(备用零件),一按"复位视角",相机把散件全框进来,整只手缩成指甲盖。
解法:距离阈值剔除------各件中心到中位锚点的距离,取第 90 百分位 × 5 作为阈值,超过的不参与取景:
cpp
std::nth_element(ds.begin(), ds.begin() + ds.size() * 9/10, ds.end());
const double thr = std::max(5.0 * ds[ds.size() * 9/10], 0.40);
注意阈值必须宽松(只剔"明显离群"的散件)
太紧会把指尖剔出取景框,不然模型不完整,我改了三版阈值才调好。
5.2 Zoom 语义
VTK 的 Zoom(k) 是反直觉的:
cpp
camera->Zoom(0.75); // 视野放大约 33%,模型变小(留边距)
camera->Zoom(1.15); // 视野缩小,模型变大(拉近)
k > 1 是放大(拉近),k < 1 是缩小(拉远)。我一开始用 1.15 想留边距,结果模型顶出画面,导致渲染不完整"。翻文档才醒悟。
5.3 防止拖拽后视角跳走
refreshPosesFromTree() 更新位姿后,绝不调 resetCamera():
cpp
// 这里绝不 resetCamera():FK 刷新只更新位姿,
// 相机视角保持用户当前状态(Gazebo/RViz 同款行为)。
否则你拖着拖着模型,视角突然跳回默认------体验极差。
六、Qt 集成:QVTKOpenGLNativeWidget
VTK 的渲染窗口嵌入 Qt 靠 QVTKOpenGLNativeWidget:
cpp
vtk_widget_ = new QVTKOpenGLNativeWidget(parent);
auto* renWin = vtk_widget_->renderWindow();
renWin->AddRenderer(scene_->renderer());
红线 :render/ 层只用 VTK 不引 Qt,QVTKOpenGLNativeWidget 归 ui/ 管。依赖方向 ui → render → core 严格单向。
下一篇讲 Qt 界面实战------属性面板的滑块联动、树控件的右键菜单、姿势串的解析与插值播放。