推荐:使用 NSDT场景设计器 快速搭建 3D场景。

有符号距离场(SDF:Signed Distance Field) 是距离场的一种变体,它在 3D(2D) 空间中将位置映射到其到最近平面(边缘)的距离。距离场在图像处理、物理学和计算机图形学等许多研究中都有应用。在计算机图形的上下文中,距离场通常是有符号的,表示某个位置是否在网格内。

在计算机图形学和游戏开发中,SDF 显示出极大的通用性,它可以用于碰撞测试、网格表示、光线追踪等。此外,人们发现它在使用光线追踪渲染场景时也有一些好处(即, ray-marching) 算法——几乎不需要额外成本就可以产生像软阴影环境光遮蔽这样的阴影效果。

这个项目是关于实时光线行进渲染器的从零开始的 C++ 实现,它包括一个 SDF 纹理烘焙工具并产生柔和的阴影效果。在实施过程中,我们使用了一些现有工具:用于网格导入和屏幕演示的 ASSIMP。本文涉及的代码下载链接将列在文档的末尾。

1、摄像头和屏幕设置

用于光线追踪的相机与用于传统渲染管道的相机不同;光线追踪不是将所有东西都转换到相机的视图空间,而是将光线投射到场景中,这样光线应该从屏幕空间转换到世界空间。

由于最终交付物预计将是一个导航器,你可以在其中从任何方向检查对象,因此我们保留了围绕原点旋转相机的想法,并以 LookAt() 样式定义Camera类。此外,它还包含必要的成员,如屏幕配置和视锥体,以正确生成光线。类的定义如下面的代码片段所示:

ameraController是移动相机的类,它是用键盘监听器实现的;虽然通过添加尚未实现的鼠标侦听器可以更完整:

为了根据渲染的帧设置像素,我们还引入了Screen类,它是 SDL 函数的常见封装,因为它们是在实验室中提供的,所以我们不会过多讨论。

2、场景、模型和网格加载

我们使用 ASSIMP 来帮助加载各种格式的网格文件,并定义一个Mesh类作为场景表示的基本集成单元。mesh类的实现参考了learnopengl网站提供的mesh类,实际代码高度适应本项目的使用:

代码网格

用于 OpenGL 的缓冲区句柄被移除,我们保留网格并包含网格的边界框、变换和距离场数据。此外,我们为网格命名,以便之后能够保存/加载距离场数据。

Mesh包含在 一个Model中,模型包含在Scene 中,我们保持这个层次结构以确保代码为扩展做好准备,因为目前我们在这个项目中只使用bunny网格。

3、SDF 生成

在构建网格时,程序会自动为其创建距离场数据。如果你听说过距离场,你可能知道一些叫做分析距离函数的东西,它计算从任何位置到某些几何形状的精确距离,比如盒子或圆柱体。然而,任意网格的距离场并不像那些几何那样简单,将网格分解为构造几何的方法似乎并不实用。在本项目中,解决方案是采样,即在多个采样点进行暴力光线追踪,并记录最小距离。

换句话说,网格的局部空间是体素化的,为了计算我们使用多少体素来表示距离场,我们设置一个默认值DF_MIN_NUM_VOXEL_PER_DIM=8,这意味着在局部边界框的最短轴上至少有 8 个体素,然后我们保持一个变量resolution来控制体素的实际数量,这个值越高,创建的体素越多。

Bunny网格有一些孔,因此不是完全封闭的,所以我们应用了一种方法来区分内部采样点,基本思想是保持一个计数器指示背面击中的光线数量,如果计数器超过一定数量(比例),就表示采样点应该在网格内部。

为了可读性,这里放上用 Python 写的伪代码,如下:

# DF bbox should be slightly larger
BBox = CalculateDistanceFieldBBox(mesh.bbox)
# Calculate Volume grid dimensions according to bbox and resolution
VolumeDimension = CalculateVoxelDimension(BBox, resolution)
# Generate sample ray direction
SampleDirections = GenerateSampleRayDirections(1200)

# for each sample location, do brute-force ray tracing
for (X,Y,Z) in VolumeDimension.XYZ:
    minDistance = VolumeMaxDistance
	for sampleDir in SampleDirections:
        # calculate ray(pos, dir)
        ray = (LocalSpace(X,Y,Z), sampleDir)
        # ray-mesh intersection
        (boolHit, curDistance, hitNormal) = Intersect(mesh, ray)
        if(boolHit):
            hit++
            # bac kface counter
            if( dot(sampleDir, hitNormal)):
                hitBack++
			minDistance = min(minDistance, curDistance)
            
	# ! the position is inside if >50% rays hit back face
    if (hitBack > SampleDirections.num * 0.5f):
        minDistance *= -1
	# ! for meshes that is not entirely close, 
    # ! this is the smooth operation on the border of in-outside. 
	if (minDistance < voxelSize && hitBack > 0.95 * hit):
        minDistance = - abs(minDistance)
        
    OutData[X,Y,Z] = minDistance

4、SDL生成的复杂性问题

当我们设置resolution = 1.0f时,距离场的生成效果很好,即在最短轴上使用 8 个体素,它适用于Bunny网格,创建一个 10x10x8 的栅格,每个栅格单元内运行 1200 条暴力光线追踪;由于我们使用具有 4k 个三角形的 zipped bunny 网格,距离场的计算大约需要半分钟。

当需要更高分辨率的距离场时,事情会变得很困难,因为我们使用的是 CPU 处理器,所以任务完全是顺序式的,计算 41x40x32 栅格单元的距离场需要几个小时。为了缓解这个问题,我们提出了三种可能的解决方案,(1)第一个是将计算传输到GPU,这将大大减少运行时间;(2) 第二个是在 CPU 上启用多任务处理,这是虚幻引擎 4 用于生成距离场的一种方式;(3)第三个是尝试另一种计算最短距离的方法,即Christer Ericson在“Real-time Collision Detection”中提出的point-and-triangle-feature algorithm,我们没有选择这种方法,因为它有局限性 —当网格没有关闭时,它不如光线追踪直观。

5、光线行进算法和SDF

光线行进算法是光线追踪的一种变体,我们在这里描述的东西也被称为“球体追踪”。球体追踪算法应该带有一个精确或近似的 SDF 函数SDF(position),该函数描述了空间中任何位置的最近距离值。在光线行进中,光线的传输被分解成离散的步长,这是可能的,因为SDF的特性保证在这个距离内你不会撞到或错过任何东西,所以光线可以像下图一样步进(来源:Google):

基于这个想法,球体追踪算法的伪代码相当简单:

or ray in RaysCameraToPixel:
    travel = 0
    step = 0
    currentPos = ray.pos
    dist = SDF(currentPos)
    while(dist > _hit_threshold_ && step < _max_step_):	# not hit & not overstep
        travel += dist	# march a step
        step += 1	# increase step counter
        currentPos = ray.pos + ray.dir * travel	# next position
        dist = SDF(currentPos)	# sdf query
    # hit
    shading()

注意,我们应该为行进步设置一个上限,否则当一条射线几乎平行于一个表面时,距离可能会收敛得很慢,因此很耗时。

6、隐式距离函数

隐式距离函数是一种分析函数,表示与空间中非常规则的对象的距离,由 Íñigo Quílez 引入。这些函数有助于代表一大类构造几何,并且在许多地方都使用了距离函数的演示。在我们的项目中,我们将Bunny网格和一些基本几何图形组合在一起,在一个场景中将它们一起渲染。

7、距离场表示

代码-DF

DistanceFieldData表示网格距离场的必要数据组成。这个类设计的基本思想是考虑如何以有效的方式检索距离数据,因此我们恢复距离场的边界框及其栅格大小。除了最重要的距离值外,我们还将每个栅格中心的位置作为地标,避免在渲染时重新计算。

你可能会发现距离场将另一个边界框与原始网格分开,我们注意到距离场的边界框应该大一点,这是因为你无法生成场景的全局距离场(因为RAM 有限)因此必须在本地保持网格的距离场;当光线在场景中传播时,应该有一个转移步骤,在此之前行进使用全局 SDF,之后使用局部 SDF,并且此转移不应该正好在距离场的边界上,所以使用网格边界框作为边框。

8、半阴影

我们在这个项目中使用的软阴影也被称为半影(penumbra),这些是表面的某些区域,它们被照亮但也有些被遮挡;通常,当光源大于接收光线的物体时会发生这种效果,而在项目中我们使用全局定向光但继承了基本思想。惠更斯-菲涅耳原理表明,一个光的波前可以被视为一系列光源,每个光源都继续向后面的相位发射光,因此当光路到达不透明物体附近时,光会被部分遮挡,这就是我们在这里计算软阴影的方式。

下面给出软阴影计算的源代码,参数按其含义命名。

9、环境光遮蔽

环境光遮蔽(Ambient Occlusion )是另一种阴影效果,可为场景添加柔和阴影以使其看起来不错。一般的想法是,不透明物体会影响投射到其他物体上的环境光,然而,要检查物体是否彼此靠近并不是那么容易,因此人们引入了成本更低的近似值,如屏幕空间环境光遮蔽 (SSAO)、屏幕空间定向遮挡(SSDO)等。它们有时看起来很好,而在其他时候会产生伪影。

距离场环境光遮蔽(DFAO)带有距离场,几乎没有额外的成本,只需多几个SDF纹理步骤就可以创造出可以接受的逼真效果;这个想法也很直观:更近的样本(步骤)点对表面的环境遮挡贡献更大,而更近的物体会产生更密集的遮挡。代码如下所示。

10、阶段成果截图

这个项目的完成需要一些时间,我在每个里程碑处制作了一个录制屏幕截图。你可以在本地文件夹./shots中找到屏幕截图

在完成基本的相机设置、屏幕和光线行进渲染器之后,我创建了一个平面和一个球体来测试相机和光线行进算法是否工作正常:

完成网格导入和距离场生成函数后,我将生成的距离函数用于渲染器,看起来体素检测正确:

现在距离函数不是返回体素中心的值,而是应用三线性插值,并添加了软阴影部分。注意到目前SDF的分辨率很低,所以兔子的耳朵是有缺陷的:

生成更准确的距离场需要一些时间,现在兔子看起来更好;也是在这个时候,为了避免每次运行都重新生成距离场,我给网格命名,并添加函数来导入和导出带文件的距离场。

然后是我提到的几何的距离函数,把它们和兔子放在一起。

现在是时候给它们颜色了,同时我将环境光遮蔽部分添加到着色功能中:

为了帮助你更清楚地识别 AO 效果,我在这里进行了比较:

比较

11、运行时间

当屏幕尺寸设置为 500x500 像素时,渲染 fps 约为 2 帧/秒,由于 SDF 查询的成本大,它运行缓慢,但是如果你在src/parameters.h中修改屏幕设置,例如使用 200x200 屏幕,渲染将达到实时水平。

12、讨论

我不得不承认这个项目可以进一步改进,我遇到了一些问题但也找到了一些解决方案,我会花时间完成我的 ToDo 并找出更多要做的事情。

当前的实现在 SDF 生成方面很慢,这是我要处理的第一个任务。它的视觉效果也有一些问题(有时),兔子bunny的边界框可能会影响环境光遮挡计算,如下图所示,兔子下方有一个浅色方块,当你调整步长时会发生这种情况环境遮挡的大小,我相信这个问题的核心是全局距离场的不连续性,在兔子的网格框的边界。我会调查这个并减轻人工合成的感觉。

13、结束语

首先声明,项目的文件和代码均由本人完成,不属于本人的工作一律注明参考。另外,本文介绍的源代码可以从这里下载。


原文链接:https://zephyrl.github.io/SDF/

BimAnt翻译整理,转载请标明出处