前回
PMX モデル描画の改善
今回は前回に引き続き、改善を行っていきたいと思います。
アルファブレンディング
前回の描画結果でミクさんの顔の部分がおかしかったのを覚えていますか?
あの黒い部分はアルファ値を持っている部分なのです。
パイプラインステートでアルファブレンディングの設定をしていなかったため、ああいう表示になっているのです。
では、アルファブレンディングとは何でしょうか?
コンピューターグラフィックスで2つのピクセルを合成することを指し、このときアルファ値を基準に2つのピクセルの色がどのように混ざるかを調整することを言います。
一般的にアルファ値は0から1の値で使用されます。0なら完全に透明で、1なら完全に不透明な状態です。
よくピクセル値のRGBにAを付けてRGBAとして使用されます。
アルファブレンディングでピクセルを描画することは、既に描画されているピクセルの色と描画する色の2つの値をアルファ値で混合する必要があるため、通常の不透明なピクセルを描画するよりもコストが高いです。
そのため、透明か不透明かのみを判断して描画するアルファテストという概念もあります。
では、実際にアルファブレンディングが適用されるように設定してみましょう。
ブレンディング設定
D3D12_RENDER_TARGET_BLEND_DESC transparencyBlendDesc;
transparencyBlendDesc.BlendEnable = true;
transparencyBlendDesc.LogicOpEnable = false;
transparencyBlendDesc.SrcBlend = D3D12_BLEND_SRC_ALPHA;
transparencyBlendDesc.DestBlend = D3D12_BLEND_INV_SRC_ALPHA;
transparencyBlendDesc.BlendOp = D3D12_BLEND_OP_ADD;
transparencyBlendDesc.SrcBlendAlpha = D3D12_BLEND_ONE;
transparencyBlendDesc.DestBlendAlpha = D3D12_BLEND_ZERO;
transparencyBlendDesc.BlendOpAlpha = D3D12_BLEND_OP_ADD;
transparencyBlendDesc.LogicOp = D3D12_LOGIC_OP_NOOP;
transparencyBlendDesc.RenderTargetWriteMask = D3D12_COLOR_WRITE_ENABLE_ALL;
ブレンド設定はD3D12_RENDER_TARGET_BLEND_DESCで作成します。
実際はD3D12_BLEND_DESCで設定できますが、特定のレンダーターゲットに設定する場合はD3D12_RENDER_TARGET_BLEND_DESCを使用します。
パラメータを1つずつ見ていきましょう。
BlendEnableをtrueにしてブレンディングを有効にします。
LogicOpEnableは論理演算を使用するかどうかです。AND、OR、XORなどの演算でピクセルの色の値を比較することができます。少し特殊なケースで使用されます。一般的なブレンディングでは使用しなくても構いません。
SrcBlendとDestBlendは、ピクセルの色のアルファ値をどのように掛け合わせるかに関するものです。
Srcは描画しようとしているピクセル、Destはすでに描画されているピクセルだと考えてください。
D3D12_BLEND_SRC_ALPHAは、アルファ値を掛けるという設定です。
D3D12_BLEND_INV_SRC_ALPHAは、1からアルファ値を引いた値を掛けるという意味です。
BlendOpは最終的な色を決定する方法です。D3D12_BLEND_OP_ADDに設定すると、2つの色の値を足すという意味になります。
SrcBlendAlphaとDestBlendAlphaは、ピクセルのアルファ値をどのように使用するかを指定します。
D3D12_BLEND_ONEはピクセルのアルファ値をそのまま使用するという設定で、
D3D12_BLEND_ZEROはピクセルのアルファ値を無視する設定です。
ここまでを式で表現してみましょう。
描画する色をsrcColor
描画する色のアルファ値をsrcAlpha
すでに描画されている色をdestColor
とします。
DestBlendAlphaはD3D12_BLEND_ZEROだったため、すでに描画されている色のアルファ値は全く使用されません。
式で表すとこうなります。
final = (srcColor srcAlpha) + (destColor (1 - srcAlpha))
式で見るとより分かりやすいですね
LogicOpは、先ほど見たLogicOpEnableをtrueにした場合に使用する演算の種類です。使用しないため、D3D12_LOGIC_OP_NOOPに設定します。
RenderTargetWriteMaskはrgbaチャンネルに対するマスクで、D3D12_COLOR_WRITE_ENABLE_ALLに設定するとrgbaすべてを出力します。
ここまで説明した設定が、最も一般的なアルファブレンディングの設定だと言えます。
他の値に設定して、どのように変化するか直接確認してみるのもいいでしょう。
ここまでで、レンダーターゲットを設定しましょう。
gpipeline.BlendState.RenderTarget[0] = transparencyBlendDesc;
レンダーターゲットは1つしか使用していないため、0番目のインデックスにのみ設定すればよいですね。
顔もよく見えるようになり、手首の服の内側も透けて見えますね。
うまく適用されたようです。
ディフューズ
ディフューズは拡散反射光のことを指します。
拡散反射光は、光が表面に当たった際に様々な方向に散乱する光のことです。
ディフューズは主にランバート(Lambert)モデルを使用します。
ランバートモデルは、法線と入射光のなす角度のコサイン値をディフューズの量として使用します。
法線ベクトルと入射光ベクトルを正規化すれば、両者の内積だけでディフューズ値を計算することができます。
それではシェーダーに適用してみましょう。
ピクセルシェーダーをこのように修正します。
float4 BasicPS(Output input) : SV_TARGET
{
float3 light = normalize(float3(1, -1, 1));
float3 normal = normalize(input.normal);
float diffuseB = saturate(dot(-light, normal));
float4 color = tex.Sample(smp, input.uv);
color.rgb = color.rgb * diffuse;
return color;
}
lightは光の方向です。
通常はディレクショナルライトの方向を受け取って使用しますが、まだディレクショナルライトを定義していないため、シェーダーに直接任意のベクトルを作成しました。
このベクトルは、おおよそ斜め下を向いているベクトルです。
normalは当然法線ベクトルですが。
なぜわざわざinputのnormalを正規化するのでしょうか?
以前の記事でシェーダーについて話したとき、ピクセルシェーダーが呼び出される前に頂点シェーダーの後にラスタライズという過程を経ると言いました。このときラスタライザーは頂点シェーダーの結果を補間してピクセルシェーダーに渡すと言いましたね。
この過程で、頂点シェーダーでは長さが1だった法線ベクトルが補間されることで長さが変わる可能性があるからです。
光ベクトルと法線ベクトルを内積するとき、両方とも長さが1でなければならないため、わざわざ正規化をしているのです。
float diffuseB = saturate(dot(-light, normal));
次に、光ベクトルと法線ベクトルの内積を取ります。光ベクトルは光源からオブジェクトに向かうベクトルであるため、正しい結果を得るにはこのベクトルを反転させる必要があります。
saturateは、パラメータとして入力された値が0〜1の範囲を超えないように調整します。
このメソッドはGPUでの演算コストがほぼゼロに近いため、気軽に使用しても問題ありません。
float4 color = tex.Sample(smp, input.uv);
color.rgb = color.rgb * diffuse;
ディフューズ値を求めたので、この値を色の値に掛けます。
HLSLのfloat4はrgbaやxyzwのように各要素のみを使用できます。
rgbにのみディフューズ値を掛けます。
aはアルファ値なので、ディフューズ値を適用してはいけません。
結果を確認する前に予想してみましょう。
内積は長さが1の二つのベクトルのコサイン値でしたね。
そうすると結果値は0〜1の範囲になります。(saturateによって0いかは0になりまし)
つまり、最も暗い部分は0で、最も明るい部分は1です。
この値を色に掛けると、明るい部分はよく見え、暗い部分はあまり見えなくなるでしょう。
言葉で長々と説明しましたが、どんな感じかわかると思います。
ミクさんに陰影が付いたのが確認できるようになりました。
しかし、何か綺麗という印象は受けませんね。
実はこの結果自体は、ディフューズ値が本当に正しく適用された結果です。
ですが、もう少しアニメーションキャラクターのような感じを求めています。
そのためにトゥーンテクスチャを活用してみましょう。
トゥーンテクスチャ
以前、カラーテクスチャとピクセルシェーダーで使用できるように追加したテクスチャでしたね。これはトゥーンシェーディング用のテクスチャです。
最近はトゥーンシェーディングを使用するゲームが多いため、わざわざ説明しなくても何を指しているかご理解いただけると思います。
簡単に特徴を言えば、フォトリアルなグラフィックスとは異なり、陰影が滑らかに適用されずに極端に生じると言えるでしょうか。
トゥーンシェーディングを簡単に作成するとしたら、ディフューズ値をceilメソッドを使用して作成することもあります。
float diffuseB = saturate(dot(-light, normal));
diffuseB = ceil(diffuseB * 5) / 5.0f;
このようにすると、ディフューズ値が5段階に分割された値で明確に区切られます。
特定の部分を見ると、陰影が滑らかではなく、途切れているような印象を受けます。
しかし、全体的に見ると、綺麗という印象からはほど遠いですね。
そのため、PMXモデルではテクスチャを使用してディフューズ値を調整する方法を採用しています。
開いてみると、ある色が上下にグラデーションしているような印象です。
このテクスチャをサンプリングする際、ディフューズ値をv値として使用してサンプリングし、その結果を最終的なディフューズカラーとして使用します。
このようにすると、陰影の色も指定可能で、陰影がどのようにつくべきかの調整も自由自在にできますね。
では、トゥーンテクスチャを使用するようにピクセルシェーダーを修正してみましょう。
float4 BasicPS(Output input) : SV_TARGET
{
float3 light = normalize(float3(1, -1, 1));
float3 normal = normalize(input.normal);
float diffuseB = saturate(dot(-light, normal));
float4 toonDif = toon.Sample(smpToon, float2(0, 1.0 - diffuseB));
float4 color = tex.Sample(smp, input.uv);
color.rgb = color.rgb * toonDif;
return color;
}
ディフューズ値を1.0から引いた値をV値として使用してサンプリングしています。
サンプリングされた結果は最終的な色に掛け合わせます。
サンプリングの際には、カラーテクスチャとは違うサンプラーを使用しています。
smpToonがどのようなサンプラーだったか覚えていますか?
samplerDesc[1].AddressU = D3D12_TEXTURE_ADDRESS_MODE_CLAMP;
samplerDesc[1].AddressV = D3D12_TEXTURE_ADDRESS_MODE_CLAMP;
samplerDesc[1].AddressW = D3D12_TEXTURE_ADDRESS_MODE_CLAMP;
samplerDesc[1].Filter = D3D12_FILTER_ANISOTROPIC;
samplerDesc[1].ShaderRegister = 1;
D3D12_TEXTURE_ADDRESS_MODE_CLAMPを設定してUV値が0〜1の範囲を超えないようにしました。
さっきよりずっと綺麗になりました。
色だけを出力した時と比べてみましょうか?
スペキュラー
スペキュラーは、鏡のように滑らかな表面で起こる反射のことを指します。
スペキュラーの中で最も有名なのはブリン-フォン(Blinn-Phong)モデルです。
私が見ている方向から反射された光がある場合、その部分のハイライトが最も強くなるという前提に基づいています。
ブリン・フォンモデルでスペキュラーを求める方法を見ていきましょう。
上の図をご覧ください。
カメラに向かうベクトルをE
光源に向かうベクトルをLとします。
法線ベクトルがあれば、Lベクトルが法線ベクトルを基準に反射するベクトルを求めることができます。この反射ベクトルをRと呼ぶことにしましょう。
そうすると、EとRの内積を取ります。二つのベクトルの角度の差が小さいほど、結果の値は1に近づきますよね?
しかし、この値をそのまま使用すると、下の写真のように小さな部分に白く照明が当たらず、広範囲に照明が当たってしまいます。
そのため、内積の結果値を乗算してこの値を狭めます。
頂点シェーダーでカメラから頂点に向かうベクトルを返すように修正しましょう。
struct Output
{
float4 svpos : SV_POSITION;
float2 uv : TEXCOORD;
float3 ray : VECTOR;
};
Outputにrayを追加します。
Output VS(
float4 pos : POSITION,
float4 normal : NORMAL,
float2 uv : TEXCOORD)
{
Output output;
pos = mul(world, pos);
output.svpos = mul(mul(proj, view), pos);
output.uv = uv;
output.ray = normalize(pos.xyz - eye);
return output;
}
頂点位置からカメラ位置を引いた結果をrayとして渡します。
これでピクセルシェーダーでカメラから頂点位置までのベクトルを使用できるようになりました。
では、法線ベクトルを基準に反射ベクトルを求めればいいですね。
反射ベクトルはどのように求めるのでしょうか?
少し考えてみましょう。
内積の結果値はベクトルのベクトルを投影したベクトルのスカラー値です。
そうすると、内積の結果値をNに掛けると投影したベクトルを求めることができます。
では、投影するためにRを反転させて内積を取りましょう。
そして、再びベクトルRが原点にあると仮定すると、Rに2(-RdotN)を加えることで反射ベクトルを求めることができます。
式で表すとこのようになりますね。
$$
Reflecion=R+2N(-R\cdot N)
$$
反射ベクトルについて話しましたが、実際にはHLSLには入射角ベクトルと法線ベクトルだけで簡単に計算してくれるreflectメソッドがあります。私たちはこれを使用しましょう。
ピクセルシェーダーにこのように追加してください。
float3 refLight = normalize(reflect(light, normal));
float specularB = pow(saturate(dot(refLight, -input.ray)), specular.a);
float3 specularColor = float3(1, 1, 1) * specularB;
光の方向ベクトルの反射ベクトルを求め、
これとカメラから位置へ向かうベクトルの内積を取ります。
値を狭めるためにpowで累乗しますが、何乗するかはマテリアルバッファのspecularのa値を使用します。
スペキュラーがうまく入りました。
しかし、白色にしたせいか、あまり綺麗ではありません。
マテリアルのスペキュラーカラーを使用するように修正しましょう。
float3 specularColor = specular.rgb * specularB;
マテリアルの色を適用しても、実際にはあまり目立ちません。
しかし、これはモデルを作成した人の意図通りに正しく適用されたものです。
次回