LoginSignup
41
24

More than 5 years have passed since last update.

Unityで点だけのメッシュを動的に生成する

Last updated at Posted at 2018-12-18

点の生成

3Dの編集モードで新規にプロジェクトを作成するか、新規シーンを作成してから、3D Object→Cubeを配置します。座標は(0,0,0)にしておくと素直にカメラに映ります。
capt1.png

作成したCubeに以下の内容のスクリプトをアタッチします。

CreateSimplePointMesh
using UnityEngine;

public class CreateSimplePointMesh : MonoBehaviour
{
    void Start()
    {
        int numPoints = 60000; // 点の個数
        float r = 1.0f; // 半径

        Mesh meshSurface = CreateSimpleSurfacePointMesh(numPoints, r);
        GetComponent<MeshFilter>().mesh = meshSurface;
    }

    /// <summary>
    /// 球の表面にランダムに点を生成
    /// </summary>
    /// <param name="numPoints">点の数</param>
    /// <param name="radius">球の半径</param>
    /// <returns></returns>
    Mesh CreateSimpleSurfacePointMesh(int numPoints, float radius)
    {
        Vector3[] points = new Vector3[numPoints];
        int[] indecies = new int[numPoints];

        for(int i = 0; i < numPoints; i++)
        {
            float z = Random.Range(-1.0f, 1.0f);
            float th = Mathf.Deg2Rad * Random.Range(0.0f, 360.0f);
            float x = Mathf.Sqrt(1.0f - z * z) * Mathf.Cos(th);
            float y = Mathf.Sqrt(1.0f - z * z) * Mathf.Sin(th);

            points[i] = new Vector3(x, y, z) * radius; // 頂点座標
            indecies[i] = i; // 配列番号をそのままインデックス番号に流用
        }

        Mesh mesh = new Mesh
        {
            vertices = points
        };
        mesh.SetIndices(indecies, MeshTopology.Points, 0); // 1頂点が1インデックスの関係

        return mesh;
    }
}

実行すると、球の表面上のランダムな位置に、6万個の点が置かれている3Dモデルデータが表示されます。
capt3.png

作成方法はシンプルで、点の三次元座標が入っているVector3型の配列を用意して、それをMesh型のインスタンスに引き渡すだけです。配列番号を頂点インデックスに設定したり、Meshの種別を設定したりする必要もありますが、そういう手順が必要という認識だけで充分です。

上記のスクリプトでは、最初に作成しておいたCubeのMeshを実行時に置き換え(乗っ取り)しています。
capt2.png

もちろん、最初からMesh FilterのCubeを外しておくこともできますし、CreateEmptyで空のゲームオブジェクトを作成してから、Mesh FilterとMesh Renderer(&Material)をAdd Componentしても同じ結果になります。

名前が紛らわしいので、CubeからSurfaceにして、Mesh FilterのCubeを外して、ついでにBox Colliderコンポーネントも削除しておきましょう。実行結果の見た目は変わりません。
capt4.png

ところで、6万個じゃ物足りないなー100万個くらいドーンと表示したいなーと思って
int numPoints = 60000; // 点の個数
ここを1000000に書き換えてもそれほど変化しません。というのも、旧来のUnityでは65535頂点を超えるメッシュを扱えなかったのです(過去形)。
Unity2017.3からはこの制約が無くなったそうなので、対応させるようにメッシュ生成部分のコードを書き換えます。

Mesh mesh = new Mesh();
mesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32; // 追加
mesh.vertices = points;
mesh.SetIndices(indecies, MeshTopology.Points, 0);

capt5.png
点っぽさがわからなくなるくらい、点が敷き詰められました。

個々の点は、ライトの影響を受けますので、ライトをぐるぐる回すと表示も変化します。
capt6.png
ライトの影響を受けるのは、最初に作成したCubeについていたMesh Rendererで指定されているMaterialがDefault-Materialだからです。Unityに最初から用意されているCubeやSphereなどがライトの影響を受けるのと同じ仕組みですね。
なので、新規にMaterialを作成して、Alberdoの色を変えると、点の色が変わります。
capt7.png

また、Post Processing StackのBloomを使うと、光っているように見せることもできます。(下記の画像は6万個の点です)
capt8.png

Default-Material(で使われているStandardシェーダ)を使うと、Materialの色の変更は全ての点の色に反映されることになります。つまり、個々の点の色を変えるようなことはできません。

ただ、Mesh自体は、点の色情報を持たせることが出来るようになっています。

    Mesh CreateSimpleSurfacePointMesh(int numPoints, float radius)
    {
        Vector3[] points = new Vector3[numPoints];
        int[] indecies = new int[numPoints];
        Color[] colors = new Color[numPoints]; // 追加

        for(int i = 0; i < numPoints; i++)
        {
            // ~ 略 ~
            points[i] = new Vector3(x, y, z) * radius; // 頂点座標
            indecies[i] = i; // 配列番号をそのままインデックス番号に流用
            colors[i] = new Color(Random.Range(0.0f, 1.0f), Random.Range(0.0f, 1.0f), Random.Range(0.0f, 1.0f)); // 追加
        }

        Mesh mesh = new Mesh();
        //  ~ 略 ~
        mesh.colors = colors; // 追加

        return mesh;
    }

あとは、この個々の点の色を使って描画するようにすればよいわけです。

点の色を個別に表示したい

Default-Materialでは点(頂点)の色情報は使ってくれませんが、Sprites-Defaultマテリアルは頂点の色情報を使った描画をしてくれるようなので、Materialを差し替えると、各点に色が着くようになります。ただ、Sprites-Defaultが使用しているシェーダはライトを反映してくれません。
capt9.png
上のキャプチャ画像ではMeshの名前が付いていませんが、mesh.name = "PointsMesh";という行を追加すると名前が表示されるようになります。

作ったメッシュを保存したい

作成したメッシュを、ファイルとして保存することが出来ます。void Start()のメッシュ生成後に、次の行を追加してから実行すると、Assetsフォルダの直下に動的に生成したメッシュが保存されます。(約62MBytesほどのファイルになります)

#if UNITY_EDITOR
        UnityEditor.AssetDatabase.CreateAsset(meshSurface, "Assets/PointsOnSurface.asset");
        UnityEditor.AssetDatabase.SaveAssets();
#endif

capt10.png

千葉の海は深い

ここまでの点は、球の表面の式を使って点をランダムに置いたものでしたが、もう少し面白そうな点のデータはないかなとネットで探したところ、日本海洋データセンターに「500mメッシュ水深データ」という、日本近海の水深データがありました。

500mメッシュ水深データ

データは、500mメッシュ水深データから取得できます。今回は東京湾の周りのデータを使わせてもらいました。
capt1.png
データの使用条件は以下の通りです。データの扱いには慎重に。

データ等を使用した論文等成果物には、「日本海洋データセンター」の資料を使用した旨を記し、成果物1部の提出をお願いいたします。これらの成果物は日本海洋データセンターに保管され、二次、三次の利用に供されます。

データの形式は、緯度、経度、水深という組み合わせのデータが行単位で並んでいるテキストファイルです。扱いやすそうですね。行データの構成は

種別(0または1)、緯度(単位:度)、経度(単位:度)、水深(単位:m)

となっています。まずは、このデータを読み込み&保持するクラスを作成します。

DepthDataLoader.cs
using System.IO;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

public struct DepthData
{
    public float Latitude { get; private set; }
    public float Longitude { get; private set; }
    public float Depth { get; private set; }

    public DepthData(float v1, float v2, float v3)
    {
        Latitude = v1;
        Longitude = v2;
        Depth = v3;
    }
}

public class DepthDataLoader
{
    public List<DepthData> DepthData { get; private set; }

    public float MaxDepth
    {
        get { return DepthData.Max(value => value.Depth); }
    }

    public float[] MinMaxLatitude()
    {
        return new float[] { DepthData.Min(value => value.Latitude), DepthData.Max(value => value.Latitude) };
    }

    public float[] MinMaxLongitude()
    {
        return new float[] { DepthData.Min(value => value.Longitude), DepthData.Max(value => value.Longitude) };
    }

    public void LoadData(string pointDataStrings)
    {
        DepthData = new List<DepthData>();

        var reader = new StringReader(pointDataStrings);
        var regSpaces = new Regex(" +");

        while(reader.Peek() > -1)
        {
            var line = reader.ReadLine();
            // string[] words = line.Split(" "); // これだと複数並びのスペースに対応できない
            var words = regSpaces.Split(line);
            DepthData.Add(new DepthData(float.Parse(words[1]), float.Parse(words[2]), float.Parse(words[3])));
        }

        reader.Close();
        reader.Dispose();
    }
}

一行単位でデータを読み取り、緯度・経度・水深の3つのfloat値を持つ構造体(DepthData)をリスト化しています。構造体ではなくクラスでも良さそうですがなんとなくです。ついでに、データの範囲をLINQで取得するメソッドも入れてあります。

これらのデータを使って、緯度・経度・水深をそのまま(Z,X,Y)としてメッシュ用の点にすれば良さそうですが、水深の単位はメートルで、緯度と経度は度ですから、メートルに揃える必要があります。緯度と経度は以下の通りです。

2標準緯線によるランベルト正角円錐図法を使用してメートルスケールの平面座標
緯度(単位:度)、経度(単位:度)

「ランベルト正角円錐図法」をググると、どうやら“地図上で長さが正しく表されます”だそうですが、提供データは0度から180度のままのようです。ここからメートルへの変換方法がわからないのですが、地球の北極から赤道までの90度を地球の半径に合わせればいいような気がします。
地球の半径をググったところ6371kmらしいので、1度は6371/90 = 70.7888 = 7078.788mということになります(します)。この半径値は天文学での数字だからとか、地球は楕円なのではとか、細かいことは気にしません(しないことにします)。

後は、これまでと同じように三次元座標値をメッシュデータにするだけです。

CreateWaterDepthMesh.cs
using UnityEngine;

public class CreateWaterDepthMesh : MonoBehaviour
{
    // 地球の半径:約6371kmを90度の範囲にしてメートル値にする
    static readonly float DegToMeter = 6371 / 90 * 100;

    void Start ()
    {
        Mesh mesh = CreateMesh();
        GetComponent<MeshFilter>().mesh = mesh;
    }

    Mesh CreateMesh()
    {
        var textAsset = Resources.Load("jodc-depth500mesh-20181112100538") as TextAsset; // ダウンロードした水深データ(テキストファイル)
        var depthDataLoader = new DepthDataLoader();
        depthDataLoader.LoadData(textAsset.text);
        Resources.UnloadAsset(textAsset);

        float maxDepth = depthDataLoader.MaxDepth;
        float[] minMaxLatitude = depthDataLoader.MinMaxLatitude();
        float[] minMaxLongitude = depthDataLoader.MinMaxLongitude();

        Debug.Log("max depth:" + maxDepth);
        Debug.Log("Latitude:" + minMaxLatitude[0] + "," + minMaxLatitude[1]);
        Debug.Log("Longitude:" + minMaxLongitude[0] + "," + minMaxLongitude[1]);

        var numPoints = depthDataLoader.DepthData.Count;

        Vector3[] points = new Vector3[numPoints];
        int[] indecies = new int[numPoints];
        Color[] colors = new Color[numPoints];

        for(int i = 0; i < numPoints; i++)
        {
            points[i] = new Vector3(
                (depthDataLoader.DepthData[i].Longitude - minMaxLongitude[0]) * DegToMeter, // データの範囲最小値を原点にしてメートル換算
                -depthDataLoader.DepthData[i].Depth,    // 深度はマイナス方向に
                (depthDataLoader.DepthData[i].Latitude - minMaxLatitude[0]) * DegToMeter
                );
            indecies[i] = i;
            //colors[i] = new Color(0.5f - depthDataLoader.DepthData[i].Depth / maxDepth / 2, 0, 0);
            colors[i] = new Color(1.0f, 1.0f, 1.0f);
        }

        Mesh mesh = new Mesh();
        mesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
        mesh.vertices = points;
        mesh.SetIndices(indecies, MeshTopology.Points, 0);
        mesh.colors = colors;
        mesh.name = "WaterDepthPointsMesh";

        return mesh;
    }
}

日本海洋データセンターからダウンロードしたテキストファイルは、適当にResourcesフォルダを作成した場所に置いて、Resources.Load()を使って文字列として読み取っています。

読み取ったデータを見ると、東京湾近海の最大深度は6048mみたいですね、海ヤバイ。
緯度の範囲は34~36、経度は139~141ですから、これはデータ取得時の地図と一致しています。これらのデータをメートル化したり位置を補正して頂点座標にしています。

このスクリプトをMesh Filter+Mesh Renderer(+Material)を持ったゲームオブジェクトにアタッチして実行すると、それっぽい結果が出ました。
capt.png

水深毎に色分けをするのであれば、色を設定している箇所を書き換えます(MaterialもSprite-Defaultにする必要があります)。

colors[i] = new Color(0, 0.7f - depthDataLoader.DepthData[i].Depth / maxDepth / 2, 0);

capt2.png
こうしてみると、東京湾は平らで、房総半島近海は深くて深海ヤバイなのがよくわかりますね。

ちなみに、ここまでの表示結果は、Sceneビューで確認していましたが、このままではGameビュー(デフォルトのカメラ)には映りません。というのも、このメッシュは緯度経度が2度の範囲でそれを14000m相当にしています。つまり、Unityの14000Unitに該当するのですが、カメラのデフォルト設定ではClipping PlanesのFar値が1000程度なので、遠くのピクセルが映らないのですね。そもそも元のデータも500m単位の精度ですから、CameraのFar値を大きくする必要があります。
もしくは、点のデータの位置を縮小して配置するとか、GameObjectのScaleを小さくすることでも表示できるようになりますし、その方がギュッと詰まった状態になりますが、遠目には点らしさが少し損なわれますね。Unity上でオブジェクトに近寄ると点群らしさが出てきます。
capt3.png
(GameObjectのScaleを0.001、Gameビュー解像度を800x480で表示した例)

まとめ

ここでの例は、実行前にメッシュの形状が確定していますから、動的生成の意味はほとんどありません。Unityで動的にメッシュを生成できる面白さは

  • Unityだけでメッシュの編集やアセットとして保存までが出来る
  • Unity Editorやビルドしたバイナリの実行中に収集したデータからメッシュを生成する

というところにあると思います。

何かしらの入力デバイスから得た情報を即座に画面に反映したい時に、テキスト表示したり、単純な形状を持つプレファブをInstantiateするのもよいですが、メッシュを生成して表示した方が便利で軽い時もあるかもしれません。

ちょっと補足

個々の点が小さくて見えにくいので、シェーダでPSIZEを指定して点の描画サイズを変えているコードを見かけますが、これは動作環境に依存するようで、例えば、WindowsのUnity EditorではPSIZEが効かないようです。
一応、UnityをOpenGLモードで起動するとPSIZEが有効になります。
"C:\Program Files\Unity\Editor\Unity.exe" -force-opengl

41
24
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
41
24