第 16 章:体渲染原理——视线采样、插值与颜色累积
学习目标
前四个阶段建立了表面、点/单元属性和图像切片。本章从一条屏幕像素对应的视线出发,逐步计算它如何穿过三维采样场、取得标量、转换为颜色与不透明度,最终形成一个像素。读完后,你应该能手算两三个采样的合成结果,并能解释采样间距、透明区域和早停为什么影响画面。配套的单条视线实验把每一步列在表中。
16.1 三种画面先分清
| 方式 | 一个屏幕像素主要读取什么 | 结果是什么 |
|---|---|---|
| 表面渲染 | 与视线相交的三角形表面 | 一层几何表面的颜色、光照与深度 |
| 图像切片 | 指定二维截面上的采样值 | 三维数据中的一张平面图像 |
| 体渲染 | 视线穿过体数据时的多个深度采样 | 多处颜色与不透明度合成的像素 |
体渲染并不要求给每个体素建立六个面。WebGL 仍通过三角形启动绘制:常见实现先绘制体的包围几何,再在片元程序中沿视线访问三维纹理、循环采样并合成。三角形是 GPU 光栅化的入口,画面内容来自对体内部的取样。这正是 Three.js 表面经验与 vtk.js 体数据经验相接的地方。
16.2 先确定视线经过哪里
透视相机下,不同屏幕像素的视线通常方向不同;平行投影下,视线方向相同、起点不同。对每条视线,只处理它与体数据空间范围相交的区段。设进入体的位置为 tEnter,离开体的位置为 tExit,采样点可以写成:
position(t) = rayOrigin + t × rayDirection
t = tEnter + Δt/2, tEnter + 3Δt/2, ... < tExit
这里 Δt 是沿视线的采样间距,采样点放在每个区段中间只是便于演示的一种选择。实际 Mapper 会结合世界坐标、体数据 origin、spacing、direction 以及 GPU 纹理坐标处理进入和离开的位置。若忽略 spacing,厚度和形状都可能错误。
第 5 阶段示例用 vtkImageData 建立 2 × 2 × 9 个采样点,沿 (i,j)=(0.5,0.5)、从 z=0 到 z=8 走一条视线。这样 1 个屏幕像素的计算可以单独查看,不受相机和其他视线干扰。
16.3 采样点通常落在网格点之间
vtkImageData 的标量存于离散采样点,但视线位置通常不是整数 (i,j,k)。需要从邻近点估计该处数值。在规则网格上,常见做法是三线性插值:
- 找到包围当前位置的相邻 8 个网格点。
- 沿 i 方向在两端值之间插值。
- 再沿 j 方向、k 方向插值。
若 8 个角的值分别记作 v000 到 v111,位置在单元内的比例为 (u,v,w),它等价于对角值加权:
s = Σ vabc × (a ? u : 1−u)
× (b ? v : 1−v)
× (c ? w : 1−w)
求和遍历 a,b,c ∈ {0,1}。8 个权重之和为 1。在示例中 i、j 都固定为 0.5,所以每个整数 k 层先取四角的平均;非整数 z 再在相邻两层间插值。例如前两层的中心值都为 8,而 k=2 层中心值为 43;在 z=1.5 处得到 8×0.5 + 43×0.5 = 25.5。这和第 15 章的二维切片插值相似,但发生在体内部的三维位置。
最近邻插值则取最近的离散点,边界更硬,适合类别标签;连续强度数据常用线性插值。插值得到的数值是显示或计算时的估计,不是新增加的测量。
16.4 将标量分类为颜色与不透明度
在某位置取得标量 s 后,需要两条函数:
c = colorFunction(s) // RGB,三个 0–1 分量
α = opacityFunction(s) // 0–1,不透明度
vtkColorTransferFunction 决定颜色,vtkPiecewiseFunction 常用于标量不透明度。两者输入可以是同一物理量,却回答不同问题:颜色告诉我们“它应是什么颜色”,不透明度告诉我们“它遮挡后方多少”。蓝色不必透明,白色也不必不透明。
示例把低标量设为接近透明,高标量逐渐变得可见。调高“透明阈值”时,前方的低密度区域会让更多后方内容透出。调高不透明度倍率时,前方较早挡住后方。若所有样本 α 都设为 0,即使颜色函数定义得很漂亮,体也完全看不见。
16.5 从前向后累积颜色
从靠近相机的一端走向远端。记当前已经累计的颜色为 C,累计不透明度为 A;当前采样的颜色和不透明度为 c、α。使用预乘颜色的前向后合成:
Cnext = C + (1 − A) × α × c
Anext = A + (1 − A) × α
起点为 C=(0,0,0)、A=0。1−A 是此前所有样本留下的透过率,越往后通常越小。每个样本的实际贡献权重是 (1−A)α,所以前方内容会遮挡后方,而不是简单平均所有颜色。
手算两个样本:前方红色 c1=(1,0,0), α1=0.5,后方蓝色 c2=(0,0,1), α2=0.5。第一步得到 C=(0.5,0,0), A=0.5。第二步的权重是 (1−0.5)×0.5=0.25,所以 C=(0.5,0,0.25), A=0.75。若背景为黑色,最终就是偏红紫色;若背景为白色,还需加上剩余透过率 0.25×(1,1,1),得到 (0.75,0.25,0.5)。这里的 C 是预乘颜色,不要把它误读为尚未乘 α 的普通 RGB。
当 A 接近 1,例如超过 0.995,深处样本对画面贡献很小,可以停止这条视线的后续采样。这叫早停,不会改变“先近后远遮挡”的基本规律。示例表中的“此前透过率”“本次权重”和“累计 A”正对应这些项。
16.6 改采样间距为什么会改变亮度
若把采样间距减半,却让每个新样本仍使用同一个 α,路径上的样本数约翻倍,整条视线就可能变得更不透明。这不是物体本身变厚了,而是重复累积了过多的同等遮挡。
把传递函数给出的 α 视为参考距离 d0 下的不透明度,一种常见修正是:
αstep = 1 − (1 − αref)^(d / d0)
其中 d 是当前沿视线走过的世界距离。示例设置 d0=1,可开关该修正。假如 αref=0.36,半距离的 α 为 1−sqrt(0.64)=0.2;两个半距离的透过率相乘是 0.8×0.8=0.64,与原来一个单位距离的 1−0.36=0.64 一致。
这只让相同标量沿一段路径时的透明度更稳定。间距变小还会在不同位置取到新标量,尤其靠近边界或尖峰时,最终颜色仍可能改变。真实 vtk.js 体渲染器还会考虑采样、插值、阴影及其他实现细节;本章的单条视线公式是理解其行为的基础模型,不把它当成逐像素源码复刻。
16.7 代价与质量
采样越密,越有机会捕捉小结构,计算量也越高。一个 1000×1000 像素的视图,若每条视线平均取 200 次样,理论上就要约 2 亿次体数据采样,还不含插值和颜色查询。透明区跳过、早停、分辨率调整和合理采样间距都可以减少成本。第 19 章会把这些概念落到 vtkVolumeMapper。
体渲染结果还依赖传递函数。如果“看不到结构”,未必是相机或 GPU 出错;常常是该结构的数值被映射为透明,或前方组织太不透明。下一两章分别检查颜色函数与不透明度函数。
本章小结
一条体渲染视线依次完成:与体相交、在三维网格中采样和插值、把标量映射成颜色及不透明度、从前向后累积。它仍借助 WebGL 的三角形绘制机制,却在片元处理中读取体内部,而不是只涂一个表面。掌握单条视线后,整张体渲染图就是对各屏幕像素重复这套过程。