DX12阴影贴图篇:实时阴影的实现
紧接上篇,我们准备好了数据,要开始渲染ShadowMap,并使用ShadowMap计算阴影了,我们分以下几个步骤实现:
一、绘制ShadowMap
二、阴影偏移(ShadowDepthBias)
三、绘制阴影图shader
四、PCF(百分比渐进过滤)
五、阴影和光照的混合
一、绘制ShadowMap
将场景深度绘制到阴影图中,我们使用DrawSceneToShadowMap函数封装。由于阴影图中只有深度信息,没有颜色信息,所以在设置RT(渲染目标)的时候,将其设置为空。注意,为了使代码更清晰,我删除了“动态CubeMap”功能,所以在设置“阴影图渲染”的PassCB地址的时候,直接偏移一个地址。
void ShapesApp::DrawSceneToShadowMap()
//设置视口和裁剪矩形
auto viewPort = mShadowMap->Viewport();
cmdList->RSSetViewports(1, &viewPort);
auto scissorRect = mShadowMap->ScissorRect();
cmdList->RSSetScissorRects(1, &scissorRect);
// 将深度图资源转成写入状态(渲染深度信息)
auto resourceBarrier0 = CD3DX12_RESOURCE_BARRIER::Transition(mShadowMap->Resource(),
D3D12_RESOURCE_STATE_GENERIC_READ, D3D12_RESOURCE_STATE_DEPTH_WRITE);
cmdList->ResourceBarrier(1, &resourceBarrier0);
UINT passCBByteSize = CalcConstantBufferByteSize(sizeof(PassConstants));
// 清除深度缓存
cmdList->ClearDepthStencilView(mShadowMap->Dsv(),
D3D12_CLEAR_FLAG_DEPTH | D3D12_CLEAR_FLAG_STENCIL, 1.0f, 0, 0, nullptr);
//将渲染目标设置为空,禁止向后台缓冲区写入颜色
auto DSVHandle = mShadowMap->Dsv();
cmdList->OMSetRenderTargets(0, //RenderTarget数量为0
nullptr, //RTV指针为空
false, //内存不连续存放
&DSVHandle);//DSV句柄
// 为阴影图渲染绑定所需的常量缓冲区
auto passCB = currFrameResources->passCB->Resource();
// 阴影图PassCB地址为:1个场景PassCB之后
D3D12_GPU_VIRTUAL_ADDRESS passCBAddress = passCB->GetGPUVirtualAddress() + 1 * passCBByteSize;
cmdList->SetGraphicsRootConstantBufferView(1, passCBAddress);
// 设置PSO并绘制
cmdList->SetPipelineState(PSOs["ShadowOpaque"].Get());
DrawRenderItems(ritemLayer[(int)RenderLayer::Opaque]);
// 将阴影图资源设置成可读,这样才能在shader中采样阴影图
auto resourceBarrier1 = CD3DX12_RESOURCE_BARRIER::Transition(mShadowMap->Resource(),
D3D12_RESOURCE_STATE_DEPTH_WRITE, D3D12_RESOURCE_STATE_GENERIC_READ);
cmdList->ResourceBarrier(1, &resourceBarrier1);
因为不输出颜色,所以我们需要单独创建一个shader来渲染深度,所以要设置单独的PSO,上面代码中使用了ShadowOpaque的PSO,PSO代码如下所示。设置hlsl文件以及禁止颜色输出,这些都没问题,但是“偏移量”那些是什么?其实这涉及到“阴影偏移”,下面着重讲述下。
D3D12_GRAPHICS_PIPELINE_STATE_DESC shadowMapPsoDesc = opaquePsoDesc;
shadowMapPsoDesc.RasterizerState.DepthBias = 100000;//固定的偏移量
shadowMapPsoDesc.RasterizerState.DepthBiasClamp = 0.0f;// 允许的最大深度偏移量
shadowMapPsoDesc.RasterizerState.SlopeScaledDepthBias = 1.0f;//根据多边形斜率来控制偏移成都的缩放因子
shadowMapPsoDesc.pRootSignature = rootSignature.Get();
shadowMapPsoDesc.VS = { reinterpret_cast<BYTE*>(shaders["ShadowMapVS"]->GetBufferPointer()), shaders["ShadowMapVS"]->GetBufferSize() };
shadowMapPsoDesc.PS = { reinterpret_cast<BYTE*>(shaders["ShadowMapPS"]->GetBufferPointer()), shaders["ShadowMapPS"]->GetBufferSize() };
// 阴影图的渲染过程无需涉及渲染目标
shadowMapPsoDesc.RTVFormats[0] = DXGI_FORMAT_UNKNOWN;
shadowMapPsoDesc.NumRenderTargets = 0; //没有渲染目标,禁止颜色输出
ThrowIfFailed(d3dDevice->CreateGraphicsPipelineState(&shadowMapPsoDesc, IID_PPV_ARGS(&PSOs["ShadowOpaque"])));
二、阴影偏移(ShadowDepthBias)
使用过unity的朋友应该知道,在阴影控制面板,有个DepthBias滑动条,如果偏移值为0,就会出现下面的情况(仔细看地面),一条一条的阴影,这就是所谓的shadow acne(阴影粉刺)。
出现shadowAcne的原因:阴影图的分辨率有限的,以致每一个阴影图纹素都要表示场景中一片区域,也就是说一个纹素范围可能对应好几个屏幕像素。请看下图,p1和p2是不同的两个屏幕像素,但都对应着同一个shadowMap纹素,所以他们的屏幕深度都要和shadowMap纹素深度s去比较,显然,p1的屏幕深度大于s,而p2的屏幕深度小于s,这就会出现p1在阴影中,而p2却不在阴影中(其实它俩都不在阴影中),这就是出现shadowAcne的原因。
解决办法也很简单,减小多边形片元的深度值,见下图所示,ScenePoly拉高了(减小了深度),这样p1和p2的深度同时小于s,两个像素都不在阴影中。既然可以减小多边形片元的深度,那也可以增加shadowMap纹素的深度,DX12选择了后者,PSO中的那3个属性便是控制阴影图深度偏移量的。
那是不是偏移量越大越好呢?也不是的,如果偏移太大,会造成peter-panning,阴影和物体分离,所以一个合适的偏移值很重要,并且偏移值还和多边形的斜率有关,上述代码中有个参数就是根据斜率调节偏移值的。
三、绘制阴影图shader
绘制shadowMap的shader很简单,因为不输出颜色,所以像素着色器为空,顶点着色器只需做基本的空间变换即可。
#include "Common.hlsl"
struct VertexIn
float3 PosL : POSITION;
float2 TexCoord : TEXCOORD;
struct VertexOut
float4 PosH : SV_POSITION;
float2 UV : TEXCOORD;
VertexOut VS(VertexIn vin)
VertexOut vout;
MaterialData matData = gMaterialData[gMaterialDataIndex];
float4 posW = mul(float4(vin.PosL, 1.0f), gWorld);//将顶点变换到世界坐标
vout.PosH = mul(posW, gViewProj);//将顶点变换到齐次裁剪空间
float4 texC = mul(float4(vin.TexCoord, 0.0f, 1.0f), gTexTransform);
vout.UV = mul(texC, matData.gMatTransform).xy;
return vout;
void PS(VertexOut pin)
}
此次案例我们将绘制好的shadowMap显示在屏幕上。
首先我们创建一个面片,代码略。然后构建面片的渲染项,因为它始终在屏幕前,所以L2W矩阵是单位矩阵,最后将其分到Debug层中。
auto quadRitem = std::make_unique<RenderItem>();
quadRitem->world = MathHelper::Identity4x4();
quadRitem->texTransform = MathHelper::Identity4x4();
quadRitem->objCBIndex = 2;
quadRitem->mat = materials["bricks"].get();
quadRitem->geo = geometries["debugQuadGeo"].get();
quadRitem->primitiveType = D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST;
quadRitem->indexCount = quadRitem->geo->DrawArgs["debugQuad"].indexCount;
quadRitem->startIndexLocation = quadRitem->geo->DrawArgs["debugQuad"].startIndexLocation;
quadRitem->baseVertexLocation = quadRitem->geo->DrawArgs["debugQuad"].baseVertexLocation;
ritemLayer[(int)RenderLayer::Debug].push_back(quadRitem.get());
allRitems.push_back(std::move(quadRitem));
我们需要单独一个shader来将采样阴影图并将其渲染到面片上,所以新建一个Debug的PSO
D3D12_GRAPHICS_PIPELINE_STATE_DESC debugPsoDesc = opaquePsoDesc;
debugPsoDesc.pRootSignature = rootSignature.Get();
debugPsoDesc.VS =
reinterpret_cast<BYTE*>(shaders["DebugVS"]->GetBufferPointer()),
shaders["DebugVS"]->GetBufferSize()
debugPsoDesc.PS =
reinterpret_cast<BYTE*>(shaders["DebugPS"]->GetBufferPointer()),
shaders["DebugPS"]->GetBufferSize()
ThrowIfFailed(d3dDevice->CreateGraphicsPipelineState(&debugPsoDesc, IID_PPV_ARGS(&PSOs["Debug"])));
然后在shader中采样阴影图,并返回最终颜色
float4 PS(VertexOut pin) : SV_Target
return float4(gShadowMap.Sample(gSamLinearWarp, pin.TexC).rrr, 1.0f);
}
运行后如下图所示,图中每个纹素都记录着场景中的深度。
四、PCF(百分比渐进过滤)
绘制完了阴影图,我们就需要在着色器中采样它。由于UV坐标往往不能直接命中纹素坐标。还记得我们是怎样使用UV坐标采样颜色贴图的吗?没错,使用BiLinear双线性插值。但是同样的方法我们却不能用于采样阴影图,而得采用一种新的方法,这种方法叫做PCF,百分比渐进过滤。
如下图所示,S0、S1、S2、S3为4个纹素坐标,中间的UV点是UV坐标,将UV坐标加上一个单位的纹素宽度和高度(dertaX和dertaY代表纹素宽高),就能得到UV坐标周围的包括自身一共4个坐标,这4个坐标分别使用“点过滤”采样得到最近的采样点,分别是S0-S3,然后使用该UV坐标所对应的场景深度值分别和S0-S4比较,得到4个布尔值,使用uv坐标在一个纹素内的位置作为插值alpha,双线性插值这4个布尔值,得到的值就是阴影的灰度值,这种算法就叫PCF。注意我们能得到灰度值,即软阴影,所以PCF也是软阴影的核心算法。这里可以参看龙书P604,结合上面的伪代码,能更好理解其中原理。
我们这里使用了4个UV坐标,所以这种PCF也称作4-tap PCF,而此次案例我们会使用9-tap PCF,即以UV为中心,横竖3排3列组成9个PCF核,然后求其插值,核越多也意味着阴影边缘过度越柔和,也就是阴影越软。DX12可以调用SampleCmpLevelZero函数来执行PCF,并最大程度的优化采样过程。
接下来上代码,我们创建一个CalcShadowFactor函数来计算PCF,可以看到PCF计算时,传入了“阴影图”、“UV坐标偏移值”、“场景像素深度”、“采样器”,这些参数都是计算PCF所必须的。
// 为阴影图构建PCF,返回阴影因子
float CalcShadowFactor(float4 shadowPosH)
// 将顶点变换到NDC空间(如果是正交投影,则W=1)
shadowPosH.xyz /= shadowPosH.w;
// NDC空间中的深度值
float depth = shadowPosH.z;
// 读取ShadowMap的宽高及mip级数
uint width, height, numMips;
gShadowMap.GetDimensions(0, width, height, numMips);
// 纹素尺寸
float dx = 1.0f / (float) width;
float percentLit = 0.0f;
// 使用9核
const float2 offsets[9] =
float2(-dx, -dx), float2(0.0f, -dx), float2(dx, -dx),
float2(-dx, 0.0f), float2(0.0f, 0.0f), float2(dx, 0.0f),
float2(-dx, +dx), float2(0.0f, +dx), float2(dx, +dx)
// PCF(类似均值模糊算法)
[unroll]
// 执行9次4-tap PCF
for (int i = 0; i < 9; ++i)
// 每个核都执行tap4 PCF计算
percentLit += gShadowMap.SampleCmpLevelZero(gsamShadow,
shadowPosH.xy + offsets[i], depth).r;
// 将9次PCF取均值
return percentLit / 9.0f;
上面提到采样器,纹理采样当然需要采样器,不同于采样颜色贴图,这里做PCF的采样需要使用“比较采样器”,这使硬件能够执行阴影图的比较测试,且需要在过滤采样结果之前完成。
DX12使用D3D12_COMPARISON_FUNC_LESS_EQUAL来执行比较操作。
CD3DX12_STATIC_SAMPLER_DESC shadow(6, // 着色器寄存器
D3D12_FILTER_COMPARISON_MIN_MAG_LINEAR_MIP_POINT, // filter
D3D12_TEXTURE_ADDRESS_MODE_BORDER, // U方向上的寻址模式为BORDER
D3D12_TEXTURE_ADDRESS_MODE_BORDER, // V方向上的寻址模式为BORDER
D3D12_TEXTURE_ADDRESS_MODE_BORDER, // W方向上的寻址模式为BORDER
0.0f, // mipLODBias
16, // maxAnisotropy
D3D12_COMPARISON_FUNC_LESS_EQUAL, //执行阴影图的比较测试
D3D12_STATIC_BORDER_COLOR_OPAQUE_BLACK);
需要注意的是,PCF技术只需在阴影边缘处执行,因为阴影内部是不渐变的,无需使用昂贵的PCF算法,庆幸的是这些工作DX12都为我们做好了。
我们单独测试一下PCF,看下软硬阴影的区别。将PCF计算中累加的offsets数组改成4号元素,即中间的0号,这样相当于没有做PCF。
for (int i = 0; i < 9; ++i)
// 每个核都执行tap4 PCF计算
percentLit += gShadowMap.SampleCmpLevelZero(gsamShadow,
shadowPosH.xy + offsets[4], depth).r;
// 将9次PCF取均值
return percentLit / 9.0f;
编译运行,可以看到阴影边缘的锯齿。
将代码改回去后,明显阴影边缘有了过度。
五、阴影光照混合
将阴影和光照混合,阴影因子乘入即可。注意,我们只让主光产生阴影,所以这里的shadowFactor数组只有一个0号元素。
for(i = 0; i < NUM_DIR_LIGHTS; i++)
//多个平行光的光照叠加(有阴影)
result += shadowFactor[i] * ComputerDirectionalLight(lights[i], mat, normal, toEye);
}
编译运行后却没有阴影,结果是忘了将ShadowTransform传入流水线了。注意我们要将其传入mainPassCB,而不是ShadowMapPassCB,因为阴影是在绘制主场景时使用阴影图计算的,而阴影图的绘制才是使用的ShadowMapPassCB
void ShapesApp::UpdatePassCB(const GameTime& gt)
............