相关文章推荐
面冷心慈的日光灯  ·  mysql存储过程if ...·  1 年前    · 
瘦瘦的野马  ·  message ...·  1 年前    · 
犯傻的葡萄  ·  java生成随机颜色 ...·  1 年前    · 
DX12阴影贴图篇:实时阴影的实现

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)
	............