ホーム < ゲームつくろー! < Shader編

その1 メッシュを任意の平面で輪切った断面図を描いてみる

〇 使用シェーダ:VS, PS


 えー、Shader編の一発目から「誰が使うんだそんなもん(^-^;;;」という内容ですが、物凄く個人的に私が必要になってしまったために最初のトピックとして挙げさせて頂きました。
 事の発端は3Dプリンタを購入したのが始まりです。3Dプリンタにも色々とタイプがありますが、多くのプリンタは「積層型」を採用しています。これは薄い層(レイヤー)を作成していき、それを積み上げる事により立体物を形成する方式です。この方式は頭の中ではうまくいきそうに感じますが、実は「重力」が敵になります。例えば下のような例をご覧ください:

 上のマグカップ、取っ手の赤丸部分が実はエラーです。下から積層していく時に、緑色の波線まではマグカップの本体のみですが、青い波線の所でいきなり取っ手の下部が出てきます。しかしこの突然現れた部分を支えてくれる物がありません。その為、この下部の塊はそのまま下に落っこちてしまいます。もちろんそこからさらに上のレイヤーも支えが無いため落ち続けてしまいます。こういう落っこちてしまう所を「オーバーハング」と呼んだりします。

 通常オーバーハングには「サポート」と呼ばれる支えを付けます。そういうのを自動的に付けてくれるツールもあるのですが、厄介な事に上のような付けて欲しい所が何故か付いていないという事があります。自動生成ツールを信じてごっそりサポートが付いたモデルをプリントアウトしてみたら、奥まった一部分が落下していた…なんて事も実際結構あります。プリントアウトには数時間〜数十時間もかかり、大きなものであればコストも馬鹿になりません。サポートミスで出力に失敗するのは本当に悔しい物なのです (T-T)g

 このミスを防ぐには「すべてのレイヤーを描いて、突然現れる箇所が無いかチェックする」必要があります。所が、そういう事をサポートしてくれるツールが中々見当たりません(レイヤー化してくれる物はあります)。それで困ったな…となったわけです。

 そこで、無いのであれば作ってしまおうと考えたのですが、はて「3Dモデルを水平面で輪切りにした断面図はどうやって作成したら良いのだろう」と…。暫くあれこれ考え、色々な人にも相談した結果、シェーダを用いると割と分かりやすく、しかも高速に作成できるのが分かりました。それをここで紹介しようと思ったわけです。ふぅ〜(^-^;



@ 内側だけ塗るには?

 ポリゴンメッシュは言わずもがなですが厚みの無い三角ポリゴンだけで形成されています。それを輪切りにした所でその中身は空っぽです。断面図を描くにはそこに何らかの方法で色を塗ってあげないといけないわけです。ぱっと思いつく方法としては、輪切りにした輪郭にある頂点を頼りにその内側にメッシュを張るというのがありそうです。しかし、この方法は意外と難易度が高いんです。例えば下のような網目構造の輪郭をうまく作れたとして、ここに三角ポリゴンを張るアルゴリズムって中々難しいわけです:

 それをさらにリアルタイムでとなったら、頑張ればいずれ出来るとは思いますが、正直やりたくない(-_-;。欲しいのは断面図のメッシュでは無くてあくまでも「絵」なので、要は塗りつぶしさえできればいいんです。そして、「塗る」という行為はピクセルシェーダの得意分野でもあります。「いやでも、ピクセルシェーダが塗る範囲はポリゴン面なわけでしょ?それはそもそもどうするのさ」と思われるかもしれません。実は、とても都合の良いポリゴン面がメッシュにはちゃんと存在しています。それは「裏面」です。

 閉じているポリゴンメッシュをとある平面でズバッと切って、その切り口から内側を覗くと、そこには必ず裏面が見えます。パイプのような構造物の場合、その厚み部分には裏面が見え、パイプの中空になっている所は裏面が見えません。マグカップの場合は中空部にカップの底(表面)が見えます:

この性質を利用して、とある水平面より下にあるポリゴンの裏面をまず描き、次に同様にとある水平面より下にある表面を上書きで描いてあげると、上図のような見た目になります。さらに言えば、表面を描く時に下地になっている裏面の色を「引き算」するように塗ると、相殺して塗る前の色(バックバッファカラー)に戻ってしまいます。そうすると断面図だけが浮き彫りになります!

 普段は元々塗られている色にシェーダで決めた色を足し算するのが普通です。それを引き算するというのは可能なのでしょうか?実はブレンドステートの演算子には「転送先から転送元を引き算する」というのがちゃんと用意されています。DirectX9ならSetRenderState( D3DBLENDOP, D3DBLENDOP_REVSUBTRACT )とすると上のような引き算が行われるんです。エフェクトファイル内に記述する場合はREVSUBTRACTの所だけを採用します。Direct3D10の場合は次の通りです:

Direct3D10でのアルファブレンディング設定
BlendState bs {
   AlphaToCoverageEnable = FALSE;
   BlendEnable[0] = TRUE;
   SrcBlend = ONE;
   DestBlend = ONE;
   BlendOp = REV_SUBTRACT;
   SrcBlendAlpha = ONE;
   DestBlendAlpha = ZERO;
   BlendOpAlpha = ADD;
   RenderTargetWriteMask[0] = 0x0F;
};

これで ( Dest * 1.0 ) - ( Src * 1.0 ) = Dest - Src という減算合成が実現します。



A 重なりメッシュでもOK

 さて、@のアルゴリズムはシンプルで綺麗な閉じたメッシュであれば問題なくうまくいきます。でも3Dプリンタのモデルというのは大にしてメッシュが重なっていたりめり込んでいたります。そもそもサポートはメッシュに少しめり込ませる事で支えとなるんです。そういう普通じゃないメッシュでもこのアルゴリズムは大丈夫なのでしょうか?具体的な例で検証してみましょう。

 大きな立方体と小さな立方体が互いに重なっているとします。それを水平面で輪切りにして@のアルゴリズムで描画してみます:

右図で緑色の○は裏面が描画される所、赤丸は表面が描画される所です。この描画では深度テストは切ります。赤丸と緑丸は足されると互いに相殺するので、真上から描画すると帯び線のようなカラーリングになります。黄色の所は緑丸が2度重なるため色が明るくなる箇所です。そう、互いに重なっている所の内側真上から見えた場合、そこは2重描画になるんです。これにより結局1色で綺麗に塗り潰されるという事にはならないのですが、断面図は描けているのでオーバーハング判定のチェックでは問題になりません。寧ろこれは「めり込みチェック」として使えます。



B 頂点シェーダ

 ではここまでの理屈をシェーダに落とし込んでいきましょう。

 まず頂点シェーダではローカル座標にあるメッシュを射影空間に変換します。この時射影変換行列として使うのはパースペクティブ行列ではなく正射影行列です。今回のアルゴリズムに遠近感を考慮してはいけないわけですから。正射影行列にはワールド空間を切り取る幅と高さのパラメータがあります。これはテクスチャの縦横比と等しくて且つ真上から見対象モデルにフィットする長方形の縦横サイズになります:

  

 水平面で切ったメッシュを真上から見下ろさなければならないため、カメラはメッシュモデルの頭上(Y軸)に置き、そこから真下、すなわちY軸と反対方向を向かせます。カメラの空方向はZ軸としましょう。ここからビュー行列を作成します。DirectX9であればD3DXMatrixLookAtLH関数に次のようなパラメータを設定します:

真上から見下ろすビュー行列
D3DXMATRIX view;
D3DXMatrixLookAtLH(
    &view,
    &D3DXVECTOR3( mx, maxY + 1.0f, mz ),   // カメラ位置はモデルのトップ位置よりも上に
    &D3DXVECTOR3( mx, maxY, mz ),          // 注視点はモデルのセンターを見下げる位置に
    &D3DXVECTOR3( 0.0f, 0.0f, 1.0f )       // 空方向は+Z軸
);

 ワールド変換行列はいい感じの位置に調整してあげて下さい。位置調整によってmaxYの位置が変わるのに注意です。

 こうして作成したワールド、ビュー、正射影行列をコード上で掛け算してあげて、頂点シェーダにWVP行列として渡してあげます。そしてもう一つ、ワールド変換行列のみも渡す必要があります。これはモデル頂点のワールド空間での位置がピクセルシェーダで必要になるためです。グローバル変数、構造体及び頂点シェーダはこんな感じになります:

頂点シェーダ(Direct3D10)
matrix world;
matrix wvp;
float yLevel;

struct VS_INPUT {
float3 pos : POSITION;
float3 norm : TEXCOORD0;
};

struct PS_INPUT {
float4 pos : SV_POSITION;
float3 norm : TEXCOORD0;
float worldPosY : TEXCOORD1;   // ワールド空間での頂点の高さ
};

PS_INPUT vsMain( VS_INPUT In ) {
    PS_INPUT Out;
    Out.worldPosY = mul( float4( In.pos, 1.0 ), world ).y;  // ワールド空間での頂点の高さをPSに渡す
    Out.pos = mul( float4( In.pos, 1.0 ), wvp );
    return Out;
}



C ピクセルシェーダ、決め手は0を足し引きする&step関数

 ピクセルシェーダでは「ある高さ(yLevel)以上にある描画点を描画しない」というのがメインの仕事になります。理屈で見てきたように、裏面及び表面はすでに描画されている色に対して足し算及び引き算をします。という事は「描画しない」というのは「ゼロを足す(引く)」という事をすれば良いのが分かります。ある高さ以上か否かは条件文を書きたくなりますが、HLSLには「step」という関数が用意されています:

step関数
ret step( y, x );

 この関数は、ret = ( x >= y ? 1 : 0 ) という演算と同値です。yよりxが同じか大きければ1が返り、それ以外は0が返るというわけです。今回のケースであればxにyLevel、yに頂点シェーダから渡されてきた描画点の高さ(In.worldPosY)が入る事になります。1であれば描画、0であれば描画しない、です。

 描画点の高さがyLevel以下の時の足し算(引き算)する色味は白色、すなわちfloat4( 1.0, 1.0, 1.0, 1.0 )にします。浮動小数点はある桁までは整数の足し引きを正確に行えるからです。ただし、レンダーターゲット(バックバッファ)が整数テクスチャの場合、値は0.0〜1.0にクランプされてしまいます。よって、「レンダーターゲットは浮動小数点テクスチャ」にする必要があります。以上からピクセルシェーダは以下のような1行のコードに凝縮されます:

ピクセルシェーダ(Direct3D10)
float4 psMain( PS_INPUT In ) : SV_TARGET {
    return step( In.worldPosY, yLevel );
}



D テクニック

 テクニックでは裏面及び表面の各描画パスを設定します:

テクニック(Direct3D10)
technique10 tech {
    pass BackfaceDraw {
        SetVertexShader( CompileShader( vs_4_0, vsMain() ) );
       SetPixelShader( CompileShader( ps_4_0, psMain() ) );

        SetRasterizerState( rsBackface );
        SetBlendState( bsBackface, float4( 0.0f, 0.0f, 0.0f, 0.0f ), 0xFFFFFFFF );
       SetDepthStencilState( dss, 0 );
    }
   
    pass FrontfaceDraw {
        SetVertexShader( CompileShader( vs_4_0, vsMain() ) );
        SetPixelShader( CompileShader( ps_4_0, psMain() ) );

        SetRasterizerState( rsFrontface );
        SetBlendState( bsFrontface, float4( 0.0f, 0.0f, 0.0f, 0.0f ), 0xFFFFFFFF );
        SetDepthStencilState( dss, 0 );
    }
}

 ラスタライザ及びブレンドステートは裏面描画及び表面描画で異なります。裏面では表面を描画してはいけないので表面カリングをラスタライズに設定します。表面描画の時はその逆です。ブレンドステートは、裏面描画時は加算合成、表面描画の時は反転引き算(REV_SUBTRACT)を設定します:

ラスタライザとブレンドステート(Direct3D10)
RasterizerState rsBackface {
    CullMode = Front;
};

RasterizerState rsFrontface {
    CullMode = Back;
};

BlendState bsBackface {
    AlphaToCoverageEnable = FALSE;
    BlendEnable[0] = TRUE;
    SrcBlend =  ONE;
    DestBlend = ONE;
    BlendOp = ADD;
    SrcBlendAlpha = ONE;
    DestBlendAlpha = ZERO;
    BlendOpAlpha = ADD;
    RenderTargetWriteMask[0] = 0x0F;
};

BlendState bsFrontface {
    AlphaToCoverageEnable = FALSE;
    BlendEnable[ 0 ] = TRUE;
    SrcBlend = ONE;
    DestBlend = ONE;
    BlendOp = REV_SUBTRACT;
    SrcBlendAlpha = ONE;
    DestBlendAlpha = ZERO;
    BlendOpAlpha = ADD;
    RenderTargetWriteMask[ 0 ] = 0x0F;
};

これでうまい事足し引き演算が行えます。もう一つ大切な設定、深度ステンシルステートで深度テストを切らないと理屈通りにうまくいきません:

深度ステンシルステート(Direct3D10)
DepthStencilState dss {
    DepthEnable = FALSE;
};

シェーダの設定は以上になります。後はランタイム側で1パス目にBackfaceDraw、2パス目にFrontfaceDrawを呼ぶだけでモデルを真上からみた切断面が描画されます(^-^)/



 シェーダ編のその1では、メッシュモデルを水平面で切るというマニアックなシェーダを作ってみました。中身が空っぽのメッシュをソリッドに見せるというのはちょっと斬新だったりします。これはうまく使えば効果的な演出にもなります。例えば、キャラクタがどこかから別世界にワープして来た時、足元から頭方向に徐々に出てくる…みたいな時に使えるかもしれません。マンガのGANTZ的な事も出来そうな感じがしますよね。
 この章のシェーダを用いたサンプルプログラムをアップしておりますので参考にしてみて下さい。