在Unity中实现简化的光线追踪_unity 光线
原文链接:
在Unity中实现简化的光线追踪
最近在学图形学,想着与其从头开始搓一个光线追踪渲染器,不如在现有的引擎基础上实践,入门更加简单。刚好看到了Unity中实现光追的相关教程,考虑到Unity方便调试,方便预览效果,故选择用Unity作为基础。
注:本文的实现基于Unity2022.3.55f1c1版本。
Unity Shader 基础
Unity Shader文件以.shader
结尾,本质上是一个对顶点/片元着色器和其他很多设置提供了一层抽象的语言。可以通过内嵌Cg/HLSL或者GLSL来编写顶点/片元着色器。我们将要实现的光线追踪的核心逻辑也将在Unity Shader中编写。
一个最简单的shader文件大概是这样:
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
。
[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坐标用颜色显示出来:
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 Lo(x,ωo)=Le(x,ωo)+∫Ωfr(x,ωi,ωo)⋅Li(x,ωi)⋅cosθi⋅dωi
而路径追踪的核心思想,就是通过蒙特卡洛积分,将对半球面上所有方向的入射光线的积分,转化为多次采样的结果,采样数越多就越逼近真实光照。
光线求交
首先需要定义光线,渲染方程中的光线用起点 x x x,方向 ω \\omega ω和辐射亮度(Radiance)来表示,其中辐射亮度是辐射度量中的物理量,与波长相关,是完全物理准确的,为了简化,我们采用RGB三个通道来表示颜色就行。这样我们就可以定义光线:
struct Ray{ float3 origin; float3 direction; float3 color;};
接下来,要让光线与场景中的物体交互,从最简单的物体——球开始,我们需要解决光线与球求交的问题。
已知光线的参数方程 R(t)=O+tD \\mathbf{R}(t)=\\mathbf{O}+t\\mathbf{D} R(t)=O+tD,其中 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=r2,其中 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+tD−C∥2=r2
令 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+tD∥2=r2
( 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)t2+2(L⋅D)t+(L⋅L−r2)=0
解这个关于t的一元二次方程,有解时,较小的解即为光线与球面的交点。完整的代码如下:
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
表示结构体数组,接着要从脚本中传入相应的数据。对于一条给定的光线,我们遍历数组,对每个球体求交,并取距离最近的作为结果。
struct Sphere{ float radius; float3 position; RayTracingMaterial material;};StructuredBuffer 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
来遍历这些物体。
public struct Sphere{ public float radius; public Vector3 position; public RayTracingMaterial material;}
传递数据:
List<Sphere> spheres = new();foreach (var elem in FindObjectsOfType<SphereObject>()){ elem.sphere.position = elem.GetComponent<Transform>().position; // Unity中默认的球体直径为1,半径为0.5,因此需要转换 elem.sphere.radius = elem.GetComponent<Transform>().localScale.x * 0.5f; spheres.Add(elem.sphere);}ComputeBuffer sphereBuffer = new(spheres.Count, sizeof(float) * 14); // Sphere结构体的大小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∈[−aspect⋅tan F o v 2 ,aspect⋅tan F o v 2 ] x\\in [-aspect\\sdot\\tan\\frac{Fov}{2}, aspect\\sdot\\tan\\frac{Fov}{2}] x∈[−aspect⋅tan2Fov,aspect⋅tan2Fov], y∈[−tan F o v 2 ,tan F o v 2 ] y\\in [-\\tan\\frac{Fov}{2}, \\tan\\frac{Fov}{2}] y∈[−tan2Fov,tan2Fov],而 z z z则都为1(注意:虽然Unity中观察空间采用右手坐标系,其他空间都采用左手坐标系,理论上观察空间中相机看向-z方向,但是在之后的空间变换中,Unity会自动帮我们处理好坐标系的转变,这里只要认为摄像机看向+z方向就行)。之后将点从局部坐标系转换到世界坐标系,就可以计算点与相机位置的差来得到光线方向了。
向着色器传递相机的参数:
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);}
在着色器中:
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来生成随机数,不过我们可以自己实现一个简单的函数,通过种子来生成随机数。
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赋值正态分布的随机数,得到的向量就是在球面均匀分布的了。
对于半球,只需要判断随即方向与法线的关系即可。
相关代码如下:
// 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));}
考虑到片元着色器逐像素调用,我们需要生成每个像素不同的随机数种子,显然可以用屏幕坐标来表示。同时,为了让每帧不同,还可以加入一个表示总帧数的变量。
uint2 numPixels = _ScreenParams.xy; // Unity内置变量,获取屏幕横纵的像素数uint2 pixelCoord = input.uv * numPixels;uint pixelIndex = pixelCoord.y * numPixels.x + pixelCoord.x; uint randomState = pixelIndex + Frame * 718249; // Frame由脚本传递,表示运行以来的总帧数
rayTracingMaterial.SetInt(\"Frame\", Time.frameCount);
路径追踪
准备工作完毕,终于可以编写路径追踪的核心代码了。
这是GAMES101中路径追踪的伪代码(无俄罗斯轮盘赌版本)
为了简化,我们不考虑重要性采样,认为概率密度函数PDF为一定值。同时,由于着色器中不支持递归,需要将其改写为非递归版本。
我们规定光线的最大弹射次数,然后在循环中进行光线的弹射,并累计结果。
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} fr=πkd, k d k_d kd为漫反射系数,通常即为材质本身的颜色,因此此处直接用材质颜色作BRDF。
我们建一个测试场景,左上角的小球作为光源,照亮其他小球,设置最大弹射次数为5时,每像素发出的光线数为1,5,25时的效果如下图:
可以看到光线数增多时,噪点明显减少,但是仍然有明显噪点。
此外,当最大弹射次数为1时,相当于没有间接光照,球的背光面是完全黑色的,可以通过对比看出:
帧累积渲染
为了减少噪点,提升图像质量,当我们渲染一个静态场景时,可以通过对多帧图像取平均的方式,达到非常好的效果。在Unity编辑器中,我们希望点击运行按钮后,每帧的渲染结果都参与贡献。为此,需要再写一个着色器,对每帧的结果再处理。
具体而言,要存储从运行开始到当前上一帧的渲染结果,在与当前帧的结果进行混合,由于每帧的权重为 1 T o t a l F r a m e s \\frac{1}{TotalFrames} TotalFrames1,可以通过计算插值来得到混合结果。
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()
不指定着色器时,相当于在纹理间进行拷贝。
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; } }
这样就可以得到几乎无噪点的渲染结果了:
渲染三角形及网格
渲染三角形与渲染球体的思路类似,定义表示三角形的数据,然后实现光线与三角形求交。
可以三个定点的位置及法线来表示一个三角形:
struct Triangle{ float3 posA, posB, posC; float3 normA, normB, normC;};
与三角形的求交算法为Möller-Trumbore算法,利用重心坐标表示三角形,列出方程:
O + t D = ( 1 − b 1 − b 2 ) P 0 + b 1P 1 + b 2P 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+tD=(1−b1−b2)P0+b1P1+b2P2
用克拉默法则解该线性方程组,并检验 b 1 , b 2 ,1− b 1 − b 2 ∈[0,1] b_1,b_2,1-b_1-b_2\\in [0,1] b1,b2,1−b1−b2∈[0,1]。这里不再赘述,代码如下:
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
。
struct MeshInfo{ uint firstTriangleIndex; // 该网格的首个三角形在三角形数组中的索引 uint numTriangles; // 该网格总三角形个数 RayTracingMaterial material;};
接着,如何从脚本中传递相关数据呢?Unity中的网格数据可以在MeshFilter
组件中获取,包括网格的所有顶点和对应法线,以及三角形的顶点索引。值得注意的是,如果希望在非运行时的场景中也能渲染网格,则需要用sharedMesh
,即原始网格资源(因为mesh
获取的是独立副本,在编辑器中使用会导致内存泄漏等风险),而sharedMesh
是在模型空间中的,需要对sharedMesh
进行顶点变换才行。
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)略加优化)
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都做了简化)。
在光线弹射时,将随机方向和镜面反射方向进行插值,可以得到介于光滑和粗糙之间的材质。
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