/
一. 阴影是如何产生的
在自然界中,一个不自发光的物体要被看见,是需要光源照射的。由于光是沿直线传播的,当光线被某些物体(图中橘色物体)遮挡后,那些本来有颜色的区域(点C)因为没有照射而变回黑色,这些区域就是阴影。
二. 如何用 ShadowMap 生成阴影
1. ShadowMap 原理
理论上,在绘制点的颜色时,只要判断该点有没有被“遮挡”,就知道是否要绘制成阴影。 而判断“遮挡”的方案有很多,最常用的就是 ShadowMap。 我们只要知道该点与光源的连线上,有没有比它离光源更近的点存在。其中点与光源的距离,在 ShadowMap 中就是深度。具体的做法是:
- (1) 生成深度纹理图:所谓深度纹理图,就是每个位置的最小深度。我们站在光源的位置,按照光线传播的视角,观察场景,计算场景中的物体距离光源的距离(也就是该视角下的深度),并记录各个位置上的最小值,从而获得一张深度纹理。
- (2) 使用深度纹理图:对于世界中的某个点 p,我们要先得到它在光源视角下的深度,再和深度纹理图中对应的深度进行比较,就可以判定它是否在阴影中了。
2. 着色器代码
(1) 生成深度纹理图
顶点着色器代码:
attribute vec4 a_Position; uniform mat4 u_MvpMatrix; // 以光源为观察点的投影矩阵 void main() { gl_Position = u_MvpMatrix * a_Position; }
片元着色器代码:precision mediump float; // 指定精度 void main() { gl_FragColor = vec4(gl_FragCoord.z, 0.0, 0.0, 0.0); // 将片元的深度值写入r值 }
(2) 使用深度纹理图
顶点着色器代码:
attribute vec4 a_Position; attribute vec4 a_Color; // 物体被照射后显示的颜色 uniform mat4 u_MvpMatrix; // 以人为观察点的投影矩阵 uniform mat4 u_MvpMatrixFromLight; // 以光源为观察点的投影矩阵 varying vec4 v_PositionFromLight; varying vec4 v_Color; void main() { gl_Position = u_MvpMatrix * a_Position; v_PositionFromLight = u_MvpMatrixFromLight * a_Position; // 以光源为观察点的坐标 v_Color = a_Color; }
片元着色器代码
precision mediump float; // 指定精度 uniform sampler2D u_ShadowMap; // 深度纹理图 varying vec4 v_PositionFromLight; // 以光源为观察点的坐标 varying vec4 v_Color; void main() { vec3 shadowCoord = (v_PositionFromLight.xyz/v_PositionFromLight.w)/2.0 + 0.5; // mvp矩阵处理完的坐标还会被自动转化成裁剪空间的坐标,范围在[0,1]区间,所以这里也要做归一化 vec4 rgbaDepth = texture2D(u_ShadowMap, shadowCoord.xy); // 拿到深度纹理中对应坐标存储的数据 float depth = rgbaDepth.r; // 拿到深度纹理中对应坐标存储的深度 float visibility = (shadowCoord.z > depth) ? 0.7 : 1.0; // 判断片元是否在阴影中 gl_FragColor = vec4(v_Color.rgb * visibility, v_Color.a); }
三. ShadowMap 的缺陷和优化
1. Self-Shadowing && Shadow Bias
Self-Shadowing。因为我们需要把物体在光源视角下的深度作归一化和存储,所以必然会导致精度丢失,而精度丢失会导致深度误差。
比如空间中有一点 p,它在光源视角下的实际深度是 0.70001,也是光源视角下的最小深度,那么理论上不会被遮挡,应该显示白色。但我们是需要事先存储光源视角下的最小深度的,此时因为精度丢失,导致0.70001 -> 0.7000,那么在绘制点 p时,判断实际深度 0.70001 > 存储的最小深度 0.7000,表示被遮挡了,误绘成了黑色。当物体表面在灯光视图空间中的倾斜度越大时,误差也越大。
解决方案: Shadow Bias - 在实际绘制时,给从深度纹理拿到的存储深度加上一个阈值。
float visibility = (shadowCoord.z > depth + 0.15) ? 0.7 : 1.0; // 判断片元是否在阴影前加上一个阈值 gl_FragColor = vec4(v_Color.rgb * visibility, v_Color.a); }
此时已经去除了条状阴影,但是阴影偏离的太严重了。这种情况称为 Peter Panning
2. Peter Panning
Peter Panning 的产生是因为我们的 Shadow Bias 加的太多,导致与它实际深度差别太大。
解决方案: 控制阈值大小。
float visibility = (shadowCoord.z > depth + 0.01) ? 0.7 : 1.0; // 控制阈值大小:0.15 -> 0.01 gl_FragColor = vec4(v_Color.rgb * visibility, v_Color.a); }
3. 阴影边缘锯齿
(1) 提升分辨率
当深度纹理图太小(分辨率太低),会导致多个片元对应深度纹理中同一个像素的情况,从而引发锯齿。
以下是把纹理尺寸从 128 * 128 扩大成 1024 * 1024 的效果。
2) Hard Shadow && PCF
在提升完深度纹理分辨率后,发现阴影仍存在锯齿。这种情况并不是阴影生成方式的问题,而是物体边缘本身就是有锯齿的。
我们既可以处理世界中各个物体的边缘锯齿,也可以采用一种更高效的方法,让阴影边缘本身变得平滑。
解决方案:PCF - Percentage Closer Filtering
PCF的核心思路是,不直接取当前点的阴影,而是通过周围的点加权平均得到。
具体的做法是对每个片元从 Shadow Map 中采样相邻的多个值,然后对每个值都进行深度比较。如果该片元处于阴影区就把比较结果记为0,否则记为1,最后把比较结果全部加起来除以采样点的个数就可以得到一个百分比p,表示其处在阴影区的可能性。若p为0代表该像素完全处于阴影区,若p为1表示完全不处于阴影区,最后根据p值设定混合系数即可。
着色器代码如下:其中样本的个数越多,平滑效果越好
precision mediump float; uniform sampler2D u_ShadowMap; varying vec4 v_PositionFromLight; varying vec4 v_Color; void main() { vec3 shadowCoord = (v_PositionFromLight.xyz/v_PositionFromLight.w)/2.0 + 0.5; // 归一化到[0,1]的纹理区间 float shadows = 0.0; float opacity = 0.6; // 阴影alpha值, 值越小暗度越深 float texelSize = 1.0/1024.0; // 阴影像素尺寸,值越小阴影越逼真 vec4 rgbaDepth; // 消除阴影边缘的锯齿,这里简化方案-用当前片元和周围点的不同记录深度做比较 for(float y=-1.5; y <= 1.5; y += 1.0){ for(float x=-1.5; x <=1.5; x += 1.0){ rgbaDepth = texture2D(u_ShadowMap, shadowCoord.xy + vec2(x,y) * texelSize); shadows += (shadowCoord.z > rgbaDepth.r + 0.01) ? 1.0 : 0.0; } } shadows /= 16.0; // 4*4的样本 float visibility = min(opacity + (1.0 - shadows), 1.0); gl_FragColor = vec4(v_Color.rgb * visibility, v_Color.a); }
/
讲述ShadowMap的实现原理。
1. ShadowMap的基本概念
想象一下,在正交摄像机下,如果我们的视线方向和平行光源(太阳光)的发射方向重合,那么我们看到的场景是全光照的(没有丝毫阴影),将这个视角下(灯光位置设置一个摄像机,并且方向和灯光一致)场景的像素深度值渲染到一张纹理上,这张纹理就称作阴影图,即ShadowMap。要注意的是,我们的视景体必须将整个场景覆盖到。
一旦阴影图渲染完成,我们就可以在正常视角渲染场景,然后将场景中的片元像素P,转换到灯光空间下,我们将它在灯光空间下的坐标Z分量(深度)记作d(p)。接着使用投影纹理技术,得到ShadowCoords(阴影图采样坐标)去采样ShadowMap,得到P点映射在ShadowMap上的深度值,记作s(p),我们只需比较d(p)和s(p)的大小,即可判断P点在阴影中还是光照中。如果d(p)<=s(p),那么在光照中;如果d(p)>s(p),则在阴影中。如下图所示,左图在阴影中,右图在光照中。
2. 投影纹理坐标(计算ShadowCoords)
上面提到“使用投影纹理技术”得到ShadowCoords,那投影纹理坐标技术是如何计算得到ShadowCoords的呢?
还是使用上面的案例,我们试想下,场景中的片元像素P在灯光空间下,可能有很多像素与之重合,而且一定有一点 �′ 是离摄像机最近的,也就是ShadowMap上对应的纹素R值(当然也可能P和 �′ 是一个点)那这个 �′ 点就可以作为ShadowCoords去采样ShadowMap,得到s(p),进而和d(p)比较,来判断是否在阴影中。如下图所示,P和摄像机的连线上的所有片元都是重合的,而ShadowMap上的采样点采到的深度值肯定是离摄像机最近点的深度。
那么问题就变为如何将点P转换到 �′ ,我们分三步走。
- 将点P转换到光源空间
- 将光源空间转至NDC空间
- 将NDC空间转至纹理空间
具体计算过程将在下面代码中给出。
3. 正交投影
在讲述ShadowMap基本概念时,我们特意提到,在正交摄像机下,为什么使用正交相机呢?因为如果使用平行光作为主光源的话,由于平行光的方向是一致的(正交光),导致投射的阴影也是正交的。如下图所示,正交的光线投射出正交的阴影。
所以在将坐标从光源空间转至NDC空间时,直接乘以正交投影矩阵即可,正交投影矩阵的推导这里暂不提,可回顾龙书第五章。由于正交投影是线性变化,所以不必做齐次除法,计算速度大大提升。
4. 传递阴影所需的各种数据
我们开始上代码,本篇代码主要以数据准备为主。
(1)创建深度缓冲区、描述符、视口和裁剪矩形
首先新建个类,用来创建深度缓冲区、深度图所用描述符、视口和裁剪矩形。阴影图实际是张深度图,那么先要构建1个深度缓冲区用来存放阴影图。这和之前创建深度缓冲并无二致。
// 构建深度模板缓冲
void ShadowMap::BuildResource()
{
// 构建2D纹理资源(深度图资源)
D3D12_RESOURCE_DESC texDesc;
ZeroMemory(&texDesc, sizeof(D3D12_RESOURCE_DESC));
texDesc.Dimension = D3D12_RESOURCE_DIMENSION_TEXTURE2D;//2D纹理
texDesc.Alignment = 0;
texDesc.Width = mWidth;
texDesc.Height = mHeight;
texDesc.DepthOrArraySize = 1;
texDesc.MipLevels = 1;
texDesc.Format = mFormat;//需要使用DXGI_FORMAT_R24G8_TYPELESS格式
texDesc.SampleDesc.Count = 1;
texDesc.SampleDesc.Quality = 0;
texDesc.Layout = D3D12_TEXTURE_LAYOUT_UNKNOWN;
texDesc.Flags = D3D12_RESOURCE_FLAG_ALLOW_DEPTH_STENCIL;//DEPTH_STENCIL
//必须指定清除组,因为需要清除后重置深度和模板值
D3D12_CLEAR_VALUE optClear;
optClear.Format = DXGI_FORMAT_D24_UNORM_S8_UINT;
optClear.DepthStencil.Depth = 1.0f;
optClear.DepthStencil.Stencil = 0;
// 上传ShadowMap到默认堆中
auto heapProperties = CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT);
ThrowIfFailed(md3dDevice->CreateCommittedResource(
&heapProperties,
D3D12_HEAP_FLAG_NONE,
&texDesc,//纹理资源
D3D12_RESOURCE_STATE_GENERIC_READ,//只读
&optClear,//清除组
IID_PPV_ARGS(&mShadowMap)));//返回ShadowMap
}
接着构建视口和裁剪矩形。
ShadowMap::ShadowMap(ID3D12Device* device, UINT width, UINT height)
{
md3dDevice = device;
mWidth = width;
mHeight = height;
// 视口(TopLeftX, TopLeftY, Width, Height, MinDepth, MaxDepth)
mViewport = { 0.0f, 0.0f, (float)mWidth, (float)mHeight, 0.0f, 1.0f };
// 裁剪矩形(Left, Top, Right, Button),此案例没有裁剪矩形
mScissorRect = { 0, 0, (int)width, (int)height };
BuildResource();
}
然后我们要为深度图资源创建SRV和DSV,创建SRV是为了在Shader中能采样深度图,而创建DSV是为了将深度数据渲染到纹理中。先前已经创建过SRV堆和DSV堆用来渲染主场景和CUBEMAP,所以我们要复写创建SRV堆、DSV堆的代码,以此加入深度图的描述符。
//复写描述符堆
//增加渲染动态CubeMap和ShadowMap所需的RTV和DSV
void ShapesApp::CreateDescriptorHeap()
{
//这里创建一个RTV堆和一个DSV堆
//首先创建RTV堆(增加6个CubeMap的RTV)
D3D12_DESCRIPTOR_HEAP_DESC rtvDescriptorHeapDesc;
rtvDescriptorHeapDesc.NumDescriptors = 2 + 6;//2个后台缓冲RTV + 6个立方体图RTV
rtvDescriptorHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_NONE;
rtvDescriptorHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_RTV;
rtvDescriptorHeapDesc.NodeMask = 0;
ThrowIfFailed(d3dDevice->CreateDescriptorHeap(&rtvDescriptorHeapDesc, IID_PPV_ARGS(&rtvHeap)));
//然后创建DSV堆(增加1个CubeMap的DSV和1个ShadowMap的DSV)
D3D12_DESCRIPTOR_HEAP_DESC dsvDescriptorHeapDesc;
dsvDescriptorHeapDesc.NumDescriptors = 1 + 1 + 1;//增加1个CubeMap的DSV和1个ShadowMap的DSV
dsvDescriptorHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_NONE;
dsvDescriptorHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_DSV;
dsvDescriptorHeapDesc.NodeMask = 0;
ThrowIfFailed(d3dDevice->CreateDescriptorHeap(&dsvDescriptorHeapDesc, IID_PPV_ARGS(&dsvHeap)));
//CubeDSV的堆中地址(句柄)
mCubeDSV = CD3DX12_CPU_DESCRIPTOR_HANDLE(
dsvHeap->GetCPUDescriptorHandleForHeapStart(),//DSV堆首地址
1,//偏移一个dsvDescriptorSize
dsvDescriptorSize);//一个DSV元素的大小
ShadowMapDSV的堆中地址
//mShadowDSV = CD3DX12_CPU_DESCRIPTOR_HANDLE(
// dsvHeap->GetCPUDescriptorHandleForHeapStart(),//DSV堆首地址
// 2,//偏移2个dsvDescriptorSize
// dsvDescriptorSize);//一个DSV元素的大小
}
然后单独为深度图创建SRV和DSV。再废话一次,创建SRV是为了在Shader中能采样深度图,而创建DSV是为了将深度数据渲染到纹理中。
// 为深度图资源创建SRV和DSV
void ShadowMap::BuildDescriptors(CD3DX12_CPU_DESCRIPTOR_HANDLE hCpuSrv,
CD3DX12_GPU_DESCRIPTOR_HANDLE hGpuSrv,
CD3DX12_CPU_DESCRIPTOR_HANDLE hCpuDsv)
{
mhCpuSrv = hCpuSrv;
mhCpuDsv = hCpuDsv;
// 为资源创建SRV后,我们才能在shader中采样深度图
D3D12_SHADER_RESOURCE_VIEW_DESC srvDesc = {};
srvDesc.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING;
srvDesc.Format = DXGI_FORMAT_R24_UNORM_X8_TYPELESS;//24+8
srvDesc.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D;
srvDesc.Texture2D.MostDetailedMip = 0;//细节最详尽的mipmap层级为0
srvDesc.Texture2D.MipLevels = 1;//mipmap层数为1,即没有mipmap
srvDesc.Texture2D.ResourceMinLODClamp = 0.0f;//可访问的mipmap最小层级数为0
srvDesc.Texture2D.PlaneSlice = 0;
md3dDevice->CreateShaderResourceView(mShadowMap.Get(), &srvDesc, mhCpuSrv);
// 为资源创建DSV后,我们才能将深度渲染到深度图中
D3D12_DEPTH_STENCIL_VIEW_DESC dsvDesc;
dsvDesc.Flags = D3D12_DSV_FLAG_NONE;
dsvDesc.ViewDimension = D3D12_DSV_DIMENSION_TEXTURE2D;
dsvDesc.Format = DXGI_FORMAT_D24_UNORM_S8_UINT;
dsvDesc.Texture2D.MipSlice = 0;
md3dDevice->CreateDepthStencilView(mShadowMap.Get(), &dsvDesc, mhCpuDsv);
}
最后在总的SRV堆和DSV堆中插入深度图的SRV和DSV,注意地址偏移的数量。
mShadowMapHeapIndex = 7;
//SRV堆中,ShadowMap的SRV句柄,继续偏移一个SRV
handle.Offset(1, cbv_srv_uavDescriptorSize);
mShadowMap->BuildDescriptors(
CD3DX12_CPU_DESCRIPTOR_HANDLE(srvCpuStart, mShadowMapHeapIndex, cbv_srv_uavDescriptorSize),//SRV在堆中地址(CPU上备份)
CD3DX12_GPU_DESCRIPTOR_HANDLE(srvGpuStart, mShadowMapHeapIndex, cbv_srv_uavDescriptorSize),//SRV在堆中地址(GPU上)
CD3DX12_CPU_DESCRIPTOR_HANDLE(dsvCpuStart, 2, dsvDescriptorSize));//DSV地址(偏移了2个地址)
(2)更新PassCB
我们将计算阴影所需用到的数据保存在PassCB中,并最终传入GPU。首先在PassCB中新增RenderTargetSize、nearZ、farZ、ShadowTransform四组数据,注意四维向量的对齐。
struct PassConstants
{
XMFLOAT4X4 viewProj = MathHelper::Identity4x4();
XMFLOAT3 eyePosW = { 0.0f, 0.0f, 0.0f };
float totalTime = 0.0f;
XMFLOAT2 renderTargetSize = { 0.0f, 0.0f };
float nearZ = 0.0f;
float farZ = 0.0f;
XMFLOAT4X4 ShadowTransform = MathHelper::Identity4x4();
XMFLOAT4 ambientLight = { 0.0f,0.0f,0.0f,1.0f };
Light lights[MAX_LIGHTS];
};
接着我们计算那4组数据,ShadowTransform最复杂(矩阵的原理),单独新建一个函数用来计算。我们先计算整个场景的包围球,然后构建出L2P矩阵(灯光转NDC),继而算出NDC转纹理空间矩阵,将所有矩阵相乘得到L2T矩阵(灯光转纹理),因为没有齐次除法,所以NDC后可以直接乘以纹理矩阵,得到L2T矩阵。
// 构建灯光转纹理空间所需的矩阵数据
void ShapesApp::UpdateShadowTransform(const GameTime& gt)
{
// 主光才投射物体阴影
XMVECTOR lightDir = XMLoadFloat3(&mRotatedLightDir[0]);
XMVECTOR lightPos = -2.0f * mSceneBounds.Radius * lightDir;
XMVECTOR targetPos = XMLoadFloat3(&mSceneBounds.Center);
XMVECTOR lightUp = XMVectorSet(0.0f, 1.0f, 0.0f, 0.0f);
// WorldToLight矩阵 (世界空间转灯光空间)
XMMATRIX lightView = XMMatrixLookAtLH(lightPos, targetPos, lightUp);
XMStoreFloat3(&mLightPosW, lightPos);//灯光坐标
// 将包围球变换到光源空间
XMFLOAT3 sphereCenterLS;
XMStoreFloat3(&sphereCenterLS, XMVector3TransformCoord(targetPos, lightView));
// 位于光源空间中包围场景的正交投影视景体
float l = sphereCenterLS.x - mSceneBounds.Radius;//左端点
float b = sphereCenterLS.y - mSceneBounds.Radius;//下端点
float n = sphereCenterLS.z - mSceneBounds.Radius;//近端点
float r = sphereCenterLS.x + mSceneBounds.Radius;//右端点
float t = sphereCenterLS.y + mSceneBounds.Radius;//上端点
float f = sphereCenterLS.z + mSceneBounds.Radius;//远端点
mLightNearZ = n;//近裁剪面距离
mLightFarZ = f;//远裁剪面距离
//构建LightToProject矩阵(灯光空间转NDC空间)
XMMATRIX lightProj = XMMatrixOrthographicOffCenterLH(l, r, b, t, n, f);
// 构建NDCToTexture矩阵(NDC空间转纹理空间)
// 从[-1, 1]转到[0, 1]
XMMATRIX T(
0.5f, 0.0f, 0.0f, 0.0f,
0.0f, -0.5f, 0.0f, 0.0f,
0.0f, 0.0f, 1.0f, 0.0f,
0.5f, 0.5f, 0.0f, 1.0f);
// 构建LightToTexture(灯光空间转纹理空间)
XMMATRIX S = lightView * lightProj * T;
XMStoreFloat4x4(&mLightView, lightView);
XMStoreFloat4x4(&mLightProj, lightProj);
XMStoreFloat4x4(&mShadowTransform, S);
}
其他3个数据都很简单,直接传值即可,然后新建一个函数,将阴影图所需的PassCB数据传入GPU流水线。
//ShadowMap的PassCB数据更新
void ShapesApp::UpdateShadowMapPassCBs(const GameTime& gt)
{
shadowMapPassConstants = mainPassConstants;
XMMATRIX view = XMLoadFloat4x4(&mLightView);
XMMATRIX proj = XMLoadFloat4x4(&mLightProj);
XMMATRIX viewProj = XMMatrixMultiply(view, proj);
UINT w = mShadowMap->Width();
UINT h = mShadowMap->Height();
XMStoreFloat4x4(&shadowMapPassConstants.viewProj, XMMatrixTranspose(viewProj));
shadowMapPassConstants.renderTargetSize = XMFLOAT2((float)w, (float)h);
shadowMapPassConstants.nearZ = mLightNearZ;
shadowMapPassConstants.farZ = mLightFarZ;
//将ShadowMap的PassCB存在7号索引(Main和CubeMap之后)
auto currPassCB = currFrameResources->passCB.get();
currPassCB->CopyData(7, shadowMapPassConstants);
}
别忘了在Update函数中运行。
void ShapesApp::Update(GameTime& gt)
{
............
UpdateShadowTransform(gt);
}
至此,我们完成了所有阴影所需数据的计算和传入GPU的工作,下一篇我们着重学习下ShadowBiase、PCF以及最终阴影在shader中的计算
///
//