作者:沈琰
本篇难度:★★★☆☆
前言
职业标题党又来写文章了。
先简单的介绍一下这期的主题:《一起玩陶艺(Let's Create! Pottery)》。
这是一个有点古老的休闲手游,从最低支持的安卓版本来看大约是10年前的游戏了,不过最近IOS版本还有更新。
游戏的玩法也很简单,就如同名字一样,通过触屏操作模拟塑造一个陶艺品的过程:
这个游戏有着手游刚刚兴起那个年代游戏的特点,即操作方式简单且极为贴合手机触屏操作这个模式。忆往昔,那个年代不少这样的优秀游戏,如《水果忍者》、《无尽之剑》、《愤怒的小鸟》等等。
而现在手机游戏操作越来越趋于复杂,内容变多了,但是反而有点渐渐失去了手游一开始的简单乐趣。
扯远了,这期我们就来尝试用Unity简单复刻一下这个游戏。
实现方法的猜想
看到这个能随意变化的陶罐,第一反应就是与Mesh脱不了关系。
我们在之前的文章里尝试过计算顶点构建一个自定义的Mesh模型。而这一次我们需要更进一步,要在游戏运行时动态改变Mesh的顶点的坐标,实现模型形状的变化。
根据游戏里的表现形式来看,首先我们需要用代码构建陶罐的原型:一个中间镂空的圆柱体,然后要在运行中改变这个Mesh的形状。
由游戏截图可以看出这个变化的规律:所有顶点的移动相对于物体自身的Y轴的变化都是对称的。换句话说Mesh的基本结构应该是一层层的环状结构,每个顶点移动时相对于自己所在的高度的物体原点的距离都是一样的。
实现过程
1.Mesh构建
到这里我们就可以开始动手了。这一步对于之前有过Mesh编程经验的同学来说并不难。
对这个地方有疑问的同学可以先看一看上一期:https://zhuanlan.zhihu.com/p/38546161
不过在构建之前还有一点要稍微注意一下:一开始就需要想好每相邻的两层之间的顶点是否是公用的,因为这关系到后面法线的计算。
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)
{
//外顶点与内顶点分开存储,方便变化操作时的计算
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>();
//外侧和内侧顶点计算
//注意这里让每一圈的首尾重合了,也就是开始和结尾的顶点坐标一致
//目的是计算UV坐标时不会出现空缺
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);
}
}
//封顶,底面由于看不见就不用管了
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);
}
}
生成模型的网格长这样:
这里的选择是不让顶点公用,也就是每一层的上下顶点与相邻层的并不是同一个顶点,但是坐标相同。这种情况下调用mesh.RecalculateNormals()自动生成法线时,每两个相同坐标的顶点会有少许偏差,所以模型会有明显的分层的感觉。
这样的好处是每一层独立计算三角形顺序,在代码构成上比较方便,相应的到后面计算法线平均化时会稍微麻烦一点。
2.动态改变形状
现在有了模型,我们进行下一步。
先来整理一下思路,根据之前猜想的实现方式可知:
1.模型中相同高度的顶点移动时到自身Y轴的距离是相同的。
2.不同高度的顶点移动时其移动的相对方向是相同的,不同的是移动的距离。
我们把这个问题转化成需求:
1.获得触碰点(在这里就是鼠标在屏幕上的坐标)投影到模型上的坐标。
2.把这个坐标点转化到模型自身的坐标系后,取其Y值。
3.遍历模型中的每一个顶点,根据顶点的Y值与之前求得的Y值之间的相对距离计算出顶点位移长度。计算出来的长度应该与鼠标投影到模型上的坐标的Y值和当前顶点的Y值成曲线关系。
在所知的函数图像中,我们发现余弦函数比较符合游戏原型的曲线变化形式:
//这个函数放在Update()里调用
void GetMouseControlTransform()
{
//从屏幕鼠标位置发射一条射线到模型上,获取这个坐标
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit info;
if (Physics.Raycast(ray.origin, ray.direction, out info))
{
//在Unity中无法直接修改MeshFilter中Mesh的信息,需要新建一个Mesh修改其引用关系
Mesh mesh = meshFilter.mesh;
Vector3[] _vertices = mesh.vertices;
for (int i = 0; i < _vertices.Length; i++)
{
//x,z平面变换
//顶点移动与Y值的关系限制在5倍单层高度
//这里可以自行修改,限制高度越大,曲线越平滑
if (Mathf.Abs(info.point.y - transform.TransformPoint(_vertices[i]).y) < (5 * Height))
{
//计算顶点移动方向的向量
Vector3 v_xz = (transform.TransformPoint(_vertices[i]) - new Vector3(transform.position.x, transform.TransformPoint(_vertices[i]).y, transform.position.z));
//外顶点与内顶点移动时相对距离应该保持不变
//因为我们知道顶点数组内的顺序关系,所以可以通过计算总顶点数除以每层单侧顶点数的商的奇偶关系来判断是外顶点还是内顶点
int n = i / SideCount;
bool side = n % 2 == 0;
//判断顶面顶点内外关系
bool caps = (i - (SideCount * layer * 2)) % 2 == 0;
//限制每个顶点最大和最小的移动距离
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);
}
//计算当前顶点到鼠标Y值之间的距离,再用余弦函数算出实际位移距离
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;
}
}
}
}
这一段代码可能看起来有点乱,究其根本是因为我们需要在顶点数组中找到每个顶点的相对关系,即它到底是外侧顶点还是内侧顶点。
所幸整个模型是我们自己计算出来的,我们知道顶点的下标与其位置的对应关系。
同学们自己动手复刻时不必完全照搬,对应关系心里有数或者用另外一个容器转换一道也行。
至于顶点在Y轴方向移动计算就很简单了,因为每一层顶点相对高度不变,其实就是在计算整个模型在Y轴方向的缩放。因此直接修改transform.localScale.y的值即可。
运行效果如下:
是不是有那么点像了?
不过还没完,当我们想仿照游戏原型,让模型沿自身的Y轴旋转时再来控制形状变化时会出现问题:
因为GIF截图的精度问题可能旋转看起来不太明显,在场景中把显示模式切换成ShadedWireFrame模式后明显看到相邻层的顶点相对坐标的XZ值偏移了。
原因是当模型旋转时我们计算出的顶点XZ平面的移动向量也发生了旋转,顶点的坐标值是相对于物体本身的坐标,但是在计算时会默认转换成世界坐标。
要修改也好办:
//计算时就把顶点坐标系转换为自身坐标系,求得向量后再转换为世界坐标系
Vector3 v_xz = transform.TransformDirection(transform.InverseTransformPoint(_vertices[i]) - transform.InverseTransformPoint(new Vector3(0, _vertices[i].y, 0)));
把计算顶点位移向量的值修改为如上代码, 改好以后控制模型的变化就如同我们预期的一样:
3.法线平均化
先大致说说什么是法线。
简单点来说就是光线到达物体表面反射的对称轴。Unity中的3D物体会有立体的感觉是因为物体的每个面的法线不同,产生的光影效果勾勒出了物体的轮廓。
举个例子,先在Unity中新建两个一样的Cube:
然后把右边cube的法线全部赋值为相同的方向:
可以看见形状并没有变化,但是你很难再分辨出来具体是什么形状了。
那么现在回到项目中来,我们计算法线是为了什么目的呢?
前面说过因为构建模型时相邻顶点没有共用,所以模型会呈现一个层层堆叠的感觉。但我们最终要的效果是看起来像一个花瓶一样光滑的感觉。
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);
}
}
我们用协程把每个顶点上的法线显示出来后就能看到问题所在了:
每一层的上下顶点之间特意分别用蓝绿不同颜色表示,可以看到虽然顶点的相对坐标相同,但是法线方向却有偏差。那么现在需求就出来了:我们需要让每两个相对位置相同的顶点的法线相同,并且这个法线的方向是这两个顶点法线方向的平均值。
但具体该怎么计算呢?还是先整理下思路:
首先分别在水平和垂直两个方向分别叉乘两个法线得到转轴,然后计算两个方向分量的角度,接着计算出两条法线转向平均值的夹角,最后让法线沿着这个轴转到计算出的角度,对不对?
错了!
恭喜,你们被我带沟里了。计算法线平均值没有这么麻烦,让相同坐标的顶点上的法线相加取模不就是了?压根不用考虑什么轴和角度问题。
好吧,其实我不是有意要皮这么一下的,只是想顺带说个趣事:
前一段时间写别的程序时查到一个把四元数分解成轴和角度的API后,总想着哪里有机会试着用一下。当后面遇到这个计算法线的问题时,不假思索的就用上面这个方法去写了。颇有一种拿着锤子看什么都像钉子的感觉,结果就是代码写的超级复杂还算不对。当后面一个围观的小伙伴提醒了我一句后,顿时心中感觉如万马奔腾...
回到项目上来。这段法线计算的代码就不放上来了,大致就是根据顶点在数组中的下标去判断位置是否相同,然后把该顶点的法线相加即可。大家自己构建Mesh时的顶点顺序可能会不太一样。
最后效果如下:
从GIF上看起来不太明显,我们还是把顶点的每条法线显示出来看看效果:
可以看到蓝绿两根法线重合在了一起,虽然模型的形状和精度没变,但整个模型看起来有一种光滑的感觉。
表现效果提升
到目前为止,基本功能差不多实现了。现在可以去找点材质来装点一下我们的游戏。
在寻找黏土的材质时一直找不到合适的,遂用了个取巧的办法。
首先新建一个材质球,把颜色调整到黏土的颜色。
然后找了张有横向花纹的贴图拖到Unity里,把贴图的类型设置为法线贴图。把它设为材质球的法线贴图。
这样会根据贴图原来的灰度生成一张纹理图,虽然还是一个平面,但是看起来会有凸凹不平的感觉。最终的效果如下:
结束
通过这期文章我们对Mesh的理解又稍微深入了一些,可以看到代码本身其实并没有多复杂,本质上其实是一个数学问题。
动态改变mesh的形状在游戏中应用的很广泛,比如游戏中的汽车撞到了东西,车头会凹进去等等。如果大家的游戏里需要加入类似的功能不知道如何去做,而本文能帮到大家稍微整理下思路,不再毫无头绪,那目的也就达到了。
本期文章工程地址:https://github.com/tank1018702/unity_003
想系统学习游戏开发的童鞋,欢迎访问 http://levelpp.com/
游戏开发搅基QQ群:869551769
微信公众号:皮皮关