做三维可视化的人应该都遇到过这种需求:场景里画了器官模型、网格切片、点云数据,光秃秃摆在那谁也看不懂,总得在边上放几个文字说明——这是哪个切面、哪个测点、哪条导联线。VTK学习系列走到第十五篇,今天把三维场景里的文字标注(3D Text)整套玩法梳理一遍,包括最常用的矢量文字、始终面向相机的Follower方案、以及实际项目里的字体、遮挡、拾取这些坑。
这篇内容适合刚接触VTK、准备在自己的渲染场景里加标注的同学,也适合已经能画基本几何体、但不知道怎么把文字自然嵌进三维空间的人。文章不会绕弯子,直接从代码和踩坑讲起。
1. 三维场景里放文字,到底难在哪
1.1 文字标注是可视化的刚需
你别看3D Text在VTK文档里不算大话题,实际项目里几乎绕不开。我最早做某跨平台系统的时候给医学影像做三维重建,模型切出来之后,医生第一句话问的是:这个切片位置是第几张?那个病灶区域右上角的数字是什么?没标注,再漂亮的渲染都是废的。
三维场景里的标注和二维界面的标签完全是两码事。二维界面你在固定位置画一行字就行,用qt的label或者图片叠加都能凑合。但三维场景里的标注要跟着物体动,要能在旋转、缩放、平移的时候始终指向对应位置,字还得保持可读。这个需求听起来简单,做起来全是细节。
VTK里实现3D Text,说白了就是解决三件事:文字怎么生成、文字怎么放到指定空间位置、文字在相机旋转时以什么姿态呈现。这三件事分别对应vtkVectorText(生成文字几何)、vtkPolyDataMapper和vtkActor(普通放置)、以及vtkFollower(跟随相机)。后两者看着像,差一个字,实际表现天差地别。
1.2 VTK里做文字标注的三条主流路线
我整理了下目前常用的三种方案,选择哪个取决于你的文字是要“贴在”三维物体上,还是始终面向屏幕,还是干脆固定为界面水印。
| 方案 | 核心类 | 特点 | 适用场景 |
|---|---|---|---|
| 矢量文字+Follower | vtkVectorText + vtkFollower | 文字是三维几何,可缩放,始终面向相机 | 模型标注、测点标签、切片说明 |
| 平面板文字(缩放不变) | vtkBillboardTextActor3D | 屏幕空间矢量文字,字号自动保持 | 点云标签、路标式annotation |
| 二维叠加文字 | vtkTextActor / vtkCornerAnnotation | 固定在视口角落,不随场景运动 | 水印、版本号、固定图例 |
三者的原理区别要搞清楚。vtkVectorText是把字符串转换成一组三角面片,本质上是多边形模型。所以你给它设置的颜色、光照、变焦行为,跟普通几何体一模一样。vtkBillboardTextActor3D则不是几何模型,它更像一个始终贴在三维坐标上的广告牌,渲染时按照屏幕大小计算字号,缩放视角时字的大小保持稳定。vtkTextActor干脆就是二维控件,直接叠加在渲染窗口上,跟三维场景没有深度关系。
三条路线里,我练得最多、项目里用得最频繁的是第一条:vtkVectorText + vtkFollower。原因很实际:它的文字是真正的三维几何,既能被光照照亮,也能参与深度测试,还能被拾取器选中。而vtkBillboardTextActor3D虽然字号稳定,但拾取和碰撞深度测试的灵活性差一点。
2. 第一次写3D Text:核心代码和最小场景
2.1 三步搭建一个能跑的Demo
先给你一个可以直接跑的Python版最小场景,用vtkVectorText造出一行字,放在三维空间某个坐标,然后用vtkFollower让它始终面向相机。
import vtk # 1. 生成文字几何 vector_text = vtk.vtkVectorText() vector_text.SetText("VTK 3D Text Demo") text_mapper = vtk.vtkPolyDataMapper() text_mapper.SetInputConnection(vector_text.GetOutputPort()) # 2. 创建Follower,让文字始终面向相机 text_follower = vtk.vtkFollower() text_follower.SetMapper(text_mapper) text_follower.SetPosition(0, 0, 0) text_follower.SetScale(2, 2, 2) # 3. 设置文字颜色 text_property = vtk.vtkProperty() text_property.SetColor(1.0, 0.85, 0.0) text_follower.SetProperty(text_property) # 渲染基础设置 renderer = vtk.vtkRenderer() render_window = vtk.vtkRenderWindow() render_window.AddRenderer(renderer) interactor = vtk.vtkRenderWindowInteractor() interactor.SetRenderWindow(render_window) renderer.AddActor(text_follower) renderer.SetBackground(0.1, 0.1, 0.1) # 别忘了设置相机的初始朝向 renderer.ResetCamera() render_window.Render() interactor.Start()跑起来之后,你会看到一行金字悬在场景中央,转动相机时它永远正对着你。这跟我第一次用vtkActor放文字时的体验完全不同——那时候文字一转到侧面就变成一条细线,啥也看不清。
2.2 为什么用vtkFollower而不是vtkActor
这是新手最容易出错的地方。vtkActor和vtkFollower用法几乎一样,但vtkFollower内部多了一个自动朝向相机的逻辑。它会在每次渲染时调整自己的法线方向,保证文字正面始终正对相机。而vtkActor只按照你给的旋转矩阵待在原地,文字在侧视角下会被挤压成一条线。
有人觉得那我自己每帧算朝向不就行了?能是能,但vtkFollower已经封装好了,你用Actor还得自己监听相机事件、计算变换矩阵,还要考虑相机重置的情况。我早期在一个模拟项目里就干过这种事,写了一大堆Observer代码,最后换成vtkFollower,删掉两百行。
不过要注意:vtkFollower最好放在根Actor下面,不要随便塞进某个复杂的Transform矩阵里。如果你给它套了一个带有旋转的父变换,它自带的“面向相机”行为会和父变换叠加,结果就是你转镜头时文字歪着飘。想控制文字朝向,优先用SetPosition和SetScale,旋转交给它内部逻辑就行。
2.3 最小场景里容易被忽略的两个点
第一个点是SetScale。vtkVectorText生成的文字默认大小是1个单位高,放在真实重建模型里经常显得巨大或者极其微小。我习惯在加载模型后统一用一个比例系数控制文字,比如模型包围盒对角线长度的2%-3%。这样可以避免换个模型,文字又大得糊一脸。
第二个点是交互器里的相机初始位置。如果你不调用ResetCamera,相机默认位置在原点附近,可能会有“文字贴脸”的诡异效果。我写Demo习惯先渲染窗口加一次ResetCamera,等相机平稳了再开始交互。
3. 实操细节:把3D Text调得又清晰又好看
3.1 文字朝向与相机跟随的底层逻辑
vtkFollower的实现并不神秘,它每帧对比自己的朝向和相机的朝向,计算一个旋转矩阵,把自己的坐标系转到正对相机的位置。这就带来一个特性:你用vtkFollower存放普通模型也可以,比如一个牌子、一个箭头,它同样会始终面向相机。我后来做点云标注的时候,把箭头也挂成Follower,让箭头永远朝向屏幕外侧,效果出奇地好。
理解了这层逻辑,你就知道一个限制:Follower面向相机,但是它自身的“上方向”还是跟随相机旋转的。文字旋转到某个视角时可能会出现地平线倾斜的现象,也就是文字正面朝你,但水平方向歪了。要解决这个问题,我一般给Follower加一个SetOrientation(0, 0, angle),根据相机俯仰角去补偿,不过多数场景下不补偿也能接受。
3.2 字号、颜色、背景的折腾方法
vtkVectorText生成的文字没有字体大小属性,只有文字内容;文字整体的尺寸靠Actor的Scale控制。所以调节字号实际是调节SetScale这个缩放比例。比例调大,文字三角形面片同步放大,渲染效果依旧平滑。但要注意,放大太多、离相机太近时,文字轮廓看起来会偏粗,那是因为纹理采样、边缘走样叠加的结果。
颜色方面,给Follower设置vtkProperty的SetColor只是改了漫反射颜色。如果你的渲染器开了灯光,文字的颜色会受光源影响。明暗不均时,远处看可能偏黑。我通常的做法是额外设置SetAmbient = 1.0,把环境光调满,让文字颜色稳定、不被灯光明暗带偏。
还有个隐藏参数是文字背景。若场景背景很花,文字很容易看不清。vtkFollower本身没有背板,需要在文字后面放一个半透明多边形。我用过一个简单方案:给文字对应的vtkActor套一个自带“广告板”效果的vtkBillboardTextActor3D,这样既能保持清晰,又免去额外放背板的负担。不过这样就牺牲了几何拾取能力,取舍看项目需求。
3.3 文字放在点、线、面上:坐标变换的坑
文字往往不是孤零零放在原点,而是要挂在某个模型的某个特征点上。比如一组圆柱体,每个圆柱体顶面中心标注一个编号。这时候要把文字坐标设成圆柱中心,这步不难,难的是让文字不糊进圆柱里面。
文字默认有深度,会参与遮挡。如果你把Follower的位置设在圆柱内部,文字会被圆柱的网格挡住一部分,看起来像被吃掉半截。要避免这个,我习惯把文字位置向外偏置一点。偏置方向一般是模型表面法线方向乘以一个小距离,比如点云标注,就沿该点在相机方向偏置0.01倍模型尺度。
如果场景里既有模型又有文字,还要考虑渲染顺序。VTK默认使用深度缓冲区,谁离相机近谁赢。文字和模型贴得太近时偶尔出现闪烁,这是z-fighting。解法不复杂:把文字往模型外再偏一点,或者给文字加上一个小的多边形偏移(PolygonOffset)。VTK里多边形偏移要通过渲染属性设置,不同版本接口略有差异,通常是在vtkProperty里找SetPolygonOffsetFactor。
4. 真实项目里3D Text一般怎么用
4.1 医学可视化里的切片标记
我在给某医疗软件项目做三维重建时,在三个正交切片的交点处各放了一行文字,分别标注“矢状位”、“冠状位”、“横断位”。每行文字用vtkFollower挂到对应切片的固定坐标。用户旋转三维模型时,文字始终面向医生,不会因为视角变化找不到当前切面。
这里有个细节我踩过坑:切面本身是vtkImageSlice或者vtkVolume,文字是几何Actor,两者不在同一个渲染管线下。你要是把文字放进渲染器后发现被切面整体挡在后面,别急着调深度,先检查切片的渲染方式。某些体绘制设置下,体数据会吞掉后面的几何体,需要把文字Actor的RenderMode改成覆盖模式,或者把文字放在距离相机更近的层。
4.2 工程仿真模型测量点标注
在某个工程仿真前处理系统里,我负责在结构模型上标出几十个测量点,每个点配一个编号。测量点本身用小红球表示,编号文字用vtkFollower挂在球旁边。用户要做的是捡起球、读取编号、记录数据。这里要用到拾取(Picking),我把vtkFollower加入Pickable之后,拾取返回的Actor带有我添加的用户数据,也就是编号ID,这样就能做到点文字直接弹出测量详情。
工程模型通常网格密集,几千个测点同时显示文字会非常卡。我后来做了动态显隐:相机拉近时显示近距离的标签,拉远时只显示当前选中点。用vtkRenderWindowInteractor的鼠标事件做距离判断,每次移动都更新文字集合的Visible属性。渲染能稳在60帧左右,比一开始全部常显快了不止一倍。
4.3 点云数据的ID标签与拾取辅助
点云项目里文字标注更特殊。点云动辄几十万个点,你不能每个点都放一个Follower,那样actor数量爆掉,界面直接崩。我后来改成只给当前选中的点显示编号,以及给聚类中心显示英文ID。为了让标签不挤,我把文字放在中心点位置,用vtkFollower保证朝向,然后字体调成屏幕固定大小——这个问题我通过响应缩放事件来实现:鼠标滚轮触发时,重新计算所有标签的Scale,让文字在屏幕上的投影尺寸恒定。
这也是一个经验:点云里的文字不适合用“世界单位固定大小”,因为点云本身是三维的,你缩放时世界单位没有概念。按屏幕尺寸反推世界尺寸才是最可读的方案。
5. 3D Text避坑指南
5.1 文字模糊、变形、走样
vtkVectorText生成的文字是曲面模型,渲染时如果分辨率不高,远处看边缘全是锯齿。这个问题的根源不是字体问题,而是几何模型本身的三角剖分。解法是提高渲染采样率或开启多重采样抗锯齿(Multisampling)。VTK里可以通过vtkRenderWindow的SetMultiSamples(8)开启,渲染效果会平滑很多。
另一个变形问题是位置和朝向设置不对,尤其是Follower在场景有旋转缩放时。我之前在某个跨平台系统里把模型整体塞进了一个带旋转的Actor里,文字也挂在这个Actor下,结果旋转相机时文字自己先转了好几圈,最后变形成一团。排查半天发现父Actor的非均匀缩放矩阵传给Follower,导致朝向计算出错。解决办法是让Follower直接挂在Renderer的RootActor层级下,文字坐标用世界坐标,不跟随模型内部的旋转。
5.2 中文显示成方块或乱码
vtkVectorText只处理TTF字体。默认情况下,它加载的是内置英文字体,中文会显示成一堆方块。这个问题在不同的VTK版本里表现不一样,早期版本简直让人崩溃。一个常见做法是使用vtkTextActor和vtkTextProperty来指定中文字体文件路径,让VTK读取系统里的中文字体。
对于三维里的文字,我给某项目写过一套通用方案:单独用FreeType相关类加载中文字体,或者把中文转成轮廓线后用vtkTexturedActor渲染。最靠谱的还是设定一个中文字体路径,然后通过vtkTextProperty.SetFontFamily和SetFontFile给每个text属性配置字体。实际测下来,Windows下用系统自带宋体、黑体的TTF文件都能正常显示。留个提醒:中文字体文件路径里不要有中文,某些VTK模块对路径解析有问题。
5.3 文字被遮挡和深度冲突
文字被模型挡住是常见问题,尤其当文字在模型内部或靠近表面时。解决方案可以分层思路:让文字Actor关闭深度测试(在vtkProperty里设置DepthTestOff),这样文字永远不会被模型覆盖。但代价是模型跑在文字前面时也会被穿透,视觉上有点假。多数项目只对当前选中的目标文字开启“始终可见”,其余保持正常深度。
如果是多个文字之间互相遮挡,优先保证被拾取的那个标签置顶。我用过一种简单办法:每次选中一个标签就把它从当前Layer挪到Renderer的最上层。Renderer的AddActor顺序直接影响绘制覆盖,后加入的会先绘制在前的场景之上。不过这个特性依赖具体渲染管线的深度写设置,最好实测验证。
5.4 Follower的文字更新不生效
有一次我把Follower的Mapper换了个新的vtkVectorText输出,SetText之后画面纹丝不动。原因是我忘了调用Modified()。VTK的渲染管线基于修改时间戳,你改了输入数据,但是Mapper端的输出端口没有收到修改信号,图形就不会刷新。解决办法是给Follower和Mapper都调用一次Modified(),或者强制重新设置一次InputConnection。
同理,SetText里传的字符串如果很长,文字几何体面片数暴增,渲染帧率骤降。我统计过,50个汉字大约生成两三万三角形,几个Follower一多就卡。长文本建议拆分多个短标签,或者直接换成vtkBillboardTextActor3D。
5.5 Pickable属性与拾取冲突
Follower默认是Pickable,但是拾取返回的位置是它内部某一面片的位置,不是你给它SetPosition的位置。你要想从拾取事件里拿到准确的世界坐标,需要手动保存原始坐标到自定义数据里。我在某点云标注系统里就是给Follower附加一个vtkIdTypeArray标记,拾取后通过GetFieldData解析出真实世界坐标,而不是从PickPosition硬读。这个问题不复杂,但是很容易在项目调试时绕弯子。
6. 从3D Text再往前走一步
6.1 和2D Overlay配合使用
有一种常见需求是:三维场景中显示一个浮动标题,同时在屏幕角落显示固定提示。前者用vtkFollower做三维跟随,后者用vtkTextActor叠在渲染窗口上。我通常把这一类2D文字放到渲染器的OverlayRenderer中,这样不会影响三维场景的深度测试,也方便统一样式和透明度。遇到DPI变化或窗口拉伸时,2D文字的字体尺寸会跟着变,建议在Resize事件里重新设置FontSize。
6.2 用vtkTextActor做画面水印和坐标尺
很多可视化软件需要在截图里自动附带“时间+坐标尺+受试者编号”。这个需求用vtkCornerAnnotation就能快速实现,它本身就是vtkTextActor的子类。把时间、比例尺、坐标系信息写进去,设置位置到右下角。你要注意,CornerAnnotation的字体随窗口缩放容易变糊,我一般都是在初始化时设定一个合适的字号,并且允许用户手动改显示大小。
这套思路和3D Text不冲突,只是层次不同:3D Text是场景内几何,2D Text是窗口上覆盖层。两者经常一起用,一个表面标注,一个信息水印,配合起来才完整。
6.3 从标注到交互,文字背后的信息量
文字的真正价值不在“显示一行字”,而在于它承载的交互入口。我在某点云交互系统里做过一个功能:每个标签的Follower上绑定pick事件,用户双击文字就能折叠或展开该点的属性面板。文字本身只是几何外观,但通过UserData和Callback,它能变成整个交互流程的状态入口。
这一点在做大规模标注系统时尤其明显。纯文字的渲染性能、显隐逻辑、拾取命中,一旦有一环做不好,整个标注流程就会卡壳。实测下来,前期把文字Actor的层级理清,把坐标偏置规则定好,后续扩展交互会顺手很多。如果只是临时标几行字,那随便写写;如果做正式系统,我强烈建议给标签写一个统一管理器,统一处理创建、删除、显隐、缩放、拾取。
VTK学习系列走到这里,3D Text算是把一个完整的纵向链条摸清了:从生成文字几何,到挂上Follower实现朝向跟随,再到字体、遮挡、性能、拾取这些现实问题。我写这套笔记,更多是希望你能像我一样少踩几个坑,把文字标注这块稳当本地落地。