Help us understand the problem. What is going on with this article?

[Unity] クォータービューのドット絵に深度バッファを適用する(重なり順解決)

前置き

withTerrain.jpgsceneViewAni.gif

クオータービューゲームってご存知ですか?
自分はあんまり最近のゲーム知らなくて、古い例しか出てきませんが、「A列車で行こう4」とか「SimCity 2000」のような、等角投影図風に描いたドット絵を配置したゲームのことです。見下ろし型とか、2.5Dとか、鳥瞰図なんて言われる場合もありますね(これらは必ずしもクオータービューだけを指す言葉じゃないですが)。英語だと isometric view と呼ぶようです。

3Dのような奥行きのある絵ですが、実体は二次元(Sprite)なので描画順(z-オーダー)で重なりを制御する必要があります。

唐突ですがここで問題。

greenFrame.pngorangeSteps.pngpurpleCargo.png

上に挙げたのドット絵それぞれから3つの Sprite を作って、下の画像を作るにはどういう描画順で描画すればいいでしょうか?

Image10000.png

答えは、「(※この問題の前提では)不可能」です。
それぞれの重なってる部分を見てください。橙>紫紫>緑緑>橙となるようなzオーダーの組み合わせは作れません。方程式で言うなら「解なし」です。

こういう絵を作りたいときに、他の人たちはどうやっているかというと、Sprite をタイルなどに分割して描く(もちろん、その時分割部分ごとに適切な順番になるようにする)のが一般的のようです。

https://gamedevelopment.tutsplus.com/tutorials/creating-isometric-worlds-a-primer-for-game-developers--gamedev-6511
https://blog.pocketcitygame.com/cheating-at-z-depth-sprite-sorting-in-an-isometric-game/
http://ashley.baldock.me/tilecutter

やりたいこと

クオータービューのドット絵ゲームを作るとき、 スプライトを分割せずに重なり順を解決したい。そのためにドット絵に深度バッファを適用したい。
そこで下記の図のように3Dオブジェクトにドット絵を正射影表示してやろうと思いました。

これができれば、重なり順解決だけでなく他の3Dオブジェクト(例えば Terrain)とドット絵を上手い具合に共存させることもできます。

実は Unity ではないですが、既にこのアイデアを実現してる記事があります。上記の図もそこからの拝借です。今回はこれを Unity に移植してみました。

テクスチャを立体に正射影する

マテリアル

materialG01.jpg

Unity使ってる諸氏には釈迦に説法かもしれませんが、テクスチャを2の累乗サイズに調整しないといけません。普段はUnityが勝手にやってくれるのですが、それに任せてるとテクスチャ上のドット絵の切り出し位置指定がややこしくなるので、それを避けるためです。
resizedGreen.jpgresizedOrange.jpgresizedPurple.jpg

シェーダーは透過ができてUnlitでTextureを指定できればよいので、 Sprite/Default か UI/Default を使えます。
ですが、将来の拡張を見越して、一応独自のシェーダーを用意しておきました。

PseudoSprite.shader
Shader "Custom/SeudoSprite"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            // make fog work
            #pragma multi_compile_fog

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                UNITY_FOG_COORDS(1)
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                UNITY_TRANSFER_FOG(o,o.vertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                // sample the texture
                fixed4 col = tex2D(_MainTex, i.uv);
                if(col.a <= 0.2) discard;
                // apply fog
                UNITY_APPLY_FOG(i.fogCoord, col);
                return col;
            }
            ENDCG
        }
    }
}

テンプレで生成されるUnlitシェーダーに透過処理を付け加えただけの単純なものです。

    if(col.a <= 0.2) discard;

メッシュ生成コンポーネント

スプライトに相当する役割のオブジェクトとして、メッシュ生成用コンポーネントをEmptyなgameObjectにつけて使います。
ちなみに Shed (シェド)は小屋という意味で名づけました。
prefabG01.jpg
上の図のように Mesh Renderer, Mesh Filter も追加してください。設定を弄る必要はありません。

以下ソース

Shed.cs
#if UNITY_EDITOR
using UnityEditor;
#endif
using UnityEngine;

[ExecuteInEditMode]
public class Shed : MonoBehaviour
{
    private bool needRestruct = true;

#if UNITY_EDITOR
    void OnEnable()
    {
        SceneView.onSceneGUIDelegate += this.OnEditorUpdate;
        needRestruct = true;
    }

    void OnDisable()
    {
        SceneView.onSceneGUIDelegate -= this.OnEditorUpdate;
    }

    void OnEditorUpdate(SceneView vw)
    {
        Start();
    }
#endif

    [SerializeField, Header("ドット絵の3Dサイズ")]
    private Vector3Int size;

    [SerializeField, Header("テクスチャの最手前座標")]
    private Vector2Int pivot;

    // ドット絵を保持するマテリアル
    public Material material;

    // マテリアルのメインテクスチャサイズ
    private Vector2Int texSize;

    private void Awake()
    {
        // なぜか Start/Awake の両方から Mesh初期化しないと、シーン再生時に表示されない
        Start();
    }

    void Start()
    {
        if (!needRestruct) return;
        needRestruct = true;

        Mesh mesh = InitializeCube();
        texSize = GetTextureSize();
        GetComponent<MeshFilter>().sharedMesh = mesh;
        GetComponent<MeshRenderer>().material = material;
    }

    /// <summary>
    /// テクスチャサイズを取得
    /// </summary>
    /// <returns></returns>
    private Vector2Int GetTextureSize()
    {
        Debug.Assert(material.mainTexture != null, "Main texture is NULL!");

        var tex = material.mainTexture;

        return new Vector2Int(tex.width, tex.height);
    }

    /// <summary>
    /// pivotからの相対位置をUV座標に変換する
    /// </summary>
    /// <param name="offestX"></param>
    /// <param name="offsetY"></param>
    /// <returns></returns>    
    private Vector2 ToUV(float offestX, float offsetY)
    {
        return new Vector2((pivot.x + offestX) / texSize.x, (texSize.y - pivot.y + offsetY) / texSize.y);
    }

    /// <summary>
    /// メッシュを生成する
    /// </summary>
    /// <returns></returns>
    private Mesh InitializeCube()
    {

        Mesh mesh = new Mesh();

        /*
         *   6
         * 5< >3
         * | 1 |
         * 4<|>2
         *   0
         */
        var vertices = new Vector3[] {            
            new Vector3(0, 0, 0), // 0:pivot
            new Vector3(0, size.y, 0), // 1:nearest top
            new Vector3(0, 0, size.z), // 2:bottom right
            new Vector3(0, size.y, size.z), // 3:top right
            new Vector3(size.x, 0, 0), // 4:bottom left
            new Vector3(size.x, size.y, 0), // 5:top left
            new Vector3(size.x, size.y, size.z), // 6:most far top
        };

        var harfX = size.x * 0.5f;
        var harfZ = size.z * 0.5f;
        var uv = new Vector2[] {
            ToUV(0, 0), // 0:pivot
            ToUV(0, size.y), // 1:nearest top
            ToUV(-size.z, harfZ), // 4:bottom left
            ToUV(-size.z, harfZ + size.y), // 5:top left
            ToUV(size.x, harfX), // 2:bottom right
            ToUV(size.x, harfX + size.y),  // 3:top right
            ToUV(size.x - size.z, harfX + harfZ + size.y), // 6:most far top
        };

        var triangles = new int[] {
            0,3,1, 0,2,3, // Right Surface 
            5,4,0, 0,1,5, // Left Surface 
            1,3,5, 3,6,5, // Upper Surface
        };

        mesh.vertices = vertices;
        mesh.uv = uv;
        mesh.triangles = triangles;

        return mesh;
    }
}

#if UNITY_EDITOR~#endifブロックは、Sceneビューでもプレビューできるようにするためのものです。

上のソースだと行間が伸びて若干見辛いですがVS上ではコメントの図はこんな感じです。
vertIndex.jpg
0番が pivot で、1番が手前上面、6番が最奥上面… という風に割り振ってます。
基本的に一方向から見ることしか想定してませんから、裏側のメッシュは作りません。
Unity に合わせて一部書き換えましたが、やってることは参照元記事にあるInitializeCubeメソッドとほぼ同じです。
(オリジナルはオブジェクトの配置位置を反映してましたが、Unityでは親のtransformで動かせば良いので0起点に固定してます。)
テクスチャサイズが2の累乗に拡大されているので、 ToUV() メソッドでは pivot に相当するピクセルから必要な範囲だけを取り出せるように補正しています。このためアトラス画像のように同じ画像に複数のドット絵を入れて使うことも可能になっています。

カメラ

Camera01.jpg

これも2Dシーンなら基本中の基本、釈迦に説法かもしれませんが、パース(遠近法)を適用しないようにカメラを Orthographic に設定します。
Transform はお好みで調整してください、Clear Frags を Solid Color にしましたが、それ以外はデフォルト設定です。

途中結果1

ここまでの段階でこうなります。ぱっと見た感じでは既にお題の絵は出来ていますが、ドット絵が綺麗に出ていません
sceneView01.jpg

ドット絵を綺麗に表示 PixelPerfect

わざわざドット絵を使うからには、ドットが潰れたり引き伸ばされたりボヤけたりせず、元の品質を保って表示したいですね。(これを英語では PixelPerfect と言うようです。)

テクスチャのアンチエイリアスを解除

texFilterMode.jpg
デフォルトだとテクスチャにアンチエイリアスがかかってぼやけてしまうので、上のように Filter Mode を Point に設定して解除します。
また、マッピングが適切に出来ていれば大差ないのですが、 Wrap Mode も Repeat から Clamp に変更しています。

Bilinear [初期設定]
|bilinear.jpg

Point(No filter)
point.jpg
※上記キャプチャはいずれも倍率2

プロジェクション行列の調整

カメラのプロジェクション(射影)行列の調整を行う QuaterView コンポーネントを作成します。
個人的にはこれこそが元記事の肝だと思ってます。【以下引用】


ちょっと注意が必要なのは、xとzは直方体の辺に沿った長さではなく、画像の横幅に占めるドット数で指定すること。
例えば、右の絵(※上の絵)なら size = new Vector(24,18,20); となるだろう。

なぜこんな仕様なのかというと、直方体の辺に沿った長さで指定すると x=20*√3, z=24*√3 になるわけだが、√3なんて無理数を使うと小数点誤差を避けようもなく、ドット絵が歪む原因になるから避けたいのだ。

というわけで、ドット絵のピクセル単位でサイズ指定できて、スクリーンのドットに綺麗に射影するための変換行列を作ります。

QuaterView
public class QuaterView : MonoBehaviour
{
    private new Camera camera;

    [SerializeField, Header("カメラが移す中心地")]
    private Vector3Int lookAt;

    [SerializeField, Header("中心地の画面内上下位置(0~1)")]
    private float vertFocusPos;

    [SerializeField, Header("カメラを離す距離")]
    private int distance = 256;

    [SerializeField, Header("ピクセル倍率"), Min(1)]
    private int zoom = 1;


    void Awake()
    {
        camera = gameObject.GetComponent<Camera>();
        Assert.IsNotNull(camera);
        AdjustCamera();
    }

    void OnValidate()
    {
        Awake();
    }

    private float CalcOrthoSize()
    {
        // 画面の高さの半分=等倍サイズ
        return camera.pixelHeight / (zoom * 2f);
    }

    private void AdjustCamera()
    {
        camera.transform.rotation = Quaternion.identity;
        camera.transform.position = Vector3.zero;
        camera.ResetProjectionMatrix();

        int depth = (lookAt.x + lookAt.z) / 2;
        Vector3Int pos = new Vector3Int(lookAt.x - lookAt.z, lookAt.y + depth, lookAt.y - depth);

        var matrix = new Matrix4x4()
        {
            m00 = 1.0f, m01 = 0.0f, m02 =-1.0f, m03 = 0.5f - pos.x,
            m10 = 0.5f, m11 = 1.0f, m12 = 0.5f, m13 = 0.0f - pos.y,
            m20 =-0.5f, m21 = 1.0f, m22 =-0.5f, m23 = 0.0f - pos.z - distance,
            m30 = 0.0f, m31 = 0.0f, m32 = 0.0f, m33 = 1.0f            
        };

        camera.worldToCameraMatrix = matrix;
        camera.orthographicSize = CalcOrthoSize();

    }
}

下の方に出てくる matrix は元記事のこの行列を Unity 用に変換したもので、z軸の符号を反転しています。また使用するクラスの違いからパラメーターの表記順が行と列入れ替わってることに注意してください。
xnaMatrix.jpg
参考: http://logicalbeat.jp/blog/929/

この行列は camera の projectionMatrix ではなく worldToCameraMatrix にセットします。
projectionMatrix は標準的な Orthographic を使います。ピクセル等倍なサイズにするには、
camera.orthographicSize = camera.pixelHeight / 2;
に設定すればよいことがわかりました。
参考: http://light11.hatenadiary.com/entry/2019/04/17/012544

カメラの回転も不要に

元記事には具体的には書いてないですが、この変換行列は回転も含んでます。
上の方で貼ったカメラの Inspector は transform/rotation が x=22.5, y=45, z=0 となっていますが、 QuaterView を適用したカメラは回転は不要です。これも小数点丸め誤差をなくすのに有用だと思います。
quarterViewCamera.jpg
projection.jpg
なお、 worldToCameraMatrix をセットすると transform 自体が無効になるので、QuaterView の lookAt でワールドの表示目標位置を設定できるようにしています。Distanceはちょっとイジっただけだと見た目の変化はないですが、目標位置とカメラの距離を示します。 near/far plane の適用範囲をシフトさせる効果があります。

途中結果2

ドットがわかるように表示倍率x2にしてます。
fixMatrix.jpg

かなりいい感じですが、よく見るとまだドットの歪みがあるようです。
解りやすくするため、元記事のようにディザテクスチャを貼ってみました。
textureCompressed.jpg

なぜか境界付近の色がおかしいですね。

テクスチャの圧縮設定

調べてみたところ、境界付近の色がおかしいのはテクスチャの圧縮によるものだとわかりました。
作成したばかりのテクスチャはデフォルトでは左のように Format が RGBA Compressed DXT5 になっています(最下部のグレーアウト部分)。これを右のようにRGBA 32 bitにしました。ついでにMax Sizeもテクスチャに合わせてます。

Before After
texDefaultComp.jpg texSetNoComp.jpg

ポリゴン境界の拡張

色がおかしいのは直りましたが、よく見ると斜めの線が途切れ途切れになっています。
needMapping.jpg
これは、左右側面のポリゴンの高さが足りないせいだと思われるので、ポリゴンを上下に 0.5f づつ拡張してみました。UVも同じように拡張しています。
polyExpand.gif

Shed.cs
        var vertices = new Vector3[] {            
            new Vector3(0, -0.5f, 0), // 0:pivot
            new Vector3(0, size.y+0.5f, 0), // 1:nearest top
            new Vector3(0, -0.5f, size.z), // 2:bottom right
            new Vector3(0, size.y+0.5f, size.z), // 3:top right
            new Vector3(size.x, -0.5f, 0), // 4:bottom left
            new Vector3(size.x, size.y+0.5f, 0), // 5:top left
            new Vector3(size.x, size.y+0.5f, size.z), // 6:most far top
        };

        var harfX = size.x * 0.5f;
        var harfZ = size.z * 0.5f;
        var uv = new Vector2[] {
            ToUV(0, -0.5f), // 0:pivot
            ToUV(0, size.y+0.5f), // 1:nearest top
            ToUV(-size.z, harfZ-0.5f), // 4:bottom left
            ToUV(-size.z, harfZ + size.y+0.5f), // 5:top left
            ToUV(size.x, harfX-0.5f), // 2:bottom right
            ToUV(size.x, harfX + size.y+0.5f),  // 3:top right
            ToUV(size.x - size.z, harfX + harfZ + size.y+0.5f), // 6:most far top
        };

これで綺麗に表示されました。文句なしの Pixel Perfect です。
allAdjusted.jpg

最終結果

元のシーンも調整してこのようになりました。ドットに歪みなく描画され、深度バッファを適用して適切な重なり順が実現できています
finalResult.jpg

3Dオブジェクトとの共演

深度バッファを適用してるので、他の3Dオブジェクトと混在させたシーンが作れます
例えば地形(Terrain)と組み合わせたり。
withTerrain.jpg
イーサンくんと組み合わせたり。
ethanWalk.jpg
↑足は紫の物体の向こう側にあるのに、右手は紫の物体の上にあるのがおわかりいただけるでしょうか。

縦横比補正

ところで、よく見ると3Dオブジェクトは若干縦に潰れた感じになります。
これはドット絵に都合のよいように強引な射影変形をしてるためですので、以下のようにアスペクト補正用のゲームオブジェクトの下に3Dオブジェクトを入れてやるとよいと思います。
adjust3D.jpg
ここで調整用オブジェクト(adjust3D)は縦を 1.155 倍にしていることに注目してください。
1.155という数値はまったく無根拠ではなくて、これはx,z軸方向のドットが本来 √3/2 の距離を1ピクセルに変換していることから、逆数である 2/√3 ≒ 1.155 をy軸方向に適用したものです。

無調整 アスペクト調整済
ethanNoAdjust.jpg ethanAdjusted.jpg

ご覧のように回転させても違和感ないですね!
rotateEthan.gif

まとめ

  • 等角投影法のドット絵を立体に正射影して深度バッファを適用することができた
  • worldToCameraMatrix を調整して Pixel Perfect にできた
  • Pixel Perfect のために必要なテクスチャ設定がわかった
  • 縦横比調整して3Dモデルとの併用もできた

プロジェクトファイル一式は GitHub にあります。

参考

【元ネタ】http://qoofast.blog76.fc2.com/blog-entry-23.html

カスタムmesh作成
http://nn-hokuson.hatenablog.com/entry/2018/02/13/200114

テクスチャ設定
http://baba-s.hatenablog.com/entry/2018/01/31/213000

射影行列とカメラ周り
http://logicalbeat.jp/blog/929/
https://note.mu/fuqunaga/n/n08d81b185514
http://light11.hatenadiary.com/entry/2019/04/17/012544
https://github.com/cmilr/DeadSimple-Pixel-Perfect-Camera

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away