Unity
Rendering
CommandBuffer
UnityDay 14

GameObjectを使わないで2D描画

この記事では、DrawMesh を使った GameObject (MeshRenderer) を生成しない描画方法を紹介します。
表題もサンプルも2D描画ですが、3Dでもいけます。

すでに DrawMesh をご存じの方でも、CommandBuffer 版 DrawMesh で書いたのと、最後の方に TextGenerator と組み合わせたダイナミックテキスト描画も載せたので、少しでも参考になれば幸いです。

サンプルコード

以下は画面左下に真っ赤な正方形を描画するスクリプトです。Main Camera にくっつけるだけで動作します。

using UnityEngine;
using UnityEngine.Rendering;

[RequireComponent(typeof(Camera))]
public sealed class Main : MonoBehaviour
{
    const int viewportW = 320;
    const int viewportH = 240;

    void Start()
    {
        // メッシュ(2D矩形)を用意する
        var mesh = new Mesh();
        mesh.vertices = new[] {
            new Vector3(0f, 0f), // 左下
            new Vector3(1f, 0f), // 右下
            new Vector3(0f, 1f), // 左上
            new Vector3(1f, 1f)  // 右上
        };
        mesh.triangles = new[] {
            0, 2, 1,
            2, 3, 1
        };

        // マテリアル(塗りつぶしの赤)を用意する
        var material = new Material(Shader.Find("Unlit/Color"));
        material.SetColor("_Color", Color.red);

        // コマンドバッファを作成する
        var commandBuffer = new CommandBuffer();
        commandBuffer.name = "TestCommand";

        // 描画先テクスチャを現在のレンダリングカメラに指定
        commandBuffer.SetRenderTarget(BuiltinRenderTextureType.CameraTarget);

        // 左下 320x240 の領域を描画ビューポートに指定
        commandBuffer.SetViewport(new Rect(0, 0, viewportW, viewportH));

        // 今回は2D描画なので、左下原点でドットバイドットになるような変換行列を指定
        commandBuffer.SetViewMatrix(Matrix4x4.TRS(new Vector3(-1f, -1f, 0), Quaternion.identity, new Vector3(2f / viewportW, 2f / viewportH, 1f)));

        // レンダーターゲットを白く塗りつぶす
        commandBuffer.ClearRenderTarget(true, true, Color.white);

        // 原点に 50x50 の赤い矩形を描画
        commandBuffer.DrawMesh(mesh, Matrix4x4.Scale(new Vector3(50f, 50f, 1f)), material);

        // メインカメラを取得し、背景を真っ黒にする
        var camera = GetComponent<Camera>();
        camera.clearFlags = CameraClearFlags.SolidColor;
        camera.backgroundColor = Color.black;

        // カメラにコマンドバッファを登録する。描画タイミングはOpaqueの直前
        camera.AddCommandBuffer(CameraEvent.BeforeForwardOpaque, commandBuffer);
    }
}

画面左下に真っ赤な正方形を描画する

DrawMesh で描画する利点

通常、Unity では Renderer コンポーネントを付けた GameObject をシーンに配置することで、様々なものを描画します。
モデルデータであれば MeshRenderer、uGUI であれば CanvasRenderer など。

これは毎フレーム

  1. シーンに存在する GameObject をイテレートして
  2. そのうち有効な Renderer を持っているものを列挙し
  3. カリングとかバッチングとかしつつ適切な描画命令に変換する

という処理をしていることを意味しますが、まあPCではあまり問題になりませんがモバイル等ではイテレートするだけでも割合的に結構な負荷になってしまいます。

また GameObject の生成は全然軽くないので、描画物を増やした瞬間だけfpsが落ちたり踏んだり蹴ったり。
(もちろんこれはオブジェクトプールという手段で緩和できます)

これを DrawMesh を用いた描画に変えることで、シーンのオブジェクト列挙やカリング・バッチング等もろもろの処理が軽くなり、CPU処理を削減することができます。
(先に上げた処理が視錐台カリングやダイナミックバッチングによる効果を上回る場合に限る)

あと純粋にレンダーパケット(マテリアル+メッシュ)を渡すだけで即描画してくれるので、できるだけスクリプトで処理したい場合にとても素直に書けて分かりやすいというのもあります。
例えば Unicessing とか中で DrawMesh を使いまくっている気がします。 (おそらく Graphics.DrawMesh)

尚さらに低レベルな UnityEngine.GL クラスもありますが、これと比較して DrawMesh が優れている点として

  1. プラットフォームごとの座標系の違いを吸収してくれる(シェーダ側の対応は要る)
  2. メインスレッドを邪魔しない(レンダースレッドで実行される)

が挙げられます。とくに座標系は OpenGL, Metal で大いにハマるのでありがたいです。
あと、GLほど低レベルなものを使うなら Unity を使っている意味が分からなくなります。

TextGeneratorと組み合わせてテキスト描画

uGUIのダイナミックテキストの仕組みを拝借することで、好きなフォントで自由な文字列を表示することができます。

using UnityEngine;
using UnityEngine.Rendering;
using System.Collections.Generic;

[RequireComponent(typeof(Camera))]
public sealed class Main : MonoBehaviour
{
    public Font _Font;

    const int viewportW = 320;
    const int viewportH = 240;

    void Start()
    {
        // テキストメッシュを用意する
        var str = "Hello world!";
        var mesh = new Mesh();
        var generator = new TextGenerator(str.Length);
        var settings = new TextGenerationSettings()
        {
            textAnchor = TextAnchor.LowerLeft,
            font = _Font,
            fontSize = 24,
            color = Color.red,
            fontStyle = FontStyle.Normal,
            verticalOverflow = VerticalWrapMode.Overflow,
            horizontalOverflow = HorizontalWrapMode.Overflow,
            alignByGeometry = true,
            richText = false,
            lineSpacing = 1f,
            scaleFactor = 1f,
            resizeTextForBestFit = false
        };
        generator.Populate(str, settings);
        convertToMesh(ref mesh, ref generator);

        // コマンドバッファを作成する
        var commandBuffer = new CommandBuffer();
        commandBuffer.name = "TextCommand";

        // 描画先テクスチャを現在のレンダリングカメラに指定
        commandBuffer.SetRenderTarget(BuiltinRenderTextureType.CameraTarget);

        // 左下 320x240 の領域を描画ビューポートに指定
        commandBuffer.SetViewport(new Rect(0, 0, viewportW, viewportH));

        // 今回は2D描画なので、左下原点でドットバイドットになるような変換行列を指定
        commandBuffer.SetViewMatrix(Matrix4x4.TRS(new Vector3(-1f, -1f, 0), Quaternion.identity, new Vector3(2f / viewportW, 2f / viewportH, 1f)));

        // レンダーターゲットを白く塗りつぶす
        commandBuffer.ClearRenderTarget(true, true, Color.white);

        // 原点に文字列を描画
        commandBuffer.DrawMesh(mesh, Matrix4x4.identity, _Font.material);

        // メインカメラを取得し、背景を真っ黒にする
        var camera = GetComponent<Camera>();
        camera.clearFlags = CameraClearFlags.SolidColor;
        camera.backgroundColor = Color.black;

        // カメラにコマンドバッファを登録する。描画タイミングはTransparentの直前
        camera.AddCommandBuffer(CameraEvent.BeforeForwardAlpha, commandBuffer);
    }

    /// <summary>
    /// <see cref="UIVertex"/> を <see cref="Mesh"/> に変換する
    /// </summary>
    void convertToMesh(ref Mesh mesh, ref TextGenerator generator)
    {
        var vertexCount = generator.vertexCount;
        var v = new List<Vector3>(vertexCount);
        var u = new List<Vector2>(vertexCount);
        var t = new List<int>(vertexCount * 6);
        var c = new List<Color>(vertexCount);

        var uiverts = generator.verts;
        for (var i = 0; i < vertexCount; i += 4)
        {
            for (var j = 0; j < 4; ++j)
            {
                var idx = i + j;
                v.Add(uiverts[idx].position);
                c.Add(uiverts[idx].color);
                u.Add(uiverts[idx].uv0);
            }
            t.Add(i);
            t.Add(i + 1);
            t.Add(i + 2);
            t.Add(i + 2);
            t.Add(i + 3);
            t.Add(i);
        }

        mesh.Clear();
        mesh.SetVertices(v);
        mesh.SetUVs(0, u);
        mesh.SetTriangles(t, 0);
        mesh.SetColors(c);
        mesh.RecalculateBounds();
    }
}

TextGeneratorと組み合わせてテキスト描画

この例では文字が変わらないので省略していますが、ダイナミックテキストは裏でフォントごとに専用テクスチャが再生成されるタイミングがあるので、リビルドイベントを捕捉して必ずメッシュも再生成するようにしないと表示が大きく崩れます。

最後に

この記事に載せたサンプルコードは1つのメッシュしか描画していませんが、複数の描画命令をコマンドバッファに積む際は、以下の工夫もあるといいです。

  • 命令をマテリアルでソートする(パスコールを減らすため)
  • 命令をz-indexでソートし手前から奥に描画する(opaqueのみ)
  • 視錐台カリング(不要な描画命令をカリングする)
  • ダイナミックバッチング(ひとつのメッシュに複数のプリミティブを結合する →

Unity Advent Calendar 2017 14日目

昨日: ScriptableObjectをマスターデータとして扱うあれこれ @keidroid
明日: @tyutana12