【Unityエディタ拡張】頂点カラーペイントツールを作ってみた


# はじめに
頂点カラーをペイントするツールを作ってみました。
![0.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44288/94c25595-b005-0b1c-3436-188992011fcb.gif)

実装方法について紹介したいと思います。

# 注意点
今回の頂点カラーペイントツールでは、メッシュにペイントした結果は保存されません (Unityを再起動するとリセットされます)

# 環境
Unity2021.1.6f1

# 実装の流れ
1. Sceneビュークリック時、カメラからモデルへ向けてRayを飛ばす
2. Rayが当たった位置の近くの頂点を選択
3. 選択頂点の頂点カラーを上書きする


3Dモデルには頂点カラーを表示するシェーダーを適用しておきます。

## Tips01. Sceneビューのマウスクリックイベント取得
マウスクリック時に実行したい処理をOnSceneGUI()に実装します

```cs:VertexPaintToolEditor.cs
[CustomEditor(typeof(VertexPaintTool))]
public class VertexPaintToolEditor : Editor
{
    private void OnSceneGUI()
    {
        var evt = Event.current;

        // マウスクリックで頂点選択
        if (evt.type == EventType.MouseDown &&  Event.current.button == 0)
        {
            // マウスクリック時の処理をここに書く
        }
    }
}
```

## Tips02. ColliderいらずのRaycast

SceneViewのマウスクリック位置からRayを飛ばし、モデルにぶつかった位置を取得する方法を紹介します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44288/7b1f4646-bbcf-7661-ef58-a09f92f6c81c.png)

以下の記事を参考に、```EditorRaycastHelper.cs``` というクラスを作成しました。
https://forum.unity.com/threads/editor-raycast-against-scene-meshes-without-collider-editor-select-object-using-gui-coordinate.485502/

```cs:EditorRaycastHelper.cs
using System.Reflection;
using UnityEditor;
using UnityEngine;

public static class EditorRaycastHelper
{
    private static readonly MethodInfo intersectRayMeshMethod = typeof(HandleUtility).GetMethod("IntersectRayMesh",
        BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);

    private static GameObject lastGameObjectUnderCursor;

    public static bool RaycastAgainstScene(out RaycastHit hit)
    {
        Ray ray = HandleUtility.GUIPointToWorldRay(Event.current.mousePosition);

        // First, try raycasting against scene geometry with or without colliders (it doesn't matter)
        // Credit: https://forum.unity.com/threads/editor-raycast-against-scene-meshes-without-collider-editor-select-object-using-gui-coordinate.485502
        GameObject gameObjectUnderCursor;
        switch (Event.current.type)
        {
            // HandleUtility.PickGameObject doesn't work with some EventTypes in OnSceneGUI
            case EventType.Layout:
            case EventType.Repaint:
            case EventType.ExecuteCommand:
                gameObjectUnderCursor = lastGameObjectUnderCursor;
                break;
            default:
                gameObjectUnderCursor = HandleUtility.PickGameObject(Event.current.mousePosition, false);
                break;
        }

        if (gameObjectUnderCursor)
        {
            Mesh meshUnderCursor = null;
            if (gameObjectUnderCursor.TryGetComponent(out MeshFilter meshFilter))
                meshUnderCursor = meshFilter.sharedMesh;
            if (!meshUnderCursor &&
                gameObjectUnderCursor.TryGetComponent(out SkinnedMeshRenderer skinnedMeshRenderer))
                meshUnderCursor = skinnedMeshRenderer.sharedMesh;

            if (meshUnderCursor)
            {
                // Remember this GameObject so that it can be used inside problematic EventTypes, as well
                lastGameObjectUnderCursor = gameObjectUnderCursor;

                object[] rayMeshParameters = new object[]
                    {ray, meshUnderCursor, gameObjectUnderCursor.transform.localToWorldMatrix, null};
                if ((bool) intersectRayMeshMethod.Invoke(null, rayMeshParameters))
                {
                    hit = (RaycastHit) rayMeshParameters[3];
                    return true;
                }
            }
            else
                lastGameObjectUnderCursor = null;
        }

        // Raycast against scene geometry with colliders
        object raycastResult = HandleUtility.RaySnap(ray);
        if (raycastResult != null && raycastResult is RaycastHit)
        {
            hit = (RaycastHit) raycastResult;
            return true;
        }

        hit = new RaycastHit();
        return false;
    }
}
```

### 使い方
上記のクラスを導入することで、SceneViewにてRaycastできるようになります。

```cs:Sceneビューのマウス位置でRaycastを実行するコード
RaycastHit hit;
if (EditorRaycastHelper.RaycastAgainstScene(out hit))
{
    Vector3 point = hit.point; // Rayがぶつかった位置を取得
}
```

## Tips03. 頂点データの取得・設定
メッシュが持つすべての頂点は以下のようなコードで取得可能です。

```cs
List<Vector3> vertices = new List<Vector3>(sharedMesh.vertexCount);
sharedMesh.GetVertices(vertices); // 頂点の取得

List<Color> colors = new List<Vector3>(sharedMesh.vertexCount); 
sharedMesh.GetColors(colors); // 頂点カラーの取得
sharedMesh.SetColors(colors); // 頂点カラーの設定
```


以下のようなコードでも頂点データは取得できますが、アクセスするたびに配列がAllocateされてしまうので、避けたほうが良いでしょう。

```cs
Vector3[] vertices = mesh.vertices;
Color[] colors = mesh.colors ;
```
参考: 
    https://docs.unity3d.com/ScriptReference/Mesh.GetVertices.html
    https://docs.unity3d.com/ScriptReference/Mesh.GetColors.html

## Tips04. 距離を利用した頂点ペイント
オブジェクトSpaceにて、Rayのヒット位置に近い頂点の頂点カラーを書き換えます。
(ワールドSpaceで距離を判定してしまうと、オブジェクトのスケールが変わったときに塗り替わる範囲が変わってしまうため
オブジェクトSpaceで距離を判定します)

```cs
// Rayのヒット位置(オブジェクトSpace)
var hitPositionOS = transform.InverseTransformPoint(hit.point); 

// 頂点カラー 取得
var colors = new List<Color>(sharedMesh.vertexCount);
sharedMesh.GetColors(colors); 
    
// 頂点カラーの書き換え
for (int i = 0; i < vertices.Count; i++)
{
    var v = vertices[i]; // 頂点座標(オブジェクトSpace)
    float sqrDistanceOS = (hitPositionOS - v).sqrMagnitude; // Rayヒットと頂点の距離 (オブジェクトSpace)
    if (sqrDistanceOS > component.BrushSize * component.BrushSize) // 距離がある程度離れている頂点は除外する
        continue;

    colors[i] = Color.Lerp(colors[i], component.PaintColor, component.PaintColor.a);
}

// 頂点カラー 設定
sharedMesh.SetColors(colors);
```


# 頂点ペイントツールの実装 

## VertexPaintTool.cs
頂点カラーペイントしたいMeshのオブジェクトに以下のコンポーネントをアタッチします。

```cs:VertexPaintTool.cs
using UnityEngine;

[RequireComponent(typeof(MeshFilter))]
public class VertexPaintTool : MonoBehaviour
{
    [SerializeField] private MeshFilter meshFilter;
    [SerializeField] private Color paintColor = new Color(0f, 0f, 0f, 1f); // ブラシカラー
    [SerializeField, Range(0f, 1f)] private float brushSize = 0.3f; // ブラシサイズ

    public Color PaintColor => paintColor;
    public float BrushSize => brushSize;
    
    /// <summary>
    /// メッシュ
    /// </summary>
    public Mesh SharedMesh
    {
        get
        {
            if (meshFilter == null) 
                meshFilter = GetComponent<MeshFilter>();

            return meshFilter.sharedMesh;
        }
    }

    /// <summary>
    /// 色のリセット
    /// </summary>
    public void ResetColor()
    {
        Color[] colors;
        if (SharedMesh.colors.Length == 0)
        {
            colors = new Color[SharedMesh.vertexCount];
        }
        else
        {
            colors = SharedMesh.colors;
        }
        for (int i = 0; i < colors.Length; i++)
        {
            colors[i] = Color.white;
        }
        SharedMesh.colors = colors;
    }
}
```


## VertexPaintToolEditor.cs
以下のスクリプトをEditorフォルダの中に入れます

```cs:VertexPaintToolEditor.cs
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(VertexPaintTool))]
public class VertexPaintToolEditor : Editor
{
    private List<Vector3> vertices = new List<Vector3>();

    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI();

        if (GUILayout.Button("Reset Color"))
        {
            var component = target as VertexPaintTool;
            component.ResetColor();
        }
    }

    private void OnSceneGUI()
    {
        // マウスクリックで頂点カラーペイント
        if (Event.current.type == EventType.MouseDown &&  Event.current.button == 0)
        {
            var component = target as VertexPaintTool;
            if (EditorRaycastHelper.RaycastAgainstScene(out var hit))
            {
                // ヒット位置(オブジェクトSpace)
                var hitPositionOS = component.transform.InverseTransformPoint(hit.point); 

                Mesh sharedMesh = component.SharedMesh;
                
                // 頂点の取得
                if (vertices.Count != sharedMesh.vertexCount)
                    component.SharedMesh.GetVertices(vertices);
                
                if (sharedMesh.colors.Length == 0)
                    component.ResetColor();

                // 頂点カラーを取得
                var colors = new List<Color>(sharedMesh.vertexCount);
                sharedMesh.GetColors(colors);
                
                // 頂点カラーの書き換え
                for (var i = 0; i < vertices.Count; i++)
                {
                    var v = vertices[i]; // 頂点座標(オブジェクトSpace)
                    float sqrDistanceOS = (hitPositionOS - v).sqrMagnitude; // Rayヒットと頂点の距離 (オブジェクトSpace)
                    if (sqrDistanceOS > component.BrushSize * component.BrushSize) // 距離がある程度離れている頂点は除外する
                        continue;

                    // 色の書き換え
                    colors[i] = Color.Lerp(colors[i], component.PaintColor, component.PaintColor.a);
                }
                
                // 頂点カラー設定
                sharedMesh.SetColors(colors);
            }
        }
    }
}
```