2016年5月19日 星期四

Perlin Noise in Unity3D 01 - basic use

有玩過 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可以在這裡下載。

沒有留言:

張貼留言