稀疏程序化体积渲染





0/5 (0投票)
稀疏程序化体积渲染 (SPVR) 是一种用于渲染实时体积效果的技术。我们很高兴即将出版的《GPU Pro 6》一书中将包含一章关于 SPVR 的内容。本文档将提供一些附加的详细信息。
Intel® Developer Zone 提供跨平台应用程序开发工具和操作指南、平台和技术信息、代码示例以及同行专业知识,以帮助开发人员进行创新并取得成功。加入我们的社区,了解 物联网、Android*、Intel® RealSense™ 技术 和 Windows*,下载工具、获取开发套件、与志同道合的开发人员分享想法,并参加黑客马拉松、竞赛、路演和本地活动。
SPVR 通过将大型体积分解成更小的块并仅处理已占用的块来高效渲染。我们将这些块称为“元体素”(metavoxels);体素 (voxel) 是体积的最小单元。元体素是体素的 3D 数组。而整个体积是元体素的 3D 数组。该示例具有用于定义这些数量的编译时常量。目前配置的总体积大小为 1024³ 体素,形式为 32³ 个元体素,每个元体素由 32³ 个体素组成。
该示例还通过用体积图元1 填充体积来实现效率。存在许多可能的体积图元类型。该示例实现了一种:径向位移球体。该示例使用立方体贴图来表示球体表面上的位移(有关详细信息,请参阅下文)。我们识别受体积图元影响的元体素,计算受影响体素的颜色和密度,传播光照,然后从眼睛的视角进行光线步进渲染(ray-march)。
该示例还实现了内存的高效利用。将体积图元视为体积内容的压缩描述。算法有效地即时解压缩它们,在填充和光线步进渲染元体素之间进行迭代。它可以对每个元体素执行此切换。但是,在填充和光线步进渲染之间切换存在成本(例如,更改着色器),因此该算法支持在切换到光线步进渲染之前先填充一组元体素。它分配相对较小的元体素数组,并根据需要重用它们来处理整个体积。另请注意,许多典型用例首先只占用总体积的一小部分。
图 1 展示了系统中的一些参与者:体素、元体素、体积图元粒子、光源、相机以及世界。每个都有一个参考坐标系,由位置 P、向上向量 U 和右向量 R 定义。实际系统是 3D 的,但图 1 为了清晰起见简化为 2D。
- 体素 (Voxel) – 体积的最小单元。每个体素存储颜色和密度。
- 元体素 (Metavoxel) – 体素的 3D 数组。每个元体素存储为 3D 纹理(例如,32³
DXGI_FORMAT_R16G16B16A16_FLOAT
3D 纹理)。 - 体积 (Volume) – 整个体积由多个元体素组成。图 1 显示了一个简化的 2x2 元体素体积。
- 粒子 (Particle) – 径向位移的球体体积图元。请注意,这是一个 3D 粒子,而不是 2D 广告牌。
- 相机 (Camera) – 用于从眼睛的视角渲染场景其余部分的同一相机。
算法概述
// Render shadow map
foreach scene model visible from light
draw model from light view
// Render eye-view Z-Prepass
foreach scene model visible from eye
draw model from eye view
// Bin particles
foreach particle
foreach metavoxel covered by the particle
append particle to metavoxel’s particle list
// Draw metavoxels to eye-view render target
foreach non-empty metavoxel
fill metavoxel with binned particles and shadow map as input
ray march metavoxel from eye point of view, with depth buffer as input
// Render scene to back buffer
foreach scene model
draw model from eye view
// Composite eye-view render target with back buffer
draw full-screen sprite with eye-view render target as texture
请注意,该示例支持在光线步进渲染之前填充多个元体素。它会填充一个元体素“缓存”,然后进行光线步进渲染,重复此过程直到完成处理所有非空元体素。它还支持每第 n 帧填充一次元体素,或者只填充一次。如果实际应用程序只需要静态或缓慢变化的体积,则可以通过不在每一帧更新元体素来显著提高速度。
填充体积
该示例用覆盖它们的粒子来填充元体素。“覆盖”意味着粒子的边界与元体素的边界相交。该示例避免处理空元体素。它在每个元体素的体素中存储颜色和密度(颜色存储在 RGB 中,密度存储在 Alpha 中)。此工作在像素着色器中完成。它将数据写入 3D 纹理,作为 RWTexture3D
无序访问视图 (UAV)。该示例绘制一个具有两个三角形的 2D 方形,大小与元体素匹配(例如,对于 32x32x32 的元体素,像素大小为 32x32)。像素着色器遍历相应体素列中的每个体素,根据覆盖该元体素的粒子计算每个体素的密度和光照颜色。
该示例确定每个体素是否位于每个粒子内部。2D 图显示了一个简化的粒子。(该图显示了一个径向位移的 2D 圆。3D 系统实现的是径向位移的球体。)图 1 显示了粒子的边界半径 rP 和其位移半径 rPD。如果粒子中心 PP 与体素中心 PV 之间的距离小于位移距离 rPD,则粒子覆盖该体素。例如:位于 PVI 的体素在粒子内部,而位于 PVO 的体素在粒子外部。
内部 = |PV– PP| < rPD
点积可以经济高效地计算向量长度的平方,从而避免了相对昂贵的 sqrt(),通过比较长度的平方来实现。
内部 = (PV– PP) ∙ (PV– PP) < rPD2
颜色和密度是体素在粒子内的位置以及从粒子中心穿过体素中心沿直线到粒子表面 rPD 的距离的函数。
C = color(PV – PP, rPD)
D = density(PV – PP, rPD)
不同的密度函数很有趣。以下是一些可能性:
- 二值 – 如果体素在粒子内部,颜色=C 且密度=D(其中 C 和 D 是常数)。否则颜色=黑色 且密度=0。
- 渐变 – 颜色从 C1 变化到 C2,密度从 D1 变化到 D2,因为到粒子中心的距离与到粒子表面的距离在变化。
- 纹理查找 – 颜色可以存储在 1D、2D 或 3D 纹理中,并使用距离 (X,Y,Z) 进行查找。
该示例实现了两个示例:1)恒定颜色,环境光项由位移值给出;2)基于半径和粒子年龄从亮黄色到黑色的渐变。
确定元体素内位置(至少)有三种有趣的方法。
- 标准化
- Float
- 原点在元体素中心
- 范围 -1.0 到 1.0
- 纹理坐标
- 浮点数(由纹理采样机制转换为定点数)
- 对于 2D,原点在左上角(对于 3D,在左上角背面)
- 范围 0.0 到 1.0
- 体素中心位于 0.5/元体素尺寸(即,0.0 是体素的角,0.5 是中心)
- 体素索引
- 整数
- 对于 2D,原点在左上角(对于 3D,在左上角背面)
- 范围 0 到元体素尺寸 – 1
- Z 是光线方向,X 和 Y 是垂直于光线方向的平面
元体素中体素的位置由元体素的位置 PM 和体素的 (X, Y) 索引给出。体素索引是离散的,在元体素的尺寸中从 0 到 N-1 变化。图 1 显示了一个简化的 2x2 网格,其中包含 8x8 体素的元体素,位于元体素 (1, 0) 的体素位置 (2, 6)。
光照
在该示例计算了元体素中每个体素的颜色和密度后,它会为体素进行光照。该示例实现了一个简单的光照模型。像素着色器沿着体素列步进,将体素的颜色乘以当前光照值。然后,根据体素的密度衰减光照值。衰减光照(至少)有两种方法。Wrenninge 和 Zafar1 使用 e-density 作为因子。我们使用 1/(1+density)。两者都从 0 时的 1 变化到无穷大的 0。结果看起来相似,但除法可能比 exp() 更快。
Ln+1 = Ln/(1+densityn)
请注意,此循环会在单个元体素中传播光照。该示例通过光照传播纹理将光照从一个元体素传播到下一个。它将最后一个光照传播值写入纹理。下一个元体素从纹理读取其初始光照传播值。此 2D 纹理的大小足以覆盖整个体积。覆盖整个体积的大小提供了两个好处:它允许并行处理多个元体素,并且其最终内容可用作光照贴图,用于将阴影从体积投射到场景的其余部分。
阴影
该示例实现了从场景投射到体积的阴影以及从体积投射到场景的阴影。它首先将场景的不透明对象渲染到简单的阴影贴图中。体积通过在光照传播开始时引用阴影贴图来接收阴影。它通过将最终的光照传播纹理投射到场景来投射阴影。我们将在本文档后面介绍有关光照传播纹理的更多详细信息。
着色器每立方体素(每体素列)只采样一次阴影贴图。着色器确定第一个体素处于阴影中的索引(即,列内的行)。阴影索引之前的体素不在阴影中。阴影索引或之后的体素处于阴影中。
shadowIndex
随着阴影值从元体素顶部到底部变化而从 0 变化到 METAVOXEL_WIDTH
。元体素局部空间以 (0, 0, 0) 为中心,范围从 -1.0 到 1.0。因此,顶部位于 (0, 0, -1),底部位于 (0, 0, 1)。转换为光照/阴影空间得到
Top = (LightWorldViewProjection._m23 - LightWorldViewProjection._m22)
Bottom = (LightWorldViewProjection._m23 + LightWorldViewProjection._m22)
导致 FillVolumePixelShader.fx 中的着色器代码如下
float shadowZ = _Shadow.Sample(ShadowSampler, lightUv.xy).r;
float startShadowZ = LightWorldViewProjection._m23 - LightWorldViewProjection._m22;
float endShadowZ = LightWorldViewProjection._m23 + LightWorldViewProjection._m22;
uint shadowIndex = METAVOXEL_WIDTH*(shadowZ-startShadowZ)/(endShadowZ-startShadowZ);
请注意,元体素是立方体,因此 METAVOXEL_WIDTH
也等于高度和深度。
光照传播纹理
除了计算每个体素的颜色和密度之外,FillVolumePixelShader.fx 还会将最终传播的光照值写入光照传播纹理。该示例使用名称“$PropagateLighting”引用此纹理。它是一个 2D 纹理,覆盖整个体积。例如,配置为 1024³ 体积(32³ 元体素,每个元体素包含 32³ 体素)的示例将具有一个 1024x1024(32*32=1024)的光照传播纹理。有两个特殊之处:此纹理包含每个元体素一像素边框的空间,并且纹理中存储的值是最后一个未被阴影遮挡的值。
每个元体素维护一个一像素边框,以便纹理过滤正常工作(在眼睛视图光线步进渲染时采样)。该示例通过将光照传播纹理投射到场景上来实现从体积到场景其余部分的阴影投射。简单的投射会在纹理复制值以支持一像素边框的地方显示视觉伪影。它通过调整纹理坐标以适应一像素边框来避免这些伪影。以下是代码(来自 DefaultShader.fx)
float oneVoxelBorderAdjust = ((float)(METAVOXEL_WIDTH-2)/(float)METAVOXEL_WIDTH);
float2 uvVol = input.VolumeUv.xy * 0.5f + 0.5f;
float2 uvMetavoxel = uvVol * WIDTH_IN_METAVOXELS;
int2 uvInt = int2(uvMetavoxel);
float2 uvOffset = uvMetavoxel - (float2)uvInt - 0.5f;
float2 lightPropagationUv = ((float2)uvInt + 0.5f + uvOffset * oneVoxelBorderAdjust )
* (1.0f/(float)WIDTH_IN_METAVOXELS);
光照传播纹理存储最后一个未处于阴影中的体素的光照值。一旦光照传播过程遇到阴影表面,传播的光照就会变为 0(光照无法穿过阴影投射器)。但是,存储最后一个光照值使我们能够将纹理用作光照贴图。将此最后一个光照值投射到场景意味着阴影投射表面接收预期的光照值。处于阴影中的表面实际上会忽略此纹理。
光线步进渲染
该示例使用像素着色器(EyeViewRayMarch.fx)对元体素进行光线步进渲染,从 3D 纹理中采样作为着色器资源视图 (SRV)。它从远到近(相对于眼睛)步进每条光线。它从元体素对应的 3D 纹理中进行过滤采样。每个采样颜色都会添加到最终颜色中,而每个采样密度会遮挡最终颜色和最终 Alpha。
blend = 1/(1+density)
colorresult = colorresult * blend + color * (1-blend)
alpharesult = alpharesult * blend
该示例独立处理每个元体素。它一次光线步进渲染一个元体素,并将结果与眼睛视图渲染目标混合,以生成组合结果。它通过从眼睛的视角绘制一个立方体(即 12 个三角形)来步进渲染每个元体素。像素着色器沿着覆盖立方体的每个像素步进光线。它使用正面剔除来渲染立方体,以便像素着色器仅为每个覆盖的像素执行一次。如果渲染时不进行剔除,则每条光线可能会被步进两次——一次用于正面,一次用于背面。如果进行背面剔除,则当相机位于立方体内部时,像素将被剔除,光线将不会被步进。
图 3 中的简单示例显示了两条光线穿过四个元体素。它说明了光线步进是如何沿着每条光线分布的。当投影到观察向量上时,步进之间的距离是相同的。这意味着偏轴光线的步进更长。与(例如)所有光线的等距步进相比,这种方法在实际应用中产生了最佳的视觉效果。请注意,所有采样点都从远平面开始,而不是从元体素的背面开始。这与渲染单个体积时它们的采样方式相匹配,而没有元体素的概念。从每个元体素的背面开始光线步进会在元体素边界处产生可见的接缝。
图 3 还显示了采样点如何落在不同的元体素中。灰色采样点位于所有元体素之外。红色、绿色和蓝色采样点落在不同的元体素中。
深度测试
光线步进着色器通过截断光线与深度缓冲区进行比较来尊重深度缓冲区。它通过从第一个会通过深度测试的光线步进开始来高效地完成此操作。
图 4 显示了深度值与光线步进索引之间的关系。它从 Z 缓冲区读取 Z 值并计算相应的深度值(即,与眼睛的距离)。随着深度从 zMin 变化到 zMax,索引从 0 变化到 totalRaymarchCount,比例变化。导致(来自 EyeViewRayMarch.fx)的以下代码
float depthBuffer = DepthBuffer.Sample( SAMPLER0, screenPosUV ).r;
float div = Near/(Near-Far);
float depth = (Far*div)/(div-depthBuffer);
uint indexAtDepth = uint(totalRaymarchCount * (depth-zMax)/(zMin-zMax));
其中 zMin 和 zMax 是光线步进范围内的深度值。zMax 和 zMin 分别是距离眼睛最远和最近点的点的值。
排序
元体素渲染遵循两种排序顺序:一种用于光照,一种用于眼睛。光照传播从离光源最近的元体素开始,然后继续到更远的元体素。元体素是半透明的,因此正确的结果也需要从眼睛视角进行排序。从眼睛视角排序的两个选择是:使用“over” alpha 混合从后到前,以及使用“under” alpha 混合从前到后。
图 5 显示了三个元体素、眼睛相机和光源的简单排列。光照传播需要顺序 1、2、3;我们必须将光照传播到元体素 1,才能知道有多少光能够到达元体素 2。并且,我们必须将光照传播到元体素 2,才能知道有多少光能够到达元体素 3。
我们还需要从眼睛的视角对元体素进行排序。如果从前到后渲染,我们会先渲染元体素 2。蓝色和紫色线条显示元体素 1 和 3 如何位于元体素 2 之后。我们需要将光照传播到元体素 1 和 2,然后才能渲染元体素 3。最坏的情况是需要将光照传播到整个列,然后才能渲染任何一个。从后到前的渲染方式允许我们在传播其光照后立即渲染每个元体素。
该示例结合了从后到前和从前到后的排序,以支持在传播光照后立即渲染元体素的能力。该示例从后到前渲染垂直平面(即绿色线)上方的元体素,并使用 over 混合,然后从前到后渲染垂直平面下方的元体素,并使用 under 混合。这种排序方法可以产生正确的结果,而无需足够的内存来容纳整个元体素列。请注意,如果应用程序能够分配足够的内存,该算法始终可以使用从前到后的排序配合 under 混合。
Alpha 混合
该示例对从后到前排序的元体素使用 over 混合(即,先渲染最远的元体素,然后是越来越近的元体素)。它对从前到后排序的元体素使用 under 混合(即,先渲染最近的元体素,然后是更远的元体素)。
Over-blend: Colordest = Colordest * Alphasrc + Colorsrc
Under-blend: Colordest = Colorsrc * Alphadest + Colordest
对于 over 和 under 混合,该示例对 alpha 通道进行相同的混合;它们都只是将目标 alpha 按像素着色器 alpha 缩放。
Alphadest = Alphadest * Alphasrc
以下是用于 over 和 under 混合的渲染状态。
Over-Blend 渲染状态 (来自 EyeViewRayMarchOver.rs)
SrcBlend = D3D11_BLEND_ONE
DestBlend = D3D11_BLEND_SRC_ALPHA
BlendOp = D3D11_BLEND_OP_ADD
SrcBlendAlpha = D3D11_BLEND_ZERO
DestBlendAlpha = D3D11_BLEND_SRC_ALPHA
BlendOpAlpha = D3D11_BLEND_OP_ADD
Under-Blend 渲染状态 (来自 EyeViewRayMarchUnder.rs)
SrcBlend = D3D11_BLEND_DEST_ALPHA
DestBlend = D3D11_BLEND_ONE
BlendOp = D3D11_BLEND_OP_ADD
SrcBlendAlpha = D3D11_BLEND_ZERO
DestBlendAlpha = D3D11_BLEND_SRC_ALPHA
BlendOpAlpha = D3D11_BLEND_OP_ADD
合成
眼睛视图光线步进渲染的结果是一个具有预乘 alpha 通道的纹理。我们绘制一个全屏精灵,并启用 alpha 混合,以与后缓冲器合成。
Colordest = Colordest * Alphasrc + Colorsrc
渲染状态是
SrcBlend = D3D11_BLEND_ONE
DestBlend = D3D11_BLEND_SRC_ALPHA
该示例支持眼睛视图渲染目标的分辨率与后缓冲器不同。较小的渲染目标可以显著提高性能,因为它减少了需要步进的光线总数。但是,当渲染目标小于后缓冲器时,合成步骤会进行上采样,这可能会在轮廓边缘产生裂缝。这个问题(留待以后解决)可以通过在合成步骤中进行上采样来解决。
已知问题
- 如果眼睛视图渲染目标小于后缓冲器,则合成时会出现裂缝。
- 光照传播纹理和阴影贴图之间的分辨率不匹配会导致裂缝。
结论
该示例通过用体积图元填充稀疏体积并从眼睛进行光线步进渲染结果来高效渲染体积效果。这是一个概念验证,展示了将体积效果与现有场景集成到端到端的支持。
杂项
查看 SIGGRAPH 2014 会议的一些 SPVR 幻灯片
https://software.intel.com/sites/default/files/managed/64/3b/VolumeRendering.25.pdf
在 Youtube* 上观看该示例的实际效果
http://www.youtube.com/watch?v=50GEvbOGUks
http://www.youtube.com/watch?v=cEHY9nVD23o
http://www.youtube.com/watch?v=5yKBukDhH80
http://www.youtube.com/watch?v=SKSPZFM2G60
了解更多关于为 Intel 平台进行优化的信息
白皮书:Intel 处理器图形 Gen 8 的计算架构
IDF 演讲:Intel 处理器图形 Gen 8 的计算架构
白皮书:Intel 处理器图形 Gen 7.5 的计算架构
Intel 处理器图形公共开发者指南和架构文档(多代)
致谢
非常感谢以下人员的贡献:Marc Fauconneau-Dufresne, Tomer Bar on, Jon Kennedy, Jefferson Montgomery, Randall Rauwendaal, Mike Burrows, Phil Taylor, Aaron Coday, Egor Yusov, Filip Strugar, Raja Bala, and Quentin Froemke。
参考文献
1. M. Wrenninge 和 N. B. Zafar,SIGGRAPH 2010 和 2011 生产体积渲染课程笔记:http://magnuswrenninge.com/content/pubs/ProductionVolumeRenderingFundamentals2011.pdf
2. M. Ikits, J. Kniss, A. Lefohn, 和 C. Hansen,体积渲染技术,《GPU Gems》,http://http.developer.nvidia.com/GPUGems/gpugems_ch39.html