Catlike學習筆記(1.3)-使用Unity畫更復雜的3D函數圖像

第三篇來了~今天去參加了 Unite 2018 Berlin,感覺就是。。。。非常困。。。回來以後稍微睡了下清醒了覺得是時候認真學習下了,不過講的很多東西都是還沒有發佈或者只有 Preview 的版本,按照 Unity 的習慣肯定 Bug 多到令人髮指,最近不太想折騰所以就先繼續寫文章把。。按照慣例奉上『原文鏈接

PART 1 概述

首先大概介紹一下什麼是『Catlike教程』,大家自行訪問一下就會發現是這位『大神』寫的一個 Unity 系列教程,裏面由淺至深的以一個個有趣的小課題來引導大家學習 Unity 的方方面面~回想自己畢業三年都在做 Unity 遊戲開發,然而看了大神的教程以後發現自己欠缺的東西非常多~真正對引擎的掌握程度非常低只是在不停的拼 UI 寫業務邏輯。做這個系列呢也是希望自己可以堅持把大神的教程學完讓自己變得更厲害~就醬。。

那麼言歸正傳我們本期節目的最終目標是實現作者配圖中的看起來很屌的圖形,像是這樣的。。。

Animation

對比上一篇文章的函數圖像,大概有以下幾個關鍵點需要實現。

  • 支持多函數疊加
  • 從一條曲線變成一個曲面
  • 由曲面擴展成真正的三維圖形

PART 2 支持多函數疊加

首先我們的目標是可以通過一個滑桿來控制「上一篇」中的曲線顯示的函數,因此先複製之前的代碼改改名字比如 Graph3DController.cs 再修改類名與文件名一致。然後我們的關鍵是需要修改這一行

var pos = new Vector3(x, Calc(x), 0);

使其變成根據滑桿中的 int 值選擇 delegate 中的某個函數,如下所示,代碼中主要修改的地方用註釋稍微解釋了下。

// 新的 deleagate
public delegate float Function(float x, float t);

// 記得修改類名與文件名一致否則不能掛在 gameobject 上
public class Graph3DController : MonoBehaviour
{
    [Range(10, 100), SerializeField] private int _resolution;
    [SerializeField] private GameObject _cube;
    // 添加新的滑桿
    [Range(0, 1), SerializeField] private int _function;
    // 一個 delegate 數組用於保存我們接下來使用的兩個函數
    private Function[] _functions;

    ...

    // Use this for initialization
    private void Start()
    {
        // 初始化 _functions 
        _functions = new Function[] {SineFunction, MultiSineFunction};      
        ...
    }

    private void Update()
    {
        _startX = -1f;
        for (int i = 0; i < _resolution; i++)
        {
            var x = _startX + i * _step;
            // 此處修改調用方法
            var pos = new Vector3(x, _functions[_function](x, Time.time), 0);
            var point = _points[i];
            point.transform.localPosition = pos;
        }
    }

    private float SineFunction(float x, float t)
    {
        return Mathf.Sin(Mathf.PI * (x + t));
    }

    private float MultiSineFunction(float x, float t)
    {
        float y = Mathf.Sin(Mathf.PI * (x + t));
        y += Mathf.Sin(2f * Mathf.PI * (x + 2f * t)) / 2f;
        y *= 2f / 3f;
        return y;
    }
}

於是我們實現瞭如下的效果~

Animation

不過作者在原文中還添加了 Enum 然後可以不用滑桿而是改用一個下拉菜單來改變要顯示的函數圖像。最終效果沒什麼不同就不再贅述了感興趣的同學可以自行找到『原文鏈接』查看更詳細的步驟~

PART 3 畫出水滴的波紋

那麼接下來開始要真正的繪製一個3D曲面了~那麼首先是創建更多的小方塊~我們在初始化的地方改成一個二維的 List 來保存所有的小方塊

private void Start()
{
    ...
    for (int i = 0; i < _resolution; i++)
    {
        _points.Add(new List<Transform>());
        for (int j = 0; j < _resolution; j++)
        {
            var point = Instantiate(_cube, transform);
            _points[i].Add(point.transform);
            point.transform.localScale = scale;
            point.SetActive(true);
        }
    }
}

在後續的遍歷也對該二維數組進行遍歷。

private void Update()
{
    for (int i = 0; i < _points.Count; i++)
    {
        for (int j = 0; j < _points[i].Count; j++)
        {
            var posX = i * _step - 1;
            var posZ = j * _step - 1;
            var pos = new Vector3(posX, _functions[(int) _function](posX, posZ, Time.time), posZ);
            var point = _points[i][j];
            point.localPosition = pos;
        }
    }
}

最後再稍微修改下兩個函數的參數就完成了從 2D 到 3D 的跳躍~如圖所示

Animation

不過我們並不應該滿足於此,感覺這樣其實並沒有充分利用 Z 軸啊,完全就是複製了很多條曲線排在一起。所以我們新建兩個這樣的函數。

private float Sine2DFunction(float x, float z, float t)
{
    float y = Mathf.Sin(Mathf.PI * (x + t));
    y += Mathf.Sin(Mathf.PI * (z + t));
    y *= 0.5f;
    return y;
}

private float MultiSine2DFunction(float x, float z, float t)
{
    float y = 4f * Mathf.Sin(Mathf.PI * (x + z + t * 0.5f));
    y += Mathf.Sin(Mathf.PI * (x + t));
    y += Mathf.Sin(2f * Mathf.PI * (z + 2f * t)) * 0.5f;
    y *= 1f / 5.5f;
    return y;
}

那麼Sine2DFunction可以很明顯的看出是兩個完全一樣的正弦波分別沿 x 軸和 Z 軸傳播並且直接疊加,那麼第二個。。。反正很複雜語言解釋不清楚大概就是 3 個波疊加起來的,大家可以一行一行註釋掉看看效果就知道了~

那麼如何畫出一個波紋呢,首先波紋是由原點也就是(0, 0)點開始均勻擴散的,那麼可能是一個從原點向周圍擴散的正弦波。那麼直覺上來說這個函數可能長這樣。。

private float Ripple (float x, float z, float t) 
{
    float d = Mathf.Sqrt(x * x + z * z);
    float y = Mathf.Sin(Mathf.PI * (d - t));
    return y;
}

運行下會發現完全不像,主要是因爲水波在擴散的過程中是要衰減的,正弦波完全不會,因此我們需要加上衰減的控制。既然是衰減的話顯然距離越大衰減的越多嘍所以我們讓 y 除以 1 + 2 * Mathf.PI * d試一試,之所以加1是爲了防止在距離原點過於近的時候結果趨近於無窮大。所以現在代碼變成了這樣~

private float Ripple(float x, float z, float t)
{
    float d = Mathf.Sqrt(x * x + z * z);
    float y = Mathf.Sin(Mathf.PI * (d - t));
    y = y / (1 + 2 * Mathf.PI * d);
    return y;
}

跑起來看一下會發現。。。emmmm

Animation

所以我們再加上一些參數比如_velocity傳播速度,frequency水波頻率,_amplitude振幅,_attenuation衰減。代碼如下。(這些參數並不是數值越大就直觀意義上越大,雖然這樣不太好但是懶得整理了。。。大家大概意思理解就好)

private float Ripple(float x, float z, float t)
{
    float d = Mathf.Sqrt(x * x + z * z);
    float y = Mathf.Sin(_frequency * Mathf.PI * (d - t / _velocity));
    y *= 1 / (_amplitude + _attenuation * 2 * Mathf.PI * d);
    return y;
}

然後將這些參數調整到合適的值,就完成一個完美的水波了~如圖所示

Animation

PART 4 畫出三維圖形

顯然我們不能滿足於此,傳入 x 和 z 來計算出唯一的 y 導致了無法有兩個點擁有相同的 x 和 z,這極大的限制了我們的發揮~比如說畫出一個球體之類的。所以我們接下來的目標是畫出真正的三維圖形~

在開始之前,我們首先要放棄傳入 x 和 z 來計算 y 的設想,所以應該把所有的函數的返回值改成 Vector3,並且爲了區分我們將函數的參數變成 u,v,t。

public delegate Vector3 Function(float u, float v, float t);

public enum GraphFunctionName {
    Sine,
    MultiSine,
    Sine2D,
    MultiSine2D,
    Ripple,
}

public class Graph3DController : MonoBehaviour
{
    [Range(10, 100), SerializeField] private int _resolution;
    [SerializeField] private GameObject _cube;
    [SerializeField] public GraphFunctionName _function;

    [SerializeField] private float _amplitude = 3;
    [SerializeField] private float _frequency = 4;
    [SerializeField] private float _velocity = 2;
    [SerializeField] private float _attenuation = 6;

    private List<List<Transform>> _points;
    private float _step;

    private Function[] _functions;

    // Use this for initialization
    private void Start()
    {
        _functions = new Function[] {SineFunction, MultiSineFunction, Sine2DFunction, MultiSine2DFunction, Ripple};

        _cube.SetActive(false);
        _points = new List<List<Transform>>();
        _step = 2f / _resolution;

        var scale = Vector3.one * _step;

        for (int i = 0; i < _resolution; i++)
        {
            _points.Add(new List<Transform>());
            for (int j = 0; j < _resolution; j++)
            {
                var point = Instantiate(_cube, transform);
                _points[i].Add(point.transform);
                point.transform.localScale = scale;
                point.SetActive(true);
            }
        }

    }

    private void Update()
    {
        for (int i = 0; i < _points.Count; i++)
        {
            for (int j = 0; j < _points[i].Count; j++)
            {
                var u = i * _step - 1;
                var v = j * _step - 1;
                var point = _points[i][j];
                point.localPosition = _functions[(int) _function](u, v, Time.time);
            }
        }
    }

    private Vector3 SineFunction(float u, float v, float t)
    {
        var x = u;
        var y = Mathf.Sin(Mathf.PI * (u + t));
        var z = v;
        return new Vector3(x, y, z);
    }

    private Vector3 MultiSineFunction(float u, float v, float t)
    {
        var x = u;
        float y = Mathf.Sin(Mathf.PI * (u + t));
        y += Mathf.Sin(2f * Mathf.PI * (u + 2f * t)) / 2f;
        y *= 2f / 3f;
        var z = v;
        return new Vector3(x, y, z);
    }

    private Vector3 Sine2DFunction(float u, float v, float t)
    {
        var x = u;
        float y = Mathf.Sin(Mathf.PI * (u + t));
        y += Mathf.Sin(Mathf.PI * (v + t));
        y *= 0.5f;
        var z = v;
        return new Vector3(x, y, z);
    }

    private Vector3 MultiSine2DFunction(float u, float v, float t)
    {
        var x = u;
        float y = 4f * Mathf.Sin(Mathf.PI * (u + v + t * 0.5f));
        y += Mathf.Sin(Mathf.PI * (u + t));
        y += Mathf.Sin(2f * Mathf.PI * (v + 2f * t)) * 0.5f;
        y *= 1f / 5.5f;
        var z = v;
        return new Vector3(x, y, z);
    }

    private Vector3 Ripple(float u, float v, float t)
    {
        var x = u;
        float d = Mathf.Sqrt(u * u + v * v);
        float y = Mathf.Sin(_frequency * Mathf.PI * (d - t / _velocity));
        y *= 1 / (_amplitude + _attenuation * 2 * Mathf.PI * d);
        var z = v;
        return new Vector3(x, y, z);
    }
}

圓柱體

那麼如何組成一個圓柱體呢,首先我們知道圓柱體可以認爲是由許多個圓環組成的,那麼如何構成一個圓環呢?我們知道 u 的取值範圍是[-1, 1],將 u PI 即可獲得 [-PI, PI] 即剛好一個圓周的弧度,對應的座標即是`(x = sin(PI u), z = cos(PI * u))`,按照以上思路我們完成以下代碼。然後每一個點的縱座標 y 就直接取 v 的值即可形成「每個水平的圓周上有100個點,共100個圓縱向排列組成的圓柱體」了好吧感覺表述的不是特別清楚寫出來跑跑看就知道了。。。

private Vector3 Cylinder(float u, float v, float t)
{
    var x = Mathf.Sin(Mathf.PI * u);
    var y = v;
    var z = Mathf.Cos(Mathf.PI * u);
    return new Vector3(x, y, z);
}

運行一下發現果然是一個圓柱體,如果想要控制圓柱體的半徑和高直接在 x 和 z 乘以 R,y 乘以 H 即可,如下圖所示。代碼就不貼了大家都會自己乘~

Animation

那麼如何讓這個圓柱體動起來呢~比如說隨便對 R 做一些手腳像下面這樣

private Vector3 InterestingCylinder(float u, float v, float t)
{
    var r = _radius * (0.8f + Mathf.Sin(Mathf.PI * (6f * u + 2f * v + t)) * 0.2f);
    var x = r * Mathf.Sin(Mathf.PI * u);
    var y = _height * v;
    var z = r * Mathf.Cos(Mathf.PI * u);
    return new Vector3(x, y, z);
}

嘗試改變 u 和 v 的係數可以看到很多有趣的現象哦~懶得自己寫的可以打開我的「Github Repo」直接運行時修改 FactorU 和 FactorV 的值查看結果~最終我們可以達到類似這樣的效果

Animation

球體

我們在圓柱體的基礎上稍加修改就可以獲得一個球體,首先,球體跟圓柱體一樣也可以認爲是很多半徑不同的圓環組成的,那麼圓環的半徑呈現怎樣的變化呢,我們想象球體沿經線切開後,可以觀察到一圈緯線的半徑和緯線的縱座標分別對應Cos(PI / 2 * v)Sin(PI / 2 * v),按照這個思路我們嘗試寫出如下代碼。

private Vector3 Sphere(float u, float v, float t)
{
    var r = _radius * Mathf.Cos(Mathf.PI / 2 * v);
    var x = r * Mathf.Sin(Mathf.PI * u);
    var y = _radius * Mathf.Sin(Mathf.PI / 2 * v);
    var z = r * Mathf.Cos(Mathf.PI * u);
    return new Vector3(x, y, z);
}

運行一下發現完全沒有問題~如圖所示。。。

Animation

所以想要讓球體動起來我們可以使用同樣地思路對 r 的計算進行一點點魔改,比如說這樣的一個參數factor

private Vector3 InterestingSphere(float u, float v, float t)
{
    var factor = 0.8f + Mathf.Sin(Mathf.PI * (_factorU * u + t)) * 0.1f;
    factor += Mathf.Sin(Mathf.PI * (_factorV * v + t)) * 0.1f;
    var r = factor * _radius * Mathf.Cos(Mathf.PI / 2 * v);
    ...
}

調一些奇怪的參數。。。然後就出現了一坨嚅動的,。。球體。。。

Animation

圓環體

那麼想象下一個圓環體和球體到底有什麼區別呢,針對每左半條或者右半條經線圈,如果直接變成一個環,那麼球體不就變成圓環了麼。。。那麼怎麼變成圓環呢,我們之前提到

一圈緯線的半徑和緯線的縱座標分別對應Cos(PI / 2 * v)和`Sin(PI / 2 * v)

所以我們把半個週期的 cos 和 sin 變成完整週期就可以了,不要除以 2 就好。。於是我們嘗試着寫下如下代碼

private Vector3 Torus(float u, float v, float t)
{
    var r = _radius * Mathf.Cos(Mathf.PI * v);
    var x = r * Mathf.Sin(Mathf.PI * u);
    var y = _radius * Mathf.Sin(Mathf.PI * v);
    var z = r * Mathf.Cos(Mathf.PI * u);
    return new Vector3(x, y, z);
}

運行一下發現還是球體啊。。這是爲什麼呢,仔細觀察發現似乎小方塊比以前稀疏了,是因爲半條經線被擴展到整個週期以後變成了一整圈經線,所以和對面的那半條完全重疊了。。所以怎麼解決這個問題呢?就是擴大緯線圈讓相對的兩個半條經線不會相互重疊甚至完全分離就可以了。所以這樣修改下試試

private Vector3 Torus(float u, float v, float t)
{
    var r = _radius * Mathf.Cos(Mathf.PI * v) + _radius2;
    ...
}

這裏之所以是加一個_radius2在最外面是爲了達到「無論 v 如何變化都可以是的半徑無條件增加 _radius2」的效果。。。運行下會發現嗯果然沒問題了。。

Animation

所以最後也順便讓它動起來吧。。。

Animation

PART 5 總結

好吧這篇真的好長,而且寫的好累並且在公式功能壞掉的情況下又很難講清楚~大家把「Github Repo」下載下來自己運行稍微修改下就很容易理解了~總之我們把簡單的圖像擴展到了三維的圖形的過程還是很有趣的~雖然不知道暫時有什麼用處不過對於培養數學思維也還是挺有幫助的~好吧希望下一篇早日更新~就醬。。。


原文鏈接:https://snatix.com/2018/06/20/021-mathematical-surfaces/

本文由 sNatic 發佈於『大喵的新窩』 轉載請保留本申明

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章