DXライブラリで「デカール」を実装してみる
CGにおけるデカールって何?
ところで、デカールとは何だろうか?
これ、一般の人とCGの人とで想像するものがちょっと違うんですよね。
プラモデルとかにおけるデカールって表面に貼るシールのことを指すんですが、アイロンプリントとかその他シール的なものを転写するものを総称して「デカール」って言うんですよね。
で、CGにおけるデカールっていうのは、元々そのテクスチャが貼られる設定のないオブジェクトに特定のルールでテクスチャを貼る事です
デカールにおけるUVは?
前述の通り「元々そのテクスチャが貼られる設定ではないオブジェクト」であるため、デカールと全く関係のないUV規則によって描画されます。
このため、もともとの頂点情報にUV情報が含まれていない物体にも使用することができます。
え?貼り付けるテクスチャのUVがわかってないのにどうやって貼るんだよ!と思われるかもしれませんが、自身のUV情報以外の情報を利用します。
例えば、ローカル座標やワールド座標や法線などです。
デカールの実装(2D編)
分かりやすくするために、まずは2Dの例からやっていきます。2Dの場合は正直あまり意味がありませんが理解の助けになると思うので、実装していきます。
まず、理屈はこうです
外側の矩形が「デカールを書かれる側」で、内側の水色の矩形が「デカール画像」だとします。で、特定の座標にデカールを貼り付けたいわけですが、書かれる側の左上から貼り付けたいデカールの中心へのベクトルを$\vec{V}$とします。
なお、書かれる側の幅と高さは$W,H$とし、デカールの幅と高さは$w,h$とします。となると、まず単純に考えてピクセルシェーダ内で「書かれる側」を描画する際に$\vec{V}.x\pm\frac{w}{2}$および$\vec{V}.y\pm\frac{h}{2}$の範囲内に入った時にデカール側を描画すればいいわけです。
簡単ですよね?
ただ、ピクセルシェーダの場合はUVが0~1の範囲内で、しかも「デカールを書かれる側」になるため縮尺を合わせる必要があります。
もし拡大縮小ナシならば縮尺はそれぞれ$r_x=\frac{w}{W} , r_y=\frac{h}{H}$で、もし拡縮があって拡大率Sがあっても$r_x=S\frac{w}{W} , r_y=S\frac{h}{H}$で済みます。
プログラム的にはコンスタントバッファで描画すべき座標とスケールを渡して、
struct ForDecal {
float x;
float y;
float angle;
float scale;
};
auto cbuff = CreateShaderConstantBuffer(sizeof(ForDecal));
ForDecal* forDecal=static_cast<ForDecal*>(GetBufferShaderConstantBuffer(cbuff));
forDecal->scale = 1.0f;
(とりあえずangleは無視しといてください)
で、左クリックの座標(のUV値)を渡すようにします
int mx, my;
int minput = GetMouseInput();
if (minput & MOUSE_INPUT_LEFT) {
GetMousePoint(&mx, &my);
forDecal->x = (float)(mx-gx) / (float)gw;
forDecal->y = (float)(my-gy)/ (float)gh;
UpdateShaderConstantBuffer(cbuff);
SetShaderConstantBuffer(cbuff, DX_SHADERTYPE_PIXEL, 4);
MyLib::DrawRotaGraph(mx, my, 1.0f, 0.0f, srcH,decalH,ps);
}
あ、ちなみに、MyLib::DrawRotaGraphというのは自分で作ったシェーダ付きのDrawRotaGraphするものです。
これ自体は割とテキトーに作ってるんであまり気にしないでください
void MyLib::DrawRotaGraph(int x, int y, float scale, float angle, int imgHandle, int secondImg, int thirdImg, int psHandle) {
int width, height;
GetGraphSize(imgHandle, &width, &height);
int w = (width*scale) / 2;
int h = (height*scale) / 2;
array<VERTEX2DSHADER, 4> verts;
for (auto& v : verts) {
v.rhw = 1.0;
v.dif = DxLib::GetColorU8(255, 255, 255, 255);//ディフューズ
v.spc = DxLib::GetColorU8(255, 255, 255, 255);//スペキュラ
v.su = 0.0f;
v.sv = 0.0f;
v.pos.z = 0.0f;
}
//左上
verts[0].pos.x = x-w;
verts[0].pos.y = y-h;
verts[0].u = 0.0f;
verts[0].v = 0.0f;
//右上
verts[1].pos.x = x + w;
verts[1].pos.y = y - h;
verts[1].u = 1.0f;
verts[1].v = 0.0f;
//左下
verts[2].pos.x = x - w;
verts[2].pos.y = y + h;
verts[2].u = 0.0f;
verts[2].v = 1.0f;
//右下
verts[3].pos.x = x + w;
verts[3].pos.y = y + h;
verts[3].u = 1.0f;
verts[3].v = 1.0f;
int alphamode, alphaparam;
SetDrawBlendMode(DX_BLENDMODE_ALPHA, 255);
GetDrawAlphaTest(&alphamode, &alphaparam);
SetDrawAlphaTest(DX_CMP_GREATER, 0);
SetUseAlphaTestFlag(true);
SetUsePixelShader(psHandle);
SetUseTextureToShader(0, imgHandle);
SetUseTextureToShader(1, secondImg);
SetUseTextureToShader(2, thirdImg);
DrawPrimitive2DToShader(verts.data(), verts.size(), DX_PRIMTYPE_TRIANGLESTRIP);
}
で、シェーダ側はこう
// 描画するテクスチャ
SamplerState smp : register(s0);
Texture2D dec : register(t0);
Texture2D src : register(t1);
// ピクセルシェーダーの入力
struct PS_INPUT
{
float4 pos : SV_POSITION;
float4 dif : COLOR0;
float4 spc : COLOR1;
float2 uv : TEXCOORD0;
};
cbuffer buff : register(b4)
{
float2 center;
float angle;
float scale;
}
で、渡された値を
float2 brushSize;
float2 srcSize;
src.GetDimensions(srcSize.x, srcSize.y);
dst.GetDimensions(brushSize.x, brushSize.y);
float2 rate = (brushSize / srcSize)*scale;
float2 p = center.xy;//クリックされた座標(UV)
float2 v = center.xy-input.uv;//クリックされた座標から見たベクトル
float2 decUV=v/rate+float2(0.5,0.5);
float4 col = src.Sample(smp,input.uv);
if((0.0<=decUV.x && decUV.x<=1.0)&&(0.0<=decUV.y && decUV.y<=1.0)){
col=dec.Sample(smp,decUV);
}
てやると、こうなります。
はい、面白くないですね。普通にそこに描画してるのと変わりませんから。ただシェーダ上でツリーのα値を見ながら描画できたりするので、ツリーでマスクすることもできます
ここまではそう難しくない。では次に参ろうか
デカール画像を回転させる
通常であればDrawRotaGraphのように「頂点情報を回転」させる事によって、画像の回転というより板ポリ自体を回転させることになりますが、今回のようなデカールになるとそうはいきません。
ピクセルシェーダ上での回転が必要になります。
図にするとこのようになります。ちょっと分かりづらいかもしれませんが、最終的にやりたい事は「書かれる側のピクセル座標をデカールのピクセル座標に変換する」ですが、この図ではわかりやすくするためにまず逆にして考えています。つまり
「デカールのピクセル座標を書かれる側のピクセル座標に変換する」事を考えます
ここで「基底ベクトルに対する内積(射影)」というのを考えます。
特定のベクトルを、別の単位ベクトルと内積すると射影(その単位ベクトル方向の長さ)を得る事ができます。
もちろん、基底ベクトルが軸平行ならば、それはそのまま座標に等しくなります。どういう事かというと
$\vec{i}=(1,0) , \vec{j}=(0,1)$とすると、ある特定のベクトル$\vec{v}=(a,b)$をこのiとjにそれぞれ内積すると$\vec{v}\cdot \vec{i}=a , \vec{v}\cdot{j}=b$となります。
言い換えると$\vec{v}=(\vec{v}\cdot \vec{i})\vec{i} + (\vec{v}\cdot{j})\vec{j}$と言えます
そして、回転とはこの基底ベクトルが軸平行ではなくなった状態と言えるため、軸平行ではなくなった(だが直交している)基底ベクトルを$\vec{i'} , \vec{j'}$とおくと…
\vec{v'}=(\vec{v}\cdot \vec{i})\vec{i'} + (\vec{v}\cdot{j})\vec{j'}
これで回転後の座標を求められるわけです。
図より、$v$から最後の$P$を求めたければ
\begin{align}
P&=\vec{V}+\vec{v'}\\
&=\vec{V}+(\vec{v}\cdot{i})\vec{i'} + (\vec{v}\cdot{j})\vec{j'}
\end{align}
になります。これがθ回転された基底ベクトルの場合$\vec{i'}=\vec{i}cosθ-\vec{j}sinθ , \vec{j'}=sinθ\vec{i}+cosθ\vec{j}$になります。
P=\vec{V}+(\vec{v}\cdot{i})(cosθ\vec{i}-sinθ\vec{j}) + (\vec{v}\cdot{j})(sinθ\vec{i}+cosθ\vec{j})
で、実際には「書かれる側」のピクセル座標からデカール側のUV値を知りたいのでこの逆を行います。
まず$\vec{m}=\vec{P}-\vec{V}$とおきます、この$\vec{m}$と回転した状態の基底ベクトルの内積を計算します。
で、その内積結果を回転していない基底ベクトルに適用します。
\vec{m'}=(\vec{m}\cdot \vec{i'})\vec{i}+(\vec{m}\cdot \vec{j'})\vec{j}
$\vec{m'}$が分かってしまえば、あとは縮尺を合わせるだけで回転にも対応できます。
縮尺はこのm'ベクトルに乗算すればいいだけなので、簡単ですね。
float2 brushSize;
float2 srcSize;
src.GetDimensions(srcSize.x, srcSize.y);
dst.GetDimensions(brushSize.x, brushSize.y);
float2 ivec=float2(cos(angle),-sin(angle));
float2 jvec=float2(sin(angle),cos(angle));
float2 rate = (brushSize / srcSize)*scale;
float2 p = center.xy;//クリックされた座標(UV)
float2 v = center.xy-input.uv;//クリックされた座標から見たベクトル
v=float2(1,0)*dot(v,ivec)+float2(0,1)*dot(v,jvec);
float2 decUV=v/rate+float2(0.5,0.5);
float4 col = src.Sample(smp,input.uv);
if((0.0<=decUV.x && decUV.x<=1.0)&&(0.0<=decUV.y && decUV.y<=1.0)){
col=dec.Sample(smp,decUV);
}
デカールの実装(3D編)
球体マッピング
これはいわゆるmatcapみたいなやつです。
座標もしくは法線からUV座標を計算します。
今回は後でやる円柱マッピングにつなげるために、座標からUVを算出します。バウンディングボックスを作っておきます。
VECTOR minpos = {10000.f,10000.f,10000.f};
VECTOR maxpos = { -10000.f,-10000.f,-10000.f };
int num = MV1GetMeshNum(model);
for (int i = 0; i < num; ++i) {
auto tmin = MV1GetMeshMinPosition(model, i);
minpos = VGet(std::min(tmin.x, minpos.x), std::min(tmin.y, minpos.y), std::min(tmin.z, minpos.z));
auto tmax = MV1GetMeshMaxPosition(model, i);
maxpos = VGet(std::max(tmax.x, maxpos.x), std::max(tmax.y, maxpos.y), std::max(tmax.z, maxpos.z));
}
これで、最大と最小のXYZを記録しておきます。
struct ForDecalCBuff {
float minY;//円柱マップの時の上
float maxY;//円柱マップの時の下
float radius;//半径
float angle;
int isSphere;//マッピングが球体か円柱か
int dummy[3];
};
int cbuffH=CreateShaderConstantBuffer(sizeof(ForDecalCBuff));
auto cbuff= (ForDecalCBuff*)GetBufferShaderConstantBuffer(cbuffH);
cbuff->radius = std::hypot(maxpos.x - minpos.x, maxpos.y-minpos.y, maxpos.z - minpos.z);
これで半径をシェーダ側に送れます。という事はすべての頂点がこの半径内に入る事になります。
で、ピクセルシェーダ側はこうなります。まずはコンスタントバッファ
struct ForDecalCBuff
{
float minY;
float maxY;
float radius;
float angle;
int isSphere;
int dummy[3];
};
cbuffer ConstatnBuffer : register(b3)
{
ForDecalCBuff g_decal;
}
んで、input.posをUVに変換します。
まず、真上から見てXZ座標を見て、円の角度を計算します。$\theta=tan^-1(\frac{z}{x})$
今度は縦方向の角度を$\phi=tan^-1(\frac{y}{|\vec{xz}|})$として、$u=\frac{\theta}{2\pi}$、$v=\frac{\phi}{\pi}$とします
float theta = atan2(pos.z, pos.x) + pi; //水平方向角度
float xzradius = length(pos.xz);
float phi = (pi / 2.0) - atan2(pos.y , xzradius); //垂直方向角度
uv.x = theta / (pi * 2.0);
uv.y = phi / pi;
円柱マップ
はい、次は円柱マップです。球体マップが分かってたらそんなに難しくないと思いますが、円柱マップはせっかくですから、高さを変更できるようにしましょう。
円柱の時はY方向をradiusに含めなくていいため
cbuff->radius = std::hypot(maxpos.x - minpos.x, 0.0f, maxpos.z - minpos.z);
cbuff->minY = minpos.y;
cbuff->maxY = maxpos.y;
とします。また、高さの範囲をモデルのバウンディングボックスから持ってきます。
この範囲は可変にしておきます。
GetHitKeyStateAll(keystate);
if (keystate[KEY_INPUT_UP]) {
maxpos.y += 1.0f;
minpos.y -= 1.0f;
}
else if (keystate[KEY_INPUT_DOWN]) {
maxpos.y -= 1.0f;
minpos.y += 1.0f;
}
で、Uのほうは球体マップの時と同じなんですが、Vが現在の座標をあらかじめ渡したMIN~MAXの範囲を0~1としたうえでの割合を計算したものを渡します。
////円柱マッピング
float2 pos = psinput.pos.xz;
pos = normalize(pos);
uv.x = (atan2(pos.y, pos.x) + pi) / (pi * 2.0);
uv.y = (g_decal.maxY - psinput.pos.y) / (g_decal.maxY - g_decal.minY);
このマッピングは帯を巻いたようになります。ですから
デフォルトだとこう
さて、ここまでは所謂ゲームで使われる「デカール」とは程遠いものでした(一応ここまでの知識自体はこの後も役に立つと思いますが)
次が真打です。真打なのですが、もうあまり時間がないのでまずは理屈だけ書いておきます。
来年の初回くらいでまた実装の話を書こうかと思いますが…
直方体(弾痕)デカール(予告編)
なにをするかというと、直方体というのは右、上、奥という軸を持っています。つまり座標系を持っているわけですね。
で、この直方体デカールに触れている部分のピクセルを描画する際に、座標を直方体の右方向と上方向の単位ベクトルに対して内積をとります。
で、この内積をとった結果を使って2Dの時同様にテクスチャの場所を計算します。
で、それで取ってきた色を合成してくれば物体表面に弾痕だの血だののシールを貼る事ができるわけです。
データ的には中心点のワールド座標と幅と高さと奥行きと、あとは有効性のデータを置けばいいため
struct BoxDecal{
Vector3 pos;//中心点
float width;//幅
float height;//高さ
float depth;//奥行
bool isEnabled;//有効フラグ
};
これを8個くらいの配列にして、一度に8個の銃弾を貼り付けられるようにします。
もう時間がないため、今回はここまで、次回はまた別のQiita記事をアップしようと思います。