柏林噪声:程序化生成算法

1,638 阅读14分钟

声明

本文来自国外文章,在原文的基础上我增了了一些自己的理解,原文于此:

Perlin Noise: A Procedural Generation Algorithm (rtouti.github.io)

前言

本文是我一次不成熟的尝试来解释该算法的原理和其使用方法

弄懂这个算法是如何工作花费了我相当长的时间,并且我参考了许多材料来帮助我理解其过程。

柏林噪声是一种非常常见的程序化生成算法,它最先由Ken Perlin发明。该算法可以用于生成诸如纹理、程序化地形等,这意味着我们不需要艺术家或设计师手动的创作这些东西。该算法可以是一维的,也可以是更高维度,这取决于你的输入值的维度。在本文中,我会使用二维数据来进行展示,因为可视化二维数据比三维数据更加容易。关于柏林噪声是什么而不是什么有一些令人疑惑的点,比如我通常会对value noisesimplex noise感到困惑,一共有4种基本的噪声十分相似并且很容混淆:

  • Classic Perlin Noise
  • Improved Perlin Noise
  • Simplex Noise
  • Value Noise

Simplex NoiseValue Noise同样是由Ken Perlin发明,但是它们与柏林噪声略有不同。一个简单的区分它们的方式是:如果一个噪声随机算法使用了伪随机数,那么它大概率是 Value Noise而本文则是介绍 Improved Perlin Noise

首先,我们看看如何使用它。该算法接受一系列的浮点数作为输入(这取决于数据的维度)并且返回一个处于某个范围内的值(对于柏林噪声来说,返回值通常位于-1.0~1.0之间)。假设我们的数据是二维的,所以函数接受2个参数x, yx, y可以表示任何值,现在我们假设x, y表示坐标值。为了生成纹理,xy被看做是纹理上的单个像素的坐标值,我们需要使用一个 for循环来遍历纹理上的每一个像素点,在每个位置都调用柏林噪声函数,根据返回值来决定它们应该如何被渲染(根据返回值来决定它们最终的颜色)。

下面是一个简单的实现例子:

 Color pixels[500][500];
 ​
 for (int y = 0; y < 500; y++) {
     for (int x = 0; x < 500; x++) {
         // Noise2D generally returns a value approximately in the range [-1.0, 1.0]
         float n = Noise2D(x*0.01, y*0.01);
         
         // Transform the range to [0.0, 1.0], supposing that the range of Noise2D is [-1.0, 1.0]
         n += 1.0;
         n /= 2.0;
         
         int c = Math.round(255*n);
         pixels[y][x] = Color(c, c, c);
     }
 }

上述代码生成的图像可能是下面这样:

Perlin noise texture

上面的代码是类C++风格,接下来的其他代码将会使用ES6 JavaScript来进行编写。

另外,本文的代码是为了可读性而牺牲了其性能,这些代码中创建了大量不必要的临时对象(Vector2),如果你想将柏林噪声应用于真实的生产环境,我推荐你使用更加标准、更快的实现,比如cs.nyu.edu/~perlin/noi…。你甚至可以使用在GPU上实现的噪声函数,GPU的实现通常比CPU的实现要快上许多。

正如你所见,每个像素不仅仅是拥有一个随机值,它还需要从一个像素到另一个像素的平滑过渡,这样我们的纹理看起来不会是完全的杂乱无章。因为柏林噪声有一条性质:如果2个输入值是相近的,那么函数的返回值也同样会在其附近。

原理解释

所以,其原理是什么呢?

我先给出一个快速的解释,随后我们再详细的看看到底发生了什么?

我们的输入值可以被看作是一个网格(如下图)。每个点位于下图中的小方格中,对于每个小方格的4个角,我们都生成1个随机值,然后我们对这个4个值进行插值,我们就能得到最终的结果了。柏林噪声与Value Noise的区别在于4个角的随机值的生成方式不同。Value Noise使用伪随机算法生成,而柏林噪声则是使用2个向量的点乘得到的。

Perlin noise grid

第一个向量是方格4个角落的其中之一与输入点之间的连线,另一个向量则是一个常数向量被赋值给方格的4个角(如下图)。该常数向量对于每个方格来说必须是相同的。

Perlin noise grid vectors

得到第一个向量的算法实现如下:

 // Suppose x, y and z are the float input
 const X = Math.floor(x) & 255;  // Used later
 const Y = Math.floor(y) & 255;  // Used later
 const xf = x-Math.floor(x);
 const yf = y-Math.floor(y);
 ​
 const topRight = new Vector2(xf-1.0, yf-1.0);
 const topLeft = new Vector2(xf, yf-1.0);
 const bottomRight = new Vector2(xf-1.0, yf);
 const bottomLeft = new Vector2(xf, yf);

通常,在柏林噪声的实现中,噪声在乘以256(后面我们将256这个常数称为 w)次后就会wrap,类似于溢出后重复第一个值。这是因为,在给方格赋予常数向量后,我们马上需要一个被称为permutation table的东西,这是一个长度为w的数组,其中包含了范围在 0~(w-1)之间的整数,但是经过乱序(也就是我们上面说的permutation)。该数组的索引值就是 XY,所以我们需要X或Y小于256。如果你想要更大的乱序数组,你可以将255的值改的更大一些。

这仅仅是Ken Perlin用于获得每个方格4个角的常亮向量的方法,你完全可以采用你自己的方式来实现这一过程,这样你或许就不会存在wrap的限制。例如,你可以使用伪随机数生成器来生成常量向量,但是在这种情况下,使用Value Noise可能更好。

我们现在创建好了permutation table并且将其乱序,下面是代码,随后我会解释它的含义。

 // Create an array (our permutation table) with the values 0 to 255 in order
 const permutation = [];
 for(let i = 0; i < 256; i++) {
     permutation[i] = i;
 }
 ​
 // Shuffle it
 permutation = Shuffle(permutation);

Shuffle 函数将会在文章的末尾给出完整的代码例子

下一步,我们需要从 permutation table中挑选一个值赋给方格的每个角,但这里有一个约束条件:每个角都必须要获得相同的值。比如:第(0, 0)个网格的的右上角的值为42,那么第(1, 0)个网格左上角的值也必须是42!。因为他们在整个网格中是同一个点,所以无论是在哪个小方格中进行计算,其最终值都必须保持一致。

如下图所示,无论我们的输入值是 input1还是 input2,他们的方格共同的顶点的值必须一致!

image-20240510150504737

 // Select a value in the array for each of the 4 corners
 // P stands for the permutation table above.
 const valueTopRight = P[P[X+1]+Y+1];
 const valueTopLeft = P[P[X]+Y+1];
 const valueBottomRight = P[P[X+1]+Y];
 const valueBottomLeft = P[P[X]+Y];

上面的代码就是我们为每个角选择随机值的方式,它遵循了我们上面提及的约束条件。(此处的P表示的就是上面提及的permutation table。)

假设我们现在位于(0, 0)号网格内,那么其valueBottomRight的值为 P[P[0+1] + 0]。当我们处于第(1, 0)号网格内时,valueBottomLeft的值为P[P[1] + 0]。这两个网格的valueBottomRightvalueBottomLeft的值一致。

我们为了实现wrap需要让permutation table的大小翻倍。如果我们想要计算P[X + 1]并且当X ===255(此时 X + 1 = == 256),如果我们不将数组大小翻倍的话我们将得到一个溢出的值,因为我们的数组的大小当前只有256,最大索引则只有255。一件重要的事情是我们一定不要先扩大数组的大小后再进行乱序的操作,而是应该先将其乱序,再进行扩容操作。这样,当 X===255时,P[X + 1]发生溢出的值就会与 P[0]保持一致了。

现在是时候来给网格的4个角赋值常量向量了。Ken Perlin原始的实现使用了一个神奇的函数,被称为grad,它为每个角直接计算了点乘。我们让事情变得简单点!我们直接根据传入的值来直接返回几个常量向量,随后再计算其点乘。

简单起见,这些常量向量将会是这4个中的其中1个: (1.0, 1.0), (1.0, -1.0), (-1.0, 1.0), (-1.0, -1.0)

代码如下:

 function GetConstantVector(v) {
     // v is the value from the permutation table
     const h = v & 3;
     if(h === 0)
         return new Vector2(1.0, 1.0);
     else if(h === 1)
         return new Vector2(-1.0, 1.0);
     else if(h === 2)
         return new Vector2(-1.0, -1.0);
     else
         return new Vector2(1.0, -1.0);
 }

在上述代码中,由于v的范围在0~255之间,并且我们有4个可能的向量,我们可以让其与3进行“与”的位运算,这相当于对4取模。来获得0, 1, 2, 3这4个值。基于这4个值,我们可以得到这4个向量中的其中之一。

现在我们可以计算器点乘:

 const dotTopRight = topRight.dot(GetConstantVector(valueTopRight));
 const dotTopLeft = topLeft.dot(GetConstantVector(valueTopLeft));
 const dotBottomRight = bottomRight.dot(GetConstantVector(valueBottomRight));
 const dotBottomLeft = bottomLeft.dot(GetConstantVector(valueBottomLeft));

现在,我们已经为每个方格的4个角都计算好了点乘值,我们现在需要以一种方式将这4个值混合起来,为了达成这个目的,我们将使用插值技术。

插值技术是一种基于2个值之间找到某个特定值的方法。假设,我们有2个值a1, a2和一个在0~1范围之间的值t(表示百分比)。

例如,如果a1===10, a2===20,并且t===0.5,插值的结果则为15。因为它是10到20的距离一半。我们看一下另一个例子:a1===50, a2===100并且t===0.4,结果为70。这种方法被称为线性插值,因为插值结果的始终是线性的。

我们给出计算线性插值的方式:

out=a1+(a2a1)tout = a_1 + (a_2 - a_1)t

或者

out=(1t)a1+ta2out = (1 - t)a_1 + ta_2

上述两个公式展开后是一样的,只是表示不同而已。

代码如下:

 function Lerp(t, a1, a2) {
     return a1 + t*(a2-a1);
 }

但是现在我们有4个值需要插值,但是现在我们一次只能对2个值进行插值。所以我们使用这样一种策略来进行插值:

  1. 我们先对 top-leftbottom-left位置的2个点进行插值,t为方格内的点在竖向方向的比例(假设输入点为(7.182, 5.234),则t=0.234)插值的结果我们称为 v1
  2. 再对top-righbottom-right这2个点进行插值,结果为v2
  3. 最后对 v1v2进行插值,t为方格内的点在横向方向的比例。

现在我们完成了插值,但是还没有得到一个比较自然的结果。即时是应用了线性插值,在每个方格的接缝处,也会发生突变。如下图所示:

Hard transition

反应在二维图像上如下:

image-20240510155714827

正如你在一维图像上看到的那样,在1的附近有一个突变,而我们想要其变得更加的丝滑,就像下面这样:

Smooth transition

2D smooth transition

在线性插值中,我们使用xf来表示线性插值的 t,现在我们要将xfyf转变为 uv。为了达到上面的丝滑的效果,我们将使用下面的这样一个函数,它可以将我们0~1的线性的值映射的更加的丝滑!!!

Ease curve

上述函数的表达式是

f(t)=6t515t4+10t3f(t) = 6t^5-15t^4+10t^3

代码如下:

 // Unoptimized version
 function Fade(t) {
     return 6*t*t*t*t*t - 15*t*t*t*t + 10*t*t*t;
 }
 ​
 // Optimized version (less multiplications)
 function Fade(t) {
     return ((6*t - 15)*t + 10)*t*t*t;
 }

现在我们就可以像上面说的那样进行线性插值了,只不过我们需要使用经过Fade函数映射后的值。代码如下:

const u = Fade(xf);
const v = Fade(yf);
const result = Lerp(u,
	Lerp(v, dotBottomLeft, dotTopLeft),
	Lerp(v, dotBottomRight, dotTopRight)
);

OK,这就是柏林噪声了!下面是完整的代码

class Vector2 {
	constructor(x, y) {
		this.x = x;
		this.y = y;
	}

	dot(other) {
		return this.x*other.x + this.y*other.y;
	}
}

function Shuffle(arrayToShuffle) {
	for(let e = arrayToShuffle.length-1; e > 0; e--) {
		const index = Math.round(Math.random()*(e-1));
		const temp = arrayToShuffle[e];
		
		arrayToShuffle[e] = arrayToShuffle[index];
		arrayToShuffle[index] = temp;
	}
}

function MakePermutation() {
	const permutation = [];
	for(let i = 0; i < 256; i++) {
		permutation.push(i);
	}

	Shuffle(permutation);
	
	for(let i = 0; i < 256; i++) {
		permutation.push(permutation[i]);
	}
	
	return permutation;
}
const Permutation = MakePermutation();

function GetConstantVector(v) {
	// v is the value from the permutation table
	const h = v & 3;
	if(h == 0)
		return new Vector2(1.0, 1.0);
	else if(h == 1)
		return new Vector2(-1.0, 1.0);
	else if(h == 2)
		return new Vector2(-1.0, -1.0);
	else
		return new Vector2(1.0, -1.0);
}

function Fade(t) {
	return ((6*t - 15)*t + 10)*t*t*t;
}

function Lerp(t, a1, a2) {
	return a1 + t*(a2-a1);
}

function Noise2D(x, y) {
	const X = Math.floor(x) & 255;
	const Y = Math.floor(y) & 255;

	const xf = x-Math.floor(x);
	const yf = y-Math.floor(y);

	const topRight = new Vector2(xf-1.0, yf-1.0);
	const topLeft = new Vector2(xf, yf-1.0);
	const bottomRight = new Vector2(xf-1.0, yf);
	const bottomLeft = new Vector2(xf, yf);
	
	// Select a value from the permutation array for each of the 4 corners
	const valueTopRight = Permutation[Permutation[X+1]+Y+1];
	const valueTopLeft = Permutation[Permutation[X]+Y+1];
	const valueBottomRight = Permutation[Permutation[X+1]+Y];
	const valueBottomLeft = Permutation[Permutation[X]+Y];
	
	const dotTopRight = topRight.dot(GetConstantVector(valueTopRight));
	const dotTopLeft = topLeft.dot(GetConstantVector(valueTopLeft));
	const dotBottomRight = bottomRight.dot(GetConstantVector(valueBottomRight));
	const dotBottomLeft = bottomLeft.dot(GetConstantVector(valueBottomLeft));
	
	const u = Fade(xf);
	const v = Fade(yf);
	
	return Lerp(u,
		Lerp(v, dotBottomLeft, dotTopLeft),
		Lerp(v, dotBottomRight, dotTopRight)
	);
}

如果你运行上面的代码来试图得到一张纹理,并且将像素的坐标作为输入值传入到 Noise函数中,你可能会得到一张全黑的图像。

Why?

当所有的输入值都是整形数据时,比如输入值为(5, 3)时,分配给网格的常量向量将都会是(0, 0),进而点积的结果也将会是0。为了解决这个小问题,我们需要乘上一个小数,这个小数我们通常将其称为“频率”。

分形布朗运动(FBM)

分形布朗运动并不是柏林噪声的核心部分,但是到目前为止以我知道的内容,大部分的FBM都会使用柏林噪声,它会产生一个很好的结果,如下:

Perlin noise with fractal brownian motion

如果不使用FBM,结果如下:

Perlin noise without fractal brownian motion

所以,它的工作原理是怎样的?

第二张图片看起来不太行是因为它“过于的丝滑”了以至于看起来不太真实,真正的地形看起来充满了更多的噪声。

所以,如果我们想要从第二张图变到第一张图,我们需要添加一些噪声,幸运的是,这正是FBM所做的事情!

下方是一幅一维柏林噪声的图像,假设输入值x的范围在0~3之间,频率为1。

1 dimensional Perlin noise with frequency of 1

假设我们往上再加上另一条柏林噪声函数的图像,x的范围仍然在0~3,但频率为2。图像将变成下面这样:

1 dimensional Perlin noise with frequency of 2

即便是我们新增的这条函数的取值范围也在0~3之间,但这条曲线看起来更加的“跳跃”了,这是因为相当于我们将最终的结果乘以了2。那倘若我们给新增的这条曲线乘上一个0.5将会怎样呢?我们会得到这个:

2 octaves of Perlin noise

如果我们再重复几次上述的操作呢?我们会得到下面的图像:

8 octaves of Perlin noise

这正是我们想要的效果!这条曲线总体上有一个光滑的形状,但是它又具有很多细节。这看起来就像是一条连绵起伏的山脉。如果你在2D图像中这样做,这正是你在上面看到的高度图!

我们所做的仅仅是增加了噪声的层数!每一层具有不同的振幅和频率,并且当一层的频率正好是上一层噪声的2倍,这一层被称为octave。虽然你可能也会在其他地方看到频率并不是2倍关系时,也被称为octave的情况。

第一层的octave具有“山脉轮廓”的整体形状,其频率较低,振幅为1。第二层的octave会增加一些振幅更小的细节,这意味着频率增加,而振幅减小,我们可以一直重复做这样的事情直到我们获得一个比较满意的结果。

你也不必担心最终的值会超过典型的柏林噪声的范围,因为即便我们一直往上增加层数,但是这些值并不总是整数,它们也可能是负数,所以它会保持平衡,并且每一层的噪声的振幅都在不断减少,这也减少了值溢出的风险,但是有的时候也可能发生溢出。

在代码中,FBM看起来是这样:

function FractalBrownianMotion(x, y, numOctaves) {
	let result = 0.0;
	let amplitude = 1.0;
	let frequency = 0.005;

	for (let octave = 0; octave < numOctaves; octave++) {
		const n = amplitude * Noise2D(x * frequency, y * frequency);
		result += n;
		
		amplitude *= 0.5;
		frequency *= 2.0;
	}

	return result;
}

OK,柏林噪声基本上就完成了。你可以使用它来生成你想要的各种东西,从山脉轮廓到高度图等等。

希望你喜欢这篇文章,感谢你的阅读!


翻译完

下面是完整的源代码: