這一節的標題是
A Smoother Approach with Perlin Noise
因為方格子標題字數限制,所以沒完整顯現
這一節介紹的是由Ken Perlin所開發的Perlin noise。
利用Perlin noise演算法所得到的,其實也是pseudorandom number。然而,Perlin noise與random.random()這種常見的亂數產生器所產生的亂數之間,卻有著很不一樣的性質。什麼不一樣的性質呢?先來看看下面這張圖(繪製程式見本節文末):
從圖中可以很明顯地看出來,Perlin noise所產生的數字序列,比亂數要來得平滑。這也就是說,Perlin noise所產生的數字序列,相鄰兩個數字之間,變化幅度不會像亂數那麼大。正因為Perlin noise的這種特性,所以比起亂數來,更適合用來製作如雲、地景、物體的紋理等視覺效果。使用亂數雖然能讓用程式模擬出來的東西,行為表現更真實、更自然,但因亂數數列各個值之間的變化可能會很劇烈,導致多多少少還是會有些地方顯得不是那麼的自然。不過,要注意的是,這並不意味著所有的東西都適合用Perlin noise來處理,畢竟在真實世界中,也存在著像亂石嶙峋這種變化劇烈的景象,這時候使用亂數反而比較適合。沒有哪種工具是萬能的,不同的問題會需要不同的解決工具,Perlin noise和亂數都是很好用的工具,就看會不會用而已。現在,就來看看Perlin noise要怎麼用。
在Python的標準程式庫中,並沒有提供Perlin noise相關的package,所以必須自己寫,或者用別人寫好的。在Python Package Index (PyPI)中,有一些可以產生Perlin noie的package,其中noise這個package,有相當多的人使用,應該是個不錯的選擇。不過要注意的是,noise所提供的,是兩種改良過的Perlin noise,其中一個是improved Perlin noise,也有人稱之為Perlin's improved noise,另一個是Perlin simplex noise。
安裝好noise之後,使用下列指令,即可看到使用說明:
import noise
help(noise)
在noise這個package中,包含下列函數:
noise.pnoise1(x) # 1D improved Perlin noise
noise.pnoise2(x, y) # 2D improved Perlin noise
noise.pnoise3(x, y, z) # 3D improved Perlin noise
noise.snoise2(x, y) # 2D simplex noise
noise.snoise3(x, y, z) # 3D simplex noise
noise.snoise4(x, y, z, w) # 4D simplex noise
這些函數除了x
、y
、z
、w
等參數外,還有一些有預設值的參數可以調整。
Perlin noise和亂數在使用上有著相當大的差異。在取亂數時,參數的作用是讓亂數落在設定的範圍內,例如random.random()
所傳回的亂數,會落在[0, 1)這個區間內;而random.uniform(0.8, 3.2)
所傳回的亂數,會落在[0.8, 3.2]這個區間內。然而,以1D improved Perlin noise為例,noise.pnoise1(0.3)
傳回的,是在0.3這個點上的noise數值。所以,程式
a = random.random()
b = random.random()
x = noise.pnoise1(0.3)
y = noise.pnoise1(0.3)
所得到的a
、b
,是兩個不同的數值;但是x
、y
卻會是相同的數值。如果想取得不同數值的noise,呼叫noise.pnoise1()
時,使用的參數必須不同,例如
for i in range(10):
x = noise.pnoise1(i*0.01)
print(x)
所印出的,就會是10個不同的數值。但是有一點要注意的,不管是幾維的pnoise
還是snoise
,在整數點上取到的值,往往會是0,所以應盡量避免使用整數參數。
既然要取得不同數值的noise必須使用不同的參數,那這些參數要怎麼設定呢?以上述的程式為例,是以0.01為間隔,來取得不同數值的noise。原則上,這個間隔越小,取出的noise數值會越平滑;而間隔越大,取出的noise數值,則會越不平滑,而越像亂數。
在取亂數時,我們會知道取出的亂數,是落在哪個範圍內。那從noise這個package所取得的Perlin noise,會落在哪個範圍內呢?可惜在使用說明裡頭,並沒有提到這一點。不過,從測試程式來看,不管是pnoise
還是snoise
所傳回的值,都會落在[-1, 1]這個區間內。
既然取得的noise值會落在區間[-1, 1]內,在實際使用時,就必須將其映射(map)到我們所需要的範圍。在Python中,並未內建這種功能的函數,所以必須自己寫,或者使用numpy
中的interp()
。現在先來推導公式,然後再來看看interp()
要怎麼用。
假設要把區間[a, b]裡頭的點p,映射到區間[x, y]中,公式可以這麼推導:
a ≤ p ≤ b
0 ≤ p-a ≤ b-a
0 ≤ (p-a)/(b-a) ≤ 1
0 ≤ (y-x)(p-a)/(b-a) ≤ y-x
x ≤ (y-x)(p-a)/(b-a)+x ≤ y
所以p映射到[x, y]之後,就會變成
(p-a)(y-x)/(b-a) + x
當然啦,在這裡我們會假設a≠b、x≠y。
另一個推導方式,用的是幾何的觀點,步驟比較少。假設[a, b]裡頭的點p,映射到區間[x, y]中會成為q。線段ap占線段ab的比例,會等於線段xq占線段xy的比例,也就是
(p-a)/(b-a) = (q-x)/(y-x)
整理一下,可以得到
q = (p-a)(y-x)/(b-a) + x
現在,把a=-1及b=1代入推導出來的式子中,得到
q = 0.5×(p+1)(y-x) + x
接下來,來看看interp()
的用法。interp()
其實是用來做內插的,不過也可以拿來映射區間。要把區間[a, b]中的點p映射到區間[x, y]中,參數的設定如下:
interp(p, [a, b], [x, y])
所以,要把取得的noise的值p,映射到區間[x, y]中,要寫成
numpy.interp(p, [-1, 1], [x, y])
應用Perlin noise映射的技術,我們可以把依循亂數移動的walker
,改成以比較平順的方式移動。
Example 0.6: A Perlin Noise Walker
class Walker:
def __init__(self, x=0, y=0):
# pygame的畫面
self.screen = pygame.display.get_surface()
# walker的位置,預設值是(0, 0)
self.x, self.y = x, y
# Perlin noise參數起始值
self.tx, self.ty = 0, 10000
# 將自己顯示在螢幕上
def show(self, color=(0, 0, 0)):
self.screen.set_at((int(self.x), int(self.y)), color)
def step(self):
self.x = numpy.interp(noise.pnoise1(self.tx), [-1, 1], [0, WIDTH])
self.y = numpy.interp(noise.pnoise1(self.ty), [-1, 1], [0, HEIGHT])
self.tx += 0.01
self.ty += 0.01
# python version 3.10.9
import sys
import pygame # version 2.3.0
import noise # version 1.2.2
import numpy # version 1.23.5
pygame.init()
pygame.display.set_caption("Example 0.6: A Perlin Noise Walker")
WHITE = (255, 255, 255)
screen_size = WIDTH, HEIGHT = 640, 360
screen = pygame.display.set_mode(screen_size)
FPS = 60
frame_rate = pygame.time.Clock()
walker = Walker(WIDTH//2, HEIGHT//2)
screen.fill(WHITE)
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
walker.step()
walker.show()
pygame.display.update()
frame_rate.tick(FPS)
在這個例子中,我們在step()
這個方法內,用兩個變數tx
、ty
來取得不同的Perlin noise,以便將其映射為walker
的實際位置。特別要注意的是,在__init__()
中設定的tx
、ty
起始值並不相同,而且差距很大,一個是0
,一個是10000
。這麼做的目的,是為了讓取得的兩個Perlin noise數列,彼此之間看起來沒什麼關聯性。從下面這張不同參數範圍Perlin noise比較圖(繪製程式見本節文末),就可以看出來這麼做的效果:
除了起始值之外,tx
、ty
的值也需要注意。在step()
中的寫法
tx += 0.01
ty += 0.01
會導致tx
、ty
每隔一定的間隔,剛好都是整數。但是如先前提到過的,在整數點上取到的noise值,往往會是0
。所以這樣子寫法所取出的兩個noise,每隔一定的間隔,就會同時為0
,而導致本來希望不相干的兩組noise,看起來有些關聯性。這個現象,可以從Example 0.6所畫出的walker
運動軌跡中看出來。在圖中,軌跡經常會通過畫面的中心位置。這個位置就是,當分別由tx
、ty
處所取得的兩個noise值,剛好都是0
時,映射到x
、y
座標後的位置。
要避免tx
、ty
同時為整數的情況發生,稍微調整一下它們改變的間隔大小就可以。例如改為
tx += 0.009
ty += 0.011
則walker
的運動軌跡就會變成
很明顯的,通過中心點的軌跡變少了。
當然啦!tx
、ty
的起始值不要用整數,也是一個可行的辦法。總之,就是要避免在整數點上取Perlin noise就是了。
Exercise 0.7
要用Perlin noise來決定walker
的步伐大小,step()
可以這樣改寫:
def step(self):
self.x += numpy.interp(noise.pnoise1(self.tx), [-1, 1], [-3, 3])
self.y += numpy.interp(noise.pnoise1(self.ty), [-1, 1], [-3, 3])
self.tx += 0.009
self.ty += 0.011
這裡的[-3, 3]
就是包含方向的步伐大小。改寫之後的walker
移動軌跡圖如下:
這個軌跡圖長相很奇怪,似乎右邊和下面各有一堵牆,walker
怎麼樣都跨不過去。使用不同的tx
、ty
間隔和起始值,也會有一樣的現象。推測這應該跟improved Perlin noise的曲線長相有關。以積分的觀點來看,似乎improved Perlin noise曲線底下所包含的面積,有upper bound存在。
無論如何,用Perlin noise來決定walker的步伐大小,看來並不是個好主意。
到目前為止,我們用的都是一維的Perlin noise。接下來,就來看看二維的Perlin noise可以怎麼用。
先來看看這個一維、二維指的是什麼東西的維度。
要計算一維、二維的Perlin noise,程式要這麼寫:
noise.pnoise1(x) # 1D
noise.pnoise2(x, y) # 2D
所以,很明顯的,這裡的一維、二維,指的是參數的數量,說得比較有學問一點,就是參數空間的維度。從這裡也就很容易可以推廣到三維,甚至是四維或更高的維度。
從一維、二維Perlin noise的程式寫法可以看出,不管參數是幾個,取得的noise都是單一的數值。既然如此,那這一維、二維的Perlin noise之間,到底有些什麼差異?
先來看看一維的情況。Perlin noise的特點就是它的平滑性,也就是說在某個點上所取得的noise,跟在這個點前、後附近的點所取得的noise,差異不大。例如,在x-0.01、x、x+0.01這三個點所取得的noise數值就會很接近。
把一維的情況推廣到二維,就會變成:在平面上某個點所取得的noise,跟在這個點周圍附近所取得的noise,彼此間的差異不大。以九宮格的觀點來看,就是中心點所取得的noise數值,跟周圍八個點所取得的noise數值,都會很接近。下面這張圖,就是利用二維Perlin noise畫出來的。
這張圖的製作方式,是利用Perlin noise來設定每個像素的顏色。因為Perlin noise平滑的特性,圖中的黑、白之間,並沒有很清楚的交界處,而是以漸層的方式改變顏色。這種效果,也就是當初發明Perlin noise的目的。製作圖片的完整的程式碼如下:
# python version 3.10.9
import sys
import pygame # version 2.3.0
import noise # version 1.2.2
import numpy # version 1.23.5
pygame.init()
pygame.display.set_caption("2D Perlin noise")
screen_size = WIDTH, HEIGHT = 640, 360
screen = pygame.display.set_mode(screen_size)
FPS = 60
frame_rate = pygame.time.Clock()
xoff = 0
for x in range(WIDTH):
yoff = 0
for y in range(HEIGHT):
bright = numpy.interp(noise.pnoise2(xoff, yoff), [-1, 1], [0, 255])
r = g = b = int(bright)
screen.set_at((x, y), (r, g, b))
yoff += 0.01
xoff += 0.01
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
pygame.display.update()
frame_rate.tick(FPS)
在程式中,並不是直接用像素位置的座標當作取improved Perlin noise的參數,而是以間隔0.01
的格點位置當成呼叫pnoise2()
的參數。這麼做的原因,是因為在整數點上取得的noise都是0
,畫出來的,會是一張沒有顏色變化灰色的圖。
從這個例子可以知道,計算noise時所使用的x
、y
、z
等參數,並不一定會跟實際的座標位置有所關聯,它們就只是拿來產生noise時所用的數值而已。例如,我們要用pnoise2(x, y)
傳回的noise值來設定(2, 3)這個位置上的像素顏色時,不見得就一定要設定成x=2
、y=3
。在這個例子中,我們是設定成x=0.02
、y=0.03
,但事實上是設定成任何數字都可以,關鍵在於參數點之間的間隔大小。參數點的間隔越小,算出來的noise就越平滑;參數點的間隔越大,算出來的noise就會越像亂數,至於要用多大的參數點間隔,那就完全取決於是不是能達到我們想要的目的而定。
總之,x
、y
、z
就只是x
、y
、z
,它們不是專指長、寬、高,也不是專指經度、緯度、海拔高度或任何其他東西。只要你喜歡,它們可以代表任何東西,只要能製造出想要達到的效果就可以。
Exercise 0.8
改變參數點間隔大小會有不同的效果
xoff += 0.03
yoff += 0.02
xoff += 0.01
yoff += 0.1
改變顏色
xoff += 0.01
yoff += 0.1
r = g = int(bright)
b = 255 - r
Exercise 0.9
新增第三個參數zoff
,間隔0.38
,原來的兩個參數xoff
和yoff
,間隔分別為0.21
和0.39
。圖案會隨著zoff
的改變而跟著改變,取大一些的間隔,比較能看出來圖案的變化。下面是其中兩張圖案截圖:
完整程式如下:
# python version 3.10.9
import sys
import pygame # version 2.3.0
import noise # version 1.2.2
import numpy # version 1.23.5
pygame.init()
pygame.display.set_caption("Exercise 0.9: xoff+=0.21, yoff+=0.39, zoff+=0.38")
screen_size = WIDTH, HEIGHT = 640, 360
screen = pygame.display.set_mode(screen_size)
FPS = 60
frame_rate = pygame.time.Clock()
zoff = 0
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
xoff = 0
for x in range(WIDTH):
yoff = 0
for y in range(HEIGHT):
#bright = numpy.interp(noise.pnoise3(xoff, yoff, zoff), [-1, 1], [0, 255])
bright = 127.5*(noise.pnoise3(xoff, yoff, zoff) + 1)
r = g = b = int(bright)
screen.set_at((x, y), (r, g, b))
yoff += 0.39
xoff += 0.21
zoff += 0.38
pygame.display.update()
frame_rate.tick(FPS)
為了加快計算速度,原本用interp()
來將noise映射成像素亮度,現在改成直接用先前推導出的公式來計算。將
a = -1
b = 1
x = 0
y = 255
代入後,得到
q = 127.5(p+1)
所以程式
bright = numpy.interp(noise.pnoise3(xoff, yoff, zoff), [-1, 1], [0, 255])
就改成
bright = 127.5*(noise.pnoise3(xoff, yoff, zoff) + 1)
Exercise 0.10
因為pygame沒有支援3D繪圖功能,所以用matplotlib來畫。完整程式如下:
# python version 3.10.9
import sys
# matplotlib version 3.7.1
import matplotlib.pyplot as plt
from matplotlib import cm
from mpl_toolkits import mplot3d
import noise # version 1.2.2
import numpy as np # version 1.23.5
xoff = 0.03
yoff = 0.03
x= np.arange(0, 5, xoff)
y= np.arange(0, 5, yoff)
X, Y = np.meshgrid(x, y)
nr, nc = X.shape
Z = np.zeros((nr, nc))
elevation = 50
for i in range(nr):
for j in range(nc):
Z[i][j] = np.interp(noise.pnoise2(X[i][j], Y[i][j]), [-1, 1], [0, elevation])
fig = plt.figure(figsize =(14, 9))
ax = plt.axes(projection ='3d')
ax.set_axis_off()
ax.set_title("Exercise 0.10", y=0.85)
ax.set_box_aspect((3, 3, 0.5))
ax.plot_surface(X, Y, Z, cmap = cm.gist_earth, edgecolors='w', linewidth=0.2)
plt.show()
要畫3D的圖,重點在於參數X
、Y
、Z
裡頭,資料是怎麼擺放的。假設在xy平面上的格點座標如下:
(0, 5) (1, 5) (2, 5)
(0, 6) (1, 6) (2, 6)
(0, 7) (1, 7) (2, 7)
(0, 8) (1, 8) (2, 8)
則X
裡頭放的,應該是
0, 1, 2
0, 1, 2
0, 1, 2
0, 1, 2
而Y
裡頭放的,則是
5, 5, 5
6, 6, 6
7, 7, 7
8, 8, 8
至於Z
,假設Z=X*Y
,則Z
裡頭放的資料是
0, 5, 10
0, 5, 12
0, 5, 14
0, 5, 16
也就是說,X
裡頭放的,就是格點座標中的x值;而Y
裡頭放的,則是格點座標中的y值;至於於Z
裡頭放的,就是X和Y
同樣位置的元素所對應的z值,也就是
Z[i][j] = X[i][j]*Y[i][j]
要把資料正確地放進X
、Y
,比較簡便的方式是使用numpy的meshgrid()
,不過Z
就沒這麼好處理了,因為不見得能像Z=X*Y
這樣,直接以X
、Y
透過array運算算出來。就以Perlin noise的計算來說,並不支援array的運算,所以必須搞清楚X
、Y
、Z
裡頭的資料擺放方式,然後一個元素一個元素的把Z
給算出來。
Perlin noise跟亂數之間的差異比較圖繪製程式
# python version 3.10.9
import random
import matplotlib.pyplot as plt # version # 3.7.1
import noise # version 1.2.2
plt.figure(figsize=(10, 3))
# Perlin noise values
x, y = [], []
for i in range(5001):
x.append(20+i*0.001)
y.append(noise.pnoise1(x[i]))
plt.subplot(1, 2, 1)
plt.xticks([])
plt.yticks([])
plt.xlim([20, 25])
plt.plot(x, y)
plt.title("Perlin noise values over time")
# random noise values
x, y = [], []
for i in range(251):
x.append(i)
y.append(random.random())
plt.subplot(1, 2, 2)
plt.xticks([])
plt.yticks([])
plt.xlim([0, 250])
plt.plot(x, y)
plt.title("Random noise values over time")
plt.show()
不同參數範圍Perlin noise比較圖繪製程式
# python version 3.10.9
import random
import matplotlib.pyplot as plt # version # 3.7.1
import noise # version 1.2.2
plt.figure(figsize=(10, 3))
plt.suptitle("Different parts of noise space")
# left part
x, y = [], []
for i in range(5001):
x.append(i*0.01)
y.append(noise.pnoise1(x[i]))
plt.subplot(1, 2, 1)
plt.xlim([0, 10])
plt.ylim([-1, 1])
plt.plot(x, y)
# right part
x, y = [], []
for i in range(5001):
x.append(10000+i*0.01)
y.append(noise.pnoise1(x[i]))
plt.subplot(1, 2, 2)
plt.xlim([10000, 10010])
plt.ylim([-1, 1])
plt.plot(x, y)
plt.show()