原文地址:https://catlikecoding.com/unity/tutorials/hex-map/part-25/
机翻+个人润色
- 显示原始地图数据
- 演化细胞气候
- 创建部分水循环模拟
这是六边形地图系列教程的第25章。上一个章节是地区和侵蚀。这次我们将给大陆增加水分。
? ? ? ? ? ? ? ? ? ? ? ? ? ? ?利用水循环确定生物群落。
1.云
到目前为止,我们的地图生成算法只调整单元格的高度。单元格之间最大的区别是它们是否被淹没。虽然我们也设置了不同的地形类型,但这只是一个简单的对不同高度的可视化。分配地形类型的更好方法是考虑当地气候。
地球的气候是一个非常复杂的系统。幸运的是,我们不需要创建一个真实的气候模拟。我们需要的只是看起来足够自然的东西。气候最重要的方面是水循环,因为动植物需要液态水才能生存。温度也很重要,但这次我们将重点关注水,始终保持全球温度不变,同时改变湿度。
水循环描述了水是如何在环境中流动的。简而言之,水体蒸发,形成云,云产生雨,雨又流回水体。它的作用远不止于此,但模拟这些步骤可能已经足够在我们的地图上产生一个看似自然的水分布。
1.1可视化数据
在我们进行实际模拟之前,如果我们能够直接看到相关的数据,将是非常有用的。为此,我们将调整我们的地形着色器。给它一个切换属性,这样我们可以将它切换到数据可视化模式,显示原始地图数据,而不是通常的地形纹理。这是通过一个带有toggle属性的浮动属性来完成的,该属性指定了一个关键字。这将使它作为一个复选框显示在material inspector中,它控制是否设置了关键字。属性的实际名称无关紧要,重要的是关键字,我们将使用SHOW_MAP_DATA作为关键词。
Properties {
_Color ("Color", Color) = (1,1,1,1)
_MainTex ("Terrain Texture Array", 2DArray) = "white" {}
_GridTex ("Grid Texture", 2D) = "white" {}
_Glossiness ("Smoothness", Range(0,1)) = 0.5
_Specular ("Specular", Color) = (0.2, 0.2, 0.2)
_BackgroundColor ("Background Color", Color) = (0,0,0)
[Toggle(SHOW_MAP_DATA)] _ShowMapData ("Show Map Data", Float) = 0
}
添加一个着色器特性来支持关键字。
#pragma multi_compile _ GRID_ON
#pragma multi_compile _ HEX_MAP_EDIT_MODE
#pragma shader_feature SHOW_MAP_DATA
我们将显示一个浮动值,就像其他地形数据一样。要实现这一点,请在定义关键字时向Input结构体添加mapData字段。
struct Input {
float4 color : COLOR;
float3 worldPos;
float3 terrain;
float4 visibility;
#if defined(SHOW_MAP_DATA)
float mapData;
#endif
};
在顶点程序中,我们将使用单元数据的Z通道来填充mapData,像往常一样在单元之间进行插值。
void vert (inout appdata_full v, out Input data) {
…
#if defined(SHOW_MAP_DATA)
data.mapData = cell0.z * v.color.x + cell1.z * v.color.y +
cell2.z * v.color.z;
#endif
}
当需要显示地图数据时,直接使用它作为片段着色器的albedo,而不是普通的颜色。将它与网格相乘,以便在可视化数据时仍然可以启用网格。
void surf (Input IN, inout SurfaceOutputStandardSpecular o) {
…
o.Albedo = c.rgb * grid * _Color * explored;
#if defined(SHOW_MAP_DATA)
o.Albedo = IN.mapData * grid;
#endif
…
}
为了真正地将一些数据放到着色器中,我们必须向hexcellent shaderdata中添加一个方法,把一些东西放到它的纹理数据的蓝色通道中。这个数据是一个独立的浮点值,固定在0-1范围内。
public void SetMapData (HexCell cell, float data) {
cellTextureData[cell.Index].b =
data < 0f ? (byte)0 : (data < 1f ? (byte)(data * 255f) : (byte)255);
enabled = true;
}
然而,这种方法干扰了我们的探索系统。数据组件的蓝色值o和255用于指示单元格的可见性是否正在转换。为了保持这个工作,我们必须使用字节值254作为最大值。请注意,单元移动将清除地图数据,但这没关系,因为我们只在调试地图生成时使用它。
cellTextureData[cell.Index].b =
data < 0f ? (byte)0 : (data < 1f ? (byte)(data * 254f) : (byte)254);
向HexCell添加一个同名的方法,它将请求传递它的着色器数据。
public void SetMapData (float data) {
ShaderData.SetMapData(this, data);
}
要测试这是否工作,请调整HexMapGenerator.SetTerrainType 用于设置每个单元格的地图数据。让我们设想一下将海拔,从整数转换为0-1范围内的浮点数。这是通过从单元格的高度中减去高度的最小值,然后除以高度的最大值减去最小值。确保它是一个浮动分区。
void SetTerrainType () {
for (int i = 0; i < cellCount; i++) {
…
cell.SetMapData(
(cell.Elevation - elevationMinimum) /
(float)(elevationMaximum - elevationMinimum)
);
}
}
通过切换地形材质资产的Show Map data复选框,您现在应该能够在普通地形和数据可视化之间进行切换。
?
地图种子1208905299,正常地形和高度可视化。
1.2创建气候
为了模拟气候,我们必须跟踪气候数据。由于我们的地图由离散的单元组成,每个单元都有自己的本地气候。创建一个包含所有相关数据的ClimateData结构体。虽然我们可以将这些数据添加到单元格本身,但我们只会在生成地图时使用它。我们将分别存储它。这意味着我们可以在HexMapGenerator中定义这个结构,就像MapRegion一样。我们将从只跟踪云开始,这可以通过一个单独的浮点数来实现。
?
struct ClimateData {
public float clouds;
}
?
添加一个列表来跟踪所有单元的气候数据。
List<ClimateData> climate = new List<ClimateData>();
现在我们需要一个方法来创建地图的气候。它应该从清理气候列表开始,然后为每个单元添加一个项目。最初的气候数据是零,这是我们通过默认的数据结构ClimateData得到的。
void CreateClimate () {
climate.Clear();
ClimateData initialData = new ClimateData();
for (int i = 0; i < cellCount; i++) {
climate.Add(initialData);
}
}
把气候的创建放在土地被侵蚀之后,在地形类型确定之前。在现实中,侵蚀主要是由空气和水的运动造成的,这是气候的一部分,但我们不打算模拟它。
public void GenerateMap (int x, int z) {
…
CreateRegions();
CreateLand();
ErodeLand();
CreateClimate();
SetTerrainType();
…
}
修改?SetTerrainType,
这样我们可以看到云的数据,而不是单元格的高度。一开始,它看起来像一张黑色的地图。
void SetTerrainType () {
for (int i = 0; i < cellCount; i++) {
…
cell.SetMapData(climate[i].clouds);
}
}
1.3变化的气候
我们气候模拟的第一步是蒸发。应该蒸发多少水?我们用滑块来控制它。0表示完全没有蒸发,1表示最大蒸发。我们将使用0.5作为默认值。
[Range(0f, 1f)]
public float evaporation = 0.5f;
蒸发的滑动条
让我们专门创建另一个方法来发展单个细胞的气候变化。将单元的索引作为参数,并使用它来检索相关单元及其气候数据。如果细胞在水下,那么我们要处理的是一个会蒸发的水体。我们将立即将水蒸气转化为云——忽略露点和冷凝——因此直接将蒸发添加到单元的云的值中。一旦我们完成了,将气候数据复制回列表。
void EvolveClimate (int cellIndex) {
HexCell cell = grid.GetCell(cellIndex);
ClimateData cellClimate = climate[cellIndex];
if (cell.IsUnderwater) {
cellClimate.clouds += evaporation;
}
climate[cellIndex] = cellClimate;
}
在CreateClimate中的每一个单元调用这个方法。
void CreateClimate () {
…
for (int i = 0; i < cellCount; i++) {
EvolveClimate(i);
}
}
只做一次是不够的。为了创建一个复杂的模拟,我们必须多次发展单元的气候。我们这样做做的越多,结果就越精细。我们取一个固定的量,用40个循环。
for (int cycle = 0; cycle < 40; cycle++) {
for (int i = 0; i < cellCount; i++) {
EvolveClimate(i);
}
}
因为现在我们只增加了被淹没的单元上方的云层,我们最终得到的是黑色的陆地和白色的水体。
蒸发了海水的效果
1.4云的传播
云不会永远停留在一个地方,尤其是当越来越多的水不断蒸发的时候。气压差导致空气移动,表现为风,风又使云移动。
如果没有一个主导的风向,平均来说,单元的云会均匀地分散到各个方向,最终到达单元的邻居。在下一个循环中将生成新的云,让我们将单元中当前的所有云分布到它的邻居中。所以每一个邻居得到细胞云的六分之一,之后局部云降为零。
if (cell.IsUnderwater) {
cellClimate.clouds += evaporation;
}
float cloudDispersal = cellClimate.clouds * (1f / 6f);
cellClimate.clouds = 0f;
climate[cellIndex] = cellClimate;
要实际地将云添加到邻居中,需要对它们进行循环,检索它们的气候数据,增加它们的云值,并将其复制回列表中。
float cloudDispersal = cellClimate.clouds * (1f / 6f);
for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
HexCell neighbor = cell.GetNeighbor(d);
if (!neighbor) {
continue;
}
ClimateData neighborClimate = climate[neighbor.Index];
neighborClimate.clouds += cloudDispersal;
climate[neighbor.Index] = neighborClimate;
}
cellClimate.clouds = 0f;
分散的云
这将生成一个几乎是白色的地图。这是因为每个周期所有的水下单元都会给全球气候增加更多的云层。在第一个周期之后,靠近水的陆地细胞现在也有一些云要散开。这个过程一直持续到地图的大部分被云覆盖。在带有默认设置种子为 1208905299的地图中,只有东北大地的内部还没有完全覆盖。
请注意,我们的水体可以产生无限数量的云。水位不是我们气候模拟的一部分。事实上,水体之所以能够存在,仅仅是因为水以与蒸发速度相同的速度流回水体。我们只是在模拟部分水循环。这很好,但是我们应该意识到这意味着模拟运行的时间越长,向气候中添加的水就越多。现在,唯一的水分流失发生在地图的边缘,分散的云消失在不存在的邻居那里。
你可以在地图的顶部看到水的流失,尤其是右上方的细胞。最后一个细胞根本没有云,因为它是最后一个进化的细胞。它还没有收到任何来自邻居的云。
难道不是所有的单元的气候都是同时发展的吗?
是的,这会产生最一致的模拟。现在,由于单元格的顺序,云在一个周期内分布在整个地图的北部和东部,但只向南部和西部移动了一步。然而,这种不对称在40个周期内被消除。它只在地图的边缘很明显。稍后我们将切换到并行发展。
1.5水分沉降
水不会永远存在。在某个时候,它会回落。这通常以雨的形式发生,但也可以是雪、冰雹或雨夹雪。一般来说,这被称为降水。云消失的程度和速度变化很大,但是我们只使用一个可配置的全局降水因子。值0表示没有降水,值1表示所有的云都立即消失。让我们使用0.25作为默认值。这意味着每个周期四分之一的云被移除。
[Range(0f, 1f)]
public float precipitationFactor = 0.25f;
降水量因子滑块
我们将模拟蒸发后和云消散前的降水量。这意味着从水体中蒸发的部分水会立即沉淀,因此分散的云的数量会减少。在陆地上,降水会使云消失。
if (cell.IsUnderwater) {
cellClimate.clouds += evaporation;
}
float precipitation = cellClimate.clouds * precipitationFactor;
cellClimate.clouds -= precipitation;
float cloudDispersal = cellClimate.clouds * (1f / 6f);
有消失的云
现在我们每个周期都要消除25%的云层,陆地又变成了黑色。云层只向内陆移动了几步,就变得不引人注意了。
unitypackage
2.水分
虽然降水可以消除云层,但它不应该把水从气候中带走。落到地面后,水还在那里,只是处于另一种状态。它可以以多种形式存在,我们将其抽象为水分。
2.1追踪水分
我们将通过跟踪两种水状态,云和湿度来增强我们的气候模型。为了支持这一点,在气候数据中增加moisture
?字段到ClimateData中。
struct ClimateData {
public float clouds, moisture;
}
在最普遍的形式下,蒸发是将水分转化为云的过程,至少在我们的简单气候模型中是这样。这意味着蒸发不应该是一个常数,而是另一个因素。所以重命名evaporation
?为evaporationFactor。
[Range(0f, 1f)]
public float evaporationFactor = 0.5f;
当单元在水下时,我们简单地将其湿度级别声明为1。这意味着蒸发等于蒸发因子。但是我们现在也可以从陆地单元中蒸发。在这种情况下,我们必须计算蒸发,从水分中减去水分,然后加到云层中。在那之后,降水被加入到湿度中。
if (cell.IsUnderwater) {
cellClimate.moisture = 1f;
cellClimate.clouds += evaporationFactor;
}
else {
float evaporation = cellClimate.moisture * evaporationFactor;
cellClimate.moisture -= evaporation;
cellClimate.clouds += evaporation;
}
float precipitation = cellClimate.clouds * precipitationFactor;
cellClimate.clouds -= precipitation;
cellClimate.moisture += precipitation;
由于云层现在靠陆地上的蒸发维持,它们能够向内陆移动。大部分土地现在是灰色的。
云带水分蒸发
让我们调整SetTerrainType,让它显示湿度而不是云,因为这是我们稍后用来确定地形类型的。
cell.SetMapData(climate[i].moisture);
显示湿度
在这一点上,水分看起来很像云——除了所有的水下细胞都是白色的——但这种情况很快就会改变。
2.2水分流失
蒸发并不是水分离开细胞的唯一途径。水循环表明,大部分添加到陆地上的水分以某种方式再次进入水体。这种现象最明显的方式是水在陆地上流动,被重力拉下来。在我们的模拟中,我们并不关心实际的河流,相反,我们将使用一个可配置的径流因子。这代表的部分水流失,流向较低的地区。默认情况下,我们抽掉25%
[Range(0f, 1f)]
public float runoffFactor = 0.25f;
水分流失的滚动条
? ? ? ? ? ? ? ? ? ? ? ?为什么我们不创建一条河流呢?
我们将在以后的教程中根据生成的气候添加它们。
径流就像云的扩散,有三个不同之处。首先,并不是所有的细胞水分都被去除。其次,它输送的是水分,而不是云。第三,它只向下延伸,所以只延伸到海拔较低的邻居。径流因子的描述是,如果所有邻居的湿度都较低,会流失多少水分,但通常情况下会更少。这意味着只有当我们找到一个较低的邻居时,我们才能减少单元的水分。
float cloudDispersal = cellClimate.clouds * (1f / 6f);
float runoff = cellClimate.moisture * runoffFactor * (1f / 6f);
for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
HexCell neighbor = cell.GetNeighbor(d);
if (!neighbor) {
continue;
}
ClimateData neighborClimate = climate[neighbor.Index];
neighborClimate.clouds += cloudDispersal;
int elevationDelta = neighbor.Elevation - cell.Elevation;
if (elevationDelta < 0) {
cellClimate.moisture -= runoff;
neighborClimate.moisture += runoff;
}
climate[neighbor.Index] = neighborClimate;
}
水流向了更低的地面
我们得到的结果是水分的分布更加多样化,因为较高的单元将水分流失到较低的单元。我们也可以看到,在沿海的单元中,水分会减少很多,因为它们会流失到水下的单元中。为了减轻这种影响,我们还应该使用水位来确定单元格是否更低,而不是使用视野高度。
int elevationDelta = neighbor.ViewElevation - cell.ViewElevation;
使用视野高度
2.3渗透
水不仅仅向下流动。它还会扩散,渗透到平坦的地形,并被水体附近的土地吸收。这可能是一个微妙的影响,但有助于平滑水分的分布,所以让我们把它添加到我们的气候模拟中。给它自己一个可配置因子,使用0.125作为默认值。
[Range(0f, 1f)]
public float seepageFactor = 0.125f;
渗透滚动条
渗透与径流相同,不同的是它适用于与地块本身具有相同水平高度的邻居。
float runoff = cellClimate.moisture * runoffFactor * (1f / 6f);
float seepage = cellClimate.moisture * seepageFactor * (1f / 6f);
for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
…
int elevationDelta = neighbor.ViewElevation - cell.ViewElevation;
if (elevationDelta < 0) {
cellClimate.moisture -= runoff;
neighborClimate.moisture += runoff;
}
else if (elevationDelta == 0) {
cellClimate.moisture -= seepage;
neighborClimate.moisture += seepage;
}
climate[neighbor.Index] = neighborClimate;
}
使用了渗透
unitypackage
3雨影
虽然我们已经有了一个不错的水循环模拟,它看起来不是很有趣。这是因为它不包含雨影,而雨影是气候差异最显著的表现之一。雨影描述的是与附近地区相比,降雨量严重不足的地区。这些地区的存在是因为高山阻挡了云到达它们。这需要高山和主要风向。
3.1风
首先让我们在模拟中添加一个主导风向。虽然主导风向在地球上变化很大,但我们将使用一个可配置的全球风向。让我们使用西北风作为默认值。除此之外,还可以配置此风的强度,从1到10,默认值为4。
public HexDirection windDirection = HexDirection.NW;
[Range(1f, 10f)]
public float windStrength = 4f;
风向和强度
主导风的强度是相对于均匀的云扩散而言的。当风力为1时,分散在各个方向上是相等的。当它是2时,分散在风向上的强度是在其他方向上的两倍,以此类推。我们可以通过改变云扩散计算的除数来实现这一点。不是6,应该是5加上风力。
float cloudDispersal = cellClimate.clouds * (1f / (5f + windStrength));
另外,风向决定了风吹的方向。所以我们需要用相反的方向作为主要的被扩散方向。
HexDirection mainDispersalDirection = windDirection.Opposite();
float cloudDispersal = cellClimate.clouds * (1f / (5f + windStrength));
现在我们可以检查一个邻域是否在主扩散方向上。如果是这样的话,我们应该把分散的云乘以风力。
ClimateData neighborClimate = climate[neighbor.Index];
if (d == mainDispersalDirection) {
neighborClimate.clouds += cloudDispersal * windStrength;
}
else {
neighborClimate.clouds += cloudDispersal;
}
西北风,风力4级
主导风增加了水分在陆地上分布的方向性。风力越大,这种效应就越极端。
3.2高地
雨影的第二个要素是山。我们对山没有严格的分类,自然界也没有。重要的是海拔。从本质上说,当空气流过一座山时,它被强迫向上并冷却,因此容纳更少的水,这迫使空气通过这座山之前进行降水。结果是山的另一边的空气干燥,因此有雨影之称。
关键的一点是,空气走得越高,它能容纳的水就越少。我们可以在我们的模拟中通过强制每个单元的最大云值来表示这一点。单元格的海拔高度越高,这个最大值应该越低。最直接的方法是将最大值设为1减去海拔的高度除以高度的最大值。实际上,我们要除以最大值- 1。这使得即使是在最高的单元上也能有少量的云流动。我们将在降水之后,在扩散之前执行这个最大值。
float precipitation = cellClimate.clouds * precipitationFactor;
cellClimate.clouds -= precipitation;
cellClimate.moisture += precipitation;
float cloudMaximum = 1f - cell.ViewElevation / (elevationMaximum + 1f);
HexDirection mainDispersalDirection = windDirection.Opposite();
如果我们得到的云比允许的多,只需将多余的云转换成水分。这就像真正的山脉一样,有效地迫使额外的降水。
float cloudMaximum = 1f - cell.ViewElevation / (elevationMaximum + 1f);
if (cellClimate.clouds > cloudMaximum) {
cellClimate.moisture += cellClimate.clouds - cloudMaximum;
cellClimate.clouds = cloudMaximum;
}
由高海拔引起的雨影
unitypackage
4完成模拟
现在我们有了一个模拟部分水循环的效果。让我们整理一下,然后使用它来确定单元格的地形类型。
4.1同步演化
正如前面提到的,我们的单元演化的顺序会影响模拟的结果。理想情况下,情况并非如此,我们可以有效地并行演化所有单元。我们可以通过将当前演化步骤的所有更改应用到第二个气候列表nextClimate来实现这一点。
List<ClimateData> climate = new List<ClimateData>();
List<ClimateData> nextClimate = new List<ClimateData>();
清除并初始化这个列表,就像另一个列表一样。然后在每个循环之后交换列表。这使得模拟使用的列表会在当前和下一个气候数据之间交替进行。
void CreateClimate () {
climate.Clear();
nextClimate.Clear();
ClimateData initialData = new ClimateData();
for (int i = 0; i < cellCount; i++) {
climate.Add(initialData);
nextClimate.Add(initialData);
}
for (int cycle = 0; cycle < 40; cycle++) {
for (int i = 0; i < cellCount; i++) {
EvolveClimate(i);
}
List<ClimateData> swap = climate;
climate = nextClimate;
nextClimate = swap;
}
}
当一个单元影响它的邻居的气候时,它应该调整那个邻居的下一个气候数据,而不是当前的。
for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
HexCell neighbor = cell.GetNeighbor(d);
if (!neighbor) {
continue;
}
ClimateData neighborClimate = nextClimate[neighbor.Index];
…
nextClimate[neighbor.Index] = neighborClimate;
}
与其将单元的气候数据复制回当前的气候列表,不如检索其下一个气候数据,将当前的湿度添加到其中,并将其复制到下一个列表。然后,重置当前列表的数据,使其在下一个循环中保持新鲜。
// cellClimate.clouds = 0f;
ClimateData nextCellClimate = nextClimate[cellIndex];
nextCellClimate.moisture += cellClimate.moisture;
nextClimate[cellIndex] = nextCellClimate;
climate[cellIndex] = new ClimateData();
在这里,让我们也执行最高1的湿度水平,所以陆地细单元不能比水下单元更潮湿。
nextCellClimate.moisture += cellClimate.moisture;
if (nextCellClimate.moisture > 1f) {
nextCellClimate.moisture = 1f;
}
nextClimate[cellIndex] = nextCellClimate;
并行演化
4.2初始化水分
我们的模拟结果可能是有太多的陆地,特别是当陆地百分比很高的时候。为了改善这种情况,我们可以添加一个可配置的初始湿度级别,默认值为0.1。
[Range(0f, 1f)]
public float startingMoisture = 0.1f;
位于最上方的湿度拖动条
将此值用于初始气候列表的湿度,但不用于下一个列表。
ClimateData initialData = new ClimateData();
initialData.moisture = startingMoisture;
ClimateData clearData = new ClimateData();
for (int i = 0; i < cellCount; i++) {
climate.Add(initialData);
nextClimate.Add(clearData);
}
使用了初始化湿度
4.3设置地形特征
最后,我们使用湿度而不是高度来设置单元格地形类型。让我们用雪来代表极干的土地,用沙来代表干旱地区,然后是石头,用草来代表相对湿润的土地,用泥土来代表浸透的水下单元。最简单的方法是使用5个0.2波段。
void SetTerrainType () {
for (int i = 0; i < cellCount; i++) {
HexCell cell = grid.GetCell(i);
float moisture = climate[i].moisture;
if (!cell.IsUnderwater) {
if (moisture < 0.2f) {
cell.TerrainTypeIndex = 4;
}
else if (moisture < 0.4f) {
cell.TerrainTypeIndex = 0;
}
else if (moisture < 0.6f) {
cell.TerrainTypeIndex = 3;
}
else if (moisture < 0.8f) {
cell.TerrainTypeIndex = 1;
}
else {
cell.TerrainTypeIndex = 2;
}
}
else {
cell.TerrainTypeIndex = 2;
}
cell.SetMapData(moisture);
}
}
地形特征
使用均匀分布并不能产生良好的效果,也不符合自然规律。采用0.05、0.12、0.28、0.85等阈值可以获得较好的效果。
if (moisture < 0.05f) {
cell.TerrainTypeIndex = 4;
}
else if (moisture < 0.12f) {
cell.TerrainTypeIndex = 0;
}
else if (moisture < 0.28f) {
cell.TerrainTypeIndex = 3;
}
else if (moisture < 0.85f) {
cell.TerrainTypeIndex = 1;
}
调整地形特征
下一篇教程:特征和河流
原文:Hex Map 26 Biomes and River
?
项目工程文件下载地址:unitypackage
项目文档下载地址:PDF
?
【版权声明】
原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权。