2024-06-13|閱讀時間 ‧ 約 49 分鐘

The Nature of Code閱讀心得與Python實作:0.6 A Smoother Approach...

這一節的標題是
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

這些函數除了xyzw等參數外,還有一些有預設值的參數可以調整。

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)

所得到的ab,是兩個不同的數值;但是xy卻會是相同的數值。如果想取得不同數值的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 Ranges

在取亂數時,我們會知道取出的亂數,是落在哪個範圍內。那從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()這個方法內,用兩個變數txty來取得不同的Perlin noise,以便將其映射為walker的實際位置。特別要注意的是,在__init__()中設定的txty起始值並不相同,而且差距很大,一個是0,一個是10000。這麼做的目的,是為了讓取得的兩個Perlin noise數列,彼此之間看起來沒什麼關聯性。從下面這張不同參數範圍Perlin noise比較圖(繪製程式見本節文末),就可以看出來這麼做的效果:

除了起始值之外,txty的值也需要注意。在step()中的寫法

tx += 0.01
ty += 0.01

會導致txty每隔一定的間隔,剛好都是整數。但是如先前提到過的,在整數點上取到的noise值,往往會是0。所以這樣子寫法所取出的兩個noise,每隔一定的間隔,就會同時為0,而導致本來希望不相干的兩組noise,看起來有些關聯性。這個現象,可以從Example 0.6所畫出的walker運動軌跡中看出來。在圖中,軌跡經常會通過畫面的中心位置。這個位置就是,當分別由txty處所取得的兩個noise值,剛好都是0時,映射到xy座標後的位置。

要避免txty同時為整數的情況發生,稍微調整一下它們改變的間隔大小就可以。例如改為

tx += 0.009
ty += 0.011

walker的運動軌跡就會變成

很明顯的,通過中心點的軌跡變少了。

當然啦!txty的起始值不要用整數,也是一個可行的辦法。總之,就是要避免在整數點上取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怎麼樣都跨不過去。使用不同的txty間隔和起始值,也會有一樣的現象。推測這應該跟improved Perlin noise的曲線長相有關。以積分的觀點來看,似乎improved Perlin noise曲線底下所包含的面積,有upper bound存在。

無論如何,用Perlin noise來決定walker的步伐大小,看來並不是個好主意。

Two-Dimensional Noise

到目前為止,我們用的都是一維的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時所使用的xyz等參數,並不一定會跟實際的座標位置有所關聯,它們就只是拿來產生noise時所用的數值而已。例如,我們要用pnoise2(x, y)傳回的noise值來設定(2, 3)這個位置上的像素顏色時,不見得就一定要設定成x=2y=3。在這個例子中,我們是設定成x=0.02y=0.03,但事實上是設定成任何數字都可以,關鍵在於參數點之間的間隔大小。參數點的間隔越小,算出來的noise就越平滑;參數點的間隔越大,算出來的noise就會越像亂數,至於要用多大的參數點間隔,那就完全取決於是不是能達到我們想要的目的而定。

總之,xyz就只是xyz,它們不是專指長、寬、高,也不是專指經度、緯度、海拔高度或任何其他東西。只要你喜歡,它們可以代表任何東西,只要能製造出想要達到的效果就可以。

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,原來的兩個參數xoffyoff,間隔分別為0.210.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的圖,重點在於參數XYZ裡頭,資料是怎麼擺放的。假設在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]

要把資料正確地放進XY,比較簡便的方式是使用numpy的meshgrid(),不過Z就沒這麼好處理了,因為不見得能像Z=X*Y這樣,直接以XY透過array運算算出來。就以Perlin noise的計算來說,並不支援array的運算,所以必須搞清楚XYZ裡頭的資料擺放方式,然後一個元素一個元素的把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()


分享至
成為作者繼續創作的動力吧!
Daniel Shiffman所著「The Nature of Code」一書的閱讀心得,並用python及pygame來實作範例及練習題。 原書網頁版:https://natureofcode.com/
© 2024 vocus All rights reserved.