最近在学图形学,想着与其从头开始搓一个光线追踪渲染器,不如在现有的引擎基础上实践,入门更加简单。刚好看到了Unity中实现光追的相关教程,考虑到Unity方便调试,方便预览效果,故选择用Unity作为基础。
注:本文的实现基于Unity2022.3.55f1c1版本。
Unity Shader 基础
Unity Shader文件以.shader
结尾,本质上是一个对顶点/片元着色器和其他很多设置提供了一层抽象的语言。可以通过内嵌Cg/HLSL或者GLSL来编写顶点/片元着色器。我们将要实现的光线追踪的核心逻辑也将在Unity Shader中编写。
一个最简单的shader文件大概是这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 Shader "ShaderName" { SubShader { Pass { CGPROGRAM // 在这里嵌入Cg/HLSL代码 #pragma vertex vert // 定义顶点着色器的名字 #pragma fragment frag // 定义片元着色器的名字 struct a2v // 程序传给顶点着色器的数据 { float4 vertex : POSITION; // ... }; struct v2f // 顶点着色器传给片元着色器的数据 { float4 pos: SV_POSITION; // ... }; v2f vert(a2v input) { v2f output; output.pos = UnityObjectToClipPos(input.vertex); // 把顶点变换到齐次裁剪空间 // ... return output; } float4 frag(v2f input) : SV_TARGET { // ... return YourPixelColor; // 输出该片元的颜色 } ENDCG } } }
Unity中的着色器往往和材质(Material)结合使用,我们需要将着色器赋给某个材质,然后使用这个材质进行渲染。除此之外,还可以在脚本中调用材质的SetInt
,SetFloat
,SetTexture
等方法,为着色器中的变量赋值。
Unity的场景默认用光栅化渲染,如何配置使其用我们自定义的着色器来渲染呢?我们可以在脚本中实现OnRenderImage
函数,该函数会在相机完成渲染后调用,允许我们修改相机的最终图像。相机渲染的原图像和修改后的图像都以RenderTexture
类型的参数传入。在该函数中,我们可以调用Graphics.Blit()
来指定用某个着色器(材质)对图像进行渲染。由于光线追踪和光栅化是两套完全不同的渲染流程,因此这里我们直接舍弃相机渲染的原图像,在Graphics.Blit()
的输入图像传入null
。
1 2 3 4 5 6 7 8 [ExecuteAlways, ImageEffectAllowedInSceneView ] public class RayTracingManager : MonoBehaviour { void OnRenderImage (RenderTexture src, RenderTexture dest ) { Graphics.Blit(null , dest, rayTracingMaterial); } }
将实现了OnRenderImage
的脚本添加到主相机上,在运行时就可以得到自己渲染的图像了。但为了更方便,可以用ExecuteAlways
和ImageEffectAllowedInSceneView
这两个Unity定义的Attribute来修饰自定义脚本类,这样在编辑器中的Scene和Game面板就都能看到自定义渲染结果了,同时Scene面板也保留了对场景的编辑功能。
在OnRenderImage
流程中,Unity会自动生成一个覆盖全屏幕的四边形,并将其顶点数据传递给着色器,TEXCOORD0
语义对应的是屏幕空间的UV坐标(左下角(0,0),右上角(1,1)),再由顶点着色器将UV坐标传给片元着色器,这样我们就可以获取当前处理的像素的屏幕空间信息了。
例如以下代码将UV坐标用颜色显示出来:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 struct a2v { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos: SV_POSITION; float2 uv: TEXCOORD0; }; v2f vert(a2v input) { v2f output; output.pos = UnityObjectToClipPos(input.vertex); output.uv = input.uv; return output; } float4 frag(v2f input) : SV_TARGET { return float4(input.uv, 0.0, 1.0); }
简化版路径追踪
路径追踪本身是一种无偏的,物理正确的渲染方法,但是(叠甲)本文实现的路径追踪经过了很多简化,并非物理正确,并且没有性能优化,仅供学习参考。
这是大名鼎鼎的渲染方程:
L o ( x , ω o ) = L e ( x , ω o ) + ∫ Ω f r ( x , ω i , ω o ) ⋅ L i ( x , ω i ) ⋅ cos θ i ⋅ d ω i L_o(x, \omega_o) = L_e(x, \omega_o) + \int_{\Omega} f_r(x, \omega_i, \omega_o) \cdot L_i(x, \omega_i) \cdot \cos\theta_i \cdot d\omega_i
L o ( x , ω o ) = L e ( x , ω o ) + ∫ Ω f r ( x , ω i , ω o ) ⋅ L i ( x , ω i ) ⋅ cos θ i ⋅ d ω i
而路径追踪的核心思想,就是通过蒙特卡洛积分,将对半球面上所有方向的入射光线的积分,转化为多次采样的结果,采样数越多就越逼近真实光照。
光线求交
首先需要定义光线,渲染方程中的光线用起点x x x ,方向ω \omega ω 和辐射亮度(Radiance)来表示,其中辐射亮度是辐射度量中的物理量,与波长相关,是完全物理准确的,为了简化,我们采用RGB三个通道来表示颜色就行。这样我们就可以定义光线:
1 2 3 4 5 6 struct Ray { float3 origin; float3 direction; float3 color; };
接下来,要让光线与场景中的物体交互,从最简单的物体——球开始,我们需要解决光线与球求交的问题。
已知光线的参数方程 R ( t ) = O + t D \mathbf{R}(t)=\mathbf{O}+t\mathbf{D} R ( t ) = O + t D ,其中O \mathbf{O} O 为起点,D \mathbf{D} D 为方向,而球表面一点P \mathbf{P} P 满足∥ P − C ∥ 2 = r 2 \|\mathbf{P}-\mathbf{C}\|^2=r^2 ∥ P − C ∥ 2 = r 2 ,其中C \mathbf{C} C 为球心,r r r 为半径。两式联立:
∥ O + t D − C ∥ 2 = r 2 \|\mathbf{O}+t\mathbf{D}-\mathbf{C}\|^{2}=r^{2}
∥ O + t D − C ∥ 2 = r 2
令L = O − C \mathbf{L}=\mathbf{O}-\mathbf{C} L = O − C ,则:
∥ L + t D ∥ 2 = r 2 \|\mathbf{L}+t\mathbf{D}\|^{2}=r^{2}
∥ L + t D ∥ 2 = r 2
( D ⋅ D ) t 2 + 2 ( L ⋅ D ) t + ( L ⋅ L − r 2 ) = 0 (\mathbf{D}\cdot\mathbf{D})t^{2}+2(\mathbf{L}\cdot\mathbf{D})t+(\mathbf{L}\cdot\mathbf{L}-r^{2})=0
( D ⋅ D ) t 2 + 2 ( L ⋅ D ) t + ( L ⋅ L − r 2 ) = 0
解这个关于t的一元二次方程,有解时,较小的解即为光线与球面的交点。完整的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 struct HitInfo { bool hit; float dist; float3 hitPoint; float3 normal; RayTracingMaterial material; }; HitInfo RaySphere(Ray ray, float3 sphereCentre, float sphereRadius) { HitInfo hitInfo; float3 L = ray.origin - sphereCentre; float a = dot(ray.dir, ray.dir); float b = 2 * dot(L, ray.dir); float c = dot(L, L) - sphereRadius * sphereRadius; float delta = b * b - 4 * a * c; if (delta >= 0) { float dist = (-b - sqrt(delta)) / (2 * a); if (dist >= 0) { hitInfo.hit = true; hitInfo.dist = dist; hitInfo.hitPoint = ray.origin + ray.dir * dist; hitInfo.normal = normalize(hitInfo.hitPoint - sphereCentre); } } return hitInfo; }
当场景中有多个球体时,我们需要获取存有每个球体信息的数据结构,完整的路径追踪都有诸如BVH、KD-Tree等加速结构,但是为简化,这里只用一个顺序表来存储。
具体来说,我们在着色器中定义一个StructuredBuffer
表示结构体数组,接着要从脚本中传入相应的数据。对于一条给定的光线,我们遍历数组,对每个球体求交,并取距离最近的作为结果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 struct Sphere { float radius; float3 position; RayTracingMaterial material; }; StructuredBuffer<Sphere> Spheres; int NumSpheres; HitInfo CalculateRayCollision(Ray ray) { HitInfo closestHit; closestHit.dist = 0x7F7FFFFF; // 初始化为最大值 for (int i = 0; i < NumSpheres; i++) { Sphere sphere = Spheres[i]; HitInfo hitInfo = RaySphere(ray, sphere.position, sphere.radius); if(hitInfo.hit && hitInfo.dist < closestHit.dist) { closestHit = hitInfo; closestHit.material = sphere.material; } } return closestHit; }
在脚本中向着色器传递数据时,我们需要获取场景中所有球体的数据。可以为每一个球体加上一个自定义脚本Sphere Object
,通过FindObjectsOfType
来遍历这些物体。
1 2 3 4 5 6 public struct Sphere{ public float radius; public Vector3 position; public RayTracingMaterial material; }
传递数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 List<Sphere> spheres = new (); foreach (var elem in FindObjectsOfType <SphereObject >()){ elem.sphere.position = elem.GetComponent<Transform>().position; elem.sphere.radius = elem.GetComponent<Transform>().localScale.x * 0.5f ; spheres.Add(elem.sphere); } ComputeBuffer sphereBuffer = new (spheres.Count, sizeof (float ) * 14 ); sphereBuffer.SetData(spheres); rayTracingMaterial.SetBuffer("_Spheres" , sphereBuffer); rayTracingMaterial.SetInt("NumSpheres" , spheres.Count);
发出光线
如何表达从相机发射到屏幕上每个像素对应位置的光线?本质上是将屏幕坐标转化为世界坐标的问题。
由于要求的光线无关长度,理论上可以取相机看向像素位置的任意一个点进行研究,不妨取正对相机且距离为1个单位的平面上的点来研究。首先,我们将x ∈ [ 0 , 1 ] , y ∈ [ 0 , 1 ] x\in[0, 1],y\in[0,1] x ∈ [ 0 , 1 ] , y ∈ [ 0 , 1 ] 的屏幕坐标映射到以相机为中心的局部坐标系。已知相机的视角大小Fov和宽高比aspect,画出示意图:
可以通过简单的几何关系得到,该平面上点x ∈ [ − a s p e c t ⋅ tan F o v 2 , a s p e c t ⋅ tan F o v 2 ] x\in [-aspect\sdot\tan\frac{Fov}{2}, aspect\sdot\tan\frac{Fov}{2}] x ∈ [ − a s p e c t ⋅ tan 2 F o v , a s p e c t ⋅ tan 2 F o v ] ,y ∈ [ − tan F o v 2 , tan F o v 2 ] y\in [-\tan\frac{Fov}{2}, \tan\frac{Fov}{2}] y ∈ [ − tan 2 F o v , tan 2 F o v ] ,而z z z 则都为1(注意:虽然Unity中观察空间采用右手坐标系,其他空间都采用左手坐标系,理论上观察空间中相机看向-z方向,但是在之后的空间变换中,Unity会自动帮我们处理好坐标系的转变,这里只要认为摄像机看向+z方向就行)。之后将点从局部坐标系转换到世界坐标系,就可以计算点与相机位置的差来得到光线方向了。
向着色器传递相机的参数:
1 2 3 4 5 6 void UpdateCameraParams (Camera cam ){ float tanHalfFOV = Mathf.Tan(cam.fieldOfView * 0.5f * Mathf.Deg2Rad); Vector3 viewParams = new (tanHalfFOV * cam.aspect, tanHalfFOV, 1.0f ); rayTracingMaterial.SetVector("ViewParams" , viewParams); }
在着色器中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 float4 frag(v2f input) : SV_TARGET { // 将x,y从[0,1]先映射到[-1,1],再乘相应参数 float3 viewPointLocal = float3((input.uv - 0.5) * 2, 1) * ViewParams; // 调用Unity内置的变换矩阵,将点从观察空间转换到世界空间 float3 viewPointWorld = mul(unity_CameraToWorld, float4(viewPointLocal, 1)); Ray ray; ray.origin = _WorldSpaceCameraPos; ray.dir = normalize(viewPointWorld - ray.origin); ray.color = float3(1.0, 1.0, 1.0); // ... }
随机采样
基于蒙特卡洛积分的路径追踪,很重要的一个需求就是在半球面内随机采样。
首先,需要在着色器内生成随机数,着色器没有方便的API来生成随机数,不过我们可以自己实现一个简单的函数,通过种子来生成随机数。
1 2 3 4 5 6 7 float RandomValue(inout uint state) { state = state * 747796405 + 2891336453; uint result = ((state >> ((state >> 28) + 4)) ^ state) * 277803737; result = (result >> 22) ^ result; return result / 4294967295.0; }
inout
关键字可以理解为按引用传递,这样每次生成随机数时会改变种子,连续调用也可以生成不同的随机数了。
有了随机数后,我们需要生成随机方向,并且在球面上均匀分布,直接给x,y,z三个分量赋值随机数,得到的不是均匀的分布。而由于正态分布的某些性质,事实上我们只要给z,y,z赋值正态分布的随机数,得到的向量就是在球面均匀分布的了。
对于半球,只需要判断随即方向与法线的关系即可。
相关代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // Box-Muller 变换,生成正态分布的随机数 float RandomValueNormalDistribution(inout uint state) { float theta = 2 * 3.1415926 * RandomValue(state); float rho = sqrt(-2 * log(RandomValue(state))); return rho * cos(theta); } float3 RandomDirection(inout uint state) { float x = RandomValueNormalDistribution(state); float y = RandomValueNormalDistribution(state); float z = RandomValueNormalDistribution(state); return normalize(float3(x, y, z)); } float3 RandomHemisphereDirection(float3 normal, inout uint state) { float3 dir = RandomDirection(state); return dir * sign(dot(dir, normal)); }
考虑到片元着色器逐像素调用,我们需要生成每个像素不同的随机数种子,显然可以用屏幕坐标来表示。同时,为了让每帧不同,还可以加入一个表示总帧数的变量。
1 2 3 4 uint2 numPixels = _ScreenParams.xy; // Unity内置变量,获取屏幕横纵的像素数 uint2 pixelCoord = input.uv * numPixels; uint pixelIndex = pixelCoord.y * numPixels.x + pixelCoord.x; uint randomState = pixelIndex + Frame * 718249; // Frame由脚本传递,表示运行以来的总帧数
1 rayTracingMaterial.SetInt("Frame" , Time.frameCount);
路径追踪
准备工作完毕,终于可以编写路径追踪的核心代码了。
这是GAMES101中路径追踪的伪代码(无俄罗斯轮盘赌版本)
为了简化,我们不考虑重要性采样,认为概率密度函数PDF为一定值。同时,由于着色器中不支持递归,需要将其改写为非递归版本。
我们规定光线的最大弹射次数,然后在循环中进行光线的弹射,并累计结果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 float3 Trace(Ray ray, inout uint state) { float3 Lo = 0; for (int i = 0; i <= MaxBounceCount; i++) { HitInfo hitInfo = CalculateRayCollision(ray); if (hitInfo.hit) { // 累计结果 RayTracingMaterial material = hitInfo.material; float3 Le = material.emissionColor * material.emissionStrength; // 材质自发光 Lo += Le * ray.color; // 更新光线 ray.origin = hitInfo.hitPoint; ray.dir = RandomHemisphereDirection(hitInfo.normal, state); float cosine = dot(hitInfo.normal, ray.dir); ray.color = ray.color * material.color * cosine; } else { break; } } return Lo; } float4 frag(v2f input) : SV_TARGET { // ... float3 pixelColor = 0; for (int rayIndex = 0; rayIndex < RayNumPerPixel; rayIndex++) { pixelColor += 1.0 / RayNumPerPixel * Trace(ray, randomState); } return float4(pixelColor, 1.0); }
由于漫反射的BRDF f r = k d π f_r=\frac{k_d}{\pi} f r = π k d ,k d k_d k d 为漫反射系数,通常即为材质本身的颜色,因此此处直接用材质颜色作BRDF。
我们建一个测试场景,左上角的小球作为光源,照亮其他小球,设置最大弹射次数为5时,每像素发出的光线数为1,5,25时的效果如下图:
可以看到光线数增多时,噪点明显减少,但是仍然有明显噪点。
此外,当最大弹射次数为1时,相当于没有间接光照,球的背光面是完全黑色的,可以通过对比看出:
帧累积渲染
为了减少噪点,提升图像质量,当我们渲染一个静态 场景时,可以通过对多帧图像取平均的方式,达到非常好的效果。在Unity编辑器中,我们希望点击运行按钮后,每帧的渲染结果都参与贡献。为此,需要再写一个着色器,对每帧的结果再处理。
具体而言,要存储从运行开始到当前上一帧的渲染结果,在与当前帧的结果进行混合,由于每帧的权重为1 T o t a l F r a m e s \frac{1}{TotalFrames} T o t a l F r a m e s 1 ,可以通过计算插值来得到混合结果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 sampler2D CurTex; sampler2D PrevTex; int NumRenderFrames; float4 frag(v2f input) : SV_TARGET { float4 prevRender = tex2D(PrevTex, input.uv); float4 curRender = tex2D(CurTex, input.uv); float weight = 1.0 / (NumRenderFrames + 1); float4 accumulatedAverage = prevRender * (1 - weight) + curRender * weight; return accumulatedAverage; }
而如何保存渲染结果呢?从着色器代码中看出,我们将其存储在两个纹理上,并需要在脚本中管理这些纹理的空间分配。用resultTexture
保存累积结果,Graphics.Blit()
不指定着色器时,相当于在纹理间进行拷贝。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 void OnRenderImage (RenderTexture src, RenderTexture dest ){ if (resultTexture == null ) { resultTexture = RenderTexture.GetTemporary(src.width, src.height, 0 , src.format); RenderCurrentRayTracing(resultTexture); Graphics.Blit(resultTexture, dest); } else { RenderTexture prevFrameCopy = RenderTexture.GetTemporary(src.width, src.height, 0 , src.format); Graphics.Blit(resultTexture, prevFrameCopy); RenderTexture currentFrame = RenderTexture.GetTemporary(src.width, src.height, 0 , src.format); RenderCurrentRayTracing(currentFrame); accumulateMaterial.SetInt("NumRenderFrames" , numAccumulatedFrames); accumulateMaterial.SetTexture("PrevTex" , prevFrameCopy); accumulateMaterial.SetTexture("CurTex" , currentFrame); Graphics.Blit(currentFrame, resultTexture, accumulateMaterial); Graphics.Blit(resultTexture, dest); RenderTexture.ReleaseTemporary(prevFrameCopy); RenderTexture.ReleaseTemporary(currentFrame); numAccumulatedFrames += Application.isPlaying ? 1 : 0 ; } }
这样就可以得到几乎无噪点的渲染结果了:
渲染三角形及网格
渲染三角形与渲染球体的思路类似,定义表示三角形的数据,然后实现光线与三角形求交。
可以三个定点的位置及法线来表示一个三角形:
1 2 3 4 5 struct Triangle { float3 posA, posB, posC; float3 normA, normB, normC; };
与三角形的求交算法为Möller-Trumbore算法,利用重心坐标表示三角形,列出方程:
O + t D = ( 1 − b 1 − b 2 ) P 0 + b 1 P 1 + b 2 P 2 \mathbf{O}+t\mathbf{D}=(1-b_1-b_2)\mathbf{P}_0+b_1\mathbf{P}_1+b_2\mathbf{P}_2
O + t D = ( 1 − b 1 − b 2 ) P 0 + b 1 P 1 + b 2 P 2
用克拉默法则解该线性方程组,并检验b 1 , b 2 , 1 − b 1 − b 2 ∈ [ 0 , 1 ] b_1,b_2,1-b_1-b_2\in [0,1] b 1 , b 2 , 1 − b 1 − b 2 ∈ [ 0 , 1 ] 。这里不再赘述,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 HitInfo RayTriangle(Ray ray, Triangle tri) { float3 edgeAB = tri.posB - tri.posA; float3 edgeAC = tri.posC - tri.posA; float3 normalVector = cross(edgeAB, edgeAC); float3 ao = ray.origin - tri.posA; float3 dao = cross(ao, ray.dir); float determinant = -dot(ray.dir, normalVector); float invDet = 1 / determinant; float dist = dot(ao, normalVector) * invDet; float u = dot(edgeAC, dao) * invDet; float v = -dot(edgeAB, dao) * invDet; float w = 1 - u - v; HitInfo hitInfo; hitInfo.hit = determinant >= 1E-8 && dist >= 0 && u >= 0 && v >= 0 && w >= 0; hitInfo.hitPoint = ray.origin + ray.dir * dist; hitInfo.normal = normalize(tri.normA * w + tri.normB * u + tri.normC * v); hitInfo.dist = dist; return hitInfo; }
渲染网格本质上是渲染一大批三角形,同时每个网格有独特的材质。因此我们用一个StructuredBuffer
来存储所有网格的所有三角形,然后定义表示网格信息的结构体MeshInfo
。
1 2 3 4 5 6 struct MeshInfo { uint firstTriangleIndex; // 该网格的首个三角形在三角形数组中的索引 uint numTriangles; // 该网格总三角形个数 RayTracingMaterial material; };
接着,如何从脚本中传递相关数据呢?Unity中的网格数据可以在MeshFilter
组件中获取,包括网格的所有顶点和对应法线,以及三角形的顶点索引。值得注意的是,如果希望在非运行时的场景中也能渲染网格,则需要用sharedMesh
,即原始网格资源(因为mesh
获取的是独立副本,在编辑器中使用会导致内存泄漏等风险),而sharedMesh
是在模型空间中的,需要对sharedMesh
进行顶点变换才行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 List<MeshInfo> meshInfos = new (); List<Triangle> triangles = new (); int curTriangleIndex = 0 ;foreach (var elem in FindObjectsOfType <MeshObject >()){ int [] triangleIndexArray = elem.meshFilter.sharedMesh.triangles;Vector3[] vertexArray = elem.meshFilter.sharedMesh.vertices; Vector3[] normalArray = elem.meshFilter.sharedMesh.normals; elem.meshInfo.numTriangles = triangleIndexArray.Length / 3 ; elem.meshInfo.firstTriangleIndex = curTriangleIndex; curTriangleIndex += elem.meshInfo.numTriangles; meshInfos.Add(elem.meshInfo); for (int i = 0 ; i < triangleIndexArray.Length; i += 3 ){ int v0 = triangleIndexArray[i]; int v1 = triangleIndexArray[i + 1 ]; int v2 = triangleIndexArray[i + 2 ]; Triangle newTriangle = new (); newTriangle.posA = elem.transform.TransformPoint(vertexArray[v0]); newTriangle.posB = elem.transform.TransformPoint(vertexArray[v1]); newTriangle.posC = elem.transform.TransformPoint(vertexArray[v2]); newTriangle.normA = elem.transform.TransformDirection(normalArray[v0]); newTriangle.normB = elem.transform.TransformDirection(normalArray[v1]); newTriangle.normC = elem.transform.TransformDirection(normalArray[v2]); triangles.Add(newTriangle); } } ComputeBuffer meshInfoBuffer = new (meshInfos.Count, sizeof (float ) * 12 ); meshInfoBuffer.SetData(meshInfos); ComputeBuffer trianglesBuffer = new (triangles.Count, sizeof (float ) * 18 ); trianglesBuffer.SetData(triangles); rayTracingMaterial.SetBuffer("Triangles" , trianglesBuffer); rayTracingMaterial.SetBuffer("AllMeshInfo" , meshInfoBuffer); rayTracingMaterial.SetInt("NumMeshes" , meshInfos.Count);
最后,在着色器的路径追踪中添加与网格求交的代码,就是暴力遍历所有三角形。(事实上可以用轴对齐包围盒(AABB)略加优化)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 HitInfo CalculateRayCollision(Ray ray) { // ... for (int meshIndex = 0; meshIndex < NumMeshes; meshIndex++) { MeshInfo meshInfo = AllMeshInfo[meshIndex]; for (int i = 0; i < meshInfo.numTriangles; i++) { int triIndex = meshInfo.firstTriangleIndex + i; Triangle tri = Triangles[triIndex]; HitInfo hitInfo = RayTriangle(ray, tri); if(hitInfo.hit && hitInfo.dist < closestHit.dist) { closestHit = hitInfo; closestHit.material = meshInfo.material; } } } return closestHit; }
至此,可以搭建一个简单场景来测试了,不妨以类似康奈尔盒的场景为例:
当然,自定义的网格也是支持的,只要能导入Unity编辑器中的网格,理论上都能渲染。但是由于一点优化没有,三角形面数到达几百时就很卡顿了。
镜面反射
以上的材质都是模拟漫反射材质,我们可以引入类似镜面的光滑材质,定义一个变量smoothness
表示材质的光滑程度(再次叠甲:这种实现并非物理准确,重要性采样和BRDF都做了简化)。
在光线弹射时,将随机方向和镜面反射方向进行插值,可以得到介于光滑和粗糙之间的材质。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 float3 Trace(Ray ray, inout uint state) { float3 Lo = 0; for (int i = 0; i <= MaxBounceCount; i++) { HitInfo hitInfo = CalculateRayCollision(ray); if (hitInfo.hit) { // 累计结果 RayTracingMaterial material = hitInfo.material; float3 Le = material.emissionColor * material.emissionStrength; // 材质自发光 Lo += Le * ray.color; // 更新光线 ray.origin = hitInfo.hitPoint; float3 diffuseDir = RandomHemisphereDirection(hitInfo.normal, state); float3 specularDir = reflect(ray.dir, hitInfo.normal); ray.dir = lerp(diffuseDir, specularDir, material.smoothness); float cosine = dot(hitInfo.normal, ray.dir); ray.color = ray.color * material.color * cosine; } else { break; } } return Lo; }
终于,一个简化的路径追踪大功告成,我们已经可以做出一些很漂亮的渲染结果了:(用了一个钻石模型,将康奈尔盒的六面都设为镜面)
光线追踪的知识远不止于此,如何通过不同的BRDF模型实现物理准确的渲染,模拟金属、玻璃等材质,如何进行重要性采样减少噪点……这些都是值得深入钻研的方向。
参考教程:
Coding Adventure: Ray Tracing