是男人就下100层—Unity实现欢乐球球(上)Mesh生成

是男人就下100层—Unity实现欢乐球球(上)Mesh生成

本篇难度: ★★★

前言

大家好,今天我们来学习做一个稍微复杂点的项目 。

以年初的《跳一跳》为爆发点,短短半年时间,微信小游戏已经呈遍地开花的趋势。据统计,截止6月7日,微信小游戏的数量已经超过了1000款。很多人都认为这是下一个风口级的机遇。

当然,我们这里不谈市场,只谈技术。林林总总的小游戏可以说是用来练手的项目示例宝库。今天的主角,是一款叫做《欢乐球球》的游戏。用Unity实现这款作品会用到一些进阶的知识,所以本次内容可能会比入门级的项目稍微难那么一点。

话不多说,我们开始。


1.实现方法的思考

通常每次准备实现或者复刻一个游戏,都免不了这一步。然而这一次在开头我们就遇到了一个大麻烦:我们没有素材!

球和圆柱体都还好说,Unity自带的3D模型里就有。但是我们没有这关键的圆环形的模型,尤其我们还需要在这个圆环模型上开出一个自定义的"缺口"。

这关系到游戏的核心玩法,所以即使是素材商店有现成的圆环模型都无法满足我们的需求。


肿么办?



凉拌。。。好了,本期文章到此结束。

........

开个玩笑,办法当然是有的。现在要用到Unity的进阶内容:Mesh编程。我们来自己动手生成我们想要形状的3D物体。


2.使用Mesh实现自定义3d物体

顾名思义,Mesh指的是组成3D物体的网格。在Unity里所有的3D物体网格都是由大小不等的三角形拼接而成。

Scene视图的左上角把ShadingMode切换成Shaded Wireframe模式就能显示出模型的网格

在Unity里每一个能显示在场景的3D物体都需要两个组件:MeshFilter和MeshRenderer。

前者负责存储物体的Mesh信息,后者则根据信息把物体渲染到场景中。

所以现在要做的事就明确了:我们通过计算得到Mesh信息来给一个空的Mesh赋值,生成我们想要的形状的物体。那么Mesh里的信息是如何计算和储存的呢?

要组成一个最基本的3D物体,需要一组在空间中确定坐标的点和一组以这些点为顶点的三角形,在Mesh信息里是以一个Vector3类型的顶点数组和一个int类型的三角形数组保存的。其中三角形数组保存的是以顶点数组下标为序号的三角形顺序,所以三角形数组的长度刚好是顶点数组的三倍。

当我们按照顶点顺序绘制出一个三角形时,这个三角形只有一个面是可见的。而具体哪个面可见则是由顶点序号的方向来确定。简而言之就是顶点顺序方向是顺时针则正面可见,顶点顺序方向为逆时针则背面可见。

有了以上的基础,我们就可以开始着手定制我们的3D物体了。我们先简单点,试着去画一个有缺口的圆片,自己指定缺口的弧度大小。

我们新建一个脚本:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
public class TestDisk : MonoBehaviour
{
    MeshFilter MeshFilter;
    MeshRenderer MeshRenderer;

    public float height = 0.4f;
    public float radius = 1f;
    public int details = 20;

    static float EPS = 0.01f;

    void Start()
    {
        MeshFilter = transform.GetComponent<MeshFilter>();
        MeshRenderer = transform.GetComponent<MeshRenderer>();

    }
    //生成一个弧度随机的圆片
    [ContextMenu("GeneratePie")]
    public void GeneratePieByRadian()
    {
        var arcs = new List<float>();
        // 按比例随机,0代表空的,1代表整圆
        float r = Random.Range(0.1f, 0.9f);
        r *= 2 * Mathf.PI;

        arcs.Add(0);
        arcs.Add(r);

        GeneratePie(arcs);
       
    }


    // 参数:每个弧用两两个弧度(float)表示,每个饼可以有多个三角块,就和切披萨一样
    public void GeneratePie(List<float> arcs)
    {
        List<Vector3> verts = new List<Vector3>();
        List<Vector2> uvs = new List<Vector2>();
        List<int> tris = new List<int>();

        List<Vector3> _verts = new List<Vector3>();
        List<Vector2> _uvs = new List<Vector2>();
        List<int> _tris = new List<int>();

        for (int i = 0; i < arcs.Count; i += 2)
        {
            _verts.Clear();
            _uvs.Clear();
            _tris.Clear();

            //先把中心点添加进顶点List中
            _verts.Add(new Vector3(0, 0, 0));
            _verts.Add(new Vector3(0, -height, 0));

            AddArcMeshInfo(arcs[i], arcs[i + 1], _verts, _uvs, _tris);

            //把顶点序号填进三角形list里
            foreach (int n in _tris)
            {
                tris.Add(n + verts.Count);
            }
            verts.AddRange(_verts);
            uvs.AddRange(_uvs);
        }
        Mesh mesh = new Mesh();
        // 填写mesh
        mesh.vertices = verts.ToArray();
        mesh.triangles = tris.ToArray();
        mesh.uv = uvs.ToArray();

        //根据顶点和三角形数据自动生成体积框,法线和切线
        mesh.RecalculateBounds();
        mesh.RecalculateNormals();
        mesh.RecalculateTangents();

        MeshFilter.mesh = mesh;

    }

    void AddArcMeshInfo(float begin, float end, List<Vector3> verts, List<Vector2> uvs, List<int> tris)
    {
        // begin和end是开始弧度、结束弧度
        // verts里面已经有了顶面中心点、底面中心点,下标分别是0和1

        float eachRad = 2 * Mathf.PI / details;

        // 用三角函数计算出圆的周长上的顶点
        float a;
        for (a = begin; a <= end; a += eachRad)
        {
            Vector3 v = new Vector3(radius * Mathf.Sin(a), 0, radius * Mathf.Cos(a));
            verts.Add(v);
            Vector3 v2 = new Vector3(radius * Mathf.Sin(a), -height, radius * Mathf.Cos(a));
            verts.Add(v2);
        }
        if (a < end + EPS)
        {
            Vector3 v = new Vector3(radius * Mathf.Sin(end), 0, radius * Mathf.Cos(end));
            verts.Add(v);
            Vector3 v2 = new Vector3(radius * Mathf.Sin(end), -height, radius * Mathf.Cos(end));
            verts.Add(v2);
        }

        // 顶面顶点序号
        int n = verts.Count;
        for (int i = 2; i < n - 2; i += 2)
        {
            tris.Add(i); tris.Add(i + 2); tris.Add(0);
        }

        // 侧面顶点序号
        for (int i = 2; i < n - 2; i += 2)
        {
            tris.Add(i); tris.Add(i + 1); tris.Add(i + 2);
            tris.Add(i + 2); tris.Add(i + 1); tris.Add(i + 3);
        }

        // 封住两个直线边
        tris.Add(2); tris.Add(0); tris.Add(1);
        tris.Add(3); tris.Add(2); tris.Add(1);
        tris.Add(n - 1); tris.Add(0); tris.Add(n - 2);
        tris.Add(1); tris.Add(0); tris.Add(n - 1);
    }
}

把脚本挂载到一个空物体上,设置好数据后运行场景:


可能会感觉有点蒙。别方,我们可以用协程按照顶点顺序依次显示出构成圆片的三角形,便于我们更加形象的理解其构成顺序。

    IEnumerator SequenceTest()
    {
        for (int i = 0; i < MeshFilter.mesh.triangles.Length; i += 3)
        {          
            Debug.DrawLine(MeshFilter.mesh.vertices[MeshFilter.mesh.triangles[i]], MeshFilter.mesh.vertices[MeshFilter.mesh.triangles[i + 1]], Color.red, 100f);

            yield return new WaitForSeconds(0.2f);
            Debug.DrawLine(MeshFilter.mesh.vertices[MeshFilter.mesh.triangles[i + 1]], MeshFilter.mesh.vertices[MeshFilter.mesh.triangles[i + 2]], Color.yellow, 100f);

            yield return new WaitForSeconds(0.2f);
            Debug.DrawLine(MeshFilter.mesh.vertices[MeshFilter.mesh.triangles[i + 2]], MeshFilter.mesh.vertices[MeshFilter.mesh.triangles[i]], Color.blue, 100f);

            yield return new WaitForSeconds(0.2f);

        }
    }

用红黄蓝三种颜色的顺序来显示三角形的三条边,效果如图:

可以看到三角形的构建顺序就是我们赋值时候的顺序。

搞明白了半圆片是怎么实现的了,相信环形也就难不倒大家了。无非是中心点换成了环形内径的顶点,这就留给大家自己去思考如何实现。



关于Mesh编程的内容网上有很多,此处只做简单说明,有兴趣的童鞋可以自己了解一下。

传送门:blog.csdn.net/qq_295791


3.小球的重力和反弹模拟

现在素材我们算是有了,该考虑小球了。不然怎么叫欢乐球球呢。

首先想一下这么一个问题:我们能不能直接用小球挂载的刚体组件自带的重力和碰撞盒子上的物理材质的弹力去实现游戏中小球反弹的效果?毕竟这样比较省事。

答案是不能。因为物理计算本身是对数据的一种近似计算,会有一定的误差。而当游戏运行一段时间后,这种误差会逐渐积累到影响游戏正常逻辑。比如在球触碰到地面时如果移动地面,球就会获得一个水平方向分量的力,球的反弹移动速度的向量就会有一点偏差。所以我们得自己来模拟重力和反弹果。

先想一下现实世界的重力是如何生效的:任何物体都会受到重力加速度的影响,自由落体时速度会逐渐增大直到碰到底面或者重力加速度与空气阻力达成平衡。碰到地面时则速度值会因为弹力改变,然后又受到重力影响,如此往复。所以代码也按照这个思路来写。

新建小球的脚本:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class BallTest : MonoBehaviour
{
    SphereCollider sphereCollider;

    public float bounceSpeed;
    public float gravity;
    public float maxSpeed = 1;

    float radius = 0;
    float speed;

    void Start ()
    {
        sphereCollider = GetComponent<SphereCollider>();
        radius = sphereCollider.radius;

	}
    //球掉落移动计算
    void Drop()
    {
        //每帧减去模拟重力带来的速度影响
        speed -= gravity * Time.deltaTime;
        //限制球能达到的最大速度
        speed = Mathf.Clamp(speed, -maxSpeed, maxSpeed);

        transform.position += new Vector3(0, speed, 0);
    }
    void Bounce()
    {
        //当球反弹回升的时候跳过检测
        if (speed >= 0)
        {
            return;
        }

        //确定一个位置合适的立方体,比球略小,位置偏下
        Vector3 p = transform.position + new Vector3(0, -radius, 0);
        Vector3 size = new Vector3(radius * 0.5f, radius * 0.5f, radius * 0.5f);
        if (Physics.OverlapBox(p, size, Quaternion.identity, LayerMask.GetMask("Ground")).Length > 0)
        {        
            speed = bounceSpeed;      
        }

    }
   
   void Update()
    {
        Bounce();
        Drop();

    }
   
   
}

在场景中新建一个球体和一个平面,把脚本挂载在球体上,修改平面的Layer为"Ground",然后运行场景:

可以看到小球如我们所期望的方式运动起来,并且由于数值都是我们设定好的,每帧更新调用,所以不管运行多久都不会有误差。如此一来我们就简单的实现了小球的重力和反弹。

结束

这期文章我们把游戏前期的准备工作基本做完了,下期我们会开始着手实现游戏逻辑,比如分数的计算,场地复用等等。

关于Mesh这期只介绍了基本的形状的构建,把一个3D物体渲染到场景里还需要计算顶点法线,

贴图的UV坐标,切线等更复杂的数据,有兴趣的同学可以自行深入研究。

本期文章工程地址:

github.com/tank1018702/

照例打个广告,大家如果有想系统学习游戏开发的,可到levelpp.com/驻足围观。

编辑于 2018-07-16

文章被以下专栏收录