英伟达推出的NVIDIA RTX技术,让实时光线追踪(Real Time Ray Tracing)进入商业游戏成为可能。光线追踪相比传统的光栅化算法更符合物理学,能带来更加真实的渲染效果,同时因为核心原理其实并不复杂,并能够制定一个通用的美术流程,相信RTX技术会在未来取得很大的进展。
在这篇文章里我想用Unity实现一个最简单的基础光线追踪效果。作为对光线追踪技术的一次实践。
1.光线追踪的原理
光线追踪希望能模拟光线在真实环境下的折射,反射,漫反射,间接反射等物理现象。如果我们能够模拟整个场景光线的传播,就能得到非常真实的图像,但如果直接从光源开始发射的光线经过传播后并不一定会进入摄像机,造成了大量计算浪费。使用逆向追踪——即从摄像机开始发射光线进行追踪,将有效减少无效计算,即使如此,计算量依然是巨大的,如果每一个像素点发射一条光线进行追踪,并且追踪10次折射或反射的话.生成一张1080P的图,相交的计算量将是1920*1080*10=20 736 000,约两千万次光线追踪运算。
单独看一个像素上的追踪过程的话:
(1)从视线方向发射一条射线。
(2)取得射线与场景最近的交点。
(3)取得交点的材质颜色。
(4)如果材质包含反射或折射,则改变光线方向。
(5)寻找下一个交点,并重复(2)。直到在场景内找不到交点,或达到最大追踪次数。
以上几个步骤中,(2)步骤因为需要遍历整个场景,开销随场景复杂度上升而上升。对于一些简单的示例场景中,可以简化为射线与球,或者射线与平面的相交。而在有模型的场景中,可以看作射线与场景中所有三角形进行相交,求最近的交点。RTX技术内置了求交运算方法,将效率提高很多。
看RTX的示例可以看到,光线追踪除了能模拟最基本的直接光,间接光,反射和折射,还能模拟阴影,环境光遮蔽(AO),粗糙表面的散射等。物体表面并非理想的平面,散射存在一定随机性,就会会产生噪点(Noise),要去除噪点,要么如离线渲染那样进行大量采样,叠加消除噪点,要么采用RTX一样,使用人工智能进行降噪。这一块比较复杂,在我的示例里就不涉及散射,表面会是都是理想的平滑表面。
2.光线追踪的实现
基本设想是,搭建一个光线追踪的场景,包含多种材质,包括反射,折射,自发光等。做出更好的宝石材质,并且能够看到反射中的折射/折射中的反射等复杂情况。
搭建个测试场景
首先一个问题是,Unity内置渲染队列,不同材质的物体将在不同的DrawCall里渲染。在顶点/片段着色器之中无法拿到所有物体的三角形,也没办法表示多种材质。
我自定义了一个数组来存储整个场景的三角形和材质信息。通过VectorArray传入着色器。在后处理时进行光线追踪。
在片段着色器中,每个像素都会发射一条沿着视线方向的射线,和整个场景求交,也就是对整个三角形数组求交。
要检测射线与三角形的相交,就需要一个射线与三角形的求交算法,这里参考了CSDN的文章
对场景进行碰撞检测,如果碰撞到手游交易任何三角形返回白色,否则返回黑色,就可以看到场景的色块。
- if (HitScene(rayTemp, intersection, matIndex, inGeometry))
- return 0;
- else
- return 1;
直接输出碰撞点的法线。
- if (HitScene(rayTemp, intersection, matIndex, inGeometry))
- return float4(intersection.normal,0);
- else
- return 1;
对光线进行反射或折射
- if (mat.reflectiveness != 0)
- {
- rayTemp.direction = reflect(rayTemp.direction, normal.xyz);
- }
- else
- {
- float refractIndex = dot(mat.refractiveIndex, channel);
- refractIndex = intersection.inside ? refractIndex : 1 / refractIndex;
- float3 reflection = refract(rayTemp.direction, normal, refractIndex);
- if (dot(reflection, reflection) < 0.001)
- {
- rayTemp.direction = reflect(rayTemp.direction, normal.xyz);
- }
- else
- {
- rayTemp.direction = reflection;
- inGeometry = !inGeometry;
- }
最后给物体赋予不同材质,地面是棋盘格纹理加反射材质;三棱锥使用反射材质;钻石采用带有色散效果的折射;三角形宝石采用祖母绿色反射材质,小三棱锥采用HDR自发光材质。
钻石的折射色散效果,其实是因为RGB每个通道的折射率有略微不同,对三个通道分别进行光线追踪,就能用很初级暴力的方式得到色散效果。
给折射上颜色比较麻烦,因为必须要得到最后的颜色才能进行上色,而要上色的像素很可能经过了好几次反射或折射。ps_4_0不支持递归程序。最终我使用了一个ColorMask来存储颜色的遮罩。当整个上色结束后再用遮罩进行上色。
至此,光线追踪基本完成。
3.项目的优化
直接采用光线追踪,意味着每次追踪都需要对整个场景内的三角形进行求交,当场景变得复杂时造成帧率下降。
我在项目里采用包围球的方法,可以让射线先对包围球求交,如果与包围球相交,再判断是否与三角形相交,可以避免每次相交都要与整个场景的三角形求交。
此外,我还加入提早结束循环的判断,与场景无交点时,或是颜色的Alpha值接近1时,不再进行后续相交操作。
当然,目前项目的性能并不算多好,如果有什么优化的建议欢迎交流讨论。
项目还加入了方便展示的几何体的旋转动画,当鼠标点击几何体时,几何体旋转进行展示。
拓展:
1.关于自定义数据结构
项目使用了一个float4的数组_Vertices来存储整个场景的信息。最初是存储了整个场景三角形的所有顶点,并在第一个顶点的w分量存储了材质索引。这时候数组里没有几何体的概念,只是一系列不同材质的三角形。但这就意味着每一条光线与场景求交就需要遍历整个场景的三角形。
在优化的时候又加上了每个几何体的包围球数据,以及每个几何体三角形顶点的长度。这时候就可以先判断光线是否交到这个几何体的包围球,如果交到了再逐三角形进行求交。
这里保存的材质是材质索引,真正的材质数据定义在着色器内,使用材质索引找到相应的材质。
此外,三角形顶点在输入到Shader之前变换到了世界坐标系,这样就只用在CPU进行一次坐标变换。
2.关于运算量
看到评论区有小伙伴对光线追踪的运算量感兴趣。
上文提到了如果每个像素进行10次光线追踪,生成一张1080p的图片,运算量将是1920*1080*10=20 736 000次光线的追踪运算。
而每一次光线追踪运算,在未优化的情况下,需要用一条射线与整个场景的三角形进行相交,并且找到一个距离最近的交点(如果有)。追踪10次,每个像素着色器就需要遍历10遍整个场景的三角形。
这个运算量是非常庞大的,并随着光线追踪次数,以及场景内的三角形数提升而提升。
用我的示例举例,为了实现钻石的色散,我对rgb三个通道分别采样,每个通道做了4次光线追踪,场景里一共208个三角形。
生成一张1080p的图片,用一条射线和一个三角形相交的运算,不优化的情况下要使用
1920*1080*3*4=24,883,200
约2500万条光线,进行
1920*1080*3*4*208=5,175,705,600
约5.2亿次射线和三角形的相交运算
注意,这仅仅是一帧,如果想要达到比较流畅的效果要30帧,意味着这一切需要发生在0.033秒内,这段时间还要减去游戏逻辑和物理运算,以及GPU准备的时间。
此外,208个三角形真的可以算非常低的面数了,当前一个手机游戏的人物模型都可以达到上万面。随着面数的上升计算量也会上升。
当前这个例子还仅仅包含直接反射或直接折射的计算,不包含阴影,环境光遮蔽,间接反射,漫反射和降噪。
这巨大的计算量导致实时光线追踪迟迟无法应用到游戏上,也导致现在的动画离线渲染每渲染一帧都需要经过长时间的计算。
NVIDIA RTX的演示视频让人看到了实时光线计算实际应用的希望,相信在不久的将来游戏画质将会有极大的飞跃。
希望这篇文章能对大家有帮助,也希望实时光线追踪技术早日普及。