[
第一部分和
第二部分。]
今天我们将取得重大飞跃。 我们将摆脱先前球形的结构和之前追踪的无限平面,并添加三角形-现代计算机图形的全部实质,这是所有虚拟世界所组成的元素。 如果您想继续上次完成的工作,请使用第
2部分中的
代码 。 我们今天将要完成的代码可以
在这里找到 。 让我们开始吧!
三角形
三角形只是三个相连
顶点的列表,每个
顶点存储自己的位置,有时还存储法线。 从您的角度来看,顶点的遍历顺序决定了我们正在看的东西-三角形的正面或背面。 传统上,“前”被视为逆时针旁路顺序。
首先,我们需要能够确定射线是否与三角形相交,如果相交,则在哪一点。 先生
Thomas Akenin-Meller和Ben Trembor于1997年提出了一种非常流行(但绝对
不是唯一的一种 )的方法来确定射线与三角形的交点。 您可以在此处的文章“快速,最小存储射线三角形交叉点”中了解更多
信息 。
本文中的代码可以轻松地移植到HLSL着色器代码中:
static const float EPSILON = 1e-8; bool IntersectTriangle_MT97(Ray ray, float3 vert0, float3 vert1, float3 vert2, inout float t, inout float u, inout float v) {
要使用此功能,我们需要一条光线和一个三角形的三个顶点。 返回值告诉我们三角形是否相交。 在相交的情况下,会计算出三个附加值:
t
描述了沿光束到相交点的距离,而
u
/
v
是三个重心坐标中的两个,它们确定了相交点在三角形上的位置(最后一个坐标可以计算为
w = 1 - u - v
)。 如果您还不熟悉重心坐标,请阅读有关
Scratchapixel的出色说明。
没有太多延迟,让我们用代码中指示的顶点跟踪一个三角形! 在着色器中找到
Trace
函数,并向其中添加以下代码片段:
就像我说的,
t
存储沿光束的距离,我们可以直接使用该值来计算交点。 法线对于计算正确的反射非常重要,可以使用三角形任意两个边的矢量积来计算。 启动游戏模式并欣赏您的第一个三角形:
练习:尝试使用重心坐标而不是距离来计算位置。 如果一切正确,那么闪亮的三角形将看起来像以前一样。
三角形网格
我们克服了第一个障碍,但是从三角形追踪整个网格是完全不同的故事。 首先,我们需要学习一些有关网格的基本信息。 如果您知道它们,则可以安全地跳过下一段。
在计算机图形学中,网格由几个缓冲区定义,其中最重要的是
顶点和
索引缓冲区。
顶点缓冲区是描述
对象空间中每个顶点位置的3D向量列表(这意味着在移动,旋转或缩放对象时不需要更改这些值-使用矩阵乘法将它们从
对象空间动态转换为
世界空间 ) 。
索引缓冲区是整数值的列表,这些整数值是指向顶点缓冲区的
索引 。 每三个索引组成一个三角形。 例如,如果索引缓冲区的格式为[0,1,2,0,2,3],则它具有两个三角形:第一个三角形由顶点缓冲区中的第一个,第二个和第三个顶点组成,第二个三角形由第一个,第三个三角形组成和第四座山峰。 因此,索引缓冲器还确定上述遍历顺序。 除了顶点缓冲区和索引之外,可能还有其他缓冲区可将其他信息添加到每个顶点。 最常见的附加缓冲区存储
法线 ,
纹理坐标 (称为
texcoords或简称为
UV )以及
顶点颜色 。
使用游戏对象
首先,我们需要找出哪些GameObjects应该成为光线追踪过程的一部分。 一个简单的解决方案是只使用
FindObjectOfType<MeshRenderer>()
,但是要做得更灵活,更快。 让我们添加一个新的
RayTracingObject
组件:
using UnityEngine; [RequireComponent(typeof(MeshRenderer))] [RequireComponent(typeof(MeshFilter))] public class RayTracingObject : MonoBehaviour { private void OnEnable() { RayTracingMaster.RegisterObject(this); } private void OnDisable() { RayTracingMaster.UnregisterObject(this); } }
该组件被添加到我们要用于光线跟踪的每个对象中,并使用
RayTracingMaster
进行其注册。 在向导中添加以下功能:
private static bool _meshObjectsNeedRebuilding = false; private static List<RayTracingObject> _rayTracingObjects = new List<RayTracingObject>(); public static void RegisterObject(RayTracingObject obj) { _rayTracingObjects.Add(obj); _meshObjectsNeedRebuilding = true; } public static void UnregisterObject(RayTracingObject obj) { _rayTracingObjects.Remove(obj); _meshObjectsNeedRebuilding = true; }
一切进展顺利-现在我们知道需要跟踪哪些对象。 但是随之而来的是困难的部分:我们将要从Unity网格(矩阵,顶点缓冲区和索引-记住它们吗?)收集所有数据,将它们写入我们自己的数据结构并将其加载到GPU中,以便着色器可以使用它们。 让我们首先在向导的C#端定义数据结构和缓冲区:
struct MeshObject { public Matrix4x4 localToWorldMatrix; public int indices_offset; public int indices_count; } private static List<MeshObject> _meshObjects = new List<MeshObject>(); private static List<Vector3> _vertices = new List<Vector3>(); private static List<int> _indices = new List<int>(); private ComputeBuffer _meshObjectBuffer; private ComputeBuffer _vertexBuffer; private ComputeBuffer _indexBuffer;
...,现在让我们在着色器中执行相同的操作。 你习惯了吗?
struct MeshObject { float4x4 localToWorldMatrix; int indices_offset; int indices_count; }; StructuredBuffer<MeshObject> _MeshObjects; StructuredBuffer<float3> _Vertices; StructuredBuffer<int> _Indices;
数据结构已经准备就绪,我们可以用真实数据填充它们。 我们将所有网格的所有顶点收集到一个大
List<Vector3>
,并将所有索引收集到一个大
List<int>
。 顶点没有问题,但是索引需要更改,以便它们继续指向我们大缓冲区中的正确顶点。 想象一下,我们已经添加了1000个顶点中的对象,现在我们添加了一个简单的网格立方体。 第一个三角形可能由索引[0,1,2]组成,但是由于缓冲区中已经有1000个顶点,因此需要在将顶点添加到多维数据集之前移动索引。 也就是说,它们将变成[1000,1001,1002]。 这是代码中的样子:
private void RebuildMeshObjectBuffers() { if (!_meshObjectsNeedRebuilding) { return; } _meshObjectsNeedRebuilding = false; _currentSample = 0;
我们在
OnRenderImage
函数中调用
RebuildMeshObjectBuffers
,不要忘记在
OnDisable
释放新的缓冲区。 这是我在上面的代码中使用的两个帮助函数,用于简化缓冲区处理:
private static void CreateComputeBuffer<T>(ref ComputeBuffer buffer, List<T> data, int stride) where T : struct {
太好了,我们创建了缓冲区,并且缓冲区中填充了必要的数据! 现在我们只需要向着色器报告即可。 将以下代码添加到
SetShaderParameters
(由于有了新的帮助器功能,我们可以减少球形缓冲区的代码):
SetComputeBuffer("_Spheres", _sphereBuffer); SetComputeBuffer("_MeshObjects", _meshObjectBuffer); SetComputeBuffer("_Vertices", _vertexBuffer); SetComputeBuffer("_Indices", _indexBuffer);
因此,这项工作很无聊,但让我们看看我们刚刚做了什么:我们收集了网格的所有内部数据(矩阵,顶点和索引),将它们放置在方便,简单的结构中,然后将它们发送到GPU,现在期待着何时他们可以使用。
网格追踪
我们不要让他等待。 在着色器中,我们已经具有单个三角形的跟踪代码,并且实际上,网格只是很多三角形。 这里唯一的新方面是,我们使用矩阵通过内置的
mul
函数(乘法的缩写)将顶点从对象空间转换为世界空间。 矩阵包含对象的平移,旋转和比例。 它的大小为4×4,因此要进行乘法运算,我们需要一个4d向量。 前三个分量(x,y,z)来自顶点缓冲区。 我们将第四个分量(w)设置为1,因为我们正在处理一个点。 如果这是方向,那么我们将在其中写入0以忽略矩阵中的所有平移和缩放。 这让您感到困惑吗? 然后至少阅读
本教程八次。 这是着色器代码:
void IntersectMeshObject(Ray ray, inout RayHit bestHit, MeshObject meshObject) { uint offset = meshObject.indices_offset; uint count = offset + meshObject.indices_count; for (uint i = offset; i < count; i += 3) { float3 v0 = (mul(meshObject.localToWorldMatrix, float4(_Vertices[_Indices[i]], 1))).xyz; float3 v1 = (mul(meshObject.localToWorldMatrix, float4(_Vertices[_Indices[i + 1]], 1))).xyz; float3 v2 = (mul(meshObject.localToWorldMatrix, float4(_Vertices[_Indices[i + 2]], 1))).xyz; float t, u, v; if (IntersectTriangle_MT97(ray, v0, v1, v2, t, u, v)) { if (t > 0 && t < bestHit.distance) { bestHit.distance = t; bestHit.position = ray.origin + t * ray.direction; bestHit.normal = normalize(cross(v1 - v0, v2 - v0)); bestHit.albedo = 0.0f; bestHit.specular = 0.65f; bestHit.smoothness = 0.99f; bestHit.emission = 0.0f; } } } }
我们距离看到一切都只有一步之遥。 让我们重新
Trace
函数,并添加一个网格物体的轨迹:
RayHit Trace(Ray ray) { RayHit bestHit = CreateRayHit(); uint count, stride, i;
结果
仅此而已! 让我们添加一些简单的网格物体(Unity基本体很好),为它们提供
RayTracingObject
组件并观察魔术。
请勿使用详细的网格(超过数百个三角形)! 我们的着色器缺乏优化,如果您过度优化,则可能需要几秒钟甚至几分钟来跟踪每个像素至少一个样本。 结果,系统将停止GPU驱动程序,Unity引擎可能崩溃,并且计算机将需要重新启动。
请注意,我们的网格没有平滑但平坦的阴影。 由于尚未将顶点的法线加载到缓冲区中,因此要获得每个三角形的顶点的法线,我们必须执行矢量积。 此外,我们无法在三角形的区域内插值。 我们将在本教程的下一部分中解决这个问题。
出于兴趣的考虑,我从
Morgan McGwire档案库中下载了Stanford Bunny,并使用
Blender程序包的十进制修改器将顶点数减少到431。您可以在
IntersectMeshObject
着色器函数中尝试使用照明参数和硬编码的材质。 这是
Grafitti Shelter的介电兔子,上面有美丽的柔和阴影和稍微分散的全局照明:
...这是一只在
开普敦山强烈的定向光下的金属兔子,将迪斯科眩光投射到地板上:
...这是两只小兔子藏在蓝天下的
Kiara 9黄昏下的大石头Suzanne下(我为第二个对象指定了替代材料,检查索引偏移是否为零):
接下来是什么?
真是第一次在自己的示踪剂中看到真实的网格物体,对吗? 今天,我们处理了一些数据,使用Meller-Trambor算法找到了相交,并收集了所有数据,以便我们可以立即使用Unity引擎的GameObjects引擎。 另外,我们看到了光线跟踪的优点之一:在代码中添加新的交集后,所有漂亮的效果(柔和的阴影,反射和漫反射的全局照明等等)立即开始起作用。
渲染有光泽的兔子会花费很多时间,而且我仍然不得不使用一些过滤来消除最明显的噪音。 为了解决该问题,通常将场景写成空间结构,例如网格,K维树或包围体积的层次结构,这大大提高了渲染大型场景的速度。
但是我们需要按顺序移动:进一步,我们将消除法线问题,以便我们的网格(甚至是低多边形网格)看起来比现在更平滑。 在移动对象并直接引用Unity材质时自动更新矩阵,而不仅仅是在代码中编写矩阵,这也很好。 这是我们在教程系列的下一部分中将要做的。 感谢您的阅读,并在第四部分见!