Shader入口
首先,指定半透明物体shader文件的入口文件是:
Renderer/Private/RayTracing/RayTracingTranslucency.cpp
其中FDeferredShadingSceneRenderer::RenderRayTracingTranslucency
函数中实例化了一个GraphBuilder
,并且在调用RenderRayTracingPrimaryRaysView
函数之后调用了:
GraphBuilder.Execute(); |
于是分配Shader的逻辑应该在RenderRayTracingPrimaryRaysView
函数中,于是我们进入所在文件:
Renderer/Private/RayTracing/RayTracingPrimaryRays.cpp
auto RayGenShader = View.ShaderMap->GetShader<FRayTracingPrimaryRaysRGS>(PermutationVector); |
所以使用的Ray Generation Shader(RGS)是FRayTracingPrimaryRaysRGS
。
进一步,找到这一语句绑定了FRayTracingPrimaryRaysRGS
使用哪个HLSL文件:
IMPLEMENT_GLOBAL_SHADER(FRayTracingPrimaryRaysRGS, "/Engine/Private/RayTracing/RayTracingPrimaryRays.usf", "RayTracingPrimaryRaysRGS", SF_RayGen); |
于是我们可以在以下路径找到此语句:
Shaders/Private/RayTracing/RayTracingPrimaryRays.usf
RAY_TRACING_ENTRY_RAYGEN(RayTracingPrimaryRaysRGS) |
在Shaders/Private/RayTracing/RayTracingCommon.ush
中找到这个宏定义:
|
翻译过来也就是HLSL中将RayTracingPrimaryRaysRGS
标记为一个RGS的语法:
[shader("raygeneration")] void RayTracingPrimaryRaysRGS() |
于是可以确定RayTracingPrimaryRaysRGS()
就是Shader入口。
RGS主体框架
下面我们正式开始梳理这个RGS中的内容:
首先是准备阶段,先拿到DispatchThreadId
和发出光线的像素坐标:
uint2 DispatchThreadId = DispatchRaysIndex().xy + View.ViewRectMin.xy; |
然后根据像素坐标初始化Ray
和RayCone
:
float2 InvBufferSize = View.BufferSizeAndInvSize.zw; |
// Trace rays from camera origin to (Gbuffer - epsilon) to only intersect translucent objects |
之后就将进入一个循环来进行主要的Ray Tracing计算,我们将在下一段详细分析:
for (uint RefractionRayIndex = 0; RefractionRayIndex < MaxRefractionRays; ++RefractionRayIndex) |
最终,经过循环计算之后,累加的PathRadiance
作为最后的主要输出存入数组:
PathRadiance = ClampToHalfFloatRange(PathRadiance); |
也可以从文件开头找到,ColorOutput
正是可读写的RWTexture2D
类型:
RWTexture2D<float4> ColorOutput; |
循环体分析
RayTracing是一个递归的过程,在每一个界面上进行分叉并进一步递归计算,我们将所有的折射写在左子树,反射写在右子树,形成一个二叉树状的结构。
然而在GPU上运行的shader中函数并不能进行递归调用,此时可以自己使用栈来实现递归。
但是,UE4中并没有在此实现递归,而是使用循环来代替递归,舍去了对最终结果影响较小的分支,仅实现了主要分支的计算,我猜测其目的是大幅缩减计算量,提高效率。
首先将需要在循环中累加或者分发的数据进行初始化:
float3 PathRadiance = 0.0; |
之后进入循环,我们可以对PathRadiance
进行追踪,抽出循环的主干:
for (uint RefractionRayIndex = 0; RefractionRayIndex < MaxRefractionRays; ++RefractionRayIndex) |
在一个RGS中,最关键的要点是TraceRay
函数的调用,这里TraceRay
被封装了几层在TraceRayAndAccumulateResults
函数中,可以沿着RayTracingLightingCommon.ush
和RayTracingCommon.ush
这两个文件中找到最终还是调用了TraceRay
。
于是我们可以总结出如下图中的结构,其中第一个TraceRay是指从摄像机向场景中发射的第一条光线,每一笔高亮表示一轮循环中计算的内容。
反射-透射分支补全
由上图可以看出,UE4中极大地省略了树右侧分支的递归,而仅仅追踪了最左路的透射部分内容。于是,由于右子树反射后续分支的内容的缺失,会出现下图中的错误,即对于一个半透明材质的镜子,第一次的反射光线遇到另一个半透明物体时不再进行后续的追踪,仅计算其本身的漫反射,导致显示为黑色。
那么如何修复这一问题呢?
我们有两种方案:
- 使用栈在GPU中实现递归,将整个树的内容全部实现。
- 继续沿用UE4的思路,用循环中添加子循环来补充实现一部分的树。
其中,方案1肯定更加接近真实世界结果,然而这却以效率作为代价,因为发出的光线数以指数级别增长。同时,有很多多次折射或反射的光线对最终结果贡献不大。由于实时光追对效率要求很严苛,所以我们最终选择做一个trade-off,使用方案2来节约计算量。
对于半透明物体的在镜子中的渲染而言,现在最缺乏的分支其实是反射后的折射分支,以及之后的多次折射分支。
于是我们在一个循环的内部添加一个函数TraceRayForRecursiveRefractionFromFirstReflection
专门处理这些分支:
TraceRayForRecursiveRefractionFromFirstReflection(ReflectionPayload, |
函数接受反射分支的光线及其Payload
,然后分别向反射方向和折射方向发出光线计算结果。函数中包含一个子循环,沿着折射方向不断递归计算下去直到指定次数。这样一来就实现了下图中的部分,其中红色为我们补充实现的部分,绿圈为函数调用处。
全反射BUG修复
实现了以上的函数后,我们发现镜子里得到了正确的结果,而镜子外半透明物体的亮度明显过亮,这又是什么原因呢?
经过排查后发现,这是UE4原有的另一个BUG导致的错误。
不难注意到,UE4原有代码是在一个循环中计算表面漫反射与反射,然后在下一个循环才计算折射分支内容,然而在每轮循环末尾计算折射方向时有这么一段代码:
float3 T = refract(Ray.Direction, N, Eta); |
这是在判断全反射,当发生全反射时refract
函数会返回一个零向量,由于此时不发生折射,折射方向会修改为反射方向,即在下一个循环中进行全反射分支的计算。全反射的物理原理详见高中物理,而我们也可以理解为当发生全反射时,折射分量与反射分量能量合并,均流向了反射方向,全反射可以理解为一种特殊的折射。UE4这种将折射方向改为反射方向的写法并没有什么不妥,但是他们忽略了在当前循环已经计算过一遍反射分支,而下一轮循环中再计算全反射分支,会导致反射分支的能量被重复计算!这就是我们补全反射-透射分支后出现过亮现象的原因。
可能有同学会问,为什么补全分支之前亮度看上去是正常的呢?很简单,这是因为UE4没有计算反射-透射分支导致的反射分支偏暗的BUG正好使得全反射时反射分支重复计算的BUG造成的影响很弱,等于两个BUG的影响相互抵消,正好得到了看似正确的结果。
于是我们将全反射判断从循环末尾处提前到计算反射分支之前,如果发生了全反射,那么就不发射反射光线,避免重复计算。
float3 T = refract(Ray.Direction, N, Eta); |
于是得到更加正确的结果:
能量守恒分析
不难发现,在不断发射分支光线时,一定要保证能量守恒,才能得到正确的结果。UE4是怎么保证能量守恒的呢?
这时我们就需要关注上文中循环前定义的PathThroughput
变量,其主要通过计算菲涅尔现象中的菲涅尔系数Fr
和表面不透明度Opacity
来控制光追过程中的能量守恒。
通过上文中的图可以看出,UE4中的实时光追将半透明表面上的能量分为三部分计算:表面漫反射分量、折射分量、反射分量(镜面反射),其中折射分量进行递归。当然这是一种近似方法,并不是完全正确的PathTracing方法,也是为了性能而做出的一种trade-off。
我们可以从代码中提取出对于PathThroughput
的改变:
|
可以发现对于PathThroughput
的改变主要集中在循环末尾计算下一轮折射分支时,这是因为PathThroughput
是每个循环的公用变量,而循环是在模拟折射方向的递归,所以不应让反射方向的计算改变PathThroughput
的数值。计算反射方向的Throughput时,其实是单独计算了ReflectionThroughput
并乘上PathThroughput
而得到:
float NoV = saturate(dot(-Ray.Direction, Payload.WorldNormal)); |
其中EnvBRDF
函数跳进去其实是进行了菲涅尔相关的计算,约等于计算出了一个Fr
。
我们还可以发现,每次累加PathRadiance
时都有一个vertexRadianceWeight
参与计算,在代码中发现其实就是表面不透明度Opacity
:
float vertexRadianceWeight = Payload.Opacity; |
于是我们可以梳理出如下两张图,分别是Fr
和Opacity
对光线throughput的影响:
欢迎交流!