19-地形渲染

地形渲染的想法是从一个平坦的网格开始(图19.1的顶部)。 然后我们调整顶点的高度(即y坐标),使网格模型平滑地从山到谷的过渡,从而模拟一个地形(图19.1的中间)。 当然,我们应用了一个很好的纹理来渲染沙滩,草地,岩石峭壁和雪山(图19.1的底部)。
目标:
1.了解如何为地形生成高度信息,以便在山谷之间实现平滑过渡。
2.了解如何纹理地形。
3.使用硬件细分来渲染具有连续细节层次的地形。
4.发现一种方法来保持相机或其他物体种植在地形表面上。


图19.1 (顶部)三角形网格。(中)平滑高度过渡的三角形网格用于创建山丘和山谷。 (底部)加了光照和纹理的地形。

19.1 高度图

我们使用高度图来描述我们地形的山丘和山谷。高度图是矩阵,其中每个元素指定地形网格中特定顶点的高度。也就是说,在每个网格顶点的高度图中存在一个条目,并且第ij个高度图条目提供了第i个顶点的高度。 通常情况下,高度图在图像编辑器中用图形表示为灰度图,其中黑色表示最小高度,白色表示最大高度,灰色阴影表示高度之间。 图19.2显示了一些高度图和它们构造的相应地形的例子。


图19.2 高度图的例子。观察高度图所描述的高度如何构建不同的地形曲面。

当我们将高度贴图存储在磁盘上时,我们通常为高度图中的每个元素分配一个内存字节,因此高度范围可以从0到255之间。范围0到255足以保持地形高度之间的过渡,但 在我们的应用程序中,我们可能需要扩展0到255范围以匹配3D世界的规模。 例如,如果我们在3D世界中的度量单位是英尺,那么0到255不会给我们足够的值来表示任何有趣的值。 出于这个原因,当我们将数据加载到我们的应用程序中时,我们为每个高度元素分配一个浮点数。 这使我们可以在0到255的范围之外进行扩展,以匹配任何所需的比例; 此外,它还使我们能够过滤高度图并生成整数之间的高度值。

NOTE:在§6.11中,我们使用数学函数创建了一个“地形”。 这是程序生成的地形的一个例子。 然而,很难想出一个精确描述你想要的地形的函数。 高度图给予更多的灵活性,因为它们可以由绘画程序或高度图编辑工具中的艺术家编辑。

19.1.1创建一个高度图

可以通过程序或图像编辑器(如Adobe Photoshop)生成高度图。 使用油漆过滤器生成不同的混沌高度图模式可以证明是一个好的开始; 那么可以通过利用绘图编辑器的工具手动调整高度图。 应用模糊滤镜可用于平滑高度贴图中的粗糙边缘。

Terragen程序(http://www.planetside.co.uk/)可以通过程序生成高度贴图,它还提供修改高度贴图的工具(或者可以导出高度贴图,然后在一个单独的绘图程序中导入和修改,如Photoshop中)。 Bryce程序(http://www.daz3d.com/i.x/software/bryce/-/)还有许多用于生成高度图的程序算法,以及内置的高度图编辑器。 Dark Tree(http://www.darksim.com/)是一个功能强大的程序纹理创作程序,尤其可用于创建灰度高度图。

完成绘制高度图后,需要将其保存为8位RAW文件。 RAW文件一个接一个地包含图像的字节。 这使得将图像读入我们的程序非常容易。 您的软件可能会要求您使用标题保存RAW文件; 不指定标题。 图19.3显示了Terragen的导出对话框。


图19.3 (左)景观生成器允许您以程序方式生成随机地形,并使用画笔工具手动雕刻地形。 (右)Terragen的导出对话框。 注意所选的导出方法是8位RAW格式。

NOTE:您不必使用RAW格式来存储您的高度图; 你可以使用任何适合你需要的格式。 RAW格式只是我们可以使用的格式的一个例子。 我们决定使用RAW格式,因为许多图像编辑器可以导出为这种格式,并且可以非常容易地将RAW文件中的数据加载到我们的程序演示中。 本书中的演示使用8位RAW文件(即,高度图中的每个元素都是8位整数)。

NOTE:如果256个高度步长对于您的需要太粗糙,您可以考虑存储16位高度图,其中每个高度条目由16位整数表示。 Terragen可以导出16位RAW高度图。

19.1.2 加载RAW文件

由于RAW文件不过是一个连续的字节块(其中每个字节都是一个高度图条目),所以我们可以轻松地在一个std :: ifstream :: read调用中读取内存块,就像在下一个方法中所做的那样:

void Terrain::LoadHeightmap()
{
// A height for each vertex
std::vector<unsigned char> in(
mInfo.HeightmapWidth * mInfo.HeightmapHeight);
// Open the file.
std::ifstream inFile;
inFile.open(mInfo.HeightMapFilename.c_str(),
std::ios_base::binary);
if(inFile)
{
// Read the RAW bytes.
inFile.read((char*)&in[0], (std::streamsize)in.size());
// Done with file.
inFile.close();
}
// Copy the array data into a float array and scale it.
mHeightmap.resize(mInfo.HeightmapHeight * mInfo.HeightmapWidth, 0);
for(UINT i = 0; i < mInfo.HeightmapHeight * mInfo.HeightmapWidth; ++i)
{
mHeightmap[i] = (in[i] / 255.0f)*mInfo.HeightScale;
}
}

mInfo变量是Terrain类的成员,它是以下结构的一个实例,它描述了地形的各种属性:

struct InitInfo
{
// Filename of RAW heightmap data.
std::wstring HeightMapFilename;
// Texture filenames used for texturing the terrain.
std::wstring LayerMapFilename0;
std::wstring LayerMapFilename1;
std::wstring LayerMapFilename2;
std::wstring LayerMapFilename3;
std::wstring LayerMapFilename4;
std::wstring BlendMapFilename;
// Scale to apply to heights after they have been
// loaded from the heightmap.
float HeightScale;
// Dimensions of the heightmap.
UINT HeightmapWidth;
UINT HeightmapHeight;
// The cell spacing along the x- and z-axes (see Figure (19.4)).
float CellSpacing;
};

NOTE:读者可能希望查看§5.11的网格结构。


图19.4 网格属性。


图19.5 (a)在[0,255]范围内的浮点高度值。(b)高度值被钳位到最近的整数。

19.1.3 平滑

使用8位高度图的问题之一是,这意味着我们只能表示256个高度的谨慎步骤。 因此,我们不能模拟图19.5a中显示的高度值; 相反,我们结束了图19.5b。 这种截断创建了一个“粗糙”的地形,而这并非意图。 当然,一旦我们截断,我们不能恢复原始高度值,但通过平滑图19.5b,我们可以得到接近19.5a的东西。

所以我们通过读取原始字节将高度图加载到内存中。 然后,我们将字节数组复制到浮点数组中,以便我们具有浮点精度。 然后,我们将过滤器应用于浮点高度图,该高度图平滑了高度图,使相邻元素之间的高度差别不那么剧烈。 我们使用的过滤算法非常基础。 通过对自身及其8个相邻像素进行平均来计算新的滤波后的高度图像素(图19.6):

hi,j˜=hi1,j1+hi1,j+hi1,j+1+hi,j1+hi,j+hi,j+1+hi+1,j1+hi+1,j+hi+1,j+19 h i , j ~ = h i − 1 , j − 1 + h i − 1 , j + h i − 1 , j + 1 + h i , j − 1 + h i , j + h i , j + 1 + h i + 1 , j − 1 + h i + 1 , j + h i + 1 , j + 1 9


图19.6 第i个顶点的高度可以通过平均第i个高度图条目及其8个邻居高度来找到。

在我们位于高度图边缘的情况下,一个像素没有八个相邻像素,那么我们只需要尽可能多的相邻像素。

以下是平均高度图中第ij个像素的函数的实现:

bool Terrain::InBounds(int i, int j)
{
// True if ij are valid indices; false otherwise.
return
i >= 0 && i < (int)mInfo.HeightmapHeight &&
j >= 0 && j < (int)mInfo.HeightmapWidth;
}
float Terrain::Average(int i, int j)
{
// Function computes the average height of the ij element.
// It averages itself with its eight neighbor pixels. Note
// that if a pixel is missing neighbor, we just don't include it
// in the average--that is, edge pixels don't have a neighbor pixel.
//
// ----------
// | 1| 2| 3|
// ----------
// |4 |ij| 6|
// ----------
// | 7| 8| 9|
// ----------
float avg = 0.0f;
float num = 0.0f;
// Use int to allow negatives. If we use UINT, @ i=0, m=i-1=UINT_MAX
// and no iterations of the outer for loop occur.
for(int m = i-1; m <= i+1; ++m)
{
for(int n = j-1; n <= j+1; ++n)
{
if(InBounds(m,n))
{
avg += mHeightmap[m*mInfo.HeightmapWidth + n];
num += 1.0f;
}
}
}
return avg / num;
}

如果条目位于高度图上,则函数inBounds返回true,否则返回false。 因此,如果我们尝试对边缘上不属于高度图的条目旁边的元素进行采样,那么inBounds将返回false,并且我们不会将其包含在我们的平均值中 - 它不存在。

为了平滑整个高度图,我们只对每个高度图条目应用平均值:

void Terrain::Smooth()
{
std::vector<float> dest(mHeightmap.size());
for(UINT i = 0; i < mInfo.HeightmapHeight; ++i)
{
for(UINT j = 0; j < mInfo.HeightmapWidth; ++j)
{
dest[i*mInfo.HeightmapWidth+j] = Average(i,j);
}
}/
/ Replace the old heightmap with the filtered one.
mHeightmap = dest;
}

19.1.4高度贴图着色器资源视图

正如我们将在下一节中看到的,为了支持曲面细分和位移贴图,我们需要在我们的着色器程序中对高度贴图进行采样。 因此,我们必须为高度图创建着色器资源视图。 这应该是现在熟悉的练习; 唯一的技巧是为了节省内存,我们使用16位浮点数而不是32位浮点数。 要将32位浮点数转换为16位浮点数,我们使用XNA数学函数XMConvertFloatToHalf。

void Terrain::BuildHeightmapSRV(ID3D11Device* device)
{
D3D11_TEXTURE2D_DESC texDesc;
texDesc.Width = mInfo.HeightmapWidth;
texDesc.Height = mInfo.HeightmapHeight;
texDesc.MipLevels = 1;
texDesc.ArraySize = 1;
texDesc.Format = DXGI_FORMAT_R16_FLOAT;
texDesc.SampleDesc.Count = 1;
texDesc.SampleDesc.Quality = 0;
texDesc.Usage = D3D11_USAGE_DEFAULT;
texDesc.BindFlags = D3D11_BIND_SHADER_RESOURCE;
texDesc.CPUAccessFlags = 0;
texDesc.MiscFlags = 0;
// HALF is defined in xnamath.h, for storing 16-bit float.
std::vector<HALF> hmap(mHeightmap.size());
std::transform(mHeightmap.begin(), mHeightmap.end(),
hmap.begin(), XMConvertFloatToHalf);
D3D11_SUBRESOURCE_DATA data;
data.pSysMem = &hmap[0];
data.SysMemPitch = mInfo.HeightmapWidth*sizeof(HALF);
data.SysMemSlicePitch = 0;
ID3D11Texture2D* hmapTex = 0;
HR(device->CreateTexture2D(&texDesc, &data, &hmapTex));
D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc;
srvDesc.Format = texDesc.Format;
srvDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D;
srvDesc.Texture2D.MostDetailedMip = 0;
srvDesc.Texture2D.MipLevels = -1;
HR(device->CreateShaderResourceView(
hmapTex, &srvDesc, &mHeightMapSRV));
// SRV saves reference.
ReleaseCOM(hmapTex);
}

19.2地形斜纹

地形覆盖大面积,因此建造它们所需的三角形数量很大。 通常,地形需要一个详细程度(LOD)系统。 也就是说,远离相机的地形部分不需要多少三角形,因为细节不被察觉; 见图19.7。


图19.7 详细程度随着距相机的距离而减少。 我们的地形曲面细分策略如下:

1.放下四个补丁网格。
2.根据它们距相机的距离对贴片进行细分。
3.将高度图绑定为着色器资源。 在域着色器中,从高度图执行位移映射以将生成的顶点偏移到其正确的高度。

19.2.1网格构造

假设我们的高度图具有尺寸 (2m+1)×(2n+1) ( 2 m + 1 ) × ( 2 n + 1 ) 。我们可以从这个高度图创建的最详细的地形是 (2m+1)×(2n+1) ( 2 m + 1 ) × ( 2 n + 1 ) 个顶点的网格; 因此,这代表了我们最大的曲面细分的地形网格,我们将其称为 T0 T 0 T0 T 0 的顶点之间的单元格间距由InitInfo :: CellSpacing属性(第9.1.2节)给出。也就是说,当我们引用单元格时,我们正在讨论最细化的网格 T0 T 0 的单元格。

我们将地形划分成一个补丁网格,使得每个补丁覆盖T0的65×65个顶点的块(见图19.8)。 我们选择65是因为最大曲面细分因子是64,因此我们可以将一个补丁拼凑成64×64个单元,这些单元与65×65个顶点相交。 因此,如果某个修补程序得到最大程度的细化,则它会从每个生成的顶点所需的高度图中获取信息。 如果一个补丁具有曲面细分系数1,则该补丁不会被细分,而只会呈现为两个三角形。 因此,补丁网格可以被认为是地形最粗糙的棋盘格版本。


图19.8 为了说明的目的,我们使用较小的数字。 最大棋盘格地形网格具有17×25个顶点和16×24个单元格。我们将网格划分为一个补丁网格,每个补丁网格覆盖8×8个网格或9×9个顶点。这导致了2×3的补丁网格。

贴片顶点网格尺寸的计算公式如下:

static const int CellsPerPatch = 64;
// Divide heightmap into patches such that each patch has CellsPerPatch.
mNumPatchVertRows = ((mInfo.HeightmapHeight-1) / CellsPerPatch) + 1;
mNumPatchVertCols = ((mInfo.HeightmapWidth-1) / CellsPerPatch) + 1;

并且贴片顶点和四元贴片基元的总数由以下公式计算:

mNumPatchVertices = mNumPatchVertRows*mNumPatchVertCols;
mNumPatchQuadFaces = (mNumPatchVertRows-1)*(mNumPatchVertCols-1);

我们的地形补丁顶点结构如下所示:

struct Terrain
{
XMFLOAT3 Pos;
XMFLOAT2 Tex;
XMFLOAT2 BoundsY;
};

BoundsY属性将在§19.3中解释。生成四色块顶点和索引缓冲区的代码如下所示:

float Terrain::GetWidth()const
{
// Total terrain width.
return (mInfo.HeightmapWidth-1)*mInfo.CellSpacing;
}
float Terrain::GetDepth()const
{
// Total terrain depth.
return (mInfo.HeightmapHeight-1)*mInfo.CellSpacing;
}
void Terrain::BuildQuadPatchVB(ID3D11Device* device)
{
std::vector<Vertex::Terrain> patchVertices(mNumPatchVertRows*mNumPatchVertCols);
float halfWidth = 0.5f*GetWidth();
float halfDepth = 0.5f*GetDepth();
float patchWidth = GetWidth() / (mNumPatchVertCols-1);
float patchDepth = GetDepth() / (mNumPatchVertRows-1);
float du = 1.0f / (mNumPatchVertCols-1);
float dv = 1.0f / (mNumPatchVertRows-1);
for(UINT i = 0; i < mNumPatchVertRows; ++i)
{
float z = halfDepth - i*patchDepth;
for(UINT j = 0; j < mNumPatchVertCols; ++j)
{
float x = -halfWidth + j*patchWidth;
patchVertices[i*mNumPatchVertCols+j].Pos = XMFLOAT3(x, 0.0f, z);
// Stretch texture over grid.
patchVertices[i*mNumPatchVertCols+j].Tex.x = j*du;
patchVertices[i*mNumPatchVertCols+j].Tex.y = i*dv;
}
}
// Store axis-aligned bounding box y-bounds in upper-left patch corner.
for(UINT i = 0; i < mNumPatchVertRows-1; ++i)
{
for(UINT j = 0; j < mNumPatchVertCols-1; ++j)
{
UINT patchID = i*(mNumPatchVertCols-1)+j;
patchVertices[i*mNumPatchVertCols+j].BoundsY = mPatchBoundsY[patchID];
}
}
D3D11_BUFFER_DESC vbd;
vbd.Usage = D3D11_USAGE_IMMUTABLE;
vbd.ByteWidth = sizeof(Vertex::Terrain) * patchVertices.size();
vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
vbd.CPUAccessFlags = 0;
vbd.MiscFlags = 0;
vbd.StructureByteStride = 0;
D3D11_SUBRESOURCE_DATA vinitData;
vinitData.pSysMem = &patchVertices[0];
HR(device->CreateBuffer(&vbd, &vinitData, &mQuadPatchVB));
}
void Terrain::BuildQuadPatchIB(ID3D11Device* device)
{
std::vector<USHORT> indices(mNumPatchQuadFaces*4); // 4 indices per quad face
// Iterate over each quad and compute indices.
int k = 0;
for(UINT i = 0; i < mNumPatchVertRows-1; ++i)
{
for(UINT j = 0; j < mNumPatchVertCols-1; ++j)
{
// Top row of 2x2 quad patch
indices[k] = i*mNumPatchVertCols+j;
indices[k+1] = i*mNumPatchVertCols+j+1;
// Bottom row of 2x2 quad patch
indices[k+2] = (i+1)*mNumPatchVertCols+j;
indices[k+3] = (i+1)*mNumPatchVertCols+j+1;
k += 4; // next quad
}
}
D3D11_BUFFER_DESC ibd;
ibd.Usage = D3D11_USAGE_IMMUTABLE;
ibd.ByteWidth = sizeof(USHORT) * indices.size();
ibd.BindFlags = D3D11_BIND_INDEX_BUFFER;
ibd.CPUAccessFlags = 0;
ibd.MiscFlags = 0;
ibd.StructureByteStride = 0;
D3D11_SUBRESOURCE_DATA iinitData;
iinitData.pSysMem = &indices[0];
HR(device->CreateBuffer(&ibd, &iinitData, &mQuadPatchIB));
}

19.2.2 地形顶点着色器

因为我们正在使用曲面细分,所以顶点着色器按照每个控制点进行操作。 我们的顶点着色器几乎是一个简单的传递着色器,不同之处在于我们通过读取高度贴图值来为贴片控制点进行位移贴图。 这使控制点的ycoordinates在适当的高度。 这样做的原因是,在外壳着色器中,我们将计算每个补丁和眼睛之间的距离; 使色块角偏移到适当的高度使得该距离计算比在xz平面中具有色块更精确。

Texture2D gHeightMap;
SamplerState samHeightmap
{
Filter = MIN_MAG_LINEAR_MIP_POINT;
AddressU = CLAMP;
AddressV = CLAMP;
};
struct VertexIn
{
float3 PosL : POSITION;
float2 Tex : TEXCOORD0;
float2 BoundsY : TEXCOORD1;
};
struct VertexOut
{
float3 PosW : POSITION;
float2 Tex : TEXCOORD0;
float2 BoundsY : TEXCOORD1;
};
VertexOut VS(VertexIn vin)
{
VertexOut vout;
// Terrain specified directly in world space.
vout.PosW = vin.PosL;
// Displace the patch corners to world space. This is to make
// the eye to patch distance calculation more accurate.
vout.PosW.y = gHeightMap.SampleLevel(samHeightmap, vin.Tex, 0).r;
// Output vertex attributes to next stage.
vout.Tex = vin.Tex;
vout.BoundsY = vin.BoundsY;
return vout;
}

19.2.3 镶嵌因子

外壳着色器常量函数负责计算每个修补程序的曲面细分因子,以指示细分每个修补程序的程度。 另外,我们可以使用外壳着色器常量函数在GPU上进行平截头体剔除。 我们将在第19.3节中解释基于GPU的补丁截取。

我们计算眼睛位置和每个色块边缘的中点之间的距离,以导出边缘细分因子。对于内部曲面细分因子,我们计算眼睛位置和色块中点之间的距离。 我们使用以下代码从距离中导出曲面细分因子:

// When distance is minimum, the tessellation is maximum.
// When distance is maximum, the tessellation is minimum.
float gMinDist;
float gMaxDist;
// Exponents for power of 2 tessellation. The tessellation
// range is [2^(gMinTess), 2^(gMaxTess)]. Since the maximum
// tessellation is 64, this means gMaxTess can be at most 6
// since 2^6 = 64, and gMinTess must be at least 0 since 2^0 = 1.
float gMinTess;
float gMaxTess;
float CalcTessFactor(float3 p)
{
float d = distance(p, gEyePosW);
float s = saturate((d - gMinDist) / (gMaxDist - gMinDist));
return pow(2, (lerp(gMaxTess, gMinTess, s)));
}

请注意,这个公式不同于第18章中做位移映射时使用的公式。我们使用2的幂,因为这意味着在每个细节的细节上,细分的数量加倍。例如,假设我们的细节级别为 23=8 2 3 = 8 ,下一个细化将细分数量加倍为 24=16 2 4 = 16 ,细节级别为细分数量的一半 22=4 2 2 = 4 。使用2的幂数 功能可以随距离更好地展现细节层次。

现在,在常量外壳着色器函数中,我们将此函数应用于贴片中点,并且修补边缘中点计算曲面细分因子:

struct PatchTess
{
float EdgeTess[4] : SV_TessFactor;
float InsideTess[2] : SV_InsideTessFactor;
};
PatchTess ConstantHS(InputPatch<VertexOut, 4> patch,
uint patchID : SV_PrimitiveID)
{
PatchTess pt;
//
// Frustum cull
//
[... Omit frustum culling code]
//
// Do normal tessellation based on distance.
//
else
{
// It is important to do the tess factor calculation
// based on the edge properties so that edges shared
// by more than one patch will have the same
// tessellation factor. Otherwise, gaps can appear.
// Compute midpoint on edges, and patch center
float3 e0 = 0.5f*(patch[0].PosW + patch[2].PosW);
float3 e1 = 0.5f*(patch[0].PosW + patch[1].PosW);
float3 e2 = 0.5f*(patch[1].PosW + patch[3].PosW);
float3 e3 = 0.5f*(patch[2].PosW + patch[3].PosW);
float3 c = 0.25f*(patch[0].PosW + patch[1].PosW +
patch[2].PosW + patch[3].PosW);
pt.EdgeTess[0] = CalcTessFactor(e0);
pt.EdgeTess[1] = CalcTessFactor(e1);
pt.EdgeTess[2] = CalcTessFactor(e2);
pt.EdgeTess[3] = CalcTessFactor(e3);
pt.InsideTess[0] = CalcTessFactor(c);
pt.InsideTess[1] = pt.InsideTess[0];
return pt;
}
}

19.2.4位移映射

回想一下,域着色器就像镶嵌细分的顶点着色器。 为每个生成的顶点评估域着色器。 我们在域着色器中的任务是使用曲面细分顶点位置的参数(u,v)坐标来插入控制点数据以导出实际的顶点位置和纹理坐标。 另外,我们对高度图进行采样以执行置换映射。

{
float4 PosH : SV_POSITION;
float3 PosW : POSITION;
float2 Tex : TEXCOORD0;
float2 TiledTex : TEXCOORD1;
};
// How much to tile the texture layers.
float2 gTexScale = 50.0f;
[domain("quad")]
DomainOut DS(PatchTess patchTess,
float2 uv : SV_DomainLocation,
const OutputPatch<HullOut, 4> quad)
{
DomainOut dout;
// Bilinear interpolation.
dout.PosW = lerp(
lerp(quad[0].PosW, quad[1].PosW, uv.x),
lerp(quad[2].PosW, quad[3].PosW, uv.x),
uv.y);
dout.Tex = lerp(
lerp(quad[0].Tex, quad[1].Tex, uv.x),
lerp(quad[2].Tex, quad[3].Tex, uv.x),
uv.y);
// Tile layer textures over terrain.
dout.TiledTex = dout.Tex*gTexScale;
// Displacement mapping
dout.PosW.y = gHeightMap.SampleLevel(samHeightmap, dout.Tex, 0).r;
// NOTE: We tried computing the normal in the domain shader
// using finite difference, but the vertices move continuously
// with fractional_even which creates noticable light shimmering
// artifacts as the normal changes. Therefore, we moved the
// calculation to the pixel shader.
// Project to homogeneous clip space.
dout.PosH = mul(float4(dout.PosW, 1.0f), gViewProj);
return dout;
}


图19.9 中央差异。 差异 =h(x+h)h(xh)2h = h ( x + h ) − h ( x − h ) 2 h 给出了x方向上切线向量的斜率。 我们使用这个差值作为点x处切线向量的估计值。

19.2.5 正切和正态矢量估计

我们使用中心差异估计像素着色器中高度贴图上的切线向量(一个在+ x方向,另一个在-z方向)(参见图19.9):

Tx(x,z)=(1,hx,0)(1,h(x+h,z)h(xh,z)2h,0)Tz(x,z)=(0,hz,1)(0,h(x,zh)h(x,z+h)2h,1) T x ( x , z ) = ( 1 , ∂ h ∂ x , 0 ) ≈ ( 1 , h ( x + h , z ) − h ( x − h , z ) 2 h , 0 ) T − z ( x , z ) = ( 0 , − ∂ h ∂ z , − 1 ) ≈ ( 0 , h ( x , z − h ) − h ( x , z + h ) 2 h , − 1 )

我们取负z方向,因为该方向对应于纹理空间v轴; 所以这些矢量也有助于形成法线贴图的切线空间。 一旦我们估计了正x方向和负z方向的切向量,我们就可以通过交叉乘积来计算法向量:

// Spacing between height values in normalized uv-space [0,1].
float gTexelCellSpaceU;
float gTexelCellSpaceV;
// Spacing between cells in world space.
float gWorldCellSpace;
//
// Estimate normal and tangent using central differences.
//
float2 leftTex = pin.Tex + float2(-gTexelCellSpaceU, 0.0f);
float2 rightTex = pin.Tex + float2(gTexelCellSpaceU, 0.0f);
float2 bottomTex = pin.Tex + float2(0.0f, gTexelCellSpaceV);
float2 topTex = pin.Tex + float2(0.0f, -gTexelCellSpaceV);
float leftY = gHeightMap.SampleLevel(samHeightmap, leftTex, 0).r;
float rightY = gHeightMap.SampleLevel(samHeightmap, rightTex, 0).r;
float bottomY = gHeightMap.SampleLevel(samHeightmap, bottomTex, 0).r;
float topY = gHeightMap.SampleLevel(samHeightmap, topTex, 0).r;
float3 tangent = normalize(
float3(2.0f*gWorldCellSpace, rightY - leftY, 0.0f));
float3 bitan = normalize(
float3(0.0f, bottomY - topY, -2.0f*gWorldCellSpace));
float3 normalW = cross(tangent, bitan);

我们尝试使用中心差分来计算域着色器中的法线,但顶点会以fractional_even曲面细分连续移动,从而产生与正常变化一样明显的光线闪烁伪影。 因此,我们将计算移至像素着色器。 不用在着色器中计算法线,也可以从离线高度图生成法线贴图; 这会在内存中交换一些GPU计算时间。 如果您决定使用纹理贴图,则只需存储 h/x||Tx|| ∂ h / ∂ x | | T x | | h/x||Tz|| ∂ h / ∂ x | | T − z | | 即可节省内存。 然后,您可以重构 Tx||Tx|| T x | | T x | | Tz||Tz|| T − z | | T − z | | 着色器中的法向量。这需要每个元素两个字节,并且除了法向矢量之外还为您提供切线向量。

19.3防尘贴片

地形通常覆盖广阔的区域,我们的许多补丁不会被相机看到。 这表明平截头体剔除将是一个很好的优化。 如果修补程序的曲面细分因子全部为零,则GPU会将该修补程序从进一步处理中丢弃; 这意味着不会浪费工作量,只为那些三角形镶嵌一个补丁,然后在裁剪阶段被淘汰。

为了进行平截头体剔除,我们需要两个成分:我们需要视锥体平面,并且我们需要每个补丁的边界体积。 第15章练习2解释了如何提取视锥平面。 实现本练习解决方案的代码如下(在d3dUtil.h / d3dUtil.cpp中实现):

void ExtractFrustumPlanes(XMFLOAT4 planes[6], CXMMATRIX M)
{
//
// Left
//
planes[0].x = M(0,3) + M(0,0);
planes[0].y = M(1,3) + M(1,0);
planes[0].z = M(2,3) + M(2,0);
planes[0].w = M(3,3) + M(3,0);
//
// Right
//
planes[1].x = M(0,3) - M(0,0);
planes[1].y = M(1,3) - M(1,0);
planes[1].z = M(2,3) - M(2,0);
planes[1].w = M(3,3) - M(3,0);
//
// Bottom
//
planes[2].x = M(0,3) + M(0,1);
planes[2].y = M(1,3) + M(1,1);
planes[2].z = M(2,3) + M(2,1);
planes[2].w = M(3,3) + M(3,1);
//
// Top
//
planes[3].x = M(0,3) - M(0,1);
planes[3].y = M(1,3) - M(1,1);
planes[3].z = M(2,3) - M(2,1);
planes[3].w = M(3,3) - M(3,1);
//
// Near
//
planes[4].x = M(0,2);
planes[4].y = M(1,2);
planes[4].z = M(2,2);
planes[4].w = M(3,2);
//
// Far
//
planes[5].x = M(0,3) - M(0,2);
planes[5].y = M(1,3) - M(1,2);
planes[5].z = M(2,3) - M(2,2);
planes[5].w = M(3,3) - M(3,2);
// Normalize the plane equations.
for(int i = 0; i < 6; ++i)
{
XMVECTOR v = XMPlaneNormalize(XMLoadFloat4(&planes[i]));
XMStoreFloat4(&planes[i], v);
}
}

接下来,我们需要一个关于每个补丁的边界卷。 因为每个补丁都是矩形的,我们选择一个轴对齐的边界框作为边界体积。 因为我们在xz平面中将补丁构造为矩形,所以补丁控制点固有地编码x和z坐标边界。 那么y坐标边界呢? 为了获得y坐标边界,我们必须做一个预处理步骤。 每个补丁包含65×65个高度图元素。 因此,对于每个补丁,我们扫描补丁覆盖的高度图条目,并计算最小和最大y坐标。 然后我们将这些值存储在补丁的左上角控制点中,以便我们可以访问常量外壳着色器中补丁的y边界。 以下代码显示我们计算每个补丁的y边界:

// x-stores minY, y-stores maxY.
std::vector<XMFLOAT2> mPatchBoundsY;
void Terrain::CalcAllPatchBoundsY()
{
mPatchBoundsY.resize(mNumPatchQuadFaces);
// For each patch
for(UINT i = 0; i < mNumPatchVertRows-1; ++i)
{
for(UINT j = 0; j < mNumPatchVertCols-1; ++j)
{
CalcPatchBoundsY(i, j);
}
}
}
void Terrain::CalcPatchBoundsY(UINT i, UINT j)
{
// Scan the heightmap values this patch covers and
// compute the min/max height.
UINT x0 = j*CellsPerPatch;
UINT x1 = (j+1)*CellsPerPatch;
UINT y0 = i*CellsPerPatch;
UINT y1 = (i+1)*CellsPerPatch;
float minY = +MathHelper::Infinity;
float maxY = -MathHelper::Infinity;
for(UINT y = y0; y <= y1; ++y)
{
for(UINT x = x0; x <= x1; ++x)
{
UINT k = y*mInfo.HeightmapWidth + x;
minY = MathHelper::Min(minY, mHeightmap[k]);
maxY = MathHelper::Max(maxY, mHeightmap[k]);
}
}
UINT patchID = i*(mNumPatchVertCols-1)+j;
mPatchBoundsY[patchID] = XMFLOAT2(minY, maxY);
}
void Terrain::BuildQuadPatchVB(ID3D11Device* device)
{
[...]
// Store axis-aligned bounding box y-bounds in upper-left patch corner.
for(UINT i = 0; i < mNumPatchVertRows-1; ++i)
{
for(UINT j = 0; j < mNumPatchVertCols-1; ++j)
{
UINT patchID = i*(mNumPatchVertCols-1)+j;
patchVertices[i*mNumPatchVertCols+j].BoundsY = mPatchBoundsY[patchID];
}
}[
...]
}

现在在恒定的外壳着色器中,我们可以构造我们的轴对齐的边界框,并执行框/平截头体相交测试,以查看框位于平截头体之外。 我们使用与第15章中介绍的不同的盒子/平面相交测试。它实际上是第15章练习4中描述的OBB/plane 测试的一个特例。因为盒子是轴对齐的,因此半径r简化:

a0r0=(a0,0,0)a1r1=(0,a1,0)a2r2=(0,0,a2)r=|a0r0n|+|a1r1n|+|a2r2n|=a0|nx|+a1|ny|+a2|nz| a 0 r 0 = ( a 0 , 0 , 0 ) a 1 r 1 = ( 0 , a 1 , 0 ) a 2 r 2 = ( 0 , 0 , a 2 ) r = | a 0 r 0 · n | + | a 1 r 1 · n | + | a 2 r 2 · n | = a 0 | n x | + a 1 | n y | + a 2 | n z |

我们喜欢对第15.2.3.3节中的测试,因为它不包含条件语句。

float4 gWorldFrustumPlanes[6];
// Returns true if the box is completely behind (in negative
// half space) of plane.
bool AabbBehindPlaneTest(float3 center, float3 extents, float4 plane)
{
float3 n = abs(plane.xyz); // (|n.x|, |n.y|, |n.z|)
// This is always positive.
float r = dot(extents, n);
// signed distance from center point to plane.
float s = dot(float4(center, 1.0f), plane);
// If the center point of the box is a distance of e or more behind the
// plane (in which case s is negative since it is behind the plane),
// then the box is completely in the negative half space of the plane.
return (s + e) < 0.0f;
}/
/ Returns true if the box is completely outside the frustum.
bool AabbOutsideFrustumTest(float3 center, float3 extents, float4 frustumPlanes[6])
{
for(int i = 0; i < 6; ++i)
{
// If the box is completely behind any of the frustum planes
// then it is outside the frustum.
if(AabbBehindPlaneTest(center, extents, frustumPlanes[i]))
{
return true;
}
}
return false;
}
PatchTess ConstantHS(InputPatch<VertexOut, 4> patch, uint patchID : SV_PrimitiveID)
{
PatchTess pt;
//
// Frustum cull
//
// We store the patch BoundsY in the first control point.
float minY = patch[0].BoundsY.x;
float maxY = patch[0].BoundsY.y;
// Build axis-aligned bounding box. patch[2] is lower-left corner
// and patch[1] is upper-right corner.
float3 vMin = float3(patch[2].PosW.x, minY, patch[2].PosW.z);
float3 vMax = float3(patch[1].PosW.x, maxY, patch[1].PosW.z);
// Center/extents representation.
float3 boxCenter = 0.5f*(vMin + vMax);
float3 boxExtents = 0.5f*(vMax - vMin);
if(AabbOutsideFrustumTest(boxCenter, boxExtents, gWorldFrustumPlanes))
{
pt.EdgeTess[0] = 0.0f;
pt.EdgeTess[1] = 0.0f;
pt.EdgeTess[2] = 0.0f;
pt.EdgeTess[3] = 0.0f;
pt.InsideTess[0] = 0.0f;
pt.InsideTess[1] = 0.0f;
return pt;
}/
/
// Do normal tessellation based on distance.
//
else
{
[...]
}


图19.10 OBB /plane 相交测试。我们可以对AABB使用相同的测试,因为AABB是OBB的特例。此外,r的公式简化了AABB的情况。

19.4纹理

回想一下§8.10,我们在山上铺平了草地的纹理。我们平铺纹理以提高分辨率(即,增加覆盖地块上三角形的纹理样本数量)。我们想在这里做同样的事情;然而,我们不希望被限制为单一的草地纹理。我们想要同时创建描绘沙子,草地,泥土,岩石和雪的地形。您可能会建议创建一个包含沙子,草地,泥土等的大质地,并将其在地形上展开。但是这会导致我们回到分辨率问题 - 地形几何非常大,我们需要一个不切实际的大纹理来获得足够的颜色样本来获得体面的分辨率。相反,我们采用了像素透明混合那样的多纹理方法。

这个想法是为每个地形图层都有一个单独的纹理(例如,一个用于草地,泥土,岩石等)。这些纹理将平铺在高分辨率的地形上。举例来说,假设我们有3个地形层(草地,泥土和岩石);然后将这些图层合并起来,如图19.11所示。

以前的过程应该让人想起透明度alpha混合。存储我们正在编写的图层的源Alpha的混合贴图指示源图层的不透明度,从而允许我们控制多少源图层覆盖现有的地形颜色。这使得我们可以用草地涂抹地形的某些部分,用灰尘涂抹一些部分,用雪涂抹其他部分,或者使用这三种混合物。


图19.11。 (顶部)首先放置第0层(草)作为当前的地形颜色。 (中)现在通过透明度alpha混合公式将当前地形颜色与第一层(污垢)混合; 混合贴图提供源alpha分量。 (底部)最后,通过透明度alpha混合公式将当前地形颜色与第二层(岩石)混合; 混合贴图提供源alpha分量。

图19.11说明了具有3个颜色映射的过程。在我们的代码中,我们使用5.为了组合5个彩色地图,我们需要4个灰度混合地图。 我们可以将这4个灰度混合贴图打包成单个RGBA贴图(红色通道存储第一个混合贴图,绿色通道存储第二个混合贴图,蓝色通道存储第三个混合贴图,Alpha通道存储第四个混合贴图)。 因此,需要总共六个纹理来实现这一点。以下地形像素着色器代码显示了我们的纹理混合是如何实现的:

// Sample layers in texture array.
float4 c0 = gLayerMapArray.Sample(samLinear, float3(pin.TiledTex, 0.0f));
float4 c1 = gLayerMapArray.Sample(samLinear, float3(pin.TiledTex, 1.0f));
float4 c2 = gLayerMapArray.Sample(samLinear, float3(pin.TiledTex, 2.0f));
float4 c3 = gLayerMapArray.Sample(samLinear, float3(pin.TiledTex, 3.0f));
float4 c4 = gLayerMapArray.Sample(samLinear, float3(pin.TiledTex, 4.0f));
// Sample the blend map.
float4 t = gBlendMap.Sample(samLinear, pin.Tex);
// Blend the layers on top of each other.
float4 texColor = c0;
texColor = lerp(texColor, c1, t.r);
texColor = lerp(texColor, c2, t.g);
texColor = lerp(texColor, c3, t.b);
texColor = lerp(texColor, c4, t.a);

混合贴图通常取决于地形的高度贴图。因此,我们不会将混合贴图嵌入到彩色贴图的Alpha通道中。如果我们这样做了,那么颜色贴图只适用于特定的高度贴图。通过分开保存颜色贴图和混合贴图,我们可以重复使用许多不同地形的颜色贴图。另一方面,将为每个地形构建不同的混合地图。

与图层纹理不同,混合贴图不会平铺,因为我们将它们拉伸到整个地形表面。这是必要的,因为我们使用混合贴图来标记要显示特定纹理的地形区域,所以混合贴图必须是全局的并覆盖整个地形。您可能想知道这是可以接受的还是过度放大。事实上,放大会发生,并且混合贴图在整个地形上被拉伸时会被纹理过滤扭曲,但混合贴图不是我们从细节中获取细节的地方(我们从平铺纹理中获取细节)。混合贴图仅仅标记特定纹理贡献的地形的一般区域(以及多少)。因此,如果混合地图变形并模糊,它不会对最终结果产生显着影响 - 例如,可能会有一些灰尘与草地混合在一起,而这实际上提供了层之间更平滑的过渡。

NOTE:您还可以通过在地形上平铺法线贴图来添加法线贴图支持。 像岩石这样的材料可以从法线贴图提供的更高细节中受益。 正如我们在§19.3中看到的,我们确实计算每个像素的切线帧。

19.5地形高度

一个常见的任务是获得给定x坐标和z坐标的地形表面的高度。这对于将物体放置在地形表面上,或者将摄像机轻轻放在地形表面上以模拟玩家在地形上行走时非常有用。

高度图给出了网格点处地形顶点的高度。但是,我们需要顶点之间的地形高度。因此,给定离散高度图采样,我们必须进行插值以形成表示地形的连续曲面y = h(x,z)。由于地形近似为三角形网格,因此使用线性插值可以使我们的高度函数与底层地形网格几何体一致。

要开始解决这个问题,我们的第一个目标是找出x坐标和z坐标位于哪个单元格中。(注意:我们假设坐标x和z与地形的局部空间有关)。这个;它告诉我们x和z坐标所在的单元格的行和列。

// Transform from terrain local space to "cell" space.
float c = (x + 0.5f*width()) / mInfo.CellSpacing;
float d = (z - 0.5f*depth()) / -mInfo.CellSpacing;
// Get the row and column we are in.
int row = (int)floorf(d);
int col = (int)floorf(c);

图19.12ab解释了这段代码的作用。 本质上,我们正在转换到一个新的坐标系,其中原点位于最左上角的地形顶点,正z轴下移,并且每个单位都缩放到该坐标系,直到它对应于一个单元格空间。现在,在此坐标系 系统,图19.12b清楚地表明了单元的行和列分别由floor(z)和floor(x)给出。 在图例中,该点位于第4行和第1列(使用基于零的索引)。 (回想floor(t)的值是小于或等于t的最大整数。)还要注意row和col给出单元格左上顶点的索引。

现在我们知道我们所在的单元格了,我们从高度图中获取四个单元顶点的高度:

// Grab the heights of the cell we are in.
// A*--*B
// | /|
// |/ |
// C*--*D
float A = mHeightmap[row*mInfo.HeightmapWidth + col];
float B = mHeightmap[row*mInfo.HeightmapWidth + col + 1];
float C = mHeightmap[(row+1)*mInfo.HeightmapWidth + col];
float D = mHeightmap[(row+1)*mInfo.HeightmapWidth + col + 1];


图19.12。 (a)相对于地形坐标系的xz平面中的点具有坐标(x,z)。 (b)我们选择一个新的坐标系统,其中原点是最左上方的网格顶点,正的z轴下移,并且每个单位都被缩放,以便它对应于一个单元格空间。 该点具有相对于该坐标系的坐标(c,d)。 这种转变涉及到翻译和缩放。 一旦在这个新的坐标系中,找到我们所在的单元格的行和列是微不足道的。 (c)我们引入第三个坐标系,它的起点位于点所在单元的左上顶点。该点具有相对于该坐标系的坐标(s,t)。 将坐标转换为该系统只涉及简单的平移以抵消坐标。 观察如果s + t≤1,我们处于“上”三角形,否则我们处于“下”三角形。

在这一点上,我们知道我们所在的单元格,并且我们知道该单元格的四个顶点的高度。 现在我们需要在特定的x坐标和z坐标处找到地形曲面的高度(y坐标)。 这有点棘手,因为细胞可以倾向于几个方向; 见图19.13。

图19.13 地形表面在特定x坐标和z坐标处的高度(y坐标)。

为了找到高度,我们需要知道我们所处细胞的哪个三角形(回想起我们的细胞呈现为两个三角形)。 为了找到高度,我们需要知道我们所在的细胞的三角形(回想起我们的细胞呈现为两个三角形)。 要找到我们所在的三角形,我们要改变我们的坐标,以便相对于单元坐标系来描述坐标(c,d)(见图19.12c)。 这种简单的坐标变换只涉及翻译,并按如下方式完成:

float s = c - (float)col;
float t = d - (float)row;

那么,如果s + t≤1,我们处于“上”三角形ΔABC中,否则我们处于“下”三角形ΔDCB中。

现在我们解释如何在“上”三角形中找到高度。 该过程与“较低”三角形相似,当然,两者的代码很快就会出现。 为了找到高度,如果我们处于“上”三角形,我们首先在三角形的边上构造两个向量u =(Δx,B-A,0)和v =(0,C - A, - Δz) 从终点Q开始,如图19.14a所示。 然后我们用s对u进行线性插值,然后沿着v对t进行线性插值。 图19.14b说明了这些插值。 Q + su + tv点的y坐标给出了基于给定的x坐标和z坐标的高度(回想一下向量加法的几何解释来看这个)。

请注意,因为我们只关心插值高度值,我们可以插入y分量并忽略其他分量。 因此,高度由总和 A+suy+tvy A + s u y + t v y 获得。

因此,Terrain :: GetHeight代码的结论是:


图19.14 (b)在上三角形边缘计算两个向量。(b)高度是矢量的y坐标。

// If upper triangle ABC.
if(s + t <= 1.0f)
{
float uy = B - A;
float vy = C - A;
return A + s*uy + t*vy;
}
else // lower triangle DCB.
{
float uy = C - D;
float vy = B - D;
return D + (1.0f-s)*uy + (1.0f-t)*vy;
}
We can now clamp the camera above the terrain to simulate that the player is walking on the terrain:
void TerrainApp::UpdateScene(float dt)
{
//
// Control the camera.
//
if(GetAsyncKeyState('W') & 0x8000)
mCam.Walk(10.0f*dt);
if(GetAsyncKeyState('S') & 0x8000)
mCam.Walk(-10.0f*dt);
if(GetAsyncKeyState('A') & 0x8000)
mCam.Strafe(-10.0f*dt);
if(GetAsyncKeyState('D') & 0x8000)
mCam.Strafe(10.0f*dt);
//
// Walk/fly mode
//
if(GetAsyncKeyState('2') & 0x8000)
mWalkCamMode = true;
if(GetAsyncKeyState('3') & 0x8000)
mWalkCamMode = false;
//
// Clamp camera to terrain surface in walk mode.
//
if(mWalkCamMode)
{
XMFLOAT3 camPos = mCam.GetPosition();
float y = mTerrain.GetHeight(camPos.x, camPos.z);
mCam.SetPosition(camPos.x, y + 2.0f, camPos.z);
}
mCam.UpdateViewMatrix();
}

19.6总结

1.我们可以使用三角形网格对地形进行建模,其中每个顶点的高度均以模拟山丘和山谷的方式指定。
2.高度图是一个矩阵,其中每个元素指定地形网格中特定顶点的高度。在每个网格顶点的高度图中存在一个条目,并且第ij个高度图条目提供第i个顶点的高度。高度图通常在视觉上被表示为灰度图,其中黑色表示最小高度,白色表示最大高度,以及
灰色代表两者之间的高度。
3.地形覆盖大面积,因此构建它们所需的三角形数量很大。如果我们使用均匀镶嵌网格,则由于透视投影的性质,地形的屏幕空间三角形密度将随距离增加。这实际上与我们想要的相反:我们希望三角形密度在照相机附近很大,细节将被注意到,并且三角形密度距离相机较远的地方更小,其中细节将被忽略。我们可以使用硬件细分来根据距相机的距离来实现连续的细节水平。地形曲面细分的总体策略可以总结如下:
a)放下四个补丁网格。
b)根据它们距相机的距离对贴片进行细分。
c)将高度图绑定为着色器资源。在域着色器中,从高度图执行位移映射以将生成的顶点偏移到其正确的高度。
4.我们可以在GPU上实施平截头体剔除,以在平截头体之外剔除四块补丁。这是在常量外壳着色器函数中完成的,通过将所有曲面细分因数设置为零来获得平截头以外的补丁。
5.我们通过在彼此之间混合层来构造地形(例如,草地,泥土,岩石,雪)。混合贴图用于控制每个图层对最终地形图像的贡献量。
6.高度图给我们在网格点处的地形顶点的高度,但是我们还需要顶点之间的地形高度。因此,给定离散高度图采样,我们必须进行插值以形成表示地形的连续曲面y = h(x,z)。由于地形近似为三角形网格,因此使用线性插值可以使我们的高度函数与底层地形网格几何体一致。具有地形的高度功能对于将物体放置在地形表面上或将相机稍微放置在地形表面上以模拟玩家在地形上行走时很有用。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值