草庐IT

有符号距离场原理及实现源码

新缸中之脑 2025-04-08 原文

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

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

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

1、摄像头和屏幕设置

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

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

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

为了根据渲染的帧设置像素,我们还引入了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、距离场表示

类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、结束语

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


原文链接:有符号距离场原理及实现 — BimAnt

有关有符号距离场原理及实现源码的更多相关文章

  1. ruby - 如何根据特征实现 FactoryGirl 的条件行为 - 2

    我有一个用户工厂。我希望默认情况下确认用户。但是鉴于unconfirmed特征,我不希望它们被确认。虽然我有一个基于实现细节而不是抽象的工作实现,但我想知道如何正确地做到这一点。factory:userdoafter(:create)do|user,evaluator|#unwantedimplementationdetailshereunlessFactoryGirl.factories[:user].defined_traits.map(&:name).include?(:unconfirmed)user.confirm!endendtrait:unconfirmeddoenden

  2. 华为OD机试用Python实现 -【明明的随机数】 2023Q1A - 2

    华为OD机试题本篇题目:明明的随机数题目输入描述输出描述:示例1输入输出说明代码编写思路最近更新的博客华为od2023|什么是华为od,od薪资待遇,od机试题清单华为OD机试真题大全,用Python解华为机试题|机试宝典【华为OD机试】全流程解析+经验分享,题型分享,防作弊指南华为o

  3. UE4 源码阅读:从引擎启动到Receive Begin Play - 2

    一、引擎主循环UE版本:4.27一、引擎主循环的位置:Launch.cpp:GuardedMain函数二、、GuardedMain函数执行逻辑:1、EnginePreInit:加载大多数模块int32ErrorLevel=EnginePreInit(CmdLine);PreInit模块加载顺序:模块加载过程:(1)注册模块中定义的UObject,同时为每个类构造一个类默认对象(CDO,记录类的默认状态,作为模板用于子类实例创建)(2)调用模块的StartUpModule方法2、FEngineLoop::Init()1、检查Engine的配置文件找出使用了哪一个GameEngine类(UGame

  4. 基于C#实现简易绘图工具【100010177】 - 2

    C#实现简易绘图工具一.引言实验目的:通过制作窗体应用程序(C#画图软件),熟悉基本的窗体设计过程以及控件设计,事件处理等,熟悉使用C#的winform窗体进行绘图的基本步骤,对于面向对象编程有更加深刻的体会.Tutorial任务设计一个具有基本功能的画图软件**·包括简单的新建文件,保存,重新绘图等功能**·实现一些基本图形的绘制,包括铅笔和基本形状等,学习橡皮工具的创建**·设计一个合理舒适的UI界面**注明:你可能需要先了解一些关于winform窗体应用程序绘图的基本知识,以及关于GDI+类和结构的知识二.实验环境Windows系统下的visualstudio2017C#窗体应用程序三.

  5. ruby-on-rails - 创建 ruby​​ 数据库时惰性符号绑定(bind)失败 - 2

    我正在尝试在Rails上安装ruby​​,到目前为止一切都已安装,但是当我尝试使用rakedb:create创建数据库时,我收到一个奇怪的错误:dyld:lazysymbolbindingfailed:Symbolnotfound:_mysql_get_client_infoReferencedfrom:/Library/Ruby/Gems/1.8/gems/mysql2-0.3.11/lib/mysql2/mysql2.bundleExpectedin:flatnamespacedyld:Symbolnotfound:_mysql_get_client_infoReferencedf

  6. MIMO-OFDM无线通信技术及MATLAB实现(1)无线信道:传播和衰落 - 2

     MIMO技术的优缺点优点通过下面三个增益来总体概括:阵列增益。阵列增益是指由于接收机通过对接收信号的相干合并而活得的平均SNR的提高。在发射机不知道信道信息的情况下,MIMO系统可以获得的阵列增益与接收天线数成正比复用增益。在采用空间复用方案的MIMO系统中,可以获得复用增益,即信道容量成倍增加。信道容量的增加与min(Nt,Nr)成正比分集增益。在采用空间分集方案的MIMO系统中,可以获得分集增益,即可靠性性能的改善。分集增益用独立衰落支路数来描述,即分集指数。在使用了空时编码的MIMO系统中,由于接收天线或发射天线之间的间距较远,可认为它们各自的大尺度衰落是相互独立的,因此分布式MIMO

  7. 【Java入门】使用Java实现文件夹的遍历 - 2

    遍历文件夹我们通常是使用递归进行操作,这种方式比较简单,也比较容易理解。本文为大家介绍另一种不使用递归的方式,由于没有使用递归,只用到了循环和集合,所以效率更高一些!一、使用递归遍历文件夹整体思路1、使用File封装初始目录,2、打印这个目录3、获取这个目录下所有的子文件和子目录的数组。4、遍历这个数组,取出每个File对象4-1、如果File是否是一个文件,打印4-2、否则就是一个目录,递归调用代码实现publicclassSearchFile{publicstaticvoidmain(String[]args){//初始目录Filedir=newFile("d:/Dev");Datebeg

  8. ruby - Arrays Sets 和 SortedSets 在 Ruby 中是如何实现的 - 2

    通常,数组被实现为内存块,集合被实现为HashMap,有序集合被实现为跳跃列表。在Ruby中也是如此吗?我正在尝试从性能和内存占用方面评估Ruby中不同容器的使用情况 最佳答案 数组是Ruby核心库的一部分。每个Ruby实现都有自己的数组实现。Ruby语言规范只规定了Ruby数组的行为,并没有规定任何特定的实现策略。它甚至没有指定任何会强制或至少建议特定实现策略的性能约束。然而,大多数Rubyist对数组的性能特征有一些期望,这会迫使不符合它们的实现变得默默无闻,因为实际上没有人会使用它:插入、前置或追加以及删除元素的最坏情况步骤复

  9. ruby - 鸭子输入字符串、符号和数组的优雅方式? - 2

    这是针对我无法破坏的现有公共(public)API,但我确实希望对其进行扩展。目前,该方法采用字符串或符号或任何其他在作为第一个参数传递给send时有意义的内容我想添加发送字符串、符号等列表的功能。我可以只使用is_a吗?数组,但还有其他发送列表的方法,这不是很像ruby​​。我将调用列表中的map,所以第一个倾向是使用respond_to?:map。但是字符串也会响应:map,所以这行不通。 最佳答案 如何将它们全部视为数组?String的行为与仅包含String的Array相同:deffoo(obj,arg)[*arg].eac

  10. ruby - 如果它是标点符号,我怎么能从字符串中删除最后一个字符,在 ruby​​ 中? - 2

    啊,正则表达式有点困惑。我正在尝试删除字符串末尾所有可能的标点符号:ifstr[str.length-1]=='?'||str[str.length-1]=='.'||str[str.length-1]=='!'orstr[str.length-1]==','||str[str.length-1]==';'str.chomp!end我相信有更好的方法来做到这一点。有什么指点吗? 最佳答案 str.sub!(/[?.!,;]?$/,'')[?.!,;]-字符类。匹配这5个字符中的任何一个(注意,。在字符类中并不特殊)?-前一个字符或组

随机推荐