三维模型在窗口里转起来那一刻是挺爽的,但真正让人卡住的从来不是"能不能显示",而是显示之后那一下鼠标点击。这套工程从VS2022 + Qt5.12.3 + VTK一路搭过来,前面几篇都在解决窗口黑屏、渲染管线不通、模型加载不出来的问题,到这一篇,STL骨模型总算在qvtkWidget里正常着色、能转能缩放了,结果我在模型表面点一下想取个特征点,标记球要么飘在模型外面几十个像素,要么缩放一下位置整体跑偏,换个视角干脆一个点都取不到。STL三维图像的加载本身不难,难的是屏幕坐标到世界坐标这条链路上每一环都可能出岔子,而VTK给的默认参数又恰好把这些问题掩盖了一半。下面我把从控件选型、STL渲染管线、坐标映射、拾取器选择到点标记落地这整条路重新走一遍,重点讲清楚每个参数为什么这么设、哪几个地方最容易翻车,方便你在自己的医学图像工程里直接对着改。
1. 控件选型先定生死:Qt5.12.3下到底该用哪个VTK窗口类
VTK和Qt绑定的这个控件类,版本之间的差异大到能直接决定你后面写的代码能不能编译通过。我见过不少工程从旧项目里直接拷一段QVTKWidget的代码贴进新环境,结果头文件找不到、链接报一堆未定义符号,然后开始怀疑是编译选项的问题,实际上根本原因是VTK 9已经把这个类移除了。这一步选错,后面所有关于取点的讨论都没有意义,所以先把它说清楚。
1.1 三个窗口类的前世今生与可编译性对照
VTK提供过的Qt嵌入控件其实有三代。第一代是QVTKWidget,出现在VTK 8.2及更早版本,模块名vtkGUISupportQt,本质上是一个普通QWidget加内部paintEvent,绘制走的是VTK自己的渲染窗口句柄嵌入,不依赖QOpenGLWidget。第二代是QVTKOpenGLWidget,在VTK 8.90那个过渡版本里短暂存在,是为了让VTK渲染走Qt的OpenGL上下文,但类名和接口都很不稳定。第三代是现在主流的QVTKOpenGLNativeWidget,从VTK 9开始正式定名,继承自QOpenGLWidget,由Qt负责管理OpenGL上下文和帧缓冲,VTK只负责往上面画。
关键点在于:Qt5.12.3这个版本同时兼容第一代和第三代控件,但取决于你手上编译的是VTK 8.2还是VTK 9.x。如果你用的是VTK 8.2预编译包,那QVTKWidget完全可用,工程里不需要额外设置OpenGL格式,代码写起来也最短。如果你用的是VTK 9.x,那只能用QVTKOpenGLNativeWidget,头文件是QVTKOpenGLNativeWidget.h,并且必须在QApplication构造之前调用QVTKOpenGLNativeWidget::defaultFormat()来设置默认的QSurfaceFormat,否则启动时会因为上下文格式不匹配导致窗口全黑或者直接崩溃。
| 控件类 | 所属VTK版本 | 基类 | 是否需要QSurfaceFormat预处理 | 典型踩坑 |
|---|---|---|---|---|
| QVTKWidget | 8.2及以前 | QWidget | 否 | 升级VTK9后直接编译不过 |
| QVTKOpenGLWidget | 8.90过渡版 | QOpenGLWidget | 是 | 接口不稳定,跨版本兼容差 |
| QVTKOpenGLNativeWidget | 9.x | QOpenGLWidget | 是,必须放在QApplication前 | 忘记设format导致黑屏 |
我在这个项目里最后选的是VTK 9.1配QVTKOpenGLNativeWidget,理由很实际:VTK 9对现代OpenGL的支持更完整,STL这类几十万三角面的模型在渲染和拾取时帧率明显更稳;而且Qt5.12.3的QOpenGLWidget已经很成熟,上下文共享问题比VTK 8.2那套自绘方案少很多。代价是多了一行格式初始化代码,以及所有旧的QVTKWidget示例代码都不能直接抄。
1.2 VS2022工程里让窗口显示出来所需的最小配置
Visual Studio这边要配的东西其实不多,但漏一项就是一堆链接错误。工程如果走的是Qt VS Tools的qmake或者CMake生成,包含目录要指向VTK的include\vtk-9.1,库目录指向lib。附加依赖项至少要包含以下几组:vtkCommonCore、vtkCommonDataModel、vtkRenderingCore、vtkRenderingOpenGL2、vtkRenderingFreeType、vtkInteractionStyle、vtkInteractionWidgets、vtkFiltersSources、vtkFiltersGeometry、vtkIOGeometry(读STL要用)、vtkGUISupportQt。少一个vtkGUISupportQt就会出现QVTKOpenGLNativeWidget构造函数的未解析外部符号,这个错误信息很不直观,容易误判成Qt版本不匹配。
运行时的问题更隐蔽:编译能过,双击exe闪退,或者窗口能出来但三维区域是纯黑。九成是dll没拷全。Qt那五个核心dll(Core、Gui、Widgets、OpenGL、Svg)要和所有vtk*.dll放在exe同目录,其中vtkGUISupportQt-9.1.dll和vtkRenderingOpenGL2-9.1.dll最容易被漏掉,因为它们不会在链接阶段报错。
初始化的顺序也要严格按下面的写法,setDefaultFormat必须在QApplication实例化之前:
#include <QApplication> #include <QSurfaceFormat> #include <QVTKOpenGLNativeWidget.h> int main(int argc, char* argv[]) { QSurfaceFormat::setDefaultFormat(QVTKOpenGLNativeWidget::defaultFormat()); QApplication app(argc, argv); // ... 主窗口 return app.exec(); }在Qt Designer里拖控件的话,面板上是找不到这个类的,得先放一个普通的QWidget占位,右键选"提升为",类名填QVTKOpenGLNativeWidget,头文件填QVTKOpenGLNativeWidget.h,勾上全局包含。提升之后设计器里显示的仍然是空白矩形,这是正常的,运行起来才有内容。
2. STL从磁盘到渲染窗口:管线里每一环都在影响后面的取点
很多人以为STL加载就是vtkSTLReader读文件、vtkPolyDataMapper接上、vtkActor添加到renderer这三行,能看见模型就算完事。实际上这条链路上有几个参数会直接影响后续拾取的行为,尤其是法向量、标量着色开关和相机裁剪范围,它们要么让拾取位置失真,要么让拾取直接失效,只是显示阶段看不出来。
2.1 Reader到Actor的链路拆解与那几行容易被忽略的配置
标准的加载链路是vtkSTLReader→vtkPolyDataMapper→vtkActor→vtkRenderer→vtkRenderWindow。读进来的vtkPolyData包含点和三角面片,STL本身不存拓扑,顶点是重复的,所以VTK拿到的是一个"三角汤"。这一点非常重要,后面讲拾取精度的时候会回到这里。
第一个要设的是mapper->ScalarVisibilityOff()。STL文件通常不带标量数据,但有些从其他软件导出的STL会附带法向量数组或者自定义数组,mapper默认ScalarVisibility是开的,会拿某个数组当标量映射颜色,结果模型变成一片诡异的红蓝色块或者纯黑。医学图像项目里我们只需要灰白或骨色,直接关掉最省事。
第二个是插值方式。默认是Gouraud插值,模型表面看起来比较平滑;如果想看清三角面片的原始形态,用actor->GetProperty()->SetInterpolationToFlat()。对于骨模型这种细节多的数据,我一般保持Phong插值,视觉上更接近真实骨面,取点时也更容易判断位置。
vtkNew<vtkSTLReader> reader; reader->SetFileName("D:/data/skull.stl"); reader->Update(); vtkNew<vtkPolyDataMapper> mapper; mapper->SetInputConnection(reader->GetOutputPort()); mapper->ScalarVisibilityOff(); vtkNew<vtkActor> actor; actor->SetMapper(mapper); actor->GetProperty()->SetColor(0.92, 0.88, 0.80); actor->GetProperty()->SetInterpolationToPhong(); vtkNew<vtkRenderer> renderer; renderer->AddActor(actor); renderer->SetBackground(0.12, 0.13, 0.15); ui->qvtkWidget->renderWindow()->AddRenderer(renderer); ui->qvtkWidget->renderWindow()->Render();第三个是法向量。有些STL导出的面片绕序不一致,光照算出来会有整片发黑或者明暗突变。如果发现模型表面光照很怪,可以插一个vtkPolyDataNormals做重新计算,设ConsistencyOn和AutoOrientNormalsOn,但要注意这个滤波器会产生新的点集,如果后面要按原始顶点索引取点,就得在重新计算之后再建立索引映射,别搞混了。
2.2 相机、光照与模型尺度如何决定拾取的有效范围
医学影像的STL模型尺度差异非常大。颅骨的CT重建,单位通常是毫米,整体尺寸在150到250之间;而某些显微扫描导出的模型可能是0.1量级。这个尺度差异直接影响相机的近远裁剪面(clipping range)。renderer->ResetCamera()会根据模型的包围盒自动算一次,但如果你之后手动改过相机位置或者加了其他很小的actor,裁剪范围可能被压得很窄,导致模型一部分被切掉或者干脆看不见。稳妥的做法是每次添加大模型之后显式调用renderer->ResetCamera(),并在相机操作后调用renderer->ResetCameraClippingRange()。
光照也是类似的问题。默认renderer只有一个头灯(headlight),对于凹面比较多的骨模型,内部结构会看不清。加一盏跟随相机的补光会更舒服,但这不是必需的。真正需要注意的是,如果场景里加了多个actor,比如模型加上后面要讲的标记球,拾取器的PickFromList范围要限定清楚,不然会拾取到标记球本身。
还有一个容易被忽视的点:vtkRenderer的GetActiveCamera()->ParallelProjection。医学图像查看器里常用平行投影,视觉上不变形,但平行投影下拾取射线是平行的,vtkCellPicker依然能正确工作,只是有些老版本VTK在平行投影下的拾取深度排序会有偏差。透射投影下没有这个问题,我默认保持透射投影。
3. 三维取点的核心:屏幕坐标到世界坐标差了三层映射
这是整个功能里最烧脑的部分。Qt的鼠标事件给你一个坐标,VTK的拾取器要另一个坐标,中间隔着DPI缩放、坐标系原点翻转、以及渲染窗口实际尺寸和控件尺寸可能不一致这三道坎。我当时就是在这里连续翻了三次车,每次都表现为"标记球偏移几十像素",但原因完全不同。
3.1 Qt鼠标坐标与VTK显示坐标之间差了哪几样东西
先说清楚三个坐标系。
Qt的鼠标事件坐标event->pos(),单位是逻辑像素,原点是控件的左上角,y轴向下为正。这是第一层。
VTK的显示坐标(display coordinate),单位是渲染窗口的像素,原点是渲染窗口的左下角,y轴向上为正。vtkPicker::Pick(x, y, z, renderer)接收的就是这个坐标。这是第二层。
VTK的世界坐标(world coordinate),是模型实际所在的三维空间,GetPickPosition返回的就是这个。这是第三层。
两层之间的转换至少涉及两个变换:y轴翻转和DPI缩放。y轴翻转很好理解,直接height - y。DPI缩放是因为QT5.12在开启高DPI支持后,event->pos()返回的是逻辑像素,而QVTKOpenGLNativeWidget内部的QVTKInteractorAdapter会把渲染窗口的尺寸设置成物理像素,两者的比值就是devicePixelRatioF()。
正确的转换代码是:
void MainWindow::onPickRequested(const QPoint& pos) { vtkRenderWindow* rw = ui->qvtkWidget->renderWindow(); int* sz = rw->GetSize(); const qreal dpr = ui->qvtkWidget->devicePixelRatioF(); double x = pos.x() * dpr; double y = sz[1] - pos.y() * dpr; vtkSmartPointer<vtkCellPicker> picker = vtkSmartPointer<vtkCellPicker>::New(); picker->SetTolerance(0.008); picker->Pick(x, y, 0.0, renderer); if (picker->GetCellId() < 0) return; double world[3]; picker->GetPickPosition(world); // world 就是世界坐标 }这里的sz[1]用渲染窗口自己的高度而不是控件的height(),是我踩过坑之后改的。绝大多数情况下两者满足sz[1] == height() * dpr,但如果你在布局里给控件设了边距、或者控件被嵌套在带边框的容器里,QVTKOpenGLNativeWidget内部的帧缓冲尺寸可能和控件尺寸不完全一致,用rw->GetSize()更保险。
3.2 vtkCellPicker、vtkPointPicker、vtkPropPicker到底该选谁
VTK提供的拾取器有好几种,选错了不是取不到点,而是取到的点不是你要的那个点。
vtkPropPicker最快,只告诉你点到了哪个actor或者prop,不返回具体坐标,适合做"点中了没"这种判断,比如高亮某个模型。
vtkPointPicker返回距离射线最近的顶点的id和坐标。它适合点云或者需要精确取网格顶点索引的场景,但在STL上用它有个大问题:STL的顶点是三角汤里重复的原始点,点密度完全取决于模型精度,你想在某个平滑位置取点,它返回的可能是半厘米外的某个顶点。
vtkCellPicker返回的是射线与模型表面的交点所在的cell id和世界坐标,也就是真正的"表面上那一点"。它内部做了深度排序,前面的面会遮挡后面的面,所以不会出现穿透到模型背面的情况。做STL表面取点,这个是首选。
| 拾取器 | 返回内容 | 是否处理遮挡 | 精度来源 | 推荐场景 |
|---|---|---|---|---|
| vtkPropPicker | 命中prop指针 | 是 | 不涉及 | 点选高亮、切换选中模型 |
| vtkPointPicker | 顶点id + 顶点坐标 | 弱 | 网格顶点 | 点云取点、精确顶点索引 |
| vtkCellPicker | cell id + 表面交点坐标 | 是 | 三角面交点 | STL表面取点、测量 |
| vtkAreaPicker | 区域内所有prop | 是 | 不涉及 | 框选批量选中 |
SetTolerance这个参数也值得单独说一下。它默认是0.025,单位不是像素,而是相对于渲染窗口尺寸的归一化比例,也就是说窗口越宽容差越大。取点场景下我一般设成0.005到0.01之间,太大会在你没点到模型的时候也返回一个附近的面,太小则边缘位置容易漏点。
3.3 鼠标事件怎么才能顺利从Qt传到VTK
这个坑我印象最深。QVTKOpenGLNativeWidget内部挂了一个QVTKInteractorAdapter,会把Qt的鼠标事件翻译成VTK的交互事件,交给vtkInteractorStyle处理。默认的vtkInteractorStyleTrackballCamera会把左键按下拖动当成旋转,这意味着你在mousePressEvent里做拾取是可以的,但因为拖动也会触发press,用户每转一次视角就会甩出一个标记点。
我试过三种方案。第一种是继承QVTKOpenGLNativeWidget重写mousePressEvent,在里面先调父类再拾取,结果是每转一次模型就多一个球,直接放弃。第二种是装eventFilter,在QEvent::MouseButtonPress时处理,问题一样,因为它拦的是同一个事件。第三种是判断"按下和抬起之间鼠标移动距离",小于阈值才算点击,这个方案最后跑通了。
bool MainWindow::eventFilter(QObject* obj, QEvent* ev) { if (obj == ui->qvtkWidget) { if (ev->type() == QEvent::MouseButtonPress) { auto* me = static_cast<QMouseEvent*>(ev); m_pressPos = me->pos(); m_pressing = true; } else if (ev->type() == QEvent::MouseButtonRelease) { auto* me = static_cast<QMouseEvent*>(ev); if (m_pressing && (me->pos() - m_pressPos).manhattanLength() < 4) { onPickRequested(me->pos()); } m_pressing = false; } } return QMainWindow::eventFilter(obj, ev); }阈值4个像素是测出来的经验值。太小的话,手持鼠标轻微抖动就会漏掉正常点击;太大的话,小幅拖动旋转也会被误判成点击。3到5之间比较合适,具体看你的鼠标灵敏度。
4. 实测中反复踩到的几个坑及对应处理
上面讲的是原理和标准做法,但真实工程里总会有一些偏离标准的情况。这一节把我在这个项目里反复遇到的几个问题进行拆解,每个都给出定位思路,而不只是结论,方便你遇到类似现象时能自己找到原因。
4.1 高DPI下点位整体偏移的完整定位过程
现象是:不加DPI缩放时取点完全准,开启高DPI后,点模型左上部分的标记球会向右下方偏移,越往右下偏移越小,靠近右下角时基本准确。这个规律很典型,说明是一个与x、y成比例的系统误差,也就是缩放系数不对。
定位过程是这样的。第一步,在拾取函数里把pos、dpr、sz都打印出来,发现sz[0]约等于width() * 1.5,说明确实是1.5倍DPI。第二步,临时把坐标转换改成不做dpr缩放,只做y翻转,发现偏移量变小但方向还在。第三步,检查发现代码里y翻转用的是控件的height()而不是sz[1],在1.5倍缩放下这个差值正好和偏移量吻合。第四步,改成sz[1]并乘dpr,偏移消失。
这里有个细节需要注意:Qt5.12的高DPI支持需要在QApplication之前设置QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling),否则devicePixelRatioF()会返回1,但窗口本身又按系统缩放显示了,两边对不上,偏移会更乱。我在项目里是把这个属性打开、并且在转换时统一乘dpr,逻辑最清晰。如果你不想折腾,另一个办法是直接把这行属性去掉,让Windows的缩放机制完全接管,此时devicePixelRatioF()会保持1,坐标转换就退化成简单的y翻转,程序能跑但你看到的界面会比较小,4K屏上体验不好。
4.2 拾取到模型背面与"没点到却返回了点"
第一种情况是模型是空心的,或者只建了单面,从正面点下去,vtkCellPicker本身会返回最近的正面交点,这没问题。但如果你用了vtkPointPicker,它是按距离算的,当背面某个顶点的屏幕投影距离比正面交点更近时,就会返回背面那个点,视觉上表现为标记球出现在了模型内部或者背面。
判断的方法很简单,在拾取之后比较交点和相机的方向:把交点减去相机位置得到视线向量,和该处的表面法向量做点积,如果是负数说明是背面朝向。对于需要"只取正面"的场景就过滤掉。不过对于后面要做配准的医学点选,正反面点其实都有价值,我的做法是不过滤,但把背面点的颜色标成另一种颜色,让操作者自己判断。
第二种情况是明明点在空白处,GetCellId()却返回了一个有效的id。这基本就是SetTolerance设太大了。前面说过它是归一化比例,窗口宽度是1600的时候,0.025的容差相当于40像素的半径,点在模型外围40像素以内都会被判成命中。改成0.005之后这个现象就消失了。
4.3 STL没有拓扑结构带来的取点精度问题
STL三角汤的特点决定了"取到的点"其实落在某个三角面内部,而不是任何原始顶点上。这在测距、测角度场景下没问题,因为交点本身是准确的表面位置。但如果你的下游算法需要网格顶点索引,比如要做形变配准或者特征点匹配,就必须从cell反推出三个顶点,再选最近的。
vtkPolyData* pd = vtkPolyData::SafeDownCast(mapper->GetInput()); vtkCell* cell = pd->GetCell(picker->GetCellId()); vtkIdList* idList = cell->GetPointIds(); double nearest[3]; double bestDist2 = VTK_DOUBLE_MAX; for (vtkIdType i = 0; i < idList->GetNumberOfIds(); ++i) { double* p = pd->GetPoint(idList->GetId(i)); double d2 = vtkMath::Distance2BetweenPoints(p, world); if (d2 < bestDist2) { bestDist2 = d2; nearest[0] = p[0]; nearest[1] = p[1]; nearest[2] = p[2]; } }这段代码有个隐藏前提:actor没有施加额外的transform。如果actor上挂了SetUserTransform或者SetPosition,那么pd->GetPoint()返回的是模型局部坐标,必须再经过transform转成世界坐标才能和GetPickPosition的结果比较。
vtkMatrix4x4* mat = actor->GetMatrix(); double local[4] = {p[0], p[1], p[2], 1.0}; double worldPt[4]; mat->MultiplyPoint(local, worldPt);我在第一版里就漏了这一步,因为当时模型是原点加载的,transform是单位矩阵,测不出来。后来换成从DICOM重建带位置偏移的模型,取点整体平移了一大截,找了大半天才发现。
5. 把拾取到的点用起来:标记、交互与持久化
取点本身只是中间结果,项目里真正有用的部分是"取完之后怎么办"。医学图像场景下的典型需求是:在模型上标记若干解剖标志点,实时显示,最后导出坐标交给配准算法或者测量模块。这一块看起来简单,但标记的显示效果、刷新性能和导出格式,每一项都有讲究。
5.1 用vtkGlyph3DMapper做实时点标记而不是逐个加Actor
最直接的想法是每取一个点就new一个vtkSphereSource、vtkPolyDataMapper、vtkActor,逐个加到renderer里。五个点没问题,五十个点开始卡,五百个点基本转不动。原因是每个actor都意味着一次独立的绘制调用,VTK在render window里逐个走管线,开销随点数线性增长。
更合适的做法是维护一个vtkPoints,所有拾取到的坐标都往里加,然后一次性用vtkGlyph3DMapper把每个点渲染成一个小球。这样无论多少点,都只是一个actor、一次绘制。
m_pickedPoints = vtkSmartPointer<vtkPoints>::New(); m_pickedPoly = vtkSmartPointer<vtkPolyData>::New(); m_pickedPoly->SetPoints(m_pickedPoints); vtkNew<vtkSphereSource> sphere; sphere->SetRadius(m_markerRadius); sphere->SetThetaResolution(16); sphere->SetPhiResolution(16); vtkNew<vtkGlyph3DMapper> glyphMapper; glyphMapper->SetInputData(m_pickedPoly); glyphMapper->SetSourceConnection(sphere->GetOutputPort()); glyphMapper->ScalingOff(); glyphMapper->SetScalarVisibility(false); m_markerActor = vtkSmartPointer<vtkActor>::New(); m_markerActor->SetMapper(glyphMapper); m_markerActor->GetProperty()->SetColor(1.0, 0.35, 0.1); renderer->AddActor(m_markerActor);ScalingOff()这一行很关键。vtkGlyph3DMapper默认会根据数据里的标量或者向量缩放每个glyph,如果点数据里没有标量数组,某些版本会按包围盒自动缩放,导致球体大小随点数变化。关掉之后球体半径就是SphereSource设定的固定值。
半径的设定我建议按模型包围盒来算,而不是写死。取模型polydata的bounds,算出对角线长度,半径取对角线的1/120到1/150之间。这样无论是毫米级的颅骨还是米级的场景,标记球在屏幕上的视觉大小都差不多。
double bounds[6]; mapper->GetInput()->GetBounds(bounds); double diag = std::sqrt(std::pow(bounds[1]-bounds[0],2) + std::pow(bounds[3]-bounds[2],2) + std::pow(bounds[5]-bounds[4],2)); m_markerRadius = diag / 130.0;每次新增点之后,只需要m_pickedPoints->InsertNextPoint(world); m_pickedPoints->Modified();,然后触发一次渲染即可。glyph mapper会检测到输入数据被修改,重新生成glyph。
5.2 点集导出的几种格式与后续配准的衔接
导出这里,我最常用的有三种格式,选哪种取决于下游要接什么。
纯文本txt最简单,每行三个浮点数,空格分隔。好处是任何语言都能读,坏处是没有元信息,没有坐标系说明,时间长了不知道这个文件是从哪个模型的哪个坐标系导出来的。
CSV比txt多一个表头,可以带上点序号、是否可见、点的类型(比如A类解剖点、B类解剖点),对于有分类需求的项目很合适。
vtkPolyData的.vtp格式最完整,可以带上点的属性数组,也能被Paraview直接打开检查,缺点是下游如果用Python或者C#,解析稍微麻烦点。
void MainWindow::exportPickedPoints(const QString& path) { std::ofstream out(path.toStdString()); out << "index,x,y,z\n"; for (vtkIdType i = 0; i < m_pickedPoints->GetNumberOfPoints(); ++i) { double* p = m_pickedPoints->GetPoint(i); out << i << "," << p[0] << "," << p[1] << "," << p[2] << "\n"; } out.close(); }这里有个细节要提前想清楚:导出的坐标必须是世界坐标。前面讲过的actor transform问题在这里同样适用。如果你的模型有位置偏移,GetPickPosition返回的已经是世界坐标,没问题;但如果你是从vtkPoints里从别的地方拷贝的坐标,就要确认来源。我建议在拾取的那一刻就把世界坐标存进去,中间不做任何转换,避免后续扯皮。
还有一个交互上的建议:导出之前先让用户确认点的顺序。医学标志点一般有固定的解剖顺序,比如先鼻根后下颌角,顺序错了配准算法算出来的变换矩阵就是乱的。加一个"撤销上一个点"的按钮,比让用户导出来再去Excel里删要友好得多。
5.3 高频取点时的刷新策略与交互手感
连续快速取点时,如果每加一个点就调用一次renderWindow()->Render(),在模型面数几十万的情况下会明显掉帧,手感发涩。VTK的渲染是同步的,Render()返回之前不会处理下一个事件,所以用户会感觉鼠标卡顿。
改善办法是不要把渲染写在拾取函数里,而是发一个信号出去,由主线程的定时器统一刷新。或者用renderWindow()->SetDesiredUpdateRate()配合交互状态,但这个更适合拖动相机时降采样,对取点帮助不大。
我实测下来最简单有效的方案是:拾取函数只负责往vtkPoints里加数据,然后调用ui->qvtkWidget->renderWindow()->Render()。因为取点是低频操作,一秒钟最多点两三次,几十万面的模型单次渲染在普通独显上也就十几毫秒,感受不到卡顿。真正卡的是那种"按住鼠标连续扫过表面生成一串点"的交互,那种场景我建议加一个节流,每50毫秒最多触发一次拾取,中间的点直接丢弃,等松开鼠标之后再补一次最终的精确拾取。
另外,视口里点的数量多了之后,用鼠标旋转时会感觉变慢,主要开销在glyph mapper重新生成几何。如果你的应用需要支持几百个点,可以设置glyphMapper->SetMaximumNumberOfGlyphs()或者在点数超过阈值时降低球的细分度,SetThetaResolution(8)和SetPhiResolution(8)能省掉一半以上的三角面。
最后分享一个我在调试阶段用的小技巧:在拾取函数里加一个开关,把拾取射线画出来,也就是从相机位置沿穿过鼠标位置的方向画一条长线,用vtkLineSource和一个细长的actor表示。这样每次点下去,你能直接看到射线打在了模型的哪个位置,比盯着标记球猜要直观得多。排查坐标偏移类问题时,这条辅助线几乎是一眼定位,比打印一堆坐标值快得多。