【ゲーム制作】スカイボックスとは?|空を「遠くの箱」に描く仕組みをDirectXで実装して、消える・ズレる原因を検証

【ゲーム制作】スカイボックスとは?|空を「遠くの箱」に描く仕組みをDirectXで実装して、消える・ズレる原因を検証 ゲーム制作

空を描くには、巨大な球を置けばよいのでしょうか。スカイボックスが、カメラと一緒に動くとは、どういうことなのか。空が真っ黒になったり、カメラを動かすと山がズレたりするのは、なぜなのか。

この記事では、空や遠景を描く定番の手法、スカイボックスを、DirectX11でゼロから実装します。空の絵は、雲・太陽・山並みまで、プログラムで作った(外部の画像は使っていない)キューブマップです。そのうえで、私が実際に踏んだ「空が消える」「ズレる」「継ぎ目が出る」を1つずつ再現して、原因を数字で確認しました。

スカイボックスとは?

スカイボックスとは、

・カメラを囲む立方体の内側に、6枚の空の絵(キューブマップ)を貼っておく手法

です。実際に空を遠くに置くのではなく、「カメラの向きだけで決まる背景」を描いているのがポイントです。

スカイボックスで描いた空。正面、後ろ、見上げた空、空を映す球の4枚
実装した空。カメラの向きだけで、太陽・雲・山並みが見える方向が変わる

仕組みは次の3つです。

  1. 立方体を、カメラのまわりに描く(カメラが動いても、常にカメラの位置に立方体がいる)
  2. 立方体の各頂点の「向き」を、そのままキューブマップを引く方向として使う
  3. 空は一番先に、深度を使わずに描く。あとから描く地形やキャラクターが、その上に重なる

キューブマップは、東西南北と上下の6面を持つ特殊なテクスチャで、float3の向きを渡すと、その方向に見える色を返してくれます。通常のテクスチャのようにUV座標は要りません。

キューブマップを作る:6面の「向き」の決まり

DirectXのキューブマップは、面の順番が+X、-X、+Y、-Y、+Z、-Zと決まっています。さらに、各面の中の「右」「下」がどちらの軸に当たるかも決まっていて、ここを間違えると空が壊れます。作例では、面の中の座標(s, t)(s=右、t=下、-1〜1)から、その画素が指す「向き」を求める関数を作りました。

▼SkyboxDemo.cpp
// Direct3Dのキューブマップの向きの決まり(面の順番と、面内の s=右 t=下 の意味)
static XMFLOAT3 FaceDir(int face, float s, float t) {
    XMFLOAT3 d;
    switch (face) {
    case 0:  d = XMFLOAT3(1.0f, -t, -s);  break;   // +X
    case 1:  d = XMFLOAT3(-1.0f, -t, s);  break;   // -X
    case 2:  d = XMFLOAT3(s, 1.0f, t);    break;   // +Y
    case 3:  d = XMFLOAT3(s, -1.0f, -t);  break;   // -Y
    case 4:  d = XMFLOAT3(s, -t, 1.0f);   break;   // +Z
    default: d = XMFLOAT3(-s, -t, -1.0f); break;   // -Z
    }
    float l = sqrtf(d.x * d.x + d.y * d.y + d.z * d.z);
    d.x /= l; d.y /= l; d.z /= l;
    return d;
}

これで、空の色を「面ごと」ではなく、「向き」から計算できます。向きから色を決めれば、面の継ぎ目で色が食い違うことは原理的にありません。作例では、天頂の青、地平線の白、太陽、雲(ノイズ)、山の稜線、地面の霞を、この向きの関数として書きました。

作るテクスチャの設定で忘れてはいけないのが、6枚を1つの配列にすることと、キューブの印D3D11_RESOURCE_MISC_TEXTURECUBEです。

▼SkyboxDemo.cpp
D3D11_TEXTURE2D_DESC td = {};
td.Width = td.Height = size;
td.MipLevels = 1;
td.ArraySize = 6;
td.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
td.SampleDesc.Count = 1;
td.Usage = D3D11_USAGE_IMMUTABLE;
td.BindFlags = D3D11_BIND_SHADER_RESOURCE;
td.MiscFlags = D3D11_RESOURCE_MISC_TEXTURECUBE;        // 6枚をキューブとして扱う印

また、面の向きを間違えていないかは、空を見ても分かりにくいので、デバッグ用に「面ごとに色を変えたキューブマップ」を作りました。各面に色を付け、左上に白い印、中央に「上」を示す黒い線を入れています。

6面のキューブマップを色分けして十字に並べたデバッグ画像
6方向を見た画面を十字に並べたもの。上下は端に隣の面がのぞいている

6面とも、白い印が左上、黒い線が上向きに見えていれば、面の向きと上下左右が合っています。空の絵は自然物なので、少し違っていても気づきにくいのが怖いところです。

頂点シェーダーが肝:カメラに追従させて、最奥に固定する

スカイボックスの頂点シェーダーには、2つの工夫があります。

SKY_OUT VSSky(VS_IN In) {
    SKY_OUT o;
    float3 p = In.Position.xyz * SkyParam.z;

    // w=0 で View を掛けると、カメラの平行移動が消えて「向き」だけが残る
    float wTrans = (SkyParam.x > 0.5f) ? 0.0f : 1.0f;
    float4 v = mul(float4(p, wTrans), View);
    float4 c = mul(v, Projection);

    // xyww にすると、深度 z/w がいつも 1.0(ファークリップ面)になる
    o.Position = (SkyParam.y > 0.5f) ? c.xyww : c;
    o.Dir = In.Position.xyz;      // 箱の頂点の向きが、そのままキューブマップを引く向きになる
    return o;
}
  • w = 0でViewを掛ける:頂点をfloat4(p, 0)にすると、行列の平行移動が掛からず、カメラの向き(回転)だけが残ります。カメラがどこへ動いても、空との距離は変わりません
  • c.xywwにする:射影後のzをwに置き換えます。GPUは最後にz / wで深度を作るので、深度が常に1.0(ファークリップ面ちょうど)になります。空が必ず最奥になり、しかも立方体がfarを越えていても切られません

また、空は深度テストも書き込みも切って、一番先に描きます。

▼SkyboxDemo.cpp
D3D11_DEPTH_STENCIL_DESC dd = {};
dd.DepthEnable = FALSE;                     // 空は先に描く。深度テストも書き込みもしない
dd.DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ZERO;
dd.DepthFunc = D3D11_COMPARISON_ALWAYS;
GetDevice()->CreateDepthStencilState(&dd, &dssNoDepth);

【重要】実際に踏んだ罠:空が消えた、ズレた、継ぎ目が出た

ここからが本題です。スカイボックスは、間違えてもエラーにならず、ただ「何も見えない」か「なんとなく変」になるので原因が分かりにくい機能です。実際に踏んだ罠を、1つずつ再現して確かめました。

罠①:カリングが背面のままで、空が完全に消える

立方体は、外から見たときに表になる向きで作られています。ところがスカイボックスは、立方体の内側から見ます。すると、見えている面は「裏」になり、通常の背面カリングで全部捨てられてしまいます。

カリング設定とxyww固定の有無による、空が消える失敗例
左上:背面カリングで空が消えた 右上:前面カリング 左下:xywwの細工なしでfarを超えた 右下:細工あり
条件クリア色のまま残った画素
背面カリング(初期設定のまま)100%
前面カリング0%
xywwなし(箱がfarより遠い)100%
xywwあり(箱がfarより遠い)0%
画面の全画素のうち、空が描かれずクリア色(暗い灰色)が残った割合。実測

背面カリングでは、100%の画素が暗い灰色のままでした。「空が全部消える」という、原因が分かりにくい症状の代表例です。前面カリング(または、カリングなし)に切り替えれば0%になります。

罠②:xywwを使わないと、farより遠い箱が切り取られる

立方体を大きく(半分の大きさ100)して、farを50に縮めると、箱の頂点はfarクリップ面より遠くなり、丸ごと切り取られました(上の表の3行目、画素の100%が残る)。xywwで深度を1.0に固定すれば、箱がfarより遠くても、クリップされません。「箱をどれだけ大きくすればいいか」を悩まずに済むのが、この方法のメリットです。

罠③:平行移動を捨てないと、カメラを動かすと空がズレる

w = 0にせず、ふつうにw = 1で頂点を変換すると、カメラを動かしたときに、立方体が「ただの遠くにある箱」になります。カメラがx=6に動いたところを撮りました(箱の半分の大きさ10)。

平行移動を捨てない失敗と、面を左右逆に作った失敗の比較
左上:平行移動を捨てず、カメラx=0 右上:同、カメラx=6(山が歪み、太陽が大きくなる) 左下:平行移動を捨てて、カメラx=6 右下:面を左右逆に作った

カメラがx=0のときは正常に見えるので、原点でテストしていると気づきません。x=6に動かすと、山の稜線が歪み、太陽が近づいて大きくなり、視差が出てしまいました。「空は無限遠にある」という前提が崩れています。w = 0にすると、同じカメラ位置でも左下のように、元どおりの見た目です。

罠④:面を左右逆に作ると、継ぎ目で絵がつながらない

キューブマップの各面は、外側から見た向きで作るのか、内側から見た向きで作るのかで、左右が逆になります。これを取り違えて、各面を左右逆に作ってみたのが右下の画像です。太陽が視界の外へ追い出され、山の稜線が中央で段差のように途切れています。

この継ぎ目のつながりを、目視ではなく数字で確かめました。画面の中央にある立方体の縦の辺の左右で、隣り合う画素の色の差を測り、他の場所での隣り合う画素の差の平均と比べた比です。

画像継ぎ目の差それ以外の平均比
正しい空0.230.112.1倍
面を左右逆に作った空6.200.0971.2倍
デバッグ用の色分けキューブ(色が違うのが正常)94.000.25373倍
画面中央の縦の辺の左右の色の差(0〜255)と、比較の平均。比が大きいほど、継ぎ目で絵がつながっていない

正しく作ると比は2.1倍で、ほぼ連続です。左右逆にすると71倍まで跳ね上がりました。目で見て「なんとなくおかしい」と思ったら、まず継ぎ目の差を数字にすると、原因の切り分けが早くなります。

太陽は、正しい空では画面の右寄りにありました(重心のx座標が723)。左右逆にすると、明るい画素は7,144個から116個に減り、太陽はほぼ視界から消えました。空の見た目が「なんとなく変」なとき、太陽の位置がずれていないかを見るのも手がかりになります。

偽物の継ぎ目:私のプログラムのバグを、面のつなぎ目だと思い込んだ

実は、最初に作った空には、継ぎ目のように見える線がありました。面の向きの取り違えを疑って向きの表を見直しましたが、表は合っていました。原因は、山の稜線の高さを計算する式にありました。

稜線の高さは、方位ごとにsinを足して作っています。この高さが0以下になる方位があると、その先のe / ridge(高さで割る)が暴れて、色が飛んでいました。稜線の高さに下限(0.02)を付けたら消えました。修正後は、隣り合う画素の稜線の高さの飛びは1ピクセル以内です。

▼SkyboxDemo.cpp
float ridge = 0.075f + 0.050f * sinf(3.0f * az + 1.0f) + 0.030f * sinf(7.0f * az + 2.0f)
            + 0.014f * sinf(17.0f * az);
ridge = fmaxf(ridge, 0.02f);                        // 高さが0以下になると、下の割り算が暴れて色が飛ぶ

「継ぎ目に見えるものが、継ぎ目のせいとは限らない」という教訓です。原因を疑う前に、色分けしたデバッグ用のキューブで、面のつながりだけを先に確認しておくと切り分けができます。

おまけ:空を映す球(環境マッピング)

同じキューブマップは、光沢のある物体に空を映すのにも使えます。ピクセルシェーダーで、視線の反射ベクトルを計算して、その向きでキューブマップを引くだけです。

float4 PSBall(BALL_OUT In) : SV_Target {
    float3 N = normalize(In.Normal);
    float3 V = normalize(In.WorldPos - CamPos.xyz);      // カメラから球へ
    float3 R = reflect(V, N);                            // 反射した向きの空を引く
    float fres = pow(1.0f - saturate(dot(-V, N)), 3.0f); // 縁ほど強く映る
    float3 sky = gCube.Sample(gSamp, R).rgb;
    float3 col = lerp(sky * 0.55f, sky, fres * 0.8f + 0.2f);
    return float4(col, 1.0f);
}

球の上半分に、雲と山並みが映り込んでいます(先頭の画像の右下)。空を1枚用意するだけで、周囲の物にも自然な照り返しを付けられます。

スカイボックスのよくある失敗例と対処法

①空が真っ暗(クリア色のまま)

まずカリング設定を疑います。立方体の内側から見るので、前面カリングか、カリングなしにします。それでも消えるなら、xywwの有無と、箱の大きさとfarの関係です。

②カメラを動かすと山や雲が動いてしまう

頂点をfloat4(p, 0)で変換して、平行移動を捨てます。原点でテストして気づかないのが厄介なので、カメラを動かして確認します。

③6面の向きを間違えて、絵がつながらない

デバッグ用の色分けキューブを作って、6面とも左上の印と上向きの線の向きを確認します。空の絵で確認しようとしないのがコツです。

注意点

  • キューブマップの面の順番は+X, -X, +Y, -Y, +Z, -Z
  • 立方体は内側から見るので、カリングは前面か、なし
  • 空は最初に、深度なしで描く。xywwで最奥(深度1.0)に固定する
  • カメラを動かした状態でも確認する(平行移動の取り忘れは原点では気づけない)
  • 実際のゲームでは、6枚のPNGやキューブマップ入りのDDSを読み込む。面の向きの確認は、色分けキューブを1回通すと安心

ここまでのおさらい

  • スカイボックスは、カメラの向きだけで決まる背景。頂点をw = 0で変換し、xywwで最奥に固定して、深度なしで最初に描く
  • 空が消えるときはカリング(背面カリングで画素の100%が消えた)とxywwなしでfarを越えた場合
  • カメラを動かしたときのズレは平行移動を捨て忘れたのが原因。原点では気づけない
  • 継ぎ目のつながりは数字で確認できる(正常は2.1倍、左右逆は71倍)

空が消えるときは、カリング(背面カリングで画素の100%が消えました)か、xywwなしでfarを越えた場合です。カメラを動かしたときのズレは、平行移動を捨て忘れたのが原因で、原点では気づけません。継ぎ目のつながりは、数字で確認できます(正常は2.1倍、左右逆は71倍)。

関連記事