Unity 3D中的无限大地形的生成和调度

2017年04月24日 14:59 1 点赞 0 评论 更新于 2025-11-21 21:24
Unity 3D中的无限大地形的生成和调度

随着硬件性能的不断提升,游戏地形的规模和细节度都有了显著增长。如今的游戏中,地形不仅面积更大,还增加了诸如特色地形、大片草地、树木和水体等元素。在过去几年里,地形范围逐渐扩展到数百平方英里,这在RPG游戏中尤为明显。

在本教程中,我们将探讨如何生成一个需要花费很长时间才能浏览完的3D地形。我们将使用Unity 3D引擎和C#语言进行代码编写。虽然完整的源代码可以免费下载(见下文),但本教程将重点解释关键部分并给出示例代码,因此需要读者具备一定的编程基础。

教程开始

在3D地形的呈现中,高度图是一种常用的方法。高度图是由一组海拔数据构成的图像,其尺寸与地形的宽度和高度相匹配。通常,图像颜色越暗,表示地面越高。下面我们将介绍如何将这些数据转换为可见的网格物体。

由于我们要创建的是无限大(或极其广阔)的地形,直接使用高度图并不现实。因为存储所有数据需要巨大的内存空间,而且高度图的分辨率可能会增长到数千像素。

为了解决这个问题,我们可以将地形分割成一个个被称为“块”的片段,而不是创建一个整体的巨大地形。每个块都有自己的网格,并且相邻的块可以无缝融合成一个更大的地形。可以将这些块看作具有2D(X / Z)坐标的方形图块。关键在于在玩家周围(视线范围内)创建多个地形块,并随着玩家位置的移动动态调整地形块的分布。当某些块离相机过远时,将其删除以释放内存。

单块几何形状的描述

  • 长度:指的是Unity单位中块边框的大小。
  • 高度:表示块中地形的最大可用高度(即Unity距离单位)。
  • 高度图和透明图的分辨率:这两个参数决定了块的网格和纹理的精度。分辨率越高,我们就能获得越复杂的网格。根据Unity文档,其尺寸需要满足N的2次方 + 1(例如129,513)。

以下是地形块设置类的代码:

public class TerrainChunkSettings
{
public int HeightmapResolution { get; private set; }
public int AlphamapResolution { get; private set; }
public int Length { get; private set; }
public int Height { get; private set; }
}

以下是地形块类的代码(暂时跳过部分方法):

public class TerrainChunk
{
public int X { get; private set; }
public int Z { get; private set; }
private Terrain Terrain { get; set; }
private TerrainChunkSettings Settings { get; set; }
private NoiseProvider NoiseProvider { get; set; }
}

每个块由其X / Z位置、设置和Unity地形对象(通过网格表现的实际游戏物体以及渲染场景所需的所有元素)等因素定义。最后一个字段 NoiseProvider 将在后面详细讨论。

高度图的创建

如何创建一个具有起伏和纹理的山谷或山丘的高度图呢?有多种方法可以实现这一目标,您可以在程序生成维基上找到大量相关信息。在本教程中,我们将使用LibNoise库来填充高度图的噪声值。关于噪声值的细节和使用方法,可以参考以下两个网站:http://libnoise.sourceforge.net/tutorials/tutorial3.htmlhttp://libnoise.sourceforge.net/tutorials/tutorial3.html,强烈推荐阅读这两篇文章。

在3D空间(x,y,z)中,每个位置都可以对应一个特定的噪声值。将这些噪声值转化为纹理后,就可以形成类似于真实地形的图像。由于我们只在平面上创建地形,所以目前只考虑X和Z两个方向,暂时跳过Y方向。LibNoise返回的噪声值范围是 -1 到 1,而我们需要将其缩放为 0 到 1 的范围,这样更便于处理。

为了实现这一功能,我创建了 INoiseProvider 接口,该接口强制要求在Unity世界空间中为给定的X / Z坐标返回一个值。NoiseProvider 类将通过Perlin噪声接收这个值(参考上述两个链接),这只是一个开始。

以下是 NoiseProvider 类的代码:

public class NoiseProvider : INoiseProvider
{
private Perlin PerlinNoiseGenerator;

public NoiseProvider()
{
PerlinNoiseGenerator = new Perlin();
}

public float GetValue(float x, float z)
{
return (float)(PerlinNoiseGenerator.GetValue(x, 0, z) / 2f) + 0.5f;
}
}

Unity地形的创建

现在我们已经有了简单的噪声发生器,接下来解决Unity地形的创建问题。通常,您可以通过 GameObject / 3D Object / Terrain 菜单创建地形。但如果要通过代码创建地形,我们需要先创建地形数据对象,该对象包含了生成地形网格所需的大部分信息。然后,我们可以设置高度图值、分辨率和地图大小。最后,使用Unity的指令创建地形游戏物体,并设置其变换位置,让Unity自动完成其余的工作。

以下是创建地形的代码:

public void CreateTerrain()
{
var terrainData = new TerrainData();
terrainData.heightmapResolution = Settings.HeightmapResolution;
terrainData.alphamapResolution = Settings.AlphamapResolution;

var heightmap = GetHeightmap();
terrainData.SetHeights(0, 0, heightmap);
terrainData.size = new Vector3(Settings.Length, Settings.Height, Settings.Length);

var newTerrainGameObject = Terrain.CreateTerrainGameObject(terrainData);
newTerrainGameObject.transform.position = new Vector3(X * Settings.Length, 0, Z * Settings.Length);
Terrain = newTerrainGameObject.GetComponent<Terrain>();
Terrain.Flush();
}

以下是 GetHeightmap 方法的代码:

private float[,] GetHeightmap()
{
var heightmap = new float[Settings.HeightmapResolution, Settings.HeightmapResolution];

for (var zRes = 0; zRes < Settings.HeightmapResolution; zRes++)
{
for (var xRes = 0; xRes < Settings.HeightmapResolution; xRes++)
{
var xCoordinate = X + (float)xRes / (Settings.HeightmapResolution - 1);
var zCoordinate = Z + (float)zRes / (Settings.HeightmapResolution - 1);

heightmap[zRes, xRes] = NoiseProvider.GetValue(xCoordinate, zCoordinate);
}
}

return heightmap;
}

高度图填充原理

为了填充整个海拔数组值(其尺寸等于地形分辨率),我们需要叠加噪声值数据以获得每个位置(X / Z)的值。NoiseProvider 的最终坐标计算公式为:块位置 + 叠加后的数据 /(分辨率 - 1)。这样可以将X / Z方向缩放为 0..1(第一块),1..2(第二块),2..3(第三块)等。新增加的数据不会破坏之前创建的 NoiseProvider,只是在原有基础上完善地图细节。

测试与优化

单块测试

现在核心应用程序已经设置好,我们可以进行一些测试。以下是创建一个129分辨率、尺寸为100米、高20米的单块地形的代码:

void Test()
{
var settings = new TerrainChunkSettings(129, 129, 100, 20);
var noiseProvider = new NoiseProvider();
var terrain = new TerrainChunk(settings, noiseProvider, 0, 0);
terrain.CreateTerrain();
}

运行上述代码后,您将得到一个地形图。虽然目前它看起来还不太完善,因为尚未应用纹理,但已经可以看到一些山丘起伏,这是一个良好的开端。

多块测试

为了使地形看起来更大,我们可以创建更多的块。以下是创建16块地形的代码:

void Test()
{
Settings = new TerrainChunkSettings(129, 129, 100, 20);
NoiseProvider = new NoiseProvider();
for (var i = 0; i < 4; i++)
for (var j = 0; j < 4; j++)
new TerrainChunk(Settings, NoiseProvider, i, j).CreateTerrain();
}

从测试结果可以看出,地形正在增长,说明我们的方法是可行的。然而,创建更大的地形需要花费大量时间。在我的PC上,创建16个块大约需要1500毫秒,这期间整个应用程序会被冻结,给玩家带来不顺畅的游戏体验。

性能优化

大部分延迟是由于需要大量计算每个地形部分的噪声值,这种性能问题在单线程应用程序中很常见。为了解决这个问题,我们需要将高度生成函数放在独立于主线程的单独线程中。通过在创建的线程上创建块,可以提高计算效率,避免主应用程序线程冻结,加快生成时间。

这种改进会使代码发生一些变化,主要包括:

  • 添加地形块生成类:该类可以添加和删除块,使地形始终保持最新状态。它可以作为地形和其他应用程序之间的主要接口,如果需要修改地形,应通过此类中的相应指令进行操作。
  • 添加缓存块类:用于保存所有正在请求和已经创建的块的信息,并追踪块的状态。
  • 使用唯一标识:块由X / Z位置来标识,我创建了 Vector2i 类来保存块的位置信息。

此外,我们还添加了删除块的功能。删除块时,将其添加到队列中。每个帧缓存块都会检查此队列,并尝试删除这些块。如果块正在生成,则无法删除,删除操作将被延迟,直到块生成完成。虽然这可能不是最有效的方式,但处理速度快且操作方便,只需在缓存块类中调用删除整列块指令即可实现。

生成大量块

现在我们来编写一个程序,用于在玩家周围生成大量的块。我们需要获取玩家周围所有块的坐标列表,以确定玩家的位置以及与新生成块的距离。以下是相关代码:

private List<Vector2i> GetChunkPositionsInRadius(Vector2i chunkPosition, int radius)
{
var result = new List<Vector2i>();

for (var zCircle = -radius; zCircle <= radius; zCircle++)
{
for (var xCircle = -radius; xCircle <= radius; xCircle++)
{
if (xCircle * xCircle + zCircle * zCircle < radius * radius)
result.Add(new Vector2i(chunkPosition.X + xCircle, chunkPosition.Z + zCircle));
}
}

return result;
}

该程序需要输入初始块的位置和半径数值,并返回与圆方程匹配的所有坐标。例如,输入位置(0,0),组块半径为7(玩家位置在中间)。

玩家移动处理

为了实现无限大地形的效果,我们需要监控玩家的运动轨迹。当玩家移动到块的边界时,我们需要在相应位置创建新的块,并删除视线范围外的块。以下是更新地形的代码:

public void UpdateTerrain(Vector3 worldPosition, int radius)
{
var chunkPosition = GetChunkPosition(worldPosition);
var newPositions = GetChunkPositionsInRadius(chunkPosition, radius);

var loadedChunks = Cache.GetGeneratedChunks();
var chunksToRemove = loadedChunks.Except(newPositions).ToList();

var positionsToGenerate = newPositions.Except(chunksToRemove).ToList();
foreach (var position in positionsToGenerate)
GenerateChunk(position.X, position.Z);

foreach (var position in chunksToRemove)
RemoveChunk(position.X, position.Z);
}

每次玩家从一个块移动到另一个块时,都会重复执行上述操作。为了测试这些功能,我们添加了一个标准的Unity FPS控制器,并在游戏控制类中创建了玩家管理代码(添加UI以生成新的地形)。现在玩家就可以体验在数百英里的无限地形中行走的感觉了。

添加纹理

目前的地形看起来还不够真实,我们需要添加一些纹理来改善效果。操作方法如下:首先,定义一些可应用于地形的纹理(SplatPrototypes),然后为地形上的每个点指定需要添加的各个纹理的数量(数量取决于 AlphamapResolution)。所有这些信息都输入到地形数据类中,纹理存储在地形块设置类中。

在本教程中,我们使用了两种纹理:一种用于平坦地形,另一种用于陡峭表面。每个纹理效果的呈现基于地形的陡度,我们可以在应用高程数据后从地形数据类中获取这些坡度数值。

以下是应用纹理的代码:

private void ApplyTextures(TerrainData terrainData)
{
var flatSplat = new SplatPrototype();
var steepSplat = new SplatPrototype();

flatSplat.texture = Settings.FlatTexture;
steepSplat.texture = Settings.SteepTexture;

terrainData.splatPrototypes = new SplatPrototype[]
{
flatSplat,
steepSplat
};

terrainData.RefreshPrototypes();

var splatMap = new float[terrainData.alphamapResolution, terrainData.alphamapResolution, 2];

for (var zRes = 0; zRes < terrainData.alphamapHeight; zRes++)
{
for (var xRes = 0; xRes < terrainData.alphamapWidth; xRes++)
{
var normalizedX = (float)xRes / (terrainData.alphamapWidth - 1);
var normalizedZ = (float)zRes / (terrainData.alphamapHeight - 1);

var steepness = terrainData.GetSteepness(normalizedX, normalizedZ);
var steepnessNormalized = Mathf.Clamp(steepness / 1.5f, 0, 1f);

splatMap[zRes, xRes, 0] = 1f - steepnessNormalized;
splatMap[zRes, xRes, 1] = steepnessNormalized;
}
}

terrainData.SetAlphamaps(0, 0, splatMap);
}

添加纹理后,地形的效果会有明显改善。现在,一个功能完备的地形就创建完成了,您可以尽情体验在无限宽广的地形中步行的感觉。虽然这并非真正的无限,但足以欺骗您的感官。

以上就是本教程的全部内容。

作者信息

孟子菇凉

孟子菇凉

共发布了 3994 篇文章