基于Unity的光线追踪(一)
雅雅雅雅各布
编辑于 2020年10月20日 15:03
收录于文集
共3篇

按照步骤完成后你将得到这样的渲染画面:

光线追踪理论

首先我想先快速回顾一下光线追踪的基本理论,如果你已经很熟悉了,请随时跳过!

让我们思考一下现实世界的照片是如何产生的。简单的说,一切都从一个光源发射的光子开始。光子沿着直线飞行,直到撞击到了物体表面,光子会在这个点发生反射或者折射,而后失去部分被物体表面吸收的能量并继续它的行程。最终,一些光子撞击到相机的图像传感器,从而产生了结果的图像。光线追踪基本上模拟了这些过程,旨在创建逼真的图像。

在实际中,光源发射的光子只有很小一部分能最终抵达相机。因此,根据亥姆霍兹互换原理(principle of Helmholtz reciprocity),所作的计算恰恰相反:不必考虑从光源发射的光子,而假定光线从场景里的相机出发,经反射和折射后最终撞击光源。

我们将要创建的光线追踪基于Turner Whitted在1980年发表的论文。它将能够模拟硬阴影和完美的反射,也会作为更高级的效果的基础,比如折射、漫反射、全局照明、光泽反射和柔和阴影等。

基础设定

首先创建一个新的Unity项目,创建一个C# script叫做RayTracingMaster.cs,和一个compute shader叫做RayTracingShader.compute。然后在C# script中写好如下代码:

public class RayTracingMaster : MonoBehaviour {     public ComputeShader RayTracingShader;     private RenderTexture _target;     private void OnRenderImage(RenderTexture source, RenderTexture destination) {         Render(destination);     }     private void Render(RenderTexture destination) {         //Make sure we have a current render target         InitRenderTexture();         //Set the target and dispatch the compute shader         RayTracingShader.SetTexture(0, "Result", _target);         int threadGroupsX = Mathf.CeilToInt(Screen.width / 8.0f);         int threadGroupsY = Mathf.CeilToInt(Screen.height / 8.0f);         RayTracingShader.Dispatch(0, threadGroupsX, threadGroupsY, 1);         //Bilt the result texture to the screen         Graphics.Blit(_target, destination);     }     private void InitRenderTexture() {         if (_target == null || _target.width != Screen.width || _target.height != Screen.height) {             if (_target != null)                 //Release render texture if we already have one                 _target.Release();                 //Get a render target for Ray Tracing                 _target = new RenderTexture(Screen.width, Screen.height, 0, RenderTextureFormat.ARGBFloat,RenderTextureReadWrite.Linear);                 _target.enableRandomWrite = true;                 _target.Create();         }     } }

每当摄像机完成渲染后,Unity就会自动调用OnRenderImage函数。为了渲染,我们首先创建了一个合适的渲染目标,然后传达给compute shader。那个“0”是compute shader的内核函数的编号,因为我们现在只有一个内核函数。

然后,我们派遣了shader。也就是说我们要告诉GPU去处理执行shader代码中的线程组。每一个线程组由shader本身设置的多个线程组成,线程组的大小和数量可以被三个维度指定,这使得shader可以轻松处理不同维度的问题。在我们的例子中,我想给渲染目标的每个像素生成一个线程。默认的线程组大小在Unity的compute shader范例被定义为[numthreads(8,8,1)],所以我们先依照这个在每8X8的像素生成一个线程组。最后,我们要把结果写回给屏幕,使用Graphics.Blit函数。

接着,把RayTracingMaster组件添加到场景的相机(这一步对调用OnRenderImage函数很重要),然后在组件指定你的compute shader并进入游玩模式,你应该会看见Unity默认的compute shader例子输出给你的三角形分形图案。

相机

现在可以在屏幕上显示一些东西了,让我们先生成一些相机的射线。因为Unity已经提供了可以正常工作的相机,所以这一步只需要用到计算出的矩阵。要着色器上添加矩阵之前,先在RayTracingMaster.cs脚本里添加以下代码:

    private Camera _camera;     private void Awake() {         _camera = GetComponent<Camera>();     }     private void SetShaderParameters() {         RayTracingShader.SetMatrix("_CameraToWorld", _camera.cameraToWorldMatrix);         RayTracingShader.SetMatrix("_CameraInverseProjection", _camera.projectionMatrix.inverse);     }

在渲染之前,从OnRenderImage函数调用SetShaderParameters函数。

在shader里,我们要定义矩阵,构建Ray的结构体和函数。这里注意在HLSL,和C#不同的是,一个函数或者变量在它使用之前声明。对于每一个屏幕像素的中心,我们计算射线的原点和方向然后把后者输出为颜色,shader的完整代码如下:

#pragma kernel CSMain RWTexture2D<float4> Result; float4x4 _CameraToWorld; float4x4 _CameraInverseProjection; struct Ray {     float3 origin;     float3 direction; }; Ray CreateRay(float3 origin, float3 direction) {     Ray ray;     ray.origin = origin;     ray.direction = direction;     return ray; } Ray CreateCameraRay(float2 uv) {     //Transform the camera origin to world space     float3 origin = mul(_CameraToWorld, float4(0.0f, 0.0f, 0.0f, 1.0f)).xyz;     //Invert the perspective projection of the view-space position     float3 direction = mul(_CameraInverseProjection, float4(uv, 0.0f, 1.0f)).xyz;     //Transform the direction from camera to world space and normalize     direction = mul(_CameraToWorld, float4(direction, 0.0f)).xyz;     direction = normalize(direction);     return CreateRay(origin, direction); } [numthreads(8,8,1)] void CSMain (uint3 id : SV_DispatchThreadID) {     //Get the dimesions of the RenderTexture     uint width, height;     Result.GetDimensions(width, height);     //Transform pixel to [-1,1]range     float2 uv = float2((id.xy + float2(0.5f, 0.5f)) / float2(width, height) * 2.0f - 1.0f);     //Get a ray for the UVs     Ray ray = CreateCameraRay(uv);     //Write some colors     Result[id.xy] = float4(ray.direction * 0.5f + 0.5f, 1.0f); }

尝试旋转相机,你应该可以看见屏幕相应地出现了“色彩缤纷的天空”。

现在让我们把颜色替换成真正的天空盒。首先在RayTracingMaster脚本中添加语句public Texture SkyboxTexture,并在inspector窗口指定你想要的天空盒纹理纹理(我例子中使用的是HDRI Haven’s Cape Hill),然后在SetShaderParameters函数中添加语句RayTracingShader.SetTexture(0, "_SkyboxTexture", SkyboxTexture);以将纹理设置到shader中。在shader里,我门还要定义纹理,对应的采样器和一个常数Π:

Texture2D<float4> _SkyboxTexture; SamplerState sampler_SkyboxTexture; static const float PI = 3.14159265f;

现在我们不再把方向写成颜色,我们将对天空盒采样,为了达到这个目的,要把笛卡尔坐标系转换为球坐标系然后把它映射到纹理坐标。所以用以下代码替换CSMain的最后一行:

    //Sample the skybox and write it     float theta = acos(ray.direction.y) / -PI;     float phi = atan2(ray.direction.x, -ray.direction.z) / -PI * 0.5f;     Result[id.xy] = _SkyboxTexture.SampleLevel(sampler_SkyboxTexture, float2(phi, theta), 0);

追踪

目前为止一切顺利,现在我们可以开始真正的追踪我们的光线了。在数学上,我们要计算光线和场景几何之间的交点,然后储存这些命中参数(位置,法线和沿着光线的距离)。如果我们的光线命中多个物体,则选择最近的一个。所以在shader中这样写:

struct RayHit {     float3 position;     float distance;     float3 normal; }; RayHit CreateRayHit() {     RayHit hit;     hit.position = float3(0.0f, 0.0f, 0.0f);     hit.distance = 1.#INF;     hit.normal = float3(0.0f, 0.0f, 0.0f);     return hit; }

通常来说,场景由很多个三角形组成,但是我们先从简单的地方开始:让一个无限的平面和数个球体相交。

平面

让直线和无限平面在y=0的地方相交非常简单。不过我们只计沿着光线正方向的命中,拒绝任何比可能命中但还未命中远的命中。

默认的,在HLSL里的参数不能被引用传递而是被值传递,所以我们只能处理一个副本,而不能对调用函数更改。我们用限定词inout传递RayHit bestHit,以便能修改原本的结构体。下面是shader的代码:

void IntersectGroundPlane(Ray ray, inout RayHit bestHit) {     //Calculate distance along the ray where the ground plane is intersected     float t = -ray.origin.y / ray.direction.y;     if (t > 0 && t < bestHit.distance) {         bestHit.distance = t;         bestHit.position = ray.origin + t * ray.direction;         bestHit.normal = float3(0.0f, 1.0f, 0.0f);     } }

在使用它之前,我们需要后面添加一个Trace函数框架:

RayHit Trace(Ray ray) {     RayHit bestHit = CreateRayHit();     IntersectGroundPlane(ray, bestHit);     return bestHit; }

此外,我们需要一个基础着色器函数。同样的,还是用inout传递Ray,以便之后涉及反射的时候能修改它。出于调试的目的,如果几何体被命中了则返回法线,否则返回到天空盒的采样代码:

float3 Shade(inout Ray ray, RayHit hit) {     if (hit.distance < 1.#INF) {         //Return the normal         return hit.normal * 0.5f + 0.5f;     }     else {         //Sample the skybox and write it         float theta = acos(ray.direction.y) / -PI;         float phi = atan2(ray.direction.x, -ray.direction.z) / -PI * 0.5f;         return _SkyboxTexture.SampleLevel(sampler_SkyboxTexture, float2(phi, theta), 0).xyz;     } }

我们会在CSMain里用到这两个函数。如果你还没有准备好的话请移除天空盒采样的代码,然后把下面的几行代码添加进去:

    //Trace and shade     RayHit hit = Trace(ray);     float3 result = Shade(ray, hit);     Result[id.xy] = float4(result, 1);

球体

接下来让我们添加球体。线-球相交的数学公式可以在百科上查到。现在有两个候选的光线命中点:入口点,p1-p2,和出口点,p1+p2。我们先检查入口点的情况,当入口点不符合的时候再选择出口点。我们这样定义一个球体,看作是包含了位置信息(xyz)和半径(w)的四维数float4,这里是代码:

void IntersectSphere(Ray ray, inout RayHit bestHit, float4 sphere) {     //Calculate distance along the ray where sphere is intersected     float3 d = ray.origin - sphere.xyz;     float p1 = -dot(ray.direction, d);     float p2sqr = p1 * p1 - dot(d, d) + sphere.w * sphere.w;     if (p2sqr < 0)         return;     float p2 = sqrt(p2sqr);     float t = p1 - p2 > 0 ? p1 - p2 : p1 + p2;     if (t > 0 && t < bestHit.distance) {         bestHit.distance = t;         bestHit.position = ray.origin + t * ray.direction;         bestHit.normal = normalize(bestHit.position - sphere.xyz);     } }

在Trace里调用这个方法即可添加一个球体,比如这样:

    //Add a floating unit sphere     IntersectSphere(ray, bestHit, float4(0, 3.0f, 0, 1.0f));

抗锯齿

目前还存在一个问题就是我们只测试了每个像素的中心,所以你可能会看见一些恼人的混叠效果(锯齿)。为了避免这个情况,我们对每个像素将不再只跟踪一个而是多个光线。每一个光线获得一个在像素区域内的随机的补偿。为了保持能接受的帧率,我们用渐进式的采样,换句话说也就是如果相机不发生移动,我们每帧每像素只最终一个光线并把结果平均化。而只要相机移动(或者其他的参数比如景深、其他集合体或者光照改变)了,我们就从头开始一遍。

那么开始做一个简单的的image effect shader用于我们对几个结果的累加。给shader命名为AddShader,确保第一行写的是“Hidden/AddShader”,后面是Cull off Zwrite off Ztest Always和Blend SrcAlpha OneMinusSrcAlpha,然后,把默认的frag换成下面的代码:

            float _Sample;             fixed4 frag(v2f i) : SV_Target {                 return float4(tex2D(_MainTex,i.uv).rgb,1.0f / (_Sample + 1.0f));             }

这个shader现在可以在第一个采样中绘制透明度为1,下一个采样绘制采样为1/2,然后1/3以此类推,最后对所有的采样进行平均。

在脚本里,我们还需要记下采样数并利用这个新创建的image effect shader:

    private uint _currentSample = 0;     private Material _addMaterial;

当然在渲染目标在初始化函数重建的时候也需要重设_currentSample = 0,所以添加一个Update函数,追踪相机位置的变化:

    private void Update() {         if (transform.hasChanged) {             _currentSample = 0;             transform.hasChanged = false;         }     }

使用这个自定义着色器,需要初始化一个材质,告诉它当前的采样,然后用它填充到屏幕,所以在Render函数中:

        //Bilt the result texture to the screen         if (_addMaterial == null)             _addMaterial = new Material(Shader.Find("Hidden/AddShader"));         _addMaterial.SetFloat("_Sample", _currentSample);         Graphics.Blit(_target, destination, _addMaterial);         _currentSample++;

所以现在可以做到渐进式采样了,不过我现在使用的还是像素的中心。在compute shader里,定义一个float2 _PixelOffset然后再CSMain里替换float2(0.5f,0.5f)补偿。回到脚本,创建一个随机的补偿,如下:

SetShaderParameters:

        RayTracingShader.SetVector("_PixelOffset", new Vector2(Random.value, Random.value));

如果你移动相机,你应该会看见图片产生了锯齿,但如果等待几帧后就会快速的消失。

反射

现在光线追踪的基础工作已经完成了,所以我们可以开始做一些有趣的事。比如完美的反射就是我们待做清单上的第一件。它的思路很简单,每当光线击中了表面,我们将按照以前学过的法则(入射角=反射角)对光线反射,减少一定的能量,然后重复直到光线要么射向天空,要么能量在一次次的反射中消耗殆尽(固定的最大反弹次数)。

在shader里,对Ray结构体添加一个float3 energy,然后在CreateRay函数里像这样初始化:ray.energy = float3(1.0f,1.0f,1.0f)。光线在所有颜色通道上以全通量开始,并随着反射而减少。

现在我们准备执行最大次数为8的追踪(初始光线加7次反射),乘以光线的能量后并汇总Shader函数调用的结果。还是举个例子,想象光线在一次反射中损失3/4的能量,而后它射向了天空,那么我们就只转移1/4的天空的能量到像素。所以,调整我们的CSMain,像这样替换之前的Trace和Shade调用:((注意这里不要误删了Result[id.xy] = float4(result, 1);))

    //Trace and shade

    float3 result = float3(0, 0, 0);     for (int i = 0; i < 8; i++) {         RayHit hit = Trace(ray);         result += ray.energy * Shade(ray, hit);         if (!any(ray.energy))             break;     }

我们的Shade函数现在也将负责更新能量变化和产生反射的光线,所以这里正是使用inout显得尤为重要的地方。为了更新能量的变化,我们在表面的镜面颜色执行对应元素逐个相乘法。举个例子,比如黄金的镜面反射率大约为float3)(1.0f,0.78f,0.34f),所以它会反射100%的红光,78%的绿光和34%的蓝光,从而赋予它所反射出的独有的金色色调。要注意这里任何一个值都不要超过1,因为你这样凭空制造出能量。而且,反射率其实通常都比你想象的要低。

HLSL有内置函数,可以用给出的法线来反射光线,这就很方便。由于浮点数是不准确的,可能会出现使反射的光线被反射的表面阻挡的情况,为了防止这种自遮蔽现象,我们可以沿着法线的方向使位置稍微偏斜一点,下面是新的Shader函数代码:

float3 Shade(inout Ray ray, RayHit hit) {     if (hit.distance < 1.#INF) {         float3 specular = float3(0.6f, 0.6f, 0.6f);         //Reflect the ray and multiply energy with specular reflection;         ray.origin = hit.position + hit.normal * 0.001f;         ray.direction = reflect(ray.direction, hit.normal);         ray.energy *= specular;         //Return nothing         return float3(0.0f, 0.0f, 0.0f);     }     else {         //Erase the ray's energy - the sky doesn't reflect anything         ray.energy = 0.0f;         //Sample the skybox and write it         float theta = acos(ray.direction.y) / -PI;         float phi = atan2(ray.direction.x, -ray.direction.z) / -PI * 0.5f;         return _SkyboxTexture.SampleLevel(sampler_SkyboxTexture, float2(phi, theta), 0).xyz;     } }

现在你可以通过给天空盒乘以一个大于1的数来提高光照的强度,然后在Trace函数中更改你喜欢的数值。比如把球体放在一个循环中,你的结果会像这样:

平行光

所以现在我们可以追踪这种类似的镜面的反射,用于渲染比如金属的光滑表面,但对非金属来说,我们还需要漫反射。简单地说,金属只会反射以高光着色的入射光,而非金属则允许光线折射到表面,散射并以反照率颜色着色向随机的方向离开。通常在假定是理想兰伯特光照模型的情况下,几率于所述方向和表面法线之间角度的余弦成正比。

为了实现漫反射光照,让我们现在RayTracingMaster脚本力添加一段public Light Direction,然后给场景分配一个平行光。如果你还想检测平行光的位置变化,你可以在Update函数里添加,就像之前检测相机那样。然后我们在SetShaderParameters函数里添加下面代码:

        Vector3 l = DirectionalLight.transform.forward;         RayTracingShader.SetVector("_DirectionalLight", new Vector4(l.x, l.y, l.z, DirectionalLight.intensity));

回到shader,在开头定义float4 _DirectionalLight,在Shade函数里定义高光下的反照率:

    float3 albedo = float3(0.8f, 0.8f, 0.8f);

用漫反射着色替换之前注释Return nothing下的返回黑色:

        //Return a diffuse-shaded color         return saturate(dot(hit.normal, _DirectionalLight.xyz) * -1) * _DirectionalLight.w * albedo;

记住这里的点积dot定义为:a·b = ||a|| ||b|| cosθ。由于两个向量(法线和平行光方向)都是单位长度,所以这里的点积实际上就是夹角的余弦值。而光线(相机发射的射线)和平行光指向相反的方向,所以对于正面照明,点积会返回-1而不是1。所以我们需要取反弥补这个结果。最后,将取值饱和处理(钳位在[0,1]范围内)以防止出现负能量。

对于平行光投的阴影,我们将追踪阴影的射线。它从物体的表面开始(同样给一个微笑的位移防止自遮蔽现象),然后指向光线来的方向。如果有任何阻挡了无限远的路径,则不使用漫反射光。然后在diffuse return语句的上方添加一下代码:

        bool shadow = false;         Ray shadowRay = CreateRay(hit.position + hit.normal * 0.0001f, -1 * _DirectionalLight.xyz);         RayHit shadowHit = Trace(shadowRay);         if (shadowHit.distance != 1.#INF) {             return float3(0.0f, 0.0f, 0.0f);         }

现在我们可以追踪到带有硬阴影的光滑塑料材质的球,如果设置specular=0.04和albedo=0.8,你大概会得到这样的图片:

场景和材质

有了这些基础,我们可以创建一些更加丰富多彩的复杂场景。我们不再需要对着色器的内容进行硬编码,而直接使用C#定义的场景提高光线追踪的灵活性。

首先我们要拓展shader里的RayHit结构体。我们不再需要于Shader函数里全局定义材质属性,而是对每个实体定义并储存在RayHit里。所以,在结构体添加float3 albedo和float3 specular,并在CreateRayHit里初始化为float3(0.0f,0.0f,0.0f)。还要更改Shade函数,以匹配来自hit的值而不是硬编码的值。

为了更好地CPU和GPU上的球体,我们先在shader和C#脚本里同时定义结构体Sphere。在shader端,代码是这样的:

struct Sphere {     float3 position;     float radius;     float3 albedo;     float3 specular };

然后在C#脚本里做同样的定义。

在shader里,我们要让IntersectSphere函数使用刚才自定义的结构体而不是float4。下面是示例:

void IntersectSphere(Ray ray, inout RayHit bestHit, Sphere sphere) {     //Calculate distance along the ray where sphere is intersected     float3 d = ray.origin - sphere.position;     float p1 = -dot(ray.direction, d);     float p2sqr = p1 * p1 - dot(d, d) + sphere.radius * sphere.radius;     if (p2sqr < 0)         return;     float p2 = sqrt(p2sqr);     float t = p1 - p2 > 0 ? p1 - p2 : p1 + p2;     if (t > 0 && t < bestHit.distance) {         bestHit.distance = t;         bestHit.position = ray.origin + t * ray.direction;         bestHit.normal = normalize(bestHit.position - sphere.position);         bestHit.albedo = sphere.albedo;         bestHit.specular = sphere.specular;     } }

同样的,在IntersectGroundPlane函数里设置bestHit.albedo和bestHit.specular用于调整材质。

然后定义StructuredBuffer<Sphere> _Spheres。这里是CPU储存构成场景的所有球体的信息的地方。移除Trace函数里所有关于球体的硬编码,然后替换为下面的代码:

    //Trace spheres     uint numSpheres, stride;     _Spheres.GetDimensions(numSpheres, stride);     for (uint i = 0; i < numSpheres; i++)         IntersectSphere(ray, bestHit, _Spheres[i]);

回到C#脚本,添加一些公共参数控制球体的放置和实际的计算缓存区:

    public Vector2 SphereRadius = new Vector2(3.0f, 8.0f );     public uint SphereSMax = 100;     public float SpherePlacementRadius = 100.0f;     private ComputeBuffer _sphereBuffer;

在OnEnable周期设置场景,然后再OnDisable周期释放缓存,如此每当你激活组件,随机的场景就会被生成。SetUpScene函数可以使球体生成在一个确定的半径内,且防止生成相交的球体。一ban的球体是金属的(黑色的反照率,彩色的高光),另一半则是非金属(彩色的反照率,4%的高光),代码如下:

    private void OnEnable() {         _currentSample = 0;         SetUpScene();     }     private void OnDisable() {         if (_sphereBuffer != null) {             _sphereBuffer.Release();         }     }

    private void SetUpScene() {         List<Sphere> spheres = new List<Sphere>();         //Add a number of random spheres         for (int i = 0; i < SphereSMax; i++) {             Sphere sphere = new Sphere();             //Radius and radius             sphere.radius = SphereRadius.x + Random.value * (SphereRadius.y - SphereRadius.x);             Vector2 randomPos = Random.insideUnitCircle * SpherePlacementRadius;             sphere.position = new Vector3(randomPos.x, sphere.radius, randomPos.y);             //Reject spheres that are intersecting others             foreach (Sphere other in spheres) {                 float minDst = sphere.radius + other.radius;                 if (Vector3.SqrMagnitude(sphere.position - other.position) < minDst * minDst) {                     goto SkipSphere;                 }             }             Color color = Random.ColorHSV();             bool metal = Random.value < 0.5f;             sphere.albedo = metal ? Vector3.zero : new Vector3(color.r, color.g, color.b);             sphere.specular = metal ? new Vector3(color.r, color.g, color.b) : Vector3.one * 0.04f;             //Add the sphere to the list             spheres.Add(sphere);             SkipSphere: continue;         }         //Assign to compute buffer         _sphereBuffer = new ComputeBuffer(spheres.Count, 40);         _sphereBuffer.SetData(spheres);     }

你也许会奇怪new ComputeBuffer(spheres.Count, 40);中的40这个数字,它其实是缓存区中一个元素的大小,比如说内存中一个球体的字节大小,为Sphere结构体中浮点数的数量乘以浮点数的字节大小,即(3+3+3+1)*4=40。最后,我们在SetShaderParameters函数里设置缓存区:

        RayTracingShader.SetBuffer(0, "_Spheres", _sphereBuffer);

结果

如果你坚持到了这一步,那么恭喜!你现在已经拥有一个GPU驱动的,能够通过镜面反射渲染一个平面和多个球体并实现简单的漫反射和硬阴影的Whitted光照追踪模型了。如果没有差错,你最后可以得到这样的图像:

不过,目前的结果其实还有许多瑕疵等待我们解决,比如全局的漫反射照明、光滑反射、柔和阴影、非透明材料的反射等等。在下一篇文章中,我们会解决这些问题,并用三角形网格模型取代球体。