Unity GPU路径跟踪-第2部分

图片

“没有什么比模糊概念的清晰形象更糟糕的了。” -摄影师安塞尔·亚当斯(Ansel Adams)

在本文的第一部分中,我们创建了一个Whited射线跟踪器,能够跟踪完美的反射和清晰的阴影。 但是我们缺乏模​​糊性的影响:漫反射,光泽反射和柔和阴影。

根据已有代码 ,我们将迭代求解James Cajia在1986年制定的渲染方程式 ,并将渲染器转换为能够传递上述效果的路径跟踪器。 我们将再次对脚本使用C#,对着色器使用HLSL。 该代码已上传到Bitbucket

本文比上一篇文章更具数学意义,但请不要惊慌。 我将尝试尽可能清楚地解释每个公式。 这里需要使用公式,以查看发生了什么以及我们的渲染器为何起作用,因此我建议尝试理解它们,如果不清楚,请在原始文章的注释中提问。

下图是使用HDRI Haven网站上的Graffiti Shelter地图绘制的。 本文中的其他图像已使用Kiara 9 Dusk卡渲染。

图片

渲染方程


从形式上看,真实感渲染器的任务是求解渲染方程,其编写如下:

Lx\, vec omegao=Lex\, vec omegao+ int Omegafrx\, vec omegai\, vec omegao\, vec omegai cdot vecn\,Lx\, vec omegai\,d vec omegai


让我们来分析一下。 我们的最终目标是确定屏幕像素的亮度。 渲染方程式为我们提供了照明量 Lx\, vec omegao 从一个点来 x (入射点)方向  vec omegao (光束下落的方向)。 表面本身可以是发光的光源 Lex\, vec omegao 在我们的方向。 大多数表面不会,因此它们仅反射来自外部的光。 这就是为什么使用积分的原因。 它累积了来自半球各个方向的照明。 \欧 围绕法线(因此,考虑到从上方而不是从内部落在表面上的照明,这对于半透明材质可能是必需的)。

第一部分是 fr 称为双向反射率分布函数(BRDF)。 此功能直观地描述了我们要处理的材料类型:金属或电介质,深色或明亮,光泽或无光泽。 BRDF确定来自  vec omegai 反映在方向上  vec omegao 。 实际上,这是使用三分量矢量来实现的,该矢量的间隔为红色,绿色和蓝色 [0,1]

第二部分-  vec omegai cdot vecn 等于1 cos theta 在哪里  theta -入射光和表面法线之间的角度  vecn 。 想象一列平行的光线垂直入射在表面上。 现在想象一下,同一条光线以平角入射到表面。 光线将散布在更大的区域上,但这也意味着该区域的每个点都会显得更暗。 需要考虑余弦。

最后,照明本身从  vec omegai 使用相同的公式递归确定。 也就是说,该点的照明 x 取决于来自上半球所有可能方向的入射光。 从一个角度来看每个方向 x 还有一点 x\质 ,其亮度再次取决于从该点的上半球所有可能方向射出的光。 重复所有计算。

这是这里发生的情况,这是一个无限递归积分方程,具有无限数量的半球形积分区域。 我们无法直接求解此方程,但是有一个相当简单的解决方案。



1不要忘记它! 我们将经常谈论余弦,并且我们将始终牢记标量积。 由于  veca cdot vecb= | veca |  | vecb | cos theta ,而我们正在处理方向 (单位矢量),那么在大多数计算机图形任务中,标量积就是余弦。

蒙特卡洛(Monte Carlo)来营救


蒙特卡洛积分是一种数值积分技术,可让我们使用有限数量的随机样本近似计算任何积分。 此外,蒙特卡洛(Monte Carlo)保证收敛到正确的决策-我们抽取的样本越多越好。 这是它的一般形式:

FN\大 frac1N sumNn=0 fracfxnpxn


因此,功能的积分 fxn 可以通过对积分域中的随机样本求平均来近似计算。 每个样本除以其选择的概率。 pxn 。 因此,选择频率较高的样品将比选择频率较低的样品具有更大的重量。

对于半球中均匀的样本(每个方向具有相同的被选择概率),样本的概率是恒定的: p omega= frac12 pi (因为 2 pi 是单个半球的表面积)。 如果将所有这些放在一起,我们将得到以下结果:

Lx\, vec omegao\大Lex\, vec omegao+ frac1N sumNn=0 colorGreen2 pi\,frx\, vec omegai\, vec omegao\, vec omegai cdot vecn\,Lx\, vec omegai


放射线 Lex\, vec omegao 仅仅是我们的Shade函数返回的值。  frac1N 已经在我们的AddShader函数中运行。 乘以 Lx\, vec omegai 当我们反射光线并进一步追踪时会发生。 我们的任务是赋予方程式绿色部分以生命。

先决条件


在开始旅程之前,让我们先考虑一些方面:积累样本,确定性场景和着色器随机性。

积累


由于某些原因,Unity不会将HDR纹理传递给我作为OnRenderImage destinationR8G8B8A8_Typeless格式对我R8G8B8A8_Typeless ,因此准确度很快变得太低而无法累积大量样本。 为了解决这个问题,我们将private RenderTexture _converged添加到C# private RenderTexture _converged 。 这将是我们的缓冲区,在将结果显示在屏幕上之前,会高度准确地累积结果。 我们以与InitRenderTexture函数中的_target相同的方式初始化/释放纹理。 在“ Render功能中,将blitting加倍:

 Graphics.Blit(_target, _converged, _addMaterial); Graphics.Blit(_converged, destination); 

确定性场景


在更改渲染以评估效果时,将其与以前的结果进行比较很有用。 到目前为止,每次重新启动播放模式或重新编译脚本时,我们都会获得一个新的随机场景。 为了避免这种情况,请将public int SphereSeed添加到C# public int SphereSeed并在SetUpScene的开头添加以下行:

 Random.InitState(SphereSeed); 

现在我们可以手动设置种子场景。 输入任何数字,然后再次打开/关闭RayTracingMaster ,直到获得正确的场景为止。

以下参数用于样本图像:Sphere Seed 1223832719,Sphere Radius [5,30],Spheres Max 10000,Sphere Placement Radius 100。

着色器随机性


在开始随机采样之前,我们需要向着色器添加随机性。 我将使用在网络上找到的规范字符串 ,为方便起见对其进行了修改:

 float2 _Pixel; float _Seed; float rand() { float result = frac(sin(_Seed / 100.0f * dot(_Pixel, float2(12.9898f, 78.233f))) * 43758.5453f); _Seed += 1.0f; return result; } 

直接在CSMain中将_Pixel = id.xy初始化为_Pixel = id.xy以便每个像素可以使用不同的随机值。 _SeedSetShaderParameters函数中的C#初始化的。

 RayTracingShader.SetFloat("_Seed", Random.value); 

此处生成的随机数的质量不稳定。 将来,值得通过分析参数的影响并将其与其他方法进行比较来探索和测试此功能。 但就目前而言,我们将只使用它并希望取得最好的成绩。

半球采样


让我们重新开始:我们需要在半球中均匀分布的随机方向。 Corey Simon在本文中详细描述了这项艰巨的任务。 很容易适应半球。 着色器代码如下所示:

 float3 SampleHemisphere(float3 normal) { //     float cosTheta = rand(); float sinTheta = sqrt(max(0.0f, 1.0f - cosTheta * cosTheta)); float phi = 2 * PI * rand(); float3 tangentSpaceDir = float3(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta); //      return mul(tangentSpaceDir, GetTangentSpace(normal)); } 

方向是针对以正Z轴为中心的半球生成的,因此我们需要对其进行变换,以使其以所需法线为中心。 我们生成一个切线和双法线(两个与法线正交且彼此正交的向量)。 首先,我们选择一个辅助向量来生成切线。 为此,我们取正的X轴,只有在正(近似)与X轴对齐的情况下才返回正Z,然后可以使用矢量乘积生成切线,然后生成双法线。

 float3x3 GetTangentSpace(float3 normal) { //       float3 helper = float3(1, 0, 0); if (abs(normal.x) > 0.99f) helper = float3(0, 0, 1); //   float3 tangent = normalize(cross(normal, helper)); float3 binormal = normalize(cross(normal, tangent)); return float3x3(tangent, binormal, normal); } 

朗伯散射


现在我们有了统一的随机方向,我们可以继续实施第一个BRDF。 对于漫反射,最常用的是Lambert BRDF,它非常简单: frx\, vec omegai\, vec omegao= frackd pi 在哪里 kd -这是反照率表面。 让我们将其插入到Monte Carlo渲染方程式中(我将不考虑发射率),看看会发生什么:

Lx\, vec omegao\大 frac1N sumNn=0 colorBlueViolet2kd\, vec omegai cdot vecn\,Lx\, vec omegai


让我们立即将此方程式插入着色器。 在Shade函数中,用以下几行替换if (hit.distance < 1.#INF)构造内部的代码:

 //   ray.origin = hit.position + hit.normal * 0.001f; ray.direction = SampleHemisphere(hit.normal); ray.energy *= 2 * hit.albedo * sdot(hit.normal, ray.direction); return 0.0f; 

反射光束的新方向是使用我们的均匀半球样本函数确定的。 光束的能量乘以上述方程式的相应部分。 由于表面不发出任何照明(它仅反射直接或间接从天空接收的光),因此我们返回0。在这里,请不要忘记AddShader对样本AddShader平均值,因此我们无需担心  frac1N sumCSMain已包含乘以 Lx\, vec omegai (下一个反射光束),所以我们还有很多工作要做。

sdot是我为自己定义的辅助函数。 它只是返回带有附加系数的标量积的结果,然后将其限制为区间 [0,1]

 float sdot(float3 x, float3 y, float f = 1.0f) { return saturate(dot(x, y) * f); } 

让我们总结到目前为止我们的代码在做什么。 CSMain生成相机的主光线并调用Shade 。 当穿过表面时,此功能又会生成新的光束(在法线周围的半球中均匀地随机分布),并考虑了材料的BRDF和光束能量中的余弦。 在光线与天空的交点处,我们对HDRI(我们唯一的照明源)进行采样并返回照度,该照度乘以射线的能量(即从照相机开始的所有先前交点的结果)。 这是一个简单的示例,融合了收敛的结果。 结果,在每个样本中都考虑到了影响。  frac1N

现在该检查工作中的所有内容了。 由于金属不具有漫反射,因此我们现在在C#脚本的SetUpScene函数SetUpScene其关闭(但仍在此处调用Random.value以保留场景的确定性):

 bool metal = Random.value < 0.0f; 

启动“播放”模式,查看如何清除最初的噪点图像并收敛为漂亮的渲染:

Phong镜像


只需几行代码(和一小部分数学运算)就不错了。 让我们通过使用Phong的BRDF添加镜面反射来优化图片。 方的原始表述存在问题(缺乏关系和节约能源),但幸运的是其他人消除了它们 。 增强的BRDF如下所示。  vec omegar 是完全反射光的方向,并且  alpha 是控制粗糙度的Phong指示器:

frx\, vec omegai\, vec omegao=ks\, frac alpha+22 pi\,vec omegar cdot vec omegao alpha


交互式二维显示了当Phong出现时BRDF的外观  alpha=15 对于以45°角入射的光束。 尝试更改值。  alpha

将其粘贴到我们的蒙特卡洛渲染方程中:

Lx\, vec omegao\大 frac1N sumNn=0 colorbrownks\, alpha+2\, vec omegar cdot vec omegao alpha\, vec omegai cdot vecn\,Lx\, vec omegai


最后,让我们将其添加到现有的Lambert BRDF中:

Lx\, vec omegao\大 frac1N sumNn=0[ colorBlueViolet2kd+ colorks\, alpha+2\, vec omegar cdot vec omegao alpha]\, vec omegai cdot vecn\,Lx\, vec omegai


这就是它们与Lambert散射一起在代码中的外观:

 //    ray.origin = hit.position + hit.normal * 0.001f; float3 reflected = reflect(ray.direction, hit.normal); ray.direction = SampleHemisphere(hit.normal); float3 diffuse = 2 * min(1.0f - hit.specular, hit.albedo); float alpha = 15.0f; float3 specular = hit.specular * (alpha + 2) * pow(sdot(ray.direction, reflected), alpha); ray.energy *= (diffuse + specular) * sdot(hit.normal, ray.direction); return 0.0f; 

请注意,我们将标量积替换为稍有不同但等效的标量(反映了  omegao 代替  omegai ) 现在,将金属材料重新设置为SetUpScene函数,并检查其工作方式。

尝试不同的值  alpha ,您可能会注意到一个问题:即使性能低下,收敛也需要大量时间,而在高性能下,噪声尤为明显。 即使经过几分钟的等待,结果仍然不理想,这对于如此简单的场景是无法接受的。  alpha=15 alpha=300 有8192个样本如下所示:



为什么会这样呢? 毕竟,在我们进行如此美丽的理想反射之前(  alpha= infty 问题是我们生成了均质样本,并根据BRDF为其分配了权重。 Phong值高时,每个人的BRDF很小,但是这些方向非常接近完美反射,因此我们不太可能会使用均质样本随机选择它们。 另一方面, 如果我们实际上跨越了这些方向之一,那么BRDF将会很大,可以补偿所有其他小的样本。 结果是非常大的分散。 具有多次镜面反射的路径甚至更糟,并导致图像中可见的噪声。

增强采样


为了使我们的路径追踪器切实可行,我们需要更改范例。 让我们生成重要的样本 ,而不是在它们最终不重要的区域上浪费珍贵的样本(因为它们得到的BRDF和/或余弦值非常低)。

第一步,我们将返回理想的思考,然后看一下如何将这个想法推广。 为此,我们将阴影逻辑分为漫反射和镜面反射。 对于每个样本,我们将随机选择一个或另一个(取决于比率 kdks ) 在漫反射的情况下,我们将坚持均匀的样本,但对于镜面反射,我们将在唯一重要的方向上明确反射光束。 由于现在在每种反射类型上将花费更少的样本,因此我们需要相应地增加影响,以便获得相同的总值:

 //       hit.albedo = min(1.0f - hit.specular, hit.albedo); float specChance = energy(hit.specular); float diffChance = energy(hit.albedo); float sum = specChance + diffChance; specChance /= sum; diffChance /= sum; //     float roulette = rand(); if (roulette < specChance) { //   ray.origin = hit.position + hit.normal * 0.001f; ray.direction = reflect(ray.direction, hit.normal); ray.energy *= (1.0f / specChance) * hit.specular * sdot(hit.normal, ray.direction); } else { //   ray.origin = hit.position + hit.normal * 0.001f; ray.direction = SampleHemisphere(hit.normal); ray.energy *= (1.0f / diffChance) * 2 * hit.albedo * sdot(hit.normal, ray.direction); } return 0.0f; 

energy是平均颜色通道的小辅助功能:

 float energy(float3 color) { return dot(color, 1.0f / 3.0f); } 

因此,我们在上一部分中创建了一个更漂亮的Whited射线跟踪器,但是现在有了真正的漫反射阴影(这意味着柔和的阴影,环境光遮挡,漫反射的全局照明):

图片

重要性样本


让我们再看一下基本的蒙特卡洛公式:

FN\大 frac1N sumNn=0 fracfxnpxn


如您所见,我们将每个样本(样本)的影响除以选择该特定样本的概率。 到目前为止,我们已经使用了均质的半球样本,因此我们得到了一个常数 p omega= frac12 pi 。 正如我们在上面看到的,例如,在Phong BRDF的情况下,这远非理想之举,Phong BRDF在非常少的方向上很大。

想象一下,我们可以找到与可积分函数完全匹配的概率分布: px=fx 。 然后将发生以下情况:

FN\大 frac1N sumNn=01


现在,我们没有任何样本贡献很小。 这些样本将不太可能被选择。 这将大大减少结果的差异并加快渲染的收敛速度。

在实践中,不可能找到理想的分布,因为可积函数的某些部分(在我们的例子中为BRDF×余弦×入射光)是未知的(这对于入射光最为明显),但是根据BRDF×余弦甚至仅根据BRDF的样本分布将有所帮助我们。 此原则称为重要性抽样。

余弦样本


在以下步骤中,我们需要用根据余弦规则的分布替换样本的均匀分布。 别忘了,我们要生成比例较小的样本,而不是用余弦乘以均匀样本,以减少其影响。

Thomas Poole的这篇文章描述了如何做到这一点。 我们将alpha参数添加到我们的SampleHemisphere函数中。 该函数确定余弦选择的索引:对于均匀样本,0;对于余弦选择,1;对于较高的Phong值,更高。 在代码中,它看起来像这样:

 float3 SampleHemisphere(float3 normal, float alpha) { //  ,      float cosTheta = pow(rand(), 1.0f / (alpha + 1.0f)); float sinTheta = sqrt(1.0f - cosTheta * cosTheta); float phi = 2 * PI * rand(); float3 tangentSpaceDir = float3(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta); //      return mul(tangentSpaceDir, GetTangentSpace(normal)); } 

现在每个样本的概率相等 p omega= frac alpha+12 pi\, vec omega cdot vecn alpha 。 这个方程式的优点可能不会立即显现出来,但是过一会儿您就会理解它。

兰伯特样本的重要性


首先,我们将优化漫反射渲染。 在我们的均匀分布中,已经使用了Lambert常数BRDF,但是我们可以通过添加余弦来对其进行改进。 样本的余弦概率分布(其中  alpha=1 )相等  frac vec omegai cdot vecn pi ,它简化了漫反射的蒙特卡洛公式:

Lx\, vec omegao\大 frac1N sumNn=0 colorBlueVioletkd\,Lx\, vec omegai


 //   ray.origin = hit.position + hit.normal * 0.001f; ray.direction = SampleHemisphere(hit.normal, 1.0f); ray.energy *= (1.0f / diffChance) * hit.albedo; 

这将加快我们的漫反射底纹。 现在让我们进入真正的问题。

Fongov重要性抽样


对于Phong BRDF,此过程类似。 这次我们得到两个余弦的乘积:渲染方程式中的标准余弦(如漫反射的情况)乘以BRDF固有余弦。 我们只会处理最后一个。

让我们将上述示例中的概率分布插入到Phong方程中。 可以在Lafortune和Willems中找到详细的结论:使用修改后的Phong反射模型进行基于物理的渲染(1994)

Lx\, vec omegao\大 frac1N sumNn=0 colorbrownks\, frac alpha+2 alpha+1\, ( V ê ç ö é 一个 ç d ö v È Ç Ñ \,大号 X \,v È Ç ö é 一个      


 //   float alpha = 15.0f; ray.origin = hit.position + hit.normal * 0.001f; ray.direction = SampleHemisphere(reflect(ray.direction, hit.normal), alpha); float f = (alpha + 2) / (alpha + 1); ray.energy *= (1.0f / specChance) * hit.specular * sdot(hit.normal, ray.direction, f); 

这些更改足以消除Phong中的任何高性能问题,并使我们的渲染在更合理的时间内收敛。

用料


最后,让我们扩展场景的生成范围,为球体的平滑度和发射率创建变化的值! 在C#脚本的struct Sphere ,添加public float smoothnesspublic Vector3 emission 。 由于我们更改了结构的大小,因此在创建计算缓冲区时需要更改步骤(4×浮点数,还记得吗?)。 使SetUpScene函数插入用于平滑度和SetUpScene值。

在着色器中,将两个变量都添加到struct Spherestruct RayHit ,然后在struct RayHit进行初始化。 最后,在IntersectGroundPlane (硬编码,粘贴任何值)和IntersectSphere (从Sphere获取值)中设置两个值。

我想以与标准Unity着色器相同的方式使用平滑度值,这与相当随意的Fong指数不同。 这是可以在Shade函数中使用的良好转换:

 float SmoothnessToPhongAlpha(float s) { return pow(1000.0f, s * s); } 

 float alpha = SmoothnessToPhongAlpha(hit.smoothness); 



通过在Shade返回值来使用发射率:

 return hit.emission; 

结果


深吸一口气。 放松并等待,直到图像变成如此美丽的图画:

图片

恭喜你! 您设法通过了数学表达式的丛林。 我们实现了一个路径跟踪器,该路径执行扩散和镜像着色,通过重要性了解了采样,并立即应用了此概念,以便渲染在数分钟而不是数小时或数天内收敛。

与上一篇相比,本文在复杂性方面迈出了巨大的一步,但也显着提高了结果的质量。 使用数学计算会花费一些时间,但它本身就是合理的,因为它可以极大地加深您对正在发生的事情的理解,并使您可以扩展算法而不会破坏物理可靠性。

感谢您的阅读! 在第三部分中,我们(一会儿)将离开采样和阴影的丛林,返回文明,与Moller和Trumbor先生见面。 我们将需要与他们谈谈三角形。

关于作者: David Curie是“三眼游戏”的开发商,大众汽车公司的虚拟工程实验室程序员,计算机图形学研究员和重金属音乐家。

图片

Source: https://habr.com/ru/post/zh-CN412685/


All Articles