该系统的开发动机
作为MC玩家与戴森球玩家,我特别喜欢戴森球这种用小小尺度的星球实现的宏大的效果。 但是玩过MC装了遥远地平线mod+Bliss光影之后,感觉要是有一个游戏,能既有戴森球的自由度,又能有装了遥远地平线mod的MC那样壮观的地形,还能有较为真实的轨道物理系统,那该有多好。
所以如果可能的话,我希望结合MC的大规模可交互地形、戴森球在三维空间上的自由度,接近真实的轨道物理。但是目前的时间有限,因此我想先聚焦于相对容易实现的目标:实现无明显网格畸变的正二十面体球 + 三角形四叉树星球LOD地形,预留地形实时编辑的拓展接口。目前星球的着色还有待完善,轨道物理这方面等以后有空了再补充相关物理知识去写,现在做最关键的也是自己力所能及的部分。
为避免混淆,以下的"顶点"指代正方体的八个"顶点"的"顶点"时,使用"几何顶点",当指代mesh的"顶点"时,使用"网格顶点"。同理20面体的20个"面"使用"几何面",mesh的"面"使用"网格面"
本项目使用unity 2022.2.55f1c1
效果展示:

近处lod0层级的patch使用普通gameObject而非GPU instancing直接画,确保玩家可互动,远处的所有patch使用GPU instancing。


所有Lod0层级都会使用gameObject而非GPU instancing
裁剪结果

裁剪动态:

一、球体网格
1、选用球体网格类型
为了实现星球地形的四叉树的细分,首先要确定一个合适的球体网格,主要有两个方面的评价标准:四叉树逻辑实现的难度 和网格形状畸变的程度 。 综合各方面因素考虑,我最终决定选择的是20面体球。一方面 是因为之前也有很多人实现过cubesphere的四叉树算法了。另一方面是,像《戴森球计划》这种较小的星球,不需要四叉树细分,直接使用unity自带的LODgroup根据距离切换整个网格就可以了。而想无人深空这种超大规模的星球,并不支持地形编辑,自然无所谓使用什么网格,直接使用cubeSphere就行,而我想要做的游戏希望以后能够支持全球地形高度编辑,就得考虑cubeSphere、八面体球的畸变问题。
(1)cubeSphere,把一个正方体的表面的每个网格顶点映射到球面上,作为四叉树LOD地形的网格的优点是细分的逻辑可以复用平面地形的四叉树细分逻辑,然而缺点是作为一个星球的网格,在正方体的几何顶点附近会发生很大的畸变,如图。
(2)八面体球 畸变情况相比cubeSphere没好到哪去,八面体的正三角形的映射到球面上,导致每个角都变成90度了,而四叉树细分的逻辑又不能和平面地形使用相同的逻辑,属于是优点一个没有,缺点全部吃满。 
(3)Icosphere(20面体球) Icosphere 是一种由等边三角形组成的球体网格结构,常用于计算机图形学和 3D 建模中。与传统的 UV 球体(UV Sphere)相比,Icosphere 的顶点分布更加均匀,因此天然更适合进行四叉树细分,而畸变相对较小。而正二十面体在所有柏拉图几何体中已经是面数最大的了。如果采用更多面数的几何体,那就要有一些几何面要在四叉树细分的逻辑中搞特殊处理了。
从上图可以看到,等边三角形最多只是在20面体的几何顶点处畸变到一个角变成了72°,还在可以接受的范围内。
| 网格类型 | 四叉树实现 | 网格畸变程度 |
|---|---|---|
| CubeSphere | 复用平面逻辑 | 几何顶点处严重拉伸 |
| 八面体球 | 需适配三角逻辑 | 几何顶点处畸变(角 90°) |
| 正二十面体球 | 需适配三角逻辑 | 几何顶点处轻微畸变(角 72°) |
二、二十面球体网格生成
1、三角形索引
接下来的问题就是,为了存储以及让GPU高效访问顶点信息,到底应该使用什么坐标能唯一标识20面体的一个几何面 的三角形网格的每个网格顶点 (暂不考虑20面体的每个几何面的相邻顶点的重叠),一开始这个问题让我考虑了很久。 最简单的想法是,每个几何面都是等边三角形,那自然可以把一张2d贴图的左边一半一一对应到右边,也就是(0,0)就对应下图等边三角形的左下角,(4,0)代表右下角,但使用这种索引方式,存储时必然会浪费一半的空间。而且在语义上来说,三角形的三个顶点就不平等了,表达"向右上方前进一步"、"向左下方前进一步"时,必然有不少歧义。

考虑每个几何面都是等边三角形,再考虑到等边三角形上使用重心坐标有不少良好的性质,比如旋转对称性,决定使用重心坐标。按照顺时针从右下角开始给每个几何顶点分别命名为U、V、W 一般数学上的重心坐标 都是u+v+w=1。但为了计算精确,可以直接两边乘以三角形的细分段数N,从而:u+v+w=N,其中u、v、w都变成了整数,我暂且称这种表示方法为整数重心坐标,我不知道是不是有更好的说法,于是如下图所示,每个点都可以使用整数重心坐标表示出来。 在u+v+w=N这个约束条件下,实际上每个(u,v,w)=(0,0,8),在程序中存储的时候实际上也可以简写成(u,w)=(0,8),大大方便了计算。

肯定有读者疑惑,那你这和上面的贴图二维坐标有什么区别呢?毕竟简写以后,它们看起来表示方法都一样。比如左下角的表示方法一个是(x,y)=(0,0),一个是(u,w)=(0,0)。虽然它们数值上惊人的巧合,但是在写程序时,这中间的语义差异能方便很多。
比如:从(u,v,w)=(0,8,0)点向右上角前进三步,由于到U点的步数不变,到V点远了三步,到W点的步数近了三步,于是有:u_new=0,v_new=8-3=5,w_new=0+3=3,目标点为(0,5,3)

2、20面体的几何顶点索引与几何面的索引
在生成20面体球的顶点坐标之前,还有一个需要解决的问题就是,20面体的12个顶点的几何顶点的索引该如何分配。
(1)几何顶点索引,一个简单思路是如下图所示,构建20面体可以依赖3个辅助矩形,蓝、绿、红矩形分别在x=0,y=0,z=0平面上,每个辅助矩形的顶点顺序为叉乘计算时手掌✋️的旋转方向。 
我们还需要20面体的12个几何顶点的每个点的坐标,这样后续才能使用重心坐标计算中间的网格顶点的位置。根据20面体的数学性质,其辅助矩形的长边:短边=黄金比例1.618,于是容易得到每个顶点的坐标如以下代码所示:
c#
public static class TerrainGenUtil
{
private readonly static double goldenRatio = 1.618033988749895;
public readonly static float goldenRatioFloat = (float)goldenRatio;
public readonly static Vector3[] IcosahedronVerts = new Vector3[] {
//x=0平面上的辅助长方形的4个顶点
new Vector3(0, goldenRatioFloat, 1), //v0
new Vector3(0, -goldenRatioFloat, 1),
new Vector3(0, -goldenRatioFloat, -1),
new Vector3(0, goldenRatioFloat, -1),
//y=0平面上的辅助长方形的4个顶点
new Vector3(1, 0, goldenRatioFloat), //v4
new Vector3(1, 0, -goldenRatioFloat),
new Vector3(-1, 0, -goldenRatioFloat),
new Vector3(-1, 0, goldenRatioFloat),
//z=0平面上的辅助长方形的4个顶点
new Vector3(goldenRatioFloat, 1, 0), //v8
new Vector3(-goldenRatioFloat, 1, 0),
new Vector3(-goldenRatioFloat, -1, 0),
new Vector3(goldenRatioFloat, -1, 0), //v11
};
//...
}
(2)20面体的几何面的索引 20个面可以分成两类,一类12个三角形,都是由一个辅助矩形的一个顶点和另一个辅助矩形的短边构成的三角形,另一类8个三角形,都是由三个辅助矩形的三个点构成的三角形。
多说无益,直接上代码表示;
c#
public static class TerrainGenUtil
{
public readonly static int[] IcosahedronTriangles = new int[] {
//以下三角形统一按照从20面体外部看是顺时针的顺序排列,尽量参考下图
// W
// / \
// / \
// / \
// /V______U\
//以1个辅助长方形的短边和另一个辅助长方形的顶点构成的三角形(12个)
//x=0矩形的四个
//矩形短边在+y方向, 三角顶点+x方向
3, 0, 8,
//矩形短边在+y方向, 三角顶点-x方向
0, 3, 9,
//矩形短边在-y方向, 三角顶点+x方向
1, 2, 11,
//矩形短边在-y方向, 三角顶点-x方向
2, 1, 10,
//y=0矩形的四个
//矩形短边在+z方向,三角顶点+y方向
7, 4, 0,
//矩形短边在+z方向,三角顶点-y方向
4, 7, 1,
//矩形短边在-z方向,三角顶点+y方向
5, 6 ,3,
//矩形短边在-z方向,三角顶点-y方向
6, 5, 2,
//z=0矩形的四个
//矩形短边在+x方向,三角顶点+z方向
4, 11, 8,
//矩形短边在+x方向,三角顶点-z方向
11, 5, 8,
//矩形短边在-x方向,三角顶点+z方向
10, 7, 9,
//矩形短边在-x方向,三角顶点-z方向
6, 10, 9,
//以三个辅助长方形的顶点构成的三角形(8个分别在8个象限中)
//+x+y+z
4, 8, 0,
//-x+y+z
9, 7, 0,
//-x+y-z
6, 9, 3,
//+x+y-z
8, 5, 3,
//+x-y+z
1, 11, 4,
//-x-y+z
10, 1, 7,
//-x-y-z
2, 10, 6,
//+x-y-z
11, 2, 5
};
}
3、根据重心坐标生成20面体球的网格顶点位置
三角形网格内部的每个网格顶点的笛卡尔坐标位置可以由如下公式计算得到
<math xmlns="http://www.w3.org/1998/Math/MathML"> P = ( u ∗ U 0 + u ∗ V 0 + u ∗ W 0 ) / N P=(u*U_0+u*V_0+u*W_0)/N </math>P=(u∗U0+u∗V0+u∗W0)/N
规定三角形的重心坐标的顶点遍历顺序为先u从0递增,后w从0递增,之后所有类似操作都使用这个顺序
伪代码
for w=0;w<N;w++ :
for u=0;u<N;u++:
v=N-u-w;
输出(u,v,w)
根据(u,v,w)计算三角形内部网格顶点笛卡尔坐标
有了上面的基础,12个顶点的顺序与坐标位置、20个面的构成顶点信息,我们终于可以写一个测试脚本来验证20面体/20面体球的生成正确与否了。
C#
public static class TerrainGenUtil
{
//...
/// <summary>
/// 通过整数重心坐标获取归一化20面体(中心到12个顶点的距离为1)的顶点位置
/// </summary>
/// <param name="u"></param>
/// <param name="v"></param>
/// <param name="w"></param>
/// <param name="n">这个20面体的三角形的边被细分为n段</param>
/// <returns></returns>
public static Vector3 GetNormalizedIcosahedronVertByIntCentroidCoord(int triangleIdx, int u, int v, int w, int n)
{
Debug.Assert(triangleIdx >= 0 && triangleIdx < 20, "triangleIdx 必须在 [0, 20) 范围内");
Debug.Assert(u + v + w == n, "u + v + w 必须等于 n");
int vertexUIdx = IcosahedronTriangles[triangleIdx * 3];
int vertexVIdx = IcosahedronTriangles[triangleIdx * 3 + 1];
int vertexWIdx = IcosahedronTriangles[triangleIdx * 3 + 2];
return (NormalizedIcosahedronVerts[vertexUIdx] * u + NormalizedIcosahedronVerts[vertexVIdx] * v + NormalizedIcosahedronVerts[vertexWIdx] * w) / n;
}
public static Vector3 GetNormalizedIcosphereVertByIntCentroidCoord(int triangleIdx, int u, int v, int w, int n)
{
return GetNormalizedIcosahedronVertByIntCentroidCoord(triangleIdx, u, v, w, n).normalized;
}
//...
C#
//测试脚本内
void OnDrawGizmos()
{
if (!Application.isPlaying)
{
return;
}
if (!drawGizmos)
{
return;
}
//----------------绘制20面体的每一个面的采样点,每个面采用不同的颜色--------------------
int n = 20;//20面体的每个三角形的一条边被分割成几份
//获取主相机的方向,只绘制朝向摄像机的面
// var camDir = Camera.main.transform.forward;
var camPos = UnityEditor.SceneView.lastActiveSceneView.camera.transform.position;
// var camDir = UnityEditor.SceneView.lastActiveSceneView.camera.transform.forward;
Vector3 camDir;
for (int i = 0; i < 20; i++)
{
if (!faceBool[i])
{
continue;
}
Gizmos.color = Color.HSVToRGB(i / 20f, 1, 1);
for (int w = 0; w <= n; w++)
{
for (int u = 0; u <= n - w; u++)
{
int v = n - w - u;
Vector3 pos;
if (!sphereOrIcosahedron)
{
// 20面体
pos = transform.TransformPoint(radius * TerrainGenUtil.GetNormalizedIcosahedronVertByIntCentroidCoord(i, u, v, w, n));
}
else
{
// 20面体球
pos = transform.TransformPoint(radius * TerrainGenUtil.GetNormalizedIcosphereVertByIntCentroidCoord(i, u, v, w, n));
}
camDir = (pos - camPos).normalized;
if (Vector3.Dot(camDir, pos) < 0)
{//不绘制背后的点
Gizmos.DrawSphere(
pos,
0.1f
);
}
}
}
}
}
效果如下,我们终于可以得到20面体的最初级的可视化了 

三、使用柏林噪声生成地形高度
有了一个规整的球体,我们还需要使用柏林噪声来生成星球表面的高度。柏林噪声的代码参考了SebLague的Solar System的开源代码。我根据自己的需要引入了高度序列化的功能,这里就不多赘述了。

接下来,我们的目标就是考虑,如何使用三角形的基础网格,拼接成完整的星球地形,将在(二)中继续
未完待续
完整代码
IcoSphereTerrainLODSystem: 实现了基于20面体球面网格的三角形四叉树细分星球LOD地形系统。
参考
GPU驱动的四叉树地形以及参考了这个文章的代码
地形的噪声生成、海洋和大气层渲染参考了这个仓库的代码