第七章 PolyData详解(PolyData Deep Dive)
本章导读
在第三章中,我们初次遇见了vtkPolyData——那是在数据模型概览中,它被描述为VTK中"最灵活、最常用的数据集类型之一"。第六章的"3D几何画廊"综合实战中,你看到了vtkPolyData在过滤器、Mapper和Actor之间流转的身影。但你或许还没有机会停下来,仔细审视这个数据类型本身:它如何组织数据?如何用代码构造一个带有顶点、线段、三角形、纹理坐标和法向量的完整几何模型?
本章就是为此而来。我们将对vtkPolyData进行一次深入、完整、从原理到实践的审视。你将学习:
- PolyData的双层结构——拓扑(Topology,单元连接关系)与几何(Geometry,点坐标)的分离设计,以及这种分离为何是VTK数据模型中最优雅的设计决策之一。
- 四种基本图元类型——Verts(点元)、Lines(线元)、Polys(面元)、Strips(三角带),它们在
vtkCellArray中如何存储,以及如何被组装到同一个vtkPolyData中。 vtkPoints与vtkCellArray的完整API——从InsertNextPoint()到InsertNextCell(),从传统的"两步插入法"到现代的std::initializer_list语法。- 法向量(Normals)与着色——光滑着色(Smooth Shading)与平面着色(Flat Shading)的本质区别,
vtkPolyDataNormals过滤器的工作原理,以及SetFeatureAngle()如何控制两者之间的过渡。 - 纹理坐标(Texture Coordinates)——如何将二维图像映射到三维曲面上,以及纹理坐标与几何坐标之间的关系。
- 一个完整的综合代码示例——从零开始构建一个带高程着色和表面法向量的3D地形曲面,整合本章所有知识点,并包含完整可编译的CMakeLists.txt。
本章是第二卷(进阶篇)的起点。如果说第一卷帮助你"会用VTK",本章将帮助你"理解VTK如何表示几何世界"。读完本章后,你将对手动构建任意vtkPolyData充满信心——无论是程序化生成的数学曲面、从外部传感器采集的点云、还是自定义的CAD几何体。
7.1 PolyData的结构
7.1.1 拓扑与几何的分离
vtkPolyData的核心设计理念可以浓缩为一句话:拓扑(Topology)和几何(Geometry)是分开存储的。
- 几何指的是点在三维空间中的实际位置——
(x, y, z)坐标。在vtkPolyData中,几何由vtkPoints对象管理。 - 拓扑指的是点之间的连接关系——哪些点组成一条线段,哪些点组成一个三角形面片。在
vtkPolyData中,拓扑由四个独立的vtkCellArray对象管理。
这种分离意味着:一段描述三角形面片的拓扑数据{0, 1, 2}是独立于这三个点的实际坐标的。你可以修改点的坐标(例如在动画中移动顶点),而单元的拓扑定义保持不变。反过来,你也可以修改拓扑(例如将一个四边形拆分为两个三角形)而不需要改变任何点的坐标。
这种设计在科学可视化中至关重要: - 网格变形:在有限元分析中,结构受力变形时点坐标改变,但单元的连接关系(哪些点组成一个六面体单元)保持不变。 - 网格细化:你可以重新三角化一个曲面(改变拓扑),但保留原始采样点的位置(保持几何)。 - 内存效率:多个单元可以共享同一个点,而不需要为每个单元重复存储点的坐标。
7.1.2 四类基本图元
vtkPolyData支持四种不同维度的图元类型,每一种都由一个独立的vtkCellArray管理:
| 图元类型 | 维度 | 存储位置 | 支持的单元格类型 |
|---|---|---|---|
| Verts(点元) | 0D | GetVerts() / SetVerts() |
VTK_VERTEX (1), VTK_POLY_VERTEX (2) |
| Lines(线元) | 1D | GetLines() / SetLines() |
VTK_LINE (3), VTK_POLY_LINE (4) |
| Polys(面元) | 2D | GetPolys() / SetPolys() |
VTK_TRIANGLE (5), VTK_QUAD (9), VTK_POLYGON (7) |
| Strips(三角带) | 2D | GetStrips() / SetStrips() |
VTK_TRIANGLE_STRIP (6) |
一个vtkPolyData可以同时包含所有这些图元类型。例如,一个科学可视化场景可能包含:
- 散点图(Verts——每个采样点是一个独立的顶点)
- 粒子轨迹线(Lines——每条轨迹是一条折线段)
- 等值面(Polys——由三角形组成的三维曲面)
- 地形条带(Strips——高效存储规则采样地形)
这种混合能力使得vtkPolyData异常灵活。但请注意VTK头文件中的警告:如果要在同一个vtkPolyData中混合使用多种类型,必须按照Verts、Lines、Polys、Strips的顺序插入单元,否则单元ID的一致性会被破坏,单元数据(Cell Data)的渲染也可能出错。
7.1.3 vtkCellArray的内部存储结构(现代格式)
理解vtkCellArray的内部存储格式对于高效使用vtkPolyData至关重要。在VTK 9.x中,vtkCellArray使用一种称为"偏移量+连接性"(Offsets + Connectivity)的双数组结构:
假设我们有 3 个单元:
单元 0: Triangle | 点索引: {0, 1, 2}
单元 1: Quad | 点索引: {3, 4, 6, 7}
单元 2: Line | 点索引: {5, 8}
内部存储:
Offsets: {0, 3, 7, 9} ← NumCells+1 个值,每个值指向Connectivity中的起始位置
Connectivity: {0, 1, 2, 3, 4, 6, 7, 5, 8} ← 所有点ID的连续排列
解析方式:
- 单元0:Offsets[0]=0, Offsets[1]=3,读取Connectivity[0..2] → {0, 1, 2},长度=3
- 单元1:Offsets[1]=3, Offsets[2]=7,读取Connectivity[3..6] → {3, 4, 6, 7},长度=4
- 单元2:Offsets[2]=7, Offsets[3]=9,读取Connectivity[7..8] → {5, 8},长度=2
这种格式的优点是:定位任意单元时不需要遍历所有前置单元——通过Offsets数组可以直接跳转到Connectivity的对应位置。这支持了O(1)时间复杂度的随机访问。
与旧格式的对比: 在VTK的旧存储格式(已废弃但仍被一些老旧代码使用)中,单元长度被直接嵌入Connectivity数组:
Connectivity(旧格式): {3, 0, 1, 2, 4, 3, 4, 6, 7, 2, 5, 8}
|--Cell 0--||----Cell 1---||--C2-|
旧格式中每个单元的第一个数字是点数。这种格式的随机访问需要遍历并累计,效率较低。VTK 9.x中已默认使用现代Offsets+Connectivity格式(由VTK_CELL_ARRAY_V2宏标记)。
7.1.4 点索引如何映射到坐标——图解
下面用ASCII图展示一个简单PolyData的完整结构:
vtkPoints (几何层)
====================
索引 坐标
[0] (0.0, 0.0, 0.0) ← 原点
[1] (1.0, 0.0, 0.0) ← X轴方向1个单位
[2] (0.0, 1.0, 0.0) ← Y轴方向1个单位
[3] (1.0, 1.0, 0.0) ← XY对角点
[4] (0.5, 0.5, 1.0) ← 中心上方(金字塔尖)
vtkCellArray: Polys (拓扑层)
==============================
Offsets: {0, 3, 6, 10}
Connectivity: {0, 1, 4, 2, 0, 4, 1, 3, 4, 2}
|--△t0--| |--△t1--| |--□q0--|
△t0: 三角形(0,1,4) → 顶点1→2→尖
△t1: 三角形(2,0,4) → 顶点0→1→尖
□q0: 四边形(1,3,4,2) → 底面四边形
三维空间中的视觉效果:
(0.5, 0.5, 1.0)
[4]
/|\
/ | \
/ | \
/ t0|t1 \
/ | \
[0] /_____|_____\ [3]
(0,0,0) *------+------* (1,1,0)
| / \ |
| / \ |
| / q0 \ |
| / \ |
*------------*
[1] [2]
(1,0,0) (0,1,0)
关键理解: 每个单元并不存储坐标,只存储整数索引。渲染时,Mapper通过索引查阅vtkPoints获取实际的(x, y, z)坐标。当点坐标发生变化时(如points->SetPoint(4, 0.6, 0.6, 1.2)),所有引用该点的单元(t0, t1, q0)的形状会自动随之变化——因为索引不变,但坐标变了。
7.1.5 PolyData的内部成员概览
从vtkPolyData头文件(Common/DataModel/vtkPolyData.h)中可以看到,vtkPolyData内部持有以下核心成员:
// 从vtkPointSet继承:vtkPoints* Points —— 点坐标
// 从vtkDataSet继承:vtkPointData —— 点属性数据(标量、矢量、法向等)
// vtkCellData —— 单元属性数据
// vtkPolyData特有的四个CellArray:
vtkSmartPointer<vtkCellArray> Verts; // 0D 点元
vtkSmartPointer<vtkCellArray> Lines; // 1D 线元
vtkSmartPointer<vtkCellArray> Polys; // 2D 面元(三角形、四边形、多边形)
vtkSmartPointer<vtkCellArray> Strips; // 2D 三角形带
// 辅助结构(按需构建):
vtkSmartPointer<CellMap> Cells; // 全局单元索引映射
vtkSmartPointer<vtkAbstractCellLinks> Links; // 点到单元的向上链接
Cells(CellMap)和Links并非总是存在。它们在使用BuildCells()或BuildLinks()时才被构建,用于加速单元随机访问和拓扑查询。直接操作Verts/Lines/Polys/Strips CellArray时,这些缓存结构会失效,需要重新构建。
7.2 点(Points)与顶点(Verts)
7.2.1 vtkPoints——几何的容器
vtkPoints是VTK中管理三维点坐标的核心类。它与vtkCellArray一样独立于vtkPolyData存在——你可以在没有PolyData对象的情况下创建和操作Points。
核心API:
#include "vtkPoints.h"
// 创建Points对象
vtkNew<vtkPoints> points;
// 方法1:InsertNextPoint —— 在末尾追加一个新点,返回其索引
vtkIdType id0 = points->InsertNextPoint(0.0, 0.0, 0.0); // 返回 0
vtkIdType id1 = points->InsertNextPoint(1.0, 0.0, 0.0); // 返回 1
vtkIdType id2 = points->InsertNextPoint(0.5, 0.866, 0.0); // 返回 2
// 方法2:SetPoint —— 设置或修改指定索引处的点坐标(索引必须已存在)
points->SetPoint(1, 2.0, 0.0, 0.0); // 修改 pt[1] 的坐标
// 方法3:GetPoint —— 获取指定索引处的点坐标
double pt[3];
points->GetPoint(2, pt); // pt现在 = {0.5, 0.866, 0.0}
// 方法4:GetNumberOfPoints —— 获取点的总数
vtkIdType numPts = points->GetNumberOfPoints();
std::cout << "Total points: " << numPts << std::endl;
// 方法5:访问底层数据(高级用法)
// 获取内部数组的只读指针
vtkDataArray* dataArray = points->GetData();
// 或者获取指定类型的指针(注意:实际类型取决于VTK内部选择)
double* rawPtr = static_cast<vtkDoubleArray*>(points->GetData())->GetPointer(0);
// 遍历所有点:第i个点的x坐标 = rawPtr[i*3], y = rawPtr[i*3+1], z = rawPtr[i*3+2]
// 方法6:SetNumberOfPoints —— 预分配空间
points->SetNumberOfPoints(10000); // 预分配后可以用SetPoint逐个设置
性能提示: 当你知道将要插入多少个点时,使用SetNumberOfPoints()或Allocate()预分配空间可以避免动态扩容带来的多次内存重新分配和数据复制。对于10万+点的大规模点云,这一优化可以将插入速度提升数倍。
特殊技巧——从外部数组传递数据:
// 场景:你已有一个外部生成的点坐标数组
std::vector<double> coords = {0,0,0, 1,0,0, 0.5,0.866,0, ...};
vtkNew<vtkPoints> points;
// 直接写入底层数据数组
vtkNew<vtkDoubleArray> dataArray;
dataArray->SetNumberOfComponents(3);
dataArray->SetArray(coords.data(), coords.size(), 1); // 1 = 保存引用,不复制
points->SetData(dataArray);
7.2.2 顶点(Verts)——零维图元
Verts(点元)是最简单的图元类型——每个单元由单个点组成。它们用于点云可视化、粒子效果、或散点图。
创建单个顶点(VTK_VERTEX):
#include "vtkCellArray.h"
#include "vtkPolyData.h"
vtkNew<vtkPoints> points;
points->InsertNextPoint(0.0, 0.0, 0.0);
points->InsertNextPoint(1.0, 0.0, 0.0);
points->InsertNextPoint(0.5, 1.0, 0.0);
// 创建Verts的CellArray
vtkNew<vtkCellArray> verts;
// 方法A: 两步法 —— 先声明点数,再逐个插入点索引
verts->InsertNextCell(1); // 下一个单元有1个点
verts->InsertCellPoint(0); // 单元0: 点索引0
verts->InsertNextCell(1); // 下一个单元有1个点
verts->InsertCellPoint(1); // 单元1: 点索引1
verts->InsertNextCell(1);
verts->InsertCellPoint(2); // 单元2: 点索引2
// 方法B: 一次性传递整组索引(推荐)
vtkNew<vtkCellArray> vertsB;
vtkIdType pts0[] = {0};
vtkIdType pts1[] = {1};
vtkIdType pts2[] = {2};
vertsB->InsertNextCell(1, pts0);
vertsB->InsertNextCell(1, pts1);
vertsB->InsertNextCell(1, pts2);
// 方法C: 使用C++11 initializer_list(最简洁)
vtkNew<vtkCellArray> vertsC;
vertsC->InsertNextCell({0});
vertsC->InsertNextCell({1});
vertsC->InsertNextCell({2});
// 组装PolyData
vtkNew<vtkPolyData> pointCloud;
pointCloud->SetPoints(points);
pointCloud->SetVerts(vertsC);
创建多顶点(VTK_POLY_VERTEX):
VTK_POLY_VERTEX是一个包含多个点的"集合顶点"——它与多个独立的VTK_VERTEX在内存布局和渲染行为上不同。通常VTK_VERTEX更常用,但在某些特定场景(如需要将一组点作为一个整体单元处理)中,VTK_POLY_VERTEX更合适。
7.2.3 示例:点云可视化
下面是一个完整的点云可视化程序。我们生成100个随机分布的点,并为每个点赋予随机的颜色。
// point_cloud_example.cxx
// 生成随机点云并可视化——演示 vtkPolyData 的 Verts 用法
#include "vtkActor.h"
#include "vtkCellArray.h"
#include "vtkInteractorStyleTrackballCamera.h"
#include "vtkNamedColors.h"
#include "vtkNew.h"
#include "vtkPointData.h"
#include "vtkPoints.h"
#include "vtkPolyData.h"
#include "vtkPolyDataMapper.h"
#include "vtkProperty.h"
#include "vtkRenderWindow.h"
#include "vtkRenderWindowInteractor.h"
#include "vtkRenderer.h"
#include "vtkUnsignedCharArray.h"
#include <cstdlib>
#include <ctime>
int main()
{
// 初始化随机种子
std::srand(static_cast<unsigned>(std::time(nullptr)));
const vtkIdType numParticles = 100;
// ============================================================
// 第一步:创建几何(点坐标)
// ============================================================
vtkNew<vtkPoints> points;
points->SetNumberOfPoints(numParticles);
for (vtkIdType i = 0; i < numParticles; ++i)
{
// 在 [-5, 5]^3 的立方体内随机生成点
double x = (std::rand() / (double)RAND_MAX) * 10.0 - 5.0;
double y = (std::rand() / (double)RAND_MAX) * 10.0 - 5.0;
double z = (std::rand() / (double)RAND_MAX) * 10.0 - 5.0;
points->SetPoint(i, x, y, z);
}
// ============================================================
// 第二步:创建拓扑(顶点单元)
// ============================================================
vtkNew<vtkCellArray> verts;
for (vtkIdType i = 0; i < numParticles; ++i)
{
// 为每个点创建一个独立的 VTK_VERTEX 单元
verts->InsertNextCell(1);
verts->InsertCellPoint(i);
}
// ============================================================
// 第三步:组装 vtkPolyData
// ============================================================
vtkNew<vtkPolyData> polyData;
polyData->SetPoints(points);
polyData->SetVerts(verts);
// ============================================================
// 第四步:创建属性数据(粒子颜色——基于位置)
// ============================================================
vtkNew<vtkUnsignedCharArray> colors;
colors->SetName("Colors");
colors->SetNumberOfComponents(3); // RGB
colors->SetNumberOfTuples(numParticles);
for (vtkIdType i = 0; i < numParticles; ++i)
{
double pt[3];
points->GetPoint(i, pt);
// 将坐标映射到颜色分量([-5,5] → [0,255])
unsigned char r = static_cast<unsigned char>((pt[0] + 5.0) / 10.0 * 255);
unsigned char g = static_cast<unsigned char>((pt[1] + 5.0) / 10.0 * 255);
unsigned char b = static_cast<unsigned char>((pt[2] + 5.0) / 10.0 * 255);
colors->InsertNextTuple3(r, g, b);
}
polyData->GetPointData()->SetScalars(colors);
// ============================================================
// 第五步:渲染管道
// ============================================================
vtkNew<vtkPolyDataMapper> mapper;
mapper->SetInputData(polyData);
mapper->ScalarVisibilityOn(); // 开启标量颜色映射
mapper->SetScalarModeToUsePointData(); // 使用点数据中的标量
vtkNew<vtkActor> actor;
actor->SetMapper(mapper);
actor->GetProperty()->SetPointSize(5); // 设置点的像素大小
vtkNew<vtkRenderer> renderer;
renderer->AddActor(actor);
renderer->SetBackground(0.1, 0.1, 0.15);
vtkNew<vtkRenderWindow> renderWindow;
renderWindow->AddRenderer(renderer);
renderWindow->SetSize(800, 600);
renderWindow->SetWindowName("Point Cloud Example");
vtkNew<vtkRenderWindowInteractor> interactor;
interactor->SetRenderWindow(renderWindow);
vtkNew<vtkInteractorStyleTrackballCamera> style;
style->SetDefaultRenderer(renderer);
interactor->SetInteractorStyle(style);
renderWindow->Render();
std::cout << "Generated " << numParticles
<< " random particles. Close window to exit." << std::endl;
interactor->Start();
return 0;
}
代码要点:
-
SetNumberOfPoints()预分配:先调用SetNumberOfPoints(100),然后用SetPoint(i, x, y, z)逐个设置。这比循环调用InsertNextPoint()的效率更高(因为避免了多次内存重新分配)。 -
两步法创建Verts:
InsertNextCell(1)告诉CellArray"接下来这个单元有1个点",然后InsertCellPoint(i)添加该点的索引。这种模式也适用于Lines和Polys。 -
vtkUnsignedCharArray用于颜色:VTK通常使用0-255的unsigned char存储RGB颜色。SetNumberOfComponents(3)表示每个元组有3个分量(R, G, B)。InsertNextTuple3(r, g, b)一次性追加一个RGB元组。 -
SetPointSize(5):通过Actor的Property设置顶点在屏幕上的像素大小。注意这个大小是屏幕空间的大小,不随相机距离变化(除非使用vtkPointGaussianMapper等更高级的映射器)。
7.3 线(Lines)与折线(Polylines)
7.3.1 单线段(VTK_LINE)
一条线段由两个端点定义,是最简单的1D图元。
// 创建一个包含两条线段的PolyData
vtkNew<vtkPoints> points;
points->InsertNextPoint(0.0, 0.0, 0.0); // 索引 0
points->InsertNextPoint(1.0, 1.0, 0.0); // 索引 1
points->InsertNextPoint(2.0, 0.0, 0.0); // 索引 2
points->InsertNextPoint(3.0, 1.0, 0.0); // 索引 3
vtkNew<vtkCellArray> lines;
// 线段0: 从(0,0,0)到(1,1,0)
lines->InsertNextCell({0, 1});
// 线段1: 从(2,0,0)到(3,1,0)
lines->InsertNextCell({2, 3});
vtkNew<vtkPolyData> polyData;
polyData->SetPoints(points);
polyData->SetLines(lines);
7.3.2 折线(VTK_POLY_LINE)
折线(Polyline)是由三个或更多点定义的连续线段序列。折线中的每对相邻点之间形成一条线段。
// 一条折线穿过多个点:{0, 1, 2, 3, 4, 5}
vtkIdType polylinePts[] = {0, 1, 2, 3, 4, 5};
lines->InsertNextCell(6, polylinePts); // 6个点,形成5段连续线段
折线的渲染顺序是:p0→p1, p1→p2, p2→p3, p3→p4, p4→p5。
折线与多条独立线段的区别:
独立线段 (6条):
线段0: p0-p1
线段1: p2-p3
线段2: p4-p5
...
一条折线:
折线0: p0-p1-p2-p3-p4-p5(5段连接线)
关键差异:
- 折线作为一个整体单元存在,在单元数据(Cell Data)中只有一条记录
- 多条独立线段各自是独立的单元,每条线段在Cell Data中有各自的记录
- 在渲染上两者看起来相同(假设折线没有闭合),但数据语义不同
7.3.3 线段宽度控制
通过vtkProperty可以设置线段在屏幕上的像素宽度:
actor->GetProperty()->SetLineWidth(3.0); // 设置为3像素宽
actor->GetProperty()->SetLineStipplePattern(0x00FF); // 设置虚线模式(可选)
actor->GetProperty()->SetLineStippleRepeatFactor(1);
注意:SetLineWidth()设置的像素宽度是屏幕空间的大小。无论相机拉近还是拉远,线条在屏幕上的像素宽度保持不变。如果需要世界空间中的线条厚度,应使用vtkTubeFilter(将线段转换为管道几何体)。
7.3.4 示例:绘制3D路径(螺旋线)
以下程序创建一个三维螺旋路径,展示折线的用法以及线宽的设置。
// spiral_path_example.cxx
// 生成3D螺旋线,演示折线(Polyline)和线宽控制
#include "vtkActor.h"
#include "vtkCellArray.h"
#include "vtkInteractorStyleTrackballCamera.h"
#include "vtkMath.h"
#include "vtkNamedColors.h"
#include "vtkNew.h"
#include "vtkPoints.h"
#include "vtkPolyData.h"
#include "vtkPolyDataMapper.h"
#include "vtkProperty.h"
#include "vtkRenderWindow.h"
#include "vtkRenderWindowInteractor.h"
#include "vtkRenderer.h"
#include <cmath>
int main()
{
const double pi = vtkMath::Pi();
const int numTurns = 5; // 螺旋圈数
const int pointsPerTurn = 100; // 每圈点数
const int totalPoints = numTurns * pointsPerTurn + 1;
const double radius = 2.0; // 螺旋半径
const double height = 10.0; // 总高度
// ============================================================
// 第一步:创建螺旋线的点
// ============================================================
vtkNew<vtkPoints> points;
points->SetNumberOfPoints(totalPoints);
for (int i = 0; i < totalPoints; ++i)
{
double t = static_cast<double>(i) / pointsPerTurn; // 角度(弧度)
double angle = t * 2.0 * pi;
double x = radius * std::cos(angle);
double y = radius * std::sin(angle);
double z = t * height / numTurns;
points->SetPoint(i, x, y, z);
}
// ============================================================
// 第二步:创建一条折线连接所有点
// ============================================================
vtkNew<vtkCellArray> lines;
// 构建索引数组 {0, 1, 2, ..., totalPoints-1}
vtkNew<vtkIdList> polylinePts;
polylinePts->SetNumberOfIds(totalPoints);
for (int i = 0; i < totalPoints; ++i)
{
polylinePts->SetId(i, i);
}
lines->InsertNextCell(polylinePts);
// ============================================================
// 第三步:组装 PolyData
// ============================================================
vtkNew<vtkPolyData> polyData;
polyData->SetPoints(points);
polyData->SetLines(lines);
// ============================================================
// 第四步:渲染管道
// ============================================================
vtkNew<vtkPolyDataMapper> mapper;
mapper->SetInputData(polyData);
vtkNew<vtkActor> actor;
actor->SetMapper(mapper);
actor->GetProperty()->SetColor(1.0, 0.6, 0.0); // 橙色
actor->GetProperty()->SetLineWidth(3.0); // 线宽3像素
vtkNew<vtkRenderer> renderer;
renderer->AddActor(actor);
renderer->SetBackground(0.1, 0.1, 0.15);
vtkNew<vtkRenderWindow> renderWindow;
renderWindow->AddRenderer(renderer);
renderWindow->SetSize(800, 600);
renderWindow->SetWindowName("3D Spiral Path (Polyline)");
vtkNew<vtkRenderWindowInteractor> interactor;
interactor->SetRenderWindow(renderWindow);
vtkNew<vtkInteractorStyleTrackballCamera> style;
style->SetDefaultRenderer(renderer);
interactor->SetInteractorStyle(style);
renderWindow->Render();
std::cout << "Created a 3D spiral with " << totalPoints
<< " points over " << numTurns << " turns."
<< "\nClose window to exit." << std::endl;
interactor->Start();
return 0;
}
关键知识点:
vtkMath::Pi():VTK提供了常用的数学常量,vtkMath::Pi()返回双精度的圆周率值。vtkIdList:这是一个可变长度的整数列表,当你的索引数组长度在运行时确定时非常方便。- 线宽不变特性:无论你如何缩放/旋转场景,线条在屏幕上始终是3个像素宽。这不是bug,而是OpenGL线宽渲染的标准行为。
7.4 多边形(Polygons)与三角带(Triangle Strips)
7.4.1 三角形(VTK_TRIANGLE)
三角形是3D图形学中最基本的渲染单元——所有图形管线都以三角形作为渲染基础。
// 创建两个三角形:{0,1,2} 和 {0,2,3}
vtkNew<vtkCellArray> triangles;
// 方法A: 两步法
triangles->InsertNextCell(3);
triangles->InsertCellPoint(0);
triangles->InsertCellPoint(1);
triangles->InsertCellPoint(2);
// 方法B: 一次性传递索引数组(推荐)
vtkIdType triPts[] = {0, 2, 3};
triangles->InsertNextCell(3, triPts);
// 方法C: initializer_list(最简洁)
triangles->InsertNextCell({0, 1, 2});
// 组装
vtkNew<vtkPolyData> polyData;
polyData->SetPoints(points);
polyData->SetPolys(triangles);
7.4.2 四边形(VTK_QUAD)
四边形由四个点定义。VTK要求四边形的四个点共面,否则渲染可能出现伪影(Artifact)。
// 四边形: {0, 1, 2, 3}
vtkNew<vtkCellArray> quads;
quads->InsertNextCell({0, 1, 2, 3});
polyData->SetPolys(quads);
7.4.3 通用多边形(VTK_POLYGON)
VTK_POLYGON支持任意数量(>=3)的点定义一个凸多边形。多边形中的所有点必须共面。
// 五边形: {0, 1, 2, 3, 4}
vtkIdType pentagonPts[] = {0, 1, 2, 3, 4};
polygons->InsertNextCell(5, pentagonPts);
7.4.4 三角带(VTK_TRIANGLE_STRIP)
三角带(Triangle Strip)是一种高效存储连续三角形网格的方式。在三角带中,每个新增的点与前两个点形成一个三角形。
三角带的构建规则:
- 前三个点{p0, p1, p2}定义第一个三角形
- 此后每个新点p_i与p_{i-2}和p_{i-1}形成一个新的三角形
- 为保证一致的面朝向,VTK自动交换每对三角形的顶点顺序
Strips: {0, 1, 2, 3, 4, 5}
产生的三角形:
三角形0: {0, 1, 2} ← (0→1→2)
三角形1: {2, 1, 3} ← (1→2→3) 【注意顺序交换】
三角形2: {2, 3, 4} ← (2→3→4)
三角形3: {4, 3, 5} ← (3→4→5) 【注意顺序交换】
空间布局示意:
p0 ---- p2 ---- p4
| \ / \ / |
| \/ \/ |
| /\ /\ |
| / \ / \ |
p1 ---- p3 ---- p5
三角带在存储规则网格地形时特别高效:一个m x n的网格只需要m*n个点和一个包含2*m*n个索引的Strip单元(而不是2*m*n*3个索引的独立三角形单元)。这可以节省约2/3的索引存储空间。
创建三角带的代码:
vtkNew<vtkCellArray> strips;
// 方法A: 两步法
strips->InsertNextCell(6); // 6个点定义4个三角形
strips->InsertCellPoint(0);
strips->InsertCellPoint(1);
strips->InsertCellPoint(2);
strips->InsertCellPoint(3);
strips->InsertCellPoint(4);
strips->InsertCellPoint(5);
// 方法B: initializer_list
strips->InsertNextCell({0, 1, 2, 3, 4, 5});
polyData->SetStrips(strips);
7.4.5 顶点顺序与面朝向(右手定则)
这是VTK渲染中的一个关键概念:顶点的排列顺序决定了面的朝向。
VTK的vtkPolyDataMapper默认渲染正面(Front Face),正面由右手定则定义:将右手四指沿顶点排列方向弯曲,拇指指向的方向即为面的法向(向外)。
正面(Front Face)—— 逆时针(Counter-Clockwise, CCW):
p0
/\
/ \
/ \
p1 *----* p2
顶点顺序: {p0, p1, p2}
法向指向: 屏幕外(朝向观察者)
反面(Back Face)—— 顺时针(Clockwise, CW):
p0
/\
/ \
/ \
p2 *----* p1
顶点顺序: {p0, p2, p1}
法向指向: 屏幕内(远离观察者)
如果你发现渲染出的面是黑色的(或根本看不见),很可能是顶点的排列顺序反了。有两种解决方法:
- 翻转顶点顺序:将
{0, 1, 2}改为{0, 2, 1}。 - 禁用背面剔除:
actor->GetProperty()->BackfaceCullingOff()——但这会使背面也渲染,并可能在光照计算中产生不自然的效果,通常不推荐。
7.4.6 示例:手动构建立方体
以下程序手动构建一个单位立方体,使用四边形(VTK_QUAD)定义6个面。
// manual_cube_example.cxx
// 手动构建一个立方体——演示四边形面元的顶点顺序和右手定则
#include "vtkActor.h"
#include "vtkCellArray.h"
#include "vtkInteractorStyleTrackballCamera.h"
#include "vtkNamedColors.h"
#include "vtkNew.h"
#include "vtkPoints.h"
#include "vtkPolyData.h"
#include "vtkPolyDataMapper.h"
#include "vtkProperty.h"
#include "vtkRenderWindow.h"
#include "vtkRenderWindowInteractor.h"
#include "vtkRenderer.h"
int main()
{
// ============================================================
// 第一步:定义立方体的8个顶点(单位立方体,中心在原点)
// ============================================================
// p7*---------*p6 Y
// /| /| |
// p4*---------*p5| |
// | p3*-----|---*p2 +----X
// |/ |/ /
// p0*---------*p1 Z
//
// 坐标: p0(-0.5,-0.5,-0.5), p1(0.5,-0.5,-0.5), p2(0.5,-0.5,0.5), p3(-0.5,-0.5,0.5)
// p4(-0.5, 0.5,-0.5), p5(0.5, 0.5,-0.5), p6(0.5, 0.5, 0.5), p7(-0.5, 0.5, 0.5)
vtkNew<vtkPoints> cubePoints;
cubePoints->InsertNextPoint(-0.5, -0.5, -0.5); // p0
cubePoints->InsertNextPoint( 0.5, -0.5, -0.5); // p1
cubePoints->InsertNextPoint( 0.5, -0.5, 0.5); // p2
cubePoints->InsertNextPoint(-0.5, -0.5, 0.5); // p3
cubePoints->InsertNextPoint(-0.5, 0.5, -0.5); // p4
cubePoints->InsertNextPoint( 0.5, 0.5, -0.5); // p5
cubePoints->InsertNextPoint( 0.5, 0.5, 0.5); // p6
cubePoints->InsertNextPoint(-0.5, 0.5, 0.5); // p7
// ============================================================
// 第二步:定义6个四边形面(注意顶点顺序遵循右手定则)
// ============================================================
vtkNew<vtkCellArray> cubeFaces;
// 底面 (Y = -0.5): 法向指向 -Y
// 从外部看是逆时针: {0, 1, 2, 3}
cubeFaces->InsertNextCell({0, 1, 2, 3});
// 顶面 (Y = 0.5): 法向指向 +Y
// 从外部看是逆时针: {4, 7, 6, 5}
cubeFaces->InsertNextCell({4, 7, 6, 5});
// 前面 (Z = -0.5): 法向指向 -Z
cubeFaces->InsertNextCell({0, 4, 5, 1});
// 后面 (Z = 0.5): 法向指向 +Z
cubeFaces->InsertNextCell({3, 2, 6, 7});
// 左面 (X = -0.5): 法向指向 -X
cubeFaces->InsertNextCell({0, 3, 7, 4});
// 右面 (X = 0.5): 法向指向 +X
cubeFaces->InsertNextCell({1, 5, 6, 2});
// ============================================================
// 第三步:组装 PolyData
// ============================================================
vtkNew<vtkPolyData> cube;
cube->SetPoints(cubePoints);
cube->SetPolys(cubeFaces);
std::cout << "Cube: " << cubePoints->GetNumberOfPoints() << " points, "
<< cubeFaces->GetNumberOfCells() << " faces." << std::endl;
// ============================================================
// 第四步:渲染管道
// ============================================================
vtkNew<vtkPolyDataMapper> mapper;
mapper->SetInputData(cube);
vtkNew<vtkActor> actor;
actor->SetMapper(mapper);
actor->GetProperty()->SetColor(0.3, 0.7, 0.9); // 浅蓝色
actor->GetProperty()->SetEdgeVisibility(1); // 显示边线
actor->GetProperty()->SetEdgeColor(0.0, 0.0, 0.0); // 黑色边线
actor->GetProperty()->SetLineWidth(1.5);
vtkNew<vtkRenderer> renderer;
renderer->AddActor(actor);
renderer->SetBackground(0.15, 0.15, 0.18);
vtkNew<vtkRenderWindow> renderWindow;
renderWindow->AddRenderer(renderer);
renderWindow->SetSize(600, 500);
renderWindow->SetWindowName("Manually Built Cube (Quads)");
vtkNew<vtkRenderWindowInteractor> interactor;
interactor->SetRenderWindow(renderWindow);
vtkNew<vtkInteractorStyleTrackballCamera> style;
style->SetDefaultRenderer(renderer);
interactor->SetInteractorStyle(style);
renderWindow->Render();
std::cout << "Cube rendered. Rotate to see all 6 faces. Close window to exit."
<< std::endl;
interactor->Start();
return 0;
}
顶点顺序验证方法: 当程序运行后,旋转立方体观察每个面。如果某个面呈现为黑色(或看不见),说明该面的顶点顺序是反的(反面朝向观察者)。此时该面的顶点顺序需要反转——将{a, b, c, d}改为{a, d, c, b}。
7.5 法向量(Normals)
7.5.1 为什么法向量重要
法向量(Normal Vector)是垂直于曲面的单位向量。它在基于光照的渲染中扮演核心角色:
- 漫反射(Diffuse):表面亮度 = 光源方向 · 法向(点积)。法向朝向光源时最亮,背离光源时最暗。
- 镜面反射(Specular):高光的位置取决于法向和视线方向的半角向量。
- 背面剔除(Backface Culling):法向用于判断多边形是"正面"还是"背面"。背面的多边形在默认情况下不渲染。
没有法向量的表面将被渲染为纯色——光照对它不产生任何效果。 这就是为什么有些时候你的模型看起来"扁平"的原因。
7.5.2 光滑着色 vs 平面着色
法向量有两种关联方式,对应两种不同的着色效果:
| 着色模式 | 法向存储位置 | 视觉效果 | 表示 |
|---|---|---|---|
| 平面着色(Flat Shading) | Cell Data(单元数据) | 每个面片有均匀的亮度,面片之间有明显的光泽不连续 | 低多边形风格 |
| 光滑着色(Smooth Shading) | Point Data(点数据) | 表面亮度平滑过渡,视觉上模拟曲面 | 真实感渲染 |
平面着色(Flat Shading)示意图:
每个三角形有自己唯一的法向,同一三角形内亮度一致
↗n0 ↗n1
/ \ / \
/ \ / \ 面片之间有明显的亮度分界
/ \/ \
*------*------*
光滑着色(Smooth Shading)示意图:
每个顶点有自己的法向(通常是相邻面的法向平均值)
↗n0 ↗n1
/ \ / \
/ \ / \ 三角形内部亮度通过插值平滑过渡
/ \/ \
*------*------*
7.5.3 手动设置法向量
#include "vtkDoubleArray.h"
#include "vtkPointData.h"
// 为每个点创建法向量
vtkNew<vtkDoubleArray> normals;
normals->SetName("Normals");
normals->SetNumberOfComponents(3);
normals->SetNumberOfTuples(numPoints);
// 设置每个点的法向(以球体为例——法向从球心指向表面)
for (vtkIdType i = 0; i < numPoints; ++i)
{
double pt[3];
points->GetPoint(i, pt);
double len = std::sqrt(pt[0]*pt[0] + pt[1]*pt[1] + pt[2]*pt[2]);
// 单位化:对于以原点为中心的球来说,法向就是归一化的坐标
normals->SetTuple3(i, pt[0]/len, pt[1]/len, pt[2]/len);
}
// 将法向量关联到Point Data
polyData->GetPointData()->SetNormals(normals);
7.5.4 vtkPolyDataNormals——自动计算法向量
在大多数实际场景中,你不需要手动设置法向量。VTK提供了vtkPolyDataNormals过滤器来自动计算法向量。
#include "vtkPolyDataNormals.h"
vtkNew<vtkPolyDataNormals> normalsFilter;
normalsFilter->SetInputConnection(someSource->GetOutputPort());
// 或者
normalsFilter->SetInputData(somePolyData);
// 【关键参数】SetFeatureAngle —— 控制平滑/平面着色的分界线
normalsFilter->SetFeatureAngle(30.0); // 特征角(度),默认30°
normalsFilter->Update();
vtkPolyData* smoothOutput = normalsFilter->GetOutput();
SetFeatureAngle()的工作原理:
- 该角度定义了"尖锐边缘"(Sharp Edge)的判定阈值。
- 对于网格中的每条边,如果共享该边的两个面的法向夹角大于
FeatureAngle,则该边被视为"特征边"(Feature Edge)。 - 在特征边两侧,顶点法向量不共享——每个面上的顶点有自己的法向拷贝,实现平面着色效果。
- 在非特征边(夹角小于阈值)上,顶点法向被平滑(平均),实现光滑着色效果。
特征角 = 30° 示例:
╲
面A ╲ 边
╲_______
\
面B \
\
面A法向与面B法向夹角 = 35° > 30°
→ 这条边是特征边
→ 边上顶点的法向量分离(产生棱角效果)
如果夹角 = 20° < 30°
→ 这条边不是特征边
→ 边上顶点的法向量被平滑平均
常用FeatureAngle参考值:
| 值 | 效果 | 适用场景 |
|---|---|---|
| 0° | 完全平面着色(所有边都是特征边) | 展示网格结构、低多边形风格 |
| 30° | 默认值,尖锐棱角可见,平缓曲面平滑 | 一般用途 |
| 60° | 只有较尖锐的棱角保留 | 有机形状、地形 |
| 180° | 完全光滑着色(所有法向都被平均) | 球体、流线型曲面 |
7.5.5 示例:法向量对渲染效果的对比
以下程序同时展示有法向量和无法向量的渲染效果,让你直观理解法向量在光照计算中的作用。
// normals_comparison.cxx
// 对比有法向量 vs 无法向量的渲染效果
#include "vtkActor.h"
#include "vtkInteractorStyleTrackballCamera.h"
#include "vtkNamedColors.h"
#include "vtkNew.h"
#include "vtkPointData.h"
#include "vtkPolyData.h"
#include "vtkPolyDataMapper.h"
#include "vtkPolyDataNormals.h"
#include "vtkProperty.h"
#include "vtkRenderWindow.h"
#include "vtkRenderWindowInteractor.h"
#include "vtkRenderer.h"
#include "vtkSphereSource.h"
#include "vtkTextActor.h"
#include "vtkTextProperty.h"
int main()
{
// ============================================================
// 第一步:创建球体数据源
// ============================================================
vtkNew<vtkSphereSource> sphere;
sphere->SetThetaResolution(16);
sphere->SetPhiResolution(16);
sphere->Update();
vtkPolyData* sphereData = sphere->GetOutput();
// ============================================================
// 第二步:创建"无法向"版本(移除PointData中的Normals)
// ============================================================
vtkNew<vtkPolyData> noNormals;
noNormals->DeepCopy(sphereData); // 深拷贝整个数据集
// 移除法向量数据(VTK的Cone/Sphere源默认会生成Normals)
noNormals->GetPointData()->RemoveArray("Normals");
// ============================================================
// 第三步:创建"光滑着色"版本
// ============================================================
vtkNew<vtkPolyDataNormals> smoothNormals;
smoothNormals->SetInputData(sphereData);
smoothNormals->SetFeatureAngle(180.0); // 全部平滑
smoothNormals->Update();
// ============================================================
// 第四步:创建"平面着色"版本
// ============================================================
vtkNew<vtkPolyDataNormals> flatNormals;
flatNormals->SetInputData(sphereData);
flatNormals->SetFeatureAngle(0.0); // 全部平面
flatNormals->Update();
// ============================================================
// 第五步:渲染——三个视口的对比布局
// ============================================================
// 左视口 (0.0 - 0.33): 无法向量
vtkNew<vtkPolyDataMapper> mapperNoNL;
mapperNoNL->SetInputData(noNormals);
vtkNew<vtkActor> actorNoNL;
actorNoNL->SetMapper(mapperNoNL);
actorNoNL->GetProperty()->SetColor(0.2, 0.6, 1.0); // 蓝色
vtkNew<vtkRenderer> renNoNL;
renNoNL->SetViewport(0.0, 0.0, 0.33, 1.0);
renNoNL->AddActor(actorNoNL);
renNoNL->SetBackground(0.1, 0.1, 0.15);
// 中视口 (0.33 - 0.66): 平面着色
vtkNew<vtkPolyDataMapper> mapperFlat;
mapperFlat->SetInputConnection(flatNormals->GetOutputPort());
vtkNew<vtkActor> actorFlat;
actorFlat->SetMapper(mapperFlat);
actorFlat->GetProperty()->SetColor(0.2, 0.6, 1.0);
vtkNew<vtkRenderer> renFlat;
renFlat->SetViewport(0.33, 0.0, 0.66, 1.0);
renFlat->AddActor(actorFlat);
renFlat->SetBackground(0.1, 0.1, 0.15);
// 右视口 (0.66 - 1.0): 光滑着色
vtkNew<vtkPolyDataMapper> mapperSmooth;
mapperSmooth->SetInputConnection(smoothNormals->GetOutputPort());
vtkNew<vtkActor> actorSmooth;
actorSmooth->SetMapper(mapperSmooth);
actorSmooth->GetProperty()->SetColor(0.2, 0.6, 1.0);
vtkNew<vtkRenderer> renSmooth;
renSmooth->SetViewport(0.66, 0.0, 1.0, 1.0);
renSmooth->AddActor(actorSmooth);
renSmooth->SetBackground(0.1, 0.1, 0.15);
// 共享相机(三个渲染器使用相同视角)
renSmooth->SetActiveCamera(renNoNL->GetActiveCamera());
// ============================================================
// 第六步:添加标签
// ============================================================
auto createLabel = [](const char* text, double x, double y) {
vtkNew<vtkTextActor> label;
label->SetInput(text);
label->GetTextProperty()->SetColor(1.0, 1.0, 1.0);
label->GetTextProperty()->SetFontSize(14);
label->GetTextProperty()->BoldOn();
label->SetPosition(x, y);
return label;
};
renNoNL->AddActor2D(createLabel("No Normals (Flat Color)", 10, 10));
renFlat->AddActor2D(createLabel("Flat Shading (Angle=0)", 10, 10));
renSmooth->AddActor2D(createLabel("Smooth Shading (Angle=180)", 10, 10));
// ============================================================
// 第七步:渲染窗口
// ============================================================
vtkNew<vtkRenderWindow> renderWindow;
renderWindow->AddRenderer(renNoNL);
renderWindow->AddRenderer(renFlat);
renderWindow->AddRenderer(renSmooth);
renderWindow->SetSize(1200, 400);
renderWindow->SetWindowName("Normals Comparison: None vs Flat vs Smooth");
vtkNew<vtkRenderWindowInteractor> interactor;
interactor->SetRenderWindow(renderWindow);
vtkNew<vtkInteractorStyleTrackballCamera> style;
renderWindow->Render();
std::cout << "Observe the three spheres:\n"
<< " Left: No normals — uniform flat color, no lighting effect\n"
<< " Middle: Flat shading — each triangle has uniform brightness\n"
<< " Right: Smooth shading — brightness interpolates across surface\n"
<< "Close window to exit." << std::endl;
interactor->Start();
return 0;
}
预期效果: - 左球(无法向量):整个球体呈现均匀的蓝色,没有明暗变化——光照计算被跳过。 - 中球(平面着色):可以清晰地看到每个三角形面片,面片之间有棱角分明的亮度变化。由于仅16x16的分辨率,多边形的边缘非常明显。 - 右球(光滑着色):球体表面平滑过渡,看起来像一个真正的球。即使在16x16的低分辨率下,光照过渡也很自然。
7.6 纹理坐标(Texture Coordinates)
7.6.1 纹理坐标的基本概念
纹理坐标(Texture Coordinates,简称TCoords,也常被称为UV坐标)定义了2D纹理图像如何映射到3D几何表面上。纹理坐标通常用(u, v)表示,范围通常在[0, 1]之间:
- (0, 0) 对应纹理图像的左下角
- (1, 1) 对应纹理图像的右上角
纹理图像 (256x256) 3D几何体
+-----------------------+ p3(u=0,v=1) *----* p2(u=1,v=1)
| | |\ |
| [图像内容] | | \ |
| | | \ |
| | | \|
+-----------------------+ p0(u=0,v=0) *----* p1(u=1,v=0)
(0,0) (1,1)
纹理坐标存储在vtkPointData中,以名为"TCoords"的数组存在,每个点关联一个(u, v)坐标。
7.6.2 设置纹理坐标
#include "vtkDoubleArray.h"
#include "vtkPointData.h"
vtkNew<vtkDoubleArray> tcoords;
tcoords->SetName("TCoords");
tcoords->SetNumberOfComponents(2); // 2D纹理坐标 = (u, v)
tcoords->SetNumberOfTuples(numPoints);
// 为一个平面设置纹理坐标
tcoords->SetTuple2(0, 0.0, 0.0); // 左下角
tcoords->SetTuple2(1, 1.0, 0.0); // 右下角
tcoords->SetTuple2(2, 1.0, 1.0); // 右上角
tcoords->SetTuple2(3, 0.0, 1.0); // 左上角
polyData->GetPointData()->SetTCoords(tcoords);
注意:纹理坐标的分量数通常是2(2D纹理),但也可以是1(1D纹理)或3(3D体积纹理)。大多数场景使用2D纹理。
7.6.3 示例:为平面应用棋盘格纹理
以下程序创建一个平面并应用程序化生成的棋盘格纹理。
// checkerboard_texture_example.cxx
// 演示如何将纹理坐标应用于平面,并使用棋盘格纹理
#include "vtkActor.h"
#include "vtkCellArray.h"
#include "vtkImageData.h"
#include "vtkInteractorStyleTrackballCamera.h"
#include "vtkNew.h"
#include "vtkPointData.h"
#include "vtkPoints.h"
#include "vtkPolyData.h"
#include "vtkPolyDataMapper.h"
#include "vtkProperty.h"
#include "vtkRenderWindow.h"
#include "vtkRenderWindowInteractor.h"
#include "vtkRenderer.h"
#include "vtkTexture.h"
#include "vtkUnsignedCharArray.h"
int main()
{
// ============================================================
// 第一步:创建平面几何体
// ============================================================
vtkNew<vtkPoints> planePoints;
planePoints->InsertNextPoint(-3.0, -2.0, 0.0); // p0: 左下
planePoints->InsertNextPoint( 3.0, -2.0, 0.0); // p1: 右下
planePoints->InsertNextPoint( 3.0, 2.0, 0.0); // p2: 右上
planePoints->InsertNextPoint(-3.0, 2.0, 0.0); // p3: 左上
vtkNew<vtkCellArray> planeCell;
planeCell->InsertNextCell({0, 1, 2, 3}); // 一个四边形
vtkNew<vtkPolyData> plane;
plane->SetPoints(planePoints);
plane->SetPolys(planeCell);
// ============================================================
// 第二步:设置纹理坐标(将整个纹理贴在平面上)
// ============================================================
vtkNew<vtkDoubleArray> tcoords;
tcoords->SetName("TCoords");
tcoords->SetNumberOfComponents(2);
tcoords->SetNumberOfTuples(4);
tcoords->SetTuple2(0, 0.0, 0.0); // p0 → 左下角
tcoords->SetTuple2(1, 1.0, 0.0); // p1 → 右下角
tcoords->SetTuple2(2, 1.0, 1.0); // p2 → 右上角
tcoords->SetTuple2(3, 0.0, 1.0); // p3 → 左上角
plane->GetPointData()->SetTCoords(tcoords);
// ============================================================
// 第三步:程序化生成棋盘格纹理图像
// ============================================================
const int texSize = 256;
const int checkerSize = 32; // 每个格子的像素大小
vtkNew<vtkImageData> checkerboard;
checkerboard->SetDimensions(texSize, texSize, 1);
checkerboard->AllocateScalars(VTK_UNSIGNED_CHAR, 3); // RGB
unsigned char* pixels =
static_cast<unsigned char*>(checkerboard->GetScalarPointer());
for (int y = 0; y < texSize; ++y)
{
for (int x = 0; x < texSize; ++x)
{
int idx = (y * texSize + x) * 3;
bool isWhite = ((x / checkerSize) + (y / checkerSize)) % 2 == 0;
unsigned char value = isWhite ? 255 : 50;
pixels[idx + 0] = value; // R
pixels[idx + 1] = value; // G
pixels[idx + 2] = value; // B
}
}
// ============================================================
// 第四步:创建纹理对象
// ============================================================
vtkNew<vtkTexture> texture;
texture->SetInputData(checkerboard);
texture->InterpolateOn(); // 线性插值(让纹理在放大/缩小时平滑)
texture->RepeatOn(); // 当纹理坐标超出[0,1]范围时重复纹理
// ============================================================
// 第五步:渲染管道
// ============================================================
vtkNew<vtkPolyDataMapper> mapper;
mapper->SetInputData(plane);
vtkNew<vtkActor> actor;
actor->SetMapper(mapper);
actor->SetTexture(texture); // 绑定纹理
actor->GetProperty()->SetColor(1.0, 1.0, 1.0); // 白色基底(以便纹理颜色正确呈现)
vtkNew<vtkRenderer> renderer;
renderer->AddActor(actor);
renderer->SetBackground(0.15, 0.15, 0.18);
renderer->GetActiveCamera()->SetPosition(0, -6, 4);
renderer->GetActiveCamera()->SetFocalPoint(0, 0, 0);
renderer->GetActiveCamera()->SetViewUp(0, 0, 1);
vtkNew<vtkRenderWindow> renderWindow;
renderWindow->AddRenderer(renderer);
renderWindow->SetSize(800, 600);
renderWindow->SetWindowName("Checkerboard Texture on a Plane");
vtkNew<vtkRenderWindowInteractor> interactor;
interactor->SetRenderWindow(renderWindow);
vtkNew<vtkInteractorStyleTrackballCamera> style;
style->SetDefaultRenderer(renderer);
interactor->SetInteractorStyle(style);
renderWindow->Render();
std::cout << "Checkerboard texture applied to a plane." << std::endl;
std::cout << "Rotate to see the textured surface. Close window to exit."
<< std::endl;
interactor->Start();
return 0;
}
纹理关键API说明:
texture->InterpolateOn():当纹理在屏幕上被放大或缩小时,使用线性插值在相邻纹素之间过渡。关闭后使用最近邻采样(会产生明显的像素化效果)。texture->RepeatOn():当纹理坐标的值超出[0, 1]范围时,纹理图像会重复平铺,而不是被截断。actor->SetTexture(texture):将纹理绑定到Actor上。注意这不是Mapper的职责——纹理绑定在Actor级别,与Property一起管理外观属性。
7.7 代码示例:构建复杂PolyData(3D地形曲面)
本节将综合运用本章所学全部知识,从零开始构建一个3D地形曲面。我们将:
- 创建一个规则网格的顶点(使用
vtkPoints) - 根据数学函数计算每个顶点的高程(Z坐标)
- 使用三角带(Triangle Strips)高效构建拓扑
- 为每个顶点设置高程标量值(用于颜色映射)
- 使用
vtkPolyDataNormals自动计算法向量 - 使用颜色映射表(LookupTable)根据高程着色
7.7.1 完整源代码
// terrain_surface_example.cxx
// 构建一个3D地形曲面,综合演示 PolyData 的完整用法:
// - Points: 网格顶点
// - Strips: 三角形带拓扑
// - Point Data Scalars: 高程值
// - Point Data Normals: 自动计算的表面法向
// - LookupTable: 按高程着色
#include "vtkActor.h"
#include "vtkCamera.h"
#include "vtkCellArray.h"
#include "vtkDoubleArray.h"
#include "vtkInteractorStyleTrackballCamera.h"
#include "vtkLookupTable.h"
#include "vtkMath.h"
#include "vtkNew.h"
#include "vtkPointData.h"
#include "vtkPoints.h"
#include "vtkPolyData.h"
#include "vtkPolyDataMapper.h"
#include "vtkPolyDataNormals.h"
#include "vtkProperty.h"
#include "vtkRenderWindow.h"
#include "vtkRenderWindowInteractor.h"
#include "vtkRenderer.h"
#include "vtkScalarBarActor.h"
#include "vtkTextProperty.h"
#include <cmath>
#include <iostream>
// 地形高程函数:Gaussian型山丘叠加
// 返回在 (x, y) 处的地表高度
double TerrainHeight(double x, double y)
{
// 主山峰
double peak1 = 4.0 * std::exp(-((x - 1.0) * (x - 1.0) + (y - 1.0) * (y - 1.0)) / 4.0);
// 次山峰
double peak2 = 2.5 * std::exp(-((x + 2.0) * (x + 2.0) + (y + 1.5) * (y + 1.5)) / 3.0);
// 第三山峰
double peak3 = 3.0 * std::exp(-((x + 1.0) * (x + 1.0) + (y - 2.5) * (y - 2.5)) / 3.5);
// 微小起伏(噪声模拟)
double ripple = 0.3 * std::sin(x * 3.0) * std::cos(y * 3.0);
return peak1 + peak2 + peak3 + ripple;
}
int main()
{
// ==============================================================
// 第一步:定义网格参数
// ==============================================================
const int gridRows = 150; // 行数(Y方向)
const int gridCols = 150; // 列数(X方向)
const double xMin = -5.0, xMax = 5.0;
const double yMin = -5.0, yMax = 5.0;
const double dx = (xMax - xMin) / (gridCols - 1);
const double dy = (yMax - yMin) / (gridRows - 1);
const vtkIdType totalPoints = gridRows * gridCols;
std::cout << "Generating terrain: " << gridCols << " x " << gridRows
<< " = " << totalPoints << " points" << std::endl;
// ==============================================================
// 第二步:创建顶点(几何层)
// ==============================================================
vtkNew<vtkPoints> points;
points->SetNumberOfPoints(totalPoints);
vtkNew<vtkDoubleArray> elevationScalars;
elevationScalars->SetName("Elevation");
elevationScalars->SetNumberOfComponents(1);
elevationScalars->SetNumberOfTuples(totalPoints);
for (int j = 0; j < gridRows; ++j)
{
double y = yMin + j * dy;
for (int i = 0; i < gridCols; ++i)
{
double x = xMin + i * dx;
double z = TerrainHeight(x, y);
vtkIdType ptId = j * gridCols + i;
points->SetPoint(ptId, x, y, z);
elevationScalars->SetValue(ptId, z);
}
}
std::cout << " Min elevation: " << elevationScalars->GetRange()[0]
<< ", Max elevation: " << elevationScalars->GetRange()[1] << std::endl;
// ==============================================================
// 第三步:创建三角形带(拓扑层)
// ==============================================================
// 每一行网格是一条Triangle Strip
// 每条Strip包含 gridCols*2 个顶点索引
vtkNew<vtkCellArray> strips;
for (int j = 0; j < gridRows - 1; ++j)
{
strips->InsertNextCell(gridCols * 2);
for (int i = 0; i < gridCols; ++i)
{
vtkIdType lowerPtId = j * gridCols + i; // 当前行
vtkIdType upperPtId = (j + 1) * gridCols + i; // 下一行
strips->InsertCellPoint(lowerPtId);
strips->InsertCellPoint(upperPtId);
}
}
// ==============================================================
// 第四步:组装 vtkPolyData
// ==============================================================
vtkNew<vtkPolyData> terrain;
terrain->SetPoints(points);
terrain->SetStrips(strips);
terrain->GetPointData()->SetScalars(elevationScalars);
std::cout << " Created " << strips->GetNumberOfCells()
<< " triangle strips (" << (gridRows - 1) * (gridCols - 1) * 2
<< " triangles total)" << std::endl;
// ==============================================================
// 第五步:计算法向量(光滑着色)
// ==============================================================
vtkNew<vtkPolyDataNormals> normalsFilter;
normalsFilter->SetInputData(terrain);
normalsFilter->SetFeatureAngle(40.0); // 40度以下的曲面平滑着色
normalsFilter->SplittingOff(); // 不分裂法向量(保持拓扑不变)
normalsFilter->ConsistencyOn(); // 确保法向方向一致(全部朝外)
normalsFilter->Update();
vtkPolyData* terrainWithNormals = normalsFilter->GetOutput();
std::cout << " Normals computed (FeatureAngle="
<< normalsFilter->GetFeatureAngle() << " degrees)" << std::endl;
// ==============================================================
// 第六步:创建颜色映射表
// ==============================================================
double elevRange[2];
elevationScalars->GetRange(elevRange);
vtkNew<vtkLookupTable> lut;
lut->SetNumberOfTableValues(256);
lut->SetRange(elevRange[0], elevRange[1]);
// 使用蓝-绿-黄-红配色方案(模拟地形图)
// Hue: 0.667(蓝) -> 0.333(绿) -> 0.167(黄) -> 0.0(红)
lut->SetHueRange(0.667, 0.0);
lut->SetSaturationRange(0.8, 0.8);
lut->SetValueRange(0.6, 1.0);
lut->Build();
// ==============================================================
// 第七步:渲染管道
// ==============================================================
vtkNew<vtkPolyDataMapper> mapper;
mapper->SetInputConnection(normalsFilter->GetOutputPort());
mapper->SetLookupTable(lut);
mapper->SetScalarRange(elevRange[0], elevRange[1]);
mapper->ScalarVisibilityOn();
vtkNew<vtkActor> actor;
actor->SetMapper(mapper);
actor->GetProperty()->SetSpecular(0.3); // 高光反射系数
actor->GetProperty()->SetSpecularPower(20); // 高光锐度(Phong指数)
actor->GetProperty()->SetAmbient(0.2); // 环境光
actor->GetProperty()->SetDiffuse(0.8); // 漫反射
// 标量条(Scalar Bar)
vtkNew<vtkScalarBarActor> scalarBar;
scalarBar->SetLookupTable(lut);
scalarBar->SetTitle("Elevation (m)");
scalarBar->SetNumberOfLabels(8);
scalarBar->GetTitleTextProperty()->SetColor(0.0, 0.0, 0.0);
scalarBar->GetTitleTextProperty()->SetFontSize(12);
scalarBar->GetLabelTextProperty()->SetColor(0.0, 0.0, 0.0);
scalarBar->GetLabelTextProperty()->SetFontSize(10);
// ==============================================================
// 第八步:渲染器与相机设置
// ==============================================================
vtkNew<vtkRenderer> renderer;
renderer->AddActor(actor);
renderer->AddActor2D(scalarBar);
renderer->SetBackground(0.2, 0.25, 0.35); // 天空蓝背景
renderer->SetBackground2(0.8, 0.85, 0.9); // 渐变(接近水平线的浅色)
renderer->GradientBackgroundOn(); // 启用背景渐变
// 设置相机位置以获得俯瞰视角
vtkCamera* camera = renderer->GetActiveCamera();
camera->SetPosition(0.0, -8.0, 5.0); // 相机位置
camera->SetFocalPoint(0.0, 0.0, 2.0); // 注视中心
camera->SetViewUp(0.0, 0.0, 1.0); // 上方为Z轴
camera->Elevation(45); // 抬高视角(俯视角度)
camera->Azimuth(30); // 水平旋转
// ==============================================================
// 第九步:渲染窗口与交互
// ==============================================================
vtkNew<vtkRenderWindow> renderWindow;
renderWindow->AddRenderer(renderer);
renderWindow->SetSize(1000, 700);
renderWindow->SetMultiSamples(8); // 8x MSAA 抗锯齿
renderWindow->SetWindowName("3D Terrain Surface");
vtkNew<vtkRenderWindowInteractor> interactor;
interactor->SetRenderWindow(renderWindow);
vtkNew<vtkInteractorStyleTrackballCamera> style;
style->SetDefaultRenderer(renderer);
interactor->SetInteractorStyle(style);
// ==============================================================
// 第十步:序列信息输出并启动
// ==============================================================
std::cout << "\n========== Terrain Statistics ==========" << std::endl;
std::cout << "Total Points: " << terrain->GetNumberOfPoints() << std::endl;
std::cout << "Total Cells: " << terrain->GetNumberOfCells() << std::endl;
std::cout << "Total Strips: " << terrain->GetNumberOfStrips() << std::endl;
double bounds[6];
terrainWithNormals->GetBounds(bounds);
std::cout << "Bounds:\n"
<< " X: [" << bounds[0] << ", " << bounds[1] << "]\n"
<< " Y: [" << bounds[2] << ", " << bounds[3] << "]\n"
<< " Z: [" << bounds[4] << ", " << bounds[5] << "]" << std::endl;
// 检查法向量是否已计算
vtkDataArray* computedNormals =
terrainWithNormals->GetPointData()->GetNormals();
if (computedNormals)
{
std::cout << "Normals: Computed ("
<< computedNormals->GetNumberOfTuples() << " tuples)" << std::endl;
}
std::cout << "========================================" << std::endl;
std::cout << "\nClose window to exit." << std::endl;
renderWindow->Render();
interactor->Start();
return 0;
}
7.7.2 CMakeLists.txt
cmake_minimum_required(VERSION 3.8 FATAL_ERROR)
project(TerrainSurface VERSION 1.0.0 LANGUAGES CXX)
# 设置C++标准
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# 查找VTK 9.5.2
find_package(VTK 9.5.2
COMPONENTS
CommonCore
CommonColor
CommonDataModel
CommonExecutionModel
CommonMath
FiltersCore
InteractionStyle
RenderingCore
RenderingOpenGL2
REQUIRED
)
# 打印VTK信息(帮助调试)
message(STATUS "VTK_VERSION: ${VTK_VERSION}")
message(STATUS "VTK_RENDERING_BACKEND: OpenGL2")
# 创建可执行文件
add_executable(TerrainSurface terrain_surface_example.cxx)
# 链接VTK库
target_link_libraries(TerrainSurface
PRIVATE
${VTK_LIBRARIES}
)
# VTK模块工厂自动初始化(必须!)
vtk_module_autoinit(
TARGETS TerrainSurface
MODULES ${VTK_LIBRARIES}
)
7.7.3 代码详解
本节逐部分解释地形曲面示例中的关键技术点。
第一步:网格参数定义
const int gridRows = 150;
const int gridCols = 150;
选择150x150的网格意味着22,500个顶点和44,102个三角形(每个矩形区域由2个三角形组成)。这个分辨率在大多数现代硬件上可以流畅渲染。如果你的机器性能较弱,可以将网格降低到100x100或50x50。
dx和dy是网格间距,通过(最大值 - 最小值) / (点数 - 1)计算,确保边界点恰好落在指定范围上。
第二步:创建顶点与高程标量
使用了一个关键的索引公式:ptId = j * gridCols + i。这定义了点在规则网格中的"行优先"(Row-Major)索引方式:
网格 (gridCols=3, gridRows=2):
j=0: pt[0] pt[1] pt[2] ← 第0行
j=1: pt[3] pt[4] pt[5] ← 第1行
其中 pt[j * gridCols + i] = pt[j * 3 + i]
TerrainHeight(x, y)函数叠加了三个高斯型山峰和一个小幅度的正弦噪声,创建了自然地形的外观。
elevationScalars数组在每个点上存储一个标量值(Z坐标即高程)。这个数组既是属性数据(用于颜色映射),也是数据的有机组成部分。
第三步:使用三角带构建拓扑
这是本节最重要的技术决策。对于一个规则网格,我们使用三角形带(Triangle Strips)而不是独立三角形,以实现最高的存储效率。
两条相邻行形成一条Strip:
第j行: p[j*C+0] p[j*C+1] p[j*C+2] p[j*C+3] ...
第j+1行: p[(j+1)*C+0] p[(j+1)*C+1] p[(j+1)*C+2] p[(j+1)*C+3] ...
一条Strip的索引序列:
{p0, p0', p1, p1', p2, p2', p3, p3', ...}
产生的三角形:
三角1: {p0, p0', p1}
三角2: {p1, p0', p1'}
三角3: {p1, p1', p2}
三角4: {p2, p1', p2'}
...
每条Strip对应网格中相邻的两行。对于gridRows=150的网格,有gridRows-1=149条Strip,每条Strip包含gridCols * 2 = 300个索引。
存储效率对比:
- 独立三角形:(gridRows-1) * (gridCols-1) * 2 * 3 = 132,006 个索引存储
- 三角带:(gridRows-1) * gridCols * 2 + (gridRows-1) = 89,400 个索引存储(加上每个Strip的单元计数)
- 节省约 32% 的索引存储空间
第五步:法向量计算
vtkNew<vtkPolyDataNormals> normalsFilter;
normalsFilter->SetInputData(terrain);
normalsFilter->SetFeatureAngle(40.0);
normalsFilter->SplittingOff();
normalsFilter->ConsistencyOn();
SetFeatureAngle(40.0):特征角设为40度。这意味着相邻面法向夹角小于40度的区域会获得平滑着色(如平缓的山坡),而大于40度的区域会保留尖锐的棱角效果(如山峰)。SplittingOff():关闭法向分裂。开启时(默认),Filter会在特征边处创建重复的顶点(每个面片有自己的顶点拷贝),这会改变顶点数量。关闭时,它在不改变拓扑的情况下尽可能平滑着色——保持顶点索引和数量不变。ConsistencyOn():确保所有法向的一致性(统一朝外)。这很重要——如果某些三角形的顶点顺序不一致,某些面的法向可能指向内部,导致渲染时出现"黑洞"。
第六步:颜色映射表
LookupTable定义了从标量值到颜色的映射。SetHueRange(0.667, 0.0)创建了从蓝色(低海拔)经过绿色和黄色到红色(高海拔)的配色方案,这是地理可视化中常用的高程配色。
第九步:相机设置
camera->Elevation(45); // 垂直方向上抬45度
camera->Azimuth(30); // 水平方向旋转30度
这些调用在初始相机位置的基础上叠加旋转,提供了从东南方向上方向俯瞰地形的视角。GradientBackgroundOn()启用了天空渐变背景——顶部为深蓝灰色,底部为浅蓝灰色,模拟真实天空的视觉效果。
7.7.4 运行结果
运行程序后,你将看到一个1000x700像素的窗口,其中显示一个彩色的3D地形曲面: - 低洼处(Z值低)显示为蓝色 - 中等高度显示为绿色和黄色 - 山峰处(Z值高)显示为橙红色 - 表面有平滑的光照效果(得益于法向量计算) - 右侧显示高程标量条 - 鼠标可旋转、缩放、平移地形
7.7.5 扩展思路
这个地形示例是一个很好的起点。你可以基于此进行以下扩展:
- 添加等高线(Contour Lines):使用
vtkBandedPolyDataContourFilter在地形表面叠加等高线。 - 从真实数据加载高程:读取DEM(Digital Elevation Model)文件,替换
TerrainHeight()函数。 - 动态变形:在计时器回调中随时间修改点坐标,创建动画地形(如波浪传播)。
- 叠加纹理:加载卫星影像作为纹理,叠加在地形表面。
- 添加水体平面:在某个Z值处创建一个半透明平面,模拟水面效果。
7.8 本章小结
本章深入剖析了vtkPolyData的完整结构和编程接口。让我们回顾核心要点:
要点速览
-
双层分离设计:
vtkPolyData将几何(vtkPoints——点坐标)与拓扑(vtkCellArray——连接关系)分开存储。这种分离使得网格变形、细化、动画等操作变得简单高效,是VTK数据模型中最优雅的设计决策之一。 -
四类基本图元:Verts(0D点元)、Lines(1D线元/折线)、Polys(2D三角形/四边形/多边形)、Strips(2D三角形带),分别存储在四个独立的
vtkCellArray中。一个PolyData可以同时包含所有四种图元。 -
vtkCellArray的现代存储格式:使用Offsets+Connectivity双数组结构,支持O(1)随机访问。InsertNextCell()和InsertCellPoint()是构建单元的主要API,C++11的initializer_list重载(InsertNextCell({0, 1, 2}))提供了最简洁的语法。 -
顶点顺序决定面朝向:遵循右手定则——逆时针顶点顺序定义正面(Front Face),法向指向观察者。如果渲染的面"看不见"或呈黑色,首先检查顶点顺序是否正确。
-
法向量是光照渲染的关键:没有法向量的表面将渲染为纯色。
vtkPolyDataNormals过滤器自动计算法向量,SetFeatureAngle()控制光滑着色与平面着色之间的分界线。法向量存储在Point Data(光滑着色)或Cell Data(平面着色)中。 -
纹理坐标映射2D图像到3D表面:通过
vtkPointData::SetTCoords()设置纹理坐标,通过vtkTexture创建纹理对象,通过actor->SetTexture()绑定。vtkImageData可用于程序化生成纹理图像。 -
三角形带是规则网格的高效存储方案:对于
m x n网格,使用三角带可以比独立三角形节省约32%的索引存储,同时渲染性能更高。
从本章到全书
本章是第二卷(进阶篇)的开门砖。理解了vtkPolyData的结构,你将在以下章节中游刃有余:
- 第八章(过滤器的实际应用):所有涉及表面网格的过滤器(
vtkClipPolyData、vtkSmoothPolyDataFilter、vtkDecimatePro、vtkContourFilter)都操作于vtkPolyData之上。理解其内部结构将使你能够预测过滤器的行为并调试输出结果。 - 第九章(数据I/O):STL、OBJ、PLY、VTK等文件格式本质上都是
vtkPolyData的不同序列化方式——点坐标+单元连接+属性数据。 - 第十章(颜色映射与标量可视化):本章的标量数据(如地形的高程值)在后续章节中被广泛用于驱动颜色映射、变形(Warp)和标签(Glyph)等高级可视化技术。
- 第十二章(高级渲染):理解法向量和纹理坐标是掌握PBR(Physically-Based Rendering)材质、法向贴图、环境遮蔽等高级渲染技术的基础。
延伸阅读与资源
- vtkPolyData官方文档:https://docs.vtk.org/en/latest/api/CommonDataModel/vtkPolyData.html
- vtkCellArray官方文档:https://docs.vtk.org/en/latest/api/CommonDataModel/vtkCellArray.html
- vtkPolyDataNormals文档:https://docs.vtk.org/en/latest/api/FiltersCore/vtkPolyDataNormals.html
- VTK Examples - Geometric Objects:https://examples.vtk.org/site/Cxx/GeometricObjects/
- VTK Examples - PolyData:https://examples.vtk.org/site/Cxx/PolyData/
进入下一章之前
在进入第八章(过滤器的实际应用)之前,建议你:
- 运行本章的所有代码示例(点云、螺旋线、立方体、法向对比、棋盘格纹理、地形曲面),亲自观察每种图元类型的渲染效果。
- 修改地形示例中的
TerrainHeight()函数,创建你自己的地形形状——这是巩固PolyData手动构建技能的最佳练习。 - 尝试用
VTK_POLYGON类型创建一个正六边形(六个顶点的多边形),并用vtkPolyDataNormals计算其法向。 - 修改立方体示例中某个面的顶点顺序(故意弄反),观察渲染结果——这能帮助你直观理解右手定则。
本章关键概念口诀:"几何存坐标,拓扑存索引;法向定光照,纹理贴图像;顶点逆时针,正面朝向你。"
本章完成。第七章是第二卷的基石——深入理解PolyData的结构和构造方法,将使你在后续章节中面对各种数据操作时从容不迫。