有玩過 Minecraft 的人,會注意到,它的地圖產生起點是透過一個 seed 來的。每張 Minecraft 地圖,都有無盡的延伸,一個地圖的 seed 會產生固定的地圖,不同的 seed 也會產生不同的地圖。這就是說,一個簡單的 seed,就能把無盡的複雜度記錄起來。利用這個特性,網路上有很多 Minecraft map 的
分享。你只要給別人seed (字串或數字),他們就能在自己的 Minecraft 裡重現一樣的地圖。
我覺得這東西對做遊戲很有用,所以找了一下,最後找到 Perlin Noise。Perlin Noise 背後的原理和實作可以見 Hugo Elias 的
"Perlin Noise" 文章。但如果你用 Unity,那它裡面就內建 Perlin Noise 了,叫
Mathf.PerlinNoise()。
底下的內容簡介 Unity 的 PerlinNoise() 的基本用法。
首先測試一下來自官網上的範例:
1 namespace TestPerlinNoise
2 {
3 using UnityEngine;
4 using UnityEngine.UI;
5 using System.Collections;
6 using System.Collections.Generic;
7 using System;
8 using Random = UnityEngine.Random;
9
10 using UnityEngine.Events;
11 using UnityEngine.EventSystems;
12
13 public class Test_Official_Sample_01 : MonoBehaviour
14 {
15 public int pixWidth;
16 public int pixHeight;
17 public float xOrg;
18 public float yOrg;
19 public float scale = 1.0F;
20 private Texture2D noiseTex;
21 private Color[] pix;
22 private Renderer rend;
23
24 void Start ()
25 {
26 rend = GetComponent<Renderer> ();
27 noiseTex = new Texture2D (pixWidth, pixHeight);
28 pix = new Color[noiseTex.width * noiseTex.height];
29 rend.material.mainTexture = noiseTex;
30 }
31
32 void CalcNoise ()
33 {
34 float y = 0.0F;
35 while (y < noiseTex.height) {
36 float x = 0.0F;
37 while (x < noiseTex.width) {
38 float xCoord = xOrg + (x / noiseTex.width) * scale;
39 float yCoord = yOrg + (y / noiseTex.height) * scale;
40 float sample = Mathf.PerlinNoise (xCoord, yCoord);
41 pix [(int)(y * noiseTex.width + x)] = new Color (sample, sample, sample);
42 x++;
43 }
44 y++;
45 }
46 noiseTex.SetPixels (pix);
47 noiseTex.Apply ();
48 }
49
50 void Update ()
51 {
52 CalcNoise ();
53 }
54 }
55 }
56
注意,如果照貼官網上的 code,會報錯誤,說 float 不能用在 int,實際上就是少一個 (int) 轉型,上面已在 line 41 中修正。
這段code會去撈 Renderer component,所以只要是看得到的任何物件都能套上來用。最簡單就是在 scene 裡建一個 Plane (3D Object->Plane):
再把這個 script 拖給它,成為 script component。不過在進入 Play Mode 前,先去 inspector 中調整 2 個參數,這樣 code 裡的 Texture 才有寬和高:
進入 Play Mode。圖看起來類似這樣:
有明有暗,但不確定看到什麼。再試幾種不同的 scale 看看:
scale = 1.0
scale = 2.0
scale = 5.0
很像在上空拍到的土地,有高低起伏。可以看出 Perlin Noise 的效果了。
回頭仔細看 Mathf.PerlinNoise() 這個 API:
public static float PerlinNoise(float x, float y);
我們往裡面填 x, y, 它傳回介於 0 和 1 之間的值。傳回值比較好理解,拿到後,看要放大幾倍就乘上多少。x, y 這兩個輸入值就比較不直覺了,我們知道那是採樣座標,不過該填多少?
同樣是拿剛剛的例子當說明:
35 while (y < noiseTex.height) {
36 float x = 0.0F;
37 while (x < noiseTex.width) {
38 float xCoord = xOrg + (x / noiseTex.width) * scale;
39 float yCoord = yOrg + (y / noiseTex.height) * scale;
40 float sample = Mathf.PerlinNoise (xCoord, yCoord);
41 pix [(int)(y * noiseTex.width + x)] = new Color (sample, sample, sample);
42 x++;
43 }
44 y++;
45 }
以 xOrg = yOrg = 0, width = height = 100, scale = 2 來說,while loop 內的兩個輸入值的範圍是這樣的:
xCoord = [0, 2.0]
yCoord = [0, 2.0]
把 scale 換成 5,兩個輸入值的範圍:
xCoord = [0, 5.0]
yCoord = [0, 5.0]
再比對圖,可以這樣想像:如果 (x, y) 的輸入值的範圍大一些(0~5),就是視野較大;值的範圍小(0~2),就是視野較小。有了這個概念,比較能掌握該輸入的範圍。譬如,假設我們要模擬衛星拍攝山嶺的照片,就不可能挑 [0, 1] 當輸入範圍,而是要挑類似 [0, 10] 當輸入範圍。
範例中還有一組 (xOrg, yOrg) 參數:
這相當於空拍時的位移。可以拿它當做 map 的 seed,因為不同的 (xOrg, yOrg),看起來的地圖就不同。
但要注意,餵給 Mathf.PerlinNoise() 的值不能太大,否則會有奇怪的問題,像把 (xOrg, yOrg) 設定成 (0, 2000000),就會開始出現精度不足的問題了:
也就是說 PerlinNoise() 內的參數,不只起終點範圍要挑對,就連大小都要留意。我們不能隨便給個類似 seed = 4535838512378634 這樣的大數字就丟進去給 PerlinNoise() 裡的 x 或 y 用,而是要轉換,轉成PerlinNoise() 可接受的合理範圍,譬如 50000.0 之類的。
我試了一下,湊出一組簡易的公式:
xOrg = (seed * 15013 + 4585787) % 50000;
yOrg = (seed * 8627 + 196051) % 50000;
有取餘數,無論如何一定不會超過 50000 了。
修改過後的code:
1 namespace TestPerlinNoise
2 {
3 using UnityEngine;
4 using UnityEngine.UI;
5 using System.Collections;
6 using System.Collections.Generic;
7 using System;
8 using Random = UnityEngine.Random;
9
10 using UnityEngine.Events;
11 using UnityEngine.EventSystems;
12
13 public class Test_by_seed: MonoBehaviour
14 {
15 public int pixWidth;
16 public int pixHeight;
17 public float scale = 1.0F;
18 private Texture2D noiseTex;
19 private Color[] pix;
20 private Renderer rend;
21
22 public long seed = 0;
23 private long lastSeed = 1;
24 private float lastXOrg;
25 private float lastYOrg;
26
27 void Start ()
28 {
29 rend = GetComponent<Renderer> ();
30 noiseTex = new Texture2D (pixWidth, pixHeight);
31 pix = new Color[noiseTex.width * noiseTex.height];
32 rend.material.mainTexture = noiseTex;
33 }
34
35 void CalcNoise (long seed )
36 {
37 float xOrg;
38 float yOrg;
39
40 if (seed != lastSeed) {
41 xOrg = (seed * 15013 + 4585787) % 50000;
42 yOrg = (seed * 8627 + 196051) % 50000;
43 } else {
44 xOrg = lastXOrg;
45 yOrg = lastYOrg;
46 }
47 lastXOrg = xOrg;
48 lastYOrg = yOrg;
49 lastSeed = seed;
50
51 float y = 0.0F;
52 while (y < noiseTex.height) {
53 float x = 0.0F;
54 while (x < noiseTex.width) {
55 float xCoord = xOrg + (x / noiseTex.width) * scale;
56 float yCoord = yOrg + (y / noiseTex.height) * scale;
57 float sample = Mathf.PerlinNoise (xCoord, yCoord);
58 pix [(int)(y * noiseTex.width + x)] = new Color (sample, sample, sample);
59 x++;
60 }
61 y++;
62 }
63 noiseTex.SetPixels (pix);
64 noiseTex.Apply ();
65 }
66
67 void Update ()
68 {
69 CalcNoise (seed);
70 }
71 }
72 }
73
在 inspector 上,會看到 xOrg 和 yOrg 合併成一個值叫 seed:
在 Update()內頻繁做重複的費時計算會拖累系統,所以我讓 xOrg 和 yOrg 的計算在有必要時才做,也就是在 inspector 中拖曳 Seed 時才會重算。
code可以在
這裡下載。