编者按
本文来自游戏设计博客 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。其中 1
、0.5
、0.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
来生成生物群系。我不认为这就是最优算法了。我认为,你需要尝试不同的公式,调整参数,来达到你想要的效果。
此外,气候也包含季节性因素。夏天和冬天,南北半球会分别变得更冷或更暖,赤道附近却不会有太大变化。还有许多可添加的因素,比如,模拟盛行风向和洋流,考虑生物群系对气候的影响,计算海洋对温度的调整作用等等。
岛屿
在某些项目中,我希望地图边界为水域。一种方案是,像前文那样生成地图再调整其形状。从这个角度来看,到底是什么阻止了一片地形成为岛屿呢?边界区域需要沉入水中,同时,中央区域的水下部分需要隆起,形成陆地。
如何实现呢?涉及两个因素:
- 距离函数,为地图的每个位置设置一个到地图中心的距离值,地图中心位置为 0,地图边界位置为 1。
- 塑形函数(即地形再分布一节中使用的函数),取海拔为输入值,输出新的海拔。
在地图中心(距离为 0),我们使用输出结果总为陆地的塑形函数。在地图边缘(距离为 1),我们使用输出结构总为水域的塑形函数。在两者之间的位置,我们允许陆地和水域同时存在。为简便起见,我们假设水平面高度为 0.5,因此大于等于 0.5 为陆地,小于 0.5 为水域。
为了计算距离 d
,设 nx = 2x/width - 1
,ny = 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 表示原始图形,½ 处表示生成的岛屿:
距离函数:
- SquareBump 方形隆起
- EuclideanSquared 平方欧氏距离
- Diagonal 对角线距离
- Manhattan 曼哈顿距离
- Euclidean 欧氏距离
- Hyperboloid 双曲距离
- 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);
我对这项技术也没有太多经验,必须多加尝试才能掌握窍门。将高耸的低频噪声和平坦的高频噪声叠加,结果应该也会非常有趣。
台地
如果我们将海拔值取整到最近的12个值,我们就得到了台地:
这是对形如 e=f(e)
的海拔再分布公式的一种应用。早前,我们设 e = Math.pow(e, exponent)
,来让山峰更陡峭;这里,我们设 e = Math.round(e * replace) / replace
,来生成台地。使用阶跃函数以外的函数,可以使台地更圆整,或者只在某些海拔出现。
种树
我们通常使用分形噪声来获得海拔与湿度,但它也能够用来放置不规则形状的物体,如树木、岩石。处理海拔,我们使用高波幅低频率的噪声(“红色噪声”)。对放置物体,我们则希望使用高波幅高频率的噪声(“蓝色噪声”。)左侧是蓝色噪声的图形,右侧则标识处噪声值大于周围数值的位置。

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.x
和 camera.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)
只要使用了噪声库,代码就会很相似。注意:一些噪声库会自动叠加多个频程的噪声,虽然很方便,但却让你难以按照自己的方式叠加不同噪声。
- Python: opensimplex for Python
- C++: SimplexNoise、FastNoiseLite、libnoise
- Javascript、Typescript:simplex-noise.js
- Java、C#:opensimplex2
- Unity:Unity.Mathematics.noise、Mathf.PerlinNoise
大部分流行编程语言都有许多噪声程序库。另外,你可能想要花一些时间研究 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 */ } }
调整参数 →
这里是对于复杂处的一点提示:海拔噪声与湿度噪声需要使用不同的随机数种子,否则两者会生成同样的噪声值,得到的地图就不那么有趣了。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 来交互探索更多可能性。还推荐参阅这些:
- 博客 Inigo Quilezles, 必读资料。
- 用数学构建世界,《无人深空》制作人 Sean Murray 的演讲;跳转到从第 20 分钟开始。
- 《无人深空》中无尽世界的生成 - Innes McKendrick
- 《异星工厂》地图中的噪声函数
原文链接:https://www.redblobgames.com/maps/terrain-from-noise/
*本文内容系作者独立观点,不代表 indienova 立场。未经授权允许,请勿转载。
太牛了