在unity中,我们可以使用unity自带的地形系统创建一个超大的地形场景,并且可以利用地形图层,创建出富有真实感的地表材质。但是当我们需要更改地形的渲染方式的时候,比如需要风格化渲染时,使用unity自带的地形系统就会很麻烦。因此,我尝试在unity中使用mesh的方式实现了一个简易的地形系统,这样地形的渲染就和场景中其他网格物体的渲染没有什么区别了,可以很方便地实现各种效果。
以下我将分步简要描述实现思路与过程,目录如下:
1、 将world machine中创建的地形导入到unity的mesh中;
2、 处理地形数据,按照四叉树层级分块;
3、 根据摄像机位置动态地组合分块,达到动态地形LOD的效果;
以下是实现的具体过程,个人编码水平比较低,希望大家多多提出意见。
一、导入地形数据
在unity自带的地形系统中,我们可以通过一张高度图的方式将地形数据从地形生成软件(World Machine,Gaea等)导入到unity引擎中,这张高度图通常是原始图片数据格式(.raw)。.raw格式的图片保存了最原始、无损的信息,而我们常用的png、jpg图片采用了一些压缩算法来减小图片的大小,但是会损失一些数据,因此在程序化地形工作流中,我们常常使用.raw来保存地形信息。
但是.raw格式的图片并不方便,windows本身是无法查看这类图片的,而C#也需要配置一些环境,并且也比较复杂。因此我选择不使用.raw图片来存储地形数据,转而直接使用模型文件.obj格式。
在World Machine中,我们可以直接导出.obj文件格式,方式如下:
其中Triangulation项表示导出模型的精度,我们选择Full Resolution Mesh,创建完整的模型。第三项坐标系统,我们选择X=east Z=north,以匹配unity的坐标系统。其他导出选项可以根据需要来选择。
然后我们就获得了地形网格模型,是常见的obj文件,本质上是一个,分别用建模软件和VS code查看如下
可以看到三角形网格和以字符文本形式保存的模型信息。在.obj文件中,v关键字表示顶点,后面三个值分别表示该顶点的x、y、z位置坐标。
现在我们获得了地形的完整的模型文件,在使用它之前需要先将它按照四叉树分割成小块,具体如下图所示:
对于本例而言,地形大小为4096*4096,因此采用六级LOD,最终LOD0有1024块,所有LOD分块一共1365块,我们先从obj文件中提取顶点数据,再根据每一块的LOD级别与覆盖范围分别建立网格。
因此,我们先用C++写一个小程序来将顶点信息提取出来,存入到一个文本文件中,方便后续建立网格时使用。
观察顶点数据,发现顶点数据按照行-列的方式排列,这方便了后续的操作。因此,为了提高文件读取的效率,可以将x轴与z轴的数据抛弃,通过在第一行添加一个常数来表示x、z平面上顶点的间隔距离,并且添加第二个常数来表示地形高度的乘数,增强了我们对网格的控制。
在完善之后,这一部分的C++代码如下:
#include <iostream>
#include <fstream>
#include <sstream>
#include <cstring>
#include <string>
using namespace std;
struct vec3{
int x;
float y;
int z;
};
vec3 vertices[16785409];
int main() {
fstream newtxt;
newtxt.open(R"(D:\WorldMachine\World Machine 4016\World Machine 4016 Professional\World Machine Documents\Desert\TerrainData.txt)", ios::out);
fstream myObj;
myObj.open(R"(D:\WorldMachine\World Machine 4016\World Machine 4016 Professional\World Machine Documents\Desert\height.obj)", ios::in);
if(!myObj.is_open())
std::cerr<<"cannot open the file";
// 输出先导数据
float xz_bias = 5;
float y_bias = 1;
newtxt << xz_bias << " " << y_bias << endl;
// 全局
char buffer[1024] = {0};// 读取文件行缓存
int mesh_num = 0; // 网格(物体)计数编号
int count_num = 0; // 行数计数编号
int cnt_vert = 0;
while (myObj.getline(buffer,sizeof(buffer))){
count_num ++;
stringstream ss(buffer); // 一行的字符串流操作
if (ss.str().empty()) // 空行
continue;
string flag; // 行首关键字
ss >> flag;
if (flag == "#"){
cout << "第 " << count_num << " 行: " << "这是一行注释, " << "注释的内容是:" << ss.str() << endl;
}
else if (flag == "mtllib"){
continue;
}
else if (flag == "v"){ // 顶点
float a, b, c;
ss >> a >> b >> c;
vertices[cnt_vert].x = (cnt_vert % 4097);
vertices[cnt_vert].y = b;
vertices[cnt_vert].z = (cnt_vert / 4097);
newtxt << b << endl;
cnt_vert ++;
}
else
cout << ss.str() << endl;
memset(buffer, 0, sizeof(buffer));
}
myObj.close();
cout << "读取文件完成力" << endl;
return 0;
}
在拿到处理后的顶点数据后,我们打开unity,新建一个脚本。
首先创建如下的成员变量:
// 需要读取的文件路径
public string datapath;
private float xzbias = 0, ybias = 0; // 格点间隔距离与高度乘数
public int Size = 4097; // 地形的规模(正方形边长,要求是2的幂+1)
private int LEN; // 常数,表示完整顶点组的长度,等于Size^2
private int len; // 常数,表示单片顶点组的长度,等于129*129
private Vector3[] CompVert; // 完整的地形顶点数据,在开始时从文本文件读取数据到该顶点组,在构建mesh时按需从该顶点组读取数据
private Vector2[] CompUV1; // CompVert对应的第一套UV(地表贴图纹理uv)
private Vector2[] CompUV2; // CompVert对应的第二套UV(遮罩图贴图splat map uv)
private Mesh LodMesh; // 结果的网格,在每次四叉树遍历过程中即时更新该mesh并保存至本地,因此不需要开数组
private Vector3[] vert; // 结果的顶点数据
private Vector2[] uv1;
private Vector2[] uv2;
private int[] tri; // 结果的三角形数据(不需要在每次遍历中更新)
private struct quadTreeNodeInfo // 地形瓦片 四叉树节点 信息
{
public Vector2 begin_Pos; // 开始顶点在完整顶点组中的第几行、第几列
public int interval; // 该地形瓦片的连续顶点在完整顶点组中对应顶点相差数量(LOD0对应该值为1,LOD2对应该值为4)
public int LodLeval; // 该地形瓦片的lod等级
public Vector2 Center; // 该地形瓦片的xz平面几何中心位置
}
private quadTreeNodeInfo[] qTree; // 四叉树
我们为所有顶点创建了两套uv,因为地表纹理贴图的uv和splat map的uv是不同的。
然后将文本文件中的地形高度信息读取到CompVert数组中。我们用C#中的StreamReader类来按行读取,再利用string.Split()方法将读取到的行根据空格分割,然后用float.TryParse()来将字符串转化为浮点数。同时,按照行-列的顺序填充x、z轴的顶点位置和两套uv的信息。这一部分的代码如下:
private void ReadRawData() // 参数1:文本文件路径;参数2:完整顶点组数组引用
{
// 创建一个StreamReader类
StreamReader RawTerrainData = new StreamReader(@datapath);
string line;
line = RawTerrainData.ReadLine();
string[] bias = line.Split(' ');
float.TryParse(bias[0], out xzbias);
float.TryParse(bias[1], out ybias);
for (int i = 0; i < LEN; i++) // 读取所有顶点 并且给原始uv数据赋值
{
line = RawTerrainData.ReadLine();
string[] stringdata = line.Split(' ');
float y = 0;
float.TryParse(stringdata[0], out y);
// xz等于计算值*偏移值,y等于读取值*偏移值
CompVert[i].x = (i % 4097) * xzbias;
CompVert[i].z = ((int)(i / 4097)) * xzbias;
CompVert[i].y = y * ybias;
// uv1在0-1之间周期性变化
CompUV1[i].x = ((i % 4097) % 2) * 0.5f;
CompUV1[i].y = (((int)(i / 4097)) % 2) * 0.5f;
// uv2在0-1之间随着0-4097之间变化
CompUV2[i].x = (float)(i % 4097) / 4097.0f;
CompUV2[i].y = ((float)i / 4097.0f) / 4097.0f;
}
}
在完整填充了的顶点数组后,我们还要准备好四叉树。四叉树用线性数组存储,索引0表示根节点,对于索引为i(i≠0)的元素,其父节点为(i-1)/4取整,其子节点(如果存在)为i*4+1到i*4+4。
四叉树的每个子节点存放了以下信息:
1、 开始位置:该节点代表的地形分块的第一个顶点在完整顶点组中的索引是第几行、第几列;
2、 间隔距离:该节点代表的地形分块的相邻的顶点在完整顶点组中的索引的间隔索引数;
3、 LOD等级:该节点代表的地形分块的LOD等级,0表示最细节,最大值表示最简单;
4、 中心位置:该节点代表的地形分块的顶点的在世界空间中的几何中心坐标;
其中,开始位置与间隔距离信息在创建网格时使用,中心位置信息在动态显示地形时使用。
然后我们将四叉树的信息保存到本地,因为这部分的数据不会在游戏运行时被更改。我们按照行数对应四叉树节点索引的方式,数据间以空格分隔的方式保存到本地,这一部分的代码如下所示:
private void BuildQuadTree()
{
// 循环形式
// 处理根节点
qTree[0].Center.x = CompVert[Size * (Size - 1) / 2 + (Size - 1) / 2].x;
qTree[0].Center.y = CompVert[Size * (Size - 1) / 2 + (Size - 1) / 2].z;
qTree[0].begin_Pos = new Vector2(0, 0);
qTree[0].interval = (Size - 1) / 128;
qTree[0].LodLeval = 5; // 完整顶点是0级,最简是6级
for(int index = 1; index < qTree.Length; index++)
{
int num = (index - 1) % 4;
int parent = (int)((index - 1) / 4);
qTree[index].LodLeval = qTree[parent].LodLeval - 1;
qTree[index].interval = qTree[parent].interval / 2;
qTree[index].Center = qTree[parent].Center;
if (num == 0)
{
qTree[index].begin_Pos = qTree[parent].begin_Pos;
qTree[index].Center.x -= 64 * qTree[index].interval * xzbias;
qTree[index].Center.y -= 64 * qTree[index].interval * xzbias;
}
else if (num == 1)
{
qTree[index].begin_Pos = new Vector2(qTree[parent].begin_Pos.x + 64 * qTree[parent].interval, qTree[parent].begin_Pos.y);
qTree[index].Center.x += 64 * qTree[index].interval * xzbias;
qTree[index].Center.y -= 64 * qTree[index].interval * xzbias;
}
else if (num == 2)
{
qTree[index].begin_Pos = new Vector2(qTree[parent].begin_Pos.x, qTree[parent].begin_Pos.y + 64 * qTree[parent].interval);
qTree[index].Center.x -= 64 * qTree[index].interval * xzbias;
qTree[index].Center.y += 64 * qTree[index].interval * xzbias;
}
else
{
qTree[index].begin_Pos = new Vector2(qTree[parent].begin_Pos.x + 64 * qTree[parent].interval, qTree[parent].begin_Pos.y + 64 * qTree[parent].interval);
qTree[index].Center.x += 64 * qTree[index].interval * xzbias;
qTree[index].Center.y += 64 * qTree[index].interval * xzbias;
}
}
}
private void SaveTree()
{
using (StreamWriter sw = new StreamWriter(@"E:\Unity\MyProjects\Desert_01\Assets\TerrainTree\MyQTree.txt"))
{
for (int i = 0; i < qTree.Length; i++)
{
string line;
line = qTree[i].begin_Pos.x.ToString();
line += " ";
line += qTree[i].begin_Pos.y.ToString();
line += " ";
line += qTree[i].interval.ToString();
line += " ";
line += qTree[i].LodLeval.ToString();
line += " ";
line += qTree[i].Center.x.ToString();
line += " ";
line += qTree[i].Center.y.ToString();
sw.WriteLine(line);
}
}
}
四叉树创建完成后,我们在创建并保存网格之前,先创建三角形索引数组。在一般情况下,我们在创建网格时会先创建顶点数组再创建三角形索引数组,但是在这里我们每个地形分块的顶点排列和三角形索引情况完全一致,唯一的区别是顶点位置的区别,因此我们先创建出三角形数组再在遍历四叉树时每次创建网格时在改变顶点数据就可以了。
在这里,我暂时没有考虑不同LOD层级的地形块的接缝问题,因此三角形按照两个三角形-一个四边形为一个单位,按照行-列的顺序来创建。这一部分的代码如下:
private void CreateTriangle()
{
int tri_n = 0;
for (int u = 0; u < 128; u++)
{
for (int v = 0; v < 128; v++)
{
tri[tri_n * 6] = u * 129 + v;
tri[tri_n * 6 + 1] = tri[tri_n * 6 + 4] = (u + 1) * 129 + v;
tri[tri_n * 6 + 2] = tri[tri_n * 6 + 3] = u * 129 + v + 1;
tri[tri_n * 6 + 5] = (u + 1) * 129 + v + 1;
tri_n++;
}
}
}
接下来就是重头戏——创建网格了。我们根据四叉树的每一个节点创建一个网格。第一步我们首先将网格的索引数类型改为unsigned int32,unity默认的索引是16位整型,这样会导致顶点数量非常容易溢出,因此更改为32位。然后我们根据四叉树信息获取地形块的开始位置,按照间隔距离递增的方式进行行-列遍历,查找完整顶点数组数据,创建出该块网格顶点数组。同时创建uv数据。在顶点、uv、三角形都完成后,重新计算一次顶点法线。然后将网格保存到Assets目录。这一部分的代码如下:
private void CreateMesh()
{
for(int i = 0; i < qTree.Length; i++)
{
Mesh mesh = new Mesh();
mesh.indexFormat = LodMesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
int begin;
if (i == 0)
begin = 0;
else
begin = (int)(qTree[i].begin_Pos.x * Size + qTree[i].begin_Pos.y);
for (int u = 0; u < 129; u++)
{
for (int v = 0; v < 129; v++)
{
vert[u * 129 + v] = CompVert[begin + u * Size * qTree[i].interval + v * qTree[i].interval];
uv1[u * 129 + v] = CompUV1[begin + u * Size * qTree[i].interval + v * qTree[i].interval];
//uv2[u * 129 + v] = CompUV2[begin + u * Size * qTree[i].interval + v * qTree[i].interval];
}
}
mesh.vertices = vert;
mesh.uv = uv1;
mesh.uv2 = uv2;
mesh.triangles = tri;
mesh.RecalculateNormals();
string MeshName = "Assets/LodMeshes/LodMesh_" + i.ToString() + ".asset";
AssetDatabase.CreateAsset(mesh, MeshName);
}
}
至此,我们已经完成了四叉树地形的大部分工作,在下一节,我将简要讲解如何在游戏过程中动态地组合创建好的地形分块网格,从而实现地形实时LOD的效果。