空を描くには、巨大な球を置けばよいのでしょうか。スカイボックスが、カメラと一緒に動くとは、どういうことなのか。空が真っ黒になったり、カメラを動かすと山がズレたりするのは、なぜなのか。
この記事では、空や遠景を描く定番の手法、スカイボックスを、DirectX11でゼロから実装します。空の絵は、雲・太陽・山並みまで、プログラムで作った(外部の画像は使っていない)キューブマップです。そのうえで、私が実際に踏んだ「空が消える」「ズレる」「継ぎ目が出る」を1つずつ再現して、原因を数字で確認しました。
スカイボックスとは?
スカイボックスとは、
・カメラを囲む立方体の内側に、6枚の空の絵(キューブマップ)を貼っておく手法
です。実際に空を遠くに置くのではなく、「カメラの向きだけで決まる背景」を描いているのがポイントです。

仕組みは次の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面とも、白い印が左上、黒い線が上向きに見えていれば、面の向きと上下左右が合っています。空の絵は自然物なので、少し違っていても気づきにくいのが怖いところです。
頂点シェーダーが肝:カメラに追従させて、最奥に固定する
スカイボックスの頂点シェーダーには、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つずつ再現して確かめました。
罠①:カリングが背面のままで、空が完全に消える
立方体は、外から見たときに表になる向きで作られています。ところがスカイボックスは、立方体の内側から見ます。すると、見えている面は「裏」になり、通常の背面カリングで全部捨てられてしまいます。

| 条件 | クリア色のまま残った画素 |
|---|---|
| 背面カリング(初期設定のまま) | 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に動かすと、山の稜線が歪み、太陽が近づいて大きくなり、視差が出てしまいました。「空は無限遠にある」という前提が崩れています。w = 0にすると、同じカメラ位置でも左下のように、元どおりの見た目です。
罠④:面を左右逆に作ると、継ぎ目で絵がつながらない
キューブマップの各面は、外側から見た向きで作るのか、内側から見た向きで作るのかで、左右が逆になります。これを取り違えて、各面を左右逆に作ってみたのが右下の画像です。太陽が視界の外へ追い出され、山の稜線が中央で段差のように途切れています。
この継ぎ目のつながりを、目視ではなく数字で確かめました。画面の中央にある立方体の縦の辺の左右で、隣り合う画素の色の差を測り、他の場所での隣り合う画素の差の平均と比べた比です。
| 画像 | 継ぎ目の差 | それ以外の平均 | 比 |
|---|---|---|---|
| 正しい空 | 0.23 | 0.11 | 2.1倍 |
| 面を左右逆に作った空 | 6.20 | 0.09 | 71.2倍 |
| デバッグ用の色分けキューブ(色が違うのが正常) | 94.00 | 0.25 | 373倍 |
正しく作ると比は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倍)。


