图1.由帖子中描述的WebGL渲染器执行的体积渲染的示例。 左:模拟高电位蛋白质分子中电子的空间概率分布。 右:盆景树的断层图。 这两个数据集均来自Open SciVis数据集存储库 。在科学可视化中,体绘制被广泛用于可视化三维标量场。 这些标量场通常是均匀的值网格,这些值表示例如分子周围的电荷密度,MRI或CT扫描,包围飞机的空气流等。 体积渲染是从概念上将此类数据转换为图像的简单方法:通过沿眼睛的光线对数据进行采样并为每个样本分配颜色和透明度,我们可以创建此类标量场的有用且美观的图像(参见图1)。 在GPU渲染器中,此类三维标量字段存储为3D纹理。 但是,WebGL1不支持3D纹理,因此需要额外的技巧才能在体积渲染中模拟它们。 WebGL2最近增加了对3D纹理的支持,使浏览器可以实现优雅,快速的体积渲染。 在这篇文章中,我们将讨论体积渲染的数学基础,并讨论如何在WebGL2上实现它,以创建可在浏览器中完全使用的交互式体积渲染器! 在开始之前,您可以测试本文中描述的
在线体积渲染器。
1.简介
图2:物理体积渲染,其中考虑了按体积吸收和发射的光以及散射效果。为了从体积数据创建物理上逼真的图像,我们需要模拟光线如何被介质吸收,发射和散射(图2)。 尽管在此级别对光通过介质的传播进行建模可以创建美观且物理上正确的结果,但对于交互式渲染而言这太昂贵了,这是可视化软件的目标。 在科学的可视化中,最终目标是允许科学家以交互方式研究他们的数据,并提出有关其研究任务的问题并回答。 由于完全物理的散射模型对于交互式渲染来说太昂贵了,因此可视化应用程序使用简化的发射吸收模型,或者忽略了昂贵的散射效果,或者以某种方式近似了它们。 在本文中,我们仅考虑排放吸收模型。
在发射吸收模型中,我们计算仅沿黑射线出现在图2中的照明效果,而忽略由点状灰射线引起的照明效果。 穿过该体积并到达眼睛的光线会累积该体积发出的颜色,并逐渐消退,直到它们被该体积完全吸收为止。 如果我们跟踪通过体积的眼睛发出的光线,则可以通过在整个体积上对光束进行积分来计算进入眼睛的光,以便沿该光束积累发射和吸收。 拍摄光线进入某一点的体积
并在某一点超出容量
。 我们可以使用以下积分计算进入眼睛的光:
随着光束穿过体积,我们对发射的颜色进行积分
和吸收
在每一点上
沿着光束。 在每个点处发出的光会逐渐消失并返回到眼睛,直至该点为止的体积吸收量由以下公式计算
。
在一般情况下,无法通过解析计算此积分;因此,必须使用数值近似。 我们通过抽取许多样本来进行积分的近似
沿着射线在间隔
每个都相距一定距离
彼此分开(图3),并对所有这些样本求和。 在每个采样点的阻尼项成为先前采样中累积吸收的序列的乘积。
为了进一步简化该总和,我们将阻尼项近似为(
)他在泰勒附近。 此外,为方便起见,我们介绍了alpha
。 这为我们提供了从前到后执行的Alpha合成方程式:
图3:计算体积吸收吸收的积分。上面的方程式简化为for循环,其中我们逐步遍历光束并逐步累积颜色和不透明度。 这个循环一直持续到光束离开体积或累积的颜色变得不透明(
) 以上总和的迭代计算是使用熟悉的前后合成方程式进行的:
这些最终方程式包含先前乘以不正确混合的不透明度,
。
要渲染体积图像,只需将眼睛的光线跟踪到每个像素,然后对穿过体积的每条光线执行上面显示的迭代。 每条经过处理的光线(或像素)都是独立的,因此,如果要快速渲染图像,我们需要一种并行处理大量像素的方法。 这是GPU派上用场的地方。 通过在片段着色器中实现光线行进过程,我们可以使用并行GPU计算的功能来实现非常快速的体绘制器!
图4:在体积网格上进行Raymarching。2. GPU在WebGL2上的实现
为了在片段着色器中执行光线行进,必须强制GPU在要跟踪光线的像素处执行片段着色器。 但是,OpenGL管道可用于几何图元(图5),并且没有直接的方法在屏幕的特定区域执行片段着色器。 为了解决这个问题,我们可以渲染某种中间几何体,以便在需要渲染的像素上执行片段着色器。 我们的渲染体积方法将与
Shader Toy和
演示场景渲染器相似,后者渲染两个全屏三角形以执行片段着色器,然后真正完成渲染工作。
图5:WebGL中的OpenGL管道包括两个阶段的可编程着色器:顶点着色器,负责将输入顶点转换为截断坐标的空间(剪辑空间),以及片段着色器,负责对三角形所覆盖的像素进行着色。尽管可以按照ShaderToy的方式渲染两个全屏三角形,但是当体积不能覆盖整个屏幕时,它将执行不必要的片段处理。 这种情况很常见:用户将相机移开音量,以查看一般的大量数据或研究大型特征部分。 要将片段处理限制为仅受体积影响的像素,我们可以光栅化体积网格的边界平行四边形,然后在片段着色器中执行射线行进步骤。 另外,我们不需要渲染平行四边形的正面和背面,因为按照一定的渲染三角形顺序,这种情况下的片段着色器可以执行两次。 此外,如果仅渲染正面,则在用户放大时可能会遇到问题,因为正面将投影在摄影机后面,这意味着它们将被裁剪,即这些像素将不会渲染。 为了使用户能够将相机完全拉近音量,我们将仅渲染平行四边形的反面。 生成的渲染管道如图6所示。
图6:用于raymarching量的WebGL管道。 我们将光栅化边界体积平行四边形的反面,以便对接触该体积的像素执行片段着色器。 在片段着色器内部,我们逐步渲染通过体积的光线以进行渲染。在此管道中,大部分实际渲染是在片段着色器中完成的。 但是,我们仍然可以使用顶点着色器和设备对功能进行固定插值,以执行有用的计算。 顶点着色器将根据用户的相机位置转换体积,计算光束方向和眼睛在体积空间中的位置,然后将它们传输到片段着色器。 然后,通过GPU中的固定功能插值设备对每个顶点计算的光束方向进行三角形插值,从而使我们能够以较低的成本计算每个片段的射线方向,但是,当转移到片段着色器时,这些方向可能无法归一化,因此仍然必须将它们标准化。
我们将边界平行四边形渲染为单个立方体[0,1],并通过体积轴的值对其进行缩放,以支持不等体积的体积。 眼睛的位置被转换为单个立方体,并在此空间中计算光束的方向。 在单个多维数据集空间中进行光线行进将使我们能够简化在片段着色器中进行光线行进期间的纹理采样操作。 因为它们已经在三维体积的纹理坐标[0,1]中。
上面显示了我们使用的顶点着色器,在可见光方向上绘制的光栅化背面如图7所示。
#version 300 es layout(location=0) in vec3 pos; uniform mat4 proj_view; uniform vec3 eye_pos; uniform vec3 volume_scale; out vec3 vray_dir; flat out vec3 transformed_eye; void main(void) {
图7:沿光束方向绘制的边界体积平行四边形的反面。既然片段着色器处理了我们需要为其渲染体积的像素,我们就可以对体积进行光线分割并为每个像素计算颜色。 除了在顶点着色器中计算的光束方向和眼睛的位置以渲染体积外,我们还需要将其他输入数据传输到片段着色器。 当然,对于初学者来说,我们需要3D纹理采样器来采样体积。 但是,volume只是标量值的一个块,如果我们将它们直接用作颜色值(
)和不透明度(
),那么灰度渲染的图像对用户而言将不是很有用。 例如,不可能用不同的颜色突出显示有趣的区域,添加噪点并使背景区域透明以隐藏它们。
为了使用户能够控制分配给每个样本值的颜色和不透明度,在科学可视化的渲染器中使用了一个称为
传递函数的附加颜色图。 传递函数将颜色和不透明度设置为从体积采样的特定值。 尽管存在更复杂的传递函数,但通常使用简单的颜色搜索表作为此类函数,这些表可以表示为颜色和不透明度的一维纹理(采用RGBA格式)。 要在执行体积光线marching时应用传递函数,我们可以基于从体积纹理采样的标量值对传递函数的纹理进行采样。 然后将返回的颜色值和不透明度用作
和
样品。
片段着色器的最后输入数据是我们用来计算光束步长大小的体积尺寸(
)至少要对沿波束的每个体素采样一次。 由于
传统的光束方程具有以下形式
,为了合规,我们将更改代码中的术语,并表示
怎么
。 同样,间隔
沿着光束,被体积覆盖,我们表示为
。
要在片段着色器中执行体积光线行进,我们将执行以下操作:
- 我们对从顶点着色器输入的可见光束的方向进行归一化;
- 越过视线与体积的边界以确定间隔 以渲染体积为目标执行光线行进;
- 我们计算出这样的步长 这样每个体素至少要采样一次;
- 从入口点开始 ,让我们穿过光束,直到到达终点
- 在每个点上,我们对体积进行采样,并使用传递函数分配颜色和不透明度;
- 我们将使用从前到后的合成方程,沿光束累积颜色和不透明度。
作为附加的优化,您可以添加条件,当累积的颜色变得几乎不透明时,过早退出光线行进循环以完成它。 当颜色变得几乎不透明时,此后的所有样本对像素几乎都没有影响,因为它们的颜色将被环境完全吸收并且不会到达眼睛。
我们的体积渲染器的完整片段着色器如下所示。 注释已添加到其中,标记了过程的每个阶段。
图8:从与图7相同的角度来看,渲染好的盆景可视化结果。仅此而已!
本文描述的渲染器将能够创建类似于图8和图1所示的图像。您也可以
在线对其进行测试。 为了简洁起见,我省略了准备WebGL上下文,加载体积纹理和传递函数,配置着色器以及渲染用于渲染体积的多维数据集所需的Javascript代码; 完整的渲染器代码可在
Github上参考。