DirectX11 With Windows SDK–34 位移贴图

前言

在前面的章节中,我们学到了法线贴图和曲面细分。现在我们可以将这两者进行结合以改善效果,因为法线贴图仅仅只是改善了光照的细节,但它并没有从根本上改善几何体的细节。从某种意义上来说,法线贴图只是一个光照的小把戏。接下来我们将会学习如何使用位移贴图来改善网格细节。

在此之前你需要了解如下章节:

章节
25 法线贴图
33 曲面细分阶段(Tessellation)

学习目标:

  1. 了解位移贴图
  2. 熟悉如何用曲面细分来改善网格细节

DirectX11 With Windows SDK完整目录

Github项目源码

欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。

位移贴图(Displacement Mapping)

位移贴图的想法是利用一个额外的贴图,称作高度图,它描述了一个表面的凸起和缝隙。换句话说,法线贴图有三个颜色通道来为每个像素产生法线向量(x, y, z),而高度图仅仅由一个颜色通道来为每个像素产生高度值h。从视觉上来看,高度图仅仅是一张灰度图(因为灰度图只有一个颜色通道),每个像素可以解释成一个高度值,它基本上是一个2D标量场的离散表示h = f(x, z)。当我们对网格进行曲面细分时,我们在域着色器对高度图进行采样,然后利用法线的方向来对顶点进行偏移,以此来增加网格的几何体细节。

尽管我们通过镶嵌来对几何体增加三角形,但是它并没有增加其本身的细节。那是因为如果你对三角形进行多次细分,你只是获得了更多的和原来的三角形同属于一个平面的三角形。为了增加细节(如凸起和缝隙),你需要以某种方式来偏移这些经过镶嵌后得到的顶点。高度图是其中一个座位输入的纹理资源,它可以用来对镶嵌后的顶点进行偏移。通常情况下,我们会用到下面的公式,为此我们还需要用到法线贴图采样出来的法向量来确定偏移的方向:

\[\mathbf{p’}=\mathbf{p}+s(h-1)\mathbf{n}
\]

其中标量h∈[0, 1]是从高度图得到的高度值。我们对高度值减1来让区间[0, 1]→[-1, 0]。因为表面的法向量通常是面向网格的外部,这意味着我们以向内偏移的方式来替代向外偏移。一般将几何体弹入会比将几何体拉出更为方便一些。标量s则是用来控制在世界空间的塌陷程度。这样高度值的将从[0, 1]→[-s, 0],即高度值最大的时候将不会有向内的偏移,而高度值最小的时候将会产生最大的向内偏移。通常我们会将高度图存放在法线贴图中的alpha通道。

生成高度图是一项艺术性的工作,纹理艺术家可以绘制它们,或者使用工具来产生(例如:crazybump

位移贴图的着色器代码

位移贴图的代码主要在顶点着色器、外壳着色器和域着色器有所变化。像素着色器则和我们之前使用了法线贴图的版本一样无需改动。

图元类型

为了将位移贴图整合到我们的渲染当中,我们需要曲面细分的支持,这样我们就可以细化我们的几何分辨率,使得他能够与位移贴图更好地匹配。接下来我们将使用图元类型D3D11_PRIMITIVE_TOPOLOGY_3_CONTROL_POINT_PATCHLIST而不是D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST来绘制我们的网格三角形。通过这种方式,三角形的三个顶点将解释成三角形面片的3个控制点,以允许我们来对每个三角形进行镶嵌。

顶点着色器

当我们处理曲面细分的时候,我们必须决定每个三角形的细分程度。这里我们将引入一个简单的距离量来确定细分数目。若三角形离摄像机越近,它的细分程度越大。顶点着色器通过计算每个顶点和摄像机之间的距离来帮助我们计算出曲面细分因子,然后将其传递给外给着色器。

在常量缓冲区中,我们引入了下面这些数据来控制距离的计算。这些值的设置非常依赖于场景(你的场景有多大,以及你想要怎样的细分程度):

cbuffer CBChangesEveryFrame
{
    // ...
    float g_HeightScale;
    float g_MaxTessDistance;
    float g_MinTessDistance;
    float g_MinTessFactor;
    float g_MaxTessFactor;
}
  1. g_MaxTessDistance:从摄像机到该顶点的距离拉近到某个阈值时,将会达到最大的曲面细分因子
  2. g_MinTessDistance:从摄像机到该顶点的距离拉远到某个阈值时,将会达到最小的曲面细分因子
  3. g_MinTessFactor:曲面细分因子的最小值。比如说你想让每个三角形无论距离摄像机多远,都要让它最少被镶嵌成3份
  4. g_MaxTessFactor:曲面细分因子的最大值。比如说你想让这些三角形无论距离摄像机多近,它最多的镶嵌份数不超过6.此外,回想起上一章所提到的建议,镶嵌后的三角形如果少于8个像素将会变得低效。

此外我们应该留意到g_MaxTessDistance < g_MinTessDistance,因为随着顶点距离我们的摄像机越近,镶嵌的份数将会越多。

使用这些变量,我们就可以创建一个关于距离的线性函数来决定如何根据距离来确定镶嵌的份数。

// DisplacementMapObject_VS.hlsl
#include "Basic.hlsli"

// 顶点着色器
TessVertexOut VS(VertexPosNormalTangentTex vIn)
{
    TessVertexOut vOut;

    vOut.PosW = mul(float4(vIn.PosL, 1.0f), g_World).xyz;
    vOut.NormalW = mul(vIn.NormalL, (float3x3) g_WorldInvTranspose);
    vOut.TangentW = mul(vIn.TangentL, g_World);
    vOut.Tex = vIn.Tex;
    
    float d = distance(vOut.PosW, g_EyePosW);
    
    // 标准化曲面细分因子
    // TessFactor = 
    //   0, d >= g_MinTessDistance
    //   (g_MinTessDistance - d) / (g_MinTessDistance - g_MaxTessDistance), g_MinTessDistance <= d <= g_MaxTessDistance
    //   1, d <= g_MaxTessDistance
    float tess = saturate((g_MinTessDistance - d) / (g_MinTessDistance - g_MaxTessDistance));
    
    // [0, 1] --> [g_MinTessFactor, g_MaxTessFactor]
    vOut.TessFactor = g_MinTessFactor + tess * (g_MaxTessFactor - g_MinTessFactor);
    
    return vOut;
}

// DisplacementMapInstance_VS.hlsl
#include "Basic.hlsli"

// 顶点着色器
TessVertexOut VS(InstancePosNormalTangentTex vIn)
{
    TessVertexOut vOut;
    
    vOut.PosW = mul(float4(vIn.PosL, 1.0f), vIn.World).xyz;
    vOut.NormalW = mul(vIn.NormalL, (float3x3) vIn.WorldInvTranspose);
    vOut.TangentW = mul(vIn.TangentL, vIn.World);
    vOut.Tex = vIn.Tex;
    
    float d = distance(vOut.PosW, g_EyePosW);
    
    // 标准化曲面细分因子
    // TessFactor = 
    //   0, d >= g_MinTessDistance
    //   (g_MinTessDistance - d) / (g_MinTessDistance - g_MaxTessDistance), g_MinTessDistance <= d <= g_MaxTessDistance
    //   1, d <= g_MaxTessDistance
    float tess = saturate((g_MinTessDistance - d) / (g_MinTessDistance - g_MaxTessDistance));
    
    // [0, 1] --> [g_MinTessFactor, g_MaxTessFactor]
    vOut.TessFactor = g_MinTessFactor + tess * (g_MaxTessFactor - g_MinTessFactor);
    
    return vOut;
}


外壳着色器

回想上一章说的,常量外壳着色器对每个面片进行计算,并且它的任务是要输出该面片的曲面细分因子。曲面细分因子将告诉镶嵌器阶段对该面片以怎样的程度来进行镶嵌处理。曲面细分因子计算的大部分工作是由顶点着色器所完成的,但仍有一部分的工作需要交给常量外壳着色器处理。特别地,我们通过对顶点曲面细分因子进行求平均值的方式来得到边缘的曲面细分因子。至于内部的曲面细分因子,我们就随意挑选了第一条边的曲面细分因子。

PatchTess PatchHS(InputPatch<TessVertexOut, 3> patch,
                  uint patchID : SV_PrimitiveID)
{
    PatchTess pt;
	
    // 对每条边的曲面细分因子求平均值,并选择其中一条边的作为其内部的
    // 曲面细分因子。基于边的属性来进行曲面细分因子的计算非常重要,这
    // 样那些与多个三角形共享的边将会拥有相同的曲面细分因子,否则会导
    // 致间隙的产生
    pt.EdgeTess[0] = 0.5f * (patch[1].TessFactor + patch[2].TessFactor);
    pt.EdgeTess[1] = 0.5f * (patch[2].TessFactor + patch[0].TessFactor);
    pt.EdgeTess[2] = 0.5f * (patch[0].TessFactor + patch[1].TessFactor);
    pt.InsideTess = pt.EdgeTess[0];
	
    return pt;
}

那些与多个三角形所共享的边应当拥有相同的曲面细分因子,否则网格的裂纹可能会出现(见下图)。举个例子说下不计算曲面细分因子的情况,加入我们通过摄像机到三角形中心点的距离来计算内部曲面细分因子。然后我们将内部的曲面细分因子也设置到边缘曲面细分因子上。如果两个邻接三角形拥有不同的内部曲面细分因子,它们的边也将会拥有不同的曲面细分因子,从而导致在进行位移映射后会产生T型连接的裂纹效果。

可以看到,图a展示了两个三角形共享一条边。图b上面的三角形进行了一次边缘细分,下面的三角形则没有细分。图c上面的三角形进行了一次内部细分,经过位移映射后,新产生的顶点被移走了(一般是向内移动),从而在两个三角形之间产生了一条缝隙。

控制点外壳着色器以面片的控制点作为输入,每次调用处理一个控制点并输出。在本章示例项目中,控制点外壳着色器仅仅是将数据进行直传:

// 外壳着色器
[domain("tri")]
[partitioning("fractional_odd")]
[outputtopology("triangle_cw")]
[outputcontrolpoints(3)]
[patchconstantfunc("PatchHS")]
HullOut HS(InputPatch<TessVertexOut, 3> patch,
    uint i : SV_OutputControlPointID,
    uint patchId : SV_PrimitiveID)
{
    HullOut hOut;
	
	// 直传
    hOut.PosW = patch[i].PosW;
    hOut.NormalW = patch[i].NormalW;
    hOut.TangentW = patch[i].TangentW;
    hOut.Tex = patch[i].Tex;
	
    return hOut;
}

域着色器

经过镶嵌器创建出来的每个顶点都会有调用一次域着色器。在这里我们将会对高度图(即法线贴图的Alpha通道部分)进行采样,然后利用法向量对顶点偏移,从而完成整个位移映射的过程。

// DisplacementMap_DS.hlsl
#include "Basic.hlsli"

[domain("tri")]
VertexOutNormalMap DS(PatchTess patchTess,
             float3 bary : SV_DomainLocation,
             const OutputPatch<HullOut, 3> tri)
{
    VertexOutNormalMap dOut;
    
    // 对面片属性进行插值以生成顶点
    dOut.PosW     = bary.x * tri[0].PosW     + bary.y * tri[1].PosW     + bary.z * tri[2].PosW;
    dOut.NormalW  = bary.x * tri[0].NormalW  + bary.y * tri[1].NormalW  + bary.z * tri[2].NormalW;
    dOut.TangentW = bary.x * tri[0].TangentW + bary.y * tri[1].TangentW + bary.z * tri[2].TangentW;
    dOut.Tex      = bary.x * tri[0].Tex      + bary.y * tri[1].Tex      + bary.z * tri[2].Tex;
    
    // 对插值后的法向量进行标准化
    dOut.NormalW = normalize(dOut.NormalW);
    
    //
    // 位移映射
    //
    
    // 基于摄像机到顶点的距离选取mipmap等级;特别地,对每个MipInterval单位选择下一个mipLevel
    // 然后将mipLevel限制在[0, 6]
    const float MipInterval = 20.0f;
    float mipLevel = clamp((distance(dOut.PosW, g_EyePosW) - MipInterval) / MipInterval, 0.0f, 6.0f);
    
    // 对高度图采样(存在法线贴图的alpha通道)
    float h = g_NormalMap.SampleLevel(g_Sam, dOut.Tex, mipLevel).a;
    
    // 沿着法向量进行偏移
    dOut.PosW += (g_HeightScale * (h - 1.0f)) * dOut.NormalW;
    
    // 生成投影纹理坐标
    dOut.ShadowPosH = mul(float4(dOut.PosW, 1.0f), g_ShadowTransform);
    
    // 投影到齐次裁减空间
    dOut.PosH = mul(float4(dOut.PosW, 1.0f), g_ViewProj);
    
    // 从NDC坐标[-1, 1]^2变换到纹理空间坐标[0, 1]^2
    // u = 0.5x + 0.5
    // v = -0.5y + 0.5
    // ((xw, yw, zw, w) + (w, w, 0, 0)) * (0.5, -0.5, 1, 1) = ((0.5x + 0.5)w, (-0.5y + 0.5)w, zw, w)
    //                                                      = (uw, vw, zw, w)
    //                                                      =>  (u, v, z, 1)
    dOut.SSAOPosH = (dOut.PosH + float4(dOut.PosH.ww, 0.0f, 0.0f)) * float4(0.5f, -0.5f, 1.0f, 1.0f);
    
    return dOut;
}

这里值得注意的是,我们需要在域着色器中自行选择mipmap等级。像素着色器中的方法Texture2D::Sample在域着色器中是不能使用的,所以我们必须使用Texture2D::SampleLevel方法并手工指定mipmap等级。

如果我们只是学了法线贴图的话,到这里基本上就了解的差不多了。但如果学了阴影映射和SSAO的话,那么这里就又多了两个坑要填了。如果我们用了位移映射来绘制,那么在绘制阴影的时候,也一样要走一遍位移映射;对于SSAO来说也更是如此,如果不对SSAO写入深度值的过程加入位移映射,那么在正式绘制场景的时候就会因为像素深度值不一致而被剔除,从而导致了在运行龙书的SSAO Demo时,在开启了位移映射之后,那些拥有法线贴图的物体都没有被画出来的现象:

所以接下来做的事情就是体力活了,把DisplacementMap从顶点着色器到域着色器的实现原理也要搬运到绘制阴影图的过程,以及在SSAO绘制法向量/深度缓冲区顺便写入深度/模板缓冲区的过程中。因为代码上高度相似,这里我就只是列出本章新增的着色器文件列表:

BasicEffect SSAOEffect ShadowEffect
DisplacementMapObject_VS SSAO_NormalDepth_ObjectTess_VS ShadowObjectTess_VS
DisplacementMapInstance_VS SSAO_NormalDepth_InstanceTess_VS ShadowInstanceTess_VS
DisplacementMap_HS SSAO_NormalDepth_HS Shadow_DS
DisplacementMap_DS SSAO_NormalDepth_DS Shadow_HS

C++端代码实现

在本章中,与位移映射直接相关的类有BasicEffectSSAOEffectShadowEffect类,都是在前面的基础上作的修改。然后GameObjectDraw也为此有所修改。GameApp类承担了实现过程,和SSAO的相比绘制框架的变动比较小。这里就不放出修改的部分了,读者可以自行浏览。

网格细节问题

首先要注意的是,我们是对顶点进行位移映射。如果网格的三角形比较大,比如说只有4个顶点的地板,经过曲面细分后能生成的新顶点也比较有限,位移映射的效果就不明显。为此,我们需要增大网格模型的顶点密集程度,意味着我们增大了高度图的采样点数目,以让我们能够逼近真实的地形。如果我们不走曲面细分,那我们就需要提前准备三角形密集的网格数据,这样需要占用比较多的显存或内存。但即便是用了曲面细分,我们要权衡初始网格的顶点密集程度,以及经过曲面细分后的顶点密集程度如何。

在本章的示例中,我们的地面不再使用Geometry::CreatePlain,而是用Geometry::CreateTerrain来创建出更加精细的地面网格。由于一开始写的Geometry::CreateCylinder它的侧面三角形比较大,曲面细分后的顶点数目也不够密集,为此我已经修改了它的实现,让侧面能够支持分层的三角形。

演示

下面的动图展示了基础绘制、法线贴图绘制、位移贴图绘制下的区别,以及曲面细分前后网格的区别。

而下面的动图则展示了不同的HeightScale下位移映射的效果。

DirectX11 With Windows SDK完整目录

Github项目源码

欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。