专栏/少年玩泥巴吗?——用Unity复刻《一起玩陶艺》

少年玩泥巴吗?——用Unity复刻《一起玩陶艺》

2018年09月17日 11:06--浏览 · --点赞 · --评论
皮皮关做游戏
粉丝:4.8万文章:256

作者:沈琰

本篇难度:★★★☆☆


前言

职业标题党又来写文章了。

先简单的介绍一下这期的主题:《一起玩陶艺(Let's Create! Pottery)》。

这是一个有点古老的休闲手游,从最低支持的安卓版本来看大约是10年前的游戏了,不过最近IOS版本还有更新。

游戏的玩法也很简单,就如同名字一样,通过触屏操作模拟塑造一个陶艺品的过程:

这个游戏有着手游刚刚兴起那个年代游戏的特点,即操作方式简单且极为贴合手机触屏操作这个模式。忆往昔,那个年代不少这样的优秀游戏,如《水果忍者》、《无尽之剑》、《愤怒的小鸟》等等。

而现在手机游戏操作越来越趋于复杂,内容变多了,但是反而有点渐渐失去了手游一开始的简单乐趣。

扯远了,这期我们就来尝试用Unity简单复刻一下这个游戏。

实现方法的猜想

看到这个能随意变化的陶罐,第一反应就是与Mesh脱不了关系。

我们在之前的文章里尝试过计算顶点构建一个自定义的Mesh模型。而这一次我们需要更进一步,要在游戏运行时动态改变Mesh的顶点的坐标,实现模型形状的变化。

根据游戏里的表现形式来看,首先我们需要用代码构建陶罐的原型:一个中间镂空的圆柱体,然后要在运行中改变这个Mesh的形状。

由游戏截图可以看出这个变化的规律:所有顶点的移动相对于物体自身的Y轴的变化都是对称的。换句话说Mesh的基本结构应该是一层层的环状结构,每个顶点移动时相对于自己所在的高度的物体原点的距离都是一样的。

从XZ的截面看也是一样的


实现过程

1.Mesh构建

到这里我们就可以开始动手了。这一步对于之前有过Mesh编程经验的同学来说并不难。

对这个地方有疑问的同学可以先看一看上一期:zhuanlan.zhihu.com/p/38

不过在构建之前还有一点要稍微注意一下:一开始就需要想好每相邻的两层之间的顶点是否是公用的,因为这关系到后面法线的计算。

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()自动生成法线时,每两个相同坐标的顶点会有少许偏差,所以模型会有明显的分层的感觉。

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      

微信公众号:皮皮关

投诉或建议

玻璃钢生产厂家水牛玻璃钢雕塑造型上海大型商场创意商业美陈哪家好深圳开业商场美陈哪里有来宾玻璃钢座椅雕塑造价介绍重庆玻璃钢雕塑卡通娃娃好的景观玻璃钢雕塑定做广场玻璃钢雕塑大连人物玻璃钢雕塑吉林创意玻璃钢雕塑玻璃钢雕塑着色用什么漆东方玻璃钢雕塑制作厂家玻璃钢音乐人雕塑陕西多彩玻璃钢雕塑订做价格商场中庭海洋世界美陈玻璃钢仿生雕塑价格昆明市玻璃钢雕塑加工公司玻璃钢房地产鹿雕塑价格安庆学校玻璃钢雕塑定制南京玻璃钢雕塑摆件报价玻璃钢雕塑容易断吗阿坝玻璃钢花盆厂家购物中心大熊玻璃钢雕塑深圳主题玻璃钢雕塑商丘大型玻璃钢仿铜雕塑厂家青岛卡通玻璃钢雕塑四川通道商场美陈采购玻璃钢花盆花器信息锻铜玻璃钢彩绘雕塑制造安徽景观玻璃钢雕塑批发玻璃钢花盆适合摆放在太阳下吗香港通过《维护国家安全条例》两大学生合买彩票中奖一人不认账让美丽中国“从细节出发”19岁小伙救下5人后溺亡 多方发声单亲妈妈陷入热恋 14岁儿子报警汪小菲曝离婚始末遭遇山火的松茸之乡雅江山火三名扑火人员牺牲系谣言何赛飞追着代拍打萧美琴窜访捷克 外交部回应卫健委通报少年有偿捐血浆16次猝死手机成瘾是影响睡眠质量重要因素高校汽车撞人致3死16伤 司机系学生315晚会后胖东来又人满为患了小米汽车超级工厂正式揭幕中国拥有亿元资产的家庭达13.3万户周杰伦一审败诉网易男孩8年未见母亲被告知被遗忘许家印被限制高消费饲养员用铁锨驱打大熊猫被辞退男子被猫抓伤后确诊“猫抓病”特朗普无法缴纳4.54亿美元罚金倪萍分享减重40斤方法联合利华开始重组张家界的山上“长”满了韩国人?张立群任西安交通大学校长杨倩无缘巴黎奥运“重生之我在北大当嫡校长”黑马情侣提车了专访95后高颜值猪保姆考生莫言也上北大硕士复试名单了网友洛杉矶偶遇贾玲专家建议不必谈骨泥色变沉迷短剧的人就像掉进了杀猪盘奥巴马现身唐宁街 黑色着装引猜测七年后宇文玥被薅头发捞上岸事业单位女子向同事水杯投不明物质凯特王妃现身!外出购物视频曝光河南驻马店通报西平中学跳楼事件王树国卸任西安交大校长 师生送别恒大被罚41.75亿到底怎么缴男子被流浪猫绊倒 投喂者赔24万房客欠租失踪 房东直发愁西双版纳热带植物园回应蜉蝣大爆发钱人豪晒法院裁定实锤抄袭外国人感慨凌晨的中国很安全胖东来员工每周单休无小长假白宫:哈马斯三号人物被杀测试车高速逃费 小米:已补缴老人退休金被冒领16年 金额超20万

玻璃钢生产厂家 XML地图 TXT地图 虚拟主机 SEO 网站制作 网站优化