噪聲
噪聲是游戲編程的常見技術,廣泛應用於地形生成,圖形學等多方面。

那么為什么要引入噪聲這個概念呢?在程序中,我們經常使用直接使用最簡單的rand()生成隨機值,但它的問題在於生成的隨機值太“隨機”了,得到的值往往總是參差不齊,如下圖使用隨機值作為像素點的黑白程度:

而使用噪聲,我們得到的值看起來雖然隨機但平緩,這種圖也看起來更自然和舒服:
而根據wiki,現在噪聲類型已經有很多種類:
類別 | 名稱 |
---|---|
基於晶格的方法(Lattice based) | Perlin噪聲,Simplex噪聲,Wavelet噪聲,Value噪聲 |
基於點的方法(Point based) | Worley噪聲 |
本文主要說明Value噪聲,Perlin噪聲,Simplex噪聲這三種常見的噪聲。
隨機性
隨機性是噪聲的基礎,不必多說。
哈希性
在《Minecraft》里,由於世界是無限大的,它以“Chunk”區塊(16×16×256格子)為單位,只加載玩家附近的區塊。也就是說,當玩家在移動時,它會卸載遠離的區塊,然后加載靠近的區塊。
一個問題是,當玩家離開一個區塊時,進入第二個區塊,然后又回到第一個區塊,此時玩家期望看到的第一個區塊和之前看到的保持一致。例如,輸入1時得到0.3,輸入2時得到0.7,當再次輸入1時預期得到0.3。
因此噪聲的一個重要性質是哈希性(可哈希的)。
盡管使用輸入值作為srand()的參數來設置rand()的種子,從而達到哈希效果也是可行的。
然而最好花點時間寫一個自己的哈希函數,使其簡易使用而且也不破壞程序其他地方使用rand()的效果。
//一個隨機性的哈希函數
unsigned int hash11(int position){
const unsigned int BIT_NOISE1 = 0x85297A4D;
const unsigned int BIT_NOISE2 = 0x68E31DA4;
const unsigned int BIT_NOISE3 = 0x1B56C4E9;
unsigned int mangled = position;
mangled *= BIT_NOISE1;
mangled ^= (mangled >> 8);
mangled += BIT_NOISE2;
mangled ^= (mangled << 8);
mangled *= BIT_NOISE3;
mangled ^= (mangled >> 8);
return mangled;
}
hash11的11代表輸入一維坐標,輸出一維值。類似的hash22代表輸入二維坐標,輸出二維值。
若要了解更多隨機性哈希函數實現,可參考下面兩個shadertoy的代碼:
平滑性(連續性)
對一個隨機生成地形來說,如果簡單的使用隨機和哈希組合,
那么容易得到下圖(以一維地圖舉例,x軸為位置,y軸為地形高度):
容易看出的問題是,由於隨機的雜亂無章,地形非常的參差不齊,這可不是一個自然的地形。
我們期望得到的地形不僅隨機還應該是平滑的,這樣才顯得自然,如下圖:
Value噪聲
Value噪聲是最簡單的一種噪聲,其主要思路是定義若干個頂點且每個頂點含有一個隨機值,這些頂點會根據自己的隨機值對周圍坐標產生影響,越靠近頂點則越容易受該頂點影響。當需要求某個坐標的輸出值時,需要將該坐標附近的各個頂點所造成的影響值進行疊加,從而得到一個總值並輸出之。
原理
1.首先定義一個晶格結構,每個晶格的頂點有一個偽隨機值(Value)。對於二維的Value噪聲來說,晶格結構就是一個平面網格(通常是正方形),三維的就是一個立體網格(通常是正方體)。
2.輸入一個點(二維的話就是二維坐標,三維就是三維坐標,n維的就是n個坐標),我們找到和它相鄰的那些晶格頂點(二維下有4個,三維下有8個,n維下有2n個),得到這些頂點的偽隨機值。
3.使用緩和曲線(ease curves)來計算這些偽隨機值的權重和。在原始的Perlin噪聲實現所使用的緩和曲線是\(s(t)=3t^2−2t^3\),在2002年的論文中,Perlin又改進為 \(s(t)=6t^5−15t^4+10t^3\)。
//計算緩和曲線
float fade(float t){
//return t * t * (3.0 - 2.0 * t); 也是可行的
return t * t * t * (t * (t * 6 - 15) + 10); // 6t^5 - 15t^4 + 10t^3
}
//緩和曲線插值應使用lerp(v1,v2,fade(t);
若直接使用線性插值,其一階導在晶格頂點處(即t = 0或t = 1)不為0,會造成明顯的不連續性。\(s(t)=3t^2−2t^3\) 在一階導滿足連續性,\(s(t)=6t^5−15t^4+10t^3\) 在二階導上仍然滿足連續性。
所以實際上兩種緩和曲線都是可用的,如果需要壓榨開銷,則使用 \(s(t)=3t^2−2t^3\) 。
對於預計算,例如程序化生成凹凸紋理(置換紋理),使用\(s(t)=6t^5−15t^4+10t^3\) 的效果更好。
實現(二維)
int valueNoise(Vector2 p){
Vector2 vertex[4] = {{p.x,p.y},{[p.x+1.0f,p.y},{p.x,p.y+1.0f},{p.x+1.0f,p.y+1.0f}};
float wx = (p.x-(int)p.x)/1.0f;
float wy = (p.y-(int)p.y)/1.0f;
return lerp(lerp(hash21(vertex[0]), hash21(vertex[1]), fade(wx)),
lerp(hash21(vertex[2]), hash21(vertex[3]), fade(wx)),
fade(wy));
}
柏林噪聲
談起噪聲,最著名的且最常用的莫過於Perlin噪聲,Perlin噪聲的名字來源於它的創始人Ken Perlin。
在理解了上面Value噪聲后,我們再來看看柏林噪聲的主要想法:
定義若干個頂點且每個頂點含有一個隨機梯度向量,這些頂點會根據自己的梯度向量對周圍坐標產生勢能影響,沿着頂點的梯度方向越上升則勢能越高。當需要求某個坐標的輸出值時,需要將該坐標附近的各個頂點所造成的勢能進行疊加,從而得到一個總勢能並輸出之。
我們給頂點賦予一個隨機性的哈希函數,輸入一個坐標可以得到一個隨機向量,滿足上述隨機性和哈希性。
此外,由於勢能是沿着梯度方向漸變的,所以很容易得到平滑性。
原理
和Value噪聲一樣,它也是一種基於晶格的噪聲,也需要三個步驟:
1.首先定義一個晶格結構,每個晶格的頂點有一個隨機的梯度向量。對於二維的Perlin噪聲來說,晶格結構就是一個平面網格(通常是正方形),三維的就是一個立體網格(通常是正方體)。
2.輸入一個點坐標(二維的話就是二維坐標,三維就是三維坐標,n維的就是n維坐標),我們找到和它相鄰的那些晶格頂點(二維下有4個,三維下有8個,n維下有 \(2^n\) 個),計算該點到各個晶格頂點的距離向量,再分別與頂點上的梯度向量做點乘,得到\(2^n\)個點乘結果。
//點乘
float dot(Vector2 v1,Vector2 v2){
return v1.x*v2.x+v1.y*v2.y;
}
3.使用緩和曲線來計算它們的權重和(同樣的,可以是\(s(t)=3t^2−2t^3\),也可以是s(t)=\(6t^5−15t^4+10t^3\))
下圖通過顏色差異顯示了由2D柏林噪聲生成的各像素點的值:
實現(二維)
//求梯度值(本質是求頂點代表的梯度向量與距離向量的點積)
float grad(Vector2 vertex, Vector2 p)
{
return dot(hash22(vertex), p);
}
//二維柏林噪聲
float perlinNoise(Vector2 p)
{
//向量兩個緯度值向下取整
Vector2 pi = Vector2((int)p.x,(int)p.y);
//計算緩和曲線
Vector2 pf = p - pi;
Vector2 w = pf * pf * (Vector2(3.0f,3.0f) - 2.0 * pf);
//二維晶體格四個頂點
Vector2 vertex[4] = {{pi.x,pi.y},{pi.x+1,pi.y},{pi.x,pi.y+1},{pi.x+1,pi.y+1}};
return lerp(
lerp(grad(Vertex[0],pf),
grad(Vertex[1],pf - Vector2(1.0f, 0.0f)),
w.x),
lerp(grad(Vertex[2],pf - Vector2(0.0f, 1.0f)),
grad(Vertex[3],pf - Vector2(1.0f, 1.0f)),
w.x),
w.y);
}
另一個更快的實現方式,它與標准實現方式的區別是:晶體頂點是從若干個梯度向量里隨機選擇一個向量而不是產生一個隨機向量,這樣做可以預先計算好求梯度值時各項的系數。因此我們只需這樣重寫一下grad函數:
//求梯度值(本質是求頂點代表的梯度向量與距離向量的點積)
float grad(Vector2 vertex, Vector2 p)
{
switch(hash21(vertex) % 4)
{
case 1: return p.x + p.y; //代表梯度向量(1,1)
case 2: return -p.x + p.y; //代表梯度向量(-1,1)
case 3: return p.x - p.y; //代表梯度向量(1,-1)
case 4: return -p.x - p.y; //代表梯度向量(-1,-1)
default: return 0; // never happens
}
}
這里示例提供了4個可選的隨機向量,實際上這個數量是偏少的,如果想要更加多樣的效果,建議在實現時多提供些可選的隨機向量。
Simplex噪聲
Simplex噪聲也是一種基於晶格的梯度噪聲,它和Perlin噪聲在實現上唯一不同的地方在於,它的晶格並不是方形(在2D下是正方形,在3D下是立方體,在更高緯度上我們稱它們為超立方體,hypercube),而是單形(simplex)。
通俗解釋單形的話,可以認為是在N維空間里,選出一個最簡單最緊湊的多邊形,讓它可以平鋪整個N維空間。我們可以很容易地想到一維空間下的單形是等長的線段,把這些線段收尾相連即可鋪滿整個一維空間。在二維空間下,單形是三角形,我們可以把等腰三角形連接起來鋪滿整個平面。三維空間下的單形就是四面體。更高維空間的單形也是存在的。
總結起來,在n維空間下,超立方體的頂點數目是\(2^n\),而單形的頂點數目是\(n+1\),這使得我們在計算梯度噪聲時可以大大減少需要計算的頂點權重數目。
一個潛在的問題是如何找到輸入點所在的單形。
在計算Perlin噪聲時,判斷輸入點所在的正方形是非常容易的,我們只需要對輸入點下取整即可找到。
對於單形來說,我們需要對單形進行坐標偏斜(skewing),把平鋪空間的單形變成一個新的網格結構,這個網格結構是由超立方體組成的,而每個超立方體又由一定數量的單形構成:
我們之前講到的單形網格如上圖中的紅色網格所示,它們有一些等邊三角形組成(注意到這些等邊三角形是沿空間對角線排列的)。經過坐標傾斜后,它們變成了后面的黑色網格,這些網格由正方形組成,每個正方形是由之前兩個等邊三角形變形而來的三角形組成。這個把N維空間下的單形網格變形成新網格的公式如下:
\(x′=x+(x+y+...)⋅K1\)
\(y′=y+(x+y+...)⋅K1\)
其中,\(K1=\frac{\sqrt{n+1}-1}{n}\)
在二維空間下,取n為2即可。這樣變換之后,我們就可以按照之前方法判斷該點所在的超立方體,在二維下即為正方形。
原理
1.坐標偏斜:把輸入點坐標進行坐標偏斜。
\(x′=x+(x+y+...)⋅K1\)
\(y′=y+(x+y+...)⋅K1\)
其中,\(K1=\frac{\sqrt{n+1}-1}{n}\)
2.找到頂點:對偏斜后坐標下取整得到輸入點所在的超立方體\(xi=floor(x′)\),\(yi=floor(y′)\),...我們還可以得到小數部分\(xf=x′−xi\),\(yf=y′−yi\),...
我們把之前得到的(xf,yf,...)中的數值按降序排序,來決定輸入點位於變形后的哪個單形內。這個單形的頂點是由按序排列的(0, 0, …, 0)到(1, 1, …, 1)中的n+1個頂點組成,共有n!種可能性。
我們可以按下面的過程來得到這n+1個頂點:從零坐標(0, 0, …, 0)開始,找到當前最大的分量,在該分量位置加1,直至添加了所有分量。這一步的算法復雜度即為排序復雜度\(O(n^2)\)。
例如,對於二維空間來說,如果xf,yf滿足xf>yf,那么對應的3個單形坐標為:首先找到(0, 0),由於x分量比較大,因此下一個坐標是(1, 0),接下來是y分量,坐標為(1, 1);對於三維空間來說,如果xf,yf,zf滿足xf>zf>yf,那么對應的4個單形坐標位:首先從(0, 0, 0)開始,接下來在x分量上加1得(1, 0, 0),再在z分量上加1得(1, 0, 1),最后在y分量上加1得(1, 1, 1)。
3.梯度選取:我們在偏斜后的超立方體網格上獲取該單形的各個頂點的偽隨機梯度向量。
4.變換回單形網格里的頂點:我們首先需要把單形頂點變回到之前由單形組成的單形網格。這一步需要使用第一步公式的逆函數來求得:
\(x=x′+(x′+y′+...)⋅K2\)
\(y=y′+(x′+y′+...)⋅K2\)
其中,\(K2=\frac{\frac{1}{\sqrt{n+1}}-1}{n}\)
5.貢獻度取和:我們由此可以得到輸入點到這些單形頂點的位移向量。這些向量有兩個用途,一個是為了和頂點梯度向量點乘,另一個是為了得到之前提到的距離值dist,來據此求得每個頂點對結果的貢獻度:
\((r^2−|dist|^2)^4 × dot(dist,grad)\)
實現(二維)
float simplexNoise(Vector2 p)
{
const float K1 = 0.366025404; // (sqrt(3)-1)/2;
const float K2 = -0.211324865; // (sqrt(3)-3)/6;
//坐標偏斜
Vector2 p2 = p + (p.x + p.y) * K1;
//向下取整
Vector2 p2i = Vector2((int)p2.x,(int)p2.y);
Vector2 p2f = p2 - p2i;
Vector2 vertex2Offset = (p2f.x < p2f.y) ? Vector2(0, 1) : Vector2(1, 0);
//頂點變換回單行網格空間
Vector2 a = p - (p2i + (p2i.x + p2i.y) * K2 * Vector2(1,1));
//Vector2 b = p - (p2i + vertex2Offset + (p2i.x + p2i.y + 1) * K2 * Vector2(1,1));
Vector2 b = a - vertex2Offset - K2 * Vector2(1,1);
//Vector2 c = p - (p2i + Vecotr2(1,1) + (p2i.x+1 + p2i.y+1) * K2 * Vector2(1,1));
Vector2 c = a - Vecotr2(1,1) - 2 * K2 * Vector2(1,1);
//頂點變換回單行網格空間
Vector2 a = p - (p2i - (p2i.x + p2i.y) * K2 * Vector2(1,1));
//計算貢獻度取和
Vector3 h = Vector3(0.5f - dot(a, a), 0.5f - dot(b, b), 0.5f - dot(c, c));
Vector3 n = h * h * h * h * Vector3(dot(a, hash22(p2i)), dot(b, hash22(p2i + vertex2Offset)), dot(c, hash22(p2i + Vector2(1,1))));
return n.x + n.y + n.z;
}
\(r^2\)取0.5的原因是因為要求經過第一步坐標偏斜后得到的網格寬度為1,因此我們可以倒推出在變形前單形網格中每個單形邊的邊長為\(\sqrt{\frac{2}{3}}\),這樣一來單形每個頂點到對面邊的距離(即高)的長度為\(\frac{\sqrt{2}}{2}\),它的平方即為0.5。很奇妙的是,不僅是二維,在其他維度下,每個單形頂點到對面邊/面的距離都是0.5。
雖然理解上Simplex噪聲相比於Perlin噪聲更難理解,但由於它的效果更好、速度更優,因此很多情況下會替代Perlin噪聲。
而且高維的噪聲並不少見,例如對於常見的二維噪聲紋理,我們可以額外引入時間分量,變成一個2D紋理動畫(三維噪聲),用於火焰紋理動畫等..
對於常見的三維噪聲紋理,引入額外的時間分量,就可以變成一個3D紋理動畫(四維噪聲),用於3D雲霧動畫等..
當我們需要一個可循環無縫銜接的動畫時(見下文可平埔的噪聲),那噪聲又要提高一個維度。
可平鋪的噪聲
可平鋪的噪聲就是指那些可以tiling的、seamless的噪聲,因為很多時候我們想要讓噪聲紋理可以無縫連接,例如在生成地形時。按照我們之前提到的方法直接產生噪聲,得到的噪聲紋理其實是不可以平鋪的,你可以看生成紋理的左右、上下其實是不一樣的。那么,怎么生成可平鋪的噪聲紋理呢?
翻轉紋理
一種低開銷的trick是,首先對一張噪聲紋理分別進行X軸翻轉,Y軸翻轉,XY軸同時翻轉,從而得到新的三張噪聲紋理,將它們拼接成一張大紋理,此時該大紋理為可tiling無縫的。
一個基礎噪聲紋理:

一個基礎噪聲紋理和另外三個生成的紋理拼接城的大紋理:

缺點是這樣的紋理看起來可能會過於對稱,影響美觀。
對高維度的圓采樣
一種方法是在2n維上計算n維可平鋪噪聲。我們以二維噪聲為例,如果我們想要得到二維的無縫Perlin噪聲,就需要用四維噪聲算法來產生。
這種方法是思想是,由於我們想要每個維度都是無縫的,也就是當該維度的值從0變成1的過程中,0和1之間比較是平滑過渡的,這讓我們想起了“圓”,繞圓一周就是對該維度的采樣過程,這樣就可以保證無縫了。
因此,對於二維噪聲中的x軸,我們會在四維空間下的xz平面上的一個圓上進行采樣,而二維噪聲的y軸,則會在四維空間下的yw平面上的一個圓上進行采樣。這個轉化過程很簡單,我們只需要使用三角函數sin和cos即可把二維采樣坐標轉化到單位圓上。同樣,三維空間的也是類似的,我們會在六維空間下計算。這種方法不僅適用於Perlin噪聲,像Worley噪聲這種也同樣是適合的。
Unity Wiki里二維可平鋪的Simplex噪聲的實現:
float seamlessNoise( float x, float y, float dx, float dy, float xyOffset ) {
const float PI = 3.1415926f;
float s = x;
float t = y;
float nx = xyOffset + cos(s * 2.0f * Mathf.PI) * dx / (2.0f * PI);
float ny = xyOffset + cos(t * 2.0f * Mathf.PI) * dy / (2.0f * PI);
float nz = xyOffset + sin(s * 2.0f * Mathf.PI) * dx / (2.0f * PI);
float nw = xyOffset + sin(t * 2.0f * Mathf.PI) * dy / (2.0f * PI);
return Noise(nx, ny, nz, nw);
}
其中,xyOffset是指在四維空間某個平面上的偏移,即這個單位圓是以xyOffset為圓心的。
該方法缺點是計算量大大增加,一般噪聲的復雜度為\(O(2^n)\)(Simplex噪聲例外,是\(O(n^2)\)),是指數增加的,因此比較適合預計算,例如程序化生成噪聲紋理。
GameDev上關於可平鋪噪聲的討論,許多人分享了他們各自創造性的做法(非常有趣):
https://gamedev.stackexchange.com/questions/23625/how-do-you-generate-tileable-perlin-noise
分形噪聲
當我們使用基於晶格的噪聲時,主要有兩個參數可以調整:
- 頻率(frequencies):晶體格的邊長(即采樣間隔,例如頻率越高,單位面積(特指二維)內的晶格數目越多,看起來噪聲紋理“越密集”。)
- 振幅(amplitudes):返回值的幅度范圍
而在地形生成中,地形可能會有大段連綿、高聳山地,也會有丘陵和蝕坑,更小點的有岩石塊,甚至更小的鵝卵石塊。為了模擬出這樣的自然噪聲特性,我們可以使用不同的參數進行多幾次柏林噪聲計算,然后將結果疊加在一起。
將不同頻率和振幅參數下的柏林噪聲結果疊加在一起,我們就能得到以下結果:
很明顯,這樣的噪聲結果更加令人信服。上面的6組噪聲被稱之為噪聲的不同倍頻(Octave)。隨着倍頻增大,噪聲對於最終疊加噪聲的影響程度變小。
那我們應該分別挑選多大的頻率和振幅來進行噪聲計算呢?這個可以通過persistence參數確定。Hugo Elias對persistence的定義使用如下:
\(frequency = 2^i\)
\(amplitude = persistence^i\)
簡單來說,對於一維噪聲,合適的組合是\(Noise(x)+\frac{1}{2}Noise(2x)+\frac{1}{4}Noise(4x)+...\)
二維噪聲則是\(Noise(x,y)+\frac{1}{2}Noise(2x,2y)+\frac{1}{4}Noise(4x,4y)+....\)
公式:\(\sum\limits_{i=0}^n \frac{Noise(2^i point)}{2^i}\)
以上公式i的值取決於想要倍頻組數。此外至於為什么最好按1倍,2倍,4倍,8倍...的倍頻疊加,是因為這樣的頻率疊加更貼近模擬自然界的自相似過程(可wiki查下自相似)
double octavePerlin(double x, double y, double z, int octaves, double persistence) {
double total = 0;
double frequency = 1; //頻率
double amplitude = 1; //振幅
double maxValue = 10.0f; //用於將結果映射於在[0.0,1.0]的區間內,本文定為10
for(int i=0;i<octaves;i++) {
total += perlinNoise(Vector2(x * frequency, y * frequency)) * amplitude;
maxValue += amplitude;
amplitude *= persistence;
frequency *= 2;
}
return total/maxValue;
}
當然,倍頻組數的增加,會線性地增加代碼執行時間,在游戲運行時使用噪聲算法,再好不要使用超過幾組倍頻(比如,當你想在60fps下模擬火焰特效時,最好不要這么干)。然而,做數據預處理時,就很適合使用多組倍頻疊加來模擬更自然的噪聲(比如用於提前生成游戲地形等)。
結語
- 什么時候使用Value噪聲和柏林噪聲:Value噪聲簡單性能開銷小,但是產生的噪聲可能略顯機械重復(看起來不自然,容易形成"磚塊",可參考本文第三張圖片效果);而柏林噪聲則計算稍加復雜,但是產生的噪聲更為自然。也就是說這是在性能和效果之間做取舍。
- 2維的可平鋪噪聲其實也可以在3維球上采樣(因為三維球可以用yaw/pitch兩個維度表示三維球面上所有點),而無需每個維度在二倍的維度上采樣(導致最終是4維采樣)。
- 柏林噪聲的隨機向量一定需要同樣的長度嗎:測試中...
實際上本文很多篇幅來源於直接參考的幾個文章,這些業界前輩在他們的原文已經寫的相當精彩。只是為了簡化整理內容和補充細節,我才有了整理出這篇博文的想法。
之后有空再寫一篇關於地形生成機制,不過在那之前先好好研究Minecraft和其他類似游戲的地形生成機制,如果有不錯的資料(不必是代碼實現,只是剖析/概念也可)不妨請告訴我,感激不盡。
參考
- [1] 【圖形學】談談噪聲 - candycat - CSDN博客
- [2] 一篇文章搞懂柏林噪聲算法,附代碼講解 - 立航 - 博客園
- [3] 一篇關於simplexnoise和perlinnoise的論文
- [4] Perlin噪聲 wiki
- [5] 程序丨游戲編程中的數學:基於噪音的隨機數字生成
- [6] 《游戲編程精粹2》 Mark A. Deloura [2003-12-1]