少年玩泥巴嗎?——用Unity復(fù)刻《一起玩陶藝》

作者:沈琰
本篇難度:★★★☆☆
前言
職業(yè)標(biāo)題黨又來(lái)寫(xiě)文章了。

先簡(jiǎn)單的介紹一下這期的主題:《一起玩陶藝(Let's Create! Pottery)》。
這是一個(gè)有點(diǎn)古老的休閑手游,從最低支持的安卓版本來(lái)看大約是10年前的游戲了,不過(guò)最近IOS版本還有更新。

游戲的玩法也很簡(jiǎn)單,就如同名字一樣,通過(guò)觸屏操作模擬塑造一個(gè)陶藝品的過(guò)程:

這個(gè)游戲有著手游剛剛興起那個(gè)年代游戲的特點(diǎn),即操作方式簡(jiǎn)單且極為貼合手機(jī)觸屏操作這個(gè)模式。憶往昔,那個(gè)年代不少這樣的優(yōu)秀游戲,如《水果忍者》、《無(wú)盡之劍》、《憤怒的小鳥(niǎo)》等等。
而現(xiàn)在手機(jī)游戲操作越來(lái)越趨于復(fù)雜,內(nèi)容變多了,但是反而有點(diǎn)漸漸失去了手游一開(kāi)始的簡(jiǎn)單樂(lè)趣。
扯遠(yuǎn)了,這期我們就來(lái)嘗試用Unity簡(jiǎn)單復(fù)刻一下這個(gè)游戲。
實(shí)現(xiàn)方法的猜想
看到這個(gè)能隨意變化的陶罐,第一反應(yīng)就是與Mesh脫不了關(guān)系。
我們?cè)谥暗奈恼吕飮L試過(guò)計(jì)算頂點(diǎn)構(gòu)建一個(gè)自定義的Mesh模型。而這一次我們需要更進(jìn)一步,要在游戲運(yùn)行時(shí)動(dòng)態(tài)改變Mesh的頂點(diǎn)的坐標(biāo),實(shí)現(xiàn)模型形狀的變化。
根據(jù)游戲里的表現(xiàn)形式來(lái)看,首先我們需要用代碼構(gòu)建陶罐的原型:一個(gè)中間鏤空的圓柱體,然后要在運(yùn)行中改變這個(gè)Mesh的形狀。
由游戲截圖可以看出這個(gè)變化的規(guī)律:所有頂點(diǎn)的移動(dòng)相對(duì)于物體自身的Y軸的變化都是對(duì)稱(chēng)的。換句話(huà)說(shuō)Mesh的基本結(jié)構(gòu)應(yīng)該是一層層的環(huán)狀結(jié)構(gòu),每個(gè)頂點(diǎn)移動(dòng)時(shí)相對(duì)于自己所在的高度的物體原點(diǎn)的距離都是一樣的。

實(shí)現(xiàn)過(guò)程
1.Mesh構(gòu)建
到這里我們就可以開(kāi)始動(dòng)手了。這一步對(duì)于之前有過(guò)Mesh編程經(jīng)驗(yàn)的同學(xué)來(lái)說(shuō)并不難。
對(duì)這個(gè)地方有疑問(wèn)的同學(xué)可以先看一看上一期:https://zhuanlan.zhihu.com/p/38546161
不過(guò)在構(gòu)建之前還有一點(diǎn)要稍微注意一下:一開(kāi)始就需要想好每相鄰的兩層之間的頂點(diǎn)是否是公用的,因?yàn)檫@關(guān)系到后面法線(xiàn)的計(jì)算。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer), typeof(MeshCollider))]
public class Potteryprototype : MonoBehaviour
{
MeshFilter meshFilter;
MeshRenderer meshRenderer;
MeshCollider meshCollider;
Mesh mesh;
public int details = 40;
public int layer = 20;
public float Height = 0.1f;
public float OuterRadius = 1.0f;
public float InnerRadius = 0.9f;
List<Vector3> vertices;
List<Vector2> UV;
List<int> triangles;
float EachAngle ;
int SideCount;
public MouseControl mouse;
void Start()
{
meshFilter = GetComponent<MeshFilter>();
meshCollider = GetComponent<MeshCollider>();
meshRenderer = GetComponent<MeshRenderer>();
}
[ContextMenu("GeneratePottery")]
void GeneratePrototype()
{
vertices = new List<Vector3>();
triangles = new List<int>();
UV = new List<Vector2>();
EachAngle = Mathf.PI * 2 / details;
for (int i = 0; i < layer; i++)
{
GenerateCircle(i);
}
Capping();
mesh = new Mesh();
mesh.vertices = vertices.ToArray();
mesh.triangles = triangles.ToArray();
mesh.uv = UV.ToArray();
mesh.RecalculateBounds();
mesh.RecalculateTangents();
meshFilter.mesh = mesh;
mesh.RecalculateNormals();
meshCollider.sharedMesh = mesh;
}
void GenerateCircle(int _layer)
{
//外頂點(diǎn)與內(nèi)頂點(diǎn)分開(kāi)存儲(chǔ),方便變化操作時(shí)的計(jì)算
List<Vector3> vertices_outside = new List<Vector3>();
List<Vector3> vertices_inside = new List<Vector3>();
List<Vector2> UV_outside = new List<Vector2>();
List<Vector2> UV_inside = new List<Vector2>();
//外側(cè)和內(nèi)側(cè)頂點(diǎn)計(jì)算
//注意這里讓每一圈的首尾重合了,也就是開(kāi)始和結(jié)尾的頂點(diǎn)坐標(biāo)一致
//目的是計(jì)算UV坐標(biāo)時(shí)不會(huì)出現(xiàn)空缺
for (float i = 0; i <= Mathf.PI * 2+EachAngle; i += EachAngle)
{
Vector3 v1 = new Vector3(OuterRadius * Mathf.Sin(i), _layer * Height, OuterRadius * Mathf.Cos(i));
Vector3 v2 = new Vector3(OuterRadius * Mathf.Sin(i), (_layer +1)* Height, OuterRadius * Mathf.Cos(i));
Vector3 v3 = new Vector3(InnerRadius * Mathf.Sin(i), _layer * Height, InnerRadius * Mathf.Cos(i));
Vector3 v4 = new Vector3(InnerRadius * Mathf.Sin(i), (_layer+1) * Height, InnerRadius * Mathf.Cos(i));
vertices_outside.Add(v1); vertices_outside.Add(v2);
vertices_inside.Add(v3); vertices_inside.Add(v4);
Vector2 uv1 = new Vector2(i / Mathf.PI*2, _layer*1.0f / layer * 1.0f);
Vector2 uv2 = new Vector2(i / Mathf.PI*2, (_layer + 1)*1.0f / layer * 1.0f);
Vector2 uv3 = new Vector2(i / Mathf.PI*2, _layer*1.0f / layer * 1.0f);
Vector2 uv4 = new Vector2(i / Mathf.PI*2, (_layer + 1) *1.0f/ layer * 1.0f);
UV_outside.Add(uv1); UV_outside.Add(uv2);
UV_inside.Add(uv3); UV_inside.Add(uv4);
}
vertices.AddRange(vertices_outside);
vertices.AddRange(vertices_inside);
UV.AddRange(UV_outside);
UV.AddRange(UV_inside);
SideCount = vertices_outside.Count;
int j = vertices_outside.Count * _layer * 2;
int n = vertices_outside.Count;
for (int i = j; i < j + vertices_outside.Count - 2; i += 2)
{
triangles.Add(i); triangles.Add(i + 2); triangles.Add(i + 1);
triangles.Add(i + 2); triangles.Add(i + 3); triangles.Add(i + 1);
triangles.Add(i + n); triangles.Add(i + n + 1); triangles.Add(i + n + 2);
triangles.Add(i + n + 2); triangles.Add(i + n + 1); triangles.Add(i + n + 3);
}
}
//封頂,底面由于看不見(jiàn)就不用管了
void Capping()
{
for (float i = 0; i <= Mathf.PI * 2+EachAngle; i += EachAngle)
{
Vector3 outer = new Vector3(OuterRadius * Mathf.Sin(i),layer * Height, OuterRadius * Mathf.Cos(i));
Vector3 inner= new Vector3(InnerRadius * Mathf.Sin(i), layer * Height, InnerRadius * Mathf.Cos(i));
vertices.Add(outer);vertices.Add(inner);
Vector2 uv1 = new Vector2(i / Mathf.PI * 2,0); Vector2 uv2 = new Vector2(i / Mathf.PI * 2, 1);
UV.Add(uv1); UV.Add(uv2);
}
int j = SideCount * layer * 2;
for (int i=j;i<vertices.Count-2;i+=2)
{
triangles.Add(i);triangles.Add(i + 3);triangles.Add(i + 1);
triangles.Add(i);triangles.Add(i + 2);triangles.Add(i + 3);
}
triangles.Add(vertices.Count - 2);triangles.Add(j + 1);triangles.Add(vertices.Count - 1);
triangles.Add(vertices.Count - 2);triangles.Add(j);triangles.Add(j + 1);
}
}
生成模型的網(wǎng)格長(zhǎng)這樣:

這里的選擇是不讓頂點(diǎn)公用,也就是每一層的上下頂點(diǎn)與相鄰層的并不是同一個(gè)頂點(diǎn),但是坐標(biāo)相同。這種情況下調(diào)用mesh.RecalculateNormals()自動(dòng)生成法線(xiàn)時(shí),每?jī)蓚€(gè)相同坐標(biāo)的頂點(diǎn)會(huì)有少許偏差,所以模型會(huì)有明顯的分層的感覺(jué)。

這樣的好處是每一層獨(dú)立計(jì)算三角形順序,在代碼構(gòu)成上比較方便,相應(yīng)的到后面計(jì)算法線(xiàn)平均化時(shí)會(huì)稍微麻煩一點(diǎn)。
2.動(dòng)態(tài)改變形狀
現(xiàn)在有了模型,我們進(jìn)行下一步。
先來(lái)整理一下思路,根據(jù)之前猜想的實(shí)現(xiàn)方式可知:
1.模型中相同高度的頂點(diǎn)移動(dòng)時(shí)到自身Y軸的距離是相同的。
2.不同高度的頂點(diǎn)移動(dòng)時(shí)其移動(dòng)的相對(duì)方向是相同的,不同的是移動(dòng)的距離。
我們把這個(gè)問(wèn)題轉(zhuǎn)化成需求:
1.獲得觸碰點(diǎn)(在這里就是鼠標(biāo)在屏幕上的坐標(biāo))投影到模型上的坐標(biāo)。
2.把這個(gè)坐標(biāo)點(diǎn)轉(zhuǎn)化到模型自身的坐標(biāo)系后,取其Y值。
3.遍歷模型中的每一個(gè)頂點(diǎn),根據(jù)頂點(diǎn)的Y值與之前求得的Y值之間的相對(duì)距離計(jì)算出頂點(diǎn)位移長(zhǎng)度。計(jì)算出來(lái)的長(zhǎng)度應(yīng)該與鼠標(biāo)投影到模型上的坐標(biāo)的Y值和當(dāng)前頂點(diǎn)的Y值成曲線(xiàn)關(guān)系。
在所知的函數(shù)圖像中,我們發(fā)現(xiàn)余弦函數(shù)比較符合游戲原型的曲線(xiàn)變化形式:
//這個(gè)函數(shù)放在Update()里調(diào)用
void GetMouseControlTransform()
{
//從屏幕鼠標(biāo)位置發(fā)射一條射線(xiàn)到模型上,獲取這個(gè)坐標(biāo)
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit info;
if (Physics.Raycast(ray.origin, ray.direction, out info))
{
//在Unity中無(wú)法直接修改MeshFilter中Mesh的信息,需要新建一個(gè)Mesh修改其引用關(guān)系
Mesh mesh = meshFilter.mesh;
Vector3[] _vertices = mesh.vertices;
for (int i = 0; i < _vertices.Length; i++)
{
//x,z平面變換
//頂點(diǎn)移動(dòng)與Y值的關(guān)系限制在5倍單層高度
//這里可以自行修改,限制高度越大,曲線(xiàn)越平滑
if (Mathf.Abs(info.point.y - transform.TransformPoint(_vertices[i]).y) < (5 * Height))
{
//計(jì)算頂點(diǎn)移動(dòng)方向的向量
Vector3 v_xz = (transform.TransformPoint(_vertices[i]) - new Vector3(transform.position.x, transform.TransformPoint(_vertices[i]).y, transform.position.z));
//外頂點(diǎn)與內(nèi)頂點(diǎn)移動(dòng)時(shí)相對(duì)距離應(yīng)該保持不變
//因?yàn)槲覀冎理旤c(diǎn)數(shù)組內(nèi)的順序關(guān)系,所以可以通過(guò)計(jì)算總頂點(diǎn)數(shù)除以每層單側(cè)頂點(diǎn)數(shù)的商的奇偶關(guān)系來(lái)判斷是外頂點(diǎn)還是內(nèi)頂點(diǎn)
int n = i / SideCount;
bool side = n % 2 == 0;
//判斷頂面頂點(diǎn)內(nèi)外關(guān)系
bool caps = (i - (SideCount * layer * 2)) % 2 == 0;
//限制每個(gè)頂點(diǎn)最大和最小的移動(dòng)距離
float max;
float min;
if (i < SideCount * layer * 2)
{
max = side ? 2f * OuterRadius : 2f * OuterRadius - (OuterRadius - InnerRadius);
min = side ? 0.5f * OuterRadius : 0.5f * OuterRadius - (OuterRadius - InnerRadius);
}
else
{
max = caps ? 2f * OuterRadius : 2f * OuterRadius - (OuterRadius - InnerRadius); ;
min = caps ? 0.5f * OuterRadius : 0.5f * OuterRadius - (OuterRadius - InnerRadius);
}
//計(jì)算當(dāng)前頂點(diǎn)到鼠標(biāo)Y值之間的距離,再用余弦函數(shù)算出實(shí)際位移距離
float dif = Mathf.Abs(info.point.y - transform.TransformPoint(_vertices[i]).y);
if (Input.GetKey(KeyCode.RightArrow))
{
float outer = max - v_xz.magnitude;
_vertices[i] += v_xz.normalized * Mathf.Min(0.01f * Mathf.Cos(((dif / 5 * Height) * Mathf.PI) / 2), outer);
}
else if (Input.GetKey(KeyCode.LeftArrow))
{
float inner = v_xz.magnitude - min;
_vertices[i] -= v_xz.normalized * Mathf.Min(0.01f * Mathf.Cos(((dif / 5 * Height) * Mathf.PI) / 2), inner);
}
//Y軸變換
float scale_y = transform.localScale.y;
if (Input.GetKey(KeyCode.UpArrow))
{
scale_y = Mathf.Min(transform.localScale.y + 0.000001f, 2.0f);
}
else if (Input.GetKey(KeyCode.DownArrow))
{
scale_y = Mathf.Max(transform.localScale.y - 0.000001f, 0.3f);
}
transform.localScale = new Vector3(transform.localScale.x, scale_y, transform.localScale.z);
}
mesh.vertices = _vertices;
mesh.RecalculateBounds();
mesh.RecalculateNormals();
meshFilter.mesh = mesh;
meshCollider.sharedMesh = mesh;
}
}
}
}
這一段代碼可能看起來(lái)有點(diǎn)亂,究其根本是因?yàn)槲覀冃枰陧旤c(diǎn)數(shù)組中找到每個(gè)頂點(diǎn)的相對(duì)關(guān)系,即它到底是外側(cè)頂點(diǎn)還是內(nèi)側(cè)頂點(diǎn)。
所幸整個(gè)模型是我們自己計(jì)算出來(lái)的,我們知道頂點(diǎn)的下標(biāo)與其位置的對(duì)應(yīng)關(guān)系。
同學(xué)們自己動(dòng)手復(fù)刻時(shí)不必完全照搬,對(duì)應(yīng)關(guān)系心里有數(shù)或者用另外一個(gè)容器轉(zhuǎn)換一道也行。
至于頂點(diǎn)在Y軸方向移動(dòng)計(jì)算就很簡(jiǎn)單了,因?yàn)槊恳粚禹旤c(diǎn)相對(duì)高度不變,其實(shí)就是在計(jì)算整個(gè)模型在Y軸方向的縮放。因此直接修改transform.localScale.y的值即可。
運(yùn)行效果如下:

是不是有那么點(diǎn)像了?
不過(guò)還沒(méi)完,當(dāng)我們想仿照游戲原型,讓模型沿自身的Y軸旋轉(zhuǎn)時(shí)再來(lái)控制形狀變化時(shí)會(huì)出現(xiàn)問(wèn)題:

因?yàn)镚IF截圖的精度問(wèn)題可能旋轉(zhuǎn)看起來(lái)不太明顯,在場(chǎng)景中把顯示模式切換成ShadedWireFrame模式后明顯看到相鄰層的頂點(diǎn)相對(duì)坐標(biāo)的XZ值偏移了。
原因是當(dāng)模型旋轉(zhuǎn)時(shí)我們計(jì)算出的頂點(diǎn)XZ平面的移動(dòng)向量也發(fā)生了旋轉(zhuǎn),頂點(diǎn)的坐標(biāo)值是相對(duì)于物體本身的坐標(biāo),但是在計(jì)算時(shí)會(huì)默認(rèn)轉(zhuǎn)換成世界坐標(biāo)。
要修改也好辦:
//計(jì)算時(shí)就把頂點(diǎn)坐標(biāo)系轉(zhuǎn)換為自身坐標(biāo)系,求得向量后再轉(zhuǎn)換為世界坐標(biāo)系
Vector3 v_xz = transform.TransformDirection(transform.InverseTransformPoint(_vertices[i]) - transform.InverseTransformPoint(new Vector3(0, _vertices[i].y, 0)));
把計(jì)算頂點(diǎn)位移向量的值修改為如上代碼, 改好以后控制模型的變化就如同我們預(yù)期的一樣:

3.法線(xiàn)平均化
先大致說(shuō)說(shuō)什么是法線(xiàn)。
簡(jiǎn)單點(diǎn)來(lái)說(shuō)就是光線(xiàn)到達(dá)物體表面反射的對(duì)稱(chēng)軸。Unity中的3D物體會(huì)有立體的感覺(jué)是因?yàn)槲矬w的每個(gè)面的法線(xiàn)不同,產(chǎn)生的光影效果勾勒出了物體的輪廓。
舉個(gè)例子,先在Unity中新建兩個(gè)一樣的Cube:

然后把右邊cube的法線(xiàn)全部賦值為相同的方向:

可以看見(jiàn)形狀并沒(méi)有變化,但是你很難再分辨出來(lái)具體是什么形狀了。
那么現(xiàn)在回到項(xiàng)目中來(lái),我們計(jì)算法線(xiàn)是為了什么目的呢?
前面說(shuō)過(guò)因?yàn)闃?gòu)建模型時(shí)相鄰頂點(diǎn)沒(méi)有共用,所以模型會(huì)呈現(xiàn)一個(gè)層層堆疊的感覺(jué)。但我們最終要的效果是看起來(lái)像一個(gè)花瓶一樣光滑的感覺(jué)。
IEnumerator Print_Normals()
{
for (int i = 0; i < meshFilter.mesh.vertices.Length; i++)
{
if (i % 2 == 0)
{
Debug.DrawRay(transform.TransformPoint(meshFilter.mesh.vertices[i]), transform.TransformDirection(meshFilter.mesh.normals[i] * 0.3f), Color.green, 1000f);
}
else
{
Debug.DrawRay(transform.TransformPoint(meshFilter.mesh.vertices[i]), transform.TransformDirection(meshFilter.mesh.normals[i] * 0.3f), Color.blue, 1000f);
}
yield return new WaitForSeconds(Time.deltaTime);
}
}
我們用協(xié)程把每個(gè)頂點(diǎn)上的法線(xiàn)顯示出來(lái)后就能看到問(wèn)題所在了:

每一層的上下頂點(diǎn)之間特意分別用藍(lán)綠不同顏色表示,可以看到雖然頂點(diǎn)的相對(duì)坐標(biāo)相同,但是法線(xiàn)方向卻有偏差。那么現(xiàn)在需求就出來(lái)了:我們需要讓每?jī)蓚€(gè)相對(duì)位置相同的頂點(diǎn)的法線(xiàn)相同,并且這個(gè)法線(xiàn)的方向是這兩個(gè)頂點(diǎn)法線(xiàn)方向的平均值。
但具體該怎么計(jì)算呢?還是先整理下思路:

首先分別在水平和垂直兩個(gè)方向分別叉乘兩個(gè)法線(xiàn)得到轉(zhuǎn)軸,然后計(jì)算兩個(gè)方向分量的角度,接著計(jì)算出兩條法線(xiàn)轉(zhuǎn)向平均值的夾角,最后讓法線(xiàn)沿著這個(gè)軸轉(zhuǎn)到計(jì)算出的角度,對(duì)不對(duì)?

錯(cuò)了!
恭喜,你們被我?guī)侠锪恕S?jì)算法線(xiàn)平均值沒(méi)有這么麻煩,讓相同坐標(biāo)的頂點(diǎn)上的法線(xiàn)相加取模不就是了?壓根不用考慮什么軸和角度問(wèn)題。

好吧,其實(shí)我不是有意要皮這么一下的,只是想順帶說(shuō)個(gè)趣事:
前一段時(shí)間寫(xiě)別的程序時(shí)查到一個(gè)把四元數(shù)分解成軸和角度的API后,總想著哪里有機(jī)會(huì)試著用一下。當(dāng)后面遇到這個(gè)計(jì)算法線(xiàn)的問(wèn)題時(shí),不假思索的就用上面這個(gè)方法去寫(xiě)了。頗有一種拿著錘子看什么都像釘子的感覺(jué),結(jié)果就是代碼寫(xiě)的超級(jí)復(fù)雜還算不對(duì)。當(dāng)后面一個(gè)圍觀的小伙伴提醒了我一句后,頓時(shí)心中感覺(jué)如萬(wàn)馬奔騰...
回到項(xiàng)目上來(lái)。這段法線(xiàn)計(jì)算的代碼就不放上來(lái)了,大致就是根據(jù)頂點(diǎn)在數(shù)組中的下標(biāo)去判斷位置是否相同,然后把該頂點(diǎn)的法線(xiàn)相加即可。大家自己構(gòu)建Mesh時(shí)的頂點(diǎn)順序可能會(huì)不太一樣。
最后效果如下:

從GIF上看起來(lái)不太明顯,我們還是把頂點(diǎn)的每條法線(xiàn)顯示出來(lái)看看效果:

可以看到藍(lán)綠兩根法線(xiàn)重合在了一起,雖然模型的形狀和精度沒(méi)變,但整個(gè)模型看起來(lái)有一種光滑的感覺(jué)。
表現(xiàn)效果提升
到目前為止,基本功能差不多實(shí)現(xiàn)了。現(xiàn)在可以去找點(diǎn)材質(zhì)來(lái)裝點(diǎn)一下我們的游戲。
在尋找黏土的材質(zhì)時(shí)一直找不到合適的,遂用了個(gè)取巧的辦法。
首先新建一個(gè)材質(zhì)球,把顏色調(diào)整到黏土的顏色。
然后找了張有橫向花紋的貼圖拖到Unity里,把貼圖的類(lèi)型設(shè)置為法線(xiàn)貼圖。把它設(shè)為材質(zhì)球的法線(xiàn)貼圖。

這樣會(huì)根據(jù)貼圖原來(lái)的灰度生成一張紋理圖,雖然還是一個(gè)平面,但是看起來(lái)會(huì)有凸凹不平的感覺(jué)。最終的效果如下:

結(jié)束
通過(guò)這期文章我們對(duì)Mesh的理解又稍微深入了一些,可以看到代碼本身其實(shí)并沒(méi)有多復(fù)雜,本質(zhì)上其實(shí)是一個(gè)數(shù)學(xué)問(wèn)題。
動(dòng)態(tài)改變mesh的形狀在游戲中應(yīng)用的很廣泛,比如游戲中的汽車(chē)撞到了東西,車(chē)頭會(huì)凹進(jìn)去等等。如果大家的游戲里需要加入類(lèi)似的功能不知道如何去做,而本文能幫到大家稍微整理下思路,不再毫無(wú)頭緒,那目的也就達(dá)到了。
本期文章工程地址:https://github.com/tank1018702/unity_003
想系統(tǒng)學(xué)習(xí)游戲開(kāi)發(fā)的童鞋,歡迎訪問(wèn) http://levelpp.com/
游戲開(kāi)發(fā)攪基QQ群:869551769
微信公眾號(hào):皮皮關(guān)