自作の2D/3Dゲームエンジン Lantern のソフトウェアレンダラに、ささやかな隠面消去を入れました。やったことは単純です:後に描かれる不透明な矩形に完全に覆われるピクセルは、先に描かない。大した話ではないのですが、バイト単位で正しさを保ちながらやるのは思ったより気を使う作業だったので、記録として残します。
背景:2D描画のコストはオーバードロー
Lantern の2D描画はペインター法です。描画順に上から重ねていく。ということは、後に不透明なものが描かれる領域では、先に描いたピクセルは全部無駄になります。UIの背景パネル、画面いっぱいの暗転、全画面のフェード——こういう「後から来る大きな不透明矩形」はゲームによく出てきます。
3DならZバッファや階層Zが当たり前ですが、2Dのスプライトバッチで真面目にオクルージョンをやる例はあまり見かけません。やってみました。
前提:painted 2D compositor
まず土台として、軸平行(axis-aligned)な描画だけを集めて直接サンプリングする painted 2D compositor を用意しました(LANTERN_PAINTED_2D=1 で opt-in、デフォルトは従来レンダラ)。従来は三角形ラスタライズ経由でしたが、軸平行ならアフィン変換で直接サンプリングできる。alpha-weighted bilinear filtering、アトラスセルのクランプ、tint、描画順はそのまま保ちます。
並列化は行バンド単位:互いに重ならない行の帯をワーカー(LANTERN_RENDER_WORKERS=1..8)に割り当てます。ワーカーはフレームをまたいで使い回し、フレーム毎のスレッド生成やヒープ確保はしません。スレッドや確保に失敗したら serial にフォールバックします。設定値の検証は厳格にやって、変な値はプラットフォーム確保の前に落とします。
やったこと:後発の不透明矩形で穴をあける
axis-aligned バッチの中で、後に描かれる不透明・無地(テクスチャなし)の矩形がひとつあれば、それに覆われる先発ピクセルをスキップします。
実装の核心は、ラスタライザに渡す occlusion(矩形)です。各行について、穴の左右に最大2つの可視スパンだけを描きます。矩形全体が覆われる場合は即 return します。サンプル座標系をいじらないので、テクスチャ付き描画の UV 計算もそのままです。
難しいところ:バイト単位の正確さ
削るのは簡単、正しく削るのが難しい。可視境界の1ピクセル、描画順、フレームバッファのアルファ値は厳密に保つ必要があります(Lantern の2DはRGBのみ書き込みでFBのアルファを温存する、という少し変わった仕様も含めて)。
なので適用条件はかなり絞っています:
- 後発は不透明かつ無地の矩形のみ
- 半透明・テクスチャ付きの描画は除外
- 変形(transformed)バッチの境界も除外
「削れる条件」を広げるより「削ってはいけない条件」を確実に除外する方が、安全に速くなります。まだ手探りですが、今のところこの方針で困っていません。
検証:336フレームのバイト比較
カリングなしの painted 参照実装と、カリングありの出力を336フレーム分バイト比較します。ワーカー数 1/2/8 のすべてで一致することを確認。小数点のクリッピング、透明部分、テクスチャに覆われるケース、変形境界もテストに含めています。並列と直列で出力がバイト一致するのも、このテストで担保しています。
正直な注記
これは実験的機能で、opt-in です。代表的なCPUシーンの計測はしていますが、持続60FPSは未検証のままです。READMEにもそう書いています。速くなったと胸を張れるのは、削ったピクセルが本当に不要だったことの証明ができたときだけだと思っています。
おわりに
2Dでオクルージョンカリングを真面目にやっている例をあまり見ないのですが、みなさんはスプライトバッチのオーバードロー対策ってどうされていますか?描画順のソートで済ませる派ですか、それとも何か削っていますか?