目的
DirectXでビューワーを作っていて久々にハマったので個人的記録。記憶違いと知識不足が原因で、次の二点に気づかずに苦労しました。
- 頂点シェーダで射影行列(projection matrix)を作用後の座標値は、スクリーン(ウィンドウ)のサイズ[-1.0, 1.0]で正規化されていない。wの値で割ることで[-1.0, 1.0]に正規化される。
- 頂点シェーダでSV_POSITIONセマンティクスを付けた変数へ出力したものを、ピクセルシェーダで受け取った時には、値が変わっている。SV_POSITIONセマンティクスを付けた変数はラスタライザで利用しやすいように加工されてから、ピクセルシェーダに受け渡される。DirectX(10以降?)ではx,y座標に関してはスクリーン(ウィンドウ)のピクセル座標に変換されている。
参考:https://shikihuiku.github.io/post/projection_matrix/
以下は、DirectX11を前提に記述します。一部はOpenGLでも通じる概念かもしれません。
1. 頂点シェーダで射影行列を作用後の座標値
一般的に頂点シェーダでは、入力となる頂点ベクトルに、ワールド変換行列、ビュー行列、射影行列の順に作用して、SV_POSITIONセマンティクスを付けた変数として出力すると思います。
//example 頂点シェーダ
struct VS_INPUT
{
float3 pos : POSITION;
...
};
struct PS_INPUT
{
float4 pos : SV_POSITION;
...
};
PS_INPUT main(VS_INPUT input)
{
PS_INPUT output;
float4 vpos = float4(input.pos, 1.0f);
vpos = mul(vpos, world);
vpos = mul(vpos, view);
output.pos = mul(vpos, projection);
...
return output;
}
これでスクリーンに二次元射影された座標になっていそうです。スクリーン座標はスクリーン(ウィンドウ)の中心を(0,0)として、左端を$x=-1$、右端を$x=1$、下端を$y=-1$、上端を$y=1$とします。変換後の頂点が、$(x,y) \in [-1,1]\times[-1,1]$の範囲に入れば画面に描画されると説明されます。つまり画面幅で規格化されています。
一方で、上記のような頂点シェーダから出力されるoutput.pos
の座標は、規格化されておらず、output.pos.xy
には、もっと大きな値や小さな値が入っています。スクリーン座標$(x,y)$と結びつけるには、第四成分のoutput.pos.w
で割ってやる必要があります。
つまり、
x = output.pos.x / output.pos.w;
y = output.pos.y / output.pos.w;
として初めてスクリーン座標になります。
これはマウスクリックなどでスクリーン座標を得てから、オブジェクトのあるワールド座標へ逆変換する際にも気を付ける点ですね。
(私は、記憶違いでwでの除算を忘れていてハマりました。)
2. 頂点シェーダ出力したSV_POSITIONの値は、ピクセルシェーダで受け取るときには変わっている
これも厄介です。上記のように、頂点シェーダで出力した座標output.pos
は、wでの除算をしてやればスクリーン座標になるはずです。しかし、ピクセルシェーダの入力として受け取った際には、値が変わってしまっているのです。
例えば、上記の頂点シェーダと組み合わせて使うピクセルシェーダとして次のようなものを書くでしょう
//example ピクセルシェーダ
struct PS_INPUT
{
float4 pos : SV_POSITION;
...
};
float4 main(PS_INPUT input) : SV_TARGET
{
float2 uv = float2(input.pos.x / input.pos.w * 0.5f + 0.5f,
input.pos.y / input.pos.w * 0.5f + 0.5f);
float val = texture.Sample(sampler, uv);
...
...
return color;
}
ここでは、画面全体を覆う一枚のテクスチャから、色を取得しようとしています。テクスチャ座標のuv
に、input.pos
の座標を利用しようとしてます。wで割れば規格化されるので、0.5で書けて、0.5シフトすれば、uvは[0,1]の範囲に収まりそうです。ただ、このコードは意図した動きになりません。
というのは、ピクセルシェーダの入力として渡されるinput.pos
の値は、頂点シェーダで出力されたものとは変わってしまっているためです。その原因は、input.pos
にSV_POSITION
セマンティクスが指定されているためです。これが指定された座標情報は、ピクセルシェーダに渡る前に、システムに都合の良い値に書き換えられてしまうそうです。
webで調べた限りでは断片的な情報しか見つけられませんでしたが、良くあるのはラスタライザに都合よく変換されるようで、私の環境ではスクリーンの左上を[0,0]としたピクセル座標に変換されていました。なので、上記のピクセルシェーダを動くようにするには、例えば次のように書きなおします。
float4 main(PS_INPUT input) : SV_TARGET
{
float2 uv = float2(input.pos.x / screen_w, input.pos.y / screen_h);
float val = texture.Sample(sampler, uv);
...
...
return color;
}
ここで、screen_w
やscreen_h
にはスクリーンのピクセル幅が入っているとします。これでuv
は[0,1]の範囲に収まり、意図通りにテクスチャの値を取得できます。
ただ、SV_POSITION
の変換動作がこれに限るのかどうかが良く分かりません。利用するシェーダや組み合わせ、また、ハードウェアにもよるのかもしれないなどの情報もあるので、気を付ける必要がありそうです。
おわりに
DirectXは昔から苦労していますが、私の場合は必要に応じて時々機能を追加するというスタイルなので、どうにも記憶違いが多く、毎回何かにハマります。
同じようにハマった方の参考になれば幸いです。