Joël van der Werf
記事一覧

3D空間にピクセルアートのスプライトを立てる:カード、カットアウト、深度

シーンを作り直さずに、平面の2Dの村をジオラマに変えた方法。スプライトごとの向きの設定、深度バッファ、そしてdiscard一つ。

Hydroは2Dエンジンとして始まりました。テスト用の村は平らなXY平面で、地面、道、木、家はすべて矩形として描かれ、SortOrderとY座標で並べ替えていました。Eonloreで目指したのは、ライティングされた3Dシーンの中にピクセルアートのキャラクターが立ち、それを傾けたカメラで見下ろす表現です。

3Dで作り直したくなるところですが、もっと小さな方法を選びました。世界は地面の平面のまま残し、各スプライトが自分の立ち方を決めるという方法です。

フィールドは一つだけ:Facing

SpriteRendererComponentにFacingを追加しました。

  • Flat:これまで通り地面に寝かせる。地面、道、デカール、水。
  • Upright:下辺の中央を軸に立ち、カメラの方を向く(ヨーと傾きのみ、ロールはなし)。木、家、キャラクター。
  • Billboard:中心を軸に完全にカメラを向く。パーティクル、光、アイコン。

レンダラーは、平らな矩形を立つ位置を軸に回転させます。真上からのカメラでは矩形をそのまま返すので、既存の2Dシーンの見た目はまったく変わりません。

glm::mat4 SceneSpriteRenderer::ApplyFacing(const glm::mat4& quad, SpriteFacing facing,
                                           const SceneCameraBasis& camera)
{
    if (facing == SpriteFacing::Flat || camera.IsTopDown())
    {
        return quad;
    }

    // Turn the quad about where it stands: its bottom edge (Upright) or its centre (Billboard).
    const bool upright = facing == SpriteFacing::Upright;
    const glm::vec3 pivot = glm::vec3(quad * (upright ? glm::vec4(0.0f, -0.5f, 0.0f, 1.0f)
                                                      : glm::vec4(0.0f, 0.0f, 0.0f, 1.0f)));
    const glm::mat3& rotation = upright ? camera.Upright : camera.Rotation;
    return glm::translate(glm::mat4(1.0f), pivot) * glm::mat4(rotation)
         * glm::translate(glm::mat4(1.0f), -pivot) * quad;
}

Box2Dの物理、ライト、丸影はもともと地面の平面上にあったので、そのまま動き続けました。村のすべてのスプライトをスクリプトでFlatかUprightに分類し、Vキーで真上視点と50度の傾き視点を滑らかに切り替えられるようにしました。

並べ替えだけでは足りなくなる

カメラを傾けると、Y座標での並べ替えはカメラの視線方向の距離での並べ替えになります。スプライト単位では正しい順序になりますが、ピクセル単位では正しくありません。木の枝の間を歩くキャラクターは、完全に前か完全に後ろのどちらかになってしまいます。

解決策は深度バッファで、そのために先延ばしにしていた決断をしました。ダイナミックレンダリングのためにVulkan 1.3を必須にすることです。ピクセルアートはアルファの境界がはっきりしているので、透明なピクセルを捨てれば、スプライトも不透明なジオメトリと同じように深度を書き込めます。

// Cut-out sprites: depth test and write, no blending. A pixel either covers
// what is behind it or is discarded before it writes anything.
void main()
{
    vec4 color = SampleTexture(int(texIndex), fragTexCoord) * fragColor;
    if (color.a < 0.5)
    {
        discard;
    }
    outColor = vec4(color.rgb + color.rgb * fragEmission, 1.0);
    outEntityID = entityID;
}

これで木とプレイヤーはピクセル単位で正しく重なります。木のスプライトを分割したり、並べ替えの工夫をしたりする必要はありません。奥から手前への並べ替えが必要なのは、水、パーティクル、光のような本当に半透明なものだけです。

描画順を決めたバグ

現在のフレームは、メッシュ、デカール、カットアウトのスプライト、半透明の矩形の順に描画します。デカールをカットアウトより先に描くのには、痛い目を見て学んだ理由があります。真上からの視点では、立っているスプライトはデカールと同じ深度で地面の平面上に寝ています。デカールを後から描くとLESS_OR_EQUALの深度テストを通ってしまい、その上に立つキャラクターを塗りつぶしてしまうのです。デカールにはカメラ方向へのわずかな深度バイアスもかけ、同じ高さの地面ジオメトリの上でちらつかないようにしています。

最初の自分に伝えたいこと

  • 世界ではなく、データモデルを変える。コンポーネントに列挙型を一つ追加しただけで、3Dの作り直し以上の効果があり、古いシーンもすべて動き続けました。
  • 古い動作を新しい動作の特殊ケースにする。真上視点は「カメラが傾いていない」だけなので、コードの経路は2つではなく1つです。
  • ピクセルアートは深度と相性がいい。アルファの境界がはっきりしていればカットアウトにでき、カットアウトなら並べ替えはほとんど要りません。

このシリーズの次回は、同じカットアウトのカードから落とす影と、チルトシフトの見た目を作るHDR・ブルーム・被写界深度についてです。

前の記事:実行時の状態はどこに置くか:ECSのコンポーネントのための4つのルール次の記事:ペラペラのキャラクターに影を落とす

お仕事のご相談はお気軽にどうぞ

東京在住です。Unity・C++・ゲームエンジン関連のポジション、C#・.NETのソフトウェア開発のポジション、そしてAIエージェントを活用した開発体制づくりに関心があります。英語・オランダ語・日本語(勉強中)で対応できます。