ホーム < ゲームつくろー! < プログラマブルシェーダ編

シェーダシステム編
その6 クラスのリコンストラクタでシェーダをサクサク作れるよう改良だ


 前章でディフューズとスペキュラマップを貼り付けた自動生成シェーダを実際に作って描画に成功しました。やった、パチパチ。

 ただ、実際に派生クラスを実装してみて様々な「気になる所」が出てきました。その多くは「派生クラスでやらなくてはならないことが多すぎる」という点に端を発します。派生クラスでシェーダをサクサク作りたいのですが、シェーダプログラム意外に色々な仮想メソッドをオーバーライドするのがとにかく面倒という印象でした。

 そこでこの章では先に作成したシェーダ作成クラスであるShaderCreatorクラスをリコンストラクタ(再構築)する事で、派生クラスで継承すべき仮想メソッドをぐっと減らしてみます。細かく色々とあるためすべては説明しきれませんが、最終的には継承メソッドが7つから3つに減り、非常に扱いやすくなります。…ま、まぁ、ある意味眠くなる章でもありますね。リコンストラクタの過程を見てみたい御暇な方はどうぞです。



@ 何が一体面倒を引き起こしているのか?

 先のサンプル実装を見ながら、少しずつ面倒を無くしていきます。

○ テクスチャの名前はセマンティクスで良い気がします

 乱雑さの多くは初期化部分(宣言時初期化)にあるように感じました。その一つとしてテクスチャ変数(GlobalTextureValクラス)の名前定義を見てみます。宣言時初期化はこんな感じでした:

DSShaderCreator::DSShaderCreator() :
    diffuseTexture("diffuseTexture", "DIFFUSETEXTURE"),
    specularTexture("specularTexture", "SPECULARTEXTURE"),
...

 メンバ変数diffuseTexture、でその変数が保持するシェーダ内のテクスチャ名が"diffuseTexutre"、んでそのセマンティクスがDIFFUSETEXTURE…。明らかに冗長ですよね。これを少しシュリンクしましょう。

 メンバ変数としてdiffuseTextureという宣言は必要です。またセマンティクス「DIFFUSETEXTURE」はプログラム上でテクスチャをアタッチする時に必要になります。セマンティクスは重複がゆるされないし、シェーダが必要とするテクスチャを判別するのにも使える便利なものです。そこで、テクスチャ名はセマンティクス名と同じ(一応全部小文字にして区別)にすることにしました。

 文字列を小文字に変換する方法は色々ありますが、宣言時に小文字化するため次のような簡易な関数を作りました:

namespace {
    // 文字列を小文字変換
    std::string toLowerStr(std::string &src) {
        std::string res = src;
        for (size_t i = 0; i < res.size(); i++)
            res[i] = (char)tolower(res[i]);
        return res;
    }
}

namespace Dix {
    GlobalTextureVal::GlobalTextureVal(std::string semanticsName) :
        GlobalVal("texture", toLowerStr(semanticsName).c_str(), "", semanticsName)
    {}
}

 これにより、テクスチャの宣言時初期化は、

DSShaderCreator::DSShaderCreator() :
    diffuseTexture("DIFFUSETEXTURE"),
    specularTexture("SPECULARTEXTURE"),
...

とセマンティクス名だけで良くなってしまいます。一つ楽になりました(^-^)



○ サンプラの初期化時に対応テクスチャとかサンプリング関数を渡してしまおう

 続いてサンプラ(GlobalSamplerValクラス)です。このメンバ変数の宣言時初期化では現在サンプラ名だけを指定しています。しかし、サンプラは宣言される時に自分がサンプリングするテクスチャを内部に持ってしまうものです。ですから宣言時初期化時にもうテクスチャを渡しちゃえば後で面倒が無くなります:

GlobalSamplVal.h
class GlobalSamplerVal : public GlobalVal {

    GlobalTextureVal texture;   // サンプル対象テクスチャ
    SamplerFunc samplerFunc;    // サンプリング関数

    // コンストラクタ
    GlobalSamplerVal(std::string valName, GlobalTextureVal texture, D3DXVECTOR4 &initVal) :
    GlobalVal("sampler", valName, ""), texture(texture), samplerFunc(std::string("get")+texture.n(), initVal, valName)
    {}
    ....

 要はですね、「テクスチャから色を貰う」という作業を担う人をサンプラクラスに集約したわけです。今までは「テクスチャ」「サンプラ」「サンプリング関数(SamplerFunc)」と3つがバラバラだったのをサンプラだけにしてしまったのが上の改良です。実質サンプラ名(valName)、テクスチャオブジェクト(texture)、テクスチャが無かった時の代替初期化カラー(initVal)だけですべてが賄えます。

 この仕様変更に伴ない、ピクセルシェーダ内でのサンプリング関数の呼び出しは下のようにサンプラを直接指定する形式となります:

// ピクセルシェーダコード
void DSShaderCreator::pixelShaderCode( std::string &out, PixelInputSemanticsStruct &In, PixelOutputSemanticsStruct &Out ) {

    ....
    // ディフューズカラー算出
    "\tfloat4 diffuseColor = abs(dot( -normalize($(In.tex1).xyz), normalize($(In.tex2).xyz) )) * $(diffuseSampler)($(In.tex0).xy);\n"
    ....

まぁ、何となくすっきりしているかなと思います(^-^;



○ コンバータへの登録とグローバル変数の書き込みの冗長性を排除しよう

 ShaderCreatorクラスは、シェーダプログラムを上のように可読性よく書きやすくするために、内部に「シェーダ文コンバータ」を持っています。このコンバータが変数やサンプラを元シェーダ文から識別できるようにするため、コンバータに派生ShaderCreatorクラス内で宣言した各種メンバを登録する作業がどうしても必要になります。そしてもう一つ、シェーダのグローバル変数を宣言する部分では、メンバ変数が持っているdeclメソッドを逐一呼び出す必要がありました。実装コードを御覧下さい:

// 派生クラスの変数を登録
void DSShaderCreator::registUserVals() {
    conv.regist(diffuseTexture);
    conv.regist(specularTexture);
    conv.regist(diffuseSampler);
    conv.regist(specularSampler);
    conv.regist(lightDir);
    conv.regist(worldMatrix);
    conv.regist(viewMatrix);
    conv.regist(projMatrix);
}

// グローバル変数の書き出し
void DSShaderCreator::globalValCode( std::string &out ) {
    out += diffuseTexture.decl();
    out += specularTexture.decl();
    out += diffuseSampler.decl();
    out += specularSampler.decl();
    out += lightDir.decl();
    out += worldMatrix.decl();
    out += viewMatrix.decl();
    out += projMatrix.decl();
}

いやっはっは、笑えるほど同じような羅列を書いていますね。これは何とかしたい所です。グローバル変数はすべてGlobalValクラスを継承していて、派生クラスはdeclメソッドを実装するように強要されています。つまりGlobalValな配列に登録してしまえば上のように具体的に変数.decl()とする必要はありません。そして、そういう登録をしているのは、あらそうですよ、conv.regist()ではないですか(笑)。

 よって、グローバル変数の書き出しはコンバートクラスに担ってもらいましょう。つまり:

// 派生クラスの変数を登録
void DSShaderCreator::registUserVals() {
    conv.regist(diffuseTexture);
    conv.regist(specularTexture);
    conv.regist(diffuseSampler);
    conv.regist(specularSampler);
    conv.regist(lightDir);
    conv.regist(worldMatrix);
    conv.regist(viewMatrix);
    conv.regist(projMatrix);
}

// グローバル変数の書き出し
void DSShaderCreator::globalValCode( std::string &out ) {
    conv.globalValCode(out);
}

のような呼び出しでシェーダ文を書き出してもらうように改良します。こうする事により、元々仮想メソッドだったグローバル変数の書き出しはShaderCreator親クラスの固定作業となり、派生クラスでわざわざ上書きする手間がごっそりと省ける事になりました。もう、globalValCodeメソッドは忘れて良い存在となりました(^-^)。でもregistUserValsメソッドは、残念ながら必要です。



○ テクスチャサンプリング関数の置き換えを行うsamplerFunctionsCodeメソッドもコンバータの仕事に

 次です。テクスチャサンプリング関数の置換えを行うsamplerFunctionsCodeメソッドも冗長です。ちょっと御覧下さい:

// テクスチャサンプリング関数実装部
void DSShaderCreator::samplerFunctionsCode( std::string &out, TextureInfo &texInfo ) {
    // ディフューズテクスチャの置き換え関数作成
    out += samplerFuncOut(texInfo, diffuseSampler);

    // スペキュラテクスチャの置き換え関数作成
    out += samplerFuncOut(texInfo, specularSampler);
}

これ、ShaderCreator::sanplerFuncOutというヘルパーメソッドを呼び出して置き換え関数の書き出しを簡略化しているのですが、それでも上のコードでは2回呼び出ししています。しかも2行のコードで異なっているのはサンプラ変数だけです。しかも、このサンプラはすでにregistUserValsメソッド内でコンバータに登録してあります。と言うことは、この呼び出しもコンバータクラスに委譲できてしまいます:

// テクスチャサンプリング関数実装部
void DSShaderCreator::samplerFunctionsCode( std::string &out, TextureInfo &texInfo ) {
    conv.samplerFunctionsCode(out, texInfo);
}

こうすれば、先ほどと同じように処理をShaderCreator親クラスで担えますので、派生クラスで上書き実装する必要が無くなります。まぁびっくり、このメソッドももはや忘れて良いメソッドになってしまいました(^-^)g



○ 頂点シェーダ入力セマンティクスの欠落時初期化はコンストラクタで設定しちゃおう

 まだまだ改良点は尽きません。ガンガン行きます。
 シェーダを適用する予定のモデルが、シェーダが要求する頂点シェーダの入力セマンティクスを持っていなかった場合、この自動生成システムでは代わりに初期値を与えて対処しています。現在これはmissingVertexSemanticsInitializeCodeメソッド内で次のように書き出していました:

// 頂点シェーダの欠落セマンティクスの初期化コード挿入
void DSShaderCreator::missingVertexSemanticsInitializeCode( std::string &out, SemanticsInfo &modelSemInfo, VertexInputSemanticsStruct &In ) {
    // 座標欠落
    if ( !modelSemInfo.find(SemanticsInfo::POSITION) )
        out += "\t$(In.pos) = float4(0.0f, 0.0f, 0.0f, 0.0f);\n";

    // UV欠落
    if ( !modelSemInfo.find(SemanticsInfo::TEXCOORD) )
        out += "\t$(In.uv) = float2(0.0f, 0.0f);\n";

    // 法線欠落
    if ( !modelSemInfo.find(SemanticsInfo::NORMAL) )
        out += "\t$(In.normal) = float3(0.0f, 0.0f, -1.0f);\n";

    out += "\n";
}

 やっぱりこれも似たようなコードを書きますね。こういう重複はなるべく避けるのが得策です。上の初期値は各セマンティクス毎に与えるもので、その入力セマンティクスはVertexInputSemanticsStructクラスで扱っていました。このクラスは派生ShaderCreatorクラスのコンストラクタで初期化しています。せっかくですからその初期化時に上の初期化値も書き込むようメソッドを追加してしまいましょう。するとコンストラクタでこんな風に書けるようになります:

ShaderCreator派生クラス(DSShaderCreator)のコンストラクタ
DSShaderCreator::DSShaderCreator() :
....
{
    // セマンティクス構造体の初期化
    conv.getVISem() = VertexInputSemanticsStruct("VS_INPUT",
                        SemanticsInfo::POSITION |
                        SemanticsInfo::TEXCOORD |
                        SemanticsInfo::NORMAL
                     );
    conv.getVISem().setInitialVal(SemanticsInfo::POSITION, D3DXVECTOR4(0.0f, 0.0f, 0.0f, 0.0f));
    conv.getVISem().setInitialVal(SemanticsInfo::TEXCOORD, D3DXVECTOR2(0.0f, 0.0f));
    conv.getVISem().setInitialVal(SemanticsInfo::NORMAL, D3DXVECTOR3(0.0f, 0.0f, -1.0f));

後はコンバータさんに欠落セマンティクスの初期化コードを次のように書き出してもらうだけです:

// 頂点シェーダの欠落セマンティクスの初期化コード挿入
void ShaderCreator::missingVertexSemanticsInitializeCode( std::string &out, SemanticsInfo &modelSemInfo, VertexInputSemanticsStruct &In ) {
    In.missingVertexSemanticsInitializeCode(out, modelSemInfo);
    out += "\n";
}

という事でこのメソッドももう気にする必要のないメソッドになってしまいました。もう忘れて良いメソッドが沢山できました。これがリコンストラクタ(リファクタリング)の良さですね。



A 必要なメソッドは3つだけ!

 以上でリコンストラクタ作業は一応終わりです。上を実現するために実際はあちこち色々変更するのですが、それは流石に冗長な説明になるので割愛します。リコンストラクタの結果、派生クラスで継承する必要のある仮想メソッドはたったの3つになってしまいました:

#ifndef IKD_DIX_DSDHADERCREATOR_H
#define IKD_DIX_DSDHADERCREATOR_H

#include "ShaderCreator.h"

namespace Dix {
    class DSShaderCreator : public ShaderCreator {
        GlobalTextureVal diffuseTexture; // ディフューズテクスチャ
        GlobalTextureVal specularTexture; // スペキュラテクスチャ
        GlobalSamplerVal diffuseSampler; // ディフューズサンプラ
        GlobalSamplerVal specularSampler; // スペキュラサンプラ
        GlobalVal lightDir; // ライト方向
        GlobalVal worldMatrix, viewMatrix, projMatrix; // WVP行列

    public:
        DSShaderCreator();
        virtual ~DSShaderCreator();

        // グローバル変数を登録
        virtual void registUserVals();

        // 頂点シェーダコード
        virtual void vertexShaderCode( std::string &out, VertexInputSemanticsStruct &In, VertexOutputSemanticsStruct &Out );

        // ピクセルシェーダコード
        virtual void pixelShaderCode( std::string &out, PixelInputSemanticsStruct &In, PixelOutputSemanticsStruct &Out );
    };
}

グローバル変数をコンバータに登録するregistUserVals、頂点シェーダのコードを記述するvertexShaderCode、そしてピクセルシェーダのコードを書くpixelShaderCode。この3つとコンストラクタでの各メンバを形式的に初期化するだけで柔軟なシェーダコードを作成してくれるようになりました。前章の実装に比べて非常にシンプルに生まれ変わりました〜


 今回のリコンストラクトしたShaderCreatorを用いたサンプルを公開します。内容は前章と同じですが、DSShaderCreatorクラスの宣言や実装が遥かに簡素になっています。よ〜し、使える奴に少しずつなってきました。