【D3D11游戏编程】学习笔记十八:模板缓冲区的使用、镜子的实现
模板缓冲区(Stencil Buffer)是一个与后缓冲区(Back Buffer)尺寸一样的离屏缓冲区(Off-Screen Buffer),主要用于实现一些特效。模板缓冲区中的每一个像素Pi,j,与后缓冲区中的像素Pi,j是一一对应的。在功能上,与深度缓冲区类似,都是用来控制一个片段能否通过3D渲染管线相应的阶段,以被进一步处理。不同之处在于,模板缓冲区与深度缓冲区用于控制片段是否通过所使用的判断依据不一样。对于深度缓冲区,它通过比较每个片段与当前缓冲区中对应像素处的深度值来判断,如果小于该深度值则通过,否则丢弃该片段 ;模板缓冲区使用其他的判断依据,我们稍后会详细介绍。 1. 模板缓冲区相关数据格式
实际上,模板缓冲区与深度缓冲区是共用一个“物理缓冲区”的,即真正存在的只有一个缓冲区,该缓冲区中任一像素处保存了两种信息:深度值与模板值。比如一个像素占用4个字节(32位),那么深度值可能占用前面几位,模板值占用后面几位。在D3D11中针对该缓冲区,定义了如下几种数据格式:
DXGI_FORMAT_D32_FLOAT_S8X24_UINT:该格式中,每个像素为8字节(64位),其中深度值占32位,为float型。模板值为8位,为位于[0,255]中的整型,后面24位无任何用途,纯对齐用;
DXGI_FORMAT_D24_UNORM_S8_UINT:该格式中,每个像素为4字节(32位),其中深度值占24位,并映射到[0,1]之间。模板值为8位,为位于[0,255]中的整型;
在大多数情况下,我们使用第二种格式,在我们前面所有的示例程序中,使用的正是这种格式。在初始化D3D时,我们需要在创建深度/模板缓冲区时为它指定相应的格式,对应代码如下(对应 dsDesc.Format部分):
D3D11_TEXTURE2D_DESC dsDesc;dsDesc.Format = DXGI_FORMAT_D24_UNORM_S8_UINT;dsDesc.Width = m_clientWidth;dsDesc.Height = m_clientHeight;dsDesc.BindFlags = D3D11_BIND_DEPTH_STENCIL;dsDesc.MipLevels = 1;dsDesc.ArraySize = 1;dsDesc.CPUAccessFlags = 0;dsDesc.SampleDesc.Count = g_x4MsaaQuality<1?1:4;dsDesc.SampleDesc.Quality = g_x4MsaaQuality<1?0:g_x4MsaaQuality-1;dsDesc.MiscFlags = 0;dsDesc.Usage = D3D11_USAGE_DEFAULT;hr = m_d3dDevice->CreateTexture2D(&dsDesc,0,&m_depthStencilBuffer);if(FAILED(hr)){MessageBox(NULL,_T("Create depth stencil buffer failed!"),_T("ERROR"),MB_OK);return false;}hr = m_d3dDevice->CreateDepthStencilView(m_depthStencilBuffer,0,&m_depthStencilView);if(FAILED(hr)){MessageBox(NULL,_T("Create depth stencil view failed!"),_T("ERROR"),MB_OK);return false;}m_deviceContext->OMSetRenderTargets(1,&m_renderTargetView,m_depthStencilView);
2. 模板测试判断依据
正如深度缓冲区使用片段的深度值作为判断该片段是否通过的依据,模板缓冲区也有它独特的判断依据,如以下公式所示:
该判断主要包含两部分:COMPARISON左边部分和右边部分。
StencilRef为程序员设定的一个参考值,StencilReadMask为模板值读取掩码,与参考值“按位与”作为式子中的左边的结果;一般情况下,我们设定该掩码为0xFF,即接位与的结果就是模板参考值本身;
Value为模板缓冲区中对应位置处的当前值,同样与掩码按位与后作为右边的结果值;
式子中左、右两部分的结果通过中间的比较操作“COMPARISON”来决定决断的结果,在D3D11中,比较操作定义为如下枚举类型:
typedef enum D3D11_COMPARISON_FUNC { D3D11_COMPARISON_NEVER = 1, D3D11_COMPARISON_LESS = 2, D3D11_COMPARISON_EQUAL = 3, D3D11_COMPARISON_LESS_EQUAL = 4, D3D11_COMPARISON_GREATER = 5, D3D11_COMPARISON_NOT_EQUAL = 6, D3D11_COMPARISON_GREATER_EQUAL = 7, D3D11_COMPARISON_ALWAYS = 8 } D3D11_COMPARISON_FUNC;
通过名字即很容易想到其意义,我们忽略前缀:
NEVER:判断操作永远失败,即片段全部不通过模板测试;
LESS:该判断为“<"操作,即左边<右边时测试通过;
EQUAL:“=”操作,即当左、右两边相等时测试通过;
LESS_EQUAL:“<="操作,当左边<=右边时测试通过;
GREATER:">"操作,当左边>右边时测试通过;
NOT_EQUAL:"!="操作,当左、右两边不相等时测试通过;
GREATER_EQUAL:">="操作,当左边>=右边时测试通过;
ALWAYS:永远通过,即不管左右两边的值,恒通过。
这里的枚举类型同样适应于深度缓冲区中比较操作的设定。
举个例子说明下模板测试的过程:
比如我们设定模板参考值为1,掩码值为0xffffffff,针对某个片段,如果模板缓冲区中对应的当前模板值为0,则按上述公式,
左边 = 1 & 0xFF = 1;
右边 = 0 & 0xFF = 0;
1. 如果比较操作我们设定为ALWAYS,则该片段的模板测试通过(不管左右两边什么值);
2. 如果比较操作我们设定为LESS,则由于1 < 0是错误的,因此该片段的模板测试失败,片段被丢弃;
3. 如果比较操作我们设定为GREATER,由于1 > 0正确,因此模板测试成功,片段通过。
其他比较操作依次类推,很简单。
在上一步骤中的模板测试之后,不管片段是否通过测试,都要对模板缓冲区进行相应的更新。至于怎么更新,取决于程序员的设定。D3D11中针对模板缓冲区的更新操作定义了如下枚举类型:
typedef enum D3D11_STENCIL_OP { D3D11_STENCIL_OP_KEEP = 1, D3D11_STENCIL_OP_ZERO = 2, D3D11_STENCIL_OP_REPLACE = 3, D3D11_STENCIL_OP_INCR_SAT = 4, D3D11_STENCIL_OP_DECR_SAT = 5, D3D11_STENCIL_OP_INVERT = 6, D3D11_STENCIL_OP_INCR = 7, D3D11_STENCIL_OP_DECR = 8 } D3D11_STENCIL_OP;
同样,我们忽略前缀:
KEEP:保持当前值 不变,比如测试前模板值为0,则继续为0不变;
ZERO:把模板缓冲区对应位置的模板值设为0;
REPLACE:"replace"即替换的意思,即使用模板参考值替换模板缓冲区中对应的当前值;
INCR_SAT:"INCR"即increase,自增的意思,"SAT"为saturate,用于限制自增的范围。即把当前的模板值加1。如果值超过了255(因为我们的模板缓冲区为8位,因此255即为最大值),则保持在255。
DECR_SAT:同上,DECR为"decrease",自减的意思,即把当前值自减1,如果值低于0,则保持在0;
INVERT:把当前模板值按位取反。比如对于0xffffffff,更新后的结果为0x00000000;
INCR:同上面的INCR一样,也是把当前模板值自增1,但如果值超过255,则返回到0,之后继续自增;
DECR:同上面的DECR一样,也是把当前模板值自减1,但如果值低于0,则为255,之后继续自减。
4. D3D11中针对模板缓冲区的操作
之前我们使用过混合,在使用混合,我们首先要创建一个BlendState,然后通过SetBlendState来使用它。同样,这里我们要使用模板缓冲区,也是首先创建相应的DepthStencilState,然后SetDepthStencilState。在D3D11中对应的函数为:
HRESULT CreateDepthStencilState( [in] const D3D11_DEPTH_STENCIL_DESC *pDepthStencilDesc, [out] ID3D11DepthStencilState **ppDepthStencilState);
第一个参数为对应的深度/模板缓冲区状态描述,我们应该已经很习惯这种步骤了。无论是创建纹理、缓冲区、还是各种渲染状态,第一步都是通过给出其描述开始;
第二个参数为我们要创建的状态接口的地址。
状态描述结果定义如下:
typedef struct D3D11_DEPTH_STENCIL_DESC { BOOL DepthEnable; D3D11_DEPTH_WRITE_MASK DepthWriteMask; D3D11_COMPARISON_FUNC DepthFunc; BOOL StencilEnable; UINT8 StencilReadMask; UINT8 StencilWriteMask; D3D11_DEPTH_STENCILOP_DESC FrontFace; D3D11_DEPTH_STENCILOP_DESC BackFace;} D3D11_DEPTH_STENCIL_DESC;
该结构中前几个用于描述深度缓冲区,后面用于描述模板缓冲区。(毕竟该两个缓冲区位于一起嘛~)
DepthEnable:是否使用深度缓冲区,显然大多数数情况下为true;
DepthWriteMask:深度值写入掩码值,大多数情况下我们把整个深度值完整写入,因此掩码值为D3D11_DEPTH_WRITE_MASK_ALL;
DepthFunc:深度判断值,即本文第二部分中提到的比较函数,大多数情况下我们使用LESS,即更小的深度(更靠前)通过测试;
StencilEnable:是否使用模板缓冲区,我们就是要开启模板缓冲区,因此为true;
StencilReadMask:模板值读取掩码,大多数情况下我们使用0xff;
StencilWriteMask:模板值写入掩码,同样为0xff;
FrontFace:针对渲染物体的正面,所使用的模板更新操作;
BackFace:针对渲染物体的背面,所使用的模板更新操作。
这里的FrontFace和BackFace对应的结果定义如下:
typedef struct D3D11_DEPTH_STENCILOP_DESC { D3D11_STENCIL_OP StencilFailOp; D3D11_STENCIL_OP StencilDepthFailOp; D3D11_STENCIL_OP StencilPassOp; D3D11_COMPARISON_FUNC StencilFunc;} D3D11_DEPTH_STENCILOP_DESC;
这里前三个操作就是本文第三部分所提到的模板更新操作。
StencilFailOp:模板测试失败后的操作,比如我们想设置为如果失败,则保持不变,则为KEEP;
StencilDepthFailOp:深度测试失败后的操作;
StencilPassOp:模板测试通过后的操作,比如我们在通过测试后更新为参考值,则为REPLACE;
StencilFunc:比较操作,即本文第二部分中提到的比较操作:LESS、GREATER、ALWAYS等等。
一般情况下,我们只渲染物体的正面,背面是剔除的,因此在上面的结构中,对于BackFace的设置是无关紧要的。
到现在为此,你可能会有疑问:在模板测试中使用的模板参考值怎么设定? 没错,上面我们只是说是程序员设定的,但到目前为止还没提到如何设定这个值。其实该模板参考值正是通过SetDepthStencilState函数设定的。该函数原型如下:
void OMSetDepthStencilState( [in] ID3D11DepthStencilState *pDepthStencilState, [in] UINT StencilRef);
第一个参数就是我们刚刚创建的深度/模板状态接口;
第二个参数即指定模板参考值,为UINT类型。
好了,有关模板缓冲区的使用就这些,下面来个小结:
1. 使用模板缓冲区时最重要的两个值:缓冲区中的当前值value,模板参考值ref;
2. 模板测试的本质即对该两个值使用特定的比较操作:NEVER, ALWAYS, LESS, EQUAL, GREATER等等;
3. 模板测试后要对模板缓冲区进行相应的更新,更新操作包括:KEEP, REPLACE, INCR_SAT, INCR, DECR, DECR_SAT等等;
4. 模板测试后针对不同结果可以使用不同的更新操作,包括测试成功操作(StencilPassOp),测试失败操作(StencilFailOp),深度测试失败操作(DepthFailOp).
有关模板缓冲区的使用,理论知识就是这些。但是学习模板缓冲区,最好的方法就是研究实际的例子。下面我们就通过一个平面镜子反射的例子来进一步掌握模板缓冲区的使用。
5. 实际例子:平面镜的实现 5.1 要解决的两个关键问题要实现平面镜反射效果,主要有两大关键问题要解决。
首先是平面镜反射的变换操作。对于一个要绘制的物体,如何得到它在镜子中的影子的表示。在3D中,任何变换都是通过矩阵来实现的。这里也一样,这时我们就需要一个反射变换矩阵。反射变换矩阵可以惟一地通过一个平面给确定,该平面就是反射平面。在3D中,平面的数学表示为一个4维的向量[Nx, Ny, Nz, d]。此外,XNAMath也提供了相应的函数来等到反射变换矩阵:
XMMATRIX XMMatrixReflect( XMVECTOR ReflectionPlane)
这里面惟一的参数即我们的反射平面。
由于篇幅限制,在本文中暂时不对任意平面的3D数学表示进行解释,后面的示例程序中我们对于使用的平面直接给出其表示形式。如果大家对3D平面的数学表示有困惑,可以参考专门的3D数学方面的书籍。也欢迎向我提出,如有必要我后面会专门写篇文章来解释3D平面表示的推导。
第二个问题,也是核心问题,即:当我们的观察点在空间中任意移动时,在移动到特定范围之外时,我们将看不到镜子中的物体。我想这种情况在现实当中很容易想到吧。下面我通过几张图来说明下这种情景(这些图来自本文对应的示例程序截图,大家可以自由下载参考源代码):
上面这张图是正常情况下我们在镜子中看到物体的情形。
当我们在空间移动时,由于镜子尺寸的限制,整个物体(或部分)可能会移动到镜子范围之外。正如以上图片所示,这时我们应该只能看到一部分物体了。这种情况也正是我们最终期待看到的情况。 但这就涉及到一个问题:如何让程序知道哪些部分位于镜子当中,以正确绘制它;而哪些部分位于镜子之外,从而不绘制呢?
这时就是“模板缓冲区”大显身手的时候啦!
在继续介绍之前,我们先看一下如果没有模板缓冲区,将是什么样的情况:针对上幅图中的视角,以下是不使用模板缓冲区时的渲染结果:
物体竟然显示到镜子外面去啦!这显然是不允许的!还好有模板缓冲区的存在~
下面我们来详细地看下如何作用模板缓冲区来实现第二张图中的效果。
5.2 绘制过程在这个场景中,主要有如下几部分:墙面、地面、箱子、镜子、镜中的箱子。
1. 我们按正常情况绘制墙面、地面和箱子。这些物体的绘制与模板缓冲区无任何关系。
2. 第二步我们就要针对镜子区域,使用模板缓冲区来进行限制了。
为了告诉计算机,哪些区域是镜子所在区域,哪些区域是镜子之外的部分,我们需要使用模板缓冲区来标记给它看。因此在这一步当中,我们只针对模板缓冲区进行操作,而不更改场景中的颜色值。
为了实现这种效果,我们首先要禁止颜色的写入。在之前文章介绍“混合”的使用时,提到过禁止颜色写入的实现,即通过把D3D11_RENDER_TARGET_BLEND_DESC中对应的RenderTargetWriteMask设置为0即可。如有疑问,可以参考这里。 相应的状态我已经在源代码框架中的RenderStates中定义好了,即NoColorWrite状态,详细情况请参考源代码。
其次就是模板缓冲区的操作了。还记得不,在每开始绘制一帧时,我们要做的第一件事就是清屏,包括后缓冲区以及深度/模板缓冲区。清屏时我们指定了默认的模板值,比如0,也是我们之前一直做的。如下语句,最后一个参数就是清屏时设定的模板值:
m_deviceContext->ClearDepthStencilView(m_depthStencilView,D3D11_CLEAR_DEPTH|D3D11_CLEAR_STENCIL,1.f,0);
因此在经过第一步绘制后,模板缓冲区中所有的模板值依然为0,因为我们没打开模板操作。为了惟一地标记镜子所在区域,我们需要修改该区域对应的模板值,以区别于其他区域的模板值。比如把该区域的模板值设为参考值。
要修改模板缓冲区,我们就需要调用绘制函数。因此我们只需要更新镜子所在区域的模板值,因此这一步中我们只绘制镜子,由于禁止颜色写入,因此场景并不会被改变,仅仅是用于修改模板缓冲区。为了把镜子所在区域全部更新,我们使用的模板比较函数为ALWAYS,即只要绘制该区域,总能够通过测试而修改模板值。对于模板更新操作,正如刚才所言,我们使用REPLACE操作,把对应区域设为参考值,即针对StencilPass的。因为现在模板测试总是通过,因此StencilFail的操作就无所谓的。对于DepthFail后的更新操作,我们设为KEEP,即即使模板测试通过,但深度测试不通过的,依然不改变对应的模板值(设想下,如果箱子挡住了一部分镜子,对于这一部分,即使位于镜子范围之内,我们也看不到镜子的物体吧。这就是深度测试失败导致的。因此这时我们就不需要修改对应的模板值了。当然,就算修改了,后面真正绘制镜子物体时,还会因为深度测试失败而丢弃)。
为了形象地表示这两步中后缓冲区和模板缓冲区相应的改变,请看以下几张图:
这张图是第一步绘制完墙面、地面、箱子后对应的后缓冲区和模板缓冲区。我们重点关注模板缓冲区,图中一致地为灰色,代表默认的0.
这张图为第二步把镜子区域模板值修改为参考值后的情形。显然,后缓冲区没有任何改变。而模板缓冲区,我们看到镜子区域的改变,而其他地方保持不变。这样我们就惟一地镜子区域标记出来了。
3. 绘制镜中的物体
现在,我们开始绘制箱子经反射后在镜子中的部分。 这里有两大事情要做:
1. 生成相应的变换矩阵,注意光源的方向也要经过镜子的反射
在该程序中,我们的镜子所在平面为与z轴垂直且位于z = 5的平面,正面指向z轴负方向。这里我直接给出其数学表示, 为【0,0,-1, 5】。通过它生成反射矩阵,应用于箱子,并且相应地修改光源的方向,代码如下:
//首先计算反射矩阵:反射面为墙面XMVECTOR refPlane = XMVectorSet(0.f,0.f,-1.f,5.f);//墙面的数学表示:[0,0,-1,5]XMMATRIX R = XMMatrixReflect(refPlane);//更新镜中物品相应的变换矩阵XMMATRIX view = XMLoadFloat4x4(&m_view);XMMATRIX proj = XMLoadFloat4x4(&m_proj);XMMATRIX worldBox = XMLoadFloat4x4(&m_worldBox)*R;XMMATRIX worldInvTranspose = InverseTranspose(worldBox);XMMATRIX wvp = worldBox * view * proj;XMMATRIX texTrans = XMMatrixIdentity();//注意也要对光照方向进行相应的反射变换XMFLOAT3 oldDirs[3];for(UINT i=0; i<3; ++i){oldDirs[i] = m_dirLights[i].dir;XMVECTOR dir = XMVectorSet(oldDirs[i].x,oldDirs[i].y,oldDirs[i].z,1.f);XMVECTOR rdir = XMVector3TransformNormal(dir,R);XMStoreFloat3(&m_dirLights[i].dir,rdir);}
还有一点要注意,原物体中规定顶点以顺时针为正面,但经过镜面反射后,对应的正面会变成逆时针方向,因此我们还需要相应地设置渲染状态,规定逆时针为正面。否则镜子中的物体会被作为背面而剔除掉。
第二件大事为设置相应的模板缓冲区状态。
为了使用之前标记好的区域,我们规定,只要当对应的模板值为参考值时才通过模板测试,否则失败。即这时的模板比较函数为EQUAL。显然,只有镜子中区域模板值才为参考值,其他区域都不会通过的。因此保证了物体只能被绘制在镜子范围之内。这个对应的模板状态在源代码框架中RenderStates中也定义好了,为DrawReflectionDSS。
现在设置好渲染状态就可以绘制镜子中的箱子了,渲染状态有如下两个关键点:
//由于反射前顺时针顺序的顶点在反射后变为逆时针,因此暂时需要让逆时针为正面来渲染m_deviceContext->RSSetState(RenderStates::CounterClockFrontRS);//设置好相应的模板缓冲区状态m_deviceContext->OMSetDepthStencilState(RenderStates::DrawReflectionDSS,0x1);
当然,绘制完还要记得把光源方向改回来。
for(UINT i=0; i<3; ++i){m_dirLights[i].dir = oldDirs[i];}
4. 最后一步,渲染镜子本身
现在只差最后的镜子了。由于我们既要看到镜子本身,也要看到里面的物体,因此对于镜子的渲染,我们要开启混合,使用“透明”效果把镜子和里面的物体混合起来。这一步很简单了,不需要模板缓冲区的操作,只要设置好“透明”状态即可。透明状态我们在之前的例子中已经定义好了,我们直接使用。如下:
//开启透明状态m_deviceContext->OMSetBlendState(RenderStates::TransparentBS,blendFactor,0xffffffff);
OK, 整个渲染过程就结束了。小结下:
1. 正常绘制墙面、地面、箱子
2. 设置“禁止颜色写入”状态,只渲染镜子,以利用模板缓冲区标记镜子区域(ALWAYS比较操作,通过模板测试的使用REPLACE更新模板值)(不仅限于REPLACE,比如INCR、INCR_SAT也是可以的)
3. 使用相应的反射变换矩阵,渲染镜子中物体(光源的方向也需要相应的改变),设置相应的模板状态,只在标记区域内渲染(EQUAL比较操作)
4. 开启“透明”效果,渲染镜子本身。
6. 总结
模板缓冲区的介绍到这儿就结束了。学习模板缓冲区的关键在于例子的学习,这节通过一个平面镜的反射效果实现初步演示了模板的操作。此外,模板还可以用于其他很多特效中,比如阴影的实现。总之,模板缓冲区的使用是非常灵活的,大家可以在今后结合例子逐步的体会它的使用,并不断地总结,以慢慢地学会自己使用模板来解决其他相关问题。
最后是本文对应的示例程序源代码
本文完