うにてぃブログ

UnityやUnreal Engineの記事を書いていきます

【Unity】メッシュをリアルタイムにスライスする

こんにちは!今回はUnityで任意の3Dオブジェクトをメッシュ単位でリアルタイムにカットする処理を実装してみました。この記事では、その仕組みやコードのポイントを解説していきます。

ソースコードは記事の最後に掲載していますので、ぜひ最後までご覧ください。

🎬 まずは実際のカット動画をご覧ください!

スワイプ一つで、3Dオブジェクトがスパッと切れて分裂する様子が確認できます。

メッシュカットの仕組み

メッシュを切るロジックはすべて MeshSlicer クラスに詰め込まれています。以下がその全体構造です。

✂️ メッシュの分割方法

1. Plane(平面)を使って分割面を定義
Plane plane = new Plane(sliceUp, slicePosition);

ここでスライス面を定義します。sliceUp が面の法線ベクトル、slicePosition が位置。

2. 各三角形を、平面のどちら側かで分類

各頂点が平面のどちら側にあるかを調べ、上側 or 下側に振り分けます。交差している場合は補間して新たな頂点とUVを生成し、トリミングして分割。

3. 断面を塞ぐキャップ(蓋)を生成

分割によって生じた断面は、キャップメッシュとして閉じる処理がされています。

4. 2つの新しいオブジェクトを生成し、それぞれにメッシュとマテリアルを適用

GameSceneでのカット操作

TapPositionGetter スクリプトを使うことで、画面上のスワイプ操作でメッシュをカットできるようになっています。

スワイプ検知の流れ

  • タップ開始で始点を記録
  • タップ終了で終点を記録し、スワイプ方向とカメラ向きからスライス面の法線を計算
  • BoxCast を使ってオブジェクトを検出し、ヒットしたものをスライス!
Vector3 sliceNormal = Vector3.Cross(dragDir, forward).normalized;

この行が特にキモで、スワイプとカメラの向きから「切断面の向き」を算出しています。

💡 開発上のポイント

  • 切断後の両パーツに異なるマテリアルが設定可能 → baseMaterialとslicedMaterialでそれぞれ設定できるため、切断面の表現も自由自在!

ソースコード

MeshSlicer.cs

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

public static class MeshSlicer
{
    public static (GameObject, GameObject) Slice(Transform targetObject, Vector3 slicePosition, Vector3 sliceUp, Material slicedMaterial)
    {
        var meshFilter = targetObject.GetComponent<MeshFilter>();
        if (meshFilter == null)
        {
            Debug.LogError("MeshFilter not found.");
            return (null, null);
        }
        
        var meshRenderer = targetObject.GetComponent<MeshRenderer>();
        if (meshRenderer == null)
        {
            Debug.LogError("MeshRenderer not found.");
            return (null, null);
        }

        var mesh = meshFilter.sharedMesh;
        Plane plane = new Plane(sliceUp, slicePosition);

        var originalVerts = mesh.vertices;
        var originalUVs = mesh.uv;

        var upperVerts = new List<Vector3>();
        var upperTris = new List<int>();
        var upperUVs = new List<Vector2>();

        var lowerVerts = new List<Vector3>();
        var lowerTris = new List<int>();
        var lowerUVs = new List<Vector2>();

        var cutEdges = new List<(Vector3 pos, Vector2 uv)>();

        var capVertsTop = new List<Vector3>();
        var capTrisTop = new List<int>();
        var capUVsTop = new List<Vector2>();

        var capVertsBottom = new List<Vector3>();
        var capTrisBottom = new List<int>();
        var capUVsBottom = new List<Vector2>();

        for (int i = 0; i < mesh.triangles.Length; i += 3)
        {
            int i0 = mesh.triangles[i];
            int i1 = mesh.triangles[i + 1];
            int i2 = mesh.triangles[i + 2];

            Vector3[] v = new Vector3[] {
                targetObject.TransformPoint(originalVerts[i0]),
                targetObject.TransformPoint(originalVerts[i1]),
                targetObject.TransformPoint(originalVerts[i2])
            };

            Vector2[] uv = new Vector2[] {
                originalUVs[i0],
                originalUVs[i1],
                originalUVs[i2]
            };

            bool[] side = new bool[] {
                plane.GetSide(v[0]),
                plane.GetSide(v[1]),
                plane.GetSide(v[2])
            };

            int sideCount = side.Count(s => s);

            if (sideCount == 3)
                AddTriangle(upperVerts, upperTris, upperUVs, v[0], v[1], v[2], uv[0], uv[1], uv[2]);
            else if (sideCount == 0)
                AddTriangle(lowerVerts, lowerTris, lowerUVs, v[0], v[1], v[2], uv[0], uv[1], uv[2]);
            else
                SliceTriangleStable(plane, v, uv, side, upperVerts, upperTris, upperUVs, lowerVerts, lowerTris, lowerUVs, cutEdges);
        }

        // 上面(正順)
        FillCutFace(cutEdges, -plane.normal, capVertsTop, capTrisTop, capUVsTop);

        // 下面(逆順)
        FillCutFace(cutEdges, plane.normal, capVertsBottom, capTrisBottom, capUVsBottom);

        var baseMaterial = meshRenderer.sharedMaterial;
        var upperObject = 
            CreateMesh("UpperHull", upperVerts, upperTris, upperUVs, capVertsTop, capTrisTop, capUVsTop, baseMaterial, slicedMaterial);
        
        var lowerObject =
            CreateMesh("LowerHull", lowerVerts, lowerTris, lowerUVs, capVertsBottom, capTrisBottom, capUVsBottom, baseMaterial, slicedMaterial);

        Object.Destroy(targetObject.gameObject);
        return (upperObject, lowerObject);
    }

    static void SliceTriangleStable(Plane plane, Vector3[] v, Vector2[] uv, bool[] side,
        List<Vector3> upperVerts, List<int> upperTris, List<Vector2> upperUVs,
        List<Vector3> lowerVerts, List<int> lowerTris, List<Vector2> lowerUVs,
        List<(Vector3, Vector2)> cutEdges)
    {
        List<(Vector3, Vector2)> upper = new List<(Vector3, Vector2)>();
        List<(Vector3, Vector2)> lower = new List<(Vector3, Vector2)>();

        (Vector3, Vector2)[] intersections = new (Vector3, Vector2)[2];
        int interCount = 0;

        for (int i = 0; i < 3; i++)
        {
            int next = (i + 1) % 3;
            if (side[i]) upper.Add((v[i], uv[i]));
            else lower.Add((v[i], uv[i]));

            if (side[i] != side[next])
            {
                Vector3 pos = GetIntersection(plane, v[i], v[next]);
                float t = (pos - v[i]).magnitude / (v[next] - v[i]).magnitude;
                Vector2 interpolatedUV = Vector2.Lerp(uv[i], uv[next], t);
                var pair = (pos, interpolatedUV);

                upper.Add(pair);
                lower.Add(pair);

                intersections[interCount++] = pair;
            }
        }

        cutEdges.AddRange(intersections);

        if (upper.Count == 3)
            AddTriangle(upperVerts, upperTris, upperUVs, upper[0], upper[1], upper[2]);
        else if (upper.Count == 4)
        {
            AddTriangle(upperVerts, upperTris, upperUVs, upper[0], upper[1], upper[2]);
            AddTriangle(upperVerts, upperTris, upperUVs, upper[0], upper[2], upper[3]);
        }

        if (lower.Count == 3)
            AddTriangle(lowerVerts, lowerTris, lowerUVs, lower[0], lower[1], lower[2]);
        else if (lower.Count == 4)
        {
            AddTriangle(lowerVerts, lowerTris, lowerUVs, lower[0], lower[1], lower[2]);
            AddTriangle(lowerVerts, lowerTris, lowerUVs, lower[0], lower[2], lower[3]);
        }
    }

    private static void FillCutFace(List<(Vector3 pos, Vector2 uv)> cutEdges, Vector3 normal,
        List<Vector3> capVerts, List<int> capTris, List<Vector2> capUVs)
    {
        Vector3 center = cutEdges.Aggregate(Vector3.zero, (c, p) => c + p.pos) / cutEdges.Count;
        Vector2 centerUV = new Vector2(0.5f, 0.5f);

        Vector3 axisX = Vector3.Cross(normal, Vector3.up).normalized;
        if (axisX == Vector3.zero)
            axisX = Vector3.Cross(normal, Vector3.forward).normalized;

        var ordered = cutEdges.OrderBy(p =>
            Mathf.Atan2(Vector3.Dot(Vector3.Cross(axisX, p.pos - center), normal),
                        Vector3.Dot(axisX, p.pos - center))
        ).ToList();

        int centerIndex = capVerts.Count;
        capVerts.Add(center);
        capUVs.Add(centerUV);

        for (int i = 0; i < ordered.Count; i++)
        {
            var a = ordered[i];
            var b = ordered[(i + 1) % ordered.Count];

            int aIdx = capVerts.Count;
            int bIdx = capVerts.Count + 1;

            capVerts.Add(a.pos);
            capVerts.Add(b.pos);

            capUVs.Add(new Vector2(0.5f + (a.pos - center).x * 0.5f, 0.5f + (a.pos - center).z * 0.5f));
            capUVs.Add(new Vector2(0.5f + (b.pos - center).x * 0.5f, 0.5f + (b.pos - center).z * 0.5f));

            capTris.Add(centerIndex);
            capTris.Add(aIdx);
            capTris.Add(bIdx);
        }
    }

    private static Vector3 GetIntersection(Plane plane, Vector3 a, Vector3 b)
    {
        Ray ray = new Ray(a, b - a);
        plane.Raycast(ray, out float enter);
        return ray.GetPoint(enter);
    }

    private static void AddTriangle(List<Vector3> verts, List<int> tris, List<Vector2> uvs,
        Vector3 a, Vector3 b, Vector3 c, Vector2 uva, Vector2 uvb, Vector2 uvc)
    {
        int idx = verts.Count;
        verts.AddRange(new[] { a, b, c });
        uvs.AddRange(new[] { uva, uvb, uvc });
        tris.AddRange(new[] { idx, idx + 1, idx + 2 });
    }

    private static void AddTriangle(List<Vector3> verts, List<int> tris, List<Vector2> uvs,
        (Vector3, Vector2) a, (Vector3, Vector2) b, (Vector3, Vector2) c)
    {
        int idx = verts.Count;
        verts.AddRange(new[] { a.Item1, b.Item1, c.Item1 });
        uvs.AddRange(new[] { a.Item2, b.Item2, c.Item2 });
        tris.AddRange(new[] { idx, idx + 1, idx + 2 });
    }

    private static GameObject CreateMesh(string name,
        List<Vector3> verts, List<int> tris, List<Vector2> uvs,
        List<Vector3> capVerts, List<int> capTris, List<Vector2> capUVs,
        Material baseMaterial, Material slicedMaterial)
    {
        if (verts.Count == 0 && capVerts.Count == 0)
        {
            Debug.LogWarning($"[{name}] Mesh skipped: no vertices.");
            return null;
        }

        var obj = new GameObject(name, typeof(MeshRenderer), typeof(MeshFilter), typeof(MeshCollider), typeof(Rigidbody));
        var mesh = new Mesh();

        var allVerts = verts.Concat(capVerts).ToList();
        var allUVs = uvs.Concat(capUVs).ToList();

        // 頂点とUVをペアでチェック
        var filtered = allVerts.Zip(allUVs, (v, uv) => new { v, uv })
            .Where(p => !(float.IsNaN(p.v.x) || float.IsNaN(p.v.y) || float.IsNaN(p.v.z)))
            .ToList();

        if (filtered.Count != allVerts.Count)
        {
            Debug.LogWarning($"[{name}] Some vertices contained NaN and were removed.");
        }

        var cleanVerts = filtered.Select(p => obj.transform.InverseTransformPoint(p.v)).ToList();
        var cleanUVs = filtered.Select(p => p.uv).ToList();

        mesh.SetVertices(cleanVerts);
        mesh.subMeshCount = 2;

        mesh.SetTriangles(tris, 0);
        mesh.SetTriangles(capTris.Select(i => i + verts.Count).ToList(), 1);

        mesh.SetUVs(0, cleanUVs);
        mesh.RecalculateNormals();
        mesh.RecalculateTangents();
        mesh.RecalculateBounds();

        obj.GetComponent<MeshFilter>().mesh = mesh;
        var meshCollider = obj.GetComponent<MeshCollider>();
        meshCollider.convex = true;
        meshCollider.sharedMesh = mesh;
        var renderer = obj.GetComponent<MeshRenderer>();
        renderer.materials = new[] { baseMaterial, slicedMaterial};
        return obj;
    }
}

TapPositionGetter.cs

using UnityEngine;

[RequireComponent(typeof(LineRenderer))]
public class TapPositionGetter : MonoBehaviour
{
    [SerializeField]
    private Camera _targetCamera;
    [SerializeField]
    private Material _slicedMaterial;
    [SerializeField]
    private LineRenderer _lineRenderer;

    private Vector3 _startPosition;
    private Vector3 _endPosition;
    private bool _isDragging = false;

    private RaycastHit[] _hits = new RaycastHit[10];
    
    private void Start()
    {
        if (_targetCamera == null)
            _targetCamera = Camera.main;

        // LineRendererの初期設定
        _lineRenderer.positionCount = 2;
        _lineRenderer.enabled = false;
        _lineRenderer.widthMultiplier = 0.02f;
    }

    private void Update()
    {
        // ドラッグ開始
        if (Input.GetMouseButtonDown(0))
        {
            _startPosition = Input.mousePosition;
            _isDragging = true;

            _lineRenderer.enabled = true;
        }

        // ドラッグ中:線の更新
        if (_isDragging)
        {
            _endPosition = Input.mousePosition;

            var startWorld = GetWorldPosition(_startPosition);
            var endWorld = GetWorldPosition(_endPosition);

            startWorld.z -= 1;
            endWorld.z -= 1;
            _lineRenderer.SetPosition(0, startWorld);
            _lineRenderer.SetPosition(1, endWorld);
        }

        if (Input.GetMouseButtonUp(0) && _isDragging)
        {
            _isDragging = false;
            _lineRenderer.enabled = false;

            var midScreenPosition = (_startPosition + _endPosition) / 2f;
            var midRay = _targetCamera.ScreenPointToRay(midScreenPosition);
            
            Debug.DrawRay(midRay.origin, midRay.direction * 10f, Color.red, 2f);

            // BoxCastの中心をRayのちょっと前方にする(5単位前)
            var boxCenter = midRay.origin + midRay.direction * 5f;

            // スクリーン座標をワールド座標に変換して方向を出す
            var startWorld = _targetCamera.ScreenToWorldPoint(new Vector3(_startPosition.x, _startPosition.y, 5f));
            var endWorld = _targetCamera.ScreenToWorldPoint(new Vector3(_endPosition.x, _endPosition.y, 5f));

            Vector3 dragDir = (endWorld - startWorld).normalized;

            var forward = _targetCamera.transform.forward;
            // カット面の法線(スワイプ方向とカメラ前方の外積)
            Vector3 sliceNormal = Vector3.Cross(dragDir, forward).normalized;

            // Boxの回転をカット面に合わせる
            Quaternion boxRotation = Quaternion.LookRotation(sliceNormal, dragDir);
            
            // Boxのサイズを調整(厚みや幅は好みで)
            Vector3 boxHalfExtents = new Vector3(Vector3.Distance(startWorld, endWorld) / 2f, 0.05f, 1f);

            // BoxCast実行
            var hitCount = Physics.BoxCastNonAlloc(boxCenter, boxHalfExtents, forward, _hits, boxRotation, 0.01f);
            
            for (int i = 0; i < hitCount; i++)
            {
                MeshSlicer.Slice(_hits[i].transform, boxCenter, sliceNormal, _slicedMaterial);
                Debug.Log($"カット対象: {_hits[i].transform.name}, Normal={sliceNormal}");
            }
            
            if (hitCount == 0)
            {
                Debug.Log("BoxCastでヒットなし。");
            }
        }
    }

    private Vector3 GetWorldPosition(Vector3 screenPosition)
    {
        var ray = _targetCamera.ScreenPointToRay(screenPosition);
        return ray.origin + ray.direction * 5f;
    }
}