柏林噪声是一个非常强大算法,经常用于程序生成随机内容,在游戏和其他像电影等多媒体领域广泛应用。算法发明者Ken Perlin也因此算法获得奥斯卡科技成果奖(靠算法拿奥斯卡也是没谁了666)。本文将剖析他于2002发表的改进版柏林噪声算法。在游戏开发领域,柏林噪声可以用于生成波形,起伏不平的材质或者纹理。例如,它能用于程序生成地形(例如使用柏林噪声来生成我的世界(Minecraft)里的地形),火焰燃烧特效,水和云等等。柏林噪声绝大部分应用在2维,3维层面上,但某种意义上也能拓展到4维。柏林噪声在1维层面上可用于卷轴地形、模拟手绘线条等。
如果拓展柏林噪声到4维层面,我们用第4维,即w轴代表时间,就能利用柏林噪声做动画。例如,2D柏林噪声可以通过插值生成地形,而3D柏林噪声则可以模拟海平面上起伏的波浪。下面是柏林噪声在不同维度的图像以及在游戏中的应用场景。
正如图所示,柏林噪声算法可以用来模拟许多自然中的噪声现象。接下来让我们从数理上分析算法的实现原理。
基本原理
注意:事先声明,本节内容大多源于this wonderful article by Matt Zucker,不过该篇文章内容也是建立在1980年所发明的柏林噪声算法基础上的。本文我将使用2002年发明的改进版柏林噪声算法。因此,我的算法版本跟Zucker的版本会有些不同。
让我们从最基本的柏林噪声函数看起:
public double perlin(double x, double y, double z);
函数接收x,y,z
三个坐标分量作为输入,并返回0.0~1.0的double值作为输出。那我们应该怎么处理输入值?首先,我们取3个输入值x,y,z
的小数点部分,就可以表示为单元空间里的一个点了。为了方便讲解,我们将问题降维到2维空间来讨论(原理是一样的),下图是该点在2维空间上的表示:
图1:小蓝点代表输入值在单元正方形里的空间坐标,其他4个点则是单元正方形的各顶点
接着,我们给4个顶点(在3维空间则是8个顶点)各自生成一个伪随机的梯度向量。梯度向量代表该顶点相对单元正方形内某点的影响是正向还是反向的(向量指向方向为正向,相反方向为反向)。而伪随机是指,对于任意组相同的输入,必定得到相同的输出。因此,虽然每个顶点生成的梯度向量看似随机,实际上并不是。这也保证了在梯度向量在生成函数不变的情况下,每个坐标的梯度向量都是确定不变的。
举个例子来理解伪随机,比如我们从圆周率π(3.14159...)的小数部分中随机抽取某一位数字,结果看似随机,但如果抽取小数点后1位,结果必定为1;抽取小数点后2位,结果必定为4。
图2:各顶点上的梯度向量随机选取结果
请注意,上图所示的梯度向量并不是完全准确的。在本文所介绍的改进版柏林噪声中,这些梯度向量并不是完全随机的,而是由12条单位正方体(3维)的中心点到各条边中点的向量组成:
(1,1,0),(-1,1,0),(1,-1,0),(-1,-1,0), (1,0,1),(-1,0,1),(1,0,-1),(-1,0,-1), (0,1,1),(0,-1,1),(0,1,-1),(0,-1,-1)
采用这些特殊梯度向量的原因在Ken Perlin's SIGGRAPH 2002 paper: Improving Noise这篇文章里有具体讲解。
注意:许多介绍柏林噪声算法的文章都是根据最初版柏林噪声算法来讲解的,预定义的梯度表不是本文所说的这12个向量。如图2所示的梯度向量就是最初版算法所随机出来的梯度向量,不过这两种算法的原理都是一样的。
接着,我们需要求出另外4个向量(在3维空间则是8个),它们分别从各顶点指向输入点(蓝色点)。下面有个2维空间下的例子:
图3:各个距离向量
接着,对每个顶点的梯度向量和距离向量做点积运算,我们就可以得出每个顶点的影响值:
grad.x * dist.x + grad.y * dist.y + grad.z * dist.z
这正是算法所需要的值,点积运算为两向量长度之积,再乘以两向量夹角余弦:
dot(vec1,vec2) = cos(angle(vec1,vec2)) * vec1.length * vec2.length
换句话说,如果两向量指向同一方向,点积结果为:
1 * vec1.length * vec2.length
如果两向量指向相反方向,则点积结果为:
-1 * vec1.length * vec2.length
如果两向量互相垂直,则点积结果为0。
0 * vec1.length * vec2.length
点积也可以理解为向量a在向量b上的投影,当距离向量在梯度向量上的投影为同方向,点积结果为正数;当距离向量在梯度向量上的投影为反方向,点积结果为负数。因此,通过两向量点积,我们就知道该顶点的影响值是正还是负的。不难看出,顶点的梯度向量直接决定了这一点。下面通过一副彩色图,直观地看下各顶点的影响值:
图4:2D柏林噪声的影响值
下一步,我们需要对4个顶点的影响值做插值,求得加权平均值(在3维空间则是8个)。算法非常简单(2维空间下的解法):
// Below are 4 influence values in the arrangement:
// [g1] | [g2]
// -----------
// [g3] | [g4]
int g1, g2, g3, g4;
int u, v; // These coordinates are the location of the input coordinate in its unit square.
// For example a value of (0.5,0.5) is in the exact center of its unit square.
int x1 = lerp(g1,g2,u);
int x2 = lerp(g3,h4,u);
int average = lerp(x1,x2,v);
至此,整个柏林噪声算法还剩下最后一块拼图了:如果直接使用上述代码,由于是采用lerp线性插值计算得出的值,虽然运行效率高,但噪声效果不好,看起来会不自然。我们需要采用一种更为平滑,非线性的插值函数:fade函数,通常也被称为ease curve(也作为缓动函数在游戏中广泛使用):
图5:ease curve
ease curve的值会用来计算前面代码里的u和v,这样插值变化不再是单调的线性变化,而是这样一个过程:初始变化慢,中间变化快,结尾变化又慢下来(也就是在当数值趋近于整数时,变化变慢)。这个用于改善柏林噪声算法的fade函数可以表示为以下数学形式:
基本上,这就是整个柏林噪声算法的原理了!搞清了算法的各个实现关键步骤后,现在让我们着手把代码实现出来。
代码实现
在本节开始前我需要重申一遍,代码实现是C#版本。相比Ken Perlin的Java版本实现做了小小的改动,主要是增加了代码的整洁性和可读性,支持噪声重复(瓦片重复)特性。代码完全开源,可免费使用(考虑到这毕竟不是我原创发明的算法 - Ken Perlin才是!)
准备工作
第一步,我们需要先声明一个排列表(permutation table),或者直接缩写为p[]
数组就行了。数组长度为256,分别随机、无重复地存放了0-255这些数值。为了避免缓存溢出,我们再重复填充一次数组的值,所以数组最终长度为512:
private static readonly int[] permutation = { 151,160,137,91,90,15,