【项目记录】Unity通用路径追踪实现(ComputeShader)

项目背景

  (废话比较多,不重要可以略过)

  这个项目是去年(2022)十月份做的计算机图形学课程的大作业,因为选题自由(但最后汇报来看,感觉学校里研究图形学的同学确实很少,而且大部分同学都是做的东西都是偏CV、人工智能方向),于是想尝试在Unity URP中实现一个全局光照算法(可以说是很久之前就埋下了伏笔)。

  因为近些年硬件提升后光线追踪也越来越多地被提及和应用,当时也觉得光追十分简单粗暴,于是选择了实现光线追踪算法,选择使用ComputeShader实现则是因为我的设备本身并不支持硬件光追,而且确实能避免更多的必要硬件要求。不过之后也了解到实现软光追可以说是相当适合新手入门的一个方向了,而当时的我完全低估了这种简单粗暴所需要付出的代价,在我后续了解了UE5 Lumen软光追的大致实现后更觉得当时的自己太天真了…

  因为那时研一上选的课比较多,作业也多,做这个项目的时间也比较紧迫(两年制研究生急着修完课,感觉快要毕业了),所以当时从深入了解光追、了解路径追踪实现、了解ComputeShader到最后做到在Unity中实现三角面求交、BRDF材质、简单时空降噪、动态构建BVH大概断断续续花了两三周的时间,主要部分都是在十一国庆假期的时候实现的,后边进行了一些缝缝补补。因为代码一直没有整理比较乱,再加上之后也没做过其他相关的内容,其实自己都已经忘得差不多了,而且记得没错的话当时实现上也有一些细节上的错误,所以这里仅凭记忆简单讲一下自己的实现思路、展示一下实现效果。(主要还是确实时间过得比较久了,东西多了就懒得回顾整理了

路径追踪基本过程

  先介绍一下项目中路径追踪执行的基本过程,方便后续内容介绍:

   1. 第一条射线,从屏幕像素发射一条射线与场景求交(这里可以在像素内进行抖动采样实现抗锯齿)
   2. 第二条射线,从1的交点处对光源进行采样,对1处进行直接光着色(项目中只考虑了传统的没有体积的光源,也就是在游戏引擎中常用的光源模型,没有考虑面光源)
   3. 第三条射线,从1的交点处随机向外(项目中为BRDF模型,也就是上半球,这里也可以不是随机而是根据BRDF进行重要性采样)发射一条射线,如果与物体相交则继续,否则采样环境光对1处进行间接光着色。
   4. 第四条射线,从3的交点处对光源进行采样,计算3处的直接光照用于对1处进行间接光着色。

  当然,项目中支持可调节的单像素采样数(SPP)以及光线弹射次数,但实现基本的全局光效果所需的就是上边这四条。

项目中实现的光追渲染结果、效率及可调节的参数,配置笔记本1050ti

在Unity中的执行过程

  一开始计划是将路径追踪作为一个后处理效果叠加在渲染画面上,仅用于补充间接光照。也就是直接光照仍然采用光栅化管线,间接光照采用光追,可以节约两次光线求交开销(即上边提到的1、2两条射线),于是采用了通过RenderFeature在特定位置插入RenderPass实现叠加间接光效果,也使得其能够支持其他后处理效果。

  但因为不了解怎么在Unity中获取光栅化管线相应的渲染信息,刚开始就直接跑了光栅化管线+1、3、4三条射线先把主要功能做出来,后来感觉干脆直接把光栅化渲染的结果覆盖了,完全由光追进行渲染(所以现在的情况是白跑一趟光栅化,原本动力十足准备假期继续完善这个项目,包括试一试自定义渲染管线,后来因为发现效率太低、需要优化的太多就还是做自己的游戏去了,所以这个项目也烂尾到现在)。

  因此整体过程的核心调用都在RenderPass的Execute函数中,简单来说就是在CPU端将所有所需数据(也就是场景信息、RenderTexture、渲染参数等)整理好后交给GPU(ComputeShader),主要的渲染逻辑自然都是在ComputeShader中实现,执行ComputeShader并CPU端使用CommandBuffer存储计算结果的Blit命令,最终在渲染管线对应位置执行。

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
    
    
        InitShaderResources();

        RayTracingVolume rayTracingVolume = volume.GetComponent<RayTracingVolume>();
        if (rayTracingVolume == null || !rayTracingVolume.IsActive()) return;

        InitRenderTexture(renderingData.cameraData.camera);
        InitTracingData();
        InitComputeBuffer();

        int threadGroupsX = Mathf.CeilToInt(Screen.width / 8.0f);
        int threadGroupsY = Mathf.CeilToInt(Screen.height / 8.0f);
        
        SetAndDispatchRayTracingShader(renderingData.cameraData.camera, rayTracingVolume, threadGroupsX, threadGroupsY);
        SetAndDispatchDenoiseShader(rayTracingVolume, threadGroupsX, threadGroupsY);

        CommandBuffer cmd = CommandBufferPool.Get();
        cmd.Blit(target, renderingData.cameraData.renderer.cameraColorTarget);
        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);

        context.Submit();
    }

RayTracing

  这里先简单放上基本代码结构(里面有一些修修改改被弃用的部分),其实似乎也没什么特别需要讲的,基本思路前边已经讲了,后续有机会可以补上讲讲项目里BVH在GPU端的组织结构和如何进行BVH遍历的(其实从代码里已经能看出一些了,甚至能看到我在前面项目背景里提到的一些“细节上的错误”…)

float3 ScreenRayDirection(uint3 id) {
    
     ... }

float3 GetPositionWithTriangle(float3 position, float3 v0, float3 v1, float3 v2) {
    
     ... }

float3 GetTAndPositionWithTriangle(float3 origin, float3 dir, float3 v0, float3 v1, float3 v2) {
    
     ... }

float Rand() {
    
     ... }

float3 RandomRayDirection(float3 normal) {
    
     ... }

void IntersectTriangle(MeshTriangle meshTriangle, Ray ray, inout Hit hit) {
    
     ... }

void IntersecBoxTriangles(Ray ray, int bvhNodeIndex, inout Hit hit) {
    
     ... }

bool IntersectBox(Ray ray, int bvhNodeIndex) {
    
     ... }

bool BVHFindNext(inout int lastIndex, inout int curIndex) {
    
     ... }

Hit IntersectBVH(Ray ray) {
    
     ... }

Hit Intersect(Ray ray) {
    
     ... }

float Fr(float3 lDir, float3 vDir, float3 normal, int meshIndex) {
    
     ... }

float3 EnvironmentLight(Ray oray, Ray iray, Hit hit) {
    
     ... }

float3 LightSampling(Ray oray, Hit ohit) {
    
     ... }

float3 Shade(Ray oray, Hit ohit, uint3 id) {
    
     ... }

void Tracing(uint3 id) {
    
     ... }

[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
    
    
	Tracing(id);
}

  这里记得有两个小问题:一个是渲染球体发现的问题,即如果用模型顶点计算法线来剔除射线与三角面背面相交的部分,会导致如一些渲染错误,因为进行光照计算的法线是经过插值的法线,与三角面的法线不同。还有一个是射线的方向如果十分接近于与本身表面相切,也会出现错误的相交(在项目中如果直接光照采样出现问题则渲染错误十分明显),可以把射线的起始位置沿法线方向进行微小的偏移。

时空滤波

  时空滤波都是使用较小的额外开销在同采样下获得更好的效果,因为项目里实现的直接光照是稳定的,所以只对间接光照结果进行了滤波。

  基于时间的降噪是将前几帧渲染的信息利用起来,项目里直接简单粗暴的进行了叠加,问题就是出现残影、产生延迟,可以通过motion vector、减少对上一帧信息的信任等方式减轻:

在这里插入图片描述
  基于空间的降噪则是利用法线、深度、颜色、物体ID等信息对图片进行去噪、模糊(就是图像处理,只不过多了些维度,项目里差不多基本上用的就是简单的高频噪点去除加高斯模糊),当然问题就是如果处理不当就会把细节全糊了(比如如果不做边缘检测的话下方红框内效果,但这好像已经不是细节问题了):


BVH

  光追慢就慢在与场景求交上,暴力地与场景中所有三角形进行相交检测会非常慢(场景中只放一个球我的笔记本就受不了),BVH便是用来加速求交过程。BVH是一颗二叉树,简单来说就是如果与包围盒没有相交,则与包围盒内的包围盒以及三角面也不会有交点。

BVH包围盒渲染输出,最白色部分为金色小球的位置,可以说画面整体越白渲染压力越大

  项目中使用的是在CPU端根据三角面的重心动态构建的BVH,实现上可以对静态、动态网格体,静态、动态物体的BVH采用不同策略进行整理并更新,并使用快速选择算法(当然项目中实际直接拿的C#提供的快排…)划分三角面以构建BVH,并且可以将这一部分放在GPU端并行处理(这里没有具体了解过,简单的个人思考是比如在CPU端进行初步的划分后,将每一块交给不同的GPU单元进行处理)。


(补充)关于BVH在GPU端的组织结构和遍历:

  其实对BVH的组织和遍历原本是一个很简单的事情,但因为使用的cs5.0并不支持递归调用,在GPU端使用数组实现栈、队列等也有其限制(好处是逻辑简单,缺点是不确定长度产生的额外存储开销以及最大长度的限制),所以需要对算法进行一些额外处理,即不使用额外存储空间对二叉树进行遍历。

  因为需要将BVH以数组形式传递进GPU,所以实际传递进GPU的BVH节点除了自身的包围盒信息外还存储了父、左、右节点的索引。对于叶子节点,其中左、右节点索引存储的不是子BVH节点索引,而是对应了存储三角面的数组中三角面所在的范围的左、右索引(加1)的负值(因为在构建BVH时,同一BVH节点下的三角面会被连续的存放在数组中)。这么做的话当遍历时发现右索引的值小于0时,说明该节点为叶子节点,则对其中的三角面进行求交。

  说明了数据结构后,接下来就是遍历过程了。在遍历开始初始化前一索引lastIndex(初始值-1)和当前索引curIndex(初始值0):

  • 如果不与当前BVH节点包围盒相交,lastIndex设为curIndex,curIndex设为父节点的索引;如果相交且右索引小于0,则与其中的三角形求交(求交的结果会被记录在对应的变量中),lastIndex及curIndex赋值同上。(这一赋值其实代表着这条路走到了尽头)
  • 根据lastIndex及curIndex的值寻找需要遍历的下一BVH节点:
    • 如果当前节点的右索引等于0,则返回false(未找到,出现的情况为游戏场景中没有三角面)
    • 如果curIndex大于0,则不断循环以下操作:如果lastIndex等于当前节点的左索引(说明刚遍历完左子树),则需要遍历右子树,即赋值lastIndex为curIndex,curIndex为当前节点的右索引,返回true,表示找到需要遍历的节点;如果lastIndex等于当前节点的右索引(说明左右都遍历完了),则需要返回父节点,即赋值lastIndex为curIndex,curIndex为当前节点的父索引,不返回(继续循环);否则(即lastIndex等于当前节点的父索引,说明刚从上边下来)则需要遍历左子树,类似第一种情况。
    • 默认返回false(当前索引小于等于0,情况如跟BVH根节点包围盒便没有相交,此时进入该函数时curIndex等于-1)
  • 对于上述的返回值,返回false则结束算法,true则从第一步开始继续循环

  经过上述步骤便完成了BVH的遍历,此时求交的结果也被记录下来,后续对应处理即可。

猜你喜欢

转载自blog.csdn.net/qq_43459138/article/details/129256989