渋谷ほととぎす通信

「Unityをわかりやすく」初心者のためのゲーム作りブログ

【Unity】ペイントアプリのようなメッシュの作り方

【Unity】ペイントアプリのようなメッシュの作り方

こんにちは、エンジニアのオオバです。

お悩みさん
お悩みさん
  • Unityでマウスを動かして絵を書くアプリを作りたい
  • 作り方のとっかかりもわからない
  • 初心者でもわかるような解説がほしい
  • オオバ
    オオバ
    本記事ではこれらの悩みを解決します。

    「ペイントアプリ」って聞くとかなり難しそうな印象を受けますよね。 実はそうでもありません。さまざまな実装方法がある中、本記事ではメッシュを使ってペイントアプリのようなものを作ってみます。

    【Unity】ペイントアプリのようなメッシュの作り方_0

    毎フレームスクリーン座標を元にメッシュを作る

    結論を簡単にまとめます。

    ペイントアプリの作り方

    ①マウス座標をワールド座標に変換

    ②ワールド座標に四角形メッシュを作成する

    ③1つ前に作成したメッシュをつなげる

    ペイントアプリでは動的にメッシュを作るテクニックと、適切な座標に配置する必要があります。本ブログでは今までにプログラミングでメッシュを作成する方法を紹介してきました。今回はその応用編です。

    まだプログラミングで動的にメッシュを作ったことがない方はこちらの記事から読むことをオススメします。

    動的にメッシュを作るのはもう大丈夫な方は、ぜひペイントアプリに挑戦してみてください。では詳細の解説に入ります。

    👉DOTweenの教科書を読んでUnityアニメーションをプログラミングしてみよう!

    ペイントアプリを作る4手順

    ペイントアプリを4つの手順で解説していきます。

    ①マウス押下したスクリーン座標をワールド座標に変換

    ②ワール座標内に初回メッシュの作成

    ③メッシュの接続

    ④メッシュを両面描画

    手順①マウス押下したスクリーン座標をワールド座標に変換

    まず最初にやるべきことはマウス押下した座標の特定です。メッシュはワールド座標内に作るため、マウスの座標(スクリーン座標)をワールド座標に変換する必要があるのです。

    【Unity】ペイントアプリのようなメッシュの作り方_1

    スクリーン座標とは、2Dのディスプレイ上の座標で左下が原点、右上がスクリーンのサイズになります。

    Unityでは簡単にマウスの座標をワールド座標に変換するメソッドがあります。Cameraコンポーネントの 「 ScreenToWorldPointメソッド 」です。 マウスの座標を渡すとでワールド座標に変換 してくれます。

    ScreenToWorldPointメソッドについては、こちらの記事で詳しく解説していますので読んでみてください。

    ワールド座標が決まったら最初のメッシュを作ります。

    手順②ワール座標内に最初のメッシュを作る

    マウス押下したときにやることは、最初のメッシュを作ることです。ここで言うメッシュとは四角形のメッシュです。

    💻ソースコード : 初回描画の四角形メッシュの
    // _widthは筆の幅、posはワールド座標に変換したマウス座標  
    var pt0 = new Vector3(pos.x - _width * 0.5f, pos.y, 0);  
    var pt1 = new Vector3(pos.x + _width * 0.5f, pos.y, 0);  
    var pt2 = new Vector3(pos.x - _width * 0.5f, pos.y, 0);  
    var pt3 = new Vector3(pos.x + _width * 0.5f, pos.y, 0);  
    _vertices.Add(pt0);  
    _vertices.Add(pt1);  
    _vertices.Add(pt2);  
    _vertices.Add(pt3);  
    

    初回フレームは4頂点で四角形メッシュを作ります。この最初の四角形メッシュが筆を画面に置いたときに付いたインク表現になるのです。

    手順③2頂点ずつ追加して四角形メッシュをつなげる

    初回は四角形メッシュを作るために4頂点作りましたが、以降は2頂点ずつ増やして四角形を伸ばしていきます。ソースコードにすると以下のような感じです。

    💻ソースコード : 描画2フレーム以降の処理
    var pt2 = new Vector3(pos.x - _width * 0.5f, pos.y, 0);  
    var pt3 = new Vector3(pos.x + _width * 0.5f, pos.y, 0);  
    _vertices.Add(pt2);  
    _vertices.Add(pt3);  
    

    次の図のように スクリーン座標からワールド座標に変換 した座標をもとに各頂点の座標を決めていきます。

    【Unity】ペイントアプリのようなメッシュの作り方_2

    数字は頂点の番号、つまりインデックス配列です。このようなイメージでメッシュを描画していきます。

    インデックス配列の順が大事

    今回のペイントアプリのインデックス配列は以下のような順です。

    0, 1, 3  
    0, 3, 2  
    

    次のコードで実現しています。

    _indices.Add(_offsetIndex);  
    _indices.Add(_offsetIndex + 1);  
    _indices.Add(_offsetIndex + 3);  
    _indices.Add(_offsetIndex);  
    _indices.Add(_offsetIndex + 3);  
    _indices.Add(_offsetIndex + 2);  
    

    初回に描画する四角形のインデックスは「0, 1, 3, 2」です。2つ目の四角形は「2, 3, 5, 4」というインデックスになります。ソースコード上の _offsetIndex とは初回は「0」、2回目は「2」が入ります。

    つまり四角形をつなげるためのインデックス値をオフセットしているのです。

    _offsetIndex += 2;  
    

    四角形を描画し終えた番号に2を加算するとインデックスとメッシュは上手くつながります。

    手順④メッシュの両面描画

    このペイントアプリを動かしてみるとわかるのですが、ときどきメッシュが表示されなくなります。これはメッシュの背面が描画されていないからです。

    シェーダーのデフォルト値は片面描画です。そこでシェーダーのかリング設定を変更します。 「 Cull Off 」に設定しましょう。するとメッシュは両面描画され、メッシュが消えることがなくなります。

    Pass  
    {
        // 両面描画設定  
        Cull Off  
    

    シェーダーのソースコードは記事後半「ソースコード全文の共有」で確認してみてください。

    【Unity】ペイントアプリのようなメッシュの作り方_3

    このようにマウスの位置にあわせてペイントされるアプリができました。この後は色を変えたり、ブラシのようなテクスチャを貼ればおもしろいアプリになりそうですね。

    ペイントのようなメッシュの作り方まとめ

    記事の内容を簡単にまとめます。

    ペイントのようなメッシュの作り方まとめ

    1. マウス座標をワールド座標に変換

    2. ワールド座標にメッシュを生成

    3. 初回描画は4頂点描画

    4. 2回目以降は2頂点ずつ追加して描画

    こんな感じです。

    ペイントアプリ、思っていたより難しくなかったのではないでしょうか。もちろん、プログラミング初心者にはハードルが高かったかもしれません。

    とくにメッシュをつなげるときのインデックス配列順は頭を悩ませるポイントです。

    この記事を読んで全く意味がわからない方は、まず基礎から復習することをおすすめします。今回はあくまで応用編でした。基本的な解説もどうしても少なくなってしまいます。

    3Dプログラミングを基礎から学びたい方はこちらの記事をオススメします。初心者向けにかなり丁寧に解説しました。この記事を理解した後、再度本記事のペイントアプリに挑戦してもらえたら嬉しいです。

    ソースコード全文の共有

    最後に今回使用したソースコード全文を共有します。全体で2ファイルです。C#ファイル1、シェーダーファイル1です。シェーダーファイルを作って、C#ファイルはGameObjectにAddComponentすると、、この記事で紹介したマウスを追いかけるペイント風アプリが体験できます。

    💻ソースコード : C#コード全文
    using System.Collections.Generic;  
    using UnityEngine;  
    
    [RequireComponent(typeof(MeshFilter))]  
    [RequireComponent(typeof(MeshRenderer))]  
    public class DynamicBrushMesh : MonoBehaviour  
    {
        private MeshFilter _filter;  
        private MeshFilter Filter => _filter ? _filter : _filter = GetComponent<MeshFilter>();  
    
        private readonly List<Vector3> _vertices = new List<Vector3>();  
        private readonly List<int> _indices = new List<int>();  
        // 筆の太さ  
        [SerializeField] private float _radius = 0.1f;  
    
        private bool _isInit;  
        private int _offsetIndex = 0;  
        private Mesh _mesh;  
    
        private void Awake()  
        {
            var renderer = GetComponent<MeshRenderer>();  
            renderer.material = new Material(Shader.Find("Unlit/Brush"));  
            Clear();  
        }
    
        void Update()  
        {
            if (Input.GetMouseButton(0))  
            {
                var mousePos = Input.mousePosition;  
                mousePos.z = Mathf.Abs(Camera.main.transform.position.z);  
                var pos = Camera.main.ScreenToWorldPoint(mousePos);  
                Draw(pos);  
            }
            else if(_isInit)  
            {
                // マウスが離れたので初期化  
                Clear();  
            }
        }
    
        private void Clear()  
        {
            _isInit = false;  
            _mesh = null;  
            _vertices.Clear();  
            _indices.Clear();  
            _offsetIndex = 0;  
        }
    
        private void Draw(Vector3 pos)  
        {
            UpdateVertex(pos);  
            if (_mesh == null) _mesh = new Mesh();  
    
            _mesh.SetVertices(_vertices);  
            _mesh.SetIndices(_indices, MeshTopology.Triangles, 0);  
            _mesh.RecalculateNormals();  
            Filter.mesh = _mesh;  
        }
    
        /// <summary>  
        /// 頂点情報の更新  
        /// </summary>  
        private void UpdateVertex(Vector3 pos)  
        {
            if (_isInit == false)  
            {
                // 初回処理  
                _isInit = true;  
                var pt0 = new Vector3(pos.x - _radius * 0.5f, pos.y, 0);  
                var pt1 = new Vector3(pos.x + _radius * 0.5f, pos.y, 0);  
                var pt2 = new Vector3(pos.x - _radius * 0.5f, pos.y, 0);  
                var pt3 = new Vector3(pos.x + _radius * 0.5f, pos.y, 0);  
                _vertices.Add(pt0);  
                _vertices.Add(pt1);  
                _vertices.Add(pt2);  
                _vertices.Add(pt3);  
            }
            else  
            {
                // 2回目以降  
                var pt2 = new Vector3(pos.x - _radius * 0.5f, pos.y, 0);  
                var pt3 = new Vector3(pos.x + _radius * 0.5f, pos.y, 0);  
                _vertices.Add(pt2);  
                _vertices.Add(pt3);  
            }
    
            _indices.Add(_offsetIndex);  
            _indices.Add(_offsetIndex + 1);  
            _indices.Add(_offsetIndex + 3);  
            _indices.Add(_offsetIndex);  
            _indices.Add(_offsetIndex + 3);  
            _indices.Add(_offsetIndex + 2);  
    
            // 変数更新処理  
            _offsetIndex += 2;  
        }
    }
    
    💻ソースコード : シェーダー全文
    Shader "Unlit/Brush"  
    {
        SubShader  
        {
            Pass  
            {
                // 両面描画設定  
                Cull Off  
    
                CGPROGRAM  
                #pragma vertex vert_img  
                #pragma fragment frag  
    
                #include "UnityCG.cginc"  
    
                fixed4 frag (v2f_img i) : SV_Target  
                {
                    // 筆の色設定  
                    return fixed4(1, 1, 1, 1);  
                }
                ENDCG  
            }
        }
    }
    

    この記事は以上です。何かものづくりの参考になれば幸いです。

    「Unity初心者大学」というUnity初心者向けのYouTube始めました!!
    ぜひチャンネル登録をお願いします!

    最後まで読んでいただきありがとうございました!
    すばらしいUnityライフをお過ごしください。

    オススメ記事
    検証環境
    • Unity2020.3.19f1
    • macOS Big Sur11.5.2