本文主要参考NVIDIA Vulkan Ray Tracing Tutorial教程,环境配置与程序均可参照此文档执行(个人水平有限,如有错误请参照原文)。
一、简单光照
当前最近的命中着色器仅返回单一颜色。为了添加光照,我们需要引入表面法线的概念。但是,光线追踪仅提供击中点的重心坐标。为了获得法线和其他顶点属性,我们需要在顶点缓冲区中找到它们并使用重心坐标对它们进行插值。这就是为什么我们在创建光线追踪描述符集时扩展了顶点和索引缓冲区的原因。
1.1 最近命中着色器 (raytrace.rchit)
当我们创建光线追踪描述符集时,我们已经包含了几何定义。因此,我们可以通过场景描述直接在最近的命中着色器中引用顶点和索引缓冲区: binding = 2
我们首先引用有效光路载荷的定义和 OBJ-Wavefront 结构体
#extension GL_EXT_scalar_block_layout : enable
#extension GL_GOOGLE_include_directive : enable
#extension GL_EXT_shader_explicit_arithmetic_types_int64 : require
#extension GL_EXT_buffer_reference2 : require
#include "raycommon.glsl"
#include "wavefront.glsl"
然后我们根据描述符集布局来描述资源
layout(location = 0) rayPayloadInEXT hitPayload prd;
layout(buffer_reference, scalar) buffer Vertices {Vertex v[]; }; // Positions of an object
layout(buffer_reference, scalar) buffer Indices {ivec3 i[]; }; // Triangle indices
layout(buffer_reference, scalar) buffer Materials {WaveFrontMaterial m[]; }; // Array of all materials on an object
layout(buffer_reference, scalar) buffer MatIndices {int i[]; }; // Material ID for each triangle
layout(set = 1, binding = eObjDescs, scalar) buffer ObjDesc_ { ObjDesc i[]; } objDesc;
layout(push_constant) uniform _PushConstantRay { PushConstantRay pcRay; };
在main函数中,gl_InstanceCustomIndexEXT告诉我们哪个对象被击中,并且gl_PrimitiveID允许我们找到被射线击中的三角形的顶点:
void main()
{
// Object data
ObjDesc objResource = objDesc.i[gl_InstanceCustomIndexEXT];
MatIndices matIndices = MatIndices(objResource.materialIndexAddress);
Materials materials = Materials(objResource.materialAddress);
Indices indices = Indices(objResource.indexAddress);
Vertices vertices = Vertices(objResource.vertexAddress);
// Indices of the triangle
ivec3 ind = indices.i[gl_PrimitiveID];
// Vertex of the triangle
Vertex v0 = vertices.v[ind.x];
Vertex v1 = vertices.v[ind.y];
Vertex v2 = vertices.v[ind.z];
通过以下方式完成计算重心坐标:
const vec3 barycentrics = vec3(1.0 - attribs.x - attribs.y, attribs.x, attribs.y);
世界空间位置可以通过两种方式计算
- 第一种:使用来自命中着色器的信息。但如果命中点很远,这可能会出现精度问题。
vec3 worldPos = gl_WorldRayOriginEXT + gl_WorldRayDirectionEXT * gl_HitTEXT;
- **另一种:**更精确的方法是通过插值计算位置。我们使用命中着色器提供的材料信息。这些矩阵是使用我们设置 TLAS 和 BLAS 提供的信息计算的。请注意,我们所有的 BLAS 都没有应用任何转换,仅使用的是实例数据。
// 计算命中位置的坐标
const vec3 pos = v0.pos * barycentrics.x + v1.pos * barycentrics.y + v2.pos * barycentrics.z;
// 将位置坐标转换到世界空间
const vec3 worldPos = vec3(gl_ObjectToWorldEXT * vec4(pos, 1.0));
同方的方式计算法线
// 计算击中位置的法线
const vec3 nrm = v0.nrm * barycentrics.x + v1.nrm * barycentrics.y + v2.nrm * barycentrics.z;
// 将法线转换到世界空间
const vec3 worldNrm = normalize(vec3(nrm * gl_WorldToObjectEXT));
然后可以使用常量中指定的光源来计算法线与光照方向的点积,给出简单的漫射照明效果:
// Vector toward the light
vec3 L;
float lightIntensity = pcRay.lightIntensity;
float lightDistance = 100000.0;
// Point light
if(pcRay.lightType == 0)
{
vec3 lDir = pcRay.lightPosition - worldPos;
lightDistance = length(lDir);
lightIntensity = pcRay.lightIntensity / (lightDistance * lightDistance);
L = normalize(lDir);
}
else // Directional light
{
L = normalize(pcRay.lightPosition);
}
如下图所示:
二、材质
通过添加对材质的支持,可以使上面的渲染更有趣。导入的 OBJ 对象提供了简化的 Alias Wavefront 材质定义。
2.1 最近命中着色器 (raytrace.rchit)
这些材质使用简单的颜色系数定义了它们的基本反射特性,并且还支持纹理。我们已经为光栅化创建了包含材质的缓冲区,并且也已将其添加到光线跟踪描述符集中。
因此在着色器中添加纹理采样器数组的绑定(材质的定义与用于光栅化器的定义相同):
layout(set = 1, binding = eTextures) uniform sampler2D textureSamplers[];
顶点数组结构中包含一个材质索引,所以我们使用它来查找缓冲区中的相应材质。
我们首先在main()末尾删除这些行 :
float dotNL = max(dot(normal, L), 0.2);
prd.hitValue = vec3(dotNL);
取而代之的是获取材料定义:
// 物体的材质
int matIdx = matIndices.i[gl_PrimitiveID];
WaveFrontMaterial mat = materials.m[matIdx];
其中,每个对象有一个材质缓冲区,每个材质都可以通过索引访问。每个三角形都有一个材料索引。
根据材质定义,我们使用漫反射和镜面反射来计算漫射光照。此代码还支持纹理来调节表面反射率。
// Diffuse
vec3 diffuse = computeDiffuse(mat, L, normal);
if(mat.textureId >= 0)
{
uint txtId = mat.textureId + scnDesc.i[gl_InstanceCustomIndexEXT].txtOffset;
vec2 texCoord =
v0.texCoord * barycentrics.x + v1.texCoord * barycentrics.y + v2.texCoord * barycentrics.z;
diffuse *= texture(textureSamplers[nonuniformEXT(txtId)], texCoord).xyz;
}
// Specular
vec3 specular = computeSpecular(mat, gl_WorldRayDirectionEXT, L, normal);
最终的光照计算如下
prd.hitValue = vec3(lightIntensity * (diffuse + specular));
2.2 更改模型
在main.cpp中我们通过调用helloVk.loadModel来加载一些比立方体更复杂的OBJ 模型:
helloVk.loadModel(nvh::findFile("media/scenes/Medieval_building.obj", defaultSearchPaths, true));
helloVk.loadModel(nvh::findFile("media/scenes/plane.obj", defaultSearchPaths, true));
由于该模型更大,我们可以将CameraManip.setLookat调用更改为
CameraManip.setLookat(nvmath::vec3f(4, 4, 4), nvmath::vec3f(0, 1, 0), nvmath::vec3f(0, 1, 0));
三、阴影
以上代码可以让我们对场景进行光线追踪并应用一些光照,但它仍然缺少阴影。为此,我们将添加一个新的光线类型,并从最近命中着色器发射光线。这种新的光线类型需要我们在代码中添加一个新的未命中着色器。
3.1 修改createRaytracingPipeline
对于简单的阴影光线,我们只需要沿光线计算光路是否击中了某些几何体。这可以通过使用初始化一个bool有效载荷值来实现代表是否存在遮挡物体,并且光线跟踪仅需要使用额外的一个未命中着色器将有效载荷bool值设置为未命中来传递结果。
在 createRtPipeline 函数中,我们需要在之前的未命中着色器之后立即定义一个新的未命中着色器:
enum StageIndices
{
eRaygen,
eMiss,
eMiss2,
eClosestHit,
eShaderGroupCount
};
之后创建组件
// 当阴影射线没有击中几何体时,第二个未命中着色器被调用。 它只是表明没有发现咬合
stage.module =
nvvk::createShaderModule(m_device, nvh::loadFile("spv/raytraceShadow.rmiss.spv", true, defaultSearchPaths, true));
stage.stage = VK_SHADER_STAGE_MISS_BIT_KHR;
stages[eMiss2] = stage;
在添加未命中着色器之后,我们继续添加阴影光线的未命中着色器:
// Shadow Miss
group.type = VK_RAY_TRACING_SHADER_GROUP_TYPE_GENERAL_KHR;
group.generalShader = eMiss2;
m_rtShaderGroups.push_back(group);
光追管线现在必须允许从最近命中着色器发射光线,因此需要将递归级别增加到 2:
//光线追踪过程可以从相机发射光线,阴影光线可以从相机光线的命中点发射,因此递归级别为2。
// 出于性能考虑,这个数字应该尽可能地保持在较低的水平。 即使是递归射线追踪也应该在射线生成中被平展成一个循环,以避免递归深度过大。
rayPipelineInfo.maxPipelineRayRecursionDepth = 2; // Ray depth
递归深度受限于硬件,需进行判断由于我们现在需要 2 的递归深度,我们应该检查设备是否支持所需的递归级别:
// 根据本机硬件限制进行判断
if (m_rtProperties.maxRayRecursionDepth <= 1) {
throw std::runtime_error("Device fails to support ray recursion (m_rtProperties.maxRayRecursionDepth <= 1)");
}
3.2 修改createRtShaderBindingTable
新miss shader组的添加使得我们的shader绑定表也需要对应修改,新的布局如下:
因此,我们必须更改HelloVulkan::createRtShaderBindingTable以标识有两个未命中着色器。
uint32_t missCount{2};
3.3 修改createRtDescriptorSet
对于描述符集中的每个资源实例,分别代表了哪个着色器阶段将能够使用它。由于阴影光线将从最近的命中着色器发出,因此我们需要添加VK_SHADER_STAGE_CLOSEST_HIT_BIT_KHR到加速结构绑定中去:
// TLAS加速结构,可用于射线生成和最近的命中(发射阴影射线)
m_rtDescSetLayoutBind.addBinding(RtxBindings::eTlas, VK_DESCRIPTOR_TYPE_ACCELERATION_STRUCTURE_KHR, 1,
VK_SHADER_STAGE_RAYGEN_BIT_KHR | VK_SHADER_STAGE_CLOSEST_HIT_BIT_KHR); // TLAS
3.4 修改最近命中着色器(raytrace.rchit)
最近命中着色器现在需要能够知道TLAS加速结构才能发射光线:
layout(set = 0, binding = eTlas) uniform accelerationStructureEXT topLevelAS;
这些射线还将保存一个有效光路载荷值,它需要在与当前射线有效光路不同的位置进行重定义 。此处有效光路载荷将是一个简单的布尔值,指示是否命中遮挡物:
layout(location = 1) rayPayloadEXT bool isShadowed;
在main函数中,我们将发出一条新射线,而不是简单地将有效载荷设置为prd.hitValue = c。要选择未命中阴影着色器,我们需要将函数traceRayEXT()中的参数missIndex设置为1而不再是0 。有效光路荷载值的定义与上面的声明布局(location = 1)相匹配。 。另外在调用traceRayEXT() 时我们设置标识为:
-
gl_RayFlagsSkipClosestHitShaderKHR: 不会调用命中着色器,只会调用未命中着色器
-
gl_RayFlagsOpaqueKHR :不会调用任意命中着色器,因此所有对象都将是不透明的
-
gl_RayFlagsTerminateOnFirstHitKHR : 第一击中点总是有效的。
由于我们跳过了阴影命中组,因此在命中表面时不会调用任何代码。因此,我们将有效载荷isShadowed初始化为true,如果没有击中任何几何体表面,我们将使用未命中着色器将其设置为 false。
此外,我们还设置了光线标志来优化光线追踪:由于这些简单的阴影光线只需要返回光线是否与任何几何体表面相交的结果,我们可以控制光线追踪引擎在找到第一个交点后停止遍历,而无需继续执行最近命中着色器。
因为仅当光线位于表面前方时才需要投射阴影光线,如果我们处于阴影中,则不应计算镜面反射(因为从着色点看不到光源)。所以之前计算镜面反射项的代码将如下所示修改一下:
vec3 specular = vec3(0);
float attenuation = 1;
// Tracing shadow ray only if the light is visible from the surface
if(dot(normal, L) > 0)
{
float tMin = 0.001;
float tMax = lightDistance;
vec3 origin = gl_WorldRayOriginEXT + gl_WorldRayDirectionEXT * gl_HitTEXT;
vec3 rayDir = L;
uint flags =
gl_RayFlagsTerminateOnFirstHitEXT | gl_RayFlagsOpaqueEXT | gl_RayFlagsSkipClosestHitShaderEXT;
isShadowed = true;
traceRayEXT(topLevelAS, // acceleration structure
flags, // rayFlags
0xFF, // cullMask
0, // sbtRecordOffset
0, // sbtRecordStride
1, // missIndex
origin, // ray origin
tMin, // ray min range
rayDir, // ray direction
tMax, // ray max range
1 // payload (location = 1)
);
if(isShadowed)
{
attenuation = 0.3;
}
else
{
// Specular
specular = computeSpecular(mat, gl_WorldRayDirectionEXT, L, normal);
}
}
然后可以根据阴影射线的结果调整最终的有效光路载荷值:
prd.hitValue = vec3(lightIntensity * attenuation * (diffuse + specular));