https://www.shamusyoung.com/twentysidedtale/?p=141

大约一个月后,这个项目终于完成了。你可以按照顺序阅读整个系列,了解我对每一步的讲解,也可以随意翻翻,欣赏那些漂亮的图片。如果你比较急性子,也可以直接跳到第10部分,看看最终的成果。

地形引擎

1-bsvo.webp

我的目标是从零开始编写一个地形引擎,全部使用全新的代码。在大多数情况下,这样的东西会作为电脑游戏的一个组件。我也会以这样的方式来进行这个项目。

我过去曾经使用过几个地形渲染引擎。在我的工作中,我维护着一个包含地形渲染代码的大型代码库。我也玩过虚幻引擎(Unreal Engine),它有一个很漂亮的地形系统。右边是我通过 Google 图片搜索收集到的一些不同引擎生成的地形渲染图。这只是为了让大家了解一下这个项目的目标方向,不过在能做出那样的效果之前,我还有很长的路要走。

我做这个项目主要是为了自我学习——目前并没有特定的用途。尽管如此,我会像对待一个大型项目那样设定目标并编写代码。这意味着我要写出干净整洁的代码(而不是草率的原型代码),并且即使我的资源很充足,也会注意处理器性能和内存的使用。

第一步是决定我要编写哪种类型的地形引擎。如果这是游戏的一部分,引擎会如何被使用?它会像第一人称游戏那样从地面视角观看,还是像即时战略或模拟类游戏那样从上方俯视?尽管这个引擎实际上不会真的用在游戏中,我仍然需要假装它会被用在游戏里,这样才能知道该如何优化各方面。那么,这两种情况有什么区别呢?

在第一人称游戏中,附近的山丘可能会挡住你对远处景色的视线。通常会有雾或黑暗来限制可见距离,防止玩家看到地平线。你通常看不到一英里以外的地方(实际上通常远不到一英里),而且地形往往非常起伏。如果我选择这种方向,就需要编写代码来判断哪些区域被附近的山丘遮挡,并且不渲染这些部分。地形在近距离下必须看起来非常出色:玩家的视角几乎从不离地面超过六英尺(也就是他们自己的眼睛高度)。世界需要有丰富的细节和清晰的渲染。在右边的图片中,从上数第三张就是《虚幻竞技场》(Unreal Tournament),属于这种类型的游戏。

相比之下,另一类游戏通常是从俯视角度进行观看。视野距离要远得多,但地面不需要那么精细,因为玩家永远不会靠得很近。然而,这类游戏通常需要能够一次性绘制整个地形。在右侧的图片中,最上面那张就是《黑与白》(Black & White),属于这种类型的游戏。

我不想设计一个两者都能做却都做不好的程序。由于我对第一种类型(第一人称视角)有经验,这次我打算选择另一种类型,把这个程序设计成擅长渲染地形俯视图的。

基础

第一步有点枯燥。我建立了一个项目,添加了一个用于调试的独立控制台窗口(未显示),实现了可以用鼠标在地形上飞行的功能,以及一堆其他非常枯燥的事情。

接着,我制作了地形引擎的第一个版本。为了测试,我把地图设为一个512 x 512方格的网格。这并不过分。此时,我完全没有做任何优化处理,只是直接把原始的网格数据发送给渲染。结果如下:

2-hdhg.webp

有史以来最无聊的图片!

512 x 512 个方格总共是 262,144 个方格,每个方格在渲染时被分成两个三角形。所以,总共有 524,288 个多边形。每个多边形由三个顶点(点)组成,因此我一共发送了 1,572,864 个顶点进行渲染。这是非常大量的数据。现代显卡处理这些完全没问题,但一旦我们开始在场景中添加一些东西,比如树木、城市、外星军队、比基尼女孩、巨型机器人、宝可梦,或者其他乱七八糟的东西,处理能力很快就会捉襟见肘。这个“引擎”在目前的状态下毫无用处,除非你只想看看地形。

添加高度数据

在我能做任何有趣的事情之前,我需要一些小山!大多数游戏使用的是由美术人员制作的地形,这意味着会有非常不真实的小山,像被压扁的半球体从平坦的区域中隆起,还有一些像碗一样的高地洼地,以及其他乱七八糟的东西。这种地形在地面上看起来还凑合,但从空中看就很假。这类数据也更容易优化。但我并不想要那样的效果。

我不想退缩,所以我想要看起来真实的东西。这意味着要么使用美国地质调查局(USGS)的测绘数据,要么使用侵蚀模拟器生成的地形。我更喜欢后者,因为这样我还能控制世界的整体形状。我手头还有一些之前项目里造出来的数据,把它用到这个项目上也很容易。

一种常见的方法是将高度数据保存为一张大的灰度图像。这样做便于编辑,也便于在程序外“看到”地形的样子。我会用一张位图(Windows 的 BMP 文件是个不错的选择,因为它们易于加载、易于创建,并且是无损压缩),每个像素代表网格上的一个点。像素越亮,点的高度就越高。这是个不错的起点,但灰度 BMP 文件的深度有限。每个点的值在零(全黑)到 255(全白)之间。当你用这些数据来存储高分辨率的高度信息时,256 个不同的数值远远不够。如果你想让地形有深谷和高山,拉大高差,地形最终会变成“台阶”状。这很糟糕,看起来很假,而且这些锯齿状的形状会让任何简化地形的尝试变得非常困难。为了解决这个问题,我仍然使用 BMP 文件,但我会用上所有三个颜色通道。实际上,这个 BMP 文件由三张独立的图像组成,如下所示:

3-nbat.webp

红色通道代表地形的“整体”形状。这个通道用来生成山脉和山谷。在这里,我只在中间放了一个明亮的斑点,这会在地图中央形成一个大而平缓的隆起,就像一个巨大的(山一样高的)投手丘。绿色通道是我们的“真实”高程数据,包含了像现实世界中那样的真实丘陵。蓝色通道只是均匀的噪声。

红色通道处理的是以数百米为量级的数值。绿色通道处理的是以数十米为量级的数值。蓝色通道处理的是以米及亚米级别的数值。这只是任意的选择:我完全可以让这些通道对应任何我想要的尺度,只不过这种设置看起来比较好用,也更有可能生成一些好看的地形。这三个通道组合成一张复合图像,用来描述我们世界中地形的形状。

4-wepw.webp

这只是大幅缩小后的版本。原图是1024 x 1024的。我目前的世界大小是512 x 512,所以只用到了四分之一,但其余的数据仍然保留,以备将来想做更大世界时使用。

5-sdck.webp

不错。

你可以看到方格网是如何构成地形的。程序会拉起构成这个网格的各个点,对其进行变形。这有点像把毯子盖在某个物体上,通过观察毯子如何落下来自然地确定物体的形状。这无疑是目前最常见的地形生成方式。我相信还有其他方法存在,但我从未见过它们的实际应用。

注意一个缺点:我们只会上下拉动点的位置,却从不在水平方向移动它们。这意味着我们永远无法实现悬垂的悬崖,甚至连垂直的峭壁都做不到。我并不打算重新发明轮子,试图绕过这个问题。大多数人根本不会注意到这些限制,而要克服它们,就需要远比我计划中的更复杂的方法。

Unity 实现

6-sfjn.webp

创建一个空的 GameObject,命名为 Terrain。

挂上脚本

using System;
using UnityEngine;
using UnityEngine.Rendering;

[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
public class GenerateGridMesh : MonoBehaviour
{
    public int gridWidth = 5;
    public int gridHeight = 5;

    public float squareSize = 1.0f;

    private Vector3[] vertices;
    
    // Start is called before the first frame update
    void Start()
    {
        GenerateMesh();
    }

    void GenerateMesh()
    {
        Mesh mesh = new Mesh();
        mesh.indexFormat = IndexFormat.UInt32;
        GetComponent<MeshFilter>().mesh = mesh;

        vertices = new Vector3[(gridWidth + 1) * (gridHeight + 1)];
        int[] triangles = new int[gridWidth * gridHeight * 6];
        
        // 生成顶点
        for (int z = 0; z <= gridHeight; ++z)
        {
            for (int x = 0; x <= gridWidth; ++x)
            {
                int index = z * (gridWidth + 1) + x;
                float y = Mathf.PerlinNoise(x * .9f, z * .3f) * 5f;
                vertices[index] = new Vector3(x * squareSize, y, z * squareSize);
            }
        }
        
        // 生成三角形索引
        int triIndex = 0;
        for (int z = 0; z < gridHeight; ++z)
        {
            for (int x = 0; x < gridWidth; ++x)
            {
                int bottomLeft = z * (gridWidth + 1) + x;
                int bottomRight = bottomLeft + 1;
                int topLeft = (z + 1) * (gridWidth + 1) + x;
                int topRight = topLeft + 1;

                triangles[triIndex] = bottomLeft;
                triangles[triIndex + 1] = bottomRight;
                triangles[triIndex + 2] = topRight;

                triangles[triIndex + 3] = bottomLeft;
                triangles[triIndex + 4] = topRight;
                triangles[triIndex + 5] = topLeft;

                triIndex += 6;
            }
        }
        
        mesh.vertices = vertices;
        mesh.triangles = triangles;

        GetComponent<MeshRenderer>().material = new Material(Shader.Find("Standard"));
    }
}

使用柏林噪声来模拟高度

7-cpvs.webp