代码已上传至GitHub,这部分对应的项目为NormalDisplacementSsao。运行项目时可以使用数字键1-3切换渲染的shader,另外按下z键可以看到SSAO生成的遮罩贴图。
这部分内容较多,所以以介绍原理为主。另外在阅读源码时,为了剔除枝干让整体的代码更加一目了然,我用了模板和SFINAE(主要在SceneObject.h),如果看不懂的话也没有关系,不重要,关键是了解这些技术的主要流程。
首先我会对每个技术的原理做简要介绍。如果有时间和心情的话会结合代码分析。
目录
Cubemap
定义
环境贴图
模拟反射
动态Cubemap
法线贴图
贴图置换
Shadow Mapping
渲染阴影贴图
阴影贴图采样
PCF Filtering
环境光遮罩
通过光线追踪估算周围闭塞程度
屏幕空间环境光遮罩
环境光遮罩纹理
遮罩程度的估测
这部分代码可以参看DemoCubeMap这个项目。
Cube mapping 保存6个纹理,可以把它们具象化为一个立方体的六个面。立方体是轴对称的,每一个面都可以正好对应到空间坐标的一个轴方向(±X, ±Y, ±Z,),因此Direct3D提供了D3D11_TEXTURECUBE_FACE用于描述六个面
typedef enum D3D11_TEXTURECUBE_FACE
{
D3D11_TEXTURECUBE_FACE_POSITIVE_X = 0,
D3D11_TEXTURECUBE_FACE_NEGATIVE_X = 1,
D3D11_TEXTURECUBE_FACE_POSITIVE_Y = 2,
D3D11_TEXTURECUBE_FACE_NEGATIVE_Y = 3,
D3D11_TEXTURECUBE_FACE_POSITIVE_Z = 4,
D3D11_TEXTURECUBE_FACE_NEGATIVE_Z = 5
} D3D11_TEXTURECUBE_FACE;
与2D纹理不同,我们无法再通过一个2d纹理坐标来确定Cube mapping中的一个图素。我们需要定义一个3D的纹理坐标,定义上,它是一个从原点出发的方向向量,cube map和这个方向相交的点就是这个3d坐标对应的图素。之前讨论过的texture filtering同样适用于cube map。
在HLSL中,TextureCube 代表了一个Cubemap。
TextureCube gCubeMap;
SamplerState gTriLinearSam
{
Filter = MIN_MAG_MIP_LINEAR;
AddressU = Wrap;
AddressV = Wrap;
};
…
// in pixel shader
float3 v = float3(x,y,z); // some lookup vector
float4 color = gCubeMap.Sample(gTriLinearSam, v);
Cubemap的主要应用之一是环境贴图,即天空盒。
首先我们创建好Cubemap纹理,然后我们需要一系列顶点组成一个球形。由于天空可以认为是非常远的,所以实际上球形的精度并不是很重要。
绘制时,始终认为Camera是原点,因此直接使用球体的本地坐标作为世界坐标来使用。
我们可以认为天空盒的位置是最远的像素,因此直接将w设置为1。但此时必须要修改深度测试时的测试函数,默认是Less,我们需要改为Less_Equal否则w=1的像素会被舍弃,天空盒就无法绘制出来了。
通过环境贴图可以模拟反射,将周围的环境显示在光滑的物体表面。
主要过程是通过眼睛的位置和物体表面的法线,确定反射的方向,根据方向对cube map进行采样,从而把反射出来的图象绘制在物体上。
游戏中有许多动态的物体,因此需要通过动态生成环境贴图来模拟反射。一个做法是在反射的物体位置,向周围六个方向,渲染出Cubemap的六个面,这样就能绘制出动态的环境贴图。
物体的表面不总是光滑的,但如果将物体表面的每一个褶皱都用顶点表现出来会导致顶点数量剧增。为了在不增加模型顶点数的情况下,模拟光照在不光滑物体表面的情况,引入了法线贴图。
法线贴图指的是一张保存物体法线信息的贴图,此时RGB中不保存颜色信息,而是保存法线信息,RGB对应xyz。由于单位向量的每个分量的大小总是位于[0,1],所以通过下面的公式可以把坐标从[0,255]映射到[-1,1]中。
法线贴图中保存的法线坐标是相对于纹理的,即三个坐标轴分别为纹理的U/V和法线方向,对应下图中的TBN坐标系。所以我们要把法线贴图中的坐标转换到世界坐标系。为此,除了顶点法线坐标外,我们还需要一个切线方向T或者N,这样就可以确定一个转换矩阵。
float3 NormalSampleToWorldSpace(float3 normalMapSample, float3 unitNormalW, float3 tangentW)
{
// Uncompress each component from [0,1] to [-1,1].
float3 normalT = 2.0f*normalMapSample - 1.0f;
// Build orthonormal basis.
float3 N = unitNormalW;
float3 T = normalize(tangentW - dot(tangentW, N)*N);
float3 B = cross(N, T);
float3x3 TBN = float3x3(T, B, N);
// Transform from tangent space to world space.
float3 bumpedNormalW = mul(normalT, TBN);
return bumpedNormalW;
}
假设已知NTB的世界坐标,则法线normalW = normalTex.x * T + normalTex.y * B + normalTex.z * N,写成矩阵的形式就是上述代码的计算过程。
由此我们可以在光滑的模型表面,通过法线贴图和光照,制造出凹凸不平的错觉。
引入法线贴图后,物体表面的高光给人一种立体感单纯使用法线贴图,只能模拟光照效果,看起来虽然有凹凸的错觉,但实际上表面还是光滑的。贴图置换能够切实的渲染出复杂的表面。
之前的法线贴图中,只使用了rgb三个分量,还有一个a分量没有使用。在贴图置换中a分量被用于存放高度信息。
通过曲面细分,扩展模型的顶点数,在Domain Shader中,根据法线贴图的a分量,对顶点做一个位移,从而让顶点呈现需要的样子。
最后PS中按照法线贴图的做法来处理光照即可。
Shadow Mapping的基本原理是,以光源方向为起点向光照射的方向,渲染出一张深度贴图,然后在渲染物体是,把图素的坐标转换到光照深度图的坐标系中,并比较要绘制的图素和深度贴图的深度关系,就可以知道在光照方向上,这个图素是否受到光照。
首先我们要渲染一张阴影贴图。贴图的大小关系到最后生成的阴影质量,越大的贴图质量越好,但同时也意味着更大的内存和更多的性能消耗。
在一般情况下,我们使用透视投影来渲染物体。在模拟平行光时,我们应该使用正交投影。
阴影贴图渲染完成后,通过对阴影贴图进行采样获取从光源出发的深度值。由于阴影贴图的分辨率有限,阴影贴图是光照场景深度的离散取样,所以会产生一定的走样,被称为shadow acne。
如上图所示的,由于阴影贴图的分辨率有限,带有坡度的直线在阴影贴图中最终会呈现阶梯状。此时进行测试时,对P1,它的深度值大于贴图中的深度值,所以出现了阴影,而p2小于阴影贴图,所以p2是明亮的,于是平面上会出现错误的波纹样阴影。
通过偏移的方式能够消除Acne,但固定的偏移不适用于所有的几何体。不适宜的偏移会造成peter-panning。从下图可以看出,与光源斜率越大的几何体需要越大的偏移。
图形硬件支持这种slope-scaled-bias 光栅化状态。
typedef struct D3D11_RASTERIZER_DESC
{
[...]
INT DepthBias;
FLOAT DepthBiasClamp;
FLOAT SlopeScaledDepthBias;
[...]
} D3D11_RASTERIZER_DESC;
其中,
DepthBias:一个固定的偏移值。具体计算时根据深度缓冲区的格式会使用不同的格式,在下面的会详细介绍。
DepthBiasClamp: 最大的偏移值,它允许我们为偏移设定一个上限。因为对非常陡的斜面,slope-scaled-bias产生的偏移可能会非常大,从而导致peter-panning现象。因此我们要限制它的大小。
SlopeScaledDepthBias:一个缩放参数,用于控制偏移和斜率的关系。
如果一个绑定到输出合并阶段的深度贴图是UNORM格式或者没有深度贴图,偏移值按照下述公式计算,
Bias = (float)DepthBias * r + SlopeScaledDepthBias * MaxDepthSlope;
r为深度贴图格式下最小的非负值,并转化成float32格式。例如对一个24位的深度缓冲,
r = 1/2^24
所以DepthBias = 100000时,实际的Bias = 100000/2^24 = 0.006
而对MaxDepthSlope,大致理解是像素深度斜率在横向和纵向上的两个值中,取最大值,具体计算公式不明。
DepthBiasClamp 用于限制偏移,具体来说
if(DepthBiasClamp > 0)
Bias = min(DepthBiasClamp, Bias)
else if(DepthBiasClamp < 0)
Bias = max(DepthBiasClamp, Bias)
对浮点格式下的深度贴图
Bias = (float)DepthBias * 2**(exponent(max z in primitive) - r) + SlopeScaledDepthBias * MaxDepthSlope;
由于图素位置不一定总在阴影贴图对应像素点上,所以取周围四个像素点,然后根据四个点的遮挡情况,再通过插值计算出目标点的阴影深度,从而获得较为平滑的阴影边缘。
我们用环境光来模拟非直接光照,但是环境光由于是单一强度且无方向的,在没其他光照的情况下,环境光下的物体看起来十分的平面,因此引入了环境光遮罩这一技术。
环境光遮罩的主要思路是,物体表面上某一点P受到的非直接光照的强度,和表面半球的闭塞程度有关。
一个估算闭塞程度的方法是光线追踪。随机的从p点穿过半球面投射出射线,检查射线和模型的交叉情况。假设我们投射出N条射线,其中h条和模型交叉了,那么这点的闭塞值为
因此所以要知道我们能接收到多少光照,我们引入一个量accessibility,
accessiblity = 1 - occlusion∈[0, 1]
值越大代表接收到的环境光越多。
screen space ambient occlusion (SSAO)的策略是,在每一帧的时候把屏幕空间的法线和深度值渲染到纹理上,然后以这个法线深度纹理为参照来估算环境光的遮罩程度,并渲染成一个环境光遮罩(阴影)纹理。这部分纹理在PS中会作为环境光的阴影计算在最终的像素之中。
法线深度贴图中存放的是屏幕空间的深度信息,所以首先我们要根据深度值,把它还原为屏幕中的一点。
首先我们将屏幕四个顶点在视锥坐标系中的坐标作为顶点传入到顶点着色器中。在Demo中,顶点缓冲区中的顶点存放的是屏幕四角的index,然后再用index从全局变量中索引顶点信息。之所以要这样做,是因为当屏幕尺寸,fov,n/f改变时,四角坐标要随之改变,比起更新顶点坐标,设置全局缓冲区要更加容易。
四个顶点分别对应远平面的四个角坐标,然后作为顶点着色器的输出。由于硬件会帮我们对顶点做插值,所以在像素着色器中,我们就获得了摄像机位置到远平面的射线,它也是屏幕空间的投影方向。此时通过对法线深度贴图进行采样,获得投影处的深度法线信息,如图中所示,Eye到P的长度即是深度值,但现在我们需要的是P点的坐标,根据深度值和Eye到远平面的距离的比例,可以求出P向量和V向量长度的比值,进而求出P点坐标。
float3 p = (pz/pin.ToFarPlane.z)*pin.ToFarPlane;
接着我们估测p点的闭塞程度,需要在p点随机射出射线来判断p点和周围环境的关系。Demo中使用的方法是,预先选定14个方向,它们是矩形的6个面+8个顶点,然后取一个随机的方向变量,以随机变量为法线做方向的反射,从而得到14相对均匀分布的随机方向。由于射线的方向限定在法线方向上的半球上,所以当随机射线和法线的叉乘小于0时,需要反转射线方向,确保射出的射线不会射向表面内部。
图中的q点即是一个测试点,
q = p + flip * gOcclusionRadius * offset;
q点为p以随机射线方向上位移为gOcclusionRadius 上的一个偏移点。
将q点的坐标转换为屏幕空间纹理坐标,从而能从深度法线纹理贴图中取样到该屏幕点上的深度法线信息。同样的,我们把这一点的坐标转换到View Space,此时我们获得了三点坐标p,q,r,通过p和r关系我们可以大致估测这个方向上,p点受到遮罩的情况。
float distZ = p.z - r.z;
float dp = max(dot(n, normalize(r - p)), 0.0f);
float occlusion = dp * OcclusionFunction(distZ);
dot(n, normalize(r - p)表明了r和p的位置关系,值越大则r越靠近p的正前方,遮罩越强。OcclusionFunction根据两点的深度差值,当两点距离较远时,p不会被r遮罩。
最后得到
float access = 1.0f - occlusionSum;
发通过对access进行乘幂计算可以让效果更加明显,这个值就是遮罩贴图上一点的颜色值。
由于我们在每个点只随机投射了极少数的射线,所以整体渲染出来的遮罩贴图看起来非常粗糙,
为了消除这些噪点,我们会对贴图做一个模糊。模糊的计算方法类似于之前的高斯模糊算法,但这里直接使用像素着色器来计算模糊。通过使用两个纹理缓冲区交替作为输入和输出,可以最终得到模糊后的遮罩贴图。
for(int i = 0; i < blurCount; ++i)
{
// Ping-pong the two ambient map textures as we apply
// horizontal and vertical blur passes.
BlurAmbientMap(mAmbientSRV0, mAmbientRTV1, true);
BlurAmbientMap(mAmbientSRV1, mAmbientRTV0, false);
}
接下来只需要在像素着色器中对某点的环境光遮罩贴图进行取样,获得环境光在这个位置上的阴影,在计算环境光时将阴影也考虑在内即可。