随着图形硬件变得越来越通用和可编程化,采用实时3D图形渲染的应用程序已经开始探索传统渲染管线的替代方案,以避免其缺点。其中一项最流行的技术就是所谓的延迟渲染。这项技术主要是为了支持大量的动态灯光,而不需要一套复杂的着色器程序。
为了迎接这一章的项目,会对原来的代码有许多改动,并且会有许多引申的内容。
DirectX11 With Windows SDK完整目录
欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。
在设计渲染器时,最重要的一个方面是确定如何处理光照。这非常重要,因为它需要进行涉及到光照强度以及几何体表面一点的反射颜色的计算。通常它包含如下步骤:
这种传统的处理方式通常叫做前向渲染。通过前向渲染,几何体使用大量存储在顶点属性数据、纹理、常量缓冲区中的表面属性来进行渲染。然后,根据输入的几何体对每个像素进行光栅化操作,结合材质数据对一个或多个灯光进行光照计算。最终的计算结果将输出到每个像素中,并且可能与之前存在渲染目标的结果进行累加。这种方式是直接且直观的,在可编程图形硬件出现之前,它是实时3D图形渲染的绝大部分方式。早期固定管线的GPU支持三种不同的光照类型:点光源、聚光灯、方向光。
而随着实时渲染引擎的发展以适应可编程硬件和更复杂的场景,这些基本的光照类型由于其灵活性和普遍性,大部分都被保留了下来。然而,它们的使用方式已经开始显示出它们的年迈。这是因为在现代硬件和API下,当它们开始支持大量的动态光源时,前向渲染暴露出几项关键的弱点:
第一个缺点是,光源的使用与场景中绘制的几何体的粒度(对象)挂钩。换句话说,当我们开启一个点光源,我们必须将其应用到在任何特定的绘制调用期间经过光栅化后的所有几何体当中。一开始我们可能注意不到这会带来什么样的问题,尤其是我们在场景中只是添加了少数几盏灯光。然而随着光源数目的增长,着色器的计算量开始剧增,选择性地应用光源就开始变得重要了,这么做是为了减少像素着色器中光照计算的执行次数。但因为我们只能在每次绘制之间完成光照的修改,导致我们在剔除不需要的灯光时受到了限制。那些灯光本应该只会影响到光栅化中的一小部分几何体的情况,我们却又不得不将那盏灯光应用到所有的几何体。虽然我们可以将场景中的网格分割成更小的部分来提升粒度,但这增加了当前帧的绘制调用的数目(从而导致CPU开销的提升)。这还增加了需要确定光照是否影响网格的相交测试的数目,给CPU带来了更大的负担。另一个相关的后果是我们在单批次渲染多个几何体实例的能力将会降低,因为单批次中所有几何体实例所使用的灯光数目必须是相同的,这些实例可能会出现在场景中的不同位置,它们本应该会受到不同灯光组合的影响。
另一个前向渲染的主要缺点是着色器程序的复杂性问题。为了控制大量的灯光和各种材质变化的灯光类型,可能会导致所需的着色器排列组合产生的数量激增。大量的着色器排列组合不是我们所期望的,因为这会增加内存的使用,以及着色器程序之间切换的开销。由于依赖于这些组合怎么被编译,它们还会显著增加编译的次数。使用多着色器程序的另一种选择是使用动态流控制,这将影响GPU的性能。另一种方法是一次只渲染一个灯光,并将结果添加到渲染目标中,这称为多通道渲染(multi-pass rendering)。然而,这种方法需要为多次变换和光栅化几何体付出代价。即便忽略掉与许多排列组合相关的问题,生成的像素着色器程序本身也可能变得非常昂贵和复杂。这是因为需要评估材质属性并对所有活动的光源执行必要的照明和阴影计算。这会使得着色器程序难以编写、维护和优化。它们的性能还与场景的过度绘制有关,因为过度绘制会产生甚至不可见的着色像素。一个只使用z深度的预处理可以显著减少过度绘制,但它的效率受到硬件中Hi-Z单元的实现的限制。着色器执行也会被浪费,因为像素着色器必须在至少2x2个四边形中进行,这对于具有许多小三角形的高度细分场景尤其不利。
这些缺陷共同导致很难在场景中扩展动态光照的数目,同时为实时应用保持足够的性能。然而如果你非常仔细地去看这些描述,你将会注意到所有这些缺陷根源自前向渲染固有的一个主要问题:光照与场景几何图形的光栅化紧密耦合。如果我们将这两个步骤进行解耦,我们就有可能限制或完全绕过其中的一些缺点。仔细观察光照过程中的第二步,我们可以发现为了计算光照,需要利用到材质属性和几何体表面属性。这意味着如果我们有这样一个步骤,在渲染过程中将所有需要的这些属性放入一个缓冲区中(我们通常叫它为几何缓冲区,或G-Buffer),紧接着我们就可以对所有场景中的灯光进行遍历,并为每个像素计算出光照相关的值,这正是延迟渲染的前提。
使用RTX 2070 Mobile渲染带1024点光源的Sponza场景,即便是开启了预视锥体剔除,总体*均帧数仅有40帧:
前向渲染也是有比较大的优化空间的,但现阶段我们只讲能够很快实现的部分。
在绘制一个复杂场景的时候,可能会出现某些区域三角形反复覆盖绘制的情况,增加了很多不必要的绘制。为此我们可以在进行正式渲染前,先对场景中的物体只做深度测试,不执行像素着色阶段;然后第二遍场景绘制的时候只绘制和深度缓冲区中深度值相等的像素。这样做可以有效减少像素着色器的执行次数。
Pre-Z Pass在C++端的代码实现也十分简单,具体可以参考项目中的代码。
注意:在测试帧数差距时务必使用Release模式。
延迟渲染管线的第一步是几何阶段:渲染所有的场景几何体到G-Buffer当中。这些缓冲区通常由一些渲染目标纹理所组成,但它们的各个通道用于逐像素存储几何体表面信息,例如表面法向量和材质属性等。
第二步是光照阶段。在这一步中,可以将渲染表示为当前光照影响屏幕区域的几何体的过程。对于每个待着色的像素片元,将从G-Buffer中采样几何体的信息,然后与光照的属性结合来确定对于该像素产生的光照贡献值,再与所有其它影响那一像素的光源的贡献值累加到一起,来确定最终表面的发光颜色。
因为延迟渲染避免了前向渲染的主要缺陷(光照与几何体紧密耦合),它拥有下面这些优势:
这些优势使得延迟渲染在渲染引擎中非常流行。不幸的是,这种方法也会产生一些缺陷:
现假定我们的场景中只包含一系列的点光源,并且使用的是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,在光栅化过程中,GPU会在同一时刻并行运行很多像素着色器(如32-64个一组)而不是逐像素来执行,然后这些像素将组织在2x2一组的像素分块中。
这里的偏导数对应的是这一像素块中的变化率。ddx就是拿右边像素的值减去左边像素的值,ddy则是拿下面像素的值减去上面像素的值。x和y为屏幕的坐标。
而ddx和ddy系列的函数只能用于像素着色器,是因为它需要依赖的是像素片元中的输入数据来求偏导。
例如,若传递的是posH,则有:
\[dFdx(posH(x,y))=posH(x+1,y)-posH(x,y)==(1,0,z(x+1,y)-z(x,y),0)
\]
我们可以通过对位置求偏导,来求出该像素点处所属三角表面的法线。
float3 ComputeFaceNormal(float3 pos)
{
return cross(ddx_coarse(pos), ddy_coarse(pos));
}
需要注意的是,通过这种方式求出来的法线,会和它对应三角面的所有像素用这种方式求出来的法线是*似相同的,但它不要求传入的顶点有法线数据,因此它适用于顶点没有法线属性的情况。在有顶点法线时,三角面内的像素法线是通过插值得到到的,看起来表面就会比较光滑。
此外ddx/ddy还有精度较低的ddx_coarse/ddy_coarse和精度较高的ddx_fine/ddx_fine版本
实际上,在对纹理进行采样时,选用哪一级的mipmap正是依赖于ddx和ddy的信息。屏幕空间的贴图uv偏导数过大表示贴图理我们过远,就会选择mipLevel更高的子纹理。
要使用延迟渲染,当前的图形库必须要能支持多渲染目标。在C++端,这是通过ID3D11DeviceContext::OMSetRenderTargets
所提供的:
void ID3D11DeviceContext::OMSetRenderTargets(
UINT NumViews,
ID3D11RenderTargetView * const *ppRenderTargetViews,
ID3D11DepthStencilView *pDepthStencilView
);
对Direct3D 11来说,我们最多能够同时设置八个RTV,即像素着色器最多能够同时向八个纹理输出数据。
在HLSL,我们需要通过SV_Target[n]来指定当前变量输出到哪个渲染目标。例如:
struct PixelOut
{
float4 Normal_Specular : SV_Target0;
float4 Diffuse : SV_Target1;
};
PixelOut PS(PixelIn input)
{
// ...
}
回看前面的前向渲染的着色器代码,我们可以得知漫反射颜色、镜面反射颜色的计算公式为:
\[Diffuse=Albedo\times lightContrib \times (N\cdot L)\\
Specular= specAmount\times Albedo \times lightContrib \times (R\cdot V)^{specPow}\\
R=reflect(L, N)
\]
这里只以物体的漫反射贴图作为Albedo的贡献部分。lightContrib受光照强度和衰弱的影响,而衰弱的计算需要用到光源位置与表面位置。在不考虑优化的情况下,G-Buffer需要存放的信息有:
为了让我们的G-Buffer包含所有需要用于计算上面公式的值,我们需要存储12个不同的浮点数到我们的G-Buffer渲染目标当中。因为在一张纹理中,每个像素最多存储4个值,我们使用3个G-Buffer就可以将它们全部存入。我们可以设计出如下的布局:
// | x | y | z | w |
// RT0: | Normal | SpecPower | float4
// RT1: | Albedo | float4
// RT2: | PositionV | SpecAmount | float4
// 注意:该版本的GBuffer与项目中的有差别
struct GBuffer
{
float4 normal_specPow : SV_Target0;
float4 albedo : SV_Target1;
float4 posV_specAmount : SV_Target2;
}
由于表面指向观察点的向量是通过eyePosW - surface.posW
求得的,转换到观察空间后,观察点的坐标为原点,这样只需要传入surface.posV
而不需要在常量缓冲区提供摄像机的eyePosW
,但这样光源需要传入的也是light.posV
,在光照计算时统一在观察空间进行。
在几何阶段,我们使用GeometryVS着色器处理顶点,使用下面基础版本的像素着色器将几何信息写入到GBuffer:
// GBuffer.hlsl
//--------------------------------------------------------------------------------------
// G-buffer 渲染
//--------------------------------------------------------------------------------------
void GBufferPS(VertexPosHVNormalVTex input, out GBuffer outputGBuffer)
{
SurfaceData surface = ComputeSurfaceDataFromGeometry(input);
outputGBuffer.normal_specPow = float4(surface.normalV, surface.specularPower);
outputGBuffer.albedo = surface.albedo;
outputGBuffer.posV_specAmount = float4(surface.posV, surface.specularAmount);
}
然后在光照阶段,我们的顶点着色器要能够覆盖全屏。可以使用如下的代码:
// FullScreenTriangle
// 使用一个三角形覆盖NDC空间
// (-1, 1)________ (3, 1)
// | | /
// (-1,-1)|___|/ (1, -1)
// | /
// (-1,-3)|/
float4 FullScreenTriangleVS(uint vertexID : SV_VertexID) : SV_Position
{
float4 output;
float2 grid = float2((vertexID << 1) & 2, vertexID & 2);
float2 xy = grid * float2(2.0f, -2.0f) + float2(-1.0f, 1.0f);
return float4(xy, 1.0f, 1.0f);
}
这样通过一个超大的三角形覆盖NDC空间,并且不需要提供输入布局和顶点缓冲区数据:
deviceContext->IASetInputLayout(nullptr);
deviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
deviceContext->IASetVertexBuffers(0, 0, nullptr, nullptr, nullptr);
// ...
deviceContext->Draw(3, 0);
像素着色器的基础版本如下:
// Rendering.hlsl
struct SurfaceData
{
float3 posV;
float3 posV_DX; // 忽略
float3 posV_DY; // 忽略
float3 normalV;
float4 albedo;
float specularAmount;
float specularPower;
};
// GBuffer.hlsl
SurfaceData ComputeSurfaceDataFromGBuffer(uint2 posViewport)
{
// 从GBuffer读取数据
GBuffer rawData;
rawData.normal_specPow = g_GBufferTextures[0].Load(posViewport.xy).xyzw;
rawData.albedo = g_GBufferTextures[1].Load(posViewport.xy).xyzw;
rawData.posV_specAmount = g_GBufferTextures[2].Load(posViewport.xy).xyzw;
// 解码到合适的输出
SurfaceData data;
data.posV = rawData.posV_specAmount.xyz;
data.specularPower = rawData.posV_specAmount.w;
data.normalV = rawData.normal_specPow.xyz;
data.specularAmount = rawData.normal_specPow.w;
data.albedo = rawData.albedo;
return data;
}
// BasicDeferred.hlsl
// posViewport表示的是(屏幕坐标xy + 0.5偏移, NDC深度, 1)
float4 BasicDeferredPS(float4 posViewport : SV_Position) : SV_Target
{
uint totalLights, dummy;
g_Light.GetDimensions(totalLights, dummy);
float3 lit = float3(0.0f, 0.0f, 0.0f);
[branch]
if (g_VisualizeLightCount)
{
// 用亮度表示该像素会被多少灯光处理
lit = (float(totalLights) * rcp(255.0f)).xxx;
}
else
{
SurfaceData surface = ComputeSurfaceDataFromGBuffer(uint2(posViewport.xy));
// 避免对天空盒/背景像素着色
if (surface.posV.z < g_CameraNearFar.y)
{
for (uint lightIndex = 0; lightIndex < totalLights; ++lightIndex)
{
PointLight light = g_Light[lightIndex];
AccumulateColor(surface, light, lit);
}
}
}
return float4(lit, 1.0f);
}
渲染完场景后,还需要进行天空盒的渲染。现在的天空盒着色器代码基础版本为:
// SkyboxToneMap.hlsl
//--------------------------------------------------------------------------------------
// 使用天空盒几何体渲染
//--------------------------------------------------------------------------------------
TextureCube<float4> g_SkyboxTexture : register(t5);
Texture2D<float> g_DepthTexture : register(t6);
// 场景渲染的纹理
Texture2D<float4> g_LitTexture : register(t7);
struct SkyboxVSOut
{
float4 posViewport : SV_Position;
float3 skyboxCoord : skyboxCoord;
};
SkyboxVSOut SkyboxVS(VertexPosNormalTex input)
{
SkyboxVSOut output;
// 注意:不要移动天空盒并确保深度值为1(避免裁剪)
output.posViewport = mul(float4(input.posL, 0.0f), g_ViewProj).xyww;
output.skyboxCoord = input.posL;
return output;
}
float4 SkyboxPS(SkyboxVSOut input) : SV_Target
{
uint2 coords = input.posViewport.xy;
float3 lit = float3(0.0f, 0.0f, 0.0f);
float depth = g_DepthTexture.Load(coords);
// 检查天空盒的状态(注意:反转Z!)
// 如果不使用反转Z,则为depth >= 1.0f
[branch]
if (depth <= 0.0f && !g_VisualizeLightCount)
lit += g_SkyboxTexture.Sample(g_SamplerDiffuse, input.skyboxCoord).xyz;
else
lit += sampleLit;
return float4(lit, 1.0f);
}
这些着色器不是最终的版本。
由于渲染一帧的工作量主要来源于像素片元的执行数量和每个像素片元计算用到的光源数量,目前版本的延迟渲染相比前向渲染已经能够减少像素片元的数量,但即便如此,基础的延迟着色法的运行效率依然是低效的,因为目前我们渲染的每个像素仍然与场景中的所有光源所绑定,提升灯光数量都会引起渲染一帧工作量的显著增加。
此外,回顾之前的G-Buffer,如果这些元素都使用float4来存储的话,那么每渲染一个像素就需要驻留48字节。渲染1280x720分辨率需要占用42.2MB显存,而渲染1920x1080分辨率需要占用94.9MB显存。每帧都要驻留差不多100M的显存用于主要渲染,占用了比较多的显存带宽,对性能也会有一定的影响。当然,后续我们可以想办法对这些数据想办法进行一些处理,降低显存带宽的占用。
这样我们对延迟渲染的优化有两个确定的方向:
在这一章我们将讨论前者,下一章则对后者进行讨论。
由于延迟渲染需要同时对一到多个渲染目标进行输出,这会占用较大的显存带宽。如果我们能降低输出的数目,这意味着能减小显存带宽的占用,以及G-Buffer所占用的显存空间。 为了降低G-Buffer的空间,我们可能会采用一些数学方法进行压缩/解压处理,即便这带来了更多的运算量,得益于现代GPU庞大的运算单元,使得给GPU进行更多运算带来的收益比增加显存带宽的占用要大得多。基于这个思想,我们将对存储在G-Buffer中的数据,基于他们的值域、精度需求、打包/解包的开销来寻找合适的存储类型。
对于法线的压缩,我们有两种方向:
DXGI_FORMAT_R8G8B8A8_SNORM
)在这里由于我们有SpecPower
和SpecAmount
两个float,选择第二种方式似乎是一个不错的选择,可以构成float4。并且我们实际上可以用DXGI_FORMAT_R16G16B16A16_FLOAT
来减少8字节的占用。
考虑到法线的性质,我们可以用球面坐标系来表示一个法线。压缩/解压方式如下:
float2 CartesianToSpherical(float3 normal)
{
float2 s;
s.x = atan2(normal.y, normal.x) / 3.14159f;
s.y = normal.z;
return s;
}
float3 SphericalToCartesian(float2 p)
{
float2 sinCosTheta, sinCosPhi;
sincos(p.x * 3.14159f, sinCosTheta.x, sinCosTheta.y);
sinCosPhi = float2(sqrt(1.0f - p.y * p.y), p.y);
return float3(sinCosTheta.y * sinCosPhi.x,
sinCosTheta.x * sinCosPhi.x,
sinCosPhi.y
);
}
这种方式的主要缺点在于它需要使用三角函数(sin,cos,atan2),这些三角函数对运算单元的负担会比较大。如果能有其它的方法避免三角函数的话会更好一些
另一种办法是使用spheremap transformation。这种变换原来是用于将反射向量映射到[0, 1]范围,但它也适用于法向量。它的工作原理是存储在映射上的二维位置,每个位置对应球上的一个法线。具体做法如下:
float2 EncodeSphereMap(float3 normal)
{
return normalize(normal.xy) * (sqrt(-normal.z * 0.5f + 0.5f));
}
float3 DecodeSphereMap(float2 encoded)
{
float4 nn = float4(encoded, 1, -1);
float l = dot(nn.xyz, -nn.xyw);
nn.z = l;
nn.xy *= sqrt(l);
return nn.xyz * 2 + float3(0, 0, -1);
}
编码过程:
\[x'=\frac{x}{\sqrt{x^2+y^2}}\cdot\sqrt{0.5(1-z)}\\
y'=\frac{y}{\sqrt{x^2+y^2}}\cdot\sqrt{0.5(1-z)}\\
\]
解码过程:
\[l=1-(x')^2-(y')^2=1-0.5(1-z)=0.5(1+z)\\
\sqrt{1-z^2}=\sqrt{x^2+y^2}\\
2x'\sqrt{l}=\frac{2x}{\sqrt{x^2+y^2}}\cdot\sqrt{0.5(1-z)\cdot0.5(1+z)}=x\\
2y'\sqrt{l}=\frac{2y}{\sqrt{x^2+y^2}}\cdot\sqrt{0.5(1-z)\cdot0.5(1+z)}=y\\
2\cdot l-1=z
\]
整个运算过程最多就再用到开方,总体效率也比较理想。
由于颜色的值域通常在[0, 1],这意味着我们可以使用无符号规格化的8位整数格式DXGI_FORMAT_R8G8B8A8_UNORM
来存储。当然也可以将颜色值保存在sRGB空间,因为这通常用于漫反射纹理的存储格式。使用DXGI_FORMAT_R8G8B8A8_UNORM_SRGB
会让硬件对像素着色器的输出执行sRGB转换。还有就是如果alpha通道不怎么需要用到的话也可以用DXGI_FORMAT_R10G10B16A2_UNORM
来提升RGB的精度
注意:sRGB指的是标准的RGB颜色空间,通常用于图像文件和显示设备。使用sRGB使更多的精度用于较暗的颜色值,符合人类眼睛对这些颜色区域的自然敏感性
位置通常需要高精度,且它要用于高频阴影计算/光照计算中。为了存储世界空间或观察空间中的坐标,甚至16位的浮点数格式通常不足以避免Artifacts。幸运的是,我们还有别的方式存储完整的XYZ值。在执行像素着色器的时候,屏幕空间坐标XY是可以通过SV_Position语义取得的,然后在渲染场景几何体的时候,通过用到的观察变换和投影变换矩阵是有可能恢复出观察空间或世界空间的坐标的。
对屏幕中的任意一个像素,都有一个表示从摄像机到像素位置对应远*面一点的方向向量。我们可以利用屏幕空间XY坐标来根据视锥体远*面的四个角点进行线性插值来构建方向向量,然后标准化(如果你在观察空间做这件事的话,就不需要再减去摄像机的位置)。如果几何体光栅化在当前像素位置,这意味着当前表面通过之前构建的方向向量从摄像机方向移动是可以接触到的。我们只要再有摄像机到表面的距离就可以构建出该表面的坐标了:
// 重建位置的过程,当前GBuffer需要提供一个float存储摄像机到表面的距离
// GBuffer顶点着色器
vOut.posV = mul(PosL, g_WorldView).xyz;
// GBuffer像素着色器
gBuffer.distance.x = length(gBuffer.posV);
// 光照阶段顶点着色器
vOut.ViewRay = posW - g_CamPosW;
// 光照阶段像素着色器
float3 viewRay = normalize(pIn.viewRay);
float viewDistance = g_TextureDistance.Sample(g_Sam, pIn.tex);
float3 posW = g_CamPosW + viewRay * viewDistance;
这为我们提供了一种灵活而又相当有效的方法,可以从存储在G-Buffer中的单一高精度值中重建位置。
然而如果我们只考虑观察空间的话,仍可以做进一步的优化:我们有办法避免存储多一个值,即通过透视投影的参数,我们可以从屏幕空间反推到观察空间。仔细观察下面的透视投影变换:
\[\begin{align}
[x,y,z,1]\begin{bmatrix}
m_{00} & 0 & 0 & 0 \\
0 & m_{11} & 0 & 0 \\
0 & 0 & m_{22} & 1 \\
0 & 0 & m_{32} & 0 \\
\end{bmatrix}&=[m_{00}x,m_{11}y,m_{22}z+m_{32},z]\\
&\Rightarrow[\frac{m_{00}x}{z},\frac{m_{11}y}{z},m_{22}+\frac{B}{m_{32}}, 1]
\end{align}\\
z_{ndc}=m_{22}+\frac{m_{32}}{z}
\]
其中ndc坐标我们可以通过像素着色器输入的屏幕坐标和屏幕宽高的信息求出来,这样逆变换的条件也足够了:
\[z=\frac{m_{32}}{z_{ndc}-m_{22}}\\
x=\frac{x_{ndc}\cdot z}{m_{00}}\\
y=\frac{y_{ndc}\cdot z}{m_{11}}
\]
在着色器代码中,重建观察空间坐标的方式如下:
float3 ComputePositionViewFromZ(float2 posNdc, float viewSpaceZ)
{
float2 screenSpaceRay = float2(posNdc.x / g_Proj._m00,
posNdc.y / g_Proj._m11);
float3 posV;
posV.z = viewSpaceZ;
posV.xy = screenSpaceRay.xy * posV.z;
return posV;
}
// ...
float2 gbufferDim;
uint dummy;
g_GBufferTextures[0].GetDimensions(gbufferDim.x, gbufferDim.y, dummy);
float2 screenPixelOffset = float2(2.0f, -2.0f) / gbufferDim;
float2 posNdc = (float2(posViewport.xy) + 0.5f) * screenPixelOffset.xy + float2(-1.0f, 1.0f);
// ndcZ = A + B/z => z = B/(ndcZ - A)
float viewSpaceZ = g_Proj._m32 / (ndcZ - g_Proj._m22);
float3 posV = ComputePositionViewFromZ(posNdc, viewSpaceZ);
然后是深度值的问题。观察空间的深度值在经过透视投影后变为:
\[d=a\frac{1}{z}+b
\]
其中a,b与*/远*面的设置关联。把重映射后的深度值等距采样,在该函数的图像如下:
可以看到d的大部分区域落在了**面上。然而考虑浮点数的精度问题,它的数值分布大部分也是落在0上,如果选出浮点数能表示的0-1范围的所有d值,对应的图如下:
经过映射后发现这种现象被加剧了,绝大部分的点都被映射到**面上,这是一种浪费。且靠* 远*面 的z值,在经过映射后得到的d,受到精度的限制变得会更加难以区分,使得多个相*的深度值被映射到同一个d值的可能性被放大,导致出现Z-Fighting的问题。
一种广为人知的方法为反向Z,让**面映射到d=1,远*面映射到d=0:
可以看到,反向Z可以利用浮点数的特性,让其在靠* 远*面 的分布也能变得较为均匀,从而较为良好地抵消掉重映射所带来的影响。这可以使Z-Fighting出现问题的可能性大幅降低。
我们只需要:
在C++代码中将视锥体的*/远*面对调:
m_pCamera->SetFrustum(XM_PI / 3, AspectRatio(), 300.0f, 0.5f);
在清空深度缓冲区时用0清空:
m_pd3dImmediateContext->ClearDepthStencilView(m_pDepthBuffer->GetDepthStencil(), D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 0.0f, 0);
在深度测试时使用>=比较方式(使用>=是为了兼容Forward PreZ-Pass,在实际绘制时能同时处理>和=的情况):
m_pd3dImmediateContext->SetDepthStencilState(RenderStates::DSSGreaterEqual.Get(), 0);
但是在构造碰撞视锥体的时候,别忘了把*/远*面交换回来:
BoundingFrustum::CreateFromMatrix(frustum, DirectX::XMMatrixPerspectiveFovLH(m_pCamera->GetFovY(),
m_pCamera->GetAspectRatio(), m_pCamera->GetFarZ(), m_pCamera->GetNearZ()));
首先复习一下超采样(SSAA)。现渲染WxH大小的屏幕,为了降低走样/锯齿的影响,可以使用2倍宽高的后备缓冲区及深度缓冲区,光栅化的操作量增大到了4倍左右,同样像素着色器的执行次数也增大到了原来的4倍左右。每个2x2的像素块对应原来的1个像素,通过对这2x2的像素块求*均值得到目标像素的颜色。
多重采样(MSAA)采用的是一种较为折中的方案。以4xMSAA为例,指的是单个像素需要按特定的模式对周围采样4次:
假定当前像素位置为(x+0.5,y+0.5),在D3D11定义的采样模式下,这四个采样点的位置为:(x+0.375, y+0.125)、(x+0.875, y+0.375)、(x+0.125, y+0.625)、(x+0.625, y+0.875)
在光栅化阶段,在一个像素区域内使用4个子采样点,但每个像素仍只执行1次像素着色器的计算。这4个子采样点都会计算自己的深度值,然后根据深度测试和三角形覆盖性测试来决定是否复制该像素的计算结果。为此深度缓冲区和渲染目标需要的空间依然为原来的4倍。
假设三角形1覆盖了2-3号采样,且通过了深度测试,则2-3号采样复制像素着色器得到的红色。此时渲染三角形2覆盖了1号采样,且通过了深度测试,则1号采样复制像素着色器得到的蓝色。在渲染完三角形后,对这四个采样点的颜色进行一个resolve操作,可以得到当前像素最终的颜色。这里我们可以认为是对这四个采样点的颜色求*均值。
void ID3D11DeviceContext::ResolveSubresource(
ID3D11Resource *pDstResource, // [In]目标非多重采样资源
UINT DstSubresource, // [In]目标子资源索引
ID3D11Resource *pSrcResource, // [In]源多重采样资源
UINT SrcSubresource, // [In]源子资源索引
DXGI_FORMAT Format // [In]解析成非多重采样资源的格式
);
该API最常用于需要将当前pass的渲染目标结果作为下一个pass的输入。源/目标资源必须拥有相同的资源类型和相同的维度。此外,它们必须拥有兼容的格式:
DXGI_FORMAT_R8G8B8A8_UNORM
,在Format中一样要填上DXGI_FORMAT_R32_FLOAT
和DXGI_FORMAT_R32_TYPELESS
,在Format参数中必须指定DXGI_FORMAT_R32_FLOAT
DXGI_FORMAT_R32_TYPELESS
,那么在Format参数中可以指定DXGI_FORMAT_R32_FLOAT
或DXGI_FORMAT_R32_UINT
等若后备缓冲区创建的时候指定了多重采样,正常渲染完后到呈现(Present)时会自动调用ResolveSubresource
方法得到用于显示的非多重采样纹理。当然这仅限于交换链使用的是BLIT模型而非FLIP模型,因为翻转模型的后备缓冲区不能使用多重采样纹理,要使用MSAA我们需要另外新建一个等宽高的MSAA纹理先渲染到此处,然后ResolveSubresource
到后备缓冲区。
前面我们已经知道了如果创建多重采样资源并作为渲染目标使用。而为了能够在着色器使用多重采样纹理,需要在HLSL声明如下类型的资源:
Texture2DMS<type, sampleCount> g_Texture : register(t*)
非多重采样的纹理设置的sampleCount是1,除了Texture2D
类型,还可以传入到Texture2DMS<float4, 1>
中使用
Texture2DMS
类型只支持读取,不支持采样:
T Texture2DMS<T>::Load(
in int2 coord,
in int sampleIndex
);
延迟着色与多重采样的兼容性并不是很好,但不代表延迟着色不能使用多重采样。当然使用FXAA/TAA这些后处理会更好都是后话。现在需要补充多重采样与Direct3D 11相关的应用内容。
给延迟着色添加多重采样实际上是在几何阶段进行的,光照阶段完成的相当于是Resolve的操作。渲染流程如下:
在几何阶段:
在光照阶段:
如果我们对全屏区域进行逐样本着色,那本质上进行的就是SSAA(超采样)了。而真正需要逐样本着色的地方通常是位于物体边界的情况。逐样本着色的像素着色器需要添加SV_SampleIndex
:
float4 PS(float4 posViewPort : SV_Position, uint sampleIndex : SV_SampleIndex) : SV_Target
{
// ...
}
目前的GBuffer布局如下:
struct GBuffer
{
float4 normal_specular : SV_Target0; // R16G16B16A16_FLOAT
float4 albedo : SV_Target1; // R8G8B8A8_UNORM
float2 posZGrad : SV_Target2; // R16G16_FLOAT
};
其中新增的posZGrad用于后面的逐样本绘制需求检测。目前这套G-Buffer每个像素只需要占据16字节,相比原来的48字节已经少了2/3的显存占用。当然,如果使用了4xMSAA又要开多4倍的显存。
几何阶段的着色器如下:
//
// 源自Rendering.hlsl
//
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;
}
//
// GBuffer.hlsl
//
struct GBuffer
{
float4 normal_specular : SV_Target0;
float4 albedo : SV_Target1;
float2 posZGrad : SV_Target2; // ( d(x+1,y)-d(x,y), d(x,y+1)-d(x,y) )
};
// 法线编码
float2 EncodeSphereMap(float3 normal)
{
return normalize(normal.xy) * (sqrt(-normal.z * 0.5f + 0.5f));
}
//--------------------------------------------------------------------------------------
// G-buffer 渲染
//--------------------------------------------------------------------------------------
void GBufferPS(VertexPosHVNormalVTex input, out GBuffer outputGBuffer)
{
SurfaceData surface = ComputeSurfaceDataFromGeometry(input);
outputGBuffer.normal_specular = float4(EncodeSphereMap(surface.normalV),
surface.specularAmount,
surface.specularPower);
outputGBuffer.albedo = surface.albedo;
outputGBuffer.posZGrad = float2(ddx_coarse(surface.posV.z),
ddy_coarse(surface.posV.z));
}
顶点着色器使用GeometryVS,像素着色器使用GBufferPS,然后开启>或>=深度测试比较
// GBuffer.hlsl
struct GBuffer
{
float4 normal_specular : SV_Target0;
float4 albedo : SV_Target1;
float2 posZGrad : SV_Target2; // ( d(x+1,y)-d(x,y), d(x,y+1)-d(x,y) )
};
// 上述GBuffer加上深度缓冲区(最后一个元素) t1-t4
Texture2DMS<float4, MSAA_SAMPLES> g_GBufferTextures[4] : register(t1);
// 检查一个给定的像素是否需要进行逐样本着色
bool RequiresPerSampleShading(SurfaceData surface[MSAA_SAMPLES])
{
bool perSample = false;
const float maxZDelta = abs(surface[0].posV_DX.z) + abs(surface[0].posV_DY.z);
const float minNormalDot = 0.99f; // 允许大约8度的法线角度差异
[unroll]
for (uint i = 1; i < MSAA_SAMPLES; ++i)
{
// 使用三角形的位置偏移,如果所有的采样深度存在差异较大的情况,则有可能属于边界
perSample = perSample ||
abs(surface[i].posV.z - surface[0].posV.z) > maxZDelta;
// 若法线角度差异较大,则有可能来自不同的三角形/表面
perSample = perSample ||
dot(surface[i].normalV, surface[0].normalV) < minNormalDot;
}
return perSample;
}
// 使用逐采样(1)/逐像素(0)标志来初始化模板掩码值
void RequiresPerSampleShadingPS(float4 posViewport : SV_Position)
{
SurfaceData surfaceSamples[MSAA_SAMPLES];
ComputeSurfaceDataFromGBufferAllSamples(uint2(posViewport.xy), surfaceSamples);
bool perSample = RequiresPerSampleShading(surfaceSamples);
// 如果我们不需要逐采样着色,抛弃该像素片元(例如:不写入模板)
[flatten]
if (!perSample)
{
discard;
}
}
顶点着色器使用全屏三角形绘制,像素着色器使用RequiresPerSampleShadingPS,然后模板写入,对需要逐采样着色的像素标记为1,其余为0的默认进行逐像素着色
float3 DecodeSphereMap(float2 encoded)
{
float4 nn = float4(encoded, 1, -1);
float l = dot(nn.xyz, -nn.xyw);
nn.z = l;
nn.xy *= sqrt(l);
return nn.xyz * 2 + float3(0, 0, -1);
}
SurfaceData ComputeSurfaceDataFromGBufferSample(uint2 posViewport, uint sampleIndex)
{
// 从GBuffer读取数据
GBuffer rawData;
rawData.normal_specular = g_GBufferTextures[0].Load(posViewport.xy, sampleIndex).xyzw;
rawData.albedo = g_GBufferTextures[1].Load(posViewport.xy, sampleIndex).xyzw;
rawData.posZGrad = g_GBufferTextures[2].Load(posViewport.xy, sampleIndex).xy;
float zBuffer = g_GBufferTextures[3].Load(posViewport.xy, sampleIndex).x;
float2 gbufferDim;
uint dummy;
g_GBufferTextures[0].GetDimensions(gbufferDim.x, gbufferDim.y, dummy);
// 计算屏幕/裁剪空间坐标和相邻的位置
// 注意:需要留意DX11的视口变换和像素中心位于(x+0.5, y+0.5)位置
// 注意:该偏移实际上可以在CPU预计算但将它放到常量缓冲区读取实际上比在这里重新计算更慢一些
float2 screenPixelOffset = float2(2.0f, -2.0f) / gbufferDim;
float2 posNdc = (float2(posViewport.xy) + 0.5f) * screenPixelOffset.xy + float2(-1.0f, 1.0f);
float2 posNdcX = posNdc + float2(screenPixelOffset.x, 0.0f);
float2 posNdcY = posNdc + float2(0.0f, screenPixelOffset.y);
// 解码到合适的输出
SurfaceData data;
// 反投影深度缓冲Z值到观察空间
float viewSpaceZ = g_Proj._m32 / (zBuffer - g_Proj._m22);
data.posV = ComputePositionViewFromZ(posNdc, viewSpaceZ);
data.posV_DX = ComputePositionViewFromZ(posNdcX, viewSpaceZ + rawData.posZGrad.x) - data.posV;
data.posV_DY = ComputePositionViewFromZ(posNdcY, viewSpaceZ + rawData.posZGrad.y) - data.posV;
data.normalV = DecodeSphereMap(rawData.normal_specular.xy);
data.albedo = rawData.albedo;
data.specularAmount = rawData.normal_specular.z;
data.specularPower = rawData.normal_specular.w;
return data;
}
float4 BasicDeferred(float4 posViewport, uint sampleIndex)
{
uint totalLights, dummy;
g_Light.GetDimensions(totalLights, dummy);
float3 lit = float3(0.0f, 0.0f, 0.0f);
[branch]
if (g_VisualizeLightCount)
{
// 用亮度表示该像素会被多少灯光处理
lit = (float(totalLights) * rcp(255.0f)).xxx;
}
else
{
SurfaceData surface = ComputeSurfaceDataFromGBufferSample(uint2(posViewport.xy), sampleIndex);
// 避免对天空盒/背景像素着色
if (surface.posV.z < g_CameraNearFar.y)
{
for (uint lightIndex = 0; lightIndex < totalLights; ++lightIndex)
{
PointLight light = g_Light[lightIndex];
AccumulateColor(surface, light, lit);
}
}
}
return float4(lit, 1.0f);
}
float4 BasicDeferredPS(float4 posViewport : SV_Position) : SV_Target
{
return BasicDeferred(posViewport, 0);
}
顶点着色器使用全屏三角形绘制,像素着色器使用BasicDeferredPS,然后模板测试要求模板值为0才能进行着色,混合采用的是加法混合。由于在逐像素绘制的区域中,G-Buffer的4个子采样存的内容是相同的(某种意义上这也是一种浪费),我们可以直接取子采样0的信息进行着色。
float4 BasicDeferredPerSamplePS(float4 posViewport : SV_Position,
uint sampleIndex : SV_SampleIndex) : SV_Target
{
float4 result;
if (g_VisualizePerSampleShading)
{
result = float4(1, 0, 0, 1);
}
else
{
result = BasicDeferred(posViewport, sampleIndex);
}
return result;
}
顶点着色器使用全屏三角形绘制,像素着色器使用BasicDeferredPerSamplePS,然后模板测试要求模板值为1才能进行着色,混合采用的是加法混合。
// SkyboxToneMap.hlsl
//--------------------------------------------------------------------------------------
// 后处理, 天空盒等
// 使用天空盒几何体渲染
//--------------------------------------------------------------------------------------
TextureCube<float4> g_SkyboxTexture : register(t5);
Texture2DMS<float, MSAA_SAMPLES> g_DepthTexture : register(t6);
// 常规多重采样的场景渲染的纹理
Texture2DMS<float4, MSAA_SAMPLES> g_LitTexture : register(t7);
struct SkyboxVSOut
{
float4 posViewport : SV_Position;
float3 skyboxCoord : skyboxCoord;
};
SkyboxVSOut SkyboxVS(VertexPosNormalTex input)
{
SkyboxVSOut output;
// 注意:不要移动天空盒并确保深度值为1(避免裁剪)
output.posViewport = mul(float4(input.posL, 0.0f), g_ViewProj).xyww;
output.skyboxCoord = input.posL;
return output;
}
float4 SkyboxPS(SkyboxVSOut input) : SV_Target
{
uint2 coords = input.posViewport.xy;
float3 lit = float3(0.0f, 0.0f, 0.0f);
float skyboxSamples = 0.0f;
#if MSAA_SAMPLES <= 1
[unroll]
#endif
for (unsigned int sampleIndex = 0; sampleIndex < MSAA_SAMPLES; ++sampleIndex)
{
float depth = g_DepthTexture.Load(coords, sampleIndex);
// 检查天空盒的状态(注意:反向Z!)
if (depth <= 0.0f && !g_VisualizeLightCount)
{
++skyboxSamples;
}
else
{
lit += g_LitTexture.Load(coords, sampleIndex).xyz;
}
}
// 如果这里没有场景渲染,则渲染天空盒
[branch]
if (skyboxSamples > 0)
{
float3 skybox = g_SkyboxTexture.Sample(g_SamplerDiffuse, input.skyboxCoord).xyz;
lit += skyboxSamples * skybox;
}
// Resolve 多重采样(简单盒型滤波)
return float4(lit * rcp(MSAA_SAMPLES), 1.0f);
}
顶点着色器使用SkyboxVS,像素着色器使用SkyboxPS,光栅化取消背面裁剪。
目前对原来的代码做了比较多的改动,比较重要的包括:
具体的C++代码细节建议直接阅读源码。
由于可改变的选项很多,演示起来也比较麻烦,这里只放出截屏,然后具体描述各自的功能
在下一章我们将会重点讨论光照裁剪技术。
Deferred Rendering for Current and Future Rendering Pipelines
Compact Normal Storage for small G-Buffers · Aras' website (aras-p.info)
DirectX11 With Windows SDK完整目录
欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。
手机扫一扫
移动阅读更方便
你可能感兴趣的文章