ドリコムでゲーム開発基盤チームのリーダーをやらせてもらっている武田豊と言います。
少し前にリリースされました、ダービースタリオン マスターズ、遊んでみた方もいらっしゃるかもしれません。(遊んでくれた方、ありがとうございます)

こちらは弊社で開発しているゲーム開発基盤 with Cocos2d-xで作られています。
3Dゲームを作る上で選択肢としてはUnityが挙がるとは思いますが、軽い動作でサクサク遊ばせるというコンセプトがあり、凝った3D表現はやらない方針でした。
また、弊社ではCocos2d-xでの開発経験が多く、開発期間が短かったため導入コストが少ないCocos2d-xを選択しました。

ダービースタリオン マスターズも無事リリースされましたので、その開発過程で得たCocos2d-xでの3D周りの情報を展開させて頂きます。
内容としては、Cocos2d-xの使用経験者を想定した内容となります。
Cocos2d-xの入門者向け情報は色々あると思うので、そちらを参考にして下さい。

『ダービースタリオン マスターズ(ダビマス)』公式サイト
©2016ParityBit

Cocos2d-xで3Dってやれんの?

基本的な3DのAPIはそろっています。
モデルデータのコンバート、表示、アニメーション、カメラ、シェーダー、一通り基本のAPIがあります。
足りない機能などは、 追加して対応しました。
アニメーションの機能はだいぶ足りない印象です。
私の方では、3Dモデル表示の不具合の対応をやりました。Cocos2d-xへのpull requestも出しましたが、テストデータやらテスト方法の提供が難しく、全ては取り込まれていません。

バグ修正の参考として、該当するpull requestのリンクを張っておきます。

  • Sprite3D。モデルデータによっては、Sprite3Dの中に子供のSprite3Dが出来る。この際、子供のSprite3Dの位置がズレてしまうのを修正。
    https://github.com/cocos2d/cocos2d-x/pull/15012
  • node curve animationのロジックに入った際、子供のnodeを全てsetAdditionalTransformで処理をすると、Sprite3Dのトランスフォームを行った場合、子供の位置がズレてしまう。
    https://github.com/cocos2d/cocos2d-x/pull/15045

Cocos2d-xへpull requestを送って、その修正内容を取り込んでもらう手間は、結構かかりますが、取り込まれたときの達成感は良かったですね。

3Dモデル表示でカスタムシェーダーをMaterialシステムを使ってやってみる

Materialシステムという機能があります。
materialファイルを読み込んで、シェーダーやuniformのパラメーター、レンダーステートをSprite3Dへ渡すものです。
Sprite3Dへカスタムシェーダーを設定する場合、Spriteと同様にGLProgramStateを生成してやる方法もとれますが、今回はMaterialシステムを使いました。
メリットとしては、シェーダーとそれに渡すパラメーター(uniformの値)を外部ファイル化出来、調整が楽なためです。

cocosのマニュアルのページ
http://www.cocos2d-x.org/docs/programmers-guide/advanced_topics/index.html#what-is-a-material

Cocos2d-xのサンプル、cppTestにMaterial Systemでありますので参考にして下さい。
こちらを使用して、カスタムしたシェーダーをSprite3Dへ適応させます。

Material *mat = Material::createWithFilename("Materials/3d_fog.material");
sprite->setMaterial(mat);

materialファイルの内容はこんな感じです。

material model_common
{
    technique fog
    {
        pass 0
        {
            shader
            {
                vertexShader = shaders/3D_PositionTex_fog.vert
                fragmentShader = shaders/3D_ColorTex_fog.frag
                // Uniforms
                u_fogStart = 100
                u_fogEnd = 1000
                u_fogColor = 0.8, 0.8, 0.8, 1.0
                u_fogMaxRate = 0.30
                // auto bindings
                u_eyePosition = FOG_EYE_POSITION
            }
        }
    }
}

uniformの値が固定値の場合はmaterialファイルへ値を設定すればOKです。
動的にuniformの値を設定したい場合(距離フォグをする際のカメラの位置の設定とか etc)は、いくつか方法があるとは思いますが、私はAutoBindingResolverという機能を使いuniformへ値を設定しています。

例です。
materialファイルのuniformの右辺の名前がFOG_EYE_POSITIONの際にカメラの位置をuniformに設定しています。
AutoBindingResolverクラスを生成すれば、uniformの値を設定する関数 resolveAutoBindingがコールされます。

class FogAutoBindingResolver : public GLProgramState::AutoBindingResolver
{
public:
    virtual ~FogAutoBindingResolver();
private:
    bool resolveAutoBinding(GLProgramState* glProgramState, Node* node, const std::string& uniform, const std::string& autoBinding);

    void callbackEyePosition(GLProgram* glProgram, Uniform* uniform);
};

FogAutoBindingResolver::~FogAutoBindingResolver()
{
}

bool FogAutoBindingResolver::resolveAutoBinding(GLProgramState* glProgramState, Node* node, const std::string& uniform, const std::string& autoBinding)
{
    if (autoBinding.compare("FOG_EYE_POSITION")==0)
    {
        glProgramState->setUniformCallback(uniform, CC_CALLBACK_2(FogAutoBindingResolver::callbackEyePosition, this));
        return true;
    }
    return false;
}

void FogAutoBindingResolver::callbackEyePosition(GLProgram* glProgram, Uniform* uniform)
{
    auto pCamera = cocos2d::Camera::getVisitingCamera();
    cocos2d::Vec3 eyePosition = cocos2d::Vec3::ZERO;
    if (pCamera)
    {
        eyePosition = pCamera->getPosition3D();
    }
    glProgram->setUniformLocationWith3f(uniform->location, eyePosition.x, eyePosition.y, eyePosition.z);
}

このようにして、シェーダーとそれに必要なパラメーターの受け渡しをやりました。

テストで作ったシェーダーとしては、

  • 距離フォグ
  • マルチuv、マルチテクスチャ

です。

法線マップもこの手法で大丈夫なのですが、期間が足りずテスト出来ませんでした。
結果として、カスタムシェーダーを作成しそれをSprite3Dに適応させレンダリングは、シンプルに実装出来たと思います。
上記のMaterialの機能はまだ一部です。みなさんも色々やってみて下さい。

注意点

Sprite3DのsetMaterial関数ですが、自分自身のMeshだけmaterialの適応をします。
モデルデータによっては、Sprite3Dの子供にSprite3Dが生成されます。
こういった場合は、子供のSprite3Dに対してmaterialが適応されないです。
自分でSprite3Dの子供のノードをチェックして、全てのSprite3Dに対してsetMaterialを行わないといけません。

ポストエフェクト

3Dのレンダリングが終了した後、その結果のフレームバッファを取得し、それに対してポストエフェクトをかけてみました。

以下のようなソースコードです。

bool PostEffect::createPostEffect()
{
    auto size = cocos2d::Director::getInstance()->getWinSizeInPixels();
    auto fboSize = cocos2d::Size(size.width, size.height);
    auto fbo = cocos2d::experimental::FrameBuffer::create(1, fboSize.width, fboSize.height);
    auto rt = cocos2d::experimental::RenderTarget::create(fboSize.width, fboSize.height, cocos2d::Texture2D::PixelFormat::RGB888);
    auto rtDS = cocos2d::experimental::RenderTargetDepthStencil::create(fboSize.width, fboSize.height);
    auto properties = cocos2d::Properties::createNonRefCounted("shaders/posteffect.material#posteffect");
    if((fbo == nullptr) || (rt == nullptr) || (rtDS == nullptr) || (properties == nullptr))
    {
        CC_SAFE_DELETE(properties);
        return false;
    }
    auto material = cocos2d::Material::createWithProperties(properties);
    if (material == nullptr)
    {
        CC_SAFE_DELETE(properties);
        return false;
    }

    fbo->attachRenderTarget(rt);
    fbo->attachDepthStencilTarget(rtDS);

    // ポストエフェクトエフェクトを行うスプライト生成
    auto sprite = cocos2d::Sprite::createWithTexture(fbo->getRenderTarget()->getTexture());    // ダウンサンプリングをする場合はここをPostEffectSpriteに変更
    if (sprite == nullptr)
    {
        CC_SAFE_DELETE(properties);
        return false;
    }
    sprite->setPosition(size.width/2, size.height/2);

    // ポストエフェクト用シェーダーの設定
    // ここではブルームのシェーダーを使った
    auto state = material->getTechniqueByName("bloom")->getPassByIndex(0)->getGLProgramState();
    if (state != nullptr)
    {
        sprite->setGLProgramState(state);    // ダウンサンプリングする場合はここを setPostEffectGLProgramState に変更
    }

    fbo->retain();
    sprite->retain();
    m_pFrameBuffer = fbo;
    m_pRenderTarget = rt;
    m_pRenderTargetDepthStencil = rtDS;
    m_pPostEffect = sprite;
    CC_SAFE_DELETE(properties);
    return true;
}

bool PostEffect::attachCamera(cocos2d::Camera* camera)
{
    camera->setFrameBufferObject(m_pFrameBuffer);
}

処理の流れは

  1. cocos2d::experimental::FrameBuffer::createで フレームバッファ生成
  2. cocos2d::experimental::RenderTarget::createで レンダーターゲットを生成
  3. cocos2d::experimental::RenderTargetDepthStencil::createで デプスステンシルを生成
  4. フレームバッファへ、レンダーターゲットとデプスステンシルをセット
  5. フレームバッファのテクスチャを使用したスプライトを生成し、それにポストエフェクトのシェーダーをセット
  6. カメラへフレームバッファをセット

です。

コード内の、m_pPostEffect(sprite)を描画するようにすればOKです。
(普通にaddChild(m_pPostEffect)を適宜行えば良い)

これでポストエフェクトは可能なのですが、ポストエフェクトの処理をするテクスチャが大きく、フラグメントシェーダのオーバーヘッドが大きくなります。
そのため、ダウンサンプリングを行うように改良しました。

次のようなものです。

/*
    ポストエフェクト用スプライトクラス
*/
class PostEffectSprite : public cocos2d::Sprite
{
public:
    static PostEffectSprite* createPostEffectSprite(cocos2d::experimental::FrameBuffer* fbo);
public:
    ~PostEffectSprite();
    bool init(cocos2d::experimental::FrameBuffer* fbo);
    bool setPostEffectGLProgramState(cocos2d::GLProgramState* state);
    virtual void draw(cocos2d::Renderer *renderer, const cocos2d::Mat4 &transform, uint32_t flags) override;
private:
    cocos2d::experimental::FrameBuffer* m_pFrameBuffer = nullptr;
    cocos2d::Node*        m_pFrameBufferSprite = nullptr;
    cocos2d::RenderTexture* m_pRenderTexture = nullptr;
    cocos2d::Size           m_reduceTextureSize;
};

PostEffectSprite::~PostEffectSprite()
{
    m_pFrameBuffer = nullptr;
    CC_SAFE_RELEASE_NULL(m_pFrameBufferSprite);
    CC_SAFE_RELEASE_NULL(m_pRenderTexture);
}

PostEffectSprite* PostEffectSprite::createPostEffectSprite(cocos2d::experimental::FrameBuffer* fbo)
{
    if (fbo == nullptr)
    {
        return nullptr;
    }
    PostEffectSprite* sprite = new PostEffectSprite();
    if ((sprite != nullptr) && (sprite->init(fbo)))
    {
        sprite->autorelease();
        return sprite;
    }
    CC_SAFE_DELETE(sprite);
    return nullptr;
}

bool PostEffectSprite::init(cocos2d::experimental::FrameBuffer* fbo)
{
    if (fbo == nullptr)
    {
        return false;
    }
    auto state = cocos2d::GLProgramState::getOrCreateWithShaders("shaders/posteffect_common.vert", "shaders/posteffect_result.frag", "");
    if (state == nullptr)
    {
        return false;
    }
    m_pFrameBuffer = fbo;
    cocos2d::Size size(m_pFrameBuffer->getWidth(), m_pFrameBuffer->getHeight());
    // ダウンサンプリングするサイズ
    m_reduceTextureSize.width = 256.0f;
    m_reduceTextureSize.height = 512.0f;
    // フレームバッファの初期化
    m_pFrameBufferSprite = cocos2d::Sprite::createWithTexture(m_pFrameBuffer->getRenderTarget()->getTexture());
    m_pFrameBufferSprite->setAnchorPoint(cocos2d::Vec2(0.0f, 0.0f));
    m_pFrameBufferSprite->setPosition(0.0f, 0.0f);
    m_pFrameBufferSprite->retain();
    // レンダーテクスチャの初期化
    m_pRenderTexture = cocos2d::RenderTexture::create(m_reduceTextureSize.width, m_reduceTextureSize.height, cocos2d::Texture2D::PixelFormat::RGB888);
    m_pRenderTexture->setPosition(m_reduceTextureSize.width*0.5f, m_reduceTextureSize.height*0.5f);
    m_pRenderTexture->setKeepMatrix(true);
    cocos2d::Rect rectViewport;
    rectViewport.origin = cocos2d::Vec2::ZERO;
    rectViewport.size = m_reduceTextureSize;
    m_pRenderTexture->setVirtualViewport(cocos2d::Vec2(0.0f, 0.0f), rectViewport, rectViewport);
    m_pRenderTexture->retain();
    // スプライトの初期化
    initWithTexture(m_pFrameBuffer->getRenderTarget()->getTexture());
    setGLProgramState(state);
    state->setUniformTexture("postEffectResultTexture", m_pRenderTexture->getSprite()->getTexture());
    // テクスチャパラメーターセット
    const cocos2d::Texture2D::TexParams param = {GL_LINEAR, GL_LINEAR, GL_CLAMP_TO_EDGE, GL_CLAMP_TO_EDGE};
    m_pRenderTexture->getSprite()->getTexture()->setTexParameters(param);
    return true;
}

/*
    ポストエフェクト用シェーダー設定
*/
bool PostEffectSprite::setPostEffectGLProgramState(cocos2d::GLProgramState* state)
{
    if (m_pFrameBufferSprite == nullptr)
    {
        return false;
    }
    m_pFrameBufferSprite->setGLProgramState(state);
    return true;
}

void PostEffectSprite::draw(cocos2d::Renderer *renderer, const cocos2d::Mat4 &transform, uint32_t flags)
{
    if (m_pFrameBuffer == nullptr)
    {
        return;
    }
    // 縮小バッファに対してフレームバッファを描画
    // ポストエフェクトのシェーダーを行う
    m_pRenderTexture->begin();
    m_pFrameBufferSprite->visit();
    m_pRenderTexture->end();

    // ポストエフェクト前の画像とポストエフェクト後の画像の合成を行う
    cocos2d::Sprite::draw(renderer, transform, flags);
}

処理が複雑になったので、ポストエフェクトをダウンサンプリングし描画するSpriteクラスを作りました。
PostEffect::createPostEffect関数内でポストエフェクトを処理するSpriteを生成していますが、それをPostEffectSpriteへ変更しています。
また、シェーダーをセットする関数もそれに合わせて変更しています。

PostEffectSprite::initの処理の流れは

  1. ポストエフェクト前とポストエフェクト後の画像を合成するシェーダー生成
  2. フレームバッファをテクスチャとしたSpriteを生成(m_pFrameBufferSprite)
    このSpriteでポストエフェクトの処理をします。
  3. ダウンサンプリングするレンダーテクスチャーを生成。
    今回は 256×512。 (m_pRenderTexture)
    ポストエフェクト後の画像です。
  4. レンダーテクスチャーのVirtualViewportを設定
  5. 自分自身のテクスチャーを初期化。ポストエフェクト前のレンダリング結果と、ダウンサンプリングしたテクスチャーをセット。
    自分自身のSpriteクラスでは、ポストエフェクト前の画像とポストエフェクト後の画像の合成をします。

PostEffectSprite::drawの処理の流れは

  1. ダウンサンプリングされたレンダーテクスチャーに対してポストエフェクトをするSpriteを描画
  2. 自分自身のSpriteの描画でポストエフェクトエフェクト前の画像とポストエフェクト後の画像を合成
    最終結果の画像を作る

このような形で実装しました。
他の実装方法もあるとは思いますが、ひとつのやり方として参考にして貰えたら幸いです。

まとめ

Cocos2d-xで3D表現がどこまで出来るか?というと、基本的な機能、モデル表示、アニメーション、基本的なシェーダーはそろっています。
そこからカスタマイズして、ある程度リッチな表現も可能だと今回やってみて思いました。
カスタムシェーダーを使用してマルチテクスチャーでの表現までは行けます。
その先、マルチレンダーターゲットを使った表現は、まだ一工夫必要なのと、スマートフォン自体マルチレンダーターゲットが苦手で遅い点があり、辛い印象があります。

ポストエフェクトに関しても、軽めのシェーダーであれば大丈夫なのですが、重めのフラグメントシェーダだと辛い印象です。
またマルチレンダーターゲットが辛いため、出来ることも限られています。

OpenGLES2.0向けでの3Dゲームであれば、Cocos2d-xでも十分実用に耐えるものであると、私は考えています。
やはりソースコードが全て見えて動作が把握出来、カスタマイズやデバッグも出来るというのは、とても良い利点です。