1. 程式人生 > >移動端大規模草渲染的實現

移動端大規模草渲染的實現

先上最終效果:

1.用程式生成草地網格

1.1為什麼使用程式生成而不使用美術提供的網格

要在移動端進行大規模草的渲染,首先要考慮的就是效能問題。對於草的渲染,目前肯定要採取的手段是批處理。就是一次性提交所有的草體到gpu進行渲染。目前主流的批渲染有三種方式,具體如下:

1)動態批處理

這也是渲染草的最簡單的方式,只需要美術提供一根草的模型和貼圖,程式通過自己的程式碼去控制草的數量和位置,然後依賴Unity自帶的動態批處理系統,你可以看到所有的草體都是一次性提交渲染的。但我卻非常不推薦這樣的方式。因為動態批處理其實是每一幀都對這些草的網格進行動態合併,也就是說需要不停的新建一個大網格,然後去填充所有的草體資料,這樣不僅會造成大量的gc,而且草的數量過多的話,可能會把效能瓶頸轉移到草的合併過程上,這是下策。

2)靜態批處理

這和動態批處理的區別在於你並不是每一幀都去動態合併,而是事先通過設定草體為靜態的或者自己通過程式碼去合併所有的網格。一旦合併之後,就不需要再次合併了。這樣合併之後,整個草原可以進行整體的移動,但每一根草是無法單獨移動的。另外,Unity自帶的靜態合併是以SubMesh的方式進行合併的,就是說他們是拆開的一個一個小部分,好處是可以對區域性進行隱藏,但帶來的效能開銷也是需要引起重視的。總體來說會比動態批處理好上不少。

3)Gpu Instancing

這是OpenGL3.0之後的新特性,只提交一次頂點資料,然後根據不同的instancing id可以去獲得不同的屬性,從而讓每一根草都可以有不一樣的表現。如果不考慮支援OpenGL2.0的話,這種做法應該是三種裡面的最佳策略。

然而還有更好的辦法,那就是直接生成一個大網格,這個網格就是一整片草。首先這樣做不需要OpengGL3.0的支援,同時,傳輸幾萬個頂點資料對於現在的手機來說應該是小菜一碟。而且這是沒有任何合併開銷和額外記憶體開銷。重點是這樣做非常靈活,我們可以根據需要自己控制草的分佈和顏色,每次換一種草都只是重新用工具生成而已,而不需要讓美術從頭開始製作。另外由於沒有任何額外的處理,它可以達到和Gpu Instancing一樣的效能,甚至更好。

1.2怎麼在unity裡用程式生成網格

我們知道所有的模型網格其實都是由一個個三角形拼合而成的。檢視上面這個四邊形,他其實是由(0,1,3)和(0,3,2)這兩個三角形組成的。如果我們要建立這樣一個四邊形網格,其實只需要以下幾行程式碼:

//面是由頂點組成的,我們首先建立這四個頂點
Vector3[] vertices = new Vector3[4];
//頂點裡面存的是位置,我們手動對位置進行賦值
vertices[0] = new Vector3(0, 0, 0);
vertices[1] = new Vector3(1, 0, 0);
vertices[2] = new Vector3(1, 1, 0);
vertices[3] = new Vector3(1, 0, 0);      

//有了頂點之後,我們就需要用這些頂點建立三角形了,通過建立一個隊頂點陣列下標的索引,我們可以構建出兩//個三角形              
int[] triangles = new int[6];
triangles[0] = 0;
triangles[1] = 1;
triangles[2] = 2;
triangles[3] = 0;
triangles[4] = 2;
triangles[5] = 3;

//triangles每三個為一組,其中前三個值為0,1,2,根據這三個下標去頂點陣列中取值,構成了第一個三角形,//第二個三角形為0,2,3,根據這三個下標,我們可以取值得到第二個三角形。

//最後,我們呼叫幾個函式就可以實現網格的建立
Mesh mesh = new Mesh();
mesh.vertices = vertices;
mesh.triangles = triangles;
mesh.RecalculateNormals();
mesh.RecalculateBounds();

只要你明白網格的組成部分,就可以通過自己的各種組合生成複雜的網格。考慮一下草原,我們只需要建立行和列兩個迴圈,然後根據行列號算出草的位置,然後通過上述方式建立一個個片面,草原的網格就建立好了。當然,由於我們後面要實現風吹搖擺的效果,所以我們需要多建立幾個面片,也只是多幾個迴圈而已。

上圖就是我自己建立的草原網格,3w個頂點,注意,由於Unity的網格最多支援65535個頂點,如果你需要更大的草原,你可以將多個小草原進行拼接,達到你想要的效果。

1.3 alpha test還是alpha blend

手機上的由於功耗發熱等原因,導致頻寬比pc小很多,這樣就導致了我們要儘可能的篩選掉不需要渲染的片段。簡而言之就是所有的物體在提交給片段著色器處理之前,都會進行一次深度篩選。比如一根草擋住了另一根草,那麼被擋住的那根草其實是不需要被渲染的。那麼可以在早期階段就把這些片段個篩選掉。我們把這個技術叫做early-z。但是如果一個材質在片段著色器中使用alpha test,那麼在片段著色器執行之前,我們其實是不知道是否應該要篩選這些片段的,於是alpha test就會導致early-z無效。對於草來說,這是致命的,因為草其實是有大量的遮擋的,如果不進行early-z進行過濾,那麼會有很多效能的浪費。

那麼alpha blend呢,alpha blend是可以使用early-z技術了,但你為了獲得正確的渲染結果,你必須保證草要從後往前渲染。也就是說你要對這麼多的草進行排序。對於草來說,這個開銷也是巨大的。

我自己在vivo x6手機上進行測試,兩種方式都會導致巨大的效能開銷。30w面,幀率大概只有10幀左右。

既然都不行,是否還有第三種方案?

有,而且特別適合草,上面我們已經說了我們需要更多的頂點來實現風吹草動,那麼我們完全可以用多個頂點去生成一個類梯形的草網格,這樣就可以不使用任何透明畫素了。這樣就可以完美的享受early-z帶來的效能提升。

於是通過簡單的修改生成網格的程式碼,我們得到了一片沒有透貼的梯形草。放在vivo x6上,可以跑到30幀。如果不看那麼多草的話,10w面左右的時候,大概可以接近60幀。

經過上述工作,效果如下:

草肯定不會這麼均勻,我先打亂下草的分佈。感覺有點差強人意,真實的草不應該是這麼雜亂,而且打亂之後變稀疏了,就更醜了。調整下疏密度:

稍微舒服了點, 這裡覺得顏色太單一,但又不想通過額外的引數來控制顏色變化,想了一下其實可以通過頂點色或者頂點索引。頂點索引需要在shader裡多幾步計算,考慮到效能,還是先往頂點色裡塞,如果後續需要做其他東西,我覺得可以通過uv2來塞額外資料。

到這裡,想必你應該感受到自己生成草的好處了,一切都是你自己控制的,想怎麼改就怎麼改。

2.實現風場,讓草隨風擺動

2.1自然界中的各種波

自然界中有很多效果,我們可以把他們理解成波,水波,風波等等。而這些波最簡單的形式,就是我們平時所知道的三角函式,sin和cos。

當然,大自然中的波並不是如此簡單,但我們可以通過多個波形的疊加得到一個更加複雜的波形。

例如水體渲染中有名的Gerstner波:

2.2實現風波函式

自然風的波形也有大量的文章可以參考,這裡我是用了用sin和cos進行組合得到的風波效果,並配合一定隨機速進行打亂,這部分內容涉及到一些數學推倒和經驗公式,不建議大家深究,感興趣的話可以去檢視歷年gdc的論文或者圖形學關於風的專著。

//用位置資訊作為隨機種子
#ifdef GRASS_OBJECT_MODE
	float3 randCalcPos = p[0].objectSpacePos;
#else
	float3 randCalcPos = oPos;
#endif

進一步獲得xz的隨機
fixed randX = rand(randCalcPos.xz + 1000) * _Disorder * 2 - _Disorder;
fixed randZ = rand(randCalcPos.xz - 1000) * _Disorder * 2 - _Disorder;

//Random value from 2D value between 0 and 1
inline float rand(float2 co){
	return frac(sin(dot(co.xy, float2(12.9898,78.233))) * 43758.5453);
}

//If grass is looked at from the top, it should still look like grass
#ifdef GRASS_TOP_VIEW_COMPENSATION
	fixed topViewCompensation = 1 + pow(max(0, dot(viewDir, up)), 20) * 0.8;
	width *= topViewCompensation;
		
	fixed2 windDir = wind(randCalcPos, fixed2(randX, randZ) * (topViewCompensation));
#else
	fixed2 windDir = wind(randCalcPos, fixed2(randX, randZ));
#endif

inline fixed2 wind(float3 pos, fixed2 offset)
{
	float3 realPos = float3(pos.x * cos(_WindRotation) - pos.z * sin(_WindRotation), pos.y, pos.x * sin(_WindRotation) + pos.z * cos(_WindRotation));
//這個是繞原點旋轉之後的新座標,具體推倒可以參考https://blog.csdn.net/u012138730/article/details/80320162

	fixed2 windWaveStrength = _WindParams.x * sin(0.7f*windStrength(realPos)) * cos(0.15f*windStrength(realPos));
	windWaveStrength += windRipple(realPos);

	fixed2 wind = fixed2(windWaveStrength.x + offset.x, windWaveStrength.y + offset.y);

	return fixed2(wind.x * cos(_WindRotation) - wind.y * sin(_WindRotation), wind.x * sin(_WindRotation) + wind.y * cos(_WindRotation));
}

//這個風力函式比較複雜,用四個正餘弦函式弄出的一個波動效果
inline fixed windStrength(float3 pos)
{
	return pos.x + _Time.w*_WindParams.y + 5*cos(0.01f*pos.z + _Time.y*_WindParams.y * 0.2f) + 4*sin(0.05f*pos.z - _Time.y*_WindParams.y*0.15f) + 4*sin(0.2f*pos.z + _Time.y*_WindParams.y * 0.2f) + 2*cos(0.6f*pos.z - _Time.y*_WindParams.y*0.4f);
}

inline fixed windRippleStrength(float3 pos)
{
	return sin(100*pos.x + _Time.y*_WindParams.w*3 + pos.z)*cos(10*pos.x + _Time.y*_WindParams.w*2 + pos.z*0.5f);
}

inline fixed2 windRipple(float3 pos)
{
	return _WindParams.z * fixed2(windRippleStrength(pos), windRippleStrength(pos + float3(452, 0, 987)));
}
//lod是這個草要分成幾段,是曲面細分那邊的內容
for(fixed i = 1; i <= lod; i++)
{
	fixed segment = i*invLod;
	fixed sqrSegment = segment*segment;
        //segment是草的長度的百分比,pos就是最終的實際高度
	float3 pos = float3(up*segment*realHeight);

        //xz要加上風所帶來的影響,
	pos.xz += windDir.xy * sqrSegment * stiffnessFactor;
        //高度也要矯正
	pos.y  -= length(windDir) * sqrSegment * 0.5f * stiffnessFactor;



	fixed uvHeight = segment;

	viewDir = normalize(rendererPos - pos);

	fixed3 localUp = pos - lastPos;
	//Simple grass has no texture, so the mesh has to look like a blade of grass
	pIn.vertex =  float4((pos - width * groundRight * (1 - sqrSegment)).xyz, 1);
	getNormals(localUp, lightDir, groundRight, /*out*/ pIn.normal, /*out*/ pIn.reflectionNormal);

	triStream.Append(geomToFrag(pIn));

	//Simple grass has no texture, so the mesh has to look like a blade of grass
	pIn.vertex =  float4((pos + width * groundRight * (1 - sqrSegment)).xyz, 1);	
	getNormals(localUp, lightDir, groundRight, /*out*/ pIn.normal, /*out*/ pIn.reflectionNormal);
		
	triStream.Append(geomToFrag(pIn));

	lastPos = pos;
}

這裡我把草改成了紅色,自己做網格就是這麼任性自由。

這裡還是需要處理一個問題,就是草是單面的,從其他方向看就會是一條線,一般是用billboard技術,但對我這種做法卻不適用,因為billboard的重點是中心點,而我這麼一大片網格,中心點和草是沒啥關係的。於是我就換了一種做法,堆了兩層草。用十字星的形式,這樣的壞處是增加了面數,但好處也很明顯,讓整個草看上去更加自然。你可以自己拷貝一份草,然後旋轉九十度疊在一起。而我選擇了自動化的做法,生成網格後儲存成prefab的過程中,自動幫我做了這部分工作。

3.增加和草的互動

2.1互動方案設計

想了一下方案,首先草根據對應rendertexture裡的座標讀取畫素,根據畫素確定歪的方向,並且做歪曲。那麼如何得到這張rendertexture呢?

第一步是球的正面下壓,我的想法是根據不同朝向,分別轉成顏色值並顯示出來,最終渲染到rendertexture中。

shader程式碼如下:

v2f o;
float4 vertex = v.vertex;
o.vertex = UnityObjectToClipPos(v.vertex);
o.pos = mul(unity_ObjectToWorld, v.vertex);
//因為座標的範圍是[-0.5,0.5],顏色範圍是[0,1],我們需要做一個簡單的對映
float r = v.vertex.x + 0.5;
float b = v.vertex.z + 0.5;
float len = length(v.vertex.xz * 2);

o.color.r = r;
//這裡pow(xx,1)相當於沒操作,但為了以後方便調整幅度,還是保留了這個費計算
//g的作用是保留壓倒的強度,控制方向的只有r和b
o.color.g = pow(1 - len, 1);
o.color.b = b;
o.color.a = 1;
return o;

渲染結果如下:

藍色是左上方,紅色是右下方,其他方向是這些顏色的混合。我們可以通過顏色判斷來得到草應該往哪個方向倒下。

然後是運動軌跡,原理一樣,只不過需要通過運動動態建立網格,沒錯,我們又用到了動態建立網格,只不過比起上面的草的網格建立,這裡的更加複雜一點,你需要根據速度給網格頂點填充不同的顏色並顯示出來。

完整程式碼如下:

public class MoveTrail : MonoBehaviour {
    public float unit = 1f;
    public float minVertexDistance = 0.5f;
    public float lefttime = 5f;
    public Material material;
    int layer;
    Vector3 upDir = Vector3.up;
    List<TrailPoint> points = new List<TrailPoint>();
    List<Vector3> vertices = new List<Vector3>();
    List<int> triangles = new List<int>();

    List<Color> colors = new List<Color>();
    Mesh mesh;
	// Use this for initialization
	void Start () {
        layer = LayerMask.NameToLayer("Units");
        mesh = new Mesh();
	}
	
	// Update is called once per frame
	void Update () {
		while(points.Count > 0)
        {
            if(Time.time - points[0].creationTime > lefttime)
            {
                points.RemoveAt(0);
            }
            else
            {
                break;
            }
        }

        Vector3 pos = transform.position;
        bool addedPoint = false;
        if(points.Count == 0 || Vector3.Distance(points[points.Count - 1].pos, pos) > minVertexDistance)
        {
            points.Add(new TrailPoint(pos, Time.time));
            addedPoint = true;
        }

        List<TrailPoint> renderPoints = new List<TrailPoint>(points);
        if(!addedPoint)
        {
            renderPoints.Add(new TrailPoint(pos, Time.time));
        }

        if(renderPoints.Count < 2)
        {
            return;
        }

        mesh.Clear();
        if(renderPoints.Count < 2)
        {
            return;
        }

        vertices.Clear();
        triangles.Clear();
        colors.Clear();
        float uvFactor = 1f / (renderPoints.Count - 1);
        for(int i = 0; i < renderPoints.Count; i++)
        {
            TrailPoint point = renderPoints[i];
            if(i == 0)
            {
                AddPoint(point, renderPoints[i + 1].pos - point.pos, 0);
                continue;
            }

            TrailPoint lastPoint = renderPoints[i - 1];
            if(i == renderPoints.Count - 1)
            {
                AddPoint(point, point.pos - lastPoint.pos, 1f);
                break;
            }

            TrailPoint nextPoint = renderPoints[i + 1];
            AddPoint(point, nextPoint.pos - lastPoint.pos, i * uvFactor);
        }

        mesh.vertices = vertices.ToArray();
        mesh.triangles = triangles.ToArray();
        mesh.colors = colors.ToArray();
        Graphics.DrawMesh(mesh, Matrix4x4.identity, material, layer);
    }

    void AddPoint(TrailPoint point, Vector3 direction, float uv)
    {
        float lifePercent = (Time.time - point.creationTime) / lefttime;
        float halfWidth = unit;// (1 - lifePercent) * unit;
        float normalStrength = 1 - lifePercent;
        Vector2 dir = new Vector2();
        dir.x = direction.x * 0.5f + 0.5f;
        dir.y = direction.z * 0.5f + 0.5f;
        Color normalStrengthColor = new Color(dir.x, normalStrength * 0.5f, dir.y, 1f);
        Vector3 pos = point.pos;
        Vector3 right = Vector3.Cross(upDir, direction);
        vertices.Add(pos - right * halfWidth);
        vertices.Add(pos + right * halfWidth);
        colors.Add(normalStrengthColor);
        colors.Add(normalStrengthColor);
        int lastVert = vertices.Count - 1;
        if(lastVert >= 3)
        {
            triangles.Add(lastVert - 1);
            triangles.Add(lastVert);
            triangles.Add(lastVert - 2);
            triangles.Add(lastVert - 2);
            triangles.Add(lastVert - 3);
            triangles.Add(lastVert - 1);
        }
    }
}

[Serializable]
public struct TrailPoint
{
    public Vector3 pos;
    public float creationTime;

    public TrailPoint(Vector3 pos, float creationTime)
    {
        this.pos = pos;
        this.creationTime = creationTime;
    }
}

程式碼中通過球體的移動,記錄了路徑的軌跡。通過對軌跡的計算,得到顏色並賦值。中間還會根據點的生命週期調整顏色,這樣就可以實現草逐步從壓倒狀態恢復,因為是每幀都進行計算,所以恢復過程特別自然。

效果如下:

這樣rendertexture就是用攝像機從正上方去看它,並增加以下程式碼指令碼。

public class GrassCamera : MonoBehaviour {
    public Material grassMat;
    public int textureSize = 512;
    RenderTexture texture;
    // Use this for initialization
    Matrix4x4 matVP;
    Camera c;
	void Start () {
        texture = new RenderTexture(textureSize, textureSize, 0);
        c = GetComponent<Camera>();
        c.targetTexture = texture;
        matVP = GL.GetGPUProjectionMatrix(c.projectionMatrix, true) * c.worldToCameraMatrix;
        grassMat.SetMatrix("GrassMatrix", matVP);

    }
	
	// Update is called once per frame
	void Update () {
        grassMat.SetTexture("_GrassTex", texture);
        matVP = GL.GetGPUProjectionMatrix(c.projectionMatrix, true) * c.worldToCameraMatrix;
        grassMat.SetMatrix("GrassMatrix", matVP);
    }
}

這裡需要注意的是matVP矩陣。這個矩陣的作用是將草的世界座標對映到這個攝像機對應的裁剪空間座標,從而獲得草在這張rendertexture中對應的位置。

2.2矩陣和座標變換

這裡補充下矩陣的基本知識,如果要徹底解釋明白需要涉及大量的知識點,所以我會在文章後面的補充資料中列出具體的書籍供大家深入瞭解。這裡只做概念解釋。

很多時候我們需要進行座標變換,例如我們如果要知道某一個物體相對於攝像機的位置,其實我們就是要把這個物體從世界座標變換到攝像機座標。再如我們已經知道一個模型的手相對於模型的位置,但我們想知道這個模型的手相對於整個世界的位置,那麼其實我們就需要將這個手的模型座標轉到世界座標。我們可以通過一堆計算去得到這個過程,可以使用最普通的加減乘除。但是如果我們使用矩陣,那麼這個計算可能只需要一次矩陣乘法就可以了。而現代硬體對於矩陣乘法有特別的優化,導致用矩陣去計算特別快,所以對於座標變換,我們往往會優先使用矩陣去做處理。

好,然後我們看下我們所面臨的這個問題,首先我們當前攝像機正在看著一個球,這個球在草上。我們可以知道這個球的世界座標,也可以知道他相對於我們攝像機的視覺空間座標。然後球開始運動,我們在整個草的上方又放了一個攝像機,我們稱呼他為攝像機B,它拍下了球的運動過程,並且將結果繪製到一張貼圖中。現在我們要開始建立這些座標之間的聯絡,並且得到判斷草有沒有被壓住的方法。

1)我們首先拿到了一個草的頂點,我們可以把它的座標轉化到世界座標。這裡是最簡單的部分。

2)然後我們把這個世界座標轉化成攝像機B的視角座標系下的座標,這樣我們就知道了這個頂點相對於攝像機B的相對位置。

3)接下來是最難理解的部分,我們將得到的這個相對於攝像機B的座標進行投影操作,也就是通過投影矩陣變換得到了攝像機B看到這個頂點的裁剪空間座標。這裡有很多新名詞,我們可以更加簡單的說,我們通過新的一個矩陣,去做了這樣一個變換,得到了這個頂點被攝像機看到的2D影象螢幕中的位置。(具體涉及到裁剪空間座標和裝置空間座標先不考慮,因為它並不妨礙整個過程的原理)得到了攝像機B看到這個頂點座標在2D影象螢幕中的位置的時候,我們可以用這個位置去對攝像機渲染的貼圖結果進行取樣,也就是根據這個座標去取色。我們通過前面的步驟,已經做到了球運動軌跡上的路線會有各種不同的顏色,那麼我們根據得到的顏色的不同,就可以得到這個草是從哪個方向被壓倒的。

整個流程就這麼完成了。

這部分需要一些3D圖形渲染的基本知識,如果實在理解不了,可以先看我後面推薦的書籍,等了解了一些基礎知識後,再來看這個部分也不遲。

2.3解決草部分被壓的問題

實際測試的時候發現一個大問題,就是如果草只有一部分頂點被壓住,那麼草會扭曲成很難看的樣子,解決方案是把草的位置濃縮成一個點,草上面的所有頂點都用濃縮的這個點來判斷,這樣草的位置就一致了。

                    v2f vert(appdata v, uint vid : SV_VertexID)
	            {
			v2f o;
			float index = floor(vid / ((_GrassSeg + 1) * 2));
			float grid = _GrassRange / _GrassNum;
			float row = floor(index / _GrassNum);
			float col = index % _GrassNum;
                        //前面所有的計算都是為了將這個頂點所對應的點座標算出來,上面的過程是基                於我生成網格的規則,如果你用別的規則,你只需要自己調整程式碼即可
			float4 objectPos = float4(-_GrassRange / 2 + row * grid, 0, -_GrassRange / 2 + col * grid, 1);

                        //把得到的模型座標轉到世界座標,通過對世界座標轉到攝像機座標後,再對貼圖取樣,完成草被壓的資料的獲取
			float4 worldPos1 = mul(unity_ObjectToWorld, objectPos);
			float4 vertex = v.vertex;
				
				
			float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
			float3 randCalcPos = worldPos;
                        //風函式的計算
			float2 windDir = wind(randCalcPos);
				
			float4 grassuv1 = mul(GrassMatrix, worldPos1);
			float2 grassuv2 = grassuv1.xy / grassuv1.w * 0.5 + 0.5;
#if UNITY_UV_STARTS_AT_TOP
			grassuv2.y = 1 - grassuv2.y;
#endif
		        float4 n = tex2Dlod(_GrassTex, float4(grassuv2, 0, 0));
			n.xz = (n.xz - 0.5) * 2;
			float2 off = (windDir.xy) * pow(v.vertex.y * 2, 2);// +n.xyz;
			worldPos.y -= 10 * dot(off, off);
			float2 newOff = off * (1 - n.g) + normalize(n.xz) * v.vertex.y * n.g * 0.6;
			worldPos.y *= (1 - n.g);
			//off = float2(-0.3, 0) * v.vertex.y;
			worldPos.xz += newOff;
				
			o.pos = mul(UNITY_MATRIX_VP, float4(worldPos, 1));
			//o.pos = UnityObjectToClipPos(vertex);
			o.uv = v.uv;
			half3 worldNormal = UnityObjectToWorldNormal(v.normal);
			half nl = max(0, dot(worldNormal, _WorldSpaceLightPos0.xyz));
			o.diff = nl * _LightColor0;
			o.diff *= v.color;
			o.diff.rgb += +ShadeSH9(half4(worldNormal, 1));
			TRANSFER_SHADOW(o)
			return o;
		}

上面可能存在大量的魔數,你可能會覺得為什麼要用這個計算公式,其實這些都是經驗公式。我是一邊做一邊調整引數看效果,直到覺得滿意就行。圖形學有一句名言,看到是對的就是對的。不建議深究每一個公式的值,而是去理解整個方案的原理。可能你自己少幾個公式,少幾個引數,也會得到差不多的效果。

工程用的是Unity2017版本,理論上2018和5.x應該也可以使用,不過還是推薦使用2017版本開啟,避免報錯。

這是上面工程的百度網盤地址:(免費下載,但請勿在網上傳播)

連結:https://pan.baidu.com/s/1S6a5Zhx8a_KDHuOPKgblyg 提取碼:c4bk  

這是上面工程在Unity商店裡的地址:(需要付費購買)

附錄

Unity入門推薦:

官方文件目前比市面上的所有書籍都要好,比官方自己出的書籍還要好些。

如果實在看不懂英文,就去買官方書籍:

Unity 從入門到精通

Unity Shader入門推薦:

馮女神的書用來入門特別適合,她講的也特別清晰,大家也可以去她的csdn部落格看看,她的部落格地址:

圖形學入門推薦:

《3D遊戲與計算機圖形學中的數學方法》

這本是入門最佳書籍,但裡面包含一些複雜的推導。雖然你可以直接使用推導結論,但如果完全無視推導過程很多地方可能會難以理解。建議還是好好理解下,可以去複習下微積分和線性代數,再來看這本書,可能會好很多。

OpenGL入門推薦:

《OpenGL超級寶典》

因為我們主要涉及的是移動端的開發,移動端更多使用的是OpenGL,所以我們可以只學習OpenGL的渲染管線。

通過這本書的學習,你可以大致瞭解手機上,一個物體從頭到腳是怎麼在我們手機上渲染出來的。這對於你自己實現更好更棒的效果是必須的。

高階進階:

《GPU Gems》系列

《GPU Pro》系列

還有大量的其他書籍,更多的可以參考騰訊大神Milo的書單: