最近在学图形学,想着与其从头开始搓一个光线追踪渲染器,不如在现有的引擎基础上实践,入门更加简单。刚好看到了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的脚本添加到主相机上,在运行时就可以得到自己渲染的图像了。但为了更方便,可以用ExecuteAlwaysImageEffectAllowedInSceneView这两个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);
}

展示屏幕UV

简化版路径追踪

路径追踪本身是一种无偏的,物理正确的渲染方法,但是(叠甲)本文实现的路径追踪经过了很多简化,并非物理正确,并且没有性能优化,仅供学习参考。

这是大名鼎鼎的渲染方程:

Lo(x,ωo)=Le(x,ωo)+Ωfr(x,ωi,ωo)Li(x,ωi)cosθidωiL_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

而路径追踪的核心思想,就是通过蒙特卡洛积分,将对半球面上所有方向的入射光线的积分,转化为多次采样的结果,采样数越多就越逼近真实光照。

光线求交

首先需要定义光线,渲染方程中的光线用起点xx,方向ω\omega和辐射亮度(Radiance)来表示,其中辐射亮度是辐射度量中的物理量,与波长相关,是完全物理准确的,为了简化,我们采用RGB三个通道来表示颜色就行。这样我们就可以定义光线:

1
2
3
4
5
6
struct Ray
{
float3 origin;
float3 direction;
float3 color;
};

接下来,要让光线与场景中的物体交互,从最简单的物体——球开始,我们需要解决光线与球求交的问题。

已知光线的参数方程 R(t)=O+tD\mathbf{R}(t)=\mathbf{O}+t\mathbf{D},其中O\mathbf{O}为起点,D\mathbf{D}为方向,而球表面一点P\mathbf{P}满足PC2=r2\|\mathbf{P}-\mathbf{C}\|^2=r^2,其中C\mathbf{C}为球心,rr为半径。两式联立:

O+tDC2=r2\|\mathbf{O}+t\mathbf{D}-\mathbf{C}\|^{2}=r^{2}

L=OC\mathbf{L}=\mathbf{O}-\mathbf{C},则:

L+tD2=r2\|\mathbf{L}+t\mathbf{D}\|^{2}=r^{2}

(DD)t2+2(LD)t+(LLr2)=0(\mathbf{D}\cdot\mathbf{D})t^{2}+2(\mathbf{L}\cdot\mathbf{D})t+(\mathbf{L}\cdot\mathbf{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;
// 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]的屏幕坐标映射到以相机为中心的局部坐标系。已知相机的视角大小Fov和宽高比aspect,画出示意图:
示意图

可以通过简单的几何关系得到,该平面上点x[aspecttanFov2,aspecttanFov2]x\in [-aspect\sdot\tan\frac{Fov}{2}, aspect\sdot\tan\frac{Fov}{2}]y[tanFov2,tanFov2]y\in [-\tan\frac{Fov}{2}, \tan\frac{Fov}{2}],而zz则都为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中路径追踪的伪代码(无俄罗斯轮盘赌版本)
ray_generation
shade
为了简化,我们不考虑重要性采样,认为概率密度函数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 fr=kdπf_r=\frac{k_d}{\pi}kdk_d为漫反射系数,通常即为材质本身的颜色,因此此处直接用材质颜色作BRDF。

我们建一个测试场景,左上角的小球作为光源,照亮其他小球,设置最大弹射次数为5时,每像素发出的光线数为1,5,25时的效果如下图:
光线数对比图
可以看到光线数增多时,噪点明显减少,但是仍然有明显噪点。

此外,当最大弹射次数为1时,相当于没有间接光照,球的背光面是完全黑色的,可以通过对比看出:
间接光照对比图

帧累积渲染

为了减少噪点,提升图像质量,当我们渲染一个静态场景时,可以通过对多帧图像取平均的方式,达到非常好的效果。在Unity编辑器中,我们希望点击运行按钮后,每帧的渲染结果都参与贡献。为此,需要再写一个着色器,对每帧的结果再处理。

具体而言,要存储从运行开始到当前上一帧的渲染结果,在与当前帧的结果进行混合,由于每帧的权重为1TotalFrames\frac{1}{TotalFrames},可以通过计算插值来得到混合结果。

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+tD=(1b1b2)P0+b1P1+b2P2\mathbf{O}+t\mathbf{D}=(1-b_1-b_2)\mathbf{P}_0+b_1\mathbf{P}_1+b_2\mathbf{P}_2

用克拉默法则解该线性方程组,并检验b1,b2,1b1b2[0,1]b_1,b_2,1-b_1-b_2\in [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