光线追踪变得更加精炼
Diligent Engine 中光线追踪 API 的介绍。
背景
本文假定读者具备 Direct3D11、Direct3D12、OpenGL、Vulkan、Metal 或任何其他图形 API 的基础知识。
引言
硬件加速光线追踪是近年来次世代 API(Direct3D12 和 Vulkan)最具创新性的新增功能之一。它解锁了大量在传统光栅化管线中无法实现或实现起来非常棘手的算法。然而,Direct3D12 和 Vulkan 中的光线追踪 API 都相当复杂,并不总是易于理解。
Diligent Engine 是一个现代化的跨平台底层图形库和渲染框架,支持包括 Direct3D12 和 Vulkan 在内的多种渲染后端。有关该项目的介绍,请参阅这篇文章。在最近的一次发布中,Diligent Engine 通过一个通用、易用且功能完备的 API 启用了对硬件加速光线追踪的支持。用 HLSL 编写的光线追踪着色器无需任何特殊技巧或修改即可在两个后端中工作。用 GLSL 编写的着色器以及已编译的 SPIRV 字节码也可以在 Vulkan 后端使用。
本文通过一个模拟场景中基于物理的光线传输以渲染软阴影、多次反射和折射以及色散的简单应用程序,介绍了 Diligent Engine 中的光线追踪 API。
光线追踪 vs 光栅化
在传统的渲染管线中,三角形经过一系列可编程和固定功能阶段的处理,最终被投影并光栅化到规则的像素网格上。最终颜色由像素着色器和一系列可选的混合操作形成。这是一种非常高效且高性能的方法,但性能的代价是存在一些限制。首先,像素着色器只能为预定义的采样位置调用(这使得 GPU 能够非常高效地并行化执行)。其次,GPU 无法访问整个场景,只有相机可见的三角形才会被处理。
光线追踪消除了这些限制。与光栅化不同,它允许应用程序通过在任意方向投射光线,并在交点处运行指定的着色器来查询任意位置的场景属性。
加速结构
与光栅化不同,在光栅化中,对象不需要任何预处理就可以直接投入管线,而在光线追踪中,情况要复杂一些。由于光线可以向任何方向投射,GPU 必须有一种高效的方式来计算光线与整个场景的交点。这种方式由加速结构提供,其内部包含某种形式的包围体层次结构。
光线追踪 API 中有两种类型的加速结构(AS):底层加速结构(BLAS)和顶层加速结构(TLAS)。底层加速结构(BLAS)是实际几何体所在的位置。顶层加速结构是一组对 BLAS 的引用。一个 TLAS 可以引用同一个 BLAS 的多个具有不同变换的实例。构建或更新 BLAS 的成本比 TLAS 更高。这种两级结构是在运行时更新 AS 的能力和光线追踪效率之间的一种权衡。例如,可以通过更新 TLAS 中的实例变换来实现对象动画,而无需重建代表动画对象的 BLAS。
底层加速结构
BLAS 可以包含两种类型的几何体:三角形几何体或程序化几何体。三角形几何体由一组常规的顶点和索引表示。程序化几何体要求应用程序定义一种特殊类型的着色器,来确定光线如何与对象相交。该着色器可以实现任何自定义算法,但比内置的光线-三角形相交测试成本更高。
单个 BLAS 只能包含一种类型的几何体:要么是三角形,要么是定义基本程序化对象形状的轴对齐包围盒(AABB)。
在本例中,我们将使用两种类型的对象:一个立方体和一个球体。立方体将由三角形几何体定义,而球体将定义为程序化几何体。立方体数据将通过一个统一缓冲区(而不是传统的顶点/索引缓冲区)来访问,以便最近命中着色器可以读取任何图元的三角形属性(位置、法线、UV)。
对于我们的立方体 BLAS,我们指定了一个包含 24 个顶点和 12 个图元的单一三角形几何体。该 BLAS 将分配足以容纳此几何体描述的空间。
const float3 CubePos[24] = /* ... */;
const uint Indices[36] = /* ... */;
BLASTriangleDesc Triangles;
Triangles.GeometryName = "Cube";
Triangles.MaxVertexCount = _countof(CubePos);
Triangles.VertexValueType = VT_FLOAT32;
Triangles.VertexComponentCount = 3;
Triangles.MaxPrimitiveCount = _countof(Indices) / 3;
Triangles.IndexType = VT_UINT32;
BottomLevelASDesc ASDesc;
ASDesc.Name = "Cube BLAS";
ASDesc.Flags = RAYTRACING_BUILD_AS_PREFER_FAST_TRACE;
ASDesc.pTriangles = &Triangles;
ASDesc.TriangleCount = 1;
m_pDevice->CreateBLAS(ASDesc, &m_pCubeBLAS);
请注意,在此示例中,GeometryName
成员除了在 BLAS 构建时使用外,没有在其他任何地方使用,但在其他情况下,几何体名称可用于通过 BLAS 更新操作来更改几何体数据。几何体名称也可能在着色器绑定表中使用,如下所述。
现在立方体 BLAS 已经创建,但不包含任何数据:我们需要对其进行初始化。为此,我们需要创建常规的顶点和索引缓冲区,唯一的区别是我们将使用 BIND_RAY_TRACING
标志,以允许在 BLAS 构建操作期间访问这些缓冲区。所有在 BLAS 或 TLAS 构建命令中使用的缓冲区都必须使用 BIND_RAY_TRACING
标志创建。GPU 需要一些暂存空间来执行构建操作并保存临时数据。必须将暂存缓冲区提供给 BuildBLAS
命令。
调用 m_pCubeBLAS->GetScratchBufferSizes()
来获取最小的缓冲区大小。
BLASBuildTriangleData TriangleData;
TriangleData.GeometryName = Triangles.GeometryName;
TriangleData.pVertexBuffer = pCubeVertexBuffer;
TriangleData.VertexStride = sizeof(CubePos[0]);
TriangleData.VertexCount = Triangles.MaxVertexCount;
TriangleData.VertexValueType = Triangles.VertexValueType;
TriangleData.VertexComponentCount = Triangles.VertexComponentCount;
TriangleData.pIndexBuffer = pCubeIndexBuffer;
TriangleData.PrimitiveCount = Triangles.MaxPrimitiveCount;
TriangleData.IndexType = Triangles.IndexType;
TriangleData.Flags = RAYTRACING_GEOMETRY_FLAG_OPAQUE;
BuildBLASAttribs Attribs;
Attribs.pBLAS = m_pCubeBLAS;
Attribs.pTriangleData = &TriangleData;
Attribs.TriangleDataCount = 1;
Attribs.pScratchBuffer = pScratchBuffer;
m_pImmediateContext->BuildBLAS(Attribs);
请注意,BLASBuildTriangleData
struct
实例的 GeometryName
成员必须与 BLASTriangleDesc
中使用的几何体名称相匹配。当 BLAS 包含多个几何体时,这就是三角形数据映射到 BLAS 中特定几何体的方式。
为程序化球体创建 BLAS 的过程与此类似。
顶层加速结构
顶层加速结构代表整个场景,由多个 BLAS 实例组成。
要创建 TLAS,我们只需要指定它将包含的实例数量。
TopLevelASDesc TLASDesc;
TLASDesc.Name = "TLAS";
TLASDesc.MaxInstanceCount = NumInstances;
TLASDesc.Flags = RAYTRACING_BUILD_AS_ALLOW_UPDATE |
RAYTRACING_BUILD_AS_PREFER_FAST_TRACE;
m_pDevice->CreateTLAS(TLASDesc, &m_pTLAS);
附加标志告诉系统应用程序将如何使用该结构。
RAYTRACING_BUILD_AS_ALLOW_UPDATE
标志允许 TLAS 在创建后使用不同的实例变换进行更新。RAYTRACING_BUILD_AS_PREFER_FAST_TRACE
标志告诉 GPU 进行一些优化以提高光线追踪效率,代价是增加构建时间。
与 BLAS 类似,新创建的 TLAS 不包含数据,需要进行构建。要构建 TLAS,我们需要准备一个 TLASBuildInstanceData
结构数组,其中每个元素将包含实例数据。
Instances[0].InstanceName = "Cube Instance 1";
Instances[0].CustomId = 0; // texture index
Instances[0].pBLAS = m_pCubeBLAS;
Instances[0].Mask = OPAQUE_GEOM_MASK;
Instances[1].InstanceName = "Cube Instance 2";
Instances[1].CustomId = 1; // texture index
Instances[1].pBLAS = m_pCubeBLAS;
Instances[1].Mask = OPAQUE_GEOM_MASK;
AnimateOpaqueCube(Instances[1]);
...
Instances[5].InstanceName = "Sphere Instance";
Instances[5].CustomId = 0; // box index
Instances[5].pBLAS = m_pProceduralBLAS;
Instances[5].Mask = OPAQUE_GEOM_MASK;
Instances[6].InstanceName = "Glass Instance";
Instances[6].pBLAS = m_pCubeBLAS;
Instances[6].Mask = TRANSPARENT_GEOM_MASK;
InstanceName
成员用于 TLAS 更新操作,以将实例数据与先前的实例状态进行匹配,也用于着色器绑定表中,以将着色器命中组绑定到实例。
命中着色器可以通过 InstanceIndex()
函数查询实例在数组中的索引。CustomId
成员由用户指定,并通过 InstanceID()
函数传递给命中着色器。
CustomId
可用于为具有相同几何体的每个实例应用不同的材质。
Mask
可用于对实例进行分组,并仅针对选定的组追踪光线(例如,阴影光线 vs 主光线)。
对于每个实例,我们可以指定一个包含旋转和平移的变换矩阵,例如:
Instances[6].Transform.SetTranslation(4.0f, 4.5f, -7.0f);
在 TLAS 更新操作期间更新实例变换比用顶点变换更新 BLAS 或使用变换缓冲区要快得多。
要构建/更新 TLAS,我们需要准备一个 BuildTLASAttribs
struct
的实例。
BuildTLASAttribs Attribs;
Attribs.HitGroupStride = HIT_GROUP_STRIDE;
Attribs.BindingMode = HIT_GROUP_BINDING_MODE_PER_INSTANCE;
HitGroupStride
是不同光线类型的数量。在此示例中,我们使用两种光线类型:主光线 (primary) 和阴影光线 (shadow)。您可以添加更多光线类型,例如,为反射光线使用简化命中着色器的次级光线。
BindingMode
是命中组位置的计算模式。在我们的示例中,我们将为不同的实例分配不同的命中组,因此我们使用 HIT_GROUP_BINDING_MODE_PER_INSTANCE
模式。如果应用程序需要更多控制,它可以使用 HIT_GROUP_BINDING_MODE_PER_GEOMETRY
模式为每个实例中的每个几何体分配单独的命中组。另一方面,它也可以使用 HIT_GROUP_BINDING_MODE_PER_TLAS
模式为所有实例中的所有几何体分配相同的命中组。
实际的 TLAS 实例数据存储在实例缓冲区中。每个实例所需的固定大小由 TLAS_INSTANCE_DATA_SIZE
常量(64 字节)给出。
与 BLAS 构建操作类似,GPU 需要一个暂存缓冲区来保存临时数据。
构建和更新所需的暂存缓冲区大小由 m_pTLAS->GetScratchBufferSizes()
方法给出。
Attribs.pInstances = Instances;
Attribs.InstanceCount = _countof(Instances);
Attribs.pInstanceBuffer = m_InstanceBuffer;
Attribs.pScratchBuffer = m_ScratchBuffer;
Attribs.pTLAS = m_pTLAS;
m_pImmediateContext->BuildTLAS(Attribs);
光线追踪管线状态
光线追踪管线状态对象比图形或计算管线更复杂,因为在一个着色器阶段中可能存在多个相同类型的着色器。这是必需的,以便 GPU 在命中不同对象时可以运行不同的着色器。
与其他管线类型类似,我们首先创建光线追踪管线将使用的所有着色器。Diligent Engine 允许在 D3D12 和 Vulkan 后端同时使用 HLSL。支持光线追踪的最低 HLSL 着色器模型是 6.3。只有新的 DirectX 编译器(DXC)支持着色器模型 6.0+,我们需要明确指定它。
ShaderCI.ShaderCompiler = SHADER_COMPILER_DXC;
ShaderCI.HLSLVersion = {6, 3};
ShaderCI.SourceLanguage = SHADER_SOURCE_LANGUAGE_HLSL;
要创建一个光线追踪 PSO,我们需要定义一个 RayTracingPipelineStateCreateInfo
struct
的实例。
RayTracingPipelineStateCreateInfo PSOCreateInfo;
PSOCreateInfo.PSODesc.PipelineType = PIPELINE_TYPE_RAY_TRACING;
光线追踪管线的主要组成部分是一组着色器组。
有三种组类型:
- 通用着色器组,包含单个光线生成、光线未命中或可调用着色器。
- 三角形命中组,包含一个最近命中着色器和一个可选的任意命中着色器。
- 程序化命中组,包含一个相交着色器和可选的最近命中和任意命中着色器。
我们的光线追踪管线将包含以下命中组:
const RayTracingGeneralShaderGroup GeneralShaders[] =
{
{"Main", pRayGen},
{"PrimaryMiss", pPrimaryMiss},
{"ShadowMiss", pShadowMiss}
};
const RayTracingTriangleHitShaderGroup TriangleHitShaders[] =
{
{"CubePrimaryHit", pCubePrimaryHit},
{"GroundHit", pGroundHit},
{"GlassPrimaryHit", pGlassPrimaryHit}
};
const RayTracingProceduralHitShaderGroup ProceduralHitShaders[] =
{
{"SpherePrimaryHit", pSphereIntersection, pSpherePrimaryHit},
{"SphereShadowHit", pSphereIntersection}
};
PSOCreateInfo.pGeneralShaders = GeneralShaders;
PSOCreateInfo.GeneralShaderCount = _countof(GeneralShaders);
PSOCreateInfo.pTriangleHitShaders = TriangleHitShaders;
PSOCreateInfo.TriangleHitShaderCount = _countof(TriangleHitShaders);
PSOCreateInfo.pProceduralHitShaders = ProceduralHitShaders;
PSOCreateInfo.ProceduralHitShaderCount = _countof(ProceduralHitShaders);
除了命中组,我们还必须定义一些额外的字段。
PSOCreateInfo.RayTracingPipeline.MaxRecursionDepth = MaxRecursionDepth;
PSOCreateInfo.RayTracingPipeline.ShaderRecordSize = 0;
PSOCreateInfo.MaxAttributeSize =
max(sizeof(/*BuiltInTriangleIntersectionAttributes*/float2),
sizeof(ProceduralGeomIntersectionAttribs));
PSOCreateInfo.MaxPayloadSize = max(sizeof(PrimaryRayPayload), sizeof(ShadowRayPayload));
MaxRecursionDepth
指定了应用程序在光线追踪着色器中可以进行递归调用的次数。零表示只能执行光线生成着色器。1 表示光线生成着色器可以产生光线,但这些光线不允许再生成其他光线。2 表示由光线生成着色器产生的光线的最近命中着色器也将被允许生成次级光线,依此类推。
MaxRecursionDepth
字段用于驱动程序分配所需的堆栈大小。应用程序应手动控制递归深度,且不得超过指定的限制,以避免驱动程序崩溃。该字段允许的最大值为 31。
ShaderRecordSize
可用于为每个着色器、实例或几何体指定常量,支持的最大大小为 4096 字节减去着色器句柄大小(32 字节)。
MaxAttributeSize
和 MaxPayloadSize
仅在 DirectX 12 后端使用。这些值应尽可能小,以最小化内存使用。
当一切准备就绪后,我们调用 CreateRayTracingPipelineState
来创建 PSO。
m_pDevice->CreateRayTracingPipelineState(PSOCreateInfo, &m_pRayTracingPSO);
着色器绑定表
光线追踪设置的另一个关键组成部分是着色器绑定表(SBT),它用于将 TLAS 中的实例与光线追踪 PSO 中的着色器连接起来,当光线击中特定实例时,这些着色器将被执行。SBT 必须使用将与追踪光线命令一起使用的同一管线来创建。
要创建 SBT,我们定义一个 ShaderBindingTableDesc
结构实例,并调用 CreateSBT()
设备方法。
ShaderBindingTableDesc SBTDesc;
SBTDesc.Name = "SBT";
SBTDesc.pPSO = m_pRayTracingPSO;
m_pDevice->CreateSBT(SBTDesc, &m_pSBT);
SBT 创建后,我们开始进行着色器关联。第一个需要绑定的着色器是光线生成着色器,它是光线追踪管线的入口点,与计算着色器非常相似。
m_pSBT->BindRayGenShader("Main");
请注意,"Main"
是我们在光线追踪 PSO 中使用的包含光线生成着色器的通用着色器组的名称。
接下来,我们绑定未命中着色器。如果光线没有击中任何对象,就会执行未命中着色器。我们对主光线和阴影光线使用不同的行为,因此有两个未命中着色器。
m_pSBT->BindMissShader("PrimaryMiss", PRIMARY_RAY_INDEX);
m_pSBT->BindMissShader("ShadowMiss", SHADOW_RAY_INDEX );
同样,"PrimaryMiss"
和 "ShadowMiss"
是我们在创建 PSO 时与未命中着色器关联的名称。BindMissShader
函数的第二个参数是未命中着色器索引:0 用于主光线,1 用于阴影光线。
接下来,我们为不同的 TLAS 实例定义一组命中着色器。
m_pSBT->BindHitGroupForInstance
(m_pTLAS, "Cube Instance 1", PRIMARY_RAY_INDEX, "CubePrimaryHit" );
m_pSBT->BindHitGroupForInstance
(m_pTLAS, "Cube Instance 2", PRIMARY_RAY_INDEX, "CubePrimaryHit" );
m_pSBT->BindHitGroupForInstance
(m_pTLAS, "Cube Instance 3", PRIMARY_RAY_INDEX, "CubePrimaryHit" );
m_pSBT->BindHitGroupForInstance
(m_pTLAS, "Cube Instance 4", PRIMARY_RAY_INDEX, "CubePrimaryHit" );
m_pSBT->BindHitGroupForInstance
(m_pTLAS, "Ground Instance", PRIMARY_RAY_INDEX, "GroundHit" );
m_pSBT->BindHitGroupForInstance
(m_pTLAS, "Glass Instance", PRIMARY_RAY_INDEX, "GlassPrimaryHit" );
m_pSBT->BindHitGroupForInstance
(m_pTLAS, "Sphere Instance", PRIMARY_RAY_INDEX, "SpherePrimaryHit");
BindHitGroupForInstance()
方法的第一个参数是包含该实例的 TLAS 对象。第二个参数是该命中组将要绑定的实例名称。这些名称必须与我们创建 TLAS 时为实例指定的名称相匹配。第三个参数是着色器绑定表中的光线类型(也称为光线偏移)。上面的命中组是为主光线定义的,所以我们使用 PRIMARY_RAY_INDEX
类型。最后一个参数是在管线初始化期间在 TriangleHitShaders
数组中定义的命中组名称。
对于阴影光线,我们通过使用 nullptr
来禁用所有命中着色器的调用。我们使用 BindHitGroupForTLAS
方法一次性为所有实例的阴影光线类型绑定空着色器。
m_pSBT->BindHitGroupForTLAS(m_pTLAS, SHADOW_RAY_INDEX, nullptr);
然而,程序化球体需要一些特殊处理:我们需要提供相交着色器,以便 GPU 知道如何与我们的程序化对象进行光线相交。最近命中着色器不是必需的,所以我们将使用只包含相交着色器的 "SphereShadowHit"
命中组。
m_pSBT->BindHitGroupForInstance
(m_pTLAS, "Sphere Instance", SHADOW_RAY_INDEX, "SphereShadowHit");
请注意,TLAS 可以使用 BindingMode = HIT_GROUP_BINDING_MODE_PER_GEOMETRY
标志创建,在这种情况下,可以为每个实例中的每个几何体单独指定命中组,例如:
m_pSBT->BindHitGroupForGeometry(m_pTLAS, "Cube Instance 1",
"Cube", PRIMARY_RAY_INDEX, "CubePrimaryHit");
最终的 SBT 将包含以下数据:
实例 | Geometry | 光线类型 | Location | 着色器组 |
立方体实例 1 | 立方体 | 主光线 | 0 | CubePrimaryHit |
阴影光线 | 1 | empty | ||
立方体实例 2 | 立方体 | 主光线 | 2 | CubePrimaryHit |
阴影光线 | 3 | empty | ||
立方体实例 3 | 立方体 | 主光线 | 4 | CubePrimaryHit |
阴影光线 | 5 | empty | ||
立方体实例 4 | 立方体 | 主光线 | 6 | CubePrimaryHit |
阴影光线 | 7 | empty | ||
地面实例 | 立方体 | 主光线 | 8 | GroundHit |
阴影光线 | 9 | empty | ||
玻璃实例 | 立方体 | 主光线 | 10 | GlassPrimaryHit |
阴影光线 | 11 | empty | ||
球体实例 | Box | 主光线 | 12 | SpherePrimaryHit |
阴影光线 | 13 | SphereShadowHit |
着色器组(以及我们示例中没有的着色器常量)是实际存储在 SBT 中的内容,表中的其他字段用于计算数据位置。
资源绑定
与其他管线不同,在光线追踪管线中,我们使用多个相同类型的着色器。在相同类型的着色器中具有相同名称的资源被视为同一资源。
它们必须被相同地定义,例如:
// in closest_hit_shader1:
// OK - shader register is ignored and will be remapped
ConstantBuffer<CubeAttribs> g_CubeAttribsCB : register(b0);
// in closest_hit_shader2:
// OK - shader register is ignored and will be remapped
ConstantBuffer<CubeAttribs> g_CubeAttribsCB : register(b1);
请注意,由编译器或着色器开发者分配的着色器寄存器将被忽略,并由引擎重新映射。
在同一着色器阶段的不同着色器中,拥有相同名称但不同资源类型是错误的,例如:
// in closest_hit_shader1:
ConstantBuffer<CubeAttribs> g_CubeAttribs;
// in closest_hit_shader2:
// ERROR - g_CubeAttribs is already defined as constant buffer
// in another closest hit shader
StructuredBuffer<CubeAttribs> g_CubeAttribs;
要修复上述错误,请重命名其中一个着色器变量以使其具有唯一的名称。
在不同的着色器阶段拥有相同名称但不同资源类型是可以的。
// in closest_hit_shader:
ConstantBuffer<CubeAttribs> g_CubeAttribs;
// in any_hit_shader:
// OK - name spaces for different shader types
// do not overlap
StructuredBuffer<CubeAttribs> g_CubeAttribs;
除了上述细节,创建 SRB 和绑定着色器资源的操作与其他管线类型类似。
m_pRayTracingPSO->CreateShaderResourceBinding(&m_pRayTracingSRB, true);
m_pRayTracingSRB->
GetVariableByName(SHADER_TYPE_RAY_GEN, "g_ConstantsCB")->
Set(m_ConstantsCB);
m_pRayTracingSRB->
GetVariableByName(SHADER_TYPE_RAY_CLOSEST_HIT, "g_ConstantsCB")->
Set(m_ConstantsCB);
追踪光线
在开始光线追踪之前,我们通过更新 TLAS 中实例的变换来使其产生动画。
更新加速结构比从头开始构建它们要快得多,并且占用更少的内存。
UpdateTLAS();
光线追踪由设备上下文方法 TraceRays
启动。该方法接收光线网格维度和 SBT 作为参数。
TraceRaysAttribs Attribs;
Attribs.DimensionX = m_pColorRT->GetDesc().Width;
Attribs.DimensionY = m_pColorRT->GetDesc().Height;
Attribs.pSBT = m_pSBT;
Attribs.SBTTransitionMode = RESOURCE_STATE_TRANSITION_MODE_TRANSITION;
m_pImmediateContext->TraceRays(Attribs);
在这个例子中,我们为每个屏幕像素生成一条光线,所以 DimensionX
和 DimensionY
是渲染目标的尺寸。
然后,使用一个简单的图形管线将光线追踪的结果复制到交换链图像中。
在更复杂的渲染器中,此过程将被色调映射或其他后处理效果所取代。
光线生成着色器
光线生成着色器是光线追踪管线的入口点。其目的是生成主光线。为了为每个屏幕像素生成光线,我们将首先计算构成视锥体平面的四条光线。
我们首先使用 ExtractViewFrustumPlanesFromMatrix()
函数从视图-投影矩阵中提取视锥体平面。然后我们对这些平面进行归一化并计算交点。接着,我们将得到的四条光线传递给着色器。
ViewFrustum Frustum;
ExtractViewFrustumPlanesFromMatrix(CameraViewProj, Frustum, false);
// Normalize frustum planes.
for (uint i = 0; i < ViewFrustum::NUM_PLANES; ++i)
{
Plane3D& plane = Frustum.GetPlane(static_cast<ViewFrustum::PLANE_IDX>(i));
float invlen = 1.0f / length(plane.Normal);
plane.Normal *= invlen;
plane.Distance *= invlen;
}
// Calculate the ray formed by the intersection of the two planes.
auto GetPlaneIntersection = [&Frustum](ViewFrustum::PLANE_IDX lhs,
ViewFrustum::PLANE_IDX rhs,
float4& result) {
const Plane3D& lp = Frustum.GetPlane(lhs);
const Plane3D& rp = Frustum.GetPlane(rhs);
const float3 dir = cross(lp.Normal, rp.Normal);
const float len = dot(dir, dir);
result = dir * (1.0f / sqrt(len));
};
GetPlaneIntersection(ViewFrustum::BOTTOM_PLANE_IDX,
ViewFrustum::LEFT_PLANE_IDX, m_Constants.FrustumRayLB);
GetPlaneIntersection(ViewFrustum::LEFT_PLANE_IDX,
ViewFrustum::TOP_PLANE_IDX, m_Constants.FrustumRayLT);
GetPlaneIntersection(ViewFrustum::RIGHT_PLANE_IDX,
ViewFrustum::BOTTOM_PLANE_IDX, m_Constants.FrustumRayRB);
GetPlaneIntersection(ViewFrustum::TOP_PLANE_IDX,
ViewFrustum::RIGHT_PLANE_IDX, m_Constants.FrustumRayRT);
在光线生成着色器中,我们计算归一化的纹理坐标 uv
,并用它们从视锥体光线中计算出光线方向。
struct Constants
{
float4 Position;
float4 FrustumRayLT;
float4 FrustumRayLB;
float4 FrustumRayRT;
float4 FrustumRayRB;
...
};
ConstantBuffer<Constants> g_ConstantsCB;
...
float3 rayOrigin = g_ConstantsCB.CameraPos.xyz;
float2 uv =
(float2(DispatchRaysIndex().xy) + float2(0.5, 0.5)) /
float2(DispatchRaysDimensions().xy);
float3 rayDir = normalize(lerp(
lerp(g_ConstantsCB.FrustumRayLB.xyz, g_ConstantsCB.FrustumRayRB.xyz, uv.x),
lerp(g_ConstantsCB.FrustumRayLT.xyz, g_ConstantsCB.FrustumRayRT.xyz, uv.x),
uv.y));
接下来,我们准备 RayDesc
结构体并调用 CastPrimaryRay
辅助函数。
RayDesc ray;
ray.Origin = rayOrigin;
ray.Direction = rayDir;
ray.TMin = g_ConstantsCB.ClipPlanes.x;
ray.TMax = g_ConstantsCB.ClipPlanes.y;
PrimaryRayPayload payload = CastPrimaryRay(ray, /*recursion number*/0);
CastPrimaryRay()
光线函数计算屏幕上一个像素的最终颜色,并为反射和折射生成次级光线。
它为了可重用性而封装了 TraceRay()
HLSL 内置函数。CastPrimaryRay()
的定义如下:
RaytracingAccelerationStructure g_TLAS;
PrimaryRayPayload CastPrimaryRay(RayDesc ray, uint Recursion)
{
PrimaryRayPayload payload = {float3(0, 0, 0), 0.0, Recursion};
if (Recursion >= g_ConstantsCB.MaxRecursion)
{
// set pink color for debugging
payload.Color = float3(0.95, 0.18, 0.95);
return payload;
}
TraceRay(g_TLAS, // Acceleration structure
RAY_FLAG_NONE,
~0, // Instance inclusion mask - all
// instances are visible
PRIMARY_RAY_INDEX, // Ray contribution to hit
// group index (aka ray type)
HIT_GROUP_STRIDE, // Multiplier for geometry
// contribution to hit group index
// (aka the number of ray types)
PRIMARY_RAY_INDEX, // Miss shader index
ray,
payload);
return payload;
}
请注意,我们检查光线递归深度,并在当前深度超过最大深度时终止它。这样做至关重要,因为 GPU 设备在达到最大递归深度时不会自动停止光线追踪,如果我们超过 PSO 初始化时指定的限制,将会导致崩溃。
下图显示了改变递归深度的效果。
在光线生成着色器的末尾,我们将有效载荷颜色写入输出的 UAV 纹理中。
g_ColorBuffer[DispatchRaysIndex().xy] = float4(payload.Color, 1.0);
最近命中着色器
当光线击中三角形几何体或自定义相交着色器报告命中时,最近命中着色器会被执行。
下图展示了主光线最近命中着色器的工作流程:
最近命中着色器将几何体相交信息和可编辑的光线有效载荷作为输入。
[shader("closesthit")]
void main(inout PrimaryRayPayload payload, in BuiltInTriangleIntersectionAttributes attr)
为了计算命中点的属性,我们将使用三维重心坐标。
float3 barycentrics = float3(1.0 - attr.barycentrics.x - attr.barycentrics.y,
attr.barycentrics.x,
attr.barycentrics.y);
内置函数 PrimitiveIndex()
给了我们当前图元的索引。使用这个索引,我们可以从立方体常量数据缓冲区中读取 3 个顶点索引。
uint3 primitive = g_CubeAttribsCB.Primitives[PrimitiveIndex()].xyz;
然后我们使用重心坐标来计算交点处的纹理坐标。
float2 uv = g_CubeAttribsCB.UVs[primitive.x].xy * barycentrics.x +
g_CubeAttribsCB.UVs[primitive.y].xy * barycentrics.y +
g_CubeAttribsCB.UVs[primitive.z].xy * barycentrics.z;
类似地,我们获取三角形的法线。但请注意,我们需要应用由 ObjectToWorld3x4()
内置函数给出的对象到世界的变换。
float3 normal = g_CubeAttribsCB.Normals[primitive.x].xyz * barycentrics.x +
g_CubeAttribsCB.Normals[primitive.y].xyz * barycentrics.y +
g_CubeAttribsCB.Normals[primitive.z].xyz * barycentrics.z;
normal = normalize(mul((float3x3) ObjectToWorld3x4(), normal));
在光线追踪中,不支持 Texture2D::Sample()
方法,因为 GPU 无法隐式计算导数,所以我们必须手动计算纹理的 mipmap 等级,或者为各向异性过滤提供显式梯度。在本例中,为简单起见,我们不这样做,只是采样最精细的 mip 等级。
payload.Color = g_Texture[InstanceID()].SampleLevel(g_SamLinearWrap, uv, 0).rgb;
最后一步是光照计算。我们使用内置函数来获取光线参数并计算交点。
float3 pos = WorldRayOrigin() + WorldRayDirection() * RayTCurrent();
LightingPass(payload.Color, pos, normal, payload.Recursion + 1);
然后我们投射一条光线来测试光源是否被遮挡,并应用一个简单的漫反射光照。
void LightingPass(inout float3 Color, float3 Pos, float3 Norm, uint Recursion)
{
float3 col = float3(0.0, 0.0, 0.0);
for (int i = 0; i < NUM_LIGHTS; ++i)
{
float NdotL = max(0.0, dot(Norm, rayDir));
// Optimization - don't trace rays if NdotL is zero
if (NdotL > 0.0)
{
RayDesc ray;
// Add a small offset to avoid self-intersections.
ray.Origin = Pos + Norm * 0.001;
ray.Direction = normalize(g_ConstantsCB.LightPos[i].xyz - Pos);
// Limit max ray length by distance to light source.
ray.TMax = distance(g_ConstantsCB.LightPos[i].xyz, Pos);
ray.TMin = 0.0;
col += CastShadow(ray, Recursion).Shading *
NdotL * Color *
g_ConstantsCB.LightColor[i].rgb;
}
}
Color = col / float(NUM_LIGHTS) + g_ConstantsCB.AmbientColor.rgb;
}
请注意,在上面的代码片段中,我们只投射了一条阴影光线。示例应用程序会向光源投射多条光线来计算软阴影。更多细节请参见源代码。
对于阴影光线,我们使用一种新的有效载荷类型 ShadowRayPayload
,它只包含两个分量,以最小化内存使用。
为了提高光线追踪性能,我们使用以下标志:
RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH
- 如果遇到任何几何体命中,立即停止光线处理。RAY_FLAG_FORCE_OPAQUE
- 跳过任意命中着色器的调用。
在 SBT 中,我们为三角形几何体的阴影光线绑定了空命中组。对于程序化几何体,我们只使用相交着色器,所以我们没有任何最近命中着色器,RAY_FLAG_SKIP_CLOSEST_HIT_SHADER
标志可能没有效果。然而,如果你使用与主光线相同的
着色器,你可能会看到显著的性能提升。
我们使用一个实例掩码 OPAQUE_GEOM_MASK
来跳过具有折射的透明立方体。
具有折射的物体的光照计算要复杂得多,在计算阴影时被忽略。
初始的 Shading
值被设置为 0
,意味着物体完全处于阴影中。如果找到了交点,该值将被保留;但如果在未命中着色器中,它将被 1
覆盖,表示没有遮挡。
CastShadow()
函数的定义如下:
ShadowRayPayload CastShadow(RayDesc ray, uint Recursion)
{
ShadowRayPayload payload = {0.0, Recursion};
if (Recursion >= g_ConstantsCB.MaxRecursion)
{
payload.Shading = 1.0;
return payload;
}
TraceRay(g_TLAS, // Acceleration structure
RAY_FLAG_FORCE_OPAQUE |
RAY_FLAG_SKIP_CLOSEST_HIT_SHADER |
RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH,
OPAQUE_GEOM_MASK, // Instance inclusion mask -
// only opaque instances are visible
SHADOW_RAY_INDEX, // Ray contribution to hit
// group index (aka ray type)
HIT_GROUP_STRIDE, // Multiplier for geometry
// contribution to hit group index
// (aka the number of ray types)
SHADOW_RAY_INDEX, // Miss shader index
ray,
payload);
return payload;
}
下图展示了阴影光线的工作流程:
请注意,某些硬件不支持光线递归,但你可以在光线生成着色器中使用循环来替代。
未命中着色器
如果没有找到交点,就会执行未命中着色器。
作为输入,未命中着色器只接收可编辑的光线有效载荷。
[shader("miss")]
void main(inout PrimaryRayPayload payload)
主光线的未命中着色器生成天空颜色。
阴影光线的未命中着色器只将 1.0 写入有效载荷。
相交着色器
相交着色器只能与程序化几何体一起使用。对于三角形几何体,会使用内置的硬件加速相交测试。对于每条与 AABB 相交的光线,都会执行相交着色器。请注意,根据实现的不同,即使 AABB 被其他几何体遮挡,相交着色器也可能被执行,因此相交测试应尽可能快。如果相交着色器有任何副作用,例如向 UAV 写入数据,你也需要非常小心。
相交着色器没有任何输入或输出。
[shader("intersection")]
void main()
我们没有任何关于交点的信息,所以我们需要使用构建 BLAS 时指定的相同 AABB,并在着色器中计算交点。
如果存在交点,我们使用 ReportHit()
内置函数向系统报告命中。
ProceduralGeomIntersectionAttribs attr;
...
ReportHit(hitT, RAY_KIND_PROCEDURAL_FRONT_FACE, attr);
结论
光线追踪 API 由多个组件构成,可能不易立即掌握。Diligent Engine 通过提供一个直观的 API 来促进光线追踪应用程序的开发,该 API 使用人类可读的名称连接这些组件,并承担了隐藏底层实现细节的繁重工作。我们鼓励读者下载示例应用程序进行实验,并亲自观察这些组件的实际运作。
菲涅耳方程、软阴影和光色散等几个主题未在本文的讨论范围之内。详情请查阅示例源代码。
致谢
感谢 Andrey Zhirnov 为 Diligent Engine 添加了光线追踪功能并实现了光线追踪教程,本文的描述正是基于该教程。
延伸阅读
历史
- 2021年1月4日 - 初始版本