Unity网格编程之切割对象(用mesh做3D切水果)


前言

在游戏模型中,一个物体是由点和面组成的,而面的最小基础部分是三角形。至于为啥不用多边形,估测是因为多边形的不稳定性,出现了凹凸不平的形状就比较难以用算法控制。这篇博客的主要内容是如何利用mesh中的顶点,三角形,法线向量等改变物体的形状以及生成新得分物体。

一、什么是mesh编程?

在unity模型中,mesh包含一个物体的形状(Vetices/Trangles),光照信息(Normals),贴图标识(UV)等一系列信息。当我们需要修改一个物体的形状的时候,就需要对它的mesh信息中的Vetices/Trangles进行操作。

mesh.Vetices,是组成模型的所有顶点的坐标数组;
mesh.Trangles,是包含所有组成三角形的顶点在Vetices中的下标数组;
mesh.Normals,是顶点的法线向量,物体根据光照向量与此向量的方向夹角判断物体应该接受的光照强度。
mesh.UV,是顶点对应的贴图坐标。
还要其他的信息,这里不再一一介绍,此次我们基本只对前三个信息进行操作。


二、获取切割平面

1.输入

采用手指或者鼠标在屏幕的滑动,作为切割的输入信息。那么首先要判断切割的是哪个物体。

此处获得屏幕滑动信息的时候,我们能够获得的是开始滑动和滑动结束之后产生的屏幕坐标。这个时候,需要通过射线来判断切割到的是那个物体。选择开始滑动(startPos)和滑动结束(endPos)的坐标,取中间点作为射线的起始点。发射射线之后,可以得到需要切割的对象。代码如下:

void Update()
{
    if (Input.GetMouseButtonDown(0))
    {
        _startPos = Input.mousePosition;
    }

    if (Input.GetMouseButtonUp(0))
    {
        _endPos = Input.mousePosition;
        RayAndCut();
    }
}
private void RayAndCut()
{
    var center = (_endPos + _startPos) * 0.5f;
    var ray = Camera.main.ScreenPointToRay(center);

    if (Physics.Raycast(ray, out RaycastHit hit, 10f)) 
    {
        _point = hit.point;
        _hitTrans = hit.transform;
        _mesh = hit.transform.GetComponent<MeshFilter>().mesh;
        _renderer = hit.transform.GetComponent<MeshRenderer>();

        Vector3 startPos = Camera.main.ScreenToWorldPoint(new Vector3(_startPos.x, _startPos.y, 10f));
        Vector3 endPos = Camera.main.ScreenToWorldPoint(new Vector3(_endPos.x, _endPos.y, 10f));

        _dir = (hit.point - Camera.main.transform.position).normalized;
        _upDir = (endPos - startPos).normalized;
        _upDir = (-_dir * Vector3.Dot(_upDir, _dir) + _upDir).normalized;
        _planeNormal = Vector3.Cross(_dir, _upDir);
    }
}

其他部分的代码先不看,这里到if (Physics.Raycast(ray, out RaycastHit hit, 10f)) ,可以获取到hit的对象。


2.计算切割平面

这里我们首先肯定有一个方向是屏幕滑动的起始和结束组成的线,是在切割面上的。只需要找出另外一条与之相交的线,就可以画出一个平面来。在unity3D空间内,我们看到的是摄像机渲染出来的投影,而深度则沿着摄像机到物体的方向。所以我们需要采用的是摄像机坐标到碰撞点的向量,来作为另一条线。

①,屏幕坐标形成的向量,如何转换到世界空间
在Camera下的ScreenToWorldPoint方法,是用来转换屏幕坐标到世界坐标的。它的参数是一个Vector3。这里需要注意的一点是,这里的转换是通过映射一个与摄像机距离为z的点。但是屏幕坐标中的z=0,所以直接把Input.mousePosition放进去,获得的值是无效的。这里就要手动为它增加一个z值。
例如:Vector3 startPos = Camera.main.ScreenToWorldPoint(new Vector3(_startPos.x, _startPos.y, 10f));

②,获得另外一条线
很简单,就是按照上述想法,直接用碰撞点的坐标减去摄像机的坐标即可。代码中是
_dir = (hit.point - Camera.main.transform.position).normalized;

③,获取这个面的三维坐标系来简化后续的运算
看到上面代码中好多normalized,我们通过①和②获得的两条线的夹角不是90°,在后面的算法中很难应用。这里需要求出这个面上的三维坐标系的三个方向向量(垂直与面的法线向量也会用到)。这里需要用到向量的数学知识,如图:
在这里插入图片描述
首先把分别取向量dir和updir_0(endPos - startPos)的单位向量,然后如图若想求出与dir垂直的updir,那么只要求出y代表的向量,使updir_0减去y代表的向量即可。而由于图中是个长方形,所以y与updir_0在dir方向上的投影向量是一致的。
通过点乘,我们可以求得y的大小,因为向量updir_0向量dir=|updir_0||dir|*cosa,a是两者的夹角。这里都是长度为1的单位向量,所以y的长度可以这样得出,进而_upDir = (-_dir * Vector3.Dot(_upDir, _dir) + _upDir).normalized;得出的就是与平面给内与dir垂直的向量。
后面就是通过叉乘得出法线向量了。


三、分离平面两边的顶点

1,数学知识基础

这款儿有点儿难理解,如果对向量和平面的关系不理解的话,只能记住这个方法。
在第二章里,我们获得了一个切割平面和法线向量,把它抽象出一个三维坐标系,有三个方向(dir,updir,_planeNormal)。这里我们以碰撞点point为原点,创建一个坐标系A。假设一个组成三角形的顶点全部在切割面的左边,那么首先把这三个点平移到坐标系A中updir与planenormal组成的平面上。

为啥①?:对于上述三维坐标系,在空间内可以作为一个相对坐标系存在。只要找到合适的原点,就可以让顶点处于updir与planenormal组成的平面上。既然是相对位置,那么我们不仅仅可以平移坐标系,也可以平移顶点的位置,使顶点处于规定的坐标系内并不破坏它原本的相对位置。这里为了计算两者的相对位置关系是可以的,但是若要计算实际的坐标,则还是要找到符合条件的原点。

然后从这三个点起始向point发出一个向量T,从图中可以看出向量T和向量planenormal的夹角全部为锐角。在没有给出的另一张图上,可以同理得到在切割面右边的三角形的顶点到point的向量,和向量planenormal的夹角全部为钝角。
看图:
图1
根据角度的三角函数性值,锐角的cos结果是整数,而钝角的cos结果是负数。好了,算法清晰了起来,利用这个夹角的cos值可以明确的区分出来模型的顶点是分部在切割面的左侧,还是右侧。当三角形被切割面分割时,同样适用于这个结论,不过这个时候需要生成新的顶点来显示切割之后的切割面。


2,判断三角形顶点在右侧还是左侧的方法

如上分析的方法,计算cos值即可,这里采用点乘的方法,因为两个向量的点乘结果的正负受cos(a)影响。
代码如下:

    //1,计算出被切割的切面两边的顶点信息
    Vector3[] vetexTemp = new Vector3[3];
    float[] result = new float[3];
    for (int i = 0; i < trangles.Length; i += 3)
    {
        for (int j = 0; j < result.Length; j++)
        {
            vetexTemp[j] = _hitTrans.TransformPoint(vetices[trangles[i + j]]);
            result[j] = Vector3.Dot(_planeNormal, _point - vetexTemp[j]);
        }

        if (result[0] <= 0 && result[1] <= 0 && result[2] <= 0)
        {
            //全部在左侧
            SaveVetexInfo(_leftVectices, _leftTrangles, _leftNormals, i, trangles);
        }
        else if (result[0] >= 0 && result[1] >= 0 && result[2] >= 0)
        {
            //全部在右侧
            SaveVetexInfo(_rightVectices, _rightTrangles, _rightNormals, i, trangles);
        }
        else
        {
            //与切面相交的三角形顶点
            int index = DeffrentSectionPoint(result);
            if (result[index] <= 0)
            {
                int p0_Index = index + i;
                int p1_Index = (p0_Index + 1) % 3 + i;
                int p2_Index = (p0_Index + 2) % 3 + i;

                SaveSectionPoint(true, p0_Index, p1_Index, p2_Index, trangles);
            }
            else
            {
                int p0_Index = index + i;
                int p1_Index = (p0_Index + 1) % 3 + i;
                int p2_Index = (p0_Index + 2) % 3 + i;

                SaveSectionPoint(false, p0_Index, p1_Index, p2_Index, trangles);
            }
        }
    }

说明:
①,数组result是存储判断的结果,数组vetexTemp是存储当前正在判断的三角形的顶点;
②,流程是首先把一个完整三角形顶点的判断结果存储,然后判断此三角形是否整体在同一边;
③,处于同一边的存储方法是SaveVetexInfo,而恰好处于切割面上的存储方法是SaveSectionPoint。


3,存储处于同一边的顶点(SaveVetexInfo)

存储思路是,由于我们是按三角形顶点索引数组trangles取得顶点值,所以依次把三个顶点存入Vectices数组即可,而Trangles则按顺序加入下标值,Normals取至原来的normals值。代码如下:

private void SaveVetexInfo(List<Vector3> curVectices, List<int> curTrangles, List<Vector3> curNormals, int index, int[] trangles)
{
    for (int i = 0; i < 3; i++)
    {
        curVectices.Add(_mesh.vertices[trangles[index + i]]);
        curNormals.Add(_mesh.normals[trangles[index + i]]);
        curTrangles.Add(curVectices.Count - 1);
    }
}

说明:参数,根据result结果的正负传入left或者right预先设置好的缓存List。


4,存储处于切割面的顶点(SaveSectionPoint)

这里就比较复杂一些,首先要生成两个新的顶点,然后再根据顺序存放到缓存里。对于生成新的顶点是一个不容易理解的过程,先看图吧:
在这里插入图片描述
同样需要引入相对坐标系的概念,如图三个顶点P0,P1,P2,C1和C2是三角形边与切割面的交点。此时把C1作为坐标系的原点,平移射线碰撞的点point到此坐标系内,它在planenormal上的投影与此时图上标注的point具备同样的性质。然我们以世界空间(0,0,0)点与point,P0,C1连成三条直线。
如果要求出C1点的坐标,就可以使用向量(00_P0)加上向量(P0_C1)。而已知条件是P0点,则求出(P0_C1)的长度即可。这里再次拿出向量点乘大法!通过单位向量的投影长度比值与向量的投影长度比值相同来获得P0到C1的长度,具体的推导过程就不写了,这里其实对于空间中向量的投影等更难理解。看不懂的只能靠想象力~~

为什么每个三角形都符合这个算法?
各个三角形的顶点不一定与图中的updir和planeNormal组成的面在一个平面上!
(0,0,0)点的位置与模型的位置更是千差万别!
OK,发挥你们的空间想象力和数学基础的时候到了,请翻阅数学书~~

计算公式为:
在这里插入图片描述


①,(代码)计算新生成的顶点坐标的方法

private Vector3 CalculateSectionPoint(int index1, int index2, int[] trangles)
{
    Vector3 p0 = _hitTrans.TransformPoint(_mesh.vertices[trangles[index1]]);
    Vector3 p1 = _hitTrans.TransformPoint(_mesh.vertices[trangles[index2]]);
    Vector3 dirP01 = (p1 - p0).normalized;
    float pointLent = Vector3.Dot(_point, _planeNormal);
    float p0Lent = Vector3.Dot(p0, _planeNormal);
    float lenght = (pointLent - p0Lent) / Vector3.Dot(dirP01, _planeNormal);
    Vector3 cals = p0 + lenght * dirP01;
    return _hitTrans.InverseTransformPoint(cals);
}

②,(代码)存储旧顶点与新顶点组成的三角形的顶点信息

private void SaveSectionPoint(bool isLeft, int p0_Index, int p1_Index, int p2_Index, int[] trangles)
{
    bool isLeftTemp = isLeft ? true : false;
    Vector3 c1 = CalculateSectionPoint(p0_Index, p1_Index, trangles);
    Vector3 c2 = CalculateSectionPoint(p0_Index, p2_Index, trangles);
    if (!isLeft)
    {
        _newVectices.Add(c1);
        _newVectices.Add(c2);
    }
    else
    {
        _newVectices.Add(c2);
        _newVectices.Add(c1);
    }
    //下面的顺序很重要,顺序是如何得来的,可以用上面的图画一下,分为P0的result小于等于0和大于0两种情况
    SaveOldVetexInfo(isLeftTemp, p0_Index, trangles);
    SaveNewVetexInfo(isLeftTemp, c1, p0_Index, trangles);
    SaveNewVetexInfo(isLeftTemp, c2, p0_Index, trangles);
    SaveNewVetexInfo(!isLeftTemp, c1, p0_Index, trangles);
    SaveOldVetexInfo(!isLeftTemp, p1_Index, trangles);
    SaveOldVetexInfo(!isLeftTemp, p2_Index, trangles);
    SaveNewVetexInfo(!isLeftTemp, c2, p0_Index, trangles);
    SaveNewVetexInfo(!isLeftTemp, c1, p0_Index, trangles);
    SaveOldVetexInfo(!isLeftTemp, p2_Index, trangles);
}
    /// <summary>
    /// 存储被切割的判断为左侧Or右侧的旧顶点坐标信息
    /// </summary>
private void SaveOldVetexInfo(bool isLeft, int index, int[] trangles)
{
    if (isLeft)
    {
        _leftVectices.Add(_mesh.vertices[trangles[index]]);
        _leftNormals.Add(_mesh.normals[trangles[index]]);
        _leftTrangles.Add(_leftVectices.Count - 1);
    }
    else
    {
        _rightVectices.Add(_mesh.vertices[trangles[index]]);
        _rightNormals.Add(_mesh.normals[trangles[index]]);
        _rightTrangles.Add(_rightVectices.Count - 1);
    }
}
    /// <summary>
    /// 存储被切割的判断为左侧Or右侧的新顶点坐标信息
    /// </summary>
private void SaveNewVetexInfo(bool isLeft, Vector3 cals, int p0_Index, int[] trangles)
{
    if (isLeft)
    {
        _leftVectices.Add(cals);
        _leftNormals.Add(_mesh.normals[trangles[p0_Index]]);
        _leftTrangles.Add(_leftVectices.Count - 1);
    }
    else
    {
        _rightVectices.Add(cals);
        _rightNormals.Add(_mesh.normals[trangles[p0_Index]]);
        _rightTrangles.Add(_rightVectices.Count - 1);
    }
}

③,切割面的生成之新顶点有序存入缓存
以上代码有一个很微小的部分,就是_newVectices,它是用来存储新生成的顶点缓存。因为我们在切割完成之后,除了物体本身的面,还有一个新的面需要完全使用新的顶点生成。例如下图中箭头指向的部分。
在这里插入图片描述
这里存储C1和C2顶点的顺序也是有要求的,比如我这里写如果P0的result<=0则先存C2,再存C1。至于为什么,可以通过画图来理解:
在这里插入图片描述

我们生成的mesh顶点顺序必须是顺时针的,才能在视角范围内显示,不然就是反方向显示了。可以看出在P0大于0和小于0的时候,算法中的C1和C2方向是不同的,所以在存入缓存的时候要保持同一个方向存入。这里存入的方向与生成面时的顶点方向的顺序相关,不一定严格按照上述顺序。


③,切割面的生成之存入顶点信息

思路是,取_newVectices缓存中的第一个顶点和中间的顶点,计算出一个中心点,然后用这个中心点依次与缓存新顶点组成新的三角形,存入对应的缓存数组。

if (_newVectices.Count > 0)
{
    Vector3 center = (_newVectices[0] + _newVectices[_newVectices.Count / 2]) * 0.5f;
    Vector3 normalNew = Vector3.Cross(_newVectices[0] - _newVectices[_newVectices.Count / 2], _newVectices[_newVectices.Count - 1] - _newVectices[_newVectices.Count / 2]);

    for (int i = 0; i < _newVectices.Count; i += 2)
    {
        _leftVectices.Add(center);
        _leftTrangles.Add(_leftVectices.Count - 1);
        _leftVectices.Add(_newVectices[i]);
        _leftTrangles.Add(_leftVectices.Count - 1);
        _leftVectices.Add(_newVectices[(i + 1) % _newVectices.Count]);
        _leftTrangles.Add(_leftVectices.Count - 1);

        for (int j = 0; j < 3; j++)
        {
            _leftNormals.Add(normalNew);
        }
    }

    for (int i = 0; i < _newVectices.Count; i += 2)
    {
        _rightVectices.Add(center);
        _rightTrangles.Add(_rightVectices.Count - 1);

        _rightVectices.Add(_newVectices[(i + 1) % _newVectices.Count]);
        _rightTrangles.Add(_rightVectices.Count - 1);

        _rightVectices.Add(_newVectices[i]);
        _rightTrangles.Add(_rightVectices.Count - 1);

        for (int j = 0; j < 3; j++)
        {
            _rightNormals.Add(-normalNew);
        }
    }
}

说明:这里判断_newVectices的长度是因为在测试中我发现不停的切割同一个物体会发生物体变得很小,切到顶点情况。这是_newVectices的长度会是0。


四、mesh信息赋值

通常情况下,把mesh的Vectices,Trangles,Normals,赋值即可满足我们大部分需求,有时候切割面的材质贴图需要修改的话,则需要更多操作。

//3,把其中一边的mesh赋值给原物体
_mesh.Clear();
_mesh.vertices = _leftVectices.ToArray();
_mesh.triangles = _leftTrangles.ToArray();
_mesh.normals = _leftNormals.ToArray();

//4,生成一个新的物体,赋值另一边的mesh值
GameObject rightObject = new GameObject();
rightObject.transform.position = _hitTrans.position;
rightObject.AddComponent<MeshFilter>();
rightObject.AddComponent<MeshRenderer>();
rightObject.AddComponent<Rigidbody>();
rightObject.AddComponent<MeshCollider>();

Mesh rightMesh = rightObject.GetComponent<MeshFilter>().mesh;
rightMesh.vertices = _rightVectices.ToArray();
rightMesh.triangles = _rightTrangles.ToArray();
rightMesh.normals = _rightNormals.ToArray();

rightObject.GetComponent<MeshRenderer>().material = _renderer.material;

说明:
1,执行_mesh.Clear(),如果对一个旧物体执行多次mesh修改,会出现顶点数量过少或者过多的错误,猜测是由于对mesh进行修改的时候,并没有修改mesh中各数组的长度,所以修改之前执行clear();
2,生成的新物体,组件MeshFilter和MeshRenderer是必须的,一个包含顶点信息,一个包含渲染信息。


总结

对于mesh这方面的知识来说,通常用处比较少,在设计特效和动画的时候才比较常用。
大部分需要操作物体顶点的部分,可以通过建模来完成。并且顶点的运算是很消耗性能的,对比来说使用消耗内存的方法提前建模,可各有取舍。完整的源代码(存在github上):https://github.com/IceCream-Eayet/MeshTest-Creat-/tree/main

一个很重要的一点写在这里,mesh中的顶点信息存储的是局部坐标,而我们在运算的时候用到的大多数是世界坐标。所以对于坐标的转换一定要严格控制好,不然会出现预料不到的错误~~

var target1 : Transform; var target1C : Transform; var target2 : Transform; var target2C : Transform; var mousePos1 : Vector3; var mousePos2 : Vector3; var cursorImage : Texture; var Mouse : GUISkin; private var MouseImg : boolean = false; function Update() { if(Application.platform == RuntimePlatform.Android || Application.platform == RuntimePlatform.IPhonePlayer) { if(Input.touchCount == 1) { mousePos1 = Input.touches[0].position; } else if(Input.touchCount == 2) { mousePos1 = Input.touches[0].position; mousePos2 = Input.touches[1].position; } } else { mousePos1 = Input.mousePosition; } target1.position = camera.ScreenToWorldPoint (Vector3(mousePos1.x,mousePos1.y,1)); target2.position = camera.ScreenToWorldPoint (Vector3(mousePos2.x,mousePos2.y,1)); } function LateUpdate() { if(Input.GetKey(KeyCode.Escape)) { Application.Quit(); } } function OnGUI() { if(MouseImg) { GUI.skin = Mouse; var windowRect : Rect = Rect (mousePos1.x - cursorImage.width/2, Screen.height - mousePos1.y - cursorImage.height/2, cursorImage.width, cursorImage.height); windowRect = GUI.Window (0, windowRect, DoMyWindow, "My Window"); } if(GUILayout.Button("PlanA")) { Screen.showCursor = !Screen.showCursor; target1.gameObject.active = !target1.gameObject.active; target1C.gameObject.active = target1.gameObject.active; if(Application.platform == RuntimePlatform.Android || Application.platform == RuntimePlatform.IPhonePlayer) { target2.gameObject.active = !target2.gameObject.active; target2C.gameObject.active = target2.gameObject.active; } } else if(GUILayout.Button("PlanB")) { Screen.showCursor = !Screen.showCursor; MouseImg = !MouseImg; } else if(GUILayout.Button("Restart")) { Application.LoadLevel(0); } if(GUI.Button(new Rect(Screen.width-120,Screen.height-40,120,30),"Click to YUHUA!")) {
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值