利用噪声函数制作地图

作者:Aud42nce
2025-05-20
12 8 1

编者按

本文来自游戏设计博客 Red Blob Games,由 indienova 取得授权并译制发表,原文链接见文末。

  • 原文作者:Amit Patel
  • 译者:Aud42nce

(部分交互内容可能无法在移动端正常显示,如想体验完整交互请使用电脑端浏览)

前言

在我的站点上,最受欢迎的文章之一是多边形地图生成。制作这些地图费时费力。那并不是我最开始的努力方向。我的工作从一些简单得多的东西开始,这也是我打算在本文介绍的内容。这项更简单的技术能使用不到五十行代码生成出像这样的地图:

我不打算解释如何绘制这些地图;这取决于你使用的编程语言、图形库、平台等等。我只打算解释如何设置带有海拔高度和生态群系的地图数据。

噪声

使用带宽受限的噪声函数,如 Simplex 噪声或 Perlin 噪声,来作为构建块,是生成 2D 地图的常见做法。噪声函数的图形看起来会是这样:

我们为地图上的每个位置分配一个 0.0 到 1.0 之间的数值。在本图中,0.0 为黑色,而 1.0 为白色。下面用类 C 语言语法的代码展示如何设置每个网格位置的颜色:

for (int y = 0; y < height; y++) {
  for (int x = 0; x < width; x++) {      
    double nx = x/width - 0.5, ny = y/height - 0.5;
    value[y][x] = noise(nx, ny);
  }
}

这段循环在 Javascript, Python, Haxe, C++, C#,Java 以及大部分其他流行编程语言中都能同样运行,因此,我用类 C 语言语法的代码来展示,你可以将其转化成你所使用的语言。教程后续部分,我会向大家展示循环的主体(value[y][x]=… 这一行)如何随着我们添加更多功能而发生变化。在文章的最后,我会为大家展示完整的示例。

根据你所使用的不同程序库,可能需要调整或缩放你得到的返回值,使其映射到 0.0 到 1.0 的范围内。有些程序库会返回 0.0 到 1.0;有些会返回 -1.0 到 1.0;有些会返回其他范围,比如 -0.7 到 +0.7;还有些库不会说明自己的返回值,因此,你可能需要检查返回值来确定其范围。

海拔

就其本身而言,噪声仅仅只是一组数字。我们需要赋予它意义。我们首先可能会想到的是,将噪声与海拔关联起来(也称“高度图”)。让我们将刚刚得到的噪声绘制为海拔:

代码几乎没有变化,只不过内层循环体稍有不同;现在它是这样的:

elevation[y][x] = noise(nx, ny);

好,就是这样。地图数据完全相同,但现在我叫它海拔,而非数值。

有很多孤立的山峰,但没有其他地形。出了什么问题呢?


频率

噪声可以以任意频率(frequency)生成。目前我只选择了一种频率。让我们看看频率的影响。试着滑动滑块,看看不同噪声频率下会发生什么:

频率 =

仅仅出现了地图缩放。乍一看这并不怎么实用,但事实上它很有用。

elevation[y][x] = noise(frequency * nx, frequency * ny);

如果你能联想到波长(wavelength),即频率的倒数,它有时就能很有用。频率以单位距离内的振动数量来量度。频率翻倍会使所有对象尺寸减半。波长则以每个震动的距离来量度,单位为像素,瓦片,米等等。波长翻倍会使所有对象尺寸翻倍。波长和频率有如下关系:wavelength = map_size / frequency

elevation[y][x] = noise(x / wavelength, y / wavelength);

另一篇教程中,我解释了相关概念:频率,波长,波幅,频程,粉红噪声、蓝色噪声和白噪声等等。


频程

为了让高度图变得更有意思,我们将加入不同频率的噪声:

 +   +   = 
elevation[y][x] =    1 * noise(1 * nx, 1 * ny);
                +  0.5 * noise(2 * nx, 2 * ny);
                + 0.25 * noise(4 * nx, 4 * ny);

让我们把高大的低频率山丘和矮小的高频率山丘叠加到同一张地图。移动滑块来增加矮小山丘的比重:

现在,它看上去更加接近我们想要的分形地形了!我们现在有了山丘和崎岖的山脉,但还是没有得到平坦的谷地。我们还需要做别的工作。

不过,还有一个潜在问题。由于噪声 noise 的取值范围为 0 到 1,取和后,1 * noise() + 0.5 * noise() + 0.25 * noise() 的取值范围会变成 0 到 1.75。其中 10.50.25 这些数值名为波幅(Amplitude)。最简单的处理方案是,将结果除以波幅之和:

             e  =    1 * noise(1 * nx, 1 * ny);
                +  0.5 * noise(2 * nx, 2 * ny);
                + 0.25 * noise(4 * nx, 4 * ny);
elevation[y][x] = e / (1 + 0.5 + 0.25);

实践中,你可能希望通过试验找出最佳除数。尽管除以波幅之和能确保海拔在 0 到 1 的范围内,但海拔值的分布并不一定符合你的预期。

波幅通常设置为数组 [1, 1/2, 1/4, 1/8, 1/16, ...],每个波幅值都是前一个的½。这一比例关系称为增益(gain)或持续度(persistence)。但我们并不一定要使用固定比例——在本文的多数示例中,我采用了 [1, 1/2, 1/3, 1/4, 1/5] 这样的波幅数组,相比传统设置能呈现出更丰富的细节。此外,波幅还可以动态计算:既可以根据前序噪声值计算(比如第一倍频程的噪声会影响第二倍频程的波幅),也可以借助独立噪声场,或是结合玩家/模拟数据来生成。

可能存在的另一个问题是:当 nx 和 ny 接近 0 时,使用 noise(1 * nx, 1 * ny) noise(2 * nx, 2 * ny)noise(4 * nx, 4 * ny) 会出现什么情况?这些噪声值是相互关联的。为了取得好的结果,我们希望它们相互独立。如果你使用的噪声函数库支持随机数种子,那么您可以为不同的频程设置不同的随机数种子。如果噪声函数库不支持随机种子,你可以为每个频程增加一个偏移量,例如,使用 noise(1 * nx, 1 * ny)noise(2 * nx + 5.3, 2 * ny + 9.1)noise(4 * nx + 17.8, 4 * ny + 23.5)。这样一来,每个频程都从噪声空间的不同部分取样,我们就能确保其独立而非关联了。还可能存在的问题是:这些噪声数值有可能会在相同的方向上整齐排列,这有时可能会导致明显的人工痕迹,尤其是使用 Perlin 噪声时。为避免这种情况,可以为一些频程的输出数值添加旋转角度,也可以改用 Simplex 噪声。


再分布(Redistribution)

噪声函数会返回 0 到 1 范围的数值。为了构造平坦的谷地,我们可以取用海拔的幂函数。移动滑块来尝试不同的指数:

幂指数 =
e =    1 * noise(1 * nx, 1 * ny);
  +  0.5 * noise(2 * nx, 2 * ny);
  + 0.25 * noise(4 * nx, 4 * ny);
e = e / (1 + 0.5 + 0.25);
elevation[y][x] = Math.pow(e, exponent);

指数取较大值时,海拔居中的位置会下沉为谷地;指数取较小值时,海拔居中的位置则会隆起为山峰。我们的目标是让这些位置下沉。我采用幂函数是因为它足够简单,但你也可以使用任何你想要使用的曲线函数。这里我制作了一个更精致的 Demo。

实践中,使用 Math.pow(e * fudge_factor, exponent) 的效果可能会更好,其中 fudge_factor 是一个接近 1 的数值。在上述样例中,我取其为 1.2。你也可尝试其他数值,看看哪个效果最好。

幂函数 pow() 并非唯一可以用来重塑海拔图形状的方法,还有许多别的函数可以尝试。也不局限于必须使用数学函数,你可以考虑自己绘制曲线,类似图片编辑软件中的曲线工具那样。

现在,我们已经获得了一张理想的高度图,让我们来为它添加一些生物群系吧!

生物群系

噪声函数返回的是数值,但我们要的却是森林、沙漠和海洋。我们需要做的第一件事就是让低海拔区域沉入水中:

水位线 =
function biome(e) {
    // a threshold between 0.2 and 0.5 work well in the demo
    // but each generator will need its own parameter tuning
    if (e < waterlevel) return WATER;
    else return LAND;
}

嘿,现在看上去是不是开始更像一个过程生成的世界了!我们有了水域、草地和雪地。如果我们希望添加更多内容呢?让我们依次构建水域、沙滩、草地、森林、稀树草原、沙漠和雪地:

仅基于海拔生成的地形
function biome(e) {
  // these thresholds will need tuning to match your generator
  if (e < 0.1) return WATER;
  else if (e < 0.2) return BEACH;
  else if (e < 0.3) return FOREST;
  else if (e < 0.5) return JUNGLE;
  else if (e < 0.7) return SAVANNAH;
  else if (e < 0.9) return DESERT;
  else return SNOW;
}

嘿,看起来真不错!你可能会想要为你的游戏调整这些阈值和生物群系。《孤岛危机》中有更多雨林;《上古卷轴》则包含更多冰川与雪地。但是,无论你如何调整这些阈值,这种方案能存在局限。地形类型总是和海拔高度线性相关,因此,会形成地形带。为了让结果更加有趣,我们需要根据海拔以外的因素来决定群系。让我们创建第二张噪声图来表示“湿度”:

左侧为海拔噪声图,右侧为湿度噪声图

现在,我们同时使用海拔与湿度。在左下图中,y 轴为海拔(见左上图),x 轴为湿度(见右上图)。这样我们就生成了一张看起来很不错的地图:

基于两种噪声值生成的地形

低海拔地区为海洋或沙滩。高海拔地区为岩石或雪地。居中位置,则有各种各样的生态群系。代码如下:

function biome(e, m) {      
  // these thresholds will need tuning to match your generator
  if (e < 0.1) return OCEAN;
  if (e < 0.12) return BEACH;
  
  if (e > 0.8) {
    if (m < 0.1) return SCORCHED;
    if (m < 0.2) return BARE;
    if (m < 0.5) return TUNDRA;
    return SNOW;
  }

  if (e > 0.6) {
    if (m < 0.33) return TEMPERATE_DESERT;
    if (m < 0.66) return SHRUBLAND;
    return TAIGA;
  }

  if (e > 0.3) {
    if (m < 0.16) return TEMPERATE_DESERT;
    if (m < 0.50) return GRASSLAND;
    if (m < 0.83) return TEMPERATE_DECIDUOUS_FOREST;
    return TEMPERATE_RAIN_FOREST;
  }

  if (m < 0.16) return SUBTROPICAL_DESERT;
  if (m < 0.33) return GRASSLAND;
  if (m < 0.66) return TROPICAL_SEASONAL_FOREST;
  return TROPICAL_RAIN_FOREST;
}

这些阈值仅作为范例参考。在我参与的每个项目中,我都调整了它们,不仅因为主生物群系的差异(《Dagobah》地图包含更多沼泽,《Hoth》有较多冻原,《Tatoonie》则有更多沙漠),也因为噪声函数库和频程叠加的方式有所不同。一定要计划好调整这些阈值!

或者,如果你不需要添加生物群系,也可以用平滑的梯度(参见制图师 Tom Patterson 的文章)来生成颜色:

无论是想要生成生物群系还是梯度,单个噪声函数都无法获得足够的多样性。但使用两个噪声函数效果就很不错了。当生态学家罗伯特·惠特克(Robert Whittaker)研究生物群系时使用的就是两个噪声。

气候

在之前的小节中,我用海拔来间接表示温度。海拔越高温度越低。但是,纬度也会影响温度。让我们同时用海拔与纬度来控制温度:

赤道温度:高
极点温度:高

靠近极点(高纬度地区),气候会更寒冷,此外,靠近山顶(高海拔地区),气温也会更低。我们先取海拔高度 e,再考虑纬度数据,将其调整为另一个“等价”的海拔高度。在本例中,我使用 equivalent_elevation = 10ee + poles + (equator-poles) * sin(PI * (y / height)),随后使用 equivalent_elevation 而不是 e 来生成生物群系。我不认为这就是最优算法了。我认为,你需要尝试不同的公式,调整参数,来达到你想要的效果。

此外,气候也包含季节性因素。夏天和冬天,南北半球会分别变得更冷或更暖,赤道附近却不会有太大变化。还有许多可添加的因素,比如,模拟盛行风向和洋流,考虑生物群系对气候的影响,计算海洋对温度的调整作用等等。

岛屿

在某些项目中,我希望地图边界为水域。一种方案是,像前文那样生成地图再调整其形状。从这个角度来看,到底是什么阻止了一片地形成为岛屿呢?边界区域需要沉入水中,同时,中央区域的水下部分需要隆起,形成陆地。

应低于水面的部分应高于水面的部分应低于水面的部分

如何实现呢?涉及两个因素

  1. 距离函数,为地图的每个位置设置一个到地图中心的距离值,地图中心位置为 0,地图边界位置为 1。
  2. 塑形函数(即地形再分布一节中使用的函数),取海拔为输入值,输出新的海拔。

在地图中心(距离为 0),我们使用输出结果总为陆地的塑形函数。在地图边缘(距离为 1),我们使用输出结构总为水域的塑形函数。在两者之间的位置,我们允许陆地和水域同时存在。为简便起见,我们假设水平面高度为 0.5,因此大于等于 0.5 为陆地,小于 0.5 为水域。

构型函数:线性转化

为了计算距离 d,设 nx = 2x/width - 1ny = 2y/height - 1,范围均为-1 到+1。接着, 从 Reddit 用户 KdotJPG 在这个帖子中推荐的距离函数中选取一个来使用:

方形隆起

d = 1 - (1-nx²) * (1-ny²),适用情况:地图为方形,希望岛屿在不触及边界的前提下尽可能填满整张地图。

平方欧氏距离

d = min(1, (nx² + ny²) / sqrt(2)),适用情况:岛屿为圆形,且你计划将其嵌入到更大的地图中使用。

要对海拔高度 e 进行塑形,最简单的方法是将其与 1-d 进行线性混合。当地图中心处 d 值为 0 时,我们希望海拔较高(1);而当地图边缘处 d 值为 1 时,我们希望海拔较低(0)。这可以通过线性插值实现:e = lerp(e, 1-d, mix),其中 mix 的取值范围是 0 到 1。mix 滑块调到位置 0,可查看原始地图,调到位置 1,则可查看限制后的效果。

线性塑形 mix 参数:距离函数:0 表示原始图形,½ 处表示生成的岛屿:
线性塑形函数将原始噪声和某一特定形状混合

距离函数:

  1. SquareBump 方形隆起
  2. EuclideanSquared 平方欧氏距离
  3. Diagonal 对角线距离
  4. Manhattan 曼哈顿距离
  5. Euclidean 欧氏距离
  6. Hyperboloid 双曲距离
  7. Blob 圆角五星

还有更多内容可以尝试。试着加入一个常量,上下偏移距离函数,或乘以一个常量来增加或减少坡度,或者,将指数从 2 调整为 4 或者 6。也可以仅对噪声生成器中的低频频程应用塑形函数,高频频程则直接叠加到整个地图上。试着选择自己想要的岛屿数量,抬升或降低全部海拔高度,直到得到理想的岛屿大小。试着通过查表算法来实现任意(分段线性分布)的塑形或距离函数。你需要多加尝试,来找到自己喜欢的组合。

脊形噪声

除了使用海拔的幂函数,我们还可以用绝对值来构造陡峭的山脊:

function ridgenoise(nx, ny) {
  return 2 * (0.5 - abs(0.5 - noise(nx, ny)));
}

想要添加频程,可以调节高频部分的波幅,这样一来,只有山地区域会叠加噪声值:

e0 =    1 * ridgenoise(1 * nx, 1 * ny);
e1 =  0.5 * ridgenoise(2 * nx, 2 * ny) * e0;
e2 = 0.25 * ridgenoise(4 * nx, 4 * ny) * (e0+e1);
e = (e0 + e1 + e2) / (1 + 0.5 + 0.25);
elevation[y][x] = Math.pow(e, exponent);
n =2

我对这项技术也没有太多经验,必须多加尝试才能掌握窍门。将高耸的低频噪声和平坦的高频噪声叠加,结果应该也会非常有趣。

台地

如果我们将海拔值取整到最近的12个值,我们就得到了台地:

n =23

这是对形如 e=f(e) 的海拔再分布公式的一种应用。早前,我们设 e = Math.pow(e, exponent),来让山峰更陡峭;这里,我们设  e = Math.round(e * replace) / replace,来生成台地。使用阶跃函数以外的函数,可以使台地更圆整,或者只在某些海拔出现。

种树

我们通常使用分形噪声来获得海拔与湿度,但它也能够用来放置不规则形状的物体,如树木、岩石。处理海拔,我们使用高波幅低频率的噪声(“红色噪声”)。对放置物体,我们则希望使用高波幅高频率的噪声(“蓝色噪声”。)左侧是蓝色噪声的图形,右侧则标识处噪声值大于周围数值的位置。

R =4
R 为范围(即在所有噪声值为其附近 2R*2R 范围最大的位置种一棵树)
for (int y = 0; y < height; y++) {
  for (int x = 0; x < width; x++) {
    double nx = x/width - 0.5, ny = y/height - 0.5;
    // blue noise is high frequency; try varying this
    bluenoise[y][x] = noise(50 * nx, 50 * ny); 
  }
}

for (int yc = 0; yc < height; yc++) {
  for (int xc = 0; xc < width; xc++) {
    double max = 0;
    // there are more efficient algorithms than this
    for (int dy = -R; dy <= R; dy++) {
      for (int dx = -R; dx <= R; dx++) {
        int xn = dx + xc, yn = dy + yc;
        // optionally check that (dx*dx + dy*dy <= R * (R + 1))
        if (0 <= yn && yn < height && 0 <= xn && xn < width) {
          double e = bluenoise[yn][xn];
          if (e > max) { max = e; }
        }
      }
    }
    if (bluenoise[yc][xc] == max) {
      // place tree at xc,yc
    }
  }
}

KDotJPG 建议,通过检查 dx*dx + dy*dy <= R * (R + 1) ,可以将方形重叠检测改为圆形重叠检测,同时支持非整数半径值。此外还需注意,半径不一定必须是固定数值。通过为不同的生物群系选择不同的 R 值,就能种下不同密度的树木:

虽然利用 Simplex/Perlin 噪声来种树也没问题,但其他算法通常更加高效,也能获得更好的树木分布效果。对于放置树木或其他物品,我推荐使用泊松分布或者抖动网格算法,而非这里展示的高频噪声函数。对于 Javasript,我使用 poisson-disk-sampling 库, fast-2d-poisson-disk-sampling 库 或者 jittered-hexagonal-grid-sampling 库。王浩砖以及图像抖动等其他算法也值得留意。

循环地图

有时,我们希望地图东边与西边相连,类似 3D 空间中的柱体。我们可以稍作调整来将其实现。我们可以将平面地图上的 x 坐标转换为柱形地图的一个角度。接着我们将该角度映射到笛卡尔坐标系中。为了让地图南北相连,我们也可以用同样的方式将 y 坐标转换为角度,并在相应的四维噪声空间中确定其值。让我们看看与其自身边缘相连的地图是树木样的:

地图仅东西相连与地图东西和南北均相连

第一章地图东西相连,南北不相连。第二章地图四个方向均相连。代码如下:

const TAU = 2 * M_PI;

function cylindernoise(double nx, double ny) {
    double angle_x = TAU * nx;
    /* In "noise parameter space", we need nx and ny to travel the
       same distance. The circle created from nx needs to have
       circumference=1 to match the length=1 line created from ny,
       which means the circle's radius is 1/2π, or 1/tau */
    return noise3D(cos(angle_x) / TAU, sin(angle_x) / TAU, ny);
}

function torusnoise(double nx, double ny) {
    double angle_x = TAU * nx,
           angle_y = TAU * ny;
    return noise4D(cos(angle_x) / TAU, sin(angle_x) / TAU,
      cos(angle_y) / TAU, sin(angle_y) / TAU);
}

实践中,你可能必须将这些噪声数值放大。更高维度的噪声数值会比低维噪声取值范围更窄,因此,如果你是根据二维噪声函数生成的生物群系常量,在三维噪声函数中,需要将其乘以√1.5,在四维噪声函数中,需要乘以√2,或者,你可以进一步调整这些数值来满足需求。参见 Rudi Chen 有关 Perlin 噪声取值的文章。有关噪声图平铺的话题,可参阅 Ron Valstar 的指南

另一个可能影响质量的因素是,根据 Cook 和 DeRose 在论文《小波噪声》中的描述,不同频程的噪声可能会“渗透”到彼此的频率范围内:

渲染过程中,通常通过采样 3D 噪声函数来为 2D 表面添加纹理,然而,即便原始的 3D 函数本身是完美带限的,最终生成的 2D 纹理通常也不会保持带限特性。

无穷乃至超越

在位置(x,y)进行的生物群系的计算与其他任何位置相互独立。这种局部计算产生了两点好处:一是能够平行进行多个计算,二是能够用于生成无限范围的地形。将鼠标放在左侧小地图上来生成右侧地图。无需生成(也不必存储)完整地图,我们就得到地图的任意部分。

如果在自己的代码中实现它呢?只需对我们已经使用的代码稍作修改。找到调用 noise(…, …) 这样代码的位置,替换为 noise(… - camera.x, … - camera.y)。在我的 Demo 中,我用鼠标位置来为 camera.xcamera.y 取值,你也可以用键盘 WASD 或者其他操控方式来移动无限地图上的摄像头位置。

实现

使用噪声生成地形是一项十分流行的技术,你可以发现基于许多不同语言与平台的教程。不同语言下的地图生成代码非常相似。下面是三种不同的语言下的最简单循环:

Javascript:

import { createNoise2D } from 'simplex-noise';
let gen = createNoise2D();
function noise(nx, ny) {
  // Rescale from -1.0:+1.0 to 0.0:1.0
  return gen(nx, ny) / 2 + 0.5;
}

let value = [];   
for (let y = 0; y < height; y++) {
  value[y] = [];
  for (let x = 0; x < width; x++) {      
    let nx = x/width - 0.5, ny = y/height - 0.5;
    value[y][x] = noise(nx, ny);
  }
}

C++:

module::Perlin gen; // if using libnoise
double noise(double nx, double ny) { // if using libnoise
  // Rescale from -1.0:+1.0 to 0.0:1.0
  return gen.GetValue(nx, ny, 0) / 2.0 + 0.5;
}

FastNoiseLite gen; // if using fastnoiselite
double noise(double nx, double ny) { // if using fastnoiselite
  // Rescale from -1.0:+1.0 to 0.0:1.0
  return gen.GetNoise(nx, ny) / 2.0 + 0.5;
}

double value[height][width];
for (int y = 0; y < height; y++) {
  for (int x = 0; x < width; x++) {
    double nx = x/width - 0.5, 
           ny = y/height - 0.5;
    value[y][x] = noise(nx, ny);
  }
}

Python:

from opensimplex import OpenSimplex
gen = OpenSimplex()
def noise(nx, ny):
    # Rescale from -1.0:+1.0 to 0.0:1.0
    return gen.noise2d(nx, ny) / 2.0 + 0.5

value = []
for y in range(height):
    value.append([0] * width)
    for x in range(width):
        nx = x/width - 0.5
        ny = y/height - 0.5
        value[y][x] = noise(nx, ny)

只要使用了噪声库,代码就会很相似。注意:一些噪声库会自动叠加多个频程的噪声,虽然很方便,但却让你难以按照自己的方式叠加不同噪声。

大部分流行编程语言都有许多噪声程序库。另外,你可能想要花一些时间研究 Simplex/Perlin/OpenSimplex 噪声函数的原理,或者实现一个自己的版本。不过,我没有这个兴趣。我就用现成的噪声库就可以了。

一旦你为自己最喜欢的编程语言找到噪声库,虽然细节不尽相同(比如,有些返回值范围为 0.0 到 1.0,有些则是-1.0 到+1.0)但基本思路都一样。在实际项目中,你可能会想要把噪声函数和生成对象打包进一个类中,但这些细节无关紧要,我将它们都设为了全局变量。

对于这个简单的项目,无论是用 Simplex 噪声、OpenSimplex 噪声、Perlin 噪声、值噪声(Value Noise)、随机中点位移(midpoint displacement)、菱形正方形算法(diamond square displacement)或者逆傅里叶变换,都无关紧要。它们各有利弊,但针对这类地图生成器,都能得到足够相似的结果。

地图绘制涉及具体的平台和游戏,因此我这里不作介绍。这里的代码能够生成海拔与生物群系,你可以使用任意游戏风格来绘制它们。你可以将这些代码随意复制,迁移和运用到自己的项目中。

游乐园

前文已经介绍了叠加频程,使用海拔的幂函数,以及结合海拔与湿度来确定生物群系等技术。下面是一个包含上述所有参数的可互动图像,随后我会展示如何整合这些代码:

import alea from 'alea';
import { createNoise2D } from 'simplex-noise';
const genE = createNoise2D(alea(seed1));
const genM = createNoise2D(alea(seed2));
function noiseE(nx, ny) { return genE(nx, ny)/2 + 0.5; }
function noiseM(nx, ny) { return genM(nx, ny)/2 + 0.5; }
   
for (var y = 0; y < height; y++) {
  for (var x = 0; x < width; x++) {      
    var nx = x/width - 0.5, ny = y/height - 0.5;
    var e = (1.00 * noiseE( 1 * nx,  1 * ny)
           + 0.50 * noiseE( 2 * nx,  2 * ny)
           + 0.25 * noiseE( 4 * nx,  4 * ny)
           + 0.13 * noiseE( 8 * nx,  8 * ny)
           + 0.06 * noiseE(16 * nx, 16 * ny)
           + 0.03 * noiseE(32 * nx, 32 * ny));
    e = e / (1.00 + 0.50 + 0.25 + 0.13 + 0.06 + 0.03);
    e = Math.pow(e, 7.72);
    var m = (1.00 * noiseM( 1 * nx,  1 * ny)
           + 0.75 * noiseM( 2 * nx,  2 * ny)
           + 0.33 * noiseM( 4 * nx,  4 * ny)
           + 0.33 * noiseM( 8 * nx,  8 * ny)
           + 0.33 * noiseM(16 * nx, 16 * ny)
           + 0.50 * noiseM(32 * nx, 32 * ny));
    m = m / (1.00 + 0.75 + 0.33 + 0.33 + 0.33 + 0.50);
    /* draw biome(e, m) at x,y */
  }
}
调整参数 →
幂函数 =
海拔频程:
e1 =
e2 =
e3 =
e4 =
e5 =
e6 =
湿度频程:
m1 =
m2 =
m3 =
m4 =
m5 =
m6 =

这里是对于复杂处的一点提示:海拔噪声与湿度噪声需要使用不同的随机数种子,否则两者会生成同样的噪声值,得到的地图就不那么有趣了。Javascript 库 simplex-noise v3 package,Python 库 opensimplex 和 C++ 库 FastNoiseLite 都支持随机数种子。Javascript 库 Simplex-noise v4 则允许你插入允许随机数种子的自定义随机数生成器。如果你使用的噪声库不支持随机数种子,有一个替代方案是从远处位置采样,比如使用 noise2D(nx,ny) 生成海拔,使用 noise2D(nx + 1000, ny) 生成湿度。

另一个提示:叠加多个频程的噪声后,输出结果的取值范围可能会超出预计,因此,你必须通过加减乘除,将输出结果映射到需要的范围内(比如 0.0 到 0.1)。Scott Turne 针对噪声函数的常见问题稍微进行了深入讨论;Rudi Chen 则分析了 Perlin 噪声输出值的取值范围;另外,KDotJPG 研究了 Simplex、OpenSimplex 和 Perlin 噪声的相关问题。我正在使用的 simplex-noise.js 库已考虑这一因素,并将输出值重新缩放至 -1.0 到 +1.0 范围内。

思考

这种地图生成方式让我喜欢的地方是:它简单。它很高效。它能以简短的代码得到还不错的结果。

我不喜欢的地方则是,它很受限。局部计算意味着每个位置都相互独立。不地图的不同区域之间没有关联。地图上的每个地方都给人相同的感觉。没办法加入“这里应该有 3 到 5 个湖泊”这样的全局限制条件,或者是“河流从最高山峰流入大海”这样的全局特性。另一个让我不太喜欢的地方是,为了得到想要的结果,你需要调整大量参数。

那为什么我还要推荐它呢?因为它可以作为一个很好的起点,尤其是对独立游戏或者 Game Jam 作品。我的两个朋友在一次游戏比赛中,花了 30 天时间完成了《狂神国度》(Realm of the Mad God)的初始版本。他们请求我帮忙制作游戏地图。我采用这项技术(还加上了一些后来证明没那么有用的额外功能)为他们制作了游戏地图。数月过去,得到了玩家的反馈,我也更加深入地了解了这款游戏的设计后,我们用维诺多边形(Voronoi polygons)重新设计了更高级的地图生成器,参阅这里。这个地图生成器使用的并非本文介绍的技术,而是以很不相同的方式来利用噪声生成地图。

基于噪声的海拔生成很有趣,也很容易上手,但很快就会意识到它的局限性。Scott Turner 写过一篇很有见地的文章,讲述了使用替代方案的一些原因。Artifexian 有关海岸线的视频,则能让你直观感受到基于噪声生成的地形多么具有局限性。

延伸阅读

噪声函数还能帮助你完成许多有趣的事。如果你在互联网上搜索,会发现很多衍生项目,诸如 turbulence(一种滤波器),Pillow(Python 图像处理库),ridged multifractal,amplitude damping,Terraced,voronoi 噪声,解析微分,domain warping 等。可以看这个页面来寻找灵感。我不再这里详细介绍他们;相反,我希望本文尽可能简洁。

我之前参与的地图生成器项目对其有一定影响:

  • 我使用通用 Perlin 噪声来制作《狂神国度》的第一代地图生成器。在 alpha 测试的前六个月,我们一直在使用它,一直到测试期间,我们基于游戏性需要,用自己设计的维诺多边形地图生成器代替了它。这篇文章中的生物群系与颜色就取自这些项目。
  • 在研究音频信号处理时,我写了一篇有关噪声的教程,涉及频率、波幅、频程以及噪声“颜色”等概念。这些概念也同样适用于基于噪声的地图生成。那时我曾制作过一些未经打磨的地图生成器 Demo,但一直没能完成它们。
  • 有时,我会尝试找到极限。我想知道,我可以用多么简短的代码来生成一张理想的地图。在这个小项目中,我做到了一行代码都不用:只用到了图像滤波器(turbulence, threshold, color gradients 等)。我既开心,又感到困扰。能用图像滤波器生成多少种地图呢?非常多。前文中涉及“颜色平滑渐变”的一切都来自这场试验。噪声层由 Turbulence 图像滤波器提供;频程是上下叠加的图层;幂函数的指数则对应 Photoshop 中的“曲线调整”工具。

让我感到有点困扰的是,我们游戏开发者基于噪声地形生成(包括随机中点位移算法)所编写的大部分代码所得的结果,竟然与音频或图像滤波器毫无区别。另一方面,只用极少的代码就获得了不错的效果,也是我撰写本文的原因。这是一个容易且很快能上手的起点。通常,我不会长期使用这类地图。当游戏实现了更多内容,我也对什么类型的地图更贴合游戏的设计有了更好的判断,我就会用自定义的地图生成器替换掉它。这也是我很习惯的行为模式:从非常简单的东西开始,只有对正在使用的系统有更深入的了解后,才尝试替换掉这些简单的内容。

借助噪声函数还能做很多有趣的事,本文中我几乎没有展开介绍。请尝试这里的 Noise Studio 来交互探索更多可能性。还推荐参阅这些:


原文链接:https://www.redblobgames.com/maps/terrain-from-noise/
*本文内容系作者独立观点,不代表 indienova 立场。未经授权允许,请勿转载。

近期点赞的会员

 分享这篇文章

您可能还会对这些文章感兴趣

参与此文章的讨论

  1. 奶油菠萝冻 2025-05-25

    太牛了

您需要登录或者注册后才能发表评论

登录/注册