前言

随着图形硬件变得越来越通用和可编程化,采用实时3D图形渲染的应用程序已经开始探索传统渲染管线的替代方案,以避免其缺点。其中一项最流行的技术就是所谓的延迟渲染。这项技术主要是为了支持大量的动态灯光,而不需要一套复杂的着色器程序。

DirectX11 With Windows SDK--36 延迟渲染基础-LMLPHP

为了迎接这一章的项目,会对原来的代码有许多改动,并且会有许多引申的内容。

DirectX11 With Windows SDK完整目录

Github项目源码

欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。

前向渲染(Forward Rendering)的问题

在设计渲染器时,最重要的一个方面是确定如何处理光照。这非常重要,因为它需要进行涉及到光照强度以及几何体表面一点的反射颜色的计算。通常它包含如下步骤:

  1. 基于光照类型和衰弱属性,确定哪些光照需要应用到当前的特定像素上
  2. 使用材质、表面属性以及刚才确定需要用到的光照来为每个像素计算颜色
  3. 确定这些像素是否在阴影中,即光照是否能打到该像素。

这种传统的处理方式通常叫做前向渲染。通过前向渲染,几何体使用大量存储在顶点属性数据、纹理、常量缓冲区中的表面属性来进行渲染。然后,根据输入的几何体对每个像素进行光栅化操作,结合材质数据对一个或多个灯光进行光照计算。最终的计算结果将输出到每个像素中,并且可能与之前存在渲染目标的结果进行累加。这种方式是直接且直观的,在可编程图形硬件出现之前,它是实时3D图形渲染的绝大部分方式。早期固定管线的GPU支持三种不同的光照类型:点光源、聚光灯、方向光

而随着实时渲染引擎的发展以适应可编程硬件和更复杂的场景,这些基本的光照类型由于其灵活性和普遍性,大部分都被保留了下来。然而,它们的使用方式已经开始显示出它们的年迈。这是因为在现代硬件和API下,当它们开始支持大量的动态光源时,前向渲染暴露出几项关键的弱点:

第一个缺点是,光源的使用与场景中绘制的几何体的粒度(对象)挂钩。换句话说,当我们开启一个点光源,我们必须将其应用到在任何特定的绘制调用期间经过光栅化后的所有几何体当中。一开始我们可能注意不到这会带来什么样的问题,尤其是我们在场景中只是添加了少数几盏灯光。然而随着光源数目的增长,着色器的计算量开始剧增,选择性地应用光源就开始变得重要了,这么做是为了减少像素着色器中光照计算的执行次数。但因为我们只能在每次绘制之间完成光照的修改,导致我们在剔除不需要的灯光时受到了限制。那些灯光本应该只会影响到光栅化中的一小部分几何体的情况,我们却又不得不将那盏灯光应用到所有的几何体。虽然我们可以将场景中的网格分割成更小的部分来提升粒度,但这增加了当前帧的绘制调用的数目(从而导致CPU开销的提升)。这还增加了需要确定光照是否影响网格的相交测试的数目,给CPU带来了更大的负担。另一个相关的后果是我们在单批次渲染多个几何体实例的能力将会降低,因为单批次中所有几何体实例所使用的灯光数目必须是相同的,这些实例可能会出现在场景中的不同位置,它们本应该会受到不同灯光组合的影响。

另一个前向渲染的主要缺点是着色器程序的复杂性问题。为了控制大量的灯光和各种材质变化的灯光类型,可能会导致所需的着色器排列组合产生的数量激增。大量的着色器排列组合不是我们所期望的,因为这会增加内存的使用,以及着色器程序之间切换的开销。由于依赖于这些组合怎么被编译,它们还会显著增加编译的次数。使用多着色器程序的另一种选择是使用动态流控制,这将影响GPU的性能。另一种方法是一次只渲染一个灯光,并将结果添加到渲染目标中,这称为多通道渲染multi-pass rendering)。然而,这种方法需要为多次变换和光栅化几何体付出代价。即便忽略掉与许多排列组合相关的问题,生成的像素着色器程序本身也可能变得非常昂贵和复杂。这是因为需要评估材质属性并对所有活动的光源执行必要的照明和阴影计算。这会使得着色器程序难以编写、维护和优化。它们的性能还与场景的过度绘制有关,因为过度绘制会产生甚至不可见的着色像素。一个只使用z深度的预处理可以显著减少过度绘制,但它的效率受到硬件中Hi-Z单元的实现的限制。着色器执行也会被浪费,因为像素着色器必须在至少2x2个四边形中进行,这对于具有许多小三角形的高度细分场景尤其不利

这些缺陷共同导致很难在场景中扩展动态光照的数目,同时为实时应用保持足够的性能。然而如果你非常仔细地去看这些描述,你将会注意到所有这些缺陷根源自前向渲染固有的一个主要问题:光照场景几何图形的光栅化紧密耦合。如果我们将这两个步骤进行解耦,我们就有可能限制或完全绕过其中的一些缺点。仔细观察光照过程中的第二步,我们可以发现为了计算光照,需要利用到材质属性几何体表面属性。这意味着如果我们有这样一个步骤,在渲染过程中将所有需要的这些属性放入一个缓冲区中(我们通常叫它为几何缓冲区,或G-Buffer),紧接着我们就可以对所有场景中的灯光进行遍历,并为每个像素计算出光照相关的值,这正是延迟渲染的前提。

使用RTX 2070 Mobile渲染带1024点光源的Sponza场景,即便是开启了预视锥体剔除,总体*均帧数仅有40帧:

DirectX11 With Windows SDK--36 延迟渲染基础-LMLPHP

Pre-Z Pass/Depth Prepass

前向渲染也是有比较大的优化空间的,但现阶段我们只讲能够很快实现的部分。

在绘制一个复杂场景的时候,可能会出现某些区域三角形反复覆盖绘制的情况,增加了很多不必要的绘制。为此我们可以在进行正式渲染前,先对场景中的物体只做深度测试,不执行像素着色阶段;然后第二遍场景绘制的时候只绘制和深度缓冲区中深度值相等的像素。这样做可以有效减少像素着色器的执行次数。

Pre-Z Pass在C++端的代码实现也十分简单,具体可以参考项目中的代码。

延迟渲染管线

延迟渲染管线的第一步是几何阶段:渲染所有的场景几何体到G-Buffer当中。这些缓冲区通常由一些渲染目标纹理所组成,但它们的各个通道用于逐像素存储几何体表面信息,例如表面法向量材质属性等。

第二步是光照阶段。在这一步中,可以将渲染表示为当前光照影响屏幕区域的几何体的过程。对于每个待着色的像素片元,将从G-Buffer中采样几何体的信息,然后与光照的属性结合来确定对于该像素产生的光照贡献值,再与所有其它影响那一像素的光源的贡献值累加到一起,来确定最终表面的发光颜色。

DirectX11 With Windows SDK--36 延迟渲染基础-LMLPHP

因为延迟渲染避免了前向渲染的主要缺陷(光照与几何体紧密耦合),它拥有下面这些优势:

  • 着色器的组合数目将大幅减少,因为光照和阴影的计算可以移动到独立的着色器程序中进行。
  • 可以更加频繁地批处理网格实例,因为我们在渲染场景几何体的时候不再需要光照参数。所以,对于网格的所有实例,活动的光源可以不需要相同。
  • 不再需要在CPU执行工作来确定哪些灯光影响屏幕的不同区域。取而代之的是,碰撞体或屏幕空间四边形可以光栅化在灯光影响的屏幕部分上。深度和模板测试也可以用于进一步减少着色器执行的次数。
  • 总共需要的着色器和渲染框架体系可以被简化,因为光照和几何体已经被解耦了。

这些优势使得延迟渲染在渲染引擎中非常流行。不幸的是,这种方法也会产生一些缺陷:

  • 必须有大量的内存和带宽专门用于G-Buffer的生成和采样,因为它需要存储计算该像素所需的任何信息。
  • 使用硬件MSAA变得困难,不是说使用延迟渲染就不能使用MSAA了。开启MSAA可能意味着连同G-Buffer的占用空间也会成倍的提升,而且可能带来显著提升的计算负担。
  • 透明几何体的处理不能以不透明几何体处理的方式进行,因为它不能被渲染进G-Buffer。又因为G-Buffer仅仅能够保存单个表面的属性,且渲染透明几何体需要计算为多个重叠的逐像素光照,并将计算颜色的结果结合起来。
  • 如果使用BRDF材质模型的话,这种方式将变得不再简单,因为计算像素最终的颜色已经被移到光照pass了

渲染相关的着色器

现假定我们的场景中只包含一系列的点光源,并且使用的是Phong光照模型,不引入阴影和SSAO,没有透明物体。对于材质,我们只用到漫反射贴图。

在几何阶段,有:

// ConstantBuffers.hlsl
cbuffer CBChangesEveryInstanceDrawing : register(b0)
{
    matrix g_WorldInvTransposeView;
    matrix g_WorldView;
    matrix g_ViewProj;
    matrix g_Proj;
    matrix g_WorldViewProj;
}

cbuffer CBPerFrame : register(b1)
{
    float4 g_CameraNearFar;
    uint g_LightingOnly;
    uint g_FaceNormals;
    uint g_VisualizeLightCount;
    uint g_VisualizePerSampleShading;
}

// Rendering.hlsl
//--------------------------------------------------------------------------------------
// 几何阶段
//--------------------------------------------------------------------------------------
Texture2D g_TextureDiffuse : register(t0);
SamplerState g_SamplerDiffuse : register(s0);

struct VertexPosNormalTex
{
    float3 posL : POSITION;
    float3 normalL : NORMAL;
    float2 texCoord : TEXCOORD;
};

struct VertexPosHVNormalVTex
{
    float4 posH : SV_POSITION;
    float3 posV : POSITION;
    float3 normalV : NORMAL;
    float2 texCoord : TEXCOORD;
};

VertexPosHVNormalVTex GeometryVS(VertexPosNormalTex input)
{
    VertexPosHVNormalVTex output;

    output.posH = mul(float4(input.posL, 1.0f), g_WorldViewProj);
    output.posV = mul(float4(input.posL, 1.0f), g_WorldView).xyz;
    output.normalV = mul(float4(input.normalL, 0.0f), g_WorldInvTransposeView).xyz;
    output.texCoord = input.texCoord;

    return output;
}

对光照阶段,有:

// Rendering.hlsl
float3 ComputeFaceNormal(float3 pos)
{
    return cross(ddx_coarse(pos), ddy_coarse(pos));
}

struct SurfaceData
{
    float3 posV;
    float3 posV_DX;
    float3 posV_DY;
    float3 normalV;
    float4 albedo;
    float specularAmount;
    float specularPower;
};

SurfaceData ComputeSurfaceDataFromGeometry(VertexPosHVNormalVTex input)
{
    SurfaceData surface;
    surface.posV = input.posV;

    // 右/下相邻像素与当前像素的位置差
    surface.posV_DX = ddx_coarse(surface.posV);
    surface.posV_DY = ddy_coarse(surface.posV);

    // 该表面法线可用于替代提供的法线
    float3 faceNormal = ComputeFaceNormal(input.posV);
    surface.normalV = normalize(g_FaceNormals ? faceNormal : input.normalV);

    surface.albedo = g_TextureDiffuse.Sample(g_SamplerDiffuse, input.texCoord);
    surface.albedo.rgb = g_LightingOnly ? float3(1.0f, 1.0f, 1.0f) : surface.albedo.rgb;

    // 将空漫反射纹理映射为白色
    uint2 textureDim;
    g_TextureDiffuse.GetDimensions(textureDim.x, textureDim.y);
    surface.albedo = (textureDim.x == 0U ? float4(1.0f, 1.0f, 1.0f, 1.0f) : surface.albedo);

    // 我们没有艺术资产相关的值来设置下面这些,现在暂且设置成看起来比较合理的值
    surface.specularAmount = 0.9f;
    surface.specularPower = 25.0f;

    return surface;
}

//--------------------------------------------------------------------------------------
// 光照阶段
//--------------------------------------------------------------------------------------
struct PointLight
{
    float3 posV;
    float attenuationBegin;
    float3 color;
    float attenuationEnd;
};

// 大量的动态点光源
StructuredBuffer<PointLight> g_Light : register(t5);

// 这里分成diffuse/specular项方便后续延迟光照使用
void AccumulatePhong(float3 normal, float3 lightDir, float3 viewDir, float3 lightContrib, float specularPower,
                     inout float3 litDiffuse, inout float3 litSpecular)
{
    float NdotL = dot(normal, lightDir);
    [flatten]
    if (NdotL > 0.0f)
    {
        float3 r = reflect(lightDir, normal);
        float RdotV = max(0.0f, dot(r, viewDir));
        float specular = pow(RdotV, specularPower);

        litDiffuse += lightContrib * NdotL;
        litSpecular += lightContrib * specular;
    }
}

void AccumulateDiffuseSpecular(SurfaceData surface, PointLight light,
                               inout float3 litDiffuse, inout float3 litSpecular)
{
    float3 dirToLight = light.posV - surface.posV;
    float distToLight = length(dirToLight);

    [branch]
    if (distToLight < light.attenuationEnd)
    {
        float attenuation = linstep(light.attenuationEnd, light.attenuationBegin, distToLight);
        dirToLight *= rcp(distToLight);
        AccumulatePhong(surface.normalV, dirToLight, normalize(surface.posV),
            attenuation * light.color, surface.specularPower, litDiffuse, litSpecular);
    }

}

void AccumulateColor(SurfaceData surface, PointLight light,
                     inout float3 litColor)
{
    float3 dirToLight = light.posV - surface.posV;
    float distToLight = length(dirToLight);

    [branch]
    if (distToLight < light.attenuationEnd)
    {
        float attenuation = linstep(light.attenuationEnd, light.attenuationBegin, distToLight);
        dirToLight *= rcp(distToLight);

        float3 litDiffuse = float3(0.0f, 0.0f, 0.0f);
        float3 litSpecular = float3(0.0f, 0.0f, 0.0f);
        AccumulatePhong(surface.normalV, dirToLight, normalize(surface.posV),
            attenuation * light.color, surface.specularPower, litDiffuse, litSpecular);
        litColor += surface.albedo.rgb * (litDiffuse + surface.specularAmount * litSpecular);
    }
}

// Forward.hlsl

//--------------------------------------------------------------------------------------
// 计算点光源着色
float4 ForwardPS(VertexPosHVNormalVTex input) : SV_Target
{
    uint totalLights, dummy;
    g_Light.GetDimensions(totalLights, dummy);

    float3 litColor = float3(0.0f, 0.0f, 0.0f);

    [branch]
    // 用灰度表示当前像素接受的灯光数目
    if (g_VisualizeLightCount)
    {
        litColor = (float(totalLights) * rcp(255.0f)).xxx;
    }
    else
    {
        SurfaceData surface = ComputeSurfaceDataFromGeometry(input);
        for (uint lightIndex = 0; lightIndex < totalLights; ++lightIndex)
        {
            PointLight light = g_Light[lightIndex];
            AccumulateColor(surface, light, litColor);
        }
    }

    return float4(litColor, 1.0f);
}

ddx和ddy

对于ddx和ddy,在光栅化过程中,GPU会在同一时刻并行运行很多像素着色器(如32-64个一组)而不是逐像素来执行,然后这些像素将组织在2x2一组的像素分块中。

这里的偏导数对应的是这一像素块中的变化率。ddx就是拿右边像素的值减去左边像素的值,ddy则是拿下面像素的值减去上面像素的值。x和y为屏幕的坐标。

DirectX11 With Windows SDK--36 延迟渲染基础-LMLPHP

而ddx和ddy系列的函数只能用于像素着色器,是因为它需要依赖的是像素片元中的输入数据来求偏导。

例如,若传递的是posH,则有:

\[dFdx(posH(x,y))=posH(x+1,y)-posH(x,y)==(1,0,z(x+1,y)-z(x,y),0)\]
03-05 18:30