Presentは進むのに、画面は黒い
症状は、アプリケーションが動作を続ける一方で、表示だけが黒になることだった。
これは、macOS 上で古い日本のビジュアルノベル を動かすための互換レイヤーでの出来事だ。この 32bit タイトルは DXVK(Direct3D を Vulkan に翻訳する層)を経由して描かせていて、その先で Vulkan は MoltenVK によって Apple の Metal に変換される。32bit のメモリ枯渇を解消した後、デバイス生成とシェーダの compile を通し、フレームの present まで到達していた。presentは8000フレーム以上継続し、crashは0件だったが、表示内容は黒のままだった。
黒画面だけでは、故障した処理段階を特定できない。バックバッファを直接 赤で clear すると、画面は赤くなる ― present の経路は正常だと確認できる。フラグメントシェーダの出力をマゼンタに固定しても表示は黒のままだったため、テクスチャの色ではなく、フラグメント出力より前の段階に原因がある。
clear は効くが、draw は表示へ反映されない。この比較によって、原因範囲を描画パイプラインの上流へ限定した。
最初に検証した仮説
レンダリングパイプラインを上流から順に計装し、各段階が原因かどうかをログで確認した。
出力段が色を捨てている棄却
まず、全 draw のブレンド・書き込みマスク・カリング・アルファテスト・シザーを signature 単位でログに落として実測した。ブレンドは標準のα合成、書き込みマスクは全チャンネル 0xf、カリングは無効、シザーもオフ。出力を0にする状態は確認されなかった。
ジオメトリが縮退している棄却
全ての描画で三角形は非ゼロ面積、スクリーン座標は正常、ビューポート変換も正しい。CPU 側で再計算した clip 座標に異常はなかった。
警告ログが指す別バグ棄却
「Metal は primitive restart を無効化できない」という警告も確認した。だが compile 失敗は 0 件で、実際の描画は非インデックス draw なので無関係だった。
描き先を取り違えている棄却
clear の消し込み、present の壊れ、別テクスチャへの描画、blit 経路、フレームの順序、画像属性 ― 六つをまとめて計装した。draw が描く画像は present されるバックバッファと同一。別の場所に描いているわけではなかった。
変換層自体に問題がある棄却
D3D を Metal に橋渡しする MoltenVK を疑い、同梱版・クリーンな upstream 版・Metal の内部 API を有効化した版の 3 変種 を試した。3変種すべてで黒画面が再現したため、特定のMoltenVK buildだけでは症状を説明できなかった。
推測による対症修正棄却
定数の埋め込み、メモリタイプの変更、明示バリアの挿入を比較した。ただし、 メモリタイプ変更は、変換層が結局同じ型を選ぶため そもそも変化を起こさないテストだった。頂点データを CPU に読み戻すと、期待した値と一致した。
頂点入力へ範囲を絞る
状態ログだけでは範囲を狭めきれなかったため、次は既知の信号を注入することにした。
頂点シェーダを書き換え、頂点の位置をバッファから読むのをやめて、頂点番号だけから画面いっぱいの四角形を組み立てさせた。頂点バッファにも UBO にも一切依存しない、純粋に「番号から座標を作る」だけのコードだ。
頂点番号から座標を生成するシェーダへ切り替えると、画面全体にマゼンタが表示された。
この結果から、ラスタライザ以降のフラグメント生成、出力、present は正常であり、原因範囲を頂点入力のフェッチまで絞り込めた。
CPU側の頂点データ、属性layout、オフセット、coherenceを確認した結果、残った異常はbinding 16の未bind状態だった。
未bindだったbinding 16
頂点入力の実 layout をログに出すと、頂点の入力先が 二つあったのだ ― 0 番と、16 番。
binding 16は、固定機能の頂点シェーダが生成する未使用入力用のnull streamだ。固定機能パイプラインは、法線や余ったテクスチャ座標のような「宣言されているが今回は使わない入力」をbinding 16へまとめる。DXVKは、このbindingにも有効なバッファが割り当てられる前提で頂点入力を構成する。
ところが、この描画が使う経路 ― メモリ上のデータを直接渡す UP(user pointer)描画 ― は、binding 0にしか頂点バッファをbindしない。そのため、シェーダから参照されるbinding 16は未bindのまま残る。
多くの Vulkan ドライバは、未bindの入力を0として扱う。MoltenVK / Metalでは、シェーダから参照されるbinding 16が未bindだと頂点フェッチ全体が不正になる。binding 0の正しい座標まで読み取れなくなり、頂点が画面外へ移動するため、ラスタライズされる画素がなくなる。
CPU側のデータ、オフセット、layout、coherenceが正常であることを確認したうえで、binding 16の状態だけを変更した。binding 0と同じin-boundsのUP sliceを同じstrideでbinding 16にもbindすると、描画が戻った。
ここでは「何かをbindすればよい」わけではない。永続zero bufferをstride 0でbindする案は白画面へ回帰し、棄却した。採用したのは、実機で描画が戻ったフェッチ範囲を保つmirror方式だ。
この結果は、固定機能を使う一部のタイトルだけで発生する理由とも整合する。プログラマブルな頂点シェーダを使うゲームは、入力が 宣言と 1 対 1 で対応するため、未使用入力をnull streamへ割り当てない。binding 16の未bind状態は、固定機能の頂点処理を使う古い世代のタイトルで発生する。
観測結果
観測 A ── 頂点入力layoutに残った異常
attrCount=18 bindCount=2 attrs=[loc=0,bind=0 ,FLOAT4,off=0] ← position 正しい [loc=4,bind=0 ,FLOAT2,off=16] ← texcoord 正しい [loc=1,bind=16,...][loc=2,bind=16,...] binds=[bind=0,ext=32][bind=16,ext=16] ↑ 存在するが UP 経路で未 bind
bind=16だけが、シェーダから参照される一方でバッファをbindされていなかった。
観測 B ── 頂点データは CPU 側で正しい
stride=32 sliceOffset=0 v0=(-0.5, -0.5, 1, 1) v1=(1279.5, -0.5) =全画面 quad のスクリーン座標。 データもオフセットも正しい。
CPU側の値は全画面quadと整合し、不正になるのはGPUの頂点フェッチ段だと確認した。
観測 C ── 同じUP sliceをmirrorして描画回復
binding 0 ← 実頂点UP slice / stride binding 16 ← 同じin-bounds slice / 同じstride DrawPrimitiveUP → タイトル・背景・sceneを表示 → present 8461 / crash 0
採用diffは同じmirrorをDrawIndexedPrimitiveUPにも実装した。ただし、このタイトルで実機確認したdrawは非indexedのDrawPrimitiveUPであり、indexed経路は同機構への拡張である。
調査手順
計装と強制信号を用いて、原因範囲を出力段から頂点入力へ段階的に限定した。
出力段から順に計装する
出力段・clear・present・描き先・blit・フレーム順・画像属性を、実行を止めずにログへ記録。各段階が原因ではないことをログごとに確認した。
強制信号で処理段階を分ける
フラグメント強制(マゼンタ)と頂点強制(番号から座標生成)で、「下流か上流か」「フラグメントか頂点か」を切り分け、頂点入力層へ範囲を絞った。
外部要因は変種で比較する
変換層(MoltenVK)を 3 変種で試し、変換層そのものでは説明できないことを確認した。
CPU側データとGPU入力を分けて確認する
データ(CPU では正しい)→ bind オフセット → coherence → 属性 layout を順に確認し、binding 16の未bind状態へ範囲を限定した。
binding 16だけを変更して比較する
binding 16に有効な入力を与えた条件では、マゼンタ固定出力と実ゲーム描画の両方を確認した。変更点と表示結果が対応している。
再利用できる知見
黒は故障した層を特定しない
出力、ラスタライズ、頂点入力のどこが壊れても見た目は同じ。警告文より先に、層ごとの観測結果で範囲を狭める。
CPU の正しさ ≠ GPU の正しさ
clip 座標も頂点データも CPU では正しかった。GPU の実フェッチ結果は別物。強制信号で GPU 側を直接二分する。
一変数を同じ実行で比較する
別ビルド・別時刻の結果を混ぜない。強制信号とbinding 16だけを変え、同じ実行条件で因果を確認する。
移植先が暗黙の前提を表面化させる
一部のドライバが0として扱う未bindのbindingを、MoltenVK / Metalは同じようには処理しなかった。固定機能が生成する入力まで確認すると、実装間の前提差を特定できる。
現在の状態と制約
本編描画の原因と採用修正は確認済み。結果は未検証の処理へ一般化しない。
- binding 0のUP sliceを同じ非0 strideでbinding 16へmirrorする。
- 採用diffはDrawPrimitiveUPとDrawIndexedPrimitiveUPの両経路へ適用した。
- 実証runではタイトル・背景・sceneを表示し、present 8461 / crash 0を確認した。
- ゲーム本体のexe・配布物・セーブデータは変更していない。
- 実ゲームで観測したdrawはDrawPrimitiveUP。indexed経路は独立したタイトル実証を済ませていない。
- 動画・音声・frame pacing・first-load latencyは、この結果だけでは保証しない。
- セーブ時のthumbnail/readbackは別surfaceとして扱う。
- 検証対象は32-bit Artemisタイトル1本。すべてのDXVK黒画面へ一般化しない。