TECHNICAL CASE NOTE D3D9タイトル · 黒画面 · DXVK / 固定機能パイプライン 解決済み・2026-07-21確認

未bindの頂点ストリームで画面が黒くなる

binding 16の未bind状態が、正常なbinding 0の頂点フェッチまで破壊した

§1

Presentは進むのに、画面は黒い

症状は、アプリケーションが動作を続ける一方で、表示だけが黒になることだった。

これは、macOS 上で古い日本のビジュアルノベル を動かすための互換レイヤーでの出来事だ。この 32bit タイトルは DXVK(Direct3D を Vulkan に翻訳する層)を経由して描かせていて、その先で Vulkan は MoltenVK によって Apple の Metal に変換される。32bit のメモリ枯渇を解消した後、デバイス生成とシェーダの compile を通し、フレームの present まで到達していた。presentは8000フレーム以上継続し、crashは0件だったが、表示内容は黒のままだった。

黒画面だけでは、故障した処理段階を特定できない。バックバッファを直接 赤で clear すると、画面は赤くなる ― present の経路は正常だと確認できる。フラグメントシェーダの出力をマゼンタに固定しても表示は黒のままだったため、テクスチャの色ではなく、フラグメント出力より前の段階に原因がある

clear は効くが、draw は表示へ反映されない。この比較によって、原因範囲を描画パイプラインの上流へ限定した。

§2

最初に検証した仮説

レンダリングパイプラインを上流から順に計装し、各段階が原因かどうかをログで確認した。

出力段が色を捨てている棄却

まず、全 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 に読み戻すと、期待した値と一致した。

§3

頂点入力へ範囲を絞る

状態ログだけでは範囲を狭めきれなかったため、次は既知の信号を注入することにした。

頂点シェーダを書き換え、頂点の位置をバッファから読むのをやめて、頂点番号だけから画面いっぱいの四角形を組み立てさせた。頂点バッファにも UBO にも一切依存しない、純粋に「番号から座標を作る」だけのコードだ。

頂点番号から座標を生成するシェーダへ切り替えると、画面全体にマゼンタが表示された。

この結果から、ラスタライザ以降のフラグメント生成、出力、present は正常であり、原因範囲を頂点入力のフェッチまで絞り込めた。

CPU側の頂点データ、属性layout、オフセット、coherenceを確認した結果、残った異常はbinding 16の未bind状態だった。

§4

未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の正しい座標まで読み取れなくなり、頂点が画面外へ移動するため、ラスタライズされる画素がなくなる。

切り替えて確認する。違いは binding 16 が空か、binding 0と同じUP sliceを持つかだけ。
bind = 0
position / texcoord ― 正しい実頂点データ
bind = 16
null ストリーム ― 宣言はあるが未bind
↓ Metal の頂点フェッチ
頂点座標が不正 → 画面外 → 黒 ✗ 未bindの入力が、binding 0 の座標フェッチにも影響する。ラスタライズされる画素がなくなり、画面は黒いままになる。
bind = 0
position / texcoord ― 正しい実頂点データ
bind = 16
binding 0と同じUP slice ― 同じ非0 stride
↓ Metal の頂点フェッチ
頂点フェッチ成立 → 実ゲームを描画 ✓ binding 0 と同じ有効なフェッチ範囲を与えると、実頂点データが正しく読まれ、タイトル・背景・sceneが表示された。

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状態は、固定機能の頂点処理を使う古い世代のタイトルで発生する

§5

観測結果

観測 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経路は同機構への拡張である。

§6

調査手順

計装と強制信号を用いて、原因範囲を出力段から頂点入力へ段階的に限定した。

出力段から順に計装する

出力段・clear・present・描き先・blit・フレーム順・画像属性を、実行を止めずにログへ記録。各段階が原因ではないことをログごとに確認した。

強制信号で処理段階を分ける

フラグメント強制(マゼンタ)と頂点強制(番号から座標生成)で、「下流か上流か」「フラグメントか頂点か」を切り分け、頂点入力層へ範囲を絞った。

外部要因は変種で比較する

変換層(MoltenVK)を 3 変種で試し、変換層そのものでは説明できないことを確認した。

CPU側データとGPU入力を分けて確認する

データ(CPU では正しい)→ bind オフセット → coherence → 属性 layout を順に確認し、binding 16の未bind状態へ範囲を限定した。

binding 16だけを変更して比較する

binding 16に有効な入力を与えた条件では、マゼンタ固定出力と実ゲーム描画の両方を確認した。変更点と表示結果が対応している。

§7

再利用できる知見

黒は故障した層を特定しない

出力、ラスタライズ、頂点入力のどこが壊れても見た目は同じ。警告文より先に、層ごとの観測結果で範囲を狭める。

CPU の正しさ ≠ GPU の正しさ

clip 座標も頂点データも CPU では正しかった。GPU の実フェッチ結果は別物。強制信号で GPU 側を直接二分する。

一変数を同じ実行で比較する

別ビルド・別時刻の結果を混ぜない。強制信号とbinding 16だけを変え、同じ実行条件で因果を確認する。

移植先が暗黙の前提を表面化させる

一部のドライバが0として扱う未bindのbindingを、MoltenVK / Metalは同じようには処理しなかった。固定機能が生成する入力まで確認すると、実装間の前提差を特定できる。

§8

現在の状態と制約

本編描画の原因と採用修正は確認済み。結果は未検証の処理へ一般化しない。

解決済み
  • 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黒画面へ一般化しない。